Skip to Content
OOP Part I

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.

TermMeaning
classBlueprint defining structure and behavior
object / instanceA concrete entity created from a class
instantiationThe act of creating an object from a class
attributeData stored on a class or instance
methodA 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)) # True

Dog 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) # 4

Dog("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

TypeWhere definedShared?Access
Instance attributeInside methods on selfNo — each instance has its ownobj.attr
Class attributeDirectly in the class bodyYes — all instances see the sameClass.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) # 4

Shadowing: 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 again

Mutable 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 too

The fix is to initialize mutable attributes in __init__ as instance attributes:

class Team: def __init__(self): self.members = [] # each instance gets its own list

Instance 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()) # 96

Calling r.area() is equivalent to calling Rectangle.area(r). Python handles the translation automatically.

Class Methods and Static Methods

DecoratorFirst parameterAccessesTypical use
(none)selfInstance and classMost methods
@classmethodclsClass onlyAlternative constructors, class-level state
@staticmethod(none)NeitherUtility 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.

MethodCalled byPurpose
__repr__repr(), REPL, containersUnambiguous developer view; ideally eval(repr(obj)) == obj
__str__str(), print(), f-stringsReadable 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

MethodTriggered 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

MethodTriggered 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

MethodTriggered 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

MethodTriggered 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) # 12

A 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
AspectDefault (__dict__)With __slots__
MemoryHigher (dict overhead)Lower (fixed array)
Attribute accessSlightly slowerSlightly faster
Dynamic attributesAllowedNot allowed
__dict__PresentAbsent

__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) # 2

Class/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__.

Last updated on