$ sudo teach IT
МОДУЛЬ 4 · УРОК 1

Переопределение методов и полиморфизм

Мы умеем создавать классы и выстраивать иерархию наследования. Сегодня делаем следующий шаг: узнаём, как дочерний класс может переопределить поведение родителя, и открываем один из самых мощных принципов ООП — полиморфизм.

⏱ ~35 минут 🎓 Для новичков 🐍 Python 3

🔤 Что вообще означает это слово?

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

🇬🇷

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 не важно, какого типа объект, — важно лишь, что у него есть нужный метод.