Устраняем зависимости между объектами в Python

Хорошо спроектированная программа состоит из объектов, отвечающих принципу единственной обязанности (каждый объект делает только одну вещь, и делает это хорошо). Такие объекты постоянно “общаются” друг с другом. Для такого общения, один объект должен что-то знать о другом объекте, а это вызывает зависимость между ними. И зависимость между объектами неизбежна. Но сильная зависимость приводит к большим проблемам. Изменение одного объекта приводит к изменению другого объекта, а затем и к изменению третьего объекта… Получается спагетти код.

Пример классов с сильной зависимостью:

class Customer:

    def __init__(self, name, email, balance):
        self.name = name
        self.email = email
        self.balance = balance

    def charge(self, amount):
        self.balance -= amount

        mailer = MailService(
            'mail.mailservice.com', 'secret_username', 'secret_password')
        mailer.send(self.email, 'admin@example.com', 'Payment', f'Charging {amount}')


class MailService:

    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password

    def send(self, to, from_, subject, text):
        print(f'Connecting to "{self.host}" '
              f'with "{self.username}:{self.password}"')

        print(f'Sending "{subject}: {text}"'
              f'to "{to}" from "{from_}"')


wayne = Customer('Bruce Wayne', 'brucy@mail.com', 1000)
wayne.charge(50)

У объекта есть зависимость когда он знает:

  • имя другого класса. Класс Customer знает имя класса MailService
  • требуемые аргументы для сообщения и их порядок. Класс Customer знает, что для того, чтобы создать объект класса MailService, ему нужно передать 3 параметра в определенном порядке
  • имя сообщения, которое он собирается отправить еще кому-то кроме себя самого (кроме self). Класс Customer знает, что MailService отзывается на сообщение send

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

Внедрение зависимости (dependency injection)

Почему плохо не внедрять

У объекта есть зависимость когда он знает имя другого класса.

В примере выше, класс Customer в методе charge ссылается на класс MailService. Такая сильная связь между Customer и MailService – безосновательна. Ведь все, что нужно классу Customer – это объект, который будет отзываться на сообщение send(отправить email). Этим объектом может быть экземпляр любого другого класса, в котором определен метод send. Но при такой реализации класса Customer, внедрить такой объект будет невозможно.

Как внедрять

Зависимость между Customer и MailService легко устранить с помощью паттерна “Dependency Injection”. Вместо того, чтобы создавать объект внутри метода Customer.charge, мы внедряем его в класс извне. Внедрить зависимость в класс можно двумя способами:

  • Через конструктор класса:
class Customer:

    def __init__(self, mailer, ...):
        self.mailer = mailer
        ...


mail_service = MailService(
    'mail.mailservice.com', 'secret_username', 'secret_password')
customer = Customer(mail_service, ...)
  • Через параметры метода:
class Customer:

    def charge(self, mailer, ...):
        ...

mail_service = MailService(
    'mail.mailservice.com', 'secret_username', 'secret_password')
customer = Customer(...)
customer.charge(mail_service, ...)

Какой из методов внедрения лучше? Зависит от ситуации. В данном примере разумнее использовать внедрение через параметр, так как при каждом вызове метода charge, мы можем захотеть использовать разные объекты для отправки сообщения на почту. То есть все те объекты, которые отзываются на сообщение send.

Плюсы внедрения

Код легко тестировать

Представьте, что метод send класса MailService отправляет настоящее сообщение на почту. Ваша задача – написать тесты к методу charge класса Customer. Как это сделать?

Так как метод send вызывается при каждом вызове метода charge, то ваши тесты заспамят чью-то почту. Решение – создать фейковый объект класса MailService, который при вызове метода send не будет отправлять настоящее сообщение на почту. Такие фейковые объекты называют mock-объектами.

Как создать mock-объект это тема отдельной статьи. Сейчас важно то, что при тестировании, в методе charge должен использоваться mock-объект, вместо настоящего объекта класса MailService. Это легко сделать когда класс Customer принимает зависимости извне. Все что для этого нужно – это создать mock-объект в тестах и передать его затем в класс. При таком раскладе вы имеете полный контроль над зависимостями класса. Но если объект класса MailService создается внутри метода charge, то заменить этот объект на mock-объект уже сложнее.

Код гибче

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

Для примера, вместо внедрения объекта класса MailService, вы можете внедрить объект класса AnotherMailService, у которого есть метод send, но с иной реализацией.

Не получается внедрить

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

  1. Изолировать внутри конструктора класса:
class Customer:

    def __init__(self, name, email, balance):
        ...
        self.mailer = MailService(
            'mail.mailservice.com', 'secret_username', 'secret_password')
  1. Изолировать внутри свойства/метода:
class Customer:
    ...

    @property
    def mailer(self):
        if not hasattr(self, '_mailer'):
            self._mailer = MailService(
                'mail.mailservice.com', 'secret_username', 'secret_password')
        return self._mailer

Раньше зависимость была спрятана глубоко в методе charge, а теперь сразу видно, что Customer зависит от MailService. Такой код проще изменять.

Использование именованных параметров

У объекта есть зависимость когда он знает требуемые аргументы для сообщения и их порядок.

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

Такая зависимость приводит к тому, что при каждом изменении порядка аргументов в методе, придется вносить изменения там, где этот метод вызывается.

От этой зависимости легко избавиться с помощью именованных параметров:

# без именованных параметров
wayne = Customer('Bruce Wayne', 'brucy@mail.com', 1000)

# с именованными параметрами
wayne = Customer(
    name='Bruce Wayne',
    email='brucy@mail.com',
    balance=1000)

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

Когда использовать

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

Изоляция внешних сообщений

У объекта есть зависимость когда он знает имя сообщения, которое он собирается отправить еще кому-то кроме себя самого (кроме self).

В каких-то ситуацией имеет смысл устранить зависимость и от внешних сообщений, отправляемых другому объекту. В методе charge класса Customer известно, что объект mailer должен откликнуться на сообщение send:

class Customer:
    ...

    def charge(self, amount):
        ...
        self.mailer.send(
            to=self.email,
            from_='admin@example.com',
            subject='Payment',
            text=f'Charging {amount}')

В нашем примере, метод send объекта mailer используется только в методе charge. Если метод send вызывался бы в нескольких местах, то при каждом изменении имени метода send или его аргументов, пришлось бы делать изменения везде, где метод sendвызывается. Эту зависимость можно частично устранить, если вынести вызов метода send в собственный метод класса Customer:

class Customer:
    ...

    def send_email(self, subject, text):
        self.mailer.send(
            to=self.email,
            from_='admin@example.com',
            subject=subject,
            text=text)

    def charge(self, amount):
        ...
        self.send_email('Payment', f'Charging {amount}')

Заключение

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

Внедрение зависимости – легкий способ избавиться от зависимости от конкретного класса. Этот паттерн делает код гибче и легко тестируемым. Зависимость внедряется через конструктор класса, либо через параметр метода. Если по каким-то причинам не удается внедрить зависимость, то изолируйте ее в конструкторе класса, либо в свойстве/методе.

Для устранения зависимости от порядка следования аргументов используйте именованные параметры. Бонус использования – код становится читабельнее.

В каких-то ситуацией есть смысл устранить зависимость и от внешних сообщений, отправляемых другому объекту. Для этого вынесете вызов сообщения в отдельный метод.

1 Comment

  1. Konstantin April 16, 2019 at 6:58 pm

    Отличные статьи! Все перечитал. Спасибо большое! Жду новых.

    Reply

Leave a Reply to Konstantin Cancel reply

Your email address will not be published. Required fields are marked *