Polymorphism & Encapsulation

Python · Lesson 36 of 56 7 min read Updated

Two big ideas sit at the heart of object-oriented programming. Polymorphism (Greek for "many forms") means one piece of code can work with many different types of object, as long as they offer the right methods. Encapsulation means an object keeps its data and the code that changes that data together, and hides the parts that outsiders should not touch.

Python handles both ideas in its own relaxed style. In this lesson you will see polymorphism through overriding and through duck typing, and learn how Python's underscore conventions and name mangling give you encapsulation without strict locks.

Polymorphism you already use

You have used polymorphism since your first lesson. The same function or operator does the right thing for different types:

PythonRun it yourself
print(len("Kochi"), len([4, 8, 15]), len({"tea": 20, "coffee": 30}))
print(3 + 4, "Rail" + "way", [1, 2] + [3])

for thing in ("abc", [10, 20], {"x": 1}):
    for item in thing:
        print(item, end=" ")
    print()
Output
5 3 2
7 Railway [1, 2, 3]
a b c 
10 20 
x 

len() does not care whether it receives a string, list or dictionary; it asks the object for its length. + adds numbers but joins strings and lists. A for loop walks through anything iterable. Each type supplies its own version of the operation, and your code stays the same. That is polymorphism. You can give your own classes the same powers with special methods, as shown in Special (Dunder) Methods.

Polymorphism through overriding

The classic object-oriented form uses inheritance. A parent class defines a method, and each child class overrides it with its own version (you learned the mechanics in Inheritance). Code that loops over a mixed list can call the method without knowing which child it has.

PythonRun it yourself
class Notifier:
    def __init__(self, to):
        self.to = to

    def send(self, message):
        raise NotImplementedError("Each notifier must define send()")

class SmsNotifier(Notifier):
    def send(self, message):
        return f"SMS to {self.to}: {message[:20]}"

class EmailNotifier(Notifier):
    def send(self, message):
        return f"Email to {self.to}\nSubject: Update\n\n{message}"

class PushNotifier(Notifier):
    def send(self, message):
        return f"[App alert] {message.upper()}"

channels = [SmsNotifier("98XXXXXX01"), EmailNotifier("priya@example.com"), PushNotifier("device-7")]
for channel in channels:
    print(channel.send("Your parcel reaches Lucknow today"))
    print("---")
Output
SMS to 98XXXXXX01: Your parcel reaches 
---
Email to priya@example.com
Subject: Update

Your parcel reaches Lucknow today
---
[App alert] YOUR PARCEL REACHES LUCKNOW TODAY
---

The loop is identical for every channel. Next month, someone can add a VoiceCallNotifier with its own send(), and the loop does not change at all. The parent's send() raises NotImplementedError to signal "children must replace me"; in Properties & Abstract Classes you will see the stricter @abstractmethod way.

Duck typing

Python does not actually need a shared parent. When you call channel.send(...), Python only checks, at that moment, whether the object has a send method. This is duck typing, from the saying "if it walks like a duck and quacks like a duck, it is a duck". What matters is what an object can do, not what class it belongs to.

The standard library relies on this everywhere. Many functions accept any "file-like object": anything with a write() method. Here the same function writes a receipt to a real in-memory text file, to a list-based class we made ourselves, and to the screen:

PythonRun it yourself
import io
import sys

def print_receipt(customer, items, out):
    out.write(f"Receipt for {customer}\n")
    for name, price in items:
        out.write(f"  {name:<10} ₹{price}\n")

class LineCollector:                     # no parent class at all
    def __init__(self):
        self.lines = []
    def write(self, text):
        self.lines.append(text.rstrip("\n"))

items = [("Notebook", 60), ("Pen", 20)]

buffer = io.StringIO()
print_receipt("Ananya", items, buffer)
collector = LineCollector()
print_receipt("Ananya", items, collector)

print(len(buffer.getvalue()), "characters in the buffer")
print(collector.lines)
print_receipt("Ananya", items, sys.stdout)
Output
53 characters in the buffer
['Receipt for Ananya', '  Notebook   ₹60', '  Pen        ₹20']
Receipt for Ananya
  Notebook   ₹60
  Pen        ₹20

io.StringIO, our LineCollector and sys.stdout share no class at all, yet all three work, because each has write().

Ask forgiveness, not permission

With duck typing you usually just call the method and handle the failure if it happens, rather than checking types first. Python programmers call this EAFP (easier to ask forgiveness than permission). The opposite style, checking with isinstance() or hasattr() first, is called LBYL (look before you leap).

