Изображение — Fachrizal Maulana — Unsplash.com

Привет, Хабр! Меня зовут Павел Корсаков, я python-разработчик в облачном провайдере beeline cloud.

Почти на всех собеседованиях задают вопросы про SOLID:

  • Что это такое?

  • Зачем нужен?

  • Как его применяет кандидат?

  • Как понимает принципы из него?

Мы тоже спрашиваем про SOLID, потому что он часто выступает аргументом на ревью. Знание принципов помогает снять градус накала в комментариях под мёрдж-реквестом.

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

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

Принципы SOLID разбираются в последовательности по буквам S, O, L, I, D, что неверно. Акроним сам по себе мнемонический и больше нужен для запоминания, а не для того, чтобы именно в таком порядке разбираться. К тому же такой «последовательный подход» не дает полного понимания SOLID. А вот подача в логической последовательности с закрепляющими примерами и комментариями помогает собрать нужную картинку. Это основной посыл, которым я руководствовался при написании статьи.

Чтобы мой материал не получился очередной статьей про SOLID, я изменю формат подачи и последовательность объяснения принципов. Буду добавлять код небольшими инкрементами и на каждом из них указывать, какие принципы SOLID используются в том или ином случае. 

Это авторский текст (не перевод) с примерами, которые я обычно использую для объяснения принципов SOLID. Приведу только пару ссылок на Википедию про SOLID и сошлюсь на первоисточник butunclebob.com.

Принцип инверсии зависимостей

В моем идеальном мире SOLID начинается с принципа инверсии зависимостей. Википедия нам дает такое определение:

Классы должны зависеть от абстракций, а не от конкретных деталей.

A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Wiki

Это определение совершенно.

«Совершенство достигается не тогда, когда нечего добавить, а когда нечего убрать».

Антуан де Сент-Экзюпери

Я осознанно начинаю с последнего принципа, поскольку это первое, с чем приходится сталкиваться при написании кода. Если у вас нет зависимости на абстракциях, то SOLID не будет полноценным и понять его значительно сложнее.
from abc import ABC, abstractmethod


class AbstractAuthUser(ABC):
    """Абстрактный класс, реализующий обязательные методы."""

    @abstractmethod
    def is_authenticated(self) -> bool:
        """
        Метод проверяет аутентификацию пользователя.
        Возвращает True, если аутентифицирован, и False — если не аутентифицирован
        """

    @abstractmethod
    def get_email(self) -> str:
        """Метод возвращает email пользователя"""

    @abstractmethod
    def get_department(self) -> str:
        """Метод возвращает отдел, в котором работает пользователь"""

Давайте разбираться по порядку. ABC — это класс-помощник, который указывает метакласс metaclass=ABCMeta в качестве параметров класса. 

Вариант class AbstractAuthUser(metaclass=abc.ABCMeta) тоже рабочий, но Python предлагает нам синтаксический сахар, и мы его используем. Оставим первый вариант.

Декоратор abstractmethod гарантирует, что у дочернего класса будут все методы, которые декорированы этим декоратором. Им нужно оборачивать все методы, которые будет использовать бизнес-логика. Разработчики, которые будут обращаться к классу аутентификации, могут быть уверены, что у него всегда есть методы is_authenticated, get_email, get_department, потому что они декорированы abstractmethod, а значит, обязательны для реализации в дочерних классах, унаследованных от абстрактного.

Вот так это выглядит:
from abc import ABC, abstractmethod


class AbstractAuthUser(ABC):
    """Абстрактный класс, реализующий обязательные методы."""

    @abstractmethod
    def is_authenticated(self) -> bool:
        """
        Метод проверяет аутентификацию пользователя.
        Возвращает True, если аутентифицирован, и False — если не аутентифицирован
        """

    @abstractmethod
    def get_email(self) -> str:
        """Метод возвращает email пользователя"""

    @abstractmethod
    def get_department(self) -> str:
        """Метод возвращает отдел, в котором работает пользователь"""


class AuthUserAD(AbstractAuthUser):
    """Класс аутентификации через AD"""


auth = AuthUserAD()

# Traceback (most recent call last):
#  File "/home/pavel/Projects/solid/solid.py", line 38, in <module>
#    auth = AuthUserAD()
#           ^^^^^^^^^^^^
# TypeError: Can't instantiate abstract class AuthUserAD with abstract methods get_department, get_email, is_authenticated

Давайте их объявим.
from abc import ABC, abstractmethod


class AbstractAuthUser(ABC):
    """Абстрактный класс, реализующий обязательные методы."""

    @abstractmethod
    def is_authenticated(self) -> bool:
        """
        Метод проверяет аутентификацию пользователя.
        Возвращает True, если аутентифицирован, и False — если не аутентифицирован
        """

    @abstractmethod
    def get_email(self) -> str:
        """Метод возвращает email пользователя"""

    @abstractmethod
    def get_department(self) -> str:
        """Метод возвращает отдел, в котором работает пользователь"""


class AuthUserAD(AbstractAuthUser):
    """Класс аутентификации через AD"""
    
    def is_authenticated(self) -> bool:
        raise NotImplementedError()

    def get_email(self) -> str:
        raise NotImplementedError()

    def get_department(self) -> str:
        raise NotImplementedError()


auth = AuthUserAD()

В абстрактном классе стоит указывать методы, которые будут вызываться извне. Это своего рода API класса (компонента). В свою очередь методы, которые не должны использоваться извне, не следует указывать в абстрактном классе. При реализации AuthUserAD методы, не являющиеся частью API класса, можно пометить одним подчеркиванием в начале имени. PEP8

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

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

Принципы открытости/закрытости

Википедия описывает два подхода к пониманию этих принципов. Рассмотрим их подробнее.

  • Принцип открытости/закрытости Мейера

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

Определение Мейера поддерживает идею наследования реализации. Реализация может быть переопределена через наследование, но спецификации интерфейса могут измениться. Существующая реализация должна быть закрыта для изменений, при этом новым реализациям не обязательно использовать существующий интерфейс*.

  • Полиморфный принцип открытости/закрытости

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

Wiki

*Что такое интерфейс — подробно разберем в принципе разделения интерфейса.

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

Но такой подход идет вразрез с принципом подстановки Лисков (о нем ниже). У себя в коде мы используем полиморфный принцип открытости/закрытости. В тексте и в примерах я описываю именно его.

Давайте предположим, что AuthUserAD — это жуткое легаси, которому лет шесть. И вариантов, чтобы кто-то дал свои комментарии, тоже нет. 

AuthUserAD регулярно DDoS-ит и роняет сервер аутентификации. Админ предлагает дешевое решение — поднять несколько инстансов Keycloak. Инстансы Keycloak будут ходить в AD по расписанию, получать список всех пользователей и брать на себя нагрузку, которая лежала на AD.

Вносить изменения в класс AuthUserAD — плохая идея. Трудно спрогнозировать риски, которые могут произойти от новых изменений. К тому же после внесения изменений его захочется переименовать, а это равносильно удалению класса. Если мы не исправим все места, где он вызывается по старому имени, то гарантированно уроним код.

Сейчас самое время вспомнить о принципе открытости/закрытости. Хорошей идеей будет не трогать класс AuthUserAD. Лучше сделать его осуждаемым и написать новый класс для работы с новой схемой аутентификации. В таком классе вместо ошибок из Keycloak будем райзить кастомные ошибки.

Сделать класс осуждаемым — что это значит

Немного подробнее о том, что я понимаю под фразой «сделать класс осуждаемым». В фреймворках встречается такое поведение: когда мы используем метод, в логи валится warning. Это говорит о том, что метод осуждаемый и перестанет поддерживаться с такой-то версии Python или с такой-то версии библиотеки. Я предлагаю тут сделать то же самое. Когда мы будем создавать инстанс осуждаемого класса, к нам в логи (и в Sentry) будет прилетать ошибка, которая указывает, что где-то используется осуждаемый класс.

Реализуем это, немного поправив метод init:
from abc import ABC, abstractmethod
import logging

logger = logging.getLogger('root')


class AbstractAuthUser(ABC):
    """Абстрактный класс, реализующий обязательные методы."""

    @abstractmethod
    def is_authenticated(self) -> bool:
        """
        Метод проверяет аутентификацию пользователя.
        Возвращает True, если аутентифицирован, и False — если не аутентифицирован
        """

    @abstractmethod
    def get_email(self) -> str:
        """Метод возвращает email пользователя"""

    @abstractmethod
    def get_department(self) -> str:
        """Метод возвращает отдел, в котором работает пользователь"""


class AuthUserAD(AbstractAuthUser):
    """Класс аутентификации через AD"""

    def __init__(self, *args, **kwargs):
        logger.error('Class AuthUserAD is deprecated. You should use AuthUserKeycloak.')
        super().__init__(*args, **kwargs)

    def is_authenticated(self) -> bool:
        raise NotImplementedError()

    def get_email(self) -> str:
        raise NotImplementedError()

    def get_department(self) -> str:
        raise NotImplementedError()


class AuthUserKeycloak(AbstractAuthUser):
    """Класс аутентификации через Keycloak"""
    
    def is_authenticated(self) -> bool:
        raise NotImplementedError()

    def get_email(self) -> str:
        raise NotImplementedError()

    def get_department(self) -> str:
        raise NotImplementedError()

Если мы где-то не заменили класс AuthUserAD на AuthUserKeycloak, у нас все продолжит работать, поскольку мы не удалили класс AuthUserAD. Он все еще присутствует в коде и работает лучше, чем прежде, поскольку нагрузка на AD уменьшится. При каждом создании инстанса AuthUserAD мы будем видеть ошибку в логах и постепенно безболезненно выпилим его из возможных мест. Принцип открытости/закрытости работает на нас.

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

Теперь у нас есть абстрактный класс AbstractAuthUser. От него зависят оба класса реализации AuthUserAD и AuthUserKeycloak. Причем нам не важны детали реализации каждого конкретного класса. Классу AuthUserKeycloak все равно, как реализован класс AuthUserAD и реализован ли он вообще. 

Также у нас абстракция не зависит от деталей: классу AbstractAuthUser не важны детали реализации дочерних классов AuthUserAD на AuthUserKeycloak, например класс с заглушками для тестов AuthTest. Более того, мы добавили новый функционал в классе AuthUserKeycloak, не меняя закрытый для изменения класс AuthUserAD.

Налицо соблюдение принципа открытости/закрытости.
class AuthTest(AbstractAuthUser):
    """Класс аутентификации для тестов с аутентифицированным пользователем"""
    
    def is_authenticated(self) -> bool:
        """Метод возвращает значение, необходимое для тестов"""
        return True

    def get_email(self) -> str:
        """Метод возвращает значение, необходимое для тестов"""
        return 'skyworker@jedi.com'

    def get_department(self) -> str:
        """Метод возвращает значение, необходимое для тестов"""
        return 'Департамент света'


