Принципы SOLID в ООП с примерами на Python
SOLID – это аббревиатура, состоящая из названий пяти основных принципов проектирования в объектно-ориентированном программировании:
- S — принцип единой ответственности (Single responsibility Principle)
- O — принцип открытости / закрытости (Open-closed Principle)
- L — принцип подстановки Барбары Лисков (Liskov Substitution Principle)
- I — принцип разделения интерфейса (Interface Segregation Principle)
- D — принцип инверсии зависимостей (Dependency Inversion Principle)
Соблюдение этих принципов поможет сделать ваши проекты более удобными в обслуживании и гибкими.
Принцип единой ответственности
Принцип единой ответственности (SRP) гласит, что каждый класс, метод и функция должны выполнять только одну задачу или иметь только одну причину для изменения.
- создавать классы, методы и функции с высокой степенью взаимозависимости между подмодулями;
- способствовать композиции классов;
- избегать дублирования кода.
В качестве примера рассмотрим класс Person
:
class Person: def __init__(self, name): self.name = name def __repr__(self): return f'Person(name={self.name})' @classmethod def save(cls, person): print(f'Save the {person} to the database') if __name__ == '__main__': p = Person('John Doe') Person.save(p)
При такой композиции, если вы захотите сохранить информацию о сущности в другое место, например, в файл, вам нужно будет изменить метод save()
, который, в свою очередь, изменит весь класс Person
.
Чтобы класс соответствовал SRP, вам нужно создать второй, отвечающий за хранение информации о человеке в базе данных. Например:
class Person: def __init__(self, name): self.name = name def __repr__(self): return f'Person(name={self.name})' class PersonDB: def save(self, person): print(f'Save the {person} to the database') if __name__ == '__main__': p = Person('John Doe') db = PersonDB() db.save(p)
Таким образом, мы разделяем класс Person
на два (Person
и PersonDB
):
Person
отвечает за управление свойствами сущности «человек».PersonDB
отвечает за хранение данных о сущности в БД.
Теперь, чтобы сохранить информацию в другом хранилище, вам не нужно изменять класс Person
.
При написании классов следует помещать в один класс методы, имеющие одну причину для изменения. Если у них разные причины для изменения, лучше реализовать их в отдельных классах.
У этой конструкции есть одна проблема – теперь вам придётся иметь дело с двумя классами. Однако вы можете использовать паттерн «Фасад», чтобы класс Person
был фасадом для класса PersonDB
:
class PersonDB: def save(self, person): print(f'Save the {person} to the database') class Person: def __init__(self, name): self.name = name self.db = PersonDB() def __repr__(self): return f'Person(name={self.name})' def save(self): self.db.save(person=self) if __name__ == '__main__': p = Person('John Doe') p.save()
Принцип единой ответственности (SRP) гласит: каждый класс, метод и функция должны выполнять только одну задачу или иметь только одну причину для изменения.
Используйте принцип единой ответственности для разделения классов, методов и функций с одинаковой причиной для изменений.
Принцип открытости/закрытости
Это второй из пяти основных принципов объектно-ориентированного программирования. Он гласит, что любой класс, метод и функция должны быть открыты для расширения, но закрыты для модификации.
Звучит противоречиво. Цель, вообще-то, благая – упростить добавление новых функций (или вариантов использования) в систему без непосредственного изменения существующего кода.
class Person: def __init__(self, name): self.name = name def __repr__(self): return f'Person(name={self.name})' class PersonStorage: def save_to_database(self, person): print(f'Save the {person} to database') def save_to_json(self, person): print(f'Save the {person} to a JSON file') if __name__ == '__main__': person = Person('John Doe') storage = PersonStorage() storage.save_to_database(person)
В нём у класса PersonStorage
два метода:
save_to_database()
сохраняет информацию о сущности «человек» в базе данных;save_to_json()
сохраняет данные о сущности в файл JSON.
Класс PersonStorage
не будет соответствовать второму принципу ООП, если его нужно будет изменять всякий раз, когда возникнет необходимость сохранить объект Person в другой формат файла, например, в XML.
А как сделать так, чтобы класс ему соответствовал? Рассмотрим следующую схему классов:
Во-первых, определим абстрактный класс PersonStorage
, который содержит абстрактный метод save()
:
from abc import ABC, abstractmethod class PersonStorage(ABC): @abstractmethod def save(self, person): pass
Во-вторых, создадим классы PersonDB
и PersonJSON
, которые будут сохранять объект Person
в базе данных и файле JSON. Эти классы наследуются от класса PersonStorage
:
class PersonDB(PersonStorage): def save(self, person): print(f'Save the {person} to database') class PersonJSON(PersonStorage): def save(self, person): print(f'Save the {person} to a JSON file')
Теперь, чтобы сохранить данные о сущности «человек» в XML-файл, можно определить новый класс PersonXML
, который наследуется от класса PersonStorage
следующим образом:
class PersonXML(PersonStorage): def save(self, person): print(f'Save the {person} to an XML file') if __name__ == '__main__': person = Person('John Doe') storage = PersonXML() storage.save(person)
from abc import ABC, abstractmethod class Person: def __init__(self, name): self.name = name def __repr__(self): return f'Person(name={self.name})' class PersonStorage(ABC): @abstractmethod def save(self, person): pass class PersonDB(PersonStorage): def save(self, person): print(f'Save the {person} to database') class PersonJSON(PersonStorage): def save(self, person): print(f'Save the {person} to a JSON file') class PersonXML(PersonStorage): def save(self, person): print(f'Save the {person} to a XML file') if __name__ == '__main__': person = Person('John Doe') storage = PersonXML() storage.save(person)
Принцип открытости / закрытости позволяет спроектировать систему таким образом, чтобы её можно было расширять, но не изменять.
Принцип подстановки Барбары Лисков (LSP)
Принцип подстановки Лисков гласит: дочерний класс должен быть написан так, чтобы он мог заменить родительский. Таким образом дочерний класс мог бы занять место своего родительского класса, не вызывая никаких ошибок.
from abc import ABC, abstractmethod class Notification(ABC): @abstractmethod def notify(self, message, email): pass class Email(Notification): def notify(self, message, email): print(f'Send {message} to {email}') class SMS(Notification): def notify(self, message, phone): print(f'Send {message} to {phone}') if __name__ == '__main__': notification = SMS() notification.notify('Hello', '[email protected]')
У нас есть три класса: Notification
, Email
и SMS
. Классы Email
и SMS
наследуются от класса Notification
.
В абстрактном классе Notification
есть метод notify()
, который отправляет сообщение на электронную почту.
Метод notify()
в классе Email
отправляет сообщение на электронную почту, что вполне нормально.
Однако в классе SMS
для отправки сообщения используется номер телефона, а не адрес электронной почты. Поэтому нам нужно изменить сигнатуру метода notify()
класса SMS
, чтобы он принимал номер телефона вместо электронной почты.
Следующий класс NotificationManager
использует объект Notification
для отправки сообщения контакту (Contact
):
class Contact: def __init__(self, name, email, phone): self.name = name self.email = email self.phone = phone class NotificationManager: def __init__(self, notification, contact): self.contact = contact self.notification = notification def send(self, message): if isinstance(self.notification, Email): self.notification.notify(message, contact.email) elif isinstance(self.notification, SMS): self.notification.notify(message, contact.phone) else: raise Exception('The notification is not supported') if __name__ == '__main__': contact = Contact('Some Name', '[email protected]', '8-999-999-9999') notification_manager = NotificationManager(SMS(), contact) notification_manager.send('Hello!')
Метод send()
класса NoticationManager
принимает некое уведомление и проверяет, является ли оно экземпляром класса Email
или класса SMS
. После метод передает электронную почту и телефон контакта в метод notify()
соответственно.
Делаем всё правильно
Для этого переопределим метод notify()
класса Notification
так, чтобы он не включал параметр email
:
class Notification(ABC): @abstractmethod def notify(self, message): pass
Затем добавляем параметр email
в метод __init__
класса Email
:
class Email(Notification): def __init__(self, email): self.email = email def notify(self, message): print(f'Send "{message}" to {self.email}')
Добавляем параметр phone
в метод __init__
класса SMS
:
class SMS(Notification): def __init__(self, phone): self.phone = phone def notify(self, message): print(f'Send "{message}" to {self.phone}')
Изменяем класс NotificationManager
:
class NotificationManager: def __init__(self, notification): self.notification = notification def send(self, message): self.notification.notify(message)
Просто как 0, 1, 2, 3. Собираем всё в кучу:
from abc import ABC, abstractmethod class Notification(ABC): @abstractmethod def notify(self, message): pass class Email(Notification): def __init__(self, email): self.email = email def notify(self, message): print(f'Send "{message}" to {self.email}') class SMS(Notification): def __init__(self, phone): self.phone = phone def notify(self, message): print(f'Send "{message}" to {self.phone}') class Contact: def __init__(self, name, email, phone): self.name = name self.email = email self.phone = phone class NotificationManager: def __init__(self, notification): self.notification = notification def send(self, message): self.notification.notify(message) if __name__ == '__main__': contact = Contact('John Doe', '[email protected]', '(408)-888-9999') sms_notification = SMS(contact.phone) email_notification = Email(contact.email) notification_manager = NotificationManager(sms_notification) notification_manager.send('Hello John') notification_manager.notification = email_notification notification_manager.send('Hi John')
Принцип подстановки Барбары Лисков гласит: дочерний класс должен быть написан так, чтобы он мог заменить свой родительский класс, не вызывая при этом ошибок.
Принцип разделения интерфейса (ISP)
Интерфейс — это условный перечень описаний поведения некоего объекта. Например, вы нажимаете кнопку на пульте телевизора, «ящик» включается, и вам особо не важно, как.
В объектно-ориентированном программировании интерфейс — это набор методов, которыми должен обладать объект. Цель интерфейсов — позволить пользователям запрашивать через него нужные методы объекта.
В качестве интерфейсов в Python используются абстрактные классы благодаря так называемому «принципу утиной типизации». Он гласит:
«Если оно ходит как утка и крякает как утка, то оно должно быть уткой»
Другими словами, методы класса определяют, какими будут его объекты, а не тип класса.
Принцип разделения интерфейсов гласит: интерфейс должен быть как можно более лаконичным с точки зрения связности. Другими словами, он должен выполнять всего ОДНУ какую-то задачу.
Это не означает, что должен быть только один метод. Интерфейс может иметь несколько связных методов.
Например, у интерфейса класса Database
могут быть методы connect()
и disconnect()
, потому что они должны работать вместе. С одним методом конкретно этот класс будет неполным.
Дядя Боб, который является родоначальником термина SOLID, объясняет принцип разделения интерфейсов так:
«Создавайте мелкодисперсные интерфейсы, специфичные для клиента. Клиенты не должны быть вынуждены использовать то, что им не нужно»
Как НЕ НАДО делать
Определяем абстрактный класс Vehicle
с двумя абстрактными методами go()
и fly()
:
from abc import ABC, abstractmethod class Vehicle(ABC): @abstractmethod def go(self): pass @abstractmethod def fly(self): pass
Определяем класс Aircraft
, который наследуется от класса Vehicle
и реализует методы go()
и fly()
:
class Aircraft(Vehicle): def go(self): print("Taxiing") def fly(self): print("Flying")
Определяем класс Car
, который наследуется от класса Vehicle
. Поскольку автомобиль не может летать (если, конечно, это не старенький Форд Англия ;)), мы вызываем исключение в методе fly()
:
class Car(Vehicle): def go(self): print("Going") def fly(self): raise Exception("The car cannot fly")
В этой конструкции класс Car
вынужден реализовать метод fly()
из класса Vehicle
, но не использует. Такая конструкция нарушает принцип разделения интерфейсов.
Как НАДО делать
Разобьём класс Vehicle
: создадим абстрактный класс Movable
и наследуемый от него Flyable.
class Movable(ABC): @abstractmethod def go(self): pass class Flyable(Movable): @abstractmethod def fly(self): pass
Тогда Aircraft
станет классом-потомком Flyable
:
class Aircraft(Flyable): def go(self): print("Taxiing") def fly(self): print("Flying")
А Car
— классом-потомком Movable
:
class Car(Movable): def go(self): print("Going")
При такой композиции кода классу Car
нужно реализовать только метод go()
и не нужно реализовывать метод fly()
, который он не использует.
Создавайте мелкодисперсные интерфейсы, специфичные для клиента. Клиентов не следует заставлять реализовывать интерфейсы, которые они не используют.
Принцип инверсии зависимостей (DIP)
Принцип инверсии зависимостей гласит:
- Модули высокого уровня не должны зависеть от модулей низкого уровня. И те, и другие должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Принцип инверсии зависимостей направлен на уменьшение связи между классами путем создания между ними слоя абстракции.
Как вам, а? Погнали разбираться, кто что курил.
class FXConverter: def convert(self, from_currency, to_currency, amount): print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}') return amount * 1.2 class App: def start(self): converter = FXConverter() converter.convert('EUR', 'USD', 100) if __name__ == '__main__': app = App() app.start()
Есть два класса: FXConverter
и App
. Представим, что класс FXConverter
использует API стороннего конвертора для перевода суммы из одной валюты в другую. Для простоты мы жестко закодировали обменный курс как 1,2. На практике для получения обменного курса вам придется обратиться к API.
У класса App
есть метод start()
, который использует экземпляр класса FXconverter
для конвертации 100 EUR в USD.
App
— это модуль, который в значительной степени зависит от класса FXConverter
, который, в свою очередь, зависит от API конвертора.
В будущем, если API конвертора изменится, это приведет к поломке кода. Также, если вы захотите использовать другой API, вам нужно будет изменить класс App
.
Чтобы всего этого избежать, необходимо инвертировать зависимость таким образом, чтобы класс FXConverter
адаптировался к классу App
.
Для этого вы определяете интерфейс и делаете App
зависимым от него, а не от класса FXConverter
. Затем вы изменяете FXConverter
так, чтобы он соответствовал интерфейсу.
Сначала определяем абстрактный класс CurrencyConverter
, который будет действовать как интерфейс. Пусть у него будет метод convert()
, который должны реализовывать все подклассы CurrencyConverter
:
from abc import ABC class CurrencyConverter(ABC): def convert(self, from_currency, to_currency, amount) -> float: pass
Далее определяем класс FXConverter
так, чтобы он наследовался от класса CurrencyConverter
и реализовал метод convert()
:
class FXConverter(CurrencyConverter): def convert(self, from_currency, to_currency, amount) -> float: print('Converting currency using FX API') print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}') return amount * 2
И добавляем метод __init__
в класс App
и инициализируйте объект CurrencyConverter
:
class App: def __init__(self, converter: CurrencyConverter): self.converter = converter def start(self): self.converter.convert('EUR', 'USD', 100)
Теперь класс App
зависит от интерфейса CurrencyConverter
, а не от класса FXConverter
.
Ниже пример создания экземпляра FXConverter
и его передачи в App
:
if __name__ == '__main__': converter = FXConverter() app = App(converter) app.star # Converting currency using FX API # 100 EUR = 120.0 USD
В будущем можно реализовать поддержку API другого конвертера валют, создав подкласс CurrencyConverter
. Например, ниже определен класс AlphaConverter
, который наследуется от CurrencyConverter
.
class AlphaConverter(CurrencyConverter): def convert(self, from_currency, to_currency, amount) -> float: print('Converting currency using Alpha API') print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}') return amount * 1.15
Поскольку класс AlphaConvert
наследуется от класса CurrencyConverter
, то можно использовать его объект в классе App
без изменения последнего:
if __name__ == '__main__': converter = AlphaConverter() app = App(converter) app.start() # Converting currency using Alpha API # 100 EUR = 120.0 USD
from abc import ABC class CurrencyConverter(ABC): def convert(self, from_currency, to_currency, amount) -> float: pass class FXConverter(CurrencyConverter): def convert(self, from_currency, to_currency, amount) -> float: print('Converting currency using FX API') print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}') return amount * 1.15 class AlphaConverter(CurrencyConverter): def convert(self, from_currency, to_currency, amount) -> float: print('Converting currency using Alpha API') print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}') return amount * 1.2 class App: def __init__(self, converter: CurrencyConverter): self.converter = converter def start(self): self.converter.convert('EUR', 'USD', 100) if __name__ == '__main__': converter = AlphaConverter() app = App(converter) app.start()
Используйте принцип инверсии зависимостей, чтобы сделать ваш код более надежным. Делайте высокоуровневый модуль зависимым от абстракции, а не от конкретной реализации.
👉🏻Подписывайтесь на PythonTalk в Telegram 👈🏻
Источник: Python Tutorial
Перевод и адаптация: Екатерина Прохорова