Python
def total_length(things):
    total = 0
    for thing in things:
        try:
            total += len(thing)          # EAFP: just try it
        except TypeError:
            print(f"Skipping {thing!r}: it has no length")
    return total

print(total_length(["Pune", [1, 2, 3], 42, {"a": 1}]))
Output
Skipping 42: it has no length
8

EAFP keeps code short and accepts new types automatically. Use isinstance() checks when you truly need a specific type, for example before doing maths on user data.

Encapsulation with underscores

Encapsulation protects an object's invariants: rules that must always be true, such as "a wallet balance is never negative". The object offers methods that keep the rules, and outside code should use those methods instead of changing the data directly.

Many languages enforce this with keywords like private. Python uses naming conventions instead:

NameMeaning
balancePublic: part of the class's normal use
_balanceInternal: "please do not use this from outside". Not enforced.
__pinName-mangled: Python renames it to avoid clashes in subclasses
__init__Special method: names with two underscores on both sides are never mangled
PythonRun it yourself
class Wallet:
    def __init__(self, owner, pin):
        self.owner = owner
        self._balance = 0          # internal: change it through methods
        self.__pin = pin           # mangled to _Wallet__pin

    def add_money(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self._balance += amount

    def pay(self, amount, pin):
        if pin != self.__pin:
            return "Wrong PIN"
        if amount > self._balance:
            return "Insufficient balance"
        self._balance -= amount
        return f"Paid ₹{amount}, balance ₹{self._balance}"

w = Wallet("Rohan", "4321")
w.add_money(500)
print(w.pay(120, "4321"))
print(w.pay(120, "0000"))

try:
    print(w.__pin)
except AttributeError as error:
    print("AttributeError:", error)
print(list(vars(w)))
Output
Paid ₹120, balance ₹380
Wrong PIN
AttributeError: 'Wallet' object has no attribute '__pin'
['owner', '_balance', '_Wallet__pin']

Inside the class, self.__pin works normally. Outside, w.__pin fails because Python stored the attribute as _Wallet__pin. This renaming is called name mangling.

Python's attitude is often summed up as "we are all consenting adults here": the underscore tells other programmers what is internal, and they are trusted to respect it. In practice, use a single underscore for internal details, and use the double underscore only when you design a class for others to subclass.

Common mistakes

  • Checking type(x) == SomeClass before calling a method. It blocks subclasses and duck-typed objects. Call the method, or use isinstance() when you must.
  • Changing _balance from outside the class. It skips the checks in the methods and can break the object's rules.
  • Thinking __name makes data private. It only renames the attribute; it can still be read.
  • Forgetting to override. If a child class misspells send as sned, the parent's version runs. Abstract classes catch this early.

Key points

  • Polymorphism lets one piece of code work with many types that offer the same methods.
  • Overriding gives each subclass its own version of a parent method.
  • Duck typing means Python checks for methods when they are used, not for a class name.
  • EAFP (try, then handle the error) is the usual Python style; LBYL checks first.
  • _name marks internal attributes by convention; __name is mangled to _ClassName__name.
  • Encapsulation in Python is about clear conventions and methods that protect rules, not hard locks.

Exercise

Create three unrelated classes for a cricket scoring app: Batter, Bowler and Fielder. Each has a name and a points() method: batters get 1 point per run, bowlers 25 points per wicket, and fielders 8 points per catch. Store the raw numbers in single-underscore attributes. Write a function team_points(players) that adds up everyone's points using duck typing, then test it with one player of each kind.

Show solution
Python
class Batter:
    def __init__(self, name, runs):
        self.name = name
        self._runs = runs
    def points(self):
        return self._runs

class Bowler:
    def __init__(self, name, wickets):
        self.name = name
        self._wickets = wickets
    def points(self):
        return self._wickets * 25

class Fielder:
    def __init__(self, name, catches):
        self.name = name
        self._catches = catches
    def points(self):
        return self._catches * 8

def team_points(players):
    for p in players:
        print(f"{p.name}: {p.points()}")
    return sum(p.points() for p in players)

squad = [Batter("Aarav", 64), Bowler("Farhan", 3), Fielder("Isha", 2)]
print("Total:", team_points(squad))
Output
Aarav: 64
Farhan: 75
Isha: 16
Total: 155

The three classes share no parent, but team_points() works with all of them because each has name and points(). That is duck typing.

Quick Quiz

1. What is duck typing?

2. What does Python store self.__code as inside a class named Shop?

3. What does a single leading underscore, as in _total, mean?

4. Which style does idiomatic Python usually prefer?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call