Sitemap

Object-Oriented Programming (OOP) from basic to advanced in Python

--

We will cover:

  1. Introduction to OOP
  2. Classes and Objects
  3. Encapsulation
  4. Inheritance
  5. Polymorphism
  6. Abstraction
  7. Magic Methods & Dunder Methods
  8. Class & Static Methods
  9. Composition vs. Inheritance
  10. Metaclasses (Advanced)
  11. Design Patterns in OOP
  12. Real-World Projects in OOP

Lets explore each topic one by one

1. Object-Oriented Programming (OOP)

Imagine the world around you:

You see cars, dogs, humans, phones, etc. Each of these things has features (attributes) and actions (behaviors).

For example:

  • A car has a color, brand, model (features).
  • A car can start, stop, accelerate (actions).

OOP is a way of writing code where we think of everything as objects, just like real-world things.

Key Concepts of OOP

OOP is based on four key ideas:

  • Class — A blueprint (like a plan for making objects).
  • Object — The actual thing created from a class.
  • Attributes — Properties (features) of an object.
  • Methods — Actions (behaviors) of an object.

Example: A Car

  • Class: A general template (e.g., “Car”).
  • Object: A real car (e.g., “Toyota Corolla”).
  • Attributes: Color, brand, speed.
  • Methods: Drive, stop, honk.

Step 1: Creating a Class

A class is like a template or blueprint.

class Car:
pass # This means "do nothing" for now

This is an empty class, just a blueprint. Now, let’s create objects from this blueprint.

Step 2: Creating an Object

An object is a real thing created from a class.

my_car = Car()  # Creating an object
print(my_car) # Output: <__main__.Car object at some_memory_address>

my_car is an object of the Car class. Right now, it does nothing because our class is empty.

Step 3: Adding Attributes (Features)

We give our car attributes (color, brand, speed).

class Car:
def __init__(self, brand, color): # The constructor (initializer)
self.brand = brand # Attribute
self.color = color # Attribute
# Creating objects (real cars)
car1 = Car("Toyota", "Red")
car2 = Car("BMW", "Black")
print(car1.brand, car1.color) # Output: Toyota Red
print(car2.brand, car2.color) # Output: BMW Black

How it works:

  • __init__() is a constructor. It runs when we create an object.
  • self.brand = brand assigns values to the object.
  • car1 and car2 are two different cars (objects).

Step 4: Adding Methods (Actions)

A method is like a function inside a class.

Example: Let’s add a drive() action.

class Car:
def __init__(self, brand, color):
self.brand = brand
self.color = color
    def drive(self):  # Method (action)
print(f"The {self.color} {self.brand} is driving.")
# Creating objects
car1 = Car("Toyota", "Red")
car2 = Car("BMW", "Black")
# Calling the method
car1.drive() # Output: The Red Toyota is driving.
car2.drive() # Output: The Black BMW is driving.

How it works:

  • Methods are like verbs — they do something.
  • self means this object (car1 or car2).
  • car1.drive() runs the drive() method.

Step 5: Changing Attributes

We can change an attribute after creating an object.

car1.color = "Blue"  # Changing color
print(car1.color) # Output: Blue

Step 6: Multiple Objects

You can create many objects from a class.

car1 = Car("Toyota", "Red")
car2 = Car("BMW", "Black")
car3 = Car("Tesla", "White")
car1.drive()
car2.drive()
car3.drive()

Output:

The Red Toyota is driving.
The Black BMW is driving.
The White Tesla is driving.

Each object has its own attributes and methods.

Summary

  • Class = Blueprint (e.g., “Car”).
  • Object = A real thing created from a class (e.g., “Toyota Corolla”).
  • Attributes = Properties (e.g., color, brand).
  • Methods = Actions (e.g., drive, stop).

OOP makes code organized, reusable, and easy to maintain. Start using it in your projects today!

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

2. Encapsulation

Encapsulation in Python: Protecting Your Data Like a Secret Safe!

Imagine you have a secret safe at home. Inside, you store your money, but you don’t want just anyone to open it and take cash out. Instead, you create a small slot for deposits and a button for withdrawals. This setup gives you control over your money because:

  • ✅ No one can directly access the safe’s contents.
  • ✅ Money can only be added or withdrawn through the designated slots.
  • ✅ You decide how much can be withdrawn at a time, preventing overdrafts.

This is exactly what Encapsulation does in programming! 🚀

Step 1: A Class Without Encapsulation (Unsafe)

Let’s first look at a BankAccount class that does not use encapsulation. Here, the balance is a public attribute, which means anyone can modify it directly.

class BankAccount:
def __init__(self, balance):
self.balance = balance # Public attribute (Unsafe)


# Creating an account
account = BankAccount(5000)
# Accessing balance (which is fine)
print(account.balance) # Output: 5000
# But there's a problem! 😨
account.balance = -1000 # Someone changed the balance to negative!
print(account.balance) # Output: -1000 (This should not happen!)

🚨 PROBLEM:

  • Anyone can change the balance directly.
  • Negative balances are allowed, which is unrealistic.
  • The bank loses control over balance updates.

Step 2: Protecting Data with Encapsulation

Now, let’s use Encapsulation to hide the balance and provide controlled methods for accessing and updating it.

class BankAccount:
def __init__(self, balance):
self.__balance = balance # Private attribute (Hidden)

def get_balance(self): # Getter method
return self.__balance

def deposit(self, amount): # Safe way to add money
if amount > 0:
self.__balance += amount
print(f"Deposited: {amount}, New Balance: {self.__balance}")
else:
print("Invalid deposit amount!")

def withdraw(self, amount): # Safe way to remove money
if 0 < amount <= self.__balance:
self.__balance -= amount
print(f"Withdrew: {amount}, Remaining Balance: {self.__balance}")
else:
print("Invalid withdraw amount!")


# Creating an account
account = BankAccount(5000)
# Accessing balance safely
print(account.get_balance()) # Output: 5000
# Depositing money
account.deposit(1000) # Deposited: 1000, New Balance: 6000
# Withdrawing money safely
account.withdraw(2000) # Withdrew: 2000, Remaining Balance: 4000
# Trying to withdraw too much
account.withdraw(5000) # Invalid withdraw amount!

