Objects at Work: State, Information Hiding, Properties and a Complete Class

Lesson goals
  • Work with a list of objects — looping, searching and sorting — and with an object that holds an object
  • Explain what happens when two names point to the same object
  • Hide data behind a public interface, and tell apart _name and __name
  • Enforce rules on every assignment with @property and a setter, and write a read-only field and a calculated field
  • Design and build a complete class — from a one-sentence responsibility, through rules, to a clear interface

In this lesson

This lesson is built as one story, in the same order as the slides. Every chapter relies on the previous one:

  1. Chapter 1 — objects and state: many objects, state that changes, and an object that holds an object
  2. Chapter 2 — information hiding: any code can break the data, so we hide it behind an interface
  3. Chapter 3 — validation: the simple syntax of a field, and a check on every assignment with @property
  4. Chapter 4 — a complete class: connecting everything into one class with a responsibility, rules and an interface

At the end of the lesson — practice, and a mini project that connects all the chapters.

Chapter 1: Objects and State — Many Objects and State That Changes

What will we learn?

  • How to manage many objects: lists, searching, filtering and sorting.
  • How the State of objects changes throughout the program.
  • An object that holds another object (for example: an order that holds a customer).
  • References: two names for the same object, versus two objects.

Why do we need this?

A real system is not one object. It is hundreds of objects that talk to each other: customers, orders, products. You need to know how to find the customer with ID 7, all the orders that were not paid, and update the stock when an order is shipped. And also understand what happens when you change an object that several parts of the system “hold”.

Explanation from the basics

Objects in a list

A list can hold objects exactly as it holds numbers or dicts. Everything we learned in the Python topics works:

ActionWith dicts (the Python topics)With objects
Accessing a fielditem["price"]item.price
Sortingsorted(items, key=lambda i: i["price"])sorted(items, key=lambda i: i.price)
Filteringif item["price"] > 100if item.price > 100
An actiondeposit(account, 50)account.deposit(50)

State over time

State = the values of the Attributes now. An object “lives” throughout the program, and its State changes through methods: an order is created in the state "new", moves to "paid", and then to "shipped". Every object goes through the path separately.

An object that holds an object

An Attribute can hold any value, including another object:

order.customer = dana      # the order "knows" its customer

order.customer is a Customer object, so you can continue: order.customer.name.

References — a critical reminder

Remember from the Python topics: a variable is a sticker on an object. This is also true for our own objects:

dana ──────────────►┌──────────────┐
order1.customer ───►│ Customer     │   ONE object, three names
order2.customer ───►│ name: "Dana" │
                    └──────────────┘

If Dana changes her address (dana.city = "Tel Aviv"), both orders see the new address immediately. They do not hold a copy, they hold a reference to the same Dana. This is usually exactly what you want: there is one Dana in the system.

Analogy: a contact in the phone

Several WhatsApp groups contain “Dana”. When Dana changes her profile picture, it is updated in all the groups, because the groups do not hold a photo of Dana. They hold a link to the real Dana.

Syntax

objects = [ClassName(...), ClassName(...)]

for obj in objects:                       # loop
    obj.method()

found = None
for obj in objects:                       # search
    if obj.attr == target:
        found = obj
        break

matching = [obj for obj in objects if obj.attr > 10]   # filter (list comprehension)

ordered = sorted(objects, key=lambda obj: obj.attr)    # sort

holder.other = some_object                # an attribute that holds another object
holder.other.attr                         # chain through it

A List Comprehension (the 3rd line from the bottom) is a short way to build a filtered list. It is equivalent to a loop:

matching = []
for obj in objects:
    if obj.attr > 10:
        matching.append(obj)

Use the version that is more readable for you. Both are correct.

First code example

week-3/team.py:

class Player:
    def __init__(self, name, score=0):
        self.name = name
        self.score = score

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)

team = [Player("Dana"), Player("Avi"), Player("Noa")]

team[0].add_points(30)
team[1].add_points(15)
team[2].add_points(40)
team[0].add_points(20)

for player in team:
    print(player)

leader = max(team, key=lambda player: player.score)
print("Leader:", leader.name)

ranking = sorted(team, key=lambda player: player.score, reverse=True)
for place, player in enumerate(ranking, start=1):
    print(place, player)

Output:

Dana: 50
Avi: 15
Noa: 40
Leader: Dana
1 Dana: 50
2 Noa: 40
3 Avi: 15

Breaking down the code line by line

CodeExplanation
team = [Player("Dana"), ...]A list of 3 separate objects, each with its own score.
team[0].add_points(30)team[0] is a Player object. We call its method. Only its State changes.
team[0].add_points(20)The same object again. Its score adds up to 50.
max(team, key=lambda p: p.score)Finds the object with the highest score, and returns the whole object.
sorted(..., reverse=True)A new sorted list. team itself did not change. But the objects are the same objects, only their order in the new list is different.

Another example

Customers and orders — objects that hold objects, and State that changes. week-3/orders.py:

class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city

class Order:
    VALID_STATUSES = ["new", "paid", "shipped"]
    next_number = 1

    def __init__(self, customer, total):
        self.number = Order.next_number
        Order.next_number = Order.next_number + 1
        self.customer = customer
        self.total = total
        self.status = "new"

    def advance(self):
        position = Order.VALID_STATUSES.index(self.status)
        if position == len(Order.VALID_STATUSES) - 1:
            raise ValueError("order " + str(self.number) + " is already shipped")
        self.status = Order.VALID_STATUSES[position + 1]

    def __str__(self):
        return ("Order #" + str(self.number) + " | " + self.customer.name + " (" + self.customer.city
                + ") | " + str(self.total) + " NIS | " + self.status)

dana = Customer("Dana", "Haifa")
avi = Customer("Avi", "Eilat")

orders = [Order(dana, 120), Order(avi, 80), Order(dana, 300)]

orders[0].advance()
orders[0].advance()
orders[2].advance()

dana.city = "Tel Aviv"

for order in orders:
    print(order)

dana_orders = [order for order in orders if order.customer is dana]
print("Dana's total:", sum(order.total for order in dana_orders))

not_shipped = [order.number for order in orders if order.status != "shipped"]
print("Not shipped yet:", not_shipped)

Output:

Order #1 | Dana (Tel Aviv) | 120 NIS | shipped
Order #2 | Avi (Eilat) | 80 NIS | new
Order #3 | Dana (Tel Aviv) | 300 NIS | paid
Dana's total: 420
Not shipped yet: [2, 3]

Breakdown:

  • Order(dana, 120) — the order holds a reference to the object dana. It does not copy the name and the city.
  • self.customer.name — chaining: from the order to the customer, and from there to the customer’s name.
  • dana.city = "Tel Aviv" — one change, and both of Dana’s orders show the new city. If we had stored self.city = customer.city in __init__, the orders would be “stuck” with Haifa.
  • advance() — a simple State Machine: new → paid → shipped, and you cannot skip or go back. Every order moves forward separately.
  • order.customer is dana — is is correct here: we really are asking “is it the same object?” (and not “does it have the same name?”, because there can be two customers named Dana).
  • sum(order.total for order in dana_orders) — a sum in one line (a Generator Expression, like in the max of the Python topics).

Summary

  • Objects in a list work like any value: looping, searching, sorted/max with key=lambda obj: obj.attr.
  • Every object holds its own State, which changes through its methods.
  • An Attribute can hold another object (order.customer), and you access it by chaining: order.customer.name.
  • It is a reference, not a copy. A change to the object is visible from everyone who holds it.
  • b = a = another name, not a new object. is checks “the same object”.
  • Changing State “from outside” bypasses the rules. In the next chapter: Encapsulation.

Official documentation

Chapter 2: Information Hiding — Encapsulation

What will we learn?

  • What Encapsulation is and why it exists.
  • A Public Interface versus the internal Implementation.
  • Python’s conventions: _protected and __private.
  • What Name Mangling is.

Why do we need this?

Until now we wrote methods with nice checks, for example withdraw that checks there is enough money. But:

account = BankAccount("Dana", 100)
account.balance = -1_000_000

One line bypassed all the rules. No error, no log. The carefully written withdraw simply did not run. In a real system, with dozens of developers and hundreds of files, someone will write a line like this (maybe you, in a month, “just for a test”).

Another problem: suppose we decided to store the balance in agorot (balance_cents) to avoid rounding problems. If 30 places in the code access account.balance directly, all of them break. If they had been using methods, we would change only the methods.

Encapsulation solves both problems.

Explanation from the basics

What is Encapsulation?

Encapsulation = packing the data together with the code that handles it, and hiding the internal details, so that the outside world works only through a defined interface.

Two ideas:

  1. Protecting rules (Invariants) — sensitive data changes only through methods that check it stays valid. “The balance is not negative”, “a grade is between 0 and 100”, “the status moves only forward”.
  2. Hiding the implementation — whoever uses the Class needs to know what it does, not how. That way you can change the “how” without breaking whoever uses it.

Public Interface versus Implementation

Public InterfaceImplementation
WhatThe methods and Attributes that are meant to be used from outsideThe data and the internal methods that let the interface work
Example in a bank accountdeposit(), withdraw(), get_balance()How the balance is stored, the list of transactions, helper checks
StabilityChanges rarely. Others depend on itAllowed to change freely
Analogy: an ATM

