LEARN PYTHON | 06 OBJECT-ORIENTED PROGRAMMING
Build it. Test it. Explain it.
Give objects clear responsibilities and test the state changes they promise. Work independently before opening a hint, then compare your tested solution with one possible approach.
YOUR CHALLENGE ROUTE
Complete these tasks in your Python editor. The page is a challenge guide, not a general Python runner. Use fictional details where helpful. Work in order or choose a task matching an area you need to practise.
These tasks combine object-oriented design with earlier functions, collections, validation and tests. Run Python in your editor; the page does not execute arbitrary source. Capstone checks require the companion project modules. Use fictional names and records.
TRACK YOUR TESTED SOLUTIONS
Tick a task only after you have run it, checked at least two relevant cases and explained one line. These are self-recorded checks, not an automatic assessment of your source code.
Progress lasts while this page is open. Save your actual Python files in your project folder.
01 | FOUNDATION
CREATE INDEPENDENT ROBOTS
Create two instances, each with a name, battery value and independent history.
YOUR REQUIREMENTS
- Use __init__ and self.
- Create a fresh history list in each initializer.
- Append a message only to Scout.
- Print both histories.
- Explain why class creation is separate from instance creation.
Hint: where to begin
A mutable history in the class body would risk sharing. Execute self.history = [] for each new instance.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Scout history | [started] |
| Rover history | [] |
| Second fresh instance | Own empty history, not Scout’s list |
Reveal one possible solution and explanation
class Robot:
def __init__(self, name, battery):
self.name = name
self.battery = battery
self.history = []
scout = Robot("Scout", 80)
rover = Robot("Rover", 60)
scout.history.append("started")
print(scout.name, scout.history)
print(rover.name, rover.history)The initializer creates a new list per instance. The example assumes suitable name and battery arguments; a production boundary would validate them.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add constructor checks for non-blank text and a whole battery percentage.
02 | REPAIR REFERENCES
REPAIR A SHARED CLASS LIST
Two reading objects unexpectedly share titles. Move the list to the correct creation point and compare identity with aliasing.
YOUR REQUIREMENTS
- Create self.titles inside __init__.
- Add one title only to Maya.
- Create alias = maya.
- Show alias is maya and maya is noor.
- Explain why assignment does not copy an instance.
SOURCE TO REPAIR
class ReadingList:
titles = []
a = ReadingList()
b = ReadingList()
a.titles.append("Python Adventures")
print(b.titles)Hint: where to begin
An instance attribute name does not help unless its mutable value is created independently.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Maya after alias append | [Python Adventures] |
| Noor | [] |
| Identity | True, then False |
Reveal one possible solution and explanation
class ReadingList:
def __init__(self, name):
self.name = name
self.titles = []
maya = ReadingList("Maya")
noor = ReadingList("Noor")
alias = maya
alias.titles.append("Python Adventures")
print(maya.titles)
print(noor.titles)
print(alias is maya, maya is noor)The two initializers created distinct lists. The alias deliberately refers to Maya’s object, so its mutation is visible through Maya too.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Demonstrate the original class-list bug separately and explain the different before and after state.
03 | METHODS AND INVARIANTS
ENCAPSULATE A BATTERY
Create a battery with a read-only public level and a checked consumption method.
YOUR REQUIREMENTS
- Validate the starting whole percentage.
- Keep internal state under _level.
- Provide @property level.
- Reject negative, excessive and Boolean consumption.
- Leave state unchanged after rejection.
Hint: where to begin
Validate before subtraction. A read-only property does not make underscore access impossible.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Consume twenty from eighty | 60 |
| Request sixty-one next | ValueError; still 60 |
| Zero | Accepted with no change |
| True | Rejected under exact-int rule |
Reveal one possible solution and explanation
class Battery:
def __init__(self, level=100):
if type(level) is not int or not 0 <= level <= 100:
raise ValueError("Use 0 to 100")
self._level = level
@property
def level(self):
return self._level
def use_energy(self, amount):
if type(amount) is not int or not 0 <= amount <= self._level:
raise ValueError("Invalid consumption")
self._level -= amount
return self._level
battery = Battery(80)
print(battery.use_energy(20))
before = battery.level
try:
battery.use_energy(61)
except ValueError:
assert battery.level == before
print("After rejection:", battery.level)The property exposes observation while the method owns valid transitions. The underscore is a design convention, not a security barrier.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Write unittest cases for zero, exact remaining energy and a rejected negative request.
04 | ENCAPSULATION
RETURN A SNAPSHOT, NOT A MUTABLE INTERNAL LIST
Build a reading list with an add method and tuple report. Confirm an earlier snapshot does not grow after a later addition.
YOUR REQUIREMENTS
- Validate non-blank title text.
- Store titles internally.
- Return a tuple of immutable strings.
- Capture a snapshot, then add another title.
- Compare the snapshot with the current report.
Hint: where to begin
A tuple creates a new outer view. Its safety here depends on its contents being immutable strings.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Earlier snapshot | One title |
| Current report | Two titles |
| Blank add | ValueError before mutation |
Reveal one possible solution and explanation
class ReadingList:
def __init__(self):
self._titles = []
def add(self, title):
if type(title) is not str or not title.strip():
raise ValueError("Use non-blank text")
self._titles.append(title.strip())
def titles(self):
return tuple(self._titles)
reading = ReadingList()
reading.add("Python Adventures")
snapshot = reading.titles()
reading.add("Robots in Action")
print("Earlier:", snapshot)
print("Current:", reading.titles())The supported add method preserves the title rule. Returning the actual internal list would allow unchecked appends. A tuple of mutable records would require further attention to their contents.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Explain how frozen record fields can help a catalogue snapshot without claiming absolute privacy.
05 | INHERITANCE
EXTEND A ROBOT WITH SUPER
Create a specialised delivery robot that preserves the base describe contract.
YOUR REQUIREMENTS
- Initialise a name in Robot.
- Initialise destination in DeliveryRobot after super.
- Override describe with no new compulsory arguments.
- Return text rather than print inside describe.
- Extend the base message.
Hint: where to begin
super delegates for the same instance; it does not create a separate base robot component.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Description | Robot Courier delivering to Library |
| Inherited name | Courier |
| isinstance base | True |
Reveal one possible solution and explanation
class Robot:
def __init__(self, name):
self.name = name
def describe(self):
return f"Robot {self.name}"
class DeliveryRobot(Robot):
def __init__(self, name, destination):
super().__init__(name)
self.destination = destination
def describe(self):
return super().describe() + f" delivering to {self.destination}"
robot = DeliveryRobot("Courier", "Library")
print(robot.describe())
print(isinstance(robot, Robot))The override remains callable in the same way and returns the same kind of result. The base initialisation supplies inherited state.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a survey subclass and test that both satisfy the no-argument text-report contract.
06 | POLYMORPHISM
USE ONE CALLER WITH DIFFERENT OBJECTS
Write a reporting function that works with two compatible objects without checking their class names.
YOUR REQUIREMENTS
- Give each object a describe method.
- Use one report function.
- Return text consistently.
- Avoid isinstance branches in the caller.
- Explain that this example uses duck typing without a shared custom base.
Hint: where to begin
The caller needs behaviour, not a manually enumerated class list. Both objects must still satisfy the contract.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Two compatible objects | Both text descriptions in order |
| Empty list | [] |
| Object without describe | AttributeError; incompatible interface |
Reveal one possible solution and explanation
class DeliveryRobot:
def describe(self):
return "Delivering a book"
class SurveyRobot:
def describe(self):
return "Measuring a room"
def report(objects):
return [obj.describe() for obj in objects]
print(report([DeliveryRobot(), SurveyRobot()]))Polymorphism can work without shared implementation inheritance. Compatibility still matters; the function assumes a suitable describe method on each supplied object.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a third compatible class and verify the caller needs no change.
07 | COMPOSITION
COMPOSE A ROBOT WITH A TEST SENSOR
Supply a fixed sensor to a robot, then substitute a second sensor with a different reading.
YOUR REQUIREMENTS
- Define a sensor read method.
- Accept the component in Robot.__init__.
- Delegate the operation.
- Do not make Robot inherit from Sensor.
- Use fixed readings for repeatable tests.
Hint: where to begin
Composition stores the supplied reference. A fixed test collaborator can avoid a hardware dependency.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| First component | 25 |
| Second component | 40 |
| Relationship | Robot has a sensor, rather than is a sensor |
Reveal one possible solution and explanation
class FixedSensor:
def __init__(self, reading):
self._reading = reading
def read(self):
return self._reading
class Robot:
def __init__(self, sensor):
self._sensor = sensor
def distance(self):
return self._sensor.read()
print(Robot(FixedSensor(25)).distance())
print(Robot(FixedSensor(40)).distance())Supplying a collaborator makes the dependency explicit and easy to replace in tests. The robot owns the delegation, not the sensor’s measurement implementation.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Write a Protocol describing read() -> int after explaining the compatible-interface rule.
08 | RECORD DATA
CREATE DATACLASSES WITH INDEPENDENT DEFAULTS
Use a dataclass for a named reading list and prove both field equality and independent default lists.
YOUR REQUIREMENTS
- Use annotated fields.
- Use field(default_factory=list).
- Compare two initially equal same-name records.
- Append to only one.
- Explain == versus is.
Hint: where to begin
The factory is a callable. Dataclass equality compares fields; equal values do not prove identical objects.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Initial comparison | True equality; False identity |
| After append | Only a has the title |
| New comparison | False equality |
Reveal one possible solution and explanation
from dataclasses import dataclass, field
@dataclass
class ReadingList:
name: str
titles: list[str] = field(default_factory=list)
a = ReadingList("Maya")
b = ReadingList("Maya")
print("Initially:", a == b, a is b)
a.titles.append("Python Adventures")
print(a.titles, b.titles)
print("After change:", a == b)The generated equality observes field values. Each omitted titles argument receives a fresh list. An explicitly shared caller list could still create aliasing.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Repeat with frozen=True and explain why the contained list can still be appended to.
09 | DATACLASS BOUNDARIES
VALIDATE A FROZEN RECORD
Create a frozen book record that rejects blank titles and blocks ordinary later field assignment.
YOUR REQUIREMENTS
- Use @dataclass(frozen=True).
- Validate title in __post_init__.
- Construct a valid record.
- Catch FrozenInstanceError for a deliberate reassignment test.
- Avoid claiming that all frozen nested objects are deeply immutable.
Hint: where to begin
The generated initializer assigns fields, then post-initialisation checks them. Type hints do not supply that check automatically.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Valid record | Python Adventures |
| Later assignment | FrozenInstanceError; title unchanged |
| Blank title | ValueError from post-initialisation |
Reveal one possible solution and explanation
from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)
class Book:
copy_id: str
title: str
def __post_init__(self):
if type(self.title) is not str or not self.title.strip():
raise ValueError("Use a non-blank title")
book = Book("B001", "Python Adventures")
try:
book.title = "Changed"
except FrozenInstanceError:
print("Ordinary reassignment blocked")
print(book.title)Frozen fields protect ordinary record use. This small exercise validates the title only; ID format and collection uniqueness need their own explicit requirements.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add the B-plus-three-digits ID rule and test invalid construction separately.
10 | TYPE HINTS
DOCUMENT AN OPTIONAL SEARCH RESULT
Annotate a linear search and document its absent result, first-match policy and non-mutation.
YOUR REQUIREMENTS
- Use list[int], int and int | None.
- Return the first matching index.
- Use is not None for a zero index.
- Write a docstring explaining effects and result meaning.
- Explain why annotations do not validate every supplied list.
Hint: where to begin
Type hints communicate expectations. The search still needs a clear value contract and tests.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Target eight | First index zero, reported as found |
| Absent target | None |
| After search | Input unchanged |
Reveal one possible solution and explanation
def find_first(values: list[int], target: int) -> int | None:
"""Return the first target index or None; do not mutate values."""
for index, value in enumerate(values):
if value == target:
return index
return None
values = [8, 5, 8]
position = find_first(values, 8)
if position is not None:
print("Found at:", position)
print("Absent:", find_first(values, 99))
print("Input:", values)The union captures the two result possibilities. Documentation adds first-match and non-mutation meaning that types alone do not express.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add unittest checks for duplicates, empty input and unchanged values, then use a type checker separately.
11 | CAPSTONE
BUILD THE LIBRARY SERVICE
Build the project modules, then run this direct service-check script beside them.
YOUR REQUIREMENTS
- Keep entities.py, lending.py, app.py and test_library.py together.
- Use a physical copy ID distinct from title.
- Check repeated lending fails without changing state.
- Check the due date and overdue boundary.
- Return the copy and check availability.
- Run the bundled suite.
Hint: where to begin
The service receives dates explicitly and asks for no input. The check file can therefore test rules independently of the console.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Loan October first | Due October fifteenth |
| Double loan | Rejected; earlier record unchanged |
| Due day versus next | Zero overdue, then one |
| After return | Available again |
Reveal one possible solution and explanation
from datetime import date
from app import demo_library
library = demo_library()
loan = library.lend("B001", "M001", date(2026, 10, 1))
assert loan.due_on == date(2026, 10, 15)
assert not library.available("B001")
assert library.available("B002")
before = library.active_loans()
try:
library.lend("B001", "M002", date(2026, 10, 1))
except ValueError:
assert library.active_loans() == before
else:
raise AssertionError("Repeated loan was not rejected")
assert library.overdue(date(2026, 10, 15)) == ()
assert len(library.overdue(date(2026, 10, 16))) == 1
library.return_book("B001", date(2026, 10, 16))
assert library.available("B001")
print("Library checks passed")The check inspects both return values and state transitions. B002 remains independently available despite sharing B001’s title. Session records are not saved across restarts.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a controlled renewal requirement and tests before changing the service.
12 | TEST AND REFLECT
TEST LIMITS AND HAND OVER THE DESIGN
Test active-loan capacity, returned capacity and independent library instances. Provide a partner with a module map and honest data-lifetime description.
YOUR REQUIREMENTS
- Borrow two copies for M001.
- Reject a third under the demo policy.
- Return one and successfully borrow another.
- Check a fresh library has no active loans.
- Explain composition rather than forcing Library to inherit Book.
- Document the session-only limit.
Hint: where to begin
A rejection should leave two active records. A return changes capacity because the limit counts active loans, not historical borrowing.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Third simultaneous loan | Rejected |
| Return then lend | Capacity available; two active records |
| Fresh instance | No active loans |
| Restart | Seeded demo recreated, not a saved history |
Reveal one possible solution and explanation
from datetime import date
from app import demo_library
library = demo_library()
day = date(2026, 10, 1)
library.lend("B001", "M001", day)
library.lend("B002", "M001", day)
try:
library.lend("B003", "M001", day)
except ValueError:
print("Third active loan rejected")
else:
raise AssertionError("Limit not enforced")
assert len(library.active_loans()) == 2
library.return_book("B001", day)
library.lend("B003", "M001", day)
assert len(library.active_loans()) == 2
assert demo_library().active_loans() == ()
print("Capacity and independence checks passed")Fresh per-instance dictionaries prevent one library’s loans leaking into another. The handover tests communication as well as code: users should know which file starts the app and what is retained.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Plan persistence with a defined schema and consistency checks without claiming that dumping an object’s __dict__ restores every rule.
KEEP A SMALL TEST RECORD
Record construction arguments, the method call, expected result, expected state change and actual behaviour. For rejection, compare earlier state with the state afterwards.
Include identity versus equality, independent versus shared lists, exact policy endpoints and a snapshot taken before a later change. For the library, test the due day and next day separately. Document the runtime rules that annotations alone do not enforce.
READY FOR THE NEXT SECTION?
You are ready to move on when you can explain classes and instances, preserve state rules through methods, choose inheritance or composition, use dataclasses deliberately and distinguish hints from runtime checks.
If one skill is uncertain, revisit its lesson and change a worked example before repeating the challenge. The Section 6 workbook is coming soon.
Next: terminal commands and virtual environments Python course navigation