دیدنِ چند خروجی درست از یک مولد، پایانِ درستِ آن را ثابت نمی‌کند. ممکن است همهٔ اختلاف‌های مورد انتظار را بگیریم، اما درخواست بعدی به جای پایان عادی، خطای برنامه‌نویسی را آشکار کند. در این درس، یک محاسبهٔ کوچکِ میلی‌ولتی را بهانه می‌کنیم تا خروجی‌های تحویل‌شده، مقدارِ بازگشتی و علتِ استثنا را جدا بررسی کنیم. داده‌ها تألیفی و مصنوعی‌اند؛ حسگر یا دریافت زنده‌ای در آزمایش وجود ندارد.

۱. پایان عادی را از خطای داخل مولد جدا کنیم

بازگشتِ عادی را از عضوِ تحویل‌شده جدا کنیم
تصویر مفهومی: بازگشتِ عادی را از عضوِ تحویل‌شده جدا کنیم؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

در نمونهٔ اصلی، چهار مقدار ۱۰۰۰،۱۰۱۰،۱۰۰۵،۱۰۲۰ میلی‌ولت داریم. هر دو عضو پیاپی یک جفت‌اند و اختلاف را عضو دوم منهای اول می‌گیریم؛ پس خروجی دستی ۱۰ و۱۵ میلی‌ولت است. این اختلاف، مشتق زمانی یا خطای اندازه‌گیری نیست: زمان، نویز و مرجع فیزیکی تعریف نکرده‌ایم. تعدادِ دو جفت نیز کمیتی بدون واحد است و نباید به فهرستِ ولتاژها افزوده شود.

مولد سالم این دو اختلاف را با yield تحویل می‌دهد و در پایان با return count تمام می‌شود. مطابق مرجع رسمی دستور return، مقدارِ return در مولد، عضوِ بعدیِ دنباله نیست؛ در ویژگی value نخستین StopIteration پایانی قرار می‌گیرد. حلقهٔ معمولی یا list فقط مقدارهای yieldشده را جمع می‌کند و این خلاصه را خودکار به خروجی نمی‌افزاید.

برای عیب‌یابی، دو سؤال مستقل می‌پرسیم: «چه پیشوندی تحویل شد؟» و «درخواست بعدی چگونه تمام شد؟». نسخهٔ معیوب نیز همین دو اختلاف درست را می‌دهد، اما پس از آن RuntimeError دارد. بنابراین آزمونی که فقط دو بار next بزند، شاهد اصلی را از دست می‌دهد. در درس دسته‌بندیِ مولد حسگر، پوشش رکورد و مصرف دوباره موضوع اصلی بود؛ اینجا مرزِ پایانِ خودِ بدنهٔ مولد را می‌سنجیم.

۲. چرا next خالی به RuntimeError می‌رسد؟

کمبودِ ناخواستهٔ منبع باید آشکار بماند
تصویر مفهومی: کمبودِ ناخواستهٔ منبع باید آشکار بماند؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

نسخهٔ عمداً خراب، برای گرفتن هر نیمهٔ جفت، next را بدون مقدارِ پیش‌فرض صدا می‌زند. وقتی منبع تمام شود، همان فراخوانی StopIteration ایجاد می‌کند. اگر این استثنا از بدنهٔ مولد بیرون برود، پایتون آن را به RuntimeError تبدیل می‌کند؛ اصلِ StopIteration در __cause__ باقی می‌ماند. مدخل رسمی StopIteration، حالت مستقیم و غیرمستقیم، علت و مقدار را صریح توضیح می‌دهد.

این قاعده، پایان عادیِ حاصل از return را تبدیل نمی‌کند. همچنین هر StopIteration در هر جای برنامه RuntimeError نمی‌شود: next روی تکرارگرِ خالی، بیرون از بدنهٔ مولد، همچنان StopIteration می‌دهد. اگر تابع کمکیِ معمولی همان خطا را بسازد و خطا از مولدِ فراخواننده بیرون برود، تبدیل در مرزِ مولد اتفاق می‌افتد. آزمونِ علت برای همین تفاوت است، نه برای تطبیقِ متنِ انگلیسیِ پیام خطا.

