Перейти к основному содержимому

Углубленное ООП

📺 Слайды к лекции

Вы уже умеете писать классы и использовать наследование. Но понимаете ли вы, что происходит, когда вы пишете obj.method()? Как Python ищет атрибуты, почему self передаётся явно и на чём построены property, classmethod и super()? Эта лекция — мост между «использую ООП» и «понимаю, как оно устроено внутри».

Классы и объекты

Класс — это чертёж. Объект — то, что построено по этому чертежу. Класс описывает, какие данные (атрибуты) и какое поведение (методы) будет у объекта. Сам по себе класс ничего не делает — как архитектурный план не является зданием.

class_basics.py
class Car:
def __init__(self, brand: str, speed: int = 0):
self.brand = brand
self.speed = speed

def accelerate(self, delta: int) -> None:
self.speed += delta

# Класс — чертёж, объект — конкретная машина
my_car = Car("Toyota", 60)
my_car.accelerate(20)
print(my_car.speed) # 80

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

Принципы ООП

Полиморфизм

Полиморфизм — исполнение разного кода одинаковыми вызовами. В Python полиморфизм повсюду благодаря утиной типизации: если объект умеет quack(), неважно, утка это или робот.

polymorphism.py
class Dog:
def speak(self) -> str:
return "Гав!"

class Cat:
def speak(self) -> str:
return "Мяу!"

def greet(animal) -> None:
print(animal.speak())

greet(Dog()) # Гав!
greet(Cat()) # Мяу!

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

Инкапсуляция — объединение данных и методов в один компонент с контролируемым доступом. Мы прячем внутреннее состояние и предоставляем интерфейс для работы с ним.

В Python нет настоящих private-полей. Всё работает на уровне договорённостей — и name mangling для __var:

encapsulation.py
class PasswordKeeper:
def __init__(self) -> None:
self.__password: str | None = None # name mangling: _PasswordKeeper__password

def set_password(self, new_pass: str) -> None:
if len(new_pass) < 8:
raise ValueError("Пароль слишком короткий")
self.__password = new_pass

def check_password(self, attempt: str) -> bool:
return self.__password == attempt

keeper = PasswordKeeper()
keeper.set_password("securepass123")
# keeper.__password # AttributeError!
print(keeper._PasswordKeeper__password) # securepass123 — доступ есть, но так не делают
Важно

Двойное подчёркивание __var — это не защита, а защита от случайного переопределения в подклассах. Python переименовывает __var в _ClassName__var (name mangling), но доступ к атрибуту по полному имени всё равно открыт.

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

Наследование — передача всех свойств класса потомку. Мощный инструмент, но часто усложняет код, заставляя строить глубокие иерархии. На практике наследованию нередко предпочитают композицию.

inheritance.py
class Animal:
def __init__(self, name: str):
self.name = name

def speak(self) -> str:
return "..."

class Dog(Animal):
def speak(self) -> str:
return f"{self.name} говорит: Гав!"

dog = Dog("Шарик")
print(dog.speak()) # Шарик говорит: Гав!

Абстракция

Абстракция — представление объекта минимальным набором полей и методов, достаточным для решаемой задачи. Мы моделируем не весь реальный мир, а только то, что нужно программе.

Закон протекающих абстракций

Все нетривиальные абстракции до определённого предела протекают. — Joel Spolsky

Абстракции упрощают работу со сложными системами, но в определённых ситуациях «протекают» — пропускают наверх детали нижележащих слоёв. ORM скрывает SQL, но при N+1 запросах вы вынуждены думать о SQL. Сетевой протокол скрывает ненадёжность канала, но таймауты всё равно случаются.

к сведению

Это не повод отказываться от абстракций — это повод понимать, что лежит под ними. Хороший разработчик знает и интерфейс, и реализацию.

Соглашения об именовании (дандеры)

«Дандер» — double underscore. В Python нет private/protected, поэтому всё строится на договорённостях:

ФорматНазначение
_var«Приватный» атрибут — не трогайте извне
var_Избежать конфликта с ключевым словом (class_, list_)
__varName mangling — не перейдёт в наследника напрямую
__var__Магический метод (__init__, __str__, __add__)

Создание объектов: __new__ и __init__

В Python создание объекта происходит в два этапа: __new__ создаёт экземпляр, __init__ инициализирует его.

new_init.py
class MyClass:
def __new__(cls):
print("__new__ вызван")
instance = super().__new__(cls)
return instance

def __init__(self):
print("__init__ вызван")

obj = MyClass()
# __new__ вызван
# __init__ вызван
Важно

Если __new__ возвращает объект другого типа, __init__ не вызывается:

class A:
def __new__(cls):
return 1 # не объект A!
def __init__(self):
print("Никогда не выполнится")

A() # вернёт 1, __init__ не вызван

Жизненный цикл объекта: __new____init____del__

Метод __del__ вызывается перед удалением объекта, когда счётчик ссылок на него достигает нуля. Это поведение специфично для CPython, где используется подсчёт ссылок. В других реализациях (PyPy, Jython, GraalPy) сборка мусора работает иначе, и __del__ может быть вызван в непредсказуемый момент — или не вызван вовсе.

del_lifecycle.py
import sys

class Resource:
def __del__(self):
print("Resource уничтожен")

a = Resource()
b = a
print(sys.getrefcount(a)) # 3 (a, b, аргумент getrefcount)

del a # счётчик уменьшился, но объект жив — b всё ещё ссылается
print("b всё ещё существует")
del b # счётчик стал 0 → вызывается __del__
print("Конец")
Осторожно с __del__
  • del x не вызывает x.__del__() напрямую — он лишь уменьшает счётчик ссылок на 1.
  • __del__ — противоположность __new__, а не __init__.
  • Нет гарантий, что интерпретатор вызовет __del__ для всех объектов при завершении программы.
  • Если можно обойтись без __del__ — обходитесь. Используйте контекстные менеджеры вместо деструкторов.

Атрибуты класса и экземпляра

Атрибуты класса — общие для всех экземпляров. Атрибуты экземпляра — у каждого свои. Важно не путать, иначе один объект «случайно» изменит данные другого.

class_vs_instance.py
class Phone:
# Атрибуты класса (общие для всех)
default_color = "Grey"
default_model = "C385"

def __init__(self, color: str, model: str):
# Атрибуты экземпляра (у каждого свои)
self.color = color
self.model = model

p1 = Phone("Red", "iPhone")
p2 = Phone("Blue", "Pixel")

print(p1.default_color) # Grey — берётся из класса
print(p1.color) # Red — собственный атрибут
print(Phone.default_color) # Grey
Практический совет

Будьте осторожны с мутабельными атрибутами класса (списки, словари) — они разделяются между всеми экземплярами. Если нужен свой список для каждого объекта, инициализируйте его в __init__.

Статические методы и методы класса

methods.py
class Car:
_registry: list["Car"] = []

def __init__(self, brand: str):
self.brand = brand
Car._registry.append(self)

@staticmethod
def is_valid_brand(brand: str) -> bool:
"""Не принимает self/cls — обычная функция, живущая в пространстве класса."""
return brand.isalpha() and len(brand) > 1

@classmethod
def create(cls, brand: str) -> "Car":
"""Принимает cls — может создавать экземпляры правильного подкласса."""
if not cls.is_valid_brand(brand):
raise ValueError(f"Некорректный бренд: {brand}")
return cls(brand)

@classmethod
def count(cls) -> int:
return len(cls._registry)

car = Car.create("Toyota")
print(Car.count()) # 1
примечание

@classmethod — фабричный метод на уровне языка. Он получает cls первым аргументом, что позволяет корректно работать с наследованием: подкласс, вызвав cls(...), создаст экземпляр своего типа.

__repr__ vs __str__

Два метода для строкового представления, но с разными целями:

  • __repr__ — однозначная информация об объекте для разработчика. Идеальный repr позволяет воссоздать объект: eval(repr(x)) == x.
  • __str__ — человекочитаемое представление для пользователя.

Если __str__ не определён, Python использует __repr__ как fallback.

repr_str.py
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y

def __repr__(self) -> str:
return f"Point({self.x!r}, {self.y!r})"

def __str__(self) -> str:
return f"({self.x}, {self.y})"

p = Point(1.5, 2.0)
print(repr(p)) # Point(1.5, 2.0) — можно вставить в код
print(str(p)) # (1.5, 2.0) — красиво для пользователя
print(p) # (1.5, 2.0) — print вызывает __str__

__call__ — вызываемые объекты

