1. Inheritance

Inheritance is an OOP mechanism that allows one class (called the child or derived class) to acquire the properties and behaviors of another class (called the parent or base class). This lets you reuse existing code, avoid repetition, and build logical hierarchies of classes.

Inheritance creates an “is-a” relationship.
For example:

A Dog is a Animal

A Car is a Vehicle

By inheriting from a parent class, the child class automatically gets access to the parent’s attributes and methods. It can also override methods to provide more specific behavior.

Inheritance is one of the strongest tools for organizing and expanding large applications, especially when multiple related classes share common functionality.

1.1 Single Inheritance

Single inheritance occurs when a child class inherits from just one parent class.
This is the most common form of inheritance and is useful when you want to extend or customize the behavior of a single base class.

class Animal:
   def speak(self):
       return "Animal makes a sound"

class Dog(Animal):
   def speak(self):
       return "Dog barks"

d = Dog()
print(d.speak())

Output:

Dog barks

The Dog class inherits from Animal and overrides the speak() method to provide more specific behavior.

1.2 Multi-Level Inheritance

Multi-level inheritance forms a chain of inheritance where a class inherits from another derived class.

A → B → C
This means C inherits from B, and B inherits from A, so C indirectly inherits from A.

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

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

class C(B):
   def show(self):
       return "Class C"

obj = C()
print(obj.show())

Output:

Class C

Here, class C inherits from B and indirectly from A.

1.3 Multiple Inheritance

Multiple inheritance allows a class to inherit from more than one parent class.
It is powerful but must be used carefully to avoid confusion, especially when parents share method names.

class Flyer:
   def fly(self):
       return "Flying"

class Swimmer:
   def swim(self):
       return "Swimming"

class Duck(Flyer, Swimmer):
   pass

d = Duck()
print(d.fly(), d.swim())

Output:

Flying Swimming

This shows how a single class can use behavior from two different parents.

1.4 Hierarchical Inheritance

Hierarchical inheritance occurs when multiple child classes inherit from the same parent class. This allows different subclasses to share common functionality from one base class while still providing their own specialized behaviors.

class Animal:
   def sound(self):
       return "Some generic sound"

class Dog(Animal):
   def sound(self):
       return "Bark"

class Cat(Animal):
   def sound(self):
       return "Meow"

d = Dog()
c = Cat()

print(d.sound())
print(c.sound())

Output:

Bark
Meow

Here, Dog and Cat both inherit from Animal, demonstrating how one parent can support several kinds of children.

1.5 Hybrid Inheritance

Hybrid inheritance is a combination of multiple types of inheritance, often involving both multiple and multilevel inheritance.
This creates more complex class structures — similar to real-world systems where multiple hierarchies mix.

class A:
   def show(self):
       return "A"

class B(A):
   pass

class C(A):
   pass

class D(B, C):
   pass

obj = D()
print(obj.show())

Output:

A

This example mixes hierarchical inheritance (B and C inherit from A) and multiple inheritance (D inherits from both B and C), creating a hybrid structure.

1.6 Method Resolution Order (MRO)

When multiple inheritance is used, Python must decide which parent class to search first when calling a method.
This order is known as the Method Resolution Order (MRO) and follows the C3 Linearization algorithm.

You can view the order using:

print(Duck.__mro__)

or:

print(Duck.mro())

MRO ensures that Python resolves methods in a predictable and consistent order, avoiding confusion when different parents define the same method.

2. Method Overriding and super()

What is Method Overriding?

Method overriding happens when a child class defines a method with the same name as a method in its parent class but provides a new implementation. This allows the child class to modify or extend the behavior inherited from its parent. Overriding is essential for customizing behavior while still reusing existing logic from the parent class.

Using super()

The super() function is used inside a child class to call a method from the parent class. This helps combine both parent and child functionality instead of completely replacing the parent’s method. It is commonly used in constructors and overridden methods for extending behavior.

Example: Overriding with super()

class Vehicle:
   def start(self):
       print("Starting the vehicle...")

class Car(Vehicle):
   def start(self):
       super().start()    # Call parent method
       print("Car engine started.")

c = Car()
c.start()

Output:

Starting the vehicle...
Car engine started.

This shows how the child class extends the parent functionality by calling it through super() and adding its own behavior.

3. Polymorphism

Polymorphism allows different classes to define methods with the same name but different behaviors, and Python will call the correct method based on the object being used. This gives flexibility because you can write general-purpose code that works with many different types of objects as long as they share method names.

