Как упростить себе жизнь или почему ты должен уметь создавать объекты правильно?

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

В вопросе "как создавать объект?" я сторонник эволюционного подхода. Я не стремлюсь использовать порождающие паттерны при первой возможности. У меня есть простой набор вопросов самому себе, который помогает мне принять решение, стоит ли изменить способ создания объекта или нет.

Для чего вообще создавать объекты как-то, кроме стандартной передачи всех параметров в __init__ ? Работает же!

class SomeObject:
    def __init__(self, arg_1: str, arg_2: int) -> None:
        self._attr_1 = arg_1
        self._attr_2 = arg_2

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

Давайте для начала посмотрим несколько примеров проблем, которые нам помогают решать порождающие паттерны проектирования:

  • При сложной инициализации объекта - паттерн Builder позволяет отделить процесс пошагового конструирования от инициализации самих объектов.

  • Контроль над количеством экземпляров может быть осуществлён паттернами Singleton или Multiton. Они обеспечивают создание требуемого кол-ва экземпляров и управление ими.

  • Для поддержки семейства взаимосвязанных объектов подойдёт паттерн Abstract Factory, предоставляющий интерфейс для создания семейств связанных или зависимых объектов без указания их конкретных классов.

  • Для динамического создания объектов можно использовать паттерны Factory Method и Prototype, которые как раз позволяют динамически определять и создавать новые объекты в соответствии с конкретными требованиями.

И ещё посмотрим на то, как эти паттерны могут улучшить наш код:

  • Уменьшение зависимости от конкретных классов

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

  • Контроль над процессом создания объектов

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

  • Оптимизация использования ресурсов

    Некоторые порождающие паттерны (например, singleton, prototype, flyweight, lazy initialization или object pool) помогают оптимизировать использование ресурсов(память или процессорное время), избегая избыточного, несвоевременного создания объектов и повторного использования уже созданных экземпляров.

  • Обеспечение инкапсуляции

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

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

Мы начнём с очень простого примера и постепенно будем выдумывать требования, которые будут подталкивать нас к вопросу: "не стоит ли начать создавать этот объект иначе?"

  1. Нам нужно DTO для создания данных запроса и последующей передаче его клиенту, который отправит этот запрос.

from typing import Any


class PriceRequestData:
    def __init__(
        self,
        method: str,
        url: str,
        data: dict[str, Any] | None = None,
    ) -> None:
        self._method = method
        self._url = url
        self._data = data


class PricingClient(AbstractPricingClients):
    def get_price(self, request_data: PriceRequestData) -> Decimal:
        ...

Пока всё хорошо.

  1. Через какое-то время оказалось, что на этапе создания данных запроса нам также нужно указывать content_type.

При правках объекта на стадии, когда сложность создания не выходит за __init__, первый вопрос, который я задаю себе - Становится ли __init__ перегружен и не попала ли туда логика?(вместо логика я обычно говорю "математика". Это когда c входящими аргументами или на их основе нужно сделать что-то ещё, прежде чем создать объект).

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

class PriceRequestData:
    def __init__(
        self,
        method: str,
        url: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
    ) -> None:
        self._method = method
        self._url = url
        self._content_type = content_type
        self._data = data

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

  1. Представим, что нам также можно добавить headers, но если это post запрос, то нам обязательно нужен некий X-authorization-header(для авторизации в сервисе цен).

Я снова задаю себе вопрос: Становится ли __init__ перегружен и не попала ли туда логика? И в этот раз оказывается, что попала.

class PriceRequestData:
    def __init__(
        self,
        method: str,
        url: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ) -> None:
        self._method = method
        self._url = url
        self._content_type = content_type
        self._data = data
        if method == "POST" and not headers.get("X-authorization-id"):
            raise PriceRequestDataException("Authorization headers not found.")
        self._headers = headers

Как видите, на этапе инициализации объекта, у нас появилась дополнительная логика, основанная на входных атрибутах.

Другой пример:

class PriceRequestData:
    def __init__(
        self,
        method: str,
        url: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
    ) -> None:
        if method not in ["POST", "GET"]:
            raise PriceRequestDataException("Method name not valid.")
        self._method = method
        self._url = url
        self._content_type = content_type
        self._data = data

Здесь мы проверяем валидность входных параметров.

Ещё пример, когда одни аргументы получаются на основании других аргументов:

class PriceRequestData:
    def __init__(
        self,
        method: str,
        path: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
    ) -> None:
        self._method = method
        self._url = urljoin(settings.pricing_host, path)
        self._content_type = content_type
        self._data = data

