مستوى المقال: مبتدئ. لو برنامجك بيقع فجأة بخطأ MemoryError وهو بيقرأ ملف كبير، بعد المقال ده هتعرف تقرأ ملف حجمه 10 جيجابايت بذاكرة ثابتة مش بتزيد عن بضعة ميجابايت، بسطر كود واحد.
المولّدات في بايثون: تقرأ الملف من غير ما تحمّله كله
المشكلة باختصار
عندك ملف log حجمه 10 جيجابايت وفيه 50 مليون سطر. عايز تعدّ الأسطر اللي فيها كلمة ERROR. أول حاجة بتخطر على البال: اقرأ الملف كله بـ readlines() وبعدين لُف عليه. الطريقة دي بتفشل. بتحمّل الـ 50 مليون سطر في الذاكرة دفعة واحدة، فالبرنامج ياكل ذاكرة أكبر من حجم الملف نفسه ويقع.
الافتراض هنا إن الملف أكبر من الرام المتاحة، أو إنك شغّال على سيرفر بذاكرة محدودة. لو الملف صغير ومضمون إنه يقعد في الذاكرة، المشكلة دي مش موجودة أصلًا.
الفكرة ببساطة قبل الكلام العلمي
تخيّل إنك عايز تقرأ رواية من 800 صفحة. فيه طريقتين. الأولى: تصوّر الكتاب كله ورق وتحطه قدامك على المكتب مرة واحدة، فتاخد مساحة ضخمة. التانية: تفتح الكتاب وتقرأ صفحة، تخلّصها، تقلب على اللي بعدها. في أي لحظة مفيش قدامك غير صفحة واحدة، فالمساحة اللي بتاخدها ثابتة سواء الكتاب 100 صفحة أو 10 آلاف.
المولّد (Generator) في بايثون هو الطريقة التانية بالظبط. بيدّيك عنصر واحد كل مرة، وينتظر لحد ما تطلب اللي بعده، بدل ما يجهّز القايمة كلها في الذاكرة.
ليه بيحصل ده علميًا
الدالة العادية بتشتغل من أول سطر لآخر سطر، وترجّع القيمة بـ return، وتنتهي. المولّد مختلف: بيستخدم كلمة yield بدل return. أول ما بايثون يشوف yield جوّه دالة، بيحوّلها لمولّد.
اللي بيحصل فعلاً إن الدالة بتقف عند yield وترجّع القيمة، بس محتفظة بحالتها كلها: المتغيّرات ومكان التنفيذ. أول ما تطلب العنصر اللي بعده، بتكمّل من نفس النقطة. ده اسمه التقييم الكسول (lazy evaluation): مفيش حاجة بتتحسب قبل ما تُطلَب. وملف بايثون نفسه مكرّر (iterator)؛ لما تلُف عليه بـ for، بيدّيك سطر واحد كل مرة من غير ما يحمّل الباقي.
الحل بالكود
ده المثال اللي بيفشل مع الملفات الكبيرة:
# الطريقة اللي بتاكل الذاكرة كلها
with open("app.log") as fh:
lines = fh.readlines() # 50 مليون سطر في الذاكرة دفعة واحدة
errors = [ln for ln in lines if "ERROR" in ln]
print(len(errors))
وده الحل بمولّد. لاحظ إن مفيش أكتر من سطر واحد في الذاكرة في أي لحظة: