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.

How to use this article

  1. Read each exercise’s statement and expected output, and write your solution without looking below.
  2. If you get stuck, open the hint, but try for five more minutes first.
  3. Before the solutions, do “What’s wrong here?”: predict what each fragment prints and then check.
  4. 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 (ValueError otherwise).
  • Offer an @property for name, age and grades (the latter read-only and returning a copy).
  • Have the methods add_grade(grade), average() and passed() (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_vehicles with 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() and is_full() return True or False.
  • Two custom exceptions: StackEmptyError (pop or peek on an empty stack) and StackFullError (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.

Shared listLeft: laura and pablo point to the same class list, which ends up with 9 and 4. Right: each has its own list, with 9 and with 4.grades = [] in the class vs self.grades = [] in __init__Wrong: class attributelaurapablo[9, 4]a single list (in the class)Right: instance attributelaurapablo[9][4]Every time __init__ runs a new list is created; the class body runs only once.
With the list in the class, Pablo's grade turns up on Laura's report.

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 = name and self.age = age in __init__: they go through the setters, so a Student('', 200) never even gets created.
  • The grades list, in __init__: each student with their own (fragment 1).
  • add_grade validates and raises: the 11 gives ValueError and the loop catches it without stopping. The list ends up with four grades.
  • grades without 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. Raising ValueError('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 = 0 gives AttributeError. It only goes up with drive, which validates. Winding back a car’s mileage is, literally, a crime.
  • drive first 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_value reads self.__year and self.__mileage directly: 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: append and pop() work at the end and are fast. If the top were the start, insert(0, x) and pop(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 except clauses.
  • push, pop and peek use self.is_full() and self.is_empty(): the condition is written only once.
  • __len__: makes len(s) work, as with lists. It’s another magic method.
Stack statesFour states of a capacity-3 stack: after pushing 10, 20 and 30; after a pop returning 30; after pushing 40, full; and a push of 50 raising the full-stack exception.Stack(3): last in, first outpush 10, 20, 30102030pop1020-> 30push 40102040fullpush 50102040StackFullErrorThe top is the last item in the list: append() and pop() always work on that end.
The top (in coral) is always the last thing in; with 3 items, push raises the exception.

Trace of the stack (top on the right):

OperationItems afterwardsReturns 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.

Question 1Fill in the gaps

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.

Question 2Single choice

With this class, how do you create a 600-page book?

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages
  1. b = Book("Dune", 600)
  2. b = new Book("Dune", 600)
  3. b = Book()
  4. 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.

Question 3Short answer

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.

Question 4True or false

True or false?

  1. self is the object the method is called on.
  2. self is a Python keyword: no other name could be used.
  3. __init__ returns the newly created object.
  4. Two objects of the same class each have their own instance attributes.
  5. __init__ runs automatically when the object is created.
Solution
  • T — self is the object the method is called on.
  • F — self is 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.

Question 5Match

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.

Question 6Short answer

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

Question 7Single choice

In class Account:, __init__ does self.__balance = 0. Under what name does Python store that attribute inside the object?

  1. _Account__balance
  2. __balance
  3. Account.__balance
  4. _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.

Question 8Multiple · wrong ticks subtract

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 = value
  1. Thermometer(-300) raises ValueError
  2. t.celsius = 20 calls the setter
  3. The value is really stored in the attribute _celsius
  4. t.celsius() returns the temperature
  5. 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.

Question 9Short answer

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.

Question 10Single choice

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)
  1. So that, called from a subclass, it creates an object of that subclass
  2. Because inside the class you can’t write its own name
  3. Because cls is faster
  4. Because Date(d, m, y) would give TypeError
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").

Question 11Multiple · wrong ticks subtract

With the Date class above and d = Date(1, 2, 2026), which calls to from_text are valid?

  1. Date.from_text("1/2/2026")
  2. d.from_text("1/2/2026")
  3. type(d).from_text("1/2/2026")
  4. from_text(Date, "1/2/2026")
  5. 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.

Question 12True or false

About documenting code, true or false?

  1. The docstring is the first triple-quoted string in the body of a function, class or module.
  2. Comments written with # can be read later with function.__doc__.
  3. help(Class) shows the docstrings of the class and its methods.
  4. 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 with function.__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.

Question 13Short answer

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.

Question 14Single choice

A class is said to be an encapsulation mechanism because…

  1. it bundles the data with the methods that handle it, and lets you decide what is used from outside
  2. it stops anyone from reading its attributes, 100 %
  3. it makes the program faster
  4. 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:

  1. The Student class box has grades pointing to an empty list.
  2. When Laura and Pablo are created, each object gets its own own list, but neither has grades inside.
  3. In laura.add(9), the arrow for self.grades goes to the class list; the one for self.own, to Laura’s.
  4. After pablo.add(4), the class list holds [9, 4] and each own only 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…CodeResult
Define a classclass Book:A new mould
Constructordef __init__(self, title):Runs on creation
Store a valueself.title = titleObject attribute
Create an objectb = Book('Rayuela')An instance
See its attributesvars(b)Dictionary
Data for everyonetotal = 0 in the bodyClass attribute
Change itBook.total += 1 or cls.totalFor all
Hideself.__isbnStored 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 printdef __str__(self): return …print(b)
Text for listsdef __repr__(self): return …print([b])
Make len() workdef __len__(self): return …len(b)
Hand out without letting it be touchedreturn 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})'
SymptomLikely cause
AttributeError: … has no attribute 'x' right after creating the objectMissing self. in __init__ (x = x)
TypeError: f() takes 0 positional arguments but 1 was givenMissing self in the method definition
Two objects share a listThe list is in the class body, not in __init__
I change a class attribute and only one object changesYou assigned it with obj.x = instead of Class.x =
RecursionError with a propertyThe property and the data have the same name
<__main__.X object at 0x…> inside a listMissing __repr__
AttributeError: property … has no setterIt’s read-only (on purpose or not)
An inner list can be broken from outsideThe property returns the list, not a copy

Before handing in, ask yourself:

  1. Is all of each object’s data created in __init__ with self.?
  2. Do I validate in the setters and go through them in __init__ too?
  3. Do I return copies of anything mutable?
  4. 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):.

Keep goingAll Python articles

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *