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

Лекция 2. Наследование и полиморфизм

Введение

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

Эти механизмы отвечают на вопрос: «Как переиспользовать уже написанный код и строить семейства родственных типов, не дублируя логику?». Наследование позволяет строить новые классы на основе существующих, а полиморфизм — работать с разными объектами через единый интерфейс, не вникая в детали их реализации.

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


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

1.1. Базовый и производный класс

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

Производный класс автоматически получает все атрибуты и методы базового, после чего может:

  • использовать их как есть (повторное использование кода);
  • добавлять новые (расширение);
  • изменять поведение унаследованных методов (переопределение).

Синтаксис наследования в Python — указание базового класса в скобках после имени:

class Animal: # базовый класс
def __init__(self, name: str):
self.name = name
def speak(self) -> str:
return "..."
def describe(self) -> str:
return f"{self.name} говорит: {self.speak()}"
class Dog(Animal): # производный класс
pass
d = Dog("Рекс")
print(d.name) # Рекс — атрибут унаследован
print(d.describe()) # Рекс говорит: ...

Здесь Dog не определяет ничего своего, но уже умеет всё, что умеет Animal. Отношение между ними читается как «является» (is-a): собака является животным. Это ключевой критерий уместности наследования, к которому мы ещё вернёмся.

1.2. Переопределение методов

Переопределение (override) — определение в производном классе метода с тем же именем, что и в базовом. Вызов такого метода у объекта подкласса приведёт к выполнению новой версии:

class Dog(Animal):
def speak(self) -> str: # переопределяем метод базового класса
return "Гав"
class Cat(Animal):
def speak(self) -> str:
return "Мяу"
print(Dog("Рекс").describe()) # Рекс говорит: Гав
print(Cat("Барсик").describe()) # Барсик говорит: Мяу

Обратите внимание: метод describe мы не переопределяли, он остался в базовом классе. Но внутри него вызов self.speak() обращается уже к конкретной реализации того класса, экземпляром которого является объект. Это и есть зачаток полиморфизма, о котором речь пойдёт ниже.

1.3. Функция super() и расширение __init__

Часто производный класс должен не заменить поведение базового, а дополнить его. Для обращения к реализации родителя используется встроенная функция super().

Наиболее частый сценарий — расширение конструктора __init__: подкласс хочет сохранить инициализацию родителя и добавить собственные атрибуты.

class Animal:
def __init__(self, name: str):
self.name = name
class Dog(Animal):
def __init__(self, name: str, breed: str):
super().__init__(name) # вызываем конструктор базового класса
self.breed = breed # добавляем собственный атрибут
d = Dog("Рекс", "овчарка")
print(d.name, d.breed) # Рекс овчарка

Если бы мы не вызвали super().__init__(name), атрибут name не был бы создан, и обращение к d.name привело бы к ошибке AttributeError. Вызов super() — правильный способ переиспользовать логику родителя, а не копировать её.

super() работает и для обычных методов. Типичный приём — «обернуть» поведение базового класса, добавив что-то до или после:

class LoudDog(Dog):
def speak(self) -> str:
base = super().speak() # берём результат родителя
return base.upper() + "!!!"
# если Dog.speak() возвращает "Гав", то LoudDog.speak() вернёт "ГАВ!!!"

Преимущество super() перед прямым вызовом Animal.__init__(self, name) в том, что он корректно работает при множественном наследовании, опираясь на порядок разрешения методов (MRO). Об этом — чуть ниже.

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

Python допускает наследование сразу от нескольких базовых классов. Это мощный, но требующий аккуратности инструмент:

class Swimmer:
def move(self) -> str:
return "плыву"
class Walker:
def move(self) -> str:
return "иду"
class Amphibian(Swimmer, Walker):
pass
print(Amphibian().move()) # плыву

Когда у нескольких родителей есть метод с одинаковым именем, Python должен решить, чью реализацию вызвать. Для этого используется MRO (Method Resolution Order) — порядок разрешения методов, вычисляемый по алгоритму C3-линеаризации. Посмотреть его можно так:

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass
print(D.mro())
# [D, B, C, A, object]

Поиск метода идёт слева направо по этому списку и берёт первую найденную реализацию. Заметьте, что вершина любой иерархии в Python — класс object, от которого неявно наследуются все классы.

Множественное наследование стоит применять осторожно: оно быстро усложняет понимание кода. Часто более чистым решением оказывается композиция (см. лекцию 6).


2. Полиморфизм

2.1. Идея: один интерфейс — много реализаций

Полиморфизм (от греч. «много форм») — способность объектов разных классов реагировать на один и тот же вызов по-своему. В основе лежит принцип «один интерфейс — множество реализаций».

