فهرست مقاله
۱. قرارداد آداپتور را از نام کلاس جدا کنیم

فرض کنید بخشِ پردازشِ یک ابزار مهندسی، دادهٔ آزمایشی را از آداپتوری با عضوِ read دریافت میکند. امروز آداپتور دادهٔ ازپیشساخته برمیگرداند و فردا ممکن است پیادهسازی دیگری جای آن بنشیند. نیازِ مصرفکننده این است: یک فراخوانیِ بدون آرگومان، مقداری دقیقاً از نوعِ بایت و با طولِ مجاز بدهد. نامِ کلاس، وجودِ یک توضیحِ نوع و موفقیتِ آزمونِ عضویت، هر کدام اطلاعاتی میدهند؛ اما هیچکدام بهتنهایی این رفتارِ کامل را نشان نمیدهند.
در این مثال، Readable یک Protocol با امضای read() -> bytes است. کلاسِ آزمایشیِ ما از آن ارث نمیبرد. هدف، بیانِ شکلِ موردنیازِ رابط است، نه الزامِ همهٔ آداپتورها به یک تبارِ کلاسی. مستنداتِ رسمیِ Protocol کاربردِ اصلیِ این سازوکار در نوعگذاریِ ساختاری را توضیح میدهند. در این آزمایش هیچ بررسیکنندهٔ ایستای نوع اجرا نشده است؛ از خروجیِ مفسر نمیتوان نتیجهٔ اجرای چنین ابزاری را استنباط کرد.
دامنه را عمداً کوچک نگه میداریم: نمونههای ساده و تألیفی، دادهٔ بایتیِ مصنوعی، و سقفِ هشت بایت. بایتها به ولتاژ، وضعیتِ واقعیِ حسگر یا پیامِ شبکه تفسیر نمیشوند. این قرارداد ادامهٔ بحثِ اعتبارسنجیِ زمانِ اجرای دادهٔ حسگر است، با پرسشی متفاوت: این بار فقط مقدارهای ورودی را نمیسنجیم؛ فاصلهٔ میانِ حضورِ یک رابط و رفتارِ واقعیِ فراخوانی را آشکار میکنیم.
۲. runtime_checkable چه چیزی را واقعاً میسنجد؟

تزئینگرِ @runtime_checkable اجازه میدهد این پروتکل در آرگومانِ دومِ isinstance به کار رود. مطابقِ مرجعِ رسمیِ runtime_checkable، این بررسی امضا و نوعِ اعضا را اعتبارسنجی نمیکند. نمونهای میتواند عضوِ موردنیاز را داشته باشد، اما متدش آرگومانِ اجباریِ دیگری بخواهد یا به جای بایت، رشته برگرداند. بنابراین نتیجهٔ درستِ آزمون، یک مشاهدهٔ ساختاری است؛ مجوزِ حذفِ بررسیِ خروجی نیست.
جدولِ مثال این فاصله را ملموس میکند. آداپتورِ سالم، خروجیِ خالی، خروجیِ نُهبایتی، خروجیِ رشتهای و متدی با آرگومانِ اجباری، همگی در نمونههای انتخابشده آزمونِ پروتکل را میگذرانند. حتی عضوِ عددیِ read = 0 در اجرای ثبتشده عضو تشخیص داده میشود، درحالیکه قابلِ فراخوانی نیست. در مقابل، عضوِ غایب و عضوِ صریحاً برابرِ None در همین نمونههای ساده رد میشوند. این مشاهدات را به همهٔ اشیای پویا یا کلاسهای ممکن تعمیم نمیدهیم.
مبنای تاریخیِ انتخابیبودنِ بررسیِ زمانِ اجرا در بخشِ مربوطِ PEP 544 آمده است. متنِ طرح، جای اجرای تحلیلِ ایستای امروز را نمیگیرد. برای خواندنِ جزئیات، مرجعِ جاریِ نسخهٔ ۳٫۱۴٫۸ بررسی شده؛ اجرای ما با CPython ۳٫۱۴٫۵ است. به همین دلیل نتیجهٔ جدول را با نسخهٔ اجرا نگه میداریم و دربارهٔ تمامِ نسخهها، جزئیاتِ کش یا سرعتِ این بررسی ادعایی نداریم.
۳. امضا و خروجی به بررسی جدا نیاز دارند

تابعِ read_payload قراردادِ اجراییِ خودمان را روشن میکند. ابتدا عضو را میگیرد و قابلِ فراخوانیبودن را میسنجد؛ سپس فقط یک بار آن را بدون آرگومان صدا میزند. بعد از بازگشت، نوعِ دقیقِ خروجی باید bytes باشد و طولش از هشت بیشتر نشود. مقدارِ پذیرفتهشده همان شیءِ برگشتی است؛ تبدیلِ پنهان، رمزگذاریِ متن یا کوتاهکردنِ خروجی انجام نمیشود. این سیاستِ محدود، تعریفِ عمومیِ همهٔ رابطهای خواندن نیست.
خودِ callable هم موفقیتِ یک فراخوانیِ مشخص را تضمین نمیکند. متدی که آرگومانِ اجباری دارد، قابلِ فراخوانی است؛ اما فراخوانیِ بدون آرگومان خطای نوع میدهد و بدنهاش اجرا نمیشود. پس شمارندهٔ صفرِ بدنه به معنیِ نبودنِ تلاشِ فراخوانی نیست. همچنین، پس از یک خواندنِ واقعی ممکن است خروجیِ نامناسب دیده شود؛ این بررسی اثرِ انجامشدهٔ متد را برنمیگرداند.
خروجیِ خالی را معتبر میدانیم، چون طولِ صفر داخلِ دامنه است. رشته، آرایهٔ بایتیِ تغییرپذیر، نمای حافظه و زیرکلاسِ بایت را طبقِ سیاستِ نوعِ دقیق رد میکنیم؛ حتی اگر بتوان آنها را تبدیل کرد. مانندِ قراردادِ بیتهای شناختهشدهٔ IntFlag، «قابلِ نمایشبودن» با «مجازبودن در مرزِ برنامه» یکی نیست. پذیرشِ بایتِ خالی هم هنوز معنای کاربردی، کاملبودنِ داده یا صحتِ محتوا را تعیین نمیکند.
۴. کد کامل و جدولِ موردانتظارِ مستقل

مرجعِ آزمون، پانزده ردیفِ موردانتظارِ نوشتهشده پیش از مشاهدهٔ اجرای برنامه است. در ستونِ انتظار، از خودِ پروتکل یا الگوریتمِ تشخیصِ عضو استفاده نمیکنیم: برای هر نمونه، عضویت، نتیجه، نمایشِ شانزدهشانزدهیِ بایتها و تعدادِ ورود به بدنه را صریح تعیین کردهایم. استقلالِ این مرجع محدود به همین انتظارهای نمونهای است؛ نه اثباتِ کاملِ سامانهٔ نوعِ پایتون و نه اعتبارسنجیِ همهٔ آداپتورها. کد و خروجیِ کامل برای بازبینی در ادامه آمدهاند.
"""Bounded plain-adapter experiment; Protocol membership is not a behavior test."""
from typing import Protocol, runtime_checkable
import json
import platform
@runtime_checkable
class Readable(Protocol):
def read(self) -> bytes:
...
class StaticReadable(Protocol):
def read(self) -> bytes:
...
MAX_PAYLOAD = 8 # Authored policy, not a Protocol or device limit.
def read_payload(adapter):
"""One call on authored plain fixtures; no arbitrary-plugin safety promise."""
method = getattr(adapter, 'read', None)
if not callable(method):
raise TypeError('read must be callable')
payload = method()
if type(payload) is not bytes:
raise TypeError('read must return exact bytes, without coercion')
if len(payload) > MAX_PAYLOAD:
raise ValueError('payload exceeds the authored eight-byte limit')
return payload
class PlainReader:
# Deliberately not a subclass of either protocol.
def __init__(self, payload):
self.payload = payload
self.calls = 0
def read(self) -> bytes:
self.calls += 1
# Some test fixtures intentionally violate this annotation.
return self.payload
class NeedsArgument:
def __init__(self):
self.calls = 0
def read(self, channel) -> bytes:
self.calls += 1
return b'X'
class NumberMember:
read = 0
class NoneMember:
read = None
class MissingMember:
pass
class BytesChild(bytes):
pass
class AdapterFailure(Exception):
pass
class RaisingReader:
def __init__(self, error):
self.error = error
self.calls = 0
def read(self) -> bytes:
self.calls += 1
raise self.error
class ChangingReader:
def __init__(self):
self.calls = 0
def read(self) -> bytes:
self.calls += 1
return b'A' if self.calls == 1 else 'later wrong type'
def observe(name, adapter):
member = isinstance(adapter, Readable)
before = getattr(adapter, 'calls', 0)
try:
payload = read_payload(adapter)
status, payload_hex = 'ok', payload.hex()
except (TypeError, ValueError) as error:
status, payload_hex = type(error).__name__, None
return {'name': name, 'protocol_member': member, 'status': status,
'payload_hex': payload_hex,
'method_body_calls': getattr(adapter, 'calls', 0) - before}
def rejects(call, exception):
try:
call()
except exception:
return True
return False
def run_example():
fixtures = [
('good', PlainReader(b'AB')), ('empty', PlainReader(b'')),
('limit8', PlainReader(bytes(range(8)))),
('too_long9', PlainReader(bytes(range(9)))),
('text', PlainReader('AB')), ('mutable', PlainReader(bytearray(b'AB'))),
('view', PlainReader(memoryview(b'AB'))),
('bytes_subclass', PlainReader(BytesChild(b'AB'))),
('none_result', PlainReader(None)), ('bool_result', PlainReader(True)),
('integer_result', PlainReader(2)), ('needs_argument', NeedsArgument()),
('number_member', NumberMember()), ('none_member', NoneMember()),
('missing_member', MissingMember()),
]
# Independently authored literal outcomes, no Protocol/introspection algorithm.
expected_rows = [
('good', True, 'ok', '4142', 1),
('empty', True, 'ok', '', 1),
('limit8', True, 'ok', '0001020304050607', 1),
('too_long9', True, 'ValueError', None, 1),
('text', True, 'TypeError', None, 1),
('mutable', True, 'TypeError', None, 1),
('view', True, 'TypeError', None, 1),
('bytes_subclass', True, 'TypeError', None, 1),
('none_result', True, 'TypeError', None, 1),
('bool_result', True, 'TypeError', None, 1),
('integer_result', True, 'TypeError', None, 1),
('needs_argument', True, 'TypeError', None, 0),
('number_member', True, 'TypeError', None, 0),
('none_member', False, 'TypeError', None, 0),
('missing_member', False, 'TypeError', None, 0),
]
actual = [observe(name, adapter) for name, adapter in fixtures]
expected = [dict(zip(('name', 'protocol_member', 'status', 'payload_hex',
'method_body_calls'), row)) for row in expected_rows]
payloads = [bytes(range(n)) for n in range(9)]
valid_rows = [observe('length' + str(n), PlainReader(p))
for n, p in enumerate(payloads)]
invalid_returns = ('AB', bytearray(b'AB'), memoryview(b'AB'),
BytesChild(b'AB'), None, True, 2, (65, 66))
signature = NeedsArgument()
shape_probe = PlainReader(b'AB')
membership_results = [isinstance(shape_probe, Readable) for _ in range(3)]
before_validation = shape_probe.calls
first_result = read_payload(shape_probe)
identity_payload = bytes(range(8))
identity_reader = PlainReader(identity_payload)
same_payload = read_payload(identity_reader)
sentinel = AdapterFailure('authored diagnostic failure')
failing = RaisingReader(sentinel)
same_exception = False
try:
read_payload(failing)
except AdapterFailure as error:
same_exception = error is sentinel
changing = ChangingReader()
first_change = read_payload(changing)
second_change_rejected = rejects(lambda: read_payload(changing), TypeError)
checks = {
'fifteen_plain_fixtures_match_independent_literal_outcome_table_one_family': actual == expected,
'every_authored_length_zero_through_eight_accepts_exact_payload_bytes': all(
row['status'] == 'ok' and row['payload_hex'] == payloads[n].hex()
and row['method_body_calls'] == 1 for n, row in enumerate(valid_rows)),
'nine_byte_payload_rejected_after_one_read_without_silent_truncation': (
actual[3]['status'] == 'ValueError' and actual[3]['method_body_calls'] == 1
and fixtures[3][1].payload == bytes(range(9))),
'eight_wrong_return_types_rejected_without_coercion_one_family': all(
rejects(lambda p=p: read_payload(PlainReader(p)), TypeError)
for p in invalid_returns),
'callable_with_wrong_signature_fails_before_method_body_runs': (
isinstance(signature, Readable) and callable(signature.read)
and rejects(lambda: read_payload(signature), TypeError) and signature.calls == 0),
'noncallable_nonNone_member_can_pass_protocol_but_fails_own_boundary': (
isinstance(NumberMember(), Readable) and not callable(NumberMember().read)
and rejects(lambda: read_payload(NumberMember()), TypeError)),
'None_and_missing_method_fail_membership_and_boundary_for_plain_fixtures': all(
not isinstance(obj, Readable) and rejects(lambda obj=obj: read_payload(obj), TypeError)
for obj in (NoneMember(), MissingMember())),
'implicit_structural_membership_does_not_require_protocol_inheritance': (
Readable not in PlainReader.__mro__ and isinstance(PlainReader(b'A'), Readable)),
'repeated_plain_membership_checks_do_not_read_then_validation_reads_once': (
membership_results == [True, True, True] and before_validation == 0
and first_result == b'AB' and shape_probe.calls == 1),
'successful_boundary_returns_original_bytes_object_not_an_encoded_copy': (
same_payload is identity_payload and identity_reader.calls == 1),
'adapter_exception_is_propagated_unchanged_after_one_call_without_retry': (
same_exception and failing.calls == 1 and isinstance(failing, Readable)),
'one_success_does_not_certify_later_reads': (
first_change == b'A' and second_change_rejected and changing.calls == 2
and isinstance(changing, Readable)),
'nonruntime_protocol_cannot_be_used_as_an_isinstance_gate': rejects(
lambda: isinstance(PlainReader(b'A'), StaticReadable), TypeError),
}
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(),
'actual_stdlib_runtime_checkable_Protocol': True,
'static_type_checker_executed': False, 'hardware_or_network_measured': False,
'scope': 'authored plain fixtures only; exact bytes length0..8 after one read',
'independent_oracle': 'fifteen literal expected rows; no Protocol or introspection in oracle',
'observed_fixture_table': actual,
'call_observations': {'membership_checks': len(membership_results),
'reads_before_validation': before_validation,
'reads_after_one_validation': shape_probe.calls,
'wrong_signature_body_calls': signature.calls,
'propagated_error_read_calls': failing.calls},
'case_counts_not_extra_groups': {'literal_rows': len(fixtures),
'accepted_length_cases': len(payloads),
'wrong_return_type_cases': len(invalid_returns)},
'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), indent=2, allow_nan=False))
{
"python": "3.14.5",
"implementation": "CPython",
"actual_stdlib_runtime_checkable_Protocol": true,
"static_type_checker_executed": false,
"hardware_or_network_measured": false,
"scope": "authored plain fixtures only; exact bytes length0..8 after one read",
"independent_oracle": "fifteen literal expected rows; no Protocol or introspection in oracle",
"observed_fixture_table": [
{
"name": "good",
"protocol_member": true,
"status": "ok",
"payload_hex": "4142",
"method_body_calls": 1
},
{
"name": "empty",
"protocol_member": true,
"status": "ok",
"payload_hex": "",
"method_body_calls": 1
},
{
"name": "limit8",
"protocol_member": true,
"status": "ok",
"payload_hex": "0001020304050607",
"method_body_calls": 1
},
{
"name": "too_long9",
"protocol_member": true,
"status": "ValueError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "text",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "mutable",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "view",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "bytes_subclass",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "none_result",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "bool_result",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "integer_result",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 1
},
{
"name": "needs_argument",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 0
},
{
"name": "number_member",
"protocol_member": true,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 0
},
{
"name": "none_member",
"protocol_member": false,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 0
},
{
"name": "missing_member",
"protocol_member": false,
"status": "TypeError",
"payload_hex": null,
"method_body_calls": 0
}
],
"call_observations": {
"membership_checks": 3,
"reads_before_validation": 0,
"reads_after_one_validation": 1,
"wrong_signature_body_calls": 0,
"propagated_error_read_calls": 1
},
"case_counts_not_extra_groups": {
"literal_rows": 15,
"accepted_length_cases": 9,
"wrong_return_type_cases": 8
},
"checks": {
"fifteen_plain_fixtures_match_independent_literal_outcome_table_one_family": true,
"every_authored_length_zero_through_eight_accepts_exact_payload_bytes": true,
"nine_byte_payload_rejected_after_one_read_without_silent_truncation": true,
"eight_wrong_return_types_rejected_without_coercion_one_family": true,
"callable_with_wrong_signature_fails_before_method_body_runs": true,
"noncallable_nonNone_member_can_pass_protocol_but_fails_own_boundary": true,
"None_and_missing_method_fail_membership_and_boundary_for_plain_fixtures": true,
"implicit_structural_membership_does_not_require_protocol_inheritance": true,
"repeated_plain_membership_checks_do_not_read_then_validation_reads_once": true,
"successful_boundary_returns_original_bytes_object_not_an_encoded_copy": true,
"adapter_exception_is_propagated_unchanged_after_one_call_without_retry": true,
"one_success_does_not_certify_later_reads": true,
"nonruntime_protocol_cannot_be_used_as_an_isinstance_gate": true
}
}
اجرای ثبتشده سیزده خانوادهٔ بررسیِ موفق دارد. پانزده ردیفِ جدول یک خانوادهاند؛ طولهای صفر تا هشت و هشت نوعِ خروجیِ نامجاز نیز هر کدام یک خانواده محسوب میشوند. برای آداپتورِ سالم، سه بررسیِ عضویت هیچ خواندنی انجام نمیدهد؛ یک اعتبارسنجی، یک بار بدنه را اجرا میکند. خروجیِ نُهبایتی پس از همان یک خواندن رد میشود. این اعداد مشاهدهٔ نمونههای محلیاند و نه اندازهگیریِ دستگاه، آزمونِ بار یا معیارِ کارایی.
دو شاهدِ دیگر دامنهٔ نتیجه را محدود میکنند. خطای تألیفیِ آداپتور همان شیء استثنا، بدون تکرارِ خودکارِ خواندن، به فراخواننده میرسد. آداپتورِ تغییرکننده نیز بارِ اول بایت و بارِ دوم رشته میدهد؛ موفقیتِ نخست، خواندنهای آینده را تضمین نمیکند. نمونهها ویژگی، توصیفگر و دسترسیِ پویای سفارشی ندارند. گرفتنِ عضو و اجرای کدِ شیء ناشناس میتواند رفتارِ دیگری داشته باشد؛ این تابع جعبهٔ ایمنیِ افزونه، احرازِ هویت یا مرزِ امنیتی نیست.
۵. چهار پرسش دربارهٔ مرزِ زمان اجرا

پرسش اول: چرا آداپتورِ دارای خروجیِ رشتهای عضوِ پروتکل است، اما پذیرفته نمیشود؟ پاسخ: عضوِ موردنیاز را دارد و بررسیِ ساختاری امضای بازگشت را نمیآزماید. تابعِ ما پس از یک خواندن، نوعِ واقعی را میبیند و خطای نوع میدهد. اضافهکردنِ توضیحِ بازگشت به متد، رشته را به بایت تبدیل نمیکند. راهِ درست، اصلاحِ پیادهسازی یا تعریفِ تبدیلِ آگاهانه در یک قراردادِ جداگانه است.
پرسش دوم: آیا بررسیِ callable خطای امضا را حذف میکند؟ پاسخ: خیر؛ متدِ نیازمندِ آرگومان هم قابلِ فراخوانی است. در نمونهٔ ما فراخوانیِ بدون آرگومان پیش از ورود به بدنه شکست میخورد، پس شمارنده صفر میماند. نمیتوان نتیجه گرفت که دستگاهی خوانده شده یا باید خودکار دوباره تلاش کرد. مدیریتِ خطا باید با رفتارِ واقعیِ همان آداپتور هماهنگ باشد، نه فقط با نتیجهٔ یک بررسیِ ظاهری.
پرسش سوم: با مقدارِ خالی و خروجیِ بلند چه کنیم؟ پاسخ: بایتِ خالی در این قرارداد پذیرفته میشود؛ نبودنِ عضو رد میشود. هشت بایت مجاز و نُه بایت نامجاز است. خروجیِ بلند را قطع نمیکنیم، چون حذفِ بیصدا میتواند معنای داده را تغییر دهد. اگر کاربرد به حداقلِ طول یا ساختارِ داخلی نیاز دارد، باید شرطِ مستقل و آزمونهای متناظر به قراردادِ مصرفکننده افزوده شود.
پرسش چهارم: یک بار موفقیت چه چیزی را ثابت میکند؟ پاسخ: فقط همان بازگشت، از همان فراخوانی، نوع و طولِ مجاز داشته است. دربارهٔ فراخوانیِ بعد، درستیِ فیزیکیِ اندازهگیری، اجرای بررسیکنندهٔ ایستا یا امنیتِ آداپتور چیزی ثابت نمیشود. برای نگهداریِ رابط، انتظارهای مستقل، نسخهٔ اجرا و سیاستِ خطا را کنارِ امضای نوع حفظ میکنیم؛ هر تغییرِ قرارداد هم باید با نمونهٔ مخالف و پاسخِ روشن همراه باشد.
منابع و مطالعهٔ بیشتر
- Python3.14.8؛ بررسیِ ساختاریِ Protocol در زمان اجرا
- PEP544؛ طراحیِ بررسیِ انتخابیِ زمان اجرا
- Python3.14.8؛ حدِ تضمینِ callable
بررسی منابع: ۲۷ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

