Наследование и полиморфизм – это ещё два основных принципа ООП, которые часто используются вместе. Наследование позволяет дочернему классу перенимать свойства и поведение родительского класса. А полиморфизм позволяет работать с объектами разных классов через единый интерфейс.

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

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

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

Допустим, нам нужно создать классы для разных типов банковских счетов. У всех счетов есть общий набор атрибутов и методов: у них есть номер account_number и баланс balance, который можно пополнить через метод deposit() или снять с него средства с помощью метода withdraw(). Тогда мы можем создать базовый класс счёта Account:

class Account:
    def __init__(self, account_number: int, balance: int | float=0.0):
        self.account_number = account_number  # Номер счёта
        self.balance = balance  # Баланс

    def deposit(self, amount: int | float) -> None:
        self.balance += amount
        print(f"Счет №{self.account_number} пополнен на {amount} руб.") 
        
    def withdraw(self, amount: int | float) -> bool:
        if self.balance >= amount:
            self.balance -= amount
            print(f"Со счета №{self.account_number} снято {amount} руб.") 
            return True
        
        print("Недостаточно средств")
        return False

Давайте создадим объект этого класса и посмотрим на его работу:

acc = Account(134876134, 10_000)
acc.deposit(1000)
# Вывод: Счет №134876134 пополнен на 1000 руб.
acc.withdraw(100)
# Вывод: Со счета №134876134 снято 100 руб.
print(f"Баланс счёта {acc.account_number}: {acc.balance} руб.")
# Вывод: Баланс счёта 134876134: 10900 руб.

То есть мы можем создать счёт с определённым балансом, который можем пополнять и с которого можем снимать деньги. Это базовая функциональность практически любого счёта в банке.

Также в банке есть сберегательные счета. Они обладают всеми теми же характеристиками, что и обычные счета, но также имеют процентную ставку и уникальный метод для начисления процентов. Чтобы не дублировать код, воспользуемся механизмом наследования и создадим класс SavingsAccount, который будет дочерним классом класса Account, после чего добавим в него специфичные для сберегательного счета атрибуты и методы.

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

class SavingsAccount(Account):
    pass

Функция super() и переопределение методов

При создании объектов класса SavingsAccount мы должны инициализировать все его атрибуты – и те, что принадлежат ему самому, и те, что пришли от родительского класса. Конечно, мы можем вручную прописать инициализацию каждого общего атрибута, но вместо дублирования кода мы можем использовать функцию super().

Функция

super()

Описание

Обеспечивает доступ к методам родительского класса

Возвращаемое значение

Объект-посредник, обеспечивающий доступ к методам родительского класса

Эта функция позволяет обращаться к методам родительского класса, в том числе, магическим методам, поэтому она может вызвать конструктор родительского класса __init__() и инициализировать общие атрибуты:

class SavingsAccount(Account):
    def __init__(self, account_number: int, balance: float=0.0):
        super().__init__(account_number, balance)

При таком вызове любого метода родительского класса ему также надо передать все его аргументы. Например, для метода __init__() – это все значения атрибутов. 

Вызов функции super().__init__() гарантирует, что родительский класс будет инициализирован корректно, независимо от того, как изменится его конструктор в будущем. И если в класс Account будут добавлены новые атрибуты (например, валюта currency), нам не придётся менять конструкторы всех его дочерних классов.

При этом функция super() не обязательно используется только для вызова метода __init__(). Вы можете использовать её для вызова любого метода родительского класса, который вы хотите расширить или переопределить.

Например, если банк решит начислять дополнительный бонус при пополнении сберегательного счёта на сумму от 100000 рублей, то мы можем расширить логику метода deposit():
 

class SavingsAccount(Account):
    def __init__(self, account_number: int, balance: float=0.0):
        super().__init__(account_number, balance)
        
    def deposit(self, amount: int | float) -> None:
        # Вызываем родительский метод
        super().deposit(amount)
        
        # Добавляем новые действия
        if amount > 100_000:
            bonus = amount * 0.02 # Бонус 2% от суммы пополнения
            self.balance += bonus
            print(f"Начислен бонус в размере {bonus} руб.")

Здесь мы создаём новый метод deposit() с тем же именем, что и в родительском классе. Это называется переопределением метода. То есть дочерний класс может предоставлять свою собственную реализацию метода, который уже определён в его родительском классе.

Вызов функции super().deposit(amount) передаёт управление родительскому классу, чтобы он увеличил баланс счёта. Это избавляет нас от необходимости повторять строку self.balance += amount. После того как родительский метод отработал, мы добавляем нашу новую, специфическую для класса SavingsAccount логику: проверяем, превышает ли сумма пополнения 100000 и, если это так, начисляем дополнительный бонус.

