درک iterator، generator در پایتون

هر چند ممکن است زبان پایتون بسیار ساده بنظر برسد اما ظرایفی در آن وجود دارد که بعد از سالها کار کردن متوجه آن می شوید و حتی برخی ها که فکر میکنند به اندازه کافی این زبان را می دانند از آن ها نا آگاه هستند یا در مورد آن ها دچار سردرگمی می شوند. اینجا به یکی از مهم ترین مفاهیم پایه ای پایتون می پردازیم که برای درک کد ها و بهینه نوشتن آن ها بسیار حیاتی است.

حتما دقت کرده اید که حلقه فور در پایتون قدر با بقیه زبان ها متفاوت است. به مثال ساده زیر دقت کنید:

numbers = [4, 2, 8, 0]
for number in numbers:
print(number)

که به ترتیب اعدادی که در لیست هستند را چاپ می کند. مهمترین تفاوت این است که حلقه در پایتون بر روی یک عدد و اندیس نیست. مثلا در زبان C که حلقه به ما یک عدد به تدریج بزرگ شونده می داد(مثلا از صفر شروع میشد و یکی یکی بالا میرفت تا مثلا ۱۰۰) و بر اساس آن می توانستیم دسترسی به اعضای یک آرایه از اعداد را داشته باشیم. در پایتون حلقه بر روی خود اعضای چیزی است که سمت راست in قرار می گیرد. آن چیز می تواند لیست، دیکشنری، مجموعه یا چیز های دیگری باشد. این نحوه حلقه بسیار طبیعی تر و شهودی تر بنظر می رسد. اما می تواند معنای بسیار جامع تری هم بخود بگیرد.

ظرف ها(Container)

لیست دیکشنری و مجموعه ساختار داده هایی هستند که به آن ها container گفته می شود. container چیزی شبیه به یک ظرف واقعی است که تمام آنچه درون آن است محدود است. می توان آن را دید. container یک بخش از حافظه را می گیرند و بسیار شبیه به درک معمول ما از ساختار داده ها هستند.

لیست، دیکشنری و مجموعه شبیه به ظرف های محدود هستند.
لیست، دیکشنری و مجموعه شبیه به ظرف های محدود هستند.

نوشتن حلقه برای همه آن ها کم و بیش شبیه به هم هست.

#list
a = [1, 3, 5, 6]
for item in a:
    print(a)

#dictionary
b = {"apple": 2, "banana": 7, "berry": 3}
for key in b:
    print(b[key])

#set
c = {1, 6, 2, 8, 9, 7, 0, 1}
for item in c:
    print(item)

قابل شمارش ها(Iterables)

هر چند container ها دارای اعضا هستند و می شود بر روی آن ها حلقه فور زد اما این بخاطر یک خاصیت دیگر از container هاست. دلیل این امر این است که آن ها iterable هستند. یعنی قابل شمارش هستند(ترجمه بهتری پیدا نکردم). شاید بگویید این حرف چه فایده ای دارد! فایده این خاصیت آن است که باید بدانید تنها iterable ها (یا داده های قابل شمارش) container ها نیستند. شما می توانید بر روی مجموعه های نامتنهایی هم حلقه درست کنید! این خاصیت عجیب و بسیار مفید از پایتون زبان زیبایی ساخته است.

با این تعریف iterable ها لزوما ساختار داده نیستند زیرا بر طبق تعریف ساختار داده ها در کامپیوتر باید متناهی باشند. iterable ها در واقع اشیایی هستند که به کمک iterator ها اعضای جدید تولید می کنند. برای ساده تر شدن موضوع یک مثال را در نظر بگیرید. وقتی شما یک نایلون پر از نان دارید این شبیه به یک container است. وقتی با دستتان نان بعدی را از نایلون بیرون می کشید دست شما iterator است. دلیل این که می توانید این کار را بکنید این است که نایلون پر از نان یک iterable است. حالا بر اساس همین مثال تنور نانوایی که میچرخد هم یک iterable است با این تفاوت که یک ظرف نیست که بتوانید آن را بشمارید. تنور نانوایی به صورت نامتناهی (فرض کنید آرد و گندم جهان نامحدود است!) نان بیرون می دهد. باز هم دست شما (یا نانوا) iterator است چون نان بعدی را بیرون می کشد!

تمثیل نانوا برای iterator و iterable

تمثیل نانوا برای iterator و iterable

حالا اگر فرض کنیم یک لیست داریم. آن گاه تابع iter آن را به شی iterable تبدیل می کند و آن گاه کافی است که با استفاده از تابع __next__ نمونه های بعدی آن را به ترتیب بخوانیم (در واقع __next__ مانند همان دستی است که نمونه ها را بیرون می کشید).

a = [1, 3, 4, 5, 6]

x = iter(a)

print(x.__next__())
print(x.__next__())

خروجی به صورت زیر خواهد بود:

1
3

در شکل زیر می توانیم این ارتباط را ببینیم:

نکته مهمی در کد بالا این است که x دارای حالت است. یعنی اولین فراخوانی __next__ با دومی نتایج متفاوتی دارد. همانطور که اولین نانی که از نایلون بیرون میکشید با دومی یکی نیست.چون برای بار دوم خود ظرف تغییر کرده است(یک نان از آن کم شده است!)

اما iterator های نامتنهایی چه شکلی هستند؟! برای دیدن یک نمونه از آن ها باید با کتابخانه itertools آشنا شویم که حاوی چنین اشیایی هست. نمونه زیر را در نظر بگیرید:

from itertools import count

counter = count(10)

print(counter.__next__())
print(counter.__next__())
print(counter.__next__())

در این جا counter یک iterator است که از ۱۰ شروع می شود ولی هرگز تمام نمی شود! شما می توانید یک حلقه فور برای آن بنویسید و متوجه می شوید این یک حلقه بی نهایت است(مکانیزم کار حلقه در ادامه می آید):

for item in counter:
    print(item)

مثال دیگر آن cycle است.

from itertools import cycle

colors = cycle(["red", "greed", "blue"])

print(colors.__next__())
print(colors.__next__())
print(colors.__next__())
print(colors.__next__())

که خروجی آن این است:

red
greed
blue
red

این یک حلقه بی نهایت بر روی سه عضوش است! می توان تصور کرد که برنامه های زیادی می توانند از حلقه های بی نهایت این چنینی بهره ببرند اما قدرت iterator ها بیشتر است!

چه چیزی iterator ها را قدرتمند میسازد؟ تنبل بودن آنها! تبل بودن یا lazy‌ بودن در علوم کامپیوتر معمولا معنای خوبی دارد! تنبل بودن به این معناست که iterator نیازی نمی بیند که همه چیز را یکباره تولید کند(در مورد مثال های بالا که نامتناهی هستند اصلا غیر ممکن است!) بلکه عضو بعدی را فقط زمانی که از آن درخواست می شود تولید میکند. مثل نانوایی ای که تا زمانی که مشتری دارد کار میکند و هر وقت مشتری نیست می تواند کارش را متوقف کند.

ویژگی تنبل بودن همچنین باعث می شود که علاوه بر iterate کردن بر روی اشیای نامتنهایی بر روی اشیای خیلی خیلی بزرگ هم بتوانیم این کار بکنیم. یک مثال از آن نوشتن حلقه بر روی یک فایل بسیار بزرگ با حجم مثلا یک ترابایت (بر روی دیسک) است در حالی که شما یک گیگابایت بیشتر حافظه ندارید. پایتون فایل ها را هم مانند iterable هایی می بیند که قابل iterate کردن هستند! اگر فایل بزرگ شما sample.txt باشد. شما می توانید به راحتی به شیوه زیر خط به خط آن را بخوانید:

f = open("sample.txt")
print(f.__next__())
print(f.__next__())
print(f.__next__())

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

123with open("sample.txt") as f:
    for line in f:
        print(line)

تا این جای کار خیلی خوب است اما می توان بیشتر انتظار داشت!

سوالی که ممکن است تا الان به ذهنتان رسیده باشد این است که چرا next در بالا چنین نوشته شده است با دو underscore قبل و بعد از آن. در پایتون برخی از توابع هستند که عملیات بسیار پایه ای وخاص زبان را انجام می دهند و برای اینکه با بقیه تابع ها قاطی نشود آن را با دو underscore قبل و بعدش مشخص می کنند. اما قسمت جالب تر ماجرا این است که اشیا بالا که همگی برای خود پایتون هستند به شیوه ای تابع __next__ را پیاده کرده اند ولی ما هم می توانیم این کار را برای اشیا خودمان(کلاس) پیاده سازی کنیم!

class MyInfiniteIterable():
    def __init__(self, value):
        self.value = value

    def __next__(self):
        return self.value

    def __iter__(self):
        return self

m = MyInfiniteIterable(5)
print(m.__next__())

اینجا علاوه بر __init__ که کانستراکتور هست و احتمالا با آن آشنایی دارید دو تابع دیگر هم پیاده شده است. اولی __next__ است که فقط همان مقداری که به کلاس داده شده است را بر میگرداند و __iter__ که خود کلاس را بر می گرداند. کد بالا مشخصا ۵ را بر میگرداند. اما دلیل اینکه __iter__‌اضافه شده است این است که بتوانیم m را که یک شی از کلاس بالاست و در واقع یک iterable است به یک iterable تبدیل کنیم:

for item in m:
    print(item)

این حلقه تا بی نهایت عدد ۵ تولید می کند!

بنابراین حلقه فور به صورت زیر کار می کند:

۱-ابتدا حلقه فور چک می کند که آیا شی مورد نظر دارای متد __iter__ هست آنگاه تابع iter را بر روی آن اعمال می کند تا iterable را به iterator تبدیل کند.

۲- تابع __next__ بر روی شی فراخوانی می شود تا مقدار بعدی را بخواند و همینطور ادامه می دهد تا..

۳-وقتی با exception مخصوص StopIteration میرسیم یعنی دیگر چیزی در iterator نمانده است. در مثال بالا چنین چیزی وجود ندارد پس تا ابد ادامه پیدا می کند.

یک مثال دیگر که محدود است را می توان به صورت زیر تصور کرد:

class MyRange():

    def __init__(self, start, end):
        self.current = start
        self.start = start
        self.end = end

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.end:
            raise StopIteration
        else:
            self.current += 1
            return self.current - 1


myrange = MyRange(3, 10)

for i in myrange:
    print(i)

که اعداد ۳ تا ۱۰ را چاپ می کند.(بسیار شبیه به range در خود پایتون)

می توانید کلاس بالا را هر طور که دوست دارید تغییر دهید. مثلا می توانید آن را به شکلی در آورید که مثلا از یک فایل بخواند و عملیاتی روی آن انجام داده و سپس مقدار را بر گرداند. مثلا می توان iterator اعداد فیبوناچی ساخت!!

class FibonacciIterator:
     def __init__(self):
         self.prev = 0
         self.curr = 1

     def __iter__(self):
         return self

     def __next__(self):
         value = self.curr
         self.curr += self.prev
         self.prev = value
         return value

fib = FibonacciIterator()
for f in fib:
    print(f)

حالا سعی کنید بر روی چیزی که می دانید iterator نیست حلقه فور بنویسید و خطایی که پایتون به شما می دهد را نگاه کنید!

تولید کننده ها Generators

برای اینکه iterable ها عالی هستند پایتون استفاده از آنها را حتی راحت تر هم کرده است. شما حتی نیازی ندارید که مواظب منطق گاه دشوار __next__‌شوید. تولید کننده ها یا generator ها در واقع iterator هایی هستند که سینتکس ساده تری در اختیار برنامه نویس قرار می دهند.

شما کافی است که حلقه مورد نظر خودتان را بنویسید (بدون توجه به اینکه چنین حلقه ای عملا ناممکن است!) و از کلمه جادویی yield استفاده کنید. برای استفاده از generator ها حتی نیازی به تعریف کلاس هم نداریم!

def firstn(n):
    num = 0
    while num < n:
        yield num
        num += 1

for i in firstn(10):
    print(i)

این کد قدری عجیب به نظر می رسد زیرا ما تابعی داریم که خودش یک حلقه دارد ولی از بیرون روی آن یک حلقه دیگر درست کرده ایم. باید دقت کنید حلقه ی داخل تابع فقط زمانی شروع به حرکت می کند که حلقه بیرونی آن را فراخوانی کند. بنابراین اینجا با چیزی شبیه به حلقه تو در تو سر و کار نداریم. تابع firstn هر بار num را بر میگرداند پس شبیه به return است اما با آن تفاوت دارد. زمانی که return اجرا می شود تابع خاتمه پیدا می کند و از حافظه خارج می شود اما yield مثل ساعت برنارد تابع را با تمام حالات آن نگه می دارد مقدار را بر میگرداند و منتظر می ماند تا چیزی که آن را فراخوانده است دوباره این کار را بکند!

در اینجا تابع firstn می تواند n عدد طبیعی را به ترتیب تولید کند. در واقع قسمت مهم کد بالا yield است که مقدار num را در هر حلقه بر میگرداند. این دستور دقیقا مانند __next__ عمل می کند و به صورت lazy این مقدار را بر میگرداند. اصلا می توانید به صورت زیر هم آن را فراخوانی کنید:

f = firstn(10)

print(f.__next__())
print(f.__next__())
print(f.__next__())

که به ترتیب اعداد ۰ و ۱ و ۲ را بر میگرداند.

یک مثال جالب تر ایجاد یک generator از تمام لینک های یک صفحه وبی است. این نمونه هم یکی از نمونه هایی است که نشان می دهد شما نمی توانید از قبل بدانید که چند لینک خواهید داشت.

import requests
import re


def get_pages(link):
    links_to_visit = []
    links_to_visit.append(link)
    while links_to_visit:
        current_link = links_to_visit.pop(0)
        page = requests.get(current_link)
        for url in re.findall('<a href="([^"]+)">', str(page.content)):
            if url[0] == '/':
                url = current_link + url[1:]
            pattern = re.compile('https?')
            if pattern.match(url):
                links_to_visit.append(url)
        yield current_link


webpage = get_pages('http://www.google.com')
for result in webpage:
    print(result)

اگر بخواهیم آنچه تاکنون بوده است را خلاصه کنیم چیزی شبیه به شکل زیر را داریم:

نتیجه گیری

استفاده از iterator ها و generator ها خوب است چون:

۱- مکانیزم iterator از لحاظ حافظه بسیار بهینه است و گاهی حتی چاره ای جز استفاده از iterator نداریم.

۲- خاصیت lazy بودن به ما امکان می دهد iterator هایی بسازیم که اندازه ندارند و عملا نامتناهی هستند.

۳- با استفاده از توابع __next__‌و یا yield می توان کلاس های بسیار مرتبی نوشت که به صورت کاملا شخصی سازی شده (customized) یک iterator را بسازند. مثال آن می تواند ساختن iterator بر روی یک دیتابیس یا دیتاست باشد.

منبع : virgool.io