An ATM has a public interface: a screen, keys, a card slot, an opening for banknotes. You can “withdraw 200”, and the ATM checks the code, the balance and the daily limit. The safe inside (the implementation) is locked. If anyone could open the safe and take money directly, all the checks would be meaningless. And the bank can replace the counting mechanism inside without you noticing, because the interface stays the same.

How Python does it: conventions

In languages like Java there is real “locking” (private). In Python there is no absolute locking. There are conventions that all Python developers respect:

Written asNameThe meaningWhat Python does
namePublicPart of the interface. Allowed to use from outsideNothing
_nameProtected (a convention)“Internal, don’t touch from outside”Nothing. It is only a message to developers. VS Code also will not suggest it in autocomplete
__namePrivate (Name Mangling)“Very internal”Changes the name behind the scenes, so that access from outside “by mistake” fails

Python’s philosophy: “We are all consenting adults here”. The language trusts you. An underscore says “this is not for you”. Whoever touches it anyway takes responsibility.

Name Mangling

When you write self.__balance inside a Class named BankAccount, Python actually changes the name to _BankAccount__balance. That is why account.__balance from outside fails with an AttributeError. It is not security (you can still access it through account._BankAccount__balance), but it prevents access by mistake, and makes the intention very clear.

In this course: we will mainly use _name (one underscore), which is the most accepted way in real Python code. We get to know __name so we know what it does when we see it.

Syntax

class ClassName:
    def __init__(self, value):
        self.public_value = value      # public: part of the interface
        self._internal = []            # protected by convention: "don't touch from outside"
        self.__secret = 0              # private: name-mangled to _ClassName__secret

    def public_method(self):           # public interface
        self._helper()
        return self.__secret

    def _helper(self):                 # internal helper method
        ...

First code example

week-3/encapsulated_account.py:

class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("initial balance cannot be negative")
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance

account = BankAccount("Dana", 100)
account.deposit(50)
account.withdraw(30)
print("Balance:", account.get_balance())

try:
    account.withdraw(1000)
except ValueError as error:
    print("Rejected:", error)
print("Balance still:", account.get_balance())

Output:

Balance: 120
Rejected: insufficient funds
Balance still: 120

Breaking down the code line by line

CodeExplanation
self.ownerPublic. Allowed to read, and there is no special rule that needs protecting.
self._balanceProtected (a convention). The underscore tells every developer: “the balance is not changed directly”.
deposit / withdrawThe public interface for changes: the only respectable way to change the balance. Every change goes through a check.
get_balance()A Getter: a method that returns the value for reading. From outside you can read the balance, but not change it directly.
account.withdraw(1000) failsAnd the balance did not change. The rule was kept.

And what if someone still writes account._balance = -999? Python will not stop them. But the underscore turns it into a clear, visible mistake in the code: anyone doing a Code Review will immediately see “touching _balance from outside — not allowed”. That is exactly the goal.

Another example

__private and Name Mangling in action, and an internal helper method. week-3/secure_vault.py:

class Vault:
    MAX_ATTEMPTS = 3

    def __init__(self, code):
        self.__code = code
        self._failed_attempts = 0
        self.is_locked = False

    def open(self, code):
        if self.is_locked:
            return "Vault is locked"
        if self._check_code(code):
            self._failed_attempts = 0
            return "Vault opened"
        self._failed_attempts = self._failed_attempts + 1
        if self._failed_attempts >= Vault.MAX_ATTEMPTS:
            self.is_locked = True
            return "Wrong code - vault is now LOCKED"
        return "Wrong code (" + str(self._failed_attempts) + "/" + str(Vault.MAX_ATTEMPTS) + ")"

    def _check_code(self, code):
        return code == self.__code

vault = Vault("1234")
print(vault.open("0000"))
print(vault.open("1234"))

try:
    print(vault.__code)
except AttributeError as error:
    print("AttributeError:", error)

print("Mangled name:", vault._Vault__code)

Output:

Wrong code (1/3)
Vault opened
AttributeError: 'Vault' object has no attribute '__code'
Mangled name: 1234

Breakdown:

  • self.__code — inside the Class you write __code, and Python actually stores it as _Vault__code.
  • vault.__code from outside → AttributeError. It cannot be accessed “by mistake”.
  • vault._Vault__code → works! Name Mangling is not security. A real password is never kept in code (and certainly not in plain text). Here it is only an example for demonstration.
  • _check_code — an internal helper method. It is part of the implementation, not of the interface. Tomorrow it can be replaced with an encrypted check, and no outside code will break, because nobody outside is supposed to call it.
  • is_locked — Public, for reading. (From outside you can also write to it, and that is a small “hole”. In the next chapter we will see how to make an Attribute read-only with @property.)

