א.18 — מחלקות

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

כל שחקן שנוצר מהתבנית הוא אובייקט — Object בפני עצמו: לדנה יכולים להיות 10 נקודות ולנועם 20, אף ששניהם נוצרו מאותה מחלקה. הנתונים של האובייקט נקראים מאפיינים — Attributes, והפונקציות שפועלות עליו נקראות מתודות — Methods; למשל, מתודת תנועה יכולה לשנות את ערכי המיקום שלו. דרך ארגון זו של קוד נקראת תכנות מונחה עצמים, והיא תלווה אותנו בכל הספר: דמויות ב־Pygame, רשתות נוירונים ב־PyTorch וסוכנים בלמידת חיזוק יוגדרו כולם כמחלקות.

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

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

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

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

הגדרת מחלקה ואתחול אובייקט

נגדיר מחלקה המייצגת נקודה ונקבע אילו נתונים תקבל כל נקודה חדשה. מגדירים מחלקה באמצעות class ונקודתיים, ובגופה כותבים את המתודות. הפעולה __init__ מאתחלת אובייקט חדש: פייתון קוראת לה באופן אוטומטי בכל פעם שיוצרים אובייקט מהמחלקה, ומעבירה אליה את הערכים שנכתבו בסוגריים. לשמה שני קווים תחתונים בכל צד. בדוגמה, Point(3, 5) יוצרת נקודה חדשה ומפעילה את __init__ עם x=3 ו־y=5:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 5)
print(p.x, p.y)
p.x = 8
print(p.x, p.y)

פלט

3 5
8 5

self מציין את האובייקט שעליו פועלת המתודה. self.x הוא מאפיין שלו; x הוא הפרמטר שנמסר לאתחול. השורה self.x = x שומרת אפוא את הערך שהתקבל בתוך האובייקט, כדי שיהיה זמין גם אחרי שהאתחול הסתיים. לכל אובייקט מאפיינים משלו. הגישה אליהם נעשית באמצעות נקודה, למשל p.x, ואפשר גם לשנות אותם כך, כפי שרואים בהשמה p.x = 8.

כדי לראות שהנתונים שייכים לכל אובייקט בנפרד, ניצור נקודה נוספת:

q = Point(1, 2)
p.x = 9
print(p.x, p.y)
print(q.x, q.y)

פלט

9 5
1 2

עדכון p.x אינו משנה את q.x. בזמן האתחול של p, הפרמטר self מציין את p; בזמן האתחול של q, הוא מציין את q.

מתודות — פעולות של אובייקט

עד כאן המחלקה רק שומרת נתונים. כדי שנקודה תוכל לשנות את מיקומה בעצמה, נוסיף לה פעולת תנועה. פעולה כזו היא מתודה, והיא נכתבת באמצעות def בתוך המחלקה, כמו פונקציה רגילה. הפרמטר הראשון הוא self, אבל בקריאה דרך האובייקט לא מעבירים אותו במפורש: בקריאה p.move(2, -1) פייתון מציבה את p ב־self בעצמה, ולכן המתודה יודעת את מיקומה של איזו נקודה לשנות.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def move(self, dx=0, dy=0):
        self.x += dx
        self.y += dy

p = Point(3, 5)
p.move(2, -1)
p.move(dy=3)
print(p.x, p.y)

פלט

5 7

הנקודה התחילה ב־(3, 5), הקריאה הראשונה הזיזה אותה ל־(5, 4), והקריאה השנייה הוסיפה 3 לציר y בלבד. המחלקה מוזחת רמה אחת, וגוף כל מתודה רמה נוספת. בדוגמה יש ארבעה רווחים לפני def ושמונה לפני הפקודות שבתוכה.

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

מאפיין מחלקה

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

class Player:
    max_lives = 3

    def __init__(self, name):
        self.name = name
        self.lives = Player.max_lives

