Еще раз о создании подклассов в Python

Размышления на тему вариантов структурирования кода в Python по принципу наследования и композиции от известного программиста Хайнека Шлавака (Hynek Schlawack). По его просьбе указываю, что данный перевод сделан исключительно мной и не согласовывался с ним. В перевод не включены концевые сноски.

Источник: Subclassing in Python Redux

Конфликт между созданием подклассов и композицией возник одновременно с появлением самого объектно-ориентированного программирования. Последний урожай языков, например Go и Rust, доказывает, что для успешного написания кода вам не нужно создавать подклассы. Но как выглядит прагматический подход к созданию подклассов в Python?

Достаточно долго следящие за мной читатели знают, что я уверенно придерживаюсь позиции о том, что «композиция лучше наследования». Однако в Python такая структура, что иногда невозможно написать идиоматический код без создания подклассов. В данной статье моя цель – поразмышлять над вопросом о том, в каких ситуациях такое бывает, и разобраться в своих чувствах по данной теме.

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

В связи с этим может случиться так, что если три человека начнут спорить, и при этом каждый будет по-своему прав, они никогда не найдут точек соприкосновения. Мы все видели, как разворачивались данные дискуссии.

Мы всегда жертвуем читаемостью ради удобства написания. Это не обязательно плохо, так как структурирование программного обеспечения всегда подразумевает компромиссы, и вы, возможно, придете к выводу о том, что в определенных случаях оно вполне того стоит. Вот почему я не предполагаю, что вы согласитесь со всем, что написано ниже. Но я надеюсь дать импульс для каких-нибудь мыслей, которые помогут вам принять данное решение в будущем.

Но теперь, без лишних слов, давайте посмотрим на три варианта. Начнем с плохого.

Вариант 1: Совместное использование кода

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

Принцип преобладания композиции над наследованием, автор: Брэндон Роудс (Brandon Rhodes),

Закат объектного наследования и восход новой модульности, автор: Оги Фэклер (Augie Fackler) и Натаниэль Маниста (Nathaniel Manista) на конференции PyCon US 2013,

и Ничего - это уже кое-что, автор: Сэнди Метц (Sandi Metz) на конференции RailsConf 2015.

В двух словах, есть три глобальные проблемы:

1. Внесение изменений вдоль более чем одной оси. Это важный практический вывод из поста Брэндона и второй половины выступления Сэнди. Объяснить непросто, поэтому я сошлюсь на их работы, но суть в следующем: если вы хотите индивидуализировать более чем один аспект в поведении класса, совместное использование кода через создание подклассов не будет эффективным. Результатом этого становится взрывной рост подклассов.

Это не мнение и не компромисс. Это факт.

2. Перемешиваются пространства имен классов и экземпляров. Допустим, есть атрибут self.x в классе, который наследует из одного или нескольких базовых классов, то в этом случае вам потребуются исследования и умственная энергия, чтобы определить источник появления x. Так будет при чтении кода, а также во время отладки.

Это также означает, что всегда есть опасность того, что два не знающих друг о друге класса из одной иерархии попытаются создать атрибут с одним и тем же именем. В Python есть концепция двойного переднего подчеркивания (__x), которая должна решать данную проблему, но такой подход встречает неодобрение, и высказываются возражения, что лучше действовать по принципу взаимного согласия между совершеннолетними.

Проблема в том, что информированное согласие невозможно, если не проинформированы все стороны. Данная проблема усугубляется по экспоненте при множественном наследовании и его крайней форме: классы-примеси (mixins). Мы полагаемся на классы, которые потенциально не контролируем и которые ничего не знают друг о друге, уживаясь в общем пространстве имен.

Еще одна проблема в том, что мы не контролируем методы и атрибуты из базовых классов, к которым мы даем доступ пользователям. Они просто существуют, замусоривая поверхность нашего API. Потенциально меняясь со временем по мере развития наших базовых классов, добавления или переименования методов и атрибутов. Это одна из причин, почему в attrs (и, в конечном итоге, для классов данных (dataclasses)) принято решение использовать декораторы классов вместо создания подклассов: нужно обдуманно решать, что прикреплять к классу. Невозможно случайно позволить чему-нибудь растечься по всем подклассам.