Key Changes:

  • ✅ __balance is private – It cannot be accessed directly.
  • ✅ Getter (get_balance()) – Allows only reading the balance.
  • ✅ Setter methods (deposit(), withdraw()) – Control how the balance is updated.
  • ✅ Prevents errors — No negative balances or direct modifications.

Now, the account is safe! 🔒

Step 3: Understanding Private Attributes (__balance)

In Python, private attributes start with __ (double underscore). If we try to access a private attribute directly, we get an error:

print(account.__balance)  # ERROR! AttributeError: 'BankAccount' object has no attribute '__balance'

🔍 Why?

  • __balance is private, so it cannot be accessed directly from outside the class.
  • But inside the class, we can still use it:
def get_balance(self):
return self.__balance # This works inside the class

Step 4: Using @property for a Cleaner Approach

Python provides a better way to define getter and setter methods using @property. This makes accessing attributes more natural.

class BankAccount:
def __init__(self, balance):
self.__balance = balance # Private

@property # Getter (Looks like an attribute)
def balance(self):
return self.__balance

@balance.setter # Setter (Looks like an attribute)
def balance(self, amount):
if amount >= 0:
self.__balance = amount
else:
print("Invalid balance!")

# Creating an account
account = BankAccount(5000)
# Accessing balance like an attribute
print(account.balance) # Output: 5000
# Changing balance safely
account.balance = 7000
print(account.balance) # Output: 7000
# Trying to set a negative balance (Blocked)
account.balance = -100 # Output: Invalid balance!

Step 7: Using Encapsulation in Real-World Applications

Encapsulation is widely used in real-world applications, including:

  • Banking Systems: Protecting user account details and transactions.
  • Healthcare Applications: Securing patient records and sensitive medical data.
  • E-commerce Platforms: Managing user data, orders, and payments securely.
  • Game Development: Keeping internal game mechanics hidden from external modifications.

Encapsulation ensures data integrity, security, and maintainability, making it an essential concept in software development.

🔥 Final Summary

Feature Without Encapsulation ❌ With Encapsulation ✅ Access Attributes Direct (account.balance) Only via methods (account.get_balance()) Modify Attributes Direct (account.balance = -1000) Controlled via setters Data Safety ❌ Unsafe (Anyone can change values) ✅ Safe (Hides data) Read-Only Option ❌ No ✅ Yes (@property) Code Readability ❌ Messy ✅ Clean and structured

🌟 Encapsulation = Protection + Control!

1️⃣ Encapsulation hides data using __private_attributes. 2️⃣ Getters (@property) let us read data safely. 3️⃣ Setters (@property.setter) allow controlled modifications. 4️⃣ Read-only attributes protect data from being modified. 5️⃣ Encapsulation is widely used in real-world applications.

Now, your data is as safe as a locked safe! 🔐🚀

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

3. Inheritance

Understanding Inheritance in Python: Passing Down Features!

Imagine you inherit traits from your parents:

  • You inherit their physical features (like eye color, hair color).
  • You inherit their skills (like cooking, driving).
  • But, you can also learn new things on your own!

In Object-Oriented Programming (OOP), Inheritance allows one class (child) to inherit properties and methods from another class (parent). This helps in reusing code, structuring it better, and making programs more scalable.

Step 1: Creating a Parent Class

The parent class contains common properties and methods that can be inherited by child classes.

class Animal:
def __init__(self, name):
self.name = name # Common attribute
def make_sound(self):
print("Some generic animal sound") # Common method
# Creating an object
animal = Animal("Generic Animal")
print(animal.name) # Output: Generic Animal
animal.make_sound() # Output: Some generic animal sound

Step 2: Creating a Child Class (Inheritance)

A child class (also called a subclass) inherits from a parent class.

class Dog(Animal):  # Dog inherits from Animal
def make_sound(self):
print("Woof! Woof!") # Overriding the method

# Creating an object
dog = Dog("Buddy")
print(dog.name) # Output: Buddy
dog.make_sound() # Output: Woof! Woof!

🔍 How does this work?

  • Dog inherits everything from Animal.
  • Dog overrides (make_sound()) to provide its own implementation.

Step 3: Adding New Features to Child Class

A child class can have extra features of its own.

class Cat(Animal):
def __init__(self, name, color):
super().__init__(name) # Call parent constructor
self.color = color # New attribute
def make_sound(self):
print("Meow! Meow!")


# Creating an object
cat = Cat("Kitty", "White")
print(cat.name) # Output: Kitty
print(cat.color) # Output: White
cat.make_sound() # Output: Meow! Meow!

Key Takeaways:

✔ super().__init__(name) calls the parent’s constructor. ✔ Cat has a new attribute (color). ✔ Cat overrides make_sound().

Step 4: Multiple Levels of Inheritance (Multilevel)

A class can inherit from another class, which inherits from another class.

class Animal:
def eat(self):
print("This animal eats food.")

class Mammal(Animal): # Mammal inherits from Animal
def breathe(self):
print("This mammal breathes air.")

class Dog(Mammal): # Dog inherits from Mammal
def make_sound(self):
print("Woof! Woof!")

dog = Dog()
dog.eat() # Output: This animal eats food.
dog.breathe() # Output: This mammal breathes air.
dog.make_sound() # Output: Woof! Woof!

✔ Dog gets features from Mammal and Animal. ✔ The chain of inheritance happens naturally.

Step 5: Multiple Inheritance (Inherit from Multiple Classes)

A class can inherit from more than one parent.

class Swimmer:
def swim(self):
print("This animal can swim.")

class Walker:
def walk(self):
print("This animal can walk.")

class Duck(Swimmer, Walker): # Inherits from both
def make_sound(self):
print("Quack! Quack!")


duck = Duck()
duck.swim() # Output: This animal can swim.
duck.walk() # Output: This animal can walk.
duck.make_sound() # Output: Quack! Quack!

✔ Duck inherits from both Swimmer and Walker. ✔ It can swim, walk, and quack! 🦆

Step 6: Method Resolution Order (MRO)

When a class inherits from multiple classes, Python follows Method Resolution Order (MRO) to decide which method to call first.

class A:
def show(self):
print("Class A")

class B(A):
def show(self):
print("Class B")

class C(A):
def show(self):
print("Class C")

class D(B, C): # Inherits from both B and C
pass


obj = D()
obj.show() # Output: Class B

