پایتونمتوسط

ژنراتورها در پایتون: پیمایش تنبل با yield

اینکه yield چطور یک تابع را به ژنراتور تبدیل می‌کند، چرا ژنراتورها تقریباً هیچ حافظه‌ای مصرف نمی‌کنند و چطور با آن‌ها پایپ‌لاین داده بسازیم.

یک تابع ژنراتور پایتون که اعداد مربع را یکی‌یکی تحویل می‌دهد و مقدار بعدی هنوز محاسبه نشده
در این صفحه

فرض کنید باید یک فایل لاگ ۱۰ گیگابایتی را برای پیدا کردن خطاها بگردید. اگر آن را در یک لیست بخوانید، برنامه پیش از رسیدن به اولین خطا حافظه کم می‌آورد. اگر آن را خط‌به‌خط پردازش کنید، روی یک لپ‌تاپ معمولی هم راحت اجرا می‌شود.

ژنراتورها (generators) ابزار پایتون دقیقاً برای همین کارند: تولید مقدارها یکی‌یکی و فقط وقتی که لازم‌اند. در این مقاله یاد می‌گیرید yield چطور کار می‌کند، چرا ژنراتورها تقریباً هیچ حافظه‌ای مصرف نمی‌کنند، چطور آن‌ها را به هم زنجیر کنید و چه نکاتی معمولاً همه را غافلگیر می‌کند.

مشکلِ ساختن همه‌چیز از همان اول#

لیست کامپرهنشن کل لیست را می‌سازد، پیش از آنکه بتوانید حتی از یک آیتم استفاده کنید:

Python
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list))  # 8448728  (about 8 MB)
print(sys.getsizeof(squares_gen))   # 104      (the same for any size)

اگر کروشه‌ها را با پرانتز عوض کنید، یک generator expression دارید. این عبارت هنوز هیچ چیزی محاسبه نمی‌کند؛ فقط به خاطر می‌سپارد مقدار بعدی را چطور بسازد. برای همین اندازه‌اش هر چقدر هم آیتم تولید کند کوچک می‌ماند. (تعداد دقیق بایت‌ها به نسخهٔ پایتون شما بستگی دارد.)

اولین تابع ژنراتور#

هر تابعی که yield داشته باشد یک تابع ژنراتور است. صدا زدنش بدنه‌اش را اجرا نمی‌کند؛ یک شیء ژنراتور برمی‌گرداند که هر بار مقداری بخواهید، بدنه را قدم‌به‌قدم اجرا می‌کند:

Python
def countdown(n):
    print("Starting")
    while n > 0:
        yield n
        n -= 1
    print("Done")

gen = countdown(3)
print(gen)  # <generator object countdown at 0x7f...>  (nothing printed yet)

مقدارها را با next() بگیرید:

Pycon
>>> next(gen)
Starting
3
>>> next(gen)
2
>>> next(gen)
1
>>> next(gen)
Done
Traceback (most recent call last):
  ...
StopIteration

در عمل به‌ندرت خودتان next() را صدا می‌زنید. حلقهٔ for این کار را برایتان انجام می‌دهد و با رسیدن به StopIteration بی‌دردسر متوقف می‌شود:

Python
for n in countdown(3):
    print(n)

yield چطور تابع را متوقف می‌کند#

یک تابع معمولی از بالا تا پایین اجرا می‌شود و وقتی برمی‌گردد، متغیرهای محلی‌اش از بین می‌روند. ژنراتور فرق دارد:

  1. next() بدنه را اجرا می‌کند تا به یک yield برسد.
  2. مقدارِ yieldشده به فراخواننده داده می‌شود و تابع متوقف می‌شود؛ با همهٔ متغیرهای محلی و دقیقاً در همان نقطه.
  3. next() بعدی درست از بعدِ همان yield ادامه می‌دهد.
  4. وقتی تابع برمی‌گردد، ژنراتور StopIteration می‌دهد و کارش تمام است.
⁧فراخواننده⁩ ⁧ژنراتور⁩countdown(3) yield 3 yield 2 yield 1 return next()next()next()next() 321 StopIteration ⁧متوقف⁩⁧متوقف⁩⁧متوقف⁩ ⁧هر ⁦next()⁩ بدنه را تا ⁦yield⁩ بعدی اجرا می‌کند؛ در زمان توقف، متغیرهای محلی حفظ می‌شوند⁩
هر فراخوانی next() ژنراتور را تا yield بعدی اجرا می‌کند؛ return در پایان به StopIteration می‌رسد

کل ترفند همین توقف است. بین دو فراخوانی next() ژنراتور هیچ کاری نمی‌کند و فقط متغیرهای محلی خودش را نگه می‌دارد.

generator expressionها#

برای حالت‌های ساده اصلاً به تابع نیاز ندارید. generator expression همان شکل لیست کامپرهنشن را دارد، فقط با پرانتز:

Python
total = sum(x * x for x in range(1_000_000))
has_admin = any(user.is_admin for user in users)
longest = max(len(word) for word in words)

وقتی generator expression تنها آرگومانِ یک تابع باشد، مثل بالا می‌توانید پرانتزهای خودش را حذف کنید. توابعی مثل sum()، any()، all()، min() و max() آن را آیتم‌به‌آیتم مصرف می‌کنند، بی‌آنکه هرگز لیستی بسازند.

ساختن پایپ‌لاین‌های تنبل#

ژنراتورها وقتی واقعاً می‌درخشند که آن‌ها را به هم زنجیر کنید. هر مرحله آیتم‌ها را یکی‌یکی از مرحلهٔ قبل می‌کشد، پس کل پایپ‌لاین هر چقدر هم ورودی بزرگ باشد، مقدار ثابتی حافظه مصرف می‌کند.

اینجا پنج خطای سرورِ اول را در لاگ دسترسی یک وب‌سرور پیدا می‌کنیم:

Python
from itertools import islice

def read_lines(path):
    with open(path, encoding="utf-8") as file:
        for line in file:
            yield line.rstrip("\n")

def parse(lines):
    for line in lines:
        parts = line.split()
        if len(parts) >= 9:
            yield {"ip": parts[0], "path": parts[6], "status": int(parts[8])}

def server_errors(records):
    return (r for r in records if r["status"] >= 500)

lines = read_lines("access.log")
records = parse(lines)
first_five = islice(server_errors(records), 5)

for record in first_five:
    print(record["ip"], record["path"])

تا وقتی حلقهٔ for اولین رکورد را نخواسته، هیچ اتفاقی نمی‌افتد. بعد هر مرحله فقط به اندازه‌ای کار می‌کند که یک آیتم را تحویل بدهد. و به محض پیدا شدن پنج خطا، خواندن فایل متوقف می‌شود؛ حتی اگر ۹ گیگابایت دیگر از آن مانده باشد.

مرحله‌های کوچک، تست‌های ساده

هر مرحله تابع کوچکی است که یک iterable می‌گیرد و آیتم yield می‌کند؛ پس می‌توانید parse() را با یک لیست ساده از رشته‌ها و بدون هیچ فایلی تست کنید.

ژنراتورهای بی‌پایان#

از آنجا که مقدارها فقط وقتی خواسته شوند محاسبه می‌شوند، یک ژنراتور می‌تواند دنباله‌ای را توصیف کند که هرگز تمام نمی‌شود:

Python
from itertools import islice

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

print(list(islice(fibonacci(), 10)))
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

while True یک تابع معمولی را برای همیشه گیر می‌اندازد. اینجا مشکلی نیست، چون ژنراتور بعد از هر مقدار متوقف می‌شود و islice() بعد از ده مقدار دیگر چیزی نمی‌خواهد. ماژول itertools ژنراتورهای بی‌پایانِ آماده هم دارد: count()، cycle() و repeat().

واگذاری با yield from#

yield from کار را به یک iterable دیگر واگذار می‌کند و هر چیزی را که تولید کند yield می‌کند. برای ساختارهای بازگشتی عالی است:

Python
def flatten(items):
    for item in items:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

print(list(flatten([1, [2, [3, 4]], 5])))
# [1, 2, 3, 4, 5]

بدون آن باید for x in flatten(item): yield x می‌نوشتید. yield from کوتاه‌تر است و فراخوانی‌های send() و close() را هم درست منتقل می‌کند.

نکته‌های غافلگیرکننده#

هر ژنراتور فقط یک بار قابل استفاده است. وقتی تمام شد، خالی می‌ماند:

Python
squares = (x * x for x in range(3))
print(list(squares))  # [0, 1, 4]
print(list(squares))  # []  (already used up)

اگر به مقدارها بیش از یک بار نیاز دارید، یا آن‌ها را در یک لیست نگه دارید یا تابع ژنراتور را دوباره صدا بزنید تا ژنراتوری تازه بگیرید.

نه len() و نه اندیس. ژنراتور نمی‌داند چند آیتم تولید خواهد کرد، پس len(gen) و gen[0] خطای TypeError می‌دهند. برای برداشتن یک برش از itertools.islice() استفاده کنید، یا وقتی داده کوچک است آن را به لیست تبدیل کنید.

خطاها دیر ظاهر می‌شوند. چون بدنه با تأخیر اجرا می‌شود، باگی که داخل ژنراتور است فقط وقتی پیدا می‌شود که آن آیتم واقعاً خواسته شود؛ که ممکن است خیلی دور از خطی باشد که ژنراتور را ساخته.

ژنراتورهایی که منبعی را باز نگه می‌دارند

در read_lines() بالا، تا وقتی ژنراتور متوقف است فایل باز می‌ماند. اگر زودتر دست بکشید، فایل وقتی بسته می‌شود که ژنراتور توسط garbage collector جمع شود، یا بلافاصله اگر gen.close() را صدا بزنید. در برنامه‌هایی که مدت طولانی اجرا می‌شوند، ژنراتورهایی را که نیمه‌کاره رها می‌کنید ببندید یا از contextlib.closing() استفاده کنید.

لیست یا ژنراتور؟#

لیست ژنراتور
حافظه همهٔ آیتم‌ها را یک‌جا نگه می‌دارد هر بار یک آیتم را نگه می‌دارد
استفادهٔ دوباره هر چند بار که بخواهید پیمایش کنید فقط یک بار
len()، اندیس و برش بله نه (از islice() استفاده کنید)
دنباله‌های بی‌پایان غیرممکن طبیعی
مناسب برای داده‌های کوچکی که دوباره استفاده می‌کنید داده‌های بزرگ یا جریانی و پایپ‌لاین‌ها

یک قانون خوب: اگر قرار است فقط یک بار روی مقدارها حلقه بزنید، با ژنراتور شروع کنید. وقتی لازم شد دوباره استفاده‌شان کنید، بشمارید یا با اندیس به آن‌ها برسید، سراغ لیست بروید.

جمع‌بندی#

  • تابعی که yield دارد یک ژنراتور برمی‌گرداند؛ بدنه‌اش فقط وقتی مقدار خواسته شود اجرا می‌شود.
  • هر next() تا yield بعدی اجرا می‌شود و بعد تابع با متغیرهای محلی دست‌نخورده متوقف می‌شود.
  • generator expressionها یعنی (x for x in ...) نسخهٔ تنبلِ لیست کامپرهنشن‌اند.
  • ژنراتورها را در پایپ‌لاین‌هایی به هم زنجیر کنید که ورودی‌های عظیم را با حافظهٔ ثابت پردازش می‌کنند.
  • برای برداشتن چند آیتم از ژنراتورهای طولانی یا بی‌پایان از islice() و برای واگذاری از yield from استفاده کنید.
  • ژنراتورها یک‌بارمصرف‌اند، len() ندارند و ممکن است وقتی متوقف‌اند منبعی را باز نگه دارند.

دفعهٔ بعد که خواستید یک لیست بزرگ بسازید فقط برای اینکه یک بار رویش حلقه بزنید، کروشه‌ها را با پرانتز عوض کنید. خیلی وقت‌ها همین کافی است.