Skip to main content
Python · lesson 12 of 34
Python beginner Lesson 12 of 34

Object-Oriented Programming in Python

Learn Python OOP — classes, objects, instances, inheritance, dunder methods, and encapsulation — with real-world examples.

By SkillByExample · Updated

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. Output below is from Python 3.13.

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."""

    bank_name = "PyBank"             # class attribute: shared by every instance

    def __init__(self, owner, balance=0.0):
        self.owner = owner           # instance attributes: one copy per object
        self._balance = balance      # leading _ means "internal, by convention"

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self._balance += amount

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

    def get_balance(self):
        return self._balance

    def __str__(self):               # used by print() and str()
        return f"BankAccount(owner={self.owner!r}, balance={self._balance:.2f})"

acc = BankAccount("Alice", 1000.0)
acc.deposit(500.0)
acc.withdraw(200.0)
print(acc)
print(acc.get_balance())
print(BankAccount.bank_name, acc.bank_name)
$ python bank_account.py
BankAccount(owner='Alice', balance=1300.00)
1300.0
PyBank PyBank

acc.deposit(500.0) is really BankAccount.deposit(acc, 500.0): Python passes the instance in as self, which is how each method reaches that object’s own _balance. bank_name is not stored on acc at all; the lookup falls through from the instance to the class, so both spellings print the same value.

Inheritance

Inheritance lets a child class reuse and extend a parent class without copying code. The child gains all the parent’s methods and can override any of them or add new ones.

class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        self._balance += amount

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

    def __init__(self, owner, balance, rate):
        super().__init__(owner, balance)   # let the parent set up its attributes
        self.rate = rate                   # then add the child's own

    def apply_interest(self):
        interest = self._balance * self.rate
        self._balance += interest
        print(f"Interest applied: +{interest:.2f}")

    def __str__(self):
        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, not reimplemented
savings.apply_interest()
print(savings)
print(isinstance(savings, BankAccount))
$ python savings_account.py
Interest applied: +275.00
SavingsAccount(owner='Bob', balance=5775.00, rate=5.0%)
True

Interest is 5% of 5,500, not of the opening 5,000, because the inherited deposit() ran first and updated the same _balance attribute the child reads. super().__init__() is what created that attribute; skip it and the first method that touches _balance fails with AttributeError. A SavingsAccount is still a BankAccount, so isinstance agrees.

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)   # __add__ is called automatically
print(v1 == v2)  # __eq__ is called automatically
$ python vector.py
Vector(4, 6)
False

v1 + v2 is translated into v1.__add__(v2), and print uses __repr__ because no __str__ is defined. Without __eq__, == would compare identity, and even Vector(1, 2) == Vector(1, 2) would be False.

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)
t.celsius = 0         # calls the setter, which validates
print(t.fahrenheit)

try:
    t.fahrenheit = 50     # no setter defined for fahrenheit
except AttributeError as e:
    print(f"AttributeError: {e}")
$ python temperature.py
212.0
32.0
AttributeError: property 'fahrenheit' of 'Temperature' object has no setter

t.fahrenheit reads like a plain attribute but runs a method each time, so it is recomputed from the current _celsius and never goes stale. A property with no setter is read-only, which is why the last assignment fails.

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)
print(Date.is_valid_month(13))
$ python date.py
2024
False

from_string returns cls(...) rather than Date(...), so a subclass calling it gets an instance of the subclass. is_valid_month needs neither, so it is a @staticmethod grouped with the class only for organisation.

For a deeper look at the other three OOP pillars, see Encapsulation, Polymorphism, and Abstraction — plus Multiple Inheritance and MRO, Composition vs Inheritance, and Dataclasses for the topics that build on what’s here.

Practice

1. Add a transfer(self, other, amount) method to BankAccount that withdraws from self and deposits into other, then move 300 from one account to another.

Solution
class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        self._balance += amount

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

    def transfer(self, other, amount):
        self.withdraw(amount)      # raises first if self can't afford it
        other.deposit(amount)

    def __str__(self):
        return f"BankAccount(owner={self.owner!r}, balance={self._balance:.2f})"

alice = BankAccount("Alice", 1000.0)
bob = BankAccount("Bob", 200.0)
alice.transfer(bob, 300.0)
print(alice)
print(bob)
$ python transfer.py
BankAccount(owner='Alice', balance=700.00)
BankAccount(owner='Bob', balance=500.00)

transfer reuses withdraw and deposit instead of touching _balance directly, so the insufficient-funds check applies to transfers for free.

2. Give SavingsAccount both a __str__ and a __repr__, and show the difference between str() and repr() on the same instance.

Solution
class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self._balance = balance

class SavingsAccount(BankAccount):
    def __init__(self, owner, balance, rate):
        super().__init__(owner, balance)
        self.rate = rate

    def __str__(self):
        return f"{self.owner}'s savings: {self._balance:.2f} at {self.rate:.1%}"

    def __repr__(self):
        return f"SavingsAccount({self.owner!r}, {self._balance!r}, {self.rate!r})"

s = SavingsAccount("Bob", 5000.0, 0.05)
print(str(s))
print(repr(s))
print([s])
$ python str_vs_repr.py
Bob's savings: 5000.00 at 5.0%
SavingsAccount('Bob', 5000.0, 0.05)
[SavingsAccount('Bob', 5000.0, 0.05)]

str() is for readers; repr() is for developers and ideally looks like the code that rebuilds the object. Containers always show their items’ repr, which is why the list prints the second form.

3. Add a __lt__ to Vector that compares by magnitude (x**2 + y**2), then sort a list of Vector instances.

Solution
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __lt__(self, other):
        return (self.x**2 + self.y**2) < (other.x**2 + other.y**2)

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

vectors = [Vector(3, 4), Vector(1, 1), Vector(0, 6)]
print(sorted(vectors))
$ python vector_sort.py
[Vector(1, 1), Vector(3, 4), Vector(0, 6)]

sorted() only needs <, so __lt__ alone is enough. Magnitudes squared are 25, 2 and 36, which gives the order shown.

4. Give Temperature a read-only kelvin property alongside fahrenheit, computed from _celsius.

Solution
class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @property
    def kelvin(self):
        return self._celsius + 273.15

t = Temperature(0)
print(t.fahrenheit, t.kelvin)
t._celsius = 100
print(t.fahrenheit, t.kelvin)
$ python kelvin.py
32.0 273.15
212.0 373.15

Both properties are computed from the one stored value, so changing _celsius updates them together. There is no second copy that could drift out of sync.

5. Add a second alternative constructor to Date, today(), using datetime.date.today(), and check that a subclass inherits both constructors.

Solution
from datetime import date

class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    @classmethod
    def from_string(cls, date_string):
        year, month, day = map(int, date_string.split("-"))
        return cls(year, month, day)

    @classmethod
    def today(cls):
        d = date.today()
        return cls(d.year, d.month, d.day)

class EventDate(Date):
    pass

print(type(EventDate.from_string("2024-06-15")).__name__)
print(type(EventDate.today()).__name__)
print(Date.today().year == date.today().year)
$ python date_today.py
EventDate
EventDate
True

Because both constructors build with cls(...), calling them on EventDate returns EventDate instances. The year check prints True whenever you run it, unlike the date itself.

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.