בשבוע שעבר למדנו מה הן מחלקות, וסקרנו טכניקות שונות הנוגעות לכתיבת קוד בעזרתן.
למדנו כיצד ליצור מחלקה, שהיא בעצם תבנית שמתארת את התכונות ואת הפעולות השייכות לכל מופע שנוצר ממנה.
הסברנו מהי פעולת אתחול (__init__
), שרצה מייד עם יצירתו של מופע חדש, ודיברנו על פעולות קסם נוספות.
ראינו כיצד מגדירים משתני מחלקה, כאלו שמשותפים לכל המופעים,
ודיברנו גם על תכונות ופעולות פרטיות ומוגנות, טכניקה שמאפשרת לנו להחליט אילו תכונות ופעולות אנחנו חושפים למשתמשים שלנו.
לסיום, דיברנו גם על רעיון ההכלה, שמאפשר לנו להשתמש במופעי מחלקה אחת בתוך מופעים של מחלקה אחרת.
השבוע נרחיב את ארגז הכלים שלנו, ונדבר על רעיונות נוספים שקשורים לעולם המחלקות.
השבוע נתמקד ביצירת אתר השירים החדש והנוצץ "שירומת".
נתחיל בכתיבת מחלקה המייצגת שיר עבור האתר.
לכל שיר יש שם, מילים ורשימת יוצרים.
כדי לנהל את רשימת היוצרים, צרו את הפעולות add_artist ו־remove_artist שיוסיפו ויסירו אומנים בהתאמה.
לשיר תהיה גם פעולה שנקראת count_words שתחזיר את מספר המילים בשיר.
כמו כן, לכל שיר יהיה מונה שיספור כמה פעמים הוא הודפס.
הדפסת שיר תיראה כך:
נסו לממש את מחלקת השיר בעצמכם.
חשוב!
פתרו לפני שתמשיכו!
נציג את הפתרון, ונסביר את המימוש מייד לאחר מכן:
In [ ]:
class Song:
"""Represent a Song in our lyrics site.
Parameters
----------
name : str
The name of the song.
lyrics : str
The lyrics of the song.
artists : list of str or str, optional
Can be either a list, or a string separated by commas.
Attributes
----------
name : str
The name of the song.
lyrics : str
The lyrics of the song.
_views : int
Views counter, which indicates how many times the users printed
a specific song.
_artists : list of str
A list of the song's artists.
"""
def __init__(self, name, lyrics, artists=None):
self.name = name
self.lyrics = lyrics
self._views = 0
self._artists = self._reformat_artists(artists)
def _reformat_artists(self, artists):
if isinstance(artists, str):
return self._listify_artists_from_string(artists)
elif artists is None:
return []
return artists
def _listify_artists_from_string(self, artists):
"""Create list of artists from string."""
for possible_split_token in (', ', ','):
if possible_split_token in artists:
return artists.split(possible_split_token)
return [artists]
def add_artist(self, artist):
"""Add an artist to the song's artists list."""
self._artists.append(artist)
def remove_artist(self, artist):
"""Remove an artist from the song's artists list."""
if len(self._artists) <= 1 or artist not in self._artists:
return False
self._artists.remove(artist)
def get_artists(self):
"""Return the song's artists list."""
return self._artists
def count_words(self):
"""Return the word count in the song's lyrics."""
return len(self.lyrics.split())
def __str__(self):
self._views = self._views + 1
artists = ', '.join(self.get_artists())
title = f'"{self.name}" / {artists}'
separator = "-" * len(title)
return (
f"{title}\n"
+ f"{separator}\n"
+ f"{self.lyrics}\n"
+ f"{separator}\n"
+ f"Seen: {self._views} time(s)."
)
lyrics = """
Her Majesty's a pretty nice girl
But she doesn't have a lot to say
Her Majesty's a pretty nice girl
But she changes from day to day
I want to tell her that I love her a lot
But I gotta get a bellyful of wine
Her Majesty's a pretty nice girl
Someday I'm going to make her mine, oh yeah
Someday I'm going to make her mine
""".strip()
her_majestys = Song("Her Majesty's", lyrics, "The Beatles")
print(her_majestys)
print(her_majestys.count_words())
פעולת ה־__init__
של המחלקה Song
מגדירה את השם ואת הליריקה של השיר.
היא גם מאתחלת את מספר הצפיות בשיר ל־0, ויוצרת את רשימת האומנים של השיר.
כדי להקל על המשתמש במחלקה, אפשרנו להעביר את שמות האומנים כרשימה, כמחרוזת מופרדת בפסיקים, או לא להכניס אומנים כלל.
עיבוד השמות לכדי צורה אחידה, רשימה של מחרוזות, יתבצע בפעולה _reformat_artists
, שתחליט כיצד לפעול לפי סוג המשתנה:
אם הועברה לפרמטר האומנים מחרוזת, נשלח אותה ל־_listify_artists_from_string
שתיצור מהם רשימה. אם הועבר None
, נחזיר רשימה ריקה.
הפעולה count_words מפצלת את מילות השיר לרשימה, ומחזירה את מספר האיברים ברשימה.
בקריאה ל־__str__
אנחנו מגדילים את ערכה של התכונה _views ב־1. כך נספור את הפעמים שביקשו להדפיס שיר.
אקרוסטיכון, כזכור לנו משבועות קודמים, הוא שיר ששרשור האות הראשונה של כל שורה בו יוצר ביטוי קריא.
כחלק מהטכנולוגיה המשוכללת של "שירומת", נרצה ליצור מחלקה שמייצגת אקרוסטיכון.
כשחושבים על זה, אקרוסטיכון הוא סוג של שיר, כך שתכונות המחלקה ופעולות המחלקה אמורות להיות זהות לאלו של Song.
למעשה, בבואנו לבנות את מחלקת האקרוסטיכון Acrostic נגלה במהרה שהיא העתק מדויק כמעט של מחלקת Song שכבר כתבנו.
חסרה לנו רק הפעולה get_acrostic, נניח, שתחזיר לנו את האקרוסטיכון שנמצא בשיר.
בהינתן המצב הזה, איך יהיה נכון לכתוב את מחלקת אקרוסטיכון?
הפתרון הראשון שקופץ לראש הוא פשוט להוסיף למחלקה Song את הפעולה "get_acrostic".
זה, כמובן, לא מתאים כל כך.
ברוב השירים אין אקרוסטיכון, והפעולה הזו לא מתאימה להם ולא שייכת למחלקה הכללית יותר, Song.
הוספת הפעולה למחלקה Song גם תיצור עומס מיותר במחלקה – מה יקרה כשנרצה לתמוך בחמשירים? או בשירים מעגליים?
עד מהרה כל מופע יכלול פעולות רבות שלא קשורות אליו, והשימוש במחלקה יהפוך מסורבל מאוד.
הפתרון השני, אם כך, הוא להעתיק את הקוד של מחלקת Song וליצור מחלקה כמעט זהה בשם Acrostic.
במחלקה יהיו בדיוק כל התכונות והפעולות שנמצאות תחת Song, וכן את הפעולה "get_acrostic".
במקרה הזה מדובר בשכפול קוד – כך שנראה שאף פתרון זה אינו מושלם.
בכל פעם שנרצה לשנות משהו במחלקה Song נצטרך לשנות את שתי המחלקות,
ועד מהרה פעולת התחזוקה של הקוד הכפול תהפוך להיות מפרכת, לא נעימה ומקור לבאגים נוראיים.
נסכם: אנחנו מחפשים מנגנון או טכניקה שיאפשרו לנו לשכפל את התכונות והפעולות של מחלקה אחת למחלקה אחרת.
השכפול צריך להשאיר את המחלקה המקורית עובדת ועצמאית, וליצור מחלקה חדשה עם תכונות ופעולות שזהות לאלו של המחלקה המקורית.
הפתרון לבעיה שתיארנו בהקדמה נקרא "ירושה".
נגדיר מחלקת Acrostic ריקה, שיורשת ממחלקת Song.
כשאנחנו אומרים דבר כזה, אנחנו מתכוונים שהמחלקה הריקה Acrostic תקבל את כל התכונות והפעולות שנמצאות בתוך מחלקת Song.
התחביר של ירושה הוא פשוט:
בסוף השורה שבה אנחנו מגדירים את המחלקה וקובעים את שמה, נוסיף סוגריים שבהם נציין את שם המחלקה שממנה אנחנו רוצים לרשת.
In [ ]:
class Acrostic(Song):
pass
וכמו קסם, בעזרת שורה אחת פשוטה שכפלנו את התכונות והפעולות של מחלקת Song.
ניצור מופע חדש של אקרוסטיכון בדיוק באותה הצורה שבה יצרנו מופע של שיר, רק שהפעם נשתמש במחלקת Acrostic במקום במחלקת Song.
זה אפשרי כיוון ש־Acrostic ירשה את פעולת __init__
של Song.
In [ ]:
lyrics = """A boat, beneath a sunny sky
Lingering onward dreamily
In an evening of July -
Children three that nestle near,
Eager eye and willing ear,
Pleased a simple tale to hear -
Long has paled that sunny sky:
Echoes fade and memories die:
Autumn frosts have slain July.
Still she haunts me, phantomwise,
Alice moving under skies
Never seen by waking eyes.
Children yet, the tale to hear,
Eager eye and willing ear,
Lovingly shall nestle near.
In a Wonderland they lie,
Dreaming as the days go by,
Dreaming as the summers die:
Ever drifting down the stream -
Lingering in the golden gleam -
Life, what is it but a dream?"""
In [ ]:
song = Acrostic("A Boat, Beneath a Sunny Sky", lyrics, "Lewis Carroll")
print(song)
print(song.count_words())
בדוגמה שלמעלה ראינו כיצד יוצרים מופע חדש של שיר בעזרת המחלקה Acrostic.
כשמחלקה יורשת ממחלקה אחרת, אפשר להתייחס למחלקה היורשת כאילו כל התכונות של מחלקת־העל (superclass), המחלקה שממנה היא יורשת, הועתקו אליה.
כיוון ש־Acrostic ירשה את כל הפעולות ממחלקת־העל שלה, Song, ובהן גם את הפעולה __init__
,
יצירת מופע חדש באמצעות קריאה ל־Acrostic קוראת למעשה לפעולה Song.__init__
.
פייתון לא מסתפקת בזה, ומעתיקה עבורנו גם את התיעוד של מחלקת־העל ושל הפעולות שבה:
In [ ]:
help(Acrostic)
ירושה מאפשרת לנו להוסיף בקלות פעולות ל־Acrostic, מעבר לאלו שהיא ירשה ממחלקת Song.
כדי לעשות זאת, פשוט נגדיר את הפעולה הרלוונטית במחלקה היורשת.
נממש את הפעולה שלשמה התחלנו את כל העניין, get_acrostic, שתשרשר את האות הראשונה מכל שורה בשיר:
In [ ]:
class Acrostic(Song):
def get_acrostic(self):
song_lines = self.lyrics.splitlines()
first_chars = (line[0] for line in song_lines if line)
return ''.join(first_chars)
הפעולה get_acrostic אוספת את האות הראשונה מכל שורה, אם קיימת כזו, ומאחדת את כל האותיות שנאספו למחרוזת אחת:
In [ ]:
song = Acrostic("A Boat, Beneath a Sunny Sky", lyrics, "Lewis Carroll")
song.get_acrostic()
שימו לב - אומנם הפעולה קיימת במחלקה Acrostic, אך אין זה אומר שהיא קיימת במחלקה Song:
In [ ]:
song = Song("A Boat, Beneath a Sunny Sky", lyrics, "Lewis Carroll")
song.get_acrostic()
נוכל לראות שפייתון מבינה שמופע שנוצר מ־Acrostic הוא גם Acrostic, אבל הוא גם Song:
In [ ]:
song = Acrostic("A Boat, Beneath a Sunny Sky", lyrics, "Lewis Carroll")
print(isinstance(song, Song))
print(isinstance(song, Acrostic))
אבל לא להפך:
In [ ]:
song = Song("A Boat, Beneath a Sunny Sky", lyrics, "Lewis Carroll")
print(isinstance(song, Song))
print(isinstance(song, Acrostic))
מכאן שירושה היא חד־כיוונית: המחלקה היורשת מקבלת את כל התכונות והפעולות של מחלקת־העל, אבל לא להפך.
אם מחלקה א יורשת ממחלקה ב, מופע שנוצר ממחלקה א יכול להשתמש בתכונות ובפעולות שמוגדרות במחלקה ב.
למרות זאת, במקרה שכזה, מופע שנוצר ממחלקה ב לא יוכל להשתמש בתכונות ובפעולות שמוגדרות במחלקה א.
כשמשתמשים בירושה, נהוג שתת־המחלקה היורשת יכולה לגשת ולשנות גם תכונות פרטיות של מחלקת־העל שאותה היא יורשת.
לפי ויקיפדיה, ססטינה הוא שיר בעל מבנה נוקשה שמציית לכללים הבאים:
ממשו את המחלקה Sestina.
המחלקה תכיל את כל התכונות והפעולות של שיר רגיל, נוסף על הפעולה is_valid שתבדוק אם הסטטינה תקינה.
בבדיקתכם, התעלמו מהחוקים הנוגעים לבית האחרון.
הפעולה תחזיר True
אם כן ו־False
אם לא.
בדקו את הפעולה שלכם על "ססטינת ערפד" של ניל גיימן.
לפעמים נרצה לרשת ממחלקה מסוימת, אבל חלק מהפעולות או מהתכונות במחלקת־העל לא יתאימו לצרכים שלנו.
במקרים אלו כן נרצה לרשת ממחלקת־העל את תכונותיה ופעולותיה, אבל נגדיר מחדש תכונות ופעולות שאנחנו רוצים לשנות.
ניקח כדוגמה את מחלקת Instrumental.
קטע כלי (או שיר אינסטרומנטלי) הוא קטע מוזיקלי ללא שירה.
In [ ]:
class Instrumental(Song):
pass
ניצור מתוך המחלקה מופע עבור הקטע של Yiruma, יצירתו המהממת River Flows in You:
In [ ]:
song = Instrumental("River Flows in You", "", "Yiruma")
print(song)
בשלב הזה נגלה שפעולת ה־__str__
עושה פחות חסד עם קטעים כליים.
תעלול נחמד בירושה הוא היכולת לדרוס את הפעולה של מחלקת־העל בתת־מחלקה שיורשת ממנה.
בדריסה, אנחנו "מבטלים" את הפעולה של מחלקת־העל (היא לא תרוץ), ומחליפים אותה בפעולה חדשה שניצור עבור תת־המחלקה.
נדרוס את הפעולה __str__
ונממש צורת תצוגה שמתאימה יותר לקטעים כליים:
In [ ]:
class Instrumental(Song):
def __str__(self):
self._views = self._views + 1
artists = ', '.join(self.get_artists())
title = f'"{self.name}" / {artists}'
separator = "-" * len(title)
return f"{title}\n{separator}\nSeen: {self._views} time(s)."
In [ ]:
song = Instrumental("River Flows in You", "", "Yiruma")
print(song)
בתא שלמעלה הגדרנו מחדש את הפעולה __str__
שבמחלקה Instrumental.
ויתרנו על הצגת מילות השיר (כי אין כאלו) ועל הקווים המפרידים הסמוכים להם.
כמה נחמד!
עכשיו כשנדפיס את המופע, מי שתיקרא כדי להמיר את המופע למחרוזת היא הפעולה Instrumental.__str__
ולא Song.__str__
.
איך פייתון מחליטה לאיזו פעולה לקרוא?
השיטה היא תמיד לחפש תחילה במחלקה שממנה נוצר המופע.
אם לא מצאנו את הפעולה שם, נעבור לחפש אותה במחלקת־העל.
סדר המחלקות שבו נחפש את הפעולה קיבל את השם "סדר בחירת פעולות" (Method Resolution Order; או MRO).
אפשר לראות את סדר בחירת הפעולות של song אם נקרא ל־Instrumental.mro()
:
In [ ]:
Instrumental.mro()
זה אומר שכשאנחנו יוצרים מופע מהמחלקה Instrumental, פייתון תחפש פעולות ותכונות קודם כול אצלה, ורק אז במחלקה Song, הרי היא מחלקת־העל.
נשים לב שבראש השרשרת עומדת המחלקה object.
כשאנחנו מגדירים מחלקה "רגילה", ללא ירושה, פייתון אוטומטית מניחה שהיא יורשת ממחלקת object.
מחלקה זו מגדירה התנהגויות בסיסיות עבור המחלקה שיצרנו.
לדוגמה: הפעולה __init__
שמוגדרת ב־object מאפשרת לנו לאתחל מופעים חדשים מבלי שנגדיר __init__
במחלקה שלנו:
In [ ]:
class Example:
pass
In [ ]:
e = Example()
הפעולה __str__
שמוגדרת ב־object מאפשרת לנו לראות את הייצוג המכוער של אובייקט כשאנחנו מדפיסים אותו,
גם בלי לממש את __str__
במחלקה שלנו:
In [ ]:
print(e)
מקום נוסף לשיפור במחלקה Instrumental הוא פעולת ה־__init__
.
כיוון שלקטעים כליים אין מילים, הפרמטר השני שאנחנו מעבירים לפעולת האתחול (lyrics) הוא מיותר לחלוטין.
In [ ]:
song = Instrumental("Kiss the rain", "", "Yiruma")
print(song)
היה עדיף לו __init__
של המחלקה Instrumental היה קצת שונה מה־__init__
של המחלקה Song.
מה עושים? את הפעולה שהורשה דורסים!
נגדיר __init__
חדש ללא הפרמטר lyrics:
In [ ]:
class Instrumental(Song):
def __init__(self, name, artists=None):
self.name = name
self.lyrics = ""
self._views = 0
self._artists = self._reformat_artists(artists)
def __str__(self):
self._views = self._views + 1
artists = ', '.join(self.get_artists())
title = f'"{self.name}" / {artists}'
separator = "-" * len(title)
return f"{title}\n{separator}\nSeen: {self._views} time(s)."
בקוד שלמעלה דרסנו את הפעולה __init__
.
חדי העין מביניכם זיהו שהיא כמעט זהה לפעולת ה־__init__
של Song.
ההבדלים הם שלא מוגדר בה הפרמטר lyrics, ושתכונת המופע lyrics מוגדרת תמיד למחרוזת ריקה.
מצד אחד – השגנו את מה שרצינו. מצד שני – מטרת העל של ירושה הייתה מלכתחילה למנוע שכפול קוד.
נצמצם את כפילות הקוד בין Instrumental.__init__
לבין Song.__init__
–
במקום להעתיק את השורות שכתובות ב־Song.__init__
, נקרא לפעולת האתחול הזו מתוך Instrumental.__init__
:
In [ ]:
class Instrumental(Song):
def __init__(self, name, artists=None):
Song.__init__(self, name=name, lyrics="", artists=artists)
def __str__(self):
self._views = self._views + 1
artists = ', '.join(self.get_artists())
title = f'"{self.name}" / {artists}'
separator = "-" * len(title)
return f"{title}\n{separator}\nSeen: {self._views} time(s)."
מה יקרה כעת בעת יצירת מופע חדש של Instrumental?
Instrumental.__init__
תרוץ.Song.__init__
כאשר מועבר לה הפרמטר self באופן מפורש.Song.__init__
תרוץ, השורה self.name = name
שנמצאת בתוך הפעולה תתייחס למופע שיצרנו ממחלקת Instrumental.self
של הפעולה Song.__init__
את המופע שיצרנו במחלקת Instrumental.
עשינו פה טריק מוזר ואולי קצת מלוכלך.
פנינו ישירות לפעולת הקסם Song.__init__
, ו"השתלנו" בה את ה־self שמייצג את המופע הנוכחי.
למה עשינו זאת?
אם נקרא ל־Song()
, ייווצר אוטומטית מופע חדש של שיר "רגיל", והוא יהיה זה שייכנס לפרמטר self של Song.__init__
.
לעומת זאת, אם נקרא ישירות ל־Song.__init__()
, נוכל להעביר את הפרמטר self באופן מפורש, בעצמנו.
הטריק הזה מאפשר לנו להעביר לתוך הפרמטר self של Song.__init__
מופע שיצרנו בעזרת מחלקת Instrumental,
או במילים אחרות – הטריק הזה מאפשר לנו להפעיל את פעולת האתחול של Song עבור המופע שנוצר מ־Instrumental.
למה זה שימושי?
כיוון שאז אנחנו מנצלים את פעולת האתחול של Song שנראית כך:
In [ ]:
def __init__(self, name, lyrics, artists=None):
self.name = name
self.lyrics = lyrics
self._views = 0
self._artists = self._reformat_artists(artists)
באמצעות שורת הקוד שהוספנו ל־Instrumental שקוראת לפעולה הזאת, פעולת האתחול של Song,
אנחנו חוסכים את שכפול הקוד בתת־המחלקה וגורמים לכך שהפעולה של מחלקת־העל תפעל ותגדיר עבורנו את תכונות המחלקה.
זו דרך נוחה להגיד לפייתון "אמנם דרסנו את הפעולה של מחלקת־העל, אבל עדיין נרצה לבצע גם את מה שמחלקת־העל עושה".
זה עובד כיוון שהעברנו את self, המופע שלנו, לפעולה Song.__init__
,
מה שיגרור את הגדרתן של self.name
, self.lyrics
, self._views
ו־self._artists
עבור המופע שנוצר מ־Instrumental.
קריאה לפעולה שדרסנו במחלקת־העל היא טריק נפוץ ושימושי מאוד.
למעשה, נהוג להשתמש בו הרבה גם על פעולות שהן לא __init__
.
נוכל להחיל את אותו הטריק גם על __str__
, לדוגמה, ולחסוך את ההעלאה של self._views
ב־1:
ממשו בעצמכם את אותו תעלול עבור __str__
.
הפתרון מופיע בתא שלהלן.
חשוב!
פתרו לפני שתמשיכו!
In [ ]:
class Instrumental(Song):
def __init__(self, name, artists=None):
Song.__init__(self, name=name, lyrics="", artists=artists)
def __str__(self):
Song.__str__(self)
artists = ', '.join(self.get_artists())
title = f'"{self.name}" / {artists}'
separator = "-" * len(title)
return f"{title}\n{separator}\nSeen: {self._views} time(s)."
בתא שלמעלה אנחנו רואים איך משתמשים בפעולה של מחלקת־העל,
אבל עדיין מגדירים גם התנהגות עצמאית משל עצמנו.
כיוון שהטריק הזה נפוץ יחסית, פייתון נותנת בידינו דרך נוחה להתייחס למחלקת־העל, כשהפרמטר הראשון שנעביר לפעולה שבה הוא self.
במקום לכתוב בכל פעם את הביטוי המתיש Song.__init__(self)
, נוכל לכתוב super().__init__()
.
הפונקציה super מאפשרת לנו להעביר את המופע שיצרנו בתת־המחלקה, לתוך פעולה הנמצאת במחלקת־העל.
In [ ]:
class Instrumental(Song):
def __init__(self, name, artists=None):
super().__init__(name=name, lyrics="", artists=artists)
def __str__(self):
super().__str__()
artists = ', '.join(self.get_artists())
title = f'"{self.name}" / {artists}'
separator = "-" * len(title)
return f"{title}\n{separator}\nSeen: {self._views} time(s)."
In [ ]:
song = Instrumental("River Flows in You", "Yiruma")
print(song)
print(song)
חשוב להבין היטב את הרעיון של קריאה לפעולה במחלקת־העל מתוך הפעולה הדורסת בתת־מחלקה.
הטכניקה הזו מאפשרת לנו למנוע שכפול קוד, ואם בעתיד תשתנה מעט הפעולה במחלקת־העל, תת־המחלקה "תזכה" אוטומטית בשינויים האלו.
כידוע לכם בכפר הדרדסים יש הרבה דרדסים "רגילים", אבל יש גם כמה דרדסים מפורסמים, כמו דרדסבא, דרדסית ודרדשף.
לכל דרדס (Smurf) יש את התכונה name שמכילה את שמו, ואת הפעולות eat ו־sleep.
לדרדס המיוחד "דרדסאבא" (PapaSmurf) יש גם את הפעולה give_order, שמקבלת פעולה של דרדס רגיל ומפעילה אותו עליו.
ל"דרדסית" (Smurfette) יש את הפעולה kill_gargamel, שמקבלת כפרמטר מופע שנוצר ממחלקת Gargamel ומשנה את התכונה is_alive שבו ל־False
.
ל"דרדשף" (ChefSmurf) יש את הפעולה create_food, שמקבלת את שם המנה שהוא מכין וכמה "חתיכות" (slices) הוא יצר ממנה.
דרדסים ישנים 8 שעות בדיוק. דרדס שישן לא יכול לבצע פעולות אחרות אלא אם הוא דרדסבא, שהוא חזק ומהיר והוא דרדס והוא גם חזק.
דרדס יכול לאכול מזון רק אם ChefSmurf הכין אותו באמצעות הפעולה create_food, ורק אם נשארו עוד "חתיכות" מאותה מנה.
הפעולה eat של כל דרדס תקבל רשימה של מנות ותאכל מכולן. הרשימה יכולה להכיל כל מספר איברים שהוא.
בשנות ה־90 העליזות החלה להישמע ביקורת הולכת וגוברת על רעיון הירושה.
הביקורת העיקרית היא על כך שירושה יוצרת תלות בין מחלקת־העל לתתי־המחלקות שיורשות ממנה.
כאשר מחלקה יורשת ממחלקה אחרת, כל הפעולות והתכונות של מחלקת־העל נחשפות לתת־המחלקה.
נהוג שבתת־המחלקה אף מותר לשנות תכונות מוגנות של מחלקת־העל.
התנהגות זו מובילה לצימוד גבוה מאוד בין מחלקות־העל לתתי־המחלקות, ופוגעת ברעיון הסתרת הנתונים שנמצא בליבת הרעיון של תכנות מונחה עצמים.
סוג נוסף של ביקורת שמועברת תדיר על ירושה נוגעת לתסמונת בשם "מחלקת־העל השברירית".
תסמונת זו מדברת על כך שלאחר הירושה, שינוי במחלקת־העל עלול ליצור תקלים בקוד של תתי־המחלקות שיורשות ממנה.
נדון בבעיה זו בהרחבה בהמשך הפרק.
שימוש רב במחלקות ויצירת היררכיית ירושה מורכבת ועמוסה עלולים גם לגרום ל"בעיית היו־יו" –
מתכנת שצריך לעבור על מחלקות בתוכנית רצוא ושוב כדי להבין את המטרה של חלק קטן בקוד.
היררכיה עמוסה שכזו גם מקשה על תחזוקת התוכנית וגורמת לניפוי שגיאות להיות קשה יותר במידה ניכרת.
בספר האלמותי של כנופיית הארבעה, "תבניות עיצוב" (Design Patterns), הכותבים מותחים ביקורת נוקבת על הבעיות שירושה עלולה להכניס לקוד.
הם מציעים לבחור בהכלה במקום בירושה כשהדבר אפשרי.
ירושה גורמת לקוד להיות חשוף לתסמונת "מחלקת־העל השברירית".
התסמונת מתארת בעיה נפוצה בירושה:
שינויים במחלקת־העל עלולים לגרור תוצאות בלתי צפויות בתתי־המחלקות שיורשות ממנה, אפילו אם מחלקת העל עובדת באופן מושלם.
כדי לקבל מושג איך נראית התסמונת בעולם האמיתי, נראה דוגמה למימוש של אתר הקניות "מוצרי לכון ובניו".
נגדיר מחלקה פשוטה בשם Clickable שמתארת עצם לחיץ. המחלקה תאפשר לנו לספור כמה פעמים לחצו על מופע מסוים:
In [ ]:
class Clickable:
def __init__(self):
self.clicks = 0
def click(self):
self.clicks = self.clicks + 1
def double_click(self):
self.clicks = self.clicks + 2
באתר הקניות המדובר ישנו מופע של כפתור שנוצר מהמחלקה CrazyButton.
המחלקה CrazyButton יורשת מהמחלקה Clickable.
המיוחד במחלקת CrazyButton הוא שכל לחיצה נחשבת כלחיצה כפולה:
In [ ]:
class CrazyButton(Clickable):
def click(self):
self.double_click()
In [ ]:
buy_now = CrazyButton()
buy_now.double_click()
buy_now.clicks
יום אחד, הסתכל אחיתופל המתכנת על הקוד. "רגע! יש פה קוד כפול!" הוא נזעק,
"הפעולה double_click במחלקה Clickcable עושה כמעט את מה שעושה הפעולה click".
אחיתופל ניגש בחופזה לתקן את הקוד, ולהשתמש ב־click פעמיים במקום בערך המפורש 2:
In [ ]:
class Clickable:
def __init__(self):
self.clicks = 0
def click(self):
self.clicks = self.clicks + 1
def double_click(self):
self.click()
self.click()
class CrazyButton(Clickable):
def click(self):
self.double_click()
ואכן, המחלקה Clickable עודנה עובדת מצוין:
In [ ]:
buy_now = Clickable()
buy_now.click()
buy_now.double_click()
print(buy_now.clicks)
אך מה יקרה אם נרצה להשתמש ב־CrazyButton?
In [ ]:
buy_now = CrazyButton()
buy_now.click()
אחיתופל אומנם תיקן את הקוד של המחלקה Clickable, שנראית עכשיו תקינה לחלוטין.
הבעיה היא שבתת־המחלקה CrazyButton הפעולה click מסתמכת על המימוש של הפעולה double_click שבמחלקת־העל שלה.
כך נוצרת שרשרת קריאות אין־סופית: בעת קריאה ל־click ב־CrazyButton, נקראת הפעולה double_click, שקוראת בחזרה לפעולה click, וחוזר חלילה.
בספר Code Complete מוצג כלל אצבע שזכה לאהדה בקרב מתכנתים רבים:
כשמתארים קשר בין שתי מחלקות בעזרת המילים "סוג של" (is a), הקשר התכנותי ביניהן יהיה לרוב ירושה.
כשמתארים קשר בין שתי מחלקות בעזרת המילה "יש..." (has a), הקשר התכנותי ביניהן יהיה לרוב הכלה.
אם אנחנו יכולים להגיד "א הוא סוג של ב", כאשר מדובר בשמות של מחלקות, ייתכן שנכון להשתמש בירושה.
לדוגמה: כלב הוא סוג של חיה, ולכן ייתכן שמחלקת Dog תירש ממחלקת Animal.
מכונית היא סוג של רכב, ולכן הגיוני שמחלקת Car תירש ממחלקת Vehicle.
אם אנחנו יכולים להגיד "אצל א יש ב", כאשר מדובר בשמות של מחלקות, ייתכן שנכון להשתמש בהכלה.
לדוגמה: למכונית יש מנוע, ולכן הגיוני שבמחלקה Car תופיע התכונה engine, שהיא מופע שנוצר מהמחלקה Engine.
זה עובד על תכונות באופן כללי, ולא רק על קשר של הכלה. לכפתור באתר יש כמה לחיצות, ולכן הגיוני ש־clicks תהיה תכונה של המחלקה Button.
ברברה ליסקוב היא אחת מנשות מדעי המחשב המשפיעות בהיסטוריה, ואחת משלוש הנשים היחידות שזכו בפרס טיורינג.
תרומתה הנודעת ביותר נקראת "עקרון ההחלפה של ליסקוב" שנוסח פורמלית בשנת 1987.
בפשטות, העיקרון גורס כך:
כל מופע שנוצר ממחלקת־על מסוימת, חייב להמשיך לעבוד כרגיל גם אם ביום בהיר אחד יחליטו ליצור אותו מתת־מחלקה שיורשת מאותה מחלקת־על.
נדמיין שיש לנו מחלקה של שיר ומחלקה של אקרוסטיכון שיורשת ממנה.
לפי ליסקוב, כל מופע שנוצר מהמחלקה "שיר" אמור להמשיך לפעול כרגיל, אפילו אם שינינו את הקוד כך שיווצר מהמחלקה "אקרוסטיכון".
דוגמה פופולרית שנותן רוברט מרטין (הידוע בשם העט Uncle Bob) היא שכל ריבוע הוא גם מלבן.
חלקנו נמהר להשתמש בהורשה כדי לייצג את הרעיון הזה:
In [ ]:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
def __str__(self):
dimensions = f"{self.width}x{self.height}"
return f"Size of {dimensions} is {self.get_area()}"
class Square(Rectangle):
def __init__(self, side_size):
super().__init__(side_size, side_size)
בקוד שלמעלה מימשנו מחלקת מלבן Rectangle, שבה התכונות "גובה" (height) ו"רוחב" (width), ואת הפעולה "חישוב שטח" (get_area).
בקוד ישנה מחלקה נוספת בשם ריבוע (Square) שפעולת האתחול שלה מקבלת רק אורך של צלע אחת, שכן כל צלעותיו של ריבוע זהות.
מחלקת הריבוע יורשת ממחלקת המלבן שבנינו.
הרעיון יעבוד נהדר:
In [ ]:
print(Rectangle(5, 6))
print(Square(3))
אבל שימו לב שאנחנו מפרים את עקרון ההחלפה של ליסקוב.
על פי העיקרון, כל שימוש במחלקה יוכל להיות מוחלף בתת־מחלקה שיורשת ממנה.
לפי אותו עיקרון, אנחנו אמורים להיות מסוגלים להחליף את הקריאה למחלקת מלבן בקריאה למחלקת ריבוע.
משמע, עבור:
In [ ]:
print(Rectangle(5, 6))
אמור להתאפשר:
In [ ]:
print(Square(5, 6))
לְמַאי נַפְקָא מִנַּהּ? (יענו, ככה החלטתי לתכנת, למה מה תעשה לי?)
אז כמו שראינו – יצירת ריבוע שיגרתית כן תעבוד כמצופה.
עד שלפתע, אחד המתכנתים ירצה לשנות את רוחבו של הריבוע:
In [ ]:
my_square = Square(3)
my_square.width = 5
print(my_square)
לאחר עריכת רוחב הריבוע ציפינו ששטח הריבוע יהיה 25, אך ארכיטקטורת הקוד שלנו כשלה בטיפול במקרה הזה.
הקוד שלנו שינה רק את הרוחב של הריבוע ולא את אורכו, בזמן שבריבוע דבר כזה לא אמור להתאפשר.
בשורה התחתונה – הקוד שלנו נשבר כיוון שלא עקבנו אחרי עקרון ההחלפה.
נוכל לתקן את הבאג בקלות יחסית אם נהפוך את width ואת height למשתנים פרטיים ב־Rectangle,
אבל כדאי לשים לב שבדרך כלל אם אנחנו פונים לתקן את מחלקת־העל כדי שדברים יעבדו – סימן שמשהו בעייתי בבסיס הארכיטקטורה שלנו.
ננסה בכל זאת:
In [ ]:
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
def set_width(self, width):
self._width = width
def set_height(self, height):
self._height = height
def get_width(self):
return self._width
def get_height(self):
return self._height
def get_area(self):
return self.get_width() * self.get_height()
def __str__(self):
dimensions = f"{self.get_width()}x{self.get_height()}"
return f"Size of {dimensions} is {self.get_area()}"
class Square(Rectangle):
def __init__(self, side_size):
super().__init__(side_size, side_size)
def set_width(self, width):
super().set_width(width)
super().set_height(width)
def set_height(self, height):
super().set_width(height)
super().set_height(height)
הדבר הראשון שקופץ לעין זה שיצרנו פה מפלצת. הקוד שלנו הפך מסורבל בניסיון לטפל בכל מקרי הקצה.
הבעיה הבאה תצוץ כשנבנה פונקציה שאמורה לעבוד על מקרה כללי של מלבן או ריבוע:
In [ ]:
def set_and_print(my_shape):
my_shape.set_height(2)
my_shape.set_width(3)
print(my_shape)
set_and_print(Rectangle(4, 5))
set_and_print(Square(4))
המטרה הברורה של הפונקציה הייתה למצוא את השטח של הצורה לאחר שערכנו את גודלו ל־3 על 2.
מובן שהפונקציה לא החזירה את המצופה ממנה כשהעברנו לה ריבוע, כיוון שאי אפשר לשנות רק את רוחבו או רק את אורכו של הריבוע.
בשלב הזה נפסיק לנסות לשכנע את הריבוע שהוא גם מלבן, ופשוט נממש את שתי המחלקות בנפרד:
In [ ]:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
def __str__(self):
dimensions = f"{self.width}x{self.height}"
return f"Size of {dimensions} rectangle is {self.get_area()}"
class Square:
def __init__(self, side):
self.side = side
def get_area(self):
return self.side ** 2
def __str__(self):
dimensions = f"{self.side}x{self.side}"
return f"Size of {dimensions} square is {self.get_area()}"
נזכה בקוד קצר, נוח לתחזוקה, שמאפשר למי שישתמש במחלקות שלנו לקבל בדיוק את מה שהוא רוצה בלי החשש שיטעה.
נממש תוכנה לקבלת דואר אלקטרוני, שיודעת לתמוך בקבלה ובשליחה של דואר מ־Gmail ומ־Walla – ספקי דואר אלקטרוני פופולריים.
נתחיל במימוש מחלקה כללית עבור התוכנה שלנו.
ההדפסות בפעולות הן לצורך ההמחשה. בדרך כלל נמליץ שלא לכלול הדפסות בתוך פעולות מחלקה או בתוך פונקציות.
In [ ]:
class EmailClient:
def __init__(self, username, password):
print("Setting up a new mail client...")
self._inbox = []
self.username = username
self.password = password
כעת נממש את המחלקות של ספקי שירות הדואר האלקטרוני.
פתרון שעושה שימוש בירושה עשוי להיראות כך:
In [ ]:
class Walla(EmailClient):
DOMAIN = 'walla.co.il'
def read(self):
mail_address = f"{self.username}@{self.DOMAIN}"
print(f"Reading mail of {mail_address} in Walla: [...]")
class Gmail(EmailClient):
DOMAIN = 'gmail.com'
def read(self):
mail_address = f"{self.username}@{self.DOMAIN}"
print(f"Reading mail of {mail_address} in Gmail: [...]")
נדגים שימוש במחלקה:
In [ ]:
mail = Walla(username='Yam', password='correcthorsebatterystaple')
mail.read()
נשים לב שאם נרצה לעבור שירות דוא"ל, נהיה חייבים ליצור מופע חדש לחלוטין של תוכנת דוא"ל:
In [ ]:
mail = Gmail(username='Yam', password='correcthorsebatterystaple')
mail.read()
מה יקרה אם נשתמש בהכלה במקום בירושה?
נתחיל בהתאמת המחלקה EmailClient:
In [ ]:
class EmailClient:
def __init__(self, username, password, provider):
print("Setting up a new mail client...")
self._inbox = []
self.username = username
self.password = password
self.provider = provider
def read(self):
self.provider.read(self.username)
בקוד שלמעלה הוספנו את התכונה provider ל־EmailClient.
תכונה זו תכיל מופע של ספק הדוא"ל.
המחלקות של שירותי הדוא"ל יישארו כפי שהן, אך לא יירשו מ־EmailClient:
In [ ]:
class Walla:
DOMAIN = 'walla.co.il'
def read(self, username):
mail_address = f"{username}@{self.DOMAIN}"
print(f"Reading mail of {mail_address} in Walla: [...]")
class Gmail:
DOMAIN = 'gmail.com'
def read(self, username):
mail_address = f"{username}@{self.DOMAIN}"
print(f"Reading mail of {mail_address} in Gmail: [...]")
נראה דוגמה לשימוש:
In [ ]:
mail = EmailClient(
username='Yam',
password='correcthorsebatterystaple',
provider=Walla(),
)
mail.read()
בניגוד לדוגמה שהשתמשה בירושה, אם נרצה להחליף ספק דוא"ל לא נצטרך ליצור מופע חדש של תוכנת דואר אלקטרוני.
פשוט נחליף את הספק:
In [ ]:
mail.provider = Gmail()
mail.read()
במחברת זו סקרנו את הרעיון של ירושה בתכנות מונחה עצמים.
למדנו מהי ירושה ואיך משתמשים בה בפייתון,
ראינו איך דורסים פעולות ותכונות בתת־המחלקה, ואיך משתמשים ב־super כדי להתייחס למחלקת־העל.
סקרנו גם את הבעיות שמתעוררות כשמשתמשים בירושה, ולמדנו שיש לנהוג משנה זהירות לפני שבוחרים לממש משהו בעזרת מחלקות.
דיברנו על רעיונות תיאורטיים, כמו העדפת הכלה על ירושה, על סינדרום מחלקת־העל השברירית ועל עקרון ההחלפה של ליסקוב.
מתכנתים טובים משתמשים בירושה במשורה, רק אחרי שווידאו שבחירה בפתרון הזה לא תוסיף סיבוכיות מיותרת לקוד.
במחברת הבאה נסקור טכניקות פופולריות שנולדו בזכות רעיון הירושה, ונלמד כיצד משתמשים בהן.
לאדון פסו בגז' יש חנות גדולה לממכר ספרים.
ניצור מחלקה בסיסית שמייצגת מוצר בחנות של פסו:
In [ ]:
class Product:
def __init__(self, product_id, name, price):
self.id = product_id
self.name = name
self.price = price
def __str__(self):
return f"{self.name} - {self.price}$"
הלקוח יכול לבצע הזמנה דרך המרשתת, דרך הגעה לאחד הסניפים או טלפונית.
נממש את המחלקה שאחראית להזמנה הטלפונית:
In [ ]:
import datetime
class PhoneOrder:
PHONE_CALL_TOLL_IN_USD = 1.99
DELIVERY_PRICE_IN_USD = 5
VAT_IN_PERCENTS = 20
def __init__(self, seller_id, buyer_id, products):
self.seller = seller_id
self.buyer = buyer_id
self.products = products
self.time = datetime.datetime.now()
self.price = self.calculate_price()
self.delivered = False
def calculate_price(self):
base_price = sum(product.price for product in self.products)
return base_price + self._calculate_extra_price(base_price)
def _calculate_extra_price(self, base_price, include_vat=True):
tax = self.VAT_IN_PERCENTS / 100
return (
base_price * tax
+ self.DELIVERY_PRICE_IN_USD
+ self.PHONE_CALL_TOLL_IN_USD
)
def __str__(self):
return (
f"Buyer #{self.buyer}, created by #{self.seller}.\n"
+ f"Delivered: {self.delivered}.\n"
+ f"{'-' * 40}\n"
+ "\n".join(str(product) for product in self.products)
+ f"\n{'-' * 40}\n"
+ f"Total: {self.price}$"
)
book1 = Product(1, "The Fountainhead", 27.99)
book2 = Product(2, "Thinking, Fast and Slow", 19.69)
order = PhoneOrder(251, 666, [book1, book2])
print(order)
דמיינו שכעת עליכם לממש את המחלקות שאחראיות להזמנות מהסניפים ומהמרשתת.
עד מהרה נגלה שהקוד במחלקות הללו דומה מאוד לקוד שיצרנו במחלקת PhoneOrder.
אם כך, נשמע שהדבר הנכון הוא ליצור מחלקה ששלוש המחלקות יורשות ממנה:
In [ ]:
import datetime
class Order:
DELIVERY_PRICE_IN_USD = 5
VAT_IN_PERCENTS = 20
def __init__(self, seller_id, buyer_id, products):
self.seller = seller_id
self.buyer = buyer_id
self.products = products
self.time = datetime.datetime.now()
self.price = self.calculate_price()
self.delivered = False
def calculate_price(self):
base_price = sum(product.price for product in self.products)
return base_price + self._calculate_extra_price(base_price)
def _calculate_extra_price(
self, base_price, include_vat=True, include_delivery=True,
):
tax = self.VAT_IN_PERCENTS / 100
price = base_price * tax
if include_delivery:
price = price + self.DELIVERY_PRICE_IN_USD
return price
def __str__(self):
return (
f"Buyer #{self.buyer}, created by #{self.seller}.\n"
+ f"Delivered: {self.delivered}.\n"
+ f"{'-' * 40}\n"
+ "\n".join(str(product) for product in self.products)
+ f"\n{'-' * 40}\n"
+ f"Total: {self.price}$"
)
class PhoneOrder(Order):
PHONE_CALL_TOLL_IN_USD = 1.99
def _calculate_extra_price(self, base_price, **kwargs):
base_price = super()._calculate_extra_price(base_price, **kwargs)
return base_price + self.PHONE_CALL_TOLL_IN_USD
class OnlineOrder(Order):
pass
class StoreOrder(Order):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.delivered = True
def _calculate_extra_price(self, base_price, **kwargs):
return super()._calculate_extra_price(
base_price, include_delivery=False, **kwargs,
)
In [ ]:
print("Show all three kinds of orders:\n\n")
book1 = Product(1, "The Fountainhead", 27.99)
book2 = Product(2, "Thinking, Fast and Slow", 19.69)
order = PhoneOrder(seller_id=251, buyer_id=666, products=[book1, book2])
print(order)
print('\n\n')
order = StoreOrder(seller_id=251, buyer_id=666, products=[book1, book2])
print(order)
print('\n\n')
order = OnlineOrder(seller_id=251, buyer_id=666, products=[book1, book2])
print(order)
כתבו תוכנה שמדמה מערכת קבצים.
כל קובץ הוא מסוג מסוים – טקסטואלי, בינארי או תיקייה.
תיקייה היא קובץ שמכיל בתוכו רשימת קבצים, שיכולים להיות טקסטואליים, בינאריים או תיקיות.
נדמיין, לדוגמה, את היררכיית התיקיות הבאה:
בדוגמה שלמעלה יש 5 קבצים תחת תיקיית week8: שניים מהם תיקיות (images, resources) ו־3 מהם מחברות.
התיקייה resources ריקה, ובתיקייה images יש את הקבצים הטקסטואליים exercise.svg ו־recall.svg ואת הקובץ הבינארי logo.jpg.
במערכת שלנו, הנתיב לתיקיית week8 הוא /week8, והנתיב לקובץ logo.jpg הוא /week8/images/logo.jpg.
צרו בתוכנה שלכם מערכת לניהול משתמשים. לכל משתמש יש שם משתמש וסיסמה.
משתמש יכול להיות מסוג "מנהל מערכת" או "משתמש רגיל".
לקובץ בינארי או טקסטואלי יש משקל המיוצג בקילובייטים, תוכן, ומשתמש שיצר אותו.
על קובץ טקסטואלי ניתן להפעיל את הפעולה count, שמקבלת מחרוזת לחיפוש ומחזירה כמה פעמים המחרוזת הופיעה בקובץ.
אם הקובץ הבינארי הוא מסוג תמונה, צרו עבורו את הפעולה get_dimensions שמחזירה את אורך התמונה ואת רוחבה. אין צורך לממש את תוכן הפעולה עצמה.
לכל קובץ שאינו תיקייה צריכה להיות הפעולה .read()
, שאליה מעבירים כפרמטר משתמש.
אם המשתמש הוא זה שיצר את הקובץ או שהוא מנהל המערכת, מהפעולה יוחזר תוכן הקובץ. אחרת יוחזר None
.
ממשו את משחק המאפיה.
המחלקה Game תחזיק את הנתונים על המשחק.
היא תאפשר למשתמש חדש להצטרף למשחק בעזרת הפעולה add_player, כשהפרמטר שמועבר לה הוא שם השחקן.
כשהפעולה start תיקרא, יחולק תפקיד לכל אחד מהשחקנים: אזרח, שוטר או איש מאפיה. רק השחקן יודע מה תפקידו במשחק.
בגרסת המשחק שלנו, יהיה רק איש מאפיה אחד ואיש משטרה אחד.
אם הפעולה start נקראה אך אין די משתתפים כך שיהיה לפחות אזרח אחד, החזירו מהפעולה False ואל תתחילו את המשחק.
בכל סיבוב 3 חלקים – רצח על ידי המאפיה, עיכוב על ידי השוטר והצבעת האזרחים.
כשהפעולה start_round נקראת, מתרחשות לפי הסדר הפעולות האלה:
המשחק מסתיים בניצחון למאפיה כאשר נשארים רק שני משתתפים במשחק, שאחד מהם הוא איש המאפיה.
המשחק מסתיים בניצחון לאזרחים אם השוטר עיכב את איש המאפיה, או אם האזרחים הוציאו להורג את איש המאפיה.
בונוס: ממשו את השאלה כבוט לטלגרם.