Python Zero to Advanced #8: OOP - Classes, Inheritance, Dunder Methods & Dataclasses
Welcome to the advanced block. Up to now we have combined functions with dicts and lists. That works, but as programs grow you want to bundle data and the behaviour that operates on it into one unit. That unit is a class. Everything in Python is already an object (strings, lists, even functions), so learning OOP also helps you understand how Python itself works.
Classes and instances
A class is a blueprint; an instance is a concrete object built from it. __init__ initialises a new instance, and self refers to that instance. Attributes defined on the class itself are shared by all instances.
class BankAccount:
bank_name = "Example Bank" # class attribute (shared)
def __init__(self, owner, balance=0):
self.owner = owner # instance attributes
self.balance = balance
def deposit(self, amount):
self.balance += amount
return self.balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("insufficient balance")
self.balance -= amount
return self.balance
budi = BankAccount("Budi", 100000)
siti = BankAccount("Siti")
budi.deposit(50000)
siti.deposit(20000)
print(budi.owner, budi.balance)
print(siti.owner, siti.balance)
print(budi.bank_name, BankAccount.bank_name)
print(isinstance(budi, BankAccount))
Output:
Budi 150000
Siti 20000
Example Bank Example Bank
True
When you call budi.deposit(50000), Python actually runs BankAccount.deposit(budi, 50000). That is why every instance method takes self as its first parameter.
Encapsulation, properties, class and static methods
Python has no truly private attributes. By convention a leading underscore (_balance) means "internal, do not touch". A @property exposes a computed or validated value with plain attribute syntax. @classmethod receives the class (handy for alternative constructors), and @staticmethod is just a function namespaced in the class.
class Product:
def __init__(self, name, price):
self.name = name
self.price = price # goes through the setter below
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("price cannot be negative")
self._price = value
@property
def price_with_tax(self):
return round(self._price * 1.11)
@classmethod
def from_csv(cls, row):
name, price = row.split(",")
return cls(name, int(price))
@staticmethod
def is_valid_name(name):
return bool(name.strip())
coffee = Product.from_csv("Coffee,18000")
print(coffee.name, coffee.price, coffee.price_with_tax)
coffee.price = 20000
print(coffee.price_with_tax)
try:
coffee.price = -1
except ValueError as e:
print("Error:", e)
print(Product.is_valid_name(" "))
Output:
Coffee 18000 19980
22200
Error: price cannot be negative
False
Inheritance and super()
A subclass inherits attributes and methods from its parent and can override them. Call super() to reuse the parent implementation instead of copying it. This enables polymorphism: code that works with the parent type works with any subclass.
class Notification:
def __init__(self, recipient):
self.recipient = recipient
def send(self, message):
return f"[{self.channel()}] to {self.recipient}: {message}"
def channel(self):
return "generic"
class EmailNotification(Notification):
def channel(self):
return "email"
class SmsNotification(Notification):
def __init__(self, recipient, sender_id="EXAMPLE"):
super().__init__(recipient)
self.sender_id = sender_id
def channel(self):
return f"sms/{self.sender_id}"
def send(self, message):
short = message[:20]
return super().send(short)
notifications = [
EmailNotification("budi@example.com"),
SmsNotification("+620000000000"),
]
for n in notifications:
print(n.send("Your order #1024 has been shipped"))
print(issubclass(SmsNotification, Notification))
print(SmsNotification.__mro__[1].__name__)
Output:
[email] to budi@example.com: Your order #1024 has been shipped
[sms/EXAMPLE] to +620000000000: Your order #1024 has
True
Notification
Prefer shallow hierarchies. If a class merely uses another (a Car has an Engine), use composition, storing the object as an attribute, rather than inheritance.
Dunder (magic) methods
Methods with double underscores, like __repr__, let your objects plug into Python's built-in syntax: printing, comparison, arithmetic, len(), iteration, and more.
class Money:
def __init__(self, amount, currency="IDR"):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
def __str__(self):
return f"{self.currency} {self.amount:,}"
def __eq__(self, other):
return (self.amount, self.currency) == (other.amount, other.currency)
def __lt__(self, other):
return self.amount < other.amount
def __add__(self, other):
return Money(self.amount + other.amount, self.currency)
def __bool__(self):
return self.amount != 0
a = Money(50000)
b = Money(25000)
print(a) # __str__
print(repr(b)) # __repr__
print([a, b]) # containers use __repr__
print(a + b) # __add__
print(a == Money(50000), a < b)
print(sorted([a, b]))
print(bool(Money(0)))
Output:
IDR 50,000
Money(25000, 'IDR')
[Money(50000, 'IDR'), Money(25000, 'IDR')]
IDR 75,000
True False
[Money(25000, 'IDR'), Money(50000, 'IDR')]
False
| Method | Triggered by |
|---|---|
__str__ / __repr__ | print(x) / repr(x), REPL |
__eq__, __lt__ | ==, <, sorted() |
__add__ | a + b |
__len__, __getitem__ | len(x), x[i] |
__iter__ | for item in x |
Dataclasses
Writing __init__, __repr__, and __eq__ by hand for simple data containers is tedious. The @dataclass decorator generates them from type-annotated fields. Use field(default_factory=list) for mutable defaults, frozen=True for immutability, and order=True for comparisons.
from dataclasses import dataclass, field, asdict
@dataclass
class OrderItem:
name: str
price: int
qty: int = 1
@property
def subtotal(self) -> int:
return self.price * self.qty
@dataclass
class Order:
customer: str
items: list[OrderItem] = field(default_factory=list)
def total(self) -> int:
return sum(item.subtotal for item in self.items)
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
order = Order("Siti")
order.items.append(OrderItem("Coffee", 18000, 2))
order.items.append(OrderItem("Cake", 25000))
print(order.items[0])
print(order.total())
print(OrderItem("Tea", 12000) == OrderItem("Tea", 12000))
print(asdict(order)["items"][1])
print(sorted([Version(1, 10), Version(1, 2), Version(0, 9)]))
Output:
OrderItem(name='Coffee', price=18000, qty=2)
61000
True
{'name': 'Cake', 'price': 25000, 'qty': 1}
[Version(major=0, minor=9), Version(major=1, minor=2), Version(major=1, minor=10)]
Tips and common mistakes
- Forgetting
selfin a method signature leads toTypeError: takes 0 positional arguments but 1 was given. - Mutable class attributes (
items = []at class level) are shared by every instance. Initialise them in__init__or usedefault_factory. - Always implement
__repr__(or use a dataclass); debugging objects that print as<object at 0x...>is painful. - Do not build a class for everything. If it has only
__init__and one method, a function may be simpler.
Exercise
Build a ShoppingCart class that supports len(cart), iteration with for, and a readable print(cart).
class ShoppingCart:
def __init__(self, owner):
self.owner = owner
self._items = []
def add(self, name, price):
self._items.append((name, price))
return self # allows chaining
def __len__(self):
return len(self._items)
def __iter__(self):
return iter(self._items)
def __str__(self):
total = sum(price for _, price in self._items)
return f"Cart({self.owner}, {len(self)} items, total={total:,})"
cart = ShoppingCart("Budi").add("Coffee", 18000).add("Tea", 12000)
print(len(cart))
for name, price in cart:
print(f"- {name}: {price:,}")
print(cart)
Output:
2
- Coffee: 18,000
- Tea: 12,000
Cart(Budi, 2 items, total=30,000)
Summary
- Classes bundle data and behaviour;
__init__sets up instances andselfrefers to the current one. - Use
@propertyfor validation and computed values,@classmethodfor alternative constructors. - Inheritance plus
super()enables reuse and polymorphism; prefer composition when in doubt. - Dunder methods integrate objects with Python syntax; dataclasses generate the boilerplate for you.
In Part 9 we will look under the hood of for loops with iterators and generators, then master decorators and context managers.
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Structure real PHP projects: organize code with namespaces, manage dependencies with Composer, autoload classes with PSR-4, and handle errors cleanly with exceptions.
Ruby on Rails Zero to Advanced #8: Forms, Strong Parameters & Full CRUD
Build a complete CRUD feature by hand with form_with and strong parameters, then compare it with what rails generate scaffold gives you for free.