المولدات (Generators): كيف تتعامل مع البيانات الضخمة بذكاء؟
تخيل أنك تحاول قراءة كتاب ضخم يحتوي على مليون صفحة. هل ستحاول حمل الكتاب كله وفتحه مرة واحدة؟ بالتأكيد لا، بل ستقرأ صفحة بصفحة ثم تنتقل للتي تليها. في البرمجة، عندما نتعامل مع ملايين السجلات أو الأرقام، فإن تخزينها كلها في الذاكرة (RAM) قد يؤدي لانهيار البرنامج. هنا يأتي دور Generators (المولدات). هي تقنية سحرية تسمح لك بإنتاج البيانات فقط عند الحاجة إليها، مما يوفر موارد جهازك بشكل مذهل ويجعل برامجك قادرة على معالجة بيانات "لا نهائية" دون أي مشكلة.
1. تعريف: ما هو المولد (Generator) وفلسفة "التقييم الكسول"؟
المولد (Generator) هو نوع خاص من الدوال التي لا تعيد قيمة واحدة وتتوقف، بل تعيد "كائن مكرر" (Iterator) يمكننا استخراج القيم منه واحدة تلو الأخرى. إنه مثل "آلة توزيع" تعطيك عنصراً واحداً في كل مرة تضغط فيها على الزر.
الفرق الجوهري بين الدالة العادية والمولد:
- الدالة العادية: تُنفذ كل الكود، تُرجع قيمة واحدة بـ
return، ثم تنتهي وتُنسى حالتها تماماً. - المولد: يُنفذ الكود حتى يصل لـ
yield، يُرجع قيمة، ثم "يتجمد" (يحفظ حالته). عندما تطلب القيمة التالية، يستأنف من حيث توقف.
الفلسفة التي يعتمد عليها المولد تسمى Lazy Evaluation (التقييم الكسول). بدلاً من أن يكون المولد "نشيطاً" ويحسب كل شيء مسبقاً، فإنه يظل "خاملاً" ولا يقوم بأي عملية حسابية حتى تطلب منه أنت القيمة التالية. هذا يشبه مطعم الوجبات السريعة: لا يطبخون كل الوجبات مسبقاً، بل يطبخون فقط عندما يطلب الزبون.
كيف نميز المولد؟
المولد يشبه الدالة العادية تماماً، لكنه يستخدم كلمة yield بدلاً من return. وجود yield واحدة فقط في الدالة يحولها تلقائياً إلى مولد.
__iter__ و __next__.
2. لماذا نحتاج للمولدات؟ مشكلة الذاكرة
دعنا نفهم المشكلة أولاً قبل الحل. عندما تُنشئ قائمة كبيرة، تُخزن كل العناصر في الذاكرة (RAM) فوراً:
# إنشاء قائمة بمليون رقم
big_list = [i for i in range(1000000)]
# ماذا يحدث في الذاكرة؟
# ✅ يتم إنشاء مليون عنصر فوراً
# ✅ كل عنصر يأخذ مساحة في RAM
# ✅ إجمالي: حوالي 8 ميجابايت
# ❌ إذا كانت البيانات أكبر، قد تنفد الذاكرة!
المشاكل الناتجة:
- استهلاك ذاكرة ضخم: قد يؤدي لبطء النظام أو انهيار البرنامج.
- وقت انتظار طويل: يجب إنشاء كل العناصر قبل البدء بالمعالجة.
- هدر موارد: قد لا تحتاج لكل العناصر، لكنها موجودة في الذاكرة.
الحل: المولدات! بدلاً من إنشاء كل العناصر، المولد يُنشئ عنصراً واحداً فقط عند الحاجة.
3. مقارنة الذاكرة: القائمة vs المولد
هذا هو الاختبار الحقيقي الذي يوضح لماذا نحتاج للمولدات. لنقارن بين إنشاء قائمة بمليون رقم وبين إنشاء مولد لنفس الأرقام.
import sys
# 1. القائمة: تحجز مكاناً لكل رقم فوراً
my_list = [i for i in range(1000000)]
list_size = sys.getsizeof(my_list)
print(f"حجم القائمة في الذاكرة: {list_size / 1024 / 1024:.2f} ميجابايت")
print(f"عدد العناصر: {len(my_list):,}")
# 2. المولد: يحجز مكاناً لـ 'خطة التوليد' فقط
my_gen = (i for i in range(1000000))
gen_size = sys.getsizeof(my_gen)
print(f"\nحجم المولد في الذاكرة: {gen_size} بايت فقط!")
print(f"الفرق: القائمة أكبر بـ {list_size / gen_size:.0f}x مرة!")
# 3. حتى لو زدنا الأرقام، المولد يبقى بنفس الحجم!
huge_gen = (i for i in range(1000000000)) # مليار رقم!
print(f"\nمولد بمليار رقم: {sys.getsizeof(huge_gen)} بايت فقط!")
شرح الكود سطراً بسطر:
- السطر 1: نستورد
sysلقياس حجم الكائنات في الذاكرة. - السطر 4: نُنشئ قائمة بمليون رقم باستخدام list comprehension (أقواس مربعة
[]). - السطر 5:
sys.getsizeof()تُرجع حجم الكائن بالبايت. - السطر 10: نُنشئ مولداً بنفس الأرقام باستخدام generator expression (أقواس دائرية
()). - السطر 16: حتى مع مليار رقم، المولد يبقى بنفس الحجم الصغير!
تخيل الفرق! القائمة استهلكت ~8 ميجابايت، بينما المولد استهلك 112 بايت فقط، سواء كان يولد مليون رقم أو مليار رقم. المولد لا يخزن الأرقام، بل يخزن "الوصفة" لتوليدها.
4. تعبيرات المولد (Generator Expressions)
يمكنك إنشاء مولد بسيط في سطر واحد تماماً مثل List Comprehension، والفرق الوحيد هو استخدام الأقواس الدائرية () بدلاً من المربعة [].
# List Comprehension - ينشئ قائمة كاملة فوراً
squares_list = [x**2 for x in range(10) if x % 2 == 0]
print(f"List: {squares_list}")
print(f"النوع: {type(squares_list)}")
# Generator Expression - ينشئ مولداً
squares_gen = (x**2 for x in range(10) if x % 2 == 0)
print(f"\nGenerator: {squares_gen}")
print(f"النوع: {type(squares_gen)}")
# استخراج القيم من المولد
print("\nاستخراج القيم:")
for val in squares_gen:
print(val, end=" ")
# محاولة المرور مرة أخرى - لن يعمل!
print("\n\nمحاولة ثانية:")
for val in squares_gen:
print(val, end=" ")
print("(فارغ - المولد استُنفد!)")
شرح الكود سطراً بسطر:
- السطر 2: List comprehension بأقواس مربعة - تُنشئ قائمة كاملة.
- السطر 7: Generator expression بأقواس دائرية - تُنشئ مولداً.
- الأسطر 12-14: نمر على المولد بحلقة
for. كل قيمة تُحسب عند الطلب. - الأسطر 17-20: نقطة مهمة: المولدات تُستنفد بعد الاستخدام! لا يمكن المرور عليها مرة أخرى.
استخدام next() لاستخراج القيم يدوياً:
gen = (x * 2 for x in range(5))
print(next(gen)) # 0
print(next(gen)) # 2
print(next(gen)) # 4
print(next(gen)) # 6
print(next(gen)) # 8
# print(next(gen)) # سيرمي خطأ StopIteration
# يمكن استخدام قيمة افتراضية
print(next(gen, "انتهى المولد")) # انتهى المولد
5. دوال المولد (Generator Functions)
عندما يكون منطق التوليد معقداً، نستخدم دالة كاملة. الميزة هنا هي أن الدالة "تتذكر" حالتها بين كل استدعاء وآخر. إنها مثل لعبة فيديو تحفظ تقدمك - عندما تعود، تبدأ من حيث توقفت.
def countdown(n):
"""مولد للعد التنازلي"""
print("--- بدء العد التنازلي ---")
while n > 0:
yield n # توقف هنا وأرجع القيمة
n -= 1 # عند الاستئناف، نكمل من هنا
print("--- انتهى الوقت! ---")
# إنشاء كائن المولد (لم يُنفذ أي كود بعد!)
timer = countdown(3)
print(f"نوع timer: {type(timer)}\n")
# الآن نبدأ بطلب القيم
print(f"القيمة الأولى: {next(timer)}")
print(f"القيمة الثانية: {next(timer)}")
print(f"القيمة الثالثة: {next(timer)}")
# محاولة طلب قيمة رابعة - سيطبع "انتهى الوقت" ثم يرمي خطأ
try:
print(f"القيمة الرابعة: {next(timer)}")
except StopIteration:
print("لا توجد قيم أخرى!")
شرح الكود سطراً بسطر:
- السطر 1: نعرّف دالة عادية، لكن وجود
yieldفيها يجعلها مولداً. - السطر 5:
yield nتُرجع القيمة وتُجمد الدالة عند هذه النقطة. - السطر 6: عند طلب القيمة التالية، تستأنف الدالة من هنا.
- السطر 10: استدعاء الدالة لا يُنفذها! بل يُنشئ كائن مولد فقط.
- الأسطر 14-16: كل
next()تستأنف الدالة حتىyieldالتالية. - السطر 7: يُطبع فقط بعد انتهاء الحلقة (بعد آخر
next()).
6. تطبيقات واقعية مذهلة
أ) معالجة الملفات الضخمة:
إذا كان لديك ملف حجمه 10 جيجابايت، لا يمكنك فتحه بـ read() لأنه سيملأ الذاكرة. المولدات تسمح لك بقراءته سطراً بسطر.
def read_huge_file(file_path):
"""قراءة ملف ضخم سطراً بسطر"""
with open(file_path, 'r', encoding='utf-8') as file:
for line in file:
# معالجة السطر (إزالة المسافات، تحويل لأحرف صغيرة، إلخ)
yield line.strip().lower()
# الاستخدام - يقرأ سطراً واحداً فقط في كل دورة
# for line in read_huge_file("big_data.txt"):
# if "error" in line:
# print(f"وجدنا خطأ: {line}")
# مثال عملي: عد الأسطر التي تحتوي على كلمة معينة
def count_keyword(file_path, keyword):
count = 0
for line in read_huge_file(file_path):
if keyword in line:
count += 1
return count
# استخدام
# errors = count_keyword("server.log", "error")
# print(f"عدد الأخطاء: {errors}")
ب) توليد متسلسلات لا نهائية:
بما أن المولد لا يخزن البيانات، يمكنه توليد أرقام إلى الأبد! هذا مستحيل مع القوائم.
def infinite_even_numbers():
"""مولد أرقام زوجية لا نهائية"""
n = 0
while True: # حلقة لا نهائية!
yield n
n += 2
# استخدام
evens = infinite_even_numbers()
print(next(evens)) # 0
print(next(evens)) # 2
print(next(evens)) # 4
# ... يمكن الاستمرار إلى الأبد
# مثال: متسلسلة فيبوناتشي اللانهائية
def fibonacci():
"""مولد أرقام فيبوناتشي"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# أول 10 أرقام فيبوناتشي
fib = fibonacci()
for i in range(10):
print(next(fib), end=" ")
print()
# يمكن استخدام itertools.islice لأخذ عدد محدد
from itertools import islice
fib2 = fibonacci()
first_20 = list(islice(fib2, 20))
print(f"\nأول 20 رقم فيبوناتشي: {first_20}")
ج) Pipeline معالجة البيانات:
def read_numbers(file_path):
"""قراءة أرقام من ملف"""
with open(file_path) as f:
for line in f:
yield int(line.strip())
def filter_even(numbers):
"""تصفية الأرقام الزوجية"""
for num in numbers:
if num % 2 == 0:
yield num
def square(numbers):
"""تربيع الأرقام"""
for num in numbers:
yield num ** 2
# Pipeline: قراءة → تصفية → تربيع
# pipeline = square(filter_even(read_numbers("data.txt")))
# for result in pipeline:
# print(result)
# كل خطوة تعمل على عنصر واحد فقط في الذاكرة!
7. متى تستخدم المولدات ومتى تتجنبها؟
| الموقف | استخدم Generator | استخدم List |
|---|---|---|
| البيانات ضخمة جداً | ✅ نعم | ❌ لا |
| تحتاج للمرور عدة مرات | ❌ لا | ✅ نعم |
| تحتاج للوصول العشوائي (indexing) | ❌ لا | ✅ نعم |
| معالجة ملفات كبيرة | ✅ نعم | ❌ لا |
| متسلسلات لا نهائية | ✅ نعم | ❌ مستحيل |
| تحتاج لـ len() أو slicing | ❌ لا | ✅ نعم |
قواعد ذهبية:
- استخدم المولدات عندما:
- تكون البيانات ضخمة ولا تريد تحميلها كلها في الذاكرة.
- تكون الحسابات مكلفة وتريد حساب القيمة فقط عندما يحتاجها المستخدم فعلياً.
- تريد بناء pipeline معالجة بيانات كفؤ.
- تتعامل مع متسلسلات لا نهائية.
- تجنب المولدات عندما:
- تحتاج للمرور على البيانات عدة مرات.
- تحتاج للوصول العشوائي (مثل
data[5]). - تحتاج لمعرفة الطول (
len()). - البيانات صغيرة ويمكن تحميلها بسهولة.
8. أخطاء شائعة يجب تجنبها
خطأ 1: محاولة استخدام len() على مولد
gen = (x for x in range(10))
# len(gen) # TypeError: object of type 'generator' has no len()
# الحل: حوله لقائمة إذا كنت بحاجة للطول
gen_list = list(gen)
print(len(gen_list)) # 10
خطأ 2: توقع إعادة استخدام المولد
gen = (x * 2 for x in range(5))
# المرور الأول
for x in gen:
print(x, end=" ") # 0 2 4 6 8
# المرور الثاني - لن يطبع شيئاً!
for x in gen:
print(x, end=" ") # (فارغ)
# الحل: أنشئ مولداً جديداً
gen = (x * 2 for x in range(5))
for x in gen:
print(x, end=" ") # 0 2 4 6 8
ملخص الدرس
- Generators: هي دوال تنتج قيماً عند الطلب باستخدام
yield. - تعتمد مبدأ Lazy Evaluation لتوفير الذاكرة بشكل هائل.
- تستخدم الأقواس الدائرية
()في التعبيرات المختصرة. - مثالية لمعالجة الملفات الكبيرة والمتسلسلات اللانهائية.
- لا تخزن النتائج، بل تخزن "الحالة" الحالية للدالة فقط.
- يمكن المرور عليها مرة واحدة فقط.
- لا تدعم
len()أو الوصول بالـ index. - توفر الذاكرة بنسبة تصل إلى 99% مقارنة بالقوائم!
الخطوة التالية
لنتعمق في المحرك الحقيقي للمولدات ونفهم كيف تقوم كلمة yield بتجميد الزمن داخل الدالة.
الدرس التالي: الكلمة المفتاحية yield بالتفصيل