Почему вам следует делать ваши конструкторы простыми

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

class MyClass:
    def __init__(self, attr1, attr2):
        self.attr1 = attr1
        self.attr2 = attr2

    def get_variables(self):
        return self.attr1, self.attr2


my_object = MyClass("value1", "value2")
my_object.get_variables()  # -> ("value1", "value2")

Создание объекта соответствует синтаксису <classname>(<arguments passed to __init__>). В этом случае метод __init__ принимает два аргумента, которые хранятся как переменные экземпляра. После создания объекта к нему можно вызывать методы, использующие эти данные.

Однако большинство объектов построить гораздо сложнее. Часто данные, которые объект должен хранить, недоступны, и их необходимо получить из других входных данных. Часто мы хотим иметь возможность создавать один и тот же объект из разных типов ввода.

Ошибка, которую я вижу во многих базах кода Python, заключается в том, что вся эта логика встроена в метод __init__. Тот факт, что все происходит в __init__, можно запутать каким-нибудь вспомогательным методом _initialize, но результат один: логика создания объектов Python становится непонятными чудовищами.

Давайте посмотрим на пример. У нас есть объект, представляющий некоторый набор конфигураций, которые мы обычно загружаем из файла. Я видел реализацию такого класса следующим образом:

def Configuration:
    def __init__(self, filepath):
        self.filepath = filepath
        self._initialize()

    def _initialize(self):
        self._parse_config_file()
        self._precompute_stuff()

    def _parse_config_file(self):
        # parse the file in self.filepath, and store
        # data in a number of variables self.<attr>      
        ...

    def _precompute_stuff(self):
        # use the variables defined in
        # self._parse_config_file to compute and set
        # new instance variables
        ...

Что с этим не так? Две вещи:

  1. Очень сложно судить о состоянии объекта при его создании. Какие переменные экземпляра определены и каковы их значения? Чтобы это выяснить, мы должны пройти всю иерархию функций инициализации и принять во внимание любые self.<attr> присвоения. В этом фиктивном примере это все еще возможно, но я видел примеры, где код, вызываемый __init__, состоит из более чем 1000 строк и включает методы, вызываемые из суперкласса.
  2. Логика создания теперь жестко запрограммирована. Нет другого способа создать объект Configuration, кроме как указать путь к файлу, поскольку для создания объекта всегда необходимо пройти через метод __init__. Мы всегда можем создать Configuration из файла в данный момент времени, но кто сказал, что это останется верным и в будущем? Кроме того, хотя реальному приложению может потребоваться только один способ создания экземпляра, для тестирования может быть удобно создать фиктивный объект Configuration, не полагаясь на отдельный файл.

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

  • Разрешение входной переменной иметь несколько типов, затем проверка типа входных данных с помощью isinstance и переход к другой ветке инициализации в зависимости от результата. В нашем примере мы можем изменить входную переменную filepath на config и разрешить ей быть строкой или словарем, который мы будем интерпретировать как путь к файлу или уже проанализированный файл соответственно.
  • Добавление аргументов, которые переопределяют друг друга. Например, мы могли бы принять в качестве аргумента и config, и filepath и игнорировать filepath, если указан config.
  • Добавление аргументов, которые могут принимать логические или перечислимые значения, для выбора ветвей в логике инициализации. Например, если у нас есть несколько версий одного и того же файла конфигурации, мы можем просто добавить аргумент version в __init__.
  • Добавление *args или **kwargs в __init__, потому что тогда подпись __init__ больше не нужно будет менять, однако логика реализации может измениться при необходимости.

Почему все эти вещи являются плохими решениями? По сути, потому что это патчи, решающие проблему 2), но все они делают проблему 1) еще хуже. Если у вас возникли проблемы с логикой инициализации и вы используете одну из вышеперечисленных стратегий, подумайте о том, чтобы сделать шаг назад и использовать альтернативный подход.

