Polymorphism: a Common Interface, Different Behavior

Lesson goals
  • Explain what Polymorphism is in simple words and with an example
  • Write code that works on objects of different types through a common interface, without checking the type
  • Understand why Polymorphism replaces chains of if/elif by type
  • Know Duck Typing — Polymorphism without a common inheritance

What will we learn?

  • What Polymorphism is.
  • A Common Interface: the same method, different behavior for each type.
  • Why it replaces the if type == ....
  • Duck Typing: Python’s flexible version.

Why do we need this?

Suppose a payment system needs to charge a customer, and the customer can pay by credit card, by PayPal or by bank transfer. Without Polymorphism:

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"])

And what happens when you add Apple Pay? You have to add an elif here, and also in the refund function, and also in the monthly report, and also in the fee check… Every function that handles payments contains the same if chain. You forget one, and there is a bug.

In the first OOP lesson this was “problem 4”. Polymorphism is its solution.

Explanation from the basics

What is Polymorphism?

Polymorphism (from Greek: “many forms”) = the ability to work with objects of different types through the same interface, where every object performs the action in its own way.

In simple words: the same call (method.pay(100)) → different behavior, according to the object’s type. The calling code does not need to know which type it is holding.

Analogy: a universal remote control

The remote has an “on” button. You press it in front of a TV, and it turns on. In front of an air conditioner, it starts cooling. In front of a speaker, it starts playing. The same button (an interface), a different action for every device. And you, holding the remote, do not need to know how each device works inside. A new device arrives? If it “understands” the button, the remote works with it without any change.

How it works: a common interface

  1. In the Parent, define the interface: which methods all the types have (pay, refund, fee).
  2. Every Child overrides the methods in its own way.
  3. The code that uses them works against the interface: method.pay(amount), and not against the types.

We have already seen this! In the describe() of Employee, which called self.monthly_cost() and reached the right version of a manager or an intern.

The main advantage: Open/Closed

With Polymorphism, adding a new type (Apple Pay) = writing one new Class. Not a single line in the existing code that uses payments changes. The system is open to extension and closed to modification, an important principle in software design.

Duck Typing

In Python, Polymorphism does not require inheritance. Python does not check “does the object inherit from PaymentMethod?”. It only tries to call the method. If the method exists, it works.

“If it walks like a duck and quacks like a duck, it’s a duck.”

Duck Typing = “it does not matter what you are, it matters what you know how to do”. We have already used this: len() works on str, list, dict and set, because they all have a “length”. for works on anything you can go over.

And still, inheriting from a common Parent is the recommended way when you design an interface on purpose: it documents what the interface is, and allows shared code.

Syntax

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 in the Parent = “every Child must implement this method”. If a Child forgot, you get a clear error at the moment of the call.

First code example

The payment system, with Polymorphism. 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)

Output:

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

Breaking down the code line by line

CodeExplanation
class PaymentMethodThe interface: every payment method must have pay, and has fee (default 0).
raise NotImplementedErrorAn “abstract method”: it defines an interface, without an implementation.
CreditCard.pay, PayPal.pay, BankTransfer.payEach one implements pay in its own way.
CreditCard.feeAn Override: a 2% fee. The others inherit the Parent’s 0.
def checkout(method, amount):The polymorphic code: it does not check the type, it contains no if. It only calls method.fee() and method.pay().
The loopExactly the same function, three different behaviors.

Another example

Adding a new type — without touching existing code. And then Duck Typing.

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 did not change at all. We added a Class, and it works. In the if/elif version we would have had to edit every function that handles payments.

Duck Typing — an object that does not inherit from 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 does not inherit from PaymentMethod, but it has pay and fee, so checkout works with it. This is Duck Typing. (In a real project it is still better for it to inherit, so that the interface is documented and explicit.)

Another familiar example: notifications

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

Every channel has its own rules (SMS is cut to 20 characters), and notify_all does not know and does not care.

Common mistake

Mistake 1: checking the type instead of using Polymorphism.

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

This takes us back exactly to the original problem, only with Classes. If you find yourself writing if isinstance(...) on your own Classes to decide how to perform an action, it is a sign that the action should be a method that every Class implements.

Mistake 2: forgetting to implement an abstract method.

class Cash(PaymentMethod):
    pass

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

And that is good: the error is clear and points exactly at what is missing. Without the raise in the Parent, you would get a silent, wrong behavior, or None instead of a string.

Mistake 3: an inconsistent interface between the types.

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

