انتقل إلى المحتوى

الاستثناءات: رمي، حاول، قبض

فهم آلية استثناء C++ - تفكيك المكدس، والتسلسل الهرمي للاستثناءات القياسية، والاستثناءات المخصصة، ومتى تفضل الاستثناءات على رموز الخطأ.

لماذا توجد الاستثناءات

قبل الاستثناءات، أشار كود C إلى الأخطاء عن طريق إرجاع قيم خاصة (مثل-1أوNULL). وكان لهذا عيب فادح: يمكن للمتصلين تجاهل الخطأ بصمت. تحل الاستثناءات هذه المشكلة عن طريق إجبار البرنامج على معالجة الخطأ أو إنهائه. كما أنها تفصل موقع اكتشاف الأخطاء (متعمقًا في كود المكتبة) عن موقع معالجة الأخطاء (في كود التطبيق)، والذي يمكن أن يكون متباعدًا بعدة إطارات استدعاء.

تتبع استثناءات C++ نموذج "التكلفة الصفرية على مسار النجاح" في معظم المترجمين الحديثين (باستخدام الفك المستند إلى الجدول). عندما لا يتم طرح أي استثناء، لا توجد لا توجد أعباء إضافية في وقت التشغيل — لا توجد عمليات تحقق مخفية لـif، ولا توجد تنبؤات فرعية مفقودة. يتم دفع التكلفة فقط عندما يتم طرح استثناء فعليًا، مما يجعل الاستثناءات مثالية لحالات الخطأ الاستثنائية (النادرة).

ارمي وحاول وأمسك

الآلية الأساسية واضحة ومباشرة: رمي كائن للإشارة إلى وجود خطأ، وتغليف التعليمات البرمجية الخطرة في كتلة 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";
    }
}

تفكيك المكدس بالتفصيل

عند طرح استثناء، ينفذ وقت التشغيل فك المكدس: فهو يسير احتياطيًا إلى مكدس الاستدعاءات، ويدمر كافة المتغيرات المحلية في كل إطار عن طريق استدعاء أدوات التدمير الخاصة بها. وهذا هو سبب أهمية RAII (تهيئة الحصول على الموارد) - إذا كنت تحتفظ بالموارد في أغلفة RAII، فسيتم تحريرها تلقائيًا أثناء التفكيك.

تتم عملية التفكيك على النحو التالي:

1. يقوم تعبيرthrowبإنشاء كائن الاستثناء (عادةً في منطقة خاصة من الذاكرة، وليس في المكدس).
2. يبحث وقت التشغيل في الوظيفة الحالية عن معالجcatchالمطابق.
3. إذا لم يتم العثور على أي شيء، فسيتم الخروج من الوظيفة الحالية، مع تدمير كافة الكائنات المحلية بترتيب عكسي للبناء.
4. يكرر الخطوات من 2 إلى 3 في مكدس الاستدعاءات.
5. إذا لم يتم العثور على معالج في أي إطار، فسيتم استدعاءstd::terminate().

القاعدة الأساسية: الرمي حسب القيمة، والتقاط بواسطة مرجعconst. الرمي حسب القيمة يتجنب المؤشرات المتدلية. يتجنب الالتقاط بواسطة مرجعconstالتقطيع (حيث يتم نسخ الاستثناء المشتق إلى كائن فئة أساسية، مما يؤدي إلى فقدان البيانات المشتقة) ويتجنب النسخ غير الضرورية.

التسلسل الهرمي للاستثناءات القياسية

توفر المكتبة القياسية تسلسلًا هرميًا متجذرًا في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()لتقديم رسالة ذات معنى.

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سوف تحرك العناصر أثناء إعادة التخصيص فقط إذا كان مُنشئ النقل هوnoexcept. وبخلاف ذلك، فإنهم يلجأون إلى النسخ للحفاظ على ضمان الاستثناء القوي. يمكن أن يكون هذا فرقًا كبيرًا في الأداء.
- التوثيق: هو عقد مع المتصلين على أن الوظيفة لا يمكن أن تفشل.

استخدمnoexceptفي: نقل المنشئات، ونقل عوامل التعيين، ووظائف المبادلة، والمدمرات (ضمنيًاnoexcept)، والأحرف البسيطة، وعوامل المقارنة.

تم إهمال مواصفاتthrow()القديمة (وthrow(type-list)) في C++11 وإزالتها في C++20**. استخدم دائمًاnoexceptبدلاً من ذلك.

خطأ شائع

لا ترمي أبدًا استثناءات في المدمرات. أثناء تفكيك المكدس من استثناء آخر، يؤدي طرح استثناء ثانٍ إلى استدعاءstd::terminate(). إذا كان يجب على أداة التدمير تنفيذ عملية يمكن أن تفشل، فالتقط الاستثناء داخل أداة التدمير وتعامل معها هناك (على سبيل المثال، التسجيل والقمع).

  • الاستثناءات المشتقة من الشرائح الالتقاط حسب القيمة** - يتم التقاطها دائمًا بواسطةconst&
  • رمي المؤشرات (على سبيل المثال،throw new std::runtime_error(...)) يؤدي إلى تسرب الذاكرة - الرمي حسب القيمة
  • التقاط...أولاً يخفي جميع المعالجات المحددة الموجودة أسفله — ضعه أخيرًا
  • الاستثناءات في أدوات التدمير أثناء تفكيك المكدس تسببstd::terminate()
  • استخدام الاستثناءات للتحكم في التدفق (المسارات الخالية من الأخطاء) يؤدي إلى إحباط نموذج التكلفة الصفرية والإضرار بالأداء
النقاط الرئيسية
  • قم بالرمي حسب القيمة، والتقاط بواسطة مرجعconstلتجنب التقطيع والتسربات
  • يؤدي تفكيك المكدس إلى تدمير الكائنات المحلية بترتيب البناء العكسي — يضمن RAII التنظيف
  • استثناءات C++ هي صفر تكلفة على مسار النجاح (التفكيك القائم على الجدول)، ومكلفة فقط عند طرحها
  • يتيحnoexceptتحسينات للمترجم وهو أمر بالغ الأهمية لعمليات النقل التي تستخدمها STL
  • Derive custom exceptions fromstd::runtime_errororstd::logic_errorand overridewhat()

اختبار — اختبر معرفتك

(15 نقطة خبرة)

1. Why should you catch exceptions by`const&`rather than by value?

2. What happens if a function marked`noexcept`throws an exception?

3. During stack unwinding, what happens to local variables in each stack frame?