Skip to main content

Python Classes Practice Problems & Exercises

Practice: Classes and Objects — Python's Object Model at Engineering Depth

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Predict the Output — Class vs Instance AttributesEasy
class attributesinstance attributesoutput prediction

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.

Python
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\n2
Hints

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).


#2Simple Point ClassEasy
class definitioninstance attributesmethods

Complete the Point class so that it stores coordinates and can compute the distance from the origin.

Python
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.0
Hints

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.


#3Spot the Bug — Mutable Default AttributeEasy
bug findingmutable defaultsclass attributes

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.

Python
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.


#4Class Method as Alternative ConstructorEasy
classmethodalternative constructorfactory

Implement the from_fahrenheit class method so it acts as an alternative constructor, converting a Fahrenheit value and returning a Temperature instance.

Python
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.0C
Hints

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

#5Stack Class with Size LimitMedium
class designinstance attributeserror handling

Design a bounded Stack class that raises appropriate exceptions when the stack is full or empty.

Python
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
      pass
Expected Output
3\nfalse\nc\nc\nb
Hints

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.


#6Instance Registry via Class VariableMedium
class attributesinstance trackingweak references

Implement a Robot class that tracks every instance it creates using a class-level registry. Class methods provide read access to the registry.

Python
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).


#7Static Factory vs Class Method FactoryMedium
staticmethodclassmethodfactory pattern

Implement from_hex as a @classmethod and is_valid_hex as a @staticmethod. Understand when each decorator is appropriate.

Python
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.


#8Predict the Output — Attribute Lookup ChainMedium
attribute lookupMROoutput prediction

Trace through Python's attribute lookup rules to predict all four print outputs.

Python
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\n10
Hints

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

#9Descriptor-Based Type-Checked AttributeHard
descriptorsclass designtype checking

Implement a data descriptor TypedAttr that validates the type of any value assigned to it. Use __set_name__ to capture the attribute name automatically.

Python
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 str
Hints

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.


#10Singleton Pattern via __new__Hard
__new__singletonmetaclass alternative

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.

Python
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\n10
Hints

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.


#11Immutable Value ObjectHard
__slots__immutabilityvalue objectclass design

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.

Python
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 immutable
Hints

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.

© 2026 EngineersOfAI. All rights reserved.