Тут я вижу сразу две проблемы. Первая, "математика" получения _url. Вторая, обращение из __init__ к модулю настроек(settings). Не всегда можно красиво убрать обращение к настройкам, но я стараюсь вынести это обращение как можно выше(ближе к контроллеру), чтобы лишний раз мои объекты не зависели от конфигурации приложения напрямую.

Вот мы и подобрались к первому этапу эволюции. Нам нужно куда-то вынести "математику" из __init__ , но куда?

Ответ прост, но зависит от вида объекта. Не любой объект в коде, который я создаю по умолчанию начинается с интерфейса или абстрактного класса. Иногда этого просто не нужно, как в нашем примере с PriceRequestData. Если бы наш объект имел определённый интерфейс, полученный от абстракции, то мы бы использовали паттерн factory method.

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

class SomeObject(ABC):
    def execute(self) -> None:
        ...

    @abstractmethod
    def make(self):
        raise NotImplementedError()


class FirstConcreteObject(SomeObject):
    def make(self) -> FirstConcreteObject:
        return FirstConcreteObject()


class SecondConcreteObject(SomeObject):
    def make(self) -> SecondConcreteObject:
        return SecondConcreteObject()

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

Вернёмся к тому, что наш класс не является частью иерархии наследования, а значит, мы можем использовать любой требуемый нам формат создания. Используемый мной паттерн называется static factory method.

class PriceRequestData:
    def __init__(
        self,
        method: str,
        url: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ):
        self._method = method
        self._url = url
        self._content_type = content_type
        self._data = data
        self._headers = headers
       
    @classmethod
    def make(
        cls,
        method: str,
        path: str,
        host: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ) -> PriceRequestData:
        if method not in ["POST", "GET"]:
            raise PriceRequestDataException("Method name not valid.")
        if method == "POST" and not headers.get("X-authorization-id"):
            raise PriceRequestDataException("Authorization headers not found.")
        
        return cls(
            method=method,
            url=urljoin(host, path),
            content_type=content_type,
            data=data,
            headers=headers,
        )

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

Очень плохой способ скрыть сложность создания:

class PriceRequestData:
    def __init__(
        self,
        method: str,
        host: str,
        path: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ):
        self._method = method
        self._content_type = content_type
        self._data = data
        self._headers = headers
        self._validate()
        self._post_init(host, path)
    
    def _validate(self) -> None:
        if self._method not in ["POST", "GET"]:
            raise PriceRequestDataException("Method name not valid.")
        if self._method == "POST" and not self._headers.get("X-authorization-id"):
            raise PriceRequestDataException("Authorization headers not found.")
    
    def _post_init(self, host: str, path: str) -> None:
        self._url = urljoin(host, path)

Мало того что, кроме инициализации мы валидируем данные(-SRP), мы дополнительно размазываем конструирование нашего объекта. Теперь, не посмотрев метод _post_init а, кто-то может сделать это ещё сложнее, мы банально не знаем, какое состояние будет храниться в экземпляре нашего класса(какие атрибуты могут быть).

Главное правило, как только создание объекта выходит за рамки __init__ - тот, кто наделён ответственностью создавать объект должен, инкапсулировать в себе ВСЮ сложность создания этого объекта.

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

    4.1 Требуется ли несколько способов создания объекта?
    4.2 У нас большое кол-во логики создания объекта в статическом фабричном методе?
    4.3 Для создания нам нужен доступ к другим объектам/механизмам?

Если ответ на любой из этих вопросов - "да", то смело можно переходить к следующему этапу эволюции. Этот этап - отдельная фабрика(класс/функция в зависимости от нужды)

Отдельной фабрикой я называю вынос сложности создания объекта в отдельный объект

def make_price_request_data(
    method: str,
    path: str,
    host: str,
    content_type: ContentTypeEnum,
    data: dict[str, Any] | None = None,
    headers: dict[str, Any] | None = None,
) -> PriceRequestData:
    if method not in ["POST", "GET"]:
        raise PriceRequestDataException("Method name not valid.")
    if method == "POST" and not headers.get("X-authorization-id"):
        raise PriceRequestDataException("Authorization headers not found.")
    
    return PriceRequestData(
        method=method,
        url=urljoin(host, path),
        content_type=content_type,
        data=data,
        headers=headers,
    )

Или

class PriceRequestDataFactory:
    def make(
        self,
        method: str,
        path: str,
        host: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ) -> PriceRequestData:
        if method not in ["POST", "GET"]:
            raise PriceRequestDataException("Method name not valid.")
        if method == "POST" and not headers.get("X-authorization-id"):
            raise PriceRequestDataException("Authorization headers not found.")
        
        return self._make(
            method=method,
            host=host,
            path=path,
            content_type=content_type,
            data=data,
            headers=headers,
        )
    
    def make_base(self) -> PriceRequestData:
        # Логика получения данных
        # data = ...
        return self._make(**data)
    
    def make_many_from_csv(self, file_path: str) -> list[PriceRequestData]:
        # Логика получения данных
        # datasets = ...
        return [self._make(**data) for data in datasets]
    
    def _make(
        self,
        method: str,
        path: str,
        host: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ) -> PriceRequestData:
        return PriceRequestData(
            method=method,
            url=urljoin(host, path),
            content_type=content_type,
            data=data,
            headers=headers,
        )

Вернёмся к трём вопросам:

  • Нужно ли нам несколько способов создания объекта?
    - Да!
    Ответ, как это сделать - PriceRequestDataFactory

  • У нас большое кол-во логики создания объекта в статическом фабричном методе?
    - Да!
    Тут, думаю особых примеров не нужно, чем более сложное создание объекта, тем больше вас должно заботить то, что следует вынести ответственность в отдельную фабрику.

  • Для создания нам нужен доступ к другим объектам/механизмам?
    - Да!
    А вот такой пример, как раз следует далее:

class PriceRequestDataFactory:
    def __init__(
        self,
        price_request_validator: AbstractPriceRequestValidator,
    ) -> None:
        self._price_request_validator = price_request_validator
        
    def make(
        self,
        method: str,
        path: str,
        host: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ) -> PriceRequestData:
        self._price_request_validator.validate(
            method=method,
            path=path,
            host=host,
            content_type=content_type,
            data=data,
            headers=headers,
        )
        
        return PriceRequestData(
            method=method,
            url=urljoin(host, path),
            content_type=content_type,
            data=data,
            headers=headers,
        )

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

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

В моей практике были такие примеры, что, прежде чем создать объект, нужно "сходить" в другой сервис и проверить, точно ли мы можем создать такой объект. Проверка происходила за счёт валидации конкретного поля(contract_name). Сущность с таким значением должна была быть создана(в базе другого сервиса), что бы мы могли создавать у себя подобный объект в коде.

Вот и подошла к концу первая часть статьи про эволюционный подход к созданию объектов. Во второй части мы продолжим и пойдём ещё дальше, затронув такие порождающие паттерны, как - builder, builder with director, abstract factory и может быть другие.

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

Напоследок покажу, что имею в виду(Не делайте так??):

class PriceRequestData:
    def __init__(
        self,
        method: str,
        url: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ):
        self._method = method
        self._url = url
        self._content_type = content_type
        self._data = data
        self._headers = headers
    
    @classmethod
    def make(
        cls,
        method: str,
        path: str,
        host: str,
        content_type: ContentTypeEnum,
        data: dict[str, Any] | None = None,
        headers: dict[str, Any] | None = None,
    ) -> PriceRequestData:
        return cls(
            method=method,
            url=urljoin(host, path),
            content_type=content_type,
            data=data,
            headers=headers,
        )


def make_price_request_data(
    method: str,
    path: str,
    host: str,
    content_type: ContentTypeEnum,
    data: dict[str, Any] | None = None,
    headers: dict[str, Any] | None = None,
) -> PriceRequestData:
    if method not in ["POST", "GET"]:
        raise PriceRequestDataException("Method name not valid.")
    if method == "POST" and not headers.get("X-authorization-id"):
        raise PriceRequestDataException("Authorization headers not found.")
    
    return PriceRequestData.make(
        method=method,
        host=host,
        path=path,
        content_type=content_type,
        data=data,
        headers=headers,
    )

Спасибо всем, кто осилил статью!

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


  1. Mcublog
    18.05.2024 20:34
    +4

    Здравствуйте, спасибо за статью

    А как в рамках концепций из статьи относитесь к декортатору dataclass и их методу "__post_init__"?

    Часто встречал в коде, что "математику" располагают там.

    Заранее, спасибо


    1. KarmanovichDev Автор
      18.05.2024 20:34
      +1

      Здравствуйте. Рад, что статья оказалась интересной для вас)

      По поводу post_init: Нужно смотреть конкретные примеры, что там происходит.

      Если там происходит создание новых полей на основе тех, которые пришли в init, то в рамках эволюции при создании объекта, мне такой подход нравится меньше.

      Если условная фабрика полностью конструирует объект, то post_init как будто доделывает чью-то работу. Выходит, что он не забирает на себя всю сложность создания. Эта сложность размазывается между init и post_init.

      Учитывая, что они неразрывно связаны, то получается, что это то же самое, что, если бы мы в конце init вызывали post_init(За нас в данном случае это делает механизм dataclass)

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

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

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