ירושה – חלק 2

הקדמה

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

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

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

שימושים נפוצים לירושה

רמות ירושה מרובות

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

בקטע הקוד הבא, לדוגמה, המחלקה יונק (Mammal) יורשת מהמחלקה "חיה" (Animal).
המחלקות עטלף (Bat) וארנב (Rabbit) יורשות מהמחלקה "יונק".


In [ ]:
class Animal:
    pass


class Mammal(Animal):
    pass


class Bat(Mammal):
    pass


class Rabbit(Mammal):
    pass

במקרה כזה, המחלקות Bat ו־Rabbit ירשו הן את התכונות והפעולות של המחלקה Mammal, והן את אלו של Animal.

דוגמה לרמות ירושה מרובות: המחלקות Rabbit ו־Bat יורשות ממחלקה שיורשת ממחלקה נוספת.

אפשר לקבל את שרשרת מחלקות־העל של מחלקה מסוימת לפי סדרן בעזרת class_name.mro():


In [ ]:
Bat.mro()

בפנייה לפעולה כלשהי, תחפש פייתון את הפעולה במחלקה הראשונה שמופיעה ב־MRO.
אם הפעולה לא מופיעה שם, תיגש פייתון למחלקה שאחריה, כך עד שהיא תגיע ל־object שתמיד יהיה בראש השרשרת.
אם הפעולה לא קיימת באף אחת מהמחלקות שמופיעות ב־MRO, פייתון תזרוק NameError.

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

מחלקה מופשטת

כל חיה משמיעה צליל האופייני לה: כלב נובח, פרה גועה ויונה הומה.
נממש מחלקה עבור כל חיה:


In [ ]:
class Dog:
    def __init__(self, name, gender):
        self.name = name
        self.gender = gender

    def make_sound(self):
        print("Woof")
    
    def __str__(self):
        return f"I'm {self.name} the dog!"


class Cow:
    def __init__(self, name, gender):
        self.name = name
        self.gender = gender

    def make_sound(self):
        print("Muuuuuuuuuu")

    def __str__(self):
        return f"I'm {self.name} the cow!"


class Dove:
    def __init__(self, name, gender):
        self.name = name
        self.gender = gender

    def make_sound(self):
        print("Kukukuku!")

    def __str__(self):
        return f"I'm {self.name} the dove!"


print(Dove("Rexi", "Female"))

נשים לב שצורת כל המחלקות דומה – מקרה קלאסי לירושה:


In [ ]:
class Animal:
    def __init__(self, name, gender):
        self.name = name
        self.gender = gender


class Dog(Animal):
    def make_sound(self):
        print("Woof")

    def __str__(self):
        return f"I'm {self.name} the dog!"


class Cow(Animal):
    def make_sound(self):
        print("Muuuuuuuuuu")

    def __str__(self):
        return f"I'm {self.name} the cow!"


class Dove(Animal):
    def make_sound(self):
        print("Kukukuku!")

    def __str__(self):
        return f"I'm {self.name} the dove!"


print(Dove("Rexi", "Female"))

לעיתים קרובות אנחנו רוצים לממש קבוצת מחלקות שיש להן את אותן תכונות ופעולות – בדיוק כמו במקרה של Dog, Cow ו־Dove.
במקרה כזה נפנה באופן טבעי לירושה, שבה מחלקת־העל תכיל את התכונות והפעולות המשותפות לכל המחלקות.

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

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

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

  1. איגוד של תתי־מחלקות והורשת תכונות ופעולות לכולן (ירושה קלאסית).
  2. אכיפה שכל תתי־המחלקות יממשו פעולות שהגדרנו במחלקת־העל כפעולות שחייבים לממש.
  3. אכיפה שתאסור יצירת מופעים ממחלקת־העל המופשטת. יצירת מופעים תתאפשר רק מתתי־המחלקות שיורשות אותה.

נהוג לכתוב במחלקת־העל המופשטת את כותרות הפעולות שעל תת־המחלקות שיורשות ממנה לממש:


In [ ]:
class Animal:
    def __init__(self, name, gender):
        self.name = name
        self.gender = gender
    
    def make_sound(self):
        pass

    def __str__(self):
        pass


