[Перевод] Вероятно, вы неправильно используете метод __init__ в Python
Почему вам следует делать ваши конструкторы простыми
Многие методы __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")
Создание объекта следует синтаксису
. В нашем случае метод __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.
...
def _precompute_stuff(self):
# использовать переменные, определенные в
# self._parse_config_file, для вычисления и установки
# новых переменных экземпляра
...
Но что в этом плохого? Две вещи:
1. Очень сложно судить о состоянии объекта при его создании. Какие переменные экземпляра определены и каковы их значения? Чтобы это выяснить, мы должны пройти всю иерархию функций инициализации и принять во внимание любые присвоения self.
. В этом фиктивном примере это всё ещё возможно, но я видел примеры, где вызываемый в __init__
код состоит из более чем 1000 строк и включает методы, вызываемые из суперкласса.
2. Логика создания теперь жёстко запрограммирована. Нет другого способа создать объект 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.