Определив __call__, вы делаете экземпляр класса вызываемым как функцию. Это полезно для объектов с состоянием, которые должны вести себя как функции:

callable.py
class Multiplier:
def __init__(self, factor: int):
self.factor = factor

def __call__(self, value: int) -> int:
return self.factor * value

double = Multiplier(2)
triple = Multiplier(3)

print(double(5)) # 10
print(triple(5)) # 15
print(callable(double)) # True

Встроенные атрибуты классов

Каждый класс и объект в Python несёт набор встроенных атрибутов:

builtin_attrs.py
class Noop:
"""I do nothing at all."""

print(Noop.__doc__) # I do nothing at all.
print(Noop.__name__) # Noop
print(Noop.__module__) # __main__
print(Noop.__bases__) # (<class 'object'>,)

noop = Noop()
print(noop.__class__) # <class '__main__.Noop'>
print(noop.__dict__) # {} — словарь атрибутов экземпляра

__dict__ подробнее

У классов __dict__ — это mappingproxy (read-only обёртка над словарём). Интерпретатор гарантирует, что ключи — строки, и проводит оптимизации на основе этого.

У обычных экземпляров __dict__ — обычный словарь, куда можно записать что угодно, в том числе нестроковые ключи:

dict_details.py
class Box:
size = 10

# Класс: mappingproxy
print(type(Box.__dict__)) # <class 'mappingproxy'>

# Экземпляр: обычный dict, можно даже с нестроковыми ключами
box = Box()
box.__dict__["label"] = "fragile"
box.__dict__[42] = "ответ"
box.__dict__[(1, 2)] = "координаты"

print(box.label) # fragile
print(box.__dict__[42]) # ответ
print(box.__dict__[(1, 2)]) # координаты

__getattr__, __setattr__, __getattribute__

Три метода, управляющих доступом к атрибутам. Путать их между собой — классический источник багов.

МетодКогда вызывается
__getattribute__Всегда при обращении к любому атрибуту
__getattr__Только если __getattribute__ выбросил AttributeError (атрибут не найден)
__setattr__Всегда при присваивании любому атрибуту
attr_protocol.py
class LoggedAccess:
def __init__(self, **kwargs):
for k, v in kwargs.items():
self.__dict__[k] = v # обходим __setattr__ при инициализации

def __getattr__(self, name: str):
"""Вызывается ТОЛЬКО если атрибут не найден обычным способом."""
print(f"⚠ Атрибут '{name}' не найден")
raise AttributeError(name)

def __setattr__(self, name: str, value):
"""Вызывается ВСЕГДА при присваивании."""
print(f"Устанавливаем {name} = {value}")
self.__dict__[name] = value

obj = LoggedAccess(x=10)
print(obj.x) # 10 — __getattr__ НЕ вызывается (атрибут есть)
obj.y = 20 # Устанавливаем y = 20
obj.missing # ⚠ Атрибут 'missing' не найден → AttributeError
Ловушка рекурсии

В __setattr__ нельзя писать self.name = value — это вызовет __setattr__ снова, и так до бесконечности. Используйте self.__dict__[name] = value или super().__setattr__(name, value).

Та же проблема у __getattribute__ — вызовите super().__getattribute__(name) для реального доступа.

__slots__ — оптимизация памяти

По умолчанию атрибуты хранятся в __dict__. Использование __slots__ заменяет словарь на фиксированный массив, экономя память:

slots.py
class Point:
__slots__ = ('x', 'y')

def __init__(self, x, y):
self.x = x
self.y = y

p = Point(1, 2)
# p.z = 3 # AttributeError — нельзя добавить новый атрибут
Практический совет

Используйте __slots__ для классов с большим количеством экземпляров (например, ORM-модели, точки данных). Экономия памяти может составлять 30-50%.

Дескрипторы

Дескриптор — объект, определяющий один из методов __get__, __set__ или __delete__. Дескрипторы управляют доступом к атрибутам на уровне класса. Именно на дескрипторах работают property, classmethod и staticmethod (Descriptor HowTo Guide).

descriptor.py
class Validated:
def __init__(self, min_value, max_value):
self.min_value = min_value
self.max_value = max_value

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

def __set__(self, instance, value):
if not self.min_value <= value <= self.max_value:
raise ValueError(f"{self.name}: {value} не в диапазоне")
instance.__dict__[self.name] = value

def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)

class Temperature:
celsius = Validated(-273.15, 1000)

t = Temperature()
t.celsius = 36.6 # OK
# t.celsius = -300 # ValueError
Определение

Data descriptor определяет __set__ или __delete__ и имеет приоритет над __dict__ экземпляра. Non-data descriptor определяет только __get__ — атрибут экземпляра может его перекрыть.

Properties (@property)

@property — встроенный дескриптор для создания управляемых атрибутов. В Python вместо явных get_x()/set_x() используют property — так сохраняется чистый синтаксис доступа через точку, но с контролем под капотом:

property.py
class Temperature:
def __init__(self, temperature: float = 0):
self.temperature = temperature # вызовет setter!

@property
def temperature(self) -> float:
return self._temperature

@temperature.setter
def temperature(self, value: float) -> None:
if value < -273.15:
raise ValueError("Ниже абсолютного нуля!")
self._temperature = value

human = Temperature(36.6)
print(human.temperature) # 36.6
# human.temperature = -300 # ValueError

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

Property поддерживает не только чтение и запись, но и удаление атрибута:

property_deleter.py
class PosDataModel:
def __init__(self):
self._params: list[float] = []

@property
def params(self) -> list[float]:
return self._params

@params.setter
def params(self, new_params: list[float]) -> None:
if not all(p > 0 for p in new_params):
raise ValueError("Все параметры должны быть положительными")
self._params = new_params

@params.deleter
def params(self) -> None:
del self._params

model = PosDataModel()
model.params = [0.1, 0.5, 0.4]
print(model.params) # [0.1, 0.5, 0.4]
del model.params
# model.params # AttributeError — атрибут удалён

Множественное наследование и MRO

При множественном наследовании возникает классическая проблема — ромбовидное наследование (diamond problem):

Ромбовидное наследование: D наследует от B и C, которые оба наследуют от A

Python решает эту проблему с помощью C3-линеаризации (Method Resolution Order).

mro.py
class A: x = 'a'
class B(A): pass
class C(A): x = 'c'
class D(B, C): pass

print(D.x) # 'c' — C перекрывает A
print(D.__mro__) # D -> B -> C -> A -> object
Определение

MRO (Method Resolution Order) — порядок, в котором Python ищет метод при вызове. В Python 2 old-style классах использовался поиск в глубину (DFS). В Python 2.3+ и Python 3 используется алгоритм C3-линеаризации — это не BFS, а отдельный алгоритм, гарантирующий сохранение локального порядка наследования и монотонность.

Частая ошибка

C3-линеаризация может отказать, если порядок наследования противоречив:

class X: pass
class Y: pass
class A(X, Y): pass
class B(Y, X): pass # обратный порядок!
# class C(A, B): pass # TypeError: Cannot create a consistent MRO

Про self

В Python self — не магия и не ключевое слово, а обычный параметр. Когда вы пишете obj.method(arg), интерпретатор превращает это в Type.method(obj, arg). Именно поэтому self передаётся явно — метод это обычная функция, привязанная к классу.

self_explained.py
class Greeter:
def hello(self, name: str) -> str:
return f"{self.__class__.__name__} приветствует {name}"

g = Greeter()

# Эти два вызова полностью эквивалентны:
print(g.hello("мир")) # Greeter приветствует мир
print(Greeter.hello(g, "мир")) # Greeter приветствует мир

Явный self даёт ряд преимуществ:

  • Методы — обычные функции, их можно передавать, декорировать, подменять.
  • Нет скрытого контекста: всегда видно, откуда берётся состояние.
  • Это основа для понимания super(), дескрипторов и протокола доступа к атрибутам.
примечание

Имя self — соглашение, а не требование языка. Можно написать def hello(this, name), и код будет работать. Но нарушать соглашение не стоит — это запутает всех, включая IDE и линтеры. Подробнее: Why explicit self has to stay (Guido van Rossum).

super() — как работает

super() возвращает объект-посредник, который делегирует вызовы методов следующему классу в MRO. Это не «вызов метода родителя» — это вызов метода следующего класса в цепочке разрешения.

super_basics.py
class A:
def greet(self):
print("A.greet")

class B(A):
def greet(self):
print("B.greet")
super().greet() # следующий в MRO — A

class C(A):
def greet(self):
print("C.greet")
super().greet() # следующий в MRO — A

class D(B, C):
def greet(self):
print("D.greet")
super().greet() # следующий в MRO — B

D().greet()
# D.greet → B.greet → C.greet → A.greet
# MRO: D → B → C → A → object

Перегрузка методов с super()

super_override.py
class Counter:
all_counters: list["Counter"] = []

def __init__(self, initial: int = 0):
self.__class__.all_counters.append(self)
self.value = initial

class OtherCounter(Counter):
def __init__(self, initial: int = 0):
self.initial = initial
super().__init__(initial) # вызываем Counter.__init__

oc = OtherCounter(10)
print(vars(oc)) # {'initial': 10, 'value': 10}

isinstance и issubclass

isinstance_check.py
class Animal: pass
class Dog(Animal): pass
class Cat(Animal): pass

dog = Dog()

# isinstance — проверяет, является ли объект экземпляром класса (или его потомка)
print(isinstance(dog, Animal)) # True
print(isinstance(dog, Dog)) # True
print(isinstance(dog, Cat)) # False

# Можно передать кортеж классов
print(isinstance(dog, (Dog, Cat))) # True

# issubclass — проверяет, является ли класс потомком другого класса
print(issubclass(Dog, Animal)) # True
print(issubclass(Dog, Cat)) # False

Композиция vs наследование

Наследование описывается словом «является» (is a): собака является животным. Композиция — словом «имеет» (has a): автомобиль имеет двигатель.

composition.py
# Наследование: "является"
class Animal:
def breathe(self):
print("Дышу")

class Dog(Animal): # Собака ЯВЛЯЕТСЯ животным
pass

# Композиция: "имеет"
class Engine:
def start(self):
print("Двигатель запущен")

class Car: # Автомобиль ИМЕЕТ двигатель
def __init__(self):
self.engine = Engine()

def start(self):
self.engine.start()
print("Машина поехала")

Car().start()
# Двигатель запущен
# Машина поехала
Правило большого пальца

Предпочитайте композицию наследованию. Наследование создаёт жёсткую связь — изменение родителя может сломать потомков. Композиция гибче: можно заменить компонент, не трогая остальной код.

Mixins (примеси)

Mixin — класс, который добавляет функциональность, но не предназначен для самостоятельного использования. Это способ «вклеить» поведение в несколько несвязанных классов без глубокой иерархии наследования.

mixin.py
class AddMixin:
"""Примесь: добавляет поддержку оператора +"""
def __add__(self, right):
return self.__class__(self.value + right)

class Value:
def __init__(self, value) -> None:
self.value = value

def __str__(self) -> str:
return str(self.value)

class AddableValue(Value, AddMixin):
"""Комбинируем Value и AddMixin"""
pass

result = AddableValue(5) + 3
print(result) # 8

Вся функциональность скрыта в примесях. Можно конструировать новые классы, добавляя и убирая миксины как детали конструктора.

Декораторы классов

Работают точно так же, как декораторы функций — принимают класс, возвращают обёртку (или модифицированный класс):

class_decorator.py
import functools

def singleton(cls):
"""Декоратор: гарантирует единственный экземпляр класса."""
instance = None

@functools.wraps(cls)
def inner(*args, **kwargs):
nonlocal instance
if instance is None:
instance = cls(*args, **kwargs)
return instance

return inner

@singleton
class Database:
"""Подключение к БД — должно быть одно."""
def __init__(self, url: str = "localhost"):
self.url = url

db1 = Database("prod.example.com")
db2 = Database("other.example.com")
print(db1 is db2) # True
print(db1.url) # prod.example.com

Абстрактные классы (ABC)

Абстрактный класс задаёт контракт: какие методы обязан реализовать потомок. Создать экземпляр абстрактного класса нельзя — только наследоваться и реализовать все абстрактные методы.

abc_example.py
from abc import ABC, abstractmethod
import math

class Shape(ABC):
@abstractmethod
def area(self) -> float:
...

@abstractmethod
def perimeter(self) -> float:
...

class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius

def area(self) -> float:
return math.pi * self.radius ** 2

def perimeter(self) -> float:
return 2 * math.pi * self.radius

print(Circle(5).area()) # 78.539...
# Shape() # TypeError: Can't instantiate abstract class

Dataclasses

