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.

pythonoopencapsulationinheritancemethod-overridingintermediate
Object-Oriented Programming in PythonPart 2 of 3

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:

Python
account.balance = -1000000  # No validation, no limits

That'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:

Python
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.__balance
Python
account = 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:

Python
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.

TermMeaning
Parent / Base / SuperclassThe class being inherited from
Child / Derived / SubclassThe class that inherits

Basic Inheritance

Python
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:

Python
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.

Python
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.

Python
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:

Python
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})")
Python
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:

Python
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}"
Python
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: Python

Each 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.

Object-Oriented Programming in PythonPart 2 of 3