💻 Примеры: итоговые сценарии

⚡ Кратко: пять идей блока ООП на одном экране

class D(B, C):              # порядок родителей важен
    pass
print(D.__mro__)                # порядок поиска методов
super().action()                # вызов следующего по MRO

if not hasattr(self, "email"):
    raise AttributeError("Нет email")

self.__balance = 0              # -> _Class__balance
ТемаЧто делаетКлючевая конструкция
Множественное наследованиекласс получает поведение сразу от нескольких родителейclass Child(A, B):
MROпорядок поиска метода: в глубину, слева направоCls.__mro__
super()вызывает следующий класс по MRO, а не «родителя»super().method()
Миксин + hasattrповедение без состояния + проверка перед использованиемhasattr(self, "name")
Инкапсуляция + @propertyприватное поле + управляемый доступ снаружиself.__x / @property
Топ-3 ошибки: MRO — не «все родители сразу», а глубина слева направо · забытый hasattr() в миксине → AttributeError · поле в __init__ мимо сеттера — валидация не сработает.

Примеры идут от простого к сложному: сначала синтаксис множественного наследования, затем 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.

Связь с соседними уроками: примеры 1–5 подробно разбирает урок 72 «Множественное наследование», примеры 6–11 — урок 74 «Инкапсуляция». После этого summary — урок 76 «Абстрактные классы и пользовательские исключения», где похожая идея контролируемого интерфейса используется на уровне класса целиком, а не отдельного поля.
⚠️ Проверить по документации: протокол дескрипторов (__get__/__set__/__set_name__) и точный порядок поиска атрибута через __mro__ — из справочника разработчика, а не из основного текста лекции сессии 19. Сверьтесь с разделами Descriptor HowTo Guide и Python Data Model.