Материал

ООП в Python

Объектно-ориентированное программирование (ООП) — подход, в котором программа строится из взаимодействующих объектов, объединяющих состояние и поведение. Объекты часто являются экземплярами классов, но классы не обязаны образовывать единую прикладную иерархию наследования.

Принципы ООП

Полиморфизм

**Полиморфизм **- способность функции через единый интерфейс работать с данными разных типов. Существует 2 вида полиморфизма:

Параметрический полиморфизм позволяет писать код, параметризованный типом, например через TypeVar и generic-типы. Duck typing ближе к полиморфизму подтипов по поведению: код использует поддерживаемый объектом интерфейс, а не требует конкретный класс. Например, мы можем создать функцию, которая будет суммировать длину всех последовательностей, которые мы в нее передаем. И нам неважно, к каким именно классам будут принадлежать объекты, им необязательно быть подтипами общего класса, главное, чтобы у них был реализован метод __len__. Ad-hoc-полиморфизм выбирает реализацию в зависимости от типов аргументов. В Python он встречается, например, в перегрузке операторов через специальные методы и в functools.singledispatch; статические перегрузки для анализаторов типов описываются через typing.overload.

Инкапсуляция

Инкапсуляция - механизм языка, позволяющий объединить данные и методы, работающие с этими данными, в единый объект и скрыть детали реализации от пользователя. Инкапсуляция в Python реализована путем определения атрибутов и методов класса внутри класса. Сокрытие - это необязательный аспект инкапсуляции, который подразумевает, что данные класса скрыты от прямого доступа извне класса.

Сокрытие в Python не строгое: язык использует соглашения об именовании и name mangling, а не модификаторы доступа, запрещающие обращение к атрибуту.

Наследование

**Наследование **- принцип ООП, согласно которому мы можем создать класс (дочерний) на основе другого класса (родительского). Дочерний класс будет получать весь функционал родительского класса. При этом мы можем переопределить методы родительского класса в дочернем.

Python поддерживает множественное наследование. В ромбовидной иерархии порядок поиска определяется полной C3-линеаризацией MRO, а не простым правилом «слева направо». Иллюстрация к материалу «ООП в Python» Виды наследования:

Одиночное

Иллюстрация к материалу «ООП в Python»

Многоуровневое

Иллюстрация к материалу «ООП в Python»

Множественное

Иллюстрация к материалу «ООП в Python»

Иерархическое

Иллюстрация к материалу «ООП в Python»

Гибридное

комбинирует два или более типа наследования Иллюстрация к материалу «ООП в Python»

По умолчанию все пользовательские классы в Python наследуются от object. Чтобы узнать, является ли один класс наследником (любого уровня, не только прямым потомком) другого, есть функция issubclass(child, parent).

Абстракция

Абстракция - это использование только тех характеристик объекта, которые с достаточной точностью представляют его. Мы скрываем ту часть реализации, с которой нам не нужно взаимодействовать, для упрощения работы с объектом. В Python абстракция реализована с помощью абстрактных классов из модуля ABC.

Абстрактные классы

Абстрактный класс - это класс, который является классом-шаблоном для других классов. Он содержит абстрактные методы и не предназначен для создания объектов напрямую (если мы попытаемся создать экземпляр абстрактного класса, то получим ошибку). Абстрактные классы нужны, чтобы создать общий интерфейс для классов и быть уверенными, что все дочерние классы будут обязательно иметь реализацию каждого абстрактного метода. Если мы попытаемся создать класс, унаследованный от абстрактного класса, и не реализуем все абстрактные методы, то получим ошибку. Разница интерфейса и абстрактного класса в том, что интерфейс не содержит реализации, а абстрактный класс может помимо абстрактных методов содержать и часть реализованных методов. Модуль abc предоставляет базовый класс ABC, от которого должны наследоваться абстрактные классы и декоратор @abstractmethod для создания абстрактных методов.

from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @abstractmethod
    def make_sound(self):
        pass

class Dog(Animal):
    def make_sound(self):
        return "Woof!"

class Cat(Animal):
    def make_sound(self):
        return "Meow!"

MRO (Method Resolution Order)