Добавление новых методов и атрибутов в дочерний класс

Теперь, когда мы предоставили дочернему классу доступ к общим атрибутам и переопределили нужные нам методы, настало время добавить уникальные атрибуты и методы для этого класса.

Для класса сберегательного счёта SavingsAccount следует добавить уникальный атрибут процентной ставки interest_rate и метод начисления процентов add_interest():

class SavingsAccount(Account):
    def __init__(
        self, 
        account_number: int, 
        balance: float=0.0, 
        interest_rate: int=5
    ):
        super().__init__(account_number, balance)
        self.interest_rate = interest_rate  # Процентная ставка
        
    def deposit(self, amount: int | float) -> None:
        # Вызываем родительский метод
        super().deposit(amount)
        
        # Добавляем новые действия
        if amount > 100_000:
            bonus = amount * 0.02 # Бонус 2% от суммы пополнения
            self.balance += bonus
            print(f"Начислен бонус в размере {bonus} руб.")
            
    def add_interest(self) -> None:
        interest = self.balance * (self.interest_rate / 100)
        self.balance += interest
        print(f"Начислены проценты: {interest} руб.")

Теперь давайте создадим новый сберегательный счёт и начислим на него проценты:

sav_acc = SavingsAccount(134876135, 100_000, 9)   
sav_acc.add_interest()
# Вывод: Начислены проценты: 9000.0 руб.
print(f"Баланс счёта {sav_acc.account_number}: {sav_acc.balance} руб.")
# Вывод: Баланс счёта 134876135: 109000.0 руб.

Также мы можем использовать метод родительского класса withdraw() для снятия денег со счёта:

sav_acc.withdraw(100)
# Вывод: Со счета №134876135 снято 100 руб.

И переопределённый метод deposit(), начисляющий бонус, если сумма пополнения больше 100 тысяч:

sav_acc.deposit(1000)
# Счет №134876135 пополнен на 1000 руб.
sav_acc.deposit(200_000)
# Счет №134876135 пополнен на 200000 руб.
# Начислен бонус в размере 4000.0 руб.

Так как в переопределённом методе был вызван метод deposit() родительского класса Account, то здесь сначала выполняется пополнение счёта, а затем проверяется сумма пополнения для начисления бонусов.

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

Класс может наследоваться не только от одного, но и от нескольких родительских классов. Это называется множественным наследованием.

Допустим, у нас есть класс BluetoothDevice для подключения беспроводного устройства через Bluetooth и класс WiFiDevice для подключения через Wi-Fi:

class BluetoothDevice:
    def connect_bluetooth(self) -> None:
        # Логика подключения
        print("Подключено через Bluetooth")
        
    def turn_on(self) -> None:
        print("Включение Bluetooth-модуля")


class WiFiDevice:
    def connect_wifi(self) -> None:
        # Логика подключения
        print("Подключено через Wi-Fi")
        
    def turn_on(self) -> None:
        print("Включение Wi-Fi-модуля")

Умная колонка может быть подключена через Bluetooth или Wi-Fi, и если мы разрабатываем класс SmartSpeaker для управления этой колонкой, то вместо того, чтобы писать все функции ещё раз, мы можем унаследовать нужные функции от двух базовых классов: BluetoothDevice и WiFiDevice. В таком случае в скобках после имени дочернего класса через запятую перечисляются имена всех родительских классов:

class SmartSpeaker(BluetoothDevice, WiFiDevice):
    def play_music(self) -> None:
        print("Воспроизведение музыки...")

Теперь все объекты класса SmartSpeaker могут использовать как собственные методы, так и имеют доступ ко всем методам обоих родителей:

my_speaker = SmartSpeaker()
my_speaker.connect_bluetooth()  # Метод от BluetoothDevice
# Вывод: Подключено через Bluetooth
my_speaker.connect_wifi()  # Метод от WiFiDevice
# Вывод: Подключено через Wi-Fi
my_speaker.play_music()  # Собственный метод
# Вывод: Воспроизведение музыки...

Порядок разрешения методов

Множественное наследование может создать проблему ромба. Она возникает, когда два родительских класса наследуют от общего предка. Пусть у классов BluetoothDevice и WiFiDevice есть общий родительский класс устройства Device. При этом у класса Device, как и у обоих его дочерних классов, есть метод включения turn_on():

class Device:
    def turn_on(self) -> None:
        print("Включение устройства")

Тогда возникает вопрос: какой именно из методов turn_on() будет вызван у объекта класса SmartSpeaker?

