رفتن به محتوا

راهبردهای مدیریت خطا: انتخاب رویکرد مناسب

مقایسه استثناها، کدهای خطا، و std::expected — یاد بگیرید هر رویکرد چه زمانی مناسب است و چگونه خطاها را در سازنده‌ها، مخرب‌ها و کد چندنخی مدیریت کنید.

راه‌حل واحدی وجود ندارد

‎C++‎ چندین سازوکار مدیریت خطا ارائه می‌دهد که هر کدام مصالحه‌های متفاوتی دارند. انتخاب مناسب به دامنه شما بستگی دارد:

استثناها برای خطاهایی بهترین هستند که نادر و قابل مدیریت محلی نیستند. در مسیر موفقیت هزینه صفر دارند اما هنگام پرتاب هزینه‌بر هستند (هزاران نانوثانیه). آن‌ها به‌طور خودکار در قاب‌های فراخوانی منتشر می‌شوند.

کدهای خطا (مقادیر بازگشتی، errno، std::error_code) برای خطاهایی بهترین هستند که مورد انتظار و پرتکرار هستند. هزینه‌ای کوچک و ثابت در هر فراخوانی دارند (فراخواننده باید مقدار بازگشتی را بررسی کند). آن‌ها استاندارد در رابط‌های C و کد سطح هسته هستند.

std::expected (‎C++‎23) بهترین ویژگی‌های هر دو را ترکیب می‌کند: فراخواننده را مجبور به پذیرش خطا می‌کند (مانند کدهای خطا) اما نوع خطای غنی حمل می‌کند (مانند استثناها) و از زنجیره‌سازی مونادیک پشتیبانی می‌کند. به سرعت در حال تبدیل شدن به رویکرد ترجیحی در کد جدید ‎C++‎ است.

بینش کلیدی: این‌ها فلسفه‌های رقیب نیستند — ابزارهایی برای موقعیت‌های مختلف هستند، و کد تولیدی اغلب از هر سه استفاده می‌کند.

مقایسه: استثناها در برابر کدهای خطا در برابر std::expected

| معیار | استثناها | کدهای خطا | std::expected |
|---|---|---|---|
| هزینه در موفقیت | صفر (مبتنی بر جدول) | شاخه + بررسی | حداقل (مقدار ذخیره می‌شود) |
| هزینه در خطا | بالا (بازگشایی) | مانند موفقیت | مانند موفقیت |
| قابل نادیده‌گیری؟ | خیر (برنامه خاتمه می‌یابد) | بله (باگ‌های خاموش) | دشوارتر (باید به .value() دسترسی داشت) |
| سازنده‌ها | تنها گزینه | نمی‌تواند مقدار برگرداند | نمی‌تواند مقدار برگرداند |
| مخرب‌ها | نباید پرتاب کند | مشکلی ندارد | مشکلی ندارد |
| ترکیب‌پذیری | محدود (try/catch) | زنجیره‌های if دستی | مونادیک (and_then، transform) |
| اندازه باینری | بزرگ‌تر (جداول بازگشایی) | حداقل | حداقل |
| امن برای بلادرنگ؟ | خیر (زمان نامحدود) | بله | بله |

مدیریت خطا در سازنده‌ها

سازنده‌ها نمی‌توانند کدهای خطا برگردانند — مقدار بازگشتی ندارند. استثناها تنها سازوکار تمیز برای اعلام شکست ساخت هستند. پرتاب استثنا به این معنی است که شیء هرگز به‌طور کامل ساخته نشده، بنابراین مخرب آن هرگز فراخوانی نمی‌شود (اما مخرب‌های زیرشیءها و پایه‌های قبلاً ساخته‌شده فراخوانی می‌شوند).

constructor_error.cpp
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>

class ConfigFile {
public:
    explicit ConfigFile(const std::string& path)
        : path_(path), stream_(path) {
        if (!stream_.is_open()) {
            throw std::runtime_error(
                "Cannot open config file: " + path);
        }
        // Parse the file — if this throws, stream_ is
        // already constructed and its destructor will
        // close the file automatically (RAII)
        parse();
    }

    std::string get(const std::string& key) const {
        auto it = data_.find(key);
        if (it == data_.end()) {
            throw std::out_of_range("Key not found: " + key);
        }
        return it->second;
    }

private:
    void parse() {
        std::string line;
        while (std::getline(stream_, line)) {
            auto pos = line.find('=');
            if (pos != std::string::npos) {
                data_[line.substr(0, pos)] = line.substr(pos + 1);
            }
        }
    }

    std::string path_;
    std::ifstream stream_;  // RAII: closed by destructor
    std::map<std::string, std::string> data_;
};

// Usage: the object is fully valid or does not exist
int main() {
    try {
        ConfigFile cfg("/etc/myapp.conf");
        std::cout << cfg.get("port") << '\n';
    } catch (const std::exception& e) {
        std::cerr << e.what() << '\n';
    }
}

std::error_code برای خطاهای سطح سیستم

std::error_code سازوکاری سبک‌وزن و بدون پرتاب برای خطاهای سطح سیستم فراهم می‌کند. کتابخانه مثال عالی‌ای است — اغلب توابع یک بار‌گذاری اضافی (overload) دارند که پارامتر std::error_code& می‌گیرد به جای پرتاب استثنا.

error_code.cpp
#include <filesystem>
#include <iostream>
#include <system_error>

namespace fs = std::filesystem;

// Throwing version — suitable for unexpected errors
void copy_file_throwing(const fs::path& src, const fs::path& dst) {
    fs::copy_file(src, dst);  // throws fs::filesystem_error on failure
}

// Non-throwing version — suitable for expected failures
bool copy_file_safe(const fs::path& src, const fs::path& dst) {
    std::error_code ec;
    fs::copy_file(src, dst, ec);  // sets ec instead of throwing
    if (ec) {
        std::cerr << "Copy failed: " << ec.message()
                  << " (" << ec.category().name()
                  << ':' << ec.value() << ")\n";
        return false;
    }
    return true;
}

int main() {
    // The error_code version is preferable in loops where
    // failure is expected (e.g., batch file processing)
    for (const auto& entry : fs::directory_iterator(".")) {
        std::error_code ec;
        auto sz = fs::file_size(entry.path(), ec);
        if (!ec) {
            std::cout << entry.path().filename()
                      << ": " << sz << " bytes\n";
        }
    }
}
دام

مخرب‌ها در ‎C++‎11 و بعد از آن به‌طور ضمنی noexcept هستند. اگر مخرب استثنا پرتاب کند، std::terminate() فراخوانی می‌شود. حتی اگر مخرب را صراحتاً noexcept(false) علامت‌گذاری کنید، پرتاب در طول بازگشایی پشته (وقتی استثنای دیگری فعال است) باعث فراخوانی std::terminate() می‌شود.

  • ثبت و سرکوب: اگر عملیات پاک‌سازی شکست خورد، خطا را ثبت کنید و ادامه دهید
  • یک متد close() جداگانه فراهم کنید: به فراخواننده‌ها اجازه دهید صراحتاً خطاهای پاک‌سازی را قبل از تخریب مدیریت کنند
  • منابع را طوری طراحی کنید که پاک‌سازی نتواند شکست بخورد: به عنوان مثال، بستن یک توصیف‌گر فایل (file descriptor) همیشه در سطح سیستم‌عامل موفق می‌شود
  • هرگز از مخرب یک تابع پرتاب‌کننده فراخوانی نکنید بدون اینکه آن را در try/catch بپیچید

مدیریت خطا در کد چندنخی

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

1. از std::future و std::async استفاده کنید: استثناهای پرتاب‌شده در وظیفه ناهمگام گرفته شده و هنگام فراخوانی .get() روی future مجدداً پرتاب می‌شوند.
2. از std::exception_ptr با std::current_exception() و std::rethrow_exception() برای گرفتن و انتقال دستی استثناها استفاده کنید.
3. از std::promise::set_exception() برای ارسال استثنا از نخ تولیدکننده به مصرف‌کننده استفاده کنید.

برای کد همزمان با توان عملیاتی بالا، کدهای خطا یا std::expected اغلب ترجیح داده می‌شوند زیرا هزینه زیرساخت استثنا در هر نخ را حذف می‌کنند و استدلال درباره آن‌ها در مرزهای نخ ساده‌تر است.

بهترین شیوه

سازوکار مدیریت خطا را با موقعیت تطبیق دهید.

  • از استثناها استفاده کنید برای خطاهایی که نادر هستند، نمی‌توانند به‌صورت محلی مدیریت شوند، و باید در پشته منتشر شوند (مثلاً فایل یافت نشد، پایگاه داده از کار افتاده)
  • از کدهای خطا استفاده کنید برای عملیات‌های پرتکرار که شکست عادی و مورد انتظار است (مثلاً عدم یافتن در حافظه نهان، مسدود شدن I/O غیرمسدود)
  • از std::expected استفاده کنید برای کد جدید که می‌خواهید مدیریت خطای صریح با انواع خطای غنی و ترکیب مونادیک داشته باشید
  • از استثناها در سازنده‌ها استفاده کنید — آن‌ها تنها راه اعلام شکست قبل از وجود شیء هستند
  • هرگز در مخرب‌ها استثنا پرتاب نکنید — خطا را ثبت و سرکوب کنید
نکات کلیدی
  • استثناها، کدهای خطا، و std::expected ابزارهای مکمل هستند — نه فلسفه‌های رقیب
  • سازنده‌ها باید برای اعلام شکست از استثناها استفاده کنند؛ کدهای خطا گزینه نیستند
  • مخرب‌ها هرگز نباید استثنا پرتاب کنند — آن‌ها از ‎C++‎11 به‌طور ضمنی noexcept هستند
  • استثناها از مرزهای نخ عبور نمی‌کنند — از std::future یا std::exception_ptr برای انتقال آن‌ها استفاده کنید
  • std::error_code برای خطاهای سطح سیستم که رابط بدون پرتاب می‌خواهید ایده‌آل است

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

(15 امتیاز)

1. چرا استثناها سازوکار ترجیحی مدیریت خطا در سازنده‌ها هستند؟

2. اگر در طول بازگشایی پشته از یک استثنای دیگر، در مخرب استثنا پرتاب شود، چه اتفاقی می‌افتد؟

3. چگونه می‌توان استثناها را بین نخ‌ها منتقل کرد؟