تُستخدَم الأصناف (classes) لتمثيل مجموعة كائنات (objects) تَتَشارَك نفس البِنْية (structure) والسلوك (behavior). يُحدِّد الصَنْف بِنْية تلك الكائنات عن طريق تَخْصِيصه للمُتْغيِّرات الموجودة ضِمْن أي نسخة (instance) من الصَنْف، كما يُحدِّد سلوكها بتَعْريفه لتوابع نُسخ (instance methods) تُعبر عن سلوك (behavior) تلك الكائنات. هذه الفكرة قوية للغاية، ولكنها مع ذلك مُمكِنة بغالبية اللغات البرمجية التقليدية، فما هي إذًا الفكرة الجديدة التي تُقدِّمها البرمجة كائنية التوجه (object-oriented programming)، وتُفرِّقها عن البرمجة التقليدية؟ في الواقع، تُدعِّم البرمجة كائنية التوجه كُلًا من الوراثة (inheritance) والتعددية الشكلية (polymorphism)، والتي تسمح للأصناف (classes) بتمثيل التشابهات بين مجموعة كائنات تَتَشارَك بعضًا من بِنْيتها وسُلوكها، وليس كلها.
توسيع (extend) الأصناف الموجودة
لابُدّ لأي مبرمج أن يفهم المقصود بكُلًا من المفاهيم التالية: الصَنْف الفرعي (subclass)، والوراثة (inheritance)، والتعددية الشكلية (polymorphism). قد تَستغرِق بعض الوقت حتى تستطيع تَوْظِيف مواضيع مثل الوراثة (inheritance) تَوْظِيفًا فعليًا بعيدًا عن مُجرَّد تَمْديد (extending) بعض الأصناف الموجودة، وهو ما سنُناقشه بالجزء الأول من هذا القسم.
ببداية تَعامُل المبرمجين مع الكائنات، عادةً ما يَقْتصر اِستخدَامهم للأصناف الفرعية على موقف واحد فقط: "التَعْديل على صَنْف موجود بالفعل أو الإضافة إليه بحيث يَتلائَم مع ما يُريدونه". يُعدّ ذلك أكثر شيوعًا من تصميم مجموعة كاملة من الأصناف الأعلى والأصناف الفرعية (subclasses). تستطيع أن تُمدِّد (extend) صَنْف فرعي من صَنْف آخر باستخدام الصيغة التالية:
public class <subclass-name> extends <existing-class-name> { . . // التعديلات والإضافات . }
لنَفْترِض أنك تُريد كتابة برنامج للعبة الورق "بلاك جاك (Blackjack)" تَستخدِم فيه الأصناف Card
و Hand
و Deck
من القسم ٥.٤. أولًا، تَختلِف اليد (hand) بلعبة بلاك جاك نوعًا ما عن اليد في العموم؛ حيث تُحسَب قيمة اليد بلعبة بلاك جاك وِفقًا لقواعد اللعبة، والتي يُمكِن تَلخَّيصها كالتالي: تُحسَب قيمة اليد بجَمْع قيم ورق اللعب باليد، بحيث تَكُون قيمة ورقة اللعب العددية (numeric card)، مثل "ثلاثة" أو "عشرة"، مُساوِية لنفس قيمتها العددية، أما ورقة اللعب المُصورة، مثل "الرجل" أو "الملكة" أو "الملك"، فقيمتها تُساوِي عشرة. وأخيرًا، قيمة ورقة "الآص" قد تُساوِي ١ أو ١١. إنها في العموم تُساوِي ١١ إلا لو أدى ذلك إلى قيمة كلية أكبر من ٢١، مما يَعنِي أن ورقة "الآص" الثانية والثالثة والرابعة قيمها بالضرورة ستُساوِي ١.
أحد الطرائق لمعالجة ما سبق هو توسيع (extend) الصنف Hand
الموجود بإضافة تابع (method) يَحسِب قيمة اليد وفقًا للعبة بلاك جاك. اُنظر تعريف (definition) الصنف:
public class BlackjackHand extends Hand { /** * Computes and returns the value of this hand in the game * of Blackjack. */ public int getBlackjackValue() { int val; // قيمة اليد boolean ace; int cards; // عدد ورق اللعب باليد val = 0; ace = false; cards = getCardCount(); // تابع معرف بالصنف الأعلى for ( int i = 0; i < cards; i++ ) { // أضف قيمة ورقة اللعب الحالية Card card; // ورقة اللعب الحالية int cardVal; // قيمة بلاك جاك لورقة اللعب الحالية card = getCard(i); cardVal = card.getValue(); // قيمة ورقة اللعب if (cardVal > 10) { cardVal = 10; } if (cardVal == 1) { ace = true; // هناك على الأقل ورقة آص } val = val + cardVal; } if ( ace == true && val + 10 <= 21 ) val = val + 10; return val; } // نهاية getBlackjackValue } // نهاية الصنف BlackjackHand
لمّا كان الصَنْف BlackjackHand
هو صَنْف فرعي (subclass) من الصَنْف الأعلى Hand
، فإن أيّ كائن من الصَنْف الفرعي BlackjackHand
سيَتَضمَّن، إلى جانب تابع النسخة getBlackjackValue()
المُعرَّف ضِمْن صَنْفه الفرعي، جميع مُتْغيِّرات النسخ (instance variables) وتوابع النسخ (instance methods) المُعرَّفة بالصَنْف الأعلى Hand
. فمثلًا، إذا كان bjh
مُتْغيِّرًا من النوع BlackjackHand
، يَصِِح اِستخدَام أي من الاستدعاءات التالية: bjh.getCardCount()
و bjh.removeCard(0)
و bjh.getBlackjackValue()
. على الرغم من أن التابعين الأول والثاني مُعرَّفين بالصَنْف Hand
، إلا أنهما قد وُرِثَا إلى الصَنْف BlackjackHand
.
تُورَث المُتْغيِّرات والتوابع من الصَنْف الأعلى Hand
إلى الصَنْف الفرعي BlackjackHand
، ولهذا تستطيع اِستخدَامها بتعريف الصَنْف الفرعي BlackjackHand
كما لو كانت مُعرَّفة فعليًا بذلك الصَنْف، باستثناء تلكم المُعرَّفة باِستخدَام المُبدِّل private
ضِمْن الصَنْف الأعلى؛ حيث يَمنَع ذلك المُبدِّل الوصول إليها حتى من الأصناف الفرعية (subclasses). مثلًا، بتعريف التابع getBlackjackValue()
-بالأعلى-، تَمَكَّنت التَعْليمَة cards = getCardCount();
من استدعاء تابع النسخة getCardCount()
المُعرَّف بالصَنْف Hand
.
يُساعد تَمْديد (extend) الأصناف على الاعتماد على أعمالك السابقة، وفي الواقع لقد كُتبَت الكثير من الأصناف القياسية (standard classes) خصيصًا لتَكُون قاعدةً وأساسًا لإنشاء أصناف فرعية.
تُستخدَم مُبدِّلات الوصول (access modifiers) مثل public
و private
للتَحكُّم بالوصول إلى أعضاء (members) الصَنْف. عندما نَأخُذ الأصناف الفرعية (subclasses) بالحُسبان، يُصبِح من المناسب الحديث عن مُبدِّل وصول آخر تُوفِّره الجافا، هو protected
. عند اِستخدَام ذلك المُبدِّل أثناء التَّصْريح عن تابع (method) أو مُتْغيِّر عضو (member variable) بصَنْف، يُصبِح اِستخدَام ذلك العضو مُمكنًا ضِمْن الأصناف الفرعية لذلك الصَنْف وليس بأي مكان آخر. هنالك استثناء: عندما تَستخدِم المُبدِّل protected
للتّصريح عن عضو (member)، فإنه يَكُون قابلًا للوصول من أي صَنْف بنفس الحزمة (package). ذَكَرَنا من قَبْل أنه في حالة عدم تخصيص أي مُبدِّل وصول (access modifier) لعضو معين، فإنه يُصبِح قابلًا للوصول من جميع الأصناف الواقعة ضِمْن نفس الحزمة وليس بأي مكان آخر. يُعدّ المُبدَّل protected
أكثر تحررًا من ذلك بقليل؛ فبالإضافة إلى الأصناف الواقعة ضِمْن نفس الحزمة، فإنه يجعل العضو أيضًا قابلًا للوصول من الأصناف الفرعية (subclasses) التي لا تقع ضِمْن نفس الحزمة.
عندما تُصرِّح عن تابع أو مُتْغيِّر عضو باستخدام المُبدِّل protected
، يُصبِح ذلك العضو جزءًا من تّنْفيذ (implementation) الصنف لا واجهته (interface) العامة، كما يُسمَح للأصناف الفرعية (subclasses) باِستخدَام ذلك الجزء من التّنْفيذ (implementation) أو تَعْدِيله.
على سبيل المثال، يَتَضمَّن الصَنْف PairOfDice
مُتْغيِّري النسخة die1
و die2
لتمثيل الأعداد الظاهرة على حجرى نَّرد. قد نُصرِّح عنهما باِستخدَام المُبدِّل private
لكي يُصبِح تَعْديل قيمتهما من خارج الصنف مستحيلًا، ثُمَّ نُعرِّف توابع جَلْب (getter methods) للسماح بقراءة قيمتهما من خارج الصنف، لكن لو تَبيَّن لك إمكانية الحاجة لاِستخدَام الصنف PairOfDice
لإنشاء أصناف فرعية (subclasses)، فقد يَكُون من الأفضل حينها السماح لتلك الأصناف الفرعية (subclasses) بتَعْديل قيم تلك الأعداد، أي تعديل قيم تلك المُتْغيِّرات. فمثلًا، يَرسم الصنف الفرعي GraphicalDice
حجري النَّرد، وقد يَضطرّ إلى تعديل قيم تلك الأعداد بتوقيت غَيْر ذلك الذي يُرمَي فيه حجري النَّرد. بدلًا من اِستخدَام المُبدِّل public
في تلك الحالة، قد نَستخدِم المُبدِّل protected
للتَّصْريح عن كُلًا من die1
و die2
، مما سيَسمَح للأصناف الفرعية فقط -دون العالم الخارجي- بتَعْديل قيمتهما. كبديل، قد تُفضِّل تعريف توابع ضَبْط (setter methods) لتلك المُتْغيِّرات ضِمْن الصنف الأعلى، بحيث يُصرَّح عنها باِستخدَام المُبدِّل protected
، وذلك لكي تَتَمكَّن من التَأكُّد من أن القيمة المطلوب إِسْنادها للمُتْغيِّر تقع ضِمْن نطاق يتراوح بين ١ و ٦.
الوراثة (inheritance) وسلالة أصناف (class hierarchy)
يُمكِن لصَنْف معين أن يَرِث جزء أو كل بِنيته (structure)، وسلوكه (behavior) من صَنْف آخر، وهو ما يُعرَف باسم الوراثة (inheritance). يُقال عن الصنف الوَارِث أنه صنفًا فرعيًا (subclass) من الصنف المَورُوث. إذا كان الصنف B
هو صنف فرعي من الصنف A
، فإننا نستطيع أيضًا أن نقول أن الصنف A
هو صنف أعلى (superclass) من الصنف B
. قد يُضيف الصنف الفرعي إلى ما وَرِثه من بنية وسلوك، كما قد يُعدِّل أو يَستبدِل ما وَرِثه من سلوك. تَستخدِم بعض اللغات البرمجية الآخرى، مثل لغة C++، مصطلحات أخرى كالصنف المُشتقّ (derived class) والصنف الأساسي (base class) بدلًا من الصنف الفرعي (subclass) والصنف الأعلى (superclass). عادةً ما تُوضَح العلاقة بين الصنف الفرعي (subclass) والصنف الأعلى (superclass) برسم توضيحي، يقع فيه الصنف الفرعي (subclass) أسفل صنفه الأعلى (superclass) ويَكُون مُتصلًا به، تمامًا كما هو مُوضَح بيسار الصورة التالية:
تُستخدَم الشيفرة التالية لإنشاء صَنْف اسمه B
بحيث يكون صنفًا فرعيًا من صنف آخر اسمه A
:
class B extends A { . . // إضافات أو تعديلات على الموروث من الصنف A . }
يُمكِنك أن تُصرِّح عن عدة أصناف على أساس كَوْنها أصناف فرعية (subclasses) من نفس الصنف الأعلى (superclass)، وتُعدّ الأصناف الفرعية في تلك الحالة "أصناف أخوة (sibling classes)" تَتَشارَك بعضًا من بِنيتها (structure) وسلوكها (behavior)، تَحْديدًا تلك المُوروثة من الصَنْف الأعلى المشترك، فمثلًا، الأصناف B
و C
و D
بيمين الصورة السابقة هي أصناف أخوة (sibling classes). يُمكِن للوراثة (inheritance) أن تتمدَّد أيضًا عَبْر أجيال من الأصناف، فمثلًا، الصنف E
-بالصورة السابقة- هو صنف فرعي من الصنف D
، والذي هو بدوره صنف فرعي من الصنف A
، ويُعدّ الصنف E
عندها صنفًا فرعيًا من الصنف A
حتى وإن لَمْ يَكُن صنفًا فرعيًا مباشرًا. تُشكِّل مجموعة الأصناف تلك سُلالة أصناف (class hierarchy) صغيرة.
مثال
والآن، لنَفْترِض أننا نريد كتابة برنامج ينبغي له التَعامُل مع المركبات المتحركة كالسيارات، والشاحنات، والدراجات النارية (قد يَستخدِمه قسم المركبات المتحركة لأغراض تَعقُّب التسجيلات). يُمكِننا أن نُعرِّف صَنْفًا اسمه Vehicle
لتمثيل جميع أنواع المركبات، ولأن السيارات والشاحنات والدراجات النارية هي أنواع من المركبات، يُمكِننا تمثيلها باستخدام أصناف فرعية (subclasses) من الصنف Vehicle
، كما هو مُوضَح بسُلالة الأصناف (class hierarchy) التالية:
قد يَتَضمَّن الصنف Vehicle
مُتْغيِّرات نُسخ (instance variables) مثل registrationNumber
و owner
، بالإضافة إلى توابع نُسخ (instance methods) مثل transferOwnership()
. ستَملُك جميع المركبات تلك المُتْغيِّرات والتوابع. نستطيع الآن تعريف الأصناف الفرعية (subclasses) الثلاثة Car
و Truck
و Motorcycle
المُشتقّة من الصَنْف Vehicle
بحيث يَحمِل كُلًا منها مُتْغيِّرات وتوابع تَخُصّ ذلك النوع المُحدَّد من المركبات، فمثلًا، قد يُعرَّف الصنف Car
مُتْغيِّر نسخة numberOfDoors
، بينما قد يُعرَّف الصنف Truck
مُتْغيِّر نسخة numberOfAxles
، وقد يُضيف الصنف Motorcycle
مُتْغيِّرًا منطقيًا اسمه hasSidecar
. يُمكِننا التَّصْريح عن تلك الأصناف كالتالي (ببرنامج حقيقي، ستُعرَّف تلك الأصناف عادةً بملفات مُنفصلة وعلى أساس كَوْنها عامة):
class Vehicle { int registrationNumber; Person owner; // (بفرض تعريف الصنف Person) void transferOwnership(Person newOwner) { . . . } . . . } class Car extends Vehicle { int numberOfDoors; . . . } class Truck extends Vehicle { int numberOfAxles; . . . } class Motorcycle extends Vehicle { boolean hasSidecar; . . . }
لنَفْترِض أن myCar
هو مُتْغيِّر من النوع Car
صُرِّح عنه وهُيئ باِستخدَام التَعْليمَة التالية:
Car myCar = new Car();
الآن، نستطيع الإشارة إلى myCar.numberOfDoors
؛ لأن numberOfDoors
مُتْغيِّر نسخة بالصنف Car
. بالإضافة إلى ذلك، لمّا كان الصنف Car
مُشتقًّا من الصنف Vehicle
، فإن أي كائن من الصنف Car
يَملُك بنية وسلوك الكائنات من الصنف Vehicle
، مما يعني إمكانية الإشارة إلى كُلًا من myCar.registrationNumber
و myCar.owner
و myCar.transferOwnership()
.
بالعالم الحقيقي، تُعدّ كُلًا من السيارات والشاحنات والدراجات النارية مركبات، وهو ما أَصبَح مُتحقِّقًا بالبرنامج أيضًا، فأيّ كائن من النوع Car
أو Truck
أو Motorcycle
يُعدّ كذلك كائنًا من النوع Vehicle
تلقائيًا. يَقُودنا ذلك إلى الحقيقة الهامة التالية:
إذا أمكَّن لمُتْغيِّر حَمْل مَرجِع إلى كائن من الصَنْف A
، فحتمًا بإِمكانه حَمْل مَرجِع إلى الكائنات المُنتمية لأيّ من أصناف A
الفرعية.
عمليًا، يَعنِي ذلك إمكانية إِسْناد كائن من الصنف Car
إلى مُتْغيِّر من النوع Vehicle
، كالتالي:
Vehicle myVehicle = myCar;
أو كالتالي:
Vehicle myVehicle = new Car();
بَعْد إجراء أيّ من التَعْليمَتين -بالأعلى-، سيَحمِل المُتْغيِّر myVehicle
مَرجِعًا (reference) إلى كائن من النوع Vehicle
، والذي هو في حقيقته نُسخة (instance) من الصَنْف الفرعي Car
. يُدرك الكائن حقيقة انتماءه للصنف Car
وليس فقط للصنف Vehicle
، كما تُخزَّن تلك المعلومة -أيّ صنف الكائن الفعليّ- كجزء من الكائن نفسه، وتستطيع حتى اختبار ما إذا كان كائن معين ينتمي إلى أحد الأصناف عن طريق العَامِل instanceof
، كالتالي:
if (myVehicle instanceof Car) ...
تَفْحَص التَعْليمَة -بالأعلى- ما إذا كان الكائن المُشار إليه باستخدام المُتْغيِّر myVehicle
هو بالأساس من النوع Car
.
في المقابل، لا تَصِح تَعْليمَة الإِسْناد (assignment) التالية:
myCar = myVehicle; // خطأ
لأن myVehicle
قد يُشير إلى أنواع آخرى غَيْر النوع Car
. يُشبه ذلك المشكلة التي تَعرَّضنا لها بالقسم الفرعي ٢.٥.٦: لن يَسمَح الحاسوب بإِسْناد قيمة من النوع int
إلى مُتْغيِّر من النوع short
؛ لأن ليس كل int
هو short
بالضرورة. بالمثل، فإنه لن يَسمَح بإِسْناد قيمة من النوع Vehicle
إلى مُتْغيِّر من النوع Car
؛ لأن ليس كل كائن من النوع Vehicle
هو كائن من النوع Car
بالضرورة. بإِمكانك تَجاوُز تلك المشكلة عن طريق إجراء عملية التَحْوِيل بين الأنواع (type-casting)، فمثلًا، إذا كنت تَعلَم -بطريقة ما- أن myVehicle
يُشير فعليًا إلى كائن من النوع Car
، تستطيع اِستخدَام (Car)myVehicle
لإجبار الحاسوب على التَعامُل مع myVehicle
كما لو كان من النوع Car
. لذا تستطيع كتابة التالي:
myCar = (Car)myVehicle;
تستطيع حتى الإشارة إلى ((Car)myVehicle).numberOfDoors
. لاحِظ أن الأقواس ضرورية لأسباب تَتَعلَّق بالأولوية (precedence)؛ فالعَامِل .
لديه أولوية أكبر من عملية إجراء التَحْوِيل بين الأنواع (type-cast) أيّ أن التعبير (Car)myVehicle.numberOfDoors
سيُقرأ كما لو كان مَكْتُوبًا على الصورة (Car)(myVehicle.numberOfDoors)
وستُجرَى عندها محاولة للتَحْوِيل من النوع int
إلى Vehicle
وهو أمر مستحيل.
سنَفْحَص الآن مثالًا لكيفية اِستخدَام ما سبق ضِمْن برنامج. لنَفْترِض أننا نُريد طباعة البيانات المُتعلّقة بالكائن المُشار إليه باِستخدَام المُتْغيِّر myVehicle
، فمثلًا، نَطبَع numberOfDoors
إذا كان الكائن من النوع Car
. لا نستطيع ببساطة الإشارة إليه باستخدام myVehicle.numberOfDoors
؛ لأن الصَنْف Vehicle
لا يَتَضمَّن أي numberOfDoors
، وإنما نستطيع كتابة التالي:
System.out.println("Vehicle Data:"); System.out.println("Registration number: " + myVehicle.registrationNumber); if (myVehicle instanceof Car) { System.out.println("Type of vehicle: Car"); Car c; c = (Car)myVehicle; // تحويل للنوع للوصول إلى numberOfDoors System.out.println("Number of doors: " + c.numberOfDoors); } else if (myVehicle instanceof Truck) { System.out.println("Type of vehicle: Truck"); Truck t; t = (Truck)myVehicle; // تحويل للنوع للوصول إلى numberOfAxles System.out.println("Number of axles: " + t.numberOfAxles); } else if (myVehicle instanceof Motorcycle) { System.out.println("Type of vehicle: Motorcycle"); Motorcycle m; m = (Motorcycle)myVehicle; // تحويل للنوع للوصول إلى hasSidecar System.out.println("Has a sidecar: " + m.hasSidecar); }
فيما هو مُتعلِّق بالأنواع الكائنية (object type)، عندما يُجرِي الحاسوب عملية التَحْويل بين الأنواع (type cast)، فإنه يَفْحص أولًا ما إذا كانت تلك العملية صالحة أم لا، فمثلًا، إذا كان myVehicle
يُشير إلى كائن من النوع Truck
، فلا تَصِح العملية (Car)myVehicle
، وعليه، سيُبلَّغ عن اعتراض (exception) من النوع ClassCastException
، ولهذا تَستخدِم الشيفرة -بالأعلى- العَامِل instanceof
لاختبار نوع المُتْغيِّر قَبْل إجراء عملية التحويل بين الأنواع (type cast)؛ لتتجنَّب حُدوث الاعتراض ClassCastException
. لاحِظ أن هذا الفَحْص يَحدُث وقت التنفيذ (run time) وليس وقت التصريف (compile time)؛ لأن النوع الفعليّ للكائن الذي يُشير إليه المُتْغيِّر myVehicle
لا يَكُون معروفًا وقت تصريف البرنامج.
التعددية الشكلية (polymorphism)
لنَفْحص برنامجًا يَتعامَل مع أشكال تُرسَم على الشاشة، والتي قد تَكُون مستطيلة (rectangles)، أو بيضاوية (ovals)، أو مستطيلة بأركان دائرية (roundrect)، ومُلوَّنة جميعًا بألوان مختلفة.
سنَستخدِم الأصناف الثلاثة Rectangle
و Oval
و RoundRect
لتمثيل الأشكال المختلفة بحيث تَرِث تلك الأصناف من صَنْف أعلى (superclass) مُشترك Shape
، وذلك لتمثيل السمات المشتركة بين جميع الأشكال. فمثلًا، قد يَتَضمَّن الصنف Shape
مُتْغيِّرات نُسخ (instance variables) لتمثيل كُلًا من لون الشكل، وموضعه، وحجمه، كما قد يَتَضمَّن توابع نُسخ (instance methods) لتَعْديل قيم تلك السمات. مثلًا، لتَعْديل لون أحد الأشكال، فإننا سنُعدِّل قيمة مُتْغيِّر نُسخة ثم نُعيد رسم الشكل بلونه الجديد:
class Shape { Color color; // لابد من استيرادها من حزمة javafx.scene.paint void setColor(Color newColor) { // تابع لتغيير لون الشكل color = newColor; // غير قيمة متغير النسخة redraw(); // أعد رسم الشكل ليظهر باللون الجديد } void redraw() { // تابع لرسم الشكل ? ? ? // ما الذي ينبغي كتابته هنا؟ } . . . // المزيد من متغيرات النسخ والتوابع } // نهاية الصنف Shape
يُرسَم كل نوع من تلك الأشكال بطريقة مختلفة، ولهذا ستُواجهنا مشكلة بخصوص التابع redraw()
. نستطيع عمومًا استدعاء التابع setColor()
لأي نوع من الأشكال، وبالتالي سيَحتَاج الحاسوب إلى تَّنْفيذ الاستدعاء redraw()
، فكيف له إذًا أن يَعرِف الشكل الذي ينبغي عليه رَسْمه؟ نظريًا، يَطلُب الحاسوب من الشكل نفسه أن يقوم بعملية الرسم، فبالنهاية، يُفْترَض أن يَكُون كل كائن من النوع Shape
على علم بما عليه القيام به لكي يَرسِم نفسه.
عمليًا، يَعنِي ذلك أن كل صنف من الأصناف الثلاثة ينبغي له أن يُعرِّف نسخته الخاصة من التابع redraw()
، كالتالي:
class Rectangle extends Shape { void redraw() { . . . // تعليمات رسم مستطيل } . . . // المزيد من توابع ومتغيرات النسخ } class Oval extends Shape { void redraw() { . . . // تعليمات رسم شكل بيضاوي } . . . // المزيد من توابع ومتغيرات النسخ } class RoundRect extends Shape { void redraw() { . . . // تعليمات رسم مستطيل دائري الأركان } . . . // المزيد من توابع ومتغيرات النسخ }
لنَفْترِض أن someShape
هو مُتْغيِّر من النوع Shape
، أيّ أنه يستطيع الإشارة إلى أيّ كائن ينتمي لأيّ من الأنواع Rectangle
و Oval
و RoundRect
. أثناء تَّنْفيذ البرنامج، قد تَتغيَّر قيمة المُتْغيِّر someShape
، مما يَعنِي أنه قد يُشير إلى كائنات من أنواع مختلفة بأوقات مختلفة! لذا، عندما تُنفَّذ التَعْليمَة التالية:
someShape.redraw();
فإن نسخة التابع redraw
المُستدعَاة فعليًا تَكُون تلك المُتناسبة مع النوع الفعليّ للكائن الذي يُشير إليه المُتْغيِّر someShape
. بالنظر إلى نص الشيفرة فقط، قد لا تَتَمكَّن حتى من مَعرِفة الشكل الذي ستَرسِمه تلك التَعْليمَة حيث يعتمد ذلك بالأساس على القيمة التي تَصادَف أن احتواها someShape
أثناء تَّنْفيذ البرنامج. علاوة على ذلك، لنَفْترِض أن تلك التَعْليمَة مُضمَّنة بحَلْقة تَكْرار (loop) وتُنفَّذ عدة مرات. إذا كانت قيمة someShape
تتغيَّر بينما تُنفَّذ حَلْقة التَكْرار، فقد تَستدعِي نفس تلك التَعْليمَة someShape.redraw();
-أثناء تَّنْفيذها عدة مرات- توابعًا (methods) مختلفة، وتَرسِم أنواعًا مختلفة من الأشكال. يُقال عندها أن التابع redraw()
هو مُتعدِّد الأشكال (polymorphic). في العموم، يُعدّ تابع معين مُتعدد الأشكال (polymorphic) إذا كان ما يُنفِّذه ذلك التابع مُعتمدًا على النوع الفعليّ (actual type) للكائن المُطبقّ عليه التابع أثناء وقت التَّنْفيذ (run time)، وتُعدّ التعددية الشكلية (polymorphism) واحدة من أهم السمات المميزة للبرمجة كائنية التوجه (object-oriented programming).
تَتضِح الصورة أكثر إذا كان لديك مصفوفة أشكال (array of shapes). لنَفْترِض أن shapelist
هو مُتْغيِّر من النوع Shape[]
يُشير إلى مصفوفة قد أُنشأت وهُيأت عناصرها بمجموعة كائنات، والتي قد تَكُون من النوع Rectangle
أو Oval
أو RoundRect
. نستطيع رسم جميع الأشكال بالمصفوفة بكتابة التالي:
for (int i = 0; i < shapelist.length; i++ ) { Shape shape = shapelist[i]; shape.redraw(); }
بينما تُنفَّذ الحلقة (loop) -بالأعلى-، فإن التَعْليمَة shape.redraw()
قد تَرسِم مستطيلًا أو شكلًا بيضاويًا أو مستطيلًا دائري الأركان اعتمادًا على نوع الكائن الذي يُشير إليه عنصر المصفوفة برقم المَوضِع i
.
ربما تَتضِح الفكرة أكثر إذا بدّلنا المصطلحات قليلًا كالتالي: بالبرمجة كائنية التوجه (object-oriented programming)، تُعدّ تَعْليمَة استدعاء أي تابع بمثابة إرسال رسالة إلى كائن، بحيث يَرُد ذلك الكائن على تلك الرسالة بتَّنْفيذ التابع المناسب. مثلًا، التَعْليمَة someShape.redraw();
هي بمثابة رسالة إلى الكائن المُشار إليه باِستخدَام someShape
، ولمّا كان هذا الكائن يَعرِف بالضرورة النوع الذي ينتمي إليه، فإنه يَعرِف الكيفية التي ينبغي أن يَرُد بها على تلك الرسالة. وفقًا لذلك، يُنفِّذ الحاسوب الاستدعاء someShape.redraw();
بنفس الطريقة دائمًا، أيّ يُرسِل رسالة يَعتمِد الرد عليها على المُستقبِل. تَكُون الكائنات (objects) عندها بمثابة كيانات نَشِطة تُرسِل الرسائل وتَستقبِلها، كما تَكُون التعددية الشكلية (polymorphism) -وفقًا لهذا التصور- جزءًا طبيعًيا، بل وضروريًا، فتَعنِي فقط أنه من الممكن لكائنات مختلفة الرد على نفس الرسالة بطرائق مختلفة.
تَسمَح لك التعددية الشكلية (polymorphism) بكتابة شيفرة تُنفِّذ أشياء قد لا تَتَصوَّرها حتى أثناء الكتابة، وهو في الواقع أحد أكثر الأشياء الرائعة المُتعلِّقة بها. لنَفْترِض مثلًا أنه قد تَقرَّر إضافة مستطيلات مشطوفة الأركان (beveled rectangles) إلى أنواع الأشكال التي يُمكِن للبرنامج التَعامُل معها.
لتَّنْفيذ (implement) المستطيلات مشطوفة الأركان، يُمكِننا أن نُضيف صنفًا فرعيًا (subclass) جديدًا، وليَكُن BeveledRect
، مُشتقًّا من الصنف Shape
، ثم نُعرِّف به نسخته من التابع redraw()
. تلقائيًا، ستَجِدْ أن الشيفرة التي كنا قد كتبناها سابقًا، أيّ someShape.redraw()
، قد أَصبَح بإِمكانها فجأة رسم المستطيلات مشطوفة الأركان على الرغم من أن صَنْف تلك المستطيلات لم يَكُن موجودًا من الأساس أثناء كتابة تلك التَعْليمَة.
تُرسَل الرسالة redraw
إلى الكائن someShape
أثناء تَّنْفيذ التَعْليمَة someShape.redraw();
. لنَفْحَص مرة آخرى التابع المسئول عن تَعْديل لون الشكل والمُعرَّف بالصنف Shape
:
void setColor(Color newColor) { color = newColor; // غير قيمة متغير النسخة redraw(); // أعد رسم الشكل ليظهر باللون الجديد }
بالأعلى، تُرسَل الرسالة redraw
ولكن إلى أي كائن؟ حسنًا، يُعدّ التابع setColor
هو الآخر بمثابة رسالة كانت قد أُرْسِلَت إلى كائن معين. لذا، فإن الرسالة redraw
تُرسَل إلى نفس ذلك الكائن الذي كان قد اِستقبَل الرسالة setColor
. إذا كان هذا الكائن مستطيلًا، فإنه يَحتوِي على تابع redraw()
لرسم المستطيلات وذاك التابع هو ما سيُنفَّذ. أما إذا كان هذا الكائن شكلًا بيضاويًا، فسيُنفَّذ التابع redraw()
المُعرَّف بالصنف Oval
. يَعنِي ذلك أنه ليس من الضروري لتَعْليمَة redraw();
بالتابع setColor()
أن تَستدعِي التابع redraw()
المُعرَّف بالصنف Shape
، وإنما قد يُنفَّذ أي تابع redraw()
طالما كان مُعرَّفًا بصنف فرعي مشتق من Shape
. هذه هي فقط حالة آخرى من التعددية الشكلية (polymorphism).
الأصناف المجردة (abstract classes)
وقتما يَضطرّ كائن من الصنف Rectangle
أو Oval
أو RoundRect
إلى رَسْم نفسه، سيُنفَّذ التابع redraw()
المُعرَّف بالصنف المناسب، وهو ما يَقُودنا للسؤال التالي: ما دور التابع redraw()
المُعرَّف بالصنف Shape
؟ وكيف لنا أن نُعرِّفه؟
قد تتفاجئ من الإجابة، ففي الحقيقة ينبغي أن نَترُكه فارغًا لأن الصنف Shape
لا يُمثِل سوى فكرة مُجردة (abstract) لشكل، وبالتأكيد ليس هناك طريقة لرسم شيء كذلك، وإنما يُمكِن فقط رَسْم الأشكال الحقيقية (concrete) كالمستطيلات والأشكال البيضاوية. إذًا، لماذا ينبغي أن نُعرِّف التابع redraw()
بالصنف Shape
من الأساس؟ حسنًا، نحن مُضطرون إلى ذلك وإلا لن يَصِح استدعائه بالتابع setColor()
المُعرَّف بالصنف Shape
كما لن يَصِح كتابة شيئًا مثل someShape.redraw();
؛ حيث سيَعترِض المُصرِّف لكَوْن someShape
مُتْغيِّرًا من النوع Shape
في حين لا يَحتوِي ذلك الصَنْف على التابع redraw()
.
وعليه، لن يُستدعَى التابع redraw()
المُعرَّف بالصنف Shape
نهائيًا، وربما -إذا فكرت بالأمر- ستَجِدْ أنه ليس هناك أي سبب قد يَضطرّنا حتى إلى إنشاء كائن فعليّ من الصنف Shape
. تستطيع بالتأكيد إنشاء مُتْغيِّرات من النوع Shape
، ولكنها دومًا ستُشير إلى كائنات (objects) من الأصناف الفرعية (subclasses) للصَنْف Shape
. في مثل تلك الحالات، يُعدّ الصنف Shape
صنفًا مُجرَّدًا (abstract class). لا تُستخدَم الأصناف المُجرّدة (abstract class) لإنشاء كائنات، وإنما فقط تُمثِل قاعدة وأساسًا لإنشاء أصناف فرعية أيّ أنها موجودة فقط للتعبير عن بعض السمات والخاصيات المشتركة بجميع أصنافها الفرعية (subclasses). أما الصَنْف غَيْر المُجرّد فيُعدّ صنفًا حقيقيًا (concrete class). في العموم، تستطيع إنشاء كائنات تنتمي لأصناف حقيقية (concrete class) وليس لأصناف مُجرّدة (abstract class)، كما يستطيع مُتْغيِّر من نوع هو عبارة عن صنف مُجرّد (abstract class) أن يُشير فقط إلى كائنات تنتمي لأحد الأصناف الفرعية الحقيقية (concrete subclasses) المُشتقّة من ذلك الصنف المُجرّد (abstract class).
بالمثل، يُعدّ التابع redraw()
المُعرَّف بالصنف Shape
تابعًا مجردًا (abstract method)؛ فهو لا يُستدعَى نهائيًا وإنما تُستدعَى توابع redraw()
المُعرَّفة بالأصناف الفرعية للصنف Shape
لرَسْم الأشكال بصورة فعليّة. اِضطرّرنا مع ذلك لتعريف التابع redraw()
بالصَنْف Shape
للتأكيد على فِهم جميع الأشكال للرسالة redraw
، أي أنه موجود فقط لتَخْصِيص الواجهة (interface) المُشتركة لنُسخ التابع redraw()
الفعليّة والحقيقية (concrete) والمُعرَّفة بالأصناف الفرعية، وبالتالي ليس هناك ما يَستدعِي احتواء التابع المُجرَّد redraw()
بالصنف Shape
على أيّ شيفرة.
كما ذَكَرَنا سابقًا، يُعدّ كُلًا من الصنف Shape
وتابعه redraw()
بمثابة أفكارًا مجردةً (abstract) على نحو دلالي فقط حتى الآن، ولكن تستطيع إِعلام الحاسوب بهذه الحقيقة على نحو صياغي (syntactically) أيضًا عن طريق إضافة المُبدِّل abstract
إلى تعريف (definition) كُلًا منهما. بخلاف أي تابع عادي، لا يُكتَب الجزء التنفيذي (implementation) للتوابع المُجرّدة (abstract method) وإنما تُستخدَم فاصلة منقوطة (semicolon). في المقابل، لابُدّ من كتابة الجزء التنفيذي (implementation) للتابع المُجرّد (abstract method) ضِمْن أي صَنْف فرعي حقيقي (concrete subclass) مُشتقّ من الصنف المُجرّد (abstract class). تَستعرِض الشيفرة التالية طريقة تعريف الصَنْف Shape
كصنف مُجرّد (abstract class):
public abstract class Shape { Color color; // لون الشكل void setColor(Color newColor) { // تابع لتغيير لون الشكل color = newColor; // غير قيمة متغير النسخة redraw(); // أعد رسم الشكل ليظهر باللون الجديد } abstract void redraw(); // تابع مجرد ينبغي أن يُعرَّف بالأصناف الفرعية الحقيقية . . . // المزيد من التوابع والنسخ } // نهاية الصنف Shape
بمُجرّد اِستخدَامك للمُبدِّل abstract
أثناء التَّصْريح عن الصَنْف، لا تستطيع إنشاء كائنات فعليّة من النوع Shape
، وسيُبلِّغ الحاسوب عن حُدوث خطأ في بناء الجملة (syntax error) إذا حاولت القيام بذلك.
ربما يُعدّ الصَنْف Vehicle
-الذي ناقشناه بالأعلى- صنفًا مجردًا (abstract class) أيضًا، فليس هناك أي طريقة لامتلاك مركبة كتلك، وإنما لابُدّ للمركبة الفعليّة أن تَكُون سيارة أو شاحنة أو دراجة نارية أو حتى أي نوع حقيقي (concrete) آخر.
ذَكَرنا بالقسم الفرعي ٥.٣.٢ أن أيّ صنف لا يُصرَّح عن كَوْنه صنف فرعي (subclass) من أيّ صنف آخر -أيّ بدون الجزء extends
- فإنه تلقائيًا يُصبِح صنفًا فرعيًا من الصنف القياسي Object
كالتالي:
public class myClass { . . .
يُعدّ التَّصْريح بالأعلى مُكافئًا للتالي تمامًا:
public class myClass extends Object { . . .
يُمكِننا القول إذًا أن الصَنْف Object
يقع بقمة سُلالة أصناف (class hierarchy) ضخمة تَتَضمَّن جميع الأصناف الآخرى. من الناحية الدلالية، يُعدّ الصنف Object
صنفًا مجردًا (abstract class) بل ربما أكثر صنف مجرد على الإطلاق، ومع ذلك، لم يُصرَّح عنه على هذا الأساس من الناحية الصياغية، أيّ تستطيع إنشاء كائنات من النوع Object
ولكن لن تَجِدْ الكثير لتفعله بها.
ولأن أيّ صَنْف هو بالضرورة صنف فرعي (subclass) من الصَنْف Object
، فيُمكِن لمُتْغيِّر من النوع Object
أن يُشير إلى أي كائن مهما كان نوعه. بالمثل، تستطيع مصفوفة من النوع Object[]
أن تَحمِل كائنات من أي نوع.
تَستخدِم الشيفرة المصدرية بالملف ShapeDraw.java
الصَنْف المُجرّد Shape
، بالإضافة إلى مصفوفة من النوع Shape[]
لحَمْل قائمة من الأشكال. قد تَرغَب بإلقاء نظرة على ذلك الملف مع أنك لن تَتَمكَّن من فهمه بالكامل حاليًا؛ فتعريفات الأصناف الفرعية بالملف مختلفة نوعًا ما عن تلك التي رأيتها بالأعلى. مثلًا، يَستقبِل التابع draw()
مُعامِلًا (parameter) من النوع GraphicsContext
؛ لأن الرَسْم بالجافا يَتطلَّب سياقًا رُسوميًا. سنَتَعرَّض لأمثلة شبيهة بالفصول اللاحقة بعدما تَتَعرَف على برمجة واجهات المُستخدِم الرُسومية (GUI). يُمكِنك مع ذلك فَحْص تعريف الصنف Shape
وأصنافه الفرعية (subclasses) بالإضافة إلى طريقة اِستخدَام المصفوفة لحَمْل قائمة الأشكال. تَستعرِض الصورة التالية لقطة للشاشة أثناء تَشْغِيل البرنامج:
بعد تَشْغِيل البرنامج ShapeDraw
، تستطيع أن تُضيف شكلًا جديدًا إلى الصورة عن طريق الضغط على أيّ من الأزرار الموجودة بالأسفل. يُمكِنك أيضًا اختيار لون ذلك الشكل من خلال قائمة الألوان الموجودة أسفل مساحة الرسم (drawing area). سيَظهَر الشكل الجديد بالركن الأيسر العلوي من مساحة الرسم (drawing area)، وبمُجرّد ظُهوره، تستطيع سَحْبه باستخدَام الفأرة. سيُحافِظ الشكل دومًا على ترتيبه بالنسبة لبقية الأشكال الموجودة على الشاشة حتى أثناء عملية السَحْب، ومع ذلك، تستطيع اختيار شكل معين ليُصبِح أمام جميع الأشكال الأخرى عن طريق الضغط باستمرار على المفتاح "shift" بينما تَضغَط على ذلك الشكل.
في الحقيقة، يُستخدَم الصنف الفعليّ للشكل أثناء إضافته إلى الشاشة فقط. بعد ذلك، يُعالَج بالكامل على أساس كَوْنه شكلًا مجردًا (abstract). على سبيل المثال، يَتعامَل البرنامج (routine) المسئول عن عملية السَحْب مع مُتْغيِّرات من النوع Shape
، ولا يُشير نهائيًا إلى أي من الأصناف الفرعية (subclasses). عندما يحتاج إلى رَسْم شكل، فإنه فقط يَستدعِي التابع draw
أي أنه غَيْر مضطرّ لمَعرِفة طريقة رَسْم الشكل أو حتى مَعرِفة نوعه الفعليّ، وإنما تُوكَل عملية الرسم إلى الكائن ذاته. إذا أردت أن تُضيف نوعًا جديدًا من الأشكال إلى البرنامج، كل ما عليك القيام به هو الآتي: أولًا، ستُعرِّف صنفًا فرعيًا (subclass) جديدًا مُشتقًّا من Shape
، ثم ستُضيف زرًا جديدًا وتُبرمجه بحيث يُضيف الشكل إلى الشاشة.
ترجمة -بتصرّف- للقسم Section 5: Inheritance, Polymorphism, and Abstract Classes من فصل Chapter 5: Programming in the Large II: Objects and Classes من كتاب Introduction to Programming Using Java.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.