Image Gallery

Структура множественного наследования в виде ромба

Чтобы избежать путаницы и обеспечить предсказуемое поведение, в Python существует порядок разрешения методов (англ. method resolution order или MRO). Это строго определённый порядок, в котором Python ищет методы и атрибуты в иерархии наследования.

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

Поэтому в нашем случае будет вызван метод turn_on() класса BluetoothDevice, так как он указан первым:

my_speaker = SmartSpeaker()
my_speaker.turn_on()
# Вывод: Включение Bluetooth-модуля

То есть если искомый метод есть или переопределён в классе объекта (дочернем классе), то будет использоваться он, иначе поиск будет вестись выше. Тогда для нашего примера с умной колонкой порядок будет следующий: SmartSpeaker → BluetoothDevice → WiFiDevice → Device → object. В конце поиск будет вестись во встроенном базовом классе object, от которого неявно наследуют все остальные классы.

Полиморфизм

Полиморфизм – это способность одного и того же метода выполнять разные действия в зависимости от того, на каком объекте он был вызван. Это позволяет нам писать более универсальный и гибкий код.

Представьте, что вы создаете программу, которая обрабатывает заказы в интернет-магазине. У вас есть разные типы товаров: обычные товары, цифровые товары и подарочные сертификаты. Каждый из них нужно обрабатывать по-разному, но в то же время все они являются товарами, поэтому создадим базовый класс товара Product с методом обработки заказа process_order():

class Product:
    def __init__(self, name: str, price: int):
        self.name = name
        self.price = price

    def process_order(self) -> None:
        print(f"Обработка заказа товара \"{self.name}\"")

Теперь создадим на его основе два дочерних класса: DigitalProduct для цифровых товаров и GiftCertificate для подарочных сертификатов, которые будут переопределять метод process_order() для своей уникальной логики.
 

class DigitalProduct(Product):
    def process_order(self) -> None:
        print(f"Отправка ссылки на скачивание товара \"{self.name}\"")

class GiftCertificate(Product):
    def process_order(self) -> None:
        print(f"Отправка кода подарочного сертификата \"{self.name}\"")

Теперь мы можем создать список, который содержит объекты разных классов, и пройтись по нему в цикле, вызывая один и тот же метод process_order().

products = [
    Product("Футболка", 500),
    DigitalProduct("Электронная книга", 300),
    GiftCertificate("Сертификат на 1000 руб.", 1000)
]
for item in products:
    item.process_order()
# Вывод: Обработка заказа товара "Футболка"
# Вывод: Отправка ссылки на скачивание товара "Электронная книга"
# Вывод: Отправка кода подарочного сертификата "Сертификат на 1000 руб."

Как вы видите, одна и та же команда item.process_order() приводит к разным результатам, так как Python вызывает соответствующую реализацию метода для каждого объекта. В этом и заключается суть полиморфизма.

Полиморфизм позволяет писать код, который работает с разными типами объектов. Если в будущем вы добавите новый класс товара, например подписку Subscription, вам не придется менять существующий код, который обрабатывает заказы. Достаточно будет создать новый класс, унаследовать его от Product и переопределить метод process_order().

Другими словами, полиморфизм позволяет создать универсальный интерфейс для работы с различными объектами, поэтому нам не нужно писать множество условных операторов if/elif/else, чтобы определить, какой метод вызывать.

Примеры

Пример 1. Создание отчётов

Класс StandardReport описывает стандартный отчёт в виде строки, который создаёт метод generate(). Класс PDFReport наследуется от класса Report, то есть он наследует атрибут title для заголовка и атрибут data для данных, но переопределяет метод generate() и форматирует строку с отчётом для представления его в PDF:

class StandardReport:
    """Базовый класс. Описывает стандартный отчёт в виде строки."""
    
    def __init__(self, title: str, data: dict):
        """Конструктор класса Report.
        
        Параметры:
            title: Заголовок.
            data: Данные.
        """
        self.title = title
        self.data = data

    def generate(self) -> str:
        """Возвращает строку с отчётом в самом простом виде."""
        return f"{self.title}: {self.data}"


class PDFReport(StandardReport):
    """Описывает стандартный отчёт в PDF-формате."""
    
    def generate(self) -> str:
        """Возвращает строку с отчётом для представления его в PDF."""
        detail_lines = [f"{key}: {value}" for key, value in self.data.items()]
        content = "\n\t".join(detail_lines)
        return f"=== {self.title} (PDF) ===\n\t{content}"


# Создание объектов разных типов
data = {"Продажи": 150000, "Расходы": 45000, "Прибыль": 105000}
title = "Ежемесячный финансовый отчёт"

