0%

38- Advanced OOP Concepts

Encapsulation, inheritance, polymorphism, abstraction, and composition. Take your object-oriented skills to the next level with these powerful patterns.

You know how to write classes. You understand instance methods, class methods, and properties. Now it is time to explore the deeper concepts that make OOP truly powerful. Encapsulation hides internal details. Inheritance allows code reuse. Polymorphism enables flexible interfaces. Abstraction simplifies complexity. Composition builds complex objects from simpler ones. These are not just buzzwords. They are proven design patterns that have shaped software development for decades. Master them, and you will write code that is more maintainable, more reusable, and easier to understand. This lesson explores each of these advanced concepts with practical examples. By the end, you will see OOP not just as a syntax, but as a way of thinking about code structure.

🕯️ Magic Note

These four pillars—Encapsulation, Inheritance, Polymorphism, and Abstraction—are often called the “four pillars of OOP.” They were first articulated in the 1970s with the Simula language and later popularized by C++ and Java. Python implements all of them, but with its own unique flavor.

Encapsulation: Protecting Data
Encapsulation means bundling data and methods together while hiding internal details from the outside world. In Python, encapsulation is more convention than enforcement, but it is still powerful.

Python

class BankAccount:

def __init__(self, owner, initial_balance):

self.owner = owner

self._balance = initial_balance # Protected (convention)

self.__pin = 1234 # Private (name mangling)

def deposit(self, amount):

if amount > 0:

self._balance += amount

return True

return False

def withdraw(self, amount, pin):

if pin != self.__pin:

raise ValueError(“Invalid PIN”)

if 0 < amount <= self._balance:

self._balance -= amount

return True

return False

def get_balance(self):

return self._balance # Controlled access

account = BankAccount(“Ali”, 1000)

print(account.get_balance()) # 1000 (controlled)

# print(account._balance) # Works but violates convention (don’t do it)

# print(account.__pin) # AttributeError (name mangling hides it)

print(account._BankAccount__pin) # 1234 (accessible but don’t do it)

🕯️ Magic Note

Name mangling (double underscore) changes the attribute name to _ClassName__attribute. This is not true privacy—it is just a way to avoid accidental name conflicts in subclasses. The Python philosophy is “we are all consenting adults.” Encapsulation is a convention, not a wall.

Encapsulation Levels in Python
ConventionHow to WriteMeaning
Publicself.nameAccessible from anywhere (default)
Protectedself._nameInternal use. Can be accessed but don’t.
Privateself.__nameName mangling. Stronger protection.

Python

class Example:

def __init__(self):

self.public = “Anyone can see me”

self._protected = “Please don’t touch me directly”

self.__private = “You can’t easily access me”

def get_private(self):

return self.__private # Provide controlled access

ex = Example()

print(ex.public) # Works

print(ex._protected) # Works but is discouraged

print(ex.get_private()) # Works (proper way)

# print(ex.__private) # AttributeError

Inheritance: Reusing Code
Inheritance allows a class to inherit attributes and methods from another class. The child class can add new functionality or override existing methods.

Python

class Animal:

def __init__(self, name):

self.name = name

def speak(self):

raise NotImplementedError(“Subclass must implement this method”)

def move(self):

print(f”{self.name} is moving”)

# Dog inherits from Animal

class Dog(Animal):

def speak(self):

return “Woof!”

def fetch(self):

print(f”{self.name} is fetching the ball”)

# Cat inherits from Animal

class Cat(Animal):

def speak(self):

return “Meow!”

def climb(self):

print(f”{self.name} is climbing a tree”)

buddy = Dog(“Buddy”)

whiskers = Cat(“Whiskers”)

print(buddy.speak()) # Woof!

print(whiskers.speak()) # Meow!

buddy.move() # Buddy is moving (inherited)

whiskers.move() # Whiskers is moving (inherited)

buddy.fetch() # Buddy is fetching the ball (Dog-specific)

whiskers.climb() # Whiskers is climbing a tree (Cat-specific)

💡 Use inheritance when you have an “is-a” relationship. A Dog is an Animal. A Car is a Vehicle. A SavingsAccount is a BankAccount. If the relationship does not feel like “is-a”, consider composition instead.
The super() Function
super() allows you to call methods from the parent class. It is essential for extending parent functionality without breaking it.

Python

class Employee:

def __init__(self, name, salary):

self.name = name

self.salary = salary

def get_info(self):

return f”Name: {self.name}, Salary: {self.salary}”

def work(self):

return f”{self.name} is working”

class Manager(Employee):

def __init__(self, name, salary, team_size):

super().__init__(name, salary) # Call parent constructor

self.team_size = team_size

def get_info(self):

# Extend parent method

parent_info = super().get_info()

return f”{parent_info}, Team Size: {self.team_size}”

def work(self):

# Override with new behavior

return f”{self.name} is managing a team of {self.team_size} people”

emp = Employee(“Ali”, 50000)

mgr = Manager(“Sara”, 80000, 5)

print(emp.get_info()) # Name: Ali, Salary: 50000

print(mgr.get_info()) # Name: Sara, Salary: 80000, Team Size: 5

print(emp.work()) # Ali is working

print(mgr.work()) # Sara is managing a team of 5 people

🕯️ Magic Note

The super() function is even more powerful in multiple inheritance. It follows the Method Resolution Order (MRO) to call the next class in the inheritance chain, not necessarily the immediate parent.

Multiple Inheritance
Python supports multiple inheritance—a class can inherit from multiple parent classes. This is powerful but can be complex.

Python

class Flyer:

def fly(self):

return “Flying through the air!”

def move(self):

return “Flying”

class Swimmer:

def swim(self):

return “Swimming through water!”

def move(self):

return “Swimming”

# Duck inherits from both Flyer and Swimmer

class Duck(Flyer, Swimmer):

def quack(self):

return “Quack!”

donald = Duck()

print(donald.fly()) # Flying through the air!

print(donald.swim()) # Swimming through water!

print(donald.quack()) # Quack!

print(donald.move()) # Flying (from Flyer – first parent)

# Check Method Resolution Order

print(Duck.__mro__) # (<class ‘Duck’>, <class ‘Flyer’>, <class ‘Swimmer’>, <class ‘object’>)

⚠️ Multiple inheritance can lead to the “diamond problem” where the same method appears in multiple parent classes. Python resolves this using C3 linearization (Method Resolution Order). While powerful, multiple inheritance can make code hard to understand. Use it sparingly and document clearly.
Polymorphism: One Interface, Many Forms
Polymorphism allows objects of different classes to be treated through the same interface. The same method name can behave differently on different objects.

Python

class Circle:

def __init__(self, radius):

self.radius = radius

def area(self):

return 3.14159 * self.radius ** 2

def perimeter(self):

return 2 * 3.14159 * self.radius

class Square:

def __init__(self, side):

self.side = side

def area(self):

return self.side ** 2

def perimeter(self):

return 4 * self.side

class Rectangle:

def __init__(self, width, height):

self.width = width

self.height = height

def area(self):

return self.width * self.height

def perimeter(self):

return 2 * (self.width + self.height)

# Polymorphic function

def print_shape_info(shape):

print(f”Area: {shape.area():.2f}”)

print(f”Perimeter: {shape.perimeter():.2f}”)

# Same function works with different shape types

shapes = [Circle(5), Square(4), Rectangle(3, 6)]

for shape in shapes:

print_shape_info(shape)

print(“—“)

🕯️ Magic Note

Python’s polymorphism is “duck typing”: If it walks like a duck and quacks like a duck, it is a duck. You do not need inheritance. If an object has the required methods, it works. This is more flexible than traditional polymorphism.

Duck Typing in Action
Python does not require explicit inheritance for polymorphism. If an object implements the expected method, it can be used.

Python

class Camera:

def click(self):

return “📷 Click!”

class Mouse:

def click(self):

return “🖱️ Click!”

class Button:

def click(self):

return “🔘 Button pressed!”

# No inheritance needed! Just the same method name

def perform_click(thing):

print(thing.click())

perform_click(Camera()) # 📷 Click!

perform_click(Mouse()) # 🖱️ Click!

perform_click(Button()) # 🔘 Button pressed!

Abstraction: Hiding Complexity
Abstraction means hiding complex implementation details behind a simple interface. In Python, we use abstract base classes (ABC) to define interfaces.

Python

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):

@abstractmethod

def process_payment(self, amount):

pass

@abstractmethod

def refund(self, amount):

pass

class CreditCardProcessor(PaymentProcessor):

def process_payment(self, amount):

# Complex credit card logic here

return f”Processing ${amount} via Credit Card”

def refund(self, amount):

return f”Refunding ${amount} to Credit Card”

class PayPalProcessor(PaymentProcessor):

def process_payment(self, amount):

# Complex PayPal logic here

return f”Processing ${amount} via PayPal”

def refund(self, amount):

return f”Refunding ${amount} to PayPal”

# Client code only depends on the abstract interface

def checkout(processor, amount):

print(processor.process_payment(amount))

checkout(CreditCardProcessor(), 100)

checkout(PayPalProcessor(), 50)

🕯️ Magic Note

Abstract base classes cannot be instantiated. They define a contract that subclasses must fulfill. This is useful for large systems where different teams implement different parts of an interface.

Composition: Building Complex Objects
Composition means building complex objects by combining simpler ones. Often preferred over inheritance because it is more flexible.

Python

class Engine:

def start(self):

return “Engine started”

def stop(self):

return “Engine stopped”

class Wheels:

def rotate(self):

return “Wheels rotating”

class Seats:

def adjust(self, position):

return f”Seat adjusted to {position}”

# Car is composed of parts (has-a relationship)

class Car:

def __init__(self):

self.engine = Engine()

self.wheels = Wheels()

self.seats = Seats()

def drive(self):

return f”{self.engine.start()}, {self.wheels.rotate()}”

def stop(self):

return self.engine.stop()

my_car = Car()

print(my_car.drive()) # Engine started, Wheels rotating

print(my_car.seats.adjust(“driver position”)) # Seat adjusted to driver position

💡 Use inheritance for “is-a” relationships (Dog is an Animal). Use composition for “has-a” relationships (Car has an Engine). Composition is often more flexible because you can change parts at runtime.
Inheritance vs Composition Comparison
InheritanceComposition
“Is-a” relationship“Has-a” relationship
Defined at compile time (static)Can be changed at runtime (dynamic)
Tighter couplingLoose coupling
Can lead to fragile base class problemMore resilient to changes
Code reuse through extensionCode reuse through delegation
Parent class changes affect all childrenChanging part is isolated

Python

# Composition allows runtime changes (more flexible)

class Logger:

def log(self, message):

print(f”LOG: {message}”)

class FileLogger:

def log(self, message):

with open(“app.log”, “a”) as f:

f.write(f”{message}\n”)

class Application:

def __init__(self, logger):

self.logger = logger # Inject dependency (composition)

def run(self):

self.logger.log(“Application started”)

# Can switch logging behavior easily

app1 = Application(Logger())

app2 = Application(FileLogger())

app1.run() # Prints to console

app2.run() # Writes to file

Method Resolution Order (MRO)
When a method is called on a class with multiple inheritance, Python follows a specific order to find which method to execute.

Python

class A:

def who(self):

return “A”

class B(A):

def who(self):

return “B”

class C(A):

def who(self):

return “C”

class D(B, C):

pass

d = D()

print(d.who()) # B (MRO: D → B → C → A)

# View the MRO

print(D.__mro__)

# (<class ‘D’>, <class ‘B’>, <class ‘C’>, <class ‘A’>, <class ‘object’>)

🕯️ Magic Note

Python uses the C3 linearization algorithm to compute the MRO. It ensures that subclasses come before parents, and the order of parent classes is respected. You can view the MRO using Class.__mro__ or Class.mro().

Common Advanced OOP Mistakes
  • Overusing inheritance when composition would be better
  • Creating inheritance hierarchies that are too deep (more than 3-4 levels)
  • Forgetting to call super().__init__() in child classes
  • Using multiple inheritance without understanding MRO
  • Breaking encapsulation by accessing private attributes directly
  • Creating huge abstract classes that try to do everything
Check Your Understanding
  • What is the difference between _name and __name?
  • Write a class SavingsAccount that inherits from BankAccount and adds interest.
  • What is duck typing? Give an example.
  • When should you use composition instead of inheritance?
  • What is MRO and how can you view it?
  • Write an abstract class Shape with an abstract method area().

⚡ Whisper

Encapsulation is the wall around your data. Inheritance is the bridge between classes. Polymorphism is the many faces of a single name. Abstraction is the mask hiding complexity. Composition is the art of building with parts. These are not just techniques. They are ways of thinking. When you design a system, ask: What should be hidden? What can be reused? Where do I need flexibility? What is the simplest interface? How do parts combine? The answers shape your code. A well-encapsulated class is a fortress. A well-designed inheritance tree is a family. A polymorphic interface is a promise kept in many ways. An abstraction is a gift of simplicity. Composition is a workshop of cooperation. Master these concepts, and you master the mind of an object-oriented programmer. The syntax is small. The ideas are large. Learn them deeply.

Related posts