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.

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.
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.
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):
passd = Duck()
print(d.fly(), d.swim())
Output:
Flying Swimming
This shows how a single class can use behavior from two different parents.
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.
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):
passclass C(A):
passclass D(B, C):
passobj = 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.
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.
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.
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.
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.
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.
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!
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.”
Object-oriented design often requires choosing between inheritance and composition, and understanding the difference is essential for writing clean, maintainable code.
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.
class Vehicle:
def start(self):
return "Vehicle starting..."class Car(Vehicle):
passc = Car()
print(c.start())
Output:
Vehicle starting...
The Car inherits the behavior from Vehicle because a car is a vehicle.
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.
class Engine:
def start(self):
return "Engine started."class Car:
def __init__(self):
self.engine = Engine() # Car HAS-A Enginedef 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.
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.
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.
Called when you print an object.
class Person:
def __init__(self, name):
self.name = namedef __str__(self):
return f"Person: {self.name}"p = Person("Aarav")
print(p)
Output:
Person: Aarav
Used by developers—returns a string that can recreate the object.
class Point:
def __init__(self, x, y):
self.x = x
self.y = ydef __repr__(self):
return f"Point({self.x}, {self.y})"p = Point(2, 3)
print(repr(p))
Output:
Point(2, 3)
Operator overloading lets us define how objects behave with operators like +, -, ==, <, etc. This makes custom classes behave similar to standard types.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = ydef __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.
| Method | Purpose |
|---|---|
| __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.
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() 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.
class Animal:
passclass Dog(Animal):
passd = 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() checks relationships between classes, not objects. It tells whether one class is derived (inherited) from another.
class A:
passclass B(A):
passprint(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.
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.
@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().
A property typically has:
Optional deleter
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 = valuec = 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
Properties allow you to:
For example:
obj.temp is cleaner than obj.get_temp() or obj.set_temp(value).
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.
A dataclass is a class decorated with @dataclass that automatically creates:
You define only the fields, and Python generates all the boring parts for you.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: intp = 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.
You can also assign default values easily:
@dataclass
class Student:
name: str
grade: int = 1 # default values = Student("Aarav")
print(s)
Output
Student(name='Aarav', grade=1)
The class remains clean and readable while automatically gaining all standard behavior.
Dataclasses are widely used in modern Python projects such as APIs, models, configurations, and game data structures.