Skip to main content
Python beginner Lesson 7 of 28

Object-Oriented Programming in Python

Learn classes, instances, inheritance, dunder methods, and encapsulation with real-world examples.

Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — bundles of data (attributes) and behaviour (methods). The benefit is encapsulation: related state and logic live together, making systems easier to reason about, test, and extend. Python is a fully object-oriented language where even primitive types like int and str are objects.

Defining a Class

A class is a blueprint. Each time you call it, Python creates a new independent instance. The __init__ method is the constructor — it runs automatically when a new instance is created and is where you set up the initial state of the object.

class BankAccount:
    """Represents a simple bank account."""

    # Class-level attribute — shared across all instances of this class
    bank_name: str = "PyBank"

    def __init__(self, owner: str, balance: float = 0.0) -> None:
        # Instance attributes — unique to each individual object
        self.owner = owner
        self._balance = balance  # leading _ signals "protected by convention"

    def deposit(self, amount: float) -> None:
        # Validate inputs to keep the object in a consistent state
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self._balance += amount

    def withdraw(self, amount: float) -> None:
        if amount > self._balance:
            raise ValueError("Insufficient funds.")
        self._balance -= amount

    def get_balance(self) -> float:
        return self._balance

    # __str__ is called by print() and str() — intended for end users
    def __str__(self) -> str:
        return f"BankAccount(owner={self.owner!r}, balance={self._balance:.2f})"

    # __repr__ is called in the REPL and by repr() — intended for developers
    def __repr__(self) -> str:
        return f"BankAccount({self.owner!r}, {self._balance!r})"

Creating and using instances:

acc = BankAccount("Alice", 1000.0)
acc.deposit(500.0)
acc.withdraw(200.0)
print(acc)               # BankAccount(owner='Alice', balance=1300.00)
print(acc.get_balance()) # 1300.0

# Class attributes are accessible on the class itself or any instance
print(BankAccount.bank_name)  # PyBank
print(acc.bank_name)          # PyBank

Inheritance

Inheritance lets a child class reuse and extend a parent class without copying code. The child gains all the parent’s methods and attributes, and can override any of them or add new ones. This is how Python’s standard library extends base classes — list, dict, Exception, and so on.

class SavingsAccount(BankAccount):
    """A bank account that earns periodic interest."""

    def __init__(self, owner: str, balance: float, rate: float) -> None:
        # super() delegates to the parent's __init__ — always call it first
        super().__init__(owner, balance)
        self.rate = rate   # new attribute not present in the parent

    def apply_interest(self) -> None:
        # Extend the parent's functionality without touching its source
        interest = self._balance * self.rate
        self._balance += interest
        print(f"Interest applied: +{interest:.2f}")

    def __str__(self) -> str:
        # Override the parent's __str__ to include the interest rate
        return (
            f"SavingsAccount(owner={self.owner!r}, "
            f"balance={self._balance:.2f}, rate={self.rate:.1%})"
        )

savings = SavingsAccount("Bob", 5000.0, rate=0.05)
savings.deposit(500.0)        # inherited from BankAccount — no reimplementation needed
savings.apply_interest()      # Interest applied: +275.00
print(savings)                # SavingsAccount(owner='Bob', balance=5775.00, rate=5.0%)

Key Dunder Methods

Dunder (double-underscore) methods define how your objects respond to Python’s built-in operations. Implementing them lets your class integrate seamlessly with the language — objects that support len(), +, ==, and with feel like first-class Python types rather than bolted-on structures.

MethodTriggered by
__init__MyClass() — object construction
__str__str(obj), print(obj)
__repr__repr(obj), interactive shell display
__len__len(obj)
__eq__obj1 == obj2
__lt__obj1 < obj2
__add__obj1 + obj2
__enter__ / __exit__with statement context manager
class Vector:
    """A 2D mathematical vector that supports arithmetic operators."""

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def __add__(self, other: "Vector") -> "Vector":
        # Called when you write v1 + v2
        return Vector(self.x + other.x, self.y + other.y)

    def __eq__(self, other: object) -> bool:
        # Called when you write v1 == v2
        if not isinstance(other, Vector):
            return NotImplemented  # let Python handle the comparison the other way
        return self.x == other.x and self.y == other.y

    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)   # Vector(4, 6) — __add__ is called automatically
print(v1 == v2)  # False        — __eq__ is called automatically

Properties — Controlled Attribute Access

The @property decorator lets you expose an attribute through a getter and optionally a setter, while keeping the calling syntax clean (no explicit method call). The benefit is that you can add validation, computation, or caching without changing how the attribute is accessed — existing code that reads obj.celsius doesn’t need to change when you add validation logic.

class Temperature:
    """Stores a temperature with automatic Fahrenheit conversion."""

    def __init__(self, celsius: float) -> None:
        # Store as the internal name; public access goes through the property
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        """Read the temperature in Celsius."""
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        """Set the temperature in Celsius, enforcing physical limits."""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero.")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        """Compute Fahrenheit on the fly — no redundant stored state."""
        return self._celsius * 9 / 5 + 32

t = Temperature(100)
print(t.fahrenheit)   # 212.0
t.celsius = 0         # calls the setter — triggers validation
print(t.fahrenheit)   # 32.0

# t.fahrenheit = 50  # would raise AttributeError — no setter defined

Class and Static Methods

Regular methods receive the instance as self. Class methods receive the class as cls and are useful for alternative constructors. Static methods receive neither — they’re plain functions namespaced inside the class.

class Date:
    def __init__(self, year: int, month: int, day: int) -> None:
        self.year = year
        self.month = month
        self.day = day

    @classmethod
    def from_string(cls, date_string: str) -> "Date":
        """Alternative constructor — parse a 'YYYY-MM-DD' string."""
        year, month, day = map(int, date_string.split("-"))
        return cls(year, month, day)  # cls() calls Date() or any subclass

    @staticmethod
    def is_valid_month(month: int) -> bool:
        """Utility check — doesn't need instance or class state."""
        return 1 <= month <= 12

d = Date.from_string("2024-06-15")   # alternative constructor
print(d.year)                         # 2024
print(Date.is_valid_month(13))        # False

Frequently Asked Questions

What is the difference between a class and an instance?
A class is a blueprint that defines attributes and methods. An instance is a concrete object created from that blueprint using the class constructor.
What are dunder (magic) methods?
Dunder methods are special methods surrounded by double underscores (e.g. __init__, __str__, __len__). They let you define how your objects behave with Python's built-in operators and functions.
Does Python support multiple inheritance?
Yes. Python supports multiple inheritance. When a class inherits from multiple parents, Python uses the C3 linearisation algorithm (MRO) to determine method resolution order.