מחלקות – חלק 2

רענון

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

לדוגמה, מחלקת "קבוצת ווטסאפ" יכולה להיות מוגדרת כך:

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

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

משתני מחלקה

במסעדת "זלוברי" ליד כל רכיב בתפריט רשומים ערכיו התזונתיים ל־100 גרם.
מנהלת המסעדה קיפף אוסין שכרה את שירותינו וביקשה מאיתנו לכתוב מחלקה המייצגת רכיב במנה.

לכל רכיב יש:

  • תכונות: שם, משקל פחמימות, משקל חלבונים ומשקל שומנים (בגרמים).
  • פעולות: חישוב מספר הקלוריות ברכיב.

להזכירכם, דרך להערכה מהירה של מספר הקלוריות שיש ברכיב, היא זו:

  1. מצאו את משקל הפחמימות ברכיב והכפילו ב־4.
  2. מצאו את משקל השומן ברכיב והכפילו ב־9.
  3. מצאו את משקל החלבון ברכיב והכפילו ב־4.
  4. מספר הקלוריות ברכיב היא חיבור הערכים שקיבלתם בשלבים 1–3.

נסו לממש בעצמכם את מחלקת Ingredient לפי התכונות והפעולות שתוארו למעלה.

חשוב!
פתרו לפני שתמשיכו!

מימוש של המחלקה ייראה כך:


In [ ]:
class Ingredient:
    def __init__(self, name, carbs, fats, proteins):
        self.name = name
        self.carbs = carbs
        self.fats = fats
        self.proteins = proteins
    
    def calculate_calories(self):
        return (
            self.carbs * 4
            + self.fats * 9
            + self.proteins * 4
        )

ושימוש בה ייראה כך:


In [ ]:
mango = Ingredient('Mango', carbs=15, fats=0.4, proteins=0.8)
mango.calculate_calories()

אוסין מגדירה רכיב כ"בריא" אם מספר הקלוריות שבו קטן מ־100 עבור 100 גרם.
נממש את הפעולה is_healthy שמחזירה True או False:


In [ ]:
class Ingredient:
    def __init__(self, name, carbs, fats, proteins):
        self.name = name
        self.carbs = carbs
        self.fats = fats
        self.proteins = proteins
    
    def calculate_calories(self):
        return (
            self.carbs * 4
            + self.fats * 9
            + self.proteins * 4
        )

    def is_healthy(self):
        return self.calculate_calories() < 100

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

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


In [ ]:
HEALTHY_CALORIES_UPPER_BOUND = 100


class Ingredient:
    def __init__(self, name, carbs, fats, proteins):
        self.name = name
        self.carbs = carbs
        self.fats = fats
        self.proteins = proteins
    
    def calculate_calories(self):
        return (
            self.carbs * 4
            + self.fats * 9
            + self.proteins * 4
        )

    def is_healthy(self):
        return self.calculate_calories() < HEALTHY_CALORIES_UPPER_BOUND

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

אפשרות נוספת היא להגדיר את הערך כתכונה של כל מופע חדש שניצור:


In [ ]:
class Ingredient:
    def __init__(self, name, carbs, fats, proteins):
        self.name = name
        self.carbs = carbs
        self.fats = fats
        self.proteins = proteins
        self.HEALTHY_CALORIES_UPPER_BOUND = 100

    def calculate_calories(self):
        return (
            self.carbs * 4
            + self.fats * 9
            + self.proteins * 4
        )

    def is_healthy(self):
        return self.calculate_calories() < self.HEALTHY_CALORIES_UPPER_BOUND

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

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


In [ ]:
class Ingredient:
    HEALTHY_CALORIES_UPPER_BOUND = 100

    def __init__(self, name, carbs, fats, proteins):
        self.name = name
        self.carbs = carbs
        self.fats = fats
        self.proteins = proteins

    def calculate_calories(self):
        return (
            self.carbs * 4
            + self.fats * 9
            + self.proteins * 4
        )

    def is_healthy(self):
        return self.calculate_calories() < self.HEALTHY_CALORIES_UPPER_BOUND

מה קרה בקוד שלמעלה?
הגדרנו את HEALTHY_CALORIES_UPPER_BOUND באותה רמה היררכית של הגדרת הפעולות במחלקה.
הגדרה של משתנה ברמת המחלקה גורמת לו להיות משותף לכל המופעים שייווצרו מאותה מחלקה.
בשלב זה כל מופע יכול לגשת לערך HEALTHY_CALORIES_UPPER_BOUND כאילו הוא חלק מתכונות המופע.


In [ ]:
banana = Ingredient('Banana', carbs=23, fats=0.3, proteins=1.1)
melon = Ingredient('Melon', carbs=8, fats=0.2, proteins=0.8)