MRO — порядок поиска методов и атрибутов в классе и его базовых классах. Начиная с Python 2.3 для new-style classes, а в Python 3 для всех классов, используется C3-линеаризация. Это не обход в ширину: C3 сохраняет локальный порядок базовых классов и монотонность иерархии. См. Python MRO HOWTO. MRO можно узнать с помощью функции mro(). Могут возникнуть ситуации, когда иерархию классов, которую мы пытаемся реализовать, нельзя линеаризовать. Тогда мы получим ошибку TypeError: Cannot create a consistent method resolution order:

class X:
    pass

class Y(X):
    pass

class A(X, Y):
    pass

Композиция

**Композиция **— это концепция объектно-ориентированного программирования, в которой один класс включает в себя объекты другого класса в качестве своего атрибута. Вместо того чтобы наследовать поведение другого класса, объект использует функциональность другого класса, делая его частью своей реализации. Например, у нас есть классы Car и Engine, Engine относится к Car, но не является его подвидом. То есть связь не is-a, как при наследовании (например Sedan is a Car) а **has-a, Car has an **Engine.

class Engine:
    def start(self):
        print("Engine started")

class Car:
    def __init__(self):
        self.engine = Engine()  # Композиция - объект Car включает в себя объект Engine

    def drive(self):
        print("Car is moving")
        self.engine.start()

slots

Объекты хранят свойства и их значения в словаре __dict__. Поскольку словарь – изменяемая структура, вы можете на лету добавлять и удалять из класса поля, но это требует больше ресурсов. Параметр __slots__ в классе жестко фиксирует набор полей объекта. При этом в самом классе мы все так же можем объявлять любые свойства. __slots__ может убрать отдельный __dict__ у экземпляров и уменьшить расход памяти, но не даёт универсальной гарантии ускорения. __dict__ может быть унаследован, добавлен явно в slots или снова появиться у подкласса без __slots__.

class Person:
    __slots__ = ('name', 'age')

    def __init__(self, name, age):
        self.name = name
        self.age = age

Миксины

Миксин (mix-in - “примесь”) - небольшой класс-помощник, который добавляется в цепочку наследования. Они позволяют добавлять новую функциональность в классы, не изменяя их иерархии наследования. Миксины могут быть использованы для решения общих задач, которые возникают в разных классах. Например, миксин для логирования или кэширования результатов методов. В названия миксинов принято добавлять слово Mixin, так как не существует никакого механизма для понимания полноценный это класс или миксин. Миксин технически является самым обычным классом.

не очень важно

Для cooperative multiple inheritance конструкторы классов должны иметь совместимые сигнатуры, вызывать super() и корректно передавать аргументы дальше — часто через именованные параметры или **kwargs. «Без параметров» — возможное соглашение проекта, а не правило Python.

Функция super()

Функция super() возвращает proxy для делегированного поиска атрибутов после указанного класса в MRO. Это не экземпляр родительского класса, и сам вызов super() ещё не вызывает метод. Например, у нас в дочернем классе есть __init__, который принимает на один дополнительный параметр больше, чем __init__ в родительском классе. Тогда, чтобы не дублировать код, вызываем через super() __init__ в родительском классе и прокидываем туда аргументы из дочернего класса:

class Parent:
    def __init__(self, name):
        self.name = name

class Child(Parent):
    def __init__(self, name, age):
        super().__init__(name)
        self.age = age

super(type, object_or_type=None) принимает 2 аргумента - класс и объект этого класса. Класс указывает с какого класса в MRO нужно начинать поиск метода. Объект класса указывает, что полученный метод должен быть привязан к этому объекту. Два вызова super() ниже одинаковы:

class Square(Rectangle):
    def __init__(self, width, height):
        super().__init__(width, height)

Но для большинства случаев хватает вызова super() без параметров.

Public, protected и private

В Python нет принудительных модификаторов доступа public, protected и private, как в некоторых других языках.

  • Имя без ведущего подчёркивания считается публичным API.
  • Имя _method по соглашению считается внутренней деталью реализации. Интерпретатор не запрещает доступ к нему ни внешнему коду, ни наследникам.
  • Имя __method запускает name mangling: внутри определения класса оно преобразуется в _ClassName__method. Это помогает избегать случайных конфликтов имён в наследниках, но не обеспечивает безопасность и не делает атрибут недоступным извне.
class User:
    def __init__(self):
        self.__name = "John"


john = User()
print(john._User__name)

Name mangling - механизм в Python, с помощью которого имена приватных свойств объектов, начинающиеся с двух подчеркиваний типа __method, заменяются на _Class__method

Classmethod и staticmethod

@classmethod - это метод, который получает ссылку на класс cls в качестве неявного первого аргумента, точно так же, как обычный метод экземпляра получает ссылку на экземпляр self. Используется, если мы работаем в методе только с атрибутами класса. @staticmethod – используется для создания метода, который ничего не знает о классе или экземпляре, через который он был вызван. Он просто получает переданные аргументы, без неявного первого аргумента self или cls. По факту это обычная функция, определенная для удобства внутри класса и которая может быть вызвана через объект класса или его экземпляра. Может быть полезно для вспомогательных функций, чтобы не засорять пространство имён модуля.

Геттеры и сеттеры, property

Геттеры и сеттеры позволяют сохранить стабильный API атрибута и добавить вычисление или валидацию. Они инкапсулируют детали реализации, но сами по себе не являются механизмом безопасности. Декоратор @property используется, чтобы сделать из метода свойство. То есть мы будем использовать синтаксис обычного обращения к свойству объекта, а под капотом у нас будет вызываться соответствующий геттер или сеттер.

class Alphabet:
    def __init__(self, value):
        self._value = value

    # getting the values
    @property
    def value(self):
        print('Getting value')
        return self._value

    # setting the values
    @value.setter
    def value(self, value):
        print('Setting value to ' + value)
        self._value = value

    # deleting the values
    @value.deleter
    def value(self):
        print('Deleting value')
        del self._value

Декоратор @property является синтаксическим сахаром для функции property(fget=None, fset=None, fdel=None, doc=None), которая реализует протокол дескриптора. Параметры fget, fset, fdel отвечают соответственно за геттер, сеттер и делитер.

Магические методы для атрибутов

__setattr__(self, key, value) - автоматически вызывается при изменении свойства key __getattribute__(self, item) - автоматически вызывается при получении свойства с именем item __getattr__(self, item) - автоматически вызывается при получении несуществующего свойства с именем item __delattr__(self, item) - автоматически вызывается при удалении свойства с именем item (неважно, существует оно или нет)

Процесс создания объекта

Процесс создания объекта в Python представляет собой последовательный вызов двух методов:
__new__(cls, *args, **kwargs) вызывается первым и отвечает непосредственно за создание объекта, он принимает ссылку на класс, объект которого создается. __new__ возвращает созданный объект класса. Этот метод довольно редко переопределяют, обычно при наследовании от неизменяемых типов или при реализации паттерна Singleton. __init__(self, *args, **kwargs) отвечает за инициализацию уже созданного объекта. Он должен вернуть None; явный возврат другого значения приводит к TypeError. Например, у нас есть класс Language:

class Language:
    def __init__(self, lang, year):
        self.lang = lang
        self.year = year

Тогда создание объекта через вызов класса

language = Language('Python', 1991)

Будет эквивалентно такому коду

language = object.__new__(Language)
language.__init__('Python', 1991)

Дескрипторы

Дескрипторы - это объекты Python, реализующие протокол дескриптора, который позволяет создавать объекты с особым поведением при обращении к ним в качестве атрибутов других объектов. Протокол дескриптора состоит из следующих методов:

__get__(self, obj, owner=None) -> Any
__set__(self, obj, value) -> None
__delete__(self, obj) -> None
__set_name__(self, owner, name)

self - это экземпляр дескриптора
obj - объект, к которому прикреплен дескриптор.
owner - класс объекта, к которому прикреплен дескриптор. Если дескриптор реализует только __get__, то это non-data descriptor. Если же он реализует также __set__ или __delete__, то это data descriptor. Дескрипторы участвует в цепочке attribute lookup. Объект дескриптора создается только один раз для каждого свойства. Это означает, что все объекты с таким свойством разделяют один и тот же объект дескриптора. Поэтому значения нужно сохранять не в объекте дескриптора:

def __set__(self, obj, value):
    self.value = value

а в объекте, на котором вызван дескриптор:

def __set__(self, obj, value) -> None:
    obj.__dict__[self.name] = value

Атрибут self.name мы устанавливаем в методе __set_name__, который вызывается при создании объекта дескриптора. Здесь в атрибут name прокидывается значение 'number':

class OneDigitNumericValue():
    def __set_name__(self, owner, name):
        self.name = name

class Foo():
    number = OneDigitNumericValue()

Дескрипторы используются довольно редко. Один из юзкейсов - создание lazy properties:

import time

class LazyProperty:
    def __init__(self, function):
        self.function = function
        self.name = function.__name__

    def __get__(self, obj, type=None) -> object:
        obj.__dict__[self.name] = self.function(obj)
        return obj.__dict__[self.name]

class DeepThought:
    @LazyProperty
    def meaning_of_life(self):
        time.sleep(3)
        return 42

my_deep_thought_instance = DeepThought()
print(my_deep_thought_instance.meaning_of_life)
print(my_deep_thought_instance.meaning_of_life)
print(my_deep_thought_instance.meaning_of_life)

# весь код выполнится за 3 секунды

Объяснение, как это работает - здесь, если коротко - благодаря lookup chain. Также дескриптором можно заменить повторяющуюся логику в разных property:

class Values:
    def __init__(self):
        self._value1 = 0
        self._value2 = 0

    @property
    def value1(self):
        return self._value1

    @value1.setter
    def value1(self, value):
        self._value1 = value if value % 2 == 0 else 0

    @property
    def value2(self):
        return self._value2

    @value2.setter
    def value2(self, value):
        self._value2 = value if value % 2 == 0 else 0

    # и еще много таких же property...

Вместо этого мы можем прописать дескриптор:

class EvenNumber:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, type=None) -> object:
        if obj is None:
            return self
        return obj.__dict__.get(self.name, 0)

    def __set__(self, obj, value) -> None:
        obj.__dict__[self.name] = (value if value % 2 == 0 else 0)

class Values:
    value1 = EvenNumber()
    value2 = EvenNumber()

Attribute lookup

Когда мы обращаемся к атрибуту объекта через точку, Python ищет этот атрибут в таком порядке:

  1. Сначала мы получим результат, возвращенный методом __get__ data descriptor’а с именем искомого атрибута.
  2. Если это не удается, то мы получим значение из словаря __dict__ объекта по ключу с именем искомого атрибута.
  3. Если это не удастся, то мы получим результат, возвращенный методом __get__ **non-data descriptor’а **с именем искомого атрибута.
  4. Если это не удается, то мы получим значение из словаря __dict__ класса, к которому принадлежит объект, по ключу с именем искомого атрибута.
  5. Если это не удастся, то атрибут будет искаться в словарях __dict__ всех классов, от которых отнаследован класс объекта, согласно MRO.
  6. Если обычный поиск не нашёл атрибут, Python может вызвать __getattr__. Если и он не вернул значение, будет выброшено AttributeError.

Магические методы

Магическими называют методы, имена которых начинаются и заканчиваются двойным подчеркиванием. Магические они потому, что почти никогда не вызываются явно. Их вызывают встроенные функции или синтаксические конструкции. Например, функция len() вызывает метод __len__() переданного объекта. Метод __add__(self, other) вызывается автоматически при сложении оператором +. __add__ - сложение + __sub__ - вычитание - __mul__ - умножение * __truediv__ - деление / __eq__ ==
__ne__ !=
__lt__ <
__le__ <=
__gt__ >
__ge__ >= Когда мы переопределяем метод __eq__ в классе, то в нем перестает по умолчанию работать метод __hash__, поэтому, если он нужен, надо его также переопределить. Функция bool() для пользовательских классов вызывает метод __bool__, если он не определен - то смотрит на __len__, если он также не определен - возвращает True. __getitem__(self, item) - получение значения по ключу item __setitem__(self, key, value) - запись значения value по ключу key __delitem__(self, key) - удаление элемента по ключу key __repr__ отвечает за отображение объекта внутри нашей системы, которое может быть использовано для воссоздания объекта. __str__ возвращает менее формальное представление объекта, которое будет отображаться для пользователя. Если метод __str__ явно не прописан, то вызывается __repr__

