المولدات (Generators): كيف تتعامل مع البيانات الضخمة بذكاء؟

تخيل أنك تحاول قراءة كتاب ضخم يحتوي على مليون صفحة. هل ستحاول حمل الكتاب كله وفتحه مرة واحدة؟ بالتأكيد لا، بل ستقرأ صفحة بصفحة ثم تنتقل للتي تليها. في البرمجة، عندما نتعامل مع ملايين السجلات أو الأرقام، فإن تخزينها كلها في الذاكرة (RAM) قد يؤدي لانهيار البرنامج. هنا يأتي دور Generators (المولدات). هي تقنية سحرية تسمح لك بإنتاج البيانات فقط عند الحاجة إليها، مما يوفر موارد جهازك بشكل مذهل ويجعل برامجك قادرة على معالجة بيانات "لا نهائية" دون أي مشكلة.

1. تعريف: ما هو المولد (Generator) وفلسفة "التقييم الكسول"؟

المولد (Generator) هو نوع خاص من الدوال التي لا تعيد قيمة واحدة وتتوقف، بل تعيد "كائن مكرر" (Iterator) يمكننا استخراج القيم منه واحدة تلو الأخرى. إنه مثل "آلة توزيع" تعطيك عنصراً واحداً في كل مرة تضغط فيها على الزر.

الفرق الجوهري بين الدالة العادية والمولد:

  • الدالة العادية: تُنفذ كل الكود، تُرجع قيمة واحدة بـ return، ثم تنتهي وتُنسى حالتها تماماً.
  • المولد: يُنفذ الكود حتى يصل لـ yield، يُرجع قيمة، ثم "يتجمد" (يحفظ حالته). عندما تطلب القيمة التالية، يستأنف من حيث توقف.

الفلسفة التي يعتمد عليها المولد تسمى Lazy Evaluation (التقييم الكسول). بدلاً من أن يكون المولد "نشيطاً" ويحسب كل شيء مسبقاً، فإنه يظل "خاملاً" ولا يقوم بأي عملية حسابية حتى تطلب منه أنت القيمة التالية. هذا يشبه مطعم الوجبات السريعة: لا يطبخون كل الوجبات مسبقاً، بل يطبخون فقط عندما يطلب الزبون.

كيف نميز المولد؟

المولد يشبه الدالة العادية تماماً، لكنه يستخدم كلمة yield بدلاً من return. وجود yield واحدة فقط في الدالة يحولها تلقائياً إلى مولد.

ملاحظة مهمة: المولدات هي نوع من الـ Iterators، لكن ليس كل Iterator هو مولد. المولدات طريقة سهلة لإنشاء Iterators دون الحاجة لكتابة classes معقدة بـ __iter__ و __next__.

2. لماذا نحتاج للمولدات؟ مشكلة الذاكرة

دعنا نفهم المشكلة أولاً قبل الحل. عندما تُنشئ قائمة كبيرة، تُخزن كل العناصر في الذاكرة (RAM) فوراً:

# إنشاء قائمة بمليون رقم
big_list = [i for i in range(1000000)]

# ماذا يحدث في الذاكرة؟
# ✅ يتم إنشاء مليون عنصر فوراً
# ✅ كل عنصر يأخذ مساحة في RAM
# ✅ إجمالي: حوالي 8 ميجابايت
# ❌ إذا كانت البيانات أكبر، قد تنفد الذاكرة!

المشاكل الناتجة:

  • استهلاك ذاكرة ضخم: قد يؤدي لبطء النظام أو انهيار البرنامج.
  • وقت انتظار طويل: يجب إنشاء كل العناصر قبل البدء بالمعالجة.
  • هدر موارد: قد لا تحتاج لكل العناصر، لكنها موجودة في الذاكرة.

الحل: المولدات! بدلاً من إنشاء كل العناصر، المولد يُنشئ عنصراً واحداً فقط عند الحاجة.

3. مقارنة الذاكرة: القائمة vs المولد

هذا هو الاختبار الحقيقي الذي يوضح لماذا نحتاج للمولدات. لنقارن بين إنشاء قائمة بمليون رقم وبين إنشاء مولد لنفس الأرقام.