print(banana.HEALTHY_CALORIES_UPPER_BOUND)
print(melon.HEALTHY_CALORIES_UPPER_BOUND)

בתא האחרון יצרנו שני מופעים חדשים של רכיבים – הראשון של בננה והשני של מלון.
הצלחנו לגשת למשתנה המחלקה HEALTHY_CALORIES_UPPER_BOUND שמשותף לשניהם דרך המופע שלהם.

קריאה ל־banana.is_healthy() תעביר את המופע banana לפרמטר self של הפעולה is_healthy.
הביטוי self.HEALTHY_CALORIES_UPPER_BOUND יאפשר לנו להשיג את משתנה המחלקה, כיוון שאל self הועבר המופע עצמו:


In [ ]:
print(f"Is Banana healthy? -- {banana.is_healthy()}")
print(f"Is Melon healthy? -- {melon.is_healthy()}")

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

תרגיל ביניים: חללר

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

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

כדי שהסימולציה תדמה מצב מציאותי, ביקש אלון להגביל את מהירות הגשושית.
אם המהירות הממוצעת שהוגדרה לגשושית קטנה מ־0 – הגדירו אותה כ־0. אם היא גדולה ממהירות האור – הגדירו אותה כמהירות האור.

מחלקות הן אזרחיות ממדרגה ראשונה

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


In [ ]:
SomethingINeedForMyRecipe = Ingredient
honey = SomethingINeedForMyRecipe('Honey', carbs=82, fats=0, proteins=0.3)
honey.calculate_calories()

בדוגמה האחרונה ביצענו השמה, וגרמנו למשתנה SomethingINeedForMyRecipe להצביע על המחלקה Ingredient.
שימו לב שלא הפעלנו את ה־__init__ של Ingredient, אלא השתמשנו בה כערך – ציינו את שמה ללא הסוגריים.
בשלב הזה SomethingINeedForMyRecipe ו־Ingredient הצביעו לאותו מקום, ולכן אפשר היה ליצור רכיבים בעזרת SomethingINeedForMyRecipe.

הדפסה של המשתנה SomethingINeedForMyRecipe תגלה לנו שמדובר במחלקה המקורית, Ingredient:


In [ ]:
print(SomethingINeedForMyRecipe)

וכך גם בדיקת הסוג של המופע honey, שנוצר מהקריאה ל־SomethingINeedForMyRecipe:


In [ ]:
type(honey)

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


In [ ]:
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def __str__(self):
        return f"{self.name} is {self.age} years old."


class Superstar:
    def __init__(self, name, age):
        self.name = f'🌟Superstar {name}🌟'
        self.age = age - 5  # Your skin is so young!
    
    def __str__(self):
        return f"{self.name} is {self.age} years old."


classes = [User, Superstar]
for class_object in classes:
    print(f"The result from {class_object} is...")
    kipik = class_object("Kipik the Turtle", 75)
    print(kipik)
    print('-' * 50)

מה התרחש בקוד שלמעלה?

  1. יצרנו שתי מחלקות, User ו־Superstar.
  2. יצרנו רשימה שאיבריה הם המחלקות User ו־Superstar, וגרמנו למשתנה בשם classes להצביע על הרשימה בעזרת השמה.
  3. עברנו על התוכן של classes בעזרת לולאת for.
  4. בכל איטרציה לקחנו מחלקה אחת (שעליה הצביע המשתנה class_object), ובנינו בעזרתה מופע של קיפיק.
  5. הדפסנו את המופע בכל מחלקה שהגענו אליה – הראשון מופע שנוצר מהמחלקה User, והשני מופע שנוצר מהמחלקה Superstar.

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

שימוש ישיר בערך המחלקה

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

כך ניצור מחלקה:


In [ ]:
class Ingredient:
    HEALTHY_CALORIES_UPPER_BOUND = 100

    def __init__(self, name, carbs, fats, proteins):
        self.name = name
        self.carbs = carbs
        self.fats = fats
        self.proteins = proteins

    def calculate_calories(self):
        return (
            self.carbs * 4
            + self.fats * 9
            + self.proteins * 4
        )

    def is_healthy(self):
        return self.calculate_calories() < self.HEALTHY_CALORIES_UPPER_BOUND

וכך ניצור מופע:


In [ ]:
cinnamon = Ingredient('Cinnamon', carbs=81, fats=1.2, proteins=4)

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

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


In [ ]:
Ingredient.HEALTHY_CALORIES_UPPER_BOUND

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

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


In [ ]:
print(f"healthy if calories < 100: {cinnamon.is_healthy()}")
Ingredient.HEALTHY_CALORIES_UPPER_BOUND = 400
print(f"healthy if calories < 400: {cinnamon.is_healthy()}")

בניגוד לגישה למשתנה המחלקה בצורה שהוצגה למעלה, לא נוכל לגשת באותו אופן לתכונה Ingredient.name.
הרי אין בכך היגיון – התכונה name היא תכונה שמוגדרת עבור כל מופע, ולא עבור המחלקה כולה:


In [ ]:
Ingredient.name

ומה יקרה אם ננסה לפנות דרך המחלקה עצמה לפעולה שהוגדרה ברמת המחלקה?


In [ ]:
Ingredient.is_healthy()

כמובן!
הפעולה is_healthy מצפה ל־self, מופע כלשהו של המחלקה, שבדרך כלל מועבר לה כשאנחנו קוראים לה בעזרת המופע:


In [ ]:
cinnamon.is_healthy()

בתא למעלה, כפי שלמדנו בפרק הקודם על מחלקות, cinnamon נשלח לפעולה is_healthy בתור הארגומנט self.

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


In [ ]:
Ingredient.is_healthy(cinnamon)

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

הכלה

לאור ההצלחה המסחררת של מחלקת "רכיב", ביקשה מאיתנו מנהלת המסעדה קיפף אוסין לתכנת מחלקה בשם Dish שתייצג מנה במסעדה.
לכל מנה יש שם (name), סימון אם היא צמחונית (is_vegetarian) ורשימת רכיבים (ingredients).
הפעולה get_total_calories תחזיר את סכום הקלוריות של רכיבי המנה.

ממשו בעצמכם את מחלקת Dish.
לעת עתה, התעלמו מהכמות של כל רכיב במנה.

חשוב!
פתרו לפני שתמשיכו!

חלק מהכיף האמיתי בתכנות מחלקות מסתתר בכתיבת האינטרקציה ביניהן.
לכן התרגיל למעלה הוא מעניין – הוא משלב את המחלקה הקודמת שבנינו, Ingredient, עם המחלקה Dish.

כדי לייצג את הרכיבים בכל מנה נשתמש במופעי המחלקה Ingredient.
נוכל לשמור רשימה של רכיבים כתכונה של מחלקת "מנה".
במילים אחרות: לכל מנה יש רשימת רכיבים.

נממש:


In [ ]:
class Dish:
    """Create a new dish for the restaurant using our ingredients.
    
    Args:
        name (str): The name of the dish.
        is_vegetarian (bool): `True` if the dish is vegetarian.
        ingredients (list of Ingredient): All the required ingredients
                                          for the dish.

    Attributes:
        name (str): The name of the dish.
        is_vegetarian (bool): `True` if the dish is vegetarian.
        ingredients (list of Ingredient): All the ingredients that are
                                          required to prepare the dish.
    """
    def __init__(self, name, is_vegetarian, ingredients):
        self.name = name
        self.is_vegetarian = is_vegetarian
        self.ingredients = ingredients
    
    def get_total_calories(self):
        """Calculate calories based on the list of the ingredients."""
        calories = 0
        for ingredient in self.ingredients:
            calories = calories + ingredient.calculate_calories()
        return calories
    
    def __str__(self):
        calories = self.get_total_calories()
        return f"{self.name} has {calories:.7} calories in it."

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

נבדוק שהמחלקה עובדת.
ניצור רשימת רכיבים ונעביר אותה ל־Dish כדי לייצר מופע של מנה:


In [ ]:
ingredients = [
    Ingredient('Butter', carbs=0.1, fats=81.9, proteins=0.9),
    Ingredient('Honey', carbs=82, fats=0, proteins=0.3),
    Ingredient('Flour', carbs=79, fats=1.8, proteins=7),
]
bread = Dish("Lembas Bread", is_vegetarian=True, ingredients=ingredients)
print(bread)

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

תרגיל ביניים: צָב שָׁלוּחַ 2

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

צרו את מחלקת Message והחליטו אילו תכונות כדאי שיהיו לה.
ממשו פעולת __str__ שתציג את ההודעה בצורה נאה.
הרצת הפונקציה len על מופע של הודעה יחזיר את אורך ההודעה (ללא הכותרת).

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

כימוס, הגנה ופרטיות

מחלקת Dish שכתבנו שינתה את עולם המסעדנות כולו, ואין מתכנת מסעדן שלא משתמש בה.
הגברת קיפף אוסין משתמשת בה במסעדת הדגל שלה "מסון קייפי" ומעוניינת להשתמש בה במסעדה החדשה שלה, "7 טעמים".

במסעדת 7 טעמים אין אף מנה שמורכבת מיותר מ־7 רכיבים, והגברת אוסין מבקשת מאיתנו לעזור לה לאכוף זאת במחלקה.

נסו לחשוב בעצמכם איך אתם הייתם פותרים את הבעיה.
כיצד תשפרו את מחלקת Dish כך שתגביל את המשתמשים בה לעד 7 רכיבים?