class Dog(Animal):
    def make_sound(self):
        print("Woof")

    def __str__(self):
        return f"I'm {self.name} the dog!"


class Cow(Animal):
    def make_sound(self):
        print("Muuuuuuuuuu")

    def __str__(self):
        return f"I'm {self.name} the cow!"


class Dove(Animal):
    def make_sound(self):
        print("Kukukuku!")

    def __str__(self):
        return f"I'm {self.name} the dove!"


print(Dove("Rexi", "Female"))

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

  • המחלקה המופשטת שלנו תבצע ירושה מהמחלקה ABC שבמודול abc.
  • מעל כל פעולה מופשטת (כזו שמשמשת רק לירושה ולא עומדת בפני עצמה) נוסיף @abstractmethod.

In [ ]:
from abc import ABC, abstractmethod


class Animal(ABC):
    @abstractmethod
    def __init__(self, name, gender):
        self.name = name
        self.gender = gender

    @abstractmethod
    def make_sound(self):
        pass

    @abstractmethod
    def __str__(self):
        pass


class Dog(Animal):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def make_sound(self):
        print("Woof")

    def __str__(self):
        return f"I'm {self.name} the dog!"


class Cow(Animal):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def make_sound(self):
        print("Muuuuuuuuuu")

    def __str__(self):
        return f"I'm {self.name} the cow!"


class Dove(Animal):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def make_sound(self):
        print("Kukukuku!")

    def __str__(self):
        return f"I'm {self.name} the dove!"


print(Dove(name="Rexi", gender="Female"))

חדי העין ישימו לב שהחזרנו את פעולת האתחול __init__ לכל תתי־המחלקות.
זה קרה כיוון שהגדרנו את __init__ כפעולה מופשטת במחלקת Animal.
להגדרה של פעולה כמופשטת שתי השלכות מיידיות:

  • תתי־המחלקות שיורשות מהמחלקה המופשטת, חייבות לממש את כל הפעולות שמוגדרות במחלקת־העל כמופשטות.
  • גישה ישירה לפעולה המופשטת במחלקה שבה היא הוגדרה במקור כמופשטת, תביא לזריקת שגיאה.

עכשיו כשנרצה ליצור מופע מ־Animal, נגלה שזה בלתי אפשרי, כיוון ש־Animal.__init__ מוגדרת כמופשטת:


In [ ]:
Animal(name="Rexi", gender="Female")

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


In [ ]:
class Fox(Animal):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    # What does the fox say?
    # (there is no make_sound method)

    def __str__(self):
        return f"I'm {self.name} the fox!"


Fox(name="Ylvis", gender="Male")

עוד דבר מעניין שכדאי לשים לב אליו הוא השימוש הכבד ב־**kwargs.
כיוון שהפכנו את __init__ למופשטת, אנחנו חייבים לממש אותה בכל תתי־המחלקות שיורשות מ־Animal.
למרות זאת, ה־__init__ "המעניינת" שעושה השמות לתוך תכונות המופע היא זו של מחלקת־העל Animal,
זו שמקבלת את הפרמטרים name ו־gender ומשנה לפיהם את מצב המופע.

ביצירת מופע של אחת מהחיות, נרצה להעביר את הפרמטרים name ו־gender שמיועדים ל־Animal.__init__.
אבל ביצירת מופע של יונה, של פרה או של כלב אנחנו נקרא בפועל ל־__init__ שמימשו תתי־המחלקות.
בשלב הזה, תתי־המחלקות צריכות למצוא דרך לקבל את הפרמטרים הרלוונטיים ולהעביר אותם למחלקת־העל שעושה השמות ל־self.
כדי להעביר את כל הפרמטרים שמחלקת־העל צריכה, גם אם חתימת הפעולה Animal.__init__ תשתנה בעתיד, אנחנו משתמשים ב־**kwargs.

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

תרגיל ביניים: Friendliness Pellets

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

המחיר של סייפן הוא 4 ש"ח ושל סחלב 10 ש"ח.
המחיר של ורד נקבע לפי הצבע שלו: ורד לבן עולה 5 ש"ח, וורד אדום עולה 6 ש"ח.
עבור זר עם אגרטל, על הלקוח להוסיף 20 ש"ח.

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

