Перейти к содержимому

Лекция 6. Протоколы, утиная типизация и композиция vs наследование

1. Введение

На предыдущих лекциях мы рассматривали наследование и абстрактные базовые классы (ABC) как основной способ задавать «общий интерфейс» для группы объектов. Сегодня мы посмотрим на эту тему под другим углом и разберём, как Python работает с типами на самом деле.

Python — язык с динамической типизацией, и его подход к полиморфизму отличается от того, что принято в строго типизированных языках вроде Java или C#. Здесь объект «подходит» под нужный интерфейс не потому, что он унаследован от правильного класса, а потому, что у него есть нужные методы. Этот подход называется утиной типизацией.

Параллельно мы обсудим один из самых практичных принципов проектирования — «предпочитай композицию наследованию». Наследование — мощный инструмент, но он часто используется не по назначению и создаёт жёсткие, плохо сопровождаемые иерархии. Композиция в большинстве случаев даёт более гибкое решение.

План лекции:

  1. Утиная типизация и философия «если крякает как утка».
  2. Структурная типизация через typing.Protocol.
  3. Отличие структурной типизации от номинальной (ABC).
  4. Композиция против наследования и приём делегирования.
  5. Практика рефакторинга наследования в композицию.

2. Утиная типизация (duck typing)

2.1. «Если оно крякает как утка…»

Классический афоризм, давший название подходу, звучит так:

«Если что-то ходит как утка и крякает как утка, то это и есть утка».

Применительно к программированию это означает: нам не важно, какого типа объект и от какого класса он унаследован. Важно только, поддерживает ли он нужные операции — то есть имеет ли он нужные методы и атрибуты. Если у объекта есть метод quack(), мы можем обращаться с ним как с уткой, даже если формально это не утка.

class Duck:
def quack(self) -> str:
return "Кря-кря"
class Person:
def quack(self) -> str:
return "Человек изображает утку: кря!"
def make_it_quack(entity) -> None:
# Нас не интересует тип entity.
# Важно лишь, что у него есть метод quack().
print(entity.quack())
make_it_quack(Duck()) # Кря-кря
make_it_quack(Person()) # Человек изображает утку: кря!

Функция make_it_quack не проверяет тип аргумента и не требует наследования от какого-либо базового класса. Она просто вызывает quack(). Если метод есть — всё работает; если нет — будет ошибка AttributeError во время выполнения.

2.2. Утиная типизация в стандартной библиотеке

Этот принцип пронизывает весь Python. Многие встроенные конструкции опираются не на конкретный тип, а на наличие нужных «дандер»-методов.

# Любой объект с __iter__ можно перебирать в цикле for
class Countdown:
def __init__(self, start: int):
self.start = start
def __iter__(self):
n = self.start
while n > 0:
yield n
n -= 1
for x in Countdown(3):
print(x) # 3, 2, 1
# Любой объект с __len__ работает с функцией len()
class Box:
def __init__(self, items):
self.items = items
def __len__(self) -> int:
return len(self.items)
print(len(Box([1, 2, 3]))) # 3

for-цикл не спрашивает, является ли Countdown подклассом какого-то «итерируемого» класса. Он просто пытается вызвать __iter__. Это и есть утиная типизация на уровне самого языка.

2.3. Преимущества и риски

Преимущества:

  • Гибкость. Код работает с любыми объектами подходящей «формы», в том числе с теми, о которых автор функции не знал.
  • Слабая связанность. Не нужно навязывать пользователям общий базовый класс.

Риски:

  • Ошибки только во время выполнения. Если у объекта нет нужного метода, программа упадёт уже в продакшене, а не на этапе компиляции.
  • Неявность контракта. По сигнатуре функции def make_it_quack(entity) невозможно понять, что от entity требуется метод quack().

Именно вторую проблему — неявность контракта — решают протоколы.


3. Протоколы и структурная типизация

3.1. Зачем нужны протоколы

Утиная типизация хороша гибкостью, но плоха тем, что требование к объекту нигде не записано. Мы хотим сохранить гибкость утиной типизации, но при этом явно задокументировать ожидаемый интерфейс и дать инструментам (статическим анализаторам вроде mypy) возможность проверять корректность кода ещё до запуска.

Для этого в модуле typing есть класс Protocol (доступен с Python 3.8).

from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "Рисую круг"
class Square:
def draw(self) -> str:
return "Рисую квадрат"
def render_shape(shape: Drawable) -> str:
return shape.draw()
for shape in (Circle(), Square()):
print(render_shape(shape))

Обратите внимание: Circle и Square не наследуются от Drawable. Они ничего не знают о его существовании. И всё же они считаются совместимыми с протоколом Drawable, потому что у них есть метод draw() с подходящей сигнатурой. Это называется структурной типизацией: соответствие типу определяется структурой объекта (набором его методов), а не его происхождением.

Тип-аннотация shape: Drawable теперь явно сообщает, что функция ожидает объект с методом draw(). Статический анализатор проверит это до запуска программы.

3.2. @runtime_checkable

По умолчанию протоколы существуют только для статической проверки типов. Если попытаться использовать isinstance(obj, Drawable) без дополнительного декоратора, Python выбросит TypeError. Чтобы разрешить проверку во время выполнения, протокол помечают декоратором @runtime_checkable.

from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "Рисую круг"
class Cat:
def meow(self) -> str:
return "Мяу"
print(isinstance(Circle(), Drawable)) # True — есть метод draw()
print(isinstance(Cat(), Drawable)) # False — метода draw() нет

Важное ограничение: проверка isinstance для runtime-протокола смотрит только на наличие методов (по именам), но не проверяет их сигнатуры. То есть объект с методом draw(self, color, size) тоже пройдёт проверку, хотя вызвать его как draw() не получится. Полную проверку сигнатур выполняет только статический анализатор.

3.3. Протоколы с атрибутами

Протокол может описывать не только методы, но и ожидаемые атрибуты.

from typing import Protocol
class Named(Protocol):
name: str
def greet(self) -> str: ...
class User:
def __init__(self, name: str):
self.name = name
def greet(self) -> str:
return f"Привет, я {self.name}"
def welcome(obj: Named) -> str:
return obj.greet()
print(welcome(User("Анна"))) # Привет, я Анна

4. Структурная типизация против номинальной

Теперь сопоставим два подхода к интерфейсам: протоколы и абстрактные базовые классы (ABC), которые мы изучали ранее.

4.1. Номинальная типизация (ABC)

ABC реализуют номинальную типизацию: объект совместим с интерфейсом, только если его класс явно унаследован от соответствующего базового класса («по имени», по объявленному родству).

from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def speak(self) -> str: ...
class Dog(Animal): # явно объявляем наследование
def speak(self) -> str:
return "Гав"
print(isinstance(Dog(), Animal)) # True
# Класс с методом speak(), но БЕЗ наследования от Animal,
# не считается Animal:
class Robot:
def speak(self) -> str:
return "Бип"
print(isinstance(Robot(), Animal)) # False

Robot умеет speak(), но «не является» Animal, потому что не объявил это явно. Кроме того, ABC не даст создать экземпляр подкласса, в котором не реализованы все абстрактные методы — это удобный контроль на этапе создания объекта.

4.2. Сравнение подходов

КритерийABC (номинальная)Protocol (структурная)
Как достигается совместимостьЯвное наследованиеСовпадение по структуре
СвязанностьКласс зависит от базовогоКласс ничего не знает о протоколе
Сторонние классыНельзя «вписать» без обёрткиПодходят автоматически, если есть методы
Проверка при созданииЗапрещает неполные реализацииНе контролирует создание объектов
Основная проверкаВо время выполненияСтатически (mypy); опционально runtime
Можно делиться кодомДа, методы базового классаНет, протокол — только описание

4.3. Когда что выбирать

  • ABC уместен, когда вы проектируете собственную иерархию «с нуля», хотите поделиться общей реализацией между подклассами и/или гарантировать на этапе создания объекта, что все обязательные методы реализованы.
  • Protocol уместен, когда вы хотите описать ожидаемый интерфейс для чужих объектов, не навязывая им наследование, и сохранить гибкость утиной типизации, добавив при этом статическую проверку. Это особенно ценно при работе с кодом из сторонних библиотек, который вы не можете изменить.

Хорошее эмпирическое правило: протоколы описывают то, что объект умеет делать (поведение), а ABC чаще описывают то, чем объект является (его место в иерархии).


5. Композиция против наследования

5.1. Две формы повторного использования

Наследование и композиция — два основных способа переиспользовать код и выстраивать отношения между классами.

  • Наследование задаёт отношение «является» (is-a): Dog является Animal. Подкласс получает интерфейс и реализацию родителя.
  • Композиция задаёт отношение «имеет» (has-a): Car имеет Engine. Один объект хранит другой внутри себя и пользуется его возможностями.

5.2. Проблемы наследования

