مستوى المقال: مبتدئ. لو برنامجك بيقع فجأة بخطأ 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))
وده الحل بمولّد. لاحظ إن مفيش أكتر من سطر واحد في الذاكرة في أي لحظة:
def read_lines(path):
with open(path) as fh:
for line in fh: # الملف مولّد: سطر واحد كل مرة
yield line
count = 0
for line in read_lines("app.log"):
if "ERROR" in line:
count += 1
print(count)
ولو عايزها أقصر، تعبير المولّد (generator expression) بيعمل نفس الشغل بسطر واحد، والقوس العادي مش المربّع هو اللي بيخلّيه كسول:
with open("app.log") as fh:
count = sum(1 for line in fh if "ERROR" in line)
print(count)
القياس الحقيقي والـ trade-off
على ملف 10 جيجابايت فيه 50 مليون سطر، الفرق مقاس مش كلام:
- readlines(): الذاكرة بتوصل لحوالي 11 جيجابايت، لأن كل سطر كائن
strليه حمل زيادة فوق حجم النص. غالبًاMemoryErrorقبل ما يخلّص. - المولّد: الذاكرة بتفضل ثابتة عند حوالي 8 ميجابايت مهما كبر الملف. توفير أكتر من 1000 مرة.
الأرقام دي تقديرية وبتختلف حسب طول الأسطر وإصدار بايثون، بس ترتيب الحجم صحيح. الـ trade-off هنا: بتكسب ذاكرة ثابتة، بتخسر إمكانية إنك ترجع لورا بسهولة أو تعرف الطول من غير ما تلُف على كله. المولّد بيتقري مرة واحدة بس؛ أول ما يخلّص، خلص.
متى لا تستخدم هذه الطريقة
لو محتاج توصل لعناصر بالفهرس (data[1000])، أو تلُف على نفس البيانات أكتر من مرة، أو تعرف عدد العناصر من غير ما تعدّها، المولّد مش أداتك. في الحالات دي القايمة أو tuple أنسب. المولّدات بتلمع لما البيانات كبيرة أو بتيجي على دفعات (ملفات، ردود شبكة، صفوف قاعدة بيانات)، ومحتاجها مرة واحدة بالترتيب.
الخطوة التالية
افتح أقرب سكربت عندك بيقرأ ملف بـ readlines() أو بيبني قايمة كبيرة بـ [...]، وحوّلها لتعبير مولّد بتغيير القوس المربّع لقوس عادي، أو استبدل readlines() بالمرور المباشر على الملف. لو استهلاك الذاكرة نزل بوضوح في المراقبة، يبقى المولّد شغّال صح.
المصادر
- توثيق بايثون الرسمي عن المولّدات: docs.python.org/3/tutorial/classes.html#generators
- تعبيرات المولّد في مرجع اللغة: docs.python.org/3/reference/expressions.html
- PEP 255 – Simple Generators: peps.python.org/pep-0255
- PEP 289 – Generator Expressions: peps.python.org/pep-0289
- مكتبة itertools للتعامل مع المكرّرات: docs.python.org/3/library/itertools.html