memory_battle.py
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)} بايت فقط!")
النتيجة التقريبية
حجم القائمة في الذاكرة: 8.06 ميجابايت عدد العناصر: 1,000,000 حجم المولد في الذاكرة: 112 بايت فقط! الفرق: القائمة أكبر بـ 75536x مرة! مولد بمليار رقم: 112 بايت فقط!

شرح الكود سطراً بسطر:

  • السطر 1: نستورد sys لقياس حجم الكائنات في الذاكرة.
  • السطر 4: نُنشئ قائمة بمليون رقم باستخدام list comprehension (أقواس مربعة []).
  • السطر 5: sys.getsizeof() تُرجع حجم الكائن بالبايت.
  • السطر 10: نُنشئ مولداً بنفس الأرقام باستخدام generator expression (أقواس دائرية ()).
  • السطر 16: حتى مع مليار رقم، المولد يبقى بنفس الحجم الصغير!

تخيل الفرق! القائمة استهلكت ~8 ميجابايت، بينما المولد استهلك 112 بايت فقط، سواء كان يولد مليون رقم أو مليار رقم. المولد لا يخزن الأرقام، بل يخزن "الوصفة" لتوليدها.

4. تعبيرات المولد (Generator Expressions)

يمكنك إنشاء مولد بسيط في سطر واحد تماماً مثل List Comprehension، والفرق الوحيد هو استخدام الأقواس الدائرية () بدلاً من المربعة [].

generator_expressions.py
# 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("(فارغ - المولد استُنفد!)")
النتيجة
List: [0, 4, 16, 36, 64] النوع: Generator: at 0x...> النوع: استخراج القيم: 0 4 16 36 64 محاولة ثانية: (فارغ - المولد استُنفد!)

شرح الكود سطراً بسطر:

  • السطر 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)

عندما يكون منطق التوليد معقداً، نستخدم دالة كاملة. الميزة هنا هي أن الدالة "تتذكر" حالتها بين كل استدعاء وآخر. إنها مثل لعبة فيديو تحفظ تقدمك - عندما تعود، تبدأ من حيث توقفت.

custom_generator.py
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("لا توجد قيم أخرى!")
النتيجة
نوع timer: --- بدء العد التنازلي --- القيمة الأولى: 3 القيمة الثانية: 2 القيمة الثالثة: 1 --- انتهى الوقت! --- لا توجد قيم أخرى!

شرح الكود سطراً بسطر:

  • السطر 1: نعرّف دالة عادية، لكن وجود yield فيها يجعلها مولداً.
  • السطر 5: yield n تُرجع القيمة وتُجمد الدالة عند هذه النقطة.
  • السطر 6: عند طلب القيمة التالية، تستأنف الدالة من هنا.
  • السطر 10: استدعاء الدالة لا يُنفذها! بل يُنشئ كائن مولد فقط.
  • الأسطر 14-16: كل next() تستأنف الدالة حتى yield التالية.
  • السطر 7: يُطبع فقط بعد انتهاء الحلقة (بعد آخر next()).

6. تطبيقات واقعية مذهلة

أ) معالجة الملفات الضخمة:

إذا كان لديك ملف حجمه 10 جيجابايت، لا يمكنك فتحه بـ read() لأنه سيملأ الذاكرة. المولدات تسمح لك بقراءته سطراً بسطر.

file_processor.py
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}")
ب) توليد متسلسلات لا نهائية:

بما أن المولد لا يخزن البيانات، يمكنه توليد أرقام إلى الأبد! هذا مستحيل مع القوائم.

infinite_sequences.py
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}")
النتيجة
0 2 4 0 1 1 2 3 5 8 13 21 34 أول 20 رقم فيبوناتشي: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181]
ج) 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 بالتفصيل
المحرر الذكي

اكتب الكود وشاهد النتيجة فوراً

جرب الآن مجاناً
قناة ديف عربي

تابع أحدث الدروس والتحديثات مباشرة على واتساب

انضم الآن