رفتن به محتوا

تضمین‌های ایمنی استثنا

سه سطح ایمنی استثنا را بیاموزید — پایه، قوی، و بدون پرتاب — و اینکه چگونه RAII و کپی‌وجابه‌جایی آن‌ها را فراهم می‌کنند.

سه تضمین

ایمنی استثنا درباره جلوگیری از استثناها نیست — درباره تعریف آن چیزی است که کد شما هنگام وقوع یک استثنا تضمین می‌کند. هر تابعی که می‌نویسید به‌طور ضمنی یکی از این سطوح را ارائه می‌دهد:

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

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

۳. تضمین پایه (حداقل قابل‌قبول): اگر تابع استثنا پرتاب کند، برنامه در وضعیتی معتبر اما نامشخص است. هیچ منبعی نشت نمی‌کند، تمام ناوردایی‌ها (invariant) حفظ می‌شوند، اما وضعیت دقیق ممکن است با قبل متفاوت باشد.

همچنین سطح چهارمی وجود دارد که غیرقابل‌قبول است: بدون تضمین — که در آن یک استثنا می‌تواند برنامه را با منابع نشتی در وضعیت خراب بگذارد. کد تولیدی هرگز نباید کمتر از تضمین پایه ارائه دهد.

RAII: پایه و اساس ایمنی استثنا

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

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

class DatabaseConnection {
public:
    explicit DatabaseConnection(const std::string& conn_str)
        : conn_str_(conn_str), connected_(false) {
        // Simulate connection
        if (conn_str.empty()) {
            throw std::invalid_argument("empty connection string");
        }
        connected_ = true;
        std::cout << "Connected to: " << conn_str_ << '\n';
    }

    ~DatabaseConnection() {
        if (connected_) {
            // Cleanup never throws
            std::cout << "Disconnected from: " << conn_str_ << '\n';
            connected_ = false;
        }
    }

    void execute(const std::string& query) {
        if (!connected_) throw std::runtime_error("not connected");
        // Even if this function throws, the destructor
        // will still close the connection during unwinding
        std::cout << "Executing: " << query << '\n';
    }

    // Non-copyable, movable
    DatabaseConnection(const DatabaseConnection&) = delete;
    DatabaseConnection& operator=(const DatabaseConnection&) = delete;
    DatabaseConnection(DatabaseConnection&& other) noexcept
        : conn_str_(std::move(other.conn_str_)),
          connected_(other.connected_) {
        other.connected_ = false;
    }

private:
    std::string conn_str_;
    bool connected_;
};

void process_data() {
    DatabaseConnection db("postgres://localhost/mydb");
    db.execute("SELECT * FROM users");
    // If execute() throws, ~DatabaseConnection() still runs
    // — the connection is always properly closed
}

کپی‌وجابه‌جایی برای تضمین قوی

اصطلاح کپی‌وجابه‌جایی (copy-and-swap) تضمین استثنای قوی را برای عملگرهای انتساب فراهم می‌کند. ایده: ابتدا یک کپی بسازید (که ممکن است استثنا پرتاب کند)، سپس با کپی جابه‌جا کنید (که هرگز استثنا پرتاب نمی‌کند). اگر کپی استثنا پرتاب کند، شیء اصلی دست‌نخورده باقی می‌ماند.

copy_and_swap.cpp
#include <algorithm>
#include <utility>
#include <cstddef>

class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols)
        : rows_(rows), cols_(cols),
          data_(new double[rows * cols]{}) {}

    // Copy constructor — may throw (allocates memory)
    Matrix(const Matrix& other)
        : rows_(other.rows_), cols_(other.cols_),
          data_(new double[other.rows_ * other.cols_]) {
        std::copy(other.data_,
                  other.data_ + rows_ * cols_,
                  data_);
    }

    // Move constructor — never throws
    Matrix(Matrix&& other) noexcept
        : rows_(other.rows_), cols_(other.cols_),
          data_(other.data_) {
        other.data_ = nullptr;
        other.rows_ = other.cols_ = 0;
    }

    ~Matrix() { delete[] data_; }

    // No-throw swap — the key ingredient
    friend void swap(Matrix& a, Matrix& b) noexcept {
        using std::swap;
        swap(a.rows_, b.rows_);
        swap(a.cols_, b.cols_);
        swap(a.data_, b.data_);
    }

    // Copy-and-swap assignment: STRONG guarantee
    // Takes parameter by value (invokes copy/move ctor)
    Matrix& operator=(Matrix other) noexcept {
        swap(*this, other);
        return *this;
        // 'other' is destroyed here, freeing old data
    }

private:
    std::size_t rows_, cols_;
    double* data_;
};

noexcept و عملیات‌های انتقال

STL به‌شدت به noexcept برای سازنده‌های انتقال (move constructor) وابسته است. std::vector::push_back را در نظر بگیرید: وقتی بردار باید تخصیص مجدد کند، نیاز دارد عناصر را از بافر قدیمی به بافر جدید منتقل کند.

- اگر سازنده انتقال عنصر noexcept باشد، بردار عناصر را جابه‌جا (move) می‌کند (سریع).
- اگر سازنده انتقال ممکن است استثنا پرتاب کند، بردار به جای آن عناصر را کپی می‌کند (کند، اما امن — اگر کپی استثنا پرتاب کند، بافر اصلی هنوز سالم است).

به همین دلیل علامت‌گذاری سازنده‌های انتقال با noexcept فقط یک عمل خوب نیست — می‌تواند ۱۰ تا ۱۰۰ برابر تفاوت عملکرد برای انواعی که در ظروف ذخیره می‌شوند ایجاد کند.

می‌توانید در زمان کامپایل با std::is_nothrow_move_constructible_v بررسی کنید. STL از این ویژگی (trait) به‌صورت داخلی برای تصمیم‌گیری درباره راهبرد خود استفاده می‌کند.

تشخیص سطح تضمین

هنگام نوشتن یک تابع، به این فکر کنید که کدام تضمین را ارائه می‌دهد. در اینجا نمونه‌ای از هر سه سطح در یک کلاس واحد آمده است.

guarantee_levels.cpp
#include <vector>
#include <string>
#include <algorithm>
#include <stdexcept>

class UserRegistry {
public:
    // NO-THROW: simple getter, can never fail
    std::size_t size() const noexcept {
        return users_.size();
    }

    // STRONG GUARANTEE: either the user is added or nothing changes
    void add_user(const std::string& name) {
        if (name.empty()) {
            throw std::invalid_argument("name cannot be empty");
        }
        // If push_back throws (e.g., bad_alloc during reallocation),
        // vector guarantees the original data is intact.
        users_.push_back(name);
    }

    // BASIC GUARANTEE: if an exception occurs partway through,
    // some users may have been processed and some may not.
    // But the object is still in a valid state (no leaks, no corruption).
    void normalize_all() {
        for (auto& user : users_) {
            // std::transform could theoretically throw
            // (e.g., if user string allocation fails during tolower)
            std::transform(user.begin(), user.end(), user.begin(),
                          [](unsigned char c) { return std::tolower(c); });
        }
    }

private:
    std::vector<std::string> users_;
};
بهترین شیوه

این رهنمودها را دنبال کنید تا کد ایمن در برابر استثنا بنویسید.

  • از RAII همه‌جا استفاده کنید: هرگز منابع خام نگه ندارید — آن‌ها را در اشاره‌گرهای هوشمند یا پوشش‌دهنده‌های RAII بپیچید
  • سازنده‌های انتقال و swap را noexcept علامت‌گذاری کنید: این بهینه‌سازی‌های STL را فعال و تضمین بدون پرتاب را ارائه می‌کند
  • تمام کارهایی که ممکن است استثنا پرتاب کنند را قبل از تغییر وضعیت انجام دهید: ابتدا تخصیص، محاسبه و اعتبارسنجی کنید — سپس با عملیات بدون پرتاب مانند swap ثبت کنید
  • اصطلاح کپی‌وجابه‌جایی را ترجیح دهید برای عملگرهای انتساب تا تضمین قوی را با حداقل تلاش به دست آورید
  • مخرب‌ها هرگز نباید استثنا پرتاب کنند: آن‌ها در ‎C++‎11 و بعد از آن به‌طور ضمنی noexcept هستند
دام

این الگوها تضمین‌های ایمنی استثنا را نقض می‌کنند و به باگ‌های ظریف منجر می‌شوند.

  • تغییر وضعیت قبل از تکمیل تمام عملیات‌های پرتاب‌کننده: اگر مرحله ۲ از ۳ استثنا پرتاب کند، مرحله ۱ قبلاً شیء را تغییر داده است
  • فراموش کردن اینکه new می‌تواند std::bad_alloc پرتاب کند: هر تخصیص پویا یک نقطه بالقوه پرتاب است
  • سازنده‌های انتقال غیر noexcept: STL بی‌سروصدا به کپی‌کردن بازمی‌گردد و عملکرد را از بین می‌برد
  • گرفتن استثناها بیش از حد گسترده: catch(...) بدون پرتاب مجدد، مشکل واقعی را پنهان می‌کند
نکات کلیدی
  • سه تضمین عبارتند از: بدون پرتاب (همیشه موفق)، قوی (ثبت یا بازگشت)، و پایه (وضعیت معتبر اما نامشخص)
  • RAII بنیاد است — مخرب‌ها در طول بازگشایی اجرا می‌شوند و تضمین می‌کنند که منابع همیشه آزاد شوند
  • کپی‌وجابه‌جایی تضمین قوی را ارائه می‌دهد: ابتدا تمام کارهای پرتاب‌کننده را انجام دهید، سپس swap کنید (که noexcept است)
  • سازنده‌های انتقال noexcept حیاتی هستند — بدون آن‌ها، std::vector هنگام تخصیص مجدد به کپی‌کردن بازمی‌گردد
  • هر تابع باید حداقل تضمین پایه را هدف بگیرد؛ بدون پرتاب در صورت امکان

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

(20 امتیاز)

1. تضمین استثنای قوی چه چیزی را وعده می‌دهد؟

2. چرا `std::vector` به جای جابه‌جایی (move) عناصر را کپی می‌کند وقتی سازنده انتقال `noexcept` نیست؟

3. در اصطلاح کپی‌وجابه‌جایی، چرا عملگر انتساب پارامتر خود را با مقدار می‌گیرد؟