class NoAuthTest(AbstractAuthUser):
    """Класс аутентификации для тестов с неаутентифицированным пользователем"""
    
    def is_authenticated(self) -> bool:
        """Метод возвращает значение, необходимое для тестов"""
        return False

    def get_email(self) -> str:
        """Метод возвращает значение, необходимое для тестов"""
        return ''

    def get_department(self) -> str:
        """Метод возвращает значение, необходимое для тестов"""
        return ''

Принцип подстановки Лисков

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

Wiki

Я считаю, что принцип подстановки Лисков — сердце SOLID и то, ради чего SOLID задумывался. Этот принцип связан с тем, что я описал выше. 

В классах, помимо обязательных методов, описанных в абстрактном классе, могут быть свои методы. В них могут отличаться сигнатуры, названия методов и реализация (как же без полиморфизма). Например, для http-запроса на сервис аутентификации могут быть реализованы разные методы для проверки токена, получения почты и названия отдела. Это не противоречит SOLID. Главное, чтобы бизнес-логика работала только с методами из абстрактного класса и сигнатуры методов, с которыми будет работать бизнес-логика, были одинаковыми.

Может показаться неочевидным, но в Python для принципа подстановки Лисков недостаточно ограничиваться методами, описанными в абстрактном классе. Если классы AuthUserAD и AuthUserKeycloak рейзят разные ошибки, это значит, что классы не удовлетворяют принципу подстановки Лисков

В контексте SOLID использование кастомных исключений — больше, чем просто хорошая практика. Это расширение понимания принципа подстановки Лисков. Мы знаем, какие ошибки могут возникнуть, и если вы напишете свои кастомные ошибки — это будет отличной идеей. То есть не пробрасывать ошибки из AD и Keycloak, а обрабатывать их в классе и рейзить свои исключения.

class AuthException(Exception):
    pass


class InvalidCredential(AuthException):
    pass


class AuthenticationServerIsNotAvailable(AuthException):
    pass

Поскольку новый класс AuthUserKeycloak реализует такие же обязательные методы, как и AuthUserAD и рейзит те же самые ошибки, то в эндпоинте (или middleware), где использовался класс AuthUserAD или его инстансы, мы можем безболезненно заменить его на новый AuthUserKeycloak. Принцип подстановки Лисков работает при такой реализации. 

Мы написали собственные классы, теперь любую нашу реализацию можно заменить без внесения изменений в код. Для меня это самое важное в SOLID.

Небольшое замечание. Декоратор abstractmethod гарантирует, что обязательные методы будут созданы, но он позволяет создать их с различными сигнатурами. А для принципа подстановки Лисков важно, чтобы сигнатуры были одинаковые или хотя бы совместимые. Без этого заменить базовый класс дочерним или один дочерний класс другим дочерним не получится.

Возможно, кто-то скажет, что принцип подстановки Лисков связан с базовым и дочерним типами. И определение в Википедии говорит об этом:

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

Но нам ничего не мешает сделать наследование таким образом:
class AuthUserAD(AbstractAuthUser):
    """Класс аутентификации через AD"""

    def __init__(self, *args, **kwargs):
        logger.error('Class AuthUserAD is deprecated. You should use AuthUserKeycloak.')
        super().__init__(*args, **kwargs)

    def is_authenticated(self) -> bool:
        raise NotImplementedError()

    def get_email(self) -> str:
        raise NotImplementedError()

    def get_department(self) -> str:
        raise NotImplementedError()


class AuthUserKeycloak(AuthUserAD):
    """Класс аутентификации через Keycloak"""
    
    def is_authenticated(self) -> bool:
        raise NotImplementedError()

Поскольку AuthUserKeycloak реализует такие же обязательные методы, как и AuthUserAD, это никак не влияет на возможность использования одного класса вместо другого. Если, например, реализация методов get_email и get_department в AuthUserKeycloak такая же, как и в AuthUserAD, такое наследование возможно.

Если же у AuthUserKeycloak своя реализация методов API класса — такое наследование не нужно. Кроме того, мы получим нежелательный побочный эффект в экземпляре метода AuthUserKeycloak — нам доступны все методы базового класса.

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

Всё, с наиболее сложными для понимания принципами мы закончили. Остались те, которые обычно не вызывают трудностей.

Принцип единственной ответственности

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

Все его поведения должны быть направлены исключительно на обеспечение этой ответственности.

Wiki

Здесь все более прозрачно. У класса AuthUserKeycloak может быть только одна причина для внесения изменений — изменения в аутентификацию или в работу с Keycloak. Если вы вносите изменения в класс для добавления новых пермишенов или для изменения способа сохранения, это уже дополнительные причины, а по рассматриваемому нами принципу причина должна быть одна. Поэтому авторизацию и работу с пермишинами необходимо вынести в отдельный класс, чтобы замена AuthUserKeycloak обратно на AuthUserAD не влияла на пермишены и на то, какие действия для пользователя разрешены или запрещены.

Аналогично можно рассмотреть и хранение данных: если класс AuthUserKeycloak сохраняет какие-то данные, можно создать метод для этого действия, а он в свою очередь будет работать с экземпляром класса для хранения. Логика открытия файла или соединения, логика сохранения в файл или базу, закрытия файла или соединения должны быть вынесены из класса AuthUserKeycloak в соответствующий метод класса, который отвечает за работу с хранилищем.