Summary

  • Encapsulation = data + the code that handles it together, exposing an interface only.
  • Why: (1) protecting rules: changes only through methods that check. (2) hiding the implementation: you can change the “how” without breaking whoever uses it.
  • name = Public. _name = “internal, don’t touch” (a convention, the most common). __name = Name Mangling (_Class__name).
  • Python does not lock. The conventions make the intention clear and prevent mistakes. It is not security.
  • Return a copy of an internal list/dict, not the original.
  • Don’t hide everything. Hide when there is a rule or an implementation detail.

Official documentation

Chapter 3: Validation — Getters, Setters and @property

What will we learn?

  • What a Getter and a Setter are.
  • Validation: checking every new value before it is saved.
  • @property: Python’s way to do this elegantly.
  • A read-only Attribute and a calculated Attribute.

Why do we need this?

In the previous chapter we protected _balance, because it had natural methods (deposit, withdraw). But what about data that is supposed to change directly, only within rules?

  • A user’s age: you can update it, but not to (-5) and not to 300.
  • A product’s price: you can change it, but not to a negative one.
  • An email: you can update it, but only to an address with @.

If age is a regular Attribute, anyone can write user.age = -5. If you hide it (_age), you need an orderly way to read it and change it. That is a Getter and a Setter.

Explanation from the basics

Getter and Setter — the idea

  • Getter — a method that returns the value of internal data. get_age().
  • Setter — a method that receives a new value, checks it, and only if it is valid, saves it. set_age(new_age).

That way the Class has full control: every read and every write goes through it.

Analogy: a bank clerk

You don’t go to your folder in the bank’s archive by yourself and change your address with a pen. You go to a clerk (a Setter): they check that the address is valid and that you are authorized, and only then update it. And whoever wants to see the address asks the clerk (a Getter). The clerk can decide what to show, and maybe hide some of the details.

The problem with get_ and set_ in Python

It works, but it is clumsy:

user.set_age(user.get_age() + 1)

Compared with:

user.age = user.age + 1

In addition, if at first age was a regular Attribute and 50 places in the code write user.age, and later you decide you need validation, you have to change all 50 places to get_/set_.

@property — Python’s solution

@property lets you define a method that looks from outside like a regular Attribute:

  • When you write user.age, the Getter method is called.
  • When you write user.age = 30, the Setter method is called, with the validation.

The code that uses the Class writes user.age, as usual, but behind the scenes every access goes through the checks. The best of both worlds.

@property is a Decorator — exactly what we learned in the lesson on decorators: a line that starts with @ above a def, and “wraps” the function with extra behavior. Here it is enough to know the pattern of @property. (In the backend and databases topics, FastAPI will use Decorators like @app.get(...).)

Syntax

Version 1 — classic Getter/Setter methods (the idea):

class ClassName:
    def __init__(self, value):
        self._value = None
        self.set_value(value)          # validation also on creation

    def get_value(self):
        return self._value

    def set_value(self, new_value):
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value

Version 2 — @property (the accepted way in Python):

class ClassName:
    def __init__(self, value):
        self.value = value             # goes through the setter below!

    @property
    def value(self):                   # getter: runs on  obj.value
        return self._value

    @value.setter
    def value(self, new_value):        # setter: runs on  obj.value = x
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value
  • Both methods have the same name (value), the name of the public “Attribute”.
  • The real data is stored under a different name, with an underscore (_value).
  • @property above the Getter. @value.setter (the property’s name + .setter) above the Setter.
  • Read-only: define only @property, without a Setter.

First code example

The idea — classic Getter and Setter. week-3/user_classic.py:

class User:
    MIN_AGE = 0
    MAX_AGE = 120

    def __init__(self, name, age):
        self.name = name
        self._age = None
        self.set_age(age)

    def get_age(self):
        return self._age

    def set_age(self, new_age):
        if not isinstance(new_age, int):
            raise TypeError("age must be an int, got " + type(new_age).__name__)
        if new_age < User.MIN_AGE or new_age > User.MAX_AGE:
            raise ValueError("age must be between 0 and 120, got " + str(new_age))
        self._age = new_age

dana = User("Dana", 24)
dana.set_age(dana.get_age() + 1)
print(dana.name, dana.get_age())

for bad_value in [-5, "thirty"]:
    try:
        dana.set_age(bad_value)
    except (ValueError, TypeError) as error:
        print("Rejected:", error)

print("Age unchanged:", dana.get_age())

Output:

Dana 25
Rejected: age must be between 0 and 120, got -5
Rejected: age must be an int, got str
Age unchanged: 25

Breaking down the code line by line

CodeExplanation
self._age = None and then self.set_age(age)Even at creation, the age goes through the Setter, so you cannot create a user with an invalid age. Validation in one place.
get_age()A Getter: returns the internal value.
set_age(new_age)A Setter: first checks the type (isinstance), then the range, and only then saves.
type(new_age).__name__The type’s name as clean text ("str"), instead of <class 'str'>.
dana.set_age(dana.get_age() + 1)It works, but it is clumsy. That is the motivation for @property.
Age unchanged: 25The invalid values were not saved.

Another example

The same idea with @property, plus a read-only Attribute and a calculated Attribute. week-3/product_property.py:

class Product:
    def __init__(self, sku, name, price):
        self._sku = sku
        self.name = name
        self.price = price

    @property
    def sku(self):
        return self._sku

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, new_price):
        if not isinstance(new_price, (int, float)):
            raise TypeError("price must be a number")
        if new_price <= 0:
            raise ValueError("price must be positive, got " + str(new_price))
        self._price = new_price

    @property
    def price_with_vat(self):
        return round(self._price * 1.17, 2)

laptop = Product("A1", "Laptop", 3000)
print(laptop.sku, laptop.name, laptop.price, laptop.price_with_vat)

laptop.price = laptop.price * 0.9
print("After discount:", laptop.price, laptop.price_with_vat)

try:
    laptop.price = -100
except ValueError as error:
    print("Rejected:", error)

try:
    laptop.sku = "HACKED"
except AttributeError as error:
    print("Rejected:", error)

try:
    Product("A2", "Ghost", 0)
except ValueError as error:
    print("Cannot create:", error)

Output:

A1 Laptop 3000 3510.0
After discount: 2700.0 3159.0
Rejected: price must be positive, got -100
Rejected: property 'sku' of 'Product' object has no setter
Cannot create: price must be positive, got 0

Breakdown:

  • laptop.price — looks like an Attribute, but actually runs the Getter method. No brackets!
  • laptop.price = laptop.price * 0.9 — a completely natural syntax, but it goes through the Setter with the checks.
  • self.price = price in __init__ — this also goes through the Setter. That is why Product("A2", "Ghost", 0) fails already at creation. One validation for both cases.
  • sku — @property without a Setter → read-only. A product code is not supposed to change after creation. An attempt to change it throws an AttributeError.
  • price_with_vat — a calculated Property: it has no data of its own, and it is calculated from _price on every access. Always up to date (remember the description that became outdated? Here is an even more elegant solution).
  • name — stays a regular Attribute, because there is no rule on it. Don’t add a property without a reason.

Summary

  • A Getter returns internal data. A Setter receives a value, checks it, and saves it. That way the Class controls every read and write.
  • In Python you use @property (Getter) and @name.setter (Setter): from outside it looks like a regular Attribute.
  • The real data is stored under a name with an underscore (_price). The same name creates recursion.
  • In __init__ write self.price = price, so that creation also goes through validation.
  • A property without a Setter = read-only. A calculated property = always up to date.
  • Don’t add a property without a rule. Start with a regular Attribute, and upgrade when needed.

Official documentation

Chapter 4: A Complete Class — Responsibility, Rules and Interface

What will we learn?

Until now we learned parts: __init__, methods, Class Attributes, Encapsulation and properties. Now we will connect all of them into one work process: how to build a complete, good Class from scratch.

Why do we need this?

The real difficulty in OOP is not the syntax, but the decisions: what is the Class responsible for? What do you expose? What do you hide? Which rules must it keep? Beginners who jump straight into code get a Class that does everything, exposes everything, and breaks rules. A fixed process prevents that.

Explanation from the basics

The process — 6 steps

StepQuestionProduct
1. ResponsibilityWhat does this Class do, in one sentence?A sentence
2. DataWhat does it need to remember?A list of Attributes
3. Public interfaceWhat does the outside world need to do with it and know about it?A list of methods and properties
4. Rules (Invariants)What must stay true always?A list of rules
5. ImplementationHow is the data kept inside? Which helper methods?Code
6. TestingIs every rule kept? What about edge cases?A test script

Invariant = a condition that must be true at every moment of the object’s life. For example “balance ≥ 0”, or “the number of enrolled students ≤ the number of seats”. Every method that changes State must leave all the Invariants true.

“One sentence” is a test for SRP: if the sentence needs “and also… and also…”, these are probably two Classes.

Syntax

There is no new syntax. We will combine everything we learned:

class Name:
    CLASS_CONSTANT = ...                 # shared rules / constants

    def __init__(self, ...):             # validation through properties
        ...

    @property                            # read access / read-only / computed
    def value(self): ...

    def command(self, ...):              # changes state, keeps invariants
        ...

    def query(self): ...                 # returns info, changes nothing

    def _helper(self): ...               # internal

    def __str__(self): ...               # human-readable

First code example

The task: a course registration system. We will build a Class named Course according to the 6 steps.

Step 1 — responsibility: “Course manages the registration for one course: who is enrolled, how many seats are left, and the waiting list.”

Step 2 — data: a course code, a title, the maximum number of seats, a list of enrolled students, a waiting list.

Step 3 — the public interface:

Interface memberKindDescription
code, title, capacityproperties (read-only)The course details
enroll(student)SetterEnrolls. If full, puts the student on the waiting list. Returns "enrolled" or "waitlisted"
drop(student)SetterCancels a registration. The first one on the waiting list takes the place
is_enrolled(student)Getter
available_seatscalculated property
is_fullcalculated property
roster()GetterA copy of the list of enrolled students
__str__Display

Step 4 — rules:

  1. The number of enrolled students ≤ capacity, always.
  2. A student is not registered twice, and is not both enrolled and waiting.
  3. capacity is a positive whole number.
  4. When a seat becomes free and there are students waiting, the first in the queue gets in (FIFO, First In First Out).

Step 5 — implementation. week-3/course.py:

class Course:
    MAX_CAPACITY = 500

    def __init__(self, code, title, capacity):
        if not isinstance(capacity, int) or capacity <= 0 or capacity > Course.MAX_CAPACITY:
            raise ValueError("capacity must be a whole number between 1 and " + str(Course.MAX_CAPACITY))
        self._code = code
        self._title = title
        self._capacity = capacity
        self._enrolled = []
        self._waitlist = []

    @property
    def code(self):
        return self._code

    @property
    def title(self):
        return self._title

    @property
    def capacity(self):
        return self._capacity

    @property
    def available_seats(self):
        return self._capacity - len(self._enrolled)

    @property
    def is_full(self):
        return self.available_seats == 0

    def is_enrolled(self, student):
        return student in self._enrolled

    def enroll(self, student):
        self._ensure_not_registered(student)
        if self.is_full:
            self._waitlist.append(student)
            return "waitlisted"
        self._enrolled.append(student)
        return "enrolled"

    def drop(self, student):
        if student in self._waitlist:
            self._waitlist.remove(student)
            return
        if student not in self._enrolled:
            raise ValueError(student + " is not registered to " + self._code)
        self._enrolled.remove(student)
        if self._waitlist:
            next_student = self._waitlist.pop(0)
            self._enrolled.append(next_student)

    def roster(self):
        return list(self._enrolled)

    def waitlist(self):
        return list(self._waitlist)

    def _ensure_not_registered(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered to " + self._code)

    def __str__(self):
        return (self._code + " " + self._title + " | " + str(len(self._enrolled)) + "/"
                + str(self._capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))

Breaking down the code line by line

PartHow it serves the design
Validation in __init__Rule 3: you cannot create a course with an invalid capacity.
_enrolled, _waitlist (internal)Encapsulation: nobody outside can append and bypass rule 1.
code, title, capacity as properties without a SetterRead-only: the course details do not change after creation.
available_seats, is_full are calculatedThere is no duplicated data that may get out of sync. They are always derived from _enrolled.
enrollRule 2 (through _ensure_not_registered), and rule 1 (checking is_full before adding).
dropRule 4: pop(0) = the first one who entered the queue leaves first.
roster() / waitlist() return a copyWhoever got a list cannot change the internal one.
_ensure_not_registeredAn internal helper method: one rule, in one place.
__str__A readable snapshot, for debugging and printing.

Another example

Step 6 — testing. A script that checks every rule, including edge cases. At the end of course.py:

def main():
    course = Course("PY101", "Intro to Python", 2)

    print(course.enroll("Dana"))
    print(course.enroll("Avi"))
    print(course.enroll("Noa"))
    print(course.enroll("Tom"))
    print(course)

    try:
        course.enroll("Dana")
    except ValueError as error:
        print("Rule 2 holds:", error)

    course.drop("Avi")
    print("After Avi dropped:", course.roster(), "| waitlist:", course.waitlist())

    leaked = course.roster()
    leaked.append("Hacker")
    print("Roster after outside append:", course.roster())

    try:
        course.drop("Ghost")
    except ValueError as error:
        print("Drop unknown:", error)

    try:
        Course("BAD1", "Broken", 0)
    except ValueError as error:
        print("Rule 3 holds:", error)

    print("Seats:", course.available_seats, "| full?", course.is_full)

if __name__ == "__main__":
    main()

Output:

enrolled
enrolled
waitlisted
waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 2
Rule 2 holds: Dana is already registered to PY101
After Avi dropped: ['Dana', 'Noa'] | waitlist: ['Tom']
Roster after outside append: ['Dana', 'Noa']
Drop unknown: Ghost is not registered to PY101
Rule 3 holds: capacity must be a whole number between 1 and 500
Seats: 0 | full? True

What the test proved:

  • Rule 1: never more than 2 enrolled students. The third and the fourth went to the waiting list.
  • Rule 2: Dana was not registered twice.
  • Rule 4: when Avi left, Noa (the first in the queue) got in, and Tom stayed on the waiting list.
  • Encapsulation: append on the copy did not change the real list.
  • Rule 3: a course with 0 seats was not created.

Summary

  • The process: responsibility (one sentence) → data → public interface → rules (Invariants) → implementation → testing.
  • Invariant = what must always be true. Every Setter must keep all of them.
  • Internal data is hidden (_x). Fixed details are read-only properties. Derived information is calculated properties.
  • Return copies of internal collections. One rule → one helper method (_ensure_...).
  • Test every rule and edge cases, not only the happy path.

Official documentation

Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

What you practice on this page: a list of objects, an object that holds another object, hiding data (Encapsulation), a property with a check, and a complete class with rules.

By the end of the page you will know how to:

  • Loop over a list of objects and find one by an attribute
  • Build an object that holds a reference to another object
  • Hide data behind methods that check every change
  • Write a property with a setter that enforces a rule
  • Build a complete class that enforces its own rules

Exercise 1: A list of players

What you build: a table of players in a game — and find the leader.

What to write:

  1. A Class named Player. The __init__ receives a name, and the score starts at 0.
  2. add_points — receives a number of points and adds them to the score.
  3. __str__ — returns the name and the score, for example Dana: 50.
  4. After the test code: loop over the list, find the player with the highest score, and print them.

Test code:

players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

The desired output:

Leader: Dana: 50
Show solution
class Player:
    def __init__(self, name):
        self.name = name
        self.score = 0

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)


