# מחלקות ואובייקטים: מהבעיה ועד Class שלמה<no value>


## מה בשיעור

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

1. **פרק ראשון** — למה צריך OOP: הבעיות של קוד פרוצדורלי
2. **פרק שני** — Class ו-Object: תבנית ומופעים
3. **פרק שלישי** — `__init__` ו-`self`: אובייקט שנולד שלם
4. **פרק רביעי** — Methods: האובייקט לא רק יודע, הוא גם עושה
5. **פרק חמישי** — נתונים משותפים: Class Attributes
6. **פרק שישי** — מה הופך ל-Class: מדרישה מילולית למחלקות
7. **פרק שביעי** — חוזרים לבנק: הכול ביחד

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

## פרק ראשון: למה צריך OOP — קוד פרוצדורלי מול אובייקטים

### מה נלמד?

השבוע כולו מוקדש ל-**OOP** — **Object-Oriented Programming** (תכנות מונחה עצמים). לפני שנכתוב שורת קוד אחת של OOP, נענה על השאלה החשובה ביותר: **למה?**

בפרק הזה:

- נכתוב מערכת קטנה **בלי** OOP, בדרך שאנחנו כבר מכירים.
- נראה **איפה** היא מתחילה להיות בעייתית.
- נבין את **הרעיון** של OOP כפתרון, עדיין בלי תחביר.

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

כל מה שלמדנו עד עכשיו עובד. אפשר לכתוב כל תוכנה עם משתנים, dicts, רשימות ופונקציות. זה נקרא **תכנות פרוצדורלי** (Procedural Programming): **נתונים** מצד אחד, **פונקציות** שפועלות עליהם מצד שני.

אבל כשמערכת גדלה, לעשרות סוגי "דברים" ומאות פונקציות, מתחילות בעיות. OOP הוא הדרך שבה כמעט כל המערכות הגדולות בנויות. וגם הכלים שתשתמשו בהם (FastAPI, ספריות Python) כתובים ב-OOP. כדי לקרוא ולהשתמש בהם, צריך להבין אותו.

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

#### הבעיה: מערכת חשבונות בנק — בלי OOP

נבנה מערכת קטנה לניהול חשבונות בנק, עם מה שאנחנו כבר יודעים:

```python
account1 = {"owner": "Dana", "balance": 1000}
account2 = {"owner": "Avi", "balance": 500}


def deposit(account, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    account["balance"] = account["balance"] + amount


def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("insufficient funds")
    account["balance"] = account["balance"] - amount


deposit(account1, 200)
withdraw(account2, 100)
print(account1, account2)
```

```text
{'owner': 'Dana', 'balance': 1200} {'owner': 'Avi', 'balance': 400}
```

עובד! אז מה הבעיה? בואו נגדיל את המערכת.

#### בעיה 1: כל אחד יכול "לגעת" בנתונים

```python
account1["balance"] = -999999
account2["balnce"] = 100
```

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

**הכללים** (לא למשוך יותר ממה שיש, סכום חיובי) קיימים רק אם כולם זוכרים להשתמש בפונקציות. שום דבר לא **מחייב** אותם.

#### בעיה 2: הנתונים והפעולות מפוזרים

כל פעולה על חשבון היא פונקציה נפרדת שמקבלת את ה-dict: `deposit(account, ...)`, `withdraw(account, ...)`, `transfer(...)`, `monthly_fee(...)`, `print_statement(...)`. במערכת אמיתית, אלה **עשרות פונקציות** בכמה קבצים. מי שמקבל dict של חשבון לא יודע אילו פעולות קיימות עליו ואיפה הן.

#### בעיה 3: אין "מבנה" מוגדר

איך נראה חשבון? מה **חייב** להיות בו? אולי במקום אחד בקוד יצרו `{"owner": ..., "balance": ...}`, ובמקום אחר `{"name": ..., "money": ...}`. אין מקום **אחד** שמגדיר "זה חשבון בנק, אלה השדות שלו, ואלה הפעולות שלו".

#### בעיה 4: סוגים דומים אבל שונים

נוסף "חשבון חיסכון": כמו חשבון רגיל, אבל עם ריבית, ובלי אפשרות למשוך מתחת ל-1,000. עכשיו `withdraw` צריכה לבדוק: `if account["type"] == "savings": ... elif ...`. ועם כל סוג חשבון חדש, **כל** הפונקציות מתמלאות ב-`if`-ים.

#### הרעיון של OOP

**OOP מארגן את הקוד סביב "דברים" (אובייקטים), ולא סביב פעולות.** כל "דבר" **מכיל יחד**:

- את **הנתונים** שלו: **Attributes** (תכונות). לחשבון: בעלים, יתרה.
- את **הפעולות** שאפשר לעשות איתו: **Methods** (מתודות). לחשבון: הפקדה, משיכה.

```text
PROCEDURAL                          OBJECT-ORIENTED
data:      account dict             ┌──────────────────────────┐
functions: deposit(account, x)      │ BankAccount              │
           withdraw(account, x)     │   data:    owner, balance│
           print_statement(account) │   actions: deposit()     │
                                    │            withdraw()    │
"functions act ON data"             │            statement()   │
                                    └──────────────────────────┘
                                    "the object knows its own data
                                     and its own actions"
```

ואיך זה פותר את הבעיות?

| בעיה | הפתרון של OOP | נלמד ב... |
|---|---|---|
| כל אחד נוגע בנתונים | האובייקט **שומר** על הנתונים שלו, ושינוי עובר דרך הפעולות שלו (**Encapsulation**) | נושא OOP – Classes ואובייקטים |
| נתונים ופעולות מפוזרים | הכול **בתוך** ה-Class. `account.` + Tab ב-VS Code מראה את כל מה שאפשר לעשות | היום |
| אין מבנה מוגדר | **Class** אחת מגדירה בדיוק אילו נתונים יש לכל חשבון | היום |
| סוגים דומים | **Inheritance** ו-**Polymorphism**: חשבון חיסכון "יורש" מחשבון רגיל ומשנה רק את מה ששונה | נושא ירושה ופולימורפיזם |

#### תבנית מול דבר ספציפי

הרעיון הכי בסיסי ב-OOP קיים כבר בעולם האמיתי:

| תבנית (Class) | דברים ספציפיים שנוצרו ממנה (Objects) |
|---|---|
| **תוכנית בנייה** של דירה | הדירה של דנה בקומה 3, הדירה של אבי בקומה 5 |
| **טופס** "פרטי סטודנט" | הטופס שדנה מילאה, הטופס שאבי מילא |
| **תבנית** לייצור מכוניות בקו ייצור | המכונית האדומה עם מספר רישוי 12-345-67 |
| **מושג** "חשבון בנק" | החשבון של דנה עם 1,200 ש"ח, החשבון של אבי עם 400 ש"ח |

- לכל הדירות שנבנו מאותה תוכנית יש **אותו מבנה** (חדרים, מטבח), אבל **ערכים שונים** (צבע קירות, מי גר בה).
- שינוי בדירה של דנה **לא משנה** את הדירה של אבי.
- התוכנית עצמה **היא לא דירה**. אי אפשר לגור בתוכנית.

{{< box "analogy" "אנלוגיה מרכזית לכל השבוע" >}}
**Class = תבנית לייצור מכוניות.** היא מגדירה שלכל מכונית יש צבע, דגם ומספר רישוי (**Attributes**), ושכל מכונית יודעת לנסוע, לבלום ולצפור (**Methods**).
**Object = מכונית אחת ספציפית** שיוצרה מתוך התבנית: אדומה, דגם 2024, מספר 12-345-67. אפשר לייצר ממנה אלפי מכוניות, וכל אחת עם צבע ומספר משלה.
{{< /box >}}

### תחביר

עדיין לא כותבים class. בפרק הבא נלמד את התחביר המלא. אבל הנה "טעימה", כדי לראות לאן אנחנו הולכים. אותה מערכת בנק, ב-OOP:

```python
class BankAccount:
    def __init__(self, owner, balance):
        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("insufficient funds")
        self.balance = self.balance - amount


account1 = BankAccount("Dana", 1000)
account1.deposit(200)
print(account1.balance)
```

**אל תנסו להבין כל מילה עכשיו** (`class`, `__init__`, `self`), כי על כל אחת מהן יש פרק שלם בהמשך השיעור. רק שימו לב לדבר אחד: **הנתונים (`owner`, `balance`) והפעולות (`deposit`, `withdraw`) נמצאים באותו מקום**, וקוראים להן **דרך** האובייקט: `account1.deposit(200)`.

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

כבר השתמשתם באובייקטים כל הזמן, בלי לדעת! ב-Python, **כל ערך הוא אובייקט**, עם נתונים ומתודות משלו:

```python
name = "dana cohen"
print(name.title())
print(name.upper())
print(type(name))

numbers = [3, 1, 2]
numbers.append(5)
numbers.sort()
print(numbers)
print(type(numbers))
```

**פלט:**

```text
Dana Cohen
DANA COHEN
<class 'str'>
[1, 2, 3, 5]
<class 'list'>
```

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

