Python

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 private or public keywords 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.
class Parent:
            def say_hello(self):
                print("Hello from Parent")

        class Child(Parent):
            def say_hi(self):
                print("Hi from Child")

        c = Child()
        c.say_hello()  # Inherited method
        c.say_hi()     # Child's method
Multiple Inheritance A class inherits from more than one base class.
class A:
            def method_a(self):
                print("A")

        class B:
            def method_b(self):
                print("B")

        class C(A, B):  # Inherits from both
            def method_c(self):
                print("C")

        c = C()
        c.method_a()  # A
        c.method_b()  # B
        c.method_c()  # C
Multilevel Inheritance A class inherits from a class which is already derived from another class.
class Grandparent:
            def info(self):
                print("Grandparent")

        class Parent(Grandparent):
            def info_parent(self):
                print("Parent")

        class Child(Parent):
            def info_child(self):
                print("Child")

        c = Child()
        c.info()           # Grandparent
        c.info_parent()    # Parent
        c.info_child()     # Child
Hierarchical Inheritance Multiple child classes inherit from the same base class.
class Person:
            def greet(self):
                print("Hello, I am a Person")

        class Student(Person):
            def study(self):
                print("I study")

        class Employee(Person):
            def work(self):
                print("I work")

        s = Student()
        e = Employee()
        s.greet()  # Hello, I am a Person
        e.greet()  # Hello, I am a Person
Hybrid Inheritance A mix of two or more types of inheritance (e.g., multiple + multilevel).
class A:
            pass

        class B(A):
            pass

        class C(A):
            pass

        class D(B, C):  # Hybrid: multiple + multilevel
            pass

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 A and B have method_a, Python follows the MRO to decide which one to call.
  • Here, C.__mro__ would be: (C, A, B, object). So method_a from A is called first.
  • If you wanted B's method_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-a relationship.
  • 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 *args or **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.