Лекция 6. Протоколы, утиная типизация и композиция vs наследование
1. Введение
На предыдущих лекциях мы рассматривали наследование и абстрактные базовые классы (ABC) как основной способ задавать «общий интерфейс» для группы объектов. Сегодня мы посмотрим на эту тему под другим углом и разберём, как Python работает с типами на самом деле.
Python — язык с динамической типизацией, и его подход к полиморфизму отличается от того, что принято в строго типизированных языках вроде Java или C#. Здесь объект «подходит» под нужный интерфейс не потому, что он унаследован от правильного класса, а потому, что у него есть нужные методы. Этот подход называется утиной типизацией.
Параллельно мы обсудим один из самых практичных принципов проектирования — «предпочитай композицию наследованию». Наследование — мощный инструмент, но он часто используется не по назначению и создаёт жёсткие, плохо сопровождаемые иерархии. Композиция в большинстве случаев даёт более гибкое решение.
План лекции:
- Утиная типизация и философия «если крякает как утка».
- Структурная типизация через
typing.Protocol. - Отличие структурной типизации от номинальной (ABC).
- Композиция против наследования и приём делегирования.
- Практика рефакторинга наследования в композицию.
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__ можно перебирать в цикле forclass 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]))) # 3for-цикл не спрашивает, является ли 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_checkableclass 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)) # FalseRobot умеет 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. Проблемы наследования
Наследование — сильная связь. У него есть известные недостатки:
- Жёсткость иерархии. Дерево классов фиксируется на этапе проектирования. Изменить отношения родства в работающей системе тяжело.
- Хрупкий базовый класс. Изменение родителя может неожиданно сломать всех потомков, потому что потомки зависят от деталей реализации родителя.
- Нарушение инкапсуляции. Подкласс часто видит внутренности родителя, что усиливает связанность.
- Комбинаторный взрыв. При попытке скрестить несколько независимых характеристик через наследование число классов растёт лавинообразно.
Рассмотрим четвёртую проблему на примере.
# Плохо: пытаемся выразить независимые характеристики наследованием.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)) # 2print(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. Вопросы для самопроверки
- Сформулируйте принцип утиной типизации. Какие у него плюсы и какие риски?
- Чем структурная типизация отличается от номинальной? Приведите пример каждой в Python.
- Почему класс, удовлетворяющий протоколу, не обязан наследоваться от него?
- Что делает декоратор
@runtime_checkableи какое у него ограничение? - В каких случаях стоит выбрать ABC, а в каких —
Protocol? - Объясните разницу между отношениями «является» (is-a) и «имеет» (has-a).
- Назовите минимум три проблемы избыточного использования наследования.
- Что такое делегирование и как оно связано с композицией?
- Как с помощью
__getattr__организовать автоматическое делегирование и чем это опасно? - Перепишите класс, добавляющий кэширование через наследование от хранилища, в вариант на композиции (обёртку).