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

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

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

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

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

```python
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)`) → **разное поведение** в зависимости от типа объекта. Вызывающему коду **не нужно знать**, какой тип он держит.

{{< box "analogy" "Аналогия: универсальный пульт" >}}
На пульте есть кнопка «включить». Нажимаете её перед телевизором — он включается. Перед кондиционером — он начинает охлаждать. Перед колонкой — она начинает играть. **Одна и та же кнопка** (интерфейс), **разное действие** для каждого устройства. И вам, держащему пульт, не нужно знать, как каждое устройство устроено внутри. Появилось новое устройство? Если оно «понимает» кнопку, пульт работает с ним без всяких изменений.
{{< /box >}}

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

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` работает со всем, что можно перебрать.

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

## Синтаксис

```python
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`:

```python
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)
```

**Вывод:**

```text
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()`. |
| Цикл | Ровно та же функция, три разных поведения. |

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

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

```python
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)
```

```text
Paid 101 with Apple Pay on iPhone 15 | fee: 1
```

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

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

```python
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)
```

```text
Redeemed 100 from gift card GIFT-2026 | fee: 0
```

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

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

```python
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")
```

```text
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: проверять тип вместо использования полиморфизма.**

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

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

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

```python
class Cash(PaymentMethod):
    pass

checkout(Cash("Rina"), 50)
```

```text
NotImplementedError: subclasses must implement pay()
```

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

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

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

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

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

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Практика

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

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

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

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

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

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

```text
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` по типу.

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

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

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

```text
Charging card 100
Redirecting to PayPal 100
```

{{< solution >}}
```python
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))
```

```text
Charging card 100
Redirecting to PayPal 100
```

`checkout` не знает и не должен знать, какой это вид оплаты, — он только вызывает `pay`. Каждый тип реализует его по-своему. Это и есть **полиморфизм**.
{{< /solution >}}

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

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

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

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

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

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

```text
Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0
```

{{< solution >}}
```python
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))
```

```text
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` его переопределяет.
{{< /solution >}}

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

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

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

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

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

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

```text
Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90
```

{{< solution >}}
```python
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)
```

```text
Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90
```

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

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

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

**Код:**

```text
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. Объясните одним предложением, что такое утиная типизация.

{{< solution >}}
1. **Да.** `make_it_speak` только вызывает `speak()` — он не проверяет тип. Подходит любой объект, у которого есть `speak`.
2. **Утиная типизация**: «если что-то ходит как утка и крякает как утка — это утка». В Python важно, **что метод существует**, а не из какого класса объект. Общий базовый класс всё равно полезен — чтобы определить интерфейс в одном месте и дать поведение по умолчанию, — но он не обязателен.
{{< /solution >}}

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

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

**Код:**

```text
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.

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

```text
9
28.27
```

{{< solution >}}
1. Будет выброшен `NotImplementedError: subclasses must implement area()`. Это **хорошо**: ошибка обнаруживается сразу и понятно — тот, кто писал `Square`, забыл реализовать `area`. Без этого молча получился бы неверный результат.
2.

```python
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())
```

```text
9
28.27
```
{{< /solution >}}

<!-- exercises:end -->

## Итоги

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

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

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

- [Python Glossary — duck-typing](https://docs.python.org/3/glossary.html#term-duck-typing)
- [Python Tutorial — Inheritance](https://docs.python.org/3/tutorial/classes.html#inheritance)

<!-- docs:end -->
