Продвинутый Python
December 2

Принципы 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 👈🏻

👨🏻‍💻Ещё больше полезного на OlegTalks💬

🍩 Поддержите канал 🫶

Источник: Python Tutorial
Перевод и адаптация: Екатерина Прохорова