مولدات Python: زيادة الأداء وتبسيط الشفرة

تخيل أنك تعمل على مشروع علم البيانات، ومهمتك هي معالجة مجموعة بيانات كبيرة إلى درجة أن تحميلها في الذاكرة يتسبب في تعطل جهازك. أو ربما تتعامل مع تسلسل لا نهائي، مثل بيانات مباشرة تتدفق، حيث لا يمكنك تخزين كل شيء بشكل متزامن. هذه هي التحديات التي تجعل علماء البيانات يلجؤون إلى قدر من القهوة—وأحيانًا، زر إعادة الضبط. 

في هذه المقالة، سنتعرف على مولِّدات Python، وكيف يمكنك استخدامها لتبسيط كودك. هذه الفكرة تتطلب بعض التمرين لذا، إذا كنت جديدًا على Python وتشعر ببعض الضياع في هذه المقالة، جرب دورتنا مقدمة إلى Python لبناء أساس قوي. 

ما هي مولدات Python؟

في جوهرها، مولّدات Python هي نوع خاص من الدوال أو حتى تعبير مُدمج يُنتج تسلسل قيم بشكل كسلان. فكر في المولّدات كسير ناقل في مصنع: بدلاً من تراص كل المنتجات في مكان واحد والاكتظاظ بالمساحة، يتم معالجة كل عنصر بمجرد وصوله. هذا يجعل المولّدات كفاءة في استهلاك الذاكرة وامتدادًا طبيعيًا لبروتوكول الـiterator في Python، الذي يستند إلى العديد من أدوات Python المدمجة مثل حلقات الـfor والاستيعاب.  

السحر الكامن وراء المولّدات يكمن في الكلمة المفتاحية yield. على عكس return، التي تُخرج قيمة واحدة وتخرج من الدالة، تنتج yield قيمة، توقف تنفيذ الدالة، ويتم حفظ حالتها. عند استدعاء المولّد مرة أخرى، يستأنف من حيث توقف. 

على سبيل المثال، تخيل أنك تقوم بقراءة ملف سجل ضخم سطراً بسطر. يمكن لمولّد معالجة كل سطر بمجرد قراءته دون تحميل الملف بأكمله في الذاكرة. هذا “التقييم الكسول” يميز المولّدات عن الدوال التقليدية ويجعلها أداة أساسية للمهام التي تتطلب أداءً مرتفع الحساسية.  

مثال بسيط على مولّد Python

لنمارس قليلاً لنتعود على الفكرة. ها هي وظيفة مولدة تنتج أول n أعداد صحيحة.  

def generate_integers(n): for i in range(n): yield i # توقف هنا ويرجع i # باستخدام المولد for num in generate_integers(5): print(num)
0 1 2 3 4

لقد أنشأت صورة توضيحية لمساعدتك على رؤية ما يحدث تحت الغطاء:  

بناء جملة مولد Python وأنماطه  

يمكن تنفيذ المولدين بطرق متعددة. ومع ذلك، هناك طريقتان رئيسيتان: وظائف المولد وتعابير المولد.  

وظائف المولد  

تُعرف وظيفة المولد مثل الوظيفة العادية ولكنها تستخدم كلمة yield بدلاً من return. عند استدعائها، تعيد كائن مولد يمكن تكراره.  

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # باستخدام المولد counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

من المثال أعلاه، يمكننا رؤية أنه عند استدعاء الوظيفة count_up_to، تعيد كائن مولد. في كل مرة يطلب فيها حلقة for قيمة، تعمل الوظيفة حتى تصل إلى yield، مما ينتج قيمة count الحالية ويحتفظ بحالتها بين التكرارات بحيث يمكنها استئناف العملية بالضبط من حيث توقفت.

تعابير المولدات

تعابير المولدات هي طريقة مدمجة لإنشاء مولدات. إنها مشابهة لتكوينات القوائم ولكن باستخدام القوسين بدلاً من الأقواس المربعة.

# تكوين القائمة (التقييم الواعي) squares_list = [x**2 for x in range(5)] # [0، 1، 4، 9، 16] # تعبير المولد (التقييم الكسلي) squares_gen = (x**2 for x in range(5)) # باستخدام المولد for square in squares_gen: print(square)
0 1 4 9 16

إذا، ما الفرق بين تكوين القائمة وتعبير المولد؟ ينشئ تكوين القائمة القائمة بأكملها في الذاكرة، بينما ينتج تعبير المولد قيمًا واحدة تلو الأخرى، مما يوفر الذاكرة. إذا كنت غير معتاد على تكوينات القوائم، يمكنك قراءة المزيد عنها في دليلنا حول تكوين القوائم في Python.

المولد في Python مقابل المكرر

كانت المكررات التقليدية في Python تتطلب فئات تحتوي على طرق __iter__() و __next__() واضحة، مما يتضمن العديد من النماذج الأساسية وإدارة الحالة يدويًا، على عكس وظائف المولد التي تبسط العملية عن طريق الحفاظ تلقائيًا على الحالة والقضاء على الحاجة لهذه الطرق— كما يُظهرها وظيفة بسيطة تُعيد تربيع كل رقم حتى n.

لماذا نستخدم مولدي Python

في شرح مفهوم ما هي مولدات Python، نقلت أيضًا بعض الأفكار حول سبب استخدامها. في هذا القسم، أود التفصيل قليلاً أكثر. لأن المولدات ليست ميزة Python مبهرة فقط وإنما هي تحل في الواقع مشاكل حقيقية.

كفاءة الذاكرة

على عكس القوائم أو المصفوفات التي تخزن جميع عناصرها في الذاكرة في نفس الوقت، تنتج المولدات قيمًا عند الحاجة، لذا يتم الاحتفاظ بعنصر واحد فقط في الذاكرة في كل مرة.

على سبيل المثال، تفكر في الفرق بين range() و xrange() في Python 2:

  • range() أنشأت قائمة في الذاكرة، وهو ما قد يكون مشكلة لنطاقات كبيرة.

  • xrange() كان يتصرف كمولد، ينتج القيم بشكل كسلان.

نظرًا لأن سلوك xrange() كان أكثر فائدة، فإنه الآن في Python 3، range() يتصرف أيضًا كمولد، لذلك يتجنب الاستهلاك الزائد للذاكرة من تخزين جميع القيم مباشرة.

لنقم بمقارنة استخدام الذاكرة عند إنشاء تسلسل من 10 ملايين رقم:

import sys # باستخدام قائمة numbers_list = [x for x in range(10_000_000)] print(f"Memory used by list: {sys.getsizeof(numbers_list) / 1_000_000:.2f} MB") # باستخدام مولد numbers_gen = (x for x in range(10_000_000)) print(f"Memory used by generator: {sys.getsizeof(numbers_gen)} bytes")
Memory used by list: 89.48 MB Memory used by the generator: 112 bytes

كما يمكنك رؤية، يستخدم المولد تقريبًا لا يوجد ذاكرة مقارنة بالقائمة، وهذا الفرق ملحوظ.

تحسينات الأداء 

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

على سبيل المثال، تخيل جمع تربيعات أول مليون رقم:

# باستخدام قائمة (التقييم النشط) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # باستخدام مولد (التقييم الكسلي) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

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

البساطة والقراءة 

تبسيط المنتجات يبسط تنفيذ المكررات عن طريق القضاء على رموز القالب الزائدة. قارن بين المكرر القائم على الفئة ووظيفة المنتج:

ها هو المكرر القائم على الفئة:

class SquaresIterator: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration result = self.current ** 2 self.current += 1 return result # الاستخدام squares = SquaresIterator(5) for square in squares: print(square)

ها هي وظيفة المنتج:

def squares_generator(n): for i in range(n): yield i ** 2 # الاستخدام squares = squares_generator(5) for square in squares: print(square)

النسخة المنتجة أقصر وأسهل قراءة، ولا تتطلب رموز القالب. إنه مثال مثالي على فلسفة Python: البساطة هي الأفضل.

معالجة التسلسلات غير المنتهية

أخيرًا، أريد أن أقول أن المنتجات مناسبة بشكل فريد لتمثيل تسلسلات لا نهائية، شيء لا يمكن ببساطة تحقيقه بالقوائم. على سبيل المثال، توخي تسلسل فيبوناتشي:

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b # الاستخدام fib = fibonacci() for _ in range(10): print(next(fib))
0 1 1 2 3 5 8 13 21 34

يمكن لهذا المولد إنتاج أرقام فيبوناتشي بلا حدود دون استنزاف الذاكرة. أمثلة أخرى تشمل معالجة تيارات البيانات الحية أو العمل مع بيانات السلاسل الزمنية.

مفاهيم مولدات Python المتقدمة

الآن، دعونا نلقي نظرة على بعض الأفكار الأصعب. في هذا القسم، سنستكشف كيفية تكوين المولدات واستخدام طرق المولد الفريدة مثل .send()، .throw()، و .close().

ربط المولدات معًا

يمكن دمج المولدات. يمكنك تحويل، تصفية، ومعالجة البيانات بشكل وحداتي عن طريق ربط المولدات معًا.

لنفترض أن لديك سلسلة لا نهائية من الأرقام وترغب في تربيع كل رقم وتصفية النتائج الفردية:

def infinite_sequence(): num = 0 while True: yield num num += 1 def square_numbers(sequence): for num in sequence: yield num ** 2 def filter_evens(sequence): for num in sequence: if num % 2 == 0: yield num # قم بتكوين المولدات numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # اطبع أول 10 تربيعات زوجية for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

ينطوي العملية على وظيفة infinite_sequence توليد الأرقام بشكل لا نهائي، بينما تُعيد وظيفة square_numbers تربيع كل رقم، ثم تقوم وظيفة filter_evens بتصفية الأرقام الفردية لتنتج فقط المربعات الزوجية.مسارنا الوظيفي مطور Python مشارك يتناول هذا النوع من الأمور، حتى تتمكن من رؤية كيفية بناء وتصحيح الأنابيب المعقدة باستخدام المولدات، فضلاً عن المكررات وتكامل القوائم.

طرق توليد خاصة  

تأتي المولدات مع طرق متقدمة تسمح بالاتصال ذو الاتجاهين والإنهاء المُتحكم فيه.

send()

تُمكنك طريقة .send() من تمرير القيم مرة أخرى إلى مولد، محوّلة إياه إلى تعاقب. هذا مفيد لإنشاء مولدين تفاعليين أو ذوي حالة.  

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # باستخدام المولد acc = accumulator() next(acc) # ابدأ المولد print(acc.send(10)) # الناتج: 10 print(acc.send(5)) # الناتج: 15 print(acc.send(20)) # الناتج: 35

ها هي الطريقة التي تعمل بها:

  • يبدأ المولد بـ next(acc) لتهيئته.

  • كل استدعاء لـ .send(value) يمرر قيمة إلى المولد، والتي تُسند إلى value في تعليمة yield.

  • يقوم المولد بتحديث حالته (total) ويُرجع النتيجة الجديدة.

throw()

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

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # باستخدام المولد gen = resilient_generator() print(next(gen)) # الناتج: 0 print(next(gen)) # الناتج: 1 print(gen.throw(ValueError)) # الناتج: "حدث خطأ!"

ها هي كيف يعمل هذا: 

  • المولد عادة ما يستمر حتى يتم استدعاء .throw().  

  •  يتم رفع الاستثناء داخل المولد، الذي يمكنه التعامل معه باستخدام كتلة try-except.

close()

يقوم الأسلوب .close() بإيقاف المولد عن طريق رفع استثناء GeneratorExit. هذا مفيد لتنظيف الموارد أو إيقاف المولدات اللانهائية.

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # باستخدام المولد counter = infinite_counter() print(next(counter)) # الناتج: 0 print(next(counter)) # الناتج: 1 counter.close() # الناتج: "تم إغلاق المولد!"

وهنا كيف يعمل:

  • يعمل المولد حتى يتم استدعاء .close().

  • تُثار استثناء GeneratorExit، مما يسمح للمولد بتنظيف نفسه أو تسجيل رسالة قبل الانتهاء.

تطبيقات العالم الحقيقي في علم البيانات  

آمل أن تبدأ في تقدير فائدة المولّدين. في هذا القسم، سأحاول إبراز حالات الاستخدام بحيث تتمكن من تصوّر كيف تعمل فعليًا بالنسبة لك في حياتك اليومية.

معالجة مجموعات بيانات كبيرة  

واحدة من التحديات الأكثر شيوعًا في علم البيانات هي العمل مع مجموعات بيانات كبيرة جدًا لا تتناسب مع الذاكرة. المولّدات توفر طريقة لمعالجة هذه البيانات سطرًا بسطر.

تخيل أن لديك ملف CSV بحجم 10 جيجابايت يحتوي على بيانات المبيعات وتحتاج إلى تصفية السجلات لمنطقة محددة. إليك كيف يمكنك استخدام أنبوب مولد لتحقيق ذلك:

import csv def read_large_csv(file_path): """ Generator to read a large CSV file line by line.""" with open(file_path, mode="r") as file: reader = csv.DictReader(file) for row in reader: yield row def filter_by_region(data, region): """ Generator to filter rows by a specific region.""" for row in data: if row["Region"] == region: yield row # أنبوب المولد file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # معالجة البيانات المصفاة for record in filtered_data: print(record)

هنا ما يحدث:

  1. read_large_csv يقرأ الملف سطراً بسطر، مع تقديم كل صف كقاموس.

  2. filter_by_region يقوم بتصفية الصفوف استنادًا إلى المنطقة المحددة.

  3. تقوم الخطوط العريضة بمعالجة البيانات تدريجياً، مما يجنب حدوث تحميل زائد على الذاكرة.

تستفيد هذه النهج من سير العمل لاستخراج البيانات وتحويلها وتحميلها، حيث يجب تنظيف البيانات وتحويلها قبل التحليل. سترى هذا النوع من الأمور في دورتنا ETL and ELT in Python.

بث البيانات والخطوط العريضة  

أحيانًا يصل البيانات على شكل تيار مستمر. فكر في بيانات الاستشعار، والبث المباشر، أو وسائل التواصل الاجتماعي.

من المفترض أنك تعمل مع أجهزة الإنترنت الأشياء التي تولد قراءات الحرارة كل ثانية. تريد حساب متوسط درجة الحرارة على نافذة متحركة تحتوي على 10 قراءات:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # تحاكي بيانات الاستشعار def sliding_window_average(stream, window_size): """ Calculate the average over a sliding window of readings.""" window = [] for value in stream: window.append(value) if len(window) > window_size: window.pop(0) if len(window) == window_size: yield sum(window) / window_size # خط إنتاج المولد sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # طباعة المتوسط كل ثانية for avg in averages: print(f"Average temperature: {avg:.2f}")

ها هي الشرح:

  1. sensor_data_stream تحاكي تيارًا لا نهاية له من قراءات الاستشعار.

  2. sliding_window_average يحتفظ بنافذة منزلقة لآخر 10 قراءات ويعيد متوسطها.

  3. تقوم الخطوط العريضة بمعالجة البيانات في الوقت الحقيقي، مما يجعلها مثالية للمراقبة والتحليلات.

حالات استخدام إضافية

تُستخدم المُولِّدات أيضًا في الحالات التي تكون فيها حجم البيانات غير متوقع أو عندما تستمر البيانات في الوصول بشكل لا نهائي.

جلب محتوى الويب

عند جلب مواقع الويب، غالبًا ما لا تعرف كم صفحة أو عناصر ستحتاج إلى معالجتها. تسمح المولدات لك بالتعامل مع هذه العدم التنبؤ بأناقة:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # محاكاة جلب وتحليل صفحة print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # وظيفة وهمية للحصول على الصفحة التالية # الاستخدام scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

مهام المحاكاة

في المحاكاة، مثل طرق مونتي كارلو أو تطوير الألعاب، يمكن للمولدات تمثيل سلاسل لا نهائية أو ديناميكية:

def monte_carlo_simulation(): """ Generator to simulate random events for Monte Carlo analysis.""" import random while True: yield random.random() # الاستخدام simulation = monte_carlo_simulation() for _ in range(10): print(next(simulation))

مقاييس الذاكرة والسرعة

بسبب كيفية عملها، تتفوق المولدات في السيناريوهات التي تكون فيها كفاءة الذاكرة حاسمة، ولكن (قد يفاجئك أن) قد لا تكون دائمًا الخيار الأسرع. دعنا نقارن بين المولدات والقوائم لفهم تضحياتها.

سابقًا، أظهرنا كيف أن المُولِّدات أفضل من القوائم من حيث الذاكرة. كانت هذه الجزء حيث قمنا بمقارنة استخدام الذاكرة عند إنشاء سلسلة من 10 ملايين رقم. دعونا نقوم الآن بشيء مختلف، وهو مقارنة السرعة:

import time # تعبير القائمة start_time = time.time() sum([x**2 for x in range(1_000_000)]) print(f"List comprehension time: {time.time() - start_time:.4f} seconds") # تعبير المولِّد start_time = time.time() sum(x**2 for x in range(1_000_000)) print(f"Generator expression time: {time.time() - start_time:.4f} seconds")
List comprehension time: 0.1234 seconds Generator expression time: 0.1456 seconds

بينما يوفر المولد الذاكرة، في هذه الحالة، فإنه أبطأ من القائمة. هذا يعود لأنه، لمجموعة البيانات الصغيرة هذه، يوجد تكاليف توقف التنفيذ واستئنافه.

الفارق في الأداء يكاد يكون لا يُلاحظ في حالة البيانات الصغيرة، ولكن بالنسبة للبيانات الكبيرة، يكون توفير الذاكرة من المولِّدات غالبًا أكبر من العقوبة الطفيفة في السرعة.

المشاكل التي تطرأ

أخيرًا، دعونا نلقي نظرة على بعض الأخطاء أو المشاكل الشائعة:

المُولِّدات يمكن أن تُستنفد

بمجرد استنفاد المولد، لا يمكن إعادة استخدامه. ستحتاج إلى إعادة إنشائه إذا كنت ترغب في التكرار مرة أخرى.

gen = (x for x in range(5)) print(list(gen)) # الناتج: [0، 1، 2، 3، 4] print(list(gen)) # الناتج: [] (المولد قد استنفد)

التقييم الكسلي قد يكون صعبًا

نظرًا لأن المولدات تنتج قيم حسب الطلب، قد لا تظهر الأخطاء أو الآثار الجانبية حتى يتم تكرار المولد.

يمكنك إساءة استخدام المولدات

بالنسبة للمجموعات البيانات الصغيرة أو المهام البسيطة، قد لا تكون التكلفة الزائدة لاستخدام مولد تستحق توفير الذاكرة. اعتبر هذا المثال حيث أقوم بتجسيد البيانات لعدة تكرارات.

# تعبير المولد gen = (x**2 for x in range(10)) # تجسيد إلى قائمة squares = list(gen) # إعادة استخدام القائمة print(sum(squares)) # الناتج: 285 print(max(squares)) # الناتج: 81

اختيار متى يجب استخدام المولدات

للمراجعة، سأقدم بعض القواعد العامة جدًا حول متى يجب استخدام المُنشئات. استخدمها للـ:

  • مجموعات بيانات كبيرة: استخدم المنشئات عند العمل مع مجموعات بيانات كبيرة جدًا لا تتناسب مع الذاكرة.  
  • سلاسل لانهائية: استخدم المنشئات لتمثيل سلاسل لانهائية، مثل تيارات البيانات الحية أو المحاكيات.
  • أنابيب البيانات: استخدم المنشئات لبناء أنابيب معالجة البيانات القائمة على النماذج التي تقوم بتحويل وتصفية البيانات تدريجيًا.  

متى نقوم بتحويل البيانات إلى شكل قائمة

  • المجموعات البيانية الصغيرة: لا تستخدم المولدات إذا لم يكن هناك مشكلة في الذاكرة وتحتاج إلى وصول سريع إلى جميع العناصر. بدلاً من ذلك، استخدم قائمة.  
  • عدة تكرارات: لا تستخدم المولدات إذا كنت بحاجة لتكرار نفس البيانات عدة مرات، بدلاً من ذلك، قم بتحويلها إلى قائمة لتجنب إعادة إنشاء المولد.  

الاستنتاج والأفكار الرئيسية

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

بمجرد أن تتقن الأساسيات، يمكنك استكشاف مواضيع جديدة وأكثر تقدمًا تعتمد على مفهوم المولد:  

  • الروتينات الفرعية: استخدم .send() و .throw() لإنشاء مولدات يمكنها استقبال البيانات ومعالجتها، مما يتيح التواصل ذو الاتجاهين.  

  • البرمجة الغير متزامنة: قم بدمج المولِّدات مع مكتبة asyncio في Python لبناء تطبيقات فعالة وغير مانعة.  

  • التوازي: تعلم كيف يمكن للمولِّدات تنفيذ التعدد التعاوني والتوازي الخفيف.  

استمر في التعلم وكن خبيرًا. انضم إلى مسار حياتنا المهنية مطور بايثون أو مسار مهارات برمجة بايثون اليوم. انقر على الرابط أدناه للبدء.

Source:
https://www.datacamp.com/tutorial/python-generators