Menu
flag Ar iconالعربيةdown icon

شرح المُدمِّرات في C++‏: ‎~ClassName وRAII والتنظيف

يعمل المُدمِّر تلقائيًا عند إتلاف الكائن. تعرّف على صيغة ~ClassName()، ومتى يُستدعى، ولماذا يحرّر الموارد، وقاعدة الثلاثة/الخمسة.

تحتوي هذه الصفحة على محررات قابلة للتشغيل - حرّر، شغّل، وشاهد النتيجة فوراً.

ما هو المُدمِّر

في الصفحة السابقة رأيت المُنشِئات - دوال خاصة تعمل عندما يُولد الكائن لتهيئة حالته الأولية. المُدمِّر هو الصورة المُعاكِسة لها: دالة خاصة تعمل عندما يموت الكائن، لتنظّف بعده.

تُصرّح عنه باسم الصنف مسبوقًا بعلامة التلدة (~). لا يأخذ أي مُعاملات، ولا يُرجع شيئًا، ويمكن للصنف أن يملك واحدًا منه بالضبط. نادرًا جدًا ما تستدعيه يدويًا - فلغة C++ تستدعيه نيابةً عنك في اللحظة المناسبة.

لاحظ أن رسالة المُدمِّر تُطبع بعد أن ينهي main متن دالته لكن قبل أن ينتهي البرنامج. عندما يخرج log من نطاقه عند القوس المعقوف الختامي، تُنفّذ C++ الدالة ~Logger() نيابةً عنك.

متى تعمل المُدمِّرات

يعتمد التوقيت الدقيق على المكان الذي يعيش فيه الكائن:

  • الكائنات على المكدس (المحلية) تُتلَف عندما تخرج من نطاقها - عند القوس المعقوف الختامي } للكتلة.
  • الكائنات في الكومة (المُنشأة بـ new) تُتلَف عندما تستدعي delete. إن نسيت delete، فلن يعمل المُدمِّر أبدًا وستتسبب في تسرّب.

يُبرز هذا المثال الفرق بوضوح:

تُتلَف الكائنات بترتيب عكسي لترتيب إنشائها. أُنشئ a أولًا، لذا يموت أخيرًا. هذا الترتيب من نوع LIFO (الداخل أخيرًا يخرج أولًا) مهمّ عندما تعتمد الكائنات بعضها على بعض.

لماذا تهمّ المُدمِّرات: RAII

القوة الحقيقية للمُدمِّرات هي أنها تجعل التنظيف تلقائيًا وآمنًا أمام الاستثناءات. فبدلًا من تذكّر تحرير المورد في كل مسار من مسارات الكود، تضع التحرير داخل مُدمِّر وتترك للغة ضمان تنفيذه. يُسمى هذا النمط RAII - أي Resource Acquisition Is Initialization (الحصول على المورد هو تهيئته) - وهو العمود الفقري للغة C++ الحديثة.

هنا يملك صنفٌ مخزنًا مؤقتًا في الكومة: يحجزه في المُنشِئ ويحرّره في المُدمِّر، فلا يلمس المُستدعون new/delete بأنفسهم أبدًا.

الفكرة الجوهرية: حتى لو أُلقي استثناء بعد إنشاء squares، فسيُفكّ المكدس وسيظل ~IntArray() يعمل رغم ذلك. هذا الضمان هو ما يجعل RAII موثوقًا إلى هذا الحد - وهو السبب في أنك نادرًا ما تكتب delete مجرّدًا في كودِ C++ الجيد.

قاعدة الثلاثة (والخمسة)

الصنف ذو المُدمِّر المخصّص يملك في الغالب موردًا خامًا، وهذا يخلق خطرًا خفيًا. فمُنشِئ النسخ ومعامل إسناد النسخ اللذان يولّدهما المُترجِم ينفّذان نسخًا سطحيًا - أي ينسخان المؤشر، لا المخزن الذي يشير إليه. والآن يحمل كائنان المؤشر نفسه، وسيستدعي كلا المُدمِّرين delete عليه، مُسبّبَين انهيارًا بسبب التحرير المزدوج.

IntArray a(5);
IntArray b = a;   // نسخ سطحي: a.data و b.data هما المؤشر نفسه
// عند نهاية النطاق: يحرّر مُدمِّر b المخزن المؤقت،
// ثم يحرّره مُدمِّر a مرة أخرى -> سلوك غير مُعرَّف (تحرير مزدوج)

يقودنا هذا إلى قاعدة الثلاثة: إن كتبت أيًّا من الثلاثة - المُدمِّر، أو مُنشِئ النسخ، أو معامل إسناد النسخ - فأنت بالتأكيد تقريبًا بحاجة إلى الثلاثة جميعًا. وفي C++11 وما بعدها تمتدّ إلى قاعدة الخمسة، بإضافة مُنشِئ النقل وإسناد النقل.

