Polymorphism & Encapsulation
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:
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()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.
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("---")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:
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)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).
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}]))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:
| Name | Meaning |
|---|---|
balance | Public: part of the class's normal use |
_balance | Internal: "please do not use this from outside". Not enforced. |
__pin | Name-mangled: Python renames it to avoid clashes in subclasses |
__init__ | Special method: names with two underscores on both sides are never mangled |
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)))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) == SomeClassbefore calling a method. It blocks subclasses and duck-typed objects. Call the method, or useisinstance()when you must. - Changing
_balancefrom outside the class. It skips the checks in the methods and can break the object's rules. - Thinking
__namemakes data private. It only renames the attribute; it can still be read. - Forgetting to override. If a child class misspells
sendassned, 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.
_namemarks internal attributes by convention;__nameis 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
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))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?
If an object has the method you call, Python is happy, whatever its class.
2. What does Python store self.__code as inside a class named Shop?
Name mangling adds an underscore and the class name in front.
3. What does a single leading underscore, as in _total, mean?
Python does not enforce it; programmers respect it by agreement.
4. Which style does idiomatic Python usually prefer?
EAFP keeps code short and works with any object that supports the operation.
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