לדוגמה, אם הזר עלה 200 ש"ח ומושניק החליט לתת הנחה של 10 אחוזים, הזר יעלה כעת 180 ש"ח.
אם מושניק החליט לתת הנחה נוספת על הזר, הפעם של 30 ש"ח, מחירו של הזר יהיה כעת 150 ש"ח.

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

ירושה מרובה

ישר ולעניין – יש לי חדשות משוגעות עבורכם: כל מחלקה יכולה לרשת מיותר ממחלקה אחת!

ניצור מחלקה המייצגת כלי נשק, ומחלקה אחרת המייצגת כלי רכב.
מחלקת כלי הנשק תכלול את כוח כלי הנשק (strength) ופעולת תקיפה (attack).
מחלקת כלי הרכב תכלול את המהירות המרבית של כלי הרכב (max_speed) ופעולה של הערכת זמן הגעה משוער (estimate_arrival_time).

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


In [ ]:
class Weapon:
    def __init__(self, strength, **kwargs):
        super().__init__(**kwargs)
        self.strength = strength

    def attack(self, enemy):
        enemy.decrease_health_points(self.strength)


class Vehicle:
    def __init__(self, max_speed, **kwargs):
        super().__init__(**kwargs)
        self.max_speed = max_speed

    def estimate_arrival_time(self, distance):
        return distance / (self.max_speed * 80 / 100)


class Tank(Weapon, Vehicle):
    def __init__(self, name, **kwargs):
        super().__init__(**kwargs)
        self.name = name

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


In [ ]:
tank = Tank(name="Merkava Mk 4M Windbreaker", max_speed=64, strength=17.7)
distance = 101  # km to Eilat

# לטנק יש תכונות של טנק, של כלי נשק ושל כלי רכב
print(f"Tank name: {tank.name}")
print(f"Hours from here to Eilat: {tank.estimate_arrival_time(distance)}")
print(f"Weapon strength: {tank.strength}")

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

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

עץ ירושה בצורת יהלום המדגים את הבעיה המתוארת.

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

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


In [ ]:
Tank.mro()

אף על פי שטכנית היינו מצפים לראות עץ שבו Weapon ו־Vehicle נמצאות באותה רמה ומצביעות על Tank,
בפועל פייתון "משטחת" את עץ הירושה כך ש־Tank יורשת מ־Weapon שיורשת מ־Vehicle.
הסדר של המחלקות לאחר השיטוח נקבע לפי אלגוריתם שנקרא C3 Linearization, אבל זה לא משהו שבאמת חשוב לדעת בשלב הזה.

דוגמה להשטחה שפייתון עושה לירושה מרובה.

Mixin־ים

רעיון נפוץ המבוסס על ירושה מרובה הוא Mixin.
Mixin היא מחלקה שאין לה תכלית בפני עצמה, והיא קיימת כדי "לתרום" תכונות ופעולות למחלקה שתירש אותה.
לרוב נשתמש בשתי Mixins לפחות, כדי ליצור מהן מחלקות מורכבות יותר, שכוללות את הפעולות והתכונות של כל ה־Mixins.
לדוגמה: ניצור מחלקת "כפתור" (Button) שיורשת ממחלקת "מלבן גדול" (LargeRectangle) וממחלקת "לחיץ" (Clickable).


In [ ]:
class Clickable:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.clicks = 0

    def click(self):
        self.clicks = self.clicks + 1


class LargeRectangle:
    def __init__(self, width=10, height=5, **kwargs):
        super().__init__(**kwargs)
        self.width = width
        self.height = height
    
    def size(self):
        return self.width * self.height


class Button(Clickable, LargeRectangle):
    pass

ניצור כפתור ונראה שהוא אכן קיבל את התכונות והפעולות משתי המחלקות:


In [ ]:
buy_now = Button()
buy_now.click()
print(f"Button size (from LargeRectangle class): {buy_now.size()}")
print(f"Button clicks (from Clickable class): {buy_now.clicks}")

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

מונחים

מחלקה מופשטת (Abstract Class)
מחלקה שלא נהוג ליצור ממנה מופעים.
המחלקה המופשטת תגדיר פעולות ללא מימוש שנקראות "פעולות מופשטות".
המחלקות שיורשות מהמחלקה המופשטת יממשו את הפעולות הללו.
ירושה מרובה (Multiple Inheritance)
ירושה שמחלקה מבצעת ממחלקות רבות, במטרה לקבל את תכונותיהן ופעולותיהן של כמה מחלקות־על.
Mixin
מחלקה שמטרתה לספק תכונות ופעולות למחלקות היורשות אותה, ואין לה שימוש כשהיא עומדת בפני עצמה.
לרוב נעשה שימוש ביותר מ־Mixin אחת בעזרת ירושה מרובה.

תרגיל לדוגמה

הקדמה

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

ממשו לוח של משחק שחמט בגודל 8 על 8 משבצות.

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

בשחמט 6 סוגים של כלי משחק: רגלי, צריח, פרש, רץ, מלך ומלכה.
בפתיחת המשחק, שורות 1 ו־2 מלאות בכליו של השחקן הלבן ושורות 7 ו־8 מלאות בכליו של השחקן השחור.
הכלים מסודרים על הלוח כדלקמן:

  • על כל המשבצות בשורה 2 ובשורה 7 מונחים רגלים.
  • בשורות 1 ו־8, בטורים א ו־ח מונחים צריחים.
  • בשורות 1 ו־8, בטורים ב ו־ז מונחים פרשים.
  • בשורות 1 ו־8, בטורים ג ו־ו מונחים רצים.
  • בשורות 1 ו־8, בטור ד מונחת המלכה.
  • בשורות 1 ו־8, בטור ה מונח המלך.

חוקי התנועה של הכלים מפורטים להלן:

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

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

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

התרגיל

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

לצורך כך, צרו מחלקה כללית בשם Piece המייצגת כלי משחק בשחמט.
לכל כלי משחק צבע (color), השורה שבה הוא נמצא (row) והעמודה שבה הוא נמצא (column).
צרו גם מחלקות עבור כל אחד מהכלים: Pawn (רגלי), Rook (צריח), Knight (פרש), Bishop (רץ), Queen (מלכה) ו־King (מלך).

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

פתרון אפשרי

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


In [ ]:
from abc import ABC, abstractmethod


class Color:
    """Describe the game pieces' color"""
    BLACK = 0
    WHITE = 1

    def enemy_of(color):
        """Return the opponent color."""
        if color == Color.BLACK:
            return Color.WHITE
        return Color.BLACK


class Board:
    """Create and maintain the game board."""

    # Some functions below will not work well with altered board size.
    BOARD_SIZE = (8, 8)

    def __init__(self):
        self.reset()

    def get_square(self, row, col):
        """Return the game piece by its position on board.

        If there is no piece in this position, or if the position does
        not exist - return False.
        """
        if self.is_valid_square(row, col):
            return self.board[row][col]

    def set_square(self, row, col, piece):
        """Place piece on board."""
        self.board[row][col] = piece

    def is_valid_square(self, row, column):
        """Return True if square in board bounds, False otherwise."""
        row_exists = row in range(self.BOARD_SIZE[0])
        column_exists = column in range(self.BOARD_SIZE[1])
        return row_exists and column_exists

    def is_empty_square(self, square):
        """Return True if square is unoccupied, False otherwise.

        An empty square is a square which has no game piece on it.
        If the square is out of board bounds, we consider it empty.
        """
        return self.get_square(*square) is None

    def _generate_back_row(self, color):
        """Place player's first row pieces on board."""
        row_by_color = {Color.BLACK: 0, Color.WHITE: self.BOARD_SIZE[0] - 1}
        row = row_by_color[color]

        order = (Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook)
        params = {'color': color, 'row': row}
        return [order[i](col=i, **params) for i in range(self.BOARD_SIZE[0])]

    def _generate_pawns_row(self, color):
        """Place player's pawns row on board."""
        row_by_color = {Color.BLACK: 1, Color.WHITE: self.BOARD_SIZE[0] - 2}
        row = row_by_color[color]
        params = {'color': color, 'row': row}
        return [Pawn(col=i, **params) for i in range(self.BOARD_SIZE[0])]

    def get_pieces(self, color=None):
        """Yield the player's pieces.

        If color is unspecified (None), yield all pieces on board.
        """
        for row in self.board:
            for square in row:
                if square is not None and (color in (None, square.color)):
                    yield square

    def reset(self):
        """Set traditional board and pieces in initial positions."""
        self.board = [
            self._generate_back_row(Color.BLACK),
            self._generate_pawns_row(Color.BLACK),
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            self._generate_pawns_row(Color.WHITE),
            self._generate_back_row(Color.WHITE),
        ]

    def move(self, source, destination):
        """Move a piece from its place to a designated location."""
        piece = self.get_square(*source)
        return piece.move(board=self, destination=destination)

    def __str__(self):
        """Return current state of the board for display purposes."""
        printable = ""
        for row in self.board:
            for col in row:
                if col is None:
                    printable = printable + " ▭ "
                else:
                    printable = printable + f" {col} "
            printable = printable + '\n'
        return printable


