Полиморфизм: общий интерфейс, разное поведение

Цели урока
  • Объяснить простыми словами и на примере, что такое полиморфизм
  • Писать код, который работает с объектами разных типов через общий интерфейс, не проверяя тип
  • Понимать, почему полиморфизм заменяет цепочки if/elif по типу
  • Знать утиную типизацию (Duck Typing) — полиморфизм без общего наследования

Что мы изучим?

  • Что такое полиморфизм (Polymorphism).
  • Общий интерфейс (Common Interface): тот же метод, разное поведение для каждого типа.
  • Почему он заменяет if type == ....
  • Утиную типизацию (Duck Typing): гибкую версию Python.

Зачем это нужно?

Допустим, платёжная система должна списать деньги с клиента, а клиент может платить кредитной картой, через PayPal или банковским переводом. Без полиморфизма:

def pay(method, amount):
    if method["type"] == "credit":
        print("Charging card", method["card"], amount)
    elif method["type"] == "paypal":
        print("Redirecting", method["email"], "to PayPal for", amount)
    elif method["type"] == "bank":
        print("Waiting for transfer of", amount, "to account", method["account"])

А что будет, когда добавят Apple Pay? Придётся добавить elif здесь, и ещё в функции возврата, и в месячном отчёте, и в проверке комиссии… Каждая функция, которая работает с платежами, содержит одну и ту же цепочку if. Забыли одну — и баг.

В первом уроке ООП это была «проблема 4». Полиморфизм — её решение.

Объяснение с основ

Что такое полиморфизм?

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

Простыми словами: один и тот же вызов (method.pay(100)) → разное поведение в зависимости от типа объекта. Вызывающему коду не нужно знать, какой тип он держит.

Аналогия: универсальный пульт

На пульте есть кнопка «включить». Нажимаете её перед телевизором — он включается. Перед кондиционером — он начинает охлаждать. Перед колонкой — она начинает играть. Одна и та же кнопка (интерфейс), разное действие для каждого устройства. И вам, держащему пульт, не нужно знать, как каждое устройство устроено внутри. Появилось новое устройство? Если оно «понимает» кнопку, пульт работает с ним без всяких изменений.

Как это работает: общий интерфейс

  1. В родителе определите интерфейс: какие методы есть у всех типов (pay, refund, fee).
  2. Каждый потомок переопределяет методы по-своему.
  3. Код, который их использует, работает с интерфейсом: method.pay(amount), а не с типами.

Мы это уже видели! В describe() у Employee, который вызывал self.monthly_cost() и доходил до нужной версии менеджера или стажёра.

Главное преимущество: открытость/закрытость (Open/Closed)

С полиморфизмом добавление нового типа (Apple Pay) = написание одного нового класса. Ни одна строка в существующем коде, который использует платежи, не меняется. Система открыта для расширения и закрыта для изменения — важный принцип проектирования ПО.

Утиная типизация

В Python полиморфизм не требует наследования. Python не проверяет «наследует ли объект PaymentMethod?». Он просто пытается вызвать метод. Если метод есть — работает.

«Если что-то ходит как утка и крякает как утка, то это утка.»

Утиная типизация (Duck Typing) = «неважно, кто ты, важно, что ты умеешь». Мы уже этим пользовались: len() работает со str, list, dict и set, потому что у всех них есть «длина». for работает со всем, что можно перебрать.

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

Синтаксис

class Base:
    def action(self):                    # the common interface
        raise NotImplementedError("subclasses must implement action()")

class TypeA(Base):
    def action(self):
        return "A does it this way"

class TypeB(Base):
    def action(self):
        return "B does it another way"

for item in [TypeA(), TypeB()]:          # polymorphic code:
    print(item.action())                 # one call, many behaviors, no if/elif

raise NotImplementedError в родителе = «каждый потомок обязан реализовать этот метод». Если потомок забыл, в момент вызова вы получите понятную ошибку.

Первый пример кода

Платёжная система с полиморфизмом. week-3/payments.py:

class PaymentMethod:
    def __init__(self, owner):
        self.owner = owner

    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")

    def fee(self, amount):
        return 0

class CreditCard(PaymentMethod):
    def __init__(self, owner, last_digits):
        super().__init__(owner)
        self.last_digits = last_digits

    def pay(self, amount):
        return "Charged " + str(amount) + " to card ****" + self.last_digits

    def fee(self, amount):
        return round(amount * 0.02, 2)

class PayPal(PaymentMethod):
    def __init__(self, owner, email):
        super().__init__(owner)
        self.email = email

    def pay(self, amount):
        return "Sent " + str(amount) + " via PayPal from " + self.email

class BankTransfer(PaymentMethod):
    def pay(self, amount):
        return "Waiting for a bank transfer of " + str(amount) + " from " + self.owner

def checkout(method, amount):
    total = amount + method.fee(amount)
    print(method.pay(total), "| fee:", method.fee(amount))

methods = [CreditCard("Dana", "4321"), PayPal("Avi", "avi@mail.com"), BankTransfer("Noa")]
for method in methods:
    checkout(method, 100)

Вывод:

Charged 102.0 to card ****4321 | fee: 2.0
Sent 100 via PayPal from avi@mail.com | fee: 0
Waiting for a bank transfer of 100 from Noa | fee: 0

Разбор кода построчно

КодОбъяснение
class PaymentMethodИнтерфейс: у каждого способа оплаты обязан быть pay, и есть fee (по умолчанию 0).
raise NotImplementedError«Абстрактный метод»: определяет интерфейс без реализации.
CreditCard.pay, PayPal.pay, BankTransfer.payКаждый реализует pay по-своему.
CreditCard.feeПереопределение: комиссия 2%. Остальные наследуют 0 родителя.
def checkout(method, amount):Полиморфный код: не проверяет тип, не содержит ни одного if. Только вызывает method.fee() и method.pay().
ЦиклРовно та же функция, три разных поведения.

Ещё один пример

Добавляем новый тип — не трогая существующий код. А затем утиная типизация.

class ApplePay(PaymentMethod):
    def __init__(self, owner, device):
        super().__init__(owner)
        self.device = device

    def pay(self, amount):
        return "Paid " + str(amount) + " with Apple Pay on " + self.device

    def fee(self, amount):
        return 1

checkout(ApplePay("Tom", "iPhone 15"), 100)
Paid 101 with Apple Pay on iPhone 15 | fee: 1

checkout вообще не изменился. Мы добавили класс, и он работает. В версии с if/elif пришлось бы редактировать каждую функцию, которая работает с платежами.

Утиная типизация — объект, который не наследует PaymentMethod:

class GiftCard:
    def __init__(self, code):
        self.code = code

    def pay(self, amount):
        return "Redeemed " + str(amount) + " from gift card " + self.code

    def fee(self, amount):
        return 0

checkout(GiftCard("GIFT-2026"), 100)
Redeemed 100 from gift card GIFT-2026 | fee: 0

GiftCard не наследует PaymentMethod, но у него есть pay и fee, поэтому checkout с ним работает. Это утиная типизация. (В настоящем проекте всё же лучше, чтобы он наследовал, — тогда интерфейс задокументирован и явен.)

Ещё один знакомый пример: уведомления

class Notification:
    def __init__(self, recipient):
        self.recipient = recipient

    def send(self, text):
        raise NotImplementedError

class EmailNotification(Notification):
    def send(self, text):
        return "Email to " + self.recipient + ": " + text

class SmsNotification(Notification):
    def send(self, text):
        return "SMS to " + self.recipient + ": " + text[:20]

def notify_all(channels, text):
    for channel in channels:
        print(channel.send(text))

notify_all([EmailNotification("dana@mail.com"), SmsNotification("050-1234567")],
           "Your order #1042 has shipped and will arrive tomorrow")
Email to dana@mail.com: Your order #1042 has shipped and will arrive tomorrow
SMS to 050-1234567: Your order #1042 has

У каждого канала свои правила (SMS обрезается до 20 символов), а notify_all этого не знает и не интересуется.

Типичная ошибка

Ошибка 1: проверять тип вместо использования полиморфизма.

def checkout(method, amount):
    if isinstance(method, CreditCard):
        ...
    elif isinstance(method, PayPal):
        ...

Это возвращает нас ровно к исходной проблеме, только с классами. Если вы ловите себя на том, что пишете if isinstance(...) для своих классов, чтобы решить, как выполнить действие, — это признак того, что действие должно быть методом, который реализует каждый класс.

Ошибка 2: забыть реализовать абстрактный метод.

class Cash(PaymentMethod):
    pass

checkout(Cash("Rina"), 50)
NotImplementedError: subclasses must implement pay()

И это хорошо: ошибка понятная и точно указывает, чего не хватает. Без raise в родителе вы получили бы тихое неправильное поведение или None вместо строки.

Ошибка 3: несогласованный интерфейс между типами.

class CreditCard(PaymentMethod):
    def pay(self, amount, cvv):
        ...

Теперь checkout (который вызывает method.pay(total)) падает только на кредитных картах. Все реализации должны получать одни и те же параметры. Информация, специфичная для типа (например, CVV), относится к __init__ или к атрибуту этого типа.

Ошибка 4: думать, что полиморфизм требует наследования. В Python — нет (утиная типизация). Но наследование от общей базы документирует интерфейс и предотвращает ошибки. Предпочитайте его, когда проектируете.

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Что вы отрабатываете на этой странице: полиморфизм — один и тот же вызов для нескольких типов, где каждый тип ведёт себя по-своему; добавление нового типа без изменения существующего кода; утиная типизация и NotImplementedError.

К концу страницы вы будете уметь:

  • Заменять цепочку if по типу общим методом, который реализует каждый тип
  • Добавлять новый тип, не трогая использующий его код
  • Объяснять, что такое утиная типизация (Duck Typing)
  • Заставлять потомков реализовать метод с помощью NotImplementedError

Упражнение 1: Вместо цепочки if

Что вы строите: способы оплаты на кассе — без проверки в коде, какого типа каждый из них.

Код, который вы заменяете:

def pay(method, amount):
    if method["type"] == "credit":
        return "Charging card " + str(amount)
    elif method["type"] == "paypal":
        return "Redirecting to PayPal " + str(amount)

Что написать:

  1. Базовый класс с именем PaymentMethod с pay, который получает сумму и выбрасывает NotImplementedError.
  2. Два потомка: CreditCard и PayPal. Каждый переопределяет pay и возвращает своё предложение из вывода.
  3. Функцию с именем checkout, которая получает способ оплаты и сумму и вызывает pay — без if по типу.

Проверочный код:

for method in [CreditCard(), PayPal()]:
    print(checkout(method, 100))

Желаемый вывод:

Charging card 100
Redirecting to PayPal 100
Показать решение
class PaymentMethod:
    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")


class CreditCard(PaymentMethod):
    def pay(self, amount):
        return "Charging card " + str(amount)


class PayPal(PaymentMethod):
    def pay(self, amount):
        return "Redirecting to PayPal " + str(amount)


def checkout(method, amount):
    return method.pay(amount)


for method in [CreditCard(), PayPal()]:
    print(checkout(method, 100))
Charging card 100
Redirecting to PayPal 100

checkout не знает и не должен знать, какой это вид оплаты, — он только вызывает pay. Каждый тип реализует его по-своему. Это и есть полиморфизм.

Упражнение 2: Новый тип — не трогая checkout

Что вы строите: продолжение кассы — добавляете новый способ оплаты и комиссию.

Что написать:

  1. Новый потомок с именем ApplePay с pay, который возвращает своё предложение из вывода.
  2. В PaymentMethod добавьте fee, который получает сумму и возвращает 0, — значение по умолчанию для всех.
  3. В CreditCard переопределите fee: комиссия 2% от суммы.
  4. Обновите checkout, чтобы он возвращал и комиссию, как в выводе.

Проверочный код:

for method in [CreditCard(), PayPal(), ApplePay()]:
    print(checkout(method, 100))

Желаемый вывод:

Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0
Показать решение
class PaymentMethod:
    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")

    def fee(self, amount):
        return 0


class CreditCard(PaymentMethod):
    FEE_PERCENT = 2

    def pay(self, amount):
        return "Charging card " + str(amount)

    def fee(self, amount):
        return amount * CreditCard.FEE_PERCENT / 100


class PayPal(PaymentMethod):
    def pay(self, amount):
        return "Redirecting to PayPal " + str(amount)


class ApplePay(PaymentMethod):
    def pay(self, amount):
        return "Paid with Apple Pay " + str(amount)


def checkout(method, amount):
    return method.pay(amount) + " | fee: " + str(method.fee(amount))


for method in [CreditCard(), PayPal(), ApplePay()]:
    print(checkout(method, 100))
Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0

ApplePay добавлен без изменения хотя бы одной строки в checkout. PayPal и ApplePay наследуют fee родителя, а CreditCard его переопределяет.

Упражнение 3: Персонажи в игре

Что вы строите: бой между персонажами в игре — где у каждого вида персонажа своя сила атаки.

Что написать:

  1. Базовый класс с именем Character с именем и здоровьем и attack_power, который выбрасывает NotImplementedError.
  2. Warrior — его сила атаки всегда 15.
  3. Mage — его сила атаки 10, плюс 10, если у него больше 50 здоровья.
  4. Функцию с именем attack, которая получает атакующего и цель: она отнимает у цели здоровье по силе атакующего и печатает предложение, как в выводе.

Проверочный код:

conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)

Желаемый вывод:

Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90
Показать решение
class Character:
    def __init__(self, name, health):
        self.name = name
        self.health = health

    def attack_power(self):
        raise NotImplementedError("subclasses must implement attack_power()")


class Warrior(Character):
    def attack_power(self):
        return 15


class Mage(Character):
    STRONG_FROM = 50

    def attack_power(self):
        if self.health > Mage.STRONG_FROM:
            return 20
        return 10


def attack(attacker, target):
    power = attacker.attack_power()
    target.health = target.health - power
    print(attacker.name, "hits", target.name, "for", power)


conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)
Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90

После атаки Конана у Мерлина осталось 45 здоровья — меньше 50, — поэтому его сила упала до 10. attack ничего не знает о правилах каждого персонажа.

Упражнение 4: Утиная типизация

Что вы делаете: читаете код и объясняете — без написания кода.

Код:

class Duck:
    def speak(self):
        return "Quack"

class Robot:
    def speak(self):
        return "Beep"

def make_it_speak(thing):
    return thing.speak()

Задание:

  1. Duck и Robot не наследуют одного и того же родителя. Будет ли make_it_speak работать с обоими? Почему?
  2. Объясните одним предложением, что такое утиная типизация.
Показать решение
  1. Да. make_it_speak только вызывает speak() — он не проверяет тип. Подходит любой объект, у которого есть speak.
  2. Утиная типизация: «если что-то ходит как утка и крякает как утка — это утка». В Python важно, что метод существует, а не из какого класса объект. Общий базовый класс всё равно полезен — чтобы определить интерфейс в одном месте и дать поведение по умолчанию, — но он не обязателен.

Упражнение 5: NotImplementedError

Что вы строите: фигуры, каждая из которых умеет считать свою площадь, — и родителя, который этого требует.

Код:

class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")

class Square(Shape):
    def __init__(self, side):
        self.side = side

Задание:

  1. Что произойдёт, когда вы вызовете площадь Square(3)? Почему это на самом деле хорошо?
  2. Исправьте Square, чтобы он считал площадь.
  3. Добавьте Circle, который получает радиус и вычисляет площадь с помощью math.pi, округлённую до двух знаков.
  4. Напечатайте в цикле площадь квадрата со стороной 3 и круга с радиусом 3.

Желаемый вывод:

9
28.27
Показать решение
  1. Будет выброшен NotImplementedError: subclasses must implement area(). Это хорошо: ошибка обнаруживается сразу и понятно — тот, кто писал Square, забыл реализовать area. Без этого молча получился бы неверный результат.
import math


class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")


class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return round(math.pi * self.radius ** 2, 2)


for shape in [Square(3), Circle(3)]:
    print(shape.area())
9
28.27

Итоги

  • Полиморфизм = один и тот же вызов, разное поведение в зависимости от типа объекта. Вызывающему коду не нужно знать тип.
  • Стройте общий интерфейс в родителе (с raise NotImplementedError для обязательных методов), и каждый потомок реализует его по-своему.
  • Он заменяет цепочки if isinstance / if type ==. Новый тип = новый класс, без изменения существующего кода.
  • Метод родителя, который вызывает self.x(), доходит до версии потомка (шаблонный метод, Template Method).
  • Утиная типизация: в Python достаточно, чтобы у объекта был метод. Общее наследование всё равно рекомендуется как документация.
  • Все реализации получают одни и те же параметры.

Официальная документация