שני הכללים של Generators בפייתון
לפייתון לא אכפת איזה Generators נרצה לכתוב, אבל יש שני כללים שכשאנחנו חורגים מהם כדאי לחשוב שנית אם Generator הוא הפיתרון הנכון-
גנרטור לא מחשב את כל הערכים מראש.
גנרטור לא תופס יותר זיכרון ככל שמחשבים יותר פריטים.
האינטואיציה של הכלל הראשון היא שאם גנרטור צריך לחשב את כל הערכים מראש עדיף להחזיר את כל מה שהוא חישב בתור רשימה (או כל מבנה נתונים אחר) ולא צריך לעשות yield כל פעם לאיבר הבא. האינטואיציה של הכלל השני היא שאנשים שמשתמשים בלולאת for עם גנרטור לא מצפים שצריכת הזיכרון תעלה ככל שמושכים יותר איברים, ובמקום אנחנו חושבים שכל פריט בו אנחנו מטפלים משוחרר מהזיכרון באיטרציה הבאה של הלולאה.
ועם הכללים האלה אפשר לראות כמה Generators שעדיף היה לכתוב אותם בתור פונקציות רגילות. דוגמה ראשונה היא groupby, שבניגוד לזו מ itertools מחזירה את כל הקבוצות עם כל הפריטים בכל קבוצה:
def groupby(sequence, key):
groups = defaultdict(list)
for item in sequence:
groups[key(item)].append(item)
for key, values in groups.items():
yield key, values
הפונקציה אומנם קוראת ל yield ונראית כמו Generator, אבל היא חישבה מראש כבר את כל הערכים בסידרה ושומרת הכל בזיכרון. עדיף יהיה להחזיר את groups במקום להחזיר אותם אחד אחד.
הכלל השני קצת יותר עדין אבל אני חושב שגם פה האינטואיציה ברורה והבעיה היא שקשה לראות את צריכה הזיכרון העולה בתוך קוד שמשתמש בגנרטור. דוגמה:
import time
import random
def uniq(seq):
seen = set()
for i in seq:
if i not in seen:
yield i
seen.add(i)
def random_numbers():
while True:
yield random.randint(1, 100)
for i in uniq(random_numbers()):
time.sleep(2)
print(i)
אם נדלג בעין על הגדרת הפונקציה uniq, אפשר לפספס את תוספת הזיכרון שתגרום לתוכנית בסוף להתרסק. כשאני מוותר על הגנרטור ומעביר את הקוד פנימה ללולאה התוצאה לדעתי יותר ברורה:
import time
import random
def random_numbers():
while True:
yield random.randint(1, 100)
seen = set()
for i in random_numbers():
if i not in seen:
time.sleep(2)
print(i)
seen.add(i)