Skip to main content
Published / updated

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
PrefixMeaning
namePublic
_nameInternal — do not use from outside
__nameName-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
MethodCalled 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")
DecoratorFirst parameterUse for
(none)selfInstance behaviour
@classmethodclsAlternative constructors
@staticmethodnoneA 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

MessageCauseFix
TypeError: __init__() missing 1 required positional argumentConstructor argument not passedPass it
AttributeError: object has no attribute 'x'Never assigned in __init__, or a typoAssign it
All instances share the same listMutable class attributeCreate it in __init__
TypeError: unhashable type after defining __eq____eq__ without __hash__Define both
A property setter never runsAssigned to a different attribute nameMatch the names

A mutable class attribute is shared by every instance. Lists and dicts belong in __init__, not on the class.

Common mistakes

  • Forgetting self in 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
  • @staticmethod where a module function belongs
  • A stateless class with one method
  • Treating _name as enforced privacy
  • Deep multiple inheritance

Practice

  1. Write a Student class with __init__, __str__ and __repr__. Print a list of three and compare with and without __repr__.
  2. Omit self from a method and read the TypeError.
  3. Add a mutable class attribute list and append to it from __init__. Create three instances and confirm the sharing.
  4. Write a FeeAccount with outstanding as a property. Try to assign to it.
  5. Store outstanding as an attribute instead. Record a payment without updating it and confirm the drift.
  6. Add __eq__ without __hash__, then put instances in a set. Record the error.
  7. Add both and confirm the set deduplicates by roll number.
  8. Write from_csv_row as a @classmethod using cls. Subclass and confirm you get the subclass.
  9. Write a Person base and a Teacher subclass. Omit super().__init__() and find where it fails.
  10. Define an ABC with @abstractmethod and try to instantiate it.
  11. Rewrite Student as a dataclass. Compare the line counts.
  12. Write subjects: list[str] = [] in a dataclass and read the error.
  13. Use frozen=True and put instances in a set. Then try to mutate one.
  14. Add __post_init__ validation and construct an invalid instance.
  15. Define a Protocol and pass two unrelated classes to a function typed with it. Run mypy.
  16. 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 @property for computed and validated values
  • Use dataclasses for record types
  • Avoid the mutable class attribute trap

Review questions

  1. Why is a mutable class attribute dangerous?
  2. What breaks when __eq__ is defined without __hash__?
  3. Why should a derived value be a property rather than an attribute?
  4. How does Python's encapsulation differ from C#'s, and what does that mean in practice?
  5. When is composition better than inheritance?

Next: Testing and quality