Лекция 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: passclass B(A): passclass C(A): passclass 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: oklog(SocketLogger(), "ok") # socket: okFileLogger и 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 наследник Animalprint(isinstance(5, int)) # Trueprint(issubclass(Dog, Animal)) # Trueprint(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).
Вопросы для самопроверки
- Чем отличается базовый класс от производного? Как читается корректное отношение между ними?
- Что произойдёт, если в
__init__подкласса забыть вызватьsuper().__init__()? - В чём разница между переопределением и расширением метода? Как
super()помогает при расширении? - Что такое MRO и зачем он нужен при множественном наследовании?
- Чем утиная типизация отличается от полиморфизма через общий базовый класс?
- Какие магические методы нужно определить, чтобы объекты вашего класса можно было
складывать оператором
+и измерять функциейlen()? - В чём разница между
__str__и__repr__? - Почему
isinstanceпредпочтительнее, чемtype(x) == SomeClass? - Приведите пример, где наследование неуместно и его стоит заменить композицией.
- Перечислите основные риски глубоких иерархий наследования.