تُعدّ البرمجة كائنية التوجه (object-oriented programming - OOP) بمثابة محاولة لتَمْكِين البرامج من نمذجة طريقة تفكيرنا بالعالم الخارجي وتَعامُلنا معه. بالأنماط الأقدم من البرمجة، كان المبرمج مُضطرًّا لتَحْديد المُهِمّة الحاسوبية التي ينبغي له تّنْفيذها لكي يَحلّ المشكلة الفعليّة التي تُواجهه. ووفقًا لهذا التصور، فإن البرمجة تَتكوَّن من محاولة إيجاد متتالية التَعْليمَات المسئولة عن إنجاز تلك المُهِمّة. في المقابل، تُحاوِل البرمجة كائنية التوجه (object-oriented programming) العُثور على عدة كائنات (objects) تُمثِل كيانات (entities) حقيقية أو مُجرّدة مرتبطة بموضوع المشكلة (problem domain). تَملُك تلك الكيانات سلوكيات معينة (behavior)، كما تَتَضمَّن مجموعة من البيانات، وتستطيع أن تَتَفاعَل مع بعضها البعض. ووفقًا لهذا التصور، فإن البرمجة تَتكوَّن من عملية تصميم مجموعة الكائنات (objects) التي يُمكِنها محاكاة المشكلة المطلوب حَلّها. يجعل ذلك تصميم البرامج أكثر طبيعية، وأسهل في الكتابة والفهم عمومًا.
يُمكِننا القول أن البرمجة كائنية التوجه (OOP) هي مجرد تَغيير بوجهات النظر؛ فبحَسَب مفاهيم البرمجة القياسية، قد نُفكِر بالكائن (object) على أساس أنه مُجرّد مجموعة من المُتْغيِّرات والبرامج الفرعية (subroutines) المسئولة عن معالجة تلك المُتْغيِّرات ضُمِّنت معًا بطريقة ما. بل حتى يُمكِننا اِستخدَام الأساليب كائنية التوجه (object-oriented) بأي لغة برمجية، ولكن بالطبع هنالك فرق كبير بين لغة تَسمَح بالبرمجة كائنية التوجه، وآخرى تُدعِّمها بشكل نَشِط. تُدعِّم اللغات البرمجية كائنية التوجه، مثل الجافا، عدة مِيزَات تَجعلها مختلفة عن أي لغة قياسية آخرى، ولكي نَستغِل تلك السمات والميزات بأكبر قدر مُمكن، ينبغي أن نُعيد توجيه تفكيرنا على نحو سليم.
ملحوظة: عادةً ما يُستخدَم مصطلح "التوابع (methods)" للإشارة إلى البرامج الفرعية (subroutines) ضِمْن سياق البرمجة كائنية التوجه (object-oriented)، ولهذا سيَستخدِم هذا الفصل مصطلح "التوابع (method)" بدلًا من مصطلح "البرامج الفرعية (subroutine)".
الكائنات (objects) والأصناف (classes) والنسخ (instances)
لقد تحدثنا عن الأصناف (classes) خلال الفصول السابقة، واَتضح لنا أن الصَنْف يُمكِنه أن يَحتوِي على مُتْغيِّرات وتوابع (methods) يُشار إليها باسم البرامج الفرعية. سنَنتقل الآن إلى الحديث عما يُعرَف باسم الكائنات (objects)، والتي هي وثيقة الصلة بالأصناف. بدايةً، تَعَرَّضنا للكائنات (objects) مرة وحيدة من قَبْل خلال القسم ٣.٩، ولم نَلحظ خلالها فارقًا جوهريًا؛ فلقد اِستبعدنا فقط كلمة static
من تعريف إحدى البرامج الفرعية (subroutine definition). في الواقع، يَتَكوَّن أي كائن من مجموعة من المُتْغيِّرات والتوابع، فكيف يختلف إذًا عن الأصناف (classes)؟ للإجابة على مثل هذا السؤال، ستحتاج إلى طريقة تفكير مختلفة نسبيًا وذلك حتى تُدرِك أهمية الكائنات وتَتَمكَّن من اِستخدَامها بطريقة فعالة.
ذَكَرَنا أن الأصناف تُستخدَم لوصف الكائنات (objects)، أو بتعبير أدق، أن الأجزاء غَيْر الساكنة (non-static) من الأصناف تُستخدَم لوصف الكائنات. قد لا يَكُون المقصود من ذلك واضحًا بما فيه الكفاية، لذا دَعْنَا نُصيغه بطريقة آخرى والتي هي أكثر شيوعًا نوعًا ما. نستطيع أن نقول أن الكائنات تنتمي إلى أصناف (classes)، ولكن ليس بنفس الطريقة التي ينتمي بها مُتْغيِّر عضو معين (member variable) لصنف. فلربما من الأدق أن نقول أن الأصناف تُستخدَم لإنشاء كائنات، فالصَنْف (class) هو أَشْبه ما يَكُون بمصنع أو مُخطط أوَّليّ (blueprint) لإنشاء الكائنات بحيث تُحدِّد الأجزاء غَيْر الساكنة (non-static) منه مجموعة المُتْغيِّرات والتوابع التي يُفْترَض أن تَتَضمَّنها كائنات ذلك الصنف. لذا فإن أحد الاختلافات الجوهرية بين الأصناف والكائنات هو حقيقة أن الكائنات تُنشَئ وتُهدَم أثناء تَشْغِيل البرنامج، بل يُمكِن اِستخدَام صَنْف معين لإنشاء أكثر من كائن (object) بحيث يَكُون لها جميعًا نفس البنية.
لنَفْترِض أن لدينا الصَنْف التالي، والذي يَحتوِي على عدة مُتْغيِّرات أعضاء ساكنة (static member variables). يُمكِننا أن نَستخدِمه لتَخْزِين معلومات عن مُستخدِم البرنامج مثلًا:
class UserData { static String name; static int age; }
عندما يُحمِّل الحاسوب ذلك الصَنْف، فإنه سيُكرِّس جزءًا من الذاكرة له بحيث يَتَضمَّن ذلك الجزء مساحة لقيم المُتْغيِّرين name
و age
، أيّ سيَكُون هناك نسخة وحيدة فقط من كلا المُتْغيِّرين UserData.name
و UserData.age
ضِمْن البرنامج عمومًا. تُبيِّن الصورة التالية تَصوُّرًا للصَنْف داخل الذاكرة:
تُعدّ المُتْغيِّرات الأعضاء الساكنة (static) جزءًا من تمثيل الصنف داخل الذاكرة، ولهذا تُستخدَم الأسماء الكاملة المُتضمِّنة لاسم الصَنْف ذاته مثل UserData.name
و UserData.age
للإشارة إليها. في المثال السابق، قررنا اِستخدَام الصنف UserData
لتمثيل مُستخدِم البرنامج، ولمّا كان لدينا مساحة بالذاكرة لتَخْزِين البيانات المُتعلِّقة بمُستخدِم واحد فقط، فبطبيعة الحال سيَكُون البرنامج قادرًا على حَمْل بيانات مُستخدِم وحيد. لاحِظ أن الصنف UserData
والمُتْغيِّرات الساكنة (static) المُعرَّفة بداخله تَظلّ موجودة طوال فترة تَشْغِيل البرنامج، وهذا هو المقصود بكَوْنها ساكنة بالأساس. لنَفْحَص الآن صَنْفًا مُشابهًا، ولكنه يَتَضمَّن بعضًا من المُتْغيِّرات غَيْر الساكنة (non-static):
class PlayerData { static int playerCount; String name; int age; }
أُضيف إلى الصنف PlayerData
مُتْغيِّر ساكن هو playerCount
أيّ أن اسمه الكامل هو PlayerData.playerCount
، كما أنه مُخزَّن كجزء من تمثيل الصَنْف بالذاكرة، وتَتَوَّفر منه نُسخة واحدة فقط تَظلّ موجودة طوال فترة تَّنْفيذ البرنامج. بالإضافة إلى ذلك، يَتَضمَّن تعريف الصَنْف (class definition) مُتْغيِّرين غَيْر ساكنين (non-static)، ولأن المُتْغيِّرات غَيْر الساكنة لا تُعدّ جزءًا من الصنف ذاته، فإنه لا يُمكِن الإشارة إلى هذين المُتْغيِّرين بالأسماء PlayerData.name
أو PlayerData.age
. نستطيع الآن اِستخدَام الصَنْف PlayerData
لإنشاء كائنات بأيّ قدر نُريده، بحيث يَمتلك كل كائن منها مُتْغيِّراته الخاصة به والتي تَحمِل الأسماء name
و age
، أيّ يَحصُل كل كائن على نسخته الخاصة من أجزاء الصَنْف غَيْر الساكنة (non-static)، وهذا هو ما يَعنِيه القول بأن أجزاء الصَنْف غَيْر الساكنة تُعدّ بمثابة مُخططًا أوَّليًّا أو قالبًا (template) للكائنات. تُبيِّن الصورة التالية تصورًا لذاكرة الحاسوب بَعْد إِنشاء عدة كائنات:
كما ترى بالأعلى، يُعدّ المُتْغيِّر الساكن playerCount
جزءًا من الصنف وتَتَوفَّر منه نُسخة وحيدة، بينما تَتَوفَّر نُسخة خاصة من المُتْغيِّرين غَيْر الساكنين name
و age
بكل كائن (object) مُنشَئ من ذلك الصنف. يُعدّ أيّ كائن (object) نُسخة (instance) من صَنْفه، ويَكُون على دراية بالصَنْف المُستخدَم لإِنشائه. تُبيِّن الصورة أن الصَنْف PlayerData
يَحتوِي على ما يُعرَف باسم "البَانِي أو بَانِي الكائن (constructor)". البَانِي ببساطة هو برنامج فرعي (subroutine) يُستخدَم لإِنشاء الكائنات سنتحدث عنه لاحقًا.
ولأننا نستطيع إِنشاء كائنات (objects) جديدة من الصَنْف PlayerData
لتَمْثيل لاعبين جُدد بقَدْر ما نَشَاء، فقد يُصبِح لدينا الآن مجموعة من اللاعبين. على سبيل المثال، قد يَستخدِم برنامج معين الصَنْف PlayerData
لتَخْزين بيانات مجموعة لاعبين ضِمْن لعبة بحيث يَملُك كُلًا منهم name
و age
خاص به، وعندما يَنضَم لاعب جديد إلى اللعبة، يَستطيع البرنامج أن يُنشِئ كائنًا جديدًا من الصَنْف PlayerData
لتمثيل ذلك اللاعب، أما إذا غادر أحد اللاعبين، يَستطيع البرنامج هَدْم الكائن المُمثِل لذلك اللاعب، أي سيَستخدِم ذلك البرنامج شبكة من الكائنات لنَمذجة أحداث اللعبة بصورة ديناميكية (dynamic)، وهو ما لا يُمكِنك القيام به باِستخدَام المُتْغيِّرات الساكنة (static).
يُعدّ أيّ كائن (object) نُسخة (instance) من الصَنْف المُستخدَم لإِنشائه، ونقول أحيانًا بأنه يَنتمي إلى ذلك الصَنْف. يُطلَق اسم مُتْغيِّرات النُسخ (instance variables) على المُتْغيِّرات التي يَتَضمَّنها الكائن بينما يُطلَق اسم توابع النُسخ (instance methods) على التوابع (methods) -أو البرامج الفرعية- ضِمْن الكائن. فمثلًا، إذا اِستخدَمنا الصَنْف PlayerData
المُعرَّف بالأعلى لإِنشاء كائن، فإن ذلك الكائن يُعدّ نُسخة (instance) من الصنف PlayerData
كما يُعدّ كُلًا من name
و age
مُتْغيِّرات نُسخ (instance variable) ضِمْن الكائن.
لا يَتَضمَّن المثال بالأعلى أيّ توابع (methods)، ولكنها تَعَمَل عمومًا بصورة مشابهة للمُتْغيِّرات أيّ تُعدّ التوابع الساكنة (static) جزءًا من الصَنْف ذاته بينما تُعدّ التوابع غَيْر الساكنة (non-static) أو توابع النُسخ (instance methods) جزءًا من الكائنات (objects) المُنشئة من ذلك الصَنْف. لكن، لا تَأخُذ ذلك بمعناه الحرفي؛ فلا يَحتوِي كل كائن على نُسخته الخاصة من شيفرة توابع النُسخ (instance method) المُعرَّفة ضِمْن الصَنْف بشكل فعليّ وإنما يُعدّ ذلك صحيحًا على نحو منطقي فقط، وعمومًا سنستمر بالإشارة إلى احتواء كائن معين على توابع نُسخ (instance method) صَنْفه.
ينبغي عمومًا أن تُميز بين الشيفرة المصدرية (source code) للصَنْف والصَنْف ذاته بالذاكرة. تُحدِّد الشيفرة المصدرية كُلًا من هيئة الصَنْف وهيئة الكائنات المُنشئة منه: تُحدِّد التعريفات الساكنة (static definitions) بالشيفرة العناصر التي ستُصبِح جُزءًا من الصَنْف ذاته بذاكرة الحاسوب بينما تُحدِّد التعريفات غَيْر الساكنة (non-static definitions) بالشيفرة العناصر التي ستُصبِح جُزءًا من كل كائن يُنشَئ من ذلك الصنف. بالمناسبة، يُطلَق اسم مُتْغيِّرات الأصناف (class variables) وتوابع الأصناف (class methods) على المُتْغيِّرات الأعضاء (member variables) الساكنة والبرامج الفرعية الأعضاء (member subroutines) الساكنة المُعرَّفة ضِمْن الصنف على الترتيب؛ وذلك لكَوْنها تنتمي للصَنْف ذاته وليس لنُسخ (instances) ذلك الصَنْف.
يَتَّضِح لنا الآن الكيفية التي تَختلِف بها الأجزاء الساكنة من صنف معين عن الأجزاء غَيْر الساكنة، حيث يَخدِم كُلًا منهما غرضًا مُختلفًا تمامًا. وفي العموم، ستَجِدْ أن كثيرًا من الأصناف إِما أنها تَحتوِي على أعضاء ساكنة (static) فقط أو على أعضاء غَيْر ساكنة (non-static) فقط، ومع ذلك سنَرى أمثلة قليلة لأصناف تَحتوِي على مَزيج منهما.
أساسيات الكائنات
اِقْتصَر حديثنا إلى الآن على الكائنات (objects) في العموم دون ذِكْر أيّ تفاصيل عن طريقة اِستخدَامها بصورة فعليّة ضِمْن برنامج، لذا سنَفْحَص مثالًا مُحدَّدًا لنوضح ذلك. لنَفْترِض أن لدينا الصَنْف Student
المُعرَّف بالأسفل، والذي يُمكِننا اِستخدَامه لتَخْزِين بعض بيانات الطلبة المُلتحِقة بدورة تدريبية:
public class Student { public String name; // اسم الطالب public double test1, test2, test3; // الدرجة بثلاثة اختبارات public double getAverage() { // احسب متوسط درجة الاختبارات return (test1 + test2 + test3) / 3; } } // نهاية الصنف Student
لم يُستخدَم المُبدِّل static
أثناء التَّصْريح عن أيّ من أعضاء الصَنْف Student
، لذا فالصَنْف موجود فقط بغَرْض إِنشاء الكائنات (objects). يُحدِّد تعريف الصَنْف Student
-بالأعلى- أن كائناته أو نُسخه (instance) ستَحتوِي على مُتْغيِّرات النُسخ (instance variables) name
و test1
و test2
و test3
كما ستَحتوِي على تابع نسخة (instance method) هو getAverage()
. سيَحتوِي كل كائن -يُمثِل طالبًا- في العموم على اسم طالب معين ودرجاته، وعندما يُستدعَى التابع getAverage()
لكائن معين، ستُحسَب قيمة المتوسط باِستخدَام درجات الاختبار ضِمْن ذلك الكائن تَحْديدًا أيّ سيَحصُل كل كائن على قيمة متوسط مختلفة، وهذا هو في الواقع ما يَعنِيه انتماء توابع النُسخ (instance method) للكائنات المُفردة لا الأصناف نفسها.
الأصناف بلغة الجافا هي أنواع تمامًا كالأنواع المَبنية مُسْبَقًا (built-in) مثل int
و boolean
، لذا تستطيع اِستخدَام اسم صَنْف معين لتَحْدِيد نوع مُتْغيِّر ضِمْن تَعْليمَة تَّصْريح (declaration statement) أو لتَخْصِيص نوع مُعامِل صُّوريّ (formal parameter) أو لتَحْدِيد نوع القيمة المُعادة (return type) من دالة (function). يُمكِن مثلًا تعريف مُتْغيِّر اسمه std
من النوع Student
باِستخدَام التَعْليمَة التالية:
Student std;
انتبه للنقطة الهامة التالية، لا يُؤدي تَّصْرِيحك (declare) عن مُتْغيِّر نوعه عبارة عن صنف إلى إِنشاء الكائن ذاته! تَرتبط هذه النقطة بالحقيقتين التاليتين: لا يُمكِن لأيّ مُتْغيِّر أن يَحمِل كائنًا. يستطيع المُتْغيِّر أن يَحمِل مَرجِعًا (reference) يُشير إلى كائن.
يُفضَّل أن تُفكِر بالكائنات (objects) كما لو أنها تَعيش مُستقلة بذاكرة الحاسوب، تحديدًا بقسم الكَوْمة (heap) من الذاكرة. لا تَحمِل المُتْغيِّرات كائنية النوع (object type) قيم الكائنات نفسها وإنما تَحمِل المعلومات الضرورية للعُثور على تلك الكائنات بالذاكرة، تَحْديدًا تَحمِل عناوين مَواضِعها (location address) بالذاكرة، ويُقال عندها أن المُتْغيِّر يَحمِل مَرجِعًا (reference) أو مُؤشرًا (pointer) إلى الكائن. عندما تَستخدِم مُتْغيِّر كائني النوع (object type)، يَستخدِم الحاسوب المَرجِع (reference) المُخْزَّن بالمُتْغيِّر للعُثور على قيمة الكائن الفعليّة.
يُنشِئ العَامِل (operator) new
كائنًا جديدًا (object) حيث يَستدعِى برنامجًا فرعيًا -ذكرناه سابقًا- يقع ضِمْن الصَنْف يُطلَق عليه اسم البَانِي (constructor)، ثم يُعيد مَرجِعًا (reference) يُشير إلى الكائن المُنشَىء. لنَفْترِض مثلًا أن لدينا مُتْغيِّر std
من النوع Student
المُصرَّح عنه بالأعلى، يُمكِننا الآن كتابة تَعْليمَة الإِسْناد التالية (assignment statement):
std = new Student();
تُنشِئ تَعْليمَة الإِسْناد -بالأعلى- كائنًًا (object) جديدًا بقسم الكَوْمة (heap) من الذاكرة، والذي يُعدّ نُسخة (instance) من الصَنْف Student
، ثم تُخزِّن التَعْليمَة مَرجِعًا (reference) إلى ذلك الكائن بالمُتْغيِّر std
أيّ أن قيمة المُتْغيِّر عبارة عن مَرجِع (reference) أو مُؤشر (pointer) إلى الكائن الواقع بمكان ما. لا يَصِح في العموم أن تقول "قيمة المُتْغيِّر std
هي الكائن ذاته" على الرغم من صعوبة تَجنُّب ذلك أحيانًا، ولكن لا تَقل أبدًا أن "الكائن مُخزَّن بالمُتْغيِّر std
" فهو أمر غَيْر صحيح على الإطلاق. يُفْترَض عمومًا أن تقول "يُشير المُتْغيِّر std
إلى الكائن"، وهو ما سنُحاول الالتزام به قَدْر الإِمكان. لاحِظ أنه إذا ذَكَرَنا مثلًا أن "std
هو كائن"، فالمقصود هو "std
هو مُتْغيِّر يُشيِر إلى كائن".
لنَفْترِض أن المُتْغيِّر std
يُشير إلى كائن (object) عبارة عن نُسخة (instance) من الصَنْف Student
أيّ يَحتوِي ذلك الكائن بطبيعة الحال على مُتْغيِّرات النُسخ name
و test1
و test2
و test3
. يُمكِننا الإشارة إلى تلك المُتْغيِّرات باِستخدَام std.name
و std.test1
و std.test2
و std.test3
على الترتيب. يَتَّبِع ذلك نفس نَمْط التسمية المُعتاد بأنه عندما يَكُون B
جزءًا من A
، فإن اسم B
الكامل هو A.B
. اُنظر المثال التالي:
System.out.println("Hello, " + std.name + ". Your test grades are:"); System.out.println(std.test1); System.out.println(std.test2); System.out.println(std.test3);
ستَطبَع الشيفرة بالأعلى الاسم وقيم الدرجات بالكائن المُشار إليه باِستخدَام المُتْغيِّر std
. بنفس الأسلوب، يَحتوِي ذلك الكائن على تابع نسخة هو getAverage()
، والذي يُمكِننا استدعائه باِستخدَام std.getAverage()
. يُمكِنك كتابة الشيفرة التالية لطباعة مُتوسط درجات الطالب:
System.out.println( "Your average is " + std.getAverage() );
تستطيع عمومًا اِستخدَام std.name
أينما أَمَكَن اِستخدَام مُتْغيِّر من النوع String
. فمثلًا، قد تَستخدِمه ضِمْن تعبير (expression) أو قد تُسنِد قيمة إليه أو قد تَستخدِمه حتى لاستدعاء أحد البرامج الفرعية (subroutines) المُعرَّفة بالصَنْف String
. فمثلًا، يُمثِل التعبير std.name.length()
عدد المحارف باسم الطالب.
قد لا تُشير مُتْغيِّرات الكائنات (object variables) أيّ تلك التي نوعها هو عبارة عن صَنْف -مثل المُتْغيِّر std
- إلى أيّ كائن نهائيًا، وفي تلك الحالة، يُقال أنها تَحمِل مُؤشرًا فارغًا (null pointer) أو مَرجِعًا فارغًا (null reference). يُكْتَب المُؤشر الفارغ (null pointer) باِستخدَام كلمة null
، ولهذا تستطيع اِستخدَام الشيفرة التالية لتَخْزِين مَرجِع فارغ بالمُتْغيِّر std
:
std = null;
في المثال بالأعلى، لا يَحتوِي المُتْغيِّر على مُؤشر لأيّ شيء آخر وإنما تُعدّ null
هي قيمته الفعليّة، لذا لا يَصِح أن تقول: "يُشير المُتْغيِّر إلى القيمة الفارغة (null)"، فالمُتْغيِّر نفسه هو قيمة فارغة. تستطيع اختبار ما إذا كانت قيمة المُتْغيِّر std
فارغة باِستخدَام التالي:
if (std == null) . . .
عندما تَكُون قيمة مُتْغيِّر كائن (object variable) فارغة أيّ تَحتوِي على null
، يَعنِي ذلك أن المُتْغيِّر لا يُشير إلى أيّ كائن، وبالتالي لا يَكُون هناك أيّ مُتْغيِّرات نُسخ (instance variables) أو توابع نُسخ (instance methods) يُمكِن الإشارة إليها، ويَكُون من الخطأ محاولة القيام بذلك عبر ذاك المُتْغيِّر. فمثلًا، إذا كانت قيمة المُتْغيِّر std
هي null
، وحَاوَل برنامج معين الإشارة إلى std.test1
، سيُؤدِي ذلك إلى محاولة اِستخدَام مؤشر فارغ (null pointer) بطريقة خاطئة، ولهذا سيُبلَّغ عن اِعتراض مُؤشِر فارغ (null pointer exception) من النوع NullPointerException
أثناء تّنْفيذ البرنامج.
لنَفْحَص مُتتالية التَعْليمَات التالية:
Student std, std1, // صرح عن أربعة متغيرات من النوع Student std2, std3; // أنشئ كائن جديد من الصنف Student وخزن مرجعه بالمتغير std std = new Student(); // أنشئ كائن آخر من الصنف Student وخزن مرجعه بالمتغير std1 std1 = new Student(); // انسخ مرجع std1 إلى المتغير std2 std2 = std1; // خزن مؤشرا فارغا بالمتغير std3 std3 = null; // اضبط قيم متغيرات الأعضاء std.name = "John Smith"; std1.name = "Mary Jones";
بعدما يُنفِّذ الحاسوب التَعْليمَات بالأعلى، ستُصبِح ذاكرة الحاسوب كالتالي:
يَتَبين لنا التالي بَعْد فَحْص الصورة بالأعلى: أولًا، يَحتوِي أيّ مُتْغيِّر على مَرجِع (reference) إلى كائن ظَهَرَت قيمته ضِمْن الصورة بهيئة سهم يُشير إلى الكائن (object). ثانيًا، يُمكِننا أن نَرَى أن السَلاسِل النصية من النوع String
هي كائنات. ثالثًا، لا يُشير المُتْغيِّر std3
إلى أيّ مكان؛ لأنه فارغ ويَحتوِي على القيمة null
. وأخيرًا، تُشير الأسهم من std1
و std2
إلى نفس ذات الكائن وهو ما يُبرِز النقطة المهمة التالية: عندما نُسنِد مُتْغيِّر كائن (object variable) إلى آخر، يُنسَخ المَرجِع (reference) فقط لا الكائن (object) المُشار إليه بواسطة ذلك المَرجِع.
لنَأخُذ مثلًا التَعْليمَة std2 = std1;
كمثال، لمّا كانت تَعْليمَة الإِسْناد (assignment statement) في العموم تَنسَخ فقط القيمة المُخْزَّنة بمُعاملها الأيمن std1
إلى مُعاملها الأيسر std2
، ولأن القيمة في تلك الحالة هي مُجرَّد مُؤشِر (pointer) إلى كائن وليست الكائن (object) ذاته، فإن تلك التَعْليمَة لم تُنشِئ كائنًا جديدًا، وإنما ضَبَطَت std2
بحيث يُشير إلى نفس الكائن الذي يُشير إليه std1
. يَترتَب على ذلك عدة نتائج قد تَكُون مفاجئة بالنسبة لك. مثلًا، تُعدّ كُلًا من std1.name
و std2.name
أسماءً مختلفة لنفس مُتْغيِّر النُسخة (instance variable) المُعرَّف ضِمْن الكائن الذي يُشير إليه كِلا المُتْغيِّرين std1
و std2
، ولهذا عندما تُسنِد السِلسِلة النصية "Mary Jones" مثلًا إلى std1.name
، ستُصبِح قيمة std2.name
هي أيضًا "Mary Jones". إذا التبس عليك الأمر، حَاوِل فقط أن تَستحضر الحقيقة التالية: "المُتْغيِّر ليس هو الكائن (object)، فالأول يَحمِل فقط مُؤشرًا (pointer) إلى الثاني."
يُستخدَم العَامِلان ==
و !=
لاختبار تَساوِي كائنين أو عدم تَساوِيهما على الترتيب، ولكن لاحِظ أن المَعنَى الدلالي (semantics) وراء تلك الاختبارات مُختلف عما أنت مُعتاد عليه. فمثلًا، يَفْحَص الاختبار if (std1 == std2)
ما إذا كانت القيم المُخْزَّنة بكُلًا من std1
و std2
مُتساوية، ولأن تلك القيم هي مُجرَّد مَراجِع (references) تُشير إلى كائنات وليست الكائنات ذاتها، فإن ذلك الاختبار يَفْحَص ما إذا كان std1
و std2
يُشيران إلى نفس ذات الكائن أيّ ما إذا كانا يُشيران إلى نفس المَوضِع بالذاكرة. لا يُعدّ ذلك مشكلة إذا كان هذا هو ما تُريد اختباره، لكن أحيانًا يَكُون المقصود هو مَعرِفة ما إذا كانت مُتْغيِّرات النُسخ (instance variables) تَحمِل نفس القيم بغَضْ النظر عن انتمائها لنفس ذات الكائن. في تلك الحالات، ستحتاج إلى إجراء الاختبار التالي:
std1.test1 == std2.test1 && std1.test2 == std2.test2 && std1.test3 == std2.test3 && std1.name.equals(std2.name)
ذَكَرَنا مُسْبَقًا أن السَلاسِل النصية من النوع String
هي بالأساس كائنات، كما أَظَهَرنا السِلسِلتين النصيتين "Mary Jones" و "John Smith" بهيئة كائنات بالصورة التوضيحية بالأعلى ولكن بدون البنية الداخلية للنوع String
. تُعدّ تلك السِلاسِل كائنات مميزة، وتُعامَل وفقًا لقواعد صيغة خاصة. كأيّ مُتْغيِّر كائن (object variable)، تَحمِل المُتْغيِّرات من النوع String
مَرجِعًا (reference) إلى سِلسِلة نصية وليس السِلسِلة النصية ذاتها. بُناءً على ذلك، لا يَصِح عادةً اِستخدَام العَامِل ==
لاختبار تَساوِي السَلاسِل النصية. فمثلًا، إذا كان لدينا مُتْغيِّر من النوع String
اسمه هو greeting
ويُشير إلى السِلسِلة النصية "Hello"، فهل يَكُون الاختبار greeting == "Hello"
مُتحقِّقًا؟ في الواقع، يُشير كُلًا من المُتْغيِّر greeting
والسِلسِلة النصية المُجرَّدة "Hello" إلى سِلسِلة نصية تَحتوِي على المحارف H-e-l-l-o. ومع ذلك، فإنهما قد يَكُونان كائنين مختلفين صَدَفَ فقط احتوائهما على نفس المحارف، ولأن ذلك التعبير يَختبِر ما إذا كان greeting
و "Hello" يَحتوِيان على نفس المحارف، وكذلك ما إذا كانا واقْعين بنفس مَوضِع الذاكرة، فإن الاختبار greeting == "Hello"
لا يُعدّ مُتحقِّقًا. في المقابل، تَقْتصِر الدالة greeting.equals("Hello")
على اختبار ما إذا كان greeting
و "Hello" يَحتوِيان على نفس المحارف وهو عادةً ما تَرغَب باختباره. أخيرًا، يُمكِن للمُتْغيِّرات من النوع String
أن تَكُون فارغة أيّ تحتوي على القيمة null
، وفي تلك الحالة، سيَكُون اِستخدَام العَامِل ==
مُناسبًا لاختبار ما إذا كان المُتْغيِّر فارغًا كالتالي greeting == null
. ذَكَرَنا أكثر من مرة أن مُتْغيِّرات الكائنات لا تَحمِل الكائنات (objects) ذاتها، وإنما تَحمِل مَراجِع (references) تُشير إلى تلك الكائنات، وهو ما يَترتَب عليه بعض من النتائج الآخرى التي ينبغي أن تَكُون على دراية بها والتي ستَجِدها منطقية إذا استحضرت بذهنك الحقيقة التالية: "لا تُخْزَّن الكائنات (object) بالمُتْغيِّرات، وإنما بأماكن آخرى تُشير إليها المُتْغيِّرات". النتائج كالتالي:
أولًا، لنَفْترِض أن لدينا مُتْغيِّر كائن (object variable) صُرِّح عنه باِستخدَام المُبدِّل final
أي لا يُمكِن تَغْيير القيمة المُخْزَّنة بذلك المُتْغيِّر نهائيًا بَعْد تهيئته مبدئيًا (initialize)، ولأن تلك القيمة هي مَرجِع (reference) يُشير إلى كائن، فإن المُتْغيِّر سيَستمر بالإشارة إلى نفس الكائن طالما كان موجودًا. مع ذلك، يُمكِن تَعْديل البيانات المُخْزَّنة بالكائن لأن المُتْغيِّر هو ما صُرِّح عنه باِستخدَام المُبدِّل final
وليس الكائن نفسه، لذا يُمكِن كتابة الآتي:
final Student stu = new Student(); stu.name = "John Doe";
ثانيًا، لنَفْترِض أن obj
هو مُتْغيِّر يُشير إلى كائن، ماذا سيَحدُث عند تمرير obj
كمُعامِل فعليّ (actual parameter) إلى برنامج فرعي (subroutine)؟ ببساطة، ستُسنَد قيمة obj
إلى مُعامِل صُّوريّ (formal parameter)، ثم سيُنفَّذ البرنامج الفرعي، ولأن البرنامج الفرعي ليس لديه سوى نُسخة من قيمة المُتْغيِّر obj
، فإنه لن يَستطيع تَغْيير القيمة المُخْزَّنة بالمُتْغيِّر الأصلي. ومع ذلك، لمّا كانت تلك القيمة هي مَرجِع (reference) إلى كائن، سيَتَمكَّن البرنامج الفرعي من تَعْديل البيانات المُخْزَّنة بالكائن. أي أنه وبانتهاء البرنامج الفرعي، فحتمًا ما يزال المُتْغيِّر obj
يُشير إلى نفس الكائن، ولكن البيانات المُخزَّنة بالكائن ربما تَكُون قد تَغيَّرت. لنَفْترِض أن لدينا مُتْغيِّر x
من النوع int
، اُنظر الشيفرة التالية:
void dontChange(int z) { z = 42; } x = 17; dontChange(x); System.out.println(x); // output the value 17.
لاحظ أن البرنامج الفرعي لم يَتَمكَّن من تَغْيير قيمة x
، وهو ما يُكافئ التالي:
z = x; z = 42;
والآن، لنَفْترِض أن لدينا مُتْغيِّر stu
من النوع Student
، اُنظر الشيفرة التالية:
void change(Student s) { s.name = "Fred"; } stu.name = "Jane"; change(stu); System.out.println(stu.name); // output the value "Fred".
في حين لم تَتَغيَّر قيمة stu
، تَغيَّرت قيمة stu.name
، وهو ما يُكافئ كتابة:
s = stu; s.name = "Fred";
الضوابط (setters) والجوالب (getters)
ينبغي أن تُراعِي مسألة التَحكُّم بالوصول (access control) عند كتابة أصناف (classes) جديدة، فتَّصْريحك عن كَوْن العضو (member) عامًا (public) يَجعله قابلًا للوصول من أي مكان بما في ذلك الأصناف الآخرى، في حين أن تَّصْريحك عن كَوْنه خاصًا (private) يجعل اِستخدَامه مَقْصورًا على الصَنْف المُعرَّف بداخله فقط.
يُفضَّل عمومًا التَّصْريح عن غالبية المُتْغيِّرات الأعضاء (member variables) -إن لم يَكُن كلها- على أساس كَوْنها خاصة (private)؛ حتى يَكُون بإمكانك التَحكُّم بما يُمكِن القيام به بتلك المُتْغيِّرات تَحكُّمًا كاملًا. بَعْد ذلك، إذا أردت أن تَسمَح لأصناف آخرى بالوُصول لقيمة أحد تلك المُتْغيِّرات المُصرَّح عنها بكَوْنها خاصة، فيُمكِنك ببساطة كتابة تابع وُصول عام (public accessor method) يُعيد قيمة ذلك المُتْغيِّر. على سبيل المثال، إذا كان لديك صَنْف يَحتوِي على مُتْغيِّر عضو (member variable) خاص اسمه title
من النوع String
، تستطيع كتابة التابع (method) التالي:
public String getTitle() { return title; }
يُعيد التابع -بالأعلى- قيمة المُتْغيِّر العضو title
. اُصطلح على تسمية توابع الوصول (accessor methods) للمُتْغيِّرات وفقًا لنمط معين بحيث يَتكوَّن الاسم من كلمة "get" مَتبوعة باسم المُتْغيِّر بعد تَكْبير حُروفه الأولى (capitalizing)، ولهذا يَتكوَّن اسم تابع وصول (accessor method) المُتْغيِّر title
-بالأعلى- من الكلمتين "get" و "Title" أيّ يُصبح الاسم getTitle()
. ولهذا يُطلَق عادةً على توابع الوصول اسم توابع الجَلْب (getter methods)، لأنها تُوفِّر "تَّصْريح قراءة (read access)" للمُتْغيِّرات. انتبه أنه في حالة المُتْغيِّرات من النوع boolean
، تُستخدَم عادةً كلمة "is" بدلًا من "get" أيّ أنه إذا كان لدينا مُتْغيِّر عضو من النوع boolean
اسمه done
، فإن اسم جَالِبه (getter) قد يَكُون isDone()
.
قد تحتاج أيضًا إلى تَوْفِير "تصريح كتابة (write access)" لمُتْغيِّر خاص (private)؛ حتى تَتَمكَّن الأصناف الآخرى من تَخْصيص قيم جديدة لذلك المُتْغيِّر. تُستخدَم توابع الضَبْط (setter method) -أو تسمى أحيانًا بتوابع التَعْدِيل (mutator method)- لهذا الغرض، وبالمثل من توابع الجَلْب (getter methods)، ينبغي أن يَتكوَّن اسم تابع ضَبْط (setter method) مُتْغيِّر معين من كلمة "set" مَتبوعة باسم ذلك المُتْغيِّر بعد تَكْبير حُروفه الأولى. بالإضافة إلى ذلك، لابُدّ أن يَستقبِل تابع الضَبْط مُعامِلًا (parameter) من نفس نوع المُتْغيِّر. يُمكِن كتابة تابع ضْبْط (setter method) المُتْغيِّر title
كالتالي:
public void setTitle( String newTitle ) { title = newTitle; }
ستحتاج غالبًا إلى كتابة كُلًا من تابعي الجَلْب (getter method) والضَبْط (setter method) لمُتْغيِّر عضو خاص معين؛ حتى تَتَمكَّن الأصناف الآخرى من رؤية قيمة ذلك المُتْغيِّر والتَعْدِيل عليها كذلك. قد تتساءل، لما لا نُصرِّح عن المُتْغيِّر على أساس كَوْنه عامًا (public) من البداية؟ قد يَكُون ذلك منطقيًا إذا كانت الجوالب (getters) والضوابط (setters) مُقْتصرة على قراءة قيم المُتْغيِّرات وكتابتها، ولكنها في الحقيقة قادرة على القيام بأيّ شيء آخر. فمثلًا، يستطيع تابع جَلْب (getter method) مُتْغيِّر معين أن يَحتفظ بعَدَدَ مرات قرائته كالتالي:
public String getTitle() { titleAccessCount++; // Increment member variable titleAccessCount. return title; }
كما يستطيع تابع ضَبْط (setter method) مُتْغيِّر آخر أن يَفْحص القيمة المطلوب إِسْنادها إليه لتَحْديد ما إذا كانت صالحة أم لا كالتالي:
public void setTitle( String newTitle ) { if ( newTitle == null ) // لا تسمح بسَلاسِل نصية فارغة title = "(Untitled)"; // استخدم قيمة افتراضية else title = newTitle; }
يُفضَّل عمومًا كتابة تابعي ضَبْط (setter method) وجَلْب (getter method) المُتْغيِّر حتى لو وَجَدَت أن دورهما مُقْتصِر على قراءة قيمة المُتْغيِّر وكتابتها، فأنت لا تدري، ربما تُغيِّر رأيك مُستقبلًا بينما تُعيد تصميم ذلك الصنف أو تُحسنه. ولأن تابعي الضبط والجلب جزء من الواجهة العامة (public interface) للصنف بعكس المُتْغيِّرات الأعضاء (member variable) الخاصة، فستستطيع ببساطة أن تُعدِّل تَّنْفيذ (implementations) تلك التوابع -هذا إذا كنت قد اِستخدَمتها منذ البداية- بدون تَغْيير الواجهة العامة للصَنْف وبدون أن تُؤثِر على أيّ أصناف أُخرى كانت قد اِستخدَمت ذلك الصَنْف. أما إذا لَمْ تَكُن قد اِستخدَمتها وتُريد ذلك الآن، فستَضطرّ آسفًا إلى التَواصُل مع كل شخص صَدَف وأن اِستخدَم الصَنْف؛ لتُبلِّغه بأن عليه فقط أن يَتَعقَّب كل مَوضِع بشيفرته يَستخدِم فيه ذلك المُتْغيِّر ويُعدِّله بحيث يَستخدِم تابعي الضَبْط (setter method) والجَلْب (getter method) للمُتْغيِّر العضو بدلًا من اسمه.
كملاحظة أخيرة: تَشترِط بعض الخصائص المُتقدمة بلغة الجافا تَسمية توابع الضَبْط (setter methods) والجَلْب (getter methods) وفقًا للنَمْط المذكور بالأعلى، لهذا ينبغي في العموم أن تَتَّبِعه بصرامة. انتبه أيضًا للتالي: في حين أننا قد نَاقشنا تابعي الضَبْط والجَلْب ضِمْن سياق المُتْغيِّرات الأعضاء، فإنه في الواقع يُمكِن تَعْرِيف كُلًا منهما حتى في حالة عدم وجود مُتْغيِّر. يُعرِّف تابعي الضَبْط والجَلْب بالأساس خاصية (property) ضِمْن الصَنْف، والتي قد يُناظرها مُتْغيِّر أو لا. بتعبير آخر، إذا كان لديك صَنْف يَحتوِي على تابع نسخة (instance method) مُعرَّف باِستخدَام المُبدِّلات public void
، وبَصمته (signature) هي setValue(double)
، فإن ذلك الصَنْف حتمًا يَمتلك "خاصية (property)" اسمها value
من النوع double
، وذلك بغض النظر عن وجود مُتْغيِّر عضو (member variable) يَحمِل الاسم value
ضِمْن ذلك الصَنْف من عدمه.
المصفوفات والكائنات
ذَكَرنا بالقسم الفرعي ٣.٨.١ أن المصفوفات (arrays) هي بالأساس كائنات أو ربما كائنات مميزة -تمامًا كالسَلاسِل النصية من النوع String
-، وتُكْتَب وفقًا لقواعد صيغة (syntax) خاصة. فمثلًا تَتَوفَّر نسخة خاصة من العَامِل new
لإنشاء المصفوفات. كأيّ مُتْغيِّر كائن (object variable)، لا يَحمِل أيّ مُتْغيِّر مصفوفة (array variable) قيمة مصفوفة فعليّة وإنما يَحمِل مرجعًا (reference) يُشير إلى كائن مصفوفة (array object) والذي يَكُون مُْخْزَّنًا بجزء المَكْدَس (heap) من الذاكرة. يُمكِن أيضًا لمُتْغيِّر مصفوفة أن يَحمِل القيمة null
في حالة عدم وجود مصفوفة فعليّة. لاحظ أن كل نوع مصفوفة (array type) كالنوع int[]
أو النوع String[]
يُقابله صَنْف (class).
فلنَفْترِض أن list
هو مُتْغيِّر من النوع int[]
. إذا كان ذلك المُتْغيِّر يَحمِل القيمة null
، فسيَكُون من الخطأ أن تحاول قراءة list.length
أو قراءة أيّ من عناصر المصفوفة (array element) list
، وسيَتَسبَّب بحُدوث اعتراض (exception) من النوع NullPointerException
. بفَرْض أن newlist
هو مُتْغيِّر آخر من نفس النوع int[]
، اُنظر التَعْليمَة التالية:
newlist = list;
يَقْتصِر دور تَعْليمَة الإِسْناد (assignment statement) بالأعلى على نَسْخ قيمة المَرجِع (reference) المُخْزَّنة بالمُتْغيِّر list
فقط إلى newlist
. إذا كان المُتْغيِّر list
يَحمِل القيمة null
، فسيَحمِل المُتْغيِّر newlist
القيمة null
أيضًا أما إذا كان المُتْغيِّر list
يُشير إلى مصفوفة، فلن تَنسَخ تَعْليمَة الإِسْناد تلك المصفوفة وإنما ستَضبُط المُتْغيِّر newlist
فقط بحيث يُشير إلى نفس المصفوفة التي يُشير إليها المُتْغيِّر list
. اُنظر الشيفرة التالية كمثال:
list = new int[3]; list[1] = 17; // تشير newlist إلى نفس المصفوفة التي يشير إليها list newlist = list; newlist[1] = 42; System.out.println( list[1] );
لأن list[1]
و newlist[1]
هي مجرد أسماء مختلفة لنفس عنصر المصفوفة، فسيَكُون خَرْج الشيفرة بالأعلى هو 42 وليس 17. قد تَجِدْ كل تلك التفاصيل مُربكة بالبداية، ولكن بمُجرَّد أن تَستحضِر بذهنك أن "أي مصفوفة هي عبارة عن كائن (object) وأن أيّ مُتْغيِّر مصفوفة (array variables) هو فقط يَحمِل مؤشرًا (pointers) إلى مصفوفة"، سيُصبِح كل شيء بديهيًا.
تَنطبِق تلك الحقيقة أيضًا على تمرير مصفوفة كمُعامِل (parameter) إلى برنامج فرعي (subroutine) حيث سيَستقبِل البرنامج في تلك الحالة نُسخة فقط من المُؤشر (pointer) وليس المصفوفة ذاتها، وبذلك سيَملُك مَرجِعًا (reference) إلى المصفوفة الأصلية، وعليه فإن أيّ تَعْدِيلات قد يُجرِيها على عناصر المصفوفة (array elements)، سيَمتدّ أثرها إلى المصفوفة الأصلية وستستمر إلى ما بَعْد انتهاء البرنامج الفرعي.
بالإضافة إلى كَوْن المصفوفات كائنات (objects) بالأساس، فإنها قد تُستخدَم لحَمل كائنات أيضًا، ويَكُون نوع المصفوفة الأساسي (base type) في تلك الحالة عبارة عن صَنْف. في الواقع، لقد تَعرَّضنا لذلك بالفعل عندما تَعامَلنا مع المصفوفات من النوع String[]
، ولكن لا يَقْتصِر الأمر على النوع String
بل تستطيع اِستخدَام أيّ صَنْف آخر. فمثلًا، يُمكِننا إنشاء مصفوفة من النوع Student[]
، والتي نوعها الأساسي هو الصنف Student
المُعرَّف مُسْبَقًا بهذا القسم، وفي تلك الحالة، يَكُون كل عنصر بالمصفوفة عبارة عن مُتْغيِّر من النوع Student
. يُمكِننا مثلًا كتابة الشيفرة التالية لتَخْزين بيانات 30 طالب داخل مصفوفة:
Student[] classlist; // Declare a variable of type Student[]. classlist = new Student[30]; // The variable now points to an array.
تَتَكوَّن المصفوفة -بالأعلى- من 30 عنصر هي classlist[0]
و classlist[1]
وحتى classlist[29]
. عند إنشاء تلك المصفوفة، تُهيَئ عناصرها إلى قيمة مبدئية افتراضية تُساوِي القيمة الفارغة null
في حالة الكائنات، لذلك يُصبِح لدينا 30 عنصر مصفوفة، كُلًا منها أينعم من النوع Student
لكنه يَحتوِي على القيمة الفارغة (null) وليس على كائن فعليّ من النوع Student
. ينبغي أن نُنشِئ تلك الكائنات بأنفسنا كالتالي:
Student[] classlist; classlist = new Student[30]; for ( int i = 0; i < 30; i++ ) { classlist[i] = new Student(); }
وعليه، يُشير كل عنصر مصفوفة classlist
إلى كائن من النوع Student
أيّ يُمكِن اِستخدَام classlist
بأيّ طريقة يُمكِن بها اِستخدَام مُتْغيِّر من النوع Student
. فمثلًا، يُمكِن اِستخدَام classlist[3].name
للإشارة إلى اسم الطالب المُخْزَّن برقم المَوضِع الثالث. كمثال آخر، يُمكِن استدعاء classlist.getAverage()
لحساب متوسط درجات الطالب المُخْزَّن برقم المَوضِع i
.
ترجمة -بتصرّف- للقسم Section 1: Objects, Instance Methods, and Instance Variables من فصل Chapter 5: Programming in the Large II: Objects and Classes من كتاب Introduction to Programming Using Java.
أفضل التعليقات
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.