# אובייקטים בעבודה: מצב, הסתרת מידע, Properties ומחלקה שלמה<no value>


## מה בשיעור

השיעור הזה בנוי כסיפור אחד, באותו סדר כמו במצגת. כל פרק נשען על הקודם:

1. **פרק ראשון** — אובייקטים ומצב: הרבה אובייקטים, מצב שמשתנה, ואובייקט שמחזיק אובייקט
2. **פרק שני** — הסתרת מידע: כל קוד יכול לשבור את הנתונים, אז מסתירים אותם מאחורי ממשק
3. **פרק שלישי** — בדיקת תקינות: תחביר פשוט של שדה, ובדיקה בכל השמה עם `@property`
4. **פרק רביעי** — מחלקה שלמה: מחברים את הכול למחלקה אחת עם אחריות, כללים וממשק

בסוף השיעור — תרגול, ו**מיני פרויקט** שמחבר את כל הפרקים.

## פרק ראשון: אובייקטים ומצב — הרבה אובייקטים ומצב שמשתנה

### מה נלמד?

- איך מנהלים **הרבה אובייקטים**: רשימות, חיפוש, סינון ומיון.
- איך ה-**State** של אובייקטים משתנה לאורך התוכנית.
- **אובייקט שמחזיק אובייקט אחר** (למשל: הזמנה שמחזיקה לקוח).
- **הפניות**: שני שמות לאותו אובייקט, מול שני אובייקטים.

### למה צריך את זה?

מערכת אמיתית היא לא אובייקט אחד. היא **מאות אובייקטים** שמדברים זה עם זה: לקוחות, הזמנות, מוצרים. צריך לדעת למצוא את הלקוח עם מזהה 7, את כל ההזמנות שלא שולמו, ולעדכן את המלאי כשהזמנה נשלחת. וגם להבין מה קורה כשמשנים אובייקט שכמה חלקים במערכת "מחזיקים".

### הסבר מהבסיס

#### אובייקטים ברשימה

רשימה יכולה להחזיק אובייקטים בדיוק כמו שהיא מחזיקה מספרים או dicts. כל מה שלמדנו בנושא Python עובד:

| פעולה | עם dicts (נושא Python) | עם אובייקטים |
|---|---|---|
| גישה לשדה | `item["price"]` | `item.price` |
| מיון | `sorted(items, key=lambda i: i["price"])` | `sorted(items, key=lambda i: i.price)` |
| סינון | `if item["price"] > 100` | `if item.price > 100` |
| פעולה | `deposit(account, 50)` | `account.deposit(50)` |

#### State לאורך זמן

**State** = הערכים של ה-Attributes עכשיו. אובייקט "חי" לאורך התוכנית, וה-State שלו משתנה דרך מתודות: הזמנה נוצרת במצב `"new"`, עוברת ל-`"paid"`, ואז ל-`"shipped"`. כל אובייקט עובר את המסלול **בנפרד**.

#### אובייקט שמחזיק אובייקט

Attribute יכול להחזיק **כל ערך**, כולל **אובייקט אחר**:

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

`order.customer` **הוא** אובייקט `Customer`, ולכן אפשר להמשיך: `order.customer.name`.

#### הפניות (References) — תזכורת קריטית

זוכרים מנושא Python: **משתנה הוא מדבקה על אובייקט**. זה נכון גם לאובייקטים שלנו:

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

אם דנה משנה כתובת (`dana.city = "Tel Aviv"`), **שתי ההזמנות** רואות את הכתובת החדשה מיד. הן לא מחזיקות **עותק**, הן מחזיקות **הפניה** לאותה דנה. זה בדרך כלל בדיוק מה שרוצים: יש **דנה אחת** במערכת.

{{< box "analogy" "אנלוגיה: איש קשר בטלפון" >}}
כמה קבוצות וואטסאפ מכילות את "דנה". כשדנה מחליפה תמונת פרופיל, היא מתעדכנת **בכל** הקבוצות, כי הקבוצות לא מחזיקות **צילום** של דנה. הן מחזיקות **קישור** לדנה האמיתית.
{{< /box >}}

### תחביר

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

**List Comprehension** (שורה 3 מלמטה) היא דרך קצרה לבנות רשימה מסוננת. היא שקולה ללולאה:

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

השתמשו בגרסה שקריאה לכם יותר. שתיהן נכונות.

### דוגמת קוד ראשונה

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

**פלט:**

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

### פירוק הקוד שורה אחר שורה

| קוד | הסבר |
|---|---|
| `team = [Player("Dana"), ...]` | רשימה של 3 אובייקטים **נפרדים**, כל אחד עם `score` משלו. |
| `team[0].add_points(30)` | `team[0]` הוא אובייקט Player. קוראים למתודה שלו. רק ה-State **שלו** משתנה. |
| `team[0].add_points(20)` | אותו אובייקט שוב. ה-score שלו מצטבר ל-50. |
| `max(team, key=lambda p: p.score)` | מוצא את האובייקט עם ה-score הגבוה, ומחזיר **את האובייקט כולו**. |
| `sorted(..., reverse=True)` | רשימה **חדשה** ממוינת. `team` עצמה לא השתנתה. אבל האובייקטים **הם אותם אובייקטים**, ורק הסדר ברשימה החדשה שונה. |

### דוגמה נוספת

**לקוחות והזמנות — אובייקטים שמחזיקים אובייקטים, ו-State שמשתנה.** `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)
```

**פלט:**

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

**פירוק:**

- `Order(dana, 120)` — ההזמנה **מחזיקה הפניה** לאובייקט `dana`. היא לא מעתיקה את השם ואת העיר.
- `self.customer.name` — **שרשור**: מההזמנה ללקוח, ומשם לשם הלקוח.
- `dana.city = "Tel Aviv"` — שינוי **אחד**, ו**שתי ההזמנות** של דנה מראות את העיר החדשה. אילו שמרנו `self.city = customer.city` ב-`__init__`, ההזמנות היו "תקועות" עם חיפה.
- `advance()` — **State Machine** פשוטה: new → paid → shipped, ואי אפשר לדלג או לחזור. כל הזמנה מתקדמת **בנפרד**.
- `order.customer is dana` — `is` כאן **נכון**: אנחנו באמת שואלים "האם זה **אותו אובייקט**?" (ולא "האם יש לו אותו שם?", כי יכולות להיות שתי לקוחות בשם דנה).
- `sum(order.total for order in dana_orders)` — סכום בשורה אחת (Generator Expression, כמו ב-`max` של נושא Python).

### סיכום

- אובייקטים ברשימה עובדים כמו כל ערך: לולאה, חיפוש, `sorted`/`max` עם `key=lambda obj: obj.attr`.
- כל אובייקט מחזיק **State משלו**, שמשתנה דרך המתודות שלו.
- Attribute יכול להחזיק **אובייקט אחר** (`order.customer`), וניגשים בשרשור: `order.customer.name`.
- זו **הפניה**, לא עותק. שינוי באובייקט נראה מכל מי שמחזיק אותו.
- `b = a` = שם נוסף, לא אובייקט חדש. `is` בודק "אותו אובייקט".
- שינוי State "מבחוץ" עוקף את הכללים. בפרק הבא: Encapsulation.

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

### דוקומנטציה רשמית