پیشنهاد PEP479 تاریخچهٔ این تصمیم را بیان می‌کند؛ گذار از نسخه‌های قدیمی را نباید رفتارِ امروز معرفی کرد. طبق مستندات، از Python3.7 این رفتار پیش‌فرض است. اجرای ثبت‌شدهٔ ما CPython3.14.5 است و صفحات مرجع، نسخهٔ3.14.8 را نمایش می‌دهند. تاریخ ایجاد PEP در سال۲۰۱۴، تاریخ دسترسیِ امروز و نسخهٔ اجرای مثال، سه دادهٔ جدا هستند.

۳. مرز جفتِ ناقص را صریح نگه داریم

نیمهٔ یک جفت با پایانِ پیش از جفت فرق دارد
تصویر مفهومی: نیمهٔ یک جفت با پایانِ پیش از جفت فرق دارد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

اصلاحِ ما یک نشانهٔ خصوصی با object می‌سازد و آن را به next می‌دهد. اگر پیش از شروع جفت به این نشانه برسیم، پایان عادی است؛ اگر عضو اول رسیده ولی عضو دوم نرسد، ValueError صریحِ «جفت ناقص» داریم. مقایسه با نشانه با is انجام می‌شود. این نشانه با عددِ صفر یا منفی اشتباه نمی‌شود؛ ورودیِ پذیرفته‌شده فقط عدد صحیحِ دقیق است و خودِ نشانه از بیرون دریافت نمی‌شود.

قرارداد ورودی عمداً کوچک است: tuple دقیق، صفر تا هشت نمونه، هر نمونه int دقیق در بازهٔ منفی ده‌هزار تا ده‌هزار میلی‌ولت. bool، زیرکلاسِ int، اعشار، NaN، رشته و فهرست پذیرفته نیستند. این انتخاب آموزشی، محدودیتِ مولدهای پایتون یا مشخصات دستگاه نیست. اختلافِ دو ورودیِ مرزی می‌تواند۲۰۰۰۰ میلی‌ولت شود؛ حدِ ورودی، حدِ خروجی نیست. تهی بودن نیز اینجا پایانِ معتبر با شمارندهٔ صفر است.

کارخانهٔ عمومی، اعتبارسنجی را پیش از برگرداندن مولد انجام می‌دهد؛ بنابراین خطای نوعِ ورودی منتظر نخستین next نمی‌ماند. این بازبینی، یک پیمایشِ جدا دارد و کل برنامه را «تک‌گذر روی منبع نامحدود» نمی‌نامیم. برای سه نمونه، یک اختلافِ کامل پیش از ValueError تحویل می‌شود. همان‌طور که درس zip سخت‌گیرانه نشان می‌داد، استثنای دیررس به‌تنهایی اثرِ مصرفِ قبلی را برنمی‌گرداند؛ این مثال نیز تراکنش یا تحویلِ اتمی پیاده نکرده است.

۴. کد کامل و مرجعِ مستقلِ نتیجه و پایان

مرجعِ مستقل، نتیجه و نوعِ پایان را جدا می‌سنجد
تصویر مفهومی: مرجعِ مستقل، نتیجه و نوعِ پایان را جدا می‌سنجد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل زیر فقط کتابخانهٔ استاندارد می‌خواهد. observe_terminal برای همین آزمایشِ متناهی، خروجی‌ها و نوعِ پایان را ثبت می‌کند؛ سیاستِ عمومیِ گرفتنِ همهٔ استثناها یا تکرارِ عملیات نیست. مرجع دوم، روی fixture معتبر از طول و اندیس استفاده می‌کند، نه next، مولد یا تابعِ تولیدِ اختلاف. پس انتظارِ پیشوند، زوج یا فرد بودنِ طول و نوعِ پایان را از مسیرِ دیگری می‌سازد.

"""Bounded generator exception-boundary lesson; synthetic millivolts only."""
from itertools import product
import json
import platform


def validate_samples(samples):
    """Eager own profile, not an arbitrary streaming-source interface."""
    if type(samples) is not tuple:
        raise TypeError("exact tuple required")
    if len(samples) > 8:
        raise ValueError("at most eight samples")
    for value in samples:
        if type(value) is not int:
            raise TypeError("exact integer millivolts required")
        if not -10000 <= value <= 10000:
            raise ValueError("sample outside teaching range")


def _checked_pairs(source):
    end = object()
    count = 0
    while True:
        left = next(source, end)
        if left is end:
            return count
        right = next(source, end)
        if right is end:
            raise ValueError("incomplete pair")
        yield right - left
        count += 1


def _broken_pairs(source):
    # Deliberate bug: internal exhaustion is allowed to escape this frame.
    while True:
        left = next(source)
        right = next(source)
        yield right - left


def pair_deltas(samples):
    validate_samples(samples)
    return _checked_pairs(iter(samples))


def broken_pair_deltas(samples):
    validate_samples(samples)
    return _broken_pairs(iter(samples))


def observe_terminal(generator):
    """Finite experiment collector, no retry or arbitrary exception policy."""
    prefix = []
    while True:
        try:
            prefix.append(next(generator))
        except StopIteration as error:
            return {"prefix_mv": prefix, "terminal": "StopIteration",
                    "value": error.value, "cause": None, "cause_value": None}
        except (RuntimeError, ValueError) as error:
            cause = error.__cause__
            return {"prefix_mv": prefix, "terminal": type(error).__name__,
                    "value": None, "cause": type(cause).__name__ if cause else None,
                    "cause_value": cause.value if isinstance(cause, StopIteration) else None}


