StopIteration در پایتون خطا نیست؛ سیگنالی است که پایان طبیعی یک پیمایش را اعلام می‌کند، ولی از پایتون ۳٫۷ به بعد اگر همین سیگنال داخل یک ژنراتور پرتاب شود، پایتون آن را به RuntimeError ترجمه می‌کند تا باگ‌های پنهان را لو بدهد. این تغییر کوچک در PEP 479، بسیاری از کدهایی که سال‌ها بی‌سروصدا کار می‌کردند را در نسخه‌های جدید به کلافگی کشاند. در این مقاله، تجربه‌ام از دیباگ ده‌ها پروندهٔ واقعی StopIteration و StopAsyncIteration را با شما به اشتراک می‌گذارم.

StopIteration چیست و از کجا می‌آید؟

StopIteration یک استثنای داخلی پایتون است که در پروتکل iterator نقش «سیگنال پایان» را بازی می‌کند. وقتی یک iterator دیگر عنصری برای بازگرداندن ندارد، به‌جای بازگرداندن یک مقدار خاص مثل None یا -1، یک استثنای StopIteration پرتاب می‌کند. این رفتار در طراحی پایتون عمدی است، چون هم از نظر عملکردی سریع‌تر از بازگشت یک مقدارِ نگهبان است و هم از نظر معنایی روشن‌تر: «دیگر چیزی نیست».

ساختار ارث‌بری این استثنا ساده است:

BaseException
 └── Exception
      └── StopIteration
           └── StopAsyncIteration  # در پایتون ۳٫۵ به بعد

نکتهٔ مهم در همین ساختار نهفته است: StopIteration زیرشاخهٔ Exception است، نه زیرشاخهٔ BaseException. یعنی اگر جایی except Exception بنویسید، StopIteration هم گرفته می‌شود و این دقیقاً همان نقطه‌ای است که باگ‌های پنهان متولد می‌شوند. برای درک چارچوب گسترده‌تر، مدیریت خطا در پایتون را پیشنهاد می‌کنم.

StopIteration یک خطا نیست که «اتفاق بیفتد»؛ یک پیام است که «اعلام شود». تفاوت این دو نگاه، تمام تفاوت میان کدی سالم و کدی پر از باگ‌های مرموز است.

مفهوم پروتکل iterator و رفتار استثنای پایان، در ادبیات علوم کامپیوتر به‌عنوان الگوی «Iterator Pattern» شناخته می‌شود و در ویکی‌پدیا ذیل Iterator Pattern توضیح داده شده است. پایتون این الگو را نه فقط برای پیمایش مجموعه‌ها، بلکه برای ژنراتورها، فایل‌ها، اتصالات دیتابیس و حتی حلقه‌های async به‌کار می‌گیرد.

از منظر کاربردی، StopIteration در سه سطح ظاهر می‌شود. در سطح اول، کاربر عادی هیچ‌وقت آن را نمی‌بیند، چون حلقهٔ for خودش آن را می‌بلعد. در سطح دوم، توسعه‌دهنده‌ای که با next() مستقیم کار می‌کند، ممکن است با آن مواجه شود. و در سطح سوم، توسعه‌دهنده‌ای که ژنراتور می‌نویسد و اشتباهاً StopIteration را در بدنه raise می‌کند، با تبدیل آن به RuntimeError در پایتون ۳٫۷ به بعد روبرو می‌شود.

پروتکل iterator: قرارداد پنهان پایتون

برای درک عمیق StopIteration، باید پروتکل iterator را بشناسید. این پروتکل، قراردادی ساده است که پایتون با هر شیء قابل‌پیمایش می‌بندد. دو متد در این قرارداد وجود دارد:

class MyIterator:
    def __iter__(self):
        return self

    def __next__(self):
        # اگر عنصر بعدی وجود دارد، برگردان
        # وگرنه StopIteration پرتاب کن
        raise StopIteration

متد __iter__ باید خودِ iterator (یا یک iterator جدید) را برگرداند و متد __next__ باید عنصر بعدی را برگرداند یا StopIteration پرتاب کند. این قرارداد، انعطاف‌پذیری فوق‌العاده‌ای به پایتون می‌دهد: هر شیئی که این دو متد را داشته باشد، در حلقهٔ for و در توابعی مثل list()، sum() و sorted() کار می‌کند.

حلقهٔ for در پایتون در واقع پوششی زیبا روی این مکانیزم است:

# آنچه می‌نویسید
for x in [1, 2, 3]:
    print(x)

# آنچه پایتون اجرا می‌کند (به‌طور مفهومی)
iterator = iter([1, 2, 3])
while True:
    try:
        x = next(iterator)
    except StopIteration:
        break
    print(x)

این ترجمهٔ پنهان، نقطهٔ کلیدی است. هر بار که حلقهٔ for تمام می‌شود، در واقع یک استثنای StopIteration پرتاب و بلعیده شده است. هزینهٔ این استثنا در پایتون بسیار کم است و به همین دلیل، for روی میلیون‌ها عنصر هم سریع باقی می‌ماند.

مفهوم عمیق‌تر این پروتکل، در طراحی کار با فایل‌ها در پایتون هم دیده می‌شود: هر بار که روی خطوط یک فایل پیمایش می‌کنید، در واقع یک iterator در حال کار است که با رسیدن به انتهای فایل، StopIteration پرتاب می‌کند.

ژنراتورها و نسخهٔ ساده‌شده پروتکل

ژنراتورها، سطح بالاتری از همین پروتکل هستند. هر تابعی که yield دارد، به‌طور خودکار به یک iterator تبدیل می‌شود و پایتون تمام پیچیدگی __iter__ و __next__ را پشت صحنه مدیریت می‌کند:

def count_up_to(n):
    i = 0
    while i < n:
        yield i
        i += 1

for x in count_up_to(3):
    print(x)  # 0, 1, 2

وقتی ژنراتور از بدنهٔ تابع خارج می‌شود (در این مثال، وقتی i == n)، پایتون به‌طور خودکار StopIteration پرتاب می‌کند. یعنی شما نیازی نیست خودتان این استثنا را raise کنید. این دقیقاً همان نقطه‌ای است که بسیاری از توسعه‌دهندگان اشتباه می‌کنند: raise دستی StopIteration داخل ژنراتور، در پایتون ۳٫۷ به بعد خطاست.

PEP 479: چرا پایان طبیعی به خطا تبدیل شد؟

پیش از پایتون ۳٫۵، اگر یک ژنراتور در بدنهٔ خود StopIteration را raise می‌کرد، این استثنا به‌آرامی بلعیده می‌شد و ژنراتور تمام می‌شد. این رفتار به‌ظاهر بی‌ضرر بود ولی یک باگ پنهان داشت: اگر ژنراتوری تصادفاً یک StopIteration از یک iterator داخلی بیرون می‌داد، پایتون آن را به‌عنوان «پایان ژنراتور» تفسیر می‌کرد و باعث می‌شد ژنراتور زودتر از موعد تمام شود — بدون هیچ هشداری.

پیشنهاد PEP 479 این مشکل را حل کرد. در پایتون ۳٫۵ این تغییر به‌صورت opt-in از طریق from __future__ import generator_stop قابل فعال‌سازی بود و از پایتون ۳٫۷ به بعد، رفتار پیش‌فرض شد. قاعده ساده است:

هر StopIteration که از داخل بدنهٔ یک ژنراتور بیرون بیاید، به‌طور خودکار به RuntimeError ترجمه می‌شود تا برنامه‌نویس متوجه باگ پنهان بشود.

این ترجمه در کد زیر به‌شکل روشن دیده می‌شود:

def broken_generator():
    yield 1
    raise StopIteration  # در پایتون ۳٫۷ به RuntimeError تبدیل می‌شود

list(broken_generator())
# RuntimeError: generator raised StopIteration

نکتهٔ ظریف اینکه این ترجمه در سطح مفسر رخ می‌دهد و در traceback گاهی خود StopIteration اصلی را به‌عنوان cause می‌بینید. خبر خوب اینکه پیام خطا بسیار صریح است: «generator raised StopIteration». این پیام، راهنمایی مستقیم برای رفع باگ است.

پیامد جانبی PEP 479 این است که برخی الگوهای قدیمی مانند استفاده از StopIteration برای کنترل جریان (مشابه break در حلقه) دیگر کار نمی‌کنند. توسعه‌دهندگانی که از این ترفند استفاده می‌کردند، در نسخه‌های جدید پایتون با خطا مواجه می‌شوند و باید کد خود را بازنویسی کنند. مستندات کامل این تغییر در PEP 479 و صفحهٔ رسمی آن موجود است.

اگر پیام دقیق خطا را با RuntimeError می‌بینید و گمان می‌کنید ناشی از StopIteration است، پیشنهاد می‌کنم همزمان خطای RuntimeError در پایتون را هم مرور کنید تا تفاوت ریشه‌ای را در ذهن داشته باشید.

سناریوهای واقعی که این خطا را می‌سازند

