رفتن به محتوا

درک و اجتناب از رفتار تعریف‌نشده

بیاموزید رفتار تعریف‌نشده (UB) چیست، چرا وجود دارد، چگونه کامپایلرها از آن برای بهینه‌سازی بهره‌برداری می‌کنند، و چگونه آن را با ابزارهای بهداشتی (sanitizer) و هشدارهای کامپایلر تشخیص دهید.

رفتار تعریف‌نشده چیست و چرا وجود دارد؟

رفتار تعریف‌نشده (UB) به این معنی است که استاندارد ‎C++‎ هیچ الزامی بر رفتار برنامه قرار نمی‌دهد. برنامه ممکن است خراب شود، نتایج اشتباه تولید کند، به‌ظاهر درست کار کند، یا هارد دیسک شما را فرمت کند — همه نتایج «معتبر» هستند.

رفتار تعریف‌نشده وجود دارد زیرا بهینه‌سازی تهاجمی را ممکن می‌سازد. وقتی کامپایلر می‌داند که موقعیت‌های خاصی «نمی‌توانند اتفاق بیفتند» (زیرا رفتار تعریف‌نشده خواهند بود)، می‌تواند با حذف بررسی‌های آن موقعیت‌ها کد سریع‌تری تولید کند. به عنوان مثال:

- سرریز اعداد صحیح علامت‌دار رفتار تعریف‌نشده است و به کامپایلر اجازه می‌دهد فرض کند x + 1 > x همیشه درست است و بهینه‌سازی‌های حلقه را فعال کند.
- ارجاع‌زدایی اشاره‌گر تهی رفتار تعریف‌نشده است و به کامپایلر اجازه می‌دهد بررسی‌های تهی را پس از ارجاع‌زدایی حذف کند.
- دسترسی به حافظه خارج از محدوده رفتار تعریف‌نشده است و به کامپایلر اجازه می‌دهد بررسی‌های محدوده را حذف کند و دسترسی آرایه را به اندازه حافظه خام سریع کند.

معامله اساسی: ‎C++‎ به برنامه‌نویس اعتماد می‌کند که از رفتار تعریف‌نشده اجتناب کند در ازای حداکثر عملکرد. وقتی این اعتماد نقض شود، تمام تضمین‌ها از بین می‌رود.

منابع رایج رفتار تعریف‌نشده

در اینجا رایج‌ترین منابع رفتار تعریف‌نشده در کد ‎C++‎ واقعی آمده است. هر برنامه‌نویس ‎C++‎ با این‌ها مواجه می‌شود.

common_ub.cpp
#include <vector>
#include <iostream>
#include <cstdint>

// 1. SIGNED INTEGER OVERFLOW
void signed_overflow() {
    int x = INT32_MAX;
    int y = x + 1;        // UB! Signed overflow is undefined
    // unsigned overflow is well-defined (wraps around)
    unsigned u = UINT32_MAX;
    unsigned v = u + 1;   // OK: v == 0 (well-defined wrap)
}

// 2. NULL POINTER DEREFERENCE
void null_deref(int* ptr) {
    int val = *ptr;       // UB if ptr is null
    if (ptr == nullptr) { // Compiler may remove this check!
        // The compiler reasons: "ptr was already dereferenced above,
        // which would be UB if null. Since UB cannot happen in a
        // correct program, ptr must be non-null. This check is dead code."
        std::cout << "null!\n";
    }
}

// 3. OUT-OF-BOUNDS ACCESS
void out_of_bounds() {
    int arr[5] = {1, 2, 3, 4, 5};
    int bad = arr[10];    // UB! No bounds checking on raw arrays
    // std::vector::operator[] is also UB if out of bounds
    // Use .at() for bounds-checked access (throws std::out_of_range)
}

// 4. USE-AFTER-FREE / DANGLING REFERENCES
int& dangling_reference() {
    int local = 42;
    return local;         // UB! Reference to destroyed local
}

// 5. DATA RACES
// int shared = 0;
// Thread 1: shared++;    // UB if concurrent with Thread 2
// Thread 2: shared++;    // UB! Data race on non-atomic variable

مشکل «سفر در زمان»