Благодаря полиморфизму мы можем писать код, который работает с целым семейством типов одинаково, не зная заранее, с каким конкретным классом он имеет дело:

def make_them_talk(animals: list[Animal]) -> None:
for a in animals:
print(a.speak()) # вызов один, поведение разное
make_them_talk([Dog("Рекс", "овчарка"), Cat("Барсик")])
# Гав
# Мяу

Функция make_them_talk написана один раз и не меняется, когда мы добавляем новый вид животного. Достаточно создать новый подкласс с собственным speak(). Это — прямое следствие переопределения методов, рассмотренного выше.

2.2. Утиная типизация (вводно)

В статически типизированных языках (Java, C++) для полиморфизма обычно требуется общий базовый класс или интерфейс. Python устроен иначе: благодаря динамической типизации работает принцип утиной типизации (duck typing):

«Если нечто выглядит как утка, плавает как утка и крякает как утка — это и есть утка».

На практике это означает: важен не тип объекта, а наличие у него нужных методов и атрибутов. Объекты могут быть полиморфны, не имея общего предка:

class FileLogger:
def write(self, msg: str) -> None:
print(f"file: {msg}")
class SocketLogger:
def write(self, msg: str) -> None:
print(f"socket: {msg}")
def log(writer, message: str) -> None:
writer.write(message) # нужен лишь метод write()
log(FileLogger(), "ok") # file: ok
log(SocketLogger(), "ok") # socket: ok

FileLogger и SocketLogger ничем не связаны через наследование, но взаимозаменяемы для функции log, потому что оба имеют метод write. Это очень «питоновский» подход: мы полагаемся на поведение, а не на формальную принадлежность к типу.

2.3. Полиморфизм встроенных функций

Полиморфизм пронизывает сам язык. Одна и та же функция len() работает со строками, списками, словарями и любыми объектами, реализующими нужный «магический» метод. Оператор + складывает числа, конкатенирует строки и объединяет списки. Всё это — проявления полиморфизма, и мы можем встроить в него собственные классы через перегрузку операторов.


3. Перегрузка операторов и магические методы

Магические (специальные, dunder — от «double underscore») методы — это методы с именами вида __add__, __len__, __str__. Python вызывает их автоматически в ответ на определённые операции. Определяя их в своём классе, мы делаем его объекты полноценными «гражданами» языка: их можно складывать, сравнивать, печатать, измерять.

Рассмотрим класс двумерного вектора:

