Object-Oriented Programming (OOP) is a paradigm that organises code around objects — bundles of related data and behaviour. Python is built around this model. Everything in Python is an object — integers, strings, lists, functions — and understanding OOP is essential for writing well-structured Python code, working with Python libraries, and building anything beyond small scripts.
Classes and Objects
A class is a blueprint that defines the structure and behaviour of a type of object. An object (also called an instance) is a concrete realisation of that blueprint — created from the class with specific values.
class User:
pass # An empty class — valid but does nothing yet
user1 = User() # Create an instance
user2 = User() # Create another instance
user1 and user2 are separate objects — each is an independent instance of the User class.
The __init__ Method
__init__ is the constructor — a special method called automatically when a new instance is created. It initialises the instance's attributes:
class User:
def __init__(self, name, age):
self.name = name
self.age = age
user = User("Wariz", 20)
print(user.name) # "Wariz"
print(user.age) # 20
self
self is a reference to the instance being created or used. It is always the first parameter of every instance method, and Python passes it automatically — you never include it when calling the method.
When you write self.name = name, you are creating an instance attribute — a variable that belongs to this specific instance. Different instances have separate copies:
user1 = User("Wariz", 20)
user2 = User("Ada", 25)
user1.name # "Wariz"
user2.name # "Ada" — separate from user1
Instance Methods
Methods are functions defined inside a class. They always take self as their first parameter:
class User:
def __init__(self, name, age):
self.name = name
self.age = age
def greet(self):
return f"Hello, I'm {self.name}."
def is_adult(self):
return self.age >= 18
user = User("Wariz", 20)
print(user.greet()) # Hello, I'm Wariz.
print(user.is_adult()) # True
Class Attributes vs. Instance Attributes
Instance attributes belong to a specific object — defined in __init__ using self.
Class attributes belong to the class itself and are shared across all instances:
class User:
platform = "WinehouseLabs" # Class attribute — shared by all instances
def __init__(self, name):
self.name = name # Instance attribute — unique per instance
user1 = User("Wariz")
user2 = User("Ada")
print(user1.platform) # "WinehouseLabs"
print(user2.platform) # "WinehouseLabs" — same value
print(user1.name) # "Wariz"
print(user2.name) # "Ada" — different values
User.platform = "WHL" # Changing the class attribute affects all instances
print(user1.platform) # "WHL"
print(user2.platform) # "WHL"
The Four Pillars of OOP
1. Encapsulation
Encapsulation means bundling data and the methods that operate on it together in a class, and controlling access to the internal state of an object.
Python uses naming conventions to signal access levels:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # Public — accessible anywhere
self._balance = balance # Protected — convention: don't access directly
self.__pin = "1234" # Private — name-mangled by Python
def deposit(self, amount):
if amount > 0:
self._balance += amount
def get_balance(self):
return self._balance # Controlled access to internal state
name— public. Accessible from anywhere._name— protected by convention. Signals "internal use only" but not enforced.__name— private. Python name-mangles it to_ClassName__name, making accidental external access unlikely.
Python does not enforce truly private attributes the way Java does. The underscore conventions are agreements among developers rather than hard restrictions.
2. Inheritance
Inheritance allows a class to acquire the attributes and methods of another class. The class being inherited from is the parent (or base) class; the class that inherits is the child (or derived) class.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound."
class Dog(Animal): # Dog inherits from Animal
def speak(self): # Override the parent method
return f"{self.name} barks."
class Cat(Animal):
def speak(self):
return f"{self.name} meows."
dog = Dog("Rex")
cat = Cat("Whiskers")
print(dog.speak()) # Rex barks.
print(cat.speak()) # Whiskers meows.
super()
super() calls a method from the parent class. It is used in __init__ to run the parent's initialisation logic before adding child-specific attributes:
class Animal:
def __init__(self, name, species):
self.name = name
self.species = species
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, "Canis lupus familiaris") # Call parent __init__
self.breed = breed
dog = Dog("Rex", "Labrador")
print(dog.name) # "Rex"
print(dog.species) # "Canis lupus familiaris"
print(dog.breed) # "Labrador"
Multiple Inheritance
Python supports inheriting from more than one parent class:
class Flyable:
def fly(self):
return "I can fly."
class Swimmable:
def swim(self):
return "I can swim."
class Duck(Flyable, Swimmable):
pass
duck = Duck()
print(duck.fly()) # I can fly.
print(duck.swim()) # I can swim.
3. Polymorphism
Polymorphism means different classes can implement the same interface — the same method name — in different ways. The caller does not need to know which specific class it is working with:
animals = [Dog("Rex"), Cat("Whiskers"), Animal("Generic")]
for animal in animals:
print(animal.speak())
# Rex barks.
# Whiskers meows.
# Generic makes a sound.
Each speak() call produces a different result depending on the object's actual type. This is one of the most powerful aspects of OOP — code that works with a general type automatically works with any subtype.
4. Abstraction
Abstraction hides implementation complexity and exposes only what is necessary. In Python, this is achieved using the abc module (Abstract Base Classes):
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass # No implementation — subclasses must provide it
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
# Shape() would raise TypeError — cannot instantiate abstract class
circle = Circle(5)
rect = Rectangle(4, 6)
print(circle.area()) # 78.53975
print(rect.area()) # 24
Special (Dunder) Methods
Python classes can implement special methods — also called dunder methods (double underscore) — that define how objects behave with built-in operations.
__str__ and __repr__
class User:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"User: {self.name}, Age: {self.age}"
def __repr__(self):
return f"User(name='{self.name}', age={self.age})"
user = User("Wariz", 20)
print(user) # User: Wariz, Age: 20 — uses __str__
print(repr(user)) # User(name='Wariz', age=20) — uses __repr__
__str__— human-readable string, used byprint().__repr__— unambiguous representation, used for debugging.
__len__, __getitem__, and others
class Playlist:
def __init__(self):
self.songs = []
def add(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
def __getitem__(self, index):
return self.songs[index]
playlist = Playlist()
playlist.add("Song A")
playlist.add("Song B")
print(len(playlist)) # 2 — uses __len__
print(playlist[0]) # "Song A" — uses __getitem__
Common dunder methods
| Method | Triggered by |
|---|---|
__init__ | ClassName() — object creation |
__str__ | print(obj), str(obj) |
__repr__ | repr(obj), debugger output |
__len__ | len(obj) |
__getitem__ | obj[key] |
__setitem__ | obj[key] = value |
__contains__ | item in obj |
__eq__ | obj1 == obj2 |
__lt__ | obj1 < obj2 |
__add__ | obj1 + obj2 |
__iter__ | for item in obj |
Class Methods and Static Methods
@classmethod
A class method receives the class itself as its first argument (conventionally cls) rather than an instance. It is used for alternative constructors or factory methods:
class User:
def __init__(self, name, age):
self.name = name
self.age = age
@classmethod
def from_dict(cls, data):
return cls(data["name"], data["age"])
user_data = {"name": "Wariz", "age": 20}
user = User.from_dict(user_data)
print(user.name) # "Wariz"
@staticmethod
A static method belongs to the class namespace but receives no automatic first argument — no self, no cls. It is a regular function that happens to live inside a class:
class MathUtils:
@staticmethod
def add(a, b):
return a + b
print(MathUtils.add(3, 5)) # 8
__slots__
For classes that create large numbers of instances, __slots__ can reduce memory usage by telling Python exactly which attributes an instance will have:
class Point:
__slots__ = ["x", "y"]
def __init__(self, x, y):
self.x = x
self.y = y
Instances of a __slots__ class cannot have attributes not listed in __slots__, and they use significantly less memory than regular instances.
Summary
| Concept | Python Syntax |
|---|---|
| Define a class | class ClassName: |
| Constructor | def __init__(self, params): |
| Instance attribute | self.attribute = value |
| Class attribute | Defined at class level, outside methods |
| Instance method | def method(self): |
| Inheritance | class Child(Parent): |
| Call parent method | super().method() |
| Abstract class | class Name(ABC): with @abstractmethod |
| Class method | @classmethod — first arg is cls |
| Static method | @staticmethod — no automatic first arg |
| String representation | def __str__(self): |
| Debug representation | def __repr__(self): |
| Public attribute | name |
| Protected attribute | _name (convention) |
| Private attribute | __name (name-mangled) |