Last updated: 2026-05-21

Python OOP

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 by print().
  • __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

MethodTriggered 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

ConceptPython Syntax
Define a classclass ClassName:
Constructordef __init__(self, params):
Instance attributeself.attribute = value
Class attributeDefined at class level, outside methods
Instance methoddef method(self):
Inheritanceclass Child(Parent):
Call parent methodsuper().method()
Abstract classclass Name(ABC): with @abstractmethod
Class method@classmethod — first arg is cls
Static method@staticmethod — no automatic first arg
String representationdef __str__(self):
Debug representationdef __repr__(self):
Public attributename
Protected attribute_name (convention)
Private attribute__name (name-mangled)