Engineering Notes
Home/Languages/Python/OOP: Classes and Objects
Languages · Python

OOP: Classes and Objects

__init__ is the initializer, called automatically right after a new instance is created. self is the instance itself — Python passes it explicitly as the first parameter of every instance method (there's no implicit this).

💻 Languages● Intermediate⏱ 6 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Defining a Class
  • Instance Attributes vs Class Attributes
  • Dunder (Magic) Methods
  • Properties — Computed Attributes with Attribute Syntax
  • __slots__ — Opting Out of Per-Instance __dict__

1Defining a Class

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def distance_from_origin(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

p = Point(3, 4)
print(p.distance_from_origin())  # 5.0

__init__ is the initializer, called automatically right after a new instance is created. self is the instance itself — Python passes it explicitly as the first parameter of every instance method (there's no implicit this).

2Instance Attributes vs Class Attributes

python
class Dog:
    species = "Canis familiaris"   # class attribute — shared by ALL instances

    def __init__(self, name):
        self.name = name             # instance attribute — unique per instance

d1 = Dog("Rex")
d2 = Dog("Fido")
print(d1.species, d2.species)   # both "Canis familiaris" — same shared object
Dog.species = "changed"
print(d1.species)                # "changed" — instances see class-level changes

d1.species = "just d1's"          # creates a NEW instance attribute, shadowing the class one
print(d1.species, d2.species)     # "just d1's" "changed"

A class attribute lives once on the class object; an instance attribute lives on that specific instance's __dict__. Reading d1.species looks in d1.__dict__ first, and only falls back to Dog.__dict__ if not found there — which is exactly why assigning d1.species = ... shadows rather than mutates the shared value.

3Dunder (Magic) Methods

python
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __repr__(self):
        return f"Money({self.amount})"

    def __eq__(self, other):
        return isinstance(other, Money) and self.amount == other.amount

    def __add__(self, other):
        return Money(self.amount + other.amount)

    def __lt__(self, other):
        return self.amount < other.amount

a, b = Money(10), Money(5)
print(a + b)       # Money(15) — uses __add__
print(a == Money(10))  # True — uses __eq__, not identity
print(a > b)         # True — Python derives > from __lt__ via reflection when needed

Dunder methods are how Python objects hook into built-in syntax and functions: + calls __add__, len() calls __len__, str() calls __str__, for calls __iter__, [] calls __getitem__, and so on. Defining __repr__ (unambiguous, for developers) and __str__ (readable, for end users) is standard practice — __repr__ is used as a fallback if __str__ isn't defined.

4Properties — Computed Attributes with Attribute Syntax

python
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("radius cannot be negative")
        self._radius = value

c = Circle(2)
print(c.area)     # 12.56636 — computed, called with NO parentheses
c.radius = 5        # goes through the setter, validated
c.radius = -1        # raises ValueError

@property lets a method be accessed with plain attribute syntax (c.area, not c.area()), and pairing it with @<name>.setter gives you validated, controlled attribute assignment — encapsulation without forcing callers to write explicit getter/setter method calls.

5__slots__ — Opting Out of Per-Instance __dict__

python
class Point:
    __slots__ = ("x", "y")   # no per-instance __dict__ at all

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
p.z = 3   # AttributeError — __slots__ forbids attributes not listed

Declaring __slots__ tells CPython to allocate fixed, typed storage slots for exactly those attribute names instead of giving every instance a general-purpose __dict__. This trades flexibility (no dynamically adding new attributes, more friction with multiple inheritance) for meaningfully lower per-instance memory use when creating many instances of a simple class.

6Class Methods and Static Methods

python
class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    @classmethod
    def margherita(cls):
        return cls(["mozzarella", "tomato"])   # cls, not the hardcoded class name — subclass-friendly

    @staticmethod
    def is_valid_topping(name):
        return name in {"mozzarella", "tomato", "basil", "pepperoni"}

p = Pizza.margherita()
print(Pizza.is_valid_topping("basil"))  # True

@classmethod receives the class itself (cls) instead of an instance — commonly used for alternative constructors. @staticmethod receives neither self nor cls; it's a plain function namespaced under the class purely for organization.

Under the HoodInstances, __dict__, and Attribute Lookup▾

An "ordinary" instance is a thin wrapper around a __dict__

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.__dict__)   # {'x': 1, 'y': 2} — a REAL dict, same machinery as any other dict

By default, every instance carries its own __dict__ — the same compact hash-table structure covered in the Data Structures topic. self.x = x inside __init__ is, mechanically, a dict insertion into p.__dict__. This is why arbitrary attributes can be added to most Python objects at any time (p.new_attr = 5 just works) — there's a real, general-purpose hash table backing it, not a fixed C-struct layout.

Attribute lookup walks instance __dict__, then the type, through the descriptor protocol

python
class Dog:
    species = "Canis familiaris"
    def bark(self):
        return "Woof"

d = Dog()
d.bark  # not in d.__dict__ — found on type(d).__dict__ instead, and BOUND on the way out

d.bark triggers object.__getattribute__, which checks type(d).__dict__ for a data descriptor (something with both __get__ and __set__, like a property) first, then d.__dict__, then type(d).__dict__ again for a non-data descriptor (something with only __get__, like a plain function) or a plain class attribute. Functions implement __get__, which is precisely the mechanism that turns Dog.bark (a plain function) into a bound method when accessed through an instance (see the Functions topic's "bound methods" section for the full descriptor mechanics). property is implemented the same way — as a data descriptor — which is why a property takes priority over an instance attribute of the same name even though instance __dict__ is normally checked first.

Attribute lookup order: data descriptors on the type win first, then instance __dict__, then non-data descriptors/class attributes
Attribute lookup order: data descriptors on the type win first, then instance __dict__, then non-data descriptors/class attributes

__slots__ trades the __dict__ for fixed C-level slots

When a class defines __slots__, CPython does not allocate a __dict__ for its instances at all (unless a base class still provides one). Instead, each named slot becomes a descriptor on the class that reads/writes a fixed-offset C struct member directly on the instance — closer to how a field works in a statically-typed language. This is strictly a memory/speed trade-off: no __dict__ per instance means no hash table overhead per object (meaningful when instantiating millions of small objects), at the cost of losing dynamic attribute assignment and needing every base class in the hierarchy to cooperate with __slots__ to fully avoid a __dict__ appearing anyway.

⚠️ Common Pitfalls

  1. Mutable class attributes shared unintentionally — class Foo: items = [] creates ONE list shared by every instance unless overridden in __init__; mutating it through one instance affects all of them. Initialize mutable state in __init__ as self.items = [].
  2. Forgetting self isn't magic — it's just the first parameter, conventionally named self; def method(this): ... works identically, but don't do it — every Python codebase expects self.
  3. Defining __eq__ without __hash__ — Python automatically sets __hash__ = None when you define __eq__ without __hash__, making instances unhashable (can't go in a set or be a dict key) unless you explicitly define __hash__ too.
  4. Expecting __slots__ to save memory when a base class doesn't also use it — if any class in the MRO lacks __slots__, instances still get a __dict__, and the memory savings evaporate.

📋 Quick Reference

python
class C:
    class_attr = 0                  # shared across all instances
    def __init__(self, x):
        self.x = x                    # instance attribute, in self.__dict__
    def __repr__(self): ...            # repr(obj) / debugger display
    def __eq__(self, other): ...        # obj == other
    @property
    def computed(self): ...              # obj.computed — no parens
    @classmethod
    def from_x(cls, x): return cls(x)     # alternative constructor
    @staticmethod
    def helper(): ...                       # no self/cls at all
    __slots__ = ("x", "y")                    # no per-instance __dict__

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How do classes work in Python?core▾

class defines attributes and methods; __init__ initializes instances; self is the explicit instance reference. Class attributes are shared; instance attributes belong to each object.

Follow-ups interviewers ask
  • Is __init__ a constructor? It's an initializer; __new__ creates the object.
Q2What are dunder (magic) methods?core▾

Special methods implementing protocols: __repr__/__str__, __eq__/__hash__, __lt__ (ordering), __len__, __iter__, __getitem__, __enter__/__exit__, __call__.

Follow-ups interviewers ask
  • __repr__ vs __str__? repr for developers (unambiguous); str for users.
Q3What are dataclasses and when do you use them?core▾

@dataclass generates __init__, __repr__, __eq__ (and optionally ordering, frozen immutability, slots) from type-annotated fields, ideal for data containers.

Follow-ups interviewers ask
  • Mutable default fields? Use field(default_factory=list).
Q4staticmethod vs classmethod vs instance method?core▾

Instance methods receive self; @classmethod receives the class (cls), used for alternative constructors; @staticmethod receives neither, a namespaced function.

Follow-ups interviewers ask
  • Alternative constructor example? User.from_dict(data).
Q5What are properties and __slots__?senior▾

@property exposes computed or validated attributes with attribute syntax; __slots__ declares fixed attributes, saving memory and speeding access by avoiding per-instance __dict__.

Follow-ups interviewers ask
  • When use slots? Many small objects.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.