Примеры идут от простого к сложному: сначала синтаксис множественного наследования, затем MRO и super() отдельно от миксинов, затем композиция/агрегация, и в конце — инкапсуляция от уровней доступа до дескриптора. Последние два примера сводят темы уроков 72 и 74 в одном классе. Весь вывод в комментариях получен запуском кода, а не выдуман.
Пример 1. Множественное наследование: базовый синтаксис
Класс может наследовать сразу от нескольких родителей — это и есть множественное наследование. Оно нужно, когда объекту требуется поведение из двух независимых «способностей», которые неудобно тянуть через одну цепочку наследования.
class Printable:
def print_info(self) -> None:
print("Печать информации...")
class Savable:
def save(self) -> None:
print("Сохраняем в файл...")
class Report(Printable, Savable):
pass
report = Report()
report.print_info()
report.save()
# Печать информации...
# Сохраняем в файл...
Что происходит: Report не определяет собственных методов, но получает их сразу от обоих родителей — Printable и Savable. Если бы у родителей совпадали имена методов, выбор зависел бы от порядка в скобках — это разбирает следующий пример.
Пример 2. MRO: как Python ищет метод при конфликте имён
Когда несколько родителей определяют метод с одним именем, побеждает не «первый в алфавите» и не «более специализированный», а порядок из __mro__ — Method Resolution Order.
class Vehicle:
def info(self) -> None:
print("Vehicle: базовый транспорт")
class Car(Vehicle):
pass
class Boat:
def info(self) -> None:
print("Boat: передвигается по воде")
class AmphibiousVehicle(Car, Boat):
pass
av = AmphibiousVehicle()
av.info()
# Vehicle: базовый транспорт
print(AmphibiousVehicle.__mro__)
# (<class '__main__.AmphibiousVehicle'>, <class '__main__.Car'>,
# <class '__main__.Vehicle'>, <class '__main__.Boat'>, <class 'object'>)
Почему так: поиск идёт в глубину по первому родителю: AmphibiousVehicle → Car → Vehicle — здесь метод найден, и до Boat Python не доходит, хотя Boat тоже указан родителем. Частая ошибка — думать, что сначала проверяются все прямые родители (Car и Boat), и только потом их предки.
Пример 3. super() в диамант-наследовании: кооперативный вызов
super() — это не «вызов родителя», а «вызов следующего класса по MRO». В диамант-структуре (когда два родителя происходят от одного общего предка) это позволяет вызвать каждую реализацию метода ровно один раз, а не два.
class LivingBeing:
def describe(self) -> None:
print("Живое существо")
class LandAnimal(LivingBeing):
def describe(self) -> None:
print("Наземное животное")
super().describe()
class WaterAnimal(LivingBeing):
def describe(self) -> None:
print("Водное животное")
super().describe()
class Platypus(LandAnimal, WaterAnimal):
def describe(self) -> None:
print("Утконос")
super().describe()
print(Platypus.__mro__)
# (Platypus, LandAnimal, WaterAnimal, LivingBeing, object)
p = Platypus()
p.describe()
# Утконос
# Наземное животное
# Водное животное
# Живое существо
Что происходит: каждый super().describe() передаёт вызов не «родителю этого класса», а следующему классу в Platypus.__mro__. Поэтому LivingBeing.describe() срабатывает один раз в самом конце, а не дважды — через LandAnimal и через WaterAnimal по отдельности. Это и есть решение проблемы ромба (diamond problem) в Python.
Пример 4. Миксины и hasattr()
Миксин добавляет поведение, но не хранит собственное состояние и не рассчитан на самостоятельное создание. Внутри миксина hasattr() проверяет, что класс-наследник действительно предоставил нужные данные, прежде чем к ним обращаться.
class AuthMixin:
def login(self) -> None:
if not hasattr(self, "username"):
raise AttributeError("Не задан username")
print(f"{self.username} вошёл в систему.")
def logout(self) -> None:
print("Пользователь вышел из системы.")
class NotificationMixin:
def send_email(self, message: str) -> None:
if not hasattr(self, "email"):
raise AttributeError("Не задан email")
print(f"Отправка письма на {self.email}: {message}")
class UserProfile(AuthMixin, NotificationMixin):
def __init__(self, username: str, email: str) -> None:
self.username = username
self.email = email
user = UserProfile("alice", "alice@example.com")
user.login()
user.send_email("Добро пожаловать!")
user.logout()
# alice вошёл в систему.
# Отправка письма на alice@example.com: Добро пожаловать!
# Пользователь вышел из системы.
class Anonymous(NotificationMixin):
pass
anon = Anonymous()
try:
anon.send_email("Привет")
except AttributeError as error:
print("AttributeError:", error)
# AttributeError: Не задан email
Почему так: Anonymous получает метод send_email от миксина, но не задаёт self.email в __init__. Без проверки hasattr() обращение к self.email тоже упало бы с AttributeError, только с менее понятным сообщением — само сообщение об ошибке не изменилось бы к лучшему.
Пример 5. Композиция и агрегация в одном сравнении
Оба паттерна собирают объект из частей без наследования, но по-разному управляют жизненным циклом вложенного объекта. Вопрос для выбора между ними: «часть создаётся здесь и умирает вместе с целым, или существует независимо и передаётся снаружи?»
class Engine:
def start(self) -> None:
print("Двигатель завёлся")
class Automobile:
def __init__(self, model: str) -> None:
self.model = model
self.engine = Engine() # композиция: двигатель создаётся внутри
def drive(self) -> None:
print(f"{self.model} трогается с места")
self.engine.start()
class University:
def __init__(self, name: str) -> None:
self.name = name
def get_info(self) -> None:
print(f"Обучение проходит в университете: {self.name}")
class Teacher:
def __init__(self, name: str, university: University) -> None:
self.name = name
self.university = university # агрегация: университет передан извне
def introduce(self) -> None:
print(f"Преподаватель: {self.name}")
self.university.get_info()
car = Automobile("Tesla Model 3")
car.drive()
# Tesla Model 3 трогается с места
# Двигатель завёлся
uni = University("Tech University")
teacher1 = Teacher("Anna", uni)
teacher2 = Teacher("Dmitry", uni)
teacher1.introduce()
teacher2.introduce()
print(teacher1.university is teacher2.university) # True
Что происходит: Engine создаётся внутри Automobile.__init__ — без автомобиля этого конкретного двигателя не существует (композиция, «часть-целое»). University создаётся отдельно и передаётся сразу двум учителям — университет не зависит от конкретного преподавателя и переживёт его увольнение (агрегация, «использует»). Отсюда и практическое правило из теории: наследование — это «является», а композиция/агрегация — «содержит» или «использует».
Пример 6. Уровни доступа и манглирование имён
Python не запрещает доступ к атрибутам по-настоящему — есть только соглашения (_protected) и манглирование (__private). Пример показывает разницу между тремя уровнями на одном классе и то, как приватное имя на самом деле хранится в объекте.
class Book:
def __init__(self, title: str, author: str) -> None:
self.title = title # публичный
self._author = author # защищённый (соглашение)
self.__notes: list[str] = [] # приватный (манглируется)
def add_note(self, note: str) -> None:
self.__notes.append(note)
def show_notes(self) -> None:
for note in self.__notes:
print(f"- {note}")
book = Book("1984", "George Orwell")
print(book.title) # 1984 — публичный доступен свободно
print(book._author) # George Orwell — доступ возможен, но нежелателен
book.add_note("Первая глава прочитана")
book.show_notes() # - Первая глава прочитана
print(sorted(book.__dict__.keys()))
# ['_Book__notes', '_author', 'title']
try:
print(book.__notes)
except AttributeError as error:
print("AttributeError:", error)
# AttributeError: 'Book' object has no attribute '__notes'
print(book._Book__notes) # ['Первая глава прочитана'] — обход через манглированное имя
Почему так: вне тела класса имя __notes не манглируется, поэтому book.__notes ищет атрибут с именно таким именем и не находит его — Python не «блокирует» приватность, а просто переименовывает атрибут в _Book__notes при определении внутри класса. Через это переименованное имя доступ технически возможен, но использовать его в обычном коде не следует — это обход инкапсуляции, а не штатный способ работы с классом.
Пример 7. Геттеры и сеттеры до @property
До @property контроль над приватным полем делали через явные методы get_.../set_.... Так тоже можно валидировать значение, но вызывать метод приходится с скобками — temp.get_celsius(), а не temp.celsius.
class CelsiusOldStyle:
def __init__(self, value: float) -> None:
self.__validate(value)
self.__celsius = value
def get_celsius(self) -> float:
return self.__celsius
def set_celsius(self, value: float) -> None:
self.__validate(value)
self.__celsius = value
@staticmethod
def __validate(value: float) -> None:
if value < -273.15:
raise ValueError("Температура не может быть ниже абсолютного нуля")
temp = CelsiusOldStyle(20)
print(temp.get_celsius()) # 20
temp.set_celsius(25)
print(temp.get_celsius()) # 25
try:
temp.set_celsius(-300)
except ValueError as error:
print("ValueError:", error)
# ValueError: Температура не может быть ниже абсолютного нуля
Что происходит: и в конструкторе, и в сеттере вызывается одна и та же приватная проверка __validate, поэтому объект не может оказаться в невалидном состоянии ни при создании, ни при изменении. Следующий пример показывает, где эта симметрия чаще всего ломается уже с @property.
Пример 8. Ловушка @property: проверка работает, только если через неё идёт __init__
@property с сеттером выглядит как гарантия валидации, но гарантирует её только код, который реально обращается к self.age = value, а не напрямую к self.__age. Разница в одну строку конструктора решает, защищён объект или нет.
class PersonUnsafe:
def __init__(self, age: int) -> None:
self.__age = age # проверка пропущена: минуя сеттер
@property
def age(self) -> int:
return self.__age
@age.setter
def age(self, value: int) -> None:
if value < 0:
raise ValueError("Возраст не может быть отрицательным")
self.__age = value
class PersonSafe:
def __init__(self, age: int) -> None:
self.age = age # значение идёт через сеттер
@property
def age(self) -> int:
return self.__age
@age.setter
def age(self, value: int) -> None:
if value < 0:
raise ValueError("Возраст не может быть отрицательным")
self.__age = value
bad = PersonUnsafe(-10)
print(bad.age) # -10 — объект с некорректными данными
try:
PersonSafe(-10)
except ValueError as error:
print("ValueError:", error)
# ValueError: Возраст не может быть отрицательным
Почему так: в PersonUnsafe.__init__ строка self.__age = age пишет напрямую в манглированное имя _PersonUnsafe__age, минуя дескриптор свойства — сеттер в этот момент вообще не вызывается. В PersonSafe.__init__ строка self.age = age обращается к публичному имени age, за которым стоит @property, поэтому валидация отрабатывает уже при создании объекта.
Пример 9. Read-only свойство: только вычисление, без сеттера
Если у @property нет пары @x.setter, атрибут доступен только для чтения — попытка присвоить значение снаружи завершится ошибкой. Так удобно выражать значения, которые вычисляются из других полей и не должны задаваться напрямую.
class Circle:
def __init__(self, radius: float) -> None:
if radius <= 0:
raise ValueError("Радиус должен быть положительным")
self.__radius = radius
@property
def radius(self) -> float:
return self.__radius
@property
def area(self) -> float:
return 3.14159 * self.__radius ** 2
circle = Circle(5)
print(circle.radius) # 5
print(circle.area) # 78.53975
try:
circle.radius = 10
except AttributeError as error:
print("AttributeError:", error)
# AttributeError: property 'radius' of 'Circle' object has no setter
Что происходит: area вообще не хранится в объекте — это результат вычисления по radius в момент обращения, всегда актуальный. Присваивание circle.radius = 10 падает, потому что у свойства radius нет сеттера: Python честно говорит об этом в тексте ошибки.
Пример 10. Синтез: миксин + композиция + @property в одном классе
Реальный класс редко использует одну технику ООП — обычно это комбинация. BankAccount ниже одновременно: применяет миксин с hasattr-проверкой, хранит вложенный объект как композицию и защищает баланс через @property.
class AuditMixin:
def audit(self, message: str) -> None:
if not hasattr(self, "owner"):
raise AttributeError("AuditMixin требует атрибут owner")
print(f"[AUDIT] {self.owner}: {message}")
class TransactionLog:
def __init__(self) -> None:
self.entries: list[str] = []
def add(self, entry: str) -> None:
self.entries.append(entry)
class BankAccount(AuditMixin):
def __init__(self, owner: str, balance: float = 0) -> None:
self.owner = owner
self.__balance = balance
self.__log = TransactionLog() # композиция: лог не живёт без счёта
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Сумма должна быть положительной")
self.__balance += amount
self.__log.add(f"+{amount}")
self.audit(f"пополнение на {amount}")
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Сумма должна быть положительной")
if amount > self.__balance:
raise ValueError("Недостаточно средств")
self.__balance -= amount
self.__log.add(f"-{amount}")
self.audit(f"снятие {amount}")
@property
def balance(self) -> float:
return self.__balance
@property
def history(self) -> list[str]:
return self.__log.entries.copy()
account = BankAccount("Alice", 100)
account.deposit(150)
account.withdraw(100)
print(account.balance) # 150
print(account.history) # ['+150', '-100']
# [AUDIT] Alice: пополнение на 150
# [AUDIT] Alice: снятие 100
Что происходит: AuditMixin не хранит своё состояние и проверяет через hasattr, что у наследника есть owner, прежде чем его использовать — если бы BankAccount не задавал self.owner, вызов self.audit(...) упал бы с понятной ошибкой. TransactionLog — это композиция: объект-лог создаётся внутри __init__ и не имеет смысла отдельно от конкретного счёта. Балансом и историей снаружи можно только читать через @property — изменить их напрямую нельзя, только методами deposit/withdraw с валидацией.
Пример 11. Дескриптор: одна проверка на несколько полей
У BankAccount из примера 10 проверка на «не отрицательное значение» была бы нужна для нескольких полей. Вместо копирования одного и того же @property для каждого поля дескриптор описывает проверку один раз и переиспользует её для всех атрибутов, где она назначена.
class PositiveNumber:
def __set_name__(self, owner, name: str) -> None:
self.private_name = "_" + name
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.private_name)
def __set__(self, instance, value: float) -> None:
if value < 0:
raise ValueError("Значение не может быть отрицательным")
setattr(instance, self.private_name, value)
class Employee:
age = PositiveNumber()
salary = PositiveNumber()
def __init__(self, name: str, age: int, salary: float = 0) -> None:
self.name = name
self.age = age
self.salary = salary
employee = Employee("Viktor", 48, 1000)
print(employee.age, employee.salary) # 48 1000
print(sorted(employee.__dict__.keys()))
# ['_age', '_salary', 'name']
employee.salary = 1500
print(employee.salary) # 1500
try:
employee.age = -10
except ValueError as error:
print("ValueError:", error)
# ValueError: Значение не может быть отрицательным
Почему так: age и salary — это не обычные атрибуты, а объекты-дескрипторы класса PositiveNumber. При обращении employee.age = 48 Python вызывает PositiveNumber.__set__, который проверяет значение и кладёт его в _age — это видно в employee.__dict__. Один класс дескриптора обслуживает сразу оба поля вместо двух одинаковых пар @property/@x.setter.
__get__/__set__/__set_name__) и точный порядок поиска атрибута через __mro__ — из справочника разработчика, а не из основного текста лекции сессии 19. Сверьтесь с разделами Descriptor HowTo Guide и Python Data Model.