Абстрактные классы, утиная типизация и Protocol
Утиная типизация
If it walks like a duck and it quacks like a duck, then it must be a duck.
Python не спрашивает, какого класса объект. Ему важно, умеет ли объект то, что от него требуют:
class Duck:
def speak(self):
return "Кря"
class Robot: # не связан с Duck никаким наследованием
def speak(self):
return "Бип-буп"
def make_noise(thing):
return thing.speak() # нужен только метод speak()
for t in [Duck(), Robot()]:
print(make_noise(t))
Именно так работает весь Python: for принимает всё, у чего есть __iter__; len() — всё, у чего __len__; json.dump пишет в любой объект с методом write.
import io, json
class Collector: # «файлоподобный» объект: у него есть write()
def __init__(self):
self.parts = []
def write(self, s):
self.parts.append(s)
c = Collector()
json.dump({"a": [1, 2]}, c)
print(c.parts)
buf = io.StringIO() # стандартный «файл в памяти»
print("привет", file=buf)
print(repr(buf.getvalue()))
EAFP против LBYL
data = {"name": "Анна"}
# LBYL — Look Before You Leap
if "age" in data:
age = data["age"]
else:
age = None
# EAFP — Easier to Ask Forgiveness than Permission (питоничный стиль)
try:
age = data["age"]
except KeyError:
age = None
print(age)
EAFP короче, не дублирует проверку и избегает гонок (файл могли удалить между проверкой exists() и открытием).
Абстрактные базовые классы
Утиная типизация гибкая, но ошибки «забыл реализовать метод» всплывают только при вызове. ABC позволяет объявить контракт явно:
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: int) -> str:
"""Списать деньги и вернуть id транзакции."""
@property
@abstractmethod
def name(self) -> str: ...
def pay(self, amount): # обычный метод использует абстрактные
tx = self.charge(amount)
return f"{self.name}: {amount} ₽, транзакция {tx}"
class FakeGateway(PaymentGateway):
name = "Тестовый шлюз"
def charge(self, amount):
return f"tx-{amount}"
print(FakeGateway().pay(500))
try:
PaymentGateway()
except TypeError as e:
print("TypeError:", e)
class Broken(PaymentGateway):
name = "Сломанный"
try:
Broken()
except TypeError as e:
print("TypeError:", e)
Класс с хотя бы одним нереализованным @abstractmethod нельзя создать — ошибка возникает сразу при создании объекта, а не при вызове метода.
collections.abc
Модуль collections.abc описывает протоколы встроенных коллекций. Наследуясь от них, вы реализуете пару методов и получаете остальные бесплатно:
| ABC | Нужно реализовать | Получаете даром |
|---|---|---|
Iterable |
__iter__ |
— |
Sized |
__len__ |
— |
Container |
__contains__ |
— |
Sequence |
__getitem__, __len__ |
__contains__, __iter__, __reversed__, index, count |
Mapping |
__getitem__, __iter__, __len__ |
get, keys, items, values, __contains__, __eq__ |
MutableMapping |
+ __setitem__, __delitem__ |
+ pop, update, setdefault, clear |
from collections.abc import MutableMapping
class LowerDict(MutableMapping):
"""Словарь, нечувствительный к регистру ключей."""
def __init__(self, *args, **kwargs):
self._data = {}
self.update(*args, **kwargs) # update() достался от MutableMapping
def __getitem__(self, key):
return self._data[key.lower()]
def __setitem__(self, key, value):
self._data[key.lower()] = value
def __delitem__(self, key):
del self._data[key.lower()]
def __iter__(self):
return iter(self._data)
def __len__(self):
return len(self._data)
headers = LowerDict({"Content-Type": "text/html"})
print(headers["content-type"], "CONTENT-TYPE" in headers, headers.get("X-Missing", "—"))
headers.setdefault("Accept", "*/*")
print(dict(headers.items()))
isinstance без наследования
Некоторые ABC распознают класс по наличию методов (__subclasshook__):
from collections.abc import Iterable, Sized, Hashable
class Bag:
def __iter__(self):
return iter([1, 2])
print(isinstance(Bag(), Iterable)) # True, хотя Bag не наследует Iterable
print(isinstance(Bag(), Sized)) # False — нет __len__
print(isinstance([], Hashable), isinstance((), Hashable))
Класс можно и явно «зарегистрировать» как виртуальный подкласс: Iterable.register(MyClass).
Protocol: структурная типизация
ABC требует наследования — это номинальная типизация. typing.Protocol описывает интерфейс по структуре: подходит любой класс с нужными методами. Это утиная типизация, которую понимают mypy и IDE:
from typing import Protocol, runtime_checkable
@runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
class DbConnection: # ничего не наследует
def close(self):
print(" соединение закрыто")
class Report:
pass
def shutdown(resources: list[SupportsClose]) -> None:
for r in resources:
r.close()
shutdown([DbConnection()])
print(isinstance(DbConnection(), SupportsClose), isinstance(Report(), SupportsClose))
Без @runtime_checkable протокол работает только для статических проверок (mypy), а isinstance бросит TypeError. И isinstance проверяет лишь наличие методов, а не их сигнатуры.
Что выбрать
| Ситуация | Инструмент |
|---|---|
| Функции нужен «объект с методом X», классы ваши и чужие | утиная типизация + Protocol для подсказок типов |
| Есть иерархия ваших классов, общий код в базовом классе, нужно запретить неполные реализации | ABC + @abstractmethod |
| Свой контейнер, похожий на list/dict | collections.abc.Sequence / MutableMapping |
| Проверить «можно ли итерировать» | isinstance(x, Iterable) или просто EAFP |
Вопрос на собеседовании
«Как в Python сделать интерфейс?» — В Python нет ключевого слова interface. Варианты: абстрактный класс abc.ABC с @abstractmethod (наследники обязаны реализовать методы, иначе объект не создать) или typing.Protocol для структурной типизации (класс подходит, если у него есть нужные методы, проверяет mypy). На практике часто хватает утиной типизации.
Итог
- Утиная типизация: важны методы объекта, а не его класс. Питоничный стиль — EAFP.
ABC+@abstractmethod— явный контракт; неполную реализацию нельзя создать.collections.abcдают готовые протоколы коллекций и методы-примеси.Protocol— структурная типизация для mypy;@runtime_checkable— для isinstance.
Практика решено 0 из 5
Создайте абстрактный класс Shape(ABC) с абстрактными методами area() и perimeter()
и обычным методом describe(), который возвращает строку "площадь=12.00, периметр=14.00"
(числа с двумя знаками после точки).
Затем реализуйте наследника Rect(w, h).
Какие тесты проверяют код (3)
assert Rect(3, 4).describe() == "площадь=12.00, периметр=14.00"
try:
Shape()
ok = False
except TypeError:
ok = True
assert ok, "Shape должен быть абстрактным: Shape() → TypeError"
class Bad(Shape):
def area(self):
return 0
try:
Bad()
ok = False
except TypeError:
ok = True
assert ok, "Наследник без perimeter() не должен создаваться"
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self): ...
@abstractmethod
def perimeter(self): ...
def describe(self):
return f"площадь={self.area():.2f}, периметр={self.perimeter():.2f}"
class Rect(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
def perimeter(self):
return 2 * (self.w + self.h)
Реализуйте класс Range3 — неизменяемую последовательность из трёх чисел start, start+1, start+2,
унаследовав его от collections.abc.Sequence.
Достаточно определить __len__ и __getitem__ — методы index, count, __contains__,
__reversed__ и __iter__ Sequence даст бесплатно.
Какие тесты проверяют код (4)
r = Range3(10); assert list(r) == [10, 11, 12] r = Range3(10); assert 11 in r and r.index(12) == 2 and r.count(10) == 1 assert list(reversed(Range3(1))) == [3, 2, 1] assert Range3(0)[-1] == 2
from collections.abc import Sequence
class Range3(Sequence):
def __init__(self, start):
self.start = start
def __len__(self):
return 3
def __getitem__(self, i):
if not -3 <= i < 3:
raise IndexError(i)
return self.start + i % 3
Что такое утиная типизация?
Чем typing.Protocol отличается от abc.ABC?
Какой стиль считается питоничным: EAFP или LBYL?
Войдите, чтобы отмечать пройденные уроки.