Переопределение методов и полиморфизм
Мы умеем создавать классы и выстраивать иерархию наследования. Сегодня делаем следующий шаг: узнаём, как дочерний класс может переопределить поведение родителя, и открываем один из самых мощных принципов ООП — полиморфизм.
🔤 Что вообще означает это слово?
Давайте начнём с этимологии — то есть с происхождения слова. Это звучит скучно, но поверьте: когда вы поймёте, из чего оно сложено, вы никогда его не забудете.
poly
греч. «много»
как в слове «полиглот» — человек, говорящий на многих языках
morph
греч. «форма»
как в слове «метаморфоза» — превращение, смена формы
Полиморфизм = «много форм». В программировании это означает: один и тот же интерфейс (одно и то же имя метода) — но разные реализации в разных классах.
💡 Аналогия из жизни. У каждого человека есть кнопка «позвонить» в контакт-листе телефона. Когда вы нажимаете её для мамы — звучит голосовой звонок. Нажимаете для коллеги — открывается мессенджер. Нажимаете для пиццерии — набирается номер. Кнопка одна и та же — действие разное. Вот это и есть полиморфизм в жизни.
В ООП полиморфизм чаще всего реализуется через переопределение методов — когда дочерний класс заменяет метод, унаследованный от родителя, своей собственной версией. Именно это мы сегодня изучим.
🐾 Вспоминаем наших животных из модуля 3
В модуле 3 мы создавали класс Animal с методом speak(). Давайте восстановим эту картину и зайдём глубже.
Представьте: у нас есть базовый класс Animal — «животное». У любого животного есть имя и способность издавать звук. Метод speak() в базовом классе — это такая заглушка, которая говорит: «у всех животных есть голос, но каждый должен реализовать его сам».
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
# Базовый класс даёт "заглушку"
print(f"{self.name} издаёт какой-то звук...")
class Dog(Animal):
def speak(self):
# Dog ПЕРЕОПРЕДЕЛЯЕТ метод speak()
print(f"{self.name} говорит: Гав-гав! 🐶")
class Cat(Animal):
def speak(self):
# Cat ПЕРЕОПРЕДЕЛЯЕТ метод speak() по-своему
print(f"{self.name} говорит: Мяу-мяу! 🐱")
# Создаём объекты
generic_animal = Animal("Животное")
dog = Dog("Рекс")
cat = Cat("Мурка")
# Вызываем один и тот же метод — разный результат!
generic_animal.speak() # Животное издаёт какой-то звук...
dog.speak() # Рекс говорит: Гав-гав! 🐶
cat.speak() # Мурка говорит: Мяу-мяу! 🐱
🎯 Вот оно — переопределение! Классы Dog и Cat заменили метод speak(), который унаследовали от Animal, своими собственными версиями. Имя метода одно, а поведение — разное. Это и есть полиморфизм через наследование.
По-английски это называется method overriding — переопределение метода. Просто объявляете в дочернем классе метод с таким же именем, и Python автоматически будет использовать новую версию вместо родительской.
🔄 Цикл по разным объектам — полиморфизм в действии
Теперь — самый захватывающий момент. Посмотрите, что происходит, когда мы собираем разных животных в один список и вызываем один и тот же метод:
animals = [
Dog("Рекс"),
Cat("Мурка"),
Dog("Бобик"),
Cat("Рыжик"),
Animal("Загадочное существо"),
]
for animal in animals:
animal.speak()
# Вывод:
# Рекс говорит: Гав-гав! 🐶
# Мурка говорит: Мяу-мяу! 🐱
# Бобик говорит: Гав-гав! 🐶
# Рыжик говорит: Мяу-мяу! 🐱
# Загадочное существо издаёт какой-то звук...
Мы пишем один и тот же вызов — animal.speak() — но получаем разный результат в зависимости от реального типа объекта. Python сам разбирается, какую версию метода вызвать.
🧠 Как Python это делает? Когда вы вызываете animal.speak(), Python смотрит на реальный тип объекта в момент вызова (не на тип переменной). Если это Dog — вызывает Dog.speak(). Если Cat — вызывает Cat.speak(). Этот механизм называется динамическая диспетчеризация или позднее связывание (late binding).
В списке могут быть объекты совершенно разных классов — главное, что у всех них есть метод speak(). Код цикла не знает и не должен знать, с каким конкретно классом работает. Это и есть сила полиморфизма.
😫 Как выглядит код БЕЗ полиморфизма
Чтобы понять ценность полиморфизма, представьте, что его не существует. Нам нужна функция make_sound(animal), которая заставляет животное издать звук. Как бы мы её написали без полиморфизма?
Пришлось бы проверять тип каждого объекта вручную:
# ❌ ПЛОХО: код без полиморфизма
def make_sound(animal):
if type(animal).__name__ == "Dog":
print(f"{animal.name} говорит: Гав-гав! 🐶")
elif type(animal).__name__ == "Cat":
print(f"{animal.name} говорит: Мяу-мяу! 🐱")
elif type(animal).__name__ == "Animal":
print(f"{animal.name} издаёт какой-то звук...")
else:
print("Неизвестное животное, не знаю что делать!")
make_sound(Dog("Рекс")) # Рекс говорит: Гав-гав! 🐶
make_sound(Cat("Мурка")) # Мурка говорит: Мяу-мяу! 🐱
🚨 Проблемы этого подхода:
1. Функция знает о каждом конкретном классе — тесно связана с ними.
2. Добавили новый класс Bird? Надо лезть в функцию и добавлять ещё один elif.
3. Таких функций может быть десятки — придётся менять все.
4. Можно легко забыть про какой-то класс — и получить тихий баг.
А теперь посмотрите, как та же задача решается с полиморфизмом:
# ✅ ХОРОШО: код с полиморфизмом
def make_sound(animal):
animal.speak() # Просто вызываем speak() — и всё!
make_sound(Dog("Рекс")) # Рекс говорит: Гав-гав! 🐶
make_sound(Cat("Мурка")) # Мурка говорит: Мяу-мяу! 🐱
Одна строка вместо бесконечного if/elif. Функция make_sound вообще не знает, с каким типом животного работает — и ей это не нужно. Она просто говорит: «Эй, объект, издай звук!» — а объект сам знает как.
🚪 Принцип Open/Closed — добавляем новое без изменения старого
Полиморфизм реализует один из важнейших принципов проектирования кода — принцип открытости/закрытости (Open/Closed Principle). Его сформулировал программист Бертран Мейер ещё в 1988 году, и он не потерял актуальности до сих пор.
Программный компонент должен быть:
🟢 Открыт для расширения — легко добавлять новые классы и поведение
🔴 Закрыт для изменения — старый код не нужно переписывать ради нового функционала
Давайте проверим это на практике. Добавим в нашу систему новый класс — Bird (птица). При подходе с if/elif нам бы пришлось редактировать функцию make_sound. А что произойдёт с полиморфным кодом?
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} издаёт какой-то звук...")
class Dog(Animal):
def speak(self):
print(f"{self.name} говорит: Гав-гав! 🐶")
class Cat(Animal):
def speak(self):
print(f"{self.name} говорит: Мяу-мяу! 🐱")
# ✨ Добавляем новый класс — не трогаем ничего из написанного выше!
class Bird(Animal):
def speak(self):
print(f"{self.name} говорит: Чирик-чирик! 🐦")
# Функция make_sound НЕ ИЗМЕНИЛАСЬ — один и тот же код!
def make_sound(animal):
animal.speak()
# Всё работает с новым классом автоматически
animals = [Dog("Рекс"), Cat("Мурка"), Bird("Кеша")]
for animal in animals:
make_sound(animal)
# Рекс говорит: Гав-гав! 🐶
# Мурка говорит: Мяу-мяу! 🐱
# Кеша говорит: Чирик-чирик! 🐦
✅ Результат: Мы добавили класс Bird — и функция make_sound сразу же заработала с ним без каких-либо изменений. Старый код не тронут. Вот что значит «открыт для расширения, закрыт для изменения».
Представьте, что работаете в команде из 5 человек. Один разработчик пишет функцию make_sound, другие — классы животных. Благодаря полиморфизму они могут работать независимо: каждый добавляет новый класс, и всё сразу работает вместе.
🧩 Полный пример: от проблемы к решению
Соберём всё воедино. Посмотрим сначала на плохой код с if/elif, а потом — на красивое полиморфное решение. Пусть задача будет более реалистичной: у нас фигуры, и нам нужно посчитать площадь каждой.
❌ Без полиморфизма — код становится кошмаром
import math
# Данные о фигурах в виде словарей — некрасиво и неудобно
shapes = [
{"type": "circle", "radius": 5},
{"type": "rectangle", "width": 4, "height": 6},
{"type": "triangle", "base": 3, "height": 8},
]
def print_area(shape):
if shape["type"] == "circle":
area = math.pi * shape["radius"] ** 2
print(f"Круг: площадь = {area:.2f}")
elif shape["type"] == "rectangle":
area = shape["width"] * shape["height"]
print(f"Прямоугольник: площадь = {area:.2f}")
elif shape["type"] == "triangle":
area = 0.5 * shape["base"] * shape["height"]
print(f"Треугольник: площадь = {area:.2f}")
# Новая фигура? Придётся лезть сюда и добавлять elif...
for shape in shapes:
print_area(shape)
✅ С полиморфизмом — чисто, расширяемо, элегантно
import math
class Shape:
"""Базовый класс для всех геометрических фигур."""
def area(self):
# Заглушка — каждая фигура должна реализовать сама
return 0
def describe(self):
print(f"Площадь фигуры: {self.area():.2f}")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
# Добавляем новую фигуру — функция print_area не меняется!
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
# Создаём список — все объекты разных классов, но один интерфейс
shapes = [
Circle(5),
Rectangle(4, 6),
Triangle(3, 8),
Square(7),
]
# Одна функция — работает со всеми!
def print_area(shape):
shape.describe() # Один вызов — Python сам разберётся
for shape in shapes:
print_area(shape)
# Вывод:
# Площадь фигуры: 78.54
# Площадь фигуры: 24.00
# Площадь фигуры: 12.00
# Площадь фигуры: 49.00
💡 Обратите внимание на метод describe() в базовом классе Shape. Он вызывает self.area() — и при этом не знает, какая именно реализация area() будет вызвана. Когда describe() вызывается на объекте Circle, метод self.area() вернёт площадь круга. На объекте Rectangle — площадь прямоугольника. Это полиморфизм внутри самого класса!
🤔 А нужно ли переопределять каждый метод?
Хороший вопрос! Нет, конечно. Переопределяйте метод только тогда, когда поведение в дочернем классе должно отличаться от родительского. Если метод подходит для всех дочерних классов — оставьте его в родителе, и все дочерние классы будут его использовать.
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
def get_info(self):
# Этот метод одинаков для всех животных — НЕ переопределяем
return f"{self.name}, возраст: {self.age} лет"
def speak(self):
# Этот метод разный у каждого — переопределяем в дочерних классах
print(f"{self.name} издаёт звук...")
class Dog(Animal):
def speak(self):
print(f"{self.name} говорит: Гав! 🐶")
# get_info() не переопределяем — родительский вариант нас устраивает
class Cat(Animal):
def speak(self):
print(f"{self.name} говорит: Мяу! 🐱")
# get_info() тоже не переопределяем
dog = Dog("Рекс", 3)
cat = Cat("Мурка", 5)
print(dog.get_info()) # Рекс, возраст: 3 лет ← метод из Animal
print(cat.get_info()) # Мурка, возраст: 5 лет ← тот же метод из Animal
dog.speak() # Рекс говорит: Гав! 🐶 ← метод из Dog
cat.speak() # Мурка говорит: Мяу! 🐱 ← метод из Cat
Это и есть правильный дизайн: общее — в базовом классе, уникальное — переопределяем в дочерних.
✅ Итоги урока
- Полиморфизм — «много форм»: один интерфейс (имя метода), разные реализации.
- Переопределение метода (method overriding) — объявление в дочернем классе метода с тем же именем, что в родителе. Новая версия заменяет родительскую.
- Цикл по объектам разных классов с одним вызовом метода — классический пример полиморфизма.
- Python сам определяет, какую версию метода вызвать, по реальному типу объекта — это позднее связывание.
- Принцип Open/Closed: добавляем новый класс — старый код не меняем. Это возможно именно благодаря полиморфизму.
- Без полиморфизма — бесконечные if/elif с проверкой типов. С полиморфизмом — один чистый вызов.
- Переопределяйте метод только тогда, когда поведение в дочернем классе должно отличаться от родительского.
👉 В следующем уроке мы познакомимся с утиной типизацией — ещё одним видом полиморфизма в Python, который работает даже без наследования. Вы увидите, почему в Python не важно, какого типа объект, — важно лишь, что у него есть нужный метод.