رضوى العربي
الأعضاء-
المساهمات
114 -
تاريخ الانضمام
-
تاريخ آخر زيارة
نوع المحتوى
ريادة الأعمال
البرمجة
التصميم
DevOps
التسويق والمبيعات
العمل الحر
البرامج والتطبيقات
آخر التحديثات
قصص نجاح
أسئلة وأجوبة
كتب
دورات
كل منشورات العضو رضوى العربي
-
يُمكِننا التفكير بمصفوفةٍ مكونةٍ من N عنصر كما لو كانت طريقةً لربط عنصرٍ معينٍ بالأعداد الصحيحة 0، و 1، وصولًا إلى N-1. إذا كان i أحد تلك الأعداد الصحيحة، يُمكِننا استرجاع get القيمة المرتبطة بالعدد i، كما يُمكِننا وضع put عنصرٍ جديدٍ في الموضع i، حيث تُعرِّف العمليتان get و put ماهية المصفوفة. تُعدّ الخرائط maps نوعًا عامًا من المصفوفة؛ حيث يُمكِننا تعريفها باستخدام عمليتي get و put، إلا أنّ هذه العمليات لا تَكون مُعرَّفةً للأعداد الصحيحة 0، و 1، وصولًا إلى N-1، وإنما تَكون مُعرَّفةً لكائناتٍ objects عشوائية من نوع T، كما يرتبط بكل كائنٍ منها كائنٌ من نوعٍ آخر مختلف S. تستخدِم بعض لغات البرمجة مصطلح المصفوفة الارتباطية associative array بدلًا من مصطلح الخريطة، كما تَستخدِم نفس الترميز مع المصفوفات العادية والارتباطية. فقد ترى ترميزًا، مثل A["fred"] للإشارة إلى العنصر المرتبط بالسلسلة النصية "fred" بمصفوفةٍ ارتباطية A. لا تَستخدِم جافا نفس الترميز العادي مع الخرائط، ولكن الفكرة تبقى واحدةً بالنهاية؛ حيث تُشبه الخريطة أي مصفوفة، ولكن تكون فهارسها indices كائناتٍ objects وليس أعدادًا صحيحة. يُطلَق على الكائن الذي يعمل مثل فهرسٍ index ضمن خريطة اسم مفتاح key؛ أما العنصر المرتبط بالمفتاح، فيُطلَق عليه اسم قيمة value. يُقابل كل مفتاحٍ قيمةً واحدةً على الأكثر، ولكن يُمكِن لنفس القيمة الارتباط بعدة مفاتيحٍ مختلفة. يُمكنك أن تنظر للخريطة على أنها مجموعةٌ من الارتباطات associations، حيث يُمثِّل كل ارتباطٍ زوج مفتاحٍ وقيمة key/value pair. واجهة تمثيل الخرائط تُوفِّر جافا الواجهة java.util.Map لتمثيل الخرائط، حيث تتضمَّن تلك الواجهة التابعين get و put بالإضافة إلى عدة توابعٍ أخرى للعمل مع الخرائط في العموم. تُعدّ الواجهة Map<K,V> من الأنواع ذات المعاملات غير محدَّدة النوع parameterized، وتَملُك تحديدًا معاملي نوع، الأول هو K، والثاني هو V؛ حيث يُخصِّص K نوع الكائن المُستخدَم مثل مفتاح بالخريطة؛ بينما يُخصِّص V نوع الكائن المُستخدَم مثل قيمة. على سبيل المثال، تَربُط خريطةٌ من النوع Map<Date,Button> قيمًا من النوع Button بمفاتيحٍ من النوع Date؛ بينما تَربُط خريطةٌ من النوع Map<String,String> قيمًا بمفاتيحٍ من نفس النوع String. نستعرِض فيما يلي بعضًا من التوابع المتاحة لمُتغيّر map يُمثِل خريطةً من النوع Map<K,V> لنوعين K و V: map.get(key): يُعيد كائنًا من النوع V يُمثِّل القيمة المرتبطة بالمفتاح key؛ ويُعيد القيمة الفارغة null إذا لم تحتوي الخريطة على قيمةٍ مقابلةٍ للمفتاح المُمرَّر، أو في حالة كانت القيمة الفارغة مرتبطةً صراحةً بذلك المفتاح. يُشبه كثيرًا استدعاء map.get(key) لخريطة map استخدام A[key] مع مصفوفة A، ولكن لا يحدث اعتراضٌ exception من النوع IndexOutOfBoundsException في حالة الخرائط. map.put(key,value): يَربُط قيمة value المُمرَّرة مع المفتاح key، حيث يجب أن يكون key من النوع K، وأن يَكون value من النوع V. إذا كانت الخريطة تَربُط بالفعل قيمةً ما مع نفس المفتاح المُخصَّص، يَستبدِل التابع القيمة الجديدة بالقيمة القديمة، ويُشبه ذلك الأمر A[key] = value المُستخدَم مع المصفوفات. map.putAll(map2): إذا كانت map2 خريطةً أخرى من النوع Map<K,V>، فسينسخ التابع جميع القيم الموجودة بها إلى map. map.remove(key): إذا كانت map تَربُط قيمةً معينةً بالمفتاح key، فسيحذف التابع هذا الارتباط من الخريطة map. map.containsKey(key): يُعيد القيمة المنطقية true إذا كانت الخريطة map تَربُط قيمةً معينةً بالمفتاح المُمرَّر key. map.containsValue(value): يُعيد القيمة المنطقية true إذا كانت الخريطة map تَربُط القيمة المُمرَّرة value بأي مفتاحٍ ضمن الخريطة. map.size(): يُعيد قيمةً من النوع int تُمثِّل عدد الارتباطات بين المفاتيح والقيم الموجودة بالخريطة map. map.isEmpty(): يُعيد القيمة المنطقية true إذا كانت الخريطة map فارغةً، أي لا تَربُط أي قيمٍ بأي مفاتيح. map.clear(): يحذف جميع الارتباطات الموجودة بالخريطة map. يُعدّ التابعان put و get أكثر التوابع استخدامًا من بين التوابع الأخرى المُعرَّفة بالواجهة Map، حيث يقتصر استخدام الكثير من التطبيقات للخرائط على هذين التابعين فقط دون غيرهما، ويكون عندها استخدام الخريطة بنفس سهولة استخدام أي مصفوفةٍ عادية. تُوفِّر جافا الصنفين TreeMap<K,V> و HashMap<K,V> المُنفِّذين للواجهة Map<K,V>، حيث تُخزِّن الخرائط من الصنف TreeMap ارتباطات المفاتيح بالقيم key/value associations ضمن شجرة tree، وتكون الارتباطات مُرتَّبةً بحسب مفاتيحها. يَعنِي ذلك ضرورة إمكانية موازنة مفتاحٍ بآخر، أي يجب أن تُنفِّذ أصناف المفاتيح الواجهة Comparable<K>، أو أن نُوفِّر كائنًا من النوع Comparator لإجراء الموازنة من خلال تمريره معاملًا لباني الصنف TreeMap. تستخدم الخرائط من النوع TreeMap التابع compareTo()، أو compare() كما هو الحال مع الأطقم من النوع TreeSet للموازنة بين مفتاحين، وهو ما قد يَتسبَّب بنتائجٍ غير مُتوقَّعة إذا لم يَكُن التابع compareTo() مُعرَّفٌ بما يتوافق مع مفهوم التساوي. لا تُخزِّن الخرائط من النوع HashMap الارتباطات وفقًا لأي ترتيبٍ معين، ولذلك ليس من الضروري لأصناف المفاتيح المُستخدَمة أن تكون قابلة للموازنة، لكن يتوجب عليها تعريف التابعين equals() و hashCode() تعريفًا ملائمًا، وهو ما تَضمَنه غالبية أصناف جافا القياسية. تُعدّ غالبية العمليات على الخرائط من الصنف HashMap أكثر كفاءةً عمومًا بالموازنة مع نظيراتها بالصنف TreeMap، لذلك اِستخدِم الصنف HashMap، خاصةً إذا كان استخدامك للخريطة مقتصرًا على التابعين put و get؛ واِستخدِم الصنف TreeMap إذا كنت تحتاج إلى خاصية الترتيب. لنفحص الآن مثالًا على استخدام الخرائط. تعرَّضنا في مقال البحث والترتيب في المصفوفات Array في جافا للصنف PhoneDirectory المُستخدَم لربط أرقام الهواتف بأسماء الأشخاص، حيث يُعرِّف ذلك الصنف العمليتين التاليتين: addEntry(name,number) getNumber(name) حيث name و number من النوع String. يشبه الصنف PhoneDirectory خريطةً يؤدي تابعيها addEntry و getNumber دور عمليتي put و get على الترتيب.، ولا نُعرِّف عادةً بأي تطبيقٍ حقيقي مثل ذلك الصنف، وإنما نَستخدِم ببساطةٍ خريطةً من النوع Map<String,String> على النحو التالي: Map<String,String> directory = new TreeMap<>(); لاحِظ أننا استخدمنا الصنف TreeMap حتى تكون أرقام الهواتف مُرتَّبةً بحسب أسماء الأشخاص، ويُمكِننا الآن ببساطة إضافة رقم هاتف إلى الخريطة باستدعاء directory.put(name,number) أو استرجاع رقم الهاتف المرتبط باسمٍ معينٍ باستدعاء directory.get(name). العروض والأطقم الجزئية والخرائط الجزئية لا تُعدّ الخرائط من النوع Map تجميعاتٍ من النوع Collection، لعدم تنفيذ الخرائط جميع العمليات المُعرَّفة بالتجميعات.لا تحتوي الخرائط مثلًا على مُكرِّرات iterators، ولكن قد نحتاج في بعض الأحيان إلى المرور عبر جميع الارتباطات الموجودة ضمن خريطةٍ معينة، وهو ما تُوفِّره جافا لحسن الحظ. بفرض أن map مُتغيّرٌ من النوع Map<K,V>، فسيُعيد التابع التالي طقمًا يحتوي على جميع الكائنات المُمثِلة لمفاتيح الارتباطات ضمن الخريطة map: map.keySet() تَكون القيمة المعادة كائنًا مُنفِّذًا للواجهة Set<K>، تُمثِّل عناصره مفاتيح الخريطة. قد تظن أن التابع keySet() يُنشِئ طقمًا جديدًا، ويُضيف إليه جميع مفاتيح الخريطة، ثم يُعيده، ولكن هذا غير صحيح؛ فليس الكائن الذي يُعيده الاستدعاء map.keySet() كائنًا مستقلًا، وإنما هو بمثابة عرض view للكائنات الفعلية المُخزَّنة بالخريطة. على الرغم من تنفيذ العرض للواجهة Set<K>، إلا إنه يُنفِّذها بحيث تشير التوابع المُعرَّفة ضمنه إلى مفاتيح الخريطة مباشرةً. إذا حذفت مفتاحًا من عرضٍ على سبيل المثال، فسيُحذف أيضًا مع قيمته value المرتبط بها من الخريطة. في المقابل، لا يُمكِنك إضافة كائنٍ إلى عرض؛ لأن عملية إضافة مفتاح بدون تخصيص قيمته المرتبط بها لا يكون لها معنى. بناءً على ما سبق، يَعمَل التابع map.keySet() بكفاءةٍ عاليةٍ حتى مع الخرائط الكبيرة. إذا كان لديك طقمٌ من النوع Set، يُمكِنك بسهولةٍ الحصول على مُكرّرٍ من النوع Iterator، واستخدامه للمرور عبر جميع عناصر ذلك الطقم واحدًا تلو الآخر؛ وتستطيع كذلك استخدِام مُكرِّرٍ للطقم المُمثِّل لمفاتيح خريطة للمرور عبر جميع الارتباطات الموجودة بها. فإذا كانت map خريطةً من النوع Map<String,Double>، يُمكِننا كتابة ما يَلي: Set<String> keys = map.keySet(); // The set of keys in the map. Iterator<String> keyIter = keys.iterator(); System.out.println("The map contains the following associations:"); while (keyIter.hasNext()) { String key = keyIter.next(); // استرجع المفتاح التالي Double value = map.get(key); // استرجع قيمة ذلك المفتاح System.out.println( " (" + key + "," + value + ")" ); } أو قد نتجنَّب الاستخدام الصريح للمُكرّر باستخدام حلقة التكرار for-each على النحو التالي: System.out.println("The map contains the following associations:"); for ( String key : map.keySet() ) { // "for each key in the map's key set" Double value = map.get(key); System.out.println( " (" + key + "," + value + ")" ); } إذا كانت map من النوع TreeMap، تكون مفاتيحها مُرتّبةً بالطقم، ويَمُرّ المُكرِّر بناءً على ذلك على المفاتيح بحسب ترتيبها التصاعدي؛ أما إذا كانت من النوع HashMap، يمر بها المُكرِّر مرورًا عشوائيًا غير مُتوقَّع. تُعرِّف الواجهة Map عرضين views آخرين. إذا كان map مُتغيِّرًا من النوع Map<K,V>، سيعيد التابع التالي تجميعةً من النوع Collection<V> تحتوي على جميع قيم الارتباطات المُخزَّنة بالخريطة: map.values() نظرًا لأن الخريطة قد تَربُط نفس القيمة بأكثر من مجرد مفتاحٍ واحد، كان من الضروري أن تَكون القيمة المعادة من النوع Collection وليس من النوع Set؛ لأن الأول قادرٌ على تخزين عناصرٍ مُكرَّرة بخلاف الثاني. ألقِ نظرةً على التابع التالي، الذي يُعيد طقمًا يحتوي على جميع الارتباطات الموجودة بالخريطة. map.entrySet() لاحِظ أن عناصر الطقم هي كائناتٌ تنتمي للواجهة Map.Entry<K,V> المُعرَّفة مثل واجهةٍ ساكنة static nested داخل الواجهة Map<K,V>، ولهذا يَحتوِي اسمها على نقطة، وهذا يَعنِي أن القيمة المُعادة من التابع map.entrySet() هي من النوع Set<Map.Entry<K,V>>. في تلك الحالة، يكون معامل النوع type parameter ذاته نوعًا ذا معاملات غير محدَّدة النوع parameterized type. قد يبدو ذلك مُربِكًا في البداية، ولكنه يَعنِي ببساطة أن عناصر الطقم هي نفسها من النوع Map.Entry<K,V>. لا تختلف المعلومات المُخزَّنة بالطقم المُعاد من استدعاء map.entrySet() عن تلك المُخزَّنة بالخريطة ذاتها، حيث يُوفِّر الطقم فقط عرضًا مختلفًا لنفس المعلومات، كما يُوفِّر بعض العمليات الآخرى. يَحتوِي كل كائنٍ من النوع Map.Entry على زوج مفتاح/قيمة، ويُعرِّف التابعين getKey() و getValue() لاسترجاعهما، كما يُعرِّف التابع setValue(value) لضبط القيمة. عند استدعاء التابع setValue على كائنٍ من النوع Map.Entry، تُعدَّل قيمته بالخريطة أيضًا كما لو كنا قد استدعينا التابع put المُعرَّف بالخريطة. يُمكِننا استخدام طقم الارتباطات المُعاد من التابع لطباعة جميع القيم والمفاتيح الموجودة بالخريطة، ويُعدّ هذا أكثر كفاءةً من استخدام طقم المفاتيح لطباعة نفس المعلومات (كما فعلنا بالمثال السابق)؛ لأننا لن نضطّر لاستدعاء التابع get() لمعرفة القيمة المرتبطة بكل مفتاح. تَنفِّذ الشيفرة التالية ذلك بفرض أن map خريطةٌ من النوع Map<String,Double>: Set<Map.Entry<String,Double>> entries = map.entrySet(); Iterator<Map.Entry<String,Double>> entryIter = entries.iterator(); System.out.println("The map contains the following associations:"); while (entryIter.hasNext()) { Map.Entry<String,Double> entry = entryIter.next(); String key = entry.getKey(); // استرجع المفتاح من entry Double value = entry.getValue(); // استرجع القيمة System.out.println( " (" + key + "," + value + ")" ); } أو قد نَستخدِم حلقة التكرار for-each لشيفرةٍ أكثر وضوحًا: System.out.println("The map contains the following associations:"); for ( Map.Entry<String,Double> entry : map.entrySet() ) { System.out.println( " (" + entry.getKey() + "," + entry.getValue() + ")" ); } يُعدّ هذا مثالًا جيدًا على استخدام var للتصريح عن المتغيرات (انظر مقال مفهوم التصريحات (declarations) في جافا)، ويُمكِّننا هذا من كتابة الشيفرة على النحو التالي: var entries = map.entrySet(); var entryIter = entries.iterator(); System.out.println("The map contains the following associations:"); while (entryIter.hasNext()) { . . . ملاحظة: تتطلَّب تلك الشيفرة الإصدار 10 من جافا على الأقل. تُستخدَم العروض بأماكنٍ أخرى غير الخرائط، حيث تُعرِّف الواجهة List<T> مثلًا قائمةً جزئيةً sublist مثل عرضٍ view لجزءٍ من القائمة الأصلية. بفرض أن list تُنفِّذ الواجهة List<T>، ألقِ نظرةً على الشيفرة التالية: list.subList( fromIndex, toIndex ) حيث أن fromIndex و toIndex أعدادٌ صحيحة. يعيد التابع عرضًا يُمثِل ذلك الجزء من القائمة المُتضمِّن للعناصر الواقعة بين الموضعين fromIndex و toIndex، متضمنًا الأول دون الثاني، مما يَسمَح بإجراء أيٍّ من العمليات المُعرَّفة بالقوائم على جزءٍ معينٍ من قائمة. ليست القوائم الجزئية sublists قوائمًا مستقلةً؛ أي أنه في حال إجراء أي تعديلٍ عليها، فسيُنفَّذ أيضًا على القائمة الأصلية. يُمكِننا كذلك الحصول على عرضٍ لتمثيل طقمٍ جزئي subset من طقمٍ معين. إذا كان set طقمًا من النوع TreeSet<T>، فسيعيد الاستدعاء التالي: 7set.subSet(fromElement,toElement) 7 طقمًا من النوع Set<T> يحتوي على جميع عناصر الطقم set الواقعة بين fromElement و toElement. يجب أن يكون المعاملان fromElement و toElement كائنين من النوع T. فإذا كان words طقمًا من النوع TreeSet<String> على سبيل المثال، وكانت جميع عناصره سلاسلًا نصيةً مُكوَّنةً من أحرفٍ أبجدية بحالةٍ صغيرة lower case، فسيحتوي الطقم الجزئي subset المُعاد من الاستدعاء words.subSet("m","n") على جميع عناصر الطقم الأصلي البادئة بالحرف "m". يُعدّ الطقم الجزئي عرضًا view لجزءٍ معينٍ من الطقم الأصلي، حيث لا يتضمَّن إنشاءه نَسْخًا لأي عنصرٍ من العناصر الأصلية؛ أي إذا عدَّلت الطقم الجزئي بإضافة عناصرٍ إليه أو بحذفها، ستُعدَّل عناصر الطقم الأصلي أيضًا. يُعيد الاستدعاء set.headSet(toElement) عرضًا view مُكوَّنًا من جميع عناصر الطقم set الأقل من قيمة toElement؛ بينما يُعيد الاستدعاء set.tailSet(fromElement) عرضًا مُكوَّنًا من جميع عناصر الطقم set الأكبر من قيمة fromElement. يُعرِّف الصنف TreeMap<K,V> ثلاثة عروضٍ لتمثيل خرائطٍ جزئية submaps، والتي هي أيضًا خريطةٌ من النوع Map تحتوي على جزءٍ من مفاتيح الخريطة الأصلية إلى جانب قيمها المرتبطة بها. إذا كان map مُتغيرًا من النوع TreeMap<K,V>، وكان fromKey و toKey من النوع K، فسيُعيد الاستدعاء map.subMap(fromKey,toKey) عرضًا يحتوي على جميع مفاتيح وقيم الخريطة map بشرط وقوع المفتاح بين fromKey و toKey. يتوفَّر أيضًا التابعين map.headMap(toKey) و map.tailMap(fromKey) المُشابهين تمامًا للتابعين headSet و tailSet. لنفترض أن phoneBook خريطةٌ من النوع TreeMap<String,String>، حيث تُمثِّل مفاتيحها أسماء أشخاص، بينما تُمثِّل قيمها values أرقام هواتف هؤلاء الأشخاص. تطبع الشيفرة التالية أرقام هواتف الأشخاص الموجودين بالخريطة phoneBook شرط أن تبدأ أسماؤهم بالحرف "M": Map<String,String> ems = phoneBook.subMap("M","N"); // 1 if (ems.isEmpty()) { System.out.println("No entries beginning with M."); } else { System.out.println("Entries beginning with M:"); for ( Map.Entry<String,String> entry : ems.entrySet() ) System.out.println( " " + entry.getKey() + ": " + entry.getValue() ); } [1] تحتوي هذه الخريطة الجزئية على الارتباطات، التي مفتاحها أكبر من أو يُساوِي "M" وأقل من "N". يُمكِننا التفكير بالأطقم الجزئية subsets والخرائط الجزئية submaps كما لو كانت عملية بحثٍ مُعمَّمةٍ تُمكِّننا من العثور على جميع العناصر الواقعة ضمن نطاقٍ معينٍ من القيم بدلًا من مجرد العثور على قيمةٍ واحدة. إذا خزَّنا مثلًا قاعدة بياناتٍ database لمجموعةٍ من المناسبات events ضمن خريطةٍ من النوع TreeMap<Date,Event>، بحيث يُمثِّل المفتاح تاريخ توقيت المناسبة. بفرض أردنا عرض قائمة المناسبات الواقعة بتاريخٍ معين، مثل July 4, 2018، يُمكِننا ببساطة الحصُول على خريطةٍ جزئيةٍ تحتوي على جميع المفاتيح الواقعة من التاريخ 12:00 AM, July 4, 2018 حتى التاريخ 12:00 AM, July 5, 2018، ثم طباعة جميع الارتباطات الموجودة بتلك الخريطة الجزئية، ويُعرَف هذا النوع من البحث باسم الاستعلام ضمن نطاقٍ جزئي subrange query، وهو شائعٌ جدًا. جداول Hash والشيفرات المعماة تُنفِّذ جافا الصنفين HashMap و HashSet باستخدام بنية بياناتٍ data structure تُعرَف باسم جدول hash. لا نحتاج في العموم لفهم طريقة عمل تلك الجداول لنتمكَّن من استخدام الصنفين HashSet و HashMap، لكن يجب أن يكون كل مبرمجٍ على اطلاعٍ بطريقة عملها. تُعدّ جداول Hash حلًا فعالًا لمشكلة البحث، فهي تُخزِّن أزواجًا من المفاتيح keys والقيم values مثل الصنف HashMap، وإذا كان لدينا مفتاحٌ معين، يُمكِننا البحث عن القيمة المقابلة له ضمن الأزواج المخزَّنة بالجدول؛ بينما لايكون هناك أي قيمٍ، إذا اِستخدَمنا جدول hash لتنفيذ طقمٍ، ويكون السؤال الوحيد هو: هل المفتاح موجودٌ بالطقم أم لا؟ ويبقى علينا البحث عن المفتاح لاختبار إذا كان موجودًا أم لا. بالنظر إلى غالبية خوارزميات البحث، حيث يَكون الغرض هو العثور على عنصرٍ معين، فستَجِد أنها تضطّر للمرور عبر مجموعةٍ من العناصر الأخرى، والتي نحن في الحقيقة غير مهتمين بها إطلاقًا. إذا أردنا مثلًا العثور على قيمةٍ معينةٍ ضمن قائمةٍ list غير مُرتَّبة، فسنمر على جميع عناصر القائمة واحدًا تلو الآخر حتى نعثُر على ذلك العنصر الذي نبحث عنه؛ أما إذا كان لدينا شجرة بحثٍ ثنائية binary search tree، فسنبدأ من جذر الشجرة root، ثم نستمر بالتحرُّك إلى أسفل الشجرة حتى نعثر على العنصر المطلوب؛ بينما إذا أردت البحث عن زوج مفتاح/قيمة ضمن جدول hash، نستطيع الذهاب مباشرةً إلى موضع العنصر المطلوب دون الحاجة للمرور عبر أي عناصرٍ اخرى؛ حيث يُستخدَم المفتاح لحساب الموضع المُخزَّن به العنصر. ربما تتساءل الآن عن كيفية فعل بذلك. لنفترض أن مفاتيح جدولٍ معينٍ مُكوَّنةٌ من الأعداد الصحيحة الواقعة بين 0 و 99، فيُمكِننا إذًا تخزين أزواج المفاتيح والقيم key/value pairs ضمن مصفوفةٍ A مُكوَّنةٍ من 100 عنصر. بناءً على ذلك، يكون الزوج ذو المفتاح K مُخزَّنًا بعنصر المصفوفة A[K]. يَعنِي ذلك، أننا نستطيع الذهاب مباشرةً إلى الموضع المُتضمِّن لزوجٍ معين بناءً على مفتاحه. تَكْمُن المشكلة في وجود عددٍ كبيرٍ جدًا من المفاتيح المُحتمَلة لدرجةٍ يَستحيل معها استخدام مصفوفةٍ بموضعٍ لكل مفتاحٍ مُحتمَل. قد يكون المفتاح أي قيمةٍ من النوع int، وعندها سنحتاج إلى مصفوفةٍ تحتوي على أكثر من 4 بليون موضع، وهو ما سيُمثِل هدرًا كبيرًا للمساحة إذا كنا سنُخزِّن بالنهاية بضعة آلافٍ من العناصر فقط. وقد يكون المفتاح أي سلسلةٍ نصية string بأي طول، وسيكون في تلك الحالة عدد المفاتيح المُحتمَلة لا نهائيًا، وسيَستحِيل عندها من الأساس استخدام مصفوفة بموضعٍ لكل مفتاحٍ مُحتمَل. بالرغم من ذلك، تُخزِّن جداول hash البيانات ضمن مصفوفة، حيث يَعتمِد فهرس index مفتاحٍ معينٍ على المفتاح ذاته؛ أي لا يكون الفهرس هو نفسه المفتاح، ولكنه يُحسَب على أساسه. يُطلَق على فهرس مفتاح معين اسم الشيفرة المُعمَّاة hash code لذلك المفتاح؛ بينما يُطلَق اسم دالة التعمية hash function على الدالة المُستخدَمة لحساب الشيفرة المعمَّاة hash code لمفتاحٍ معين. إذا أردنا العثور على مفتاحٍ معينٍ ضمن جدول hash، سنحتاج فقط إلى حساب الشيفرة المعمَّاة الخاصة بذلك المفتاح، ثم سنذهب مباشرةً إلى موضع المصفوفة المُخصَّص لتلك الشيفرة. على سبيل المثال، إذا كانت الشيفرة المعمَّاة تُساوِي 17، علينا فحَص موضع المصفوفة رقم 17. نظرًا لوجود مواضع مصفوفة أقل من المفاتيح المُحتمَلة، قد يؤدي ذلك إلى محاولة تخزين مفتاحين أو أكثر بنفس موضع المصفوفة، وهو ما يُعرَف باسم التصادم collision. لا يُعدّ التعارض خطأً error؛ لأنه لا يُمكِننا رَفض مفتاحٍ معينٍ لمجرد وجود مفتاحٍ آخر صَدَفَ أن كان له نفس الشيفرة المعمَّاة hash code. ومع ذلك، يجب أن يَكون جدول hash قادرًا على معالجة التعارضات بطريقةٍ معقولة. بلغة جافا: يَحمِل كل موضع مصفوفة قائمةً مترابطةً linked list من أزواج المفاتيح والقيم key/value pairs؛ وفي حال وجود عنصرين بنفس الشيفرة المعمَّاة، فسيُخزَّن كلاهما بنفس القائمة المترابطة. يوضح الشكل التالي جدول hash. يوجد بالشكل الموضح بالأعلى عنصران لهما نفس الشيفرة المعمَّاة 0؛ بينما لا يوجد أي عنصرٍ بشيفرة معمَّاة تُساوي 1؛ في حين يوجد عنصرٌ واحدٌ فقط بشيفرةٍ معمَّاة تُساوِي 2، وهكذا. إذا كان جدول hash مُصمَّمًا تصميمًا مناسبًا، يجب أن يكون طول غالبية القوائم المترابطة linked lists مُساويًا للصفر أو للواحد، وأن يكون طولها في المتوسط أقل من الواحد. على الرغم من أنه ليس من الضروري للشيفرة المعمَّاة لمفتاحٍ معين أن تأخذك مباشرةً إلى ذلك المفتاح، فليس هناك أكثر من مجرد عنصرٍ واحدٍ أو اثنين تحتاج للمرور بهما قبل العثور على المفتاح المطلوب، حيث يجب أن يكون عدد العناصر الموجودة بالجدول أقل من عدد مواضع المصفوفة ليعمَل ذلك بالشكل المناسب في العموم. بلغة جافا: عندما يجتاز عدد العناصر 75% من حجم المصفوفة، فإنها تُستبدَل بواحدةٍ جديدةٍ أكبر منها، وتُنقَل بالطبع جميع العناصر من المصفوفة القديمة إلى المصفوفة الجديدة، ولهذا يتسبَّب أحيانًا إدخال عنصرٍ واحد بالجدول إلى تَغيُّر ترتيب عناصره تمامًا. سنوضِح الآن طريقة الحصول على الشيفرات المعمَّاة hash codes، حيث يَملُك كل كائنٍ بلغة جافا شيفرةً معمَّاة، ويُعرِّف الصنف Object التابع hashCode() الذي يُعيد قيمةً من النوع int. عندما نُخِّزن كائنًا، وليَكُن اسمه obj، بجدول hash يَحتوي على عدد N من المواضع، فسنحتاج إلى شيفرةٍ معمَّاةٍ تقع بين 0 وN-1؛ حيث تُحسَب تلك الشيفرة باستخدام الآتي: Math.abs(obj.hashCode()) % N أي أنها تُساوِي باقي قسمة القيمة المُطلَقة المُعادة من obj.hashCode() على N. لاحِظ ضرورة استخدام Math.abs؛ لأن قيمة obj.hashCode() قد تكون سالبة، ونحن بالتأكيد نريد فهرس مصفوفةٍ موجب. لتعمل التعمية hashing على النحو الصحيح، يجب أن يَكون لأيِّ كائنين objects متساويين وفقًا للتابع equals() نفس الشيفرة المعمَّاة، ويَستوفِي الصنف Object ذلك الشرط لحسن الحظ؛ لأن التابعين equals() و hashCode() معتمدان على عنوان موضع الذاكرة الخاص بالكائن. يعيد مع ذلك كثيرٌ من الأصناف classes تعريف التابع equals()، كما رأينا بمقال مفهوم البرمجة المعممة Generic Programming؛ فإذا أعدت تعريف التابع equals() ضمن صنفٍ معين، وكنت تَنوِي استخدام كائناته مفاتيحًا ضمن جداول hash، فلا بُدّ من إعادة تعريف التابع hashCode() ضمن ذلك الصنف أيضًا. يُعيد الصنف String على سبيل المثال تعريف التابع equals() ليَضمَن تَساوِي كائنين من النوع String فيما إذا كانا يحتويان على نفس متتالية المحارف، كما يُعيد تعريف التابع hashCode() ليَحسِب الشيفرة المعمَّاة من محارف السلسلة النصية بدلًا من حسابها بناءً على موضعها بالذاكرة. لا يوجى داعٍ للقلق بشأن أصناف جافا القياسية Java's standard classes؛ فهي تُعرِّف التابعين على النحو الصحيح. اهتم فقط بالأصناف التي تكتبها بنفسك واحرص على تعريف التابعين معًا إذا أردت تعريف إحداهما. تشبه كتابة دوال التعمية hash function الفن؛ فمن أجل كتابة دالة تعميةٍ جيدة، ينبغي لها أن تُوزِّع المفاتيح المُحتمَلة بالتساوي على طول الجدول، وإلا فقد تتركَّز عناصره ضمن جزءٍ معينٍ فقط من المواضع المتاحة، وسينمو عندئذٍ حجم القوائم المترابطة linked lists الخاصة بتلك المواضع بصورةٍ كبيرة، مما يؤدي إلى الحد من كفاءة الجدول، والذي هو السبب الرئيسي لوجودها أساسًا. لن نُغطِي التقنيات المُستخدَمة لإنشاء دوال التعمية، فهي لا تُعدّ جزءًا أساسيًا من موضوع الكتاب. ترجمة -بتصرّف- للقسم Section 3: Maps من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: القوائم lists والأطقم sets في جافا تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا استخدام خريطة ومجموعة لبناء مفهرس Indexer
-
اطلَّعنا بالمقال السابق على الخواص العامة لعناصر التجميعات بلغة جافا، وحان الآن الوقت لنفحص بعضًا من تلك الأصناف، ونتعرَّف على طريقة استخدامها، حيث يُمكِننا تقسيم تلك الأصناف في العموم إلى مجموعتين رئيسيتين، هما القوائم lists والأطقم sets؛ حيث تتكوَّن أي قائمةٍ من متتاليةٍ من العناصر المُرتَّبة خطيًا، بمعنى أنها مُرتَبة وفقًا لترتيب معين لا يُشترَط له أن يَكون ترتيبًا تصاعديًا؛ أما الطقم set فهو تجميعةٌ لا تحتوي أي عناصرٍ مُكرَّرة، وربما تكون العناصر مُرتّبةً بترتيبٍ مُحدَّد أو لا. سنناقش سريًعا نوعًا آخرًا من التجميعات إضافةً الى النوعين السابقين، يُعرَف باسم أرتال الأولوية priority queue؛ وهي بنيةٌ بيانيةٌ data structures مثل الأرتال ولكن عناصرها ذات أولوية. أصناف تمثيل القوائم تعرّفنا في مقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا ومقال بنى البيانات المترابطة Linked Data Structures على طريقتين لتمثيل القوائم، هما المصفوفات الديناميكية dynamic array والقوائم المترابطة linked list. تُوفِّر جافا الصنفين java.util.ArrayList و java.util.LinkedList ضمن إطار عمل جافا للتجميعات Java Collection Framework لتمثيلهما بصيغةٍ مُعمَّمة generic، حيث يُنفِّذ كلاهما الواجهة List<T>، وبالتالي الواجهة Collection<T> أيضًا. يُمثِل كائنٌ من النوع ArrayList<T> متتاليةً مُرتَّبةً من الكائنات المُنتمية إلى النوع T، والمُخزَّنة ضمن مصفوفةٍ يزداد حجمها تلقائيًا عند الضرورة،؛ بينما يُمثِل كائنٌ من النوع LinkedList<T> متتاليةً مُرتّبةً من الكائنات المُنتمية إلى النوع T، والمُخزَّنة -بخلاف المصفوفة- بعُقدٍ nodes تَربُطها مؤشرات pointers ببعضها بعضًا. يدعم الصنفان السابقان عمليات القوائم الأساسية المُعرَّفة بالواجهة List<T>، كما يُعرَّف أي نوع بيانات مُجرَّد abstract data type بعملياته وليس طريقة تمثيله representation. قد تتساءل: لماذا نحتاج إلى تعريف صنفين بدلًا من تعريف صنف قائمةٍ وحيد له نفس طريقة التمثيل؟ المشكلة هي أننا لن نتمكَّن من تمثيل القوائم بطريقةٍ واحدة، وتكون كفاءة جميع عمليات القوائم على ذلك التمثيل بنفس الدرجة؛ حيث تَكون كفاءة عمليات معينة أفضل دائمًا عند تطبيقها على القوائم المترابطة linked lists بالموازنة مع المصفوفات؛ بينما ستَكون كفاءة عمليات أخرى أفضل عند تطبيقها على المصفوفات. يعتمد أي تطبيق application عمومًا على مجموعةٍ معينة من عمليات القوائم أكثر من غيرها، ولذلك ينبغي اختيار التمثيل الذي تَبلغ فيه كفاءة تلك العمليات أقصى ما يُمكِن. يُعدّ الصنف LinkedList المُمثِّل للقوائم المترابطة مثلًا أكثر كفاءةً بالتطبيقات التي تعتمد بكثرة على إضافة العناصر إلى بداية القائمة ومنتصفها، أو حذفها من نفس تلك المواضع؛ حيث تتطلَّب نفس تلك العمليات عند تطبيقها على مصفوفة تحريك عددٍ كبيرٍ من العناصر مسافة موضعٍ واحدٍ إلى الأمام أو الخلف لإتاحة مساحةٍ للعنصر الجديد المطلوب إضافته، أو لملئ الفراغ الذي تسبَّب به حذف عنصر. إن زمن التشغيل المطلوب لإضافة عنصرٍ إلى بداية أو منتصف مصفوفة وفقًا لمصطلحات التحليل المقارب asymptotic analysis (انظر مقال تحليل الخوارزميات في جافا) يُساوِي Θ(n)، حيث تمثّل n عدد عناصر المصفوفة؛ أما بالنسبة للقوائم المترابطة، تقتصر عمليتي الإضافة والحذف على ضبط عددٍ قليل من المؤشرات، ويكون زمن التشغيل المطلوب لإضافة عقدةٍ node إلى أي موضعٍ ضمن القائمة أو حذفها من أي موضع مساوٍ إلى Θ(1)، أي تَستغرِق العملية مقدارً ثابتًا من الزمن بغض النظر عن عدد العناصر الموجود بالقائمة. من الجهة الأخرى، يُعدّ الصنف ArrayList المُمثِل للمصفوفات أكثر كفاءةً عندما تَكون عملية الجَلْب العشوائي random access للعناصر أمرًا ضروريًا؛ وهي ببساطة عملية قراءة قيمة العنصر الموجود بموضعٍ معين k ضمن القائمة، وتُستخدَم تلك العملية عند محاولة قراءة أو تعديل القيمة المُخزَّنة بموضعٍ معين ضمن القائمة. تُعدّ تلك العمليات أمرًا في غاية البساطة بالنسبة للمصفوفة، وتحديدًا بالنسبة لزمن التشغيل الذي يساويΘ(1)؛ أما بالنسبة للقوائم المترابطة linked list، فتَعنِي تلك العمليات البدء من بداية القائمة، ثم التحرك من عقدةٍ إلى أخرى على طول القائمة بعدد خطواتٍ يصل إلى k، ويَكون زمن التشغيل هو Θ(k). تتساوى كفاءة عملية الترتيب sorting وإضافة عنصرٍ إلى نهاية القائمة لكلا النوعين السابقين. تٌنفِّذ جميع أصناف القوائم توابع الواجهة Collection<T>، التي ناقشناها بالمقال السابق، مثل size() و isEmpty() و add(T) و remove(Object) و clear()؛ حيث يضيف التابع add(T) الكائن إلى نهاية القائمة؛ بينما يحاول التابع remove(Object) العثور أولًا على الكائن المطلوب حَذْفه باستخدام خوارزمية البحث الخطي linear search، التي لا تتميز بالكفاءة نهائيًا لأي نوعٍ من القوائم لأنها تتضمَّن المرور عبر جميع عناصر القائمة من بدايتها إلى نهايتها لحين العثور على الكائن. تحتوي الواجهة List<T> على عدة توابعٍ أخرى للوصول إلى عناصر القائمة عبر مواضعها العددية. لنفترض أن list كائنٌ من النوع List<T>، سيُصبِح لدينا التوابع التالية: list.get(index): يُعيد الكائن الموجود بالموضع index ضمن القائمة، حيث أن index هو عددٌ صحيح. لاحِظ أن العناصر مُرقَّمةٌ على النحو التالي: 0، 1، 2، .. إلى list.size()-1، وبالتالي، لا بُدّ أن تَقَع القيمة المُمرَّرة مثل مُعامِلٍ ضمن ذلك النطاق، وإلا سيَحدُث اعتراضٌ من النوع IndexOutOfBoundsException. list.set(index,obj): يَستبدِل الكائن obj بالكائن الموجود حاليًا بالموضع index ضمن القائمة، وبالتالي لا يُغيِّر التابع عدد عناصر القائمة، كما أنه لا يُحرِّك أيًا من عناصرها الأخرى. يجب أن يكون الكائن obj من النوع T. list.add(index,obj): يُدخِل الكائن obj إلى الموضع index ضمن القائمة؛ أي يُزيِد التابع عدد عناصر القائمة بمقدار الواحد؛ كما أنه يُحرِّك جميع العناصر الواقعة بعد الموضع index مسافة موضعٍ واحدٍ إلى الأمام لإتاحة مساحةٍ للعنصر الجديد. يجب أن يَكون الكائن obj من النوع T، كما يجب أن تتراوح قيمة index بين 0 و list.size()، وإذا كان index يُساوي list.size()، فسيُضيِف التابع الكائن obj إلى نهاية القائمة. list.remove(index): يَحذِف الكائن الموجود بالموضع index، ثم يعيده قيمةً للتابع؛ حيث يُحرِّك التابع العناصر الواقعة بعد ذلك الموضع مسافة موضعٍ واحدٍ إلى الخلف لملء الفراغ الذي تسبَّب به حذف العنصر، ويَقِل بذلك عدد عناصر القائمة بمقدار الواحد. يجب أن تتراوح قيمة index بين 0 و list.size()-1. list.indexOf(obj): يُعيد قيمةً عدديةً من النوع int تُمثِّل موضع الكائن obj بالقائمة في حال وجوده بها؛ بينما يُعيد القيمة -1 إذا لم يَكُن موجودًا. يُمكِن للكائن obj أن يَكون من أي نوع وليس فقط T. إذا كان الكائن obj موجودًا أكثر من مرةٍ ضمن القائمة، فسيُعيد التابع موضع أول حدوثٍ له. لاحِظ أن التوابع المذكورة أعلاه مُعرَّفةٌ لكلا الصنفين ArrayList<T> و LinkedList<T> على الرغم من أن كفاءة بعضها، مثل get و set مقتصرةٌ على الصنف ArrayList. يُعرِّف الصنف LinkedList<T> عدة توابع إضافية أخرى غير مُعرَّفةٍ بالصنف ArrayList. إذا كان linkedlist كائنًا من النوع LinkedList<T>، فسنحصل على التوابع التالية: linkedlist.getFirst(): يُعيد قيمةً من النوع T تُمثِّل أول عنصرٍ ضمن القائمة دون إجراء أيّ تعديلٍ عليها. سيحدث اعتراضٌ exception من النوع NoSuchElementException، إذا كانت القائمة فارغةً، وينطبق ذلك على التوابع الثلاثة التالية أيضًا. linkedlist.getLast(): يُعيد قيمةً من النوع T تُمثِّل آخر عنصرٍ ضمن القائمة دون إجراء أيّ تعديلٍ عليها. linkedlist.removeFirst(): يحذف الصنف أول عنصرٍ ضمن القائمة، ويُعيده قيمةً للتابع. التابعان linkedlist.remove() و linkedlist.pop() مُعرَّفان أيضًا، ولهما نفس دلالة التابع removeFirst(). linkedlist.removeLast(): يَحذِف آخر عنصرٍ ضمن القائمة، ويُعيده قيمةً للتابع. linkedlist.addFirst(obj): يُضيف الكائن obj إلى بداية القائمة، والذي يجب أن يكون من النوع T. التابع linkedlist.push(obj) مُعرَّفٌ أيضًا، وله نفس الدلالة. linkedlist.addLast(obj): يُضيف الكائن obj إلى نهاية القائمة، والذي يجب أن يكون من النوع T. يعمل بصورة مشابهة تمامًا للتابع linkedlist.add(obj)؛ فهو بالنهاية مُعرَّفٌ فقط للتأكد من الحصول على أسماء توابعٍ مُتسقّة consistent. ستلاحِظ وجود بعض التكرار ضمن الصنف LinkedList، لتسهيل استخدامه كما لو كان مكدسًا stack، أو رتلًا queue (انظر مقال المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT). يُمكِننا على سبيل المثال استخدام قائمةٍ مترابطة من النوع LinkedList مثل مكدسٍ باستخدام التوابع push() و pop()، أو مثل رتلٍ باستخدام التوابع add() و remove() لتنفيذ عمليتي الإدراج enqueue والسحب dequeue. إذا كان الكائن list قائمةً من النوع List<T>، فسيعيد التابع list.iterator() المُعرَّف بالواجهة Collection<T> مُكرّرًا iterator من النوع Iterator، والذي يُمكِننا استخدامه لاجتياز traverse القائمة من البداية حتى النهاية. يتوفَّر أيضًا نوعٌ آخر من مُكرِّرات القوائم ListIterator يتميز بخواصٍ إضافية. لاحِظ أن الواجهة ListIterator<T> مُوسعَّةٌ من الواجهة Iterator<T>، ويُعيد التابع list.listIterator() كائنًا من النوع ListIterator<T>. تتضمَّن الواجهة ListIterator توابع المُكرِّرات العادية، مثل hasNext() و next() و remove()، ولكنها تحتوي أيضًا على توابعٍ أخرى، مثل hasPrevious() و previous() و add(obj) و set(obj)، والتي تُساعد على التحرُّك إلى الخلف ؛ وإضافة عنصرٍ بالموضع الحالي للمُكرِّر؛ واستبدال أحد عناصر القائمة على الترتيب. فكِّر بالمُكرِّرات كما لو كانت تُشير إلى موضعٍ بين عنصرين ضمن القائمة، أو إلى بداية القائمة، أو نهايتها لتتمكَّن من فِهم طريقة عمل التوابع السابقة. تُظهر الصورة التالية العناصر على هيئة مربعات، بحيث تُشير تلك الأسهم إلى المواضع المحتملة للمُكرِّر iterator: إذا كان iter مُكرِّرًا من النوع ListIterator<T>، فسيحركه التابع iter.next() مسافة موضعٍ واحدٍ إلى يمين القائمة، ويعيد العنصر الذي مرّ به المُكرِّر أثناء تحركه؛ ويُحرِّك التابع iter.previous() المُكرِّر مسافة موضعٍ واحد إلى يسار القائمة، ويعيد العنصر الذي مرّ به. يَحذِف التابع iter.remove() أحدث عنصرٍ مرّ به المُكرِّر أثناء تحركُّه أي بعد استدعاء التابع iter.next() أو التابع iter.previous(). يَعمَل التابع iter.set(obj) بنفس الطريقة، أي يستبدل obj بنفس العنصر الذي يفترض للتابع iter.remove() أن يَحذِفه عند استدعائه. يتوفَّر أيضًا التابع iter.add(obj) المسؤول عن إضافة الكائن obj من النوع T إلى الموضع الحالي للمُكرِّر، والذي من الممكن أن يكون في بداية القائمة، أو نهايتها، أو بين عنصرين موجودين مُسبَقًا ضمن القائمة. تُعدّ القوائم المُستخدَمة بالواجهة LinkedList<T> قوائمًا مترابطةً مزدوجة doubly linked lists، حيث تحتوي كل عقدةٍ node ضمن القائمة على مؤشرين pointers، يُشير أحدهما إلى العقدة التالية بالقائمة، بينما يشير الآخر إلى العقدة السابقة، ويُمكِّننا هذا من تنفيذ التابعين next() و previous() بأحسن كفاءةٍ ممكنة. كما يحتوي الصنف LinkedList<T> على مؤشر ذيل tail pointer للإشارة إلى آخر عقدةٍ ضمن القائمة، ويُمكِّننا ذلك من تنفيذ التابعين addLast() و getLast() بكفاءة. سنَدرِس الآن مثالًا عن كيفية استخدام مُكرِّرٍ من النوع ListIterator. لنفترض أننا نريد معالجة قائمةٍ من العناصر مع مراعاة الإبقاء عليها مُرتَّبةً ترتيبًا تصاعديًا. عند إضافة عنصرٍ إلى القائمة، سيَعثُر المُكرِّر من النوع ListIterator أولًا على الموضع الذي ينبغي إضافة العنصر إليه، ثم سيَضعُه به. يبدأ المُكرِّر ببساطةٍ من بداية القائمة، ثم يتحرَّك إلى الأمام بحيث يَمُر بجميع العناصر التي تقل قيمتها عن قيمة العنصر المطلوب إضافته، ويُضيِف التابع add() العنصر إلى القائمة عند هذه النقطة. إذا كان stringList مُتغيِّرًا من النوع List<String> مثلًا، وكان newItem السلسلة النصية التي نريد إضافتها إلى القائمة، وبِفَرض كانت السلاسل النصية الموجودة حاليًا ضمن القائمة مُرتَّبةً ترتيبًا تصاعديًا بالفعل، يُمكِننا إذًا استخدام الشيفرة التالية لوضع العنصر newItem بموضعه الصحيح ضمن القائمة بحيث نُحافِظ على ترتيبها: ListIterator<String> iter = stringList.listIterator(); // 1 while (iter.hasNext()) { String item = iter.next(); if (newItem.compareTo(item) <= 0) { // 2 iter.previous(); break; } } iter.add(newItem); حيث أن: [1] تعني حرِّك المُكرِّر بحيث يُشير إلى موضع القائمة الذي ينبغي إضافة newItem إليه؛ فإذا كان newItem أكبر من جميع عناصر القائمة، فستنتهي حلقة التكرار while عندما تُصبِح قيمة iter.hasNext() مُساويةً للقيمة false، أي عندما يَصِل المُكرِّر إلى نهاية القائمة. [2] تشير إلى يجب أن يأتي newItem قبل item. حرِّك المُكرِّر خطوةً للوراء، بحيث يُشير إلى موضع الإدخال الصحيح، وأنهي الحلقة. قد يكون stringList من النوع ArrayList<String>، أو النوع LinkedList<String>. لاحِظ أن كفاءة الخوارزمية المُستخدَمة لإدخال newItem إلى القائمة مُتساويةٌ لكليهما، كما أنها ستَعمَل مع أي أصنافٍ أخرى طالما كانت تُنفِّذ الواجهة List<String>. قد تجد أنه من الأسهل تصميم خوارزمية الإدراج باستخدام الفهرسة indexing على هيئة توابعٍ، مثل get(index) و add(index,obj)، ولكن ستكون كفائتها سيئةً للغاية بالنسبة للقوائم المترابطة LinkedList؛ لأنها لا تَعمَل بكفاءةٍ عند الجلب العشوائي random access. ملاحظة: ستَعمَل خوارزمية الإدراج insertion حتى لو كانت القائمة فارغة. الترتيب نظرًا لأن عملية ترتيب sorting القوائم من أكثر العمليات شيوعًا، كان من الضروري حقًا أن تُعرِّف الواجهة List تابعًا مسؤولًا عن تلك العملية، إلا أنه غير موجود؛ ربما لأن عملية ترتيب قوائم أنواعٍ معينة من الكائنات ليس لها معنى. بالرغم من ذلك، يتضمَّن الصنف java.util.Collections توابعًا ساكنة static methods للترتيب، كما يحتوي على توابعٍ ساكنةٍ أخرى للعمل مع التجميعات collections؛ وهي توابعٌ من النوع المُعمَّم generic، أي أنها تعمل مع تجميعات أنواعٍ مختلفة من الكائنات. لنفترض أن list قائمةً من النوع List<T>، يُمكِن للأمر التالي ترتيب القائمة تصاعديًا: Collections.sort(list); يجب أن تُنفِّذ عناصر القائمة الواجهة Comparable<T>. سيعمل التابع Collections.sort() على قوائم السلاسل النصية من النوع String، وكذلك لقوائم أي نوعٍ من الأصناف المُغلِّفة، مثل Integer و Double. يتوفَّر أيضًا تابع ترتيبٍ آخرٍ يَستقبِل معاملًا ثانيًا إضافيًا من النوع Comparator: Collections.sort(list,comparator); يُوازن المعامل الثاني comparator بين عناصر القائمة في تلك الحالة. كما ذكرنا بالمقال السابق، تُعرِّف كائنات الصنف Comparator التابع compare() الذي يُمكِننا من استخدِامه لموازنة كائنين. سنفحص مثالًا على استخدام الصنف Comparator في مقال قادم. يَعتمِد التابع Collections.sort() على خوارزمية الترتيب بالدمج merge sort بزمن تشغيل run time يساوي Θ(n*log(n)) لكُلٍّ من الحالة الأسوأ worst-case والحالة الوسطى average-case، حيث n هو حجم القائمة. على الرغم من أن زمن التشغيل لتلك الخوارزمية أبطأ قليلًا في المتوسط من خوارزمية الترتيب السريع QuickSort (انظر مقال التعاود recursion في جافا لمزيد من التفاصيل)، إلا أن زمن تشغليها في الحالة الأسوأ أفضل بكثير. تتميز خوارزمية الترتيب بالدمج MergeSort علاوةً على ذلك بخاصية الاستقرار stability، التي سنناقشها بمقال لاحق. يتضمَّن الصنف Collection تابعين آخرين مفيدين على الأقل لتعديل القوائم؛ حيث يُنظِم التابع الآتي: Collections.shuffle(list) عناصر القائمة بحيث تكون مُرتبةً ترتيبًا عشوائيًا؛ بينما يعكس التابع Collections.reverse(list) ترتيب عناصر القائمة، بحيث ينتقل آخر عنصرٍ في القائمة إلى مقدمتها، وثاني آخر عنصرٍ إلى الموضع الثاني بالقائمة، وهكذا. نظرًا لأن الصنف List يُوفِّر لنا بالفعل تابع ترتيب ذا كفاءة عالية، فلا حاجة لكتابته بنفسك. أصناف الأطقم TreeSet و HashSet يُعدّ الطقم set تجميعة كائنات، لا يتكرَّر فيها أي عنصرٍ أكثر من مرة. تُنفِّذ الأطقم جميع توابع الواجهة Collection<T> بطريقةٍ تَضمن عدم تكرار أي عنصرٍ مرتين؛ فإذا كان set كائن تجميعةٍ من النوع Set<T>، وكان يَحتوي على عنصرٍ obj، فلن يكون لاستدعاء التابع set.add(obj) أي تأثيرٍ على set. توفِّر جافا صنفين لتنفيذ الواجهة Set<T>، هما java.util.TreeSet و java.util.HashSet. بالإضافة إلى كون الصنف TreeSet من النوع Set، فإن عناصره تكون مُرتّبةً دائمًا ترتيبًا تصاعديًا، أي ستجتاز مُكرِّرات الأطقم من النوع TreeSet العناصر دائمًا بحسب ترتيبها التصاعدي. لا يُمكِن للأطقم من النوع TreeSet أن تحتوي على أية كائنات عشوائيًا؛ حيث لا بُدّ من معرفة الطريقة التي ينبغي على أساسها ترتيب تلك الكائنات؛ أي ينبغي لأي كائنٍ موجودٍ ضمن طقم من النوع TreeSet<T> أن يُنفِّذ الواجهة Comparable<T>، بحيث يَكون للاستدعاء obj1.compareTo(obj2) لأي كائنين obj1 و obj2 ضمن الطقم معنى. يُمكِننا بدلًا من ذلك تمرير كائنٍ من النوع Comparator<T> مثل معاملٍ للباني constructor عند إنشاء طقمٍ من النوع TreeSet، ويُستخدَم في تلك الحالة التابع compare() المُعرَّف ضمن Comparator لموازنة الكائنات المضافة إلى الطقم. لا تَستخدِم الأطقم من النوع TreeSet التابع equals() من أجل اختبار تساوي كائنين معينين، وإنما تَستخدِم التابع compareTo()، أو التابع compare()، وهذا قد يُحدِث مشكلة؛ لأن التابع compareTo() (كما ناقشنا بالمقال السابق) قد يُعامِل كائنين غير متساويين كما لو كانا كذلك لغرض الموازنة comparison، مما يَعنِي إمكانية وقوع أحدهما فقط ضمن طقمٍ من النوع TreeSet. لنفترض مثلًا أن لدينا طقمًا يحتوي على مجموعةٍ من عناوين البريد، وكان التابع compareTo() مُعرَّفٌ بحيث يوازن فقط الأرقام البريدية لتلك العناوين، وبالتالي يُمكِن للطقم أن يحتوي على عنوانٍ واحدٍ فقط لكل رقمٍ بريدي، وهو بالتأكيد أمرٌ غير منطقي. يجب إذًا الانتباه دومًا لدلالة الأطقم من النوع TreeSet، والتأكُّد من أن التابع compareTo() مُعرَّفٌ بطريقةٍ منطقية للكائنات المُتوقَّع إضافتها لهذا النوع من الأطقم، ويَنطبِق ذلك على السلاسل النصية من النوع String، والأعداد الصحيحة من النوع Integer، وغيرها من الأنواع الأخرى المبنية مُسبَقًا built-in؛ حيث يُعامِل التابع compareTo() الكائنات بتلك الأنواع على أنها متساوية إذا كانت فعلًا كذلك. تُخزَّن عناصر الأطقم من النوع TreeSet داخل ما يُشبِه أشجار الترتيب الثنائية binary sort tree (انظر مقال الأشجار الثنائية Binary Trees في جافا)، حيث تكون بنية البيانات data structure مُتزِّنةً؛ أي تكون جميع أوراق leaves الشجرة الثنائية على نفس البعد تقريبًا من جذر الشجرة root، مما يضمَن تنفيذ جميع العمليات الأساسية، مثل الإدْخال والحذف والبحث بكفاءة، وبزمن تشغيلٍ للحالة الأسوأ worst-case run time مساوٍ Θ(log(n))، حيث n هو عدد عناصر الطقم. كما ذكرنا مُسبقًا، تكون عناصر الأطقم من النوع TreeSet مُرتّبةً وغير مُكرَّرة، وهذا يجعلها مناسبةً لبعض التطبيقات. تَضمَّن تمرين 7.6 على سبيل المثال كتابة برنامجٍ يقرأ ملفًا ثم يَطبَع قائمة الكلمات الموجودة ضمن ذلك الملف بعد حذف جميع الكلمات المُكرَّرة، وبحيث تَكون مُرتّبةً أبجديًا. كنا قد اِستخدَمنا مصفوفةً من النوع ArrayList، وعليه كان من الضروري التأكُّد من كون عناصر المصفوفة مُرتّبةً وغير مُكرَّرة. يُمكننا في الواقع استخدام طقمٍ من النوع TreeSet لتخزين العناصر بدلًا من استخدام قائمة، وسيُبسِّط ذلك الحل كثيرًا؛ لأنه سيَحذِف العناصر المُكرَّرة تلقائيًا، كما سيجتاز مُكرِّر الطقم العناصر على نحوٍ مُرتّبٍ تلقائيًا. يُمكِننا كتابة الحل باستخدام الصنف TreeSet على النحو التالي: TreeSet<String> words = new TreeSet<String>(); // طالما ما يزال هناك بيانات أخرى بملف الدخل while there is more data in the input file: // أسنِد الكلمة التالية بالملف إلى word Let word = the next word from the file // حوِّل word إلى الحالة الصغيرة Convert word to lower case // أضِف word إذا لم تكن موجودةً بالفعل words.add(word) for ( String w : words ) // words في w من أجل كل سلسلة نصية Output w // تُطبَع الكلمات مُرتبة يُمكِنك أيضًا الاطلاع على الشيفرة الكاملة للبرنامج بالملف WordListWithTreeSet.java. لنفحص مثالًا آخرًا، بفرض أن coll تجميعةٌ من السلاسل النصية من النوع String، يُمكِننا استخدام طقمٍ من النوع TreeSet لترتيب عناصر التجميعة coll، ولحَذْف أي عناصر مُكرَّرة بكتابة الشيفرة التالية: TreeSet<String> set = new TreeSet<String>(); set.addAll(coll); تُضيِف التعليمة الثانية جميع عناصر التجميعة إلى طقم، وبما أنه من النوع Set، فسيتجاهل العناصر المُكرَّرة تلقائيًا، ونظرًا لكونه من النوع TreeSet تحديدًا، ستكون العناصر مُرتَّبة. إذا أردت تخزين بيانات طقمٍ معينٍ داخل بنية بيانات data structure مختلفة، يُمكِنك ببساطة نسخها من الطقم. تَنسَخ الشيفرة التالية عناصر طقمٍ إلى مصفوفةٍ من النوع ArrayList: TreeSet<String> set = new TreeSet<String>(); set.addAll(coll); ArrayList<String> list = new ArrayList<String>(); list.addAll(set); تَستقبل بناة constructors جميع الأصناف المُمثِلة للتجميعات ضمن لغة جافا تجميعةً من النوع Collection؛ وعند استدعاء إحداها، ستُضَاف جميع عناصر التجميعة المُمرَّرة إلى التجميعة الجديدة المُنشَئة. إذا كان coll من النوع Collection<String> مثلًا، يُنشِئ الاستدعاء new TreeSet<String>(coll) طقمًا من النوع TreeSet يحتوي على نفس العناصر الموجودة بالتجميعة coll بعد حذف أي عناصرٍ مُكرَّرة، كما أنها تكون مُرتَّبة. يُمكِننا بناءً على ذلك إعادة كتابة الأسطر الأربعة السابقة على النحو التالي: ArrayList<String> list = new ArrayList<>( new TreeSet<>(coll) ); تُنشِيء التعليمة السابقة قائمةً مُرتبةً من العناصر غير المُكرَّرة ضمن التجميعة coll. يُبيّن المثال السابق مدى فعالية البرمجة المُعمَّمة generic programming. لاحِظ أنه من غير الضروري كتابة معامل النوع String بالبانيين السابقين؛ لأن المُصرِّف compiler قادرٌ على استنتاجهما بالفعل. تُخزِّن الأطقم من النوع HashSet عناصرها ضمن بنيةٍ بيانية تُعرَف باسم جدول hash table، وسنتناول تلك البنية البيانية في المقال الموالي. تَعمَل عمليات البحث والإضافة والحذف على الجداول بكفاءة عالية، وأعلى حتى من الصنف TreeSet. بخلاف الصنف TreeSet، لا تُخزِّن الأطقم من النوع HashSet عناصرها وفقًا لأي ترتيبٍ مُحدَّد، وبالتالي لا تَكون مُضطّرةً لتنفيذ الواجهة Comparable؛ ولكن ينبغي في المقابل أن تُعرِّف شيفرة تعمية hash code مناسبة كما سنرى بالمقال التالي. يُحدِّد التابع equals() فيما إذا كان من الممكن عدّ كائنين بطقمٍ من النوع HashSet متساويين، حيث تَمرّ مُكرِّرات أطقم النوع HashSet عبر عناصرها مرورًا عشوائيًا، بل قد يتغيَّر ترتيب مرورها بالعناصر مع إضافة عنصرٍ جديد. اِستخدِم الصنف HashSet بدلًا من الصنف TreeSet إذا لم تَكْن العناصر قابلة للموازنة، أو إذا لم يَكْن ترتيبها مُهمًا، أو إذا كنت مهتمًا بكفاءة العمليات على العناصر أكثر من أي شيءٍ آخر. ملاحظة: يُطلق على عناصر الأطقم وفقًا لنظرية المجموعات set theory الحسابية أعضاء members أو عناصر elements. وتتضمَّن العمليات الهامة على تلك الأطقم ما يلي: إضافة عنصرٍ إلى مجموعة، وحذف عنصرٍ من مجموعة، وفحص فيما إذا كانت قيمةٌ ما عنصرًا ضمن مجموعة. إذا كان لدينا طقمين، يُمكِننا إجراء العمليات التالية عليهما: توحيد union طقمين، وتقاطع intersection بين طقمين، والفرق بين طقمين. تُوفِّر جافا تلك العمليات للأطقم من النوع Set، ولكن بأسماءٍ مختلفة. بفرض أن لدينا طقمين A و B، فإن: A.add(x): يُضيف العنصر x إلى الطقم A. A.remove(x): يحذف العنصر x من الطقم A. A.contains(x): يفحص إذا كانت x عنصرًا بالطقم A. A.addAll(B): يحسب اتحاد الطقمين A و B. A.retainAll(B): يحسب التقاطع بين الطقمين A و B. A.removeAll(B): يحسب الفرق بين الطقمين A - B. تختلف الأطقم بمفهومها الحسابي عن الأطقم بلغة جافا بالنقاط التالية: يجب أن تكون الأطقم نهائية finite، بينما تكون المجموعات الحسابية عادةً لا نهائية. قد تحتوي المجموعات الحسابية على عناصر عشوائية، بينما تكون الأطقم من نوعٍ محدد مثل Set<T>، ولا يُمكِنها أن تحتوي على أية عناصر غير مُنتمية للنوع T. تُعدِّل العملية A.addAll(B) قيمة A، بينما تَحسب عملية الاتحاد بين الطقمين A وB طقمًا جديدًا دون أن تُعدِّل من قيمة أيٍّ من الطقمين الأصليين. سنتعرض بالتمرين 10.2 لمثالٍ عن العمليات الحسابية على الأطقم. أرتال الأولوية يُعدّ رتل الأولوية priority queue نوعًا بيانيًا مجردًا abstract data type يُمثِّل تجميعة عناصر، حيث يُسنَد إلى كل عنصرٍ منها أولوية priority معينة، وهو ما يَسمَح بالموازنة بينها. تتضمَّن العمليات على أرتال الأولوية ما يلي: عملية add المسؤولة عن إضافة عنصرٍ إلى التجميعة. عملية remove المسؤولة عن حذف العنصر ذو الأولوية الأقل من التجميعة وإعادته قيمةً لعملية الحذف ذاتها. عملية الحذف remove بحيث تَحذف العنصر ذا الأولوية الأقل، ولكن من الممكن نظريًا حذف العنصر ذي الأولوية القصوى. يُمكنِنا تنفيذ رتل الأولوية باستخدام قائمةٍ مترابطة linked list لتخزين العناصر بحيث تكون مُرتبةً تصاعديًا وفقًا لترتيب أولوياتها. تَحذِف remove في تلك الحالة أول عنصرٍ ضمن القائمة وتُعيده؛ بينما يجب على عملية add إضافة العنصر الجديد بموضعه الصحيح ضمن القائمة، وهو ما يستغرق زمن تشغيل وسطي قدره Θ(n)، حيث n هي عدد عناصر القائمة. يُمكِننا أيضًا تنفيذ رتل الأولوية بطريقةٍ أكثر كفاءةً بحيث يكون زمن تشغيل عمليتي add و remove مُساويًا Θ(log(n))؛ وتعتمد تلك الطريقة على استخدام بنية بياناتٍ تُعرَف باسم الكومة heap، وهي مختلفةٌ عن قسم الكومة بالذاكرة الذي تُنشأ فيه الكائنات objects. يُنفِّذ الصنف PriorityQueue<T> ذو المعاملات غير مُحدَّدة النوع parameterized رتل أولوية للكائنات من النوع T، كما يُنفِّذ الواجهة Collection<T>. فإذا كان pq رتل أولويةٍ من النوع PriorityQueue، فسيحتوي على جميع التوابع methods المُعرَّفة ضمن تلك الواجهة interface. سنستعرِض فيما يلي أكثرها أهمية: pq.add(obj): يُضيف obj إلى رتل الأولوية. يجب أن يكون obj كائنًا من النوع T. pq.remove(): يَحذِف أقل العناصر أولوية، ويعيدها أي تكون القيمة المُعادة كائنٌ من النوع T، وإذا كان الرتل فارغًا، يَحدُث اعتراض exception. pq.isEmpty(): يَفْحَص إذا كان رتل الأولوية فارغًا. سنفحص الآن الطريقة التي تتحدَّد على أساسها أولوية العناصر ضمن رتل أولوية، وهي تشبه عملية الترتيب، ولهذا يجب أن نكون قادرين على موازنة أي عنصرين داخل الرتل. قد نواجه موقفًا من اثنين: إما أن تكون العناصر مُنفِّذة للواجهة Comparable، ويُستخدَم عندها التابع compareTo() المُعرَّف بتلك الواجهة لموازنة العناصر؛ أو أن نُمرِّر كائنًا من النوع Comparator مثل معاملٍ لباني الصنف PriorityQueue ويُستخدَم في تلك الحالة التابع compare المُعرَّف بالنوع Comparator للموازنة. يُمكِننا استخدام الأصناف المُنفِّذة للواجهة Comparable، مثل String و Integer و Date مع أرتال الأولوية. فعلى سبيل المثال، قد نَستخدِم رتل أولوية من السلاسل النصية PriorityQueue<String> لنُرتِّبها ترتيبًا أبجديًا على النحو التالي: سنُضيِف جميع السلاسل النصية إلى رتل الأولوية، ثم نَحذِفها واحدةً تلو الأخرى. وبما أن عناصر أرتال الأولوية تُحذَف بحسب أولويتها، فستَجِد أنها تُحذَف بحسب ترتيبها الأبجدي. كنا قد أوضحنا سابقًا استخدام طقمٍ من النوع TreeSet لترتيب تجميعةٍ من العناصر، وكذلك لحذف المُكرَّر منها، ويُمكِننا بالمثل استخدام الصنف PriorityQueue لترتيب عناصر تجميعة، ولكن بدون حذف أي عنصرٍ حتى المُكرَّر منها. إذا كانت coll مثلًا تجميعةً من النوع Collection<String>، فستطبع الشيفرة التالية جميع عناصرها بما في ذلك المُكرَّر منها: PriorityQueue<String> pq = new PriorityQueue<>(); pq.addAll( coll ); while ( ! pq.isEmpty() ) { System.out.println( pq.remove() ); } ملاحظة: لا يُمكِن اِستخدَام مُكرِّر iterator أو حلقة for-each لطباعة العناصر بالمثال السابق، لأنها لا تجتاز عناصر أرتال الأولوية priority queue وفقًا لترتيبها التصاعدي. يُنشِئ البرنامج التوضيحي WordListWithPriorityQueue.java قائمةً مُرتّبةً من الكلمات الموجودة بملفٍ معين دون أن يَحذِف أيّ كلماتٍ مُكرَّرة، حيث يُخزِّن البرنامج الكلمات برتل أولوية. يُمثِل هذا البرنامج تعديلًا بسيطًا على البرنامج الأصلي WordListWithTreeSet.java. تُستخدَم أرتال الأولوية في تطبيقاتٍ أخرى غير الترتيب، مثل تنظيم عملية تنفيذ الحاسوب لعدة وظائف jobs ذات أولوياتٍ مختلفة، وبحيث يكون ترتيب التنفيذ من الوظائف ذات الأقل أولوية فالأعلى. يُمكِننا بناءً على ذلك تخزين الوظائف برتل أولوية؛ وعندما يَحذِف الحاسوب وظيفةً من الرتل لينفِّذها، سيَحذِفها وفقًا للترتيب التصاعدي لأولويتها. ترجمة -بتصرّف- للقسم Section 2: Lists and Sets من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا تحليل زمن تشغيل القوائم المنفذة باستخدام مصفوفة تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة مترابطة مفهوم المصفوفات الديناميكية (ArrayLists) في جافا
-
تُشير البرمجة المُعمَّمة generic programming إلى كتابة شيفرةٍ يُمكِن تطبيقها على أنواعٍ كثيرة من البيانات. كنا قد تعرَّضنا بمقال معالجة المصفوفات Arrays في جافا للمصفوفات الديناميكية، والتي يُمكِن عدّها بديلًا عن البرمجة المُعمَّمة، وكتبنا شيفرةً تَعمَل مع مصفوفةٍ ديناميكية من الأعداد الصحيحة. في الحقيقة، لا يُمكِن لتلك الشيفرة أن تَعمَل مع أي نوعٍ غير النوع int، رغم أن الشيفرة المصدرية للمصفوفات الديناميكية من النوع double أو String أو Color أو أي نوع بياناتٍ آخر هي نفسها تقريبًا باستثناء تبديل اسم النوع؛ وبالتالي يبدو من الحماقة إعادة كتابة نفس الشيفرة مرارًا وتكرارًا. تُقدِّم جافا حلًا لتلك المشكلة يُعرَف باسم الأنواع ذات المعاملات غير مُحدَّدة النوع parameterized types. كما رأينا بمقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا، يُنفِّذ الصنف ArrayList المصفوفات الديناميكية، ونظرًا لكونه نوعًا ذا معاملاتٍ غير مُحدَّدة النوع، فستَجِد أنواعًا؛ مثل النوع ArrayList<String> لتمثيل مصفوفةٍ ديناميكيةٍ من السلاسل النصية من النوع String؛ والنوع ArrayList<Color> لتمثيل مصفوفة ديناميكية من الألوان؛ والنوع ArrayList<T> لأي نوع كائن T. لاحِظ أن ArrayList هي صنفٌ واحدٌ فقط، ولكنه مُعرَّف ليَعمَل مع أنواعٍ مختلفةٍ كثيرة، وهذا هو صميم البرمجة المُعمَّمة. الصنف ArrayList هو في الواقع مجرد صنفٍ واحدٍ ضمن عددٍ كبيرٍ من أصناف جافا القياسية standard classes التي تَستخدِم البرمجة المُعمَّمة. سنناقش خلال المقالات الثلاثة التالية بعضًا من تلك الأصناف، ونتعرَّف على طريقة استخدامها، كما سنتعرَّض لواجهات interfaces وتوابع methods مُعمَّمة. لاحِظ أن جميع الأصناف والواجهات التي سنناقشها خلال تلك الأقسام مُعرَّفةٌ بحزمة package java.util، لذلك ستحتاج إلى كتابة التعليمة import ببداية برامجك لتتمكّن من استخدامها. سنرى في مقال لاحق من هذه السلسلة إمكانية تعريف define أصنافٍ وواجهاتٍ وتوابعٍ مُعمَّمة، ولكن لحين وصولنا إلى تلك الجزئية، سنكتفي بأصناف جافا المُعمَّمة المُعرَّفة مُسبقًا. أخيرًا، سنفحص مجاري التدفق streams بالقسم قادم أيضًا؛ وهي خاصيةٌ جديدةٌ نسبيًا بلغة جافا، وتَستخدِم البرمجة المُعمَّمة بكثرة. لا يُعدّ تصميم مكتبةٍ للبرمجة المُعمَّمة أمرًا سهلًا؛ في حين يتميز التصميم الذي تقترحه جافا لتنفيذ هذا النوع من البرمجة بالكثير من الخاصيات الرائعة، إلا أنه ليس الأسلوب الوحيد الممكن، وهو بالتأكيد ليس الحل الأمثل؛ حيث يُعاني -من وجهة نظر الكاتب- من بعض الخاصيات الشاذة، والتي قد تكون مع ذلك أفضل ما يمكن تحقيقه بالنظر إلى التصميم العام للغة جافا. سنَمُرّ الآن سريعًا على بعض المناهج الأخرى لتنفيذ البرمجة المُعمّمة، لتُكوِّن منظورًا عامًا عنها. البرمجة المعممة بلغة Smalltalk تُعدّ لغة Smalltalk واحدةً من أولى لغات البرمجة كائنية التوجه object-oriented، حيث لا تزال مُستخدَمةً حاليًا، ولكنها ليست شائعة. على الرغم من عدم وصولها إلى شعبية لغاتٍ، مثل Java و ++C، إلا أنها ألهمت الكثير من الأفكار المُستخدَمة بتلك اللغات. تُعد البرمجة بلغة Smalltalk مُعمَّمة بصورةٍ أساسيية نظرًا لتمتُّعها بخاصيتين أساسيتين، هما: ليس المُتغيَّرات بلغة Smalltalk نوعًا؛ أي أن قيم البيانات لها نوعٌ مثل عددٍ صحيح أو سلسلة نصية، ولكن المُتغيَّرات ليس لها نوع، حيث يُمكِن لمُتغيِّر مُعين أن يَحمِل قيمًا بيانيةُ من أي نوع. وبالمثل، لا يوجد للمُعاملات parameters نوعًا، أي يُمكِن تطبيق برنامجٍ فرعي subroutine معينٍ على قيم معاملاتٍ من أي نوع؛ كما يُمكِن لبنيةٍ بيانيةٍ data structure مُعينةٍ أن تَحمِل قيمًا بيانيةً من أي نوع. حيث يمكنك مثلًا وبمجرد تعريف بنية شجرةٍ ثنائيةٍ معينة بلغة Smalltalk، استخدامها مع الأعداد الصحيحة، أو السلاسل النصية، أو التواريخ، أو أي بياناتٍ من أي نوعٍ آخر؛ أي ليس هناك حاجةً لكتابة شيفرةٍ جديدةٍ لكل نوعٍ بياني. تُعدّ أي قيمةٍ بيانيةٍ بمثابة كائن object، كما أن جميع العمليات المُمكِن تطبيقها على الكائنات مُعرَّفةٌ مثل توابع methods ضِمْن صنفٍ معين، ويَنطبِق الأمر نفسه على جميع الأنواع الأساسية primitive بلغة جافا، مثل الأعداد الصحيحة. يعني ذلك أنه عند استخدِام العامل + مثلًا لجمع عددين صحيحين، تُنفَّذ العملية باستدعاء التابع المُعرَّف ضمن الصنف المُمثِل للأعداد الصحيحة. وبالمثل، عندما تُعرِّف صنفًا جديدًا، يُمكِنك تعرّيف العامل + ضمنه، ويمكنك بالتالي كتابة a + b لجمع كائناتٍ تنتمي إلى ذلك الصنف تمامًا مثلما تجمع أعدادًا. لنفترض الآن أنك تَكتُب برنامجًا فرعيًا يَستخدِم العامل + لجمع العناصر الموجودة ضمن قائمة list، حيث تستطيع أن تُطبِق ذلك البرنامج على قائمة أعدادٍ صحيحة أو على قائمةٍ من أي نوعٍ آخر يَتَضمَّن تعريفًا للعامل +. يكون الأمر نفسه إذا عرَّفت برنامجًا فرعيًا يَستخدِم العامل > لترتيب قائمة، حيث يُمكِنك تطبيقه على قوائمٍ تحتوي على أي نوعٍ من البيانات طالما يَتَضمَّن ذلك النوع تعريفًا للعامل >، أي ليس هناك حاجةً لكتابة برنامجٍ فرعي لكل نوعٍ مختلفٍ من البيانات. إذا توفَّرت الخاصيتان السابقتان ضمن لغةٍ معينة، يُمكِننا تطبيق الخوارزميات وبنى البيانات على أي نوعٍ من البيانات طالما كانت العمليات المناسبة مُعرَّفةً ضمن تلك الأنواع، حيث يُمثِل ذلك ماهية البرمجة المُعمَّمة. ربما ترى أن ذلك أمرًا مفيدًا دائمًا، وهو ما قد يدفعك إلى طرح السؤال التالي: لما لا تعمل كل اللغات البرمجية ببساطةٍ بنفس الأسلوب؟ في الحقيقة، في حين يُسهِل ذلك النوع من الحرية من كتابة البرامج، إلا أنه يُصعِّب مهمة كتابتها كتابةً صحيحةً متينة. إذا كان لديك مثلًا بنيةً بيانيةً قادرةً على حمل أي نوعٍ من البيانات، ستَجِد أنه من الصعب التأكُّد من كونها تَحمِل فقط النوع المطلوب من البيانات؛ وبالمثل، إذا كان لديك برنامجًا فرعيًا يُمكِنه ترتيب أي نوعٍ من البيانات، ستَجِد أنه من الصعب التأكُّد من تطبيقه فقط على تلك الأنواع التي تتضمَّن تعريفًا للعامل >. ليس هناك طريقةً تُمكِّن المُصرِّف compiler من التأكُّد من مثل تلك الأشياء، وعليه ستَظهَر المشكلة فقط أثناء زمن التشغيل run time، أي عند محاولة تطبيق عمليةٍ معينةٍ على نوع بياناتٍ لا يتضمَّن تعريفًا لتلك العملية، وعندها سينهار crash البرنامج. البرمجة المعممة بلغة C++ تُعدّ لغة C++ -بخلاف Smalltalk- لغةً صارمةً في تحديد النوع strongly typed، حيث يوجد لكل متغيرٍ variable نوعًا معينًا، ويُمكنه أن يَحمِل فقط قيمًا بيانيةً تنتمي إلى ذلك النوع؛ يعني ذلك أنه يستحيل تطبيق البرمجة المعمَّمة بنفس الكيفية المتوفرة بلغة Smalltalk. تتوفّر خاصية القوالب templates بلغة C++ مما يُمكِّنها من تطبيق نظامٍ آخر من البرمجة المُعمَّمة، حيث يُمكِنك ببساطةٍ كتابة قالب برنامجٍ فرعي subroutine template واحدٍ، بدلًا من كتابة برنامجٍ فرعي subroutine مختلف لترتيب كل نوعٍ من البيانات. لاحِظ أن القالب ليس برنامجًا فرعيًا، وإنما هو أشبه بمصنعٍ لإنشاء البرامج الفرعية. اُنظر المثال التالي: template<class ItemType> void sort( ItemType A[], int count ) { //1 for (int i = count-1; i > 0; i--) { int position_of_max = 0; for (int j = 1; j <= i ; j++) if ( A[j] > A[position_of_max] ) position_of_max = j; ItemType temp = A[i]; A[i] = A[position_of_max]; A[position_of_max] = temp; } } حيث تعني [1]: رتِّب عناصر المصفوفة A ترتيبًا تصاعديًا، حيث تُرتَّب العناصر الموجودة بالمواضع 0 و 1 و 2 وصولًا إلى count-1، وتُدعى الخوارزمية المُستخدَمة للترتيب باسم الترتيب الانتقائي selection sort. تُعرِّف الشيفرة بالأعلى قالب برنامجٍ فرعي؛ فإذا حذفت السطر الأول template<class ItemType>، واستبدلت كلمة int بكلمة ItemType بباقي شيفرة القالب، فستَكون النتيجة برنامجًا فرعيًا subroutine بإمكانه ترتيب مصفوفاتٍ من الأعداد الصحيحة. يُمكِنك في الواقع استبدال أي نوعٍ بالنوع ItemType بما في ذلك الأنواع الأساسية primitive types، حيث يمكنك مثلًا استبدال كلمة string بكلمة ItemType للحصول على برنامج فرعي يُرتِّب مصفوفات سلاسل نصية. هذه ببساطةٍ الطريقة التي يتعامل بها المُصرِّف مع أي قالب؛ فإذا كتبت sort(list,10) بالبرنامج، حيث list مصفوفة أعدادٍ صحيحة من النوع int، فسيستخدم المُصرِّف القالب لتوليد برنامجٍ فرعي لترتيب مصفوفة أعدادٍ صحيحة؛ إذا كتبت sort(cards,10)، حيث cards هي مصفوفة كائناتٍ objects من النوع Card، فسيولّد المُصرِّف برنامجًا فرعيًا لترتيب مصفوفة كائناتٍ من النوع Card. يَستخدِم القالب العامل > للموازنة بين القيم؛ فإذا كان ذلك العامل مُعرَّفًا للقيم من النوع Card، فسينجَح المُصرِّف بتوليد برنامجٍ فرعي لترتيب كانئات النوع Card بالاستعانة بالقالب؛ أما إذا لم يَكُن العامل > مُعرَّفًا للصنف Card، فسيَفشَل المُصرِّف أثناء زمن التصريف compile time وليس أثناء زمن التشغيل run time، مما يتسبَّب بانهيار البرنامج كما هو الحال بلغة Smalltalk. يُمكِنك كتابة تعريفاتٍ لعوامل مثل > لأي نوعٍ بلغة C++، أي يُمكِن للعامل > أن يَعمَل مع قيمٍ من النوع Card. توفَّر C++ قوالبًا للأصناف بالإضافة إلى قوالب البرامج الفرعية subroutine templates؛ فإذا كتبت قالبًا لصنف شجرة ثنائية، فسيُمكِنك استخدِامه لإنشاء أصناف أشجارٍ ثنائيةٍ مُكوَّنةٍ من أعدادٍ صحيحةٍ من النوع int، أو سلاسل نصية من النوع string، أو تواريخ، أو غير ذلك وجميعها بنفس القالب. تأتي الإصدارات الأحدث من C++ مصحوبةً بعددٍ كبيرٍ من القوالب المكتوبة مُسبقًا، فيما يُعرَف باسم مكتبة القوالب القياسية Standard Template Library -أو اختصارًا STL-، والتي يراها الكثير معقدةً للغاية، ولكنها تُعدُّ مع ذلك واحدةً من أكثر خاصيات C++ تشويقًا. البرمجة المعممة بلغة جافا Java مرّت البرمجة المُعمَّمة بلغة جافا بعدة مراحلٍ من التطوير، وفي حين لم تتضمَّن الإصدارات الأولى من اللغة خاصية الأنواع ذات المعامِلات غير محدَّدة النوع parameterized types، إلا أنها وفَّرت أصنافًا تُمثِل بنى البيانات data structures الشائعة؛ حيث صُمِّمت تلك الأصناف لتعمل مع النوع Object أي يُمكِنها أن تَحمِل أي نوعٍ من الكائنات objects. لم تكن هناك أي طريقةٍ لتخصيص أو قصر أنواع الكائنات المسموح بتخزينها ضمن بنيةٍ بيانيةٍ معينة، حيث لم يَكُن الصنف ArrayList مبدئيًا ضمن الأنواع ذات المعاملات غير محدَّدة النوع، أي كان من الممكن لأي مُتغيِّر من الصنف ArrayList أن يحمل أي نوعٍ من الكائنات. إذا كان list مثلًا متغيرًا من النوع ArrayList، فسيعيد list.get(i) قيمةً من النوع Object، وبالتالي إذا استخدم المبرمج المُتغيِّر list لتخزين سلاسلٍ نصية من النوع String، فسيتوجب عليه تحويل نوع type-cast القيمة المعادة من list.get(i) إلى سلسلةٍ نصية من النوع String على النحو التالي: String item = (String)list.get(i); يقع ذلك تحت تصنيف البرمجة المُعمَّمة؛ حيث يُمكِننا بالنهاية استخدام صنفٍ واحدٍ فقط للعمل مع أي نوعٍ من الكائنات، ولكنه في الواقع أشبه بلغة Smalltalk منه بلغة C++. وكما هو الحال مع لغة Smalltalk، سينتج عن ذلك حدوث الأخطاء أثناء زمن التشغيل لا زمن التصريف compile time؛ فإذا افترض مبرمجٌ مثلًا أن جميع العناصر الموجودة ضمن بنيةٍ بيانيةٍ معينة هي سلاسلٌ نصية strings، وحاول معالجتها بناءً على ذلك الأساس؛ فسيقع خطأٌ أثناء زمن التشغيل run time إذا احتوت تلك البنية على نوعٍ آخر من البيانات. عندما يحاول البرنامج أن يُحوِّل type-cast نوع قيمةٍ واقعةٍ ضمن بنيةٍ بيانيةٍ data structure إلى النوع String، سيقع خطأ من النوع ClassCastException بلغة جافا إذا لم تكن تلك القيمة من النوع String بالأساس. أضاف الإصدار الخامس من جافا خاصية الأنواع ذات المعاملات غير محدَّدة النوع، مما ساعد على إنشاء بنى بياناتٍ مُعمَّمة generic data structures يُمكِن فحص نوعها أثناء زمن التصريف لا زمن التشغيل. إذا كانت list مثلًا قائمةٌ من النوع ArrayList<String>، فسيَسمَح المُصرِّف بإضافة الكائنات التي تنتمي إلى النوع String فقط إلى تلك القائمة list. علاوةً على ذلك، تَكون القيمة المعادة من استدعاء list.get(i) من النوع String، ولهذا ليس من الضروري تحويلها إلى النوع الفعلي type-casting. تُعدّ الأصناف ذات المعاملات غير محدَّدة النوع بلغة جافا شبيهةً نوعًا ما بأصناف القوالب template classes بلغة C++، على الرغم من اختلاف طريقة التنفيذ. سنعتمد خلال هذا الفصل على تلك الأصناف، ولكن عليك أن تتذكَّر بأن استخدام تلك المعاملات ليس إلزاميًا عند التعامل مع تلك الأصناف، فما يزال بإمكانك استخدام صنفٍ ذو معاملاتٍ غير محدَّدة النوع كما لو لم يَكُن كذلك، مثل كتابة ArrayList، حيث يُمكِن لأي كائنٍ أن يُخزَّن ضمنه في تلك الحالة؛ وإذا كان ذلك ما تريده حقًا، فمن الأفضل كتابة النوع ArrayList<Object>. لاحِظ وجود فرقٍ كبير بين الأصناف ذات المعاملات غير محدَّدة النوع بلغة جافا Java وبين أصناف القوالب بلغة C++؛ حيث لا تُعدّ أصناف القوالب بلغة C++ أصنافًا من الأساس، ولكنها بمثابة مصانعٍ لإنشاء الأصناف. في كل مرةٍ نَستخدِم خلالها صنف قالبٍ template class معين مع نوعٍ جديد، فسيُصرِّف المُصرِّف صنفًا جديدًا؛ أما بالنسبة للغة جافا، فهناك ملف صنف مُصرَّف وحيد لكل صنفٍ ذو معاملاتٍ غير مُحدَّدة النوع، حيث يوجد مثلًا ملف صنفٍ وحيدٍ اسمه ArrayList.class للصنف ArrayList، وتَستخدِم أنواعٌ، مثل ArrayList<String> و ArrayList<Integer> نفس ذلك الملف المُصرَّف كما هو الحال مع النوع ArrayList العادي. يقتصر دور معامل النوع type parameter، مثل String أو Integer في الواقع على تبليغ المُصرِّف بوجوب تقييد نوع الكائنات المسموح بتخزينها ضمن تلك البنية البيانية، وليس له أي تأثيرٍ خلال زمن التشغيل، حيث يُقال عندها أن معلومة النوع قد حُذِفَت أثناء زمن التشغيل run time، مما يؤدي إلى الكثير من الأمور الغريبة نوعًا ما. لا يُمكِنك مثلًا فحص اختبارٍ مثل: if (list instanceof ArrayList<String>) لأن قيمة العامل instanceof تُحصَّل أثناء زمن التشغيل، ولا يوجد سوى الصنف العادي ArrayList أثناء زمن التشغيل. ولا يُمكِنك أيضًا إجراء تحويلٍ بين الأنواع type-cast إلى ArrayList<String>، بل حتى لا تستطيع استخدام العامل new لإنشاء مصفوفةٍ نوعها الأساسي base type هو ArrayList<String>، كأن تَكْتُب: new ArrayList<String>[N] لأن العامل new يُحصَّل أثناء زمن التشغيل، ولا يكون هناك شيءٌ اسمه ArrayList<String> في ذلك الوقت كما ذكرنا مُسبَقًا، وإنما فقط النوع العادي ArrayList بدون معاملاتٍ غير مُحدَّدة النوع non-parameterized. على الرغم من عدم القدرة على إنشاء مصفوفةٍ من النوع ArrayList<String>، يُمكِنك إنشاء قائمةٍ من النوع ArrayList تحتوي على قائمةٍ أخرى من النوع ArrayList<String>، ويُكتَب النوع على النحو التالي: ArrayList<ArrayList<String>> تظهَر تلك المشكلات لحسن الحظ فقط بالبرمجة المتقدمة نوعًا ما، حيث لا يُواجِه غالبية المبرمجين الذين يَستخدِمون الأصناف ذات المعاملات غير محدَّدة النوع تلك المشكلات، ويُمكِنهم الاستفادة من نموذج البرمجة المُعمَّمة وبأنواع بياناتٍ آمنة type-safe دون أي صعوبة. لاحِظ أنه في حالة كان المُصرِّف قادرًا على استنتاج اسم معامل النوع type parameter المُستخدَم بصنفٍ ذي معاملاتٍ غير مُحدَّدة النوع، يمكن عندها حَذْف اسم معامل النوع. نظرًا لأن المصفوفة المُنشأة في المثال التالي ستكون حتمًا من النوع ArrayList<String> لتتوافق مع نوع المُتغيِّر، فمن الممكن حَذْف كلمة String بتعليمة الباني constructor على النحو التالي: ArrayList<String> words = new ArrayList<>(); إطار جافا للتجميعات تُوفِّر جافا عدة أنواعٍ ذات معاملاتٍ غير مُحدَّدة النوع لتمثيل بنى البيانات الشائعة، حيث يُشار عادةً إلى تلك المجموعة من الأصناف والواجهات interfaces باسم إطار عمل جافا للتجميعات Java Collection Framework -أو اختصارًا JCF-، والتي سنناقشها خلال الأقسام القليلة التالية. يُمكِننا تقسيم بنى البيانات المُعمَّمة generic بإطار عمل جافا للتجميعات إلى تصنيفين، هما التجميعات collections والخرائط maps؛ حيث تشير التجميعة ببساطة إلى تجميعةٍ من الكائنات؛ أما الخريطة فهي تَربُط كائنات مجموعة بكائنات مجموعةٍ أخرى بنفس الأسلوب الذي يَربُط به القاموس التعريفات بالكلمات، أو يَربُط به دليل الهاتف أرقام الهواتف بأسماء الأشخاص. تُشبِه الخريطة القوائم المترابطة association list، التي ناقشناها بمقال البحث والترتيب في المصفوفات Array في جافا؛ حيث تُمثِل الواجهتين Collection<T> و Map<T,S> ذواتا المعاملات غير مُحدَّدة النوع التجميعات والخرائط بلغة جافا، بحيث تُشير T و S إلى أي نوعٍ باستثناء الأنواع الأساسية. تُعدّ الواجهة Map<T,S> مثالًا على الأنواع ذات المعاملات غير مُحدَّدة النوع، ويَملُك تحديدًا معاملي نوع type parameters، هما T و S. سنتناول خلال هذا المقال التجميعات، بينما سنناقش الخرائط تفصيليًا في مقال قادم. تُقسم التجميعات بدورها إلى نوعين، هما القوائم lists والأطقم sets؛ حيث تُخزِّن القوائم الكائنات الموجودة بها وفقًا لتسلسلٍ خطيٍ معين، وهذا يَعنِي أنه يُمكِننا الإشارة إلى العنصر الأول أو الثاني الموجود ضمن قائمةٍ معينة. علاوةً على ذلك، لا بُدّ أن يَتبَع أي عنصرٍ ضمن القائمة باستثناء العنصر الأخير عنصرًا آخرًا. في المقابل، لا يُمكِن لأي طقمٍ set أن يتضمَّن نفس الكائن أكثر من مرة، ولا تُعدّ العناصر الموجودة به مُرتَّبةً وفقًا لأي ترتيب، أي لا ينبغي أن تفكر بها على هذا الأساس. تُمثِل الواجهتان List<T> و Set<T> القوائم والأطقم على الترتيب، وهما مُشتقتان من الواجهة Collection<T>، وهذا يَعنِي أن الكائنات المُنفِّذة للواجهة List<T> أو Set<T> تُنفِّذ الواجهة Collection<T> أيضًا على نحوٍ تلقائي. تُخصِّص الواجهة Collection<T> العمليات العامة المُمكن تطبيقها على أي تجميعة؛ بينما تُخصِّص الواجهتان List<T> أو Set<T> أي عملياتٍ إضافيةٍ أخرى ضروريةً للقوائم والأطقم على الترتيب. لاحِظ أن أي كائن فعليّ سواءٌ كان تجميعةً، أو قائمةً، أو طقمًا set، لا بُدّ أن ينتمي إلى صنفٍ حقيقي concrete class يُنفِّذ الواجهة المقابلة. يُنفِّذ الصنف ArrayList<T> الواجهة List<T> على سبيل المثال، ويُنفِّذ بالتالي Collection<T>؛ وهذا يعني أننا نستطيع استخدام جميع التوابع المُعرَّفة بواجهتي القوائم والتجميعات مع النوع ArrayList. سنفحص أصنافًا مختلفة تُنفِّذ واجهتي الطقم والقائمة بالمقال التالي، ولكن قبل أن نفعل ذلك، سنناقش سريعًا بعضًا من العمليات العامة المتاحة بأي تجميعة. تُخصِّص الواجهة Collection<T> توابعًا لإجراء عددٍ من العمليات الأساسية على أي تجميعةٍ من الكائنات. بما أن التجميعة مفهومٌ عام، فإن العمليات التي يُمكِن تطبيقها عليها في العموم عامةٌ أيضًا؛ وهذا يعني أنها عملياتٌ مُعمَّمة أي قابلة للتطبيق على أنواعٍ مختلفة من التجميعات التي تحتوي بدورها على أنواعٍ مختلفة من الكائنات. فإذا كان coll كائنًا يُنفِّذ الواجهة Collection<T> على سبيل المثال، تَكُون العمليات التالية مُعرَّفةً له: coll.size(): تُعيد عددًا صحيحًا من النوع int يُمثِل عدد الكائنات الموجودة بالتجميعة. coll.isEmpty(): تُعيد قيمةً من النوع المنطقي boolean، حيث تَكُون مساويةً للقيمة true إذا كان حجم التجميعة يُساوي الصفر. coll.clear(): تَحذِف جميع الكائنات الموجودة بالتجميعة. coll.add(tobject): تُضيف tobject إلى التجميعة. لا بُدّ أن يَكون المعامل من النوع T، وإلا سيَحدُث خطأ في بناء الجملة syntax error أثناء زمن التصريف compile time. إذا كان T صنف، فإنه يَشمَل جميع الكائنات التي تنتمي لأي صنفٍ فرعي subclass مُشتقٍّ من T؛ أما إذا كان T واجهة، فإنه يَتضمَّن أي كائنٍ مُنفّذٍ لتلك الواجهة T. يُعيد التابع add() قيمةً من النوع المنطقي تُمثِّل فيما إذا كان التابع قد أجرى تعديلًا فعليًا على التجميعة أم لا؛ فإذا أضفت كائنًا إلى طقمٍ set معين، وكان ذلك الكائن موجودًا بالفعل ضمن ذلك الطقم، لا يكون للتابع أي تأثير. coll.contains(object): تُعيد قيمةً من النوع المنطقي، والتي تكون مساويةً القيمة true إذا كان object موجودًا بالتجميعة. لاحِظ أنه من غير الضروري أن يكون المُعامل object من النوع T؛ فقد ترغب بفحص ما إذا كان object موجودًا ضمن التجميعة بغض النظر عن نوعه. بالنسبة لعملية اختبار التساوي، تُعدّ القيمة الفارغة null مُساويةً لنفسها؛ أما بالنسبة للكائنات غير الفارغة، يختلف المقياس الذي يُحدَّد على أساسه تَساوِي تلك الكائنات من عدمه من نوع تجميعةٍ إلى آخر. coll.remove(object): تَحذِف object من التجميعة إذا كان موجودًا بها، وتُعيد قيمةً من النوع المنطقي تُحدِّد فيما إذا كان التابع قد عثر على object ضمن التجميعة أم لا. ليس من الضروري أن يكون المعامل object من النوع T. تُجرَى عملية اختبار التساوي بنفس الطريقة المُتبعَّة بالتابع contains. coll.containsAll(coll2): تُعيد قيمةً منطقيةً تَكُون مساويةً للقيمة true إذا كانت جميع كائنات التجميعة coll2 موجودةً أيضًا بالتجميعة coll، ويُمكِن للمعامل coll2 أن ينتمي لأي نوع تجميعة. coll.addAll(coll2): تُضيف جميع كائنات التجميعة coll2 إلى coll، حيث يُمكِن للمعامل coll2 أن يُمثِل أي تجميعةٍ من النوع Collection<T>، ولكنه قد يكون أيضًا أعم من ذلك. على سبيل المثال، إذا كان T صنف و S صنفٌ فرعي subclass مُشتقٌ من T، فقد يكون coll2 من النوع Collection<S>، وهو أمرٌ منطقي لأن أي كائن من النوع S هو بالضرورة من النوع T، وبالتالي يُمكِن إضافته إلى coll. coll.removeAll(coll2): تَحذِف أي كائنٍ من التجميعة coll إذا كان موجودًا أيضًا بالتجميعة coll2، حيث يُمكِن للمعامل coll2 أن ينتمي لأي نوع تجميعة. coll.retainAll(coll2): تَحذِف أي كائنٍ من التجميعة coll إذا لم يَكُن موجودًا بالتجميعة coll2؛ أي أنها تُبقِي فقط الكائنات غير الموجودة بالتجميعة coll2، ويُمكِن للمعامل coll2 أن ينتمي لأي نوع تجميعة. coll.toArray(): تُعيد مصفوفةً من النوع Object[] تحتوي على جميع عناصر التجميعة. نلاحظ أن النوع المُعاد من التابع هو Object[] وليس T[]. هناك في الواقع نسخةٌ أخرى من نفس التابع coll.toArray(tarray)، والتي تَستقبِل مصفوفةً من النوع T[] مثل مُعامل وتُعيد مصفوفةً من النوع T[] تحتوي على جميع عناصر التجميعة. في حال كانت المصفوفة المُمرَّرة tarray كبيرةً بما يكفي لحَمْل جميع عناصر التجميعة، فسيُخزَّن التابع العناصرأيضًا بنفس المصفوفة المُمرَّرة ويُعيدها مثل قيمة للتابع؛ أما إذا لم تَكُن المصفوفة كبيرة بما يكفي، يُنشِئ التابع مصفوفةً جديدةً لحمل تلك العناصر، ويَقتصِر في تلك الحالة دور المصفوفة المُمرَّرة tarray على تحديد نوع المصفوفة المُعادة فقط. يُمكِننا مثلًا استدعِاء coll.toArray(new String[0]) إذا كان coll تجميعةً من السلاسل النصية من النوع String، وسيُعيد الاستدعاء السابق بناءً على ذلك مصفوفةً جديدةً من النوع String. طالما أنّ التوابع السابقة مُعرَّفةٌ ضمن الواجهة Collection<T>، فإنها بطبيعة الحال مُعرَّفةٌ ضمْن كل كائنٍ يُنفِّذ تلك الواجهة، ولكن هناك مشكلة، حيث لا يُمكِن تغيير حجم بعض أنواع التجميعات بعد إنشائها، وبالتالي لا يَكون للتوابع المسؤولة عن إضافة الكائنات وحذفها معنىً بالنسبة لتلك التجميعات على الرغم من أنه ما يزال من الممكن استدعائها، ويَحدُث في تلك الحالة اعتراضٌ exception من النوع UnsupportedOperationException أثناء زمن التشغيل. إضافةً لذلك ونظرًا لأن Collection<T> هي واجهة interface وليست صنفًا حقيقيًا concrete class، فإن التنفيذ الفعلي للتابع متروكٌ للأصناف المُنفِّذة للواجهة؛ مما يعني عدم امكانية ضمَان توافق الدلالة الفعلية لتلك التوابع مع ما شرحناه بالأعلى لجميع تجميعات الكائنات من خارج إطار عمل جافا للتجميعات Java Collection Framework. بالنسبة لكفاءة تلك العمليات، فليس من الضروري أن تعمل عمليةٌ معينةٌ بنفس كفاءة أنواعٍ مختلفةٍ من التجميعات؛ حيث أنها بالنهاية مُعرَّفةٌ بكل تجميعةٍ على حدى. يَنطبِق ذلك حتى على أبسط التوابع مثل size()التي فقد تختلف كفائتها تمامًا من تجميعةٍ لأخرى؛ حيث من الممكن أن يتضمَّن تحصيل قيمة التابع size() عَدّ العناصر الموجودة بالتجميعة بالنسبة لبعض أنواع التجميعات، ويكون عندها عدد خطوات العملية مُساويًا لعدد عناصر التجميعة؛ وقد يحتفظ نوعٌ آخر من التجميعات بمتغيرات نسخ instance variables تُحدِّد حجمها الحالي، وعندها يقتصر تحصيل قيمة التابع size() على إعادة قيمة مُتغيِّر، أي يَستغرِق تنفيذ العملية خطوةً واحدةً فقط بغض النظر عن عدد عناصر التجميعة. بناءً على ما سبق، لا بُدّ من الانتباه دائمًا لكفاءة العمليات، واختيار التجميعة بحيث تكون العمليات التي ستجريها أكثر من غيرها ذات الكفاءة الأعلى، وسنرى عدة أمثلة على ذلك في المقالين التاليين. المكررات وحلقات التكرار for-each تُعرِّف الواجهة Collection<T> بعض الخوارزميات المُعمَّمة البسيطة، ولكن كيف يختلف ذلك عن كتابة خوارزميةٍ مُعمَّمةٍ خاصةٍ جديدة؟ لنفترض مثلًا أننا نريد طباعة جميع العناصر الموجودة ضمن التجميعة. لنُنفِّذ ذلك تنفيذًا مُعمَّمًا، نحتاج إلى طريقةٍ ما للمرور عبر جميع عناصر التجميعة واحدًا تلو الآخر. رأينا طريقة فعل ذلك لبعض بنى البيانات data structure؛ فإذا كان لدينا مصفوفةٌ مثلًا، فإننا نستطيع ببساطة استخدام حلقة التكرار for للمرور عبر جميع فهارسها indices. تُعدُّ القائمة المترابطة linked list مثالًا آخر، حيث يُمكِننا المرور عبر عناصرها باستخدام حلقة التكرار while، بحيث نُحرِّك ضمن تلك الحلقة مؤشرًا على طول القائمة. بالنسبة للشجرة الثنائية binary tree، يُمكِننا استخدام برنامجٍ فرعيٍ تعاودي recursive لإجراء ما يُعرَف باسم اجتياز في الترتيب inorder traversal؛ أما بالنسبة للتجميعة collection، فيمكننا تمثيلها بأيٍ مما سبق، وبالتالي علينا الإجابة عن السؤال التالي: كيف سنستطيع كتابة تابعٍ مُعمَّمٍ واحدٍ يُمكِنه العمل مع تجميعاتٍ يُمكِن تخزينها بصيغٍ مختلفةٍ تمامًا؟ يَكْمُن حل تلك المشكلة فيما يُعرَف باسم المُكرِّرات iterators؛ وهو ببساطةٍ كائنٌ يُمكِن استخدامه لاجتياز تجميعة. تختلف طريقة تنفيذ المُكرِّر بحسب نوع التجميعة، لكنها تستخدَم جميعًا بنفس الأسلوب. وبالتالي، أيُّ خوارزميةٍ يعتمد اجتيازها لعناصر التجميعة على وجود مُكرِّر هي خوارزمية مُعمَّمة؛ لأننا ببساطة سنتمكَّن من تطبيقها على أي نوعٍ من التجميعات. قد تبدو فكرة المُكرِّرات غريبةً نوعًا ما خاصةً إذا كانت هذه هي المرة الأولى التي تتعرَّض خلالها للبرمجة المُعمَّمة، ولكن عليك أن تدرك أنها ستساعدك على حل بعض أصعب المشكلات بطريقةٍ أنيقة. تُعرِّف الواجهة Collection<T> تابعًا يُمكِننا استخدامه للحصول على المُكرِّر iterator لأي تجميعة. إذا كانت coll تجميعة، فسيعيد coll.iterator() مُكرِّرًا يُمكِننا اِستخدَامه لاجتياز عناصر التجميعة. يُمكِنك التفكير بالمُكرِّر كما لو كان نوعًا عامًا من المؤشرات يبدأ من مقدمة التجميعة، وبإمكانه التحرُّك على طول التجميعة من عنصرٍ إلى آخر. تُعرَّف المُكرِّرات عن طريق واجهةٍ ذات معاملات غير مُحدَّدة النوع parameterized interface اسمها Iterator<T>. إذا نفِّذت coll الواجهة Collection<T> للنوع T، فسيعيد استدعاء coll.iterator() مُكرِّرًا من النوع Iterator<T>، حيث تُشير T إلى معامل النوع type parameter. تُعرِّف الواجهة Iterator<T> ثلاثة توابع فقط. إذا كان tier يُشير إلى كائن مُنفِّذ للواجهة Iterator<T>، يكون لدينا التوابع التالية: iter.next(): يُعيد العنصر التالي، ويُقدِّم المُكرِّر خطوةً للأمام، وتكون القيمة المُعادة من النوع T. يسمح لك التابع بفحص أحد عناصر التجميعة. لاحِظ أنه لا توجد طريقةٌ لفحص عنصرٍ دون أن يَمُر المُكرِّر عبره خطوةً للأمام. إذا استدعينا هذا التابع ولم يَكن هناك أي عناصرٍ متبقية ضمن التجميعة، فسيحدث اعتراضٌ من النوع NoSuchElementException. iter.hasNext(): يُعيد قيمةً منطقيةً تُمثِل فيما إذا كان هناك عناصرٌ جاهزةٌ متبقيةٌ للمعالجة. ينبغي استدعاء هذا التابع عمومًا قبل استدعاء iter.next(). iter.remove(): إذا استدعيت هذا التابع بعد iter.next()، فسيحذِف العنصر الذي رأيته للتو من التجميعة. لا يستقبل هذا التابع أي مُعاملات، ويَحذِف آخر عنصرٍ أعاده التابع iter.next()، مما قد يؤدي إلى اعتراضٍ من النوع UnsupportedOperationException في حال لم تدعم تلك التجميعة حذف العناصر. نستطيع كتابة شيفرة لطباعة كل العناصر الموجودة بأي تجميعة بالاستعانة بالمُكرِّرات iterators. لنفترض مثلًا أن coll من النوع Collection<String>، وبالتالي سيعيد التابع coll.iterator() قيمةً من النوع Iterator<String>، ويُمكِننا كتابة ما يلي: Iterator<String> iter; // صرِّح عن المُكرِّر iter = coll.iterator(); // استرجع مُكررًا للتجميعة while ( iter.hasNext() ) { String item = iter.next(); // اقرأ العنصر التالي System.out.println(item); } ستَعمَل الصيغة العامة السابقة مع أي أنواعٍ أخرى من المعالجة، حيث تَحذِف الشيفرة التالية مثلًا جميع القيم الفارغة null من أي تجميعةٍ من النوع Collection<Color>، طالما كانت التجميعة تدعم حذف القيم: Iterator<Color> iter = coll.iterator(): while ( iter.hasNext() ) { Color item = iter.next(); if (item == null) iter.remove(); } لاحِظ أنه عند استخدامنا أنواعًا، مثل Collection<T>، أو Iterator<T>، أو أي نوعٍ آخر ذا معاملاتٍ غير مُحدَّدة النوع ضمن شيفرةٍ فعلية، فإننا نستخدمها دائمًا مع أنواعٍ فعليةٍ، مثل String، أو Color في موضع معامل النوع الصوري T؛ حيث يُستخدَم مثلًا مُكرِّرٌ من النوع Iterator<String> للمرور عبر عناصر تجميعة سلاسلٍ نصيةٍ من النوع String؛ بينما يُستخدَم مُكرِّرٌ من النوع Iterator<Color> للمرور عبر عناصر تجميعةٍ من النوع Color وهكذا. تُستخدَم المُكرِّرات عادةً لتطبيق نفس العملية على جميع عناصر تجميعةٍ معينة، ولكن يمكننا استخدام حلقة التكرار for-each بدلًا من المُكرِّر في كثيرٍ من الحالات. كنا قد ناقشنا طريقة استخدام حلقة for-each مع المصفوفات بمقال تعرف على المصفوفات (Arrays) في جافا، ومع النوع ArrayList بمقال المشار إليه سلفًا، ويُمكِنها أن تُستخدَم أيضًا للمرور عبر عناصر أي تجميعة. على سبيل المثال، إذا كان coll تجميعةً من النوع Collection<T>، تُكْتَب حلقة for-each بالصياغة التالية: for ( T x : coll ) { // لكل كائن x من النوع T بالتجميعة coll // عالج x } تمثِّل x بالأعلى مُتغيِّرًا مُتحكِّمًا بالحلقة loop control variable، وسيُسند كل كائنٍ بالتجميعة coll إلى x، وسيُطبَق متن body الحلقة عليه. صرَّحنا عن x لتَكون من النوع T، لأن الكائنات الموجودة بالتجميعة coll من النوع T. إذا كانت namelist تجميعةً من النوع Collection<String> مثلًا، يُمكِننا طباعة جميع الأسماء الموجودة بالتجميعة على النحو التالي: for ( String name : namelist ) { System.out.println( name ); } يُمكِننا بالطبع كْتابة حلقة while مع مُكرِّر بدلًا من حلقة for-each، ولكن الأخيرة أسهل بالقراءة. التساوي Equality والموازنة Comparison تتضمَّن الواجهة Collection عدة توابعٍ methods لفحص تَساوِي كائنين. يبحث التابعان coll.contains(object) و coll.remove(object) على سبيل المثال عن عنصرٍ يُساوِي object ضمن التجميعة. لا يُعد اختبار تساوي كائنين أمرًا بسيطًا كما قد تظنّ، ولا يُعطِي العامل == دائمًا إجاباتٍ معقولةً عند تطبيقه على الكائنات؛ لأنه يَفحَص فيما إذا كان الكائنان متطابقين أي إذا كانا بنفس موضع الذاكرة memory location، وهو ما لا نعنيه عادةً عندما نرغب بفْحَص تَساوِي كائنين، وإنما نَعنِي ما إذا كانا يحملان نفس القيمة، وهو أمرٌ مختلفٌ كليًا. إذا كان لدينا مثلًا قيمتين من النوع String، فلا بُدّ أن نَعُدّهما متساويين إذا تضمَّنا نفس متتالية المحارف بغض النظر عن وجودهما بنفس موضع الذاكرة من عدمه؛ وإذا كان لدينا قيمتين من النوع Date، فلا بُدّ أن نَعُدّهما متساويين إذا كانا يُمثِلان نفس التوقيت. يُعرِّف الصنف Object تابعًا اسمه equals(Object) بهدف فَحْص تساوي كائنين، وبحيث يؤدي نفس دور الاختبار التالي obj1 == obj2، ثم يُعيد قيمةً من النوع المنطقي boolean. تَستخدم كثيرًا من أصناف التجميعات ذلك التابع، ومع ذلك، لا يُعدّ هذا التعريف مناسبًا لكثيرٍ من الأصناف الفرعية المُشتقَّة من الصنف Object، وبالتالي يَنبغي أن يُعاد تعريفه overridden، حيث يُعيد الصنف String مثلًا تعريف التابع equals() بحيث تَكون قيمة str.equals(obj) لسلسلةٍ نصية str مساويةً للقيمة المنطقية true إذا كان obj من النوع String وكان يحتوي على نفس متتالية المحارف الموجود بالسلسلة النصية str. إذا أضفت صنفًا جديدًا، فينبغي أن يحتوي تعريفه على إعادة تعريفٍ للتابع equals() لتحصل على السلوك المطلوب عند فَحص تساوي كائنين من ذلك الصنف. يُمكِننا على سبيل المثال تعريف صنف Card على النحو التالي لنتمكَّن من اِستخدَامه داخل تجميعة collection: public class Card { // صنف لتمثيل ورق اللعب private int suit; // عدد من 0 إلى 3 لتمثيل بطاقات الكوبة والبستوني // والسباتي والديناري private int value; // عدد من 1 إلى 13 لتمثيل قيمة الورقة public boolean equals(Object obj) { try { Card other = (Card)obj; // Type-cast obj to a Card. if (suit == other.suit && value == other.value) { // 1 return true; } else return false; } catch (Exception e) { // 2 return false; } } . . // توابع أخرى وبناةٌ آخرين . } حيث تعني كل من: [1] تملك الورقة الأخرى نفس القيمة والرمز الخاص بهذه الورقة، لذلك يُمكن عدّهما متساويين. [2] سيلتقط الاعتراض NullPointerException الذي يَحدُث إذا كان obj فارغًا، وكذلك الاعتراض ClassCastException الذي يَحدُث إذا لم يَكُن obj من النوع Card. في تلك الحالات، لا يكون obj مُساوٍ لورقة اللعب 'Card'، لذلك أعِد false. لاحِظ أنه في حالة عدم وجود التابع equals() داخل الصنف، لن تعمل توابعٌ، مثل contains() و remove() المُعرَّفة بالواجهة Collection<Card> على النحو المُتوقَّع. يَنطبِق الأمر نفسه على عملية ترتيب العناصر الموجودة ضمن تجميعة، أي عملية ترتيبها تصاعديًا وفقًا لمعيارٍ معين. ليس هناك مفهومٌ بديهيٌ لمعنى الترتيب التصاعدي لعدِّة كائناتٍ objects عشوائية، ولهذا لا بُدّ من إضافة تابعٍ آخر قبل محاولة ترتيب تلك العناصر، بحيث يكون ذلك التابع مسؤولًا عن الموازنة بين تلك العناصر. يجب أن يُنفِّذ أيُّ كائنٍ تَنوِي استخدامه ضمن عملية موازنة الواجهة java.lang.Comparable. لاحِظ أن تلك الواجهة مُعرَّفةٌ مثل واجهةٍ ذات معاملات غير محدَّدة النوع Comparable<T>، مما يعني إمكانية الموازنة مع كائنٍ من النوع T. تُعرِّف الواجهة Comparable<T> التابع التالي: public int compareTo( T obj ) يُعيد الاستدعاء obj1.compareTo(obj2) قيمةً سالبةً، إذا كان obj1 يَأتِي قبل obj2 عندما تكون الكائنات مُرتَّبة ترتيبًا تصاعديًا؛ ويُعيد قيمةً موجبةً، إذا كان obj2 يَأتِي قبل obj1؛ وإذا كان الكائنان مُتساوِيين وفقًا للغرض من الموازنة، يُعيد التابع صفرًا. لا يعني ذلك بالضرورة أن الكائنين مُتساويان وفقًا للتابع الآخر obj1.equals(obj2). فإذا كانت الكائنات قيد الموازنة من النوع Address على سبيل المثال، فقد ترغب بترتيبها وفقًا للرقم البريدي، بحيث تَكُون العناوين ذات نفس الرقم البريدي مُتساوية، ولا يَعنِي ذلك أن تلك العناوين هي نفسها. يُنفِّذ الصنف String الواجهة Comparable<String>، ويُعرِّف التابع compareTo بحيث يُعيد صفرًا فقط إذا كانت السلسلتان النصيتان قيد الموازنة متساويتين. إذا عرَّفت صنفًا خاصًا جديدًا، وكنت ترغب بترتيب الكائنات المنتمية لذلك الصنف، فينبغي أن تَفعَل الشيء نفسه. اُنظر المثال التالي: // 4 public class FullName implements Comparable<FullName> { private String firstName, lastName; // الاسم الأول والأخير غير الفارغين public FullName(String first, String last) { // الباني if (first == null || last == null) throw new IllegalArgumentException("Names must be non-null."); firstName = first; lastName = last; } public boolean equals(Object obj) { try { FullName other = (FullName)obj; // Type-cast obj to type FullName return firstName.equals(other.firstName) && lastName.equals(other.lastName); } catch (Exception e) { return false; // إذا كان `obj` فارغًا أو لم يكن من النوع FullName } } public int compareTo( FullName other ) { if ( lastName.compareTo(other.lastName) < 0 ) { // 1 return -1; } else if ( lastName.compareTo(other.lastName) > 0 ) { // 2 return 1; } else { // 3 return firstName.compareTo(other.firstName); } } . . // توابع أخرى . } وتشير العناصر الآتية إلى: [1]: إذا جاء lastName قبل الاسم الأخير للكائن الآخر، فسيأتي FullName لهذا الكائن قبل FullName للكائن الآخر، ولذلك أعِد قيمةً سالبة. [2]: إذا جاء lastName بعد الاسم الأخير للكائن الآخر، فسيأتي FullName لهذا الكائن بعد FullName للكائن الآخر، ولذلك أعد قيمةً موجبة. [3]: الاسم الأخير لكلا الكائنين هو نفسه، ولذلك سنوازن بين أسمائهما الأولى باستخدام التابع compareTo المُعرَّف بالصنف String. [4]: يمثِّل هذا الصنف الاسم الكامل المُكوَّن من اسمٍ أول واسمٍ أخير. لاحِظ أن الصنف مُعرَّفٌ على النحو التالي class FullName implements Comparable<FullName>، وقد يبدو استخدام كلمة FullName مثل معامل نوع type parameter ضمن اسم الواجهة غريبًا بعض الشيء ولكنه صحيح؛ حيث يعني أننا ننوي موازنة الكائنات المنتمية إلى الصنف FullName مع كائناتٍ أخرى من نفس النوع. قد ترى أنه من البديهي أن تكون عملية الموازنة مع كائنٍ من نفس النوع، ولكنها ليست كذلك بالنسبة لمُصرِّف جافا، ولهذا أضفنا معامل النوع إلى اسم الواجهة على النحو التالي Comparable<FullName>. تُوفِّر جافا طريقةً أخرى لموازنة الكائنات عبر إضافة كائنٍ آخر يكون قادرًا على إجراء الموازنة، ويجب على ذلك الكائن تنفيذ الواجهة Comparator<T>، حيث T هي نوع الكائنات المطلوب موازنتها. تُعرِّف تلك الواجهة التابع التالي: public int compare( T obj1, T obj2 ) يُوازن التابع السابق كائنين من النوع T، حيث يُعيد قيمةً سالبةً أو موجبةً أو صفرًا اعتمادًا على ما إذا كان obj1 يَسبِق obj2، أو إذا كان obj1 يَلحَق obj2، أو إذا كان يُمكِن عَدّهما مُتساويين فيما يتعلق بالموازنة. تُستخدَم تلك الواجهة عادةً لموازنة الكائنات التي لا تُنفِّذ الواجهة Comparable، وكذلك لتخصيص أساليب ترتيبٍ مختلفة لنفس تجميعة الكائنات. لاحِظ أنه نظرًا لأن Comparator هو واجهة من نوع دالة functional interface، وتُستخدَم غالبًا تعبيرات لامدا lambda expressions لتعريفها (انظر مقال تعبيرات لامدا (Lambda Expressions) في جافا). سنناقش خلال المقالين التاليين طريقة استخدام Comparable و Comparator بالتجميعات والخرائط. الأنواع المعممة والأصناف المغلفة لا يُمكِننا تطبيق نموذج البرمجة المُعمَّمة generic programming بلغة جافا على الأنواع الأساسية primitive types كما ذكرنا بمقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا. أثناء حديثنا عن الصنف ArrayList؛ حيث يمكن لبنى البيانات data structures المُعمَّمة أن تَحمِل كائناتٍ فقط وليست الأنواع الأساسية كائنات. تستطيع في المقابل الأصناف المُغلِّفة wrapper classes، التي تعرَّضنا لها بمقال مفهوم المصفوفات الديناميكية ArrayLists في جافا أن تتجاوز ذلك القيد إلى حدٍ بعيد. يقابل كل نوعٍ أساسي صنفًا مُغلّفًا wrapper class، حيث يوجد لدينا مثلًا الصنف Integer للنوع int؛ والصنف Boolean للنوع boolean؛ والصنف Character للنوع char، وهكذا. يحتوي أي كائنٍ من النوع Integer على قيمةٍ من النوع int، حيث يعمل الكائن ببساطة مثل مغلِّف wrapper لقيمة النوع الأساسي، ويسمح هذا باستخدام النوع الأساسي ضمن سياقاتٍ تتطلّب بالأساس كائناتٍ مثل بنى البيانات المُعمَّمة generic data structures. يُمكِننا على سبيل المثال تخزين قائمة أعدادٍ صحيحة من النوع Integer بمُتغيّرٍ من النوع ArrayList<Integer>، وستكون واجهاتٌ، مثل Collection<Integer> و Set<Integer> مُعرَّفة. يُعرِّف الصنف Integer علاوةً على ذلك التوابع التالية: equals(). compareTo(). toString(). حيث تُنفِّذ ما ينبغي إجراؤه بما يتناسب مع النوع الأساسي المقابل. تنطبق الأمور نفسها على جميع الأصناف المُغلِّفة wrapper classes. تُجري لغة جافا تحويلًا تلقائيًا بين الأنواع الأساسية primitive types وما يُقابِلها من أنواعٍ مُغلِّفة. يعني ذلك أنه بمجرد إنشاء بنية بياناتٍ مُعمَّمة تحمل كائناتٍ تنتمي إلى إحدى الأصناف المُغلَّفة، فمن الممكن استخدام بنى البيانات كما لو كان بإمكانها حَمل قيمٍ من النوع الأساسي. إذا كان numbers مُتغيّرًا من النوع Collection<Integer>، فبإمكانك كتابة numbers.add(17)، أو numbers.remove(42)، ولا يُمكِنك حرفيًا إضافة قيمةٍ من النوع الأساسي مثل 17 إلى numbers؛ وإنما تُحوِّل جافا تلك القيمة تلقائيًا إلى كائنٍ مُغلِّف مُقابِل، أي Integer.valueOf(17)، ثم تُضيف ذلك الكائن إلى التجميعة. تُؤثِر عملية إنشاء كائنٍ جديدٍ على كلٍ من الوقت والذاكرة المُستهلَكين أثناء العملية، وهو ما ينبغي أن تَضعُه بالحسبان تحديدًا إذا كنت مهتمًا بكفاءة البرنامج. تُعدّ مصفوفةٌ من النوع int عمومًا أكثر كفاءةً من مصفوفةٍ من النوع ArrayList<Integer>. ترجمة -بتصرّف- للقسم Section 1: Generic Programming من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: تصميم محلل نموذجي تعاودي بسيط Recursive Descent Parser في جافا واجهة المستخدم الحديثة في جافا الأصناف المتداخلة Nested Classes في جافا
-
يستخدم الحاسوب اللغات الطبيعية natural language، مثل اللغة الإنجليزية واللغات الصناعية. هناك تساؤلاتٌ كثيرة حول الطريقة التي تنقل بها لغةٌ معينة معلومةً ما، وحول بنية اللغات في العموم، حيث تتشابه اللغات الطبيعية والصناعية إلى الحد الذي يُمكِن معه الاستعانة بدراسة اللغات البرمجية البسيطة والمفهومة نوعًا ما من أجل فهم اللغات الطبيعية الأكثر تعقيدًا. تطرح اللغات البرمجية الكثير من القضايا والتساؤلات الشيقة بما يكفي لتكون دراستها بحد ذاتها جديرةً بالاهتمام، ومنها مثلًا التساؤل حول كيفية تصميم الحاسوب ليكون قادرًا على فهم أبسط اللغات المُستخدَمة لكتابة البرامج. في الواقع، يستطيع الحاسوب التعامل على نحوٍ مباشر مع التعليمات المكتوبة بلغة الآلة machine language البسيطة؛ أي لا بُدّ من ترجمة اللغات عالية المستوى high level languages إلى لغة الآلة، ولكن إذا نظرنا للأمر، فإن المُصرِّف المسؤول عن ترجمة البرامج هو بالنهاية برنامج، فكيف يُمكِننا كتابة برنامج الترجمة ذاك؟ صيغة باكوس ناور Backus-Naur Form تتشابه اللغات الصناعية والطبيعية بأن لديهما بنيةً معروفةً تُعرَف باسم قواعد الصيغة syntax، والتي تتكوَّن من مجموعةٍ من القواعد المُستخدَمة لوصف ما يعنيه أن تكون الجملة صحيحة. يُستخدَم غالبًا أسلوب صيغة باكوس ناور Backus-Naur Form - تختصر إلى BNF - بالنسبة للغات البرمجة للتعبير عن قواعد الصيغة، حيث طوَّر عالمي الحاسوب جون باكوس John Backus وبيتر ناور Peter Naur هذا الأسلوب بأواخر الخمسينيات، وفي نفس تلك الفترة، طوّر عالم اللغويات نعوم تشومسكي Noam Chomsky نظامًا مكافئًا مُستقلًا لوصف قواعد اللغات الطبيعية. لا تستطيع صيغة باكوس ناور BNF التعبير عن كل قواعد الصيغة المحتملة، فلا يُمكنها مثلًا التعبير عن قاعدة "ضرورة الإعلان عن المتغير قبل استخدامه"، كما أنها لا تستطيع أن تقول أي شيءٍ عن معنى أو دلالة semantics اللغة. تُعدّ مشكلة تخصيص دلالة لغة معينة حتى وإن كانت لغة برمجةٍ صناعية واحدةً من المشكلات التي لم تُحَل إلى الآن. على الرغم من ذلك، تستطيع صيغة باكوس ناور BNF التعبير عن البنيات الأساسية للغة كما تلعب دورًا مركزيًا بتصميم المُصرِّفات compilers. تتوفَّر في الحقيقة تشكيلةٌ مختلفة من الترميزات لصيغة باكوس ناور، وسنستخدم خلال ما يلي الصيغة الأكثر شيوعًا. تُشيِر مصطلحاتٌ مثل "اسم noun" و"فعل متعد transitive verb" و"شبه جملة prepositional phrase" في اللغة الإنجليزية إلى تصنيفاتٍ نحويةٍ syntactic تصِف لَبِنات building blocks الجمل؛ وتُشير أيضًا مصطلحاتٌ، مثل "تعليمة statement" و"عدد number" و"حلقة while" إلى تصنيفاتٍ نحويةٍ تَصِف اللبنات الأساسية لبرامج جافا. يُكْتَب التصنيف النحوي بصيغة باكوس ناور على هيئة كلمةٍ محاطةٍ بالقوسين "<" و">"، مثل و و، وتُخصِّص القواعد rules بصيغة باكوس ناور BNF بنية عنصرٍ معينٍ ضمن تصنيفٍ نحويٍ معينٍ بالنسبة للتصنيفات النحوية الآخرى والرموز الأساسية للغة، ويُمثِل ما يلي أحد قواعد صيغة باكوس ناور للغة الإنجليزية: <sentence> ::= <noun-phrase> <verb-phrase> يُقْرأ الرمز "::=" على النحو التالي "يُمكِنه أن يكون"، حيث تنص القاعدة بالأعلى على أن أي جملة يُمكِنها أن تكون جملة اسمية متبوعةً بجملةٍ فعلية . نستخدم "يُمكِنه أن يكون" بدلًا من "يكون"؛ حيث من الممكن أن يكون هناك قواعدٌ أخرى تُخصِّص صيغًا محتملةً أخرى لتكوين الجمل. يُمكِننا التفكير بتلك القواعد على أنها أشبه بوصفةٍ لتكوين جملة ؛ أي إذا كنت تريد إنشاء جملةٍ، فيُمكِنك إنشاء جملةٍ اسمية ، ثم اتباعها بجملة فعلية . لاحِظ أنه لا بُدّ من تعريف الجملة الفعلية والاسمية أيضًا باستخدام قواعد صيغة باكوس ناور. عند استخدام صيغة باكوس ناور، يُستخدَم الرمز "|"، الذي يُقْرأ "أو" لتمثيل البدائل المتاحة. انظر القاعدة التالية على سبيل المثال، والتي تنص على احتمالية كون الجملة الفعلية فعلًا لازمًا ، أو فعلًا متعديًا متبوعًا بجملةٍ اسمية . <verb-phrase> ::= <intransitive-verb> | ( <transitive-verb> <noun-phrase> ) لاحِظ استخدام الأقواس للتجميع؛ فإذا أردت التعبير عن أن عنصرًا معينًا اختياري، ضعه بين القوسين "[" و"]"؛ وإذا كان من الممكن تكرار العنصر الاختياري أي عددٍ من المرات، ضعه بين القوسين "[" و"]…"؛ وضَع الرموز التي تُمثِل جزءًا فعليًا من اللغة الموصوفة داخل أقواس اقتباس quotes. ألقِ نظرةً على المثال التالي: <noun-phrase> ::= <common-noun> [ "that" <verb-phrase> ] | <common-noun> [ <prepositional-phrase> ]... تنص القاعدة السابقة على أن الجملة الاسمية من الممكن أن تكون اسمًا قد يتبعُه كلمة "that" وجملة فعلية ، أو أن تكون اسمًا متبوعًا بصفرٍ أو عددٍ من أشباه الجمل . يُمكِننا إذًا وصف أي بنية معقدة بنفس الأسلوب، حيث تَكْمُن القوة الحقيقية لقواعد صيغة باكوس ناور BNF بأنها قد تكون تعاودية recursive، حيث تُعدّ القاعدتان المُبينتان أعلاه تعاوديتين. لقد عرَّفنا الجملة الاسمية جزئيًا باستخدام الجملة الفعلية ، في حين أن الجملة الفعلية مُعرَّفةٌ أيضًا جزئيًا باستخدام الجملة الاسمية، حيث تُمثِل "the rat that ate the cheese" جملةً اسميةً ؛ لأن "ate the cheese" جملةً فعلية . نستطيع الآن استخدام التعاود recursion لإنشاء جملةٍ اسميةٍ أكثر تعقيدًا، مثل: المُكوَّنة من الاسم "the cat" وكلمة "that" والجملة الفعلية: وبناءً على ذلك، يُمكِننا إنشاء جملةٍ اسميةٍ ، مثل الجملة: تُعدّ البنية التعاودية recursive لأي لغة واحدةً من أهم خواصها الأساسية، وقدرة صيغة باكوس ناور BNF على التعبير عن تلك البنية التعاودية هو ما يجعلها مفيدة، حيث يُمكِننا استخدام صيغة باكوس ناور BNF لوصف قواعد صيغة لغات البرمجة، مثل جافا بطريقةٍ رسميةٍ مختصرة. على سبيل المثال، يُمكِننا تعريف حلقة التكرار على النحو التالي: <while-loop> ::= "while" "(" <condition> ")" <statement> تنص القاعدة المُوضحة أعلاه على أن حلقة التكرار تتكوَّن من كلمة "while" متبوعةً بقوس أيسر ")" يتبعه شرطٌ ، ثم يتبعه قوسٌ أيمن "("، وبالنهاية تعليمة . سيتبقى بالطبع تعريف المقصود في كلٍ من الشرط والتعليمة. وبما أنّه من الممكن للتعليمة أن تكون حلقة تكرار من بين أشياءٍ أخرى محتملة، فيُمكِننا أن نرى بوضوح البنية التعاودية recursive structure للغة جافا، ويُمكننا أيضًا تخصيص تعريفٍ دقيق لتعليمة if على النحو التالي: <if-statement> ::= "if" "(" <condition> ")" <statement> [ "else" "if" "(" <condition> ")" <statement> ]... [ "else" <statement> ] تبيِّن القاعدة بالأعلى أن الجزء "else" اختياري، وأنه من الممكن إضافة جزءٍ واحدٍ أو أكثر من "else if". التحليل النموذجي التعاودي recursive descent parsing سنستخدم في الجزء المتبقي من هذا المقال، قواعد صيغة باكوس ناور BNF للغةٍ معينة لإنشاء مُحلّل parser؛ والمُحلّل ببساطة هو برنامجٌ يُحدِّد البنية النحوية لجملةٍ مُصاغَةٍ بتلك اللغة، ويُعدّ ذلك بمثابة الخطوة الأولى لتحديد معنى الجملة، وهو ما يعني ضمن سياق لغات البرمجة ترجمتها إلى لغة الآلة machine language. على الرغم من أننا سنتناول مثالًا بسيطًا، نأمل أن يقنعك هذا المثال بأننا في الحقيقة قادرون على كتابة المُصرِّفات compilers وفهمها. يُطلَق اسم "التحليل النموذجي التعاودي recursive descent parsing" على أسلوب التحليل parsing الذي سنستخدمه، وهو ليس الأسلوب الوحيد المتوفِّر كما أنه ليس الأسلوب الأكثر كفاءة، ولكنه يُعدّ الأكثر ملائمة إذا كنا نريد كتابة المُصرِّف يدويًا بدلًا من الاستعانة بالبرامج المعروفة باسم "مولد مُحلّل parser generator"؛ حيث تُعدّ كل قاعدةٍ بصيغة باكوس ناور BNF في المُحلل النموذجي التعاودي recursive descent parser نموذجًا لبرنامجٍ فرعي subroutine. ليس بالضرورة أن تكون كل قاعدةٍ ملائمةً للتحليل النموذجي التعاودي، حيث ينبغي أن تتوفر بها خاصيةٌ معينة؛ وتتلّخص تلك الخاصية بأنه أثناء تحليل أي جملة، لا بُدّ أن يكون بإمكاننا معرفة التصنيف النحوي syntactic category التالي بمجرد النظر إلى العنصر التالي بالمُدْخَل. غالبية القواعد مُصمَّمة بحيث تَستوفِي تلك الخاصية. عندما نحاول تحليل parse جملةٍ مُكوّنةٍ من خطأ في بناء الجملة syntax error، فإننا نحتاج إلى طريقةٍ للاستجابة إلى ذلك الخطأ، ويُعدّ التبليغ عن حدوث اعتراض exception إحدى الطرائق المناسبة، حيث سنعتمد تلك الطريقة، وسنَستخدِم تحديدًا صنف اعتراض اسمه ParseError المُعرَّف على النحو التالي: private static class ParseError extends Exception { ParseError(String message) { super(message); } } // end nested class ParseError نلاحظ عدم ذكر قواعد صيغة باكوس ناور BNF أي شيءٍ عن الفراغات المحتملة بين العناصر، ولكن ينبغي واقعيًا أن نكون قادرين على إدخال فراغاتٍ بين العناصر كما نشاء. وللسماح بذلك، يُمكِننا استدعاء البرنامج TextIO.skipBlanks() قبل محاولة قراءة قيمة الدْخَل التالية، حيث يتخطى البرنامج TextIO.skipBlanks() أي مسافاتٍ فارغة بالمُدْخَل حتى يُصبِح الحرف التالي حرفًا غير فارغ أو محرف نهاية السطر. ألقِ نظرةً على مقال كيفية كتابة برامج صحيحة باستخدام لغة جافا للإطلاع على المزيد عن الصنف TextIO. لنبدأ الآن بمثال بسيط يُمكّننا من وصف "تعبير بين قوسين بالكامل fully parenthesized expression" بصيغة باكوس ناور BNF بكتابة القواعد التالية: <expression> ::= <number> | "(" <expression> <operator> <expression> ")" <operator> ::= "+" | "-" | "*" | "/" يشير بالأعلى إلى أي عددٍ حقيقيٍ موجب. يُعدّ "(((34-17)8)+(27))" مثالًا على "تعبير بين قوسين بالكامل". نظرًا لأن كل عامل operator يقابله زوجًا من الأقواس، ليس هناك أي غموضٍ بخصوص الترتيب الذي ينبغي أن تُطبَّق به العوامل. لنفترض أننا نريد كتابة برنامجٍ يقرأ تلك التعبيرات ويُحصِّل قيمتها، فسيقرأ البرنامج التعبيرات من الدخل القياسي standard input باستخدام الصنف TextIO؛ ولتطبيق التحليل النموذجي التعاودي recursive descent parsing، سنحتاج إلى تعريف برنامجٍ فرعيٍ subroutine لكل قاعدة. سنُعرِّف مثلًا برنامجًا فرعيًا مقابلًا لقاعدة العامل ، بحيث يكون ذلك البرنامج الفرعي مسؤولًا عن قراءة العامل . وفقًا للقاعدة المُبينة أعلاه، فيُمكِن للعامل أن يكون أيًا من الأربعة أشياء المذكورة بالأعلى، بينما سيُعدّ أي مُدْخل آخر خطأً. ألقِ نظرةً على تعريف البرنامج الفرعي: // 1 static char getOperator() throws ParseError { TextIO.skipBlanks(); char op = TextIO.peek(); // انظر إلى المحرف التالي دون أن تقرأه if ( op == '+' || op == '-' || op == '*' || op == '/' ) { TextIO.getAnyChar(); // اقرأ العامل لحذفه من المُدْخلات return op; } else if (op == '\n') throw new ParseError("Missing operator at end of line."); else throw new ParseError("Missing operator. Found \"" + op + "\" instead of +, -, *, or /."); } // end getOperator() [1] إذا كان المحرف المُدخَل التالي أحد العوامل الصحيحة، اقرأه ثم أعده مثل قيمة للتابع؛ أما إذا لم يكن كذلك، بلغ عن حدوث اعتراض. حاولنا إعطاء رسالة خطأ معقولةٍ نوعًا ما اعتمادًا على ما إذا كان الحرف التالي هو محرف نهاية السطر أو شيءٌ آخر. استخدمنا التابع TextIO.peek() للإطلاع على قيمة الحرف التالي دون قراءته، كما استخدمنا TextIO.skipBlanks() قبل استدعاء TextIO.peek() للتأكُّد من تجاهُل أي فراغاتٍ محتملة بين العناصر، وسنتبِع نفس النمط بكل حالة. نحتاج الآن إلى تعريف البرنامج الفرعي المقابل لقاعدة التعبير ، والتي تنص على إمكانية أن يكون التعبير عددًا أو تعبيرًا بين قوسين. يُمكِننا بسهولة معرفة إلى أيهما ينتمي المُدْخَل التالي بمجرد فَحْص الحرف التالي؛ فإذا كان الحرف رقمًا digit، فسنقرأ عددًا؛ أما إذا كان الحرف قوسًا ")"، فسنقرأ القوس ")" متبوعًا بتعبير متبوعًا بعامل متبوعًا بتعبير آخر، وأخيرًا القوس "("؛ وإذا كان الحرف التالي أي شيءٍ آخر، يعني ذلك حدوث خطأ. سنحتاج إلى التعاود recursion لقراءة التعبيرات المتداخلة nested expressions، حيث لا يقرأ البرنامج التعبير فقط، وإنما يُحصِّل قيمته أيضًا ثم يعيدها. يتطلَّب ذلك بعض المعلومات الدلالية semantical، التي لا تُوضِحها قواعد باكوس ناور BNF. private static double expressionValue() throws ParseError { TextIO.skipBlanks(); if ( Character.isDigit(TextIO.peek()) ) { // العنصر التالي بالمُدْخلات هو عدد، لذلك ينبغي أن يتكون // التعبير من مجرد ذلك العدد. اقرأه وأعده return TextIO.getDouble(); } else if ( TextIO.peek() == '(' ) { // 1 TextIO.getAnyChar(); // اقرأ القوس "(" double leftVal = expressionValue(); // اقرأ المعامل الأول وحصِّل قيمته char op = getOperator(); // اقرأ العامل double rightVal = expressionValue(); // اقرأ المعامل الثاني وحصِّل قيمته TextIO.skipBlanks(); if ( TextIO.peek() != ')' ) { // وفقًا للقاعدة، لا بُدّ من وجود قوس هنا // نظرًا لأنه غير موجود، سنُبلِّغ عن اعتراض throw new ParseError("Missing right parenthesis."); } TextIO.getAnyChar(); // اقرأ القوس ")" switch (op) { // طبّق العامل وأعد النتيجة case '+': return leftVal + rightVal; case '-': return leftVal - rightVal; case '*': return leftVal * rightVal; case '/': return leftVal / rightVal; default: return 0; // لا يُمكِن أن يحدث لأن العامل لا بُدّ أن يكون أيًا مما سبق } } else { // لا يُمكِن لأي محرفٍ آخر أن يكون ببدية التعبير throw new ParseError("Encountered unexpected character, \"" + TextIO.peek() + "\" in input."); } } // end expressionValue() [1] لا بُدّ أن يكون التعبير expression مكتوبًا بالصياغة التالية "(" ")". اقرأ جميع أجزاء التعبير واحدًا تلو الآخر، ثم نفِّذ العملية، وأعد الناتج. ينبغي أن تكون قادرًا على فهم الكيفية التي يُمثِل بها البرنامج المُعرَّف بالأعلى قاعدة باكوس ناور BNF، فبينما تَستخدِم القاعدة الترميز "|" لتسمح بالاختيار بين عدة بدائل، فإن البرنامج الفرعي يَستخدِم تعليمة if لتحديد الخيار الذي ينبغي أن يتخذه؛ وبينما تحتوي القاعدة على متتالية من العناصر "(" ")"، يَستخدِم البرنامج الفرعي سلسلةً من التعليمات لقراءة تلك المُدْخَلات واحدًا تلو الآخر. عند استدعاء expressionValue() لتحصيل قيمة التعبير "(((34-17)8)+(27))"، سيَجِد التابع القوس ")" ببداية المُدْخَل، ولهذا سيُنفِّذ جزء "else" من تعليمة if. بعد قراءته لذلك القوس، سيقرأ الاستدعاء التعاودي الأول للتابع expressionValue() التعبير الفرعي "((34-17)8)" ويُحصِّل قيمته. بعد ذلك، سيستدعي البرنامج التابع getOperator() لقراءة العامل "+"، ثم سيقرأ الاستدعاء التعاودي الثاني للتابع expressionValue() التعبير الفرعي الآخر "(27)" ويُحصِّل قيمته. أخيرًا، سيقرأ البرنامج القوس "(" الموجود بنهاية التعبير. تشتمل قراءة التعبير الفرعي الأول "((34-17)*8)" بالطبع على استدعاءاتٍ تعاوديةٍ أخرى للبرنامج expressionValue()، ولكن من الأفضل عدم التفكير بالأمر على نحوٍ أبعد من ذلك، حيث علينا فقط الاعتماد على التعاود لمعالجة تلك التفاصيل. يُمكِنك الاطلاع على البرنامج بالكامل الذي يَستخدِم تلك البرامج بالملف SimpleParser1.java. لا يلجأ أحدٌ في العموم إلى استخدام التعبيرات بين الأقواس بالكامل fully parenthesized expressions؛ وفي المقابل، إذا استخدمنا التعبيرات العادية، فسنضطّر للقلق بشأن أولوية العوامل operator precedence، التي تُخبرنا بوجوب تطبيق العامل "" قبل العامل "+" بتعبيرٍ مثل "5+37". سننظر للتعبير التالي "36+8(7+1)/4-24" على أنه مُكوّنٌ من ثلاثة أجزاء terms، هي: 36 و 8(7+1)/4 و 24؛ وهذه الأجزاء مربوطةٌ معًا باستخدام "+" و"-"، ويتكوَّن كل جزءٍ term منها من عدة عوامل factors مربوطةٍ معًا باستخدام "*" و"/". يتكون التعبير "8*(7+1)/4" مثلًا من العوامل 8، و (7+1)، و 4، حيث يُمكِننا أن نرى أن العامل factor قد يكون عددًا أو تعبيرًا داخل أقواس. سنُعقِد الأمر قليلًا بالسماح باستخدام الإشارة السالبة ضمن التعبيرات، مثل "-(3+4)" أو "-7". بما أن القاعدة تُمثّل عددًا موجبًا، فإنها تُعدّ الطريقة الوحيدة للحصول على أعدادٍ سالبة. يُمكننا التعبير عما سبق باستخدام قواعد باكوس ناور BNF التالية: <expression> ::= [ "-" ] <term> [ ( "+" | "-" ) <term> ]... <term> ::= <factor> [ ( "*" | "/" ) <factor> ]... <factor> ::= <number> | "(" <expression> ")" تَستخدِم القاعدة الأولى الترميز "[ ]…" لتُبيِّن أن العناصر المُضمَّنة داخل الأقواس قد تحدث أي عددٍ من المرات بما في ذلك الصفر، وتنص القاعدة أيضًا على أن أي تعبيرٍ يُمكِنه أن يبدأ بإشارة سالبة "-" يتبعها بالضرورة ، الذي من الممكن أن يتبعه اختياريًا العامل "+" أو "-" مع جزءٍ آخر، ثم قد يتبع ذلك عاملًا آخرًا مع جزء ، وهكذا. سيَستخدِم البرنامج الفرعي المسؤول عن قراءة تعبير وتحصيل قيمته حلقة while لمعالجة التكرار السابق، كما سيَستخدِم تعليمة if ببداية الحلقة لفحص ما إذا كانت الإشارة السالبة موجودةً أم لا. اُنظر تعريف البرنامج الفرعي: private static double expressionValue() throws ParseError { TextIO.skipBlanks(); boolean negative; // يُساوِي `true` في حالة وجود إشارة سالبة negative = false; if (TextIO.peek() == '-') { TextIO.getAnyChar(); // اقرأ الإشارة السالبة negative = true; } double val; // قيمة التعبير val = termValue(); // اقرأ الجزء الأول وحصِّل قيمته if (negative) val = -val; TextIO.skipBlanks(); while ( TextIO.peek() == '+' || TextIO.peek() == '-' ) { // اقرأ الجزء التالي وأَضِفه، أو اطرحه من قيمة الجزء السابق بالتعبير char op = TextIO.getAnyChar(); // اقرأ العامل double nextVal = termValue(); // اقرأ الجزء التالي وحصِّل قيمته if (op == '+') val += nextVal; else val -= nextVal; TextIO.skipBlanks(); } return val; } // end expressionValue() يتشابه البرنامج الفرعي المسؤول عن قراءة جزء مع البرنامج المُعرَّف بالأعلى؛ أما البرنامج الفرعي المسؤول عن قراءة عامل فهو شبيهٌ بمثال تعبيراتٍ ذات أقواس بالكامل fully parenthesized expressions. يُمكِنك الإطلاع على شيفرة البرنامج المسؤول عن قراءة التعبيرات وتحصيل قيمتها اعتمادًا على قواعد باكوس ناور BNF المذكورة بالأعلى بالملف SimpleParser2.java. إنشاء شجرة تعبير Expression Tree لم نفعل حتى الآن أكثر من مجرد تحصيل قيم التعبيرات expressions، فما العلاقة بين ذلك وبين ترجمة البرامج إلى لغة الآلة machine language؟ ببساطة، بدلًا من تحصيل قيمة التعبير فعليًا، يُمكِننا توليد التعليمات المطلوبة لتحصيل قيمة التعبير بلغة الآلة؛ فإذا كنا نعمل مع "آلة مكدس stack machine" مثلًا، فستكون التعليمات عملياتٍ على المكدس، مثل دفع push عددٍ إلى المكدس، أو تطبيق عملية "+". يمكن للبرنامج SimpleParser3.java تحصيل قيمة التعبيرات وطباعة قائمة العمليات المطلوبة لتحقيق ذلك. في الواقع، يُمثّل الانتقال من البرنامج السابق إلى برنامج "محلل نموذجي تعاودي recursive descent parser" قادرٍ على قراءة البرامج المكتوبة بلغة جافا وتحويلها إلى شيفرة لغة الآلة خطوةً كبيرةً، ولكن الفرق في المفاهيم ليس كبيرًا. لا يولِّد البرنامج SimpleParser3 عمليات المكدس مباشرةً أثناء تحليله parse لتعبيرٍ معين، ولكنه يُنشِئ شجرة تعبير expression tree، التي ناقشناها بالمقال السابق حول الأشجار الثنائية Binary Trees في جافا لتمثيل ذلك التعبير، ثم يَستخدِم شجرة التعبير لحساب قيمة التعبير وتوليد عمليات المكدس. تتكوَّن الشجرة من عقدٍ nodes تنتمي إلى الصنفين ConstNode وBinOpNode المشابهين تمامًا للأصناف التي تعرَّضنا لها بالمقال السابق. وعرَّفنا صنفًا فرعيًا جديدًا من الصنف ExpNode اسمه هو UnaryMinusNode لتمثيل عملية الإشارة السالبة الأحادية، كما أضفنا أيضًا تابعًا اسمه printStackCommands() لكل صنف؛ بحيث يكون مسؤولًا عن طباعة عمليات المكدس المطلوبة لتحصيل قيمة تعبير معين. تعرض الشيفرة التالية التعريف الجديد للصنف BinOpNode من برنامج SimpleParser3.java: private static class BinOpNode extends ExpNode { char op; // العامل ExpNode left; // التعبير على يسار العامل ExpNode right; // التعبير على يمين العامل BinOpNode(char op, ExpNode left, ExpNode right) { // أنشِئ عقدة من النوع BinOpNode تحتوي على البيانات المُخصصة assert op == '+' || op == '-' || op == '*' || op == '/'; assert left != null && right != null; this.op = op; this.left = left; this.right = right; } double value() { // القيمة التي حصلنا عليها بعد تطبيق العامل على قيمة المعاملين // الأيمن والأيسر double x = left.value(); double y = right.value(); switch (op) { case '+': return x + y; case '-': return x - y; case '*': return x * y; case '/': return x / y; default: return Double.NaN; // لا يُمكِن أن يحدث } } void printStackCommands() { // 1 left.printStackCommands(); right.printStackCommands(); System.out.println(" Operator " + op); } } حيث تشير [1] إلى تحصيل قيمة التعبير باستخدام آلة مكدس، علينا تحصيل قيمة المعامل الأيسر، ثم ترك الإجابة بالمكدس. بعد ذلك، سنفعل الأمر نفسه مع المعامل الثاني. وأخيرًا، سنُطبّق العامل؛ أي سنسحب pop قيمة المعاملين من المكدس ونطبِّق العامل عليهما، ثم ندفع الناتج إلى المكدس. لنفحص أيضًا البرامج الفرعية subroutine المسؤولة عن عملية التحليل parsing، فبدلًا من حساب قيمة التعبير، سيبني كل برنامجٍ فرعي منها شجرة تعبير expression tree. تعرض الشيفرة التالية مثلًا البرنامج الفرعي المقابل لقاعدة : static ExpNode expressionTree() throws ParseError { // اقرأ تعبيرًا من سطر المُدْخلات الحالي ثم أعد شجرة تعبير تُمثِل ذلك السطر TextIO.skipBlanks(); boolean negative; // True if there is a leading minus sign. negative = false; if (TextIO.peek() == '-') { TextIO.getAnyChar(); negative = true; } ExpNode exp; // شجرة التعبير الممثلة للتعبير المقروء exp = termTree(); // ابدأ بشجرة للجزء الأول من التعبير if (negative) { // ابنِ شجرةً مُكافِئةً لتطبيق عامل الإشارة السالبة الأحادي // على الجزء المقروء exp = new UnaryMinusNode(exp); } TextIO.skipBlanks(); while ( TextIO.peek() == '+' || TextIO.peek() == '-' ) { // اقرأ الجزء التالي واِدمجه مع الجزء السابق ضمن شجرة تعبير أكبر char op = TextIO.getAnyChar(); ExpNode nextTerm = termTree(); // انشئ شجرة تُطبِق العامل الثنائي على الشجرة السابقة والجزء الذي قرأناه للتو exp = new BinOpNode(op, exp, nextTerm); TextIO.skipBlanks(); } return exp; } // end expressionTree() يُنشِئ المحلّل parser ببعض المُصرِّفات compilers الحقيقية شجرةً لتمثيل البرنامج قيد التحليل، حيث يُطلَق على تلك الشجرة اسم "شجرة تحليل parse tree"، أو "شجرة صيغة مجردة abstract syntax tree". تختلف أشجار التحليل بعض الشيء عن أشجار التعبير expression trees، ولكنها تُستخدَم لنفس الغرض. بمجرد الحصول على شجرة تحليل، يُمكِنك استخدامها كما تشاء، كأن تولِّد منها شيفرة لغة الآلة machine language المقابلة. تتوفَّر أيضًا بعض التقنيات المسؤولة عن فحص الشجرة والكشف عن أنواعٍ معينةٍ من الأخطاء البرمجية مثل محاولة الإشارة إلى متغيّرٍ محلي local variable قبل إسناده إلى قيمة؛ حيث يرفض مُصرِّف جافا بالطبع أن يُصرِّف البرنامج إذا كان يحتوي على مثل ذلك الخطأ. من الممكن أيضًا إجراء بعض التعديلات على شجرة التحليل لتحسين أداء البرنامج قبل توليد شيفرة لغة الآلة. والآن، عدنا إلى النقطة التي بدأنا بها الجزء الأول من السلسلة أي فحص لغات البرمجة والمُصرِّفات compilers ولغة الآلة machine language، ولكن نأمل أن تكون المفاهيم أكثر وضوحًا وأن يكون التصور أكثر اتساعًا الآن. ترجمة -بتصرّف- للقسم Section 5: A Simple Recursive Descent Parser من فصل Chapter 9: Linked Data Structures and Recursion من كتاب Introduction to Programming Using Java. اقرأ أيضًا المفاهيم الأساسية لتعلم الآلة مفهوم التعاود (Recursion) والكائنات القابلة للاستدعاء (Callable Objects) في Cpp مفهوم التعاودية Recursion
-
ناقشنا في المقالين السابقين كيفية ارتباط الكائنات مع بعضها لتكوين قوائم. لنتخيل الآن أنه لدينا كائنٌ يحتوي على مؤشرين pointers إلى كائنين من نفس النوع. ستُصبح في هذه الحالة بنى البيانات data structures الناتجة أكثر تعقيدًا من القوائم المترابطة linked list. سنناقش في هذا المقال أحد أبسط البنى البيانية التابعة لذلك النوع، وتحديدًا ما يُعرَف باسم "الأشجار الثنائية binary trees"؛ حيث يحتوي أي كائنٍ ضمن شجرةٍ ثنائيةٍ على مؤشرين، يُطلَق عليهما عادةً left وright. بالإضافة إلى تلك المؤشرات، يمكن للعقد أن تحتوي بالطبع على أي نوعٍ آخر من البيانات، فقد تتكوَّن مثلًا شجرةٌ ثنائيةٌ binary tree من الأعداد الصحيحة من كائناتٍ من النوع التالي: class TreeNode { int item; // بيانات العقدة TreeNode left; // مؤشر إلى الشجرة الفرعية اليسرى TreeNode right; // مؤشر إلى الشجرة الفرعية اليمنى } يُمكِن للمؤشرين left وright أن يكونا فارغين في كائنٍ من النوع TreeNode، أو أن يُشيرا إلى كائناتٍ أخرى من النوع TreeNode. عندما تُشير عقدةٌ node معينةٌ إلى عقدةٍ أخرى، تُعدّ الأولى أبًا parent للثانية التي تُعدّ "عُقدة ابن child". يُمكِن للابن في الأشجار الثنائية binary tree أن يكون "ابنًا أيسر left child"، أو "ابنًا أيمن right child"، وقد يكون لعقدةٍ معينة "ابنٌ أيمن" حتى لو لم يكن لديها "ابنٌ أيسر". تُعدّ العقدة 3 أبًا للعقدة 6 في الصورة التالية على سبيل المثال، كما تُعدّ العقد 4 و5 أبناءً للعقدة 2. لا تُعدّ أي بنيةٍ structure مُكوَّنةٍ من هذا النوع من العقد شجرةً ثنائية binary tree؛ ولكن لا بُدّ أن تتوفَّر بها بعض الخاصيات، وهي: وجود عقدةٍ واحدةٍ فقط بدون أب ضمن الشجرة، ويُطلَق على تلك العقدة اسم "جذر الشجرة root". تملُك كل العقد الأخرى ضمن الشجرة أبًا واحدًا فقط. عدم وجود أي حلقاتٍ loops بالشجرة، أي لا يُمكِن بأي حالٍ من الأحوال البدء بعقدةٍ معينة ثم التحرك منها لعقدٍ أخرى عبر سلسلةٍ من المؤشرات ثم الانتهاء بنفس العقدة التي بدأت منها. يُطلَق على العقد التي ليس لديها أي أبناءٍ اسم العقد الورقية leaves، ويُمكِنك تمييز هذا النوع من العقد بسهولة؛ نظرًا لاحتواء مؤشريها الأيمن والأيسر على القيمة الفارغة null. وفقًا للتصور العام للشجرة الثنائية binary tree، فإن عقدة الجذر root node تُعرَض بالأعلى بينما تُعرَض العقد الورقية بالأسفل، وهو ما يُخالف واقع الأشجار الحقيقية تمامًا. ولكن على الأقل، ما يزال بإمكاننا رؤية بنيةٍ تفريعيةٍ شبيهةٍ بالأشجار وهو السبب وراء تسميتها بذلك الاسم. اجتياز الشجرة Tree Traversal لنفكر بعقدةٍ node ما ضمن شجرةٍ ثنائية binary tree، فإذا نظرنا إلى تلك العقدة مصحوبةً بجميع العقد المُشتقَة منها، أي أبنائها وأبناء أبنائها …إلخ، فستشكّل تلك المجموعة من العقد شجرةً ثنائيةً binary tree يُطلَق عليها اسم شجرة فرعية subtree من الشجرة الأصلية. تُشكِّل العقد 2 و4 و5 في الصورة السابقة على سبيل المثال شجرةً فرعية، ويُطلَق عليها اسم شجرةٍ فرعيةٍ يُسرى مُشتقّةٍ من الجذر root؛ وبالمثل، تُشكِّل العقد 3 و6 شجرةً فرعيةً يُمنى مُشتقةً من الجذر. تتكوَّن أي شجرةٍ ثنائية binary tree غير فارغة من عقدة جذر root node، وشجرةٍ فرعيةٍ يسرى، وشجرةٍ فرعيةٍ يُمنى، وقد تكون أي شجرةٍ فرعيةٍ منهما فارغةً أو حتى كلتيهما. يُعدّ ما سبق تعريفًا تعاوديًا recursive يتماشى مع التعريف التعاودي للصنف TreeNode، وبالتالي لا ينبغي أن تكون حقيقة شيوع استخدام البرامج الفرعية التعاودية recursive subroutines لمعالجة الأشجار أمرًا مفاجئًا. لنفكر الآن بمشكلة عدّ العقد الموجودة في شجرة ثنائية، حيث من الممكن تصميم خوارزميةٍ غير تعاوديةٍ لحل تلك المشكلة، ولكن لا تتوقَّع أن تَصِل إليها بسهولة. تَكْمُن المشكلة في تذكُّر العقد التي ما تزال بحاجة للعدّ، وهو في الواقع ليس بالأمر السهل، بل إنه مستحيلٌ في حالة عدم استخدام بنيةٍ بيانيةٍ إضافية، مثل المكدس stack أو الرتل queue على الأقل. في المقابل، ستصبح الخوارزمية في غاية السهولة لدى استخدام التعاود recursion، حيث إما أن تكون الشجرة فارغةً، أو مُكوَّنةً من جذرٍ وشجرتين فرعيتين subtrees؛ فإذا كانت الشجرة فارغةً، فإن عدد العقد ببساطة يُساوِي صفر، وتمثّل الحالة الأساسية base case للتعاود؛ أما إذا لم تكن فارغة، فينبغي تطبيق التعاود لحساب عدد العقد بالشجرتين الفرعيتين، ثم حساب مجموعهما، وزيادة الناتج بمقدار الواحد لعدّ الجذر، وهو ما يُعطينا العدد الكلي للعقد الموجودة بالشجرة. ألقِ نظرةً على شيفرة جافا التالية: static int countNodes( TreeNode root ) { if ( root == null ) return 0; // الشجرة فارغة ولا تحتوي على أي عقد else { int count = 1; // ابدأ بعدّ الجذر // أضف عدد العقد بالشجرة الفرعية اليسرى count += countNodes(root.left); // أضف عدد العقد بالشجرة الفرعية اليمنى count += countNodes(root.right); return count; // أعد العدد الإجمالي } } // end countNodes() سنناقش مثالًا آخر وهو مشكلة طباعة العناصر الموجودة في شجرة ثنائية binary tree،فإذا كانت الشجرة فارغةً، فليس هناك شيءٌ ينبغي فعله؛ أما إذا كانت الشجرة غير فارغةٍ، فهذا يعني أنها تتكوَّن من جذرٍ بالإضافة إلى شجرتين فرعيتين subtrees، وينبغي في هذه الحالة طباعة العنصر الموجود بعقدة الجذر ثم استخدام التعاود recursion لطباعة عناصر الشجرتين الفرعيتين. تعرض الشيفرة التالية برنامجًا فرعيًا subroutine يطبع جميع العناصر الموجودة بالشجرة ضمن سطر خرجٍ وحيد. static void preorderPrint( TreeNode root ) { if ( root != null ) { // (Otherwise, there's nothing to print.) System.out.print( root.item + " " ); // اطبع عنصر الجذر preorderPrint( root.left ); // اطبع العناصر بالشجرة الفرعية اليسرى preorderPrint( root.right ); // اطبع العناصر بالشجرة الفرعية اليمنى } } // end preorderPrint() يُطلق على التابع المُعرَّف بالأعلى اسمpreorderPrint؛ لأنه يستخدم اجتيازًا ذا ترتيبٍ سابق preorder traversal، وعند استخدام هذا النوع من الاجتياز، سيعالج البرنامج عقدة الجذر root node أولًا، ثم يجتاز الشجرة الفرعية اليسرى left subtree، وبعدها يجتاز الشجرة الفرعية اليمنى right subtree. في المقابل، إذا استخدم البرنامج اجتيازًا ذا ترتيبٍ لاحق، فإنه يجتاز الشجرة الفرعية اليسرى أولًا، ثم الشجرة الفرعية اليمنى، وأخيرًا يُعالِج عقدة الجذر؛ أما إذا كان البرنامج يستخدم اجتيازًا مُرتّبًا inorder traversal، فإنه يجتاز الشجرة الفرعية اليسرى أولًا، ثم يُعالِج عقدة الجذر root node، ويجتاز أخيرًا الشجرة الفرعية اليمنى. تختلف البرامج الفرعية المعتمدة على اجتيازٍ ذي ترتيبٍ لاحق أو اجتيازٍ مُرتّبٍ أثناء طباعة محتويات الشجرة عن التابع preorderPrint() فقط بمَوضِع تعليمة طباعة عنصر عقدة الجذر. static void postorderPrint( TreeNode root ) { if ( root != null ) { // (Otherwise, there's nothing to print.) // اطبع العناصر بالشجرة الفرعية اليسرى postorderPrint( root.left ); // اطبع العناصر بالشجرة الفرعية اليمنى postorderPrint( root.right ); System.out.print( root.item + " " ); // اطبع عنصر الجذر } } // end postorderPrint() static void inorderPrint( TreeNode root ) { if ( root != null ) { // (Otherwise, there's nothing to print.) // اطبع العناصر بالشجرة الفرعية اليسرى inorderPrint( root.left ); System.out.print( root.item + " " ); // اطبع عنصر الجذر // اطبع العناصر بالشجرة الفرعية اليمنى inorderPrint( root.right ); } } // end inorderPrint() يُمكِننا تطبيق البرامج الفرعية المبينة أعلاه على الشجرة الثنائية المُوضحة بالصورة المعروضة ببداية هذا المقال، حيث سنجد اختلاف ترتيب طباعة العناصر بكل حالةٍ كما هو مُبيَّن على النحو التالي: preorderPrint outputs: 1 2 4 5 3 6 postorderPrint outputs: 4 5 2 6 3 1 inorderPrint outputs: 4 2 5 1 3 6 بالنسبة للتابع preorderPrint: يُطبَع عنصر الجذر وهو العدد 1 أولًا قبل أي عناصر أخرى. لاحِظ أن الأمر نفسه يَنطبِق على الأشجار الفرعية المُشتقَة من الجذر؛ أي نظرًا لأن عنصر الجذر للشجرة الفرعية اليسرى left subtree يُساوي 2، فيُطبَع العدد 2 قبل العناصر الأخرى الواقعة ضمن تلك الشجرة الفرعية أي 4 و5، يُطبَع بالمثل عنصر الجذر 3 قبل العدد 6. يعني ذلك أنه عند استخدام اجتيازٍ ذي ترتيبٍ سابق preorder traversal، فإنه يُطبَّق على جميع مستويات الشجرة دون استثناء، ويُمكنك بالمثل تحليل الترتيب الناتج عن استخدام الطريقتين الأخريين من الاجتياز traversal. أشجار الترتيب الثنائية Binary Sort Trees تعرَّضنا بمقال بنى البيانات المترابطة Linked Data Structures لمثالٍ عن قائمةٍ مترابطة likned list من السلاسل النصية strings تحتفظ بها بحيث تكون مُرتّبةً ترتيبًا تصاعديًا. تعمل القوائم المترابطة جيدًا عند استخدامها مع عددٍ قليلٍ من السلاسل النصية، ولكنها تُصبِح غير فعالةٍ في حالة وجود عددٍ كبيرٍ من العناصر. عند إضافة عنصرٍ إلى القائمة، يتطلَّب تحديد موضع ذلك العنصر في المتوسط فحص نصف العناصر الموجودة بالقائمة، كما يتطلَّب العثور على عنصر ضمن القائمة نفس الوقت تقريبًا. في المقابل، إذا كانت السلاسل النصية مُخزّنةً بمصفوفةٍ مرتّبةٍ وليس بقائمةٍ مترابطة، يُصبِح البحث أكثر فعاليةً، حيث من الممكن عندها تطبيق البحث الثنائي binary search. ولكن، عند إضافة عنصرٍ جديد، فإن المصفوفة لا تكون فعالة؛ لأننا سنضطّر إلى تحريك نصف عناصر المصفوفة تقريبًا لتوفير مساحةٍ للعنصر المطلوب إضافته. في الحقيقة، تُخزِّن الشجرة الثنائية binary tree قائمةً مُرتبةً بطريقةٍ تجعل كُلًا من عمليتي البحث والإدخال أكثر فعالية، وعند استخدام شجرةٍ ثنائيةٍ بتلك الطريقة، يُطلَق عليها اسم شجرة الترتيب الثنائية binary sort trees -أو اختصارًا BST. تتمتع شجرة الترتيب الثنائية هي بالنهاية شجرة ثنائية binary tree بخاصيتين إضافيتين؛ فلأجل كل عقدةٍ node ضمن الشجرة، فإن العنصر الموجود بتلك العقدة أكبر من أو يُساوِي جميع العناصر الموجودة بالشجرة الفرعية subtree اليسرى المشتقة من تلك العقدة، كما أنه أقل من أو يساوي جميع العناصر الموجودة بالشجرة الفرعية اليمنى المشتقة من نفس العقدة. تَعرِض الصورة التالية مثالًا لشجرة ترتيب ثنائية binary sort tree مكوَّنةٍ من عناصرٍ من النوع String. تتميز أشجار الترتيب الثنائية بخاصيةٍ أخرى، حيث يؤدي تطبيق اجتياز مُرتبٍ inorder traversal على تلك الشجرة إلى معالجة عناصرها بترتيبٍ تصاعدي، وتُعدّ تلك الخاصية بمثابة طريقةٍ أخرى لتعريفها؛ فإذا استخدمنا مثلًا اجتيازًا مُرتبًا من أجل طباعة عناصر الشجرة المعروضة بالصورة، فستُطبَع بترتيبٍ أبجدي. يَضمَن تعريف هذا النوع من الاجتياز طباعة جميع العناصر الموجودة بالشجرة الفرعية اليسرى المشتقة من العنصر "judy" قبل طباعة ذلك العنصر ذاته. نظرًا لاستخدامنا شجرة ترتيبٍ ثنائية، فهذا يعني أنه لا بُدّ وأن تَسبِق عناصر الشجرة الفرعية اليسرى المُشتقَّة من العنصر "judy" العنصر "judy" وفقًا للترتيب الأبجدي، كما لا بُدّ أن تلحق عناصر الشجرة الفرعية اليمنى العنصر "judy" وفقًا للترتيب الأبجدي؛ يعني ذلك أننا نطبع "judy" بموضعها الأبجدي الصحيح. يُمكِن تطبيق نفس المنطق على الأشجار الفرعية subtrees؛ أي أننا سنطبع "Bill" بعد "alice" وقبل "fred" وأحفاده، وسنطبع "Fred" بعد "dave" وقبل "jane" و"joe"، وهكذا. لنفترض أننا نريد أن نبحث عن عنصرٍ معينٍ داخل شجرة بحثٍ ثنائية binary search tree. سنوازن ذلك العنصر مع عنصر جذر الشجرة؛ فإذا كانا متساويين، فإننا نكون قد انتهينا؛ أما إذا كان العنصر الذي نبحث عنه أقل من عنصر الجذر، فعلينا أن نُضيِّق نطاق البحث إلى عناصر الشجرة الفرعية اليسرى المشتقة من الجذر، أما الشجرة الفرعية اليمنى فيُمكِن إلغاؤها لأنها تحتوي على عناصرٍ أكبر من أو تساوي الجذر. وبالمثل، إذا كان العنصر الذي نبحث عنه أكبر من عنصر الجذر، يُمكِننا تضييق نطاق البحث إلى عناصر الشجرة الفرعية اليمنى. يُمكِننا بكلتا الحالتين إعادة تطبيق نفس عملية البحث على واحدةٍ من الشجرتين الفرعيتين. علاوةً على ذلك، إذا أردنا إضافة عنصرٍ جديد، فسيكون الأمر مشابهًا، حيث سنبدأ بالبحث عن الموضع الذي ينبغي أن ينتمي إليه العنصر الجديد، وبعد العثور عليه، يُمكِننا بسهولة أن إنشاء عقدةٍ جديدة، وربطها بذلك الموضع من الشجرة. تمتاز عمليتا البحث searching والإضافة insertion إلى شجرة بحثٍ ثنائية binary search tree بالكفاءة، بالأخص عندما تكون الشجرة متزنةٌ تقريبًا؛ حيث تُعدّ الشجرة الثنائية مُتزِنةً balanced، إذا كانت الشجرة الفرعية اليسرى المُشتقَّة من كل عقدةٍ بالشجرة تحتوي تقريبًا على نفس عدد العقد الذي تحتويه الشجرة الفرعية اليمنى المُشتقَّة من نفس العقدة، وعندما يكون الفرق بين العددين مساويًا للواحد على الأكثر، تُعدّ الشجرة الثنائية متزنةً تمامًا. في الحقيقة، ليست كل الأشجار الثنائية متزنةً، ولكن إذا أنشأنا الشجرة بحيث تُضَاف العناصر إليها بترتيبٍ عشوائي، ستزداد احتمالية أن تكون الشجرة متزنةً تقريبًا؛ أما إذا كان ترتيب الإدخال غير عشوائي، فستكون الشجرة غير متزنة. أثناء البحث بشجرة ترتيبٍ ثنائية binary sort tree، تلغي كل عملية موازنة comparison إحدى الشجرتين الفرعيتين؛ وهذا يعني أنه إذا كانت الشجرة متزنةً، فإن عدد العناصر قيد البحث يقل إلى النصف في كل مرة، ويُشبه ذلك خوارزمية البحث الثنائي binary search algorithm تمامًا، أي أننا نحصل على خوارزمية بنفس الفاعلية عند استخدام شجرة الترتيب الثنائية. وفقًا لمصطلحات التحليل المقارب asymptotic analysis المُوضحة في مقال تحليل الخوارزميات في جافا، فإن زمن تشغيل الحالة الوسطى average case لعمليات البحث والإضافة والحذف على شجرة بحثٍ ثنائية يُساوِي Θ(log(n))، حيث تُمثّل n عدد عناصر الشجرة، ويأخذ المتوسط بالحسبان جميع احتمالات الترتيب المختلفة التي يُمكِن أن نُدْخِل بها العناصر إلى الشجرة. طالما كان ترتيب الإدخال المُستخدَم فعليًا عشوائيًا، فغالبًا ما سيكون زمن التشغيل الفعلي قريبًا جدًا من القيمة المتوسطة. ومع ذلك، فإن زمن تشغيل الحالة الأسوأ worst case لنفس العمليات يُساوِي Θ(n)، ,هو أسوأ بكثيرٍ من Θ(log(n)). تحدث الحالة الأسوأ عند إدخال العناصر بترتيبٍ معين، فإذا أدخلنا مثلًا العناصر مرتبةً ترتيبًا تصاعديًا، فستتحرك العناصر دائمًا إلى اليمين أثناء تحركها لأسفل الشجرة، وسنَحصُل في النهاية على شجرةٍ أقرب ما تكون لقائمةٍ مترابطة linked list مُكوّنةٍ من سلسلةٍ خطيةٍ من العقد مربوطةٍ معًا عبر مؤشرات أبنائها اليُمنى. تستغرق العمليات على مثل تلك الشجرة نفس الزمن الذي تستغرقه عند تطبيقها على قائمةٍ مترابطة. تتوفَّر بنى بيانية data structures أخرى تشبه أشجار الترتيب الثنائية باستثناء تنفيذ عمليتي إدخال العقد وحذفها بطريقةٍ تَضمَن دومًا بقاء الشجرة متزنةً تقريبًا. بالنسبة لهذا النوع من بنى البيانات، فإن زمن تشغيل الحالة الوسطى والحالة الأسوأ لعمليات البحث والإضافة والحذف تُساوِي Θ(log(n)). سنناقش فيما يلي نسخًا بسيطةً من عمليتي الإدخال والبحث. يستخدم البرنامج التوضيحي SortTreeDemo.java أشجار الترتيب الثنائية، حيث يتضمن برامجًا فرعيةً تُنفِّذ كُلًا من عمليات البحث والإدخال وكذلك اجتيازً مُرتبًا inorder traversal. سنَفْحَصفيما يلي برنامجي البحث والإدخال فقط. يَسمَح برنامج main() التالي للمُستخدِم بكتابة سلاسلٍ نصيةٍ ثم إدخالها إلى الشجرة. تُمثَل العقد ضمن الشجرة الثنائية بالبرنامج SortTreeDemo باستخدام الصنف المتداخل الساكن static nested التالي، حيث يُعرِّف الصنف باني كائن constructor بسيطٍ لتسهيل إنشاء العقد. private static class TreeNode { String item; // بيانات العقدة TreeNode left; // مؤشر إلى الشجرة الفرعية اليسرى TreeNode right; // مؤشر إلى الشجرة الفرعية اليمنى TreeNode(String str) { // يُنشِيء الباني عقدةً تحتوي على سلسلة نصية // لاحِظ أن المؤشرين الأيمن والأيسر فارغان في تلك الحالة item = str; } } // end class TreeNode يُشير متغير عضوٍ ساكنٍ static member variable اسمه root من النوع TreeNode إلى شجرة الترتيب الثنائية binary sort tree التي يستخدمها البرنامج. // مؤشر إلى عقدة جذر الشجرة // عندما تكون الشجرة فارغة، فإن root يُساوي null private static TreeNode root; يستخدم البرنامج برنامجًا فرعيًا تعاوديًا recursive subroutine اسمه treeContains للبحث عن عنصرٍ معينٍ ضمن الشجرة، حيث يُنفِّذ ذلك البرنامج خوارزمية البحث للأشجار الثنائية التي تعرَّضنا لها بالأعلى. static boolean treeContains( TreeNode root, String item ) { if ( root == null ) { // الشجرة فارغةٌ، أي أن العنصر غير موجود بالتأكيد return false; } else if ( item.equals(root.item) ) { // عثرنا على العنصر المطلوب بعقدة الجذر return true; } else if ( item.compareTo(root.item) < 0 ) { // إذا كان العنصر موجودًا، فلا بُدّ أن يقع بالشجرة الفرعية اليسرى return treeContains( root.left, item ); } else { // إذا كان العنصر موجودًا، فلا بُدّ أن يقع بالشجرة الفرعية اليمنى return treeContains( root.right, item ); } } // end treeContains() عندما يَستدعِي برنامج main البرنامج المُعرَّف بالأعلى، فإنه يُمرِّر المتغير العضو الساكن root مثل قيمةٍ للمعامل الأول؛ حيث يُشير root إلى جذر شجرة الترتيب الثنائية بالكامل. لا يُعدّ استخدام التعاود recursion في تلك الحالة ضروريًا، حيث يُمكِننا استخدام خوارزميةٍ غير تعاودية non-recursive بسيطةٍ للبحث ضمن شجرة الترتيب الثنائية binary sort tree على النحو التالي: ابدأ من الجذر، وتحرَّك لأسفل الشجرة إلى أن تَعثُر على العنصر المطلوب أو أن تصل إلى مؤشرٍ فارغ null pointer. نظرًا لاتّباع البحث مسارًا واحدًا على طول الشجرة، يُمكِننا استخدام حلقة while لتنفيذه. تَعرِض الشيفرة التالية نسخةً غير تعاودية من برنامج البحث. private static boolean treeContainsNR( TreeNode root, String item ) { TreeNode runner; // المؤشر المستخدم لاجتياز الشجرة runner = root; // ابدأ بعقدة الجذر while (true) { if (runner == null) { // وقعنا إلى خارج الشجرة دون العثور على العنصر return false; } else if ( item.equals(runner.item) ) { // لقد عثرنا على العنصر المطلوب return true; } else if ( item.compareTo(runner.item) < 0 ) { // إذا كان العنصر موجودًا، فلا بُدّ أن يكون بالشجرة الفرعية اليسرى // ولذلك، حرك المؤشر إلى يسار المستوى التالي runner = runner.left; } else { // إذا كان العنصر موجودًا، فلا بُدّ أن يكون بالشجرة الفرعية اليمنى // ولذلك، حرك المؤشر إلى يمين المستوى التالي runner = runner.right; } } // end while } // end treeContainsNR(); يشبه البرنامج الفرعي المسؤول عن إضافة عنصرٍ جديدٍ إلى الشجرة كثيرًا النسخة غير التعاودية من برنامج البحث. وينبغي لبرنامج الإدخال معالجة حالة كون الشجرة فارغة؛ ففي تلك الحالة، لا بُدّ من تعديل قيمة root، وضَبْطها لتُشير إلى عقدةٍ تحتوي على العنصر الجديد. root = new TreeNode( newItem ); ولكن هذا يعني أنه ليس بإمكاننا تمرير الجذر مثل معامل parameter إلى البرنامج الفرعي subroutine؛ حيث من المستحيل لأي برنامجٍ فرعيٍ تعديل القيمة المُخزَّنة فعليًا بالمعامل (تستطيع بعض لغات البرمجة الأخرى فعل ذلك). هناك عدة طرقٍ لحل تلك المعضلة، ولكن الطريقة الأسهل هي استخدام برنامج إدخالٍ غير تعاودي non-recursive يُمكنه الوصول إلى قيمة المتغير العضو الساكن root مباشرةً. إحدى الاختلافات بين عملية إضافة عنصرٍ وعملية البحث عن عنصرٍ، هي أنه علينا الانتباه حتى لا نقع بمشكلة "السقوط خارج الشجرة"؛ أي علينا التوقُّف عن البحث قبل أن تُصبِح قيمة runner مساويةً للقيمة الفارغة null، وعند الوصول إلى موضعٍ فارغٍ بالشجرة، يُمكِننا إضافة العنصر الجديد إليه. // 0 private static void treeInsert(String newItem) { if ( root == null ) { // الشجرة فارغة. سنَضبُط root لكي يُشير إلى العقدة الجديدة التي // تحتوي على العنصر الجديد. ستُصبِح تلك العقدة هي العقدة الوحيدة // بالشجرة root = new TreeNode( newItem ); return; } TreeNode runner; // المؤشر المستخدم لاجتياز الشجرة للعثور على موضع العقدة الجديدة runner = root; // سنبدأ من جذر الشجرة while (true) { if ( newItem.compareTo(runner.item) < 0 ) { // 1 if ( runner.left == null ) { runner.left = new TreeNode( newItem ); return; // أضفنا العنصر الجديد إلى الشجرة } else runner = runner.left; } else { // 2 if ( runner.right == null ) { runner.right = new TreeNode( newItem ); return; // أضفنا العنصر الجديد إلى الشجرة } else runner = runner.right; } } // end while } // end treeInsert() حيث تعني كل من: [0]: أضف العنصر إلى شجرة الترتيب الثنائية، التي يُشير إليها المُتغيِّر العام root. لاحِظ أنه من غير الممكن تمرير root مثل مُعاملٍ للبرنامج؛ لأنه قد يُعدِّل قيمة root في حين لا تؤثر التعديلات الواقعة على قيم المعاملات الصورية formal parameters بالقيمة الفعلية للمعامل. [1]: نظرًا لأن العنصر الجديد أقل من العنصر الموجود بالعقدة التي يشير إليها runner، فينتمي العنصر إذًا إلى الشجرة الفرعية اليسرى المشتقة من runner. إذا كان runner.left فارغًا، أضف العقدة الجديدة هناك؛ أما إذا لم تكن فارغة، فحرِك المؤشر runner إلى يسار المستوى التالي. [2]: نظرًا لأن العنصر الجديد أكبر من العنصر الموجود بالعقدة التي يُشير إليها runner، فإن العنصر إذًا ينتمي إلى الشجرة الفرعية اليمنى المُشتقَّة من runner. إذا كان runner.right فارغًا، أضف العقدة الجديدة هناك؛ أما إذا لم تكن فارغة، فحرِك المؤشر runner إلى يمين المستوى التالي. أشجار التعبير Expression Trees يُعدّ تخزين التعابير الحسابية mathematical expression مثل "15*(x+y)" أو "sqrt(42)+7" تطبيقًا آخرًا على استخدام الأشجار trees، حيث سنكتفي الآن بالتعبيرات المكوَّنة من أعدادٍ وعواملٍ بسيطة مثل "+" و"-" و"" و"/". لنفترض مثلًا أنه لدينا التعبير التالي "3*((7+1)/4)+(17-5)"، الذي يتكوَّن من تعبيرين فرعيين subexpressions، هما "3*((7+1)/4)" و"(17-5)" بالإضافة إلى العامل "+"؛ فإذا مَثَّلنا التعبير مثل شجرةٍ ثنائية binary tree، فستَحمِل عقدة الجذر root node العامل "+"، بينما ستُمثِل الأشجار الفرعية subtrees المُشتقَّة من عقدة الجذر التعابير الفرعية "3*((7+1)/4)" و"(17-5)". تحمل كل عقدةٍ بالشجرة عددًا أو عاملًا operator؛ حيث تُعدّ العقدة التي تحمل عددًا عقدةً ورقيةً leaf node؛ أما العقدة التي تحمل عاملًا operator، فإنها تملُك شجرتين فرعيتين تُمثلان المعاملين operands اللذين ينبغي أن يُطبَق عليهما ذلك العامل. تُبين الصورة بالأسفل رسمًا توضيحيًا لتلك الشجرة، والتي سنُشير إليها باسم "شجرة تعبير expression tree". إذا كان لدينا شجرة تعبير، فسيكون من السهل حساب قيمة التعبير الذي تمثله تلك الشجرة. تملك كل عقدةٍ ضمن الشجرة قيمةً معينة؛ فإذا كانت العقدة عقدةً ورقيةً، فإن قيمتها ببساطة هي نفس قيمة العدد الذي تحمله؛ أما إذا احتوت العقدة على عامل operator، ستُحسب قيمتها عن طريق حساب قيم عقدها الأبناء child nodes، ثم تطبيق العامل على تلك القيم. تُبيِّن الأسهم الصاعدة بالصورة التالية طريقة المعالجة، حيث نلاحِظ أن قيمة عقدة الجذر root node هي قيمة كل التعبير. تتوفَّر استخداماتٌ أخرى لأشجار التعبير expression trees. على سبيل المثال، إذا طبقّنا اجتيازًا ذا ترتيبٍ لاحق postorder traversal على الشجرة، فإننا سنَحصُل على تعبير الإلحاق المكافئ postfix expression. تتكوَّن أي شجرة تعبير من نوعين من العقد: العقد التي تحتوي على أعداد، والعقد التي تحتوي على عوامل operators. علاوةً على ذلك، قد نرغب بإضافة أنواعٍ أخرى من العقد لاستغلال الأشجار بصورةٍ أفضل، مثل استخدام عقدٍ تحتوي على متغيرات. الآن، إذا أردنا العمل مع أشجار التعبير expression trees بلغة جافا، كيف يُمكننا التعامل مع أنواع العقد المختلفة؟ أحد الطرائق التي ستُغضِب المتعصبين للبرمجة كائنية التوجه object-oriented، هو تضمين متغير نسخة instance variable بكل كائن عقدة node object لتحديد نوع العقدة. اُنظر الشيفرة التالية: enum NodeType { NUMBER, OPERATOR } // Possible kinds of node. class ExpNode { // A node in an expression tree. NodeType kind; // نوع العقدة double number; // قيمة عُقدة ممثلة لعدد char op; // قيمة العامل بعقدة ممثلة لعامل // مؤشرات إلى الشجرتين الفرعيتين المشتقتين من عقدة العامل ExpNode left; ExpNode right; ExpNode( double val ) { // يُنشِئ هذا الباني عقدة ممثلة لعدد kind = NodeType.NUMBER; number = val; } ExpNode( char op, ExpNode left, ExpNode right ) { // يُنشِئ هذا الباني عقدة ممثلة لعامل kind = NodeType.OPERATOR; this.op = op; this.left = left; this.right = right; } } // end class ExpNode يُعطي البرنامج الفرعي التعاودي التالي قيمة شجرة التعبير expression tree: static double getValue( ExpNode node ) { // أعد قيمة التعبير الذي تُمثِله الشجرة التي تُشير إليها العقدة node if ( node.kind == NodeType.NUMBER ) { // قيمة العقد العددية هي ببساطة العدد الذي تحمله return node.number; } else { // لابُدّ أن يكون عاملًا // احسب قيمة المعاملين ثم ادمجهما معًا باستخدام العامل double leftVal = getValue( node.left ); double rightVal = getValue( node.right ); switch ( node.op ) { case '+': return leftVal + rightVal; case '-': return leftVal - rightVal; case '*': return leftVal * rightVal; case '/': return leftVal / rightVal; default: return Double.NaN; // Bad operator. } } } // end getValue() يتوفَّر حل آخر يميل إلى البرمجة كائنية التوجه object-oriented، على الرغم من أن الطريقة السابقة ستعمل بنجاح. نظرًا لوجود نوعين فقط من العقد، فمن البديهي أن يكون لدينا صنفين classes لتمثيل كل نوعٍ منهما، هما ConstNode وBinOpNode. لتمثيل الفكرة العامة لعقدةٍ ضمن شجرة تعبير، سنحتاج إلى صنفٍ آخر، وليكن اسمه ExpNode. سنُعرِّف الصنفين ConstNode وBinOpNode بحيث يكونا أصنافًا فرعيةً subclasses مُشتقّةً من الصنف ExpNode، ونظرًا لأن أي عقدةٍ فعليةٍ ستكون من الصنف ConstNode أو الصنف BinOpNode، فسينبغي أن يكون الصنف ExpNode صنفًا مجردًا abstract class (ألقِ نظرةً على مقال الوراثة والتعددية الشكلية Polymorphism والأصناف المجردة Abstract Classes في جافا). لأننا نريد بالأساس تحصيل قيمة العقد، فإننا سنُعرِّف تابع نسخة instance method في كل صنف عقدة لتحصيل قيمتها. انظر الشيفرة التالية: abstract class ExpNode { // يُمثل عقدة من أي نوع بشجرة تعبير abstract double value(); // يُعيد قيمة العقدة } // end class ExpNode class ConstNode extends ExpNode { // يُمثِل عقدى تحمل عددًا double number; // العدد الموجود بالعقدة ConstNode( double val ) { // يُنشِئ الباني عقدة تحتوي على قيمة val number = val; } double value() { // القيمة هي العدد الذي تحمله العقدة return number; } } // end class ConstNode class BinOpNode extends ExpNode { // تُمثِل عقدة تحمل عاملًا char op; // العامل ExpNode left; // المعامل الأيسر ExpNode right; // المعامل الأيمن BinOpNode( char op, ExpNode left, ExpNode right ) { // يُنشِئ الباني عقدة تحمل البيانات المخصصَّة this.op = op; this.left = left; this.right = right; } double value() { // احسب قيمة المعاملين الأيمن والأيسر ثم اِدْمجهما معًا باستخدام العامل double leftVal = left.value(); double rightVal = right.value(); switch ( op ) { case '+': return leftVal + rightVal; case '-': return leftVal - rightVal; case '*': return leftVal * rightVal; case '/': return leftVal / rightVal; default: return Double.NaN; // Bad operator. } } } // end class BinOpNode ينتمي المعاملان الأيمن والأيسر بعقد الصَنْف BinOpNode إلى النوع ExpNode وليس BinOpNode، ويسمح ذلك للمعاملات بأن تكون من النوع ConstNode، أو النوع BinOpNode، أو حتى أي نوعٍ آخر مُشتقٍّ من النوع ExpNode. بما أن كل عقدةٍ من النوع ExpNode تُعرِّف تابع النسخة value()، يُمكِننا إذًا استدعاء left.value() لحساب قيمة المعامل الأيسر؛ فإذا كان left من النوع ConstNode، سيؤدي استدعاء left.value() إلى استدعاء التابع value() المُعرَّف بالصنف ConstNode؛ أما إذا كان left من النوع BinOpNode، فسيؤدي استدعاء التابع value() إلى استدعاء التابع value() المُعرَّف بالصنف BinOpNode، وبذلك تَعرِف كل عقدةٍ الطريقة المناسبة لحساب قيمتها. قد يبدو الأسلوب كائني التوجه أعقد قليلًا بالبداية، ولكنه يتمتع بعدة مميزات، منها عدم هدره للذاكرة. كان الاستخدام الفعلي بالصنف ExpNode الأصلي مقتصرًا على بعض متغيرات النسخ instance variables بكل عقدة، كما أننا أضفنا متغير نسخةٍ إضافي لتمكيننا من معرفة نوع العقدة. الأهم من ذلك هو امكانية إضافة أنواعٍ جديدةٍ من العقد بطريقةٍ أبسط، فكل ما علينا فعله هو إنشاء صنفٍ فرعي subclass جديدٍ مُشتقٍّ من الصنف ExpNode بدلًا من تعديل صنفٍ موجود بالفعل. سنعود مجددًا للحديث عن موضوع أشجار التعبير في المقال التالي، حيث سنناقش خلاله طريقة إنشاء شجرة تعبير تُمثّل تعبيرًا معينًا. ترجمة -بتصرّف- للقسم Section 4: Binary Trees من فصل Chapter 9: Linked Data Structures and Recursion من كتاب Introduction to Programming Using Java. اقرأ أيضًا مفهوم الأشجار Trees في الخوارزميات خوارزميات تحليل المسارات في الأشجار المتغيرات والعوامل والأخطاء البرمجية في لغة جافا
-
سنُعرِّف في هذه المقالة الصنفَ MyBetterMap الذي يُنفِّذ الواجهة Map بشكلٍ أفضلَ من MyLinearMap، كما سنتناول تقنية التعمية hashing التي ساعدتنا على تنفيذ الصنف MyBetterMap بتلك الكفاءة. التعمية Hashing بهدف تحسين أداء الصنف MyLinearMap، سنُعرِّف صنفًا جديدًا هو MyBetterMap يحتوي على تجميعة كائناتٍ تنتمي إلى الصنف MyLinearMap. يُقسِّم الصنف الجديد المفاتيح على الخرائط المُرفقة لكي يُقلِّل عدد المُدْخَلات الموجودة في كل واحدةٍ منها، وبذلك يتمكَّن من زيادة سرعة التابع findEntry والتوابع التي تعتمد عليه. انظر إلى تعريف الصنف: public class MyBetterMap<K, V> implements Map<K, V> { protected List<MyLinearMap<K, V>> maps; public MyBetterMap(int k) { makeMaps(k); } protected void makeMaps(int k) { maps = new ArrayList<MyLinearMap<K, V>>(k); for (int i=0; i<k; i++) { maps.add(new MyLinearMap<K, V>()); } } } يُمثِل متغير النسخة maps تجميعة كائناتٍ تنتمي إلى الصنف MyLinearMap، حيث يَستقبِل الباني constructor المعاملَ k الذي يُحدِّد عدد الخرائط المُستخدَمة مبدئيًا على الأقل، ثم يُنشِئ التابع makeMaps تلك الخرائط ويُخزِّنها في قائمةٍ من النوع ArrayList. والآن، سنحتاج إلى طريقة تُمكِّننا من فحص مفتاح معين، وتقرير الخريطة التي ينبغي أن نَستخدِمها. وعندما نَستدعِي التابع put مع مفتاح جديد، سنختار إحدى الخرائط؛ أما عندما نَستدعِي التابع get مع نفس المفتاح، فعلينا أن نتذكّر الخريطة التي وضعنا فيها المفتاح. يُمكِننا إجراء ذلك باختيار إحدى الخرائط الفرعية عشوائيًا وتعقّب المكان الذي وضعنا فيه كل مفتاح، ولكن كيف سنفعل ذلك؟ يُمكِننا مثلًا أن نَستخدِم خريطةً من النوع Map للبحث عن المفتاح والعثور على الخريطة الفرعية المُستخدَمة، ولكن الهدف الأساسي من هذا التمرين هو كتابة تنفيذٍ ذي كفاءةٍ عاليةٍ للواجهة Map، وعليه، لا يُمكِننا أن نفترض وجود ذلك التنفيذ فعليًا. بدلًا من ذلك، يُمكِننا أن نَستخدِم دالةً تعميةً hash function تَستقبِل كائنًا من النوع Object، وتعيد عددًا صحيحًا يُعرَف باسم شيفرة التعمية hash code. الأهم من ذلك هو أننا عندما نقابل نفس الكائن مرةً أخرى، فلا بُدّ أن تعيد الدالة نفس شيفرة التعمية دائمًا. بتلك الطريقة، إذا استخدمنا شيفرة التعمية لتخزين مفتاحٍ معينٍ، فإننا سنحصل على نفس شيفرة التعمية إذا أردنا استرجاعه. يُوفِّر أيّ كائنٍ من النوع Object بلغة جافا تابعًا اسمه hashCode، حيث يَحسِب هذا التابع شيفرة التعمية الخاصة بالكائن. يختلف تنفيذ هذا التابع باختلاف نوع الكائن، وسنرى مثالًا على ذلك لاحقًا. يختار التابعُ المساعدُ التالي الخريطةَ الفرعيّةَ المناسبة لمفتاحٍ معينٍ: protected MyLinearMap<K, V> chooseMap(Object key) { int index = 0; if (key != null) { index = Math.abs(key.hashCode()) % maps.size(); } return maps.get(index); } إذا كان key يساوي null، فإننا سنختار الخريطة الفرعية الموجودة في الفهرس 0 عشوائيًا. وفيما عدا ذلك، سنَستخدِم التابع hashCode لكي نحصل على عددٍ صحيحٍ، ثم نُطبِّق عليه التابع Math.abs لكي نتأكّد من أنه لا يحتوي على قيمة سالبة، ثم نَستخدِم عامل باقي القسمة \% لكي نحصل على قيمةٍ واقعةٍ بين 0 وmaps.size()-1، وبذلك نضمَن أن يكون index فهرسًا صالحًا للاستخدام مع التجميعة maps دائمًا. وفي الأخير، سيعيد chooseMap مرجًعا reference إلى الخريطة المختارة. لاحِظ أننا استدعينا chooseMap بالتابعين put وget، وبالتالي عندما نبحث عن مفتاحٍ معينٍ، يُفترَض أن نحصل على نفس الخريطة التي حصلنا عليها عندما أضفنا ذلك المفتاح. نقول هنا أنه يُفترَض وليس حتمًا، لأنه من المحتمل ألا يحدث، وهو ما سنشرح أسبابه لاحقًا. انظر إلى تعريف التابعين put و get: public V put(K key, V value) { MyLinearMap<K, V> map = chooseMap(key); return map.put(key, value); } public V get(Object key) { MyLinearMap<K, V> map = chooseMap(key); return map.get(key); } ربما لاحظت أن الشيفرة بسيطةٌ للغاية، حيث يَستدعِي التابعان التابعَ chooseMap للعثور على الخريطة الفرعية الصحيحة، ثم يَستدعِيان تابعًا في تلك الخريطة الفرعية، وهذا كلّ ما في الأمر. والآن، لنفحص أداء التابعين. إذا كان لدينا عدد مقداره n من المُدْخَلات مُقسّمًا على عدد مقداره k من الخرائط الفرعية، فسيصبح لدينا في المتوسط عدد n/k من المُدْخَلات في كل خريطة. وعندما نبحث عن مفتاح معين، سنضطرّ إلى حساب شيفرة تعميته، والتي تستغرق بعض الوقت، ثم سنبحث في الخريطة الفرعية المقابلة. لمّا كان حجم قوائم المُدْخَلات في الصنف MyBetterMap أقلّ بمقدار k مرة من حجمها في الصنف MyLinearMap، فمن المُتوقَّع أن يكون البحث أسرع بمقدار k مرة، ومع ذلك، ما يزال زمن التشغيل متناسبًا مع n، وبالتالي ما يزال الصنف MyBetterMap خطيًّا. سنعالج تلك المشكلة في التمرين التالي. كيف تعمل التعمية؟ إذا طبَّقنا دالة تعميةٍ على نفس الكائن، فلا بُدّ لها أن تنتج نفس شيفرة التعمية في كل مرّةٍ، وهو أمرٌ سهلٌ نوعًا ما إذا كان الكائن غيرَ قابلٍ للتعديل immutable؛ أما إذا كان قابلًا للتعديل، فالأمر يحتاج الى بعض التفكير. كمثال على الكائنات غير القابلة للتعديل، سنُعرِّف الصنف SillyString، حيث يُغلِّف ذلك الصنف سلسلةً نصيّةً من النوع String: public class SillyString { private final String innerString; public SillyString(String innerString) { this.innerString = innerString; } public String toString() { return innerString; } في الواقع، هذا الصنف ليس ذا فائدةٍ كبيرةٍ، ولهذا السبب سميناه SillyString، ولكنه مع ذلك يُوضِّح كيف يُمكِن لصنفٍ أن يُعرِّف دالة التعمية الخاصّة به: @Override public boolean equals(Object other) { return this.toString().equals(other.toString()); } @Override public int hashCode() { int total = 0; for (int i=0; i<innerString.length(); i++) { total += innerString.charAt(i); } return total; } أعاد الصنف SillyString تعريفَ التابعين equals وhashCode، وهذا الأمر ضروري لأننا لو أردنا له أن يَعمَل بشكل مناسب، فلا بُدّ أن يكون التابع equals متوافقًا مع التابع hashCode. يَعنِي هذا أنه لو كان لدينا كائنان متساويين -يُعيد التابع equals القيمة true عند تطبيقه عليهما-، فلا بُدّ أن تكون لهما نفس شيفرة التعمية، ولكن هذا صحيحٌ من اتجاهٍ واحدٍ فقط، فمن المحتمل أن يملك كائنان نفس شيفرة التعمية، ومع ذلك لا يكونان متساويين. يَستدعِي equals التابعَ toString الذي يعيد قيمةَ متغير النسخة innerString، ولذلك يتساوى كائنان من النوع SillyString إذا تساوى متغير النسخة innerString المُعرَّف فيهما. يمرّ التابع hashCode عبر محارف السلسلة النصية -من النوع String- ويَحسِب حاصل مجموعها. وعندما نضيف محرفًا إلى عددٍ صحيحٍ من النوع int، ستُحوِّل جافا المحرف إلى عددٍ صحيحٍ باستخدام رقم محرف يونيكود Unicode code point الخاصّ به. يُمكنكِ قراءة المزيد عن أرقام محارف اليونيكود إذا أردت، ولكنه غير ضروريٍّ لفهم هذا المثال. تُحقِّق دالّةُ التعمية السابقة الشرطَ التالي: تَعمَل الشيفرة السابقة بشكل صحيح، ولكنها ليست بالكفاءة المطلوبة؛ فهي تعيد شيفرة التعمية نفسها لعدد كبير من السلاسل النصية المختلفة؛ فمثلًا لو تكوَّنت سلسلتان من نفس الأحرف مهما كان ترتيبها، فإنهما ستحصلان على نفس شيفرة التعمية، بل حتى لو لم تتكونا من نفس الأحرف، فقد يكون حاصل المجموع متساويًا مثل ac وbb. إذا حصلت كائناتٌ كثيرةٌ على نفس شيفرة التعمية، فإنها ستُخزَّن في نفس الخريطة الفرعية، وإذا احتوت خرائط فرعيّةٌ معينّةٌ على مُدخَلاتٍ أكثرَ من غيرها، فإن السرعة التي نُحقّقها باستخدام عدد مقداره k من الخرائط تكون أقلّ بكثيرٍ من k، ولذلك ينبغي أن تكون دوال التعمية منتظمةً، أي لا بُدّ أن تكون احتمالية الحصول على أي قيمةٍ ضمن النطاق المسموح به متساوية. يُمكنك قراءة المزيد عن التصميم الجيّد لدوال التعمية لو أردت. التعمية والقابلية للتغيير mutation يُعَد الصنف String غير قابلٍ للتعديل، وكذلك الصنف SillyString؛ وذلك لأننا صرحنا عنه باستخدام final. بمجرد أن تُنشِئ كائنًا من النوع SillyString، فإنك لا تستطيع أن تُعدِّل متغير النسخة innerString المُعرَّف فيه لتجعله يشير إلى سلسلةٍ نصيّةٍ مختلفةٍ من النوع String، كما أنك لا تستطيع أن تُعدِّل السلسلة النصيّةَ التي يشير إليها، وبالتالي ستكون للكائن نفس شيفرة التعمية دائمًا. ولكن، ماذا يحدث لو كان الكائن قابلًا للتعديل؟ انظر إلى تعريف الصنف SillyArray المطابقَ للصنف SillyString باستثناء أنه يَستخدِم مصفوفةَ محارفَ بدلًا من الصنف String: public class SillyArray { private final char[] array; public SillyArray(char[] array) { this.array = array; } public String toString() { return Arrays.toString(array); } @Override public boolean equals(Object other) { return this.toString().equals(other.toString()); } @Override public int hashCode() { int total = 0; for (int i=0; i<array.length; i++) { total += array[i]; } System.out.println(total); return total; } يُوفِّر الصنف SillyArray التابع setChar الذي يَسمَح بتعديل المحارف الموجودة في المصفوفة: public void setChar(int i, char c) { this.array[i] = c; } والآن، لنفترض أننا أنشأنا كائنًا من النوع SillyArray، ثم أضفناه إلى خريطةٍ كالتالي: SillyArray array1 = new SillyArray("Word1".toCharArray()); map.put(array1, 1); شيفرة التعمية لتلك المصفوفة هي 461. والآن إذا عدَّلنا محتويات المصفوفة، وحاولنا أن نسترجعها كالتالي: array1.setChar(0, 'C'); Integer value = map.get(array1); ستكون شيفرة التعمية بعد التعديل قد أصبحت 441. بحصولنا على شيفرة تعميةٍ مختلفةٍ، فإننا غالبًا سنبحث في الخريطة الفرعيّة الخاطئة، وبالتالي لن نعثر على المفتاح على الرغم من أنه موجود في الخريطة، وهذا أمرٌ سيّء. لا يُعَد استخدام الكائنات القابلة للتعديل مفاتيحًا لهياكل البيانات المبنية على التعمية -مثل MyBetterMap وHashMap- حلًّا آمنًا، فإذا كنت متأكّدًا من أن قيم المفاتيح لن تتعدّل بينما هي مُستخدَمة في الخريطة، أو أن أي تعديلٍ يُجرَى عليها لن يؤثر على شيفرة التعمية؛ فلربما يكون استخدامها مناسبًا، ولكن من الأفضل دائمًا أن تتجنَّب ذلك. تمرين 8 ستُنهِي في هذا التمرين تنفيذ الصنف MyBetterMap، حيث ستجد ملفات شيفرة التمرين في مستودع السلسلة: MyLinearMap.java: يحتوي على حل تمرين مقالة "تحليل زمن تشغيل الخرائط المُنفَّذة باستخدام مصفوفة" الذي سنبني عليه هذا التمرين. MyBetterMap.java: يحتوي على شيفرة من نفس المقالة مع إضافة بعض التوابع التي يُفترَض أن تُكملها. MyHashMap.java: يحتوي على تصوّرٍ مبدئيٍّ -عليك اكماله- لجدولٍ ينمو عند الضرورة. MyLinearMapTest.java: يحتوي على اختباراتِ وحدةٍ للصنف MyLinearMap. MyBetterMapTest.java: يحتوي على اختبارات وحدةٍ للصنف MyBetterMap. MyHashMapTest.java: يحتوي على اختبارات وحدةٍ للصنف MyHashMap. Profiler.java: يحتوي على شيفرةٍ لقياس الأداء ورسم تأثير حجم المشكلة على زمن التشغيل. ProfileMapPut.java: يحتوي على شيفرة تقيس أداء التابع Map.put. كالعادة، عليك أن تُنفِّذ الأمر ant build لكي تُصرِّف ملفات الشيفرة، ثم الأمر ant MyBetterMapTest. ستفشل العديد من الاختبارات؛ وذلك لأنه ما يزال عليك اكمال بعض التوابع. راجع تنفيذ التابعين put وget من ذات المقالة المشار إليها في الأعلى، ثم أكمل متن التابع containsKey. سيكون عليك استخدام التابع chooseMap، وبعدما تنتهي نفِّذ الأمر ant MyBetterMapTest مرةً أخرى، وتأكّد من نجاح testContainsKey. أكمل متن التابع containsValue، ولا تَستخدِم لذلك التابع chooseMap. نفِّذ الأمر ant MyBetterMapTest مرةً أخرى، وتأكّد من نجاح testContainsValue. لاحِظ أن العثور على القيمةِ يتطلَّب عملًا أكبر من العثور على المفتاح. يُعَد تنفيذ التابع containsKey تنفيذًا خطيًّا مثل التابعين put وget؛ لأنه عليه أن يبحث في إحدى الخرائط الفرعية. سنشرح في المقال التالي كيف يُمكِننا تحسين هذا التنفيذ أكثر. ترجمة -بتصرّف- للفصل Chapter 10: Hashing من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تحسين أداء الخرائط المنفذة باستخدام التعمية HashMap في جافا المقال السابق: تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا استخدام خريطة ومجموعة لبناء مفهرس Indexer تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة مترابطة تحليل زمن تشغيل القوائم المنفذة باستخدام مصفوفة تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة ازدواجية الترابط
-
تُعدّ القائمة المترابطة linked list نوعًا خاصًا من بنى البيانات data structure، حيث تتكوَّن من مجموعة كائناتٍ مربوطةٍ مع بعضها بعضًا باستخدام مؤشرات pointers. استخدمنا في المقال السابق قائمةً مترابطةً لتخزين قائمةٍ مرتّبةٍ من السلاسل النصية من النوع String، كما نفَّذنا عمليات الإضافة والحذف والبحث على تلك القائمة. ومع ذلك، كان بإمكاننا بسهولة تخزين قائمة السلاسل النصية بمصفوفةٍ أو كائنٍ من النوع ArrayList بدلًا من استخدام قائمةٍ مترابطة، وكنا سنتمكَّن من إجراء نفس العمليات على القائمة. على الرغم من أن تنفيذ تلك العمليات سيكون مختلفًا بالتأكيد، إلا أن الواجهات interfaces المُستخدمة والسلوك المنطقي لتلك العمليات سيكون نفسه. يُشير مصطلح نوع بيانات مجرد abstract data type -أو اختصارًا ADT- إلى مجموعة قيمٍ محتملة ومجموعة من العمليات المسموح بتطبيقها على تلك القيم دون أي تخصيصٍ لطريقة تمثيل تلك القيم أو طريقة تنفيذ تلك العمليات، حيث يُمكِننا مثلًا تعريف قائمةٍ مُرتَّبةٍ من السلاسل النصية باستخدام نوع بياناتٍ مجرد، على أنها أي متتاليةٍ من السلاسل النصية من النوع String شرط أن تكون مُرتَّبةً ترتيبًا تصاعديًا، وبحيث يُمكِننا إجراء عملياتٍ عليها، مثل إضافة سلسلةٍ نصيةٍ جديدة، أو حذف سلسلةٍ نصية، أو البحث عن سلسلةٍ نصيةٍ ضمن القائمة. يُمكِنك بالتأكيد تنفيذ نفس نوع البيانات المُجرَّد ADT باستخدام طرقٍ مختلفة، فمثلًا قد تُنفِّذ قائمة السلاسل النصية المُرتَّبة باستخدام قائمة مترابطة أو مصفوفة array. في الواقع، تعتمد البرامج المُستخدِمة لهذا النوع من البيانات على التعريف المجرد للنوع، وبالتالي يُمكِنها استخدام أيٍ من تنفيذاتها implementation المُتوفِّرة، كما يُمكِنها التبديل بينها بسهولة؛ وهذا يعني أنه من الممكن تبديل التنفيذ الخاص بنوع بياناتٍ مجردٍ معين دون التأثير على البرنامج، ويُسهِّل ذلك من عملية تنقيح أخطاء debug البرامج. تُعدّ أنواع البيانات المجردة ADTs عمومًا واحدةً من الأدوات الهامة بهندسة البرمجيات software engineering. سنناقش في هذا المقال نوعين من أنواع البيانات المجردة، هما المكدس stack والرتل queue، حيث تُستخدَم عادةً القوائم المترابطة لتنفيذ كليهما، ولكنها بالتأكيد ليست التنفيذ الأوحد. يُمكِنك النظر للجزء المتبقي من هذا القسم على أساس كونه مناقشةً عن الأكداس والأرتال من ناحية؛ وعلى أساس كونه دراسة حالة case study لأنواع البيانات المجردة من الناحية الأخرى. 9.3.1: المكدسات Stacks يتكوَّن المكدس stack من متتاليةٍ من العناصر التي يُمكِننا النظر إليها على أنها مجموعةٌ من العناصر المتراكمة فوق بعضها بعضًا، حيث يُمكننا الوصول مباشرةً وفي أي لحظة إلى العنصر الموجود بالأعلى، كما يُمكِننا حذفه من المكدس باستخدام عمليةٍ تُعرَف باسم السحب pop، ويُمكِننا في المقابل حذف عنصرٍ آخر أسفل المكدس فقط بعد سحب pop جميع العناصر الواقعة أعلى ذلك العنصر من المكدس. من الممكن أيضًا إضافة عنصرٍ جديدٍ أعلى المكدس باستخدام عمليةٍ تُعرَف باسم الدفع push. يُمكِن لعناصر المكدس أن تكون من أي نوع، فإذا كانت العناصر قيمٌ من النوع int مثلًا، فيمكن تنفيذ عمليتي السحب pop والدفع push كما في توابع النسخ instance methods التالية: void push(int newItem): لإضافة عنصرٍ جديد newItem أعلى المكدس. int pop(): لحذف العنصر الموجود أعلى المكدس وإعادته. لاحِظ أنه من الخطأ أن تحاول سحب pop عنصرٍ من مكدسٍ فارغ، ولذلك عليك أن تختبر أولًَا فيما إذا كان المكدس فارغًا أم لا، وسنحتاج بالتالي إلى عمليةٍ أخرى لإجراء ذلك الاختبار، والتي يُمكِننا تنفيذها باستخدام تابع النسخة التالي: boolean isEmpty(): يعيد القيمة true إذا كان المكدس فارغًا. نكون الآن قد عرَّفنا مكدسًا من الأعداد الصحيحة على أنه نوع بيانات مجرد abstract data type - ADT، ويُمكِننا تنفيذه بعدة طرقٍ شرط أن يَكُون سلوكه العام مكافئًا للتصور المُجرَّد للمكدس. لدى تنفيذ المكدس باستخدام القوائم المترابطة linked list، ستكون عقدة رأس القائمة head أعلى المكدس. تُعدّ إضافة العقد إلى مقدمة قائمةٍ مترابطة أو حذفها من المقدمة أسهل بكثير بالموازنة مع الإضافة أو الحذف من منتصف قائمة مترابطة. يَستخدِم الصنف بالشيفرة التالية قائمةً مترابطةً لتنفيذ النوع المجرد ADT مكدس أعداد صحيحة. لاحِظ تعريف الصنف المُوضّح بالأسفل لصنفٍ متداخلٍ nested ساكن static لتمثيل عقد القائمة المترابطة، وهو ما يُعدّ جزءًا من التنفيذ الخاص private implementation للنوع المجرد. public class StackOfInts { private static class Node { int item; Node next; } // مؤشر إلى العقدة الموجودة أعلى المكدس // إذا كان top يُساوِي القيمة الفارغة، فإن المكدس يكون فارغًا private Node top; // أضف N إلى أعلى المكدس public void push( int N ) { Node newTop; // العقدة التي ستحمل العنصر الجديد newTop = new Node(); newTop.item = N; // خزِّن N بالعقدة الجديدة newTop.next = top; // تشير العقدة الجديدة إلى العقدة التي كانت تحتل موضع أعلى المكدس مسبقًا top = newTop; // تُصبح العقدة الجديدة أعلى المكدس } // 1 public int pop() { if ( top == null ) throw new IllegalStateException("Can't pop from an empty stack."); int topItem = top.item; // العنصر المسحوب من المكدس top = top.next; // العنصر الموجود أعلى المكدس حاليًا return topItem; } // 2 public boolean isEmpty() { return (top == null); } } // end class StackOfInts [1] احذف العنصر الموجود في أعلى المكدس، ثم أعده. إذا كان المكدس فارغًا، بلِّغ عن حدوث اعتراض من النوع IllegalStateException. [2] أعد القيمة المنطقية true إذا كان المكدس فارغًا؛ أما إذا كان يحتوي على عنصرٍ واحد أو أكثر، أعد القيمة المنطقية false. عليك التأكُّد من فهم طريقة عمل عمليتي السحب pop والدفع push بالقائمة المترابطة، وقد يُساعدك رسم بعض الصور على ذلك. تُمثّل القائمة المترابطة بالأعلى جزءًا من التنفيذ الخاص للصنف StackOfInts، أي لا يحتاج البرنامج الذي يَستخدِم ذلك الصنف إلى معرفة حقيقة كونه يستخدم قائمةً مترابطة. يُمكننا الآن أيضًا تنفيذ المكدس باستخدام مصفوفةٍ بدلًا من قائمةٍ مترابطة، حيث سنحتاج إلى عدّاد counter لمعرفة عدد الخانات المُستخدَمة فعليًا بالمصفوفة، لأن عدد عناصر المكدس يختلف من وقتٍ لآخر. بفرض أن اسم العداد هو top، فستكون عناصر المكدس مُخزَّنةً بمواضع المصفوفة 0، و1، … حتى top-1، ويتواجد العنصر بالموضِع 0 أسفل المكدس بينما يتواجد العنصر بالموضِع top-1 أعلى المكدس. من السهل دفع push عنصرٍ إلى المكدس على النحو التالي: ضع العنصر بالموضِع top وزِد قيمة top بمقدار الواحد. وإذا لم نكن نريد وضع حدٍ أقصى لعدد العناصر التي يستطيع المكدس أن يحملها، يُمكِننا استخدام مصفوفةٍ ديناميكية dynamic array، التي كنا قد تعرَّضنا لها بالقسم الفرعي 7.2.4. يعرض التصور النموذجي للمصفوفة المكدس مقلوبًا رأسًا على عقب؛ أي سيظهر أسفل المكدس بأعلى المصفوفة، وهذا في الواقع غير مهم؛ فالمصفوفة هي مجردُ تنفيذٍ للفكرة المجرَّدة للمكدس، وطالما تجري العمليات على المكدس كما ينبغي لها، فليس هناك أي داعٍ للقلق. تعرض الشيفرة التالية تنفيذًا آخرًا للصنف StackOfInts باستخدام مصفوفةٍ ديناميكية. import java.util.Arrays; // Arrays.copyOf() من أجل تابع public class StackOfInts { // (إصدار بديل، استعمال مصفوفة) private int[] items = new int[10]; // مصفوفة لحمل عناصر المكدس private int top = 0; // عدد العناصر الموجودة بالمكدس حاليًا // أضف N إلى أعلى المكدس public void push( int N ) { if (top == items.length) { // إذا كانت المصفوفة ممتلئة، أنشِئ واحدة جديدة أكبر حجمًا // وانسخ العناصر الموجودة بالمكدس حاليًا إليها items = Arrays.copyOf( items, 2*items.length ); } items[top] = N; // ضع N بالموضِع المتاح التالي top++; // أزد عدد العناصر بمقدار الواحد } // 1 public int pop() { if ( top == 0 ) throw new IllegalStateException("Can't pop from an empty stack."); int topItem = items[top - 1]; // العنصر الموجود أعلى المكدس top--; // انقص عدد العناصر الموجودة بالمكدس بمقدار الواحد return topItem; } // 2 public boolean isEmpty() { return (top == 0); } } // end class StackOfInts [1] احذف العنصر الموجود أعلى المكدس، ثم أعده مثل قيمةٍ للدالة، وإذا كان المكدس فارغًا، بلِّغ عن حدوث اعتراض من النوع IllegalStateException. [2] أعد القيمة المنطقية true إذا كان المكدس فارغًا؛ أما إذا كان يحتوي على عنصرٍ واحدٍ أو أكثر، أعد القيمة المنطقية false. نُعيد التأكيد على أن تنفيذ المكدس على هيئة مصفوفة شأنٌ خاصٌ بالصنف؛ وهذا يعني أنه من الممكن استبدال نسختي الصنف StackOfInts المُعرفتين بالأعلى بعضهما ببعض دون أي مشكلة، لأن واجهتهما العامة public interface متطابقة. يُمكِننا الآن تحليل زمن تشغيل run time العمليات على المكدس (ألقِ نظرةً على القسم 8.5)، حيث يمكننا قياس حجم المشكلة من خلال عدد العناصر الموجودة بالمكدس؛ فبالنسبة للتنفيذ الذي يَستخدِم قائمةً مترابطة، فإن زمن تشغيل الحالة الأسوأ worst case لعمليتي الدفع push والسحب pop يُساوِي Θ(1)، وهذا يعني أن زمن التشغيل أقل من قيمةٍ ثابتةٍ معينة لا تعتمد على عدد عناصر المكدس. يُمكِنك رؤية ذلك بوضوحٍ من خلال الشيفرة ذاتها؛ فتنفيذ العمليتين مُكوَّنٌ من عدة تعليمات إسناد assignment قليلة وبسيطة، وعدد عناصر المكدس ليس له أي دور. أما بالنسبة للتنفيذ المُعتمِد على مصفوفة، فهناك حالةٌ خاصة تَحدُث أحيانًا أثناء عملية الدفع push، تحديدًا عندما تكون المصفوفة ممتلئة، حيث يُنشِئ الصنف في تلك الحالة مصفوفةً جديدةً ويَنسَخ جميع عناصر المكدس إلى تلك المصفوفة الجديدة. يستغرق ذلك وقتًا يتناسب طرديًا مع عدد عناصر المكدس. لذلك، على الرغم من أن زمن تشغيل عملية الدفع push يُساوِي عادةً Θ(1)، فإنه في الحالة الأسوأ worst case يُساوِي Θ(n)، حيث تمثّل n عدد العناصر الموجودة بالمكدس. نظرًا لندرة حدوث الحالة الأسوأ، يُمكِننا أن نقول أن زمن تشغيل الحالة المتوسطة average case هو Θ(1). 9.3.2: الأرتال Queues على نحوٍ مشابه من المكدس، يتكوَّن الرتل queue من متتاليةٍ من العناصر كما يوجد عددٌ من القيود بخصوص الكيفية التي نُضيف بها العناصر إلى الرتل أو نحذفها منه؛ وبخلاف المكدس، فإن الرتل لديه طرفين هما الطرف الأمامي والخلفي، حيث تُضاف العناصر دائمًا إلى الطرف الخلفي من الرتل، بينما تُحذَف من طرفه الأمامي. سنُطلِق على عملية إضافة العناصر إلى الرتل اسم إدراج enqueue، بينما سنُطلِق على عملية حذف العناصر منه اسم "سحب dequeue*، مع الملاحظة بأن هذه التسميات ليست قياسيةً بخلاف عمليتي *الدفع push* والسحب pop. عند إضافة عنصر إلى الطرف الخلفي من الرتل، فإنه يبقى بالرتل إلى أن تُحذَف جميع العناصر التي تسبقه، ويُعدّ ذلك أمرًا بديهيًا؛ فالرتل queue بالنهاية يُشبه خطًا أو صفًا من العملاء المنتظرين لخدمةٍ معينة، حيث تُقدَم الخدمة للعملاء بنفس ترتيب وصولهم إلى الصف. يُمكِن لعناصر الرتل أن تكون من أي نوع، فمثلًا قد يكون لدينا رتلًا من النوع العددي الصحيح int، كما يُمكِن تنفيذ عمليتي الإدراج enqueue والسحب dequeue كأنها توابع نسخ instance methods داخل تعريف الصنف QueueOfInts. علاوةً على ذلك، سنحتاج أيضًا إلى تابع نسخة آخر لاختبار فيما إذا كان الرتل queue فارغًا أم لا: void enqueue(int N): يضيف عنصرًا جديدًا N إلى الطرف الخلفي من الرتل. int dequeue(): يَحذِف عنصرًا من الطرف الأمامي من الرتل ويعيده. boolean isEmpty(): يُعيد القيمة true إذا كان الرتل فارغًا. يمكننا تنفيذ الرتل باستخدام قائمةٍ مترابطة أو مصفوفة، ومن الممكن أن يكون استخدام مصفوفةٍ لتنفيذ الرتل أعقد قليلًا من استخدامها لتنفيذ المكدس stack، ولهذا لن نتناوله هنا، وسنكتفي بالتنفيذ المبني على قائمةٍ مترابطة. يُمثِل العنصر الأول بقائمةٍ مترابطة الطرف الأمامي من الرتل، بينما يُمثِل العنصر الأخير بالقائمة الطرف الخلفي من الرتل، وتشبه عملية سحب dequeue عنصرٍ من الطرف الأمامي للرتل عملية سحب pop عنصرٍ من المكدس. في المقابل، تتضمَّن عملية إدراج enqueue عنصرٍ جديدٍ إلى الرتل ضبط المؤشر pointer الموجود بآخر عقدةٍ ضمن القائمة؛ لكي يُشير إلى عقدةٍ جديدةٍ تحتوي على العنصر المطلوب إضافته. يُمكِننا إجراء ذلك بتنفيذ الأمر tail.next = newNode;، حيث تمثّل tail مؤشرًا لآخر عقدةٍ ضمن القائمة المترابطة. بفرض أن head مؤشرٌ لأول عقدةٍ ضمن قائمةٍ مترابطة، يُمكننا استخدام الشيفرة التالية للحصول على مؤشرٍ لآخر عقدةٍ ضمن تلك القائمة: Node tail; // سيشير إلى العنصر الأخير ضمن القائمة tail = head; // ابدأ من العقدة الأولى while (tail.next != null) { tail = tail.next; // تحرَك إلى العقدة التالية } // 1 [1] بوصولنا إلى تلك النقطة من البرنامج، فإن tail.next يكون فارغًا مما يَعنِي أن tail يُشير إلى آخر عقدةٍ بالقائمة. ومع ذلك، سيكون من غير المناسب فعل ذلك في كل مرةٍ نرغب فيها بإضافة عنصرٍ جديدٍ إلى الرتل. لزيادة كفاءة الشيفرة بالأعلى، سنُعرِّف متغير نسخة instance variable يحتوي على مؤشرٍ لآخر عقدةٍ ضمن القائمة المترابطة، وهذا سيُعقِّد الصنف QueueOfInts بعض الشيء؛ وبالتالي علينا الانتباه إلى ضرورة تحديث قيمة ذلك المتغير أينما أضفنا عقدةً جديدةً إلى نهاية القائمة. يُمكِننا إذًا إعادة كتابة الصنف QueueOfInts على النحو التالي: public class QueueOfInts { /** * كائنٌ من النوع Node * يحمل أحد العناصر الموجودة ضمن القائمة المترابطة الممثلة للرتل */ private static class Node { int item; Node next; } private Node head = null; // يشير إلى العنصر الأول ضمن القائمة private Node tail = null; // يُشير إلى العنصر الأخير ضمن القائمة // أضف N إلى الطرف الخلفي من الرتل public void enqueue( int N ) { Node newTail = new Node(); // العقدة التي تحمل العنصر الجديد newTail.item = N; if (head == null) { // 1 head = newTail; tail = newTail; } else { // تُصبِح العقدة الجديدة هي ذيل القائمة بينما لا يتأثر رأسها tail.next = newTail; tail = newTail; } } // 2 public int dequeue() { if ( head == null) throw new IllegalStateException("Can't dequeue from an empty queue."); int firstItem = head.item; head = head.next; // أصبح العنصر الثاني مسبقًا العنصر الأول بالرتل if (head == null) { // 3 tail = null; } return firstItem; } // أعد القيمة المنطقية `true` إذا كان الرتل فارغًا boolean isEmpty() { return (head == null); } } // end class QueueOfInts [1] بما أن الرتل كان فارغًا، ستصبح العقدة الجديدة العقدة الوحيدة الموجودة بالقائمة؛ ونظرًا لكونها العقدة الأولى والأخيرة، سيشير head وtail إليها. [2] احذِف العنصر الموجود بمقدمة الرتل وأعده مثل قيمةٍ للتابع. في حال كان الرتل فارغًا، بلِّغ عن اعتراضٍ من النوع IllegalStateException. [3] أصبح الرتل فارغًا، وبما أن العقدة التي حذفناها كانت بمثابة عقدة الرأس head والذيل tail، فلم يَعُدّ هناك ذيلٌ للقائمة في الوقت الحالي. لفهم الدور الذي يلعبه المؤشر tail بالأعلى، يمكنك التفكير وفقًا لقاعدة الأصناف اللامتغايرة class invariant (أو الصنف اللامتغاير)، التي تعرَّضنا لها بالقسم الفرعي 8.2.3 والتي تنص على: "إذا لم يكن الرتل queue فارغًا، فإن tail يُشير إلى آخر عقدةٍ بالرتل". لا بُدّ أن يكون اللامتغاير صحيحًا ببداية ونهاية كل عملية استدعاءٍ للتابع. إذا طبَقنا تلك القاعدة على التابع enqueue في حالة القوائم غير الفارغة، يُخبرنا اللامتغاير بأننا نستطيع إضافة عقدةٍ جديدةٍ إلى نهاية القائمة بتنفيذ تعليمة مثل tail.next = newNode، كما يُخبرنا بكيفية ضبط قيمة tail قبل العودة من التابع، وجعله يُشير إلى العقدة الجديدة التي أُضيفَت للتو إلى الرتل. يستخدم الحاسوب الأرتال queues عندما يرغب بمعالجة عنصرٍ واحدٍ فقط بكل مرة في حين تنتظر عناصرٌ أخرى دورها للمعالجة. نستعرض فيما يلي بعض الأمثلة على ذلك: برامج جافا المُستخدِمة لعدِّة خيوط threads، حيث يحتفظ الحاسوب بالخيوط التي تطمع بزمنٍ للمعالجة من قِبَل وحدة المعالجة المركزية CPU داخل رتل. عند إنشاء خيطٍ جديد، سيُضيفه الحاسوب إلى الطرف الخلفي من ذلك الرتل. تسير الأمور على النحو التالي: يَحذِف الحاسوب خيطًا من الطرف الأمامي للرتل ثم يُعطِيه بعضًا من زمن المعالجة، وإذا لم ينتهي بعدها، يُرسِله الحاسوب مُجددًا إلى الطرف الخلفي من الرتل لينتظر دوره مرةً أخرى. تُخزَّن الأحداث events، مثل نقرات الفأرة وضغطات لوحة المفاتيح داخل رتلٍ اسمه "رتل الأحداث event queue"، ويَحذِف برنامجٌ معينٌ الأحداث الموجودة بذلك الرتل ويُعالِجها واحدًا تلو الآخر. يُمكِن وقوع أحداثٍ كثيرة أخرى بينما يُعالِج البرنامج حدثًا معينًا، ولكن نظرًا لأنها تُخزَّن تلقائيًا داخل ذلك الرتل، فإن البرنامج يُعالِجها دائمًا بنفس ترتيب حدوثها. يستقبل خادم الويب web server طلباتٍ من المتصفحات للوصول إلى بعض الصفحات. بطبيعة الحال، تَصِل طلبات جديدة بينما يُعالِج خادم الويب طلبًا سابقًا؛ لذلك تُوضَع الطلبات الجديدة برتلٍ وتنتظر إلى أن يحين دورها للمعالجة، حيث يَضمَن الرتل معالجة الطلبات بنفس ترتيب وصولها. تُنفِّذ الأرتال سياسة الداخل أولًا، يخرج أولًا FIFO؛ بينما تُنفِّذ الأكداس stacks سياسة الداخل آخرًا، يخرج أولًا LIFO. على نحوٍ مشابهٍ من الأرتال، يُمكِن اِستخدَام الأكداس لحمل العناصر التي تنتظر أن يحين دورها للمعالجة، على الرغم من أنها قد لا تكون عادلة ببعض الأحيان. لتوضيح الفرق بين المكدس stack والرتل queue، سنناقش البرنامج التوضيحي DepthBreadth.java. حاول تشغيل البرنامج، فهو يَعرِض شبكة مربعاتٍ مُلوَّنةٍ جميعها باللون الأبيض على نحوٍ مبدئي، وعندما يَنقُر المُستخدِم على مربعٍ أبيض، سيَضِع البرنامج علامةً على ذلك المربع، ثم يُحوِّله إلى اللون الأحمر. بعد ذلك، سيبدأ البرنامج بوضع علاماتٍ على أي مربعٍ مُتصِلٍ أفقيًا أو رأسيًا مع أيٍ من المربعات التي سَبَقَ للبرنامج وضع علامةٍ عليها. بالنهاية، ستُطبَق تلك العملية على جميع مربعات الشبكة. لنتمكَّن من فهم طريقة عمل البرنامج، سنحاول أن نضع أنفسنا مكان البرنامج: عندما يَنقُر المستخدم على مربع، لنتخيل أننا سنحصل على بطاقةٍ مكتوب عليها موضع ذلك المربع أي رقمي الصف والعمود. سنَضَع بعدها تلك البطاقة في كومة pile، والتي ستَتَضمَّن الآن بطاقةً واحدةً فقط. سنُكرِّر بعد ذلك ما يلي: إذا كانت الكومة فارغةً، نكون قد انتهينا؛ أما إذا لم تكن فارغة، فسنسحب إحدى البطاقات التي تُقابِل إحدى مربعات الشبكة. سنَفْحَص جميع المربعات المجاورة أفقيًا ورأسيًا لذلك المربع؛ فإذا لم نَكن قد مررنا بأيٍ من المربعات المجاورة من قبل، سنُدوِن موضعه على بطاقةٍ جديدة ونضعها بالكومة، ونكون قد انتهينا عندما لا يكون هناك أي بطاقات أخرى بالكومة تنتظر المعالجة. بهذا البرنامج: عندما يكون هناك مربعٌ بالكومة بانتظار المعالجة، يكون ملوَّنًا باللون الأحمر؛ وبالتالي تُمثِل المربعات الملونة بالأحمر تلك المربعات التي مررنا عبرها ولم نعالجها بعد. يتبدل لونه إلى اللون الرمادي بعد أن نأخذ المربع من الكومة ونعالجه، وبمجرد حدوث ذلك، فإن البرنامج يتخطاه دائمًا ولن يحاول معالجته مرة أخرى؛ لأن جميع المربعات المجاورة له أخذت بالحسبان بالفعل. بالنهاية، سيُعالِج البرنامج جميع المربعات، أي سيتحول لونها جميعًا إلى اللون الرمادي، وسينتهي البرنامج. يمكنك تشغيل البرنامج باستخدام إحدى الخيارات الثلاثة التالية: المكدس stack أو الرتل queue أو عشوائيًا، وسيتبّع البرنامج نفس النهج العام أيًا كان الخيار، حيث أن الفرق الوحيد بينها هو أسلوب إدارة كومة البطاقات؛ فبالنسبة للمكدس، ستُضاف البطاقات وتُحذَف بأعلى الكومة؛ أما بالنسبة للرتل، ستُضاف البطاقات إلى أسفل الكومة وتُحذَف من أعلى الكومة؛ وبالنسبة للحالة العشوائية، سيختار البرنامج إحدى البطاقات الموجودة بالكومة عشوائيًا. سيختلف بالتأكيد ترتيب معالجة مربعات الشبكة تمامًا باختلاف نوع الكومة، وهو ما يظهر بوضوحٍ من خلال الصور الثلاثة التالية المأخوذة من البرنامج، حيث سيبدأ البرنامج باختيار مربعٍ بالقرب من منتصف الشبكة مهما كان نوع الكومة المُستخدَمة. يَستخدِم البرنامج على اليسار مكدسًا، أما البرنامج بالمنتصف فيَستخدِم رتلًا، أما البرنامج على اليمين فيَستخدِم الاختيار العشوائي random selection. تختلف الأنماط الناتجة اختلافًا كبيرً، فعندما يَستخدِم البرنامج مكدسًا stack، فإنه يُحاوِل أن يستكشف أقصى قدرٍ ممكنٍ قبل البدء بإجراء تتبعٍ خلفي backtracking لفحص المربعات التي واجهها مُسبقًا؛ وعندما يستخدم البرنامج رتلًا queue، فإنه يعالج المربعات بنفس ترتيب بُعدها عن النقطة المبدئية تقريبًا؛ أما عندما يستخدم البرنامج الاختيار العشوائي random selection، فإننا نحصل على كائنٍ blob غير منتظم، ولكنه يَكون متصلًا بالتأكيد؛ حيث يمكن للبرنامج أن يواجه مربعًا معينًا فقط في حالة كان ذلك المربع مجاورًا لمربعٍ سَبَقَ أن واجهه البرنامج. يُمكنك تجريب البرنامج لترى طريقة عمله، وحاول أيضًا فهم طريقة استخدام المكدس والرتل ضمن ذلك البرنامج،وابدأ بمربعٍ واقعٍ بإحدى الزوايا المربعة. بينما ما يزال البرنامج قيد المعالجة، تستطيع أن تنقر على مربعاتٍ بيضاء أخرى، وستُضاف عندها إلى قائمة المربعات التي واجهها البرنامج. في تلك الحالة، إذا كان البرنامج يَستخدِم مكدسًا، ستلاحظِ أن البرنامج يُعالِج المربع الذي نقرت عليه فورًا بينما ستضطّر بقية المربعات الحمراء التي كانت بالفعل تنتظر دورها إلى الانتظار أكثر؛ وإذا كان البرنامج يستخدم رتلًا، فإنه لن يُعالِج المربع الذي نقرت عليه إلا بعد الانتهاء من معالجة جميع المربعات التي كانت موجودة بالفعل ضمن الكومة. يُمكِنك الإطلاع على الشيفرة المصدرية للبرنامج من الملف DepthBreadth.java. يبدو الرتل أكثر بداهةً لأنه يحدث بصورةٍ أكبر بالحياة الواقعية؛ ولكن في بعض الأحيان، يكون المكدس مناسبًا أكثر أو حتى أساسيًا. فماذا سيحدث مثلًا عندما يستدعي برنامج routine برنامجًا فرعيًا subroutine؟ يُعلَّق البرنامج الأول أثناء تنفيذ البرنامج الفرعي، ولا يكتمل تنفيذه إلى بعد انتهاء البرنامج الفرعي. لنفترض الآن أن ذلك البرنامج الفرعي استدعى بدوره برنامجًا فرعيًا ثانيًا، وأن ذلك البرنامج الفرعي الثاني قد استدعى بدوره برنامجًا ثالثًا، وهكذا، لذلك لا بُدّ من تعليق تنفيذ كل برنامجٍ فرعي بينما تُنفَّذ البرامج الفرعية اللاحقة؛ وهذا يعني أن الحاسوب لا بُدّ أن يحتفظ بحالة جميع البرامج الفرعية المُعلَّقة، وهو ما يفعله باستخدام مكدس. عند استدعاء برنامجٍ فرعي، يُنشَأ سجلٌ نشطٌ activation record له؛ حيث يحتوي على المعلومات المُتعلِّقة بتنفيذ البرنامج الفرعي، مثل متغيراته المحلية local variables ومعاملاته parameters، ويُوضَع ذلك السجل بالمكدس، ويُحذَف فقط عندما ينتهي البرنامج الفرعي من عمله ويعيد قيمة. إذا استدعى البرنامج الفرعي برنامجًا فرعيًا آخرًا، يُنشَئ سجلًا نشطًا جديدًا للبرنامج الفرعي الآخر ويُدفَع push إلى المكدس أعلى السجل الخاص بالبرنامج الفرعي الأول. يستمر المكدس بالنمو مع كل استدعاءٍ لبرنامجٍ فرعي، ويتقلص حجمه عند انتهاء تلك البرامج الفرعية من عملها. قد يحتوي المكدس على عدة سجلات لنفس البرنامج الفرعي في حالة البرامج الفرعية التعاودية recursive subroutine؛ أي تلك التي تعاود استدعاء ذاتها تعاوديًا. في الواقع، هذه هي ببساطة الطريقة التي يتمكَّن بها الحاسوب من تذكُّر مجموعة من الاستدعاءات التعاودية بنفس الوقت. 9.3.3: تعبيرات الإلحاق Postfix Expressions يمكن استخدام المكدس لتحصيل قيم تعبيرات الإلحاق postfix expressions. يُطلَق اسم "تعبير تدوين داخلي infix expression" على أي تعبيرٍ حسابيٍ عادي، مثل "2+(15-12)17"، حيث يُوضَع العامل operator في هذا النوع من التعبيرات بين معاملين operands، مثل "2 + 2"؛ أما بالنسبة لتعبيرات الإلحاق، يأتي العامل بعد معامليه مثل "2 2 +". يُمكِن كتابة التعبير "2+(15-12)17" بصياغة تعبيرات الإلحاق على النحو التالي "2 15 12 - 17 * +"، حيث يُطبَق العامل "-" بهذا التعبير على المعاملين اللذين يَسبِقانه أي "15" و"12". يُطبّق العامل "*" بالمثل على المعاملين اللذين يسبقانه أي "15 12 -" و"17". أخيرًا، يُطبّق "+" على "2" و"15 12 - 17 *". في الواقع، هذه هي نفس العمليات الحسابية التي يُنفِّذها تعبير التدوين الداخلي الأصلي. لنفترض الآن أننا نريد معالجة التعبير "2 15 12 - 17 * +" من اليسار إلى اليمين، وحساب قيمته. سيكون العنصر الأول الذي نواجهه هو 2، ولكن ما الذي يُمكِننا أن نفعله به؟ لا نعرف بهذه النقطة من البرنامج أي عاملٍ ينبغي تطبيقه على العدد 2، كما أننا لا نعرف قيمة المعامل الآخر. ينبغي إذًا أن نتذكر العدد 2 حيث سنحتاج إلى مُعالِجته لاحقًا بدفعه push إلى المكدس. سننتقل بعدها إلى العنصر التالي، أي 15، والذي سندفعه أيضًا إلى المكدس أعلى العدد 2، ثم ندفع العدد 12. الآن، سنواجه العامل "-"، والذي ينبغي أن نطبّقه على المعاملين اللذين يسبقانه بالتعبير، وبما أننا خزَّنا قيمة هذين المعاملين بالمكدس، يُمكِننا معالجة العامل "-" بسحب pull عددين من المكدس أي 12 و15، ثم نحسب قيمة التعبير "15 - 12" لنحصل على الإجابة 3. لا بُدّ من أن نتذكر تلك الإجابة لأننا سنحتاج إلى مُعالِجتها لاحقًا، ولهذا سندفعها إلى المكدس أعلى العدد 2 الذي ما يزال منتظرًا. سنفحص الآن العنصر التالي بالتعبير، أي العدد 17، والذي سندفعه إلى المكدس أعلى العدد 3. ولنتمكَّن من معالجة العنصر التالي أي "*"، يجب أن نسحب pop عددين من المكدس أي 17 و3، حيث يُمثّل العدد 3 قيمة "15 12 -". سنحسب الآن حاصل ضرب العددين، وسنحصل على الناتج 51، الذي سندفعه إلى المكدس. سنجد بعد ذلك أن العنصر التالي بالتعبير هو العامل "+"، والذي يُمكِننا مُعالجته بسحب pop العددين 51 و2 من المكدس، ثم حساب مجموعهما، ودفع الناتج 53 إلى المكدس. أخيرًا، وصلنا إلى نهاية التعبير، ويكون العدد الموجود بالمكدس هو القيمة الإجمالية للتعبير؛ أي أن كل ما علينا فعله هو أن نسحبه من المكدس، ونكون قد انتهينا. على الرغم من أنك قد ترى أن تعبيرات التدوين الداخلي infix expressions أكثر سهولةً، لكن تتمتع تعبيرات الإلحاق postfix expression ببعض المميزات؛ فهي لا تتطلَّب أي أقواسٍ أو قواعد أولوية precedence rules، حيث يعتمد الترتيب الذي تُطبّق على أساسه العوامل operators على ترتيب حدوثها ضمن التعبير، ويسمح ذلك بكتابة خوارزمياتٍ algorithms بسيطةٍ ومباشرة لتحصيل قيم تعبيرات الإلحاق. ألقِ نظرةً على ما يلي: // ابدأ بمكدس فارغ Start with an empty stack // لكل عنصر ضمن التعبير، نفذ ما يلي for each item in the expression: // إذا كان العنصر عددًا if the item is a number: // ادفع العدد إلى داخل المكدس Push the number onto the stack // إذا كان العنصر عاملًا else if the item is an operator: // اسحب معاملين من المكدس Pop the operands from the stack // Can generate an error // طبّق العامل على المعاملين Apply the operator to the operands // ادفع الناتج إلى المكدس Push the result onto the stack else // هناك خطأ بالتعبير There is an error in the expression // اسحب عددًا من المكدس Pop a number from the stack // Can generate an error // إذا كان المكدس فارغًا if the stack is not empty: // هناك خطأ بالتعبير There is an error in the expression else: // القيمة الأخيرة المسحوبة من المكدس هي قيمة التعبير The last number that was popped is the value of the expression علاوةً على ذلك، يمكن الكشف عن الأخطاء الموجودة ضمن أي تعبيرٍ بسهولة. لنفترض مثلًا أنه لدينا التعبير التالي "2 3 + "، حيث يُمكِننا بسهولة أن نرى أنه لا توجد معاملاٌت operands كافيةٌ للعامل ""، وستكشف الخوارزمية الموضحة أعلاه عن ذلك الخطأ عندما تحاول سحب pop معاملٍ ثانٍ للعامل "*" من المكدس الذي سيكون فارغًا. قد تحدث مشكلةٌ أخرى عندما لا يكون هناك عددٌ كافٍ من العوامل operators لجميع الأعداد ضمن التعبير مثل "2 3 4 +". ستكشف الخوارزمية عن ذلك الخطأ عندما يبقى العدد 2 بالمكدس بعد انتهاء الخوارزمية. يستخدم البرنامج التوضيحي PostfixEval.java تلك الخوارزمية، حيث يسمح للمُستخدم بكتابة تعبيرات إلحاقٍ posftix expression مكوّنةٍ من أعدادٍ حقيقيةٍ موجبة إلى جانب العوامل التالية "+" و"-" و"*" و"/" و"^"، حيث يُمثِل العامل "^" الأس؛ أي يُقيّّم التعبير "2 3 ^" على النحو التالي 23؛ كما يطبع البرنامج رسالةً نصيةً أثناء معالجته لكل عنصرٍ ضمن التعبير، ويستخدِم الصنف StackOfDouble المُعرَّف بالملف StackOfDouble.java، والذي يتطابق مع الصنف الأول StackOfInts المُبيَّن أعلاه باستثناء أنه يُخزِّن قيمًا من النوع double بدلًا من النوع int. الجانب الوحيد الشيّق في هذا البرنامج هو التابع method المُنفِّذ لخوارزمية تحصيل قيمة تعبيرات الإلحاق postfix evaluation. تعرض الشيفرة التالية تنفيذًا لتلك الخوارزمية، والذي هو في الواقع مجرد تحويلٍ مباشرٍ للشيفرة الوهمية المُوضحة بالأعلى إلى لغة جافا. private static void readAndEvaluate() { StackOfDouble stack; // المكدس المستخدم لتحصيل قيمة التعبير stack = new StackOfDouble(); // أنشِئ مكدسًا فارغًا System.out.println(); while (TextIO.peek() != '\n') { if ( Character.isDigit(TextIO.peek()) ) { // العنصر التالي المُدْخَل عبارة عن عدد // اِقرأه وخزنه بالمكدس double num = TextIO.getDouble(); stack.push(num); System.out.println(" Pushed constant " + num); } else { // نظرًا لأن العنصر التالي ليس عددًا، فإنه ينبغي أن يكون عاملًا // اقرأ العامل ونفذ العملية char op; // العامل الذي ينبغي أن تكون قيمته + أو - أو / أو * double x,y; // المعاملين اللذين سنسحبهما من المكدس double answer; // ناتج العملية op = TextIO.getChar(); if (op != '+' && op != '-' && op != '*' && op != '/' && op != '^') { // لا يُمثِل المحرف أي من العمليات المتاحة System.out.println("\nIllegal operator found in input: " + op); return; } if (stack.isEmpty()) { System.out.println(" Stack is empty while trying to evaluate " + op); System.out.println("\nNot enough numbers in expression!"); return; } y = stack.pop(); if (stack.isEmpty()) { System.out.println(" Stack is empty while trying to evaluate " + op); System.out.println("\nNot enough numbers in expression!"); return; } x = stack.pop(); switch (op) { case '+': answer = x + y; break; case '-': answer = x - y; break; case '*': answer = x * y; break; case '/': answer = x / y; break; default: answer = Math.pow(x,y); // (op must be '^'.) } stack.push(answer); System.out.println(" Evaluated " + op + " and pushed " + answer); } TextIO.skipBlanks(); } // end while // 1 if (stack.isEmpty()) { // يستحيل إذا كان المدخل غير فارغ فعليًا System.out.println("No expression provided."); return; } double value = stack.pop(); // قيمة التعبير System.out.println(" Popped " + value + " at end of expression."); if (stack.isEmpty() == false) { System.out.println(" Stack is not empty."); System.out.println("\nNot enough operators for all the numbers!"); return; } System.out.println("\nValue = " + value); } // end readAndEvaluate() [1] إذا وصلنا إلى تلك النقطة من البرنامج، سنكون قد قرأنا المُدْخلات بصورة سليمة. إذا كان التعبير صالحًا، فإن قيمة التعبير ستكون الشيء الوحيد الموجود بالمكدس. يلجأ الحاسوب عادةً إلى الاعتماد على تعبيرات الإلحاق postfix expressions. في الواقع، تُعدّ آلة جافا الافتراضية Java virtual machine آلة مكدس stack machine، بمعنى أنها تَستخدِم أسلوبًا يعتمد على المكدس لتحصيل قيم التعبيرات expressions. يُمكِن تمديد الخوارزمية بسهولةٍ بحيث تتمكَّن من معالجة المتغيرات variables والثوابت constants. عندما تواجه الخوارزمية متغيرًا ضمن تعبير، فإن قيمة ذلك المتغير ينبغي أن تُدفَع إلى المكدس، ويُمكن أيضًا تطبيقها على العوامل operators التي تَملُك أكثر أو أقل من مجرد معاملين اثنين operands، حيث يُمكِننا ببساطةٍ سحب pop العدد المطلوب من المعاملات من المكدس، ثم دفع الناتج إليه؛ فيُستخدَم على سبيل المثال عامل الطرح الأحادي "-" ضمن تعبيرٍ مثل "-x" له معاملٌ واحد. سنستمر بمناقشة التعبيرات وتحصيل قيمة التعبيرات expression evaluation بالقسمين التاليين. ترجمة -بتصرّف- للقسم Section 3: Stacks, Queues, and ADTs من فصل Chapter 9: Linked Data Structures and Recursion من كتاب Introduction to Programming Using Java. اقرأ أيضًا مقدمة إلى الاستثناءت exceptions ومعالجتها في جافا التوكيد assertion والتوصيف annotation في لغة جافا مقدمة إلى صحة البرامج ومتانتها في جافا التعاود recursion والمكدس stack في جافاسكربت
-
يتضمَّن أي كائنٍ object صالحٍ للاستعمال عددًا من متغيِّرات النسخ instance variables. عندما يُعطى نوع متغير نسخةٍ معين من قِبل اسم صنف class أو واجهة interface، فسيحمل هذا المتغير مرجًعا reference إلى كائنٍ آخر، ويُطلَق على المرجع اسم مؤشر pointer، ويُقال أن المُتغيِّر يُشير إلى كائنٍ ما. ومع ذلك، قد يحتوي المتغيِّر على القيمة الخاصة null؛ وفي تلك الحالة، لا يُشير المُتغير فعليًا إلى أي شيء. في المقابل، عندما يحتوي كائنٌ على متغير نسخة instance variable يُشير فعليًا إلى كائنٍ آخر، يُقال أن الكائنين مربوطين من خلال المؤشر. إن غالبية بنى البيانية data structures المعقدة مبنيّةٌ على تلك الفكرة؛ أي ربط عدة كائناتٍ ببعضها بعضًا. الترابط التعاودي Recursive Linking عندما يتضمَّن كائنٌ معينٌ مُتغيِّر نسخة instance variable يُشير إلى كائنٍ آخر من نفس نوع الكائن الأصلي، يُقال أن تعريف الصنف تعاودي recursive. يحدث هذا النوع من التعاود في كثير من الحالات، فإذا كان لدينا مثلًا صنفٌ مُصمَّمٌ لتمثيل الموظفين العاملين ضمن شركةٍ معينة، بحيث يُشرف على كل موظف باستنثاء رئيس الشركة موظفٌ آخر ضمن نفس الشركة. في تلك الحالة، ينبغي أن يتضمَّن الصنف Employee مُتغيّر نسخة من النوع Employee لتمثيل المشرف. يمكننا تعريف الصنف على النحو التالي: public class Employee { String name; // اسم الموظف Employee supervisor; // مشرف الموظف . . // متغيرات وتوابع نسخ أخرى . } // نهاية صنف الموظف إذا كان emp متغيرًا من النوع Employee، فإن emp.supervisor هو بالضرورة متغيرٌ آخر من النوع Employee؛ فإذا كان emp يُشير إلى رئيس الشركة، فينبغي أن تكون قيمة emp.supervisor فارغة أي تُساوِي null للدلالة على أن رئيس الشركة ليس لديه أي مشرف. إذا أردنا طباعة اسم المشرف الخاص بموظف معين، يُمكِننا استخدام الشيفرة التالية على سبيل المثال: if ( emp.supervisor == null) { System.out.println( emp.name + " is the boss and has no supervisor!" ); } else { System.out.print( "The supervisor of " + emp.name + " is " ); System.out.println( emp.supervisor.name ); } لنفترض الآن أننا نريد معرفة عدد المشرفين الواقعين بين موظفٍ معين وبين رئيس الشركة. كل ما علينا فعله هو تتبُّع متتاليةٍ من المشرفين، وعَدّ عدد الخطوات المطلوبة حتى نصل إلى رئيس الشركة. ألقِ نظرةً على الشيفرة التالية. if ( emp.supervisor == null ) { System.out.println( emp.name + " is the boss!" ); } else { Employee runner; // For "running" up the chain of command. runner = emp.supervisor; if ( runner.supervisor == null) { System.out.println( emp.name + " reports directly to the boss." ); } else { int count = 0; while ( runner.supervisor != null ) { count++; // Count the supervisor on this level. runner = runner.supervisor; // Move up to the next level. } System.out.println( "There are " + count + " supervisors between " + emp.name + " and the boss." ); } } بينما يُنفِّذ الحاسوب حلقة التكرار while بالأعلى، سيشير runner مبدئيًا إلى الموظف الأصلي أي emp، ثم إلى مشرف الموظف emp، ثم إلى مشرف مشرف الموظف emp، وهكذا. ستزداد قيمة المُتغيّر count بمقدار الواحد بكل مرةٍ يزور خلالها المُتغيّر runner موظفًا جديدًا، وتنتهي حلقة التكرار عندما يُصبِح runner.supervisor فارغًا، وهو ما يُشير إلى وصولنا إلى رئيس الشركة. سيحتوِي المتغير count في تلك النقطة من البرنامج على عدد الخطوات بين emp ورئيس الشركة. يُعدّ المتغير supervisor في هذا المثال مفيدًا وبديهيًا نوعًا ما، حيث تُعدّ بنى البيانات data structures المعتمدة على ربط عدة كائناتٍ ببعضها بعضًا مفيدةً جدًا لدرجة أنها تُمثِل موضوعًا رئيسيًا للدراسة بعلوم الحاسوب computer science. سنناقش عدة أمثلةٍ نموذجيةٍ متعلّقةٍ بهذا النوع من بنى البيانات، وسنتناول بالتحديد القوائم المترابطة linked lists ضمن هذا المقال وما يليه. تتكوَّن أي قائمةٍ مترابطة linked list من سلسلةٍ من الكائنات من نفس النوع، حيث يَربُط مؤشرٌ pointer كائنًا معينًا بالكائن الذي يليه. يشبه ذلك كثيرًا سلسلة المشرفين الواقعة بين الموظف emp ورئيس الشركة بالمثال السابق. من الممكن أيضًا أن نتعرَّض لمواقف ٍأكثر تعقيدًا، وعندها قد يَتضمَّن كائنٌ واحدٌ روابطًا إلى كائناتٍ أخرى عديدة، وهو ما سنناقشه بمقال قادم. القوائم المترابطة Linked Lists ستُبنَى القوائم المترابطة linked lists في غالبية الأمثلة المتبقية ضمن هذا المقال من كائناتٍ objects تنتمي إلى الصنف Node المُعرَّف على النحو التالي: class Node { String item; Node next; } يُستخدم عادةً مصطلح العقدة node للإشارة إلى أحد الكائنات الموجودة ببنيةٍ بيانيةٍ مترابطة linked data structure، حيث يُمكِننا ربط كائناتٍ من النوع Node ببعضها بعضًا كما هو مُوضَّح بالجزء العلوي من الصورة السابقة. تحتوي كل عقدةٍ على سلسلةٍ نصيةٍ من النوع String بالإضافة إلى مؤشرٍ للعقدة التالية ضمن القائمة إن وُجدت. يُمكِننا دائمًا تمييز العقدة الأخيرة بمثل تلك القائمة؛ حيث سيحمل متغير النسخة next ضمن تلك العقدة القيمة الفارغة null، وليس مؤشرًا إلى عقدةٍ أخرى. الهدف من ربط العقد بتلك الطريقة هو تمثيل قائمةٍ من السلاسل النصية strings؛ حيث تكون السلسلة النصية الأولى ضمن تلك القائمة مُخزَّنةً بالعقدة الأولى؛ بينما تكون السلسلة النصية الثانية مُخزَّنةً بالعقدة الثانية، وهكذا. في حين اِستخدَمنا مؤشراتٍ وكائناتٍ من النوع Node لبناء البنية البيانية data structure، ستكون البيانات التي نريد تمثيلها بالأساس هي قائمة السلاسل النصية. ونستطيع كذلك تمثيل قائمةٍ من الأعداد الصحيحة، أو قائمةٍ من الأزرار من النوع Button، أو قائمةٍ من أي نوعٍ آخر من البيانات فقط بتغيير نوع متغير النسخة item المُخزَّن بكل عقدة. على الرغم من تعريف الصنف Nodes بهذا المثال تعريفًا بسيطًا، فما يزال بإمكاننا استخدامه لتوضيح العمليات الشائعة على القوائم المترابطة linked lists، حيث تتضمَّن تلك العمليات ما يلي: حذف عقدةٍ من القائمة، أو إضافة عقدةٍ جديدة إلى القائمة، أو البحث عن سلسلةٍ نصية معينةٍ من النوع String ضمن عناصر القائمةitems. سنناقش مجموعةً من البرامج الفرعية subroutines المسؤولة عن تنفيذ جميع تلك العمليات. لاستخدام قائمةٍ مترابطةٍ ضمن برنامج، فإنه يحتاج إلى تعريف مُتغيّرٍ يشير إلى العقدة الأولى من تلك القائمة. في الواقع، كل ما يحتاجه البرنامج هو مؤشرٌ واحدٌ فقط إلى العقدة الأولى؛ بينما يمكن الوصول لجميع العقد الأخرى ضمن القائمة من خلال البدء من العقدة الأولى ثم التنقُّل من عقدةٍ لأخرى باتباع الروابط عبر القائمة. سنستخدم دائمًا بالأمثلة التالية متغيرًا اسمه head من النوع Node للإشارة إلى العقدة الأولى بالقائمة المترابطة، وعندما تَكون القائمة فارغةً، ستكون قيمة المتغير head هي القيمة الفارغة null. إجراء المعالجات الأساسية على قائمة مترابطة نحتاج عادةً إلى إجراء معالجةٍ معينةٍ على جميع العناصر الموجودة ضمن قائمةٍ مترابطة linked list، حيث لا بُدّ من البدء من رأس head القائمة، ثم التحرُّك من كل عقدةٍ للعقدة التي تليها باتباع المؤشر المُعرَّف ضمن كل عقدة، والتوقُّف أخيرًا عند الوصول إلى نهاية القائمة، وهو ما سندركه عند الوصول إلى القيمة الفارغة null. فإذا كان head متغيرًا من النوع Node، وكان يُشير إلى العقدة الأولى ضِمْن قائمةٍ مترابطة، فستكون الصياغة العامة المُستخدمة لمعالجة جميع عناصر تلك القائمة على النحو التالي: Node runner; // المؤشر المستخدم لاجتياز القائمة runner = head; // سيُشير runner إلى رأس القائمة مبدئيًا while ( runner != null ) { // استمر إلى أن تصل إلى القيمة الفارغة process( runner.item ); // عالج العنصر الموجود بالعقدة الحالية runner = runner.next; // تحرك إلى العقدة التالية } يُعدّ المتغير head الطريقة الوحيدة المُتاحة للوصول إلى عناصر القائمة. ونظرًا لأننا بحاجةٍ إلى تعديل قيمة المُتغيِّر runner، فلا بُدّ من إنشاء نسخةٍ من المتغير head، وإلا سنخسر الطريقة الوحيدة المتاحة للوصول إلى القائمة. بناءً على ذلك، سنستخدم تعليمة الإسناد assignment statement التالية runner = head لإنشاء نسخةٍ من المتغير head، حيث سيُشير المتغير runner إلى كل عقدةٍ ضمن القائمة تباعًا، وسيحمل runner.next مؤشرًا إلى العقدة التالية ضمن القائمة. وبالتالي، ستُحرِك تعليمة الإسناد runner = runner.next المؤشر على طول القائمة من عقدةٍ إلى أخرى، وعندما تُصبِح قيمة runner مساويةً للقيمة الفارغة null، نكون قد وصلنا إلى نهاية القائمة. لاحظ أن شيفرة المعالجة بالأعلى ستعمل بنجاح حتى لو كانت القائمة فارغة؛ لأن قيمة head لقائمةٍ فارغة هي القيمة الفارغة null، وعندها لن يُنفَّذ متن body حلقة التكرار while نهائيًا. يُمكِننا مثلًا طباعة جميع السلاسل النصية strings ضمن قائمةٍ من النوع String بكتابة ما يلي: Node runner = head; while ( runner != null ) { System.out.println( runner.item ); runner = runner.next; } يمكننا إعادة كتابة حلقة التكرار while باستخدام حلقة التكرار for، فعلى الرغم من أن المتغير المُتحكِّم بحلقة التكرار for عادةً ما يكون من النوع العددي، فإن ذلك ليس أمرًا ضروريًا. تعرض الشيفرة التالية حلقة تكرار for مُكافِئة لحلقة while بالأعلى: for ( Node runner = head; runner != null; runner = runner.next ) { System.out.println( runner.item ); } بالمثل، يُمكِننا أن نجتاز traverse قائمة أعدادٍ صحيحة لحساب حاصل مجموع الأعداد ضمن تلك القائمة، ويُمكننا بناء قائمةٍ مترابطة من الأعداد الصحيحة باستخدام الصنف التالي: public class IntNode { int item; // أحد الأعداد الصحيحة ضمن القائمة IntNode next; // مؤشر إلى العقدة التالية } إذا كان head متغيرًا من النوع IntNode، وكان يشير إلى قائمةٍ مترابطة من الأعداد الصحيحة، فإن الشيفرة التالية تحسب حاصل مجموع الأعداد الصحيحة ضمن تلك القائمة. int sum = 0; IntNode runner = head; while ( runner != null ) { sum = sum + runner.item; // أضف العنصر الحالي إلى المجموع runner = runner.next; } System.out.println("The sum of the list of items is " + sum); يُمكِننا أيضًا استخدام التعاود recursion لمعالجة قائمة مترابطة، ولكن من النادر استخدامه لمعالجة قائمة؛ لأنه من السهل دومًا استخدام حلقة loop لاجتيازها. ومع ذلك، قد يُساعد فهم طريقة تطبيق التعاود على القوائم على استيعاب كيفية تطبيق المعالجة التعاودية على بنى البيانات الأكثر تعقيدًا. يُمكِننا النظر لأي قائمةٍ مترابطة غير فارغة على أنها مُكوَّنة من جزئين: رأس القائمة head المُكوَّن من العقدة الأولى بالقائمة. ذيل القائمة tail المُكوَّن من جميع ما هو متبقي من القائمة. يُعدّ ذيل القائمة ذاته بمثابة قائمةٍ مترابطةٍ أخرى، والتي هي أقصر من القائمة الأصلية بفارق عقدةٍ واحدة. يُمثِل ذلك أرضيةً مناسبةً نوعًا ما لتطبيق التعاود، حيث يمكن تقسيم معالجة القائمة إلى معالجة رأس القائمة ومعالجةٍ تعاوديةٍ لذيل القائمة، ويُمثِّل العثور على قائمةٍ فارغة الحالة الأساسية base case. تعرض الشيفرة التالية مثلًا خوارزميةً تعاوديةً recursive algorithm لحساب حاصل مجموع الأعداد الصحيحة ضمن قائمة مترابطة. // إذا كانت القائمة فارغة if the list is empty then // أعد صفر لأنه ليس هناك أي أعداد لجمعها return 0 (since there are no numbers to be added up) otherwise // اضبط listsum إلى العدد الموجود بعقدة الرأس let listsum = the number in the head node // اضبط tailsum إلى حاصل مجموع الأعداد الموجودة بقائمة الذيل تعاوديًا let tailsum be the sum of the numbers in the tail list (recursively) // أضف tailsum إلى listsum add tailsum to listsum return listsum يتبقى الآن الإجابة على السؤال التالي: كيف نحصل على ذيل قائمة مترابطة غير فارغة؟ إذا كان head متغيرًا يُشير إلى عقدة الرأس لقائمةٍ معينة، فسيشير متغير head.next إلى العقدة الثانية ضمن تلك القائمة، والتي تُمثِل في الوقت نفسه العقدة الأولى بذيل القائمة. لذلك، يُمكِننا النظر إلى head.next على أنه مؤشرٌ إلى ذيل القائمة. لاحِظ أنه في حالة كانت القائمة الأصلية مُكوَّنةً من عقدةٍ واحدةٍ فقط، فسيكون ذيل القائمة فارغًا، وعليه ستكون قيمة head.next مُساويةً للقيمة الفارغة null. نظرًا لاستخدام مؤشرٍ فارغٍ null pointer عمومًا لتمثيل القوائم الفارغة، فسيظل head.next مُمثِلًا مناسبًا لذيل القائمة حتى في تلك الحالة الخاصة. يمكننا إذًا تعريف الدالة التعاودية التالية بلغة جافا لحساب حاصل مجموع الأعداد ضمن قائمة. public static int addItemsInList( IntNode head ) { if ( head == null ) { // تُمثِل القائمة الفارغة أحد الحالات الأساسية return 0; } else { // 1 int listsum = head.item; int tailsum = addItemsInList( head.next ); listsum = listsum + tailsum; return listsum; } } تشير [1] إلى الحالة التعاودية وذلك عندما لا تكون القائمة فارغةً، احسب حاصل مجموع قائمة الذيل، ثم أضفه إلى العنصر الموجود بعقدة الرأس. لاحِظ أنه من الممكن كتابة ذلك في خطوةٍ واحدة على النحو التالي return head.item + addItemsInList( head.next );. سنناقش الآن مشكلةً أخرى يَسهُل حلها باستخدام التعاود بينما يَصعُب قليلًا بدونه، حيث تتمثل المشكلة بطباعة جميع السلاسل النصية الموجودة ضمن قائمةٍ مترابطةٍ من السلاسل النصية بترتيبٍ معاكسٍ لترتيب حدوثها ضمن القائمة؛ يعني ذلك أن نطبع العنصر الموجود برأس القائمة head بعد طباعة جميع عناصر ذيل القائمة. يقودنا ذلك إلى البرنامج التعاودي recursive routine التالي: public static void printReversed( Node head ) { if ( head == null ) { // الحالة الأساسية: أن تكون القائمة فارغة // ليس هناك أي شيءٍ لطباعته return; } else { // الحالة التعاودية: القائمة غير فارغة printReversed( head.next ); // اطبع السلاسل النصية بالذيل بترتيب معكوس System.out.println( head.item ); // اطبع السلسلة النصية بعقدة الرأس } } لا بُدّ من الاقتناع بأن هذا البرنامج يعمل، مع التفكير بإمكانية تنفيذه دون استخدام التعاود recursion. سنناقش خلال بقية هذا المقال عددًا من العمليات الأكثر تعقيدًا على قائمةٍ مترابطةٍ من السلاسل النصية، حيث ستكون جميع البرامج الفرعية subroutines التي سنتناولها توابع نُسخ instance methods مُعرَّفةً ضمن الصنف StringList الذي نفذَّه الكاتب. يُمثِل أي كائنٍ من النوع StringList قائمةً مترابطةً من السلاسل النصية، حيث يَملُك الصنف متغير نسخة خاص private اسمه head من النوع Node، ويشير إلى العقدة الأولى ضمن القائمة أو يحتوي على القيمة null إذا كانت القائمة فارغة. تستطيع توابع النسخ instance methods المُعرَّفة بالصنف StringList الوصول إلى head مثل متغير عام global. يُمكِنك العثور على الشيفرة المصدرية للصنف StringList بالملف StringList.java، كما أنه مُستخدمٌ بالبرنامج التوضيحي ListDemo.java، الذي يُمكِّنك من رؤية طريقة استخدامه بوضوح. سنُلقِي نظرةً على إحدى التوابع المُعرَّفة بالصنف StringList للإطلاع على مثالٍ عن المعالجة البسيطة للقوائم واجتيازها، حيث يبحث التابع ذو النوع StringList عن سلسلةٍ نصيةٍ معينة ضمن قائمة؛ إذا كان searchItem مُمثِلًا للسلسلة النصية التي نبحث عنها، فعلينا موازنة searchItem مع كل عنصرٍ ضمن تلك القائمة، بحيث نتوقف عن المعالجة عند العثور على العنصر الذي نبحث عنه. انظر الشيفرة التالية: public boolean find(String searchItem) { Node runner; // مؤشر لاجتياز القائمة // ابدأ بفحص رأس القائمة runner = head; while ( runner != null ) { // 1 if ( runner.item.equals(searchItem) ) return true; runner = runner.next; // تحرك إلى العقدة التالية } // 2 return false; } // end find() حيث تشير كل من [1] و[2] إلى الآتي: [1] افحص السلاسل النصية الموجودة بكل عقدة. إذا كانت السلسلة النصية هي نفسها التي نبحث عنها، أعد القيمة المنطقية true؛ لأننا ببساطة قد وجدناها بالقائمة. [2] عند وصولنا إلى تلك النقطة من البرنامج، نكون قد فحصنا جميع العناصر الموجودة بالقائمة دون العثور على searchItem، ولذلك سنعيد القيمة المنطقية false للإشارة إلى حقيقة عدم وجود العنصر بالقائمة. لاحِظ أنه من الممكن أن تكون القائمة فارغةً أي تكون قيمة head مساويةً للقيمة الفارغة null، لذلك ينبغي معالجة تلك الحالة معالجةً سليمةً؛ فإذا احتوى head المُعرَّف بالشيفرة بالأعلى على القيمة null، فلن يُنفَّذ متن حلقة التكرار while نهائيًا، أي لن يعالج التابع أي عقد nodes، وستكون القيمة المعادة هي القيمة false. هذا هو ما نريد فعله تمامًا عندما تكون القائمة فارغة؛ نظرًا لعدم امكانية حدوث searchItem ضمن قائمةٍ فارغة. الإضافة إلى قائمة مترابطة قد تكون مشكلة إضافة عنصرٍ جديدٍ إلى قائمةٍ مترابطة أكثر صعوبةً نوعًا ما، بالأخص عندما يكون من الضروري إضافة العنصر إلى منتصف القائمة، وتُعد هذه المشكلة أعقد معالجةٍ سنُجرِيها على بنى البيانات المترابطة linked data structures خلال هذا المقال بالكامل. تُحفَظ العناصر الموجودة بعُقد القائمة المترابطة في الصنف StringList مُرتَّبةً تصاعديًا، لذلك عند إضافة عنصرٍ جديدٍ إلى القائمة، لابُدّ من إضافته إلى الموضع الصحيح وفقًا لذلك الترتيب؛ وهذا يعني أننا سنضطَّر في أغلب الحالات إلى إضافة العنصر الجديد إلى مكانٍ ما بمنتصف القائمة أي بين عقدتين موجودتين بالفعل. لكي نتمكَّن من فعل ذلك، سيكون من الضروري تعريف متغيرين من النوع Node للإشارة إلى العقدتين اللتين ينبغي أن تقعا على جانبي العقدة الجديدة، حيث يُمثّل previous وrunner بالصورة التالية المتغيرين المذكورين. إلى جانب ذلك، سنحتاج إلى متغيرٍ آخر newNode للإشارة إلى العقدة الجديدة. لتمكين عملية الإضافة insertion، لا بُدّ من إلغاء الرابط الموجود من previous إلى runner، وإضافة رابطين جديدين من previous إلى newNode، ومن newNode إلى runner. بمجرد ضبط قيمة المتغيرين previous وrunner بحيث يُشيرا إلى العقد الصحيحة، يُمكِننا استخدام الأمر previous.next = newNode; لضبط previous.next لكي يشير إلى العقدة الجديدة، والأمر newNode.next = runner لضبط newNode.next لكي يُشير إلى المكان الصحيح. ولكن قبل أن نستطيع تنفيذ أي من تلك الأوامر، سنحتاج أولًا لضبط كلٍ من runner وprevious كما هو موضحٌ بالصورة السابقة. الفكرة ببساطة هي أن نبدأ من العقدة الأولى بالقائمة، ثم نتحرك على طول القائمة مرورًا بجميع العناصر الأقل من العنصر الجديد، ويجب علينا الانتباه جيدًا أثناء ذلك حتى لا نقع بمشكلة السقوط خارج نهاية القائمة؛ أي أننا لن نستطيع الاستمرار إذا وصل runner إلى نهاية القائمة وأصبحت قيمته تساوي null. لنفترض أن insertItem هو العنصر المطلوب إضافته إلى القائمة، ولنفترض أيضًا أنه ينتمي إلى مكانٍ ما بمنتصف القائمة، ستَتَمكَّن الشيفرة التالية من ضبط قيمة المتغيرين previous وrunner على نحوٍ صحيح. Node runner, previous; previous = head; // ابدأ من مقدمة القائمة runner = head.next; while ( runner != null && runner.item.compareTo(insertItem) < 0 ) { previous = runner; // "previous = previous.next" would also work runner = runner.next; } يستخدم المثال الموضح أعلاه تابع النسخة compareTo() -انظر إلى مقال السلاسل النصية String والأصناف Class والكائنات Object والبرامج الفرعية Subroutine في جافا- المُعرَّف بالصنف String لاختبار ما إذا كان العنصر الموجود بالعقدة node أقل من العنصر المطلوب إضافته. ليس هناك أي مشكلةٍ فيما سبق باستثناء افتراضنا الدائم بانتماء العقدة الجديدة إلى مكانٍ ما بمنتصف القائمة، وهو أمرٌ غير صحيح؛ فقد تكون قيمة العقدة المطلوب إضافتها insertItem أحيانًا أقل من قيمة العنصر الأول ضمن القائمة، وهنا لا بُدّ من إضافة العقدة الجديدة إلى رأس القائمة، وهو ما تفعله الشيفرة التالية. newNode.next = head; // Make newNode.next point to the old head. head = newNode; // Make newNode the new head of the list. من الممكن أيضًا أن تكون القائمة فارغة، وينبغي في هذه الحالة أن تكون newNode هي العقدة الأولى والوحيدة ضمن القائمة. يمكننا إنجاز ذلك ببساطةٍ من خلال تنفيذ تعليمة الإسناد التالية head = newNode، ويعالِج التعريف التالي للتابع insert()، المُعرَّف بالصنف StringList جميع تلك الاحتمالات. public void insert(String insertItem) { Node newNode; // عقدة تحتوي على العنصر الجديد newNode = new Node(); newNode.item = insertItem; // (N.B. newNode.next == null.) if ( head == null ) { // العنصر الجديد هو العنصر الأول والوحيد ضمن القائمة // اضبط head بحيث تشير إليه head = newNode; } else if ( head.item.compareTo(insertItem) >= 0 ) { // العنصر الجديد أقل من أول عنصر ضمن القائمة // لذلك ينبغي أن يُضبَط بحيث يكون رأسًا للقائمة newNode.next = head; head = newNode; } else { // ينتمي العنصر الجديد إلى مكان ما بعد العنصر الأول ضمن القائمة // ابحث عن موضعه المناسب وأضِفه إليه Node runner; // العقدة المستخدمة لاجتيار القائمة Node previous; // تُشير دائمًا إلى العقدة السابقة لـ runner runner = head.next; // ابدأ بفحص الموضع الثاني ضمن القائمة previous = head; while ( runner != null && runner.item.compareTo(insertItem) < 0 ) { // 1 previous = runner; runner = runner.next; } newNode.next = runner; // أضف newNode بعد previous previous.next = newNode; } } // end insert() يعني [1] حرّك previous وrunner على طول القائمة إلى أن يقع runner خارج القائمة، أو إلى أن يصل إلى عنصرٍ أكبر من أو يساوي insertItem. بانتهاء تلك الحلقة، سيشير previous إلى الموضع الذي ينبغي إضافة insertItem إليه. إذا كنت منتبهًا للمناقشة الموضحة أعلاه، فقد تكون لاحظت أن هناك حالةً خاصةً أخرى لم نذكرها، فما الذي سيحدث إذا كان علينا إضافة العقدة الجديدة إلى نهاية القائمة؟ وهذا يحدث إذا كانت جميع عناصر القائمة أقل من العنصر الجديد. في الواقع، يُعالِج البرنامج الفرعي subroutine المُعرَّف بالأعلى تلك الحالة أيضًا ضمن الجزء الأخير من تعليمة if؛ فإذا كان insertItem أكبر من جميع عناصر القائمة، فستنتهي حلقة while بعدما يجتاز runner القائمة بالكامل، وستُصبِح قيمته عندها مساويةً للقيمة الفارغة null. ولكن عند الوصول إلى تلك النقطة، سيكون previous ما يزال يشير إلى العقدة الأخيرة بالقائمة؛ ولهذا سنُنفِّذ التعليمة previous.next = newNode لإضافة newNode إلى نهاية القائمة. ونظرًا لأن runner يُساوِي القيمة الفارغة null، فسيَضبُط الأمر newNode.next = runner قيمة newNode.next إلى القيمة الفارغة null، وهو ما نحتاج إليه تمامًا لنتمكَّن من الإشارة إلى أن القائمة قد انتهت. الحذف من قائمة مترابطة تشبه عملية الحذف عملية الإدخال على الرغم من أنها أبسط نوعًا ما، حيث توجد حالاتٌ خاصة ينبغي أخذها بالحسبان؛ فإذا أردنا حذف العقدة الأولى بقائمة، فينبغي علينا ضبط المُتغيِّر head، وجعله يُشيِر إلى العقدة التي كانت تُعدّ مُسبقًا العقدة الثانية بتلك القائمة. بما أن المتغير head.next يشير بالأساس إلى العقدة الثانية بالقائمة، يُمكِننا إجراء التعديل المطلوب بتنفيذ التعليمة head = head.next، وعلينا أيضًا التأكُّد من أن البرنامج يعمل على نحوٍ سليم حتى وإن كانت قيمة head.next تُساوِي null. عندما لا يكون هناك سوى عقدةٍ واحدةٍ ضمن قائمةٍ معينة، فستصبح القائمة فارغةً. أما إذا كنا نريد حذف عقدةٍ بمنتصف قائمةٍ معينة، فعلينا ضبط قيمة المتغيرين previous وrunner كما فعلنا سابقًا؛ حيث يُشير runner إلى العقدة المطلوب حذفها، بينما يُشيِر previous إلى العقدة التي تسبق العقدة المطلوب حذفها ضمن تلك القائمة. بمجرد انتهائنا من ضبط قيمة المتغيرين، سيحذف الأمر previous.next = runner.next; العقدة المعنية، وسيتولى كانس المهملات garbage collector مسؤولية تحريرها. حاول رسم صورةٍ توضيحيةٍ لعملية الحذف. يُمكِننا الآن تعريف التابع delete() على النحو التالي: public boolean delete(String deleteItem) { if ( head == null ) { // القائمة فارغة، وبالتالي هي بالتأكيد لا تحتوي على deleteString return false; } else if ( head.item.equals(deleteItem) ) { // عثرنا على السلسلة النصية بأول عنصر بالقائمة. احذفه head = head.next; return true; } else { // إذا كانت السلسلة النصية موجودةً بالقائمة، فإنها موجودة بمكانٍ ما بعد // العنصر الأول، وعليه سنبحث عنه بتلك القائمة Node runner; // العقدة المستخدمة لاجتياز القائمة Node previous; // تُشير دائمًا إلى العقدة السابقة للعقدة runner runner = head.next; // ابدأ بفحص الموضع الثاني ضمن القائمة previous = head; while ( runner != null && runner.item.compareTo(deleteItem) < 0 ) { // 1 previous = runner; runner = runner.next; } if ( runner != null && runner.item.equals(deleteItem) ) { // يشير runner إلى العقدة المطلوب حذفها // احذف تلك العقدة بتعديل المؤشر بالعقدة السابقة previous.next = runner.next; return true; } else { // العنصر غير موجود بالقائمة return false; } } } // end delete() وتعني [1] حَرِك previous وrunner على طول القائمة إلى أن يقع runner خارج القائمة أو إلى أن يَصِل إلى عنصرٍ أكبر من أو يساوي deleteItem. بانتهاء تلك الحلقة، سيُشير runner إلى موضع العنصر المطلوب حذفه، إذا كان موجودًا بالقائمة. ترجمة -بتصرّف- للقسم Section 2: Linked Data Structures من فصل Chapter 9: Linked Data Structures and Recursion من كتاب Introduction to Programming Using Java. اقرأ أيضًا الحلقات التكرارية في البرمجة الأصناف المتداخلة Nested Classes في جافا تعرف على أهم الأحداث والتعامل معها في مكتبة جافا إف إكس JavaFX تعرف على المصفوفات (Arrays) في جافا
-
سنتناول في التمارين التالية تنفيذاتٍ مختلفةً للواجهة Map، حيث يعتمدُ أحدها على الجدول hash table، والذي يُعدّ واحدًا من أفضل هياكل البيانات الموجودة، في حين يتشابه تنفيذٌ آخرُ مع الصنف TreeMap، ويُمكِّننا من المرور عبر العناصر بحسب ترتيبها، غير أنّه لا يتمتع بكفاءة الجداول. ستكون لديك الفرصة لتنفيذ هياكل البيانات تلك وتحليل أدائها، وسنبدأ أولًا بتنفيذ بسيط للواجهة Map باستخدام قائمة من النوع List تتكوّن من أزواج مفاتيح/قيم key-value، ثم سننتقل إلى شرح الجداول. تنفيذ الصنف MyLinearMap سنُوفِّر كالمعتاد شيفرةً مبدئيةً، ومهمتك إكمال التوابع غير المكتملة. انظر إلى بداية تعريف الصنف MyLinearMap: public class MyLinearMap<K, V> implements Map<K, V> { private List<Entry> entries = new ArrayList<Entry>(); يَستخدِم هذا الصنف معاملي نوع type parameters، حيث يشير المعامل الأول K إلى نوع المفاتيح، بينما يشير المعامل الثاني V إلى نوع القيم. ونظرًا لأن الصنف MyLinearMap يُنفِّذ الواجهة Map، فإن عليه أن يُوفِّر التوابع الموجودة في تلك الواجهة. تحتوي كائنات النوع MyLinearMap على متغير نسخةٍ instance variable وحيدٍ entries، وهو عبارة عن قائمة من النوع ArrayList مكوّنة من كائنات تنتمي إلى النوع Entry، حيث يحتوي كل كائن من النوع Entry على زوج مفتاح-قيمة. انظر فيما يلي إلى تعريف الصنف: public class Entry implements Map.Entry<K, V> { private K key; private V value; public Entry(K key, V value) { this.key = key; this.value = value; } @Override public K getKey() { return key; } @Override public V getValue() { return value; } } لا تتعدى كائنات الصنف Entry كونها أكثر من مجرد حاوٍ لزوج مفتاح/قيمة، ويقع تعريف ذلك الصنف ضمن الصنف MyLinearList، ويَستخدِم نفس معاملات النوع K وV. هذا هو كل ما ينبغي أن تعرفه لحل التمرين، ولذا سننتقل إليه الآن. تمرين 7 ستجد ملفات شيفرة التمرين في المستودع: MyLinearMap.java: يحتوي هذا الصنف على الشيفرة المبدئية للجزء الأول من التمرين. MyLinearMapTest.java: يحتوي على اختبارات الواحدة unit tests للصنف MyLinearMap. ستجد أيضًا ملف البناء build.xml في المستودع. نفِّذ الأمر ant build لكي تُصرِّف ملفات الشيفرة، ثم نفِّذ الأمر ant MyLinearMapTest. ستجد أن بعض الاختبارات لم تنجح؛ والسبب هو أنه ما يزال عليك القيام ببعض العمل. أكمل متن التابع المساعد findEntry أولًا. لا يُعدّ هذا التابع جزءًا من الواجهة Map، ولكن بمجرد أن تكمله بشكلٍ صحيح، ستتمكَّن من استخدامه ضمن توابعَ كثيرة. يبحث هذا التابع عن مفتاحٍ معينٍ ضمن المُدْخَلات، ثم يعيد إما المُدْخَل الذي يحتوي على ذلك المفتاح، أو القيمة الفارغة null إذا لم يكن موجودًا، كما يوازن التابع equals -الذي وفرناه لك- بين مفتاحين، ويعالج القيم الفارغة null بشكل مناسب. نفِّذ الأمر ant MyLinearMapTest مرةً أخرى. حتى لو كنت قد أكملت التابع findEntry بشكل صحيح، فلن تنجح الاختبارات لأن التابع put غير مكتملٍ بعد، لهذا أكمل التابع put. ينبغي أن تقرأ توثيق التابع Map.put أولًا لكي تَعرِف ما ينبغي أن تفعله. ويُمكِنك البدء بكتابة نسخةٍ بسيطةٍ من التابع put، تضيف دومًا مُدْخَلًا جديدًا ولا تُعدِّل المدخلاتِ الموجودة. سيساعدك ذلك على اختبار الحالة البسيطة من التابع، أما إذا كانت لديك الثقة الكافية، فبإمكانك كتابة التابع كاملًا من البداية. ينبغي أن ينجح الاختبار containsKey بعدما تنتهي من كتابة التابع put. اقرأ توثيق التابع Map.get ثم نفِّذه، وشغِّل الاختبارات مرةً أخرى. بعد ذلك اقرأ توثيق التابع Map.remove، ثم نفِّذه. بوصولك إلى هذه النقطة، يُفترَض أن تكون جميع الاختبارات قد نجحت. تحليل الصنف MyLinearMap سنُقدِّم حلًا للتمرين الوارد في الأعلى، ثم سنُحلِّل أداء التوابع الأساسية. انظر إلى تعريف التابعين findEntry وequals: private Entry findEntry(Object target) { for (Entry entry: entries) { if (equals(target, entry.getKey())) { return entry; } } return null; } private boolean equals(Object target, Object obj) { if (target == null) { return obj == null; } return target.equals(obj); } قد يعتمد زمن تشغيل التابع equals على حجم target والمفاتيح، ولكنه لا يعتمد في العموم على عدد المُدْخَلات n، وعليه، يَستغرِق التابع equals زمنًا ثابتًا. بالنسبة للتابع findEntry، ربما يحالفنا الحظ ونجد المفتاح الذي نبحث عنه في البداية، ولكن هذا ليس مضمونًا، ففي العموم، يتناسب عدد المُدْخَلات التي سنبحث فيها مع n، وعليه، يَستغرِق التابع findEntry زمنًا خطيًا. تعتمد معظم التوابع الأساسية المُعرَّفة في الصنف MyLinearMap على التابع findEntry، بما في ذلك التوابع put وget وremove. انظر تعريف تلك التوابع: public V put(K key, V value) { Entry entry = findEntry(key); if (entry == null) { entries.add(new Entry(key, value)); return null; } else { V oldValue = entry.getValue(); entry.setValue(value); return oldValue; } } public V get(Object key) { Entry entry = findEntry(key); if (entry == null) { return null; } return entry.getValue(); } public V remove(Object key) { Entry entry = findEntry(key); if (entry == null) { return null; } else { V value = entry.getValue(); entries.remove(entry); return value; } } بعدما يَستدعِي التابعُ put التابعَ findEntry، فإن كل شيءٍ آخرَ ضمنَه يستغرق زمنًا ثابتًا. لاحِظ أن entries هي عبارةٌ عن قائمةٍ من النوع ArrayList، وأن إضافة عنصر إلى نهاية قائمةٍ من ذلك النوع تستغرق زمنًا ثابتًا في المتوسط؛ فإذا كان المفتاح موجودًا بالفعل في الخريطة، فإننا لن نضطّر إلى إضافة مُدْخَلٍ جديدٍ، ولكننا في المقابل سنضطّر لاستدعاء التابعين entry.getValue وentry.setValue، وكلاهما يستغرق زمنًا ثابتًا. بناءً على ما سبق، يُعدّ التابع put خطيًا، ويَستغرِق التابع get زمنًا خطيًا لنفس السبب. يُعدّ التابع remove أعقد نوعًا ما؛ فقد يضطّر التابع entries.remove إلى حذف العنصر من بداية أو وسط قائمةٍ من النوع ArrayList، وهو ما يستغرِق زمنًا خطيًا. والواقع أنّه ليس هناك مشكلة في ذلك، فما تزال محصلة عمليتين خطيتين عمليةً خطيّةً أيضًا. نستخلص مما سبق أن جميع التوابع الأساسية ضمن ذلك الصنف خطية، ولهذا السبب أطلقنا عليه اسم MyLinearMap. قد يكون هذا التنفيذ مناسبًا إذا كان عدد المُدْخَلات صغيرًا، ولكن ما يزال بإمكاننا أن نُحسِّنه. في الحقيقة، يُمكِننا أن نُنفِّذ جميع توابع الواجهة Map، بحيث تَستغرِق زمنًا ثابتًا. قد يبدو ذلك مستحيلًا عندما تسمعه لأول مرة، فهو أشبه بأن نقول أن بإمكاننا العثور على إبرةٍ في كومة قشٍّ في زمنٍ ثابتٍ، وذلك بغض النظر عن حجم كومة القش. سنشرح كيف لذلك أن يكون ممكنًا في خطوتين: بدلًا من أن نُخزِّن المُدْخَلات في قائمةٍ واحدةٍ كبيرةٍ من النوع List، سنقسِّمها على عدة قوائمَ قصيرةٍ، وسنَستخدِم شيفرة تعمية hash code - وسنشرح معناها في المقال التالي- لكل مفتاح؛ وذلك لتحديد القائمة التي سنَستخدِمها. يُعَد استخدام عدة قوائمَ قصيرةٍ أسرعَ من اِستخدَام قائمةٍ واحدةٍ كبيرةٍ، ولكنه مع ذلك لا يُغيِّر ترتيب النمو order of growth -كما سنناقش لاحقًا-، فما تزال العمليات الأساسية خطيّةً، ولكن هنالك خدعة ستُمكِّننا من تجاوز ذلك، فإذا زِدنا عدد القوائم بحيث نُقيّد عدد المُدْخَلات الموجودة في كل قائمة، فسنحصل على خريطةٍ ذات زمنٍ ثابتٍ. سنناقش تفاصيل ذلك في تمرين المقال التالي، ولكن قبل أن نفعل ذلك سنشرح ما تعنيه التعمية hashing. سنتناول حل هذا التمرين ونُحلِّل أداء التوابع الأساسية للواجهة Map في المقال التالي، وسنُقدِّم أيضًا تنفيذًا أكثر كفاءة. ترجمة -بتصرّف- للفصل Chapter 9: The Map interface من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تنفيذ الخرائط باستخدام التعمية hashing في جافا المقال السابق: استخدام خريطة ومجموعة لبناء مفهرس Indexer المصفوفات والدوال في جافا، طرق التحويل بين أنواع البيانات، ولمحة عن الأصناف والوراثة هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت البحث والترتيب في المصفوفات Array في جافا مفهوم المصفوفات الديناميكية (ArrayLists) في جافا
-
انتهينا من بناء زاحف الانترنت crawler في مقالة "تنفيذ أسلوب البحث بالعمق أولًا باستخدام الواجهتين Iterables و Iterators"، وسننتقل الآن إلى الجزء التالي من تطبيق محرك البحث، وهو الفهرس. يُعدّ الفهرس -في سياق البحث عبر الإنترنت- هيكل بياناتٍ data structure يُسهِّل من العثور على الصفحات التي تحتوي على كلمة معينة، كما يساعدنا على معرفة عدد مرات ظهور الكلمة في كل صفحة، مما يُمكِّننا من تحديد الصفحات الأكثر صلة. على سبيل المثال، إذا أدخل المُستخدِم كلمتي البحث Java وprogramming، فإننا نبحث عن كلتيهما ونسترجع عدة صفحاتٍ لكل كلمة. ستتضمَّن الصفحات الناتجة عن البحث عن كلمة Java مقالاتٍ عن جزيرة Java، وعن الاسم المستعار للقهوة، وعن لغة البرمجة جافا. في المقابل، ستتضمَّن الصفحات الناتجة عن البحث عن كلمة programming مقالاتٍ عن لغات البرمجة المختلفة، وعن استخداماتٍ أخرى للكلمة. باختيارنا لكلمات بحث تبحث عن الصفحات التي تحتوي على الكلمتين، سنتطلّع لاستبعاد المقالات التي ليس لها علاقة بكلمات البحث، وفي التركيز على الصفحات التي تتحدث عن البرمجة بلغة جافا. والآن وقد فهمنا ما يعنيه الفهرس والعمليات التي يُنفذّها، يُمكِننا أن نُصمم هيكل بياناتٍ يُمثِّله. اختيار هيكل البيانات تتلخص العملية الرئيسية للفهرس في إجراء البحث، فنحن نحتاج إلى إمكانية البحث عن كلمةٍ معيّنةٍ والعثور على جميع الصفحات التي تتضمَّنها. ربما يكون استخدام تجميعة من الصفحات هو الأسلوب الأبسط لتحقيق ذلك، فبتوفّر كلمة بحث معينة، يُمكِننا المرور عبر محتويات الصفحات، وأن نختار من بينها تلك التي تحتوي على كلمة البحث، ولكن زمن التشغيل في تلك الطريقة سيتناسب مع عدد الكلمات الموجودة في جميع الصفحات، مما يَعنِي أن العملية ستكون بطيئةً للغاية. والطريقة البديلة عن تجميعة الصفحات collection هي الخريطة map، والتي هي عبارة عن هيكل بيانات يتكون من مجموعة من أزواج، حيث يتألف كل منها من مفتاح وقيمة key-value. تُوفِّر الخريطة طريقةً سريعةً للبحث عن مفتاح معين والعثور على قيمته المقابلة. سنُنشِئ مثلًا الخريطة TermCounter، بحيث تربُط كل كلمة بحث بعدد مرات ظهور تلك الكلمة في كل صفحة، وستُمثِل المفاتيح كلمات البحث، بينما ستُمثِل القيم عدد مرات الظهور (أو تكرار الظهور). تُوفِّر جافا الواجهة Map التي تُخصِّص التوابع methods المُفترَض توافرها في أيّ خريطة، ومن أهمها ما يلي: get(key): يبحث هذا التابع عن مفتاح معين ويعيد قيمته المقابلة. put(key, value): يضيف هذا التابع زوجًا جديدًا من أزواج مفتاح/قيمة إلى خريطة من النوع Map، أو يستبدل القيمة المرتبطة بالمفتاح في حالة وجوده بالفعل. تُوفِّر جافا عدة تنفيذاتٍ للواجهة Map، ومن بينها التنفيذان HashMap وTreeMap اللذان سنناقشهما في مقالات قادمة ونُحلّل أداء كُلٍّ منهما. بالإضافة إلى الخريطة TermCounter التي تربط كلمات البحث بعدد مرات ظهورها، سنُعرِّف أيضًا الصنف Index الذي يربط كل كلمة بحث بتجميعة الصفحات التي تَظهرَ فيها الكلمة. يقودنا ذلك إلى السؤال التالي: كيف نُمثِل تجميعة الصفحات؟ سنتوصل إلى الإجابة المناسبة إذا فكرنا في العمليات التي ننوي تنفيذها على تلك التجميعة. سنحتاج في هذا المثال إلى دمج مجموعتين أو أكثر، وإلى العثور على الصفحات التي تظهر الكلمات فيها جميعًا. ويُمكِن النظر إلى ذلك وكأنه عملية تقاطع مجموعتين sets. يتمثَل تقاطع أي مجموعتين بمجموعة العناصر الموجودة في كلتيهما. تُخصِّص الواجهة Set بلغة جافا العملياتِ التي يُفترَض لأي مجموعة أن تكون قادرةً على تنفيذها، ولكنها لا تُوفِّر عملية تقاطعِ مجموعتين، وإن كانت تُوفِّر توابعَ يُمكِن باستخدامها تنفيذ عملية التقاطع وغيرها بكفاءة. وفيما يلي التوابع الأساسية للواجهة Set: add(element): يضيف هذا التابع عنصرًا إلى مجموعة. وإذا كان العنصر موجودًا فعليًا ضمن المجموعة، فإنه لا يفعل شيئًا. contains(element): يَفحَص هذا التابع ما إذا كان العنصر المُمرَّر موجودًا في المجموعة. تُوفِّر جافا عدة تنفيذات للواجهة Set، ومن بينها الصنفان HashSet وTreeSet. الآن وقد صممنا هياكل البيانات من أعلى لأسفل، فإننا سنُنفِّذها من الداخل إلى الخارج بدءًا من الصنف TermCounter. الصنف TermCounter يُمثِل الصنف TermCounter ربطًا بين كلمات البحث مع عدد مرات حدوثها في الصفحات، وتَعرِض الشيفرة التالية الجزء الأول من تعريف الصنف: public class TermCounter { private Map<String, Integer> map; private String label; public TermCounter(String label) { this.label = label; this.map = new HashMap<String, Integer>(); } } يربط متغير النسخة map الكلمات بعدد مرات حدوثها، بينما يُحدّد المتغير label المستند الذي يحتوي على تلك الكلمات، وسنَستخدِمه لتخزين محددات الموارد الموحدة URLs. يُعدّ الصنف HashMap أكثر تنفيذات الواجهة Map شيوعًا، وسنَستخدِمه لتنفيذ عملية الربط، كما سنتناول طريقة عمله ونفهم سبب شيوع استخدامه في المقالات القادمة. يُوفِّر الصنف TermCounter التابعين put وget المُعرَّفين على النحو التالي: public void put(String term, int count) { map.put(term, count); } public Integer get(String term) { Integer count = map.get(term); return count == null ? 0 : count; } يَعمَل التابع put بمثابة تابع مُغلِّف، فعندما تستدعيه، سيَستدعِي بدوره التابع put المُعرَّف في الخريطة المُخزَّنة داخله. من الجهة الأخرى، يقوم التابع get بعمل حقيقيّ، فعندما تَستدعِيه سيَستدعِي التابع get المُعرَّف في الخريطة، ثم يَفحَص النتيجة، فإذا لم تكن الكلمة موجودةً في الخريطة من قبل، فإن التابع TermCounter.get يعيد القيمة 0. يُساعدنا تعريف التابع get بتلك الطريقة على تعريف التابع incrementTermCount بسهولة، ويَستقبِل ذلك التابع كلمةً ويزيد العدّاد الخاصَّ بها بمقدار 1. public void incrementTermCount(String term) { put(term, get(term) + 1); } إذا لم تكن الكلمة موجودةً ضمن الخريطة، فسيعيد get القيمة 0، ونزيد العداد بمقدار 1، ثم نَستخدِم التابع put لإضافة زوج مفتاح/قيمة key-value جديد إلى الخريطة. في المقابل، إذا كانت الكلمة موجودةً في الخريطة فعلًا، فإننا نسترجع قيمة العداد القديم، ونزيدها بمقدار 1، ثم نُخزِّنها بحيث تَستبدِل القيمة القديمة. يُعرِّف الصنف TermCounter توابع أخرى للمساعدة على فهرسة صفحات الإنترنت: public void processElements(Elements paragraphs) { for (Node node: paragraphs) { processTree(node); } } public void processTree(Node root) { for (Node node: new WikiNodeIterable(root)) { if (node instanceof TextNode) { processText(((TextNode) node).text()); } } } public void processText(String text) { String[] array = text.replaceAll("\\pP", " "). toLowerCase(). split("\\s+"); for (int i=0; i<array.length; i++) { String term = array[i]; incrementTermCount(term); } } processElements: يَستقبِل هذا التابع كائنًا من النوع Elements الذي هو تجميعة من كائنات Element. يمرّ التابع عبر التجميعة ويَستدعِي لكل كائنٍ منها التابع processTree. processTree: يَستقبِل عقدةً تُمثِّل عقدة جذر شجرة DOM، ويَمرّ التابع عبر الشجرة، ليعثر على العقد التي تحتوي على نص، ثم يَستخرِج منها النص ويُمرِّره إلى التابع processText. processText: يَستقبِل سلسلةً نصيةً من النوع String تحتوي على كلمات وفراغات وعلامات ترقيم وغيرها. يَحذِف التابع علامات الترقيم باستبدالها بفراغات، ويُحوِّل الأحرف المتبقية إلى حالتها الصغرى، ثم يُقسِّم النص إلى كلمات. يَمرّ التابع عبر تلك الكلمات، ويَستدِعي التابع incrementTermCount لكُلٍّ منها، ويَستقبِل التابعان replaceAll وsplit تعبيرات نمطية regular expression مثل معاملات. وأخيرًا، انظر إلى المثال التالي الذي يُوضِّح طريقة استخدام الصنف TermCounter: String url = "http://en.wikipedia.org/wiki/Java_(programming_language)"; WikiFetcher wf = new WikiFetcher(); Elements paragraphs = wf.fetchWikipedia(url); TermCounter counter = new TermCounter(url); counter.processElements(paragraphs); counter.printCounts(); يَستخدِم هذا المثال كائنًا من النوع WikiFetcher لتحميل صفحةٍ من موقع ويكيبيديا، ثم يُحلّل النص الرئيسيَّ الموجودَ بها، ويُنشِئ كائنًا من النوع TermCounter ويَستخدِمه لعدّ الكلمات الموجودة في الصفحة. يُمكِنك تشغيل الشيفرة في القسم التالي، واختبار فهمك لها بإكمال متن التابع غير المكتمل. تمرين 6 ستجد ملفات شيفرة التمرين في مستودع الكتاب: TermCounter.java: يحتوي على شيفرة القسم السابق. TermCounterTest.java: يحتوي على شيفرة اختبار الملف TermCounter.java. Index.java: يحتوي على تعريف الصنف الخاص بالجزء التالي من التمرين. WikiFetcher.java: يحتوي على الصنف الذي استخدمناه في التمرين السابق لتحميل صفحة إنترنت وتحليلها. WikiNodeIterable.java: يحتوي على الصنف الذي استخدمناه لاجتياز عقد شجرة DOM. ستَجِد أيضًا ملف البناء build.xml. نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant TermCounter لكي تُشغِّل شيفرة القسم السابق. تَطبَع تلك الشيفرة قائمة بالكلمات وعدد مرات ظهورها، وينبغي أن تحصُل على خرجٍ مشابهٍ لما يلي: genericservlet, 2 configurations, 1 claimed, 1 servletresponse, 2 occur, 2 Total of all counts = -1 قد تجد ترتيب ظهور الكلمات مختلفًا عندما تُشغِّل الشيفرة، وينبغي أن يَطبَع السطر الأخير المجموعَ الكلّيَّ لعدد مرات ظهور جميع الكلمات، ولكنه يعيد القيمة -1 في هذا المثال لأن التابع size غير مكتمل. أكمل متن هذا التابع، ثم نفِّذ الأمر ant TermCounter مرةً أخرى، حيث ينبغي أن تحصل على القيمة 4798. نفِّذ الأمر ant TermCounterTest لكي تتأكَّد من أنك قد أكملت جزء التمرين ذاك بشكلٍ صحيح. بالنسبة للجزء الثاني من التمرين، فسنُوفِّر تنفيذًا لكائنٍ من النوع Index، وسيكون عليك إكمال متن التابع غيرِ المكتمل. انظر إلى تعريف الصنف: public class Index { private Map<String, Set<TermCounter>> index = new HashMap<String, Set<TermCounter>>(); public void add(String term, TermCounter tc) { Set<TermCounter> set = get(term); // أنشئ مجموعةً جديدةً إذا كنت ترى الكلمة للمرة الأولى if (set == null) { set = new HashSet<TermCounter>(); index.put(term, set); } // إذا كنت قد رأيت الكلمة من قبل، عدِّل المجموعة الموجودة set.add(tc); } public Set<TermCounter> get(String term) { return index.get(term); } يُمثِل متغير النسخة index خريطةً map تربط كل كلمة بحثٍ بمجموعةِ كائناتٍ تنتمي إلى النوع TermCounter، ويُمثِّل كل كائنٍ منها صفحةً ظهرت فيها تلك الكلمة. يضيف التابع add كائنًا جديدًا من النوع TermCounter إلى المجموعة الخاصة بكلمةٍ معينة. وعندما نُفهرس كلمةً لأول مرة، سيكون علينا أن نُنشِئ لها مجموعةً جديدة، أما إذا كنا قد قابلنا الكلمة من قبل، فسنضيف فقط عنصرًا جديدًا إلى مجموعة تلك الكلمة، أي يُعدِّل التابع set.add عندما تكون المجموعة موجودةً بالفعل داخل index ولا يُعدِّل index ذاته، حيث إننا سنضطر إلى تعديل index فقط عند إضافة كلمةٍ جديدة. وأخيرًا، يستقبل التابع get كلمة بحثٍ، ويعيد مجموعة كائنات الصنف TermCounter المقابلة للكلمة. يُعَدّ هيكل البيانات هذا مُعقدًا بعض الشيء. ولاختصاره، يمكن القول أن كائن النوع Index يحتوي على خريطةٍ من النوع Map تربط كل كلمة بحثٍ بمجموعةٍ من النوع Set، المكوَّنةٍ من كائناتٍ تنتمي إلى النوع TermCounter، حيث يُمثِل كلّ كائنٍ منها خريطةً تربط كلماتِ البحث بعدد مرات ظهور تلك الكلمات. تَعرِض الصورة السابقة رسمًا توضيحيًّا لتلك الكائنات، حيث يحتوي كائن الصنف Index على متغير نسخة اسمه index يشير إلى كائن الصنف Map، الذي يحتوي -في هذا المثال- على سلسلةٍ نصيّةٍ واحدةٍ Java مرتبطةٍ بمجموعةٍ من النوع Set تحتوي على كائنين من النوع TermCounter؛ بحيث يكون واحدًا لكل صفحة قد ظهرت فيها كلمة Java. يتضمَّن كلّ كائنٍ من النوع TermCounter على متغيرَ النسخة label الذي يُمثِل مُحدّد الموارد الموحد URL الخاص بالصفحة، كما يتضمَّن المتغيرَ map الذي يحتوي على الكلمات الموجودة في الصفحة، وعدد مرات حدوث كلّ كلمةٍ منها. يُوضِّح التابع printIndex طريقة قراءة هيكل البيانات ذاك: public void printIndex() { // مرّ عبر كلمات البحث for (String term: keySet()) { System.out.println(term); // لكل كلمة، اطبع الصفحات التي ظهرت فيها الكلمة وعدد مرات ظهورها Set<TermCounter> tcs = get(term); for (TermCounter tc: tcs) { Integer count = tc.get(term); System.out.println(" " + tc.getLabel() + " " + count); } } } تمرّ حلقة التكرار الخارجية عبر كلمات البحث، بينما تمرّ حلقة التكرار الداخلية عبر كائنات الصنف TermCounter. نفِّذ الأمر ant build لكي تتأكَّد من تصريف ملفات الشيفرة، ثم نفِّذ الأمر ant Index. سيُحمِّل صفحتين من موقع ويكيبيديا ويُفهرسهما، ثم يَطبَع النتائج، ولكنك لن ترى أي خرجٍ عند تشغيله لأننا تركنا أحد التوابع فارغًا. دورك الآن هو إكمال التابع indexPage الذي يَستقبِل مُحدّد موارد موحّدًا URL (عبارة عن سلسلةٍ نصيةٍ)، وكائنًا من النوع Elements، ويُحدِّث الفهرس. تُوضِّح التعليقات ما ينبغي أن تفعله: public void indexPage(String url, Elements paragraphs) { // أنشئ كائنًا من النوع TermCounter وعدّ الكلمات بكل فقرة // لكل كلمة في كائن النوع TermCounter، أضفه إلى index } نفِّذ الأمر ant Index . وبعد الانتهاء، إذا كان كل شيء سليمًا، فستحصل على الخرج التالي: ... configurations http://en.wikipedia.org/wiki/Programming_language 1 http://en.wikipedia.org/wiki/Java_(programming_language) 1 claimed http://en.wikipedia.org/wiki/Java_(programming_language) 1 servletresponse http://en.wikipedia.org/wiki/Java_(programming_language) 2 occur http://en.wikipedia.org/wiki/Java_(programming_language) 2 ضع في الحسبان أنه عند إجرائك للبحث قد يختلف ترتيب ظهور كلمات البحث. وأخيرًا، نفِّذ الأمر ant TestIndex لكي تتأكَّد من اكتمال هذا الجزء من التمرين على النحو المطلوب. ترجمة -بتصرّف- للفصل Chapter 8: Indexer من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا المقال السابق: تنفيذ أسلوب البحث بالعمق أولا باستخدام الواجهتين Iterables وIterators تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة ازدواجية الترابط كيف تتعلم البرمجة ما هي البرمجة ومتطلبات تعلمها؟
-
سنبني في هذه المقالة زاحفَ إنترنت crawler يختبر صحة فرضيّة "الطريق إلى مقالة الفلسفة" في موقع ويكيبيديا التي شرحنا معناها في مقالة تنفيذ أسلوب "البحث بالعمق أولًا" تعاوديًا وتكراريًا. البداية ستجد في مستودع الكتاب ملفات الشيفرة التالية التي ستساعدك على بدء العمل: WikiNodeExample.java: يحتوي على شيفرة التنفيذ التعاودي recursive والتكراري iterative لتقنية البحث بالعمق أولًا depth-first search. WikiNodeIterable.java: يحتوي على صنف ممتدٍّ من النوع Iterable بإمكانه المرور عبر شجرة DOM. WikiFetcher.java: يحتوي على صنفٍ يُعرِّف أداةً تَستخدِم مكتبة jsoup لتحميل الصفحات من موقع ويكيبيديا. ويضع الصنف حدًّا لسرعة تحميل الصفحات امتثالًا لشروط الخدمة في موقع ويكيبيديا، فإذا طلبت أكثر من صفحة في الثانية الواحدة، فإنه ينتظر قليلًا قبل أن يُحمِّل الصفحة التالية. WikiPhilosophy.java: يحتوي على تصوّرٍ مبدئيٍّ عن الشيفرة التي ينبغي أن تكملها في هذا التمرين، وسنناقشها في الأسفل. ستجد أيضًا ملف البناء build.xml، حيث ستَعمَل الشيفرة المبدئية إذا نفَّذت الأمر ant WikiPhilosophy. الواجهتان Iterables وIterators تناولنا في مقالة أسلوب "البحث بالعمق أولًا" تنفيذًا تكراريًا له، وذكرنا وجه تفضيله على التنفيذ التعاودي من جهة سهولة تضمينه في كائنٍ من النوع Iterator. سنناقش في هذا المقال طريقة القيام بذلك. يُمكِنك القراءة عن الواجهتين Iterator وIterable إذا لم تكن على معرفة بهما. ألقِ نظرةً على محتويات الملف WikiNodeIterable.java. يُنفِّذ الصنف الخارجيُّ WikiNodeIterable الواجهة Iterable<Node>، ولذا يُمكِننا أن نَستخدِمه ضمن حلقة تكرار loop على النحو التالي: Node root = ... Iterable<Node> iter = new WikiNodeIterable(root); for (Node node: iter) { visit(node); } يشير root إلى جذر الشجرة التي ننوي اجتيازها، بينما يُمثِل visit التابع الذي نرغب في تطبيقه عند مرورنا بعقدةٍ ما. يَتبِع التنفيذ WikiNodeIterable المعادلة التقليدية: يَستقبِل الباني constructor مرجعًا إلى عقدة الجذر. يُنشِئ التابع iterator كائنًا من النوع Iterator ويعيده. انظر إلى شيفرة الصنف: public class WikiNodeIterable implements Iterable<Node> { private Node root; public WikiNodeIterable(Node root) { this.root = root; } @Override public Iterator<Node> iterator() { return new WikiNodeIterator(root); } } في المقابل، يُنجِز الصنف الداخلي WikiNodeIterator العمل الفعلي: private class WikiNodeIterator implements Iterator<Node> { Deque<Node> stack; public WikiNodeIterator(Node node) { stack = new ArrayDeque<Node>(); stack.push(root); } @Override public boolean hasNext() { return !stack.isEmpty(); } @Override public Node next() { if (stack.isEmpty()) { throw new NoSuchElementException(); } Node node = stack.pop(); List<Node> nodes = new ArrayList<Node>(node.childNodes()); Collections.reverse(nodes); for (Node child: nodes) { stack.push(child); } return node; } } تتطابق الشيفرة السابقة مع التنفيذ التكراري لأسلوب "البحث بالعمق أولًا" إلى حد كبير، ولكنّها مُقسَّمة الآن على ثلاثة توابع: يُهيئ الباني المكدس stack (المُنفَّذ باستخدام كائن من النوع ArrayDeque)، ويُضيف إليه عقدة الجذر. isEmpty: يفحص ما إذا كان المكدس فارغًا. next: يَسحَب العقدة التالية من المكدّس، ويضيف أبناءها بترتيبٍ معاكسٍ إلى المكدّس، ثم يعيد العقدة التي سحبها. وفي حال استدعاء التابع next في كائن Iterator فارغٍ، فإنه يُبلِّغ عن اعتراض exception. ربما تعتقد أن إعادة كتابة تابع جيد فعليًا باستخدام صنفين، وأن خمسة توابع تُعَد فكرةً غير جديرة بالاهتمام. ولكننا وقد فعلنا ذلك الآن، أصبح بإمكاننا أن نَستخدِم الصنف WikiNodeIterable في أي مكانٍ يُمكِننا فيه استخدام النوع Iterable. يُسهِّل ذلك من الفصل بين منطق التنفيذ التكراري (البحث بالعمق أولًا) وبين المعالجة التي نريد إجراءها على العقد. الصنف WikiFetcher يستطيع زاحف الويب أن يُحمِّل صفحاتٍ كثيرةً بسرعةٍ فائقةٍ، مما قد يؤدي إلى انتهاك شروط الخدمة للخادم الذي يُحمِّل منه تلك الصفحات. ولكي نتجنَّب ذلك، وفَّرنا الصنف WikiFetcher الذي يقوم بما يلي: يُغلِّف الشيفرة التي تناولناها بمقالة "البحث بالعمق أولًا"، أي تلك التي تُحمِّل الصفحات من موقع ويكيبيديا، وتُحلِّل HTML، وتختار المحتوى النصي. يقيس الزمن المُنقضِي بين طلبات الاتصال، فإذا لم يَكن كافيًا، فإنه ينتظر حتى تمرّ فترةٌ معقولة. وقد ضبطنا تلك الفترة لتكون ثانيةً واحدةً بشكلٍ افتراضيّ. انظر فيما يلي إلى تعريف الصنف WikiFetcher: public class WikiFetcher { private long lastRequestTime = -1; private long minInterval = 1000; /** * حمِّل صفحة محدد موارد موحد وحللها * أعد قائمة تحتوي على عناصر تُمثِل الفقرات * * @param url * @return * @throws IOException */ public Elements fetchWikipedia(String url) throws IOException { sleepIfNeeded(); Connection conn = Jsoup.connect(url); Document doc = conn.get(); Element content = doc.getElementById("mw-content-text"); Elements paragraphs = content.select("p"); return paragraphs; } private void sleepIfNeeded() { if (lastRequestTime != -1) { long currentTime = System.currentTimeMillis(); long nextRequestTime = lastRequestTime + minInterval; if (currentTime < nextRequestTime) { try { Thread.sleep(nextRequestTime - currentTime); } catch (InterruptedException e) { System.err.println( "Warning: sleep interrupted in fetchWikipedia."); } } } lastRequestTime = System.currentTimeMillis(); } } يُعدّ fetchWikipedia هو التابع الوحيد المُعرَّف باستخدام المُعدِّل public ضمن ذلك الصنف. يَستقبِل هذا التابع سلسلةً نصيّةً من النوع String وتُمثِل مُحدّد موارد موحّدًا URL، ويعيد تجميعةً من النوع التي Elements تحتوي على عنصر DOM لكل فقرةٍ ضمن المحتوى النصيّ. يُفترَض أن تكون تلك الشيفرة مألوفةً بالنسبة لك. تقع الشيفرة الجديدة ضمن التابع sleepIfNeeded الذي يفحص الزمنَ المنقضيَ منذ آخر طلبٍ، وينتظر إذا كان الزمن أقلّ من القيمة الدنيا minInterval والمقدّرة بوحدة الميلي ثانية. هذا هو كل ما يفعله الصنف WikiFetcher. وتُوضِّح الشيفرة التالية طريقة استخدامه: WikiFetcher wf = new WikiFetcher(); for (String url: urlList) { Elements paragraphs = wf.fetchWikipedia(url); processParagraphs(paragraphs); } افترضنا في هذا المثال أن urlList عبارة عن تجميعة تحتوي على سلاسلَ نصيّة من النوع String وأن التابع processParagraphs يُعالِج بطريقةٍ ما كائن الصنف Elements الذي أعاده التابع fetchWikipedia. يُوضِّح هذا المثال شيئًا مهمًا، حيث ينبغي أن تُنشِئ كائنًا واحدًا فقط من النوع WikiFetcher وأن تَستخدِمه لمعالجة جميع الطلبات؛ فلو كانت لديك عدة نسخ instances من الصنف WikiFetcher، فإنها لن تتمكَّن من فرض الزمن الأدنى اللازم بين كل طلب والطلب الذي يليه. تمرين 5 ستجد في الملف WikiPhilosophy.java تابع main بسيطًا يُوضِّح طريقة استخدام أجزاءٍ من تلك الشيفرة. وبدءًا منه، ستكون وظيفتك هي كتابةُ زاحفٍ يقوم بما يلي: يَستقبِل مُحدّد موارد موحّدًا URL لصفحةٍ من موقع ويكيبيديا، ويُحمِّلها ويُحلِّلها. يجتاز شجرة DOM الناتجة ويعثر على أول رابطٍ صالحٍ. وسنشرح المقصود بكلمة "صالح" في الأسفل. إذا لم تحتوِ الصفحة على أية روابطَ أو كنا قد زرنا أوّل رابطٍ من قبل، فعندئذٍ ينبغي أن ينتهي البرنامج مشيرًا إلى فشله. إذا كان مُحدّد الموارد الموحد يشير إلى مقالة ويكيبيديا عن الفلسفة، فينبغي أن ينتهي البرنامج مشيرًا إلى نجاحه. وفيما عدا ذلك، يعود إلى الخطوة رقم 1. ينبغي أن يُنشِئ البرنامج قائمةً من النوع List تحتوي على جميع مُحدّدات الموارد التي زارها، ويَعرِض النتائج عند انتهائه، سواءٌ أكانت النتيجة الفشل أم النجاح. والآن، ما الذي نعنيه برابط "صالح"؟ في الحقيقة لدينا بعض الخيارات، إذ تَستخدِم النسخ المختلفة من نظرية "الوصول إلى مقالة ويكيبيديا عن الفلسفة" قواعدَ مختلفةً نَستعرِض بعضًا منها هنا: ينبغي أن يكون الرابط ضمن المحتوى النصي للصفحة وليس في شريط التنقل الجانبي أو خارجَ الصندوق. لا ينبغي أن يكون الرابطُ مكتوبًا بخطٍّ مائلٍ أو بين أقواس. ينبغي أن تتجاهل الروابط الخارجيّة والروابط التي تشير إلى الصفحة الحالية والروابط الحمراء. ينبغي أن تتجاهل الرابط إذا كان بادئًا بحرفٍ كبير. ليس من الضروري أن تتقيد بكل تلك القواعد، ولكن يُمكِنك على الأقل معالجة الأقواس والخطوط المائلة والروابط التي تشير إلى الصفحة الحالية. إذا كنت تظن أن لديك المعلومات الكافية لتبدأ، فابدأ الآن، ولكن لا بأس قبل ذلك بقراءة التلميحات التالية: ستحتاج إلى معالجة نوعين من العقد بينما تجتاز الشجرة، هما الصنفان TextNode وElement. إذا قابلت كائنًا من النوع Element، فلربما قد تضطّر إلى تحويل نوعه typecast لكي تتمكَّن من استرجاع الوسم وغيره من المعلومات. عندما تقابل كائنًا من النوع Element يحتوي على رابط، فعندها يُمكِنك اختبار ما إذا كان مكتوبًا بخطٍ مائلٍ باتباع روابط عقد الأب أعلى الشجرة، فإذا وجدت بينها الوسم <i> أو الوسم <em>، فهذا يَعني أن الرابط مكتوبٌ بخطٍّ مائل. لكي تفحص ما إذا كان الرابط مكتوبًا بين أقواس، ستضطّر إلى فحص النص أثناء اجتياز الشجرة لكي تتعقب أقواس الفتح والغلق (سيكون مثاليًا لو استطاع الحل الخاص بك معالجة الأقواس المتداخلة (مثل تلك)). إذا بدأت من مقالة ويكيبيديا عن جافا، فينبغي أن تصل إلى مقالة الفلسفة بعد اتباع 7 روابط لو لم يحدث تغيير في صفحات ويكيبيديا منذ لحظة بدئنا بتشغيل الشيفرة. الآن وقد حصلت على كل المساعدة الممكنة، يُمكِنك أن تبدأ في العمل. ترجمة -بتصرّف- للفصل Chapter 7: Getting to Philosophy من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: استخدام خريطة ومجموعة لبناء مفهرس Indexer المقال السابق: تنفيذ أسلوب البحث بالعمق أولا باستخدام طريقتي التعاود والتكرار في جافا التعاود recursion والمكدس stack في جافاسكربت طريقة عمل الواجهات في لغة جافا
-
سنتناول في هذه المقالة مقدمةً سريعةً عن تطبيق محرك البحث الذي ننوي بناءه، حيث سنَصِف مكوِّناته ونشرح أُولاها، وهي عبارة عن زاحف ويب crawler يُحمِّل صفحات موقع ويكيبيديا ويُحلِّلها. سنتناول أيضًا تنفيذًا تعاوديًا recursive لأسلوب البحث بالعمق أولًا depth-first وكذلك تنفيذًا تكراريًا للمُكدِّس stack (الداخل آخرًا، يخرج أولًا LIFO) باستخدام Deque. محركات البحث تستقبل محركات البحث -مثل محرك جوجل وبينغ- مجموعةً من كلمات البحث، وتعيد قائمةً بصفحات الإنترنت المرتبطة بتلك الكلمات (سنناقش ما تعنيه كلمة مرتبطة لاحقًا). يُمكِنك قراءة المزيد عن محركات البحث، ولكننا سنشرح هنا كل ما قد تحتاج إليه. يتكوّن أي محرك بحث من عدة مكوناتٍ أساسيةٍ نستعرضها فيما يلي: الزحف crawling: برنامج بإمكانه تحميل صفحة إنترنت وتحليلها واستخراج النص وأي روابط إلى صفحات أخرى. الفهرسة indexing: هيكل بيانات data structure بإمكانه البحث عن كلمةٍ والعثور على الصفحات التي تحتوي على تلك الكلمة. الاسترجاع retrieval: طريقة لتجميع نتائج المُفهرِس واختيار الصفحات الأكثر صلةً بكلمات البحث. سنبدأ بالزاحف، والذي تتلخص مهمته في اكتشاف مجموعة من صفحات الويب وتحميلها، في حين تهدف محركات البحث مثل جوجل وبينغ إلى العثور على جميع صفحات الإنترنت، لكن المعتاد أيضًا أن يكون الزاحف مقتصرًا على نطاق أصغر. وفي حالتنا هذه، سنقتصر على صفحات موقع ويكيبيديا فقط. في البداية، سنبني زاحفًا يقرأ صفحةً من موقع ويكيبيديا، ويبحث عن أول رابطٍ ضمن الصفحة، وينتقل إلى الصفحة التي يشير إليها الرابط، ثم يكرر الأمر. سنَستخدِم ذلك الزاحف لاختبار صحة فرضيّة فرضيّة الطريق إلى الفلسفة، الموجودة في صفحات ويكيبيديا والتي تنصّ على ما يلي: سيسمح لنا اختبار تلك الفرضيّة ببناء القطع الأساسية للزاحف بدون الحاجة إلى الزحف عبر الإنترنت بأكمله أو حتى عبر كل صفحات موقع ويكيبيديا، كما أن هذا التمرين ممتعٌ نوعًا ما. أمّا المُفهرس والمُسترجِع فسنبني كلًّا منهما في مقال مستقلّ لاحقًا. تحليل مستند HTML عندما تُحمِّل صفحة إنترنت، فإن محتوياتها تكون مكتوبةً بلغة ترميز النص الفائق HyperText Markup Language، التي تُختصَر عادةً إلى HTML. على سبيل المثال، انظر إلى مستند HTML التالي: <!DOCTYPE html> <html> <head> <title>This is a title</title> </head> <body> <p>Hello world!</p> </body> </html> تُمثِّل العبارات "This is a title" و"Hello world!" النصَّ الفعليَّ المعروض في الصفحة، أما بقية العناصر فهي عبارة عن وسوم tags تشير إلى الكيفية التي ستُعرَض بها تلك النصوص. بعد أن يُحمِّل الزاحف صفحة إنترنت، يُحلِّل محتوياتها المكتوبة بلغة HTML ليتمكَّن من استخراج النص وإيجاد الروابط. سنَستخدِم مكتبة jsoup مفتوحة المصدر من لغة جافا لإجراء ذلك، حيث تستطيع تلك المكتبة تحميل صفحات HTML وتحليلها. ينتج عن تحليل مستندات HTML شجرة نموذج كائن المستند Document Object Model التي تُختصرُ إلى DOM، حيث تحتوي تلك الشجرة على ما يتضمنه المستند من عناصر بما في ذلك النصوص والوسوم، تمثِّل هيكل بياناتٍ مترابطًا linked يتألف من عقد nodes تُمثِّلُ كلًا من النصوص والوسوم والعناصر الأخرى. تُحدِّد بنية المستند العلاقات بين العقد. يُعدّ الوسم <html> -في المثال المُوضَّح في الأعلى مثلًا، العقدة الأولى التي يُطلَق عليها اسم الجذر root، وتحتوي تلك العقدة على روابط تشير إلى العقد التي تتضمنها وفي حالتنا هما العقدتان <head> و<body>، وتُعدّ كلٌّ منهما ابنًا للعقدة الجذر. تملك العقدة <head> ابنًا واحدًا هو العقدة <title>، وبالمثل، تملك العقدة <body> ابنًا واحدًا هو العقدة <p> (اختصارًا لكلمة paragraph). تُوضِّح الصورة التالية تلك الشجرة بيانيًا. تحتوي كلّ عقدة على روابط إلى عقد الأبناء، كما تحتوي على رابط إلى عقدة الأب الخاصة بها، وبالتالي يُمكِننا أن نبدأ من أي عقدة في الشجرة، ثم نتنقّل إلى أعلاها أو أسفلها. عادةً ما تكون أشجار DOM المُمثِلة للصفحات الحقيقية أعقدَ بكثيرٍ من هذا المثال. تُوفِّر غالبية متصفحات الإنترنت أدوات للتحقّق من نموذج DOM الخاص بالصفحة المعروضة. ففي متصفح كروم مثلًا، يُمكِنك النقر بزر الفأرة الأيمن على أي مكان من الصفحة، واختيار "Inspect" من القائمة؛ أما في متصفح فايرفوكس، فيُمكِنك أيضًا النقر بزر الفأرة الأيمن على أي مكان، واختيار "Inspect Element" من القائمة. يُمكِنك القراءة عن أداة Web Inspector التي يُوفِّرها متصفح سفاري أو قراءة التعليمات الخاصة بمتصفح إنترنت إكسبلورر. تعرض الصورة السابقة لقطة شاشة لنموذج DOM الخاص بـمقالة ويكيبيديا عن لغة جافا، حيث يُمثِّلُ العنصر المظلل أول فقرة في النص الرئيسي من المقالة. لاحِظ أن الفقرة تقع داخل عنصر <div> الذي يملك السمة id="mw-content-text"، والتي سنَستخدِمها للعثور على النص الرئيسي في أي مقالةٍ نُحمِّلها. استخدام مكتبة jsoup تُسهِّل مكتبة jsoup من تحميل صفحات الإنترنت وتحليلها، وكذلك التنقُل عبر شجرة DOM. انظر إلى المثال التالي: String url = "http://en.wikipedia.org/wiki/Java_(programming_language)"; // حمِّل المستند وحلّله Connection conn = Jsoup.connect(url); Document doc = conn.get(); // اختر المحتوى النصي واسترجع الفقرات Element content = doc.getElementById("mw-content-text"); Elements paragraphs = content.select("p"); يَستقبِل التابع Jsoup.connect مُحدِّد موارد موحدًا URL من النوع String، ويُنشِئ اتصالًا مع خادم الويب. بعد ذلك يُحمِّل التابع get مستند HTML ويُحلِّله، ويعيد كائنًا من النوع Document يُمثِل شجرة DOM. يُوفِّر الصنف Document توابعًا للتنقل عبر الشجرة واختيار العقد. في الواقع، إنه يُوفِّر توابع كثيرةً جدًا لدرجة تُصيبَك بالحيرة. وسيَعرِض المثال التالي طريقتين لاختيار العقد: getElementById: يستقبِل سلسلةً نصيةً من النوع String، ويبحث ضمن الشجرة عن عنصرٍ يملك نفس قيمة حقل id المُمرَّرة. يختار التابع في هذا المثال العقدة <div id="mw-content-text" lang="en" dir="ltr" class="mw-content-ltr"> التي تَظهَر في أيّ مقالةٍ من موقع ويكيبيديا لكي تُميّز عنصر <div> المُتضمِّن للنص الرئيسي للصفحة، عن شريط التنقل الجانبي والعناصر الأخرى. يعيد التابع getElementById كائنًا من النوع Element يُمثِل عنصر <div> ذاك، ويحتوي على العناصر الموجودة داخله بهيئة أبناءٍ وأحفادٍ وغيرها. select: يستقبل سلسلةً نصيّةً من النوع String، ويتنقّل عبر الشجرة، ثم يُعيد جميع العناصر التي يتوافق الوسم tag الخاص بها مع تلك السلسلة النصية. يعيد التابع في هذا المثال جميع وسوم الفقرات الموجودة في الكائن content. تكون القيمة المعادة عبارة عن كائن من النوع Elements. قبل أن تُكمِل القراءة، يُفضَّل أن تلقي نظرةً على توثيق كلٍّ من الأصناف المذكورة لكي تتعرف على إمكانيات كلٍّ منها. تجدر الإشارة إلى أنّ الأصناف Element و Elements و Node هي الأصناف الأهمّ. يُمثِل الصنف Node عقدةً في شجرة DOM. وتمتد منه أصنافٌ فرعيةٌ subclasses كثيرةٌ مثل Element وTextNode وDataNode وComment. يُعدّ الصنف Elements تجميعةً من النوع Collection التي تحتوي على كائناتٍ من النوع Element. تحتوي الصورة السابقة على مخطط UML يُوضّح العلاقة بين تلك الأصناف. يشير الخط ذو الرأس الأجوف إلى أن هناك صنفًا يمتد من صنفٍ آخر، إذ يمتد الصنف Elements مثلًا، من الصنف ArrayList. وسنعود لاحقًا للحديث عن مخططات UML. اجتياز شجرة DOM يَسمَح لك الصنف WikiNodeIterable -الذي كتبه المؤلف- بالمرور عبر عقد شجرة DOM. انظر إلى المثال التالي الذي يبين طريقة استخدامه: Elements paragraphs = content.select("p"); Element firstPara = paragraphs.get(0); Iterable<Node> iter = new WikiNodeIterable(firstPara); for (Node node: iter) { if (node instanceof TextNode) { System.out.print(node); } } يُكمِل هذا المثال ما وصلنا إليه في المثال السابق، فهو يختار الفقرة الأولى في الكائن paragraphs أولًا، ثم يُنشِئ كائنًا من النوع WikiNodeIterable ليُنفِّذ الواجهة Iterable<Node>. يُجرِي WikiNodeIterable بحثًا بتقنية العمق أولًا depth-first، ويُولِّد العقد بنفس ترتيب ظهورها بالصفحة. تَطبَع الشيفرةُ العقدَ إذا كانت من النوع TextNode وتتجاهلها إذا كانت من أي نوع آخر، والتي تُمثِل وسومًا من الصنف Element في هذا المثال. ينتج عن ذلك طباعة نص الفقرة بدون أيّ ترميزات. وقد كان الخرج في هذا المثال كما يلي: البحث بالعمق أولا Depth-first search تتوفَّر العديد من الطرائق لاجتياز الأشجار، ويتلاءم كلٌّ منها مع أنواعٍ مختلفةٍ من التطبيقات. سنبدأ بطريقة البحث بالعمق أولًا DFS. تبدأ تلك الطريقة من جذر الشجرة، ثم تختار الابن الأول للجذر. إذا كان لديه أبناء، فإنها ستختار الابن الأول، وتستمر في ذلك حتى تصل إلى عقدةٍ ليس لها أبناء، أين تبدأ بالتراجع عندها والتحرك لأعلى إلى عقدة الأب، لتختار منها الابن التالي إن كان موجودًا، وفي حالة عدم وجوده، فإنها تتراجع للوراء مجددًا. عندما تنتهي من البحث في الابن الأخير لعقدة الجذر، فإنها تكون قد انتهت. هناك طريقتان شائعتان لتنفيذ DFS، وذلك إما بالتعاود recursion، أو بالتكرار. يُعدّ التنفيذ بالتعاود هو الطريقة الأبسط: private static void recursiveDFS(Node node) { if (node instanceof TextNode) { System.out.print(node); } for (Node child: node.childNodes()) { recursiveDFS(child); } } يُستدعَى التابع السابق من أجل كل عقدةٍ ضمن الشجرة بدايةً من الجذر. إذا كانت العقدة المُمرَّرة من النوع TextNode، ويطبع التابع محتوياتِها، ثم يفحص إذا كان للعقدة أي أبناء. فإذا كان لها أبناء، فإنه سيَستدعِي recursiveDFS -أي ذاته- لجميع عقد الأبناء على التوالي. في هذا المثال، طَبَعَنا محتويات العقد التي تنتمي إلى النوع TextNode قبل أن ننتقل إلى الأبناء، وهو ما يُعدّ مثالًا على الاجتياز ذي الترتيب السابق. يُمكِنك القراءة عن الاجتيازات ذات الترتيب السابق pre-order والترتيب اللاحق post-order وفي الترتيب in-order. لا يُشكّل ترتيب الاجتياز في تطبيقنا هذا أي فارق. نظرًا لأن التابع recursiveDFS يَستدعِي ذاته تعاوديًا، فقد كان بإمكانه استخدام مُكدِّس الاستدعاءات للاحتفاظ بالعقد الأبناء، ومعالجتها بالترتيب المناسب، لكننا بدلًا من ذلك يُمكِننا أن نَستخدِم مُكدِّسًا صريحًا للاحتفاظ بالعقد، وفي تلك الحالة لن نحتاج إلى التعاود، حيث سنتمكَّن من اجتياز الشجرة عبر حلقة تكراريّة. المكدسات Stacks في جافا قبل أن نشرح التنفيذ التكراري لتقنية DFS، سنناقش أولًا هيكل بياناتٍ يُعرَف باسم المُكدّس. سنبدأ بالفكرة العامة للمُكدِّس، ثم سنتحدث عن واجهتين interfaces بلغة جافا تُعرِّفان توابع المُكدِّس، وهما Stack وDeque. يُعدّ المُكدِّس هيكل بياناتٍ مشابهًا للقائمة، فهو عبارة عن تجميعة تتذكر ترتيب العناصر. ويتمثل الفرق بين المكدّس والقائمة في أن المُكدِّس يوفِّر توابعَ أقل، وأنه عادةً ما يُوفِّر المُكدِّس التوابع التالية: push: يضيف عنصرًا إلى أعلى المُكدِّس. pop: يحذِف العنصر الموجود أعلى المُكدِّس ويعيده. peek: يعيد العنصر الموجود أعلى المُكدِّس دون حذفه. isEmpty: يشير إلى ما إذا كان المُكدِّس فارغًا. نظرًا لأن التابع pop يسترجع العنصر الموجود في أعلى المكدّسِ دائمًا، يُشار إلى المكدّساتِ باستخدام كلمة LIFO، والتي تُعدّ اختصارًا لعبارة "الداخل آخرًا، يخرج أولًا". في المقابل، تُعدّ الأرتال queue بديلًا للمكدّساتِ، ولكنها تُعيد العناصر بنفس ترتيب إضافتها، ولذلك، يُشار إليها عادةً باستخدام كلمة FIFO أي "الداخل أولًا، يخرج أولًا". قد لا تكون أهمية المُكدِّسات والأرتال واضحةً بالنسبة لك، فهما لا يوفران أي إمكانياتٍ إضافيةً عن تلك الموجودة في القوائم lists. بل يوفران إمكانيات أقل، لذلك قد تتساءل لم لا نكتفي باستخدام القوائم؟ والإجابة هي أن هناك سببان: إذا ألزمت نفسك بعدد أقل من التوابع، أي بواجهة تطوير تطبيقات API أصغر، فعادةً ما تصبح الشيفرة مقروءةً أكثر، كما تقل احتمالية احتوائها على أخطاء. على سبيل المثال، إذا استخدمت قائمةً لتمثيل مُكدِّس، فقد تَحذِف -عن طريق الخطأ- عنصرًا بترتيب خاطئ. في المقابل، إذا استخدمت واجهة تطوير التطبيقات المُخصَّصة للمُكدِّس، فسيستحيل أن تقع في مثل هذا الخطأ، ولهذا فالطريقة الأفضل لتجنُّب الأخطاء هي بأن تجعلها مستحيلة. إذا كانت واجهة تطوير التطبيقات التي يُوفِّرها هيكل البيانات صغيرةً، فسيكون تنفيذها بكفاءةٍ أسهل. على سبيل المثال، يُمكِننا أن نَستخدِم قائمةً مترابطةً linked list أحادية الترابط لتنفيذ المُكدِّس بسهولة، وعندما نضع عنصرًا في المُكدِّس، فعلينا أن نضيفه إلى بداية القائمة؛ أما عندما نسحب عنصرًا منها، فعلينا أن نَحذفه من بدايتها. ونظرًا لأن عمليتي إضافة العناصر وحذفها من بداية القوائم المترابطة تستغرق زمنًا ثابتًا، فإننا نكون قد حصلنا على تنفيذٍ ذي كفاءةٍ عالية. في المقابل، يَصعُب تنفيذ واجهات التطوير الكبيرة بكفاءة. لديك ثلاثة خيارات لتنفيذ مُكدِّسٍ بلغة جافا: اِستخدِم الصنف ArrayList أو الصنف LinkedList. إذا اِستخدَمت الصنف ArrayList، تأكَّد من إجراء عمليتي الإضافة والحذف من نهاية القائمة لأنهما بذلك سيستغرِقان زمنًا ثابتًا، وانتبه من إضافة العناصر في مكانٍ خاطئٍ أو تحذفها بترتيبٍ خاطئ. تُوفِّر جافا الصنف Stack الذي يحتوي على التوابع الأساسية للمُكدِّسات، ولكنه يُعدّ جزءًا قديمًا من لغة جافا، فهو غير متوافق مع إطار عمل جافا للتجميعات Java Collections Framework الذي أُضيفَ لاحقًا. ربما الخيار الأفضل هو استخدام إحدى تنفيذات الواجهة Deque مثل الصنف ArrayDeque. إن كلمة Deque هي اختصار للتسمية رتل ذو نهايتين double-ended queue، والتي يُفترَض أن تُلفَظ deck، ولكنها تُلفَظ أحيانًا deek. تُوفِّر واجهة Deque بلغة جافا التوابع push وpop وpeek وisEmpty، لذلك يُمكِنك أن تَستخدِم كائنًا من النوع Deque كمُكدِّس، كما أنها تُوفِّر توابع أخرى ولكننا لن نَستخدِمها حاليًا. التنفيذ التكراري لتقنية البحث بالعمق أولا انظر إلى التنفيذ التكراري لأسلوب "البحث بالعمق أولًا". يَستخدِم ذلك التنفيذ كائنًا من النوع ArrayDeque ليُمثِل مُكدِّسًا يحتوي على كائنات تنتمي إلى النوع Node: private static void iterativeDFS(Node root) { Deque<Node> stack = new ArrayDeque<Node>(); stack.push(root); while (!stack.isEmpty()) { Node node = stack.pop(); if (node instanceof TextNode) { System.out.print(node); } List<Node> nodes = new ArrayList<Node>(node.childNodes()); Collections.reverse(nodes); for (Node child: nodes) { stack.push(child); } } } يُمثِل المعامل root جذر الشجرة التي نريد أن نجتازها، حيث سنُنشِئ المُكدِّس ونضيف الجذر إليه. تستمر الحلقة loop بالعمل إلى أن يُصبِح المُكدِّس فارغًا. يَسحَب كل تكرار ضمن الحلقة عقدةً من المُكدِّس، فإذا كانت العقدة من النوع TextNode، فإنه يَطبَعُ محتوياتِها ثم يضيف أبناءها إلى المُكدِّس. ينبغي أن نضيف الأبناء إلى المُكدِّس بترتيبٍ معاكسٍ لكي نتمكَّن من معالجتها بالترتيب الصحيح، ولذلك سنَنَسخ الأبناء أولًا إلى قائمة من النوع ArrayList، ثم نعكس ترتيب العناصر فيها، وفي النهاية سنمرّ عبر القائمة المعكوسة. من السهل كتابة التنفيذ التكراري لتقنيةِ البحث بالعمق أولًا باستخدام كائن من النوع Iterator، وسترى ذلك في المقالة التالية. في ملاحظة أخيرة عن الواجهة Deque، بالإضافة إلى الصنف ArrayDeque، تُوفِّر جافا تنفيذًا آخرًا لتلك الواجهة، هو الصنف LinkedList الذي يُنفِّذ الواجهتين List وDeque، وتعتمد الواجهة التي تحصل عليها على الطريقة التي تَستخدِمه بها. على سبيل المثال، إذا أسندت كائنًا من النوع LinkedList إلى متغيرٍ من النوع Deque كالتالي: Deqeue<Node> deque = new LinkedList<Node>(); فسيكون في إمكانك استخدام التوابع المُعرَّفة بالواجهة Deque، لا توابع الواجهةِ List. وفي المقابل، إذا أسندته إلى متغيرٍ من النوع List، كالتالي: List<Node> deque = new LinkedList<Node>(); فسيكون في إمكانك استخدام التوابع المُعرَّفة بالواجهة List لا توابع الواجهة Deque؛ أما إذا أسندته على النحو التالي: LinkedList<Node> deque = new LinkedList<Node>(); فسيكون بإمكانك استخدام جميع التوابع، ولكن الذي يحدث عند دمج توابع من واجهاتٍ مختلفةٍ، هو أن الشيفرة ستصبح أصعب قراءةً وأكثر عرضةً لاحتواء الأخطاء. ترجمة -بتصرّف- للفصل Chapter 6: Tree traversal من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تنفيذ أسلوب البحث بالعمق أولا باستخدام الواجهتين Iterables وIterators المقال السابق: تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة ازدواجية الترابط التعاود recursion والمكدس stack في جافاسكربت طريقة عمل الواجهات في لغة جافا
-
تضرب هذه المقالة عصفورين بحجرٍ واحدٍ، حيث سنحل فيها تمرين المقالة السابقة مدخل إلى تحليل الخوارزميات، وسنتطرق لوسيلة نصنّف من خلالها الخوارزميات باستخدام ما يسمّى التحليل بالتسديد amortized analysis. تصنيف توابع الصنف MyArrayList يُمكِننا تحديد ترتيب نمو order of growth غالبية التوابع بالنظر إلى شيفرتها. على سبيل المثال، انظر إلى تنفيذ التابع get المُعرَّف بالصنف MyArrayList: public E get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException(); } return array[index]; } تستغرِق كل تعليمةٍ من تعليمات التابع get زمنًا ثابتًا، وبناءً على ذلك يستغرِق التابع get في المجمل زمنًا ثابتًا. الآن وقد صنّفنا التابع get، يمكننا بنفس الطريقة أن نصنّف التابع set الذي يَستخدِمه. انظر إلى تنفيذ التابع set من التمرين السابق الذي مرّ بنا في الفصل الثاني: public E set(int index, E element) { E old = get(index); array[index] = element; return old; } ربما لاحظت أن التابع set لا يفحص نطاق المصفوفة صراحةً، فهو يعتمد في ذلك على استدعائه للتابع get الذي يُبلِّغ عن اعتراض exception عندما لا يكون الفهرس صالحًا. تَستغرق كل تعليمة من تعليمات التابع set -بما في ذلك استدعاؤه للتابع get- زمنًا ثابتًا، وعليه يُعدّ التابع set ثابت الزمن أيضًا. ولننتقل الآن إلى بعض التوابع الخطيّة. انظر مثلاً إلى تنفيذنا للتابع indexOf: public int indexOf(Object target) { for (int i = 0; i<size; i++) { if (equals(target, array[i])) { return i; } } return -1; } يحتوي التابع indexOf على حلقة تكرارية كما نرى، وفي كل مرورٍ تكراريٍّ في تلك الحلقة يستدعي التابعَequals. علينا إذًا أن نُصنّف التابع equals أولًا لنتمكن من تصنيف التابعindexOf. لننظر إلى تعريف ذلك التابع: private boolean equals(Object target, Object element) { if (target == null) { return element == null; } else { return target.equals(element); } } يستدعي التابعُ السابق التابعَ target.equals الذي يعتمد زمن تنفيذه على حجم المتغير target وelement، ولكنه لا يعتمد على حجم المصفوفة، ولذلك سنَعُدّه ثابت الزمن لكي نُكمِل تحليل التابع indexOf. لنعُد الآنَ إلى التابع indexOf. تَستغرق كل تعليمة ضمن الحلقة زمنًا ثابتًا، مما يقودنا إلى السؤال التالي: كم عدد مرات تنفيذ الحلقة؟ إذا حالفنا الحظ، قد نجد الكائن المطلوب مباشرةً ونعود بعد اختبار عنصر واحد فقط؛ أما إذا لم نكن محظوظين، فقد نضطرّ لاختبار جميع العناصر. لنقلْ إننا سنحتاج وسطيًّا إلى اختبار نصف عدد العناصر، ومن ثم يمكن القول بأن هذا التابع يصنّف بأنه تابع خطّيٌ أيضًا باستثناء الحالة الأقل احتمالًا، والتي يكون فيها العنصر المطلوب هو أول عنصر في المصفوفة. وهكذا يتشابه تحليل التابع remove مع التابع السابق. وفيما يلي تنفيذه: public E remove(int index) { E element = get(index); for (int i=index; i<size-1; i++) { array[i] = array[i+1]; } size--; return element; } يَستدعِي التابعُ السابق التابعَ get ذا الزمن الثابت، ثم يُمرّ عبر عناصر المصفوفة بدايةً من الفهرس index. وإذا حذفنا العنصر الموجود في نهاية القائمة، فلن يُنفِّذ التابع حلقة التكرار على الإطلاق، وسيستغرِق التابع عندئذٍ زمنًا ثابتًا. في المقابل، إذا حذفنا العنصر الأول فسيمرّ التابع عبر جميع العناصر المتبقية، وبالتالي سيستغرِق التابع زمنًا خطيًا. لذلك يُمكِننا أن نَعُدّ التابع خطيًا في المجمل، باستثناء الحالة الخاصة التي يكون خلالها العنصر المطلوب حذفه واقعًا في نهاية المصفوفة أو على بعد مسافةٍ ثابتةٍ من نهايتها. تصنيف التابع add تستقبل النسخة التالية من التابع add فهرسًا وعنصرًا كمعاملات parameters: public void add(int index, E element) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException(); } // أضف عنصرًا للتأكّد من ضبط حجم المصفوفة add(element); // حرك العناصر الأخرى for (int i=size-1; i>index; i--) { array[i] = array[i-1]; } // ضع العنصر الجديد في المكان الصحيح array[index] = element; } تستدعي النسخةُ ذات المعاملين add(int, E) النسخةَ ذات المعامل الواحد add(E) أولًا لكي تضع العنصر الجديد في نهاية المصفوفة، وبعد ذلك تُحرِّك العناصر الأخرى إلى اليمين، وتضع العنصر الجديد في المكان الصحيح. سنُحلّل أولًا زمن النسخة ذات المعامل الواحد add(E) قبل أن ننتقل إلى تحليل النسخة ذات المعاملين add(int, E): public boolean add(E element) { if (size >= array.length) { // أنشئ مصفوفة أكبر وانسخ العناصر إليها E[] bigger = (E[]) new Object[array.length * 2]; System.arraycopy(array, 0, bigger, 0, array.length); array = bigger; } array[size] = element; size++; return true; } تتضح لنا هنا صعوبة تحليل زمن النسخة ذات المعامل الواحد؛ لأنه لو كانت هناك مساحة غير مُستخدَمةٍ في المصفوفة، فسيستغرِق التابع زمنًا ثابتًا؛ أما لو اضطرّرنا لإعادة ضبط حجم المصفوفة، فسيستغرِق التابع زمنًا خطيًا؛ لأن التابع System.arraycopy يستغرِق بدوره زمنًا يتناسب مع حجم المصفوفة. إذاً فهل هذا التابع ثابت أم خطي؟ يُمكِننا أن نُصنِّفه بالتفكير في متوسط عدد العمليات التي تتطلَّبها عملية الإضافة خلال عدد من الإضافات مقداره n. وسنفترض للتبسيط بأن لدينا مصفوفةً بإمكانها تخزين عنصرين فقط. في المرة الأولى التي سنستدعي خلالها add، سيجد التابع مساحةً شاغرةً في المصفوفة، وسيُخزِّن عنصرًا واحدًا. في المرة الثانية، سيجد التابع مساحةً شاغرةً في المصفوفة، وسيُخزِّن عنصرًا واحدًا. في المرة الثالثة، سيعيد التابع ضبط حجم المصفوفة، وينسخ العنصرين السابقين، ثم يخزن العنصر الجديد وهو الثالث، وسيُصبِح بإمكان المصفوفة تخزين 4 عناصر في المجمل. ستخُزِّن المرة الرابعة عنصرًا واحدًا. ستعيد المرة الخامسة ضبط حجم المصفوفة، وتنسخ أربعة العناصر السابقة، وتخزّن عنصرًا جديدًا وهو الخامس، وسيكون في إمكان المصفوفة تخزين ثمانية عناصر إجمالًا. ستُخزِّن المرات الثلاث التالية ثلاثة عناصر. ستنسخ المرة التالية ثمانية العناصر السابقة وتُخزِّن عنصرًا جديدًا وهو التاسع، وسيُصبِح بإمكان المصفوفة تخزين ستة عشر عنصرًا. ستُخزِّن سبعُ المرات التالية سبعةَ عناصر. وهكذا دواليك. وإذا أردنا أن نلخص ما سبق: فإننا بعد 4 إضافات، سنكون قد خزَّنا 4 عناصر ونسخنا عنصرين. بعد 8 إضافات، سنكون قد خزَّنا 8 عناصر ونسخنا 6 عناصر. بعد 16 إضافةً، سنكون قد خزَّنا 16 عنصرًا ونسخنا 14 عنصرًا. يُفترَض أن تكون قد استقرأت سير العملية وحصلت على ما يلي: لكي نُضيف عدد مقداره n من العناصر، سنضطرّ إلى تخزين عدد n من العناصر ونسخ عدد n-2 من العناصر، وبالتالي يكون عدد العمليات الإجمالي هو n+n-2 أي 2n-2. لكي نحسب متوسط عدد العمليات المطلوبة لعملية الإضافة، ينبغي أن نقسِّم العدد الكلي للعمليات على عدد الإضافات n، وبذلك ستكون النتيجة هي 2-2/n. لاحِظ أنه كلما ازدادت قيمة n، ستقل قيمة الجزء الثاني 2/n. ونظراً لأن ما يهمنا هنا هو الأسّ الأكبر للأساس n، فيُمكِننا أن نَعُدّ التابع add ثابت الزمن. قد يبدو من الغريب لخوارزمية تحتاج إلى زمن خطي أحيانًا أن تكون ثابتة الزمن في المتوسط. والفكرة هي أننا نضاعف طول المصفوفة في كل مرة نضطرّ فيها إلى إعادة ضبط حجمها. يُقلِّل ذلك عدد المرات التي نَنَسَخ خلالها جميع العناصر، أما لو كنا نضيف مقدارًا ثابتًا إلى طول المصفوفة بدلًا من مضاعفتها بمقدارٍ ثابت، فإنّ هذا التحليل لا يصلح. يُطلَق على تصنيف الخوارزميات وفقًا لتلك الطريقة -أي بحساب متوسط الزمن الذي تستغرقه متتالية من الاستدعاءات- باسم التحليل بالتسديد. الآن وقد عرفنا أنّ التابع add(E) ثابت الزمن، ماذا عن التابع add(int, E)؟ يُنفِّذ التابع add(int, E) -بعد استدعائه للتابع add(E)- حلقةً تمُرّ عبر جزءٍ من عناصر المصفوفة وتُحرِّكها. تستغرِق تلك الحلقة زمنًا خطيًا باستثناء الحالة التي نضيف خلالها عنصرًا إلى نهاية المصفوفة، وعليه يكون التابع add(int, E) بدوره خطيًا. حجم المشكلة ولننتقل الآن إلى المثال الأخير في هذا المقال. انظر فيما يلي إلى تنفيذ التابع removeAll ضمن الصنف MyArrayList: public boolean removeAll(Collection<?> collection) { boolean flag = true; for (Object obj: collection) { flag &= remove(obj); } return flag; } يستدعِي التابع removeAll في كلّ تكرارٍ ضمن الحلقة التابعَ remove الذي يستغرِق زمنًا خطّيًّا. قد يدفعك ذلك إلى الظن بأنّ التابعَ removeAll تربيعي، ولكن ليس بالضرورة أن يكون كذلك. يُنفِّذ التابع removeAll الحلقة مرةً واحدةً لكل عنصر في المتغير collection. فإذا كان المتغير يحتوي على عدد m من العناصر، وكانت القائمة التي نحذِف منها العنصر مكوَّنةً من عدد n من العناصر، فإن هذا التابع ينتمي إلى المجموعة O(nm). لو افترضنا أن حجم collection ثابت، فسيكون التابع removeAll خطيًا بالنسبة لـ n، ولكن إذا كان حجم collection متناسبًا مع n، فسيكون التابع removeAll تربيعيًا. على سبيل المثال، إذا كان collection يحتوي دائمًا على 100 عنصر أو أقل، فإن التابع removeAll يستغرِق زمنًا خطيًا؛ أما إذا كان collection يحتوي في العموم على 1% من عناصر القائمة، فإن التابع removeAll يستغرِق زمنًا تربيعيًا. عند الحديث عن حجم المشكلة، ينبغي أن ننتبه إلى ماهية الحجم أو الأحجام التي نحن بصددها. يبين هذا المثال إحدى مشاكل تحليل الخوارزميات، وهي الاختصار المغري الناجم عن عدّ الحلقات، ففي حالة وجود حلقة واحدة، غالبًا ما تكون الخوارزمية خطية، وفي حالة وجود حلقتين متداخلتين، فغالبًا ما تكون الخوارزمية تربيعية، ولكن انتبه وفكر أولًا في عدد مرات تنفيذ كل حلقة، فإذا كان عددها يتناسب مع n لجميع الحلقات، فيمكنك الاكتفاء بعدّ الحلقات؛ أما إذا لم يكن عددها متناسبًا دائمًا مع n -كما هو الحال في هذا المثال- فعليك أن تتريث وتمنح الأمر مزيدًا من التفكير. هياكل البيانات المترابطة linked data structures سنقدم في التمرين التالي تنفيذًا جزئيًا للواجهة List. يَستخدِم هذا التنفيذ قائمةً متصلةً linked list لتخزين العناصر. يُعدّ هيكل البيانات مترابطًا إذا كان مُؤلفًا من كائنات يُطلَق عليها عادةً اسم عُقد nodes، حيث تحتوي العقد على مراجع references تشير إلى عقد أخرى. وفي القوائم المترابطة، تحتوي كل عقدة على مرجع إلى العقدة التالية في القائمة. قد تحتوي العقد في أنواعٍ أخرى من هياكل البيانات المترابطة على مراجع تشير إلى عدة عقد، مثل الأشجار trees والشُعب graphs. تعرض الشيفرة التالية تنفيذًا بسيطًا لصنف عقدة: public class ListNode { public Object data; public ListNode next; public ListNode() { this.data = null; this.next = null; } public ListNode(Object data) { this.data = data; this.next = null; } public ListNode(Object data, ListNode next) { this.data = data; this.next = next; } public String toString() { return "ListNode(" + data.toString() + ")"; } } يتضمَّن الصنف ListNode متغيري نسخة هما: data وnext. يحتوي data على مرجعٍ يشير إلى كائن ما من النوع Object، بينما يحتوي next على مرجع يشير إلى العقدة التالية في القائمة. ويحتوي next في العقدة الأخيرة من القائمة على القيمة الفارغة null كما هو متعارف عليه. يُعرِّف الصنف ListNode مجموعةً من البواني constructors التي تُمكِّنك من تمرير قيمٍ مبدئيةٍ للمتغيرين data وnext، أو تمكّنك من مجرد تهيئتهما إلى القيمة الافتراضية null. ويُمكِنك أن تُفكِر في عقدةٍ واحدةٍ من النوع ListNode كما لو أنها قائمةٌ مُكوَّنةٌ من عنصرٍ واحدٍ، ولكن على العموم، يُمكِن لأي قائمة أن تحتوي على أي عدد من العقد. هناك الكثير من الطرق المستخدمة لإنشاء قائمة جديدة، وتتكوَّن إحداها من إنشاء مجموعة من كائنات الصنف ListNode على النحو التالي: ListNode node1 = new ListNode(1); ListNode node2 = new ListNode(2); ListNode node3 = new ListNode(3); ثم ربطها ببعض: node1.next = node2; node2.next = node3; node3.next = null; وهناك طريقة أخرى هي أن تُنشِئ عقدةً وتربطها في نفس الوقت. على سبيل المثال، إذا أردت أن تضيف عقدةً جديدةً إلى بداية قائمة، يُمكِنك كتابة ما يلي: ListNode node0 = new ListNode(0، node1); والآن، بعد تنفيذ سلسلة التعليمات السابقة، أصبح لدينا أربعةُ عقدٍ تحتوي على الأعداد الصحيحة 0 و1 و2 و3 مثل بيانات، ومربوطةٌ معًا بترتيب تصاعدي. لاحِظ أن قيمة next في العقدة الأخيرة تحتوي على القيمة الفارغة null. الرسمة التوضيحية السابقة هي رسم بيانيٌّ لكائنٍ يُوضِّح المتغيرات والكائنات التي تشير إليها تلك المتغيرات. تَظهَر المتغيرات بهيئة أسماءٍ داخل صناديقَ مع أسهمٍ تُوضِّح ما تشير إليه المتغيرات، بينما تَظهَر الكائنات بهيئة صناديق تَجِد خارجها النوع الذي تنتمي إليه (مثل ListNode وInteger)، وداخلها متغيرات النسخ المُعرَّفة بها. تمرين 3 ستجد ملفات الشيفرة المطلوبة لهذا التمرين في مستودع الكتاب. MyLinkedList.java: يحتوي على تنفيذ جزئي للواجهة List، ويَستخدِم قائمةً مترابطةً لتخزين العناصر. MyLinkedListTest.java: يحتوي على اختبارات JUnit للصنف MyLinkedList. نفِّذ الأمر ant MyArrayList لتشغيل MyArrayList.java الذي يحتوي على عدة اختبارات بسيطة. ثم نفِّذ ant MyArrayListTest لتشغيل اختبارات JUnit التي سيفشل البعض منها. إذا نظرت إلى الشيفرة، ستجد ثلاثة تعليقات TODO للإشارة إلى التوابع التي ينبغي عليك إكمالها. لننظر إلى بعض أجزاء الشيفرة قبل أن تبدأ. انظر إلى المتغيرات والبواني المُعرَّفة في الصنف MyLinkedList: public class MyLinkedList<E> implements List<E> { private int size; // احتفظ بعدد العناصر private Node head; // مرجع إلى العقدة الأولى public MyLinkedList() { head = null; size = 0; } } يحتفظ المتغير size -كما يُوضِّح التعليق- بعدد العناصر التي يَحمِلها كائن من النوع MyLinkedList، بينما يشير المتغير head إلى العقدة الأولى في القائمة أو يحمل القيمة الفارغة null إذا كانت القائمة فارغة. ليس من الضروري تخزين عدد العناصر. ولا يُعدّ الاحتفاظ بمعلوماتٍ إضافية في العموم أمرًا جيدًا؛ والسبب هو أن المعلومات الإضافية تحمّلنا عبء التحديث المستمر لها، ما قد يتسبَّب في وقوع أخطاء، كما أنها تحتل حيزًا إضافيًا من الذاكرة. لكننا لو خزَّنا size صراحةً، فإننا سنتمكَّن من كتابة تنفيذٍ للتابع size، بحيث يَستغرِق تنفيذه زمنًا ثابتًا؛ أما لو لم نفعل ذلك، فسنضطرّ إلى المرور عبر القائمة وعدّ عناصرها في كل مرة، وهذا يتطلَّب زمنًا خطيًا. من جهة أخرى، نظرًا لأننا نُخزِّن size صراحةً، فإننا سنضطرّ إلى تحديثه في كل مرة نضيف فيها عنصرًا إلى القائمة أو نحذفه منها. يؤدي ذلك إلى إبطاء تلك التوابع نوعًا ما، ولكنه لن يُؤثر على ترتيب النمو الخاص بها، ولذلك فلربما الأمر يستحق. يضبُط الباني قيمة head إلى null، مما يشير إلى كون القائمة فارغة، كما يضبُط size إلى صفر، ويَستخدِم هذا الصنف معامل نوع type parameter اسمه E لتخصيص نوع العناصر. ويَظهَر معامل النوع أيضًا بتعريف الصنف Node المُضمَّن داخل الصنف MyLinkedList. انظر تعريفه: private class Node { public E data; public Node next; public Node(E data, Node next) { this.data = data; this.next = next; } } أضف إلى هذا أن الصنف Node مشابهٌ تمامًا للصنف ListNode في الأعلى. والآن، انظر إلى تنفيذ التابع add: public boolean add(E element) { if (head == null) { head = new Node(element); } else { Node node = head; // نفذ الحلقة حتى تصل إلى العقدة الأخيرة for ( ; node.next != null; node = node.next) {} node.next = new Node(element); } size++; return true; } يُوضِّح هذا المثال نمطين ستحتاج لمعرفتهما لإكمال حل التمرين: في كثير من التوابع، عادةً ما نضطرّ إلى معالجة أول عنصرٍ في القائمة بطريقةٍ خاصة. وفي هذا المثال، إذا كنا نضيف العنصر الأول الى القائمة، فعلينا أن نُعدِّل قيمة head، أما في الحالات الأخرى، فعلينا أن نجتاز القائمة، حتى نصل إلى نهايتها، ثم نضيف العقدة الجديدة. يُبيّن ذلك التابع طريقة استخدام حلقة التكرار for من أجل اجتياز العقد الموجودة في القائمة. في مثالنا هذا لدينا حلقة واحدة. ولكن في الواقع قد تحتاج إلى كتابة نسخٍ عديدةٍ من تلك الحلقة ضمن الحلول الخاصة بك. من جهة أخرى، لاحِظ كيف صرّحنا عن node قبل بداية الحلقة؛ والهدف من ذلك هو أن نتمكَّن من استرجاعها بعد انتهاء الحلقة. والآن حان دورك، أكمل متن التابع indexOf. ويجب أن تقرأ توثيق List indexOf لكي تعرف ما ينبغي عليك القيام به. انتبه تحديدًا للطريقة التي يُفترَض له معالجة القيمة الفارغة null بها. كما هو الحال في تمرين مقالة تحليل الخوارزميات، وفَّرنا التابع المساعد equals للموازنة بين قيمة عنصرٍ ضمن المصفوفة وبين قيمةٍ معينةٍ أخرى، وفَحْص ما إذا كانت القيمتان متساويتين. يُعالِج التابع القيمة الفارغة معالجةً سليمة. لاحِظ أن التابع مُعرَّفٌ باستخدام المُعدِّل private؛ لأنه ليس جزءًا من الواجهة List، ويُستخدَم فقط داخل الصنف. شغِّل الاختبارات مرةً أخرى عندما تنتهي. ينبغي أن ينجح الاختبار testIndexOf وكذلك الاختبارات الأخرى التي تعتمد عليه. والآن، عليك أن تكمل نسخة التابع add ذات المعاملين. تَستقبِل تلك النسخة فهرسًا وتُخزِّن القيمة الجديدة في الفهرس المُمرَّر. وبالمثل، اقرأ أولًا التوثيق List add ثم نفِّذ التابع، وأخيرًا، شغِّل الاختبارات لكي تتأكّد من أنك نفّذتها بشكل سليم. لننتقل الآن إلى التابع الأخير: أكمل متن التابع remove. اقرأ توثيق التابع List remove. بعدما تنتهي من إكمال هذا التابع، ينبغي أن تنجح جميع الاختبارات. بعد أن تُنهِي جميع التوابع وتتأكَّد من أنها تَعمَل بكفاءة، يُمكِنك مقابتها مع النسخ المتاحة في مجلد solution في مستودع الكتاب ThinkDataStructures على GitHub. ملحوظة متعلقة بكنس المهملات garbage collection تنمو المصفوفة في الصنف MyArrayList -من التمرين المشار إليه- عند الضرورة، ولكنها لا تتقلص أبدًا، وبالتالي لا تُكنَس المصفوفة ولا يُكنَس أي من عناصرها حتى يحين موعد تدمير القائمة ذاتها. في المقابل، تتقلص القائمة المترابطة عند حذف العناصر منها، كما تُكنَس العقد غير المُستخدَمة مباشرةً، وهو ما يُمثِل واحدةً من مميزات هذا النوع من هياكل البيانات. انظر إلى تنفيذ التابع clear: public void clear() { head = null; size = 0; } عندما نضبُط قيمة الرأس head بالقيمة null، فإننا نحذف المرجع إلى العقدة الأولى. إذا لم تكن هناك أي مراجع أخرى إلى ذلك الكائن (من المفترض ألا يكون هناك أي مراجع أخرى)، فسيُكنَس الكائن مباشرةً. في تلك اللحظة، سيُحذَف المرجع إلى الكائن المُمثِل للعقدة الثانية، وبالتالي يُكنَس بدوره أيَضًا. وستستمر تلك العملية إلى أن تُكنَس جميع العقد. بناءً على ما سبق، ما هو تصنيف التابع clear؟ يتضمَّن التابع عمليتين ثابتتي الزمن، ويبدو لهذا كما لو أنه يستغرِق زمنًا ثابتًا، ولكنك عندما تستدعيه ستُحفزّ كانس المهملات على إجراء عملية تتناسب مع عدد العناصر، ولذلك ربما علينا أن نَعُدّه خطّيَّ الزمن. يُعدّ هذا مثالًا على ما نُسميه أحيانًا بـمشكلة برمجية في الأداء، أي أن البرنامج يفعل الشيء الصحيح، ولكنه لا ينتمي إلى ترتيب النمو المُتوقَّع. يَصعُب العثور على هذا النوع من الأخطاء خاصةً في اللغات التي تُنفِّذ أعمالًا كثيرةً وراء الكواليس مثل عملية كنس المهملات مثلاً، وتُعدّ لغة جافا واحدةً من تلك اللغات. ترجمة -بتصرّف- للفصل Chapter 3: ArrayList من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة مترابطة المقال السابق: مدخل إلى تحليل الخوارزميات تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة ازدواجية الترابط تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة مترابطة
-
سنتناول في هذه المقالة حل تمرين مقالة تحليل زمن تشغيل القوائم المُنفَّذة باستخدام مصفوفة، ثم نتابع مناقشة تحليل الخوارزميات. تصنيف توابع الصنف MyLinkedList تَعرِض الشيفرة التالية تنفيذ التابع indexOf. اقرأها وحاول تحديد ترتيب نمو order of growth التابع قبل المتابعة: public int indexOf(Object target) { Node node = head; for (int i=0; i<size; i++) { if (equals(target, node.data)) { return i; } node = node.next; } return -1; } تُسنَد head إلى node أولًا، ويعني هذا أنّ كليهما يشير الآن إلى نفس العقدة. يَعُدّ المُتغيّرُ i هو المُتحكِّم بالحلقة من 0 إلى size-1، ويَستدعِي في كل تكرارٍ التابعَ equals ليفحص ما إذا كان قد وجد القيمة المطلوبة. فإذا وجدها، فسيعيد قيمة i على الفور؛ أما إذا لم يجدها، فسينتقل إلى العقدة التالية ضمن القائمة. عادةً ما نتأكَّد أولًا مما إذا كانت العقدة التالية لا تحتوي على قيمة فارغة null، ولكن ليس هذا ضروريًا في حالتنا؛ لأن الحلقة تنتهي بمجرد وصولنا إلى نهاية القائمة (بفرض أن قيمة size مُتّسقةٌ مع العدد الفعلي للعقد الموجودة ضمن القائمة). إذا نفَّذنا الحلقة بالكامل دون العثور على القيمة المطلوبة، فسيعيد التابع القيمة -1. والآن، ما هو ترتيب نمو هذا التابع؟ إننا نستدعِي في كل تكرار التابع equals الذي يَستغرِق زمنًا ثابتًا (قد يعتمد على حجم target أو data، ولكنه لا يعتمد على حجم القائمة). تستغرق جميع التعليمات الأخرى ضمن الحلقة زمنًا ثابتًا أيضًا. تُنفَّذ الحلقة عددًا من المرات مقدراه n لأننا قد نضطّر إلى اجتياز القائمة بالكامل في الحالة الأسوأ. وبالتالي يتناسب زمن تنفيذ ذلك التابع مع طول القائمة. والآن، انظر إلى تنفيذ التابع add ذي المعاملين، وحاول تصنيفه قبل متابعة القراءة. public void add(int index, E element) { if (index == 0) { head = new Node(element, head); } else { Node node = getNode(index-1); node.next = new Node(element, node.next); } size++; } إذا كان index يُساوِي الصِّفر، فإننا نضيف العقدة الجديدة إلى بداية القائمة، ولهذا علينا أن نُعالِج ذلك مثل حالة خاصة. وبخلاف ذلك سنضطرّ إلى اجتياز القائمة إلى أن نصِل إلى العنصر الموجود في الفهرس index-1، كما سنستخدِم لهذا الهدف التابعَ المساعدَ getNode، وفيما يلي شيفرته: private Node getNode(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException(); } Node node = head; for (int i=0; i<index; i++) { node = node.next; } return node; } يفحص التابع getNode ما إذا كانت قيمة index خارج النطاق المسموح به، فإذا كانت كذلك، فإنه يُبلِّغ عن اعتراض exception؛ أما إذا لم تكن كذلك، فإنه يمرّ عبر عناصر القائمة ويعيد العقدة المطلوبة. الآن وقد حصلنا على العقدة المطلوبة، نعود إلى التابع add وننشئ عقدةً جديدةً، ونضعها بين العقدتين node وnode.next. قد يساعدك رسم هذه العملية على التأكد من فهمها بوضوح. والآن، ما هو ترتيب نمو التابع add؟ يشبه التابع getNode التابع indexOf، وهو خطّيٌّ لنفس السبب. تَستغرِق جميع التعليمات زمنًا ثابتًا سواءٌ قبل استدعاء التابع getNode أوبعد استدعائه ضمن التابع add. وعليه، يكون التابع add خطيًّا. وأخيرًا، لنُلقِ نظرةً على التابع remove: public E remove(int index) { E element = get(index); if (index == 0) { head = head.next; } else { Node node = getNode(index-1); node.next = node.next.next; } size--; return element; } يَستدعِي remove التابع get للعثور على العنصر الموجود في الفهرس index ثم عندما يجده يحذف العقدة التي تتضمنه. إذا كان index يُساوي الصفر، نُعالِج ذلك مثل حالة خاصة. وإذا لم يكن يساوي الصفر فسنذهب إلى العقدة الموجودة في الفهرس index-1، وهي العقدة التي تقع قبل العقدة المستهدفة بالحذف، ونُعدِّل حقل next فيها ليشير مباشرةً إلى العقدة node.next.next، وبذلك نكون قد حذفنا العقدة node.next من القائمة، ومن ثمَّ تُحرّر الذاكرة التي كانت تحتّلها عن طريق الكنس garbage collection. وأخيرًا، يُنقِص التابع قيمة size ويُعيد العنصر المُسترجَع في البداية. والآن بناءً على ما سبق، ما هو ترتيب نمو التابع remove؟ تَستغرِق جميع التعليمات في ذلك التابع زمنًا ثابتًا باستثناء استدعائه للتابعين get وgetNode اللذين يستغرقان زمنًا خطّيًّا. وبناءً على ذلك يكون التابع remove خطّيًّا هو الآخر. بعض الأشخاص عندما يرون عمليتين خطّيّتين أن النتيجة الإجمالية ستكون تربيعيّةً، ولكن هذا ليس صحيحًا إلا إذا كانت إحداهما داخل الأخرى؛ أما إذا استُدعِيت إحداهما تلو الأخرى، فستُحسَب المُحصلة بجمع زمنيهما، ولأن كليهما ينتميان إلى المجموعة O(n)، فسينتمي المجموع إلى O(n) أيضًا. الموازنة بين الصنفين MyArrayList وMyLinkedList يُلخِّص الجدول التالي الاختلافات بين الصنفين MyArrayList وMyLinkedList. يشير 1 إلى المجموعة O(1) أو الزمن الثابت، بينما يشير n إلى المجموعة O(n) أو الزمن الخطّي: MyArrayList MyLinkedList add (في النهاية) 1 n add (في البداية) n 1 add (في العموم) n n get / set 1 n indexOf / lastIndexOf n n isEmpty / size 1 1 remove (من النهاية) 1 n remove (من البداية) n 1 remove (في العموم) n n table { width: 100%; } thead { vertical-align: middle; text-align: center;} td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } في حالتي إضافة عنصر أو حذفه من نهاية القائمة، فإنّ الصنف MyArrayList هو أفضلُ من نظيره MyLinkedList، وكذلك في عمليتي الاسترجاع والتعديل؛ أمّا في حالتي إضافة عنصر أو حذفه من مقدّمة القائمة، فإن الصنف MyLinkedList هو أفضل من نظيره MyArrayList. يحظى الصنفان بنفس ترتيب النمو بالنسبة للعمليات الأخرى. إذًا، أيهما أفضل؟ يعتمد ذلك على العمليات التي يُحتمل استخدامها أكثر، وهذا السبب هو الذي يجعل جافا تُوفِّر أكثر من تنفيذٍ implementation وحيد. التشخيص Profiling ستحتاج إلى الصنف Profiler في التمرين التالي. يحتوي هذا الصنف على شيفرة بإمكانها أن تُشغِّل تابعًا ما على مشاكلَ ذات أحجامٍ متفاوتةٍ، وتقيس زمن التشغيل لكلٍّ منها، وتَعرِض النتائج. ستَستخدِم الصنف Profiler لتصنيف أداء التابع add المُعرَّف في كلٍّ من الصنفين ArrayList وLinkedList اللذين تُوفِّرهما لغة جافا. تُوضِّح الشيفرة التالية طريقة استخدام ذلك الصنف: public static void profileArrayListAddEnd() { Timeable timeable = new Timeable() { List<String> list; public void setup(int n) { list = new ArrayList<String>(); } public void timeMe(int n) { for (int i=0; i<n; i++) { list.add("a string"); } } }; String title = "ArrayList add end"; Profiler profiler = new Profiler(title, timeable); int startN = 4000; int endMillis = 1000; XYSeries series = profiler.timingLoop(startN, endMillis); profiler.plotResults(series); } يقيس التابعُ السابقُ الزمنَ الذي يستغرقه تشغيلُ التابعِ add الذي يُضيف عنصرًا جديدًا في نهاية قائمةٍ من النوع ArrayList. سنشرح الشيفرة أولًا ثم نَعرِض النتائج. لكي يتمكَّن الصنف Profiler من أداء عمله، سنحتاج أولًا إلى إنشاء كائنٍ من النوع Timeable. يُوفِّر هذا الكائنُ التابعين setup وtimeMe، حيث يُنفِّذ التابع setup كل ما ينبغي فعله قبل بدء تشغيل المؤقت، وفي هذا المثال سيُنشِئ قائمةً فارغة، أمّا التابع timeMe فيُنفِّذ العملية التي نحاول قياس أدائها. في هذا المثال، سنجعله يُضيف عددًا من العناصر مقداره n إلى القائمة. لقد عرَّفنا الشيفرة المسؤولة عن إنشاء المتغير timeable ضمن صنفٍ مجهول الاسم anonymous، حيث يُعرِّف ذلك الصنف تنفيذًا جديدًا للواجهة Timeable، ويُنشِئ نسخةً من الصنف الجديد في نفس الوقت. ولكنك على كل حالٍ لست في حاجةٍ إلى معرفة الكثير عنها لحل التمرين التالي، ويُمكِنك نسخ شيفرة المثال وتعديلها. والآن سننتقل إلى الخطوة التالية، وهي إنشاء كائن من الصنف Profiler مع تمرير معاملين له هما: العنوان title وكائن من النوع Timeable. يحتوي الصنف Profiler على التابع timingLoop. يستخدم ذلك التابعُ الكائنَ Timeable المُخزَّن مثل متغيِّرِ نُسْخَةٍ instance variable، حيث يَستدعِي تابعه timeMe عدة مراتٍ مع قيم مختلفةٍ لحجم المشكلة n في كل مرة. ويَستقبِل التابع timingLoop المعاملين التاليين: startN: هي قيمة n التي تبدأ منها الحلقة. endMillis: عبارة عن قيمة قصوى بوحدة الميلي ثانية. يزداد زمن التشغيل عندما يُزيد التابع timingLoop حجم المشكلة، وعندما يتجاوز ذلك الزمنُ القيمةَ القصوى المُحدّدة، فينبغي أن يتوقف التابع timingLoop. قد تضطّر إلى ضبط قيم تلك المعاملات عند إجراء تلك التجارب، فإذا كانت قيمة startN ضئيلةً للغاية، فلن يكون زمن التشغيل طويلًا بما يكفي لقياسه بدقّة، وإذا كانت قيمة endMillis صغيرةً للغاية، فقد لا تحصل على بياناتٍ كافيةٍ لاستنباط العلاقة بين حجم المشكلة وزمن التشغيل. ستجِد تلك الشيفرة -التي ستحتاج إليها في التمرين التالي- في الملف ProfileListAdd.java، والتي حصلنا عند تشغيلها على الخرج التالي: 4000, 3 8000, 0 16000, 1 32000, 2 64000, 3 128000, 6 256000, 18 512000, 30 1024000, 88 2048000, 185 4096000, 242 8192000, 544 16384000, 1325 يُمثِل العمود الأول حجم المشكلة n، أما العمود الثاني فيُمثِل زمن التشغيل بوحدة الميلي ثانية. كما تلاحظ، فالقياسات القليلة الأولى ليست دقيقةً تمامًا وليس لها مدلول حقيقي، ولعلّه كان من الأفضل ضبط قيمة startN إلى 64000. يُعيد التابعُ timingLoop النتائجَ بهيئة كائن من النوع XYSeries، ويُمثِل متتاليةً تحتوي على القياسات. إذا مرَّرتها إلى التابع plotResults، فإنه يَرسِم شكلًا بيانيًا -مشابها للموجود في الصورة التالية- وسنشرحه طبعًا في القسم التالي. تفسير النتائج بناءً على فهمنا لكيفية عمل الصنف ArrayList، فإننا نتوقَّع أن يستغرق التابع add زمنًا ثابتًا عندما نضيف عناصرَ إلى نهاية القائمة، وبالتالي ينبغي أن يكون الزمنُ الكلّيُّ لإضافة عددٍ من العناصر مقداره n زمنًا خطيًا. لكي نختبر صحة تلك النظرية، سنَعرِض تأثير زيادة حجم المشكلة على زمن التشغيل الكلّي. من المفترض أن نحصل على خطٍّ مستقيمٍ على الأقل لأحجام المشكلة الكبيرة بالقدر الكافي لقياس زمن التشغيل بدقة. ويُمكِننا كتابةُ دالّة هذا الخط المستقيم رياضيًّا على النحو التالي: runtime = a + b*n حيث يشير a إلى إزاحةِ الخط وهو قيمة ثابتة و b إلى ميل الخط. في المقابل، إذا كان التابع add خطّيًّا، فسيكون الزمن الكليُّ لتنفيذ عددٍ من الإضافات بمقدار n تربيعيًا. وعندها إذا نظرنا إلى تأثير زيادة حجم المشكلة على زمن التشغيل، فسنتوقَّع الحصول على قطعٍ مكافئٍ parabola، والذي يُمكِن كتابة معادلته رياضيًّا على النحو التالي: runtime = a + b*n + c*n^2 إذا كانت القياسات التي حصلنا عليها دقيقةً فسنتمكَّن بسهولةٍ من التمييز بين الخط المستقيم والقطع المكافئ، أمّا إذا لم تكن دقيقةً تمامًا، فقد يكون تمييز ذلك صعبًا إلى حدٍّ ما، وعندئذٍ يُفضّلُ استخدام مقياس لوغاريتمي-لوغاريتمي log-log لعرض تأثير حجم المشكلة على زمن التشغيل. ولِنَعرِفَ السببَ، لنفترض أن زمن التشغيل يتناسب مع nk، ولكننا لا نعلم قيمة الأس k. يُمكِننا كتابة تلك العلاقة على النحو التالي: runtime = a + b*n + … + c*n^k كما ترى في المعادلة السابقة، فإنّ الأساس ذا الأسِّ الأكبر هو الأهم من بين القيم الكبيرة لحجم المشكلة، وبالتالي يُمكِن تقريبيًّا إهمال باقي الحدود وكتابة العلاقة على النحو التالي: runtime ≈ c * n^k حيث نعني بالرمز ≈ "يساوي تقريبًا"، فإذا حسبنا اللوغاريتم لطرفي المعادلة، فستصبح مثل الآتي: log(runtime) ≈ log(c) + k*log(n) تعني المعادلة السابقة أنه لو رسمنا زمن التشغيل مقابل حجم المشكلة n باستخدام مقياس لوغاريتمي-لوغاريتمي، فسنرى خطًا مستقيمًا بثابتٍ -يمثل الإزاحة- يساوي log(c) وبميل يساوي k. لا يهمنا الثابت هنا وإنما يهمنا الميل k، فهو الذي يشير إلى ترتيب النمو. وزبدةُ الكلام أنه إذا كانت قيمة k تساوي 1، فالخوارزميةُ خطيّة؛ أما إذا كانت تساوي 2، فالخوارزمية تربيعيّة. إذا تأمّلنا في الرسم البيانيّ السابق، يُمكِننا أن نُقدِّر قيمة المَيْلِ تقريبيًا، في حين لو استدعينا التابع plotResults، فسيحسب قيمة الميل بتطبيق طريقة المربعات الدنيا least squares fit على القياسات، ثم يَطبَعه. و قد كانت قيمة الميل التي حصل عليها التابع: Estimated slope = 1.06194352346708 أي تقريبًا يساوي 1. إذًا فالزمنُ الكلّيّ لإجراء عدد مقداره n من الإضافات هو زمن خطي، وزمن كل إضافة منها ثابت كما توقعنا. تمرين 4 ستجد ملفات الشيفرة المطلوبة لهذا التمرين في مستودع الكتاب. Profiler.java: يحتوي على تنفيذ الصنف Profiler الذي شرحناه فيما سبق. ستَستخدِم ذلك الصنف، ولن تحتاج لفهم طريقة عمله، ومع ذلك يُمكِنك الاطلاع على شيفرته إن أردت. ProfileListAdd.java: يحتوي على شيفرة تشغيل التمرين، بما في ذلك المثال العلوي الذي شخَّصنا خلاله التابع ArrayList.add. ستُعدِّل هذا الملف لتُجرِي التشخيص على القليل من التوابع الأخرى. ستجد ملف البناء build.xml في المجلد code أيضًا. نفِّذ الأمر ant ProfileListAdd لكي تُشغِّل الملف ProfileListAdd.java. ينبغي أن تحصل على نتائج مشابهة للصورة المُرفقة في الأعلى، ولكنك قد تضطّر إلى ضبط قيمة المعاملين startN وendMillis. ينبغي أن يكون الميل المُقدَّر قريبًا من 1، مما يَعني أن تنفيذ عدد n من عمليات الإضافة يستغرق زمنًا متناسبًا مع n مرفوعة للأس 1، أي ينتمي إلى المجموعة O(n). ستجد تابعًا فارغًا في الملف ProfileListAdd.java اسمه كالآتي: profileArrayListAddBeginning عندها املأه بشيفرةٍ تفحص التابع ArrayList.add جاعلًا إياه يُضيِف العنصر الجديد دائمًا إلى بداية القائمة. إذا نَسخَت شيفرة التابع profileArrayListAddEnd، فستحتاج فقط إلى إجراء القليل من التعديلات. في الأخير، أضف سطرًا ضمن التابع main لاستدعاء تلك الدالة. نفِّذ الأمر ant ProfileListAdd مرةً أخرى وفسِّر النتائج. بناءً على فهمنا لطريقة عمل الصنف ArrayList، فإننا نتوقَّع أن تَستغرِق عملية الإضافة الواحدة زمنًا خطيًا، وبالتالي سيكون الزمن الكلي الذي يَستغرِقه عدد مقداره n من الإضافات تربيعيًا. إن كان ذلك صحيحًا، فإن الميل المُقدَّر للخط بمقياس لوغاريتمي-لوغاريتمي ينبغي أن يكون قريبًا من 2. بعد ذلك، وازن ذلك الأداء مع أداء الصنف LinkedList. املأ متن التابع الآتي واستخدمه لتصنيف التابع LinkedList.add بينما يُضيِف عنصرًا جديدًا إلى بداية القائمة: profileLinkedListAddBeginning أخيرًا، املأ متن التابع profileLinkedListAddEnd، واِستخدَمه لتصنيف التابع LinkedList.add بينما يُضيِف عنصرًا جديدًا إلى نهاية القائمة. ما الأداء الذي تتوقعه؟ وهل تتوافق النتائج مع تلك التوقعات؟ سنَعرِض النتائج ونجيب على تلك الأسئلة في المقالة التالية. ترجمة -بتصرّف- للفصل Chapter 4: LinkedList من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة ازدواجية الترابط المقال السابق: تحليل زمن تشغيل القوائم المنفذة باستخدام مصفوفة
-
سنراجع في هذه المقالة نتائج تمرين مقالة تحليل زمن تشغيل القوائم المُنفَّذة باستخدام قائمة مترابطة، ثم سنُقدِّم تنفيذًا آخرَ للواجهة List، وهو القائمة ازدواجية الترابط doubly-linked list. نتائج تشخيص الأداء اِستخدَمنا الصنف Profiler.java -في التمرين المشار إليه- لكي نُطبِّق عمليات الصنفين ArrayList وLinkedList على أحجام مختلفة من المشكلة، ثم عرضنا زمن التشغيل مقابل حجم المشكلة بمقياس لوغاريتمي-لوغاريتمي log-log، وقدّرنا ميل المنحني الناتج. يُوضِّح ذلك الميل العلاقة بين زمن التشغيل وحجم المشكلة. فعلى سبيل المثال، عندما استخدمنا التابع add لإضافة عناصر إلى نهاية قائمة من النوع ArrayList، وجدنا أن الزمن الكلّي لتنفيذ عدد n من الإضافات يتناسب مع n، أي أن الميل المُقدَّر كان قريبًا من 1، وبناءً على ذلك استنتجنا أن تنفيذ عدد n من الإضافات ينتمي إلى المجموعة O(n)، وأن تنفيذَ إضافةٍ واحدةٍ يتطلَّب زمنًا ثابتًا في المتوسط، أي أنه ينتمي إلى المجموعة O(1)، وهو نفس ما توصلنا إليه باستخدام تحليل الخوارزميات. كان المطلوب من ذلك التمرين هو إكمال متن التابع profileArrayListAddBeginning الذي يُشخِّص عملية إضافة عناصر جديدة إلى بداية قائمة من النوع ArrayList. وبناءً على تحليلنا للخوارزمية Y، فقد توقّعنا أن يتطلَّب تنفيذ إضافة واحدة زمنًا خطيًا بسبب تحريك العناصر الأخرى إلى اليمين، وعليه توقَّعنا أن يتطلَّب تنفيذ عدد n من الإضافات زمنًا تربيعيًا. انظر إلى حل التمرين الذي ستجده في مجلد الحل داخل مستودع الكتاب: public static void profileArrayListAddBeginning() { Timeable timeable = new Timeable() { List<String> list; public void setup(int n) { list = new ArrayList<String>(); } public void timeMe(int n) { for (int i=0; i<n; i++) { list.add(0, "a string"); } } }; int startN = 4000; int endMillis = 10000; runProfiler("ArrayList add beginning", timeable, startN, endMillis); } يتطابق هذا التابع تقريبًا مع التابع profileArrayListAddEnd، فالفارق الوحيد موجود في التابع timeMe، حيث يَستخدِم نسخةً ثنائيةَ المعامل من التابع add لكي يضع العناصر الجديدة في الفهرس 0، كما أنه يزيد من قيمة endMillis لكي يحصل على نقطة بياناتٍ إضافيّة. انظر إلى النتائج (حجم المشكلة على اليسار وزمن التشغيل بوحدة الميلي ثانية على اليمين): 4000, 14 8000, 35 16000, 150 32000, 604 64000, 2518 128000, 11555 تَعرِض الصورة التالية رسمًا بيانيًا لزمن التشغيل مقابل حجم المشكلة. لا يَعنِي ظهور خط مستقيم في هذا الرسم البياني أن الخوارزمية خطّيّة، وإنما يعني أنه إذا كان زمن التشغيل متناسبًا مع nk لأي أس k، فإنه من المتوقَّع أن نرى خطًّا مستقيمًا ميله يساوي k. نتوقَّع في هذا المثال أن يكون الزمنُ الكلّيّ لعدد n من الإضافات متناسبًا مع n2، وأن نحصل على خطٍّ مستقيمٍ بميلٍ يساوي 2، وفي الحقيقة يساوي الميل المُقدَّر 1.992 تقريبًا، وهو في الحقيقة دقيق جدًا لدرجةٍ تجعلنا لا نرغب في تزوير بيانات بهذه الجودة. تشخيص توابع الصنف LinkedList طلبَ التمرين المشار إليه منك أيضًا تشخيص أداء عملية إضافة عناصرَ جديدةٍ إلى بداية قائمةٍ من النوع LinkedList. وبناءً على تحليلنا للخوارزمية، توقّعنا أن يتطلَّب تنفيذ إضافةٍ واحدةٍ زمنًا ثابتًا؛ لأننا لا نضطّر إلى تحريك العناصر الموجودة في هذا النوع من القوائم، وإنما نضيف فقط عقدةً جديدةً إلى بداية القائمة، وعليه توقَّعنا أن يتطلَّب تنفيذ عدد n من الإضافات زمنًا خطّيًّا. انظر شيفرة الحل: public static void profileLinkedListAddBeginning() { Timeable timeable = new Timeable() { List<String> list; public void setup(int n) { list = new LinkedList<String>(); } public void timeMe(int n) { for (int i=0; i<n; i++) { list.add(0, "a string"); } } }; int startN = 128000; int endMillis = 2000; runProfiler("LinkedList add beginning", timeable, startN, endMillis); } اضطرّرنا إلى إجراء القليل من التعديلات، فعدّلنا الصنف ArrayList إلى الصنف LinkedList، كما ضبطنا قيمة المعاملين startN وendMillis لكي نحصل على قياسات مناسبة، فقد لاحظنا أن القياسات ليست بدقة القياسات السابقة. انظر إلى النتائج: 128000, 16 256000, 19 512000, 28 1024000, 77 2048000, 330 4096000, 892 8192000, 1047 16384000, 4755 لم نحصل على خط مستقيم تمامًا، وميل الخيط لا يساوي 1 بالضبط، وقد قدَّرت المربعات الدنيا least squares fit الميل بحوالي 1.23، ومع ذلك تشير تلك النتائج إلى أن الزمن الكلي لعدد n من الإضافات ينتمي إلى المجموعة O(n) على الأقل، وبالتالي يتطلَّب تنفيذُ إضافةٍ واحدةٍ زمنًا ثابتًا. الإضافة إلى نهاية قائمة من الصنف LinkedList تُعدّ إضافة العناصر إلى بداية القائمة واحدةً من العمليات التي نتوقَّع أن يكون الصنف LinkedList أثناء تنفيذها أسرع من الصنف ArrayList؛ وفي المقابل، بالنسبة لإضافة العناصر إلى نهاية القائمة، فإننا نتوقَّع أن يكون الصنف LinkedList أبطأ، حيث يضطّر تابع الإضافة إلى المرور عبر قائمة العناصر بالكامل لكي يتمكَّن من إضافة عنصر جديد إلى النهاية، مما يَعنِي أن العملية خطية، وعليه نتوقَّع أن يكون الزمن الكلي لعدد n من الإضافات تربيعيًا. في الواقع هذا ليس صحيحًا، ويمكنك الاطلاع إلى الشيفرة التالية: public static void profileLinkedListAddEnd() { Timeable timeable = new Timeable() { List<String> list; public void setup(int n) { list = new LinkedList<String>(); } public void timeMe(int n) { for (int i=0; i<n; i++) { list.add("a string"); } } }; int startN = 64000; int endMillis = 1000; runProfiler("LinkedList add end", timeable, startN, endMillis); } ها هي النتائج التي حصلنا عليها: 64000, 9 128000, 9 256000, 21 512000, 24 1024000, 78 2048000, 235 4096000, 851 8192000, 950 16384000, 6160 كما ترى هنا فالقياسات غير دقيقة أيضًا، كما أن الخط ليس مستقيمًا تمامًا، والميل المُقدّر يُساوِي 1.19، وهو قريبٌ لما حصلنا عليه عند إضافة العناصر إلى بداية القائمة، وليس قريبًا من 2 الذي توقّعنا أن نحصل عليه بناءً على تحليلنا للخوارزمية. في الواقع، هو أقرب إلى 1، مما قد يشير إلى أن إضافة العناصر إلى نهاية القائمةِ يستغرق زمنًا ثابتًا. القوائم ازدواجية الترابط Doubly-linked list الفكرة هي أن الصنف MyLinkedList الذي نفَّذناه يَستخدِم قائمة مترابطة أحادية، أي أن كل عنصرٍ يحتوي على رابطٍ واحدٍ إلى العنصر التالي، في حين يحتوي الكائن MyArrayList نفسه على رابط إلى العقدة الأولى. في المقابل، إذا اطلعت على توثيق الصنف LinkedList باللغة الإنجليزية الذي تُوفِّره جافا، فإننا نجد ما يلي: تنفيذ قائمة ازدواجية الترابط للواجهتين List وDeque. تَعمَل جميع العمليات بالشكل المُتوقَّع من قائمةٍ ازدواجية الترابط، أي تؤدي عمليات استرجاع فهرس معين إلى اجتياز عناصر القائمة من البداية أو من النهاية بناءً على أيهما أقرب لذلك الفهرس. فيما يلي نذكر الفكرة العامّة عن القوائم ازدواجية الترابط، إذ فيها: تحتوي كل عقدةٍ على رابطٍ إلى العقدة التالية ورابطٍ إلى العقدة السابقة. تحتوي كائنات الصنف LinkedList على روابط إلى العنصر الأول والعنصر الأخير في القائمة. بناءً على ما سبق، يُمكِننا أن نبدأ من أي طرف، وأن نجتاز القائمة بأي اتجاه، وعليه تتطلَّب إضافة العناصر وحذفها من بداية القائمة أو نهايتها زمنًا ثابتًا. يُلخِّص الجدول التالي الأداء المُتوقَّع من الصنف ArrayList والصنف المُخصَّص MyLinkedList الذي تحتوي عقده على رابط واحد والصنف LinkedList الذي تحتوي عقده على رابطين: MyArrayList MyLinkedList LinkedList add (بالنهاية) 1 n 1 add (بالبداية) n 1 1 add (في العموم) n n n get / set 1 n n indexOf / lastIndexOf n n n isEmpty / size 1 1 1 remove (من النهاية) 1 n 1 remove (من البداية) n 1 1 remove (في العموم) n n n table { width: 100%; } thead { vertical-align: middle; text-align: center;} td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } اختيار هيكل البيانات الأنسب يُعدّ التنفيذ مزدوج الروابط أفضل من التنفيذ ArrayList فيما يتعلَّق بعمليتي الإضافة والحذف من بداية القائمة، ويتمتعان بنفس الكفاءة فيما يتعلَّق بعمليتي الإضافة والحذف من نهاية القائمة، وبالتالي تقتصر أفضلية الصنف ArrayList عليه بعمليتي get وset، لأنهما تتطلبان زمنًا خطيًا في القوائم المترابطة حتى لو كانت مزدوجة. إذا كان زمن تشغيل التطبيق الخاص بك يعتمد على الزمن الذي تتطلَّبه عمليتا get وset، فقد يكون التنفيذ ArrayList هو الخيار الأفضل؛ أما إذا كان يَعتمِد على عملية إضافة العناصر وحذفها إلى بداية القائمة ونهايتها، فلربما التنفيذ LinkedList هو الخيار الأفضل. ولكن تذكّر أن هذه التوصياتِ مبنيّةٌ على ترتيب النمو order of growth للأحجام الكبيرة من المشكلات. هنالك عوامل أخرى ينبغي أن تأخذها في الحسبان أيضًا: لو لم تكن تلك العمليات تستغرِق جزءًا كبيرًا من زمن تشغيل التطبيق الخاص بك -أي لو كان التطبيق يقضِي غالبية زمن تشغيله في تنفيذ أشياء أخرى-، فإن اختيارك لتنفيذ الواجهة List غير مهم لتلك الدرجة. إذا لم تكن القوائم التي تُعالجها كبيرةً بدرجة كافية، فلربما لن تحصل على الأداء الذي تتوقَّعه، فبالنسبة للمشكلات الصغيرة، قد تكون الخوارزمية التربيعية أسرع من الخوارزمية الخطية، وقد تكون الخوارزمية الخطية أسرع من الخوارزمية ذات الزمن الثابت، كما أن الاختلاف بينها في العموم لا يُهمّ كثيرًا. لا تنسى عامل المساحة. ركزَّنا حتى الآن على زمن التشغيل، ولكن عامل المساحة مهم أيضًا، إذ تتطلَّب التنفيذات المختلفة مساحاتٍ مختلفةً من الذاكرة، وتُخزَّن العناصر في قائمةٍ من الصنف ArrayList إلى جانب بعضها البعض ضمن قطعة واحدة من الذاكرة، وبالتالي لا تُبدَّد مساحة الذاكرة، كما أن الحاسوب عادةً ما يكون أسرع عندما يتعامل مع أجزاء متصلة من الذاكرة. في المقابل، يتطلَّب كل عنصر في القوائم المترابطة عقدةً مكوَّنةً من رابطٍ أو رابطين. تحتل تلك الروابط حيزًا من الذاكرة - أحيانًا ما يكون أكبرَ من الحيزِ الذي تحتله البيانات نفسها-، كما تكون تلك العقدُ مبعثرةً ضمن أجزاءٍ مختلفةٍ من الذاكرة، مما يَجعَل الحاسوب أقلّ كفاءةً في تعامله معها. خلاصة القول هي أن تحليل الخوارزميات يُوفِّر بعض الإرشادات التي قد تساعدك على اختيار هياكل البيانات الأنسب، ولكن بشروط: زمن تشغيل التطبيق مهم. زمن تشغيل التطبيق يعتمد على اختيارك لهيكل البيانات. حجم المشكلة كبيرٌ بالقدر الكافي بحيث يتمكن ترتيب النمو من توقع هيكل البيانات الأنسب. في الحقيقة، يُمكِنك أن تتمتع بحياةٍ مهنيّةٍ طويلةٍ أثناء عملك كمهندس برمجيات دون أن تتعرَّض لهذا الموقف على الإطلاق. ترجمة -بتصرّف- للفصل Chapter 5: Doubly-linked list من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تنفيذ أسلوب البحث بالعمق أولا باستخدام طريقتي التعاود والتكرار في جافا المقال السابق: تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة مترابطة تعقيد الخوارزميات Algorithms Complexity الأشجار Trees في الخوارزميات ترميز Big-O في الخوارزميات الرسوم التخطيطية Graphs في الخوارزميات
-
كما رأينا في مقالة طريقة عمل الواجهات بلغة جافا، تُوفِّر جافا تنفيذين implementations للواجهة List، هما ArrayList وLinkedList، حيث يكون النوع LinkedList أسرع بالنسبة لبعض التطبيقات، بينما يكون النوع ArrayList أسرع بالنسبة لتطبيقاتٍ أخرى. وإذا أردنا أن نُحدِّد أيهما أفضل للاستخدام في تطبيق معين، فيمكننا تجربة كلٍّ منهما على حدةٍ لنرى الزمن الذي سيَستغرِقه. يُطلَق على هذا الأسلوب اسم التشخيص profiling، ولكنّ له بعض الإشكاليّات: أننا سنضطّر إلى تنفيذ الخوارزميتين كلتيهما لكي نتمكَّن من الموازنة بينهما. قد تعتمد النتائج على نوع الحاسوب المُستخدَم، فقد تعمل خوارزمية معينة بكفاءةٍ عالية على حاسوب معين، في حين قد تَعمَل خوارزميةٌ أخرى بكفاءةٍ عاليةٍ على حاسوب مختلف. قد تعتمد النتائج على حجم المشكلة أو البيانات المُدْخَلة. يُمكِننا معالجة بعض هذه النقاط المُشكلةِ بالاستعانة بما يُعرَف باسم تحليل الخوارزميات، الذي يُمكِّننا من الموازنة بين عدة خوارزمياتٍ دون الحاجة إلى تنفيذها فعليًا، ولكننا سنضطّر عندئذٍ لوضع بعض الافتراضات: فلكي نتجنَّب التفاصيل المتعلقة بعتاد الحاسوب، سنُحدِّد العمليات الأساسية التي تتألف منها أي خوارزميةٍ مثل الجمع والضرب وموازنة عددين، ثم نَحسِب عدد العمليات التي تتطلّبها كل خوارزمية. ولكي نتجنَّب التفاصيل المتعلقة بالبيانات المُدْخَلة، فإن الخيار الأفضل هو تحليل متوسط الأداء للمُدْخَلات التي نتوقع التعامل معها. فإذا لم يَكُن ذلك متاحًا، فسيكون تحليل الحالة الأسوأ هو الخيار البديل الأكثر شيوعًا. أخيرًا، سيتعيّن علينا التعامل مع احتمالية أن يكون أداء خوارزميةٍ معينةٍ فعّالًا عند التعامل مع مشكلات صغيرة وأن يكون أداء خوارزميةٍ أخرى فعّالًا عند التعامل مع مشكلاتٍ كبيرة. وفي تلك الحالة، عادةً ما نُركِّز على المشكلات الكبيرة، لأن الاختلاف في الأداء لا يكون كبيرًا مع المشكلات الصغيرة، ولكنه يكون كذلك مع المشكلات الكبيرة. يقودنا هذا النوع من التحليل إلى تصنيف بسيط للخوارزميات. على سبيل المثال، إذا كان زمن تشغيل خوارزمية A يتناسب مع حجم المدخلات n، وكان زمن تشغيل خوارزمية أخرى B يتناسب مع n2، فيُمكِننا أن نقول إن الخوارزمية A أسرع من الخوارزمية B لقيم n الكبيرة على الأقل. يُمكِن تصنيف غالبية الخوارزميات البسيطة إلى إحدى التصنيفات التالية: ذات زمن ثابت: تكون الخوارزمية ثابتة الزمن إذا لم يعتمد زمن تشغيلها على حجم المدخلات. على سبيل المثال، إذا كان لدينا مصفوفةٌ مكوَّنةٌ من عدد n من العناصر، واستخدمنا العامل [] لقراءة أيٍّ من عناصرها، فإن ذلك يتطلَّب نفس عدد العمليات بغضّ النظر عن حجم المصفوفة. ذات زمن خطّي: تكون الخوارزمية خطيّةً إذا تناسب زمن تشغيلها مع حجم المدخلات. فإذا كنا نحسب حاصل مجموع العناصر الموجودة ضمن مصفوفة مثلًا، فعلينا أن نسترجع قيمة عدد n من العناصر، وأن نُنفِّذ عدد n-1 من عمليات الجمع، وبالتالي يكون العدد الكليّ للعمليات (الاسترجاع والجمع) هو 2*n-1، وهو عددٌ يتناسب مع n. ذات زمن تربيعي: تكون الخوارزمية خطيةً إذا تناسب زمن تشغيلها مع n2. على سبيل المثال، إذا كنا نريد أن نفحص ما إذا كان هنالك أيُّ عنصرٍ ضمن قائمةٍ معينةٍ مُكرَّرًا، فإن بإمكان خوارزميةٍ بسيطةٍ أن توازن كل عنصرٍ ضمن القائمة بجميع العناصر الأخرى، وذلك نظرًا لوجود عدد n من العناصر، والتي لا بُدّ من موازنة كُلٍّ منها مع عدد n-1 من العناصر الأخرى، يكون العدد الكليّ لعمليات الموازنة هو n2-n، وهو عددٌ يتناسب مع n2. الترتيب الانتقائي Selection sort تُنفِّذ الشيفرة المثال التالية خوارزميةً بسيطةً تُعرَف باسم الترتيب الانتقائي: public class SelectionSort { /** * بدل العنصرين الموجودين بالفهرس i والفهرس j */ public static void swapElements(int[] array, int i, int j) { int temp = array[i]; array[i] = array[j]; array[j] = temp; } /** * اعثر على فهرس أصغر عنصر بدءًا من الفهرس المُمرَّر * عبر المعامل index وحتى نهاية المصفوفة */ public static int indexLowest(int[] array, int start) { int lowIndex = start; for (int i = start; i < array.length; i++) { if (array[i] < array[lowIndex]) { lowIndex = i; } } return lowIndex; } /** * رتب المصفوفة باستخدام خوارزمية الترتيب الانتقائي */ public static void selectionSort(int[] array) { for (int i = 0; i < array.length; i++) { int j = indexLowest(array, i); swapElements(array, i, j); } } } يُبدِّل التابع الأول swapElements عنصرين ضمن المصفوفة، وتَستغرِق عمليتا قراءة العناصر وكتابتها زمنًا ثابتًا؛ لأننا لو عَرَفنا حجم العناصر وموضع العنصر الأول ضمن المصفوفة، فسيكون بإمكاننا حساب موضع أي عنصرٍ آخرَ باستخدام عمليتي ضربٍ وجمعٍ فقط، وكلتاهما من العمليات التي تَستغرِق زمنًا ثابتًا. ولمّا كانت جميع العمليات ضمن التابع swapElements تَستغرِق زمنًا ثابتًا، فإن التابع بالكامل يَستغرِق بدوره زمنًا ثابتًا. يبحثُ التابع الثاني indexLowest عن فهرسِ index أصغرِ عنصرٍ في المصفوفة بدءًا من فهرسٍ معينٍ يُخصِّصه المعامل start، ويقرأ كل تكرارٍ ضمن الحلقة التكراريّة عنصرين من المصفوفة ويُوازن بينهما، ونظرًا لأن كل تلك العمليات تستغرِق زمنًا ثابتًا، فلا يَهُمّ أيُها نَعُدّ. ونحن هنا بهدف التبسيط سنحسب عدد عمليات الموازنة: إذا كان start يُساوِي الصفر، فسيَمُرّ التابع indexLowest عبر المصفوفة بالكامل، وبالتالي يكون عدد عمليات الموازنة المُجراة مُساويًا لعدد عناصر المصفوفة، وليكن n. إذا كان start يُساوِي 1، فإن عدد عمليات الموازنة يُساوِي n-1. في العموم، يكون عدد عمليات الموازنة مساويًا لقيمة n-start، وبالتالي، يَستغرِق التابع indexLowest زمنًا خطّيًا. يُرتِّب التابع الثالث selectionSort المصفوفة. ويُنفِّذ التابع حلقة تكرار من 0 إلى n-1، أي يُنفذِّ الحلقة عدد n من المرات. وفي كل مرة يَستدعِي خلالها التابع indexLowest، ثم يُنفِّذ العملية swapElements التي تَستغرِق زمنًا ثابتًا. عند استدعاء التابع indexLowest لأوّلِ مرة، فإنه يُنفِّذ عددًا من عمليات الموازنة مقداره n، وعند استدعائه للمرة الثانية، فإنه يُنفِّذ عددًا من عمليات الموازنة مقداره n-1، وهكذا. وبالتالي سيكون العدد الإجمالي لعمليات الموازنة هو: n + n-1 + n-2 + ... + 1 + 0 يبلُغ مجموع تلك السلسلة مقدارًا يُساوِي n(n+1)/2، وهو مقدارٌ يتناسب مع n2، مما يَعنِي أن التابع selectionSort يقع تحت التصنيف التربيعي. يُمكِننا الوصول إلى نفس النتيجة بطريقة أخرى، وهي أن ننظر للتابع indexLowest كما لو كان حلقة تكرارٍ متداخلةً nested، ففي كل مرة نَستدعِي خلالها التابع indexLowest، فإنه يُنفِّذ مجموعةً من العمليات يكون عددها متناسبًا مع n، ونظرًا لأننا نَستدعيه عددًا من المرات مقداره n، فإن العدد الكليّ للعمليات يكون متناسبًا مع n2. ترميز Big O تنتمي جميع الخوارزميات التي تَستغرِق زمنًا ثابتًا إلى مجموعةٍ يُطلَق عليها اسم O(1)، فإذا قلنا إن خوارزميةً معينةً تنتمي إلى المجموعة O(1)، فهذا يعني ضمنيًّا أنها تستغرِق زمنًا ثابتًا. وعلى نفس المنوال، تنتمي جميع الخوارزميات الخطيّة -التي تستغرِق زمنًا خطيًا- إلى المجموعة O(n)، بينما تنتمي جميع الخوارزميات التربيعية إلى المجموعة O(n2). تطلَق على تصنيف الخوارزميات بهذا الأسلوب تسمية ترميز Big O. يُوفِّر هذا الترميز أسلوبًا سهلًا لكتابة القواعد العامة التي تسلُكها الخوارزميات في العموم. فلو نفَّذنا خوارزميةً خطيةً وتبعناها بخوارزميةٍ ثابتة الزمن على سبيل المثال، ، فإن زمن التشغيل الإجمالي يكون خطيًا. وننبّه هنا إلى أنّ ∈ تَعنِي "ينتمي إلى": If f ∈ O(n) and g ∈ O(1), f+g ∈ O(n) إذا أجرينا عمليتين خطيتين، فسيكون المجموع الإجمالي خطيًا: If f ∈ O(n) and g ∈ O(n), f+g ∈ O(n) في الحقيقة، إذا أجرينا عمليةً خطيّةً أي عددٍ من المرات، وليكن k، فإن المجموع الإجمالي سيبقى خطيًا طالما أن k قيمة ثابتة لا تعتمد على n: If f ∈ O(n) and k is constant, kf ∈ O(n) في المقابل، إذا أجرينا عمليةً خطيةً عدد n من المرات، فستكون النتيجة تربيعيةً: If f ∈ O(n), nf ∈ O(n^2) وفي العموم، ما يهمنا هو أكبر أسٍّ للأساس n، فإذا كان العدد الكليّ للعمليات يُساوِي 2n+1، فإنه إجمالًا ينتمي إلى O(n)، ولا أهمية للثابت 2 ولا للقيمة المضافة 1 في هذا النوع من تحليل الخوارزميات. وبالمثل، ينتمي n2+100n+1000 إلى O( n2). ولا أهمّية للأرقام الكبيرة التي تراها. يُعدّ ترتيب النمو Order of growth طريقةً أخرى للتعبير عن نفس الفكرة، ويشير ترتيبُ نموٍّ معين إلى مجموعة الخوارزميات التي ينتمي زمن تشغيلها إلى نفس تصنيف ترميز big O، حيث تنتمي جميع الخوارزميات الخطية مثلًا إلى نفس ترتيب النمو؛ وذلك لأن زمن تشغيلها ينتمي إلى المجموعة O(n). ويُقصَد بكلمة "ترتيب" ضمن هذا السياق "مجموعة"، مثل اِستخدَامنا لتلك الكلمة في عبارةٍ مثل "ترتيب فرسان المائدة المستديرة". ويُقصَد بهذا أنهم مجموعة من الفرسان، وليس طريقة صفّهم أو ترتيبهم، أي يُمكِنك أن تنظر إلى ترتيب الخوارزميات الخطية وكأنها مجموعة من الخوارزميات التي تتمتّع بكفاءةٍ عالية. تمرين 2 يشتمل التمرين التالي على تنفيذ الواجهة List باستخدام مصفوفةٍ لتخزين عناصر القائمة. ستجد الملفات التالية في مستودع الشيفرة الخاص بالكتاب -انظر القسم 0.1-: MyArrayList.java : يحتوي على تنفيذ جزئي للواجهة List، فهناك أربعةُ توابعَ غير مكتملة عليك أن تكمل كتابة شيفرتها. MyArrayListTest.java: يحتوي على مجموعة من اختبارات JUnit، والتي يُمكِنك أن تَستخدِمها للتحقق من صحة عملك. كما ستجد الملف build.xml. يُمكِنك أن تُنفِّذ الأمر ant MyArrayList؛ لكي تتمكَّن من تشغيل الصنف MyArrayList.java وأنت ما تزال في المجلد code الذي يحتوي على عدة اختباراتٍ بسيطة. ويُمكِنك بدلًا من ذلك أن تُنفِّذ الأمر ant MyArrayListTest لكي تُشغِّل اختباراتِ JUnit. عندما تُشغِّل تلك الاختبارات فسيفشل بعضها، والسبب هو وجود توابع ينبغي عليك إكمالها. إذا نظرت إلى الشيفرة، فستجد 4 تعليقات TODO تشير إلى هذه موضع كل من هذه التوابع. ولكن قبل أن تبدأ في إكمال تلك التوابع، دعنا نلق نظرةً سريعةً على بعض أجزاء الشيفرة. تحتوي الشيفرة التالية على تعريف الصنف ومتغيراتِ النُّسَخ instance variables وباني الصنف constructor: public class MyArrayList<E> implements List<E> { int size; // احتفظ بعدد العناصر private E[] array; // خزِّن العناصر public MyArrayList() { array = (E[]) new Object[10]; size = 0; } } يحتفظ المتغير size -كما يُوضِّح التعليق- بعدد العناصر التي يَحمِلها كائنٌ من النوع MyArrayList، بينما يُمثِل المتغير array المصفوفة التي تحتوي على تلك العناصر ذاتها. يُنشِئ الباني مصفوفةً مكوَّنةً من عشرة عناصر تَحمِل مبدئيًّا القيمة الفارغة null، كما يَضبُط قيمة المتغير size إلى 0. غالبًا ما يكون طول المصفوفة أكبر من قيمة المتغير size، مما يَعنِي وجود أماكنَ غير مُستخدَمةٍ في المصفوفة. array = new E[10]; لكي تتمكَّن من تخطِي تلك العقبة، عليك أن تُنشِئ مصفوفةً من النوع Object، ثم تُحوِّل نوعها typecast. يُمكِنك قراءة المزيد عن ذلك في ما المقصود بالأنواع المعمّمة (باللغة الإنجليزية). ولنُلقِ نظرةً الآن على التابع المسؤول عن إضافة العناصر إلى القائمة: public boolean add(E element) { if (size >= array.length) { // أنشئ مصفوفة أكبر وانسخ إليها العناصر E[] bigger = (E[]) new Object[array.length * 2]; System.arraycopy(array, 0, bigger, 0, array.length); array = bigger; } array[size] = element; size++; return true; } في حالة عدم وجود المزيد من الأماكن الشاغرة في المصفوفة، سنضطّر إلى إنشاء مصفوفةٍ أكبر نَنسَخ إليها العناصر الموجودة سابقًا، وعندئذٍ سنتمكَّن من إضافة العنصر الجديد إلى تلك المصفوفة، مع زيادة قيمة المتغير size. يعيد ذلك التابع قيمةً من النوع boolean. قد لا يكون سبب ذلك واضحًا، فلربما تظن أنه سيعيد القيمة true دائمًا. قد لا تتضح لنا الكيفية التي ينبغي أن نُحلِّل أداء التابع على أساسها. في الأحوال العاديّة يستغرق التابع زمنًا ثابتًا، ولكنه في الحالات التي نضطّر خلالها إلى إعادة ضبط حجم المصفوفة سيستغرِق زمنًا خطيًا. وسنتطرّق إلى كيفية معالجة ذلك في جزئية لاحقة من هذه السلسلة. في الأخير، لنُلقِ نظرةً على التابع get، وبعدها يُمكِنك البدء في حل التمرين: public T get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException(); } return array[index]; } كما نرى، فالتابع get بسيطٌ للغاية ويعمل كما يلي: إذا كان الفهرس المطلوب خارج نطاق المصفوفة، فسيُبلِّغ التابع عن اعتراض exception؛ أما إذا ضمن نطاق المصفوفة، فإن التابع يسترجع عنصر المصفوفة ويعيده. لاحِظ أن التابع يَفحَص ما إذا كانت قيمة الفهرس أقل من قيمة size لا قيمة array.length، وبالتالي لا يعيد التابع قيم عناصر المصفوفة غير المُستخدَمة. ستجد التابع set في الملف MyArrayList.java على النحو التالي: public T set(int index, T element) { // TODO: fill in this method. return null; } اقرأ توثيق set باللغة الإنجليزية، ثم أكمل متن التابع. لا بُدّ أن ينجح الاختبار testSet عندما تُشغِّل MyArrayListTest مرةً أخرى. الخطوة التالية هي إكمال التابع indexOf. وقد وفَّرنا لك أيضًا التابع المساعد equals للموازنة بين قيمة عنصر ضمن المصفوفة وبين قيمة معينة أخرى. يعيد ذلك التابع القيمة true إذا كانت القيمتان متساويتين كما يُعالِج القيمة الفارغة null بشكل سليم. لاحِظ أن هذا التابع مُعرَّف باستخدام المُعدِّل private؛ لأنه ليس جزءًا من الواجهة List، ويُستخدَم فقط داخل الصنف. شغِّل الاختبار MyArrayListTest مرة أخرى عندما تنتهي، والآن ينبغي أن ينجح الاختبار testIndexOf وكذلك الاختبارات الأخرى التي تعتمد عليه. ما يزال هناك تابعان آخران عليك إكمالهما لكي تنتهي من التمرين، حيث أن التابع الأول هو عبارة عن بصمة أخرى من التابع add. تَستقبِل تلك البصمة فهرسًا وتُخزِّن فيه قيمةً جديدة. قد تضطّر أثناء ذلك إلى تحريك العناصر الأخرى لكي تُوفِّر مكانًا للعنصر الجديد. مثلما سبق، اقرأ التوثيق باللغة الإنجليزية أولًا ثم نفِّذ التابع، بعدها شغِّل الاختبارات لكي تتأكّد من أنك تنفيذك سليم. لننتقل الآن إلى التابع الأخير. أكمل متن التابع remove وعندما تنتهي من إكمال هذا التابع، فالمتوقع أن تنجح جميع الاختبارات. وبعد أن تُنهِي جميع التوابع وتتأكَّد من أنها تَعمَل بكفاءة، يُمكِنك الاطلاع على الشيفرة. ترجمة -بتصرّف- للفصل Chapter 2: Analysis of Algorithms من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تحليل زمن تشغيل القوائم المنفذة باستخدام مصفوفة المقال السابق: طريقة عمل الواجهات في لغة جافا تحليل الخوارزميات في جافا الرسوم التخطيطية Graphs في الخوارزميات تعقيد الخوارزميات Algorithms Complexity الأشجار Trees في الخوارزميات
-
ربما أخبرك أحدهم من قبل بأنك لا تستطيع أن تُعرِّف الشيء بالإشارة إلى ذاته، ولكن هذا ليس صحيحًا عمومًا؛ فإذا أُنجز هذا التعريف بالصورة الصحيحة، فسيُصبِح مُمكِنًا ويتحول إلى تقنيةٍ فعالةٍ للغاية. التعريف التعاودي recursive لشيءٍ أو لمفهومٍ هو ذلك التعريف الذي يُشير إلى نفسه ضِمْن تعريفه، حيث يُمكِننا مثلًا تعريف السلف ancestor بأن يكون إما أبًا أو سلفًا للأب؛ وفي مثال آخر، تعريف الجملة sentence على أنها مكوَّنة -من بين أشياءٍ أخرى كثيرة- من جملتين مرتبطتين بحرف عطفٍ مثل "و". مثالٌ ثالث هو تعريف المُجلد directory على أنه جزءٌ من قرصٍ صلب disk drive، والذي بدوره قد يحتوي على ملفاتٍ ومجلدات. يُمكِننا أيضًا تعريف المجموعة set بسياق علم الرياضيات على أنها مجموعةٌ من العناصر التي يُمكِنها أن تكون مجموعةً بحد ذاتها. في مثال أخير، يمكن الإشارة إلى إمكانية تعريف التعليمة statement بلغة جافا، على أنها قد تَكُون تَعليمَة while المُكوَّنة بدورها من كلمة while وشرط منطقي وتعليمة. تستطيع التعريفات التعاودية recursive وصف أكثر المواقف تعقيدًا بعدة كلماتٍ قليلة، فإذا أردنا مثلًا تعريف السلف دون استخدام التعاود، فإننا قد نقول أنه "أبٌ، أو جدٌ، أو جدٌ أكبر،… وهكذا". في الحقيقة، ليس واضحًا ما هو المقصود وراء كلمة "إلخ"، حيث قد تقع بنفس المشكلة إذا حاولت تعريف مجلد على أنه ملفٌ مُكوَّنٌ من قائمةٍ من الملفات التي قد يكون بعضها بدوره قائمةً أخرى من الملفات، والتي قد يكون بعضها أيضًا قائمةً من الملفات، وهكذا". حاول وصف مصطلح تعليمة جافا statement دون استخدام التعاود، وسترى أنه أمرٌ غايةٌ في الصعوبة. يُمكِننا في الواقع استخدام التعاود مثل تقنيةٍ برمجية، فعلى سبيل المثال البرنامج الفرعي التعاودي recursive subroutine، أو التابع التعاودي recursive method هو ذلك البرنامج أو التابع الذي يُعاود استدعاء ذاته استدعاءً مباشرًا أو غير مباشر. ويعني الاستدعاء المباشر احتواء تعريف البرنامج الفرعي على تعليمة استدعاء برنامجٍ فرعي للبرنامج ذاته قيد التعريف؛ أما الاستدعاء غير المباشر فيعني استدعاء البرنامج الفرعي لبرنامجٍ فرعيٍ ثانٍ، والذي بدوره يستدعي البرنامج الفرعي الأول استدعاءً مباشرًا أو غير مباشر. تستطيع البرامج الفرعية التعاودية التي تتمكن من معاودة استدعاء ذاتها، أن تُعرِّف بعض المهمات المعقدة بعدة أسطر من الشيفرة. سنناقش بالجزء المتبقي من هذا المقال عدة أمثلة على ذلك، كما سنتعرَّض لأمثلةٍ أخرى خلال المقالات المتبقية من هذه السلسلة. بحث ثنائي تعاودي Recursive Binary Search لنبدأ بمثال خوارزمية البحث الثنائي binary search الذي تَعرَّضنا له بمقال البحث والترتيب في المصفوفات Array في جافا، حيث يُستخدم البحث الثنائي للعثور على قيمةٍ مُحدَّدةٍ ضِمْن قائمةٍ مُرتَّبةٍ من العناصر في حالة وجودها ضِمْن تلك القائمة. وتتمحور فكرة الخوارزمية حول فحص عنصر منتصف القائمة، فإذا كان ذلك العنصر يُساوِي القيمة المطلوبة، فإننا نَكُون قد انتهينا بالفعل؛ أما إذا كانت القيمة المطلوبة أقل من قيمة عنصر منتصف القائمة، فإننا نبحث عن تلك القيمة بالنصف الأول من القائمة؛ أما إن لم تَكْن كذلك، فإننا نبحث عنها بالنصف الثاني من القائمة. يُطلق اسم البحث الثنائي binary search على الأسلوب المُتبَّع للبحث عن قيمةٍ ضِمْن النصف الأول أو الثاني من قائمة؛ أي أن نَفْحَص عنصر منتصف القائمة الذي ما يزال قيد البحث، وبناءً على ذلك إما نَجِد القيمة التي نبحث عنها أو أن نضطَّر إلى إعادة تطبيق البحث الثنائي على النصف المُتبقي من عناصر القائمة. يُعدّ هذا توصيفًا تعاوديًا، ولهذا يُمكِننا تنفيذه بكتابة برنامجٍ فرعيٍ تعاودي recursive subroutine. قبل كتابة ذلك البرنامج، يجب علينا أخذ نقطتين مهمتين بالحسبان تُمثِلان حقائقًا عامةً عن البرامج الفرعية التعاودية. النقطة الأولى: تبدأ خوارزمية البحث الثنائي بفحص عنصر منتصف القائمة، ولكن ما الذي سيحدُث إذا كانت تلك القائمة فارغة؟ ببساطة، إذا لم jكْن هناك أي عناصر ضمن القائمة، فسيستحيل بالتأكيد فحص عنصر منتصف تلك القائمة. كنا قد أضفنا شرطًا مُسبقًا precondition بمقال كيفية كتابة برامج صحيحة باستخدام لغة جافا ينص على ضرورة أن تَكُون القائمة غير فارغة. وبناءً على ذلك، علينا تعديل الخوارزمية، بحيث تأخذ هذا الشرط المُسبَّق بالحسبان. وينقلنا هذا إلى السؤال التالي. النقطة الثانية: ما الذي ينبغي أن نفعله في تلك الحالة؟ الإجابة بسيطة، فإذا كانت القائمة فارغة، فيُمكِننا ببساطة استنتاج أن القيمة التي نبحث عنها غير موجودةٍ بالقائمة، أي أنه يُمكِننا إعادة الإجابة دون الحاجة لإجراء أي عملٍ آخر. يُعدّ وصولنا لقائمةٍ فارغة وفقًا لخوارزمية البحث الثنائي حالةً أساسيةً base case؛ حيث أن الحالة الأساسية لخوارزمية تعاودية، هي تلك الحالة المُمكن مُعالجتها مباشرةً دون الحاجة لإعادة تطبيق الخوارزمية تطبيقًا تعاوديًا. بالإضافة إلى ذلك، يُعدّ العثور على القيمة المطلوبة بعنصر منتصف القائمة وفقًا لخوارزمية البحث الثنائي حالةً أساسيةً أخرى، حيث نكون قد انتهينا دون الحاجة لأي إجراءٍ تعاوديٍ آخر. تتعلَّق النقطة الثانية بمعاملات parameters البرنامج الفرعي، حيث تُصاغ المشكلة عادةً بكوننا نبحث عن قيمةٍ معينة ضمن قائمة، وتستقبل النسخة الأصلية غير التعاودية non-recursive من برنامج البحث الثنائي تلك القائمة على أنها مصفوفة. في المقابل، لابُدّ أن تُمكِّننا النسخة التعاودية من تطبيق البرنامج الفرعي تطبيقًا تعاوديًا على جزءٍ معينٍ من القائمة الأصلية. بصياغةٍ أخرى، إذا كان البرنامج الفرعي الأصلي مُصمَّمًا للبحث ضمن كامل المصفوفة، فلا بُدّ للبرنامج الفرعي التعاودي أن يَكُون قادرًا على البحث ضِمْن جزءٍ معينٍ من المصفوفة، وبناءً على ذلك لا بُدّ أن يستقبل البرنامج الفرعي معاملاتٍ تُحدِّد ذلك الجزء من المصفوفة الذي ينبغي البحث ضمنه. ولكي تَكُون قادرًا على حل مشكلةٍ معينة حلًا تعاوديًا، فعادةً ما يَكُون من الضروري تَعمِيم المشكلة بعض الشيء. يوضح المثال التالي خوارزمية البحث الثنائي التعاودي التي تبحث عن قيمة مُحدٍَّدة ضمن جزءٍ من مصفوفة أعداد صحيحة. static int binarySearch(int[] A, int loIndex, int hiIndex, int value) { if (loIndex > hiIndex) { // 1 return -1; } else { // 2 int middle = (loIndex + hiIndex) / 2; if (value == A[middle]) return middle; else if (value < A[middle]) return binarySearch(A, loIndex, middle - 1, value); else // لابُدّ أن تكون القيمة أكبر من [A[middle return binarySearch(A, middle + 1, hiIndex, value); } } // end binarySearch() [1] جاء موضع البداية لنطاق البحث قبل موضع نهايته؛ أي ليس هناك أي عناصرٍ فعليةٍ ضمن ذلك النطاق، مما يعني أن القيمة غير موجودةٍ بالقائمة الفارغة. [2] انظر للموضع بمنتصف القائمة، فإذا كانت القيمة بذلك الموضع، أعِد هذا الموضع؛ أما إذا لم تكن، فابحث بالنصف الأول أو الثاني من القائمة بحثًا تعاوديًا. يُحدِّد المعاملان loIndex وhiIndex بالبرنامج الفرعي السابق جزء المصفوفة المطلوب البحث فيه. لاحظ أنه ما يزال بإمكاننا استدعاء binarySearch(A, 0, A.length - 1, value) للبحث ضمن المصفوفة بأكملها. في حال وقوع أي من الحالتين الأساسيتين base cases، أي عند عدم وجود أي عناصرٍ أخرى ضمن النطاق المخصص من فهارس المصفوفة، أو عندما تكون القيمة بمنتصف النطاق المُخصَّص هي نفسها القيمة المطلوب العثور عليها؛ فيمكن عندها للبرنامج الفرعي إعادة الإجابة تلقائيًا دون اللجوء إلى التعاود؛ بينما يضطر في الحالات الأخرى إلى الاستدعاء التعاودي recursive call لحساب الإجابة ثم يعيدها. يواجه الكثير من المبرمجين في البداية صعوبةً ليدركوا أن التعاود يعمل بنجاح فعليًا، حيث تَكمُن الفكرة في تَحقُّق أمرين لضمان عمل التعاود بالشكل السليم، أولهما أنه لا بُدّ من تخصيص واحدةٍ أو أكثر من الحالات الأساسية base cases المُمكِن معالجتها تلقائيًا دون اللجوء إلى التعاود. ثانيًا، عند تطبيق التعاود أثناء حل المشكلة، فلا بُدّ أن تكون المشكلة أصغر بطريقةٍ ما بكل مرة؛ وبتعبيرٍ آخر، لابُدّ أن تكون المشكلة بكل مرةٍ أقرب إلى واحدةٍ من الحالات الأساسية بالموازنة مع المشكلة الأصلية. يَعنِي ما سبق أنه إذا كنا نستطيع حلّ نُسخٍ أصغر من مشكلةٍ معينة، وكذلك تَقسِيم النسخ الكبيرة من تلك المشكلة إلى نسخٍ أصغر، فيمكننا حل تلك المشكلة مهما بلغ حجمها. وفي النهاية، سنتمكَّن من تقسيم النسخة الكبيرة من المشكلة عبر عدة خطوات إلى النسخة الأصغر من المشكلة؛ أي إلى واحدةٍ من الحالات الأساسية. في الواقع، يتطلّب ذلك الاحتفاظ بقدرٍ هائلٍ من التفاصيل، ولكن لحسن الحظ لست مطالبًا بإجراء أي من ذلك يدويًا، حيث يُجرِيه الحاسوب بدلًا منك. في المقابل، عليك فقط تحديد الخطوط العريضة أي الحالات الأساسية وكيفية تقسيم المشكلة الكبيرة إلى مشكلاتٍ صغيرة. يُجري الحاسوب كل ما هو مطلوب لتقسيم المشكلة الكبيرة إلى مشكلاتٍ أصغر عبر كثيرٍ من الخطوات، حتى يَصِل إلى أيٍ من الحالات الأساسية. ستدفعك محاولة التفكير بكيفية إجراء عملية التقسيم تفصيليًا إلى الجنون، وستشعر أن التعاود عمليةً معقدةً وصعبة، لكنه في الحقيقة أسلوبٌ رائعٌ وفعال، وعادةً ما يُعدّ الطريقة الأبسط لحل المشكلات المُعقدة. تذكَّر دومًا القاعدتين الأساسيتين: لا بُدّ من تخصيص واحدة أو أكثر من الحالات الأساسية base cases. لا بُدّ من تطبيق البرنامج الفرعي تطبيقًا تعاوديًا recursively على مشكلاتٍ أصغر من المشكلة الأصلية. يُعدّ انتهاك أي من هاتين القاعدتين أخطاءً شائعةً أثناء كتابة البرامج الفرعية التعاودية، وينتج عن انتهاك أي من القاعدتين عادةً حدوث تعاودٍ لا نهائي infinite recursion؛ أي يستمر البرنامج الفرعي باستدعاء نفسه مرةً بعد أخرى بصورةٍ لانهائية دون الوصول أبدًا إلى حالة أساسية. يتشابه التعاود اللانهائي نوعًا ما مع التكرار اللانهائي infinite loop، ولكنه يستهلك أيضًا من ذاكرة الحاسوب؛ حيث تتطلَّب كل عملية استدعاء تعاودي recursive call جزءًا من الذاكرة، وبالتالي ستَنفُد الذاكرة لدى الوقوع بتعاودٍ لا نهائي، ثم ينهار crash البرنامج، حيث ينهار البرنامج نتيجةً لحدوث اعتراض exception من النوع StackOverflowError بلغة جافا تحديدًا. مشكلة أبراج هانوي Hanoi درسنا حتى الآن خوارزمية البحث الثنائي، والتي يُمكِن تنفيذها بسهولة باستخدام حلقة while بدلًا من التعاود. سنتناول الآن مشكلةً يسهُل حلّها بالتعاود ويَصعُب بدونه، وهي مثالٌ نموذجيٌ يُعرَف باسم مشكلة أبراج هانوي The Towers of Hanoi، حيث تتكوَّن المشكلة من مجموعة أقراصٍ مختلفة الحجم موضوعةٍ على قاعدة بترتيبٍ تنازلي، والمطلوب هو تحريك مجموعة الأقراص من مكدسٍ Stack إلى آخر وفقًا لشرطين، هما: يُمكِن تحريك قرصٍ واحدٍ فقط بكل مرة. لا يُمكِن أبدًا وضع قرصٍ معينٍ فوق آخر أصغر منه. هناك أيضًا قرصٌ إضافيٌ ثالث يُمكِنك استخدامه، حيث يُظهِر النصف الأعلى من الصورة التالية الحالة المبدئية لعشرة أقراص، بينما يُوضِح النصف السفلي حالة الأقراص بعد إجراء عدة حركات. الصورة التالية مأخوذة من البرنامج التوضيحي TowersOfHanoiGUI.java الذي سنتعرض له جزئية لاحقة من هذه السلسلة، حيث سننشئ خلال ذلك البرنامج صورةً متحركةً animation لحل هذه المشكلة خطوةً بخطوة. تكمن المشكلة ببساطة في تحريك عشرة أقراصٍ من المكدس 0 إلى المكدس 1 وفقًا للشروط المذكورة أعلاه، ويُمكِن أيضًا استخدام المكدس 2 بمثابة موضعٍ مؤقت. يقودنا ذلك إلى السؤال التالي: هل يُمكننا تقسيم هذه المشكلة إلى مشكلاتٍ أصغر من نفس النوع أي تعميم المشكلة قليلًا؟ من البديهي أن يكون حجم المشكلة هو عدد الأقراص المطلوب تحريكها، فإذا كان هناك عدد N من الأقراص بالمكدس 0، فإننا نعرف أنه سيكون علينا بالنهاية تحريك القرص السفلي من المكدس 0 إلى المكدس 1. مع ذلك وفقًا للشروط، فلا بُدّ أن يكون أول عددٍ N-1 من الأقراص في المكدس 2، وبمجرد أن نُحرِّك القرص N إلى المكدس 1، فسيكون علينا تحريك عدد N-1 من الأقراص من المكدس 2 إلى المكدس 1 لإكمال الحل. لاحِظ أن مشكلة تحريك عدد N-1 من الأقراص هي من نفس نوع المشكلة الأصلية أي تحريك عدد Nمن الأقراص، باستثناء أنها نسخةٌ أصغر من المشكلة، حيث يُمثل ذلك بالضبط ما نريده لإجراء التعاود. علينا تعميم المشكلة قليلًا، حيث تتطلب المشكلات الأصغر تحريك الأقراص من المكدس 0 إلى المكدس 2، أو من المكدس 2 إلى المكدس 1، بدلًا من مجرد تحريكها من المكدس 0 إلى المكدس 1، وذلك يعني وجوب تمرير كلٍ من مكدس المصدر والوجهة إلى البرنامج الفرعي التعاودي المسؤول عن حل المشكلة. على الرغم من أن ذلك سيُمكِّننا من تحديد المكدس الاحتياطي ضمنيًا، إلا أنه سيبقى من المريح تمرير المكدس الاحتياطي صراحةً للبرنامج. ويُمثِل وجود قرص واحد فقط الحالة الأساسية، كم سيكون الحل ببساطة في هذه الحالة هو تحريك القرص ضمن خطوةٍ واحدة. تطبع شيفرة البرنامج الفرعي التالي تعليمات حل المشكلة تفصيليًا خطوةً بخطوة. static void towersOfHanoi(int disks, int from, int to, int spare) { if (disks == 1) { // هناك قرصٌ واحدٌ فقط للتحريك. عليك فقط أن تُحرِكه System.out.printf("Move disk 1 from stack %d to stack %d%n", from, to); } else { // 1 towersOfHanoi(disks-1, from, spare, to); System.out.printf("Move disk %d from stack %d to stack %d%n", disks, from, to); towersOfHanoi(disks-1, spare, to, from); } } [1] حَرِّك جميع الأقراص باستثناء قرصٍ واحدٍ فقط إلى المكدس الاحتياطي، ثم حرِّك القرص السفلي وضَع بقية الأقراص الأخرى فوقه. يعبِّر هذا البرنامج الفرعي عن الحل التعاودي البديهي، حيث يشتمل الاستدعاء التعاودي بكل مرةٍ على عددٍ أقل من الأقراص، كما أنه يُمكِن حل المشكلة بسهولةٍ في حالتها الأساسية base case؛ أي عند وجود قرصٍ واحدٍ فقط. لحل مشكلة تحريك عدد N من الأقراص من المكدس 0 إلى المكدس 1، ينبغي استدعاء البرنامج الفرعي على النحو التالي TowersOfHanoi(N,0,1,2)، ويُبيِّن لنا ما يلي خَرْج البرنامج الفرعي عند ضَبْط عدد الأقراص ليُساوِي 4. Move disk 1 from stack 0 to stack 2 Move disk 2 from stack 0 to stack 1 Move disk 1 from stack 2 to stack 1 Move disk 3 from stack 0 to stack 2 Move disk 1 from stack 1 to stack 0 Move disk 2 from stack 1 to stack 2 Move disk 1 from stack 0 to stack 2 Move disk 4 from stack 0 to stack 1 Move disk 1 from stack 2 to stack 1 Move disk 2 from stack 2 to stack 0 Move disk 1 from stack 1 to stack 0 Move disk 3 from stack 2 to stack 1 Move disk 1 from stack 0 to stack 2 Move disk 2 from stack 0 to stack 1 Move disk 1 from stack 2 to stack 1 يُبيِّن خَرْج البرنامج بالأعلى تفاصيل حل المشكلة، ولست بحاجةٍ إلى التفكير بطريقة الحل تفصيليًا، حيث من الصعب جدًا تتبُّع تفاصيل عمل الخوارزميات التعاودية بدقة على الرغم من سهولتها وأناقتها، وسيتولى الحاسوب عمومًا مهمة تنفيذ كل تلك التفاصيل. قد تفكر بما سيحدث في حال عدم تحقُّق الشرط المُسبَق precondition، الذي ينص على أن يكون عدد الأقراص موجبًا، حيث سيؤدي ذلك ببساطة إلى حدوث تعاودٍ لا نهائي infinite recursion. هناك قصةٌ متداولةٌ تشرح السبب وراء تسمية مشكلة أبراج هانوي بذلك الاسم. وفقًا لتلك القصة أُعطيَ مجموعةٌ من الرهبان المتواجدون ببرجٍ معزول قرب هانوي عدد 64 من الأقراص، وطُلِبَ منهم تحريك قرصٍ واحدٍ يوميًا وفقًا لنفس شروط المشكلة بالأعلى؛ وفي اليوم الذي سيكملون فيه تحريك جميع الأقراص من مكدسٍ إلى آخر، سينتهي الكون، لكن لا داعي للقلق، حيث ما يزال أمامنا الكثير من الوقت؛ فعدد الخطوات المطلوبة لحل مشكلة أبراج هانوي لعدد N من الأقراص هو 2N-1 أي 264-1 أي ما يَقرُب من 50,000,000,000,000 سنة. وفقًا لما تطرقنا له في المقال السابق، فإن زمن تشغيل run time خوارزمية أبراج هانوي هو Θ(2n)، حيث تُمثِل n عدد الأقراص المطلوب تحريكها، ونظرًا لنمو الدالة الأسية 2n بسرعةٍ كبيرة، فإنه من الممكن عمليًا حل مشكلة أبراج هانوي لعددٍ صغيرٍ من الأقراص فقط. بالإضافة إلى برنامج حل مشكلة أبراج هانوي بالأعلى، هناك أيضًا برنامجين توضيحين آخرين قد ترغب بإلقاء نظرةٍ عليهما، حيث يوفر كل برنامجٍ منهما توضيحًا مرئيًا لخوارزمية تعاودية. البرنامج الأول هو برنامج Maze.java، والذي يَستخدِم التعاود لحل متاهة؛ أما البرنامج الآخر فهو برنامج LittlePentominos.java، الذي يَستخدِم التعاود لحل نوعٍ معروفٍ من الألغاز. في الحقيقة، تستخدم الشيفرة المصدرية لتلك البرامج بعض التقنيات التي لن نتعرَّض الآن، ومع ذلك سيكون من المفيد أن تُشغِّلها وتشاهدها قليلًا. يُنشِئ البرنامج الأول متاهةً عشوائيةً، ثم يُحاول أن يحل تلك المتاهة بالعثور على مسارٍ عبر المتاهة من ركنها الأيسر العلوي إلى ركنها الأيمن السفلي. في الواقع، تتشابه مشكلة المتاهة كثيرًا مع مشكلة عدّ الكائنات blob-counting، التي سنتعرَّض لها لاحقًا ضمن هذا المقال، حيث يبدأ برنامج حل مشكلة المتاهة التعاودي من مربعٍ معين، ويَفْحَص جميع المربعات المجاورة من خلال إجراء استدعاءٍ تعاودي، وينتهي التعاود إذا تَمكَّن البرنامج من الوصول إلى الركن الأيمن السفلي للمتاهة. في حال لم يتمكَّن البرنامج من العثور على حلٍ من مربعٍ معين، فإنه سيَخرُج من ذلك المربع ويحاول بمكانٍ آخر. يشيع استخدام ذلك الأسلوب ويُعرَف باسم التتبع الخلفي التعاودي recursive backtracking. يُعدّ البرنامج الآخر LittlePentominos تنفيذًا للغزٍ تقليدي، حيث أن pentomino هو شكلٌ متصلٌ مُكوَّنٌ من خمسة مربعات متساوية الحجم. هناك بالتحديد 12 شكلًا محتملًا يُمكن تكوينه بتلك الطريقة بدون عدّ الانعكاسات أو الدورانات المحتملة للأشكال، والمطلوب هو وضع الأشكال الاثنى عشر داخل لوحة 8x8 مملوءة مبدئيًا بأربعة من تلك الأشكال. يفحص الحل التعاودي اللوحة المملوءة جزئيًا، كما يفحص جميع الأشكال المتبقية واحدةً تلو الأخرى، ويُحاول أن يضعها بالموضِع المتاح التالي من اللوحة؛ فإذا كان الموضع مناسبًا، فإنه يعاود استدعاء ذاته استدعاءً تعاوديًا لإكمال الحل؛ أما إذا فشل أثناء ذلك، فإنه سينتقل إلى الشكل التالي. لاحِظ أن ذلك يُعدّ مثالًا آخر على أسلوب التتبع الخلفي التعاودي، وستجد في بنتومينوس نسخةً أعم من البرنامج بميزاتٍ أخرى متعددة. خوارزمية ترتيب تعاودي سنناقش الآن كيفية كتابة خوارزميةٍ تعاوديةٍ لترتيب مصفوفة، وربما يُعَد هذا البرنامج الأكثر عمليًا حتى الآن. لقد تعرَّضنا لخوارزميتي الترتيب بالإدراج insertion sort والترتيب الانتقائي selection sort بمقال البحث والترتيب في المصفوفات Array في جافا، وعلى الرغم من سهولة هاتين الخوارزميتين وبساطتهما، إلا أنهما بطيئتان عند تطبيقهما على المصفوفات الكبيرة. في الواقع، تتوفَّر خوارزمياتٌ أخرى أسرع لترتيب المصفوفات، مثل الترتيب السريع Quicksort؛ التي أثبتت كونها واحدةً من أسرع خوارزميات الترتيب بغالبية الحالات، وهي في الواقع خوارزميةٌ تعاودية recursive algorithm. تعتمد خوارزمية الترتيب السريع Quicksort على فكرةٍ بسيطةٍ وذكية، فإذا كانت لدينا قائمةٌ من العناصر، فسنختار أي عنصر منها وسنُطلِق عليه اسم المحور pivot، حيث يُستخدم العنصر الأول مثل محور، وبعد ذلك سنُحرِك جميع العناصر التي قيمتها أصغر من قيمة المحور إلى بداية القائمة، كما سنُحرِك جميع العناصر التي قيمتها أكبر من قيمة المحور إلى نهاية القائمة. في الأخير، سنضع المحور بين مجموعتي العناصر، وبذلك نكون قد وضعنا المحور بموضعه الصحيح الذي ينبغي أن يحتله بالمصفوفة النهائية المُرتَّبة بالكامل؛ أي أننا لن نحتاج إلى تحريكه مجددًا، وسنُشير إلى تلك العملية باسم QuicksortStep. يُمكنك بسهولة أن ترى أن العملية السابقة QuicksortStep ليست تعاودية، وإنما تُستخدم فقط من قِبل خوارزمية الترتيب السريع Quicksort. تعتمد سرعة الخوارزمية الأساسية على كتابة تنفيذ سريع لعملية QuicksortStep، ونظرًا لأن تلك العملية ليست محور المناقشة، فإننا سنكتفي بعرض تنفيذٍ لها دون مناقشتها تفصيليًا. انظر الشيفرة التالية: static int quicksortStep(int[] A, int lo, int hi) { int pivot = A[lo]; // Get the pivot value. // 0 while (hi > lo) { // Loop invariant (See Subsection Subsection 8.2.3): A[i] <= pivot // for i < lo, and A[i] >= pivot for i > hi. while (hi > lo && A[hi] >= pivot) { // 1 hi--; } if (hi == lo) break; // 2 A[lo] = A[hi]; lo++; while (hi > lo && A[lo] <= pivot) { // 3 lo++; } if (hi == lo) break; // 4 A[hi] = A[lo]; hi--; } // end while // 5 A[lo] = pivot; return lo; } // end QuicksortStep [0] تُحدِّد الأعداد hi وlo نطاق الأعداد المطلوب فحصها. اُنقُص العدد hi وزِد العدد lo حتى يُصبحِا متساويين، وحرِّك الأعداد التي قيمتها أكبر من قيمة المحور بحيث تقع قبل hi، وحرِّك أيضًا الأعداد التي قيمتها أقل من قيمة المحور، بحيث تقع بعد lo. بالبداية، سيكون الموضع A[lo] متاحًا لأن قيمته قد نُسخَت إلى المتغير المحلي pivot. [1] حرِك hi للأسفل بعد الأعداد الأكبر من قيمة المحور، ولاحِظ أنه ليست هناك حاجةً لتحريك أيٍ من تلك الأعداد. [2] العدد A[hi] أقل من قيمة المحور، ولذلك يُمكِنك تحريكه إلى المساحة المتاحة A[lo]، وبالتالي سيُصبِح الموضع A[hi] مُتاحًا. [3] حرِك lo للأعلى قبل الأعداد الأقل من قيمة المحور. لاحِظ أنه ليس هناك حاجةً لتحريك أيٍ من تلك الأعداد. [4] العدد A[lo] أكبر من قيمة المحور، لذلك حرِكه إلى المساحة المتاحة A[hi] وبالتالي سيُصبِح الموضع A[lo] متاحًا. [5] ستُصبِح قيمة lo مساويةً لقيمة hi بهذه النقطة من البرنامج، كما ستتبقَّى مساحةٌ متاحةٌ لذلك الموضع بين الأعداد الأقل من قيمة المحور والأعداد الأكبر منه. ضع المحور بذلك المكان ثم أعِد موضعه. بمجرد حصولنا على التنفيذ السابق لعملية QuicksortStep، ستُصبِح خوارزمية الترتيب السريع Quicksort لترتيب قائمة من العناصر بسيطةً جدًا؛ فهي تتكوَّن من مجرد تطبيق عملية QuicksortStep على تلك القائمة، ثم تطبيق الخوارزمية تطبيقًا تعاوديًا على العناصر الواقعة على كلٍ من يسار الموضع الجديد للمحور ويمينه. سنحتاج أيضًا إلى تخصيص الحالات الأساسية base cases، وفي حال احتواء القائمة على عنصرٍ واحدٍ فقط أو عدم احتوائها على أية عناصر، فذلك يعني أنها مُرتَّبة فعلًا، أي لا حاجة لأي إجراءٍ تعاوديٍ آخر. static void quicksort(int[] A, int lo, int hi) { if (hi <= lo) { // 1 return; } else { // 2 int pivotPosition = quicksortStep(A, lo, hi); quicksort(A, lo, pivotPosition - 1); quicksort(A, pivotPosition + 1, hi); } } [1] طول القائمة يُساوي 0 أو 1، لذلك لسنا بحاجةٍ إلى أي إجراءاتٍ أخرى. [2] سنُطبِّق عملية quicksortStep للحصول على موضع المحور الجديد، ثم سنُطبِّق عملية quicksort تعاوديًا لترتيب تلك العناصر التي تسبق المحور، وتلك التي تليه. كان تعميم المشكلة ضروريًا، حيث كانت المشكلة الأصلية هي ترتيب كامل المصفوفة، بينما ضُبطَت الخوارزمية التعاودية recursive algorithm، بحيث تُصبِح قادرةً على ترتيب جزءٍ معينٍ من المصفوفة. يُمكنك استخدام البرنامج الفرعي quickSort() لترتيب مصفوفةٍ بأكملها من خلال استدعاء quicksort(A, 0, A.length - 1). تُعدّ خوارزمية الترتيب السريع Quicksort مثالًا شيقًا على تحليل الخوارزميات analysis of algorithms؛ لأن زمن تنفيذ الحالة الوسطى average case يختلف تمامًا عن زمن تنفيذ الحالة الأسوأ worst case. سنتناول هنا تحليلًا عاميًا informal للخوارزمية، فبالنسبة للحالة الوسطى، ستُقسِّم عملية quicksortStep المشكلة إلى مشكلتين فرعيتين ويكون حجمهما في المتوسط متساويًا؛ وذلك يعني أننا نُقسِّم مشكلةً بحجم 'n' إلى مشكلتين بحجم 'n/2' تقريبًا، ثم نُقسِّمهما إلى أربع مشكلات بحجم 'n/4' تقريبًا، وهكذا. نظرًا لأن حجم المشكلة يقل إلى النصف في كل مرحلة، فسيكون لدينا عدد log(n) من المراحل. يتناسب القدر المطلوب من المعالجة بكل مرحلةٍ من تلك المراحل مع 'n'؛ أي تُفحَص بالمرحلة الأولى جميع عناصر المصفوفة ويُحتمَل تحريكها. ستكون لدينا بالمرحلة الثانية مشكلتين فرعيتين، حيث تقع جميع عناصر المصفوفة عدا واحدٍ ضمن واحدةٍ من المشكلتين، وبالتالي ستُفحَص جميع تلك العناصر ويُحتمَل تحريكها، وبذلك يكون لدينا إجماليًا عدد n من الخطوات في كلتا المشكلتين الفرعيتين. بالمرحلة الثالثة، ستكون لدينا أربعة مشكلات فرعية وعدد n من الخطوات، وهذا يعني أن لدينا عدد log(n) من المراحل تقريبًا؛ حيث تشتمل كل مرحلةٍ على عدد n من الخطوات، ولهذا يكون زمن تشغيل الحالة الوسطى average case لخوارزمية الترتيب السريع هو Θ(n*log(n)). يَفترِض هذا التحليل تقسيم عملية quicksortStep المشكلة إلى أجزاءٍ متساويةٍ تقريبيًا. وفي الحالة الأسوأ worst case، سيُقسِّم كل تطبيق لعملية quicksortStep مشكلةً بحجم n إلى مشكلتين، حيث تكون الأولى بحجم 0 والأخرى بحجم n-1. يحدث ذلك عندما يكون موضع عنصر المحور ببداية أو نهاية المصفوفة بعد الترتيب، وفي هذه الحالة سيكون لدينا عدد n من المراحل، ويكون زمن تشغيل الحالة الأسوأ هو Θ(n<sup>2</sup>). يندر وقوع الحالة الأسوأ؛ حيث تعتمد على أن تَكون عناصر المصفوفة مُرتَّبةً بطريقةٍ خاصةٍ جدًا، لذلك يُعدّ متوسط أداء خوارزمية الترتيب السريع Quicksort في العموم جيدًا جدًا، باستثناء بعض الحالات النادرة. يُعدّ كَوْن المصفوفة مُرتَّبة بالكامل أو تقريبًا، واحدةً من تلك الحالات النادرة، وإذا أردنا توخّي الدقة، فهي ليست بتلك الحالة النادرة عمليًا. سيستغرق تطبيق خوارزمية الترتيب السريع المُوضَّحة أعلاه على مصفوفةٍ كبيرةٍ مُرتَّبة وقتًا طويلًا، ويُمكِننا في الغالب تَجنُّب حدوث ذلك باختيار موضع المحور عشوائيًا بدلًا من استخدام العنصر الأول دائمًا. تتوفَّر خوازرميات ترتيبٍ أخرى بزمن تشغيلٍ يُساوِي Θ(n*log(n)) بالحالتين الأسوأ والوسطى؛ واحدةٌ منها هي خوارزمية الترتيب بالدمج MergeSort، والتي تُعدّ من الخوارزميات سهلة الفهم، ويُمكِنك البحث عنها إذا كنت مهتمًا. عد الكائنات Blob سنفحص الآن مثالًا آخرًا، حيث سنَعُدّ خلاله عدد المربعات الموجودة ضمن تكتلٍ من المربعات المتصلة، وسنُطلِق على ذلك التكتل اسم كائن blob. يعرض البرنامج التوضيحي Blobs.java شبكةً grid من المربعات الصغيرة مُلوّنةً بالأبيض والرمادي والأحمر. وتُظهِر الصورة التالية لقطة شاشةٍ من البرنامج، والتي يُمكِنك أن ترى خلالها شبكة المربعات مع بعض أزرار التحكم. تُعدّ المربعات الحمراء والرمادية مملوءةً أما المربعات البيضاء فهي فارغة. سنُعرِّف الكائن blob لهذا المثال على النحو التالي: يتكوَّن أي كائنٍ من مربعٍ معين مملوءٍ، بالإضافة إلى جميع المربعات المملوءة التي يُمكِن الوصول إليها من خلال ذلك المربع الأصلي، وذلك عن طريق خلال التحرك لأعلى، أو لأسفل، أو لليمين، أو لليسار عبر مربعاتٍ مملوءةٍ أخرى. إذا نقر المُستخدِم على أي مربعٍ مملوءٍ بالبرنامج، فسيَعُدّ الحاسوب عدد المربعات التي يشتملها الكائن المُتضمِّن للمربع المنقور عليه، وسيُبدِّل لون تلك المربعات إلى الأحمر، حيث أظهرت الصورة السابقة إحدى الكائنات باللون الأحمر. يُوفِّر البرنامج أيضًا أزرار تحكم أخرى مثل زر كائنات جديدة New Blobs، الذي يؤدي النقر عليه إلى إنشاء نمطٍ عشوائي جديدٍ بالشبكة، كما تتوفَّر أيضًا قائمةٌ لتخصيص النسبة التقريبية للمربعات التي ينبغي ملؤها بالنمط الجديد، حيث ستؤدي زيادة تلك النسبة إلى الحصول على كائناتٍ بأحجام أكبر. إلى جانب ما سبق، يتوفَّر أيضًا زر عدّ الكائنات Count the Blobs، الذي سيحسُب بطبيعة الحال عدد الكائناتٍ المختلفة الموجودة بالنمط. يعتمد البرنامج على التعاود recursion لحساب عدد المربعات التي يحتويها كائنٌ blob معين؛ فإذا لم يستخدم البرنامج التعاود، فستكون المهمة صعبة التنفيذ. على الرغم من تسهيل التعاود لحل تلك المشكلة نسبيًا، فما يزال من الضروري استخدام تقنيةٍ أخرى جديدة يشيع استخدامها بعددٍ من التطبيقات الأخرى. سنُخزِّن بيانات شبكة المربعات بمصفوفةٍ ثنائية الأبعاد من القيم المنطقية boolean boolean[][] filled; تكون قيمة filled[r][c] مساويةً للقيمة المنطقية true إذا كان المربع بالصف r والعمود c مملوءًا. وسنُخزِّن عدد صفوف الشبكة بمتغير نسخة instance variable اسمه rows، بينما سنُخزِّن عدد الأعمدة بمتغير النسخة columns. يستخدم البرنامج تابع نسخةٍ تعاودي recursive instance method اسمه getBlobSize(r,c) لعدّ عدد المربعات بكائن، حيث يستطيع التابع الاستدلال على الكائن المطلوب الذي عدّ عدد مربعاته من خلال المعاملين r وc؛ أي عليه أن يَعُدّ عدد مربعات الكائن المُتضمِن للمربع الواقع بالصف r والعمود c، وإذا كان المربع بالموضِع (r,c) فارغًا، فستكون الإجابة ببساطة صفرًا؛ أما إذا لم يَكْن كذلك، فينبغي للتابع getBlobSize() أن يَعُدّ جميع المربعات المملوءة المُمكن الوصول إليها من المربع الموجود بالموضِع (r,c). تتلخَّص الفكرة باستدعاء getBlobSize() استدعاءً تعاوديًا لحساب عدد المربعات المملوءة المُمكن الوصول إليها من المواضع المجاورة، أي (r+1,c) و(r-1,c) و(r,c+1) و(r,c-1). بحساب حاصل مجموع تلك الأعداد، ثم زيادتها بمقدار الواحد لعدّ المربع بالموضِع (r,c) نفسه، نكون قد حصلنا على العدد الكلي للمربعات المملوءة المُمكِن الوصول إليها من الموضع (r,c). تعرض الشيفرة التالية تنفيذًا لتلك الخوارزمية، ولكنها تعاني من عيبٍ خطر؛ فهي ببساطة تؤدي إلى حدوث تعاودٍ لا نهائي infinite recursion. int getBlobSize(int r, int c) { // BUGGY, INCORRECT VERSION!! // 1 if (r < 0 || r >= rows || c < 0 || c >= columns) { // هذا الموضع غير موجودٍ بالشبكة، أي ليس هنالك أي كائنٍ بذلك الموضع // أعد كائنًا حجمه يساوي صفر return 0; } if (filled[r][c] == false) { // المربع ليس جزءًا من الكائن، لذلك أعد القيمة صفر return 0; } // 2 int size = 1; size += getBlobSize(r-1,c); size += getBlobSize(r+1,c); size += getBlobSize(r,c-1); size += getBlobSize(r,c+1); return size; } // end INCORRECT getBlobSize() [1] لاحِظ أن هذه الطريقة غير صحيحة، حيث تحاول عَدّ كل المربعات المملوءة التي يُمكِن الوصول إليها من الموضع (r,c). [2] عَدّ المربع بذلك الموضع، ثم عَدّ عدد الكائنات المتصلة بذلك المربع ارتباطًا أفقيًا أو رأسيًا لسوء الحظ، سيَعُدّ البرنامج بالأعلى نفس المربع أكثر من مرة، فإذا تضمّن الكائن مربعين على الأقل، فسيحاول البرنامج عدّ كل مربعٍ منهما بصورةٍ لانهائية. لنتخيل أننا نقف بالموِضِع (r,c) ونحاول أن نتبّع التعليمات، حيث تخبرنا التعليمة الأولى بأنه علينا التحرك للأعلى بمقدار صفٍ واحدٍ، ثم نُطبِق نفس الإجراء على ذلك المربع. بينما نُنفِّذ ذلك الإجراء، سيتعيّن علينا أن نتحرك لأسفل بمقدار صفٍ واحد، ونُطبِق نفس الإجراء مرةً أخرى، ولكن سيُعيدنا ذلك إلى الموضع (r,c) مجددًا، حيث سيتعين علينا التحرك لأعلى مجددًا، وهكذا. يُمكِننا أن نرى بوضوحٍ أننا سنستمر بذلك إلى الأبد، لذلك علينا أن نتأكَّد من أننا نُعالِج كل مربعٍ ونَعُدّه مرةً واحدةً فقط حتى لا ينتهي بنا الحال جيئةً وذهابًا. يَكْمُن الحل بأن نترك أثرًا من القيم المنطقية boolean مثل علامةٍ لتمييز المربعات التي زرناها فعليًا. بمجرد وضع تلك العلامة على مربعٍ معين، فلن نُعالجه مرةً أخرى، وسيقل بذلك عدد المربعات المتبقية غير المُعالَجة ونكون قد أحرزنا تقدمًا بتقليل حجم المشكلة، وعليه نتجنَّب حدوث تعاودٍ لا نهائي. سنُعرِّف مصفوفةً أخرى من القيم المنطقية visited[r][c] لتمييز المربعات التي زارها البرنامج وعالجها بالفعل، حيث أنه من الضروري بالطبع ضَبْط قيم جميع عناصر تلك المصفوفة إلى القيمة المنطقية false قبل استدعاء getBlobSize(). عندما يواجه التابع getBlobSize() مربعًا لم يزره من قبل، فسيَضبُط القيمة المقابلة لذلك المربع بالمصفوفة visited إلى القيمة المنطقية true؛ وفي المقابل، عندما يواجه التابع getBlobSize() مربعًا زاره من قبل، فعليه ببساطةٍ تخطيه دون إجراء أي معالجةٍ أخرى. في الحقيقة، يَكثُر استخدام ذلك الأسلوب من تمييز العناصر المُعالَجَة من غير المُعالجة بالخوارزميات التعاودية recursive algorithms. اُنظُر النسخة المُعدَّلة من التابع getBlobSize(). int getBlobSize(int r, int c) { if (r < 0 || r >= rows || c < 0 || c >= columns) { // هذا الموضع غير موجود بالشبكة، أي ليس هنالك أي كائنٍ بذلك الموضع // أعد كائنًا حجمه يساوي صفر return 0; } if (filled[r][c] == false || visited[r][c] == true) { // المربع ليس جزءًا من الكائن أو عُدّ من قبل، لذلك أعِد صفرًا return 0; } visited[r][c] = true; // 1 int size = 1; // 2 size += getBlobSize(r-1,c); size += getBlobSize(r+1,c); size += getBlobSize(r,c-1); size += getBlobSize(r,c+1); return size; } // end getBlobSize() [1] ضع علامةً تُبيِّن أن البرنامج زار هذا الموضع حتى لا نَعُدّه مرةً أخرى أثناء الاستدعاءات التعاودية. [2] عدّ المربع بذلك الموضع، ثم عدّ عدد الكائنات المتصلة بذلك المربع ارتباطًا أفقيًا أو رأسيًا. يَستخدِم البرنامج التابع المُعرَّف أعلاه لتحديد حجم كائنٍ معين عندما ينقُر المُستخدِم على مربعٍ ينتمي لذلك الكائن. بعد انتهاء التابع getBlobSize() من مهمته، ستكون المصفوفة visited مضبوطةً بحيث تُشير إلى جميع مربعات الكائن. بناءً على ذلك، سيُظهِر التابع المسؤول عن رسم الشبكة المربعات التي قد زارها باللون الأحمر، وعليه سيُصبِح الكائن مرئيًا. يُستخدَم التابع getBlobSize() أيضًا لعدّ عدد الكائنات، كما هو موضحٌ في الشيفرة التالية. void countBlobs() { int count = 0; // عدد الكائنات // 1 for (int r = 0; r < rows; r++) for (int c = 0; c < columns; c++) visited[r][c] = false; // 2 for (int r = 0; r < rows; r++) for (int c = 0; c < columns; c++) { if (getBlobSize(r,c) > 0) count++; } draw(); // 3 message.setText("The number of blobs is " + count); } // end countBlobs() [1] أولًا، امسح المصفوفة visited. سيحدِّد التابع getBlobSize() كل مربعٍ مملوءٍ مر عليه من خلال ضبط عنصر المصفوفة المقابل إلى القيمة المنطقية true. وبمجرد زيارة مربعٍ معين ووضع علامةٍ عليه، فسيبقى كذلك إلى أن ينتهي التابع من عدّ كل الكائنات، وسيمنع هذا عدّ نفس الكائن أكثر من مرة. [2] استدعِ التابع getBlobSize() لكل موضعٍ في الشبكة من أجل حساب حجم الكائن الموجود بذلك الموضع؛ فإذا كان حجمه لا يُساوي الصفر، فزِد قيمة العداد بمقدار الواحد. لاحِظ أنه في حالة عُدنا إلى موضعٍ ينتمي لكائنٍ زرناه من قبل، فسيعيد التابع getBlobSize() القيمة صفر، وبذلك لن نَعُدّ ذلك الكائن مرةً أخرى. [3] أعِد رسم شبكة المربعات. لاحِظ أن كل المربعات المملوءة ستكون باللون الأحمر. ترجمة -بتصرّف- للقسم Section 1: Recursion من فصل Chapter 9: Linked Data Structures and Recursion من كتاب Introduction to Programming Using Java. اقرأ أيضًا كيفية كتابة برامج صحيحة باستخدام لغة جافا تطبيق عملي: بناء لعبة ورق في جافا التعاود recursion والمكدس stack في جافاسكربت
-
تقدّم هذه السلسلة، هياكل البيانات 101، ثلاثة موضوعات: هياكل البيانات Data Structures: سنناقش هياكل البيانات التي يُوفِّرها إطار التجميعات في لغة جافا Java Collections Framework والتي تُختصرُ إلى JCF، وسنتعلم كيفية استخدام بعض هياكل البيانات مثل القوائم والخرائط وسنرى طريقة عملها. تحليل الخوارزميات ِAlgorithms: سنتعرض لتقنياتٍ تساعد على تحليل الشيفرة وعلى التنبؤ بسرعة تنفيذها ومقدار الذاكرة الذي تتطلَّبه. استرجاع المعلومات Information retrieval: سنَستخدِم الموضوعين السابقين: هياكل البيانات والخوارزميات لإنشاء محرك بحثٍ بسيطٍ عبر الإنترنت، وذلك لنستفيد منهما عمليًّا ونجعل التمارين أكثر تشويقًا. وسنناقش تلك الموضوعات وفقًا للترتيب التالي: سنبدأ بالواجهة List، وسنكتب صنفين ينفذ كلٌ منهما تلك الواجهة بطريقة مختلفة، ثم سنوازن بين هذين الصنفين اللذين كتبناهما وبين صنفي الجافا ArrayList وLinkedList. بعد ذلك، سنقدِّم هياكل بيانات شجريّة الشكل، ونبدأ بكتابة شيفرة التطبيق الأول. حيث سيقرأ هذا التطبيق صفحاتٍ من موقع Wikipedia، ثم يُحلِّل محتوياتها ويعطي النتيجة على هيئة شجرة، وفي النهاية سيمر عبر تلك الشجرة بحثًا عن روابطَ ومزايا أخرى. سنَستخدِم تلك الأدوات لاختبار الفرضيّة الشهيرة "الطريق إلى الفلسفة" Getting to Philosophy. سنتطرق للواجهة Map وصنف الجافا HashMap المُنفِّذ لها، ثم سنكتب أصنافًا تُنفِّذ تلك الواجهة باستخدام جدول hash وشجرة بحثٍ ثنائيّة. أخيرًا، سنستخدِم تلك الأصناف وبعض الأصناف الأخرى التي سنتناولها عبر الكتاب لتنفيذ محرك بحث عبر الإنترنت. سيكون هذا المحرك بمنزلة زاحف crawler يبحث عن الصفحات ويقرؤها، كما أنه سيُفهرِس ويُخزِّن محتويات صفحات الإنترنت بهيئةٍ تُمكِّنه من إجراء عملية البحث فيها بكفاءة، كما أنه سيَعمَل مثل مُسترجِع للمعلومات، أي أنه سيَستقبِل استفساراتٍ من المُستخدِم ويعيد النتائج ذات الصلة. ولنبدأ الآن. لماذا هنالك نوعان من الصنف List؟ عندما يبدأ المبرمجون باستخدام إطار عمل جافا للتجميعات، فإنهم عادةً يحتارون أي الصنفين يختارون ArrayList أم LinkedList. فلماذا تُوفِّر جافا تنفيذين implementations للواجهة List؟ وكيف ينبغي الاختيار بينهما؟ سنجيب عن تلك الأسئلة خلال الفصول القليلة القادمة. سنبدأ باستعراض الواجهات والأصناف المُنفِّذَة لها، وسنُقدِّم فكرة البرمجة إلى واجهة. سننفِّذ في التمارين القليلة الأولى أصنافًا مشابهةً للصنفين ArrayList وLinkedList، لكي نتمكَّن من فهم طريقة عملهما، وسنرى أن لكل منهما عيوبًا ومميزاتٍ، فبعض العمليات تكون أسرع وتحتاج إلى مساحة أقل عند استخدام الصنف ArrayList، وبعضها الآخر يكون أسرع وأصغر عند استخدام الصنف LinkedList، وبهذا يمكن القول: إن تحديد الصنف الأفضل لتطبيقٍ معيّنٍ يعتمد على نوعية العمليات الأكثر استخدامًا ضمن ذلك التطبيق. الواجهات في لغة جافا تُحدّد الواجهة بلغة جافا مجموعةً من التوابع methods، ولا بُدّ لأي صنفٍ يُنفِّذ تلك الواجهة أن يُوفِّر تلك التوابع. على سبيل المثال، انظر إلى شيفرة الواجهة Comparable المُعرَّفة ضمن الحزمة java.lang: public interface Comparable<T> { public int compareTo(T o); } يَستخدِم تعريف تلك الواجهة معاملَ نوعٍ type parameter اسمه T، وبذلك تكون تلك الواجهة من النوع المُعمَّم generic type، وينبغي لأي صنفٍ يُنفِّذ تلك الواجهة أن: يُحدد النوع الذي يشير إليه معامل النوع T. يُوفِّر تابعًا اسمه compareTo يَستقبِل كائنًا كمعامل parameter ويعيد قيمةً من النوع int. وفي مثالٍ على ما نقول، انظر إلى الشيفرة المصدرية للصنف java.lang.Integer فيما يلي: public final class Integer extends Number implements Comparable<Integer> { public int compareTo(Integer anotherInteger) { int thisVal = this.value; int anotherVal = anotherInteger.value; return (thisVal<anotherVal ? -1 : (thisVal==anotherVal ? 0 : 1)); } // other methods omitted } يمتدُّ هذا الصنف من الصنف Number، وبالتالي فإنه يَرِث التوابع ومتغيرات النُّسَخ instance variables المُعرَّفة في ذلك الصنف، كما أنه يُنفِّذ أيضًا الواجهة Comparable<Integer>، ولذلك فإنه يُوفِّر تابعًا اسمه compareTo ويَستقبِل معاملًا من النوع Integer ويُعيد قيمةً من النوع int. عندما يُصرِّح صنفٌ معيّنٌ بأنه يُنفِّذ واجهةً معينة، فإن المُصرِّف compiler يتأكَّد من أن ذلك الصنف يُوفِّر جميع التوابع المُعرَّفة في تلك الواجهة. لاحِظ أنّ تنفيذ التابع compareTo الوارد في الأعلى يَستخدِم عاملًا ثلاثيًّا ternary operator يُكتَب أحيانًا على النحو التالي ?:. إذا لم يكن لديك فكرةٌ عن العوامل الثلاثية، فيُمكِنك قراءة مقال القيم والأنواع والعوامل في جافاسكربت. الواجهة List يحتوي إطار التجميعات في لغة جافا JCF على واجهة اسمها List، ويُوفِّر تنفيذين لها هما ArrayList وLinkedList. تُعرِّف تلك الواجهة ما ينبغي أن يكون عليه الكائن لكي يُمثِل قائمةً من النوع List، ومن ثمّ فلا بُدّ أن يُوفِّر أي صنفٍ يُنفِّذ تلك الواجهة مجموعةً محددةً من التوابع، منها add وget وremove، بالإضافة إلى 20 تابعًا آخر. يوفّر كلا الصنفين ArrayList وLinkedList تلك التوابع، وبالتالي يُمكِن التبديل بينهما. ويَعنِي ذلك أنه في حالة وجود تابعٍ مُصمَّمٍ ليَعمَل مع كائنٍ من النوع List، فإن بإمكانه العمل أيضًا مع كائنٍ من النوع ArrayList أو من النوع LinkedList، أو من أيّ نوعٍ آخرَ يُنفِّذ الواجهة List. يُوضِّح المثال التالي تلك الفكرة: public class ListClientExample { private List list; public ListClientExample() { list = new LinkedList(); } private List getList() { return list; } public static void main(String[] args) { ListClientExample lce = new ListClientExample(); List list = lce.getList(); System.out.println(list); } } كما نرى، لا يقوم الصنف ListClientExample بعملٍ مفيد، غير أنه يحتوي على بعض العناصر الضرورية لتغليف قائمة من النوع List، إذ يتضمَّن متغير نسخةٍ من النوع List. سنستخدِم هذا الصنف لتوضيح فكرةٍ معينة، ثم سنحتاج إلى استخدامه في التمرين الأول. يُهيئ باني الصنف ListClientExample القائمة list باستنساخ instantiating -أي بإنشاء- كائنٍ جديدٍ من النوع LinkedList، بينما يعيد الجالب getList مرجعًا reference إلى الكائن الداخليّ المُمثِل للقائمة، في حين يحتوي التابع main على أسطرٍ قليلةٍ من الشيفرة لاختبار تلك التوابع. النقطة الأساسية التي أردنا الإشارة إليها في هذا المثال هو أنه يحاول استخدام List ، دون أن يلجأ لتحديد نوع القائمة هل هي LinkedList أم ArrayList ما لم تكن هناك ضرورة، فكما نرى متغير النسخة كيف أنه مُعرَّف ليكون من النوع List، كما أن التابع getList يعيد قيمةً من النوع List، دون التطرق لنوع القائمة في أيٍّ منهما. وبالتالي إذا غيرّت رأيك مستقبلًا وقررت أن تَستخدِم كائنًا من النوع ArrayList، فكل ما ستحتاج إليه هو تعديل الباني دون الحاجة لإجراء أي تعديلاتٍ أخرى. تُطلَق على هذا الأسلوب تسميةُ البرمجة المعتمدة على الواجهات أو البرمجة إلى واجهة. تجدر الإشارة هنا إلى أنّ الكلام هنا عن الواجهات بمفهومها العام وليس مقتصرًا على الواجهات بلغة جافا. في أسلوب البرمجة المعتمدة على الواجهات، تعتمد الشيفرة المكتوبة على الواجهات فقط مثل List، ولا تعتمد على تنفيذاتٍ معيّنةٍ لتلك الواجهات، مثل ArrayList. وبهذا، ستعمل الشيفرة حتى لو تغيّر التنفيذ المعتمد عليها في المستقبل. وفي المقابل، إذا تغيّرت الواجهة، فلا بُدّ أيضًا من تعديل الشيفرة التي تعتمد على تلك الواجهة، ولهذا السبب يتجنَّب مطورو المكتبات تعديل الواجهات إلا عند الضرورة القصوى. تمرين 1 نظرًا لأن هذا التمرين هو الأول، فقد حرصنا على تبسيطه. انسَخ الشيفرة الموجودة في القسم السابق، وأجرِ التبديل التالي: ضع الصنف ArrayList بدلًا من الصنف LinkedList. لاحظ هنا أن الشيفرة تُطبِّق مبدأ البرمجة إلى واجهة، ولذا فإنك لن تحتاج لتعديل أكثر من سطرٍ واحدٍ فقط وإضافة تعليمة import. لكن قبل كل شيء، يجب ضبط بيئة التطوير المستخدمة؛ كما يجب أيضًا أن تكون عارفًا بكيفية تصريف شيفرات جافا وتشغيلها لكي تتمكَّن من حل التمارين. وقد طُورَّت أمثلة هذا الكتاب باستخدام الإصدار السابع من عدة تطوير جافا Java SE Development Kit، فإذا كنت تَستخدِم إصدارًا أحدث، فينبغي أن يَعمَل كل شيءٍ على ما يرام؛ أما إذا كنت تَستخدِم إصدارًا أقدم، فربما لا تكون الشيفرة متوافقةً مع عدة التطوير لديك. يُفضّل استخدام بيئة تطوير تفاعلية IDE لأنها تُوفِّر مزايا إضافية مثل فحص قواعد الصياغة syntax والإكمال التلقائي لتعليمات الشيفرة وتحسين هيكلة الشيفرة المصدرية refactoring، وهذا من شأنه أن يُساعدك على تجنُّب الكثير من الأخطاء، وعلى العثور عليها بسرعة إن وُجِدت، ولكن إذا كنت متقدمًا بطلب وظيفة في شركة ما مثلًا وتنتظرك مقابلة عمل تقنية، فهذه الأدوات لن تكون تحت تصرّفك غالبًا في أثناء المقابلة، ولهذا لعلّ من الأفضل التعوّد على كتابة الشيفرة بدونها أيضًا. إذا لم تكن قد حمَّلت الشيفرة المصدرية للكتاب إلى الآن، فانظر إلى التعليمات في القسم 0.1. ستَجِد الملفات والمجلدات التالية داخل مجلد اسمه code: build.xml: هو ملف Ant يساعد على تصريف الشيفرة وتشغيلها. lib: يحتوي على المكتبات اللازمة لتشغيل الأمثلة (مكتبة JUnit فقط في هذا التمرين). src: يحتوي على الشيفرة المصدرية. إذا ذهبت إلى المجلد src/com/allendowney/thinkdast فستجد ملفات الشيفرة التالية الخاصة بهذا التمرين: ListClientExample.java: يحتوي على الشيفرة المصدرية الموجودة في القسم السابق. ListClientExampleTest.java: يحتوي على اختبارات JUnit للصنف ListClientExample. راجع الصنف ListClientExample وبعد أن تتأكَّد أنك فهمت كيف يعمل، صرِّفه وشغّله؛ وإذا كنت تستخدم أداة Ant، فاذهب إلى مجلد code ونفِّذ الأمر ant ListClientExample. ربما تتلقى تحذيرًا يشبه التالي: List is a raw type. References to generic type List<E> should be parameterized. سبب ظهور هذا التحذير هو أننا لم نُحدّد نوع عناصر القائمة، وقد فعلنا ذلك بهدف تبسيط المثال، لكن يُمكِن حل إشكاليّة هذا التحذير بتعديل كل List أو LinkedList إلى List<Integer> أو LinkedList<Integer> على الترتيب. يُجرِي الصنف ListClientExampleTest اختبارً واحدًا، يُنشِئ من خلاله كائنًا من النوع ListClientExample، ويَستدعِي تابعه الجالب getList، ثم يَفحَص ما إذا كانت القيمة المعادة منه هي كائن من النوع ArrayList. سيفشَل هذا الاختبار في البداية لأن التابع سيعيد قيمةً من النوع LinkedList لا من النوع ArrayList، لهذا شغِّل الاختبار ولاحظ كيف أنه سيفشل. والآن لنعدّل الشيفرة كما يلي: ضعLinkedList بدلًا من ArrayList ضمن الصنف ListClientExample، وربما تحتاج أيضًا إلى إضافة تعليمة import. صرِّف الصنف ListClientExample وشغِّله، ثم شغِّل الاختبار مرةً أخرى. يُفترضُ أن ينجح الاختبار بعد هذا التعديل. إن سبب نجاح هذا الاختبار هو تعديلك للتسمية LinkedList في باني الصنف، دون تعديلٍ لاسم الواجهة List في أي مكانٍ آخر. لكن ماذا سيحدث لو فعلت؟ دعنا نجرب. عدِّل اسم الواجهة List في مكان واحد أو أكثر إلى الصنف ArrayList، عندها ستجد أن البرنامج ما يزال بإمكانه العمل بشكل صحيح، ولكنه الآن يحدد تفاصيلَ زائدةً عن الحاجة، وبالتالي إذا أردت أن تُبدِّل الواجهة مرةً أخرى في المستقبل، فستضطّر إلى إجراء تعديلاتٍ أكثر على الشيفرة. تُرى، ماذا سيحدث لو اِستخدَمت List بدلًا من ArrayList داخل باني الصنف ListClientExample؟ ولماذا لا تستطيع إنشاء نسخة من List؟ ترجمة -بتصرّف- للفصل Chapter 1: Interfaces من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: مدخل إلى تحليل الخوارزميات الدليل السريع للغة البرمجة Java واجهة المستخدم الحديثة في جافا واجهة برمجة التطبيقات والحزم والوحدات والتوثيق Javadoc في جافا الواجهات Interfaces في جافا
-
ركّزنا في المقالات السابقة من هذه السلسلة على صحة البرامج، وإلى جانب ذلك، تُعَد مشكلة الكفاءة efficiency من المشاكل المهمة كذلك، فعندما نحلِّل كفاءة برنامجٍ ما، فعادةً ما تُطرح أسئلةٌ مثل كم من الوقت سيستغرقه البرنامج؟ وهل هناك طريقةٌ أخرى للحصول على نفس الإجابة ولكن بطريقةٍ أسرع؟ وعمومًا دائمًا ما ستكون كفاءة البرنامج أقلّ أهميةً من صحته؛ فإذا لم تهتم بصحة البرنامج، فيمكنك إذًا أن تشغّله بسرعةٍ، ولكن قلّما سيهتم به أحدٌ. كذلك لا توجد أي فائدةٍ من برنامجٍ يستغرق عشرات الآلاف من السنين ليعطيك إجابةً صحيحةً. يشير مصطلح الكفاءة عمومًا إلى الاستخدام الأمثل لأي مورِد resource بما في ذلك الوقت وذاكرة الحاسوب ونطاق التردد الشبكي، وسنركّز في هذا المقال على الوقت، والسؤال الأهم الذي نريد الإجابة عليه هو ما الوقت الذي يستغرقه البرنامج ليُنجز المَهمّة الموكلة إليه؟ في الواقع، ليس هناك أي معنىً من تصنيف البرامج على أنها تعمل بكفاءةٍ أم لا، وإنما يكون من الأنسب أن نوازن بين برنامجين صحيحين ينفّذان نفس المهمة لنعرف أيًا منهما أكثر كفاءةً من الآخر، بمعنى أيهما ينجز مهمّته بصورةٍ أسرع، إلا أن تحديد ذلك ليس بالأمر السهل لأن زمن تشغيل البرنامج غير معرَّف؛ فقد يختلف حسب عدد معالجات الحاسوب وسرعتها، كما قد يعتمد على تصميم آلة جافا الافتراضية Java Virtual Machine (في حالة برامج جافا) المفسِّرة للبرنامج؛ وقد يعتمد كذلك على المصرّف المستخدَم لتصريف البرنامج إلى لغة الآلة، كما يعتمد زمن تشغيل أي برنامجٍ على حجم المشكلة التي ينبغي للبرنامج أن يحلّها، فمثلًا سيستغرق برنامج ترتيب sorting وقتًا أطول لترتيب 10000 عنصر مما سيستغرقه لترتيب 100 عنصر؛ وعندما نوازن بين زمنيْ تشغيل برنامجين، سنجد أن برنامج A يحلّ المشاكل الصغيرة أسرع بكثيرٍ من برنامج B بينما يحلّ البرنامج B المشاكل الكبيرة أسرع من البرنامج A، فلا يوجد برنامجٌ معينٌ هو الأسرع دائمًا في جميع الحالات. عمومًا، هناك حقلٌ ضِمن علوم الحاسوب يُكرّس لتحليل كفاءة البرامج، ويعرف باسم تحليل الخوارزميات Analysis of Algorithms، حيث يركِّز على الخوارزميات نفسها لا البرامج؛ لتجنُّب التعامل مع التنفيذات implementations المختلفة لنفس الخوارزمية باستخدام لغاتٍ برمجيةٍ مختلفةٍ ومصرّفةٍ بأدواتٍ مختلفةٍ وتعمل على حواسيب مختلفةٍ؛ وعمومًا يُعَد مجال تحليل الخوارزميات مجالًا رياضيًا مجرّدًا عن كل تلك التفاصيل الصغيرة، فعلى الرغم من أنه حقلٌ نظريٌ في المقام الأول، إلا أنه يلزَم كلّ مبرمجٍ أن يطّلع على بعضٍ من تقنياته ومفاهيمه الأساسية، ولهذا سيَتناول هذا المقال مقدمةً مختصرةً جدًا عن بعضٍ من تلك الأساسيات والمفاهيم، ولأن هذه السلسلة ليست ذات اتجاه رياضي، فستكون المناقشة عامةً نوعًا ما. يُعَد التحليل المُقارِب asymptotic analysis أحد أهم تقنيات ذلك المجال، ويُقصد بالمُقارِب asymptotic ما يميل إليه على المدى البعيد بازدياد حجم المشكلة، حيث يجيب التحليل المُقارِب لزمن تشغيل run time خوارزميةٍ عن أسئلةٍ مثل، كيف يؤثر حجم المشكلة problem size على زمن التشغيل؟ ويُعَد التحليل مُقارِبًا؛ لأنه يهتم بما سيحدث لزمن التشغيل عند زيادة حجم المشكلة بدون أي قيودٍ، أما ما سيحدث للأحجام الصغيرة من المشاكل فهي أمورٌ لا تعنيه؛ فإذا أظهر التحليل المُقارِب لخوارزمية A أنها أسرع من خوارزمية B، فلا يعني ذلك بالضرورة أن الخوارزمية A ستكون أسرع من B عندما يكون حجم المشكلة صغيرًا مثل 10 أو 1000 أو حتى 1000000، وإنما يعني أنه بزيادة حجم المشكلة، ستصل حتمًا إلى نقطةٍ تكون عندها خوارزمية A أسرع من خوارزمية B. يرتبط مفهوم التحليل المُقارِب بـمصطلح ترميز O الكبير Big-Oh notation، حيث يمكّننا هذا الترميز من قوْل أن زمن تشغيل خوارزميةٍ معينةٍ هو O(n2) أو O(n) أو O(log(n))، وعمومًا يُشار إليه بـ O(f(n))، حيث f(n) عِبارةٌ عن دالة تُسند عددًا حقيقيًا موجبًا لكلّ عددٍ صحيحٍ موجبٍ n، بينما يشير n ضِمن هذا الترميز إلى حجم المشكلة، ولذلك يلزَمك أن تحدّد حجم المشكلة قبل أن تبدأ في تحليلها، وهو ليس أمرًا معقدًا على أية حال؛ فمثلًا، إذا كانت المشكلة هي ترتيب قائمةٍ من العناصر، فإن حجم تلك المشكلة يمكنه أن يكون عدد العناصر ضِمن القائمة، وهذا مثالٌ آخرٌ، عندما يكون مُدخَل الخوارزمية عِبارةٌ عن عددٍ صحيحٍ (لفحْص إذا ما كان ذلك العدد عددًا أوليًا prime أم لا) يكون حجم المشكلة في تلك الحالة هو عدد البتات bits الموجودة ضِمن ذلك العدد المُدخَل لا العدد ذاته؛ وعمومًا يُعَد عدد البتات bits الموجودة في قيمة المُدخَل قياسًا جيدًا لحجم المشكلة. إذا كان زمن تشغيل خوارزميةٍ معينةٍ هو O(f(n))، فذلك يعني أنه بالنسبة للقيم الكبيرة من حجم المشكلة، لن يتعدى الزمن حاصل ضرْب قيمةٍ ثابتةٍ معينةٍ في f(n)، أي أن هناك عدد C وعددٌ صحيحٌ آخرٌ موجبٌ M، وعندما تصبح n أكبر من M، فإن زمن تشغيل الخوارزمية يكون أقلّ من أو يساوي C×f(n)؛ حيث يأخذ الثابت C في حسبانه تفاصيلًا مثل سرعة الحاسوب المستخدَم في تشغيل الخوارزمية، وإذا كان ذلك الحاسوب أبطأ، فقد تستخدم قيمة أكبر للثابت، إلا أن تغييره لن يغيّر من حقيقة أن زمن تشغيل الخوارزمية هو O(f(n)؛ وبفضل ذلك الثابت، لن يكون من الضروري تحديد إذا ما كنا نقيس الزمن بالثواني أو السنوات أو أي وحدة قياسٍ أخرى؛ لأن التحويل من وحدةٍ معينةٍ إلى أخرى يتمثَّل بعملية ضربٍ في قيمة ثابتة، بالإضافة إلى ما سبق، لا تَعتمد O(f(n)) نهائيًا على ما يحدُث في الأحجام الأصغر من المشكلة، وإنما على ما يحدُث على المدى الطويل بينما يزيد حجم المشكلة دون أي قيودٍ. سنفحص مثالًا بسيطًا عِبارةً عن حساب حاصل مجموع العناصر الموجودة ضِمن مصفوفةٍ، وفي هذه الحالة، يكون حجم المشكلة n هو طول تلك المصفوفة، فإذا كان A هو اسم المصفوفة، ستُكتب الخوارزمية بلغة جافا كالتالي: total = 0; for (int i = 0; i < n; i++) total = total + A[i]; تنفِّذ الخوارزمية عملية total = total + A[i] عدد n من المرات، أي أن الزمن الكلي المُستغرق أثناء تلك العملية يساوي a×n، حيث a هو زمن تنفيذ العملية مرةٍ واحدةٍ؛ إلى جانب ذلك، تزيد الخوارزمية قيمة المتغيّر i وتوازنه مع قيمة n في كلّ مرةٍ تنفِّذ فيها مَتْن الحلقة loop، الأمر الذي يؤدي إلى زيادة زمن التشغيل بمقدار يساوي b×n حيث b عِبارةٌ عن ثابتٍ، وبالإضافة إلى ما سبق، يُهيئ كلًا من i وtotal إلى الصفر مبدئيًا مما يزيد من زمن التشغيل بمقدار ثابتٍ معينٍ وليكن c، وبالتالي، يساوي زمن تشغيل الخوارزمية للقيمة (a+b)×n+c، حيث a وb وc عِبارةٌ عن ثوابتٍ تعتمد على عوامل مثل كيفية تصريف compile الشيفرة ونوع الحاسوب المستخدَم، وبالاعتماد على حقيقة أن c دائمًا ما تكون أقلّ من أو تساوي c×n لأي عددٍ صحيحٍ موجبٍ n، يمكننا إذًا أن نقول أن زمن التشغيل أقلّ من أو يساوي (a+b+c)×n، أي أنه أقلّ من أو يساوي حاصل ضرْب ثابتٍ في n، أي يكون زمن تشغيل الخوارزمية هو O(n). إذا استشكل عليك أن تفهم ما سبق، فإنه يعني أنه لأي قيم n كبيرة، فإن الثابت c في المعادلة (a+b)×n+c غير مهمٍ إذا ووزِن مع (a+b)×n، ونصيغ ذلك بأن نقول إن c تعبّر عن عنصرٍ ذات رتبةٍ أقل lower order، وعادةً ما نتجاهل تلك العناصر في سياق التحليل المُقارِب؛ ويمكن لتحليلٍ مُقارِب آخرٍ أكثر حدَّة أن يستنتج ما يلي: "يَستغرق كلّ تكرار iteration ضِمن حلقة for مقدارًا ثابتًا من الوقت، ولأن الخوارزمية تتضمّن عدد n من التكرارات، فإن زمن التشغيل الكلي هو حاصل ضرْب ثابتٍ في n مضافًا إليه عناصر ذات رتبة أقلّ للتهيئة المبدئية، وإذا تجاهلنا تلك العناصر، سنجد أن زمن التشغيل يساوي O(n). يفيد أحيانًا أن نخصِّص حدًا أدنىً lower limit لزمن التشغيل، حيث سيمكّننا ذلك من أن نقول أن زمن تشغيل خوارزميةٍ معينةٍ أكبر من أو يساوي حاصل ضرْب قيمةٍ ثابتةٍ في f(n)، وهو ما يعرّفه ترميزٌ آخرٌ هو Ω(f(n))، ويُقرأ أوميجا لدالة f أو ترميز أوميجا الكبير لدالة f (أوميجا Omega هو حرفٌ أبجديٌ يونانيٌ ويمثِّل الترميز Ω حالته الكبيرة)؛ وإذا شئنا الدقة، عندما نقول أن زمن تشغيل خوارزمية هو Ω(f(n))، فإن المقصود هو وجود عددٍ موجبٍ C وعددٍ صحيحٍ آخرٍ موجبٍ M، وعندما تكون قيمة n أكبر من M، فإن زمن تشغيل الخوارزمية يكون أكبر من أو يساوي حاصل ضرْب C في f(n)، ونستخلّص مما سبق أن O(f(n)) يوفّر معلومةً عن الحد الأقصى للزمن الذي قد تنتظره حتى تنتهي الخوارزمية من العمل، بينما يوفّر Ω(f(n)) معلومةً عن الحد الأدنى للزمن. سنفْحص الآن خوارزميةً أخرى: public static void simpleBubbleSort( int[] A, int n ) { for (int i = 0; i < n; i++) { // Do n passes through the array... for (int j = 0; j < n-1; j++) { if ( A[j] > A[j+1] ) { // A[j] و A[j+1] رتِّب int temp = A[j]; A[j] = A[j+1]; A[j+1] = temp; } } } } يمثِّل المعامِل n في المثال السابق حجم المشكلة، حيث ينفِّذ الحاسوب حلقة for الخارجية عدد n من المرات، وفي كلّ مرة ينفِّذ فيها تلك الحلقة، فإنه ينفِّذ أيضًا حلقة for داخليةً عدد n-1 من المرات، إذًا سينفّذ الحاسوب تعليمة if عدد n×(n-1) من المرات، أي يساوي n2-n، ولأن العناصر ذات الرتبة الأقل غير مهمّةٍ في التحليل المُقارِب، سنكتفي بأن نقول أن تعليمة if تنفَّذ عدد n2 مرةٍ؛ وعلى وجهٍ أكثر تحديدًا، ينفِّذ الحاسوب الاختبار A[j] > A[j+1] عدد n2 من المرات، ويكون زمن تشغيل الخوارزمية هو Ω( n2)، أي يساوي حاصل ضرْب قيمةٍ ثابتةٍ في n2 على الأقل، وإذا فحصنا العمليات الأخرى (تعليمات الإسناد assignment وزيادة i وj بمقدار الواحد..إلخ)، فإننا لن نجد أي عمليةً منها تنفَّذ أكثر من عدد n2 مرة؛ ونستنتج من ذلك أن زمن التشغيل هو O(n2) أيضًا، أي أنه لن يتجاوز حاصل ضرْب قيمةٍ ثابتةٍ في n2، ونظرًا لأن زمن التشغيل يساوي كلًا من Ω( n2) و O( n2)، فإنه أيضًا يساوي Θ( n2). لقد أوضحنا حتى الآن أن زمن التشغيل يعتمد على حجم المشكلة، ولكننا تجاهلنا تفصيلةٍ مهمةٍ أخرى، وهي أنه لا يعتمد فقط على حجم المشكلة، وإنما كثيرًا ما يعتمد على نوعية البيانات المطلوب معالجتها، فمثلًا قد يعتمد زمن تشغيل خوارزمية ترتيب على الترتيب الأولي للعناصر المطلوب ترتيبها، وليس فقط على عددها. لكي نأخذ تلك الاعتمادية في الحسبان، سنُجري تحليلًا لزمن التشغيل على كلًا من الحالة الأسوأ the worst case analysis والحالة الوسطى average case analysis، بالنسبة لتحليل زمن تشغيل الحالة الأسوأ، سنفحص جميع المشاكل المحتملة لحجم يساوي n وسنحدّد أطول زمن تشغيل من بينها جميعًا، بالمِثل بالنسبة للحالة الوسطى، سنفحص جميع المشاكل المحتملة لحجم يساوي n ونحسب قيمة متوسط زمن تشغيلها جميعًا، وعمومًا سيَفترض تحليل زمن التشغيل للحالة الوسطى أن جميع المشاكل بحجم n لها نفس احتمالية الحدوث على الرغم من عدم واقعية ذلك في بعض الأحيان، أو حتى إمكانية حدوثه وذلك في حالة وجود عددٍ لا نهائيٍ من المشاكل المختلفة لحجمٍ معينٍ. عادةً ما يتساوى زمن تشغيل الحالة الأسوأ والوسطى ضِمن مُضاعفٍ ثابتٍ، وذلك يعني أنهما متساويان بقدر اهتمام التحليل المُقارِب، أي أن زمن تشغيل الحالة الوسطى والحالة الأسوأ هو O(f(n)) أو Θ(f(n))، إلا أن هنالك بعض الحالات القليلة التي يختلف فيها التحليل المُقارِب للحالة الأسوأ عن الحالة الوسطى كما سنرى لاحقًا. بالإضافة إلى ما سبق، يمكن مناقشة تحليل زمن تشغيل الحالة المُثلى best case، والذي سيفحص أقصر زمن تشغيلٍ ممكنٍ لجميع المُدخَلات من حجمٍ معينٍ، وعمومًا يُعَد أقلهم فائدةً. حسنًا، ما الذي ينبغي أن تعرفه حقًا عن تحليل الخوارزميات لتستكمل قراءة ما هو متبقّيٍ من هذه السلسلة؟ في الواقع، لن نقدِم على أي تحليلٍ رياضيٍ حقيقيٍ، ولكن ينبغي أن تفهَم بعض المناقشات العامة عن حالاتٍ بسيطةٍ مثل الأمثلة التي رأيناها في هذا المقال، والأهم من ذلك هو أن تفهم ما يعنيه بالضبط قوْل أن وقت تشغيل خوارزميةٍ معينةٍ هو O(f(n)) أو Θ(f(n)) لبعض الدوال الشائعة f(n)، كما أن النقطة المُهمّة هي أن تلك الترميزات لا تُخبرك أي شيءٍ عن القيمة العددية الفعلية لزمن تشغيل الخوارزمية لأي حالةٍ معينةٍ كما أنها لا تخبرك بأي شيءٍ عن زمن تشغيل الخوارزمية للقيم الصغيرة من n، وإنما ستخبرك بمعدل زيادة زمن التشغيل بزيادة حجم المشكلة. سنفترض أننا نوازن بين خوارزميتين لحل نفس المشكلة، وزمن تشغيل إحداها هو Θ( n2) بينما زمن تشغيل الأخرى هو Θ(n3)، فما الذي يعنيه ذلك؟ إذا كنت تريد معرفة أي خوارزميةٍ منهما هي الأسرع لمشكلةٍ حجمها 100 مثلًا، فالأمر غير مؤكدٍ، فوِفقًا للتحليل المُقارِب يمكن أن تكون أي خوارزميةٍ منهما هي الأسرع في هذه الحالة؛ أما بالنسبة للمشاكل الأكبر حجمًا، سيصل حجم المشكلة n إلى نقطةٍ تكون معها خوارزمية Θ( n2) أسرع بكثيرٍ من خوارزمية Θ(n3)؛ وعلاوةً على ذلك، يزداد تميز خوارزمية Θ( n2) عن خوارزمية Θ(n3) بزيادة حجم المشكلة أكثر فأكثر، وعمومًا ستكون هناك قيمٌ لحجم المشكلة n تكون معها خوارزمية Θ( n2) أسرع ألف مرةٍ أو مليون مرةٍ أو حتى بليون مرةٍ وهكذا؛ وذلك لأن دالة a×n3 تنمو أسرع بكثيرٍ من دالة b×n2 لأي ثابتين موجبين a وb، وذلك يعني أنه بالنسبة للمشاكل الكبيرة، ستكون خوارزمية Θ( n2) أسرع بكثيرٍ من خوارزمية Θ(n3)، ونحن لا نعرف بالضبط إلى أي درجةٍ بالضبط ينبغي أن تكون المشكلة كبيرةٌ، وعمليًا، يُحتمل لخوارزمية Θ( n2) أن تكون أسرع حتى للقيم الصغيرة من حجم المشكلة n؛ وعمومًا تُفضّل خوارزمية Θ( n2) عن خوارزمية Θ(n3). لكي تفهم وتطبّق التحليل المُقارِب، لابدّ أن يكون لديك فكرةً عن معدّل نمو بعض الدوال الشائعة، وبالنسبة للدوال الأسية (power) n, n2, n3, n4, …,، كلما كان الأس أكبر، ازداد معدّل نمو الدالة؛ أما بالنسبة للدوال الأسية exponential من النوع 2n و 10n حيث n هو الأس، يكون معدّل نموها أسرع بكثيرٍ من أي دالةٍ أسيةٍ عاديةٍ power، وعمومًا تنمو الدوال الأسية بسرعةٍ جدًا لدرجة تجعل الخوارزميات التي ينمو زمن تشغيلها بذلك المعدّل غير عمليةٍ عمومًا حتى للقيم الصغيرة من n لأن زمن التشغيل طويلٌ جدًا، وإلى جانب ذلك، تُستخدم دالة اللوغاريتم log(n) بكثرةٍ في التحليل المُقارِب، فإذا كان هناك عددٌ كبيرٌ من دوال اللوغاريتم، ولكن تلك التي أساسها يساوي 2 هي الأكثر استخدامًا في علوم الحاسوب، وتُكتب عادة كالتالي log2(n)، حيث تنمو دالة اللوغاريتم ببطئٍ إلى درجةٍ أبطأ من معدّل نمو n، وينبغي أن يساعدك الجدول التالي على فهم الفروق بين معدّلات النمو للدوال المختلفة: يرجع السبب وراء استخدام log(n) بكثرةٍ إلى ارتباطها بالضرب والقسمة على 2، سنفْترض أنك بدأت بعدد n ثم قسَمته على 2 ثم قسَمته على 2 مرةٍ أخرى، وهكذا إلى أن وصَلت إلى عددٍ أقل من أو يساوي 1، سيساوي عدد مرات القسمة (مقربًا لأقرب عدد صحيح) لقيمة log(n). فمثلًا انظر إلى خوارزمية البحث الثنائي binary search في مقال البحث والترتيب في المصفوفات Array في جافا، حيث تبحث تلك الخوارزمية عن عنصرٍ ضِمن مصفوفةٍ مرتّبةٍ، وسنستخدم طول المصفوفة مثل حجمٍ للمشكلة n، إذ يُقسَم عدد عناصر المصفوفة على 2 في كلّ خطوةٍ ضِمن خوارزمية البحث الثنائي، بحيث تتوقّف عندما يصبح عدد عناصرها أقلّ من أو يساوي 1، وذلك يعني أن عدد خطوات الخوارزمية لمصفوفة طولها n يساوي log(n) للحدّ الأقصى؛ ونستنتج من ذلك أن زمن تشغيل الحالة الأسوأ لخوارزمية البحث الثنائي هو Θ(log(n)) كما أنه هو نفسه زمن تشغيل الحالة الوسطى، في المقابل، يساوي زمن تشغيل خوارزمية البحث الخطي الذي تعرّضنا له في مقال السابق ذكره أعلاه حول البحث والترتيب في المصفوفات Array في جافا لقيمة Θ(n)، حيث يمنحك ترميز Θ طريقةً كميّةً quantitative لتعبّر عن حقيقة كون البحث الثنائي أسرع بكثيرٍ من البحث الخطي linear search. تَقسِم كلّ خطوةٍ في خوارزمية البحث الثنائي حجم المسألة على 2، وعمومًا يحدُث كثيرًا أن تُقسَم عمليةٌ معينةٌ ضِمن خوارزمية حجم المشكلة n على 2، وعندما يحدث ذلك ستظهر دالة اللوغاريتم في التحليل المُقارِب لزمن تشغيل الخوارزمية. يُعَد موضوع تحليل الخوارزميات algorithms analysis حقلًا ضخمًا ورائعًا، ناقشنا هنا جزءًا صغيرًا فقط من مفاهيمه الأساسية التي تفيد لفهم واستيعاب الاختلافات بين الخوارزميات المختلفة. ترجمة -بتصرّف- للقسم Section 5: Analysis of Algorithms من فصل Chapter 8: Correctness, Robustness, Efficiency من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: التوكيد assertion والتوصيف annotation في لغة جافا مدخل إلى الخوارزميات ترميز Big-O في الخوارزميات دليل شامل عن تحليل تعقيد الخوارزمية تعقيد الخوارزميات Algorithms Complexity النسخة الكاملة لكتاب مدخل إلى الذكاء الاصطناعي وتعلم الآلة
-
سنلقي في هذا المقال نظرةً سريعةً على خاصيتين في جافا لم نتعرّض لهما من قبل هما التوكيد والتوصيف، وعمومًا يُعَدان من المواضيع المتقدّمة في البرمجة. التوكيد يجب أن يتحقَّق الشرْط المسبَق عند نقطةٍ معينةٍ في البرنامج لنضمن أن يستمر في العمل بصورةٍ صحيحةٍ؛ أما إذا كان الشرْط المسبَق غير متحقِّقٍ كأن يعتمد على القيمة المدْخَلة من قِبل المستخدِم، فحينها سنفحص ذلك باستخدام تعليمة if الشرطية، ويقودنا ذلك إلى السؤال التالي: ماذا إذا لم يتحقَّق الشرْط المسبَق؟ قد نبلِّغ عن استثناء، مما يؤدي إلى إنهاء البرنامج إلا إذا التقطه جزءٌ آخرٌ من البرنامج وعالجه. يحدُث كثيرًا أن يحاول المبرمِج أن يكْتب برنامجًا بطريقةٍ تضْمن تحقُّق شرطٍ مسبَقٍ معينٍ بدلًا من أن يستخدم تعليمة if ليَفحص إذا ما تحقَّق الشرط أم لا، ومهما كانت نية المبرمِج، فإن البرنامج قد يحتوي على خطأٍ برمجيٍ bug يُفسد ذلك الشرْط المسبَق، وعليه يظلّ فحْص الشرْط المسبَق فكرةً جيدةً أثناء مرحلة تنقيح الأخطاء debugging على الأقل. كما يجب أن يتحقَّق الشرْط اللاحق عند نقطةٍ معينةٍ في البرنامج بناءً على الشيفرة التي نفذها الحاسوب قبلها. سنفترض أن الشيفرة كُتبت بطريقةٍ صحيحةٍ، لذا سيتحقَّق الشرْط اللاحق؛ ومع ذلك يُعَد اختبار إذا ما كان الشرْط اللاحق متحققًا أم لا بصورةٍ فعليةٍ فكرةً جيدةً (خصوصًا أثناء مرحلة تنقيح الأخطاء) لفحْص ما إذا كان هناك خطأٌ برمجيٌ قد أفسد ذلك الشرط أم لا؟ وينطبق الأمر نفسه على لامتباينات حلقات التكرار ولامتباينات الأصناف، حيث يجب أن تتحقَّق ضِمن نُقطٍ معينةٍ من البرنامج، أما إذا لم تحقَّق في تلك النقط، فيعني ذلك احتواء البرنامج على خطأٍ برمجيٍ. امتلكت لغتي C وC++ دومًا طريقةً لإضافة ما يُعرف باسم التوكيدات إلى البرامج، وتُكتب تلك التوكيدات على الصورة assert(condition) حيث condition عِبارةٌ عن تعبيرٍ expression من النوع المنطقي الذي يمثِّل شرْطًا مسبَقًا أو لاحقًا، وينبغي يتحقَّق ذلك الشرْط عند تلك النقطة من البرنامج، فعندما يقابل الحاسوب توكيدًا أثناء تنفيذه لبرنامجٍ، فسيحصّل قيمة الشرط condition؛ فإذا لم يتحقَّق الشرط، فإنه سينهي البرنامج؛ أما إذ كان الشرط متحققًا، فسيستمر بتنفيذ البرنامج على نحوٍ طبيعيٍ، ويسمح ذلك باختبار إذا ما كانت نية المبرمج بخصوص شرطٍ معينٍ متحقِّقةً أم لا، فإذا لم تكن كذلك، فذلك يعني وجود خطأٍ برمجيٍ في ذلك الجزء من البرنامج الذي يسبق التوكيد، بالإضافة إلى ذلك تمكّنك لغتي C وC++ من تعطيل فحْص تلك التوكيدات أثناء وقت التصريف compile time، بمعنى أنك تستطيع أن تختار تضْمين التوكيدات أو عدم تضْمينها في البرنامج المصرّف، وعادةً ما تصرّف البرامج أثناء مرحلة تنقيح الأخطاء وفقًا للنوع الأول من التصريف compilation، بينما يصرّف إصدار نسخة البرنامج الفعلي مع تعطيل التوكيدات مما يضمن كفاءةً أعلى للبرنامج لأن الحاسوب لن يفحص كلّ تلك التوكيدات. لا توفّر النسخ الأقدم من جافا خاصية التوكيدات، فمنذ الإصدار 1.4، أضيفت إليها نسخةً مماثلةً لتلك المدعّمة بلغتي C/C++، وبالمثل، يمكنك أن تفعّل توكيدات جافا أثناء مرحلة تنقيح الأخطاء أو أن تعطّلها أثناء التنفيذ العادي للبرنامج، إلا أن ذلك يحدث أثناء وقت التشغيل run time لا وقت التصريف، مما يعني أن التوكيدات تتضمّن دائمًا ملفات الأصناف المصرّفة بحيث يتجاهل الحاسوب تلك التوكيدات ولا يفحص شروطها أثناء التشغيل العادي للبرنامج ولا تؤثّر على أداء البرنامج؛ في المقابل، يمكن تشغيل البرنامج أثناء تفعيل التوكيدات خلال مرحلة تنقيح الأخطاء، وعندها قد تَكتشف تلك التوكيدات بعض الأخطاء البرمجية وتُحدد مواضعها إلى حدٍ كبيرٍ. تُكتب تعليمات التوكيد assertion statement في جافا بصيغةٍ من اثنتين: assert condition ; أو: assert condition : error-message ; حيث يُعَد condition عبارةٌ عن تعبيرٍ منطقيٍ boolean-valued expression أما error-message فهي عبارةٌ عن سلسلةٍ نصيةٍ string أو تعبيرٍ من النوع String. إذا عطّلت التوكيدات وشغّلت برنامجٍ معينٍ، فستكافِئ عندها التوكيدات التعليمة الفارغة empty statement، ولن يكون لها أي تأثيرٍ؛ أما إذا فعّلت التوكيدات فسيواجه الحاسوب تعليمة توكيدٍ في البرنامج، وسيحصّل قيمة الشرْط condition، وإذا كانت قيمته تساوي true، فسيستمر في تنفيذ البرنامج بطريقةٍ عاديةٍ، وإذا كانت قيمته تساوي false، فسيبلِّغ الحاسوب عن استثناء من النوع java.lang.AssertionError، وينهار البرنامج (إلا إذا التُقط بتعليمة try)؛ أما إذا تضمّنت تعليمة التوكيد error-message، فستظهر مثل رسالة خطأٍ في الاستثناء AssertionError المبلَّغ عنه. يمكننا إذًا أن نقول أن التعليمة الآتية: assert condition : error-message; تكافِئ الشيفرة التالية: if ( condition == false ) throw new AssertionError( error-message ); ينقلنا ذلك إلى السؤال التالي: متى نستخدم التوكيدات بدلًا من الاستثناءات؟ القاعدة العامة هي استخدام التوكيدات لفحص الشروط التي يجب أن تتحقَّق إذا كُتب البرنامج كتابةً صحيحةً، حيث تساعد التوكيدات على اختبار إذا ما كُتب البرنامج بصورةٍ صحيحةٍ أم لا، كما تساعد في العثور على الأخطاء إن وُجدت، في المقابل، لا تُفحص تلك التوكيدات عند التشغيل العادي للبرنامج أي بعْد اختباره وتنقيحه، ومع ذلك إذا ظهرت مشكلةً لاحقًا، فيمكننا أن نحدّد موضع ذلك الخطأ عن طريق الاستعانة بتلك التوكيدات؛ فمثلًا إذا أخبرك شخصٌ ما بأن البرنامج الخاص بك لا يعمل عندما يفعل كذا وكذا، فتستطيع بسهولةٍ أن تشغّل البرنامج مع تفعيل التوكيدات وتنفّذ كذا وكذا، وقد تفيدك التوكيدات في تحديد موضع البرنامج المتضمّن لذلك الخطأ. فمثلًا، يحسِب التابع root() التالي من المقال السابق هو الجذر التربيعي لمعادلةٍ من الدرجة الثانية، فإذا تأكدت من أن البرنامج سيستدعي ذلك التابع بقيم معامِلاتٍ arguments صالحةٍ فقط، سنضيف تعليمات توكيدٍ لا استثناءات كالتالي: // [1] static public double root( double A, double B, double C ) { assert A != 0 : "Leading coefficient of quadratic equation cannot be zero."; double disc = B*B - 4*A*C; assert disc >= 0 : "Discriminant of quadratic equation cannot be negative."; return (-B + Math.sqrt(disc)) / (2*A); } [1] يعيد قيمة الجذر الأكبر للمعادلة A*x*x + B*x + C = 0، ويكون الشرْط المسبَق: A != 0 و B*B - 4*A*C >= 0 كما ذكرنا مسبقًا، لا تُفحص التوكيدات عند التشغيل العادي للبرنامج؛ لأنه إذا كان اعتقادك بأن التابع في المثال السابق لا يستدعى أبدًا إلا بمعاملاتٍ صالحةٍ صحيحًا، فإن فحْص الشروط بتلك التوكيدات غير ضروريٍ، أما إذا كان اعتقادك غير صحيحٍ، فينبغي أن تظْهر تلك المشكلة أثناء مرحلة اختبار البرنامج وتنقيح أخطاءه أي عند تشغيل البرنامج مع تفعيل التوكيدات. إذا كان التابع root() جزءًا من مكتبةٍ برمجيةٍ تُستخدَم من قِبل مبرمجين آخرين، فسيكون الموقف أقل وضوحًا، حيث يوصي توثيق أوراكل جافا بألا تُستخدم التوكيدات لفحص المواصفات الاصطلاحية contract للتوابع العامة public، فإذا تعدّى مستدعيًا تابعًا على مواصفاته الاصطلاحية بتمرير معاملاتٍ غير صالحةٍ، فينبغي أن يبلِّغ التابع عن استثناءٍ، وسيَفرض تلك المواصفة الاصطلاحية بغض النظر عما إذا ما فُعّلت التوكيدات أم لا، وفي حين يَعتمد مبرمجي جافا على الستثناءات لفرض المواصفة الاصطلاحية الخاصة بتابعٍ معينٍ، يكون استخدام التوكيدات أحيانًا أكثر معقوليةً؛ وقد يرى البعض أن التوكيدات صُمّمت لتساعدك على تنقيح شيفرتك، أما الاستثناءات فقد صُمّمت لتنبيه المبرمجين الآخرين عندما يستخدمون شيفرتك على نحوٍ خاطئٍ. عمومًا لن يضرّ استخدام توكيدٍ لفحْص شرطٍ لاحقٍ أو لامتباينٍ، فهي في النهاية شروطٌ يتوقّع أن تتحقَّق دائمًا مما إذا كان البرنامج خاليًا من أي أخطاءٍ برمجيةٍ، وعليه يكون من المنطقي استخدام توكيداتٍ لفحْص تلك الشروط أثناء مرحلة التنقيح دون التسبّب بأي ضررٍ على كفاءة البرنامج أثناء التشغيل العادي للبرنامج، فإذا لم يتحقَّق أي من الشرطين اللاحق أو اللامتباين، فسيكون ذلك بمثابة خطأٍ برمجيٍ، وهو أمر يجب أن يُكتشف أثناء مرحلة الاختبار testing وتنقيح الأخطاء. يجب أن تُفعَّل التوكيدات عند تشغيل البرنامج لتؤثِّر به، حيث تعتمد طريقة التفعيل على البيئة البرمجية programming environment المُستخدمة، ولذلك ينبغي أن تضيف الخيار -enableassertions إلى أمر جافا المستخدَم لتشغيل البرنامج، وذلك في أي بيئةٍ سطر أوامرٍ command line environment تقليديةٍ، فمثلًا، إذا كان RootFinder هو الصنف المتضمّن للبرنامج main()، فإن الأمر التالي: java -enableassertions RootFinder سينفِّذ البرنامج مع تفعيل التوكيدات، كما يمكنك أن تستخدم -ea مثل اختصارٍ للخيار -enableassertions، بمعنى أنه يمكنك كتابة الأمر السابق على النحو التالي: java -ea RootFinder يمكنك كذلك أن تفعّل التوكيدات ضمْن جزءٍ معينٍ من البرنامج، فمثلًا، يفعّل الخيار -ea:class-name التوكيدات الموجودة في الصنف المخصّص فقط، ولاحظ عدم وجود أي مسافاتٍ بين -ea و: وclass-name، في المقابل، يفعّل الخيار -ea:package-name... كلّ التوكيدات الموجودة في حزمةٍ package معينةٍ إلى جانب حِزمها الفرعية، كما يفعّل الخيار -ea:... التوكيدات الواقعة ضِمن الحزمة الافتراضية default package، أي تلك الأصناف التي لم تخصّص لها حزمةٌ معينةٌ بعد، فمثلًا يشغّل الأمر التالي برنامج جافا اسمه MegaPaint مع تفعيل التوكيدات بأصناف الحزمتين paintutils وdrawing: java -ea:paintutils... -ea:drawing... MegaPaint إذا كنت تستخدم بيئة تطوير إكلبس Eclipse، سيمكنك أن تخصّص الخيار -ea من خلال إنشاء إعدادات تشغيل run configurations، حيث ستنقر بزر الفأرة الأيمن على اسم الصنف الرئيسي في نافذة مُستكشِف الحِزم Package Explorer، ثم اختر Run As من القائمة، ثم Run من القائمة الفرعية، وستظهر نافذة ضبط إعدادات التشغيل؛ وستجد أن كلًا من اسم المشروع وصنفه الأساسي مملوئين بالفعل، انقر على نافذة Arguments، ثم اكتب -ea في الصندوق أسفل VM Arguments، الآن ستُضاف محتويات ذلك الصندوق إلى أمر جافا المستخدَم لتشغيل البرنامج، إذًا تستطيع أن تُدخِل أي خياراتٍ أخرى ضِمن نفس الصندوق بما في ذلك خيارات تفعيل توكيدات أخرى أكثر تعقيدًا مثل "-ea:paintutils…"، وعندما تنقر على زر Run ، ستُطبَّق تلك الخيارات كما ستطبَّق في كلّ مرةٍ تشغّل فيها البرنامج، إلا إذا بدلّت إعدادات التشغيل أو أنشأت واحدةٍ جديدةٍ، ويمكنك أن تنشئ إعدادي تشغيل مختلفين لنفس الصنف، واحدًا مع تفعيل التوكيدات وآخر مع تعطيلها. التوصيف يشير مصطلح التوصيف عادةً إلى الملاحظات المضافة أو المكتوبة إلى جانب النص الأساسي للمساعدة على فهمه وإدراكه، وقد يأخذ التوصيف هيئةً ملحوظةً تكتبها لنفسك في هامش كتابٍ أو هامشٍ يضيفه المحرِّر إلى روايةٍ قديمةٍ ليوضّح السياق التاريخي لحدثٍ معينٍ، حيث يُعَد التوصيف بمثابة البيانات الوصفية أو النص الوصفي، بمعنى أنه نصٌ مكتوبٌ عن النص الأساسي وليس جزءًا منه. تُعَد التعليقات comments مثالًا على التوصيف، ولمّا كان المصرّف يتجاهلها، فهي لن تؤثِّر على معنى البرنامج، إذ أنها تشرح معنى الشيفرة للقارئ فقط، وقد يمكن لأي برنامجٍ آخرٍ باستثناء المصرّف أن يعالج تلك التعليقات؛ وتُعَد تعليقات Javadoc مثالًا على ذلك، حيث تعالَج عبر برنامجٍ ينشِئ منها توثيق واجهة تطوير التطبيقات API documentation، وعمومًا تُعَد التعليقات أحد البيانات الوصفية الكثيرة التي تُضاف للبرامج. توفّر لغة جافا منذ الإصدار 5.0 ما يُعرف باسم التوصيف، وهي طريقةٌ تسهِّل إنشاء أنواعٍ أخرى جديدةٍ من البيانات الوصفية لبرامج جافا، حيث مكّن التوصيف المبرمجين من اختراع طرقٍ جديدةٍ تمامًا لتوصيف برامجهم وكذلك من كتابة برامج يمكنها قراءة تلك التوصيفات واستخدامها. لا يؤثر التوصيف على البرامج الموصّفة بصورةٍ مباشرةٍ، ومع ذلك فإنها تُستخدم بكثرةٍ، فمثلًا تصرّح بعض أنواع التوصيف عن نية المبرمج بصورةٍ أكثر وضوحًا، حيث قد يستخدم المصرّف تلك التوصيفات المضافة ليتأكّد من توافق الشيفرة مع نية المبرمج، فقد يُستخدم التوصيف القياسي @Override ضِمن تعريف definition تابعٍ ليبيّن أنه يعيد تعريف override تابعٍ آخرٍ له نفس بصمة signature معرّفٍ ضِمن إحدى الأصناف العليا superclass؛ حيث يستطيع المصرّف أن يتحقَّق من وجود ذلك التابع المستبدَل، فإذا لم يكن موجودًا فإنه يبلِّغ المبرمِج، ويَعُد التوصيف في تلك الحالة مثل أداةٍ تساعد على كتابة برامجٍ صحيحةٍ؛ لأنها تحذّر المبرمج من وجود خطأٍ محتملٍ بدلًا من تركه يبحث عنه بعد ذلك في هيئة خطأٍ برمجيٍ. يمكنك أن تكتب @Override في مقدمة تعريف تابعٍ لإضافة ذلك التوصيف إليه، ويعرّف التوصيف صياغيًا بأنه عِبارة عن مبدِّلٍ modifiers مثل المبدّلات public وfinal. انظر المثال التالي: @Override public void WindowClosed(WindowEvent evt) { ... } إذا لم يكن هناك تابعٌ اسمه WindowClosed(WindowEvent) في أي صنفٍ أعلى، فسيبلّغ المصرّف عن وجود خطأٍ، وعمومًا يعتمد هذا المثال على خطأٍ برمجيٍ واجهه الكاتب عندما أراد أن يعيد تعريف التابع windowClosed بتابعٍ آخرٍ اسمه WindowClosed، فإذا كان التوصيف @Override متاحًا في لغة جافا في ذلك الوقت (واستخدمه الكاتب)، كان المصرّف سيرفض الشيفرة وبذلك يوفّر عليه الكثير من الوقت. تُعَد التوصيفات خاصيةً متقدمةً نوعًا ما، وكان من الممكن عدم ذكرها باستثناء @Override التي ستراها ضِمن الشيفرة المولّدة تلقائيًا عبر إكلبس وبعض بيئات التطوير المتكاملة integrated development environments الأخرى. سنذكر كذلك توصيفين قياسيين آخرين هما @Deprecated و@SurpressWarnings، حيث يُستخدم الأول لوضع علامة على كل من الأصناف والتوابع والمتغيّرات المتروكة deprecated (أي تلك العناصر المهجورة من اللغة، والتي ما تزال متاحةً فقط لأغراض التوافق مع الشيفرات القديمة)، حيث يولّد المصرّف رسائل تحذيرٍ warning messages عند استخدام أي عنصرٍ متروكٍ، أما التوصيف الثاني فيُستخدم لتعطيل رسائل التحذير التي يولّدها المصرّف. @SuppressWarnings("deprecation") وعليه، لن يُصدر المصرّف أي رسائل تحذيرٍ عند استخدام أي عنصرٍ متروكٍ، وهناك أنواعٌ أخرى من رسائل التحذير التي يمكن تعطيلها، إلا أن أسمائها غير قياسيةٍ وتختلف من مصرّفٍ لآخرٍ. بالإضافة إلى ما سبق، يمكنك أن تعرّف توصيفًا جديدًا وتستخدمه ضِمن الشيفرة الخاصة بك، في حين ستتجاهل المصرّفات والأداوت البرمجية القياسية تلك التوصيفات، ويمكنك أن تكتب برامجًا قادرةٌ على فهمها وفحْص وجودها ضِمن الشيفرة، كما يمكنك أن تنشئ توصيفٍ يعمل أثناء وقت التنفيذ، مما سيمكن البرنامج من فحْص وجود التوصيف ضِمن الشيفرة المصرّفة الجاري تنفيذها، واتخاذ قرارات بناءً على وجودها أو على قيم معاملاتها. تساعد التوصيفات المبرمجين على كتابة برامجٍ صحيحةٍ، فيمكنها أن تساعد على إنشاء شيفرةٍ متداولةٍ boilerplate أي شيفرةٌ لها هيئةٌ قياسيةٌ تولَّد بصورةٍ تلقائيةٍ، وعادةً ما تعتمد الشيفرة المتداولة على شيفرةٍ أخرى؛ مثل الشيفرة المستخدمة لحفظ جوانبٍ معينةٍ من حالة البرنامج في ملفٍ ثم استعادتها لاحقًا، وتُعَد الشيفرة المسئولة عن كتابة قيم المتغيّرات المتعلِّقة بحالة البرنامج وقرائتها عمليةً متكرّرةً، ولذلك، يجب أن تُكتب بصورةٍ يدويةٍ، فقد يستخدم المبرمج توصيفًا لوضع علامةٍ على المتْغيّرات التي تُعَد جزءًا من الحالة المطلوب حفظها بحيث يفْحص برنامجًا آخرًا وجود تلك التوصيفات وبناءً على ذلك، يمكنه أن يولِّد شيفرة لحفظ تلك المتغيّرات واستعادتها؛ وقد يكون ذلك ممكنًا حتى بدون تلك الشيفرة إذا كان البرنامج يفْحص وجود تلك التوصيفات أثناء وقت التشغيل ويقرّر على أساس ذلك المتْغيّرات المطلوب تخزينها أو استعادتها. ترجمة -بتصرّف- للقسم Section 4: Assertions and Annotations من فصل Chapter 8: Correctness, Robustness, Efficiency من كتاب Introduction to Programming Using Java. اقرأ أيضًا الدليل السريع للغة البرمجة Java كيفية كتابة برامج صحيحة باستخدام لغة جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا بيئات تطوير أندرويد التي تستخدم التأشير Annotation Processing في KOTLIN
-
تسهُل كتابة البرامج عادةً بصورةٍ مثاليةٍ عن كتابتها لتكون متينة robust، حيث تتأقلم البرامج المتينة مع أيّ ظروفٍ استثنائيةٍ تواجهها دون أن تنهار crash، كما يمكنك أن تكتبها عن طريق تحديد المشاكل التي يُحتمل أن تقع، وكذلك تضمين الاختبارات الضرورية لكلّ مشكلةٍ منها؛ فمثلًا، إذا كان لدينا مصفوفة A واستخدَم برنامجٌ عنصر المصفوفة A[i]، فإنه قد ينهار إذا كانت قيمة i خارج النطاق المسموح به لقيم فهارس indices تلك المصفوفة؛ إذًا، ينبغي لأيّ برنامجٍ متينٍ أن يتوقّع احتمالية استخدام فهرسٍ غير صالحٍ، ويوفّر أيضًا الحماية الضرورية؛ ونستطيع أن نكتب برنامجًا يتأكد من أن الفهرس المستخدَم سيقع دائمًا ضِمن النطاق المسموح به بأن يكون الشرْط اللاحق للشيفرة التي تسبِق استخدام عنصر المصفوفة، أو اختبار صلاحية قيمة الفهرس index قبل استخدامها للإشارة إلى عنصرٍ بالمصفوفة كالتالي: if (i < 0 || i >= A.length) { ... // Do something to handle the out-of-range index, i } else { ... // Process the array element, A[i] } تعاني الطريقة السابقة من بعض المشاكل، فمثلًا، يصعُب أحيانًا أن تتوقّع جميع الأمور التي يمكنها أن تتسبّب بالأخطاء، كما أنه لن يتضح ما ينبغي القيام به عندما تكتشِف خطأً معينًا، إذ أن محاولة توقُّع جميع المشاكل المحتملة قد تحوُّل خوارزميةً بسيطةً إلى كتلةٍ متشابكةٍ من تعليمات if الشرْطية. الاستثناءات وأصناف Exception توفّر جافا تقنيةً أكثر تنظيمًا وأناقةً للتعامل مع الأخطاء التي قد تحدُث أثناء تشغيل البرنامج، وعادةً ما يشار إلى تلك التقنية باسم معالجة الاستثناءات exception handling، إذ يُعَد الاستثناء مفهومًا أشمل من الخطأ، حيث يتضمّن أيّ ظروفٍ قد تحدُث أثناء تنفيذ البرنامج وتحيده عن مساره الطبيعي. عمومًا ما يُقال أن البرنامج بلّغ thrown عن استثناء عند حدوث خطأٍ أثناء تنفيذ البرنامج، وفي تلك الحالات يخرُج البرنامج عن مساره الطبيعي وقد ينهار؛ ويمكن تجنُّب ذلك بالتقاط caught الاستثناء ومعالجته بطريقةٍ ما؛ وجرت العادة أن يبلّغ جزءٌ من البرنامج عن الاستثناء فيما يلتقطه جزءٌ آخر، وبينما يتسبّب الاستثناء -إذا لم يُلتقط- في انهيار البرنامج. يمكن للخيوط الأخرى في البرامج متعددة الخيوط multithreaded أن تستمر بالعمل حتى بعد انهيار إحداها؛ فمثلًا، تُعَد برامج واجهة المستخدم الرسومية واحدةٌ من البرامج متعددة الخيوط التي يمكن لبعض أجزائها أن تستمر بالعمل حتى إذا تعطّلت بعض أجزائها الأخرى نتيجةً لحدوث استثناء ما. نظرًا لأن برامج جافا تنفَّذ بواسطة مفسِّر جافا interpreter، لذلك فإن انهيار برنامجٍ معينٍ لا يعني انهيار المفسِّر، وإنما يعني انتهاء البرنامج قبل أوانه، بصورةٍ أكثر تحديدًا يلتقِط المفسِّر أي استثناء لم يلتقطه البرنامج ثم ينهي ذلك البرنامج؛ أما في لغاتٍ برمجيةٍ أخرى فقد يؤدي انهيار برنامج إلى انهيار النظام بأكمله إلى أن يُعاد تشغيله. الأمر الذي يستحيل حدوثه في لغة جافا. لقد تعرّضنا لكل من الاستثناءات وتعليمة try..catch المستخدَمة لالتقاط الاستثناءات ومعالجتها، إلا أنه ما تزال هناك بعض قواعد الصيغة syntax الخاصة بتلك التعليمة التي سنناقشها خلال هذا المقال. يحدُث استثناء عندما يكون الشئ المبلّغ عنه عبارةً عن كائنٍ يحتوي على معلوماتٍ عن تلك النقطة المحدَّدة من البرنامج التي حدث بها الاستثناء، وعادةً ما تتضمّن تلك المعلومات قائمةً بالبرامج الفرعية التي كان البرنامج ينفّذها عندما حدث الاستثناء، والتي تسمّى باستدعاءات المكدّس call stack، ولمّا كان بإمكان أيّ برنامجٍ فرعيٍ أن يستدعي برنامجًا آخر جاز أن يوجد أكثر من برنامجٍ فرعيٍ قيد التنفيذ في نفْس الوقت، وتتضمّن المعلومات رسالة خطأٍ تصِف سبب حدوث الاستثناء، وكما أنها قد تتضمّن بياناتٍ إضافيةٍ أخرى. يجب أن تنتمي الكائنات المبلّغ عنها إلى صنفٍ فرعيٍ subclass من الصنف القياسي java.lang.Throwable، وعمومًا يمثَّل كلّ نوعٍ مختلفٍ من الاستثناءات بواسطة صنفٍ فرعيٍ خاصٍ مُشتقٍ من Throwable، وترتَّب تلك الأصناف الفرعية وفقًا لسلالة أصنافٍ class hierarchy معقدةٍ تُظهر العلاقات بين أنواع الاستثناءات المختلفة، كما يملك الصنف Throwable صنفين فرعيين مباشرين هما Error وException، وبدورهما يملكان الكثير من الأصناف الفرعية الأخرى المعرّفة مسبقًا؛ إذًا يستطيع المبرمِج أن ينشئ أصناف استثناءات جديدةٍ لتمثيل أنواعٍ جديدةٍ من الاستثناءات. تمثِّل أغلب الأصناف الفرعية المشتقّة من الصنف Error أخطاءً خطيرةً في آلة جافا الافتراضية Java virtual machine، كما تتسبّب عادةً في انتهاء البرنامج لعدم توفُّر أي طريقةٍ لمعالجتها، وعمومًا لا يحبَّذ أن تُلتقط تلك النوعية من الأخطاء، فمثلًا يقع الخطأ ClassFormatError عندما تحاول آلة جافا الافتراضية العثور على بياناتٍ غير صالحةٍ في ملفٍ يُفترض أنه يحتوي على شيفرة صنف مصرّفة، فإذا كان الصنف الجاري تحميله جزءًا من البرنامج، فلن يُستكمل البرنامج. بينما تمثِّل الأصناف الفرعية المشتقّة من الصنف Exception استثناءات يمكن التقاطها، وفي حالاتٍ كثيرةٍ تُعَد الأخطاء errors استثناءات أيضًا، إلأ أنها تكون أخطاءً ضمن البرنامج أو ضمن البيانات المُدخَلة والتي يتوقّعها المبرمِج ويستجيب لها بالطريقة المناسبة. يملِك الصنف Exception صنفًا فرعيًا اسمه RuntimeException، ويتضمّن ذلك الصنف على الكثير من أصناف الاستثناءات الشائعة بما في ذلك كلّ الأصناف التي تعرّضنا لها بالأقسام السابقة؛ فمثلًا الصنفان الآتيان: IllegalArgumentException NullPointerException مشتقّان من الصنف RuntimeException، وعمومًا تشير تلك النوعية من أصناف الاستثناءات إلى وجود خطأٍ برمجيٍ bug ينبغي على المبرمِج إصلاحه، بينما يتجاهل البرنامج احتمالية حدوث الاستثناءات من النوعين RuntimeExceptions وError لينهار في حالة وقوعها، الأمر الذي بالفعل في كل مرة يستخدِم فيها البرنامج عنصر مصفوفة A[i] بدون أن يستعدّ لالتقاط استثناء من النوع التالي: ArrayIndexOutOfBoundsException وتُعَد معالجة أصناف الاستثناءات الأخرى من غير الصنفين Error وRuntimeExceptions عمليةً إجباريةً mandatory، الأمر الذي سيتضّح معناه بعد قليل. تظهِر سلالة الأصناف الصنف Throwable وعددٌ قليلٌ من أصنافه الفرعية، حيث تتطلّب الأصناف الملونة باللون الأحمر معالجة استثناءات اجباريةٍ mandatory exception-handling، كما في الصورة التالية: يتضمّن الصنف Throwable مجموعةً من توابع النسَخ instance methods التي تستخدمها كائنات الاستثناء، فإذا كان e ينتمي إلى الصنف Throwable أو إلى أيٍ من أصنافه الفرعية، فإن الدالة e.getMessage() تعيد سلسلةً نصيةً من النوع String لتصِف ذلك الاستثناء، وكذلك تعيد الدالة e.toString() التي يستخدمها النظام للتمثيل النصي string representation للكائنات سلسلةً نصيةً من النوع String تتكون من اسم الصنف الذي ينتمي إليه الاستثناء، بالإضافة إلى نفْس السلسلة النصية التي تعيدها الدالة e.getMessage()، كما يطبع التابع e.printStackTrace() استدعاءات المكدّس stack trace إلى الخرْج القياسي، الأمر الذي قد يفيد عند التعرّف على سبب مشكلةٍ ما، وفي حالة لم يلتقط البرنامج استثناءً معينًا، ترسَل قائمة الاستدعاءات إلى الخرْج القياسي بصورةٍ افتراضيةٍ. تعليمة Try تلتقِط تعليمة try الاستثناءات في برامج جافا، حيث تشبه تعليمات try التعليمة التالية: try { double determinant = M[0][0]*M[1][1] - M[0][1]*M[1][0]; System.out.println("The determinant of M is " + determinant); } catch ( ArrayIndexOutOfBoundsException e ) { System.out.println("M is the wrong size to have a determinant."); e.printStackTrace(); } سيحاول الحاسوب أن ينفّذ كتلة التعليمات التالية لكلمة try، وإذا لم يحدث استثناء أثناء تنفيذها، فإنه سيتجاهل تمامًا عبارة catch ضمن التعليمة؛ أما إذا حدث استثناء من النوع ArrayIndexOutOfBoundsException فسيقفز الحاسوب إلى عِبارة catch الخاصة بالتعليمة، حيث تعالِج عِبارة catch في هذه الحالة الاستثناء من النوع ArrayIndexOutOfBoundsException إذ ستمنع معالجة الاستثناء بهذه الطريقة البرنامج من الانهيار. تتيح قواعد صيغة تعليمة try الكثير من الخيارات الأخرى، فمثلًا يمكن لتعليمة try..catch أن تتضمّن أكثر من عِبارة catch واحدةٍ، الأمر الذي ما يسمَح بالتقاط أنواعٍ مختلفةٍ من الاستثناءات ضِمن تعليمةٍ try واحدةٍ، ويمكن أن يحدَث استثناء من النوع NullPointerException إذا كانت قيمة M فارغةٌ بالإضافة إلى الاستثناء من النوع ArrayIndexOutOfBoundsException الذي حدَث في المثال السابق، ونعالِج لاستثناءين بإضافة عبارة catch أخرى إلى تعليمة try، هكذا: try { double determinant = M[0][0]*M[1][1] - M[0][1]*M[1][0]; System.out.println("The determinant of M is " + determinant); } catch ( ArrayIndexOutOfBoundsException e ) { System.out.println("M is the wrong size to have a determinant."); } catch ( NullPointerException e ) { System.out.print("Programming error! M doesn't exist." + ); } سيحاول الحاسوب أن ينفّذ التعليمات ضِمن عِبارة try، فإذا لم يقع أيّ خطأٍ، سيتخطّى الحاسوب عِبارتي catch في المثال السابق؛ أما إذا حدث استثناء من النوع ArrayIndexOutOfBoundsException فسينفّذ الحاسوب عِبارة catch الأولى ويتخطّى الثانية، بينما إذا حدث استثناء من النوع NullPointerException، فسينفّذ الحاسوب عِبارة catch الثانية ويتجاهل عِبارة catch الأولى. يُشتّق الصنفان التاليان من الصنف RuntimeException: ArrayIndexOutOfBoundsException NullPointerException ولذلك يسهُل أن تلتقِط جميع الاستثناءات من النوع RuntimeException بعبارة catch واحدةٍ كما في المثال التالي: try { double determinant = M[0][0]*M[1][1] - M[0][1]*M[1][0]; System.out.println("The determinant of M is " + determinant); } catch ( RuntimeException err ) { System.out.println("Sorry, an error has occurred."); System.out.println("The error was: " + err); } ستَلتقط عبارة catch في تعليمة try السابقة أيّ استثناء مشتقٍ من الصنف RuntimeException أو من أصنافه الفرعية؛ ويوضِّح ذلك هدف تنظيم الأصناف الممثِّلة للاستثناءات في هيئة سلالة أصنافٍ، حيث يمكنك أن تضيّق نطاق الالتقاط على نوع استثناء محدّدٍ أو توسّعه ليلتقط نطاقًا أوسع من أنواع الاستثناءات. يتوافق ملتقِط الاستثناءات من النوع RuntimeException مع أنواعٍ كثيرةٍ أخرى غير تلك التي تكون موضع الاهتمام، ولذلك يمكنك أن تدمج أنواع الاستثناءات المتوقّعة ضِمن عِبارة catch واحدةٍ كالتالي: try { double determinant = M[0][0]*M[1][1] - M[0][1]*M[1][0]; System.out.println("The determinant of M is " + determinant); } catch ( NullPointerException | ArrayIndexOutOfBoundsException err ) { System.out.println("Sorry, an error has occurred."); System.out.println("The error was: " + err); } دمجنا في المثال السابق الاستثناءين بواسطة محرِّف الخط العمودي "|" الذي يُستخدم أيضًا لتمثيل العامل المنطقي or، حيث تلتقط عبارة catch في المثال السابق الأخطاء من النوعين الآتيين فقط: NullPointerException ArrayIndexOutOfBoundsException في الحقيقة، المثال السابق غير واقعيٍ؛ لأنه من يستبعَد استخدام معالجة الاستثناءات لتخطّي أخطاءٍ مثل استخدام مؤشرٍ فارغٍ null pointer أو استخدام فهرسٍ غير صالحٍ لمصفوفة، ولذلك عليك أن تتأكّد من أن البرنامج قد أسنَد assign قيمةً غير فارغةٍ للمصفوفة M، فلو كانت لغة جافا ستجبرك على كتابة تعليمة try..catch في كل مرةٍ تستخدم فيها مصفوفة، لكنت ستشعر بالاستياء بلا شك، ولهذا السبب لا تُعَد معالجة الاستثناءات من النوع RuntimeException عمليةً ضروريةً أو إلزاميةً؛ فهناك أمورً كثيرةً قد تقع على نحوٍ خاطئٍ، وعمومًا نستنتج من ذلك أن معالجة الاستثناءات لا تمثِّل حلًا لمشكلة متانة البرامج، وإنما هي مجرّد أداةٍ تساعدك على حلّ المشكلة بطريقة أكثر تنظيمًا في معظم الحالات. نستكمل حديثنا عن قواعد الصيغة الخاصة بتعليمة try، فبالإضافة إلى ما سبق، يمكنك أن تضيف عِبارة finally في النهاية على النحو التالي: try { statements } optional-catch-clauses optional-finally-clause سنكتب عبارة catch بالصيغة التالية: catch ( exception-class-names variable-name ) { statements } قد تمثِّل exception-class-names صنف استثناء واحدٍ أو عدّة أصنافٍ يفصِل بينها المحرِّف "|"، وسنكتب عِبارة finally بالصيغة التالية: finally { statements } تنفَّذ عِبارة finally دائمًا مثل خطوةٍ أخيرةٍ ضِمن تعليمة try، وسواءٌ إذا حدَث استثناء وعولِج أولم يعالَج، أو إذا لم يحدُث من الأساس، ستتضمّن عبارة finally بعض الشيفرة المسئولة عن عمليات التنظيف cleanup التي ستنفَّذ في جميع الأحوال، وأحد الأمثلة على شيفرة التنظيف هو غلْق الاتصال الشبكي المفتوح انظر مثلًا إلى خوارزمية الشيفرة الوهمية (استخدمنا شيفرةً وهميةً لأننا لم نناقش الشبكات بعد) التالية: try { // افتح اتصال شبكي open a network connection // تفاعَل عبر الاتصال communicate over the connection } catch ( IOException e ) { // بلِّغ عن خطأ report the error } finally { // إذا كان الاتصال قد فُتح بنجاح if the connection was successfully opened // أغلِق الاتصال close the connection } تتأكّد عِبارة finally من أن الاتصال الشبكي قد أُغلق بصوةٍ مؤكَّدةٍ سواءٌ حدَث خطأٌ أو لم يحدُث أثناء الاتصال، حيث تتبّع الشيفرة الوهمية في المثال السابق نمطًا pattern شائعًا عندما تتعامل مع الموارد resources، فتحصل أولًا على المورِد ثم استخدامه وبعد ذلك تحرِّره release في نهاية الأمر. يشتَرط لنفعّل ذلك الخيار أن يمثَّل المورد بواسطة كائنٍ ينفِّذ implement واجهة interface تُعرف بواجهة AutoCloseable، حيث تعرِّف تابعًا اسمه close() دون أية معامِلات parameters؛ وعمومًا تنفِّذ أصناف جافا القياسية الممثِّلة لأشياء مثل الملفات والاتصالات الشبكية تلك الواجهة بالفعل، كما ينفِّذ الصنف Scanner تلك الواجهة، وستستخدِم الشيفرة التالية النمط السابق لتتأكّد من غلْق كائنٍ من الصنف Scanner بصورةٍ تلقائيةٍ: try( Scanner in = new Scanner(System.in) ) { // استخدم الصنف لتقرأ الدخل القياسي } catch (Exception e) { // حدثت بعض الأخطاء أثناء استخدام الصنف } يُشترط للتعليمة التي تنشِئ كائنًا من الصنف Scanner أن توضع ضِمن قوسين بعد كلمة try، وأن يعرّف المتغيّر variable declaration الموجود بها وأن تتضمّن تهيئةً initialization مبدئيةً لقيمة المتغيّر، بمعنى أن يكون المتغيّر محليًا local، كما يمكنك أن تعرِّف عِدة متغيّراتٍ يُفصل بينها بفاصلةٍ منقوطةٍ ضِمن هذين القوسين؛ وتضمن الطريقة السابقة نجاح استدعاء النظام للدالة in.close() في نهاية تعليمة try بشرْط أن يُهيأ الكائن الممثِّل للصنف Scanner بنجاح. ما تزال هناك خياراتٌ أخرى توفّرها تعليمة try، ويمكنك أن تطّلع على برنامج TryStatementDemo.java، حيث يتضمّن أمثلةً على جميع تلك الخيارات،كما ستجد الكثير من التعليقات التي قد تساعدك على فهْم ما قد يحدُث عند تنفيذ البرنامج. التبليغ عن الاستثناءات Throwing Exceptions قد يبلِّغ البرنامج نفْسه في بعض الأحيان عن اسستثناء بصورةٍ متعمَّدة، فمثًا قد يكتشِف البرنامج نوعًا معينًا من الأخطاء التي لا يجد طريقةً معقولةً ليعالجها ضِمن تلك النقطة من البرنامج التي حدثت فيها المشكلة، وفي تلك الحالة، يستطيع البرنامج أن يبلِّغ عن استثناء على أمل أن يلتقطه جزءٌ آخرٌ من البرنامج ويعالجه؛ وسنستخدِم تعليمة throw لذلك، حيث تُكتب على النحو التالي: throw exception-object ; يجب أن ينتمي exception-object إلى أحد الأصناف الفرعية المشتقّة من Throwable، وعادةً ما ينتمي إلى صنفٍ فرعيٍ من الصنف Exception على وجه التحديد، ويُنشَأ باستخدام العامل new كالتالي: throw new ArithmeticException("Division by zero"); يمثِّل المُعامل الممرِّر للمنشِأ رسالة الخطأ ضِمن كائن استثناء، فمثلًا إذا كان e يشير إلى ذلك الكائن، فتُستعاد نفْس رسالة الخطأ باستدعاء e.getMessage(). قد تجد أن المثال السابق سخيفًا نوعًا ما؛ لأنك قد تتوقّع أن النظام سيبلّغ عن استثناء من النوع ArithmeticException عندما يَقسم عددًا على 0، فإذا كانت الأعداد المقسومة من النوع int، إذًا ستؤدي القسمة على 0 إلى حدوث استثناء من النوع المذكور، بينما إذا تضمنت العمليات الحسابية أعدادًا حقيقيةً، فلن تقع أيّ استثناءاتٍ نهائيًا، وإنما ستُستخدم القيمة الخاصة Double.NaN لتمثِّل عمليةً غير صالحةٍ؛ ومع ذلك قد يُبلّغ أحيانًا عن استثناء من النوع ArithmeticException عند قسمة عددٍ حقيقيٍ على 0. تعالَج الاستثناءات المبلّغ عنها سواءٌ عن طريق النظام أو تعليمة throw بنفْس الطريقة، سنفترض أن لدينا استثناءً بُلغ عنه ضِمن تعليمة try، فإذا احتوت تلك التعليمة على عِبارة catch تتوافق مع نوع الاستثناء المبلّغ عنه، فسيقفز الحاسوب إلى تلك العِبارة وينفّذها، وحينها يعالَج الاستثناء؛ ثم سينفّذ الحاسوب عِبارة finally إذا ضمِّنت في تعليمة try وسيستمر الحاسوب في تنفيذ شيفرة البرنامج بصورةٍ طبيعيةٍ، أي سينتقل إلى التعليمات التالية لتعليمة try، بينما إذا لم يُلتقط الاستثناء ويعالَج على الفور، فسيستمر الحاسوب بعملية معالجة الاستثناء. ينتهي البرنامج بعد تنفيذ عِبارة finally ويحصُل البرنامج routine المستدعي لذاك البرنامج الفرعي على فرصةٍ ليعالِج الاستثناء إذا حدث استثناء أثناء تنفيذ برنامجٍ فرعيٍ، ولم يعالَج بواسطة البرنامج الفرعي؛ فمثلًا، إذا وقَعت عملية استدعاء البرنامج الفرعي ضِمن تعليمة try تتوافق عِبارة catch الموجودة بها مع نوع الاستثناء المبلّغ عنه، فسينفِّذ الحاسوب تلك العِبارة وسيستمر بعدها في تنفيذ البرنامج بصورةٍ طبيعيةٍ، بينما إذا لم يعالِج البرنامج الثاني الاستثناء أيضًا فسينتهي، وحينها سيحصل البرنامج المُستدعي له على فرصته ليعالج الاستثناء وهكذا، وفي حال مرّ الاستثناء بكامل سلسلة استدعاءات البرامج الفرعية دون أن يعالَج، فسينهار البرنامج بالكامل. يُحتمل لأي برنامجٍ فرعيٍ أن يولّد استثناء، ويعلِن عن تلك الحقيقة صراحةً بإضافة العبارة throws exception-class-name إلى تعريفه، انظر المثال التالي: // [1] static public double root( double A, double B, double C ) throws IllegalArgumentException { if (A == 0) { throw new IllegalArgumentException("A can't be zero."); } else { double disc = B*B - 4*A*C; if (disc < 0) throw new IllegalArgumentException("Discriminant < zero."); return (-B + Math.sqrt(disc)) / (2*A); } } [1] يعيد قيمة الجَذر الأكبر للمعادلة التربيعية Axx + Bx + C = 0، فإذا كان A == 0 أو BB - 4AC قيمةً سالبةً، حينها سيبلَّغ عن استثناء من النوع IllegalArgumentException. تفترض الشيفرة السابقة أن الشرْطين المُسبَقين هما A != 0 وBB-4AC >= 0، وسيبلّغ البرنامج عن استثناء من النوع IllegalArgumentException إذا لم يتحقّق أي من الشرطين السابقين، وعمومًا، عندما يجد برنامجٌ فرعيٌ شرطًا غير صالحٍ فإنّه يبلِّغ عن الاستثناء بصورةٍ مناسبةٍ، فإذا امتلك البرنامج المستدعي للبرنامج الفرعي طريقةً مناسبةً لمعالجة الخطأ، فسيَلتقط الاستثناء ويعالجه، بينما سينهار البرنامج إذا لم يعالَج الاستثناء، وسيكون على المبرمج إصلاح برنامجه. تتضمّن عبارة throw الموجودة تعريف definition البرنامج الفرعي أنواعًا مختلفةً من الاستثناءات، يمكن أن يُفصل بينها بفاصلةٍ منقوطةٍ كالتالي: void processArray(int[] A) throws NullPointerException, ArrayIndexOutOfBoundsException { ... معالجة الأحداث الإجبارية Mandatory Exception Handling وضّحنا في المثال السابق إمكانية تبليغ البرنامج الفرعي root() عن استثناء من النوع IllegalArgumentException، الأمر الذي يُعَد مجرد مجاملةً للقارئين المحتملين للشيفرة، وذلك لأن الاستثناءات من النوع IllegalArgumentException ليست إجباريةً، إذ يمكن لأي برنامجٍ أن يبلّغ عن استثناء من النوع IllegalArgumentException دون أن يعلِن عن ذلك صراحةً، وعليه قد يَلتقط البرنامج المستدعي الاستثناء أو يتجاهله بنفس الكيفية التي يمكن لمبرمجٍ أن يختار التقاط استثناء من النوع NullPointerException أو يتجاهله. يختلف الأمر بالنسبة لأصناف الاستثناءات التي تتطلّب ما يعرَف باسم المعالجة الإجبارية mandatory handling، فإذا أمكن لبرنامجٍ فرعيٍ أن يبلّغ عن استثناء من ذلك النوع، فيجب أن يعلَن عن ذلك صراحةً ضِمن تعريف البرنامج routine definition باستخدام عِبارة throws، حيث يُعَد عدم الإعلان في تلك الحالة خطأً في قواعد بناء الجملة syntax error، ويطلَق عليها اسم الاستثناءات المتحقَّق منها checked exceptions، أي أن المصرِّف سيفحص إذا ما كانت تلك الاستثناءات قد عولجت أم لا. سنفترض أنه من الممكن لتعليمةٍ ضِمن برنامجٍ فرعيٍ أن تولّد استثناءً متحقَّقًا منه، بمعنى أنه استثناء يتطلّب معالجةً إجباريةً، فمثلًا قد تكون تعليمة throw إذا كان التبليغ مباشرًا، أو تعليمة استدعاءٍ لبرنامجٍ فرعيٍ آخرٍ قد أعلَن عن قدرته على التبليغ عن الاستثناء؛ وفي جميع الحالات، يجب أن يعالَج الاستثناء، وهو ما يتحقَّق بطريقةٍ من اثنتين، الأولى عن طريق وضع تلك التعليمة داخل تعليمة try تتضمّن عِبارة catch متلائمةً مع نوع الاستثناء، في تلك الحالة يعالِج البرنامج الفرعي الاستثناء، وبذلك لن يرى أي مستدعي له ذلك الاستثناء أبدًا، بينما يعلِن البرنامج الفرعي عن قدرته على التبليغ عن الاستثناء من خلال إضافة عِبارة throws إلى تعريفه لتنبيه أي مستدعي له من احتمالية حدوث استثناء أثناء الاستدعاء، وبذلك يَلزم ذلك المستدعي الجديد أن يعالِج الاستثناء ضِمن تعليمة try أو أن يعلِن بدوره عن إمكانية حدوث الاستثناء ضِمن تعريفه. تُعَد معالجة الاستثناء إجباريةً لأي صنفٍ غير مشتقٍ من الصنف الفرعي RuntimeException أو الصنف Error، حيث تمثِّل الاستثناءات المتحقّق منها شروطًا لا يتحكّم بها المبرمج مثل قيمةٍ مُدخَلةٍ غير صالحةٍ أو فعلٍ غير صالحٍ من قبل المستخدم، ولأنه لا توجد طريقةٌ لتتجنّب وقوع مثل تلك الأخطاء، يَلزم أي برنامجٍ متينٍ أن يستعد لمعالجتها، ولهذا لا تسمح جافا للمبرمجين بتجاهُل معالجة تلك الأخطاء. تحتوي برامج جافا للدخْل والخرْج على جزءٍ كبيرٍ من الاستثتاء المتحقَّق منها، ويعني ذلك أنه يصعُب استخدام تلك البرامج دون درايةٍ كافيةٍ بمفهوم معالجة الاستثناءات، وسنناقش عمليات الدخْل والخرْج وكيفية استخدام الاستثناءات المتحقَّق منها على بصورةٍ مكثّفةٍ في جزئية لاحقة من هذه السلسلة. البرمجة باستخدام الاستثناءات يساعدك استخدام الاستثناءات على كتابة برامجٍ متينةٍ لأنها توفّر أسلوبًا مُنظمًا ومُهيئًا للتعامل مع المتانة، إذ تحتاج عادةً عندما تكتب برنامجٍ متينٍ إلى كتابة الكثير من تعليمات if، الأمر الذي عادةً ما يشوّش على الشيفرة الأساسية، ولحسن الحظ، تمكّنك الاستثناءات من كتابة تنفيذٍ نظيفٍ يشمل جميع الحالات الطبيعية لخوارزميةٍ معينةٍ، بحيث تعالَج الحالات الاستثنائية بعبارة catch ضِمن تعليمة try. يبلّغ البرنامج عن استثناء عندما يواجه حالةً استثنائيةً ولا يجد طريقةً لمعالجتها، ففي بعض الحالات، يكون من المنطقي التبليغ عن استثناء ينتمي لإحدى أصناف جافا المعرّفة مسبقًا مثل IllegalArgumentException أو IOException، بينما إذا لم يتوفّر صنفٌ قياسيٌ يمثِّل تلك الحالة الاستثنائية تمثيلًا كافيًا، سنعرِّف صنفًا جديدًا بشرْط أن ينتمي للصنف القياسي Throwable أو أيًا من أصنافه الفرعية. فمثلًا، يُشتق الصنف التالي من الصنف Exception، وذلك لأنه يتطلّب معالجةً اجباريةً عند استخدامه: public class ParseError extends Exception { public ParseError(String message) { // أنشيء كائنًا من النوع ParseError بحيث يحتوي على رسالة الخطأ الممرَّرة super(message); } } يبني المُنشِئ constructor المُعرَّف سابقًا بإنشاء كائناتٍ من النوع ParseError، ويرث الصنف البرامج getMessage() وprintStackTrace() من الصنف الأعلى، كما تَستدعي التعليمة super(message) المُنشِئ المُعرَّف في الصنف الأعلى superclass، فإذا كان e كائنًا من النوع ParseError، فسيسترجع استدعاء الدالة e.getMessage() رسالة الخطأ المخصّصة بالمُنشِئ. سنستخدم تعليمة throw التالية للتبليغ عن استثناء من النوع ParseError، إذ يجب أن تمرّر رسالة خطأٍ إلى منشِئ الكائن، كما يلي: throw new ParseError("Encountered an illegal negative number."); أو هكذا: throw new ParseError("The word '" + word + "' is not a valid file name."); يُعَدParseError صنفًا فرعيًا من الصنف Exception، ولذلك فإنه يمثِّل استثناءً متحقَّقًا منه، بمعني أنه إذا لم تقع تعليمة throw المبلّغة عنه ضِمن تعليمة try لالتقاطه، فيجب أن يعلِن البرنامج الفرعي المتضمِّن للتعليمة عن قدرته على التبليغ عن استثناء من النوع ParseError بإضافة عبارة throws ParseError إلى تعريفه، هكذا: void getUserData() throws ParseError { . . . } في المقابل، إذا اشتُق ParseError من الصنف الفرعي RuntimeException بدلًا من الصنف Exception، فلن يكون من الضروري إضافة العبارة السابقة؛ إذ يُعَد الاستثناء ParseError في هذه الحالة استثناءً متحقَّقًا منه. تُستخدم تعليمة try مع عِبارة catch خاصة بالصنف ParseError إذا أراد برنامجٌ معينٌ أن يعالِج استثناء من النوع ParseError، هكذا: try { getUserData(); processUserData(); } catch (ParseError pe) { . . . // Handle the error } تَلتقط عبارةً مثل catch (Exception e) الاستثناءات من النوع ParseError إلى جانب أيّ كائنٍ آخرٍ من النوع Exception، حيث يُعَد ParseError صنفًا فرعيًا من الصنف Exception. يفيد عادةً تخزين بعض البيانات الإضافية ضمن الكائنات الممثِّلة للاستثناءات كالتالي: class ShipDestroyed extends RuntimeException { Ship ship; // Which ship was destroyed. int where_x, where_y; // Location where ship was destroyed. ShipDestroyed(String message, Ship s, int x, int y) { super(message); ship = s; where_x = x; where_y = y; } } يتضمّن كائن ShipDestroyed المعرَّف في المثال السابق رسالة خطأٍ إلى جانب بعض المعلومات الإضافية عن السفينة المدمَّرة، انظر المثال التالي: if ( userShip.isHit() ) throw new ShipDestroyed("You've been hit!", userShip, xPos, yPos); قد لا يمثِّل الشرط المستخدَم خطأً فعليًا، وإنما قد يكون مجرد انحرافٍ عن المسار الطبيعي للعبة، حيث تُستخدم الاستثناءات في بعض الأحيان لتعالِج مثل تلك الانحرافات بدقةٍ. تبرز فائدة التبليغ عن الاستثناءات عندما تكتب توابعًا أو أصنافًا متعددة الأغراض، ضمن أكثر من برنامجٍ واحدٍ، ففي مثل تلك الحالات، قد لا يدرك مبرمج تلك التوابع والأصناف أنسب طريقةٍ لمعالجة خطأٍ معينٍ؛ إذ أنه لا يعرِف الكيفية التي سيستخدِم بها ذلك التابع أو الصنف، ويكتفي المبرمجون المبتدئون في تلك الحالة بطباعة رسالة خطأٍ ثم الاستمرار على نحوٍ طبيعيٍ، الأمر الذي قد يؤدي إلى نتائج غير متوقعةٍ، إذ لا يُعَد طباعة رسالة خطأٍ وإنهاء البرنامج الخيار الأفضل أيضًا؛ وذلك لأنه لا يعطي فرصةً للبرنامج ليعالِج الخطأ. يحتاج البرنامج المستدعي للدالة أو المستخدِم للصنف إلى معرفة أن خطأً معينًا قد حدث بالفعل، وفي اللغات التي لا تدعم الاستثناءات، يكون البديل الوحيد هو إعادة قيمةٍ خاصةٍ أو ضبْط قيمة متغيّرٍ عامٍ global variable ليشير إلى حدوث الخطأ، فمثلًا، تعيد الدالة readMeasurement() قيمة "1-" إذا كانت قيمة مُدخَل المستخدم غير صالحةٍ، ومع ذلك قد لا يَفحص البرنامج القيمة المعادة من الدالة، كما أنه لا يستخدِم القيمة "1-" للإشارة على حدوث خطأٍ من قراءة القياسات السالبة؛ ولهذه الأسباب تُعَدّ الاستثناءات الطريقة الأمثل التي ينبغي للبرامج الفرعية أن تَلجأ إليها عندما تواجه خطأً معينًا. يَسهُل أن نعدّل الدالة readMeasurement() لتبلّغ عن استثناء بدلًا من أن تعيد قيمةً خاصةً تمثِّل حدوث خطأٍ، فقد تبلّغ النسخة المعدّلة من البرنامج الفرعي عن استثناء من النوع ParseError المشتق من الصنف Exception إذا أدخل المستخدم قيمةً غير صالحةٍ، وقد يكون من الأنسب في تلك الحالة أن يبلَّغ عن استثناء من الصنف القياسي IllegalArgumentException بدلًا من اللجوء إلى تعريف صنفٍ جديدٍ، انظر النسخة المعدّلة من الدالة: // [2] static double readMeasurement() throws ParseError { double inches; // حاصل مجموع البوصات الكلية double measurement; // قيمة القياس المُدخَلة String units; // وحدة القياس المُدخَلة char ch; // لفحْص الحرف التالي من مُدخَل المستخدم inches = 0; // No inches have yet been read. skipBlanks(); ch = TextIO.peek(); // [1] while (ch != '\n') { /* Get the next measurement and the units. Before reading anything, make sure that a legal value is there to read. */ if ( ! Character.isDigit(ch) ) { throw new ParseError("Expected to find a number, but found " + ch); } measurement = TextIO.getDouble(); skipBlanks(); if (TextIO.peek() == '\n') { throw new ParseError("Missing unit of measure at end of line."); } units = TextIO.getWord(); units = units.toLowerCase(); /* حوّل قيمة القياس إلى البوصة وأضفها إلى inches */ if (units.equals("inch") || units.equals("inches") || units.equals("in")) { inches += measurement; } else if (units.equals("foot") || units.equals("feet") || units.equals("ft")) { inches += measurement * 12; } else if (units.equals("yard") || units.equals("yards") || units.equals("yd")) { inches += measurement * 36; } else if (units.equals("mile") || units.equals("miles") || units.equals("mi")) { inches += measurement * 12 * 5280; } else { throw new ParseError("\"" + units + "\" is not a legal unit of measure."); } // اختبر إذا ما كان المحرِّف التالي هو محرِّف نهاية السطر أم لا skipBlanks(); ch = TextIO.peek(); } // end while return inches; } // end readMeasurement() [1] إذا كان هناك مُدخَلات أخرى في السطر، اقرأ قيمة القياس وأضف مكافئها من البوصات إلى المتغيّر inches، وإذا وقع خطأٌ أثناء تنفيذ الحلقة، أنهِ البرنامج الفرعي فورًا وأعد القيمة "1-". [2] اقرأ سطرًا واحدًأ من مُدخَلات المستخدِم، بحيث يكون الشرط المُسبَق: السطر المُدخَل غير فارغ، ويكون الشرط اللاحق: إذا كان مُدخَل المستخدم صالحًا، حوّل قيمة القياس إلى وحدة البوصة وأعدها مثل قيمةٍ للدالة، بينما إذا كانت قيمة المُدخَل غير صالحةٍ، ستبلّغ الدالة عن استثناء من النوع ParseError. سنستدعي البرنامج الفرعي المعرَّف في المثال السابق داخل تعليمة try، هكذا: try { inches = readMeasurement(); } catch (ParseError e) { . . . // Handle the error. } يمكنك الاطلاع على شيفرة البرنامج بالكامل في الملف LengthConverter3.java. ترجمة -بتصرّف- للمقال Section 3: Exceptions and try..catch من فصل Chapter 8: Correctness, Robustness, Efficiency من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: كيفية كتابة برامج صحيحة باستخدام لغة جافا مقدمة إلى الاستثناءات exceptions ومعالجتها في جافا الاستثناءات Exceptions في Cpp الاستثناءات Exceptions في dot NET
-
لا تُكتب البرامج بصورةٍ صحيحةٍ من قبيل المصادفة، وإنما تتطلّب تخطيطًا وانتباهًا للتفاصيل لتجنُّب أي أخطاءٍ محتمَلةٍ، ولحسن الحظ، تتوفّر بعض التقنيات التي عادةً ما يستعين بها المبرمجون لزيادة فرصة صحة برامجهم. برامج يمكن إثبات صحتها تستطيع في بعض الحالات القليلة أن تُثبت صحة برنامجٍ، بمعنى أن توضِّح بصورةٍ رياضيةٍ أن متتالية الحسابات التي يمثّلها البرنامج ستعطي دائمًا النتيجة الصحيحة، وغالبًا ما يصعُب توفير هذا النوع من الإثباتات الصارمة، فغالبًا ما يَقتصر تطبيقها بصورةٍ عمليةٍ على البرامج الصغيرة. وإلى جانب ذلك، يجب أن يوصَّف البرنامج على نحوٍ سليمٍ وكاملٍ ليعطي النتيجة الصحيحةً دائمًا؛ فليس هناك أي فائدةٍ من برنامج ينفّذ توصيفًا specification خاطئًا حتى إذا نفّذه بصورةٍ سليمةٍ، حيث تتوفّر في الواقع بعض الأفكار والأساليب التي تُستخدم لإثبات صحة البرامج. يُقصد بالأفكار الأساسية كلًا من الحالة state والعملية process؛ وتتكون الحالة من كلّ المعلومات المتعلّقة بتنفيذ البرنامج خلال لحظةٍ معينةٍ، فقد تتضمن قيم المتغيّرات مثلًا، الخرْج الناتج (أي دخْلٍ يُنتظر قراءته) من سطْر البرنامج المنفَّذ حاليًا؛ بينما تتكون العملية من متتالية الحالات التي يمر بها البرنامج أثناء تنفيذه، ومن وجهة النظر تلك، يمكننا أن نفسّر معنى أي تعليمةٍ statement ضِمن برنامجٍ بالكيفية التي تؤثّر بها على حالته، فمثلًا، تعني التعليمة x = 7 أن قيمة المتغيّر x ستساوي 7، في الواقع نحن على يقينٍ من تلك النتيجة تمامًا، ولهذا يمكننا أن نبني على أساسها إثباتًا رياضيًا. قد ندرك أحيانًا أن حقيقةً معينةً هي تصِح خلال لحظةٍ معينةٍ في البرنامج بمجرد أن ننظر إليه؛ انظر إلى حلقة التكرار loop التالية: do { System.out.print("Enter a positive integer: "); N = TextIO.getlnInt(); } while (N <= 0); ندرك أن قيمة المتغيّر N ستكون أكبر من الصفر بعد تنفيذ الحلقة السابقة؛ لأنها لن تنتهي حتى يَصِح ذلك الشرط condition، حيث تُعَد تلك الحقيقة جزءًا من معنى حلقة التكرار while؛ وعمومًا إذا تضمّنت حلقة التكرار while اختبارًا while (condition)، ولم تكن تحتوي على أي تعليماتٍ من نوع break، فسنعلم يقينًا أن ذلك الشرط لن يكون متحقِّقًا بعد انتهائها، وقد نبني على تلك الحقيقة استنتاجاتٍ أخرى ضِمن لحظاتٍ أخرىٍ في نفْس البرنامج، ويجب أن نتأكد أيضًا من إمكانية انتهاء حلقة التكرار خلال وقتٍ ما. الشروط اللاحقة Postconditions والشروط المسبقة Pretconditions يمكننا أن نُثبِت صحة الشروط اللاحقة ضِمن برنامجٍ متى انتهينا من تنفيذه، بمعنى أنها ستكون بمثابة حقائق معروفةٍ قد نبني عليها استنتاجاتٍ عن سلوك البرنامج. وبتعبيرٍ آخرٍ، يُعَد أي شرْطٍ لاحقٍ ضِمن برنامجٍ حقيقةً يمكن إثباتها بمجرد انتهاء تنفيذ البرنامج، وعليه يمكننا أن نُثبِت صحة برنامجٍ معينٍ عن طريق بأن نوضّح أن شروطه اللاحقة مُستوفيةٌ لتوصيفه. انظر شيفرة المثال التالي حيث كل المتغيّرات من النوع double: disc = B*B - 4*A*C; x = (-B + Math.sqrt(disc)) / (2*A); نفرض أن قيمة disc أكبر من أو تساوي الصفر وأن قيمة A غير صفريةٍ، إذ تؤكد لنا المعادلة التربيعية في الشيفرة السابقة أن القيمة المسنَدة للمتغيّر x هي حلٌ للمعادلة A*x2 + B*x + C = 0، وإذا كنا سنضمن صحة كلّ من الشرطين B*B-4*A*C >= 0 وA != 0، ستكون عندها حقيقةً "لأن x يمثّل حلًا للمعادلة" شَرطًا لاحقًا لذلك الجزء من البرنامج، كما سيكون عندها الشرْط B*B-4*A*C >= 0 شرطًا مُسبقًا، بالإضافة إلى الشرْط A != 0 لذلك الجزء من البرنامج، ويمكننا تعريف الشرْط المُسبق بأنه شرطٌ يجب أن يتحقّق ضِمن نقطةٍ معينةٍ في البرنامج ليستمر على نحوٍ صحيحٍ. إذا ما أردت لبرنامجك أن يكون صحيحًا، tيجب أن تفْحص الشرْط المُسبق إذا ما كان متحقِّقًا أم لا، أو أن تُجبره على ذلك. لقد تعرّضنا لمفهوميْ الشروط اللاحقة والشروط المسبَقة من قَبل في القِسم 4.7.1 لكونهما طريقةٌ لتخصيص المواصفة الاصطلاحية contract لبرنامجٍ فرعيٍ subroutine، حيث عرّفنا الشرْط المسبَق لبرنامجٍ فرعيٍ بأنه شرْطٌ مُسبَقٌ لشيفرة ذلك البرنامج الفرعي، بينما عرّفنا الشرْط اللاحق بأنه شرطٌ يتحقّق بتنفيذ تلك الشيفرة؛ وبهذا القِسم، عمّمنا المقصود بهذين المصطلحين ليكونا أكثر فائدةً بينما نتحدث عن صحة البرامج عمومًا. افحص المثال التالي: do { System.out.println("Enter A, B, and C."); System.out.println("A must be non-zero and B*B-4*A*C must be >= 0."); System.out.print("A = "); A = TextIO.getlnDouble(); System.out.print("B = "); B = TextIO.getlnDouble(); System.out.print("C = "); C = TextIO.getlnDouble(); if (A == 0 || B*B - 4*A*C < 0) System.out.println("Your input is illegal. Try again."); } while (A == 0 || B*B - 4*A*C < 0); disc = B*B - 4*A*C; x = (-B + Math.sqrt(disc)) / (2*A); بعدما تنتهي الحلقة، نتأكد من تحقُّق الشرطين B*B-4*A*C >= 0 وA != 0، ونظرًا لتحقُّق الشروط المسبقَة للسطرين الأخيرين، فإن الشرْط اللاحق (أي كَوْن x حلًا للمعادلة A*x2 + B*x + C = 0) يكون صالحٌ كذلك، إذًا تَحسِب الشيفرة السابقة حلّ المعادلة بصورةٍ صحيحةٍ ومُثبَتةٍ. الآن سنفحص مثالًا آخر، تختبِر تعليمة if في الشيفرة التالية شرطًا مسبَقًا، إذ يتحقّق بكلّ تأكيدٍ أثناء حساب قيمة الحل وطباعته ضِمن الجزء الأول من تعليمة if، بينما لا يتحقّق الشرط ضِمن الأجزاء الأخرى من التعليمة، إلا أن البرنامج صحيحٌ في جميع الأحوال. System.out.println("Enter your values for A, B, and C."); System.out.print("A = "); A = TextIO.getlnDouble(); System.out.print("B = "); B = TextIO.getlnDouble(); System.out.print("C = "); C = TextIO.getlnDouble(); if (A != 0 && B*B - 4*A*C >= 0) { disc = B*B - 4*A*C; x = (-B + Math.sqrt(disc)) / (2*A); System.out.println("A solution of A*X*X + B*X + C = 0 is " + x); } else if (A == 0) { System.out.println("The value of A cannot be zero."); } else { System.out.println("Since B*B - 4*A*C is less than zero, the"); System.out.println("equation A*X*X + B*X + C = 0 has no solution."); } قبل تكتب برنامجًا، فكِّر بشروطه المسبَقة وبالكيفية التي سيتعامل بها برنامجك معها؛ فغالبًا ما سيعطيك ذلك الشرْط تلميحًا لما ينبغي أن تفعله. فمثلًا، يملِك كل عنصر مصفوفة مثل A[i] شرطًا مسبَقًا بضرورة أن يقع الموضع المُشار إليه ضِمن مجال تلك المصفوفة؛ إذًا سيفحص الحاسوب الشرْط المسبَق للعنصر A[i]< وهو 0 <= i < A.length عندما يُحصّل قيمة A[i]، فإذا لم يتحقّق الشرْط فسينتهي البرنامج، وينبغي عليك أن تتجنّب وقوع ذلك. تبحث الشيفرة التالية عن عدد x ضِمن مصفوفة A، حيث تُضبَط قيمة i إلى موضع عنصر المصفوفة الذي يحتوي على ذلك العدد إذا وُجد: i = 0; while (A[i] != x) { i++; } عندما نُشغّل الشيفرة السابقة، يكون لدينا شرطٌ مسبَقٌ بأن العدد يوجد فعليًا ضِمن نطاق المصفوفة، فإذا تحقّق ذلك الشرط، فستنتهي الحلقة عندما تكون A[i] == x، وذلك يعني أن قيمة i ستحمل موضع العدد x في المصفوفة، بينما إذا لم يكن x ضِمن المصفوفة، فإن قيمة i ستزداد إلى أن تصل قيمتها إلى A.length، بحيث سيشير A[i] إلى عنصرٍ غير صالحٍ، ثم بعد ذلك سينتهي البرنامج؛ ولذلك يَلزم عليك أن تضيف اختبارًا يتأكد من تحقُّق الشرْط المسبَق، كالتالي: i = 0; while (i < A.length && A[i] != x) { i++; } هنا ستنتهي الحلقة، ونستطيع أن نحدّد أي الشرطين i == A.length أوA[i] == x قد تسبّب في انتهاء الحلقة، وهو ما تختبره تعليمة if في نهاية الشيفرة التالية: i = 0; while (i < A.length && A[i] != x) { i++; } if (i == A.length) System.out.println("x is not in the array"); else System.out.println("x is in position " + i); اللامتغايرات Invariants سنفحص الآن طريقة عمَل حلقات التكرار من قُربٍ، حيث يحسِب البرنامج الفرعي التالي حاصل مجموع عناصر مصفوفةٍ من الأعداد الصحيحة: static int arraySum( int[] A ) { int total = 0; int i = 0; while ( i < A.length ) { total = total + A[i]; i = i + 1; } return total; } سيفترِض البرنامج الفرعي السابق شرطًا مسبَقًا بأن A لا تحتوي على القيمة الفارغة، فإذا لم يتحقّق ذلك الشرْط، سيبلِّغ البرنامج الفرعي عن اعتراضٍ من النوع NullPointerException. إذًا كيف نتأكد من أن البرنامج في السابق يعمل بطريقةٍ سليمةٍ؟ ينبغي أن نُثبِت أن قيمة total تُساوي حاصل مجموع العناصر ضِمن المصفوفة A قبل تنفيذ تعليمة return؛ ولهذا نستخدم حلقات تكرار لامتغايرة loop invariants. تُمثّل لامتغاير حلقة التكرار تلك التعليمات التي تَبقى صحيحةً أثناء تنفيذ الحلقة، وبتعبيرٍ آخرٍ، نتحقّق من أن تعليمة معينةً تُعبّر عن حالة صحيحة ثابتة لحلقة التكرار إذا حافظت هذه التعليمة على تحقّقها وصحّتها قبل تنفيذ الشيفرة، وبعد انتهائها، ويعني ذلك أن اللامتغاير invariant يعبّر عن شرطٍ مسبَقٍ ولاحقٍ في نفس الوقت، وهو ما يجعل الحلقة أكثر متانةً. ملاحظة: اخترنا المصطلح العربي "لامتغاير" لترجمة المصطلح الأجنبي invariant، إذ معنى مُتغَاير variant بالعربية مختلف ومتباين ومتبدِّل وحتى تكون مختلفة عن "مُتغيِّر" التي هي ترجمة variable. فمثلًا، تُعَد تعليمة total (حاصل مجموع أول عددٍ i من عناصر المصفوفة A) لامتغاير لحلقة التكرار السابقة، وسنفترض أن ذلك كان متحققًا في بداية حلقة التكرار while، أي قبل تنفيذ التعليمة total = total + A[i]؛ لذا فبعْد إضافة A[i] إلى total، فستساوي total حاصل مجموع أول عددٍ i+1 من عناصر تلك المصفوفة، في هذه اللحظة تحديدًا لا يَصِح لامتغاير الحلقة، بينما يعود لامتغاير الحلقة ليُصبِح صحيحًا مرةً أخرى بعد أن ننفّذ التعليمة التالية i = i + 1، أي زيادة i بمقدار الواحد؛ وبذلك نكون قد تأكدنا من صحة لامتغاير الحلقة أثناء بداية الحلقة ونهايتها. هل نكون بذلك قد أثبتنا أن البرنامج الفرعي arraySum() صحيحٌ تمامًا؟ لا بكلّ تأكيد، فما تزال هناك بعض الأشياء التي يجب أن تُفحَص، حيث يجب أولًا أن نتأكد من صحة لامتغاير حلقة التكرار قبل أول تنفيذٍ للحلقة، ففي تلك اللحظة، كانت قيمة كلّ من i وtotal تساوي 0، وهو حاصل المجموع الصحيح لمصفوفةٍ فارغةٍ، ويعني ذلك أن لامتغاير الحلقة كان صحيحًا قبل بدايتها، كما أنه سيبقى صحيحًا بعد كلّ تنفيذٍ لها وكذلك حتى بعد انتهائها. تبقّى لنا أن نتأكد من قابلية حلقة التكرار للانتهاء، إذ تزداد قيمة i بمقدار الواحد مع كلّ تنفيذٍ للحلقة مما يعني أنها ستصل في النهاية إلى A.length، وهنا لن يتحقَّق شرْط حلقة while وستنتهي الحلقة. بعد انتهاء الحلقة، ستتساوي قيمة كلّ من i وA.length، كما سيتحقّق لامتغاير الحلقة، ونظرًا لتساوي قيمة كلّ من i وA.length، فسنجد أن total تعبّر عن حاصل مجموع أول عددٍ مقداره A.length من عناصر المصفوفة A، كما تعبّر عن حاصل جمع جميع عناصر المصفوفة A، وبهذا سيعطينا لامتغاير الحلقة ما نريده تمامًا؛ وعندما يعيد البرنامج الفرعي قيمة total، فإنها ستساوي حاصل مجموع عناصر المصفوفة. قد يُعَد هذا جهدًا كبيرًا لإثبات صحة شيءٍ واضحٍ، ولكنك إذا حاولت أن تشرح لماذا يعمل arraySum() على نحوٍ صحيحٍ، فغالبًا ستستخدِم نفس المنطق الذي يُبنى عليه لامتغاير حلقة التكرار حتى إذا لم تستخدِم نفس المصطلح. مثالٌ آخر، يبحث البرنامج الفرعي التالي عن أكبر قيمةٍ ضِمن مصفوفةٍ من الأعداد الصحيحة على فرْض أنها تحتوي على عنصرٍ واحدٍ على الأقل، اُنظر الشيفرة التالية: static int maxInArray( int[] A ) { int max = A[0]; int i = 1; while ( i < A.length ) { if ( A[i] > max ) max = A[i]; i = i + 1; } return max; } يُعَد لامتغاير الحلقة في تلك الحالة أن max هو أكبر قيمةٍ ضِمن أول عناصر i من المصفوفة A، وسنفترض صحة ذلك قبل تعليمة if،إذًا بعد انتهاء الحلقة، سيكون max أكبر من أو يساوي A[i] لأنه شرْطٌ لاحقٌ لتعليمة if، كما سيكون أكبر من أو يساوي القيم التي تتراوح بين A[0] وA[i-1] نتيجةً للامتغاير الحلقة. وعندما نضع تلك الحقيقتين معًا، فنستطيع أن تُثبِت أن max هو أكبر قيمةٍ بين أول عددٍ i+1من عناصر المصفوفة A؛ وعندما نستبدل i+1 بـ i في التعليمة التالية، فسيتحقّق لامتغاير الحلقة مرةً أخرى، كما سنجد بعد انتهاء الحلقة أن i يساوي A.length، وسيُثبِت لامتغاير الحلقة أن max هو أكبر عنصرٍ في المصفوفة. مثالٌ آخر، افحَص خوارزمية الترتيب بالإدراج insertion sort التالية التي سبق وأن ناقشناها في القِسم الفرعي 7.4.3، وسنفترِض أننا سنرتّب مصفوفة A، ولذا ينبغي أن يتحقّق ما يلي في نهاية الخوارزمية: A[0] <= A[1] <= ... <= A[A.length-1] سيَطرح السؤال التالي نفْسه، ما هو الأسلوب الذي نتبعه لنتأكّد من صحة ذلك خطوةٍ بخطوةٍ؟ وهل يمكننا أن نوجد لامتغاير حلقةٍ يُمثّل العِبارة التي نريدها أن تتحقَّق في النهاية؟ فمثلًا، إذا أردنا لجميع عناصر مصفوفة أن تكون مرتبة في النهاية، فماذا عن لامتغاير حلقةٍ يشير إلى أن بعض عناصر المصفوفة مرتّبةٌ، وبصورةٍ أكثر تحديدًا يشير إلى أن العناصر الأولى من المصفوفة وحتى الفهرس i مرتبةٌ؟! ويقودنا ذلك إلى الخوارزمية التالية: i = 0; while (i < A.length ) { // لامتغاير الحلقة [A[0] <= A[1] <= ... <= A[i-1 . . // شيفرة إضافة عنصر إلى الجزء المرتب من المصفوفة . i = i + 1; } // بهذه النقطة، تكون i = A.length, وA[0] <= A[1] <= ... <= A[A.length-1] تُعَد لامتغايرات الصنف class invariants (أو يطلق عليها أصناف لامتغايرة في بعض المواضع) نوعًا آخرًا من اللامتغايرات التي تفيد أثناء كتابة البرامج، حيث يمثّل لامتغاير الصنف عبارةً صحيحةً دائمًا عن حالة الصنف أو حالة الكائنات التي أُنشئت من ذلك الصنف؛ سنفترض أن لدينا صنف PairOfDice يُخزّن القيم الظاهرة على حجَريْ نرْدٍ في متغيّرين هما die1 وdie2 لأننا نرغب بوجود لامتغاير صنف يُخبِر أن قيَم حجَريْ النرْد تتراوح بين 1 و6؛ وهذه العِبارة صحيحةٌ دائمًا لأي حَجريْ نْرد. ينبغي أن تَصِح العبارة السابقة في جميع الأحوال إذا أردنا تمثيلها بأحد لامتغايرات الأصناف، وإذا كان die1 وdie2 عبارة عن متغيّرات نُسخٍ عامةٍ instance variables، فلا توجد أي ضمانةٍ ممكنةٍ لتحقيق ذلك؛ لأننا لا نتحكم بالقيم التي يُسنِدها أي برنامج (يستخدم هذا الصنف) إلى تلك المتغيّرات، ولذلك نَعُدهما مثل المتغيرات الخاصة private variables، وبعد ذلك، نتأكد من تطبيق الشيفرة الموجودة داخل الصنف لقاعدة لامتغاير الصنف. بدايةً، عندما نُنشِئ كائنًا من الصنف PairOfDice، فإن قيم die1 وdie2 ينبغي أن تُهيّئ إلى قيم تتراوح بين 1 و6، كما يجب أن يحافِظ كل تابعٍ method معرَّف داخل الصنف على تلك الحقيقة، بمعني أن يتأكد أيّ تابعٍ يُسنِد قيمةً إلى die1 أو die2 من وقوع تلك القيمة في نطاقٍ يتراوح من 1 إلى 6؛ فمثلًا، قد يَفحص تابع ضبْطٍ setter method ما إذا كانت القيمة المسنَدة صالحةٌ أم لا. عمومًا يُمثّل لامتغاير الصنف شرطًا لاحقًا للمُنشئ constructor، كما أنه يُمثّل شرطًا لاحقًا ومسبقًا لأيّ تابعٍ آخرٍ ضِمن الصنف، فعندما تكتب صنفًا، فيجب أن تتأكد من صحة لامتغاير الصنف في كل الأوقات؛ بينما عندما تكتب تابعًا، فيجب أن تتأكد من أن شيفرة ذلك التابع تطبّق قواعد لامتغاير الصنف. سنفترض مثلًا تحقُّق لامتغاير الصنف عند استدعاء تابع، إذًا ينبغي أن نتأكد من صحته حتى بعد انتهاء تنفيذ شيفرة ذلك التابع، وستساعدك طريقة التفكير السابقة بقوةٍ أثناء تصميم الأصناف. مثالٌ آخرٌ، سبَق وأن عرّفنا صنفًا ممثِّلًا لمصفوفةٍ ديناميكيةٍ في القِسم الفرعي 7.2.4، حيث يخزّن ذلك الصنف القيم في مصفوفةٍ عاديةٍ، بالإضافة إلى عدّادٍ لمعرفة عدد العناصر المخزَّنة فعليًا ضِمن المصفوفة: private int[] items = new int[8]; private int itemCount = 0; تحتوي لامتغايرات الصنف على عباراتٍ مِثل "تمثيلٍ itemCount لعدد العناصر" و"تراوح itemCount بين صفر وعدد عناصر المصفوفة" و"وجود قيم العناصر بمواضع المصفوفة من items[0] إلى items[itemCount-1]"؛ ولذلك يجب أن تتذكر دائمًا لامتغايرات الصنف أثناء تعريفه، فعندما تكتب تابعًا ليضيف عنصرًا مثلًا، فسيذّكرك اللامتغاير الأول بضرورة زيادة itemCount بمقدار الواحد ليتحقّق اللامتغاير دائمًا، بينما سيُخبرك اللامتغاير الثاني بالمكان الذي ينبغي أن تخزِّن فيه العنصر الجديد، كما سيخبرك اللامتغاير الأخير أنه في حالة زيادة itemCount إلى items.length، فستحتاج إلى فعل شئٍ لتتجنّب مخالفة ما تنص عليه قواعد اللامتغاير. سنشير أحيانًا خلال الفصول القادمة إلى فائدة التفكير بالشروط اللاحقة والمسبَقة واللامتغايرات. يتعقّد تحليل البرامج المتوازية parallel programs باستخدام اللامتغايرات، إذ تنفِّذ هذه البرامج عِدّة سلاسل threads من التعليمات تتعامل مع نفْس البيانات في نفْس الوقت. وسنناقش تلك المشكلة أثناء حديثنا عن السلاسل بالفصل 12. معالجة المدخلات تُعَد معالجة البيانات المُدخَلة من المستخدِم أو المقروءة من ملفٍ أو المستقبَلة عبْر الشبكة واحدةٌ من أهم الأشياء التي نتأكد من خلالها من صحة البرنامج ومتانته، وسنتناول الملفات والشبكات بالفصل 11 بصورةٍ تعتمد على ما سنُناقشه بالقِسم التالي، سنفحص الآن مثالًا على معالجة مدخَلات المستخدِم. سنستخدِم الصنف TextIO الدّاعم لمعالجة أخطاء قراءة مُدخَلات المستخدِم، فمثلًا تعيد الدالة TextIO.getDouble() قيمةً صالحةً من النوع double، فإذا أَدخل المستخدِم قيمةً غير صالحةً، فإن الصنف TextIO سيَطلب منه أن يعيد إدخال قيمةٍ صالحةٍ، بمعنى أن هذا الصنف لن يمرِّر قيمةً غير صالحةٍ للبرنامج؛ ومع ذلك، قد لا تتلاءم تلك الطريقة مع بعض الحالات، لاسيّما إذا أدخل المستخدِم بياناتٍ معقدّةٍ، وسنفحص تلك الأخطاء في المثال التالي. قد يفيد أحيانًا أن تطلّع على ما سيُقرَأ لاحقًا مثل مُدخَلٍ دون أن تقرأه بصورةٍ فعليةٍ، فقد يحتاج برنامجٍ معينٍ مثلًا إلى معرفة إذا ما كانت قيمة المُدخَل التالي عبارةٌ عن عددٍ أم كلمةٍ، ولهذا يتضمّن الصنف TextIO على الدالة TextIO.peek()، حيث تعيد تلك الدالة قيمةً من النوع char، وتعرّف المحرّف التالي بمُدخَلات المستخدِم، رغم أنها لا تقرؤها بصورةٍ فعلية، فإذا كان المُدخَل التالي عبارةٌ عن محرّف نهاية السطر، فإن الدالة TextIO.peek() ستعيد المحرّف إلى بداية السطر '\n'. نحتاج عادةً إلى معرفة المحرّف التالي غير الفارغ بمُدخَلات المستخدِم، ولكننا نحتاج إلى تخطّي أيّ مسافاتٍ أو فراغاتٍ قبل أن نختبر ذلك؛ إذ تستخدِم الشيفرة المعرَّفة بالأسفل الدالة TextIO.peek() لتتطلّع على الأحرف التالية إلى أن تُقابِل محرّف نهاية السطر أو محرّفًا غير فارغٍ، كما تقرأ الدالة TextIO.getAnyChar() المحرّف التالي وتعيده ضِمن مُدخَلات المستخدِم حتى إذا كان مجرّد مسافةٍ فارغةٍ، في المقابل، تتخطّى الدالة TextIO.getChar() الأكثر شيوعًا أي مسافاتٍ فارغةٍ ثم تعيد المحرّف التالي غير الفارغ بعد قراءته؛ ولا يمكننا أن نستخدم TextIO.getChar() هنا، لأن الغرض هو تخطّي المسافات الفارغة دون قراءة المحرّف غير الفارغ التالي. //[1] static void skipBlanks() { char ch; ch = TextIO.peek(); while (ch == ' ' || ch == '\t') { // الحرف التالي عبارة عن مسافة فارغة، اقرأه // وافحص الحرف الذي يليه ch = TextIO.getAnyChar(); ch = TextIO.peek(); } } // end skipBlanks() [1] في الشيفرة السابقة تعني "اقرأ أي مسافاتٍ فارغةٍ بمُدخَلات المستخدم وسيكون الشرط المسبق، أما الحرف التالي بمُدخَلات المستخدم الذي هو عبارةٌ عن محرّف نهاية السطر أو حرفٍ غير فارغٍ". تَشيع هذه العملية جدًا لدرجة أننا أضفناها إلى الصنف TextIO، ونستخدمها باستدعاء TextIO.skipBlanks() المُماثِل تمامًا للتابع skipBlanks(). يَسمح المثال بالقِسم الفرعي 3.5.3 للمستخدِم بأن يدخِل قياسات الطول مثل "3 miles" أو "1 ft"، ثم يحوِّلها إلى وِحداتٍ مثل البوصة inches والقدم feet والياردة yards والميل miles؛ وعادةً ما يدخِل المستخدِم عدّة قياساتٍ بأكثر من وحدةٍ مثل "3 feet 7 inches"، وسنحسِّن البرنامج ليسمَح بمُدخَلاتٍ من هذه النوعية. بصورةٍ أكثر تحديدًا، سيدخِل المستخدِم أسطرًا تحتوي على واحدةٍ أو أكثر من قياسات الطول مثل "1 foot" أو "3 miles 20 yards 2 feet" بحيث تكون وِحدات القياس المسموح بها هي inch وfoot وyard وmile، وكذلك يجب أن يُميّز البرنامج صيغ الجمع منها أي inches وfeet وyards وmiles، واختصاراتها مثل in وft وyd وmi. سنكتب الآن برنامجًا فرعيًا يقرأ سطرًا واحدًا بتلك الصيغة ثم يحسِب عدد البوصات المكافِئة، ثم سيَستخدم البرنامج ذلك العدد لحساب القيمة المكافئة بالوحدات الأخرى feet وyards وmiles، وإذا احتوت مُدخَلات المستخدِم على خطأٍ، فسيطبَع البرنامج الفرعي رسالة خطأٍ ثم يُعيد القيمة -1؛ حيث سيَفترض البرنامج الفرعي أن السطر المُدخَل غير فارغٍ، ولذلك سيَفحص البرنامج main ذلك قبل استدعائه للبرنامج الفرعي، حيث سيَستخدم سطرًا فارغًا ليُشير إلى انتهاء البرنامج، وبفرض تجاهُل احتمالية المُدخَلات غير الصالحة، سنكتب خوارزمية الشيفرة الوهمية pseudocode للبرنامج الفرعي كالتالي: inches = 0 // This will be the total number of inches // طالما ما يزال هناك مُدخَلات بالسطر while there is more input on the line: // اقرأ قيمة القياس الغددية read the numerical measurement // اقرأ وحدة القياس read the units of measure // أضف القيمة المقروءة إلى inches add the measurement to inches return inches الآن، سنختبر إذا ما كانت هناك أي مُدخَلاتٍ أخرى ضِمن نفْس السطر، وسنفحَص إذا ما كان المحرّف التالي غير الفارغ هو محرّف نهاية السطر أم لا، إلا أن هذا الاختبار سيتطلّب هنا شرطًا مُسبَقًا للتأكد من أن المحرّف التالي إما محرّف نهاية السطر أو محرّفٌ غير فارغٍ، ولهذا يجب أن نتخطّى أولًا أي محارف فارغةٍ، وسنعيد كتابة الخوارزمية كالتالي: inches = 0 skipBlanks() while TextIO.peek() is not '\n': // اقرأ قيمة القياس الغددية read the numerical measurement // اقرأ وحدة القياس read the unit of measure // أضف القيمة المقروءة إلى inches add the measurement to inches skipBlanks() return inches يتأكد التابع skipBlanks() في نهاية حلقة التكرار while من تحقُّق الشرط المسبَق، وعمومًا إذا كان هناك شرْطٌ مسبَقٌ لحلقة تكرارٍ معينةٍ، فيجب أن نتأكد من تحقُّقه في نهاية الحلقة قبل أن يحاول الحاسوب أن يحصّل قيمته مرةً أخرى. ماذا عن فحْص الأخطاء؟ ينبغي أن نتأكد من وجود قيمةٍ عدديةٍ قبل أن نقرأ قيمة القياس، كما يجب أن نتأكد من وجود رمزٍ أو كلمةٍ قبل أن نقرأ وحدة القياس، إلا أن وجود العدد آخر شيءٍ ضِمن السطر مثل "3" بدون وحدة قياسٍ يُعَد أمرًا غير مقبولٍ؛ في النهاية يجب أن نفحص إذا ما كانت وِحدة القياس المُدخَلة هي إحدى القيم التالية: inches و feet وyards و miles . ستكون الخوارزمية التي تتضمن شيفرة فحْص الأخطاء كالتالي: inches = 0 skipBlanks() while TextIO.peek() is not '\n': // إذا لم يكن الحرف التالي عبارة عن رقم if the next character is not a digit: // بلغ عن خطأ وأعد -1 report an error and return -1 Let measurement = TextIO.getDouble(); skipBlanks() // Precondition for the next test!! if the next character is end-of-line: report an error and return -1 Let units = TextIO.getWord() if the units are inches: // إذا كانت وحدة لقياس inches // أضف قيمة القياس إلى inches add measurement to inches else if the units are feet: // إذا كانت قدم feet // أضف حاصل ضرب 12*قيمة القياس إلى inches add 12*measurement to inches else if the units are yards: // إذا كانت ياردة // أضف حاصل ضرب 36*قيمة القياس إلى inches add 36*measurement to inches else if the units are miles: // أضف حاصل ضرب 5280*قيمة القياس إلى inches add 12*5280*measurement to inches else // بلغ عن الخطأ وأعد -1 report an error and return -1 skipBlanks() return inches كما ترى، عقّد اختبار الأخطاء الكثير من الخوارزمية بصورةٍ كبيرةٍ مع أنها مجرّد مثالٍ بسيطٍ؛ رغم أننا لم نعالِج جميع الأخطاء الممكِنة مثل إدخال قيمة قياسٍ من خارج النطاق المسموح به للنوع double مثل "1e400"، ولذلك سيعود البرنامج في تلك الحالة للاعتماد على الصنف TextIO ليعالِج الأخطاء، باإضافة إلى ذلك، قد يُدخِل المستخدِم قيمةً مثل "1e308 miles"، وفي حين أن العدد 1e308 يصلح للإدخال، إلا أن قيمته المكافِئة بوحدة البوصة تقع خارج النطاق المسموح به للنوع double، وعندها سيَحصل الحاسوب (كما ذكرنا في القِسم السابق) على القيمة Double.POSITIVE_INFINITY، وبإمكانك أن تُشغّل البرنامج وتجرّبه بنفْسك. سنكتب الخوارزمية بلغة جافا كالتالي: // [2] static double readMeasurement() { double inches; // حاصل مجموع البوصات الكلية double measurement; // قيمة القياس المُدخَلة String units; // وحدة القياس المُدخَلة char ch; // لفحْص الحرف التالي من مُدخَل المستخدم inches = 0; // No inches have yet been read. skipBlanks(); ch = TextIO.peek(); // [1] while (ch != '\n') { // اقرأ قيمة القياس التالية ووحدة القياس المستخدمة if ( ! Character.isDigit(ch) ) { System.out.println( "Error: Expected to find a number, but found " + ch); return -1; } measurement = TextIO.getDouble(); skipBlanks(); if (TextIO.peek() == '\n') { System.out.println( "Error: Missing unit of measure at end of line."); return -1; } units = TextIO.getWord(); units = units.toLowerCase(); /* حوّل قيمة القياس إلى وحدة البوصة وأضفها إلى inches */ if (units.equals("inch") || units.equals("inches") || units.equals("in")) { inches += measurement; } else if (units.equals("foot") || units.equals("feet") || units.equals("ft")) { inches += measurement * 12; } else if (units.equals("yard") || units.equals("yards") || units.equals("yd")) { inches += measurement * 36; } else if (units.equals("mile") || units.equals("miles") || units.equals("mi")) { inches += measurement * 12 * 5280; } else { System.out.println("Error: \"" + units + "\" is not a legal unit of measure."); return -1; } // اختبر إذا ما كان المحرّف التالي هو محرّف نهاية السطر skipBlanks(); ch = TextIO.peek(); } // end while return inches; } // end readMeasurement() حيث تعني [1] أنه طالما ما تزال هناك مُدخَلات أخرى بالسطر، اقرأ قيمة القياس وأضف مكافئها من البوصات إلى المتغيّر inches، وإذا وقع خطأٌ أثناء تنفيذ الحلقة، انهي البرنامج الفرعي فورًا وأعِد القيمة -1. في حين [2] تعني "اقرأ سطرًا واحدًا من مُدخَلات المستخدم، بحيث يكون الشرط المسبَق هو السطر المُدخَل غير الفارغ، ويكون الشرط اللاحق هو تحويل قيمة القياس إلى وحدة البوصة ثم إعادتها مثل قيمةٍ للدالة إذا كان المُدخَل صالحًا، بينما إذا كانت قيمة المُدخَل غير صالحةٍ، تعيد الدالة قيمة "-1". توجد شيفرة البرنامج بالكامل في الملف LengthConverter2.java. ترجمة -بتصرّف- للقسم Section 2: Writing Correct Programs من فصل Chapter 8: Correctness, Robustness, Efficiency من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مقدمة إلى صحة البرامج ومتانتها في جافا تصميم البرامج في جافا البرامج الفرعية والمتغيرات الساكنة في جافا أسلوب كتابة الشيفرات البرمجية وتحقيق سهولة قراءتها كيفية تصريف وبناء البرامج المكتوبة بلغة Cpp
-
يُعَد عمل البرنامج صحيحًا إذا أنجز المَهمَّة المُوكَلة إليه بصورةٍ صائبة، بينما يُعَد البرنامج متينًا robust إذا عالَج قِيم المُدخَلات غير الصالحة أو غيرها من المواقف غير المُتوقَّعة على نحوٍ مقبولٍ، فمثلًا إذا صُمِّم برنامج لقراءة مجموعةٍ من الأعداد المدخَلة من قِبل المستخدِم ثم طباعتها بصورةٍ مرتّبة، فسيعمل البرنامج بصورةٍ صحيحةٍ إذا أعاد حاصل المجموع الصحيح لأي أعدادٍ مدخَلةٍ، بينما سيكون متينًا إذا طُبعت رسالة خطأ مع المدخَلات غير العددية أو تم تجاهلها؛ أما البرامج غير المتينة فقد تنهار في هذه الحالة أو تعطي خرجًا غير مقبول. لابدّ لأي برنامجٍ أن يعمل بصورةٍ صحيحة؛ فما الفائدة إذًا من برنامج ترتيب sorting لا ينجز عملية الترتيب بصورة سليمة؟ وليس من الضروري دائمًا أن يكون البرنامج متينًا بالكامل، فذلك يعتمد على المستخدم وطريقة استخدامه؛ فمثلًا لا يُشترط لبرنامج مساعدة صغيرٍ كتبته لنفسك أن يكون متينًا. يعتمد عملُ المبرمِج على توصيف specification ما يُفترَض أن يعمل به البرنامج، ويصح عمل المبرمِج إذا توافق البرنامج مع توصيفه، ولكن هل يعني ذلك أن البرنامج نفسه سيكون صحيحًا؟ وماذا لو كان التوصيف غير صحيح أو غير كامل؟ في الواقع، يُعرَّف البرنامج الصحيح على أنه تنفيذ implementation صحيح لتوصيف صحيح وكاملٍ؛ الأمر الذي ينقلنا إلى السؤال عمَّا إذا ما كان التوصيف يعبِّر عن نية مستخدمي البرنامج ورغباتهم تعبيرًا صحيحًا أم لا؛ إلا أن هذا السؤال يقع خارج مجال علوم الحاسوب. قصص مرعبة لقد حظى معظم مستخدمي الحاسوب بتجربةٍ مع تعطل البرامج أثناء تشغيلها أو حتى انهيارها بالكامل، فعادةً ما تسبب تلك المشاكل الضجر للمستخدمين، كما قد تتسبب أيضًا في نتائج أخطر من ذلك بكثيرٍ مثل خسارة عملٍ مهم أو خسارة نقود؛ فببساطة عندما تُسند مهمات جدية للحاسوب، فسيتوقع أن تكون توابع فشلها بنفس جدية تلك المهمات. منذ عشرين عامًا، فشلت بعثتي فضاء إلى المريخ بتكاليف تصل إلى ملايين الدولارات، ورجح أن سبب الفشل في كلتا الحالتين كان نتيجةً لمشاكل برمجية، ومع ذلك لم تكن المشكلة بسبب برنامج مكتوب بطريقة خاطئة، ففي سبتمبر 1999، احترقت مركبة المريخ المناخية المدارية Mars Climate Orbiter في الغلاف الجوي للمريخ، وذلك نتيجةً لإدخال البيانات بوحدة القياس الإنجليزية مثل القدم والرطل إلى برنامج حاسوب صُمم لاستقبال قياسات بوحدات قياس النظام العالمي مثل السنتيمتر والجرام؛ كما تحطمت بعد أشهر قليلة مركبة الهبوط على قطب المريخ Mars Polar Lander، وذلك لأن البرنامج أغلق محركات الهبوط قبل الهبوط الفعلي للمركبة، إذ صُمِّم البرنامج ليغلق المحركات عندما يستشعر أي ارتطام أثناء هبوط المركبة الفضائية، واتضح بعد ذلك أن معدات الهبوط اهتزت بصورة شديدة حتى فُعِّل نظام الارتطام، وعليه أُغلِقت المحركات بينما كانت المركبة ما تزال مرتفعةً عن الأرض، الأمر الذي أدى لسقوطها على سطح المريخ. فإذا كان البرنامج أكثر متانةً، لَفحَص ارتفاعها عن الأرض قبل أن يغلق المحركات. وهناك حكاياتٌ أخرى بنفس المأساوية تسببت فيها برامج لا تعمل بصورة صحيحة، أو رديئة لا تعمل بكفاءةٍ جيدة؛ وفيما يلي بعضًا من الحوادث القليلة المسرودة في كتاب "أخلاقيات الحاسوب Computer Ethics" للكاتبين Tom Forester وPerry Morrison، والذي يُناقِشان فيه قضايا أخلاقية مختلفة تتعلّق بالحوسبة، وسنذكر منها الآتي: تسببت جرعة إشعاع مفرطة في عاميْ 1985 و1986 في قتل شخص وإصابة آخرين أثناء خضعوهم لعلاج إشعاعيٍ نتيجة لبرمجة آلة الإشعاع الحاسوبية بصورة خاطئة؛ كما تَعرّض حوالي 1000 مصاب بالسرطان لجرعات إشعاع أقلّ من الموصوفة لهم بحوالي 30% بسبب خطأ برمجي آخر في عام 1992، أي بعد 6 سنوات من الحالة السابقة تقريبًا. حذَف حاسوب في بنك نيويورك معاملات جارية بسبب خطأ برمجي، الأمر الذي استغرق قرابة 24 ساعة لإصلاح المشكلة، وحينها دفع البنك فائدة تقدر بـحوالي 5,000,000 دولار على المدفوعات التي اقترضها لتغطية المشكلة، وذلك في عام 1985. اكتُشف خطأ خلال محاكاة برمجة نظام التوجيه بالقصور الذاتي المستخدم في مقاتلة F-16، الأمر الذي كان سيتسبب في قلب الطائرة رأسًا على عقب بعد عبورها لخط الاستواء، كما فُقد مكوك الفضاء Mariner 18 بسبب خطأ برمجي في سطر واحد من برنامج تشغيله، وكذلك تجاوزت كبسولة الفضاء Gemini V موقع هبوطها المقرر بمائة ميل، حيث نسي المبرمج أن يأخذ في حسبانه دوران الأرض. في عام 1990، تعطلت خدمة هاتف AT&T في الولايات المتحدة الأمريكية بعد اكتشاف خطأ برمجي في نسختها المحدثة. بالطبع، هناك مشاكلٌ أخرى أحدث من ذلك، فمثلًا ساهم خطأ برمجي في أحد أكبر انقطاعات التيار الكهربي في التاريخ في شمال شرق أمريكا عام 2003، وفي عام 2006 تأخرت طائرة Airbus A380 بسبب مشاكل عدم توافُق البرمجيات، حيث كلَّفتها خسائر تصل إلى بلايين الدولارات، كما أدى خطأ برمجي في عام 2007 إلى تَوقُّف الآلاف من الطائرات في مطار Los Angeles الدولي، كما تسبَّب خطأ في برنامج تداول آلي في انخفاض مؤشِّر داو جونز الصناعي بحوالي 1000 نقطة، وذلك في مايو 2010. هذه بعض الأمثلة القليلة، فمن المعروف أن المشاكل البرمجية تنتشر بكثرة، وعلينا نحن المبرمجين أن نفهَم سبب ذلك وما الذي يمكن أن نفعله حِيال ذلك؟ المنقذ جافا وفقًا لمطوري لغة جافا، يعود سبب المشكلة إلى طريقة تصميم اللغات البرمجية ذاتها، ولهذا صُممت جافا بطريقة توفِّر حمايةً من تلك النوعية من الأخطاء. قد تتساءل عن كيف يمكن لخاصيةٍ في اللغة أن تمنع حدوث مثل تلك الأخطاء؟ ولهذا دعنا نفحَص بعض الأمثلة. لا تتطلب بعض لغات البرمجة تعريف المتغيرات قبل استخدامها، حيث يُنشَئ المتغير تلقائيًا عندما تستخدمه لأول مرة ضِمن برنامج كُتب بإحدى تلك اللغات، وقد ترى أن ذلك مناسبٌ أكثر من تعريف عن كلِّ متغير على حدة، الأمر الذي قد يكون صحيحًا أحيانًا، إلا أنه قد يؤدِّي إلى نتائج مؤسفة، فقد يَتسبَّب خطأ إملائي في إنشاء متغير جديد مثلًا دون أي نية لذلك؛ وفي الواقع، يقال أن خطأ مماثلًا كان هو المسئول عن فقدان سفينة فضاء؛ فلتُكتَب حلقة عدِّ بلغة FORTRAN مثلًا؛ فسيُستخدَم الأمر DO 20 I = 1,5، ولمَّا كانت المسافات غير مهمة بلغة FORTRAN، فستكافئ التعليمَة السابقة الأمر DO20I=1,5، بينما يمثل الأمر DO20I=1.5 (في حال استبدلنا الفاصلة بنقطةٍ) تعليمة إسناد assignment، فتسند القيمة 1.5 إلى متغير اسمه DO20I، يمكنك ملاحظة كيف تسبب استبدالٌ غير مقصود في تعليمة مماثلة إلى انفجار الصاروخ أثناء الاقلاع؛ ونظرًا لأن لغة FORTRAN لا تتطلب تعريفًا للمتغيرات، فسيقبل المحرر تعليمةً مثل DO20I=1.5، وينشِئ متغيرًا جديدًا اسمه DO20I، بينما إذا كانت FORTRAN تتطلب تعريف المتغيِّرات أولًا، فسيُظهر المحرِّر رسالة خطأ، كون المتغير DO20I لم يُعرَّف بَعد. عمومًا، تتطلب غالبية لغات البرمجة حاليًا تعريف المتغيرات قبل استخدامها، وبالرغم من ذلك ما تزال هناك خاصيات أخرى يكثر استخدامها رغم أنها قد تتسبب في حدوث المشكلات؛ وقد ألغت جافا بعضًا من تلك الخاصيات. وبينما يشكو بعض المبرمجين من أن ذلك يقلل من قوة وكفاءة اللغة، إلا أن هذا النقد قد يكون عادلًا إلى حدٍ ما، إذ تستحق زيادة أمن ومتانة اللغة تلك التكلفة في غالبية الأحوال، فأفضل دفاع ضد بعض الأخطاء هو تصميم اللغة بصورةٍ يستحِيل معها حدوث تلك الأخطاء من الأساس. في حالات أخرى، لا يسهل التخلص من الخطأ بصورةٍ كاملة، وعندها تصمَّم لغة البرمجة بطريقةٍ تمكنها من اكتشاف تلك الأخطاء عندما تحدث بصورة تلقائية، وتمنعها من التسبب في حدوث المشاكل البرمجية؛ حيث تنبه المبرمج إلى وجود خطأ برمجي ينبغي تصحيحه؛ ولنفحَص مجموعةً من الحالات التي اتبعها مصمِّمي جافا. تتكون أي مصفوفةٍ من عدد محدَّد من العناصر، بحيث تُرقم من الصفر إلى قيمة عظمى معينة، وبالطبع من الخطأ استخدام عنصر مصفوفة من خارج ذلك النطاق؛ ولهذا تكتشف جافا أي محاولة لفعل ذلك بصورةٍ تلقائيةٍ، بينما تُلقِي بعض لغات البرمجة الأخرى مثل C وC++ مسؤولية التأكد من استخدام العنصر ضمن النطاق المسموح به للمبرمِج. سَنفترِض أن لدينا مصفوفةً A تتكون من ثلاثة عناصر A[0] وA[1] وA[2]، أي أن عناصر مثل A[3] وA[4] تشير إلى مواضع تقع خارج المصفوفة؛ بلغة جافا، ستُكتشف أي محاولةٍ لتخزين بياناتٍ في العنصر A[3] بصورةٍ تلقائية، ولا ينتهي البرنامج إلا إذا التقط catching الخطأ كما ناقشنا في القسم 3.7؛ أما بلغتي C وC++، فسيخزِّن الحاسوب البيانات في موضع في الذاكرة رغم أنه لا يُعَد جزءًا من المصفوفة، ولأن معرفة الغرض من موضعٍ معينٍ في الذاكرة غير ممكنٍ، فلن نتوقَّع النتائج، إلا أنها قد تكون أكثر خطورةً بكثيرٍ من مجرد انتهاء البرنامج (سنناقش لاحقًا في هذا القسم مثال خطأ تجاوز سعة المُخزِّن المؤقَّت buffer overflow). تُعَد المؤشرات pointers في العموم مصدرًا شائعًا للأخطاء البرمجية، ويحمل أي متغيِّر كائني النوع object type في جافا مؤشرًا يشير إما إلى كائن أو قيمة خاصة null، وبهذا يكتشف النظام أي محاولةٍ تستخدم القيمة الخاصة null على أنها مؤشر إلى كائن فعلي، بينما في المقابل، تلقي بعض لغات البرمجة الأخرى مسئولية تجنب أخطاء المؤشر الفارغ null pointer على المبرمج، وفي الواقع نُفذ implement المؤشر الفارغ بحواسيب ماكنتوش Macintosh على أنه مؤشر إلى موضع الذاكرة 0؛ ولسوء الحظ كانت تلك الحواسيب تُخزِّن بعض بيانات النظام المهمة ضمن تلك المواضع، وكان من الممكن لأي برنامج أن يستخدم مؤشرًا فارغًا ليعدّل القيم المُخزَّنة بالقرب من الموضع 0 في الذاكرة، الأمر الذي قد يؤدي إلى انهيار النظام بالكامل، وهي نتيجة أسوأ بكثير من مجرد انهيار برنامجٍ واحدٍ بكل تأكيد. يَحدُث نوع آخر من أخطاء المؤشر عندما يُضبط مؤشر إلى كائن من غير النوع الصحيح أو إلى جزء من الذاكرة لا يَحمل كائنًا صالحًا من الأساس، وهذا النوع من الأخطاء لا يحدث في جافا لأنها لا تسمح للمبرمجين بالتعامل مع المؤشِّرات تعاملًا مباشرًا، بينما في المقابل، تسمح بعض لغات البرمجة الأخرى بضبط مؤشر معين، بحيث يشير إلى أي موضع في الذاكرة؛ وإذا حدث ذلك بطريقةٍ خاطئة، فإنه قد يؤدي إلى نتائج غير متوقعة. تسريب الذاكرة memory leak هو نوع آخر من الأخطاء غير الممكنة في لغة جافا، فعندما لا يوجد أي مؤشِّرٍ آخر إلى كائن معين، فسيحرر جامع المهملات garbage collector ذلك الكائن وتصبح المساحة التي احتلها قابلةً لإعادة الاستخدام مرةً أخرى؛ بينما في لغات أخرى، تقع مسؤولية تحرير الذاكرة غير المُستخدَمة على عاتق المبرمج، وإذا فشل المبرمج في ذلك، فستتراكم الذاكرة غير المستخدمة، بحيث تترك مساحةً أقل للبرامج والبيانات. يُقال إن أغلب برامج نظام التشغيل ويندوز Windows القديمة تعاني كثيرًا من مشكلات تسريب الذاكرة، الأمر الذي يتسبب في نفاذ ذاكرة الحاسوب بعد أيام قليلة من الاستخدام، ولذلك تصبح إعادة تشغيل الحاسوب عمليةً ضروريةً. علاوةً على ذلك، وُجد أن الكثير من البرامج تعاني من أخطاء تجاوز سعة المخزّن المؤقَّت وكثيرًا ما تُذاع أخبار عن تلك الأخطاء، حيث ترتبط كتيرًا بالمشاكل المتعلقة بأمن الشبكة network security، فعندما يَستقبِل حاسوب ما بياناتٍ من حاسوبٍ آخر عبر الشبكة، فستُخزن تلك البيانات في مخزن مؤقت buffer، إذ تستطيع البرامج أن تخصِّص أجزاءً من الذاكرة للمخزنات المؤقتة لكي تحمل البيانات التي يُتوقَّع أن تستقبلها؛ حيث تَحدث أخطاء تجاوز سعة المخزن المؤقت عندما يَستقبل بيانات حجمها أكبر من سعته، وهنا يَطرح السؤال التالي نفسه: ماذا يحدث في تلك الحالة؟ إذا اكتشف هذا البرنامج أو برنامج الاتصال الشبكي ذلك الخطأ، فستفشل عملية نقل البيانات عبر الشبكة، بينما تَقَع المشكلة الحقيقية عندما لا يكتشف البرنامج حدوث ذلك؛ وفي تلك الحالة سيستمر البرنامج في تخزين البيانات في الذاكرة حتى بعد امتلاء المخزن المؤقت، وبالتالي ستُخزَّن البيانات الإضافية في مكانٍ في الذاكرة لم يَكُن مُخصصا ليكون جزءًا من المخزِّن المؤقت؛ وقد يُستخدم ذلك المكان لبعض الأغراض الأخرى، أو قد يحتوي على بيانات مهمة، أو على أجزاء من البرنامج نفسه، وهنا تكمن المشاكل الأمنية الحقيقية. لنفترض أن خطأ تجاوز سعة المخزِّن المؤقّت قد استبدَل بعض البيانات الإضافية المستقبَلة عبر الشبكة بأجزاء من البرنامج نفسه، فعندما يُنفِّذ البرنامج ذلك الجزء من البرنامج المستبدَل، فإنه سينفِّذ البيانات المستقبَلة من حاسوبٍ آخر، وقد تحتوي تلك البيانات على أي شيء قد يتسبب في انهيار الحاسوب أو التحكم به، وإذا عثر مبرمج ضار malicious على خطأ من ذلك النوع في برنامج يعمل عبر الشبكة؛ فسيستغله ليخدع الحواسيب الأخرى لكي تنفَّذ برامجه. أما بالنسبة للبرامج المكتوبة بالكامل بلغة جافا، فإن أخطاء تجاوز سعة المخزِّن المؤقت غير ممكنة؛ وذلك لأن اللغة لا توفِّر أي طريقة لتخزين البيانات في مواضع الذاكرة غير المخصصة لها، ولكي تفعل ذلك، ستحتاج إلى مؤشِّر يشير إلى موضع غير مخصَّص في الذاكرة، أو يشير إلى موضع مصفوفة يقع خارج نطاقها المسموح به؛ وكما أوضحنا سابقًا، لا تسمح جافا بوقوع أي من الأمرين، ومع ذلك ربما ما تزال هناك بعض الأخطاء في تصنيفات جافا القياسية Java standard classes، لأن بعض التوابع methods ضمن تلك التصنيفات تُكتب بلغة سي بدلًا من جافا. يتضح لنا الآن أن تصميم لغة البرمجة قد يساعد على منع الأخطاء أو اكتشافها عند حدوثها. على الرغم من أنه قد يقيِّد بعضًا مما يفعله المبرمِج أو يتطلّب إجراء اختبارات، مثل فحص ما إذا كان المؤشر فارغًا أم لا، وهو ما يستغرق وقتًا أطول للمعالجة، ويرى بعض المبرمجين أن التضحية بالقوة والكفاءة تكلفة كبيرة لزيادة الحماية، وقد يصح ذلك في بعض التطبيقات، إلا أن هناك الكثير من المواقف الأخرى التي تكون فيها الأولوية للحماية والأمن، إذ صُممت لغة جافا لتلك المواقف. مشاكل متبقية بجافا اختار مصممي جافا ألا تُحدَّد الأخطاء المتعلقة بالحسابات العددية بصورةٍ تلقائية؛ فمثلاً بلغة جافا، تُمثِّل أي قيمةٍ من نوع int عددًا ثنائيًا 32 bits يمكِنه أن يحمل ما يصل إلى أكثر بقليل من أربعة بلايين قيمة مختلفة، إذ تتراوح قيم النوع int من -2147483648 إلى 2147483647، فماذا يحدث إذًا عندما تقع نتيجة حسبةٍ معينةٍ خارج ذلك النطاق؟ فمثلًا، ما هو مجموع 2147483647+1؟ أو ما هو حاصل ضرب 2000000000*2؟ في الحالتين، لا تُمثَّل النتيجة الصحيحة حسابيًا مثل قيمة من النوع int، إذ يشار إلى ذلك عادةً باسم تجاوز المتغير العددي integer overflow، وهو ما ينبغي أن يُعامل مثل نوع من الخطأ، ومع ذلك لا تُحدِّد جافا هذا النوع من الأخطاء بصورةٍ تلقائية، إذ تعيد جافا العدد -2147483648 مثلًا، مثل قيمةٍ لحاصل مجموع 2147483647+1، أي أنها تُحوِّل القيم الأكبر من 2147483647 إلى قيم سالبة. انظر إلى مسألة 3N+1، والتي ناقشناها سابقًا في القسم الفرعي 3.2.2، يحسب البرنامج التالي متتاليةً محددةً من الأعداد الصحيحة بدءًا من عددٍ صحيحٍ موجب N: while ( N != 1 ) { if ( N % 2 == 0 ) // If N is even... N = N / 2; else N = 3 * N + 1; System.out.println(N); } إذا كانت قيمة N كبيرةً جدًا، فستجد هنا مشكلة؛ إذ لن تكون قيمة 3N+1 صحيحةٌ رياضيًا نتيجةً لحدوث ما يعرف بتجاوز المتغيِّر العددي، وستظهر المشكلة تحديدًا عندما تزيد قيمة 3N+1 عن 2147483647 أي عندما تزيد قيمة N عن 2147483646/3. ولكي نُصحِّح ذلك الخطأ، لابد أن نفحص تلك الاحتمالية أولًا كالتالي: while ( N != 1 ) { if ( N % 2 == 0 ) // If N is even... N = N / 2; else { if (N > 2147483646/3) { System.out.println("Sorry, but the value of N has become"); System.out.println("too large for your computer!"); break; } N = 3 * N + 1; } System.out.println(N); } لا يمكننا أن نختبر الآتي if (3*N+1 > 2147483647) بصورةٍ مباشرة، فمن المُلاحظ أن المشكلة هنا ليست في وجود خطأ في خوارزمية حساب قيم متتالية الأعداد 3N+1، وإنما تكمن في عدم إمكانية تنفيذ الخوارزمية باستخدام نوع عددي صحيح 32 Bits، وبينما تتجاهل الكثير من البرامج هذا النوع من المشكلات، فقد ثَبتَت مسؤولية تجاوز المتغير العددي عن عدد لا بأس به من مشكلات فشل الحواسيب، ولذلك لابد لأي برنامجٍ متينٍ وضع تلك الاحتمالية في الحسبان؛ إذ عُدَت المشكلة البرمجية Y2K سيئة السمعة في بداية عام 2000 نوعًا من تلك الأخطاء. تعاني الأعداد من النوع double من مشكلاتٍ أكثر، كما تتواجد أيضًا مشكلة تجاوز المتغير العددي والتي تحدث عندما يتعدَّى ناتج حسبة معينة النطاق المسموح به للنوع double، أي 1.7*10^308. وبخلاف النوع int، لا تتحوَّل الأعداد في تلك الحالة إلى قيمٍ سالبةٍ، وإنما تعيد البرامج عندها قيمًا خاصةً ليس لها أيّ معنى عددي مكافئٍ، حيث تُمثِّل القيم الخاصة الآتية: Double.POSITIVE_INFINITY. Double.NEGATIVE_INFINITY. الأعداد من خارج النطاق المسموح به، بحيث يعيد الناتج 20*1e308 القيمة Double.POSITIVE_INFINITY، كما تمثِّل القيمة الخاصة Double.NaN النتائج غير المعرَّفة أو غير الصالحة، حيث تكون نتيجة قسمة صفر على صفر أو حساب الجذر التربيعي لعددٍ سالبٍ مثلًا مساويةً للقيمة Double.Nan، ويُمكِنك استدعاء الدالة Double.isNaN(x) لتَختبِر إذا ما كان عددٌ معينٌ x يحتوي على القيمة الخاصة Double.Nan أم لا. إلى جانب ما سبق، هناك جانب آخر من التعقيد يكمن في أن غالبية الأعداد الحقيقية تمثَّل بصورةٍ تقريبية فقط؛ وذلك لأنها تحتوي على عدد لا نهائي من الأرقام العشرية، حيث تصِل دقة الأرقام العشرية في النوع double إلى 15 رقم، فالعدد الحقيقي 1/3 هو الرقم المكرر …0.3333333333، فلا يمكن تمثيله بدقة متناهية باستخدام عدد محدود من الأرقام، ولذلك فإن الحسابات المتضمنة لأعداد حقيقية ليست دقيقةً تمامًا. في الواقع، يُعَد علم التحليل العددي numerical analysis أحد علوم الحاسوب المخصَّصة لدراسة الخوارزميات التي تتعامل مع الأعداد الحقيقية، إذًا لا تُحدِّد جافا جميع أنواع الأخطاء الممكنة تلقائيًا، وحتى عندما تكتشف الخطأ بصورةٍ تلقائيةِ فسيبلَّغ عن الخطأ ويُغلق النظام بصورةٍ افتراضية، وهو تصرُّف لا يصدر عن برنامج يتمتع بالمتانة الكافية، فما يزال المبرمج بحاجةٍ إلى تعلم التقنيات اللازمة ليتجنب الأخطاء ويتعامل معها، وهو موضوع الأقسام الثلاثة القادمة. ترجمة -بتصرّف- للقسم Section 1: Introduction to Correctness and Robustness من فصل Chapter 8: Correctness, Robustness, Efficiency من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: المصفوفات ثنائية البعد Two-dimensional Arrays في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا
-
تَعرَّضنا بالقسم الفرعي 3.8.5 للمصفوفات ثنائية البعد (two-dimensional arrays)، ولكننا لم نَستخدِمها بالقدر الكافي منذ ذلك الحين. تَملُك المصفوفات ثنائية البعد نوعًا أيضًا مثل int[][] أو String[][] بزوجين من الأقواس المربعة. تُرتَّب عناصر المصفوفات ثنائية البعد ضِمْن مجموعة من الصفوف والأعمدة بحيث يُخصِّص العامل new كُلًا من عدد تلك الصفوف والأعمدة كالتالي: int[][] A; A = new int[3][4]; تُنشِئ تلك التَعليمَة مصفوفة ثنائية البعد مُكوَّنة من 12 عنصر مُرتَّبين ضِمْن 3 صفوف و 4 أعمدة. يَتَوفَّر أيضًا مُهيِئ (initializers) للمصفوفات ثنائية البعد. فمثلًا، تُنشِئ التَعْليمَة التالية مصفوفة 3x4: int[][] A = { { 1, 0, 12, -1 }, { 7, -3, 2, 5 }, { -5, -2, 2, -9 } }; يَتَكوَّن مُهيِئ المصفوفات ثنائية البعد من عدة صفوف مفصولة بفواصل (comma) ومُضمَّنة داخل أقواس. بالمثل، كل صف عبارة عن قائمة من القيم مفصولة بفواصل ومُضمَّنة داخل أقواس. إلى جانب ذلك، تَتَوفَّر قيم مُصنَّفة النوع (literals) لتَمثيِل المصفوفات ثنائية البعد لها نفس قواعد الصيغة (syntax)، ويُمكِن اِستخدَامها بأي مكان وليس فقط تَعْليمَات التّصرِيح (declarations). اُنظر المثال التالي: A = new int[][] { { 1, 0, 12, -1 }, { 7, -3, 2, 5 }, { -5, -2, 2, -9 } }; في الواقع، يُمكِنك أن تُطبِق نفس الأمر على المصفوفات ثلاثية البعد ورباعية البعد..إلخ، ولكنها غَيْر شائعة الاِستخدَام في العموم. حقيقة المصفوفات ثنائية البعد (two-dimensional arrays) قبل أن نتعمق أكثر من ذلك، هنالك مفاجأة صغيرة! لا تَملُك الجافا مصفوفات ثنائية البعد (two-dimensional arrays) بصورة فعلية. تَحتلّ العناصر بمصفوفة فعلية ثنائية البُعد مَواضِعًا مُتصِلة من الذاكرة، ولكن هذا ليس صحيحًا بالجافا. في الحقيقة، تَكشِف الصيغة (syntax) المُستخدَمة لتمثيل أنواع المصفوفة ذلك: بِفرض وجود النوع BaseType، ينبغي إذًا أن نَكُون قادرين على إنشاء النوع BaseType[]، وهو ما يَعنِي "مصفوفة من النوع BaseType". إذا اِستخدَمنا النوع int[] ذاته كنوع أساسي للمصفوفة، فإننا نَحصُل على النوع int[][] الذي يُمثِل "مصفوفة من النوع int[]" أو "مصفوفة من مصفوفة من النوع int ". بمصفوفة ثنائية البعد من النوع int[][]، تَكُون العناصر نفسها مُتْغيِّرات من النوع int[]. ولمّا كان أي مُتْغيِّر من النوع int[] بدوره قادرًا على حَمْل مؤشر (pointer) إلى مصفوفة من النوع int. يُمكِننا إذًا أن نقول أن أي مصفوفة ثنائية البعد هي في الواقع عبارة عن مصفوفة من المؤشرات (pointers) بحيث يُشيِر كل مؤشر منها بدوره إلى مصفوفة أحادية البعد (one-dimensional array). تُمثِل المصفوفات أحادية البعد إذًا صفوفًا (rows) ضِمْن المصفوفة ثنائية البعد (two-dimensional). تُبيِّن الصورة التالية مصفوفة مُكوَّنة من 3 صفوف و 4 أعمدة: غالبًا ما نتجاهل تلك الحقيقة، ونُفكِر بالمصفوفة ثنائية البعد كما لو كانت شبكة (grid). ولكننا سنحتاج أحيانًا لأن نتذكَّر أن كل صف ضِمْن تلك الشبكة هو مصفوفة بحد ذاته. يُمكننا في الواقع أن نُشيِر إلى تلك المصفوفات باِستخدَام A[0] و A[1] و A[2] أي أن كل صف هو قيمة من النوع int[] يُمكِننا أن نُمرِّرها حتى إلى برنامج فرعي (subroutine) يَستقبِل مُعاملًا (parameter) من النوع int[] على سبيل المثال. يترتَّب على التفكير بمصفوفة ثنائية البعد A على أنها مصفوفة من المصفوفات عدة نتائج: أولًا، يُساوِي A.length عدد الصفوف (rows) ضِمْن المصفوفة، وهو ما يَتضِح معناه عند التفكير بها بتلك الطريقة. ثانيًا، إذا كانت A تَملُك الشكل المعتاد للمصفوفات ثنائية البعد، فسيَكُون عدد الأعمدة بالمصفوفة A هو نفسه عدد العناصر بالصف الأول أي A[0].length. لاحِظ مع ذلك أنه لا توجد قاعدة تَنصّ على ضرورة أن تَكُون جميع الصفوف ضِمْن مصفوفة ثنائية البعد بنفس الطول حيث يُعدّ كل صف بمثابة مصفوفة مُنفصِلة أحادية البعد، ويُمكِن لها إذًا أن تَملٌك طولًا مختلفًا. في الواقع، قد يَكُون صف معين ضِمْن مصفوفة فارغًا (null). اُنظر التَعْليمَة التالية على سبيل المثال: A = new int[3][]; لا يَتَضمَّن التعريف السابق عددًا داخل الأقواس الثانية، ولهذا فإنه يُنشِئ مصفوفة مُكوَّنة من ثلاثة عناصر جميعها فارغة (null) أي أنه يُوفِّر مساحة لثلاثة صفوف (rows)، ولكنه لا يُنشِئ تلك الصفوف بشكل فعليّ. يُمكِنك أن تُنشِئ الصفوف A[0] و A[1] و A[2] بشكل منفصل. كمثال آخر، لنُفكِر بمصفوفة مُتماثِلة M -المصفوفة المتماثلة (symmetric) عبارة عن مصفوفة ثنائية البعد يَتساوَى عدد صفوفها مع عدد أعمدتها كما تَتَساوَى قيم M[j] و M[j] لجميع قيم i و j-، نحتاج لتَخْزِين قيم M[j] التي تُحقِّق الشَّرط i >= j أي يُمكِننا أن نُخزِّن البيانات بمصفوفة مُثلثة (triangular array) كالتالي: يُمكِننا أن نُنشِئ مصفوفة مثلثية (triangular array) إذا أنشأنا كل صف على حدى. تُنشِئ الشيفرة التالية مصفوفة مثلثية 7x7 من النوع double: double[][] matrix = new double[7][]; // لم ننشئ الصفوف بعد for (int i = 0; i < 7; i++) { // انشئ الصف i بحيث يحتوي على i+1 من العناصر matrix[i] = new double[i+1]; } إذا أردنا أن نَجلب قيمة matrix بالمَوضِع (i,j)، وكان i < j، فإنه ينبغي أن نَجلب قيمة matrix[j]، ويَنطبِق الأمر نفسه على ضَبْط قيم تلك المصفوفة. تُعرِّف الشيفرة التالية صَنْفًا لتمثيل المصفوفات المُتماثِلة (symmetric matrices): public class SymmetricMatrix { private double[][] matrix; // مصفوفة مثلثية لحمل البيانات public SymmetricMatrix(int n) { matrix = new double[n][]; for (int i = 0; i < n; i++) matrix[i] = new double[i+1]; } public double get( int row, int col ) { if (row >= col) return matrix[row][col]; else return matrix[col][row]; } public void set( int row, int col, double value ) { if (row >= col) matrix[row][col] = value; else matrix[col][row] = value; } public int size() { return matrix.length; // يمثل عدد الصفوف } } // end class SymmetricMatrix يُمكِنك أن تجد تعريف الصَنْف بالأعلى داخل الملف SymmetricMatrix.java كما يَحتوِي الملف TestSymmetricMatrix.java على برنامج صغير لاختبار الصَنْف. بالمناسبة، لا تستطيع الدالة (function) القياسية Arrays.copyOf() أن تُنشِئ نسخة كاملة من مصفوفة ثنائية البعد (2d array) ضِمْن خطوة واحدة، وإنما ينبغي أن تَفعَل ذلك لكل صف (row) ضِمْن المصفوفة على حدى. نستطيع إذًا كتابة الشيفرة التالية لنسخ مصفوفة ثنائية البعد من الأعداد الصحيحة: int[][] B = new int[A.length][]; // B و A لديهم نفس عدد الصفوف for (int i = 0; i < A.length; i++) { B[i] = Arrays.copyOf(A[i], A[i].length)); // انسخ الصف i } لعبة الحياة (Game of Life) كمثال آخر على معالجة المصفوفات ثنائية البعد، سنَفْحَص مثال مشهور آخر: لعبة الحياة (Game of Life) الذي اخترعها عالم الرياضيات جون هورتون كونواي (John Horton Conway) بعام 1970. لا تُعدّ لعبة الحياة (Game of Life) لعبة بحق، فهي تُمثِل آلة أوتوماتيكية ثنائية البعد. يَعنِي ذلك أنها مُكوَّنة من شبكة (grid) من الخلايا (cells) تَتَغيَّر محتوياتها بمرور الوقت وفقًا لقواعد محدَّدة. يُمكِن لأي خلية أن تَكُون بحالة من اثنين: "حية (alive)" أو "ميتة (dead)". سنَستخدِم مصفوفة ثنائية البعد لتَمثيِل تلك الشبكة بحيث يُمثِل كل عنصر بالمصفوفة حالة خلية واحدة ضِمْن الشبكة. ستُهيِئ اللعبة شبكة (grid) مبدئية بحيث تَكُون جميع خلاياها إما "حية" أو "ميتة". بَعْد ذلك، ستتطوَّر الشبكة وفقًا لسِلسِلة من الخطوات. ستُحدَّد حالة خلايا الشبكة بكل خطوة وفقًا لحالة خلاياها بالخطوة السابقة ووفقًا لعدد من القواعد البسيطة: ستَفحَص كل خلية بالشبكة الخلايا المجاورة لها (أفقيًا ورأسيًا وقطريًا)، وتَحسِب عدد الخلايا الحية ثم تُحدَّد حالة الخلية بالخطوة التالية وفقًا للقواعد التالية: في حالة كانت الخلية "حية" بالخطوة الحالية، وكانت أيضًا مُحاطَة بعدد 2 أو 3 خلايا حية، فإنها ستَبقَى حية بالخطوة التالية أما إذا لم تَكُن مُحاطَة بذلك العدد، فإنها ستموت. (أي تَموُت الخلية الحية نتيجة للوحدة إذا كانت مُحاطة بعدد أقل من خليتين حيتين أو نتيجة للازدحام إذا كانت مُحاطَة بأكثر من 3 خلايا حية). في حالة كانت الخلية "ميتة" بالخطوة الحالية، وكانت أيضًا مُحاطَة بثلاث خلايا حية، فإنها تتحوَّل لتُصبِح حية بالخطوة التالية أما إذا لم تَكُن مُحاطَة بذلك العدد، فإنها ستَبقَى ميتة. (أي يَنتُج عن وجود ثلاثة خلايا حية خلية حية جديدة). تُظهِر الصورة التالية شكل شبكة الخلايا قَبْل تطبيق قواعد اللعبة وبَعْدها. تُطبَق القواعد على كل خلية بالشبكة، وتُبيِّن الصورة كيفية تطبيقها على أربعة خلايا: تُعدّ لعبة الحياة (Game of Life) شيقة حيث تَعرِض الكثير من الأنماط المفاجئة (اُنظر بصفحة ويكيبيديا الخاصة باللعبة). نحن هنا مُهتمين فقط بكتابة برنامج لمحاكاة تلك اللعبة. يُمكِنك أن تَطلِع على شيفرة البرنامج بالكامل بالملف Life.java. تَظهَر رقعة الحياة كشبكة من المربعات بحيث تَكون المربعات الميتة سوداء اللون أما المربعات الحية فتَكُون بيضاء اللون. (سيَستخدِم البرنامج الصنف MosaicCanvas.java من القسم 4.7 لتَمثيِل تلك الشبكة، لذلك ستحتاج إلى إضافة ذلك الملف لتَصرِيف [compile] البرنامج وتَشْغِيله). يُمكِننا أن نملئ رقعة الحياة بخلايا حية وميتة عشوائيًا أو قد نَستخدِم الفأرة لضَبطها. يُوفِّر البرنامج الزر "Step" المسئول عن تحديد حالة الشبكة بَعْد خطوة واحدة فقط" بالإضافة إلى الزر "Start" المسئول عن تَشْغِيل تلك الخطوات بهيئة تحريكة (animation). سنَفْحَص العمليات المُتعلِقة بمعالجة المصفوفات (array processing) اللازمة لتّنفِيذ برنامج لعبة الحياة (Game of Life). أولًا، يُمكِن لأي خلية أن تَكُون حية أو ميتة، لذلك سنَستخدِم بطبيعة الحال مصفوفة ثنائية البعد من النوع boolean[][] لتمثيل حالة جميع الخلايا، وليَكُن اسم تلك المصفوفة هو alive. ستُساوِي قيمة أي عنصر alive[r][c] القيمة true إذا كانت الخلية برقم الصف r ورقم العمود c حية. سنَستخدِم أيضًا نفس قيمة الثابت GRID_SIZE لتمثيل عدد الصفوف والأعمدة بتلك المصفوفة. يُمكِننا إذًا أن نَستخدِم حَلْقة التَكْرار for المُتداخلة (nested) التالية لمَلْئ قيم تلك المصفوفة المُمثِلة لشبكة الحياة بقيم عشوائية: for (int r = 0; r < GRID_SIZE; r++) { for (int c = 0; c < GRID_SIZE; c++) { // اضبط الخلية لتكون حية بنسبة احتمال تساوي 25% alive[r][c] = (Math.random() < 0.25); } } يُعيد التعبير Math.random() < 0.25 القيمة true أو false، والتي يُمكِننا أن نُسنِدها (assign) إلى عنصر مصفوفة من النوع boolean. سنَستخدِم تلك المصفوفة لضَبْط لون كل خلية بالشاشة. نظرًا لأننا سنَرسِم شبكة الخلايا على شاشة من الصَنْف MosaicCanvas، فإننا سنَستخدِم واجهة برمجة التطبيقات (ِAPI) الخاصة بذلك الصنف لضَبْط ألوانها. ونظرًا لأن الصَنْف MosaicCanvas هو المسئول عن الرسم الفعليّ، سيَكُون برنامج لعبة الحياة مسئولًا فقط عن ضَبْط الألوان باِستخدَام واجهة برمجة التطبيقات الخاصة بالصَنْف. سنُعرِّف ذلك بتابع اسمه showBoard()، والذي ينبغي أن يُستدعَى بكل مرة تَتَغيَّر فيها رقعة الحياة. سنَستخدِم مجددًا حَلْقة تَكْرار for مُتداخِلة لضَبْط لون كل مربع بالشبكة كالتالي: for (int r = 0; r < GRID_SIZE; r++) { for (int c = 0; c < GRID_SIZE; c++) { if (alive[r][c]) display.setColor(r,c,Color.WHITE); else display.setColor(r,c,null); // اعرض لون الخلفية بالأسود } } بالطبع، يُعدّ حساب الحالة الجديدة لشبكة الخلايا بَعْد تطبيق قواعد اللعبة على حالتها الحالية هو الجزء الأكثر تشويقًا من البرنامج. لمّا كانت القواعد ستُطبَق على كل خلية على حدى، فإننا سنحتاج إلى اِستخدَام حَلْقة تَكْرار for مُتداخِلة للمرور عبر جميع خلايا الشبكة، ولكن ستَكُون المعالجة أكثر تعقيدًا هذه المرة. لاحظ أنه لا يُمكِننا أن نُجرِي أي تغييرات على قيم المصفوفة أثناء معالجتها؛ لأننا سنحتاج إلى معرفة الحالة القديمة لخلية معينة أثناء معالجة خلاياها المجاورة. سيَستخدِم البرنامج مصفوفة آخرى للاحتفاظ بالحالة الجديدة أثناء المعالجة، وعندما ننتهي من معالجة جميع الخلايا ضِمْن الشبكة، يُمكننا أن نَستخدِم المصفوفة الجديدة بدلًا من القديمة. يُمكِننا كتابة الخوارزمية (algorithm) بالشيفرة الوهمية (pseudocode) كالتالي: let newboard be a new boolean[][] array for each row r: for each column c: Let N be the number of neighbors of cell (r,c) in the alive array if ((N is 3) or (N is 2 and alive[r][c])) newboard[r][c] = true; else newboard[r][c] = false; alive = newboard سيُشيِر alive عند انتهاء المعالجة إلى مصفوفة جديدة، وهو أمر لا ينبغي أن نقلق بشأنه طالما كانت محتويات المصفوفة الجديدة مُمثِلة للحالة الجديدة لشبكة الخلايا أما المصفوفة القديمة فسيُحرِّرها كانس المُهملات (garabage collector). قد لا يَكون اختبار ما إذا كان newboard[r][c] مُتحقِقًا أم لا واضحًا بما فيه الكفاية، ولكنه يُنفِّذ القواعد بشكل سليم. سنحتاج أيضًا لحِسَاب عدد الخلايا المجاورة. إذا كان لدينا خلية بالصف r والعمود c، ولم تَكُن تلك الخلية واقعة على حافة الرقعة، فإن الخلايا المجاورة لتلك الخلية هي كالتالي: لاحِظ أن رقم الصف فوق الصف r يُساوِي r-1 أما الصف أسفله فهو r+1. يَنطبِق نفس الأمر على الأعمدة. ينبغي إذًا أن نَفْحَص القيم alive[r-1][c-1] و alive[r-1][c] و alive[r-1][c+1] و alive[r][c-1] و alive[r][c+1] و alive[r+1][c-1] و alive[r+1][c] و alive[r+1][c+1]، ونَعِد منها تلكم المُحتوية على القيمة true. اِحرِص على فهم كيفية عمل فهارس المصفوفة. في المقابل، ستواجهنا مشكلة إذا كانت الخلية وَاقِعة على إحدى حواف الشبكة. في تلك الحالة، لا تَكُون بعض العناصر ضِمْن القائمة السابقة موجودة، وستَتَسبَّب محاولة الاشارة إليها إلى حدوث اِعترَاض (exception). لكي نَتَجنَّب حُدوث ذلك، ينبغي أن نُعامِل الخلايا الواقعة على الحواف بطريقة مختلفة. يُمكِننا مثلًا أن نَفْحَص ما إذا كان عنصر المصفوفة موجودًا قبل محاولة الإشارة إليه. في تلك الحالة، يُمكِننا مثلًا أن نَكْتُب شيفرة حساب عدد الخلايا المجاورة الحية كالتالي: if (r-1 >= 0 && c-1 >= 0 && alive[r-1][c-1]) N++; // خلية بالموضع (r-1,c-1) موجودة وحية if (r-1 >= 0 && alive[r-1][c]) N++; // خلية بالموضع (r-1,c) موجودة وحية if (r-1 >= 0 && c+1 <= GRID_SIZE && alive[r-1][c+1]) N++; // خلية بالموضع (r-1,c+1) موجودة وحية // إلخ سنتجنَّب بذلك كل الاعتراضات (exceptions). اِستخدَمنا في الواقع حلًا آخر شائع الاِستخدَام بألعاب الحاسوب ثنائية البعد. سنَتَظاهَر كما لو أن الحافة اليسرى للرقعة مُرتبطِة بالحافة اليمنى وكما لو أن الحافة العلوية مُرتبطِة بالحافة السفلية. بالنسبة لخلية برقم الصف 0 على سبيل المثال، فإن الصف أعلاها هو الصف الأخير أي بالرقم GRID_SIZE-1. سنَستخدِم أيضًا مُتْغيِّرات لتمثيل المَواضِع above و below و left و right الخاصة بخلية معينة. اُنظر شيفرة التابع المسئولة عن حساب الحالة الجديدة للرقعة، والتي ستَجِد أنها أبسط كثيرًا: private void doFrame() { // احسب الحالة الجديدة لرقعة لعبة الحياة boolean[][] newboard = new boolean[GRID_SIZE][GRID_SIZE]; for ( int r = 0; r < GRID_SIZE; r++ ) { // تعد الصفوف أعلى وأسفل الصف r int above, below; // تعد الأعمدة على يمين ويسار العمود c int left, right; above = r > 0 ? r-1 : GRID_SIZE-1; // (for "?:" see Subsection 2.5.5) below = r < GRID_SIZE-1 ? r+1 : 0; for ( int c = 0; c < GRID_SIZE; c++ ) { left = c > 0 ? c-1 : GRID_SIZE-1; right = c < GRID_SIZE-1 ? c+1 : 0; int n = 0; // عدد الخلايا الحية المجاورة if (alive[above][left]) n++; if (alive[above][c]) n++; if (alive[above][right]) n++; if (alive[r][left]) n++; if (alive[r][right]) n++; if (alive[below][left]) n++; if (alive[below][c]) n++; if (alive[below][right]) n++; if (n == 3 || (alive[r][c] && n == 2)) newboard[r][c] = true; else newboard[r][c] = false; } } alive = newboard; } يُمكِنك الإطلاع على شيفرة البرنامج بالملف Life.java، وأن تُجرِبه. لا تنسى أنك ستحتاج إلى اِستخدَام الملف MosaicCanvas.java أيضًا. لعبة داما (Checkers) سنَفْحَص الآن مثالًا أكثر واقعية لاستخدام المصفوفات ثنائية البعد. يُعدّ هذا البرنامج هو الأطول حتى الآن حيث يَتَكوَّن من 745 سطر. يَسمَح ذلك البرنامج للمُستخدمين بلعب مباراة داما (checkers). تتكوَّن لعبة الداما من رقعة مُكوَّنة من 8 صفوف و8 أعمدة. سنعتمد على المثال الذي كتبناه بالقسم الفرعي 6.5.1. سنُسمِي اللاعبين بأسماء "أحمر" و "أسود" وفقًا للون قطع الداما الخاصة بهما. لن نشرح قواعد لعبة الداما هنا، ولربما تستطيع أن تتعلَّمها بينما تُجرِّب البرنامج. يستطيع اللاعب أن يَتحرَك بالنَقْر على القطعة التي يريد أن يُحرِكها ثُمَّ بالنَقْر على المربع الفارغ الذي يريد أن يَنقِل إليه تلك القطعة. سيَرسِم البرنامج بأي لحظة إطارًا حول المربعات التي يستطيع اللاعب أن يَنقُر عليها بلون أفتح قليلًا كنوع من المساعدة. على سبيل المثال، سيُحَاط المربع المُتضمِّن للقطعة التي اختارها المُستخدِم للحركة بإطار أصفر اللون بينما ستُحَاط القطع الآخرى التي بإمكانه تَحرِيكها بإطار أزرق سماوي. إذا كان المُستخدِم قد اختار قطعة بالفعل لتَحرِيكها، فستُحَاط جميع المربعات الفارغة التي يستطيع أن يُحرِك إليها تلك القطعة بإطار أخضر اللون. ستَتبِع اللعبة قاعدة تَنُص على أنه إذا كان اللاعب الحالي قادرًا على القفز (jump) على إحدى قطع الخصم، فإنه لابُدّ أن يَقفِز. عندما تُصبِح إحدى القطع "قطعة ملك" أي بَعْد وصولها إلى الحافة الأخرى من الرقعة، سيَرسِم البرنامج حرف "K" أبيض كبير على تلك القطعة. تَعرِض الصورة التالية لقطة شاشة مبكرة من اللعبة، والتي تُظهِر اللاعب الأسود وقد اختار القطعة الموجودة بالمربع ذو الإطار الأصفر للحركة. يستطيع اللاعب الآن أن يَنقُر على إحدى المربعات المُحاطَة بإطار أخضر لكي يُكمِل تلك الحركة أو قد يَنقُر على إحدى المربعات المُحاطَة بإطار أزرق سماوي ليختار قطعة آخرى لتَحرِيكها. سنَمُر عبر جزءًا من شيفرة ذلك المثال، ولكن يُمكِنك الاطلاع على الشيفرة بالكامل بالملف Checkers.java. قد يَكُون البرنامج طويلا ومعقدًا، ولكنه بقليل من الدراسة، ستَتَمكَّن من فهم جميع التقنيات المُستخدَمة به. يُعدّ البرنامج مثالًا جيدًا على البرمجة كائنية التوجه (object-oriented) المُعتمدِة على كُلًا من الأحداث (event-driven) والحالات (state-based). سنُخزِّن بيانات قطع الرقعة بمصفوفة ثنائية البعد (two-dimensional array). نظرًا لأن البرنامج مُعقَد نوعًا ما، سنُقسِّمه إلى عدة أصناف. إلى جانب الصَنْف الأساسي، سنُعرِّف بعض الأصناف المُتداخِلة (nested) الآخرى منها الصَنْف CheckersData المُستخدَم لمعالجة بيانات الرقعة. لا يُعدّ ذلك الصَنْف مسئولًا مباشرًا عن أي جزء من الرسوم (graphics) أو مُعالجة الأحداث (event-handling)، ولكنه يُوفِّر التوابع (methods) التي يُمكِننا أن نستدعيها بأصناف آخرى لأغراض معالجة الرسوم والأحداث (events). سنناقش ذلك الصَنْف قليلًا. يَحتوِي الصَنْف CheckersData على مُتْغيِّر نُسخة (instance variables) اسمه هو board من النوع int[][]. تُضبَط قيمة ذلك المُتْغيِّر إلى new int[8][8] لتُمثِل شبكة 8x8 من الأعداد الصحيحة (integers). تُستخدَم ثوابت (constants) لتعريف القيم المُحتمَل تَخْزِينها بمربع برقعة شطرنج (checkboard): static final int EMPTY = 0, // يمثل مربعًا فارغًا RED = 1, // قطعة حمراء عادية RED_KING = 2, // قطعة ملك حمراء BLACK = 3, // قطعة سوداء عادية BLACK_KING = 4; // قطعة ملك سوداء سنَستخدِم أيضًا الثابتين RED و BLACK بالبرنامج لتَمثيِل لاعبي المباراة. عندما تبدأ المباراة، ستُضبَط قيم المصفوفة لتمثيل الحالة المبدئية للرقعة، وستبدو كالتالي: يُمكِن لأي قطعة سوداء عادية أن تتحرك لأسفل الشبكة فقط أي لابُدّ أن يكون رقم الصف للمربع الذي ستنتقل إليه أكبر من رقم الصف للمربع القادمة منه. بالمقابل، تستطيع أي قطعة حمراء عادية أن تَتَحرك لأعلى الشبكة فقط. يُمكِن للملوك بأي من اللونين الحركة بكلا الاتجاهين. لابُدّ أن ينتبه الصَنْف CheckersData لأي تَغييرات ينبغي إجراؤها على هياكل البيانات (data structures) عندما يُحرِك أحد اللاعبين قطعة معينة بالرقعة. في الواقع، يُعرِّف الصَنْف تابع النسخة makeMove() المُخصَّص لذلك الغرض. عندما يُحرِك لاعب قطعة من مربع إلى آخر، سيُعدِّل التابع قيمة عنصرين ضِمْن المصفوفة. بالإضافة إلى ذلك، إذا كانت الحركة عبارة عن قفزة (jump)، ستُحذَف القطعة التي كانت موجودة بالمربع المقفوز إليه من الرقعة. يستطيع التابع أن يُحدِّد ما إذا كانت حركة معينة عبارة عن قفزة أم لا بِفْحَص ما إذا كان المربع الذي تَحرَكت إليه القطعة يَبعُد بمقدار صفين عن المربع الذي بدأت منه. إلى جانب ما سبق، تُصبِح القطعة الحمراء RED التي تتحرك إلى الصف 0 أو القطعة السوداء Black التي تتحرك إلى الصف 7 بمثابة قطعة ملك (king). سنَضَع جميع ما سبق ببرنامج فرعي (subroutine)، وبالتالي، لن يضطّر باقي البرنامج للقلق بشأن أي من تلك التفاصيل السابقة. يُمكِنه فقط أن يَستدعِي التابع makeMove(): void makeMove(int fromRow, int fromCol, int toRow, int toCol) { board[toRow][toCol] = board[fromRow][fromCol]; // حرك القطعة board[fromRow][fromCol] = EMPTY; // المربع الذي تحركت منه أصبح فارغًا if (fromRow - toRow == 2 || fromRow - toRow == -2) { // الحركة كانت قفزة لذلك احذف القطعة المقفوز إليها من الرقعة int jumpRow = (fromRow + toRow) / 2; // صف القطعة المقفوز إليها int jumpCol = (fromCol + toCol) / 2; // عمود القطعة المقفوز إليها board[jumpRow][jumpCol] = EMPTY; } if (toRow == 0 && board[toRow][toCol] == RED) board[toRow][toCol] = RED_KING; // تصبح القطعة الحمراء ملك if (toRow == 7 && board[toRow][toCol] == BLACK) board[toRow][toCol] = BLACK_KING; // تصبح القطعة السوداء ملك } // end makeMove() ينبغي أن يَسمَح الصَنْف CheckersData بالعثور على جميع الحركات الصالحة بالرقعة. سنستخدم كائنًا ينتمي للصَنْف التالي لتمثيل الحركة داخل لعبة داما: private static class CheckersMove { int fromRow, fromCol; // موضع القطعة المطلوب تحريكها int toRow, toCol; // المربع الذي انتقلت إليه القطعة CheckersMove(int r1, int c1, int r2, int c2) { // اضبط قيم متغيرات النسخة fromRow = r1; fromCol = c1; toRow = r2; toCol = c2; } boolean isJump() { // اختبر ما إذا كانت الحركة عبارة عن قفزة // بالقفزة، تتحرك القطعة مسافة صفين return (fromRow - toRow == 2 || fromRow - toRow == -2); } } // end class CheckersMove. يُعرِّف الصَنْف CheckersData تابع نسخة (instance method) للعثور على جميع الحركات المتاحة حاليًا لكل لاعب. يُعدّ ذلك التابع بمثابة دالة (function) تُعيِد مصفوفة من النوع CheckersMove[] تحتوي على جميع الحركات المتاحة مُمثَلة بهيئة كائنات من النوع CheckersMove. يُمكِننا إذًا كتابة توصيف التابع كالتالي: CheckersMove[] getLegalMoves(int player) يُمكِننا كتابة خوارزمية التابع بالشيفرة الوهمية (pseudocode) كالتالي: // ابدأ بقائمة فارغة من الحركات Start with an empty list of moves // ابحث عن أي قفزات صالحة و أضفها إلى القائمة Find any legal jumps and add them to the list // إذا لم يكن هناك أي قفزات صالحة if there are no jumps: // ابحث عن الحركات العادية الأخرى و أضفها إلى القائمة Find any other legal moves and add them to the list // إذا كانت القائمة فارغة if the list is empty: return null else: return the list الآن، ما هو المقصود بالقائمة (list)؟ في الواقع، ينبغي أن نُعيد الحركات المسموح بها ضِمْن مصفوفة، ولكن لأن المصفوفات ثابتة الحجم، لا يُمكِننا أن نُنشِئها حتى نَعرِف عدد الحركات، وهو في الواقع ما لا يُمكِننا معرفته حتى نَصِل إلى نهاية التابع أي بَعْد أن نَكُون قد أنشأنا القائمة بالفعل! أحد الحلول إذًا هو اِستخدَام مصفوفة ديناميكية من النوع ArrayList بدلًا من مصفوفة عادية لكي تَحمِل الحركات بينما نَجِدها. يَستخدِم البرنامج بالأسفل كائنًا من النوع ArrayList<CheckersMove> لكي يُمكِّن القائمة من حَمْل كائنات من الصَنْف CheckersMove فقط. بينما نُضيِف الحركات إلى تلك القائمة، سيزداد حجمها بما يتناسب مع عدد الحركات. يُمكِننا أن نُنشِئ المصفوفة المطلوبة ونَنسَخ البيانات إليها بنهاية التابع. اُنظر الشيفرة الوهمية التالية: // أنشئ قائمة moves فارغة للحركات Let "moves" be an empty ArrayList<CheckersMove> // ابحث عن القفزات المسموح بها و أضفها إلى القائمة Find any legal jumps and add them to moves // إذا كان عدد الحركات ما يزال يساوي 0 if moves.size() is 0: // There are no legal jumps! // ابحث عن الحركات العادية الصالحة و أضفها إلى القائمة Find any other legal moves and add them to moves // إذا لم يكن عدد الحركات يساوي 0 if moves.size() is 0: // There are no legal moves at all! return null else: // عرف مصفوفة moveArray من النوع CheckersMoves // طولها يساوي moves.size() Let moveArray be an array of CheckersMoves of length moves.size() // انسخ محتويات المصفوفة moves إلى moveArray Copy the contents of moves into moveArray return moveArray علينا الآن أن نُحدِّد الحركات والقفزات المسموح بها. تَتَوفَّر المعلومات التي نحتاج إليها بالمصفوفة board، ولكن يَلزَمنا بعض العمل لاِستخراجها. لابُدّ أن نَفْحَص جميع المواضع بالمصفوفة ونَعثُر على القطع التي تنتمي للاعب الحالي. بَعْد ذلك، ينبغي أن نَفْحَص المربعات التي يُمكِن لكل قطعة أن تنتقل إليها، ونُحدِّد ما إذا كانت تلك الحركة صالحة أم لا. إذا كنا نبحث عن القفزات المسموح بها، فينبغي أن نَفْحَص المربعات التي تَبعُد بمسافة صفين وعمودين من كل قطعة. يُمكِننا إذًا أن نُوسِّع سطر الخوارزمية "اِبحَث عن القفزات المسموح بها ثم أَضِفها إلى الحركات" كالتالي: // لكل صف بالرقعة For each row of the board: // لكل عمود بالرقعة For each column of the board: // إذا كانت إحدى قطع اللاعبين بذلك المكان if one of the player's pieces is at this location: // إذا كان من الممكن القفز إلى الموضع row+2,column+2 if it is legal to jump to row + 2, column + 2 // أضف تلك الحركة إلى moves add this move to moves // إذا كان من الممكن القفز إلى الموضع row-2,column+2 if it is legal to jump to row - 2, column + 2 add this move to moves // إذا كان من الممكن القفز إلى الموضع row+2,column-2 if it is legal to jump to row + 2, column - 2 add this move to moves // إذا كان من الممكن القفز إلى الموضع row-2,column-2 if it is legal to jump to row - 2, column - 2 add this move to moves ينبغي أن نُوسِّع السطر "ابحث عن الحركات الصالحة الآخرى وأضفها إلى قائمة الحركات" بنفس الطريقة باستثناء أن علينا الآن فَحْص المربعات الأربعة التي تَبعُد مسافة صف واحد وعمود واحد من كل قطعة. لاحِظ أن اختبار ما إذا كان لاعب معين يستطيع أن يُحرِك قطعة من مربع معين إلى مربع آخر ليس بالأمر السهل. لابُدّ أن يَكُون المربع الذي ينتقل إليه اللاعب موجودًا بالرقعة، كما ينبغي أن يَكُون فارغًا. علاوة على ذلك، تستطيع القطع الحمراء والسوداء العادية أن تَتَحرَك باتجاه واحد. سنَستخدِم التابع (method) التالي لفْحَص ما إذا كان لاعب معين قادر على القيام بتحريك مربع: private boolean canMove(int player, int r1, int c1, int r2, int c2) { if (r2 < 0 || r2 >= 8 || c2 < 0 || c2 >= 8) return false; // (r2,c2) خارج الرقعة if (board[r2][c2] != EMPTY) return false; // (r2,c2) يحتوي بالفعل على قطعة if (player == RED) { if (board[r1][c1] == RED && r2 > r1) return false; // القطع الحمراء العادية يمكنها أن تتحرك للأسفل فقط return true; // الحركة صالحة } else { if (board[r1][c1] == BLACK && r2 < r1) return false; // القطع السوداء العادية يمكنها أن تتحرك للأعلى فقط return true; // الحركة صالحة } } // end canMove() يَستدعِى التابع getLegalMoves() التابع canMove() المُعرَّف بالأعلى ليَفْحَص ما إذا كانت حركة مُحتمَلة لقطعة معينة صالحة فعليًا أم لا. سنُعرِّف تابعًا مشابهًا لفَحْص ما إذا كانت قفزة (jump) معينة صالحة أم لا. في تلك الحالة، سنُمرِّر للتابع كُلًا من مربع القطعة والمربع الذي يُحتمَل أن تَنتقِل إليه وكذلك المربع الواقع بين هذين المربعين أي ذلك الذي سيَقفِز اللاعب فوقه. لابُدّ أن يَحتوِي المربع المقفوز إليه على إحدى قطع الخصم. يُمكننا تَوصِيف ذلك التابع كالتالي: private boolean canJump(int player, int r1, int c1, int r2, int c2, int r3, int c3) { . . . ينبغي الآن أن تَكُون قادرًا على فِهم شيفرة التابع getLegalMoves() بالكامل. يَدمِج ذلك التابع عدة مواضيع معًا: المصفوفات أحادية البعد (one-dimensional) والمصفوفات الديناميكية ArrayLists والمصفوفات ثنائية البعد (two-dimensional): CheckersMove[] getLegalMoves(int player) { if (player != RED && player != BLACK) return null; // لن يحدث هذا ببرنامج سليم int playerKing; // The constant for a King belonging to the player. if (player == RED) playerKing = RED_KING; else playerKing = BLACK_KING; ArrayList<CheckersMove> moves = new ArrayList<CheckersMove>(); // ستخزن الحركة ضمن القائمة // تحقق من جميع القفزات الممكنة for (int row = 0; row < 8; row++) { for (int col = 0; col < 8; col++) { if (board[row][col] == player || board[row][col] == playerKing) { if (canJump(player, row, col, row+1, col+1, row+2, col+2)) moves.add(new CheckersMove(row, col, row+2, col+2)); if (canJump(player, row, col, row-1, col+1, row-2, col+2)) moves.add(new CheckersMove(row, col, row-2, col+2)); if (canJump(player, row, col, row+1, col-1, row+2, col-2)) moves.add(new CheckersMove(row, col, row+2, col-2)); if (canJump(player, row, col, row-1, col-1, row-2, col-2)) moves.add(new CheckersMove(row, col, row-2, col-2)); } } } // إذا وجدت أي قفزات ممكنة، فلابد أن يقفز اللاعب. لذلك لن نضيف أي // حركات عادية. أما إذا لم تجد أي قفزات، ابحث عن أي حركات عادية if (moves.size() == 0) { for (int row = 0; row < 8; row++) { for (int col = 0; col < 8; col++) { if (board[row][col] == player || board[row][col] == playerKing) { if (canMove(player,row,col,row+1,col+1)) moves.add(new CheckersMove(row,col,row+1,col+1)); if (canMove(player,row,col,row-1,col+1)) moves.add(new CheckersMove(row,col,row-1,col+1)); if (canMove(player,row,col,row+1,col-1)) moves.add(new CheckersMove(row,col,row+1,col-1)); if (canMove(player,row,col,row-1,col-1)) moves.add(new CheckersMove(row,col,row-1,col-1)); } } } } // إذا لم تجد أي حركات صالحة، أعد فراغا if (moves.size() == 0) return null; else { // انشئ مصفوفة وأضف إليها الحركات المسموح بها CheckersMove[] moveArray = new CheckersMove[moves.size()]; for (int i = 0; i < moves.size(); i++) moveArray[i] = moves.get(i); return moveArray; } } // end getLegalMoves يُعدّ برنامج الداما مُعقدًا نوعًا ما، ويحتاج إلى الكثير من التصميم الجيد لتَقْرِير الأصناف (classes) والكائنات (classes) المُستخدمَة، وكذلك لتَقرِير التوابع (methods) التي ينبغي كتابتها، وكذلك لتقرير الخوارزميات (algorithms) التي ينبغي لتلك التوابع اِستخدَامها. يُمكِنك الاطلاع على شيفرة البرنامج بالكامل بالملف Checkers.java. ترجمة -بتصرّف- للقسم Section 5: Two-dimensional Arrays من فصل Chapter 7: Arrays and ArrayLists من كتاب Introduction to Programming Using Java.
-
تُعدّ عمليتي البحث (searching) والترتيب (sorting) أكثر الأساليب شيوعًا لمعالجة المصفوفات. يُشير البحث (searching) هنا إلى محاولة العثور على عنصر بمواصفات معينة ضِمْن مصفوفة بينما يُشير الترتيب (sorting) إلى إعادة ترتيب عناصر المصفوفة ترتيبًا تصاعديًا أو تنازليًا. عادةً ما يَعتمِد المقصود بالترتيب التصاعدي والتنازلي على السياق المُستخدَم به الترتيب. في الواقع، تُوفِّر الجافا تُوفِّر بعض التوابع المَبنية مُسْبَقّا (built-in methods) الخاصة بعمليات البحث والترتيب -كما رأينا بالقسم الفرعي 7.2.2-، ومع ذلك ينبغي أن تَكُون على اطلاع ومعرفة بالخوارزميات (algorithms) التي تَستخدِمها تلك التوابع. ولهذا، سنَتعلَّم بعضًا من تلك الخوارزميات بهذا القسم. عادةً ما يُناقَش البحث (searching) والترتيب (sorting) نظريًا باِستخدَام مصفوفة من الأعداد. ولكن من الناحية العملية، هناك أمور أكثر تشويقًا بكثير. فمثلًا، قد تَكُون المصفوفة عبارة عن قائمة بريدية بحيث يَكُون كل عنصر بها عبارة عن كائن (object) يَحتوِي على اسم وعنوان. إذا كان لدينا اسم شخص معين، يُمكِننا العثور على عنوانه، وهو ما يُعدّ مثالًا على عملية البحث (searching)؛ لأنك ببساطة تَبحَث عن كائن ضِمْن مصفوفة بالاعتماد على اسم الشخص. سيَكُون من المفيد أيضًا لو تَمَكَّنا من ترتيب المصفوفات وفقًا لمعيار معين مثل أن نُرتِّب المصفوفة السابقة بحيث تَكُون الأسماء مُرتَّبة ترتيبًا أبجديًا أو قد نُرتِّبها وفقًا للرقم البريدي. سنُعمِّم الآن المثال السابق إلى تصور مُجرَّد بعض الشئ: لنتخيل أن لدينا مصفوفة تَحتوِي على عدة كائنات (objects)، وأننا نرغب بالبحث داخل تلك المصفوفة أو نرغب بترتيبها بالاعتماد على احدى مُتْغيِّرات النُسخ (instance variables) المُعرَّفة بتلك الكائنات. سنلجأ إلى اِستخدَام بعض المصطلحات الشائعة بقواعد البيانات (databases). سنُطلِق اسم "تسجيل (record)" على كل كائن ضِمْن المصفوفة بينما سنُطلِق اسم "الحقول (fields)" على مُتْغيِّرات النُسخ المُعرَّفة بتلك الكائنات. نستطيع الآن أن نُعيِد صياغة مثال القائمة البريدية إلى ما يلي: يَتَكوَّن كل تسجيل (record) من اسم وعنوان. قد تَتَكوَّن حقول التسجيل من الاسم الأول والأخير والعنوان والمدينة والدولة والرقم البريدي. ينبغي أن نختار إحدى تلك الحقول لتُصبِح "مفتاحًا (key)" لكي نَتَمكَّن من إجراء عمليتي البحث والترتيب. وفقًا لهذا التصور، سيُمثِل البحث محاولة العثور على تسجيل بالمصفوفة بحيث يَحتوِي مفتاحه على قيمة معينة بينما سيُمثِل الترتيب (sorting) تَبديِل مواضع تسجيلات المصفوفة إلى أن تُصبِح مفاتيحها (keys) مُرتَّبة ترتيبًا تصاعديًا أو تنازليًا. دورة تطوير التطبيقات باستخدام لغة Python احترف تطوير التطبيقات مع أكاديمية حسوب والتحق بسوق العمل فور انتهائك من الدورة اشترك الآن البحث (Searching) هناك خوارزمية واضحة للبحث عن عنصر معين داخل مصفوفة: افحص كل عنصر بالمصفوفة بالترتيب، واختبر ما إذا كانت قيمة ذلك العنصر هي نفسها القيمة التي تبحث عنها. إذا كان كذلك، فقد انتهت عملية البحث إذًا. أما إذا لم تعثر عليه بعد فحص جميع عناصر المصفوفة، فهو إذًا غير موجود بها. يُمكننا كتابة برنامج فرعي لتنفيذ تلك الخوارزمية (algorithm) بسهولة. بفرض أن المصفوفة التي تريد البحث بها عبارة عن مصفوفة من النوع int[]، يبحث التابع (method) التالي عن قيمة عددية ضمن مصفوفة. سيعيد التابع فهرس (index) العنصر بالمصفوفة إذا عثر عليه بينما سيعيد -1 إذا لم يعثر عليه كإشارة أن العدد الصحيح غير موجود: static int find(int[] A, int N) { for (int index = 0; index < A.length; index++) { if ( A[index] == N ) // عثرنا على N بهذا الفهرس return index; } // إذا وصلنا إلى هنا، فإن N غير موجودة بأي مكان ضمن المصفوفة // أعد القيمة -1 return -1; } يُطلَق على طريقة البحث السابقة اسم البحث الخطي (linear search). إذا لم يَكُن لدينا أي معلومة عن كيفية ترتيب العناصر ضِمْن المصفوفة، فإنها تُمثِل الخيار الأفضل. في المقابل، إذا كنا على علم بكَوْن عناصر المصفوفة مُرتَّبة تصاعديًا أو تنازليًا، يُمكِننا إذًا أن نستغل تلك الحقيقة، ونَستخدِم خوارزميات أسرع. إذا كانت عناصر مصفوفة معينة مرتبة، يُقال عليها أنها "مصفوفة مرتبة (sorted)". يَستغرق ترتيب عناصر مصفوفة بعض الوقت بالطبع، ولكن إذا كانت المصفوفة مرتبة بالفعل، فيُمكننا إذًا أن نستغل تلك الحقيقة. يُعدّ البحث الثنائي (binary search) أحد الطرائق المُتاحة للبحث عن عنصر ضِمْن مصفوفة مُرتَّبة (sorted). على الرغم من أن تّنْفِيذه (implement) ليس سهلًا تمامًا، فإن فكرته بسيطة: إذا كنت تَبحَث عن عنصر ضِمْن قائمة مُرتَّبة، يُمكِنك أن تَستبعِد نُصف العناصر ضِمْن القائمة بِفَحْص عنصر واحد فقط. على سبيل المثال، لنَفْترِض أننا نبحث عن العدد 42 ضِمْن مصفوفة مُرتَّبة مُكوَّنة من 1000 عدد صحيح، ولنَفْترِض أن المصفوفة مُرتَّبة ترتيبًا تصاعديًا، ولنَفْترِض أننا فَحصَنا العنصر الموجود بالفهرس 500، ووجدنا أن قيمته تُساوِي 93. لمّا كان العدد 42 أقل من 93، ولمّا كانت العناصر بالمصفوفة مُرتَّبة ترتيبًا تصاعديًا، يُمكِننا إذًا أن نَستنتج أنه في حالة كان العدد 42 موجودًا بتلك المصفوفة من الأساس، فإنه لابُدّ وأنه يَقَع بمَوضِع فهرسه أقل من 500. يُمكِننا إذًا أن نَستبِعد جميع العناصر الموجودة بمَواضِع فهرسها أكبر من 500؛ فهي بالضرورة أكبر من أو تُساوِي 93. الخطوة التالية ببساطة هي فَحْص قيمة العنصر بالمَوْضِع 250. إذا كان العدد بذلك المَوْضِع يُساوِي -21 مثلًا، يُمكِننا إذًا أن نَستبعِد جميع العناصر قَبل المَوْضِع 250، ونُقصِر بحثنا على المَواضِع من 251 إلى 499. سيُقصِر الاختبار التالي بحثنا إلى 125 مَوضِع فقط ثُمَّ إلى 62. سيَتَبقَّى مَوضِع واحد فقط بعد 10 خطوات. تُعدّ تلك الطريقة أفضل كثيرًا من فَحْص كل عنصر ضِمْن المصفوفة. فمثلًا، إذا كانت المصفوفة تَحتوِي على مليون عنصر، ستَستغرِق خوارزمية البحث الثنائي 20 خطوة فقط للبحث بكامل المصفوفة. في العموم، يُساوِي عدد الخطوات اللازمة للبحث بأي مصفوفة لوغاريتم عدد العناصر بتلك المصفوفة بالنسبة للأساس 2. لكي نَتَمكَّن من تَحْوِيل خوارزمية البحث الثنائي (binary search) إلى برنامج فرعي (subroutine) يَبحَث عن عنصر N ضِمْن مصفوفة A، ينبغي أن نَحتفِظ بنطاق المَوْاضِع التي يُحتمَل أن تَحتوِي على N بحيث نَستبعِد منها تدريجيًا المزيد من الاحتمالات، ونُقلِّل من حجم النطاق. سنَفْحَص دائمًا العنصر الموجود بمنتصف النطاق. إذا كانت قيمته أكبر من N، يُمكِننا إذًا أن نَستبعِد النصف الثاني من النطاق أما إذا كانت قيمته أقل من N، يُمكِننا أن نَستبعِد النصف الأول. أما إذا كانت قيمته تُساوي N، فإن البحث يَكُون قد انتهى. إذا لم يَتبقَّى أية عناصر، فإن العدد N غَيْر موجود إذًا بالمصفوفة. اُنظر شيفرة البرنامج الفرعي (subroutine): /** * Precondition: A must be sorted into increasing order. * Postcondition: If N is in the array, then the return value, i, * satisfies A[i] == N. If N is not in the array, then the * return value is -1. */ static int binarySearch(int[] A, int N) { int lowestPossibleLoc = 0; int highestPossibleLoc = A.length - 1; while (highestPossibleLoc >= lowestPossibleLoc) { int middle = (lowestPossibleLoc + highestPossibleLoc) / 2; if (A[middle] == N) { // عثرنا على N بهذا الفهرس return middle; } else if (A[middle] > N) { // استبعد المواضع الأكبر من أو تساوي middle highestPossibleLoc = middle - 1; } else { // استبعد المواضع الأقل من أو تساوي middle lowestPossibleLoc = middle + 1; } } // إذا وصلنا إلى هنا فإن highestPossibleLoc < LowestPossibleLoc // أي أن N غير موجود بالمصفوفة. // أعد القيمة -1 لكي تشير إلى عدم وجود N بالمصفوفة return -1; } القوائم الارتباطية (Association Lists) تُعدّ القوائم الارتباطية (association lists) مثل القاموس (dictionary) واحدة من أشهر التطبيقات على البحث (searching). يَربُط القاموس (dictionary) مجموعة من التعريفات مع مجموعة من الكلمات. يُمكِنك مثلًا أن تَستخدِم كلمة معينة لمعرفة تَعرِيفها. قد تُفكِر بالقاموس على أنه قائمة من الأزواج (pairs) على الهيئة (w,d) حيث تُمثِل w كلمة معينة بينما d هي تعريف تلك الكلمة. بالمثل، تَتَكوَّن القوائم الارتباطية (association list) من قائمة من الأزواج (k,v) حيث تُمثِل k مفتاحًا (key) معينًا بينما تُمثِل v القيمة (value) المرتبطة بذلك المفتاح. لا يُمكِن لزوجين (pairs) ضِمْن قائمة معينة أن يَملُكا نفس قيمة المفتاح (key). عادةً ما نُطبِق عمليتين أساسيتين على القوائم الارتباطية: أولًا، إذا كان لديك مفتاح معين k، يُمكِنك أن تَجلُب القيمة v المُرتبِطة به إن وجدت. ثانيًا، يُمكِننا أن نُضيِف زوجًا جديدًا (k,v) إلى قائمة ارتباطية (association list). لاحِظ أنه في حالة إضافة زوج (pair) إلى قائمة، وكانت تلك القائمة تَحتوِي على زوج له نفس المفتاح، فإن القيمة الجديدة المضافة تَحلّ محلّ القديمة. يُطلق عادةً على تلك العمليتين اسم الجَلْب (get) والإضافة (put). يُشاع اِستخدَام القوائم الارتباطية (association lists) في العموم بعلوم الحاسوب (computer science). على سبيل المثال، لابُدّ أن يَحتفِظ المُصرِّف (compiler) بمَوضِع الذاكرة (memory location) الخاص بكل مُْتْغيِّر. يستطيع المُصرِّف إذًا أن يَستخدِم قائمة ارتباطية بحيث يُمثِل كل مفتاح (key) بها اسمًا لمُتْغيِّر بينما تُمثِل قيمة (value) ذلك المفتاح عنوانه بالذاكرة. مثال آخر هو القوائم البريدية إذا كان العنوان مَربُوطًا باسم ضِمْن تلك القائمة. فمثلًا، يَربُط دليل الهاتف كل اسم برقم هاتف. سنَفْحَص فيما يَلي نُسخة مُبسَطة من ذلك المثال. يُمكِننا أن نُمثِل عناصر دليل الهاتف بالقائمة الارتباطية (association list) بهيئة كائنات تنتمي إلى الصَنْف التالي: class PhoneEntry { String name; String phoneNum; } تَتَكوَّن البيانات الموجودة بدليل الهاتف من مصفوفة من النوع PhoneEntry[] بالإضافة إلى مُتْغيِّر من النوع العددي الصحيح (integer) للاحتفاظ بعدد المُدْخَلات المُخزَّنة فعليًا بذلك الدليل. يُمكِننا أيضًا أن نَستخدِم تقنية المصفوفات الديناميكية (dynamic arrays) -اُنظر القسم الفرعي 7.2.4- لكي نَتجنَّب وَضْع حد أقصى عشوائي على عدد المُدْخَلات التي يُمكِن لدليل الهاتف أن يَحتوِيه أو قد نَستخدِم النوع ArrayList. ينبغي أن يَحتوِي الصَنْف PhoneDirectory على توابع نسخ (instance methods) لتّنْفِيذ (implement) كلًا من عمليتي الجَلْب (get) والإضافة (put). تُمثِل الشيفرة التالية تعريفًا (definition) بسيطًا لذلك الصَنْف: public class PhoneDirectory { private static class PhoneEntry { String name; // الاسم String number; // رقم الهاتف } private PhoneEntry[] data; // مصفوفة لحمل أزواج مكونة من أسماء وأرقام هاتف private int dataCount; // عدد الأزواج ضمن المصفوفة /** * Constructor creates an initially empty directory. */ public PhoneDirectory() { data = new PhoneEntry[1]; dataCount = 0; } private int find( String name ) { for (int i = 0; i < dataCount; i++) { if (data[i].name.equals(name)) return i; // الاسم موجود بالموضع i } return -1; // الاسم غير موجود بالمصفوفة } /** * @return The phone number associated with the name; if the name does * not occur in the phone directory, then the return value is null. */ public String getNumber( String name ) { int position = find(name); if (position == -1) return null; // لا يوجد بيانات لذلك الاسم else return data[position].number; } public void putNumber( String name, String number ) { if (name == null || number == null) throw new IllegalArgumentException("name and number cannot be null"); int i = find(name); if (i >= 0) { // الاسم موجود بالفعل بالموضع i بالمصفوفة // استبدل العدد الجديد بالعدد القديم بذلك الموضع data[i].number = number; } else { // أضف زوج جديد مكون من اسم وعدد إلى المصفوفة // إذا كانت المصفوفة ممتلئة، أنشئ مصفوفة جديدة أكبر if (dataCount == data.length) { data = Arrays.copyOf( data, 2*data.length ); } PhoneEntry newEntry = new PhoneEntry(); // أنشئ زوجا جديدا newEntry.name = name; newEntry.number = number; data[dataCount] = newEntry; // أضف الزوج الجديد إلى المصفوفة dataCount++; } } } // end class PhoneDirectory يُعرِّف الصَنْف تابع النسخة find(). يَستخدِم ذلك التابع أسلوب البحث الخطي (linear search) للعثور على مَوْضِع اسم معين بالمصفوفة المُكوَّنة من أزواج من الأسماء والأرقام. يَعتمِد كُلًا من التابعين getNumber() و putNumber() على التابع find(). لاحِظ أن التابع putNumber(name,number) يَفحَص أولًا ما إذا كان الاسم موجودًا بدليل الهاتف أم لا. إذا كان موجودًا، فإنه فقط يُغيِّر الرقم المُرتبِط بذلك الاسم أما إذا لم يَكُن موجودًا، فإنه يُنشِئ مُدخلًا جديدًا ويُضيفه إلى المصفوفة. قد نُضيِف الكثير من التحسينات بالطبع على الصَنْف المعرَّف بالأعلى. فمثلًا، قد نَستخدِم البحث الثنائي (binary search) بالتابع getNumber() بدلًا من البحث الخطي، ولكن يَتَطلَّب ذلك أن تَكُون الأسماء المُخزَّنة بقائمة المُدْخَلات مُرتَّبة ترتيبًا أبجديًا، وهو ليس أمرًا صعبًا كما ستَرَى بالقسم الفرعي التالي. عادةً ما يُطلَق اسم الخارطة (maps) على القوائم الارتباطية (association lists)، وتُوفِّر الجافا صَنْفًا قياسيًا ذو معاملات غَيْر محددة النوع (parameterized) اسمه هو Map كتنفيذ (implementation) لها. تستطيع أن تَستخدِم ذلك الصَنْف لكي تُنشِئ قوائمًا ارتباطية مُكوَّنة من مفاتيح (keys) وقيم (values) من أي نوع. يُعدّ ذلك التنفيذ (implementation) أكفأ بكثير من أي شيء قد تَفعَلُه باِستخدَام مصفوفات بسيطة. سنَتعرَّض بالفصل العاشر لذلك الصَنْف. الترتيب بالإدراج (Insertion Sort) اتضح لنا الآن الحاجة إلى ترتيب المصفوفات. في الواقع، تَتَوفَّر الكثير من الخوارزميات المُخصَّصة لذلك الغرض، منها خوارزمية الترتيب بالإدراج (insertion sort). تُعدّ تلك الخوارزمية واحدة من أسهل الطرائق لترتيب مصفوفة، كما يُمكِننا أن نُطبِقها للإبقاء على المصفوفة مُرتَّبة بينما نُضيِف إليها عناصر جديدة. لنَفْحَص المثال التالي أولًا: لنَفْترِض أن لدينا قائمة مُرتَّبة ونريد إضافة عنصر جديد إليها. إذا كنا نريد التأكُّد من أنها ستظلّ مُرتَّبة، ينبغي أن نُضيِف ذلك العنصر بمَوْضِعه الصحيح بحيث تأتي قبله جميع العناصر الأصغر بينما تَحِلّ بَعْده جميع العناصر الأكبر. يَعنِي ذلك تَحرِيك جميع العناصر الأكبر بمقدار خانة واحدة لترك مساحة للعنصر الجديد. static void insert(int[] A, int itemsInArray, int newItem) { int loc = itemsInArray - 1; // ابدأ من نهاية المصفوفة // حرك العناصر الأكبر من newItem للأعلى بمقدار مسافة واحدة // وتوقف عندما تقابل عنصر أصغر أو عندما تصل إلى بداية المصفوفة while (loc >= 0 && A[loc] > newItem) { A[loc + 1] = A[loc]; // Bump item from A[loc] up to loc+1. loc = loc - 1; // انتقل إلى الموضع التالي } // ضع newItem بآخر موضع فارغ A[loc + 1] = newItem; } يُمكِننا أن نَمِد ذلك إلى تابع للترتيب (sorting) بأخذ جميع العناصر الموجودة بمصفوفة غَيْر مُرتَّبة (unsorted) ثم اضافتها تدريجيًا واحدًا تلو الآخر إلى مصفوفة آخرى مع الإبقاء عليها مُرتَّبة. نستطيع أن نَستخدِم البرنامج insert بالأعلى أثناء كل عملية إدراج (insertion) لعنصر جديد. ملحوظة: بالخوارزمية الفعليّة، لا نأخُذ جميع العناصر من المصفوفة، ولكننا فقط نتذكَّر الأجزاء المُرتَّبة منها: static void insertionSort(int[] A) { // رتب المصفوفة A ترتيبًا تصاعديا int itemsSorted; // عدد العناصر المُرتَّبة إلى الآن for (itemsSorted = 1; itemsSorted < A.length; itemsSorted++) { // افترض أن العناصر A[0] و A[1] .. إلخ // مرتبة بالفعل. أضف A[itemsSorted] إلى الجزء المرتب من // القائمة int temp = A[itemsSorted]; // العنصر المطلوب إضافته int loc = itemsSorted - 1; // ابدأ بنهاية القائمة while (loc >= 0 && A[loc] > temp) { A[loc + 1] = A[loc]; // Bump item from A[loc] up to loc+1. loc = loc - 1; // Go on to next location. } // ضع temp بآخر موضع فارغ A[loc + 1] = temp; } } تُوضِح الصورة التالية مرحلة واحدة من عملية الترتيب بالإدراج (insertion sort) حيث تُبيِّن ما يحدث أثناء تّنفِيذ تَكْرار واحد من الحلقة for بالأعلى بالتحديد عندما يَكُون عدد العناصر ضِمْن المصفوفة itemsSorted مُساوِيًا للقيمة 5: الترتيب الانتقائي (Selection Sort) تَستخدِم خوارزمية ترتيب (sorting) آخرى فكرة العُثور على أكبر عنصر ضِمْن القائمة، وتَحرِيكه إلى نهايتها حيث ينبغي أن يتواجد إذا كنا نريد ترتيبها ترتيبًا تصاعديًا. بمُجرَّد تَحرِيك أكبر عنصر بالقائمة إلى مَوْضِعه الصحيح، يُمكِننا أن نُطبِق نفس الفكرة على العناصر المُتبقَاة أي أن نَعثُر على العنصر الأكبر التالي، ونُحرِكه إلى المَوْضِع قبل الأخير، وهكذا. يُطلَق اسم "الترتيب الانتقائي (selection sort)" على تلك الخوارزمية (algorithm). يُمكِننا كتابتها كما يلي: static void selectionSort(int[] A) { // رتب المصفوفة A ترتيبا تصاعديا باستخدام الترتيب الانتقائي for (int lastPlace = A.length-1; lastPlace > 0; lastPlace--) { int maxLoc = 0; // موضع أكبر عنصر إلى الآن for (int j = 1; j <= lastPlace; j++) { if (A[j] > A[maxLoc]) { // لأن A[j] أكبر من أكبر قيمة رأيناها إلى الآن، فإن j هو // الموضع الجديد لأكبر قيمة وجدناها إلى الآن maxLoc = j; } } int temp = A[maxLoc]; // Swap largest item with A[lastPlace]. A[maxLoc] = A[lastPlace]; A[lastPlace] = temp; } // end of for loop } يَستخدِم الصَنْف Hand الذي كتبناه بالقسم الفرعي 5.4.1 نسخة مختلفة قليلًا من الترتيب الانتقائي (selection sort). يُعرِّف الصَنْف Hand مُتْغيِّرًا من النوع ArrayList<Card> لتَمثيِل اليد (hand) يحتوي بطبيعة الحال على كائنات من النوع Card. يَحتوِي أي كائن من النوع Card على توابع النسخ getSuit() و getValue()، والتي يُمكِننا أن نَستخدِمها لمَعرِفة كُلًا من قيمة ورقة اللعب ورمزها (suit). يُنشِئ التابع (method) المسئول عن عملية الترتيب (sorting) قائمة جديدة، بحيث يَختار ورق اللعب من القائمة القديمة تدريجيًا وبترتيب مُتصاعِد ثم يُحرِكها من القائمة القديمة إلى الجديدة. يَستخدِم التابع (method) القائمة الجديدة بالنهاية لتمثيل اليد بدلًا من القديمة. قد لا يَكُون ذلك هو الأسلوب الأكفأ لإنجاز الأمر، ولكن نظرًا لأن عدد ورق اللعب ضِمْن أي يد (hand) عادةً ما يَكون صغيرًا، فإنه لا يُمثِل مشكلة كبيرة. اُنظر شيفرة ترتيب (sorting) ورق اللعب: public void sortBySuit() { ArrayList<Card> newHand = new ArrayList<Card>(); while (hand.size() > 0) { int pos = 0; // موضع البطاقة الأقل Card c = hand.get(0); // البطاقة الأقل for (int i = 1; i < hand.size(); i++) { Card c1 = hand.get(i); if ( c1.getSuit() < c.getSuit() || (c1.getSuit() == c.getSuit() && c1.getValue() < c.getValue()) ) { pos = i; // Update the minimal card and location. c = c1; } } hand.remove(pos); // احذف ورقة اللعب من اليد الأصلية newHand.add(c); // أضف ورقة اللعب إلى اليد الجديدة } hand = newHand; } لاحِظ أن موازنة العناصر ضِمْن قائمة ليست دائمًا ببساطة اِستخدَام < كالمثال بالأعلى. في هذه الحالة، تُعدّ ورقة لعب معينة أصغر من ورقة لعب آخرى إذا كان رمز (suit) الأولى أقل من رمز الثانية أو أن يَكُونا متساويين بشرط أن تَكُون قيمة ورقة اللعب الثانية أقل من قيمة الأولى. يتأكَّد الجزء الثاني من الاختبار من ترتيب ورق اللعب بنفس الرمز ترتيبًا صحيحًا بحسب قيمته. يُمثِل ترتيب قائمة من النوع String مشكلة مشابهة؛ فالعامل < غَيْر مُعرَّف للسَلاسِل النصية. في المقابل، يُعرِّف الصنف String التابع compareTo. إذا كان str1 و str2 من النوع String، يُمكِننا كتابة ما يلي: str1.compareTo(str2) يُعيِد التابع السابق قيمة من النوع int تُساوِي 0 إذا كان str1 يُساوِي str2 أو قيمة أقل من 0 عندما يأتي str1 قبل str2 أو أكبر من 0 عندما يأتي str1 بَعْد str2. تستطيع مثلًا أن تختبر ما إذا كان str1 يَسبِق str2 أو يُساويه باِستخدَام الاختبار التالي: if ( str1.compareTo(str2) <= 0 ) يُقصَد بكلمات مثل "يَسبِق" أو "يَتبَع" -عند اِستخدَامها مع السَلاسِل النصية (string)- ترتيبها وفقًا للترتيب المعجمي (lexicographic ordering)، والذي يَعتمِد على قيمة اليونيكود (Unicode) للمحارف (characters) المُكوِّنة للسِلسِلة النصية (strings). يَختلِف الترتيب المعجمي (lexicographic ordering) عن الترتيب الأبجدي (alphabetical) حتى بالنسبة للكلمات المُكوَّنة من أحرف أبجدية فقط؛ لأن جميع الأحرف بحالتها الكبيرة (upper case) تَسبِق جميع الأحرف بحالتها الصغيرة (lower case). في المقابل، يُعدّ الترتيب الأبجدي والمعجمي للكلمات المُقتصِرة على 26 حرف بحالتها الصغيرة فقط أو الكبيرة فقط هو نفسه. يُحوِّل التابع str1.compareToIgnoreCase(str2) أحرف سِلسِلتين نصيتين (strings) إلى الحالة الصغيرة (lowercsae) أولًا قبل موازنتهما. يتناسب كُلًا من الترتيب الانتقائي (selection sort) والترتيب بالادراج (insertion sort) مع المصفوفات الصغيرة المُكوَّنة من مئات قليلة من العناصر. أما بالنسبة للمصفوفات الكبيرة، فتَتَوفَّر خوارزميات آخرى أكثر تعقيدًا ولكنها أسرع بكثير. هذه الخوارزميات أسرع بكثير ربما بنفس الكيفية التي يُعدّ بها البحث الثنائي (binary search) أسرع من البحث الخطي (linear search). يَعتمِد التابع القياسي Arrays.sort على الخوارزميات السريعة. سنَتعرَّض لإحدى تلك الخوارزميات بالفصل التاسع. الترتيب العشوائي (unsorting) يُعدّ الترتيب العشوائي لعناصر مصفوفة مشكلة أقل شيوعًا ولكنها شيقة. قد نحتاجها مثلًا لخَلْط مجموعة ورق لعب ضِمْن برنامج. تَتًوفَّر خوارزمية شبيهة بالترتيب الانتقائي (selection sort)، فبدلًا من تَحرِيك أكبر عنصر بالمصفوفة إلى نهايتها، سنختار عنصرًا عشوائيًا ونُحرِكه إلى نهايتها. يَخلِط البرنامج الفرعي (subroutine) التالي عناصر مصفوفة من النوع int: /** * Postcondition: The items in A have been rearranged into a random order. */ static void shuffle(int[] A) { for (int lastPlace = A.length-1; lastPlace > 0; lastPlace--) { // اختر موضعا عشوائيًا بين 0 و 1 و... حتى lastPlace int randLoc = (int)(Math.random()*(lastPlace+1)); // بدل العناصر بالموضعين randLoc و lastPlace int temp = A[randLoc]; A[randLoc] = A[lastPlace]; A[lastPlace] = temp; } } ترجمة -بتصرّف- للقسم Section 4: Searching and Sorting من فصل Chapter 7: Arrays and ArrayLists من كتاب Introduction to Programming Using Java.