| קוד | מה זה ב-OOP |
|---|---|
| `name = "dana cohen"` | נוצר **אובייקט** מסוג `str`. הנתונים שלו: התווים. |
| `name.title()` | קריאה ל**מתודה** `title` של האובייקט. היא "יודעת" לעבוד על הטקסט **שלו**. |
| `type(name)` → `<class 'str'>` | ה-**Class** (התבנית) של האובייקט היא `str`. |
| `numbers.append(5)` | מתודה של אובייקט `list` שמשנה **את הנתונים שלו**. |
| `<class 'list'>` | גם `list` היא Class. כל רשימה שיצרתם היא Object של ה-Class `list`. |

**נקודת מפתח:** `str` ו-`list` הן **Classes** ש-Python כתבה בשבילנו. השבוע נלמד לכתוב **Classes משלנו**: `BankAccount`, `Student`, `Product`, שמתאימים לבעיה שלנו.

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

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

```python
student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}


def add_grade(student, grade):
    student["grades"].append(grade)


def average(student):
    return sum(student["grades"]) / len(student["grades"])


add_grade(student1, 100)
student2["grades"].append(500)
print(average(student1))
print(average(student2))
print(average(student3))
```

```text
91.66666666666667
285.0
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero
```

**הבעיות:**

1. `student3` נוצר עם **שגיאת כתיב** (`"nmae"`). אין שום מקום שמגדיר מה "חייב" להיות בסטודנט.
2. `student2["grades"].append(500)` **עקף** את `add_grade`. גם אילו `add_grade` הייתה בודקת שהציון בין 0 ל-100, הבדיקה לא הייתה רצה. ממוצע 285!
3. `average` קורסת על סטודנט בלי ציונים. ומי שמשתמש ב-dict של סטודנט צריך **לדעת** שהפונקציה הזו קיימת, ולזכור לטפל במקרה הזה.

ב-OOP, `Student` תהיה Class אחת עם **מבנה מוגדר** (שם וציונים, **תמיד**), עם מתודה `add_grade` שהיא **הדרך היחידה** להוסיף ציון (ובודקת אותו), ועם מתודה `average` שיודעת לטפל ברשימה ריקה. **הכללים יושבים ליד הנתונים שהם שומרים עליהם.**

### סיכום

- **תכנות פרוצדורלי**: נתונים (dicts) ופונקציות בנפרד. עובד, אבל כשהמערכת גדלה, הנתונים חשופים, הפעולות מפוזרות, ואין מבנה מחייב.
- **OOP**: מארגנים את הקוד סביב **דברים**. כל אובייקט מחזיק **נתונים** (Attributes) ו**פעולות** (Methods) יחד.
- **Class** = תבנית (תוכנית בנייה, תבנית ייצור). **Object** = דבר ספציפי שנוצר ממנה.
- ב-Python **כל ערך הוא אובייקט**: `str` ו-`list` הן Classes, ו-`"hi".upper()` היא קריאה למתודה.
- OOP הוא **דרך חשיבה**: אילו דברים יש, מה הם יודעים, ומה הם עושים. לא כל דבר צריך להיות Class.

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

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

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html) — הפרק הרשמי על מחלקות

<!-- docs:end -->

## פרק שני: Class ו-Object — מגדירים תבנית ויוצרים ממנה אובייקטים

### מה נלמד?

- מהי **Class** ומהו **Object** (נקרא גם **Instance**).
- מה המשמעות של המילה **`class`**.
- **התחביר** להגדרת Class.
- איך **יוצרים Object** מתוך Class.
- למה כל Object **עצמאי**.

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

בפרק הקודם ראינו שהבעיה של "dict + פונקציות" היא שאין **מבנה מוגדר** ואין מקום אחד שמרכז את הנתונים והפעולות. Class היא הכלי שיוצר את המבנה הזה. כל השאר (`__init__`, `self`, מתודות) בנוי עליה.

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

#### מהי Class?

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

- Class **לא** מכילה נתונים של דבר ספציפי. היא רק **ההגדרה**.
- מ-Class אחת אפשר ליצור **כמה דברים שרוצים**.
- כשמגדירים Class, יוצרים בעצם **טיפוס חדש** ב-Python, בדיוק כמו `str` או `list`.

#### מהו Object (Instance)?

**Object** (אובייקט / עצם) הוא **דבר ספציפי שנוצר מתוך Class**. יש לו נתונים **משלו**.

**Instance** (מופע) היא מילה נרדפת: "Object **של** ה-Class `Student`" = "**Instance** של `Student`". את הפעולה של יצירת Object מ-Class קוראים **Instantiation**.

| | Class | Object / Instance |
|---|---|---|
| מה זה | תבנית, הגדרה | דבר ספציפי |
| כמה יש | **אחת** לכל סוג | **כמה** שרוצים |
| נתונים | מגדירה **אילו** שדות יהיו | מחזיק **ערכים** ספציפיים |
| דוגמה | `Student` | דנה, אבי, נועה |
| אנלוגיה | תבנית ייצור מכוניות | המכונית האדומה עם מספר 12-345-67 |

#### המילה `class`

`class` היא **מילה שמורה** (Keyword) ב-Python, כמו `def`, `if` ו-`for`. היא אומרת ל-Python: "מכאן מתחילה הגדרה של **סוג חדש**". אחריה בא **שם** ה-Class, ואחריו **גוף** מוזח.

#### שמות של Classes

לפי PEP 8, שמות Classes נכתבים ב-**PascalCase**: כל מילה מתחילה באות גדולה, בלי קווים תחתונים. `Student`, `BankAccount`, `ShoppingCart`. זה שונה מפונקציות ומשתנים (`snake_case`), וכך רואים מיד אם משהו הוא Class.

### תחביר

```python
class ClassName:
    # class body - indented
    pass


my_object = ClassName()
```

| חלק | הסבר |
|---|---|
| `class` | מילה שמורה: "מתחילה הגדרה של Class". |
| `ClassName` | שם ה-Class, ב-PascalCase. |
| `:` | חובה, כמו אחרי `def` או `if`. |
| גוף מוזח | מה שהתבנית כוללת (בפרקים הבאים: `__init__` ומתודות). |
| `pass` | "אל תעשה כלום". גוף של Class (או פונקציה) לא יכול להיות ריק, ולכן `pass` הוא ממלא-מקום. |
| `ClassName()` | **יצירת Object**: קוראים ל-Class **עם סוגריים**, כמו לפונקציה. מתקבל Object חדש. |

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

`week-3/first_class.py`:

```python
class Person:
    pass


dana = Person()
avi = Person()

print(dana)
print(avi)
print(type(dana))
print(dana is avi)
print(isinstance(dana, Person))
```

**פלט:**

```text
<__main__.Person object at 0x000001F4A3B2C1D0>
<__main__.Person object at 0x000001F4A3B2C2E0>
<class '__main__.Person'>
False
True
```

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

| שורה | הסבר |
|---|---|
| `class Person:` | מגדירה **סוג חדש** בשם `Person`. עדיין לא נוצר שום אדם. |
| `pass` | התבנית ריקה בינתיים. |
| `dana = Person()` | **Instantiation**: נוצר Object חדש מסוג `Person`, והשם `dana` מצביע עליו. |
| `avi = Person()` | Object **נוסף**, נפרד לגמרי. |
| `print(dana)` | אין ל-Object "תוכן" להציג, ולכן Python מדפיסה תיאור ברירת מחדל: הסוג, וכתובת בזיכרון (`0x...`). הכתובת אצלכם תהיה שונה. |
| שתי כתובות שונות | שני אובייקטים נפרדים ב-RAM. |
| `type(dana)` | `<class '__main__.Person'>`. ה-Class של האובייקט היא `Person`, שהוגדרה בקובץ הראשי (`__main__`). |
| `dana is avi` | `False`: לא אותו אובייקט (נושא Python, `is`!). |
| `isinstance(dana, Person)` | `True`: "האם `dana` היא Instance של `Person`?" |

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

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

```python
class Product:
    pass


laptop = Product()
laptop.name = "Laptop"
laptop.price = 3500

mouse = Product()
mouse.name = "Mouse"
mouse.price = 80

print(laptop.name, laptop.price)
print(mouse.name, mouse.price)

mouse.price = 70
print("After update:", mouse.price, "| laptop unchanged:", laptop.price)
```

**פלט:**

```text
Laptop 3500
Mouse 80
After update: 70 | laptop unchanged: 3500
```

**פירוק:**

- `laptop.name = "Laptop"` — יוצר **Attribute** בשם `name` **על האובייקט `laptop`**. הנקודה פירושה "של": `laptop.name` = "השם **של** ה-laptop".
- לכל אובייקט Attributes **משלו**. שינוי `mouse.price` לא נוגע ב-`laptop`.
- **זה כבר יותר טוב מ-dict:** `laptop.price` קריא יותר מ-`laptop["price"]`.

**אבל יש כאן בעיה גדולה.** מה מבטיח שלכל `Product` יש `name` ו-`price`?