یکی از ضدشهودی‌ترین جنبه‌های رفتار تعریف‌نشده این است که می‌تواند بر کد قبل از نقطه UB تأثیر بگذارد. کامپایلرها بر اساس فرض اینکه UB هرگز در برنامه صحیح رخ نمی‌دهد بهینه‌سازی می‌کنند، و این بهینه‌سازی‌ها می‌توانند کدی را که قبل از UB ظاهر می‌شود بازترتیب یا حذف کنند.

در نظر بگیرید:

bool checked = false;
void process(int* p) {
    int value = *p;        // UB if p is null
    checked = true;        // This line is BEFORE the use of 'value'
    if (p == nullptr) {    // Compiler: "p was dereferenced, so it's not null"
        abort();           // Compiler: "This is dead code, remove it"
    }
    use(value);
}

کامپایلر ممکن است checked = true را قبل از ارجاع‌زدایی تنظیم کند (بازترتیب برای خط لوله)، و سپس بررسی تهی را به‌طور کامل حذف کند. نتیجه: checked حتی وقتی p تهی است روی true تنظیم می‌شود، و ارجاع‌زدایی تهی بدون abort ادامه می‌یابد. UB «در زمان به عقب سفر کرد» تا پرچم checked را خراب کند.

این باگ کامپایلر نیست — نتیجه مستقیم آزادی‌های بهینه‌سازی است که UB اعطا می‌کند.

تشخیص UB با ابزارهای بهداشتی (Sanitizer)

ابزارهای بهداشتی (sanitizer) ابزارهای ارائه‌شده توسط کامپایلر هستند که کد شما را برای تشخیص UB در زمان اجرا ابزاربندی (instrument) می‌کنند. آن‌ها بررسی‌های زمان اجرا اضافه می‌کنند که UB را با پیام‌های خطای واضح می‌گیرند. همیشه مجموعه تست خود را با sanitizer فعال اجرا کنید.

sanitizers.sh
# AddressSanitizer (ASan) — detects:
#   - Buffer overflow (stack, heap, global)
#   - Use-after-free, use-after-return
#   - Double-free, memory leaks
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp -o program

# UndefinedBehaviorSanitizer (UBSan) — detects:
#   - Signed integer overflow
#   - Null pointer dereference
#   - Out-of-bounds array access
#   - Misaligned memory access
#   - Type punning violations
g++ -fsanitize=undefined -fno-omit-frame-pointer -g program.cpp -o program

# ThreadSanitizer (TSan) — detects:
#   - Data races between threads
#   - Lock order inversions (potential deadlocks)
g++ -fsanitize=thread -g program.cpp -o program

# MemorySanitizer (MSan) — detects:
#   - Reads of uninitialized memory
#   (Clang-only, not available in GCC)
clang++ -fsanitize=memory -fno-omit-frame-pointer -g program.cpp -o program

# Combine ASan + UBSan (common in CI pipelines)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
    -Wall -Wextra -Werror program.cpp -o program

# CMake integration
# In CMakeLists.txt:
# target_compile_options(myapp PRIVATE -fsanitize=address,undefined)
# target_link_options(myapp PRIVATE -fsanitize=address,undefined)

هشدارهای کامپایلر به‌عنوان شبکه ایمنی

کامپایلرهای مدرن می‌توانند بسیاری از موقعیت‌های بالقوه UB را در زمان کامپایل تشخیص دهند. هشدارها را به‌عنوان خطا تلقی کنید (-Werror) در CI تا تضمین شود هیچ هشداری نادیده گرفته نشود.

warnings.sh
# Recommended warning flags for all C++ projects
g++ -std=c++23 \
    -Wall           # Enable most warnings \
    -Wextra         # Enable additional warnings \
    -Wpedantic      # Strict ISO C++ compliance \
    -Werror         # Treat warnings as errors \
    -Wconversion    # Warn on implicit narrowing conversions \
    -Wshadow        # Warn when a variable shadows another \
    -Wnon-virtual-dtor  # Warn on classes with virtual functions but non-virtual dtor \
    -Wold-style-cast    # Warn on C-style casts \
    -Wcast-align    # Warn on potentially misaligned casts \
    -Wunused        # Warn on unused variables/functions \
    -Woverloaded-virtual  # Warn when a derived class hides a base virtual function \
    -Wnull-dereference   # Warn on potential null dereferences (GCC) \
    -Wdouble-promotion   # Warn when float is implicitly promoted to double \
    -Wformat=2      # Warn on printf/scanf format string issues \
    program.cpp -o program