st_report = StandardReport(title, data)
pdf_report = PDFReport(title, data)

# Использование полиморфизма: вызов одного метода для разных объектов
for rep in [st_report, pdf_report]:
    print(rep.generate())

Вывод:

Ежемесячный финансовый отчёт: {'Продажи': 150000, 'Расходы': 45000, 'Прибыль': 105000}
=== Ежемесячный финансовый отчёт (PDF) ===
        Продажи: 150000
        Расходы: 45000
        Прибыль: 105000

Пример 2. Финансовые транзакции

Базовый класс Transaction описывает финансовые транзакции в общем виде. Два его дочерних класса DebitTransaction и CreditTransaction переопределяют общий метод get_description() для описания списания или зачисления средств:

class Transaction:
    """Базовый класс. Описывает все финансовые транзакции."""
    
    def __init__(self, amount: float, date: str):
        """Конструктор класса Transaction.

        Параметры:
            amount: Сумма транзакции.
            date: Дата совершения транзакции.
        """
        self.amount = amount
        self.date = date

    def get_description(self) -> str:
        """Метод для описания конкретного типа транзакции.
        
        Возвращает:
            Строка с общим описанием.
        """
        return f"Транзакция от {self.date} на сумму {self.amount} руб."


class DebitTransaction(Transaction):
    """Описывает дебетовую транзакцию (списание)."""
    
    def get_description(self) -> str:
        """Возвращает описание списания."""
        return f"Списание средств ({self.date}): -{self.amount} руб."


class CreditTransaction(Transaction):
    """Описывает кредитную транзакцию (зачисление)."""
    
    def get_description(self) -> str:
        """Возвращает описание зачисления."""
        return f"Зачисление средств ({self.date}): +{self.amount} руб."


# Создание транзакций разных классов
transactions = [
    DebitTransaction(1500.00, "2025-10-25"),
    CreditTransaction(50000.00, "2025-10-25"),
    DebitTransaction(500.50, "2025-10-26"),
]
for trans in transactions:
    print(trans.get_description())

Вывод:

Списание средств (2025-10-25): -1500.00 руб.
Зачисление средств (2025-10-25): +50000.00 руб.
Списание средств (2025-10-26): -500.50 руб.

Пример 3. Расчёт итоговой стоимости с учётом скидки и НДС

Класс Invoice, описывающий счёт за товар, является дочерним классом класса TaxCalculator, описывающим расчёт налога, и класса DiscountApplier, описывающим применение скидки. Он рассчитывает итоговую сумму с учётом скидки и налога, расчёт которых унаследован от обоих этих классов:

class TaxCalculator:
    """Базовый класс. Описывает расчёт налога."""

    def calculate_tax(self, amount: float, tax_rate: int) -> float:
        """Рассчитывает сумму налога для заданной суммы.

        Параметры:
            amount: Сумма без налога.
            tax_rate: Налоговая ставка в процентах.
            
        Возвращает:
            Сумма налога.
        """
        return amount * tax_rate / 100


class DiscountApplier:
    """Базовый класс. Описывает применение скидки."""
    
    def apply_discount(self, amount: float, discount: int) -> float:
        """Применяет скидку к сумме.

        Параметры:
            amount: Исходная сумма.
            discount: Скидка в процентах.
            
        Возвращает:
            Сумма после применения скидки.
        """
        discount_amount = amount * (discount / 100)
        return amount - discount_amount


class Invoice(TaxCalculator, DiscountApplier):
    """Описывает выставление счёта с учётом налога и скидки."""
    
    def finalize_total(self, raw_amount: float, discount: int, tax_rate: int) -> float:
        """Рассчитывает итоговую сумму: сначала скидка, потом налог.

        Параметры:
            raw_amount: Исходная сумма до всех расчетов.
            discount: Скидка в процентах.
            tax_rate: Налоговая ставка в процентах.
            
        Возвращает:
            Финальная сумма к оплате (float).
        """
        # Применяем скидку (от DiscountApplier)
        amount_after_discount = self.apply_discount(raw_amount, discount)
        
        # Рассчитываем налог (от TaxCalculator)
        tax = self.calculate_tax(amount_after_discount, tax_rate)
        
        final_total = amount_after_discount + tax
        print(f"Исходная сумма: {raw_amount} руб.")
        print(f"Сумма после скидки ({discount} %): {amount_after_discount} руб.")
        print(f"Налог ({tax_rate} %): {tax} руб.")
        return final_total


invoice = Invoice()
final_price = invoice.finalize_total(
    raw_amount=1000,
    discount=10, 
    tax_rate=20
)
print(f"Сумма к оплате: {final_price} руб.")

