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

מטרות השיעור
  • לעבוד עם רשימה של אובייקטים — לולאה, חיפוש ומיון — ועם אובייקט שמחזיק אובייקט
  • להסביר מה קורה כששני שמות מצביעים על אותו אובייקט
  • להסתיר נתונים מאחורי ממשק ציבורי, ולהבחין בין _name ל-__name
  • לאכוף כללים בכל השמה עם @property ו-setter, ולכתוב שדה לקריאה בלבד ושדה מחושב
  • לתכנן ולבנות מחלקה שלמה — מאחריות במשפט אחד, דרך כללים, ועד ממשק ברור

מה בשיעור

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

  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"] > 100if item.price > 100
פעולהdeposit(account, 50)account.deposit(50)

State לאורך זמן

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

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

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

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

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

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

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

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

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

אנלוגיה: איש קשר בטלפון

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

תחביר

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 מלמטה) היא דרך קצרה לבנות רשימה מסוננת. היא שקולה ללולאה:

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

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

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

week-3/team.py:

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

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

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


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

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

for player in team:
    print(player)

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

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

פלט:

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:

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)

פלט:

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.

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

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

מה נלמד?

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

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

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

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()איך היתרה נשמרת, רשימת התנועות, בדיקות עזר
יציבותמשתנה לעיתים רחוקות. אחרים תלויים בומותר לשנות בחופשיות
אנלוגיה: כספומט

לכספומט יש ממשק ציבורי: מסך, מקשים, חריץ לכרטיס, פתח לשטרות. אתם יכולים “למשוך 200”, והכספומט בודק קוד, יתרה ומגבלה יומית. הכספת בפנים (המימוש) נעולה. אם כל אחד היה יכול לפתוח את הכספת ולקחת כסף ישירות, כל הבדיקות היו חסרות משמעות. והבנק יכול להחליף את מנגנון הספירה בפנים בלי שתשימו לב, כי הממשק נשאר אותו דבר.

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

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

כתיבהשםהמשמעותמה Python עושה
namePublicחלק מהממשק. מותר להשתמש מבחוץכלום
_nameProtected (מוסכמה)“פנימי, אל תיגע מבחוץ”כלום. זו רק הודעה למפתחים. VS Code גם לא יציע אותו בהשלמה אוטומטית
__namePrivate (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 נכיר כדי לדעת מה הוא עושה כשנראה אותו.

תחביר

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:

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

פלט:

Balance: 120
Rejected: insufficient funds
Balance still: 120

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

קודהסבר
self.ownerPublic. מותר לקרוא, ואין כלל מיוחד שצריך להגן עליו.
self._balanceProtected (מוסכמה). הקו התחתון אומר לכל מפתח: “את היתרה לא משנים ישירות”.
deposit / withdrawהממשק הציבורי לשינוי: הדרך היחידה המכובדת לשנות יתרה. כל שינוי עובר בדיקה.
get_balance()Getter: מתודה שמחזירה את הערך לקריאה. מבחוץ אפשר לקרוא את היתרה, אבל לא לשנות אותה ישירות.
account.withdraw(1000) נכשלוהיתרה לא השתנתה. הכלל נשמר.

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

דוגמה נוספת

__private ו-Name Mangling בפעולה, ומתודת עזר פנימית. week-3/secure_vault.py:

class Vault:
    MAX_ATTEMPTS = 3

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

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

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


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

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

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

פלט:

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 פנימי, ולא את המקור.
  • לא מסתירים הכול. מסתירים כשיש כלל או פרט מימוש.

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

פרק שלישי: בדיקת תקינות — 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 יש שליטה מלאה: כל קריאה וכל כתיבה עוברות דרכה.

אנלוגיה: פקיד בבנק

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

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

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

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

לעומת:

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 קלאסיות (הרעיון):

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

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:

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

פלט:

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:

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)

פלט:

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 רגיל, ומשדרגים כשצריך.

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

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

מה נלמד?

עד עכשיו למדנו חלקים: __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.

תחביר

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

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, capacityproperties (קריאה בלבד)פרטי הקורס
enroll(student)Setterרושם. אם מלא, מכניס לרשימת המתנה. מחזיר "enrolled" או "waitlisted"
drop(student)Setterמבטל הרשמה. הראשון ברשימת ההמתנה נכנס במקומו
is_enrolled(student)Getter
available_seatsproperty מחושב
is_fullproperty מחושב
roster()Getterעותק של רשימת הרשומים
__str__הצגה

שלב 4 — כללים:

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

שלב 5 — מימוש. week-3/course.py:

class Course:
    MAX_CAPACITY = 500

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

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

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

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

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

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

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

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

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

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

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

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

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

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

חלקאיך הוא משרת את התכנון
ולידציה ב-__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:

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

פלט:

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_...).
  • בודקים כל כלל ומקרי קצה, לא רק את המסלול השמח.

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

תרגול

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

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

בסוף הדף תדעו:

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

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

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

מה לכתוב:

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

קוד לבדיקה:

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

הפלט הרצוי:

Leader: Dana: 50
הצג פתרון
class Player:
    def __init__(self, name):
        self.name = name
        self.score = 0

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

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


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

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

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

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

מה לכתוב:

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

הפלט הרצוי:

Haifa Haifa
Tel Aviv Tel Aviv

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

הצג פתרון
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city


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


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

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

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

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

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

מה לכתוב:

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

קוד לבדיקה:

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

הפלט הרצוי:

error: not enough money
150
הצג פתרון
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

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

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

    def get_balance(self):
        return self._balance


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

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

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

מה לכתוב:

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

קוד לבדיקה:

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

הפלט הרצוי:

31
error: age must be 0-120
31
הצג פתרון
class User:
    def __init__(self, age):
        self.age = age

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

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


user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
31
error: age must be 0-120
31
  • self.age = age ב-__init__ עובר דרך ה-setter — אז גם אובייקט חדש נבדק.
  • הערך שמור ב-_age, וה-property חושף אותו כאילו הוא Attribute רגיל.

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

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

מה לכתוב:

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

קוד לבדיקה:

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

הפלט הרצוי:

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

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

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


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

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

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

מה תלמדו מהפרויקט:

  • להסתיר נתונים (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 מחושב: סכום השווי של כל המוצרים.

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

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

קוד לבדיקה:

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

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

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

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

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

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

הפלט הרצוי:

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

בסוף הפרויקט תדעו:

  • לבנות אובייקט שמגן על הנתונים שלו — מבחוץ אי אפשר לשבור אותו
  • לבחור בין שדה רגיל, property עם בדיקה, שדה לקריאה בלבד וערך מחושב
  • לבנות “מנהל” שמחזיק אובייקטים, מחפש ביניהם ומסכם אותם

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

הצג פתרון
class Product:
    def __init__(self, name, price, quantity=0):
        self._name = name
        self.price = price
        self._quantity = quantity

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

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

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

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

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

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

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

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


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

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

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

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

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


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

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

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

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

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

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
  • Inventory מחזיקה אובייקטים של Product (אובייקט שמחזיק אובייקטים), ו-by_value מחזירה רשימה חדשה — sorted לא נוגע ברשימה הפנימית.
  • _name, _price, _quantity מוסתרים. מבחוץ משנים כמות רק דרך add_stock ו-sell — שבודקות את הכללים.
  • price עם setter שבודק — גם ב-__init__. name ו-quantity לקריאה בלבד, ו-total_value מחושב בכל קריאה.

בונוס:

    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'].