# For Clang, add:
#   -Weverything    # Enable ALL warnings (then selectively disable)
#   -Wno-c++98-compat  # Disable C++98 compatibility warnings
بهترین شیوه

این رهنمودها را دنبال کنید تا UB را در پایگاه کد خود به حداقل برسانید.

  • تمام هشدارها را فعال و به‌عنوان خطا تلقی کنید: -Wall -Wextra -Wpedantic -Werror
  • sanitizer را در CI اجرا کنید: ASan + UBSan را برای هر اجرای تست ترکیب کنید؛ TSan برای کد چندنخی
  • از .at() به جای [] برای دسترسی با بررسی محدوده به ظروف در طول توسعه استفاده کنید
  • از اشاره‌گرهای هوشمند (unique_ptr، shared_ptr) به جای new/delete خام برای حذف use-after-free استفاده کنید
  • از std::atomic برای متغیرهای مشترک بین نخ‌ها استفاده کنید — هرگز از انواع ساده برای داده‌های بین‌نخی استفاده نکنید
  • انواع بدون علامت را ترجیح دهید برای دست‌کاری بیت اما انواع علامت‌دار برای حساب — حساب بدون علامت پیچ‌خوردگی (wrap) دارد که می‌تواند باگ‌های ظریف در محدوده‌های حلقه ایجاد کند
دام

خطرناک‌ترین UB کدی است که در تست کاملاً درست کار می‌کند اما در تولید شکست می‌خورد. دلیلش اینجاست.

  • UB می‌تواند سال‌ها «کار کند» و سپس هنگام ارتقای کامپایلر، تغییر سطح بهینه‌سازی، یا اجرا روی CPU متفاوت خراب شود
  • سرریز علامت‌دار در حلقه‌ها: for (int i = 0; i < n; i++) — اگر n برابر INT_MAX باشد، i++ سرریز می‌کند. کامپایلر ممکن است فرض کند حلقه خاتمه می‌یابد و بر اساس آن بهینه‌سازی کند
  • خواندن متغیرهای مقداردهی‌نشده: مقدار «تصادفی» نیست — کامپایلر ممکن است متغیر را به‌طور کامل بهینه‌سازی حذف کند یا هر بیتی که اتفاقاً در ثبات (register) است را منتشر کند
  • نقض نام‌گذاری سخت‌گیرانه (strict aliasing): تبدیل int* به float* و ارجاع‌زدایی رفتار تعریف‌نشده است (مگر از طریق char* یا std::byte*). از std::bit_cast (‎C++‎20) برای تبدیل نوع استفاده کنید
  • std::string_view که بیش از رشته عمر می‌کند: یک نمای معلق برمی‌گرداند که تا زمان استفاده مجدد حافظه به‌ظاهر کار می‌کند
نکات کلیدی
  • رفتار تعریف‌نشده برای امکان‌پذیر ساختن بهینه‌سازی‌های تهاجمی کامپایلر وجود دارد — ‎C++‎ ایمنی را با عملکرد معامله می‌کند
  • UB می‌تواند «در زمان سفر کند» — کامپایلر ممکن است کدی که قبل از نقطه UB است را بازترتیب یا حذف کند
  • رایج‌ترین منابع UB عبارتند از سرریز علامت‌دار، ارجاع‌زدایی تهی، دسترسی خارج از محدوده، use-after-free، و مسابقه داده
  • ابزارهای بهداشتی (ASan، UBSan، TSan، MSan) رفتار تعریف‌نشده را در زمان اجرا تشخیص می‌دهند — آن‌ها را در هر خط لوله CI اجرا کنید
  • هشدارهای کامپایلر (-Wall -Wextra -Werror) بسیاری از موقعیت‌های بالقوه UB را در زمان کامپایل می‌گیرند
  • UB‌ای که امروز «کار می‌کند» می‌تواند با کامپایلر جدید، سطح بهینه‌سازی، یا پلتفرم جدید بی‌سروصدا خراب شود

آزمون — دانش خود را بسنجید

(15 امتیاز)

1. چرا سرریز اعداد صحیح علامت‌دار در ‎C++‎ رفتار تعریف‌نشده است؟

2. AddressSanitizer (ASan) چه چیزی را تشخیص می‌دهد؟

3. مشکل «سفر در زمان» در رفتار تعریف‌نشده چیست؟