ژنراتورها در پایتون: پیمایش تنبل با yield
اینکه yield چطور یک تابع را به ژنراتور تبدیل میکند، چرا ژنراتورها تقریباً هیچ حافظهای مصرف نمیکنند و چطور با آنها پایپلاین داده بسازیم.
در این صفحه
فرض کنید باید یک فایل لاگ ۱۰ گیگابایتی را برای پیدا کردن خطاها بگردید. اگر آن را در یک لیست بخوانید، برنامه پیش از رسیدن به اولین خطا حافظه کم میآورد. اگر آن را خطبهخط پردازش کنید، روی یک لپتاپ معمولی هم راحت اجرا میشود.
ژنراتورها (generators) ابزار پایتون دقیقاً برای همین کارند: تولید مقدارها یکییکی و فقط وقتی که لازماند. در این مقاله یاد میگیرید yield چطور کار میکند، چرا ژنراتورها تقریباً هیچ حافظهای مصرف نمیکنند، چطور آنها را به هم زنجیر کنید و چه نکاتی معمولاً همه را غافلگیر میکند.
مشکلِ ساختن همهچیز از همان اول#
لیست کامپرهنشن کل لیست را میسازد، پیش از آنکه بتوانید حتی از یک آیتم استفاده کنید:
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 داشته باشد یک تابع ژنراتور است. صدا زدنش بدنهاش را اجرا نمیکند؛ یک شیء ژنراتور برمیگرداند که هر بار مقداری بخواهید، بدنه را قدمبهقدم اجرا میکند:
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() بگیرید:
>>> next(gen)
Starting
3
>>> next(gen)
2
>>> next(gen)
1
>>> next(gen)
Done
Traceback (most recent call last):
...
StopIterationدر عمل بهندرت خودتان next() را صدا میزنید. حلقهٔ for این کار را برایتان انجام میدهد و با رسیدن به StopIteration بیدردسر متوقف میشود:
for n in countdown(3):
print(n)yield چطور تابع را متوقف میکند#
یک تابع معمولی از بالا تا پایین اجرا میشود و وقتی برمیگردد، متغیرهای محلیاش از بین میروند. ژنراتور فرق دارد:
next()بدنه را اجرا میکند تا به یکyieldبرسد.- مقدارِ yieldشده به فراخواننده داده میشود و تابع متوقف میشود؛ با همهٔ متغیرهای محلی و دقیقاً در همان نقطه.
next()بعدی درست از بعدِ همانyieldادامه میدهد.- وقتی تابع برمیگردد، ژنراتور
StopIterationمیدهد و کارش تمام است.
کل ترفند همین توقف است. بین دو فراخوانی next() ژنراتور هیچ کاری نمیکند و فقط متغیرهای محلی خودش را نگه میدارد.
generator expressionها#
برای حالتهای ساده اصلاً به تابع نیاز ندارید. generator expression همان شکل لیست کامپرهنشن را دارد، فقط با پرانتز:
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() آن را آیتمبهآیتم مصرف میکنند، بیآنکه هرگز لیستی بسازند.
ساختن پایپلاینهای تنبل#
ژنراتورها وقتی واقعاً میدرخشند که آنها را به هم زنجیر کنید. هر مرحله آیتمها را یکییکی از مرحلهٔ قبل میکشد، پس کل پایپلاین هر چقدر هم ورودی بزرگ باشد، مقدار ثابتی حافظه مصرف میکند.
اینجا پنج خطای سرورِ اول را در لاگ دسترسی یک وبسرور پیدا میکنیم:
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() را با یک لیست ساده از رشتهها و بدون هیچ فایلی تست کنید.
ژنراتورهای بیپایان#
از آنجا که مقدارها فقط وقتی خواسته شوند محاسبه میشوند، یک ژنراتور میتواند دنبالهای را توصیف کند که هرگز تمام نمیشود:
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 میکند. برای ساختارهای بازگشتی عالی است:
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() را هم درست منتقل میکند.
نکتههای غافلگیرکننده#
هر ژنراتور فقط یک بار قابل استفاده است. وقتی تمام شد، خالی میماند:
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()ندارند و ممکن است وقتی متوقفاند منبعی را باز نگه دارند.
دفعهٔ بعد که خواستید یک لیست بزرگ بسازید فقط برای اینکه یک بار رویش حلقه بزنید، کروشهها را با پرانتز عوض کنید. خیلی وقتها همین کافی است.