Object-Oriented Programming (OOP) in Python
Python supports Object-Oriented Programming (OOP), which allows you to model real-world entities using classes and objects. OOP improves code organization, reusability, and maintainability.
1. Classes and Objects
A class is a blueprint for creating objects, and an object is an instance of a class.
# Defining a class
class Person:
def __init__(self, name: str, age: int):
self.name = name # Instance variable
self.age = age
def greet(self):
print(f"Hello, my name is {self.name} and I am {self.age} years old.")
# Creating objects
person1 = Person("Talha", 25)
person2 = Person("Ali", 30)
person1.greet() # Hello, my name is Talha and I am 25 years old.
person2.greet() # Hello, my name is Ali and I am 30 years old.
2. Instance Variables vs Class Variables
Instance variables belong to each object, while class (static) variables are shared among all objects.
class Car:
wheels = 4 # Class variable
def __init__(self, brand: str, color: str):
self.brand = brand # Instance variable
self.color = color
car1 = Car("Toyota", "Red")
car2 = Car("Honda", "Blue")
# Accessing instance and class variables
print(car1.brand, car1.wheels) # Toyota 4
print(car2.brand, car2.wheels) # Honda 4
# Modifying class variable via class
Car.wheels = 6
print(car1.wheels) # 6
print(car2.wheels) # 6
# Overriding class variable for an instance
car1.wheels = 8
print(car1.wheels) # 8
print(car2.wheels) # 6
3. Encapsulation
Encapsulation hides the internal state of objects. Prefix _ or __ for private variables.
class BankAccount:
def __init__(self, balance: float):
self.__balance = balance # Private variable
def deposit(self, amount: float):
if amount > 0:
self.__balance += amount
def withdraw(self, amount: float):
if 0 < amount <= self.__balance:
self.__balance -= amount
def get_balance(self):
return self.__balance
account = BankAccount(1000)
account.deposit(500)
account.withdraw(200)
print(account.get_balance()) # 1300
# Attempting direct access fails
# print(account.__balance) # AttributeError
# Accessing via name mangling works
print(account._BankAccount__balance) # 1300
Note: Python doesn’t have explicit private keywords; using _ or __ is a convention. The double underscore (__) triggers name mangling, which makes accidental access harder but does not prevent deliberate access.
Public, Protected, and Pseudo-Private in Python
| Type | Prefix | Access | Notes |
|---|---|---|---|
| Public | None | Accessible everywhere | Default in Python |
| Protected | _ |
Accessible, but intended for internal use | Convention only; no enforcement |
| Pseudo-Private | __ |
Accessible via name mangling _ClassName__var |
Makes accidental access harder; still not truly private |
4. Constructors & Destructors
Constructor (__init__) initializes an object when it’s created. Destructor (__del__) cleans up before the object is destroyed.
class Sample:
def __init__(self, name):
self.name = name
print(f"{self.name} object created")
def __del__(self):
print(f"{self.name} object destroyed")
obj = Sample("Test")
print(obj.name)
If you don’t define __init__, Python provides a default constructor. If you don’t define __del__, Python handles cleanup automatically.
5. Static/Class Variables & Methods
Class variables are shared across all objects. Static methods don’t require an instance and are defined using @staticmethod. Class methods use @classmethod and receive cls as the first parameter.
class MathUtils:
pi = 3.14159 # Class variable
@staticmethod
def add(a, b):
return a + b
@classmethod
def set_pi(cls, new_pi):
cls.pi = new_pi
# Accessing class variable via class
print(MathUtils.pi) # 3.14159
# Using static method
print(MathUtils.add(5, 10)) # 15
# Modifying class variable via class method
MathUtils.set_pi(3.14)
print(MathUtils.pi) # 3.14
Accessing an uninitialized class variable will raise AttributeError.
- Classes are blueprints; objects are instances.
- Instance variables belong to objects; class variables are shared.
- Encapsulation is done using
_and__prefixes. - Python provides constructors/destructors, but defaults exist if not defined.
- Static/class variables and methods are accessed via class name or instance, but uninitialized variables raise errors.
- Python does not have strict
privateorpublickeywords like Java/C++. - Prefixing a variable with
__triggers name mangling, making accidental access from outside harder. - Prefixing a variable with
_is a soft convention to indicate "internal use only". - Best practice: access internal state via getter/setter methods rather than directly accessing "private" variables.
6. Inheritance
Inheritance is a core OOP concept that allows a class (the child or derived class) to inherit attributes and methods from another class (the parent or base class). Inheritance promotes code reuse and helps in modeling real-world relationships.
6.1 Why Use Inheritance?
- Code Reusability: Child classes can reuse functionality of parent classes.
- Extensibility: You can extend or customize inherited behavior in derived classes.
- Hierarchy Modeling: Helps represent “is‑a” relationships (e.g., Employee is a Person).
6.2 Types of Inheritance in Python
Python supports several types of inheritance depending on how classes are related:
| Type | Description | Example |
|---|---|---|
| Single Inheritance | A derived class inherits from exactly one base class. |
|
| Multiple Inheritance | A class inherits from more than one base class. |
|
| Multilevel Inheritance | A class inherits from a class which is already derived from another class. |
|
| Hierarchical Inheritance | Multiple child classes inherit from the same base class. |
|
| Hybrid Inheritance | A mix of two or more types of inheritance (e.g., multiple + multilevel). |
|
6.3 How Inheritance Is Implemented
In Python, you define a child class by putting the parent class name(s) inside parentheses. You can use the built-in super() function to call base class methods (especially __init__) and to follow Python’s Method Resolution Order (MRO).
class Person:
def __init__(self, name: str):
self.name = name
class Employee(Person):
def __init__(self, name: str, position: str):
super().__init__(name) # Calls parent's __init__
self.position = position
def work(self):
print(f"{self.name} works as a {self.position}")
e = Employee("Talha", "Developer")
e.work() # Talha works as a Developer
6.4 Method Resolution Order (MRO)
When using multiple inheritance, Python needs to decide which parent class to look at first for methods or attributes. This is handled by the Method Resolution Order (MRO). Python uses C3 linearization to compute MRO. :contentReference[oaicite:0]{index=0}
You can inspect the MRO of a class via __mro__ or .mro():
class A:
def method(self):
print("A")
class B(A):
def method(self):
print("B")
class C(A):
def method(self):
print("C")
class D(B, C):
pass
print(D.__mro__) # (, , , , )
d = D()
d.method() # B.method(), because B comes before C in MRO
6.5 Real-World Example: Employee Hierarchy
class Person:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def show_info(self):
print(f"Name: {self.name}, Age: {self.age}")
class Employee(Person):
def __init__(self, name: str, age: int, position: str, salary: float):
super().__init__(name, age)
self.position = position
self.salary = salary
def show_job(self):
print(f"{self.name} is a {self.position} earning ${self.salary}")
class Manager(Employee):
def __init__(self, name: str, age: int, salary: float, team_size: int):
super().__init__(name, age, "Manager", salary)
self.team_size = team_size
def show_team(self):
print(f"{self.name} manages a team of {self.team_size} people")
m = Manager("Talha", 30, 120000, 5)
m.show_info() # Name: Talha, Age: 30
m.show_job() # Talha is a Manager earning $120000
m.show_team() # Talha manages a team of 5 people
6.6 Diamond Problem in Python
The diamond problem occurs in multiple inheritance when a class inherits from two classes that share a common base class. Python allows it but resolves method calls using the Method Resolution Order (MRO).
class A:
def method_a(self):
print("A")
class B:
def method_a(self):
print("B")
class C(A, B): # Inherits from both A and B
def method_c(self):
print("C")
c = C()
c.method_a() # Output: A, because A appears first in MRO
c.method_c() # Output: C
Explanation:
- Even though both
AandBhavemethod_a, Python follows the MRO to decide which one to call. - Here,
C.__mro__would be:(C, A, B, object). Somethod_afromAis called first. - If you wanted
B'smethod_a, you could explicitly call it:B.method_a(c). - This prevents ambiguity typical in the classic diamond problem seen in languages like C++.
print(C.__mro__)
# (, , , )
# Calling B's method explicitly
B.method_a(c) # Output: B
Key Takeaway: Python supports multiple inheritance, including diamond-shaped hierarchies, but the super() function and MRO ensure a deterministic and safe method resolution.
6.7 Key Points & Best Practices
- Use inheritance only when there’s a real
is-arelationship. - Don’t overuse inheritance — sometimes composition is a better alternative.
- Use
super()to ensure base classes are properly initialized, especially in complex hierarchies. - Be careful with multiple inheritance; always check the MRO.
7. Polymorphism in Python
Polymorphism means "many forms". In Python, it allows objects of different classes to be treated as objects of a common superclass. The same interface can be used for different underlying forms (data types or classes).
7.1 Types of Polymorphism
- Compile-time / Static-like Polymorphism: Python does not support method overloading by default as in Java or C++. However, we can achieve it using default arguments or variable-length arguments.
- Runtime / Dynamic Polymorphism: Method overriding allows a subclass to provide a specific implementation of a method already defined in the superclass. Python resolves the method at runtime.
7.2 Method Overriding (Runtime Polymorphism)
class Animal:
def speak(self):
print("Generic sound")
class Dog(Animal):
def speak(self):
print("Woof!")
class Cat(Animal):
def speak(self):
print("Meow!")
# Runtime polymorphism in action
animals = [Dog(), Cat(), Animal()]
for animal in animals:
animal.speak()
# Output:
# Woof!
# Meow!
# Generic sound
Here, speak() is resolved at runtime based on the actual object type.
7.3 Method Overloading Using Default / Variable Arguments (Static-like)
class Calculator:
def add(self, a, b=0, c=0): # Default arguments
return a + b + c
calc = Calculator()
print(calc.add(5)) # 5
print(calc.add(5, 10)) # 15
print(calc.add(5, 10, 15))# 30
# Using *args for variable-length arguments
class Calculator2:
def add(self, *numbers):
return sum(numbers)
calc2 = Calculator2()
print(calc2.add(1,2,3,4)) # 10
Python does not enforce traditional compile-time overloading, but this pattern mimics method overloading.
---7.4 Operator Overloading (Polymorphism with Operators)
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other): # Overloading + operator
return Point(self.x + other.x, self.y + other.y)
p1 = Point(2, 3)
p2 = Point(4, 5)
p3 = p1 + p2
print(p3.x, p3.y) # 6 8
Here, the + operator behaves differently based on the object types — another form of polymorphism.
7.5 Polymorphism with Functions
Python functions can accept arguments of different types and behave accordingly:
def add(a, b):
return a + b
print(add(5, 10)) # 15 (integers)
print(add("Hi", "Bye"))# HiBye (strings)
print(add([1,2], [3])) # [1,2,3] (lists)
This is polymorphism at the function level: same function name, different behavior based on argument type.
---Method Overloading Attempt
Changing the return type or input variable type does not create a new method in Python. Only the method name matters.
class Example:
def greet(self):
return "Hello"
# Attempting to overload by changing return type
def greet(self) -> int:
return 123
obj = Example()
print(obj.greet()) # Output: 123
Explanation:
- Python keeps only the last definition of a method with the same name.
- Changing the return type or input variable types has no effect.
- “Overloading” based on input types must be handled manually using
*argsor**kwargs.
Proper Python Overloading with *args / **kwargs
class Example:
def greet(self, *args):
if not args:
return "Hello"
elif len(args) == 1:
return f"Hello, {args[0]}"
else:
return "Hello everyone"
obj = Example()
print(obj.greet()) # Hello
print(obj.greet("Talha")) # Hello, Talha
print(obj.greet("Talha", "Ali")) # Hello everyone
7.6 Key Points
- Python supports polymorphism naturally due to dynamic typing.
- Runtime polymorphism is achieved via method overriding.
- Static-like polymorphism can be mimicked using default arguments or
*args / **kwargs. - Operators can be overloaded to perform different operations based on object type.
- Polymorphism allows flexibility and cleaner, more maintainable code.
8. Abstraction and Abstract Classes
Abstraction is an Object-Oriented Programming (OOP) concept that hides the implementation details of a class and shows only the essential features to the user. In Python, abstraction is achieved using abstract classes and abstract methods from the abc module.
1. Abstract Classes
An abstract class cannot be instantiated directly. It is meant to be inherited by other classes that implement the abstract methods.
from abc import ABC, abstractmethod
# Abstract class
class Vehicle(ABC):
@abstractmethod
def start_engine(self):
pass
@abstractmethod
def stop_engine(self):
pass
2. Implementing Abstract Methods
Any subclass of an abstract class must implement all abstract methods; otherwise, it will also be considered abstract.
class Car(Vehicle):
def start_engine(self):
print("Car engine started")
def stop_engine(self):
print("Car engine stopped")
# Instantiating subclass
my_car = Car()
my_car.start_engine() # Car engine started
my_car.stop_engine() # Car engine stopped
3. Interfaces in Python
Python does not have a separate interface keyword like Java. Interfaces can be simulated using abstract classes that contain only abstract methods. Example:
from abc import ABC, abstractmethod
class PrinterInterface(ABC):
@abstractmethod
def print_document(self, doc):
pass
@abstractmethod
def scan_document(self, doc):
pass
class Printer(PrinterInterface):
def print_document(self, doc):
print(f"Printing {doc}")
def scan_document(self, doc):
print(f"Scanning {doc}")
p = Printer()
p.print_document("Report.pdf") # Printing Report.pdf
p.scan_document("Report.pdf") # Scanning Report.pdf
4. Difference Between Abstract Classes and Interfaces
| Feature | Abstract Class | Interface (Python) |
|---|---|---|
| Purpose | Define common base functionality | Define a contract with only abstract methods |
| Methods | Can have both abstract and concrete methods | Only abstract methods (no implementation) |
| Instantiation | Cannot instantiate abstract class | Cannot instantiate interface |
| Multiple Inheritance | Allowed | Allowed (can implement multiple interfaces) |
| Use Case | When you want shared functionality and enforce some methods | When you want only method signatures for multiple implementations |
5. When to Use Abstract Classes vs Interfaces
- Use abstract classes when you have common functionality that subclasses can share.
- Use interfaces when you want to define a contract and enforce method implementation across unrelated classes.
- Python allows multiple inheritance, so you can combine abstract classes and interfaces for flexible design.
6. Key Notes
- Abstract methods must be implemented in subclasses.
- Abstract classes can also contain normal methods with implementation.
- Python interfaces are just abstract classes with only abstract methods.
- Trying to instantiate an abstract class directly will raise
TypeError.
7. Special Methods (Magic Methods)
Special methods allow customization of object behavior, like printing or arithmetic operations.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Point({self.x}, {self.y})"
def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)
p1 = Point(2, 3)
p2 = Point(4, 5)
print(p1) # Point(2, 3)
p3 = p1 + p2
print(p3) # Point(6, 8)
8. Class Methods & Static Methods
Use @classmethod for methods that act on the class, and @staticmethod for methods independent of class/instance.
class Circle:
pi = 3.1416
def __init__(self, radius):
self.radius = radius
@classmethod
def set_pi(cls, value):
cls.pi = value
@staticmethod
def area_formula(radius):
return Circle.pi * radius * radius
c = Circle(5)
print(Circle.area_formula(5)) # 78.54
Circle.set_pi(3.14)
print(Circle.area_formula(5)) # 78.5
Key Points
- OOP promotes modular, reusable, and maintainable code.
- Encapsulation protects internal state.
- Inheritance allows extending classes.
- Polymorphism allows flexibility in method behavior.
- Abstraction hides implementation details and exposes interfaces.
- Special methods customize object behavior.
- Class methods operate on the class; static methods operate independently.