Python classes exercises — a student and their grades, a vehicle that loses value and a stack with its own exceptions (and Laura’s report with Pablo’s fail on it)
Preparing for the first FP2 midterm I wrote a Student class with its list of grades, created Laura and Pablo, gave each of them one grade… and both had both grades. Laura, with her 9, showed up with Pablo’s 4 on her report. It took me a good while to see that I had written grades = [] in the class body and not in __init__. It’s the first fragment of “What’s wrong here?”, and once you’ve seen it you won’t forget it.
You have already seen the classes theory and the practice with three programs. Now it’s your turn: three exercises on three levels, three fragments with a trap, the explained solutions, the exam-style quiz and the cheat sheet. The error you’ll chase most today is bypassing encapsulation: touching from outside what should only change through its methods and properties, and losing all the validation.
Table of Contents
How to use this article
- Read each exercise’s statement and expected output, and write your solution without looking below.
- If you get stuck, open the hint, but try for five more minutes first.
- Before the solutions, do “What’s wrong here?”: predict what each fragment prints and then check.
- Compare your code with the commented solution and read “Why it is done this way”, even if yours works.
Python classes exercises — Basic Level
Exercise 1 — Student class with grades and statistics
Design a Student class that stores a student’s name, age and list of grades. The class must:
- Keep the three pieces of data private and validate them: non-empty name, age between 16 and 99 and grades between 0 and 10 (
ValueErrorotherwise). - Offer an
@propertyforname,ageandgrades(the latter read-only and returning a copy). - Have the methods
add_grade(grade),average()andpassed()(average of 5 or more). - Implement
__str__and__repr__.
The program creates Laura (20 years old), tries to add 7.5, 8, 6.5, 9 and 11 and shows her statistics. At the end it tries to sneak in a −5 with e.grades.append(-5).
Expected output:
Error: invalid grade: 11
Student: Laura (20 years old)
Grades: [7.5, 8.0, 6.5, 9.0]
Average: 7.75
Highest: 9.0 | Lowest: 6.5
Status: Pass
repr: Student('Laura', 20)
Grades after the attempt: [7.5, 8.0, 6.5, 9.0]
If the grades property returns list(self.__grades), the final append lands in a copy. For the highest and lowest, max() and min() over e.grades are enough.
Python classes exercises — Intermediate Level
Exercise 2 — Vehicle class with make, model and mileage
Design a Vehicle class with full encapsulation:
- Make and model, non-empty texts; year between 1900 and the current one (2026); initial mileage not negative.
- The mileage is read-only: it only goes up with
drive(km), which requires positive km and an active vehicle. retire()deactivates it for good.estimated_value(): 15,000 €, minus 1,000 € per year of age, minus 0.01 € per kilometre, and never below 1,000 €.- A class attribute
_total_vehicleswith its@classmethod, plus__str__and__repr__.
Expected output (a 2018 Toyota with 45,000 km and a brand-new 2023 scooter):
Toyota Corolla (2018), 45000 km, active
Estimated value: 6550.00 €
Toyota Corolla (2018), 95000 km, active
Estimated value: 6050.00 €
Bike: 12000.00 €
Toyota Corolla (2018), 95000 km, retired
v.drive(10) ValueError: the vehicle has been retired
bike.drive(-5) ValueError: kilometres must be positive
v.mileage = 0 AttributeError: property 'mileage' of 'Vehicle' object has no setter
Vehicle('Seat', 'Ibiza', 2031) ValueError: invalid year: 2031
Total vehicles: 2
Vehicle('Honda', 'PCX', 2023, 0)
The sums for the first value: 2026 − 2018 = 8 years, so 15,000 − 8,000 − 0.01 × 45,000 = 15,000 − 8,000 − 450 = 6,550. For “never below 1,000” use max(value, 1000). The old article gave 8,950 €, which didn’t match its own formula.
Python classes exercises — Final Challenge
Exercise 3 — Stack class with push, pop and peek
Implement a Stack class, the LIFO (Last In, First Out) structure: the last item in is the first one out, like a pile of trays. With a configurable maximum capacity:
push(item)puts an item on top;pop()removes and returns the top one;peek()returns it without removing it.is_empty()andis_full()returnTrueorFalse.- Two custom exceptions:
StackEmptyError(pop or peek on an empty stack) andStackFullError(push on a full stack). - The capacity is read-only;
len(stack)gives the number of items (__len__);__str__shows the items and the top, and__repr__, the capacity and the items.
Expected output:
Empty stack: True Stack: [10, 20, 30] (top: 30) Peek: 30 Pop: 30 Stack: [10, 20] (top: 20) Size: 2/3 Trying push on a full stack... Error: full stack, maximum capacity 3 Sale 40 Sale 20 Sale 10 Trying pop on an empty stack... Error: empty stack, no items repr: Stack(capacity=3, items=[])
Store the items in a private list and use its end as the top: append puts on top and pop() (with no index) removes the last one. Custom exceptions are defined as in the previous topic: class StackEmptyError(Exception): pass.
What’s wrong here?
Three fragments with a trap. Read each one, predict what it prints and then check.
Fragment 1: the shared report
This one is mine:
class Student:
grades = [] # each student's 'list of grades'
def __init__(self, name):
self.name = name
def add_grade(self, grade):
self.grades.append(grade)
laura = Student('Laura')
pablo = Student('Pablo')
laura.add_grade(9)
pablo.add_grade(4)
print(laura.name, laura.grades)
print(pablo.name, pablo.grades)
Which grades does each one have?
Laura [9, 4] Pablo [9, 4]
Both have [9, 4]. grades = [] is in the class body, so it is a class attribute: a single list for all students. self.grades.append(grade) doesn’t assign anything, it only reads self.grades (which isn’t in the object, so Python looks it up in the class) and modifies it. Laura and Pablo write to the same list.
Creating “each object’s” lists or dictionaries in the class body. The class body runs only once, when the class is defined; __init__ runs once per object.
The fix is to move the list into __init__:
class Student:
def __init__(self, name):
self.name = name
self.grades = [] # a NEW list for each object
def add_grade(self, grade):
self.grades.append(grade)
laura = Student('Laura')
pablo = Student('Pablo')
laura.add_grade(9)
pablo.add_grade(4)
print(laura.name, laura.grades)
print(pablo.name, pablo.grades)
Output:
Laura [9] Pablo [4]
vars(laura) tells you which attributes the object has. If grades isn’t there, you are reading it from the class.
Fragment 2: the property that calls itself
Someone wants the name stored without spaces and writes this property:
class Student:
def __init__(self, name):
self.name = name
@property
def name(self):
return self.name # calls the name property again...
@name.setter
def name(self, value):
self.name = value.strip() # ...and this, the setter again
laura = Student('Laura')
What happens when Laura is created?
Traceback (most recent call last):
File "recursion.py", line 14, in <module>
laura = Student('Laura')
File "recursion.py", line 3, in __init__
self.name = name
^^^^^^^^^
File "recursion.py", line 11, in name
self.name = value.strip() # ...and this, the setter again
^^^^^^^^^
File "recursion.py", line 11, in name
self.name = value.strip() # ...and this, the setter again
^^^^^^^^^
File "recursion.py", line 11, in name
self.name = value.strip() # ...and this, the setter again
^^^^^^^^^
[Previous line repeated 995 more times]
RecursionError: maximum recursion depth exceeded
RecursionError. In __init__, self.name = name calls the setter (fine). But inside the setter, self.name = value.strip() calls the setter again, which calls itself again, and so on 1,000 times, until Python says enough. The getter has the same problem: return self.name would call itself.
Using the same name for the property and for the data it stores. The property is the door (name); the data lives somewhere else (self.__name or self._name).
The fix, with the data in self.__name:
class Student:
def __init__(self, name):
self.name = name # here yes: it goes through the setter
@property
def name(self):
return self.__name # the real data lives under another name
@name.setter
def name(self, value):
self.__name = value.strip()
laura = Student(' Laura ')
print(repr(laura.name))
Output:
'Laura'
“[Previous line repeated 995 more times]” in a Traceback is almost always a function (or property) calling itself with no stopping condition.
Fragment 3: the list you can’t read
The class has a lovely __str__. But look at what comes out when printing a list of stacks:
class Stack:
def __init__(self):
self.items = []
def __str__(self):
return f'Stack with {len(self.items)} items'
stacks = [Stack(), Stack()]
stacks[0].items.append(7)
print(stacks[0])
print(stacks)
Output:
Stack with 1 items [<__main__.Stack object at 0x7f1a7e07f0e0>, <__main__.Stack object at 0x7f1a7dd7ccd0>]
print(stacks[0]) uses __str__ and comes out fine. print(stacks) doesn’t: when printing a list, Python shows each item’s __repr__, and since we haven’t defined it, the default one comes out, with the memory address (on your computer it will be another and will change on every run).
Defining only __str__ and being surprised that lists, dictionaries and the console still show <__main__.Stack object at …>.
The fix, with __repr__:
class Stack:
def __init__(self):
self.items = []
def __str__(self):
return f'Stack with {len(self.items)} items'
def __repr__(self): # what you see inside lists and in the console
return f'Stack({self.items})'
stacks = [Stack(), Stack()]
stacks[0].items.append(7)
print(stacks[0])
print(stacks)
Output:
Stack with 1 items [Stack([7]), Stack([])]
If you are only going to write one, write __repr__: Python also uses it for print when __str__ is missing.
Commented solutions
Solution Exercise 1
class Student:
def __init__(self, name, age):
self.name = name # setters
self.age = age
self.__grades = []
@property
def name(self):
return self.__name
@name.setter
def name(self, value):
if not isinstance(value, str) or not value.strip():
raise ValueError('the name must be a non-empty text')
self.__name = value.strip()
@property
def age(self):
return self.__age
@age.setter
def age(self, value):
if not isinstance(value, int) or not 16 <= value <= 99:
raise ValueError(f'invalid age: {value}')
self.__age = value
@property
def grades(self):
return list(self.__grades) # copy
def add_grade(self, grade):
if not isinstance(grade, (int, float)) or not 0 <= grade <= 10:
raise ValueError(f'invalid grade: {grade}')
self.__grades.append(grade)
def average(self):
if not self.__grades:
return 0.0 # no grades, average 0 (a design decision)
return sum(self.__grades) / len(self.__grades)
def passed(self):
return self.average() >= 5
def __str__(self):
return f'Student: {self.__name} ({self.__age} years old)'
def __repr__(self):
return f'Student({self.__name!r}, {self.__age})'
e = Student('Laura', 20)
for n in [7.5, 8.0, 6.5, 9.0, 11]:
try:
e.add_grade(n)
except ValueError as err:
print('Error:', err)
print(e)
print('Grades:', e.grades)
print(f'Average: {e.average():.2f}')
print('Highest:', max(e.grades), '| Lowest:', min(e.grades))
print('Status:', 'Pass' if e.passed() else 'Fail')
print('repr:', repr(e))
e.grades.append(-5) # it is added to a copy...
print('Grades after the attempt:', e.grades)
Output:
Error: invalid grade: 11
Student: Laura (20 years old)
Grades: [7.5, 8.0, 6.5, 9.0]
Average: 7.75
Highest: 9.0 | Lowest: 6.5
Status: Pass
repr: Student('Laura', 20)
Grades after the attempt: [7.5, 8.0, 6.5, 9.0]
Why it is done this way:
self.name = nameandself.age = agein__init__: they go through the setters, so aStudent('', 200)never even gets created.- The grades list, in
__init__: each student with their own (fragment 1). add_gradevalidates and raises: the 11 givesValueErrorand the loop catches it without stopping. The list ends up with four grades.gradeswithout a setter and returning a copy:e.grades.append(-5)adds the −5 to a temporary list that is thrown away; the inner one stays the same, as the last line shows.average()with an empty list returns 0.0: a design decision to avoid dividing by zero. RaisingValueError('no grades')would be another valid option.
Check: (7.5 + 8 + 6.5 + 9) / 4 = 31 / 4 = 7.75.
Solution Exercise 2
CURRENT_YEAR = 2026
class Vehicle:
_total_vehicles = 0
BASE_VALUE = 15000
MIN_VALUE = 1000
def __init__(self, make, model, year, mileage=0):
self.make = make
self.model = model
self.year = year
if not isinstance(mileage, int) or mileage < 0:
raise ValueError('the mileage cannot be negative')
self.__mileage = mileage
self.__active = True
Vehicle._total_vehicles += 1
@property
def make(self):
return self.__make
@make.setter
def make(self, value):
if not isinstance(value, str) or not value.strip():
raise ValueError('the make must be a non-empty text')
self.__make = value.strip()
@property
def model(self):
return self.__model
@model.setter
def model(self, value):
if not isinstance(value, str) or not value.strip():
raise ValueError('the model must be a non-empty text')
self.__model = value.strip()
@property
def year(self):
return self.__year
@year.setter
def year(self, value):
if not isinstance(value, int) or not 1900 <= value <= CURRENT_YEAR:
raise ValueError(f'invalid year: {value}')
self.__year = value
@property
def mileage(self): # read-only: it only goes up by driving
return self.__mileage
def drive(self, km):
if not self.__active:
raise ValueError('the vehicle has been retired')
if km <= 0:
raise ValueError('kilometres must be positive')
self.__mileage += km
def retire(self):
self.__active = False
def estimated_value(self):
age_years = CURRENT_YEAR - self.__year
value = Vehicle.BASE_VALUE - 1000 * age_years - 0.01 * self.__mileage
return max(value, Vehicle.MIN_VALUE)
@classmethod
def total_vehicles(cls):
return cls._total_vehicles
def __str__(self):
status = 'active' if self.__active else 'retired'
return f'{self.__make} {self.__model} ({self.__year}), {self.__mileage} km, {status}'
def __repr__(self):
return f'Vehicle({self.__make!r}, {self.__model!r}, {self.__year}, {self.__mileage})'
v = Vehicle('Toyota', 'Corolla', 2018, 45000)
bike = Vehicle('Honda', 'PCX', 2023)
print(v)
print(f'Estimated value: {v.estimated_value():.2f} €')
v.drive(50000)
print(v)
print(f'Estimated value: {v.estimated_value():.2f} €')
print(f'Bike: {bike.estimated_value():.2f} €')
v.retire()
print(v)
for order in ['v.drive(10)', 'bike.drive(-5)', 'v.mileage = 0', "Vehicle('Seat', 'Ibiza', 2031)"]:
try:
exec(order)
except (ValueError, AttributeError) as err:
print(f'{order:<32} {type(err).__name__}: {err}')
print('Total vehicles:', Vehicle.total_vehicles())
print(repr(bike))
Output:
Toyota Corolla (2018), 45000 km, active
Estimated value: 6550.00 €
Toyota Corolla (2018), 95000 km, active
Estimated value: 6050.00 €
Bike: 12000.00 €
Toyota Corolla (2018), 95000 km, retired
v.drive(10) ValueError: the vehicle has been retired
bike.drive(-5) ValueError: kilometres must be positive
v.mileage = 0 AttributeError: property 'mileage' of 'Vehicle' object has no setter
Vehicle('Seat', 'Ibiza', 2031) ValueError: invalid year: 2031
Total vehicles: 2
Vehicle('Honda', 'PCX', 2023, 0)
Why it is done this way:
- Mileage, read-only: the property has no setter, so
v.mileage = 0givesAttributeError. It only goes up withdrive, which validates. Winding back a car’s mileage is, literally, a crime. drivefirst checks that it is active: with the car retired, the error is “retired” even if the km are fine.- Class constants (
BASE_VALUE,MIN_VALUE): the formula’s numbers have a name and are changed in one place. estimated_valuereadsself.__yearandself.__mileagedirectly: inside the class it’s allowed, and it only reads.- The year 2031 stops the car being created: the year setter fires in
__init__, before the counter is incremented, which stays at 2.
Check: with 95,000 km, 15,000 − 8,000 − 950 = 6,050. The 2023 scooter: 15,000 − 3 × 1,000 − 0 = 12,000.
Solution Exercise 3
class StackEmptyError(Exception):
pass
class StackFullError(Exception):
pass
class Stack:
"""LIFO stack: the last one in is the first one out."""
def __init__(self, capacity=10):
if not isinstance(capacity, int) or capacity <= 0:
raise ValueError('capacity must be a positive integer')
self.__capacity = capacity
self.__items = [] # the top is the LAST item of the list
@property
def capacity(self): # read-only
return self.__capacity
def is_empty(self):
return len(self.__items) == 0
def is_full(self):
return len(self.__items) == self.__capacity
def push(self, item):
if self.is_full():
raise StackFullError(f'full stack, maximum capacity {self.__capacity}')
self.__items.append(item)
def pop(self):
if self.is_empty():
raise StackEmptyError('empty stack, no items')
return self.__items.pop() # list.pop() removes and returns the last one
def peek(self):
if self.is_empty():
raise StackEmptyError('empty stack, no items')
return self.__items[-1]
def __len__(self): # makes len(pila) work
return len(self.__items)
def __str__(self):
top = self.__items[-1] if self.__items else None
return f'Stack: {self.__items} (top: {top})'
def __repr__(self):
return f'Stack(capacity={self.__capacity}, items={self.__items})'
p = Stack(3)
print('Empty stack:', p.is_empty())
for x in [10, 20, 30]:
p.push(x)
print(p)
print('Peek:', p.peek())
print('Pop:', p.pop())
print(p)
print(f'Size: {len(p)}/{p.capacity}')
p.push(40)
try:
print('Trying push on a full stack...')
p.push(50)
except StackFullError as err:
print('Error:', err)
while not p.is_empty():
print('Out', p.pop())
try:
print('Trying pop on an empty stack...')
p.pop()
except StackEmptyError as err:
print('Error:', err)
print('repr:', repr(p))
Output:
Empty stack: True Stack: [10, 20, 30] (top: 30) Peek: 30 Pop: 30 Stack: [10, 20] (top: 20) Size: 2/3 Trying push on a full stack... Error: full stack, maximum capacity 3 Sale 40 Sale 20 Sale 10 Trying pop on an empty stack... Error: empty stack, no items repr: Stack(capacity=3, items=[])
Why it is done this way:
- The top is the end of the list:
appendandpop()work at the end and are fast. If the top were the start,insert(0, x)andpop(0)would have to shift every item. - Two custom exceptions: whoever uses the stack can tell “it’s empty” from “it’s full” with two different
exceptclauses. push,popandpeekuseself.is_full()andself.is_empty(): the condition is written only once.__len__: makeslen(s)work, as with lists. It’s another magic method.
Trace of the stack (top on the right):
| Operation | Items afterwards | Returns or raises |
|---|---|---|
push(10), push(20), push(30) | [10, 20, 30] | |
peek() | [10, 20, 30] | 30 |
pop() | [10, 20] | 30 |
push(40) | [10, 20, 40] | (full) |
push(50) | [10, 20, 40] | StackFullError |
three pop() | [] | 40, 20, 10 |
pop() | [] | StackEmptyError |
Notice the order they come out, 40, 20, 10: the reverse of how they went in. That’s LIFO.
Exam-style quiz
Now test yourself as in the lab. FP2 quizzes are like this: about 10 questions in 10-15 minutes, single answer, multiple answer (wrong ticks subtract), true or false, gap filling, matching and “what does this code print?”. Here are 14 original questions on classes and objects. Answer without running anything, press Check and read each explanation. These questions are also the bank of the FP2 theory mock exam, which will mix them with those of the other topics.
Fill in the object-oriented programming concepts:
A ____ is the template that describes a new type of data. Each ____ created from it is an instance with its own values, stored in its ____, and its behaviour comes from its ____.
Words:
- module
- methods
- class
- attributes
- global variable
- object
Solution
A class is the template that describes a new type of data. Each object created from it is an instance with its own values, stored in its attributes, and its behaviour comes from its methods.
The class is the concept (Book); the object is a specific case (Book("Dune", 600)). Attributes are each object’s data (title, pages) and methods are the functions that work on them.
With this class, how do you create a 600-page book?
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pagesb = Book("Dune", 600)b = new Book("Dune", 600)b = Book()b = Book.__init__("Dune", 600)
Solution
Answer: b = Book("Dune", 600)
The constructor expression is the class name with parentheses. Python creates the empty object, calls __init__ passing it as self together with your arguments, and gives you back the initialised object.
What number does this program print?
class Counter:
def __init__(self):
self.value = 0
def add(self, n=1):
self.value += n
return self
c = Counter()
c.add().add(5).add()
print(c.value)Solution
Answer: 7
Since add returns self (the object itself), calls can be chained on the same counter: 0 + 1 + 5 + 1 = 7.
True or false?
selfis the object the method is called on.selfis a Python keyword: no other name could be used.__init__returns the newly created object.- Two objects of the same class each have their own instance attributes.
__init__runs automatically when the object is created.
Solution
- T —
selfis the object the method is called on. - F —
selfis a Python keyword: no other name could be used. - F —
__init__returns the newly created object. - T — Two objects of the same class each have their own instance attributes.
- T —
__init__runs automatically when the object is created.
self is only a convention (a very strong one: always use it). __init__ doesn’t create or return the object: it initialises it and returns None; returning anything else gives TypeError.
Match each element with what it does:
__init__ | |
__str__ | |
__repr__ | |
__doc__ | |
@property |
Definitions:
- Deletes the object when the program ends
- Initialises the attributes when the object is created
- Readable text used by print() and str()
- Lets a method be used as if it were an attribute
- Formal representation for the programmer (the one in the console and in lists)
- Attribute that holds the docstring
Solution
__init__→ Initialises the attributes when the object is created__str__→ Readable text used by print() and str()__repr__→ Formal representation for the programmer (the one in the console and in lists)__doc__→ Attribute that holds the docstring@property→ Lets a method be used as if it were an attribute
print(obj) uses __str__; if it doesn’t exist, it uses __repr__. repr(obj), the interactive console and items inside a list use __repr__. The docstring of a class or function is kept in its __doc__ attribute.
What does this program print?
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Point({self.x}, {self.y})"
p = Point(2, 5)
print([p])Solution
Answer: [Point(2, 5)]
What’s printed is a list, and a list shows its items with repr(), not str(). That’s why you get [Point(2, 5)] and not [(2, 5)]. With print(p) you would get (2, 5).
In class Account:, __init__ does self.__balance = 0. Under what name does Python store that attribute inside the object?
_Account__balance__balanceAccount.__balance_balance
Solution
Answer: _Account__balance
Attributes that start with two underscores (and don’t end with them) are renamed to _Class__attribute. It isn’t real security, it’s a way of saying «this is private» and avoiding clashes with subclasses. An attribute like self.balance, with no underscores, has no mangled name.
Look at this class. Which statements are correct?
class Thermometer:
def __init__(self, c):
self.celsius = c
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = valueThermometer(-300)raisesValueErrort.celsius = 20calls the setter- The value is really stored in the attribute
_celsius t.celsius()returns the temperature- The setter is decorated with
@property.setter
Solution
Answer: Thermometer(-300) raises ValueError · t.celsius = 20 calls the setter · The value is really stored in the attribute _celsius
With @property you read t.celsius like an attribute, but the getter runs underneath; with @celsius.setter, every assignment goes through the validation. The real data lives under another name (_celsius): if the setter did self.celsius = value, it would call itself forever.
What does this program print?
class Student:
total = 0
def __init__(self, name):
self.name = name
Student.total += 1
a = Student("Ana")
b = Student("Luis")
a.total = 10
print(Student.total, b.total, a.total)Solution
Answer: 2 2 10
total is a class variable: all objects share it. Each __init__ increases it (2). But a.total = 10 doesn’t change it: it creates a new instance attribute on a that hides it. So Student.total and b.total stay at 2 and only a.total is 10. To change the class one, write Student.total.
Why does this class method return cls(d, m, y) and not Date(d, m, y)?
class Date:
def __init__(self, d, m, y):
self.d, self.m, self.y = d, m, y
@classmethod
def from_text(cls, text):
d, m, y = map(int, text.split("/"))
return cls(d, m, y)- So that, called from a subclass, it creates an object of that subclass
- Because inside the class you can’t write its own name
- Because
clsis faster - Because
Date(d, m, y)would giveTypeError
Solution
Answer: So that, called from a subclass, it creates an object of that subclass
A @classmethod receives the class as its first parameter (cls), just as a normal method receives the object (self). It’s often used as an alternative constructor: another way of creating objects (here, from a text like "27/10/2026").
With the Date class above and d = Date(1, 2, 2026), which calls to from_text are valid?
Date.from_text("1/2/2026")d.from_text("1/2/2026")type(d).from_text("1/2/2026")from_text(Date, "1/2/2026")Date().from_text("1/2/2026")
Solution
Answer: Date.from_text("1/2/2026") · d.from_text("1/2/2026") · type(d).from_text("1/2/2026")
A class method can be called from the class or from any of its objects; in both cases Python passes the class in cls.
About documenting code, true or false?
- The docstring is the first triple-quoted string in the body of a function, class or module.
- Comments written with
#can be read later withfunction.__doc__. help(Class)shows the docstrings of the class and its methods.- PEP 8 recommends that comment and docstring lines stay within 72 characters.
Solution
- T — The docstring is the first triple-quoted string in the body of a function, class or module.
- F — Comments written with
#can be read later withfunction.__doc__. - T —
help(Class)shows the docstrings of the class and its methods. - T — PEP 8 recommends that comment and docstring lines stay within 72 characters.
Comments explain the code to whoever maintains it, and Python discards them. Documentation (docstrings) explains how to use it and stays in __doc__, which is why help() finds it. For code, PEP 8 says 79 characters; for comments and docstrings, 72.
What number does this program print?
class Box:
def __init__(self, items=[]):
self.items = items
def put(self, x):
self.items.append(x)
a = Box()
b = Box()
a.put("pencil")
print(len(b.items))Solution
Answer: 1
The default value [] is created only once, when the function is defined. a and b get the same list, so whatever a puts in also shows up in b. The right way: items=None and inside self.items = [] if items is None else items.
A class is said to be an encapsulation mechanism because…
- it bundles the data with the methods that handle it, and lets you decide what is used from outside
- it stops anyone from reading its attributes, 100 %
- it makes the program faster
- it forces every attribute to have a getter and a setter
Solution
Answer: it bundles the data with the methods that handle it, and lets you decide what is used from outside
Encapsulating means hiding the internal details behind an interface: whoever uses Thermometer assigns t.celsius = 20 without knowing how it’s validated or where it’s stored.
Watch it step by step in Python Tutor
In Python Tutor (choose Python 3) fragment 1 shows up as arrows: one shared list and another one owned by each student.
class Student:
grades = []
def __init__(self, name):
self.name = name
self.own = []
def add(self, grade):
self.grades.append(grade)
self.own.append(grade)
laura = Student('Laura')
pablo = Student('Pablo')
laura.add(9)
pablo.add(4)
print(laura.grades, laura.own)
Output:
[9, 4] [9]
What to watch:
- The
Studentclass box hasgradespointing to an empty list. - When Laura and Pablo are created, each object gets its own
ownlist, but neither hasgradesinside. - In
laura.add(9), the arrow forself.gradesgoes to the class list; the one forself.own, to Laura’s. - After
pablo.add(4), the class list holds[9, 4]and eachownonly its grade:[9]for Laura.
Cheat sheet: classes in Python
The essentials in 30 seconds: a class is the mould and each object carries its data; __init__ stores it with self.. Reading looks in the object and then in the class; assigning writes to the object. What needs protecting gets a double underscore and is touched through @property (with a setter that validates, or without one for read-only). __str__ for people and __repr__ for lists and the console.
| I want to… | Code | Result |
|---|---|---|
| Define a class | class Book: | A new mould |
| Constructor | def __init__(self, title): | Runs on creation |
| Store a value | self.title = title | Object attribute |
| Create an object | b = Book('Rayuela') | An instance |
| See its attributes | vars(b) | Dictionary |
| Data for everyone | total = 0 in the body | Class attribute |
| Change it | Book.total += 1 or cls.total | For all |
| Hide | self.__isbn | Stored as _Book__isbn |
| Read with control | @property def isbn(self): | b.isbn |
| Change with control | @isbn.setter def isbn(self, v): | b.isbn = v validates |
| Class method | @classmethod def f(cls): | Book.f() |
Text for print | def __str__(self): return … | print(b) |
| Text for lists | def __repr__(self): return … | print([b]) |
Make len() work | def __len__(self): return … | len(b) |
| Hand out without letting it be touched | return list(self.__items) | A copy |
Template from the exercises:
class Student:
def __init__(self, name):
self.name = name # goes through the setter
self.__grades = [] # own list, in __init__
@property
def name(self):
return self.__name # the data lives under another name
@name.setter
def name(self, value):
if not value.strip():
raise ValueError('the name cannot be empty')
self.__name = value.strip()
@property
def grades(self): # read-only and a copy
return list(self.__grades)
def __repr__(self):
return f'Student({self.__name!r})'
| Symptom | Likely cause |
|---|---|
AttributeError: … has no attribute 'x' right after creating the object | Missing self. in __init__ (x = x) |
TypeError: f() takes 0 positional arguments but 1 was given | Missing self in the method definition |
| Two objects share a list | The list is in the class body, not in __init__ |
| I change a class attribute and only one object changes | You assigned it with obj.x = instead of Class.x = |
RecursionError with a property | The property and the data have the same name |
<__main__.X object at 0x…> inside a list | Missing __repr__ |
AttributeError: property … has no setter | It’s read-only (on purpose or not) |
| An inner list can be broken from outside | The property returns the list, not a copy |
Before handing in, ask yourself:
- Is all of each object’s data created in
__init__withself.? - Do I validate in the setters and go through them in
__init__too? - Do I return copies of anything mutable?
- Do my classes have
__repr__(and__str__if needed)?
You have finished Classes. The next FP2 topic is Python inheritance: classes that inherit from others, super() and finally the reason behind class MyError(Exception):.
Did this article help you? Here is how you can support SergioLearns →




The hardware that holds up the operating system — theory with my IC2 assignment on the printer, an interrupt that stops everything and a cache that hits (and the infinite loop that didn’t freeze my laptop)