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).
🎯 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
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
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
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 neededDunder 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
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__
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 listedDeclaring __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
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__
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 dictBy 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
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 outd.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.
__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
- 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__asself.items = []. - Forgetting
selfisn't magic — it's just the first parameter, conventionally namedself;def method(this): ...works identically, but don't do it — every Python codebase expectsself. - Defining
__eq__without__hash__— Python automatically sets__hash__ = Nonewhen you define__eq__without__hash__, making instances unhashable (can't go in asetor be adictkey) unless you explicitly define__hash__too. - 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
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
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.
- 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__.
- __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.
- 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.
- 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__.
- 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.