- [Instance Objects](https://docs.python.org/3/tutorial/classes.html#instance-objects) — אובייקט ומצב משלו

<!-- docs:end -->

## פרק שני: הסתרת מידע — Encapsulation

### מה נלמד?

- מהי **Encapsulation** (כימוס) ו**למה** היא קיימת.
- **ממשק ציבורי** (Public Interface) מול **מימוש פנימי** (Implementation).
- המוסכמות של Python: **`_protected`** ו-**`__private`**.
- מה זה **Name Mangling**.

### למה צריך את זה?

עד עכשיו כתבנו מתודות עם בדיקות יפות, למשל `withdraw` שבודקת שיש מספיק כסף. אבל:

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

**שורה אחת עקפה את כל הכללים.** אף שגיאה, אף לוג. ה-`withdraw` המושקע פשוט לא רץ. במערכת אמיתית, עם עשרות מפתחים ומאות קבצים, **מישהו** יכתוב שורה כזו (אולי אתם, בעוד חודש, "רק לבדיקה").

עוד בעיה: נניח שהחלטנו לשמור את היתרה ב**אגורות** (`balance_cents`) כדי למנוע בעיות עיגול. אם 30 מקומות בקוד ניגשים ישירות ל-`account.balance`, כולם נשברים. אילו הם היו משתמשים במתודות, היינו משנים **רק את המתודות**.

Encapsulation פותרת את שתי הבעיות.

### הסבר מהבסיס

#### מהי Encapsulation?

**Encapsulation** = **אריזה** של הנתונים יחד עם הקוד שמטפל בהם, ו**הסתרת** הפרטים הפנימיים, כך שהעולם החיצוני עובד רק דרך **ממשק מוגדר**.

שני רעיונות:

1. **הגנה על כללים (Invariants)** — נתון רגיש משתנה **רק** דרך מתודות שבודקות שהוא נשאר חוקי. "יתרה לא שלילית", "ציון בין 0 ל-100", "סטטוס מתקדם רק קדימה".
2. **הסתרת מימוש** — מי שמשתמש ב-Class צריך לדעת **מה** היא עושה, לא **איך**. כך אפשר לשנות את ה"איך" בלי לשבור את מי שמשתמש בה.

#### Public Interface מול Implementation

| | ממשק ציבורי (Public Interface) | מימוש פנימי (Implementation) |
|---|---|---|
| מה | המתודות וה-Attributes ש**מיועדים** לשימוש מבחוץ | הנתונים והמתודות הפנימיות שמאפשרים לממשק לעבוד |
| דוגמה בחשבון בנק | `deposit()`, `withdraw()`, `get_balance()` | איך היתרה נשמרת, רשימת התנועות, בדיקות עזר |
| יציבות | משתנה לעיתים רחוקות. אחרים תלויים בו | מותר לשנות בחופשיות |

{{< box "analogy" "אנלוגיה: כספומט" >}}
לכספומט יש **ממשק ציבורי**: מסך, מקשים, חריץ לכרטיס, פתח לשטרות. אתם יכולים "למשוך 200", והכספומט **בודק** קוד, יתרה ומגבלה יומית. **הכספת בפנים** (המימוש) נעולה. אם כל אחד היה יכול לפתוח את הכספת ולקחת כסף ישירות, כל הבדיקות היו חסרות משמעות. והבנק יכול להחליף את מנגנון הספירה בפנים בלי שתשימו לב, כי הממשק נשאר אותו דבר.
{{< /box >}}

#### איך Python עושה את זה: מוסכמות

בשפות כמו Java יש "נעילה" אמיתית (`private`). **ב-Python אין נעילה מוחלטת.** יש **מוסכמות** (Conventions) שכל מפתחי Python מכבדים:

| כתיבה | שם | המשמעות | מה Python עושה |
|---|---|---|---|
| `name` | Public | חלק מהממשק. מותר להשתמש מבחוץ | כלום |
| `_name` | Protected (מוסכמה) | **"פנימי, אל תיגע מבחוץ"** | **כלום**. זו רק הודעה למפתחים. VS Code גם לא יציע אותו בהשלמה אוטומטית |
| `__name` | Private (Name Mangling) | **"פנימי מאוד"** | **משנה את השם** מאחורי הקלעים, כדי שגישה מבחוץ "בטעות" תיכשל |

**הפילוסופיה של Python:** "We are all consenting adults here". השפה סומכת עליכם. קו תחתון אומר "זה לא בשבילך". מי שבכל זאת נוגע, לוקח אחריות.

#### Name Mangling

כשכותבים `self.__balance` בתוך Class בשם `BankAccount`, Python **משנה** את השם בפועל ל-`_BankAccount__balance`. לכן `account.__balance` מבחוץ נכשל ב-`AttributeError`. זה לא אבטחה (אפשר עדיין לגשת דרך `account._BankAccount__balance`), אבל זה מונע גישה **בטעות**, ומבהיר מאוד את הכוונה.

**בקורס הזה:** נשתמש בעיקר ב-**`_name`** (קו אחד), שהיא הדרך המקובלת ביותר בקוד Python אמיתי. את `__name` נכיר כדי לדעת מה הוא עושה כשנראה אותו.

### תחביר

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

### דוגמת קוד ראשונה

`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())
```

**פלט:**

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

### פירוק הקוד שורה אחר שורה

| קוד | הסבר |
|---|---|
| `self.owner` | **Public**. מותר לקרוא, ואין כלל מיוחד שצריך להגן עליו. |
| `self._balance` | **Protected (מוסכמה)**. הקו התחתון אומר לכל מפתח: "את היתרה לא משנים ישירות". |
| `deposit` / `withdraw` | **הממשק הציבורי לשינוי**: הדרך **היחידה** המכובדת לשנות יתרה. כל שינוי עובר בדיקה. |
| `get_balance()` | **Getter**: מתודה שמחזירה את הערך לקריאה. מבחוץ אפשר **לקרוא** את היתרה, אבל לא **לשנות** אותה ישירות. |
| `account.withdraw(1000)` נכשל | והיתרה **לא השתנתה**. הכלל נשמר. |

**ומה אם מישהו בכל זאת כותב `account._balance = -999`?** Python לא תעצור אותו. אבל הקו התחתון הופך את זה ל**טעות ברורה וגלויה** בקוד: כל מי שעושה Code Review יראה מיד "נוגעים ב-`_balance` מבחוץ — אסור". זו בדיוק המטרה.

### דוגמה נוספת

**`__private` ו-Name Mangling בפעולה, ומתודת עזר פנימית.** `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)
```

**פלט:**

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

**פירוק:**

- `self.__code` — בתוך ה-Class כותבים `__code`, ו-Python שומרת אותו בפועל כ-`_Vault__code`.
- `vault.__code` מבחוץ ‏←‏ `AttributeError`. לא ניתן לגשת "בטעות".
- `vault._Vault__code` ‏←‏ עובד! **Name Mangling הוא לא אבטחה**. סיסמה אמיתית לעולם לא שומרים בקוד (ובטח שלא בטקסט גלוי). כאן זו רק דוגמה להמחשה.
- `_check_code` — **מתודת עזר פנימית**. היא חלק מהמימוש, ולא מהממשק. מחר אפשר להחליף אותה בבדיקה מוצפנת, ושום קוד חיצוני לא יישבר, כי אף אחד מבחוץ לא אמור לקרוא לה.
- `is_locked` — Public, לקריאה. (מבחוץ אפשר גם לכתוב אליו, וזו "חור" קטן. בפרק הבא נראה איך הופכים Attribute לקריאה בלבד עם `@property`.)

### סיכום

- **Encapsulation** = נתונים + הקוד שמטפל בהם יחד, וחשיפה של **ממשק** בלבד.
- **למה:** (1) הגנה על כללים: שינוי רק דרך מתודות שבודקות. (2) הסתרת מימוש: אפשר לשנות "איך" בלי לשבור את מי שמשתמש.
- `name` = Public. **`_name`** = "פנימי, אל תיגע" (מוסכמה, הנפוצה ביותר). `__name` = Name Mangling (`_Class__name`).
- Python לא נועלת. המוסכמות מבהירות כוונה ומונעות טעויות. זו **לא** אבטחה.
- להחזיר **עותק** של list/dict פנימי, ולא את המקור.
- לא מסתירים הכול. מסתירים כשיש כלל או פרט מימוש.

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

### דוקומנטציה רשמית

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

<!-- docs:end -->

## פרק שלישי: בדיקת תקינות — Getters, Setters ו-`@property`

### מה נלמד?

- מה הם **Getter** ו-**Setter**.
- **Validation** (אימות): בדיקת כל ערך חדש **לפני** שהוא נשמר.
- **`@property`**: הדרך של Python לעשות את זה בצורה אלגנטית.
- **Attribute לקריאה בלבד** ו-**Attribute מחושב**.

### למה צריך את זה?

בפרק הקודם הגנו על `_balance`, כי היו לו מתודות טבעיות (`deposit`, `withdraw`). אבל מה עם נתון ש**כן** אמור להשתנות ישירות, רק **בתוך כללים**?

- **גיל** של משתמש: אפשר לעדכן, אבל לא ל-(-5) ולא ל-300.
- **מחיר** של מוצר: אפשר לשנות, אבל לא לשלילי.
- **אימייל**: אפשר לעדכן, אבל רק לכתובת עם `@`.

אם `age` הוא Attribute רגיל, כל אחד יכול לכתוב `user.age = -5`. אם מסתירים אותו (`_age`), צריך דרך **מסודרת** לקרוא ולשנות אותו. זה Getter ו-Setter.

### הסבר מהבסיס

#### Getter ו-Setter — הרעיון

- **Getter** — מתודה שמחזירה את הערך של נתון פנימי. `get_age()`.
- **Setter** — מתודה שמקבלת ערך חדש, **בודקת אותו**, ורק אם הוא חוקי, שומרת. `set_age(new_age)`.

כך ל-Class יש **שליטה מלאה**: כל קריאה וכל כתיבה עוברות דרכה.

{{< box "analogy" "אנלוגיה: פקיד בבנק" >}}
לא ניגשים לבד לתיקייה שלכם בארכיון הבנק ומשנים כתובת עם עט. פונים לפקיד (Setter): הוא **בודק** שהכתובת תקינה ושאתם מורשים, ורק אז מעדכן. ומי שרוצה לראות את הכתובת, מבקש מהפקיד (Getter). הוא יכול להחליט מה להראות, ואולי להסתיר חלק מהפרטים.
{{< /box >}}

#### הבעיה עם `get_` ו-`set_` ב-Python

זה עובד, אבל זה מסורבל:

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

לעומת:

```python
user.age = user.age + 1
```

ובנוסף, אם בהתחלה `age` היה Attribute רגיל ו-50 מקומות בקוד כותבים `user.age`, ואחר כך מחליטים שצריך ולידציה, צריך לשנות את כל 50 המקומות ל-`get_`/`set_`.

#### `@property` — הפתרון של Python

**`@property`** מאפשר להגדיר מתודה שנראית **מבחוץ כמו Attribute רגיל**:

- כשכותבים `user.age`, נקראת **מתודת ה-Getter**.
- כשכותבים `user.age = 30`, נקראת **מתודת ה-Setter**, עם הוולידציה.

הקוד שמשתמש ב-Class כותב `user.age`, כרגיל, אבל מאחורי הקלעים כל גישה עוברת דרך הבדיקות. **הטוב משני העולמות.**

`@property` הוא **Decorator** (קשטן): שורה שמתחילה ב-`@` מעל `def`, ו"עוטפת" את הפונקציה בהתנהגות נוספת. לא נעמיק ב-Decorators. מספיק לדעת את התבנית של `@property`. (בנושא Backend ומסדי נתונים, FastAPI ישתמש ב-Decorators כמו `@app.get(...)`.)

### תחביר

**גרסה 1 — מתודות Getter/Setter קלאסיות (הרעיון):**

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

**גרסה 2 — `@property` (הדרך המקובלת ב-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
```

- שתי המתודות נקראות **בשם זהה** (`value`), כשם ה-"Attribute" הציבורי.
- הנתון האמיתי נשמר בשם **אחר**, עם קו תחתון (`_value`).
- `@property` מעל ה-Getter. `@value.setter` (שם ה-property + `.setter`) מעל ה-Setter.
- **Read-only:** מגדירים רק `@property`, בלי Setter.

### דוגמת קוד ראשונה

**הרעיון — Getter ו-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())
```

**פלט:**

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

### פירוק הקוד שורה אחר שורה

| קוד | הסבר |
|---|---|
| `self._age = None` ואז `self.set_age(age)` | גם ביצירה, הגיל עובר **דרך ה-Setter**, ולכן אי אפשר ליצור משתמש עם גיל לא חוקי. ולידציה **במקום אחד**. |
| `get_age()` | Getter: מחזיר את הערך הפנימי. |
| `set_age(new_age)` | Setter: קודם בודק **טיפוס** (`isinstance`), אחר כך **טווח**, ורק אז שומר. |
| `type(new_age).__name__` | שם הטיפוס כטקסט נקי (`"str"`), במקום `<class 'str'>`. |
| `dana.set_age(dana.get_age() + 1)` | עובד, אבל מסורבל. מכאן המוטיבציה ל-`@property`. |
| `Age unchanged: 25` | הערכים הלא חוקיים **לא נשמרו**. |

### דוגמה נוספת

**אותו רעיון עם `@property`, ועוד Attribute לקריאה בלבד ו-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)
```

**פלט:**

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

**פירוק:**

- `laptop.price` — **נראה** כמו Attribute, אבל בפועל מריץ את מתודת ה-Getter. אין סוגריים!
- `laptop.price = laptop.price * 0.9` — תחביר טבעי לגמרי, אבל עובר דרך ה-Setter עם הבדיקות.
- `self.price = price` **ב-`__init__`** — גם זה עובר דרך ה-Setter. לכן `Product("A2", "Ghost", 0)` נכשל כבר ביצירה. ולידציה אחת לשני המקרים.
- `sku` — `@property` **בלי Setter** ‏←‏ **קריאה בלבד**. מק"ט לא אמור להשתנות אחרי היצירה. ניסיון לשנות זורק `AttributeError`.
- `price_with_vat` — **Property מחושב**: אין לו נתון משלו, והוא מחושב בכל גישה מ-`_price`. תמיד מעודכן (זוכרים את `description` שהתיישן? הנה פתרון אלגנטי עוד יותר).
- `name` — נשאר Attribute רגיל, כי אין עליו כלל. **לא מוסיפים property בלי סיבה.**

### סיכום

- **Getter** מחזיר נתון פנימי. **Setter** מקבל ערך, **בודק**, ושומר. כך ל-Class יש שליטה על כל קריאה וכתיבה.
- ב-Python משתמשים ב-**`@property`** (Getter) וב-**`@name.setter`** (Setter): מבחוץ זה נראה כמו Attribute רגיל.
- הנתון האמיתי נשמר בשם עם קו תחתון (`_price`). אותו שם יוצר רקורסיה.
- ב-`__init__` כותבים `self.price = price`, כדי שגם היצירה תעבור ולידציה.
- property **בלי Setter** = קריאה בלבד. property **מחושב** = תמיד מעודכן.
- לא מוסיפים property בלי כלל. מתחילים עם Attribute רגיל, ומשדרגים כשצריך.

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

### דוקומנטציה רשמית

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

<!-- docs:end -->

## פרק רביעי: מחלקה שלמה — אחריות, כללים וממשק

### מה נלמד?

עד עכשיו למדנו **חלקים**: `__init__`, מתודות, Class Attributes, Encapsulation ו-properties. היום נחבר את כולם ל**תהליך עבודה** אחד: איך בונים Class שלמה, טובה, מאפס.

### למה צריך את זה?

הקושי האמיתי ב-OOP הוא לא התחביר, אלא ה**החלטות**: מה ה-Class אחראית לו? מה חושפים? מה מסתירים? אילו כללים היא חייבת לשמור? מתחילים שקופצים ישר לקוד מקבלים Class שעושה הכול, חושפת הכול, ושוברת כללים. תהליך קבוע מונע את זה.

### הסבר מהבסיס

#### התהליך — 6 שלבים

| שלב | שאלה | תוצר |
|---|---|---|
| **1. אחריות** | מה ה-Class הזו עושה, **במשפט אחד**? | משפט |
| **2. נתונים** | מה היא צריכה **לזכור**? | רשימת Attributes |
| **3. ממשק ציבורי** | מה העולם החיצוני צריך **לעשות** איתה ו**לדעת** עליה? | רשימת מתודות ו-properties |
| **4. כללים (Invariants)** | מה **חייב להישאר נכון** תמיד? | רשימת כללים |
| **5. מימוש** | איך שומרים את הנתונים בפנים? אילו מתודות עזר? | קוד |
| **6. בדיקה** | האם כל כלל נשמר? מה עם מקרי קצה? | סקריפט בדיקה |

**Invariant** (שמורה) = תנאי שחייב להיות נכון **בכל רגע** בחיי האובייקט. למשל "יתרה ≥ 0", או "מספר הרשומים ≤ מספר המקומות". כל מתודה שמשנה State צריכה להשאיר את כל ה-Invariants נכונים.

**"משפט אחד" הוא מבחן ל-SRP:** אם המשפט צריך "וגם... וגם...", כנראה שאלה שתי Classes.

### תחביר

אין תחביר חדש. נשלב את כל מה שלמדנו:

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

### דוגמת קוד ראשונה

**המשימה:** מערכת הרשמה לקורסים. נבנה Class בשם `Course` לפי 6 השלבים.

**שלב 1 — אחריות:** "`Course` מנהלת את ההרשמה לקורס אחד: מי רשום, כמה מקומות נשארו, ורשימת ההמתנה."

**שלב 2 — נתונים:** קוד קורס, שם, מספר מקומות מקסימלי, רשימת רשומים, רשימת המתנה.

**שלב 3 — ממשק ציבורי:**

| חבר בממשק | סוג | תיאור |
|---|---|---|
| `code`, `title`, `capacity` | properties (קריאה בלבד) | פרטי הקורס |
| `enroll(student)` | Setter | רושם. אם מלא, מכניס לרשימת המתנה. מחזיר `"enrolled"` או `"waitlisted"` |
| `drop(student)` | Setter | מבטל הרשמה. הראשון ברשימת ההמתנה נכנס במקומו |
| `is_enrolled(student)` | Getter | |
| `available_seats` | property מחושב | |
| `is_full` | property מחושב | |
| `roster()` | Getter | עותק של רשימת הרשומים |
| `__str__` | הצגה | |

**שלב 4 — כללים:**

1. מספר הרשומים ≤ `capacity`, **תמיד**.
2. סטודנט לא רשום פעמיים, ולא נמצא גם ברשומים וגם בהמתנה.
3. `capacity` הוא מספר שלם חיובי.
4. כשמתפנה מקום ויש ממתינים, **הראשון** בתור נכנס (FIFO, First In First Out).

**שלב 5 — מימוש.** `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)))
```

### פירוק הקוד שורה אחר שורה

| חלק | איך הוא משרת את התכנון |
|---|---|
| ולידציה ב-`__init__` | כלל 3: אי אפשר ליצור קורס עם `capacity` לא חוקי. |
| `_enrolled`, `_waitlist` (פנימיים) | Encapsulation: אף אחד מבחוץ לא יכול לעשות `append` ולעקוף את כלל 1. |
| `code`, `title`, `capacity` כ-properties בלי Setter | קריאה בלבד: פרטי הקורס לא משתנים אחרי היצירה. |
| `available_seats`, `is_full` מחושבים | אין נתון כפול שעלול לצאת מסנכרון. תמיד נגזרים מ-`_enrolled`. |
| `enroll` | כלל 2 (דרך `_ensure_not_registered`), וכלל 1 (בדיקת `is_full` לפני הכנסה). |
| `drop` | כלל 4: `pop(0)` = הראשון שנכנס לתור יוצא ראשון. |
| `roster()` / `waitlist()` מחזירות **עותק** | מי שקיבל רשימה לא יכול לשנות את הפנימית. |
| `_ensure_not_registered` | מתודת עזר פנימית: כלל אחד, במקום אחד. |
| `__str__` | תמונת מצב קריאה, לדיבאג ולהדפסה. |

### דוגמה נוספת

**שלב 6 — בדיקה.** סקריפט שבודק כל כלל, כולל מקרי קצה. בסוף `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()
```

**פלט:**

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

**מה הבדיקה הוכיחה:**

- כלל 1: אף פעם לא יותר מ-2 רשומים. השלישי והרביעי נכנסו להמתנה.
- כלל 2: דנה לא נרשמה פעמיים.
- כלל 4: כשאבי עזב, **נועה** (הראשונה בתור) נכנסה, וטום נשאר בהמתנה.
- Encapsulation: `append` על העותק לא שינה את הרשימה האמיתית.
- כלל 3: קורס עם 0 מקומות לא נוצר.

### סיכום

- **תהליך:** אחריות (משפט אחד) ‏←‏ נתונים ‏←‏ ממשק ציבורי ‏←‏ כללים (Invariants) ‏←‏ מימוש ‏←‏ בדיקה.
- **Invariant** = מה שחייב להיות נכון תמיד. כל Setter חייב לשמור על כולם.
- נתונים פנימיים מוסתרים (`_x`). פרטים קבועים הם properties לקריאה בלבד. מידע נגזר הוא properties מחושבים.
- החזרת **עותקים** של אוספים פנימיים. כלל אחד ‏←‏ מתודת עזר אחת (`_ensure_...`).
- בודקים כל כלל ומקרי קצה, לא רק את המסלול השמח.

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

### דוקומנטציה רשמית

- [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/ -->
## תרגול

התרגילים מבוססים רק על מה שנלמד בשיעור הזה ובשיעורים שלפניו. נסו לבד, ורק אז פתחו את הפתרון.

**מה מתרגלים בדף הזה:** רשימה של אובייקטים, אובייקט שמחזיק אובייקט אחר, הסתרת נתונים (Encapsulation), property עם בדיקה, ומחלקה שלמה עם כללים.

**בסוף הדף תדעו:**
- לעבור על רשימה של אובייקטים ולמצוא בה לפי תכונה
- לבנות אובייקט שמחזיק הפניה לאובייקט אחר
- להסתיר נתון מאחורי מתודות שבודקות כל שינוי
- לכתוב property עם setter שאוכף כלל
- לבנות מחלקה שלמה שאוכפת את הכללים שלה

### תרגיל 1: רשימה של שחקנים

**מה בונים:** טבלת שחקנים במשחק — ומוצאים את המוביל.

**מה לכתוב:**
1. Class בשם `Player`. ה-`__init__` מקבל שם, והניקוד מתחיל מ-0.
2. `add_points` — מקבלת מספר נקודות ומוסיפה לניקוד.
3. `__str__` — מחזירה שם וניקוד, למשל `Dana: 50`.
4. אחרי קוד הבדיקה: עוברים בלולאה על הרשימה, מוצאים את השחקן עם הניקוד הגבוה ביותר, ומדפיסים אותו.

**קוד לבדיקה:**

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

**הפלט הרצוי:**

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

### תרגיל 2: אובייקט שמחזיק אובייקט

**מה בונים:** לקוח והזמנות — כשכמה הזמנות מחזיקות את **אותו** לקוח.

**מה לכתוב:**
1. Class בשם `Customer` עם שם ועיר.
2. Class בשם `Order` עם לקוח (אובייקט `Customer`) וסכום.
3. יוצרים לקוח אחד, ושתי הזמנות שמקבלות את אותו אובייקט לקוח.
4. מדפיסים את העיר דרך שתי ההזמנות, משנים את העיר של הלקוח לתל אביב, ומדפיסים שוב.

**הפלט הרצוי:**

```text
Haifa Haifa
Tel Aviv Tel Aviv
```

**מה מבינים מזה:** ההזמנה לא מחזיקה עותק של הלקוח, אלא הפניה אליו.

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

ההזמנות לא מחזיקות **עותק** של הלקוח, אלא **הפניה** אליו. שינוי בלקוח נראה מכל הזמנה שמחזיקה אותו.
{{< /solution >}}

### תרגיל 3: BankAccount מוגן

**מה בונים:** חשבון בנק שאי אפשר לשבור — היתרה משתנה רק דרך מתודות שבודקות אותה.

**מה לכתוב:**
1. Class בשם `BankAccount`, עם בעלים ויתרה. את היתרה שומרים ב-`_balance` — קו תחתון אומר "פנימי, לא לגעת מבחוץ".
2. `deposit` — מוסיפה סכום.
3. `withdraw` — מורידה סכום. אם אין מספיק כסף: `ValueError` עם ההודעה שבפלט.
4. `get_balance` — מחזירה את היתרה.

**קוד לבדיקה:**

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

### תרגיל 4: Setter שבודק

**מה בונים:** משתמש שהגיל שלו תמיד חוקי — עם property שבודק כל השמה.

**מה לכתוב:**
1. Class בשם `User`, ש-`__init__` שלה מקבלת גיל ושומרת אותו דרך ה-property.
2. Getter עם `@property` בשם `age` — מחזיר את הגיל שנשמר ב-`_age`.
3. Setter עם `@age.setter` — בודק שהגיל בין 0 ל-120. אם לא: `ValueError` עם ההודעה שבפלט.
4. שימו לב: גם ההשמה ב-`__init__` עוברת דרך הבדיקה.

**קוד לבדיקה:**

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

**הפלט הרצוי:**

```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` ב-`__init__` **עובר דרך ה-setter** — אז גם אובייקט חדש נבדק.
- הערך שמור ב-`_age`, וה-property חושף אותו כאילו הוא Attribute רגיל.
{{< /solution >}}

### תרגיל 5: ההרשמה

**מה בונים:** הרשמה לקורס עם מספר מקומות מוגבל ורשימת המתנה.

**מה לכתוב:**
1. Class בשם `Course`, עם קוד קורס, שם וקיבולת, ושתי רשימות פנימיות: רשומים וממתינים.
2. `enroll` — מקבלת שם של סטודנט:
   - אם הוא כבר רשום או ממתין: `ValueError` עם ההודעה שבפלט;
   - אם יש מקום: מוסיפה לרשומים ומחזירה `"enrolled"`;
   - אחרת: מוסיפה לממתינים ומחזירה `"waitlisted"`.
3. `__str__` — קוד, שם, כמה רשומים מתוך הקיבולת, וכמה ממתינים.

**קוד לבדיקה:**

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

**הפלט הרצוי:**

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

## מיני פרויקט: מלאי של חנות

**איזו מערכת בונים:** מערכת מלאי לחנות קטנה. לכל מוצר יש שם, מחיר וכמות במלאי. אפשר למכור ולהוסיף מלאי, והמערכת שומרת שלא יקרה דבר לא חוקי — מחיר שלילי, מכירה של יותר ממה שיש, או שני מוצרים עם אותו שם. בסוף היא מציגה את המוצרים לפי שווי, ואת שווי המלאי כולו.

**מה תלמדו מהפרויקט:**
- להסתיר נתונים (Encapsulation): לשנות אותם רק דרך מתודות שבודקות
- להשתמש ב-property: עם setter שבודק, לקריאה בלבד, ומחושב
- לבנות Class שמחזיקה רשימה של אובייקטים אחרים
- למיין אובייקטים לפי ערך מחושב

**מה צריך לבנות — שלב אחרי שלב:**

**חלק א — מוצר (`Product`)**

**שלב 1 — יצירה.** ה-`__init__` מקבל שם, מחיר וכמות (ברירת מחדל 0). השם נשמר ב-`_name`, הכמות ב-`_quantity`, והמחיר עובר דרך ה-setter של `price`.

**שלב 2 — המחיר.** `price` הוא property עם setter שבודק שהמחיר גדול מ-0. אחרת: `ValueError`.

**שלב 3 — שדות לקריאה בלבד.** `name` ו-`quantity` הם properties בלי setter — מבחוץ אפשר לקרוא אותם, אבל לא לשנות.

**שלב 4 — שינוי הכמות.** משנים את הכמות רק דרך שתי מתודות:
- `add_stock` — מוסיפה כמות. אם הכמות לא חיובית: `ValueError`.
- `sell` — מורידה כמות. אם אין מספיק במלאי: `ValueError` עם שם המוצר.

**שלב 5 — ערך מחושב והצגה.** `total_value` הוא property מחושב: מחיר כפול כמות. `__str__` מציגה את המוצר כמו בפלט.

**חלק ב — מלאי (`Inventory`)**

**שלב 6 — רשימה פנימית.** `Inventory` מחזיקה רשימה פנימית בשם `_products`.

**שלב 7 — הוספה וחיפוש.** `add` מוסיפה מוצר. אם כבר יש מוצר עם אותו שם: `ValueError`. `find` מקבלת שם ומחזירה את המוצר, או `None`.

**שלב 8 — דוחות.** `by_value` מחזירה עותק של רשימת המוצרים, ממוין לפי `total_value` מהגבוה לנמוך. `total_value` של המלאי הוא property מחושב: סכום השווי של כל המוצרים.

**הודעות השגיאה — בדיוק כך:**

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

**קוד לבדיקה:**

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

**הפלט הרצוי:**

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

**בסוף הפרויקט תדעו:**
- לבנות אובייקט שמגן על הנתונים שלו — מבחוץ אי אפשר לשבור אותו
- לבחור בין שדה רגיל, property עם בדיקה, שדה לקריאה בלבד וערך מחושב
- לבנות "מנהל" שמחזיק אובייקטים, מחפש ביניהם ומסכם אותם

**בונוס:** הוסיפו ל-`Inventory` מתודה `low_stock` שמקבלת גבול, ומחזירה את שמות המוצרים שהכמות שלהם קטנה ממנו.

{{< 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` **מחזיקה** אובייקטים של `Product` (אובייקט שמחזיק אובייקטים), ו-`by_value` מחזירה רשימה **חדשה** — `sorted` לא נוגע ברשימה הפנימית.
- `_name`, `_price`, `_quantity` מוסתרים. מבחוץ משנים כמות רק דרך `add_stock` ו-`sell` — שבודקות את הכללים.
- `price` עם setter שבודק — גם ב-`__init__`. `name` ו-`quantity` לקריאה בלבד, ו-`total_value` מחושב בכל קריאה.

**בונוס:**

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

(מתודה בתוך `Inventory`.) עם הנתונים למעלה, `shop.low_stock(4)` מחזירה `['Bag']`.
{{< /solution >}}

<!-- exercises:end -->
