Abstract class, interface, protocol
KarmanovichDevСегодня речь пойдёт о моём понимании и разнице между абстрактными классами, интерфейсами и протоколами.
Начнём с того, что ответим на такой вопрос: А зачем вообще они нам нужны?Для возможности выделять важное(поведение проектируемого объекта) и не думать о неважном(детали реализации). Услышав подобные слова, я сразу же вспоминаю ООП и понимаю, что речь идёт об абстракции(не именно об абстрактных классах).
Давайте попробуем выделить это самое "важное" и постараемся не думать про "неважное":
Представьте, что вам нужно реализовать сценарий использования вашего приложения, для получения цены на товар с использованием разных стратегий.
Первый плюс:
Уже на начальном этапе проектирования вы понимаете, что вашей бизнес-логике нужна стратегия, которая каким-то образом выполняет ожидаемую от неё задачу. И даже если вы знаете, какие могут/должны быть стратегии, сейчас это лишние детали в рамках начала проектирования:

Второй плюс:
Из первого плюса плавно вытекает то, что наш сценарий использования можно реализовывать независимо разными людьми. Один человек может спокойно реализовывать DBPriceStrategy, не думая о GetProductPriceUseCase, второй точно так же как первый, но будет реализовывать уже PremiumPriceStrategy, в то время как третий будет реализовать GetProductPriceUseCase, заменив зависимость от BasePriceStrategy в тестах подставным объектом[моя статья про тестовые дублёры].
Учитывая предыдущие слова: даже если мы не знаем деталей конкретных стратегий, мы можем независимо от этого написать и протестировать GetProductPriceUseCase.

Третий плюс:
Очевидно, что абстракция от деталей даёт нам возможность оставлять наш код модульным, чистым, и заменять его зависимости, не меняя код объекта(open-close(sOlid)). В нашем примере это GetProductPriceUseCase, которому неважно, какая стратегия из наследников BasePriceStrategy будет передан в момент выполнения кода.
Если мы не имеем абстракций, то скорее всего, и наш код получаются менее упорядоченными и излишни сложным, и нам приходится делать то, от чего в том числе нас лечит open-close(sOlid):

Четвёртый плюс:
Абстракция помогает нам писать тесты, экономя скорость за счёт подставных объектов и независимости от деталей(привет приверженцам Лондонской школы юнит-тестирования).

Это не все плюсы, которые есть от абстракции(ООП), но основные, на которых я бы хотел заострить внимание.
Давайте перейдём к стандартным способам реализации этой самой абстракции в python:
Абстрактный класс(abstract class):
Это класс, который описывает поведение некоторого семейства объектов(наследников), и имеет возможность реализации их базового поведения(даже все методы могут быть реализованы, но хотя бы один должен быть абстрактным).
Мой любимый пример, где базовая реализация как нельзя кстати это паттерн шаблонный метод(template method)[моя самая первая статья]. Суть паттерна состоит в том, что бы обозначить алгоритм(чёткая последовательность шагов) работы механизма, давая возможность наследникам менять части(шаги) этого алгоритма, не изменяя сам алгоритм. Как правило, этот метод нельзя переопределять, это ядро класса:

Таким образом, с помощью get мы выделяем некий алгоритм, который возможно является целым бизнес-процессом, и должен быть неизменен, но даём возможность дочерним классам принимать решение, как именно будут выполняться шаги этого алгоритма.
Для меня абстрактный класс является самым близким к деталям способом реализации абстракции(ООП) из описанных в статье способов. Можно считать, что абстрактный класс в python - это некий симбиоз класса и интерфейса.
Обычно, даже названия объектов при использовании абстрактных классов у меня получаются ближе к деталям. Я называю такие классы "конкретный абстрактный"😁:
- PriceRepository
- CurrencySource
- UserFactory
Суть в том, что в своём коде я жду конкретный абстрактный UserFactory, не любую Factory именно UserFactory ведь мне нужно создать пользователя, а не что-то абстрактное.
Интерфейс(interface):
Интерфейс, это класс, который описывает поведение некоторого вида объектов, не имея реализации этого поведения. Такой класс не может иметь никаких деталей(ни методов, ни атрибутов).
Интерфейс лишь описывает поведение объекта и поэтому может находиться дальше от деталей за счёт отсутствия какой-либо реализации внутри себя:

Этот пример(с абстрактным классом можно сделать такой же манёвр) интересен тем, что если знания об источнике(деталях) оставлять в стратегии, мы будем обязаны содержать больше деталей именно в стратегиях, которые ближе к клиентскому коду:
- DBPriceStrategy
- HTTPPriceStrategy
- FilePriceStrategy
В данном же случае после того, как мы абстрагировались от деталей получения цены в стратегии, название нашего класса, как и его код более не имеет знаний об инфраструктуре.
Интерфейс тоже ничего не знает о деталях. Получается, мы смогли "унести" знание о деталях немного дальше от бизнес-логики/клиентского кода. Не всегда это хорошо и оправдано, но например, если работа с источниками для стратегий +- одинакова, то подобным образом мы можем переиспользовать логику работы стратегии, не дублируя её в разных местах(dry).
Небольшой пример, который подсветил мне такое место, где вместо написания нескольких конкретных реализаций класса для кеширования данных, там, где код работы с объектом кеша одинаковый, можно вынести знания об этом кеше в абстракцию и переиспользовать код кешера(dry). Инфраструктурные же детали будут содержаться в реализациях объекта кеша.

Протокол(protocol):
Думаю, многие из вас не раз слышали про утиную типизацию(duck typing) в python, где говорится: если это выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, и есть утка:

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

Идея в том, что мы не хотим давать клиентскому коду/бизнес-логике знания и доступ к информации о возможности сервиса работы с подписчиками и событиями, но нам нужен этот функционал. Просто оставлять как есть, будет выглядеть явным нарушением полиморфного open-closed(sOlid).
Выход есть и это Protocol. Протоколы используются для статической утиной типизации:

Нам совсем необязательно явно наследоваться(хотя и можно) от протокола, статические средства типизации определяют, что класс соответствует протоколу, несмотря на то, что он имеет большую функциональность, чем описана в протоколе:

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