Classes #
A class is a blueprint for creating objects — combining data (attributes) and behavior (methods) into one structured unit. Python fully supports object-oriented programming (OOP), but its approach is more flexible than languages like Java: there’s no private or public keyword, encapsulation is implemented through conventions, and Python supports multiple inheritance. Understanding how classes work in Python — including when not to use them — is the key to writing well-organized code.
Defining Classes and Instances #
class Student:
"""Represents a student's data."""
def __init__(self, name: str, student_id: str, major: str):
"""Initialize a student's instance attributes."""
self.name = name
self.student_id = student_id
self.major = major
def display_info(self) -> str:
return f"{self.student_id} - {self.name} ({self.major})"
def __repr__(self) -> str:
return f"Student(name={self.name!r}, student_id={self.student_id!r})"
# Creating instances (objects) from the class
s1 = Student("Budi Santoso", "2021001", "Informatics")
s2 = Student("Ani Rahayu", "2021002", "Information Systems")
print(s1.display_info()) # → 2021001 - Budi Santoso (Informatics)
print(s2.name) # → Ani Rahayu
print(repr(s1)) # → Student(name='Budi Santoso', student_id='2021001')
self is the reference to the current instance — Python passes it automatically when calling an instance method. The name self is a convention, not a keyword — but don’t rename it without a strong reason.
Instance Attributes vs Class Attributes #
An important, often-overlooked distinction: instance attributes belong to each object separately, while class attributes are shared by all instances.
class BankAccount:
# Class attributes — shared by all instances
INTEREST_RATE = 0.05
account_count = 0
def __init__(self, owner: str, initial_balance: float = 0):
# Instance attributes — unique per object
self.owner = owner
self.balance = initial_balance
BankAccount.account_count += 1 # access the class attribute via the class name
def add_interest(self):
self.balance += self.balance * BankAccount.INTEREST_RATE
acc1 = BankAccount("Budi", 1_000_000)
acc2 = BankAccount("Ani", 2_500_000)
print(BankAccount.account_count) # → 2 (shared by all instances)
print(acc1.account_count) # → 2 (accessible via an instance)
print(acc1.balance) # → 1000000 (unique per instance)
print(acc2.balance) # → 2500000 (unique per instance)
# ANTI-PATTERN: mutable class attribute — a frequently overlooked trap
class Team:
members = [] # ← DANGEROUS! this list is shared by all instances
def add(self, name):
self.members.append(name)
team1 = Team()
team2 = Team()
team1.add("Budi")
print(team2.members) # → ['Budi'] ← team2 is affected too!
# CORRECT: initialize mutable attributes in __init__
class Team:
def __init__(self):
self.members = [] # a fresh list for every instance
def add(self, name):
self.members.append(name)
Encapsulation and property
#
Python has no real private, but uses an underscore-prefix convention and the property mechanism to control attribute access.
class Temperature:
"""Temperature conversion with validation via property."""
def __init__(self, celsius: float):
self._celsius = celsius # _prefix = "protected" convention
@property
def celsius(self) -> float:
"""Getter — access via temp.celsius"""
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
"""Setter with validation — temp.celsius = 25"""
if value < -273.15:
raise ValueError(f"Temperature {value}°C below absolute zero (-273.15°C)")
self._celsius = value
@property
def fahrenheit(self) -> float:
"""Computed property — getter only, no setter"""
return self._celsius * 9/5 + 32
@property
def kelvin(self) -> float:
return self._celsius + 273.15
t = Temperature(100)
print(t.celsius) # → 100 (accessed like a regular attribute)
print(t.fahrenheit) # → 212.0 (computed automatically)
print(t.kelvin) # → 373.15
t.celsius = 0 # the setter is called, validation runs
print(t.fahrenheit) # → 32.0
t.celsius = -300 # → ValueError: Temperature -300°C below absolute zero
# Double-underscore prefix — name mangling (not truly private)
class SecretAccount:
def __init__(self, pin: str):
self.__pin = pin # → renamed to _SecretAccount__pin
def verify(self, pin_input: str) -> bool:
return self.__pin == pin_input
account = SecretAccount("1234")
print(account.verify("1234")) # → True
# print(account.__pin) # AttributeError — can't access directly
print(account._SecretAccount__pin) # → 1234 (still reachable if you know the mangling)
Class Methods and Static Methods #
class User:
_registry: list = []
def __init__(self, name: str, email: str):
self.name = name
self.email = email
User._registry.append(self)
# Instance method — accesses self (instance data)
def greet(self) -> str:
return f"Hello, I'm {self.name}"
# Class method — accesses cls (the class itself), not an instance
@classmethod
def from_string(cls, data: str) -> "User":
"""Factory method — create an instance from a 'name:email' format."""
name, email = data.split(":")
return cls(name.strip(), email.strip())
@classmethod
def user_count(cls) -> int:
return len(cls._registry)
# Static method — accesses neither self nor cls
@staticmethod
def validate_email(email: str) -> bool:
"""Simple email format validation."""
return "@" in email and "." in email.split("@")[-1]
# Instance method
u1 = User("Budi", "[email protected]")
print(u1.greet()) # → Hello, I'm Budi
# Class method as a factory
u2 = User.from_string("Ani : [email protected]")
print(u2.name) # → Ani
# Class method for class-level data
print(User.user_count()) # → 2
# Static method — needs neither an instance nor the class
print(User.validate_email("[email protected]")) # → True
print(User.validate_email("not-an-email")) # → False
When to use each:
instance method → needs access to/modification of instance data (self)
class method → needs access to class data, or as a factory constructor
static method → a function conceptually related to the class
but needing neither self nor cls
Inheritance #
Inheritance lets a new class inherit attributes and methods from a parent class, then add or change behavior.
class Animal:
"""Base class for all animals."""
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def make_sound(self) -> str:
raise NotImplementedError("Subclasses must implement make_sound()")
def info(self) -> str:
return f"{self.name} ({self.age} years old): {self.make_sound()}"
class Dog(Animal):
"""Animal subclass — specifically for dogs."""
def __init__(self, name: str, age: int, breed: str):
super().__init__(name, age) # call the parent class __init__
self.breed = breed
def make_sound(self) -> str:
return "Woof woof!"
def info(self) -> str:
return f"{super().info()} [Breed: {self.breed}]"
class Cat(Animal):
def make_sound(self) -> str:
return "Meow!"
# Usage
dog = Dog("Rex", 3, "Golden Retriever")
cat = Cat("Mimi", 5)
print(dog.info()) # → Rex (3 years old): Woof woof! [Breed: Golden Retriever]
print(cat.info()) # → Mimi (5 years old): Meow!
# isinstance() to check types including inheritance
print(isinstance(dog, Dog)) # → True
print(isinstance(dog, Animal)) # → True (Dog is an Animal)
print(isinstance(dog, Cat)) # → False
super() — Calling Parent Class Methods
#
class Employee:
def __init__(self, name: str, salary: float):
self.name = name
self.salary = salary
def compute_bonus(self) -> float:
return self.salary * 0.10
class Manager(Employee):
def __init__(self, name: str, salary: float, team_size: int):
super().__init__(name, salary) # ← call Employee's __init__
self.team_size = team_size
def compute_bonus(self) -> float:
# Manager bonus = base bonus + bonus per team member
base_bonus = super().compute_bonus() # ← call Employee's compute_bonus
return base_bonus + (self.team_size * 500_000)
mgr = Manager("Budi", 15_000_000, 5)
print(mgr.compute_bonus()) # → 1_500_000 + 2_500_000 = 4_000_000
Multiple Inheritance and MRO #
Python supports multiple inheritance — a class can inherit from more than one parent class. The method lookup order is determined by the MRO (Method Resolution Order) using the C3 linearization algorithm.
Every time you access an attribute or method on an object (e.g. obj.name), Python doesn’t just search in one place. It follows a strictly defined attribute resolution path through the internal namespace dictionaries (__dict__) and the class order in the MRO.
Look at the attribute resolution flow diagram below:
flowchart TD
Start["Start Attribute Lookup: obj.name"] --> CheckInstance{"Does 'name' exist in obj.__dict__?"}
CheckInstance -->|Yes| CheckDescriptorInstance{"Is it a Data Descriptor (e.g. property)?"}
CheckDescriptorInstance -->|Yes| CallDescriptor["Call the Descriptor Getter"]
CheckDescriptorInstance -->|No| ReturnInstance["Return the Value from obj.__dict__"]
CheckInstance -->|No| LookupMRO["Search 'name' in the Class & Base Classes (MRO)"]
LookupMRO --> CheckClass{"Found in the MRO?"}
CheckClass -->|Yes| CheckDescriptorClass{"Is it a Descriptor?"}
CheckDescriptorClass -->|Yes| CallDescriptorClass["Call the Descriptor Getter"]
CheckDescriptorClass -->|No| ReturnClass["Return the Value from class.__dict__"]
CheckClass -->|No| CheckGetAttr{"Is __getattr__ defined?"}
CheckGetAttr -->|Yes| CallGetAttr["Call obj.__getattr__('name')"]
CheckGetAttr -->|No| RaiseError["Raise AttributeError"]Through this flow, Python supports dynamic features such as method overriding, properties, special methods, and dynamic fallback lookups using __getattr__.
Here’s an example of multiple inheritance with its MRO:
class Fly:
def move(self) -> str:
return "flying"
class Swim:
def move(self) -> str:
return "swimming"
class Duck(Fly, Swim):
"""A duck can fly AND swim."""
pass
duck = Duck()
print(duck.move()) # → "flying" (Fly comes first in the MRO)
# Look at the MRO order
print(Duck.__mro__)
# → (<class 'Duck'>, <class 'Fly'>, <class 'Swim'>, <class 'object'>)
# Mixin — a clean, common multiple-inheritance pattern
class JSONMixin:
"""Add JSON serialization capability to any class."""
def to_json(self) -> str:
import json
return json.dumps(self.__dict__)
class LogMixin:
"""Add logging capability to any class."""
def log(self, message: str) -> None:
print(f"[{self.__class__.__name__}] {message}")
class Product(JSONMixin, LogMixin):
def __init__(self, name: str, price: float):
self.name = name
self.price = price
p = Product("Laptop", 15_000_000)
print(p.to_json()) # → {"name": "Laptop", "price": 15000000.0}
p.log("Product created") # → [Product] Product created
Magic Methods (Dunder Methods) #
Magic methods let objects behave like Python built-in types — supporting operators, string representation, iteration, and more.
class Vector:
"""2D vector with mathematical operator support."""
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self) -> str:
"""Debug representation — called by repr() and in the REPL."""
return f"Vector({self.x}, {self.y})"
def __str__(self) -> str:
"""Human-readable representation — called by str() and print()."""
return f"({self.x}, {self.y})"
def __add__(self, other: "Vector") -> "Vector":
"""Supports the + operator"""
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other: "Vector") -> "Vector":
"""Supports the - operator"""
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar: float) -> "Vector":
"""Supports the * operator with a scalar"""
return Vector(self.x * scalar, self.y * scalar)
def __eq__(self, other: object) -> bool:
"""Supports the == operator"""
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __abs__(self) -> float:
"""Supports abs() — the vector length"""
return (self.x ** 2 + self.y ** 2) ** 0.5
def __len__(self) -> int:
"""Supports len() — the vector dimension"""
return 2
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1) # → (3, 4) __str__
print(repr(v1)) # → Vector(3, 4) __repr__
print(v1 + v2) # → (4, 6) __add__
print(v1 - v2) # → (2, 2) __sub__
print(v1 * 2) # → (6, 8) __mul__
print(v1 == v2) # → False __eq__
print(abs(v1)) # → 5.0 __abs__
print(len(v1)) # → 2 __len__
The most frequently used magic methods:
__init__ → constructor
__repr__ → debug representation (always implement it)
__str__ → human-readable representation
__eq__ → == operator
__lt__ → < operator (enables sorting)
__hash__ → hashing (required if you override __eq__)
__len__ → len()
__contains__ → 'in' operator
__iter__ → makes an object iterable
__getitem__ → access via []
__enter__ / __exit__ → context manager (with statement)
dataclass — Data Classes Without Boilerplate
#
dataclass (Python 3.7+) automatically generates __init__, __repr__, and __eq__ from type annotations — eliminating repetitive boilerplate code:
from dataclasses import dataclass, field
from typing import List
# ANTI-PATTERN: a data class with lots of manual boilerplate
class ManualProduct:
def __init__(self, name: str, price: float, stock: int = 0):
self.name = name
self.price = price
self.stock = stock
def __repr__(self):
return f"ManualProduct(name={self.name!r}, price={self.price}, stock={self.stock})"
def __eq__(self, other):
return (self.name, self.price, self.stock) == (other.name, other.price, other.stock)
# CORRECT: dataclass — far more concise
@dataclass
class Product:
name: str
price: float
stock: int = 0
tags: List[str] = field(default_factory=list) # mutable default via field()
def discount(self, percent: float) -> float:
return self.price * (1 - percent / 100)
p1 = Product("Laptop", 15_000_000, stock=10)
p2 = Product("Laptop", 15_000_000, stock=10)
p3 = Product("Mouse", 250_000)
print(p1) # → Product(name='Laptop', price=15000000, stock=10, tags=[])
print(p1 == p2) # → True (automatic __eq__)
print(p1 == p3) # → False
print(p1.discount(10)) # → 13500000.0
# frozen=True — make the dataclass immutable (like a namedtuple but more powerful)
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(-6.2, 106.8)
# c.lat = 0 # FrozenInstanceError — can't be changed
Composition vs Inheritance #
Excessive inheritance is one of the causes of hard-to-maintain code. Often composition (storing another object as an attribute) is the better choice.
# Use inheritance when:
# ✓ There's a clear IS-A relationship: Dog IS-A Animal
# ✓ Subclasses need to override or extend parent behavior
# ✓ You need polymorphism (a function working on all subclasses)
# Use composition when:
# ✓ There's a HAS-A relationship: Car HAS-A Engine
# ✓ You want to combine behavior from several sources
# ✓ The relationship can change at runtime
# Composition example
class Engine:
def __init__(self, cc: int, horsepower: int):
self.cc = cc
self.horsepower = horsepower
def info(self) -> str:
return f"{self.cc}cc, {self.horsepower}hp"
class Transmission:
def __init__(self, kind: str, gears: int):
self.kind = kind
self.gears = gears
class Car:
def __init__(self, brand: str, engine: Engine, transmission: Transmission):
self.brand = brand
self.engine = engine # HAS-A Engine
self.transmission = transmission # HAS-A Transmission
def specs(self) -> str:
return (
f"{self.brand}: "
f"Engine {self.engine.info()}, "
f"{self.transmission.kind} transmission with {self.transmission.gears} gears"
)
v6_engine = Engine(3500, 280)
automatic = Transmission("Automatic", 8)
car = Car("Toyota Camry", v6_engine, automatic)
print(car.specs())
# → Toyota Camry: Engine 3500cc, 280hp, Automatic transmission with 8 gears
Summary #
- Don’t put mutable attributes at class level — lists and dicts as class attributes are shared by all instances and cause hidden bugs. Initialize them in
__init__.- Use
propertyfor validation when setting attributes and for computed attributes (values derived from other attributes) without changing how they’re used from outside.@classmethodfor factory constructors — the idiomatic way to create instances from a different data format (User.from_string("name:email")).@staticmethodfor utilities conceptually related to the class but needing no access toselforcls.- Always call
super().__init__()in a subclass’s__init__so the parent’s attributes are initialized correctly.- Always implement
__repr__— very helpful when debugging and in the interactive REPL.dataclassremoves the__init__,__repr__,__eq__boilerplate for data classes. Usefield(default_factory=list)for mutable attributes.- Composition (HAS-A) is often better than inheritance (IS-A) — use inheritance only when there’s a clear IS-A relationship and polymorphism is needed.