Introduction
Object-Oriented Programming (OOP) is a programming approach that organizes programs around objects and classes. An object combines data (attributes) and behavior (methods) into a single unit.
Python has strong support for object-oriented programming. In fact, many things you use in Python—including integers, strings, lists, dictionaries, functions, and classes—are objects.
For example:
1name = "Ankit" 2 3print(type(name))
Output:
1<class 'str'>
The string "Ankit" is an object created from Python's built-in str class.
OOP becomes especially useful when applications become larger and need code that is reusable, organized, maintainable, and easy to extend.
In this module, you will learn:
- Classes and objects
- Constructors
self- Instance and class attributes
- Instance, class, and static methods
- Inheritance
- Multiple inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Method overriding
- Method overloading alternatives
- Magic methods
- Practical OOP projects
- Common OOP mistakes and best practices
Why Use Object-Oriented Programming?
OOP helps you organize related data and functionality together.
For example, a student can have:
- Name
- Roll number
- Marks
and can perform actions such as:
- Display information
- Calculate percentage
- Check whether the student passed
Instead of managing everything with separate variables and functions, OOP allows you to model the student as an object.
Main Benefits of OOP
| Benefit | Description |
|---|---|
| Reusability | Classes can be reused throughout a program |
| Organization | Related data and behavior stay together |
| Maintainability | Large programs become easier to modify |
| Extensibility | Existing classes can be extended using inheritance |
| Encapsulation | Implementation details can be controlled |
| Real-world modeling | Real-world entities can be represented as objects |
Class
A class is a blueprint used to create objects.
Think of a class like a blueprint for a house:
1Blueprint → Class 2Actual House → Object
A class defines what an object should contain and what it can do.
Basic Syntax
1class Student: 2 pass
The pass statement means that the class currently has no implementation.
You can inspect the class using:
1class Student: 2 pass 3 4print(Student)
Output will look similar to:
1<class '__main__.Student'>
Object
An object is an instance of a class.
You create an object by calling the class:
1class Student: 2 pass 3 4student1 = Student() 5student2 = Student() 6 7print(type(student1)) 8print(type(student2))
Output:
1<class '__main__.Student'> 2<class '__main__.Student'>
Although both objects were created from the same class, they are separate objects.
1print(student1 is student2)
Output:
1False
Constructor and __init__()
The __init__() method is commonly used to initialize an object's attributes when the object is created.
For example:
1class Student: 2 3 def __init__(self, name, age): 4 self.name = name 5 self.age = age
Now create an object:
1student = Student("Ankit", 23) 2 3print(student.name) 4print(student.age)
Output:
1Ankit 223
When this statement runs:
1Student("Ankit", 23)
Python creates a new object and then calls __init__() to initialize it.
Important Note
Strictly speaking, __init__() is an initializer, not the method that actually creates the object. Object creation is handled by __new__(), while __init__() initializes the newly created object.
For beginners, it is common to refer to __init__() as the constructor because it is the method normally used to initialize objects.
The self Parameter
self refers to the current instance of the class.
Consider:
1class Student: 2 3 def __init__(self, name, age): 4 self.name = name 5 self.age = age
Here:
1self.name
means the name attribute belonging to the current object.
For example:
1student1 = Student("Ankit", 23) 2student2 = Student("Rahul", 22) 3 4print(student1.name) 5print(student2.name)
Output:
1Ankit 2Rahul
Each object has its own instance attributes.
How self Works
When you write:
1student1.show()
Python effectively passes student1 as the first argument to the method.
Conceptually:
1Student.show(student1)
You normally do not pass self manually when calling a method.
Instance Attributes
Instance attributes belong to individual objects.
They are usually created using self.
1class Student: 2 3 def __init__(self, name, marks): 4 self.name = name 5 self.marks = marks 6 7 8student1 = Student("Ankit", 92) 9student2 = Student("Rahul", 85) 10 11print(student1.name) 12print(student2.name)
Output:
1Ankit 2Rahul
Each object has its own name and marks.
You can also modify an instance attribute:
1student1.marks = 95 2 3print(student1.marks)
Output:
195
Class Attributes
A class attribute belongs to the class and is shared by instances unless an instance provides an attribute with the same name.
1class Student: 2 3 college = "ABC College" 4 5 def __init__(self, name): 6 self.name = name 7 8 9student1 = Student("Ankit") 10student2 = Student("Rahul") 11 12print(student1.college) 13print(student2.college)
Output:
1ABC College 2ABC College
A class attribute is useful when a value should be common across objects.
For example:
1class Employee: 2 3 company = "TechCorp" 4 5 def __init__(self, name): 6 self.name = name
Changing a Class Attribute
Use the class when you want to change the shared value:
1Employee.company = "NewTech" 2 3print(Employee.company)
Be careful with:
1employee.company = "OtherCompany"
This creates or changes an instance attribute for that particular object rather than changing the class attribute for everyone.
Methods
A method is a function defined inside a class.
Methods usually operate on the object's data.
1class Student: 2 3 def __init__(self, name): 4 self.name = name 5 6 def greet(self): 7 print(f"Hello, {self.name}!") 8 9 10student = Student("Ankit") 11student.greet()
Output:
1Hello, Ankit!
Methods That Return Values
Methods do not have to print results. In reusable code, returning values is often better.
1class Calculator: 2 3 def add(self, a, b): 4 return a + b 5 6 def multiply(self, a, b): 7 return a * b 8 9 10calculator = Calculator() 11 12print(calculator.add(10, 20)) 13print(calculator.multiply(5, 6))
Output:
130 230
Returning values allows other parts of your program to reuse the result.
Instance Methods
An instance method operates on an individual object and normally receives self.
1class BankAccount: 2 3 def __init__(self, owner, balance=0): 4 self.owner = owner 5 self.balance = balance 6 7 def deposit(self, amount): 8 self.balance += amount 9 10 def get_balance(self): 11 return self.balance 12 13 14account = BankAccount("Ankit", 5000) 15 16account.deposit(1000) 17 18print(account.get_balance())
Output:
16000
Inheritance
Inheritance allows a child class to reuse attributes and methods from a parent class.
The basic relationship is:
1Parent Class 2 ↓ 3Child Class
Example:
1class Animal: 2 3 def sound(self): 4 print("Some animal sound") 5 6 7class Dog(Animal): 8 pass 9 10 11dog = Dog() 12dog.sound()
Output:
1Some animal sound
Dog inherits the sound() method from Animal.
Extending a Parent Class
A child class can add its own methods.
1class Person: 2 3 def introduce(self): 4 print("I am a person") 5 6 7class Student(Person): 8 9 def study(self): 10 print("I study Python") 11 12 13student = Student() 14 15student.introduce() 16student.study()
Output:
1I am a person 2I study Python
The Student class receives the behavior of Person and adds its own behavior.
Using super()
The super() function allows a child class to access functionality from its parent class.
1class Person: 2 3 def __init__(self, name): 4 self.name = name 5 6 7class Student(Person): 8 9 def __init__(self, name, course): 10 super().__init__(name) 11 self.course = course 12 13 14student = Student("Ankit", "Python") 15 16print(student.name) 17print(student.course)
Output:
1Ankit 2Python
Using super() avoids duplicating initialization logic from the parent class.
Multiple Inheritance
Multiple inheritance means a class inherits from more than one parent class.
1class Father: 2 3 def bike(self): 4 print("Father's bike") 5 6 7class Mother: 8 9 def car(self): 10 print("Mother's car") 11 12 13class Child(Father, Mother): 14 pass 15 16 17child = Child() 18 19child.bike() 20child.car()
Output:
1Father's bike 2Mother's car
Python determines which method to use through the Method Resolution Order (MRO).
You can inspect the MRO using:
1print(Child.mro())
Multiple inheritance can be useful, but it should be designed carefully to avoid complicated class relationships.
Polymorphism
Polymorphism means that the same interface can work with different types of objects.
For example, different classes can implement the same sound() method.
1class Dog: 2 3 def sound(self): 4 return "Bark" 5 6 7class Cat: 8 9 def sound(self): 10 return "Meow" 11 12 13animals = [Dog(), Cat()] 14 15for animal in animals: 16 print(animal.sound())
Output:
1Bark 2Meow
The loop does not need to know whether animal is a Dog or Cat. It only expects the object to provide a sound() method.
This style is closely related to Python's duck typing:
If an object behaves like the required type, Python can often use it without requiring an explicit inheritance relationship.
Encapsulation
Encapsulation means keeping data and the operations that work with that data together while controlling how internal details are accessed.
Python commonly uses naming conventions for this.
There are three commonly discussed levels:
- Public
- Protected
- Private
Public Attributes
Public attributes can be accessed normally.
1class Student: 2 3 def __init__(self, name): 4 self.name = name 5 6 7student = Student("Ankit") 8 9print(student.name)
Output:
1Ankit
Protected Attributes
A single underscore is a convention indicating that an attribute is intended for internal or subclass use.
1class Student: 2 3 def __init__(self): 4 self._marks = 90 5 6 7student = Student() 8 9print(student._marks)
Python does not strictly prevent access to _marks.
The underscore communicates:
This attribute is intended for internal use.
Private Attributes
Double underscores trigger name mangling.
1class BankAccount: 2 3 def __init__(self, balance): 4 self.__balance = balance 5 6 7account = BankAccount(5000)
This will not normally work:
1# print(account.__balance)
Python internally transforms the attribute name approximately into:
1_BankAccount__balance
So this can access it:
1print(account._BankAccount__balance)
However, directly using the mangled name is generally not the intended interface.
A better approach is to expose controlled methods or properties.
Encapsulation with Methods
Instead of exposing internal balance changes directly, define methods that control operations.
1class BankAccount: 2 3 def __init__(self, balance=0): 4 self.__balance = balance 5 6 def deposit(self, amount): 7 if amount <= 0: 8 raise ValueError("Deposit must be greater than zero") 9 10 self.__balance += amount 11 12 def get_balance(self): 13 return self.__balance 14 15 16account = BankAccount(5000) 17 18account.deposit(1000) 19 20print(account.get_balance())
Output:
16000
This gives the class control over how its internal data changes.
Properties
Python's @property decorator allows you to provide method-like behavior through attribute syntax.
1class Circle: 2 3 def __init__(self, radius): 4 self._radius = radius 5 6 @property 7 def radius(self): 8 return self._radius 9 10 @radius.setter 11 def radius(self, value): 12 if value <= 0: 13 raise ValueError("Radius must be greater than zero") 14 15 self._radius = value 16 17 18circle = Circle(5) 19 20print(circle.radius) 21 22circle.radius = 10 23 24print(circle.radius)
Output:
15 210
Properties are useful when you want validation or computed values while keeping a simple interface.
Abstraction
Abstraction focuses on what an object does rather than how it does it.
Python provides the abc module for defining abstract base classes.
1from abc import ABC, abstractmethod 2 3 4class Shape(ABC): 5 6 @abstractmethod 7 def area(self): 8 pass 9 10 11class Square(Shape): 12 13 def __init__(self, side): 14 self.side = side 15 16 def area(self): 17 return self.side ** 2 18 19 20square = Square(4) 21 22print(square.area())
Output:
116
Because Shape.area() is abstract, subclasses are expected to provide their own implementation.
You cannot normally create an instance of Shape directly:
1# shape = Shape()
Method Overriding
Method overriding occurs when a child class provides its own implementation of a method inherited from the parent class.
1class Animal: 2 3 def sound(self): 4 return "Some animal sound" 5 6 7class Dog(Animal): 8 9 def sound(self): 10 return "Bark" 11 12 13dog = Dog() 14 15print(dog.sound())
Output:
1Bark
The Dog implementation replaces the inherited behavior for Dog objects.
You can also call the parent implementation using super():
1class Animal: 2 3 def sound(self): 4 return "Animal sound" 5 6 7class Dog(Animal): 8 9 def sound(self): 10 return f"{super().sound()} and Bark" 11 12 13dog = Dog() 14 15print(dog.sound())
Output:
1Animal sound and Bark
Method Overloading in Python
Traditional method overloading—defining multiple methods with the same name but different parameter lists—is not supported in Python in the same way it is in languages such as Java or C++.
For example, this does not create two overloaded methods:
1class Calculator: 2 3 def add(self, a): 4 return a 5 6 def add(self, a, b): 7 return a + b
The second add() replaces the first one.
Python commonly handles similar requirements using default arguments, *args, or other flexible interfaces.
Using Default Arguments
1class Calculator: 2 3 def add(self, a, b=0, c=0): 4 return a + b + c 5 6 7calculator = Calculator() 8 9print(calculator.add(5)) 10print(calculator.add(5, 10)) 11print(calculator.add(5, 10, 15))
Output:
15 215 330
Static Methods
A static method does not receive self or cls automatically.
Use @staticmethod when the operation logically belongs to a class but does not need instance or class state.
1class MathUtils: 2 3 @staticmethod 4 def square(number): 5 return number * number 6 7 8print(MathUtils.square(5))
Output:
125
A static method can also be called through an instance:
1math_utils = MathUtils() 2 3print(math_utils.square(6))
However, calling it through the class often makes its purpose clearer.
Class Methods
A class method receives the class itself as cls.
Use @classmethod when a method needs to access or modify class-level data.
1class Student: 2 3 college = "ABC College" 4 5 @classmethod 6 def change_college(cls, name): 7 cls.college = name 8 9 10Student.change_college("XYZ College") 11 12print(Student.college)
Output:
1XYZ College
Class Methods as Alternative Constructors
One useful application of @classmethod is creating alternative ways to construct objects.
1class Student: 2 3 def __init__(self, name, age): 4 self.name = name 5 self.age = age 6 7 @classmethod 8 def from_string(cls, data): 9 name, age = data.split(",") 10 return cls(name, int(age)) 11 12 13student = Student.from_string("Ankit,23") 14 15print(student.name) 16print(student.age)
Output:
1Ankit 223
This pattern is commonly called an alternative constructor.
Magic Methods
Magic methods, also called dunder methods, have names that begin and end with double underscores.
Examples include:
| Method | Purpose |
|---|---|
__init__ | Initializes an object |
__str__ | User-friendly string representation |
__repr__ | Developer-oriented representation |
__len__ | Defines behavior for len() |
__eq__ | Defines equality comparison |
__add__ | Defines + behavior |
__lt__ | Defines < behavior |
__str__()
Without a custom __str__(), printing an object usually produces a less useful representation.
1class Student: 2 3 def __init__(self, name, marks): 4 self.name = name 5 self.marks = marks 6 7 def __str__(self): 8 return f"Student(name={self.name}, marks={self.marks})" 9 10 11student = Student("Ankit", 92) 12 13print(student)
Output:
1Student(name=Ankit, marks=92)
__repr__() vs __str__()
__str__() is intended to provide a user-friendly representation.
__repr__() is intended to provide a more detailed representation useful for developers and debugging.
1class Student: 2 3 def __init__(self, name): 4 self.name = name 5 6 def __str__(self): 7 return f"Student: {self.name}" 8 9 def __repr__(self): 10 return f"Student(name={self.name!r})" 11 12 13student = Student("Ankit") 14 15print(str(student)) 16print(repr(student))
Output:
1Student: Ankit 2Student(name='Ankit')
Operator Overloading
Magic methods can customize how operators work with your objects.
For example, __add__() controls the + operator.
1class Point: 2 3 def __init__(self, x, y): 4 self.x = x 5 self.y = y 6 7 def __add__(self, other): 8 return Point( 9 self.x + other.x, 10 self.y + other.y 11 ) 12 13 def __repr__(self): 14 return f"Point({self.x}, {self.y})" 15 16 17point1 = Point(2, 3) 18point2 = Point(4, 5) 19 20print(point1 + point2)
Output:
1Point(6, 8)
Operator overloading can make custom classes easier and more natural to use.
Dataclasses
For classes that mainly store data, Python provides the dataclasses module.
Instead of manually writing __init__() and __repr__(), you can use @dataclass.
1from dataclasses import dataclass 2 3 4@dataclass 5class Student: 6 name: str 7 age: int 8 marks: float 9 10 11student = Student("Ankit", 23, 92) 12 13print(student)
Output:
1Student(name='Ankit', age=23, marks=92)
Dataclasses are especially useful for data-oriented objects.
OOP Project: Bank Account
Let's build a simple bank account using OOP principles.
The account should support:
- Deposit money
- Withdraw money
- Check balance
- Validate transactions
1class BankAccount: 2 3 def __init__(self, owner, balance=0): 4 if balance < 0: 5 raise ValueError("Initial balance cannot be negative") 6 7 self.owner = owner 8 self._balance = balance 9 10 def deposit(self, amount): 11 if amount <= 0: 12 raise ValueError("Deposit amount must be greater than zero") 13 14 self._balance += amount 15 16 def withdraw(self, amount): 17 if amount <= 0: 18 raise ValueError("Withdrawal amount must be greater than zero") 19 20 if amount > self._balance: 21 raise ValueError("Insufficient balance") 22 23 self._balance -= amount 24 25 @property 26 def balance(self): 27 return self._balance 28 29 def __str__(self): 30 return f"BankAccount(owner={self.owner}, balance=₹{self.balance})" 31 32 33account = BankAccount("Ankit", 5000) 34 35account.deposit(1500) 36account.withdraw(2000) 37 38print(account)
Output:
1BankAccount(owner=Ankit, balance=₹4500)
This example demonstrates:
- Class
- Object
__init__()- Instance attributes
- Methods
- Property
- Validation
- Encapsulation
__str__()
OOP Project: Student Management System
A student management system can represent every student as an object.
1class Student: 2 3 def __init__(self, roll_number, name, marks): 4 if not 0 <= marks <= 100: 5 raise ValueError("Marks must be between 0 and 100") 6 7 self.roll_number = roll_number 8 self.name = name 9 self.marks = marks 10 11 def result(self): 12 return "Pass" if self.marks >= 40 else "Fail" 13 14 def display(self): 15 print(f"Roll Number: {self.roll_number}") 16 print(f"Name: {self.name}") 17 print(f"Marks: {self.marks}") 18 print(f"Result: {self.result()}") 19 20 21students = [ 22 Student(1, "Ankit", 92), 23 Student(2, "Rahul", 88), 24 Student(3, "Priya", 95) 25] 26 27for student in students: 28 print("-" * 30) 29 student.display()
Output:
1------------------------------ 2Roll Number: 1 3Name: Ankit 4Marks: 92 5Result: Pass 6------------------------------ 7Roll Number: 2 8Name: Rahul 9Marks: 88 10Result: Pass 11------------------------------ 12Roll Number: 3 13Name: Priya 14Marks: 95 15Result: Pass
OOP Project: Employee Management
Here's another practical example that demonstrates inheritance.
1class Employee: 2 3 def __init__(self, name, salary): 4 self.name = name 5 self.salary = salary 6 7 def display(self): 8 print(f"Name: {self.name}") 9 print(f"Salary: ₹{self.salary}") 10 11 12class Developer(Employee): 13 14 def __init__(self, name, salary, language): 15 super().__init__(name, salary) 16 self.language = language 17 18 def display(self): 19 super().display() 20 print(f"Language: {self.language}") 21 22 23developer = Developer("Ankit", 60000, "Python") 24 25developer.display()
Output:
1Name: Ankit 2Salary: ₹60000 3Language: Python
This demonstrates how a child class can extend a parent class.
Four Major Principles of OOP
The four commonly discussed principles of object-oriented programming are:
| Principle | Meaning |
|---|---|
| Encapsulation | Bundling data and behavior while controlling access |
| Abstraction | Hiding unnecessary implementation details |
| Inheritance | Reusing and extending existing class behavior |
| Polymorphism | Allowing different objects to provide a common interface |
These principles help developers design larger and more maintainable applications.
Common OOP Mistakes
Forgetting self
Incorrect:
1class Student: 2 3 def __init__(name): 4 self.name = name
Correct:
1class Student: 2 3 def __init__(self, name): 4 self.name = name
Forgetting to Create an Object
Defining a class does not automatically create an instance.
1class Student: 2 3 def greet(self): 4 print("Hello")
You need an object:
1student = Student() 2student.greet()
Confusing Class and Instance Attributes
1class Student: 2 3 college = "ABC College" 4 5 def __init__(self, name): 6 self.name = name
college is a class attribute.
name is an instance attribute.
Understanding the difference prevents unexpected behavior.
Using Private Attributes as a Security Mechanism
Python's double underscore does not provide cryptographic security.
1self.__password = password
Name mangling mainly prevents accidental access and naming conflicts. It should not be treated as a security boundary.
Overusing Inheritance
Inheritance is useful when there is a genuine is-a relationship.
For example:
1Dog is an Animal
But if one object simply uses another object, composition may be a better choice.
For example:
1Car has an Engine
Composition often leads to simpler and more flexible designs.
OOP Best Practices
When designing Python classes:
- Give classes clear and meaningful names.
- Keep each class focused on a specific responsibility.
- Use
selfconsistently. - Prefer methods that return values instead of printing when the result may be reused.
- Validate important input at the appropriate boundary.
- Use
@propertywhen controlled attribute access is useful. - Use inheritance only when the relationship makes sense.
- Prefer composition when it provides a simpler design.
- Use type hints for clearer interfaces.
- Use docstrings for public classes and methods.
- Avoid unnecessarily complex class hierarchies.
- Use
dataclasswhen a class primarily stores structured data.
OOP Practice Exercises
Exercise 1: Rectangle
Create a Rectangle class with:
widthheightarea()perimeter()
Example:
1rectangle = Rectangle(10, 5) 2 3print(rectangle.area()) 4print(rectangle.perimeter())
Exercise 2: Car
Create a Car class with:
- Brand
- Model
- Speed
accelerate()brake()
Prevent the speed from becoming negative.
Exercise 3: Bank Account
Extend the BankAccount project with:
- Transaction history
- Transfer between accounts
- Deposit validation
- Withdrawal validation
Exercise 4: Library Management System
Create classes for:
BookMemberLibrary
Implement features such as:
- Add books
- Borrow books
- Return books
- Display available books
Exercise 5: Employee System
Create a base Employee class and child classes such as:
DeveloperDesignerManager
Override a method such as calculate_bonus() for each employee type.
Quick OOP Reference
| Concept | Purpose |
|---|---|
class | Defines a class |
| Object | Instance of a class |
__init__() | Initializes an object |
self | Refers to the current instance |
| Attribute | Stores object or class data |
| Method | Function defined inside a class |
| Inheritance | Reuses parent class behavior |
super() | Accesses parent class functionality |
| Polymorphism | Common interface with different implementations |
| Encapsulation | Controls access to internal data |
| Abstraction | Hides implementation details |
@staticmethod | Method without automatic self or cls |
@classmethod | Method that receives the class as cls |
@property | Provides controlled attribute-style access |
| Dunder method | Special method such as __str__() |
@dataclass |
Key Takeaways
- A class is a blueprint for creating objects.
- An object is an instance of a class.
__init__()initializes an object's state.selfrefers to the current instance.- Instance attributes belong to individual objects.
- Class attributes are associated with the class and can be shared.
- Methods define object behavior.
- Inheritance allows classes to reuse and extend behavior.
- Polymorphism allows different objects to provide a common interface.
- Encapsulation helps control access to internal state.
- Abstraction hides unnecessary implementation details.
@staticmethodis useful when instance and class state are not required.@classmethodworks with class-level state.- Magic methods customize Python's built-in operations.
@dataclassis useful for classes primarily designed to hold data.- Good OOP design focuses on clear responsibilities and simple relationships.
Object-oriented programming becomes much easier once you stop thinking only in terms of syntax and start thinking about objects, responsibilities, relationships, and behavior. Practice by building small systems such as bank accounts, student management systems, libraries, shopping carts, and employee management applications.