تعدد الأشكال (Polymorphism) في جافا: دليل شامل مع أمثلة برمجية للكائنات
مقدمة إلى تعدد الأشكال (Polymorphism) في جافا
يُعد مفهوم تعدد الأشكال (Polymorphism) أحد الركائز الأساسية في البرمجة كائنية التوجه (OOP)، ويسمح بالتعامل مع الكائنات بطرق قابلة للتبديل. هذا المبدأ يقلل بشكل كبير من تكرار الكود عندما نرغب في تنفيذ نفس الإجراءات على أنواع مختلفة من الكائنات. كلمة Polymorphism تعني حرفياً "العديد من الأشكال". دعونا نوضح ما نقصده بهذا المفهوم بالضبط.
شرح مفهوم تعدد الأشكال عبر تشبيه واقعي
إذا سافرت يوماً ما إلى بلد آخر، فمن المحتمل أن يكون أحد العناصر في قائمة أغراضك هو محول قابس كهربائي. بدون هذا المحول، قد لا تتمكن من شحن هاتفك أو أجهزتك الأخرى.

الغريب أن هناك ما يقرب من 16 نوعاً مختلفاً من المقابس الكهربائية حول العالم. بعضها يحتوي على دبوسين، وبعضها على ثلاثة دبابيس، وبعض الدبابيس دائرية، وبعضها مستطيلة، وتختلف طريقة ترتيب الدبابيس. الحل الذي يلجأ إليه معظم الناس هو شراء محول قابس عالمي.
للنظر إلى المشكلة بطريقة أخرى، تكمن المشكلة عادةً في أن لدينا واجهة مقبس تقبل نوعاً واحداً فقط من كائنات القابس! المقابس ليست متعددة الأشكال (Polymorphic). ستكون الحياة أسهل بكثير لو كانت لدينا مقابس يمكنها قبول أنواع مختلفة من القوابس. يمكننا جعل واجهة المقبس متعددة الأشكال عن طريق إنشاء فتحات بأشكال مختلفة، كما يوضح الشكل التالي:
يساعدنا تعدد الأشكال في إنشاء واجهات أكثر عالمية ومرونة.
توضيح تعدد الأشكال بالأمثلة البرمجية
يُعتبر أي كائن يمتلك علاقة IS-A (هو-نوع-من) متعدد الأشكال. تتكون علاقة IS-A من خلال الوراثة (باستخدام الكلمة المفتاحية extends في تعريف الفئة) أو من خلال الواجهات (باستخدام الكلمة المفتاحية implements في تعريف الفئة). لفهم تعدد الأشكال بشكل كامل، يجب أن تكون على دراية بمفاهيم الوراثة والواجهات.
class Dog extends Animal implements Canine {
// ... some code here
}
بناءً على المقتطف أعلاه، يمتلك الكائن Dog علاقات IS-A التالية: Animal، Canine، و Object (ترث كل فئة ضمنياً من الفئة Object، وهو ما قد يبدو غريباً بعض الشيء!).
مثال بسيط لتوضيح تعدد الأشكال
دعونا نقدم مثالاً بسيطاً (وإن كان طريفاً) لتوضيح كيف يمكننا استخدام تعدد الأشكال لتبسيط الكود الخاص بنا. نريد إنشاء تطبيق يحتوي على "محقق" يمكنه إقناع أي حيوان بالتحدث.

سنقوم بإنشاء فئة Interrogator تكون مسؤولة عن إقناع الحيوانات بالتحدث. لا نريد كتابة دالة لكل نوع من الحيوانات، مثل: convinceDogToTalk(Dog dog)، convinceCatToTalk(Cat cat)، وهكذا. نفضل دالة عامة واحدة تقبل أي حيوان. كيف يمكننا تحقيق ذلك؟
class Interrogator {
public static void convinceToTalk (Animal subject) {
subject.talk();
}
}
// لا نريد لأحد إنشاء كائن من نوع Animal مباشرة!
abstract class Animal {
public abstract void talk () ;
}
class Dog extends Animal {
public void talk () {
System.out.println( "Woof!" );
}
}
class Cat extends Animal {
public void talk () {
System.out.println( "Meow!" );
}
}
public class App {
public static void main (String[] args) {
Dog dog = new Dog();
Cat cat = new Cat();
Animal animal = new Dog(); // تعدد الأشكال هنا!
Interrogator.convinceToTalk(dog); // يطبع "Woof!"
Interrogator.convinceToTalk(cat); // يطبع "Meow!"
Interrogator.convinceToTalk(animal); // يطبع "Woof!"
}
}
نحن ننشئ الدالة convinceToTalk لتقبل كائناً من نوع Animal كمعامل. داخل الدالة، نستدعي الدالة talk لهذا الكائن. طالما أن نوع الكائن هو Animal أو فئة فرعية من Animal، فإن المترجم (compiler) سيكون راضياً. تحدد آلة جافا الافتراضية (JVM) في وقت التشغيل (runtime) أي دالة سيتم استدعاؤها بناءً على الفئة الفعلية للكائن. إذا كان نوع الكائن Dog، فإن JVM تستدعي التنفيذ الذي يطبع "Woof!".
هذا النهج يحقق فائدتين رئيسيتين:
- نحن بحاجة فقط لكتابة دالة عامة واحدة.
- لا نحتاج إلى إجراء أي فحص للنوع (type checking).
في المستقبل، إذا أنشأنا نوعاً جديداً من الحيوانات، فلن نحتاج إلى تعديل فئة Interrogator. يُشار إلى هذا النوع من تعدد الأشكال باسم التجاوز (Overriding).
التجاوز (Overriding): إعادة تعريف سلوك الكائنات
لقد غطى المثال الذي ناقشناه بالفعل المفهوم الواسع للتجاوز. دعونا نقدم تعريفاً رسمياً ومزيداً من التفاصيل.
التجاوز هو عندما تنشئ تنفيذاً مختلفاً لنفس دالة الكائن بالضبط (نفس توقيع الدالة method signature) في فئة ذات صلة. في وقت التشغيل، يتم اختيار دالة نوع الكائن الفعلي. هذا هو السبب في أن التجاوز يُشار إليه أيضاً باسم تعدد الأشكال في وقت التشغيل (runtime polymorphism).
يتم تحقيق التجاوز من خلال توفير تنفيذ مختلف لدالة معرفة في فئة الأب (superclass) داخل فئة الابن (subclass).

كما يتم تحقيق التجاوز من خلال توفير تطبيقات مختلفة لدالة معرفة في واجهة.

قواعد تجاوز الدالة (Overriding Rules)
- يجب أن تكون الدالة معرفة من خلال علاقة
IS-A(عبرextendsأوimplements). لهذا السبب قد تجدها تُشار إليها باسم تعدد الأشكال الفرعي (subtype polymorphism). - يجب أن تحتوي على نفس قائمة المعاملات (
argument list) مثل تعريف الدالة الأصلي. - يجب أن يكون لها نفس نوع الإرجاع (
return type)، أو نوع إرجاع هو فئة فرعية من نوع الإرجاع للدالة الأصلية. - لا يمكن أن يكون لها معدّل وصول (
access modifier) أكثر تقييداً. قد يكون لها معدّل وصول أقل تقييداً (على سبيل المثال، منprotectedإلىpublic). - يجب ألا تطرح استثناءً (
checked exception) جديداً أو أوسع نطاقاً. قد تطرح استثناءات أضيق نطاقاً، أو أقل، أو لا تطرح أي استثناءات على الإطلاق. على سبيل المثال، يمكن تجاوز دالة تعلن عنIOExceptionبواسطة دالة تعلن عنFileNotFoundException(لأنها فئة فرعية منIOException). - يمكن للدالة المتجاوزة أن تطرح أي استثناء غير مفحوص (
unchecked exception)، بغض النظر عما إذا كانت الدالة المتجاوزة تعلن عن الاستثناء.
توصية: استخدم التعليمة البرمجية @Override عند تجاوز الدوال. إنها توفر فحصاً للأخطاء في وقت الترجمة (compile-time error-checking) على توقيع الدالة، مما يساعدك على تجنب كسر القواعد المذكورة أعلاه.

منع التجاوز (Prohibiting Overriding)
إذا كنت لا ترغب في تجاوز دالة ما، فقم بتعريفها على أنها final.
class Account {
public final void withdraw ( double amount) {
double newBalance = balance - amount;
if (newBalance > 0 ){
balance = newBalance;
}
}
}
الدوال الساكنة (Static Methods)
لا يمكنك تجاوز دالة ساكنة (static method). أنت في الواقع تقوم بإنشاء تعريف مستقل للدالة في فئة ذات صلة.
class A {
public static void print () {
System.out.println( "in A" );
}
}
class B extends A {
public static void print () {
System.out.println( "in B" );
}
}
class Test {
public static void main (String[] args) {
A myObject = new B();
myObject.print(); // يطبع "in A"
}
}
تشغيل فئة Test في المثال أعلاه سيطبع "in A". هذا يوضح أن التجاوز لا يحدث هنا. إذا قمت بتغيير الدالة print في الفئتين A و B لتكون دالة كائن (instance method) عن طريق إزالة الكلمة static من توقيع الدالة، وقمت بتشغيل فئة Test مرة أخرى، فستطبع "in B" بدلاً من ذلك! التجاوز يحدث الآن. تذكر أن التجاوز يختار الدالة بناءً على نوع الكائن، وليس نوع المتغير.
التحميل الزائد (Overloading) أو تعدد الأشكال الوظيفي
التحميل الزائد (Overloading) هو عندما تنشئ إصدارات مختلفة من نفس الدالة. يجب أن يكون اسم الدالة هو نفسه، ولكن يمكننا تغيير المعاملات (parameters) ونوع الإرجاع. في فئة Math في جافا، ستجد العديد من الأمثلة على الدوال المحملة زائداً. الدالة max محملة زائداً لأنواع مختلفة. في جميع الحالات، فإنها تُرجع الرقم ذي القيمة الأعلى من القيمتين المقدمتين، ولكنها تفعل ذلك لأنواع أرقام مختلفة (غير مرتبطة).

يحدد نوع المتغير (المُشار إليه) أي دالة محملة زائداً سيتم اختيارها. يتم التحميل الزائد في وقت الترجمة (compile time).
توفر الدوال المحملة زائداً مرونة أكبر للأشخاص الذين يستخدمون فئتك. قد يكون لدى مستخدمي فئتك بيانات بتنسيقات مختلفة، أو قد تكون لديهم بيانات مختلفة متاحة لهم اعتماداً على المواقف المختلفة في تطبيقهم. على سبيل المثال، تقوم فئة List بتحميل الدالة remove زائداً. List هي مجموعة مرتبة من الكائنات. لذلك، قد ترغب في إزالة كائن في موضع معين (index) في القائمة، أو قد لا تعرف الموضع وتريد فقط إزالة الكائن أينما كان. لهذا السبب تحتوي على إصدارين.

يمكن أيضاً تحميل المُنشئات (Constructors) زائداً. على سبيل المثال، تحتوي فئة Scanner على العديد من المدخلات المختلفة التي يمكن توفيرها لإنشاء كائن. أدناه لقطة صغيرة من المُنشئات التي تلبي هذا الغرض.

قواعد التحميل الزائد للدالة (Overloading Rules)
- يجب أن تحتوي على قائمة معاملات مختلفة.
- قد يكون لها نوع إرجاع مختلف.
- قد يكون لها معدّلات وصول مختلفة.
- قد تطرح استثناءات مختلفة.
- يمكن تحميل دوال من فئة الأب زائداً في فئة الابن.
الاختلافات الرئيسية بين التجاوز (Overriding) والتحميل الزائد (Overloading)
- يجب أن يعتمد التجاوز على دالة من علاقة
IS-A، بينما لا يشترط ذلك في التحميل الزائد. - يمكن أن يحدث التحميل الزائد داخل فئة واحدة.
- يتم اختيار الدوال المتجاوزة بناءً على نوع الكائن، بينما يتم اختيار الدوال المحملة زائداً بناءً على نوع المتغير (المُشار إليه).
- يحدث التجاوز في وقت التشغيل (
run-time)، بينما يحدث التحميل الزائد في وقت الترجمة (compile-time).
تعدد الأشكال البارامتري (Parametric Polymorphism)
يتم تحقيق تعدد الأشكال البارامتري من خلال الأنواع العامة (Generics) في جافا. تمت إضافة الأنواع العامة إلى اللغة في الإصدار 5.0. وقد تم تصميمها لتوسيع نظام أنواع جافا للسماح "لفئة أو دالة بالعمل على كائنات من أنواع مختلفة مع توفير أمان النوع في وقت الترجمة". بشكل أساسي، يمكن استبدال جميع أنواع الشكل العام لفئة أو دالة.
مثال بسيط هو ArrayList. يحتوي تعريف الفئة على نوع عام، ويُشار إليه بـ <E>. تستخدم بعض دوال الكائن، مثل add، هذا النوع العام في توقيعاتها.


من خلال توفير نوع داخل أقواس الزاوية (angle brackets) عند إنشاء كائن ArrayList، نقوم بملء المراجع العامة المعرفة في جميع أنحاء الفئة. لذلك، إذا أنشأنا ArrayList بنوع عام Dog، فإن الدالة add ستقبل كائناً من نوع Dog فقط كمعامل.

سيحدث خطأ في وقت الترجمة إذا حاولت إضافة أي شيء آخر غير Dog! إذا كنت تستخدم محرراً برمجياً مثل IntelliJ، فستحصل على خط أحمر متعرج لتسليط الضوء على هذا الخطأ (كما هو موضح أدناه).

الخلاصة التقنية
يُعد تعدد الأشكال (Polymorphism) مفهوماً قوياً وأساسياً في جافا، يمنح المطورين القدرة على كتابة كود أكثر مرونة، قابلية للتوسع، وأسهل في الصيانة. من خلال التجاوز (Overriding)، يمكننا تخصيص سلوك الدوال في الفئات الفرعية، بينما يتيح لنا التحميل الزائد (Overloading) توفير واجهات مرنة لدالة واحدة تتعامل مع أنواع مختلفة من المدخلات. أما الأنواع العامة (Generics) فتُكمل الصورة بتوفير أمان النوع في وقت الترجمة، مما يقلل من الأخطاء ويحسن من جودة الكود. إتقان هذه المفاهيم الثلاثة هو مفتاح لتصميم أنظمة برمجية قوية وفعالة في جافا.