חשוב!
פתרו לפני שתמשיכו!

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

אחת הבחירות האפשריות היא לא לקבל מלכתחילה בפעולת האתחול __init__ את רשימת הרכיבים.
במקרה כזה נצטרך לספק למשתמש דרך נוחה להוסיף רכיבים לתכונה ingredients ולהוריד רכיבים ממנה.
נאתחל את התכונה לרשימה ריקה, ונוסיף ל־Dish את הפעולות add_ingredient ו־remove_ingredient.
ברגע שהמשתמש במחלקה יקרא ל־add_ingredient כשיש במנה כבר 7 רכיבים, נכשיל את הוספת הרכיב העודף:


In [ ]:
class Dish:
    MAX_INGREDIENTS = 7
    
    """Create a new dish for the restaurant using our ingredients.
    
    Args:
        name (str): The name of the dish.
        is_vegetarian (bool): `True` if the dish is vegetarian.

    Attributes:
        name (str): The name of the dish.
        is_vegetarian (bool): `True` if the dish is vegetarian.
        ingredients (list of Ingredient): All the required ingredients
                                          for the dish.
    """
    def __init__(self, name, is_vegetarian):
        self.name = name
        self.is_vegetarian = is_vegetarian
        self.ingredients = []

    def can_add_ingredient(self):
        """Return True if we should allow to add another ingredient."""
        return (
            self.MAX_INGREDIENTS is None
            or len(self.ingredients) < self.MAX_INGREDIENTS
        )

    def add_ingredient(self, ingredient):
        """Add an ingredient to the dish.
        
        If the class variable MAX_INGREDIENTS is set, and there are
        at least MAX_INGREDIENTS ingredients in the dish, the call
        to this function will fail.
        
        Args:
            ingredient (Ingredient): An `Ingredient` instance to add.
        """
        if not self.can_add_ingredient():
            return
        self.ingredients.append(ingredient)

    def remove_ingredient(self, ingredient):
        """Remove an ingredient from the dish.
        
        Args:
            ingredient (Ingredient): An `Ingredient` instance to add.

        Raises:
            ValueError: If the supplied ingredient is not in the
                        ingredients list.
        """
        self.ingredients.remove(ingredient)
    
    def get_total_calories(self):
        """Calculate calories based on the list of the ingredients."""
        calories = 0
        for ingredient in self.ingredients:
            calories = calories + ingredient.calculate_calories()
        return calories

    def __str__(self):
        calories = self.get_total_calories()
        return f"{self.name} has {calories:.7} calories in it."

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


In [ ]:
# ניצור מנה של קוקטייל
great_cocktail = Dish("Black Magic Julep", is_vegetarian=True)

# נכין את הרכיבים בצד
ingredients = [
    Ingredient("Angostura bitters", carbs=80, fats=0, proteins=0),
    Ingredient("Fernet branca", carbs=46.41, fats=0, proteins=0),
    Ingredient("Four roses", carbs=69, fats=0, proteins=0),
    Ingredient("Mint leaves", carbs=15, fats=3.8, proteins=0.9),
    Ingredient("Amaro Averna", carbs=20.9, fats=15.7, proteins=2.8),
    Ingredient("Amaro Montenegro", carbs=54, fats=0, proteins=0),
    Ingredient("Amaro Nonino", carbs=33, fats=0, proteins=0),
]

# נצרף את 7 הרכיבים לקוקטייל
for ingredient in ingredients:
    great_cocktail.add_ingredient(ingredient)

# נבדוק שהכל עבד כשורה
print(len(great_cocktail.ingredients))
print(great_cocktail)

# נוסיף את הרכיב השמיני
sugar = Ingredient("Sugar", carbs=100, fats=0, proteins=0)
great_cocktail.add_ingredient(sugar)

# נוודא שלא התווסף
print('-' * 40)
print("Tried adding the 8th ingredient.")
print(f"Success: {sugar in great_cocktail.ingredients}")

אם ננסה להוציא את הסוכר, נגלה שהוא אכן לא ברשימת הרכיבים:


In [ ]:
great_cocktail.remove_ingredient(sugar)

מזל טוב! הכל עובד וקיפף מרוצה, בינתיים.
הכנתם לכולם Black Magic Julep כדי לחגוג את המאורע, לגמתם בשקיקה שני שלוקים ו...

"יש בעיה חמורה במחלקה!", מזדעק הסו־שף מפינת החדר. נראה שהוא טרם לגם מהמשקה שלו. הסו־שף מוסיף:
"משתמש שלא מכיר את המחלקה, יכול בטעות לערוך ישירות את התכונה ingredient השייכת למופע של המאכל שיצרנו.
בכך הוא יעקוף את המגבלה שהצבנו בפעולה add_ingredient:


In [ ]:
great_cocktail.ingredients.append(sugar)
print(len(great_cocktail.ingredients))

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

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

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

שיניתם את המחלקה. אף על פי שהשימוש בה נראה בדיוק אותו דבר, התכונה ingredients קיבלה קו תחתון לפני שמה.
המחלקה המתוקנת שיצרתם נראית עכשיו כך:


In [ ]:
class Dish:
    MAX_INGREDIENTS = 7
    
    """Create a new dish for the restaurant using our ingredients.
    
    Args:
        name (str): The name of the dish.
        is_vegetarian (bool): `True` if the dish is vegetarian.

    Attributes:
        name (str): The name of the dish.
        is_vegetarian (bool): `True` if the dish is vegetarian.
        _ingredients (list of Ingredient): All the required ingredients
                                           for the dish.
    """
    def __init__(self, name, is_vegetarian):
        self.name = name
        self.is_vegetarian = is_vegetarian
        self._ingredients = []

    def can_add_ingredient(self):
        """Return True if we should allow to add another ingredient."""
        return (
            self.MAX_INGREDIENTS is None
            or len(self._ingredients) < self.MAX_INGREDIENTS
        )

    def add_ingredient(self, ingredient):
        """Add an ingredient to the dish.
        
        If the class variable MAX_INGREDIENTS is set, and there are
        at least MAX_INGREDIENTS ingredients in the dish, the call
        to this function will fail.
        
        Args:
            ingredient (Ingredient): An `Ingredient` instance to add.
        """
        if not self.can_add_ingredient():
            return
        self._ingredients.append(ingredient)

    def remove_ingredient(self, ingredient):
        """Remove an ingredient from the dish.
        
        Args:
            ingredient (Ingredient): An `Ingredient` instance to add.

        Raises:
            ValueError: If the supplied ingredient is not in the
                        ingredients list.
        """
        self._ingredients.remove(ingredient)
    
    def get_total_calories(self):
        """Calculate calories based on the list of the ingredients."""
        calories = 0
        for ingredient in self._ingredients:
            calories = calories + ingredient.calculate_calories()
        return calories

    def __str__(self):
        calories = self.get_total_calories()
        return f"{self.name} has {calories:.7} calories in it."

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

אם המשתמש במחלקה ירצה להשיג את ערכו של מופע או לשנות אותו, הוא יוכל לקרוא לפעולה שזו מטרתה.
במקרה שלנו, הפעולות הללו הן add_ingredient או remove_ingredient.
נוכל (וכדאי) שנממש פעולה לצפייה בתכונה, כמו get_ingredients.

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

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

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

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


In [ ]:
class User:
    def __init__(self, name, age, hobbies):
        self.name = name
        self._age = age
        self.__hobbies = hobbies
    
    def __str__(self):
        return (
            f"{self.name} is {self._age} years old. "
            + f"He loves {self.__hobbies.lower()}."
        )

ניצור דמות שנקראת פרנקלין:


In [ ]:
character = User("Franklin", 200, "Lacing shoes")
print(character)

וננסה לשנות לה את התכונות:


In [ ]:
character.name = "Cookie Monster"  # עם זה אין שום בעיה
character._age = 54                # זה לא מנומס ומסוכן
character.__hobbies = "Cookies"    # זה כבר ממש לא סבבה

אבל כשננסה להדפיס את Cookie Monster, נגלה שכל תכונות הדמות השתנו חוץ מהעובדה שהיא עדיין אוהבת לשרוך נעליים!


In [ ]:
print(character)

הסיבה לכך היא שכשפייתון רואה את התחילית __ היא מבינה שממש חשוב לכם שאף גורם חיצוני למחלקה לא ייגש לתכונה.
אף על פי שניסיון פשוט לגשת לתכונה __hobbies מחוץ למחלקה לא יישא פרי, אם נחטט טוב נגלה שעדיין ישנה דרך לעשות זאת.
מתברר שפייתון רק משנה את שם התכונה למשהו מעט מסובך יותר כדי "להחביא" אותה טוב יותר:


In [ ]:
character._User__hobbies = "Cookies"
print(character)

תכונה שמתחילה בתחילית __ בפייתון נקראת, כאמור, "תכונה פרטית" (private).
גם בה מותר להשתמש רק בתוך המחלקה, רק שהפעם פייתון עושה טריק מלוכלך כדי למנוע מכם להשתמש בתכונה בכל זאת.
פייתון משנה את שם התכונה לקו תחתון, שם המחלקה, שני קווים תחתונים ואז שם התכונה.