لكن هناك قاعدة أفضل من ذلك - قاعدة الصفر: صمّم الأصناف بحيث لا تدير موارد خامًا على الإطلاق. احمل بدلًا من ذلك std::vector أو std::string أو مؤشرًا ذكيًا، وسيؤدي المُدمِّر الذي يولّده المُترجِم العملَ الصحيح مجانًا.

اعتمد قاعدة الصفر افتراضيًا. ولا تكتب مُدمِّرًا مخصّصًا إلا عندما تملك فعلًا موردًا خامًا لا يغلّفه لك أي نوع قياسي.

المُدمِّرات الافتراضية

عندما تحذف كائنًا عبر مؤشر إلى الصنف الأساسي، يجب أن يكون المُدمِّر virtual - وإلا فلن يُتلَف سوى الجزء الأساسي ويتسرّب الجزء المُشتق. هذا واحد من أكثر العلل شيوعًا في الكود متعدّد الأشكال، والمُترجِم لا يحذّرك منه افتراضيًا.

بدون virtual على ~Base، فإن delete p سيستدعي ~Base() فقط - وهو سلوك غير مُعرَّف، ولن يُنظَّف الجزء Derived من الكائن أبدًا. قاعدة عملية: أي صنف يحتوي على دوال افتراضية (صنف أساسي متعدّد الأشكال) يحتاج إلى مُدمِّر افتراضي. سترى بالضبط لماذا يهمّ ذلك بمجرد أن تبدأ في اشتقاق الأصناف.

الأخطاء والمزالق الشائعة

ثمة بضع مزالق يقع فيها الجميع تقريبًا:

عدم تطابق new/delete. إن حجزت بـ new[]، فحرّر بـ delete[]. وخلط new[] مع delete العادي (أو العكس) سلوك غير مُعرَّف.

نسيان virtual على مُدمِّر الصنف الأساسي. كما سبق، حذف كائن مُشتق عبر مؤشر إلى الأساسي دون مُدمِّر افتراضي يُسرّب الجزء المُشتق. إن كنت تكتب صنفًا مقصودًا للوراثة منه، فاجعل المُدمِّر افتراضيًا.

ترك الاستثناءات تفلت من المُدمِّر. المُدمِّر الذي يُلقي استثناءً أثناء فكّ المكدس يُنهي برنامجك. في C++ الحديثة تكون المُدمِّرات ضمنيًا noexcept - فامنع كود التنظيف من إلقاء الاستثناءات، أو ابتلع الاستثناء داخل المُدمِّر.

كتابة مُدمِّر لا تحتاج إليه. إن كانت أعضاؤك تنظّف نفسها أصلًا، فإن ~ClassName() {} فارغًا يضيف ضوضاء وقد يُعطّل عمليات النقل بصمت. وحين لا يكون ثمة ما يُنظَّف، فلا تكتب أي مُدمِّر على الإطلاق.

التالي: الوراثة

لقد رأيت الآن دورة حياة الكائن كاملة - المُنشِئات تبثّ فيه الحياة، والمُدمِّرات تنظّف بعده، والمُدمِّرات virtual تُبقي ذلك التنظيف صحيحًا عندما يُبنى صنف على آخر. تلك النقطة الأخيرة تمهيد للفكرة الكبيرة التالية: الوراثة، حيث يُعيد صنفٌ استخدام بيانات صنف آخر وسلوكه ويوسّعهما. تُظهر الصفحة التالية كيف تشتقّ صنفًا من آخر، وكيف يتسلسل الإنشاء والإتلاف عبر التسلسل الهرمي، وكيف تتلاءم القطع التي تعلّمتها للتو.

الأسئلة الشائعة

ما هو المُدمِّر في C++؟

المُدمِّر دالة عضو خاصة تُسمى ~ClassName() تعمل تلقائيًا عند إتلاف الكائن - عندما يخرج من نطاقه أو عندما تستدعي delete. وظيفته التنظيف: تحرير الذاكرة، وإغلاق الملفات، أو تحرير أي مورد يملكه الكائن. لا يأخذ أي مُعاملات وليس له نوع إرجاع، ولا يمكن للصنف أن يملك سوى مُدمِّر واحد.

متى يعمل المُدمِّر في C++؟

بالنسبة لكائن محلي (على المكدس)، يعمل المُدمِّر عند خروجه من نطاقه، عند القوس المعقوف الختامي }. أما الكائن في الكومة المُنشأ بـ new، فيعمل مُدمِّره عندما تستدعي delete. أما الأعضاء والأصناف الأساسية فتُتلَف تلقائيًا بعد ذلك، بترتيب عكسي لترتيب الإنشاء.

هل أحتاج دائمًا إلى كتابة مُدمِّر في C++؟

لا. إذا كان صنفك يحتوي فقط على أعضاء تنظّف نفسها بنفسها (مثل std::string أو std::vector أو المؤشرات الذكية)، فإن المُدمِّر الذي يولّده المُترجِم كافٍ - لا تكتب واحدًا. لا تحتاج إلى مُدمِّر مخصّص إلا عندما يملك صنفك موردًا خامًا، مثل ذاكرة من new أو مِقبض ملف مفتوح.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن