Object-Oriented Programming (OOP) is one of the most powerful ways to structure Python programs. Instead of writing long scripts, OOP lets you build reusable components—called objects—that combine data and behavior together. This lesson takes you from the very basics of classes and objects all the way to advanced concepts like dataclasses, abstract classes, polymorphism, MRO, and common design patterns.
1) What Is OOP?
OOP stands for Object-Oriented Programming, a programming style that helps you structure code around real-world entities rather than just actions or logic.
In OOP, programs are organized into objects — entities that combine data (attributes) and behavior (methods or functions) into a single, reusable unit.
Objects = Data (attributes) + Behavior (methods)
Think of an object as a real-world thing, like a Car. For example, a Car object may have:
- Attributes (data):
color,model,speed - Methods (behavior):
start(),accelerate(),stop()
You write the blueprint once — that’s the class. Then you create many objects from it.
These ideas are built on four main principles of OOP — Encapsulation, which hides internal details and exposes only what’s necessary; Abstraction, which simplifies complex systems by focusing on the most important features; Inheritance, which allows one class to reuse and extend the behavior of another; and Polymorphism, which enables different objects to share the same interface but respond in their own unique ways.
2) Creating Your First Class & Object
class Car:
def __init__(self, model, color):
self.model = model # instance attribute
self.color = color
def drive(self):
print(f"{self.model} is driving...")
# Creating objects
c1 = Car("Toyota Corolla", "White")
c2 = Car("Honda Civic", "Black")
c1.drive()
c2.drive()
What is happening?
class Car:→ defines the blueprint__init__→ constructor (runs when object is created)self→ refers to the current object
3) Instance Variables vs Class Variables
class Student:
school = "Geeksters Academy" # class variable (shared)
def __init__(self, name, score):
self.name = name # instance variable
self.score = score
s1 = Student("Ali", 90)
s2 = Student("Ammar", 85)
print(s1.school, s2.school)
- Instance variables = each object has its own
- Class variables = shared by all objects
4) Instance Methods, Class Methods & Static Methods
a) Instance Method (most common)
An instance method is the most common type of method in Python. It operates on a specific object (instance) of a class and can freely access or modify the object’s data (attributes).
Every instance method takes self as its first parameter — it represents the current object calling the method.
class Person:
def __init__(self, name):
self.name = name # instance attribute
def greet(self):
"""Access instance data using 'self'."""
print(f"Hello, my name is {self.name}")
# Create objects (instances)
p1 = Person("Ali")
p2 = Person("Ammar")
# Call instance method on each object
p1.greet() # Hello, my name is Ali
p2.greet() # Hello, my name is Ammar
When to use: Use instance methods when you need to access or modify data that belongs to a specific object. Each object has its own data stored in self, separate from other objects.
b) Class Method
A class method is used when you want a method that works at the class level, rather than on individual objects. It can access or modify class variables that are shared by all instances.
To define a class method, use the @classmethod decorator. The first parameter is conventionally named cls, which refers to the class itself (similar to how self refers to an instance).
class Person:
count = 0 # class variable shared among all instances
def __init__(self, name):
self.name = name
Person.count += 1 # increment shared counter whenever a new Person is created
@classmethod
def get_count(cls):
"""Return how many Person objects have been created."""
return cls.count
# Create some Person objects
p1 = Person("Ali")
p2 = Person("Ammar")
# Access the class method
print(Person.get_count()) # 2
print(p1.get_count()) # also works, but still refers to the class
Why use class methods? They’re perfect for keeping track of shared data or creating factory methods (alternative constructors) that initialize objects in different ways.
c) Static Method
A static method belongs to a class, but it doesn’t need access to the class (cls) or any instance (self). It behaves like a regular function, yet it’s placed inside a class because it’s logically related to that class.
Use the @staticmethod decorator when you want to group utility or helper functions that make sense conceptually within the class, but don’t depend on its data.
class MathTools:
@staticmethod
def add(a, b):
"""Return the sum of two numbers."""
return a + b
@staticmethod
def multiply(a, b):
"""Return the product of two numbers."""
return a * b
# Can be called using the class name — no need to create an object
print(MathTools.add(2, 3)) # 5
print(MathTools.multiply(4, 5)) # 20
When to use: Use static methods for operations that are related to a class but don’t require any access to an instance (self) or class (cls) data.
5) String Representation — __str__ & __repr__
class User:
def __init__(self, name):
self.name = name
def __str__(self):
return f"User(name={self.name})"
6) Encapsulation — Public, Protected, Private
Encapsulation means keeping an object's internal data safe and exposing only the parts that should be accessible. Python supports this through naming conventions for public, protected, and private attributes.
class BankAccount:
def __init__(self, balance):
self._balance = balance # protected attribute (convention)
self.__pin = "1234" # private attribute (name-mangled)
def deposit(self, amount):
self._balance += amount # modify internal state safely
def get_balance(self):
return self._balance # controlled access
How this works:
- Public: attributes/methods without underscores (e.g.,
deposit()). They are fully accessible from anywhere. - Protected (
_balance): one underscore means “intended for internal use.” It’s still accessible externally, but should be treated as non-public. - Private (
__pin): two underscores trigger name-mangling, making the attribute harder to access accidentally. It protects sensitive data (like a PIN or password).
Encapsulation ensures that the internal state of the object is modified only through safe, public methods—keeping your objects consistent and secure.
7) Inheritance — Reusing & Extending Behavior
Inheritance allows you to create a base class with common behavior and then build specialized classes on top of it. This avoids code duplication and makes your system easier to maintain and extend.
Real-World Example: Users in a System
Imagine an app with two types of users: Admin and Customer. Both share some common behavior, but each also has unique actions.
# Base class (Parent)
class User:
def __init__(self, username):
self.username = username
def get_info(self):
return f"User: {self.username}"
# Child class 1 (inherits User)
class Admin(User):
def get_info(self):
# Override + extend behavior using super()
base = super().get_info()
return base + " (Admin)"
def delete_user(self, user):
print(f"{self.username} deleted user {user.username}")
# Child class 2 (inherits User)
class Customer(User):
def get_info(self):
return super().get_info() + " (Customer)"
def purchase(self, item):
print(f"{self.username} purchased {item}")
# Using the classes
admin = Admin("Ammar")
customer = Customer("Ali")
print(admin.get_info()) # User: Ammar (Admin)
print(customer.get_info()) # User: Ali (Customer)
admin.delete_user(customer) # Ammar deleted user Ali
customer.purchase("Laptop") # Ali purchased Laptop
What This Demonstrates
- Admin and Customer both inherit from
User. - Each child reuses the parent constructor (
__init__). - Method overriding is used to customize
get_info(). super()lets subclasses extend, not replace, base behavior.
Why Inheritance Is Useful
- Avoids repeating shared attributes (like
username) - Keeps your code organized by responsibility
- Makes your design clean, scalable, and extensible
8) Polymorphism (Many Forms)
class Cat:
def speak(self):
print("Meow")
class Dog:
def speak(self):
print("Woof")
for pet in (Cat(), Dog()):
pet.speak()
Python calls the correct method automatically based on object type.
9) MRO — Method Resolution Order
class A: pass
class B(A): pass
class C(B): pass
print(C.mro())
MRO tells Python which parent to look in first when resolving methods.
10) Abstract Classes & Interfaces (abc)
Sometimes you want to define a common structure for several classes, but you don't want the base class itself to be used directly. In Python, you create such “blueprints” using abstract classes from the abc module.
An abstract class can define methods that subclasses must implement. These act like interfaces in other languages.
from abc import ABC, abstractmethod
# Abstract Base Class (interface-like)
class Payment(ABC):
@abstractmethod
def pay(self, amount):
"""Process a payment of the given amount."""
pass
# Concrete class implementing the abstract method
class PayPal(Payment):
def pay(self, amount):
print(f"Paid ${amount} using PayPal")
# Another implementation
class CreditCard(Payment):
def pay(self, amount):
print(f"Paid ${amount} using Credit Card")
# Using the classes
paypal = PayPal()
paypal.pay(50) # Paid $50 using PayPal
card = CreditCard()
card.pay(120) # Paid $120 using Credit Card
Why Use Abstract Classes?
- They enforce a consistent API across subclasses.
- They ensure required methods are implemented.
- They help design large systems (payment systems, plugins, drivers, etc.).
Key Notes
- You cannot create an object of an abstract class:
Payment()will raise an error. - Every subclass must implement all
@abstractmethodmethods. - Abstract classes work like “interfaces” in other languages.
11) Composition (HAS-A) vs Inheritance (IS-A)
Composition Example
class Engine:
def start(self):
print("Engine start")
class Car:
def __init__(self):
self.engine = Engine() # has-a
def start(self):
self.engine.start()
12) Dataclasses — Simple Models
Sometimes you just need a simple class to store data (like a small model or record), without writing a lot of boilerplate code such as __init__, __repr__, or __eq__. Python’s dataclasses module creates these automatically.
A @dataclass turns a normal class into a lightweight data container.
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool = True # default value
# Creating objects
p1 = Product("Laptop", 1200)
p2 = Product("Mouse", 25, in_stock=False)
print(p1) # Product(name='Laptop', price=1200, in_stock=True)
print(p2) # Product(name='Mouse', price=25, in_stock=False)
Why Dataclasses?
- Automatically generate
__init__,__repr__, and__eq__. - Perfect for simple models (Products, Users, Settings, Config objects).
- Cleaner and more readable than writing boilerplate manually.
Optional: Frozen Dataclasses (Immutable)
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(3, 4)
# p.x = 10 # ❌ Error — frozen dataclasses are read-only
Frozen dataclasses behave like lightweight, immutable records — great for coordinates, configuration, and constants.
13) Useful Magic Methods
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)
14) Three Common Design Patterns
Design patterns are reusable solutions to common software design problems. Here are three lightweight patterns that are easy to understand and very useful in real projects.
1) Singleton — Only One Instance Allowed
Use when you want only one object of a class to ever exist (e.g., database connection, app settings).
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# Example
s1 = Singleton()
s2 = Singleton()
print(s1 is s2) # True (both are the same instance)
2) Factory — Create Objects Without Exposing Their Logic
A Factory method centralizes object creation. Useful when your program needs to create objects based on conditions.
class Circle:
def draw(self):
print("Drawing a circle")
class Square:
def draw(self):
print("Drawing a square")
class ShapeFactory:
def get_shape(self, type):
if type == "circle":
return Circle()
elif type == "square":
return Square()
else:
raise ValueError("Unknown shape type")
# Example
factory = ShapeFactory()
shape = factory.get_shape("circle")
shape.draw() # Drawing a circle
3) Strategy Pattern — Swap Behavior Dynamically
Strategy lets you change how something behaves at runtime by swapping implementations.
class JSONStrategy:
def process(self, data):
print("Processing data as JSON")
class XMLStrategy:
def process(self, data):
print("Processing data as XML")
class DataProcessor:
def __init__(self, strategy):
self.strategy = strategy
def run(self, data):
self.strategy.process(data)
# Example
processor = DataProcessor(JSONStrategy())
processor.run("data") # Processing data as JSON
processor.strategy = XMLStrategy()
processor.run("data") # Processing data as XML
Useful when you need multiple interchangeable algorithms: sorting, formatting, validation, exporting, etc.
15) Mini Real-World Project: Tiny Billing System
from dataclasses import dataclass
@dataclass
class Item:
name: str
price: float
class Cart:
def __init__(self):
self.items = []
def add(self, item: Item):
self.items.append(item)
def total(self):
return sum(i.price for i in self.items)
cart = Cart()
cart.add(Item("Keyboard", 3000))
cart.add(Item("Mouse", 1500))
print("Total:", cart.total())
Key Takeaways
- OOP models real-world concepts using classes and objects.
__init__initializes objects;selfrefers to each instance.- Instance methods, class methods, and static methods serve different roles.
- Inheritance allows code reuse; polymorphism enables flexible behavior.
- Use abstract classes to define “rules” for subclasses.
- Prefer composition over inheritance for cleaner design.
- Magic methods let you customize how objects behave.
- Dataclasses provide clean, concise models.
- Design patterns help build scalable applications.
Leave a comment
Your email address will not be published. Required fields are marked *
