Полиморфизм: общий интерфейс, разное поведение
- Объяснить простыми словами и на примере, что такое полиморфизм
- Писать код, который работает с объектами разных типов через общий интерфейс, не проверяя тип
- Понимать, почему полиморфизм заменяет цепочки 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)) → разное поведение в зависимости от типа объекта. Вызывающему коду не нужно знать, какой тип он держит.
На пульте есть кнопка «включить». Нажимаете её перед телевизором — он включается. Перед кондиционером — он начинает охлаждать. Перед колонкой — она начинает играть. Одна и та же кнопка (интерфейс), разное действие для каждого устройства. И вам, держащему пульт, не нужно знать, как каждое устройство устроено внутри. Появилось новое устройство? Если оно «понимает» кнопку, пульт работает с ним без всяких изменений.
Как это работает: общий интерфейс
- В родителе определите интерфейс: какие методы есть у всех типов (
pay,refund,fee). - Каждый потомок переопределяет методы по-своему.
- Код, который их использует, работает с интерфейсом:
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/elifraise 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: 1checkout вообще не изменился. Мы добавили класс, и он работает. В версии с 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: 0GiftCard не наследует 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)Что написать:
- Базовый класс с именем
PaymentMethodсpay, который получает сумму и выбрасываетNotImplementedError. - Два потомка:
CreditCardиPayPal. Каждый переопределяетpayи возвращает своё предложение из вывода. - Функцию с именем
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 100checkout не знает и не должен знать, какой это вид оплаты, — он только вызывает pay. Каждый тип реализует его по-своему. Это и есть полиморфизм.
Упражнение 2: Новый тип — не трогая checkout
Что вы строите: продолжение кассы — добавляете новый способ оплаты и комиссию.
Что написать:
- Новый потомок с именем
ApplePayсpay, который возвращает своё предложение из вывода. - В
PaymentMethodдобавьтеfee, который получает сумму и возвращает 0, — значение по умолчанию для всех. - В
CreditCardпереопределитеfee: комиссия 2% от суммы. - Обновите
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: 0ApplePay добавлен без изменения хотя бы одной строки в checkout. PayPal и ApplePay наследуют fee родителя, а CreditCard его переопределяет.
Упражнение 3: Персонажи в игре
Что вы строите: бой между персонажами в игре — где у каждого вида персонажа своя сила атаки.
Что написать:
- Базовый класс с именем
Characterс именем и здоровьем иattack_power, который выбрасываетNotImplementedError. Warrior— его сила атаки всегда 15.Mage— его сила атаки 10, плюс 10, если у него больше 50 здоровья.- Функцию с именем
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()Задание:
DuckиRobotне наследуют одного и того же родителя. Будет лиmake_it_speakработать с обоими? Почему?- Объясните одним предложением, что такое утиная типизация.
Показать решение
- Да.
make_it_speakтолько вызываетspeak()— он не проверяет тип. Подходит любой объект, у которого естьspeak. - Утиная типизация: «если что-то ходит как утка и крякает как утка — это утка». В Python важно, что метод существует, а не из какого класса объект. Общий базовый класс всё равно полезен — чтобы определить интерфейс в одном месте и дать поведение по умолчанию, — но он не обязателен.
Упражнение 5: NotImplementedError
Что вы строите: фигуры, каждая из которых умеет считать свою площадь, — и родителя, который этого требует.
Код:
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
class Square(Shape):
def __init__(self, side):
self.side = sideЗадание:
- Что произойдёт, когда вы вызовете площадь
Square(3)? Почему это на самом деле хорошо? - Исправьте
Square, чтобы он считал площадь. - Добавьте
Circle, который получает радиус и вычисляет площадь с помощьюmath.pi, округлённую до двух знаков. - Напечатайте в цикле площадь квадрата со стороной 3 и круга с радиусом 3.
Желаемый вывод:
9
28.27Показать решение
- Будет выброшен
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 достаточно, чтобы у объекта был метод. Общее наследование всё равно рекомендуется как документация.
- Все реализации получают одни и те же параметры.