א.19 — ירושה והכלה

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

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

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

פתיחת המחברת ב־Colab · השיעור וההרצאה באתר הקורס

מחברות האוניברסיטה הפתוחה — 12-תכנות מונחה עצמים

הגדרת מחלקה יורשת

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

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

    def describe(self):
        return f"Player: {self.name}"

class HumanPlayer(Player):
    pass

player = HumanPlayer("Dana")
print(player.describe())

פלט

Player: Dana

pass היא פקודה שאינה עושה דבר, ומשמשת כאן כגוף חוקי למחלקה שאין בה עדיין תוספות. אף על פי שב־HumanPlayer לא הוגדרה describe, הקריאה player.describe() מצליחה: כשמתודה אינה נמצאת במחלקה של האובייקט, פייתון מחפשת אותה במחלקה המורישה.

הרחבת האתחול באמצעות super

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

class ComputerPlayer(Player):
    def __init__(self, name, level):
        super().__init__(name)
        self.level = level

bot = ComputerPlayer("Bot", 2)
print(bot.name, bot.level)

פלט

Bot 2

בירושה הפשוטה הזאת אפשר גם לכתוב Player.__init__(self, name). בקריאה הישירה דרך שם המחלקה מעבירים self במפורש.

דריסת מתודה

השחקן הממוחשב ירש את describe מ־Player, אך התיאור הזה אינו מזכיר את רמת הקושי שלו. כדי לשנות פעולה שהתקבלה בירושה, מגדירים במחלקה היורשת מתודה באותו שם; פעולה זו נקראת דריסה — Override, והמתודה החדשה היא שתופעל עבור אובייקטים של המחלקה היורשת. אין חובה לכתוב את הפעולה מאפס: אפשר לקרוא למימוש המקורי באמצעות super() ולהרחיב את התוצאה.

class ComputerPlayer(Player):
    def __init__(self, name, level):
        super().__init__(name)
        self.level = level

    def describe(self):
        text = super().describe()
        return f"{text}, level: {self.level}"

bot = ComputerPlayer("Bot", 2)
print(bot.describe())

פלט

Player: Bot, level: 2

אותה קריאה, התנהגות לפי האובייקט

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

players = [HumanPlayer("Dana"), bot]
for player in players:
    print(player.describe())

פלט

Player: Dana
Player: Bot, level: 2

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

ירושה מרובה

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

class Movable:
    def move(self):
        return "Moving"

class Drawable:
    def draw(self):
        return "Drawing"

class GameObject(Movable, Drawable):
    pass

obj = GameObject()
print(obj.move())
print(obj.draw())

פלט

Moving
Drawing

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

ירושה לעומת הכלה

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

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

class Classroom:
    def __init__(self):
        self.students = []

    def add(self, student):
        self.students.append(student)

room = Classroom()
room.add(Student("Dana"))
room.add(Student("Noam"))
for student in room.students:
    print(student.name)

פלט

Dana
Noam

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