🔍 Why did B execute first?

✔ Python checks the first parent (B). ✔ If not found, it checks the second parent (C). ✔ This follows MRO (left to right).

Use D.mro() to see the method resolution order.

print(D.mro())  
# Output: [<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>]

Step 7: When to Use Inheritance?

✅ When you have shared behavior across multiple classes. ✅ When you want to avoid repeating code. ✅ When you need code organization (parent-child relationship).

Press enter or click to view image in full size

🌟 Inheritance = Reusability + Organization!

1️⃣ Inheritance helps reuse code by passing down properties and methods. 2️⃣ Child classes can override methods for specific behaviors. 3️⃣ Multiple and multilevel inheritance allow better structure. 4️⃣ MRO helps resolve method conflicts when inheriting from multiple classes.

Now, you’re all set to use Inheritance in Python effectively! 🚀

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

4. Polymorphism

Polymorphism comes from two Greek words:

  • Poly = “Many”
  • Morph = “Forms”

So, Polymorphism means “one thing, many forms”.

Think of a human:

  • At home, you’re a child.
  • At school, you’re a student.
  • At work, you’re an employee.

🧠 Same person, different roles → This is Polymorphism!

Why Use Polymorphism?

✅ Code flexibility — Same function works with different objects.
✅ Reusability — No need to rewrite similar functions.
✅ Extensibility — You can add new classes without changing old code.

Types of Polymorphism in Python

There are two main types of Polymorphism:

  1. Method Overriding (Runtime Polymorphism)
  2. Method Overloading (Compile-time Polymorphism — Python handles it differently)

Let’s go step by step! 🚀

1. Method Overriding (Runtime Polymorphism) in Python

Method overriding is an important concept in Object-Oriented Programming (OOP), which falls under Runtime Polymorphism. It allows a child class to provide a specific implementation of a method that is already defined in its parent class.

Key Concepts of Method Overriding

  1. Inheritance: Overriding happens when a child class inherits from a parent class.
  2. Same Method Signature: The child class method should have the same name as the method in the parent class.
  3. Different Implementation: The method in the child class provides a different behavior than the method in the parent class.
  4. Method Resolution at Runtime: The method that gets executed is determined at runtime based on the object type (hence, runtime polymorphism).
  5. Dynamic Dispatch: Python determines which method to call dynamically based on the actual object instance.

Example: Animal Sounds

Let’s analyze the example in depth:

class Animal:
def make_sound(self):
print("Some generic animal sound")

class Dog(Animal):
def make_sound(self): # Overriding
print("Woof! Woof!")

class Cat(Animal):
def make_sound(self): # Overriding
print("Meow! Meow!")


# Creating objects
animal = Animal()
dog = Dog()
cat = Cat()

# Calling the same method on different objects
animal.make_sound() # Output: Some generic animal sound
dog.make_sound() # Output: Woof! Woof!
cat.make_sound() # Output: Meow! Meow!

Breakdown of the Example

  1. Parent Class (Animal)
  • It defines a method make_sound(), which prints "Some generic animal sound".
  • This is the default behavior for all animals.

2. Child Classes (Dog and Cat)

  • Each child class inherits from Animal.
  • They override make_sound() to provide their own implementation.

3. Creating Objects and Calling Methods

  • animal.make_sound() calls the original method in Animal.
  • dog.make_sound() calls the overridden method in Dog.
  • cat.make_sound() calls the overridden method in Cat.

How Python Resolves Method Calls at Runtime

When calling make_sound():

  • If the method exists in the child class, Python executes the child’s version.
  • If the method does not exist in the child class, Python looks in the parent class.

This is an example of Dynamic Method Resolution (Dynamic Dispatch), a key feature of runtime polymorphism.

Benefits of Method Overriding

✅ Improves Code Reusability: You don’t need to rewrite code in the child class — just override the specific behavior.
✅ Supports Runtime Polymorphism: The method that gets called depends on the actual object type at runtime.
✅ Encapsulation & Flexibility: The child class can modify only what it needs without changing the parent class.

Using Method Overriding in a Real-World Scenario

Let’s take a real-world example where we define a Payment system that supports different payment methods.

class Payment:
def process_payment(self, amount):
print(f"Processing a payment of ${amount}")

class CreditCardPayment(Payment):
def process_payment(self, amount):
print(f"Processing credit card payment of ${amount}")

class PayPalPayment(Payment):
def process_payment(self, amount):
print(f"Processing PayPal payment of ${amount}")

# Create objects of different payment methods
payment = Payment()
credit_card = CreditCardPayment()
paypal = PayPalPayment()

# Call the overridden method
payment.process_payment(100) # Output: Processing a payment of $100
credit_card.process_payment(200) # Output: Processing credit card payment of $200
paypal.process_payment(300) # Output: Processing PayPal payment of $300

How This Example Works

  • Payment is the base class with a generic process_payment() method.
  • CreditCardPayment and PayPalPayment override the method to implement their specific payment processing logic.
  • Even though all objects call process_payment(), the actual behavior depends on the object type at runtime.

Using super() to Call the Parent Method

Sometimes, you want to extend the functionality of the parent class instead of completely replacing it. You can use super():

class Animal:
def make_sound(self):
print("Some generic animal sound")

class Dog(Animal):
def make_sound(self):
super().make_sound() # Calls the parent class method
print("Woof! Woof!")

dog = Dog()
dog.make_sound()

Output

Some generic animal sound
Woof! Woof!

How super() Works

  • super().make_sound() calls the make_sound() method from the parent class.
  • The Dog class adds additional behavior instead of completely replacing it.

Method Overriding vs Method Overloading

FeatureMethod OverridingMethod Overloading (Not Supported in Python)DefinitionChild class redefines a parent class method with the same signatureSame method name but with different parameters (Python does not support true overloading)TypeRuntime PolymorphismCompile-time Polymorphism (Not in Python)Exampledef make_sound(self): (child class overrides the method)def add(self, a): vs. def add(self, a, b): (Not supported in Python)Handled ByPython resolves at runtimePython does not support overloading, instead uses default arguments or variable-length arguments

Key Takeaways

✔ Method overriding enables runtime polymorphism, allowing child classes to define their own behavior.
✔ Python determines which method to call dynamically based on the object type.
✔ Using super() allows calling the parent class method inside the overridden method.
✔ Method Overriding is different from Method Overloading (Python does not support true overloading).

This concept is widely used in real-world applications such as:

  • Web frameworks (Django, Flask) where views override base class methods.
  • GUI applications where button actions are overridden.
  • Machine Learning models where the fit() method is overridden in subclasses.

2. Method Overloading in Python (Compile-Time Polymorphism)

Method Overloading is a fundamental concept of Polymorphism, which allows a class to define multiple methods with the same name but with different parameters. It is a type of Compile-Time Polymorphism (also called Static Polymorphism) found in languages like Java and C++.

🚨 Python Does NOT Support True Method Overloading

Unlike Java or C++, Python does not support true method overloading based on different function signatures. This is because Python methods do not support function signature-based differentiation — only the latest defined method with the same name is retained.

However, Python provides alternative ways to achieve method overloading using:

  • Default Arguments
  • Variable-Length Arguments (*args and **kwargs)
  • Function Dispatching (functools.singledispatch)

➁ True Method Overloading vs Python’s Approach

In Java or C++, method overloading works as follows:

class MathOperations {
int add(int a, int b) {
return a + b;
}

int add(int a, int b, int c) {
return a + b + c;
}
}
// Usage
MathOperations obj = new MathOperations();
obj.add(5, 10); // Calls add(int, int)
obj.add(5, 10, 15); // Calls add(int, int, int)

✅ Here, the compiler determines the correct method to call based on the number of parameters.

❌ Why Doesn’t Python Support This?

In Python, defining multiple methods with the same name will overwrite the previous definition:

class MathOperations:
def add(self, a, b):
return a + b
    def add(self, a, b, c):  # Overwrites the previous method
return a + b + c
math_op = MathOperations()
print(math_op.add(5, 10)) # 🚨 Error! add() missing 1 required positional argument

❌ The first add method is overwritten by the second one. This is why Python does not support true method overloading.

➂ Achieving Method Overloading in Python

Although Python does not support traditional overloading, we can achieve it through different techniques.

🔹 Method Overloading Using Default Arguments

We can simulate method overloading using default values for parameters:

class MathOperations:
def add(self, a, b=0, c=0): # Default values
return a + b + c
math_op = MathOperations()
print(math_op.add(5)) # Output: 5 (a=5, b=0, c=0)
print(math_op.add(5, 10)) # Output: 15 (a=5, b=10, c=0)
print(math_op.add(5, 10, 15)) # Output: 30 (a=5, b=10, c=15)

🔹 Method Overloading Using *args (Variable-Length Arguments)

Another way to achieve method overloading is by using *args, which allows passing any number of arguments.

class MathOperations:
def add(self, *numbers): # Accepts multiple arguments
return sum(numbers)
math_op = MathOperations()
print(math_op.add(5)) # Output: 5
print(math_op.add(5, 10)) # Output: 15
print(math_op.add(5, 10, 15)) # Output: 30

🔹 Method Overloading Using **kwargs (Keyword Arguments)

If we want to handle named arguments, we can use **kwargs:

class Person:
def greet(self, **kwargs):
if 'name' in kwargs and 'age' in kwargs:
print(f"Hello, my name is {kwargs['name']} and I am {kwargs['age']} years old.")
elif 'name' in kwargs:
print(f"Hello, my name is {kwargs['name']}.")
else:
print("Hello!")
p = Person()
p.greet() # Output: Hello!
p.greet(name="Alice") # Output: Hello, my name is Alice.
p.greet(name="Bob", age=25) # Output: Hello, my name is Bob and I am 25 years old.

🔹 Method Overloading Using functools.singledispatch

Python provides a powerful way to implement function-based method overloading using functools.singledispatch:

from functools import singledispatch
@singledispatch
def process(data):
print(f"Processing generic data: {data}")
@process.register(int)
def _(data):
print(f"Processing integer data: {data * 2}")
@process.register(str)
def _(data):
print(f"Processing string data: {data.upper()}")
# Calling the function
process(10) # Output: Processing integer data: 20
process("hello") # Output: Processing string data: HELLO
process(3.14) # Output: Processing generic data: 3.14

➃ Method Overloading vs. Method Overriding

Feature Method Overloading Method Overriding Definition Multiple methods with same name but different parameters in the same class. A child class redefines a method of the parent class with the same signature. Polymorphism Type Compile-Time Polymorphism Runtime Polymorphism Supported in Python? ❌ (Not directly) but can be achieved using default arguments, *args, **kwargs, and singledispatch. ✅ Fully supported Method Resolution Decided at compile-time in languages like Java and C++, but not in Python. Decided at runtime in Python.

➄ Key Takeaways

✔ Python does not support traditional method overloading, but we can achieve similar behavior using default arguments, *args, **kwargs, and singledispatch.
✔ Method overloading allows flexibility in calling a method with different arguments.
✔ Method Overloading vs Method Overriding:

  • Overloading = Compile-time Polymorphism (not directly supported in Python).
  • Overriding = Runtime Polymorphism (fully supported in Python).
    ✔ Use singledispatch when working with function overloading based on types.
Press enter or click to view image in full size

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

5. Abstraction

Introduction to Abstraction in Python

Abstraction is a fundamental concept in object-oriented programming (OOP) that helps in hiding complex implementation details while exposing only the essential functionalities. This allows developers to interact with objects without needing to understand the intricate details of how they work internally.

Imagine using a TV remote:

  • You press a button to change the channel, but you don’t need to know how the circuit inside works.
  • You only interact with buttons that allow you to perform necessary operations.

This is what abstraction does in programming! It ensures that users only see the relevant parts while keeping the complex internal workings hidden.

What is Abstraction in Programming?

Definition:

Abstraction in programming refers to hiding the complex implementation details and exposing only the necessary parts to the user.

Real-Life Examples of Abstraction:

  • Google Search: You enter a query, and Google shows results. You don’t see the complex algorithms working behind the scenes.
  • ATM Machine: You insert your card and withdraw money without seeing the internal banking operations.
  • Car: You turn the steering wheel, and the car moves, but you don’t need to know how the engine works internally.

In Python, abstraction is achieved using Abstract Classes and Abstract Methods.

How to Implement Abstraction in Python?

Abstract Classes & Methods

