Полиморфизм в Python
Полиморфизм в Python: переопределение методов, утиная типизация, перегрузка операторов и абстрактные интерфейсы — с понятными примерами.
Полиморфизм (от греческого «многообразие форм») позволяет одному фрагменту кода работать с объектами разных типов, если эти объекты поддерживают ожидаемый интерфейс. Вы вызываете одно и то же имя метода для каждого объекта, и каждый объект отвечает в соответствии со своей реализацией.
Полиморфизм — одна из четырёх основ объектно-ориентированного программирования наряду с инкапсуляцией, наследованием и абстракцией. Именно он позволяет функциям, принимающим тип базового класса, автоматически работать с любым подклассом.
В этой главе рассматривается:
- Что такое полиморфизм и почему он важен
- Полиморфизм через переопределение методов
- Полиморфизм через утиную типизацию
- Перегрузка операторов — форма полиморфизма, встроенная в Python
- Абстрактные базовые классы как формальный способ определения полиморфных интерфейсов
- Практические паттерны и типичные ошибки
Прежде чем читать эту главу, убедитесь, что вы разобрались с классами и объектами Python и наследованием в Python.
Зачем нужен полиморфизм
Без полиморфизма функция, работающая с животными, потребовала бы явной цепочки if/elif для каждого типа животного:
def make_sound(animal):
if type(animal).__name__ == "Dog":
print("Woof!")
elif type(animal).__name__ == "Cat":
print("Meow!")
elif type(animal).__name__ == "Bird":
print("Tweet!")
# ... add a new branch every time you add a new animal typeТакой подход ненадёжен. Каждый новый тип животного требует изменения этой функции. С полиморфизмом код становится таким:
def make_sound(animal):
animal.speak() # works for any object that has a speak() methodДобавление нового типа животного требует только определения его метода speak() — сама функция никогда не меняется. Это и есть принцип открытости/закрытости: открыто для расширения, закрыто для изменения.
Полиморфизм через переопределение методов
Наиболее распространённая форма полиморфизма в Python — переопределение методов: подкласс предоставляет собственную версию метода, определённого в родительском классе.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound."
class Dog(Animal):
def speak(self):
return f"{self.name} says woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says meow!"
class Bird(Animal):
def speak(self):
return f"{self.name} says tweet!"Теперь один цикл работает со всеми тремя типами:
animals = [Dog("Rex"), Cat("Whiskers"), Bird("Tweety")]
for animal in animals:
print(animal.speak())
# Rex says woof!
# Whiskers says meow!
# Tweety says tweet!animal.speak() во время выполнения выбирает нужную версию в зависимости от фактического типа объекта. Этот выбор во время выполнения называется динамической диспетчеризацией или поздним связыванием.
Расширение versus замена метода родителя
При переопределении вы можете либо полностью заменить поведение родителя, либо расширить его с помощью super():
class Animal:
def speak(self):
print("[Animal vocalization]")
class Dog(Animal):
def speak(self):
super().speak() # keep the parent's output
print("Woof! (Dog override adds this)")
Dog().speak()
# [Animal vocalization]
# Woof! (Dog override adds this)Используйте super(), когда версия родителя выполняет полезную настройку или логирование, которые должны сохраниться. Опускайте его, когда хотите полностью заменить поведение.
Полиморфизм через утиную типизацию
Система типов Python является структурной, а не номинальной. Объект не обязан принадлежать к конкретной иерархии классов — ему просто нужно иметь нужные методы. Это называется утиной типизацией — по поговорке: «если оно ходит как утка и крякает как утка, значит, это утка».
class Dog:
def speak(self):
return "Woof!"
class Robot:
def speak(self):
return "Beep boop."
class Human:
def speak(self):
return "Hello!"
def introduce(entity):
print(entity.speak())
introduce(Dog()) # Woof!
introduce(Robot()) # Beep boop.
introduce(Human()) # Hello!Dog, Robot и Human не имеют общего родительского класса (кроме встроенного object). Тем не менее introduce() работает со всеми тремя, поскольку у каждого есть метод speak(). Функция не проверяет тип entity — она просто вызывает метод и доверяет, что объект ответит корректно.
Когда использовать утиную типизацию, а когда — наследование
| Ситуация | Предпочтительный подход |
|---|---|
| Объекты логически связаны (все — животные) | Иерархия наследования |
| Объекты не связаны, но разделяют поведение | Утиная типизация |
| Нужно принудительно проверять интерфейс при определении | Абстрактные базовые классы |
| Работа со встроенными типами или сторонними классами, которые нельзя изменить | Утиная типизация |
Утиная типизация идиоматична для Python и широко используется в стандартной библиотеке — например, len() работает с любым объектом, у которого определён __len__, независимо от его класса.
Полиморфизм с функциями и циклами
Вы можете написать одну функцию, которая единообразно обращается с разными типами через полиморфизм. Рассмотрим приложение для рисования:
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
def describe(self):
return f"Circle with radius {self.radius}"
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def describe(self):
return f"Rectangle {self.width}x{self.height}"
class Triangle:
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
def describe(self):
return f"Triangle base={self.base} height={self.height}"
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
for shape in shapes:
print(f"{shape.describe()}: area = {shape.area():.2f}")
# Circle with radius 5: area = 78.54
# Rectangle 4x6: area = 24.00
# Triangle base=3 height=8: area = 12.00Цикл вызывает area() и describe() для каждой фигуры, не заботясь о том, какому классу принадлежит объект. Добавление класса Pentagon позже потребует только написания нового класса — цикл не изменится.
Перегрузка операторов
Арифметические операторы и операторы сравнения в Python тоже полиморфны. + для целых чисел складывает их; + для строк конкатенирует их; + для списков объединяет их. Python реализует это через специальные (dunder) методы.
Вы можете заставить собственные классы реагировать на операторы, определив эти методы:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 * 3) # Vector(3, 6)Один и тот же оператор + теперь ведёт себя по-разному в зависимости от того, являются ли операнды целыми числами, строками или объектами Vector. Это полиморфизм на уровне операторов.
Подробнее о специальных методах Python читайте в разделе магические методы Python.
Полиморфизм с абстрактными базовыми классами
Абстрактные базовые классы (ABC) делают утиную типизацию ещё строже, принудительно проверяя интерфейс во время определения класса. Если подкласс не реализует обязательный метод, Python выбрасывает TypeError в момент попытки его создать.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
"""Return the area of the shape."""
@abstractmethod
def perimeter(self) -> float:
"""Return the perimeter of the shape."""
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
import math
return math.pi * self.radius ** 2
def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius
class Square(Shape):
def __init__(self, side: float):
self.side = side
def area(self) -> float:
return self.side ** 2
def perimeter(self) -> float:
return 4 * self.side
def print_info(shape: Shape) -> None:
print(f"Area: {shape.area():.2f}")
print(f"Perimeter: {shape.perimeter():.2f}")
print_info(Circle(5))
# Area: 78.54
# Perimeter: 31.42
print_info(Square(4))
# Area: 16.00
# Perimeter: 16.00Если попытаться создать экземпляр подкласса, не реализовавшего area():
class Blob(Shape):
pass # forgot to implement area() and perimeter()
b = Blob()
# TypeError: Can't instantiate abstract class Blob with abstract methods area, perimeterABC дают вам страховочную сеть формального контракта, сохраняя при этом полиморфизм во время выполнения.
ABC против утиной типизации — что выбрать?
- Утиная типизация проще и гибче. Предпочитайте её для небольших внутренних кодовых баз и скриптов.
- ABC делают ожидаемый интерфейс явным, рано выявляют ошибки отсутствующих методов и отображаются в автодополнении IDE и инструментах проверки типов. Предпочитайте их в крупных кодовых базах, публичных библиотеках и везде, где важно соблюдение контракта.
Реальный пример: обработка платежей
Полиморфизм особенно хорош в дизайне с подключаемыми модулями. Рассмотрим платёжную систему, поддерживающую несколько провайдеров:
from abc import ABC, abstractmethod
class PaymentProvider(ABC):
@abstractmethod
def charge(self, amount: float, currency: str) -> bool:
"""Attempt to charge the given amount. Return True on success."""
@abstractmethod
def refund(self, transaction_id: str) -> bool:
"""Refund a previous transaction. Return True on success."""
class StripeProvider(PaymentProvider):
def charge(self, amount: float, currency: str) -> bool:
print(f"[Stripe] Charged {amount} {currency}")
return True
def refund(self, transaction_id: str) -> bool:
print(f"[Stripe] Refunded transaction {transaction_id}")
return True
class PayPalProvider(PaymentProvider):
def charge(self, amount: float, currency: str) -> bool:
print(f"[PayPal] Charged {amount} {currency}")
return True
def refund(self, transaction_id: str) -> bool:
print(f"[PayPal] Refunded transaction {transaction_id}")
return True
def process_order(provider: PaymentProvider, amount: float) -> None:
success = provider.charge(amount, "USD")
if success:
print("Order complete.")
process_order(StripeProvider(), 99.99)
# [Stripe] Charged 99.99 USD
# Order complete.
process_order(PayPalProvider(), 49.5)
# [PayPal] Charged 49.5 USD
# Order complete.process_order не знает и не заботится о том, получает ли он StripeProvider или PayPalProvider. Добавление нового CryptoProvider требует только написания нового класса — больше ничего не меняется. Это полиморфизм, обеспечивающий реальную расширяемость.
Типичные ошибки
Ошибка 1: использование type() вместо isinstance()
Сравнение type(obj) == Dog нарушает полиморфизм, потому что для подклассов возвращает False. Предпочитайте isinstance(obj, Animal), который возвращает True как для Dog, так и для любого будущего подкласса:
class Animal:
pass
class Dog(Animal):
pass
d = Dog()
# Fragile — breaks for subclasses:
print(type(d) == Animal) # False
# Correct — subclass-aware:
print(isinstance(d, Animal)) # TrueОшибка 2: несовместимые сигнатуры методов
Полиморфизм предполагает, что все реализации метода принимают одинаковые аргументы. Если Dog.speak() требует аргумент, которого нет у Cat.speak(), вызывающий код, обращающийся к ним единообразно, сломается:
# Inconsistent — will cause errors in a loop
class Dog:
def speak(self, volume): # extra argument!
return f"Woof at volume {volume}"
class Cat:
def speak(self):
return "Meow!"Сохраняйте сигнатуры методов единообразными во всех полиморфных классах.
Ошибка 3: изменяемые аргументы по умолчанию в переопределённых методах
Это более широкая ловушка Python, но она особенно часто встречается в иерархиях классов: никогда не используйте изменяемое значение по умолчанию (список, словарь) в качестве аргумента по умолчанию — оно создаётся один раз и разделяется между всеми вызовами.
# Bug: the list is shared across all instances
class Item:
def __init__(self, tags=[]): # BAD
self.tags = tags
# Fix:
class Item:
def __init__(self, tags=None):
self.tags = tags if tags is not None else []Итоги
| Концепция | Что означает |
|---|---|
| Переопределение метода | Подкласс предоставляет собственную версию метода родителя |
| Динамическая диспетчеризация | Python выбирает нужную версию метода во время выполнения |
| Утиная типизация | Подходит любой объект с нужными методами, независимо от его класса |
| Перегрузка операторов | Dunder-методы (__add__, __len__, …) делают операторы полиморфными |
| Абстрактные базовые классы | Формально обязывают подклассы реализовывать нужные методы |
Полиморфизм делает код расширяемым без изменений. Пишите функции, которые зависят от поведения (имён методов), а не от конкретных типов, — и ваш код будет естественно принимать новые классы без каких-либо изменений.