3. Непонятные косвенные обращения. Это частный случай предыдущей проблемы и главная тема в выступлении Оги и Натаниэля. Если в каждом методе есть self, то при взгляде на результат вызова непонятно, откуда он берется. Если не соблюдать крайнюю осторожность, каждая попытка разобраться в потоке команд (control flow) заканчивается охотой за призраками. Как только в игру вступит множественное наследование, вам лучше почитать про MRO и super(). Думаю, будет справедливо сказать, что что-то не так, если вопрос, который сводится к формулировке «для чего вообще нужен super()?», получает почти 3 000 голосов и добавляется более чем в 1 000 закладок на StackOverflow.

Все это становится еще сложнее, когда создаются API, требующие создания подклассов для реализации или перезаписи существующих методов, которые вызываются откуда-то из другого места. Такие грехи совершались в классах Протоколов и Twisted, и asyncio, и эти шрамы будут со мной навсегда. Наиболее распространенные проблемы в том, что сложно определить, какие существуют методы (особенно в глубоких иерархиях, например Twisted), и в часто незаметном сбое, возникающем при чуть-чуть неправильном названии метода, не позволяющем базовому классу найти его.

 

Я использую подклассы для совместного использования кода только если мне нужно изогнуть поведение класса, который я не контролирую. Считаю это менее вопиющим видом обезьянего патча (monkey patching). В большинстве случаев лучше написать Адаптер, Фасад, Прокси или Декоратор, но бывают случаи, когда из-за количества методов, которые приходится делегировать, это становится накладно, если нужно изменить только небольшую деталь.

В любом случае не превращайте это в центральную часть своей структуры.

Вариант 2: Абстрактные типы данных, также известные как интерфейсы

Абстрактные типы данных (ADT) в основном предназначены для фиксации интерфейсных контрактов. Вы хотите иметь возможность сказать, что вам нужен объект с определенными свойствами (атрибутами, методами), и не думать об остальном. Во многих языках их называют интерфейсами, что звучит гораздо менее претенциозно, и поэтому с данного момента я буду использовать данный термин.

Поскольку в Python динамическая типизация, а аннотации типов строго не обязательные, нам не нужны формальные интерфейсы. Однако очень полезно, когда есть способ явно определить интерфейс, необходимый для работы фрагмента кода. А с появлением программ проверки типов, например Mypy, они превратились в проверенную документацию по API, что, на мой взгляд, прекрасно.

Например, если мы хотим написать функцию, которая принимает объекты с использованием метода read(), нужно каким-то образом определить интерфейс Reader с данным методом (объяснение того, как это сделать, будет через минуту), и использовать его примерно вот так:

def printer(r: Reader) -> None:
    print(r.read())

printer(FooReader())

Нашей функции printer() неважно, что делает read(), пока он возвращает пригодную для вывода строку. Он может возвращать заранее заданную строку, читать из файла или вызывать API по интернету; функцию printer() это не заботит, и ваша программа для проверки типов закричит на нас, если мы попытаемся вызвать в ней какой-нибудь другой метод, а не read().

 

Стандартная библиотека Python предлагает два подхода к определению интерфейсов:

1. Абстрактные базовые классы (ABC) представляют собой менее мощную версию zope.interface и работают с использованием номинального подтипирования. Они существуют со времен Python 2.6, и в стандартной библиотеке их много.

Обратите внимание, что не каждый абстрактный базовый класс также является абстрактным типом данных. Иногда это просто неполный класс, который предполагается дополнить через создание его подкласса и реализации его абстрактных методов, и это не интерфейс. Однако различие не всегда очевидно на 100 %.

2. Протоколы позволяют не создавать подклассы с помощью структурного подтипирования. Они появились в Python 3.8, но благодаря typing-extensions доступны еще с Python 3.5.

Номинальное подтипирование и структурное подтипирование – громкие слова, но, к счастью, их легко объяснить.

Номинальное подтипирование

Номинальное подтипирование: нужно сообщить системе типов, что ваш класс является подтипом, созданным на основе определения интерфейса. ABC традиционно выполняют данную задачу через подклассы, но еще можно использовать метод register().

Именно так можно определить интерфейс Reader из введения и пометить FooReader и BarReader как его реализацию:

import abc