class Vector2D:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __add__(self, other: "Vector2D") -> "Vector2D":
return Vector2D(self.x + other.x, self.y + other.y)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector2D):
return NotImplemented
return self.x == other.x and self.y == other.y
def __abs__(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
def __repr__(self) -> str:
return f"Vector2D({self.x}, {self.y})"
v = Vector2D(1, 2) + Vector2D(3, 4)
print(v) # Vector2D(4, 6) — сработал __repr__
print(abs(Vector2D(3, 4))) # 5.0 — сработал __abs__
print(Vector2D(1, 2) == Vector2D(1, 2)) # True — сработал __eq__

Когда мы пишем a + b, Python на самом деле вызывает a.__add__(b). Аналогично:

Операция / функцияМагический метод
a + b__add__
a - b__sub__
a * b__mul__
a == b__eq__
a < b__lt__
len(a)__len__
str(a)__str__
repr(a)__repr__
a[i]__getitem__
x in a__contains__

Ещё один пример — собственная коллекция, поддерживающая len() и индексацию:

class Playlist:
def __init__(self):
self._tracks: list[str] = []
def add(self, track: str) -> None:
self._tracks.append(track)
def __len__(self) -> int:
return len(self._tracks)
def __getitem__(self, index: int) -> str:
return self._tracks[index]
p = Playlist()
p.add("Track A")
p.add("Track B")
print(len(p)) # 2 — сработал __len__
print(p[0]) # Track A — сработал __getitem__

Различие __str__ и __repr__ стоит запомнить: __str__ — «человекочитаемое» представление (для print), а __repr__ — однозначное техническое (для отладки и интерактивной консоли). Если определён только __repr__, он используется и там, и там.

Если операция для данных типов не имеет смысла, корректно вернуть NotImplemented (как в __eq__ выше) — тогда Python попробует альтернативные варианты или выдаст понятную ошибку.


4. Проверка типов: isinstance и issubclass

Иногда всё же нужно узнать тип объекта во время выполнения — например, чтобы обработать особый случай или защититься от некорректных данных. Для этого служат две встроенные функции:

  • isinstance(obj, Class) — является ли объект экземпляром класса (или его подкласса);
  • issubclass(Sub, Base) — является ли один класс подклассом другого.
print(isinstance(Dog("Рекс", "овчарка"), Animal)) # True — Dog наследник Animal
print(isinstance(5, int)) # True
print(issubclass(Dog, Animal)) # True
print(issubclass(Cat, Dog)) # False

Важная особенность: isinstance учитывает наследование. Объект Dog является экземпляром и Dog, и Animal, и object. Это согласуется с принципом подстановки Лисков: там, где ожидается Animal, допустим любой его подкласс.

Обе функции принимают кортеж типов, что удобно для нескольких вариантов:

def to_number(x) -> float:
if isinstance(x, (int, float)):
return float(x)
raise TypeError(f"Ожидалось число, получено {type(x).__name__}")

Замечание о стиле. Сравнивать тип «в лоб» через type(x) == int не рекомендуется: такая проверка не учитывает наследование и ломает полиморфизм. Предпочитайте isinstance. И в целом — чем больше в коде явных проверок типов, тем чаще это сигнал, что стоило бы положиться на утиную типизацию и полиморфизм, а не на ветвление по типам.


5. Когда наследование уместно, а когда нет

Наследование — мощный, но легко применяемый не по делу инструмент. Несколько ориентиров.

Наследование уместно, когда между классами есть отношение «является» (is-a) и подкласс действительно может выступать в роли базового без сюрпризов:

  • Dog является Animal;
  • JsonExporter является Exporter;
  • SavingsAccount является Account.

Наследование неуместно, когда отношение скорее «имеет» (has-a) или «использует». Классический антипример: сделать Car наследником Engine, чтобы переиспользовать метод start(). Машина не является двигателем — она его содержит. Здесь нужна композиция:

class Engine:
def start(self) -> str:
return "двигатель запущен"
class Car:
def __init__(self):
self.engine = Engine() # машина СОДЕРЖИТ двигатель
def start(self) -> str:
return self.engine.start()

Риски глубоких иерархий

  • Хрупкость базового класса. Изменение в базовом классе может неожиданно сломать все подклассы. Чем глубже иерархия, тем шире «радиус поражения».
  • Сильная связанность. Подкласс знает о внутреннем устройстве родителя, и они перестают развиваться независимо.
  • Сложность понимания. Чтобы понять поведение объекта в глубокой иерархии, приходится мысленно собирать метод по кусочкам из нескольких классов вверх по дереву (особенно при множественном наследовании).
  • Негибкость. Дерево наследования фиксируется на этапе написания кода; «пересобрать» поведение объекта во время выполнения нельзя.

Практическое правило: предпочитайте композицию наследованию там, где это возможно. Наследование выбирайте, когда между типами действительно есть отношение «является» и вы хотите единый полиморфный интерфейс. Подробно сравнение наследования и композиции мы разберём в лекции 6.


Краткие итоги

  • Наследование позволяет создавать производные классы на основе базовых, переиспользуя и расширяя их код. Отношение между классами должно читаться как «является» (is-a).
  • Переопределение методов изменяет поведение унаследованного метода в подклассе.
  • Функция super() обращается к реализации родителя; чаще всего применяется для расширения __init__ без копирования логики.
  • Python поддерживает множественное наследование, а порядок поиска методов задаёт MRO (C3-линеаризация).
  • Полиморфизм — «один интерфейс, много реализаций». В Python он опирается на динамическую типизацию и утиную типизацию: важно наличие нужных методов, а не формальный тип.
  • Магические методы (__add__, __len__, __eq__, __repr__ и др.) позволяют встроить свои объекты в синтаксис языка — это перегрузка операторов.
  • isinstance / issubclass проверяют типы с учётом наследования; их следует предпочитать прямому сравнению type(...) ==.
  • Глубокие иерархии наследования рискованны; во многих случаях лучше композиция (тема лекции 6).

Вопросы для самопроверки

  1. Чем отличается базовый класс от производного? Как читается корректное отношение между ними?
  2. Что произойдёт, если в __init__ подкласса забыть вызвать super().__init__()?
  3. В чём разница между переопределением и расширением метода? Как super() помогает при расширении?
  4. Что такое MRO и зачем он нужен при множественном наследовании?
  5. Чем утиная типизация отличается от полиморфизма через общий базовый класс?
  6. Какие магические методы нужно определить, чтобы объекты вашего класса можно было складывать оператором + и измерять функцией len()?
  7. В чём разница между __str__ и __repr__?
  8. Почему isinstance предпочтительнее, чем type(x) == SomeClass?
  9. Приведите пример, где наследование неуместно и его стоит заменить композицией.
  10. Перечислите основные риски глубоких иерархий наследования.