class Piece(ABC):
    """Represent a general chess piece."""

    def __init__(self, color, row, col, **kwargs):
        super().__init__(**kwargs)
        self.color = color
        self.row = row
        self.col = col
        self.moved = False
        self.directions = set()

    def is_possible_target(self, board, target):
        """Return True if the move is legal, False otherwise.

        A move is considered legal if the piece can move from its
        current location to the target location.
        """
        is_target_valid = board.is_valid_square(*target)
        is_empty_square = board.is_empty_square(target)
        is_hitting_enemy = self.is_enemy(board.get_square(*target))
        return is_target_valid and (is_empty_square or is_hitting_enemy)

    @abstractmethod
    def get_valid_moves(self, board):
        """Yield the valid target positions the piece can travel to."""
        pass

    def get_position(self):
        """Return piece current position."""
        return self.row, self.col

    def is_enemy(self, piece):
        """Return if the piece belongs to the opponent."""
        if piece is None:
            return False
        return piece.color != self.color

    def move(self, board, destination):
        """Change piece position on the board.

        Return True if the piece's position has successfully changed.
        Return False otherwise.
        """
        if not self.is_possible_target(board, destination):
            return False
        if destination not in self.get_valid_moves(board):
            return False

        board.set_square(*self.get_position(), None)
        board.set_square(*destination, self)
        self.row, self.col = destination
        self.moved = True
        return True

    def get_squares_threatens(self, board):
        """Get all the squares which this piece threatens.

        This is usually just where the piece can go, but sometimes
        the piece threat squares which are different than the squares
        it can travel to.
        """
        for move in self.get_valid_moves(board):
            yield move

    @abstractmethod
    def __str__(self):
        pass


class WalksDiagonallyMixin:
    """Define diagonal movement on the board.

    This mixin should be used only in a Piece subclasses.
    Its purpose is to add possible movement directions to a specific
    kind of game piece.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.directions.update({
            (-1, -1),          (1, -1),

            (-1,  1),          (1,  1),
        })


class WalksStraightMixin:
    """Define straight movement on the board.

    This mixin should be used only in a Piece subclasses.
    Its purpose is to add possible movement directions to a specific
    kind of game piece.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.directions.update({
                      (0, -1),
            (-1,  0),          (1,  0),
                      (0,  1),
        })


class WalksMultipleStepsMixin:
    """Define a same-direction, multiple-step movement on the board.

    This mixin should be used only on a Piece subclasses.
    Its purpose is to allow a piece to travel long distances based on a
    single-step pattern.

    For example, the bishop can move diagonally up to 7 squares per
    turn (in an orthodox chess game). This mixin allows it if the
    `directions` property is set to the 4 possible diagonal steps. It
    does so by overriding the get_valid_moves method and uses the
    instance `directions` property to determine the possible step for
    the piece.
    """

    def get_valid_moves(self, board, **kwargs):
        """Yield the valid target positions the piece can travel to."""
        for row_change, col_change in self.directions:
            steps = 1
            stop_searching_in_this_direction = False
            while not stop_searching_in_this_direction:
                new_row = self.row + row_change * steps
                new_col = self.col + col_change * steps
                target = (new_row, new_col)
                is_valid_target = self.is_possible_target(board, target)
                if is_valid_target:
                    yield target
                    steps = steps + 1
                    is_hit_enemy = self.is_enemy(board.get_square(*target))
                if not is_valid_target or (is_valid_target and is_hit_enemy):
                    stop_searching_in_this_direction = True


class Bishop(WalksDiagonallyMixin, WalksMultipleStepsMixin, Piece):
    """A classic Bishop chess piece.

    The bishop moves any number of blank squares diagonally.
    """
    def __str__(self):
        if self.color == Color.WHITE:
            return '♗'
        return '♝'


class Rook(WalksStraightMixin, WalksMultipleStepsMixin, Piece):
    """A classic Rook chess piece.

    The rook moves any number of blank squares straight.
    """
    def __str__(self):
        if self.color == Color.WHITE:
            return '♖'
        return '♜'


class Queen(
    WalksStraightMixin, WalksDiagonallyMixin, WalksMultipleStepsMixin, Piece,
):
    """A classic Queen chess piece.

    The queen moves any number of blank squares straight or diagonally.
    """
    def __str__(self):
        if self.color == Color.WHITE:
            return '♕'
        return '♛'


class Pawn(Piece):
    """A classic Pawn chess piece.

    A pawn moves straight forward one square, if that square is empty.
    If it has not yet moved, a pawn also has the option of moving two
    squares straight forward, provided both squares are empty.
    Pawns can only move forward.

    A pawn can capture an enemy piece on either of the two squares
    diagonally in front of the pawn. It cannot move to those squares if
    they are empty, nor to capture an enemy in front of it.

    A pawn can also be involved in en-passant or in promotion, which is
    yet to be implemented on this version of the game.
    """
    DIRECTION_BY_COLOR = {Color.BLACK: 1, Color.WHITE: -1}

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.forward = self.DIRECTION_BY_COLOR[self.color]

    def _get_regular_walk(self):
        """Return position after a single step forward."""
        return self.row + self.forward, self.col

    def _get_double_walk(self):
        """Return position after a double step forward."""
        src_row, src_col = self.get_position()
        return (src_row + self.forward * 2, src_col)

    def _get_diagonal_walks(self):
        """Returns position after a diagonal move.

        This only happens when hitting an enemy.
        It could also happen on "en-passant", which is
        unimplemented feature for now.
        """
        src_row, src_col = self.get_position()
        return (
            (src_row + self.forward, src_col + 1),
            (src_row + self.forward, src_col - 1),
        )

    def is_possible_target(self, board, target):
        """Return True if the Pawn's move is legal, False otherwise.

        This one is a bit more complicated than the usual case.
        Pawns can only move forward. They also can move two ranks
        forward if they have yet to move. Not like the other pieces,
        pawns can't hit the enemy using their regular movement. They
        have to hit it diagonally, and can't take a step forward if the
        enemy is just in front of them.
        """
        is_valid_move = board.is_valid_square(*target)
        is_step_forward = (
            board.is_empty_square(target)
            and target == self._get_regular_walk()
        )
        is_valid_double_step_forward = (
            board.is_empty_square(target)
            and not self.moved
            and target == self._get_double_walk()
            and self.is_possible_target(board, self._get_regular_walk())
        )
        is_hitting_enemy = (
            self.is_enemy(board.get_square(*target))
            and target in self._get_diagonal_walks()
        )
        return is_valid_move and (
            is_step_forward or is_valid_double_step_forward or is_hitting_enemy
        )

    def get_squares_threatens(self, board, **kwargs):
        """Get all the squares which the pawn can attack."""
        for square in self._get_diagonal_walks():
            if board.is_valid_square(*square):
                yield square

    def get_valid_moves(self, board, **kwargs):
        """Yield the valid target positions the piece can travel to.

        The Pawn case is a special one - see is_possible_target's
        documentation for further details.
        """
        targets = (
            self._get_regular_walk(),
            self._get_double_walk(),
            *self._get_diagonal_walks(),
        )
        for target in targets:
            if self.is_possible_target(board, target):
                yield target

    def __str__(self):
        if self.color == Color.WHITE:
            return '♙'
        return '♟'


