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

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

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

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

اما آنچه در این پست قصد داریم مورد بررسی قرار دهیم، دیتا تایپ‌‌هایی است که از اِجماع چند دیتا تایپ یکسان و یا غیریکسان تشکیل می‌‌شوند که در زبان‌های برنامه‌نویسی عمدتاً 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)

حال در ادامه قصد داریم متغیر i که جدیداً ساخته‌‌ایم را به عنوان پارامتر ورودی مجدداً به فانکشن ()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

هنگامی که به انتهای متغیر رسیدیم، هیچ متغیر بعدی وجود نخواهد داشت و از همین روی اگر متد ()next کال شود، ارور StopIteration تولید می‌‌شود که در گام‌های بعدی می‌‌توانید آن را هَندل کنید. همچنین در تکمیل این مبحث به یاد داشته باشید هنگامی که از دستور for استفاده می کنیم، دو متغیر به آن پاس داده می‌‌شود به طوری که برای مثال داریم:

for i in l

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

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

برای درک بهتر عملکرد for در پایتون، همان‌طور که ملاحظه می‌شود، آن را به صورت جزئی‌تر با استفاده از while به صورت فوق بازنویسی کرده‌ایم.

آشنایی با مفهوم 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، اولین مقدار را ریترن کرده و خارج می‌‌شد اما در اینجا که از 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 را می‌‌توان به عنوان راه‌حل بهینه قلمداد کرد.