Урок курса

Наследование, super() и переопределение методов

Python для продвинутых: ООП, типизация и тестирование

В прошлом уроке мы разбирали композицию: один объект хранит другой как атрибут и делегирует ему работу. Это отношение «имеет» — has-a. Теперь переходим к другому механизму связи классов: наследованию, которое выражает отношение «является» — is-a. Это принципиально другая идея, и перепутать их — одна из самых частых ошибок в проектировании.

Базовый класс и подкласс: механика наследования

Наследование объявляется в одну строку — имя родителя указывается в скобках при определении класса:

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

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

class Dog(Animal):
    pass

Dog здесь ничего не определяет сам. Но это уже полноценный класс: он автоматически получает __init__ и speak от Animal. Можно создать экземпляр и вызвать метод без какого-либо дополнительного кода:

dog = Dog("Rex")
print(dog.name)    # Rex
print(dog.speak()) # ...

Python ищет атрибут или метод сначала в самом объекте, потом в его классе (Dog), потом поднимается по цепочке родителей. Это и есть MRO — Method Resolution Order. В простом случае с одним родителем цепочка линейная: Dog → Animal → object.

Проверить, является ли объект экземпляром родительского класса, можно через isinstance:

print(isinstance(dog, Dog))    # True
print(isinstance(dog, Animal)) # True

Оба вызова возвращают True — потому что Dog действительно является разновидностью Animal. Именно это слово «является» — ключевое.

Критерий is-a. Наследование имеет смысл только тогда, когда можно честно сказать: «подкласс является разновидностью базового класса». Dog является животным — это реальное is-a отношение. Но если между классами связь другого рода — один использует другой, один хранит другой — наследование здесь не на месте.

Проверка простая: подставьте оба класса в фразу «X является Y». Если фраза звучит нелепо или натянуто — скорее всего, нужна композиция, а не наследование. Например, «Движок является Автомобилем» — нет. «Автомобиль имеет Движок» — да. Значит, Engine должен быть атрибутом Car, а не его родителем.

Этот критерий — не философия, а практический фильтр. Неправильно применённое наследование создаёт классы, которые трудно менять: изменение родителя ломает всех детей, а замена поведения требует переписывать цепочку. Когда is-a соблюдается — наследование работает на вас. Когда нет — оно становится источником хрупкости.

Переопределение методов в подклассах

Пока подкласс пуст (pass), он просто использует поведение родителя. Но как только в нём появляется метод с тем же именем — Python начинает вызывать именно его, игнорируя родительскую версию. Это и есть переопределение (override).

Возьмём Animal с методом speak(), который возвращает заглушку, и определим два подкласса с собственными реализациями:

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

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

class Dog(Animal):
    def speak(self) -> str:
        return "Woof"

class Cat(Animal):
    def speak(self) -> str:
        return "Meow"

Теперь у каждого класса своя версия speak():

animals = [Dog("Rex"), Cat("Luna"), Animal("Unknown")]
for a in animals:
    print(f"{a.name}: {a.speak()}")
# Rex: Woof
# Luna: Meow
# Unknown: ...

Python определяет, какой именно speak() вызвать, следуя MRO: сначала ищет метод в классе самого объекта. Нашёл в Dog — вызвал Dog.speak. Нашёл в Cat — вызвал Cat.speak. Для экземпляра Animal своей реализации нет — берётся метод класса Animal напрямую.

Важная деталь: переопределение полное. Родительский speak() в этом примере вообще не запускается — Dog его полностью вытесняет. Это нормально, когда подклассу нужно другое поведение, а логика родителя там просто не нужна. Но если родительская логика нужна хотя бы частично — от полного переопределения стоит отказаться в пользу вызова super(), о котором пойдёт речь в следующей секции.

Функция, принимающая любой Animal, при этом работает одинаково для всех подклассов:

def make_sound(animal: Animal) -> None:
    print(animal.speak())

make_sound(Dog("Rex"))   # Woof
make_sound(Cat("Luna"))  # Meow

Здесь нет никаких if isinstance(...) — каждый объект сам знает, как реагировать. Именно в этом и состоит смысл переопределения: единый интерфейс, разное поведение в зависимости от конкретного подкласса.

super(): расширение родительской логики без дублирования

Полное переопределение метода — это выбор «всё или ничего»: родительская реализация полностью вытесняется. Но что если нужно не заменить поведение родителя, а добавить к нему что-то своё? Вот тут появляется super().

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

Самый распространённый сценарий — расширение __init__. Допустим, Animal уже инициализирует name, а Dog хочет добавить ещё и breed. Без super() придётся либо скопировать логику родителя, либо потерять её:

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

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

class Dog(Animal):
    def __init__(self, name: str, breed: str) -> None:
        super().__init__(name)   # 1) инициализируем родительскую часть
        self.breed = breed       # 2) добавляем собственный атрибут

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

Что здесь происходит по шагам:

  1. Dog.__init__ вызывается с name и breed.
  2. super().__init__(name) передаёт name в Animal.__init__, который устанавливает self.name. Это полноценный вызов родительского конструктора — никакой магии, просто делегирование.
  3. После возврата из super().__init__ продолжается инициализация Dog: устанавливается self.breed.
dog = Dog("Rex", "Labrador")
print(dog.name)   # Rex
print(dog.breed)  # Labrador
print(dog.speak()) # Woof

Оба атрибута на месте: name — от родителя, breed — от подкласса.

Порядок вызова super() важен. Если родительскому состоянию нужно быть готовым до того, как подкласс начнёт свою инициализацию — super().__init__() должен идти первым. Это правило почти всегда верно: родительская часть инициализируется раньше, чтобы подкласс мог опираться на уже установленные атрибуты.

super() работает не только в __init__. Его можно использовать в любом переопределённом методе, когда нужно расширить, а не заменить поведение:

class Animal:
    def describe(self) -> str:
        return f"Animal: {self.name}"

class Dog(Animal):
    def __init__(self, name: str, breed: str) -> None:
        super().__init__(name)
        self.breed = breed

    def describe(self) -> str:
        base = super().describe()          # берём строку от родителя
        return f"{base}, breed: {self.breed}"  # дополняем

dog = Dog("Rex", "Labrador")
print(dog.describe())  # Animal: Rex, breed: Labrador

Dog.describe() не переписывает логику Animal.describe() — он её использует и надстраивает сверху. Это и есть принципиальное отличие от полного переопределения: не «вместо», а «поверх».

Технически super() без аргументов работает в Python 3 автоматически: интерпретатор знает, в каком классе и методе он вызван, и подставляет нужные значения сам. Явная форма super(Dog, self).__init__(name) из Python 2 в современном коде не нужна — она только засоряет читаемость.

Антипаттерн: наследование ради данных вместо атрибута

Разобравшись с тем, как наследование работает в правильных случаях, стоит посмотреть на типичную ошибку — когда оно применяется не потому, что есть реальное is-a отношение, а просто чтобы «прикрепить» данные к объекту.

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

class AdminUser(str):
    pass

admin = AdminUser("alice")
print(admin)         # alice
print(type(admin))   # <class '__main__.AdminUser'>

Здесь AdminUser наследуется от встроенного str — видимо, чтобы сам объект нёс строковое значение (имя или роль). Технически это работает. Но подставим фразу: «AdminUser является строкой» — и сразу становится неловко. AdminUser — это пользователь, а не строка. Строка — это тип данных, которым описывается роль или имя. Связь между AdminUser и str — не is-a, а has-a: пользователь имеет имя, имеет роль.

Правильное решение — обычный класс с атрибутами:

class User:
    def __init__(self, name: str, role: str = "user") -> None:
        self.name = name
        self.role = role

class AdminUser(User):
    def __init__(self, name: str) -> None:
        super().__init__(name, role="admin")

Теперь AdminUser является разновидностью User — это честное is-a отношение. Роль хранится в атрибуте, а не зашита в тип через наследование от str.

Почему исходный вариант хуже на практике:

  • Поменять роль у объекта AdminUser(str) нельзя — str иммутабелен, значение фиксируется при создании. В варианте с атрибутом это одна строчка: user.role = "moderator".
  • isinstance(admin, str) возвращает True — объект ведёт себя как строка везде, где ожидается строка. Это создаёт неожиданные побочные эффекты: функции, принимающие str, молча примут AdminUser, хотя логически это разные вещи.
  • Добавить новые данные или поведение к AdminUser(str) значительно сложнее: str.__init__ не принимает произвольные параметры, и расширение через super() там нетривиально.

Один вопрос помогает сразу поставить диагноз: «Я наследуюсь от X, потому что мой класс является X, или потому что мне нужно хранить X?» Если ответ второй — нужен атрибут, а не родительский класс.

Этот антипаттерн чаще всего возникает при работе со встроенными типами: list, dict, str. Иногда наследование от них действительно оправдано — например, class SortedList(list) расширяет список специфическим поведением вставки, и объект действительно является списком. Но если единственная причина — хранить одно значение определённого типа, атрибут всегда чище и безопаснее.

Задача 1

Дан код:

class Vehicle:
 def __init__(self, brand):
 self.brand = brand

 def describe(self):
 return f'Транспорт: {self.brand}'

class Truck(Vehicle):
 def __init__(self, brand, payload):
 super().__init__(brand)
 self.payload = payload

t = Truck('Volvo', 20)
print(t.describe())
print(t.brand)

Что выведут обе строки?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Дан код:

class Shape:
 def area(self):
 return 0

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

 def area(self):
 return 3.14 * self.radius ** 2

class Square(Shape):
 def __init__(self, side):
 self.side = side

s = Square(4)
print(s.area())

Что выведет этот код?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Разработчик написал:

class Role(str):
 pass

admin_role = Role('admin')

Коллега предлагает вместо этого хранить роль как атрибут: user.role = 'admin'. Какое утверждение точнее всего объясняет, почему вариант коллеги предпочтительнее с точки зрения проектирования?

Выберите один вариант.

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Дан код:

class Base:
 def __init__(self, x):
 self.x = x

class Child(Base):
 def __init__(self, x, y):
 self.y = y
 # super().__init__ не вызывается

c = Child(10, 20)
print(c.y)
print(c.x)

Что произойдёт при выполнении?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

Разработчик хочет добавить логирование в подкласс, сохранив всю логику родительского метода:

class Processor:
 def process(self, data):
 result = [x * 2 for x in data]
 return result

class LoggingProcessor(Processor):
 def process(self, data):
 print(f'Обработка {len(data)} элементов')
 ???
 print('Готово')
 return result

Какой фрагмент нужно поставить вместо ???, чтобы не дублировать логику родителя?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 6

Иерархия транспорта: наследование, super() и переопределение

Тебе нужно построить небольшую иерархию классов для описания транспортных средств: базовый класс и два подкласса, каждый из которых расширяет родителя через super() и переопределяет метод.

Что уже дано

Проверяющая система ничего не создаёт заранее. В редакторе есть незавершённый каркас с тремя классами и TODO-местами. Весь код выполняется в одном процессе и пространстве имён.

Что нужно сделать

Определи три класса на верхнем уровне:

Vehicle — базовый класс:

  • __init__(self, make: str, year: int) — сохраняет self.make и self.year.
  • describe(self) -> str — возвращает строку вида "{make} ({year})", например "Toyota (2020)".
  • fuel_type(self) -> str — возвращает строку "unknown".

ElectricCar(Vehicle) — подкласс для электромобилей:

  • __init__(self, make: str, year: int, range_km: int) — вызывает super().__init__(make, year) и дополнительно сохраняет self.range_km.
  • describe(self) -> str — вызывает super().describe() и добавляет к строке ", range: {range_km} km", например "Toyota (2020), range: 400 km".
  • fuel_type(self) -> str — возвращает "electric".

DieselTruck(Vehicle) — подкласс для дизельных грузовиков:

  • __init__(self, make: str, year: int, payload_tons: float) — вызывает super().__init__(make, year) и дополнительно сохраняет self.payload_tons.
  • describe(self) -> str — вызывает super().describe() и добавляет ", payload: {payload_tons}t", например "Volvo (2018), payload: 12.5t".
  • fuel_type(self) -> str — возвращает "diesel".

Ввод и вывод

stdin не используется. Печатать через print в stdout ничего не нужно.

Проверяющая система создаёт экземпляры всех трёх классов и вызывает их методы напрямую. Твой код должен определить классы с нужными именами и поведением на верхнем уровне.

Пример использования

vehicles = [
    ElectricCar("Volt", 2025, 600),
    DieselTruck("Atlas", 2020, 8.5),
]
for vehicle in vehicles:
    print(vehicle.describe(), "—", vehicle.fuel_type())

Результат:

Volt (2025), range: 600 km — electric
Atlas (2020), payload: 8.5t — diesel
РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку