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 property for validation when setting attributes and for computed attributes (values derived from other attributes) without changing how they’re used from outside.
  • @classmethod for factory constructors — the idiomatic way to create instances from a different data format (User.from_string("name:email")).
  • @staticmethod for utilities conceptually related to the class but needing no access to self or cls.
  • 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.
  • dataclass removes the __init__, __repr__, __eq__ boilerplate for data classes. Use field(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.

← Previous: Functions   Next: Interfaces →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact