رفتن به محتوا

استثناها: throw، try، catch

درک سازوکار استثنای ‎C++‎ — بازگشایی پشته (stack unwinding)، سلسله‌مراتب استثناهای استاندارد، استثناهای سفارشی، و زمان ترجیح استثناها بر کدهای خطا.

چرا استثناها وجود دارند

پیش از استثناها، کد C خطاها را با بازگرداندن مقادیر خاص (مانند -1 یا NULL) اعلام می‌کرد. این روش یک نقص اساسی داشت: فراخواننده‌ها می‌توانستند خطا را بی‌سروصدا نادیده بگیرند. استثناها این مشکل را با وادار کردن برنامه به مدیریت خطا یا خاتمه دادن به آن حل می‌کنند. آن‌ها همچنین محل تشخیص خطا (در عمق کد کتابخانه) را از محل مدیریت خطا (در کد برنامه) جدا می‌کنند که ممکن است چندین قاب فراخوانی (call frame) از هم فاصله داشته باشند.

استثناهای ‎C++‎ از مدل «هزینه صفر در مسیر موفقیت» در اغلب کامپایلرهای مدرن پیروی می‌کنند (با استفاده از بازگشایی مبتنی بر جدول). وقتی هیچ استثنایی پرتاب نمی‌شود، هیچ سربار زمان اجرایی وجود ندارد — نه بررسی‌های مخفی if، نه پیش‌بینی‌های شاخه از دست رفته. هزینه تنها زمانی پرداخت می‌شود که استثنایی واقعاً پرتاب شود، و همین امر استثناها را برای شرایط خطای استثنایی (نادر) ایده‌آل می‌سازد.

throw، try و catch

سازوکار اصلی ساده است: یک شیء را برای اعلام خطا throw کنید، کد پرخطر را در یک بلوک try قرار دهید، و انواع استثنای منطبق را catch کنید.

divide.cpp
#include <iostream>
#include <stdexcept>
#include <string>

double divide(double numerator, double denominator) {
    if (denominator == 0.0) {
        throw std::invalid_argument("denominator cannot be zero");
    }
    return numerator / denominator;
}

int main() {
    try {
        double result = divide(10.0, 0.0);
        std::cout << "Result: " << result << '\n';
    } catch (const std::invalid_argument& e) {
        std::cerr << "Invalid argument: " << e.what() << '\n';
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
    } catch (...) {
        std::cerr << "Unknown error occurred\n";
    }
}

بازگشایی پشته به‌صورت جزئی

هنگامی که یک استثنا پرتاب می‌شود، زمان اجرا بازگشایی پشته (stack unwinding) را انجام می‌دهد: در پشته فراخوانی به عقب حرکت می‌کند و تمام متغیرهای محلی هر قاب را با فراخوانی مخرب‌هایشان (destructor) از بین می‌برد. به همین دلیل RAII بسیار حیاتی است — اگر منابع را در پوشش‌دهنده‌های RAII نگه دارید، آن‌ها در طول بازگشایی به‌طور خودکار آزاد می‌شوند.

فرآیند بازگشایی به شرح زیر عمل می‌کند:

1. عبارت throw شیء استثنا را ایجاد می‌کند (معمولاً در ناحیه‌ای خاص از حافظه، نه در پشته).
2. زمان اجرا در تابع فعلی به دنبال گرداننده catch منطبق می‌گردد.
3. اگر یافت نشد، از تابع فعلی خارج می‌شود و تمام اشیاء محلی را به ترتیب معکوس ساخت از بین می‌برد.
4. مراحل ۲ تا ۳ را در بالای پشته فراخوانی تکرار می‌کند.
5. اگر هیچ گرداننده‌ای در هیچ قابی یافت نشد، std::terminate() فراخوانی می‌شود.

قاعده کلیدی: با مقدار پرتاب کنید، با ارجاع const بگیرید. پرتاب با مقدار از اشاره‌گرهای معلق جلوگیری می‌کند. گرفتن با ارجاع const از برش (slicing) — که در آن استثنای مشتق‌شده به شیء کلاس پایه کپی می‌شود و داده‌های مشتق‌شده از دست می‌رود — و کپی‌های غیرضروری جلوگیری می‌کند.

سلسله‌مراتب استثناهای استاندارد

کتابخانه استاندارد یک سلسله‌مراتب با ریشه std::exception ارائه می‌دهد. درک آن به شما کمک می‌کند نوع صحیح را پرتاب و در سطح مناسب بگیرید.

hierarchy.cpp
// Standard exception hierarchy (simplified)
//
// std::exception
// ├── std::logic_error          (programmer mistakes - precondition violations)
// │   ├── std::invalid_argument
// │   ├── std::domain_error
// │   ├── std::length_error
// │   └── std::out_of_range
// ├── std::runtime_error        (errors detectable only at runtime)
// │   ├── std::range_error
// │   ├── std::overflow_error
// │   ├── std::underflow_error
// │   └── std::system_error     (OS-level errors with error_code)
// └── std::bad_alloc            (memory allocation failure)
//     std::bad_cast              (failed dynamic_cast)
//     std::bad_typeid            (typeid on null pointer)

