Language:

Search

Object-Oriented Programming (OOP) in Python — From Basics to Real-World Design

Object-Oriented Programming (OOP) in Python — From Basics to Real-World Design

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 OOPEncapsulation, 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 @abstractmethod methods.
  • 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; self refers 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.

Next up: Modules, CLI Apps & Environment Variables 

Ahmad Ali

Ahmad Ali

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy