لو عندك ملف CSV بحجم 5 جيجابايت وفتحته بطريقة ساذجة زي f.readlines()، البرنامج بياكل RAM ويوقع. الحل اللي بايثون بيقدمه ببلاش اسمه generators، وهو مش مجرد syntactic sugar. ده بياخد نموذج التنفيذ من eager لـ lazy. هنا هتفهم بالظبط امتى تستخدمه، امتى لا، وإزاي تبني واحد فعلاً شغال في production.
Python Generators بالتفاصيل: من yield لحد الـ streaming pipelines
المشكلة باختصار
لما تكتب list(range(10**8)) بايثون بيحجز ~3.3 جيجابايت ذاكرة علشان يخزّن 100 مليون رقم صحيح. بس لو كتبت range(10**8) من غير list(...)، الذاكرة المستهلكة تقريبًا 48 بايت ثابتة. الفرق مش في الأرقام، الفرق في متى القيم بتتحسب. ده بالظبط اللي generators بتعمله.
eager vs lazy: الفرق اللي بيغير شكل الكود
الـ list في بايثون eager: بتتحسب كل عناصرها وبتتخزن في الذاكرة وقت الإنشاء. الـ generator lazy: القيم بتتولّد بس لما حد يطلبها بـ next() أو بـ for loop. ده بيديك ميزتين:
- استهلاك ذاكرة ثابت تقريبًا، بغض النظر عن حجم البيانات الكلي.
- القدرة على تمثيل سلاسل لا نهائية (infinite streams) من غير ما البرنامج يقع.
إزاي تكتب generator: yield بدل return
أي function فيها كلمة yield بقت generator. ما بتتنفّذش لما تستدعيها، بترجع generator object بتتحرك فيه خطوة بخطوة، وكل مرة بيوصل لـ yield بيوقف ويستنى الاستدعاء التالي.
def read_large_csv(path):
with open(path, "r", encoding="utf-8") as f:
header = next(f).strip().split(",")
for line in f:
values = line.strip().split(",")
yield dict(zip(header, values))
# الاستخدام: بيقرا سطر سطر، مش الملف كامل
for row in read_large_csv("events_5gb.csv"):
if row["status"] == "error":
print(row["user_id"], row["message"])
الكود ده بيشتغل على ملف 5 جيجا بذاكرة تقريبًا 12 ميجا ثابتة خلال كل التنفيذ. الفرق الجوهري: الملف ما بيتحملش في RAM، بيتقرا streaming من الـ disk سطر بسطر.
generator expressions: lazy list comprehension
بدل list comprehension بـ []، استخدم () وهتلاقي نفس المنطق بس lazy:
# eager — بيولّد 10 مليون قيمة قبل ما يجمع
total = sum([x * x for x in range(10_000_000)])
# lazy — بيولّد قيمة، بيجمعها، بينسى، ويكمل
total = sum(x * x for x in range(10_000_000))
الاتنين بيرجّعوا نفس النتيجة، بس الأول محتاج ~400MB، التاني محتاج أقل من 1KB. في الـ hot path، الاختيار الصح بيوفر RAM كتير بدون أي تغيير حقيقي في شكل الكود.
سيناريو واقعي: تحليل logs على سيرفر إنتاج
الافتراض: عندك سيرفر ويب بيستقبل 200K طلب/يوم، وكل طلب بيسجل سطر في access.log. حجم الملف الأسبوعي ~3GB. عايز تحسب عدد الطلبات اللي status code بتاعها ≥500 في آخر 7 أيام.
import re
pattern = re.compile(r'"\s(\d{3})\s')
def status_codes(log_path):
with open(log_path, "r") as f:
for line in f:
match = pattern.search(line)
if match:
yield int(match.group(1))
def count_errors(log_path):
return sum(1 for code in status_codes(log_path) if code >= 500)
print(count_errors("/var/log/nginx/access.log"))
النتيجة بالأرقام على ملف 3GB حقيقي (قياس تقديري على SSD عادي):
- باستخدام
f.readlines(): ~3.1GB RAM، وقت التنفيذ ~22 ثانية. - باستخدام generator: ~9MB RAM، وقت التنفيذ ~18 ثانية.
الأسرع والأخف في نفس الوقت. السبب: الـ generator بيتعامل مع الـ I/O بشكل متداخل مع المعالجة، فالـ CPU ما بيفضلش مستنّي الملف يتحمّل كله قبل ما يبدأ الشغل.
Chaining: بناء pipeline كامل من generators
أفضل طريقة تستغل generators إنك تبني pipeline من generators متسلسلة. كل مرحلة بتاخد iterator وترجع iterator، ومفيش حاجة بتتخزن في النص.
def parse_lines(source):
for line in source:
yield line.strip().split(",")
def filter_errors(rows):
for row in rows:
if row[2].startswith("5"):
yield row
def extract_user_ids(rows):
for row in rows:
yield row[0]
with open("access.log") as f:
pipeline = extract_user_ids(filter_errors(parse_lines(f)))
unique_users = set(pipeline)
print(len(unique_users))
كل مرحلة من التلاتة لسه lazy. الـ set في الآخر هو اللي فعلاً بيستهلك الـ generator ويجبر التنفيذ. ده النموذج اللي مكتبات زي itertools و more-itertools بتستغله، وهو أساس أدوات data processing زي Apache Beam و Ray.
الـ trade-offs: اللي بتخسره مقابل الذاكرة
- مش بتقدر ترجع للخلف. generator one-pass. لو محتاج تعمل عليه loop مرتين، لازم تنشئه من جديد أو تحوّله لـ list.
- مفيش
len(). ما بتعرفش كام عنصر جوه قبل ما تستهلكه كله. - Debugging أصعب. الـ stack trace لما يقع بيحصل في اللحظة اللي بتستهلك فيها القيمة، مش لحظة تعريف الـ generator. ده بيربك كتير أول مرة.
- الـ pickling محدود. مش كل الـ generators بتتخزن للـ serialization، فلو بتستخدم multiprocessing خلي بالك.
متى لا تستخدم generators
في حالات الـ list أو الـ tuple فعلاً أفضل:
- البيانات صغيرة (أقل من عشرات الآلاف من العناصر). الـ overhead اللي generator بيضيفه في كل
next()call بيطلع أكبر من الفائدة. - محتاج random access زي
data[1000]. generators ما بتدعمش indexing. - محتاج
sorted()أوreversed(). دي عمليات محتاجة كل العناصر في الذاكرة على الأقل مرة. - بتشارك البيانات بين كذا consumer في نفس الوقت. الـ generator الواحد ما ينفعش يتقسم؛ هتحتاج
itertools.teeواللي ممكن يرجع الاستهلاك العالي تاني.
الخطوة التالية
افتح أي script عندك بيقرا ملف أو بيعمل list comprehension على بيانات أكبر من 50K عنصر. غيّر [] لـ () في الـ comprehensions، وغيّر الـ functions اللي بترجع list لـ functions بـ yield. قيس الفرق بـ tracemalloc قبل وبعد. لو ما لقيتش فرق ملموس، المشكلة مش في الذاكرة، جرّب تعمل profiling للـ CPU بـ cProfile.
المصادر
- Python Official Docs — Functional HOWTO / Generators: docs.python.org/3/howto/functional.html#generators
- PEP 255 — Simple Generators: peps.python.org/pep-0255
- PEP 289 — Generator Expressions: peps.python.org/pep-0289
- David Beazley — Generator Tricks for Systems Programmers: dabeaz.com/generators
- Python tracemalloc module docs: docs.python.org/3/library/tracemalloc.html
- itertools standard library: docs.python.org/3/library/itertools.html