Python classes exercises — master objects and encapsulation
Python classes exercises are where object-oriented thinking becomes natural. You’ve seen the theory and built three complete programs. Now it’s time to design and implement classes on your own — a Student, a Vehicle and a Stack.
As always: try to solve it yourself, use the hint if stuck for more than 10 minutes, and compare with the commented solution. Use pythontutor.com to step through your solution and watch the objects being created and modified in memory.
Table of Contents
Python classes exercises — Basic Level
Exercise 1 — Student class
Design and implement a Student class that models a university student. It must store personal data and academic records, and provide meaningful operations.
Requirements:
Attributes:
name — string, cannot be empty
student_id — string, 8 characters starting with 'S'
degree — string
grades — dictionary: {subject_name: grade}
Methods:
add_grade(subject, grade) — adds or updates a grade (0-10)
average() — returns average of all grades (0 if no grades)
is_passing() — True if average >= 5.0
best_subject() — subject with highest grade
worst_subject() — subject with lowest grade
transcript() — prints formatted academic record
Magic methods:
__str__ → "Sergio Medina (S12345678) — GCID — Average: 7.50"
__repr__ → "Student(name='Sergio', id='S12345678', degree='GCID')"
__eq__ → equal if same student_id
__lt__ → compare by average (enables sorting)
__bool__ → True if is_passing()
Expected output:
=== STUDENT === Sergio Medina (S12345678) — GCID — Average: 7.17 Transcript: FP1: 7.50 IC2: 8.00 Maths: 6.00 Statistics: 7.20 Databases: 7.00 (highest) Programming: 6.50 Best subject: Databases — 8.00 Worst subject: Maths — 6.00 Passing?: True Sorted by average: 1. Ana López — 8.25 2. Sergio Medina — 7.17 3. Carlos Ruiz — 6.50
💡 Hints:
- Validate
student_idin__init__:len(id) == 9 and id.startswith('S') and id[1:].isdigit() average():sum(self._grades.values()) / len(self._grades)— handle empty dictbest_subject():max(self._grades, key=self._grades.get)- Make
gradesprivate with_grades— expose through methods only
Exercise 2 — Vehicle class
Design and implement a Vehicle class that models a vehicle with fuel tracking and trip history.
Requirements:
Attributes: plate — string, 7 characters (e.g. '1234ABC') make — string model — string year — int, between 1900 and current year _fuel — float, 0 to tank_capacity (private) _tank_capacity — float, positive (private) _odometer — float, read-only (private) _trips — list of trip records Methods: refuel(litres) — add fuel, cannot exceed tank capacity drive(km) — consume fuel (8L/100km), add to odometer fuel_range() — how many km can still be driven add_trip(name, km) — named trip with distance Properties: fuel → current fuel (read-only externally) tank_capacity → tank size (read-only) odometer → total km driven (read-only) fuel_percent → fuel as percentage of tank Magic methods: __str__ → "1234ABC — Toyota Yaris 2022 | Odometer: 15342km | Fuel: 45.2%" __eq__ → equal if same plate __lt__ → compare by year (older < newer)
Expected output:
=== VEHICLE === 1234ABC — Toyota Yaris 2022 | Odometer: 0km | Fuel: 50.0% Refuelled 20L → 45.0/50.0L (90.0%) Drove 50km → consumed 4.0L, remaining 41.0L (82.0%) Range remaining: 512.5 km Trip log: 1. Trip to ULPGC — 15km 2. Weekend drive — 120km Odometer: 185km
💡 Hints:
drive(km): consumption =km * 8 / 100, raiseValueErrorif insufficient fuelfuel_range():self._fuel / 8 * 100— km per litre × remaining fuelyearvalidation:1900 <= year <= datetime.now().yearplatevalidation: 7 chars, e.g. digits + letters but keep it simple for FP2
Python classes exercises — Intermediate Level → Final Challenge
Exercise 3 — Generic Stack class
Implement a generic stack (LIFO — Last In, First Out) as a Python class. It must work with any data type and support all standard stack operations.
Requirements:
Methods: push(item) — add item to top pop() — remove and return top item peek() — return top item without removing clear() — empty the stack to_list() — return contents as list (bottom to top) Properties: size → number of items is_empty → True if no items is_full → True if at max_size (if set) Magic methods: __str__ → "Stack([1, 2, 3]) — top: 3" __len__ → number of items __bool__ → True if not empty __contains__ → 'in' operator: 3 in stack __iter__ → iterate from bottom to top Optional: max_size parameter — None means unlimited
Expected output:
=== STACK === Stack([]) — empty Push 1,2,3: Stack([1, 2, 3]) — top: 3 Peek: 3 (stack unchanged) Pop: 3 → Stack([1, 2]) — top: 2 Pop: 2 → Stack([1]) — top: 1 Size: 1 Contains 1?: True Contains 5?: False Push strings: Stack(['hello', 'world', 'python']) — top: python Iterate (bottom to top): hello world python Sorted list: ['hello', 'python', 'world'] === STACK WITH SIZE LIMIT === Stack of max 3: Push 10 ✓ Push 20 ✓ Push 30 ✓ Push 40 ✗ Stack is full (max 3)
💡 Hints:
- Internal storage:
self._items = [] push: append to list —self._items.append(item)pop:self._items.pop()— raisesIndexErrorif empty (catch and re-raise asStackError)peek:self._items[-1]— raises error if empty__contains__:return item in self._items__iter__:return iter(self._items)— iterates bottom to top- Create
StackError(Exception)as custom exception
Commented solutions
Solution Exercise 1
class Student:
def __init__(self, name, student_id, degree):
if not name.strip():
raise ValueError('Name cannot be empty')
if not (len(student_id) == 9 and
student_id.startswith('S') and
student_id[1:].isdigit()):
raise ValueError(
f'Invalid student ID: {student_id} '
f'(must be S followed by 8 digits)'
)
self.name = name.strip().title()
self.student_id = student_id
self.degree = degree
self._grades = {} # subject → grade
def add_grade(self, subject, grade):
if not 0 <= grade <= 10:
raise ValueError(f'Grade must be 0-10, got {grade}')
if not subject.strip():
raise ValueError('Subject name cannot be empty')
self._grades[subject.strip()] = round(grade, 2)
def average(self):
if not self._grades:
return 0
return round(sum(self._grades.values()) / len(self._grades), 2)
def is_passing(self):
return self.average() >= 5.0
def best_subject(self):
if not self._grades:
return None, 0
subject = max(self._grades, key=self._grades.get)
return subject, self._grades[subject]
def worst_subject(self):
if not self._grades:
return None, 0
subject = min(self._grades, key=self._grades.get)
return subject, self._grades[subject]
def transcript(self):
best = self.best_subject()[0]
worst = self.worst_subject()[0]
print('Transcript:')
for subject, grade in self._grades.items():
notes = []
if subject == best: notes.append('highest')
if subject == worst and subject != best: notes.append('lowest')
note = f' ({", ".join(notes)})' if notes else ''
print(f' {subject:<12} {grade:.2f}{note}')
def __str__(self):
return (f'{self.name} ({self.student_id}) — '
f'{self.degree} — Average: {self.average():.2f}')
def __repr__(self):
return (f"Student(name='{self.name}', "
f"id='{self.student_id}', degree='{self.degree}')")
def __eq__(self, other):
return isinstance(other, Student) and self.student_id == other.student_id
def __lt__(self, other):
return self.average() < other.average()
def __bool__(self):
return self.is_passing()
# Usage
print('=== STUDENT ===')
s = Student('Sergio Medina', 'S12345678', 'GCID')
s.add_grade('FP1', 7.5)
s.add_grade('IC2', 8.0)
s.add_grade('Maths', 6.0)
s.add_grade('Statistics', 7.2)
s.add_grade('Databases', 7.0)
s.add_grade('Programming', 6.5)
print(s)
print()
s.transcript()
best_subj, best_grade = s.best_subject()
worst_subj, worst_grade = s.worst_subject()
print(f'\nBest subject: {best_subj} — {best_grade:.2f}')
print(f'Worst subject: {worst_subj} — {worst_grade:.2f}')
print(f'Passing?: {bool(s)}')
# Sorting
students = [
s,
Student('Ana López', 'S98765432', 'GCID'),
Student('Carlos Ruiz', 'S11223344', 'Informatics')
]
students[1].add_grade('FP1', 8.5)
students[1].add_grade('IC2', 8.0)
students[2].add_grade('FP1', 7.0)
students[2].add_grade('IC2', 6.0)
print('\nSorted by average:')
for i, st in enumerate(sorted(students, reverse=True), 1):
print(f' {i}. {st.name} — {st.average():.2f}')
Solution Exercise 2
from datetime import datetime
class Vehicle:
CONSUMPTION_PER_100KM = 8.0 # litres per 100km
def __init__(self, plate, make, model, year,
tank_capacity=50.0, initial_fuel=None):
current_year = datetime.now().year
if not (1900 <= year <= current_year):
raise ValueError(f'Year must be between 1900 and {current_year}')
if tank_capacity <= 0:
raise ValueError('Tank capacity must be positive')
if len(plate) != 7:
raise ValueError(f'Plate must be 7 characters: {plate}')
self.plate = plate.upper()
self.make = make
self.model = model
self.year = year
self._tank_capacity = tank_capacity
self._fuel = initial_fuel if initial_fuel is not None else tank_capacity / 2
self._odometer = 0.0
self._trips = []
@property
def fuel(self):
return round(self._fuel, 1)
@property
def tank_capacity(self):
return self._tank_capacity
@property
def odometer(self):
return round(self._odometer, 1)
@property
def fuel_percent(self):
return round(self._fuel / self._tank_capacity * 100, 1)
def refuel(self, litres):
if litres <= 0:
raise ValueError(f'Litres must be positive: {litres}')
space = self._tank_capacity - self._fuel
if litres > space:
litres = space
print(f' Tank almost full — added {litres:.1f}L only')
self._fuel = round(self._fuel + litres, 2)
print(f'Refuelled {litres}L → '
f'{self._fuel:.1f}/{self._tank_capacity:.1f}L '
f'({self.fuel_percent}%)')
def drive(self, km):
if km <= 0:
raise ValueError(f'Distance must be positive: {km}')
consumption = round(km * self.CONSUMPTION_PER_100KM / 100, 2)
if consumption > self._fuel:
max_km = round(self._fuel / self.CONSUMPTION_PER_100KM * 100, 1)
raise ValueError(
f'Insufficient fuel for {km}km. '
f'Max range with current fuel: {max_km}km'
)
self._fuel = round(self._fuel - consumption, 2)
self._odometer = round(self._odometer + km, 1)
print(f'Drove {km}km → consumed {consumption}L, '
f'remaining {self._fuel}L ({self.fuel_percent}%)')
def fuel_range(self):
return round(self._fuel / self.CONSUMPTION_PER_100KM * 100, 1)
def add_trip(self, name, km):
self.drive(km)
self._trips.append({'name': name, 'km': km})
def trip_log(self):
if not self._trips:
print(' No trips recorded')
return
print('Trip log:')
for i, trip in enumerate(self._trips, 1):
print(f' {i}. {trip["name"]} — {trip["km"]}km')
def __str__(self):
return (f'{self.plate} — {self.make} {self.model} {self.year} | '
f'Odometer: {self._odometer:.0f}km | '
f'Fuel: {self.fuel_percent}%')
def __repr__(self):
return (f"Vehicle(plate='{self.plate}', make='{self.make}', "
f"model='{self.model}', year={self.year})")
def __eq__(self, other):
return isinstance(other, Vehicle) and self.plate == other.plate
def __lt__(self, other):
return self.year < other.year
# Usage
print('=== VEHICLE ===')
car = Vehicle('1234ABC', 'Toyota', 'Yaris', 2022,
tank_capacity=50, initial_fuel=25)
print(car)
print()
car.refuel(20)
car.drive(50)
print(f'Range remaining: {car.fuel_range()} km')
print()
car.add_trip('Trip to ULPGC', 15)
car.add_trip('Weekend drive', 120)
print()
car.trip_log()
print(f'\nOdometer: {car.odometer}km')
try:
car.drive(1000)
except ValueError as err:
print(f'✗ {err}')
Solution Exercise 3
class StackError(Exception):
pass
class Stack:
def __init__(self, max_size=None):
self._items = []
self._max_size = max_size
@property
def size(self):
return len(self._items)
@property
def is_empty(self):
return len(self._items) == 0
@property
def is_full(self):
if self._max_size is None:
return False
return len(self._items) >= self._max_size
def push(self, item):
if self.is_full:
raise StackError(f'Stack is full (max {self._max_size})')
self._items.append(item)
def pop(self):
if self.is_empty:
raise StackError('Cannot pop from empty stack')
return self._items.pop()
def peek(self):
if self.is_empty:
raise StackError('Cannot peek empty stack')
return self._items[-1]
def clear(self):
self._items.clear()
def to_list(self):
return list(self._items)
def __len__(self):
return len(self._items)
def __bool__(self):
return not self.is_empty
def __contains__(self, item):
return item in self._items
def __iter__(self):
return iter(self._items)
def __str__(self):
if self.is_empty:
return 'Stack([]) — empty'
return f'Stack({self._items}) — top: {self._items[-1]}'
def __repr__(self):
return f'Stack(items={self._items}, max_size={self._max_size})'
# Usage
print('=== STACK ===')
s = Stack()
print(s)
print()
print('Push 1,2,3: ', end='')
s.push(1)
s.push(2)
s.push(3)
print(s)
print(f'Peek: {s.peek()} (stack unchanged)')
val = s.pop()
print(f'Pop: {val} → {s}')
val = s.pop()
print(f'Pop: {val} → {s}')
print(f'Size: {s.size}')
print(f'Contains 1?: {1 in s}')
print(f'Contains 5?: {5 in s}')
# String stack
s2 = Stack()
s2.push('hello')
s2.push('world')
s2.push('python')
print(f'\nPush strings:\n{s2}')
print('\nIterate (bottom to top):')
for item in s2:
print(f' {item}')
print(f'\nSorted list: {sorted(s2)}')
# Stack with size limit
print('\n=== STACK WITH SIZE LIMIT ===')
limited = Stack(max_size=3)
print('Stack of max 3:')
for val in [10, 20, 30, 40]:
try:
limited.push(val)
print(f' Push {val} ✓')
except StackError as err:
print(f' Push {val} ✗ {err}')
# Empty stack errors
empty = Stack()
try:
empty.pop()
except StackError as err:
print(f'\nPop from empty: {err}')
try:
empty.peek()
except StackError as err:
print(f'Peek at empty: {err}')
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
class Stack:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
if not self._items:
raise IndexError('Empty stack')
return self._items.pop()
@property
def top(self):
return self._items[-1] if self._items else None
def __len__(self):
return len(self._items)
def __str__(self):
return f'Stack({self._items})'
s = Stack()
s.push(10)
s.push(20)
s.push(30)
print(s)
print(f'Top: {s.top}')
print(f'Popped: {s.pop()}')
print(s)
Step through and observe how push calls self._items.append(item) — the stack’s internal list grows with each push. When pop() is called, self._items.pop() removes and returns the last element — always the most recently pushed. The @property top reads self._items[-1] without modifying the list — that’s the difference between peek (non-destructive) and pop (destructive). Watch how len(s) calls __len__ automatically — Python calls magic methods transparently.
Cheat sheet — Python classes
# ============================================
# CHEAT SHEET — Python Classes
# Sergio Learns · sergiolearns.com
# ============================================
# CLASS DEFINITION
class MyClass:
class_attr = 'shared' # class attribute
def __init__(self, value): # constructor
self.value = value # instance attribute
self._private = value # convention: don't access directly
self.__mangled = value # name mangling: _MyClass__mangled
def method(self): # instance method
return self.value
@classmethod
def class_method(cls): # class method
return cls.class_attr
@staticmethod
def static_method(): # no self or cls
return 42
# CREATE OBJECTS
obj = MyClass(10)
print(obj.value) # → 10
print(obj.method()) # → 10
# @PROPERTY — controlled access
@property
def balance(self): # getter — called on read
return self._balance
@balance.setter
def balance(self, v): # setter — called on write
if v < 0: raise ValueError('Negative')
self._balance = v
@property
def read_only(self): # no setter → read-only
return self._value * 2
# Usage
obj.balance = 100 # calls setter
print(obj.balance) # calls getter
# PRIVATE ATTRIBUTES
self._protected # convention: be careful
self.__private # name mangled: _ClassName__private
# MAGIC METHODS
def __str__(self): return 'human readable' # print()
def __repr__(self): return "Class(value=x)" # repr(), shell
def __eq__(self, o): return self.v == o.v # ==
def __lt__(self, o): return self.v < o.v # < (enables sorted())
def __len__(self): return len(self._items) # len()
def __bool__(self): return self.v > 0 # if obj:
def __contains__(self, x): return x in self._items # x in obj
def __iter__(self): return iter(self._items) # for x in obj:
# CLASS vs INSTANCE ATTRIBUTES
MyClass.class_attr # access on class
obj.class_attr # access on instance (reads class)
obj.class_attr = 'new' # creates INSTANCE attr — shadows class
# TYPICAL CLASS PATTERNS
# 1. Validation in __init__
def __init__(self, age):
if age < 0: raise ValueError('Age cannot be negative')
self._age = age
# 2. Read-only @property
@property
def age(self): return self._age
# 3. Computed @property (no setter)
@property
def is_adult(self): return self._age >= 18
# 4. Setter with validation
@age.setter
def age(self, v):
if v < 0: raise ValueError('Negative')
self._age = v
# SORTING WITH __lt__
students = [Student('B', 8.0), Student('A', 7.0)]
sorted(students) # uses __lt__
sorted(students, key=lambda s: s.name) # custom key
# EQUALITY
s1 = Student('Sergio', 'S001', 'GCID')
s2 = Student('Sergio', 'S001', 'GCID')
s1 == s2 # True only if __eq__ defined (else: different objects)
# BOOL IN IF STATEMENTS
account = BankAccount('Sergio', 0)
if account: # calls __bool__
print('Has balance')
# OBJECTS CONTAINING OBJECTS
class Library:
def __init__(self):
self._books = {} # dict of Book objects
def add(self, book):
self._books[book.isbn] = book
# COMMON ERRORS
# 1. Forgetting self in method → TypeError
# 2. self.attr = value in method body (not __init__) → undefined elsewhere
# 3. Accessing __private → AttributeError (use _protected instead)
# 4. Adding @property setter before getter → SyntaxError
# 5. Mutating class attribute → accidentally creates instance attribute

One Comment