SOLID на котиках
Каждый программист хоть раз слышал о принципах SOLID. На собеседованиях и экзаменах в вузах многие из нас пытались вспомнить, о чем же был тот самый принцип Лисков. Однако вряд ли цель преподавателей и интервьюеров — заставить нас заучивать строчки из учебников. SOLID действительно помогает писать качественный код, когда во всем разберешься! Если вы этого еще не сделали, добро пожаловать под кат. Еще раз взглянем на то, как устроены всем известные принципы. Обещаю — без духоты, все рассмотрим на примерах с котиками.
Используйте оглавление, если не хотите читать текст полностью:
→ Принцип единственности ответственности
→ Принцип открытости / закрытости
→ Принцип подстановки Барбары Лисков
→ Принцип разделения интерфейсов
→ Принцип инверсии зависимостей
→ Заключение
Принцип единственности ответственности
Для игры и еды должны быть отдельные места.
Принцип единственности ответственности (Single Responsibility Principle, SRP) гласит: каждый класс или модуль должен иметь только одну причину для изменения. То есть он должен выполнять только одну задачу.
Вроде звучит несложно, но где применить этот принцип? Да и что это вообще значит — «иметь только одну причину для изменения»? Разберемся на кошачьем примере.
Моего котика зовут Боря. Сейчас я попробую его имплементировать.
class BoryaCoolCat:
def eat(self):
print("Омномном")
def play(self):
print("Тыгыдык")
def save_to_database(self):
# Код для сохранения Борика в базу данных
Pass
Борян у нас парень продвинутый — и помяукать может, и в базу себя сохранить. Допустим, ветеринар сказал Боре есть влажный корм только с утра. Давайте тогда разделим функцию eat на завтраки и остальные приемы пищи:
class BoryaCoolCat:
def morning_eat(self):
print("Омномном")
def eat(self):
print("Не люблю сухой корм")
def play(self):
print("Тыгыдык")
def save_to_database(self):
# Код для сохранения Борика в базу данных
pass
Теперь допустим, одной из игрушек нашего здоровяка станет утренняя еда со стола. Чтобы отразить это в коде, разделим функцию play, как выше сделали это с функцией eat:
class BoryaCoolCat:
def morning_eat(self):
print("Омномном")
def eat(self):
print("Не люблю сухой корм")
def morning_eat_play(self):
print("О конфетки, буду катать их по полу!")
def play(self):
print("Тыгыдык")
def save_to_database(self):
# Код для сохранения Борика в базу данных
pass
Уже сейчас видно, что код становится не очень-то понятным. Если не знать историю его написания, трудно догадаться, чем morning_eat отличается от morning_eat_play. Теперь сделаем нашего котяру чуть более солидным — объединим методы с похожей тематикой в классы. Те, что связаны с едой (morning_eat и eat) положим в класс CatFeeding. Таким образом, этот класс будет иметь только одну причину для изменения — кормежку. Аналогично, функции morning_eat_play и play перенесем в класс CatPlay, а save_to_database — в CatDatabase.
class CatFeeding:
def morning_eat(self):
print("Омномном")
def eat(self):
print("Не люблю сухой корм")
class CatPlay:
def morning_eat_play(self):
print("О конфетки, буду катать их по полу!")
def play(self):
print("Тыгыдык")
class CatDatabase:
def save_to_database(self, cat):
# Код для сохранения объекта cat в базу данных
print(f"{cat.name} сохранён в базу данных.")
Отлично, теперь каждый из классов имеет только одну причину для изменения — кормежка, игры или сохранение в БД! Но тут главное — знать меру и не наплодить классов под каждый метод.
Принцип открытости / закрытости
Если кот научится шипеть, он не должен разучиться мяукать.
Принцип открытости / закрытости (Open/Closed Principle, OCP) — программные сущности (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации.
Расширения, модификации, бла-бла-бла… Сейчас с помощью Бори во всем разберемся! Одарим нашего парнишку речью:
class BoryaCoolCat:
def speak(self):
return "Мяу!"
Немного пожив с ним, я поняла, что у него с утра есть два настроения: ласковый милашка и киборг-убийца. Будет странно, если и в том, и в другом случае он будет болтать одинаково. Давайте попробуем усовершенствовать Боряна:
class BoryaCoolCat:
def speak(self, nice_mood: bool):
if nice_mood:
return "Мур-мур-мур"
return "Шшшшшшш"
Мы видим, как наша функция становится больше, в ней становится легче сделать ошибку. Если у Бори появится еще какое-то настроение, то функция будет разрастаться. Давайте накинем solidности.
class BoryaCat:
def speak(self):
return "Мяу!"
class NiceCat(BoryaCat):
def speak(self):
return "Мур-мур-мур"
class AngryCat(BoryaCat):
def speak(self):
return "Шшшшшшш"
Посмотрите, как теперь выглядят наши классы. Они открыты для добавления новой логики, но закрыты для изменения текущей. Изменение исходной логики — очень опасная процедура. Метод может использоваться в разных местах в коде, при изменении очень трудно найти все ошибки. А вот если мы будем не изменять изначальную логику, а добавлять новые кейсы, работающий код не сломается, и мы сможем реализовать новый функционал без проблем!
Принцип подстановки Барбары Лисков
Если мы любим игрушки, то и мышек, и фантики.
Принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP) утверждает, что объекты подкласса должны быть взаимозаменяемыми с объектами суперкласса без изменения желаемых свойств программы. Это означает, что если класс S является подклассом класса T, то объекты класса T должны быть заменяемыми объектами класса S без нарушения корректности программы.
Пожалуй, самый непонятный принцип из всех. Когда я прочла его впервые, почувствовала себя на матане на первом курсе. Какие S? какие T? Классы, подклассы… Вот не сиделось же тебе, Лисков, на месте. Но готовьтесь, сейчас мы победим этот принцип раз и навсегда!
Давайте сделаем класс Бориных вещичек. В нем создадим метод, который будет возвращать, как Боря радуется и любит свои игрушки.
class BoryasStuff:
def enjoy(self):
print("Ура")
У Бори есть фантик, мои руки и все, что лежит на столе. Все это — его вещи, поэтому они должны наследоваться от BoryasStuff.
class CandyWrapper(BoryasStuff):
def enjoy(self):
print("Ура, шелестеть!")
class MommysHand(BoryasStuff):
def enjoy(self):
print("Ура, царапать!")
class TableThings(BoryasStuff):
def enjoy(self):
print("Урааа, скидывать на пол!")
Недавно я купила Боре дорогущую мышь, которая умеет бегать от него и издавать звуки. Но, как водится, чем дороже игрушка, тем меньше она Борю интересует. Так что имплементация ее будет выглядеть так:
class CoolMouse(BoryasStuff):
def enjoy(self):
raise NotImplementedError("Какая мышь? Где мой трижды погрызанный фантик?")
Вот здесь и нарушается принцип Барбары Лисков. Дело в том, что согласно нему мы должны уметь пользоваться всеми дочерними классами так же, как и родительскими. Но в классе BoryasStuff в отличие от дочернего класса CoolMouse метод enjoy выполняется без проблем.
Если мы можем вызвать BoryasStuff ().enjoy (), то у нас должна быть возможность вызвать и CoolMouse ().enjoy (). Для соблюдения принципа мы можем или исключить метод enjoy из BoryasStuff, или не наследовать CoolMouse от BoryasStuff, вот и все!
class BoryasStuff:
pass
class CandyWrapper(BoryasStuff):
def enjoy(self):
print("Ура, шелестеть!")
class MommysHand(BoryasStuff):
def enjoy(self):
print("Ура, царапать!")
class TableThings(BoryasStuff):
def enjoy(self):
print("Урааа, скидывать на пол!")
class CoolMouse(BoryasStuff):
def hate(self):
print("Какая мышь? Где мой трижды погрызанный фантик?")
Принцип разделения интерфейсов
Незачем тебе умение плавать, если ты никогда не будешь это делать.
Принцип разделения интерфейсов (Interface Segregation Principle, ISP) гласит, что клиенты не должны зависеть от интерфейсов, которые они не используют.
Как-то мы мелочимся с выборами классов. Давайте создадим общий интерфейс котов. Что они умеют? Бегать, плавать, ну и, конечно же, орать в 5 утра.
class Cat:
def run(self):
pass
def swim(self):
pass
def morning_yell(self):
pass
Но вот мой Борик — домашний крепыш. Однажды плавал в ванной и ему очень не понравилось, мы решили его не мучить. Но если мы наследуем Борю от Cat, теоретически кто-то может заставить его плавать, чего ему явно не хотелось бы. Давайте разделим интерфейсы, чтобы мы могли наследовать только то, что нужно:
class Walkable:
def run(self):
pass
class Hateable:
def morning_yell(self):
pass
class Swimmable:
def swim(self):
pass
Теперь все в порядке! Можем просто наследовать Борю от Walkable и Hateable. В этом и есть принцип разделения интерфейсов. Мы не должны наследоваться от того, что не используем.
Принцип инверсии зависимостей
То, что коты мяукают, не влияет на всех остальных зверей. Но то, что животные издают звуки, влияет на котов.
Принцип инверсии зависимостей (Dependency Inversion Principle) гласит:
- «Модули верхнего уровня не должны зависеть от модулей нижнего. Оба должны зависеть от абстракций»,
- «Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций».
Так много слов и так трудно найти в них смысл. Разберем на примере использование этого принципа. Большинство животных издает какие-то звуки. Давайте напишем интерфейс для этого:
from abc import ABC, abstractmethod
class Sound(ABC):
@abstractmethod
def make_sound(self):
pass
Теперь давайте определим кошачий звук:
class CatSound(Sound):
def make_sound(self):
return "Мяу!"
Смотрите, кошачий звук наследуется от звуков животных. Все выглядит логично. Теперь давайте в очередной раз имплементируем Борю:
class BoryaCoolCat:
def __init__(self, sound: Sound):
self.sound = sound
def speak(self):
return self.sound.make_sound()
Заметили самый сок? В конструкторе класса мы получаем звук типа Sound. Значит, если мы в какой-то момент решим, что Боря не кот, а, например, крокодил, нам не нужно будет переписывать класс BoryaCoolCat. Достаточно просто передать в него любой другой класс, который наследуется от Sound!
Этот принцип очень неплохо работает в больших проектах. Тут на помощь приходит DI-контейнер. Возможно, в следующих статьях затрону эту занятную тему.
Заключение
В завершение нашего solidного путешествия по принципам и мискам хочется подчеркнуть, что все это — не просто набор правил. Да, ты можешь выучить их к экзамену или собесу и не вспоминать больше. Но они и правда помогают создавать более качественный и поддерживаемый код.
Хороший код, как и котики, требует внимания и заботы. Применяя принципы SOLID, мы не только улучшаем его структуру, но и делаем более понятным для других разработчиков (а также для нас самих в будущем). В конечном итоге, это позволяет сосредоточиться на решении задач, а не на борьбе с последствиями наших костылей.
Создание качественного кода — это не просто задача, а искусство. Надеюсь, эта статья вдохновила вас взглянуть на принципы SOLID с новой стороны и начать применять их в своей практике.