Применение принципа единой ответственности в Python
Принцип единой ответственности (или SRP) является одним из наиболее важных понятий в разработке программного обеспечения. Основная идея этой концепции: все части программного обеспечения должны нести единственную ответственность.
Почему SRP важен? Это основная идея, стоящая за разработкой программного обеспечения. Разложите сложные задачи на набор простых строительных блоков, чтобы снова составить из них сложное программное обеспечение. Точно так же, как мы можем составить лего или встроенные функции:
print(int(input('Input number: ')))
Эта статья проведет вас через сложный процесс написания простого кода. Лично я считаю эту статью довольно сложной и трудной для восприятия, если у вас нет твердого бекграунда в Python, поэтому она разбита на несколько частей:
- Определение простого строительного блока
- Проблемы с функциональной композицией в Python
- Введение в вызываемые объекты для решения задач функциональной композиции
- Внедрение зависимостей уменьшает стандартный код вызываемых объектов
Определение строительных блоков
Давайте начнем с определения того, что это за «части программного обеспечения» и «самые простые строительные блоки», о которых я говорю?
Самыми простыми строительными блоками обычно являются выражения и операторы языка. Мы можем буквально составить все из этого. Но мы не можем полностью полагаться на них, поскольку они слишком просты. И мы не хотим повторять код повсюду. Таким образом, мы изобретаем функции, чтобы абстрагировать эти простейшие языковые конструкции в нечто более значимое, с чем мы можем фактически работать
Мы ожидаем, что эти простейшие строительные блоки (читай «функции») будут компонуемыми. И чтобы быть легко составляемыми, они должны соблюдать принцип единой ответственности. Иначе у нас были бы проблемы. Потому что вы не можете сочинять вещи, которые делают несколько вещей, когда вам нужна только их часть.
Функции тоже могут быть сложными
Теперь давайте удостоверимся, что мы действительно можем полагаться на функции как простые строительные блоки.
Мы, наверное, уже знаем, что функции тоже могут становиться сложными, и мы все видели абсолютно нечитаемые функции, подобные этой:
def create_objects(name, data, send=False, code=None): data = [r for r in data if r[0] and r[1]] keys = ['{}:{}'.format(*r) for r in data] existing_objects = dict(Object.objects.filter( name=name, key__in=keys).values_list('key', 'uid')) with transaction.commit_on_success(): for (pid, w, uid), key in izip(data, keys): if key not in existing_objects: try: if pid.startswith('_'): result = Result.objects.get(pid=pid) else: result = Result.objects.filter( Q(barcode=pid) | Q(oid=pid)).latest('created') except Result.DoesNotExist: logger.info("Can't find result [%s] for w [%s]", pid, w) continue try: t = Object.objects.get(name=name, w=w, result=result) except: if result.container.is_co: code = result.container.co.num else: code = name_code t = Object.objects.create( name=name, w=w, key=key, result=result, uid=uid, name_code=code) reannounce(t) if result.expires_date or ( result.registry.is_sending and result.status in [Result.C, Result.W]): Client().Update(result) if not result.is_blocked and not result.in_container: if send: if result.status == Result.STATUS1: Result.objects.filter( id=result.id ).update( status=Result.STATUS2, on_way_back_date=datetime.now()) else: started(result) elif uid != existing_objects[key] and uid: t = Object.objects.get(name=name, key=key) t.uid = uid t.name_code = name_code t.save() reannounce(t)
Это действительно работает и питает чью-то производственную систему. Тем не менее, мы все еще можем сказать, что эта функция определенно несет более одной ответственности и должна быть реорганизована Но как мы принимаем это решение?
- Цикломатическая сложность
- Холстед сложность
- Аргументы, высказывания, возврат
- Пределы длины тела
После того, как мы применим эти методы, нам станет ясно, что эта функция слишком сложна. И мы не сможем ей легко воспользоваться. Можно (и рекомендуется) пойти дальше и автоматизировать этот процесс. Вот как инструменты качества кода работают с wemake-python-styleguide в качестве яркого примера.
Просто используйте это. Он обнаружит всю скрытую сложность и не позволит вашему коду сгнить.
Вот менее очевидный пример функции, которая делает несколько вещей и нарушает SRP (и, к сожалению, такие вещи вообще не могут быть автоматизированы, обзоры кода - единственный способ найти такие проблемы):
def calculate_price(products: List[Product]) -> Decimal: """Возвращает итоговую цену всех выбранных товаров (в рублях)""" price = 0 for product in products: price += product.price logger.log('Final price is: {0}', price) return price
Посмотрите на эту переменную логгера. Как он попал в тело функции? Это не аргумент. Это просто жестко запрограммированное поведение. Что если я не хочу регистрировать эту конкретную цену по какой-либо причине? Должен ли я отключить его с флагом аргумента?
В случае, если я попытаюсь сделать это, я получу что-то вроде этого:
def calculate_price(products: List[Product], log: bool = True) -> ... ...
Поздравляем, теперь у нас есть хорошо известный анти-шаблон в нашем коде. Не используйте логические флаги. Они плохие.
Кроме того, как я могу проверить эту функцию? Без этого вызова logger.log это была бы абсолютно тестируемая чистая функция. Что-то входит, и я могу предсказать, что выйдет. И теперь это нечисто. Чтобы проверить, что logger.log действительно работает, мне нужно каким-то образом его смоделировать и утверждать, что журнал был создан.
Вы можете утверждать, что logger в python имеет глобальную конфигурацию только для этого случая. Но это грязное решение той же проблемы.
Такой беспорядок только из-за одной строки! Проблема с этой функцией заключается в том, что трудно заметить эту двойную ответственность. Если мы переименуем эту функцию из Calculate_price в надлежащий calc_and_log_price, станет очевидным, что эта функция не поддерживает SRP. А правило простое: если «правильное и полное» имя функции содержит and, or, или then - это хороший кандидат на рефакторинг.
Хорошо, это все страшно и все такое, но что делать с этим делом в целом? Как мы можем изменить поведение этой функции, чтобы она в конечном итоге учитывала SRP?
Я бы сказал, что единственный способ достичь SRP - это композиция: составлять разные функции вместе, чтобы каждая из них выполняла только одно, а их композиция - все, что мы хотим.
Давайте рассмотрим различные шаблоны, которые мы можем использовать для создания функций в Python.
Декораторы
Мы можем использовать шаблон декоратора для составления функций вместе.
@log('Final price is: {0}') def calculate_price(...) -> ...: ...
Какие последствия имеет этот паттерн?
- Он не только составляет, но и склеивает функции. Таким образом, у вас не будет возможности фактически запустить просто Calculate_price без журнала.
- Это статично. Вы не можете изменить вещи с точки вызова. Или вы должны передать аргументы функции декоратора перед фактическими параметрами функции
- Это создает визуальный шум. Когда количество декораторов будет расти - это будет загрязнять наши функции огромным количеством лишних строк
В общем, декораторы имеют смысл в определенных ситуациях, но не подходят для других. Хорошие примеры: @login_required, @contextmanager и другие.
Функциональная композиция
Он очень похож на шаблон декоратора, за исключением того, что он применяется во время выполнения, а не во время импорта.
from logger import log def controller(products: List[Product]): final_price = log(calculate_price, message='Price is: {0}')(products) ...
- При таком подходе мы можем легко вызывать функции так, как мы на самом деле хотим их вызывать: с нашей частью без журнала
- С другой стороны, он создает больше шаблонного и визуального шума
- Трудно провести рефакторинг из-за большого количества шаблонов и потому, что вы делегируете композицию вызывающей стороне вместо объявления
Но это также работает для некоторых случаев. Например, я постоянно использую функцию @safe:
from returns.functions import safe user_input = input('Input number: ') # Следующая строка не вызовет никаких исключений: safe_number = safe(int)(user_input)
Подробнее о том, почему исключения могут быть вредны для вашей бизнес-логики, вы можете прочитать в отдельной статье. Мы также предоставляем в типе возвращаемой библиотеки утилиту для безопасного создания типов, которую вы можете использовать для компоновки во время выполнения.
Передача аргументов
Мы всегда можем просто передать аргументы. Так легко!
def calculate_price( products: List[Product], callback=Callable[[Decimal], Decimal], ) -> Decimal: """Возвращает итоговую цену всех выбранных товаров (в рублях).""" price = 0 for product in products: price += product.price return callback(price)
И тогда мы можем вызвать это:
from functools import partial from logger import log price_log = partial(log, 'Price is: {0}') calculate_price(products_list, callback=price_log)
И это прекрасно работает. Теперь наша функция ничего не знает о регистрации. Он только рассчитывает цену и возвращает ее. Теперь мы можем предоставить любой обратный вызов, а не просто список. Это может быть любая функция, которая получает один десятичный знак и возвращает один обратно:
def make_discount(price: Decimal) -> Decimal: return price * 0.95 calculate_price(products_list, callback=make_discount)
Теперь нет проблем, просто составьте функции так, как вам нравится. Скрытый недостаток этого метода заключается в природе аргументов функции. Мы должны явно передать их. И если стек вызовов огромен, нам нужно передать много параметров различным функциям. И потенциально покрывают разные случаи: нам нужен обратный вызов A в случае a и обратный вызов B в случае b.
Конечно, мы можем попытаться как-то их пропатчить, создать больше функций, которые возвращают больше функций или повсеместно загрязнять наш код декораторами @inject, но я думаю, что это ужасно.
Нерешенные проблемы:
- Смешанные логические аргументы и аргументы зависимости, потому что мы передаем их вместе, и трудно сказать, что к чему
- Явные аргументы, которые трудно или невозможно поддерживать, если ваш стек вызовов огромен
Чтобы исправить эти проблемы, позвольте мне познакомить вас с концепцией вызываемых объектов.
Разделение логики и зависимостей
Прежде чем мы начнем обсуждать вызываемые объекты, нам нужно обсудить объекты и ООП в целом, имея в виду SRP. Я вижу главную проблему в ООП как раз в ее основной идее: «Давайте объединим данные и поведение вместе». Для меня это явное нарушение SRP, потому что объекты по замыслу делают две вещи одновременно: они содержат свое состояние и выполняют некоторое прикрепленное поведение. Конечно, мы исправим этот недостаток с помощью вызываемых объектов.
Вызываемые объекты выглядят как обычные объекты с двумя открытыми методами: __init__ и __call__. И они следуют определенным правилам, которые делают их уникальными:
- Обрабатывать только зависимости в конструкторе
- Обрабатывать только логические аргументы в методе __call__
- Нет изменяемого состояния
- Нет других открытых методов или каких-либо открытых атрибутов
- Нет родительских классов или подклассов
Прямой способ реализации вызываемого объекта - что-то вроде этого:
class CalculatePrice(object): def __init__(self, callback: Callable[[Decimal], Decimal]) -> None: self._callback = callback def __call__(self, products: List[Product]) -> Decimal: price = 0 for product in products: price += product.price return self._callback(price)
Основное различие между вызываемыми объектами и функциями заключается в том, что вызываемые объекты имеют явный шаг для передачи зависимостей, в то время как функции смешивают обычные логические аргументы с зависимостями (вы уже можете заметить, что вызываемые объекты являются лишь частным случаем применения частичной функции):
# Обычные функции смешивают обычные аргументы с зависимостями: calculate_price(products_list, callback=price_log) # Вызываемые объекты сначала обрабатывают зависимости, а затем обычные аргументы: CalculatePrice(price_log)(products_list)
Но данный пример не следует всем правилам, которые мы налагаем на вызываемые объекты. В частности, они изменчивы и могут иметь подклассы. Давайте исправим это тоже:
from typing_extensions import final from attr import dataclass @final @dataclass(frozen=True, slots=True) class CalculatePrice(object): _callback: Callable[[Decimal], Decimal] def __call__(self, products: List[Product]) -> Decimal: ...
Теперь с добавлением декоратора @final, который ограничивает подклассы этого класса, и декоратора @dataclass с свойствами frozen и slots, наш класс соблюдает все правила, которые мы навязываем в начале.
- Обрабатывать только зависимости в конструкторе. Правда, у нас есть только декларативные зависимости, конструктор для нас создан attrs
- Обрабатывать только логические аргументы в методе __call__. Правда по определению
- Нет изменяемого состояния. Правда, так как мы используем frozen и slots
- Никаких других открытых методов или каких-либо открытых атрибутов. В большинстве случаев мы не можем иметь открытые атрибуты, объявляя свойство slots и декларативные атрибуты защищенного экземпляра, но у нас все еще могут быть открытые методы. Рассмотрите использование линтера для этого
- Нет родительских классов или подклассов. Правда, мы явно наследуем от объекта и помечаем этот класс как финальный, поэтому любые подклассы будут ограничены
Теперь он может выглядеть как объект, но это, безусловно, не реальный объект. Он не может иметь никакого состояния, открытых методов или атрибутов. Но он прекрасно подходит для принципа единой ответственности. Прежде всего, у него нет данных и поведения. Просто чистое поведение. Во-вторых, сложно таким образом все испортить. У вас всегда будет один метод для вызова всех объектов, которые у вас есть. Просто убедитесь, что этот метод не слишком сложен и делает лишь одну вещь. Помните, никто не мешает вам создавать защищенные методы для декомпозиции поведения __call__.
Внедрение зависимости
Шаблон DI (Dependency injection) широко известен и используется за пределами мира python. Но, почему-то не очень популярен внутри. Я думаю, что это ошибка, которая должна быть исправлена.
Давайте посмотрим на новый пример. Представьте, что у нас есть приложение для отправки открыток. Пользователи создают открытки для отправки их другим пользователям в определенные даты: праздники, дни рождения и т.д. Нас также интересует, сколько из них было отправлено в аналитических целях. Посмотрим, как будет выглядеть этот вариант использования:
from project.postcards.repository import PostcardsForToday from project.postcards.services import ( SendPostcardsByEmail, CountPostcardsInAnalytics, ) @final @dataclass(frozen=True, slots=True) class SendTodaysPostcardsUsecase(object): _repository: PostcardsForToday _email: SendPostcardsByEmail _analytics: CountPostcardInAnalytics def __call__(self, today: datetime) -> None: postcards = self._repository(today) self._email(postcards) self._analytics(postcards)
Далее мы должны вызвать этот вызываемый класс:
# Внедрение зависимостей: send_postcards = SendTodaysPostcardsUsecase( PostcardsForToday(db=Postgres('postgres://...')), SendPostcardsByEmail(email=SendGrid('username', 'pass')), CountPostcardInAnalytics(source=GoogleAnalytics('google', 'admin')), ) # Собственно вызов: send_postcards(datetime.now())
Проблема хорошо видна в этом примере. У нас есть много шаблонов, связанных с зависимостями. Каждый раз, когда мы создаем экземпляр SendTodaysPostcardsUsecase - мы должны создавать все его зависимости.
И весь этот шаблон кажется излишним. Мы уже указали все типы ожидаемых зависимостей в нашем классе. И переходные зависимости в зависимости нашего класса, и так далее. Почему мы должны дублировать этот код еще раз?
На самом деле, мы не должны. Мы можем использовать какую-то структуру DI. Я могу лично рекомендовать dependencies или punq. Их основное отличие заключается в том, как они разрешают зависимости: dependencies используют имена, а punq использует типы. Мы пошли бы с punq для этого примера.
Не забудьте установить его:
pip install punq
Теперь наш код может быть упрощен, поэтому нам не придется связываться с зависимостями. Мы создаем единое место, где регистрируются все зависимости:
# project/implemented.py import punq container = punq.Container() # Низкоуровневые зависимости: container.register(Postgres) container.register(SendGrid) container.register(GoogleAnalytics) # Промежуточные зависимости: container.register(PostcardsForToday) container.register(SendPostcardsByEmail) container.register(CountPostcardInAnalytics) # Конечные зависимости: container.register(SendTodaysPostcardsUsecase)
И затем используйте это везде:
from project.implemented import container send_postcards = container.resolve(SendTodaysPostcardsUsecase) send_postcards(datetime.now())
Там буквально нет повторяющихся шаблонов, удобочитаемости и безопасности типов из коробки. Теперь нам не нужно вручную связывать какие-либо зависимости. Они будут связаны аннотациями от punq. Просто введите декларативные поля в вызываемых объектах так, как вам нужно, зарегистрируйте зависимости в контейнере, и вы готовы к работе.
Конечно, для улучшения Inversion of Control есть несколько продвинутых шаблонов, но это лучше описано в документации punq.
Когда не стоит использовать вызываемые объекты
Совершенно очевидно, что все концепции программирования имеют свои ограничения.
Вызываемые объекты не должны использоваться на уровне инфраструктуры вашего приложения. Поскольку существует слишком много существующих API, которые не поддерживают этот вид классов и API. Используйте его в своей бизнес-логике, чтобы сделать его более читабельным и понятным.
Заключение
Мы прошли долгий путь. От абсолютно грязных функций, которые делают страшные вещи, до простых вызываемых объектов с внедрением зависимостей, которые соблюдают принцип единой ответственности. Мы обнаружили различные инструменты, практики и шаблоны на этом пути.
Самый важный вопрос для себя: стал мой код лучше после всего этого рефакторинга?
Мой ответ: да. Это внесло значительные изменения для меня. Я могу с легкостью собрать простые строительные блоки в сложные варианты использования. Он прост, тестируем и читаем.
Как вы думаете? Поделитесь своим мнением в комментариях ниже.
Подитожим:
- Используйте простые строительные блоки, которые легко составить
- Чтобы быть составными, все сущности должны отвечать только за одну вещь
- Используйте инструменты качества кода, чтобы убедиться, что эти блоки действительно «просты»
- Чтобы сделать вещи высокого уровня ответственными только за одну вещь - используйте состав простых блоков
- Для обработки зависимостей композиции используйте вызываемые объекты
- Использовать dependency injection для уменьшения шаблонного состава
Перевод статьи: Enforcing Single Responsibility Principle in Python
Источник: sobolevn.me