Наследование — сильная связь. У него есть известные недостатки:

  1. Жёсткость иерархии. Дерево классов фиксируется на этапе проектирования. Изменить отношения родства в работающей системе тяжело.
  2. Хрупкий базовый класс. Изменение родителя может неожиданно сломать всех потомков, потому что потомки зависят от деталей реализации родителя.
  3. Нарушение инкапсуляции. Подкласс часто видит внутренности родителя, что усиливает связанность.
  4. Комбинаторный взрыв. При попытке скрестить несколько независимых характеристик через наследование число классов растёт лавинообразно.

Рассмотрим четвёртую проблему на примере.

# Плохо: пытаемся выразить независимые характеристики наследованием.
class Animal: ...
class FlyingAnimal(Animal): ...
class SwimmingAnimal(Animal): ...
class FlyingSwimmingAnimal(Animal): ... # утка умеет и то, и другое
# А если добавить "умеет бегать"? Нужны ещё классы на все комбинации:
# FlyingRunning, SwimmingRunning, FlyingSwimmingRunning, ...

Каждая новая способность удваивает число возможных комбинаций. Иерархия становится неуправляемой.

5.3. Принцип «предпочитай композицию наследованию»

Один из самых известных советов по проектированию (восходит к книге «банды четырёх», GoF) гласит:

Предпочитай композицию объектов наследованию классов.

Композиция гибче, потому что:

  • Поведение собирается из независимых частей, которые можно комбинировать свободно.
  • Составные части можно подменять, в том числе во время выполнения.
  • Связанность слабее: объект зависит от интерфейса своей части, а не от её реализации.

Тот же пример с животными на композиции выглядит так:

from typing import Protocol
class MoveBehavior(Protocol):
def move(self) -> str: ...
class Fly:
def move(self) -> str:
return "летит"
class Swim:
def move(self) -> str:
return "плывёт"
class Run:
def move(self) -> str:
return "бежит"
class Animal:
def __init__(self, name: str, behaviors: list[MoveBehavior]):
self.name = name
self.behaviors = behaviors
def describe(self) -> str:
actions = ", ".join(b.move() for b in self.behaviors)
return f"{self.name}: {actions}"
duck = Animal("Утка", [Fly(), Swim()])
penguin = Animal("Пингвин", [Swim(), Run()])
print(duck.describe()) # Утка: летит, плывёт
print(penguin.describe()) # Пингвин: плывёт, бежит

Любая комбинация способностей собирается без новых классов. А поскольку способности описаны через протокол MoveBehavior, добавить новую (например, Jump) можно, не трогая существующий код.


6. Делегирование

6.1. Что такое делегирование

Делегирование — это техника, при которой объект перепоручает выполнение части работы внутреннему объекту. Это естественный спутник композиции: класс хранит ссылку на помощника и вызывает его методы.

class Engine:
def start(self) -> str:
return "Двигатель запущен"
def stop(self) -> str:
return "Двигатель остановлен"
class Car:
def __init__(self):
self._engine = Engine() # композиция: Car ИМЕЕТ Engine
def start(self) -> str:
# делегируем работу двигателю
return self._engine.start()
def stop(self) -> str:
return self._engine.stop()
car = Car()
print(car.start()) # Двигатель запущен

Car не наследуется от Engine (машина не «является» двигателем), а владеет им и обращается к нему. Внешний код работает с Car, не зная о существовании Engine. При желании двигатель можно заменить на электрический, не меняя интерфейс машины.

6.2. Автоматическое делегирование через __getattr__

Если нужно перенаправить во внутренний объект много методов, ручное переписывание каждого утомительно. Python позволяет автоматизировать это с помощью __getattr__, который вызывается, когда атрибут не найден обычным способом.

class Playlist:
def __init__(self):
self._tracks: list[str] = []
def __getattr__(self, name):
# Перенаправляем неизвестные атрибуты во внутренний список.
return getattr(self._tracks, name)
def __len__(self) -> int:
return len(self._tracks)
pl = Playlist()
pl.append("Song A") # делегируется списку через __getattr__
pl.append("Song B")
print(len(pl)) # 2
print(pl) # объект Playlist

Будьте осторожны: чрезмерное автоматическое делегирование делает интерфейс класса непредсказуемым. Часто лучше явно перечислить методы, которые вы действительно хотите предоставить.


7. Рефакторинг наследования в композицию

Покажем типичный сценарий улучшения дизайна на примере.

7.1. Исходный код на наследовании

Предположим, у нас есть класс хранилища, и нам понадобилась его версия с логированием. Соблазнительно решить это наследованием.