Вывод:

Исходная сумма: 1000 руб.
Сумма после скидки (10 %): 900.0 руб.
Налог (20 %): 180.0 руб.
Сумма к оплате: 1080.0 руб.

Итоги

  • Наследование – это механизм, который позволяет создавать новый класс на основе уже существующего.
  • Если класс наследуется от другого, то имя родительского класса указывается в круглых скобках после имени дочернего класса.
  • Функция super() позволяет обращаться к методам родительского класса.
  • Переопределение метода – это возможность дочернего класса предоставить свою собственную реализацию метода, существующего в родительском классе.
  • Класс может наследоваться не только от одного, но и от нескольких родительских классов. Это называется множественным наследованием.
  • Если класс наследуется от двух родительских классов, у которых есть общий родительский класс, то поиск метода ведётся от дочернего класса к самому общему (базовому классу object, от которого неявно наследуются все остальные классы). При этом сохраняется порядок, в котором были указаны родительские классы.
  • Полиморфизм – это способность одного и того же метода выполнять разные действия в зависимости от того, на каком объекте он был вызван.

Задания для самопроверки

1. Опишите суть проблемы ромба, которая может возникнуть при множественном наследовании. Как она решается в Python?

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

2. Создайте классы для машины Car, велосипеда Bicycle и лодки Boat. Все три класса должны иметь метод move():

  • Метод Car.move() выводит строку "Едет по дороге...".
  • Метод Bicycle.move() выводит строку "Едет по горной тропе...".
  • Метод Boat.move() выводит строку "Плывет по реке...".

Создайте список, содержащий по одному объекту каждого класса. Пройдитесь по списку в цикле for и вызовите move() для каждого объекта, демонстрируя полиморфизм.

class Car:
    def move(self) -> None:
        print("Едет по дороге...")


class Bicycle:
    def move(self) -> None:
        print("Едет по горной тропе...")


class Boat:
    def move(self) -> None:
        print("Плывет по реке...")


vehicles = [Car(), Bicycle(), Boat()]
for vehicle in vehicles:
    vehicle.move()
# Вывод: Едет по дороге...
# Вывод: Едет по горной тропе...
# Вывод: Плывет по реке...

3. Для классов Car, Bicycle и Boat из предыдущего задания создайте родительский класс для транспорта Vehicle, в котором придумайте минимум один общий метод для этих классов. Создайте объект класса Boat и вызовите метод, унаследованный от класса Vehicle.

class Vehicle:
    # Общий метод для запуска
    def start_engine(self) -> None:
        print(f"Транспорт запущен")


class Car(Vehicle):
    def move(self) -> None:
        print("Едет по дороге...")


class Bicycle(Vehicle):
    def move(self) -> None:
        print("Едет по горной тропе...")


class Boat(Vehicle):
    def move(self) -> None:
        print("Плывет по реке...")


boat = Boat()
boat.start_engine()
# Вывод: Транспорт запущен

4. Создайте базовый класс Employee с методом log_work(), который выводит строку "Сотрудник приступил к работе". Создайте его дочерний класс Manager и переопределите в нём метод log_work(). Сначала он должен вызвать родительский метод (с помощью функции super()), а затем вывести дополнительную строку "В 12:00 будет совещание". Создайте объект класса Manager и вызовите метод log_work().

class Employee:
    def log_work(self) -> None:
        print("Сотрудник приступил к работе")


class Manager(Employee):
    def log_work(self) -> None:
        # Вызываем метод родителя
        super().log_work() 
        # Добавляем дополнительную логику
        print("В 12:00 будет совещание")


manager = Manager()
manager.log_work()
# Вывод: Сотрудник приступил к работе
# Вывод: В 12:00 будет совещание

5. Создайте два базовых класса: Walker для ходьбы с методом walk(), который выводит строку "Идет по земле" и Swimmer для плавания с методом swim(), который выводит строку "Плывет в воде". Создайте дочерний класс Amphibian, описывающий земноводных, который наследуется от классов Walker и Swimmer и инициализируется атрибутом с видом specie. Создайте объект класса Amphibian и вызовите методы walk() и swim().

class Walker:
    def walk(self) -> None:
        print("Идет по земле")


class Swimmer:
    def swim(self) -> None:
        print("Плывет в воде")


class Amphibian(Walker, Swimmer):
    def __init__(self, specie: str):
        self.specie = specie


frog = Amphibian("Лягушка")
frog.walk() 
# Вывод: Идет по земле
frog.swim()
# Вывод: Плывет в воде