Python · lesson 12 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Object-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.
| Method | Triggered 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.