Advanced OOP Concepts: Encapsulation, Abstraction, Polymorphism
Who this is for
- Grade 8–9 students who already know classes, objects, and inheritance.
- You'll learn how to design cleaner, safer, and more flexible code using three big OOP ideas.
Big picture (in one sentence each)
- Encapsulation: Keep data safe inside a class and control how it's changed.
- Abstraction: Hide messy details and expose only what users of your class need.
- Polymorphism: Call the same method on different objects and get different correct behavior.
1) Encapsulation
Idea
- Think of a class like a gaming console. The inside (wires) is hidden; you use buttons (methods) to interact safely.
- In Python, we use:
- A leading underscore (_) to say "this is internal; don't touch it directly."
- Double underscore (__name) for name-mangling (harder to access).
- Properties (@property) to control reading/writing with validation.
Example: Safe BankAccount
We protect balance so it can't go negative and can only be changed through methods.
Code:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = 0
self.balance = balance # go through setter to validate
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("Balance cannot be negative.")
self.__balance = value
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive.")
self.__balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdraw must be positive.")
if amount > self.__balance:
raise ValueError("Not enough funds.")
self.__balance -= amount
# Try it
acct = BankAccount("Ava", 100)
acct.deposit(50)
print(acct.balance) # 150
# acct.__balance # AttributeError: it's encapsulatedWhy this matters
Encapsulation protects your class from invalid states and weird bugs caused by outside code.
2) Abstraction
Idea
- Abstraction separates "what something does" from "how it does it."
- In Python, we use abstract base classes (ABCs) with @abstractmethod to define a required interface while leaving details to subclasses.
Example: Abstract Character (we'll reuse this in the project)
We define what every Character must be able to do, but not how.
Code:
from abc import ABC, abstractmethod
class Character(ABC):
def __init__(self, name):
self.name = name
@abstractmethod
def attack(self, target):
pass # subclasses must override this
# You cannot create Character() directly because it's abstract.Why this matters
Abstraction sets rules. Subclasses must follow them, which keeps your program consistent.
3) Polymorphism
Idea
- "Many forms." Different classes can share the same method name and be used interchangeably.
- You write code that uses the method name without caring about the exact class.
Quick example:
class Dog:
def speak(self):
return "Woof!"
class Robot:
def speak(self):
return "Beep."
def make_it_talk(thing):
print(thing.speak())
make_it_talk(Dog()) # Woof!
make_it_talk(Robot()) # Beep.Why this matters
Polymorphism makes your code flexible. You can add new classes without changing the code that uses them.
How they fit together
- Encapsulation: Keep each object's data safe (e.g., health points).
- Abstraction: Define the rules for your system (e.g., every character must attack).
- Polymorphism: Use objects through their common interface (e.g., call attack() on any character).
Hands-on Project: Mini Battle Game
Goal
Build a tiny turn-based battle that uses all three concepts.
What you'll learn
- Encapsulation: Health is private and validated.
- Abstraction: Abstract Character with required attack() method.
- Polymorphism: Different character types attack differently, but the battle engine treats them the same.
Full code (run this as a single script)
import random
from abc import ABC, abstractmethod
# Abstraction + Encapsulation
class Character(ABC):
def __init__(self, name, max_hp):
self.name = name
self.__max_hp = max_hp
self.__hp = max_hp # encapsulated: do not set directly
# Encapsulated health with safe access
@property
def hp(self):
return self.__hp
@property
def max_hp(self):
return self.__max_hp
def is_alive(self):
return self.__hp > 0
def take_damage(self, amount):
# Validate and clamp damage
amount = max(0, int(amount))
self.__hp = max(0, self.__hp - amount)
print(f"{self.name} takes {amount} damage. HP: {self.__hp}/{self.__max_hp}")
def heal(self, amount):
amount = max(0, int(amount))
self.__hp = min(self.__max_hp, self.__hp + amount)
print(f"{self.name} heals {amount}. HP: {self.__hp}/{self.__max_hp}")
@abstractmethod
def attack(self, target):
# Abstraction: all characters must implement attack
pass
# Polymorphism: different behaviors, same method name
class Warrior(Character):
def attack(self, target):
dmg = random.randint(8, 12)
print(f"{self.name} slashes {target.name}!")
target.take_damage(dmg)
class Wizard(Character):
def attack(self, target):
# Chance for a big hit (crit)
if random.random() < 0.30:
dmg = random.randint(14, 20)
print(f"{self.name} casts a CRITICAL fireball at {target.name}!")
else:
dmg = random.randint(4, 8)
print(f"{self.name} casts a magic bolt at {target.name}.")
target.take_damage(dmg)
class Archer(Character):
def attack(self, target):
# Two light shots
print(f"{self.name} fires two arrows at {target.name}.")
for _ in range(2):
dmg = random.randint(3, 6)
target.take_damage(dmg)
# Bonus: a Healer that uses the same interface but supports allies
class Healer(Character):
def attack(self, target):
# Healer "attacks" by healing itself if below half, else poke attack
if self.hp < self.max_hp // 2:
print(f"{self.name} uses healing pulse!")
self.heal(random.randint(6, 10))
else:
print(f"{self.name} pokes {target.name} with a staff.")
target.take_damage(random.randint(2, 4))
def battle_round(team_a, team_b):
# Each alive character from A attacks a random alive character from B (and vice versa)
print("\n--- New Round ---")
alive_a = [c for c in team_a if c.is_alive()]
alive_b = [c for c in team_b if c.is_alive()]
if not alive_a or not alive_b:
return
# Team A attacks
for attacker in alive_a:
if not alive_b: # check after each hit
break
target = random.choice(alive_b)
attacker.attack(target) # polymorphism here
alive_b = [c for c in alive_b if c.is_alive()]
# Team B attacks
alive_a = [c for c in team_a if c.is_alive()]
if not alive_a or not alive_b:
return
for attacker in alive_b:
if not alive_a:
break
target = random.choice(alive_a)
attacker.attack(target)
alive_a = [c for c in alive_a if c.is_alive()]
def winner(team_a, team_b):
a_alive = any(c.is_alive() for c in team_a)
b_alive = any(c.is_alive() for c in team_b)
if a_alive and not b_alive:
return "Team A wins!"
elif b_alive and not a_alive:
return "Team B wins!"
elif not a_alive and not b_alive:
return "It's a draw!"
else:
return None
def show_status(team, name):
print(f"{name} status:")
for c in team:
print(f" - {c.name}: {c.hp}/{c.max_hp} HP")
def main():
# Create teams
team_a = [Warrior("Aria", 40), Wizard("Bram", 30)]
team_b = [Archer("Cyra", 32), Healer("Dax", 28)]
round_count = 0
print("Battle start!")
show_status(team_a, "Team A")
show_status(team_b, "Team B")
while not winner(team_a, team_b):
round_count += 1
battle_round(team_a, team_b)
show_status(team_a, "Team A")
show_status(team_b, "Team B")
print("\n" + winner(team_a, team_b))
print(f"Rounds: {round_count}")
if __name__ == "__main__":
main()Where the concepts show up
- Encapsulation: hp and max_hp are private; only methods safely modify them.
- Abstraction: Character is an abstract base class with an abstract attack() method.
- Polymorphism: The battle engine doesn't care if it's a Warrior, Wizard, Archer, or Healer; it just calls attack().
Try this (activity)
- Add a new class Rogue with a chance to dodge incoming attacks.
- Hint: override take_damage in Rogue to sometimes ignore damage.
- Add a special ability cooldown to Warrior using encapsulation (private counter and a property).
- Make the battle fairer: if a team loses a member, the next round they get a small team heal.
- Replace Healer's attack() with logic that heals the weakest teammate instead of self.
- You'll need to change battle_round to pass allies/enemies or redesign attack to accept (allies, enemies).
Short, separate examples to reinforce learning
Encapsulation with a property:
class Temperature:
def __init__(self, celsius=0):
self.celsius = celsius
@property
def celsius(self):
return self.__c
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero!")
self.__c = float(value)Abstraction with an interface-like ABC:
from abc import ABC, abstractmethod
class Payment(ABC):
@abstractmethod
def pay(self, amount):
pass
class CardPayment(Payment):
def pay(self, amount):
print(f"Charging card: ${amount}")
class CashPayment(Payment):
def pay(self, amount):
print(f"Taking cash: ${amount}")
def checkout(payment_method, amount):
payment_method.pay(amount) # polymorphism againPolymorphism with duck typing (no inheritance needed):
class Pencil:
def draw(self):
return "Drawing with graphite"
class Brush:
def draw(self):
return "Painting with bristles"
def art_tool_demo(tool):
print(tool.draw())
art_tool_demo(Pencil())
art_tool_demo(Brush())Summary
- Encapsulation keeps data safe and consistent. Use private attributes and properties to validate changes.
- Abstraction defines what must exist without showing how. Use abstract base classes and @abstractmethod.
- Polymorphism lets you write flexible code that works with different types through the same interface.
- Together, these make your programs easier to expand, safer to use, and cleaner to read.
Next steps
- Convert parts of your existing projects to use properties for validation.
- Introduce an abstract base class in a project that has multiple "kinds" of the same thing.
- Practice by adding new classes that fit the same interface and see if the rest of your code works without changes.