لماذا لا يكفي الوراثة وحدها
في الصفحة السابقة بنيت تسلسلًا هرميًا للأصناف: الصنف المشتق يرث الأعضاء من أساسه. لكن هناك مشكلة دقيقة. عندما تستدعي دالة عبر مؤشر للصنف الأساسي، يقرر C++ أي دالة يُشغّلها بناءً على نوع المؤشر، لا على النوع الحقيقي للكائن. لذا فإن Animal* الذي يشير في الواقع إلى Dog ما زال يستدعي نسخة الدالة الخاصة بـ Animal.
هذا ليس ما تريده في معظم الأحيان. غالبًا ما يكون لديك مجموعة من مؤشرات الصنف الأساسي، كل منها يشير إلى كائن مشتق مختلف، وتريد أن يتصرف كل منها وفق طبيعته الحقيقية. الدوال الافتراضية تجعل ذلك ممكنًا.
الكائن هو بالفعل Dog، ومع ذلك شغّل a->speak() الدالة Animal::speak(). ولأن speak ليست افتراضية، اختار المُترجم الدالة في وقت الترجمة بناءً على النوع الساكن Animal*. هذا هو الخطأ الذي وُجدت الدوال الافتراضية لإصلاحه.
جعل الدالة افتراضية
أضف الكلمة المفتاحية virtual إلى دالة الصنف الأساسي. الآن يُحَل الاستدعاء أثناء التشغيل بناءً على النوع الحقيقي للكائن، وهذا هو التوزيع الديناميكي.
حلقة واحدة على Animal*، وثلاثة سلوكيات مختلفة. المؤشر الأساسي "يعرف" النوع الحقيقي أثناء التشغيل ويوزّع الاستدعاء وفقًا له. هذه الآلية الواحدة - واجهة واحدة وتنفيذات متعددة - هي بالضبط ما يعنيه تعدد الأشكال في C++.
لاحظ أن virtual يحتاج إلى الظهور في تصريح الأساس فقط؛ فبمجرد أن تصبح الدالة افتراضية، تظل افتراضية تلقائيًا في كل صنف مشتق. كتابتها مجددًا في الصنف المشتق اختيارية وزائدة عن الحاجة.
استخدم دائمًا الكلمة المفتاحية override
في المثال أعلاه، كل دالة مشتقة موسومة بـ override. هي اختيارية لكي يعمل الكود، لكن ينبغي أن تتعامل معها كأنها إلزامية. تطلب override (في C++11) من المُترجم التحقق من أنك تُعيد فعلًا تعريف دالة افتراضية من الأساس بتوقيع مطابق. وإذا أخطأت في التوقيع بشكل خفي، تحصل على خطأ واضح بدلًا من علة صامتة.
struct Animal {
virtual void speak() const { } // انتبه: const
};
struct Dog : Animal {
void speak() { } // ليست const - هذه دالة جديدة، وليست إعادة تعريف!
void speak() override { } // خطأ: 'speak' لا تُعيد تعريف شيء - يخبرك فورًا
};
من دون override، تُترجم speak() الأولى دون مشاكل لكنها لا تُستدعى أبدًا عبر Animal*، لأن توقيعها يختلف عن الأساس (ينقصها const). ستقضي فترة ما بعد الظهر كاملة تتساءل لماذا لا تفعل إعادة التعريف شيئًا. أما مع override فيلتقط المُترجم عدم التطابق في الحال. أضفها إلى كل دالة تُعيد التعريف.
الدوال الافتراضية البحتة والأصناف المجردة
أحيانًا لا يكون للصنف الأساسي قيمة افتراضية منطقية - فما الصوت الذي يصدره "Animal" عام؟ في هذه الحالة، عرّف الدالة على أنها افتراضية بحتة بإسناد = 0 إليها. هذا يتركها بلا جسم ويحوّل الصنف إلى صنف مجرد لا يمكن إنشاء كائن منه بمفرده. إنه موجود فقط لتعريف واجهة يجب على الأصناف المشتقة الوفاء بها.
كل صنف فرعي ملموس يجب أن يُنفّذ area()، وإلا يظل مجردًا هو الآخر. هكذا يعبّر C++ عن "الواجهات": فالصنف المجرد الذي يحتوي على دوال افتراضية بحتة فقط هو نظير الواجهة في C++ مقابل الواجهة في لغات مثل Java.
قاعدة الهادم الافتراضي
هذه هي المشكلة التي يقع فيها الجميع مرة واحدة على الأقل. عندما تستخدم delete لحذف كائن عبر مؤشر للصنف الأساسي، يستدعي C++ الهادم الذي يجده - وإذا كان ذلك الهادم غير افتراضي، فإنه لا يُشغّل سوى هادم الأساس. لا يُهدم الجزء المشتق أبدًا، فيُسرّب كل ما كان يملكه. ويُسمّي المعيار هذا سلوكًا غير محدد.
الإصلاح كلمة واحدة: اجعل هادم الأساس virtual. عندها يُشغّل delete p الهادم ~Derived أولًا ثم ~Base، تمامًا كما ينبغي.
struct Base {
virtual ~Base() { cout << "~Base\n"; } // صحيح
};
// الآن: ~Derived ثم ~Base
قاعدة عملية: في اللحظة التي يمتلك فيها الصنف أي دالة افتراضية، امنحه هادمًا افتراضيًا أيضًا. وإذا كان الصنف مُعدًّا ليكون صنفًا أساسيًا يُستخدم عبر المؤشرات، فيجب أن يكون هادمه افتراضيًا.
أخطاء شائعة ومزالق
إليك بضعة مزالق أخرى تنبّه لها بمجرد أن تطمئن للدوال الافتراضية:
اقتطاع الكائن (object slicing). إذا مرّرت أو خزّنت كائنًا مشتقًا بالقيمة في متغير من نوع الأساس، يُقتطَع الجزء المشتق ويتبقّى لديك كائن أساس بسيط، فلا يعود التوزيع الافتراضي يصل إلى إعادة التعريف. استخدم دائمًا المؤشرات أو المراجع لتعدد الأشكال:
Dog d;
Animal a = d; // مُقتطَع: a الآن مجرد Animal، وقد اختفى جزء Dog
a.speak(); // يُشغّل Animal::speak رغم أنها افتراضية
Animal& ref = d; // سليم: المرجع يحتفظ بالنوع الحقيقي
ref.speak(); // يُشغّل Dog::speak
لا تستدعِ الدوال الافتراضية من البواني أو الهوادم. أثناء البناء لا يكون الجزء المشتق موجودًا بعد، لذا يُحَل الاستدعاء الافتراضي إلى نسخة الصنف الحالي، لا إلى إعادة التعريف المشتقة - وهو نادرًا ما يكون ما تقصده.
للتوزيع الافتراضي تكلفة صغيرة. يمر كل استدعاء افتراضي عبر جدول مخفي من مؤشرات الدوال (الـ "vtable")، بمستوى توجيه غير مباشر واحد لكل استدعاء. التكلفة زهيدة لكنها ليست مجانية، لذا لا تجعل دالة افتراضية ما لم تكن بحاجة فعلًا إلى إعادة تعريفها.
استدعاء نسخة الأساس عمدًا. داخل إعادة التعريف، ما زال بإمكانك استدعاء تنفيذ الأساس صراحةً عبر Base::method() - وهو مفيد عندما يكون السلوك المشتق توسعة لسلوك الأساس لا استبدالًا له.
التالي: تحميل المعاملات الزائد (Operator Overloading)
تتيح الدوال الافتراضية لكائناتك تخصيص سلوكها عبر واجهة مشتركة. تُظهر الصفحة التالية كيف تخصّص المعاملات التي تعمل على كائناتك: فمع تحميل المعاملات الزائد يمكنك تعليم أنواعك الخاصة كيف تستجيب لـ + و== و<< وغيرها، بحيث يُقرأ Vector + Vector أو cout << myObject بطبيعية تضاهي قراءته للأنواع المدمجة.
الأسئلة الشائعة
ما هي الدالة الافتراضية في C++؟
الدالة الافتراضية هي دالة عضو يتم تعريفها بالكلمة المفتاحية virtual في الصنف الأساسي، بحيث عندما تستدعيها عبر مؤشر أو مرجع للصنف الأساسي، يُشغّل C++ نسخة الإعادة الخاصة بالصنف المشتق بدلًا من نسخة الأساس. يُسمى هذا الاختيار أثناء التشغيل بالتوزيع الديناميكي (dynamic dispatch) وهو أساس تعدد الأشكال.
ما الفرق بين الدالة الافتراضية والدالة الافتراضية البحتة؟
للدالة الافتراضية جسم ويمكن إعادة تعريفها. أما الدالة الافتراضية البحتة فتُعرَّف بـ = 0 وليس لها جسم في الصنف الأساسي، وهي تُجبر كل صنف مشتق ملموس على توفير تنفيذ خاص به. أي صنف يحتوي على دالة افتراضية بحتة واحدة على الأقل يكون صنفًا مجردًا ولا يمكن إنشاء كائن منه.
لماذا يحتاج الصنف الأساسي إلى هادم افتراضي في C++؟
إذا استخدمت delete لحذف كائن مشتق عبر مؤشر للصنف الأساسي وكان هادم الأساس غير افتراضي، فلن يُشغَّل سوى هادم الأساس، ولن يُنظَّف الجزء المشتق أبدًا، مما يؤدي إلى تسريب الموارد ويُعد سلوكًا غير محدد. اجعل هادم أي صنف مُعدّ للاستخدام بشكل متعدد الأشكال virtual.