class Reader(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def read(self) -> str: ...

class FooReader(Reader):
    def read(self) -> str:
        return "foo"

class BarReader:
    def read(self) -> str:
        return "bar"

Reader.register(BarReader)

assert isinstance(FooReader(), Reader)
assert isinstance(BarReader(), Reader)

Если бы в FooReader не было метода с именем read, создание экземпляра завершилось бы ошибкой во время выполнения. Если использовать вариант с register(), например BarReader, интерфейс не проверяется во время выполнения и становится (как его называют в документации) «виртуальным подклассом». Благодаря этому появляется возможность использовать более динамичные – или магические – средства для создания желательного интерфейса. Поскольку register() принимает реализующий объект как аргумент, можно использовать его как декоратор класса и сэкономить две пустые строки.

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

 

Одним из «позитивных аспектов» при использовании ABC для определения интерфейсов является то, что при создании их подклассов можно по-тихому реализовать совместное использование кода, добавляя обычные методы в свой абстрактный базовый класс. Но как упоминалось в начале: смешивание разных вариантов создания подклассов – плохая идея. Совместное использование кода через создание подклассов – плохая идея. При множественном наследовании эта идея становится особенно плохой.

Если честно, я видел ситуации с хорошим использованием данного паттерна, но подход нужно применять очень продуманно. В Python есть идиоматический вариант, когда нужно реализовать целую кучу методов с двойным подчеркиванием на основе другого четко обозначенного поведения. Хороший пример: collections.UserDict. Он не самый замечательный в связи со всеми указанными причинами, но представляет собой хороший компромисс в рамках ограничений и культуры Python. Однако в примере с UserDict возникнут проблемы, если попытаться прикрепить к своему подклассу больше поведения, чем ожидается от словаря. Тогда проблемы из раздела о совместном использовании кода путем создания подклассов вновь встанут в полный рост. Чтобы избежать этого, назначайте классам только одну обязанность.

Структурное подтипирование

Структурное подтипирование представляет собой утиную типизацию для типов: если ваш класс соответствует ограничениям Протокола, он автоматически считается его подтипом. Поэтому класс может реализовать множество Протоколов из самых разных пакетов, ничего не зная о них!

По умолчанию это работает только при наличии программ для проверки типов, но если применить typing.runtime_checkable(), также можно использовать на них проверку с помощью isinstance().

Пример из предыдущего раздела будет выглядеть так:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Reader(Protocol):
    def read(self) -> str: ...

class FooReader:
    def read(self) -> str:
        return "foo"

assert isinstance(FooReader(), Reader)

Как видите, FooReader вообще не знает о существовании протокола Reader!

 

В Протоколах мне по-настоящему нравится то, как они совершенно ненавязчиво позволяют мне определять нужный мне интерфейс, и данное определение может сосуществовать с потребителем интерфейса. Очень хорошо, когда у нас есть разные реализации одного и того же интерфейса в одной и той же кодовой базе. Например, допустим у нас есть интерфейс MailSender, который направляет электронное письмо в продуктиве, но просто выводит его на консоль в среде разработки.

Или если мы используем лишь небольшой поднабор из внешнего класса и хотим явно обозначить данный поднабор. Это очень хорошая (проверенная!) документация, которая помогает при внедрении фейков для наших тестов.

Более подробно о Протоколах и структурном подтипировании см. в материале glyph’а Хочу новую утку.

Несмотря на то, что данный способ создания подклассов в основном безвреден, в Python нам не нужно создавать подклассы для абстрактных типов данных благодаря методам typing.Protocol и register() из ABC.

Вариант 3: Специализация

Итак, у нас был один вредный и один не нужный способ создавать подклассы. Вот мы и подошли к хорошему варианту. Фактически, в Python мы не сможем обойтись без наследования такого рода, даже если бы захотели. Если только не захотим отказаться от исключений (Exceptions).

Интересно отметить, что специализацию часто понимают неправильно. На интуитивном уровне все просто: если мы говорим, что класс B является специализацией базового класса A, то мы говорим, что класс B – это класс A с дополнительными свойствами. Собака – животное. А350 – пассажирский самолет. У них есть все свойства из своего базового класса (классов), и они добавляют атрибуты, методы или просто место в иерархии.

Невзирая на эту привлекательную простоту, его часто используют неправильно. Самой печально известной ошибка было бы сказать, что квадрат является специализацией прямоугольника, потому что с точки зрения геометрии он представляет собой особый случай. Однако квадрат – не прямоугольник плюс еще что-то.

Нельзя использовать квадрат во всех случаях, когда можно использовать прямоугольник, исключая случаи, когда код знает, что ему следует ожидать еще и квадрат. Если нельзя взаимодействовать с объектом, как если бы он был экземпляром своего базового класса, мы нарушаем принцип подстановки Лисков и не сможем писать полиморфный код.

Если присмотреться внимательнее, то становится понятно, что интерфейсы из предыдущего раздела – особый случай специализации. Мы всегда создаем специализацию генерального контракта по API в виде чего-то конкретного! Ключевое отличие в том, что абстрактные типы данных… ну… абстрактны.

 

Я считаю, что специализация чрезвычайно полезна, когда я пытаюсь создать представление для данных, которые организованы в строгую иерархию.

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

В итоге мы получаем следующие четыре подхода.

Подход 1: Создается специальный класс для каждого случая

Вот так выглядят классы, которые нам действительно в итоге понадобятся:

class Mailbox:
    id: UUID
    addr: str
    pwd: str

class Forwarder:
    id: UUID
    addr: str
    targets: list[str]

Вид адреса закодирован в классе, и в каждом классе есть только те поля, которые он использует. И если ваша модель настолько проста, это будет абсолютно правильный способ. Любые попытки избавиться от дублирования имеют смысл только в случае, когда полей и типов гораздо больше.

Любой метод, который мы добавим в любой из этих классов, будет полностью независим от второго, и это не даст возникнуть путанице. Еще можно использовать данные классы со средством проверки типов, используя тип Union: Mailbox | Forwarder.

Подход 2: Создается один класс, поля сделаны необязательными

Вот так выглядит структура, которая может получиться в конечном счете, если пытаться любой ценой избежать наследования, но при этом по-прежнему избегать самоповторения:

class AddrType(enum.Enum):
    MAILBOX = "mailbox"
    FORWARDER = "forwarder"

class EmailAddr:
    type: AddrType
    id: UUID
    addr: str

    # используется, только если type == AddrType.MAILBOX
    pwd: str | None
    # используется, только если type == AddrType.FORWARDER
    target: list[str] | None

Технически данный вариант больше соответствует DRY, но с ним использование экземпляров класса становится гораздо неудобнее. Тип/факт существования большинства полей зависит только от значения в поле type, которое создано только потому, что у всех типов адресов один и тот же тип класса.

Это противоречит моему любимому принципу структурирования: обеспечить невозможность создания представления для недопустимого состояния, и это невозможно проверить разумным образом с помощью средства проверки типов, которое начало бы постоянно жаловаться на доступность полей, которые могут иметь значение None.

Все поведение, работающее на основе данного класса, будет смешиваться в кучу, и это приводит к множеству условий (операторов if-elif-else), которые значительно повышают сложность нашего кода. Вся суть полиморфизма в том, чтобы избежать этого.

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

 

Можно чуть-чуть ослабить болезненность ситуации и переместить в класс данные, имеющие отношение к mailbox, и сделать необязательным только данное поле. Так лучше, но все же излишне неуклюже.

Подход 3: Композиция

Данный подход инвертирует предыдущий и выглядит глупо в нашей чрезмерно упрощенной модели данных, но давайте просто представим, что в EmailAddr гораздо больше полей, поэтому есть смысл обернуть его в отдельный класс:

class EmailAddr:
    id: UUID
    addr: str

class Mailbox:
    email: EmailAddr
    pwd: str
    
class Forwarder:
    email: EmailAddr
    targets: list[str]

Данный подход так уж и плох! У нас нет необязательных полей, и все взаимосвязи между данными понятны. С точки зрения удобочитаемости и ясности жаловаться не на что.

За исключением того, что этот вариант еще и довольно неуклюжий, и вам не нужно консультироваться с Гвидо, чтобы понять, что данный код какой угодно, но не питонический. Но почему он выглядит так натужно, хотя предполагается, что композиция лучше наследования? Классы EmailAddr и Mailbox/Forwarder слишком тесно связаны друг с другом, и даже трудно придумать имя для поля, используемого для хранения. Композиция нас не подводит, но в данном случае принудительное создание связи ощущается как движение против течения.

Но данный вариант полезен тем, что показывает нам один момент в наших моделях: у них всех одна общая базовая информация, и они тесно связаны между собой. Итак, давайте сделаем последний шаг и воспользуемся предлагаемым в Python способом для организации использования общей базы и используем специализацию с помощью создания подклассов. Мы вернемся к композиции, когда я улучшу основанную на создании подклассов структуру в одном из следующих разделов данной статьи.

Подход 4: Создается общий базовый класс, затем создается специализация

Наконец, подход, который, на мой взгляд, наиболее эргономичен, соответствует принципу DRY, очевиден и пригоден для проверки типов:

class EmailAddr:
    id: UUID
    addr: str

class Mailbox(EmailAddr):
    pwd: str

class Forwarder(EmailAddr):
    targets: list[str]

При каждой встрече с классом Mailbox мы знаем, что есть поле pwd, как и наши средства для проверки типов. Тип закодирован в классе, поэтому повторять его в поле не нужно. Строго говоря, Mailbox – это EmailAddr и еще что-нибудь.

Что касается кода, теперь нужно знать правила ответственного подхода к созданию подклассов, например вышеприведенный принцип подстановки Лисков. Появляется дополнительная сложность и умственная нагрузка, но границы и обязанности гораздо понятнее.

Для создания подклассов от нас требуются знания и дисциплина. Композиция механически навязывает нам дисциплину, даже если это приводит к неуклюжести.

Возможно, в этом самая простая причина слишком большого доверия к композиции: она оставляет меньше пространства, в котором мы можем совершать ошибки.

С точки зрения читаемости становится хуже, как и при любом процессе создания подклассов, ведь нам нужно собрать последний класс в своей голове, чтобы узнать, какие есть поля. Но фактически получаются те же классы, что и с первым подходом. Если не переусердствовать и в идеале сохранить определения физически рядом друг с другом, данный компромисс будет оптимальным в подобных ситуациях.

Он настолько полезен, что я использовал его в своей библиотеке парсинга для PEM-файлов, и пока не жалею об этом.

 

Совет общего характера, который следует извлечь для себя из данного раздела: всегда сначала сосредотачиваться на форме своих данных, а только потом на том, что с ними делать.

Как только мы разберемся с формой, поведение придет гораздо более естественным образом. Хорошим примером является подход Sans I/O: основной фокус однозначно на данные, так как предполагается, что благодаря структуре поведение можно менять.

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

Наконец, не забудьте изучить @singledispatch; если вы еще это не сделали, то появится ощущение магии.

При следовании данным рекомендациям бонусом будут объекты с отличной тестируемостью.

За пределами личного пространства змеи

Последний подход настолько полезен, что он проник в Go, в котором как бы нет подклассов, под кличкой встраивания (embedding):

type EmailAddr struct {
    addr string
}

type Mailbox struct {
    EmailAddr
    pwd string
}

Теперь у экземпляров Mailbox есть атрибут addr, как если бы он был определен в нем: https://play.golang.org/p/WSjJA6MYUDb. Но при инициализации все равно необходимо давать явное указание, а реальной иерархии при этом нет. Никакого super() нет. Выполнять вызовы можно только на одном уровне иерархии. Прагматичный компромисс!

Оглядываясь назад, можно сказать, что здесь синтаксис из нашего подхода 3, но во многих отношениях мы получаем классы из подхода 1.

Когда я увидел это в Go, на меня нашло небольшое откровение, так как моя собственная интуитивная эвристика подклассов соответствует данному паттерну, но я не знал, как нужно их формулировать. Теперь я могу просто сказать, что использую подходы с созданием подклассов в случаях, когда мог бы, и стал бы (!) использовать встраивание из Go.

Данный момент нужен для того, чтобы подчеркнуть пользу от изучения других языков программирования и перекрестного опыления идеями. Однако никогда не забывайте проверить идеи через уникальную призму Python, когда пытаетесь их применить.

В каком направлении двигаться дальше

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

Однако важно помнить, что не получится взять свою структуру на основе наследования и просто прекратить создание подклассов. Структура на основе композиции отличается фундаментально, поэтому, вероятно, придется сменить некоторые убеждения и методы.

Лучшее введение в структурирование по ООП, о котором я знаю, пусть оно и не основано на Python, – это 99 Bottles of OOP, и вам лучше его прочитать, если пока не удалось. Это не только невероятно информативное, но и интересное чтение.

Чтобы не совсем уклоняться от своих обязанностей, закроюсь конкретным примером.

Пример использования

Я буду использовать отредактированный для ясности код из замечательной книги Architecture Patterns with Python, которую я помогал редактировать и которая безусловно стоит вашего времени и ваших денег. Я использую его здесь, так как Гарри (один из авторов книги) сказал мне сделать пост в блоге, когда я пожаловался по поводу него.

 

Целью является реализация паттерна Репозиторий (repository): класса, который позволяет добавлять и извлекать объекты, используя хранилище данных. По причинам, которые не представляют интереса для данного поста, он дополнительно должен помнить все объекты, которые он добавлял или извлекал, в поле с именем seen.

Важная цель структуры в том, что должна оставаться возможность для подключения самого хранилища, чтобы оно могло, например, использовать в продуктиве базу данных вроде Postgres и словарь в модульных тестах. Однако для запоминания увиденных объектов во всех реализациях используется один и тот же код, поэтому мы хотим иметь возможность для совместного использования кода.

Специализация здесь не работает, так как она идет в неправильном направлении: репозиторий для отслеживания – это специализация «обычного» репозитория. Поэтому код, которым мы хотим открыть для совместного использования, в итоге окажется в подклассе. Пользы – ноль.

Поэтому в книге используется наименее предпочтительный для меня способ совместного использования кода с использованием подклассов: паттерн по методу шаблона (template). Это означает, что базовый класс создает общий поток команд, а наш подкласс дополняет некоторые детали:

1. Пользователь создает экземпляр подкласса,

2. Затем вызывает методы базового класса,

3. Которые, в свою очередь, вызывают методы подкласса.

В данном случае подклассы должны реализовать следующие методы: _add_product («добавить продукт») и _get_by_sku («извлечь по артикулу»):

class AbstractRepository(abc.ABC):
    seen: set[Product]

    def __init__(self) -> None:
        self.seen = set()

    def add_product(self, product: Product) -> None:
        self._add_product(product)
        self.seen.add(product)

    def get_by_sku(self, sku: str) -> Product | None:
        product = self._get_by_sku(sku)
        if product:
            self.seen.add(product)

        return product

    @abc.abstractmethod
    def _add_product(self, product: Product):
        raise NotImplementedError

    @abc.abstractmethod
    def _get_by_sku(self, sku: str) -> Product | None:
        raise NotImplementedError

То есть каждый подкласс должен определять методы _add_product() и _get_by_sku() . Затем пользователь вызывает методы add_product() и get_by_sku() из AbstractRepository, которые, в свою очередь, делегируют на _add_product() и _get_by_sku() из подкласса, запоминая при этом, какие он увидел объекты типа Product.

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

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

И это верно даже с позиции пользователя, так как публичный API определяется абстрактным базовым классом, а не тем классом, экземпляр которого вы создали на самом деле! Этот момент плохо рассматривается во многих системах документации, и приходится прыгать туда-сюда во время чтения.

 

Встретившись с подобным кодом, который хочется освободить от пут подхода с созданием подклассов, остается два варианта:

1. Обернуть класс. Вместо того, чтобы включать его в self, держите его в атрибуте экземпляра. При необходимости делегируйте на методы атрибута.

2. Создать параметры для поведения. Это правильный путь, если нужно индивидуализировать поведение класса вдоль нескольких осей, а совместное использование кода через подклассы разваливается. Звучит сложно, но Сэнди Метц предлагает прекрасную демонстрацию в вышеуказанном выступлении Ничего - это уже кое-что, обеспечивая возможность для индивидуализации классификации и форматирования за несколько строк кода.

Если получится уловить данную концепцию, то именно тогда у многих все встает на свои места; по крайней мере, так случилось со мной.

У нас простой пример: нам нужно делать только то, что делает конкретный репозиторий, плюс кое-что еще. Поэтому мы выбираем вариант номер один. Если немного прищуриться, то станет понятно, что в данном случае создание подклассов по шаблону реализовано именно как обертывание класса. За исключением того, что пространства имен перемешаны, а поток команд сбивает с толку.

Репозиторий

Вместо абстрактного базового класса с кучей кода мы определяем интерфейс, который мы обернем через определение протокола под названием Repository:

class Repository(typing.Protocol):
    def add_product(self, product: Product) -> None: ...
    def get_by_sku(self, sku: str) -> Product | None: ...

Разумеется, если вы не используете аннотации типов, данный шаг можно пропустить.

 

Простая реализация, в которой используется словарь для хранения данных, может выглядеть так:

class DictRepository:
    _storage: dict[str, Product]

    def __init__(self):
        self._storage = {}

    def add_product(self, product: Product) -> None:
        self._storage[product.sku] = product

    def get_by_sku(self, sku: str) -> Product | None:
        return self._storage.get(sku)

В репозитории должны быть реализованы два обещанных публичных метода, но к нему относится весь класс. Никогда не возникает опасность столкновения имен. У него только одна обязанность: сохранять и извлекать Продукты (Products). Ему также не нужно знать, что протокол под названием Repository вообще существует; ваше средство для проверки типов поймет за вас, что это его реализация.

Отслеживание

Далее, давайте реализуем отслеживание вокруг Репозитория, обернув его экземпляр:

class TrackingRepository:
    _repo: Repository
    seen: set[Product]

    def __init__(self, repo: Repository) -> None:
        self._repo = repo
        self.seen = set()

    def add_product(self, product: Product) -> None:
        self._repo.add_product(product)
        self.seen.add(product)

    def get_by_sku(self, sku: str) -> Product | None:
        product = self._repo.get_by_sku(sku)
        if product:
            self.seen.add(product)

        return product

Данный класс состоит из объекта, о котором вы знаете только то, что он реализует Репозиторий, и множества (set) Продуктов. Если использjdfnm в атрибуте _repo что-нибудь, что не обещано в интерфейсе Репозитория, ваше средство для проверки типов будет кричать на вас, даже не исполняя код.

Резюме

Мне эта версия нравится гораздо больше, так как в ней кристально ясный процесс исполнения программы. Вы знаете, откуда берутся методы и атрибуты, не проверяя никакой базовый класс (классы).

Цена этой ясности в том, что мы должны хранить репозиторий в своем классе (_repo) и вызывать self._repo.add_product() вместо self._add_product(). Приходится немного больше печатать.

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

Прозрение напоследок: если вы всегда задавались вопросом, как писать для кода тесты, которые представляют собой не просто манипуляции со строками или сложение двух чисел, как во всех практикумах по тестированию, я надеюсь, вы теперь видите, что изучение более качественного структурирования по ООП без труда поможет вам и с этим.

Заключительные слова

Потрясающе, вы добрались до конца! Спасибо, что оставались со мной! Моя конечная цель – добавить в обсуждение больше нюансов. Хочу, чтобы вы поняли, что использование Исключений (Exceptions) не превращает использование еще и паттерна по методу шаблона в хорошую идею, так как «оба представляют собой подход с созданием подклассов». Надеюсь, что мне это в чем-то удалось.

Из-за своей длины данная статья вряд ли будет широко распространяться по принципу «посмотреть, прочитать, ретвитнуть/поставить лайк». Скорее всего, у вас она тоже какое-то время провела в открытой вкладке/очереди на чтение! Так что было бы здорово, если бы вы могли как-то поделиться ею, чтобы все же помочь с ее распространением.

Не стесняйтесь, расскажите мне о своих мыслях по поводу нее или сколько чашек кофе вам потребовалось, чтобы добраться до конца. Я не планирую тратить много времени на публичные обсуждения, так как они часто становятся слишком горячими, педантичными и догматичными. Одна из причин, почему я написал данную статью, – это получить возможность просто дать другим участникам ее URL и катапультироваться. Пусть она послужит тем же и вам!

Я работаю над выступлением на основе данного материала, так что дайте знать о себе, если вам интересно получить презентацию на своей конференции или в компании, как только снова появится возможность сделать это лично!

И, наконец, если вы хотите, чтобы я выдавал что-то вроде этого почаще, подумайте, может быть, поддержите меня финансово?