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

۱. پیش از کلاس، قرارداد نوع و واحد را بنویسیم

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

رکورد ما سه فیلد دارد: sequence شمارهٔ ترتیب، time_us زمان قراردادی از یک مبدأ ساختگی بر حسب میکروثانیه و voltage_mv ولتاژ بر حسب میلی‌ولت است. شماره باید عدد صحیح مثبت و زمان عدد صحیح نامنفی باشد. برای ولتاژ، فقط int یا float متناهی در بازهٔ بستهٔ منفی پنج هزار تا مثبت پنج هزار پذیرفته می‌شود. این بازه را برای تمرین انتخاب کرده‌ایم؛ مشخصات فنی هیچ محصولی نیست.

عدد صحیح یک و مقدار float با نمایش ۱٫۰ از نظر مقدار برابرند، ولی قرارداد شمارهٔ رکورد در این مثال، فقط نوع دقیق int را می‌پذیرد. همچنین رشتهٔ «۱۰۱۰» به عدد تبدیل نمی‌شود. تبدیل ورودی متنی باید مرحله‌ای جدا با قاعدهٔ روشن باشد. نام voltage_mv نیز خودش واحد را تبدیل نمی‌کند؛ وارد کردن عددی بر حسب ولت در این فیلد، حتی اگر از بررسی بازه عبور کند، معنای داده را خراب خواهد کرد.

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

۲. راهنمای نوع با اعتبارسنجی زمان اجرا فرق دارد

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

راهنمای typing پایتون توضیح می‌دهد که زمان اجرای پایتون، راهنماهای نوع را خودکار اعمال نمی‌کند. در مثال ما، AnnotatedReading با وجود راهنماهای نوع، رشتهٔ first را در شماره و رشتهٔ 1010 را در ولتاژ نگه می‌دارد. زمان منفی آن نیز از نوع int است؛ پس حتی درست بودن نوع، به تنهایی شرط دامنهٔ مقدار را پوشش نمی‌دهد. در این درس ابزار بررسی ایستای نوع اجرا نشده است.

در کلاس SensorReading، قواعد را صریح در __post_init__ می‌نویسیم. مطابق مرجع dataclasses، سازندهٔ تولیدشده این روش را فراخوانی می‌کند. اینجا خودمان آن سازنده را جایگزین نکرده‌ایم. رد ورودی ناشی از شرط‌های همین کد است؛ صرف اضافه کردن نام dataclass یا نوشتن علامت نوع، چنین بررسی‌هایی تولید نمی‌کند. تفاوت دو کلاس در آزمون، همین مسئولیت اجرایی را قابل مشاهده می‌سازد.

مقدار منطقی باید جدا مورد توجه باشد. مرجع bool رابطهٔ آن با int را شرح می‌دهد. سیاست سخت‌گیرانهٔ ما با بررسی نوع دقیق، True و False را در هر سه فیلد عددی رد می‌کند؛ زیرکلاس‌های عددی نیز در این قرارداد پذیرفته نیستند. این تصمیم برای همین مرز ورودی است و به معنی نامناسب بودن تمام نوع‌های عددی دیگر برای همهٔ پروژه‌ها نیست.

۳. کد کامل با پانزده بررسی مستقل از سخت‌افزار

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

فایل را با نام sensor_dataclass.py ذخیره و دستور python sensor_dataclass.py را اجرا کنید. کد با پایتون ۳٫۱۴٫۵ و کتابخانهٔ استاندارد اجرا شده است. سه قرائت معتبر برای حفظ فیلدها ساخته می‌شوند و مجموعهٔ جداگانه‌ای از ورودی‌های نامعتبر، مسیرهای رد را می‌سنجد. هیچ فایل داده، ارتباط شبکه یا وابستگی بیرونی برای اجرای مثال لازم نیست.

"""Author-defined contracts for synthetic readings, not physical sensor validation."""
from dataclasses import dataclass, asdict, replace, FrozenInstanceError
import json
import math
import platform

LOWER_MV, UPPER_MV = -5000, 5000


@dataclass(frozen=True)
class AnnotatedReading:
    sequence: int
    time_us: int
    voltage_mv: int | float


@dataclass(frozen=True)
class SensorReading:
    sequence: int
    time_us: int
    voltage_mv: int | float

    def __post_init__(self):
        if type(self.sequence) is not int:
            raise TypeError('sequence must be an exact int, not bool')
        if self.sequence <= 0:
            raise ValueError('sequence must be positive')
        if type(self.time_us) is not int:
            raise TypeError('time_us must be an exact int, not bool')
        if self.time_us < 0:
            raise ValueError('time_us must be nonnegative')
        if type(self.voltage_mv) not in (int, float):
            raise TypeError('voltage_mv must be an exact int or float, not bool')
        # Avoid converting arbitrarily large integers to float just to test finiteness.
        if type(self.voltage_mv) is float and not math.isfinite(self.voltage_mv):
            raise ValueError('voltage_mv must be finite')
        if not LOWER_MV <= self.voltage_mv <= UPPER_MV:
            raise ValueError('voltage_mv outside the authored contract')


def rejects(call, exception):
    try:
        call()
    except exception:
        return True
    return False


def run_example():
    unchecked = AnnotatedReading('first', -1, '1010')
    readings = (SensorReading(1, 0, 1010), SensorReading(2, 1000, 1010.5),
                SensorReading(3, 2000, 0.0))
    first = readings[0]
    later_time = SensorReading(1, 1000, 1010)
    earlier_time = SensorReading(2, 0, 1020)
    checks = {
        'annotations_alone_do_not_validate': type(unchecked.sequence) is str and type(unchecked.voltage_mv) is str,
        'valid_fixture_fields_preserved': [asdict(r) for r in readings] == [
            {'sequence': 1, 'time_us': 0, 'voltage_mv': 1010},
            {'sequence': 2, 'time_us': 1000, 'voltage_mv': 1010.5},
            {'sequence': 3, 'time_us': 2000, 'voltage_mv': 0.0}],
        'int_and_float_voltage_accepted': type(readings[0].voltage_mv) is int and type(readings[1].voltage_mv) is float,
        'inclusive_voltage_boundaries_accepted': SensorReading(1, 0, -5000).voltage_mv == -5000 and SensorReading(2, 0, 5000.0).voltage_mv == 5000,
        'bool_rejected_in_all_numeric_fields': all(rejects(call, TypeError) for call in (
            lambda: SensorReading(True, 0, 1000),
            lambda: SensorReading(1, False, 1000),
            lambda: SensorReading(1, 0, True))),
        'float_type_sequence_rejected': rejects(lambda: SensorReading(1.0, 0, 1000), TypeError),
        'zero_sequence_rejected': rejects(lambda: SensorReading(0, 0, 1000), ValueError),
        'float_type_time_rejected': rejects(lambda: SensorReading(1, 0.0, 1000), TypeError),
        'negative_time_rejected': rejects(lambda: SensorReading(1, -1, 1000), ValueError),
        'numeric_string_voltage_rejected': rejects(lambda: SensorReading(1, 0, '1010'), TypeError),
        'all_nonfinite_float_voltages_rejected': all(rejects(lambda v=v: SensorReading(1, 0, v), ValueError)
                                                  for v in (float('nan'), float('inf'), -float('inf'))),
        'out_of_range_and_huge_integer_rejected': all(rejects(lambda v=v: SensorReading(1, 0, v), ValueError)
                                                    for v in (-5001, 5001, 10**1000)),
        'ordinary_frozen_assignment_rejected': rejects(lambda: setattr(first, 'voltage_mv', 0), FrozenInstanceError),
        'replacement_revalidates_constructor': rejects(lambda: replace(first, voltage_mv=6000), ValueError),
        'record_validation_does_not_enforce_stream_time_order': earlier_time.time_us < later_time.time_us,
    }
    assert all(checks.values()), checks
    return {'python': platform.python_version(), 'model': 'synthetic_record_constructor_contract',
            'units': {'time_us': 'microsecond', 'voltage_mv': 'millivolt', 'sequence': 'count'},
            'authored_voltage_interval_mv': [LOWER_MV, UPPER_MV],
            'synthetic_readings': [asdict(r) for r in readings],
            'unchecked_annotated_reading': asdict(unchecked),
            'separate_runtime_typechecker_used': False, 'hardware_measurement': False,
            'checks': checks}


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

برای float، تابع isfinite نامتناهی و NaN را کنار می‌گذارد؛ صفر مجاز است. عدد صحیح در پایتون به تبدیل اجباری به float نیاز ندارد، بنابراین شرط متناهی بودن را فقط در شاخهٔ float می‌سنجیم و سپس بازه را کنترل می‌کنیم. این ترتیب اجازه می‌دهد عدد صحیح بسیار بزرگ نیز با خطای قراردادیِ دامنه رد شود و تبدیل غیرضروری به float ایجاد نکند.

پانزده بررسی نام‌دار، پذیرفته شدن ورودی‌های معتبر، حفظ فیلدها، مرزهای بستهٔ ولتاژ، رد مقدار منطقی، نوع نامناسب شماره و زمان، زمان منفی، رشتهٔ عددی، مقادیر نامتناهی و بیرون بازه را پوشش می‌دهند. رفتار frozen، ساخت نسخهٔ جایگزین و محدودیت ترتیب جریان هم جدا بررسی می‌شوند. موفقیت این مجموعه فقط همین نمونه‌ها و مسیرهای انتخابی را تأیید می‌کند؛ گواهی کامل درستی برنامه یا پوشش همهٔ ورودی‌ها نیست.

۴. خروجی اجرا و محدودیت frozen

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

در خروجی، کلاس دارای راهنمای نوعِ تنها هنوز مقادیر نامعتبر را نگه داشته است، در حالی که ساخت SensorReading برای نمونه‌های ممنوع استثنا می‌دهد. سه رکورد معتبر، همان شماره، زمان و ولتاژ ورودی را حفظ کرده‌اند؛ نه واحد تبدیل شده و نه عدد صحیح ولتاژ به float تبدیل شده است. مرزهای منفی پنج هزار و مثبت پنج هزار پذیرفته می‌شوند، اما مقدار بیرون بازه رد می‌شود.

{
  "python": "3.14.5",
  "model": "synthetic_record_constructor_contract",
  "units": {
    "time_us": "microsecond",
    "voltage_mv": "millivolt",
    "sequence": "count"
  },
  "authored_voltage_interval_mv": [
    -5000,
    5000
  ],
  "synthetic_readings": [
    {
      "sequence": 1,
      "time_us": 0,
      "voltage_mv": 1010
    },
    {
      "sequence": 2,
      "time_us": 1000,
      "voltage_mv": 1010.5
    },
    {
      "sequence": 3,
      "time_us": 2000,
      "voltage_mv": 0.0
    }
  ],
  "unchecked_annotated_reading": {
    "sequence": "first",
    "time_us": -1,
    "voltage_mv": "1010"
  },
  "separate_runtime_typechecker_used": false,
  "hardware_measurement": false,
  "checks": {
    "annotations_alone_do_not_validate": true,
    "valid_fixture_fields_preserved": true,
    "int_and_float_voltage_accepted": true,
    "inclusive_voltage_boundaries_accepted": true,
    "bool_rejected_in_all_numeric_fields": true,
    "float_type_sequence_rejected": true,
    "zero_sequence_rejected": true,
    "float_type_time_rejected": true,
    "negative_time_rejected": true,
    "numeric_string_voltage_rejected": true,
    "all_nonfinite_float_voltages_rejected": true,
    "out_of_range_and_huge_integer_rejected": true,
    "ordinary_frozen_assignment_rejected": true,
    "replacement_revalidates_constructor": true,
    "record_validation_does_not_enforce_stream_time_order": true
  }
}

انتخاب frozen=True باعث می‌شود انتساب عادی به فیلد، در این کلاس خطای FrozenInstanceError ایجاد کند. این رفتار را در کد واقعاً می‌سنجیم. آن را تضمین تغییرناپذیری عمیق یا مرز امنیتی معرفی نمی‌کنیم. همچنین frozen ورودی نامعتبر اولیه را اصلاح نمی‌کند؛ کلاس AnnotatedReading همین گزینه را دارد و باز هم بدون قواعد اضافی، رشتهٔ نامناسب را هنگام ساخت پذیرفته است.

برای ساخت رکوردی با مقدار جدید، replace را نیز آزمایش می‌کنیم. راهنمای replace فراخوانی سازنده و پس‌پردازش را بیان می‌کند؛ در نتیجه، جایگزینی ولتاژ با ۶۰۰۰ در این کلاس رد می‌شود. با این حال، دو رکوردِ معتبرِ جدا می‌توانند زمان کاهشی داشته باشند. برای اتصال آن‌ها به پردازش ناهمگام، مقالهٔ صف محدود asyncio و پس‌فشار دادهٔ حسگر مسئولیت متفاوتی را بررسی می‌کند.

۵. تمرین‌های پاسخ‌دار و مرز اتصال به سامانه

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

تمرین اول: آیا نوشتن int کنار زمان، مقدار منفی را رد می‌کند؟ پاسخ: خیر؛ نوع و دامنهٔ مقدار دو قرارداد جدا هستند و شرط زمان نامنفی را خودمان اجرا کرده‌ایم. تمرین دوم: چرا مقدار ۱٫۰ برای شماره رد می‌شود؟ پاسخ: مقدار عددی آن صحیح است، اما نوعش float است و قرارداد فعلی فقط نوع دقیق int را می‌پذیرد.

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

هنگام گسترش این مثال، ابتدا قرارداد ورودی، تبدیل واحد و قواعد میان رکوردها را مشخص کنید؛ سپس آزمون مناسب همان مرز را اضافه کنید. از پذیرش یک عدد نمی‌توان درستی اندازه‌گیری فیزیکی را نتیجه گرفت. منابع این آموزش در ۲۴ مهر ۱۴۰۵ خوانده شده‌اند؛ مستندات نسخهٔ ۳٫۱۴٫۸ و اجرای واقعی ۳٫۱۴٫۵ جدا ثبت شده‌اند. متن یک آموزش پایهٔ تألیفی است و خبر پژوهشی تازه یا گزارش عملکرد محصول محسوب نمی‌شود.

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

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