Цепочка методов потеряла тип наследника: когда в Python нужен Self
В приложении есть построитель отчёта. Общая часть задаёт заголовок, специализированная добавляет формат выгрузки. Хотим написать CsvReport().named("daily").delimiter(";"). Во время выполнения цепочка корректна, но проверяющий типов считает результат named обычным Report и не видит delimiter. Объект остался тем же; сведения о конкретном классе потерялись в аннотации.
Возвращаемый тип Report означает любой Report. Для метода, который возвращает self, точнее typing.Self: результат связан с классом получателя вызова. При вызове на CsvReport он остаётся CsvReport. Это также помогает описывать classmethod, создающий cls(), когда наследники совместимы по конструктору.
Результат связан с получателем
Не нужно ради этой задачи убирать аннотации или приводить каждый результат через cast. Такие обходы заставляют вызывающий код знать о деталях базового класса. Контракт лучше выразить в одном месте, рядом с методом. В Python 3.11 и новее Self входит в typing; наш пример рассчитан на Python 3.12.
from typing import Self
class Report:
def __init__(self) -> None:
self.name = "untitled"
def named(self, name: str) -> Self:
self.name = name
return self
class CsvReport(Report):
def __init__(self) -> None:
super().__init__()
self.separator = ","
def delimiter(self, separator: str) -> Self:
self.separator = separator
return self
report: CsvReport = CsvReport().named("daily").delimiter(";")
assert report.name == "daily" and report.separator == ";"
print(report.name, report.separator)
Проверяем цепочку вызовов
Запустите файл: он выведет daily ;. Для проверки типов установите mypy командой python -m pip install mypy и выполните python -m mypy --strict report.py. Если заменить аннотацию named на Report, диагностируется обращение к delimiter. Этим опытом удобно объяснять проблему на ревью: изменение касается статического интерфейса, а не необходимости создать другой объект.
У fluent-интерфейса остаётся вопрос мутабельности. В примере named изменяет текущий объект. Если две переменные ссылаются на один построитель, обе увидят новое имя. Self не обещает копирование, изоляцию или неизменяемость. Для метода, возвращающего изменённую копию, нужно действительно создать копию и проверить, что исходный объект сохранился. В названии и документации API стоит выбрать один последовательный подход.
Также нельзя аннотировать Self метод, который всегда создаёт именно Report(), даже если его вызвали на наследнике. Проверяющий ожидает более узкий результат, а реализация вернёт базовый объект. Для фабрики с cls() нужно проверить конструкторы потомков: обязательный дополнительный аргумент нарушит предположение базовой фабрики. Иногда честнее дать специализированному классу свою фабрику.
Перед добавлением новых методов проверьте цепочку из двух разных классов и обычное присваивание результата переменной конкретного типа. Затем проверьте состояние объекта после каждого вызова. Это разделит две ошибки, которые иначе выглядят одинаково в редакторе: потерю типа результата и неожиданное изменение общей ссылки. Типизация облегчает использование интерфейса, когда его поведение уже определено.
Следующий шаг — перенести этот приём из отдельного файла в поддерживаемое приложение. В курсе «Python для продвинутых: ООП, типизация и тестирование» структура пакетов, объектная модель, mypy и pytest соединяются в типизированном трекере задач с хранением данных и логированием. Можно отработать связь между интерфейсом, реализацией и регрессионным тестом, а затем применить её к своему проекту.
Посмотреть программу и практику курса.
Документация: официальный источник по теме.
Другие задачи по теме: Типы, тесты и поведение программы.
Продолжить на практике
- Python для продвинутых: ООП, типизация и тестирование ↗
Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.
