# Objects at Work: State, Information Hiding, Properties and a Complete Class<no value>

## 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:

| 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**:

```python
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:

```text
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.

{{< box "analogy" "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.
{{< /box >}}

### Syntax

```python
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:

```python
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`:

```python
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:**

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

### Breaking 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`:

```python
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:**

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Instance Objects](https://docs.python.org/3/tutorial/classes.html#instance-objects)

<!-- docs:end -->

## 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:

```python
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 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 |

{{< box "analogy" "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.
{{< /box >}}

#### 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

```python
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`:

```python
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:**

```text
Balance: 120
Rejected: insufficient funds
Balance still: 120
```

### Breaking 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`:

```python
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:**

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Private Variables](https://docs.python.org/3/tutorial/classes.html#private-variables)

<!-- docs:end -->

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

{{< box "analogy" "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.
{{< /box >}}

#### The problem with `get_` and `set_` in Python

It works, but it is clumsy:

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

Compared with:

```python
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):**

```python
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):**

```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`:

```python
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:**

```text
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

| 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`:

```python
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:**

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [property()](https://docs.python.org/3/builtins/functions.html#property)

<!-- docs:end -->

## 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:

```python
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 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:**

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`:

```python
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`:

```python
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:**

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Random Remarks](https://docs.python.org/3/tutorial/classes.html#random-remarks)

<!-- docs:end -->

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## 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:**

```python
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:**

```text
Leader: Dana: 50
```

{{< solution >}}
```python
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)
```

```text
Leader: Dana: 50
```
{{< /solution >}}

### 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:**

```text
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.

{{< solution >}}
```python
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)
```

```text
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.
{{< /solution >}}

### 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:**

```python
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:**

```text
error: not enough money
150
```

{{< solution >}}
```python
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())
```

```text
error: not enough money
150
```
{{< /solution >}}

### 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:**

```python
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:**

```text
31
error: age must be 0-120
31
```

{{< solution >}}
```python
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)
```

```text
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.
{{< /solution >}}

### 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:**

```python
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:**

```text
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
```

{{< solution >}}
```python
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)
```

```text
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
```
{{< /solution >}}

## 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:**

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

**Test code:**

```python
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:**

```text
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.

{{< solution >}}
```python
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)
```

```text
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:**

```python
    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']`.
{{< /solution >}}

<!-- exercises:end -->
