Углубленное ООП
Вы уже умеете писать классы и использовать наследование. Но понимаете ли вы, что происходит, когда вы пишете obj.method()? Как Python ищет атрибуты, почему self передаётся явно и на чём построены property, classmethod и super()? Эта лекция — мост между «использую ООП» и «понимаю, как оно устроено внутри».
Классы и объекты
Класс — это чертёж. Объект — то, что построено по этому чертежу. Класс описывает, какие данные (атр ибуты) и какое поведение (методы) будет у объекта. Сам по себе класс ничего не делает — как архитектурный план не является зданием.
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(), неважно, утка это или робот.
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:
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), но доступ к атрибуту по полному имени всё равно открыт.
Наследование
Наследование — передача всех свойств класса потомку. Мощный инструмент, но часто усложняет код, заставляя строить глубокие иерархии. На практике наследованию нередко предпочитают композицию.
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_) |
__var | Name mangling — не перейдёт в наследника напрямую |
__var__ | Магический метод (__init__, __str__, __add__) |
Создание объектов: __new__ и __init__
В Python создание объекта происходит в два эт апа: __new__ создаёт экземпляр, __init__ инициализирует его.
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__ может быть вызван в непредсказуемый момент — или не вызван вовсе.
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 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__.
Статические методы и методы класса
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.
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__, вы делаете экземпляр класса вызываемым как функцию. Это полезно для объектов с состоянием, которые должны вести себя как функции:
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 несёт набор встроенных атрибутов:
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__ — обычный словарь, куда можно записать что угодно, в том числе нестроковые ключи:
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__ | Всегда при присваивании любому атрибуту |
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__ заменяет словарь на фиксированный массив, экономя память:
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).
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 — так сохраняется чистый синтаксис доступа через точку, но с контролем под капотом:
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 поддерживает не только чтение и запись, но и удаление атрибута:
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):

Python решает эту проблему с помощью C3-линеаризации (Method Resolution Order).
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 передаётся явно — метод это обычная функция, привязанная к классу.
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. Это не «вызов метода родителя» — это вызов метода следующего класса в цепочке разрешения.
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()
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
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