الـ 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: