שני הכללים של Generators בפייתון

14/09/2023

לפייתון לא אכפת איזה Generators נרצה לכתוב, אבל יש שני כללים שכשאנחנו חורגים מהם כדאי לחשוב שנית אם Generator הוא הפיתרון הנכון-

  1. גנרטור לא מחשב את כל הערכים מראש.

  2. גנרטור לא תופס יותר זיכרון ככל שמחשבים יותר פריטים.

האינטואיציה של הכלל הראשון היא שאם גנרטור צריך לחשב את כל הערכים מראש עדיף להחזיר את כל מה שהוא חישב בתור רשימה (או כל מבנה נתונים אחר) ולא צריך לעשות 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)