#include <stdexcept>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3};
    try {
        // .at() throws std::out_of_range, unlike operator[] which is UB
        int val = v.at(10);
    } catch (const std::out_of_range& e) {
        std::cerr << "Out of range: " << e.what() << '\n';
    }
}

کلاس‌های استثنای سفارشی

از std::exception یا یکی از زیرکلاس‌هایش مشتق بگیرید تا استثناهای مختص دامنه خود بسازید. همیشه what() را بازنویسی (override) کنید تا پیامی معنادار ارائه دهد.

custom_exception.cpp
#include <stdexcept>
#include <string>
#include <iostream>

class FileParseError : public std::runtime_error {
public:
    FileParseError(const std::string& filename, int line, const std::string& msg)
        : std::runtime_error(
              "Parse error in '" + filename + "' at line " +
              std::to_string(line) + ": " + msg),
          filename_(filename),
          line_(line) {}

    const std::string& filename() const noexcept { return filename_; }
    int line() const noexcept { return line_; }

private:
    std::string filename_;
    int line_;
};

void parse_config(const std::string& filename) {
    // Simulate a parse failure at line 42
    throw FileParseError(filename, 42, "unexpected token '@@'");
}

int main() {
    try {
        parse_config("app.conf");
    } catch (const FileParseError& e) {
        std::cerr << e.what() << '\n';
        std::cerr << "  File: " << e.filename()
                  << ", Line: " << e.line() << '\n';
    }
}

noexcept و مشخصه‌های استثنا

مشخص‌کننده noexcept به کامپایلر (و خوانندگان انسانی) می‌گوید که یک تابع هرگز استثنا پرتاب نخواهد کرد. اگر یک تابع noexcept استثنا پرتاب کند، std::terminate() فوراً فراخوانی می‌شود — بازگشایی پشته انجام نمی‌شود.

چرا noexcept مهم است؟

- عملکرد: کامپایلر می‌تواند جداول بازگشایی را برای توابع noexcept حذف کند و کد فشرده‌تری تولید کند.
- بهینه‌سازی STL: ظروفی مانند std::vector فقط در صورتی عناصر را در طول تخصیص مجدد جابه‌جا (move) می‌کنند که سازنده انتقال noexcept باشد. در غیر این صورت، برای حفظ تضمین استثنای قوی به کپی‌کردن بازمی‌گردند. این می‌تواند تفاوت عملکرد چشمگیری ایجاد کند.
- مستندسازی: این یک قرارداد با فراخواننده‌ها است مبنی بر اینکه تابع نمی‌تواند شکست بخورد.

از noexcept استفاده کنید برای: سازنده‌های انتقال، عملگرهای انتساب انتقال، توابع swap، مخرب‌ها (به‌طور ضمنی noexcept)، توابع دسترسی ساده، و عملگرهای مقایسه.

مشخصه قدیمی throw() (و throw(type-list)) در ‎C++‎11 منسوخ و در ‎C++‎20 حذف شده است. همیشه به جای آن از noexcept استفاده کنید.

دام

هرگز در مخرب‌ها (destructor) استثنا پرتاب نکنید. در طول بازگشایی پشته از یک استثنای دیگر، پرتاب استثنای دوم باعث فراخوانی std::terminate() می‌شود. اگر یک مخرب باید عملیاتی انجام دهد که ممکن است شکست بخورد، استثنا را در داخل مخرب بگیرید و در همان‌جا مدیریت کنید (مثلاً ثبت و سرکوب).

  • گرفتن با مقدار استثناهای مشتق‌شده را برش (slice) می‌دهد — همیشه با const& بگیرید
  • پرتاب اشاره‌گرها (مثلاً throw new std::runtime_error(...)) به نشت حافظه منجر می‌شود — با مقدار پرتاب کنید
  • گرفتن ... در ابتدا تمام گرداننده‌های خاص زیر آن را پنهان می‌کند — آن را در آخر قرار دهید
  • استثنا در مخرب‌ها در طول بازگشایی پشته باعث فراخوانی std::terminate() می‌شود
  • استفاده از استثناها برای جریان کنترل (مسیرهای غیرخطایی) مدل هزینه صفر را نقض می‌کند و به عملکرد آسیب می‌زند
نکات کلیدی
  • با مقدار پرتاب کنید، با ارجاع const بگیرید تا از برش و نشت جلوگیری شود
  • بازگشایی پشته اشیاء محلی را به ترتیب معکوس ساخت از بین می‌برد — RAII پاک‌سازی را تضمین می‌کند
  • استثناهای ‎C++‎ در مسیر موفقیت هزینه صفر دارند (بازگشایی مبتنی بر جدول)، فقط هنگام پرتاب هزینه‌بر هستند
  • noexcept بهینه‌سازی‌های کامپایلر را فعال می‌کند و برای عملیات‌های انتقال مورد استفاده STL حیاتی است
  • استثناهای سفارشی را از std::runtime_error یا std::logic_error مشتق کنید و what() را بازنویسی کنید

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

(15 امتیاز)

1. چرا باید استثناها را با `const&` بگیرید نه با مقدار؟

2. اگر تابعی که `noexcept` علامت‌گذاری شده استثنا پرتاب کند، چه اتفاقی می‌افتد؟

3. در طول بازگشایی پشته، چه اتفاقی برای متغیرهای محلی در هر قاب پشته می‌افتد؟