الـ Generators في بايثون: اقرأ ملفًا بحجم 10 جيجابايت بذاكرة لا تتعدّى ميجابايت واحد
مستوى المقال: متوسط. الافتراض إنك بتكتب دوال وحلقات في بايثون، ومرتاح تشغّل سكربت من التيرمنال. مش لازم تكون خبير.
لو عندك ملف لوج بحجم عدة جيجابايت وحاولت تفتحه بـ readlines()، البرنامج هيحاول يحمّله كله في الرام وغالبًا هيقع بـ MemoryError. الحل مش سيرفر أكبر — الحل سطر واحد: اقرأ الملف كـ generator. هتمشي على الملف سطر سطر، والذاكرة تفضل ثابتة مهما كبر الملف.
المشكلة باختصار
أغلب الكود اللي بيتعامل مع البيانات بيعمل غلطة واحدة: بيحمّل كل حاجة في الذاكرة قبل ما يبدأ يشتغل. f.readlines() بترجّع list فيها كل أسطر الملف. ملف 1 جيجا بيتحوّل لقائمة بتاكل أكتر من 1 جيجا من الرام، لأن كل سطر بيتخزّن ككائن str مستقل بـ overhead.
الـ generator بيقلب المعادلة. بدل ما يجهّز كل النتائج دفعة واحدة، بيجهّز نتيجة واحدة، يسلّمهالك، يستنى، وبعدين يجهّز اللي بعدها لما تطلب. ده اسمه التقييم الكسول (lazy evaluation).
قبل المفهوم: مثال أمين المخزن
تخيّل إنك طلبت من أمين مخزن جرد بـ 10 ملايين صنف. الطريقة الأولى: يحطّ القائمة كاملة على مكتبك، فالرام تتملي وممكن تقع. ده الـ list. الطريقة التانية: كل ما تقوله هات اللي بعده، يجيبلك صنف واحد بس. ده الـ generator: ورقة واحدة على المكتب في كل لحظة مهما كبر المخزن. النتيجة واحدة، لكن في التانية ماحتجتش مساحة تخزّن الكل مرة واحدة.
الـ Generator علميًا
الـ generator دالة فيها yield بدل return. نداء الدالة مابيشغّلش جسمها — بيرجّع كائن generator. كل مرة تطلب القيمة اللي بعدها بحلقة for أو بـ next()، الدالة بتشتغل لحد أول yield، بتسلّم القيمة، وبتتجمّد بكل متغيّراتها. لما تطلب تاني بتكمّل من حيث وقفت. فهي بتخزّن مكان الوقوف بس، فالذاكرة شبه ثابتة O(1) مقابل O(n) في الـ list.
الكود: اقرأ ملفًا عملاقًا سطرًا سطرًا
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
total = 0
for line in read_lines("access.log"):
total += len(line)
print(total)
كائن الملف في بايثون هو نفسه iterator كسول: بيقرأ سطر واحد في كل لفة، مش الملف كله.
الأرقام اللي قِستها بنفسي
عملت ملف 555 ميجابايت (6 ملايين سطر)، وقِست ذاكرة الذروة بـ tracemalloc على بايثون 3.11:
import tracemalloc
def with_generator(path):
def reader(p):
with open(p) as f:
for ln in f:
yield ln
return sum(len(ln) for ln in reader(path))
def with_readlines(path):
with open(path) as f:
lines = f.readlines()
return sum(len(ln) for ln in lines)
for name, fn in [("generator", with_generator), ("readlines", with_readlines)]:
tracemalloc.start()
fn("big.log")
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(name, round(peak / 1024 / 1024, 2), "MB")
- readlines(): ذروة 883 ميجابايت، أكبر من حجم الملف (1.6×) بسبب overhead كائنات str.
- generator: ذروة 22 كيلوبايت فقط، وثابتة.
الفرق حوالي 40,000 مرة، والناتج واحد. ده الفرق بين سكربت بيشتغل وسكربت بيقع على ملف كبير.
نفس الفكرة بسطر واحد: generator expression
squares_list = [x * x for x in range(10_000_000)] # list يتبني كله في الرام
squares_gen = (x * x for x in range(10_000_000)) # generator كسول
total = sum(len(line) for line in open("access.log", encoding="utf-8"))
الـ trade-offs اللي لازم تعرفها
- بتكسب: ذاكرة شبه ثابتة وبداية تنفيذ أسرع.
- بتخسر: الـ generator يُستهلك مرة واحدة؛ للمرور تاني اعمل واحد جديد أو استخدم list.
- مفيش فهرسة ولا طول: مايصحّش
gen[5]ولاlen(gen).
متى لا تستخدم الـ Generators
لو البيانات صغيرة وبتدخل الرام، أو محتاج تمرّ عليها أكتر من مرة، أو محتاج فهرسة وطول، أو مكتبة بتطلب list صريحة — الـ list أنسب. الـ generator للبيانات الكبيرة أو المتدفّقة أو اللي حجمها مش معروف.
الخطوة التالية
دوّر على أي .readlines() أو list(...) بيلفّ ملف أو query كبيرة، وحوّلها لـ for x in source مباشرة. شغّل سكربت القياس فوق على ملف حقيقي وقارن ذروة الذاكرة قبل وبعد.
المصادر
- توثيق بايثون — تعريف Generator: docs.python.org/3/glossary
- PEP 255 — Simple Generators: peps.python.org/pep-0255
- PEP 289 — Generator Expressions: peps.python.org/pep-0289
- توثيق بايثون — tracemalloc: docs.python.org/3/library/tracemalloc