مرة أخرى حول السبب في أنه من السيء رمي استثناءات في المدمرات
يعلمنا العديد من خبراء C ++ (على سبيل المثال ، Herb Sutter ) أن إلقاء استثناءات في المدمرات أمر سيئ ، لأنه يمكنك الدخول إلى destructor أثناء الترقية المكدس مع استثناء تم طرحه بالفعل ، وإذا تم طرح استثناء آخر في هذه المرحلة ، فسيتم استدعاء std :: إنهاء () . يخبرنا معيار لغة C ++ 17 (المشار إليه فيما يلي بالإصدار المتاح مجانًا من مسودة N4713 ) بشأن هذا الموضوع بما يلي:
18.5.1 الدالة std :: terminate () [باستثناء. الإنهاء]
1 في بعض الحالات ، يجب التخلي عن المعالجة باستثناء أساليب معالجة الأخطاء الأقل دقة. [ملاحظة:
هذه الحالات هي:
...
(1.4) عندما ينتهي تدمير كائن أثناء فك المكدس (18.2) عن طريق رمي استثناء ، أو
...
- مذكرة نهاية]
دعونا نتحقق من مثال بسيط:
#include <iostream> class PrintInDestructor { public: ~PrintInDestructor() noexcept { std::cerr << "~PrintInDestructor() invoked\n"; } }; void throw_int_func() { std::cerr << "throw_int_func() invoked\n"; throw 1; } class ThrowInDestructor { public: ~ThrowInDestructor() noexcept(false) { std::cerr << "~ThrowInDestructor() invoked\n"; throw_int_func(); } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowInDestructor bad; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* c) { std::cerr << "Catched const char* exception: " << c << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }
النتيجة:
~ThrowInDestructor() invoked throw_int_func() invoked ~PrintInDestructor() invoked terminate called after throwing an instance of 'int' Aborted
لاحظ أن destructor PrintInDestructor مازال يسمى ، أي بعد طرح الاستثناء الثاني ، لا يتم مقاطعة الترويج المكدس. يقول المعيار (نفس الفقرة 18.5.1) حول هذا الموضوع ما يلي:
2 ... في الحالة التي لا يوجد فيها معالج مطابق ،
يتم تعريفه للتطبيق ما إذا كان الكد غير مرتبط قبل استدعاء std :: terminate () أم لا. في
الحالة التي يكون فيها البحث عن معالج (18.3) يصادف الكتلة الخارجية من دالة ب
مواصفات استثناء غير قابلة للرمي (18.4) ، يتم تعريفها فيما إذا كان المكدس غير مناسب ،
غير مرتبط جزئيًا أو غير مرغوب فيه على الإطلاق قبل أن يسمى std :: terminate () ...
لقد اختبرت هذا المثال على العديد من إصدارات GCC (8.2 ، 7.3) و Clang (6.0 ، 5.0) ، في كل مكان يستمر فيه الترويج المكدس. إذا صادفت مترجمًا حيث يختلف التطبيق المحدد ، فيرجى الكتابة عنه في التعليقات.
تجدر الإشارة أيضًا إلى أنه يتم استدعاء std :: terminate () عندما تكون المكدس غير مرتبطة فقط عندما يتم طرح استثناء من المدمر. إذا كان هناك كتلة try / catch داخل destructor تدرك الاستثناء ولا تتعدى ذلك ، فإن هذا لا يوقف الترويج لمجموعة الاستثناء الخارجي.
class ThrowCatchInDestructor { public: ~ThrowCatchInDestructor() noexcept(false) { try { throw_int_func(); } catch (int i) { std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n"; } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowCatchInDestructor good; std::cerr << "ThrowCatchInDestructor instance created\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }
يعرض
ThrowCatchInDestructor instance created throw_int_func() invoked Catched int in ~ThrowCatchInDestructor(): 1 ~PrintInDestructor() invoked Catched const char* exception: BANG!
كيفية تجنب المواقف غير السارة؟ من الناحية النظرية ، كل شيء بسيط: لا يلقي استثناءات في المدمر. ومع ذلك ، في الممارسة العملية ، ليس من السهل إدراك هذا المطلب البسيط بشكل جميل وأنيق.
إذا كنت لا تستطيع ذلك ، ولكنك تريد حقًا ...
سألاحظ على الفور أنني لا أحاول تبرير رمي الاستثناءات من المدمر ، واتباع Sutter و Meyers وغيرهم من معلمي C ++ ، فإنني أحثك على ألا تحاول أبدًا القيام بذلك (على الأقل في الكود الجديد). ومع ذلك ، في الممارسة العملية ، قد يواجه المبرمج رمزًا قديمًا ، ليس من السهل أن يؤدي إلى معايير عالية. بالإضافة إلى ذلك ، يمكن أن تأتي التقنيات الموضحة غالبًا في متناول يدي أثناء عملية تصحيح الأخطاء.
على سبيل المثال ، نحن نعمل على تطوير مكتبة مع فئة مجمّع تتضمن العمل مع مورد معين. وفقًا لمبادئ RAII ، نلتقط المورد في المنشئ ويجب أن نحرره في المدمر. ولكن ماذا لو فشلت محاولة تحرير مورد؟ خيارات لحل هذه المشكلة:
- تجاهل الخطأ. سيئ ، لأننا نخفي مشكلة قد تؤثر على أجزاء أخرى من النظام.
- اكتب إلى السجل. أفضل من مجرد تجاهلها ، ولكن لا تزال سيئة ، لأن لا تعرف مكتبتنا أي شيء عن سياسات التسجيل المعتمدة في النظام الذي يستخدمها. يمكن إعادة توجيه السجل القياسي إلى / dev / null ، ونتيجة لذلك ، مرة أخرى ، لن نرى خطأً.
- خذ إصدار المورد في دالة منفصلة تقوم بإرجاع قيمة أو طرح استثناء ، وإجبار مستخدم الفصل على الاتصال به من تلقاء نفسه. إنه أمر سيء ، لأن المستخدم يمكن أن ينسى القيام بذلك على الإطلاق ، وسوف نتلقى تسرب الموارد.
- رمي استثناء. جيد في الحالات العادية ، كما يمكن لمستخدم الفصل متابعة الاستثناء والحصول على معلومات حول الخطأ بالطريقة القياسية. سيئة خلال الترويج المكدس ، كما يؤدي إلى الأمراض المنقولة جنسيا :: إنهاء () .
كيف نفهم ما إذا كنا حاليًا في عملية الترويج للمجموعة بشكل استثنائي أم لا؟ في C ++ ، هناك دالة خاصة std :: uncaught_exception () لهذا الغرض . من خلال مساعدتها ، يمكننا أن نرمي بأمان استثناءً في الوضع الطبيعي ، أو أن نفعل شيئًا أقل صحة ، لكن لا يؤدي ذلك إلى رمي استثناء أثناء الترويج المكدس.
class ThrowInDestructor { public: ~ThrowInDestructor() noexcept(false) { if (std::uncaught_exception()) { std::cerr << "~ThrowInDestructor() stack unwinding, not throwing\n"; } else { std::cerr << "~ThrowInDestructor() normal case, throwing\n"; throw_int_func(); } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowInDestructor normal; std::cerr << "ThrowInDestructor normal destruction\n"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } try { ThrowInDestructor stack_unwind; std::cerr << "ThrowInDestructor stack unwinding\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }
النتيجة:
ThrowInDestructor normal destruction ~ThrowInDestructor() normal case, throwing throw_int_func() invoked ~PrintInDestructor() invoked Catched int exception: 1 ThrowInDestructor stack unwinding ~ThrowInDestructor() stack unwinding, not throwing ~PrintInDestructor() invoked Catched const char* exception: BANG!
لاحظ أنه تم إهمال دالة std :: uncaught_exception () منذ C ++ Standard 17 ، لذلك ، لتجميع المثال ، يجب إلغاء النسخة المقابلة (انظر المستودع بأمثلة من المقال ).
المشكلة في هذه الوظيفة هي أنها تتحقق مما إذا كنا بصدد تدوير المكدس بشكل استثنائي. لكن من المستحيل فهم ما إذا كان المدمر الحالي يسمى أثناء عملية الترويج للمكدس. نتيجة لذلك ، إذا كان هناك ترقية مكدس ، ولكن يدعى destructor بعض الكائنات بشكل طبيعي ، ستظل std :: uncaught_exception () صحيحاً .
class MayThrowInDestructor { public: ~MayThrowInDestructor() noexcept(false) { if (std::uncaught_exception()) { std::cerr << "~MayThrowInDestructor() stack unwinding, not throwing\n"; } else { std::cerr << "~MayThrowInDestructor() normal case, throwing\n"; throw_int_func(); } } }; class ThrowCatchInDestructor { public: ~ThrowCatchInDestructor() noexcept(false) { try { MayThrowInDestructor may_throw; } catch (int i) { std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n"; } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowCatchInDestructor stack_unwind; std::cerr << "ThrowInDestructor stack unwinding\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }
النتيجة:
ThrowInDestructor stack unwinding ~MayThrowInDestructor() stack unwinding, not throwing ~PrintInDestructor() invoked Catched const char* exception: BANG!
في C ++ 17 Standard الجديد ، تم تقديم الدالة std :: uncaught_exceptions () لاستبدال std :: uncaught_exception () (لاحظ الجمع) ، والتي بدلاً من القيمة المنطقية تُرجع عدد الاستثناءات النشطة حاليًا (هنا هو مبرر مفصل).
هذه هي الطريقة التي يتم بها حل المشكلة الموضحة أعلاه باستخدام std :: uncaught_exceptions () :
class MayThrowInDestructor { public: MayThrowInDestructor() : exceptions_(std::uncaught_exceptions()) {} ~MayThrowInDestructor() noexcept(false) { if (std::uncaught_exceptions() > exceptions_) { std::cerr << "~MayThrowInDestructor() stack unwinding, not throwing\n"; } else { std::cerr << "~MayThrowInDestructor() normal case, throwing\n"; throw_int_func(); } } private: int exceptions_; }; class ThrowCatchInDestructor { public: ~ThrowCatchInDestructor() noexcept(false) { try { MayThrowInDestructor may_throw; } catch (int i) { std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n"; } } private: PrintInDestructor member_; }; int main(int, char**) { try { ThrowCatchInDestructor stack_unwind; std::cerr << "ThrowInDestructor stack unwinding\n"; throw "BANG!"; } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } return 0; }
النتيجة:
ThrowInDestructor stack unwinding ~MayThrowInDestructor() normal case, throwing throw_int_func() invoked Catched int in ~ThrowCatchInDestructor(): 1 ~PrintInDestructor() invoked Catched const char* exception: BANG!
عندما أرغب حقًا في طرح بعض الاستثناءات مرة واحدة
يتجنب std :: uncaught_exceptions () استدعاء std :: terminate () ، لكنه لا يساعد في معالجة استثناءات متعددة بشكل صحيح. من الناحية المثالية ، أود أن يكون لدي آلية تسمح لي بحفظ جميع الاستثناءات التي تم إلقاؤها ، ثم معالجتها في مكان واحد.
أريد أن أذكر مرة أخرى أن الآلية التي اقترحها لي أدناه تخدم فقط لإظهار المفهوم ولا ينصح باستخدامها في الكود الصناعي الحقيقي.
جوهر الفكرة هو ملاحظة الاستثناءات وحفظها في حاوية ، ثم الحصول عليها ومعالجتها واحدة تلو الأخرى. من أجل حفظ كائنات الاستثناء ، يحتوي C ++ على نوع خاص std :: استثناء_ptr . لم يتم الكشف عن بنية الكتابة في المعيار ، ولكن يقال إنه مشترك بشكل أساسي لكل كائن استثناء.
كيف إذن لمعالجة هذه الاستثناءات؟ هناك دالة std :: rethrow_exception () لهذا ، والتي تأخذ مؤشر std :: استثناء_ptr وتطرح الاستثناء المقابل. نحتاج فقط إلى اللحاق بقسم catch المقابل ومعالجته ، وبعد ذلك يمكننا الانتقال إلى كائن الاستثناء التالي.
using exceptions_queue = std::stack<std::exception_ptr>; // Get exceptions queue for current thread exceptions_queue& get_queue() { thread_local exceptions_queue queue_; return queue_; } // Invoke functor and save exception in queue void safe_invoke(std::function<void()> f) noexcept { try { f(); } catch (...) { get_queue().push(std::current_exception()); } } class ThrowInDestructor { public: ~ThrowInDestructor() noexcept { std::cerr << "~ThrowInDestructor() invoked\n"; safe_invoke([]() { throw_int_func(); }); } private: PrintInDestructor member_; }; int main(int, char**) { safe_invoke([]() { ThrowInDestructor bad; throw "BANG!"; }); auto& q = get_queue(); while (!q.empty()) { try { std::exception_ptr ex = q.top(); q.pop(); if (ex != nullptr) { std::rethrow_exception(ex); } } catch (int i) { std::cerr << "Catched int exception: " << i << "\n"; } catch (const char* s) { std::cerr << "Catched const char* exception: " << s << "\n"; } catch (...) { std::cerr << "Catched unknown exception\n"; } } return 0; }
النتيجة:
~ThrowInDestructor() invoked throw_int_func() invoked ~PrintInDestructor() invoked Catched const char* exception: BANG! Catched int exception: 1
في المثال أعلاه ، يتم استخدام المكدس لحفظ كائنات الاستثناء ، ومع ذلك ، سيتم تنفيذ معالجة الاستثناءات وفقًا لمبدأ FIFO (أي ، من المنطقي أن هذه هي قائمة الانتظار - الاستثناء الذي تم طرحه أولاً سيكون الأول الذي تتم معالجته).
الاستنتاجات
يعتبر رمي الاستثناءات في مدمرات الكائنات فكرة سيئة حقًا ، وفي أي كود جديد أوصي بشدة بعدم القيام بذلك بإعلان مدمرات noexcept . ومع ذلك ، مع دعم وتصحيح الكود القديم ، قد تكون هناك حاجة للتعامل بشكل صحيح مع الاستثناءات التي ألقيت من المدمرات ، بما في ذلك أثناء الترويج للمكدس ، وتوفر C ++ الحديثة لنا آليات لذلك. آمل أن تساعدك الأفكار المقدمة في المقال على هذا المسار الصعب.
المراجع
مستودع مع أمثلة من المقال