درآمدی بر مباحث Iterator و Generator در زبان برنامه‌نویسی پایتون

درآمدی بر مباحث Iterator و Generator در زبان برنامه‌نویسی پایتون

در این پست آموزشی قصد داریم تا به تفصیل دربارۀ فرایند پیمایش دیتاتایپ‌‌هایی مانند لیست، دیکشنری و غیره از یک سو و همچنین مباحث مرتبط با آنها مثل Iterator و Generator از سوی دیگر نکاتی را برای دولوپرهای تازه‌کار پایتون که قصد دارند دانش خود را در این حوزه‌ها عمیق‌تر سازند نکاتی را بیان کنیم.

هر خط کدی که در فرایند توسعهٔ نرم‌افزار نوشته می‌‌شود، از دو حالت زیر خارج نیست:
- به منظور ایجاد و یا لود نوعی دیتا نوشته‌ می‌شود
- و یا عملیات خاصی بر روی یک دیتا است

با این تفاسیر، بایستی این نکته را همواره مد نظر داشته باشیم دیتایی که تعریف می‌‌کنیم خود انواع مختلفی دارد که به آن دیتاتایپ گفته می‌‌شود و بخش عمده‌‌ای از آنها در همۀ زبان‌‌های برنامه‌نویسی یکسان می‌‌باشند؛ از جمله دیتاتایپ‌‌های معروف می‌‌توان به اعداد (که خود شامل انواع مختلفی از جمله اعداد صحیح، اعداد اعشاری، اعداد مختلط و ... است)، کاراکترها (یا همان حروف)، بولین (یا همان داده‌‌های منطقی) و ... اشاره کرد.

در ادامه، قصد داریم در مورد دیتاتایپ‌‌هایی صحبت کنیم که از اِجماع چند دیتاتایپ یکسان و یا غیریکسان تشکیل می‌‌شوند که در زبان‌های برنامه‌نویسی عمدتاً Array (آرایه) نامیده می‌شوند ولی در زبان برنامه‌نویسی پایتون، به آنها List می‌‌گوییم. از دیگر دیتاتایپ‌‌های معروف که شبیه لیست هستند می‌توان به dictionary ،tuple ،set و forzenset اشاره کرد (و البته هر آبجکت دیگری که حتی خودتان تعریف کرده باشید و شامل مجموعه‌ای از عناصر به هم پیوسته باشد را می‌‌توان در این قالب قرار داد).

آشنایی با مفهوم Iterator در زبان پایتون
هنگامی که یک لیست تعریف می‌‌کنیم، با استفاده از فانکشن dir می‌‌توانیم فهرستی از تمامی متدها و خصوصیات کلاس لیست را ببینیم. نکته‌ای که در اینجا می‌بایست حتماً مد نظر داشته باشیم این است که  فانکشن ()dir یک آبجکت را به عنوان آرگومان وردی گرفته و لیستی از متدها و اَتریبیوت‌هایی که اصطلاحاً private نیستند را برمی‌‌گرداند (چه آبجکتی که خودتان تعریف کرده باشید و چه آبجکتی که جزو دیتاتایپ‌‌های به اصطلاح Built-in یا «از پیش تعریف شده» پایتون است، هر دو را می‌توان به dir به عنوان پارامتر ورودی پاس داد). برای روشن‌تر شدن این مسئله، مثالی می‌زنیم. فرض کنید لیست l را تعریف کرده و از آن dir گرفته باشیم: 

>>> l = [1, 2, 3]
>>> dir(l)
['__add__', '__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', 
 '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', 
 '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', 
 '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 
 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']

همان‌طور که مشاهده می‌‌شود، متدی به اسم __iter__ در آن قرار دارد که بعد از Call (کال یا فراخوانی) کردن آن، یک متغیر جدید از لیست ساخته می‌‌شود:

>>> i = l.__iter__()
>>> i

تایپ این متغیر از جنس list_iterator و در اصل یک Iterator است. ضمناً متد __iter__ جزو فانکشن‌های به اصطلاح Built-in (یا از پیش تعریف شده) پایتون نیز بوده و می‌‌توان به شکل زیر هم از آن استفاده کرد:

>>> iter(l)

حال در ادامه اجازه دهید از متغیر iterator که جدیداً ساخته‌‌ایم، مجدداً dir بگیریم:

>>> dir(i)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
 '__init__', '__init_subclass__', '__iter__', '__le__', '__length_hint__', '__lt__', '__ne__', '__new__', '__next__', '__reduce__', 
 '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__']

متدهای مختلفی مشاهده می‌‌شوند که برای ما __next__ حائز اهمیت است. با کال (فراخوانی) کردن این متد داریم:

>>> i.__next__()
1
>>> i.__next__()
2
>>> i.__next__()
3

ضمناً از __next__ به صورت زیر هم می‌‌توان استفاده کرد:

>>> next(i)
1
>>> next(i)
2
>>> next(i)
3

احتمالاً پس از این فرایند، باید متوجه مفهوم Iterator و عملکرد متد next شده باشید. در واقع، فانکشن iter از یک دیتاتایپ لیست مانند، متغیری ساخته است که به عنصر اول لیست اشاره کرده و آدرس عنصر بعدی را نیز حفظ می‌‌باشد و اصطلاحاً گفته می‌‌شود که به آن لینک شده است و هنگامی که فانکشن next را کال (فراخوانی) می‌‌کنیم، مقدار Iterator به عنصر بعدی که آدرسش را حفظ است تغییر کرده و آدرس را به عنصر بعدی آپدیت می‌‌کند. یعنی در یک کلام، هر Iterator شامل یک مقدار و یک پوینتر می‌‌باشد که آن پوینتر به عنصر بعدی لیست اشاره می‌‌کند.

این نکته را همواره مد نظر داشته باشید کسانی که با زبان‌‌هایی مانند C یا ++C کد زده باشند، با مفهوم پوینتر آشنایی دارند و هر پوینتر در واقع آدرس یک متغیر دیگر را درون خودش ذخیر می‌‌کند و به همین دلیل به آن پوینتر یا اشاره‌گر می‌‌گویند.

به طور خلاصه، به متغیرهایی مانند لیست که امکان پیمایش در آنها وجود دارد متغیرهای Iterable و به عملیات پیمایش اصطلاحاً Iteration می‌‌گویند. ضمناً در پایتون هر آبجکتی که دارای متد __iter__ باشد، Iterable و هر آبجکتی که متد __next__ را داشته، Iterator است. در مثال‌‌های زیر، شخصاً __iter__ و __next__ را برای کلاس‌‌ها تعریف کرده‌‌ایم:

class Fib:                                        
    def __init__(self, max):                      
        self.max = max

    def __iter__(self):                          
        self.a = 0
        self.b = 1
        return self

    def next(self):                          
        fib = self.a
        if fib > self.max:
            raise StopIteration                  
        self.a, self.b = self.b, self.a + self.b
        return fib

>>> for i in Fib(10):
        print i
0
1
1
2
3
5
8 
			
class CustomRange:
    def __init__(self, max):
        self.max = max

    def __iter__(self):
        self.curr = 0
        return self

    def next(self):
        numb = self.curr
        if self.curr >= self.max:
            raise StopIteration
        self.curr += 1
        return numb

>>> for i in CustomRange(10):
     print i 
0
1
2
3
4
5
6
7
8
9

هنگامی که به انتهای متغیر iterable رسیدیم، هیچ متغیر بعدی وجود نخواهد داشت؛ بنابراین اگر متد next کال شود، ارور StopIteration اصطلاحاً Raise (تولید) می‌‌شود و در لایه‌‌های بعدی می‌‌توانید آن را هَندل کنید.

در تکمیل این مبحث، به یاد داشته باشید هنگامی که از دستور for در پایتون استفاده می‌‌شود، دو متغیر به آن پاس داده می‌‌شوند؛ به عنوان مثال داریم:

for i in l

متغیر اول در واقع همان Iterator و متغیر دوم Iterable است. متد __iter__ یک بار و فقط هنگام ایجاد for برای ساخت Iterator کال می‌‌شود و بعد به ازای هر بار تکرار حلقه، متد __next__ در ابتدای for کال می‌‌شود تا به عنصر بعدی دسترسی پیدا کنیم. بنابراین عملیات پیمایش همین‌طور انجام می‌‌شود تا به انتهای لیست رسیده و ارور StopIteration تولید شود. در اینجا و در ابتدای for، به محض رؤیت این ارور، دیگر عملیات درون حلقه تکرار نمی‌شود و به اولین خط بعد از حلقه می‌‌رویم؛ برای درک بهتر عملکرد for در پایتون، آن را به صورت جزئی‌تر با استفاده از while به شکل زیر بازنویسی می‌کنیم:

i = iter(l)
while True:
    try:
	    print next(i)
	except StopIteration:
	    break

آشنایی با مفهوم Generator در زبان پایتون
در زبان برنامه‌نویسی پایتون هنگام تعریف یک فانکشن، به جای return می‌توان از عبارت yield نیز استفاده کرد و به تابعی که از yield درون خودش استفاده کند Generator گفته می‌‌شود. yield و return دو تفاوت عمده با یکدیگر دارند (که درک تفاوت آن ارتباط تنگاتنگی با مفهوم Iterator دارد که پیش از این با آن آشنا شدید):

۱. اولین تفاوتشان این است که وقتی تابع به yield می‌‌رسد، برخلاف return از تابع بیرون نمی‌‌آید و ادامۀ آن را اجرا می‌‌کند تا به انتهای تابع و یا عبارت return برسد.
۲. دومین تفاوت این است که در واقع عبارت yield یک ایتریتور از متغیری که به آن پاس می‌‌دهیم ساخته و آن را به جایی که دوباره yield تکرار شده باشد، لینک می‌‌کند (زیرا همان‌طور که در مورد اول ذکر شد، یک فانکشن می‌‌تواند شامل چندین دستور yield باشد).

اجازه دهید برای اینکه این مطلب کاملاً روشن شود، مثالی بزنیم:

def mygen(a, b):
	yield a
	yield b
	
>>> mygen(1, 2)

>>> i=mygen(1,2)
>>> next(i)
1
>>> next(i)
2
>>> next(i)
Traceback (most recent call last):
  File "", line 1, in 
StopIteration

همان‌طور که مشاهده می‌شود، هنگام فراخوانی این فانکشن، یک Iterator به ما پاس داده شده که به اولین جایی که عبارت yield را استفاده کرده‌‌ایم اشاره می‌‌کند و به دومین جایی که yield استفاده شده است لینک شده و همین‌طور این فرایند تا آخرین yield در فانکشن (تابع) ادامه می‌‌یابد. حال مثال زیر را در نظر بگیرید:

def mygen(n):
    for i in range(n):
	    yield i

در این مثال، از یک حلقه استفاده شده است. دقت کنید که اگر به جای yield از return استفاده شده بود، این تابع به محض ورود به حلقۀ for، اولین مقدار را return کرده و خارج می‌‌شد؛ اما در اینجا که از yield استفاده کرده‌‌ایم، تابع تا انتها اجرا شده و به ازای هر باری که yield استفاده شده، یک مقدار جدید تولید و به مقدار قبلی لینک می‌‌کند.

به یاد داشته باشیم که تعریف یک Generator را با استفاده از لیست می‌توان در یک خط انجام داد؛ به عنوان مثال داریم:

>>> squares = (i**2 for i in range(10))
>>> squares
 at 0x1069a6d70>

i**2 در واقع مقداری است که به yield در تابع جنراتور پاس داده می‌‌شود. ضمناً در این حالت، آبجکت متفاوتی توسط پایتون تولید می‌‌شود که به آن Generator Expression یا به اختصار genexpr می‌‌گویند.

کلام آخر
شاید از خودتان بپرسید که پرداختن به این مبحث اساساً قرار است چه دردی را از یک دولوپر پایتون دوا کند و در واقع، ایتریتورها و جنریتورها را کجا می‌‌توان به صورت کاربردی مورد استفاده قرار داد؟

در پاسخ به سؤالاتی از این دست، بایستی گفت که تفاوت عمده‌‌ای که یک Iterator با یک متغیر Iterable همانند لیست دارد، در میزان مصرف مِموری است. شما برای بررسی یک لیست، به طور معمول باید ابتدا آن را کاملاً در مِموری لود کرده سپس شروع به پیمایش آن کنید ولی هنگامی که از یک Iterator استفاده می‌کنید، دیگر نگران منابعی مانند مِموری نخواهید بود و این تفاوت در اِسکیل‌‌های بزرگ کاملاً محسوس است.

شما فرض کنید قرار است هر بار دیتاهایی که نمی‌‌دانید بزرگی آنها چقدر است را از دیتابیس بخوانید و ممکن است تعداد آنها در برخی موارد خیلی زیاد باشد. معقول نیست که هر بار نتیجه را ابتدا در یک متغیر Iterable همانند یک لیست یا تاپل در مِموری لود کرده و از آن استفاده کنید؛ بلکه در اینجا بهتر است شما از یک Iterator استفاده کنید و پیمایش را از طریق آن انجام دهید. بنابراین مواقعی که اندازۀ دیتا را نمی‌‌دانیم و یا مطمئنیم دیتا خیلی بزرگ است، استفاده از Iterator را می‌‌توان تنها راه‌حل ممکن دانست.

اگر علاقمند به آشنایی بیشتر با زبان برنامه‌نویسی Python هستید، می‌توانید به دورهٔ آنلاین و رایگان آموزش پایتون در سکان آکادمی مراجعه نمایید.



محمد طاهری