Исключения

Исключения выбрасываются интерпретатором либо если мы вручную пропишем raise. Для обработки существует конструкция try except else finally.

try:
    risky_operation()
except (TypeError, ValueError) as error:
    handle_expected_error(error)
except LookupError as error:
    handle_lookup_error(error)
else:
    handle_success()
finally:
    cleanup()

Блок finally выполняется при выходе из try, в том числе перед фактическим возвратом из функции. return внутри finally подавляет ранее подготовленный результат или исключение, поэтому почти всегда является ошибкой проектирования. Нужно обрабатывать как можно более конкретный тип исключения, чтобы не задевать лишние и поведение программы было более предсказуемым. Главный класс исключений - BaseException, от него наследуется Exception, а от Exception большая часть всех исключений Иллюстрация к материалу «ООП в Python» Мы можем создавать свои кастомные исключения, для этого нужно создать класс, который наследуется от Exception или его потомка. Принято называть исключения так, что имя их класса заканчивается словом Error.

Сцепление исключений

Сцепление исключений - это техника обработки исключений путем повторного вызова перехваченного исключения после его обертывания в новое исключение. Оригинальное исключение сохраняется как свойство нового исключения. При возбуждении исключения внутри блока except, старое исключение сохраняется в атрибуте данных __context__ и если новое исключение не обработано, то будет выведена информация о том, что новое исключение возникло при обработке старого “During handling of the above exception, another exception occurred". Также можно связывать исключения в одну цепь или заменять старые новыми. Для этого используется конструкция raise новое_исключение from старое_исключение, тогда старое исключение сохраняется в атрибуте __cause__ и будет выведено сообщение “The above exception was the direct cause of the following exception”. Либо raise новое_исключение from None, тогда при выводе старое исключение из блока except, фактически, будет заменено новым (хотя старое исключение всё ещё хранится в __context__). добавить exception group

Контекстный менеджер

Контекстный менеджер - это конструкция with, которая предоставляет управление доступом к некому ресурсу. Он выполняет какие-то действия перед получением доступа к ресурсу и после того, как работа с ним завершена. Контекстный менеджер должен реализовывать два метода:

  • __enter__ (__aenter__ для async). Срабатывает в момент входа и может возвращать какое-то значение, которое можно получить с помощью оператора as:
with open('file.txt') as f:
    data = f.read()
  • __exit__ (__aexit__ для async) срабатывает в момент выхода из блока, в том числе и по причине исключения. В этом случае в метод будут переданы 3 значения (exception_type, exception_value, exception_traceback). Если исключения не произошло, то эти параметры будут None. При обработке исключений в методе __exit__ важно то, что он возвращает.
  1. В случае, если исключение было обработано нормально, метод должен вернуть True.
  2. Ложное значение, включая None, не создаёт новое исключение, а позволяет исходному исключению распространиться выше. Если исключения не было, возвращаемое значение игнорируется. Помимо магических методов, мы можем реализовать контекстный менеджер с использованием декоратора @contextmanager из встроенной библиотеки contextlib
from contextlib import contextmanager

@contextmanager
def my_context_manager():
    # Код, выполняемый при входе в контекст (начало блока with)
    print("Вход в контекст")

    try:
        # Возвращаем значение, которое будет доступно в блоке with
        yield "Значение, доступное в блоке with"
    finally:
        # Код, выполняемый при выходе из контекста (окончание блока with)
        print("Выход из контекста")

Метаклассы

Метаклассы - шаблон для классов. Основная цель метаклассов - автоматически изменять класс в момент создания. Обычно это делают для API, типичный пример - Django ORM. Мы создаем простой класс модели, а Django под капотом преобразует его в более сложный класс:

class Person(models.Model):
  name = models.CharField(max_length=30)
  age = models.IntegerField()

Чаще всего в метаклассах определяется метод __new__, чтобы, например, добавить создаваемому классу новый метод. Также могут определяться методы __prepare__, __init__, __call__. Примером встроенного метакласса является type. Чтобы создать свой метакласс, нужно отнаследовать его от type. Чтобы создать класс с использованием метакласса, нужно указать его при создании класса в параметре metaclass.

class MyMeta(type):
    def __new__(cls, name, bases, dct):
        # Изменения в классе
        dct['new_method'] = lambda self: print("New method added!")
        return super().__new__(cls, name, bases, dct)

class MyClass(metaclass=MyMeta):
    def existing_method(self):
        print("Existing method.")

obj = MyClass()
obj.new_method()  # Выведет: New method added!
obj.existing_method()  # Выведет: Existing method.

По дефолту каждый пользовательский класс является инстансом класса type. В примере выше MyMeta - инстанс type, а MyClass - инстанс MyMeta. В повседневном программировании метаклассы редко используются, и большинство разработчиков может обойтись без них.

Как создать класс без ключевого слова class?

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

  1. name (имя): Это строка, содержащая имя создаваемого класса.
  2. bases (базовые классы): Это кортеж, содержащий базовые классы, от которых наследуется создаваемый класс. Если класс не наследует ничего, можно передать пустой кортеж ().
  3. dict (словарь атрибутов): Это словарь, содержащий атрибуты и методы создаваемого класса. Ключи словаря - это имена атрибутов или методов, а значения - сами атрибуты или функции.

SOLID

SOLID - это набор 5 принципов объектно-ориентированного программирования и проектирования.

Single responsibility

Принцип единственной ответственности (single responsibility principle): модуль должен быть ответственным только за одну конкретную задачу, он должен иметь только одну причину для изменения. Нужно объединять код, который меняется по одной причине, и отделять от того, который меняется по другой.

Пример

Класс User нарушает принцип единственной ответственности:

class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email

    def save(self):
        # Логика сохранения пользователя в базе данных
        pass

    def send_email(self, message):
        # Логика отправки электронной почты пользователю
        pass

    def generate_report(self):
        # Логика генерации отчета пользователя
        pass

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

class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email

    def save(self):
        # Логика сохранения пользователя в базе данных
        pass


class EmailSender:
    def send_email(self, user, message):
        # Логика отправки электронной почты пользователю
        pass


class ReportGenerator:
    def generate_report(self, user):
        # Логика генерации отчета пользователя
        pass

Open/closed

Принцип открытости и закрытости (open/closed principle): модули должны быть открыты для расширения, но закрыты для модификации. Модули надо проектировать так, чтобы их требовалось менять как можно реже, а расширять функциональность можно было с помощью создания новых сущностей и композиции их со старыми.

Пример

Например, мы пишем сервис рассылки сообщений. Он принимает текст, который надо выслать, и сервис стороннего API для отправки СМС или эмейлов. Плохо спроектированный сервис мог бы выглядеть так:

class SmsSender:
    def send_sms(self, text):
        pass

class EmailSender:
    def send_email(self, text):
        pass

class Notifier:
    def __init__(self, api: SmsSender | EmailSender):
        self.api = api

    def notify(self):
        message = 'Some user notification'

        if isinstance(self.api, SmsSender):
            self.api.send_sms(message)
        elif isinstance(self.api, EmailSender):
            self.api.send_email(message)

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

# предыдущие классы

# добавили новый API:
class PushSender:
    def send_push(self, text):
        pass

class Notifier:
    def __init__(self, api: SmsSender | PushSender | EmailSender):
        self.api = api

    def notify(self):
        message = 'Some user notification'

        if isinstance(self.api, SmsSender):
            self.api.send_sms(message)
        elif isinstance(self.api, EmailSender):
            self.api.send_email(message)
        elif isinstance(self.api, PushSender):  # это новый код, который
            self.api.send_push(message)         # пришлось добавить

OCP же предлагает не проверять конкретные типы, а использовать абстракцию, которая позволит не менять код класса Notifier. Для этого мы создадим абстрактный класс Sender, от которого будут наследоваться классы SmsSender, PushSender и EmailSender:

from abc import ABC, abstractmethod


class Sender(ABC):
    @abstractmethod
    def send_message(self, text: str) -> None:
        pass

class SmsSender(Sender):
    def send_message(self, text):
        # то, что раньше было внутри метода send_sms()
        pass

class EmailSender(Sender):
    def send_message(self, text):
        # то, что раньше было внутри метода send_email()
        pass

