Object-Oriented Python
Object-oriented Python looks straightforward until it doesn't work. A handful of rules govern how Python handles attributes, inheritance, and special methods, and breaking any one of them silently produces wrong results or a confusing error. Run each example and read the traceback before looking at the explanation.
Define a method on a class
Create a Rectangle with width 4 and height 6 and call area().
Does it return 24?
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area():
return self.width * self.height
r = Rectangle(4, 6)
print(r.area())
Show explanation
The bug is the missing self parameter in area. When you call r.area(), Python
automatically passes the instance r as the first positional argument. A method
without self declares zero parameters, so Python sees one argument for zero
parameters and raises TypeError: area() takes 0 positional arguments but 1 was given.
Shows: that every instance method must declare self as its first parameter so Python
has somewhere to put the instance it passes automatically.
To find it: the traceback says the error is in the call r.area() and mentions "0
positional arguments but 1 was given". Change def area(): to def area(self):.
Get a value back from a method
Create a Greeter and print the result of calling greet.
Does the output say "Hello, world!"?
class Greeter:
def __init__(self, name):
self.name = name
def greet(self):
return f"Hello, {self.name}!"
g = Greeter("world")
print(g.greet)
Show explanation
The bug is the missing parentheses. g.greet without () evaluates to the bound
method object itself—a callable—rather than calling it and returning its string. The
print then shows something like <bound method Greeter.greet of ...>, which is
the text representation of the method, not the greeting.
Shows: that methods, like all callables, must be called with () to run and produce
a return value.
To find it: the output will contain the word bound method instead of Hello.
Change g.greet to g.greet().
Make __init__ set up an object
Create a Config object and print its debug attribute.
Does it print True?
class Config:
def __init__(self, debug):
self.debug = debug
return self
c = Config(True)
print(c.debug)
Show explanation
The bug is return self inside __init__. Python's object creation protocol
requires __init__ to return None. Any other return value raises TypeError:
__init__() should return None (not 'Config'). The object is created before
__init__ runs, so there is no need to return it—Config(True) already produces
the new instance.
Shows: the contract for __init__: it initializes an already-created object and
must not return anything.
To find it: the traceback says TypeError: __init__() should return None. Remove
the return self line.
Spell an attribute name consistently
Create a Student and call greet(). Does it print the student's name?
class Student:
def __init__(self, name):
self.nane = name
def greet(self):
return f"Hi, I am {self.name}"
s = Student("Alice")
print(s.greet())
Show explanation
The bug is a typo: self.nane = name in __init__ stores the value under the
misspelled key nane. When greet reads self.name, Python looks for the correctly
spelled attribute, finds nothing, and raises AttributeError. Python treats each
spelling as a distinct attribute and never connects them.
Shows: how attribute-name typos produce AttributeError at the point of reading, not
at the point of writing, which can make the bug appear to be in the wrong place.
To find it: print vars(s) after creating the student. The dictionary will contain
nane but not name. Fix the spelling in __init__.
Access the computed area of a circle
Create a Circle with radius 5 and print its area. Does it print a number close to 78.5?
import math
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return math.pi * self.radius ** 2
c = Circle(5)
print(c.area())
Show explanation
The bug is the parentheses in c.area(). Because area is decorated with
@property, accessing c.area already calls the function and returns the computed
float. Appending () then tries to call that float as if it were a function, raising
TypeError: 'float' object is not callable.
Shows: that @property turns a method into an attribute-style access—you read it
like data, not call it like a function.
To find it: the traceback says TypeError: 'float' object is not callable, pointing
to the line with c.area(). Remove the parentheses: c.area.
Retrieve the top item from a stack
Push 42 onto a Stack and peek at the top. Does peek() return 42?
class Stack:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def peek(self):
if self._items:
top = self._items[-1]
s = Stack()
s.push(42)
result = s.peek()
print(result + 1) # TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
Show explanation
The bug is the missing return statement. peek computes the top item and assigns
it to top, but never returns it. Python then falls off the end of the function and
implicitly returns None. Adding 1 to None raises TypeError: unsupported operand
type(s) for +: 'NoneType' and 'int'.
Shows: that forgetting return is silent—no error at the call site—and the failure
only appears when the caller tries to use the None value it received.
To find it: print result before trying to add 1. It will print None. Add
return top inside the if block.
Check whether a status matches a target
Create a Status("ok") and call is_ok(). Does it return True?
class Status:
def __init__(self, code):
self.code = code
def is_ok(self):
return self is Status("ok")
s = Status("ok")
print(s.is_ok()) # expect True, get False
Show explanation
The bug is using is to compare objects. is tests whether two names refer to the
exact same object in memory—identity, not equality. Status("ok") inside is_ok
creates a brand-new object, which is a different object from self, so self is
Status("ok") is always False. The fix is self.code == "ok", which compares
values.
Shows: the difference between is (identity) and == (equality), and why is
should be reserved for comparisons against singletons like None, True, and False.
To find it: print id(self) and id(Status("ok")) inside is_ok. The numbers will
differ. Replace is with == and compare the code attributes directly.
Create a person with the right details
Create a Person and call introduce(). Does the output contain the correct name and age?
class Person:
def __init__(self, first, last, age):
self.first = first
self.last = last
self.age = age
def introduce(self):
return f"I am {self.first} {self.last}, age {self.age}"
p = Person("Alice", 30, "Smith")
print(p.introduce())
Show explanation
The bug is the argument order. Person.__init__ expects first, last, age, but the
call passes "Alice", 30, "Smith"—putting the age in the last position and the
last name in the age position. Python has no way to detect the mismatch because all
three arguments are accepted without error, and introduce happily uses whatever
values ended up in self.last and self.age.
Shows: how positional argument order is enforced by position, not by name, and how
using keyword arguments (e.g., Person(first="Alice", last="Smith", age=30)) prevents
this class of mistake.
To find it: compare the output of introduce() against what you expect. The name and
age will be swapped. Reorder the arguments or switch to keyword arguments.
Record a score before reporting it
Create a Scoreboard for "Alice" and immediately call report().
Does it print a score?
class Scoreboard:
def __init__(self, player):
self.player = player
def record(self, points):
self.score = points
def report(self):
return f"{self.player}: {self.score}"
board = Scoreboard("Alice")
print(board.report()) # AttributeError: 'Scoreboard' object has no attribute 'score'
Show explanation
The bug is that self.score is only created inside record(), not in __init__.
If report() is called before record() has ever run, self.score does not exist
and report raises AttributeError: 'Scoreboard' object has no attribute 'score'.
Shows: that all instance attributes an object may need should be initialized in
__init__ so the object is in a consistent state from the moment it is created.
To find it: add print(vars(board)) after creating the Scoreboard. The dictionary
will not contain score. Add self.score = 0 to __init__.
Show a card as text
Create a Card and print its repr. Does the output show the rank and suit?
class Card:
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
def __repr__(self):
f"{self.rank} of {self.suit}"
c = Card("Ace", "Spades")
print(repr(c))
Show explanation
The bug is the missing return. The f-string f"{self.rank} of {self.suit}" is
evaluated and the resulting string is immediately discarded. __repr__ then returns
None implicitly. Python raises TypeError: __repr__ returned non-string (type NoneType).
Shows: that constructing a value and returning it are two separate steps—building a string with an f-string does nothing unless the result is returned or assigned.
To find it: the traceback says TypeError: __repr__ returned non-string. Add
return before the f-string.
Store data in an instance
Create a Point with x=3 and y=4 and call distance().
Does it return 5.0?
class Point:
def __init__(self, x, y):
x = x
y = y
def distance(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
p = Point(3, 4)
print(p.distance())
Show explanation
The bug is x = x and y = y inside __init__. Those lines rebind the local
parameter names to themselves and do nothing else. Without self.x = x and
self.y = y, the values are discarded the moment __init__ returns, leaving the
object with no x or y attribute. Calling self.x in distance then raises
AttributeError.
Shows: that instance attributes must be assigned through self, and that a bare
assignment inside a method creates a local variable, not an attribute.
To find it: add print(vars(p)) after creating the point. The dictionary will be
empty, revealing that no attributes were stored.
Build a bag that can hold any items
Create two Bag objects without passing any arguments, add "apple" to the first,
and print the second. Is the second bag empty?
class Bag:
def __init__(self, items=[]):
self.items = items
def add(self, item):
self.items.append(item)
a = Bag()
b = Bag()
a.add("apple")
print(b.items) # expect [], get ['apple']
Show explanation
The bug is the mutable default argument items=[]. Python evaluates default
argument values once, when the def statement runs, not on each call. Every Bag
created without an explicit list receives a reference to the same list object. Adding
to one bag therefore adds to all of them.
Shows: why mutable objects (lists, dicts, sets) must never be used as default
argument values, and the standard fix of using None as the default and creating a
new list inside the function body.
To find it: print id(a.items) and id(b.items). They are the same number,
proving both bags point to one list. Fix it with def __init__(self, items=None)
and self.items = items if items is not None else [].
Track students enrolled in a course
Create two Roster objects, enroll "Alice" in the math roster, and print the
science roster's students. Is the science list empty?
class Roster:
students = []
def __init__(self, name):
self.name = name
def enroll(self, student):
self.students.append(student)
math = Roster("Math")
science = Roster("Science")
math.enroll("Alice")
print(science.students) # expect [], get ['Alice']
Show explanation
The bug is students = [] at class level. That makes students a class variable:
one list shared by every Roster instance. When enroll calls
self.students.append(student), Python looks up self.students, finds the class
variable, and mutates it in place. The mutation is visible through every instance.
Shows: the difference between a class variable (one copy, shared) and an instance variable (one copy per object), and why mutable class variables are a common source of unexpected sharing.
To find it: print Roster.students after enrolling Alice. It already contains her
name, confirming the class-level list was modified. Fix it by moving the list into
__init__: self.students = [].
Extend a class with a new attribute
Create a Dog named "Rex" with breed "Labrador" and call describe().
Does it print a sentence with both pieces of information?
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
self.breed = breed
def describe(self):
return f"{self.name} is a {self.breed}"
d = Dog("Rex", "Labrador")
print(d.describe())
Show explanation
The bug is the missing super().__init__(name) call inside Dog.__init__. Python
does not automatically run the parent class's __init__. Without that call,
Animal.__init__ never runs, so self.name is never set. describe then raises
AttributeError when it tries to read self.name.
Shows: that subclass __init__ methods must explicitly call the parent's __init__
to initialize inherited attributes, and that forgetting this call produces an error
only when the missing attribute is accessed, not at construction time.
To find it: add print(vars(d)) right after creating the dog. The dictionary will
contain breed but not name. Add super().__init__(name) as the first line of
Dog.__init__.
Display an object as a string
Create a Temperature of 100 degrees and print it.
Does the output look like a sensible string?
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __str__(self):
return self.celsius
t = Temperature(100)
print(t)
Show explanation
The bug is returning self.celsius (an integer) from __str__. Python requires
__str__ to return a str object. When it gets anything else, it raises
TypeError: __str__ returned non-string. The value is never printed.
Shows: that special methods must return the exact type Python expects, and that
returning the wrong type produces a TypeError that mentions the method by name.
To find it: the traceback says TypeError: __str__ returned non-string (type int).
Fix it by returning str(self.celsius) or f"{self.celsius} C".
Count how many widgets exist
Create two Widget objects and call Widget.how_many().
Does it return 2?
class Widget:
count = 0
def __init__(self):
Widget.count += 1
@staticmethod
def how_many():
return cls.count
w1 = Widget()
w2 = Widget()
print(Widget.how_many())
Show explanation
The bug is @staticmethod. A static method receives no implicit first argument, so
cls is undefined inside how_many and raises NameError. The fix is @classmethod,
which passes the class itself as the first argument, conventionally named cls.
Shows: the distinction between @staticmethod (no implicit arguments) and
@classmethod (receives the class as cls), and when each decorator is appropriate.
To find it: the traceback says NameError: name 'cls' is not defined. Replace
@staticmethod with @classmethod and add cls as the first parameter of
how_many.
Update the radius of a circle
Create a Circle with radius 5, update the radius to 10, and print the area.
Does the area reflect the new radius?
import math
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
def area(self):
return math.pi * self._radius ** 2
c = Circle(5)
c.radius = 10
print(c.area())
Show explanation
The bug is that radius is a read-only property. A @property decorator with no
corresponding @radius.setter creates a getter only. Assigning c.radius = 10
raises AttributeError: can't set attribute before area() is ever reached.
Shows: that a @property alone is read-only, and that a companion setter must be
defined explicitly for assignments to work.
To find it: the traceback pinpoints the assignment line. Add a setter:
@radius.setter
def radius(self, value):
self._radius = value
Accept any animal in a function
Call make_sound with a Dog object.
Does it print "Woof", or does it raise an error?
class Animal:
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof"
def make_sound(creature):
if type(creature) == Animal:
return creature.speak()
raise ValueError("not an Animal")
d = Dog()
print(make_sound(d))
Show explanation
The bug is type(creature) == Animal. The type() function returns the exact runtime
type of an object. A Dog instance has type Dog, not Animal, so the comparison is
False and the function raises ValueError—even though Dog is a subclass of Animal.
isinstance(creature, Animal) returns True for any instance of Animal or any of
its subclasses.
Shows: that type() checks for an exact match while isinstance() respects the
inheritance hierarchy, and that isinstance is almost always the right choice.
To find it: print type(d) and type(d) == Animal before calling the function. The
output False makes the bug obvious. Replace type(creature) == Animal with
isinstance(creature, Animal).
Read a private attribute from outside a class
Create a BankAccount, deposit some money, and print the balance by accessing
account.__balance directly. Does it print the correct total?
class BankAccount:
def __init__(self, balance):
self.__balance = balance # stored as _BankAccount__balance
def deposit(self, amount):
self.__balance += amount
def get_balance(self):
return self.__balance
account = BankAccount(100)
account.deposit(50)
print(account.__balance)
Show explanation
The bug is accessing account.__balance from outside the class. Python rewrites any
identifier starting with two underscores (but not ending with two) to
_ClassName__attribute at compile time. Inside the class, self.__balance is
automatically translated to self._BankAccount__balance. Outside the class, writing
account.__balance is not translated, so Python looks for a literal attribute named
__balance, finds nothing, and raises AttributeError.
Shows: Python's name-mangling mechanism for double-underscore attributes and how to access them from outside if necessary.
To find it: print dir(account) and look for _BankAccount__balance in the output.
Use the public get_balance() method instead, or access the mangled name directly
as account._BankAccount__balance for debugging.
Use points as dictionary keys
Create two equal Point objects and check whether the second is in a set containing
the first. Does p2 in seen return True?
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
p1 = Point(1, 2)
p2 = Point(1, 2)
seen = {p1}
print(p2 in seen)
Show explanation
The bug is defining __eq__ without also defining __hash__. Python's data model
specifies that any class defining __eq__ must also define __hash__, or Python
sets __hash__ = None automatically. An object with __hash__ = None is unhashable:
it cannot be placed in a set or used as a dict key, raising TypeError: unhashable
type: 'Point'.
Shows: the connection between __eq__ and __hash__, and why both must be defined
together when value equality is needed.
To find it: the traceback mentions TypeError: unhashable type. Add a __hash__
method that returns a hash based on the same fields used in __eq__:
def __hash__(self):
return hash((self.x, self.y))
Iterate over a countdown twice
Create a Countdown from 3 and iterate over it twice.
Do both passes produce [3, 2, 1]?
class Countdown:
def __init__(self, start):
self.start = start
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
c = Countdown(3)
print(list(c)) # [3, 2, 1]
print(list(c)) # expect [3, 2, 1], get []
Show explanation
The bug is that the Countdown object is both an iterable and its own iterator.
__iter__ returns self, so the second call to list(c) reuses the same object,
which still has self.current == 0 from the first pass. __next__ immediately
raises StopIteration, producing an empty list.
Shows: the iterator protocol and the difference between an iterable (has __iter__
returning an iterator) and an iterator (has __next__). A reusable iterable should
return a fresh iterator object from __iter__, not self.
To find it: print c.current between the two calls. It will be 0. Fix it by
extracting the iteration state into a separate class:
def __iter__(self):
return CountdownIterator(self.start)
Build a query with method chaining
Build a QueryBuilder, chain .filter() and .limit(), and call .build().
Does it return a dictionary with both values?
class QueryBuilder:
def __init__(self):
self.filters = []
self.limit_val = None
def filter(self, condition):
self.filters.append(condition)
def limit(self, n):
self.limit_val = n
return self
def build(self):
return {"filters": self.filters, "limit": self.limit_val}
query = QueryBuilder().filter("age > 18").limit(10).build()
print(query)
Show explanation
The bug is the missing return self in filter. Every method in a fluent interface
must return the object it was called on so the next method has something to call.
Without return self, filter returns None. Calling .limit(10) on None raises
AttributeError: 'NoneType' object has no attribute 'limit'.
Shows: how method chaining works and the discipline required to make every step
return self.
To find it: the traceback says the error is on the chained line, pointing at .limit.
The call before it—filter—is the culprit. Add return self at the end of filter.
Add a prefix to log messages
Create an AppLogger and call run().
Does it print a log message, or does it raise an error?
class Logger:
def log(self, message):
print(f"LOG: {message}")
class AppLogger(Logger):
def __init__(self):
self.log = "application"
def run(self):
self.log("Starting up")
app = AppLogger()
app.run()
Show explanation
The bug is self.log = "application" inside __init__. That statement creates an
instance attribute named log that holds the string "application". When Python
later looks up self.log in run(), it finds the instance attribute first and
ignores the inherited log() method. Calling the string with self.log("Starting up")
raises TypeError: 'str' object is not callable.
Shows: how instance attributes shadow methods of the same name, and why attribute names must not collide with method names inherited from parent classes.
To find it: print type(self.log) inside run() before the call. It will show
<class 'str'>. Rename the attribute—for example, self.prefix = "application"—and
update any references to it.
Get the number of items in a dataset
Create a DataSet with three numbers and call len() on it.
Does it return the integer 3?
class DataSet:
def __init__(self, data):
self.data = data
def __len__(self):
return len(self.data) / 1
ds = DataSet([10, 20, 30])
print(len(ds))
Show explanation
The bug is the / operator in __len__. In Python 3, / always performs true
(floating-point) division and always returns a float, even when both operands are
integers and the result is a whole number. len() requires an integer return value
and raises TypeError: 'float' object cannot be interpreted as an integer when it
gets a float.
Shows: the difference between / (true division, returns float) and // (integer
division, returns int), and that __len__ has a strict return-type contract.
To find it: the traceback says TypeError and names __len__. Replace / with
//, or just write return len(self.data).
Add a third coordinate to a point
Create a Point with x=3 and y=4, add z=0, and print all three coordinates.
Does it work?
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
p.z = 0
print(p.x, p.y, p.z)
Show explanation
The bug is that __slots__ lists only x and y. When __slots__ is defined,
Python removes the instance __dict__ and allows only the named attributes. Assigning
p.z = 0 raises AttributeError: 'Point' object has no attribute 'z' because z
is not in the slot list.
Shows: what __slots__ does (restricts attributes, saves memory, improves attribute
access speed) and the trade-off it imposes (no dynamic attributes unless __dict__
is also included in __slots__).
To find it: the traceback points directly at p.z = 0. Either add "z" to
__slots__, or remove __slots__ entirely if dynamic attributes are needed.