Хорошо, если для хранилища создать абстрактный класс, а от него уже наследовать дочерние классы. Например, для хранения в файле, в SQL базах данных через ORM, в NoSQL базах или другие варианты, а инстанс для нужного способа хранения использовать в AuthUserKeycloak. Я обычно использую один абстрактный класс и класс для ORM.

Он-то и используется для хранения, например StoreDB.
from abc import ABC, abstractmethod


class AbstractStore(ABC):
    """Абстрактный класс для хранения"""
    
    @abstractmethod
    def get(self, *args, **kwargs):
        pass

    @abstractmethod
    def get_multi(self, *args, **kwargs):
        pass

    @abstractmethod
    def create(self, *args, **kwargs):
        pass

    @abstractmethod
    def update(self, *args, **kwargs):
        pass

    @abstractmethod
    def delete(self, *args, **kwargs):
        pass


class StoreFile(AbstractStore):
    """Класс для хранения в файле"""
    
    def get(self, *args, **kwargs):
        raise NotImplementedError

    def get_multi(self, *args, **kwargs):
        raise NotImplementedError

    def create(self, *args, **kwargs):
        raise NotImplementedError

    def update(self, *args, **kwargs):
        raise NotImplementedError

    def delete(self, *args, **kwargs):
        raise NotImplementedError


class StoreDB(AbstractStore):  # Для ORM
    """Класс для хранения в SQL БД"""
    
    def get(self, *args, **kwargs):
        raise NotImplementedError

    def get_multi(self, *args, **kwargs):
        raise NotImplementedError

    def create(self, *args, **kwargs):
        raise NotImplementedError

    def update(self, *args, **kwargs):
        raise NotImplementedError

    def delete(self, *args, **kwargs):
        raise NotImplementedError


class StoreMongo(AbstractStore):
    """Класс для хранения в NoSQL БД"""
    
    def get(self, *args, **kwargs):
        raise NotImplementedError

    def get_multi(self, *args, **kwargs):
        raise NotImplementedError

    def create(self, *args, **kwargs):
        raise NotImplementedError

    def update(self, *args, **kwargs):
        raise NotImplementedError

    def delete(self, *args, **kwargs):
        raise NotImplementedError

Принцип разделения интерфейса

И последний незатронутый принцип. Чтобы его понять, нужно определиться с понятием интерфейса. Дело в том, что в Python нет явного понятия интерфейса, как в других языках программирования.

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

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

Приведу пример с абстрактным классом для коллектора:
from abc import ABC, abstractmethod


class AbstractCollector(ABC):
    """Абстрактный класс коллектора"""

    def __init__(self) -> None:
        self.metrics = []
        self.prepared_metrics = []

    def collect(self) -> None:
        self.get_metrics_from_service()
        self.process_metrics()
        self.save_metrics()

    @abstractmethod
    def get_metrics_from_service(self) -> None:
        """Extract."""

    @abstractmethod
    def process_metrics(self) -> None:
        """Transform."""

    @abstractmethod
    def save_metrics(self) -> None:
        """Load."""


class AbstractStore(ABC):
    """Абстрактный класс для хранения"""
        
    @abstractmethod
    def get(self, *args, **kwargs):
        pass

    @abstractmethod
    def get_multi(self, *args, **kwargs):
        pass

    @abstractmethod
    def create(self, *args, **kwargs):
        pass

    @abstractmethod
    def update(self, *args, **kwargs):
        pass

    @abstractmethod
    def delete(self, *args, **kwargs):
        pass


class AbstractReadOnlyStore(ABC):
    """Абстрактный класс для чтения их хранилища."""
    
    @abstractmethod
    def get(self, *args, **kwargs):
        pass

    @abstractmethod
    def get_multi(self, *args, **kwargs):
        pass


class GetStoreDB:
    """Класс для получения объекта из БД"""
    
    def get(self, *args, **kwargs):
        raise NotImplementedError

    def get_multi(self, *args, **kwargs):
        raise NotImplementedError


class CreateStoreDB:
    """Класс для создания объекта в БД"""
    
    def create(self, *args, **kwargs):
        raise NotImplementedError


class UpdateStoreDB:
    """Класс для изменения объекта в БД"""
    
    def update(self, *args, **kwargs):
        raise NotImplementedError


class DeleteStoreDB:
    """Класс для удаления объекта в БД"""
    
    def delete(self, *args, **kwargs):
        raise NotImplementedError


class StoreReadOnlyDB(AbstractReadOnlyStore, GetStoreDB):
    """Класс для сбора метрик. Только чтение."""


class StoreDB(AbstractStore, GetStoreDB, CreateStoreDB, UpdateStoreDB, DeleteStoreDB):
    """Класс для хранения метрик."""

Прежде чем объяснять, что тут происходит, еще раз вспомним теорию.

Программные сущности не должны зависеть от методов, которые они не используют.

Принцип разделения интерфейсов говорит о том, что слишком «толстые» интерфейсы необходимо разделять на более маленькие и

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

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

Wiki

В примере реализовано два абстрактных класса для работы с данными AbstractStore, AbstractReadOnlyStore и класс абстрактного коллектора AbstractCollector.

Метод коллектора get_metrics_from_service ходит в инфраструктуру и имеет доступ к критичным данным. В разных коллекторах данные собираются из различных источников (api, базы данных, файлы и др.). Но здесь выполняется только сбор данных (только безопасные методы). Методы, которые принято относить к опасным, в нем использоваться не будут. Поэтому при написании класса для сбора данных нужно использовать AbstractReadOnlyStore. Тогда потенциально опасные методы будут исключены на уровне интерфейса.