Dataclass избавляет от рутины: автоматически генерирует __init__, __repr__, __eq__ и другие методы по аннотациям полей.

dataclass.py
from dataclasses import dataclass, field

@dataclass
class Point:
x: float
y: float
label: str = "unnamed"
tags: list[str] = field(default_factory=list)

@property
def distance_from_origin(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5

p = Point(3.0, 4.0)
print(p) # Point(x=3.0, y=4.0, label='unnamed', tags=[])
print(p.distance_from_origin) # 5.0
Практический совет

Используйте @dataclass(frozen=True) для неизменяемых объектов — они автоматически получают __hash__ и могут использоваться как ключи словаря.

slots=True (Python 3.10+)

@dataclass(slots=True) автоматически генерирует __slots__, совмещая удобство dataclass с экономией памяти:

@dataclass(slots=True)
class Vector:
x: float
y: float
z: float

v = Vector(1.0, 2.0, 3.0)
# v.w = 4.0 # AttributeError — слотированный класс

Можно комбинировать: @dataclass(frozen=True, slots=True) для максимально компактных и неизменяемых объектов.

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

Контекстный менеджер — объект с методами __enter__ и __exit__. Гарантирует освобождение ресурсов даже при исключениях. Используется с with:

context_manager.py
class FileManager:
def __init__(self, filename: str, mode: str):
self.filename = filename
self.mode = mode

def __enter__(self):
self.file = open(self.filename, self.mode)
return self.file

def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
return False # не подавлять исключения

with FileManager("test.txt", "w") as f:
f.write("Hello")

contextlib — упрощённый синтаксис

contextlib_example.py
from contextlib import contextmanager
import time

@contextmanager
def timer(label: str):
start = time.perf_counter()
yield
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.4f}s")

with timer("operation"):
sum(range(1_000_000))

SOLID

Пять принципов проектирования, которые помогают писать поддерживаемый код. Не догма, а ориентиры.

S — Single Responsibility Principle (Единственная ответственность)

У класса должна быть одна причина для изменения. Если класс парсит данные и отправляет email — это две ответственности.

O — Open/Closed Principle (Открытость/закрытость)

Класс открыт для расширения, но закрыт для модификации. Добавляйте поведение через наследование или композицию, не редактируя существующий код.

L — Liskov Substitution Principle (Подстановка Лисков)

Объект подкласса должен корректно работать везде, где ожидается объект родительского класса. Если Square наследуется от Rectangle, но ломает контракт set_width/set_height — принцип нарушен.

I — Interface Segregation Principle (Разделение интерфейса)

Нельзя заставлять клиента реализовывать интерфейс, который он не использует. Лучше несколько маленьких интерфейсов, чем один большой.

D — Dependency Inversion Principle (Инверсия зависимостей)

Высокоуровневые модули не должны зависеть от низкоуровневых. Оба должны зависеть от абстракций.

solid_dip.py
from abc import ABC, abstractmethod

# Абстракция
class Notifier(ABC):
@abstractmethod
def send(self, message: str) -> None: ...

# Низкоуровневые реализации
class EmailNotifier(Notifier):
def send(self, message: str) -> None:
print(f"Email: {message}")

class TelegramNotifier(Notifier):
def send(self, message: str) -> None:
print(f"Telegram: {message}")

# Высокоуровневый модуль зависит от абстракции, а не от конкретной реализации
class OrderService:
def __init__(self, notifier: Notifier):
self.notifier = notifier

def place_order(self, item: str) -> None:
print(f"Заказ оформлен: {item}")
self.notifier.send(f"Новый заказ: {item}")

service = OrderService(TelegramNotifier())
service.place_order("Книга")
# Заказ оформлен: Книга
# Telegram: Новый заказ: Книга

Итоги

Под капотом ООП в Python — несколько базовых механизмов: __dict__ хранит атрибуты, дескрипторы управляют доступом к ним, C3-линеаризация определяет порядок поиска методов. Всё остальное — property, classmethod, staticmethod, super() — это надстройки над этими механизмами. Если вы понимаете, как работает поиск атрибутов и дескрипторный протокол, вам не нужно запоминать частные случаи — вы можете вывести поведение сами.

Дальше — метапрограммирование: метаклассы, протоколы, и как Python позволяет переопределять само создание классов.

Полезные ссылки