در طول سال‌ها کار با پایتون، StopIteration و پیامدهای PEP 479 را در این الگوها دیده‌ام. شناختن هر الگو، تشخیص را چند برابر سریع‌تر می‌کند.

سناریوی اول: ژنراتوری که به‌جای return از StopIteration استفاده می‌کند

رایج‌ترین خطا از دیدار با PEP 479، همان استفادهٔ دستی از StopIteration برای پایان دادن به ژنراتور است. کد قبل از پایتون ۳٫۵ این رفتار را داشت ولی امروز باید با return ساده جایگزین شود:

# اشتباه در پایتون ۳٫۷+
def gen_wrong():
    yield 1
    yield 2
    raise StopIteration  # تبدیل به RuntimeError می‌شود

# درست
def gen_right():
    yield 1
    yield 2
    return  # پایان طبیعی

سناریوی دوم: فراخوانی next روی iterator تمام‌شده

اگر iterator تمام شده باشد و شما مستقیماً next() صدا بزنید، StopIteration پرتاب می‌شود و اگر مدیریت نکنید، برنامه قطع می‌شود:

iterator = iter([1, 2])
next(iterator)  # 1
next(iterator)  # 2
next(iterator)  # StopIteration

# راه‌حل امن:
next(iterator, None)  # None بدون استثنا

الگوی next(iterator, default) یکی از تمیزترین راه‌حل‌ها است، چون هم سریع است و هم قصد را روشن بیان می‌کند.

سناریوی سوم: بلعیدن StopIteration توسط except Exception

وقتی کد شما در یک ژنراتور، داخل یک بلوک try/except Exception قرار دارد و یکی از iteratorهای داخلی تمام می‌شود، StopIteration توسط except بلعیده می‌شود. سپس کد به مسیر عادی بازمی‌گردد و ژنراتور به شکل عجیبی به کارش ادامه می‌دهد. نتیجه، یک باگ پنهان و غیرقابل تشخیص است:

# باگ پنهان
def problematic(data):
    for item in data:
        try:
            value = next(iter(some_source))
        except Exception:  # StopIteration را بلع می‌کند
            value = None
        yield value

# درست
def fixed(data):
    for item in data:
        try:
            value = next(iter(some_source))
        except StopIteration:  # صریح و روشن
            value = None
        yield value

این الگو، کلاسیک‌ترین دام PEP 479 است. بسیاری از کدهایی که به‌ظاهر کار می‌کنند، در واقع در یک مسیر پنهان، ژنراتور را زودتر از موعد می‌بندند.

سناریوی چهارم: itertools و مصرف‌شدن iterator

توابع itertools مثل chain، zip_longest و islice iteratorها را مصرف می‌کنند. اگر یک iterator را دو بار استفاده کنید، بار دوم بلافاصله StopIteration می‌دهد:

it = iter([1, 2, 3])
list(it)  # [1, 2, 3]
list(it)  # [] چون iterator مصرف شده است

این رفتار یکی از اصلی‌ترین منابع سردرگمی توسعه‌دهندگانی است که از فهرست به iterator می‌روند. راه‌حل: در صورت نیاز به پیمایش چندباره، از نوع دادهٔ نگه‌دارنده مثل list یا tuple استفاده کنید، نه iterator.

سناریوی پنجم: StopIteration در کد بازگشتی

وقتی یک تابع بازگشتی، از next() استفاده می‌کند و به انتهای iterator می‌رسد، StopIteration بدون مدیریت، کل پشتهٔ بازگشت را باز می‌کند. تشخیص این باگ دشوار است چون traceback بسیار طولانی می‌شود:

def traverse(iterator):
    try:
        item = next(iterator)
    except StopIteration:
        return
    # پردازش آیتم
    traverse(iterator)

در این مثال، مدیریت صریح StopIteration ضروری است، وگرنه هر پیمایش در انتها به خطا می‌رسد.

سناریوی ششم: نشت StopIteration به لایه‌های بالاتر

در برخی کتابخانه‌ها، StopIteration از یک iterator داخلی بیرون می‌آید و به لایهٔ بالاتر نشت می‌کند. این حالت مخصوصاً در کتابخانه‌های ORM، درایورهای دیتابیس و ابزارهای وب اسکرپینگ دیده می‌شود. نمونهٔ آن در پروژه‌های وب اسکرپینگ با پایتون زیاد رخ می‌دهد، چون generatorهایی که از یک منبع وب تغذیه می‌کنند، ممکن است زودتر از موعد تمام شوند.

سناریوهای شبیه این در خطاهای دیگر خانوادهٔ Exception هم دیده می‌شود؛ برای نمونه، مقالهٔ خطای ImportError در پایتون نشان می‌دهد که مدیریت نادرست استثناهای داخلی چطور می‌تواند به باگ‌های چندلایه منجر شود.

روش تشخیص در پنج گام

StopIteration پیام‌های ساده‌ای دارد ولی به‌دلیل ماهیت «سیگنالی» آن، تشخیص دقیق نیاز به رویکرد نظام‌مند دارد. پروتکل زیر را در پروژه‌های خودم اجرا می‌کنم.

گام اول: خواندن دقیق پیام خطا

سه پیام رایج داریم:

StopIteration                              # پرتاب مستقیم
RuntimeError: generator raised StopIteration   # PEP 479
RuntimeError: coroutine raised StopIteration   # در async

تفاوت این سه پیام، مسیر تشخیص را کاملاً تعیین می‌کند. اگر پیام دوم را می‌بینید، مطمئن باشید کد شما یک StopIteration را از داخل ژنراتور بیرون داده است. اگر پیام سوم را می‌بینید، مسئله در یک async generator است.

گام دوم: بررسی traceback کامل

در پایتون، traceback از پایین به بالا خوانده می‌شود. آخرین فریم، نقطهٔ پرتاب است. اولین فریم، نقطهٔ شروع زنجیره. در پرونده‌های PEP 479، معمولاً یک پیام During handling of the above exception, another exception occurred می‌بینید که نشان می‌دهد StopIteration اصلی به RuntimeError تبدیل شده است:

import traceback

try:
    list(broken_generator())
except RuntimeError:
    traceback.print_exc(limit=None, chain=True)

پارامتر chain=True در این حالت ضروری است تا زنجیرهٔ کامل استثناها را ببینید.

گام سوم: بازتولید در محیط کنترل‌شده

پیش از هر تغییری، خطا را در حداقل کد ممکن بازتولید کنید. اگر خطا در asyncio رخ می‌دهد، سعی کنید در یک حلقهٔ رویداد ساده بازتولید کنید. اگر در یک فریم‌ورک وب رخ می‌دهد، آن را به یک اسکریپت خط فرمانی ساده تبدیل کنید. این کار، نیمی از ابهام را حذف می‌کند.

گام چهارم: ردیابی درون iteratorها

اگر مطمئن نیستید کدام iterator مسئول است، می‌توانید یک wrapper ساده بنویسید که هر next() را لاگ کند:

import logging

class TracingIterator:
    def __init__(self, it, name="it"):
        self._it = iter(it)
        self._name = name

    def __iter__(self):
        return self

    def __next__(self):
        try:
            value = next(self._it)
            logging.debug("%s yielded: %r", self._name, value)
            return value
        except StopIteration:
            logging.debug("%s exhausted", self._name)
            raise

این wrapper را در محیط استیجینگ روی iterator مشکوک بگذارید. با این کار، نقطهٔ دقیق خالی شدن iterator مشخص می‌شود.

گام پنجم: بررسی نسخهٔ پایتون

اگر کدی روی پایتون ۳٫۴ بدون خطا کار می‌کرده و روی ۳٫۷ خطا می‌دهد، تقریباً مطمئن باشید که PEP 479 رخنه کرده است. این الگو در پروژه‌هایی که ماه‌ها روی نسخهٔ قدیمی می‌مانند و بعد یک‌جا آپدیت می‌کنند، بسیار شایع است:

import sys
print(sys.version_info)

تفاوت بین sys.version_info محیط توسعه و محیط تولید، یکی از رایج‌ترین منابع سردرگمی در این حوزه است.

اگر خطا در سطح سیستمعامل هم دیده می‌شود، ممکن است ناشی از ترکیب StopIteration با خطاهای I/O باشد. مقالهٔ خطای OSError در پایتون تفکیک این دو را روشن می‌کند.

الگوهای درست نوشتن iterator و generator

راه‌حل تمام مسائل مربوط به StopIteration در یک جمله خلاصه می‌شود: «هرگز StopIteration را دستی raise نکنید، مگر در بدنهٔ __next__ یک کلاس iterator». در ادامه، الگوهای عملی را مرور می‌کنیم.

الگوی اول: پایان طبیعی با return

def numbers():
    for i in range(10):
        yield i
    # پایان طبیعی؛ نیازی به raise نیست

اگر می‌خواهید مقدار بازگشتی را به بیرون منتقل کنید، از return value استفاده کنید. این مقدار در متغیر StopIteration.value ذخیره می‌شود، ولی توجه داشته باشید که با PEP 479، این مقدار در سطح مفسر مدیریت می‌شود و از دید کاربر عادی پنهان است.

الگوی دوم: مصرف امن iterator با next

برای خواندن دقیقاً یک عنصر از iterator:

iterator = iter([1, 2, 3])
value = next(iterator, None)
if value is None:
    # iterator خالی بوده است
    ...

این الگو در پروژه‌های processing pipeline بسیار مفید است: اگر iterator تمام شده باشد، بدون استثنا مقدار پیش‌فرض برمی‌گردد.

الگوی سوم: گرفتن StopIteration دقیق

وقتی می‌خواهید استثنا را بگیرید، هرگز except Exception ننویسید. الگوی درست:

try:
    value = next(iterator)
except StopIteration:
    value = None

در صورتی که نیاز دارید چند استثنا را با هم مدیریت کنید، ترتیب را رعایت کنید:

try:
    value = next(iterator)
except StopIteration:
    value = None
except ValueError as e:
    # مدیریت خطای مقدار
    value = None

الگوی چهارم: ساخت iterator با کلاس سفارشی

اگر به iterator سفارشی نیاز دارید، کلاس را به شکل زیر بنویسید:

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration  # این‌جا مجاز است
        value = self.current
        self.current -= 1
        return value

نکته: raise StopIteration در __next__ مجاز است و توصیه می‌شود، چون این تنها جایی است که این استثنا معنای دقیق خود را دارد. در بدنهٔ ژنراتور، استفاده از return کافی است.

الگوی پنجم: استفاده از itertools برای pipeline امن

تابع itertools.chain و itertools.islice به‌طور داخلی StopIteration را مدیریت می‌کنند. به همین دلیل، ترکیب iteratorها با این ابزارها بسیار امن‌تر از نوشتن دستی حلقه است:

import itertools

merged = itertools.chain([1, 2], [3, 4], [5])
for x in merged:
    print(x)  # 1, 2, 3, 4, 5

الگوی ششم: شکستن حلقه با StopIteration در early exit

اگر می‌خواهید یک ژنراتور را در وسط راه متوقف کنید، به‌جای raise StopIteration، از return استفاده کنید:

def first_match(items, predicate):
    for item in items:
        if predicate(item):
            yield item
            return  # نه StopIteration
        yield item

یا از break در حلقهٔ بیرونی استفاده کنید. الگوی صریح، همیشه بهتر از ترفندهای پنهان است.

اگر در پروژه‌ای با خطاهای مشابه در سطح import و ماژول‌ها سروکار دارید، رفع ModuleNotFoundError در پایتون نشان می‌دهد چگونه مدیریت دقیق استثناها، از پنهان‌شدن خطاهای واقعی جلوگیری می‌کند.

StopAsyncIteration و دنیای async

در پایتون ۳٫۵، با معرفی async/await، استثنای جدید StopAsyncIteration نیز معرفی شد. این استثنا معادل دقیق StopIteration در دنیای async است و رفتارش تقریباً یکسان است، با یک تفاوت مهم: زیرشاخهٔ StopIteration نیست، بلکه از Exception مستقیماً ارث می‌برد.

async generator و پایان آن

async def async_numbers():
    for i in range(3):
        yield i

async def main():
    async for x in async_numbers():
        print(x)

وقتی تابع async تمام شود، StopAsyncIteration به‌طور خودکار پرتاب و بلعیده می‌شود. قاعده مشابه PEP 479 در async هم اعمال می‌شود: raise دستی StopAsyncIteration در بدنهٔ async generator، خطاست.

تبدیل StopIteration به StopAsyncIteration

یک نکتهٔ ظریف: اگر داخل یک iterator async، کد همزمان اجرا می‌کنید که StopIteration پرتاب می‌کند، این استثنا به‌طور خودکار به StopAsyncIteration ترجمه نمی‌شود. باید صریحاً مدیریت کنید:

async def wrap_sync(iterator):
    while True:
        try:
            yield next(iterator)
        except StopIteration:
            return

این الگو در پروژه‌های ساخت API با پایتون بسیار رایج است، چون بسیاری از سرویس‌ها نیاز دارند iteratorهای همزمان را در pipelineهای async قرار دهند.

مدیریت خطا در async for

در async for، خطاها متفاوت از for همزمان مدیریت می‌شوند. اگر StopAsyncIteration نشت کند، traceback کوتاه‌تر و گمراه‌کننده‌تر خواهد بود. برای دیباگ بهتر:

import asyncio

async def safe_iterate(agen):
    try:
        async for x in agen:
            yield x
    except StopAsyncIteration:
        return
    except Exception as e:
        # مدیریت خطاهای غیرمنتظره
        raise

یک نکتهٔ مهم: در asyncio، خطاهای داخلی اغلب به‌صورت Task در پس‌زمینه می‌مانند و در بالاترین سطح ظاهر نمی‌شوند. اگر خطای عجیبی در async می‌بینید که traceback ندارد، پیشنهاد می‌کنم خطای RuntimeError در پایتون را هم مرور کنید تا رفتار حلقهٔ رویداد را بهتر بشناسید.

StopIteration در Django، pytest و کتابخانه‌ها

هر چارچوب و کتابخانه، رفتار خاص خود را با StopIteration دارد. شناخت این رفتارها، تشخیص باگ در محیط‌های واقعی را سریع‌تر می‌کند.

Django و QuerySet

در آموزش Django برای مبتدیان دیدیم که QuerySet پایتون را شبیه iterator رفتار می‌دهد. اگر کوئری بدون نتیجه باشد و شما مستقیماً next() صدا بزنید، StopIteration می‌گیرید. راه‌حل تمیزتر استفاده از متدهای آماده مثل .first()، .exists() یا .count() است:

# اشتباه
first = next(User.objects.filter(active=True))

# درست
first = User.objects.filter(active=True).first()

در فریم‌ورک‌های حرفه‌ای، همیشه از APIهای تخصصی استفاده کنید؛ نه از پروتکل خام iterator.

pytest و فریم‌ورک‌های تست

در تست، اگر fixture یا تابع تست از next() استفاده کند و iterator خالی باشد، StopIteration به‌جای شکست تست، ممکن است به شکل شکست غیرمنتظره ظاهر شود. pytest از نسخه‌های ۵ به بعد، این استثنا را به‌عنوان خطا گزارش می‌کند. الگوی امن:

def test_iterator():
    iterator = iter([])
    with pytest.raises(StopIteration):
        next(iterator)

SQLAlchemy و درایورهای دیتابیس

در SQLAlchemy، هر بار که نتیجهٔ کوئری را پیمایش می‌کنید، در سطح پایین‌تر یک iterator در حال کار است. اگر بعد از پایان پیمایش، دوباره روی همان result پیمایش کنید، StopIteration می‌گیرید. راه‌حل: در صورت نیاز به پیمایش چندباره، به لیست تبدیل کنید. برای آشنایی با الگوهای اتصال به دیتابیس، اتصال پایتون به MySQL را مرور کنید.

Celery و کارگرها

در Celery، اگر تسکی از generator استفاده کند و آن generator به‌درستی مدیریت نشود، StopIteration می‌تواند در نتیجهٔ تسک نشت کند. این حالت در محیط تولید، خطاهای عجیبی می‌سازد که در محیط توسعه ظاهر نمی‌شوند. راه‌حل: همیشه generatorها را قبل از return از تسک، به لیست تبدیل کنید یا از yield در تسک پرهیز کنید.

FastAPI و response streaming

در FastAPI، StreamingResponse از یک generator تغذیه می‌کند. اگر این generator به‌درستی پایان یابد، StopIteration به‌طور داخلی مدیریت می‌شود. ولی اگر داخل generator، یک StopIteration اضافی raise شود، FastAPI آن را به ۵۰۰ تبدیل می‌کند:

from fastapi.responses import StreamingResponse

async def safe_stream():
    for i in range(10):
        yield f"chunk {i}\n"
    # پایان طبیعی؛ نیازی به raise نیست

@app.get("/stream")
async def stream():
    return StreamingResponse(safe_stream())

پرسش‌های پرتکرار درباره StopIteration

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

StopIteration چه تفاوتی با RuntimeError دارد؟

در پایتون ۳٫۷ به بعد، اگر StopIteration از داخل یک ژنراتور بیرون بیاید، به‌طور خودکار به RuntimeError ترجمه می‌شود. علت این ترجمه، مشخص کردن باگ پنهان است. برای مطالعهٔ تفصیلی RuntimeError، خطای RuntimeError در پایتون را ببینید.

چرا ژنراتور من پیام «generator raised StopIteration» می‌دهد؟

چون در بدنهٔ ژنراتور، یک StopIteration دستی یا از یک iterator داخلی، بیرون داده شده است. راه‌حل: به‌جای raise StopIteration از return استفاده کنید. اگر StopIteration از یک iterator داخلی می‌آید، آن را با try/except StopIteration به‌طور صریح مدیریت کنید و در صورت لزوم، به مسیر عادی بازگردید.

