Objects at Work: State, Information Hiding, Properties and a Complete Class
- 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
_nameand__name - Enforce rules on every assignment with
@propertyand 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:
- Chapter 1 — objects and state: many objects, state that changes, and an object that holds an object
- Chapter 2 — information hiding: any code can break the data, so we hide it behind an interface
- Chapter 3 — validation: the simple syntax of a field, and a check on every assignment with
@property - 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:
| Action | With dicts (the Python topics) | With objects |
|---|---|---|
| Accessing a field | item["price"] | item.price |
| Sorting | sorted(items, key=lambda i: i["price"]) | sorted(items, key=lambda i: i.price) |
| Filtering | if item["price"] > 100 | if item.price > 100 |
| An action | deposit(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 customerorder.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.
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 itA 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: 15Breaking down the code line by line
| Code | Explanation |
|---|---|
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 objectdana. 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 storedself.city = customer.cityin__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—isis 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 themaxof the Python topics).
Summary
- Objects in a list work like any value: looping, searching,
sorted/maxwithkey=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.ischecks “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:
_protectedand__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_000One 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:
- 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”.
- 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 Interface | Implementation | |
|---|---|---|
| What | The methods and Attributes that are meant to be used from outside | The data and the internal methods that let the interface work |
| Example in a bank account | deposit(), withdraw(), get_balance() | How the balance is stored, the list of transactions, helper checks |
| Stability | Changes rarely. Others depend on it | Allowed to change freely |
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 as | Name | The meaning | What Python does |
|---|---|---|---|
name | Public | Part of the interface. Allowed to use from outside | Nothing |
_name | Protected (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 |
__name | Private (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: 120Breaking down the code line by line
| Code | Explanation |
|---|---|
self.owner | Public. Allowed to read, and there is no special rule that needs protecting. |
self._balance | Protected (a convention). The underscore tells every developer: “the balance is not changed directly”. |
deposit / withdraw | The 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) fails | And 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: 1234Breakdown:
self.__code— inside the Class you write__code, and Python actually stores it as_Vault__code.vault.__codefrom 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.
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 + 1In 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_valueVersion 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). @propertyabove 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: 25Breaking down the code line by line
| Code | Explanation |
|---|---|
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: 25 | The 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 0Breakdown:
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 = pricein__init__— this also goes through the Setter. That is whyProduct("A2", "Ghost", 0)fails already at creation. One validation for both cases.sku—@propertywithout a Setter → read-only. A product code is not supposed to change after creation. An attempt to change it throws anAttributeError.price_with_vat— a calculated Property: it has no data of its own, and it is calculated from_priceon every access. Always up to date (remember thedescriptionthat 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__writeself.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
| Step | Question | Product |
|---|---|---|
| 1. Responsibility | What does this Class do, in one sentence? | A sentence |
| 2. Data | What does it need to remember? | A list of Attributes |
| 3. Public interface | What 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. Implementation | How is the data kept inside? Which helper methods? | Code |
| 6. Testing | Is 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-readableFirst 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 member | Kind | Description |
|---|---|---|
code, title, capacity | properties (read-only) | The course details |
enroll(student) | Setter | Enrolls. If full, puts the student on the waiting list. Returns "enrolled" or "waitlisted" |
drop(student) | Setter | Cancels a registration. The first one on the waiting list takes the place |
is_enrolled(student) | Getter | |
available_seats | calculated property | |
is_full | calculated property | |
roster() | Getter | A copy of the list of enrolled students |
__str__ | Display |
Step 4 — rules:
- The number of enrolled students ≤
capacity, always. - A student is not registered twice, and is not both enrolled and waiting.
capacityis a positive whole number.- 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
| Part | How 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 Setter | Read-only: the course details do not change after creation. |
available_seats, is_full are calculated | There is no duplicated data that may get out of sync. They are always derived from _enrolled. |
enroll | Rule 2 (through _ensure_not_registered), and rule 1 (checking is_full before adding). |
drop | Rule 4: pop(0) = the first one who entered the queue leaves first. |
roster() / waitlist() return a copy | Whoever got a list cannot change the internal one. |
_ensure_not_registered | An 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? TrueWhat 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:
appendon 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:
- A Class named
Player. The__init__receives a name, and the score starts at 0. add_points— receives a number of points and adds them to the score.__str__— returns the name and the score, for exampleDana: 50.- 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: 50Show 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: 50Exercise 2: An object that holds an object
What you build: a customer and orders — when several orders hold the same customer.
What to write:
- A Class named
Customerwith a name and a city. - A Class named
Orderwith a customer (aCustomerobject) and a total. - Create one customer, and two orders that receive the same customer object.
- 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 AvivWhat 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 AvivThe 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:
- A Class named
BankAccount, with an owner and a balance. Keep the balance in_balance— an underscore says “internal, don’t touch from outside”. deposit— adds an amount.withdraw— takes away an amount. If there is not enough money: aValueErrorwith the message from the output.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
150Show 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
150Exercise 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:
- A Class named
User, whose__init__receives an age and saves it through the property. - A Getter with
@propertynamedage— returns the age saved in_age. - A Setter with
@age.setter— checks that the age is between 0 and 120. If not: aValueErrorwith the message from the output. - 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
31Show 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
31self.age = agein__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:
- A Class named
Course, with a course code, a name and a capacity, and two internal lists: enrolled and waiting. enroll— receives a student’s name:- if they are already enrolled or waiting: a
ValueErrorwith 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".
- if they are already enrolled or waiting: a
__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 registeredShow 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 registeredMini 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:ValueErrorwith 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 existsTest 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: 270By 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: 270InventoryholdsProductobjects (an object that holds objects), andby_valuereturns a new list —sorteddoes not touch the internal list._name,_price,_quantityare hidden. From outside, the quantity is changed only throughadd_stockandsell— which check the rules.pricehas a setter that checks — also in__init__.nameandquantityare read-only, andtotal_valueis 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'].