Python provides abstraction using the ABC (Abstract Base Class) module from the abc package.

✔ Abstract Class = A class that cannot be instantiated (objects cannot be created from it).
✔ Abstract Method = A method without a body (only the declaration, no code inside).

Let’s see how we can create an abstract class:

Step 1: Creating an Abstract Class

from abc import ABC, abstractmethod
class Animal(ABC):  # Abstract class
@abstractmethod
def make_sound(self): # Abstract method (no implementation)
pass

✔ Animal is an abstract class.
✔ make_sound() is an abstract method (It has no body, just pass).
✔ We cannot create an object of Animal.

Step 2: Creating Concrete Classes

To use the abstract class, we must create child classes that implement the abstract methods.

class Dog(Animal):
def make_sound(self):
print("Woof! Woof!")
class Cat(Animal):
def make_sound(self):
print("Meow! Meow!")
# Creating objects
dog = Dog()
cat = Cat()
dog.make_sound() # Output: Woof! Woof!
cat.make_sound() # Output: Meow! Meow!

✔ Child classes (Dog, Cat) must implement make_sound().
✔ Now, we can create objects of Dog and Cat, but not Animal.

Why Can’t We Create Objects of an Abstract Class?

If we try:

animal = Animal()  # ❌ ERROR!

🔴 Output: TypeError: Can't instantiate abstract class Animal with abstract methods make_sound

Get Yashnarkhede’s stories in your inbox

Join Medium for free to get updates from this writer.

Why?

  • Abstract classes are just blueprints.
  • They cannot be used directly.
  • Only their child classes can be instantiated.

Real-World Use Case: Payment System

Scenario:

We need a payment system with different payment methods (Credit Card, PayPal). Each payment method should have its own logic, but we should not allow a generic payment object.

Step 1: Create an Abstract Class

from abc import ABC, abstractmethod
class Payment(ABC):
@abstractmethod
def pay(self, amount):
pass

✔ Payment is an abstract class. ✔ pay() is an abstract method (No implementation).

Step 2: Create Concrete Payment Classes

class CreditCardPayment(Payment):
def pay(self, amount):
print(f"Paid {amount} using Credit Card.")
class PayPalPayment(Payment):
def pay(self, amount):
print(f"Paid {amount} using PayPal.")

✔ Each class implements its own payment logic.

Step 3: Using the Payment System

payment1 = CreditCardPayment()
payment1.pay(1000) # Output: Paid 1000 using Credit Card.
payment2 = PayPalPayment()
payment2.pay(2000) # Output: Paid 2000 using PayPal.

✔ Users don’t need to worry about internal implementation — they just call pay().

Difference Between Abstraction & Encapsulation

Feature Abstraction Encapsulation Definition Hides implementation details Hides data (Restricts direct access) Goal Show only essential features Protect data integrity Example make_sound() → You don’t see the logic inside __balance → Hidden bank balance Where Used? Abstract classes & methods Private attributes & methods Key Benefit Simplifies complex systems Prevents accidental changes

Another Real-World Example: Car

Scenario:

You drive a car:

  • You use the steering wheel to turn.
  • You don’t see the complex mechanisms inside (engine, gears).

Step 1: Create an Abstract Class

from abc import ABC, abstractmethod

class Car(ABC):
@abstractmethod
def start_engine(self):
pass

@abstractmethod
def drive(self):
pass

Step 2: Implement Specific Car Types

class Tesla(Car):
def start_engine(self):
print("Tesla engine started silently.")

def drive(self):
print("Tesla is driving on autopilot mode.")
class BMW(Car):
def start_engine(self):
print("BMW engine started with a roar.")

def drive(self):
print("BMW is driving manually.")

✔ Different implementations, same interface (Car).

Conclusion

Abstraction is a powerful concept in Python that hides complex details and only exposes the necessary functionalities. It is achieved using Abstract Classes and Abstract Methods.

✅ When to Use Abstraction?

  • When you want to force all child classes to implement specific methods.
  • When you want to hide complex logic and expose only necessary methods.
  • When you want to define a standard structure (like a template).

By using abstraction effectively, you can write clean, maintainable, and scalable Python programs! 🚀

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

Magic Methods & Dunder Methods

— — — — — — — — — — — — — — — — — — — — — — — — — -

Here is the list format of the magic methods:

Object Initialization & Destruction

  • __init__ - Constructor, initializes a new instance.
  • __new__ - Controls the creation of a new instance.
  • __del__ - Destructor, called when an object is deleted.

String Representation

  • __str__ - Returns a user-friendly string representation (str(obj)).
  • __repr__ - Returns an official string representation (repr(obj)).

Comparison Operators

  • __eq__ - Implements equality (==).
  • __ne__ - Implements inequality (!=).
  • __lt__ - Implements less than (<).
  • __le__ - Implements less than or equal (<=).
  • __gt__ - Implements greater than (>).
  • __ge__ - Implements greater than or equal (>=).