players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

leader = players[0]
for player in players:
    if player.score > leader.score:
        leader = player
print("Leader:", leader)
Leader: Dana: 50

Exercise 2: An object that holds an object

What you build: a customer and orders — when several orders hold the same customer.

What to write:

  1. A Class named Customer with a name and a city.
  2. A Class named Order with a customer (a Customer object) and a total.
  3. Create one customer, and two orders that receive the same customer object.
  4. Print the city through both orders, change the customer’s city to Tel Aviv, and print again.

The desired output:

Haifa Haifa
Tel Aviv Tel Aviv

What you learn from this: the order does not hold a copy of the customer, but a reference to them.

Show solution
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city


class Order:
    def __init__(self, customer, total):
        self.customer = customer
        self.total = total


dana = Customer("Dana", "Haifa")
order1 = Order(dana, 120)
order2 = Order(dana, 80)
print(order1.customer.city, order2.customer.city)

dana.city = "Tel Aviv"
print(order1.customer.city, order2.customer.city)
Haifa Haifa
Tel Aviv Tel Aviv

The orders do not hold a copy of the customer, but a reference to them. A change to the customer is visible from every order that holds them.

Exercise 3: A protected BankAccount

What you build: a bank account that cannot be broken — the balance changes only through methods that check it.

What to write:

  1. A Class named BankAccount, with an owner and a balance. Keep the balance in _balance — an underscore says “internal, don’t touch from outside”.
  2. deposit — adds an amount.
  3. withdraw — takes away an amount. If there is not enough money: a ValueError with the message from the output.
  4. get_balance — returns the balance.

Test code:

account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())

The desired output:

error: not enough money
150
Show solution
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("not enough money")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance


account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())
error: not enough money
150

Exercise 4: A Setter that checks

What you build: a user whose age is always valid — with a property that checks every assignment.

What to write:

  1. A Class named User, whose __init__ receives an age and saves it through the property.
  2. A Getter with @property named age — returns the age saved in _age.
  3. A Setter with @age.setter — checks that the age is between 0 and 120. If not: a ValueError with the message from the output.
  4. Notice: the assignment in __init__ also goes through the check.

Test code:

user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)

The desired output:

31
error: age must be 0-120
31
Show solution
class User:
    def __init__(self, age):
        self.age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0 or value > 120:
            raise ValueError("age must be 0-120")
        self._age = value


user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
31
error: age must be 0-120
31
  • self.age = age in __init__ goes through the setter — so a new object is checked too.
  • The value is saved in _age, and the property exposes it as if it were a regular Attribute.

Exercise 5: Registration

What you build: registration for a course with a limited number of seats and a waiting list.

What to write:

  1. A Class named Course, with a course code, a name and a capacity, and two internal lists: enrolled and waiting.
  2. enroll — receives a student’s name:
    • if they are already enrolled or waiting: a ValueError with the message from the output;
    • if there is a seat: adds them to the enrolled and returns "enrolled";
    • otherwise: adds them to the waiting list and returns "waitlisted".
  3. __str__ — the code, the name, how many are enrolled out of the capacity, and how many are waiting.

Test code:

course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)

The desired output:

Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
Show solution
class Course:
    def __init__(self, code, name, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.code = code
        self.name = name
        self.capacity = capacity
        self._enrolled = []
        self._waitlist = []

    def enroll(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered")
        if len(self._enrolled) < self.capacity:
            self._enrolled.append(student)
            return "enrolled"
        self._waitlist.append(student)
        return "waitlisted"

    def __str__(self):
        return (self.code + " " + self.name + " | " + str(len(self._enrolled)) + "/"
                + str(self.capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))


course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered

Mini project: A shop’s inventory

Which system you build: an inventory system for a small shop. Every product has a name, a price and a quantity in stock. You can sell and add stock, and the system makes sure nothing invalid happens — a negative price, selling more than there is, or two products with the same name. At the end it shows the products by value, and the value of the whole inventory.

What you will learn from the project:

  • Hide data (Encapsulation): change it only through methods that check
  • Use a property: with a setter that checks, read-only, and calculated
  • Build a Class that holds a list of other objects
  • Sort objects by a calculated value

What to build — step by step:

Part A — a product (Product)

Step 1 — creation. The __init__ receives a name, a price and a quantity (default 0). The name is saved in _name, the quantity in _quantity, and the price goes through the setter of price.

Step 2 — the price. price is a property with a setter that checks that the price is greater than 0. Otherwise: ValueError.

Step 3 — read-only fields. name and quantity are properties without a setter — from outside you can read them, but not change them.

Step 4 — changing the quantity. The quantity changes only through two methods:

  • add_stock — adds quantity. If the amount is not positive: ValueError.
  • sell — takes away quantity. If there is not enough in stock: ValueError with the product’s name.

Step 5 — a calculated value and display. total_value is a calculated property: the price times the quantity. __str__ shows the product as in the output.

Part B — the inventory (Inventory)

Step 6 — an internal list. Inventory holds an internal list named _products.

Step 7 — adding and searching. add adds a product. If there is already a product with the same name: ValueError. find receives a name and returns the product, or None.

Step 8 — reports. by_value returns a copy of the list of products, sorted by total_value from the highest to the lowest. The inventory’s total_value is a calculated property: the sum of the values of all the products.

The error messages — exactly like this:

price must be positive
amount must be positive
not enough <product name>
<product name> already exists

Test code:

shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)

The desired output:

error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270

By the end of the project you will know how to:

  • Build an object that protects its own data — from outside it cannot be broken
  • Choose between a regular field, a property with a check, a read-only field and a calculated value
  • Build a “manager” that holds objects, searches among them and summarizes them

Bonus: add to Inventory a method low_stock that receives a limit, and returns the names of the products whose quantity is smaller than it.

Show solution
class Product:
    def __init__(self, name, price, quantity=0):
        self._name = name
        self.price = price
        self._quantity = quantity

    @property
    def name(self):
        return self._name

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError("price must be positive")
        self._price = value

    @property
    def quantity(self):
        return self._quantity

    def add_stock(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._quantity = self._quantity + amount

    def sell(self, amount):
        if amount > self._quantity:
            raise ValueError("not enough " + self._name)
        self._quantity = self._quantity - amount

    @property
    def total_value(self):
        return self._price * self._quantity

    def __str__(self):
        return (self._name + ": " + str(self._quantity) + " x " + str(self._price)
                + " = " + str(self.total_value))


class Inventory:
    def __init__(self):
        self._products = []

    def add(self, product):
        if self.find(product.name) is not None:
            raise ValueError(product.name + " already exists")
        self._products.append(product)

    def find(self, name):
        for product in self._products:
            if product.name == name:
                return product
        return None

    def by_value(self):
        return sorted(self._products, key=lambda p: p.total_value, reverse=True)

    @property
    def total_value(self):
        total = 0
        for product in self._products:
            total = total + product.total_value
        return total


shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
  • Inventory holds Product objects (an object that holds objects), and by_value returns a new list — sorted does not touch the internal list.
  • _name, _price, _quantity are hidden. From outside, the quantity is changed only through add_stock and sell — which check the rules.
  • price has a setter that checks — also in __init__. name and quantity are read-only, and total_value is calculated on every read.

Bonus:

    def low_stock(self, limit):
        names = []
        for product in self._products:
            if product.quantity < limit:
                names.append(product.name)
        return names

(A method inside Inventory.) With the data above, shop.low_stock(4) returns ['Bag'].