مرجع واحد، أنواع متعددة
تعدد الأشكال هو ثمرة الوراثة والواجهات. يعني أن متغيّرًا واحدًا مُعرَّفًا بنوع أب (أو واجهة) يمكنه أن يحمل كائنًا من أي نوع فرعي، وعندما تستدعي دالة عليه، تُنفِّذ جافا النسخة التابعة للفئة الحقيقية للكائن، لا النسخة التي يوحي بها النوع المُعلن للمتغيّر.
لقد رأيت الأساس بالفعل في صفحة الوراثة: فئة فرعية تُعيد تعريف دالة من فئتها الأب. وتعدد الأشكال هو ما يجعل إعادة التعريف تلك جديرة بالعناء، إذ يتيح لك كتابة شيفرة تعتمد على النوع العام وترك كل كائن محدد يتصرف بطريقته الخاصة.
كلٌّ من a وb مُعلَّن بوصفه Animal، ومع ذلك يطبع كل منهما صوته الخاص. يحدث هذا الاختيار وقت التشغيل بناءً على الكائن الحقيقي.
التوزيع الديناميكي للدوال
الآلية وراء هذا هي التوزيع الديناميكي للدوال (dynamic method dispatch): فمن أجل دالة نسخة مُعاد تعريفها، تنظر آلة جافا الافتراضية (JVM) إلى فئة الكائن وقت التشغيل لتقرر أيّ تنفيذ تستدعي. ولا يتحقق المترجم إلا من أن الدالة موجودة على النوع المُعلن، أما الاختيار الفعلي فيُؤجَّل حتى يعمل البرنامج.
هذا ما يتيح لحلقة واحدة أن تتعامل مع خليط كامل من الأنواع دون أن تسأل قط عن ماهية كل واحد منها:
لا تعرف الحلقة سوى Shape. أضِف Triangle extends Shape لاحقًا وستظل هذه الشيفرة تعمل دون تغيير، وهذا هو جوهر الفكرة. فالشيفرة تعتمد على التجريد، لا على القائمة المحددة للأنواع.
التحويل لأعلى والتحويل لأسفل
تخزين Dog في متغيّر من نوع Animal هو تحويل لأعلى (upcasting): صعود في التسلسل الهرمي نحو نوع أعمّ. وهو آمن دائمًا وتفعله جافا ضمنيًا، لأن كل Dog هو Animal.
السير في الاتجاه المعاكس هو تحويل لأسفل (downcasting): أخذ مرجع أب ومعاملته بوصفه نوعًا فرعيًا محددًا. ولا يكون ذلك صحيحًا إلا إذا كان الكائن فعلًا من ذلك النوع الفرعي، لذا عليك كتابة التحويل صراحةً، وتخاطر بحدوث ClassCastException إن كنت مخطئًا:
التحويل الأخير يُترجَم دون مشكلة، إذ لا يستطيع المترجم إثبات خطئه، لكنه ينفجر عند التشغيل لأن Cat ليس Dog. لا تُجرِ تحويلًا لأسفل اعتمادًا على الظن أبدًا.
احمِ التحويل لأسفل باستخدام instanceof
قبل التحويل لأسفل، افحص النوع الحقيقي باستخدام instanceof. تتيح لك جافا الحديثة ربط النتيجة داخل التعبير نفسه (مطابقة الأنماط في instanceof)، فتستغني عن التحويل المنفصل:
يُرجِع instanceof القيمة false من أجل null، لذا يحميك هذا الفحص أيضًا من NullPointerException. ومع ذلك، إن وجدت نفسك تكتب سلاسل طويلة من instanceof، فغالبًا ما يكون ذلك إشارة إلى أن السلوك ينتمي إلى داخل الفئات بوصفه دالة مُعاد تعريفها، فدع تعدد الأشكال يتولى التفرّع عنك.
إعادة التعريف مقابل التحميل الزائد
يبدو هذان المفهومان متشابهين لكنهما غير مترابطين، والخلط بينهما مصدر كلاسيكي للالتباس.
إعادة التعريف (overriding) هي أن تستبدل فئة فرعية دالة من الأب بالتوقيع نفسه تمامًا. تُحلّ وقت التشغيل حسب نوع الكائن، وهي تعدد الأشكال الذي ما زلنا نستخدمه.
التحميل الزائد (overloading) هو أن تمتلك فئة واحدة عدة دوال بالاسم نفسه لكن بقوائم معاملات مختلفة. ويُحلّ وقت الترجمة حسب أنواع المعطيات، دون أي توزيع وقت التشغيل:
يختار المترجم describe المطابقة اعتمادًا على النوع الساكن للمعطى فحسب. لا يدخل في الأمر أي كائن أب أو ابن، لذا فهذا ليس تعدد أشكال وقت التشغيل، إنما هو إعادة استخدام لاسم دالة لا أكثر.
خطأ شائع: الحقول ليست متعددة الأشكال
تُوزَّع دوال النسخ (instance methods) فقط وقت التشغيل. أما الحقول والدوال الساكنة (static methods) فتُحلّ حسب النوع المُعلن، وهو ما يُربك كثيرين:
ينفّذ p.name() نسخة Child (تعدد الأشكال)، لكن p.label يقرأ حقل Parent، لأن الحقول تُخفى ولا تُعاد تعريفها. والحل بسيط: اجعل الحقول private وصِل إليها عبر الدوال فقط، كي يفوز الاستدعاء متعدد الأشكال دائمًا.
التالي: محددات الوصول
لا يعمل تعدد الأشكال بنظافة إلا حين تستطيع الفئات الفرعية رؤية الأعضاء المناسبين وإعادة تعريفها، بينما يعجز بقية شيفرتك عن التطفّل عليها وكسر الثوابت. ويُضبَط هذا التوازن عبر الوصول public وprotected وprivate والوصول على مستوى الحزمة، أي محددات الوصول، وهي موضوعنا التالي.
الأسئلة الشائعة
ما هو تعدد الأشكال في جافا؟
يعني تعدد الأشكال أن نوع مرجع واحد يمكنه الإشارة إلى كائنات من فئات مختلفة كثيرة، وأن الدالة التي تُنفَّذ فعليًا تُختار بناءً على النوع الحقيقي للكائن وقت التشغيل، وليس بناءً على النوع المُعلن للمتغيّر. فمتغيّر Shape shape يمكن أن يحمل Circle أو Square، واستدعاء shape.area() يُنفِّذ النسخة الصحيحة تلقائيًا.
ما الفرق بين overriding وoverloading في جافا؟
إعادة التعريف (overriding) هي أن تستبدل فئة فرعية دالة من الفئة الأب بالتوقيع نفسه، وهذا ما يقود تعدد الأشكال وقت التشغيل. أما التحميل الزائد (overloading) فهو أن تمتلك فئة واحدة عدة دوال بالاسم نفسه لكن بقوائم معاملات مختلفة، ويختار المترجم واحدة منها وقت الترجمة بناءً على المعطيات. تُحلّ إعادة التعريف وقت التشغيل حسب نوع الكائن، بينما يُحلّ التحميل الزائد وقت الترجمة حسب أنواع المعطيات.
ما الفرق بين upcasting وdowncasting في جافا؟
التحويل لأعلى يعامل كائنًا ابنًا على أنه نوعه الأب (Animal a = new Dog();)، وهو آمن دائمًا وضمني عادةً. أما التحويل لأسفل فيسير في الاتجاه المعاكس (Dog d = (Dog) a;)، ولا يكون آمنًا إلا إذا كان الكائن فعلًا من ذلك النوع الفرعي، وإلا فإنه يرمي ClassCastException. احمِ كل تحويل لأسفل بفحص instanceof أولًا.