class Storage:
def __init__(self):
self._data: dict[str, str] = {}
def save(self, key: str, value: str) -> None:
self._data[key] = value
def load(self, key: str) -> str:
return self._data[key]
class LoggingStorage(Storage):
def save(self, key: str, value: str) -> None:
print(f"LOG: сохраняю {key}={value}")
super().save(key, value)
def load(self, key: str) -> str:
print(f"LOG: читаю {key}")
return super().load(key)

Проблемы такого подхода:

  • LoggingStorage жёстко привязан к конкретному классу Storage. Если появится FileStorage или RedisStorage, для каждого придётся писать свой логирующий подкласс.
  • Логирование и хранение — две независимые ответственности, смешанные в одной иерархии.

7.2. Версия на композиции

Выделим интерфейс хранилища в протокол и сделаем логирующую обёртку, которая оборачивает любое хранилище.

from typing import Protocol
class Storage(Protocol):
def save(self, key: str, value: str) -> None: ...
def load(self, key: str) -> str: ...
class MemoryStorage:
def __init__(self):
self._data: dict[str, str] = {}
def save(self, key: str, value: str) -> None:
self._data[key] = value
def load(self, key: str) -> str:
return self._data[key]
class LoggingStorage:
"""Обёртка, добавляющая логирование к любому Storage (паттерн Декоратор)."""
def __init__(self, wrapped: Storage):
self._wrapped = wrapped # композиция вместо наследования
def save(self, key: str, value: str) -> None:
print(f"LOG: сохраняю {key}={value}")
self._wrapped.save(key, value)
def load(self, key: str) -> str:
print(f"LOG: читаю {key}")
return self._wrapped.load(key)
# Логирование можно "надеть" на любое хранилище, удовлетворяющее протоколу.
storage: Storage = LoggingStorage(MemoryStorage())
storage.save("user", "Анна")
print(storage.load("user"))

Теперь LoggingStorage работает с любым объектом, удовлетворяющим протоколу Storage, — благодаря структурной типизации ему всё равно, какой именно класс внутри. Логирование стало независимым, переиспользуемым слоем. Это пример паттерна Декоратор, который естественно выражается через композицию.

7.3. Когда наследование всё-таки уместно

Композиция — разумное предпочтение по умолчанию, но наследование не зло. Используйте наследование, когда:

  • между классами действительно есть отношение «является» (is-a), а не «имеет»;
  • подкласс может подставляться вместо родителя без сюрпризов (принцип подстановки Барбары Лисков из набора SOLID);
  • вы хотите переиспользовать общую реализацию, а не только интерфейс.

Простая проверка: спросите себя — «B является A или B имеет A?». Если «имеет» — берите композицию.


8. Краткие итоги

  • Утиная типизация — подход Python, при котором важен не тип объекта, а наличие у него нужных методов: «если крякает как утка, то это утка».
  • Утиная типизация даёт гибкость, но делает контракты неявными и переносит ошибки на время выполнения.
  • typing.Protocol добавляет к утиной типизации явное описание интерфейса и статическую проверку. Это структурная типизация: совместимость определяется набором методов, а не наследованием.
  • @runtime_checkable разрешает isinstance-проверки протокола, но смотрит только на наличие методов, не на их сигнатуры.
  • ABC реализуют номинальную типизацию (совместимость через явное наследование) и позволяют делиться кодом; протоколы описывают поведение чужих объектов без навязывания родства.
  • Принцип «предпочитай композицию наследованию»: композиция (отношение «имеет») гибче жёстких иерархий наследования (отношение «является»).
  • Делегирование — перепоручение работы внутреннему объекту — основной приём реализации композиции.
  • Рефакторинг наследования в композицию (например, логирующая обёртка вместо подкласса) снижает связанность и повышает переиспользуемость.

9. Вопросы для самопроверки

  1. Сформулируйте принцип утиной типизации. Какие у него плюсы и какие риски?
  2. Чем структурная типизация отличается от номинальной? Приведите пример каждой в Python.
  3. Почему класс, удовлетворяющий протоколу, не обязан наследоваться от него?
  4. Что делает декоратор @runtime_checkable и какое у него ограничение?
  5. В каких случаях стоит выбрать ABC, а в каких — Protocol?
  6. Объясните разницу между отношениями «является» (is-a) и «имеет» (has-a).
  7. Назовите минимум три проблемы избыточного использования наследования.
  8. Что такое делегирование и как оно связано с композицией?
  9. Как с помощью __getattr__ организовать автоматическое делегирование и чем это опасно?
  10. Перепишите класс, добавляющий кэширование через наследование от хранилища, в вариант на композиции (обёртку).