Чтобы решить проблему 1), я пытаюсь следовать подходу, заключающемуся в том, чтобы рассматривать почти каждый класс как dataclass или NamedTuple (без обязательного использования этих примитивов напрямую). Это означает, что мы должны думать об объекте как о не чем ином, как о наборе связанных данных. Класс определяет имена полей данных и их типы и, при необходимости, реализует методы для работы с этими данными. Метод __init__ должен делать только назначение этих данных; его аргументы должны отображаться непосредственно в переменных экземпляра. Многие другие языки имеют встроенную конструкцию для этой концепции: struct .

Почему это предпочтительнее любого старого объекта Python?

  1. Это заставляет вас думать о данных, которые действительно необходимы объекту для функционирования. Он защитит от установки множества бесполезных переменных экземпляра в __init__ «на всякий случай» или, что еще хуже, от установки разных переменных экземпляра в разных ветках.
  2. Он ставит состояние на первый план для других, читающих код, и отделяет его от любой логики манипулирования данными. Это сразу дает понять, какие атрибуты определены для объекта. Все сгруппировано вместе. В инициализации объекта нет никакой магии.
  3. Это значительно упрощает создание объектов различными способами, например, путем определения фабричных методов или конструкторов. Это также облегчит тестирование.

Для иллюстрации давайте посмотрим на альтернативную реализацию нашего класса Configuration:

def Configuration:
    def __init__(self, attr1, attr2):
        self.attr1 = attr1
        self.attr2 = attr2
        
    @classmethod
    def from_file(cls, filepath):
        parsed_data = cls._parse_config_file(filepath)
        computed_data = cls._precompute_stuff(parsed_data)
        return cls(
            attr1=parsed_data,
            attr2=computed_data,
        )

    @classmethod
    def _parse_config_file(cls, filepath):
        # parse the file in filepath and return the data    
        ...

    @classmethod
    def _precompute_stuff(cls, data):
        # use data parsed from a config file to calculate new data
        ...

Здесь метод __init__ настолько минимален, насколько это возможно. Сразу понятно, что Configuration необходимо хранить два атрибута. __init__ не касается того, как мы получаем данные для этих двух атрибутов.

Вместо передачи пути к файлу в конструктор теперь у нас есть фабричный метод from_file, реализованный как classmethod. Мы также преобразовали наши методы анализа и вычислений в classmethods, которые теперь принимают входные данные и возвращают результаты. Данные, возвращаемые этими методами, передаются конструктору, и возвращается результирующий объект.

Преимущества этого подхода:

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

Вы также можете рассмотреть возможность полного отделения кода создания от самого класса, например. перенося логику в функцию или в класс Factory.

Строители — это альтернатива фабрикам, когда вам нужен высокий уровень настройки при создании ваших объектов. Идея состоит в том, чтобы использовать вспомогательный объект «строитель» с сохранением состояния, который вы модифицируете, вызывая для него различные методы. Затем, когда желаемое состояние достигнуто, вызов метода типа build создаёт фактический интересующий объект. Когда вы обнаружите, что вам нужно много аргументов или много логической логики в фабричном методе, вы можете рассмотреть шаблон построителя. Обратной стороной конструктора является то, что его сложнее протестировать.

К сожалению, фабричные методы и построители довольно редки в базах кода Python, по крайней мере, в API, ориентированных на пользователя. Многие питонисты ожидают, что объекты всегда будут создаваться путем прямого вызова конструктора, и это отражено в API большинства популярных библиотек. Обычно вы хотите предоставить пользователям API, с которым они знакомы. В этом случае вы все равно можете использовать некоторые стратегии, описанные выше, но реализовать собственный метод __new__, чтобы предоставить знакомый API создания.

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

  1. Динамическая типизация: переменные могут изменить тип в любое время.
  2. Нет инкапсуляции: все атрибуты общедоступны.
  3. Нет неизменяемости: большинство атрибутов изменяемы.

Это означает, что по умолчанию новым переменным экземпляра может быть присвоено любое значение любому объекту в любое время любым другим объектом. Это здорово, когда нужно найти быстрое решение. В Python никогда не возникает «необходимости» думать о фабриках или сборщиках; вы просто собираете его на лету! Однако это ужасно для создания поддерживаемого кода. Очень сложно отлаживать и рассуждать о состоянии программы, когда из кода не ясно, где создается или изменяется состояние.

Существует ряд стратегий улучшения, и все они предполагают наложение ограничений на Python.

Во-первых, чтобы решить некоторые проблемы с динамической типизацией, вам следует внедрить статическую проверку типов с помощью mypy и использовать строгие настройки. Mypy достаточно хорошо осведомлен о состоянии объекта, т. е. о том, какие переменные определены в объекте и какие типы им присвоены в методе __init__ method. Mypy можно настроить так, чтобы запретить все другие новые назначения переменных. Это должно защитить от некоторых худших ошибок во время выполнения, таких как вызов методов, которые используют несуществующие атрибуты или имеют значение None. Mypy также не позволяет менять тип переменной, поэтому вы не можете небрежно относиться к типам Optional, т. е. вы не можете просто инициализировать переменные как None и позже присвоить им что-то еще. В конечном итоге статический анализ типов поможет вам выявить проблемы в дизайне: если вы не можете удовлетворить mypy, вам, вероятно, следует переосмыслить свою архитектуру.

Во-вторых, чтобы улучшить инкапсуляцию, сделайте все переменные экземпляра закрытыми, то есть к ним можно получить доступ только методами самого объекта. На самом деле это невозможно реализовать в Python, но по соглашению любой атрибут, начинающийся с _, считается закрытым. Поэтому, если вы обнаружите, что используете метод или переменную, начинающаяся с _, вне методов объекта, вам следует пересмотреть свой дизайн. Языковые серверы и IDE будут соблюдать соглашение _ и не будут отображать эти методы или переменные в меню автозаполнения (если вы явно не введете _). Вы можете сделать переменные экземпляра почти полностью частными, поставив перед ними префикс __.

В-третьих, по возможности выбирайте неизменяемость. Статическое состояние гораздо легче рассуждать, чем мутирующее состояние. Обеспечение неизменности может быть достигнуто несколькими способами. Если вы используете частные переменные экземпляра и предоставляете их только с помощью метода получения, это способствует неизменяемости. Вы также можете попытаться использовать неизменяемые структуры данных, такие как кортежи, вместо списков. Если вы не можете выбрать между dataclass или NamedTuple, следует отдать предпочтение NamedTuple, поскольку его поля неизменяемы.

Применяя эти дополнительные предложения к нашему предыдущему примеру, мы приходим к следующему:

from __future__ import annotations


def Configuration:
    def __init__(self, attr1: int, attr2: int) -> None:
        self._attr1 = attr1
        self._attr2 = attr2

    @property
    def attr1(self) -> int:
        return self._attr1

    @property
    def attr2(self) -> int:
        return self._attr2
        
    @classmethod
    def from_file(cls, filepath: str) -> Configuration:
        parsed_data = cls._parse_config_file(filepath)
        computed_data = cls._precompute_stuff(parsed_data)
        return cls(
            attr1=parsed_data,
            attr2=computed_data,
        )

    @classmethod
    def _parse_config_file(cls, filepath: str) -> int:
        # parse the file in filepath and return the data    
        ...

    @classmethod
    def _precompute_stuff(cls, data: int) -> int:
        # use data parsed from a config file to calculate new data
        ...

Краткое содержание

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

Я работаю исследователем, специалистом по обработке данных и разработчиком научного программного обеспечения в VITO, в основном использую Python. Раньше я также работал на предприятии консультантом по разработке данных. Мнения, выраженные в этих статьях, являются исключительно моими и не отражают точку зрения моих нынешних или прошлых работодателей. Посетите мой личный блог, где я время от времени пишу о вещах, которые меня интересуют.