Object-Oriented Python
Before you start
You need: functions (Article 04) and modules (Article 07).
Time: about 50 minutes, plus the practice.
Learning objective
Model application concepts as classes, use dataclasses where they fit, and know when a function is the better answer.
Topics
- Classes and
__init__ - Attributes and
self - Methods
- Encapsulation and properties
- Dunder methods
- Class and static methods
- Inheritance
- Composition
- Dataclasses
- Protocols
Classes
class Student:
def __init__(self, name: str, roll_number: str, class_name: str) -> None:
self.name = name
self.roll_number = roll_number
self.class_name = class_name
self.status = "Active"
def display_label(self) -> str:
return f"{self.name} ({self.roll_number})"
student = Student("Ravi Kumar", "NCA-2024-0012", "10th")
print(student.display_label())
__init__ is the constructor. self is the instance and must be the first parameter of every instance method — Python passes it automatically at the call site, but it is explicit in the definition.
Omitting it gives:
TypeError: display_label() takes 0 positional arguments but 1 was given
which is Python telling you the method is missing self.
Attributes are created by assignment, so there is no separate declaration. That also means a typo creates a new attribute rather than failing:
student.nmae = "Ravi" # no error — a new attribute
__slots__ prevents that, at the cost of flexibility:
class Student:
__slots__ = ("name", "roll_number", "class_name", "status")
Now student.nmae = ... raises AttributeError, and instances use less memory.
Class versus instance attributes
class Student:
school_code = "NCA" # class attribute — shared
all_students = [] # DANGEROUS — shared and mutable
def __init__(self, name: str) -> None:
self.name = name # instance attribute
self.all_students.append(name) # appends to the SHARED list
A mutable class attribute is shared by every instance — the same trap as a mutable default argument. Every Student appends to one list, and it grows forever.
class Student:
school_code = "NCA" # immutable — fine to share
def __init__(self, name: str) -> None:
self.name = name
self.subjects: list[str] = [] # per instance
Immutable class attributes are useful for constants. Mutable ones almost never are.
Privacy by convention
class FeeAccount:
def __init__(self, total_fees: Decimal) -> None:
self.total_fees = total_fees # public
self._paid_amount = Decimal("0") # internal, by convention
self.__receipt_seed = 1000 # name-mangled
| Prefix | Meaning |
|---|---|
name | Public |
_name | Internal — do not use from outside |
__name | Name-mangled to _ClassName__name |
Python has no access modifiers. _paid_amount is a convention every Python developer respects; nothing enforces it.
Double underscore mangles the name to avoid collisions in subclasses. It is not privacy — account._FeeAccount__receipt_seed still works. Use it rarely.
This convention, together with the properties below, is Python's encapsulation — the same idea C# expresses with private and a validating setter. The difference is enforcement: C# refuses to compile account.PaidAmount = 999999m; Python lets you write account._paid_amount = 999999 and relies on you not to. The design goal is identical — one controlled place where a value changes — but in Python it is a shared agreement rather than a compiler rule.
Properties
class FeeAccount:
def __init__(self, total_fees: Decimal, discount: Decimal = Decimal("0")) -> None:
self.total_fees = total_fees
self.discount = discount
self._paid_amount = Decimal("0")
@property
def paid_amount(self) -> Decimal:
return self._paid_amount
@property
def outstanding(self) -> Decimal:
return self.total_fees - self.discount - self._paid_amount
@property
def is_settled(self) -> bool:
return self.outstanding <= 0
def record_payment(self, amount: Decimal) -> None:
if amount <= 0:
raise ValueError(f"Payment must be positive, got {amount}")
if amount > self.outstanding:
raise PaymentExceedsBalanceError(amount, self.outstanding)
self._paid_amount += amount
account.outstanding # a method call that looks like an attribute
account.paid_amount = 100 # AttributeError — read-only
Start with plain attributes. Adding a property later does not break callers, because the access syntax is identical — which is why Python has no equivalent of C#'s "always write a property" rule.
Add a property when you need a computed value, validation on set, or a read-only view.
@property
def total_fees(self) -> Decimal:
return self._total_fees
@total_fees.setter
def total_fees(self, value: Decimal) -> None:
if value < 0:
raise ValueError(f"total_fees cannot be negative, got {value}")
self._total_fees = value
outstanding and is_settled are derived, not stored. Storing them would create two sources of truth that drift the first time a payment is recorded without updating both.
Note record_payment is a method, not a setter — it enforces rules a bare assignment could not.
Dunder methods
class Student:
def __init__(self, name: str, roll_number: str, marks: int) -> None:
self.name = name
self.roll_number = roll_number
self.marks = marks
def __str__(self) -> str:
return f"{self.name} ({self.roll_number})"
def __repr__(self) -> str:
return f"Student(name={self.name!r}, roll_number={self.roll_number!r}, marks={self.marks})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Student):
return NotImplemented
return self.roll_number == other.roll_number
def __hash__(self) -> int:
return hash(self.roll_number)
def __lt__(self, other: "Student") -> bool:
return self.marks < other.marks
| Method | Called by |
|---|---|
__str__ | str(), print() — for users |
__repr__ | repr(), the REPL, containers — for developers |
__eq__ | == |
__hash__ | hash(), set and dict membership |
__lt__ | <, and sorted() |
__len__ | len() |
__iter__ | for |
__contains__ | in |
__enter__ / __exit__ | with |
Define __repr__ on every class you debug. Without it, printing a list of students shows [<Student object at 0x7f8b...>, ...]. A good __repr__ ideally reproduces the object, which is why !r appears on the string fields.
Defining __eq__ without __hash__ makes instances unhashable. Python sets __hash__ = None when you define __eq__, so the class can no longer go in a set or be a dict key. Define both together, on the same field.
Returning NotImplemented for an unrelated type lets Python try the reflected operation rather than producing a wrong answer.
Class and static methods
class Student:
school_code = "NCA"
def __init__(self, name: str, roll_number: str) -> None:
self.name = name
self.roll_number = roll_number
@classmethod
def from_csv_row(cls, row: dict[str, str]) -> "Student":
return cls(name=row["Name"].strip(), roll_number=row["RollNumber"].strip())
@classmethod
def create_with_generated_roll(cls, name: str, year: int, sequence: int) -> "Student":
return cls(name=name, roll_number=f"{cls.school_code}-{year}-{sequence:04d}")
@staticmethod
def is_valid_roll_number(value: str) -> bool:
return bool(re.fullmatch(r"NCA-\d{4}-\d{4}", value))
student = Student.from_csv_row(row)
Student.is_valid_roll_number("NCA-2024-0012")
| Decorator | First parameter | Use for |
|---|---|---|
| (none) | self | Instance behaviour |
@classmethod | cls | Alternative constructors |
@staticmethod | none | A related function needing no state |
@classmethod is how Python does named constructors, since it has no overloading. from_csv_row, from_dict, from_json are the convention.
Using cls rather than the class name means a subclass gets an instance of itself.
A @staticmethod that never touches the class is usually better as a module-level function.
Inheritance
class Person:
def __init__(self, name: str, school_id: int) -> None:
self.name = name
self.school_id = school_id
def display_label(self) -> str:
return self.name
class Teacher(Person):
def __init__(self, name: str, school_id: int, employee_code: str) -> None:
super().__init__(name, school_id)
self.employee_code = employee_code
def display_label(self) -> str:
return f"{super().display_label()} ({self.employee_code})"
super().__init__(...) calls the parent constructor. Forgetting it leaves the parent's attributes unset, and the failure appears later as an AttributeError far from the cause.
Every method is overridable — there is no virtual and no sealed.
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get_by_id(self, entity_id: int) -> object | None:
...
@abstractmethod
def save(self, entity: object) -> None:
...
class StudentRepository(Repository):
def get_by_id(self, entity_id: int) -> Student | None:
...
def save(self, entity: Student) -> None:
...
ABC with @abstractmethod prevents instantiation until every abstract method is implemented — the error appears at construction, not at first call.
Prefer composition
# Inheritance for reuse — a poor fit
class StudentReport(StudentRepository):
def generate(self) -> str:
...
# Composition — the repository is a dependency, not a base class
class StudentReport:
def __init__(self, repository: StudentRepository) -> None:
self._repository = repository
def generate(self) -> str:
students = self._repository.get_all()
...
Inheritance is for "is-a"; composition is for "has-a". A report is not a repository — it uses one. Inheriting to reuse a few methods couples the two permanently and makes testing harder.
Python supports multiple inheritance, resolved by the MRO (Student.__mro__). It is worth knowing it exists and worth avoiding — a deep multiple-inheritance hierarchy is difficult to reason about.
Dataclasses
Most application classes are records: fields, equality, and a readable repr.
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
@dataclass
class Student:
name: str
roll_number: str
class_name: str
section: str
date_of_birth: date
parent_phone: str
address: str | None = None
status: str = "Active"
subjects: list[str] = field(default_factory=list)
student = Student(
name="Ravi Kumar",
roll_number="NCA-2024-0012",
class_name="10th",
section="A",
date_of_birth=date(2009, 5, 14),
parent_phone="9951510727"
)
print(student) # Student(name='Ravi Kumar', roll_number='NCA-2024-0012', ...)
student == other # field-by-field comparison
@dataclass generates __init__, __repr__ and __eq__ from the annotations. That is thirty lines of boilerplate removed.
field(default_factory=list) is mandatory for a mutable default. Writing subjects: list[str] = [] raises ValueError at class definition — the dataclass machinery catches the shared-mutable trap for you, which plain classes do not.
@dataclass(frozen=True)
class ExamResult:
student_id: int
exam_id: int
marks_obtained: int | None
is_absent: bool
frozen=True makes instances immutable and hashable, so they can be dict keys or set members. Good for value objects.
@dataclass(order=True)
class Score:
marks: int
name: str = field(compare=False)
order=True generates comparison methods. compare=False excludes a field from both equality and ordering.
@dataclass
class FeeAccount:
total_fees: Decimal
discount: Decimal = Decimal("0")
paid_amount: Decimal = Decimal("0")
def __post_init__(self) -> None:
if self.total_fees < 0:
raise ValueError(f"total_fees cannot be negative, got {self.total_fees}")
@property
def outstanding(self) -> Decimal:
return self.total_fees - self.discount - self.paid_amount
__post_init__ runs after the generated __init__ — the place for validation and derived values.
Use a dataclass for anything that is mostly data. Reach for a plain class when the behaviour dominates and the fields are incidental.
For validation and parsing from untrusted input, pydantic is the standard choice — and it is what FastAPI uses, covered in the API articles.
Protocols
from typing import Protocol
class SupportsDisplayLabel(Protocol):
def display_label(self) -> str:
...
def print_all(items: list[SupportsDisplayLabel]) -> None:
for item in items:
print(item.display_label())
A Protocol describes a shape. Any class with a matching display_label satisfies it without inheriting anything — structural typing, checked by mypy rather than at run time.
This is duck typing made explicit: the type checker verifies the shape, and no base class couples the implementations together.
Prefer a Protocol over an ABC when you only need "anything with these methods", and an ABC when you want to share implementation as well.
When not to use a class
# A class with one method and no state
class PercentageCalculator:
def calculate(self, marks: int, max_marks: int) -> float:
return (marks / max_marks) * 100
# A function
def calculate_percentage(marks: int, max_marks: int) -> float:
return (marks / max_marks) * 100
A class with no state and one method is a function. Python has module-level functions, so the Java-style utility class is unnecessary.
Use a class when there is state to hold, several related operations on that state, or several implementations of one interface. Otherwise a function in a module is simpler and easier to test.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
TypeError: __init__() missing 1 required positional argument | Constructor argument not passed | Pass it |
AttributeError: object has no attribute 'x' | Never assigned in __init__, or a typo | Assign it |
| All instances share the same list | Mutable class attribute | Create it in __init__ |
TypeError: unhashable type after defining __eq__ | __eq__ without __hash__ | Define both |
| A property setter never runs | Assigned to a different attribute name | Match the names |
A mutable class attribute is shared by every instance. Lists and dicts belong in __init__, not on the class.
Common mistakes
- Forgetting
selfin a method signature - A mutable class attribute shared by every instance
subjects: list[str] = []in a dataclass- No
__repr__, making debugging opaque __eq__without__hash__- Forgetting
super().__init__() - Inheritance used for code reuse rather than "is-a"
- Storing a derived value instead of a property
- A property that does expensive work, hiding the cost behind attribute syntax
@staticmethodwhere a module function belongs- A stateless class with one method
- Treating
_nameas enforced privacy - Deep multiple inheritance
Practice
- Write a
Studentclass with__init__,__str__and__repr__. Print a list of three and compare with and without__repr__. - Omit
selffrom a method and read theTypeError. - Add a mutable class attribute list and append to it from
__init__. Create three instances and confirm the sharing. - Write a
FeeAccountwithoutstandingas a property. Try to assign to it. - Store
outstandingas an attribute instead. Record a payment without updating it and confirm the drift. - Add
__eq__without__hash__, then put instances in a set. Record the error. - Add both and confirm the set deduplicates by roll number.
- Write
from_csv_rowas a@classmethodusingcls. Subclass and confirm you get the subclass. - Write a
Personbase and aTeachersubclass. Omitsuper().__init__()and find where it fails. - Define an ABC with
@abstractmethodand try to instantiate it. - Rewrite
Studentas a dataclass. Compare the line counts. - Write
subjects: list[str] = []in a dataclass and read the error. - Use
frozen=Trueand put instances in a set. Then try to mutate one. - Add
__post_init__validation and construct an invalid instance. - Define a
Protocoland pass two unrelated classes to a function typed with it. Runmypy. - Convert a stateless single-method class into a function.
Exercises 3, 5 and 12 correspond to three real bugs.
You can now
- Model concepts as classes with
__init__ - Say what encapsulation means in Python, and how it differs from C#
- Use
@propertyfor computed and validated values - Use dataclasses for record types
- Avoid the mutable class attribute trap
Review questions
- Why is a mutable class attribute dangerous?
- What breaks when
__eq__is defined without__hash__? - Why should a derived value be a property rather than an attribute?
- How does Python's encapsulation differ from C#'s, and what does that mean in practice?
- When is composition better than inheritance?
Next: Testing and quality