Метод коллектора save_metrics обычно выполняет только запись в базу данных. Получается, что для написания класса сохранения в базу достаточно реализовать только create и update. Эта реализация позволяет нам легко собрать такой класс: 

class StoreReadOnlyDB(CreateStoreDB, UpdateStoreDB):
    """Класс для записи метрик."""

Хотя это и будет наиболее соответствующий принципу разделения интерфейса класс, на практике мы так не делаем. А наследуемся от AbstractStore и в нем реализуем все методы CRUD. Можно сказать, что интерфейс с полным CRUD будет «толстым» для функционала сохранения.

Принцип разделения интерфейсов тесно связан с принципом единой ответственности, если класс StoreReadOnlyDB занимается только сбором статистики. И в соответствии с принципом единой ответственности мы не закладываем в него другой функционал. Также в соответствии с принципом разделения интерфейсов из абстрактного класса (интерфейса) AbstractReadOnlyStore убираем методы, которые не будем использовать.

Заключение

В статье я привел примеры на Python. Но хочу заметить, что принципы — это речь не столько про классы, сколько про программные сущности. В моем понимании принципы SOLID с таким же успехом могут использоваться, например, в архитектуре ПО, при описании схем взаимодействия микросервисов.

Надеюсь, что после прочтения статьи принципы SOLID стали для вас такими же очевидными, как для Роберта С. Мартина. Главная мысль, которую я хотел донести, это то, что все принципы тесно связаны друг с другом. Надеюсь, этот текст дополнил информацию из других источников и помог составить из нее законченную картину, понять, насколько тесно принципы связаны между собой. Я верю, что теперь вы легко ответите на все вопросы по SOLID на собеседовании и будете писать солидный код.

Все правила, в том числе и SOLID, можно нарушать. В небольших проектах строгое соблюдение всех принципов SOLID избыточно. Но программисты, которые пишут небольшие проекты, растут. Растут и их проекты — становятся большими, очень большими и архитектурно сложными. В таких проектах соблюдение принципов SOLID крайне желательно или обязательно.

Дополнительное чтение

beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

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


  1. axelmaker
    05.12.2023 14:29
    +1

    Хорошая статья

    Дополненю, что в python 3.12 появился декоратор @override, позволяющий указать, какие методы мы наследуем


    1. CorwinH
      05.12.2023 14:29
      +4

      deprecated в данном контексте - "устаревший", а не "осуждаемый".


      1. pavelkpv Автор
        05.12.2023 14:29
        +1

        Разные источники предлагают разные переводы. В таком варианте будет даже лучше.


    1. Andy_U
      05.12.2023 14:29

      И @deprecated тоже.


    1. pavelkpv Автор
      05.12.2023 14:29

      Спасибо.


  1. ammo
    05.12.2023 14:29
    +1

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

    Я б тоже лучше слился, чем сидеть примеры рожать по 200 строчек, как в статье.


    1. pavelkpv Автор
      05.12.2023 14:29

      Когда меня спрашивали про SOLID обычно обходилось без кода.


  1. rukhi7
    05.12.2023 14:29

    абстрактный код (код который нельзя скомпилировать и/или выполнить) делает любые принципы еще более абстрактными, а значит сложными для понимания, ИМХО.


    1. pavelkpv Автор
      05.12.2023 14:29
      +1

      Ты поднял очень важную для меня тему. Когда я отдавал статью на рецензию то рецензенты сеньоры писали разные замечание, а рецензенты уровня джун+ все написали, что примеры лучше сделать более конкретные (чтоб их можно было закинуть в IDE и выполнить). Я думал над тем, как это сделать. Отказался от примеров на кошечках и рыбках. Классы аутентификации из примеров — это реальные классы, которые крутятся на проде. В методах этих классов обычно простой реквест в сервис аутентификации, обработка статус кода и сохранение их в экземпляре класса. С кодом запроса в api все хорошо справляются. Код с запросом скорее всего будет не интересен. Но при этом увеличит и так большие примеры.

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


      1. rukhi7
        05.12.2023 14:29
        +2

        Вот например, вы пишите:

        Давайте разбираться по порядку. ABC — это класс-помощник, который указывает метакласс metaclass=ABCMeta в качестве параметров класса. 

        Зачем тут нужен помошник? есть 150 способов указать метакласс вкачестве параметров класса, зачем нужен именно помошник?

        Я начинаю это читать и сразу чуствую что я не в теме, и дальше больше но уже даже не возьмусь формулировать, возможно это мне чего то не хватает, я не претендую на авторитетность мнения!

        Тут дальше в комментариях написано:

        Куча слов - и ни одного упоминания назначения принципов.

        я думаю это замечание более важное, чем мои изначальные ощущения.

        Как-то у вас получается что вы применили SOLID и поэтому у вас все хорошо, но непонятно что было плохо до его применения! Может и без SOLID у вас все было хорошо?


  1. Andrey_Solomatin
    05.12.2023 14:29

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

    Считать ли это зависимостью на абстракциях?

    def print_items(sequence):
        for i in sequence:
            print(i)


    Утиная типизация подразумевает неявные интерфейсы. В данном случае sequence это iterable https://docs.python.org/3/glossary.html#term-iterable.

    То что абстракции не прописанны явно в Питоновском коде, не значит, что их нет. Котя кончено лучше из прописывать явно.

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

    Нет, если наследник тоже абстрактный класс.

    Если вы в своем коде уже используете абстрактные классы — скорее всего, в коде с SOLID все хорошо

    Я разный код видел.


    1. pavelkpv Автор
      05.12.2023 14:29
      +1

      Спасибо за коментарий.

      Считать ли это зависимостью на абстракциях?

      Я думаю что нет. Здесь, как в ссылке написано, объект sequence должен реализовывать методы iter и next. Тогда он будет соответствовать интерфейсу и его можно здесь применить.

      Нет, если наследник тоже абстрактный класс.

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

      Я разный код видел.

      Очень смешно. Особенно когда свой смотришь годовалый. :)


  1. Andrey_Solomatin
    05.12.2023 14:29
    +1

    осуждаемым


    Никогда не слышал такого перевода deprecated.


    1. werevolff
      05.12.2023 14:29
      +2

      Это директива ООН


    1. pavelkpv Автор
      05.12.2023 14:29
      +1

      Специально полазил по словарям. Гугл предлагает устарел, яндекс переводит как осуждаемый. Родной с детства Англо-русский словарь Мюллера переводит как 1) обесценивать(ся), падать в цене 2) унижать, умалять, недооценивать. А лингво в вариантах хужожественного перевода еще больше вариантов предлагает. Сам в шоке.


      1. teemour
        05.12.2023 14:29

        не тем словарём пользовались, погуглите историю про гуртовщика мыши
        нужно было переводить не непосредственно, а через один шаг с толковым словарём "Deprecated refers to a software or programming language feature that is tolerated or supported but not recommended"
        есть два термина obsoleted и deprecated, они оба значат одно и тоже "устаревший", но несколько разной окраски
        deprecated это указание авторов библиотеки не использовать больше конкретный API, а то хуже будет, но это просто они снимают с себя ответственность, хуже может доолго не быть
        obsoleted - нейтральный, встречался раньше, теперь норма использовать deprecated но перевод на русский один и тот же "устаревший"


  1. sshikov
    05.12.2023 14:29
    +1

    Ну вот почему каждый первый автор, кто пытается писать про SOLID, при этом умудряется даже не упомянуть то, для чего эти принципы нужны?

    Ведь из этого очевидно следует все остальное. Ну или почти все.

    Вот и тут. Куча слов - и ни одного упоминания назначения принципов.


  1. skovoroad
    05.12.2023 14:29
    +9

    Удивительно, как можно при объяснении принципа инверсии зависимостей ни полсловом не упомянуть ни зависимости, ни инверсию

    Сказать про модули верхнего и нижнего уровня и не придумать примера с абстракциями разного уровня (и их зависимостями)

    Какая зависимость инвертируется в итоге? Между чем и чем? Кто верхнего уровня, кто нижнего, какую проблему решили? Да бог его знает


    1. werevolff
      05.12.2023 14:29
      +1

      Аналогично, создание замены deprecated классу не относится к принципу open/closed. Это просто новый класс. Принцип open/closed, действительно, связан с принципом подстановки Барбары Лисков, но ещë и с принципом единой ответственности. В разрезе этой троицы мы можем сформулировать принцип открытости/закрытости интерфейсов так: "ваши родительские или абстрактные классы должны решать одну задачу. Эта задача определяется private и protected методами и свойствами класса. Если вы можете выделить в вашем абстрактном базовом классе функционал, который вы можете защитить от изменений в потомках, то он, скорее-всего, отвечает принципам SOL".

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

      Причëм, этот принцип действует в обе стороны. Например, у вас есть функция react_to_noise, которая принимает на вход наследников класса Animal: Cat, Dog, Parrot. Допустим, что функция использует метод fly() только если мы имеем дело с Parrot. Это нарушает принципы LID. Здесь мы должны сделать так, чтобы всегда выполнялось react_to_noise(animal: Animal), и внутри не было вызовов методов, специфичных для наследников. Поэтому, мы можем применить принципы ID и создать интерфейс Bird(Animal) в котором будет реализован или объявлен метод fly(). Тогда, для функций react_to_noise_animal(animal: Animal) и react_to_noise_bird(bird: Bird) будет выполняться принцип L. В python для понимания такой подстановки прекрасно подходит механизм типизации Generic.


    1. pavelkpv Автор
      05.12.2023 14:29

      Привет. Спасибо за комментарий. Ну смотри, по тексту. Порядок подачи информации у меня не такой как обычно пишут про SOLID. Написано так, потому что я отталкиваюсь не от принципов, а от кода. В идеальном мире я начинаю писать код с абстрактных классов. Значит и повествование я начинаю с одного абстрактного класса и одного зависимого продового. В этот момент кода недостаточно для всех примеров про инверсию. Поэтому в том месте, где ты это ожидаешь все про инверсию этого нет.
      Чуть ниже, когда принципе открытости и закрытости добавили дополнительный класс. А в принципе подстановки Лисков я разбирал разные виды зависимостей и некоторые особенности реализации в Python.
      В принципе единой ответственности есть примеры с множественными зависимостями абстрактных классов для тонких и толстых интерфейсов.


      1. skovoroad
        05.12.2023 14:29

        Перечитал статью и не нашёл там объяснения про инверсию зависимостей (хотя есть его вполне валидная формулировка). Само по себе выделение интерфейса (например, абстрактного класса) это не инверсия. Инверсия возникает в тот момент, когда вы определяете, ГДЕ у вас определён интерфейс.

        Вот у вас есть ваш интерфейс AbstractAuthUser. Если вы его определите в модуле, заведующем его конкретными реализациями, то у вас будет прямая зависимость: его клиенты, модули более высокого уровня, будут зависеть от модулей низкого уровня, содержащих детали. Это плохо!

        Однако если вы определите AbstractAuthUser В МЕСТЕ ЕГО ИСПОЛЬЗОВАНИЯ, в модуле высокого уровня, то зависимости инвертируются: модули более низкого уровня будут вынуждены узнать о модуле высокого уровня (чтобы узнать про интерфейс, который они реализуют), но модуль высокого уровня будет независимым и самодостаточным.

        Ура, ваши зависимости инвертированы! Вы можете разрабатывать модуль высокого уровня, вообще не зная, какие бывают конкретные реализации (и даже бывают ли они вообще). Зависимость была туда - стала сюда. Она инвертирована! И тут уже становятся важны опен-клоз, лисков и всё такое.

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


        1. pavelkpv Автор
          05.12.2023 14:29

          Спсибо за такой развернутый коментарий. Да, действительно, у меня нигде по тексту нету рекомендаций по размещению абстрактного класса.


  1. Kergan88
    05.12.2023 14:29
    +1

    Здесь все более прозрачно. У класса AuthUserKeycloak может быть только одна причина для внесения изменений — изменения в аутентификацию или в работу с Keycloak. Если вы вносите изменения в класс для добавления новых пермишенов или для изменения способа сохранения, это уже дополнительные причины

    А как определили, что в данном случае это причины разные, а не одна? Что если требования к пермишенам, способам хранения и алгоритму аутенитификации приходят от одной роли?


    1. pavelkpv Автор
      05.12.2023 14:29

      Если у тебя MVP. Если у тебя все завизано на одной роли, нет никакого переиспользования пермишинов и бизнес говорит что все зашибись больше ничего не надо. Если AuthUserAD используется только в одном сервисе. То все отлично уживается в AuthUserAD.

      Но аутентификация и авторизация это разные зоны ответственности. Аутентификация у нас используется одна на нескольких сервисах. А авторизация, что кому можно, на разных сервисах имеет свои отличия.

      Надеюсь я ответил на твой вопрос.


  1. Kelbon
    05.12.2023 14:29

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

    Декоратор abstractmethod гарантирует, что у дочернего класса будут все методы, которые декорированы этим декоратором. Им нужно оборачивать все методы, которые будет использовать бизнес-логика. Разработчики, которые будут обращаться к классу аутентификации, могут быть уверены, что у него всегда есть методы is_authenticated, get_email, get_department, потому что они декорированы abstractmethod, а значит, обязательны для реализации в дочерних классах, унаследованных от абстрактного.

    решается статической типизацией, вам больше не нужно верить, что там есть какие-то методы

    Вносить изменения в класс AuthUserAD — плохая идея ... Если мы не исправим все места, где он вызывается по старому имени, то гарантированно уроним код.

    решается статической типизацией, где опечатки не приводят к рантайм ошибке

    • Принцип открытости/закрытости Мейера

    как по мне очень длинное и странное описание, просто "код должен быть открыт для расширения и закрыт от изменения", то есть нужно стараться писать код так, чтобы при расширении не приходилось менять предыдущую логику

    Определение Мейера поддерживает идею наследования реализации. Реализация может быть переопределена через наследование,

    и

    Это определение поддерживает идею наследования от абстрактных базовых классов

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

    Если мы где-то не заменили класс AuthUserAD на AuthUserKeycloak, у нас все продолжит работать, поскольку мы не удалили класс AuthUserAD. Он все еще присутствует в коде и работает лучше, чем прежде, поскольку нагрузка на AD уменьшится. При каждом создании инстанса AuthUserAD мы будем видеть ошибку в логах и постепенно безболезненно выпилим его из возможных мест.

    опять проблемы питона, которые не являются реально проблемами программирования в целом

    Принцип подстановки Лисков

    в этом пункте в статье я вообще не увидел самого принципа подстановки Лисков, что это такое и почему. "Вот есть интерфейсы, они могут быть разные, всё такое", казалось бы, причём здесь это


    1. pavelkpv Автор
      05.12.2023 14:29

      Привет. Спасибо за коментарий.

      По-моему эта статья лишь демонстрирует, что питон непригоден для программирования больших вещей.

      Если бы ты написал это лет 10 назад я бы, скорее всего, с тобой согласился. Но сейчас Python-у ничего не нужно доказывать. Как будет через 10 лет я не знаю.

      решается статической типизацией, вам больше не нужно верить, что там есть какие-то методы

      Не уверен. Если дочерний класс написан с нарушениями интерфейса и сигнатуры методов, то проверка типа может ничего не дать.

      опять проблемы питона, которые не являются реально проблемами программирования в целом

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

      Здесь скорее задеты проблемы микросервисоной архитектуры. Мы не можем закрыть доступ из сервисов в AD на уровне инфраструктуры пока у меня живут сервисы которые туда ходят. Мы деплоим их постепенно. Такие проблемы не завязаны на языке.

      в этом пункте в статье я вообще не увидел самого принципа подстановки Лисков, что это такое и почему. "Вот есть интерфейсы, они могут быть разные, всё такое", казалось бы, причём здесь это

      Тут я могу ошибаться, поправь меня если что. Вот определение с вики.

      Принцип подстановки Лисков — это принцип организации подтипов в объектно-ориентированном программировании, предложенный Барбарой Лисков в 1987 году: если q(x) является свойством, верным относительно объектов x некоторого типа T, тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.

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

      Таким образом, идея Лисков о «подтипе» даёт определение понятия замещения — если S является подтипом T, тогда объекты типа T в программе могут быть замещены объектами типа S без каких-либо изменений желательных свойств этой программы.

      Из этого самым важным я считаю «объекты типа T в программе могут быть замещены объектами типа S без каких-либо изменений». Это главное. Это возможно при соблюдении интерфейсов и сохранении сигнатур методов. А что от чего наследуется и каким образом мы проверяем что интерфейсы совпали это вторично. Для меня проблема, когда разработчики наследуются и в новом классе незначительно меняют интерфейс или сигнатуры. И даже если сейчас это не аффектит но через год другой может выстрелить в ногу. В таком случае проверка типов ничем не поможет. Более того в Python в типизации можно указывать не родительский тип, а список из дочерних. Более того, для соблюдения этого условия, мне даже не нужно наследование. Если класс T и класс S реализуют один интерфейс с моей точки зрения они уже соответствуют принципу подстановки Лисков потому что мы можем объект T заменить на объект S и наоборот. А в коде для проверки типов линтером я укажу список типов либо родительский тип если он есть. В Python какой способ проверки типов из них выбрать это преднамеренный выбор программиста.


  1. hardtop
    05.12.2023 14:29

    Надеюсь, что после прочтения статьи принципы SOLID стали для вас такими же очевидными, как для Роберта С. Мартина.

    Нет. Вообще нет. Дядюшка Боб молодец: напихал в Джаву абстракций в 2000-х годах и свалил писать на Clojure. А люди всё продолжают слепо перетаскивать из Джавы на все остальные языки.

    cd venv/lib/python3.9/site-packages/django/
    grep -lir "from abs import"  

    И ничего не найдено, хотя там есть Абстрактные классы. Вот этот from abs - он не выглядит python way. Слишком много Абстракций начинают усложнять понимание, что в свою очередь начинает противоречить KISS. Плюс в питоне можно вот так сделать class MyModelAdmin(AdminImageMixin, admin.ModelAdmin), а в java множественное наследование запрещено. Вот тут даже интерфейса не надо

    """ Демонстрируем Инъекцию Зависимостей """
    
    class EmailNotifier:
        """ Отправим через E-mail """
        def send(self, to, message):
            print(f"Notify {to} by mail. {message}")
    
    class SmsNotifier:
        """ Отправим в Telegramm """
        def send(self, to, message):
            print(f"Notify {to} by sms. {message}")
    
    
    def notify_user_by_email(to, message):
        """ Тут функция зависит от настроек почтового сервера """
        by_email = EmailNotifier()
        by_email.send(to, message)
    
    def notify_user_di(to, message, notifier):
        """ Инъекция notifier. Главное, чтобы у notifier был метод send() А там хоть email, хоть MailChimp """
        notifier.send(to, message)
    
    
    if __name__ == "__main__":
    
        # Вот такие красавцы
        user_list = [
            {"name": "Bob", "phone": "+7-903", "email": "uncle.bob@gmail.com"},
            {"name": "Mike", "phone": "", "email": "mechanic@yahoo.com"},
            {"name": "Jeff", "phone": "+7-926", "email":""},
        ]
    
        for user in user_list:
            if user["phone"]:
                """ Отправляем в телефон """
                notify_user_di(user["phone"], "Welcome home!", SmsNotifier())
    
            if user["email"]:
                """ На email """
                notify_user_di(user["email"], "Welcome home!", EmailNotifier())


  1. LifeKILLED
    05.12.2023 14:29
    +2

    А обязательно вот так сложно говорить на собеседовании, например, "инверсия зависимостей"?

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

    То есть рекомендаций/лайфхаков, как делать код гибким/понятным такое огромное множество, что любое перечисление принципов SOLID, KISS и прочих - это маленькая капля в море личного опыта.

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

    Было бы неплохо, если бы все программисты на личном опыте понимали, зачем придуман SOLID. Но есть ли способ при приеме на работу не парить мозг зубрежкой терминов из конкретных книг/статей?

    Та же "инверсия зависимостей" странный термин. Зависимостей в идеале быть вообще не должно. Если один кусок кода лезет в другой, это просто невозможно тестировать


    1. sshikov
      05.12.2023 14:29
      +1

      Было бы неплохо, если бы все программисты на личном опыте понимали, зачем придуман SOLID.

      Ну вот заметьте, я чуть выше ровно о том же - что автор упоминает это самое "зачем" один раз в начале, в списке из еще нескольких пунктов, а потом про него забывает напрочь. И может быть он даже знает эти самые принципы назубок - но все равно забыл об их назначении. При этом если понимать его, это назначение, ну хотя бы на уровне, как вы тут пишете:

      рекомендаций/лайфхаков, как делать код гибким/понятным

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

      Более того, если немного подумать, то все принципы солид в общем про одно - как снизить связность разных компонент кода. Просто эта связность в каждом случае разного вида. А когда компоненты независимы - тут-то код и становится проще для понимания, и гибче.

      Если один кусок кода лезет в другой, это просто невозможно тестировать

      Не, ну тут обычно речь о другом. Я вот видел код, где некий коннект к базе создавался внутри определенного класса. Это зависимость. И в таком виде ее правда невозможно тестировать, потому что нельзя подсунуть классу другой коннект, он работает только с пром базой. А вот если создание зависимости вынести наружу, и сделать inject этого коннекта к базе в класс - тестирование становится возможным и достаточно удобным. Это правда про dependency inject, но суть надеюсь ясна.