Python OOP: Classes, Dataclasses, and Dunder Methods
Why Every Java Developer Writes Un-Pythonic Classes on Day One
Imagine a developer β let's call him Daniel β who has written Java for six years.
π― System Scenario & Requirements
System Goal
Imagine a developer β let's call him Daniel β who has written Java for six years.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- Imagine a developer β let's call him Daniel β who has written Java for six years.
- He sits down to write his first Python class and produces this: This code runs.
- But it is profoundly un-Pythonic, and every seasoned Python developer will tell you to throw it out.
Constraints & Assumptions
- Constraints are derived from the rebuilt scenario stages.
β‘ Execution Stages & Decision Trace
Stage 1 β Anatomy of a Python Class: __init__, self, and the Difference Between Instance and Class State
Before diving into inheritance and dunder methods, it helps to be precise about what a Python class actually is and where data lives.
When you call BankAccount("Alice", 100) , Python calls BankAccount.
Deep Dive
Scenario context
Imagine a developer β let's call him Daniel β who has written Java for six years. He sits down to write his first Python class and produces this:
class BankAccount:
def __init__(self):
self.__balance = 0.0 # private field
self.__owner = "" # private field
def getBalance(self): # getter
return self.__balance
def setBalance(self, value):β¦
This walkthrough focuses on **Why Every Java Developer Writes Un-Pythonic Classes on Day One**.
Before making a design choice, clarify:
- Imagine a developer β let's call him Daniel β who has written Java for six years.
- He sits down to write his first Python class and produces this: This code runs.
- But it is profoundly un-Pythonic, and every seasoned Python developer will tell you to throw it out.
**Concepts in play:** low-level-design-systems, python-concurrency-nogil, jvm-internals-and-garbage-collection, llm-prompt-engineering
## Stage 1 β Anatomy of a Python Class: __init__, self, and the Difference Between Instance and Class State
- Before diving into inheritance and dunder methods, it helps to be precise about what a Python class actually is and where data lives.
- When you call BankAccount("Alice", 100) , Python calls BankAccount.
- init with a freshly created object as the first argument (by convention named self ).
- The init method does not create the object β new does that β but it initialises the instance's attributes.
*Applies [**low-level-design-systems**](/concept/low-level-design-systems) to the decision above.*
```python
class Counter:
def __init__(self, start: int = 0):
self.count = start # instance attribute β stored in self.__dict__
a = Counter(10)
b = Counter(20)
print(a.count) # 10
print(b.count) # 20
print(a.__dict__) # {'count': 10}
class Counter:
default_step = 1 # class attribute β stored in Counter.__dict__
def __init__(self, start: int = 0):
self.count = start
def increment(self):
self.count += self.default_step
c = Counter()
c.increment()
print(c.count) # 1
Counter.default_step = 5
c.increment()
print(c.count) # 6 β Counter.default_step changed for everyone
c.default_step = 10 # now c has its own instance-level shadow
c.increment()
print(c.count) # 16 β uses c's own shadow, not the class attribute
Observable outcome: The init method does not create the object β new does that β but it initialises the instance's attributes.
Stage 2 β Inheritance, super(), and How Python Decides Which Method to Call
- Python supports both single inheritance (one parent) and multiple inheritance (multiple parents).
- Multiple inheritance is powerful but introduces ambiguity when two parent classes define the same method.
- Python resolves this with the Method Resolution Order (MRO) , computed using the C3 linearisation algorithm.
- The MRO for any class is a deterministic, left-to-right depth-first ordering with the constraint that a parent never appears before all of its children.
class A:
def hello(self): return "A"
class B(A):
def hello(self): return "B"
class C(A):
def hello(self): return "C"
class D(B, C):
pass
print(D.mro())
# [<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>]
d = D()
print(d.hello()) # "B"
class Animal:
def __init__(self, name: str):
self.name = name
class Flyable:
def __init__(self):
self.can_fly = True
class Bird(Animal, Flyable):
def __init__(self, name: str):
super().__init__(name) # calls Animal.__init__ per MRO
self.can_fly = True
Observable outcome: The MRO for any class is a deterministic, left-to-right depth-first ordering with the constraint that a parent never appears before all of its children.
Stage 3 β Deep Dive: The Python Object Model and Its Performance Implications
- The Internals of Python Object Model Every Python object has three core attributes: an identity ( id(obj) β its memory address), a type ( type(obj) β its class), and a value (stored in obj.
- Understanding how Python looks up attributes via dict explains almost every OOP behaviour you encounter.
- Attribute lookup chain: When you write obj.attr , Python executes roughly this sequence: 1.
- mro for a data descriptor (an object with both get and set ).
class Circle:
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
# The property object lives in the class __dict__:
print(type(Circle.__dict__['radius'])) # <class 'property'>
print(Circle.__dict__['radius'].fget) # <function Circle.radius ...>
class Point:
__slots__ = ('x', 'y')
def __init__(self, x: float, y: float):
self.x = x
self.y = y
p = Point(1.0, 2.0)
# p.__dict__ does not exist β AttributeError
# p.z = 3.0 would raise AttributeError β no new attributes allowed
Observable outcome: mro for a data descriptor (an object with both get and set ).
Stage 4 β Visualising the Shape Hierarchy: A Real Domain Class Diagram
- The following diagram models a geometry domain with a Shape base class and two concrete subclasses, Circle and Rectangle .
- It shows inheritance relationships, shared dunder methods, and the contract each class fulfils.
- Shape defines the interface contract: any concrete subclass must implement area() and perimeter() .
- The eq and hash methods live on Shape so that two shapes with the same colour and dimensions compare as equal and can be stored in sets without duplicates.
classDiagram
class Shape {
+str color
+__init__(color: str)
+area() float
+perimeter() float
+__str__() str
+__eq__(other) bool
+__hash__() int
}
class Circle {
+float radius
+__init__(color: str, radius: float)
+area() float
+perimeter() float
+__str__() str
}
class Rectangle {
+float width
+float height
+__init__(color: str, width: float, height: float)
+area() float
+perimeter() float
+__str__() str
}
Shape <|-- Circle : inherits
Shape <|-- Rectangle : inherits
Observable outcome: The eq and hash methods live on Shape so that two shapes with the same colour and dimensions compare as equal and can be stored in sets without duplicates.
Stage 5 β Python OOP in the Wild: Dataclasses, Context Managers, and Custom Iterators
- Beyond basic class definitions, Python's real power comes from implementing specific dunder methods that let your objects integrate seamlessly with built-in language constructs.
- @dataclass for clean data containers eliminates boilerplate for pure data classes.
- Instead of writing init , repr , and eq manually, you declare fields with type annotations and let the decorator generate them: Context managers with enter and exit make your class work with with statements, guaranteeinβ¦
- This is how Python handles files, database connections, locks β any resource that must be released: Custom iterators with iter and next let your objects work with for loops, list() , sum() , and any other construct thatβ¦
from dataclasses import dataclass, field
from typing import List
@dataclass
class Order:
order_id: str
customer: str
items: List[str] = field(default_factory=list)
total: float = 0.0
def add_item(self, item: str, price: float) -> None:
self.items.append(item)
self.total += price
o = Order("ORD-001", "Alice")
o.add_item("Widget", 9.99)
print(o)
# Order(order_id='ORD-001', customer='Alice', items=['Widget'], total=9.99)
class ManagedConnection:
def __init__(self, host: str):
self.host = host
self.conn = None
def __enter__(self):
print(f"Connecting to {self.host}")
self.conn = {"host": self.host, "active": True} # simulate connection
return self.conn
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"Closing connection to {self.host}")
self.conn["active"] = False
return False # do not suppress exceptions
with ManagedConnection("db.prod.example.com") as conn:
print(f"Using: {conn}")
# Prints: Connecting, Using, Closing β even if an exception is raised inside the block
Observable outcome: This is how Python handles files, database connections, locks β any resource that must be released: Custom iterators with iter and next let your objects work with for loops, list() , sum() , and any other construct thatβ¦
Stage 6 β Building a Payment Domain in Python: A Complete Runnable Example
- This section demonstrates a realistic payment processing domain model.
- It combines inheritance, abstract base classes, dataclasses, dunder methods, and properties into a single runnable Python module.
- The goal is to show how these concepts compose in production-shaped code β not toy examples.
- Pay attention to how @abstractmethod enforces the process() contract, how repr makes debugging easy, and how eq and hash let payments be deduplicated in a set.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import List
# ββ Value Object ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass(frozen=True)
class Money:
"""Immutable value object representing an amount in a given currency."""
amount: float
currency: str = "USD"
def __post_init__(self):
if self.amount < 0:
raise ValueError(f"Amount cannot be negative: {self.amount}")
def __add__(self, other: Money) -> Money:
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
def __str__(self) -> str:
return f"{self.currency} {self.amount:.2f}"
# ββ Abstract Base βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class Payment(ABC):
"""Base class for all payment types. Enforces process() contract."""
def __init__(self, payment_id: str, amount: Money):
self._payment_id = payment_id
self._amount = amount
self._processed_at: datetime | None = None
@property
def payment_id(self) -> str:
return self._payment_id
@property
def amount(self) -> Money:
return self._amount
@property
def is_processed(self) -> bool:
return self._processed_at is not None
@abstractmethod
def process(self) -> bool:
"""Execute the payment. Returns True on success."""
...
@abstractmethod
def _provider_name(self) -> str:
"""Human-readable provider name for repr/logging."""
...
def __repr__(self) -> str:
status = "processed" if self.is_processed else "pending"
return (
f"{self.__class__.__name__}("
f"id={self._payment_id!r}, "
f"amount={self._amount}, "
f"status={status!r})"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Payment):
return NotImplemented
return self._payment_id == other._payment_id
def __hash__(self) -> int:
return hash(self._payment_id)
# ββ Concrete Implementations ββββββββββββββββββββββββββββββββββββββββββββββββββ
class CreditCardPayment(Payment):
"""Payment via credit card with last-four-digits masking."""
def __init__(self, payment_id: str, amount: Money, card_number: str):
super().__init__(payment_id, amount)
if len(card_number) < 4:
raise ValueError("Invalid card number")
self._last_four = card_number[-4:]
@property
def masked_card(self) -> str:
return f"****-****-****-{self._last_four}"
def process(self) -> bool:
print(f"Charging {self._amount} to card {self.masked_card}")
self._processed_at = datetime.now()
return True
def _provider_name(self) -> str:
return "CreditCard"
class PayPalPayment(Payment):
"""Payment routed through PayPal with email association."""
def __init__(self, payment_id: str, amount: Money, paypal_email: str):
super().__init__(payment_id, amount)
self._email = paypal_email
def process(self) -> bool:
print(f"Sending {self._amount} via PayPal to {self._email}")
self._processed_at = datetime.now()
return True
def _provider_name(self) -> str:
return "PayPal"
# ββ Payment Processor (uses duck typing, not isinstance) ββββββββββββββββββββββ
@dataclass
class PaymentProcessor:
"""Processes a batch of payments. Works with any Payment subclass."""
payments: List[Payment] = field(default_factory=list)
def add(self, payment: Payment) -> None:
self.payments.append(payment)
def process_all(self) -> None:
for payment in self.payments:
if not payment.is_processed:
success = payment.process()
if success:
print(f" OK: {payment}")
else:
print(f" FAILED: {payment}")
def total(self) -> float:
return sum(p.amount.amount for p in self.payments if p.is_processed)
# ββ Demo ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if __name__ == "__main__":
processor = PaymentProcessor()
processor.add(CreditCardPayment(
"PAY-001", Money(49.99), "4111111111111234"
))
processor.add(PayPalPayment(
"PAY-002", Money(19.99), "alice@example.com"
))
processor.add(CreditCardPayment(
"PAY-003", Money(99.00), "5500005555555559"
))
processor.process_all()
print(f"\nTotal processed: USD {processor.total():.2f}")
# Deduplication via __eq__ and __hash__
p1 = CreditCardPayment("PAY-001", Money(49.99), "4111111111111234")
p2 = CreditCardPayment("PAY-001", Money(49.99), "4111111111111234")
unique = {p1, p2}
print(f"\nDuplicate check β unique payments in set: {len(unique)}") # 1
Charging USD 49.99 to card ****-****-****-1234
OK: CreditCardPayment(id='PAY-001', amount=USD 49.99, status='processed')
Sending USD 19.99 via PayPal to alice@example.com
OK: PayPalPayment(id='PAY-002', amount=USD 19.99, status='processed')
Charging USD 99.00 to card ****-****-****-5559
OK: CreditCardPayment(id='PAY-003', amount=USD 99.00, status='processed')
Total processed: USD 168.98
Duplicate check β unique payments in set: 1
Observable outcome: Pay attention to how @abstractmethod enforces the process() contract, how repr makes debugging easy, and how eq and hash let payments be deduplicated in a set.
End-to-end outcome
After working through the stages, you should be able to explain:
- Start with @dataclass , upgrade when you feel friction.
- Plain @dataclass handles 80% of use cases.
Alternatives considered
Choosing the Right Python Data Container: dataclass vs namedtuple vs TypedDict vs dict
Python offers four main ways to represent structured data, and the right choice depends on mutability, type safety, performance, and serialization needs: When to use @dataclass(frozen=True) : whenever the object represents a value (a point, a currency amount, a date range) rather than an entity.
Python OOP Decision Guide: Matching Your Need to the Right Construct
Python OOP Decision Guide: Matching Your Need to the Right Construct
Failure and degraded modes
The source does not isolate a separate failure mode. Limits that matter for the scenario are called out in the stage decisions above.