Now checkout (which calls method.pay(total)) fails only on credit cards. All the implementations must receive the same parameters. Information specific to a type (like a CVV) belongs in the __init__ or in an Attribute of that type.

Mistake 4: thinking that Polymorphism requires inheritance. In Python it does not (Duck Typing). But inheriting from a common Base documents the interface, and prevents mistakes. Prefer it when you design.

Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

What you practice on this page: polymorphism — the same call for several types, where each type behaves differently; adding a new type without changing existing code; Duck Typing and NotImplementedError.

By the end of the page you will know how to:

  • Replace an if chain by type with a shared method that every type implements
  • Add a new type without touching the code that uses it
  • Explain what Duck Typing is
  • Force Children to implement a method using NotImplementedError

Exercise 1: Instead of an if chain

What you build: payment methods at the checkout — without checking in the code what the type of each one is.

The code you replace:

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

What to write:

  1. A base Class named PaymentMethod, with pay that receives an amount and throws NotImplementedError.
  2. Two Children: CreditCard and PayPal. Each one overrides pay and returns its own sentence from the output.
  3. A function named checkout that receives a payment method and an amount, and calls pay — without an if on the type.

Test code:

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

The desired output:

Charging card 100
Redirecting to PayPal 100
Show solution
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 does not know and does not need to know which kind of payment it is — it only calls pay. Each type implements it differently. That is Polymorphism.

Exercise 2: A new type — without touching checkout

What you build: continuing the checkout — adding a new payment method and a fee.

What to write:

  1. A new Child named ApplePay, with pay that returns its own sentence from the output.
  2. In PaymentMethod, add fee that receives an amount and returns 0 — a default for everyone.
  3. In CreditCard, override fee: a fee of 2% of the amount.
  4. Update checkout so that it also returns the fee, as in the output.

Test code:

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

The desired output:

Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0
Show solution
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 was added without changing a single line in checkout. PayPal and ApplePay inherit the Parent’s fee, and CreditCard overrides it.

Exercise 3: Characters in a game

What you build: a fight between characters in a game — where every kind of character has a different attack power.

What to write:

  1. A base Class named Character, with a name and health, and attack_power that throws NotImplementedError.
  2. Warrior — its attack power is always 15.
  3. Mage — its attack power is 10, plus 10 if it has more than 50 health.
  4. A function named attack that receives an attacker and a target: it takes health away from the target according to the attacker’s power, and prints a sentence as in the output.

Test code:

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

The desired output:

Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90
Show solution
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

After Conan attacked, Merlin was left with 45 health — below 50 — so his power dropped to 10. attack knows nothing about each character’s rules.

Exercise 4: Duck Typing

What you do: read code and explain — without writing code.

The code:

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

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

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

The task:

  1. Duck and Robot do not inherit from the same Parent. Will make_it_speak work with both? Why?
  2. Explain in one sentence what Duck Typing is.
Show solution
  1. Yes. make_it_speak only calls speak() — it does not check the type. Any object that has speak fits.
  2. Duck Typing: “if it walks like a duck and quacks like a duck — it is a duck”. In Python what matters is that the method exists, not which Class the object comes from. A common base Class is still useful — to define the interface in one place and give a default behavior — but it is not required.

Exercise 5: NotImplementedError

What you build: shapes that each know how to calculate their own area — and a Parent that requires it.

The code:

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

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

The task:

  1. What will happen when you call the area of Square(3)? Why is that actually good?
  2. Fix Square so that it calculates an area.
  3. Add Circle that receives a radius, and calculates the area with math.pi, rounded to two digits.
  4. Print in a loop the area of a square with side 3 and a circle with radius 3.

The desired output:

9
28.27
Show solution
  1. A NotImplementedError: subclasses must implement area() will be thrown. That is good: the mistake is discovered immediately and clearly — whoever wrote Square forgot to implement area. Without it, a wrong result would come out silently.
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

Summary

  • Polymorphism = the same call, different behavior according to the object’s type. The calling code does not need to know the type.
  • Build a common interface in the Parent (with raise NotImplementedError for required methods), and every Child implements it in its own way.
  • It replaces chains of if isinstance / if type ==. A new type = a new Class, without changing existing code.
  • A Parent’s method that calls self.x() reaches the Child’s version (Template Method).
  • Duck Typing: in Python it is enough that the object has the method. A common inheritance is still recommended as documentation.
  • All the implementations receive the same parameters.

Official documentation