آیا StopIteration زیرشاخهٔ Exception است یا BaseException؟

StopIteration زیرشاخهٔ Exception است، نه BaseException. یعنی except Exception آن را می‌گیرد. این یکی از دلایل اصلی باگ‌های پنهان است، چون کدهایی که برای «گرفتن همهٔ خطاها» از except Exception استفاده می‌کنند، ناخواسته سیگنال پایان ژنراتور را هم بلع می‌کنند.

چگونه از next() بدون مواجهه با StopIteration استفاده کنیم؟

همیشه مقدار پیش‌فرض تعیین کنید:

value = next(iterator, None)

این الگو را در تمام پروژه‌ها توصیه می‌کنم، چون هم قصد را روشن می‌کند و هم از استثنای غیرمنتظره جلوگیری می‌کند.

چرا iterator یک بار مصرف است؟

چون پروتکل iterator طبق قرارداد، فقط «جلو» می‌رود. برای پیمایش دوباره، باید iterator جدید بسازید یا از نوع دادهٔ نگه‌دارنده مثل list و tuple استفاده کنید. این طراحی عمدی است: iterator می‌تواند جریان داده را از منبعی که به‌طور نامحدود ادامه دارد (مثل فایل‌های بزرگ یا پاسخ HTTP streaming) تغذیه کند و مصرف حافظه را ثابت نگه دارد.

آیا StopIteration در asyncio هم وجود دارد؟

در asyncio، معادل دقیق آن StopAsyncIteration است. این استثنا از Exception ارث می‌برد، ولی زیرشاخهٔ StopIteration نیست. رفتار PE 479 مشابه آن در async هم اعمال می‌شود: raise دستی StopAsyncIteration در بدنهٔ async generator، خطاست.

چطور بفهمم کدام iterator باعث StopIteration شده است؟

در traceback، نقطهٔ پرتاب استثنا در آخرین فریم نمایش داده می‌شود. اگر iterator مسئول در کتابخانه‌ای باشد، زنجیرهٔ traceback طولانی‌تر خواهد بود. برای دیدن این زنجیره، همیشه با chain=True خطا را لاگ کنید یا از ابزارهایی مثل tracing در IPython استفاده کنید.

آیا PEP 479 روی همهٔ نسخه‌های پایتون اعمال می‌شود؟

در پایتون ۳٫۵ به‌صورت opt-in از طریق from __future__ import generator_stop فعال می‌شد، در پایتون ۳٫۶ به‌عنوان پیش‌فرض پیشنهاد شد، و از پایتون ۳٫۷ به بعد رفتار پیش‌فرض و اجباری است. اگر کدی روی پایتون ۳٫۴ بدون خطا کار می‌کرد و روی ۳٫۷ خطا می‌دهد، تقریباً مطمئن باشید که PEP 479 رخنه کرده است.

چطور یک generator را از داخل یک تابع async فراخوانی کنم؟

در پایتون ۳٫۶ به بعد، می‌توانید از async for روی async generator استفاده کنید. برای generatorهای همزمان، از asyncio.to_thread یا wrapping دستی بهره ببرید:

import asyncio

async def consume_sync(gen):
    for item in gen:
        yield item
        await asyncio.sleep(0)  # آزادسازی حلقهٔ رویداد

آیا در کد همزمان، StopIteration شایع است؟

در کد همزمان، StopIteration به‌طور طبیعی توسط حلقهٔ for مدیریت می‌شود و از دید کاربر ظاهر نمی‌شود. فقط زمانی که از next() مستقیم استفاده می‌کنید یا ژنراتور سفارشی می‌نویسید، ممکن است این استثنا را ببینید. الگوی درست در ۹۰٪ موارد، استفاده از for و next(iterator, default) است.

آیا StopIteration در کتابخانه‌های C-level هم رفتار یکسانی دارد؟

خیر، به‌طور کلی. کتابخانه‌هایی که با C یا Cython نوشته شده‌اند، می‌توانند در برخی موارد استثناهای خود را با ترجمه‌های متفاوتی پرتاب کنند. برای نمونه، برخی از کتابخانه‌های پردازش تصویر یا یادگیری ماشین، در پیمایش دسته‌های داده (batches) رفتار سفارشی دارند. اگر با چنین کتابخانه‌ای سروکار دارید، مستندات رسمی را مرجع بگیرید و از raise دستی StopIteration در کد پایتونی خود پرهیز کنید.

چرا در Jupyter Notebook گاهی StopIteration ظاهر نمی‌شود؟

چون Jupyter خودش خطاهای ناشی از iterator را در برخی حالت‌ها مدیریت می‌کند تا تجربهٔ کاربری روان‌تری بسازد. اگر در سلول notebook، next(iterator) صدا بزنید و iterator خالی باشد، ممکن است به‌جای نمایش استثنا، مقدار None ببینید. برای دیدن استثنا در Jupyter، همیشه صریحاً آن را بگیرید:

try:
    value = next(iterator)
except StopIteration:
    print("iterator exhausted")

چطور در لاگ ساخت‌یافته، StopIteration را ثبت کنیم؟

StopIteration در بیشتر مواقع نباید لاگ شود، چون یک سیگنال عادی است. ولی اگر به‌عنوان باگ رخ دهد (مثلاً در قالب RuntimeError)، باید با context کامل ثبت شود:

import logging
import traceback

logger = logging.getLogger(__name__)

try:
    list(suspicious_generator())
except RuntimeError:
    logger.exception(
        "stop iteration leaked; stack=%s",
        traceback.format_exc()
    )
    raise

آیا با تغییر نسخهٔ پایتون، خطاهای StopIteration کمتر یا بیشتر می‌شوند؟

در نسخه‌های جدیدتر، خطاهای PEP 479 صریح‌تر شده‌اند و همین باعث می‌شود باگ‌های پنهان بیشتر لو بروند. یعنی ممکن است کدی که روی پایتون ۳٫۶ بدون مشکل اجرا می‌شد، روی پایتون ۳٫۱۲ خطا بدهد. این تحول طبیعی است: زبان با گذشت زمان، خطاهای پنهان را شفاف می‌کند. برای مطالعات مکمل در این حوزه، خطای ValueError در پایتون و خطای IndexError در پایتون نمونه‌های خوبی از تحول مشابه در سایر خانواده‌ها هستند.

آنچه StopIteration به معماری کد من آموخت

StopIteration بیش از آنکه یک استثنا باشد، یک «قرارداد بین‌المللی» است: زبان پایتون سیگنال پایان را از طریق این استثنا اعلام می‌کند و ما به‌عنوان توسعه‌دهنده، باید این قرارداد را محترم بشماریم. سه اصلی که پس از سال‌ها کار با آن، در معماری کد خودم رعایت می‌کنم:

نخست، سیگنال‌های کنترل جریان را با خطاها قاطی نکنید. StopIteration یک سیگنال است، نه یک خطا. اگر کد شما این سیگنال را بلعیده یا اشتباه تفسیر کند، موجودیت‌های بالاتر مثل حلقهٔ for و ابزارهای itertools از کار می‌افتند. هر بار که except Exception می‌نویسید، این سؤال را بپرسید: «اگر StopIteration یا StopAsyncIteration این‌جا باشد، منطقم خراب می‌شود؟». اگر پاسخ بله است، استثناهای صریح‌تر را جدا کنید.

دوم، PEP 479 را دوست خود بدانید، نه دشمن. این تغییر ناخوشایند به نظر می‌رسد، ولی در واقع از شما محافظت می‌کند: کدهایی که به‌طور پنهان ژنراتور را زودتر از موعد می‌بستند، امروز با RuntimeError لو می‌روند. این دقیقاً همان چیزی است که یک زبان سالم باید انجام دهد — سیگنال خطا را بلند کند، نه اینکه خاموش بگذارد.

سوم، generatorها را ابزار «جریان داده» ببینید، نه ابزار «پیمایش مجموعه». اگر می‌خواهید چند بار روی داده پیمایش کنید، generator انتخاب غلطی است. از list استفاده کنید. اگر می‌خواهید داده‌ای را به‌صورت streaming از یک منبع نامحدود بخوانید، generator عالی است. این تفکیک، نیمی از پرونده‌های StopIteration را از ابتدا حذف می‌کند.

در پایان، اگر در پروژه‌ای با حالت خاصی از StopIteration برخورد کردید که این‌جا پوشش داده نشده — مثلاً در ترکیب با FastAPI با نسخه‌های خاص، aiohttp با streaming، یا PyTorch DataLoader که رفتار اختصاصی دارد — تجربه‌تان را در دیدگاه‌ها بنویسید. به‌ویژه اگر راه‌حلی متفاوت از رویکردهای معمول پیدا کرده‌اید که می‌تواند برای خوانندهٔ بعدی ارزشمند باشد. 🧭