class PushSender(Sender):
    def send_message(self, text):
        # то, что раньше было внутри метода send_push()
        pass

class Notifier:
    def __init__(self, api: Sender):
        self.api = api

    def notify(self):
        message = 'Some user notification'

        self.api.send_message(message)

Liskov substitution

Принцип подстановки Барбары Лисков (Liskov substitution principle): если функция может работать с базовым классом, то она должна уметь работать и с наследником этого класса. Следовать LSP проще, если не строить больших и глубоких иерархий. Обычно, для связи модулей достаточно интерфейса и его реализаций.

Пример

Допустим у нас есть базовый класс банковского счета Account, в котором есть три метода: просмотр остатка на счете, пополнение счета и оплата. Нам необходимо написать еще два класса: зарплатный счет SalaryAccount и депозитный счет DepositAccount, при этом зарплатный счет должен поддерживать все операции, представленные в базовом классе, а депозитный счет - не должен поддерживать проведение оплаты. Если в коде программы везде, где мы использовали класс Account заменить на его класс-наследник SalaryAccount, то программа продолжит нормально работать, так как в классе SalaryAccount доступны все операции, которые есть и в классе Account. Если же мы такое попробуем сделать с классом DepositAccount, то есть заменим базовый класс Account на его класс-наследник DepositAccount, то программа начнет неправильно работать, так как при вызове метода оплаты payment() будет выбрасываться исключение . Таким образом произошло нарушение принципа подстановки Барбары Лисков.

Interface segregation

Принцип разделения интерфейса (interface segregation principle): программные сущности не должны зависеть от интерфейсов, которые они не используют. Класс-наследник не должен тащить за собой методы класса-родителя, которые он не использует. Чаще всего это достигается за счет дробления интерфейса.

Пример

Например, у нас есть абстрактный класс Vehicle с методами go() и fly(), от которого наследуются Aircraft и Car. Самолет может как ехать, так и летать, а автомобиль может только ехать, то есть ему приходится реализовывать метод fly(), который он не использует - это нарушение ISP: Иллюстрация к материалу «ООП в Python» Решение - разделить интерфейс Vehicle на более мелкие интерфейсы Movable и Flyable: Иллюстрация к материалу «ООП в Python»

Dependency inversion

Низкоуровневые модули содержат утилитарную функциональность: обращение к базе данных, запросы к серверу, рендеринг DOM-элементов на странице. Высокоуровневые модули содержат сложную, более абстрактную бизнес-логику. Они достаточно абстрактны, чтобы их можно было переиспользовать в разных проектах: авторизация пользователей, валидация форм, отправка уведомлений. Принцип инверсии зависимостей (dependency inversion principle): высокоуровневые модули не должны зависеть от низкоуровневых; классы должны зависеть от абстракций, а не от конкретных реализаций. Не стоит создавать объекты в классе/функции, которая использует эти объекты. Лучше передавать уже готовые. Это уменьшает связанность между классами, делает их более гибкими и более тестируемыми (за счет возможности создания заглушек, реализующих нужный интерфейс). Принцип инверсии зависимостей снижает зацепление (coupling) модулей, позволяет проектировать систему так, чтобы модули были заменяемы на другие.

Пример

Возьмем класс, который имеет сервис отправки нотификаций:

from abc import ABC, abstractmethod
from typing import Any


Report = Any


class ReportSender(ABC):
    @abstractmethod
    def send(self, report: Report):
        pass

class TelegramReportSender(ReportSender):
    def send(self, report: Report):
        print("Отправили отчет в телегу")

class EmailReportSender(ReportSender):
    def send(self, report: Report):
        print("Отправили отчет на мыло")

class UserService:
    def __init__(self, username: str):
        self._username = username
        self._report_sender = TelegramReportSender()

В этом классе мы зависим от конкретной реализации ReportSender'а - от TelegramReportSender. Чтобы такого не было, мы можем передавать реализации ReportSender'а в конструктор:

class UserService:
    def __init__(self, username: str, report_sender: ReportSender):
        self._username = username
        self._report_sender = report_sender

user_service_with_email = UserService("kiriharu", EmailReportSender())
user_service_with_tg = UserService("kiriharu", TelegramReportSender())

Официальные источники