March 13, 2026 · All About CS
Object-Oriented Programming in Python — Part 2: Encapsulation and Inheritance
Deep dive into two foundational OOP pillars — encapsulation (private attributes, getters, setters) and inheritance (parent/child classes, method overriding) — with hands-on Python examples.
Object-Oriented Programming in Python — Part 2: Encapsulation and Inheritance
In Part 1, we learned to create classes and objects — the building blocks of OOP. Now we dive into two of the four pillars that make OOP truly powerful: Encapsulation (protecting data) and Inheritance (reusing code).
These aren't abstract concepts — they're practical tools you'll use every time you design a system with multiple related entities.
Encapsulation — Protecting Data
Encapsulation is the practice of hiding an object's internal state from the outside world and controlling access through methods. Instead of letting anyone modify an attribute directly, you force them to go through a controlled interface.
Why Encapsulate?
Imagine a BankAccount class where anyone can set the balance to any value:
account.balance = -1000000 # No validation, no limitsThat's dangerous. Encapsulation lets you restrict access so changes must go through methods that enforce rules.
Private Attributes in Python
Python uses name mangling with double underscores (__) to make attributes private:
class Account:
def __init__(self, owner, balance):
self.owner = owner # Public attribute
self.__balance = balance # Private attribute
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def withdraw(self, amount):
if amount <= self.__balance:
self.__balance -= amount
else:
print("Insufficient funds!")
def get_balance(self):
return self.__balanceaccount = Account("John", 1000)
# Access through methods — works fine
account.deposit(500)
account.withdraw(200)
print(f"Balance: {account.get_balance()}") # → Balance: 1300
# Direct access — raises an error
print(account.__balance)
# AttributeError: 'Account' object has no attribute '__balance'The __balance attribute is not directly accessible through the object reference. The only way to interact with it is through the deposit(), withdraw(), and get_balance() methods — which enforce business rules.
Single vs. double underscore: A single underscore prefix (_balance) is a convention that signals "this is internal, don't touch it" — but Python doesn't enforce it. Double underscore (__balance) triggers name mangling, making it genuinely harder to access from outside the class. Use double underscore when you need real protection.
The Pattern: Getters and Setters
Methods that read private attributes are called getters. Methods that modify them are called setters:
class Temperature:
def __init__(self, celsius):
self.__celsius = celsius
# Getter
def get_celsius(self):
return self.__celsius
# Setter with validation
def set_celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero is impossible.")
self.__celsius = value
def get_fahrenheit(self):
return (self.__celsius * 9 / 5) + 32
temp = Temperature(25)
print(temp.get_celsius()) # → 25
print(temp.get_fahrenheit()) # → 77.0
temp.set_celsius(100)
print(temp.get_celsius()) # → 100
temp.set_celsius(-300)
# ValueError: Temperature below absolute zero is impossible.The setter prevents invalid data from ever reaching the internal state.
Inheritance — Reusing Code
Inheritance lets you create a new class that automatically receives all the attributes and methods of an existing class. The new class can then add its own features or override existing behavior.
| Term | Meaning |
|---|---|
| Parent / Base / Superclass | The class being inherited from |
| Child / Derived / Subclass | The class that inherits |
Basic Inheritance
class Animal:
def __init__(self, name):
self.name = name
def eat(self):
print(f"{self.name} is eating.")
def speak(self):
print(f"{self.name} makes a sound.")Now create child classes that inherit from Animal:
class Dog(Animal):
def speak(self):
print(f"{self.name} barks!")
class Cat(Animal):
def speak(self):
print(f"{self.name} meows!")The syntax class Dog(Animal): means "Dog inherits from Animal." The Dog class automatically gets the __init__ method, the name attribute, and the eat method — without writing a single line for them.
animal = Animal("Generic")
dog = Dog("Buddy")
cat = Cat("Whiskers")
animal.eat() # → Generic is eating.
dog.eat() # → Buddy is eating. (inherited)
cat.eat() # → Whiskers is eating. (inherited)
animal.speak() # → Generic makes a sound.
dog.speak() # → Buddy barks! (overridden)
cat.speak() # → Whiskers meows! (overridden)Both Dog and Cat inherit eat() from Animal and use it unchanged. But they override speak() with their own versions.
Method Overriding
When a child class defines a method with the same name as one in the parent class, the child's version takes precedence. This is called method overriding.
class Vehicle:
def start(self):
print("Vehicle is starting...")
class ElectricCar(Vehicle):
def start(self):
print("Electric car is starting silently...")
car = ElectricCar()
car.start() # → Electric car is starting silently...The child class's start() completely replaces the parent's version for objects of type ElectricCar.
Calling the Parent Method with super()
Sometimes you want to extend the parent's behavior rather than replace it. Use super() to call the parent's method from within the child:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} makes a sound.")
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # Call parent's __init__
self.breed = breed # Add new attribute
def speak(self):
super().speak() # Call parent's speak first
print(f"{self.name} barks! (Breed: {self.breed})")dog = Dog("Buddy", "Golden Retriever")
dog.speak()
# → Buddy makes a sound.
# → Buddy barks! (Breed: Golden Retriever)super().__init__(name) ensures the parent class's initialization runs before the child adds its own setup. This is essential when parent classes have logic you want to preserve.
Inheritance in Practice: A Real Example
Let's model an employee hierarchy:
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def get_info(self):
return f"{self.name} — Salary: ₹{self.salary:,}"
class Manager(Employee):
def __init__(self, name, salary, department):
super().__init__(name, salary)
self.department = department
def get_info(self):
base = super().get_info()
return f"{base} — Manages: {self.department}"
class Developer(Employee):
def __init__(self, name, salary, language):
super().__init__(name, salary)
self.language = language
def get_info(self):
base = super().get_info()
return f"{base} — Codes in: {self.language}"emp = Employee("Alice", 50000)
mgr = Manager("Bob", 80000, "Engineering")
dev = Developer("Charlie", 70000, "Python")
for person in [emp, mgr, dev]:
print(person.get_info())Output:
Alice — Salary: ₹50,000
Bob — Salary: ₹80,000 — Manages: Engineering
Charlie — Salary: ₹70,000 — Codes in: PythonEach subclass extends the base Employee with additional data and customized output, while reusing the core logic.
What We Covered
- Encapsulation hides internal state using private attributes (
__attribute) and controls access through getter/setter methods. - Inheritance lets child classes reuse code from parent classes, reducing duplication.
- Method overriding allows child classes to customize inherited behavior.
super()calls the parent class's methods from within the child, enabling extension without replacement.
In Part 3, we'll complete the four pillars with Polymorphism (one interface, many behaviors) and Abstraction (hiding complexity behind simple interfaces), plus the Pythonic concept of Duck Typing.