השם המקצועי לשינוי שם משתנה כדי לפתור בעיית ארכיטקטורה הוא name mangling, והקיום שלו בפייתון הוא נושא טעון בקרב קהילת המפתחים בשפה.
עקב כך, הקידומת __ היא אחת מהיכולות היותר שנויות במחלוקת בשפה ועדיף לא להשתמש בה, אלא אם כן ממש חייבים.
כל עוד המקרה הוא לא קיצוני מאוד, העדיפו להגן על משתנה בעזרת קידומת של קו תחתון אחד.

אפשר להגדיר גם פעולות כמוגנות או כפרטיות אם נוסיף להן את התחילית _ או __, בהתאמה.
כיוון ש־can_add_ingredient מיועדת לשימוש פנימי, שנו את הפעולה כך שתוגדר כמוגנת.

חשוב!
פתרו לפני שתמשיכו!

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

פעולות גישה ושינוי

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


In [ ]:
class Ingredient:
    def __init__(self, name, carbs, fats, proteins):
        self.set_name(name)
        self.set_carbs(carbs)
        self.set_fats(fats)
        self.set_proteins(proteins)

    def set_name(self, new_name):
        self._name = new_name

    def get_name(self):
        return self._name

    def set_carbs(self, updated_carbs):
        self._carbs = updated_carbs

    def get_carbs(self):
        return self._carbs
    
    def set_fats(self, updated_fats):
        self._fats = updated_fats

    def get_fats(self):
        return self._fats

    def set_proteins(self, updated_proteins):
        self._proteins = updated_proteins

    def get_proteins(self):
        return self._proteins
    
    def get_calories(self):
        return (
            self.get_carbs() * 4
            + self.get_fats() * 9
            + self.get_proteins() * 4
        )

הרעיון בקוד שהוצג למעלה נקרא "פעולות גישה ושינוי" (accessor and mutator methods), או getters and setters.
אלו פעולות שמטרתן עריכת תכונות מסוימות או אחזור של הערך הנוכחי שלהן, תוך כדי ניסיון למנוע מהמשתמש במחלקה לגשת ישירות לערך התכונה.
המטרה של מתכנת שנוהג כך היא לדאוג שהוא תמיד יוכל לשלוט על ערכי התכונות, לוודא את תקינותם ולמנוע מופע שמכיל נתונים שאינם תקינים.

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


In [ ]:
strawberries = Ingredient("Strawberries", carbs=8, fats=0.4, proteins=0.7)

print(f"Before: {strawberries.get_calories()}")
# ננסה לערוך את כמות השומנים בתותים ל־0.3 במקום
strawberries.set_fats(strawberries.get_fats() - 0.1)

print(f"After: {strawberries.get_calories()}")

עקב הסרבול שבעניין, בפייתון לרוב נעדיף להשתמש בפעולות גישה ושינוי רק כשיש צורך ממשי בהגדרת תכונות כמוגנות או כפרטיות.

מונחים

משתני מחלקה (Class Variables)
משתנים המוגדרים ברמת המחלקה ונגישים עבור כל המופעים שנוצרו ממנה.
בדרך כלל אלו משתנים קבועים שהמופעים משתמשים בהם לצורכי קריאה בלבד.
לדוגמה: משתנה בראש מחלקת "תיבת דואר" שמגדיר את נפח האחסון המירבי ל־5 ג'יגה בייט.
הכלה (Containment)
מצב שבו נעשה שימוש במופע שנוצר במחלקה A בתוך מופע של מחלקה B.
לדוגמה: בתוך מופע של מחלקת "הודעת דואר", תכונת הנמען והמוען יהיו מופעים של מחלקת "משתמש".
כימוס (Encapsulation)
איגוד תכונות ופעולות תחת מחלקה, וצמצום הגישה של המשתמש במחלקה למצב הפנימי של המופעים שנוצרו ממנה.
בפועל, ימומש על ידי הגבלה לגישה ולעריכה של תכונות ופעולות מסוימות, כך שיתאפשרו רק מקוד שנכתב בתוך המחלקה.
עם זאת, החלטה על הסתרת תכונות רבות מדי עלולה ליצור קוד ארוך ומסורבל, ובפייתון נהוג להשתמש ברעיון בצמצום.
תכונה/פעולה מוגנת (Protected Attribute) או תכונה/פעולה פרטית (Private Attribute)
תכונה שהגישה אליה יכולה להתבצע רק מתוך המחלקה.
טכנית, פייתון תמיד מאפשרת למתכנת לשנות ערכים – אפילו אם הם מוגדרים כמוגנים או כפרטיים.
מעשית, עליכם להימנע בכל דרך אפשרית משינוי של משתנה שמוגדר כמוגן או פרטי, כל עוד אתם יכולים.
פעולת גישה ושינוי (Accessor/Mutator Method)
פעולה שמטרתה לגשת (Accessor) או לשנות (Mutator) ערך של תכונה מסוימת, בעיקר בהקשרי תכונות מוגנות או פרטיות.
מטרת ה־Accessor היא לאחזר את ערך התכונה המבוקשת בצורה מתאימה, לעיתים אחרי עיבוד מסוים.
מטרת ה־Mutator היא לוודא ששינוי הערך תקין ולא מזיק למופע או משנה אותו למצב לא תקין.
פעולות אלו נקראות גם getters ו־setters.

