OOP Part I
Everything in Python is an object. Integers, strings, functions, and even classes themselves are objects. Object-oriented programming (OOP) takes advantage of this by building programs around objects that combine data and behavior.
OOP in Python is more hands-on than theoretical. The syntax is minimal, but the details matter: class attributes behave differently from instance attributes,
__repr__is not the same as__str__, and defining__eq__silently removes hashability. These are the parts that show up in interviews.
What is OOP
Object-oriented programming organizes code around objects instead of procedures. An object bundles data (attributes) and behavior (methods) into a single unit. This makes large programs easier to model, reason about, and extend.
Four concepts form the backbone of OOP:
- Encapsulation — bundling data and behavior together and controlling access
- Inheritance — one class reusing or extending another
- Polymorphism — different objects responding to the same interface
- Abstraction — hiding implementation details behind a clean interface
Part I covers the foundational mechanics. Part II covers these four pillars in depth.
Classes and Objects
A class is a blueprint. An object (also called an instance) is a concrete thing built from that blueprint.
| Term | Meaning |
|---|---|
| class | Blueprint defining structure and behavior |
| object / instance | A concrete entity created from a class |
| instantiation | The act of creating an object from a class |
| attribute | Data stored on a class or instance |
| method | A function defined inside a class |
class Dog:
pass
rex = Dog() # creates an instance of Dog
print(type(rex)) # <class '__main__.Dog'>
print(isinstance(rex, Dog)) # TrueDog is the class. rex is an object (instance) of Dog. Multiple independent objects can be created from the same class; each is separate.
__new__ vs __init__: Python uses two steps to create an object. __new__ is the actual constructor — it allocates memory and returns the new instance. __init__ is the initializer — it runs immediately after and sets up the instance state. In everyday code, only __init__ is ever defined. __new__ is reserved for advanced use cases like singletons or subclassing immutable types (int, str, tuple).
__init__ and self
__init__ is the initializer. It runs automatically right after a new instance is created. Its job is to attach the starting state to the instance.
self is the first parameter of every instance method. Python automatically passes the instance as self; the name is a convention, not a keyword. Any valid identifier works, but self is universal.
class Dog:
def __init__(self, name, age):
self.name = name # the instance attribute
self.age = age # the instance attribute
rex = Dog("Rex", 4)
print(rex.name) # Rex
print(rex.age) # 4Dog("Rex", 4) is equivalent to Dog.__init__(rex, "Rex", 4) under the hood. Python passes the new instance as self; the remaining arguments fill name and age.
__init__ must return None: If __init__ returns anything other than None, Python raises TypeError: __init__() should return None.
Instance Attributes vs Class Attributes
| Type | Where defined | Shared? | Access |
|---|---|---|---|
| Instance attribute | Inside methods on self | No — each instance has its own | obj.attr |
| Class attribute | Directly in the class body | Yes — all instances see the same | Class.attr or obj.attr |
class Counter:
total = 0 # the class attribute; shared by all instances
def __init__(self, value):
self.value = value # the instance attribute; unique per instance
Counter.total += 1
a = Counter(2)
b = Counter(4)
print(Counter.total) # 2
print(a.value) # 2
print(b.value) # 4Shadowing: Assigning to instance.attr when a class attribute of the same name exists creates a new instance attribute that hides (shadows) the class attribute. The class attribute itself is unchanged.
class Config:
limit = 10
c = Config()
c.limit = 20 # creates a new instance attribute
print(Config.limit) # 10 -- the class attribute is unchanged
print(c.limit) # 20 -- the instance attribute
del c.limit # removes the instance attribute
print(c.limit) # 10 -- the class attribute is visible againMutable class attributes: If the class attribute is a mutable object (a list, dict, etc.), all instances share the same object. Mutating it via .append() or .update() from any instance affects all of them.
class Team:
members = [] # shared mutable -- a common bug source
def add(self, name):
Team.members.append(name)
t1 = Team()
t2 = Team()
t1.add("Alice")
print(t2.members) # ['Alice'] -- t2 sees the mutation tooThe fix is to initialize mutable attributes in __init__ as instance attributes:
class Team:
def __init__(self):
self.members = [] # each instance gets its own listInstance Methods
An instance method takes self as its first parameter and can read and write instance attributes.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def scale(self, factor):
self.width *= factor
self.height *= factor
r = Rectangle(4, 6)
print(r.area()) # 24
r.scale(2)
print(r.area()) # 96Calling r.area() is equivalent to calling Rectangle.area(r). Python handles the translation automatically.
Class Methods and Static Methods
| Decorator | First parameter | Accesses | Typical use |
|---|---|---|---|
| (none) | self | Instance and class | Most methods |
@classmethod | cls | Class only | Alternative constructors, class-level state |
@staticmethod | (none) | Neither | Utility functions grouped with the class |
class Temperature:
unit = "Celsius"
def __init__(self, value):
self.value = value
@classmethod
def from_fahrenheit(cls, f):
"""Alternative constructor from Fahrenheit."""
celsius = (f - 32) * 5 / 9
return cls(celsius)
@staticmethod
def is_freezing(value):
"""Return True if the value is at or below 0."""
return value <= 0
t = Temperature.from_fahrenheit(68) # 68°F = 20°C
print(t.value) # 20.0
print(Temperature.is_freezing(0)) # True@classmethod receives the class as cls. When a subclass calls an inherited classmethod, cls is the subclass, so cls(...) creates an instance of the subclass correctly. @staticmethod receives nothing implicit; it is a plain function that lives in the class namespace.
__str__ and __repr__
Both return a string representation of the object, but they serve different audiences.
| Method | Called by | Purpose |
|---|---|---|
__repr__ | repr(), REPL, containers | Unambiguous developer view; ideally eval(repr(obj)) == obj |
__str__ | str(), print(), f-strings | Readable human-facing string |
Fallback rule: If only __repr__ is defined, str() falls back to it. If only __str__ is defined, repr() does not fall back to it — it uses the default <ClassName object at 0x...>.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x!r}, {self.y!r})"
def __str__(self):
return f"({self.x}, {self.y})"
p = Point(2, 4)
print(repr(p)) # Point(2, 4)
print(str(p)) # (2, 4)
print(p) # (2, 4) -- print() uses __str__
print([p]) # [Point(2, 4)] -- containers use __repr__The !r format specifier in f-strings calls repr() on the value: f"{self.x!r}" produces 2 for an int but 'hello' (with quotes) for a string. Use it inside __repr__ so that string values appear with quotes in the output.
Dunder (Magic) Methods
Dunder methods (double underscores on both sides) let custom classes plug into Python’s built-in operators and functions. Python calls them automatically in the right context; they are never meant to be called directly.
Comparison and Equality
| Method | Triggered by |
|---|---|
__eq__(self, other) | == |
__ne__(self, other) | != |
__lt__(self, other) | < |
__le__(self, other) | <= |
__gt__(self, other) | > |
__ge__(self, other) | >= |
class Box:
def __init__(self, volume):
self.volume = volume
def __eq__(self, other):
return self.volume == other.volume
def __lt__(self, other):
return self.volume < other.volume
a = Box(8)
b = Box(16)
print(a == a) # True
print(a < b) # True
print(sorted([b, a])) # sorts using __lt__@functools.total_ordering fills in the remaining comparison methods automatically when __eq__ and one ordering method are defined.
Arithmetic
| Method | Triggered by |
|---|---|
__add__(self, other) | + |
__sub__(self, other) | - |
__mul__(self, other) | * |
__truediv__(self, other) | / |
__floordiv__(self, other) | // |
__mod__(self, other) | % |
__pow__(self, other) | ** |
Container-like Behavior
| Method | Triggered by |
|---|---|
__len__(self) | len() |
__contains__(self, item) | in operator |
__getitem__(self, key) | obj[key] |
__setitem__(self, key, value) | obj[key] = value |
__delitem__(self, key) | del obj[key] |
__iter__(self) | for x in obj, iter() |
__next__(self) | next() on an iterator |
Other Useful Dunders
| Method | Triggered by |
|---|---|
__bool__(self) | bool(), if obj: |
__hash__(self) | hash(), using the instance as a dict key or set element |
__call__(self, ...) | obj(...) — makes the instance callable |
__enter__(self) | entering a with block |
__exit__(self, exc_type, exc_val, exc_tb) | leaving a with block |
class Stack:
def __init__(self, items):
self._items = list(items)
def __len__(self):
return len(self._items)
def __contains__(self, item):
return item in self._items
def __getitem__(self, index):
return self._items[index]
def __bool__(self):
return len(self._items) > 0
def __repr__(self):
return f"Stack({self._items!r})"
s = Stack([2, 4, 6])
print(len(s)) # 3
print(4 in s) # True
print(s[0]) # 2
print(bool(s)) # True__bool__ and __len__: If __bool__ is defined, if obj: uses it. If __bool__ is absent but __len__ is defined, Python uses len(obj) != 0 as the truth value. If neither is defined, all instances are truthy by default.
__hash__ and __eq__: Defining __eq__ without __hash__ causes Python to set __hash__ = None, making instances unhashable. They cannot be used as dict keys or placed in a set. Define __hash__ explicitly when both equality and hashability are needed.
@property
@property turns a method into something that looks and feels like an attribute. The getter runs when the attribute is read; the setter runs when it is assigned to. This lets internal details stay hidden while keeping a clean, attribute-style interface.
class Circle:
def __init__(self, radius):
self._radius = 0
self.radius = radius # runs the setter at creation time
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("the radius cannot be negative")
self._radius = value
@property
def diameter(self):
return self._radius * 2
c = Circle(4)
print(c.radius) # 4
print(c.diameter) # 8
c.radius = 6
print(c.diameter) # 12A property without a setter is read-only. Assigning to it raises AttributeError.
c.diameter = 20 # AttributeError: can't set attribute@property is the preferred Python idiom for encapsulation. It is better than defining separate get_x() and set_x() methods because the caller interface stays clean: c.radius = 6 instead of c.set_radius(6).
__slots__
By default, every instance stores its attributes in a __dict__ dictionary. __slots__ replaces this with a fixed-size array, trading flexibility for lower memory use and faster attribute access.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(2, 4)
print(p.x) # 2
# p.z = 6 # AttributeError -- only x and y are allowed
# p.__dict__ # AttributeError -- no __dict__ when __slots__ is defined| Aspect | Default (__dict__) | With __slots__ |
|---|---|---|
| Memory | Higher (dict overhead) | Lower (fixed array) |
| Attribute access | Slightly slower | Slightly faster |
| Dynamic attributes | Allowed | Not allowed |
__dict__ | Present | Absent |
__slots__ is most useful for classes that create large numbers of instances (geometry points, graph nodes, records). Adding "__dict__" to __slots__ re-enables dynamic attributes while keeping the declared slots fast.
Tricky Behaviors
Classes - __new__ runs before __init__
__new__ is the actual constructor. It creates and returns the instance. __init__ only initializes it. If __new__ does not return an instance of the class, __init__ is never called at all.
__init__ - returning a value raises TypeError
__init__ must return None. Returning any other value raises TypeError: __init__() should return None (not 'int'). A common mistake is adding return self at the end of __init__.
__init__ - calling it manually reinitializes the instance
obj.__init__(new_args) is valid Python. It calls __init__ on an already-created instance, overwriting its attributes. No new object is created; the same object is mutated. This is almost always unintentional and usually signals a design problem.
self - it is a convention, not a keyword
Python does not enforce the name self. def __init__(this, x): is valid Python. But deviating from the convention confuses everyone; always use self.
Attribute lookup - instance dict first, but data descriptors take priority
When Python reads obj.attr, it searches the instance’s __dict__ first, then the class’s __dict__, then each parent following the MRO. However, data descriptors (objects that define both __get__ and __set__, like @property) take priority over the instance __dict__. This is why assigning to a property invokes the setter rather than silently writing a new key to __dict__.
Class Attributes - shadowing via instance assignment
Assigning to instance.attr when a class attribute of the same name exists does not modify the class attribute. It creates a new instance attribute that shadows it. The class attribute becomes visible again after the instance attribute is deleted with del instance.attr.
Class Attributes - mutable shared state
A list or dict defined as a class attribute is shared by all instances. Mutating it via .append(), .update(), or similar from any instance affects all of them. The fix is to define mutable attributes in __init__ as instance attributes so each instance gets its own.
Class Attributes - augmented assignment creates a shadow, not a mutation
instance.count += 1 is not the same as Class.count += 1. Python reads instance.count (finding the class attribute), adds 1, then assigns the result back to instance.count. That write creates a new instance attribute that shadows the class attribute; the class attribute itself is unchanged.
class Counter:
count = 0
c = Counter()
c.count += 2 # creates c.count = 2; Counter.count is still 0
print(Counter.count) # 0
print(c.count) # 2Class/Static Methods - inheritance behavior differs
@classmethod receives the actual class as cls. If a subclass calls an inherited classmethod, cls is the subclass, so cls(...) correctly creates an instance of the subclass. @staticmethod receives no implicit argument and has no way to know which class called it.
__str__ and __repr__ - fallback direction
str(obj) falls back to __repr__ if __str__ is missing. repr(obj) does not fall back to __str__; it uses the default <ClassName object at 0x...>. Define __repr__ first because it serves as the fallback.
__repr__ - used inside containers, not __str__
When an object is inside a list, dict, or other container and that container is printed or passed to repr(), Python calls __repr__ on each element, not __str__. print([p]) shows [Point(2, 4)], not [(2, 4)].
Dunders - defining __eq__ removes hashability
Python automatically sets __hash__ = None when __eq__ is defined. Instances can no longer be used as dict keys or set elements. Define __hash__ explicitly if both custom equality and hashability are needed.
Dunders - __bool__ fallback to __len__
If __bool__ is absent but __len__ is defined, Python uses len(obj) != 0 for truth testing. Only if neither is defined are all instances truthy by default.
Dunders - iterable vs iterator: two different protocols
An iterable only needs __iter__, which returns an iterator. An iterator needs both __iter__ (returning self) and __next__ (returning the next value or raising StopIteration). A list is iterable but not an iterator; iter(lst) creates a separate iterator object. Calling iter() on an iterator returns the iterator itself.
Dunders - __call__ makes instances callable
Defining __call__ lets instances be used like functions. callable(obj) returns True if the object’s class defines __call__. Classes themselves are callable; regular instances are callable only when __call__ is defined.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return self.factor * value
double = Multiplier(2)
print(double(8)) # 16
print(callable(double)) # True@property - setter must use the property name exactly
@property
def speed(self): ...
@speed.setter # must match the property name
def speed(self, v): ...A mismatch (e.g. @velocity.setter) silently creates a separate, unrelated property instead of registering the setter for speed.
@property - no setter means read-only
Assigning to a property without a setter raises AttributeError. This is intentional: computed properties like diameter should not be settable directly.
__slots__ - no __dict__ means no dynamic attributes
Once __slots__ is defined, instances cannot have arbitrary attributes outside the declared ones. instance.new_attr = value raises AttributeError. To allow both slots and a __dict__, add "__dict__" to __slots__.
Interview Questions
What is the difference between a class and an object?
A class is a blueprint defining what attributes and methods its instances will have. An object is a concrete instance created from that blueprint. A single class can have many independent instances.
What does self mean and is it a keyword?
self is the first parameter of instance methods. Python automatically passes the instance as the first argument; self names it. It is a convention, not a keyword — any valid name works, but self is universal.
What is the attribute lookup order for an instance?
Python searches the instance’s __dict__ first, then the class’s __dict__, then each parent class following the MRO. Data descriptors (like @property) take priority over the instance __dict__. This is why assigning to a property invokes the setter rather than creating a new entry in __dict__.
What is the difference between instance attributes and class attributes?
Instance attributes are stored on each individual instance (assigned via self.attr) and are independent for each object. Class attributes are defined in the class body and shared by all instances. Assigning to an instance attribute with the same name as a class attribute creates a shadow; the class attribute itself is unchanged.
What happens to a mutable class attribute when it is mutated by one instance?
All instances share the same class attribute object. Mutating it (e.g. .append()) via any instance affects all of them. The fix is to initialize mutable attributes in __init__ as instance attributes so each instance gets its own copy.
What happens when instance.count += 1 is used and count is a class attribute?
Python reads instance.count (finding the class attribute), adds 1, and assigns the result back to instance.count. That assignment creates a new instance attribute that shadows the class attribute; the class attribute itself is unchanged. It looks like mutation but is actually shadowing.
What is the difference between __str__ and __repr__?
__repr__ is the developer representation — unambiguous, ideally eval(repr(obj)) == obj. __str__ is the user-facing string. print() and str() use __str__ and fall back to __repr__. repr() and containers use __repr__ only. If only __repr__ is defined, str() uses it. If only __str__ is defined, repr() uses the default <object at 0x...>.
What is the difference between @classmethod and @staticmethod?
@classmethod receives the class as cls. It can access and modify class-level state and is used for alternative constructors. It respects inheritance: calling it on a subclass passes the subclass as cls. @staticmethod receives no implicit first argument; it is a utility function in the class namespace with no access to self or cls.
What happens to __hash__ when __eq__ is defined?
Python automatically sets __hash__ = None, making instances unhashable. They cannot be used as dict keys or placed in sets. Define __hash__ explicitly if hashability is needed alongside custom equality.
How do you preserve hashability when also defining __eq__?
Define both __eq__ and __hash__ explicitly. The two methods must agree: equal objects must produce the same hash. A common pattern is to hash the same fields used in equality: def __hash__(self): return hash((self.x, self.y)).
What is @property and why use it instead of getter and setter methods?
@property lets a method be accessed like an attribute. It enables computed values, validation in setters, and read-only attributes — all while keeping a clean attribute-access interface. It is preferred over get_x()/set_x() methods because the call syntax stays the same as a plain attribute.
What is the difference between __new__ and __init__?
__new__ creates the instance (allocates memory, returns the new object). __init__ initializes it (sets attributes, runs setup logic). __init__ must return None. __new__ is rarely overridden; it is used for singletons and subclassing immutable types like int and str.
Can __init__ be called on an already-created instance?
Yes. obj.__init__(new_args) is valid Python and reinitializes the instance with new arguments. No new object is created; the same object is mutated in place. This is almost always unintentional. Creating a new instance is the correct approach in virtually every case.
How does __bool__ interact with __len__?
If __bool__ is defined, Python uses it for truth testing. If __bool__ is absent but __len__ is defined, Python treats len(obj) != 0 as the truth value. If neither is defined, all instances are truthy.
What is the difference between an iterable and an iterator?
An iterable implements __iter__, which returns an iterator. An iterator implements both __iter__ (returning self) and __next__ (returning the next value or raising StopIteration). A list is iterable but not an iterator. iter(lst) creates a list iterator. Calling iter() on an iterator returns the iterator itself.
What does __call__ do and how does callable() relate to it?
__call__ makes instances callable like functions. callable(obj) returns True if the object’s class defines __call__. Classes themselves are callable (calling them produces instances). Regular instances are callable only when their class defines __call__.
When would @classmethod be used as an alternative constructor?
When there are multiple valid ways to create an instance. The classmethod performs any conversion or parsing, then calls cls(...) to produce the instance. Keeping all construction paths inside the class makes the API cleaner and avoids scattered creation logic outside it.
What are __slots__ and when are they useful?
__slots__ replaces the per-instance __dict__ with a fixed-size array, reducing memory and slightly speeding up attribute access. It is useful for classes that create large numbers of instances. The tradeoff is that dynamic attributes are no longer allowed unless "__dict__" is added to __slots__.