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

Многие методы __init__ представляют собой сложный лабиринт.
Многие методы __init__ представляют собой сложный лабиринт.

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>(<аргументы, передаваемые в __init__>). В нашем случае метод __init__ принимает два аргумента, которые хранятся как переменные экземпляра. После создания объекта можно вызывать методы, использующие эти данные.

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

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

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

class 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):
       # распарсить файл self.filepath, и сохранить
       # данные в нескольких переменных self.<attr>     
       ...


   def _precompute_stuff(self):
       # использовать переменные, определенные в
       # self._parse_config_file, для вычисления и установки
       # новых переменных экземпляра
       ...

Но что в этом плохого? Две вещи:

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

  1. Логика создания теперь жёстко запрограммирована. Нет другого способа создать объект Configuration, кроме как указать путь к файлу, поскольку для создания объекта всегда необходимо пройти через метод __init__. На данный момент мы всегда можем создать Configuration из файла, но кто сказал, что так будет и в будущем? Кроме того, хотя реальному приложению может потребоваться только один способ создания экземпляра, для тестирования может быть удобно создать объект-пустышку, не полагаясь на дополнительный файл.

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

  • Разрешить входной переменной иметь несколько типов, затем проверить, к какому типу относятся входные данные экземпляра и перейти к другой ветке инициализации в зависимости от результата. В нашем примере мы могли бы изменить входную переменную filepath на config и позволить ей быть строкой или словарём, который мы будем интерпретировать соответственно как путь к файлу или уже проанализированные данные.

  • Добавление аргументов, которые переопределяют друг друга. Например, мы могли бы принять оба аргумента config и filepath и игнорировать filepath, если  указан config.

  • Добавление аргументов, которые могут быть логическими значениями или перечислением, для выбора ветвей в логике инициализации. Например, если у нас есть несколько версий одного и того же файла конфигурации, мы можем просто добавить аргумент version в __init__.

  • Добавление *args или **kwargs в __init__, потому что тогда сигнатуру __init__ больше не нужно будет менять, но логика реализации может меняться при необходимости.

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

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

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

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

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

  3. Это значительно упрощает создание объектов различными способами, например путем определения фабричных методов или конструкторов. Также это облегчит тестирование.

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

class 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):
       # разбираем файл по указанному пути и возвращаем данные
       ...


   @classmethod
   def _precompute_stuff(cls, data):
       # используем данные, полученные из конфигурационного файла,
     # для расчета новых данных
       ...

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

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

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

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

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

  • Легче расширять. Мы можем легко реализовать дополнительные фабричные методы для создания объекта Configuration альтернативными способами, например из словаря.

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

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

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

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

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

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

  2. Нет инкапсуляции: все атрибуты общедоступны.

  3. Нет неизменяемости: большинство атрибутов изменяемы.

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

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

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

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

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

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

from __future__ import annotations


class 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:
       # разбираем файл по указанному пути и возвращаем данные
       ...


   @classmethod
   def _precompute_stuff(cls, data: int) -> int:
       # используем данные, полученные из конфигурационного файла,
       # для расчета новых данных
       ...

Заключение

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

Автор перевода: @vladpen


НЛО прилетело и оставило здесь промокод для читателей нашего блога:

-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

Комментарии (19)


  1. CrazyOpossum
    29.11.2023 09:33
    +8

    Примеры неубедительные, что и понятно, потому что они слишком простые. И если они простые, нет нужды упарываться по паттернам, это частая ошибка.
    Ок, допустим, у нас большая сложная инициализация, которую мы хотим упростить. Ну, я бы предпочёл простые поля развернуть как обычно, а объекты передать через Dependency Injection - пока не встречал кода, где этого было бы недостаточно.


    1. themen2
      29.11.2023 09:33

      Можете пример короткий привести?


      1. CrazyOpossum
        29.11.2023 09:33
        +2

        Пример чего? Dependency injection?
        Вместо

        class Backend:
          def __init__(self, config):
            self.adapter = DbAdapter(user=config["user"], password=config["password"])
        
        def main():
          ...
          backend = Backend(config)
          ...
        

        Делаем так

        class Backend:
          def __init__(self, adapter):
            self.adapter = adapter
        
        def main():
          ...
          adapter = DbAdapter(config["uri"], config["password"])
          backend = Backend(adapter)
          ...
        

        Я лично даже стараюсь не использовать конструкцию DbAdapter(config["uri"], config["password"]). Для каждой сущности своя секция в конфиге: DbAdapter(config["database"]). Или DbAdapter(**config["database"]) для самоуверенных.


  1. domix32
    29.11.2023 09:33

    __init__ который вернул None? Что-то подозрительное.


    1. me21
      29.11.2023 09:33
      +1

      А разве возвращаемое значение этого метода как-то используется? Он же не вызывается явно.


    1. vladpen
      29.11.2023 09:33
      +3

      PEP-0484 рекомендует в аннотации типа метода __init__ указывать None. Вероятно, для совместимости.


    1. CrazyOpossum
      29.11.2023 09:33
      +1

      В питоне отсутствие return эквивалентно return None. Если мы аннотируем функции, то возращаемый тип тоже будет type(None) -> None


      1. domix32
        29.11.2023 09:33

        Логично. Я почему-то думал оно неявно инстанс класса возвращает как прочие конструкторы. Но оно же и не конструктор по сути.


        1. fireSparrow
          29.11.2023 09:33
          +1

          Инстанс класса возвращает __new__. А __init__ вызывается уже после него.


        1. WLMike
          29.11.2023 09:33
          +1

          Он инициализирует, в делает и возвращает инстанс __new__


    1. DobrynyaPopovich
      29.11.2023 09:33

      Все верно - __init__ сам по себе не занимается созданием объекта, это делает __new__


    1. Romiss
      29.11.2023 09:33
      +1

      __init__  и должен возвращатьNone иначе будет возбуждено исключение


  1. fishHook
    29.11.2023 09:33
    +4

    Python — объектно-ориентированный язык. Способ создания нового объекта обычно определяется в специальном методе init, реализованном в классе.

    И ведь не смущает автора поста тот факт, что сигнатура метода __ init __ первым аргументом имеет self. Не видно противоречия?

    > Если вы не можете выбрать между dataclass или NamedTuple, следует отдать предпочтение NamedTuple, поскольку его поля неизменяемы.

    Вы это серьезно? Вы не знаете, что у датаклассов есть frozen? Ваши советы откровенно все какие-то наивные

    > @classmethod
    def from_file(cls, filepath: str) -> Configuration:


    Это не будет работать, проверьте


    1. dolfinus
      29.11.2023 09:33

      Это не будет работать, проверьте

      Почему?


      1. HiroX
        29.11.2023 09:33
        +1

        В таком виде потому что не найдет Configuration как тип возвращаемого значения.

        Нужно указывать Self в 3.11+ и имя класса в кавычках как строку если 3.10 и ниже -> "Configuration":


        1. ValeryIvanov
          29.11.2023 09:33
          +4

          Автор статьи тоже не дурак и в самом начале кода прописал from __future__ import annotations , что включает Postponed Evaluation of Annotations


  1. rcrvk
    29.11.2023 09:33

    А TypedDict вместо DataClass/NamedTuple?


  1. Whitenz
    29.11.2023 09:33

    from __future__ import annotations


  1. buriy
    29.11.2023 09:33

    Статью можно свести к тому, что вместо

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

    следует использовать

    class Configuration:
        def __init__(self, filepath=None):
            self.filepath = filepath
            if filepath:
                self._initialize_with_filepath()

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

    Контракт класса определяется архитектором, если он считает, что конфигурация без пути не должна существовать, то значит так оно и есть. А вариант "класс может иметь любое внутреннее состояние" (в т.ч. недопустимое с точки зрения бизнес логики) приводит потом к трудноуловимым ошибкам в дебаге.
    Например, мы заложились на то, что путь у конфигурации всегда есть, а потом словим NoneType error при попытке по этому пути обратиться.
    Или же потом присвоим объекту filepath, забыв сделать инициализацию.
    Получится глупо и больно.
    Так что не надо такой категоричности.