```python
keyboard = Product()
keyboard.nmae = "Keyboard"
print(keyboard.name)
```

```text
AttributeError: 'Product' object has no attribute 'name'
```

- שגיאת כתיב (`nmae`) יצרה Attribute אחר.
- שכחנו להגדיר `price`, ואין שום אזהרה עד שמישהו ינסה להשתמש בו.
- זו **בדיוק הבעיה של ה-dict** מהפרק הקודם! ה-Class הריקה עדיין לא מגדירה **מבנה**.

**הפתרון: שה-Class עצמה תגדיר אילו נתונים חייבים להיות לכל אובייקט, ותמלא אותם ברגע היצירה.** זה התפקיד של **`__init__`**, נושא הפרק הבא.

### סיכום

- **Class** = תבנית שמגדירה **סוג חדש**. **Object / Instance** = דבר ספציפי שנוצר ממנה.
- תחביר: `class Name:` + גוף מוזח (או `pass`). שמות ב-**PascalCase**.
- **יצירת Object**: `obj = Name()`, **עם סוגריים**. בלי סוגריים, זו ה-Class עצמה.
- כל Object עצמאי: `a is b` → `False`. Attributes של אחד לא משפיעים על השני.
- `type(obj)` מחזירה את ה-Class. `isinstance(obj, Name)` בודקת שייכות.
- הוספת Attributes "מבחוץ" עובדת, אבל **לא מגדירה מבנה**. לכן צריך `__init__`.

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

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

