Python Classes Practice Problems & Exercises
Practice: Classes and Objects — Python's Object Model at Engineering Depth
← Back to lessonEasy
Read the class definition carefully and predict what each print statement outputs. Focus on where Python looks for an attribute — instance namespace first, then the class namespace.
class Counter:
count = 0
def __init__(self, name):
self.name = name
Counter.count += 1
a = Counter("alpha")
b = Counter("beta")
print(Counter.count)
print(a.count)
print(b.count)
a.count = 99
print(a.count)
print(Counter.count)Solution
2
2
2
99
2
Explanation: Counter.count is a class attribute initialised to 0. Every __init__ call increments Counter.count, so after creating a and b it equals 2. Reading a.count and b.count falls through to the class attribute because neither instance has its own count yet — both return 2. The assignment a.count = 99 creates a new entry in a.__dict__, shadowing the class attribute for a only. After that, a.count returns 99 (instance attribute), while Counter.count and b.count remain 2.
class Counter:
count = 0
def __init__(self, name):
self.name = name
Counter.count += 1
a = Counter("alpha")
b = Counter("beta")
print(Counter.count)
print(a.count)
print(b.count)
a.count = 99
print(a.count)
print(Counter.count)Expected Output
2\n2\n2\n99\n2Hints
Hint 1: Counter.count is a class attribute shared by all instances until an instance assigns its own value.
Hint 2: Assigning a.count = 99 creates a new instance attribute on a — it does not change the class attribute.
Hint 3: After a.count = 99, a.count reads the instance attribute (99) while Counter.count and b.count still read the class attribute (2).
Complete the Point class so that it stores coordinates and can compute the distance from the origin.
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance_from_origin(self):
return math.sqrt(self.x ** 2 + self.y ** 2)
p = Point(3, 4)
print(p.x)
print(p.y)
print(p.distance_from_origin())Solution
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance_from_origin(self):
return math.sqrt(self.x ** 2 + self.y ** 2)
Explanation: __init__ receives x and y as arguments and binds them to the instance via self.x and self.y. The distance method applies the standard Euclidean formula. For Point(3, 4), sqrt(9 + 16) = sqrt(25) = 5.0.
class Point:
def __init__(self, x, y):
# TODO: store x and y as instance attributes
pass
def distance_from_origin(self):
# TODO: return Euclidean distance from (0, 0)
pass
p = Point(3, 4)
print(p.x)
print(p.y)
print(p.distance_from_origin())Expected Output
3\n4\n5.0Hints
Hint 1: Store the arguments as self.x and self.y inside __init__.
Hint 2: The Euclidean distance formula is sqrt(x**2 + y**2). Import math or use ** 0.5.
Hint 3: A 3-4-5 right triangle gives a distance of exactly 5.0.
The code below has a classic OOP bug. Both baskets end up sharing the same list. Identify and fix the bug so each Basket instance has its own independent item list.
class Basket:
def __init__(self):
self.items = [] # fixed: each instance gets its own list
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
b.add("banana")
print(a.items)
print(b.items)Solution
class Basket:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
Explanation: A mutable class attribute (items = []) is shared across all instances. Calling self.items.append(...) mutates the single list in place — no new instance attribute is created. Moving the initialisation to __init__ creates a fresh list for each instance, stored in self.__dict__. This is one of the most common Python OOP pitfalls.
class Basket:
items = [] # shared list — bug is here
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
b.add("banana")
print(a.items) # should print ['apple']
print(b.items) # should print ['banana']Expected Output
['apple']\n['banana']Hints
Hint 1: items = [] at class level creates one list shared by every instance.
Hint 2: self.items.append() mutates the shared list — it does NOT create a per-instance list.
Hint 3: Move the list initialisation into __init__ using self.items = [] to give each instance its own list.
Implement the from_fahrenheit class method so it acts as an alternative constructor, converting a Fahrenheit value and returning a Temperature instance.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
def __str__(self):
return f"{self.celsius}C"
t = Temperature.from_fahrenheit(212)
print(t)Solution
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
def __str__(self):
return f"{self.celsius}C"
Explanation: @classmethod receives the class itself as the first argument (cls). Calling cls(...) is equivalent to calling Temperature(...), so the method acts as a factory that converts the Fahrenheit input first and then delegates to the normal constructor. This pattern is used extensively in the standard library (e.g., datetime.fromtimestamp).
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
# TODO: convert f to celsius and return a Temperature instance
pass
def __str__(self):
return f"{self.celsius}C"
t = Temperature.from_fahrenheit(212)
print(t)Expected Output
100.0CHints
Hint 1: The formula to convert Fahrenheit to Celsius is: C = (F - 32) * 5 / 9.
Hint 2: Use cls(...) inside the classmethod to create and return a new instance.
Hint 3: 212°F equals 100°C — the boiling point of water.
Medium
Design a bounded Stack class that raises appropriate exceptions when the stack is full or empty.
class Stack:
def __init__(self, max_size):
self._items = []
self._max = max_size
def push(self, item):
if len(self._items) >= self._max:
raise OverflowError("Stack is full")
self._items.append(item)
def pop(self):
if not self._items:
raise IndexError("Stack is empty")
return self._items.pop()
def peek(self):
if not self._items:
raise IndexError("Stack is empty")
return self._items[-1]
def is_empty(self):
return len(self._items) == 0
def __len__(self):
return len(self._items)
s = Stack(5)
s.push("a")
s.push("b")
s.push("c")
print(len(s))
print(s.is_empty())
print(s.peek())
print(s.pop())
print(s.pop())Solution
class Stack:
def __init__(self, max_size):
self._items = []
self._max = max_size
def push(self, item):
if len(self._items) >= self._max:
raise OverflowError("Stack is full")
self._items.append(item)
def pop(self):
if not self._items:
raise IndexError("Stack is empty")
return self._items.pop()
def peek(self):
if not self._items:
raise IndexError("Stack is empty")
return self._items[-1]
def is_empty(self):
return len(self._items) == 0
def __len__(self):
return len(self._items)
Explanation: The class stores items in a private list _items. The push method guards against exceeding _max with an OverflowError. pop and peek both guard against an empty stack. Implementing __len__ lets callers use len(stack) naturally. The single-underscore prefix signals that _items and _max are internal implementation details.
class Stack:
def __init__(self, max_size):
# TODO: initialise storage and remember max_size
pass
def push(self, item):
# TODO: add item; raise OverflowError if full
pass
def pop(self):
# TODO: remove and return top; raise IndexError if empty
pass
def peek(self):
# TODO: return top without removing; raise IndexError if empty
pass
def is_empty(self):
# TODO: return True if stack has no items
pass
def __len__(self):
# TODO: return number of items
passExpected Output
3\nfalse\nc\nc\nbHints
Hint 1: Use a list (self._items = []) for internal storage and self._max = max_size to remember the limit.
Hint 2: In push(), check len(self._items) >= self._max before appending.
Hint 3: In pop() and peek(), check if the list is empty before accessing elements.
Implement a Robot class that tracks every instance it creates using a class-level registry. Class methods provide read access to the registry.
class Robot:
_registry = []
def __init__(self, name):
self.name = name
Robot._registry.append(self)
@classmethod
def all_names(cls):
return [r.name for r in cls._registry]
@classmethod
def count(cls):
return len(cls._registry)
r1 = Robot("R2-D2")
r2 = Robot("C-3PO")
r3 = Robot("BB-8")
print(Robot.count())
print(Robot.all_names())Solution
class Robot:
_registry = []
def __init__(self, name):
self.name = name
Robot._registry.append(self)
@classmethod
def all_names(cls):
return [r.name for r in cls._registry]
@classmethod
def count(cls):
return len(cls._registry)
Explanation: Because _registry is a class attribute and is a mutable list, every __init__ call appends to the same list — this is the one case where a mutable class attribute is intentional. The class methods receive cls, so they are subclass-friendly. In production code you might use weakref.WeakSet to avoid preventing garbage collection of deleted robots.
class Robot:
_registry = []
def __init__(self, name):
self.name = name
# TODO: register this instance
pass
@classmethod
def all_names(cls):
# TODO: return list of names of all created robots
pass
@classmethod
def count(cls):
# TODO: return total number of robots created
pass
r1 = Robot("R2-D2")
r2 = Robot("C-3PO")
r3 = Robot("BB-8")
print(Robot.count())
print(Robot.all_names())Expected Output
3\n['R2-D2', 'C-3PO', 'BB-8']Hints
Hint 1: In __init__, append self to cls._registry (or Robot._registry).
Hint 2: all_names() should iterate _registry and return [r.name for r in cls._registry].
Hint 3: count() can simply return len(cls._registry).
Implement from_hex as a @classmethod and is_valid_hex as a @staticmethod. Understand when each decorator is appropriate.
class Color:
def __init__(self, r, g, b):
self.r = r
self.g = g
self.b = b
@classmethod
def from_hex(cls, hex_str):
r = int(hex_str[1:3], 16)
g = int(hex_str[3:5], 16)
b = int(hex_str[5:7], 16)
return cls(r, g, b)
@staticmethod
def is_valid_hex(hex_str):
return isinstance(hex_str, str) and len(hex_str) == 7 and hex_str.startswith("#")
def __str__(self):
return f"rgb({self.r}, {self.g}, {self.b})"
print(Color.is_valid_hex("#ff8040"))
print(Color.is_valid_hex("ff8040"))
c = Color.from_hex("#ff8040")
print(c)Solution
class Color:
def __init__(self, r, g, b):
self.r = r
self.g = g
self.b = b
@classmethod
def from_hex(cls, hex_str):
r = int(hex_str[1:3], 16)
g = int(hex_str[3:5], 16)
b = int(hex_str[5:7], 16)
return cls(r, g, b)
@staticmethod
def is_valid_hex(hex_str):
return isinstance(hex_str, str) and len(hex_str) == 7 and hex_str.startswith("#")
def __str__(self):
return f"rgb({self.r}, {self.g}, {self.b})"
Explanation: from_hex is a @classmethod because it needs cls to instantiate the correct class (enabling subclassing). is_valid_hex is a @staticmethod because it is a pure utility — it neither needs the instance nor the class. Using cls(...) instead of Color(...) in from_hex is crucial for the Liskov Substitution Principle: if you subclass Color, the factory will return the subclass, not the base class.
class Color:
def __init__(self, r, g, b):
self.r = r
self.g = g
self.b = b
@classmethod
def from_hex(cls, hex_str):
# TODO: parse '#RRGGBB' and return a Color instance
pass
@staticmethod
def is_valid_hex(hex_str):
# TODO: return True if hex_str matches '#RRGGBB' format (7 chars, starts with #)
pass
def __str__(self):
return f"rgb({self.r}, {self.g}, {self.b})"
print(Color.is_valid_hex("#ff8040"))
print(Color.is_valid_hex("ff8040"))
c = Color.from_hex("#ff8040")
print(c)Expected Output
True\nFalse\nrgb(255, 128, 64)Hints
Hint 1: is_valid_hex should check len(hex_str) == 7 and hex_str.startswith("#").
Hint 2: In from_hex, slice hex_str[1:3], hex_str[3:5], hex_str[5:7] and convert each with int(..., 16).
Hint 3: Use cls(r, g, b) not Color(r, g, b) inside from_hex so subclasses work correctly.
Trace through Python's attribute lookup rules to predict all four print outputs.
class Base:
value = 10
def show(self):
print(self.value)
class Child(Base):
pass
b = Base()
c = Child()
c.value = 42
print(b.value)
print(c.value)
print(Child.value)
del c.value
print(c.value)Solution
10
42
10
10
Explanation: Python looks up attributes on an instance first (instance.__dict__), then on the class, then up the MRO chain. Initially b.value and c.value both resolve to Base.value = 10. After c.value = 42 an instance attribute is created in c.__dict__, so c.value returns 42 while b.value and Child.value remain 10. del c.value removes the instance attribute. The next c.value lookup finds nothing in c.__dict__, so it climbs to Child, then to Base, and returns 10 again.
class Base:
value = 10
def show(self):
print(self.value)
class Child(Base):
pass
b = Base()
c = Child()
c.value = 42
print(b.value)
print(c.value)
print(Child.value)
del c.value
print(c.value)Expected Output
10\n42\n10\n10Hints
Hint 1: Child inherits value = 10 from Base. Child.value finds it via MRO.
Hint 2: c.value = 42 creates an instance attribute on c, shadowing the class attribute.
Hint 3: del c.value removes the instance attribute. After deletion, c.value falls back to Child.value → Base.value = 10.
Hard
Implement a data descriptor TypedAttr that validates the type of any value assigned to it. Use __set_name__ to capture the attribute name automatically.
class TypedAttr:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.name not in obj.__dict__:
raise AttributeError(self.name)
return obj.__dict__[self.name]
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
class Person:
name = TypedAttr("name", str)
age = TypedAttr("age", int)
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Alice", 30)
print(p.name)
print(p.age)
try:
p.age = "thirty"
except TypeError as e:
print(e)Solution
class TypedAttr:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.name not in obj.__dict__:
raise AttributeError(self.name)
return obj.__dict__[self.name]
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
Explanation: A data descriptor defines both __get__ and __set__. Because Python gives data descriptors priority over the instance __dict__, every read and write goes through the descriptor. Storing the value directly in obj.__dict__[self.name] bypasses the descriptor on retrieval — this is the standard pattern to avoid infinite recursion. __set_name__ (called automatically by the metaclass when the class body is executed) captures the actual attribute name, so explicit name arguments become optional redundancy. The if obj is None guard in __get__ handles class-level access (e.g., Person.name), returning the descriptor itself.
class TypedAttr:
"""Descriptor that enforces a type on attribute assignment."""
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
# TODO: return stored value or raise AttributeError if not set
pass
def __set__(self, obj, value):
# TODO: enforce type, then store in obj.__dict__
pass
class Person:
name = TypedAttr("name", str)
age = TypedAttr("age", int)
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Alice", 30)
print(p.name)
print(p.age)
try:
p.age = "thirty"
except TypeError as e:
print(e)Expected Output
Alice\n30\nage must be int, got strHints
Hint 1: __set__ should call isinstance(value, self.expected_type) and raise TypeError if it fails.
Hint 2: Store the value in obj.__dict__[self.name] to avoid infinite recursion (do NOT use setattr).
Hint 3: __get__ should check if self.name is in obj.__dict__; if not, raise AttributeError.
Implement the Singleton pattern using __new__ so that only one instance of the class is ever created. The second instantiation must return the first instance unchanged.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
if not hasattr(self, "_initialised"):
self.value = value
self._initialised = True
a = Singleton(10)
b = Singleton(20)
print(a is b)
print(a.value)
print(b.value)Solution
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
if not hasattr(self, "_initialised"):
self.value = value
self._initialised = True
Explanation: __new__ is the true constructor — it allocates and returns the object. By checking cls._instance before calling super().__new__(cls), we ensure only one allocation ever occurs. Both a and b hold a reference to the same object (a is b is True). Because Python calls __init__ after every __new__ call, the _initialised guard in __init__ prevents the value from being reset on the second invocation — a.value and b.value both equal 10. In production, prefer a module-level instance or a metaclass-based singleton for thread safety.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
# TODO: return existing instance if one exists, otherwise create it
pass
def __init__(self, value):
# __init__ is called every time — guard against re-initialisation
if not hasattr(self, "_initialised"):
self.value = value
self._initialised = True
a = Singleton(10)
b = Singleton(20)
print(a is b)
print(a.value)
print(b.value)Expected Output
True\n10\n10Hints
Hint 1: Check if cls._instance is None. If it is, call super().__new__(cls) and assign to cls._instance.
Hint 2: Return cls._instance at the end of __new__ regardless of whether it was just created.
Hint 3: The guard in __init__ (hasattr _initialised) prevents overwriting value on the second call.
Build an immutable Vector2D class using __slots__ and a custom __setattr__ that blocks all mutations. Use object.__setattr__ during __init__ to initialise the private slots.
class Vector2D:
__slots__ = ("_x", "_y")
def __init__(self, x, y):
object.__setattr__(self, "_x", x)
object.__setattr__(self, "_y", y)
@property
def x(self):
return self._x
@property
def y(self):
return self._y
def __setattr__(self, name, value):
raise AttributeError("Vector2D is immutable")
def __add__(self, other):
return Vector2D(self._x + other._x, self._y + other._y)
def __eq__(self, other):
if not isinstance(other, Vector2D):
return NotImplemented
return self._x == other._x and self._y == other._y
def __repr__(self):
return f"Vector2D({self._x}, {self._y})"
v1 = Vector2D(1, 2)
v2 = Vector2D(3, 4)
v3 = v1 + v2
print(v3)
print(v1 == Vector2D(1, 2))
try:
v1.x = 99
except AttributeError as e:
print(e)Solution
class Vector2D:
__slots__ = ("_x", "_y")
def __init__(self, x, y):
object.__setattr__(self, "_x", x)
object.__setattr__(self, "_y", y)
@property
def x(self):
return self._x
@property
def y(self):
return self._y
def __setattr__(self, name, value):
raise AttributeError("Vector2D is immutable")
def __add__(self, other):
return Vector2D(self._x + other._x, self._y + other._y)
def __eq__(self, other):
if not isinstance(other, Vector2D):
return NotImplemented
return self._x == other._x and self._y == other._y
def __repr__(self):
return f"Vector2D({self._x}, {self._y})"
Explanation: The custom __setattr__ blocks all attribute writes — including those in __init__. To initialise the slots, we call object.__setattr__ directly, bypassing our override. __slots__ removes the instance __dict__, reducing memory overhead and preventing arbitrary attribute addition. Every mutating operation like __add__ returns a fresh Vector2D rather than modifying self, upholding immutability. Returning NotImplemented from __eq__ when the other operand is a different type lets Python try the reflected operation before raising TypeError.
class Vector2D:
"""Immutable 2D vector. Instances cannot be modified after creation."""
__slots__ = ("_x", "_y")
def __init__(self, x, y):
# TODO: use object.__setattr__ to bypass our own __setattr__
pass
@property
def x(self):
return self._x
@property
def y(self):
return self._y
def __setattr__(self, name, value):
raise AttributeError("Vector2D is immutable")
def __add__(self, other):
# TODO: return a new Vector2D that is the sum
pass
def __eq__(self, other):
# TODO: two vectors are equal if x and y both match
pass
def __repr__(self):
return f"Vector2D({self._x}, {self._y})"
v1 = Vector2D(1, 2)
v2 = Vector2D(3, 4)
v3 = v1 + v2
print(v3)
print(v1 == Vector2D(1, 2))
try:
v1.x = 99
except AttributeError as e:
print(e)Expected Output
Vector2D(4, 6)\nTrue\nVector2D is immutableHints
Hint 1: In __init__, use object.__setattr__(self, "_x", x) to bypass the custom __setattr__ you defined.
Hint 2: __add__ should return Vector2D(self._x + other._x, self._y + other._y) — a brand-new instance.
Hint 3: __eq__ should check isinstance(other, Vector2D) then compare _x and _y.