תרגיל לדוגמה

בהמשך להצלחה המסחררת של אפליקציית צ'יקצ'וק, החלטנו להשיק את יישומון המסרים המיידיים "מנשמעעעעעעע XPPP".
תחילה, ניצור מחלקה שתייצג משתמש.

לכל משתמש יש:

  • תכונות: כינוי (nickname) תאריך אחרון שנצפה (last_seen) ואנשי קשר (contacts).
  • פעולות: התחבר (connect), האם מחובר? (is_online; בודקת אם התחבר בדקה האחרונה).

לכל הודעה יש:

  • תכונות: תאריך שליחה (send_date), אם נצפתה (seen), מוען (שולח; sender), נמען (מקבל; recipient) ותוכן (content).
  • פעולות: סימון הודעה כנקראה (mark_as_read).

In [ ]:
import datetime
import time


class User:
    """A user can connect and can contact with other users.

    Args:
        nickname (str): The username of the user.

    Attributes:
        nickname (str): The username of the user.
        _contacts (list of User): Other users whose added to the
                                  user's contacts.
        last_seen (float): The last time the user connected in seconds
                           since the Epoch.
    """
    SECONDS_UNTIL_DISCONNECTED = 60

    def __init__(self, nickname):
        self.nickname = nickname
        self._contacts = []
        self.last_seen = time.time()

    def is_online(self):
        """Determine if the user is currently connected.

        "Connected user" is a user that logged in at the last X
        seconds, where X is a constant defined as a class variable.

        Returns:
            bool: True if the user currently connected, False otherwise.
        """
        seconds_since_seen = time.time() - self.last_seen
        return seconds_since_seen < self.SECONDS_UNTIL_DISCONNECTED

    def connect(self):
        """Makes the user connected.

        Returns:
            None.
        """
        self.last_seen = time.time()
    
    def add_contact(self, contact):
        """Add contact to the user's contacts.

        Args:
            contact (User): A user to add to the user's contacts.

        Returns:
            bool: True if the addition was successful, False otherwise.
        """
        if contact in self._contacts:
            return False
        self._contacts.append(contact)
        return True

    def remove_contact(self, contact):
        """Remove a contact from the user's contacts.

        Args:
            contact (User): A user to remove from the user's contacts.

        Returns:
            bool: True if the deletion was successful, False otherwise.
        """
        if contact not in self._contacts:
            return False
        self._contacts.remove(contact)
        return True

    def get_contacts(self):
        """Return the user's contacts list."""
        return self._contacts
    
    def pretty_last_seen(self, dateformat='%Y-%m-%d %H:%M:%S'):
        """Show prettified date of when the user last connected.

        Args:
            dateformat (str): The format in which the time will
                              be displayed. 

        Returns:
            str: Last time the user connected.
        """
        localtime = time.localtime(self.last_seen)
        return time.strftime(dateformat, localtime)
    
    def __str__(self):
        """Show the user's details.

        Returns:
            str: The user representation currently.
        """
        if self.is_online():
            seen_message = 'Online'
        else:
            seen_message = self.pretty_last_seen()
        return f"{self.nickname} ({seen_message})"


class Message:
    """A deliverable message with status.
    
    A message can be sent from one user to another and
    can be mark as read by the recipient.
    
    Args:
        sender (User): The message sender's user.
        recipient (User): The message recipient's user.
        content (str): The body of the message.

    Attributes:
        send_date (float): The time the message was sent 
                           in seconds since the Epoch.
        seen (bool): True if the recipient saw the message.
        sender (User): The message sender's user.
        recipient (User): The message recipient's user.
        content (str): The body of the message.
    """

    def __init__(self, sender, recipient, content):
        self.send_date = time.time()
        self.seen = False
        self.sender = sender
        self.recipient = recipient
        self.content = content

    def pretty_send_date(self, dateformat='%Y-%m-%d %H:%M:%S'):
        """Show prettified date of when the message was sent.

        Args:
            dateformat (str): The format in which the time will be
                              displayed. 

        Returns:
            str: The time the message was sent.
        """
        localtime = time.localtime(self.send_date)
        return time.strftime(dateformat, localtime)

    def mark_as_read(self):
        """Mark the message as read.

        Returns:
            None.
        """
        self.seen = True

    def __str__(self):
        """Return the message's details.

        Returns:
            str: The message representation.
        """
        message = (
            f"From: {self.sender}\n"
            f"To: {self.recipient}\n"
            f"{self.pretty_send_date()}\n"
            f"-------------------\n"
            f"Message: {self.content}\n"
        )
        if self.seen:
            message = message + f"(Seen)"
        return message.strip()