- [A First Look at Classes](https://docs.python.org/3/tutorial/classes.html#a-first-look-at-classes) — class ו-Object
- [Class Objects](https://docs.python.org/3/tutorial/classes.html#class-objects) — יצירת אובייקט מהמחלקה

<!-- docs:end -->

## פרק שלישי: `__init__` ו-`self` — אובייקט שנולד שלם

### מה נלמד?

- מהו **`__init__`**, הפונקציה שרצה אוטומטית כשיוצרים אובייקט.
- מהו **Constructor**.
- מהו **`self`**, המילה שמופיעה בכל מקום ב-OOP.
- **Instance Attributes**: הנתונים של כל אובייקט.

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

בפרק הקודם הוספנו Attributes "מבחוץ": `laptop.name = "Laptop"`. ראינו שזה מסוכן: אפשר לשכוח שדה, לטעות בכתיב, ואין מבנה מחייב. כתבנו אפילו פונקציית עזר `make_book` שתבטיח שלכל ספר יהיו `title` ו-`pages`.

**`__init__` הוא בדיוק ה-`make_book` הזה, רק בתוך ה-Class.** הוא מבטיח ש**כל** אובייקט ייווצר **מאותחל**: עם כל השדות שלו, ועם ערכים, כבר מהרגע הראשון.

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

#### `__init__` — מתודת האתחול

**`__init__`** (קוראים: "דאנדר אינִיט", **d**ouble **under**score **init**) היא פונקציה מיוחדת שמגדירים **בתוך** Class. היא:

- **נקראת אוטומטית** ברגע שיוצרים אובייקט חדש: `Student(...)`.
- **מאתחלת** (Initialize) את האובייקט, כלומר שמה בו את הנתונים ההתחלתיים.
- **לא מחזירה** כלום (`return` לא נדרש).

השם עם שני קווים תחתונים מכל צד מסמן **מתודה מיוחדת** של Python (נקראת **Dunder Method** או **Magic Method**). Python מחפשת שם בדיוק כזה, וקוראת לו בעצמה. אתם אף פעם לא כותבים `obj.__init__()` בעצמכם.

#### Constructor

**Constructor** (בנאי) = הקוד שרץ כשבונים אובייקט חדש. ב-Python, `__init__` משמש בתפקיד הזה, ולכן קוראים לו לרוב "ה-Constructor". (בדיוק טכני: הבנייה עצמה, הקצאת האובייקט בזיכרון, נעשית בשלב לפני, ו-`__init__` "רק" ממלא אותו. לצורכי הקורס, `__init__` = Constructor.)

#### `self` — "האובייקט הזה"

**`self`** הוא **הפרמטר הראשון** של כל מתודה ב-Class, כולל `__init__`. הוא מצביע על **האובייקט הספציפי** שעליו המתודה עובדת כרגע.

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

```python
self.name = name
```

נקרא: "**ב**אובייקט הזה (`self`), צור Attribute בשם `name`, ושים בו את הערך שהתקבל בפרמטר `name`".

- `self.name` — ה-**Attribute** של האובייקט. נשאר כל עוד האובייקט קיים.
- `name` (לבד) — **הפרמטר** של `__init__`: משתנה **מקומי** (Local, נושא Python!) שנעלם בסוף `__init__`.

**מאיפה `self` מגיע?** **Python מעבירה אותו אוטומטית!** כשכותבים `Student("Dana", 24)`, אתם מעבירים 2 ארגומנטים, אבל `__init__` מקבל 3: Python מוסיפה את האובייקט החדש כ-`self`, בהתחלה.

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

#### מה קורה בשורה `dana = Student("Dana", 24)`

1. Python **יוצרת אובייקט ריק** חדש מסוג `Student` בזיכרון.
2. Python **קוראת ל-`__init__`** ומעבירה לו: `self` = האובייקט החדש, `name = "Dana"`, `age = 24`.
3. `__init__` מריץ `self.name = name`, כלומר `name` של האובייקט החדש = `"Dana"`. וכך גם `age`.
4. `__init__` מסתיים. הפרמטרים המקומיים נעלמים, וה-Attributes נשארים על האובייקט.
5. Python **מחזירה את האובייקט**, והשם `dana` מצביע עליו.

#### Instance Attributes

**Instance Attributes** = נתונים ששייכים **לאובייקט ספציפי**. לכל אובייקט ערכים משלו. מגדירים אותם ב-`__init__` עם `self.something = ...`.

### תחביר

```python
class ClassName:
    def __init__(self, param1, param2):
        self.attribute1 = param1
        self.attribute2 = param2
        self.attribute3 = "default value"   # attributes do not have to come from parameters


obj = ClassName(arg1, arg2)    # Python calls __init__(obj, arg1, arg2) for you
obj.attribute1                 # read an instance attribute
```

- `__init__` נכתב **בתוך** ה-Class, מוזח.
- `self` **תמיד ראשון** ברשימת הפרמטרים.
- בקריאה **לא מעבירים** `self`.
- אפשר לתת לפרמטרים ערכי ברירת מחדל, כמו בכל פונקציה: `def __init__(self, name, age=18):`.

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

`week-3/student.py`:

```python
class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.grades = []


dana = Student("Dana", 24)
avi = Student("Avi", 31)

print(dana.name, dana.age, dana.grades)
print(avi.name, avi.age, avi.grades)

dana.grades.append(95)
print("Dana's grades:", dana.grades)
print("Avi's grades:", avi.grades)
```

**פלט:**

```text
Dana 24 []
Avi 31 []
Dana's grades: [95]
Avi's grades: []
```

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

| שורה | הסבר |
|---|---|
| `class Student:` | מגדירים את התבנית. |
| `def __init__(self, name, age):` | ה-Constructor. `self` = האובייקט הנוצר. `name`, `age` = מה שמעבירים ביצירה. |
| `self.name = name` | Attribute `name` **על האובייקט**, עם הערך מהפרמטר. |
| `self.age = age` | אותו דבר לגיל. |
| `self.grades = []` | Attribute שלא מגיע מפרמטר: **כל** סטודנט מתחיל עם רשימת ציונים ריקה **משלו**. |
| `dana = Student("Dana", 24)` | Python: יוצרת אובייקט, קוראת `__init__(new_obj, "Dana", 24)`, ומחזירה אותו. |
| `avi = Student("Avi", 31)` | אובייקט שני. `__init__` רץ **שוב**, עם `self` = האובייקט **השני**. |
| `dana.grades.append(95)` | ניגשים לרשימה **של דנה** ומוסיפים. |
| `avi.grades` נשאר `[]` | כל `__init__` יצר רשימה **חדשה**, ולכן אין שיתוף. (השוו לבאג של `def f(cart=[])` בנושא Python!) |

**עכשיו המבנה מובטח:** אי אפשר ליצור `Student` בלי שם וגיל, ולכל סטודנט תמיד יש `grades`.

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

**Product עם ערכי ברירת מחדל, Attribute מחושב, ולידציה ראשונה.** `week-3/product.py`:

```python
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative, got " + str(price))
        self.name = name
        self.price = price
        self.quantity = quantity
        self.in_stock = quantity > 0


laptop = Product("Laptop", 3500, 4)
cable = Product("USB Cable", 25)

print(laptop.name, laptop.price, laptop.quantity, laptop.in_stock)
print(cable.name, cable.price, cable.quantity, cable.in_stock)

try:
    broken = Product("Ghost", -10)
except ValueError as error:
    print("Could not create product:", error)
```

**פלט:**

```text
Laptop 3500 4 True
USB Cable 25 0 False
Could not create product: price cannot be negative, got -10
```

**פירוק:**

- `quantity=0` — ערך ברירת מחדל. `Product("USB Cable", 25)` מקבל כמות 0.
- `self.in_stock = quantity > 0` — Attribute **מחושב** מתוך הנתונים: `True` או `False`.
- **ולידציה ב-Constructor:** `__init__` **מסרב** ליצור מוצר עם מחיר שלילי, וזורק `ValueError` (נושא Python!). **אי אפשר שיתקיים** מוצר לא חוקי במערכת. זה בדיוק "שמירת הכללים ליד הנתונים" שדיברנו עליה בפרק הראשון.
- כש-`__init__` זורק שגיאה, האובייקט **לא נוצר**, ו-`broken` לא מקבל ערך.

#### Person — דוגמה שלישית, לחיזוק

```python
class Person:
    def __init__(self, first_name, last_name, city="Unknown"):
        self.first_name = first_name
        self.last_name = last_name
        self.city = city
        self.full_name = first_name + " " + last_name


noa = Person("Noa", "Levi", "Haifa")
tom = Person("Tom", "Cohen")
print(noa.full_name, "-", noa.city)
print(tom.full_name, "-", tom.city)
```

```text
Noa Levi - Haifa
Tom Cohen - Unknown
```

### סיכום

- **`__init__`** = ה-Constructor: נקרא **אוטומטית** ביצירת אובייקט, ומאתחל את הנתונים שלו.
- **`self`** = האובייקט הספציפי שהמתודה עובדת עליו כרגע. **פרמטר ראשון תמיד**, ו-Python מעבירה אותו לבד.
- **`self.x = x`**: `self.x` = Attribute שנשמר על האובייקט. `x` = פרמטר מקומי שנעלם.
- `obj = Class(a, b)` → Python יוצרת אובייקט, קוראת `__init__(obj, a, b)`, ומחזירה את האובייקט.
- ב-`__init__` אפשר: ערכי ברירת מחדל, Attributes מחושבים, ו**ולידציה עם `raise`**, כך שאובייקט לא חוקי לא נוצר בכלל.

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

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

- [Class Objects — __init__](https://docs.python.org/3/tutorial/classes.html#class-objects) — __init__ ו-self
- [object.__init__](https://docs.python.org/3/reference/datamodel.html#object.__init__) — ההגדרה של __init__

<!-- docs:end -->

## פרק רביעי: Methods — הפעולות של האובייקט

### מה נלמד?

- מהי **Method** (מתודה).
- איך **מגדירים** אותה ואיך **קוראים** לה.
- שלושה סוגי מתודות לפי מה שהן עושות: **מחשבות**, **משנות מצב**, **מדפיסות**.
- מתודה שקוראת **למתודה אחרת**.
- **`__str__`**: איך אובייקט "מציג את עצמו".

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

עד עכשיו לאובייקטים שלנו היו רק **נתונים**. אבל הרעיון של OOP הוא **נתונים + פעולות** ביחד. בפרק הראשון ראינו שהכללים (כמו "אי אפשר למשוך יותר מהיתרה") צריכים לשבת **ליד הנתונים**. מתודות הן המקום שבו הכללים האלה גרים.

וגם בעיה פתוחה מהפרק הקודם: `description` של המכונית חושב פעם אחת ב-`__init__`, והתיישן כשהצבע השתנה. מתודה מחשבת **בכל פעם מחדש**.

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

#### מהי Method?

**Method** = **פונקציה שמוגדרת בתוך Class**, ופועלת על **אובייקט ספציפי** מאותה Class.

| | Function (פונקציה רגילה) | Method (מתודה) |
|---|---|---|
| איפה מוגדרת | ברמה הראשית של הקובץ | **בתוך** Class |
| איך קוראים לה | `function_name(args)` | `obj.method_name(args)` |
| על מה היא עובדת | על מה שמעבירים לה | על **האובייקט שלה** (`self`), ועל מה שמעבירים לה |
| פרמטר ראשון | כל מה שרוצים | **`self`** |

כבר השתמשתם במתודות: `"hi".upper()`, `numbers.append(5)`. היום כותבים **משלנו**.

#### `self` במתודות

בדיוק כמו ב-`__init__`: כשקוראים `dana.add_grade(90)`, Python מעבירה אוטומטית את `dana` כ-`self`. בתוך המתודה, `self.grades` = **הציונים של דנה**. אם קוראים `avi.add_grade(80)`, אותה מתודה בדיוק רצה, אבל `self` הוא אבי.

```text
dana.add_grade(90)   →   Student.add_grade(dana, 90)
                                            ↑     ↑
                                          self  grade
```

השורה השמאלית היא מה שאנחנו כותבים. השורה הימנית היא מה ש-Python עושה בפועל.

#### שלושה סוגי מתודות (לפי מה שהן עושות)

| סוג | מה עושה | דוגמה |
|---|---|---|
| **Getter** | **מחשבת ומחזירה** ערך מהנתונים, בלי לשנות כלום | `average()`, `is_passing()` |
| **Setter** | **משנה את המצב** (State) של האובייקט | `add_grade(90)`, `deposit(100)` |
| **הצגה** | מדפיסה / מחזירה טקסט לתצוגה | `print_report()`, `__str__()` |

הבחנה שימושית מ-Clean Code: מתודה טובה היא בדרך כלל **או** Getter **או** Setter, לא שתיהן יחד (SRP!).

#### מצב (State)

**State** = הערכים של כל ה-Attributes של אובייקט **ברגע מסוים**. חשבון עם יתרה 1,000 נמצא במצב מסוים. אחרי `deposit(200)`, הוא במצב **חדש**. מתודות Setter הן הדרך **המסודרת** לשנות מצב.

#### `__str__` — איך האובייקט נראה ב-print

זוכרים ש-`print(dana)` הדפיס `<__main__.Student object at 0x...>`? אם מגדירים ב-Class מתודה מיוחדת בשם **`__str__`** (שוב Dunder!) שמחזירה מחרוזת, **`print` ו-`str()` ישתמשו בה** כדי להציג את האובייקט.

### תחביר

```python
class ClassName:
    def __init__(self, ...):
        self.data = ...

    def getter_method(self):             # returns something, changes nothing
        return ... self.data ...

    def setter_method(self, value):      # changes the object's state
        self.data = ...

    def uses_other_method(self):
        return self.getter_method() * 2  # call another method on the same object

    def __str__(self):                   # used by print() and str()
        return "readable text about " + str(self.data)


obj = ClassName(...)
obj.setter_method(5)           # no self in the call
result = obj.getter_method()
print(obj)                     # calls __str__ automatically
```

- כל מתודה **מוזחת בתוך** ה-Class, ו-`self` **ראשון**.
- בין מתודות שמים שורה ריקה אחת (PEP 8).
- בתוך מתודה, ניגשים לנתונים **ולמתודות אחרות** עם `self.`.

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

`week-3/student_methods.py`:

```python
PASSING_GRADE = 60


class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        if grade < 0 or grade > 100:
            raise ValueError("grade must be between 0 and 100, got " + str(grade))
        self.grades.append(grade)

    def average(self):
        if not self.grades:
            return 0
        return sum(self.grades) / len(self.grades)

    def is_passing(self):
        return self.average() >= PASSING_GRADE

    def __str__(self):
        return self.name + " (average " + str(round(self.average(), 1)) + ")"


dana = Student("Dana")
dana.add_grade(90)
dana.add_grade(75)

avi = Student("Avi")
avi.add_grade(50)

print(dana)
print(avi)
print("Dana passing?", dana.is_passing())
print("Avi passing?", avi.is_passing())
```

**פלט:**

```text
Dana (average 82.5)
Avi (average 50.0)
Dana passing? True
Avi passing? False
```

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

| קוד | סוג | הסבר |
|---|---|---|
| `def add_grade(self, grade):` | **Setter** | משנה את המצב: מוסיפה ל-`self.grades`. וקודם **בודקת** את הכלל (0–100). |
| `def average(self):` | **Getter** | מחשבת מהנתונים **של האובייקט** ומחזירה. לא משנה כלום. מטפלת ברשימה ריקה (הבאג מהפרק הראשון!). |
| `def is_passing(self):` | **Getter** | קוראת **למתודה אחרת** של אותו אובייקט: `self.average()`. |
| `def __str__(self):` | **הצגה** | מחזירה מחרוזת. `print(dana)` קורא לה אוטומטית. |
| `dana.add_grade(90)` | | Python: `Student.add_grade(dana, 90)`. `self` = dana. |
| `print(avi)` | | Python: `print(avi.__str__())`, כלומר `"Avi (average 50.0)"`. |

**הנקודה החשובה:** הקוד **שמשתמש** ב-Class (`dana.add_grade(...)`) לא צריך לדעת **איך** הציונים נשמרים (רשימה), ולא צריך לבדוק טווח בעצמו. ה-Class אחראית לזה.

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

**חשבון בנק — הבעיה מהפרק הראשון, פתורה.** `week-3/bank_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
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive, got " + str(amount))
        self.balance = self.balance + amount
        self.transactions.append("deposit " + str(amount))

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive, got " + str(amount))
        if amount > self.balance:
            raise ValueError("insufficient funds: balance " + str(self.balance) + ", requested " + str(amount))
        self.balance = self.balance - amount
        self.transactions.append("withdraw " + str(amount))

    def transfer_to(self, other_account, amount):
        self.withdraw(amount)
        other_account.deposit(amount)

    def statement(self):
        print("--- Statement for", self.owner, "---")
        for transaction in self.transactions:
            print(" ", transaction)
        print("  Balance:", self.balance)

    def __str__(self):
        return "Account of " + self.owner + ": " + str(self.balance) + " NIS"


dana = BankAccount("Dana", 1000)
avi = BankAccount("Avi")

dana.deposit(200)
dana.transfer_to(avi, 300)

try:
    avi.withdraw(5000)
except ValueError as error:
    print("Rejected:", error)

print(dana)
print(avi)
dana.statement()
```

**פלט:**

```text
Rejected: insufficient funds: balance 300, requested 5000
Account of Dana: 900 NIS
Account of Avi: 300 NIS
--- Statement for Dana ---
  deposit 200
  withdraw 300
  Balance: 900
```

**פירוק:**

- `transfer_to(self, other_account, amount)` — מתודה שמקבלת **אובייקט אחר** כפרמטר. היא **לא** משנה את היתרות ישירות. היא משתמשת ב-`self.withdraw` וב-`other_account.deposit`, וכך כל הבדיקות רצות אוטומטית. **DRY!**
- אם `withdraw` נכשלת (אין מספיק כסף), היא זורקת שגיאה **לפני** ה-`deposit`, ולכן אין "כסף שנוצר מאוויר".
- `self.transactions` — היסטוריה פנימית שכל פקודה מעדכנת. מי שמשתמש בחשבון לא צריך לזכור לרשום תנועה.
- `statement()` היא מתודת **הצגה**. `__str__` נותן תיאור קצר ל-`print`.
- **השוו לגרסה הפרוצדורלית מהפרק הראשון:** אותן פעולות, אבל עכשיו יש מקום **אחד** שבו מוגדר מה זה חשבון ומה מותר לעשות איתו.

### סיכום

- **Method** = פונקציה בתוך Class. מגדירים עם `self` ראשון, וקוראים `obj.method(args)` **בלי** `self`.
- `obj.method(x)` שקול ל-`Class.method(obj, x)`. כך `self` "יודע" על איזה אובייקט לעבוד.
- בתוך מתודה, `self.attribute` ו-`self.other_method()`.
- **Getter** מחזירה ולא משנה. **Setter** משנה את ה-State (ובודקת את הכללים).
- **`__str__`** מחזירה מחרוזת, ו-`print(obj)` משתמש בה.
- מידע נגזר עדיף לחשב במתודה, ולא לשמור כ-Attribute שעלול להתיישן.

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

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

- [Method Objects](https://docs.python.org/3/tutorial/classes.html#method-objects) — איך Method מקבלת את self

<!-- docs:end -->

## פרק חמישי: נתונים משותפים — Instance Attribute מול Class Attribute

### מה נלמד?

- **Attribute** מול **Method**: איך מחליטים מה כל דבר.
- **Instance Attribute**: נתון **של כל אובייקט** בנפרד (מה שעשינו עד עכשיו).
- **Class Attribute**: נתון **של ה-Class**, משותף לכל האובייקטים.
- מתי משתמשים בכל אחד, ואילו מלכודות יש.

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

נניח שיש Class `Product`, וכל המוצרים כפופים לאותו מע"מ: 17%. איפה שומרים את המע"מ?

- כ-Instance Attribute (`self.vat = 0.17`)? אז יש **אלף עותקים** של אותו מספר, ואם המע"מ משתנה, צריך לעדכן אלף אובייקטים.
- כקבוע גלובלי בראש הקובץ? אפשרי, אבל הוא "רחוק" מה-Class שהוא שייך לה.

ומה אם רוצים לדעת **כמה מוצרים נוצרו** בסך הכול? אף מוצר בודד לא "יודע" את זה. זה מידע על **הסוג כולו**. Class Attributes פותרים את שני המקרים.

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

#### Attribute או Method?

| | Attribute | Method |
|---|---|---|
| מה זה | **נתון**, מה האובייקט **יודע / מחזיק** | **פעולה**, מה האובייקט **עושה / מחשב** |
| חלק דיבר | שם עצם / תואר | **פועל** |
| דוגמאות | `name`, `price`, `balance`, `is_active` | `deposit()`, `add_grade()`, `total()` |
| גישה | `obj.price` | `obj.total()` **עם סוגריים** |

**שאלות שעוזרות להחליט:**

1. **האם זה נתון שנשמר, או משהו שמחושב?** נתון שנשמר ‏←‏ Attribute. מחושב מנתונים אחרים ‏←‏ בדרך כלל Method (ראינו: `description` שהתיישן).
2. **האם זה משנה את האובייקט?** כל שינוי מסודר ‏←‏ Method (פקודה).
3. **האם צריך כללים כשמשנים אותו?** אם כן, השינוי עובר דרך Method (ונעמיק בזה בשיעור על Encapsulation).

#### Instance Attribute

מוגדר עם **`self.x = ...`**, בדרך כלל ב-`__init__`. **לכל אובייקט עותק משלו**, עם ערך משלו.

#### Class Attribute

מוגדר **ישירות בגוף ה-Class**, מחוץ לכל מתודה, **בלי `self`**:

```python
class Product:
    VAT_RATE = 0.17       # class attribute - one shared value
    count = 0             # class attribute

    def __init__(self, name):
        self.name = name  # instance attribute - per object
```

- יש **עותק אחד** בלבד, ששייך ל-Class עצמה.
- **כל האובייקטים** רואים אותו.
- ניגשים אליו דרך ה-Class (`Product.VAT_RATE`, הדרך המומלצת), או דרך אובייקט (`laptop.VAT_RATE`, עובד לקריאה).

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

#### איך Python מוצאת Attribute

כשכותבים `obj.x`, Python מחפשת:

1. **קודם על האובייקט** (Instance Attributes).
2. **אם לא נמצא, על ה-Class** (Class Attributes ומתודות).
3. לא נמצא ‏←‏ `AttributeError`.

(שימו לב: זה דומה ל-LEGB מנושא Python, חיפוש בשכבות.) מכאן גם המלכודת שנראה בטעות הנפוצה.

### תחביר

```python
class ClassName:
    SHARED_CONSTANT = 100        # class attribute (constant style: UPPER_CASE)
    counter = 0                  # class attribute (changes, shared)

    def __init__(self, value):
        self.value = value                     # instance attribute
        ClassName.counter = ClassName.counter + 1   # update the class attribute through the CLASS

    def uses_constant(self):
        return self.value * ClassName.SHARED_CONSTANT


ClassName.SHARED_CONSTANT        # read through the class
ClassName.counter
```

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

`week-3/products_shared.py`:

```python
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)


laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)

print(laptop.name, laptop.price_with_vat())
print(mouse.name, mouse.price_with_vat())
print("Products created:", Product.created_count)

Product.VAT_RATE = 0.18
print("After VAT change:", laptop.price_with_vat(), mouse.price_with_vat())
```

**פלט:**

```text
Laptop 1170.0
Mouse 58.5
Products created: 2
After VAT change: 1180.0 59.0
```

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

| קוד | סוג | הסבר |
|---|---|---|
| `VAT_RATE = 0.17` | **Class Attribute** | ערך אחד לכל המוצרים. אותיות גדולות = "קבוע" (מוסכמה). |
| `created_count = 0` | **Class Attribute** | מונה משותף. מתחיל ב-0 כשה-Class מוגדרת. |
| `self.name`, `self.price` | **Instance Attributes** | שונים בכל מוצר. |
| `Product.created_count = Product.created_count + 1` | | כל `__init__` מעדכן את המונה **של ה-Class**, ולכן הוא סופר את כל האובייקטים. |
| `Product.VAT_RATE` במתודה | | קריאה ל-Class Attribute. |
| `Product.VAT_RATE = 0.18` | | שינוי **אחד**, ו**כל** המוצרים מושפעים מיד, כי כולם קוראים מאותו מקום. |

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

**כיתה עם מגבלת מקומות, ומזהה ייחודי אוטומטי.** `week-3/students_ids.py`:

```python
class Student:
    SCHOOL_NAME = "Kodkod 6"
    MAX_STUDENTS = 3
    next_id = 1
    all_students = []

    def __init__(self, name):
        if len(Student.all_students) >= Student.MAX_STUDENTS:
            raise ValueError("class is full - cannot add " + name)
        self.name = name
        self.student_id = Student.next_id
        Student.next_id = Student.next_id + 1
        Student.all_students.append(self)

    def __str__(self):
        return "#" + str(self.student_id) + " " + self.name + " (" + Student.SCHOOL_NAME + ")"


for name in ["Dana", "Avi", "Noa", "Tom"]:
    try:
        Student(name)
    except ValueError as error:
        print("Error:", error)

for student in Student.all_students:
    print(student)
print("Next id will be:", Student.next_id)
```

**פלט:**

```text
Error: class is full - cannot add Tom
#1 Dana (Kodkod 6)
#2 Avi (Kodkod 6)
#3 Noa (Kodkod 6)
Next id will be: 4
```

**פירוק:**

- `next_id` — מונה ש**מייצר מזהה ייחודי** לכל סטודנט. כל אובייקט שומר את המספר **שלו** ב-Instance Attribute `student_id`, והמונה המשותף מתקדם.
- `all_students` — **רשימה משותפת** שבה ה-Class "רושמת" כל אובייקט שנוצר. `append(self)` = "תוסיף את האובייקט **הזה** לרשימה".
- `MAX_STUDENTS` — כלל שחל על **כל** ה-Class. הבדיקה רצה **לפני** שה-ID מוקצה, ולכן Tom לא "שרף" מספר.
- `Student(name)` בלולאה בלי לשמור במשתנה: האובייקט לא הולך לאיבוד, כי הוא נרשם ב-`all_students`.

{{< box "tip" "מתי כן ומתי לא" >}}
Class Attributes מצוינים ל**קבועים** (`VAT_RATE`, `MAX_STUDENTS`) ולמונים. רשימה משותפת של "כל האובייקטים" היא כלי להמחשה טוב, אבל בפרויקטים אמיתיים עדיף בדרך כלל לנהל את האוסף ב-Class נפרדת (למשל `School` שמחזיקה רשימת סטודנטים). נלמד את זה בנושא תכנון OOP (Composition).
{{< /box >}}

### סיכום

- **Attribute** = מה האובייקט יודע (שם עצם). **Method** = מה הוא עושה (פועל, עם סוגריים). מידע מחושב ‏←‏ Method.
- **Instance Attribute** (`self.x` ב-`__init__`) = לכל אובייקט ערך משלו.
- **Class Attribute** (בגוף ה-Class, בלי `self`) = עותק **אחד** משותף. מתאים לקבועים ולמונים.
- Python מחפשת `obj.x` קודם באובייקט, ואז ב-Class.
- שינוי Class Attribute **רק דרך שם ה-Class**. `obj.X = ...` יוצר Instance Attribute חדש.
- רשימה/dict "לכל אובייקט" ‏←‏ ב-`__init__`, **לא** בגוף ה-Class.

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

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

- [Class and Instance Variables](https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables) — Instance Attribute מול Class Attribute

<!-- docs:end -->

## פרק שישי: מה הופך ל-Class — מדרישה מילולית למחלקות

### מה נלמד?

עד עכשיו **אמרנו לכם** אילו Classes לכתוב. היום עוברים לצד השני: **אתם מחליטים**.

- איך מוצאים Classes בתוך **תיאור מילולי** של מערכת.
- **ארבע שאלות** שמחליטות: Class, או לא?
- מתי משהו הוא **Attribute**, ומתי הוא **Class משלו**.
- לאיזו Class **שייכת** כל פעולה.

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

הסילבוס מדגיש: המטרה היא **להבין** OOP, ולא רק לדעת להעתיק Class. בעבודה, אף אחד לא יגיד לכם "כתוב Class בשם `Order` עם השדות האלה". תקבלו **דרישה**: "צריך מערכת שבה לקוחות מזמינים מוצרים...", ותצטרכו להפוך אותה למבנה של Classes בעצמכם.

תכנון טוב בהתחלה חוסך **שבועות** של תיקונים. תכנון גרוע מוביל ל-Class אחת ענקית, או לעשרות Classes ריקות שלא עושות כלום.

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

#### כלי 1: ניתוח שמות עצם ופעלים

טכניקה פשוטה ויעילה: קוראים את הדרישה, ומסמנים:

| בטקסט | מועמד ל- |
|---|---|
| **שמות עצם** חשובים ("לקוח", "הזמנה", "מוצר") | **Classes** או **Attributes** |
| **תארים** ומאפיינים ("מחיר", "תאריך", "פעיל") | **Attributes** |
| **פעלים** ("מזמין", "משלם", "מבטל") | **Methods** |
| **כללים** ("לא ניתן ל...", "לכל היותר...") | **Validation / Invariants** |

**זו נקודת התחלה, לא תשובה.** לא כל שם עצם הופך ל-Class.

#### כלי 2: ארבע שאלות — האם זו Class?

| # | שאלה | אם התשובה "כן" |
|---|---|---|
| 1 | **יש לזה זהות ונתונים משלו?** (לכל אחד ערכים שונים) | נטייה ל-Class |
| 2 | **יש לזה התנהגות או כללים?** (פעולות, ולידציה) | נטייה חזקה ל-Class |
| 3 | **יש מזה כמה מופעים?** (הרבה לקוחות, הרבה הזמנות) | נטייה ל-Class |
| 4 | **זה רק ערך בודד, בלי שום התנהגות?** ("צבע", "שם") | **Attribute**, לא Class |

{{< box "analogy" "אנלוגיה" >}}
"**מכונית**" — יש לה מספר רישוי משלה (זהות), היא נוסעת ובולמת (התנהגות), ויש הרבה מכוניות. ✓ Class. "**צבע המכונית**" — זה רק ערך ("אדום"), בלי פעולות משלו. Attribute של המכונית. "**מנוע**" — תלוי! במשחק פשוט, Attribute (`engine_size = 1.6`). במוסך שמתקן מנועים, Class (יש לו מספר סידורי, היסטוריית טיפולים ומצב). **אותו מושג, החלטה אחרת לפי הצורך של המערכת.**
{{< /box >}}

#### כלי 3: Attribute פשוט או Class משלו?

שאלה מכרעת: **האם צריך לשמור על הדבר הזה יותר ממידע אחד, או להפעיל עליו כללים?**

| מושג | Attribute פשוט כש... | Class משלו כש... |
|---|---|---|
| כתובת | מספיק טקסט אחד: `"Herzl 10, Haifa"` | צריך עיר, רחוב ומיקוד בנפרד, ולסנן לפי עיר, ולאמת מיקוד |
| מחיר | מספר אחד | צריך מטבע, המרות ועיגול לפי מטבע |
| ציון | מספר | צריך גם תאריך, שם המבחן, משקל וערעור |

**כלל אצבע:** מתחילים **פשוט** (Attribute). כשהערך "גדל" ומקבל עוד שדות וכללים, הופכים אותו ל-Class.

#### כלי 4: לאיזו Class שייכת המתודה?

**כלל הזהב: המתודה הולכת ל-Class שמחזיקה את הנתונים שהיא צריכה.** (נקרא "Information Expert".)

- **"חשב את סכום ההזמנה"** ‏←‏ צריך את פריטי ההזמנה ‏←‏ **`Order.total()`**.
- **"האם המוצר במלאי?"** ‏←‏ צריך את הכמות של המוצר ‏←‏ **`Product.is_in_stock()`**.
- **"כמה הלקוח הוציא השנה?"** ‏←‏ צריך את ההזמנות של הלקוח ‏←‏ **`Customer`** (אם הוא מחזיק את ההזמנות שלו), או Class שמנהלת את כל ההזמנות.

**סימן אזהרה:** מתודה ב-Class A שרובה `other.x`, `other.y`, `other.z` ‏←‏ כנראה היא שייכת ל-Class של `other`.

### תחביר

אין תחביר חדש. התוצר של השלב הזה הוא **תכנון**, שאפשר לכתוב כ"שלד" קוד, עם `pass` וב-docstrings:

```python
class Order:
    """A customer's order: items, status and total."""

    def __init__(self, customer):
        self.customer = customer      # has-a: the order holds its customer
        self.items = []
        self.status = "new"

    def add_item(self, product, quantity):
        pass

    def total(self):
        pass
```

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

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

**דרישה:** "מערכת לבית קפה. **לקוחות** מזמינים **משקאות** מה**תפריט**. לכל משקה יש **שם**, **גודל** (קטן/גדול) ו**מחיר**. משקה גדול עולה 3 ש"ח יותר. **הזמנה** יכולה לכלול כמה משקאות, ויש לה **סכום כולל**. לקוח יכול **לבטל** הזמנה, כל עוד היא לא **הוכנה**."

**שלב א' — סימון:**

- **שמות עצם:** לקוח, משקה, תפריט, הזמנה, שם, גודל, מחיר, סכום.
- **פעלים:** מזמינים, כולל, לבטל, הוכנה.
- **כללים:** גדול = +3. ביטול רק לפני הכנה.

**שלב ב' — ארבע השאלות:**

| מועמד | זהות/נתונים? | התנהגות? | כמה מופעים? | החלטה |
|---|---|---|---|---|
| משקה | ✓ שם, גודל, מחיר | ✓ חישוב מחיר לפי גודל | ✓ | **Class `Drink`** |
| הזמנה | ✓ פריטים, סטטוס | ✓ סכום, ביטול, הכנה | ✓ | **Class `Order`** |
| לקוח | ✓ שם | מעט | ✓ | כאן: **Attribute** (שם) של Order. אין עליו התנהגות בדרישה. |
| תפריט | רשימת משקאות | אולי חיפוש | **אחד** | כאן: **dict** פשוט. אם יתווספו כללים, יהפוך ל-Class. |
| גודל | ערך ("small"/"large") | — | — | **Attribute** של Drink |
| מחיר | מספר | — | — | **Attribute** + מתודה מחושבת |
| סכום | נגזר | — | — | **Method** `Order.total()` |

**שלב ג' — מתודות ("מי מחזיק את הנתונים?"):**

- `price()` → Drink, כי Drink יודע את המחיר הבסיסי ואת הגודל.
- `total()` → Order, כי Order מחזיקה את המשקאות.
- `cancel()`, `mark_prepared()` → Order, כי Order מחזיקה את הסטטוס.

**שלב ד' — קוד.** `week-3/coffee_shop.py`:

```python
LARGE_EXTRA = 3


class Drink:
    def __init__(self, name, base_price, size="small"):
        if size not in ("small", "large"):
            raise ValueError("size must be small or large, got " + size)
        self.name = name
        self.base_price = base_price
        self.size = size

    def price(self):
        if self.size == "large":
            return self.base_price + LARGE_EXTRA
        return self.base_price


class Order:
    def __init__(self, customer_name):
        self.customer_name = customer_name
        self.drinks = []
        self.status = "open"

    def add(self, drink):
        if self.status != "open":
            raise ValueError("cannot add drinks to a " + self.status + " order")
        self.drinks.append(drink)

    def total(self):
        return sum(drink.price() for drink in self.drinks)

    def mark_prepared(self):
        self.status = "prepared"

    def cancel(self):
        if self.status == "prepared":
            raise ValueError("cannot cancel - the order was already prepared")
        self.status = "cancelled"


MENU = {"espresso": 9, "latte": 14, "tea": 8}

order = Order("Dana")
order.add(Drink("latte", MENU["latte"], "large"))
order.add(Drink("espresso", MENU["espresso"]))
print(order.customer_name, "pays", order.total())

order.mark_prepared()
try:
    order.cancel()
except ValueError as error:
    print("Rejected:", error)
```

**פלט:**

```text
Dana pays 26
Rejected: cannot cancel - the order was already prepared
```

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

| החלטת תכנון | איך היא נראית בקוד |
|---|---|
| משקה = Class | `class Drink` עם נתונים (שם, מחיר, גודל) והתנהגות (`price`). |
| גודל = Attribute עם כלל | `self.size`, עם ולידציה ב-`__init__`. |
| "גדול = +3" שייך למשקה | `Drink.price()`, כי המשקה יודע את הגודל שלו. |
| הזמנה = Class | `class Order` עם פריטים וסטטוס. |
| לקוח = Attribute (בינתיים) | `self.customer_name`, מחרוזת. |
| "ביטול רק לפני הכנה" | ולידציה ב-`Order.cancel()`. |
| תפריט = dict | `MENU`. אין לו התנהגות, ולכן אין צורך ב-Class. |
| סכום = מתודה | `total()` מחשב מתוך `drinks`, ולא נשמר כנתון שעלול להתיישן. |

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

**כשהדרישה משתנה — לקוח הופך ל-Class.** נוספה דרישה: "ללקוחות יש **כרטיס מועדון**: כל קנייה צוברת נקודה לכל 10 ש"ח, וב-50 נקודות מקבלים משקה חינם."

עכשיו ללקוח יש **נתונים** (נקודות), **התנהגות** (צבירה, מימוש) ו**כללים** (50 נקודות). לפי ארבע השאלות, הוא כבר **Class**:

```python
POINTS_PER_SHEKEL = 0.1
FREE_DRINK_POINTS = 50


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

    def earn_points(self, amount_paid):
        self.points = self.points + int(amount_paid * POINTS_PER_SHEKEL)

    def has_free_drink(self):
        return self.points >= FREE_DRINK_POINTS

    def redeem_free_drink(self):
        if not self.has_free_drink():
            raise ValueError(self.name + " has only " + str(self.points) + " points")
        self.points = self.points - FREE_DRINK_POINTS


dana = Customer("Dana")
for payment in [120, 260, 140]:
    dana.earn_points(payment)
print(dana.name, "has", dana.points, "points. Free drink?", dana.has_free_drink())
dana.redeem_free_drink()
print("After redeeming:", dana.points)
```

**פלט:**

```text
Dana has 52 points. Free drink? True
After redeeming: 2
```

ובמבנה: `Order` מחזיקה עכשיו `self.customer` (אובייקט Customer) במקום `customer_name`, וכשהזמנה משולמת, היא קוראת ל-`self.customer.earn_points(self.total())`. **אותו מושג, "לקוח", התחיל כ-Attribute והפך ל-Class כשהדרישות גדלו.** זה טבעי, וזה תהליך העבודה הנכון.

### סיכום

- **שמות עצם** ‏←‏ מועמדים ל-Classes/Attributes. **פעלים** ‏←‏ Methods. **כללים** ‏←‏ Validation.
- **Class** כשיש זהות + נתונים, **התנהגות או כללים**, וכמה מופעים. ערך בודד בלי התנהגות ‏←‏ **Attribute**.
- אותו מושג יכול להיות Attribute במערכת אחת ו-Class באחרת. מתחילים פשוט, ומשדרגים כשהדרישות גדלות.
- **Method שייכת ל-Class שמחזיקה את הנתונים שהיא צריכה** (Information Expert).
- לפעמים "פעולה" (השאלה, הזמנה) היא בעצם **Class**, כשיש לה נתונים ולוגיקה משלה.
- לא Class לכל שם עצם, ולא God Class שעושה הכול.

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

## פרק שביעי: חוזרים לבנק — הכול ביחד

### מה נלמד?

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

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

כל פרק הוסיף כלי אחד ופתר בעיה אחת. כאן רואים איך כולם עובדים **יחד** ב-Class אחת — וזה בדיוק מה שתעשו לבד במיני פרויקט.

### הבנק — עם כל מה שלמדנו

```python
class BankAccount:
    accounts_opened = 0                  # shared by all accounts

    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("balance cannot be negative")
        self.owner = owner               # per account
        self.balance = balance
        BankAccount.accounts_opened = BankAccount.accounts_opened + 1

    def deposit(self, amount):           # setter - changes the state
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance = self.balance + amount

    def __str__(self):
        return self.owner + ": " + str(self.balance)


dana = BankAccount("Dana", 1000)
dana.deposit(200)
print(dana)                              # Dana: 1200
```

| חלק בקוד | מאיזה פרק |
|---|---|
| `class BankAccount` — תבנית אחת לכל החשבונות | פרק שני |
| `__init__` עם בדיקה — כל חשבון נולד שלם ותקין | פרק שלישי |
| `deposit` — Setter ששומר על הכללים | פרק רביעי |
| `__str__` — איך החשבון נראה ב-print | פרק רביעי |
| `accounts_opened` — מונה משותף לכל החשבונות | פרק חמישי |

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

### סיכום

- **Class** היא תבנית, ו-**Object** הוא מופע שנבנה ממנה — עם נתונים משלו.
- **`__init__`** רץ ביצירה ומבטיח שהאובייקט נולד שלם; **`self`** הוא האובייקט הנוכחי.
- **Methods** הן הפעולות: **Getter** מחזיר מידע, **Setter** משנה את המצב ובודק את הכללים, ו-**`__str__`** קובע מה `print` מציג.
- **Class Attribute** הוא נתון אחד שמשותף לכל האובייקטים — כמו מונה.

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

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

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html) — חזרה על כל הפרק

<!-- docs:end -->

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## תרגול

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

**מה מתרגלים בדף הזה:** למה צריך OOP, יצירת Class עם `__init__` ובדיקת ערכים, מתודות (Getter ו-Setter) ו-`__str__`, נתון משותף לכל ה-Class, ותכנון — מה Class, מה Attribute ומה Method.

**בסוף הדף תדעו:**
- לזהות מתי עדיף לאחד נתונים ופעולות לאובייקט אחד
- לכתוב Class שלמה: `__init__`, מתודות ו-`__str__`
- להשתמש ב-Class Attribute כמונה משותף לכל האובייקטים
- להפוך תיאור מילולי לרשימה של Classes, Attributes ו-Methods

### תרגיל 1: זהו את הבעיות

**מה עושים:** קוראים קוד קיים ומוצאים בו בעיות — בלי לכתוב קוד.

**הקוד:**

```text
student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def average(student):
    return sum(student["grades"]) / len(student["grades"])
```

**המשימה:** מצאו שלוש בעיות שנובעות מכך שהנתונים (המילונים) והפעולות (הפונקציה) נמצאים בנפרד. לכל בעיה כתבו משפט אחד: מה הבעיה, ובאיזו שורה היא מופיעה.

**מה מגישים:** שלוש בעיות, משפט אחד לכל אחת.

{{< solution >}}
1. **שגיאת הקלדה יוצרת מפתח חדש** — ל-`student3` יש `"nmae"` במקום `"name"`. אין שום מבנה מוגדר שיתפוס את זה, והבעיה תתגלה רק כשמישהו ינסה `student3["name"]`.
2. **אין כללים** — כל קוד יכול לשים ב-`grades` גם `-50` או `"abc"`, ואף אחד לא בודק.
3. **הנתונים והפעולות מפוזרים** — `average` היא פונקציה נפרדת שמקבלת מילון, ולא יודעת אם הוא בכלל סטודנט. ועם רשימה ריקה (`student3`) היא תיפול ב-`ZeroDivisionError`.

ב-OOP, `Student` מגדיר פעם אחת **איזה נתונים** יש לו ו**אילו פעולות** הוא יודע לעשות — כולם יחד.
{{< /solution >}}

### תרגיל 2: בדיקה ביצירה

**מה בונים:** מוצר בחנות — שאי אפשר ליצור עם מחיר שלילי.

**מה לכתוב:**
1. Class בשם `Product`.
2. `__init__` שמקבל שם, מחיר וכמות. אם הכמות לא נשלחה — היא 0.
3. אם המחיר שלילי: זורקים `ValueError` עם ההודעה שמופיעה בפלט, ולא שומרים כלום.
4. אחרת: שומרים את שלושת הנתונים על האובייקט, עם `self`.

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

```python
pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
```

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

```text
Pen 5 0
error: price cannot be negative
```

{{< solution >}}
```python
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative")
        self.name = name
        self.price = price
        self.quantity = quantity


pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
```

```text
Pen 5 0
error: price cannot be negative
```

הבדיקה ב-`__init__` מבטיחה שאין אף פעם אובייקט `Product` עם מחיר לא חוקי.
{{< /solution >}}

### תרגיל 3: Student עם מתודות ו-__str__

**מה בונים:** סטודנט שמנהל את הציונים שלו בעצמו.

**מה לכתוב:**
1. Class בשם `Student`. ה-`__init__` מקבל שם ויוצר רשימת ציונים ריקה.
2. `add_grade` — מקבלת ציון ומוסיפה אותו לרשימה. זו Setter: היא משנה את המצב.
3. `average` — מחזירה את הממוצע, או 0 אם אין עדיין ציונים. זו Getter: היא רק מחזירה.
4. `__str__` — מחזירה את השם ואת הממוצע, בדיוק כמו בשורה האחרונה של הפלט.

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

```python
dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
```

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

```text
0
82.5
Dana (average 82.5)
```

{{< solution >}}
```python
class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        self.grades.append(grade)

    def average(self):
        if len(self.grades) == 0:
            return 0
        return sum(self.grades) / len(self.grades)

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


dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
```

```text
0
82.5
Dana (average 82.5)
```

`add_grade` היא **Setter** — משנה את המצב. `average` היא **Getter** — מחזירה מידע בלי לשנות. `print` קורא ל-`__str__` אוטומטית.
{{< /solution >}}

### תרגיל 4: מונה משותף

**מה בונים:** מוצר שיודע את המחיר שלו כולל מע"מ — ו-Class שסופרת כמה מוצרים נוצרו.

**מה לכתוב:**
1. Class בשם `Product`, עם `__init__` שמקבל שם ומחיר.
2. שני Class Attributes — נתונים ששייכים לכל ה-Class, לא לאובייקט אחד: `VAT_RATE` שערכו 0.17, ו-`created_count` שמתחיל ב-0.
3. בכל יצירה של מוצר מגדילים את `created_count` ב-1.
4. `price_with_vat` — מחזירה את המחיר כולל מע"מ.

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

```python
laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
```

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

```text
2
1170.0
```

{{< solution >}}
```python
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)


laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
```

```text
2
1170.0
```

- `name` ו-`price` שייכים לכל מוצר בנפרד — **Instance Attributes**.
- `VAT_RATE` ו-`created_count` משותפים לכל המוצרים — **Class Attributes**.
- המונה מתעדכן דרך `Product.created_count` — לא דרך `self`.
{{< /solution >}}

### תרגיל 5: שמות עצם ופעלים

**מה עושים:** תכנון בלבד, בלי קוד — הופכים תיאור מילולי לרשימה של Classes, Attributes ו-Methods.

**הדרישה:**

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

**המשימה:** בנו טבלה עם שלוש עמודות: המועמד, מה הוא (Class, Attribute או Method), ולמה.

**רמז:** שמות עצם הם מועמדים ל-Classes או ל-Attributes. פעלים הם מועמדים ל-Methods.

**מה מגישים:** טבלה עם כל המועמדים מהדרישה.

{{< solution >}}
| מועמד | מה הוא | למה |
|---|---|---|
| לקוח (`Customer`) | **Class** | שם עצם עם נתונים (שם, נקודות) והתנהגות (צובר, מממש) |
| משקה (`Drink`) | **Class** | שם עצם עם נתונים (מחיר בסיס, גודל) וכלל (גדול עולה יותר) |
| מחיר בסיס, גודל | **Attributes** של `Drink` | ערכים פשוטים שמתארים משקה |
| נקודות | **Attribute** של `Customer` | מספר פשוט |
| צובר נקודות, מממש | **Methods** של `Customer` | פעלים — פעולות על הנתונים של הלקוח |
| מחיר סופי | **Method** של `Drink` | מחושב מהמחיר והגודל |
{{< /solution >}}

## מיני פרויקט: ספרייה קטנה

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

**מה תלמדו מהפרויקט:**
- לבנות Class שלמה מאפס: `__init__`, מתודות ו-`__str__`
- לבדוק ערכים כבר ביצירה, ולזרוק שגיאה כשהם לא חוקיים
- להבחין בין Setter שמשנה את המצב לבין Getter שרק מחזיר מידע
- להשתמש ב-Class Attribute כמונה משותף לכל האובייקטים

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

**שלב 1 — ה-Class והמונה המשותף.** כתבו Class בשם `Book`, עם Class Attribute בשם `total_books` שמתחיל ב-0. הוא יספור כמה ספרים נוצרו.

**שלב 2 — יצירת ספר.** ה-`__init__` מקבל שם, סופר ומספר עותקים, ושומר אותם. בהתחלה כל העותקים פנויים — שומרים אותם גם ב-`available`. כל ספר חדש מגדיל את `total_books` ב-1. אם מספר העותקים קטן מ-1 — זורקים `ValueError`, ולא יוצרים ספר.

**שלב 3 — השאלה והחזרה (Setters).**
- `borrow` — מורידה עותק פנוי אחד. אם אין עותקים פנויים: `ValueError`.
- `return_book` — מחזירה עותק אחד. אם כל העותקים כבר בספרייה: `ValueError`.

**שלב 4 — בדיקה (Getter).** `is_available` מחזירה `True` אם יש לפחות עותק פנוי אחד, אחרת `False`.

**שלב 5 — הצגה.** `__str__` מחזירה שם, סופר וכמה עותקים פנויים מתוך כמה, כמו בפלט.

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

```text
copies must be at least 1
no copies available
all copies are already in
```

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

```python
books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
```

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

```text
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
```

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

**בונוס:** הוסיפו Class בשם `Library` שמחזיקה רשימת ספרים, עם `add_book` שמוסיפה ספר, ו-`available_books` שמחזירה רק את הספרים שאפשר להשאיל עכשיו.

{{< solution >}}
```python
class Book:
    total_books = 0

    def __init__(self, title, author, copies):
        if copies < 1:
            raise ValueError("copies must be at least 1")
        self.title = title
        self.author = author
        self.copies = copies
        self.available = copies
        Book.total_books = Book.total_books + 1

    def borrow(self):
        if self.available == 0:
            raise ValueError("no copies available")
        self.available = self.available - 1

    def return_book(self):
        if self.available == self.copies:
            raise ValueError("all copies are already in")
        self.available = self.available + 1

    def is_available(self):
        return self.available > 0

    def __str__(self):
        return (self.title + " by " + self.author + " ("
                + str(self.available) + "/" + str(self.copies) + " available)")


books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
```

```text
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
```

- `total_books` הוא **Class Attribute** — מונה אחד לכל הספרים. הספר `Empty` לא נוצר (השגיאה נזרקה לפני ההגדלה), ולכן המונה הוא 2.
- `borrow` ו-`return_book` הם **Setters** — משנים את המצב, ובודקים את הכללים **לפני** השינוי.
- `is_available` הוא **Getter** — מחזיר מידע בלי לשנות כלום.

**בונוס:**

```python
class Library:
    def __init__(self):
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def available_books(self):
        result = []
        for book in self.books:
            if book.is_available():
                result.append(book)
        return result


library = Library()
for book in books:
    library.add_book(book)
for book in library.available_books():
    print(book)
```

```text
Clean Code by Martin (2/3 available)
```
{{< /solution >}}

<!-- exercises:end -->