first = Player("Dana")
second = Player("Noam")
first.lives -= 1
print(Player.max_lives)
print(first.lives, second.lives)

פלט

3
2 3

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

Player.max_lives = 5
print(first.max_lives, second.max_lives)
print(first.lives, second.lives)

פלט

5 5
2 3

המאפיינים first.lives ו־second.lives נשארו כפי שהיו: באתחול נשמר בכל אחד מהם ערך מספרי משלו. שינוי ברירת המחדל במחלקה אינו מעדכן אותם בדיעבד.

רשימה של אובייקטים

במשחק יש בדרך כלל כמה שחקנים, ונוח לשמור את כולם ברשימה אחת. אובייקט הוא ערך כמו כל ערך אחר, ולכן אפשר לשים אובייקטים ברשימה, לעבור עליהם בלולאה ולפנות לאיבר לפי אינדקס, בדיוק כפי שלמדנו בפרק א.8. אפשר ליצור את האובייקטים תחילה, לשמור אותם במשתנים ולהכניס את המשתנים לרשימה, ואפשר גם ליצור אובייקט חדש ישירות בתוך הקריאה ל־append; בדוגמה מוצגות שתי הדרכים. כל איבר ברשימה הוא אובייקט שלם, ולכן פונים למאפייניו באמצעות נקודה, כמו player.name:

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

p1 = Player("Dana", 10)
p2 = Player("Noam", 25)
players = [p1, p2]
players.append(Player("Yael", 15))
for player in players:
    print(player.name, player.score)
print(players[1].name)

פלט

Dana 10
Noam 25
Yael 15
Noam

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

def find_player(players, name):
    for player in players:
        if player.name == name:
            return player
    return None

found = find_player(players, "Yael")
print(found.score)
print(find_player(players, "Omer"))

פלט

15
None

באותה דרך מוצאים את השחקן עם הניקוד הגבוה ביותר: שומרים את האיבר הראשון כמועמד, ומחליפים אותו בכל פעם שנמצא שחקן עם ניקוד גבוה יותר. max ו־sorted המובנות אינן יודעות להשוות אובייקטים של Player בלי הנחיה, ולכן כאן משווים את המאפיין בעצמנו; בסעיף הפעולות המיוחדות נראה איך ללמד אותן להשוות שחקנים:

best = players[0]
for player in players:
    if player.score > best.score:
        best = player
print(best.name, best.score)

פלט

Noam 25

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

פעולות מיוחדות: תצוגה והשוואה

אם נדפיס נקודה באמצעות print(p), פייתון אינה יודעת איך להציג אותה ותדפיס רק את שם המחלקה וכתובת בזיכרון. גם ההשוואה p == q בין שתי נקודות עם אותן קואורדינטות תחזיר False כברירת מחדל, כי מדובר בשני אובייקטים שונים. אפשר להגדיר כיצד אובייקטים שלנו יוצגו וכיצד תיבדק השוואה ביניהם באמצעות מתודות מיוחדות, ששמן מוקף בשני קווים תחתונים, כמו __init__. __str__ קובעת את הטקסט שיוצג בהדפסת אובייקט, וחייבת להחזיר מחרוזת. __eq__ מגדירה את משמעות ההשוואה ==, ומקבלת את האובייקט השני כפרמטר other.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f"({self.x}, {self.y})"

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x == other.x
                and self.y == other.y)

p = Point(3, 5)
q = Point(3, 5)
print(p)
print(p == q)
print(p is q)

פלט

(3, 5)
True
False

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

השוואת גודל: __lt__ ו־__gt__

באותו אופן אפשר להגדיר גם את אופרטורי הגודל. __lt__ (less than) מגדירה את <, ו־__gt__ (greater than) מגדירה את >; שתיהן מקבלות את האובייקט השני כ־other ומחזירות ערך בוליאני. בסעיף „רשימה של אובייקטים” ראינו ש־max ו־sorted אינן יודעות להשוות שחקנים. ברגע שהמחלקה מגדירה __lt__, פייתון יודעת מהו „קטן מ־” בין שני שחקנים, ופונקציות אלה עובדות עליהם ישירות:

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

    def __lt__(self, other):
        return self.score < other.score

    def __gt__(self, other):
        return self.score > other.score

p1 = Player("Dana", 10)
p2 = Player("Noam", 25)
print(p1 < p2)
print(p1 > p2)

players = [p1, p2, Player("Yael", 15)]
print(max(players).name)
for player in sorted(players):
    print(player.name, player.score)

פלט

True
False
Noam
Dana 10
Yael 15
Noam 25

sorted השתמשה ב־__lt__ כדי לסדר את השחקנים מהניקוד הנמוך לגבוה, ו־max החזירה את השחקן עם הניקוד הגבוה ביותר, בלי הלולאה שכתבנו קודם.

הפעולות המיוחדות העיקריות

מתודה מתי פייתון מפעילה אותה
__init__ יצירת אובייקט חדש
__str__ print(x) או str(x)
__repr__ ייצוג למפתח, למשל בהצגת רשימה
__eq__ x == y
__lt__, __gt__ x < y, x > y
__le__, __ge__ x <= y, x >= y
__add__, __sub__ x + y, x - y
__call__ x() — קריאה לאובייקט כפונקציה

הרשימה המלאה של המתודות המיוחדות מופיעה בתיעוד הרשמי של פייתון: Special method names.

אובייקט שאפשר לקרוא לו

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

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor

double = Multiplier(2)
print(double(7))

פלט

14

כימוס — מאפיינים מוגנים ופרטיים

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

  • קו תחתון אחד בתחילת השם, כגון _protected, מסמן מאפיין מוגן — Protected. זו הצהרה של המתכנת שאין לפנות אליו מחוץ למחלקה; פייתון אינה מגבילה את הגישה בפועל.
  • שני קווים תחתונים בתחילת השם, כגון __private, מסמנים מאפיין פרטי — Private. פייתון משנה את שמו בתוך האובייקט ל־_Test__private (שם המחלקה נוסף בתחילתו), ולכן הפנייה הרגילה ob.__private מחוץ למחלקה נכשלת בשגיאה. הגישה עדיין אפשרית דרך השם המלא, אך אין לעשות זאת. בדוגמה, המחלקה Test מגדירה מאפיין ציבורי, מאפיין מוגן ומאפיין פרטי, ומציעה מתודות לקריאה ולעדכון של הפרטי:
class Test:
    def __init__(self):
        self.public = 3
        self._protected = 4
        self.__private = 5
    def get_private(self):
        return self.__private
    def set_private(self, value):
        self.__private = value

ob = Test()
print(ob.public, ob._protected)
print(ob.get_private())
print(ob._Test__private)

פלט

3 4
5
5

הפנייה ob._protected עובדת, כי הקו התחתון הוא רק סימון. הפנייה ob.__private הייתה נכשלת, ולכן קוראים את הערך דרך get_private או דרך השם המלא _Test__private. השמה לשם __private מחוץ למחלקה אינה מעדכנת את המאפיין הפרטי אלא יוצרת מאפיין חדש ונפרד בשם זה, ורק המתודה set_private מעדכנת את הערך האמיתי:

ob.__private = 10
print(ob.__private, ob.get_private())

ob.set_private(10)
print(ob.get_private())

פלט

10 5
10

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

class Account:
    def __init__(self):
        self._balance = 0
    def deposit(self, amount):
        if amount < 0:
            raise ValueError("Negative deposit")
        self._balance += amount
    def balance(self):
        return self._balance

account = Account()
account.deposit(50)
print(account.balance())

פלט

50

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

פעולה סטטית

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

class ScoreRules:
    @staticmethod
    def add_bonus(score, bonus=2):
        return score + bonus

print(ScoreRules.add_bonus(10))

פלט

12