Polymorphism helps when working with collections of objects where each object may behave differently, but you want to interact with them using the same function or method name. It also makes code extensible because new classes can be added later without modifying existing logic.

Example: Same Method Name, Different Classes

class Dog:
   def speak(self):
       return "Woof!"

class Cat:
   def speak(self):
       return "Meow!"

def animal_sound(animal):
   print(animal.speak())

animal_sound(Dog())
animal_sound(Cat())

Output:

Woof!
Meow!

Duck Typing in Python

Python follows the concept of “duck typing,” which means Python does not check the actual type of an object as long as the object has the required method. If an object has a method with the right name, Python will call it successfully — regardless of the class it belongs to. This makes polymorphism very natural and flexible in Python.

class Human:
   def speak(self):
       return "Hello!"

animal_sound(Human())

Output:

Hello!

Here, even though Human is not an “animal,” Python accepts it because it has a speak() method. This is the essence of duck typing — “If it walks like a duck and quacks like a duck, Python treats it like a duck.”

4. Composition vs Inheritance

Object-oriented design often requires choosing between inheritance and composition, and understanding the difference is essential for writing clean, maintainable code.

Inheritance

Inheritance is used when one class is a specialized version of another. This forms an “is-a” relationship. A Car is a Vehicle, a Dog is an Animal, and so on. Inheritance reuses code from the parent class, but it also creates a tight connection because the child depends heavily on the parent’s structure.

Example (Inheritance):

class Vehicle:
   def start(self):
       return "Vehicle starting..."

class Car(Vehicle):
   pass

c = Car()
print(c.start())

Output:

Vehicle starting...

The Car inherits the behavior from Vehicle because a car is a vehicle.

Composition

Composition means one class contains another class inside it. This forms a “has-a” relationship. For example, a Car has an Engine, a Computer has a CPU, a Student has an Address. Composition keeps classes flexible and avoids deep inheritance chains, which is why it is preferred in many real-world projects.

Example (Composition):

class Engine:
   def start(self):
       return "Engine started."

class Car:
   def __init__(self):
       self.engine = Engine()  # Car HAS-A Engine

   def start(self):
       return self.engine.start() + " Car is ready."

c = Car()
print(c.start())

Output:

Engine started. Car is ready.

Here, the car's behavior depends on the engine object it owns, not on inheritance.

When to Use What?

Use inheritance when classes genuinely follow an IS-A relationship and share behavior.
Use composition when you want flexibility, reuse, and loose coupling — especially when building large systems.

5. Special (Dunder) Methods & Operator Overloading

Special methods, also known as dunder methods (double underscore methods), allow your objects to behave like built-in Python types. These methods start and end with __ (like __init__, __str__, etc.). Python automatically calls these methods in response to specific operations. By implementing them, you can customize how your objects behave when printed, compared, added, multiplied, iterated, or used with operators.

These methods give your classes a more natural, Pythonic feel and make objects easier to debug, read, and work with in real-world programs.

Common Special Methods

__str__() → Human-readable representation

Called when you print an object.

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

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

p = Person("Aarav")
print(p)

Output:

Person: Aarav

__repr__() → Official representation

Used by developers—returns a string that can recreate the object.

class Point:
   def __init__(self, x, y):
       self.x = x
       self.y = y

   def __repr__(self):
       return f"Point({self.x}, {self.y})"

p = Point(2, 3)
print(repr(p))

Output:

Point(2, 3)

Operator Overloading with Dunder Methods

Operator overloading lets us define how objects behave with operators like +, -, ==, <, etc. This makes custom classes behave similar to standard types.

Example: Overloading the + Operator

class Vector:
   def __init__(self, x, y):
       self.x = x
       self.y = y

   def __add__(self, other):
       return Vector(self.x + other.x, self.y + other.y)

   def __repr__(self):
       return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2
print(v3)

Output:

Vector(4, 6)

This is similar to how numbers add, but now your custom objects can behave the same way.

Other Useful Dunder Methods

MethodPurpose
__len__Defines behavior for len(obj)
__eq__                    Equality (==) comparison
__lt__Less-than comparison (<)
__mul__Multiplication (*)
__getitem__Indexing (obj[index])
__iter__Allow iteration over the object

Having these in your class improves usability and integrates your class smoothly with Python’s features.

6. Type Checking (isinstance() and issubclass())

Type checking helps you confirm whether an object belongs to a specific class or whether a class inherits from another class. This is especially useful in large OOP systems where objects interact with each other, and you need to validate input for safety or debugging.

Python provides two built-in functions for this:

isinstance() — Check if an object is an instance of a class

isinstance() is used when you want to verify if a given object was created from a particular class or any of its parent classes. This helps prevent errors in functions that expect certain types of objects.

Example

class Animal:
   pass

class Dog(Animal):
   pass

d = Dog()

print(isinstance(d, Dog))       # True
print(isinstance(d, Animal))    # True
print(isinstance(d, object))    # True

Output

True
True
True

This shows: if a class inherits another, isinstance() returns True for both.

issubclass() — Check if one class inherits from another

issubclass() checks relationships between classes, not objects. It tells whether one class is derived (inherited) from another.

Example

class A:
   pass

class B(A):
   pass

print(issubclass(B, A))    # True
print(issubclass(A, B))    # False
print(issubclass(B, object))  # True

Output

True
False
True

Every class in Python is ultimately a subclass of object.

Using these two functions helps build safer and more predictable OOP applications, especially when writing frameworks, validations, APIs, or working with inheritance-based architectures.

7. Properties (@property, Getter, Setter)

In traditional OOP languages, getters and setters are used to control how attributes are accessed or modified. Python provides a much cleaner and more elegant approach using the @property decorator. It allows you to use methods as if they were simple attributes, while still keeping control over validation, logic, or restrictions behind the scenes.

Properties let you hide internal implementation while keeping a simple interface. This helps maintain encapsulation and prevents direct modification of sensitive data.

What is @property?

@property converts a method into a "read-only attribute." It allows the user to access an internal value without calling a function explicitly. It improves readability, making code more natural, like accessing .name instead of calling .get_name().

Using Getter and Setter with @property

A property typically has:

  • A getter → retrieves a value
  • A setter → validates or modifies the value before assigning

Optional deleter

Example: Using @property with Validation

class Celsius:
   def __init__(self, temp=0):
       self._temp = temp

   @property
   def temp(self):
       return self._temp

   @temp.setter
   def temp(self, value):
       if value < -273.15:     # absolute zero
           raise ValueError("Temperature below absolute zero is impossible")
       self._temp = value

c = Celsius(25)
print(c.temp)

c.temp = 30
print(c.temp)

Output

25
30

If someone tries to assign an impossible temperature:

c.temp = -300

It raises:

ValueError: Temperature below absolute zero is impossible

Why Properties Are Useful

Properties allow you to:

  • Protect internal variables
  • Add validation without changing the external interface
  • Keep code simpler and more readable
  • Replace messy getter/setter functions with clean attribute-style access

For example:

obj.temp is cleaner than obj.get_temp() or obj.set_temp(value).

8. Dataclasses (@dataclass)

In many programs, you create classes whose main purpose is to store data. These classes often contain an __init__ method, a __repr__ method for printing, and sometimes comparison methods.
Writing all this manually becomes repetitive and increases boilerplate code.

Python solves this problem with dataclasses — a feature that automatically generates these common methods for you.

A dataclass focuses on storing data cleanly while keeping your code short, readable, and professional.

What is a Dataclass?

A dataclass is a class decorated with @dataclass that automatically creates:

  • __init__() → constructor
  • __repr__() → readable string representation
  • __eq__() → comparison support
  • Optional: ordering methods (<, >, <=, >=)

You define only the fields, and Python generates all the boring parts for you.

Basic Dataclass Example

from dataclasses import dataclass

@dataclass
class Point:
   x: int
   y: int

p = Point(3, 4)
print(p)

Output

Point(x=3, y=4)

Without dataclasses you would have needed to write an entire constructor and representation manually.

Dataclasses with Default Values

You can also assign default values easily:

@dataclass
class Student:
   name: str
   grade: int = 1   # default value

s = Student("Aarav")
print(s)

Output

Student(name='Aarav', grade=1)

The class remains clean and readable while automatically gaining all standard behavior.

Why Dataclasses Are Useful

  • They eliminate repeated boilerplate
  • They make data-focused classes cleaner
  • They improve readability and maintainability
  • They give you ready-made methods without manually coding them
  • They integrate well with type hints

Dataclasses are widely used in modern Python projects such as APIs, models, configurations, and game data structures.