Python حرفه‌ای: آشنایی با Generatorها

Professional Python: Generators

23 اسفند 1399
درسنامه درس 19 از سری پایتون حرفه‌ای
Python حرفه ای: آشنایی با Generator ها (قسمت 19)

generator در زبان پایتون چیست؟

generator ها در زبان پایتون از مباحث پیشرفته حساب می شوند و به ما اجازه می دهند در یک بازه زمانی، توالی خاصی از مقایر را تولید کنیم. ما قبلا با یک generator آشنا شده ایم؛ آیا ()range را به یاد دارید؟ تابع range یک generator حساب می شود و خصوصیت generator ها این است که بازه مشخص شده را به صورت مرتبه ای ایجاد می کنند (نه دفعه ای). یعنی چه؟ بگذارید یک مثال برایتان بزنم:

def make_list(numb):

    result = []

    for i in range(numb):

        result.append(i * 2)

    return result







my_list = make_list(100)

print(my_list)

همانطور که می بینید من تابعی را تعریف کرده ام که یک عدد را گرفته و لیستی را به تعداد آن عدد پاس داده شده می سازد. اگر کد بالا را اجرا کنیم طبیعی است که نتیجه زیر را دریافت کنیم:

[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98, 100, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 124, 126, 128, 130, 132, 134, 136, 138, 140, 142, 144, 146, 148, 150, 152, 154, 156, 158, 160, 162, 164, 166, 168, 170, 172, 174, 176, 178, 180, 182, 184, 186, 188, 190, 192, 194, 196, 198]

مسئله اینجاست که در این روش ما این مقادیر را به صورت یکجا ایجاد کرده و در مموری نگه داشته ایم (یعنی لیستی با ۱۰۰ عضو فضای مموری ما را اشغال کرده است). زمانی که از توابعی مانند ()list استفاده می کنید چنین اتفاقی رخ می دهد (مموی اشغال می شود). این در حالی است که generator هایی مانند range این کار را به صورت ترتیبی انجام می دهند. range در کد بالا، اعداد صفر تا ۹۹ را به صورت یکجا نمی سازد بلکه این حلقه for است که ابتدا عدد صفر را به range می دهد، سپس عدد ۱ را می دهد، سپس عدد ۲ و الی آخر. به کد زیر توجه کنید:

print(list(range(100)))

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

در چنین وضعیتی است که generator ها به کمک ما آمده و مسئله را حل می کنند. generator ها به جای آن که تمام این مقادیر را ایجاد کنند آن ها را تک به تک ایجاد کرده و تک به تک به ما پاس می دهند. همچنین بعد از اینکه کار ما با یک مقدار خاص تمام شد آن را از مموری حذف می کند بنابراین مموری فقط به اندازه یک عضو اشغال می شود و از آنجایی که برای کار با این مقادیر نیاز به صبر کردن نداریم، سرعت برنامه نیز به شدت افزایش پیدا می کند.

ساخت یک generator

همانطور که می دانید در زبان پایتون مفهومی به نام iterable یا گردش پذیر داریم. انواع داده ای گردش پذیر هستند که بتوانیم با استفاده از حلقه ها روی آن ها گردش کنیم. iterable ها برای اینکه گردش پذیر باشند از متد dunder ای به نام __iter__ استفاده می کنند که فراخوانی آن باعث گردش ما می شود. generator ها نیز گردش پذیر هستند بنابراین گردش روی آن ها مانعی ندارد. حالا برای ایجاد generator خودمان از منطق کد قبلی استفاده می کنیم:

def my_generator(numb):

    for i in range(numb):

        yield i

همانطور که می بینید در این قسمت از کلیدواژه جدیدی به نام yield استفاده کرده ایم. کار yield این است که مقدار i را return می کند اما از تابع خارج نمی شود بلکه تابع را موقتا متوقف می کند تا زمانی که ما دستور دیگری به نام next را صدا بزنیم. اگر بخواهیم از این تابع در یک حلقه استفاده کنیم، می توانیم به شکل زیر عمل کنیم:

def my_generator(numb):

    for i in range(numb):

        yield i







for item in my_generator(100):

    print(item)

طبیعتا با اجرای این کد اعداد صفر تا ۹۹ را مشاهده می کنیم اما تفاوت آن با کد قبلی این است که این اعداد را تک به تک دریافت کرده، چاپ کرده و سپس از مموری حذف کرده ایم بنابراین همه را یکجا نساخته ایم تا قسمت بزرگی از مموری را اشغال کنیم و سرعت برنامه را نیز افزایش داده ایم. البته مسئله اینجاست که حلقه for خودش روی generator ما گردش می کند و ما این مسئله را به چشم خود نمی بینیم. برای اینکه بتوانیم این روند را به وضوح ببینیم باید به صورت دستی روی آن گردش کنیم. مثال:

def my_generator(numb):

    for i in range(numb):

        yield i







g = my_generator(100)

print(g)

من این بار تابع my_generator را صدا زده و نتیجه اش را در متغیری به نام g قرار داده ام. با اجرای کد بالا نتیجه زیر را دریافت می کنیم:

<generator object my_generator at 0x7f3498ef5820>

یعنی شیء خاصی از نوع generator را دریافت کرده ایم و مقداری نداریم. برای دریافت اولین مقدار باید یک بار دستور next را صدا بزنیم:

def my_generator(numb):

    for i in range(numb):

        yield i







g = my_generator(100)

print(next(g))

با صدا زدن g و پاس دادن next به آن اولین مقدار خود یعنی عدد صفر را دریافت می کنید. حالا اگر چندین بار next را صدا بزنیم چطور؟

def my_generator(numb):

    for i in range(numb):

        yield i







g = my_generator(100)

print(next(g))

print(next(g))

print(next(g))

print(next(g))

print(next(g))

با اجرای کد بالا نتیجه زیر را دریافت می کنید:

0

1

2

3

4

بنابراین با هر بار صدا زدن next به عدد بعدی می رویم (یک بار گردش اضافه با هر بار صدا زدن next). البته این کار تا زمانی ممکن است که از بازه ممکن خارج نشویم. به طور مثال:

def my_generator(numb):

    for i in range(numb):

        yield i







g = my_generator(1)

print(next(g))

print(next(g))

print(next(g))

print(next(g))

print(next(g))

من عدد ۱ را به generator خودمان پاس داده ام. به نظر شما با اجرای این کد چه نتیجه ای را می گیریم؟

0

Traceback (most recent call last):

  File "/mnt/Development/Roxo Academy/Python/test.py", line 8, in <module>

    print(next(g))

StopIteration

همانطور که می بینید با اجرای این کد یک خطا از نوع StopIteration دریافت کرده ایم. چرا؟ به دلیل اینکه ما عدد ۱ را به range پاس داده ایم بنابراین فقط عدد صفر را در بازه خود داریم اما چندین بار next را صدا زده ایم در حالی که هیچ عدد دیگری برای گردش وجود ندارد. اینجاست که عدد صفر چاپ شده اما next با شکست مواجه می شود و به خطا برخورد می کنیم.

generator سریع تر هستند؟

همانطور که در ابتدای این جلسه توضیح دادم، generator ها مقادیر را تک به تک به ما می دهند و برای کار با آن ها نیازی به صبر کردن نداریم بنابراین به غیر از بهینه بودن، سریع تر نیز هستند. برای اثبات این موضوع از decorator ای استفاده می کنیم که در جلسات پیش تعریف کرده بودیم. کار این decorator اندازه گیری زمان اجرای یک تابع است بنابراین می توانیم از آن برای اندازه گیری زمان اجرای generator ها استفاده کنیم:

from time import time







def performance(fn):

    def wrapper(*args, **kwargs):

        t1 = time()

        result = fn(*args, **kwargs)

        t2 = time()

        print(f'took {t2 - t1} seconds')

        return result

    return wrapper







@performance

def duration():

    print('using generator')

    for i in range(1000000):

        i * 5







@performance

def list_duration():

    print('using list')

    for i in list(range(1000000)):

        i * 5







duration()

list_duration()

همانطور که می بینید ما در این کد decorator خود را داریم تا زمان اجرا را اندازه گیری کنیم، سپس دو تابع متفاوت را تحت این decorator نوشته ام که یکی از generator ها استفاده کرده و دیگری از روش اول (ساخت list) استفاده می کند. به نظر شما زمان اجرای کدام یک از این دو تابع کمتر است؟ با اجرای کد بالا در سیستم من نتیجه زیر را می گیریم:

using generator

took 0.07433795928955078 seconds




using list

took 0.11219167709350586 seconds

همانطور که در این کد می بینید اجرای این کد با generator ها 0.07 ثانیه و با لیست ها 0.1 ثانیه طول کشیده است! بنابراین مشاهده می کنیم که generator ها بسیار سریع تر از ساخت یک لیست هستند. آیا می دانید چرا؟ ما در تابع دوم علاوه بر گردش روی یک میلیون عدد، آن ها را به یک لیست نیز تعریف کرده ایم! دلیل اصلی کُندتر بودن تابع دوم این است که یک دستور اضافه را نیز اجرا می کند که همان ()list است در حالی که تابع اول به جای این کار از range (یک generator) استفاده می کند و دیگر لیستی را نمی سازد. سریع بودن بسیار خوب است اما نکته مهم تر اینجاست که generator ها مموری کمتری مصرف می کنند.

پشت صحنه generator ها

برای درک بهتر generator ها و به طور کل تمام دستوراتی که گردش می کنند باید تابعی را شبیه به آن ها بنویسیم تا متوجه مفهوم گردش بشوید:

def special_for(iterable):

    iterator = iter(iterable)

    while True:

        try:

            print(iterator)

            next(iterator)

        except:

            break







special_for([1, 2, 3])

در زبان پایتون تابعی به نام iter وجود دارد که یک مقدار را به یک iterable تبدیل می کند (در صورتی که قابلیتش را داشته باشد). من از این تابع استفاده کرده و مقدار پاس داده شده را iterable یا گردش پذیر کرده ام و نتیجه اش را به متغیری به نام iterator پاس داده ام. حالا می توانیم با یک حلقه while روی این مقادیر گردش کرده و next را صدا بزنیم. با اجرای کد بالا نتیجه زیر را دریافت می کنیم:

<list_iterator object at 0x7fdcc322ea00>

<list_iterator object at 0x7fdcc322ea00>

<list_iterator object at 0x7fdcc322ea00>

<list_iterator object at 0x7fdcc322ea00>

همانطور که می بینید iterator در هر گردش چاپ شده است بنابراین چهار شیء iterator را دریافت کرده ایم. سوال اینجاست که چرا مقدار اصلی نمایش داده نشده است؟ پاسخ این است که ما دستور print ای را برای آن ننوشته ایم، بنابراین:

<list_iterator object at 0x7f1e8f83fa00>

1

<list_iterator object at 0x7f1e8f83fa00>

2

<list_iterator object at 0x7f1e8f83fa00>

3

<list_iterator object at 0x7f1e8f83fa00>

همانطور که می بینید تمام مقادیر نیز تک به تک چاپ شده اند. حلقه های for و generator ها در پشت پرده چنین منطقی را پیاده می کنند.

تمام فصل‌های سری ترتیبی که روکسو برای مطالعه‌ی دروس سری پایتون حرفه‌ای توصیه می‌کند:
نویسنده شوید

دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.