Arithmetic Operators

  • __add__ - Implements addition (+).
  • __sub__ - Implements subtraction (-).
  • __mul__ - Implements multiplication (*).
  • __truediv__ - Implements division (/).
  • __floordiv__ - Implements floor division (//).
  • __mod__ - Implements modulo (%).
  • __pow__ - Implements power (**).

Bitwise Operators

  • __and__ - Implements bitwise AND (&).
  • __or__ - Implements bitwise OR (|).
  • __xor__ - Implements bitwise XOR (^).
  • __lshift__ - Implements left shift (<<).
  • __rshift__ - Implements right shift (>>).

Unary Operators

  • __neg__ - Implements unary negation (-obj).
  • __pos__ - Implements unary plus (+obj).
  • __invert__ - Implements bitwise inversion (~obj).

Container Methods

  • __len__ - Returns the length of an object (len(obj)).
  • __getitem__ - Retrieves an item (obj[key]).
  • __setitem__ - Sets an item (obj[key] = value).
  • __delitem__ - Deletes an item (del obj[key]).
  • __iter__ - Returns an iterator (iter(obj)).
  • __next__ - Returns the next item in an iterator (next(obj)).
  • __contains__ - Checks membership (value in obj).

Callable Objects

  • __call__ - Makes an instance callable (obj()).

Attribute Access

  • __getattr__ - Handles attribute access for undefined attributes.
  • __setattr__ - Handles setting attributes.
  • __delattr__ - Handles deleting attributes.

Context Manager Methods

  • __enter__ - Handles entering a context (with obj).
  • __exit__ - Handles exiting a context.

Class & Static Methods

  • __class__ - Returns the class of an object.
  • __bases__ - Returns base classes of a class.
  • __mro__ - Returns method resolution order.
  • __subclasses__ - Returns a list of subclasses.

Custom Attribute Handling

  • __dir__ - Defines behavior for dir(obj).
  • __hash__ - Implements hashing (hash(obj)).
  • __bool__ - Defines boolean representation (bool(obj)).

Now lets see them one by one :-

1. __init__ Method

What is the __init__ Method?

If you’re just starting out with Python and Object-Oriented Programming (OOP), you might have come across the term __init__. It might look intimidating at first, but don't worry—it's actually quite simple!

Think of the __init__ method as the birth ceremony of an object. When you create (or "instantiate") an object from a class, Python automatically calls this __init__ method to set things up. This is where you define the initial characteristics (or attributes) of the object.

Why Do We Need __init__?

Imagine you’re building a toy factory. Every toy needs some initial features, like a name, color, and size. If we didn’t have an __init__ method, we would have to manually set up each toy’s properties every time we create a new one. That would be repetitive and inefficient.

The __init__ method helps us automate this process by initializing object attributes as soon as the object is created.

Breaking It Down Step-by-Step

Step 1: Creating a Simple Class

Before understanding __init__, let’s create a basic class without it.

class Toy:
pass

# Creating an object
my_toy = Toy()

This code defines a class called Toy. However, it doesn’t do anything special yet. If we try to print my_toy, it won’t have any useful information.

Step 2: Adding an __init__ Method

Now, let’s add an __init__ method to our Toy class.

class Toy:
def __init__(self, name, color, size):
self.name = name # Assigning name to the object
self.color = color # Assigning color to the object
self.size = size # Assigning size to the object

# Creating an object with specific attributes
my_toy = Toy("Teddy Bear", "Brown", "Medium")

Step 3: Understanding What Happens

Let’s break down what happens when we create my_toy = Toy("Teddy Bear", "Brown", "Medium"):

  1. Python sees Toy("Teddy Bear", "Brown", "Medium") and calls the __init__ method automatically.
  2. Inside __init__, the parameters (name, color, size) get assigned to the object.
  3. self.name = "Teddy Bear" means that our toy object now has a name attribute.
  4. self.color = "Brown" means the toy object has a color.
  5. self.size = "Medium" means the toy object has a size.

What is self?

You might be wondering, “What is self?" Let’s break it down:

  • self is a reference to the current object being created.
  • It allows each object to store its own unique values.
  • Without self, Python wouldn’t know which object you’re referring to inside the class.

For example, if we create another toy:

another_toy = Toy("Race Car", "Red", "Small")

Now we have two different toys, each with its own attributes!

Accessing Object Attributes

Once an object is created, we can access its attributes using the dot (.) operator.

print(my_toy.name)  # Output: Teddy Bear
print(my_toy.color) # Output: Brown
print(my_toy.size) # Output: Medium

Each object keeps track of its own data separately.

The Magic of __init__: Automating Object Creation

Without __init__, we would have to manually set attributes like this:

my_toy = Toy()
my_toy.name = "Teddy Bear"
my_toy.color = "Brown"
my_toy.size = "Medium"

That’s a lot of extra typing! The __init__ method saves time and prevents errors.

Default Values in __init__

We can also set default values in case the user doesn’t provide any.

class Toy:
def __init__(self, name="Unknown", color="Unknown", size="Unknown"):
self.name = name
self.color = color
self.size = size

# Creating an object without arguments
mystery_toy = Toy()
print(mystery_toy.name) # Output: Unknown

If no values are given, Python will use the default ones.

Summary: Why __init__ is Important

  • It automatically runs when creating an object.
  • It initializes attributes (properties) for each object.
  • It saves time and reduces repetitive code.
  • It allows objects to store unique data.

Now, whenever you create a class in Python, remember to use __init__ to set up your objects efficiently. It’s the foundation of Object-Oriented Programming in Python!

2. __new__ Magic Method in Python

Introduction

In Python, object creation happens in two distinct steps: memory allocation and initialization. While most developers are familiar with __init__, the __new__ method is lesser-known but equally important. This blog will explore __new__ in depth, explaining its role, use cases, and best practices.

What is __new__?

The __new__ method is a special static method in Python that is responsible for creating a new instance of a class. It is the first step in the instance creation process, occurring before __init__. Unlike __init__, which sets up an instance, __new__ is responsible for allocating memory for the new object and returning it.

How __new__ Works

Python has two major steps when creating an object:

  1. Creation (__new__): This step is responsible for allocating memory and returning the instance.
  2. Initialization (__init__): This step sets up the attributes of the instance once it is created.

The __new__ method is defined as follows:

class MyClass:
def __new__(cls, *args, **kwargs):
print("Creating the object...")
instance = super().__new__(cls)
return instance

def __init__(self, value):
print("Initializing the object...")
self.value = value


obj = MyClass(10)

Output:

Creating the object...
Initializing the object...

Breakdown of Execution:

  • __new__ is a static method that takes the class (cls) as its first parameter.
  • It calls super().__new__(cls), which delegates memory allocation to the base class (typically object).
  • It must return the newly created instance; otherwise, __init__ won’t be called.
  • Once the instance is returned, __init__ initializes its attributes.

When Should You Override __new__?

You rarely need to override __new__, but it is useful in the following scenarios:

1. Singleton Pattern

A singleton ensures that only one instance of a class exists throughout the application. This can be achieved using __new__:

class Singleton:
_instance = None

def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

obj1 = Singleton()
obj2 = Singleton()
print(obj1 is obj2) # True, same instance

2. Controlling Immutable Object Creation

Since immutable objects like tuples and strings cannot be modified after creation, __new__ is used to control their creation:

class CustomTuple(tuple):
def __new__(cls, iterable):
print("Creating a custom tuple")
return super().__new__(cls, iterable)


obj = CustomTuple([1, 2, 3])
print(obj)

3. Subclassing Built-in Types

When subclassing immutable built-in types, you must use __new__ because __init__ won’t be able to modify the object:

class UppercaseString(str):
def __new__(cls, value):
return super().__new__(cls, value.upper())

obj = UppercaseString("hello")
print(obj) # Output: HELLO

Key Differences Between __new__ and __init__

Feature __new__ __init__ When it runs Before instance creation After instance creation Purpose Allocates memory for a new object Initializes the object’s attributes Return type Must return a new instance Does not return anything Used for Controlling object creation Setting up attributes

Best Practices for Using __new__

  • Always return an instance from __new__, or object creation will fail.
  • Call super().__new__(cls, *args, **kwargs) to ensure proper memory allocation.
  • Only override __new__ when necessary, such as for singletons or immutable objects.
  • Use __init__ for instance attribute initialization instead of __new__.

Conclusion

The __new__ method is a powerful but rarely needed tool in Python. It allows fine control over object creation and is useful in specialized cases such as singletons, immutable objects, and subclassing built-in types. While most developers can rely on __init__, understanding __new__ can help in advanced Python programming and optimization.

By mastering __new__, you gain deeper insight into Python’s object-oriented internals and can craft more efficient and controlled object creation strategies! 🚀

3. __del__ Method in Python

Introduction

If you’ve ever played with toys, you know that when you’re done playing, you put them back in the toy box or throw them away. The same thing happens in Python! When Python is done using an object, it cleans it up to free memory. This cleanup process is called garbage collection. The special method __del__ helps Python do this!

In this blog, we will dive deep into __del__, how it works, when to use it, and things to be careful about.

What is __del__?

In Python, __del__ is a special method, also called a destructor. It is automatically called when an object is deleted or garbage collected.

Think of __del__ as a little goodbye message from an object before it disappears!

Syntax of __del__

class MyClass:
def __del__(self):
print("Goodbye! The object is being deleted.")

Whenever Python decides an object is no longer needed, it calls __del__, and you see the message.

When is __del__ Called?

Python calls the __del__ method when an object no longer has references. This means that when nothing in the program is pointing to an object, Python removes it from memory.

Example 1: Simple Deletion

class Example:
def __del__(self):
print("Object is being deleted!")

obj = Example()
del obj # This will call __del__

Example 2: Automatic Deletion (Garbage Collection)

def create_object():
obj = Example()
print("Object created!")

create_object()
# The object is deleted automatically when the function ends

Here, the object exists only inside the function. Once the function finishes, Python deletes the object since nothing is referencing it anymore.

The Role of Garbage Collection

Python has a garbage collector that automatically deletes objects when they are no longer needed. The __del__ method is tied to this process.

Example: Checking When Garbage Collection Happens

import gc

class Example:
def __del__(self):
print("Object is being garbage collected!")
def create():
obj = Example()


create()
# Force garbage collection
gc.collect()

Python usually deletes objects automatically, but you can force it using gc.collect().

What Happens if __del__ is Not Defined?

If you don’t define __del__, Python still cleans up objects. But if you need to do extra work before an object disappears, you can define __del__.

For example:

class FileHandler:
def __init__(self, filename):
self.file = open(filename, "w")
print("File opened.")

def __del__(self):
print("File closed.")
self.file.close()

Without __del__, the file might remain open, causing issues.

Problems with __del__

While __del__ is useful, it can cause problems if not used carefully.

1. Circular References

If two objects reference each other, Python might not call __del__, because both objects still have references!

class A:
def __init__(self, obj):
self.obj = obj

def __del__(self):
print("A deleted")

class B:
def __init__(self, obj):
self.obj = obj

def __del__(self):
print("B deleted")


obj_a = A(None)
obj_b = B(obj_a)
obj_a.obj = obj_b # Circular reference


del obj_a
del obj_b # __del__ might not be called due to circular reference!

To avoid this, use weak references with the weakref module.

import weakref


class Example:
def __del__(self):
print("Deleted!")


obj = Example()
weak_obj = weakref.ref(obj)


del obj # __del__ will be called properly

2. Exceptions in __del__

If an error occurs inside __del__, Python ignores it and moves on.

class Example:
def __del__(self):
print("Deleting...")
1 / 0 # This will cause an error, but Python ignores it

obj = Example()
del obj # You won't see any error, but the object is deleted

3. Objects with __del__ May Delay Program Exit

If an object with __del__ still exists when the program ends, Python might delay closing.

class Example:
def __del__(self):
print("Waiting...")
import time
time.sleep(3) # Delays the program exit by 3 seconds


obj = Example()

To prevent this, make sure objects are deleted before the program exits.

Best Practices for Using __del__

  1. Use __del__ only when necessary – Python’s garbage collector usually handles cleanup.
  2. Avoid circular references — Use weak references (weakref.ref) if needed.
  3. Handle resources properly — Instead of relying on __del__, use context managers (with statement) for files, database connections, etc.

For example, instead of using __del__, use:

with open("myfile.txt", "w") as file:
file.write("Hello!")
# File is automatically closed!

Conclusion

The __del__ method in Python helps clean up objects when they are deleted. However, it should be used carefully to avoid problems like circular references and unexpected delays. In most cases, Python’s garbage collector does the job, so you don’t always need __del__.

If you do use __del__, make sure to handle resources correctly and avoid common pitfalls. Happy coding!

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — -

4. __str__ method in Python

Understanding the __str__ Method in Python:

Introduction

Imagine you have a toy, and you want to show your friend what it looks like. You describe it using words. In Python, objects can also describe themselves in words using the special method __str__!

The __str__ method returns a human-friendly string representation of an object. When you call print(obj), Python automatically calls obj.__str__(), so you see a nice description instead of something confusing like <__main__.MyClass object at 0x7f8b6c2d7f10>.

In this blog, we will dive deep into __str__, how it works, when to use it, and best practices.

What is __str__?

The __str__ method is a special method in Python that provides a string representation of an object. It is meant to be user-friendly, helping humans understand the object easily.

Syntax of __str__

class MyClass:
def __str__(self):
return "This is a friendly string representation of MyClass."

Whenever print(obj) or str(obj) is called, Python automatically uses obj.__str__().

Example: Using __str__

Example 1: Basic __str__ Implementation

class Person:
def __init__(self, name, age):
self.name = name
self.age = age


def __str__(self):
return f"Person(name={self.name}, age={self.age})"


person = Person("Alice", 25)
print(person) # Output: Person(name=Alice, age=25)

Here, instead of printing an unreadable object reference, Python prints a nicely formatted string.

Example 2: Without __str__

If you don’t define __str__, Python prints something like this:

class Animal:
def __init__(self, species):
self.species = species


animal = Animal("Dog")
print(animal) # Output: <__main__.Animal object at 0x7f8b6c2d7f10>

This is not very helpful! Defining __str__ makes it user-friendly.

Difference Between __str__ and __repr__

Python has another similar method called __repr__. While __str__ is for users, __repr__ is for developers.

Method Purpose __str__ User-friendly, used with print(obj) or str(obj) __repr__ Developer-friendly, used in debugging (repr(obj))

Example:

class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model


def __str__(self):
return f"Car: {self.brand} {self.model}"

def __repr__(self):
return f"Car(brand='{self.brand}', model='{self.model}')"


car = Car("Tesla", "Model S")
print(car) # Output: Car: Tesla Model S
print(repr(car)) # Output: Car(brand='Tesla', model='Model S')

When to Use __str__

  • When you want to make objects more readable for users.
  • When using print(obj) or str(obj).
  • When generating user-friendly messages from objects.

Example: Logging with __str__

class Order:
def __init__(self, order_id, item):
self.order_id = order_id
self.item = item


def __str__(self):
return f"Order #{self.order_id}: {self.item}"


order = Order(101, "Laptop")
print(f"Processing {order}") # Output: Processing Order #101: Laptop

This makes logging and debugging much easier.

Common Mistakes with __str__

1. Forgetting to Return a String

class Example:
def __str__(self):
print("This is an object")

This will cause an error because __str__ must return a string, not print it!

✅ Correct way:

class Example:
def __str__(self):
return "This is an object"

2. Overcomplicating __str__

Keep __str__ simple and readable. Avoid dumping too much data.

Bad:

class Employee:
def __str__(self):
return str(vars(self)) # Dumps all attributes, hard to read

Good:

class Employee:
def __str__(self):
return f"Employee(Name: {self.name}, ID: {self.emp_id})"

Best Practices

  1. Always return a string in __str__.
  2. Keep it readable and concise.
  3. Use f-strings for easy formatting.
  4. Implement __repr__ too for debugging.

Conclusion

The __str__ method in Python provides a way to create human-friendly string representations of objects. This makes debugging and displaying objects much easier.

Whenever you define a custom class, consider implementing __str__ to improve readability. Also, don’t forget __repr__ for developer debugging.

Happy coding! 🚀

— — — — — — — — — — — — — — — — — — — — — — — — — -

5. __repr__

Understanding the __repr__ Method in Python: Explained Like a Baby

Introduction

Imagine you have a toy, and you want to show it to a scientist who needs precise details. Instead of saying “it’s a red car,” you say “it’s a model XYZ, made in 2023, with a 1:24 scale.” This is what __repr__ does in Python—it provides an official, unambiguous string representation of an object that is useful for debugging and recreating the object.

In this blog, we will dive deep into __repr__, how it works, when to use it, and best practices.

What is __repr__?

The __repr__ method is a special method in Python that returns a developer-friendly string representation of an object. This string should be unambiguous and ideally allow the object to be recreated.

Syntax of __repr__

class MyClass:
def __repr__(self):
return "MyClass()"

Whenever repr(obj) or just obj in an interactive Python session is called, Python automatically uses obj.__repr__().

Example: Using __repr__

Example 1: Basic __repr__ Implementation

class Person:
def __init__(self, name, age):
self.name = name
self.age = age

def __repr__(self):
return f"Person(name='{self.name}', age={self.age})"


person = Person("Alice", 25)
print(repr(person)) # Output: Person(name='Alice', age=25)

Here, repr(person) prints an unambiguous representation that helps in debugging.

Example 2: Without __repr__

If you don’t define __repr__, Python falls back to a default representation:

class Animal:
def __init__(self, species):
self.species = species

animal = Animal("Dog")
print(repr(animal)) # Output: <__main__.Animal object at 0x7f8b6c2d7f10>

This is not very useful for debugging! Defining __repr__ makes it better.

Difference Between __repr__ and __str__

Python has another method called __str__. The key difference is:

Method Purpose __repr__ Developer-friendly, unambiguous, used in debugging (repr(obj)) __str__ User-friendly, used with print(obj) or str(obj)

Example:

class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model

def __repr__(self):
return f"Car(brand='{self.brand}', model='{self.model}')"

def __str__(self):
return f"Car: {self.brand} {self.model}"

car = Car("Tesla", "Model S")
print(car) # Output: Car: Tesla Model S
print(repr(car)) # Output: Car(brand='Tesla', model='Model S')

When to Use __repr__

  • When you need an exact representation of an object.
  • When writing debugging logs.
  • When implementing data models that need precise reconstruction.

Example: Debugging with __repr__

class Order:
def __init__(self, order_id, item):
self.order_id = order_id
self.item = item


def __repr__(self):
return f"Order(order_id={self.order_id}, item='{self.item}')"


order = Order(101, "Laptop")
print(f"Debugging: {repr(order)}") # Output: Debugging: Order(order_id=101, item='Laptop')

This makes debugging much easier.

Common Mistakes with __repr__

1. Forgetting to Return a String

class Example:
def __repr__(self):
print("This is an object")

This will cause an error because __repr__ must return a string, not print it!

✅ Correct way:

class Example:
def __repr__(self):
return "This is an object"

2. Not Making __repr__ Unambiguous

Bad:

class Employee:
def __repr__(self):
return f"Employee: {self.name}, {self.emp_id}"

Good:

class Employee:
def __repr__(self):
return f"Employee(name='{self.name}', emp_id={self.emp_id})"

Best Practices

  1. Always return a string in __repr__.
  2. Make it unambiguous — it should help recreate the object.
  3. Use f-strings for formatting.
  4. Implement __str__ as well if users need a friendly version.

Conclusion

The __repr__ method in Python provides a way to create developer-friendly, unambiguous string representations of objects. This is essential for debugging and logging.

Whenever you define a custom class, consider implementing __repr__ to improve debugging, and pair it with __str__ for user-friendly representations.

Happy coding! 🚀

— — — — — — — — — — —

--

--