def indexed_oracle(samples, broken=False):
    """Trusted valid finite fixtures: indexing only, no iterator/generator."""
    prefix = [samples[2 * i + 1] - samples[2 * i]
              for i in range(len(samples) // 2)]
    if broken:
        return {"prefix_mv": prefix, "terminal": "RuntimeError", "value": None,
                "cause": "StopIteration", "cause_value": None}
    if len(samples) % 2:
        return {"prefix_mv": prefix, "terminal": "ValueError", "value": None,
                "cause": None, "cause_value": None}
    return {"prefix_mv": prefix, "terminal": "StopIteration",
            "value": len(samples) // 2, "cause": None, "cause_value": None}


def returns_summary():
    yield 7
    return 99


def raises_stop():
    yield 7
    raise StopIteration(99)


def empty_helper():
    return next(iter(()))


def indirect_failure():
    yield 7
    empty_helper()


def catches_internal_end():
    yield 7
    try:
        empty_helper()
    except StopIteration:
        return 23


class AuthoredEnd(StopIteration):
    pass


def raises_subclass():
    yield 7
    raise AuthoredEnd(42)


def rejects(error_type, function, value):
    try:
        function(value)
    except error_type:
        return True
    return False


def exhausted_value(generator):
    try:
        next(generator)
    except StopIteration as error:
        return True, error.value
    return False, None


def run_example():
    samples = (1000, 1010, 1005, 1020)
    correct = observe_terminal(pair_deltas(samples))
    broken = observe_terminal(broken_pair_deltas(samples))
    hand_correct = {"prefix_mv": [10, 15], "terminal": "StopIteration",
                    "value": 2, "cause": None, "cause_value": None}
    hand_broken = {"prefix_mv": [10, 15], "terminal": "RuntimeError",
                   "value": None, "cause": "StopIteration", "cause_value": None}
    checks = {"literal_even_prefix_same_but_normal_return_differs_from_bug":
              correct == hand_correct and broken == hand_broken}
    empty = observe_terminal(pair_deltas(()))
    checks["empty_input_normal_zero_summary_versus_accidental_runtime_error"] = (
        empty == {"prefix_mv": [], "terminal": "StopIteration", "value": 0,
                  "cause": None, "cause_value": None}
        and observe_terminal(broken_pair_deltas(())) == indexed_oracle((), True))
    odd = observe_terminal(pair_deltas((1000, 1010, 1005)))
    checks["incomplete_pair_is_explicit_value_error_after_valid_prefix"] = (
        odd == {"prefix_mv": [10], "terminal": "ValueError", "value": None,
                "cause": None, "cause_value": None})
    cases = 0
    oracle_ok = True
    for length in range(7):
        for fixture in product((-1, 0, 2), repeat=length):
            cases += 1
            oracle_ok = (oracle_ok
                         and observe_terminal(pair_deltas(fixture)) == indexed_oracle(fixture)
                         and observe_terminal(broken_pair_deltas(fixture)) == indexed_oracle(fixture, True))
    checks["independent_indexed_prefix_and_terminal_oracle_1093_fixtures_one_family"] = (
        cases == 1093 and oracle_ok)
    returned = observe_terminal(returns_summary())
    raised = observe_terminal(raises_stop())
    checks["return_99_is_not_raise_stopiteration_99"] = (
        returned == {"prefix_mv": [7], "terminal": "StopIteration", "value": 99,
                     "cause": None, "cause_value": None}
        and raised == {"prefix_mv": [7], "terminal": "RuntimeError", "value": None,
                       "cause": "StopIteration", "cause_value": 99})
    checks["indirect_helper_stopiteration_also_converted_at_generator_boundary"] = (
        observe_terminal(indirect_failure()) == {
            "prefix_mv": [7], "terminal": "RuntimeError", "value": None,
            "cause": "StopIteration", "cause_value": None})
    outside_type = None
    try:
        empty_helper()
    except StopIteration as error:
        outside_type = type(error).__name__
    checks["ordinary_next_outside_generator_remains_stopiteration"] = outside_type == "StopIteration"
    checks["caught_internal_stopiteration_can_finish_by_normal_return"] = (
        observe_terminal(catches_internal_end()) == {
            "prefix_mv": [7], "terminal": "StopIteration", "value": 23,
            "cause": None, "cause_value": None})
    checks["stopiteration_subclass_cause_and_value_are_retained"] = (
        observe_terminal(raises_subclass()) == {
            "prefix_mv": [7], "terminal": "RuntimeError", "value": None,
            "cause": "AuthoredEnd", "cause_value": 42})
    bad_generator = broken_pair_deltas(samples)
    partial_generator = pair_deltas((1, 2, 3))
    observe_terminal(bad_generator)
    observe_terminal(partial_generator)
    checks["unhandled_errors_close_generator_without_resuming_or_return_summary"] = (
        exhausted_value(bad_generator) == (True, None)
        and exhausted_value(partial_generator) == (True, None))
    summary_generator = pair_deltas(samples)
    first_summary = observe_terminal(summary_generator)
    checks["summary_only_first_terminal_not_a_yielded_sample"] = (
        first_summary["value"] == 2 and exhausted_value(summary_generator) == (True, None)
        and list(pair_deltas(samples)) == [10, 15])
    class IntChild(int):
        pass
    class TupleChild(tuple):
        pass
    invalid = [[], iter(()), "", TupleChild(()), (True,), (1.0,),
               (float("nan"),), ("1",), (IntChild(1),)]
    checks["eager_exact_type_validation_rejects_nine_bad_inputs_one_family"] = all(
        rejects(TypeError, factory, value)
        for factory in (pair_deltas, broken_pair_deltas) for value in invalid)
    edge = (-10000, 10000) * 4
    checks["own_eight_sample_and_millivolt_bounds_are_enforced"] = (
        observe_terminal(pair_deltas(edge)) == indexed_oracle(edge)
        and observe_terminal(pair_deltas(edge))["prefix_mv"] == [20000] * 4
        and all(rejects(ValueError, factory, value)
                for factory in (pair_deltas, broken_pair_deltas)
                for value in ((0,) * 9, (-10001,), (10001,))))
    assert len(checks) == 13 and all(type(v) is bool and v for v in checks.values()), checks
    return {"python": platform.python_version(), "implementation": platform.python_implementation(),
            "input_origin": "authored finite synthetic integer millivolts; no sensor I/O",
            "samples_mv": samples, "correct": correct, "deliberate_bug": broken,
            "incomplete_pair": odd, "return_99": returned, "raise_stopiteration_99": raised,
            "independent_oracle_fixture_count": cases, "unique_check_groups": len(checks),
            "checks": checks}


if __name__ == "__main__":
    print(json.dumps(run_example(), indent=2, allow_nan=False))

در خانوادهٔ فراگیرِ کوچک، همهٔ دنباله‌های طول صفر تا شش از سه مقدارِ منفی یک، صفر و دو بررسی می‌شوند: جمعاً۱۰۹۳ fixture. هر fixture با مرجعِ اندیسی برای نسخهٔ سالم و معیوب مقایسه می‌شود؛ این شمار، یک خانوادهٔ آزمون است، نه۱۰۹۳ گروه مستقل. بازهٔ ورودیِ واقعی بزرگ‌تر است و این شمارش، اثباتِ آزمایشیِ همهٔ مقادیرِ آن بازه نیست. شاهدِ دستی نیز جدا جلوی هم‌خطاییِ دو روش را می‌گیرد.

{
  "python": "3.14.5",
  "implementation": "CPython",
  "input_origin": "authored finite synthetic integer millivolts; no sensor I/O",
  "samples_mv": [
    1000,
    1010,
    1005,
    1020
  ],
  "correct": {
    "prefix_mv": [
      10,
      15
    ],
    "terminal": "StopIteration",
    "value": 2,
    "cause": null,
    "cause_value": null
  },
  "deliberate_bug": {
    "prefix_mv": [
      10,
      15
    ],
    "terminal": "RuntimeError",
    "value": null,
    "cause": "StopIteration",
    "cause_value": null
  },
  "incomplete_pair": {
    "prefix_mv": [
      10
    ],
    "terminal": "ValueError",
    "value": null,
    "cause": null,
    "cause_value": null
  },
  "return_99": {
    "prefix_mv": [
      7
    ],
    "terminal": "StopIteration",
    "value": 99,
    "cause": null,
    "cause_value": null
  },
  "raise_stopiteration_99": {
    "prefix_mv": [
      7
    ],
    "terminal": "RuntimeError",
    "value": null,
    "cause": "StopIteration",
    "cause_value": 99
  },
  "independent_oracle_fixture_count": 1093,
  "unique_check_groups": 13,
  "checks": {
    "literal_even_prefix_same_but_normal_return_differs_from_bug": true,
    "empty_input_normal_zero_summary_versus_accidental_runtime_error": true,
    "incomplete_pair_is_explicit_value_error_after_valid_prefix": true,
    "independent_indexed_prefix_and_terminal_oracle_1093_fixtures_one_family": true,
    "return_99_is_not_raise_stopiteration_99": true,
    "indirect_helper_stopiteration_also_converted_at_generator_boundary": true,
    "ordinary_next_outside_generator_remains_stopiteration": true,
    "caught_internal_stopiteration_can_finish_by_normal_return": true,
    "stopiteration_subclass_cause_and_value_are_retained": true,
    "unhandled_errors_close_generator_without_resuming_or_return_summary": true,
    "summary_only_first_terminal_not_a_yielded_sample": true,
    "eager_exact_type_validation_rejects_nine_bad_inputs_one_family": true,
    "own_eight_sample_and_millivolt_bounds_are_enforced": true
  }
}

سیزده گروهِ واقعی گذشتند؛ خروجیِ کامل بالا از یک اجرای مستقلِ نویسنده ذخیره شده است. تفاوتِ return99 و raiseStopIteration99، علتِ زیرکلاسِ StopIteration، خطای تابع کمکی، گرفتنِ خطا پیش از خروج، بسته‌شدنِ مولد بعد از خطای مهارنشده، نوع‌ها و حدود هم بررسی شدند. عدد۷ در شاهدِ جدا فقط مقدارِ نمایشی است؛ عدد۹۹ خلاصهٔ بازگشتی است و هیچ‌کدام نتیجهٔ ولتاژیِ نمونهٔ اصلی معرفی نشده‌اند.

پس از خطای مهارنشده، گرفتنِ RuntimeError در مصرف‌کننده، مولد را از محلِ خطا زنده نمی‌کند. درخواستِ بعدی پایان می‌دهد؛ این پایانِ بعدی، مجوزِ موفق نامیدنِ اجرای قبلی نیست. همچنین مقدارِ۲ فقط در نخستین StopIteration پایانیِ مسیرِ سالم مشاهده می‌شود؛ درخواستِ دوباره مقدارِ None دارد. آزمون، روی کلاسِ خطا و cause تکیه می‌کند و به متنِ پیامِ وابسته به نسخه، وابستگی نمی‌سازد.

۵. چهار پرسش دربارهٔ پایان، پیشوند و علت خطا

دیدنِ پیشوندِ سالم به معنیِ پایانِ موفق نیست
تصویر مفهومی: دیدنِ پیشوندِ سالم به معنیِ پایانِ موفق نیست؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: اگر هر دو نسخه ۱۰ و۱۵ را دادند، آیا پاسخِ محاسبه کامل است؟ پاسخ: پیشوندِ عددی درست است، اما وضعیتِ پایان متفاوت است. نسخهٔ سالم با خلاصهٔ۲ تمام می‌شود؛ نسخهٔ خراب در درخواستِ بعدی RuntimeError با علتِ StopIteration دارد. برای تأییدِ کل پیمایش باید پایان را هم مشاهده کنیم؛ فقط مقایسهٔ دو عدد کافی نیست.

پرسش دوم: برای سه نمونهٔ ۱۰۰۰،۱۰۱۰،۱۰۰۵ چه چیزی دریافت می‌کنیم؟ پاسخ: اختلافِ۱۰ ابتدا تحویل می‌شود و سپس نسخهٔ اصلاح‌شده ValueErrorِ جفت ناقص می‌دهد. نمونهٔ سوم را با صفر پُر نمی‌کنیم و بی‌صدا کنار نمی‌گذاریم. اگر کاربرد سیاستِ دیگری بخواهد، باید همان سیاست را جدا تعریف و مرجعِ مورد انتظار را نیز عوض کند؛ گرفتنِ استثنا، تعریفِ مسئله را تغییر نمی‌دهد.

پرسش سوم: آیا return99 و raiseStopIteration99 هر دو یک پایان‌اند؟ پاسخ: خیر. اولی پایانِ عادی با value برابر۹۹ است؛ دومی وقتی از بدنهٔ مولد بیرون می‌رود RuntimeError می‌شود و۹۹ در valueِ علتِ آن قرار دارد. دادهٔ yieldشده در هر دو شاهد فقط۷ است. جابه‌جا کردنِ این دو دستور، جایگزینیِ بی‌خطر برای کوتاه‌کردنِ برنامه نیست.

پرسش چهارم: آیا گرفتنِ StopIteration داخل مولد همیشه خطاست؟ پاسخ: خیر؛ اگر در نقطهٔ درست آن را بگیریم و طبق قرارداد return کنیم، تبدیلِ خطای مهارنشده رخ نمی‌دهد؛ شاهدِ کد با خلاصهٔ۲۳ همین را نشان می‌دهد. با این حال گرفتنِ گستردهٔ خطا دورِ تمام محاسبه می‌تواند پایانِ ناخواستهٔ یک تابع کمکی را پنهان کند. نشانهٔ خصوصی، در این قراردادِ محدود، محلِ انتظارِ پایان را روشن نگه می‌دارد؛ تضمینِ مدیریتِ منبع یا سامانهٔ واقعی نیست.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۸ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.