• בלוג
  • מימוש תבנית העיצוב Observer בפייתון

מימוש תבנית העיצוב Observer בפייתון

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

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

בואו נראה מתי זה טוב ואיך בונים את זה בפייתון.

1. דוגמא מהחיים: חיבור קוד טיפול באירוע לכפתור

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

כך בספריית PyQt המחלקה QPushButton מייצגת כפתור אבל היא לא כוללת את הקוד שיופעל כשמישהו לוחץ על הכפתור. אנחנו גם לא רושמים קוד זה באמצעות ירושה ודריסת מתודה כלשהי. במקום אנחנו "מחברים" את קוד הטיפול לאירוע דרך פונקציה בשם connect. הקוד הבא מציג כפתור שכשלוחצים עליו מודפסת המילה Ouch לשורת הפקודה:

from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
import sys

app = QApplication(sys.argv)
btn = QPushButton("click here")

btn.clicked.connect(lambda: print('Ouch!'))
btn.show()

app.exec_()

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

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

2. מימוש מחלקה אבסטרקטית MObservable

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

class MObservable:
    def __init__(self):
        self.__listeners = defaultdict(list)

    def listen(self, event, handler):
        self.__listeners[event].append(handler)
        return lambda: self.__listeners[event].remove(handler)

    def update(self, event, *args):
        [l(*args) for l in self.__listeners[event]]

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

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

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

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

class Person(MObservable):
    def __init__(self, name):
        super().__init__()
        self.name = name

    def set_name(self, new_name):
        self.name = new_name
        self.update('rename', new_name)

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

נסיים בקוד הבא שיוצר אוביקט Person, מאזין לשינוי שם ואז מפסיק להאזין ומשנה שוב את השם:

def print_new_name(new_name):
    print(f"Name changed to: {new_name}")

p = Person('foo')
unsubscribe = p.listen('rename', print_new_name)
p.set_name('bar')

unsubscribe()
p.set_name('buz')

והפלט:

Name changed to: bar

שינוי השם הראשון זוהה והודפס. בשינוי השם השני לא היה אף מאזין ולכן לא היתה הדפסה.

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

class NameChangeCounter:
    def __init__(self, person):
        self.rename_count = 0
        person.listen('rename', self.inc)

    def inc(self, _):
        self.rename_count += 1

def print_new_name(new_name):
    print(f"Name changed to: {new_name}")

p = Person('foo')
counter = NameChangeCounter(p)

unsubscribe = p.listen('rename', print_new_name)
p.set_name('bar')

unsubscribe()
p.set_name('buz')

print(f"Rename count = {counter.rename_count}")

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

4. דגשים למימוש ב Python

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

    def update(self, event, *args):
        [l(*args) for l in self.__listeners[event]]

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

מצד שני פייתון מנהלת זיכרון במנגנון של Reference Count. זה אומר שהצבעות מעגליות מובילות לזליגות זיכרון. מאחר וכל אוביקט Observable שומר מצביע לכל המאזינים שלו, יש לשים לב ולהיזהר לא לשמור מצביע הפוך מהמאזינים בחזרה ל Observable. בדוגמא שראינו אם הקוד של NameChangeCounter היה כולל שמירה של Person היתה לנו זליגת זיכרון. אם אתם חייבים לשמור הצבעה מעגלית כזו וודאו שאתם משתמשים ב Weak Reference.