def link_everyone(users):
    for user in users:
        for another_user in users:
            if user != another_user:
                user.add_contact(another_user)


print("First example:\n----")
anonymous = User("Zarop")
almog = User("Almog")
advertisement = "Click to get your Cookie!"
message = Message(sender=anonymous, recipient=almog, content=advertisement)
print(message)

print("\n\nSecond example:\n----")
users = [User("Athos"), User("Porthos"), User("Aramis"), User("D'Artagnan")]
link_everyone(users)
print("Contacts: " + str(len(users[0].get_contacts())))
message = Message(
    sender=users[0],
    recipient=users[1],
    content="Tous pour un, un pour tous, c'est notre devise"
)
print(message)
message.mark_as_read()
print("\nAfter read:\n")
print(message)

למה כדאי לשים לב בפתרון?

  1. אנחנו משתמשים בכימוס במשורה – רק כשזה משפר את התנהגות המחלקה.
  2. מחלקת User מוכלת בתוך מחלקת Message.
  3. לא הרשינו למספרים להסתובב בקוד – SECONDS_UNTIL_DISCONNECTED הוא משתנה מחלקה קבוע שערכו 60.

תרגילים

אורטל קומבט – חלק 2

Player

במחלקת Player שבניתם, שנו את הפעולה attack.
אם הפעולה לא מקבלת פרמטרים ואין לשחקן אויבים, היא תחזיר False במקום לזרוק IndexError.
אם התקיפה הצליחה, הפעולה תחזיר True.

Arena

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

המחלקה תכיל לכל הפחות את התכונות הבאות:

  • players – מכילה את רשימת השחקנים בזירה.
  • next_player – מכילה את השחקן שתורו לשחק כעת.
  • winner – מכילה את השחקן שניצח, או None אם עדיין אין אחד כזה.

במחלקה ימומשו גם הפעולות הבאות:

  • hajime – מתחיל את הקרב. החל משלב זה אי אפשר להוסיף או להסיר שחקנים מהזירה.
  • get_players – מחזירה את רשימת השחקנים בזירה.
  • add_player – הוסף שחקן לזירה. אם הוא כבר בזירה או אם הקרב כבר התחיל, מחזירה False.
  • remove_player – מקבלת שחקן ומסירה אותו מהזירה. אם הוא אינו בזירה או אם הקרב כבר התחיל, מחזירה False.
  • make_move – גורמת לשחקן שתורו כעת להפעיל פעולת attack ללא פרמטרים.
    אם הפעולה attack החזירה False, השחקן יבחר אויב חי אקראי מהזירה ויתקוף אותו.
    בסוף הפעולה, התור מועבר לשחקן הבא.

ודאו שכל זמן שיש שחקנים בזירה, next_player מצביעה למופע של שחקן.
אם אין שחקנים בזירה, בעת אתחול המחלקה למשל, שנו את ערכה של next_player ל־None.

רמות ונקודות ניסיון

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

$$ EXP_{gain} = \frac{L_{rival} \cdot (2 \cdot L_{rival} + 10)^{2.5}}{5 \cdot (L_{rival} + L_{player} + 10) ^{2.5}} $$

כאשר $L_{rival}$ היא הרמה של המת ברובו (שחקן ב), ו־$L_{player}$ היא הרמה של המנצח (שחקן א).
אם השחקן מת לחלוטין ולא רק מת ברובו, השחקן שניצח אותו מקבל פי 2 נקודות ניסיון.

מספר נקודות הניסיון שיש להשיג בסך הכול כדי להגיע לרמה $L$ הוא:

$$ \frac{4 \cdot ({L-1})^{2.5}}{5} $$

מתחילים לשחק

ממשו סימולציית קרב:

  • צרו זירה שיכולה להכיל עד 4 משתתפים, וצרפו אליה מספר כזה של שחקנים. התחילו את הקרב.
  • התורות במשחק עוברים משחקן אחד לשני, כך שבכל סיבוב כל אחד מהשחקנים החיים משחק פעם אחת.
  • אם השחקן מת ברובו – הוא יעשה revive כשיגיע תורו, ויתקוף מייד אחר כך. החייאה לא עולה תור.
  • כל שחקן יכול למות ברובו עד פעמיים – ובפעם השלישית שהוא מת הוא נחשב מת לחלוטין.
  • אם השחקן מת לחלוטין, פעולת revive על השחקן תחזיר False, תורות לא יעברו אליו ושחקנים לא יתקיפו אותו.
  • כאשר נשאר בזירה רק שחקן חי אחד – הוא מוכתר כמנצח של הזירה.

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