class Knight(Piece):
    """A classic Knight chess piece.

    Can travel to the nearest square not on the same rank, file, or
    diagonal. It is not blocked by other pieces: it jumps to the new
    location.
    """
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.directions.update({
            (-2, 1), (-1, 2), (1, 2), (2, 1),  # Upper part
            (-2, -1), (-1, -2), (1, -2), (2, -1),  # Lower part
        })

    def get_valid_moves(self, board, **kwargs):
        super().get_valid_moves(board, **kwargs)
        for add_row, add_col in self.directions:
            target = (add_row + self.row, add_col + self.col)
            if self.is_possible_target(board, target):
                yield target

    def __str__(self):
        if self.color == Color.WHITE:
            return '♘'
        return '♞'


class King(WalksStraightMixin, WalksDiagonallyMixin, Piece):
    """A classic King chess piece.

    Can travel one step, either diagonally or straight.
    It cannot travel to places where he will be threatened.
    """

    def _get_threatened_squares(self, board):
        """Yield positions in which the king will be captured."""
        enemy = Color.enemy_of(self.color)
        for piece in board.get_pieces(color=enemy):
            for move in piece.get_squares_threatens(board):
                yield move

    def is_possible_target(self, board, target):
        """Return True if the king's move is legal, False otherwise.

        The king should not move to a square that the enemy threatens.
        """
        is_regular_valid = super().is_possible_target(board, target)
        threatened_squares = self._get_threatened_squares(board)
        return is_regular_valid and target not in threatened_squares

    def get_valid_moves(self, board, **kwargs):
        super().get_valid_moves(board, **kwargs)
        for add_row, add_col in self.directions:
            target = (add_row + self.row, add_col + self.col)
            if self.is_possible_target(board, target):
                yield target

    def get_squares_threatens(self, board):
        """Get all the squares that this piece may move to.

        This method is especially useful to see if other kings fall
        into this piece's territory. To prevent recursion, this
        function returns all squares we threat even if we can't go
        there.

        For example, take a scenario where the White Bishop is in B2,
        and the Black King is in B3. The White King is in D3, but it is
        allowed to go into C3 to threaten the black king if the white
        bishop protects it.
        """
        for direction in self.directions:
            row, col = self.get_position()
            row = row + direction[0]
            col = col + direction[1]
            if board.is_valid_square(row, col):
                yield (row, col)

    def __str__(self):
        if self.color == Color.WHITE:
            return '♔'
        return '♚'

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

תרגילים

מלון קליפורניה

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

לכל חדר במלון קליפורניה נתונים כמפורט:

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

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

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

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

שחמט גנואים

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

בשחמט גנואים רוחבו של הלוח הוא 11 משבצות ואורכו 10 משבצות.

בשורה 1, בעמודה ז (או g בגרסה האנגלית) מוצב גנו, ובעמודות ח ו־ט (h ו־i באנגלית) מוצבים גמלים.
בשורה 10, בעמודה ה (או e בגרסה האנגלית) מוצב גנו, ובעמודות ג ו־ד (c ו־d באנגלית) מוצבים גמלים.

בשורה 1, בעמודה ה (או e בגרסה האנגלית) מוצבת המלכה, ובעמודות ג ו־ד (c ו־d באנגלית) מוצבים רצים.
בשורה 10, בעמודה ז (או g בגרסה האנגלית) מוצבת המלכה, ובעמודות ח ו־ט (h ו־i באנגלית) מוצבים רצים.

שאר הכלים מסודרים באותו הסדר.

לוח שחמט ערוך למשחק שחמט גנואים.
מקור: ויקיפדיה.

גמלים נעים בצורה דומה לפרשים: 3 צעדים אנכית ואז צעד אחד אופקית, או 3 צעדים אופקית ואז צעד אחד אנכית.
גנואים יכולים לבחור בכל תור אם הם רוצים לנוע כגמל או כפרש.
שני כלי המשחק, גמל וגנו, יכולים לדלג מעל כלים אחרים מבלי להיחסם.
תוכלו להשתמש בתווים ⛀ ו־⛂ כדי לייצג גמלים ובתווים ⛁ ו־⛃ כדי לייצג גנואים.

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

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

תנועה אופיינית לחד־קרן.
מקור: ויקיפדיה.

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