שיתוף ממשק באמצעות ירושה

14/02/2019

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

1. מהו ממשק

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

class AIPlayer:
    def game_over(self, win):
        if win:
            self._score.win("AI")

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

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

2. למה שנרצה לשתף ממשק

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

נניח שנרצה להוסיף שחקן אנושי. גם שם נצטרך פונקציה בשם game_over:

    def game_over(self, win):
        if win:
            self._score.win(self.name)

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

3. ירושה

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

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

class BasePlayer:
    def __init__(self, name, score):
        self.name = name
        self._score = score

    def game_over(self, win):
        if win:
            self._score.win(self.name)

ועכשיו מגיע החלק הכיפי - בגלל שהגדרנו במחלקה נפרדת את הממשק נוכל "לרשת" מהמחלקה BasePlayer ולקבל במתנה את כל הפונקציות שלה. זה נראה כך:

class AIPlayer(BasePlayer): 
    def __init__(self, score):
        self._score = score

score = Score()

p = AIPlayer(score)
p.game_over(True)

print(score.data)

ניסיון להפעיל את הקוד בצורה כזאת ייכשל:

Traceback (most recent call last):
  File "a.py", line 68, in <module>
    p.game_over(True)
  File "a.py", line 32, in game_over
    self._score.win(self.name)
AttributeError: 'AIPlayer' object has no attribute 'name'

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

class AIPlayer(BasePlayer): 
    def __init__(self, score):
        self.name = "AI"
        self._score = score

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

מה לגבי הפונקציה __init__? במחלקה BasePlayer היא נראית כך:

def __init__(self, name, score):
    self.name = name
    self._score = score

ובמחלקה AIPlayer היא נראית כך:

def __init__(self, score):
    self.name = "AI"
    self._score = score

רואים את הדימיון? האמת שהוא לא מקרי. שני השדות name ו _score נמצאים בשימוש בפונקציה game_over ולכן שתי המחלקות צריכות לאתחל את השדות. אבל בגלל המבנה המאוד דומה נוכל לחסוך את הקוד הכפול אם נקרא לאחת מתוך השניה. אם שם הפונקציה היה שונה בכל מחלקה זאת לא היתה בעיה - היינו פשוט כותבים את השם של הפונקציה ממחלקת האב כדי להפעיל אותה. כששם הפונקציה זהה אנחנו משתמשים במילה המיוחדת super כדי לקרוא לפונקציה ממחלקת האב וזה נראה כך:

class AIPlayer(BasePlayer): 
    def __init__(self, score):
        super().__init__("AI", score)

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

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

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

  1. נוסיף בכותרת המחלקה את ההצהרה על הירושה מ BasePlayer.

  2. נמחק את הפונקציה game_over - כי אנחנו מקבלים אותה עכשיו מ BasePlayer.

  3. נעדכן את הפונקציה __init__ כך שתפעיל את __init__ של BasePlayer.

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

class HumanPlayer(BasePlayer):
    def __init__(self, name, score):
        super().__init__(name, score)

5. אזהרה: דריסת שדות מידע

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

class A:
    def __init__(self):
        self.x = 10
        self.y = 20

class B(A):
    def print_x_and_y(self):
        print(self.x, self.y)

b = B()
b.print_x_and_y()

הקוד מדפיס את שני המספרים 10 ו-20. סדר הפעלת הקוד הוא:

  1. פייתון יוצר אוביקט חדש מהמחלקה B ומחפש במחלקה את הפונקציה __init__.

  2. הפונקציה לא קיימת על B ולכן פייתון ממשיך לחפש אותה על מחלקת האב A

  3. פייתון מפעיל את __init__ שמצא על A וזו מאתחלת את המשתנים x ו y על self כלומר על הדבר שמחוץ למחלקה שמור במשתנה b.

  4. הפונקציה print_x_and_y מדפיסה את הערכים של self.x ו self.y. זה בדיוק אותו self כמו ש __init__ קיבלה ולכן יודפסו בדיוק הערכים ש __init__ שמרה.

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

class A:
    def __init__(self):
        self.x = 10
        self.y = 20

    def print_my_data(self):
        print(self.x, self.y)

class B(A):
    def __init__(self):
        super().__init__()
        self.x = 54

    def print_x(self):
        print(self.x)

b = B()
b.print_x()

b.print_my_data()

אם נסתכל רק על המחלקה B נצא עם התחושה ש print_x עושה בדיוק מה שהיא צריכה לעשות, אבל אז נסתכל על המחלקה A כדי להבין מה print_my_data ואנו עשויים להיות מופתעים שהפונקציה מדפיסה את הערך 54 בתור x, למרות שב __init__ של A נתנו ל x את הערך 10.

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

class A:
    def __init__(self):
        self.__x = 10
        self.__y = 20

    def print_my_data(self):
        print(self.__x, self.__y)

class B(A):
    def __init__(self):
        super().__init__()
        self.x = 54

    def print_x(self):
        print(self.x)

b = B()
b.print_x()

b.print_my_data()

6. סיכום: יתרונות וחסרונות

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

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

בנוסף דריסת משתנים בטעות היא בעיה אמיתית בירושה בפייתון ולכן בכל מקרה שאתם כותבים קוד במחלקת בסיס (או מחלקת אב) מומלץ להשתמש ב __ לפני שמות שדות המידע אלא אם כן אתם מעוניינים לשתף את המידע עם המחלקות היורשות.