رضوى العربي
الأعضاء-
المساهمات
114 -
تاريخ الانضمام
-
تاريخ آخر زيارة
نوع المحتوى
ريادة الأعمال
البرمجة
التصميم
DevOps
التسويق والمبيعات
العمل الحر
البرامج والتطبيقات
آخر التحديثات
قصص نجاح
أسئلة وأجوبة
كتب
دورات
كل منشورات العضو رضوى العربي
-
سنتناول خلال هذا المقال برنامجًا معقدًا بعض الشئ عما رأيناه مسبقًا، فقد كانت غالبية الأمثلة التي تعرَّضنا إليها مجرد أمثلة بسيطة هدفها توضيح تقنية برمجية أو اثنتين على الأكثر؛ أما الآن، فقد ان الوقت لتوظيف كل تلك الأفكار والتقنيات معًا ضمن برنامجٍ واحدٍ حقيقي. سنناقش أولًا البرنامج وتصميمه بصورةٍ مُبسطة، وبينما نفعل ذلك، سنتحدث سريعًا عن بعض خاصيات جافا التي لم نتمكَّن من الحديث عنها أثناء المقالات السابقة؛ إذ تتضمَّن تلك الخاصيات أمورًا يُمكِن تطبيقها على جميع البرامج وليس فقط على برامج واجهات المُستخدم الرسومية GUI. سنتحدث تحديدًا عن برنامج عرض مجموعة ماندلبرو Mandelbrot، والذي يَسمَح للمُستخدِم باكتشاف تلك المجموعة الشهيرة، إذ سنبدأ أولًا بشرح ما يعنيه ذلك. تتوفَّر نسخةٌ أقوى من هذا البرنامج؛ ونسخةٌ أخرى منه مكتوبةٌ بلغة JavaScript، وقابلةٌ للتشغيل بمتصفحات الإنترنت. مجموعة ماندلبرو Mandelbrot مجموعة ماندلبرو هي مجموعةٌ من النقاط الواقعة على سطح مستوى xy، التي تُحسَب مواضعها بواسطة عمليةٍ حسابية؛ وكل ما تحتاج إلى معرفته لكي تتمكَّن من تشغيل البرنامج هو أن تَعرِف إمكانية استخدام هذه المجموعة لصناعة مجموعةٍ من الصور الرائعة. سننتقل الآن إلى التفاصيل الحسابية: لنفترض لدينا نقطة (a,b)، وكان الإحداثي الأفقي والرأسي لتلك النقطة مكونين من أعدادٍ حقيقية، عندها يُمكِننا إذًا تطبيق العمليات التالية عليها: Let x = a Let y = b Repeat: Let newX = x*x - y*y + a Let newY = 2*x*y + b Let x = newX Let y = newY تتغير إحداثيات النقطة (x,y) أثناء تنفيذ حلقة التكرار loop بالأعلى، ويَنقلنا ذلك إلى السؤال التالي: هل تزداد قيم إحداثيات النقطة (x,y) دون أي قيد أم أنها تقتصر على منطقة نهائية ضمن المستوى؟ إذا كانت (x,y) تذهب إلى اللانهاية (أي تزداد بدون قيد)، فإن نقطة البداية (a,b) لا تنتمي إلى مجموعة ماندلبرو؛ أما إذا كانت النقطة (x,y) مقتصرةً على منطقة نهائية، فإن نقطة البداية (a,b) تنتمي إلى المجموعة. من المعروف أنه لو أصبح 'x2 + y2' أكبر من '4' ضمن أي لحظة، فإن النقطة (x,y) تذهب إلى اللانهاية، وبالتالي لو أصبح 'x2 + y2' أكبر من '4' بالحلقة المُعرَّفة بالأعلى، فيُمكِننا أن نُنهِي الحلقة، ونستنتج أن النقطة (a,b) ليست ضمن مجموعة ماندلبرو بلا شك. في المقابل، بالنسبة لنقطة (a,b) تنتمي إلى تلك المجموعة، فإن الحلقة لن تنتهي أبدًا. إذا شغَّلنا ذلك على حاسوب، فإننا بالطبع لا نريد أن نحصل على حلقة لا نهائية تَعمَل إلى الأبد، ولذلك سنضع حدًا أقصى على عدد مرات تنفيذ الحلقة، وسيُمثِّل maxIterations ذلك الحد. ألقِ نظرةً إلى ما يلي: x = a; y = b; count = 0; while ( x*x + y*y < 4.1 ) { count++; if (count > maxIterations) break; double newX = x*x - y*y + a; double newY = 2*x*y + b; x = newY; y = newY; } بعد انتهاء الحلقة، وإذا كانت قيمة count أقل من أو تُساوِي maxIterations؛ فعندها يُمكِننا أن نستنتج أن النقطة (a,b) لا تنتمي إلى مجموعة ماندلبرو؛ أما إذا كانت قيمة count أكبر من maxIterations، فقد تنتمي النقطة (a,b) إلى المجموعة أو لا؛ وفي العموم كلما كانت قيمة maxIterations أكبر، كلما زادت احتمالية انتماء النقطة (a,b) إلى المجموعة. سنُنشِئ صورةً باستخدام العملية الحسابية السابقة على النحو التالي: سنَستخدِم شبكةً مستطيلةً من البكسلات لتمثيل مستطيلٍ واقعٍ على سطح المستوى، بحيث يتوافق كل بكسل مع إحداثيات قيمها حقيقية (a,b)، إذ تُشير قيم الإحداثيات إلى مركز البكسل. سنُشغِّل حلقة التكرار بالأعلى لكل بكسل؛ فإذا تعدَّت قيمة count قيمة الحد الأقصى maxIterations، فسنُلّون البكسل باللون الأسود، مما يعني أن تلك النقطة قد تنتمي إلى مجموعة ماندلبرو؛ أما إذا لم يتعداه، فسيعتمد لون البكسل على قيمة count بعد انتهاء الحلقة، مما يَعنِي أننا سنَستخدِم ألوانًا مختلفةً للقيم المختلفة من count. كلما ازدادت قيمة count، كانت النقطة أقرب إلى مجموعة ماندلبرو؛ وبالتالي تُعطِي الألوان بعض المعلومات عن النقاط الواقعة خارج المجموعة وعن شكل المجموعة، ولكن من المهم أن تدرك أن تلك الألوان عشوائية وأن النقاط الملونة لا تنتمي إلى المجموعة. تَعرِض الصورة التالية لقطة شاشة من برنامج عرض مجموعة ماندلبرو، الذي يَستخدِم نفس تلك العملية الحسابية؛ إذ تُمثِّل المنطقة السوداء مجموعة ماندلبرو، باستثناء أن بعض النقاط السوداء قد لا تكون ضمن المجموعة فعليًا. إذا شغَّلت البرنامج فبإمكانك تكبير الصورة حول أي منطقة صغيرة ضمن سطح المستوى، وذلك بنقر زر الفأرة على الصورة مع السحب؛ إذ يؤدي ذلك إلى رسم "صندوق تكبير" مستطيل الشكل حول جزء الصورة ذاك كما هو مُوضَّح بالأعلى. وعندما تُحرِّر زر الفأرة، سيزداد حجم جزء الصورة الموجود داخل صندوق التكبير لكي يملأ الشاشة بالكامل؛ أما إذا نقرت على أي نقطة ضمن الصورة، فسيزداد حجم الصورة أيضًا عند النقطة التي نقرت عليها بمعدل تكبير يساوي الضعف. انقر على "Shift" أو اِستخدِم زر الفأرة الأيمن لكي تُصغرّ حجم الصورة. تُعدّ النقاط الموجودة عند الحدود الفاصلة لمجموعة ماندلبرو هي النقاط الأكثر تشويقًا، وفي الحقيقة، يُعَد ذلك الفاصل معقدًا تعقيدًا لا نهائيًا؛ فإذا كبَّرت الصورة إلى حدٍ بعيد، فلن يمنعك البرنامج من فعل ذلك، ولكنك ستتعدّى إمكانيات نوع البيانات double، وستبدأ بكسلات الصورة في الظهور وستصبح الصورة بلا معنى. يُمكِنك استخدام قائمة "MaxIterations" لزيادة الحد الأقصى لعدد مرات تكرار الحلقة، وتذكّر أن البكسلات السوداء قد تقع أو لا ضمن المجموعة؛ فإذا أزدت قيمة MaxIterations، فقد تجد أن بعض المناطق السوداء قد أصبحت ملونةً هي الأخرى. تُحدِّد قائمة "Palette" مجموعة الألوان المُستخدَمة، ويؤدي استخدام لوحات ألوان مختلفة إلى إنتاج صورٍ مختلفة، ولكنها مُجرّد مجموعة ألوان عشوائية فقط. وتحدِّد قائمة "PaletteLength" عدد الألوان المختلفة المُستخدَمة؛ فإذا اِستخدَمت الإعدادات الافتراضية، فإن كل قيمة ممكنة للمتغير count يُقابلها قيمة لونٍ مختلفة. قد تحصل أحيانًا على صور أفضل بكثير باستخدام عددٍ مختلف من الألوان؛ فإذا كان عدد الألوان الموجودة بلوحة الألوان المُستخدَمة أقل من قيمة maxIterations، فسيتكرَّر نفس اللون لكي يشمل جميع القيم المحتملة للمتغير count؛ أما إذا كان عددها أكبر من قيمة maxIterations، فسيُستخدَم فقط جزءٌ من الألوان المُتاحة. لذلك، إذا كانت غالبية بكسلات الصورة من خارج المجموعة مُكوَّنة من درجات مختلفة من لون واحد تقريبًا، فقللّ عدد ألوان لوحة الألوان؛ إذ يؤدي ذلك إلى اختلاف الألوان بصورةٍ أسرع مع تغيُّر قيمة count؛ أما إذا وجدتها مُكوَّنةً من ألوان عشوائية تمامًا بدون أي انسيابية بين الألوان، زِد عدد ألوان لوحة الألون. يحتوي البرنامج على قائمة "File" يُمكِن استخدامها لحفظ الصورة مثل ملف صورة بامتداد PNG؛ ويُمكِنك أيضًا أن تحفظ ملف "param" لحفظ إعدادات البرنامج التي أنتجت الصورة الحالية؛ وتستطيع لاحقًا قراءة هذا الملف إلى البرنامج باستخدام الأمر "Open". يعود اسم مجموعة ماندلبرو إلى Benoit Mandelbrot، وهو أول من لاحظ ذلك التعقيد المذهل لتلك المجموعة، إذ من الرائع الحصول على هذا التعقيد والجمال من تلك الخوارزمية البسيطة. تصميم تطبيق مجموعة ماندلبرو نجد معظم الأصناف بلغة جافا مُعرَّفةً ضمن حزم packages؛ فعلى الرغم من أننا استخدمنا بعض الحزم القياسية، مثل javafx.scene.control و java.io بكثرة، إلا أن معظم الأمثلة التي تعرَّضنا إليها كانت تَستخدِم الحزمة الافتراضية، ويَعنِي ذلك أننا لم نُصرِّح بانتمائها إلى أي حزمةٍ مسماة. وفي المقابل، عند إنجاز أي برمجة جدية، فمن الأفضل دومًا أن نُنشِئ حزمةً لاحتواء الأصناف المُستخدَمة بالبرنامج. تُوصِي مؤسسة Oracle بأن تكون أسماء الحزم مبنيةً على اسم نطاق domain name الإنترنت للمؤسسة المُنتِجة للحزمة؛ فبالنسبة لحاسوب المؤلف، فإن اسم النطاق الخاص به هو eck.hws.edu، ولا يُفترَض لأي حاسوب آخر بالعالم عمومًا أن يكون له نفس الاسم؛ ووفقًا لمؤسسة Oracle، ينبغي أن يكون اسم الحزمة في تلك الحالة هو edu.hws.eck، أي سيكون ترتيب عناصر اسم النطاق معكوسًا. علاوةً على ذلك، ينبغي أن تُسمَى الحزم الفرعية ضمن تلك الحزمة بأسماءٍ، مثل edu.hws.eck.mdbfx، وهو في الواقع الاسم الذي اختاره المؤلف لتطبيق عرض مجموعة ماندلبرو. ويَضمَن ذلك ألا يَستخدِم أي شخصٍ آخر -شرط أن يتبِع نفس نمط التسمية- نفس اسم الحزمة، وبناءً على ذلك، يُمكِن استخدام ذلك الاسم الفريد لتحديد هوية التطبيق. ناقشنا باختصار طريقة استخدام الحزم بمقال بيئات البرمجة programming environment في جافا، وكذلك أثناء شرح بعض الأمثلة البرمجية بمقال أمثلة برمجية على الشبكات في جافا: إطار عمل لتطوير الألعاب عبر الشبكة. باختصار، يُمثِل التالي كل ما ينبغي أن تعرفه بخصوص تطبيق عرض مجموعة ماندلبرو، فالبرنامج مُعرَّفٌ ضمن 7 ملفات شيفرة مصدرية تجدها بالمجلد edu/hws/eck/mdbfx الموجود داخل مجلد source بالموقع؛ أي أن الملفات موجودة بمجلدٍ اسمه mdbfx، والموجود بدوره بمجلدٍ اسمه eck، المتواجد بمجلد hws، ضمن مجلد edu. لا بُدّ أن تتبِع المجلدات اسم الحزمة بتلك الطريقة. يحتوي نفس ذلك المجلد على ملفٍ اسمه strings.properties المُستخدَم بالبرنامج والذي سنناقشه بالأسفل. ويحتوي مجلد examples على ملفات الموارد التي تَستخدِمها قائمة "Examples". ونظرًا لاعتماد البرنامج على مكتبة JavaFX، فينبغي أن تتأكّد من توفُّر المكتبة عند تصريف البرنامج أو تشغيله كما ناقشنا بمقال بيئات البرمجة (programming environment) في جافا المشار إليه في الأعلى؛ وإذا كنت تَستخدِم بيئة تطوير متكاملة Integrated Development Environment مثل Eclipse، فكل ما عليك فعله هو إضافة مجلد "edu" إلى المشروع، مع ضبطه لكي يَستخدِم مكتبة JavaFX؛ وإذا أردت استخدام سطر الأوامر، فينبغي أن يشير مجلد العمل working directory إلى المجلد المُتضمِّن لمجلد edu؛ وإذا لم تكن تَستخدِم إصدارًا قديمًا من JDK والذي تكون فيه مكتبة JavaFX مبنيةً مسبقًا، فستحتاج إلى إضافة خيارات مكتبة JavaFX إلى أمري javac و java. إذا كنت قد عرَّفت الأمرين jfxc و jfx المكافئين للأمرين javac و java مع خيارات مكتبة JavaFX، فيُمكِنك ببساطة أن تُصرِّف الشيفرة المصدرية باستخدام الأمر التالي: jfxc edu/hws/eck/mdbfx/*.java أو الأمر التالي إذا كنت تَستخدِم نظام التشغيل Windows: jfxc edu\hws\eck\mdbfx\*.java ستَجِد صنف التطبيق الرئيسي مُعرَّفًا بالصنف Main. اِستخدِم الأمر التالي لتشغيل البرنامج: jfx edu.hws.eck.mdbfx.Main يجب أن يُنفِّذ هذا الأمر بالمجلد المُتضمِّن لمجلد edu؛ وإذا كان إصدار JDK المُستخدِم يتضمَّن مكتبة JavaFX مُسبَقًا، فيُمكِنك ببساطة أن تَستخدِم الأمرين javac و java بدلًا من jfxc و jfx. يتضمَّن الملف MandelbrotCanvas.java غالبية العمليات المطلوبة لحساب صور مجموعة ماندلبرو وعرضها؛ إذ يُعدّ الصنف MandelbrotCanvas صنفًا فرعيًا من الصنف Canvas، ويُمكِنه حساب صور مجموعة ماندلبرو وعرضها كما ناقشنا بالأعلى. تعتمد الصورة الناتجة على النطاق الظاهر من قيم x و y، وعلى الحد الأقصى لعدد مرات تكرار الخوارزمية، وعلى لوحة الألوان المُستخدَمة لتلوين البكسلات خارج المجموعة. وتأتي جميع تلك المُدْخَلات من مكان آخر ضمن البرنامج، ويقتصر دور الصنف MandelbrotCanvas على حساب الصورة وعرضها بناءً على قيم المْدخَلات المُعطاة له. بالإضافة إلى تلوين بكسلات الصورة، يَستخدِم الصنف MandelbrotCanvas مصفوفةً ثنائية الأبعاد لتخزين قيمة count لكل بكسلٍ في الصورة؛ إذ تُحدِّد قيمة count لبكسل معين مع لوحة الألوان المُستخدَمة، اللون المستعمل لتلوين ذلك البكسل كما ناقشنا بالأعلى. وفي حالة تغيير لوحة الألوان المُستخدَمة، سيَستخدِم البرنامج قيمة المتغير count لكل بكسل لإعادة ضبط الألوان، دون أن يعيد حساب مجموعة ماندلبرو مرةً أخرى. في المقابل، إذا تغير نطاق قيم x و y، أو إذا تغير حجم النافذة، فسيضطّر البرنامج لإعادة حساب قيم count لجميع البكسلات. قد تستغرِق عملية حساب تلك القيم وقتًا طويلًا، ولأنه من غير المُفترَض أن نُعطِّل block واجهة المُستخدِم أثناء إجراء تلك الحسابات، سيُجرِي البرنامج تلك العمليات بخيطٍ عاملٍ worker thread منفصل كما ناقشنا بمقال البرمجة باستخدام الخيوط threads في جافا، إذ يَستخدِم البرنامج تحديدًأ خيطًا عاملًا وحيدًا لكل معالج. عند بدء عملية حساب تلك القيم، ستكون الصورة شفافة، أي أنه يُمكِنك رؤية الخلفية الرمادية للنافذة. تُقسَّم العملية إجمالًا إلى مجموعةٍ من المهام tasks، وتتكوَّن كل مهمة من عملية حساب صف واحد من الصورة. وبعد انتهاء أي مهمة، تُطبِّق الألوان الناتجة على البكسلات الموجودة بالصف الخاص بتلك المهمة. نظرًا لأنه من الممكن تعديل الحاوية فقط من خلال خيط تطبيق JavaFX، فستَستدعِي كل مهمة التابع Platform.runLater() لإجراء التغييرات المطلوبة، وهذا يُمكِّن المُستخدِم من الاستمرار باستخدام القوائم وحتى الفأرة أثناء عملية حساب الصورة. يحتوي الملف MandelbrotPane.java على كامل محتوى نافذة تطبيق عرض مجموعة ماندلبرو، إذ يُعدّ الصنف MandelbrotPane صنفًا فرعيًا من الصنف BorderPane. يحتوي منتصف كائن الحاوية المنتمي للصنف BorderPane على كائنٍ من النوع MandelbrotCanvas. وفي الحقيقة، يَستخدِم البرنامج حاويةً ثانيةً شفافةً فوق الحاوية المتضمِّنة للصورة؛ فعندما يَرسِم المُستخدِم "صندوق تكبير" باستخدام الفأرة، فإن ذلك الصندوق يُرسَم فعليًا بالحاوية العلوية لكي لا يشوه الصورة (ألقِ نظرةً على مقال أمثلة عن رسوميات فاخرة باستعمال جافا). في المقابل، تحتوي المنطقة السفلية من الحاوية على عنوان من النوع Label، يَعمَل مثل شريطٍ لعرض حالة البرنامج، إذ يُستخدَم لعرض بعض المعلومات التي قد تَهِم المُستخدِم. أخيرًا، يحتوي البرنامج على شريط قوائم أعلى الحاوية. يُعرِّف الصنف Menus.java -المُشتَق من الصنف MenuBar- شريط القوائم الخاص بالتطبيق. (ألقِ نظرةً على مقال بناء تطبيقات كاملة باستعمال مكتبة جافا إف إكس JavaFX لمزيدٍ من المعلومات عن القوائم وعناصر القوائم، كما يُعرِّف الصنف Menus مجموعةً من التوابع والأصناف الفرعية المتداخلة nested subclasses لتمثيل جميع عناصر القائمة، وكذلك الأوامر التي تُمثِّلها تلك العناصر؛ إذ تشتمل تلك الأوامر على أوامرٍ مُتعلِّقة بمعالجة الملفات وتَستخدِم التقنيات التي تَعرَّضنا لها بمقالات مدخل إلى التعامل مع الملفات في جافا ومقدمة مختصرة للغة XML واستعمالها في تطبيقات جافا وأمثلة عن رسوميات فاخرة باستعمال جافا المشار إليه بالأعلى. تحتوي القوائم "MaxIterations" و "Palette" و"PaletteLength" على مجموعةٍ من الكائنات المنتمية إلى النوع RadioMenuItems. يُعرِّف البرنامج صنفًا متداخلًا داخل الصنف Menus لتمثيل كل مجموعة؛ فعلى سبيل المثال، يُعرِّف الصنف PaletteManager عناصر قائمة "Palette" على هيئة متغيرات نسخة instance variables، ويُسجِّل معالج حدث لكل عنصر، كما يُعرِّف القليل من البرامج المفيدة لمعالجة القائمة. تتشابه الأصناف الخاصة بالقوائم الثلاثة، جتى أنه من الأفضل تعريفها على أنها أصنافٌ فرعيةٌ مشتقةٌ من صنفٍ أكثر عمومية. ويحتوي البرنامج أيضًا على قائمة "Examples" التي تتضمَّن الإعدادات الخاصة ببعض العينات لقطعٍ من مجموعة ماندلبرو. يُنفِّذ الصنف MandelbrotPane كثيرًا من العمل الذي يتطلّبه البرنامج؛ فهو يُهيئ معالجات لأحداث الفأرة MousePressed و MouseDragged و MouseReleased بالحاوية العلوية، ليُمكِّن المُستخدِم من تكبير الصورة وتصغيرها؛ كما يُهيئ معالجًا للحدث MouseMoved، الذي يُحدِّث شريط الحالة ويجعله يَعرِض إحداثيات النقطة المقابلة للمكان الحالي لمؤشر الفأرة على الصورة. يُولَّد الحدث MouseMoved عندما يُحرِّك المُستخدِم مؤشر الفأرة دون أن يضغط باستمرار على زرها. ويُستخدم كذلك الحدث MouseExited لإعادة ضبط شريط الحالة إلى كلمة "Idle" عندما يقع مؤشر الفأرة خارج الحاوية. بالإضافة إلى ما سبق، يُنفِّذ البرنامج أوامر قوائم أخرى كثيرة باستدعاء توابع مُعرَّفةٍ بالصنف MandebrotPane. ويحتوي الصنف Menus على متغير نسخة اسمه owner، يشير إلى الحاوية -من النوع MandelbrotPane- المُتضمِّنة لشريط القوائم، وبالتالي يُمكِنه استخدام ذلك المتغير لاستدعاء أي توابع مُعرَّفة بالصنف MandelbrotPane؛ إذ يضبُط التابع setLimits() مثلًا، نطاق قيم x و y الظاهرة بالصورة؛ كما أن هناك توابعٌ أخرى لضبط كُلٍ من لوحة الألوان المُستخدَمة وعدد الألوان الموجودة بلوحة الألوان، والحد الأقصى لعدد مرات تكرار الخوارزمية. بمجرد تغيُّر أي من تلك الخاصيات، لا بُدّ من تعديل الصورة المعروضة لمجموعة ماندلبرو؛ فعلى سبيل المثال، عندما تتغير لوحة الألوان المُستخدَمة أو عدد الألوان الموجودة باللوحة، يَحسِب الصنف MandelbrotPane لوحةً جديدةً من الألوان ويَستدعِي تابعًا مُعرَّفًا بالصنف MandelbrotCanvas ليُبلِّغه بأن عليه استخدام تلك اللوحة الجديدة. وفي المقابل، عندما يتغير الحد الأقصى لعدد مرات تكرار الخوارزمية، تكون إعادة حساب الصورة بالكامل ضرورية، ولهذا يَستدعِي الصنف MandelbrotPane التابع startJob() المُعرَّف بالصنف MandelbrotCanvas ليُبلِّغه بأن عليه أن يبدأ وظيفةً جديدة، ويتولى الصنف MandelbrotCanvas كل العمل اللازم لتهيئة تلك الوظيفة وإدارتها. يُمرَّر كائن الصنف MandelbrotPane المُستخدَم بالبرنامج مثل معاملٍ إلى باني الصنف Menus، ويُخزِّن بدوره كائن الصنف Menus المُمثِّل للقوائم نسخةً من ذلك الكائن بهيئة متغير نسخة، اسمه owner. في الواقع، يُعالِج الصنفان MandelbrotPane و MandelbrotCanvas غالبية أوامر القوائم، ولكي يتمكَّن الكائن المُمثِّل للقوائم من تنفيذ تلك الأوامر، فإنه يحتاج إلى مرجع reference إلى كائن الصنف MandelbrotPane. وبالمثل من الصنف MandelbrotCanvas، يُعرِّف كائن الصنف MandelbrotPane التابع getDisplay() الذي يعيد مرجعًا إلى الحاوية التي يحتويها، وبالتالي يستطيع الكائن المُمثِّل للقوائم الحصول على مرجعٍ إلى الحاوية باستدعاء owner.getDisplay(). كنا نضع شيفرة البرنامج بالكامل بالأمثلة السابقة من هذه السلسلة بملفٍ واحدٍ كبير، وبالتالي كانت جميع الكائنات متاحةً لكل أجزاء الشيفرة مباشرةً. وفي المقابل، عند تقسيم البرنامج إلى مجموعة من الملفات، لا يكون الوصول إلى الكائنات الضرورية بهذه السهولة. تُعدّ الأصناف MandelbrotPane و MandelbrotCanvas و Menus أكثر الأصناف أهميةً بتطبيق عرض مجموعة ماندلبرو؛ إذ يُعرِّف الصنف Main.java الصنف الفرعي المُشتَق من الصنف Application، والذي ينبغي تشغيله عند تنفيذ البرنامج؛ ويضع تابعه start() كائنًا من النوع MandelbrotPane داخل المرحلة stage الرئيسية للبرنامج. يحتوي البرنامج على ثلاثة أصناف أخرى، إذ يُعرِّف الصنفان SetImageSizeDialog.java و SetLimitsDialog.java صناديق نوافذ مخصَّصة، والتي لن نناقشها هنا أكثر من ذلك؛ أما الصنف الأخير، فهو I18n، والذي سنناقشه بالأسفل. أظهرت هذه المناقشة القصيرة لتصميم تطبيق عرض مجموعة ماندلبرو أنه يَستخدِم تشكيلةً واسعةً من التقنيات التي تعرَّضنا لها مُسبقًا خلال مقالات هذه السلسلة، وسنفحص بالجزء المُتبقِي من هذا المقال القليل من الخاصيات الجديدة المُستخدَمة ضمن البرنامج. الأحداث ومستمعي الأحداث والارتباطات تعاملنا مع الأحداث events ومستمعي الأحداث بكثرة، وكذلك مع ارتباط binding الخاصيات القابلة للمراقبة observable ببعض الأمثلة، وسيكون من الرائع لو رأينا طريقة استخدام تلك التقنيات ضمن تطبيق عرض مجموعة ماندلبرو، إذ سنَستخدِم مجموعةً من الأصناف. لنبدأ الآن من الحقيقة التالية: لا يَعرِف الصنف MandelbrotCanvas أي شيء عن الصنف Menus مع أن شريط القوائم يحتوي على عناصر يبدو وكأنها تَعرِف ما يحدث بصنف الحاوية MandelbrotCanvas. بالتحديد، تُعطَّل بعض عناصر القائمة عندما تكون عملية حساب الصورة قيد التنفيذ. بما أن الحاوية لا تستدعِي أي توابع أو تَستخدِم أيًا من متغيرات صنف القوائم Menus، فكيف تمكَّنت القوائم من معرفة ما إذا كانت هناك عمليةً حسابيةً قيد التنفيذ بالحاوية؟ الإجابة بالطبع هي من خلال استخدام الأحداث أو على نحوٍ أكثر دقة، وذلك من خلال استخدام الارتباط (ألقِ نظرةً على مقال البواني وتهيئة الكائنات Object Initialization في جافا). يحتوي الصنف MandelbrotCanvas على خاصية قابلة للمراقبة من النوع boolean اسمها working، إذ تحتوي تلك الخاصية على القيمة true عندما يكون هناك عملية معالجة قيد التنفيذ. وينبغي أن تكون عناصر القائمة مُعطَّلةً عندما تكون قيمة تلك الخاصية مساويةً للقيمة true، وهو ما يُمكِننا إجراؤه بسطر شيفرةٍ واحد يربُط خاصية عنصر قائمة disableProperty بخاصية الحاوية workingProperty. على سبيل المثال، يُمكِننا تطبيق ذلك على عنصر القائمة "saveImage" بكتابة ما يَلي داخل باني الصنف Menus: saveImage.disableProperty().bind(owner.getDisplay().workingProperty()); إذ يشير owner هنا إلى كائن الصنف MandelbrotPane؛ بينما تشير القيمة المُعادة من التابع owner.getDisplay() إلى كائن الصنف MandelbrotCanvas الموجود به. وبالمثل، يُعيد عنصر القائمة "Restore Previous Limits" ضبط نطاق قيم x و y الظاهرة إلى قيمها السابقة قبل آخر تحديث؛ إذ يَستخدِم الصنف Menus متغير النسخة previousLimits من النوع double[] ليتذكر نطاق القيم السابق، ولكن السؤال هو: كيف يحصل على تلك المعلومات؟ عندما يُكبّر المُستخدِم الصورة أو يُصغرّها، يحدث ذلك التغيير بالصنف MandelbrotPane؛ بالتالي لا بُدّ إذًا من وجود طريقة تُمكِّن القوائم من ملاحظة ذلك التغيير، ويكْمُن الحل طبعًا في استخدام خاصيةٍ قابلةٍ للمراقبة، وتكون تلك الخاصية من النوع ObjectProperty<double[]> في هذه الحالة. يُضيف باني الصنف Menus مستمع حدث من النوع ChangeListener إلى الخاصية limitsProperty المُعرَّفة بالصنف MandelbrotPane على النحو التالي: owner.limitsProperty().addListener( (o,oldVal,newVal) -> { // خزِّن القيمة القديمة للمتغير limitsProperty لاستخدامها بالأمر "Restore Previous Limits" previousLimits = oldVal; undoChangeOfLimits.setDisable( previousLimits == null ); }); نظرًا لأننا نَستخدِم الأحداث هنا للتواصل، فإن الصنفين MandelbrotCanvas و MandelbrotPane خفيفا الترابط loosely coupled مع الصنف Menus. في الحقيقة، يُمكِننا استخدامهما دون أي تعديل ببرامج أخرى لا تحتوي على نفس الصنف Menus من الأساس؛ وبدلًا من استخدام الأحداث والارتباط، كان من الممكن جعل صنفي الحاوية والعرض يَستدعِيان توابعًا، مثل limitsChanged() و computationStarted() مُعرَّفين بالصنف Menus. يكون الترابط بين الأصناف في تلك الحالة قويًا strong coupling، وبالتالي سيضطّر أي مبرمج يرغب باستخدام الصنف MandelbrotCanvas إلى استخدام الصنف Menus أيضًا، أو إلى تعديل الصنف MandelbrotCanvas كي لا يُشير إلى الصنف Menus. لا يُمكِننا طبعًا معالجة جميع المشاكل باستخدام الأحداث، كما أنه ليس من الضروري أن يكون أي ترابط قوي شيئًا سيئًا، إذ يشير الصنف MandelbrotPane مثلًا إلى الصنف MandelbrotCanvas مباشرةً ولا يُمكِننا استخدامه بدونه، ولكن بما أن الغرض من كائن الصنف MandelbrotPane هو حمل كائنٍ آخر من الصنف MandelbrotCanvas، فلا يُمثِّل هذا الترابط مشكلةً هنا. وفي المقابل، يُمكِننا استخدام الصنف MandelbrotCanvas على نحوٍ مستقل عن الصنف MandelbrotPane. يُوظِّف الصنف MandelbrotPane الأحداث لغرضٍ آخر؛ إذ تقع حاوية الصورة والحاوية الشفافة ضمن الكائن displayHolder من النوع StackPane، وعندما يُغيّر المُستخدِم حجم النافذة، سيتغير حجم الكائن displayHolder ليتناسب مع الحجم الجديد، وينبغي عندها أن يُضبَط حجم الحاوية لكي يتناسب مع حجم العرض الجديد؛ أي لا بُدّ من بدء عملية المعالجة لحساب صورةٍ جديدة. ولذلك، يُهيئ الصنف MandelbrotPane مستمعين إلى الخاصيات height و width المُعرَّفة بالكائن displayHolder؛ وذلك حتى يتمكَّن من الاستجابة للتغييرات بالحجم. ولكن، عندما يُغيّر المُستخدِم حجم النافذة ديناميكيًا، قد يتغير حجم displayHolder عدة مرات بكل ثانية. ونظرًا لأن بدء عملية حساب صورة جديدة يتطلَّب كثيرًا من الوقت، فإننا بالتأكيد لا نرغب في فعل ذلك عدة مراتٍ بالثانية الواحدة. في الواقع، سيبدأ البرنامج عملية حساب صورة جديدة فقط بعد مرور حوالي ثلث ثانية من توقُّف التغيير بحجم النافذة؛ وبالتالي إذا جرَّبت تغيير حجم نافذة البرنامج، فستلاحظ أن الحاوية لا تُغيِّر حجمها تلقائيًا بتُغيُّر حجم النافذة، ويَعرِض البرنامج نفس الصورة طالما كان الحجم هو نفسه. تُوضِح الشيفرة التالية طريقة فعل ذلك. يُهيئ البرنامج مستمعي أحداث تغيُّر الحجم displayHolder، ليستدعوا التابع startDelayedJob() على النحو التالي: displayHolder.widthProperty().addListener( e -> startDelayedJob(300,true) ); displayHolder.heightProperty().addListener( e -> startDelayedJob(300,true) ); إذ يُمثِّل المعامل الأول للتابع startDelayedJob() الزمن بوحدة الميلي ثانية، الذي لا بُدّ من انتظاره قبل إعادة ضبط حجم الحاوية وبدء عملية حساب جديدة؛ بينما يشير المعامل الثاني إلى أن ضبط حجم الحاوية ليس ضروريًا قبل بدء عملية المعالجة. يَستخدِم البرنامج كائنًا من النوع Timer المُعرَّف بحزمة java.util لكي يتمكَّن من تأجيل تنفيذ العملية؛ إذ يُمكِننا ببساطة أن نُمرِّر كائنًا من النوع TimerTask يُمثِّل مهمةً مؤجَلّةً إلى كائنٍ من النوع Timer، وذلك لكي تُنفَّذ المهمة بعد زمنٍ معين؛ كما يُمكِننا أيضًا إلغاء المهمة إذا لم يكن ذلك الزمن قد مرّ بعد، إذ يَستخدمِ البرنامج التابع startDelayedJob() لإضافة مهمة تغيير حجم الحاوية إلى المؤقت، لتُنفَّذ بعد 300 ميلي ثانية. وفي حالة استدعاء التابع startDelayedJob() مرةً أخرى قبل مرور 300 ميلي ثانية، فستُلغَى المهمة السابقة تلقائيًا وتُضاف المهمة الجديدة بدلًا منها إلى المؤقت. وبذلك، تكون مُحصلة ما سبق هو عدم تنفيذ أي مهمة إلى أن تَمرّ 300 ميلي ثانية دون أي استدعاء جديدٍ للتابع ()startDelayedJob. يتيح البرنامج خيار ضبط الصورة لتكون ثابتة الحجم؛ إذ لا ينبغي تلك الحالة أن يتغير حجم displayHolder نهائيًا. وبناءً على ذلك، قد تكون الصورة صغيرةً، ولا تتمكَّن من ملئ النافذة بالكامل، وستَظهَر عندها أجزاءٌ من الخلفية الرمادية الموجودة وراءها. في المقابل، قد تكون الصورة كبيرةً جدًا على النافذة، وفي تلك الحالة، ينبغي أن تظهر أشرطة تمرير يُمكِن اِستخدامها للمرور عبر كامل الصورة؛ ويُنفِّذ البرنامج ذلك باستخدَام الصنف ScrollPane الذي يُمثِّل حاويةً تحتوي على مكوِّن واجهة معين، ويُوفِّر أشرطة تمرير إذا اقتضت الضرورة؛ أما عندما يكون حجم الصورة ثابتًا، فسيُحذَف displayHolder من الصنف MandelbrotPane، ويُوضَع بكائن الصنف ScrollPane المُمثِّل للحاوية، ثم يُوضَع كائن الحاوية ذلك بمنتصف كائن الصنف MandelbrotPane. المزيد عن واجهات المستخدم الرسومية أخيرًا، سنذكر هنا بعض التفاصيل المُتعلّقة ببرمجة واجهات المُستخدِم الرسومية التي لم نتمكَّن من عرضها بالمقالات السابقة. ذكرنا من قبل أن ملفات الموارد تُمثِّل جزءًا من البرنامج ولكنها لا تتضمَّن أي شيفرة، وقد رأينا على سبيل المثال في مقال التعرف على بعض أصناف مكتبة جافا إف إكس JavaFX البسيطة مدى سهولة اِستخدَام الصور مثل ملفات موارد مع الصنف Image؛ ولكن يُمكِننا عمومًا استخدام أي نوعٍ من البيانات مثل ملفات موارد. يحتوي تطبيق عرض مجموعة ماندلبرو مثلًا، على قائمة "Examples"؛ وعندما يختار المُستخدِم أمرًا من تلك القائمة، تُحمَّل الإعدادات الخاصة بعرضٍ معيَّن لمجموعة ماندلبرو إلى البرنامج؛ وتكون هذه الإعدادات مُخزَّنةً في الواقع بهيئة ملفات موارد بصيغة XML. كيف يَصِل البرنامج إلى تلك الملفات؟ لسوء الحظ، ليس الأمر بنفس سهولة استخدام صورة على أنها ملف مورد لإنشاء كائنٍ من النوع Image. تُخزَّن الموارد بملفات تقع إلى جانب ملفات الأصناف المُصرَّفة الخاصة بالبرنامج؛ إذ يُحدِّد بلغة جافا كائنٌ من النوع ClassLoader -ويُعرَف باسم مُحمِّل أصناف class loader- مكان ملفات الأصناف ويُحمِّلها للبرنامج عند الحاجة. يملُك مُحمِّل الأصناف قائمةً بالمسارات التي ينبغي عليه البحث فيها عن ملفات الأصناف؛ إذ تُعرَف تلك القائمة باسم مسارات الأصناف class path، والتي تتضمَّن موضع تخزين أصناف جافا القياسية، كما تتضمَّن المجلد الحالي. إذا كان البرنامج مُخزَّنًا داخل ملف jar، فسيكون ذلك الملف أيضًا ضمن مسارات الأصناف. وبالإضافة إلى ملفات الأصناف، تستطيع كائنات الصنف ClassLoader العثور على ملفات الموارد الواقعة بمسارات الأصناف أو بمجلداتٍ فرعية داخل مسارات الأصناف. علينا أولًا أن نحصل على كائنٍ من النوع ClassLoader لكي نتمكَّن من استخدام ملفات الموارد؛ إذ يمكننا باستخدام ذلك الكائن أن نُحدِّد موضع ملف موردٍ معين. في العموم، يحتوي أي كائن على تابع النسخة getClass()، الذي يعيد كائنًا يُمثِّل الصنف الذي ينتمي إليه الكائن؛ ويحتوي الكائن المُمثِّل للصنف بدوره على التابع getClassLoader()، الذي يعيد الكائن -من النوع ClassLoader- الذي حمَّل الصنف المَعنِي. وبالتالي، يُمكِننا كتابة ما يلي بأي تابع نسخة لأي كائن لكي نحصل على مُحمِّل الأصناف الذي نريده. ClassLoader classLoader = getClass().getClassLoader(); يُمكِننا بدلًا من ذلك استخدام ClassName.class، إذ يشير ClassName إلى اسم الصنف الذي نريده، لكي نحصل على مرجع reference إلى الكائن المُمثِّل لذلك الصنف. على سبيل المثال، كان بإمكاننا استخدام Menus.class.getClassLoader() بتطبيق عرض مجموعة ماندلبرو لكي نسترجع مُحمِّل الأصناف. بمجرد حصولنا على مُحمِّل الأصناف، يُمكِننا اِستخدَامه للعثور على أي ملف مورد؛ إذ يعيد مُحمِّل الأصناف مُحدِّد الموارد الموحَّد URL الخاص بالملف، وهو ما يُمكِّننا من قراءة البيانات الموجودة بالملف. وكما هو الحال مع ملفات الصور، يحتاج مُحمِّل الأصناف إلى مسار الملف لكي يتمكَّن من إيجاده؛ إذ يتضمَّن المسار اسم الملف، بالإضافة إلى أي مجلدات ينبغي التنقُل عبرها للوصول إلى الملف. تقع ملفات الموارد المُتضمِّنة للأمثلة بالنسبة لتطبيق عرض مجموعة ماندلبرو داخل سلسلة المجلدات edu/hws/eck/mdbfx/examples، وبالتالي يكون مسار أحد تلك الملفات "settings1.mdb" هو edu/hws/eck/mdbfx/examples/settings1.mdb. يُعيد الأمر التالي مُحدِّد الموارد المُوحد الخاص بذلك الملف: URL resourceURL = classLoader.getResource("edu/hws/eck/mdbfx/examples/settings1.mdb"); والآن، بعد أن حصلنا على مُحدِّد الموارد الموحد الخاص بالملف، يُمكِننا ببساطة استخدام كائن مجرى من النوع InputStream لفتح الملف وقراءة بياناته على النحو التالي: InputStream stream = resourceURL.openStream(); وهذا بالضبط هو ما يفعله تطبيق عرض مجموعة ماندلبرو لكي يُنفِّذ قائمة "Examples". إذًا، باستخدامنا لمجرى دخل، يُمكِننا قراءة أي نوعٍ من البيانات الموجودة بملف مورد، وأن نفعل بها أي شيء نريده، ولكن تذكّر أن جافا تُوفِّر أساليبًا أفضل لتحميل بعض أنواع البيانات، مثل الصور إلى البرنامج. سنناقش الآن موضوعًا آخر عن استخدام المُسرِّعات accelerators لعناصر القائمة؛ إذ أن المُسرِّع ببساطة هو مفتاح، أو عدة مفاتيح بلوحة المفاتيح يُمكِن اِستخدَامها لاستدعاء عنصر قائمة معين بدلًا من اختيار عنصر القائمة عن طريق الفأرة. يشيع استخدام المُسرِّع "Control-S" مثلًا لحفظ ملف؛ كما يُستخدَم الصنف KeyCombination المُعرَّف بحزمة javafx.scene.input لتمثيل اتحاد مجموعةٍ من المفاتيح المُمكِن اِستخدَامها مثل مُسرِّع؛ ويُمكِننا إنشاء كائنٍ من هذا الصنف من سلسلةٍ نصية، مثل "ctrl+S". ينبغي أن تحتوي السلسلة النصية على مجموعة عناصرٍ تفصل بينها إشارة الجمع، بحيث يُمثِّل كل عنصر منها -باستثناء الأخير- مفتاح مُعدِّل، مثل "ctrl"، أو "alt"، أو "meta"، أو "shift"، أو "shortcut"؛ ويُمكِن كتابة تلك المفاتيح باستخدام حروفٍ كبيرة أو صغيرة. يُمثِّل المفتاح "shortcut" استثناءً، إذ يُكافئ المفتاح "meta" بنظام التشغيل Mac؛ بينما يُكافئ المفتاح "ctrl" بأنظمة Linux و Windows، وبالتالي نحصل على المُعدِّل المناسب لأوامر القائمة بحسب النظام المُشغَّل عليه البرنامج. في المقابل، لا بُدّ أن يكون العنصر الأخير بالسلسلة النصية المُمثِّلة لاتحاد مجموعة المفاتيح من نوع التعداد KeyCode؛ إذ يُمثِّل ذلك النوع في العموم حرفًا أبجديًا مكتوبًا بالحالة الكبيرة، ويُمثِّل مفتاح ذلك الحرف، ولكنه قد يكون أيضًا مفتاح دالة مثل "F9" (لا يَعمَل جميعها بالمناسبة). على سبيل المثال، تُمثِّل السلسلة النصية "ctrl+shift+N" الضغط باستمرار على مفتاحي "control" و "shift" مع الضغط على مفتاح "N"؛ بينما تُمثِّل السلسلة النصية "shortcut+S" الضغط باستمرار على المُعدِّل المناسب للحاسوب الذي يَعمَل عليه البرنامج مع الضغط على مفتاح "S". نستطيع تمرير تلك السلاسل النصية إلى التابع الساكن KeyCombination.valueOf() لنُنشِئ منها كائنًا من النوع KeyCombination، وذلك لنَستخدِمه لتهيئة مُسرِّعٍ لأي عنصرٍ من عناصر القائمة، إذ تضيف الشيفرة التالية مثلًا مُسرِّعًا لعنصر القائمة "Save Image" الموجود بتطبيق عرض مجموعة ماندلبرو: saveImage.setAccelerator( KeyCombination.valueOf("shortcut+shift+S") ); يُمكِننا استخدام المُسرِّعات مع أي نوع من أنواع عناصر القائمة، بما في ذلك RadioMenuItem و CheckMenuItem. وفي جميع الحالات، عندما ينقر المُستخدِم على مجموعة المفاتيح المُمثِّلة لمُسرِّع معين، ينبغي أن يكون لذلك نفس تأثير النقر بالفأرة على عنصر القائمة المقابل. يكون المُسرِّع الخاص بعنصر قائمة معين مكتوبًا عادةً إلى جانب نص عنصر القائمة؛ وبالنسبة لتطبيق عرض مجموعة ماندلبرو، تملُك جميع الأوامر الموجودة بقائمتي "File" و "Control" مُسرِّعات خاصة بها. التدويل Internationalization سنناقش خلال ما هو متبقي من هذا المقال موضوعين يُمكِن تطبيقهما على جميع البرامج وليس على برامج واجهات المُستخدِم الرسومية فقط؛ إذ لا نُطبِّقهما عادةً بالبرامج الصغيرة، ولكنهما مهمان جدًا للتطبيقات الضخمة. يُقصَد بالتدويل كتابة البرنامج كتابةً يَسهُل معها تهيئته ليَعمَل بمختلف أنحاء العالم. تُستخدَم كلمة "I18n" عادةً للإشارة إلى التدويل، إذ يمثّل "18" عدد الأحرف بين الحرف الأول "I" والحرف الأخير "n" من كلمة "Internationalization". في المقابل، يُطلَق على مهمة تهيئة البرنامج ليَعمَل بمنطقة معينة اسم التوطين localization؛ في حين يُطلَق اسم المحليات locales على تلك المناطق. تختلف المحليات عن بعضها بجوانب كثيرة، مثل نوع العملة المُستخدَمة، والصيغة المُستخدَمة لكتابة الأعداد والتواريخ، ولكن الاختلاف الأبرز والأكثر وضوحًا هو اللغة. سنناقش هنا طريقة كتابة البرامج لنتمكَّن من ترجمتها إلى لغاتٍ أخرى بسهولة. تتمحور الفكرة الأساسية في عدم كتابة السلاسل النصية التي تَظهَر للمُستخدِم ضمن الشيفرة المصدرية للبرنامج؛ لأننا لو فعلنا ذلك، فسنحتاج إلى مترجم قادر على البحث داخل الشيفرة بأكملها، ويَستبدِل كل سلسلةٍ نصية بترجمتها، وسنضطّر بعدها إلى إعادة تصريف البرنامج. في المقابل، إذا أردنا كتابة برنامج يدعم خاصية التدويل، فلا بُدّ أن نُخزِّن كل السلاسل النصية معًا ضمن ملفٍ واحد أو أكثر على نحوٍ منفصلٍ تمامًا عن ملفات الشيفرة المكتوبة بلغة جافا، وبالتالي نستطيع أن نعثر عليهم بسهولة ونترجمهم. ونظرًا لأننا لم نُعدِّل ملفات الشيفرة، فإننا لا نحتاج حتى إلى إعادة تصريف البرنامج. والآن لكي نُنفِّذ تلك الفكرة، علينا أن نُخزِّن السلاسل النصية داخل ملف خاصيات properties file واحدٍ أو أكثر؛ وهو ملفٌ بسيط يحتوي على قائمة أزواج مفتاح / قيمة، ولأن الغرض منها هنا هو الترجمة، فستُشير القيم إلى السلاسل النصية المعروضة للمُستخدِم، وهي ببساطة السلاسل النصية التي ينبغي أن نترجمها؛ أما المفاتيح فهي أيضًا سلاسلٌ نصية، ولكن لا حاجة لترجمتها، لأنها لن تَظهَر أبدًا للمُستخدِم. نظرًا لأننا لن نُعدِّل المفاتيح، يُمكِننا استخدامها ضمن الشيفرة المصدرية. وينبغي عمومًا أن يُقابِل كلُّ سلسلةٍ نصيةٍ مفتاحًا فريدًا يُعرِّف هويتها، وبناءً على ذلك، يستطيع البرنامج استخدام المفاتيح للعثور على ما يقابلها من سلاسل نصية من ملف الخاصيات؛ أي يحتاج البرنامج إلى معرفة المفاتيح فقط، بينما يرى المُستخدِم القيم المقابلة لتلك المفاتيح. وعند ترجمة ملف الخاصيات، سيتمكَّن المُستخدِم من رؤية قيمٍ مختلفة لنفس المفاتيح. تُكتَب أزواج مفتاح / قيمة بملفات الخاصيات على النحو التالي: key.string=value string لا ينبغي أن تحتوي السلسلة النصية المُمثِّلة للمفتاح قبل إشارة التساوي على أيّ فراغات، إذ تُستخدَم عادةً النقاط للفصل بين الكلمات المُكوِّنة لها. في المقابل، بإمكان السلسلة النصية المُمثِّلة للقيمة أن تحتوي على مسافات أو أيّ محارف أخرى. إذا انتهى السطر بمحرف "\"، ستستمر القيمة إلى السطر التالي، وتُهمَل الفراغات الموجودة ببداية ذلك السطر في تلك الحالة. لسوء الحظ، يمكن أن يحتوي ملف الخاصيات على محارف من مجموعة محارف ASCII فقط؛ إذ تدعم تلك المجموعة الأحرف الأبجدية الإنجليزية فقط. ومع ذلك، بإمكان القيمة أن تتضمَّن محارف UNICODE عشوائية، مما يَعنِي ضرورة تشفير المحارف من خارج مجموعة محارف ASCII. يتضمَّن JDK البرنامج native2ascii، الذي يَستطيع تحويل الملفات التي تَستخدِم محارف من خارج مجموعة محارف ASCII إلى ملف خاصيات بصيغةٍ مناسبة. لنفترض أننا نريد عرض سلسلة نصية للمُستخدِم، مثل اسم أمر ضمن قائمة ببرنامج معين. عندها، سيحتوي إذًا ملف الخاصيات على زوج مفتاح/قيمة على النحو التالي: menu.saveimage=Save PNG Image... إذ أن "Save PNG Image…" هي السلسلة النصية التي ينبغي أن تَظهَر بالقائمة. والآن، يستطيع البرنامج استخدام المفتاح "menu.saveimage" للعثور على قيمة المفتاح، ومن ثُمَّ يَستخدِمها مثل نص عنصر القائمة. تُجرَى عملية العثور تلك بواسطة الصنف ResourceBundle، إذ يمكنه استرجاع ملفات الخاصيات واستخدامها. قد تحتوي السلسلة النصية المعروضة للمُستخدِم -في بعض الأحيان- على سلاسل نصية فرعية لا يُمكِن تحديدها قبل تشغيل البرنامج، مثل اسم ملف معين؛ فقد يرغب البرنامج بإبلاغ المُستخدِم مثلًابالرسالة التالية "Sorry, the file, filename, cannot be loaded"، علمًا أن filename هو اسم ملفٍ اختاره المُستخدِم أثناء تشغيل البرنامج. لمعالجة تلك الحالة، بإمكان القيم -بملفات الخاصيات- أن تتضمَّن عنصرًا زائفًا placeholder؛ إذ يُستبدَل ذلك العنصر بسلسلةٍ نصيةٍ يُحدِّدها البرنامج بعد تشغيله، ويُكْتَب ذلك العنصر الزائف على النحو التالي "{0}"، أو "{1}"، أو "{2}". بالنسبة لمثال خطأ الملف، قد يحتوي ملف الخاصيات على القيمة التالية: error.cantLoad=Sorry, the file, {0}, cannot be loaded يسترجِع البرنامج القيمة المقابلة للمفتاح error.cantLoad، ثم يستبدل اسم الملف الفعلي بالعنصر الزائف "{0}". قد يختلف ترتيب الكلمات عند ترجمة السلسلة النصية، ولكن نظرًا لأننا نَستخدِم عنصرًا زائفًا لتمثيل اسم الملف، سيتمكَّن المترجم من وضع اسم الملف بالمكان النحوي الصحيح بالنسبة للغة المُستخدَمة. في الواقع، لا يُعالِج الصنف ResourceBundle عملية الاستبدال تلك، وإنما يتولَّى الصنف MessageFormat تلك المهمة. يَستخدِم تطبيق عرض مجموعة ماندلبرو ملف خاصيات، اسمه strings.properties؛ إذ لا بُدّ أن ينتهي اسم أي ملف خاصيات بكلمة ".properties". يقرأ البرنامج أي سلسلة نصية تراها عند تشغيل التطبيق من ذلك الملف. بَرمَج الكاتب الصنف I18n.java لقراءة قيم المفاتيح، ويحتوي ذلك الصنف على التابع الساكن static التالي: public static tr( String key, Object... args ) يُعالِج التابع السابق العملية بالكامل؛ إذ يَستقبِل التابع المعامل key الذي يُمثِّل المفتاح الذي ينبغي أن يبحث عنه التابع ضمن ملف الخاصيات strings.properties؛ بينما تُمثِل المعاملات الإضافية القيم التي ينبغي أن تحلّ محل العناصر المزيفة -إن وجدت- بالقيمة المقابلة للمفتاح. تذكّر أن التصريح عن المعامل باستخدام "Object…"، وهذا يَعنِي احتمالية تمرير أيّ عددٍ من المعاملات الفعلية بعد المعامل key. ألقِ نظرةً على مقال تعرف على المصفوفات (Arrays) في جافا. تشمل الاستخدامات النموذجية ما يلي: String saveImageCommandText = I18n.tr( "menu.saveimage" ); String errMess = I18n.tr( "error.cantLoad" , selectedFile.getName() ); في الواقع، سترى استدعاءاتٍ كثيرةً مشابهة ضمن شيفرة تطبيق عرض مجموعة ماندلبرو؛ إذ كُتِبَ الصنف I18n بطريقة عامة لتتمكَّن من استخدامه بأي برنامجٍ آخر، وكل ما تحتاج إليه هو توفير ملف خاصيات على أنه ملف مورد، وأن تُخصِّص اسمه بالملف I18n.java، وأخيرًا أن تضع الصنف ضمن الحزمة الخاصة بك. يُمكِننا أيضًا استخدام أكثر من ملف خاصيات ضمن نفس البرنامج. فعلى سبيل المثال، قد نُضمِّن نسخةً فرنسية وأخرى يابانية من ملف الخاصيات إلى جانب النسخة الإنجليزية، فإذا كان اسم الملف بالنسخة الإنجليزية هو strings.properties، فينبغي أن تكون أسماء الملفات بالنسختين الفرنسية واليابانية strings_fr.properties و strings_ja.properties؛ إذ تملُك كل لغة ترميزًا مكوَّنًا من حرفين، مثل "fr" و "ja"، ويُستخدَم هذا الترميز باسم ملف الخاصيات الخاص بتلك اللغة. بدايةً، يَستخدِم البرنامج الاسم البسيط لملف الخاصيات "strings"؛ فإذا كان البرنامج مُشغَّلًا بنظام جافا وكانت اللغة المُفضلة هي الفرنسية، فسيحاول البرنامج أن يُحمِّل ملف خاصيات باسم "strings_fr.properties"؛ وإذا فشل، فسيحاول أن يُحمِّل ملف خاصيات باسم "strings.properties". يَعنِي ذلك، أن البرنامج سيستَخدِم ملف الخاصيات الخاص باللغة الفرنسية في الموضع الفرنسي، وسيستخدِم ملف الخاصيات الخاص باللغة اليابانية في موضع اللغة اليابانية، وأخيرًا، سيَستخدِم ملف الخاصيات الافتراضي في الحالات الأخرى. الإعدادات المفضلة تَسمَح غالبية البرامج للمُستخدِم بضبط إعداداته المفضلة؛ إذ تُمثِّل تلك الإعدادات جزءًا من حالة البرنامج، التي ينبغي أن يتذكرها عند تشغيله مرةً أخرى، وليتمكَّن من ذلك، عليه أن يُخزِّنها بملفٍ ضمن المجلد الرئيسي للمُستخدِم، كما أن عليه أن يتمكَّن من تحديد موقعها بعد ذلك. ينبغي إذًا تسمية الملف بطريقة تُجنِّبنا أيّ تعارضٍ مع أسماء الملفات المُستخدَمة بواسطة البرامج الأخرى. هناك مشكلةٌ أخرى، وهي أننا بتلك الطريقة سنملأ المجلد الرئيسي للمُستخدِم بملفاتٍ لا ينبغي أن يُعرَف بوجودها أساسًا. تتعامل جافا مع تلك المشاكل بتوفير طريقةٍ قياسية لمعالجة الإعدادات المفضلة؛ إذ تُعرِّف جافا الصنف Preferences ضمن حزمة java.util.prefs، وهذا الصنف هو كلُّ ما تحتاج إليه. يحتوي ملف تطبيق عرض مجموعة ماندلبرو Main.java على مثالٍ لاستخدام الصنف Preferences. يضبُط المُستخدِم عادةً إعداداته المفضلة بمعظم البرامج عبر صندوق نافذة مُخصَّص، ولكن لا يحتوي تطبيق ماندلبرو على إعدادات يتناسب معها هذا النوع من المعالجة. وبدلًا من ذلك، يُخزِّن البرنامج بعضًا من جوانب حالة البرنامج أوتوماتيكيًا على أنها إعداداتٌ مفضلة؛ وبكل مرة يُشغِّل المُستخدِم بها البرنامج، فإنه يقرأ تلك الإعدادات إن وُجدت؛ وبكل مرة يُغلِق المُستخدِم بها البرنامج، فإنه يُخزِّن تلك الإعدادات. تؤدي الحاجة إلى حفظ الإعدادات المفضلة إلى مشكلةٍ شيقةٍ نوعًا ما. ينتهي البرنامج بمجرد غلق النافذة، ولكننا نحتاج إلى طريقةٍ لحفظ الإعدادات عند حدوث ذلك، ويَكْمُن الحل عادةً في استخدام الأحداث: يُسجِّل التابع start() المُعرَّف بالصنف Main مستمعًا إلى حدث غلق النافذة، وعند وقوع ذلك الحدث، يُعالِجه معالج الحدث بحفظ الإعدادات المفضلة. تُخزَّن الإعدادات المفضلة ببرامج جافا بصيغةٍ تعتمد على المنصة المُستخدَمة، وبمكانٍ يعتمد أيضًا على تلك المنصة، ولكن بصفتك مبرمج جافا، فلا حاجة للقلق بشأن ذلك، إذ يعرف نظام التفضيلات بجافا تمامًا مكان تخزين البيانات، ولكن ما يزال هناك مشكلة فصل الإعدادات المفضلة لبرنامج معين عن إعدادات بقية برامج جافا التي قد تكون مُشغَّلة على نفس الحاسوب. تحلّ جافا تلك المشكلة بنفس الطريقة التي حلّت بها مشكلة تسمية الحزم؛ إذ تُعرَّف ببساطة الإعدادات المفضلة لبرنامج معين من خلال اسم الحزمة الخاصة بالبرنامج مع اختلافٍ بسيط بالترميز. على سبيل المثال، تطبيق عرض مجموعة ماندلبرو مُعرَّف بحزمة edu.hws.eck.mdbfx، وإعداداته مُعرَّفة عبر السلسلة النصية "/edu/hws/eck/mdbfx". حلَّ المحرف "/" محلَّ النقاط مع إضافة "/" إلى البداية. تُخزَّن الإعدادات المفضلة لأي برنامج داخل عقدة node، ويُمكِن استرجاع العقدة المقابلة للسلسلة النصية المُعرِّفة لإعدادات برنامج معين على النحو التالي: Preferences root = Preferences.userRoot(); Preferences node = root.node(pathName); يشير المعامل pathname إلى السلسلة النصية المُعرِّفة للعقدة، مثل "/edu/hws/eck/mdbfx". تتكوَّن العقدة نفسها من قائمةٍ بسيطة من أزواج مفتاح / قيمة، ويتكوَّن كلٌ من المفتاح والقيمة من سلاسل نصية. في الحقيقة، يستطيع البرنامج أن يُخزِّن أي سلاسل نصية ضمن تلك العقد، فهي تُمثِّل فقط طريقةً للاحتفاظ بالبيانات من عملية تشغيل برنامج لأخرى، ولكن يُحدِّد المفتاح عمومًأ هوية عنصرٍ معيّنٍ من الإعدادات المفضلة، وتكون قيمته المقابلة هي القيمة المفضلة. إذا كان prefnode كائنًا من النوع Preferences، فإنه يحتوي على التابع prefnode.get(key) لاسترجاع القيمة المرتبطة بمفتاح معين، والتابع prefnode.put(key,value) لضبط القيمة الخاصة بمفتاح معين. يَستخدِم البرنامج "Main.java" الإعدادات المفضلة لتخزين شكل نافذة البرنامج وموضعها، إذ يَسمَح ذلك بالإبقاء على حجم النافذة وشكلها بين عمليات التشغيل المتتالية لنفس البرنامج. وعندما يُشغِّل المُستخدِم البرنامج، سيجد النافذة بنفس المكان الذي تركها فيه آخر مرة شغَّل خلالها البرنامج. يُخزِّن البرنامج أيضًا اسم المجلد الذي كان يحتوي على آخر ملف فتحه المُستخدِم أو حفظه، وهذا في الواقع أمر مهم؛ لأن السلوك الافتراضي لصندوق نافذة فتح ملف هو عرض مجلد العمل الحالي، وهو نادرًا ما سيكون المكان الذي يرغب المُستخدِم بحفظ ملفات البرنامج به. بفضل خاصية الإعدادات المفضلة، سيتمكَّن المُستخدِم من الانتقال إلى المجلد الصحيح عند أول تعاملٍ له مع البرنامج، وبعد ذلك سيجد المُستخدِم نفسه بالمجلد الصحيح أوتوماتيكيًا عند استخدامه للبرنامج مرةً أخرى. تستطيع الإطلاع على الشيفرة المصدرية بالملف Main.java لمزيدٍ من التفاصيل. يُمكِننا بالتأكيد قول المزيد عن لغة جافا وعن البرمجة عمومًأ، ولكن هذه السلسلة لا تمثِّل سوى مقدمةً إلى البرمجة باستخدام لغة جافا، وقد حان الوقت لانتهاء رحلتنا. التي نتمنى أنها كانت رحلةً مُبهِجةً لك وأنك قد استفدت منها ببناء أساسٍ قوي تستطيع استخدامه قاعدةً لاكتشافاتك التالية. ترجمة -بتصرّف- للقسم Section 5: Finishing Touches من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: النوافذ وصناديق النافذة في جافا الخاصيات والارتباطات في جافا كيفية كتابة برامج صحيحة باستخدام لغة جافا مقدمة إلى صحة البرامج ومتانتها في جافا
-
تتكوَّن جميع برامج واجهات المُستخدِم الرسومية GUI التي تعرَّضنا إليها حتى الآن من نافذةٍ واحدة، ولكن غالبية البرامج تتكوَّن في الواقع من عدة نوافذ. ولذلك سنناقش في هذا المقال طريقة إدارة التطبيقات متعددة النوافذ؛ كما سنتناول صناديق النافذة dialog boxes، وهي نوافذٌ صغيرة تظهر وتختفي، وتُستخدَم عادةًً للحصول على مُدْخَلٍ من المُستخدِم. بالإضافة إلى ذلك، سنتعرَّض للصنف WebView، وهو أداة تحكُّم بمكتبة JavaFX، وتدعم كثيرًا وظائف متصفح الإنترنت. صناديق النافذة Dialog Boxes أيّ صندوق نافذة هو ببساطة نافذة تعتمد على نافذةٍ أخرى تَعمَل بمثابة أبٍ أو مالكٍ لصندوق النافذة؛ ويَعنِي ذلك، أنه لو أغلقنا النافذة المالكة، فسيُغلَق صندوق النافذة أوتوماتيكيًا. قد يكون صندوق النافذة شرطيًا modal أو غير شرطي modeless؛ فعند فتح صندوق نافذة شرطي، ستُعطَّل النافذة المالكة له، ولا يستطيع المُستخدِم التفاعل معها حتى يُغلِق صندوق النافذة. هناك أيضًا صناديق نافذة شرطية على مستوى التطبيق application modal؛ أي أنها تُعطِّل التطبيق بالكامل وليس فقط النافذة المالكة لها، وتُعدّ غالبية صناديق النافذة بمكتبة JavaFX من هذا النوع. تظهر صناديق النافذة الشرطية عادةً أثناء تنفيذ البرنامج، وذلك لكي تطلُب من المُستخدِم مُدْخَلًا معينًا، أو فقط لعرض رسالةٍ للمُستخدِم أحيانًا. في المقابل، لا تُعطِّل صناديق النافذة غير الشرطية تفاعل المُستخدِم مع نوافذها المالكة، ولكنها ما تزال تُغلَق أوتوماتيكيًا عند غلق النافذة المالكة. يُستخدَم هذا النوع عادةً لإظهار عرضٍ view مختلفٍ للبيانات الموجودة بالنافذة المالكة، أو لعرض أدوات تحكُّم إضافية تؤثر على النافذة المالكة. يُمكِننا ضبط مرحلةٍ من الصنف Stage لتَعمَل مثل صندوق نافذة، ولكن غالبية صناديق النافذة ببرامج JavaFX هي كائناتٌ مُنتمية إلى الصنف Dialog المُعرَّف بحزمة javafx.scene.control، أو أي من أصنافه الفرعية subclasses. فإذا كان dlg كائن صندوق نافذة من النوع Dialog، فسيتضمَّن توابع النسخ instance methods التالية لعرض صندوق النافذة: dlg.show() و dlg.showAndWait(). إذا استخدمنا التابع dlg.showAndWait() لعرض الصندوق، فسيكون صندوق النافذة شرطيًا، حتى على مستوى التطبيق. لا يعود التابع showAndWait() إلى أن يُغلَق صندوق النافذة، وتكون بالتالي قيمة مُدْخَل المُستخدِم متاحةً بعد استدعاء showAndWait() مباشرةً. في المقابل، إذا استخدمنا التابع dlg.show() لعرض الصندوق، فسيكون غير شرطي، إذ يعود التابع show() فورًا، ويستطيع المُستخدِم بالتالي التعامل مع النافذة والصندوق، والتبديل بينهما. وفي الواقع، يتشابه استخدام صناديق النافذة غير الشرطية مع البرمجة على التوازي parallel programming نوعًا ما؛ فهناك شيئان قيد التنفيذ بنفس الوقت. سنُركّز هنا على صناديق النافذة الشرطية فقط. يُعدّ الصنف Dialog<T> نوعًا ذا معاملاتٍ غير مُحدّدة النوع parameterized، إذ يُمثِّل معامل النوع type parameter نوع القيمة التي سيعيدها التابع showAndWait()، والتي تكون تحديدًا من النوع Optional<T>؛ ويَعنِي ذلك أنها قيمةٌ من النوع T، ولكنها قد تكون موجودةً أو غير موجودة. يتضمَّن الصنف Optional -المُعرَّف بحزمة java.util- التابع isPresent()، والذي يُعيد قيمةً من النوع boolean، التي تشير إلى ما إذا كانت القيمة موجودةً أم لا، كما يتضمَّن التابع get() الذي يعيد تلك القيمة إذا كانت موجودة. إذا لم تكن القيمة موجودةً، فسيؤدي استدعاء التابع get() إلى حدوث استثناءٍ exception؛ ويَعنِي ذلك أنه إذا أردنا استخدام القيمة المعادة من التابع showAndWait()، فعلينا أولًا استدعاء التابع isPresent() لنتأكّد من أن التابع قد أعاد قيمةً فعلًا. يحتوي أي صندوق نافذة عادةً على زرٍ واحدٍ أو أكثر لغلق الصندوق على الأقل، وتكون أسماء تلك الأزرار غالبًا هي: "OK" أو "Cancel" أو "Yes" أو "No". كما يُستخدَم نوع التعداد ButtonType لتمثيل الأزرار الأكثر شيوعًا، ويتضمَّن القيم ButtonType.OK و ButtonType.CANCEL و ButtonType.YES و ButtonType.NO؛ إذ يُعدّ النوع ButtonType القيمة المعادة الأكثر شيوعًا من صناديق النافذة المُمثَلة بالصنف Dialog، وذلك للإشارة إلى الزر الذي نقر عليه المُستخدِم لغلق الصندوق، ويكون صندوق النافذة من النوع Dialog<ButtonType> في تلك الحالة. يُعدّ Alert صنفًا فرعيًا من الصنف Dialog<ButtonType>، إذ يُسهِّل إنشاء صناديق النافذة التقليدية التي تَعرِض رسالةً نصيةً للمُستخدِم مصحوبةً بزرٍ واحدٍ أو اثنين، وتَعمَل بمثابة تنبيهٍ للمُستخدِم. كنا قد استخدمنا هذا الصنف فعليًا في مقال مدخل إلى التعامل مع الملفات في جافا لكي نَعرِض بعض رسائل الخطأ للمُستخدِم، ولكن دون أن نتعرَّض لطريقة عمله. يُمكِننا إنشاء كائنٍ من النوع Alert على النحو التالي: Alert alert = new Alert( alertType, message ); ينتمي المعامل الأول إلى نوع التعداد Alert.AlertType الذي يُمكِن لقيمته أن تكون واحدةً مما يلي: Alert.AlertType.INFORMATION. Alert.AlertType.WARNING. Alert.AlertType.ERROR. Alert.AlertType.CONFIRMATION. وعند تخصيص أي من القيم الثلاثة الأولى، سيحتوي الصندوق المعروض على زر "OK" وحيد ولا يفعل أكثر من مجرد عرض رسالةٍ نصيةٍ للمُستخدِم، وفي تلك الحالة، لا حاجة لفحص أو استرجاع القيمة المعادة من التابع alert.showAndWait(). عند تمرير القيمة الأخيرة، سيتضمَّن الصندوق زر "OK" و زر "Cancel"، ويُستخدَم عادةً لسؤال المُستخدِم عما إذا كان يريد الاستمرار بتنفيذ عملية يُحتمَل أن تكون خطيرةً، مثل حذف ملف؛ إذ يكون من الضروري فحص القيمة المعادة من التابع في تلك الحالة، وهذا هو ما تفعله الشيفرة التالية: Alert confirm = new Alert( Alert.AlertType.CONFIRMATION, "Do you really want to delete " + file.getName() ); Optional<ButtonType> response = confirm.showAndWait(); if ( response.isPresent() && response.get() == ButtonType.OK ) { file.delete(); } إضافةً إلى الأزرار، قد يحتوي صندوق النافذة على مساحةٍ للمحتوى وعنوانٍ رئيسي يَظهَر أعلى تلك المساحة، ورسمةٍ تَظهَر إلى جانب العنوان الرئيسي إن وجد، أو إلى جانب المحتوى؛ وبالتأكيد عنوان يَظهَر بشريط عنوان صندوق النافذة. تكون الرسمة عادةً أيقونةً صغيرةً؛ فبالنسبة لصناديق النافذة من النوع Alert، تُوضَع الرسالة بمساحة المحتوى، وتُضبَط الخاصيات الأخرى أوتوماتيكيًا بما يتناسب مع نوع التنبيه المُستخدَم، ويُمكِن مع ذلك تعديلها باستدعاء التوابع التالية المُعرَّفة بالصنف Dialog قبل عرض التنبيه: alert.setTitle( windowTitle ); alert.setGraphic( node ); alert.setHeaderText( headerText ); يُمكِن لأي من تلك القيم المُمرَّرة أن تكون قيمةً فارغةً null، كما يُمكِننا ضبط المحتوى إلى أي عقدة مبيان مشهد عشوائية لتحلّ محل الرسالة النصية، وذلك باستدعاء التابع التالي: alert.getDialogPane().setContent( node ); ولكننا نُطبِّق ذلك عادةً على صندوق نافذة عادي من النوع Dialog، لا على تنبيهٍ من النوع Alert. تُوضِّح تنبيهات التأكيد بالصورة التالية المكونات المختلفة الموجودة بأي صندوق نافذة، إذ يُمكِّننا ملاحِظة أن العنوان الرئيسي الخاص بالصندوق الموجود على يمين الصورة فارغ؛ وإذا أردت عَرض نصٍ متعدد الأسطر ضمن تنبيه، فلا بُدّ من إضافة محرف السطر الجديد ("\n") إلى النص: تُوفِّرمكتبة JavaFX الصنف الفرعي TextInputDialog المُشتَق من الصنف Dialog<String> لتمثيل صناديق النافذة التي تقرأ مُدْخَلًا من المُستخدِم. ويعيد التابع showAndWait() في تلك الحالة قيمةً من النوع Optional<String>، كما يحتوي صندوق النافذة المُستخدِم لذلك الصنف على حقلٍ نصي من النوع TextField، مما يُمكِّن المُستخدِم من إدخال سطر نصي؛ كذلك، يحتوي على زر "OK" و زر "Cancel". يَستقبِل الباني معاملًا من النوع String، والذي يُمثِّل المحتوى المبدئي للحقل النصي؛ فإذا أردنا أن نطرح سؤالًا على المُستخدِم أو أن نَعرِض رسالةً معينةً عليه، فيُمكِننا وضعها بالعنوان الرئيسي للصندوق. يعيد صندوق النافذة محتويات الحقل النصي إن وُجدت، والتي قد تكون مجرد سلسلةٍ نصيةٍ فارغة، وإذا نقر المُستخدِم على زر "Cancel" أو أغلق صندوق النافذة ببساطة، فستكون القيمة المعادة غير موجودة. ألقِ نظرةً على الشيفرة التالية: TextInputDialog getNameDialog = new TextInputBox("Fred"); getNameDialog.setHeaderText("Please enter your name."); Optional<String> response = getNameDialog.showAndWait(); if (response.isPresent() && response.get().trim().length() > 0) { name = response.get().trim(); } else { Alert error = new Alert( Alert.AlertType.ERROR, "Anonymous users are not allowed!" ); error.showAndWait(); System.exit(1): } إلى جانب الصنفين Alert و TextInputDialog، يُعرِّف الصنف SimpleDialogs.java -كتبه المؤلف- التوابع الساكنة static التالية التي يُمكِن استخدامها لعرض أكثر أنواع صناديق النافذة شيوعًا: SimpleDialogs.message(text): يعرِض صندوق نافذة يحتوي على رسالة نصية وزر"OK"، ولا يًعيد أي قيمة. لاحِظ أنك لا تحتاج إلى كتابة محرف السطر الجديد ضمن الرسالة إذا أردتها أن تكون متعددة الأسطر؛ لأن ذلك يحدث تلقائيًا مع الرسائل الطويلة. يَستقبِل التابع أيضًا معاملًا ثانيًا اختياريًا لتخصيص عنوان صندوق النافذة. SimpleDialogs.prompt(text): يعرِض صندوق نافذة يحتوي على رسالة نصية وحقل إدخال نصي مع زر "OK" و زر "Cancel"، ويُعيد قيمةً من النوع String تُمثِّل محتويات الحقل النصي إذا كان المُستخدِم قد نقر على "OK"؛ أو على القيمة الفارغة null إذا كان المُستخدِم قد أغلق الصندوق. يَستقبِل التابع أيضًا معاملين اختياريين آخرين لتخصيص عنوان صندوق النافذة، والمحتوى المبدئي للحقل النصي على الترتيب. SimpleDialogs.confirm(text): يَعرِض صندوق نافذة يحتوي على رسالة نصية مع زر "Yes" و زر "No" و زر "Cancel"، ويُعيد قيمةً من النوع String، والتي لا بُدّ أن تكون واحدةً من القيم التالية "yes" أو "no" أو "cancel". يَستقبِل التابع معاملًا ثانيًا اختياريًا لتخصيص عنوان صندوق النافذة مثل التابعين السابقين. يحتوي الصنف SimpleDialogs على بعض الخيارات الأخرى، مثل صندوق نافذة بسيط لاختيار لون، والتي يُمكِنك الإطلاع عليها بقراءة شيفرة الملف SimpleDialogs.java؛ كما يَسمَح أيضًا البرنامج TestDialogs.java للمُستخدِم بتجربة صناديق النافذة المختلفة المُعرَّفة بالصنف SimpleDialogs. الصنفان WebView و WebEngine سنناقش ببقية هذا المقال برنامج متصفح إنترنت مُتعدّد النوافذ، إذ تبدو كتابة متصفح إنترنت عمليةً معقدة، وهي كذلك فعلًا، ولكن مكتبة JavaFX تُسهِّل ذلك كثيرًا بتنفيذ غالبية العمل المطلوب ضمن مجموعةٍ من الأصناف القياسية؛ إذ يُمثِّل الصنف WebView المُعرَّف بحزمة javafx.scene.control أداة تحكُّم يُمكِنها تحميل صفحة إنترنت وعرضها، كما تستطيع تلك الأداة معالجة معظم صفحات الإنترنت جيدًا بما في ذلك تنفيذ شيفرة JavaScript، إذ تُستخدَم لغة البرمجة جافاسكربت JavaScript لبرمجة صفحات إنترنت ديناميكية، ولا علاقة لها بلغة Java). إضافةً لما سبق، يُمثِّل الصنف WebView العرض view ضمن نمط نموذج-عرض-مُتحكَّم Model-View-Controller الذي ناقشناه بمقال أمثلة عن رسوميات فاخرة باستعمال جافا؛ إذ يُنفَّذ غالبية العمل المطلوب لتحميل صفحات الإنترنت وإدارتها من خلال كائنٍ من النوع WebEngine، والذي يُمثِّل جزءًا من المُتحكِّم controller؛ أما النموذج، فهو هيكل بياني data structure يتضمَّن محتويات صفحة الإنترنت. ويُنشِئ الصنف WebEngine النموذج عند تحميل الصفحة، ثم يَعرِض الصنف WebView محتوياتها. يجب أن نَعرِض كائن الصنف WebView ضمن نافذة؛ إذ يُمثِّل الصنف الفرعي BrowserWindow.java -المُشتَق من صنف النافذة القياسي Stage- نافذة متصفح إنترنت كاملة، وهو يحتوي على كائنٍ ينتمي إلى الصنف WebView، بالإضافة إلى شريط قوائم وبعض أدوات التحكُّم الأخرى، مثل صندوق إدخال نصي يُمكِّن المُستخدِم من كتابة محدّد موارد مُوحد URL لصفحة إنترنت معينة، وزر "Load" ينقر عليه المُستخدِم لتحميل صفحة الإنترنت من محدّد الموارد الموحد إلى كائن الصنف WebView. بالإضافة إلى ذلك، يُمكِن لباني الصنف BrowserWindow أن يَستقبِل معاملًا إضافيًا لتخصيص محدِّد موارد موحد مبدئي يُحمَّل تلقائيًا عند فتح النافذة. يتضمَّن كل كائن من النوع WebView كائنًا آخرًا من النوع WebEngine، والذي يُمكِننا استرجاعه باستدعاء التابع webEngine = webview.getEngine(). يمكننا الآن تحميل load صفحة إنترنت باستدعاء ما يلي: webEngine.load( urlString ); إذ أن urlString سلسلةٌ نصيةٌ تُمثِّل محدِّد موارد موحد -ألقِ نظرةً على مقال تواصل تطبيقات جافا عبر الشبكة-. ويجب أن تبدأ تلك السلسلة ببروتوكول، مثل "http:" أو "https:"، ولهذا يضيف البرنامج كلمة "http://" إلى مقدمة السلسلة النصية المُتضمِّنة لمُحدّد الموارد الموحد، إذا لم تكن تحتوي على بروتوكول فعليًا. إضافةً لما سبق، يُحمِّل البرنامج صفحة إنترنت جديدة أوتوماتيكيًا، وذلك إذا نقر المُستخدِم على رابط ضمن الصفحة المعروضة حاليًا. تُحمَّل صفحات الإنترنت بصورةٍ غير متزامنة asynchronous؛ أي أن التابع webEngine.load() يعود فورًا، في حين تُحمَّل صفحة الإنترنت ضمن خيط thread مُشغَّلٍ بالخلفية. وعند اكتمال عملية التحميل، تُعرَض صفحة الإنترنت داخل كائن الصنف WebView؛ أما في حالة فشل التحميل لسببٍ ما، فلا يحدث أي تنبيه تلقائي، ولكن ما يزال بإمكاننا الحصول على بعض المعلومات بإضافة مستمعي أحداث إلى الخاصيتين location و title -من النوع String- القابلتين للمراقبة observable، والمُعرَّفتين بالصنف WebEngine؛ إذ تُمثِّل الخاصية location محدد الموارد الموحد لصفحة الإنترنت المعروضة حاليًا، أو التي يُجرَى تحميلها؛ بينما تُمثِّل الخاصية title عنوان صفحة الإنترنت الحالية، والتي تَظهَر بشريط عنوان النافذة التي تَعرِض صفحة الإنترنت. على سبيل المثال، يستمع الصنف BrowserWindow إلى الخاصية title ويَضبُط عنوان النافذة ليتوافق مع محتوياتها كما يلي: webEngine.titleProperty().addListener( (o,oldVal,newVal) -> { if (newVal == null) setTitle("Untitled " + owner.getNextUntitledCount()); else setTitle(newVal); }); يستمع البرنامج إلى الخاصية location أيضًا، ويَعرِض قيمتها داخل عنوان من النوع Label أسفل النافذة؛ في حين سنناقش owner لاحقًا بالأسفل. يُمكِننا أيضًا أن نضيف مستمعًا إلى خاصية webEngine.getLoadWorker().stateProperty() لمراقبة تقدّم تحميل الصفحة. ألقِ نظرةً على شيفرة الصنف BrowserWindow.java لترى مثالًا على ذلك. ذكرنا بالأعلى أن كائن الصنف WebView (مع كائن الصنف WebEngine الخاص به) قادرٌ على تشغيل شيفرة JavaScript المُضمَّنة بصفحات الإنترنت؛ ولكن هذا ليس دقيقًا تمامًا، إذ تحتوي JavaScript على بعض البرامج الفرعية subroutines المسؤولة عن إظهار صناديق نوافذ بسيطة؛ فعلى سبيل المثال، يَعرِض صندوق النافذة من النوع "alert" رسالةً نصيةً للمُستخدِم؛ بينما يطرح صندوق النافذة من النوع "prompt" سؤالًا على المُستخدِم ويتلقى ردَّه النصي عليها؛ أما صندوق النافذة من النوع "confirm"، فيَعرِض رسالةً للمُستخدِم مع زر "OK" و زر "Cancel"، ويتلقى قيمةً مُعادةً من النوع boolean تشير إلى ما إذا كان المُستخدِم قد أغلق الصندوق بالنقر على زر "OK". في الواقع، يتجاهل الصنف WebEngine طلبات JavaScript لعرض تلك الصناديق افتراضيًا، ولكن يُمكِننا إضافة مستمعي أحداث للاستجابة إلى تلك الطلبات، إذ يَستخدِم الصنف BrowserWindow صناديق نافذة من تلك المُعرَّفة بالصنف SimpleDialogs للرد على تلك الأحداث؛ فعندما تحاول JavaScript أن تَعرِض صندوق نافذة تنبيهي مثلًا، فسيُولِّد كائن الصنف WebEngine حدثًا من النوع AlertEvent، يتضمَّن الرسالة التي تريد JavaScript أن تَعرِضها، وسيستجيب الصنف BrowserWindow باستدعاء SimpleDialogs.message() لكي يَعرِض الرسالة للمُستخدِم. ألقِ نظرةً على الشيفرة التالية: webEngine.setOnAlert( evt -> SimpleDialogs.message(evt.getData(), "Alert from web page") ); تختلف معالجة صناديق النافذة من النوعين prompt و confirm بعض الشيء؛ لكونهما يعيدان قيمةً، وتبين الشيفرة التالية الطريقة التي اتبعها البرنامج لمعالجتهما: webEngine.setPromptHandler( promptData -> SimpleDialogs.prompt( promptData.getMessage(), "Query from web page", promptData.getDefaultValue() ) ); webEngine.setConfirmHandler( str -> SimpleDialogs.confirm(str, "Confirmation Needed").equals("yes") ); لم نناقش بعد شريط القوائم الذي يدعمه الصنف BrowserWindow؛ إذ يحتوي ذلك الشريط على قائمةٍ واحدةٍ اسمها "Window"، وتحتوي بدورها على مجموعةٍ من الأوامر لأغراضٍ معينة، مثل فتح نافذة متصفح جديدة أو غلْق النافذة الحالية، كما تحتوي على قائمةٍ مكوّنة من نوافذ المتصفح المفتوحة حاليًا، ويستطيع المُستخدِم أن يختار أيًا منها ليُحضره إلى مقدمة الشاشة؛ ولكي تفهم طريقة تنفيذ ذلك، عليك أولًا أن تفهم طريقة استخدام الصنف BrowserWindow ضمن برنامجٍ متُعدّد النوافذ. إدارة عدة نوافذ لا يُعدّ الصنف BrowserWindow تطبيقًا، أي لا يُمكِن تشغيله على أنه برنامجٌ بحد ذاته، وإنما يُمثِّل نافذةً واحدةً ضمن برنامجٍ متعدد النوافذ. ستجِد النسخة القابلة للتشغيل من هذا الصنف بالملف WebBrowser.java. يمتد الصنف WebBrowser من الصنف Application مثل أي برنامجٍ مُصمَّم بمكتبة JavaFX، كما يعتمد على الصنفين BrowserWindow.java و SimpleDialogs.java؛ ولذلك ستحتاج إلى الأصناف الثلاثة لكي تتمكَّن من تشغيل البرنامج. يحتوي أي صنفٍ ينتمي للنوع Application على التابع start() الذي يستدعيه النظام عند بدء تشغيل التطبيق، إذ يستقبل هذا التابع معاملًا من النوع Stage يُخصِّص النافذة الرئيسية للبرنامج، ولكن ليس من الضروري للبرنامج أن يَستخدِم تلك النافذة فعليًا؛ إذ يتجاهل التابع start() المُعرَّف بالصنف WebBrowser النافذة الرئيسية المُخصَّصة، ويُنشِئ بدلًا منها نافذةً من النوع BrowserWindow، لتكون هي أول ما يَظهَر عند تشغيل البرنامج، وقد ضُبطَت تلك النافذة لتُحمِّل الصفحة الرئيسية لصفحة الإنترنت التي تحتوي على النسخة المُتاحة عبر الإنترنت من هذا الكتاب. يُمثِّل ما سبق كل ما ينبغي أن يفعله الصنف WebBrowser.java لتنفيذ البرنامج باستثناء القائمة "Window" التي تحتوي على قائمةٍ بكل النوافذ المفتوحة. ونظرًا لعدم كون تلك القائمة جزءًا من بيانات أي نافذة على حدة، فقد كان من الضروري الاحتفاظ بها بمكانٍ آخر، مثل كائن الصنف WebBrowser، ويمكن بدلًا من ذلك تخزين قائمة النوافذ مثل متغير عضو ساكن static بالصنف BrowserWindow، مما يَعنِي تشاركُه بين جميع النسخ المنشأة من ذلك الصنف؛ إذ يُعرِّف الصنف WebBrowser تابعه newBrowserWindow() بغرض فتح نافذةٍ جديدة؛ بينما يحتوي الصنف BrowserWindow على متغير النسخة owner للإشارة إلى كائن الصنف WebBrowser الذي فتح النافذة. وبالتالي إذا أراد البرنامج فتح نافذة جديدة، فإنه يفعل ذلك باستدعاء التابع owner.newBrowserWindow(url)، إذ يشير المعامل url إلى مُحدِّد الموارد الموحد الخاص بصفحة الإنترنت المطلوب تحميلها بالنافذة الجديدة. وفي المقابل، قد يحتوي المعامل على القيمة الفارغة null بهدف فتح نافذة متصفحٍ فارغة. يتحدََد حجم أي نافذة بمكتبة JavaFX وفقًا لحجم المشهد -من النوع Scene- المعروض داخلها افتراضيًا، كما تظهر النافذة بمنتصف الشاشة افتراضيًا؛ ويُمكِننا بدلًا من ذلك ضبط حجم النافذة ومكانها قبل فتحها. بالنسبة للبرامج متعددة النوافذ، لا يُحبَّذ عرض جميع النوافذ بنفس المكان بالضبط، كما يبدو أن الحجم الافتراضي لكائنات الصنف BrowserWindow صغيرٌ جدًا بمعظم شاشات الحاسوب؛ ولذلك يَضبُط التطبيق WebBrowser موضع جميع النوافذ التي يفتحها ليَبعُد مكان كل نافذةٍ مسافةً قصيرةً عن مكان النافذة التي فتحها التطبيق بالمرة السابقة؛ كما يضبُط التطبيق حجم النافذة بما يتناسب مع حجم الشاشة. يحتوي الصنف Screen المُعرَّف بحزمة javafx.stage على التابع الساكن Screen.getPrimary() الذي يعيد كائنًا يتضمَّن معلوماتٍ عن الشاشة الرئيسية للحاسوب؛ كما ويحتوي ذلك الكائن بدوره على كل من التابعين Screen.getPrimary().getVisualBounds()، الذي يُعيد كائنًا من النوع Rectangle2D ويُمثِّل المساحة القابلة للاستخدام من الشاشة الرئيسية. يَستدعِي تابع البرنامج start() ذلك التابع لكي يَحسِب كُلًا من حجم أول نافذة ومكانها على النحو التالي: public void start(Stage stage) { // (stage is not used) openWindows = new ArrayList<BrowserWindow>(); // List of open windows. screenRect = Screen.getPrimary().getVisualBounds(); // 1 locationX = screenRect.getMinX() + 30; locationY = screenRect.getMinY() + 20; // 2 windowHeight = screenRect.getHeight() - 160; windowWidth = screenRect.getWidth() - 130; if (windowWidth > windowHeight*1.6) windowWidth = windowHeight*1.6; // 3 newBrowserWindow("http://math.hws.edu/javanotes/index.html"); } // end start() حيث أن: [1]: يشير (locationX,locationY) إلى مكان الركن الأيسر العلوي للنافذة التي ستُفتَح بالمرة القادمة، وتتحرك النافذة الأولى إلى الأسفل قليلًا من الركن الأيسر العلوي للجوانب المرئية للشاشة الرئيسية. [2]: تعني أن حجم النافذة يعتمد على طول وعرض جوانب الشاشة المرئية بما يَسمَح ببعض المسافات الإضافية حتى يكون من الممكن وضع النوافذ فوق بعضها، مع إزاحة كل واحدة عن سابقتها قليلًا. قيِّد العرض ليكون على الأكثر 1.6 مرة من الطول لأسباب جمالية. [3]: تعني افتح النافذة الأولى لتَعرِض الصفحة الأمامية للكتاب. عندما يَفتح التابع newBrowserWindow() نافذةً جديدةً، سيعتمد حجمها ومكانها على قيم المتغيرات windowWidth و windowHeight و locationX و locationY؛ ولهذا ينبغي أن نُعدِّل قيم المتغيرين locationX و locationY لكي تَظهَر النافذة التالية بمكانٍ مختلف؛ وعلينا أيضًا أن نضيف النافذة الجديدة إلى قائمة النوافذ المفتوحة؛ كما ينبغي أن نتأكَّد من حذف النافذة من القائمة عند غلقها. لحسن الحظ، تُولِّد أي نافذة حدثًا عند غلقها، وبالتالي يُمكِننا أن نُضيف مستمعًا إلى ذلك الحدث، ليَحذِف معالج الحدث النافذة من قائمة النوافذ المفتوحة. ألقِ نظرةً على شيفرة التابع newBrowserWindow(): void newBrowserWindow(String url) { BrowserWindow window = new BrowserWindow(this,url); openWindows.add(window); // Add new window to open window list. window.setOnHidden( e -> { // 1 openWindows.remove( window ); System.out.println("Number of open windows is " + openWindows.size()); if (openWindows.size() == 0) { // 2 System.out.println("Program ends because all windows are closed"); } }); if (url == null) { window.setTitle("Untitled " + getNextUntitledCount()); } window.setX(locationX); // set location and size of the window window.setY(locationY); window.setWidth(windowWidth); window.setHeight(windowHeight); window.show(); locationX += 30; // set up location for NEXT window locationY += 20; if (locationX + windowWidth + 10 > screenRect.getMaxX()) { // 3 locationX = screenRect.getMinX() + 30; } if (locationY + windowHeight + 10 > screenRect.getMaxY()) { // 4 locationY = screenRect.getMinY() + 20; } } وتعني كل من: [1]: يُستدعَى عند غلق النافذة، وذلك ليحذف النافذة من قائمة النوافذ المفتوحة. [2]: ينتهي البرنامج أوتوماتيكيًا عند غلق جميع النوافذ. [3]: إذا كانت النافذة ستمتد إلى ما بعد طرف الشاشة الأيمن، فأعِد ضبط المتغير locationX إلى قيمته الأصلية. [4]: إذا كانت النافذة ستمتد إلى ما بعد طرف الشاشة السفلي، فأعِد ضبط المتغير locationY إلى قيمته الأصلية. يتضمَّن الصنف WebBrowser التابع getOpenWindowList()، والذي يعيد قائمةً بكل النوافذ المفتوحة؛ إذ يستدعِي كائن الصنف BrowserWindow ذلك التابع أثناء إنشائه للقائمة "Window". وفي الواقع، لا يحدث ذلك بأفضل كفاءةٍ ممكنة، ويُعاد بناء القائمة بكل مرة تُعرَض خلالها؛ إذ تُولِّد القائمة حدثًا عندما ينقُر المُستخدِم على اسم القائمة، وأيضًا قبل ظهورها مباشرةً. يُسجِّل الصنف BrowserWindow مستمعًا إلى ذلك الحدث، ويسترجِع معالج هذا الحدث قائمة النوافذ المفتوحة باستدعاء التابع owner.getOpenWindowList()، ويَستخدِمها لإعادة بناء القائمة قبل أن يُظهِرها على الشاشة. ألقِ نظرةً على شيفرة التابع المُعرَّف بالصنف BrowserWindow: private void populateWindowMenu() { ArrayList<BrowserWindow> windows = owner.getOpenWindowList(); while (windowMenu.getItems().size() > 4) { // 1 windowMenu.getItems().remove(windowMenu.getItems().size() - 1); } if (windows.size() > 1) { // 2 MenuItem item = new MenuItem("Close All and Exit"); item.setOnAction( e -> Platform.exit() ); windowMenu.getItems().add(item); windowMenu.getItems().add( new SeparatorMenuItem() ); } for (BrowserWindow window : windows) { String title = window.getTitle(); // Menu item text is the window title. if (title.length() > 60) { // 3 title = title.substring(0,57) + ". . ."; } MenuItem item = new MenuItem(title); final BrowserWindow win = window; // (for use in a lambda expression) // 4 item.setOnAction( e -> win.requestFocus() ); windowMenu.getItems().add(item); if (window == this) { // 5 item.setDisable(true); } } } حيث تعني كل من: [1]: تتكون القائمة من 4 عناصر دائمة. اِحذِف العناصر الأخرى المقابلة للنوافذ المفتوحة التي تُركت من آخر مرة عُرِضَت خلالها القائمة. [2]: أضف الأمر "Close All" فقط إذا لم تكن تلك هي النافذة الوحيدة. [3]: لا تَستخدِم نصوصًا طويلةً جدًا لعناصر القائمة. [4]: سيُحضِر معالج الحدث لعنصر القائمة ذاك النافذة المقابلة إلى المقدمة باستدعاء التابع requestFocus() الخاص بها. [5]: نظرًا لأن النافذة موجودة بالمقدمة فعليًا، فعطِّل العنصر المقابل لتلك النافذة. كما ترى، ليس من الصعب إدارة تطبيق متعدد النوافذ، كما أنه من السهل كتابة متصفح إنترنت بوظائف معقولة نوعًا ما باستخدام مكتبة JavaFX. كان هذا مثالًا جيدًا على استخدام أصناف موجودة مثل قاعدة نبني عليها أصنافًا أخرى. رأينا أيضًا أمثلةً جديدةً جيدةً للتعامل مع الأحداث، وبذلك نكون قد وصلنا تقريبًا إلى نهاية هذه السلسلة. سنناقش في المقال الأخير بعض الأشياء المتعلقة ببرمجة واجهات المُستخدِم الرسومية. ترجمة -بتصرّف- للقسم Section 4: Mostly Windows and Dialogs من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مكونات الواجهة المركبة ونمط MVC في جافا واجهة المستخدم الحديثة في جافا مدخل إلى التعامل مع الملفات في جافا
-
تتضمَّن واجهة تطوير التطبيقات JavaFX تعقيداتٍ أكبر بكثير مما درسناه إلى الآن، ولكن كل هذا التعقيد يعمل لصالح المبرمج عمومًا؛ فهو على الأغلب يكون مخفيًا بالاستخدامات الأكثر شيوعًا لمكتبة JavaFX، أي أنك لست مضطّرًا إلى معرفة تفاصيل أدوات التحكُّم الأكثر تعقيدًا لكي تتمكَّن من استخدامها بفعالية بغالبية البرامج. تُعرِّف مكتبة JavaFX مجموعةً من الأصناف التي تُمثِّل مكونات أكثر تعقيدًا بكثير من تلك التي رأيناها، ولكن حتى أكثر تلك المكونات تعقيدًا ليس صعب الاستخدام في غالبية الأحوال. سنناقش خلال هذا المقال بعض مكونات الواجهة التي تدعم عرض القوائم والجداول ومعالجتها؛ ولكي تتمكَّن من استخدام تلك المكونات المعقدة بفعالية، عليك أن تتعلم القليل عن نمط "نموذج-عرض-متحكِّم Model-View-Controller"، والذي يُعد أساس لكثيرٍ من مكونات واجهة المُستخدِم الرسومية. سنناقش هذا النمط لاحقًا ضمن هذا المقال. تُوفِّر مكتبة JavaFX عددًا من أدوات التحكُّم التي لن نتعرَّض لها بهذا الكتاب -على الرغم من أن بعضها مفيدٌ نوعًا ما وقد ترغب بالإطلاع عليه-، مثل TabbedPane و SplitPane و Tree و ProgressBar، وكذلك بعض أدوات التحكُّم لقراءة أنواع خاصة من المُدْخَلات، مثل ColorPicker و DatePicker و PasswordField و Spinner. سنبدأ هذا المقال بمثالٍ قصير على كتابة أداة تحكُّم مُخصَّصة، وهو أمرٌ ستحتاج إليه إذا لم تَجِد المُكوِّن الذي تريده ضمن التشكيلة الضخمة من مكونات الواجهة المُعرَّفة مُسبقًا بمكتبة JavaFX، أو من الممكن أن تجده فعلًا ولكنه معقدٌ جدًا بالموازنة مع متطلبات برنامجك، وقد ترغب مثلًا بشيء أبسط. مكون واجهة مخصص بسيط ستَجِد عادةً كل ما تحتاجه لإنشاء واجهة مُستخدِم رسومية بأصناف المكونات القياسية الموجودة بمكتبة JavaFX، ومع ذلك قد ترغب أحيانًا بشيءٍ مختلفٍ بعض الشيء. في تلك الحالة، قد تفكر بكتابة مُكوِّنك الخاص بالاعتماد على واحدة من المكونات التي تُوفِّرها المكتبة، أو بالاعتماد على الصنف البسيط Control الذي يَعمَل صنفًا أساسيًا base class لجميع أدوات التحكُّم. لنفترض مثلًا أننا نريد أداة تحكُّم تمثِّل "ساعة إيقاف"؛ فعندما ينقُر المُستخدِم على الساعة، ينبغي أن تُشغِّل الوقت؛ وعندما ينقر المُستخدِم عليها مرةً أخرى، ينبغي أن تَعرِض الزمن المُنقضِي منذ النقرة الأولى. يُمكِننا استخدام الصنف Label لعرض النص، ولكننا نريده أن يكون قادرًا على الاستجابة إلى حدث النقر على الفأرة، ويُمكِننا تحقيق ذلك بتعريف صنف مُكوِّن واجهة، وليكن اسمه هو StopWatchLabel صنفًا فرعيًا subclass مُشتقًا من الصنف Label، إذ سيَستمِع كائن الصنف StopWatchLabel لحدث نقر الفأرة عليه، ويُغيّر النص المعروض إلى "Timing…" عندما ينقر المُستخدِم عليه لأول مرة، كما سيتذكّر توقيت نقر المُستخدِم عليه. وعندما ينقر المُستخدِم عليه مرةً أخرى، سيفحص التوقيت مرةً أخرى، وسيحسِب الزمن المُنقضِي ويعرضه. في الواقع، لسنا في حاجة بالضرورة لتعريف صنفٍ فرعي جديد، إذ يمكننا استخدام عنوانٍ عادي بالبرنامج، وتهيئة مُستمعٍ ليَستجيب لحدث النقر على العنوان، ونَسمَح للبرنامج بإنجاز العمل اللازم للاحتفاظ بالزمن وتعديل النص المعروض بالعنوان. ومع ذلك، فبكتابة صنف جديد، سنتمكَّن من إعادة استخدامه بمشروعات أخرى، كما أن كل الشيفرة المُتعلقة بساعة الإيقاف مُجمعّةٌ معًا بمكانٍ واحد. يكون ذلك أكثر أهميةً عند التعامل مع المكونات الأكثر تعقيدًا. ليست كتابة الصنف StopWatchLabel بهذه الصعوبة، إذ سنحتاج إلى متغير نسخة instance variable لتخزين توقيت بدء تشغيل ساعة الإيقاف، وسنُهيئ تابعًا لمعالجة حدث النقر على ساعة الإيقاف. ينبغي أن يُحدِّد ذلك التابع ما إذا كانت ساعة الإيقاف مُشغَّلةً أم مُتوقفِة؛ ولهذا سنحتاج إلى متغير نسخة من النوع boolean، وليكن اسمه هو running للاحتفاظ بهذا الجانب من حالة المكوّن؛ كما سنَستخدِم التابع System.currentTimeMillis() للحصول على التوقيت الحالي بوحدة الميلي ثانية مثل قيمةٍ من النوع long. عند بدء تشغيل ساعة الإيقاف، سنخزِّن التوقيت الحالي بمتغير نسخة اسمه startTime؛ وعند إيقافها، سنَستخدِم التوقيت الحالي لحساب الزمن المنقضِي الذي ظلت خلاله ساعة الإيقاف قيد التشغيل. ألقِ نظرةً على شيفرة الصنف StopWatch: import javafx.scene.control.Label; // 1 public class StopWatchLabel extends Label { private long startTime; // Start time of timer. // (Time is measured in milliseconds.) private boolean running; // True when the timer is running. // 2 public StopWatchLabel() { super(" Click to start timer. "); setOnMousePressed( e -> setRunning( !running ) ); } // 3 public boolean isRunning() { return running; } // 4 public void setRunning( boolean running ) { if (this.running == running) return; this.running = running; if (running == true) { // Record the time and start the timer. startTime = System.currentTimeMillis(); setText("Timing...."); } else { // 5 long endTime = System.currentTimeMillis(); double seconds = (endTime - startTime) / 1000.0; setText( String.format("Time: %1.3f seconds", seconds) ); } } } // end StopWatchLabel حيث تشير كل من: [1] إلى مكوِّن واجهة مُخصَّص يمثِّل ساعة إيقاف بسيطة، فعندما ينقر المُستخدِم عليه، سيبدأ المؤقت بالعمل؛ وعندما ينقر المُستخدِم عليه مجددًا، يَعرِض الزمن بين النقرتين. يؤدي النقر لمرة ثالثة إلى بدء المؤقت من جديد، وهكذا. وبينما يكون المؤقت قيد التشغيل، يَعرِض العنوان الرسالة النصية "Timing…." فقط. [2] إلى أنه يضبط الباني النص المبدئي للعنوان إلى "Click to start timer"، ويُهيئ معالجًا لحدث النقر على الفأرة لكي يتمكَّن العنوان من الاستجابة إلى نقرات الفأرة. [3] يُشير إلى ما إذا المؤقت قيد التشغيل حاليًا. [4] أن المؤقت يُضبط ليَعمَل أو ليتوقف، ويُعدِّل النص المعروض بالعنوان، إذ ينبغي أن يُستدعى هذا التابع ضمن خيط تطبيق مكتبة JavaFX. يُحدِّد المعامل running ما إذا كان ينبغي أن يكون المؤقت قيد التشغيل؛ وإذا كانت قيمة المعامل تساوي حالته الحالية، لا يحدث أي شيء. [5] أنه قد أوقِف المؤقت، واحسب الزمن المنقضِي منذ لحظة بدء المؤقت واعرضه. نظرًا لأن الصنف StopWatchLabel هو صنفٌ فرعيٌ من الصنف Label، يُمكِننا تطبيق أيٍّ مما يُمكِننا فعله بكائنات الصنف Label على كائنات هذا الصنف؛ إذ يُمكِننا مثلًا إضافته إلى حاوية، أو أن نضبُط نوع الخط المُستخدَم، أو لونه، أو حجمه الأقصى، أو المُفضَّل، أو أن نضبُط تنسيق CSS الخاص به؛ كما يُمكِننا أيضًا أن نضبُط النص المعروض بداخله، مع أن ذلك يتعارض مع وظيفة ساعة الإيقاف. لاحِظ أن الصنف StopWatchLabel.java ليس تطبيقًا، ولا يمكن تشغيله بمفرده. يَستخدِم البرنامج القصير TestStopWatch.java كائنًا من ذلك الصنف، ويَضبُط مجموعةً من خاصياته لتحسين مظهره. نمط MVC يُعدّ تقسيم المسؤوليات والمهام واحدًا من أهم مبادئ التصميم كائني التوجه object-oriented design؛ إذ ينبغي أن يكون لكل كائن دورًا وحيدًا محدّدًا بوضوح ومُقيدًّا بمسؤولية معينة؛ ويُعدّ نمط نموذج-عرض-مُتحكِّم Model-View-Controller -أو اختصارًا MVC- تطبيقًا جيدًا لهذا المبدأ على تصميم واجهات المُستخدِم الرسومية، إذ يشير كُل من النموذج والعرض والمُتحكِّم، إلى واحدةٍ من المسؤوليات الثلاث الضرورية لتصميم واجهات المُستخدِم الرسومية. إذا طبقنا نمط MVC على مكون، فسيتكوَّن النموذج من البيانات المُمثِّلة للحالة الحالية للمكوِّن؛ أما العرض فسيكون ببساطةٍ هو التمثيل المرئي للمكون على الشاشة؛ بينما سيشير المُتحكِّم إلى ذلك الجزء من المكون المسؤول عن فعل ما هو ضروري نتيجةً للأحداث الصادرة عن أفعال المُستخدِم، أو عن مصادر أخرى مثل المؤقتات. يُمكِن تلخيص فكرة ذلك النمط في إسناد مسؤولية كلٍ من النموذج والعرض والمُتحكِّم إلى كائناتٍ مختلفة. من السهل فهم دور العرض view بنمط MVC، وهو يُمثَّل عادةً باستخدام كائن المكوِّن ذاته، وتتلخص مسؤوليته في رسم المكون على الشاشة؛ ولكي يتمكَّن من إنجاز ذلك، فإنه يعتمد على النموذج، وذلك لاحتواءه على حالة المكوِّن الحالية التي تؤثر بلا شك على طريقة عرض المكوِّن على الشاشة. نظرًا لأن بيانات النموذج مُخزَّنةٌ بكائنٍ منفصل طبقًا لما ينص عليه نمط MVC، فينبغي للكائن المُمثِّل للمكوِّن الاحتفاظ بمرجع reference إلى الكائن المُمثِّل للنموذج. وعندما يتغير ذلك الكائن، تكون إعادة رسم العرض ضروريةً في العادة، وذلك لتعكس الحالة الجديدة، وبالتالي يحتاج المكوِّن إلى طريقةٍ لتحديد توقيت حدوث مثل تلك التغييرات؛ وهو ما يُمكِن تحقيقه بالاستعانة بالأحداث events ومستمعي الأحداث. يُهيَأ كائن النموذج، فيُولِّد أحداثًا عند تغيُّر البيانات، ويُسجِّل كائن العرض نفسه مستمعًا لتلك الأحداث؛ وعندما يتغير النموذج، يقع حدث، ويُبلَّغ العرض بوقوعه؛ وبالتالي يكون بإمكانه الاستجابة بتحديث محتويات المكوِّن على الشاشة. عند استخدام نمط MVC مع مكونات مكتبة JavaFX، لا يكون المُتحكِّم مُنفصلًا بوضوح عن كلٍ من العرض والنموذج، إذ تُوزَّع وظيفته عادةً بين عدة كائنات. في العموم، قد يتضمَّن المُتحكِّم مستمعي أحداث الفأرة ولوحة المفاتيح المسؤولين عن الاستجابة لما يفعله المُستخدِم بالعرض؛ كما قد يتضمَّن مستمعي بعض الأحداث الأخرى عالية المستوى، مثل تلك الناتجة عن زر أو مزلاج، والتي تؤثر على حالة المكوِّن. ويَستجيب المُتحكِّم عادةً على الأحداث بإجراء تعديلات على النموذج، مما يؤدي إلى تعديل العرض مباشرةً استجابةً لتلك التغييرات التي أُجريَت على النموذج. تَستخدِم مكتبة JavaFX نمط MVC بأماكن كثيرة حتى لو لم تكن تَستخدِم مصطلحات "النموذج" و"العرض"؛ وتُعدّ الخاصيات القابلة للمراقبة observale -ألقِ نظرةً على مقال الخاصيات والارتباطات في جافا- أسلوبًا لتنفيذ فكرة النموذج المنفصل عن العرض، على الرغم من أن النموذج قد يكون موزَّعًا على كائناتٍ مختلفة كثيرة عند استخدام الخاصيات. في الواقع، ستلاحِظ وضوح دور كُلٍ من النموذج والعرض أكثر بأداتي القائمة والجدول اللتين سنناقشهما فيما يلي. صنفا القائمة ListView والجدول ComboBox يُمثِّل الصنف ListView قائمةً من العناصر التي يستطيع المُستخدِم أن يختار من بينها، كما بإمكانه أن يُعدِّل العناصر الموجودة بالقائمة. يَسمَح البرنامج SillyStamper.java للمُستخدِم باختيار أيقونة (صورة صغيرة) من قائمة أيقونات مُمثَّلةٍ بكائنٍ من النوع ListView؛ بحيث يختار المُستخدِم الأيقونة التي يرغب بها بالنقر عليها، ثم يُمكِنه أن يَطبَعها داخل الحاوية من خلال النقر على الحاوية. وفي المقابل، يُضيف النقر مع الضغط على زر Shift نسخةً أكبر من الصورة إلى الحاوية (الأيقونات المُستخدَمة بهذا البرنامج مأخوذة من مشروع سطح المكتب KDE). تَعرِض الصورة التالية نافذة البرنامج بعد أن طَبعَ المُستخدِم مجموعةً من الأيقونات فعليًا داخل مساحة الرسم، واختارَ أيقونة "star" من القائمة: ستَجِد الصنف ListView مُعرَّفًا بحزمة javafx.scene.control؛ وهو في الحقيقة صنفٌ ذو معاملات غير محددة النوع parameterized، إذ يشير معامل النوع إلى نوع الكائن المعروض بالقائمة، ويُعدّ النوع ListView<String> هو الأكثر شيوعًا؛ ولكننا استخدمنا بهذا البرنامج النوع ListView<ImageView>، إذ تستطيع كائنات النوع ListView أن عرض السلاسل النصية من النوع String والعقد من النوع Node مباشرةً؛ وعند استخدامه مع كائنات من أنواع أخرى، فإنه يَعرِض التمثيل النصي للكائن الذي يعيده التابع toString() افتراضيًا، والذي لا يكون مفيدًا في غالب الأحيان. تُخزَّن عناصر القائمة من النوع ListView<T> بكائنٍ من النوع ObservableList<T>، إذ تُعدّ قائمة العناصر جزءًا من النموذج الخاص بالمكون، ويُمكِننا استخدام التابع listView.getItems() لاسترجاع عناصر القائمة؛ وعند إضافة العناصر إلى تلك القائمة أو حذفها منها، تُحدَّث القائمة تلقائيًا لتَعكِس ذلك التغيير. يُعرِّف البرنامج SillyStamper القائمة باستخدام المُعدِّل static؛ مما يَعنِي أنه من غير الممكن تعديل القائمة بعد إنشائها، وبالتالي لا يستطيع المُستخدِم أن يُعدِّل القائمة. يقرأ البرنامج صور الأيقونات من ملفات موراد، ويحيط كل صورةٍ منها ضمن كائنٍ من النوع ImageView -ألقِ نظرةً على مقال مكونات التحكم البسيطة في واجهة المستخدم في مكتبة جافا إف إكس JavaFX-، ويُضيفه إلى قائمة العناصر بكائن الصنف ListView. تَعرِض الشيفرة التالية التابع المسؤول عن إنشاء القائمة، والذي يستدعِيه التابع start() الخاص بهذا البرنامج: private ListView<ImageView> createIconList() { String[] iconNames = new String[] { // أسماء ملفات الموارد بالمجلد stamper_icons "icon5.png", "icon7.png", "icon8.png", "icon9.png", "icon10.png", "icon11.png", "icon24.png", "icon25.png", "icon26.png", "icon31.png", "icon33.png", "icon34.png" }; iconImages = new Image[iconNames.length]; // لرسم الأيقونات ListView<ImageView> list = new ListView<>(); list.setPrefWidth(80); list.setPrefHeight(100); for (int i = 0; i < iconNames.length; i++) { Image icon = new Image("stamper_icons/" + iconNames[i]); iconImages[i] = icon; list.getItems().add( new ImageView(icon) ); } list.getSelectionModel().select(0); // اختر العنصر الأول بالقائمة return list; } يبدو أن الحجم المُفضَّل الافتراضي لأي قائمة هو 200 في 400 بغض النظر عن مكوناتها. ويضبُط التابع السابق العرض والطول المُفضَّلين للقائمة؛ فقائمة الأيقونات تحتاج عرضًا أصغر بكثير، كما أن الطول المُفضَّل الافتراضي يؤدي إلى زيادة طول الحاوية بقدرٍ أكبر مما هو مرغوب به، ولذلك يَضبُط التابع الطول المُفضَّل إلى قيمةٍ أصغر، مع أنها ستمتد ضمن هذا البرنامج لتملأ المساحة المُتاحة. يبدو استخدام التابع "للنموذج المُختار" ضمن القائمة مثيرًا بعض الشيء؛ ويُقصَد بذلك جزء النموذج الذي يحتوي على قائمة العناصر التي اختارها المُستخدِم من القائمة، إذ يستطيع المُستخدِم أن يختار عنصرًا واحدًا فقط على الأكثر افتراضيًا، وهو السلوك المناسب لهذا البرنامج؛ ولكن يُمكِننا عمومًا ضبطه ليسمح باختيار عدة عناصر بنفس الوقت، وذلك باستدعاء ما يلي: list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); في حالة تطبيق وضع الاختيار الأحادي الافتراضي، يُلغَى اختيار العنصر المُختار حاليًا -إن وجد-، إذا اختار المُستخدِم عنصًرا آخرًا من القائمة؛ إذ يستطيع المُستخدِم اختيار عنصرٍ بالنقر عليه؛ كما يستطيع البرنامج ضبط الاختيار إلى عنصرٍ معينٍ باستدعاء التابع list.getSelectionModel().select(index)، إذ يشير المعامل index إلى فهرس العنصر المطلوب اختياره. يبدأ هنا ترقيم العناصر من الصفر، وفي حالة كان index يُساوِي -1، فسيُلغَى الاختيار من جميع العناصر. يُسلًط عرض القائمة الضوء على العنصر المُختار حاليًا، كما يستمع إلى التغييرات الحادثة بالنموذج المختار؛ فعندما ينقر المُستخدِم على عنصر، سيُعدِّل مستمع أحداث الفأرة (يُمثِل جزءًا من النموذج) النموذج المختار، ويُبلَّغ العرض بحدوث ذلك التغيير؛ وبناءً على ذلك، يُحدِّث العرض مظهره ليَعكِس حقيقة اختيار عنصرٍ آخر. يستطيع البرنامج استرجاع العنصر الواقع عليه الاختيار حاليًا باستدعاء التابع التالي: list.getSelectionModel().getSelectedIndex() يَستخدِم البرنامج SillyStamper.java التابع السابق عندما ينقر المُستخدِم على الحاوية؛ ولكي يُحدِّد أي أيقونةٍ ينبغي عليه أن يطبعها بالصورة. لاحِظ أن القائمة بالبرنامج SillyStamper.java غير قابلةٍ للتعديل، لكن يحتوي البرنامج التوضيحي الثاني EditListDemo.java على قائمتين بإمكان المُستخدِم تعديلهما: الأولى قائمة سلاسلٍ نصية، والأخرى قائمة أعداد؛ إذ يستطيع المُستخدِم أن يبدأ بتعديل عنصرٍ معينٍ ضمن القائمة بالنقر المزدوج عليه، أو بالنقر عليه مرةً واحدةً إذا كان قيد الاختيار أساسًا؛ كما يُمكِنه إنهاء عملية التعديل من خلال الضغط على مفتاح "return" أو مفتاح "escape" لإلغاء عملية التعديل. ويؤدي اختيار عنصرٍ آخر ضمن القائمة إلى إلغاء عملية التعديل. هناك بعض الأشياء التي ينبغي فعلها إذا أردنا السماح للمُستخدِم بتعديل عناصر قائمةٍ من النوع ListView. أولًا، ينبغي أن نجعل القائمة قابلةً للتعديل باستدعاء التعليمة التالية: list.setEditable(true); ولكن هذا ليس كافيًا، إذ ينبغي أن تكون كل خلية ضمن القائمة قابلةً للتعديل أيضًا؛ ويُقصَد بالخلية هنا تلك المساحة الموجودة بالقائمة، والتي يُعرَض من خلالها عنصرٌ وحيد. في العموم، كل خلية هي كائنٌ مسؤولٌ عن عرض العنصر، وبإمكانه أيضًا تعديله وفق ما يفعله المُستخدِم، ولكن لاحِظ أن تلك الخلايا لا تكون قابلةً للتعديل بالوضع الافتراضي. تَستخدِم القوائم من النوع ListView مصنع factory خلايا لإنشاء الكائنات التي تُمثِّل تلك الخلايا. ففي الواقع، يُعَد مصنع الخلايا كائنًا آخر، ووظيفته هي إنشاء الخلايا؛ ولكي نَحصل على نوعٍ مختلفٍ من الخلايا، ينبغي أن نُوفِّر للقائمة مصنع خلايا مختلف. يتبِع ذلك ما يُعرَف باسم نمط المصنع factory pattern؛ فمن خلال استخدام كائن مصنع لإنشاء الخلايا، يُمكِننا أن نُخصِّص الخلايا بسهولة دون تغيير الشيفرة المصدرية للصنف ListView، فكل ما نحتاج إليه هو مصنع خلايا جديد. في الواقع، ليس من السهل كتابة مصانع الخلايا، ولكن تُوفِّر مكتبة JavaFX لحسن الحظ مجموعةً من مصانع الخلايا القياسية. فإذا كان listView من النوع ListView<String>، فيُمكِننا أن نُهيِئ مصنع خلايا بإمكانه إنشاء خلايا قابلةٍ للتعديل باستدعاء ما يلي: listView.setCellFactory( TextFieldListCell.forListView() ); يُعيد التابع TextFieldListCell.forListView() مصنعًا بإمكانه إنشاء خلايا تَعرِض سلاسلًا نصيةً وتُعدِّلها؛ إذ تَستخدِم الخلية عنوانًا من النوع Label أثناء عرض السلسلة النصية، وتَستخدِم حقلًا نصيًا من النوع TextField أثناء تعديل العنصر. هذا هو كل ما ينبغي أن تعرفه لكي تتمكَّن من إنشاء قائمة سلاسل نصية قابلةٍ للتعديل. وعلاوةً على ذلك، تتوفَّر أنواع عناصر أخرى يتناسب معها أيضًا عرض العنصر، مثل سلسلةٍ نصية واستخدام حقلٍ نصي من النوع TextField أثناء تعديل العنصر، مثل الأعداد والقيم المكونة من محرف واحد والتواريخ والأزمنة. ومع ذلك، إذا لم يكن العنصر سلسلةً نصيةً، فلا بُدّ من وجود طريقةٍ ما للتحويل بينه وبين تمثيله النصي. تُسهِّل مكتبة JavaFX لحسن الحظ تحقيق ذلك بالحالات الشائعة إلى حدٍ كبير، فهي تُوفِّر مُحوِّلات قياسية لجميع الأنواع المذكورة بالأعلى. على سبيل المثال، إذا كان intList قائمةً قابلةً للتعديل من النوع ListType<Integer>، فيُمكِننا أن نُهيِئ له مصنع خلايا مناسب باستخدام التعليمة التالية: intView.setCellFactory( TextFieldListCell.forListView( new IntegerStringConverter() ) ); إذ أن المعامل المُمرَّر للتابع forListView هو كائنٌ يُحوِّل بين الأعداد الصحيحة وتمثيلاتها النصية. ونظرًا لأن ذلك المُحوِّل القياسي لا يُعالِج المُدْخَلات غير الصالحة بطريقةٍ جيدة، فقد اِستخدَمنا بالبرنامج التوضيحي EditListDemo.java مُحوِّلًا مُخصَّصًا آخرًا -كتبه المؤلف- لمصنع الخلايا المُستخدَم بقائمة الأعداد الصحيحة ضمن البرنامج. ألقِ نظرةً على الشيفرة التالية: StringConverter<Integer> myConverter = new StringConverter<Integer>() { // 1 public Integer fromString(String s) { // حوِّل سلسلةً نصيةً إلى عدد صحيح if (s == null || s.trim().length() == 0) return 0; try { return Integer.parseInt(s); } catch (NumberFormatException e) { return null; } } public String toString(Integer n) { // حوِّل عددًا صحيحًا إلى سلسلة نصية if (n == null) return "Bad Value"; return n.toString(); } }; listView.setCellFactory( TextFieldListCell.forListView( myConverter ) ); إذ تشير [1] إلى أن مُحوِّل السلاسل النصية المُخصَّص يحوّل قيمةً نصيةً مُدخَلةً غير صالحة إلى القيمة الفارغة null بدلًا من الفشل، ويَعرِض تلك القيمة الفارغة null على هيئة "Bad Value"؛ بينما يَعرِض السلسلة النصية الفارغة على هيئة صفر. يُعرِّف الصنف StringConverter تابعين فقط، هما toString() و fromString(). ستَجِد محولات السلاسل النصية القياسية مُعرَّفةً ضمن حزمة javafx.util.converters؛ كما ستَجِد الصنف TextFieldListCell مُعرَّفًا ضمن حزمة javafx.scene.control.cell؛ كما تتوفَّر أيضًا أصنافٌ أخرى مشابهة من أجل الخلايا المُستخدَمة مع الجداول. إلى جانب القوائم، يُنفِّذ البرنامج التوضيحي بعض الأشياء الأخرى الشيّقة المُتعلّقة بالأزرار والعناوين باستخدامه لبعض الخاصيات القابلة للمراقبة observable المُعرَّفة بنموذج القائمة -كما ناقشنا بمقال الخاصيات والارتباطات في جافا-؛ إذ يحتوي البرنامج مثلًا على عناوين تَعرِض العنصر المُختار ورقمه، وقد نفَّذ البرنامج ذلك بربط خاصية text الموجودة بالعنوان مع خاصيةٍ مُعرَّفةٍ بالنموذج المُختار الموجود بالقائمة. ألقِ نظرةً على الشيفرة التالية: Label selectedIndexLabel = new Label(); selectedIndexLabel.textProperty().bind( listView.getSelectionModel() .selectedIndexProperty() .asString("Selected Index: %d") ); Label selectedNumberLabel = new Label(); selectedNumberLabel.textProperty().bind( listView.getSelectionModel() .selectedItemProperty() .asString("SelectedItem: %s") ); لا بُدّ أن يكون الزر المسؤول عن حذف عنصر القائمة المختار حاليًا مُفعَّلًا فقط في حالة وجود عنصرٍ مُختارٍ فعلًا، إذ يُنفِّذ البرنامج ذلك بربط خاصية الزر disable على النحو التالي: deleteNumberButton.disableProperty().bind( listView.getSelectionModel() .selectedIndexProperty() .isEqualTo(-1) ); وبالتالي، تُمثِّل العناوين والأزرار بدائلًا لنفس النموذج المختار الذي تعتمد عليه القائمة، إذ يُعدّ ذلك واحدًا من أهم خاصيات نمط MVC، ألا وهو: قد تتواجد عدة عروض views لنفس الكائن المُمثِّل لنموذجٍ معين. تستمع العروض للتغييرات الحادثة بالنموذج؛ وفي حالة حدوث تعديل، تُبلَّغ العروض بالتغيير الحادث، وتُحدِّث نفسها لتعكِس الحالة الجديدة للنموذج. وبالإضافة إلى ما سبق، يحتوي البرنامج على الزر "Add" المسؤول عن إضافة عنصرٍ جديدٍ إلى القائمة، إذ يَستخدِم ذلك الزر جزءًا آخرًا من نموذج كائن الصنف ListView المُمثِّل للقائمة؛ وذلك بإضافة العنصر إلى كائنٍ قابلٍ للمراقبة من النوع ObservableList يَحمِل جميع عناصر القائمة. ونظرًا لأن كائن الصنف ListView يستمع إلى التغييرات الواقعة بتلك القائمة القابلة للمراقبة، فإنه يُبلَّغ بحدوث ذلك التغيير، وبالتالي يُمكِنه أن يُحدِّث نفسه ليعرِض العنصر الجديد ضمن القائمة. وبخلاف إضافة العنصر إلى القائمة القابلة للمراقبة، فلا حاجة لفعل أي شيءٍ آخر لإظهار العنصر على الشاشة. والآن، سنناقش أداة تحكُّم أخرى مُمثَّلةٍ بالصنف ComboBox، إذ تشبه تلك الأداة أداة التحكُّم التي يُمثِّلها الصنف ListView إلى حدٍ كبير، بل هي أساسًا نفس أداة ListView، ولكنها تُظهِر العنصر المُختار فقط؛ فعندما ينقر المُستخدِم على تلك الأداة، ستَظهَر قائمةٌ بجميع العناصر المتاحة، ويستطيع المُستخدِم أن يختار أي عنصرٍ منها. في الواقع، تَستخدِم أداة التحكُّم ComboBox كائنًا من الصنف ListView لعرض القائمة التي تظهر عند نَقْر المُستخدِم على الأداة. ولقد رأينا تلك الأداة مُستخدَمةً فعلًا بهيئة قائمة ببعض الأمثلة السابقة، مثل البرنامج GUIDemo.java بمقال واجهة المستخدم الحديثة في جافا. بالمثل من الصنف ListView، إذ يُعدّ الصنف ComboBox نوعًا ذا معاملات غير محدَّدة النوع، ويُعدّ نوع العنصر String هو الأكثر اِستخدامًا معها، على الرغم من دعمها لأنواع عناصر أخرى (باستخدام مصانع الخلايا ومحوّلات السلاسل النصية). يُمكِننا إنشاء أداة تحكُّم من النوع ComboBox وإدارتها بنفس طريقة إنشاء وإدارة أداة التحكُّم ListView. ألقِ نظرةً على الشيفرة التالية على سبيل المثال: ComboBox<String> flavors = new ComboBox<>(); flavors.getItems().addAll("Vanilla", "Chocolate", "Strawberry", "Pistachio"); flavors.getSelectionModel().select(0); يُمكِننا ضبط تلك الأداة لتُصبِح قابلةً للتعديل، ولسنا بحاجةٍ إلى مصنع خلايا مُخصَّص لذلك الغرض طالما كانت العناصر المُستخدَمة من النوع String، وتكون الأداة في هذه الحالة أشبه بتركيبةٍ غريبة تجمع بين الحقل النصي والقائمة؛ إذ تَستخدِم حقلًا نصيًا لعرض العنصر المُختار بدلًا من استخدام عنوان. إلى جانب ذلك، يمكن للمُستخدِم تعديل قيمة الحقل النصي، وستُصبِح القيمة المُعدَّلة هي القيمة المختارة، ولكن لاحظ أن القيمة الأصلية للعنصر المُعدَّل لا تُحذَّف من القائمة، وإنما يُضاف العنصر الجديد فقط، كما أن العنصر الجديد لا يُصبِح جزءًا دائمًا من القائمة. يؤدي استدعاء التابع flavors.setEditable(true) في المثال السابق مثلًا، إلى السماح للمُستخدِم بكتابة "Rum Raisin," أو أي شيء آخر على أنها نكهةٌ مُفضَّلة، ولكنه لا يحلّ محل العنصر "Vanilla"، أو "Chocolate"، أو "Strawberry"، أو "Pistachio" الموجودين بالقائمة. بخلاف كائنات الصنف ListView، تُولِّد كائنات الصنف ComboBox حدثًا من النوع ActionEvent عندما يختار المُستخدِم عنصرًا جديدًا سواءٌ فَعَلَ ذلك باختيار العنصر من القائمة، أو بكتابة العنصر على أنه قيمةٌ جديدةٌ بالصندوق القابل للتعديل، ثم الضغط على "return". الصنف TableView بالمثل من أداة التحكُّم بالقائمة المُمثَّلة بالصنف ListView، تَعرِض أداة تحكُّم "الجدول" المُمثَّلة بالصنف TableView تجميعةً من العناصر للمُستخدِم، ولكنها أكثر تعقيدًا، إذ تُرتَّب عناصر الجدول ضمن شبكةٍ من الصفوف والأعمدة، ويُمثِّل كل موضعٍ بالشبكة "خليةً" ضمن الجدول. يحتوي كل عمودٍ هنا على متتاليةٍ من العناصر، ويملُك رأسًا يقع أعلى العمود ويحتوي على اسمه. وفي العموم، يتشابه العمل مع عمودٍ واحد ضمن كائن الصنف TableView مع العمل مع كائن الصنف ListView من جوانب كثيرة. يُعدّ الصنف TableView<T> نوعًا ذا معاملات غير مُحدَّدة النوع، إذ يحتوي كائن معامل النوع T على جميع البيانات المتعلقة بصف واحد ضمن الجدول، ويُمكِنه أن يتضمَّن بيانات إضافية أيضًا؛ فيمكن للجدول أن يَكون "عرضًا view " لبعض البيانات المُتاحة فقط. ينتمي نموذج البيانات الخاص بجدول من النوع TableView<T> إلى الصنف ObservableList<T>، ويُمكِننا استرجاعه باستدعاء التابع table.getItems()، كما يُمكِننا أيضًا إضافة الصفوف إلى الجدول وحذفها منه بإضافة العناصر وحذفها من تلك القائمة. لكي نُعرِّف جدولًا: لا يكون تحديد الصنف المُمثِّل لصفوف الجدول كافيًا، فعلينا أيضًا أن نحدِّد نوع البيانات التي ننوي تخزينها بكل عمود ضمن الجدول؛ لذلك سنَستخدِم كائنًا من النوع TableColumn<T,S> لوصف كل عمود ضمن الجدول، إذ يشير معامل النوع الأول T إلى نفس نوع معامل النوع الخاص بالجدول، بينما يشير معامل النوع الثاني S إلى نوع العناصر التي ننوي تخزينها بخلايا ذلك العمود. يشير النوع TableColumn<T,S> إلى كون العمود يَعرِض عناصرًا من النوع S مشتقةً من صفوفٍ من النوع T. لا تحتوي الكائنات المُمثِّلة للعمود على العناصر المعروضة بالعمود، فهم موجودون بكائنات النوع T التي تُمثِّل الصفوف، ومع ذلك تحتاج كائنات الأعمدة إلى طريقةٍ لاسترجاع العنصر المعروض بالعمود من الكائن المُمثِّل للصف؛ إذ يُمكِننا إجراء ذلك بتخصيص ما يُعرَف باسم "مصنع قيم الخلايا"، فيُمكِننا مثلًا أن نكتب مصنعًا لتطبيق أي دالةٍ function على كائنٍ مُمثِّل لصف، ولكن الصنف PropertyValueFactory يُعدّ هنا الخيار الأكثر شيوعًا، والذي يسترجِع ببساطة قيمة إحدى خاصيات كائن الصف. والآن لنفحص مثالًا. يَعرِض البرنامج التوضيحي SimpleTableDemo.java جدولًا غير قابلٍ للتعديل، ويحتوي الجدول على أسماء الولايات الخمسين الموجودة بالولايات المتحدة الأمريكية مع عواصمها وتعدادها السكاني. ألقِ نظرةً على الصورة التالية: يَستخدِم البرنامج كائناتٍ تنتمي إلى الصنف StateData المُعرَّف على أنه صنفٌ متداخلٌ ساكنٌ عام لحَمْل بيانات كل صف. لا بُدّ أن يكون الصنف عامًا لكي نتمكَّن من اِستخدَامه مع الصنف PropertyValueFactory، ولكن ليس من الضروري أن يكون متداخلًا أو ساكنًا. سنُعرِّف قيم بيانات كل صف على أنها خاصيات ضمن ذلك الصنف، أي سيكون هنالك تابع جَلْب getter لكل قيمةٍ منها. في الحقيقة، يُعدّ تعريف الخاصيات باستخدام توابع جلب كافيًا لاستخدامها مثل قيمٍ بجدولٍ غير قابل للتعديل، كما سنحتاج إلى شيء مختلف بالنسبة لأعمدة الجداول القابلة للتعديل كما سنرى لاحقًا. ألقِ نظرةً على تعريف الصنف: public static class StateData { private String state; private String capital; private int population; public String getState() { return state; } public String getCapital() { return capital; } public int getPopulation() { return population; } public StateData(String s, String c, int p) { state = s; capital = c; population = p; } } سنُنشِئ الجدول المسؤول عن عرض بيانات الولايات على النحو التالي: TableView<StateData> table = new TableView<>(); بعد ذلك سنضيف إلى نموذج بيانات الجدول عنصرًا لكل ولاية، والذي يُمكِننا استرجاعه باستدعاء التابع table.getItems()؛ ثم سنُنشِئ الكائنات المُمثِّلة للأعمدة ونُهيئها ونضيفها إلى نموذج أعمدة الجدول، والذي يُمكِننا استرجاعه باستدعاء التابع table.getColumns(). ألقِ نظرةً على الشيفرة التالية: TableColumn<StateData, String> stateCol = new TableColumn<>("State"); stateCol.setCellValueFactory( new PropertyValueFactory<StateData, String>("state") ); table.getColumns().add(stateCol); TableColumn<StateData, String> capitalCol = new TableColumn<>("Capital City"); capitalCol.setCellValueFactory( new PropertyValueFactory<StateData, String>("capital") ); table.getColumns().add(capitalCol); TableColumn<StateData, Integer> populationCol = new TableColumn<>("Population"); populationCol.setCellValueFactory( new PropertyValueFactory<StateData, Integer>("population") ); table.getColumns().add(populationCol); يُمثِّل المعامل المُمرَّر لباني الصنف TableColumn النص المعروض برأس العمود. وبالنسبة لمصانع قيم الخلايا، يحتاج أي مصنعٍ منها إلى قراءة قيمة الخلية من كائن صفٍ ينتمي إلى النوع StateData؛ أما بالنسبة للعمود الأول، فنوع البيانات هو String، وبالتالي ينبغي أن يَستقبِل المصنع مُدْخَلًا من النوع StateDate ويُخرِج قيمة خاصية من النوع String. بالتحديد، الخرج هو قيمة الخاصية state المُعرَّفة ضمن كائن الصنف StateData. بالتالي، يُمكِننا كتابة الباني على النحو التالي: new PropertyValueFactory<StateData, String>("state") يُنشِئ الاستدعاء السابق مصنع قيم خلايا يَحصُل على القيمة التي سيَعرِضها بالخلية باستدعاء obj.getState()، إذ أن obj هو الكائن المُمثِّل لصف الجدول المُتضمِّن للخلية. وقد خصَّصنا العمودين الآخرين بنفس الطريقة. هذا هو كل ما تحتاج إلى معرفته لكي تتمكَّن من إنشاء جدولٍ لا يستطيع المُستخدِم أن يُعدِّل محتويات خلاياه. يمكن للمُستخدِم افتراضيًا تعديل طول عَرْض العمود من خلال سحب الفاصل الموجود بين رأسي أي عمودين؛ كما بإمكانه أن ينقر على رأس أي عمود لكي يُرتِّب صفوف الجدول ترتيبًا تصاعديًا أو تنازليًا وفقًا لقيم ذلك العمود؛ وبإمكاننا مع ذلك تعطيل هاتين الخاصيتين بضبط بعض الخاصيات المُعرَّفة بكائن الصنف TableColumn -وهو ما سنفعله بالمثال التالي-؛ كما يستطيع المُستخدِم أيضًا إعادة ترتيب الأعمدة بسحب رأس العمود إلى اليمين أو اليسار. يتضمَّن البرنامج التوضيحي ScatterPlotTableDemo.java مثالًا على جدول قابل للتعديل، إذ يُمثِّل كل صفٍ ضمن الجدول نقطةً على سطح المستوى، ويحتوي العمودين على الإحداثي الأفقي والرأسي للنقاط. يَعرِض البرنامج تلك النقاط ضمن مخطط انتشار بياني scatter plot داخل حاوية، إذ يَرسِم تقاطعًا صغيرًا عند موضع كل نقطة. وتُوضِّح الصورة التالية لقطة شاشة من البرنامج أثناء تعديل الإحداثي الأفقي لإحدى النقاط: سنحتاج إلى نوع بيانات لتمثيل صفوف الجدول، والذي قد يكون صنفًا بسيطًا يحتوي على خاصيتين x و y لتمثيل إحداثيات النقطة؛ ولكن نظرًا لأننا نريد عمودًا قابلًا للتعديل، فلا نستطيع استخدام خاصياتٍ بسيطة مُعرَّفة بتوابع جَلْب وضبط، وإنما لا بُدّ أن تكون الخاصيات قابلةً للمراقبة. بالتحديد، لا بُدّ أن يَتبِّع الصنف نمط مكتبة JavaFX للخاصيات القابلة للمراقبة والتي تنص على مايلي: ينبغي أن تُخزَّن قيم الخاصيتين x و y بكائنات خاصيات قابلة للمراقبة، كما ينبغي أن يتضمَّن الكائن المُمثِّل للنقطة، وليكن اسمه pt، توابع النسخ pt.xProperty() و pt.yProperty()؛ إذ تعيد تلك التوابع كائنات الخاصيات القابلة للمراقبة، لكي تُستخدَم بضبط قيم الخاصيات واسترجاعها. وبما أن تلك الخاصيات تُخزِّن قيمًا من النوع double، فإن تلك الكائنات ستكون من النوع DoubleProperty. يُمكِننا تعريف صنف البيانات للجدول على النحو التالي: public static class Point { private DoubleProperty x, y; public Point(double xVal, double yVal) { x = new SimpleDoubleProperty(this,"x",xVal); y = new SimpleDoubleProperty(this,"y",yVal); } public DoubleProperty xProperty() { return x; } public DoubleProperty yProperty() { return y; } } يُعَد الصنف DoubleProperty صنفًا مجرَّدًا abstract؛ أما الصنف SimpleDoubleProperty، فهو صنفٌ فرعيٌ حقيقي concrete يتطلَّب بانيه constructor كُلًا من الكائن المُتضمِّن للخاصية واسم الخاصية والقيمة المبدئية لتلك الخاصية؛ وفي المقابل، يُوفِّر الصنف أوتوماتيكيًا مستمعي أحداث التغيير وانعدام الصلاحية invalidation الخاصين بتلك الخاصية. بعد تعريفنا للصنف Point، يُمكِننا إنشاء الجدول وإضافة بعض النقاط العشوائية إليه على النحو التالي: table = new TableView<Point>(); points = table.getItems(); for (int i = 0; i < 5; i++) { // أضف خمس نقاط عشوائية إلى الجدول points.add( new Point(5*Math.random(), 5*Math.random()) ); } عند إضافة نقطة إلى الجدول أو حذف نقطة منه، فلا بُدّ من إعادة رسم الحاوية، ولذلك سنضيف مستمعًا إلى القائمة points، التي تَعمَل مثل نموذج بيانات للجدول: points.addListener( (Observable e) -> redrawDisplay() ); لاحِظ تصريحنا عن كون المعامل e بتعبير لامدا السابق lambda expression من النوع Observable؛ وذلك لأن القائمة القابلة للمراقبة تتضمَّن نسختين من التابع addListener()، وكلاهما يَستقبِل مُعاملًا واحدًا بتعبير لامدا. وبالتالي يُمكِّن التصريح عن نوع e المُصرِّف من معرفة النسخة التي نريد استدعاءها، فنحن نضيف مستمعًا من النوع InvalidationListener لا من النوع ListChangeListener. وبذلك نكون قد ضبطنا الحاوية لكي تُعيد رسم نفسها بمجرد إضافة نقطةٍ إلى الجدول أو حذف نقطةٍ منه، ولكننا لم نضبطها بعد لتفعل ذلك عند تعديل إحدى النقاط الموجودة بالجدول؛ لأن ذلك لا يُمثِّل تغييرًا ببنية القائمة، وإنما يُمثِّل تغييرًا ضمن إحدى الكائنات الموجودة بالقائمة. لكي نتمكَّن من الإستجابة لتلك التغييرات أيضًا، يُمكِننا مثلًا إضافة مستمعين إلى الخاصيتين القابلتين للمراقبة المُعرَّفتين بكل كائنٍ من النوع Point. في الواقع، هذا هو ما يفعله الجدول أساسًا لكي يستجيب إلى التغييرات الحادثة بأي نقطة ضمن الجدول، ولكننا لن نتبِع هذا الأسلوب؛ إذ سنضبُط البرنامج بدلًا من ذلك ليستمع إلى نوعٍ آخر من الأحداث، التي ستمكِّنه أيضًا من معالجة تعديلات خلايا الجدول. يتضمَّن كل جدول خاصيةً قابلةً للمراقبة اسمها editingCell، والتي تحتوي على الخلية التي يُجرَى تعديلها حاليًا أو القيمة الفارغة null، إذا لم تكن هناك أي خليةٍ قيد التعديل. عندما تتغير قيمة تلك الخاصية إلى القيمة الفارغة null، يَعنِي ذلك أن هناك عملية تعديل لخليةٍ ما ضمن الجدول قد اكتملت، وبالتالي سنضبُط الحاوية لكي تعيد رسم نفسها بعد كل عملية تعديل من خلال تسجيل مستمعٍ إلى حدث التغيير بالخاصية editingCell على النحو التالي: table.editingCellProperty().addListener( (o,oldVal,newVal) -> { if (newVal == null) { redrawDisplay(); } }); والآن، لكي نُنهِي تعريف الجدول، ينبغي أن نُعرِّف العواميد؛ إذ سنحتاج إلى مصنع قيم خلايا لكل عمود، شرط أن يُنشَأ باستخدام مصنع قيم خاصيات. يتبِّع ذلك نفس النمط الذي اِستخدَمناه بالمثال السابق، ونظرًا لأن العمود هنا قابلٌ للتعديل، فسنحتاج إلى مصنع خلايا أيضًا كما فعلنا تمامًا بمثال القوائم القابلة للتعديل بالأعلى. يُمكِننا إذًا إنشاء مصنع خلايا باستخدام التعليمة التالية: TextFieldTableCell.forTableColumn(myConverter) إذ أن المعامل myConverter من النوع StringConverter<Double>؛ وسيكون من الأفضل بهذا البرنامج لو منعنا المُستخدِم من تغيير حجم الأعمدة أو تغيير ترتيبها. تتضمَّن الشيفرة التالية كل ما هو مطلوب لضبط إحدى العواميد: TableColumn<Point, Double> xColumn = new TableColumn<>("X Coord"); xColumn.setCellValueFactory( new PropertyValueFactory<Point, Double>("x") ); xColumn.setCellFactory( TextFieldTableCell.forTableColumn(myConverter) ); xColumn.setSortable(false); xColumn.setResizable(false); xColumn.setPrefWidth(100); // الحجم الافتراضي صغير للغاية table.getColumns().add(xColumn); بقي لنا الآن ضبط الجدول ليكون قابلًا للتعديل، وذلك باستدعاء التابع table.setEditable(true). ربما ترى أننا قد اضطررنا لفعل كثيرٍ من الأشياء لمجرد إنشاء جدول خصوصًا إذا كان قابلًا للتعديل؛ ولكن الجداول أكثر تعقيدًا من ذلك بكثير، والشيفرة التي تتطلَّبها مكتبة JavaFX لتهيئة جدول أقل بكثير مما يتطلَّبه تنفيذ جدولٍ من الصفر مباشرةً. بالمناسبة، عليك أن تنتبه للطريقة التي استخدمنا بها نمط MVC ضمن هذا البرنامج، إذ يُعَد مخطط الانتشار البياني عرضًا view بديلًا لنفس نموذج البيانات المعروض بالجدول؛ كما تُستخدَم البيانات من النموذج عند إعادة رسم الحاوية، ويَحدُث ذلك استجابةً للأحداث النابعة عن أي تعديلات بالنموذج. قد يُفاجئك ذلك، ولكننا لا نحتاج إلى إضافة ما هو أكثر من ذلك لكي نضمَّن استمرار عرض مخطط الانتشار البياني لنفس البيانات على نحو صحيح. سيكون أيضًا من الأفضل لو ألقيت نظرةً على شيفرة البرنامج ScatterPlotTableDemo.java، وستجدها موثقةً جيدًا. بالإضافة إلى فَحْص الصنف TableView؛ كما يُمكِنك كذلك إلقاء نظرةٍ على الطريقة التي اِستخدَمنا بها التحويلات transforms لرسم مخطط الانتشار البياني. ترجمة -بتصرّف- للقسم Section 3: Complex Components and MVC من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: أمثلة عن رسوميات فاخرة باستعمال جافا التخطيط الأساسي لواجهة المستخدم في مكتبة جافا إف إكس JavaFX
-
لقد رأينا أمثلةً كثيرةً على طريقة استخدام كائن سياق رسومي من النوع GraphicsContext للرسم ضمن حاوية، ولكنه يمتلك في الحقيقة ميزات أخرى كثيرة إلى جانب تلك التي تناولناها من قبل. سنناقش خلال هذا القسم رسوم الحاوية، وسنبدأ بفحص تابعين مفيدين لإدارة حالة كائن سياق رسومي. يمتلك كائنٌ g من النوع GraphicsContext خاصيات متعددة، مثل لون المِلء وعرض الخط، وتؤثر تلك الخاصيات على جميع ما يرسمه الكائن. من المهم أن تتذكر أن أي حاوية تملك كائن سياق رسومي وحيد، وأن أي تغيير يُجرَى على إحدى خاصيات ذلك الكائن، سيُطبَّق على جميع رسومه المُستقبلية إلى أن تتغير قيمة خاصياته مرةً أخرى؛ أي أن تأثير أي تغيير على خاصياته يتجاوز حدود البرنامج الفرعي subroutine الذي نُفَّذت خلاله. ومع ذلك، يحتاج المبرمجون عادةً إلى تغيير قيمة بعض الخاصيات تغييرًا مؤقتًا، بحيث تُعاد إلى قيمها السابقة بعد انتهائهم؛ ولهذا، تتضمَّن واجهة برمجة التطبيقات الخاصة بالرسوميات التابعين g.save() و g.restore() لتنفيذ ذلك بسهولة. يُخزِّن التابع g.save() عند تنفيذه حالة كائن السياق الرسومي، والتي تتضمَّن جميع الخاصيات التي تؤثر على الرسوم تقريبًا. في الواقع، يَملُك كائن السياق الرسومي مكدسًا stack.) للحالات -ألقِ نظرةً على مقال المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT-، بحيث يُخزِّن التابع g.save() حالة الكائن الحالية بالمكدس؛ وفي المقابل، يَسحَب التابع g.restore() عند استدعائه الحالة الموجودة أعلى المكدس، ويَضبُط قيم جميع خاصيات الكائن لتتوافق مع القيم المخزَّنة بالحالة المسحوبة. نظرًا لاستخدام كائن السياق الرسومي مكدس حالات، فمن الممكن استدعاء التابع save() عدة مرات قبل استدعاء التابع restore()، ولكن لا بُدّ أن يُقابِل كل استدعاءٍ للتابع save() استدعاءً للتابع ()restore. ومع ذلك، لا يؤدي استدعاء restore() بدون استدعاء سابق مقابل للتابع save() إلى حدوث خطأ؛ بل يحدث فقط تجاهُلٌ لتلك الاستدعاءات الإضافية. يُعدّ استدعاء التابع save() ببداية البرنامج الفرعي والتابع restore() بنهايته، الطريقة الأسهل عمومًا لضمان عدم تجاوز التغييرات المُجراة على كائن السياق الرسومي ضمن برنامج فرعي معين ما يليه من استدعاءات. تبرز أهمية حفظ حالة السياق الرسومي واستعادتها لاحقًا أثناء التعامل مع التحويلات transforms، والتي سنناقشها لاحقًا ضمن هذا القسم. رسم حواف الأشكال بطريقة فاخرة لقد رأينا طريقة رسم حواف الخطوط والمنحنيات وحتى خطوط المحارف النصية، وكذلك طريقة ضبط كُلٍ من لون وحجم الخط المُستخدَم لرسم تلك الحواف strokes. في الواقع، تتوفَّر خاصيات أخرى تؤثر على طريقة رسم الحواف؛ فيُمكِننا مثلًا أن نرسمها بخطوط مُنقطّة أو متقطعة، كما يُمكننا أن نتحكَّم بمظهر نهاية الحواف، وبمظهر نقطة التلاقي بين خيطين أو منحنين، مثل تقابُل جانبي مستطيل بإحدى أركانه. يحتوي كائن السياق الرسومي على خاصيات للتحكُّم بجميع تلك الخاصيات، ولكن من الضروري استخدام خطوطٍ عريضة بما يكفي حتى نتمكَّن من ملاحظة تاثير خاصيات، مثل تلك التي تتحكَّم بمظهر نهاية الحواف ونقط التلاقي. تَعرِض الصورة التالية بعض الخيارات المتاحة: تُبيّن نهايتي القطع المستقيمة الثلاثة على يسار الصورة الأنماط الثلاثة المحتملة للخط "cap."، كما ستَجِد الحافة باللون الأسود؛ أما الخط الهندسي، فهو بلون أصفر داخل الحافة. عند استخدام النمط BUTT، تُقطَّع نهايتي الخط الهندسي؛ أما عند استخدام النمط الدائري ROUND، يُضاف قرصٌ بكل نهاية قطره يساوي عرض الخط؛ بينما عند استخدام النمط المربع SQUARE، يُضاف مربعٌ بدلًا من القرص. وما تحصل عليه عند استخدام النمط الدائري أو المربع هو نفس ما تحصل عليه عند رسم حافة بقلم رأسه دائري أو مربع على الترتيب. إذا كان g كائن سياق رسومي، فسيضبُط التابع g.setLineCap(cap) نمط الخط cap المُستخدَم لرسَم الحواف، إذ يَستقبِل التابع معاملًا من نوع التعداد StrokeLineCap المُعرَّف بحزمة javafx.scene.shape، والتي قيمه المحتملة هي StrokeLineCap.BUTT و StrokeLineCap.ROUND والقيمة الافتراضية StrokeLineCap.SQUARE. تَعمَل نقط التلاقي بنفس الطريقة، إذ يَضبُط التابع g.setLineJoin(join) مظهر النقطة التي يلتقي عندها خيطان أو منحنيان، ويَستقبِل التابع في تلك الحالة كائنًا من النوع StrokeLineJoin، وقيمه المحتملة هي القيمة الافتراضية StrokeLineJoin.MITER و StrokeLineJoin.ROUND و StrokeLineJoin.BEVEL؛ إذ تعرِض الصورة السابقة هذه الأنماط الثلاثة بالمنتصف. عند اِستخدام النمط MITER، تمتد القطعتان المستقيمتان لتكوين نقطة حادة؛ أما عند اِستخدَام النمطين الآخرين، فسيُقطّع الركن. يكون التقطيع بالنسبة للنمط BEVEL باستخدام قطعة مستقيمة؛ أما بالنسبة للنمط ROUND، فسيكون التقطيع باستخدام قوس أو دائرة. تبدو نقط التلاقي الدائرية أفضل إذا رسمت منحنيًا كبيرًا بهيئة سلسلةٍ من القطع المستقيمة القصيرة. يُمكِننا استخدام التابع g.setLineDashes() لتطبيق نمط تقطيع يُظهِر الحواف على نحوٍ مُنقّط أو متقطِّع، إذ تُمثِّل معاملات هذا التابع أطوال القطع والمسافات الفاصلة بينها: g.setLineDashes( dash1, gap1, dash2, gap2, . . . ); لاحِظ أن معاملات التابع هي من النوع double، ويُمكِن تمريرها أيضًا مثل مصفوفةٍ من النوع double[]. وإذا رسمنا حافةً معينةً بعد اختيار أيٍّ من أنماط التقطيع، فسيتكوّن الشكل من خطٍ أو منحنًى طوله يُساوِي dash1، متبوعًا بمسافةٍ فارغة طولها gap1، والتي يتبعها خط أو منحنى طوله dash2، وهكذا، وسيُعاد تكرار نفس نمط الخطوط والفراغات بما يكفي لرسم طول الحافة بالكامل. على سبيل المثال، يرسِم الاستدعاء g.setLineDashes(5,5) الحافة مثل متتاليةٍ من القطع القصيرة التي يبلُغ طول كل منها 5 ويَفصِل بينها مسافةً فارغةً طولها يُساوِي 5؛ بينما يَرسِم الاستدعاء g.setLineDashes(10,2) متتاليةً من القطع المستقيمة الطويلة، بحيث يَفصِل بينها مسافات قصيرة. يُمكِن تخصيص نمطٍ مُكوَّن من قطعٍ مستقيمة ونقط باستدعاء التابع g.setLineDashes(10,2,2,2)، ويتكوَّن النمط المتقطع الافتراضي من خطٍ بدون أي نقاط أو فواصل. يَسمح البرنامج التوضيحي StrokeDemo.java للمُستخدِم برسم خطوط ومستطيلات باستخدام تشكيلةٍ مختلفة من أنماط الخطوط. ألقِ نظرةً على الشيفرة المصدرية لمزيدٍ من التفاصيل. تلوين فاخر لقد أصبح بإمكاننا رسم حوافٍ فاخرة الآن. ربما لاحظت أن كل عمليات الرسم كانت مقيدةً بلونٍ واحدٍ فقط، ولكن يُمكِننا في الواقع تجاوز ذلك باستخدام الصنف Paint؛ إذ تُستخدَم كائنات هذا الصنف لإسناد لونٍ لكل بكسل نمرُ عليه أثناء الرسم. وفي الواقع، يُعدّ الصنف Paint صنفًا مجرّدًا abstract، وهو مُعرَّف بحزمة javafx.scene.paint. يُعدّ الصنف Color واحدًا فقط من ضمن الأصناف الفرعية الحقيقية المشتقة من الصنف Paint، أي يُمكِننا أن نُمرِّر أي كائن من النوع Paint مثل معاملٍ للتابعين g.setFill() و g.setStroke(). وعندما يكون الكائن المُمرَّر من النوع Color، سيُطبَّق نفس اللون على جميع البكسلات التي تَمُرّ عبرها عملية الرسم، ولكن هنالك بالطبع أنواع أخرى يَعتمِد فيها اللون المُطبَّق على بكسلٍ معين على إحداثياته. تُوفِّر مكتبة JavaFX عدة أصناف بهذه الخاصية، مثل ImagePattern، ونوعين آخرين للتلوين المُتدرِج؛ فبالنسبة للصنف الأول ImagePattern، يُستخرَج لون البكسل من صورة مكررة -إن اقتضت الضرورة- مثل ورق حائط حتى تُغطِي سطح المستوى xy بالكامل؛ أما بالنسبة للتلوين المُتدرِج، فيتغير اللون المُطبَّق على البكسلات تدريجيًا من لونٍ لآخر بينما ننتقل من نقطة لأخرى. تُوفِّر جافا صنفين من هذا النوع، هما LinearGradient و RadialGradient. سيكون من المفيد لو اطلعنا على بعض الأمثلة، إذ تعرض الرسمة التالية مضلعًا ملونًا بأسلوبين مختلفين؛ ويَستخدِم المضلع الموجود على اليسار الصنف LinearGradient، بينما يَستخدِم المضلع الموجود على اليمين الصنف ImagePattern. لاحِظ أن اللون قد اُستخدَم هنا لملء المضلع ذاته فقط، أما حواف المُضلع فقد رُسمَت بلونٍ أسود عادي. ومع ذلك، يُمكِننا استخدام كائنات الصنف Paint لرسم حواف الأشكال وملئها أيضًا. ستَجِد شيفرة رسم المضلعين بالبرنامج PaintDemo.java، إذ يُمكِّنك هذا البرنامج من الاختيار بين عدة أنماط تلوين مختلفة، إلى جانب التحكًّم ببعض من خاصيات تلك الأنماط. إذا اخترنا استخدام الصنف ImagePattern، فسنحتاج إلى صورة أولًا. لقد تعرَّضنا للصنف Image بالقسم الفرعي 6.2.3، وتعلمنا طريقة إنشاء كائن من النوع Image من ملف مورد. وبفرض أن pict هو كائنٌ يُمثِّل صورةً من النوع Image، يُمكِننا إنشاء كائنٍ من النوع ImagePattern باستخدام باني الكائن على النحو التالي: patternPaint = new ImagePattern( pict, x, y, width, height, proportional ); تُمثِّل المعاملات x و y و width و height قيمًا من النوع double، تتحكَّم بكُلٍ من حجم الصورة وموضعها بالحاوية؛ إذ تُوضَع نسخةٌ واحدةٌ من الصورة بالحاوية، بحيث يقع ركنها الأيسر العلوي بنقطة الإحداثيات (x,y)، وتمتد وفقًا للطول والعرض المُخصَّصين. بعد ذلك، تتكرر الصورة أفقيًا ورأسيًا عدة مرات بما يكفي لملء الحاوية بالكامل، ولكنك ترى فقط الجزء الظاهر عبر الشكل المطلوب تطبيق نمط التلوين عليه. يُمثِّل المعامل الأخير للباني proportional قيمةً من النوع boolean، وتُخصِّص طريقة تفسير المعاملات الأخرى x و y و width و height؛ فإذا كانت قيمة proportional تُساوِي false، فسيُقاس كُلٌ من width و height باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فسيُقاسان باستخدام مضاعفاتٍ من حجم الشكل المطلوب تطبيق نمط التلوين عليه، وستكون (x,y) مساويةً (0,0) في الركن الأيسر العلوي للشكل (بتعبير أدق، المستطيل المُتضمِّن للشكل). انظر ما يلي على سبيل المثال: patternPaint = new ImagePattern( pict, 0, 0, 1, 1, true ); يُنشِيء هذا الباني كائنًا من النوع ImagePattern، بحيث تُغطِي نسخةٌ واحدةٌ من الصورة الشكل بالكامل. وإذا طبقنا نمط التلوين ذاك على عدة أشكال بأحجام مختلفة، فستمتد الصورة بما يتناسب مع كل شكل. وفي المقابل، إذا أردنا أن يحتوي الشكل على أربع نسخ من الصورة أفقيًا ونسختين رأسيًا، فيُمكِننا استخدام ما يلي: patternPaint = new ImagePattern( pict, 0, 0, 0.25, 0.5, true ); في المقابل، يُحدَّد نمط التلوين المُتدرِج الخطي من خلال تخصيص قطعةٍ مستقيمة ولون عدة نقاط على طول تلك القطعة؛ إذ يُطلَق على تلك النقاط وألوانها اسم وقفات الألوان color stops، وتُضاف الألوان بينها، بحيث يُسنَد لونٌ معينٌ لكل نقطة على الخط، كما تمتد الألوان عموديًا على القطعة المستقيمة لتنتج شريطًا ملونًا لا نهائي. ينبغي أيضًا أن نُخصِّص ما يحدث خارج ذلك الشريط، وهو ما يُمكِننا فعله بتخصيص ما يُعرَف باسم "أسلوب التكرار cycle method" بمكتبة JavaFX؛ إذ تشتمل قيمه المحتملة على مايلي: الثابت CycleMethod.REPEAT، الذي يُكرِّر الشريط الملون بما يكفي لتغطية سطح المستوى بالكامل. الثابت CycleMethod.MIRROR الذي يكرِّر أيضًا الشريط الملون، ولكنه يَعكِس كل تكرارٍ منه لتتوافق الألوان الموجودة على أطراف كل تكرار مع بعضها. الثابت CycleMethod.NO_REPEAT، الذي يَمِدّ اللون الموجود على كل طرف لا نهائيًا. تَعرِض الصورة التالية ثلاثة أنماط تلوين مُتدرِج تستخدِم جميعها نفس خاصيات القطعة المستقيمة ووقفات الألوان، إذ تَستخدِم تلك الموجودة على يسار الصورة أسلوب التكرار MIRROR؛ بينما تَستخدِم الموجودة بمنتصف الصورة أسلوب التكرار REPEAT؛ في حين تَستخدِم تلك الموجودة على اليمين أسلوب التكرار NO_REPEAT. رسمنا القطعة المستقيمة ووضعنا علامات على مواضع وقفات الألوان على طول تلك القطعة، كما هو موضح في الشكل التالي: يُنشِئ الباني التالي نمط تلوين متدرج: linearGradient = new LinearGradient( x1,y1, x2,y2, proportional, cycleMethod, stop1, stop2, . . . ); تُمثِّل المعاملات الأربعة الأولى قيمًا من النوع double، إذ تُخصِّص نقطتي البداية والنهاية للقطعة المستقيمة (x1,y1) و (x2,y2)؛ أما المعامل الخامس proportional، فهو من النوع boolean؛ فإذا كانت قيمته تساوي false، فستُفسَّر نقطتي البداية والنهاية باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فإنها تُفسَّر باستخدام نظام إحداثيات تقع نقطته (0,0) في الركن الأيسر العلوي للشكل المطلوب تطبيق نمط التلوين عليه، بينما تقع نقطته (1,1) في الركن الأيمن السفلي لنفس الشكل. يُمثِّل المعامل السادس cycleMethod إحدى الثوابت CycleMethod.REPEAT و CycleMethod.MIRROR و CycleMethod.NO_REPEAT؛ بينما تشير المعاملات المتبقية إلى وقفات الألوان، ويُمثَل كل وقفةٍ منها كائنًا من النوع Stop. يستقبل باني الصنف Stop معاملين من النوع double و Color؛ إذ يُخصِّص المعامل الأول مكان الوقفة على طول القطعة المستقيمة، وتكون قيمته نسبةً من المسافة بين نقطتي البداية والنهاية. في العموم، لا بُدّ أن يكون مكان الوقفة الأولى عند 0 ومكان الوقفة الأخيرة عند 1؛ كما لا بُدّ أن تكون قيمة مكان كل وقفة أكبر من قيمة مكان الوقفة التي تَسبِقها، إذ يُمكِننا مثلًا إنشاء نمط التلوين المُتدرِج المُستخدَم لتلوين الشكل الموجود على يسار الصورة السابقة على النحو التالي: grad = new LinearGradient( 120,120, 200,180, false, CycleMethod.MIRROR, new Stop( 0, Color.color(1, 0.3, 0.3) ), new Stop( 0.5, Color.color(0.3, 0.3, 1) ), new Stop( 1, Color.color(1, 1, 0.3) ) ); بالنسبة لنمط التلوين المُتدرِج الخطي، سيكون اللون ثابتًا على طول عدة خطوط معينة؛ أما بالنسبة لنمط التلوين الدائري، فسيكون اللون ثابتًا بالنسبة لدوائر معينة؛ إذ تُخصَّص وقفات الألوان على طول نصف قطر دائرة بأبسط حالات التلوين المتدرج الدائري، ويكون اللون ثابتًا للدوائر التي تتشارك نفس المركز. وبالمثل في نمط التلوين الخطي، إذ يُحدِّد أسلوب التكرار اللون المُستخدَم خارج الدائرة. في الحقيقة، الأمر أكثر تعقيدًا من ذلك، فقد يتضمن نمط التلوين الدائري أيضًا نقطةً محورية، والتي تكون هي نفسها مركز الدائرة في الحالة الأبسط، ولكنها في العموم قد تكون أي نقطة أخرى داخل الدائرة. تحدِّد وقفة اللون بالموضع 0 اللون المُستخدَم عند النقطة المحورية، بينما تُحدِّد وقفة اللون بالموضع 1 اللون المُستخدَم على طول الدائرة، وتَستخدِم جميع الرسوم بالصورة التالية نفس نمط التلوين المُتدرِّج، ولكنها تختلف بمكان النقطة المحورية. ستلاحظ وجود علامتين بالرسوم الموجودة بالصف الأول من الصورة، إذ تُمثِّل العلامتان الدائرة والنقطة المحورية. لاحِظ أن اللون المُستخدَم عند النقطة المحورية هو الأحمر، بينما اللون المُستخدَم على طول الدائرة هو الأصفر: يَستقبِل باني الكائن كثيرًا من المعاملات: radialGradient = new RadialGradient( focalAngle,focalDistance, centerX,centerY,radius, proportional, cycleMethod, stop1, stop2, . . . ); يُخصِّص المعاملان الأول والثاني موضع النقطة المحورية، إذ يشير focalDistance إلى المسافة التي تبعدها النقطة المحورية عن مركز الدائرة، وتُخصَّص على أنها نسبةٌ من نصف القطر، أي لا بُدّ أن تكون أصغر من 1؛ بينما يشير focalAngle إلى الاتجاه الذي تتحرك إليه النقطة المحورية بعيدًا عن المركز. في الحالة الأبسط من نمط التلوين المتدرج الدائري، تكون المسافة مساويةً للصفر ولا يكون للاتجاه أي معنًى. تُخصِّص المعاملات الثلاثة التالية مركز الدائرة وطول نصف قطرها؛ في حين تعمل المعاملات المتبقية على نحوٍ مشابه للمعاملات المُستخدَمة بنمط التلوين المتدرج الخطي. التحويلات Transforms تشير النقطة (0,0) تبعًا لنظام الإحداثيات القياسي الخاص بمكون الحاوية إلى ركنها الأيسر العلوي؛ بينما تشير النقطة (x,y) إلى تلك النقطة التي تبعد مسافة طولها x بكسل من الجانب الأيسر للحاوية ومسافة y بكسل من جانبها العلوي، ومع ذلك ليس هناك أي تقييد بخصوص ضرورة استخدام نظام الاحداثيات ذاك، ففي الحقيقة، يُمكِننا أن نضبُط كائن السياق الرسومي لكي يَستخدِم أنظمة إحداثيات أخرى تعتمد على وحدات طول مختلفة، بل ومحاور إحداثيات مختلفة. والهدف من ذلك هو اختيار نظام الإحداثيات الذي يتناسب أكثر مع ما نرغب برسمه. على سبيل المثال، إذا كنا نرسم مخططات معمارية، يُمكِننا عندها استخدام إحداثيات يُمثِّل طول كل وحدةٍ منها القيمة الفعلية لواحد قدم. يُطلَق اسم "التحويلات transforms" على التغييرات الحادثة بنظام الإحداثيات. وهناك ثلاثة أنواع بسيطة من التحويلات يمكن توضيحها في الآتي: أولًا، يُعدِّل الانتقال translate موضع نقطة الأصل (0,0). ثانيًا، يُعدِّل التحجيم scale المقياس المُستخدَم أي وحدة المسافة. ثالثًا، يُطبِّق الدوران rotation دورانًا حول نقطة. تتوفَّر تحويلات أخرى أقل شيوعًا، مثل الإمالة shear التي تُميِل الصورة بعض الشيء. تعرِض الرسمة التوضيحية التالية نفس الصورة بعد إجراء تحويلات مختلفة عليها: لاحظِ أن كل محتويات الصورة بما في ذلك النصوص، قد تأثرت بالتحويلات المُجراة. يُمكِننا أن نُجرِي تحويلًا أكثر تعقيدًا بدمج مجموعةٍ من تلك التحويلات الثلاثة البسيطة، إذ يمكننا مثلًا، تطبيق دورانٍ متبوعٍ بتحجيم، ثم بانتقال، ثم بدورانٍ مرةً أخرى. عندما نُطبِّق عدة تحويلات بالتتابع سيتراكم تأثيرها، ولهذا يكون من الصعب عادةً فهم تأثير التحويلات المعقدة فهمًا كاملًا. تُعدّ التحويلات عمومًا موضوعًا ضخمًا يُمكِن تغطيته بدورة عن الرسوم الحاسوبية computer graphics، ولكننا نناقش هنا فقط بعض الحالات البسيطة لكي نحظى بفكرةٍ عما يُمكِن لتلك التحويلات أن تفعله. يُعدّ التحويل الحالي خاصيةً مُعرَّفةً بكائن السياق الرسومي؛ فهو يُمثِّل جزءًا من الحالة التي يُخزِّنها التابع save() ويستعيدها التابع restore(). من المهم جدًا اِستخدَام التابعين save() و restore() عند التعامل مع التحويلات؛ لكي نمنع تأثير التحويلات التي يجريها برنامجٌ فرعيٌ معين على ما يتبعه من استدعاءات لبرامج فرعية أخرى. وبالمثل من بقية الخاصيات الأخرى المُعرَّفة بكائن السياق الرسومي، يمتد تأثير التحويلات على الأشياء المرسومة لاحقًا بعد تطبيق التحويلات على كائن السياق الرسومي. لنفترض أن g كائن سياق رسومي من النوع GraphicsContext، عندها يُمكِننا أن نُطبِّق انتقالًا على g باستدعاء g.translate(x,y)؛ إذ تمثِّل x و y قيمًا من النوع double، ويتلخص تأثيرها حسابيًا في إضافة (x,y) على الإحداثيات بعمليات الرسم التالية. فإذا استخدمنا مثلًا النقطة (0,0) بعد تطبيق هذا الانتقال، فإننا فعليًا نشير إلى النقطة التي إحداثياتها تساوي (x,y) وفقَا لنظام الإحداثيات القياسي، ويَعنِي ذلك أن جميع أزواج الإحداثيات قد تحركت بمقدارٍ معين. ألقِ نظرةً على التعليمتين التاليتين: g.translate(x,y); g.strokeLine( 0, 0, 100, 200 ); ترسم التعليمتان السابقتان نفس الخط الذي ترسمه التعليمة التالية: g.strokeLine( x, y, 100+x, 200+y ); تؤدي النسخة الثانية من الشيفرة نفس عملية الانتقال ولكن يدويًا، وبدلًا من محاولة التفكير بعملية الانتقال باستخدام أنظمة الإحداثيات، قد يكون من الأسهل لنا لو فكرنا بما يَحدُث للأشكال التي ستُرسَم لاحقًا، فمثلًا، بعد استدعاء التابع g.translate(x,y)، ستتحرك جميع الكائنات التي نرسمها بمسافة x من الوحدات أفقيًا وبمسافة y من الوحدات رأسيًا. وفي مثال آخر، قد يُفضِّل البعض أن تُمثِّل النقطة (0,0) منتصف مكون الحاوية بدلًا من ركنها الأيسر العلوي، ويُمكِننا ذلك باستدعاء الأمر التالي قبل رسم أي شيء: g.translate( canvas.getWidth()/2, canvas.getHeight()/2 ); يُمكِننا أن نُطبِّق تحجيمًا على g باستدعاء التابع g.scale(sx,sy)، إذ تشير المعاملات إلى معامل التحجيم بالاتجاهين x و y. وبعد تنفيذ هذا الأمر، ستُضرَّب إحداثيات x بمقدار يُساوِي sx؛ في حين تُضرَّب إحداثيات y بمقدار يُساوِي sy، ويكون تأثير ذلك على الأشياء المرسومة هو بجعلها أكبر أو أصغر. بالتحديد، تؤدي معاملات التحجيم التي تتجاوز قيمتها العدد 1 إلى تكبير حجم الأشكال؛ في حين تؤدي معاملات التحجيم التي قيمتها أقل من 1 إلى تصغير حجمها. وتُستخدَم عادةً نفس قيمة معامل التحجيم للمحورين، ويُطلَق عليه في تلك الحالة اسم "التحجيم المنتظم uniform scaling". تُعدّ النقطة (0,0) مركز التحجيم، أي النقطة التي لا تتأثر بعملية التحجيم؛ بينما تنتقل النقاط الأخرى باتجاه أو بعكس اتجاه النقطة (0,0)، وإذا لم يكن الشكل موجودًا بالنقطة (0,0)، فلا يقتصر تأثير التحجيم على تغيير حجم الشكل، وإنما نقله أيضًا بعيدًا عن النقطة (0,0) لمعاملات التحجيم الأكبر من 1 وقريبًا من النقطة (0,0) لمعاملات التحجيم الأقل من 1. يُمكِننا أيضًا أن نَستخدِم معاملات تحجيم سالبة، والتي يَنتُج عنها حدوث انعكاس، إذ تنعكس الأشكال أفقيًا مثلًا حول الخط x=0، وذلك بعد استدعاء التابع g.scale(-1,1). يُعدّ الدوران هو النوع الثالث من التحويلات البسيطة، إذ يتسبَّب استدعاء التابع g.rotate(r) بدوران جميع الأشكال التي نرسمها لاحقًا بزاوية r حول النقطة (0,0). تُقاس الزوايا بوحدة الدرجات، وتُمثِّل الزوايا الموجبة دورانًا باتجاه عقارب الساعة؛ بينما تُمثِل الزوايا السالبة دورانًا بعكس اتجاه عقارب الساعة، إلا لو كنا قد طبقنا مُسبقًا معامل تحجيم سالب، إذ يؤدي إلى عَكْس الإتجاه. لا تُعدّ الإمالة shearing عملية تحويل بسيطة، وذلك لأنه من الممكن تنفيذها (مع بعض الصعوبة) عبر متتالية من عمليات الدوران والتحجيم؛ إذ يتمثَّل تأثير الإمالة الأفقية بنقل الخطوط الأفقية إلى اليسار أو إلى اليمين بمقدار يتناسب مع المسافة من المحور الأفقي x، فتنتقل النقطة (x,y) إلى النقطة (x+a*y,y)، لأن a هو مقدار الإمالة ذاك. لا تتضمّن مكتبة JavaFX تابعًا لتطبيق عملية الإمالة، ولكنها تملُك طريقةً لتطبيق عملية تحويل عشوائي. إذا كان لديك معرفةٌ بالجبر الخطي linear algebra، فلربما تعرف أن المصفوفات تُستخدَم لتمثيل التحويلات، وأنه من الممكن تخصيص أي عملية تحويل بتحديد الأعداد الموجودة بالمصفوفة مباشرةً. تَستخدِم مكتبة JavaFX كائنات من النوع Affine لتمثيل التحويلات، ويُطبِّق التابع g.transform(t) تحويلًا t من النوع Affine على كائن السياق الرسومي. لن نتطرّق للرياضيات هنا، ولكن تُجرِي الشيفرة التالية عملية إمالة أفقية بمقدار يساوي a: g.transform( new Affine(1, a, 0, 0, 1, 0) ); قد نحتاج في بعض الأحيان إلى تطبيق عدة تحويلات للحصول على التأثير المطلوب. لنفترض مثلًا أننا نريد أن نعرض كلمة "hello world" بعد إمالتها بزاوية 30 درجة، بحيث تقع نقطتها الأصلية بالنقطة (x,y). في الواقع، لن تتمكَّن الشيفرة التالية من تنفيذ ذلك: g.rotate(-30); g.fillText("hello world", x, y); يكمُن سبب المشكلة في أن عملية الدوران تُطبَّق على كُلٍ من النقطة (x,y) والنص، وبالتالي لا تظِلّ النقطة الأصلية بالموضع (x,y) بعد تطبيق عملية الدوران؛ فكل ما نحتاج إليه هو أن نُنشِئ سلسلةً نصيةً مدارةً بنقطة أصلية واقعة عند (0,0)، ثم يُمكِننا أن ننقلها مسافة (x,y)، وهو ما سيؤدي إلى تحريك النقطة الأصلية من (0,0) إلى (x,y). تُنفِّذ الشيفرة التالية ذلك: g.translate(x,y); g.rotate(-30); g.fillText("hello world", 0, 0); ما ينبغي ملاحظته هنا هو الترتيب الذي تُطبَّق به عمليات التحويل؛ إذ تُطبَّق عملية الانتقال على جميع الأشياء التي تلي الأمر translate، والتي هي ببساطة بعض الشيفرة المسؤولة عن رسم سلسلة نصية مدارة بنقطة أصلية واقعة عند (0,0). بعد ذلك، تنتقل تلك السلسلة النصية المدارة بمسافة قدرها (x,y). يَعنِي ذلك أن السلسلة النصية تُدار أولًا ثم تُنقَل. يُمكِننا أن نُلخِص ذلك في أن عمليات الانتقال تُطبَّق بترتيبٍ معاكس لترتيب ظهور أوامر الانتقال بالشيفرة. بإمكان البرنامج التوضيحي TransformDemo.java تطبيق تشكيلةٍ مختلفة من التحويلات على صورة، كما يُمكِّن المُستخدِم من التحكُّم بمقدار كُلٍ من عمليات التحجيم والإمالة الأفقية والدوران والانتقال. قد يساعدك تشغيل البرنامج على فهم التحويلات أكثر، كما يُمكِّنك من فحص الشيفرة المصدرية للبرنامج لترى طريقة تنفيذ تلك العمليات. تُطبَّق التحويلات ضمن هذا البرنامج وفقًا للترتيب التالي: تحجيم، ثم إمالة، ثم دوران، ثم إنتقال. وإذا فحصت الشيفرة، سترى أن التوابع المسؤولة عن عمليات التحويل مُستدعاة بالترتيب المعاكس: انتقال، ثم دوران، ثم إمالة، ثم تحجيم. هنالك أيضًا عملية انتقال إضافية لتحريك نقطة الأصل إلى مركز الحاوية لكي يصبح مركز عمليات التحجيم والدوران والإمالة هو منتصف الحاوية. الحاويات المكدسة Stacked يُعدّ برنامج الرسم البسيط ToolPaint.java المثال الأخير الذي سنتعرض له بهذا القسم. كنا قد تعرضنا لبرامج رسم في جزئيات سابقة من هذه السلسلة، ولكنها كانت مقتصرةً على رسم المنحنيات فقط، ولكن سيُمكِّن البرنامج الجديد المُستخدِم من اختيار أداةٍ للرسم، إضافةً إلى أداة رسم المنحنيات؛ إذ سيتضمَّن البرنامج أدوات لرسم خمسة أنواع من الأشكال موضحة في الآتي: خطوط مستقيمة. مستطيلات. أشكال بيضاوية . مستطيلات ملونة. أشكال بيضاوية ملونة. عندما يَرسِم المُستخدِم شكلًا بأي من أدوات الرسم الخمسة، فعليه سحَب الفأرة ورسم الشكل من النقطة التي بدأت عندها عملية السحب إلى الموضع الحالي للفأرة. وبكل مرة تتحرك خلالها الفأرة، يُحذَف الشكل السابق ويُرسَم شكلٌ جديد. بالنسبة لأداة رسم الخط مثلًا، يكون التأثير هو رؤية خطٍ مُمتدٍ من نقطة بدء عملية السحب إلى الموضع الحالي لمؤشر الفأرة، ويتحرك هذا الخط مع حركة الفأرة. سيكون من الأفضل لو شغّلت البرنامج وجرَّبت هذا التأثير بنفسك. تَكْمُن صعوبة برمجة ذلك في أن بعض أجزاء الرسمة تُغطَّى وتُكشَف باستمرار، بينما يُحرِّك المُستخدِم مؤشر الفأرة؛ وعندما يُغطَّى جزءٌ من الرسمة الحالية ثم يُكشَف عنه مرةً أخرى، فلا بُدّ أن تَظلّ الرسمة ظاهرة. ويَعنِي ذلك أن البرنامج لا يستطيع أن يرسم الشكل على نفس الحاوية المُتضمِّنة للرسمة؛ لأن ذلك سيَمحِي ما كان موجودًا بذلك الجزء من الرسمة. يتلخص الحل بمكتبة JavaFX باستخدام مكوني حاوية واحدًا فوق الآخر؛ بحيث يحتوي مكون الحاوية السفلي على الرسمة الفعلية؛ بينما يُستخدَم مكون الحاوية العلوي لتنفيذ أدوات رسم الأشكال. ينبغي أن تكون الحاوية العلوية شفافة، أي أن تُملأ بلونٍ درجة شفافيته تُساوِي صفر. تُرسَم الأشكال بالحاوية العلوية بينما يسَحب المُستخدِم مؤشر الفأرة بعد اختياره لأداة رسم معينة، وبالتالي لا تتأثر الحاوية السفلية مع إمكانية اختفاء بعض أجزائها خلف الشكل المرسوم بالحاوية العلوية. في كل مرة يتحرك خلالها مؤشر الفأرة، ستُحذَف مكونات الحاوية العلوية وسيُعاد رسم الشكل الجديد بها؛ وعندما يُحرِر المُستخدِم زر الفأرة بنهاية عملية السحب، ستُحذَف مكونات الحاوية العلوية وسيُرسَم الشكل هذه المرة بالحاوية السفلية ليُصبِح جزءًا من الرسمة الفعلية، وبالتالي تُصبِح الحاوية العلوية شفافةً مجددًا بينما تُصبِح مكونات الحاوية السفلية مرئيةً بالكامل. يُمكِننا حذف محتويات حاوية لتصبح بعدها شفافةً تمامًا باستدعاء التعليمة التالية: g.clearRect( 0, 0, canvas.getWidth(), canvas.getHeight() ); إذ أن g هو كائن السياق الرسومي من النوع GraphicsContext المقابل للحاوية، وتكون الحاويات شفافةً بالكامل بمجرد إنشائها. يُمكننا استخدام كائنٍ من النوع StackPane لوضع حاويةٍ فوق حاوية أخرى، إذ تُرتِّب كائنات الصنف StackPane عُقدها nodes الأبناء بعضها فوق بعض بنفس ترتيب إضافتها إليها؛ وعليه، يُمكِننا إنشاء الحاويتين المُستخدمتين بهذا البرنامج على النحو التالي: canvas = new Canvas(width,height); // حاوية الرسم الرئيسية canvasGraphics = canvas.getGraphicsContext2D(); canvasGraphics.setFill(backgroundColor); canvasGraphics.fillRect(0,0,width,height); overlay = new Canvas(width,height); // الحاوية الشفافة العلوية overlayGraphics = overlay.getGraphicsContext2D(); overlay.setOnMousePressed( e -> mousePressed(e) ); overlay.setOnMouseDragged( e -> mouseDragged(e) ); overlay.setOnMouseReleased( e -> mouseReleased(e) ); StackPane canvasHolder = new StackPane(canvas,overlay); ملاحظة: أُضيفت معالجات أحداث الفأرة إلى الحاوية العلوية لا السفلية، لأنها تُغطِي الحاوية السفلية؛ فعندما يَنقُر المُستخدِم على الرسمة الموجودة بالحاوية السفلية، فإنه فعليًا ينقر على الحاوية العلوية. بالمناسبة، لا تَستخدِم أداة رسم المنحنيات الحاوية العلوية تحديدًا، وإنما تُرسَم المنحنيات مباشرةً بالحاوية السفلية. ونظرًا لأن المُستخدِم لا يستطيع حذف أي جزء من المنحنى بعد رسمه، لا حاجة لوضع نسخةٍ مؤقتةٍ منه بالحاوية العلوية. العمليات على البكسلات يتضمَّن البرنامج ToolPaint.java أداتين إضافيتين، هما "الحذف Erase" و "التلطيخ Smudge"، إذ تَعمَل كلتاهما بالحاوية السفلية مباشرةً. تَعمَل أداة الحذف على النحو التالي: بينما يَسحَب المُستخدِم مؤشر الفأرة بعد اختيار تلك الأداة، يُملأ مربعٌ صغير حول موضع المؤشر باللون الأسود لكي يحذف جزء الرسمة الموجود بهذا الموضع. وفي المقابل، تَعمَل أداة التلطيخ على النحو التالي: يؤدي السَحْب بعد اختيار تلك الأداة إلى تلطيخ اللون أسفل الأداة، تمامًا كما لو سحبت إصبعك عبر لون مبلل. تُبيِّن الصورة التالية لقطة شاشة من البرنامج بعد سَحْب أداة التلطيخ حول مركز مستطيل أحمر: لا تتضمَّن مكتبة JavaFX برنامجًا فرعيًا مبنيًا مُسبقًا لإجراء تلطيخ على صورة، فهو أمرٌ يتطلّب معالجةً مباشرةً لألوان البكسلات كُلٌ على حدى. تتلخص الفكرة الأساسية لتلك الأداة فيما يلي: يَستخدِم البرنامج ثلاث مصفوفات ثنائية البعد بحجم 9 x 9، واحدةٌ لكل مُكوِّن لون؛ أي واحدةٌ تَحمِل المكون الأحمر؛ وواحدة للمكون الأخضر؛ والأخيرة للمكون الأزرق. عندما ينقر المُستخدِم على الفأرة أثناء استخدام أداة التلطيخ، تُنسَخ مكونات لون البكسلات الموجودة داخل المربع المحيط بمؤشر الفأرة من الصورة إلى المصفوفات الثلاثة، وعندما يُحرِّك المُستخدِم الفأرة، تمتزج بعضًا من ألوانها بألوان البكسلات الموجودة بالمكان الجديد لمؤشر الفأرة. وبنفس الوقت، يُنسَخ بعضٌ من ألوان الصورة إلى المصفوفات، أي أن المصفوفات تُسقِط بعضًا من الألوان التي تحملها وتلتقط بعض الألوان من المكان الجديد. إذا فكرت بالأمر قليلًا، سترى أن ذلك يشبه تمامًا ما يحدث عندما تُمرِّر إصبعك بدهانٍ حديث. ينبغي إذًا أن نتمكَّن من قراءة ألوان البكسلات الموجودة بالصورة لكي نُنفِّذ تلك العملية، كما علينا أن نكون قادرين على كتابة ألوان جديدة بتلك البكسلات. في الواقع، من السهل كتابة الألوان بالبكسلات باستخدام الصنف PixelWriter؛ فإذا كان g كائن سياق رسومي من النوع GraphicsContext، وكان هذا الكائن مُرتبطًا بمكون حاوية، عندها يُمكِننا إنشاء كائنٍ من النوع PixelWriter لتلك الحاوية باستدعاء ما يلي: PixelWriter pixWriter = g.getPixelWriter(); ولكي نتمكَّن من ضبط لون البكسل الموجود بالنقطة (x,y) من الحاوية، يُمكِننا استدعاء التابع التالي: pixWriter.setColor( x, y, color ); إذ أن color هو كائنٌ من النوع Color، وتمثِّل x و y إحداثيات بكسل، وهم ليسوا عُرضَةً لأي عملية تحويل قد تُطبَّق على كائن السياق الرسومي. لو كانت مكتبة JavaFX توفِّر طريقةً سهلةً لقراءة ألوان البكسلات من الحاوية، فسنكون قد انتهينا فعلًا، ولكن لسوء الحظ، ليس الأمر بهذه البساطة؛ إذ يبدو أن عمليات الرسم لا تُطبَّق على الحاوية على الفور، وإنما تُخزَّن مجموعةٌ من عمليات الرسم، ثم تُرسَل إلى عتاد جهاز الرسوم دفعةً واحدة، وذلك لرفع الكفاءة. بالتحديد، تُرسَل مجموعة العمليات فقط عندما تُصبِح عملية إعادة رسم الحاوية على الشاشة ضرورية. ويَعنِي ذلك، أننا لو قرأنا لون بكسل من الحاوية، فإننا لا نَضمَن أن تكون القيمة التي نحصل عليها متضمّنةً لكل أوامر الرسم التي طبقناها على الحاوية. وبناءً على ذلك، إذا أردنا أن نقرأ ألوان البكسلات، فسنضطّر لفعل شيءٍ ما لضمان اكتمال جميع عمليات الرسم، مثل أخذ لقطة شاشة للحاوية. يُمكِننا أن نأخذ لقطة شاشة لأي عقدة بمبيان المشهد، وتعيد تلك العملية قيمةً من النوع WritableImage تحتوي على صورة للعقدة بعد تطبيق جميع العمليات قيد الانتظار. تلتقط التعليمة التالية صورةً لعقدة بأكملها: WritableImage nodePic = node.snapshot(null,null); يحتوي كائن الصنف WritableImage على صورةٍ للعقدة كما هي ظاهرةٌ على الشاشة، ويُمكِننا أن نَستخدِمه مثل أي كائن صورة عادي ينتمي إلى الصنف Image. يَستقبِل التابع من التعليمة السابقة معاملات من النوع SnapshotParameter و WriteableImage؛ فإذا مررنا صورةً غير فارغة قابلة للكتابة للمعامل الثاني، فسيَستخدِمها التابع مثل صورة طالما كانت كبيرةً كفاية لتَحمِل صورة العقدة، وربما يكون ذلك أكثر كفاءةً من إنشاء صورةٍ جديدة قابلة للكتابة. بالنسبة للمعامل الأول، يُمكِن اِستخدَامه للتحكُّم بالصورة الناتجة على نحوٍ أكبر، إذ يمكنه تحديدًا الطلب بأن تكون لقطة الشاشة مقتصرةً على مستطيلٍ معين من العقدة فقط. والآن، لكي نُنفِّذ أداة التلطيخ، ينبغي أن نقرأ البكسلات من مستطيلٍ صغيرٍ بالحاوية، إذ تبلغ مساحة ذلك المستطيل 9*9؛ ولإجراء ذلك بكفاءة، سنُمرِّر كائنًا من النوع SnapshotParameter لكي يُخصِّص رغبتنا بلقطة شاشة لذلك المستطيل لا الحاوية بالكامل، وبذلك نكون قد حصلنا على ذلك المستطيل ضمن كائن صورة من النوع WritableImage. وبعد ذلك، سنَستخدِم كائنًا من النوع PixelReader لقراءة ألوان البكسلات من الكائن الذي حصلنا عليه. في الواقع، يتضمَّن تنفيذ ذلك كثيرًا من التفاصيل، ولذلك سنعرض فقط طريقة التنفيذ. يُنشِئ البرنامج كائنًا واحدًا من كل نوعٍ من الأنواع التالية WritableImage و PixelReader و SnapshotParameter لاستخدامها مع جميع لقطات الشاشة، وقد تقع بعض البكسلات خارج الحاوية لبعض لقطات الشاشة، وهو ما يُعقدّ الأمور قليلًا. ألقِ نظرةً على الشيفرة التالية: pixels = new WritableImage(9,9); // a 9-by-9 writable image pixelReader = pixels.getPixelReader(); // a PixelReader for the writable image snapshotParams = new SnapshotParameters(); عندما ينقر المُستخدِم على زر الفأرة، ينبغي أن تُؤخذ لقطة شاشة مربعة لجزء الحاوية المحيط بمكان مؤشر الفأرة الحالي (startX,startY)، ثم تُنسخ بيانات الألوان من تلك اللقطة إلى مصفوفات مكونات الألوان smudgeRed و smudgeGreen و smudgeBlue. ألقِ نظرةً على الشيفرة التالية: snapshotParams.setViewport( new Rectangle2D(startX - 4, startY - 4, 9, 9) ); // 1 canvas.snapshot(snapshotParams, pixels); int h = (int)canvas.getHeight(); int w = (int)canvas.getWidth(); for (int j = 0; j < 9; j++) { // صفٌ في لقطة الشاشة int r = startY + j - 4; // الصف المقابل بالحاوية for (int i = 0; i < 9; i++) { // عمودٌ في لقطة الشاشة int c = startX + i - 4; // العمود المقابل بالحاوية if (r < 0 || r >= h || c < 0 || c >= w) { // 2 smudgeRed[j][i] = -1; } else { Color color = pixelReader.getColor(i, j); // pixelReader gets color from the snapshot smudgeRed[j][i] = color.getRed(); smudgeGreen[j][i] = color.getGreen(); smudgeBlue[j][i] = color.getBlue(); } } } حيث أن: [1] يُمثِّل viewport المستطيل الموجود بالحاوية والذي سيُضمَّن بلقطة الشاشة. [2] تعني أن النقطة (c,r) تقع خارج الحاوية، كما تشير قيمة -1 بالمصفوفة smudgeRed إلى أن البكسل كان خارج الحاوية. والآن، علينا مزج اللون الموجود بمصفوفات مكونات الألوان بمربع البكسلات المحيط بالنقطة (x,y)؛ ولكي نُنفِّذ ذلك، سنأخذ لقطة شاشة مربعة جديدة لمربع البكسلات المحيط بالنقطة (x,y)؛ وبمجرد حصولنا على تلك اللقطة، يُمكِننا إجراء الحسابات الضرورية لعملية المزج، ونكتب بعدها اللون الجديد الناتج إلى الحاوية باستخدام كائن الصنف PixelWriter المسؤول عن الكتابة بالحاوية. ألقِ نظرةً على الشيفرة التالية: snapshotParams.setViewport( new Rectangle2D(x - 4, y - 4, 9, 9) ); canvas.snapshot(snapshotParams, pixels); for (int j = 0; j < 9; j++) { // صف بلقطة الشاشة int c = x - 4 + j; // الصف المقابل بالحاوية for (int i = 0; i < 9; i++) { // عمود بلقطة الشاشة int r = y - 4 + i; // العمود المقابل بالحاوية if ( r >= 0 && r < h && c >= 0 && c < w && smudgeRed[i][j] != -1) { // اِسترجِع لون البكسل الحالي من لقطة الشاشة Color oldColor = pixelReader.getColor(j,i); // 1 double newRed = (oldColor.getRed()*0.8 + smudgeRed[i][j]*0.2); double newGreen = (oldColor.getGreen()*0.8 + smudgeGreen[i][j]*0.2); double newBlue = (oldColor.getBlue()*0.8 + smudgeBlue[i][j]*0.2); // اكتب لون البكسل الجديد إلى الحاوية pixelWriter.setColor( c, r, Color.color(newRed,newGreen,newBlue) ); // امزج جزء من اللون الموجود بالحاوية إلى المصفوفات smudgeRed[i][j] = oldColor.getRed()*0.2 + smudgeRed[i][j]*0.8; smudgeGreen[i][j] = oldColor.getGreen()*0.2 + smudgeGreen[i][j]*0.8; smudgeBlue[i][j] = oldColor.getBlue()*0.2 + smudgeBlue[i][j]*0.8; } } } إذ تعني [1]: احصل على لون جديد للبكسل عن طريق دمج اللون الحالي مع مكونات اللون المُخزَّنة بالمصفوفات. في الواقع، هذه عمليةٌ معقدةٌ نوعًا ما، ولكنها أوضحت طريقة التعامل مع البكسلات إلى حدٍ ما. عليك أن تتذكر أنه من الممكن كتابة ألوان البكسلات إلى الحاوية باستخدام كائنٍ ينتمي إلى الصنف PixelWriter؛ ولكن ينبغي لقراءة البكسلات منها، أن نأخذ لقطة شاشة للحاوية، ثم نَستخدِم كائنًا من الصنف PixelReader لقراءة ألوان البكسلات من كائن النوع WritableImage الذي يحتوي على لقطة الشاشة. عمليات الدخل والخرج للصور يحتوي البرنامج التوضيحي ToolPaint.java على قائمة "File" تحتوي على أمرٍ لتحميل صورةٍ من ملفٍ إلى حاوية؛ وأمرٍ آخر لحفظ صورة من حاوية إلى ملف. بالنسبة لأمر تحميل الصورة، سنحتاج أولًا إلى تحميل الصورة إلى كائنٍ من النوع Image. كنا قد اطلعنا بمقال التعرف على بعض أصناف مكتبة جافا إف إكس JavaFX البسيطة على طريقة تحميل صورةٍ من ملف مورد، وكيفية رسمها ضمن حاوية. وبنفس الطريقة تقريبًا، يُمكِن تحميل صورة من ملف على النحو التالي: Image imageFromFile = new Image( fileURL ); يُمثِّل المعامل سلسلةً نصيةً تُخصِّص موقع الملف بهيئة محدّد موارد موّحد URL، وهو ببساطة مسار الملف مسبوقٌ بكلمة "file:"؛ فإذا كان imageFile كائنًأ من النوع File يحتوي على مسار الملف، فيُمكِننا ببساطة كتابة ما يلي: Image imageFromFile = new Image( "file:" + imageFile ); نَستخدِم عادةً كائن نافذة اختيار ملف من النوع FileChooser لنسمَح للمُستخدِم باختيار ملف -ألقِ نظرةً على مقال مدخل إلى التعامل مع الملفات في جافا-، وعندها سيكون imageFile هو الملف المختار الذي تُعيده تلك النافذة. قد يختار المُستخدِم ملفًا لا يُمكِن قراءته، أو لا يحتوي على صورة، وهنا لا يُبلِّغ باني كائن الصنف Image عن استثناءٍ في تلك الحالة، وإنما يَضبُط متغيرًا مُعرَّفًا بالكائن لكي يُشير إلى وجود خطأ؛ لذلك علينا فحص ذلك المتغير باستخدام الدالة imageFromFile.isError() التي تعيد قيمةً من النوع boolean، وفي حالة وجود خطأ فعلًا، يُمكِننا أن نَستعيد الاستثناء المُتسبِّب بالخطأ باستدعاء الدالة imageFromFile.getException(). بمجرد حصولنا على الصورة وتأكُّدنا من عدم وجود خطأ، يُمكِننا أن نرسمها بالحاوية باستخدام كائن السياق الرسومي الخاص بالحاوية. يَضبُط الأمر التالي حجم الصورة، بحيث تملأ الحاوية كاملةً: g.drawImage( imageFromFile, 0, 0, canvas.getWidth(), canvas.getHeight() ); سنضع جميع ما سبق ضمن تابعٍ، اسمه doOpenImage()، وسيَستدعيه البرنامج ToolPaint لتحميل الصورة المُخزَّنة بالملف الذي اختاره المُستخدِم. ألقِ نظرةً على تعريف التابع: private void doOpenImage() { FileChooser fileDialog = new FileChooser(); fileDialog.setInitialFileName(""); fileDialog.setInitialDirectory( new File( System.getProperty("user.home") ) ); fileDialog.setTitle("Select Image File to Load"); File selectedFile = fileDialog.showOpenDialog(window); if ( selectedFile == null ) return; // لم يختر المُستخدِم أي ملف Image image = new Image("file:" + selectedFile); if (image.isError()) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, an error occurred while\ntrying to load the file:\n" + image.getException().getMessage()); errorAlert.showAndWait(); return; } canvasGraphics.drawImage(image,0,0,canvas.getWidth(),canvas.getHeight()); } والآن، لكي نستخرِج الصورة من حاويةٍ إلى ملف، ينبغي أن نحصل أولًا على الصورة من الحاوية، وذلك بأخذ لقطة شاشة للحاوية بالكامل، وهو ما سنفعله كما يلي: Image image = canvas.snapshot(null,null); لسوء الحظ، لا تتضمَّن مكتبة JavaFX -بإصدارها الحالي على الأقل- خاصية حفظ الصور إلى ملفات؛ ولهذا سنعتمد على الصنف BufferedImage من حزمة java.awt.image بأداة تطوير واجهات المُستخدِمة الرسومية AWT القديمة، إذ يُمثِّل ذلك الصنف صورةً مُخزّنةً بذاكرة الحاسوب، مثل الصنف Image بمكتبة JavaFX. في الواقع، يُمكِننا بسهولة تحويل كائن من النوع Image إلى النوع BufferedImage باستخدام تابعٍ ساكن static مُعرَّف بالصنف SwingFXUtils من حزمة javafx.embed.swing على النحو التالي: BufferedImage bufferedImage = SwingFXUtils.fromFXImage(canvasImage,null); يُمكِننا أن نُمرِّر كائنًا من النوع BufferedImage مثل معاملٍ ثانٍ للتابع ليَحمِل الصورة، والذي من الممكن أن يأخذ القيمة null. بمجرد حصولنا على كائن الصنف BufferedImage، يُمكِننا استخدام التابع الساكن التالي المُعرَّف بالصنف ImageIO من حزمة javax.imageio لكتابة الصورة إلى ملف: ImageIO.write( bufferedImage, format, file ); يُمثِّل المعامل الثاني للتابع السابق سلسلةً نصيةً من النوع String، إذ تخصِّص تلك السلسلة صيغة الملف الذي ستُحفَظ إليه الصورة. يُمكِن حفظ الصور عمومًا بعدة صيغ بما في ذلك "PNG" و "JPEG" و "GIF"، ويستخدم البرنامج ToolPaint صيغة "PNG" دائمًا. يُمثِل المعامل الثالث كائنًا من النوع File، ويُخصِّص بيانات الملف المطلوب حفظه، إذ يُبلِّغ التابع ImageIO.write() عن استثناءٍ، إذا لم يتمكَّن من حفظ الملف؛ وإذا لم يتعرف التابع على الصيغة، فإنه يَفشَل أيضًا، ولكنه لا يُبلِّغ عن استثناء. يُمكِننا الآن أن نُضمِّن كل شيء معًا داخل التابع doSaveImage() بالبرنامج ToolPaint. ألقِ نظرةً على تعريف التابع: private void doSaveImage() { FileChooser fileDialog = new FileChooser(); fileDialog.setInitialFileName("imagefile.png"); fileDialog.setInitialDirectory( new File( System.getProperty("user.home") ) ); fileDialog.setTitle("Select File to Save. Name MUST end with .png!"); File selectedFile = fileDialog.showSaveDialog(window); if ( selectedFile == null ) return; // لم يختر المُستخدِم أي ملف try { Image canvasImage = canvas.snapshot(null,null); BufferedImage image = SwingFXUtils.fromFXImage(canvasImage,null); String filename = selectedFile.getName().toLowerCase(); if ( ! filename.endsWith(".png")) { throw new Exception("The file name must end with \".png\"."); } boolean hasFormat = ImageIO.write(image,"PNG",selectedFile); if ( ! hasFormat ) { // لا ينبغي أن يحدث ذلك نهائيًا throw new Exception( "PNG format not available."); } } catch (Exception e) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, an error occurred while\ntrying to save the image:\n" + e.getMessage()); errorAlert.showAndWait(); } } ترجمة -بتصرّف- للقسم Section 2: Fancier Graphics من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: الخاصيات والارتباطات في جافا مقدمة إلى برمجة واجهات المستخدم الرسومية (GUI) في جافا
-
تعتمد برمجة واجهات المُستخدِم الرسومية GUI على اِستخدَام الأحداث events استخدامًا كبيرًا، متضمنةً الأحداث منخفضة المستوى، مثل أحداث لوحة المفاتيح والفأرة، والأحداث عالية المستوى، مثل تلك الناتجة عن اختيار قائمة أو ضبط قيمة مزلاج. تَنتُج أحداث المزلاج -كما رأينا في في مقال التخطيط الأساسي لواجهة المستخدم في مكتبة جافا إف إكس JavaFX - من خاصية قابلة للمراقبة observable مُعرَّفة بذلك المزلاج، وبالتالي إذا أردنا الإستجابة إلى تغيُّر قيمة مزلاج sldr، فعلينا أن نُسجِّل مُستمِعًا listener بالخاصية valueProperty المُعرَّفة به على النحو التالي: sldr.valueProperty().addListener( . . . ); عندما تتغير قيمة المزلاج، تُولِّد الخاصية valueProperty حدثًا، مما يَسمَح لشيفرة معالجة الأحداث بالاستجابة إلى ذلك التغيير. في الواقع، قيمة valueProperty للمزلاج هي كائنٌ object من النوع DoubleProperty، إذ تُغلِّف كائنات هذا النوع قيمةً من النوع double، وتحتوي على التابعين get() و set() لاسترجاع تلك القيمة وتعديلها. علاوةً على ذلك، تُعدّ تلك القيمة قيمةً قابلةً للمراقبة، مما يَعنِي أنها تُولِّد أحداثًا عندما تتغيَّر، كما أنها تُعَد أيضًا خاصيةً قابلةً للارتباط bindable؛ إذ يستطيع هذا النوع من الخاصيات أن يرتبط بخاصياتٍ أخرى من نفس النوع، وتُجبَر الخاصيات المُرتبطِة بتلك الطريقة على أن يكون لها نفس القيمة. تَستخدِم واجهة تطوير تطبيقات مكتبة JavaFX الخاصيات القابلة للارتباط بكثرة، وسنتعلّم بهذا المقال كيفية استخدامها والدور الذي تلعبه. الأصناف التي سنناقشها بهذا المقال مُعرَّفةٌ بحزمة javafx.beans وحزمها الفرعية، ومع ذلك لن نضطّر لاستيراد import تلك الأصناف من الحزم الموجودة بها إلى برامج JavaFX؛ لأننا غالبًا ما نعتمد على كائناتٍ موجودة بالفعل. القيم القابلة للمراقبة Observable تُعدّ الكثير من متغيرات النسخ instance variables المُعرَّفة بكائنات مكتبة JavaFX، قيمًا قابلةً للمراقبة، أي أنها تُولِّد أحداثًا، حتى أن أغلبها قابلٌ للارتباط أيضًا؛ إذ يُعدّ كلٌ من عرض حاوية canvas وارتفاعها مثلًا، قيمًا قابلةً للمراقبة من النوع DoubleProperty؛ كما يُعدّ النص الموجود بحقل نصي أو عنوان label قيمةً قابلةً للمراقبة من النوع StringProperty؛ إلى جانب أن قائمة أبناء حاوية من النوع Pane تُعَد قيمةً قابلةً للمراقبة من النوع ObservableList<Node>. ويتكَّون مربع الاختيار checkbox من خاصية قابلة للمراقبة من النوع BooleanProperty، والتي تشير إلى اختيار المربع؛ وفي مثال آخر، نجد لون النص المعروض بعنوان، فهو أيضًا قيمةٌ قابلة للمراقبة من النوع ObjectProperty<Paint>. تُولِّد القيم القابلة للمراقبة نوعين من الأحداث هما: الحدث الأول، وهو حدث تغيُّر القيمة، وفي تلك الحالة لا بُدّ أن يُنفِّذ implement معالج الحدث واجهة نوع الدالة ChangeListener<T> ذات المعاملات غير مُحدّدة النوع parameterized، والتي تحتوي على التابع changed(target,oldValue,newValue)؛ إذ يُمثِّل المعامل الأول الكائن القابل للمراقبة الذي تغيَّرت قيمته، ويحتوي المعامل الثاني على القيمة السابقة؛ في حين يحتوي المعامل الثالث على القيمة الجديدة. على سبيل المثال، إذا أدرنا عرض القيمة الحالية لمزلاج مثل نص عنوان، فعندها ينبغي أن نُغيّر النص عندما تتغير قيمة المزلاج، بحيث تتوافق قيمتهما دائمًا. ولهذا، علينا أن نُسجِّل مُستمعًا لحدث تغيُّر القيمة لكي يَضبُط نص العنوان ليتوافق مع قيمة المزلاج، على النحو التالي: slider.valueProperty().addListener( (t,oldVal,newVal) -> label.setText("Slider Value: " + newVal) ); سنرى لاحقًا أن هناك طرائقٌ أفضل لإنجاز نفس المهمة. تُولِّد القيم القابلة للمراقبة نوعًا آخرًا من الأحداث، هو حدث انعدام الصلاحية invalidation؛ إذ يُولَّد ذلك الحدث عندما تُصبِح القيمة الحالية غير صالحةٍ لسببٍ ما. لا يستطيع أي كائنٍ عمومًا قراءة القيمة غير الصالحة لكائنٍ آخر، فعند محاولته قراءتها، سيُعاد حسابها لتُصبِح قيمةً صالحةً مرةً أخرى. بمعنًى آخر، يُشيِر حدث انعدام الصلاحية إلى ضرورة إعادة حساب القيمة، ولكنها لا تُحسَب فعليًا إلا عند الحاجة؛ ويُطلَق على ذلك اسم "التحصيل المُرجَأ lazy evaluation" للقيمة، أي أن عملية إعادة حساب القيمة تُؤجَّل إلى حين الحاجة إلى استخدام القيمة الجديدة لسببٍ ما. يتميز هذا النوع من التحصيل بالكفاءة بالموازنة مع إعادة حساب القيمة بمجرد انعدام صلاحيتها. لنفترض مثلًا، أن عشرات الأشياء قد حدثت، وأدت لجعل قيمةٍ ما غير صالحة مع استخدام التحصيل المُرجَأ، وبالتالي سيُعاد حساب القيمة مرةً واحدةً فقط عند الحاجة إليها بدلًا من أن يُعاد تحصيلها عشرات المرات. والأهم من ذلك هو أن الكائن القابل للمراقبة يُولِّد حدث انعدام صلاحية وحيد لا عشرة أحداث، ويُجنبِّنا ذلك العديد من الاستدعاءات غير الضرورية لمستمعي ذلك الحدث. بالنسبة لمكتبة JavaFX، يُفضَّل عادةً تهيئة مُستمِع إلى حدث انعدام الصلاحية بدلًا من حدث تغيُّر القيمة. يُعرِّف مستمعو الأحداث من النوع InvalidationListener تابعًا وحيدًا، هو التابع invalidated(obv)، إذ يُشير معامل ذلك التابع إلى الكائن القابل للمراقبة الذي أصبحت قيمته غير صالحة. ألقِ نظرةً على الشيفرة التالية على سبيل المثال، مع الملاحظة أن sayHello هو مربع اختيار: sayHello.selectedProperty().addListener( e -> { if (sayHello.isSelected()) label.settext("Hello"); else label.setText("Goodbye"); }); تُسجِّل الشيفرة بالمثال السابق مستمع حدث من النوع InvalidationListener. ويُمكِننا تنفيذ نفس الأمر باستدعاء sayHello.setOnAction()، ولكن هنالك اختلاف: يُستدعَى مُستمعِو الأحداث من النوع ActionListener، فقط إذا كانت حالة مربع الاختيار قد تغيَّرت بواسطة المُستخدِم؛ بينما يُستدعَى مُستمعِي أحداث انعدام الصلاحية متى تغيّرت القيمة، بما في ذلك التغييرات المُجراة نتيجة لاستدعاء sayHello.setSelected(). لاحِظ أننا كنا قد اِستخدَمنا نفس التابع addListener() لتسجيل مستمعي الأحداث من النوع ChangeListener، إذ يَستطيع المُصرِّف compiler أن يُفرِّق بينهما حتى لو مرَّرنا المُستمِع بهيئة تعبير لامدا lambda expression؛ لأن تعبير لامدا لحدث من النوع ChangeListener يَستقبِل ثلاثة معاملات؛ في حين أن تعبير لامدا لحدث من النوع InvalidationListener، يَستقبِل معاملًا واحدًا. يَطرَح ذلك السؤال التالي: ماذا سيحدث لو كانت قيمة الخاصية selectedProperty بمربع الاختيار sayHello قد أصبحت غير صالحةٍ فقط دون أن تتغير؟ هل سيعيد الاستدعاء sayHello.isSelected() القيمة الحالية غير الصالحة أم القيمة الجديدة؟ في الحقيقة، سيؤدي استدعاء sayHello.isSelected() إلى تحصيل القيمة الجديدة وإعادتها. لا يُمكِن عمومًا قراءة القيم غير الصالحة نهائيًا؛ إذ تؤدي أي محاولة لقراءتها إلى إعادة تحصيلها ما يَنتُج عنه حساب القيمة الجديدة. بالنسبة لمكتبة JavaFX، يُمكِن الوصول إلى الخاصيات القابلة للمراقبة ضمن كائنٍ ما باستدعاء توابع نسخ تنتهي أسماؤها بكلمة "Property"، إذ يُستخدَم الاستدعاء slider.valueProperty() مثلًا، للوصول إلى خاصية القيمة بمزلاج؛ بينما يُستخدَم الاستدعاء label.textProperty() للوصول إلى خاصية النص بعنوان. ليست جميع القيم القابلة للقراءة بكائنات مكتبة JavaFx خاصيات؛ في حين تُعدّ الخاصيات القابلة للمراقبة قابلةً للارتباط أيضًا -كما سنرى بالمقال التالي-؛ كما لا تُعدّ القيم البسيطة القابلة للمراقبة قابلة للارتباط، مع أنها ما تزال تُولِّد حدثي تغيُّر القيمة وانعدام الصلاحية، بل ويُمكِن تسجيل مستمعي أحداث للاستجابة إلى التغيُّرات التي تحدث بقيمتها. الخاصيات القابلة للارتباط Bindable يمكن لمعظم الخاصيات القابلة للمراقبة بمكتبة JavaFX الارتباط بخاصيةٍ أخرى من نفس النوع، وتًستثنى من ذلك الخاصيات القابلة للقراءة فقط read-only؛ فلا يُمكِن ربطها بخاصية أخرى، على الرغم من إمكانية ربط خاصيةٍ بخاصية قابلة للقراءة فقط. لنفترض مثالًا بسيطًا بأننا نريد لنص عنوان معين أن يكون دائمًا هو نفس قيمة النص الموجود بحقلٍ نصي معين. يُمكِننا تنفيذ ذلك ببساطة بربط خاصية textProperty المُعرَّفة بالعنوان مع خاصية textProperty المُعرَّفة بالحقل النصي، على النحو التالي: Label message = new Label("Never Seen"); TextField input = new TextField("Type Here!"); message.textProperty().bind( input.textProperty() ); يُجبِر التابع bind() قيمة message.textProperty() بأن تكون هي نفسها قيمة input.textProperty(). وبمجرد تنفيذ التابع bind()، يُنسَخ النص الموجود بالحقل النصي إلى العنوان؛ لكي لا يرى المُستخدِم نص العنوان المبدئي (وهو "Never Seen" بالمثال السابق). عند حدوث أي تغيير على النص الموجود بالحقل النصي أثناء تشغيل البرنامج، يُطبَّق ذلك التغيير على العنوان تلقائيًا، سواءً كان ذلك التغيير نتيجةً لكتابة المُستخدِم بالحقل النصي، أو نتيجةً لاستدعائنا للتابع input.setText() برمجيًا. لاحِظ أن مفهوم الارتباط مُنفَّذ داخليًا باستخدام الأحداث ومستمعي الأحداث، فالهدف منه هو ألا نضطّر لتهيئة مستمعي الأحداث يدويًا، وإنما نَستدعِي فقط التابع bind() لتهيئة كل شيء أوتوماتيكيًا. عندما نَستخدِم bind() لربط خاصية بخاصيةٍ أخرى، لا يُمكِننا عندها تعديل قيمة الخاصية المُرتَبطِة تعديلًا مباشرًا، ولذلك يؤدي أي استدعاء للتابع message.setText() بالمثال السابق إلى حدوث استثناء exception. وبالطبع، بإمكان أي خاصيةٍ الارتباط بخاصيةٍ واحدةٍ أخرى فقط بأي لحظة. يُمكِننا استخدام التابع unbind() الذي لا يَستقبِل أي معاملات، وذلك لإلغاء ارتباطٍ معين على النحو التالي: message.textProperty().unbind(); يحتوي البرنامج التوضيحي BoundPropertyDemo.java على أمثلةٍ متعددة عن الخاصيات المرتبطة. ستَجِد كما في المثال السابق، بأن خاصية النص الموجودة بعنوان مرتبطةً بخاصية النص الموجودة بحقلٍ نصي، بحيث تؤدي الكتابة في الحقل النصي إلى تعديل نص العنوان. تعرِض الصورة التالية لقطة شاشة من البرنامج: يُمثِّل العنوان الموجود على يمين أسفل النافذة مثالًا آخرًا عن الارتباط، إذ يَعرِض العنوان قيمة المزلاج، وسيتغير نص العنوان بمجرد أن يَضبُط المُستخدِم قيمة المزلاج. كما ناقشنا بالأعلى، يُمكِننا تنفيذ ذلك بتسجيل مستمعٍ إلى خاصية المزلاج valueProperty، ولكنها مُنفَّذةٌ بهذا المثال باستخدام الارتباط. ومع ذلك توجد مشكلة، إذ أن خاصية textProprety الموجودة بالعنوان من النوع StringProperty، بينما خاصية valueProperty الموجودة بالمزلاج من النوع DoubleProperty، ولذلك لا يُمكِن ربطهما مباشرةً؛ لأن الإرتباط يَعمَل بنجاح فقط إذا كانت كلتاهما من نفس النوع. لحسن الحظ، يحتوي النوع DoubleProperty على التابع asString() الذي يُحوِّل الخاصية إلى النوع string، أي لو كان slider مزلاجٌ من النوع Slider، فسيُمثِّل مايلي خاصيةً من النوع string تحتوي على التمثيل النصي لقيمة المزلاج العددية التي هي بالأساس من النوع double: slider.valueProperty().asString() وبالتالي يُمكِننا الآن أن نربط خاصية textProprety الموجودة بالعنوان بتلك الخاصية النصية، كما يمكن للتابع asString() أن يَستقبِل صيغة سلسلة تنسيق format string، مثل تلك التي يَستخدِمها التابع System.out.printf، لتنسيق قيمة المزلاج العددية. وقد اِستخدَمنا المتغير sliderVal لتمثيل العنوان بهذا البرنامج، وربطنا خاصيته النصية بكتابة ما يلي: sliderVal.textProperty().bind( slider.valueProperty().asString("Slider Value: %1.2f") ); إذا شئنا الدقة، يُعدّ التعبير slider.valueProperty().asString() من النوع StringBinding وليس StringProperty، ولكننا سنتجاهل التمييز بينهما هنا لأنه ليس مهمًا. تحتوي الكائنات المُمثِّلة لخاصيات Property على توابع كثيرة للتحويل بين الأنواع بالإضافة إلى توابع لإجراء بعض العمليات الأخرى، إذ يُعرِّف النوع DoubleProperty التابع lessThan(number)، الذي يعيد خاصيةً من النوع boolean قيمتها تساوي true عندما تكون قيمة النوعDoubleProperty أصغر من عددٍ معين. فمثلًا، إذا كان btn من النوع Button، فإنه يحتوي على خاصية btn.disableProperty() من النوع BooleanProperty للإشارة إلى ما إذا كان الزر مُعطَّلًا أم لا؛ فإذا أردنا أن نُعطِّل الزر عندما تصبح قيمة مزلاج معين أقل من 20، يُمكِننا ربط خاصية تعطيل الزر على النحو التالي: btn.disableProperty().bind( slider.valueProperty().lessThan(20) ); تتوفَّر توابع أخرى مشابهة، مثل greaterThan() و lessThanOrEqual() و isNotEqualTo() وغيرها، كما تتوفَّر توابع أخرى لإجراء بعض العمليات الحسابية. على سبيل المثال: slider.valueProperty().multiply(2) يُمثِّل التعبير السابق خاصيةً من النوع double قيمتها تساوي ضعف قيمة المزلاج. يُمكِننا استخدام الصنف When المُعرَّف بحزمة javafx.beans.binding لكي نُطبِّق شيئًا مثل المعامل الثلاثي ":?" على خاصيات من النوع boolean، ولكن بقواعد صيغة مختلفة بعض الشيء -ألقِ نظرةً على مقال التعبيرات expressions في جافا-. على سبيل المثال، إذا كان boolProp يُمثِّل خاصيةً من النوع boolean، وكان trueVal و falseVal متغيرين يحتويان على أي قيم بشرط أن تكون من نفس النوع، فإن: new When(boolProp).then(trueVal).otherwise(falseVal) يُمثِّل خاصيةً من نفس نوع المتغيرين trueVal و falseVal، وتكون قيمتها مساويةً لقيمة trueVal إذا كان boolProp يحتوي على القيمة true، أو القيمة falseVal إذا كان boolProp يحتوي على القيمة false. كنا قد اِستخدَمنا بمثال سابق مستمعًا لضبط نص عنوان إلى كلمة "Hello" أو "Goodbye"، وذلك بناءً على اختيار مربع الاختيار sayHello أم لا. تُوضِّح الشيفرة التالية طريقة فعل نفس الشيء، ولكن باستخدام ارتباط الخاصيات: label.textProperty().bind( new When(sayHello.selectedProperty()).then("Hello").otherwise("Goodbye") ); يُمثِل المعامل المُمرَّر بالتعليمة When(sayHello.selectedProperty()) خاصيةً من النوع boolean. نظرًا لأن كُلًا من "Hello" و "Goodbye" قيمٌ من النوع String، فستكون الخاصية الناتجة من التعبير بالكامل سلسلةً نصيةً من النوع String، وهو ما يتوافق مع نوع الخاصية label.textProperty(). بنفس الأسلوب، يَستخدِم البرنامج BoundPropertyDemo.java مربع اختيار للتحكُّم بلون الخلفية الخاص بعنوان كبير باستخدام الارتباط مع كائنٍ من النوع When، ويُمكِنك الاطلاع على الشيفرة كاملة لمزيدٍ من التفاصيل. يُمثِّل البرنامج التوضيحي CanvasResizeDemo.java تطبيقًا مباشرًا ومفيدًا على ارتباط الخاصيات؛ إذ يَعرِض هذا البرنامج 50 قرصًا أحمرًا صغيرًا داخل نافذة، وتتحرك الأقراص ضمن حاويةٍ من النوع Canvas تملأ النافذة، وتتصادم مع بعضها بعضًا، كما تتصادم بحواف الحاوية. كنا قد ضبطنا حجم النافذة في الأمثلة السابقة التي اِستخدَمنا بها حاوية، ليكون غير قابل للتعديل؛ وذلك لأن حجم الحاوية لا يتغير أوتوماتيكيًا، ومع ذلك، بإمكان البرامج أن تُعدِّل حجم الحاوية من خلال ضبط الخاصيات الممثلة لعرض الحاوية وارتفاعها، وذلك باستدعاء canvas.setWidth(w) و canvas.setHeight(h). هناك حلٌ آخر لضبط حجم الحاوية، إذ يُمكِننا ربط هاتين الخاصيتين بمصدرٍ مناسب؛ لأنهما خاصيتان قابلتان للارتباط من النوع DoubleProperty. استخدمنا بهذا البرنامج كائنًا من النوع Pane ليحيط بالحاوية، ويعمل مثل جذرٍ لمبيان المشهد scene graph، ويملأ النافذة. وعندما يُعدِّل المُستخدِم حجم النافذة، يتغير حجم الكائن تلقائيًا ليتوافق مع ما اختاره المُستخدِم. إذا أردنا لحجم الحاوية أن يتغير تلقائيًا هو الآخر ليتماشى مع حجم الكائن، فعلينا أن نربط خاصية عرض الحاوية بخاصية عرض الكائن؛ وبالمثل خاصية ارتفاع الحاوية بخاصية ارتفاع الكائن. تُوضِح الشيفرة التالية طريقة فعل ذلك: canvas.widthProperty().bind( root.widthProperty() ); canvas.heightProperty().bind( root.heightProperty() ); إذا شغَّلت البرنامج وزدت حجم النافذة، فسترى أن حجم الحاوية قد ازداد أيضًا؛ إذ ستتمكَّن الأقراص الحمراء المتصادمة من الانتشار بحيز أكبر؛ وبالمثل، إذا قلصت حجمها، ستتقيد الأقراص ضمن حيِّز أصغر. يعيد البرنامج رسم الحاوية باستمرار، ولهذا لسنا مضطّرين لفعل أي شيء إضافي لإعادة رسمها عند زيادة حجمها. قد نحتاج ببرامج أخرى إلى إعادة رسم محتويات الحاوية عند تغيُّر حجمها، وهو ما يُمكِننا تنفيذه بإضافة مستمعي أحداث إلى خاصيات عرض الحاوية وارتفاعها، لكي يتمكَّنوا من إعادة رسم الحاوية استجابةً للتغيُّرات الواقعة بعرضها أو ارتفاعها. الارتباط ثنائي الاتجاه يُستخدَم التابع bind() لإنشاء ارتباطاتٍ أحادية الاتجاه؛ بمعنى أنها تَعمَل من اتجاهٍ واحدٍ فقط، وهو ما قد لا يكون مناسبًا في بعض الأحيان. لنفترض مثلًا أنه لدينا مربعي اختيار cb1 و cb2 من النوع CheckBox، ونريدهما أن يكونا متطابقين دائمًا. في الواقع، لا يُمكِننا تحقيق ذلك باستخدام الارتباطات أحادية الاتجاه؛ لأننا لو كتبنا ما يلي مثلًا: cb2.selectedProperty().bind( cb1.selectedProperty() ); فستصبح حالة مربع الاختيار cb2 مطابقةً لحالة مربع الاختيار cb1، ولكن لن يؤثر تغيير حالة cb2 على حالة cb1، وإنما يؤدي إلى حدوث استثناء؛ لأنه لا يَصِح تعديل خاصيةٍ قد رُبِطَت فعليًا باستخدام bind(). يَعنِي ذلك، أنه لو نقر المُستخدِم على مربع الاختيار cb2، فسيحدث استثناء، وذلك لأن مربع الاختيار سيحاول أن يُغيّر حالته. يَكْمُن الحل في ما يُعرَف باسم الارتباط ثنائي الاتجاه bidirectional binding؛ فعند ارتباط خاصيتين ارتباطًا ثنائي الاتجاه، عندها يُمكِن لقيمة أيٍّ منهما أن تتغير، وعندها تتغيّر قيمة الأخرى أوتوماتيكيًا إلى نفس القيمة. يُستخدَم التابع bindBidirectional() لإنشاء هذا النوع من الارتباط، فيُمكِننا مثلًا كتابة ما يَلِي لربط مربعي الاختيار: cb2.selectedProperty().bindBidirectional( cb1.selectedProperty() ); والآن، يَستطيع المُستخدِم أن ينقر على أي مربعٍ منهما، وستتغير حالة المربع الآخر تلقائيًا. قد لا يكون ذلك مفيدًا لمربعي اختيار، ولكن أهميته تبرز في حالات أخرى، مثل مزامنة حالة مربع اختيار من النوع CheckBox معروض بالنافذة مع حالة عنصر مربع اختيار ضمن قائمةٍ من النوع CheckMenuItem، إذ سيتمكَّن المُستخدِم من اِستخدَام أحد عنصري الواجهة لإتمام نفس الأمر. في الواقع، يشيع استخدام مُكوِّنات واجهة لها نفس الغرض ضمن القوائم وأشرطة الأدوات باتباع نفس الاسلوب. يُطبِّق البرنامج التوضيحي BoundPropertyDemo.java أمرًا مشابهًا مع أزرار انتقاء من النوع RadioButton وعناصر أزرار انتقاء ضمن قائمة من النوع RadioMenuItem، إذ يَسمَح البرنامج للمُستخدِم بالتحكُّم بلون عنوان معين، إما باستخدام قائمة "Color"، أو مجموعةٍ من أزرار الانتقاء؛ إذ يرتبط كل زر انتقاء ارتباطًا ثنائي الاتجاه مع عنصر زر انتقاء مكافئ معروض بالقائمة. تُوضِّح الشيفرة التالية طريقة تنفيذ ذلك بالتفصيل: Menu colorMenu = new Menu("Color"); Color[] colors = { Color.BLACK, Color.RED, Color.GREEN, Color.BLUE }; String[] colorNames = { "Black", "Red", "Green", "Blue" }; ToggleGroup colorGroup = new ToggleGroup(); for (int i = 0; i < colors.length; i++) { // أنشِئ عنصر قائمة وزر انتقاء مقابل RadioButton button = new RadioButton(colorNames[i]); RadioMenuItem menuItem = new RadioMenuItem(colorNames[i]); button.selectedProperty().bindBidirectional( menuItem.selectedProperty() ); menuItem.setToggleGroup(colorGroup); // 1 menuItem.setUserData(colors[i]); right.getChildren().add(button); // أضف زرًا إلى الحاوية colorMenu.getItems().add(menuItem); // أضف عنصر قائمة إلى القائمة if (i == 0) menuItem.setSelected(true); } colorGroup.selectedToggleProperty().addListener( e -> { // 2 Toggle t = colorGroup.getSelectedToggle(); if (t != null) { // 3 Color c = (Color)t.getUserData(); message.setTextFill(c); } }); حيث أن: [1] تعني: لاحِظ استخدام UserData لتخزين الكائن المُمثِّل للون عنصر القائمة لحاجتنا إليه لاحقًا. [2] تعني: استمع إلى التغييرات الواقعة بالخاصية selectedToggleProperty المُعرَّفة بالصنف ToggleGroup؛ لكي تتمكَّن من ضبط لون العنوان بما يتوافق مع عنصر القائمة الواقع عليه الاختيار. [3] بمعنى: يُمثِّل t العنصر المُختار من النوع RadioMenuItem، إذ يمكنك اِسترجاع اللون من UserData واستخدامه لضبط لون النص. ربما تُصبِح قيمة selectedToggleProperty فارغةً بلحظة معينة، لأن أحدهما وقع عليه الاختيار والآخر غير مختار. تُضاف عناصر القائمة إلى كائنٍ من النوع ToggleGroup -ألقِ نظرةً على مقال مكونات التحكم البسيطة في واجهة المستخدم في مكتبة جافا إف إكس JavaFX-، بينما لا تُضاف أزرار الانتقاء إليه. والآن، إذا نقر المُستخدِم على إحدى أزرار الانتقاء التي لم تكن قيد الاختيار، فستتغير حالة عنصر القائمة المكافئ إلى وضع الاختيار، ولكن قبل أن يحدث ذلك، سيُغير كائن النوع ToggleGroup حالة عنصر القائمة المختار حاليًا إلى وضع عدم الاختيار، وهو ما يؤدي إلى تغيير حالة زر الانتقاء المربوط بعنصر القائمة ذاك إلى وضع عدم الاختيار أيضًا، وبالنهاية، ستتغير حالة كلٍ من زري الانتقاء وعنصري القائمة. سيكون من الأفضل لو فحصت طريقة استخدام البرنامج لخاصية userData المُعرَّفة بأزرار الانتقاء، على الرغم من أنها لا تتعلَّق بالارتباط ثنائي الاتجاه. تَحمِل كل عقدة node بمبيان المشهد بيانات المُستخدِم ضمن خاصية اسمها userData من النوع Object. لا يَستخدِم النظام تلك الخاصية المُعرَّفة بكل عقدة، ولذلك تُعدّ مكانًا مناسبًا لتخزين بعض البيانات المتعلقة بالعقدة، ويُسمَح لتلك البيانات بأن تكون من أي نوع. بالنسبة لهذا البرنامج، ستتكوَّن بيانات المُستخدِم بالعقدة المُمثِلة لعنصر زر انتقاء بالقائمة من قيمةٍ من النوع Color، وسيُستخدَم هذا اللون عند اختيار عنصر القائمة ذاك. نتمنى أن تكون الأمثلة التي تعرَّضنا لها بهذا المقال قد أقنعتك بفعالية ارتباط الخاصيات ودورها في تنفيذ العمليات بين الكائنات ببرامج مكتبة JavaFX. قد يبدو هذا الأسلوب من البرمجة غريبًا بالبداية، ولكنه أسهل وأكثر وضوحًا من التعامل مع الأحداث ومستمعي الأحداث مباشرةً. ترجمة -بتصرّف- للقسم Section 1: Properties and Bindings من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: أمثلة برمجية على الشبكات في جافا: إطار عمل لتطوير الألعاب عبر الشبكة مدخل إلى أساسيات البرمجة بلغة Java: ما هي البرمجة؟ البواني وتهيئة الكائنات Object Initialization في جافا
-
سنناقش بهذا المقال مجموعةً من البرامج التي تَستخدِم الشبكات والخيوط. تتشارك تلك التطبيقات بمشكلة توفير الاتصال الشبكي بين مجموعةٍ من البرامج المُشغَّلة على حواسيبٍ مختلفة. تُعدّ الألعاب ثنائية اللاعبين أو متعددة اللاعبين عبر الشبكة واحدةً من الأمثلة النموذجية على هذا النوع من التطبيقات، ولكن تضطّر تطبيقاتٌ أخرى أكثر جدية لمواجهة نفس المشكلة أيضًا. سندرس بالقسم الأول من هذا المقال إطار عملٍ framework يُمكِن اِستخدامه ببرامج مختلفة تندرج تحت هذا النوع، ثم سنناقش بالجزء المتبقي من هذا المقال ثلاثة تطبيقات مبنيةً على هذا الإطار. في الواقع، قد تكون تلك التطبيقات هي الأكثر تعقيدًا بهذه السلسلة، ولذلك فهمها ليس ضروريًا لفهم أساسيات الشبكات. في الواقع، يعود الفضل لهذا المقال إلى الطالبين Alexander Kittelberger و Kieran Koehnlein؛ فقد أرادا أن يَكتُبا برنامج لعبة بوكر عبر الشبكة مشروعًا نهائيًا بالصف الذي كان الكاتب يُدرسّه. لقد ساعدهما الكاتب بالجزء المُتعلّق بالشبكات بكتابة إطار عملٍ بسيط يدعم الاتصال بين اللاعبين. يتعرَّض التطبيق إلى الكثير من الأفكار الهامة، ولذلك تقررت إضافة إصدارٍ أعم وأكثر تطورًا من إطار العمل إلى السلسلة. يمثّل المثال الأخير بهذا المقال برنامج لعبة بوكر عبر الشبكة. إطار عمل Netgame تشترك جميع الألعاب المختلفة عبر الشبكة بشيءٍ واحد فيما يتعلّق بالشبكات على الأقل؛ حيث لا بُدّ من وجود طريقةٍ لإيصال الأفعال التي يفعلها لاعبٌ معينٌ إلى اللاعبين الآخرين عبر الشبكة. لذلك، سيكون من الأفضل لو أتحنا تلك الإمكانيات بقاعدةٍ مشتركة قابلةٍ لإعادة الاستخدام بواسطة الكثير من الألعاب المختلفة. على سبيل المثال، تحتوي حزمة netgame.common، التي طورها الكاتب على الكثير من الأصناف. لم نتعامل كثيرًا مع الحزم packages بهذه السلسلة بخلاف استخدام الأصناف المبنية مسبقًا built-in. كنا قد شرحنا ماهية الحزم بمقال بيئات البرمجة programming environment في جافا، ولكننا لم نَستخدِم بجميع الأمثلة البرمجية حتى الآن سوى الحزمة الافتراضية default package. تُستخدَم الحزم عمليًا بجميع المشروعات البرمجية بهدف تقسيم الشيفرة إلى مجموعةٍ من الأصناف المرتبطة، ولذلك كان من البديهي تعريف إطار العمل القابل لإعادة الاستخدام بحزمةٍ يُمكِن إضافتها مثل مجموعة متكاملة unit إلى أي مشروع. تُسهِّل بيئات التطوير المتكاملة Integrated development environments مثل Eclipse استخدام الحزم packages: فمن أجل استخدام حزمة netgame ضمن مشروع باِستخدَام إحدى بيئات التطوير المتكاملة، كل ما عليك فعله هو نَسْخ مجلد netgame بالكامل إلى المشروع؛ ونظرًا لاستخدام حزمة netgame مكتبة JavaFX، ينبغي ضبط المشروع ببيئة Eclipse ليدعم استخدام مكتبة JavaFX. إذا كنت تَعمَل بسطر الأوامر، ينبغي أن يحتوي مجلد العمل working directory على المجلد netgame بهيئة مجلدٍ فرعي. إذا لم تكن تَستخدِم إصدارًا قديمًا من JDK يتضمَّن مكتبة JavaFX مسبقًا، فينبغى أن تُضيف خيار JavaFX إلى أوامر javac و java. لنفترض أننا عرَّفنا الأمرين jfxc و jfx ليكافئا الأمرين javac و java بعد إضافة خيارات مكتبة JavaFX إليهما. والآن، إذا أردنا أن نُصرِّف كل ملفات جافا المُعرَّفة بحزمة netgame.common مثلًا، يُمكِننا استخدام الأمر التالي في نظامي Mac OS و Linux: jfxc netgame/common/*.java أما بالنسبة لنظام Windows، يُمكِننا استخدام الشرطة المائلة للخلف بدلًا من الشرطة المائلة للأمام كما يلي: jfxc netgame\common\*.java بالنسبة لإصدارات JDK التي تتضمَّن بالفعل مكتبة JavaFX، يمكن استخدام javac وليس jfxc. ستحتاج إلى أوامرٍ مشابهة لتتمكَّن من تصريف الشيفرة المصدرية لبعض الأمثلة ضمن هذا المقال، وستجدها معرَّفةً ضمن حزمٍ فرعية subpackages أخرى من حزمة netgame. لتتمكَّن من تشغيل البرنامج main المُعَّرف بحزمةٍ معينة، ينبغي أن تكون بمجلدٍ يتضمَّن تلك الحزمة بهيئة مجلدٍ فرعي، كما ينبغي استخدام الاسم الكامل للصنف الذي تريد تشغيله. إذا أردت مثلًا تشغيل الصنف ChatRoomWindow -سيُناقش لاحقًا ضمن هذا المقال- المُعرَّف بحزمة netgame.chat، شغِّل الأمر التالي: jfx netgame.chat.ChatRoomWindow تُعدّ التطبيقات التي سنناقشها ضمن هذا المقال أمثلةً على البرمجة الموزَّعة؛ فهي تتضمَّن عدة حواسيب تتواصل عبر الشبكة. تَستخدِم تلك التطبيقات كما هو الحال في مثال قسم الحوسبة الموزعة من مقال المقال السابق خادمًا server مركزيًا أو برنامجًا رئيسيًا master مُوصَّلًا بمجموعةٍ من العملاء clients؛ حيث تمر جميع الرسائل عبر الخادم، أي لا يستطيع عميلٌ معينٌ إرسال رسالةٍ إلى عميلٍ آخر مباشرةً. سنشير إلى الخادم ضمن هذا المقال باسم "الموزّع hub" بمعنى موزع الاتصالات. هناك عدة أشياء ينبغي أن تفهمها جيدًا: لا بُدّ أن يكون الموزع مُشغَّلًا قبل بدء تشغيل أيٍّ من العملاء. يتصل العملاء بالموزع ليتمكَّنوا من إرسال رسائلهم إليه، ويُعالِج الموزع جميع رسائل العملاء واحدةً تلو الأخرى بنفس ترتيب استقبالها، وبناءً على تلك المعالجة، يُمكِنه أن يرسل عدة رسائلٍ إلى عميلٍ واحدٍ أو أكثر. يَملُك كل عميل مُعرِّف هوية ID خاصٍ به، ويُمثِل ذلك إطار عمل framework عام يُمكِن اِستخدامه بمختلف أنواع التطبيقات، بحيث يُعرِّف كل تطبيق ما يخصُّه من رسائلٍ ومعالجات. لنتعمَّق الآن بتفاصيل البرنامج. كانت الرسائل في قسم الحوسبة الموزعة من المقال السابق تُرسَل جيئةً وذهابًا بين الخادم والعميل وفقًا لمتتاليةٍ مُحدَّدةٍ ومُعرَّفةٍ مُسبقًا، حيث كان الاتصال بين الخادم والعميل بمثابة اتصالٍ بين خيطٍ واحدٍ مُشغَّلٍ ببرنامج الخادم وخيطٍ آخرٍ مُشغّلٍ ببرنامج العميل. بالنسبة لإطار عمل netgame، نريد أن نجعل الاتصال غير متزامن asynchronous؛ بمعنى أننا لا نريد انتظار وصول الرسائل بناءً على متتاليةٍ متوقَّعة مسبقًا، ولجعل هذا ممكنًا، يَستخدِم العميل وفقًا لإطار عمل netgame خيطين للاتصال: الأول لإرسال الرسائل إلى الموزع، والآخر لاستقبال الرسائل منه. وبالمثل، يَستخدِم الموزع وفقًا لإطار عمل netgame خيطين للاتصال مع كل عميل. يكون الموزع عمومًا متصلًا مع عدة عملاء، ويستطيع استقبال الرسائل من أي عميل بأي وقت. ينبغي أن يُعالِج الموزع الرسائل بطريقةٍ ما، ويَستخدِم لإجراء تلك المعالجة خيط اتصالٍ واحد فقط لمعالجة جميع الرسائل. عندما يَستقبِل ذلك الخيط رسالةً معينةً من عميلٍ ما، فإنه يُدْخِلها إلى رتلٍ queue يحتوي على الرسائل المُستقبَلة؛ حيث يتوفَّر رتل واحد فقط تُخزِّن به رسائل جميع العملاء. في المقابل، يُنفِّذ خيط معالجة الرسائل حلقة تكرار loop يقرأ خلالها رسالةً واحدةً من الرتل ويُعالجها، ثم يقرأ رسالةً أخرى ويُعالجها وهكذا. لاحِظ أن الرتل هو كائن من النوع LinkedBlockingQueue. يتضمَّن الموزع خيطًا آخرًا غير مُوضَّحٍ بالصورة السابقة؛ حيث يُنشِئ ذلك الخيط مقبسًا من النوع ServerSocket، ويَستخدِمه للاستماع لطلبات الاتصال الآتية من العملاء. يُسلّم الموزع كل طلب اتصالٍ يَستقبِله إلى كائنٍ آخر من النوع المُتداخِل ConnectionToClient؛ حيث يُعالِج ذلك الكائن الاتصال مع العميل. يَملُك كل عميلٍ متصلٍ رقم مُعرِّف هويةٍ خاصٍ به، حيث تُسنَد مُعرِّفات الهوية 1 و2 و3 .. إلى العملاء عند اتصالهم؛ ونظرًا لأن بإمكانهم غلق الاتصال، قد لا تكون أرقام مُعرِّفات الهوية الخاصة بالعملاء المتصلين متتاليةً ضمن لحظةٍ معينة. يُستخدَم متغيرٌ من النوع TreeMap<Integer,ConnectionToClient> لربط أرقام مُعرِّفات الهوية الخاصة بالعملاء المتصلين مع الكائنات المسؤولة عن معالجة اتصالاتهم. تمثِّل الرسائل المُرسَلة والمُستقبَلة كائنات، ولذلك اِستخدَمنا مجاري دْخَل وخَرْج I/O streams من النوع ObjectInputStream و ObjectOutputStream لقراءة الكائنات وكتابتها. (انظر قسم إدخال وإخراج الكائنات المسلسلة من مقال قنوات الدخل والخرج وعمليتي القراءة والكتابة في جافا). لقد غلَّفنا مجرى الخرج الخاص بالمقبس باستخدام الصنف ObjectOutputStream لنَسمَح بنقل الكائنات عبر المقبس؛ في حين غلَّفنا مجرى الدخل الخاص بالمقبس باستخدام الصنف ObjectInputStream لنَسمَح باستقبال الكائنات. ملاحظة: لا بُدّ أن تُنفِّذ الكائنات المُستخدَمة مع هذا النوع من المجاري الواجهة java.io.Serializable. ستَجِد الصنف Hub مُعرَّفًا بالملف Hub.java في حزمة netgame.common. يجب تخصيص رقم المنفذ الذي سيستمع إليه مقبس الخادم بهيئة معامل يُمرَّر إلى الباني constructor. يُعرِّف الصنف Hub التابع التالي: protected void messageReceived(int playerID, Object message) عندما تصِل رسالةٌ من عميلٍ معينٍ إلى مقدمة رتل الرسائل، يقرأها خيط معالجة الرسائل من الرتل، ويَستدعِي ذلك التابع. بتلك اللحظة فقط، تبدأ المعالجة الفعلية لرسالة العميل. يُمثِّل المعامل الأول playerID رقم مُعرِّف الهوية الخاص بالعميل المُرسِل للرسالة؛ بينما يُمثِّل المعامل الثاني الرسالة نفسها. يُمرِّر التابع نسخةً من تلك الرسالة إلى جميع العملاء المتصلين، ويُمثِّل ذلك المعالجة الافتراضية التي يُجريها الموزع على الرسائل المُستقبَلة؛ ولكي يُنفِّذ ذلك، يُغلِّف الموزع أولًا كُلًا من رقم مُعرِّف الهوية playerID ومحتويات الرسالة message بكائنٍ من النوع ForwardedMessage (مُعرَّفٌ بالملف ForwardedMessage.java بحزمة netgame.common). قد تكون المعالجة الافتراضية مناسبة تمامًا لاحتياجات تطبيقٍ بسيطٍ، مثل برنامج غرفة المحادثة الذي سنناقشه لاحقًا، ولكن سنضطّر إلى تعريف صنفٍ فرعي subclass من الصنف Hub بالنسبة لغالبية التطبيقات، وإعادة تعريف التابع messageReceived() به لإجراء معالجةٍ أكثر تعقيدًا. في الواقع، يحتوي الصنف Hub على عدة توابعٍ أخرى، والتي قد تحتاج إلى إعادة تعريفها أيضًا. نستعرِض بعضًا منها فيما يلي: protected void playerConnected(int playerID): يُستدعَى ذلك التابع في كل مرةٍ يتصل خلالها لاعبٌ بالموزع؛ حيث يُمثِّل المعامل playerID رقم مُعرِّف الهوية الخاص باللاعب الجديد. لا يفعَل هذا التابع شيئًا بالصنف Hub. (لقد أرسَلَ الموزع رسالةً من النوع StatusMessage بالفعل إلى كل عميل ليخبره بوجود لاعبٍ جديد؛ أما الغرض من التابع playerConnected() فهو إجراء أي عملياتٍ أخرى إضافية قد ترغب الأصناف الفرعية المُشتقَة من الصنف Hub بتنفيذها). من الممكن الوصول إلى قائمة مُعرِّفات الهوية لجميع اللاعبين المتصلين حاليًا باستدعاء التابع getPlayerList. protected void playerDisconnected(int playerID): يُستدعَى ذلك التابع في كل مرةٍ يُغلِق خلالها لاعبٌ معينٌ اتصاله مع الموزع (بعد أن يُرسِل الموزع رسالةً من النوع StatusMessage إلى العملاء). يُمثِّل المعامل رقم مُعرِّف الهوية الخاص باللاعب الذي أغلق الاتصال. لا يفعَل هذا التابع شيئًا بالصنف Hub. يُعرِّف الصنف Hub بعض التوابع العامة public المفيدة منها: sendToAll(message): يُرسِل ذلك التابع الرسالة message المُمرَّرة لكل عميلٍ متصل بالموزع حاليًا. لا بُدّ أن تكون الرسالة كائنًا غير فارغ يُنفِّذ الواجهة Serializable. sendToOne(recipientID,message): يُرسِل ذلك التابع الرسالة message المُمرَّرة إلى مُستخدِمٍ واحدٍ فقط؛ حيث يُمثِّل المعامل الأول recipientID رقم مُعرِّف الهوية الخاص بالعميل الذي ينبغي أن يَستقبِل تلك الرسالة، ويُعيد التابع قيمة من النوع boolean تُساوِي false في حالة عدم وجود عميلٍ مقابلٍ لقيمة recipientID. shutDownServerSocket(): يُغلِق هذا التابع مقبس الخادم الخاص بالموزع؛ لكي لا يتمكَّن أي عميلٍ آخر من الاتصال. يُمكِننا اِستخدَامه بالألعاب الثنائية (لاعبين فقط) بعد أن يتصِل اللاعب الثاني مثلًا. setAutoreset(autoreset): يضبُط قيمة خاصية autoreset؛ فإذا كانت قيمتها تساوي true، يُعَاد ضبط المجاري -من النوع ObjectOutputStreams- المُستخدَمة لنقل الرسائل إلى العملاء أوتوماتيكيًا قبل نقل تلك الرسائل. القيمة الافتراضية لتلك الخاصية هي false. يكون لإعادة ضبط مجرًى من النوع ObjectOutputStream معنًى إذا كان هناك كائنٌ قد كُتِبَ بالمجرى بالفعل، ثم عُدّل، ثم كُتِبَ إلى المجرى مرةً أخرى. إذا لم نُعِد ضبط المجرى قبل كتابة الكائن المُعدَّل به، تُرسَل القيمة القديمة غير المُعدَّلة إلى المجرى بدلًا من القيمة الجديدة. في تلك الحالة، يُفضَّل اِستخدَام كائناتٍ ثابتة غير قابلة للتعديل immutable مع الاتصال، ولا تكون عندها إعادة الضبط ضرورية. ينبغي أن تقرأ الشيفرة المصدرية للملف Hub.java للإطلاع على طريقة تنفيذ كل ما سبق وللمزيد من المعلومات في العموم. مع قليلٍ من الجهد والدراسة، ستكون قادرًا على فهم كل شيء موجود ضمن ذلك الملف، ومع ذلك تحتاج فقط إلى فهم الواجهة العامة public والمحمية protected بالصنف Hub والأصناف الأخرى المُعرَّفة بإطار عمل netgame حتى تتمكَّن من كتابة بعض التطبيقات المبنية عليها. لننتقل الآن إلى جانب العميل، ستَجِد تعريف الصنف المُمثِل للعميل بالملف Client.java بحزمة netgame.common؛ حيث يحتوي الصنف على باني كائن constructor يَستقبِل كُلًا من اسم أو عنوان بروتوكول الإنترنت، ورقم المنفذ الخاصين بالموزع الذي سيتصل به العميل. يُسبِّب هذا الباني تعطيلًا إلى أن يُنشَأ الاتصال. الصنف Client هو صنفٌ مجرَّد abstract؛ أي لا بُدّ لأي تطبيق netgame أن يُعرِّف صنفًا فرعيًا مُشتقًا من الصنف Client، وأن يُوفِّر تعريفًا للتابع المُجرّد abstract التالي: abstract protected void messageReceived(Object message); يُستدعَى التابع السابق بكل مرةٍ يَستقبِل خلالها العميل رسالةً من الموزع. قد يُعيد الصنف الفرعي المُشتق تعريف override التوابع المحمية الآتية: playerConnected. playerDisconnected. serverShutdown. connectionClosedByError. انظر الشيفرة المصدرية لمزيدٍ من المعلومات. علاوةً على ذلك، يحتوي الصنف Client على متغير نسخة instance variable، اسمه connectedPlayerIDs من نوع المصفوفة int[]. تُمثِّل تلك المصفوفة قائمة أرقام مُعرِّفات الهوية الخاصة بجميع العملاء المتصلين حاليًا بالموزع. يُعرِّف الصنف Client مجموعةً من التوابع العامة، وسنستعرِض بعضًا من أهمها فيما يلي: send(message): ينقل هذا التابع رسالةً إلى الموزع. قد يكون المعامل message أي كائنٍ غير فارغ بشرط أن يُنفِّذ الواجهة Serializable. getID(): يسترجع هذا التابع رقم مُعرِّف الهوية الذي أسنده الموزع إلى العميل. disconnect(): يغلِق اتصال العميل مع الموزع. لاحظ أن العميل لن يتمكَّن من إرسال أي رسائلٍ أخرى بعد غلق الاتصال. إذا حاول العميل فعل ذلك، سيُبلِّغ التابع send() عن استثناءٍ من النوع IllegalStateException. صُممّ الصنفان Hub و Client عمومًا ليُوفِّرا إطار عملٍ عام يُمكِن استخدامه أساسًا لكثيرٍ من الألعاب الشبكية المختلفة والبرامج المُوزَّعة أيضًا. جميع التفاصيل منخفضة المستوى المتعلقة بالاتصالات الشبكية والخيوط المتعدّدة مخفيةٌ ضمن الأقسام الخاصة private المُعرَّفة بتلك الأصناف، وتتمكَّن بذلك التطبيقات المبنية على تلك الأصناف من العمل وفقًا لمصطلحاتٍ عالية المستوى مثل اللاعبين والرسائل. صُممت تلك الأصناف على عدة مراحل بناءً على الخبرة المُكتسبَة من مجموعةٍ من التطبيقات الحقيقية، ولهذا يُفضَّل إلقاء نظرةٍ على الشيفرة المصدرية لرؤية طريقة استخدام الصنفين Hub و Client للخيوط والمقابس ومجاري الدْخَل والخرج. سنمرّ سريعًا بالجزء المُتبقِي من هذا المقال على ثلاثة تطبيقات مبنيةٍ على إطار عمل netgame وبدون مناقشة التفاصيل. يُمكِنك الإطلاع على الشيفرة المصدرية للتطبيقات الثلاثة كاملةً بحزمة netgame. تطبيق غرفة محادثة بسيط تطبيقنا الشبكي الأول هو "غرفة محادثة" تُمكِّن المُستخدمين من الاتصال بخادمٍ معين، ومن ثم إرسال الرسائل؛ حيث يستطيع المُستخدمون المتواجدون بنفس الغرفة رؤية تلك الرسائل. يتشابه هذا التطبيق مع البرنامج GUIChat من قسم برنامج محادثة عبر الشبكة غير متزامن من مقال الخيوط Threads والشبكات في جافا باستثناء أنه من الممكن لأي عددٍ من المُستخدِمين التواجد بالمحادثة. لا يُعدّ هذا التطبيق لعبة، ولكنه يظهر الوظائف الأساسية التي يُوفِّرها إطار عمل netgame. يتكوَّن تطبيق غرفة المحادثة من برنامجين: الأول هو ChatRoomServer.java، وهو في الواقع برنامجٌ بسيطٌ للغاية، فكل ما يفعله هو إنشاء موزع Hub يَستمِع إلى طلبات الاتصال القادمة من عملاء netgame: public static void main(String[] args) { try { new Hub(PORT); } catch (IOException e) { System.out.println("Can't create listening socket. Shutting down."); } } يُعرَّف رقم المنفذ PORT على أنه ثابتٌ بالبرنامج، ويمكن أن يكون أي رقمٍ عشوائي شرط أن يَستخدِم الخادم والعملاء نفس الرقم. يَستخدِم البرنامج ChatRoom الصنف Hub نفسه لا صنفًا فرعيًا منه. أما البرنامج الثاني من تطبيق غرفة المحادثة فهو البرنامج ChatRoomWindow.java، الذي ينبغي أن يُشغِّله المُستخدمون الذين يريدون المشاركة بغرفة المحادثة. يجب أن يعرِف المُستخدِم اسم أو عنوان بروتوكول الانترنت الخاص بالحاسوب الذي يَعمَل عليه الموزع. (لأغراض اختبار البرنامج، يُمكِنك تشغيل برنامج العميل بنفس الحاسوب الذي يَعمَل عليه الموزع باستخدام localhost اسمًا لحاسوب الموزع). يَعرِض ChatRoomWindow عند تشغيله صندوق نافذة ليطلب من المُستخدِم إدخال تلك المعلومات، ثم يَفتَح نافذةً تُمثِّل واجهة المُستخدِم لغرفة المحادثة؛ حيث تحتوي تلك النافذة على مساحةٍ نصيةٍ كبيرة لعرض الرسائل التي يُرسِلها المُستخدمون إلى الغرفة؛ كما تحتوي على صندوق إدخال نصي حيث يَستطيع المُستخدِم إدخال الرسائل. عندما يُدْخِل المُستخدِم رسالة، تظهر الرسالة بالمساحة النصية الموجودة بنافذة كل مُستخدِم مُتصِّلٍ بالموزع، ولذلك يرى المستخدمين جميع الرسائل المُرسَلة بواسطة أي مُستخدِم. لنفحص الآن طريقة برمجة ذلك. لابُدّ أن تُعرِّف تطبيقات netgame صنفًا فرعيًا مشتقًا من الصنف المجرّد Client. بالنسبة لتطبيق غرفة المحادثة، عرَّفنا العملاء بواسطة الصنف المتداخل ChatClient داخل البرنامج ChatRoomWindow. يُعرِّف البرنامج متغير النسخة connection من النوع ChatClient، والذي يُمثِّل اتصال البرنامج مع الموزع. عندما يُدْخِل المُستخدِم رسالة، تُرسَل الرسالة إلى الموزع باستدعاء التابع التالي: connection.send(message); عندما يَستقبِل الموزع رسالة، فإنه يُغلِّفها ضمن كائنٍ من النوع ForwardedMessage مع إضافة رقم مُعرِّف الهوية الخاص بالعميل المُرسِل للرسالة. بعد ذلك، يُرسِل الموزع نسخةً من هذا الكائن إلى كل عميلٍ متصلٍ بالموزع، بما في ذلك العميل الذي أرسل الرسالة. من الجهة الأخرى، عندما يَستقبِل العميل رسالةً من الموزع، يُستدعَى التابع messageReceived() المُعرَّف بالكائن المنتمي إلى الصنف ChatClient؛ ويُعيد الصنف ChatClient تعريف ذلك التابع لكي يجعله يضيف الرسالة إلى المساحة النصية ببرنامج ChatClientWindow. اختصارًا لما سبق، تُرسَل أي رسالةٍ يُدْخِلها أي مُستخدِم إلى الموزع بينما يُرسِل الموزع نُسخًا من أي رسالةٍ يَستقبِلها إلى كل عميل، ولذلك يتسلَّم جميع العملاء نفس مجرى الرسائل من الموزع. بالإضافة إلى ما سبق، يُنبَّه كل عميلٍ عندما يَتصِل لاعبٌ جديدٌ بالموزع أو عندما يُغلِق لاعبٌ معينٌ اتصاله مع الموزع، وكذلك عندما يَفقِد هو نفسه اتصاله مع الموزع. يعيد البرنامج ChatClient تعريف التوابع التي تُستدعَى عند وقوع تلك الأحداث ليتمكَّن من إضافة رسائلٍ مناسبة إلى المساحة النصية. نستعرِض فيما يلي تعريف صنف العميل الخاص بتطبيق غرفة المحادثة: // 1 private class ChatClient extends Client { // 2 ChatClient(String host) throws IOException { super(host, PORT); } //3 protected void messageReceived(Object message) { if (message instanceof ForwardedMessage) { // لا يوجد أنواع رسائلٍ أخرى متوقعة ForwardedMessage bm = (ForwardedMessage)message; addToTranscript("#" + bm.senderID + " SAYS: " + bm.message); } } // 4 protected void connectionClosedByError(String message) { addToTranscript( "Sorry, communication has shut down due to an error:\n " + message ); Platform.runLater( () -> { sendButton.setDisable(true); messageInput.setEditable(false); messageInput.setDisable(true); messageInput.setText(""); }); connected = false; connection = null; } // 5 protected void playerConnected(int newPlayerID) { addToTranscript( "Someone new has joined the chat room, with ID number " + newPlayerID ); } // 6 protected void playerDisconnected(int departingPlayerID) { addToTranscript( "The person with ID number " + departingPlayerID + " has left the chat room"); } } // end nested class ChatClient حيث أن: [1] يتصِل برنامج ChatClient مع الموزع ويُستخدَم لإرسال الرسائل واستقبالها من وإلى الموزع. تكون الرسائل المُستقبَلة من الموزع من النوع ForwardedMessage وتحتوي على رقم مُعرِّف الهوية الخاص بالمُرسِل وعلى السلسلة النصية التي أرسلها المُستخدِم. [2] يُنشِئ اتصالًا مع خادم غرفة المحادثة بالحاسوب المُخصَّص. [3] يُنفَّذ هذا التابع عند استقبال رسالةٍ من الخادم. ينبغي أن تكون الرسالة من النوع ForwardedMessage، وتُمثِّل شيئًا أرسله أحد المُستخدِمين المتواجدين بغرفة المحادثة. تُضَاف الرسالة ببساطة إلى الشاشة مع رقم مُعرِّف الهوية الخاص بالمُرسِل. [4] يُستدعَى هذا التابع عندما يُغلَق الاتصال مع عميلٍ ما نتيجةً لحدوث خطأ ما (يحدث ذلك عند غلق الخادم). [5] يُظهِر رسالةً على الشاشة عندما ينضم شخصٌ ما إلى غرفة المحادثة. [6] يُظهر رسالةً على الشاشة عندما يغادر شخصٌ ما غرفة المحادثة. يُمكِنك الإطلاع على ملفات الشيفرة المصدرية الخاصة بتطبيق غرفة المحادثة من حزمة netgame.chat. لعبة إكس-أو عبر الشبكة تطبيقنا الثاني سيكون لعبةً بسيطةً للغاية: لعبة إكس-أو الشهيرة؛ حيث يضع لاعبان بتلك اللعبة علاماتٍ بلوحةٍ مكوَّنة من 3 صفوف و3 أعمدة. يلعب أحدهما الرمز X بينما يلعب الآخر O، ويكون الهدف هو الحصول على 3 رموز X أو 3 رموز O بصفٍ واحد. تتكوَّن حالة لعبة إكس-أو بأي لحظة من مجموعةٍ من المعلومات، مثل مكونات اللوحة الحالية؛ واللاعب الذي حان دوره؛ وفي حال انتهت اللعبة، فمن الفائز ومن الخاسر. إذا لم نكن نُطوّر برنامجًا شبكيًا، كان بإمكاننا اِستخدَام متغيرات نسخة لتمثيل حالة اللعبة بحيث يستعين بها البرنامج لتحديد طريقة رسم اللوحة وطريقة الاستجابة لأفعال المُستخدِم مثل نقرات الفأرة، ولكن نظرًا لأننا نُطوِّر برنامجًا شبكيًا من اللعبة، سنَستخدِم ثلاثة كائنات؛ بحيث ينتمي اثنان منهما إلى صنف العميل الذي يُوفِّر واجهة المُستخدِمين للعبة بالإضافة إلى الكائن المُمثِل للموزع والمسؤول عن إدارة الاتصالات مع العملاء. لا تتواجد تلك الكائنات بنفس الحاسوب، وبالتالي لا يُمكِنهم بالتأكيد اِستخدَام نفس متغيرات الحالة؛ ومع ذلك هناك حالةٌ واحدةٌ مُحدَّدة للعبة بأي لحظة، وينبغي أن يكون اللاعبان على درايةٍ بتلك الحالة. يُمكِننا حل تلك المشكلة بتخزين الحالة "الرسمية" للعبة بالموزع، وإرسال نسخةٍ منها إلى كل لاعب كلما تغيرت. لا يستطيع اللاعبان تعديل الحالة مباشرةً، فعندما يُنفِّذ لاعبٌ فِعلًا معينًا مثل وضع قطعةٍ على اللوحة، يُرسَل ذلك الفعِل إلى الموزع بهيئة رسالة. بعد ذلك، يُعدِّل الموزع حالة اللعبة لكي تَعكس نتيجة ذلك الفعل، ثم يُرسِل الحالة الجديدة لكلا اللاعبين، وعندها تُحدَّث نافذتهما لتعكس الحالة الجديدة. يُمكِننا بتلك الطريقة ضمَان ظهور اللعبة بنفس الحالة دائمًا عند كلا اللاعبين. بدلًا من إرسال نسخةٍ كاملةٍ من الحالة بكل مرةٍ تُعدَّل فيها، قد نُرسِل ذلك التعديل فقط. ولكن، سيتطلَّب ذلك استخدام طريقةٍ ما لتشفير التعديلات وتحويلها إلى رسائلٍ يُمكِن إرسالها عبر الشبكة. نظرًا لأن الحالة بهذا البرنامج بسيطةٌ للغاية، فإنه من الأسهل إرسالها كاملة. ستَجِد البرنامج إكس-أو مُعرَّفًا بعدة أصناف بحزمة netgame.tictactoe؛ حيث يُمثِّل الصنف TicTacToeGameState حالة اللعبة، ويتضمَّن التابع التالي: public void applyMessage(int senderID, Object message) يُعدِّل ذلك التابع حالة اللعبة لتَعكِس تأثير الرسالة المُرسَلة من إحدى اللاعبين؛ حيث تُمثِّل تلك الرسالة فعِلًا معينًا أقدم عليه اللاعب، مثل النقر على اللوحة. لا يَعرِف الصنف Hub أي شيءٍ عن تطبيق إكس-أو، ولأن الموزع بهذا التطبيق ينبغي أن يحتفظ بحالة اللعبة، كان من الضروري تعريف صنفٍ فرعي مشتقٍ من الصنف Hub. عرَّفنا الصنف TicTacToeGameHub، وهو صنفٌ بسيطٌ للغاية يُعيد تعريف التابع messageReceived() ليجعله يَستجيب لرسائل اللاعبين بتطبيقها على حالة اللعبة ثم إرسال نسخةٍ من الحالة الجديدة لكلا اللاعبين. يُعيد ذلك الصنف تعريف التابعين playerConnected() و playerDisconnected() أيضًا لكي يُنفِّذا الفعِل المناسب؛ لأن المباراة تُلعَب فقط عندما يكون هناك لاعبين متصلين. انظر الشيفرة المصدرية كاملة: package netgame.tictactoe; import java.io.IOException; import netgame.common.Hub; // 1 public class TicTacToeGameHub extends Hub { private TicTacToeGameState state; // يسجّل حالة اللعبة // 2 public TicTacToeGameHub(int port) throws IOException { super(port); state = new TicTacToeGameState(); setAutoreset(true); } // 3 protected void messageReceived(int playerID, Object message) { state.applyMessage(playerID, message); sendToAll(state); } // 4 protected void playerConnected(int playerID) { if (getPlayerList().length == 2) { shutdownServerSocket(); state.startFirstGame(); sendToAll(state); } } // 5 protected void playerDisconnected(int playerID) { state.playerDisconnected = true; sendToAll(state); } } حيث أن: [1]: يُمثِّل الموزع hub باللعبة إكس-أو. هناك موزعٌ واحدٌ فقط باللعبة، ويتصِل كلا اللاعبين بنفس الموزع، الذي تتواجد بد المعلومات الرسمية عن حالة اللعبة؛ وعند حدوث تغييرات بتلك الحالة، يُرسِل الموزع الحالة الجديدة لكلا اللاعبين ليتأكّد من ظهور نفس الحالة لكليهما. [2]: يُنشِئ موزعًا يَستمِع إلى رقم المنفذ المُخصَّص. يَستدعِي التابع setAutoreset(true) لكي يتأكّد من إعادة ضبط مجرى الخرج الخاص بكل عميل تلقائيًا قبل إرسال أي رسالة. يُعدّ ذلك ضروريًا لأن نفس الكائن المُمثِل للحالة يُرسَل مرارً وتكرارً مع بعض التعديلات. يُمثِّل port رقم المنفذ الذي سيستمِع إليه الموزع. قد يُبلِّغ التابع عن استثناءِ من النوع IOException في حالة عدم التمكُّن من فتح اتصالٍ برقم المنفذ المُخصَّص. [3]: يُنفَّذ هذا التابع عند وصول رسالةٍ من عميل؛ حيث تُطبَّق الرسالة عندئذٍ على حالة اللعبة باستدعاء التابع state.applyMessage(). تُنقَل الحالة بعد ذلك إلى جميع اللاعبين المتصلين إذا كانت قد تغيرت. [4]: يُستدعَى هذا التابع عند اتصال لاعب؛ وإذا كان ذلك اللاعب هو اللاعب الثاني، يُغلَق مقبس الاستماع الخاص بالخادم (يُسمح فقط وجود لاعبين). بعد ذلك، تبدأ اللعبة الأولى وتُنقَل الحالة الجديدة إلى كلا اللاعبين. [5]: يُستدعَى هذا التابع عندما يغلق لاعبٌ اتصاله. يُنهِي ذلك اللعبة ويتسبَّب بإغلاقها عند اللاعب الآخر أيضًا. يحدث ذلك بضبط قيمة state.playerDisconnected إلى true ثم إرسال الحالة الجديدة إلى اللاعب الآخر إذا كان موجودًا لتبليغه بأن اللعبة قد انتهت. يُمثَّل الصنف TicTacToeWindow واجهة اللاعب إلى اللعبة. كما حدث في تطبيق غرفة المحادثة، يُعرِّف ذلك الصنف صنفًا فرعيًا متداخلًا مُشتقًا من الصنف Client لتمثيل اتصال العميل مع الموزع. عندما تتغيّر حالة اللعبة، تُرسَل رسالةٌ إلى كل عميل، ويُستدعَى التابع messageReceived() الخاص بالعميل ليُعالِج تلك الرسالة. يستدعي ذلك التابع بدوره التابع newState() المُعرَّف بالصنف TicTacToeWindow لتحديث النافذة. اِستخدَمنا Platform.runLater() لكي نَستدعيه بخيط تطبيق JavaFX : protected void messageReceived(Object message) { if (message instanceof TicTacToeGameState) { Platform.runLater( () -> newState( (TicTacToeGameState)message ) ); } } والآن، لنُشغِّل تطبيق إكس-أو، ينبغي أن يُشغِّل اللاعبان البرنامج Main.java الموجود بحزمة netgame.tictactoe، حيث يعرِض البرنامج نافذةً للمُستخدِم تُمكِّنه من اختيار بدء لعبة جديدة أو الانضمام إلى لعبةٍ موجودة. إذا بدأ المُستخدِم لعبةً جديدة، يُنشِئ البرنامج موزعًا من النوع TicTacToeHub لإدارة اللعبة، ويَعرِض نافذةً جديدةً من النوع TicTacToeWindow، وتكون متصلةً بالموزع على الفور. تبدأ اللعبة بمجرد اتصال لاعبٍ آخر بذلك الموزع. في المقابل، إذا اختار المُستخدِم الانضمام إلى لعبةٍ موجودة، لا يُنشِئ البرنامج موزعًا جديدًا، وإنما يُنشِئ نافذةً من النوع TicTacToeWindow، وتحاول تلك النافذة الاتصال بالموزع الذي أنشأه اللاعب الأول. ولذلك، لا بُدّ أن يَعرِف اللاعب الثاني اسم الحاسوب الذي يَعمَل عليه برنامج اللاعب الأول. إذا أردت اختبار البرنامج، يُمكِنك تشغيل كل شيء بنفس الحاسوب واستخدام "localhost" اسمًا للحاسوب. يُعدّ هذا البرنامج هو أول برنامج يَستخدِم نافذتين مختلفتين نراه. لاحِظ أن الصنف TicTacToeWindow مُعرَّف على أنه صنفٌ فرعي من الصنف Stage الذي يُستخدَم لتمثيل النوافذ بمكتبة JavaFX. تبدأ برامج JavaFX "بمرحلة رئيسية primary stage" يُنشئِها النظام ويُمرِّرها معاملًا إلى التابع start()، ولكن يستطيع التطبيق إلى جانب ذلك إنشاء أي نوافذ إضافية. لعبة بوكر Poker عبر الشبكة ننتقل الآن إلى التطبيق الذي يُعدّ المُلهم لإطار عمل netgame، وهو تطبيق لعبة بوكر. بالتحديد، نفَّذنا نسخةً من النسخة التقليدية "سحب خمسة بطاقات five card draw" من تلك اللعبة وبلاعبين. هذا التطبيق معقدٌ نوعًا ما، ولن نناقشه تفصيليًا هنا، ولكننا سنوضِّح تصميمه العام. يُمكِنك الإطلاع على الشيفرة المصدرية كاملة بحزمة netgame.fivecarddraw. ينبغي أن تكون على درايةٍ بتلك النسخة من اللعبة لتتمكَّن من فهم البرنامج بالكامل. تتشابه لعبة Poker مع لعبة إكس-أو في العموم، حيث يتوفَّر الصنف Main الذي يُشغِّله كلا اللاعبين. يبدأ اللاعب الأول لعبةً جديدة بينما ينضم اللاعب الثاني إلى اللعبة الموجودة. يُمثِّل الصنف PokerGameState حالة اللعبة؛ بينما يدير الصنف الفرعي PokerHub اللعبة. لعبة Poker أكثر تعقيدًا من لعبة إكس-أو، وهو ما ينعكس على حالة اللعبة، فهي معقدةٌ بالموازنة مع حالة لعبة إكس-أو، وبالتالي فإننا لا نرغب بنشر النسخة الجديدة من حالة اللعبة كاملةً إلى اللاعبين في كل مرة نُجرِي فيها تعديلًا صغيرًا على الحالة. علاوة على ذلك، لا معنى لأن يَعرِف اللاعبان حالة اللعبة بالكامل بما في ذلك بطاقات الخصم وبطاقات اللعب التي يَسحَب منها اللاعبان. لن تكون برامج العملاء مضطّرةً لعرض حالة اللعبة بالكامل للاعبين، ولكنهما قد يَستبدلا تلك البرامج ببرامجٍ أخرى ويتمكَّنا من الغش بسهولة. ولذلك، سيكون الموزع من النوع PokerHub فقط على درايةٍ بكامل حالة اللعبة بهذا التطبيق. سيُمثِّل كائنٌ من الصنف PokerGameState حالة اللعبة من وجهة نظر لاعبٍ واحدٍ فقط. عندما تتغير حالة اللعبة، سيُنشِئ الموزع كائنين مختلفين من النوع PokerGameState يُمثِّلان حالة اللعبة من وجهة نظر كل لاعب، ثم سيُرسِل الكائن المناسب لكل لاعب. يُمكِنك الإطلاع على الشيفرة المصدرية لمزيدٍ من التفاصيل. تُعدّ موازنة يد لاعبين لمعرفة أيهما أكبر الجزء الأصعب بلعبة البوكر، وقد عالجناها بهذا التطبيق بالصنف PokerRank. ربما تجده مفيدًا بألعاب بوكر أخرى. ترجمة -بتصرّف- للقسم Section 5: Network Programming Example: A Networked Game Framework من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: الخيوط Threads والشبكات في جافا تواصل تطبيقات جافا عبر الشبكة تطبيق عملي: بناء لعبة ورق في جافا
-
ناقشنا بالمقال السابق عدة أمثلة على البرمجة الشبكية، حيث تعلّمنا طريقة إنشاء اتصالٍ شبكيٍ وطريقة تبادل المعلومات عبر تلك الاتصالات، ولكننا لم نتعامل مع واحدةٍ من أهم خاصيات البرمجة عبر الشبكة، وهي حقيقة كون الاتصال الشبكي غير متزامن asynchronous. بالنسبة لبرنامج معين بأحد طرفي اتصال شبكي معين، قد تصِل الرسائل من الطرف الآخر بأي لحظة؛ أي يكون وصول رسالةٍ معينة حدثًا خارج نطاق تحكُّم البرنامج المُستقبِل للرسالة، ولذلك ربما تكون الاستعانة بواجهة برمجة تطبيقات API مبنيةٍ على الأحداث وخاصةٍ بالشبكات فكرةً جيدةً تُمكِّننا من التعامل مع الطبيعة غير المتزامنة للاتصالات عبر الشبكة؛ ولكن لا تعتمد جافا في الواقع على تلك الطريقة، حيث تَستخدِم برمجة الشبكات بلغة جافا الخيوط threads عمومًا. مشكلة الدخل والخرج المعطل Blocking I/O كما ناقشنا في مقال تواصل تطبيقات جافا عبر الشبكة، تَستخدِم برمجة الشبكات المقابس sockets، والتي تشير إلى أحد طرفي اتصال شبكي. يَملُك كل مقبس مجرى دْخَل input stream ومجرى خرج output stream، وتُنقَل البيانات المُرسَلة إلى مجرى الخرج بأحد طرفي الإتصال عبر الشبكة إلى مجرى الدْخَل بالطرف الآخر. إذا أراد برنامجٌ معينٌ قراءة البيانات من مجرى الدْخَل الخاص بالمقبس، فعليه استدعاء أحد توابع قراءة البيانات من مجرى الدْخَل. قد تكون البيانات قد وصلت بالفعل قبل استدعاء التابع، وفي تلك الحالة يَسترجِع التابع البيانات ويعيدها على الفور. ومع ذلك، قد يضطّر التابع لانتظار وصول البيانات من الطرف الآخر من الاتصال؛ أي يُعطَّل block كُلًا من التابع والخيط الذي استدعاه إلى أن تَصِل البيانات. قد يتعطَّل أيضًا تابع إرسال البيانات إلى مجرى الخرج الخاص بالمقبس؛ حيث يحدث ذلك إذا حاول البرنامج إرسال البيانات إلى المقبس بسرعةٍ أكبر من سرعة نقل البيانات عبر الشبكة. يَستخدِم المقبس مخزنًا مؤقتًا buffer لحمل البيانات المُفترَض نقلها عبر الشبكة. تذكَّر أن المخزن المؤقت هو مساحةٌ من الذاكرة تَعمَل بطريقةٍ مشابهةٍ للرتل queue. يضع تابع الخرج البيانات بالمخزن المؤقت، ثم يقرأها برنامج منخفض المستوى، ويُرسِلها عبر الشبكة. إذا كان المخزن ممتلئًا، يتعطَّل تابع الخرج إلى أن تتوفَّر مساحةٌ بالمخزن. يَستخدِم الاتصال الشبكي دخلًا وخرجًا مُعطِّلًا؛ لأن عمليات الدْخَل والخَرْج عبر الشبكة قد تتسبَّب بحدوث تعطّيلٍ لفتراتٍ غير محددة، ولذلك لا بُدّ أن تكون البرامج المُستخدِمة للشبكات مهيأةً للتعامل مع ذلك التعطيل. في بعض الحالات، قد يكون إغلاق البرنامج لجميع العمليات الأخرى وانتظار البيانات المطلوبة مقبولًا، مثلما يحدث عندما يُحاوِل برنامج سطر أوامر قراءة البيانات التي ينبغي أن يُدْخِلها المُستخدِم. في الواقع، تُعدّ مدخلات المُستخدِم نوعًا من الدْخل والخَرْج المُعطِّل. ومع ذلك، تَسمَح الخيوط لبعض أجزاء البرنامج بالاستمرار بالعمل بينما بعض الأجزاء الأخرى مُعطَّلة. على سبيل المثال، ربما يكون من المناسب اِستخدَام خيطٍ واحدٍ مع برنامج عميل client شبكي يُرسِل طلباتٍ إلى خادم server، إذا لم يكن هناك أي شيءٍ ينبغي للبرنامج فعله بينما ينتظر رد الخادم. في المقابل، قد يتصل برنامج خادم شبكي مع عدة عملاء بنفس الوقت، وبالتالي بينما ينتظر الخادم وصول البيانات من العميل، سيكون لديه الكثير ليفعله خلال تلك الفترة، مثل الاتصال مع عملاءٍ آخرين. عندما يَستخدِم الخادم خيوطًا مختلفةً لمعالجة الاتصالات مع العملاء المختلفة، لن يؤدي التعطُّل الناتج عن عميلٍ معين إلى إيقاف تعامل الخادم مع العملاء الآخرين. يختلف استخدام الخيوط للتعامل مع مشكلة الدْخَل والخَرْج المعُطِّل جذريًا عن استخدامها لتسريع عملية المعالجة. عندما نَستخدِم الخيوط لزيادة سرعة المعالجة -كما ناقشنا بالقسم مجمع الخيوط وأرتال المهام من مقال الخيوط threads والمعالجة على التوازي في جافا السابق- كان من المنطقي استخدام خيطٍ واحدٍ لكل معالج. إذا كان الحاسوب يحتوي على معالج واحد، فلن يزيد اِستخدَام أكثر من خيط ن سرعة المعالجة إطلاقًا، بل قد يُبطئها نتيجةً للحمل الزائد overhead الناتج عن إنشاء الخيوط وإداراتها. من الجهة الأخرى، إذا كان الهدف هو حل مشكلة الدْخَل والخَرْج المُعطِّل، فسييكون من المنطقي اِستخدَام خيوطٍ بعددٍ أكبر مما يحتويه الحاسوب من معالجات؛ لأن كثيرًا من تلك الخيوط قد يتعطَّل بأي لحظة، وستتنافس بالتالي الخيوط غير المُعطَّلة فقط على زمن المعالجة. إذا أردنا تشغيل جميع المعالجات على الدوام، فلا بُدّ من وجود خيطٍ واحدٍ نشطٍ لكل معالج (في الواقع، أقل من ذلك بعض الشيء لنَسمَح بالاختلافات بين عدد الخيوط النشطة من فترةٍ لأخرى). تقضِي الخيوط ببرامج الخوادم مثلًا معظم الوقت مُعطَّلة بانتظار اكتمال عمليات الدخل والخرج؛ فإذا كانت الخيوط مُعطَّلةً بنسبة 90% من الوقت مثلًا، فسنحتاج إلى خيوطٍ بعددٍ يُقارِب عشرة أضعاف عدد المعالجات؛ أي تستطيع الخوادم الاستفادة من اِستخدَام عددٍ كبيرٍ من الخيوط حتى لو كان الحاسوب يحتوي على معالجٍ واحد. برنامج محادثة عبر الشبكة غير متزامن سنفحص الآن المثال الأول على استخدام الخيوط ببرامج الاتصال الشبكي، وسيكون برنامج محادثة بواجهة مُستخدِم رسومية GUI. اِستخدَمنا بروتوكولًا للاتصال في برامج المحادثة عبر سطر الأوامر CLChatClient.java و CLChatServer.java في مقال تواصل تطبيقات جافا عبر الشبكة المشار إليه بالأعلى؛ حيث بعدما يُدْخِل المُستخدِم بأحد طرفي الاتصال رسالةً، لا بُدّ له أن ينتظر ردًا من الطرف الآخر. سيكون من الأفضل لو أنشأنا برنامج محادثة غير متزامن asynchronous، حيث سيتمكَّن المُستخدِم من الاستمرار بكتابة الرسائل وإرسالها دون انتظار أي ردودٍ من الطرف الآخر، كما ستُعرَض الرسائل الآتية من الطرف الآخر بمجرد وصولها. في الواقع، من الصعب تنفيذ ذلك ببرنامج سطر أوامر، ولكنه بديهي ببرنامجٍ ذي واجهة مُستخدِم رسومية. تتمثل فكرة البرنامج باختصار في إنشاء خيطٍ وظيفته قراءة الرسائل القادمة من الطرف الآخر من الاتصال، وتُعرَض للمُستخدِم بمجرد وصولها، ثم يُعطَّل خيط قراءة الرسائل إلى أن تَصِل رسائلٌ أخرى. تستطيع الخيوط الأخرى الاستمرار بالعمل بينما ذلك الخيط مُعطَّل؛ حيث سيتمكَّن خيط معالجة الأحداث events بالتحديد من الاستجابة لما يفعله المُستخدِم، وسيتمكَّن بالتالي من إرسال الرسائل بعد أن يكتبها المُستخدِم مباشرةً. يَعمَل برنامج "GUIChat" مثل عميلٍ أو خادم.و يحتوي البرنامج على زر "Listen"، وعندما ينقر عليه المُستخدِم، يُنشِئ البرنامج مقبس خادم يَستمِع إلى طلبات الاتصال القادمة، وبالتالي يَعمَل البرنامج خادمًا. يحتوي البرنامج أيضًا على زر "Connect"، وعندما يَنقُر عليه المُستخدِم، يُرسِل البرنامج طلب اتصال، وبالتالي يَعمَل البرنامج عميلًا. يَستمِع الخادم كما جرت العادة إلى رقم منفذٍ port number معين، ولا بُدّ أن يعرف العميل الحاسوب الذي يَعمَل عليه الخادم ورقم المنفذ الذي يَستمِع إليه. تحتوي نافذة البرنامج "GUIChat" على صناديق إدخال تَسمَح للمُستخدِم بإدخال تلك المعلومات. بمجرد بدء الاتصال بين برنامجي "GUIChat"، يستطيع المُستخدِم الموجود بأي طرفٍ من طرفي الاتصال إرسال الرسائل إلى المُستخدِم بالطرف الآخر. تحتوي النافذة على صندوق إدخالٍ يَسمَح للمُستخدِم بكتابة الرسائل، ثم النقر على زر العودة الى بداية السطر لإرسالها. يستجيب خيط معالجة الأحداث إلى الحدث الناشئ عن كتابة الرسالة بإرسالها. وفي المقابل، تُستقبَل الرسائل القادمة بخيطٍ منفصلٍ جُلّ ما يفعله هو انتظار الرسائل القادمة. يكون ذلك الخيط مُعطَّلًا أثناء انتظاره؛ وعند وصول رسالة، يَعرِضها للمُستخدِم. تحتوي نافذة البرنامج على مساحةٍ كبيرةٍ لِعرض الرسائل المُرسَلة والمُستقبَلة إلى جانب بعض المعلومات الأخرى عن الاتصال الشبكي. يُفضَّل أن تُصرِّف compile شيفرة البرنامج GUIChat.java، وتُجرِّبه. إذا أردت تجريبه على حاسوبٍ واحد، يُمكِنك تشغيل نسختين من البرنامج على نفس الحاسوب، وإنشاء الاتصال بين نافذتي البرنامج باستخدام "localhost" أو "127.0.0.1" على أنه اسمٌ للحاسوب. حاوِل أيضًا قراءة الشيفرة المصدرية للبرنامج. سنناقش بعضها فقط فيما يلي. يَستخدِم البرنامج الصنف المُتداخِل ConnectionHandler المُشتَق من الصنف Thread لمعالجة معظم المهام المُتعلّقة بالشبكة، حيث يتولى الخيط المُمثَّل بواسطة الصنف ConnectionHandler مهمة إنشاء الاتصال الشبكي، وقراءة الرسائل القادمة بعد فتح الاتصال. نضمَن عدم تعطُّل واجهة المُستخدِم الرسومية أثناء إنشاء الاتصال بوضعنا الشيفرة المسؤولة عن إنشاء الاتصال واستقبال الرسائل بخيطٍ منفصل. يُعدّ فتح الاتصال عمليةً مُعطِّلة، مثل عملية قراءة الرسائل، وقد تَستغرِق بعض الوقت. يُنشِئ الصنف ConnectionHandler الاتصال بكلا الحالتين؛ أي عندما يَعمَل البرنامج خادمًا أو عميلًا. يُنشَأ ذلك الخيط عندما ينقر المُستخدم على زر "Listen" أو زر "Connect"؛ حيث يؤدي زر "Listen" إلى عمل البرنامج على أنه خادم؛ بينما يؤدي زر"Connect" إلى عمله عميلًا. يُميّز الصنف ConnectionHandler بين تلك الحالتين بتعريف بانيين constructors معروضين بالأسفل. يَعرِض التابع postMessage() رسالةً بالمساحة النصية الموجودة بالنافذة لكي يراها المُستخدِم. ألقِ نظرةً على تعريف كلا البانيين: // 1 ConnectionHandler(int port) { // For acting as the "server." state = ConnectionState.LISTENING; this.port = port; postMessage("\nLISTENING ON PORT " + port + "\n"); try { setDaemon(true); } catch (Exception e) {} start(); } // 2 ConnectionHandler(String remoteHost, int port) { // For acting as "client." state = ConnectionState.CONNECTING; this.remoteHost = remoteHost; this.port = port; postMessage("\nCONNECTING TO " + remoteHost + " ON PORT " + port + "\n"); try { setDaemon(true); } catch (Exception e) {} start(); } حيث: [1]: تعني اِستمِع إلى طلب اتصالٍ برقم منفَّذٍ مُخصَّص. لا يفعل الباني أي عملياتٍ شبكية، وإنما يضبُط فقط بعض متغيرات النسخ ويُشغِّل الخيط. يَستمِع الخيط إلى طلب اتصالٍ واحدٍ فقط، ثم يَغلِق مقبس الخادم. [2]: تعني اِفتح اتصال مع الحاسوب برقم المنفَّذ المُخصَّص. لا يفعل الباني أي عمليات شبكية، وإنما يضبُط فقط بعض متغيرات النسخ ويُشغِّل الخيط. لاحِظ أن state هو متغير نسخة instance variable من نوع التعداد enumerated type التالي: enum ConnectionState { LISTENING, CONNECTING, CONNECTED, CLOSED }; تُمثِّل قيم ذلك التعداد الحالات المختلفة للاتصال الشبكي. يُفضَّل غالبًا التعامل مع الاتصال الشبكي مثل آلة حالة state machine (انظر قسم آلات الحالة من مقال تعرف على أهم الأحداث والتعامل معها في مكتبة جافا إف إكس JavaFX)؛ نظرًا لاعتماد الاستجابة إلى الأحداث المختلفة عادةً على الحالة التي كان عليها الاتصال عند وقوع الحدث. يُحدِّد الخيط ما إذا كان عليه التصرف مثل خادمٍ أو عميل أثناء إنشاء الاتصال من خلال ضبط قيمة المتغير state إلى القيمة LISTENING أو CONNECTING. بمجرد بدء تشغيل الخيط، يُنفَّذ التابع run() المُعرَّف على النحو التالي: // 1 public void run() { try { if (state == ConnectionState.LISTENING) { // أنشِئ اتصالًا بصفة خادم listener = new ServerSocket(port); socket = listener.accept(); listener.close(); } else if (state == ConnectionState.CONNECTING) { // أنشِئ اتصالًا بصفة عميل socket = new Socket(remoteHost,port); } connectionOpened(); // 2 while (state == ConnectionState.CONNECTED) { // 3 String input = in.readLine(); if (input == null) connectionClosedFromOtherSide(); // أغلق المقبس وبلِّغ المُستخدِم else received(input); // بلِّغ الرسالة إلى المُستخدم } } catch (Exception e) { // 4 if (state != ConnectionState.CLOSED) postMessage("\n\n ERROR: " + e); } finally { // Clean up before terminating the thread. cleanUp(); } } حيث أن: [1]: يُستدعَى التابع run() بواسطة الخيط. يَفتَح التابع اتصالًا مثل خادمٍ أو عميل بالاعتماد على نوع الباني المُستخدَم. [2]: جهِّز الاتصال بما في ذلك إنشاء كائن من الصنف BufferedReader لقراءة الرسائل القادمة. [3]: اقرأ سطرًا نصيًا واحدًا من الطرف الآخر من الاتصال وبلِّغه للمُستخدِم. [4]: حدث خطأ. بلِّغ المُستخدِم إذا لم يكن قد أغلق الاتصال، لأنه قد يكون الخطأ المُتوقَّع حدوثه عند غلق مقبس الاتصال. يَستدعِي هذا التابع مجموعةً من التوابع الأخرى لإنجاز بعض الأمور، ولكن بإمكانك فهم الفكرة العامة لطريقة عمله. بعد فتح الاتصال مثل خادمٍ أو عميل، يُنفِّذ التابع run() حلقة while يَستقبِل خلالها الرسائل من الطرف الآخر من الاتصال ويعالجها إلى أن يُغلَق الاتصال. من المهم فهم طريقة غلق الاتصال؛ حيث تُوفِّر نافذة البرنامج GUIChat الزر "Disconnect"، وعندما ينقر عليه المُستخدِم، يُغلَق الاتصال. يَستجِيب البرنامج إلى ذلك الحدث بغلق المقبس المُمثِّل للاتصال، وكذلك بضبط حالة الاتصال إلى CLOSED. في تلك الأثناء، قد يكون خيط معالجة الاتصال مُعطَّلًا بانتظار الرسالة التالية نتيجة لاستدعائه التابع in.readLine(). عندما يُغلِق خيط واجهة المُستخدِم الرسومية المقبس، يفشل ذلك التابع ويُبلِّغ عن استثناءٍ exception يتسبَّب بانتهاء الخيط. إذا كان خيط معالجة الاتصال واقعًا بين لحظتي استدعاء التابع in.readLine() عند غلق المقبس، تنتهي حلقة while لأن حالة الاتصال تتبدَّل من CONNECTED إلى CLOSED. يؤدي غلق نافذة البرنامج إلى غلق الاتصال بنفس الطريقة. علاوةً على ذلك، قد يُغلَق الاتصال بواسطة المُستخدِم على الطرف الآخر، ويُغلَق في تلك الحالة مجرى الرسائل القادمة، ويُعيد التابع in.readLine() بهذا الطرف من الاتصال القيمة null، وهو ما يُشير إلى نهاية المجرى، ويُمثِّل إشارةً إلى أن المُستخدِم الآخر قد أغلق الاتصال. سنُلقِي نظرةً أخيرة على شيفرة البرنامج GUIChat وخاصةً على التوابع المسؤولة عن إرسال الرسائل واستقبالها. يَستدعِي الخيطان تلك التوابع؛ حيث يَستدعِي خيط معالجة الأحداث التابع send() بغرض إرسال رسالةٍ إلى المُستخدِم على الطرف الآخر من الاتصال استجابةً لفِعِل المُستخدِم. قد تؤدي عملية إرسال البيانات إلى حدوث تعطيل -وإن كان احتمال ذلك ضئيلًا- إذا أصبح المخزن المؤقت buffer الخاص بالمقبس ممتلئًا. ربما يأخذ برنامج أكثر تطورًا ذلك الاحتمال بالحسبان. يَستخدِم التابع كائنًا من النوع PrintWriter، اسمه هو out، لإرسال الخرج إلى مجرى الخرج الخاص بالمقبس. لاحِظ أن التابع متزامنٌ ليَمنَع تعديل حالة الاتصال أثناء إجراء عملية الإرسال. انظر شيفرة التابع: // 1 synchronized void send(String message) { if (state == ConnectionState.CONNECTED) { postMessage("SEND: " + message); out.println(message); out.flush(); if (out.checkError()) { postMessage("\nERROR OCCURRED WHILE TRYING TO SEND DATA."); close(); // أغلِق الاتصال } } } وتعني [1]: يُرسِل رسالةً إلى الطرف الآخر من الاتصال ويعرضها على الشاشة. ينبغي أن يُستدعَى هذا التابع عندما تكون حالة الاتصال ConnectionState.CONNECTED فقط. يتجاهل التابع الاستدعاء بالحالات الأخرى. يَستدعِي خيط معالجة الأحداث التابع received() بعد قراءة رسالةٍ من المُستخدِم على الطرف الآخر من الاتصال، حيث يعرِض التابع الرسالة للمُستخدِم. لاحِظ أن التابع متزامن لتجنُّب حالة التسابق race condition التي يُمكِنها أن تحدُث إذا بدَّل خيطٌ آخر حالة الاتصال بينما ما يزال التابع قيد التنفيذ. ألقِ نظرةً على شيفرة التابع: // 1 synchronized private void received(String message) { if (state == ConnectionState.CONNECTED) postMessage("RECEIVE: " + message); } [1] يُستدعَى هذا التابع بواسطة التابع run() عند استقبال رسالةٍ من الطرف الآخر من الاتصال. يُظهِر التابع الرسالة على الشاشة إذا كانت حالة الاتصال CONNECTED فقط؛ لأنها قد تَصِل بعد أن ينقر المُستخدم على زر "Disconnect"، وبالتالي لا ينبغي أن يراها المُستخدِم. خادم شبكي متعدد الخيوط تُستخدَم الخيوط عادةً ببرامج الخوادم؛ لأنها تَسمَح للخادم بالتعامل مع مجموعةٍ من العملاء بنفس الوقت. عندما يبقى عميلٌ معينٌ متصلًا لفترةٍ طويلةٍ نسبيًا، لا ينبغي أن يضطّر العملاء الآخرون إلى انتظار الخدمة، وحتى إن كان من المتوقَّع أن يكون تعامل الخادم مع كل عميلٍ قصيرًا للغاية، حيث لا يُمكِننا أن نفترض أن ذلك سيكون هو الحال دائمًا؛ فقد يُسيء عميلٌ معينٌ التصرف، ويبقى متصلًا دون أن يُرسِل البيانات التي يتوقَّعها الخادم؛ لذلك ينبغي أن نأخذ تلك الاحتمالية بالحسبان، لأننا إن لم نفعل، فإنه قد يؤدي إلى غلق الخيط تمامًا. في المقابل، إذا اعتمد الخادم على الخيوط، سيكون هناك خيوطٌ أخرى بإمكانها الرد على العملاء الآخرين. يُعدّ المثال التوضيحي DateServer.java من مقال تواصل تطبيقات جافا عبر الشبكة برنامج خادم بسيطٍ للغاية؛ فهو لا يَستخدِم الخيوط، ولذلك لا بُدّ أن ينتهي الخادم من اتصاله مع عميلٍ معين قبل أن يَقبَل اتصالًا من عميلٍ آخر. سنرى الآن كيفية تحويل ذلك البرنامج إلى خادمٍ مبني على الخيوط. في الواقع، يُعَد هذا الخادم بسيطًا جدًا لدرجة أن اِستخدَام الخيوط معه لا معنى له، ولكن الفكرة أنه من الممكن تطبيق نفس تلك التقنيات على الخوادم الأكثر تعقيدًا. ألقِ نظرةً على تمرين 12.5 على سبيل المثال. لنناقش الآن المحاولة الأولى من البرنامج DateServerWithThreads.java، حيث يُنشِئ هذا البرنامج خيطًا جديدًا بكل مرة يَستقبِل خلالها طلب اتصال بدلًا من مُعالجة الاتصال بنفسه باستدعاء برنامجٍ فرعي subroutine. يُنشِئ البرنامج main الخيط ويُمرِّر إليه الاتصال. يَستغرِق ذلك وقتًا قصيرًا للغاية، والأهم أنه لن يتسبَّب بحدوث تعطيل. في المقابل، يُعالِج تابع الخيط run() الاتصال بنفس الكيفية التي عالجنا بها الاتصال بالبرنامج الأصلي. تُعد برمجة ذلك سهلة، ويمكنك إلقاء نظرةٍ على النسخة الجديدة من البرنامج بعد إجراء التعديلات؛ حيث يمكننا ملاحظة أن الباني constructor الخاص بخيط الاتصال لا يَفعَل شيئًا تقريبًا، ولا يُسبِّب تعطيلًا، وهذا أمرٌ مهم؛ لأنه يُنفَّذ بالخيط الرئيسي: import java.net.*; import java.io.*; import java.util.Date; // 1 public class DateServerWithThreads { public static final int LISTENING_PORT = 32007; public static void main(String[] args) { ServerSocket listener; // اِستمِع لطلبات الاتصال القادمة Socket connection; // للتواصل مع البرنامج المتصِل // استمر باستقبال طلبات الاتصال ومعالجتها للأبد إلى أن يحدث خطأ ما try { listener = new ServerSocket(LISTENING_PORT); System.out.println("Listening on port " + LISTENING_PORT); while (true) { // اِقبَل طلب الاتصال التالي وأنشِئ خيطًا لمعالجته connection = listener.accept(); ConnectionHandler handler = new ConnectionHandler(connection); handler.start(); } } catch (Exception e) { System.out.println("Sorry, the server has shut down."); System.out.println("Error: " + e); return; } } // end main() // 2 private static class ConnectionHandler extends Thread { Socket client; // يُمثِّل اتصالًا مع عميل ConnectionHandler(Socket socket) { client = socket; } public void run() { // (code copied from the original DateServer program) String clientAddress = client.getInetAddress().toString(); try { System.out.println("Connection from " + clientAddress ); Date now = new Date(); // التوقيت والتاريخ الحالي PrintWriter outgoing; // مجرى لإرسال البيانات outgoing = new PrintWriter( client.getOutputStream() ); outgoing.println( now.toString() ); outgoing.flush(); // تأكّد من إرسال البيانات client.close(); } catch (Exception e){ System.out.println("Error on connection with: " + clientAddress + ": " + e); } } } } //end class DateServerWithThreads إذ أن: [1]: يمثّل هذا البرنامج خادمًا يَستقبِل طلبات الاتصال برقم المنفذ الذي يُخصِّصه الثابث LISTENING_PORT. يرسِل البرنامج عند فتح اتصال التوقيت الحالي إلى المقبس المتصِل، ويستمر باستقبال طلبات الاتصال ومعالجتها إلى أن يُغلَق بالضغط على CONTROL-C على سبيل المثال. تُنشِئ هذه النسخة من البرنامج خيطًا جديدًا لكل طلب اتصال. [2]: يُعرِّف خيطًا لمعالجة الاتصال مع عميلٍ واحد. انظر إلى نهاية التابع run()، حيث أضفنا clientAddress إلى رسالة الخطأ؛ لنتمكَّن من معرفة الاتصال الذي تشير إليه رسالة الخطأ. نظرًا لأن الخيوط تُنفَّذ على التوازي، قد يَختلِط خرج الخيوط المختلفة؛ أي ليس من الضروري أن تَظهَر الرسائل الخاصة بخيطٍ معين معًا، وإنما قد يَفصِل بينها رسائلٌ من خيوطٍ أخرى. يُمثِّل ذلك واحدًا فقط من ضمن التعقيدات الكثيرة التي ينبغي الانتباه لها عند تعاملنا مع الخيوط. استخدام مجمع الخيوط لا يُعدّ إنشاء خيطٍ جديدٍ لكل اتصال الحل الأفضل، خاصةً إذا كانت تلك الاتصالات قصيرة. لحسن الحظ، يُمكِننا أن نَستخدِم مُجمّع خيوط الذي ناقشناه بقسم مجمع الخيوط وأرتال المهام من المقال السابق. يُعدّ البرنامج DateServerWithThreadPool.java نسخةً مُحسنَّةً من الخادم؛ حيث يَستخدِم مجمع خيوط. يُنفِّذ كل خيطٍ ضمن ذلك المُجمّع حلقة تكرار لا نهائية infinite loop يُعالِج كل تكرارٍ منها اتصالًا. علينا أن نَجِد طريقةً تُمكِّن البرنامج main من إرسال الاتصالات إلى الخيوط. ومن البديهي أن نَستخدِم رتلًا مُعطِّلًا blocking queue لذلك الغرض، وسيكون اسمه هو connectionQueue. تقرأ خيوط معالجة الاتصال الاتصالات من الرتل؛ ونظرًا لكونه رتلًا مُعطِّلًا، تتعطَّل الخيوط عندما يكون الرتل فارغًا، وتستيقظ عند إتاحة اتصالٍ جديدٍ بالرتل. لن نحتاج إلى أي تقنيات مزامنة أو اتصال أخرى؛ فكل شيءٍ مبنيٌ مُسبقًا بالرتل المُعطِّل. تعرض الشيفرة التالية التابع run() الخاص بخيوط معالجة الاتصال: public void run() { while (true) { Socket client; try { client = connectionQueue.take(); // تعطَّل إلى أن يُتاح عنصر جديد } catch (InterruptedException e) { continue; // إذا قُوطِعَ، عدّ إلى بداية حلقة التكرار } String clientAddress = client.getInetAddress().toString(); try { System.out.println("Connection from " + clientAddress ); System.out.println("Handled by thread " + this); Date now = new Date(); // التاريخ والتوقيت الحالي PrintWriter outgoing; // مجرى لإرسال البيانات outgoing = new PrintWriter( client.getOutputStream() ); outgoing.println( now.toString() ); outgoing.flush(); // تأكّد من إرسال البيانات فعليًا client.close(); } catch (Exception e){ System.out.println("Error on connection with: " + clientAddress + ": " + e); } } } يُنفِّذ البرنامج main() حلقة تكرارٍ لا نهائية تَستقبِل الاتصالات وتُضيفها إلى الرتل: while (true) { // اقبل طلب الاتصال التالي وأضِفه إلى الرتل connection = listener.accept(); try { connectionQueue.put(connection); // تعطَّل إذا كان الرتل ممتلئًا } catch (InterruptedException e) { } } لاحِظ أن الرتل بهذا البرنامج من النوع ArrayBlockingQueue<Socket>؛ أي أن لديه سعةً قصوى، وبالتالي إذا كان الرتل ممتلئًا، سيؤدي تنفيذ عملية put() إلى حدوث تعطيل. ولكن ألسنا نريد تجنُّب حدوث أي تعطيلٍ بالبرنامج main()؟ لأنه إذا تعطُّل، فلن يستقبل الخادم أي اتصالاتٍ أخرى، ويضطّر العملاء الذي يحاولون الاتصال إلى الانتظار. أليس من الأفضل اِستخدام الصنف LinkedBlockingQueue بسعةٍ غير محدودة؟ في الحقيقة، تُعدّ الاتصالات الموجودة بالرتل المُعطِّل قيد الانتظار على أية حال، أي أنها لم تُخدَّم بعد؛ وإذا ازداد حجم الرتل بدرجةٍ غير معقولة، فستضطّر الاتصالات الموجودة بالرتل إلى الانتظار إلى فتراتٍ غير معقولةٍ أيضًا. وبالتالي، إذا ازداد حجم الرتل إلى ما لانهاية، لا يَعنِي ذلك سوى أن الخادم يَستقبِل طلبات اتصال بسرعةٍ أكبر من سرعة قدرته على معالجة تلك الاتصالات. قد يحدث ذلك لعدة أسباب: ربما يكون خادمك غير قوي كفاية لمعالجة كمية الاتصالات المطلوبة، وينبغي في تلك الحالة أن تشتري خادمًا جديدًا؛ أو ربما بسبب عدم احتواء مجمع الخيوط على عددٍ كافٍ من الخيوط بدرجة تَسمَح بالاستفادة من إمكانيات الخادم بالكامل، وينبغي في تلك الحالة زيادة حجم مجمع الخيوط ليتوافق مع إمكانيات الخادم؛ أو ربما يتعرَّض الخادم لهجوم الحرمان من الخدمات Denial Of Service، أي أن هناك شخصٌ معينٌ يحاول متعمّدًا إرسال طلبات اتصال إلى الخادم بقدرٍ أكبر مما يَستطيع احتماله في محاولة لمنع العملاء من الوصول إلى الخدمة. في جميع الأحوال، يُعدّ الصنف ArrayBlockingQueue ذو السعة المحدودة هو الخيار الأصح. ينبغي أن يكون الرتل قصيرًا حتى لا تضطّر الإتصالات الموجودة به إلى الانتظار لفتراتٍ طويلة قبل وصولها إلى الخدمة. بالنسبة للخوادم الحقيقية، لا بُدّ من ضبط كُلٍ من حجم الرتل وعدد خيوط المجمع بما يتناسب مع الخادم؛ كما ينبغي أن يُؤخَذ العتاد والشبكة التي يَعمَل عليها الخادم بالحسبان؛ وكذلك طبيعة طلبات العملاء التي يُفترَض معالجتها، وهو ما يُمثِّل تحديًا صعبًا عمومًا. بالمناسبة، هناك احتماليةٌ أخرى قد تسوء الأمور نتيجة لها: لنفترض أن الخادم يحتاج إلى قراءة بعض البيانات من العميل، ولكن العميل لا يرسل أية بيانات. في تلك الحالة، قد يتعطَّل الخيط الذي يحاول قراءة البيانات إلى الأبد بانتظار المُدْخَلات. إذا كنا نَستخدِم مجمع خيوط، فقد يَحدُث ذلك لجميع الخيوط الموجودة بالمجمع، وعندها، لن تحدث أي معالجةٍ أخرى. يتمثل حل تلك المشكلة بإيقاف الاتصال إذا بقي غير نشطٍ لفترةٍ طويلة من الوقت. ينتبه كل خيط اتصال عادةً إلى آخر توقيت استقبل به بياناتٍ من العميل. يُشغِّل الخادم خيطًا آخر يُطلَق عليه أحيانًا اسم "خيط reaper" تيمُنًا بفرقة "Grim Reaper"، ويوقظه دوريًا؛ حتى يَفحَص خيوط الاتصال، ويرى فيما إذا كان هناك خيطٌ غير نشط لفترةٍ طويلة. إذا بقي خيطٌ قيد الانتظار لفترة طويلة، فإنه يُنهَى، ويحلّ محله خيطٌ جديد. تُمثِّل الإجابة على سؤال "كم من الوقت ينبغي أن تكون تلك الفترة الطويلة؟" تحديًا آخر. الحوسبة الموزعة لقد رأينا طريقة اِستخدام الخيوط لإجراء المعالجة على التوازي؛ حيث تَعمَل عدة معالجات معًا لإنجاز مهمةٍ معينة. كان افتراضنا حتى الآن هو أن جميع تلك المعالجات موجودةٌ ضمن حاسوبٍ واحد مُتعدِّد المعالجات، ولكن يُمكِن إجراء المعالجة على التوازي باستخدام معالجاتٍ موجودة بحواسيبٍ مختلفة شرط أن تكون تلك الحواسيب متصلةً عبر شبكةٍ معينة؛ ويُطلَق على هذا النوع من المعالجة المتوازية -أي أن تَعمَل عدة حواسيب معًا لإنجاز مهمةٍ عبر الشبكة- اسم الحوسبة المُوزَّعة distributed computing. يُمكِننا أن ننظر لشبكة الانترنت وكأنها نموذجٌ ضخمٌ لحوسبةٍ موزَّعة، ولكننا في الواقع معنيون بالكيفية التي يمكن أن تتعاون من خلالها الحواسيب المتصلة عبر شبكةٍ على حل مشكلة حوسبية. هناك طرائقٌ متعددة للحوسبة الموزّعة، وتدعم جافا بعضًا منها. على سبيل المثال، تُمكِّن كُلًا من تقنية استدعاء التوابع عن بعد Remote Method Invocation - RMI وتقنية كوربا Common Object Request Broker Architecture - CORBA برنامجًا مُشغَّلًا على حاسوبٍ معين من استدعاء توابع كائناتٍ موجودة بحواسيبٍ اخرى. يَسمَح ذلك بتصميم برنامجٍ كائني التوجه object-oriented تُنفَّذ أجزاءه المختلفة بحواسيب مختلفة. تدعم RMI الاتصال بين كائنات جافا فقط؛ بينما تُعدّ CORBA المعيار الأكثر عمومية، حيث تَسمَح للكائنات المكتوبة بأي لغة برمجية، بما في ذلك جافا، بالتواصل مع بعضها بعضًا. كما هو الحال مع الشبكات، لدينا مشكلة تحديد موقع الخدمات (المقصود بالخدمة هنا هو الكائن المُتاح للاستدعاء عبر الشبكة)، أي كيف يستطيع حاسوبٌ معين معرفة الحاسوب الذي تَعمَل عليه خدمةٌ معينة ورقم المنفذ الذي تَستمِع إليه؟ تحلّ تقنيتي "RMI" و "CORBA" تلك المشكلة باستخدام "وسيط طلب request broker"؛ حيث يمثّل ذلك الوسيط برنامج خادم يَعمَل بعنوانٍ معروف، ويحتوي على قائمة الخدمات المتاحة بالحواسيب الأخرى، وتُبلِّغ الحواسيب المُقدِّمَة لخدمات الوسيط بخدماتها. ينبغي أن تعرف الحواسيب التي تحتاج خدمةً معينةً عنوان الوسيط للتواصل معه وسؤاله عن الخدمات المُتاحة وعناوينها. تُعدّ "RMI" و "COBRA" أنظمةً معقدة وليس من السهل تمامًا استخدامها، وذُكرت هنا لأنها جزءٌ من واجهة برمجة تطبيقات API جافا للشبكات القياسية، ولكننا لن نناقشها إلى أبعد من ذلك. بدلًا منها، سنفحص مثالًا أبسط على الحوسبة المُوزَّعة يَستخدِم أساسيات الشبكات فقط. يتشابه هذا المثال مع ذلك الذي اِستخدمناه بالبرنامج MultiprocessingDemo1.java ونُسخه المختلفة بالمقالين البرمجة باستخدام الخيوط threads في جافا والمقال السابق، وهو مثال حساب ألوان صورةٍ معقدة، ولكننا لن نُنشِئ هنا برنامجًا بواجهة مُستخدِم رسومية، ولن نعرض الصورة على الشاشة. تَستخدِم عملية المعالجة أبسط أنواع المعالجة على التوازي بتقسيم المشكلة إلى عدة مهام يُمكِن تنفيذها باستقلالية دون الاتصال مع المهام الأخرى. لتطبيق الحوسبة المُوزَّعة على هذا النوع من المشكلات، سنَستخدِم برنامجًا رئيسيًا master يتولى مسؤولية تقسيم المشكلة إلى عدة مهام وإرسالها عبر الشبكة إلى البرامج العاملة worker لتُنفِّذها. ينبغي أن تُرسِل تلك البرامج نتائجها إلى البرنامج الرئيسي الذي يَدمِج نتائج جميع المهام معًا ليكوِّن حلًا لكامل المشكلة. يُطلَق على البرامج العاملة ضمن هذا السياق اسم "البرامج التابعة slaves"، ويُقال أن البرنامج يَستخدِم أسلوب "البرنامج الرئيسي والبرنامج التابع master/slave" لإجراء الحوسبة المُوزَّعة. يتكوَّن هذا البرنامج من ثلاثة ملفات؛ حيث يُعرِّف الملف CLMandelbrotMaster.java البرنامج الرئيسي، بينما يُعرِّف الملف CLMandelbrotWorker.java البرامج العاملة التابعة؛ وأخيرًا يُعرِّف الملف CLMandelbrotTask.java الصنف CLMandelbrotTask الذي يُمثِّل المهمة التي تُنفِّذها البرامج العاملة. يُقسِّم البرنامج الرئيسي master المشكلة إلى عدة مهام، ثم يُوزِّعها على البرامج العاملة، التي تُنفِّذ تلك المهام، ثم تُعيد النتائج إلى البرنامج الرئيسي. ويُطبِّق البرنامج الرئيسي بالنهاية نتائج جميع المهام المفردة على المشكلة الأساسية. لتشغيل البرنامج، سنبدأ أولًا بتشغيل البرنامج "CLMandelbrotWorker" على عدّة حواسيب (ربما بتنفيذها بسطر الأوامر). يَستخدِم ذلك البرنامج الصنف CLMandelbrotTask، ولذلك يجب أن يكون كل من الملف CLMandelbrotWorker.class والملف CLMandelbrotTask.class متوفرين بالحواسيب المُمثِّلة للبرامج العاملة. بعد ذلك، سنُشغِّل البرنامج المسمى "CLMandelbrotMaster" على الحاسوب المُمثِل للبرنامج الرئيسي، مع ملاحظة أنه يحتاج إلى الصنف CLMandelbrotTask أيضًا. يجب أن نُمرِّر اسم أو عنوان بروتوكول الانترنت IP address الخاص بجميع الحواسيب العاملة إلى البرنامج المسمى "CLMandelbrotMaster" مثل وسطاءٍ عبر سطر الأوامر. تستمع البرامج العاملة إلى طلبات الاتصال القادمة من البرنامج الرئيسي، ولهذا يجب أن يعرف البرنامج الرئيسي العناوين التي سيُرسِل إليها تلك الطلبات. على سبيل المثال، إذا كان البرنامج العامل مُشغَّلًا على ثلاثة حواسيب عناوينها هي: 172.21.7.101 و 172.21.7.102 و 172.21.7.103، يُمكِننا أن نُشغِّل البرنامج "CLMandelbrotMaster" بكتابة الأمر التالي: java CLMandelbrotMaster 172.21.7.101 172.21.7.102 172.21.7.103 سينُشِئ البرنامج الرئيسي اتصالًا شبكيًا مع البرنامج العامل بكل عنوان بروتوكول إنترنت، وستُستخدَم تلك الاتصالات للتواصل بين كُلٍ من البرنامج الرئيسي والبرامج العاملة. يُمكِننا تشغيل عدة نسخٍ من البرنامج "CLMandelbrotWorker" على نفس الحاسوب، ولكن ينبغي أن يستمع كُلًا منها إلى الاتصالات الشبكية القادمة عبر أرقام منافذٍ مختلفة. كما يُمكِننا تشغيل البرنامج "CLMandelbrotWorker" على نفس الحاسوب الذي يَعمَل عليه البرنامج "CLMandelbrotMaster"، ولربما ستلاحظ عندها زيادة سرعة البرنامج إذا كان حاسوبك يحتوي على عدة معالجات. ألقِ نظرةً على التعليقات الموجودة بشيفرة البرنامج لمزيدٍ من المعلومات. نستعرض فيما يلي بعض الأوامر المُمكِن استخدامها لتشغيل البرنامج الرئيسي مع نسختين من البرنامج العامل على نفس الحاسوب. اُكتُبها بنوافذ سطر أوامر مختلفة: java CLMandelbrotWorker (Listens on default port) java CLMandelbrotWorker 2501 (Listens on port 2501) java CLMandelbrotMaster localhost localhost:2501 يَحلّ البرنامج "CLMandelbrotMaster" نفس المشكلة بالضبط في كل مرةٍ نُشغِّل بها. في الواقع، طبيعة المشكلة ذاتها غير مهم، ولكنها تمثّل هنا حساب البيانات التي يتطلَّبها رسم جزءٍ صغير من صورة مجموعة ماندلبرو Mandelbrot Set الشهيرة. إذا كنت تريد رؤية الصورة الناتجة، ألغِ التعليق الموجود فوق الاستدعاء saveImage() بنهاية البرنامج main() المُعرَّف بالملف CLMandelbrotMaster.java). يُمكِنك تشغيل البرنامج "CLMandelbrotMaster" مع عددٍ مختلف من البرامج العاملة لرؤية مدى اعتمادية الزمن الذي يتطلَّبه حل المشكلة على عدد البرامج العاملة. لاحِظ استمرار البرامج العاملة بالعمل حتى بعد انتهاء البرنامج الرئيسي؛ أي يُمكِنك تشغيل البرنامج الرئيسي عدة مرات دون الاضطرار لإعادة تشغيل البرامج العاملة في كل مرة. علاوةً على ذلك، إذا شغَّلت البرنامج "CLMandelbrotMaster" دون أن تُمرِّر إليه أي وسطاء، فإنه سيحلّ المشكلة بالكامل بمفرده، ويُمكِنك بذلك رؤية الزمن الذي يتطلَّبه حل المشكلة بدون الحوسبة المُوزَّعة. في الواقع، جرَّبنا ذلك بإحدى الحواسيب القديمة والبطيئة جدًا، ووجدنا أن البرنامج "CLMandelbrotMaster" قد استغرق 40 ثانية لحل المشكلة بمفرده؛ في حين استغرق 43 ثانية عند تشغيل برنامجٍ عاملٍ واحد. تُمثِّل تلك الزيادة العمل الإضافي المطلوب لاستخدام الشبكات، حيث يتطلَّب إنشاء الاتصال الشبكي، وإرسال الرسائل عبر الشبكة المزيد من الوقت. في المقابل، اِستغرَق البرنامج 22 ثانية فقط لحل المشكلة عند تشغيل برنامجين عاملين بحواسيبٍ مختلفة. في تلك الحالة، نفَّذ كل برنامجٍ منهما نصف العمل على التوازي، ولذلك أنُجزَت العملية بنصف الوقت تقريبًا. يستمر الزمن بالنقصان مع زيادة عدد البرامج العاملة، ولكن إلى حدٍ معين؛ لأن البرنامج الرئيسي لديه أيضًا بعض العمل الواجب إنجازه مهما ازداد عدد البرامج العاملة، وعليه لا يُمكِن للزمن الكلي المطلوب لحل المشكلة أن يكون أقل من الزمن الذي يستغرقه البرنامج الرئيسي لتنفيذ عمله. وفي تلك الحالة، بدا أن أقل زمنٍ ممكنٍ هو 5 ثوانٍ تقريبًا. والآن، لنرى طريقة برمجة هذا التطبيق المُوزَّع. سيُقسِّم البرنامج الرئيسي المشكلة إلى مجموعة مهام؛ بحيث تُمثَّل كل مهمة منها باستخدام كائنٍ ينتمي إلى الصنف CLMandelbrotTask. تُرسَل تلك المهام إلى البرامج العاملة، التي ينبغي لها أن تعيد نتائجها بعد أن تحسبها. يَعنِي ذلك أننا بحاجة إلى نوعٍ من البروتوكول للتواصل؛ ولذلك قررنا استخدام مجرى محارف character stream لهذا الغرض. سيُشفِّر البرنامج الرئيسي كل مهمةٍ بهيئة سطرٍ نصي يُرسَل إلى البرنامج العامل، الذي سيَفك التشفير ويُعيد السطر إلى كائنٍ من الصنف CLMandelbrotTask ليتمكَّن من فهم المهمة المفترض تنفيذها. بعد ذلك، يُنجِز البرنامج العامل المهمة، ثم يُشفِّر النتائج بهيئة سطرٍ نصي آخر يُرسَل عائدًا إلى البرنامج الرئيسي. أخيرًا، يَفُك البرنامج الرئيسي الشيفرة، ويَدمِج النتيجة مع نتائج المهام الأخرى. بعد اكتمال جميع المهام ودمج نتائجها معًا، تكون المشكلة قد حُلَّت. لا يستقبل كل برنامجٍ عاملٍ مهمةً واحدةً فقط، وإنما متتاليةً من المهام؛ فبمجرد أن يُنجز مهمةً معينة، ويُرسِل نتائجها، تُسنَد إليه مهمةٌ جديدة. يَستقبِل البرنامج العامل الأمر "close" بعد اكتمال جميع المهام ليخبره بأن عليه إغلاق الاتصال. يُجرَى كل ما سبق ضمن تابعٍ، اسمه handleConnection() بالملف CLMandelbrotWorker.java، حيث يُستدعَى ذلك التابع لمعالجة اتصالٍ قد اُنشئ بالفعل مع البرنامج الرئيسي. يَستخدِم ذلك التابع بدوره تابعًا آخر، اسمه readTask() لفك تشفير المهمة التي استقبلها من البرنامج الرئيسي؛ كما يستخدِم التابع writeResults() بهدف تشفير نتائج المهمة قبل إرسالها إلى البرنامج الرئيسي. يجب عليه أيضًا معالجة أي أخطاءٍ ممكنة. انظر تعريف التابع: private static void handleConnection(Socket connection) { try { BufferedReader in = new BufferedReader( new InputStreamReader( connection.getInputStream()) ); PrintWriter out = new PrintWriter(connection.getOutputStream()); while (true) { String line = in.readLine(); // رسالة من البرنامج الرئيسي if (line == null) { // واجهنا نهاية المجرى. لا ينبغي أن يحدث ذلك throw new Exception("Connection closed unexpectedly."); } if (line.startsWith(CLOSE_CONNECTION_COMMAND)) { // يُمثِّل الانتهاء الطبيعي للاتصال System.out.println("Received close command."); break; } else if (line.startsWith(TASK_COMMAND)) { // يُمثِل مهمةً من النوع CLMandelbrotTask ينبغي أن ينفذها الخيط CLMandelbrotTask task = readTask(line); // فك تشفير الرسالة task.compute(); // نفِّذ المهمة out.println(writeResults(task)); // أرسِل النتائج out.flush(); // تأكّد من إرسال النتائج } else { // ليس هناك أي رسائل أخرى ضمن البروتوكول throw new Exception("Illegal command received."); } } } catch (Exception e) { System.out.println("Client connection closed with error " + e); } finally { try { connection.close(); // تأكّد من إغلاق المقبس } catch (Exception e) { } } } لا يُنفَّذ التابع المُعرَّف بالأعلى بخيطٍ thread مُنفصل، فالبرنامج العامل لديه شيءٌ واحدٌ فقط ليفعله بأي لحظة، ولا يحتاج إلى عدة خيوط. لنعود الآن إلى البرنامج الرئيسي CLMandelbrotMaster.java، حيث نواجه وضعًا أكثر تعقيدًا. يجب أن يُنشِئ البرنامج اتصالًا مع مجموعةٍ من البرامج العاملة عبر مجموعةٍ من الاتصالات الشبكية، وسيَستخدِم البرنامج لإنجاز ذلك عدة خيوط؛ بحيث يُعالِج كل خيطٍ منها الاتصال مع برنامج عاملٍ واحد. تُوضِّح الشيفرة الوهمية pseudocode التالية الفكرة العامة للبرنامج main(): // أنشِئ المهام التي ينبغي تنفيذها وضفها إلى الرتل create the tasks that must be performed and add them to a queue // إذا لم تُمرَّر أي وسائط بالأمر if there are no command line arguments { // يُنفِّذ البرنامج الرئيسي كل شيء بنفسه Remove each task from the queue and perform it. } else { // تُنفِّذ البرامج العاملة المهام for each command line argument: // احصل على معلومات العامل من وسيط سطر الأوامر Get information about a worker from command line argument. // أنشِئ خيطًا جديدًا وشغِّله لكي يُرسِل المهام إلى البرامج العاملة Create and start a thread to send tasks to workers. // انتظر اكتمال جميع الخيوط Wait for all threads to terminate. } // جميع المهام قد انتهت بفرض عدم حدوث أخطاء تُوضَع المهام بمتغيرٍ اسمه tasks من نوع الرتل ConcurrentBlockingQueue<CLMandelbrotTask>؛ وتَقَرأ خيوط الاتصال المهام من ذلك الرتل، وتُرسِلها إلى البرامج العاملة. يُستخدَم التابع tasks.poll() لقراءة مهمةٍ من الرتل؛ فإذا كان الرتل فارغًا، فسيُعيد القيمة null، والتي تُعدّ إشارةً إلى أن جميع المهام قد أُسنَدت بالفعل وأن بإمكان خيط الاتصال أن ينتهي. يتولى كل خيط اتصال مهمة إرسال متتاليةٍ من المهام إلى خيطٍ عاملٍ معين؛ واستقبال النتائج التي يعيدها ذلك الخيط العامل؛ كما أنه مسؤولٌ عن إنشاء اتصالٍ مع الخيط العامل بالبداية. تُوضِّح الشيفرة الوهمية pseudocode التالية الفكرة العامة للعملية التي يُنفِّذها خيط الاتصال: // أنشِئ مقبسًا متصلًا مع البرنامج العامل Create a socket connected to the worker program. // أنشِئ مجرى دخل ومجرى خرج للتواصل مع البرنامج العامل Create input and output streams for communicating with the worker. while (true) { Let task = tasks.poll(). If task == null break; // جميع المهام قد أسنَدت // شفِّر المهمة إلى رسالة وأرسلها إلى البرنامج العامل Encode the task into a message and transmit it to the worker. // اقرأ رد العامل Read the response from the worker. // فك تشفير الرد وعالجه Decode and process the response. } // أرسِل الأمر "close" إلى البرنامج العامل Send a "close" command to the worker. Close the socket. سيَعمَل ذلك بطريقة مناسبة، ولكن هناك بعض الملاحظات: أولًا، يجب أن يكون الخيط مُهيأً للتعامل مع أخطاء الشبكة. على سبيل المثال، قد تُغلَق إحدى البرامج العاملة بصورةٍ غير متوقَّعة، وسيتمكَّن البرنامج الرئيسي من إكمال عمله في تلك الحالة نظرًا لوجود برامجٍ عاملةٍ أخرى. (عندما تُشغِّل البرنامج، يُمكِنك أن تُجرِّب ما يلي: أوقف إحدى البرامج العاملة بالضغط على "CONTROL-C"، وسترى أنه بإمكان البرنامج الرئيسي أن يكتمل بنجاح). تَنشَأ المشكلة إذا حدث خطأٌ بينما يَعمَل الخيط على مهمةٍ معينة؛ ونظرًا لأننا نهدف إلى حل المشكلة كاملةً، لا بُدّ إذًا من إعادة إسناد تلك المهمة إلى برنامجٍ عاملٍ آخر. يُمكِننا تحقيق ذلك بإعادة المهام غير المكتملة إلى رتل المهام. لسوء الحظ، لا يُعالج البرنامج الذي نَستعرِضه هنا جميع الأخطاء المحتملة. على سبيل المثال، إذا فَشَل خيط العامل الأخير، فلن يكون هناك عملاء آخرين نُسنِد إليهم المهام غير المكتملة. علاوةً على ذلك، إذا عُلّقَ الاتصال الشبكي دون أن يُولِّد خطأً فعليًا، سيُعلَّق البرنامج أيضًا نتيجة انتظاره لرد العامل. لا بُدّ لأي برنامج متين أن يجد طريقةً يَكشِف من خلالها عن تلك المشكلة ومن ثم يُعيد إسناد المهمة. ثانيًا، تترك الطريقة الموضحة بالأعلى البرنامج العامل دون أي عملٍ أثناء معالجة البرنامج الرئيسي لرد العامل. سيكون من الأفضل لو أُسندت مهمةٌ جديدةٌ إلى البرنامج العامل قبل معالجة ردّه على المهمة السابقة؛ حيث سيُبقِي ذلك البرنامج العامل مُنشغِلًا؛ كما سيَسمَح بتنفيذ العمليتين على التوازي بدلًا من تنفيذهما على التوالي. بهذا المثال، تستغرق معالجة الرد زمنًا قصيرًا للغاية، ولذلك قد لا يُمثِل ترك العامل منتظرًا أو تشغيله على الفور فارقًا كبيرًا، ولكن ينبغي عمومًا تطبيق التوازي بأقصى قدرٍ ممكن. ألقِ نظرةً على التصور العام للخوارزمية بعد التعديل: try { // أنشِئ مقبسًا متصلًا مع البرنامج العامل Create a socket connected to the worker program. // أنشِئ مجرى دخل ومجرى خرج للتواصل مع البرنامج العامل Create input and output streams for communicating with the worker. Let currentTask = tasks.poll(). // شفِّر `currentTask` إلى رسالة وأرسلها إلى البرنامج العامل Encode currentTask into a message and send it to the worker. while (true) { // اِقرأ الرد من البرنامج العامل Read the response from the worker. Let nextTask = tasks.poll(). If nextTask != null { // أرسل `nextTask` إلى البرنامج العامل قبل معالجة الرد على المهمة `currentTask` // شفِّر `nextTask` إلى رسالة وأرسلها إلى البرنامج العامل Encode nextTask into a message and send it to the worker. } // فك تشفير الرد على المهمة `currentTask` وعالجه Decode and process the response to currentTask. currentTask = nextTask. if (currentTask == null) break; // جميع المهام قد أسنَدت } // أرسِل الأمر "close" إلى البرنامج العامل Send a "close" command to the worker. // أغلق المقبس Close the socket. } catch (Exception e) { // أعِد المهمة غير المكتملة إن وجدت إلى رتل المهام مرة أخرى Put uncompleted task, if any, back into the task queue. } finally { // أغلق المقبس Close the connection. } يُمكِنك الإطلاع على الصنف المتداخل WorkerConnection المُعرَّف بالملف CLMandelbrotMaster.java لترى طريقة تحويل الشيفرة الوهمية السابقة إلى شيفرة جافا. ترجمة -بتصرّف- للقسم Section 4: Threads and Networking من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: الخيوط threads والمعالجة على التوازي في جافا البرمجة باستخدام الخيوط threads في جافا مقدمة إلى الخيوط Threads في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا تواصل تطبيقات جافا عبر الشبكة
-
استخدمنا تقنية البرمجة على التوازي بمثال القسم الفرعي التعاود داخل الخيوط من المقال السابق البرمجة باستخدام الخيوط threads في جافا لتنفيذ أجزاءٍ صغيرة من مهمةٍ كبيرة، حيث تَسمَح تلك التقنية للحواسيب مُتعدّدة المعالجات بإكمال عملية المعالجة بوتيرةٍ أسرع. ولكننا في الواقع لم نَستخدِم لا الطريقة الأفضل لتقسيم المشكلة ولا الطريقة الأفضل لإدارة الخيوط threads. سنتناول بهذا القسم نسختين من نفس البرنامج؛ حيث سنُحسِّن بالنسخة الأولى طريقة تقسيم المشكلة إلى عدّة مهام؛ بينما سنُحسِّن بالنسخة الثانية طريقة اِستخدام الخيوط. بالإضافة إلى ذلك، سنمرّ عبر مجموعةٍ من الأصناف المبنية مسبقًا والتي تُوفِّرها جافا لدعم المعالجة المتوازية، وسنناقش بنهاية القسم التابعين wait() و notify() المُستخدمين للتحكُّم بالعمليات المتوازية تحكمًا مباشرًا. تقسيم المشكلات يُقسِّم البرنامج MultiprocessingDemo1.java مُهمة حساب صورة إلى عدة مهامٍ فرعية، ويُسنِد كُلًا منها إلى خيط. يَعمَل هذا البرنامج بنجاح، ولكن هناك مشكلة متمثّلة في إمكانية استغراق بعض المهام وقتًا أطول من المهام الأخرى. في حين يُقسِّم البرنامج الصورة إلى أجزاءٍ متساوية، تتطلَّب بعض أجزائها معالجةً أكثر من أجزاءٍ أخرى. إذا شغَّلت البرنامج باستخدام ثلاثة خيوط، ستلاحظ أن معالجة الجزء الأوسط تستغرِق وقتًا أطول بعض الشيء من معالجة الجزأين السفلي والعلوي. في العموم، عندما نُقسِّم مشكلةً معينةً إلى عدة مشكلاتٍ فرعيةٍ أصغر، يكون من الصعب توقُّع الزمن الذي ستحتاجه معالجة كل مشكلةٍ منها. والآن، لنفترض أن لدينا مشكلةً فرعيةً معينة تستغرق زمنًا أطول من غيرها. في تلك الحالة، ستكتمل جميع الخيوط عدا الخيط المسؤول عن معالجة تلك المشكلة؛ حيث سيستمر بالعمل لفترةٍ طويلةٍ نسبيًا، وسيَعمَل معالجٌ واحدٌ فقط خلال تلك الفترة دون بقية المعالجات. إذا احتوى الحاسوب مثلًا على معالجين، وقسَّمنا مشكلةً ما إلى مشكلتين فرعيتين، ثم أنشأنا خيطًا لكل مشكلةٍ منهما. نطمح باستخدامنا لمعالجيّن إلى الحصول على الإجابة بنصف الزمن الذي سيستغرقه الحصول عليها عند استخدام معالجٍ واحد، ولكن إذا كان حل مشكلةٍ منهما يحتاج إلى أربعة أضعاف الزمن الذي يحتاجه حل المشكلة الأخرى، فسيكون معالجًا واحدًا فقط مُشغَّلًا غالبية الوقت، ولم نُقلِل في تلك الحالة الزمن المطلوب لحل المشكلة بنسبةٍ أكبر من 20%. حتى لو تمكَّنا من تقسيم المشكلة إلى عدة مشكلاتٍ فرعية تتطلَّب زمن المعالجة نفسه، فإننا لا نستطيع الاعتماد ببساطةٍ على حقيقة كونها ستتطلَّب زمنًا متساويًا؛ حيث من الممكن أن تكون بعض المعالجات منهمكةً بتشغيل برامجٍ أخرى، أو قد يكون بعضها أبطأ عمومًا (هذا ليس محتملًا إذا شغَّلنا البرنامج على حاسوبٍ واحد، ولكن تختلف سرعة المعالجات بالحوسبة المُوزَّعة من خلال مجموعة حواسيب عبر الشبكة -وهو ما سنفعله لاحقًا-، وتُعدّ عندها مسألةً مهمة). تتمثّل التقنية الأكثر شيوعًا للتعامل مع كل تلك المشكلات في تقسيم المشكلة إلى عددٍ كبيرٍ من المشكلات الفرعية الأصغر؛ أي أكبر بكثير من عدد المعالجات الموجودة، وسيضطّر بالتالي كل معالجٍ لحلّ عدة مشكلاتٍ فرعية. عندما يُكمِل معالج مهمةً فرعيةً معينة، تُسنَد إليه مهمةٌ فرعيةٌ أخرى ليَعمَل عليها إلى أن تُسنَد جميع المهام الفرعية. سيظل بالطبع هناك اختلافٌ بالزمن الذي تتطلَّبه كل مهمة، وبالتالي قد يُكمِل معالجٌ معينٌ عدة مشكلاتٍ فرعية، بينما قد يَعمَل معالجٌ آخر على مهمةٍ واحدةٍ معقدة؛ كما قد يُكمِل معالج بطيءٌ أو مشغولٌ مهمةً واحدةً فقط أو اثنتين، بينما قد يُنهِي معالجٌ آخر خمس أو ست مهام. في العموم، سيَعمَل كل معالجٍ وفقًا لسرعته الخاصة، وستستمر غالبية المعالجات بالعمل إلى قرب اكتمال المعالجة بالكامل بشرط أن تكون المشكلات الفرعية صغيرةً كفاية. ويُطلَق على ذلك "توزيع الحمل load balancing"؛ أي توزيع حمل المعالجة بين المعالجات المتاحة لإبقائها جميعًا منشغلةً بأقصى ما يُمكِن. ستنهي بعض المعالجات بالطبع عملها قبل بعضها الآخر، ولكن بفترةٍ لا تتعدى الزمن المطلوب لإتمام المهمة الفرعية الأطول. كما ذكرنا بالأعلى، ينبغي أن يكون حجم المشكلات الفرعية صغيرًا لا غاية في الصغر؛ لأن تقسيم المشكلة وإسنادها إلى المعالجات يتطلَّب حملًا إضافيًا overhead؛ فإذا كانت المشكلات الفرعية غايةً في الصغر، سيتراكم الحمل الإضافي ويُشكِّل فارقًا بمقدار العمل الكلي المطلوب. كانت المهمة بالمثال السابق هي حساب لون كل بكسلٍ بالصورة، فإذا أردنا تقسيم تلك المهمة إلى عدة مهامٍ فرعية، هناك عدة احتمالات؛ فقد تتكوَّن كل مهمة فرعية مثلًا من حساب لون بكسلٍ واحدٍ فقط، ولكنها تُعدّ صغيرة للغاية. بدلًا من ذلك، ستتكوَّن كل مهمةٍ فرعية من حساب لون صفٍ واحد من البكسلات. نظرًا لوجود عدة مئاتٍ من الصفوف بكل صورة، سيكون عدد المهام الفرعية كبيرًا كفاية، كما سيكون حجم كل مهمةٍ فرعيةٍ منها مناسبًا. سينتج عن ذلك توزيعًا جيدًا للحمل مع قدرٍ معقولٍ من الحمل الإضافي. ملاحظة: تُعدّ المشكلة التي ناقشناها بالمثال السابق سهلةً للغاية بالنسبة للبرمجة على التوازي؛ بمعنى أنه عند تقسيم مشكلة حساب ألوان الصورة إلى عدة مشكلاتٍ فرعية أصغر، ستَظِل جميع المشكلات الفرعية مستقلةً تمامًا، وبالتالي يُمكِن معالجة أي عددٍ منها بنفس الوقت ووفقًا لأي ترتيب. في المقابل، قد تتطلّب بعض المهام الفرعية بمشكلاتٍ أخرى النتائج المحسوبة بواسطة بعض المهام الأخرى؛ أي أن المهام الفرعية غير مستقلة، وبالتالي ستتعقد الأمور في تلك الحالات، وسيُصبح ترتيب تنفيذ المهام الفرعية مُهمًا. علاوةً على ذلك، سيكون من الضروري توفير طريقةٍ ما لتشارُك تلك النتائج بين المهام؛ وفي حالة تنفيذها بخيوطٍ مختلفة، سنضطّر إلى مواجهة كل تلك المشكلات المُتعلّقة بالتحكم بوصول الخيوط إلى الموارد التشاركية. لذلك، يُعدّ تقسيم مشكلةٍ معينةٍ لمعالجتها على التوازي أمرًا أصعب بكثير مما قد يوحي به المثال السابق، ولكن بالنهاية، يُعدّ ذلك موضوعًا لدورةٍ تدريبية عن الحوسبة المتوازية لا دورةٍ عن أساسيات البرمجة. مجمع الخيوط وأرتال المهام بعد أن نُقرِّر طريقة تقسيم المشكلة إلى عدة مهامٍ فرعية، ينبغي أن نعرف كيفية إسناد تلك المهام الفرعية إلى الخيوط؛ حيث ينبغي وفقًا للأسلوب كائني التوجه object-oriented تمثيل كل مهمةٍ فرعيةٍ بواسطة كائن، ولأن كل مهمة تُمثِّل عملية معالجة معينة، فمن البديهي أن يُعرِّف ذلك الكائن تابع نسخة instance method يُنفِّذ تلك العملية. من الضروري استدعاء التابع المقابل لمهمةٍ معينة حتى تُنفَّذ، وسيكون تابع المعالجة لهذا البرنامج، هو run()، وسيُنفِّذ الكائن المُمثِّل للمهمة الواجهة interface القياسية Runnable التي ناقشناها بقسم إنشاء الخيوط وتشغيلها من مقال مقدمة إلى الخيوط Threads في جافا. تُعدّ تلك الواجهة الطريقة المباشرة لتمثيل مهام المعالجة، ويُمكِننا إنشاء خيطٍ جديدٍ لكل كائن Runnable، ولكن في حالة وجود عددٍ كبيرٍ من المهام، لا يكون لذلك أي معنى؛ نتيجةً لمقدار الحمل الإضافي الناتج عن إنشاء كل خيطٍ جديد. بدلًا من ذلك، يُفضَّل إنشاء عددٍ قليل من الخيوط، بحيث يُسمَح لكُلٍ منها بتنفيذ قدرٍ معيّنٍ من المهام. لاحِظ أن عدد الخيوط التي ينبغي استخدامها غير معروف، وقد يعتمد على المشكلة التي نحاول حلها. يتمحور الهدف عمومًا في إبقاء جميع معالجات الحاسوب مُشغَّلة؛ فبالنسبة لمثال حساب الصورة، كان إنشاء خيطٍ مقابل كل معالجٍ مناسبًا، ولكنه قد لا يتناسب مع جميع المشكلات. وبالتحديد، إذا كان هناك خيطٌ قد يتسبَّب بحدوث تعطيلٍ block لفترةٍ طويلة بينما ينتظر وقوع حدثٍ event معين، أو بينما ينتظر الوصول إلى موردٍ ما، فلربما يكون من الأفضل إنشاء خيوطٍ إضافية؛ لكي يَعمَل عليها كل معالجٍ أثناء تعطُّل الخيوط الأخرى. يُطلَق على مجموعة الخيوط المُتاحة لتنفيذ المهام اسم "مجمع الخيوط thread pool"، وتُستخدَم بغرض تجنُّب إنشاء خيطٍ جديدٍ لكل مهمة، حيث تُسنَد المهمة المطلوب تنفيذها إلى أي خيطٍ مُتاحٍ بالمجمع. عندما تنشغِل جميع الخيوط الموجودة بالمجمع، تضطّر أي مهامٍ أخرى إضافية للانتظار إلى أن تُتاح إحدى الخيوط، ويُعدّ ذلك تطبيقًا مباشرًا على الرتل queue؛ حيث يرتبط بمجمع الخيوط رتلٌ مُكوَّنٌ من المهام قيد الانتظار. بمجرد توفُّر مهمةٍ جديدة، ستُضَاف إلى الرتل؛ وبمجرد انتهاء خيطٍ معينٍ من تنفيذ المهمة المُوكَلة إليه، فسيَحصُل على مهمةٍ أخرى من الرتل ليَعمَل عليها. هناك رتل مهامٍ task queue وحيدٍ لمجمع الخيوط. يَعنِي ذلك استخدام جميع خيوط المجمع نفس الرتل، فهو يُعدّ موردًا تشاركيًا. كما هو الحال مع أي موردٍ تشاركي، قد تقع حالات التسابق race conditions، ولهذا يكون استخدام المزامنة synchronization ضروريًا؛ فقد يحاول بدونها خيطان قراءة عنصرٍ من الرتل بنفس الوقت مثلًا، وعندها سيَحصُل كلاهما على نفس العنصر. حاول التعرف على الأماكن المُحتمَلة لوقوع حالات التسابق بالتابع dequeue() المُعرَّف في قسم الأرتال Queues من الفصل المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT. تُوفِّر جافا الصنف ConcurrentLinkedQueue من أجل حل تلك المشكلة؛ وهو صنفٌ مُعرَّفٌ بحزمة package java.util.concurrent إلى جانب أصنافٍ أخرى مفيدة للبرمجة على التوازي. لاحِظ أنه صنف ذو معاملاتٍ غير مُحدَّدة النوع parameterized، ولذلك إذا أردنا إنشاء رتلٍ لحَمْل كائناتٍ من النوع Runnable، يُمكِننا كتابة ما يلي: ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>(); يُمثِّل هذا الصنف رتلًا مُنفَّذًا مثل قائمةٍ مترابطة linked list، كما أن عملياته متزامنةً بطريقةٍ مناسبة. ليست العمليات المُعرَّفة بالصنف ConcurrentLinkedQueue نفس العمليات على الأرتال التي عهدناها؛ فعلى سبيل المثال، يُضيف التابع queue.add(x) العنصر الجديد x إلى نهاية queue؛ بينما يَحذِف التابع queue.poll() عنصرًا من مقدمة الرتل queue. إذا كان الرتل فارغًا، يعيد التابع queue.poll() القيمة null، ولهذا يُمكِننا استخدامه لفحص فيما إذا كان الرتل فارغًا، أو لإسترجاع عنصرٍ إذا لم يكن كذلك. في الحقيقة، يُفضَّل فعل ذلك على هذا النحو؛ فقد يؤدي التأكُّد مما إذا كان الرتل فارغًا قبل قراءة عنصرٍ منه إلى وقوع حالة تسابق؛ حيث يستطيع خيطٌ آخر بدون تحقيق المزامنة حذف آخر عنصرٍ بالرتل باللحظة الواقعة بين لحظتي اختبارٍ للرتل فيما إذا كان فارغًا ومحاولة قراءة العنصر من الرتل، وفي تلك الحالة لن نجد شيئًا عند محاولة قراءة العنصر. في المقابل، يُعدّ التابع queue.poll() بمثابة عمليةٍ ذرية atomic. يُمكِننا استخدام رتلٍ ينتمي إلى الصنف ConcurrentLinkedQueue مع مجمع خيوطٍ لحساب الصورة من المثال السابق، حيث سنُنشِئ جميع المهام المسؤولة عن حساب الصورة ونضيفها إلى الرتل، ثم سنُنشِئ الخيوط التي ستُنفِّذ تلك المهام، ونُشغِّلها. سيتضمَّن كل خيطٍ منها حلقة تكرار loop بحيث يُستدعى تابع الرتل poll() بكل تكرارٍ لقراءة مهمةٍ منه ثم تُنفَّذ. نظراً لأن المهمة هي كائنٌ من النوع Runnable، فكل ما ينبغي أن يفعله الخيط هو استدعاء تابع المهمة run()؛ عندما يُعيد التابع poll() القيمة null، فإن الرتل قد أصبح فارغًا أي أن جميع المهام قد أُسندَت لخيوطٍ أخرى، ويُمكِن عندها للخيط المُستدعِي الانتهاء. يُنفِّذ البرنامج MultiprocessingDemo2.java تلك الفكرة؛ حيث يَستخدِم رتلًا، اسمه taskQueue من النوع ConcurrentLinkedQueue<Runnable> لحَمْل المهام. كما يَسمَح البرنامج للمُستخدِم بإلغاء العملية قبل انتهائها، حيث يَستخدِم متغيرًا منطقيًا متطايرًا volatile، اسمه running إشارةً للخيط بأن المُستخدِم قد ألغى العملية. عندما تُصبِح قيمة ذلك المتغير مساويةً للقيمة false، ينبغي أن ينتهي الخيط حتى لو لم يَكن الرتل فارغًا. تُعرِّف الشيفرة التالية الصنف المُتداخِل WorkerThread لتمثيل الخيوط: private class WorkerThread extends Thread { public void run() { try { while (running) { Runnable task = taskQueue.poll(); // اقرأ مهمةً من الرتل if (task == null) break; // لأن الرتل فارغ task.run(); // Execute the task; } } finally { threadFinished(); // تذكّر أن الخيط قد انتهى. // أضفناها بعبارة `finally` لنتأكَّد من استدعائها } } } يَستخدِم البرنامج الصنف المُتداخِل MandelbrotTask لتمثيل مهمة حساب صفٍ واحدٍ من البكسلات، حيث يُنفِّذ ذلك الصنف الواجهة Runnable، ويَحسِب تابعه run() لون كل بكسلٍ بالصف، ثم يَنسَخ تلك الألوان إلى الصورة. تُوضِح الشيفرة التالية ما يفعله البرنامج عند بدء عملية المعالجة مع حذف قليلٍ من التفاصيل: taskQueue = new ConcurrentLinkedQueue<Runnable>(); // Create the queue. for (int row = 0; row < height; row++) { // عدد الصفوف الموجودة بالصورة MandelbrotTask task; task = ... ; // أنشِئ مهمةً لمعالجة صفٍ واحد من الصورة taskQueue.add(task); // أضف المهمة إلى الرتل } int threadCount = ... ; // عدد الخيوط الموجودة بالمجمع. يضبُطه المُستخدِم workers = new WorkerThread[threadCount]; running = true; // اضبط الإشارة قبل بدء تشغيل الخيوط threadsRemaining = workers; // عدد الخيوط قيد التشغيل for (int i = 0; i < threadCount; i++) { workers[i] = new WorkerThread(); try { workers[i].setPriority( Thread.currentThread().getPriority() - 1 ); } catch (Exception e) { } workers[i].start(); } تجدر الإشارة هنا إلى أنه من الضروري إضافة المهام إلى الرتل قبل بدء تشغيل الخيوط؛ لأننا نَستخدِم الرتل الفارغ بمثابة إشارةٍ إلى ضرورة انتهاء الخيوط؛ أي إذا وجدت الخيوط عند تشغيلها الرتل فارغًا، فستنتهي فورًا دون أن تُنجِز أي مهمة. جرِّب البرنامج "MultiprocessingDemo2"؛ فهو يَحسِب نفس الصورة التي يَحسبِها البرنامج "MultiprocessingDemo1"، ولكنه يختلف عنه بالترتيب الذي تُحسَب على أساسه الصفوف في حالة استخدام أكثر من خيط. إذا شاهدت البرنامج بحرص، فستلاحظ عدم إضافة صفوف البكسلات بالترتيب من أعلى إلى أسفل؛ لأن خيط الصف i+1 قد يُنهِي عمله قبل أن يُنهِي خيط الصف i عمله أو حتى ما يَسبقه من صفوف. ستلاحِظ هذا التأثير بقدرٍ أكبر إذا استخدمت عدد خيوطٍ أكبر مما يحتويه حاسوبك من معالجات. جرِّب 20 خيطًأ مثلًا. نمط المنتج والمستهلك والأرتال المعطلة يُنشِئ البرنامج "MultiprocessingDemo2" مجمع خيوطٍ جديد تمامًا بكل مرةٍ يَرسِم بها صورة، وهو ما يبدو سيئًا. أليس من المفترض إنشاء مجموعةٍ واحدةٍ فقط من الخيوط ببداية البرنامج، واستخدامها لحساب أي صورة؟ حيث أن الهدف من اِستخدَام مجمع الخيوط بالنهاية هو انتظار الخيوط للمهام الجديدة وتنفيذها. ولكننا، لم نُوضِّح حتى الآن أي طريقةٍ لجعل خيطٍ ينتظر قدوم مهمةٍ جديدة، حيث يُوفِّر الرتل المُعطِّل blocking queue ذلك. يُعدّ الرتل المُعطِّل تنفيذًا لإحدى أنماط المعالجة على التوازي، وهو نمط المُنتِج والمُستهلِك producer/consumer؛ حيث يُستخدَم هذا النمط في حالة وجود "مُنتِجٍ" واحدٍ أو أكثر لشيءٍ معين إلى جانب وجود "مُستهلِكٍ" واحد أو أكثر لذلك الشيء. لا بُدّ أن يَعمَل جميع المُنتِجين والمُستهِلِكين بنفس الوقت (أي معالجة على التوازي). إذا لم يَتوفَّر أي شيء للمعالجة، فسيضطّر المُستهلِك للانتظار إلى أن تتَوفَّر إحداها. قد يضطَّر المُنتِج ببعض التطبيقات للانتظار أحيانًا: إذا كان مُعدّل استهلاك الأشياء واحدًا لكل دقيقةٍ مثلًا، فمن غير المنطقي أن يكون معدّل إنتاجها اثنين لكل دقيقة مثلًا؛ لأنه سيؤدي إلى تراكمها بصورةٍ غير محدودة. ولذلك، لا بُدّ من تخصيص حدٍ أقصى لعدد الأشياء قيد الانتظار، وإذا وصلنا إلى ذلك الحد، لا بُدّ أن يتوقَّف المنتجون عن إنتاج أي أشياءٍ أخرى لبعض الوقت. والآن، سنحتاج إلى طريقةٍ لنقل الأشياء من المُنتجِين إلى المُستهلِكين، حيث يُعدّ الرتل الحل الأمثل لذلك: سيَضَع المنتجون الأشياء بأحد طرفي الرتل، وسيقرأها المُستهلِكون من الطرف الآخر. نظرًا لأننا نُجرِي معالجةً على التوازي، سنَستخدِم رتلًا متزامنًا، ولكننا نحتاج إلى ما هو أكثر من ذلك؛ فعندما يُصبِح الرتل فارغًا، نريد طريقةً تضطّر المستهلِكين للانتظار إلى أن يُوضَع شيءٌ جديدٌ بالرتل؛ وإذا أصبح ممتلئًا، نريد طريقةً تضطّر المُنتجِين للانتظار إلى أن يُتاح مكانٌ بالرتل. يُمثَّل كُلٌ من المُستهلِكين والمُنتجِين باستخدام الخيوط. إذا كان الخيط مُتوقِّفًا بانتظار حدوث شيءٍ معين، يُقَال أنه مُعطَّل blocked، ولذلك يُعدُّ الرتل المُعطِّل هو نوع الرتل الذي نحتاجه. عندما نَستخدِم رتلًا معطِّلًا، وكان ذلك الرتل فارغًا، ستؤدي عملية سحب dequeue عنصرٍ من الرتل إلى تعطيل المُستدعِي؛ أي إذا حاول خيطٌ معينٌ سحَب عنصرٍ من رتلٍ فارغ، فسيتوقَّف إلى أن يُتاح عنصرٌ جديد، وسيستيقظ عندها الخيط ويقرأ العنصر ويُكمِل عمله. بالمثل، إذا وصل الرتل إلى سعته القصوى، وحاول مُنتِجٌ معينٌ إدخال عنصرٍ به، فسيتعطَّل إلى أن يُتَاح مكانٌ بالرتل. تحتوي حزمة java.util.concurrent على صنفين يُنفِّذان الرتل المُعطِّل: LinkedBlockingQueue و ArrayBlockingQueue، وهما من الأنواع ذات المعاملات غير مُحدَّدة النوع parameterized types؛ أي يَسمحَا بتخصيص نوع العنصر الذي سيَحمله الرتل، ويُنفِّذ كلٌ منهما الواجهة BlockingQueue. إذا كان bqueue رتلًا مُعطِّلًا ينتمي إلى أحد الصنفين السابقين، فإنه يُعرِّف العمليات التالية: bqueue.take(): يَحذِف item من الرتل ويعيده؛ فإذا كان الرتل فارغًا عند استدعائه، يتعطَّل الخيط المُستدعِي إلى أن يُتاح عنصرٌ جديدٌ بالرتل. يُبلِّغ التابع عن استثناءٍ exception من النوع InterruptedException إذا قُوطَع الخيط أثناء تعطُّله. bqueue.put(item): يُضيِف item إلى الرتل. إذا كان للرتل سعةً قصوى وقد أصبح ممتلئًا، يتعطَّل الخيط المُستدعِي إلى أن يُتَاح مكانٌ بالرتل. يُبلِّغ التابع عن استثناءٍ من النوع InterruptedException إذا قُوطَع الخيط أثناء تعطُّله. bqueue.add(item): يُضيف item إلى الرتل في حالة وجود مكانٍ متاح. إذا كان للرتل سعةً قصوى وقد أصبح ممتلئًا، يُبلِّغ التابع عن استثناءٍ من النوع IllegalStateException، ولكنه لا يُعطِّل المُستدعِي. bqueue.clear(): يحذِف جميع العناصر من الرتل ويُهملها. تُعرِّف الأرتال المُعطِّلة بجافا الكثير من التوابع الأخرى. يتشابه التابع bqueue.poll(500) مثلًا مع التابع bqueue.take() باستثناء أنه يُعطِّل لمدة 500 ميللي ثانية بحدٍ أقصى، ولكن التوابع المذكورة بالأعلى كافيةٌ للأمثلة التالية. لاحِظ وجود تابعين لإضافة العناصر إلى الرتل؛ حيث يُعطِّل التابع bqueue.put(item) المُستدعِي إذا لم يَكُن هناك أي مكانٍ آخر متاحٍ بالرتل، ولذلك يُستخدَم مع أرتال التعطيل محدودة السعة؛ بينما لا يُعطِّل التابع bqueue.add(item) المُستدعِي، ولذلك يُستخدَم مع أرتال التعطيل التي تملُك سعةً غير محدودة. تُخصَّص السعة القصوى لرتلٍ من النوع ArrayBlockingQueue عند إنشائه. تُنشِئ الشيفرة التالية على سبيل المثال رتلًا مُعطِّلًا بإمكانه حَمْل ما يَصِل إلى 25 كائنٍ من النوع ItemType: ArrayBlockingQueue<ItemType> bqueue = new ArrayBlockingQueue<>(25); إذا اِستخدَمنا الرتل المُعرَّف بالأعلى، فسيُعطِّل التابع bqueue.put(item) المُستدعِي إذا كان bqueue يحتوي على 25 عنصرٍ بالفعل؛ بينما يُبلِّغ التابع bqueue.add(item) عن استثناءٍ في تلك الحالة. يضمَن ذلك عدم إنتاج العناصر بمعدّلٍ أسرع من مُعدّل استهلاكها. في المقابل، يُستخدَم الصنف LinkedBlockingQueue لإنشاء أرتالٍ مُعطِّلة بسعةٍ غير محدودة. ألقِ نظرةً على المثال التالي: LinkedBlockingQueue<ItemType> bqueue = new LinkedBlockingQueue<>(); تُنشِئ الشيفرة السابقة رتلًا بدون حدٍ أقصى لعدد العناصر التي يُمكِنه حملها. في تلك الحالة، لن يتسبَّب التابع bqueue.put(item) بحدوث تعطيل نهائيًا، ولن يُبلِّغ التابع bqueue.add(item) عن استثناءٍ من النوع IllegalStateException على الإطلاق. يُمكِننا اِستخدَام الصنف LinkedBlockingQueue إذا أردنا تجنُّب تعطيل المُنتجِين، ولكن من الجهة الأخرى، لا بُدّ أن نتأكَّد من بقاء الرتل بحجمٍ معقول. يؤدي التابع bqueue.take() إلى حدوث تعطيلٍ إذا كان الرتل فارغًا لكلا الصنفين. يَستخدِم البرنامج التوضيحي MultiprocessingDemo3.java رتلًا من الصنف LinkedBlockingQueue بدلًا من الصنف ConcurrentLinkedQueue المُستخدَم في النسخة السابقة من البرنامج MultiprocessingDemo2.java. يحتوي الرتل في هذا المثال على عدة مهام (أي العناصر المنتمية للنوع Runnable)، ويُصرَّح عنه على أنه تابع نسخة instance variable اسمه taskQueue على النحو التالي: LinkedBlockingQueue<Runnable> taskQueue; عندما ينقر المُستخدِم على زر "Start" لحساب الصورة، نُضيف جميع مهام حساب الصورة إلى ذلك الرتل باستدعاء التابع taskQueue.add(task) لكل مهمةٍ على حدى. من المهم أن يحدث ذلك دون تعطيل؛ لأننا نُنشِئ تلك المهام بخيط معالجة الأحداث events الذي لا ينبغي تعطيله. لن ينمو الرتل إلى ما لانهاية؛ لأن البرنامج يَعمَل على صورةٍ واحدةٍ فقط بكل مرة، وهناك مئاتٌ قليلةٌ من المهام للصورة الواحدة. على نحوٍ مشابه للنسخة السابقة من البرنامج، ستحذِف الخيوط العاملة المنتمية إلى مجمع الخيوط thread pool المهام من الرتل وتُنفِّذها، ولكنها -أي الخيوط- تُنشَئ مرةً واحدةً فقط ببداية البرنامج؛ أو بتعبيرٍ أدق عندما ينقر المُستخدِم على زر "Start" لأول مرة. يُعاد استخدام نفس تلك الخيوط لأي عددٍ من الصور، وفي حالة عدم وجود أي مهامٍ أخرى، سيُصبِح الرتل فارغًا، وستتعطَّل الخيوط إلى حين قدوم مهامٍ جديدة. تُنفِّذ تلك الخيوط حلقة تكرارٍ لا نهائية infinite loop، وتُعالِج المهام للأبد، ولكنها تقضِي وقتًا طويلًا معطَّلةً بانتظار إضافة مهمةٍ جديدةٍ إلى الرتل. انظر تعريف الصنف المُمثِل لتلك الخيوط: // 1 private class WorkerThread extends Thread { WorkerThread() { try { setPriority( Thread.currentThread().getPriority() - 1); } catch (Exception e) { } try { setDaemon(true); } catch (Exception e) { } start(); // يبدأ الخيط العمل بمجرد تشغيله } public void run() { while (true) { try { Runnable task = taskQueue.take(); // انتظر مهمة إذا كان ذلك ضروريًا task.run(); } catch (InterruptedException e) { } } } } [1] يُعرِّف هذا الصنف الخيوط العاملة الموجودة بمجمع الخيوط، حيث يَعمَل كائنٌ من هذا الصنف بحلقةٍ يَسترجِع كل تكرارٍ منها مهمةً من الرتل taskQueue ثم يستدعي التابع run() الخاص بتلك المهمة. إذا كان الرتل فارغًا، يتعطَّل الخيط إلى أن تتوفَّر مهمةٌ جديدةٌ بالرتل. يتولى الباني مهمة بدء الخيط، وبالتالي لن يضطر البرنامج main لفعل ذلك. يَعمَل الخيط بأولويةٍ أقل من أولوية الخيط الذي اِستدعَى الباني. صُمِّم الصنف لكي يَعمَل بحلقةٍ لا نهائية تنتهي فقط عند إغلاق آلة جافا الافتراضية Java virtual machine، وهذا على فرض عدم تبليغ المهام المُنفَّذة عن أي استثناءاتٍ وهو افتراضٌ صحيحٌ بهذا البرنامج. يَضبُط الباني الخيط ليعمل مثل خيطٍ خفي، وبالتالي تنتهي آلة جافا الافتراضية تلقائيًا عندما تكون الخيوط الوحيدة الموجودة من النوع الخفي، أي لا يَمنَع وجود تلك الخيوط آلة جافا من الإغلاق. ينبغي فحص طريقة عمل مجمع الخيوط، حيث تُنشَأ الخيوط وتُشغَّل قبل وجود أي مهمة. يَستدعِي كل خيطٍ منها التابع taskQueue.take() فورًا، ونظرًا لأن رتل المهام فارغ، تتعطَّل جميع الخيوط بمجرد تشغيلها. والآن، لكي نُعالِج صورةً معينة، يُنشِئ خيط معالجة الأحداث المهام الخاصة بتلك الصورة، ويُضيفها إلى الرتل. بمجرد حدوث ذلك، تعود الخيوط للعمل وتبدأ بمعالجة المهام، ويستمر الحال كذلك إلى أن يَفرُغ الرتل مرةً أخرى. في حالة تشغيل البرنامج بحاسوبٍ مُتعدّد المعالجات، تبدأ بعض الخيوط بمعالجة المهام المُضافة إلى الرتل بينما ما يزال خيط معالجة الأحداث مستمرٌ بإضافة المهام. عندما يُصبِح الرتل فارغًا، تتعطَّل الخيوط مجددًا إلى أن نرغب بمعالجة صورةٍ جديدة. إضافةً إلى ما سبق، قد نرغب بإلغاء معالجة صورةٍ معينةٍ قبل انتهائها، ولكننا لا نريد إنهاء الخيوط العاملة في تلك الحالة. عندما ينقر المُستخدِم على الزر "Abort"، يَستدعِي البرنامج التابع taskQueue.clear()، مما يَمنَع إسناد أي مهامٍ أخرى إلى الخيوط، ومع ذلك فمن المحتمل أن تكون بعض المهام قيد التنفيذ بالفعل بينما نُفرِّغ الرتل، وستكتمل بالتالي تلك المهام بعد إلغاء المعالجة المُفترَض كونهم أجزاءٌ منها، ولكننا لا نريد تطبيق خَرْج تلك المهام على الصورة. يُمكِننا حلّ تلك المشكلة بإسناد رقم وظيفة لكل وظيفة معالجة؛ حيث سيُخزَّن رقم الوظيفة الحالية بمتغير نسخة instance variable اسمه jobNum. ينبغي أن يحتوي كل كائن مُمثِّل لمهمة على تابع نسخة يُحدِّد الوظيفة التي تُعدّ تلك المهمة جزءًا منها. تزداد قيمة jobNum بمقدار الواحد عند انتهاء وظيفة؛ إما لأنها انتهت على نحوٍ طبيعي؛ أو لأن المُستخدِم قد ألغاها. عند اكتمال مهمةٍ معينة، لا بُدّ أن نوازن بين رقم الوظيفة المُخزَّن بكائن المهمة وبين jobNum؛ فإذا كانا متساويين، تكون المهمة جزءًا من الوظيفة الحالية، ويُطبَق خَرْجها على الصورة؛ أما إذا لم يكونا متساويين، تكون تلك المهمة جزءًا من الوظيفة السابقة، ويُهمَل خَرْجها. من المهم أن يكون الوصول إلى jobNum متزامنًا synchronized، وإلا قد يَفحَص خيطٌ معينٌ رقم الوظيفة بينما يزيده خيطٌ آخر، وعندها قد نَعرِض خرجًا معنيًّا لوظيفةٍ سابقة مع أننا ألغينا تلك الوظيفة. جميع التوابع التي تقرأ قيمة jobNum أو تُعدِّله في هذا البرنامج متزامنة. يُمكِنك قراءة شيفرة البرنامج لترى طريقة عملها. هناك ملاحظةٌ إضافية عن البرنامج "MultiprocessingDemo3"، وهي: نحن لم نُوفِّر أي طريقةٍ لإنهاء الخيوط العاملة ضمن ذلك البرنامج أي أنها ستستمر بالعمل إلى أن نُغلِق آلة جافا الافتراضية Java Virtual Machine. يُمكِننا السماح بإنهاء الخيوط قبل ذلك باستخدام متغيرٍ متطايرٍ volatile، اسمه running، وضبط قيمته إلى false عندما نرغب بإنهائها، وسنُعرِّف التابع run() الموجود بالخيوط على النحو التالي: public void run() { while ( running ) { try { Runnable task = taskQueue.take(); task.run(); } catch (InterruptedException e) { } } } ومع ذلك، إذا كان هناك خيطٌ مُعطّلٌ نتيجةً لاستدعاء taskQueue.take()، فلن يتمكَّن من رؤية القيمة الجديدة للمتغيّر running قبل أن يعود للعمل. لنتأكَّد من إنهائه، يُمكِننا استدعاء التابع worker.interrupt() لكل خيط worker بعد ضبط قيمة running إلى false. في حالة تنفيذ خيطٍ لمهمةٍ بينما نضبُط قيمة running إلى false، فإنه لن ينتهي حتى يُكمِل تلك المهمة. إذا كانت المهام قصيرةً نسبيًا، لن يُشكِّل ذلك مشكلة، ولكن إذا استغرقت المهام وقتًا أطول مما ترغب بانتظاره، فلا بُدّ أن تَفحَص المهام قيمة running دوريًا، وتنتهي إذا أصبحت قيمته مساويةً القيمة false. نهج ExecutorService لتنفيذ المهام يشيع استخدام مجمعات الخيوط thread pools بالبرمجة على التوازي، ولذلك، تُوفِّر جافا أدوات عالية المستوى لإنشاء مجمعات الخيوط وإدارتها. تُعرِّف الواجهة ExecutorService من حزمة java.util.concurrent خدماتٍ يُمكِنها تنفيذ المهام المُرسَلة إليها. يحتوي الصنف Executors على توابعٍ ساكنة static تُنشِئ أنواعًا مختلفةً من النوع ExecutorService. وبالأخص، يُنشِئ التابع Executors.newFixedThreadPool(n) مجمع خيوطٍ مُكوَّن من عدد n من الخيوط، حيث n هي عدد صحيح. تُنشِئ الشيفرة التالية مجمع خيوط مُكوّنٍ من خيطٍ واحدٍ لكل معالج: int processors = Runtime.getRuntime().availableProcessors(); ExecutorService executor = Executors.newFixedThreadPool(processors); يُستخدَم التابع executor.execute(task) لإرسال كائنٍ من النوع Runnable لتنفيذه، ويعود على الفور بعد وضعه للمهمة داخل رتل المهام المُنتظِرَة. تَحذِف الخيوط الموجودة بمجمع الخيوط المهام من الرتل وتُنفِّذها. يُخبِر التابع executor.shutdown() مجمع الخيوط بأن عليه الانتهاء بعد تنفيذ جميع المهام المُنتظِرَة، ويعود التابع على الفور دون أن ينتظر انتهاء الخيوط. بعد استدعاء ذلك التابع، لا يُسمَح بإضافة مهامٍ جديدة. يُمكِنك استدعاء shutdown() أكثر من مرة، ولن يُعدّ ذلك خطأً. لا تُعدّ الخيوط الموجودة بمجمع الخيوط خيوطًا خفية daemon threads؛ أي في حالة انتهاء الخيوط الأخرى دون إغلاق الخدمة، يكون وجود تلك الخيوط كافٍ لمنع إغلاق آلة جافا الافتراضية. يتشابه التابع executor.shutdownNow() مع التابع executor.shutdown()، إلا أنه يُهمِل المهام التي ما تزال قيد الانتظار بالرتل، وتُكمِل الخيوط المهام التي كانت قد حُذفت من الرتل بالفعل قبل الإغلاق. يَختلف البرنامج التوضيحي MultiprocessingDemo4.java عن البرنامج MultiprocessingDemo3؛ حيث يَستخدِم النوع ExecutorService بدلًا من الاستخدام المباشر للخيوط والأرتال المُعطِّلة. نظرًا لعدم وجود طريقةٍ بسيطةٍ تَسمَح للنوع ExecutorService بتجاهُل المهام المُنتظِرَة دون أن يُغلَق، يُنشِئ البرنامج "MultiprocessingDemo4" كائنًا جديدًا من النوع ExecutorService لكل صورة. يُمكِننا تمثيل المهام المُستخدَمة مع النوع ExecutorService بكائناتٍ من النوع Callable<T>، حيث يُمثِّل ذلك النوع واجهة نوع دالة functional interface ذات معاملاتٍ غير مُحدَّدة النوع، ويُعرِّف التابع call()، الذي لا يستقبل أي معاملاتٍ ويعيد النوع T. يُمثِل النوع Callable مهمةً تُخرِج قيمة. يُمكِننا إرسال كائنٍ c من النوع Callable إلى النوع ExecutorService باستدعاء التابع executor.submit(c)، حيث تُنفَّذ المهمة من النوع Callable بلحظةٍ ما في المستقبل. في تلك الحالة، كيف سنَحصُل على نتيجة المعالجة عند اكتمالها؟ يُمكِننا حل تلك المشكلة باستخدام واجهةٍ أخرى هي Future<T>، التي تُمثِّل قيمةً من النوع T قد تكون غير متاحةٍ حتى وقتٍ ما بالمستقبل. يعيد التابع executor.submit(c) قيمةً من النوع Future تُمثِّل نتيجة المعالجة المؤجَّلة. يُعرِّف كائنٌ v من النوع Future مجموعةً من التوابع، مثل الدالة المنطقية v.isDone() التي يُمكِننا استدعاؤها لفحص فيما إذا كانت نتيجة المعالجة قد أصبحت متاحة؛ وكذلك التابع v.get() الذي يسترجع نتيجة المعالجة المؤجَّلة، وسيُعطًّل إلى أن تُصبِح القيمة متاحة، كما قد يُبلِّغ عن استثناءات، ولذلك ينبغي استدعاؤه ضمن تعليمة try..catch. يَستخدِم المثال ThreadTest4.java الأنواع Callable و Future و ExecutorService لعدّ عدد الأعداد الأولية الواقعة ضمن نطاقٍ معينٍ من الأعداد الصحيحة؛ كما يُجرِي نفس المعالجة التي أجراها البرنامج ThreadTest2.java في قسم الإقصاء التشاركي Mutual Exclusion وتعليمة التزامن synchronized من مقال مقدمة إلى الخيوط Threads في جافا. ستَعُدّ كل مهمةٍ فرعية في هذا البرنامج عدد الأعداد الأولية ضمن نطاقٍ أصغر من الأعداد الصحيحة، وستُمثَّل تلك المهام الفرعية من خلال كائناتٍ من النوع Callable<Integer> المُعرَّفة بالصنف المتداخل nested التالي: // 1 private static class CountPrimesTask implements Callable<Integer> { int min, max; public CountPrimesTask(int min, int max) { this.min = min; this.max = max; } public Integer call() { int count = countPrimes(min,max); // يبدأ بالعدّ return count; } } [1] تعدّ الكائنات المنتمية إلى هذا الصنف الأعداد الأولية الموجودة ضمن نطاقٍ معين من الأعداد الصحيحة من min إلى max. تُمرَّر قيمة المتغيرين min و max مثل معاملاتٍ للباني. يحسب التابع call() عدد الأعداد الأولية ثم يعيدها. ستُرسَل جميع المهام الفرعية إلى مجمع خيوط مُنفَّذ باستخدام النوع ExecutorService، وتُخزَّن النتائج من النوع Future التي يعيدها داخل مصفوفةٍ من النوع ArrayList. ألقِ نظرةً على الشيفرة التالية: int processors = Runtime.getRuntime().availableProcessors(); ExecutorService executor = Executors.newFixedThreadPool(processors); ArrayList<Future<Integer>> results = new ArrayList<>(); for (int i = 0; i < numberOfTasks; i++) { CountPrimesTask oneTask = . . . ; Future<Integer> oneResult = executor.submit( oneTask ); results.add(oneResult); // خزِّن الكائن الذي يُمثِّل النتيجة المؤجَلة } لا بُدّ أن نُضيف الأعداد الصحيحة الناتجة عن المهام الفرعية إلى المجموع النهائي. سنحصل أولًا على خَرْج تلك المهام باستدعاء التابع get() لعناصر المصفوفة المنتمية إلى النوع Future. لن تكتمل العملية إلا بعد انتهاء جميع المهام الفرعية، لأن التابع يُعطِّل المُستدعِي إلى أن تتوفَّر النتيجة. ألقِ نظرةً على الشيفرة التالية: int total = 0; for ( Future<Integer> res : results) { try { total += res.get(); // انتظر اكتمال المهمة } catch (Exception e) { // لا ينبغي أن تحدث بهذا البرنامج } } تابعا الانتظار Wait والتنبيه Notify إذا كنا نريد كتابة تنفيذٍ للرتل المعطِّل، فينبغي أن نُعطِّل الخيط إلى حين وقوع حدثٍ معين؛ أي أن ينتظر الخيط وقوع ذلك الحدث، وينبغي أن نُبلِّغه عند وقوعه بطريقةٍ ما. سنَستخدِم لذلك خيطين؛ حيث يقع الفعل المُسبِّب للحدث المنتظَر (مثل إضافة عنصرٍ إلى رتل) بخيطٍ غير الخيط المُعطَّل. لا يُمثِّل ما يَلي مشكلةً للأرتال المُعطِّلة فقط؛ ففي حالة وجود خيطٍ يُنتِج خرجًا يحتاج إليه خيطٌ آخر، فإن ذلك يَفرِض نوعًا من التقييد على الترتيب الذي ينبغي للخيوط أن تُنفِّذ العمليات على أساسه. إذا وصلنا إلى النقطة التي يحتاج خلالها الخيط الثاني إلى الخرج الناتج عن الخيط الأول، قد يضطّر الخيط الثاني إلى التوقُّف وانتظار إتاحة ذلك الخرج؛ ونظرًا لأنه لا يستطيع الاستمرار، فإنه قد ينام sleep، ولا بُدّ في تلك الحالة من توفير طريقةٍ لتنبيهه عندما يُصبِح الخرج متاحًا، حتى يستيقظ ويُكمِل عملية المعالجة. تُوفِّر جافا بالطبع طريقةً لتنفيذ هذا النوع من الانتظار والتنبيه؛ حيث يحتوي الصنف Object على تابعي النسخة wait() و notify()، ويُمكِن استخدامهما مع أي كائن، كما يمكن للأرتال المعطِّلة اِستخدَام تلك التوابع ضمن تنفيذها الداخلي، ولكنها منخفضة المستوى وعرضةً للأخطاء؛ ولذلك يُفضَّل اِستخدام أساليب التحكُّم عالية المستوى مثل أرتال الأولوية قدر الإمكان. مع ذلك، من الجيد معرفة القليل عن التابعين wait() و notify()، فلربما قد تحتاج إلى استخدامهما مباشرةً. من غير المعروف فيما إذا كانت أصناف جافا القياسية للأرتال المُعطِّلة تَستخدِم هذين التابعين فعليًا، خاصةً مع توقُّر طرائقٍ أخرى لحل مشكلة الانتظار والتنبيه. السبب وراء ضرورة ربط التابعين wait() و notify() بالكائنات واضح، وبالتالي ليس هناك داعٍ للقلق بشأن ذلك، فهو يَسمَح على الأقل بتوجيه تنبيهاتٍ من أنواعٍ مختلفة إلى مستقبلين من أنواعٍ مختلفة اعتمادًا على تابع الكائنnotify() المُستدعى. عندما يَستدعِي خيطٌ ما التابع wait() الخاص بكائنٍ معين، يتوقَّف ذلك الخيط وينام إلى حين استدعاء التابع notify() الخاص بنفس الكائن، حيث سيكون استدعاؤه ضروريًا من خلال خيطٍ آخر؛ لأن الخيط الذي اِستدعَى wait() سيكون نائمًا. تَعمَل إحدى الأنماط الشائعة على النحو التالي: يستدعِي خيط A التابع wait() عندما يحتاج إلى الخرج الناتج من خيط B، ولكن ذلك الخرج غير متاحٍ بعد. عندما يُحصِّل الخيط B الخرج المطلوب، فإنه يَستدعِي التابع notify() الذي سيوقِظ الخيط A إذا كان منتظرًا ليتمكَّن من اِستخدَام الناتج. في الواقع، ليس من الخطأ استدعاء التابع notify() حتى لو لم يَكُن هناك أي خيوطٍ مُنتظِرَة، فليس لها أي تأثير. لنُنفِّذ ذلك، ينبغي أن يُنفِّذ الخيط A شيفرةً مشابهةً لما يلي، حيث obj هو كائن: if ( resultIsAvailable() == false ) obj.wait(); // انتظر تنبيهًا بأن النتيجة مُتاحة useTheResult(); بينما ينبغي أن يُنفِّذ الخيط B شيفرةً مشابهةً لما يَلي: generateTheResult(); obj.notify(); // أرسل تنبيهًا بأن النتيجة قد أصبحت متاحة تعاني تلك الشيفرة من حالة تسابق race condition، فقد يُنفِّذ الخيطان شيفرتهما بالترتيب التالي: // يفحص الخيط A التابع `resultIsAvailable()` ولا يجد النتيجة بعد، لذلك، يُنفِّذ تعليمة `obj.wait()`، ولكن قبل أن يفعل، 1. Thread A checks resultIsAvailable() and finds that the result is not ready, so it decides to execute the obj.wait() statement, but before it does, // ينتهي الخيط B من عمله ويَستدعِي التابع `obj.notify()` 2. Thread B finishes generating the result and calls obj.notify() // يَستدعِي الخيط A التابع `obj.wait()` لينتظر تنبيهًا بتوفُّر النتيجة 3. Thread A calls obj.wait() to wait for notification that the result is ready. ينتظر الخيط A بالخطوة الثالثة تنبيهًا لن يحدث أبدًا؛ لأن notify() قد اُستدعيَت بالفعل بالخطوة الثانية. يُمثِّل ذلك نوعًا من القفل الميت deadlock الذي يُمكِنه أن يترك الخيط A مُنتظِرًا للأبد. نحتاج إذًا إلى نوعٍ من المزامنة synchronization. يكمن حل تلك المشكلة في وضع شيفرة الخيطين A و B داخل تعليمة synchronized، ومن البديهي أن تكون المزامنة بناءً على نفس الكائن obj المُستخدَم عند استدعاء wait() و notify(). نظرًا لأهمية استخدام المزامنة عند كل استدعاءٍ للتابعين wait() و notify() تقريبًا، جعلته جافا أمرًا ضروريًا؛ أي بإمكان خيطٍ معينٍ استدعاء obj.wait() أو obj.notify() فقط إذا كان ذلك الخيط قد حَصَل على قفل المزامنة المُرتبِط بالكائن obj؛ أما إذا لم يَكُن قد حَصَل عليه، يحدث استثناء من النوع IllegalMonitorStateException. لا يتطلَّب هذا الاستثناء معالجةً إجباريةً ولا يُلتقَط على الأرجح. علاوةً على ذلك، قد يُبلِّغ التابع wait() عن اتستثناءٍ من النوع InterruptedException، ولذلك لا بُدّ من استدعائه ضمن تعليمة try لمعالجته. لنفحص الآن طريقة وصول خيطٍ معينٍ إلى نتيجةٍ يحسبها خيطٌ آخر. يُعدّ ذلك مثالًا مبسطًا على مشكلة المُنتِج والمُستهلِك producer/consumer، حيث يُنتَج عنصرٌ واحدٌ فقط ثم يُستهلَك. لنفترض أن لدينا متغيرًا تشاركيًا، اسمه sharedResult مُستخدَمٌ لنقل النتيجة من المُنتِج إلى المُستهلِك. عندما تُصبِح النتيجة جاهزة، يضبُط المُنتِج ذلك المتغير إلى قيمةٍ غير فارغة. يُحدِّد المُستهلِك من الجهة الأخرى فيما إذا كانت النتيجة جاهزةً أم لا بفحص قيمة المتغير sharedResult إذا كانت فارغة. سنَستخدِم مُتغيّرًا اسمه lock للمزامنة. يُمكِننا كتابة شيفرة الخيط المُمثِّل للمُنتِج على النحو التالي: makeResult = generateTheResult(); // غير متزامن synchronized(lock) { sharedResult = makeResult; lock.notify(); } بينما سيُنفِّذ المُستهلِك الشيفرة التالية: synchronized(lock) { while ( sharedResult == null ) { try { lock.wait(); } catch (InterruptedException e) { } } useResult = sharedResult; } useTheResult(useResult); // Not synchronized! لاحِظ أن استدعاء كُلٍ من التابعين generateTheResult() و useTheResult() غير متزامن، لنَسمَح بتنفيذهما على التوازي مع الخيوط الأخرى التي قد تُجرِي تزامنًا بناءً على lock، ولكن نظرًا لأن المتغير sharedResult تشاركي، كان من الضروري أن تكون جميع مراجِعه references متزامنة؛ أي لا بُدّ من كتابتها داخل تعليمة synchronized، مع محاولة تنفيذ أقل ما يُمكِن عمومًا داخل كتل الشيفرة المتزامنة. ربما لاحظت شيئًا مضحكًا بالشيفرة: لا ينتهي lock.wait() قبل تنفيذ lock.notify()، ولكن نظرًا لأن كليهما مكتوبٌ داخل تعليمة synchronized بتزامنٍ مبني على الكائن نفسه، قد تتساءل: أليس من المستحيل تنفيذ هذين التابعين بنفس الوقت؟ في الواقع، يُعدّ التابع lock.wait() حالةً خاصة؛ فعندما يَستدعِي خيطٌ ما التابع lock.wait()، فإنه يترك قفله بالضرورة على كائن المزامنة، مما يَسمَح لخيطٍ آخرٍ بتنفيذ كتلة شيفرة داخل تعليمة synchronized(lock) أخرى يوجد بداخلها استدعاءٌ للتابع lock.notify(). وبالتالي، بعدما يُنهِي الخيط الثاني تنفيذ تلك الكتلة، يعود القفل إلى الخيط الأول المُستهلِك مما يُمكِّنه من إكمال عمله. تُنتَج في نمط المُنتِج والمُستهلِك العادي عدة نتائجٍ بواسطة خيط مُنتِجٍ واحدٍ أو أكثر، وتُستهلَك بواسطة خيط مُستهلِكٍ واحدٍ أو أكثر، وبدلًا من وجود كائن sharedResult وحيد، تجد قائمةً بالكائنات المُنتَجَة التي لم تُستهلَك بعد. لنفحص طريقة فعل ذلك باستخدام صنفٍ بسيطٍ للغاية يُنفِّذ العمليات الثلاثة على رتلٍ من النوع LinkedBlockingQueue<Runnable>، الذي اِستخدَمناه بالبرنامج MultiprocessingDemo3. ألقِ نظرةً على الشيفرة التالية: import java.util.LinkedList; public class MyLinkedBlockingQueue { private LinkedList<Runnable> taskList = new LinkedList<Runnable>(); public void clear() { synchronized(taskList) { taskList.clear(); } } public void add(Runnable task) { synchronized(taskList) { taskList.addLast(task); taskList.notify(); } } public Runnable take() throws InterruptedException { synchronized(taskList) { while (taskList.isEmpty()) taskList.wait(); return taskList.removeFirst(); } } } سنَستخدِم كائنًا من ذلك الصنف بديلًا عن الكائن taskQueue بالبرنامج "MultiprocessingDemo3". فضَّلنا إجراء المزامنة بناءً على الكائن taskList، ولكن كان من الممكن إجراؤها بناءً على أي كائنٍ آخر. يُمكِننا في الحقيقة استخدام توابعٍ متزامنة synchronized methods، وهو ما سيُكافِئ المزامنة بناءً على this. من الضروري أن يكون استدعاء التابع taskList.clear() مبنيًا على نفس الكائن حتى لو لم نَستدعِي wait() أو notify()؛ وإذا لم نَفعَل ذلك، قد تحدث حالة تسابق race condition، ألا وهي: قد تُفرَّغ القائمة بعدما يتأكَّد التابع take() من أن القائمة taskList غير فارغة وقبل أن يحاول حذف عنصرٍ منها. في تلك الحالة، ستُصبِح القائمة فارغة عند لحظة استدعاء taskList.removeFirst() مما سيتسبَّب بحدوث خطأ. في حالة تواجد عدة خيوطٍ متزامنة بناءً على كائن obj ومُنتظِرةٍ للتنبيه. يُوقِظ التابع obj.notify() عند استدعائه واحدًا فقط من تلك الخيوط المُنتظِرة؛ وإذا أردت أن توقظها جميعًا، ينبغي أن تَستدعِي التابع obj.notifyAll(). من المناسب اِستخدَام التابع obj.notify() بالمثال السابق؛ لأن الخيوط المُستهلِكة فقط هي الخيوط المُعطَّلة، ونحن نريد إيقاظ مُستهلِكٍ واحدٍ فقط عند إضافة مهمةٍ إلى الرتل، ولا يُهِم أي مُستهلِكٍ تُسنَد إليه المهمة. في المقابل، إذا كان لدينا رتلٌ مُعطِّل blocking queue بسعةٍ قصوى، أي أنه قد يُعطِّل المُنتجِين أو المُستهلِكِين؛ فعند إضافة مهمةٍ إلى الرتل، ينبغي التأكُّد من تنبيه خيط مُستهلِكٍ لا خيط مُنتِج، ويُمثِّل استدعاء التابع notifyAll() بدلًا من التابع notify() إحدى حلول تلك المشكلة، لأنه سيُنبِّه جميع الخيوط بما في ذلك أي خيط مُستهلِكٍ مُنتظِر. قد يعطيك اسم التابع obj.notify() انطباعًا خاطئًا. لا يُنبِّه ذلك التابع الكائن obj بأي شيء، وإنما يُنبِّه الخيط الذي اِستدعَى التابع obj.wait() إذا كان موجودًا. بالمثل، لا ينتظر الكائن obj بالاستدعاء obj.wait() أي شيء، وإنما الخيط المُستدعِي هو من ينتظر. وفي ملاحظة أخيرة بخصوص wait: هناك نسخةٌ أخرى من التابع wait()، وهي تَستقبِل زمنًا بوحدة الميللي ثانية مثل مُعامِل؛ وهنا سينتظر الخيط المُستدعِي للتابع obj.wait(milliseconds) تنبيهًا لفترةٍ تَصِل إلى القيمة الُممرَّرة بحدٍ أقصى؛ وإذا لم يحدث التنبيه خلال تلك الفترة، يستيقظ الخيط ويُكمِل عمله دون تنبيه. تُستخدَم تلك الخاصية عمليًا لتَسمَح لخيطٍ مُنتظِر بالاستيقاظ كل فترة لإنجاز مهمةٍ دوريةٍ معينة، مثل التسبُّب في ظهور رسالة مثل "Waiting for computation to finish". لنفحص الآن مثالًا يَستخدِم التابعين wait() و notify() ليَسمَح لخيطٍ بالتحكُّم بخيطٍ آخر. يَحِلّ البرنامج التوضيحي TowersOfHanoiGUI.java مسألة أبراج هانوي التي تعرَّضنا لها بالقسم مشكلة أبراج هانوي Hanoi من مقال التعاود recursion في جافا، ويُوفِّر أزرارًا تَسمَح للمُستخدِم بالتحكُّم بتنفيذ الخوارزمية. يَستطيع المُستخدِم مثلًا النقر على زر "Next Step" ليُنفِّذ خطوةً واحدةً من الحل، والتي تُحرِّك قرصًا واحدًا من كومةٍ لأخرى. عند النقر على زر "Run"، تُنفَّذ الخوارزمية أتوماتيكيًا دون تدخُّل المُستخدِم، ويتبدَّل النص المكتوب على الزر من "Run" إلى "Pause". عند النقر على "Pause"، يتوقَّف التشغيل التلقائي. يُوفِّر البرنامج الزر "Start Over"، الذي يُلغي الحل الحالي، ويعيد المسألة إلى حالتها الابتدائية. تَعرِض الصورة التالية شكل البرنامج بإحدى خطوات الحل، ويُمكِنك رؤية الأزرار المذكورة: يوجد خيطان بهذا البرنامج؛ حيث يُنفِّذ الأول خوارزميةً تعاودية recursive لحلّ المسألة؛ ويُعالِج الآخر الأحداث الناتجة عن أفعال المُستخدِم. عندما ينقر المُستخدِم على أحد الأزرار، تُستدعَى إحدى التوابع بخيط معالجة الأحداث، ولكن من يَستجِيب فعليًا للحدث هو الخيط المُنفِّذ للتعاود؛ فقد يُنفِّذ مثلًا خطوةً واحدةً من الحل أو يبدأه من جديد. لا بُدّ أن يُرسِل خيط معالجة الأحداث نوعًا من الإشارة إلى خيط الحل من خلال ضبط قيمة متغيرٍ يتشاركه الخيطان. اسم هذا المتغير بالبرنامج هو status، وقيمه المُحتمَلة هي الثوابت GO و PAUSE و STEP و RESTART. عندما يُعدِّل خيط معالجة الأحداث قيمة ذلك المتغيّر، لا بُدّ أن يلاحظ خيط الحل القيمة الجديدة للمتغير، ويستجيب على أساسها؛ فإذا كانت قيمة status هي PAUSE، لا بُدّ أن يتوقَّف الخيط بانتظار نَقْر المُستخدِم على زر "Run" أو "Next Step"، ويُمثِّل ذلك الحالة المبدئية عند بدء البرنامج؛ أما إذا نقر المُستخدِم على زر "Next Step"، يَضبُط خيط معالجة الأحداث قيمة status إلى "STEP"، وبالتتابع، لا بُدّ أن يلاحِظ خيط الحل القيمة الجديدة، ويستجيب بتنفيذ خطوةٍ واحدةٍ من الحل، ثم يعيد ضَبْط قيمة status إلى PAUSE مرةً أخرى. إذا نقر المُستخدِم على زر "Run"، تُضبَط قيمة status إلى "GO"، وينبغي أن يُنفِّذ خيط الحل الخوارزمية أتوماتيكيًا؛ وإذا نقر المُستخدِم على زر "Pause" بينما الحل مُشغَّل، تُضبَط قيمة status إلى "PAUSE"، وينبغي أن يعود خيط الحل إلى حالة الإيقاف؛ أما إذا نقر المُستخدِم على زر "Start Over"، يَضبُط خيط معالجة الأحداث قيمة المُتغيّر status إلى "RESTART"، ولا بُدّ أن يُنهِي خيط الحل حلّه الحالي. ما يُهمّنا بهذا المثال هو الحالة التي يتوقَّف خلالها خيط الحل؛ حيث يكون الخيط نائمًا في تلك الحالة، ولا يكون بإمكانه رؤية القيمة الجديدة للمتغير status إلا إذا أيقظناه. سنَستخدِم التابع wait() بخيط الحل لجعله ينام، وسنَستخدِم التابع notify() بخيط معالجة الأحداث عندما نُعدِّل قيمة المتغير status لكي نُوقِظ خيط الحل. تعرض الشيفرة التالية التوابع التي تَستجيب لحدث النقر على الأزرار. عندما ينقر المُستخدِم على زر معين، يُعدِّل التابع المقابل لذلك الزر قيمة المتغير status، ثم يَستدعِي التابع notify() لكي يُوقِظ خيط الحل: synchronized private void doStopGo() { if (status == GO) { // التحريكة مُشغَّلة. أوقفها status = PAUSE; nextStepButton.setDisable(false); runPauseButton.setText("Run"); } else { // Animation is paused. Start it running. status = GO; nextStepButton.setDisable(true); // يُعطَّل عند تشغيل التحريكة runPauseButton.setText("Pause"); } notify(); // أيقظ الخيط ليتمكَّن من رؤية الحالة الجديدة } synchronized private void doNextStep() { status = STEP; notify(); } synchronized private void doRestart() { status = RESTART; notify(); } لاحِظ أن تلك التوابع متزامنة لتَسمَح باستدعاء notify(). تذكَّر أنه لا بُدّ للخيط المُستدعِي للتابع notify() ضمن كائنٍ معين أن يكون قد حَصَل على قفل المزامنة المُرتبِط بذلك الكائن. في هذه الحالة، يكون كائن المزامنة هو this. تُعدّ المزامنة ضروريةً لأنه من الممكن أن تحدث حالات التسابق نظرًا لإمكانية خيط الحل أن يُعدِّل قيمة المتغير status. يَستدعِي خيط الحل تابعًا اسمه checkStatus() ليَفحَص قيمة status؛ فإذا كانت قيمة status تُساوِي "PAUSE"، يَستدعِي ذلك التابع بدوره التابع wait() مما يؤدي إلى توقُّف خيط الحل إلى حين استدعاء خيط معالجة الأحداث للتابع notify(). لاحِظ أن التابع checkStatus() يُبلِّغ عن استثناءٍ من النوع IllegalStateException إذا كانت قيمة status تُساوِي "RESTART": synchronized private void checkStatus() { while (status == PAUSE) { try { wait(); } catch (InterruptedException e) { } } // بالوصول إلى تلك النقطة، تكون الحالة RUN أو STEP أو RESTART if (status == RESTART) throw new IllegalStateException("Restart"); // بالوصول إلى تلك النقطة، تكون الحالة RUN أو STEP وينبغي أن يستمر الحل } يَضبُط التابع run() الخاص بخيط الحل الحالة المبدئية للمسألة، ثم يَستدعِي التابع solve() لحلها، كما يُنفِّذ حلقةً لا نهائية ليتمكَّن من حل المسألة عدة مرات. يَستدعِي التابع run() التابع checkStatus() قبل أن يبدأ الحل، ويَستدعِي التابع solve() التابع checkStatus() بعد كل حركة. إذا بلَّغ التابع checkStatus() عن استثناءِ من النوع IllegalStateException، يُنهَى استدعاء solve() مبكرًا. كنا قد استخدمنا نفس طريقة التبليغ عن استثناء لإنهاء خوارزميةٍ تعاوديةٍ من قبل بالقسم التعاود داخل الخيوط من المقال السابق. يُمكِنك الإطلاع على الشيفرة الكاملة للبرنامج TowersOfHanoiGUI.java لترى الطريقة التي دمجنا بها جميع تلك الأجزاء إلى البرنامج النهائي، وسيُعينك فهمه على تعلُم طريقة استخدام wait() و notify() مباشرةً. ترجمة -بتصرّف- للقسم Section 3: Threads and Parallel Processing من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: البرمجة باستخدام الخيوط threads في جافا مقدمة إلى الخيوط Threads في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا
-
لدى أقسام علوم الحاسوب هوس غير طبيعي بخوارزميات الترتيب، فبناءً على الوقت الذي يمضيه طلبة علوم الحاسوب في دراسة هذا الموضوع، قد تظن أن الاختيار ما بين خوارزميات الترتيب هو حجر الزاوية في هندسة البرمجيات الحديثة. واقع الأمر هو أن مطوري البرمجيات قد يقضون سنوات قد تصل إلى مسارهم المهني بأكمله دون التفكير في طريقة حدوث عملية الترتيب، فهم يَستخدِمون في كل التطبيقات تقريبًا الخوارزمية متعددة الأغراض التي توفِّرها اللغة أو المكتبة التي يستخدمونها، والتي تكون كافيةً في معظم الحالات. لذلك لو تجاهلت هذه المقالة ولم تتعلم أي شيء عن خوارزميات الترتيب، فما يزال بإمكانك أن تصبح مُطوِّر برمجيات جيدًا، ومع ذلك، هناك عدة أسباب قد تدفعك لقراءته: على الرغم من وجود خوارزميات متعددة الأغراض يُمكِنها العمل بشكل جيد في غالبية التطبيقات، هنالك خوارزميّتان مُتخصّصتان قد تحتاج إلى معرفة القليل عنهما: الترتيب بالجذر radix sort والترتيب بالكومة المقيدّة bounded heap sort. تُعدّ خوارزمية الترتيب بالدمج merge sort المثال التعليميّ الأمثل، فهي تُوضِّح استراتيجية "قسِّم واغزُ divide and conquer" المهمة والمفيدة في تصميم الخوارزميات. بالإضافة إلى ذلك، ستتعلم عن ترتيب نمو order of growth لم تَرَهُ من قبل، هو ترتيب النمو "الخطي-اللوغاريتمي linearithmic". ومن الجدير بالذكر أن غالبية الخوارزميات الشهيرة تكون عادةً خوارزميّاتٍ هجينةً وتستخدم بشكلٍ أو بآخر فكرة الترتيب بالدمج. أحد الأسباب الأخرى التي قد تدفعك إلى تعلم خوارزميات الترتيب هي مقابلات العمل التقنية، فعادةً ما تُسأل خلالها عن تلك الخوارزميات. إذا كنت تريد الحصول على وظيفة، فسيُساعدك إظهار اطلاعك على أبجديات علوم الحاسوب. سنُحلِّل في هذا الفصل خوارزمية الترتيب بالإدراج insertion sort، وسننفِّذ خوارزمية الترتيب بالدمج، وأخيرًا، سنشرح خوارزمية الترتيب بالجذر، وسنكتب نسخةً بسيطةً من خوارزمية الترتيب بالكومة المُقيّدة. الترتيب بالإدراج Insertion sort سنبدأ بخوارزمية الترتيب بالإدراج، لأنها بسيطة ومهمة. على الرغم من أنها ليست الخوارزمية الأكفأ إلا أنها تملك بعض الميزات المتعلقة بتحرير الذاكرة كما سنرى لاحقًا. لن نشرح هذه الخوارزمية هنا، ولكن يُفضّلُ لو قرأت مقالة ويكيبيديا عن الترتيب بالإدراج Insertion Sort، فهي تحتوي على شيفرة وهمية وأمثلة متحركة. وبعدما تفهم فكرتها العامة يمكنك متابعة القراءة هنا. تَعرِض الشيفرة التالية تنفيذًا بلغة جافا لخوارزمية الترتيب بالإدراج: public class ListSorter<T> { public void insertionSort(List<T> list, Comparator<T> comparator) { for (int i=1; i < list.size(); i++) { T elt_i = list.get(i); int j = i; while (j > 0) { T elt_j = list.get(j-1); if (comparator.compare(elt_i, elt_j) >= 0) { break; } list.set(j, elt_j); j--; } list.set(j, elt_i); } } } عرَّفنا الصنف ListSorter ليَعمَل كحاوٍ لخوارزميات الترتيب. نظرًا لأننا استخدمنا معامل نوع type parameter، اسمه T، ستتمكَّن التوابع التي سنكتبها من العمل مع قوائم تحتوي على أي نوع من الكائنات. يستقبل التابع insertionSort معاملين: الأول عبارة عن قائمة من أي نوع ممتدّ من الواجهة List والثاني عبارة عن كائن من النوع Comparator بإمكانه موازنة كائنات النوع T. يُرتِّب هذا التابع القائمة في نفس المكان أي أنه يُعدِّل القائمة الموجودة ولا يحتاج إلى حجز مساحة إضافية جديدة. تَستدعِي الشيفرة التالية هذا التابع مع قائمة من النوع List تحتوي على كائنات من النوع Integer: List<Integer> list = new ArrayList<Integer>( Arrays.asList(3, 5, 1, 4, 2)); Comparator<Integer> comparator = new Comparator<Integer>() { @Override public int compare(Integer elt1, Integer elt2) { return elt1.compareTo(elt2); } }; ListSorter<Integer> sorter = new ListSorter<Integer>(); sorter.insertionSort(list, comparator); System.out.println(list); يحتوي التابع insertionSort على حلقتين متداخلتين nested loops، ولذلك، قد تظن أن زمن تنفيذه تربيعي، وهذا صحيحٌ في تلك الحالة، ولكن قبل أن تتوصل إلى تلك النتيجة، عليك أولًا أن تتأكّد من أن عدد مرات تنفيذِ كل حلقةٍ يتناسب مع حجم المصفوفة n. تتكرر الحلقة الخارجية من 1 إلى list.size()، ولذلك تُعدّ خطيّةً بالنسبة لحجم القائمة n، بينما تتكرر الحلقة الداخلية من i إلى صفر، لذلك هي أيضًا خطيّة بالنسبة لقيمة n. بناءً على ذلك، يكون عدد مرات تنفيذ الحلقة الداخلية تربيعيًّا. إذا لم تكن متأكّدًا من ذلك، انظر إلى البرهان التالي: في المرة الأولى، قيمة i تساوي 1، وتتكرر الحلقة الداخليّة مرةً واحدةً على الأكثر. في المرة الثانية، قيمة i تساوي 2، وتتكرر الحلقة الداخليّة مرتين على الأكثر. في المرة الأخيرة، قيمة i تساوي n-1، وتتكرر الحلقة الداخليّة عددًا قدره n-1 من المرات على الأكثر. وبالتالي، يكون عدد مرات تنفيذ الحلقة الداخلية هو مجموع المتتالية 1، 2، … حتى n-1، وهو ما يُساوِي n(n-1)/2. لاحِظ أن القيمة الأساسية (ذات الأس الأكبر) بهذا التعبير هي n2. يُعدّ الترتيب بالإدراج تربيعيًا في أسوأ حالة، ومع ذلك: إذا كانت العناصر مُرتَّبةً أو شبه مُرتَّبةٍ بالفعل، فإن الترتيب بالإدراج يكون خطّيًّا. بالتحديد، إذا لم يكن كل عنصرٍ أبعدَ من موضعه الصحيح مسافةً أكبرَ من k، فإن الحلقة الداخلية لن تُنفَّذ أكثر من عددٍ قدره k من المرات، ويكون زمن التنفيذ الكلي هو O(kn). نظرًا لأن تنفيذ تلك الخوارزمية بسيط، فإن تكلفته منخفضة، أي على الرغم من أن زمن التنفيذ يساوي a n2، إلا أن المعامل a قد يكون صغيرًا. ولذلك، إذا عرفنا أن المصفوفة شبه مُرتَّبة أو إذا لم تكن كبيرةً جدًا، فقد يكون الترتيب بالإدراج خيارًا جيدًا، ولكن بالنسبة للمصفوفات الكبيرة، فهنالك خيارات أفضل بكثير. تمرين 14 تُعدّ خوارزمية الترتيب بالدمج merge sort واحدةً من ضمن مجموعةٍ من الخوارزميات التي يتفوق زمن تنفيذها على الزمن التربيعيّ. ننصحك قبل المتابعة بقراءة مقالة ويكيبيديا عن الترتيب بالدمج merge sort. بعد أن تفهم الفكرة العامة للخوارزمية، يُمكِنك العودة لاختبار فهمك بكتابة تنفيذٍ لها. ستجد ملفات الشيفرة التالية الخاصة بالتمرين في مستودع الكتاب: ListSorter.java ListSorterTest.java نفِّذ الأمر ant build لتصريف ملفات الشيفرة ثم نفِّذ الأمر ant ListSorterTest. سيفشل الاختبار كالعادة لأن ما يزال عليك إكمال بعض الشيفرة. ستجد ضمن الملف ListSorter.java شيفرةً مبدئيّةً للتابعين mergeSortInPlace و mergeSort: public void mergeSortInPlace(List<T> list, Comparator<T> comparator) { List<T> sorted = mergeSortHelper(list, comparator); list.clear(); list.addAll(sorted); } private List<T> mergeSort(List<T> list, Comparator<T> comparator) { // TODO: fill this in! return null; } يقوم التابعان بنفس الشيء، ولكنهما يوفران واجهاتٍ مختلفة. يَستقبِل التابع mergeSort قائمةً ويعيد قائمةً جديدةً تحتوي على نفس العناصر بعد ترتيبها ترتيبًا تصاعديًا. في المقابل، يُعدِّل التابع mergeSortInPlace القائمة ذاتها ولا يعيد أيّة قيمة. عليك إكمال التابع mergeSort. ويمكنك بدلًا من كتابة نسخة تعاودية recursive بالكامل، أن تتبع الطريقة التالية: قسِّم القائمة إلى نصفين. رتِّب النصفين باستخدام التابع Collections.sort أو التابع insertionSort. ادمج النصفين المُرتَّبين إلى قائمة واحدة مُرتَّبة. سيعطيك هذا التمرين الفرصة لتنقيح شيفرة الدمج دون التعامل مع تعقيدات التوابع التعاودية. والآن أضف حالة أساسية base case. إذا كان لديك قائمة تحتوي على عنصر واحد فقط، يُمكِنك أن تعيدها مباشرةً لأنها نوعًا ما مُرتَّبة بالفعل، وإذا كان طول القائمة أقل من قيمة معينة، يُمكِنك أن تُرتِّبها باستخدام التابع Collections.sort أو التابع insertionSort. اختبر الحالة الأساسية قبل إكمال القراءة. أخيرًا، عدِّل الحل واجعله يُنفِّذ استدعاءين تعاوديّين لترتيب نصفي المصفوفة. إذا عدلته بالشكل الصحيح، ينبغي أن ينجح الاختباران testMergeSort و testMergeSortInPlace. تحليل أداء خوارزمية الترتيب بالدمج لكي نصنف زمن تنفيذ خوارزمية الترتيب بالدمج، علينا أن نفكر بمستويات التعاود وبكمية العمل المطلوب في كل مستوى. لنفترض أننا سنبدأ بقائمةٍ تحتوي على عددٍ قدره n من العناصر. وفيما يلي خطوات الخوارزمية: نُنشِئ مصفوفتين وننسخ نصف العناصر إليهما. نُرتِّب النصفين. ندمج النصفين. تنسخ الخطوة الأولى كل عنصر مرةً واحدةً، أي أنها خطّيّة. بالمثل، تنسخ الخطوة الثالثة كل عنصر مرةً واحدةً فقط، أي أنها خطّيّة كذلك. علينا الآن أن نُحدِّد تعقيد الخطوة الثانية. ستساعدنا على ذلك الصورة التالية التي تَعرِض مستويات التعاود. في المستوى الأعلى، سيكون لدينا قائمة واحدة مُكوَّنة من عددٍ قدره n من العناصر. للتبسيط، سنفترض أن n عبارة عن قيمة مرفوعة للأس 2، وبالتالي، سيكون لدينا في المستوى التالي قائمتان تحتويان على عدد n/2 من العناصر. ثمّ في المستوى التالي، سيكون لدينا 4 قوائم تحتوي على عدد قدره n/4 من العناصر، وهكذا حتى نصل إلى عدد n من القوائم تحتوي جميعها على عنصر واحد فقط. لدينا إذًا عدد قدره n من العناصر في كل مستوى. أثناء نزولنا في المستويات، قسّمنا المصفوفات في كل مستوى إلى نصفين، وهو ما يستغرق زمنًا يتناسب مع n في كل مستوى، وأثناء صعودنا للأعلى، علينا أن ندمج عددًا من العناصر مجموعه n وهو ما يستغرق زمنًا خطيًا أيضًا. إذا كان عدد المستويات يساوي h، فإن العمل الإجمالي المطلوب يساوي O(nh)، والآن، كم هو عدد المستويات؟ يُمكِننا أن نفكر في ذلك بطريقتين: كم عدد المرات التي سنضطر خلالها لتقسيم n إلى نصفين حتى نصل إلى 1. أو كم عدد المرات التي سنضطرّ خلالها لمضاعفة العدد 1 قبل أن نصل إلى n. يُمكِننا طرح السؤال الثاني بطريقة أخرى: "ما هي قيمة الأس المرفوع للعدد 2 لكي نحصل على n؟". 2h = n بحساب لوغاريتم أساس 2 لكلا الطرفين، نحصل على التالي: h = log2 n أي أن الزمن الكلي يساوي O(n log(n)). لاحظ أننا تجاهلنا قيمة أساس اللوغاريتم لأن اختلاف أساس اللوغاريتم يؤثر فقط بعامل ثابت، أي أن جميع اللوغاريتمات لها نفس ترتيب النمو order of growth. يُطلَق أحيانًا على الخوارزميات التي تنتمي إلى O(n log(n)) اسم "خطي-لوغاريتمي linearithmic"، ولكن في العادة نقول "n log n". في الواقع، يُعدّ O(n log(n)) الحد الأدنى من الناحية النظرية لخوارزميات الترتيب التي تَعتمد على موازنة العناصر مع بعضها البعض. يعني ذلك أنه لا توجد خوارزمية ترتيب بالموازنة ذات ترتيب نموٍّ أفضلَ من n log n. ولكن كما سنرى في القسم التالي، هناك خوارزميات ترتيبٍ لا تعتمد على الموازنة وتستغرق زمنًا خطيًا. خوارزمية الترتيب بالجذر Radix sort أثناء الحملة الرئاسية في الولايات المتحدة الأمريكية لعام 2008، طُلِبَ من المرشح باراك أوباما Barack Obama تحليل أداء خوارزمية impromptu أثناء زيارته لمؤسسة جوجل Google. سأله الرئيس التنفيذي إريك شميدت Eric Schmidt مازحًا عن أكفأ طريقة لترتيب مليون عدد صحيح من نوع 32 بت. بدا أن أوباما كان قد اُخبرَ بذلك قبل اللقاء لأنه أجاب سريعًا "لا أظن أن خوارزمية ترتيب الفقاعات bubble sort ستكون الطريقة الأفضل". يُمكِنك مشاهدة فيديو لقاء أوباما مع إريك شميدت لو أردت. كان أوباما على حق، فخوارزمية ترتيب الفقاعات bubble sort صحيحٌ أنها بسيطة وسهلة الفهم، لكنّها تستغرق زمنًا تربيعيًا، كما أن أداءها ليس جيدًا بالموازنة مع خوارزميات الترتيب التربيعية الأخرى. ربما خوارزمية الترتيب بالجذر radix sort هي الإجابة التي كان ينتظرها شميدت، فهي خوارزمية ترتيب غير مبنيّة على الموازنة، كما أنها تَعمَل بنجاح عندما يكون حجم العناصر مقيّدًا كعدد صحيح من نوع 32 بت أو سلسلة نصية مُكوَّنة من 20 محرفًا. لكي نفهم طريقة عملها، لنتخيل أن لدينا مكدّسًا stack من البطاقات، وكل واحدة منها تحتوي على كلمة مُكوَّنة من ثلاثة أحرف. ها هي الطريقة التي يُمكِن أن نرتب بها تلك البطاقات: مرّ عبر البطاقات وقسمها إلى مجموعات بناءً على الحرف الأول، أي ينبغي أن تكون الكلمات البادئة بالحرف a ضمن مجموعة واحدة، يليها الكلمات التي تبدأ بحرف b، وهكذا. قسِّم كل مجموعة مرة أخرى بناءً على الحرف الثاني، بحيث تصبح الكلمات البادئة بالحرفين aa معًا، يليها الكلمات التي تبدأ بالحرفين ab، وهكذا. لن تكون كل المجموعات مملوءةً بالتأكيد، ولكن لا بأس بذلك. قسِّم كل مجموعة مرة أخرى بحسب الحرف الثالث. والآن، أصبحت كل مجموعة مُكوَّنة من عنصر واحد فقط، كما أصبحت المجموعات مُرتَّبةً ترتيبًا تصاعديًا. تَعرِض الصورة التالية مثالًا عن الكلمات المكوَّنة من ثلاثة أحرف. يَعرِض الصف الأول الكلمات غير المُرتَّبة، بينما يَعرِض الصف الثاني شكل المجموعات بعد اجتيازها للمرة الأولى. تبدأ كلمات كل مجموعة بنفس الحرف. بعد اجتياز الكلمات للمرة الثانية، تبدأ كلمات كل مجموعة بنفس الحرفين الأوليين، وبعد اجتيازها للمرة الثالثة، سيكون هنالك كلمة واحدة فقط في كل مجموعة، وستكون المجموعات مُرتَّبة. أثناء كل اجتياز، نمرّ عبر العناصر ونضيفها إلى المجموعات. يُعدّ كل اجتياز منها خطيًا طالما كانت تلك المجموعات تَسمَح بإضافة العناصر إليها بزمن خطي. تعتمد عدد مرات الاجتياز -التي سنطلق عليها w- على عرض الكلمات، ولكنه لا يعتمد على عدد الكلمات n، وبالتالي، يكون ترتيب النمو O(wn) وهو خطي بالنسبة لقيمة n. تتوفَّر نسخ أخرى من خوارزمية الترتيب بالجذر، ويُمكِن تنفيذ كُلٍّ منها بطرق كثيرة. يُمكِنك قراءة المزيد عن خوارزمية الترتيب بالجذر، كما يُمكِنك أن تحاول كتابة تنفيذ لها. خوارزمية الترتيب بالكومة Heap sort إلى جانب خوارزمية الترتيب بالجذر التي تُطبَّق عندما يكون حجم الأشياء المطلوب ترتيبها مقيّدًا، هنالك خوارزمية مُخصَّصة أخرى هي خوارزمية الترتيب بالكومة المُقيّدة، والتي تُطبَّق عندما نعمل مع بياناتٍ ضخمةٍ جدًا ونحتاج إلى معرفة أكبر 10 أو أكبر عدد k حيث k قيمة أصغر بكثير من n. لنفترض مثلًا أننا نراقب خدمةً عبر الإنترنت تتعامل مع بلايين المعاملات يوميًا، وأننا نريد في نهاية كل يوم معرفة أكبر k من المعاملات (أو أبطأ أو أي معيار آخر). يُمكِننا مثلًا أن نُخزِّن جميع المعاملات، ثم نُرتِّبها في نهاية اليوم، ونختار أول k من المعاملات. سيستغرق ذلك زمنًا يتناسب مع n log n، وسيكون بطيئًا جدًا لأننا من المحتمل ألا نتمكَّن من ملاءمة بلايين المعاملات داخل ذاكرة برنامج واحد، وبالتالي، قد نضطرّ لاستخدام خوارزمية ترتيب بذاكرة خارجية (خارج النواة). يُمكِننا بدلًا من ذلك أن نَستخدِم كومة مُقيدّة heap. إليك ما سنفعله في ما تبقى من هذه المقالة: سنشرح خوارزمية الترتيب بالكومة (غير المقيدة). ستنفذ الخوارزمية. سنشرح خوارزمية الترتيب بالكومة المُقيدة ونُحلِّلها. لكي تفهم ما يعنيه الترتيب بالكومة، عليك أولًا فهم ماهية الكومة. الكومة ببساطة عبارة عن هيكل بياني data structure مشابه لشجرة البحث الثنائية binary search tree. تتلخص الفروق بينهما في النقاط التالية: تتمتع أي عقدة x بشجرة البحث الثنائية بـ"خاصية BST" أي تكون جميع عقد الشجرة الفرعية subtree الموجود على يسار العقدة x أصغر من x كما تكون جميع عقد الشجرة الفرعية الموجودة على يمينها أكبر من x. تتمتع أي عقدة x ضمن الكومة بـ"خاصية الكومة" أي تكون جميع عقد الشجرتين الفرعيتين للعقدة x أكبر من x. تتشابه الكومة مع أشجار البحث الثنائية المتزنة من جهة أنه عندما تضيف العناصر إليها أو تحذفها منها، فإنها قد تقوم ببعض العمل الإضافي لضمان استمرارية اتزان الشجرة، وبالتالي، يُمكِن تنفيذها بكفاءة باستخدام مصفوفة من العناصر. دائمًا ما يكون جذر الكومة هو العنصر الأصغر، وبالتالي، يُمكِننا أن نعثر عليه بزمن ثابت. تستغرق إضافة العناصر وحذفها من الكومة زمنًا يتناسب مع طول الشجرة h، ولأن الكومة دائمًا ما تكون متزنة، فإن h يتناسب مع log n. يُمكِنك قراءة المزيد عن الكومة لو أردت. تُنفِّذ جافا الصنف PriorityQueue باستخدام كومة. يحتوي ذلك الصنف على التوابع المُعرَّفة في الواجهة Queue ومن بينها التابعان offer و poll اللّذان نلخص عملهما فيما يلي: offer: يضيف عنصرًا إلى الرتل queue، ويُحدِّث الكومة بحيث يَضمَن استيفاء "خاصية الكومة" لجميع العقد. لاحِظ أنه يستغرق زمنًا يتناسب مع log n. poll: يَحذِف أصغر عنصر من الرتل من الجذر ويُحدِّث الكومة. يستغرق أيضًا زمنًا يتناسب مع log n. إذا كان لديك كائن من النوع PriorityQueue، تستطيع بسهولة ترتيب تجميعة عناصر طولها n على النحو التالي: أضف جميع عناصر التجميعة إلى كائن الصنف PriorityQueue باستخدام التابع offer. احذف العناصر من الرتل باستخدام التابع poll وأضفها إلى قائمة من النوع List. نظرًا لأن التابع poll يعيد أصغر عنصر مُتبقٍّ في الرتل، فإن العناصر تُضاف إلى القائمة مُرتَّبةً تصاعديًّا. يُطلَق على هذا النوع من الترتيب اسم الترتيب بالكومة. تستغرق إضافة عددٍ قدره n من العناصر إلى رتلٍ زمنًا يتناسب مع n log n، ونفس الأمر ينطبق على حذف عددٍ قدره n من العناصر منه، وبالتالي، تنتمي خوارزمية الترتيب بالكومة إلى المجموعة O(n log(n)). ستجد ضمن الملف ListSorter.java تعريفًا مبدئيًا لتابع اسمه heapSort. أكمله ونفِّذ الأمر ant ListSorterTest لكي تتأكّد من أنه يَعمَل بشكل صحيح. الكومة المُقيدّة Bounded heap تَعمَل الكومة المقيدة كأي كومة عادية، ولكنها تكون مقيدة بعدد k من العناصر. إذا كان لديك عدد n من العناصر، يُمكِنك أن تحتفظ فقط بأكبر عدد k من العناصر باتباع التالي: ستكون الكومة فارغة مبدئيًا، وعليك أن تُنفِّذ التالي لكل عنصر x: التفريع الأول: إذا لم تكن الكومة ممتلئةً، أضف x إلى الكومة. التفريع الثاني: إذا كانت الكومة ممتلئةً، وازن قيمة x مع أصغر عنصر في الكومة. إذا كانت قيمة x أصغر، فلا يُمكِن أن تكون ضمن أكبر عدد k من العناصر، ولذلك، يُمكِنك أن تتجاهلها. التفريع الثالث: إذا كانت الكومة ممتلئةً، وكانت قيمة x أكبر من قيمة أصغر عنصر بالكومة، احذف أصغر عنصر، وأضف x مكانه. بوجود أصغر عنصر أعلى الكومة، يُمكِننا الاحتفاظ بأكبر عدد k من العناصر. لنحلل الآن أداء هذه الخوارزمية. إننا ننفِّذ ما يلي لكل عنصر: التفريع الأول: تستغرق إضافة عنصر إلى الكومة زمنًا يتناسب مع O(log k). التفريع الثاني: يستغرق العثور على أصغر عنصر بالكومة زمنًا يتناسب مع O(1). التفريع الثالث: يستغرق حذف أصغر عنصر زمنًا يتناسب مع O(log k)، كما أن إضافة x تستغرق نفس مقدار الزمن. في الحالة الأسوأ، تكون العناصر مُرتَّبة تصاعديًا، وبالتالي، نُنفِّذ التفريع الثالث دائمًا، ويكون الزمن الإجمالي لمعالجة عدد n من العناصر هو O(n log K) أي خطّيّ مع n. ستجد في الملف ListSorter.java تعريفًا مبدئيًا لتابع اسمه topK. يَستقبِل هذا التابع قائمةً من النوع List وكائنًا من النوع Comparator وعددًا صحيحًا k، ويعيد أكبر عدد k من عناصر القائمة بترتيب تصاعدي. أكمل متن التابع ثم نفِّذ الأمر ant ListSorterTest لكي تتأكّد من أنه يَعمَل بشكل صحيح. تعقيد المساحة Space complexity تحدثنا حتى الآن عن تحليل زمن التنفيذ فقط، ولكن بالنسبة لكثير من الخوارزميات، ينبغي أن نُولِي للمساحة التي تتطلّبها الخوارزمية بعض الاهتمام. على سبيل المثال، تحتاج خوارزمية الترتيب بالدمج merge sort إلى إنشاء نسخ من البيانات، وقد كانت مساحة الذاكرة الإجمالية التي تطلّبها تنفيذنا لتلك الخوارزمية هو O(n log n). في الواقع، يُمكِننا أن نُخفِّض ذلك إلى O(n) إذا نفذنا نفس الخوارزمية بطريقة أفضل. في المقابل، لا تنسخ خوارزمية الترتيب بالإدراج insertion sort البيانات لأنها تُرتِّب العناصر في أماكنها، وتَستخدِم متغيراتٍ مؤقتةً لموازنة عنصرين في كل مرة، كما تَستخدِم عددًا قليلًا من المتغيرات المحلية local الأخرى، ولكن المساحة التي تتطلبها لا تعتمد على n. تُنشِئ نسختنا من خوارزمية الترتيب بالكومة كائنًا جديدًا من النوع PriorityQueue، وتُخزِّن فيه العناصر، أي أن المساحة المطلوبة تنتمي إلى المجموعة O(n)، وإذا سمحنا بترتيب عناصر القائمة في نفس المكان، يُمكِننا أن نُخفِّض المساحة المطلوبة لتنفيذ خوارزمية الترتيب بالكومة إلى المجموعة O(1). من مميزات النسخة التي نفَّذناها من تلك الخوارزمية هي أنها تحتاج فقط إلى مساحة تتناسب مع k (أي عدد العناصر المطلوب الاحتفاظ بها)، وعادةً ما تكون k أصغر بكثير من قيمة n. يميل مطورو البرمجيات إلى التركيز على زمن التشغيل وإهمال حيز الذاكرة المطلوب، وهذا في الحقيقة، مناسب لكثير من التطبيقات، ولكن عند التعامل مع بيانات ضخمة، تكون المساحة المطلوبة بنفس القدر من الأهمية إن لم تكن أهم، كما في الحالات التالية مثلًا: إذا لم تكن مساحة ذاكرة البرنامج ملائمةً للبيانات، عادةً ما يزداد زمن التشغيل إلى حد كبير وقد لا يَعمَل البرنامج من الأساس. إذا اخترت خوارزمية تتطلّب حيزًا أقل من الذاكرة، وتَسمَح بملائمة المعالجة ضمن الذاكرة، فقد يَعمَل البرنامج بسرعةٍ أعلى بكثير. بالإضافة إلى ذلك، تَستغِل البرامج التي تتطلَّب مساحة ذاكرة أقل الذاكرة المؤقتة لوحدة المعالجة المركزية CPU caches بشكل أفضل وتَعمَل بسرعة أكبر. في الخوادم التي تُشغِّل برامج كثيرة في الوقت نفسه، إذا أمكنك تقليل المساحة التي يتطلبها كل برنامج، فقد تتمكّن من تشغيل برامج أكثر على نفس الخادم، مما يُقلل من تكلفة الطاقة والعتاد المطلوبة. كانت هذه بعض الأسباب التي توضح أهمية الاطلاع على متطلبات الخوارزميات المتعلقة بالذاكرة. يمكنك التوسع أكثر في الموضوع بقراءة توثيق خوارزميات الترتيب في توثيق موسوعة حسوب. ترجمة -بتصرّف- للفصل Chapter 17: Sorting من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال السابق: البحث الثنائي Boolean search ودمج نتائج البحث وترتيبها تعقيد الخوارزميات Algorithms Complexity مدخل إلى تحليل الخوارزميات
-
تضيف الخيوط threads مستوًى جديدًا من التعقيد إلى البرمجة، ولكنها مهمةٌ وستصبح أساسيةً بالمستقبل، ولذلك لا بُدّ أن يَطلِّع كل مبرمجٍ على بعض أنماط التصميم design pattern الأساسية المُستخدَمة مع الخيوط، حيث سنفحص بهذا المقال بعض التقنيات البسيطة وسنبني عليها بالأقسام التالية. الخيوط والمؤقتات ومكتبة جافا إف إكس يُمكِننا استخدام الخيوط لتنفيذ مهمةٍ معينةٍ تنفيذًا دوريًا، وهو ما يُعدّ أمرًا بسيطًا لدرجة وجود أصنافٍ مُتخصِّصة لتنفيذ تلك المهمة، ولقد تعاملنا مع إحداها بالفعل، وهو الصنف AnimationTimer المُعرَّف بحزمة javafx.animation، التي درسناها بالقسم الفرعي الصنف AnimationTimer من المقال تعرف على أهم الأحداث والتعامل معها في مكتبة جافا إف إكس JavaFX حيث يستدعِي ذلك الصنف تابعه handle() دوريًا بمعدل 60 مرةٍ لكل ثانية. في الواقع، كان اِستخدَام الخيوط ضروريًا لتنفيذ العمليات المشابهة قبل أن تتوفَّر المؤقتات. لنفترض أننا نريد فعل شيءٍ مشابه باستخدام خيط، كأن نَستدعِي برنامجًا فرعيًا subroutine على فتراتٍ دورية، مثل 30 مرةٍ لكل ثانية. سيُنفِّذ تابع الخيط run() حلقة تكرار loop، سيتوقَّف خلالها الخيط لمدة "30 ميللي ثانية"، ثم سيستدعِي بعدها البرنامج الفرعي. تُنفِّذ الشيفرة التالية ذلك باستخدِام Thread.sleep() -الذي ناقشناه بقسم العمليات على الخيوط من المقال السابق مقدمة إلى الخيوط Threads في جافا- ضمن صنفٍ متداخل nested: private class Animator extends Thread { public void run() { while (true) { try { Thread.sleep(30); } catch (InterruptedException e) { } callSubroutine(); } } } سنُنشِئ الآن كائنًا ينتمي إلى ذلك الصنف، ونَستدعِي تابعه start()، مع الملاحظة بأنه لن يكون هناك أي طريقةٍ لإيقاف الخيط بعد تشغيله؛ وإنما يُمكِننا إيقاف حلقة التكرار عندما تساوي قيمة متغيرٍ متطايرٍ volatile منطقي معين وليكن اسمه هو terminate القيمة true كما ناقشنا بقسم المتغيرات المتطايرة من المقال السابق. إذا أردنا تشغيل التحريكة animation مرةً أخرى بعد إيقافها، فسنضطّر لإنشاء كائنٍ جديدٍ من النوع Thread؛ نظرًا لأنه من الممكن تنفيذ تلك الكائنات مرةً واحدةً فقط. سنناقش بالقسم التالي بعض التقنيات المُستخدَمة للتحكم بالخيوط. تختلف الخيوط عن المؤقتات جزئيًا فيما يتعلّق بالتحريكات؛ حيث لا يفعل الخيط الذي يَستخدِمه الصنف AniamtionTimer والمُعرَّف بمكتبة جافا إف إكس أكثر من مجرد استدعاء البرنامج handle() مرةً بعد أخرى، والذي يُنفَّذ ضمن خيط تطبيق جافا إف إكس المسؤول عن إعادة رسم مكوِّنات الواجهة والإستجابة لما يفعله المُستخدِم. يُعدّ ذلك أمرًا مهمًا لأن مكتبة جافا إف إكس ليست آمنةً خيطيًا thread-safe؛ بمعنى أنها لا تَستخدِم المزامنة synchronization لتجنُّب حالات التسابق race conditions الممكن حدوثها بين الخيوط التي تحاول الوصول إلى كلٍ من مكوِّنات واجهة المُستخدِم الرسومية GUI ومتغيرات الحالة الخاصة بها. لا يُمثِّل ذلك مشكلةً بشرط أن يحدث كل شيء ضمن خيط التطبيق. في المقابل، قد تَنشَأ مشكلةٌ إذا حاول خيطٌ آخر تعديل إحدى مُكوِّنات الواجهة أو إحدى المتغيرات المُستخدَمة بخيط واجهة المُستخدِم الرسومية، وعندها قد يكون اِستخدَام المزامنة حلًا مناسبًا مع أن اِستخدَام الصنف AnimationTimer -إن كان ذلك ممكنًا- عادةً ما يكون الحل الأمثل؛ ولكن يُمكِنك استخدام Platform.runLater()، إذا كنت مضطّرًا لاستخدام خيطٍ منفصل. تحتوي حزمة javafx.application على الصنف Platform الذي يتضمَّن التابع الساكن Platform.runLater(r)؛ حيث يَستقبِل هذا التابع كائنًا من النوع Runnable، أي نفس الواجهة interface المُستخدَمة لإنشاء الخيوط على أنه معاملٌ بإمكاننا اِستدعائه من أي خيط. تتلّخص مسؤولية ذلك التابع في تسليم r إلى خيط تطبيق جافا إف إكس لتنفيذه، ثم يعود مباشرةً دون أن ينتظر انتهاء تنفيذ r. بعد ذلك، يَستدعِي خيط التطبيق التابع r.run() بعد أجزاءٍ من الثانية أو حتى على الفور إذا لم يَكُن الحاسوب مُنشغِّلًا بتنفيذ شيءٍ آخر. تُنفَّذ الأصناف المُنفِّذة للواجهة Runnable بنفس ترتيب تَسلُمها، ونظرًا لأنها تُنفَّذ داخل خيط التطبيق، يُمكِنها معالجة واجهة المُستخدِم الرسومية معالجةً آمنةً بدون مزامنة. يُمرَّر معامل التابع Platform.runLater() عادةً مثل تعبير لامدا lambda expression من النوع Runnable. سنَستخدِم Platform.runLater() بعدة أمثلة خلال هذا المقال وما يليه. سنفحص الآن المثال التوضيحي RandomArtWithThreads.java الذي يَستخدِم خيطًا للتحكُّم بتحريكةٍ بسيطةٍ جدًا. لا يفعل الخيط بهذا المثال أكثر من مجرد استدعاء التابع redraw() كل ثانيتين، الذي يُعيد رسم محتويات الحاوية canvas؛ واستخدام التابع Platform.runLater() لتنفيذ redraw() ضمن خيط التطبيق. يستطيع المُستخدِم الضغط على زر لبدء التحريكة وإيقافها. يُنشَأ خيطٌ جديدٌ بكل مرة تبدأ خلالها التحريكة، ويُضبَط متغيرٌ منطقيٌ متطايرٌ اسمه running إلى القيمة false عندما يُوقِف المُستخدِم التحريكة إشارةً للخيط بأن عليه أن يتوقف، كما ناقشنا بالمقال المتغيرات المتطايرة من المقال السابق. يُعرِّف الخيط بواسطة الصنف التالي: private class Runner extends Thread { public void run() { while (running) { Platform.runLater( () -> redraw() ); try { Thread.sleep(2000); // انتظر ثانيتين قبل إعادة رسم الشاشة } catch (InterruptedException e) { } } } } التعاود داخل الخيوط إذا كان الخيط يُنفِّذ خوارزميةً تعاوديةً recursive (ناقشنا التعاود في مقال التعاود recursion في جافا)، وكنت تريد إعادة رسم الواجهة عدة مرات أثناء حدوث التعاود؛ فقد تضطّر لاستخدام خيطٍ منفصلٍ للتحكُّم بالتحريكة. من الصعب تقسيم خوارزمية تعاودية إلى سلسلةٍ من استدعاءات التوابع داخل مؤقت، فمن البديهي أكثر استدعاء تابعٍ تعاودي واحدٍ لإجراء التعاود، وهو أمرٌ سهل إنجازه ضمن خيط. سنفحص المثال التوضيحي QuicksortThreadDemo.java الذي يَرسِم تحريكةً تُوضِح طريقة عمل خوارزمية QuickSort التعاودية لترتيب المصفوفات. ستحتوي المصفوفة في هذا المثال على ألوان، وسيكون الهدف هو ترتيبها وفقًا لسلّم الألوان المعروف من الأحمر إلى البنفسجي. يَسمَح البرنامج للمُستخدِم أيضًا بالنقر على زر "Start" لبدء العملية، وعندها تُرتَّب الألوان ترتيبًا عشوائيًا، ثم تُستدعَى خوارزمية QuickSort لترتيبها وتُعرَض العملية بحركة بطيئة. يتبدَّل زر "Start" أثناء عملية الترتيب إلى "Finish" ليَسمَح للمُستخدِم بإيقاف عملية الترتيب قبل انتهائها. في الواقع، من الممتع مشاهدة خرج هذا البرنامج، ولربما يساعدك حتى على فهم طريقة عمل خوارزمية QuickSort على نحوٍ أفضل، لذلك عليك أن تُجرِّب تشغيله. ينبغي أن تتغيّر الصورة المعروضة بالحاوية في هذا البرنامج بكل مرةٍ تُجرِي خلالها الخوارزمية تعديلًا على المصفوفة. لاحِظ أن المصفوفة تتغيّر بخيط التحريكة بينما لا بُدّ من إجراء التغيير المقابل على الحاوية بخيط تطبيق جافا إف إكس باستخدام Platform.runLater() كما ناقشنا بالأعلى. بكل مرة يُستدعى خلالها Platform.runLater()، يتوقف الخيط لمدة "100 ميللي ثانية" ليَسمَح لخيط التطبيق بتنفيذ المعامل المُمرَّر من النوع Runnable وليتمكَّن المُستخدِم من مشاهدة التعديلات. هناك أيضًا توقُّفٌ أطول بما يصِل إلى ثانيةٍ كاملة بعد ترتيب عناصر المصفوفة عشوائيًا مباشرةً وقبل بدء عملية الترتيب الفعلي. يُعرِّف الصنف QuicksortThreadDemo التابع delay() الذي يجعل الخيط المُستدِعي له يتوقَّف لفترة معينة، نظرًا لأن الشيفرة تتوقَّف بأكثر من مكان. والآن، كيف نُنفِّذ شيفرة الزر "Finish" المسؤولة عن إيقاف عملية الترتيب وإنهاء الخيط؟ في الواقع، يؤدي النقر على هذا الزر إلى ضبط قيمة المتغير المنطقي المتطاير running إلى القيمة false إشارةً للخيط بأنه عليه الانتهاء. تَكْمُن المشكلة في إمكانية النقر عليه بأي لحظة، حتى لو كان البرنامج منهمكًا بتنفيذ الخوارزمية وبمستوًى منخفضٍ جدًا من التعاود. لا بُدّ أن تعود جميع استدعاءات التوابع التعاودية لنتمكّن من إنهاء الخيط، ويُعد التبليغ عن استثناء exception إحدى أبسط الطرق التي يُمكنِها تحقيق ذلك. يُعرِّف الصنف QuickSortThreadDemo صنف استثناءٍ جديد اسمه ThreadTerminationException لهذا الغرض، ويفحص التابع delay() قيمة المُتغيّر running؛ فإذا كانت مساويةً للقيمة false، سيُبلِّغ عن استثناءٍ تسبَّب بإنهاء الخوارزمية التعاودية، وبالتتابع خيط التحريكة ذاته. ألقِ نظرةً على تعريف التابع delay(): private void delay(int millis) { if (! running) throw new ThreadTerminationException(); try { Thread.sleep(millis); } catch (InterruptedException e) { } if (! running) // افحصها مرة أخرى فربما تكون قد تغيرت أثناء توقُّف الخيط throw new ThreadTerminationException(); } يَلتقِط تابع الخيط run() الاستثناء المنتمي للصنف ThreadTerminationException: // 1 private class Runner extends Thread { public void run() { for (int i = 0; i < hue.length; i++) { // املأ المصفوفة باستخدام الفهارس hue[i] = i; } for (int i = hue.length-1; i > 0; i--) { // رتّب المصفوفة عشوائيًا int r = (int)((i+1)*Math.random()); int temp = hue[r]; hue[r] = hue[i]; // 2 setHue(i,temp); } try { delay(1000); // انتظر ثانية قبل بدء عملية الترتيب quickSort(0,hue.length-1); // رتّب المصفوفة بالكامل } catch (ThreadTerminationException e) { // ألغى المُستخدِم عملية الترتيب // 3 Platform.runLater( () -> drawSorted() ); } finally { running = false; // 4 Platform.runLater( () -> startButton.setText("Start") ); } } } حيث أن: [1]: يُعرِّف هذا الصنف خيطًا يُنفِّذ خوارزمية QuickSort التعاودية؛ حيث يبدأ الخيط بخلط عناصر المصفوفة hue عشوائيًا، ثم يَستدعِي التابع quickSort() لترتيبها بالكامل. إذا توقَّف التابع quickSort نتيجة استثناء من النوع ThreadTerminationException -يحدُث إذا نقر المُستخدِم على زر "Finish"-، يُعيد الخيط المصفوفة إلى حالتها المُرتَّبة قبل أن ينتهي؛ وبالتالي سواءٌ ألغى المُستخدِم عملية الترتيب أم لا، تكون المصفوفة مرتبةً بنهاية الخيط. في جميع الحالات، يُضبَط نص الزر إلى "Start" بالنهاية. [2]: التعليمة الأخيرة التي ينبغي إنجازها ضمن الحلقة هي hue = temp. لن تتغير قيمة hue بعد ذلك، ولذلك تُنجز عملية الإسناد باستدعاء التابع setHue(i,temp) الذي سيُبدِّل القيمة الموجودة بالمصفوفة، كما أنه يَستِخدِم Platform.runLater() لتغيير لون الشريط رقم i بالحاوية. [3]: ضع الألوان بصورةٍ مرتبة. يرسم التابع drawSorted() ألوان جميع الشرائط بالترتيب. [4]: تأكَّد من أن running يُساوِي false. يكون ذلك ضروريًا فقط إذا انتهى الخيط طبيعيًا. يَستخدِم البرنامج المتغير runner من النوع Runner لتمثيل الخيط المسؤول عن عملية الترتيب. عندما ينقر المُستخدِم على الزر "Start"، تُنفَّذ الشيفرة التالية لإنشاء الخيط وتشغيله: startButton.setText("Finish"); runner = new Runner(); running = true; // اضبط قيمة الإشارة قبل تشغيل الخيط runner.start(); لا بُدّ من ضبط قيمة متغير الإشارة running إلى القيمة true قبل بدء الخيط؛ لأنه لو كان يحتوي على القيمة false بالفعل عند بدء الخيط، فلربما سيرى الخيط تلك القيمة بمجرد بدءه، ويُفسِّرها على كونها إشارةً للتوقُّف قبل أن يفعل أي شيء. تذكَّر أنه عند استدعاء runner.start()، يبدأ الخيط runner بالعمل على التوازي مع الخيط المُستدعِي له. عندما ينقر المُستخدِم على زر "Finish"، تُضبَط قيمة running إلى القيمة false إشارةً للخيط بأن عليه الانتهاء، ولكن ماذا لو كان الخيط نائمًا في تلك اللحظة؟ في تلك الحالة لا بُدّ أن يستيقظ الخيط أولًا حتى يتمكَّن من الإستجابة لتلك الإشارة؛ أما إذا أردنا أن نجعله يستجيب بصورةٍ أسرع، يُمكِننا استدعاء التابع runner.interrupt() لإيقاظ الخيط إذا كان نائمًا. لا يؤثر هذا على البرنامج من الناحية العملية، ولكنه يجعل استجابة البرنامج أسرع على نحوٍ ملحوظ بالأخص إذا نقر المُستخدِم على زر "Finish" بعد النقر على زر "Start" مباشرةً عندما ينام الخيط لمدة ثانيةٍ كاملة. استخدام الخيوط بالعمليات المنفذة بالخلفية إذا أردنا أن تكون استجابة برامج واجهة المُستخدِم الرسومية GUI سريعة، أي تستجيب للأحداث events بمجرد وقوعها تقريبًا، لا بُدّ أن تُنهِي توابع معالجة الأحداث الموجودة بالبرنامج عملها بسرعة. تُخزَّن الأحداث برتل queue أثناء وقوعها، ولا يستطيع الحاسوب الاستجابة لحدثٍ معين قبل أن تُنهِي توابع معالجة الأحداث السابقة له عملها. يَعنِي ذلك أنه ينبغي للأحداث الانتظار أثناء تنفيذ الحاسوب لمُعالِج حدثٍ معين؛ وإذا استغرق مُعالِج حدثٍ معين فترةً طويلة لتنفيذ عمله، ستَجْمُد freeze واجهة المُستخدِم خلال تلك الفترة، وهو ما يُضايق المُستخدِم بالأخص إذا استمر لأكثر من جزءٍ من الثانية. تستطيع الحواسيب العصرية لحسن الحظ إنجاز الكثير من العمليات الضخمة خلال جزءٍ من الثانية. ومع ذلك، هناك بعض العمليات الضخمة للغاية لدرجة لا يُمكِن تنفيذها بمعالجات الأحداث event handlers (أو بتمرير مُنفَّذات للواجهة Runnable إلى Platform.runlater()). ويكون من الأفضل في تلك الحالة تنفيذ تلك العمليات بخيطٍ آخر منفصل يَعمَل على التوازي مع خيط معالجة الأحداث، وهذا يَسمَح للحاسوب بالاستجابة إلى الأحداث الأخرى في نفس الوقت الذي يُنفَّذ خلاله تلك العملية، ويُقال أن العملية "تُنفَّذ بالخلفية background". يختلف تطبيق الخيوط في هذا المثال عن المثال السابق؛ فعندما يُستخدَم الخيط للتحكُّم بتحريكة، فإنه فعليًا لا يَفعَل سوى القليل، إذ عليه فقط أن يستيقظ كل عدة ثواني ليُجرِي قليلًا من العمليات المتعلّقة بتحديث متغيرات الحالة state variables لإطار التحريكة التالي ومن ثَمّ رَسْمه. يُوفِّر ذلك وقتًا كافيًا لخيط تطبيق جافا إف إكس، ويَسمَح له بإجراء أي إعادة رسمٍ ضرورية لمُكوِّنات واجهة المُستخدِم الرسومية، وكذلك معالجة أي أحداثٍ اخرى. عندما نُنفِّذ عمليةٌ معينة بالخلفية ضمن خيط، فإننا نريد إبقاء الحاسوب مُنشغِلًا بتنفيذ تلك العملية بأقصى ما يمكن، ولكن قد يتسابق هذا الخيط مع خيط التطبيق على زمن المعالجة، وعندها يبقى تعطُّل معالجة الأحداث بالأخص إعادة الرسم ممكنًا إذا لم ننتبه كفاية. يُمكِننا لحسن الحظ استخدام أولويات priorities للخيوط لنتجنَّب تلك المشكلة، حيث يُمكِننا ضبط الخيط المسؤول عن تنفيذ العملية بالخلفية بحيث يَعمَل بأولويةٍ أقل من أولوية خيط معالجة الأحداث، وسنضمَن بذلك معالجة الأحداث بأسرع ما يمكن، وسيَحظَى بنفس الوقت الخيط الآخر بأي زمن معالجةٍ إضافي. تستغرق معالجة الأحداث وقتًا قصيرًا للغاية عمومًا، وبالتالي سيُستغَل غالبية زمن المعالجة بتنفيذ العملية المُشغَّلة بالخلفية بنفس الوقت الذي ستستجيب فيه الواجهة بسرعة. ناقشنا أولوية الخيوط في قسم العمليات على الخيوط من المقال السابق. يُعدّ البرنامج BackgroundComputationDemo.java مثالًا توضيحيًا على معالجة العمليات بالخلفية. يُنشِئ هذا البرنامج صورةً يستغرِق تحديد ألوان بكسلاتها وقتًا طويلًا نوعًا ما. تمثّل تلك الصورة قطعةً من شكلٍ هندسيٍ معروف باسم "مجموعة ماندلبرو Mandelbrot set"، وسنستخدِم تلك الصورة بعدة أمثلة خلال هذا المقال. يتشابه البرنامج "BackgroundComputationDemo" مع برنامج "QuicksortThreadDemo" الذي ناقشناه بالأعلى، حيث تُجرَى العمليات ضمن خيطٍ مُعرَّفٍ بواسطة صنفٍ مُتداخِل اسمه Runner، وسنَستخدِم متغيرًا متاطيرًا اسمه running للتحكُّم بالخيط؛ فإذا كان المُتغيّر يساوي false، ينبغي أن ينتهي الخيط. يُوفِّر البرنامج زرًا لبدء العملية وإنهائها. بخلاف البرنامج السابق، سيَعمَل الخيط باستمرار دون أن ينام. بعد انتهاء الخيط من حساب كل صف من البسكلات، سيَستدعِي التابع Platform.runLater() لينسخ تلك البكسلات إلى الصورة المعروضة على الشاشة، وسيتمكَّن بذلك المُستخدِم من مشاهدة التحديثات الناتجة عن العمليات المُجرَاة، وسيشاهد الصورة أثناء تكوُّنها صفًا بعد آخر. عندما ينقر المُستخدِم على زر "Start"، يُنشَأ الخيط المسؤول عن عملية المعالجة، والذي لا بُدّ من ضبطه ليَعمَل بأولويةٍ أقل من أولوية خيط تطبيق جافا إف إكس. نظرًا لوقوع الشيفرة المسؤولة عن إنشاء ذلك الخيط ضمن خيط التطبيق، يُمكِننا ضبط أولوية الخيط المُنشَا بحيث تكون أقل بمقدار الواحد من أولوية الخيط المُشغَّل. تذكَّر أنه من الضروري ضبط تلك الأولوية داخل تعليمة try..catch؛ فإذا حدث خطأ أثناء ذلك، نَضمَن استمرار البرنامج، وإن لم يَكُن بنفس السلاسة التي كان سيَعمَل بها لو كانت الأولوية قد ضُبطَت صحيحًا. تُنشِئ الشيفرة التالية الخيط، وتُشغِّله: runner = new Runner(); try { runner.setPriority( Thread.currentThread().getPriority() - 1 ); } catch (Exception e) { System.out.println("Error: Can't set thread priority: " + e); } running = true; // اضبط قيمة الإشارة قبل تشغيل الخيط runner.start(); على الرغم من عمل البرنامج BackgroundComputationDemo جيدًا، إلا أن هناك مشكلةً واحدةً وهي أن هدفنا هو إتمام العملية بأسرع وقتٍ ممكن من خلال استغلال ما هو مُتوفّرٌ من زمن المعالجة. سيتمكَّن البرنامج من إنجاز ذلك الهدف إذا كان مُشغَّلًا على حاسوبٍ بمعالج واحد؛ ولكن إذا كان الحاسوب يحتوي على عدة معالجات، فإننا في الواقع نَستخدِم معالجًا واحدًا فقط لإنجاز العملية، وفي تلك الحالة، لن يكون لأولوية الخيط أي أهمية؛ فمن الممكن تشغيل كُلٍ من خيط التطبيق وخيط التحريكة على التوازي باستخدام معالجين مختلفين. سيكون من الأفضل لو تمكَّنا من استخدام جميع تلك المعالجات لإتمام العملية، وهو ما يتطلَّب معالجةً على التوازي parallel processing من خلال عدة خيوط. سننتقل إلى تلك المشكلة فيما يلي. استخدام الخيوط في المعالجة المتعددة سنفحص الآن البرنامج MultiprocessingDemo1.java، الذي يختلف قليلًا عن البرنامج "BackgroundComputationDemo"؛ فبدلًا من إجراء المعالجة بخيطٍ واحد فقط، سيُقسِّم البرنامج "MultiprocessingDemo1" المعالجة على عدة خيوط. وسيَسمَح البرنامج للمُستخدِم بتخصيص عدد الخيوط المطلوب تشغيلها، وسيتولى كل خيطٍ منها المعالجة المطلوبة لجزءٍ معين من الصورة. ينبغي أن تنجز الخيوط عملها على التوازي؛ فإذا استخدمنا خيطين على سبيل المثال، فسيَحسِب الأول النصف الأعلى من الصورة؛ بينما سيَحسِب الثاني النصف السفلي. تعرض الصورة التوضيحية التالية شاشة البرنامج مع اقتراب نهاية المعالجة عند استخدام ثلاثة خيوط، حيث تُشير المساحات الرمادية إلى أجزاء الصورة غير المُعالجة بعد. عليك أن تُجرِّب البرنامج؛ فعند استخدام عدة خيوطٍ بحاسوبٍ متعدّد المعالجات، ستكتمل المعالجة على نحوٍ أسرع بالموازنة مع استخدام خيطٍ واحد. لا تُعدّ الطريقة المُستخدَمة لتقسيم المشكلة على مجموعة الخيوط بهذا المثال الطريقة الأمثل، وسنتناول بالقسم التالي إمكانية تحسين تلك الطريقة، ومع ذلك ما يزال البرنامج "MultiprocessingDemo1" مثالًا جيدًا على المعالجة المُتعدّدة. عندما ينقر المُستخدِم على زر "Start"، سيُنشِئ البرنامج عدد الخيوط المُخصَّصة ويُشغِّلها، كما سيُسنِد لكُلٍ منها جزءًا من الصورة. ألقِ نظرةً على الشيفرة التالية: workers = new Runner[threadCount]; // يحمل خيوط المعالجة int rowsPerThread; // عدد الخيوط التي ينبغي أن يحسبها كل خيط rowsPerThread = height / threadCount; // (height = vertical size of image) running = true; // اضبط قيمة الإشارة قبل تشغيل الخيوط threadsCompleted = 0; // Records how many of the threads have terminated. for (int i = 0; i < threadCount; i++) { int startRow; // الصف الأول الذي يحسبه الخيط رقم i int endRow; // 1 startRow = rowsPerThread*i; if (i == threadCount-1) endRow = height-1; else endRow = rowsPerThread*(i+1) - 1; workers[i] = new Runner(startRow, endRow); try { workers[i].setPriority( Thread.currentThread().getPriority() - 1 ); } catch (Exception e) { } workers[i].start(); } [1] الصف الأخير الذي يحسبه الخيط رقم i. انشِئ خيطًا وشغَِله لحساب صفوف الصورة من startRow إلى endRow. لا بُدّ أن تكون قيمة endRow للخيط الأخير مساويةً لرقم الصف الأخير بالصورة. أجرينا عددًا قليلًا من التعديلات لتفعيل المعالجة المتعددة إلى جانب إنشاء عدة خيوط بدلًا من خيط واحد. كما هو الحال في المثال السابق: عندما ينتهي أي خيطٍ من حساب ألوان صفٍ من البسكلات، فإنه يَستدعِي التابع Platform.runLater() ليَنسَخ الصف إلى الصورة. هناك شيءٌ واحدٌ جديد، وهو: عندما تنتهي جميع الخيوط من عملها، سيتبدّل اسم الزر من "Abort" إلى "Start Again"، وسيُعاد تفعيل القائمة التي كانت قد عُطّلَت بينما الخيوط مُشغَّلة. والآن، كيف سنعرف أن جميع الخيوط قد انتهت؟ ربما تتساءل لما لا نَستخدِم join() لننتظر انتهاء الخيوط كما فعلنا بمثالٍ سابق في قسم العمليات على الخيوط من المقال السابق؟ حيث لا يُمكِننا بالتأكيد فعل ذلك بخيط تطبيق جافا إف إكس على الأقل. سنَستخدِم في هذا المثال متغير نسخة instance variable، اسمه threadsRunning لتمثيل عدد خيوط المعالجة قيد التنفيذ؛ وعندما ينتهي كل خيطٍ من عمله، عليه استدعاء تابعٍ لإنقاص قيمة ذلك المتغير بمقدار الواحد، حيث سيُستدعَى ذلك التابع ضمن عبارة finally بتعليمة try لنتأكَّد تمامًا من تنفيذها عند انتهاء الخيط. عندما يُصبِح عدد الخيوط المُشغَّلة صفرًا، سيُحدِّث التابع حالة البرنامج على النحو المطلوب. تعرض الشيفرة التالية التابع الذي ستستدعيه الخيوط قبل انتهائها: synchronized private void threadFinished() { threadsRunning--; if (threadsRunning == 0) { // انتهت جميع الخيوط Platform.runLater( () -> { // تأكَّد من صحة حالة واجهة المُستخدِم الرسومية عندما تنتهي الخيوط startButton.setText("Start Again"); startButton.setDisable(false); threadCountSelect.setDisable(false); }); running = false; workers = null; } } لاحِظ أن التابع المُعرَّف بالأعلى متزامن synchronized؛ لضمان تجنُّب حالة التسابق race condition التي يُمكِنها أن تقع عند إنقاص قيمة المُتغيّر threadsRunning. قد يَستدعِي خيطان ذلك التابع بنفس اللحظة إذا لم نَستخدِم المزامنة، وإذا كان التوقيت دقيقًا، فربما يقرأ كلاهما نفس قيمة المتغير threadsRunning، ويَحسِبا نفس الإجابة بعد إنقاصه. ستقل في تلك الحالة قيمة المتغير threadsRunning بمقدار واحدٍ فقط وليس اثنين؛ أي أننا لم نَعُدّ خيطًا على النحو الصحيح، وعليه لن يَصِل المتغير threadsRunning إلى الصفر نهائيًا، وسيَعمَل البرنامج باستمرار بطريقةٍ مشابهة للقفل الميت deadlock. في الواقع، نادرًا ما تَحدث تلك المشكلة لأنها تعتمد على توقيتٍ بعينه، ولكن بالبرامج الأكبر حجمًا، تصبح تلك المشاكل خطيرةً للغاية كما أن تنقيحها debug ليس سهلًا. في المقابل، تمنع المزامنة حدوث ذلك الخطأ تمامًا. ترجمة -بتصرّف- للقسم Section 2: Programming with Threads من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مقدمة إلى الخيوط Threads في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا كتابة أصناف وتوابع معممة في جافا
-
سنشرح في هذه المقالة حل التمرين التالي من المقالة السابقة فهرسة الصفحات وتحليل زمن تشغيلها، ثم ننفّذ شيفرة تدمج مجموعةً من نتائج البحث وترتِّبها بحسب مدى ارتباطها بكلمات البحث. الزاحف crawler لنمرّ أولًا على حل تمرين المقالة المشار إليها. كنا قد وفَّرنا الشيفرة المبدئية للصنف WikiCrawler وكان المطلوب منك هو إكمال التابع crawl. انظر الحقول المُعرَّفة في ذلك الصنف: public class WikiCrawler { // يشير إلى المكان الذي بدأنا منه private final String source; // المفهرِس الذي سنخزن فيه النتائج private JedisIndex index; // رتل محددات الموارد الموحدة المطلوب فهرستها private Queue<String> queue = new LinkedList<String>(); // يُستخدَم لقراءة الصفحات من موقع ويكيبيديا final static WikiFetcher wf = new WikiFetcher(); } عندما نُنشِئ كائنًا من النوع WikiCrawler، علينا أن نُمرِّر قيمتي source و index. يحتوي المتغير queue مبدئيًا على عنصر واحد فقط هو source. لاحِظ أن الرتل queue مُنفَّذ باستخدام قائمةٍ من النوع LinkedList، وبالتالي، تستغرق عملية إضافة العناصر إلى نهايته -وحذفها من بدايته- زمنًا ثابتًا، ولأننا أسندنا قائمةً من النوع LinkedList إلى متغير من النوع Queue، أصبح استخدامنا له مقتصرًا على التوابع المُعرَّفة بالواجهة Queue، أي سنَستخدِم التابع offer لإضافة العناصر و poll لحذفها منه. انظر تنفيذنا للتابع WikiCrawler.crawl: public String crawl(boolean testing) throws IOException { if (queue.isEmpty()) { return null; } String url = queue.poll(); System.out.println("Crawling " + url); if (testing==false && index.isIndexed(url)) { System.out.println("Already indexed."); return null; } Elements paragraphs; if (testing) { paragraphs = wf.readWikipedia(url); } else { paragraphs = wf.fetchWikipedia(url); } index.indexPage(url, paragraphs); queueInternalLinks(paragraphs); return url; } السبب وراء التعقيد الموجود في التابع السابق هو تسهيل عملية اختباره. تُوضِّح النقاط التالية المنطق المبني عليه التابع: إذا كان الرتل فارغًا، يعيد التابع القيمة الفارغة null لكي يشير إلى أنه لم يُفهرِس أي صفحة. إذا لم يكن فارغًا، فإنه يقرأ محدد الموارد الموحد URL التالي ويَحذِفه من الرتل. إذا كان محدد الموارد قيد الاختيار مُفهرَسًا بالفعل، لا يُفهرِّسه التابع مرة أخرى إلا إذا كان في وضع الاختبار. يقرأ التابع بعد ذلك محتويات الصفحة: إذا كان التابع في وضع الاختبار، فإنه يقرؤها من ملف، وإن لم يكن كذلك، فإنه يقرؤها من شبكة الإنترنت. يُفهرِس الصفحة. يُحلِّل الصفحة ويضيف الروابط الداخلية الموجودة فيها إلى الرتل. يعيد في النهاية مُحدّد موارد الصفحة التي فهرَسها للتو. كنا قد عرضنا تنفيذًا للتابع Index.indexPage في نفس المقالة المشار إليها في الأعلى، أي أن التابع الوحيد الجديد هو WikiCrawler.queueInternalLinks. كتبنا نسختين من ذلك التابع بمعاملات parameters مختلفة: تَستقبِل الأولى كائنًا من النوع Elements يتضمَّن شجرة DOM واحدةً لكل فقرة، بينما تَستقبِل الثانية كائنًا من النوع Element يُمثِل فقرة واحدة. تمرّ النسخة الأولى عبر الفقرات، في حين تُنفِّذ النسخة الثانية العمل الفعلي. void queueInternalLinks(Elements paragraphs) { for (Element paragraph: paragraphs) { queueInternalLinks(paragraph); } } private void queueInternalLinks(Element paragraph) { Elements elts = paragraph.select("a[href]"); for (Element elt: elts) { String relURL = elt.attr("href"); if (relURL.startsWith("/wiki/")) { String absURL = elt.attr("abs:href"); queue.offer(absURL); } } } لكي نُحدِّد ما إذا كان مُحدّد موارد موحد معين هو مُحدّد داخلي، فإننا نفحص ما إذا كان يبدأ بكلمة "/wiki/". قد يتضمَّن ذلك بعض الصفحات التي لا نرغب في فهرستها مثل بعض الصفحات الوصفية لموقع ويكيبيديا، كما قد يستثني ذلك بعض الصفحات التي نريدها مثل روابط الصفحات المكتوبة بلغات أخرى غير الإنجليزية، ومع ذلك، تُعدّ تلك الطريقة جيدة بالقدر الكافي كبداية. لا يتضمَّن هذا التمرين الكثير، فهو فرصة فقط لتجميع الأجزاء الصغيرة معًا. استرجاع البيانات سننتقل الآن إلى المرحلة التالية من المشروع، وهي تنفيذ أداة بحث تتكوّن مما يلي: واجهة تُمكِّن المُستخدمين من إدخال كلمات البحث ومشاهدة النتائج. طريقة لاستقبال كلمات البحث وإعادة الصفحات التي تتضمَّنها. طريقة لدمج نتائج البحث العائدة من عدة كلمات بحث. خوارزمية تُصنِّف نتائج البحث وتُرتِّبها. يُطلَق على تلك العمليات وما يشابهها اسم استرجاع المعلومات Information retrieval. أنشأنا بالفعل نسخةً بسيطةً من الخطوة رقم 2، وسنُركِّز في هذا التمرين على الخطوتين 3 و 4. قد ترغب بالعمل أيضًا على الخطوة رقم 1 إذا كنت مهتمًا ببناء تطبيقات الويب. البحث المنطقي/الثنائي Boolean search تستطيع معظم محركات البحث أن تُنفِّذ بحثًا منطقيًا، بمعنى أن بإمكانها دمج نتائج البحث الخاصة بعدة كلمات باستخدام المنطق الثنائي، ولنأخذ أمثلةً على ذلك: تعيد عملية البحث عن "java AND programming" الصفحات التي تحتوي على الكلمتين "java" و "programming" فقط. تعيد عملية البحث عن "java OR programming" الصفحات التي تحتوي على إحدى الكلمتين وليس بالضرورة كلتيهما. تعيد عملية البحث عن "java -indonesia" الصفحات التي تحتوي على كلمة "java" ولا تحتوي على كلمة "indonesia". يُطلَق على تلك التعبيرات، أي تلك التي تحتوي على كلمات بحث وعمليات، اسم "استعلامات queries". عندما تُطبَّق تلك العمليات على نتائج البحث، فإن الكلمات "AND" و "OR" و "-" تقابل في الرياضياتِ عمليات "التقاطع" و "الاتحاد" و "الفرق" على الترتيب. لنفترض مثلًا أن: s1 يمثل مجموعة الصفحات التي تحتوي على كلمة "java"، s2 يمثل مجموعة الصفحات التي تحتوي على كلمة "programming"، s3 يمثل مجموعة الصفحات التي تحتوي على كلمة "indonesia"، في تلك الحالة: يُمثِل التقاطع بين s1 و s2 مجموعة الصفحات التي تحتوي على الكلمتين "java" و "programming" معًا. يُمثِل الاتحاد بين s1 و s2 مجموعة الصفحات التي تحتوي على كلمة "java" أو كلمة "programming". يُمثِل الفرق بين s1 و s3 مجموعة الصفحات التي تحتوي على كلمة "java" ولا تحتوي على كلمة "indonesia". ستكتب في القسم التالي تابعًا يُنفِّذ تلك العمليات. تمرين 13 ستجد ملفات شيفرة هذا التمرين في مستودع الكتاب: WikiSearch.java: يُعرِّف كائنًا يحتوي على نتائج البحث ويُطبِّق العمليات عليها. WikiSearchTest.java: يحتوي على شيفرة اختبار للصنفWikiSearch. Card.java: يُوضِّح طريقة استخدام التابع sort المُعرَّف بالنوع java.util.Collections. ستجد أيضًا بعض الأصناف المساعدة التي استخدَمناها من قبل في هذه السلسلة. انظر بداية تعريف الصنف WikiSearch: public class WikiSearch { // يربط مُحدّدات الموارد التي تحتوي على الكلمة بدرجة الارتباط private Map<String, Integer> map; public WikiSearch(Map<String, Integer> map) { this.map = map; } public Integer getRelevance(String url) { Integer relevance = map.get(url); return relevance==null ? 0: relevance; } } يحتوي كائن النوع WikiSearch على خريطة map تربط مُحدّدات الموارد الموحدة URLs بدرجة الارتباط relevance score، والتي تُمثِل -ضمن سياق استرجاع البيانات- عددًا يشير إلى المدى الذي يستوفي به مُحدِّد الموارد الاستعلام الذي يدخله المُستخدِم. تتوفّر الكثير من الطرائق لحساب درجة الارتباط، ولكنها تعتمد في الغالب على "تردد الكلمة" أي عدد مرات ظهورها في الصفحة. يُعدّ TF-IDF واحدًا من أكثر درجات الارتباط شيوعًا، وتُمثِل الأحرف اختصارًا لعبارة تردد المصطلح term frequency - معكوس تردد المستند inverse document frequency. إذا احتوى الاستعلام على كلمة بحث واحدة، فإن درجة الارتباط لصفحة معينة تساوي تردّد الكلمة، أي عدد مرات ظهورها في تلك الصفحة. بالنسبة للاستعلامات التي تحتوي على عدة كلمات، تكون درجة الارتباط لصفحة معينة هي حاصل مجموع تردد الكلمات، أي عدد مرات ظهور أي كلمة منها. والآن وقد أصبحت مستعدًا لبدء التمرين، نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant WikiSearchTest. ستفشل الاختبارات كالعادة لأن ما يزال عليك إكمال بعض العمل. أكمل متن كلٍّ من التوابع and و or و minus في الملف WikiSearch.java لكي تتمكّن من اجتياز الاختبارات المرتبطة بتلك التوابع. لا تقلق بشأن التابع testSort، فسنعود إليه لاحقًا. يُمكِنك أن تُنفِّذ WikiSearchTest بدون اِستخدَام Jedis لأنه لا يعتمد على فهرس قاعدة بيانات Redis الخاصة بك، ولكن، إذا أردت أن تَستخِدم الفهرس للاستعلام query، فلا بُدّ أن توفِّر بيانات الخادم في ملف، كما أوضحنا في مقالة "استخدام قاعدة بيانات Redis لتحقيق استمرارية البيانات". نفِّذ الأمر ant JedisMaker لكي تتأكّد من قدرته على الاتصال بخادم Redis، ثم نفِّذ WikiSearch الذي يَطبَع نتائج الاستعلامات الثلاثة التالية: "java" "programming" "java AND programming" لن تكون النتائج مُرتَّبة في البداية لأن التابع WikiSearch.sort ما يزال غير مكتمل. أكمل متن التابع sort لكي تُصبِح النتائج مُرتَّبة تصاعديًا بحسب درجة الارتباط. يُمكِنك الاستعانة بالتابع sort المُعرَّف بالنوع java.util.Collections حيث يُمكِنه ترتيب أي نوع قائمة List. يُمِكنك الاطلاع على توثيق النوع List. تتوفَّر نسختان من التابع sort: نسخة أحادية المعامل تَستقبِل قائمةً وتُرتِّب عناصرها باستخدام التابع compareTo، ولذلك ينبغي أن تكون العناصر من النوع Comparable. نسخة ثنائية المعامل تَستقبِل قائمةً من أي نوع وكائنًا من النوع Comparator، ويُستخدَم التابع compare المُعرَّف ضمن الكائن لموازنة العناصر. سنتحدث عن الواجهتين Comparable و Comparator في القسم التالي إن لم تكن على معرفة بهما. الواجهتان Comparable و Comparator يتضمَّن مستودع الكتاب الصنف Card الذي يحتوي على طريقتين لترتيب قائمة كائنات من النوع Card. انظر إلى بداية تعريف الصنف: public class Card implements Comparable<Card> { private final int rank; private final int suit; public Card(int rank, int suit) { this.rank = rank; this.suit = suit; } تحتوي كائنات الصنف Card على الحقلين rank و suit من النوع العددي الصحيح. يُنفِّذ الصنف Card الواجهة Comparable<Card> مما يَعنِي أنه بالضرورة يُوفِّر تنفيذًا للتابع compareTo: public int compareTo(Card that) { if (this.suit < that.suit) { return -1; } if (this.suit > that.suit) { return 1; } if (this.rank < that.rank) { return -1; } if (this.rank > that.rank) { return 1; } return 0; } تشير بصمة التابع compareTo إلى أن عليه أن يعيد عددًا سالبًا إذا كان this أقل من that، وعددًا موجبًا إذا كان أكبر منه، وصفرًا إذا كانا متساويين. إذا استخدمت نسخة التابع Collections.sort أحادية المعامل، فإنها بدورها تَستدعِي التابع compareTo المُعرَّف ضمن العناصر لكي تتمكّن من ترتيبها. على سبيل المثال، تُنشِئ الشيفرة التالية قائمة تحتوي على 52 بطاقة: public static List<Card> makeDeck() { List<Card> cards = new ArrayList<Card>(); for (int suit = 0; suit <= 3; suit++) { for (int rank = 1; rank <= 13; rank++) { Card card = new Card(rank, suit); cards.add(card); } } return cards; } ثم تُرتِّبها كالتالي: Collections.sort(cards); تُرتِّب تلك النسخة من التابع sort العناصر وفقًا لما يُطلَق عليه "الترتيب الطبيعي" لأن الترتيب مُحدّد بواسطة العناصر نفسها. في المقابل، يُمكِننا أيضًا أن نستعين بكائن من النوع Comparator لكي نَفرِض نوعًا مختلفًا من الترتيب. على سبيل المثال، تحتل بطاقات الأص المَرتَبَة الأقلَّ بحسب الترتيب الطبيعي للصنف Card، ومع ذلك، فإنها أحيانًا تحتل المرتبة الأكبر في بعض ألعاب البطاقات، ولذلك، سنُعرِّف كائنًا من النوع Comparator يُعامِل الأصّ على أنّها البطاقة الأكبر ضمن مجموعة بطاقات اللعب. انظر إلى شيفرة ذلك النوع: Comparator<Card> comparator = new Comparator<Card>() { @Override public int compare(Card card1, Card card2) { if (card1.getSuit() < card2.getSuit()) { return -1; } if (card1.getSuit() > card2.getSuit()) { return 1; } int rank1 = getRankAceHigh(card1); int rank2 = getRankAceHigh(card2); if (rank1 < rank2) { return -1; } if (rank1 > rank2) { return 1; } return 0; } private int getRankAceHigh(Card card) { int rank = card.getRank(); if (rank == 1) { return 14; } else { return rank; } } }; تُعرِّف تلك الشيفرة صنفًا مجهول الاسم anonymous يُنفِّذ التابع compare على النحو المطلوب، ثم تُنشِئ نسخةً منه. يُمكِنك القراءة عن الأصناف مجهولة الاسم Anonymous Classes في لغة جافا إذا لم تكن على معرفة بها. يُمكِننا الآن أن نُمرِّر ذلك الكائن المنتمي للنوع Comparator إلى التابع sort، كما هو مبين في الشيفرة التالية: Collections.sort(cards, comparator); يُعد الأص البستوني وفقًا لهذا الترتيب البطاقة الأكبر ضمن مجموعة بطاقات اللعب، بينما يُعدّ الثنائي السباتي البطاقة الأصغر. ستجد شيفرة هذا القسم في الملف Card.java إن كنت تريد تجريبه. قد ترغب أيضًا في كتابة كائن آخر من النوع Comparator يُرتِّب العناصر بناءً على قيمة rank أولًا ثم قيمة suit، وبالتالي، تصبح جميع بطاقات الأصّ معًا وجميع البطاقات الثنائية معًا، وهكذا. ملحقات إذا تمكَّنت من كتابة الكائن الذي أشرنا إليه في الأعلى، هاك بعض الأفكار الأخرى التي يُمكِنك أن تحاول القيام بها: اقرأ عن درجة الارتباط TF-IDF ونفِّذها. قد تحتاج إلى تعديل الصنف JavaIndex لكي تجعله يَحسِب قيمة ترددات المستند أي عدد مرات ظهور كل كلمة في جميع الصفحات الموجودة بالفهرس. بالنسبة للاستعلامات المكوَّنة من أكثر من كلمةٍ واحدة، يُمكِنك أن تَحسِب درجة الارتباط الإجمالية لكل صفحة بحساب مجموع درجة الارتباط لجميع الكلمات. فكر متى يُمكِن لهذه النسخة المبسطة أن تَفشَل وجرِّب بدائل أخرى. أنشِئ واجهة مُستخدِم تَسمَح للمُستخدِمين بإدخال استعلامات تحتوي على عوامل operators منطقية. حلِّل الاستعلامات المُدْخَلة، وولِّد النتائج، ثم رتِّبها بحسب درجة الارتباط، واعرض مُحدِّدات الموارد التي أحرزت أعلى درجات. حاول أيضًا أن تُولِّد مقطع شيفرة يَعرِض مكان ظهور كلمات البحث في الصفحة. إذا أردت أن تُنشِئ تطبيق ويب لواجهة المُستخدِم التي أنشأتها، فإن منصة Heroku تُعدّ خيارًا بسيطًا لتطوير تطبيقات الويب ونشرها باستخدام جافا. ترجمة -بتصرّف- للفصل Chapter 16: Boolean search من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: نظرة سريعة على بعض خوارزميات الترتيب المقال السابق: فهرسة الصفحات وتحليل زمن تشغيلها باستخدام قاعدة بيانات Redis استخدام أشجار البحث الثنائية والأشجار المتزنة balanced trees لتنفيذ الخرائط تحليل زمن تشغيل الخرائط المنفذة باستخدام شجرة بحث ثنائية TreeMap في جافا
-
يمكن للحواسيب إنجاز عدة مهامٍ مختلفة بنفس الوقت؛ فإذا كان الحاسوب مُكوَّنًا من وحدة معالجةٍ واحدة، فإنه لا يستطيع حرفيًا إنجاز شيئين بنفس الوقت؛ ولكن ما يزال بإمكانه تحويل انتباهه بين عدة مهامٍ باستمرار. تتكوَّن معظم الحواسيب العصرية من أكثر من مجرد وحدة معالجةٍ واحدة، وبإمكانها تنفيذ عدة مهامٍ بنفس الوقت حرفيًا، وغالبًا ما تكون زيادة قدرة المعالجة للحواسيب ناتجةً عن إضافة عددٍ أكبر من المعالجات إليها بدلًا من زيادة سرعة كل معالجٍ على حدى. حتى تتمكّن البرامج من تحقيق الاستفادة القصوى من الحواسيب متعددة المعالجات، لا بُدّ أن تكون مُبرمَجَةً على التوازي parallel programming؛ بما يعني كتابة البرنامج بهيئة مجموعةٍ من المهام المُمكِن تنفيذها بنفس الوقت. ما تزال تقنيات البرمجة على التوازي مفيدة حتى بالحواسيب أحادية المعالج، حيث يُساعد تقسيم المشكلات إلى مجموعة من المهام على معالجة المشكلة بصورةٍ مُبسّطة. يُطلَق اسم خيط thread بلغة جافا على كل مهمة، ويشير ذلك الاسم إلى "خيط التحكُّم" أو "خيط التنفيذ" الذي يعني متتالية التعليمات المُنفَّذة واحدةً تلو الأخرى؛ حيث يمتد الخيط عبر الزمن ويربط كل تعليمةٍ بما يليها من تعليمات. توجد خيوط تحكمٍ كثيرة بالبرامج مُتعدّدة الخيوط، وتَعمَل جميعًا على التوازي، لتُشكِّل "نسيج" البرنامج. يتكوَّن كل برنامج عمومًا من خيطٍ واحدٍ على الأقل؛ فعندما نُشغِّل برنامجًا معينًا بآلة جافا الافتراضية Java virtual machine، فإنها تُنشِئ خيطًا مسؤولًا عن تنفيذ البرنامج الرئيسي الذي يستطيع أن يُنشِئ بدوره خيوطًا أخرى قد تستمر حتى بعد انتهاء الخيط الرئيسي. بالنسبة لبرامج واجهة المُستخدِم الرسومية GUI، يكون هنالك خيطٌ إضافي مسؤولٌ عن معالجة الأحداث events ورسم مُكوِّنات الواجهة على الشاشة؛ وفي حالة اِستخدَام مكتبة جافا إف إكس JavaFX، يكون ذلك الخيط هو خيط التطبيق، ويكون مسؤولًا عن إنجاز كل الأمور المتعلّقة بمعالجة الأحداث ورسم المكونات على الشاشة. تُعدّ البرمجة المتوازية أصعب نوعًا ما من البرمجة أحادية الخيط؛ فعند وجود عدة خيوطٍ تَعمَل معًا لحل مشكلةٍ معينة، قد تَنشَأ أنواعٌ جديدة من الأخطاء، ولذلك تُعدّ التقنيات المُستخدَمة لكتابة البرامج كتابة صحيحة ومتينة أكثر أهميةً بالبرمجة المتوازية منها بالبرمجة العادية. تُوفِّر جافا لحسن الحظ واجهة برمجة تطبيقات للخيوط threads API تُسِّهل كثيرًا من استخدام الخيوط على نحوٍ معقول، كما أنها تحتوي على الكثير من الأصناف القياسية التي تُغلِّف الأجزاء الأكثر تعقيدًا وتخفيها بالكامل. يُمكِننا إنجاز أمورٍ كثيرة باستخدام الخيوط دون أن نتعلم أي شيءٍ عن تقنياتها منخفضة المستوى. إنشاء الخيوط وتشغيلها يُمثَّل الخيط بلغة جافا بواسطة كائنٍ ينتمي إلى الصنف java.lang.Thread، أو إلى أيٍّ من أصنافه الفرعية؛ حيث يُنشَأ هذا الكائن بغرض تنفيذ تابعٍ method -يُمثِّل المهمة المُفترَض للخيط تنفيذها- لمرةٍ واحدةٍ فقط، أي يُنفَّذ ذلك التابع داخل خيط التحكم الخاص به، والذي يُمكِنه أن يَعمَل بالتوازي مع خيوطٍ أخرى. يتوقف الخيط عن العمل بعد الانتهاء من تنفيذ التابع طبيعيًا أو نتيجةً لحدوث استثناءٍ exception لم يُلتقَط، وعندها لا تتوفَّر أي طريقةٍ لإعادة تشغيله أو حتى لاستخدام الكائن المُمثِّل لذلك الخيط لإنشاء واحدٍ جديد. تتوفَّر طريقتان لبرمجة خيط؛ حيث تتمثّل الأولى بإنشاء صنفٍ فرعي من الصنف Thread يحتوي على تعريفٍ للتابع public void run()، ويكون هذا التابع مسؤولًا عن تعريف المُهمة التي سيُنفِّذها الخيط؛ فهو يَعمَل بمجرد بدء تشغيل الخيط. تُعرِّف الشيفرة التالية على سبيل المثال صنفًا بسيطًا لخيطٍ لا يَفعَل أكثر من مجرد طباعة رسالةٍ إلى الخرج القياسي standard output: public class NamedThread extends Thread { private String name; // اسم الخيط public NamedThread(String name) { // يضبُط الباني اسم الخيط this.name = name; } public void run() { // تُرسِل رسالة إلى الخرج القياسي System.out.println("Greetings from thread '" + name + "'!"); } } يجب أن نُنشِئ كائنًا ينتمي إلى الصنف NamedThread لنتمكَّن من اِستخدامه. ألقِ نظرةً على ما يلي، على سبيل المثال: NamedThread greetings = new NamedThread("Fred"); لا يؤدي إنشاء ذلك الكائن إلى بدء تشغيل الخيط، أو تنفيذ تابعه run() تلقائيًا، وإنما يجب استدعاء التابع start() المُعرَّف بالكائن. يُمكِننا مثلًا كتابة التعليمة التالية: greetings.start(); يُنشِئ التابع start() خيط تحكمٍ جديد مسؤولٍ عن تنفيذ التابع run() المُعرَّف بالكائن، حيث يعمل هذا الخيط الجديد على التوازي إلى جانب الخيط الذي استدعينا به التابع start()، وكذلك إلى جانب أي خيوطٍ أخرى موجودةٍ مسبقًا. ينتهي التابع start() من العمل ويُعيد قيمته بمجرد تشغيله للخيط الجديد دون أن ينتظر انتهاء الخيط من العمل؛ وهذا يَعنِي أن شيفرة التابع run() المُعرَّف بكائن الخيط تُنفَّذ بنفس الوقت الذي تُنفَّذ خلاله التعليمات التالية لتعليمة استدعاء التابع start(). ألقِ نظرةً على الشيفرة التالية: NamedThread greetings = new NamedThread("Fred"); greetings.start(); System.out.println("Thread has been started"); يوجد بعد تنفيذ التعليمة greetings.start() خيطان؛ حيث يَطبَع الأول جملة "Thread has been started"؛ بينما يريد الآخر طباعة جملة "!`Greetings from thread 'Fred". قد يختلف ترتيب طباعة الجملتين كلما شغَّلت البرنامج، حيث يَعمَل الخيطان بنفس الوقت، ويحاول كلٌ منهما الوصول إلى الخرج القياسي لطباعة الرسالة الخاصة به. وبالتالي، سيطبع الخيط الذي يتمكَّن من الوصول إلى الخرج القياسي أولًا، رسالته أولًا. يختلف ذلك عن البرامج العادية أحادية الخيط، حيث تُنفَّذ التعليمات بترتيبٍ مُحدَّد ومتوقَّع من البداية إلى النهاية؛ بينما هناك دائمًا عدم تحديد indeterminacy في البرامج متعددة الخيوط، فلا يكون الترتيب معروفًا أو محددًا، ولذلك لا يُمكِننا التأكُّد أبدًا من الترتيب الذي ستُنفَّذ على أساسه التعليمات، وهذا يَجعَل البرمجة المتوازية parallel programming صعبةً نوعًا ما. افترضنا حتى الآن احتواء الحاسوب الذي نُشغِّل عليه البرنامج على أكثر من وحدة معالجةٍ واحدة، ويسمح هذا بتنفيذ كُلٍ من الخيط الأصلي والخيط الجديد بنفس الوقت حرفيًا. ومع ذلك، من الممكن حتى إنشاء عدة خيوطٍ بحاسوبٍ مكوَّنٍ من وحدة معالجة واحدة، ومن الممكن إنشاء خيوطٍ يتجاوز عددها عدد معالجات الحاسوب عمومًا. في تلك الحالة، يتنافس الخيطان على زمن المعالج، ويَظِل هناك نوعٌ من عدم التحديد؛ لأن المعالج يُمكِنه الانتقال من تنفيذ خيط لآخر بطريقةٍ غير متوقعة. أما بالنسبة للمبرمج، لا تختلف البرمجة بحاسوبٍ أحادي المعالج عنها بحاسوبٍ متعدد المعالجات ولهذا، سنتجاهل ذلك التمييز. كنا قد ذكرنا أن هناك طريقتين لبرمجة خيط؛ حيث كانت الطريقة الأولى بتعريف صنفٍ فرعي من الصنف Thread. ننتقل الآن إلى الطريقة الثانية، وهي بتعريف صنفٍ يُنفِّذ الواجهة java.lang.Runnable؛ حيث تُعرِّف تلك الواجهة interface تابعًا وحيدًا، هو public void run(). يُمكِننا إنشاء خيطٍ من النوع Thread مهمته هي تنفيذ التابع run() المُعرَّف بالواجهة، بمجرد حصولنا على كائنٍ منفِّذ لتلك الواجهة. يحتوي الصنف Thread على بانٍ constructor يَستقبِل كائنًا منفِّذًا للواجهة Runnable على أنه معاملٌ parameter. عندما نُمرِّر ذلك الكائن للباني، يَستدعِي تابع الخيط run() التابع run() المُعرَّف بالواجهة Runnable؛ وعندما نَستدعِي تابع الخيط start()، فإنه يُنِشئ خيط تحكمٍ جديد يكون مسؤولًا عن تنفيذ التابع run() المُعرَّف بالواجهة Runnable. يُمكِننا مثلًا تعريف الصنف التالي بدلًا من إنشاء الصنف NamedThread: public class NamedRunnable implements Runnable { private String name; // الاسم public NamedRunnable(String name) { // يَضبُط الباني اسم الكائن this.name = name; } public void run() { // يُرسِل رسالةً إلى الخرج القياسي System.out.println("Greetings from runnable '" + name +"'!"); } } سنُنشِئ الآن كائنًا ينتمي إلى الصنف NamedRunnable المُعرَّف بالأعلى، ونَستخدِمه لإنشاء كائن من النوع Thread على النحو التالي: NamedRunnable greetings = new NamedRunnable("Fred"); Thread greetingsThread = new Thread(greetings); greetingsThread.start(); تتميِّز تلك الطريقة عن الأولى في إمكانية أي كائنٍ من تنفيذ الواجهة Runnable وتعريف التابع run()، والذي يُمكِن تنفيذه بعد ذلك بخيطٍ منفصل. بالإضافة إلى ذلك، يستطيع التابع run() الوصول إلى أي شيءٍ مُعرَّفٍ بالصنف بما في ذلك توابعه ومتغيراته الخاصة private. في المقابل، لا تُعدّ تلك الطريقة كائنية التوجه object-oriented تمامًا؛ فهي تخالف المبدأ الذي ينصّ على ضرورة أن يكون لكل كائنٍ مسؤوليةً وحيدةً محددةً بوضوح. لذلك، يكون من الأفضل أن نُعرِّف الخيط باستخدام صنف متداخل nested فرعي من الصنف Thread، بدلًا من إنشاء كائنٍ عشوائي من النوع Runnable فقط لنَستخدِمه مثل خيط. انظر مقال الأصناف المتداخلة Nested Classes في جافا. أخيرًا، لاحِظ أن الواجهة Runnable هي واجهة نوع دالة functional interface، أي يُمكِن تمريرها مثل تعبير لامدا lambda expression. يَعنِي ذلك أن بإمكان باني الصنف Thread استقبال تعبير لامدا على أنه معامل. ألقِ نظرةً على المثال التالي: Thread greetingsFromFred = new Thread( () -> System.out.println("Greetings from Fred!") ); greetingsFromFred.start(); سنفحص الآن المثال التوضيحي ThreadTest1.java، لنفهم طريقة تنفيذ الخيوط المتعددة على التوازي؛ حيث سيُنشِئ هذا البرنامج عدة خيوط، بحيث ينفِّذ كل خيطٍ منها نفس المهمة تمامًا. ستكون المهمة هي عدُّ الأعداد الصحيحة الأوليّة الأقل من 5000000. ليس هناك غرضٌ محددٌ من اختيار تلك المهمة بالتحديد، فكل ما يَهُمّ هنا هو أن تستغرق المهمة وقتًا طويلًا بعض الشيء. لاحِظ أيضًا أن هذا البرنامج غير واقعي، فمن الحماقة إنشاء عدة خيوط لتنفيذ الأمر نفسه. لاحِظ أيضًا عدم عمل التابع المسؤول عن العدّ بكفاءة عالية. لن يستغرق البرنامج أكثر من عدّة ثوانٍ على أي حاسوبٍ عصري. تُعرِّف الشيفرة التالية صنفًا متداخلًا ساكنًا static nested class لتمثيل الخيوط المسؤولة عن تنفيذ المهمة: // 1 private static class CountPrimesThread extends Thread { int id; // مُعرِّف هوية لهذا الخيط public CountPrimesThread(int id) { this.id = id; } public void run() { long startTime = System.currentTimeMillis(); int count = countPrimes(2,5000000); // عدّ الأعداد الأولية long elapsedTime = System.currentTimeMillis() - startTime; System.out.println("Thread " + id + " counted " + count + " primes in " + (elapsedTime/1000.0) + " seconds."); } } [1] عند تشغيل خيطٍ ينتمي إلى هذا الصنف، فإنه يَعُدّ عدد الأعداد الأولية الواقعة بنطاقٍ يتراوح من 2 إلى 5000000. سيَطبَع النتيجة إلى الخرج القياسي، مع رقم مُعرِّف الهوية الخاص به، وكذلك الزمن المُنقضِي منذ لحظة بدء المعالجة وحتى نهايتها. سيطلب البرنامج main() المُعرَّف فيما يلي من المُستخدِم إدخال عدد الخيوط المطلوب تشغيلها، ثم سيُنشِئ تلك الخيوط ويُشغِّلها: public static void main(String[] args) { int numberOfThreads = 0; while (numberOfThreads < 1 || numberOfThreads > 25) { System.out.print("How many threads do you want to use (1 to 25) ? "); numberOfThreads = TextIO.getlnInt(); if (numberOfThreads < 1 || numberOfThreads > 25) System.out.println("Please enter a number between 1 and 25 !"); } System.out.println("\nCreating " + numberOfThreads + " prime-counting threads..."); CountPrimesThread[] worker = new CountPrimesThread[numberOfThreads]; for (int i = 0; i < numberOfThreads; i++) worker[i] = new CountPrimesThread( i ); for (int i = 0; i < numberOfThreads; i++) worker[i].start(); System.out.println("Threads have been created and started."); } ربما من الأفضل أن تُصرِّف compile البرنامج وتُشغِّله. عند تشغيل البرنامج باستخدام خيطٍ واحدٍ على حاسوبٍ قديمٍ نوعًا ما، يستغرق الحاسوب حوالي "6.251 ثانية" لإجراء العملية؛ وعند تشغيله باستخدام ثمانية خيوط، يكون الخرج على النحو التالي: Creating 8 prime-counting threads... Threads have been created and started. Thread 4 counted 348513 primes in 12.264 seconds. Thread 2 counted 348513 primes in 12.569 seconds. Thread 3 counted 348513 primes in 12.567 seconds. Thread 0 counted 348513 primes in 12.569 seconds. Thread 7 counted 348513 primes in 12.562 seconds. Thread 5 counted 348513 primes in 12.565 seconds. Thread 1 counted 348513 primes in 12.569 seconds. Thread 6 counted 348513 primes in 12.563 seconds. يَطبَع الحاسوب السطر الثاني تلقائيًا بعد السطر الأول، ويكون البرنامج main() في تلك اللحظة قد انتهى، بينما تستمر الثمانية خيوط الأخرى بالعمل. بعد فترةٍ تَصِل إلى "12.5 ثانية"، تكتمل جميع الخيوط الثمانية بنفس الوقت تقريبًا. لا يكون ترتيب انتهاء الخيوط من العمل هو نفسه ترتيب بدء تشغيلها، فالترتيب غير حتمي؛ أي إذا شغَّلنا البرنامج مرةً أخرى، فلربما سيختلف ذلك الترتيب. نظرًا لاحتواء الحاسوب على أربعة معالجات، استغرقت الثمانية خيوط عند تشغيلها عليه ضعف الزمن الذي استغرقه خيطٌ واحدٌ تقريبًا؛ فعند تشغيل ثمانية خيوط على أربعة معالجات (أي نصف معالج لكل خيط)، كان كل خيطٍ منها نَشطًا فعليًا لمدةٍ تصل إلى نصف ذلك الزمن فقط، ولهذا استغرقت ضعف الوقت لإنهاء نفس المهمة. بالمثل، إذا احتوى الحاسوب على معالجٍ واحدٍ فقط، فستستغرق الخيوط الثمان زمنًا يَصِل إلى ثمانية أضعاف الزمن الذي يستغرقه الخيط الواحد؛ وإذا احتوى الحاسوب على ثمانية معالجات أو أكثر، فلربما لن تستغرق الخيوط الثمان زمنًا أكبر مما يستغرقه الخيط الواحد. ومع ذلك، قد يكون التزايد الفعلي في السرعة أصغر قليلًا مما أشرنا إليه هنا نتيجةً لبعض التعقيدات، ويكون التزايد الفعلي مُحددًا في الحواسيب متعددة المعالجات. والآن حان دورك، ماذا يحدث عندما تُشغِّل البرنامج على حاسوبك الشخصي؟ كم عدد المعالجات الموجودة بحاسوبك؟ عندما يكون هناك خيوطٌ أكثر من عدد المعالجات المتاحة، يُقسِّم الحاسوب قدرته المعالجية على الخيوط المُشغَّلة بالتبديل بينها بسرعة. يعني ذلك تشغيل كل معالجٍ خيطًا واحدًا لفترة، ثم الانتقال إلى خيطٍ آخر لتشغيله لفترة، ثم ينتقل لغيره، وهكذا. يُطلق على تلك التنقلات اسم تبديلات السياق context switches، والتي تحدث بمعدلٍ يصل إلى 100 مرة أو أكثر بالثانية الواحدة. يستطيع الحاسوب بتلك الطريقة إحراز بعض التقدم بجميع المهمات المطلوبة، ويظن المُستخدِم أنها تُنفَّذ جميعًا بنفس الوقت. ولهذا السبب، انتهت جميع الخيوط التي كان مطلوبًا منها نفس حجم العمل بنفس الوقت تقريبًا في المثال التوضيحي السابق. خلاصة القول أنه ولأي فترةٍ زمنيةٍ أكبر من جزءٍ من الثانية، فسيُقسَّم زمن الحاسوب بالتساوي تقريبًا على جميع الخيوط. العمليات على الخيوط ستجد غالبية واجهة برمجة تطبيقات جافا للخيوط مُعرَّفةً بالصنف Thread، ومع ذلك سنبدأ بتابعٍ متعلقٍ بالخيوط ومعرَّفٍ بالصنف Runtime؛ حيث يَسمَح ذلك الصنف لبرامج جافا بالوصول إلى بعض المعلومات عن البيئة التي يعمل عليها البرنامج. عند إجراء برمجةٍ على التوازي بغرض توزيع العمل على أكثر من معالجٍ واحد، فلربما يكون من المهم أن نعرف عدد المعالجات المتاحة أولًا، فقد تُنشِئ خيطًا واحدًا لكل معالجٍ مثلًا. تستطيع معرفة عدد المعالجات باستدعاء الدالة التالية: Runtime.getRuntime().availableProcessors() تُعيد تلك الدالة قيمةً من النوع int تُمثِّل عدد المعالجات المتاحة بآلة جافا الافتراضية Java Virtual Machine. قد تكون تلك القيمة في بعض الحالات أقل من عدد المعالجات الفعلية المتاحة بالحاسوب. يحتوي أي كائنٍ من النوع Thread على الكثير من التوابع المفيدة المتعلِّقة بالعمل مع الخيوط؛ حيث يُعدّ التابع start() الذي نُوقِش بالأعلى واحدًا من أهم تلك التوابع. بمجرد بدء الخيط، فإنه يَظَل مُشغَّلًا إلى حين انتهاء تابعه run() من العمل. من المفيد في بعض الأحيان أن يعرف خيط معين فيما إذا كان خيطٌ آخر قد انتهى أم لا؛ فإذا كان thrd كائنًا من النوع Thread، ستفحص الدالة thrd.isAlive() فيما إذا thrd قد انتهى أم لا. يُعدّ الخيط "نشطًا alive" منذ لحظة تشغيله إلى لحظة انتهائه، ويُعدّ "ميتًا dead" بعد انتهائه. تُستخدَم نفس تلك الاستعارة عندما نُشير إلى "إيقاف" أو "إلغاء" الخيط. تذكَّر أنه من غير الممكن إعادة تشغيل أي خيطٍ بعد انتهائه. يؤدي استدعاء التابع الساكن Thread.sleep(milliseconds) إلى "سُبات sleep" الخيط المُستدعِي لفترةٍ مساويةٍ للزمن المُمرَّر بوحدة الميللي ثانية. يُعدّ الخيط النائم sleep نشطًا، ولكنه غير مُشغَّل، ويستطيع الحاسوب تنفيذ أي خيوطٍ أو برامجٍ أخرى أثناء توقُّف ذلك الخيط. يُمكِننا استخدام التابع Thread.sleep() لإيقاف تنفيذ خيطٍ معين مؤقتًا. بإمكان التابع sleep() التبليغ عن استثناء من النوع InterruptedException، والذي يُعدّ من الاستثناءات المُتحقَّق منها checked exception، أي لا بُدّ من معالجته؛ ويَعنِي ذلك عمليًا ضرورة استدعاء التابع sleep() داخل تعليمة try..catch لالتقاط أي استثناءات محتملةٍ من النوع InterruptedException. ألقِ نظرةً على الشيفرة التالية: try { Thread.sleep(lengthOfPause); } catch (InterruptedException e) { } يستطيع خيطٌ معينٌ مقاطعة Interrupt خيطٍ آخر نائم أو مُتوقِّف لأسبابٍ أخرى بهدف إيقاظه. إذا كان thrd كائنًا من النوع Thread، فسيؤدي استدعاء التابع thrd.interrupt() إلى مقاطعته. يُمكِننا الاستعانة بذلك التابع إذا كان من الضروري إرسال إشارةٍ معينة من خيطٍ لآخر. عندما يلتقط أي خيطٍ استثناءًا من النوع InterruptedException، فإنه يُدرك أن خيطًا آخر قد قاطعه. بالإضافة إلى ذلك، يستطيع الخيط استدعاء التابع الساكن Thread.interrupted() بأي مكانٍ خارج عبارة catch ليَفحَص فيما إذا كان قد قُوطعَ بواسطة خيطٍ آخر. بمجرد استدعاء ذلك التابع، تنمحي حالة المقاطعة من الخيط؛ أي يستطيع الخيط قراءة حالة المقاطعة لمرةٍ واحدةٍ فقط، وهو ما قد تراه غريبًا بعض الشيء. بالنسبة للبرامج الخاصة بك، فلن يُقاطع أي خيط خيطًا آخرًا إلا إذا برمجته ليفعل ذلك بنفسك؛ لذلك لا تحتاج غالبًا لفعل أي شيء فيما هو متعلِّق بالاستثناء InterruptedException أكثر من مجرد التقاطه. يكون من الضروري في بعض الأحيان لخيطٍ معين الانتظار إلى حين انتهاء خيطٍ آخر من العمل. يُمكِننا فعل ذلك باستخدام التابع join() المُعرَّف بالصنف Thread. بفرض أن thrd كائنٌ من النوع Thread، يُمكِن لأي خيطٍ آخر استدعاء thrd.join()؛ مما يَعنِي أنه سيدخل في حالة سُبات sleep إلى حين انتهاء thrd. في حالة كان thrd ميتًا بالفعل عند استدعاء thrd.join()، لا يكون لها أي تأثير. بإمكان التابع join() التبليغ عن استثناءٍ من النوع InterruptedException، والذي يجب مُعالجته كما ذكرنا بالأعلى. على سبيل المثال، تُشغِّل الشيفرة التالية عدة خيوط، وتنتظر إلى حين انتهائها جميعًا من العمل، ثم تَطبَع الزمن المُستغرَق: CountPrimesThread[] worker = new CountPrimesThread[numberOfThreads]; long startTime = System.currentTimeMillis(); for (int i = 0; i < numberOfThreads; i++) { worker[i] = new CountPrimesThread(); worker[i].start(); } for (int i = 0; i < numberOfThreads; i++) { try { worker[i].join(); // انتظر إلى أن ينتهي إذا لم يكن قد انتهى بالفعل } catch (InterruptedException e) { } } // بالوصول إلى تلك اللحظة، تكون جميع الخيوط العاملة قد انتهت long elapsedTime = System.currentTimeMillis() - startTime; System.out.println("Total elapsed time: " + (elapsedTime/1000.0) + " seconds"); ربما لاحظت أن تلك الشيفرة تفترض عدم حدوث استثناءاتٍ من النوع InterruptedException. إذا كانت تلك الاستثناءات محتملةً بالبيئة المُشغَّل عليها البرنامج، ينبغي استخدام الشيفرة التالية للتأكُّد تمامًا من أن الخيط الموجود بالكائن worker[i] قد انتهى أم لا: while (worker[i].isAlive()) { try { worker[i].join(); } catch (InterruptedException e) { } } تستقبل نسخةٌ أخرى من التابع join() معاملًا من النوع العددي الصحيح، وهو يُمثِّل الحد الأقصى من الزمن المسموح بانتظاره بوحدة الميللي ثانية، حيث ينتظر الاستدعاء thrd.join(m) إلى أن ينتهي الخيط thrd من العمل، أو إلى أن يمر m ميللي ثانية، أو إلى أن يُقاطَع الخيط المُنتظِر. يُمكِننا استخدام ذلك التابع للسماح للخيط المُنتظِر بإنجاز مهمةٍ ما أثناء انتظاره للخيط الآخر. على سبيل المثال، تُشغِّل الشيفرة التالية خيطًا اسمه thrd، ثم تَطبَع الزمن المُنقضِي كل 2 ثانية طالما كان thrd مُشغَّلًا: System.out.print("Running the thread "); thrd.start(); while (thrd.isAlive()) { try { thrd.join(2000); System.out.print("."); } catch (InterruptedException e) { } } System.out.println(" Done!"); تتميز الخيوط بخاصيتين مفيدتين في بعض الأحيان، هما: الحالة الخفية daemon status والأولوية priority؛ حيث يُمكِن للخيط أن يُضبَط ليُصبِح خيطًا خفيًا باستدعاء التابع thrd.setDaemon(true) قبل بدء تشغيل الخيط. قد يُبلِّغ الاستدعاء عن استثناءٍِ من النوع SecurityException إذا لم يكن الخيط المُستدعِي قادرًا على تعديل خاصيات الخيط thrd. وفي تلك الحالة، يكون إنهاء آلة جافا الافتراضية ممكنًا بمجرد انتهاء جميع الخيوط الحيّة غير الخفية، أي لا يُعدّ وجود بعض الخيوط الحيّة الخفية كافيًا لإبقاء آلة جافا الافتراضية مشغَّلة. يُعدّ ذلك منطقيًا، فالخيوط الخفية بالنهاية موجودةٌ فقط لتوفير بعض الخدمات للخيوط غير الخفية، ونظرًا لعدم وجود أي خيوطٍ غير خفية أخرى، لا تُستدَعى تلك الخدمات التي توفِّرها الخيوط الخفية مجددًا، ولذلك يُمكِن إنهاء البرنامج أيضًا. لاحِظ أن استدعاء System.exit() ينهِي آلة جافا الافتراضية JVM إجباريًأ حتى في حالة وجود بعض الخيوط المُشغَّلة غير الخفية. تُعدّ أولوية الخيط خاصيةً أكثر أهمية، حيث يمتلك أي خيط عمومًا أولويةً مُمثَلةً باستخدام عددٍ صحيح، ويكون تشغيل الخيوط ذات الأولوية الأكبر مُفضّلًا على حساب تشغيل الخيوط ذات الأولوية الأقل. على سبيل المثال، يُمكِن للعمليات الموجودة بالخلفية، والتي تُشغَّل عندما لا يكون هناك عملٌ ضروريٌ بخيطٍ هام آخر، أن تُشغَّل بأولوية أقل. إذا كان thrd كائنًا من النوع Thread، يُعيد التابع thrd.getPriority() عددًا صحيحًا يُمثِّل أولوية الخيط thrd، بينما يَضبُط التابع thrd.setPriority(p) أولوية الخيط إلى العدد الصحيح المُخصَّص p. لا يُمكِن تخصيص أي أعدادٍ صحيحة عشوائية على أنها أولويةً لخيطٍ معين، وسيبلِّغ التابع thrd.setPriority() عن استثناءِ من النوع llegalArgumentException، إذا لم تَكن الأولوية المُخصَّصة بالنطاق المسموح به للخيط. يختلف نطاق الأعداد المسموح بها لقيم أولوية خيطٍ من حاسوبٍ لآخر، وتكون مُخصَّصةً عبر الثوابت Thread.MIN_PRIORITY و Thread.MAX_PRIORITY، ومع ذلك، يُمكِن تقييد أولوية خيطٍ معينٍ لتقع ضمن قيمٍ أقل من الثابت Thread.MAX_PRIORITY. يتوفَّر أيضًا الثابت Thread.NORM_PRIORITY الذي يُمثِّل القيمة الافتراضية لأولوية خيط. يُمكِننا استخدام التعليمة التالية لضبط الخيط thrd؛ بحيث يَعمَل بقيمة أولوية أقل من القيمة الافتراضية بقليل: thrd.setPriority( Thread.NORM_PRIORITY - 1 ); ملاحظة: قد يُبلِّغ التابع thrd.setPriority() عن استثناءِ من النوع SecurityException أيضًا إذا لم يَكن مسموحًا للخيط المُستدعِي بضَبْط أولوية الخيط thrd إلى القيمة المُمرَّرة. أخيرًا، يعيد التابع الساكن Thread.currentThread() الخيط الحالي؛ أي أنه يعيد الخيط المُستدِعي لنفس ذلك التابع، وبذلك يستطيع الخيط أن يحصُل على مرجع reference لذاته، وهو ما يُمكِّنه من تعديل خاصياته. يُمكِننا مثلًا تحديد أولوية الخيط الجاري تشغيله باستدعاء Thread.currentThread().getPriority(). الإقصاء التشاركي Mutual Exclusion وتعليمة التزامن synchronized من السهل برمجة عدة خيوطٍ لتنفيذ بعض المهمات المستقلة تمامًا. تَكْمُن الصعوبة الحقيقية عندما تضطّر الخيوط للتفاعل مع بعضها بطريقةٍ أو بأخرى. تُعدّ مشاركة الموارد resources واحدةً من طرق تفاعل الخيوط مع بعضها؛ فعندما يحتاج خيطان مثلًا للوصول إلى نفس المورد، مثل متغير أو نافذةٍ على الشاشة، لا بُدّ من التأكُّد من عدم استخدامهما لنفس المورد بنفس اللحظة؛ وإلا سيكون الموقف مشابهًا لما يلي: إذا كان لدينا مجموعةٌ من الطباخين يتشاركون استخدام كوب قياسٍ واحدٍ فقط. لنتخيل أن الطباخ A قد ملئ كوب القياس بالحليب، وقبل أن يتمكَّن من تفريغه بالصحن الخاص به، أمسك الطباخ B بكوب القياس المملوء بالحليب. لذلك، لا بُدّ من توفير طريقة للطباخ A تُمكِّنه من المطالبة بأحقيته وحده للوصول إلى الكوب أثناء تنفيذه للعمليتين: إضافة الحليب إلى الكوب وتفريغ الكوب بالصحن. ينطبق الأمر ذاته على الخيوط حتى أثناء إجرائها لعمليةٍ بسيطة مثل زيادة قيمة عدادٍ بمقدار الواحد. ألقِ نظرةً على التعليمة التالية: count = count + 1; في الواقع، تتكوَّن التعليمة السابقة فعليًا من ثلاث عمليات: // اقرأ قيمة العداد Step 1. Get the value of count // زِد قيمة العداد بمقدار الواحد Step 2. Add 1 to the value. // خزِّن القيمة الجديدة بالعداد Step 3. Store the new value in count لنفترض أنه لدينا مجموعةٌ من الخيوط تنفِّذ جميعها نفس الخطوات الثلاثة السابقة. تذكَّر أنه من الممكن تشغيل خيطين بنفس الوقت حتى في حالة وجود معالجٍ واحدٍ فقط؛ حيث يستطيع ذلك المعالج التبديل بين الخيوط الموجودة بأي لحظة. لنفترض الآن أنه وبينما كان خيطٌ معينٌ بين الخطوتين الثانية والثالثة، بدأ خيطٌ آخر بتنفيذ نفس مجموعة الخطوات. نظرًا لعدم تخزين الخيط الأول القيمة الجديدة داخل المُتغيّر count بعد، سيقرأ الخيط الآخر القيمة "القديمة" للمتغير count، وبالتالي سيزيد تلك القيمة بمقدار الواحد. بناءً على ذلك، يَحسِب الخيطان نفس القيمة الجديدة، وعند تنفيذهما للخطوة الثالثة، يخزِّن كلاهما تلك القيمة بالمتغير count. بعد انتهاء الخيطين من العمل، تكون قيمة المتغيرcount` قد ازدادت بمقدار 1 فقط بدلًا من 2. يُطلَق على هذا النوع من المشكلات اسم "حالة التسابق race condition"، والتي تَحدُث في حالة وجود خيطٍ معينٍ وسط عمليةٍ مكوَّنةٍ من عدة خطوات، ويُغيّر خيطٌ آخر قيمةً أو شرطًا يعتمد عليه الخيط الأول لإتمام العملية التي يُجريها؛ ويُقال أن الخيط الأول يكون في حالة "تسابق" لإكمال جميع الخطوات قبل أن يقاطعه خيط آخر. يُمكِن أن تقع حالة التسابق أيضًا بتعليمة if الشرطية. لنفحص التعليمة التالية التي تحاول تجنُّب وقوع خطأ القسمة على صفر: if ( A != 0 ) { B = C / A; } لنفترض أن تلك التعليمة مُشغَّلةٌ بخيطٍ معين، ولنفترض أن هنالك خيطٌ آخر أو عدة خيوطٍ أخرى تتشارك مع الخيط الأول المورد A. إذا لم نُوفِّر حمايةً ضد حالة التسابق بطريقةٍ ما، فبإمكان أيٍّ من تلك الخيوط تعديل قيمة A إلى الصفر في اللحظة الواقعة بين لحظة فحص الخيط الأول للشرط A != 0، ولحظة إجراءه لعملية القسمة؛ أي قد ينتهي به الحال بإجراء عملية القسمة على صفر على الرغم من أنه قد فحص للتو أن المتغير A لا يساوي الصفر. لحل مشكلة حالات التسابق، لا بُدّ من توفُّر طريقة تُمكِّن الخيط من الوصول وحده دون غيره إلى موردٍ تشاركي. في الواقع، لا يُعدّ ذلك أمرًا سهل التنفيذ، ولكن تُوفِّر جافا أسلوبًا عالي المستوى وسهل الاستخدام نسبيًا لتحقيق ذلك باستخدام التوابع المتزامنة synchronized، وتعليمة synchronized؛ حيث توفِّر تلك الطرائق حمايةً للموارد التي تتشاركها عدة خيوط، وذلك من خلال السماح لخيطٍ واحدٍ فقط بالوصول إلى المورد بكل مرة. يُوفِّر التزامن بلغة جافا ما يُعرف باسم "الإقصاء التشاركي mutual exclusion"، والذي يَعنِي ضمان تحقُّق الوصول الإقصائي لموردٍ معين إذا استخدمت جميع الخيوط التي تحتاج الوصول إلى ذلك المورد المزامنة. إذا طبقنا مبدأ المزامنة على مثال الطباخين، فإنه يَعنِي أن يترك الطباخ الأول ملاحظةً تقول "إنني استخدِم كوب القياس"، وهو ما يمنحه أحقيةً حصريّةً إقصائية في الوصول إلى الكوب، ولكن لن يتحقَّق ذلك إلا إذا اتفق جميع الطباخين على فحص الملاحظة قبل محاولة الإمساك بالكوب. نظرًا لأنه موضوع معقدٌ نوعًا ما، سنبدأ بمثالٍ بسيط. لنفترض أننا نريد تجنُّب حالة التسابق ممكنة الحدوث عند محاولة مجموعةٍ من الخيوط زيادة قيمة عدادٍ بمقدار الواحد. بدايةً، سنُعرِّف صنفًا يُمثِّل العداد، وسنَستخدِم التوابع المتزامنة ضمن ذلك الصنف. يُمكِننا الإعلان عن أن تابعًا معينًا هو من النوع المتزامن بإضافة الكلمة المحجوزة synchronized مثل مُعدِّلٍ بتعريف ذلك التابع على النحو التالي: public class ThreadSafeCounter { private int count = 0; // قيمة العداد synchronized public void increment() { count = count + 1; } synchronized public int getValue() { return count; } } إذا كان tsc من النوع ThreadSafeCounter، يُمكِن لأي خيطٍ استدعاء التابع tsc.increment() لزيادة قيمة العداد بمقدار 1 بطريقةٍ آمنةٍ تمامًا. نظرًا لأن التابع tsc.increment() متزامن، سيكون بإمكان خيطٍ واحدٍ فقط تنفيذه بالمرة الواحدة. يَعنِي ذلك أنه وبمجرد بدء خيطٍ معين بتنفيذ ذلك التابع، فسيُنهي ذلك الخيط تنفيذ التابع بالضرورة قبل أن يُسمَح لخيطٍ آخر بالوصول إلى count. وبالتالي لا يكون هناك أي احتماليةٍ لحدوث حالة تسابق. لاحِظ أن ما سبق مشروطٌ بحقيقة أن count مُعرَّف على أنه متغيرٌ خاص private، وبالتالي لا بُدّ أن تحدث أي محاولةٍ للوصول إليه عبر التوابع المتزامنة المُعرَّفة بالصنف؛ وإذا كان count مُعرَّفًا على أنه متغيرٌ عام، فبإمكان خيطٍ آخر اجتياز المزامنة بكتابة tsc.count++ مثلًا، وستتغيّر في تلك الحالة قيمة المتغير count بينما ما يزال خيطٌ آخر يُنفِّذ عملية tsc.increment()؛ أي لا تضمَن عملية المزامنة بحد ذاتها تحقُّق الوصول الإقصائي للموارد في العموم، وإنما تضمَن تحقُّق "الإقصاء التشاركي" بين جميع الخيوط المتزامنة فقط. ومع ذلك، لا يمنع الصنف ThreadSafeCounter جميع حالات التسابق محتملة الحدوث عند استخدام مجموعة خيوطٍ لعداد. انظر تعليمة if التالية مثلًا: if ( tsc.getValue() == 10 ) { doSomething(); } يتطلَّب التابع doSomething() أن تكون قيمة العداد مساويةً للعدد 10. قد تحدث حالة تسابق إذا زاد خيطٌ آخر قيمة العداد بين لحظتي اختبار الخيط الأول للشرط tsc.getValue() == 10 وتنفيذه للتابع doSomething(). يحتاج الخيط الأول إذًا إلى وصولٍ إقصائي إلى العداد أثناء تنفيذه تعليمة if بالكامل؛ بينما تمنحه المزامنة بالصنف ThreadSafeCounter وصولًا إقصائيًا أثناء تحصيله لقيمة tsc.getValue() فقط. يُمكِننا حل تلك المشكلة بوضع تعليمة if داخل تعليمة synchronized على النحو التالي: synchronized(tsc) { if ( tsc.getValue() == 10 ) doSomething(); } تستقبل تعليمة synchronized كائنًا على أنه معاملٌ -كان tsc في المثال السابق-، وتُكتَب وفقًا لقواعد الصيغة التالية: synchronized( object ) { statements } يرتبط الإقصاء التشاركي بجافا دائمًا بكائنٍ معين، ويقال أن التزامن مبنيٌ على ذلك الكائن، حيث يُعدّ تزامن تعليمة if بالأعلى مثلًا مبنيًا على الكائن tsc؛ بينما يُعدّ تزامن توابع النسخ instance method المتزامنة، مثل تلك المُعرَّفة بالصنف ThreadSafeCounter، مبنيًا على الكائن المُتضمِّن لتابع النسخة. تتكافئ إضافة المُعدِّل synchronized إلى تعريف تابع نسخة مع كتابة متن التابع داخل تعليمة synchronized على الصيغة التالية synchronized(this) {...}. من الممكن أيضًا تعريف توابعٍ ساكنةٍ متزامنة، ويُعدّ تزامنها مبنيًا على الكائن الخاص الذي يُمثِّل الصنف المُتضمِّن للتابع الساكن. لا يُمكِن أن يتزامن خيطان بناءً على نفس الكائن بنفس الوقت، حيث تُعدّ العبارة السابقة القاعدة الحقيقية للمزامنة بلغة جافا؛ وتَعنِي أنه لا يُمكِن لخيطين تنفيذ شيفرتين متزامنتين بناءً على نفس الكائن بنفس الوقت؛ بمعنى أنه إذا تزامن خيطٌ معينٌ بناءً على كائنٍ معين، وحاول خيطٌ آخر أن يتزامن بناءً على نفس الكائن، فسيضطّر الخيط الثاني للانتظار إلى حين انتهاء الخيط الأول. في الواقع، لا يعني ذلك أنه ليس بإمكانهما فقط تنفيذ نفس التابع المتزامن بنفس الوقت، وإنما ليس بإمكانهما أن يُنفِّذا تابعين مختلفين بنفس الوقت إذا كان تزامن هذين التابعين مبنيًا على نفس الكائن. تُنفِّذ جافا ذلك باستخدام "قفل المزامنة synchronization lock، حيث يملك كل كائنٍ قفلًا يُمكِن أن يحصُل عليه خيطٌ واحدٌ فقط خلال أي لحظة. عندما نستخدِم تعليمة synchronized أو نستدعي تابعًا متزامنًا، فلا بُدّ للخيط أن يحصل على قفل الكائن المبني عليه التزامن أولًا؛ فإذا كان القفل متاحًا، سيحصل الخيط عليه فورًا ويبدأ بتنفيذ الشيفرة المتزامنة، ثم يُحرِّره بمجرد انتهاءه من تنفيذها؛ أما إذا حاول الخيط A الحصول على قفلٍ قد حصل عليه خيطٌ آخر B، فلا بُدّ إذًا أن ينتظر الخيط A حتى يُحرِّر الخيط B القفل؛ أي يتوقَّف/ينام الخيط A، ولا يعود للعمل حتى يُصبِح القفل متاحًا. ذكرنا بالقسم اللامتغايرات من مقال كيفية كتابة برامج صحيحة باستخدام لغة جافا أن التفكير بطريقة عمل اللا متباين invariants ستصبح أعقد كثيرًا عند استخدام الخيوط، حيث تَكُمن المشكلة في حالات التسابق race conditions. نريد للصنف ThreadSafeCounter أن يُحقِّق لا متباين الصنف الذي ينص على: "تُمثِّل قيمة count عدد مرات استدعاء increment()". يتحقَّق ذلك بدون مزامنة synchronization بالبرامج أحادية الخيط، ولكن تصبح المزامنة ضرورية بالبرامج متعددة الخيوط للتأكُّد من تحقُّق لا متباين الصنف. سنعود الآن إلى مسألة عدّ الأعداد الأولية بمثابة مثالٍ بسيطٍ على الموارد التشاركية shared resources، وبدلًا من أن تُنفِّذ جميع الخيوط نفس المهمة تمامًا، سننفِّذ معالجةً على التوازي أكثر واقعية. سيَعُدّ البرنامج الأعداد الأولية ضمن نطاقٍ معين من الأعداد الصحيحة، وسيفعل ذلك بتوزيع العمل على عدة خيوط؛ أي سيتعيّن على كل خيط عدّ الأعداد الأولية الموجودة ضمن جزءٍ معين من النطاق الكلي، ثم سيضطّر لإضافة القيمة التي حسبها إلى المجموع الكلي ضمن كامل النطاق. نظرًا لأن جميع الخيوط مضطرةٌ لإضافة عددٍ إلى المجموع الكلي، فلا بُدّ أن تتشارك جميعها مُتغيرًا يُمثِّل ذلك المجموع الكلي، وليكن اسمه هو total. إذا اِستخدَمت جميع الخيوط التعليمة التالية: total = total + count; فهناك احتماليةٌ ولو صغيرة أن يحاول خيطان تنفيذ التعليمة ذاتها بنفس الوقت، وستكون القيمة النهائية للمتغير total غير صحيحة. لذلك لا بُدّ أن يكون الوصول إلى total متزامنًا لمنع حالة التسابق race condition. سنَستخدِم ضمن هذا البرنامج تابعًا متزامنًا يَسمَح بزيادة قيمة total بمقدارٍ معين، بحيث يَستدعِي كل خيطٍ ذلك التابع لمرةٍ واحدة. سيُمثِّل ذلك التابع الطريقة الوحيدة لتعديل قيمة total. ألقِ نظرةً على شيفرة التابع: synchronized private static void addToTotal(int x) { total = total + x; System.out.println(total + " primes found so far."); } يُمكِنك الإطلاع على شيفرة البرنامج من الملف ThreadTest2.java، حيث يَعُدّ هذا البرنامج الأعداد الأولية الواقعة بنطاقٍ يتراوح من 3000001 إلى 6000000 (مجرد أعداد عشوائية). يُنشِئ البرنامج main() عدة خيوطٍ يتراوح عددها من "1" إلى "5"، ويُسنِد جزءًا من المهمة لكل خيط، ثم ينتظر إلى أن تنتهي جميع الخيوط من عملها باستخدام التابع join() المذكور بالأعلى، وأخيرًا يُبلّغ عن العدد الكلي للأعداد الأولية مصحوبًا بالزمن الذي استغرقه البرنامج لحساب ذلك العدد. لاحِظ أن اِستخدَام التابع join() ضروري؛ فلا معنى لطباعة عدد الأعداد الأولية قبل أن تنتهي جميع الخيوط. إذا شغَّلت البرنامج على حاسوبٍ متعدّد المعالجات، فسيستغرق البرنامج وقتًا أقل عند استخدام أكثر من مجرد خيطٍ واحد. تساعد المزامنة على منع حالات التسابق، ولكنها قد تتسبَّب بنوعٍ آخر من الأخطاء يُدعى "قفل ميت deadlock". يحدث هذا النوع من الأخطاء عندما ينتظر خيطٌ موردًا معينًا إلى الأبد دون أن يحصل عليه. إذا عدنا إلى مثال "المطبخ"، فلربما يحدث القفل الميت إذا أراد طبّاخان ساذجان قياس كوب حليب بنفس الوقت، حيث سيُمسِك الطباخ الأول بكوب القياس، بينما سيُمسِك الطباخ الآخر بالحليب. يحتاج الطباخ الأول إلى الحليب، ولكنه لا يستطيع العثور عليه لأنه بحوزة الطباخ الثاني. ومن الجهة الأخرى، يحتاج الطباخ الثاني إلى كوب القياس، ولكنه لا يستطيع العثور عليه لأنه بحوزة الطباخ الأول. وبذلك، لا يتمكَّن الطباخان من إكمال عملهما. يُعدّ ذلك بمثابة قفل ميت، وقد يحدث نفس الشيء ببرنامجٍ معين إذا كان هناك خيطين (الطباخين) مثلًا يريد كلاهما الحصول على قفلين على كائنين معينين (الحليب وكوب القياس) قبل أن يكملا عملهما. تقع الأقفال الميتة بسهولة إذا لم ننتبه بما يكفي لمنعها. المتغيرات المتطايرة تُعدّ المزامنة واحدةً ضمن عدة تقنيات تتحكَّم بالتواصل بين الخيوط، وسنتناول تقنياتٍ أخرى لاحقًا، أما الآن فسنُنهِي هذا القسم بمناقشة التقنيتين التاليتين: المتغيرات المتطايرة volatile variables والمتغيرات الذرية atomic variables. تتواصل الخيوط مع بعضها عمومًا من خلال تشارك عدة متغيرات والوصول إليها من خلال استخدام توابعٍ متزامنة أو تعليمة synchronized. ومع ذلك، تُعدّ عملية المزامنة مكلفةً حاسوبيًا، ولهذا ينبغي تجنُّب اِستخدَامها بكثرة، وقد يكون من المنطقي في بعض الأحيان للخيوط الإشارة إلى المتغيرات التشاركية دون مزامنة وصولها إلى تلك المتغيرات. من الجهة الأخرى، قد تنشأ مشكلةٌ صغيرة إذا كانت قيمة متغير تشاركي تُضبَط بخيطٍ وتُستخدَم بآخر. نظرًا للطريقة التي تُنفِّذ جافا الخيوط على أساسها، فلربما لا يرى الخيط الثاني القيمة الجديدة للمتغير على الفور؛ ويَعنِي ذلك أنه من الممكن لخيطٍ معين الاستمرار بقراءة القيمة "القديمة" لمتغيرٍ تشاركي لمدةٍ معينة بالرغم من أن قيمة ذلك المتغير قد عُدِّلت بخيطٍ آخر. يَحدث ذلك لأن جافا تسمح للخيوط بتخزين البيانات التشاركية مؤقتًا cache؛ أي يُمكِن لكل خيطٍ الاحتفاظ بنسخته المحلية من البيانات التشاركية، وبالتالي عندما يُعدِّل خيطٌ معينٌ قيمة متغيرٍ تشاركي، لا تُعدَّل النسخ المحلية بمخزِّنات الخيوط الأخرى المؤقتة على الفور، وقد تستمر تلك الخيوط بقراءة القيمة القديمة لفترةٍ قصيرةٍ على الأقل. في المقابل، يُعدّ استخدام مُتغيّر تشاركي داخل تابعٍ متزامن أو داخل تعليمة synchronized آمنًا شرط أن تكون جميع محاولات الوصول إلى ذلك المُتغيّر متزامنةً بناءً على نفس الكائن بجميع الحالات. بعبارة أخرى، إذا حاول خيطٌ معين الوصول إلى قيمة متغيرٍ داخل شيفرةٍ متزامنة، فإنه يَضمَن أن يرى أي تغييرات تجريها الخيوط الأخرى على ذلك المتغير شرط أن تكون تلك التعديلات قد أُجريت داخل شيفرةٍ متزامنةٍ بناءً على نفس الكائن. يُمكِننا أيضًا استخدام متغيرٍ تشاركي اِستخدَامًا آمنًا خارج شيفرةٍ متزامنة، ولكن لا بُدّ في تلك الحالة أن نُصرِّح عن كون المتغير متطايرًا volatile باستخدام كلمة volatile، حيث تُعدّ الكلمة المحجوزة volatile واحدةً من المُعدِّلات modifiers المُمكِن إضافتها إلى تعليمات التصريح عن المتغيرات العامة global variable على النحو التالي: private volatile int count; إذا صرَّحنا عن متغيرٍ باستخدام كلمة volatile، فلن يتمكَّن أي خيطٍ من الاحتفاظ بنسخةٍ محليةٍ من ذلك المتغير ضمن مُخزِّنه المؤقت، وستضطر الخيوط بدلًا من ذلك من استخدام النسخة الرئيسية الأصلية للمتغير دائمًا، وستكون بالتالي التعديلات المُجراة على ذلك المتغير مرئيةً لجميع الخيوط فورًا. بناءً على ذلك، يصبح من الآمن للخيوط الإشارة إلى المتغيرات التشاركية المُصرَّح عنها بكلمة volatile حتى خارج الشيفرات المتزامنة. ومع ذلك، يُعدّ الوصول إلى المتغيرات المتطايرة أقل كفاءةً من الوصول إلى المتغيرات العادية غير المتطايرة، ولكنه على الأقل أكثر كفاءةً من استخدام المزامنة. تذكَّر مع ذلك أن استخدام المتغيرات المتطايرة لا يحمي ضد حالات التسابق التي قد تحدث عند زيادة قيمة المتغير مثلًا، فربما يُقاطِع خيطٌ آخر عملية الزيادة. عند تطبيق المُعدِّل volatile على متغيرٍ من نوع كائني، فإن ما يُصرَّح عنه ليكون متطايرًا هو المتغير ذاته فقط لا محتويات الكائن الذي يشير إليه المتغير، ولهذا يُستخدَم غالبًا المُعدِّل volatile مع المتغيرات من الأنواع البسيطة، مثل الأنواع الأساسية primitive types، أو الأنواع الثابتة غير المتغيرة immutable types مثل String. لنفحص الآن مثالًا على استخدام متغيرٍ متطايرٍ لإرسال إشارةٍ من خيط لآخر ليخبره بأن عليه الانتهاء terminate. يتشارك الخيطان المتغير التالي: volatile boolean terminate = false; يفحص التابع run() الخاص بالخيط الثاني قيمة terminate باستمرار، وينتهي عندما تصبح قيمته مساويةً القيمة true. ألقِ نظرةً على الشيفرة التالية: public void run() { while ( terminate == false ) { . . // Do some work. . } } سيستمر هذا الخيط بالعمل إلى أن يضبط خيطٌ آخر قيمة terminate إلى true. تُعدّ الشيفرة السابقة الطريقة الوحيدة للسماح لخيطٍ بغلْق خيطٍ آخر بطريقةٍ نظيفة. قد تتساءل عن سبب استعانة الخيوط ببياناتٍ محليةٍ مؤقتة من الأساس، خاصةً وأنها تُعقّد الأمور بصورةٍ غير ضرورية. في الواقع، يُسمَح للخيوط بالتخزين المؤقت نتيجةً لبنية الحواسيب متعددة المعالجات، حيث يملك كل معالجٍ ذاكرةً محليةً متصلةً به مباشرةً، وتُخزَّن الذاكرة المحلية المؤقتة للخيط بالذاكرة المحلية للمعالج المُشغَّل عليه الخيط. يُعدّ الوصول إلى تلك الذاكرة المحلية أسرع بكثير من الوصول إلى الذاكرة الرئيسية التي تتشاركها جميع المعالجات، ولذلك يُعدّ اِستخدَام الخيط لنسخةٍ محليةٍ من المتغيرات التشاركية أكثر كفاءةً من استخدام نسخةٍ رئيسيةٍ مخزَّنةٍ بالذاكرة الرئيسية. المتغيرات الذرية تكْمُن مشكلة البرمجة على التوازي بتعليمةٍ مثل count = count + 1 في أنها تستغرق عدة خطوات لتنفيذ التعليمة، وتكون التعليمة قد نُفذَّت على النحو الصحيح فقط إذا اكتملت الخطوات دون حدوث أي تقاطعٍ مع خيوطٍ أخرى. تُعدّ العمليات الذرية atomic operation بمثابة شيءٍ لا يمكن مقاطعته؛ فإما يحدث كله أو لا شيء، بمعنى أنه لا يُمكِن أن يكتمل جزئيًا. تحتوي معظم الحواسيب على عملياتٍ ذرية بمستوى لغة الآلة machine language level. قد تتوفَّر على سبيل المثال تعليمةً بلغة الآلة مسؤولةً عن زيادة قيمة موضعٍ معينٍ بالذاكرة بخطوةٍ واحدة. لا تعاني مثل تلك التعليمات من خطر حالات التسابق. بالنسبة للبرامج، يمكن لعمليةٍ أن تكون ذرية حتى لو لم تكن كذلك حرفيًا بمستوى لغة الآلة؛ حيث تُعدّ العملية ذريةً إذا لم يكن بإمكان أي خيطٍ أن يراها مكتملةً جزئيًا. على سبيل المثال، يحتوي الصنف ThreadSafeCounter المُعرَّف بالأعلى على عملية زيادةٍ ذرية. يُمكِننا أن نفكر بالمزامنة مثل طريقةٍ للتأكيد على كون العمليات ذرية. ومع ذلك، سيكون من الأفضل لو استطعنا الحصول على عملياتٍ ذرية دون استخدام المزامنة، خاصةً وأنه من الممكن تنفيذ تلك العمليات بكفاءة عالية على مستوى العتاد. تُوفِّر جافا حزمة java.util.concurrent.atomic، والتي تحتوي على أصنافٍ تُنفِّذ عملياتٍ ذرية على عدة أنواع متغيراتٍ بسيطة. سنفحص الصنف AtomicInteger الذي يُعرَّف بعض العمليات الذرية على قيمةٍ من النوع العددي الصحيح، بما في ذلك الجمع والزيادة والنقصان. لنفترض على سبيل المثال أننا نريد إضافة قيمٍ عدديةٍ صحيحة تنتجها مجموعةٌ من الخيوط. يُمكِننا فعل ذلك باستخدام الصنف AtomicInteger على النحو التالي: private static AtomicInteger total = new AtomicInteger(); يُنشَأ total بقيمةٍ مبدئية تُساوي الصفر. عندما يحاول خيطٌ معينٌ إضافة قيمةٍ ما إلى total، فيُمكِنه استخدام التابع total.addAndGet(x)، الذي يضيف x إلى total، ويعيد قيمة total الجديدة بعد الإضافة. يُعدّ ذلك مثالًا على عمليةٍ ذرية لا يمكن مقاطعتها، أي ستكون قيمة total صحيحةً بالضرورة. يُعَد البرنامج ThreadTest3.java نسخةً أخرى من البرنامج ThreadTest2، ولكنه يَستخدِم الصنف AtomicInteger بدلًا من المزامنة لحساب حاصل مجموع بعض القيم التي تنتجها خيوطٌ متعددة. يحتوي الصنف AtomicInteger على توابعٍ أخرى، مثل total.incrementAndGet() لإضافة 1 إلى total، و total.decrementAndGet() لطرح 1 منه. يضبُط التابع total.getAndSet(x) قيمة total إلى x، ويعيد القيمة السابقة التي حلّت x محلها. تحدث جميع تلك العمليات على الفور؛ إما لأنها تَستخدِم تعليمات لغة آلة ذرية؛ أو لكونها تَستخدِم المزامنة داخليًا. تحذير: لا يُعدّ اِستخدَام المتغيرات الذرية حلًا تلقائيًا لجميع حالات التسابق التي قد تشملها تلك المتغيرات. ألقِ نظرةً على الشيفرة التالية على سبيل المثال: int currentTotal = total.addAndGet(x); System.out.println("Current total is " + currentTotal); ربما تكون قيمة total قد تغيرت بخيطٍ آخر بحلول وقت تنفيذ تعليمة الطباعة، وبذلك لا تكون currentTotal هي القيمة الحالية للمتغير total. ترجمة -بتصرّف- للقسم Section 1: Introduction to Threads من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مقدمة مختصرة للغة XML واستعمالها في تطبيقات جافا معالجة الملفات في جافا تواصل تطبيقات جافا عبر الشبكة
-
سنُقدِّم في هذه المقالة حل تمرين مقالة استخدام قاعدة بيانات Redis لحفظ البيانات. بعد ذلك، سنُحلِّل أداء خوارزمية فهرسة صفحات الإنترنت، ثم سنبني زاحفَ ويب Web crawler بسيطًا. المفهرس المبني على قاعدة بيانات Redis سنُخزِّن هيكلي البيانات التاليين في قاعدة بيانات Redis: سيُقابِل كلَّ كلمة بحثٍ كائنٌ من النوع URLSet هو عبارةٌ عن مجموعة Set في قاعدة بيانات Redis تحتوي على مُحدِّدات الموارد الموحدة URLs التي تحتوي على تلك الكلمة. سيُقابِل كل مُحدّد موارد موحد كائنًا من النوع TermCounter يُمثّل جدول Hash في قاعدة بيانات Redis يربُط كل كلمة بحث بعدد مرات ظهورها. يُمكِنك مراجعة أنواع البيانات التي ناقشناها في المقالة المشار إليها، كما يُمكِنك قراءة المزيد عن المجموعات والجداول بقاعدة بيانات Redis من توثيقها الرسمي. يُعرِّف الصنف JedisIndex تابعًا يَستقبِل كلمة بحثٍ ويعيد مفتاح كائن الصنف URLSet المقابل في قاعدة بيانات Redis: private String urlSetKey(String term) { return "URLSet:" + term; } كما يُعرِّف الصنفُ المذكور التابع termCounterKey، والذي يستقبل مُحدّد موارد موحدًا ويعيد مفتاح كائن الصنف TermCounter المقابل في قاعدة بيانات Redis: private String termCounterKey(String url) { return "TermCounter:" + url; } يَستقبِل تابع الفهرسة indexPage محددَ موارد موحدًا وكائنًا من النوع Elements يحتوي على شجرة DOM للفقرات التي نريد فهرستها: public void indexPage(String url, Elements paragraphs) { System.out.println("Indexing " + url); // أنشئ كائنًا من النوع TermCounter وأحصِ كلمات الفقرات النصية TermCounter tc = new TermCounter(url); tc.processElements(paragraphs); // أضف محتويات الكائن إلى قاعدة بيانات Redis pushTermCounterToRedis(tc); } سنقوم بالتالي لكي نُفهرِّس الصفحة: نُنشِئ كائنًا من النوع TermCounter يُمثِل محتويات الصفحة باستخدام شيفرة تمرين المقال المشار إليه بالأعلى. نُضيف محتويات ذلك الكائن في قاعدة بيانات Redis. تُوضِّح الشيفرة التالية طريقة إضافة كائنات النوع TermCounter إلى قاعدة بيانات Redis: public List<Object> pushTermCounterToRedis(TermCounter tc) { Transaction t = jedis.multi(); String url = tc.getLabel(); String hashname = termCounterKey(url); // إذا كانت الصفحة مفهرسة مسبقًا، احذف الجدول القديم t.del(hashname); // أضف مدخلًا جديدًا في كائن الصنف TermCounter وعنصرًا جديدًا إلى الفهرس // لكل كلمة بحث for (String term: tc.keySet()) { Integer count = tc.get(term); t.hset(hashname, term, count.toString()); t.sadd(urlSetKey(term), url); } List<Object> res = t.exec(); return res; } يَستخدم هذا التابع معاملةً من النوع Transaction لتجميع العمليات، ثم يرسلها جميعًا إلى الخادم على خطوة واحدة. تُعدّ تلك الطريقة أسرع بكثير من إرسال متتاليةٍ من العمليات الصغيرة. يمرّ التابع عبر العناصر الموجودة في كائن الصنف TermCounter، ويُنفِّذ التالي من أجل كل عنصرٍ منها: يبحث عن كائنٍ من النوع TermCounter -أو ينشئه إن لم يجده- في قاعدة بيانات Redis، ثم يضيف حقلًا فيه يُمثِل العنصر الجديد. يبحث عن كائنٍ من النوع URLSet -أو ينشئه إن لم يجده- في قاعدة بيانات Redis، ثم يضيف إليه محدّد الموارد الموحد الحالي. إذا كنا قد فهرَسنا تلك الصفحة من قبل، علينا أن نحذف كائن الصنف TermCounter القديم الذي يمثلها قبل أن نضيف المحتويات الجديدة. هذا هو كل ما نحتاج إليه لفهرسة الصفحات الجديدة. طلبَ الجزءُ الثاني من التمرين كتابةَ التابع getCounts الذي يَستقبِل كلمة بحثٍ ويعيد خريطةً تربط محددات الموارد الموحدة التي ظهرت فيها تلك الكلمة بعدد مرات ظهورها فيها. انظر إلى تنفيذ التابع: public Map<String, Integer> getCounts(String term) { Map<String, Integer> map = new HashMap<String, Integer>(); Set<String> urls = getURLs(term); for (String url: urls) { Integer count = getCount(url, term); map.put(url, count); } return map; } يَستخدِم هذا التابعُ تابعين مساعدين: getURLs: يَستقبِل كلمة بحثٍ ويعيد مجموعةً من النوع Set تحتوي على محددات الموارد الموحدة التي ظهرت فيها الكلمة. getCount: يَستقبِل محدد موارد موحدًا URI وكلمة بحث، ويعيد عدد مرات ظهور الكلمة بمحدد الموارد المُمرَّر. انظر تنفيذات تلك التوابع: public Set<String> getURLs(String term) { Set<String> set = jedis.smembers(urlSetKey(term)); return set; } public Integer getCount(String url, String term) { String redisKey = termCounterKey(url); String count = jedis.hget(redisKey, term); return new Integer(count); } تَعمَل تلك التوابع بكفاءةٍ نتيجةً للطريقة التي صمّمّنا بها المُفهرِس. تحليل أداء عملية البحث لنفترض أننا فهرسنا عددًا مقداره N من الصفحات، وتوصلنا إلى عددٍ مقداره M من كلمات البحث. كم الوقت الذي سيستغرقه البحث عن كلمةٍ معينةٍ؟ فكر قبل أن تكمل القراءة. سنُنفِّذ التابع getCounts للبحث عن كلمةٍ، يُنفِّذ ذلك التابع ما يلي: يُنشِئ خريطةً من النوع HashMap. يُنفِّذ التابع getURLs ليسترجع مجموعة مُحدِّدات الموارد الموحدة. يَستدعِي التابع getCount لكل مُحدِّد موارد، ويضيف مُدْخَلًا إلى الخريطة. يستغرق التابع getURLs زمنًا يتناسب مع عدد محددات الموارد الموحدة التي تحتوي على كلمة البحث. قد يكون عددًا صغيرًا بالنسبة للكلمات النادرة، ولكنه قد يكون كبيرًا -قد يَصِل إلى N- في حالة الكلمات الشائعة. سنُنفِّذ داخل الحلقة التابعَ getCount الذي يبحث عن كائنٍ من النوع TermCounter في قاعدة بيانات Redis، ثم يبحث عن كلمةٍ، ويضيف مُدخْلًا إلى خريطةٍ من النوع HashMap. تستغرق جميع تلك العمليات زمنًا ثابتًا، وبالتالي، ينتمي التابع getCounts في المجمل إلى المجموعة O(N) في أسوأ الحالات، ولكن عمليًا، يتناسب زمن تنفيذه مع عدد الصفحات التي تحتوي على تلك الكلمة، وهو عادةً عددٌ أصغر بكثيرٍ من N. وأما فيما يتعلق بتحليل الخوارزميات، فإن تلك الخوارزمية تتميز بأقصى قدرٍ من الكفاءة، ولكنها مع ذلك بطيئةٌ لأنها ترسل الكثير من العمليات الصغيرة إلى قاعدة بيانات Redis. من الممكن تحسين أدائها باستخدام معاملةٍ من النوع Transaction. يُمكِنك محاولة تنفيذ ذلك أو الاطلاع على الحل في الملف RedisIndex.java (انتبه إلى أن اسمه في المستودع JedisIndex.java والله أعلم). تحليل أداء عملية الفهرسة ما الزمن الذي تستغرقه فهرسة صفحةٍ عند استخدام هياكل البيانات التي صمّمْناها؟ فكر قبل أن تكمل القراءة. لكي نُفهرِّس صفحةً، فإننا نمرّ عبر شجرة DOM، ونعثر على الكائنات التي تنتمي إلى النوع TextNode، ونُقسِّم السلاسل النصية إلى كلمات بحثٍ. تَستغرِق كل تلك العمليات زمنًا يتناسب مع عدد الكلمات الموجودة في الصفحة. نزيد العدّاد ضمن خريطة النوع HashMap لكل كلمة بحثٍ ضمن الصفحة، وهو ما يَستغرِق زمنًا ثابتًا، ما يَجعَل الصنف TermCounter يَستغرِق زمنًا يتناسب مع عدد الكلمات الموجودة في الصفحة. تتطلَّب إضافة كائن الصنف TermCounter إلى قاعدة بيانات Redis حذف كائنٍ آخرَ منها. يستغرِق ذلك زمنًا يتناسب خطّيًّا مع عدد كلمات البحث. بعد ذلك، علينا أن نُنفِّذ التالي من أجل كل كلمة: نضيف عنصرًا إلى كائنٍ من النوع URLSet. نضيف عنصرًا إلى كائنٍ من النوع TermCounter. تَستغرِق العمليتان السابقتان زمنًا ثابتًا، وبالتالي، يكون الزمن الكليُّ المطلوبُ لإضافة كائنٍ من النوع TermCounter خطيًا مع عدد كلمات البحث الفريدة. نستخلص مما سبق أنّ زمن تنفيذ الصنف TermCounter يتناسب مع عدد الكلمات الموجودة في الصفحة، وأن إضافة كائنٍ ينتمي إلى النوع TermCounter إلى قاعدة بيانات Redis تتطلَّب زمنًا يتناسب مع عدد الكلمات الفريدة. ولمّا كان عدد الكلمات الموجودة في الصفحة يتجاوز عادةً عدد كلمات البحث الفريدة، فإن التعقيد يتناسب طردًا مع عدد الكلمات الموجودة في الصفحة، ومع ذلك، قد تحتوي صفحةٌ ما نظرًيًا على جميع كلمات البحث الموجودة في الفهرس، وعليه، ينتمي الأداء في أسوأ الحالات إلى المجموعة O(M)، ولكننا لا نتوقَّع حدوث تلك الحالة عمليًّا. يتضِّح من التحليل السابق أنه ما يزال من الممكن تحسين أداء الخوارزمية، فمثلًا يُمكِننا أن نتجنَّب فهرسة الكلمات الشائعة جدًا، لأنها أولًا تحتل مساحةً كبيرةً من الذاكرة كما أنها تستغرِق وقتًا طويلًا؛ فهي تَظهرَ تقريبًا في جميعِ كائناتِ النوعين URLSet و TermCounter، كما أنها لا تحمل أهميةً كبيرةً فهي لا تساعد على تحديد الصفحات التي يُحتمَل أن تكون ذاتَ صلةٍ بكلمات البحث. تتجنَّب غالبية محركات البحث فهرسةَ الكلماتِ الشائعةِ التي يطلق عليها اسم الكلمات المهملة stop words ضمن هذا السياق. اجتياز بيان graph إذا أكملت تمرين مقالة "تنفيذ أسلوب البحث بالعمق أولًا باستخدام الواجهتين Iterables و Iterators"، فلديك بالفعل برنامجٌ يقرأ صفحةً من موقع ويكيبيديا، ويبحث عن أول رابطٍ فيها، ويَستخدِمه لتحميل الصفحة التالية، ثم يكرر العملية. يُعدّ هذا البرنامج بمنزلةِ زاحفٍ من نوع خاص، فعادةً عندما يُذكرُ مصطلح "زاحف إنترنت" Web crawler، فالمقصود برنامج يُنفِّذ ما يلي: يُحمِّل صفحة بداية معينة، ويُفهرِس محتوياتها. يبحث عن جميع الروابط الموجودة في تلك الصفحة ويضيفها إلى تجميعة collection. يمرّ عبر تلك الروابط، ويُحمِّل كلًّا منها، ويُفهرِسه، ويضيف أثناء ذلك روابطَ جديدة. إذا عَثَر على مُحدّدِ مواردَ مُوحدٍ فهرَسَه من قبل، فإنه يتخطاه. يُمكِننا أن نتصوّر شبكة الإنترنت كما لو كانت شعبة graph. تُمثِل كل صفحة إنترنت عقدةً node في تلك الشعبة، ويُمثِل كل رابط ضلعًا مُوجّهًا directed edge من عقدةٍ إلى عقدةٍ أخرى. يُمكِنك قراءة المزيد عن الشعب إذا لم تكن على معرفةٍ بها. بناءً على هذا التصور، يستطيع الزاحف أن يبدأ من عقدةٍ معيّنةٍ، ويجتاز الشعبة، وذلك بزيارة العقد التي يُمكِنه الوصول إليها مرةً واحدةً فقط. تُحدِّد التجميعة التي سنَستخدِمها لتخزين محددات الموارد المحددة نوع الاجتياز الذي يُنفِّذه الزاحف: إذا كانت التجميعة رتلًا queue، أي تتبع أسلوب "الداخل أولًا، يخرج أولًا" FIFO، فإن الزاحف يُنفِّذ اجتياز السّعة أولًا breadth-first. إذا كانت التجميعة مكدسًا stack، أي تتبع أسلوب "الداخل آخرًا، يخرج أولًا" LIFO، فإن الزاحف يُنفِّذ اجتياز العمق أولًا depth-first. من الممكن تحديد أولويّاتٍ لعناصر التجميعة. على سبيل المثال، قد نرغب في إعطاء أولوية أعلى للصفحات التي لم تُفهرَس منذ فترة طويلة. تمرين 12 والآن، عليك كتابة الزاحف، ستجد ملفات الشيفرة التالية الخاصة بالتمرين في مستودع الكتاب: WikiCrawler.java: يحتوي على شيفرة مبدئيّة للزاحف. WikiCrawlerTest.java: يحتوي على اختبارات وحدة للصنف WikiCrawler. JedisIndex.java: يحتوي على حلِّ تمرينِ مقالة استخدام قاعدة بيانات Redis لحفظ البيانات. ستحتاج أيضًا إلى الأصناف المساعدة التالية التي استخدَمناها في تمارين المقالات السابقة: JedisMaker.java WikiFetcher.java TermCounter.java WikiNodeIterable.java سيتعيّن عليك توفير ملفٍّ يحتوي على بيانات خادم Redis قبل تنفيذ الصنف JedisMaker. إذا أكملت تمرين المقالة مقالة استخدام قاعدة بيانات Redis لحفظ البيانات، فقد جهّزت كل شيء بالفعل، أما إذا لم تكمله، فستجد التعليمات الضرورية لإتمام ذلك في نفس المقالة. نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant JedisMaker لكي تتأكّد من أنه مهياٌ للاتصال مع خادم Redis الخاص بك. والآن، نفِّذ الأمر ant WikiCrawlerTest. ستجد أن الاختبارات قد فشلت؛ لأن ما يزال عليك إتمام بعض العمل أولًا. انظر إلى بداية تعريف الصنف WikiCrawler: public class WikiCrawler { public final String source; private JedisIndex index; private Queue<String> queue = new LinkedList<String>(); final static WikiFetcher wf = new WikiFetcher(); public WikiCrawler(String source, JedisIndex index) { this.source = source; this.index = index; queue.offer(source); } public int queueSize() { return queue.size(); } يحتوي هذا الصنف على متغيرات النسخ instance variables التالية: source: مُحدّد الموارد الموحد الذي ستبدأ منه. index: مفهرِس -من النوع JedisIndex- ينبغي أن تُخزَّن النتائج فيه. queue: عبارة عن قائمة من النوع LinkedList. يُفترَض أن تُخزَّن فيها كلُّ محددات الموارد التي عثرت عليها، ولكن لم تُفهرِسها بعد. wf: عبارة عن كائن من النوع WikiFetcher. عليك أن تَستخدِمه لقراءة صفحات الإنترنت وتحليلها. عليك الآن أن تكمل التابع crawl. انظر إلى بصمته: public String crawl(boolean testing) throws IOException {} ينبغي أن تكون قيمة المعامل testing مساويةً للقيمة true إذا كان مستدعيه هو الصنف WikiCrawlerTest، وأن تكون مساويةً للقيمة false في الحالات الأخرى. عندما تكون قيمة المعامل testing مساويةً للقيمة true، يُفترَض أن يُنفِّذ التابع crawl ما يلي: يختار مُحدّدَ موارد موحدًا من الرتل وفقًا لأسلوب "الداخل أولًا، يخرج أولًا" ثم يحذفه منه. يقرأ محتويات تلك الصفحة باستخدام التابع WikiFetcher.readWikipedia الذي يعتمد في قراءته للصفحات على نسخٍ مُخزّنةٍ مؤقتًا في المستودع بهدف الاختبار (لكي نتجنَّب أيَّ مشكلات ممكنة في حال تغيّرت النسخُ الموجودةُ في موقع ويكيبيديا). يُفهرِس الصفحة بغض النظر عما إذا كانت قد فُهرِسَت من قبل. ينبغي أن يجد كل الروابط الداخلية الموجودة في الصفحة ويضيفها إلى الرتل بنفس ترتيب ظهورها. يُقصَد بالروابط الداخلية تلك الروابط التي تشير إلى صفحات أخرى ضمن موقع ويكيبيديا. ينبغي أن يعيد مُحدّد الموارد الخاص بالصفحة التي فهرسها. في المقابل، عندما تكون قيمة المعامل testing مساويةً للقيمة false، يُفترَض له أن يُنفِّذ ما يلي: يختار مُحدّد موارد موحدًا من الرتل وفقًا لأسلوب "الداخل أولًا، يخرج أولًا" ثم يحذفه منه. إذا كان محدّد الموارد المختار مفهرَسًا بالفعل، لا ينبغي أن يعيد فهرسته، وعليه أن يعيد القيمة null. إذا لم يُفهرَس من قبل، فينبغي أن يقرأ محتويات الصفحة باستخدام التابع. WikiFetcher.fetchWikipedia الذي يعتمد في قراءته للصفحات على شبكة الإنترنت. ينبغي أن يُفهرِس الصفحة، ويضيف أيَّ روابطَ موجودةٍ فيها إلى الرتل، ويعيد مُحدّد الموارد الخاصَّ بالصفحة التي فهرسها. يُحمِّل الصنف WikiCrawlerTest رتلًا يحتوي على 200 رابط، ثم يَستدعِي التابع crawl ثلاث مرّات، ويفحص في كلِّ استدعاء القيمةَ المعادة والطولَ الجديد للرتل. إذا أكملت زاحف الإنترنت الخاص بك بشكل صحيح، فينبغي أن تنجح الاختبارات. ترجمة -بتصرّف- للفصل Chapter 15: Crawling Wikipedia من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: البحث الثنائي Boolean search ودمج نتائج البحث وترتيبها المقال السابق: استخدام قاعدة بيانات Redis لحفظ البيانات تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا شرح الفروقات بين قواعد بيانات SQL ونظيراتها NoSQL كيفية استيراد وتصدير قواعد بيانات MySQL أو MariaDB
-
سنُكمِل في التمارين القليلة القادمة بناء محرك البحث الذي تحدثنا عنه في مقالة تنفيذ أسلوب البحث بالعمق أولًا تعاوديًّا وتكراريًّا. يتكوَّن أيّ محرك بحثٍ من الوظائف التالية: الزحف crawling: ويُنفّذُ من خلال برنامجٍ بإمكانه تحميلُ صفحة إنترنت وتحليلُها واستخراجُ النص وأيِّ روابط إلى صفحاتٍ أخرى. الفهرسة indexing: وتنفّذُ من خلال هيكل بيانات data structure بإمكانه البحث عن كلمة والعثور على الصفحات التي تحتوي على تلك الكلمة. الاسترجاع retrieval: وهي طريقةٌ لتجميع نتائج المُفهرِس واختيار الصفحات الأكثر صلة بكلمات البحث. إذا كنت قد أتممت تمرين مقالة استخدام خريطة ومجموعة لبناء مُفهرِّس Indexer، فقد نفَّذت مُفهرسًا بالفعل باستخدام خرائط جافا. سنناقش هذا التمرين هنا وسنُنشِئ نسخةً جديدةً تُخزِّن النتائج في قاعدة بيانات. وإذا كنت قد أكملت تمرين مقالة تنفيذ أسلوب البحث بالعمق أولًا باستخدام الواجهتين Iterables و Iterators، فقد نفَّذت بالفعل زاحفًا يَتبِع أول رابطٍ يعثرُ عليه. سنُنشِئ في التمرين التالي نسخةً أعمّ تُخزِّن كل رابطٍ تجده في رتل queue، وتتبع تلك الروابط بالترتيب. في النهاية، ستُكلّف بالعمل على برنامج الاسترجاع. سنُوفِّر شيفرةً مبدئيّةً أقصر في هذه التمارين، وسنعطيك فرصةً أكبر لاتخاذ القرارات المتعلقة بالتصميم. وتجدر الإشارة إلى أن هذه التمارين ذات نهايات مفتوحة، أي سنطرح عليك فقط بعض الأهداف البسيطة التي يتعين عليك الوصول إليها، ولكنك تستطيع بالطبع المُضِي قدمًا إذا أردت المزيد من التحدي. والآن، سنبدأ بالنسخة الجديدة من المُفهرِس. قاعدة بيانات Redis تُخزِّن النسخة السابقة من المُفهرِس البيانات في هيكلَيْ بياناتٍ: الأول هو كائنٌ من النوع TermCounter يَربُط كل كلمة بحثٍ بعدد المرات التي ظهرت فيها الكلمة في صفحة إنترنت معينةٍ، والثاني كائنٌ من النوع Index يربُط كلمة البحث بمجموعة الصفحات التي ظهرت فيها. يُخزَّن هيكلا البيانات في ذاكرة التطبيق، ولذا يتلاشيان بمجرد انتهاء البرنامج. توصف البيانات التي تُخزَّن فقط في ذاكرة التطبيق بأنها "متطايرة volatile"؛ لأنها تزول بمجرد انتهاء البرنامج. في المقابل، تُوصف البيانات التي تظل موجودةً بعد انتهاء البرنامج الذي أنشأها بأنها "مستمرة persistent". مثال ذلك الملفات المُخزَّنة في نظام الملفات فهي مُستمرة في العموم، وكذلك البيانات المُخزَّنة في قاعدة بيانات أيضًا مستمرة. يُعدّ تخزين البيانات في ملف واحدة من أبسط طرق حفظ البيانات، فيُمكِننا ببساطة أن نحوِّل هياكل البيانات المُتضمِّنة للبيانات إلى صيغة JSON ثم نكتبها إلى ملف قبل انتهاء البرنامج. عندما نُشغِّل البرنامج مرة أخرى، سنتمكَّن من قراءة الملف وإعادة بناء هياكل البيانات. ولكن هناك مشكلتان في هذا الحل: عادةً ما تكون عمليتا قراءة هياكل البيانات الضخمة (مثل مفهرس الويب) وكتابتها عمليتين بطيئتين. قد لا تستوعب مساحة ذاكرة برنامج واحد هيكل البيانات بأكمله. إذا انتهى برنامجٌ معينٌ على نحوٍ غير متوقع (نتيجة لانقطاع الكهرباء مثلًا)، فسنفقد جميع التغييرات التي أجريناها على البيانات منذ آخر مرة فتحنا فيها البرنامج. وهناك طريقة أخرى لحفظ البيانات وهي قواعد البيانات. إذ تُعدّ قواعدُ البيانات البديلَ الأفضلَ، فهي تُوفِّر مساحةَ تخزينٍ مستمرةً، كما أنها قادرةٌ على قراءة جزءٍ من قاعدة البيانات أو كتابته دون الحاجة إلى قراءة قاعدة البيانات أو كتابتها بالكامل. تتوفَّر الكثير من أنواع نظم إدارة قواعد البيانات DBMS، ويتمتع كلٌّ منها بإمكانيات مختلفة. ويُمكِنك قراءة مقارنة بين أنظمة إدارة قواعد البيانات العلاقية والاطلاع على سلسلة تصميم قواعد البيانات. تُوفِّر قاعدة بيانات Redis -التي سنَستخدِمها في هذا التمرين- هياكل بيانات مستمرة مشابهةً لهياكل البيانات التي تُوفِّرها جافا، فهي تُوفِّر: قائمة سلاسل نصية مشابهة للنوع List. جداول مشابهة للنوع Map. مجموعات من السلاسل النصية مشابهة للنوع Set. تُعدّ Redis قاعدة بيانات من نوع زوج مفتاح/قيمة، ويَعني ذلك أن هياكل البيانات (القيم) التي تُخزِّنها تكون مُعرَّفةً بواسطة سلاسل نصية ٍفريدةٍ (مفاتيح). تلعب المفاتيح في قاعدة بيانات Redis نفس الدور الذي تلعبه المراجع references في لغة جافا، أي أنّها تُعرِّف هوية الكائنات. سنرى أمثلةً على ذلك لاحقًا. خوادم وعملاء Redis عادةً ما تَعمَل قاعدة بيانات Redis كخدمةٍ عن بعد، فكلمة Redis هي في الحقيقة اختصار لعبارة "خادم قاموس عن بعد REmote DIctionary Server"، ولنتمكن من استخدامها، علينا أن نُشغِّل خادم Redis في مكانٍ ما ثم نَتصِل به عبر عميل Redis. من الممكن إعداد ذلك الخادم بأكثرَ من طريقةٍ، كما يُمكِننا الاختيار من بين العديد من برامج العملاء، وسنَستخدِم في هذا التمرين ما يلي: بدلًا من أن نُثبِّت الخادم ونُشغِّله بأنفسنا، سنَستخدِم خدمةً مثل RedisToGo. تُشغِّل تلك الخدمة قاعدة بيانات Redis على السحابة cloud، وتُقدِّم خطةً مجانيةً بمواردَ تتناسب مع متطلبات هذا التمرين. بالنسبة للعميل، سنَستخدِم Jedis، وهو عبارةٌ عن مكتبة جافا تحتوي على أصنافٍ وتوابعَ يُمكِنها العمل مع قاعدة بيانات Redis. انظر إلى التعليمات المُفصَّلة التالية لمساعدتك على البدء: أنشِئ حسابًا على موقع RedisToGo، واختر الخطة التي تريدها (ربما الخطة المجانية). أنشِئ نسخة آلة افتراضية يعمل عليها خادم Redis. إذا نقرت على تبويب "Instances"، ستجد أن النسخة الجديدة مُعرَّفة باسم استضافة ورقم منفذ. كان اسم النسخة الخاصة بنا مثلًا هو dory-10534. انقر على اسم النسخة لكي تفتح صفحة الإعدادات، وسجِّل اسم مُحدّد الموارد الموحد URL الموجود أعلى الصفحة. سيكون مشابهًا لما يلي: redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534 يحتوي محدد الموارد الموحد السابق ذكره على اسم الاستضافة الخاص بالخادم dory.redistogo.com، ورقم المنفذ 10534، وكلمة المرور التي سنحتاج إليها للاتصال بالخادم، وهي السلسلة النصية الطويلة المُكوَّنة من أحرف وأعدادٍ في المنتصف. ستحتاج تلك المعلومات في الخطوة التالية. إنشاء مفهرس يعتمد على Redis ستجد الملفات التالية الخاصة بالتمرين في مستودع الكتاب: JedisMaker.java: يحتوي على بعض الأمثلة على الاتصال بخادم Redis وتشغيل بعض توابع Jedis. JedisIndex.java: يحتوي على شيفرةٍ مبدئيةٍ لهذا التمرين. JedisIndexTest.java: يحتوي على اختبارات للصنف JedisIndex. WikiFetcher.java: يحتوي على شيفرةٍ تقرأ صفحات إنترنت وتُحلِّلها باستخدام مكتبة jsoup. كتبنا تلك الشيفرة في تمارين المقالات المشار إليها بالأعلى. ستجد أيضًا الملفات التالية التي كتبناها في نفس تلك التمارين: Index.java: يُنفِّذ مفهرِسًا باستخدام هياكل بيانات تُوفِّرها جافا. TermCounter.java: يُمثِل خريطةً تربُط كلمات البحث بعدد مرات حدوثها. WikiNodeIterable.java: يمرّ عبر عقد شجرة DOM الناتجة من مكتبة jsoup. إذا تمكَّنت من كتابة نسخك الخاصة من تلك الملفات، يُمكِنك استخدامها لهذا التمرين. إذا لم تكن قد أكملت تلك التمارين أو أكملتها ولكنك غير متأكّد مما إذا كانت تَعمَل على النحو الصحيح، يُمكِنك أن تَنسَخ الحلول من مجلد solutions. والآن، ستكون خطوتك الأولى هي استخدام عميل Jedis للاتصال بخادم Redis الخاص بك. يُوضِّح الصنف RedisMaker.java طريقة القيام بذلك: عليه أولًا أن يقرأ معلومات الخادم من ملفٍّ، ثم يتصل به، ويُسجِل دخوله باستخدام كلمة المرور، وأخيرًا، يُعيد كائنًا من النوع Jedis الذي يُمكِن استخدامه لتنفيذ عمليات Redis. ستجد الصنف JedisMaker مُعرَّفًا في الملف JedisMaker.java. يَعمَل ذلك الصنف كصنف مساعد حيث يحتوي على التابع الساكن make الذي يُنشِئ كائنًا من النوع Jedis. يُمكِنك أن تَستخدِم ذلك الكائن بعد التصديق عليه للاتصال بقاعدة بيانات Redis الخاصة بك. يقرأ الصنف JedisMaker بيانات خادم Redis من ملفٍّ اسمه redis_url.txt موجودٍ في المجلد src/resources: استخدم مُحرّرَ نصوصٍ لإنشاء وتعديل الملف ThinkDataStructures/code/src/resources/redis_url.txt. ضع فيه مُحدّد موارد الخادم الخاص بك. إذا كنت تَستخدِم خدمة RedisToGo، سيكون محدد الموارد مشابهًا لما يلي: redis://redistogo:1234567feedfacebeefa1e1234567@dory.redistogo.com:10534 لا تضع هذا الملف في مجلدٍ عامٍّ لأنه يحتوي على كلمة مرور خادم Redis. يُمكِنك تجنُّب وقوع ذلك عن طريق الخطأ باستخدام الملف .gitignore الموجود في مستودع الكتاب لمنع وضع الملف فيه. نفِّذ الأمر ant build لتصريف ملفات الشيفرة والأمر ant JedisMaker لتشغيل المثال التوضيحيّ بالتابع main: public static void main(String[] args) { Jedis jedis = make(); // String jedis.set("mykey", "myvalue"); String value = jedis.get("mykey"); System.out.println("Got value: " + value); // Set jedis.sadd("myset", "element1", "element2", "element3"); System.out.println("element2 is member: " + jedis.sismember("myset", "element2")); // List jedis.rpush("mylist", "element1", "element2", "element3"); System.out.println("element at index 1: " + jedis.lindex("mylist", 1)); // Hash jedis.hset("myhash", "word1", Integer.toString(2)); jedis.hincrBy("myhash", "word2", 1); System.out.println("frequency of word1: " + jedis.hget("myhash", "word1")); System.out.println("frequency of word1: " + jedis.hget("myhash", "word2")); jedis.close(); } يَعرِض المثال أنواع البيانات والتوابع التي ستحتاج إليها غالبًا في هذا التمرين. ينبغي أن تحصل على الخرج التالي عند تشغيله: Got value: myvalue element2 is member: true element at index 1: element2 frequency of word1: 2 frequency of word2: 1 سنشرح طريقة عمل تلك الشيفرة في القسم التالي. أنواع البيانات في قاعدة بيانات Redis تَعمَل Redis كخريطةٍ تربط مفاتيحَ (سلاسل نصيّة) بقيم. قد تنتمي تلك القيم إلى مجموعة أنواع مختلفة من البيانات. يُعدّ النوع string واحدًا من أبسط الأنواع التي تُوفِّرها قاعدة بيانات Redis. لاحظ أننا سنكتب أنواع بيانات Redis بخطوط مائلة لنُميزها عن أنواع جافا. سنَستخدِم التابع jedis.set لإضافة سلسلةٍ نصيّةٍ من النوع string إلى قاعدة البيانات. قد تجد ذلك مألوفًا، فهو يشبه التابع Map.put، حيث تُمثِل المعاملات المُمرَّرة المفتاح الجديد وقيمته المقابلة. في المقابل، يُستخدَم التابع jedis.get للبحث عن مفتاحٍ معينٍ والعثور على قيمته. انظر الشيفرة إلى التالية: jedis.set("mykey", "myvalue"); String value = jedis.get("mykey"); كان المفتاح هو "mykey" والقيمة هي "myvalue" في هذا المثال. تُوفِّر Redis هيكل البيانات set الذي يَعمَل بشكلٍ مشابهٍ للنوع Set<String> في جافا. إذا أردت أن تضيف عنصرًا جديدًا إلى مجموعةٍ من النوع set، اختر مفتاحًا يُحدّد هوية تلك المجموعة، ثم اِستخدِم التابع jedis.sadd كما يلي: jedis.sadd("myset", "element1", "element2", "element3"); boolean flag = jedis.sismember("myset", "element2"); لاحِظ أنه ليس من الضروري إنشاء المجموعة بخطوةٍ منفصلةٍ، حيث تُنشؤها Redis إن لم تكن موجودةً. تُنشِئ Redis في هذا المثال مجموعةً من النوع set اسمها myset تحتوي على ثلاثة عناصر. يفحص التابع jedis.sismember ما إذا كان عنصر معين موجودًا في مجموعة من النوع set. تَستغرِق عمليتا إضافة العناصر وفحص عضويّتها زمنًا ثابتًا. تُوفِّر Redis أيضًا هيكل البيانات list الذي يشبه النوع List<String> في جافا. يضيف التابع jedis.rpush العناصر إلى النهاية اليمنى من القائمة، كما يلي: jedis.rpush("mylist", "element1", "element2", "element3"); String element = jedis.lindex("mylist", 1); مثلما سبق، لا يُعدّ إنشاء هيكل البيانات قبل إضافة العناصر إليه أمرًا ضروريًا. يُنشِئ هذا المثال قائمةً من النوع list اسمها mylist تحتوي على ثلاثة عناصر. يَستقبِل التابع jedis.lindex فهرسًا هو عبارةٌ عن عددٍ صحيحٍ، ويعيد عنصرَ القائمةِ المشارَ إليه. تَستغرِق عمليتا إضافة العناصر واسترجاعها زمنًا ثابتًا. أخيرًا، تُوفِّر Redis الهيكل hash الذي يشبه النوع Map<String, String> في جافا. يضيف التابع jedis.hset مُدخَلًا جديدًا إلى الجدول على النحو التالي: jedis.hset("myhash", "word1", Integer.toString(2)); String value = jedis.hget("myhash", "word1"); يُنشِئ هذا المثال جدولًا جديدًا اسمه myhash يحتوي على مدخلٍ واحدٍ يربُط المفتاح word1 بالقيمة "2". تنتمي المفاتيح والقيم إلى النوع string، ولذلك، إذا أردنا أن نُخزِّن عددًا صحيحًا من النوع Integer، علينا أن نُحوِّله أولًا إلى النوع String قبل أن نَستدعِي التابع hset. وبالمثل، عندما نبحث عن قيمة باستخدام التابع hget، ستكون النتيجة من النوع String، ولذلك، قد نضطرّ إلى تحويلها مرةً أخرى إلى النوع Integer. قد يكون العمل مع النوع hash في قاعدة بيانات Redis مربكًا نوعًا ما؛ لأننا نَستخدِم مفتاحين، الأول لتحديد الجدول الذي نريده، والثاني لتحديد القيمة التي نريدها من الجدول. في سياق قاعدة بيانات Redis، يُطلَق على المفتاح الثاني اسم "الحقل field"، أي يشير مفتاح مثل myhash إلى جدولٍ معينٍ من النوع hash بينما يشير حقلٌ مثل word1 إلى قيمةٍ ضمن ذلك الجدول. عادةً ما تكون القيم المُخزَّنة في جداول من النوع hash أعدادًا صحيحة، ولذلك، يوفِّر نظام إدارة قاعدة بيانات Redis بعض التوابع الخاصة التي تعامل القيم وكأنها أعداد مثل التابع hincrby. انظر إلى المثال التالي: jedis.hincrBy("myhash", "word2", 1); يسترجع هذا التابع المفتاح myhash، ويحصل على القيمة الحالية المرتبطة بالحقل word2 (أو على الصفر إذا لم يكن الحقل موجودًا)، ثم يزيدها بمقدار 1، ويكتبها مرةً أخرى في الجدول. تستغرق عمليات إضافة المُدْخَلات إلى جدول من النوع hash واسترجاعها وزيادتها زمنًا ثابتًا. تمرين 11 بوصولك إلى هنا، أصبح لديك كل المعلومات الضرورية لإنشاء مُفهرسٍ قادرٍ على تخزين النتائج في قاعدة بيانات Redis. والآن، نفِّذ الأمر ant JedisIndexTest. ستفشل بعض الاختبارات لأنه ما يزال أمامنا بعض العمل. يختبر الصنف JedisIndexTest التوابع التالية: JedisIndex: يستقبل هذا الباني كائنًا من النوع Jedis كمعامل. indexPage: يضيف صفحة إنترنت إلى المفهرس. يَستقبِل سلسلةً نصيّةً من النوع String تُمثِل مُحدّد موارد URL بالإضافة إلى كائن من النوع Elements يحتوي على عناصر الصفحة المطلوب فهرستها. getCounts: يستقبل كلمةَ بحثٍ ويعيد خريطةً من النوع Map<String, Integer> تربُط كل محدد مواردَ يحتوي على تلك الكلمة بعدد مرات ظهورها في تلك الصفحة. يُوضِح المثال التالي طريقة استخدامِ تلك التوابع: WikiFetcher wf = new WikiFetcher(); String url1 = "http://en.wikipedia.org/wiki/Java_(programming_language)"; Elements paragraphs = wf.readWikipedia(url1); Jedis jedis = JedisMaker.make(); JedisIndex index = new JedisIndex(jedis); index.indexPage(url1, paragraphs); Map<String, Integer> map = index.getCounts("the"); إذا بحثنا عن url1 في الخريطة الناتجة map، ينبغي أن نحصل على 339، وهو عدد مرات ظهور كلمة "the" في مقالة ويكيبيديا عن لغة جافا (نسخة المقالة التي خزّناها). إذا حاولنا فهرسة الصفحة مرةً أخرى، ستحُلّ النتائج الجديدة محل النتائج القديمة. إذا أردت تحويل هياكل البيانات من جافا إلى Redis، فتذكّر أن كل كائنٍ مُخزّنٍ في قاعدة بيانات Redis مُعرَّفٌ بواسطة مفتاحٍ فريدٍ من النوع string. إذا كان لديك نوعان من الكائنات في نفس قاعدة البيانات، فقد ترغب في إضافة كلمةٍ إلى بداية المفاتيح لتمييزها عن بعضها. على سبيل المثال، لدينا النوعان التاليان من الكائنات: يُمثِل النوع URLSet مجموعةً من النوع set في قاعدة بيانات Redis. تحتوي تلك المجموعة على محددات الموارد الموحدة URL التي تحتوي على كلمةٍ بحثٍ معينة. يبدأ المفتاح الخاص بكل قيمةٍ من النوع URLSet بكلمة":URLSet"، وبالتالي، لكي نحصل على محددات الموارد الموحدة التي تحتوي على كلمة "the"، علينا أن نسترجع المجموعة التي مفتاحها هو"URLSet:the". يُمثِل النوع TermCounter جدولًا من النوع hash في قاعدة بيانات Redis. يربُط هذا الجدول كل كلمة بحث ظهرت في صفحة معيّنة بعدد مرات ظهورها. يبدأ المفتاح الخاصُّ بكل قيمة من النوع TermCounter بكلمة "TermCounter:" وينتهي بمحدد الموارد الموحّدِ الخاص بالصفحة التي نبحث فيها. يحتوي التنفيذ الخاص بنا على قيمةٍ من النوع URLSet لكل كلمة بحثٍ، وعلى قيمةٍ من النوع TermCounter لكل صفحة مُفهرسَة. وفَّرنا أيضًا التابعين المساعدين urlSetKey و termCounterKey لإنشاء تلك المفاتيح. المزيد من الاقتراحات أصبح لديك الآن كل المعلومات الضرورية لحل التمرين، لذا يُمكِنك أن تبدأ الآن إذا أردت، ولكن ما يزال هناك بعض الاقتراحات القليلة التي ننصحك بقراءتها: سنوفِّر لك مساعدةً أقلّ في هذا التمرين، ونترك لك حريّة أكبر في اتخاذ بعض القرارات المتعلقة بالتصميم. سيكون عليك تحديدًا أن تُفكِّر بالطريقة التي ستُقسِّم بها المشكلة إلى أجزاءَ صغيرةٍ يُمكِن اختبار كلٍّ منها على حدة. بعد ذلك، ستُجمِّع تلك الأجزاء إلى حلٍّ كاملٍ. إذا حاولت كتابة الحل بالكامل على خطوةٍ واحدةٍ بدون اختبار الأجزاء الأصغر، فقد تستغرِق وقتًا طويلًا جدًا لتنقيح الأخطاء. تُمثِّل الاستمرارية واحدةً من تحديات العمل مع البيانات المستمرة، لأن الهياكل المُخزَّنة في قواعد البيانات قد تتغير في كل مرةٍ تُشغِّل فيها البرنامج. فإذا تسبَّبت بخطأٍ في قاعدة البيانات، سيكون عليك إصلاحه أو البدء من جديد. ولكي نُبقِي الأمور تحت السيطرة، وفّرنا لك التوابع deleteURLSets و deleteTermCounters و deleteAllKeys التي تستطيع أن تَستخدِمها لتنظيف قاعدة البيانات والبدء من جديد. يُمكِنك أيضًا استخدام التابع printIndex لطباعة محتويات المُفهرِس. في كلّ مرةٍ تستدعي فيها أيًّا من توابع Jedis، فإنه يُرسِل رسالةً إلى الخادم الذي يُنفِّذ بدوره الأمر المطلوب، ثم يردّ برسالة. إذا نفَّذت الكثير من العمليات الصغيرة، فستحتاج إلى وقت طويل لمعالجتها، ولهذا، من الأفضل تجميع متتالية من العمليات ضمن معاملة واحدة من النوع Transaction لتحسين الأداء. على سبيل المثال، انظر إلى تلك النسخة البسيطة من التابع deleteAllKeys: public void deleteAllKeys() { Set<String> keys = jedis.keys("*"); for (String key: keys) { jedis.del(key); } } في كل مرةٍ تَستدعِي خلالها التابع del، يضطرّ العميل إلى إجراء اتصالٍ مع الخادم وانتظار الرد. إذا كان المُفهرِس يحتوي على بضع صفحاتٍ، فقد يَستغرِق تنفيذ ذلك التابع وقتًا طويلًا. بدلًا من ذلك، يُمكِنك أن تُسرِّع تلك العملية باستخدام كائنٍ من النوع Transaction على النحو التالي: public void deleteAllKeys() { Set<String> keys = jedis.keys("*"); Transaction t = jedis.multi(); for (String key: keys) { t.del(key); } t.exec(); } يعيد التابع jedis.multi كائنًا من النوع Transaction. يُوفِّر هذا الكائن جميع التوابع المتاحة في كائنات النوع Jedis. عندما تستدعي أيًّا من تلك التوابع بكائنٍ من النوع Transaction، فإن العميل لا يُنفِّذها تلقائيًا، ولا يتصل مع الخادم، وإنما يُخزِّن تلك العمليات إلى أن تَستدعِي التابع exec، وعندها، يُرسِل جميعَ العملياتِ المُخزَّنة إلى الخادم في نفس الوقت، وهو ما يكون أسرع عادةً. تلميحات بسيطة بشأن التصميم الآن وقد أصبح لديك جميع المعلومات المطلوبة، يمكنك البدء في حل التمرين. إذا لم يكن لديك فكرةٌ عن طريقة البدء، يُمكِنك العودة لقراءة المزيد من التلميحات البسيطة. لا تتابع القراءة قبل أن تُشغِّل شيفرة الاختبار، وتُجرِّب بعض أوامر Redis البسيطة، وتكتب بعض التوابع الموجودة في الملف JedisIndex.java. إذا لم تتمكّن من متابعة الحل فعلًا، إليك بعض التوابع التي قد ترغب في العمل عليها: /** * أضف محدد موارد موحدًا إلى المجموعة الخاصة بكلمة */ public void add(String term, TermCounter tc) {} /** * ابحث عن كلمة وأعد مجموعة مُحدِّدات الموارد الموحدة التي تحتوي عليها */ public Set<String> getURLs(String term) {} /** * أعد عدد مرات ظهور كلمة معينة بمحدد موارد موحد */ public Integer getCount(String url, String term) {} /** * أضف محتويات كائن من النوع `TermCounter` إلى قاعدة بيانات Redis */ public List<Object> pushTermCounterToRedis(TermCounter tc) {} تلك هي التوابع التي استخدمناها في الحل، ولكنها ليست بالتأكيد الطريقة الوحيدة لتقسيم المشكلة. لذلك، استعن بتلك الاقتراحات فقط إذا وجدتها مفيدة، وتجاهلها تمامًا إذا لم تجدها كذلك. اكتب بعض الاختبارات لكل تابعٍ منها أولًا، فبمعرفة الطريقة التي ينبغي بها أن تختبر تابعًا معينًا، عادةً ما تتكوَّن لديك فكرةٌ عن طريقة كتابته. ترجمة -بتصرّف- للفصل Chapter 14: Persistence من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: فهرسة الصفحات وتحليل زمن تشغيلها باستخدام قاعدة بيانات Redis المقال السابق: استخدام أشجار البحث الثنائية والأشجار المتزنة balanced trees لتنفيذ الخرائط تحليل نظام الملفات لإدارة البيانات وتخزينها واختلافه عن نظام قاعدة البيانات شرح الفروقات بين قواعد بيانات SQL ونظيراتها NoSQL كيفية استيراد وتصدير قواعد بيانات MySQL أو MariaDB
-
عند تخزين بياناتٍ معينة بملفٍ معين أو نقلها خلال شبكةٍ معينة، سيكون من الضروري تمثيلها بطريقةٍ ما بشرط أن تَسمَح تلك الطريقة بإعادة بناء البيانات لاحقًا عند قراءة الملف أو عند تَسلُّم البيانات. رأينا بالفعل أسبابًا جيدةً تدفعنا لتفضيل التمثيلات النصية المُعتمِدة على المحارف، ومع ذلك هناك طرائقٌ كثيرةٌ لتمثيل مجموعةٍ معينةٍ من البيانات تمثيلًا نصيًا. سنُقدِّم بهذا المقال لمحةً مختصرةً عن أحد أكثر أنواع تمثيل البيانات data representation شيوعًا. تُعدّ لغة الترميز القابلة للامتداد eXtensible Markup Language -أو اختصارًا XML- أسلوبًا لإنشاء لغاتٍ لتمثيل البيانات. هناك جانبان أو مستويان بلغة XML؛ حيث تُخصِّص XML في المستوى الأول قواعد صيغة صارمةً ولكنها بسيطةً نوعًا ما، وتُعدّ أي متتالية محارف تَتبِع تلك القواعد مستند XML سليم؛ بينما تُوفِّر XML على المستوى الآخر طريقةً لإضافة المزيد من القيود على ما يُمكِن كتابته بالمستند من خلال ربط مستند XML معين بما يُعرَف باسم "تعريف نوع المستند Document Type Definition" -أو اختصارًا DTD- الذي يُخصِّص قائمةً بالأشياء المسموح بكتابتها بمستند XML. يُقال على مستندات XML السليمة المُرتبِطَة بتعريف نوع مستند معين والمُتبعِّة للقواعد التي خصَّصها ذلك التعريف "مستندات XML صالحة". يُمكِننا النظر إلى لغة XML كما لو كانت صيغةً عامةً لتمثيل البيانات؛ في حين تُخصِّص DTD الكيفية التي ينبغي بها استخدام XML لتمثيل نوعٍ معينٍ من البيانات. تتوفَّر بدائلٌ أخرى بخلاف DTD، مثل XML schemas لتعريف مستندات XML صالحة، ولكننا لن نناقشها هنا. لا يوجد شيءٌ سحريٌ بخصوص XML، فهي ليست مثالية، وتُعدُّ في الحقيقة لغةً مُسهبةً verbose ويراها البعض قبيحة، ولكنها من الناحية الأخرى مرنةٌ للغاية؛ حيث يُمكِن اِستخدَامها لتمثيل أي نوعٍ من البيانات تقريبًا. لقد صُمِّمت من البداية لتدعم جميع لغات البرمجة، والأهم من ذلك، أنها قد أصبحت بالفعل معيارًا مقبولًا على نطاقٍ واسع، فبإمكان جميع لغات البرمجة معالجة مستندات XML، بل ويتوفَّر تعريف نوع مستند DTD قياسي لوصف مختلف أنواع البيانات. هناك طرائقٌ مختلفةٌ كثيرة لتصميم لغةٍ لتمثيل مجموعةٍ من البيانات، ولكن حظت XML بشعبيةٍ واسعة، بل إنها حتى قد وجدت طريقها إلى جميع تقنيات المعلومات. على سبيل المثال، هناك لغات XML لتمثيل كُلٍ من التعبيرات الحسابية "MathML" والرموز الموسيقية "MusicXML" والجزيئات والتفاعلات الكيميائية "CML" ورسومات الفيكتور "SVG" وغيرها من المعلومات. بالإضافة إلى ذلك، تَستخِدم برامج OpenOffice والإصدارات الأحدث من Microsoft Office لغة XML بصيغ المستندات لتطبيقات معالجة النصوص وجداول البيانات spreadsheets والعروض. مثالٌ آخر هو لغة مشاركة المواقع "RSS, ATOM"، التي سهَّلت على المواقع الإلكترونية والمدونات والجرائد الإخبارية إنشاء قائمةٍ بالعناوين الأحدث المتاحة بصيغةٍ قياسيةٍ قابلةٍ للِاستخدَام بواسطة مواقع ومتصفحات الويب، وهي في الواقع نفس الصيغة المُستخدَمة لنشر المدونات الصوتية podcasts. تُعدّ XML عمومًا الصيغة الأكثر شيوعًا لتبادل المعلومات إلكترونيًا. ليس الهدف هنا هو إطلاعك على كل شيء يُمكِن معرفته عن XML، وإنما سنشرح فقط طرائقًا قليلةً يَصلُح معها استخدام XML بالبرامج؛ أي أننا لن نناقش أي شيءٍ آخر عن تعريفات نوع المستند DTD، أو مستندات XML الصالحة، وإنما سنَكتفِي باستخدام مستندات XML سليمة دون ربطها بأي تعريفاتٍ لنوع المستند DTD؛ فهي عادةً ما تكون كافيةً للعديد من الأغراض. قواعد صيغة بسيطة للغة XML إذا كنت تعرف لغة HTML -اللغة المُستخدَمة لكتابة صفحات الإنترنت-، فستبدو لغة XML مألوفةً بالنسبة لك، حيث تشبه مستندات XML كثيرًا مستندات HTML. لا تُعدّ لغة HTML هي نفسها لغة XML؛ فهي لا تَتبِع جميع قواعد الصيغة الصارمة الخاصة بلغة XML، ولكن الأفكار الأساسية هي نفسها. نعرض فيما يلي مثالًا قصيرًا على مستند XML سليم: <?xml version="1.0"?> <simplepaint version="1.0"> <background red='1' green='0.6' blue='0.2'/> <curve> <color red='0' green='0' blue='1'/> <symmetric>false</symmetric> <point x='83' y='96'/> <point x='116' y='149'/> <point x='159' y='215'/> <point x='216' y='294'/> <point x='264' y='359'/> <point x='309' y='418'/> <point x='371' y='499'/> <point x='400' y='543'/> </curve> <curve> <color red='1' green='1' blue='1'/> <symmetric>true</symmetric> <point x='54' y='305'/> <point x='79' y='289'/> <point x='128' y='262'/> <point x='190' y='236'/> <point x='253' y='209'/> <point x='341' y='158'/> </curve> </simplepaint> يُعدّ السطر الأول بالأعلى اختياريًا، حيث يُشير ببساطةٍ إلى أن ما يليه هو مستند XML. قد يتضمَّن هذا السطر معلوماتٍ أخرى أيضًا، مثل الترميز المُستخدَم لترميز محارف المستند إلى صيغةٍ ثنائية. وقد يتضمَّن المستند سطرًا للموجه "DOCTYPE" لتحديد تعريف نوع المستند. يتكوَّن المستند بخلاف السطر الأول من مجموعةٍ من العناصر elements والسمات attributes والمحتويات النصية. يبدأ أي عنصرٍ بوسم tag، مثل <curve> وينتهي بوسم الغلق المقابل، مثل </curve>، ونجد محتويات العنصر بين هذين الوسمين، والتي يُمكِن أن تتكوَّن من نصٍ وعناصرٍ متداخلة أخرى. يُعدّ المحتوى النصي الوحيد بالمثال السابق هو القيمة true أو false بالعنصر <symmetric>. إذا لم يحتوِ عنصرٌ معينٌ على أي شيء، يُمكِننا دمج وسميه للفتح والغلق بوسمٍ واحدٍ فارغ بكتابة الاختصار <point x='83' y='96'/> بدلًا من <point x='83' y='96'></point>. لاحِظ استخدام "/" قبل "<". قد يتضمَّن الوسم عِدّة سمات، مثل x و y بالوسم <point x='83' y='96'/>، و version بالوسم <simplepaint version="1.0">. قد يتضمَّن المستند أشياءً قليلةً أخرى، مثل التعليقات ولكننا لن نناقشها هنا. ينبغي لأي مؤلف مستند XML سليم أن يختار أسماء الوسوم والسمات، وبطبيعة الحال عليه اختيار أسماءٍ ذات معنًى مفهوم للقارئ. في المقابل، إذا كان المستند يعتمد على تعريف نوع مستند DTD معين، فإن مؤلف التعريف هو من يختار أسماء الوسوم. يجب على أي مستند XML سليم أن يَتبِّع مجموعةً صارمةً من قواعد الصياغة syntax، نُلخِّص أهمها فيما يلي: تُميِّز أسماء الوسوم والسمات بمستندات XML بين حالة الأحرف case sensitive؛ فقد تتكوَّن الأسماء من مجموعةٍ من الأحرف والأرقام وبعض المحارف الأخرى، ولكن لا بُدّ أن تبدأ بحرف. ليس للفراغات ومحرف نهاية السطر أي أهمية إلا إذا وقعت بالمحتويات النصية؛ فعند كتابة وسمٍ معينٍ، ولم يَكُن هذا الوسم فارغًا، فيجب إذًا أن يُقابِله وسم غلق؛ ويَعنِي ذلك أنه إذا تداخلت عدة عناصرٍ مع بعضها، فيجب أن يكون التداخل سليمًا. بتعبيرٍ آخر، إذا تضمَّن عنصرٌ معينٌ وسمًا، فيجب أن يَقع وسم غلقه ضمن نفس العنصر. لا بُدّ أن يحتوي أي مستندٍ على عنصر جذر root، والذي يحتوي بدوره على جميع العناصر الأخرى. لاحِظ أن اسم الوسم المُمثِّل لعنصر الجذر بالمثال السابق، هو simplepaint. لا بُدّ أن يكون لأي سمةٍ قيمة، والتي ينبغي أن تكون محاطةً بعلامتي اقتباس إما مفردة أو مزدوجة. في حالة اِستخدَام المحارف الخاصة > و & بقيمةٍ لسمة أو بمحتوى نصي، فيجب أن تُكْتَب على النحو التالي > و ". أمثلة أخرى على ذلك، هي > و " و '، والتي تُمثِّل > وعلامة اقتباسٍ مزدوجة وعلامة اقتباسٍ مفردة على الترتيب. يُمكِن تعريف المزيد بتعريف DTD. يُعدّ ما سَبَق مقدمةً سريعةً عن مستندات XML، والتي ربما لن تساعدك على فهم كل شيء تقابله بمستند XML، ولكنها ستُمكِّنك على الأقل من تصميم مستندات XML سليمة لتمثيل بعض بنى البيانات data structures المُستخدَمة ببرامج جافا. العمل مع شجرة DOM صمَّمنا في الأعلى مثال XML لتخزين معلوماتٍ عن رسومٍ بسيطة يَرسِمها المُستخدِم، واعتمدنا على البرنامج التوضيحي SimplePaint2.java من مقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا فيما هو مُتعلِّقٌ برسم تلك الرسوم. سنناقش بهذا المقال نسخةً أخرى من نفس البرنامج، والتي يُمكِنها حفظ رسوم المُستخدِم بملف بياناتٍ معتمِدٍ على صيغة XML. يُمكِنك الإطلاع على شيفرة النسخة الجديدة من SimplePaintWithXML.java. سنَستخدِم مستند XML التوضيحي الذي عرضناه بالأعلى ضمن هذا البرنامج. في الحقيقة، لقد صمَّمنا هذا المستند بحيث يحتوي على جميع البيانات المطلوبة لإعادة إنشاء الكائن المُمثِّل للرسمة، والذي ينتمي للصنف SimplePaint؛ حيث يتضمَّن المستند على سبيل المثال معلوماتٍ عن لون خلفية الصورة وكذلك قائمةً بالمنحنيات التي رَسَمها المُستخدِم. لاحِظ احتواء كل عنصر <curve> على بيانات كائنٍ من النوع CurveData. من السهل كتابة برنامج يُخرِج البيانات بصيغة XML، مع الحرص طبعًا على اتباع جميع قواعد صيغة XML، حيث تبيّن الشيفرة التالية الطريقة التي يَستخدِمها الصنف SimplePaintWithXML لإرسال بيانات رسمةٍ من النوع SimplePaint إلى out من النوع PrintWriter. يَنتُج عن ذلك مستند XML بنفس هيئة المثال المُوضَح بالأعلى: out.println("<?xml version=\"1.0\"?>"); out.println("<simplepaint version=\"1.0\">"); out.println(" <background red='" + backgroundColor.getRed() + "' green='" + backgroundColor.getGreen() + "' blue='" + backgroundColor.getBlue() + "'/>"); for (CurveData c : curves) { out.println(" <curve>"); out.println(" <color red='" + c.color.getRed() + "' green='" + c.color.getGreen() + "' blue='" + c.color.getBlue() + "'/>"); out.println(" <symmetric>" + c.symmetric + "</symmetric>"); for (Point2D pt : c.points) out.println(" <point x='" + pt.getX() + "' y='" + pt.getY() + "'/>"); out.println(" </curve>"); } out.println("</simplepaint>"); قراءة البيانات من مستند XML إلى البرنامج مرةً أخرى هي عمليةٌ أخرى تمامًا، حيث ينبغي تحليل parse المستند واستخراج البيانات منه؛ لنتمكَّن من إعادة إنشاء بنية البيانات المُمثِلة للمستند، وهو ما يتطلّب الكثير من العمل. تُوفِّر جافا لحسن الحظ واجهة برمجة تطبيقات قياسية standard API لتحليل مستندات XML ومعالجتها، وهي تُوفِّر في الواقع واجهتين ولكننا سنتعامل مع واحدةٍ منهما فقط. يتكوَّن أي مستند XML سليم من بنيةٍ معروفةٍ مكوَّنة من مجموعةٍ من العناصر elements مُتضمَّنةً مجموعةً من السمات attributes؛ والعناصر المتداخلة؛ والمحتويات النصية. يُمكِننا إنشاء بنية بياناتٍ مكافئةً لبنية المستند ومحتوياته في ذاكرة الحاسوب، حيث تتوفَّر الكثير من الطرائق لذلك، ولكن يُعدّ تمثيل نموذج كائن المستند Document Object Model -أو اختصارًا DOM- أكثرها شيوعًا؛ حيث يُخصِّص هذا النموذج طريقةً لبناء بنية بياناتٍ مكافئة لمستندات XML؛ كما أنه يُعرِّف مجموعةً من التوابع methods القياسية، التي تَسمَح باسترجاع البيانات الموجودة ضمن تلك البنية. تُعدّ تلك البنية أشبه ما تكون بشجرةٍ تُكافئ بنيتها بنية المستند، حيث تتكوَّن الشجرة من أنواعٍ مختلفة من العُقد nodes المُستخدَمة لتمثيل العناصر والسمات والنصوص، كما قد تحتوي على أنواعٍ آخرى من العُقد لتمثيل جوانبٍ أخرى من XML. سنُركز فقط على العقد المُمثِّلة للعناصر بسبب عدم القدرة على معالجة السمات والنصوص مباشرةً دون معالجة ما يُقابِلها من عُقد. يُمكِّنك البرنامج التوضيحي XMLDemo.java من التعامل مع كُلٍ من XML و DOM. يتضمَّن البرنامج صندوقًا نصيًا حيث يُمكِنك كتابة مستند XML، كما يحتوي على مستند XML توضيحي من هذا المقال افتراضيًا. إذا ضغطت على الزر "Parse XML Input"، سيقرأ البرنامج المستند المكتوب بالصندوق، وسيحاول بناء تمثيل DOM له. إذا لم تَكْن المُدْخلات مُمثِّلةً لمستند XML سليم، فستظهر رسالة خطأ؛ أما إذا كانت سليمة، فسيجتاز البرنامج تمثيل DOM بالكامل، وسيَعرِض قائمةً بكل العناصر والسمات والمحتويات النصية التي سيُقابِلها. يَستخدِم البرنامج بعض التقنيات لمعالجة XML، ولكننا لن نناقشها هنا. يُمكِننا إنشاء تمثيل DOM لمستند XML باستخدام التعليمتين التاليتين. إذا كان selectedFile متغيرًا من النوع File يُمثِّل مستند XML، فسيكون xmldoc من النوع Document: DocumentBuilder docReader = DocumentBuilderFactory.newInstance().newDocumentBuilder(); xmldoc = docReader.parse(selectedFile); تَفتَح الشيفرة السابقة الملف، وتقرأ محتوياته، ثم تَبنِي تمثيل DOM. ستَجِد الصنفين DocumentBuilder و DocumentBuilderFactory بحزمة javax.xml.parsers. يُحاوِل التابع docReader.parse() إجراء العملية، ويُبلِّغ عن اعتراضٍ إذا لم يتمكَّن من قراءة الملف أو إذا لم يحتوِ الملف على مستند XML صالح. في المقابل، إذا نجح التابع، فإنه يعيد كائنًا يُمثِّل كامل المستند. تُعدّ هذه العملية معقدةً بالتأكيد، ولكنها بُرمجَت بالكامل مرةً واحدةً، وضُمِّنَت بتابعٍ يُمكِنك اِستخدَامه بسهولة بأي برنامج جافا، وهو ما يُمثِّل إحدى فوائد الاعتماد على صيغةٍ قياسية. يُمكِنك الإطلاع على تعريف بنية البيانات DOM بحزمة org.w3c.dom، والتي تحتوي على أنواع بياناتٍ مختلفة لتمثيل مستند XML بالكامل؛ وكذلك لتمثيل العقد الموجودة بالمستند على حدى. يشير الاسم "org.w3c" إلى اتحاد شبكة الويب العالمية World Wide Web Consortium -أو اختصارًا W3C، والمسؤولة عن تنظيم المعايير القياسية لتقنيات الويب. يُعدّ تمثيل DOM مثل XML معيارًا عامًا؛ أي لا يختص بلغة جافا دون غيرها. سنحتاج بهذا المثال أنواع البيانات التالية Document و Node و Element و NodeList، والمُعرَّفة جميعًا على أنها واجهات interfaces لا أصناف، ولكن هذا غير مهم. سنَستخدِم التوابع المُعرَّفة بتلك الأنواع للوصول إلى البيانات الموجودة بتمثيل DOM لمستند XML. يعيد التابع docReader.parse() قيمةً من النوع Document، والتي كانت xmldoc في المثال السابق، وهي تُمثِّل مستند XML بالكامل. سنحتاج إلى التابع التالي فقط من هذا الصنف. إذا كان xmldoc من النوع Document، يُمكِننا كتابة ما يلي: xmldoc.getDocumentElement() يُعيد هذا التابع قيمةً من النوع Element تُمثِّل عنصر الجذر root الخاص بالمستند؛ وهوالعنصر الموجود بأعلى مستوى بالمستند كما ذكرنا مُسبقًا، ويتضمَّن كافة العناصر الأخرى. يتكوَّن عنصر الجذر بمستند XML الخاص بالمثال السابق من هذا المقال من الوسمين <simplepaint version="1.0"> و </simplepaint>، وكل ما هو موجودٌ بينهما. تُمثَّل العناصر الواقعة داخل عنصر الجذر باستخدام عقد nodes، والتي تُعدّ بمثابة أبناء عقدة الجذر. تتضمَّن كائنات النوع Element مجموعةً من التوابع المفيدة، والتي سنستعرِض بعضًا منها فيما يلي. إذا كان element من النوع Element، يُمكِننا استخدام التوابع التالية: element.getTagName(): يُعيد سلسلةً نصيةً من النوع String للاسم المُستخدَم بالوسم الخاص بالعنصر؛ حيث سيُعيد على سبيل المثال السلسلة النصية "curve" للعنصر <curve>. element.getAttribute(attrName): إذا كان attrName اسم سمةٍ في العنصر، سيُعيد التابع قيمة تلك السمة. على سبيل المثال، يعيد الاستدعاء element.getAttribute("x") السلسلة النصية "83" للعنصر <point x="83" y="42"/>، وتكون القيمة المعادة من النوع String دائمًا حتى لو كانت السمة تُمثِّل قيمة عددية. إذا لم يحتوِ عنصرٌ معين على سمةٍ للاسم المُخصَّص، فسيعيد التابع سلسلةً نصيةً فارغة. element.getTextContent(): يُعيد سلسلةً نصيةً من النوع String تحتوي على المحتوى النصي الموجود بالعنصر، بما في ذلك محتوى العناصر الأخرى الواقعة ضمن العنصر. element.getChildNodes(): يعيد قيمةً من النوع NodeList تحتوي على جميع العُقد الأبناء لذلك العنصر. تتضمَّن القائمة العقد المُمثِلة لأي عنصرٍ آخر أو محتوى نصي (إلى جانب أنواع أخرى من العقد) مُتداخِل مباشرةً مع العنصر. يَسمَح التابع getChildNodes() باجتياز بنية البيانات DOM بالكامل بدءًا من عنصر الجذر، مرورًا بأبنائه، وأبناء أبنائه، وهكذا. يتوفَّر تابعٌ آخر يعيد جميع سمات العنصر، ولكننا لن نَستخدِمه هنا. element.getElementsByTagName(tagName): يُعيد قيمةً من النوع NodeList تحتوي على جميع العُقد المُمثِّلة لعناصر واقعةٍ داخل element، والتي لها نفس اسم الوسم المُخصَّص. يتضمَّن ذلك العناصر الواقعة بأي مستوى داخل element، وليس فقط تلك المتصلة مباشرةً معه. يَسمَح التابع getElementsByTagName() بالوصول إلى بيانات مُحدَّدة ضمن المستند. يُمثِّل كائنٌ من النوع NodeList قائمةً من العقدة من النوع Node، ولكنه لا يَستخدِم واجهة برمجة التطبيقات الخاصة بالقوائم والمُعرَّفة بإطار عمل جافا للتجميعات. يُعرِّف الصنف NodeList بدلًا من ذلك التابعين التاليين: nodeList.getLength() و nodeList.item(i)؛ حيث يُعيد الأول عدد العقد ضمن القائمة؛ بينما يُعيد الثاني العقدة الموجودة بموضع i، والذي تتراوح قيمه من 0 حتى nodeList.getLength() - 1. يُعيد التابع nodeList.get() قيمةً من النوع Node، والتي يُمكِن تحويلها type-cast إلى نوعٍ مُخصّصٍ من العقد قبل استخدامها. بناءً على هذه المعلومات، يُمكِنك إجراء أغلب أنواع العمليات الممكنة على تمثيلات DOM. لنفحص الآن بعض الشيفرة. لنفترض أنه وبينما تعالج إحدى المستندات، قد توصلت إلى عقدةٍ من النوع Element تُمثِّل العنصر التالي: <background red='1' green='0.6' blue='0.2'/> قد يقابلنا العنصر السابق إما أثناء اجتياز المستند باستخدام getChildNodes() أو نتيجةً لاستدعاء التابع getElementsByTagName("background"). علينا الآن إعادة إنشاء بنية البيانات التي يُمثِّلها المستند، والتي يُعدّ هذا العنصر جزءًا من بياناتها؛ حيث يُمثِل لون الخلفية تحديدًا بواسطة ثلاث سمات attributes تُمثِّل مكوّنات اللون الأحمر والأخضر والأزرق. إذا كان element متغيرًا يُشير إلى تلك العقدة، يُمكِننا استرجاع اللون بكتابة ما يَلي: double r = Double.parseDouble( element.getAttribute("red") ); double g = Double.parseDouble( element.getAttribute("green") ); double b = Double.parseDouble( element.getAttribute("blue") ); Color bgColor = Color.color(r,g,b); لنفترض الآن أن element يشير إلى العقدة المُمثِّلة للعنصر التالي: <symmetric>true</symmetric> يُمثِل element في تلك الحالة قيمة مُتغيّر من النوع المنطقي boolean، ولكنها رُمزَّت كأنها محتوًى نصي للعنصر. يُمكِننا استرجاع تلك القيمة من العنصر بكتابة ما يَلي: String bool = element.getTextContent(); boolean symmetric; if (bool.equals("true")) symmetric = true; else symmetric = false; لنفكر الآن بمثالٍ يَستخدِم كائنًا من الصنف NodeList. إذا واجهنا العنصر التالي الذي ينبغي تمثيله بقائمة عناصر تنتمي للصنف Point2D: <pointlist> <point x='17' y='42'/> <point x='23' y='8'/> <point x='109' y='342'/> <point x='18' y='270'/> </pointlist> لنفترض أن element يشير إلى العقدة المُمثِّلة للعنصر <pointlist>، علينا الآن إنشاء قائمةٍ من النوع ArrayList<Point2D> لتمثيله، حيث تجتاز الشيفرة التالية قائمة الصنف NodeList المُتضمِّنة لعقد أبناء العنصر على النحو التالي: ArrayList<Point2D> points = new ArrayList<>(); NodeList children = element.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); // أحد عقد أبناء العنصر if ( child instanceof Element ) { Element pointElement = (Element)child; // One of the <point> elements. int x = Integer.parseInt( pointElement.getAttribute("x") ); int y = Integer.parseInt( pointElement.getAttribute("y") ); // أنشئ النقطة التي يُمثِلها pointElement Point2D pt = new Point2D(x,y); points.add(pt); // أضف النقطة إلى قائمة النقاط } } تُعدّ جميع عناصر <point> الواقعة داخل العنصر <pointlist> أبناءً له. يجب أن نَستخدِم تعليمة if -كما بالأعلى- نظرًا لإمكانية احتواء العنصر على أبناء تنتمي لأصناف أخرى غير الصنف Element، والتي نحن في الواقع غير مهتمين بمعالجتها ضمن هذا المثال. يُمكِننا توظيف كل تلك التقنيات لكتابة تابعٍ يقرأ ملف الدْخَل بالبرنامج التوضيحي SimplePaintWithXML.java. عندما نُنشِئ بنية بياناتٍ لتمثيل ملف XML، يُفضَّل أن نبدأ ببنية بياناتٍ افتراضية، والتي يُمكِننا تعديلها، والإضافة إليها بينما نجتاز شجرة DOM المُمثِلة للملف. في الواقع، هذه العملية ليست سهلة نوعًا ما، ولذلك حاول أن تقرأها بعناية: Color newBackground = Color.WHITE; ArrayList<CurveData> newCurves = new ArrayList<>(); Element rootElement = xmldoc.getDocumentElement(); if ( ! rootElement.getNodeName().equals("simplepaint") ) throw new Exception("File is not a SimplePaint file."); String version = rootElement.getAttribute("version"); try { double versionNumber = Double.parseDouble(version); if (versionNumber > 1.0) throw new Exception("File requires a newer version of SimplePaint."); } catch (NumberFormatException e) { } NodeList nodes = rootElement.getChildNodes(); for (int i = 0; i < nodes.getLength(); i++) { if (nodes.item(i) instanceof Element) { Element element = (Element)nodes.item(i); if (element.getTagName().equals("background")) { double r = Double.parseDouble(element.getAttribute("red")); double g = Double.parseDouble(element.getAttribute("green")); double b = Double.parseDouble(element.getAttribute("blue")); newBackground = Color.color(r,g,b); } else if (element.getTagName().equals("curve")) { CurveData curve = new CurveData(); curve.color = Color.BLACK; curve.points = new ArrayList<>(); newCurves.add(curve); NodeList curveNodes = element.getChildNodes(); for (int j = 0; j < curveNodes.getLength(); j++) { if (curveNodes.item(j) instanceof Element) { Element curveElement = (Element)curveNodes.item(j); if (curveElement.getTagName().equals("color")) { double r = Double.parseDouble(curveElement.getAttribute("red")); double g = Double.parseDouble(curveElement.getAttribute("green")); double b = Double.parseDouble(curveElement.getAttribute("blue")); curve.color = Color.color(r,g,b); } else if (curveElement.getTagName().equals("point")) { double x = Double.parseDouble(curveElement.getAttribute("x")); double y = Double.parseDouble(curveElement.getAttribute("y")); curve.points.add(new Point2D(x,y)); } else if (curveElement.getTagName().equals("symmetric")) { String content = curveElement.getTextContent(); if (content.equals("true")) curve.symmetric = true; } } } } } } backgroundColor = newBackground; curves = newCurves; يُمكِنك الإطلاع على كامل الشيفرة المصدرية في الملف SimplePaintWithXML.java. تطوَّرت XML لتُصبِح واحدةً من أهم التقنيات المُستخدَمة لتطوير بعض أعقد التطبيقات، وهناك مع ذلك بعض الأفكار الأساسية البسيطة التي يَسهُل تطبيقها بجافا. بمجرد إطلاعك على أساسيات XML، يُمكِنك استخدامها بفعالية ضمن برامج جافا الخاصة بك. ترجمة -بتصرّف- للقسم Section 5: A Brief Introduction to XML من فصل Chapter 11: Input/Output Streams, Files, and Networking من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: تواصل تطبيقات جافا عبر الشبكة الدليل السريع للغة البرمجة Java اكتب برنامجك الأول بلغة جافا
-
سنناقش في هذا المقال حل تمرين مقالة تحليل زمن تشغيل الخرائط المُنفَّذة باستخدام شجرة بحثٍ ثنائيّةٍ، ونختبر أداء الخرائط المُنفّذة باستخدام شجرة، وبعدها سنناقش إحدى مشاكل ذلك التنفيذ والحلّ الذي يقدمه الصنف TreeMap لتلك المشكلة. الصنف MyTreeMap وفَّرنا في المقالة المشار إليها تصوّرًا مبدئيًا للصنف MyTreeMap، وتركنا للقارئ مهمة إكمال توابعه. وسنُكملها الآنَ معًا، ولْنبدأ بالتابع findNode: private Node findNode(Object target) { // some implementations can handle null as a key, but not this one if (target == null) { throw new IllegalArgumentException(); } // something to make the compiler happy @SuppressWarnings("unchecked") Comparable<? super K> k = (Comparable<? super K>) target; // the actual search Node node = root; while (node != null) { int cmp = k.compareTo(node.key); if (cmp < 0) node = node.left; else if (cmp > 0) node = node.right; else return node; } return null; } يَستخدِم التابعان containsKey و get التابعَ findNode، ولأنه ليس جزءًا من الواجهة Map، عرَّفناه باستخدام المُعدّل private. يُمثِل المعامل target المفتاحَ الذي نبحث عنه. كنا قد شرحنا الجزء الأول من هذا التابع في المقال المشار إليه: لا تُعدّ null قيمةً صالحةً كمفتاحٍ في هذا التنفيذ. ينبغي أن نحوِّلَ نوعَ المعاملِ target إلى Comparable قبل أن نَستدعِيَ تابعَه compareTo. اِستخدَمنا أكثر أنواع محارف البدل عمومية، حيث يَعمَل مع أي نوع يُنفِّذ الواجهة Comparable، كما أن تابعه compareTo يَستقبِل النوع K أو أيًّا من أنواعه الأعلى supertype. يُجرَى البحث على النحو التالي: نضبط متغير الحلقة node إلى عقدة الجذر، وفي كلّ تكرارٍ، نوازن بين المفتاح target وقيمة node.key. إذا كان target أصغرَ من مفتاح العقدة الحاليّة، سننتقل إلى عقدة الابن اليسرى، أما إذا كان أكبرَ منه، سننتقل إلى عقدة الابن اليمنى، وإذا كانا متساويين، سنعيد العقدة الحاليّة. إذا وصلنا إلى قاع الشجرة دون أن نعثر على المفتاح المطلوب، فهذا يَعنِي أنه غير موجود فيها، وسنعيد في تلك الحالة القيمة الفارغة null. البحث عن القيم values كما أوضحنا في نفس المقالة المشار إليها في الأعلى، يتناسب زمن تنفيذ التابع findNode مع ارتفاع الشجرة وليس مع عدد العقد الموجودة فيها؛ وذلك لأننا غير مضطرّين للبحث في كامل الشجرة، ولكن بالنسبة للتابع containsValue، فإننا سنضطرّ للبحث بالقيم وليس المفاتيح، ولأن خاصية BST لا تُطبَّق على القيم، فإننا سنضطرّ إلى البحث في كامل الشجرة. يَستخدِم الحلُّ التالي التعاودَ recursion: public boolean containsValue(Object target) { return containsValueHelper(root, target); } private boolean containsValueHelper(Node node, Object target) { if (node == null) { return false; } if (equals(target, node.value)) { return true; } if (containsValueHelper(node.left, target)) { return true; } if (containsValueHelper(node.right, target)) { return true; } return false; } يَستقبِل التابعُ containsValue المعاملَ target، ويُمرِّره مع معاملٍ إضافيٍّ يحتوي على عقدة الجذر إلى التابع containsValueHelper. يَعمَل التابع containsValueHelper وفقًا لما يلي: تفحص تعليمة if الأولى الحالة الأساسية للتعاود: إذا كانت قيمة node مساويةً للقيمة الفارغة null، فإن التابع وصل إلى قاع الشجرة دون إيجاد القيمة المطلوبة target، ويعيد عندها القيمة false. انتبه، يعني ذلك أن القيمة target غير موجودةٍ في واحدٍ فقط من مسارات الشجرة لا في مسارات الشجرة كلّها، ولذا ما يزال من الممكن العثور عليها في مسارٍ آخر. تفحص تعليمة if الثانية ما إذا كان التابع قد وجد القيمة المطلوبة، وفي تلك الحالة، يعيد التابع القيمة true، أما إذا لم يجدها، فإنه يستمر في البحث. تَستدعِي الحالة الشرطية الثالثة التابعَ تعاوديًا لكي يبحث عن نفس القيمة، أي target، في الشجرة الفرعية اليسرى. إذا وجدها فيها، فإنه يعيد القيمة true مباشرةً دون أن يحاول البحث في الشجرة الفرعية اليمنى، أما إذا لم يجدها فيها، فإنه يستمر في البحث. تبحث الحالة الشرطية الرابعة عن القيمة المطلوبة في الشجرة الفرعية اليمنى. إذا وجدها فيها، فإنه يعيد القيمة true، أما إذا لم يجدها، فإنه يعيد القيمة false. يمرّ التابع السابق عبر كل عقدةٍ من الشجرة، ولهذا، يَستغرِق زمنًا يتناسب مع عدد العقد. تنفيذ التابع put يُعدّ التابع put أعقد قليلاً من التابع get؛ لأن عليه أن يتعامل مع حالتين: الأولى عندما يكون المفتاح المُعطَى موجودًا في الشجرة بالفعل، وينبغي عندها أن يَستبدِله ويعيد القيمة القديمة، والثانية عندما لا يكون موجودًا، وعندها ينبغي أن يُنشِئ عقدةً جديدةً ثم يضعها في المكان الصحيح. كنا قد وفّرنا الشيفرة المبدئية التالية لذلك التابع في المقالة المذكورة: public V put(K key, V value) { if (key == null) { throw new IllegalArgumentException(); } if (root == null) { root = new Node(key, value); size++; return null; } return putHelper(root, key, value); } وكان المطلوب هو إكمال متن التابع putHelper. انظر إلى شيفرته فيما يلي: private V putHelper(Node node, K key, V value) { Comparable<? super K> k = (Comparable<? super K>) key; int cmp = k.compareTo(node.key); if (cmp < 0) { if (node.left == null) { node.left = new Node(key, value); size++; return null; } else { return putHelper(node.left, key, value); } } if (cmp > 0) { if (node.right == null) { node.right = new Node(key, value); size++; return null; } else { return putHelper(node.right, key, value); } } V oldValue = node.value; node.value = value; return oldValue; } يُضبَط المعاملُ الأوّلُ node مبدئيًا إلى عقدة الجذر root، وفي كل مرةٍ نَستدعِي فيها التابع تعاوديًّا، يشير المعامل إلى شجرةٍ فرعيّةٍ مختلفةٍ. مثل التابع get، اِستخدَمنا التابع compareTo لتحديد المسار الذي سنتبعه في الشجرة. إذا تحقّق الشرط cmp < 0، يكون المفتاح المطلوب إضافته أقلّ من node.key، وعندها يكون علينا فحص الشجرة الفرعية اليسرى. هنالك حالتان: إذا كانت الشجرةُ الفرعيّةُ فارغةً، فإن node.left تحتوي على null، وعندها نكون قد وصلنا إلى قاع الشجرة دون أن نعثر على المفتاح key. في تلك الحالة، نكون قد تأكّدنا من أن المفتاح key غير موجود في الشجرة، وعرفنا المكان الذي ينبغي أن نضيف المفتاح إليه، ولذلك، نُنشِئ عقدةً جديدةً، ونضيفها كعقدةٍ ابنةٍ يسرى للعقدة node. إن لم تكن الشجرة فارغةً، نَستدعِي التابع تعاوديًّا للبحث في الشجرة الفرعية اليسرى. في المقابل، إذا تحقّق الشرط cmp > 0، يكون المفتاح المطلوب إضافته أكبر من node.key، وعندها يكون علينا فحص الشجرة الفرعية اليمنى، وسيكون علينا معالجة نفس الحالتين السابقتين. أخيرًا، إذا تحقّق الشرط cmp == 0، نكون قد عثرنا على المفتاح داخل الشجرة، وعندها، نستطيع أن نستبدله ونعيد القيمة القديمة. كتبنا هذا التابع تعاوديًّا لكي نُسهِّل من قراءته، ولكن يُمكِن كتابته أيضًا بأسلوبٍ تكراريٍّ. يُمكِنك القيام بذلك كتمرين. اجتياز في الترتيب In-order كنا قد طلبنا منك كتابة التابع keySet لكي يعيد مجموعةً من النوع Set تحتوي على مفاتيح الشجرة مُرتَّبة تصاعديًّا. لا يعيد هذا التابع في التنفيذات الأخرى من الواجهة Map المفاتيح وفقًا لأيّ ترتيب، ولكن لأن هذا التنفيذَ يتمتع بالبساطة والكفاءة، فإنه يَسمَح لنا بترتيب المفاتيح، وعلينا أن نَستفيد من ذلك. انظر إلى شيفرة التابع فيما يلي: public Set<K> keySet() { Set<K> set = new LinkedHashSet<K>(); addInOrder(root, set); return set; } private void addInOrder(Node node, Set<K> set) { if (node == null) return; addInOrder(node.left, set); set.add(node.key); addInOrder(node.right, set); } كما ترى فقد أنشأنا قيمةً من النوع LinkedHashSet في التابع keySet. يُنفِّذ ذلك النوع الواجهة Set ويحافظ على ترتيب العناصر (بخلاف معظم تنفيذات تلك الواجهة). نَستدعِي بعد ذلك التابع addInOrder لاجتياز الشجرة. يشير المعامل الأول node مبدئيًّا إلى جذر الشجرة، ونَستخدِمه -كما يُفترَض أن تتوقَّع- لاجتياز الشجرة تعاوديًا. يجتاز التابع addInOrder الشجرة بأسلوب "في الترتيب" المعروف. إذا كانت العقدة node فارغةً، يَعنِي ذلك أن الشجرةَ الفرعيةَ فارغةٌ، وعندها يعود التابع دون إضافة أيّ شيءٍ إلى المجموعة set، أما إذا لم تكن فارغةً، نقوم بما يلي: نجتاز الشجرة الفرعية اليسرى بالترتيب. نضيف node.key. نجتاز الشجرة الفرعية اليمنى بالترتيب. تذكّر أن خاصية BST تضمن أن تكون جميع العقد الموجودة في الشجرة الفرعية اليسرى أقلَّ من node.key وأن تكون جميع العقد الموجودة في الشجرة الفرعية اليمنى أكبرَ منه، أي أننا نضيف node.key إلى الترتيب الصحيح. بتطبيق نفس المبدأ تعاوديًا، نستنتج أن عناصر الشجرة الفرعية اليسرى واليمنى مُرتَّبة، كما أن الحالة الأساسية صحيحة: إذا كانت الشجرة الفرعية فارغةً، فإننا لا نضيف أيّة مفاتيح. يَعنِي ما سبق أن هذا التابعَ يضيف جميع المفاتيح وفقًا لترتيبها الصحيح. ولأن هذا التابع يمرّ عبر كل عقدةٍ ضمن الشجرة مثله مثل التابع containsValue، فإنه يَستغرِق زمنًا يتناسب مع n. التوابع اللوغاريتمية يَستغرِق التابعان get و put في الصنف MyTreeMap زمنًا يتناسب مع ارتفاع الشجرة h. أوضحنا في المقالة المشار إليها أنه إذا كانت الشجرة ممتلئةً أي كان كل مستوىً منها يحتوي على الحد الأقصى من عدد العقد المسموح به، فإن ارتفاع تلك الشجرة يكون متناسبًا مع log(n). نفترض الآن أن التابعين get و set يستغرقان زمنًا لوغاريتميًا، أي زمنًا يتناسب مع log(n)، مع أننا لا نَضمَن أن تكون الشجرة ممتلئةً دائماً. يعتمد شكل الشجرة في العموم على المفاتيح وعلى الترتيب الذي تُضاف به. سنختبر التنفيذ الذي كتبناه بمجموعتي بيانات لكي نرى كيف يعمل. المجموعة الأولى عبارةٌ عن قائمةٍ تحتوي على سلاسلَ نصيّةٍ عشوائيّةٍ، والثانية عبارةٌ عن قائمةٍ تحتوي على علاماتٍ زمنيّةٍ timestamp مُرتَّبةٍ تصاعديًّا. تُولِّد الشيفرةُ التاليةُ السلاسلَ النصيّة العشوائية: Map<String, Integer> map = new MyTreeMap<String, Integer>(); for (int i=0; i<n; i++) { String uuid = UUID.randomUUID().toString(); map.put(uuid, 0); } يقع تعريف الصنف UUID ضمن حزمة java.util، ويُمكِنه أن يُولِّد مُعرِّف هويةٍ فريدًا عموميًا universally unique identifier بأسلوبٍ عشوائي. تُعدّ تلك المُعرّفات ذاتَ فائدةٍ كبيرةٍ في مختلِف أنواع التطبيقات، ولكننا سنَستخدِمها في هذا المثال كطريقةٍ سهلةٍ لتوليد سلاسلَ نصيّةٍ عشوائيّةٍ. شغّلنا الشيفرة التالية مع n=16384 وحسبنا زمن التنفيذ وارتفاع الشجرةِ النهائيّ، وحصلنا على الخرج التالي: Time in milliseconds = 151 Final size of MyTreeMap = 16384 log base 2 of size of MyTreeMap = 14.0 Final height of MyTreeMap = 33 أضفنا أيضًا قيمة اللوغاريتم للأساس 2 إلى الخريطة لكي نرى طول الشجرة إذا كانت ممتلئة. تشير النتيجة إلى أن شجرةً ممتلئةً بارتفاعٍ يساوي 14 تحتوي على 16,384 عقدة. في الواقع، ارتفاع شجرة السلاسل النصية العشوائية الفعلي هو 33، وهو أكبر من الحد الأدنى النظري ولكن ليس بشكل كبير. لكي نعثر على مفتاحٍ ضمن تجميعةٍ مكونةٍ من 16,384 عقدةً، سنضطرّ لإجراء 33 موازنةً، أي أسرع بـ 500 مرةً تقريبًا من البحث الخطي linear search. يُعدّ هذا الأداء نموذجيًا للسلاسل النصيّة العشوائيّة والمفاتيح الأخرى التي لا تضاف وفقًا لأيّ ترتيب. رغم أن ارتفاع الشجرة النهائيّ يصل إلى ضعف الحدَ النظريَ الأدنى أو ثلاثةِ أضعافِه، فهو ما يزال متناسبًا مع log(n)، أي أقل بكثير من n، حيث تنمو قيمة log(n) ببطءٍ مع زيادة قيمة n لدرجةٍ يَصعُب معها التمييز بين الزمن الثابت والزمن اللوغاريتمي عمليًا. في المقابل، لا تَعمَل أشجار البحث الثنائية بهذه الكفاءة دائمًا. لنرى ما قد يحدث عند إضافة المفاتيح بترتيبٍ تصاعديٍّ. يَستخدِم المثال التالي علاماتٍ زمنيةً -بوحدة النانو ثانية- كمفاتيح: MyTreeMap<String, Integer> map = new MyTreeMap<String, Integer>(); for (int i=0; i<n; i++) { String timestamp = Long.toString(System.nanoTime()); map.put(timestamp, 0); } يعيد System.nanoTime عددًا صحيحًا من النوع long يشير إلى الزمن المُنقضِي بوحدة النانو ثانية. نحصل على عددٍ أكبرَ قليلًا في كلّ مرةٍ نَستدعيه فيها. عندما نُحوِّل تلك العلامات الزمنية إلى سلاسلَ نصيّةٍ، فإنها تكون مُرتَّبة أبجديًّا. لنرى ما نحصل عليه عند التشغيل: Time in milliseconds = 1158 Final size of MyTreeMap = 16384 log base 2 of size of MyTreeMap = 14.0 Final height of MyTreeMap = 16384 يتجاوز زمن التشغيل في هذه الحالة سبعةَ أضعاف زمن التشغيل في الحالة السابقة. إذا كنت تتساءل عن السبب، فألق نظرةً على ارتفاع الشجرةِ النهائيّ 16384. إذا أمعنت النظر في الطريقة التي يَعمَل بها التابع put، فقد تفهم ما يحدث: ففي كل مرةٍ نضيف فيها مفتاحًا جديدًا، فإنه يكون أكبر من جميع المفاتيح الموجودة في الشجرة، وبالتالي، نضطرّ دائمًا لاختيار الشجرة الفرعية اليمنى، ونضيف دائمًا العقدة الجديدة كعقدة ابن يمنى للعقدة الواقعة على أقصى اليمين. نحصل بذلك على شجرة غير متزنة unbalanced تحتوي على عقدٍ أبناء يمنى فقط. يتناسب ارتفاع تلك الشجرة مع n وليس log(n)، ولذلك يصبح أداء التابعين get و set خطيًا لا لوغاريتميًا. تَعرِض الصورة السابقة مثالًا عن شجرتين إحداهما متزنة والأخرى غير متزنة. يُمكِننا أن نرى أن ارتفاع الشجرة المتزنة يساوي 4 وعدد العقد الكلية يساوي 24−1 أي 15. تحتوي الشجرة غير المتزنة على نفس عدد العقد، ولكن ارتفاعها يساوي 15. الأشجار المتزنة ذاتيا Self-balancing trees هناك حلّان محتملان لتلك المشكلة: يُمكِننا أن نتجنّب إضافة المفاتيح إلى الخريطة بالترتيب، ولكن هذا الحل ليس ممكنًا دائمًا. يُمكِننا أن نُنشِئ شجرةً قادرةً على التعامل مع المفاتيح المرتّبة تعاملًا أفضل. يبدو الحل الثاني أفضل، وتتوفّر طرائقُ عديدةٌ لتنفيذه. يُمكِننا مثلًا أن نُعدّل التابع put لكي نجعله يفحص ما إذا كانت الشجرة قد أصبحت غير متزنة، وعندها، يعيد ترتيب العقد. يُطلَق على الأشجار التي تتميز بتلك المقدرة اسم "الأشجار المتزنة ذاتيًا"، ومن أشهرها شجرة AVL (اختصار Adelson-Velskii Tree حيث إن Adelson و Velskii هما مبتكرا هذه الشجرة)، وشجرة red-black التي يَستخدِمها صنف الجافا TreeMap. إذا استخدمنا الصنف TreeMap بدلًا من الصنف MyTreeMap في الشيفرة السابقة، سيصبح زمن تشغيل مثالِ السلاسل النصية ومثالِ العلامات الزمنية هو نفسه، بل في الحقيقة، سيكون مثال العلامات الزمنية أسرع على الرغم من أن المفاتيح مُرتَّبة؛ لأنه يَستغرِق وقتًا أقل لحساب شيفرة التعمية hash. نستخلص مما سبق أن أشجار البحث الثنائية قادرةٌ على تنفيذ التابعين get و put بزمن لوغاريتمي بشرط إضافة المفاتيح إليها وفقًا لترتيبٍ يحافظ على اتزانها بشكلٍ كافٍ. في المقابل، تتجنَّب الأشجار المتزنة ذاتيًا تلك المشكلة بإنجاز بعض العمل الإضافي في كلّ مرةٍ يُضاف فيها مفتاح جديد. يُمكِنك قراءة المزيد عن الأشجار المتزنة ذاتيًّا. تمرين إضافي لم نُنفِّذ التابع remove في ذاك التمرين، ولكن يُمكِنك أن تُجرِّب كتابته الآن. إذا حذفت عقدةً من وسط الشجرة، ستضطرّ إلى إعادة ترتيب العقد المتبقية لكي تحافظ على خاصية BST. تُعدّ عمليتا حذف عقدةٍ وإعادة الشجرة إلى الاتزان عمليتين متشابهتين، لذا إذا أتممت هذا التمرين، ستفهم طريقة عمل الأشجار المتزنة ذاتيًا فهماً أعمق. ترجمة -بتصرّف- للفصل Chapter 13: Binary search tree من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: استخدام قاعدة بيانات Redis لحفظ البيانات المقال السابق: تحليل زمن تشغيل الخرائط المنفذة باستخدام شجرة بحث ثنائية TreeMap في جافا تحسين أداء الخرائط المنفذة باستخدام التعمية HashMap في جافا تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا استخدام خريطة ومجموعة لبناء مفهرس Indexer
-
لا تُعدّ الشبكات -بقدر ما يَهُمّ البرامج- أكثر من مجرد مصدرٍ يُمكِن قراءة البيانات منه، أو مقصدٍ يُمكِن إرسال البيانات إليه. يُسطِّح ذلك الأمور إلى درجةٍ كبيرة؛ فليس التعامل مع الشبكات بالتأكيد بنفس سهولة التعامل مع الملفات مثلًا، وتُمكِّنك لغة جافا مع ذلك من إجراء الاتصالات الشبكية باستخدام مجاري تدفق streams الدْخل والخرج بنفس الطريقة التي تَستخدِمها بها للتواصل مع المُستخدِم أو لمعالجة الملفات. بالرغم من ذلك، تنطوي عملية إجراء اتصال شبكي بين حاسوبين على الكثير من التعقيدات؛ حيث ينبغي أن يتفقا بطريقةٍ ما على فتح اتصالٍ بينهما. بالإضافة إلى ذلك، إذا كان كلُ حاسوبٍ قادرًا على إرسال البيانات إلى الحاسوب الآخر، تُصبِح مزامنة التواصل بينهما مشكلةً أخرى، ولكن تُعدّ الأساسيات في العموم هي نفسها. تُعدّ java.net إحدى حزم packages جافا القياسية، حيث تتضمَّن عدَّة أصنافٍ للتعامل مع الشبكات، كما تدعم طريقتين مختلفتين لإجراء عمليات الدخل والخرج خلال الشبكة. تعتمد الطريقة الأولى عالية المستوى high level على شبكة الإنترنت العالمية World Wide Web، وتُوفِّر إمكانياتٍ للاتصال الشبكي مماثلةً لتلك الإمكانيات التي يَستخدِمها متصفح الإنترنت عند تحميله للصفحات. يُعدّ java.net.URL و java.net.URLConnection الصنفين الأساسين المُستخدَمين مع هذا النوع من الشبكات. يُمثِّل أي كائنٍ من النوع URL تمثيلًا مجرّدًا لمُحدِّد مورد مُوحَّد Universal Resource Locator؛ فقد يكون عنوانًا لمستند HTML، أو لأي موردٍ آخر؛ بينما يُمثِّل أي كائنٍ من النوع URLConnection اتصالًا شبكيًا مع إحدى تلك الموارد. من الجهة الأخرى، تَنظر الطريقة الثانية الأكثر عمومية وأهمية للشبكة بمستوى منخفض قليلًا low level؛ حيث تعتمد على فكرة المقابس socket المُستخدَمة لإنشاء اتصالٍ مع برنامجٍ آخر خلال الشبكة. يشتمل الاتصال عبر الشبكة على مقبسين، واحدٌ في كل طرف من طرفي الاتصال، وتَستخدِم جافا الصنف java.net.Socket لتمثيل المقابس المُستخدَمة بالاتصال الشبكي. أُخِذَت كلمة مِقبس من التصور المادي لتوصيل الحاسوب بسلكٍ بهدف ربطه بشبكة، ولكن ما نعنيه بالمقبس هنا أنه كائنٌ ينتمي إلى الصنف Socket. يَستطيع أي برنامجٍ أن يحتوي على مجموعةٍ من المقابس بنفس الوقت؛ بحيث يتَصِل كُلٌ منها ببرنامجٍ آخرٍ مُشغَّلٍ على حاسوبٍ آخر ضمن الشبكة، أو ربما على نفس الحاسوب. تَعتمِد جميع تلك الاتصالات على الاتصال الشبكي المادي نفسه. يتناول هذا مقال مقدمةً مُختصرة عن أساسيات أصناف الشبكات، كما يشرح علاقتها بمجاري الدْخَل والخرج. محددات الموارد والصنفان URL و URLConnection يُستخدَم الصنف URL لتمثيل الموارد resources بشبكة الويب العالمية World Wide Web، حيث يَملُك كل موردٍ منها عنوانًا يُميّزها، والذي يحتوي على معلوماتٍ كافية تُمكِّن متصفح الويب من العثور على المورد على الشبكة واسترجاعه. يُعرَف هذا العنوان باسم "محدِّد الموارد المُوحَّد universal resource locator - URL"، كما يُمكِنه في الواقع أن يشير إلى مواردٍ من مصادر أخرى غير الويب، فقد يُشير مثلًا إلى إحدى ملفات الحاسوب. يُمثِّل أي كائنٍ ينتمي إلى الصنف URL عنوانًا معينًا، وبمجرد حصولك على واحدٍ من تلك الكائنات، يُمكِنك إنشاء كائنٍ من الصنف URLConnection للاتصال مع المورد الموجود بذلك العنوان. يُكْتَب محدِّد الموارد المُوحَّد عادةً بهيئة سلسلةٍ نصية، مثل "http://math.hws.edu/eck/index.html"، ولكن هناك أيضًا محدِّدات مواردٍ نسبية relative url، والتي يُمكِنها تخصيص موقع موردٍ معين بالنسبة لموقع موردٍ آخر، والذي يَعمَل في تلك الحالة كأنه أساسٌ أو سياقٌ لمحدِّد المورد النسبي؛ فإذا كان السياق هو "http://math.hws.edu/eck/" مثلًا، فسيُشير المورد النسبي غير الكامل "index.html" في تلك الحالة إلى الآتي: "http://math.hws.edu/eck/index.html" لاحِظ أن كائنات الصنف URL ليست مجرد سلاسلٍ نصية، ولكن يُمكِن إنشاؤها من التمثيل النصي لمحدِّد موردٍ موحَّد، كما يُمكِن إنشاء تلك الكائنات بالاستعانة بكائن URL آخر يُوفِّر سياقًا معينًا مع سلسلةٍ نصيةٍ تُوفِّر مُحدِّد المورد النسبي لذلك السياق. انظر تعريف البناة constructors المُعرَّفة بالصنف: public URL(String urlName) throws MalformedURLException و public URL(URL context, String relativeName) throws MalformedURLException حيث تُبلِّغ تلك البناة عن استثناء exception من النوع MalformedURLException، إذا لم تكن السلاسل النصية المُمرَّرة إليها مُمثِلةً لمحدِّدات مواردٍ مُوحَّدةٍ سليمة. لاحِظ أن الصنف MalformedURLException هو صنفٌ فرعيٌ من الصنف IOException، أي أنه يتطلَّب معالجةً إلزاميةً للاستثناءات. بمجرد حصولنا على كائن URL سليم، يُمكِننا استدعاء تابِعه openConnection() لإجراء اتصالٍ معه؛ حيث يُعيد هذا التابع كائنًا من النوع URLConnection، والذي يُمكِننا استدعاء تابعه getInputStream() لإنشاء كائنٍ من النوع InputStream، ونَستطيع بذلك قراءة بيانات المورد الذي يُمثِّله الكائن. انظر الشيفرة التالية: URL url = new URL(urlAddressString); URLConnection connection = url.openConnection(); InputStream in = connection.getInputStream(); قد يُبلِّغ التابعان openConnection() و getInputStream() عن استثناؤات من النوع IOException. بمجرد إنشاء كائن الصنف InputStream، يُمكِننا أن نقرأ بياناته كما ناقشنا بالأقسام السابقة، بما في ذلك تضمينه داخل مجاري الدخل input stream من أنواعٍ أخرى، مثل BufferedReader أو Scanner. قد تؤدي قراءة بيانات المجرى إلى حدوث استثناءات بالتأكيد. يتضمَّن الصنف URLConnection توابع نسخ instance methods أخرى مفيدة؛ حيث يُعيد التابع getContentType() مثلًا سلسلةً نصيةً من النوع String تَصِف نوع المعلومات الموجودة بالمورد الذي يُمثِله كائن الصنف URL، كما يُمكِنه إعادة القيمة null إذا لم تكن نوعية المعلومات معروفةً بعد، أو لم يكن تحديد نوعها ممكنًا؛ أي قد لا نتمكَّن من معرفة نوع المعلومات حتى نُنشِئ مجرى المْدْخَلات باستدعاء التابع getInputStream() ثم التابع getContentType()، حيث يُعيد هذا التابع سلسلةً نصيةً بصيغةٍ تُعرَف باسم نوع الوسيط "mime type"، مثل "text/plain" و "text/html" و "image/jpeg" و "image/png" وغيرها. تتكوَّن جميع أنواع الوسائط من جزئين: نوعٌ عام، مثل "text" أو "image"، ونوعٌ أكثر تحديدًا من النوع العام، مثل "html" أو "png"؛ فإذا كنت مهتمًا بالبيانات النصية فقط مثلًا، يُمكِنك فحص فيما إذا بدأت السلسلة النصية المُعادة من التابع getContentType() بكلمة "text". كان الهدف الأساسي من أنواع الوسائط هو مجرد وصف محتويات رسائل البريد الإلكتروني؛ فالاسم "mime" هو أساسًا اختصارٌ لعبارة "Multipurpose Internet Mail Extensions"، ولكنها مُستخدَمةٌ الآن على نطاقٍ واسع لتحديد نوع المعلومات الموجودة بملفٍ أو بموردٍ آخر عمومًا. لنناقش الآن مثالًا قصيرًا على استخدام كائنٍ من النوع URL لقراءة البيانات الموجودة بمُحدِّد موردٍ معين، حيث يُنشِئ البرنامج الفرعي التالي اتصالًا مع المورد الذي يُمثِّله الكائن، ثم يَتأكَّد من أن البيانات التي يُشير إليها مُحدِّد المورد من النوع النصي، ويَنسَخ بعد ذلك النص إلى الشاشة. قد تُبلِّغ الكثير من العمليات المُضمَّنة بالبرنامج عن استثناءات، ولذلك أضفنا عبارة "throws IOException" أثناء التصريح عن البرنامج الفرعي؛ لنترك القرار للبرنامج المُستدعِي باختيار ما ينبغي فعله عند حدوث خطأٍ معين: static void readTextFromURL( String urlString ) throws IOException { // 1 URL url = new URL(urlString); URLConnection connection = url.openConnection(); InputStream urlData = connection.getInputStream(); // 2 String contentType = connection.getContentType(); System.out.println("Stream opened with content type: " + contentType); System.out.println(); if (contentType == null || contentType.startsWith("text") == false) throw new IOException("URL does not seem to refer to a text file."); System.out.println("Fetching context from " + urlString + " ..."); System.out.println(); // 3 BufferedReader in; // للقراءة من مجرى الدخل الخاص بالاتصال in = new BufferedReader( new InputStreamReader(urlData) ); while (true) { String line = in.readLine(); if (line == null) break; System.out.println(line); } in.close(); } // end readTextFromURL() حيث: [1]: افتح اتصالًا مع مُحدِّد مورد موحَّد واحصل على مجرى دخل لقراءة البيانات منه. [2]: اِفحص فيما إذا كانت المحتويات من النوع النصي. [3]: اِنسَخ الأسطر النصية من مجرى الدخل إلى الشاشة حتى تصِل إلى نهاية الملف، أو حتى يقع خطأ. يَستخدِم البرنامج FetchURL.java البرنامج الفرعي المُعرَّف بالأعلى. بإمكانك تخصيص مُحدِّد المورد المطلوب أثناء تشغيل البرنامج من خلال سطر الأوامر؛ وإذا لم تُخصِّصه، فسيطلُب منك البرنامج تخصيصه. يَسمَح البرنامج بمُحدِّدات الموارد البادئة بكلمة "http://" أو "https://" إذا كان المُحدِّد يُشير إلى مورد بشبكة الإنترنت؛ أو تلك البادئة بكلمة "file://" إذا كان المُحدِّد يِشير إلى إحدى ملفات حاسوبك؛ أو تلك البادئة بكلمة "ftp://" إذا كان المُحدِّد يَستخدِم بروتوكول نقل الملفات File Transfer Protocol. إذا لم يبدأ بأي من تلك الكلمات، فسيُضيف كلمة "http://" تلقائيًا إلى بداية مُحدِّد المورد. يُمكِنك أن تُجرِّب مثلًا مُحدِّد المورد "math.hws.edu/javanotes" لاسترجاع الصفحة الرئيسية لهذا الكتاب من موقعه الإلكتروني، كما يُمكِنك تجريبه أيضًا مع بعض المُدْخَلات غير السليمة، لترى نوعية الأخطاء المختلفة التي قد تَحصُل عليها. بروتوكول TCP/IP والخوادم والعملاء يعتمد نقل المعلومات عبر شبكة الانترنت على بروتوكولين، هما بروتوكول التحكم بالنقل Transmission Control Protocol وبروتوكول الإنترنت Internet Protocol، ويُشار إليهما مجتمعين باسم TCP/IP. هناك أيضًا بروتوكولٌ أبسط يُسمَى UDP؛ حيث يُمكِن اِستخدَامه بدلًا من TCP بتطبيقاتٍ معينة، وهو مدعومٌ من قِبل جافا. ولكن سنكتفي هنا بمناقشة TCP/IP الذي يُوفِّر نقلًا موثوقًا ثنائي الاتجاه للمعلومات بين حواسيب الشبكات. لكي يتمكَّن برنامجان من تبادل المعلومات عبر بروتوكول TCP/IP، ينبغي أن يُنشِئ كلٌ منهما مقبسًا socket، كما ينبغي أن يكون المقبسان متصلين ببعضهما. تُنقَل المعلومات بينهما بعد إنشاء هذا الاتصال باستخدام مجاري تدفق streams دْخَل وخَرج. يَملُك كل برنامجٍ منهما مجريين للمُدخلات وللمُخرجات، ويَستطيع أحدهما مثلًا إرسال بعض البيانات إلى مجرى الخرج الخاص به، وستُنقَل إلى الحاسوب الآخر، ومنه إلى مجرى الدْخَل الخاص بالبرنامج الموجود على الطرف الآخر من الاتصال الشبكي. عندما يقرأ هذا البرنامج البيانات من مجرى الدْخَل الخاص به، يكون قد تَسلَّم بذلك البيانات التي أُرسلَت إليه عبر الشبكة. تَكْمُن الصعوبة في إنشاء الاتصال الشبكي ذاته من الأساس، حيث تنطوي العملية على مقبسين. يجب أن يُنشِئ أحد البرنامجين مقبسًا socket ويتركه ينتظر طلب اتصالٍ من المقبس الآخر، ويُقال أن المقبس المُنتظِر يَستمِع للاتصال. ويُنشِئ البرنامج الآخر من الجهة الأخرى من الاتصال المُفترَض مقبسًا آخرًا ليُرسِل طلب اتصالٍ إلى المقبس المُستمِع، والذي يَستجيب بدوره عندما يَتَسلّم طلب الاتصال، وبذلك يكون الاتصال قد اُنشِئ. بمجرد إنشاء الاتصال، يستطيع كل برنامج الحصول على مجريين للدخل والخرج لإرسال البيانات عبر الاتصال، ويستمر الاتصال بين البرنامجين من خلال تلك المجاري حتى يُقرِّر أحدهما إغلاقه. يُطلَق على البرنامج المُنشِئ لمقبس الاستماع listening socket اسم "الخادم server"؛ بينما يُطلَق على المقبس اسم "مقبس الخادم server socket". في المقابل، يُطلَق على البرنامج الذي يَتصِل بالخادم اسم "العميل client"؛ بينما يُطلَق على المقبس الذي يَستخدِمه لإجراء الاتصال اسم "مقبس العميل client socket". تكمن الفكرة ببساطة في أن الخادم يَقبُع بمكانٍ ما داخل الشبكة منتظرًا طلبات الاتصال من العملاء. يُمكِننا إذًا أن نفكر بالخادم وكأنه يُقدِم نوعًا معينًا من الخدمات، في حين يحاول العميل الوصول إلى تلك الخدمة عن طريق الاتصال بالخادم. يُعرَف ذلك باسم نموذج العميل / الخادم client/server model لنقل المعلومات عبر الشبكة. يستطيع الخادم أيضًا وبالكثير من التطبيقات أن يُوفِّر اتصالات لأكثر من عميلٍ واحدٍ بنفس الوقت؛ فعندما يَتصِّل عميلٌ معين بمقبس الاستماع الخاص بخادمٍ من هذا النوع، لا يتوقف المقبس عن الاستماع، وإنما يستمر بالاستماع إلى أي طلبات اتصالٍ إضافية بنفس الوقت الذي يَخدِّم خلاله العميل الأول. يتطلَّب ذلك استخدام الخيوط threads التي سنناقش طريقة عملها بالمقال التالي. يَستخدِم الصنف URL -الذي نُوقِش ببداية هذا المقال- مقبس عميلٍ لإجراء أي اتصالٍ ضروري عبر الشبكة؛ بينما يتواجد في الجهة الأخرى لذلك الاتصال برنامج خادم لاستقبال طلب الاتصال من كائن الصنف URL، ويقرأ طلبه بخصوص إحدى الملفات الموجودة بحاسوب الخادم، ثم يَستجيب للطلب بإرسال محتويات الملف المطلوب إلى ذلك الكائن عبر الشبكة. وأخيرًا، يُغلِق الخادم الاتصال بعد انتهائه من نقل البيانات. يجب أن يَجِد برنامج العميل طريقةً ما لتخصيص أي حاسوبٍ من ضمن كل تلك الحواسيب الموجودة بالشبكة يريد الاتصال به. في الحقيقة، يَملُك كل حاسوبٍ بشبكة الإنترنت عنوان IP يُميّزه عن غيره؛ كما يُمكِن الإشارة إلى كثيرٍ من الحواسيب باستخدام أسماء المجال domain names، مثل "math.hws.edu" أو "http://www.whitehouse.gov" (انظر مقال "الإنترنت وما بعده وعلاقته بجافا"). تتكوَّن عناوين IP (أو IPv4) التقليدية من أعدادٍ صحيحةٍ من "32 بت"، وتُكْتَب عادةً بصيغةٍ عشريةٍ مُنقطَّة، مثل "64.89.144.237"؛ بحيث يُمثِّل كل عددٍ من الأعداد الأربعة ضمن ذلك العنوان عددًا صحيحًا من "8 بتات" بنطاقٍ يتراوح من "0" إلى "255". يتوفَّر الآن إصدارٌ أحدث من بروتوكول الإنترنت هو IPv6؛ حيث تتكوَّن عناوينه من أعدادٍ صحيحة من "128 بت"، وتُكْتَب عادةً بصيغةٍ ست عشريّة hexadecimal، ويَستخدِم نقطتين وربما بعض المعلومات الإضافية الآخرى. ما يزال الإصدار الأحدث IPv6 نادرًا من الناحية العملية. يُمكِن لأي حاسوب أن يمتلك مجموعةً من عناوين IP، كما قد يمتلك عناوين IPv4 و IPv6، والتي يُعرَف إحداها عادةً باسم "عنوان الاسترجاع loopback"؛ حيث تستخدِم البرامج عنوان الاسترجاع، إذا كانت تريد التواصل مع برامجٍ أخرى على نفس الحاسوب. يَملُك عنوان الاسترجاع عنوان IPv4 هو "127.0.0.1"، ويُمكِننا الإشارة إليه باستخدام اسم المجال "localhost". بالإضافة إلى ذلك، قد يكون هناك عناوين IP أخرى مرتبطة باتصالٍ شبكي مادي، كما يحتوي عادةً أي حاسوب على أداةٍ لعرض عناوين IP الموجودة به. كتب المؤلف البرنامج ShowMyNetwork.java ليَفعَل الشيء نفسه، وحَصَل على الخرج التالي بعد أن شغَّله على حاسوبه: en1 : /192.168.1.47 /fe80:0:0:0:211:24ff:fe9c:5271%5 lo0 : /127.0.0.1 /fe80:0:0:0:0:0:0:1%1 /0:0:0:0:0:0:0:1%0 تُشيِر أول كلمة بكل سطرٍ منهما إلى اسم بطاقة الشبكة network interface، والتي يَفهَم معناها نظام التشغيل فقط، كما يحتوي كل سطرٍ على عناوين IP الخاصة بالبطاقة؛ حيث تُشير بطاقة "lo0" على سبيل المثال إلى عنوان الاسترجاع loopback الذي يَملُك عادةً عنوان IPv4 هو "127.0.0.1". في الحقيقة، إن العدد "192.168.1.47" هو الأكثر أهميةً من بين كل تلك الأعداد؛ فهو يُمثِّل عنوان IPv4 المُستخدَم للاتصالات عبر الشبكة؛ أما الأعداد الآخرى فهي عناوين IPv6. ملاحظة: لا تُعدّ الخطوط المائلة ببداية كل عنوان جزءًا فعليًا منه. قد يحتوي الحاسوب على عدّة برامجٍ تُجرِي اتصالات شبكية بنفس الوقت، أو على برنامجٍ واحد يتبادل المعلومات مع مجموعةٍ من الحواسيب الأخرى؛ حيث يملك كلُّ اتصالٍ شبكي رقم مَنفَذ port number إلى جانب عنوان IP. يتكوَّن رقم المَنفَذ ببساطة من عددٍ صحيحٍ موجبٍ من "16 بت". لا يَستمِع الخادم إلى الاتصالات في العموم، وإنما يَستمِع إلى الاتصالات الواقعة برقم منفذٍ معين، ولذلك يجب أن يَعرِف أي عميلٍ مُحتمَل لخادمٍ معين كُلًا من عنوان الإنترنت (أو اسم المجال) الخاص بالحاسوب الذي يَعمَل عليه ذلك الخادم، وكذلك رقم المَنفَذ الذي يَستمِع إليه الخادم. تَستمِع خوادم الإنترنت عمومًا إلى الاتصالات الواقعة برقم المنفذ "80"، كما تحدث بعض خدمات الويب القياسية الأخرى بأرقام منافذٍ قياسيةٍ أخرى. تُعدّ أرقام المنافذ الأقل من "1024" ضمن أرقام المنافذ القياسية، وهي في الواقع محجوزةٌ لخدماتٍ معينة، ولذلك إذا أنشأت خادمك الخاص، ينبغي أن تَستخدِم رقم منفذٍ أكبر من "1024". المقابس والصنف Socket تُوفِّر حزمة java.net الصنفين ServerSocket و Socket لتنفيذ اتصالات بروتوكول TCP/IP؛ حيث يُمثِل كائنٌ من الصنف ServerSocket مقبس استماع listening socket ينتظر طلبات الاتصال من العملاء؛ بينما يُمثِّل كائنٌ من الصنف Socket طرفًا واحدًا من اتصالٍ فعلي، حيث يمكن أن يُمثِّل عميلًا قد أرسل طلبًا إلى خادم. يستطيع الخادم إنشاء كائنٍ من هذا الصنف -أي Socket-، ويطلب منه معالجة طلب اتصالٍ من عميلٍ معين، ويَسمَح ذلك للخادم بإنشاء عدة كائناتٍ من ذلك الصنف لمعالجة اتصالات عديدة. في المقابل، لا تُشارِك كائنات الصنف ServerSocket بالاتصالات، فهي فقط تستمع إلى طلبات الاتصال، وُتنشِئ كائناتٍ من الصنف Socket لمعالجة الاتصالات الفعلية. عندما تُنشِئ كائنًا من الصنف ServerSocket، عليك أن تُخصِّص رقم المَنفَذ port number الذي سيستمع إليه الخادم. انظر الباني constructor الخاص بهذا الصنف: public ServerSocket(int port) throws IOException يجب أن يقع رقم المَنفَذ ضمن نطاقٍ يتراوح من "0" إلى "65535"، كما يجب أن يكون أكبر من "1024". يُبلِّغ الباني عن استثناء من النوع SecurityException، إذا كان رقم المَنفَذ المُخصَّص أقل من "1024"؛ كما يُبلِّغ عن استثناء من النوع IOException، إذا كان رقم المَنفَذ المُحدَّد مُستخدَمًا بالفعل. يُمكِنك مع ذلك تمرير القيمة "0" مثل معاملٍ للتابع لتخبره بأن الخادم بإمكانه الاستماع إلى أي رقم مَنفَذٍ متاح. بمجرّد إنشاء كائنٍ من الصنف ServerSocket، فسيبدأ بالاستماع إلى طلبات الاتصال من العملاء. يَستقبِل التابع accept() المُعرَّف بالصنف ServerSocket طلبًا، ثم يُنشِئ اتصالًا مع العميل، ويعيد كائنًا من النوع Socket يُمكِن اِستخدَامه للاتصال مع العميل. يُعرَّف التابع accept() على النحو التالي: public Socket accept() throws IOException عندما تَستدعِي التابع accept()، فإنه لا يعيد قيمته حتى يتسلَّم طلب اتصال، أو حتى يقع خطأ ما، ولذلك يُعدّ تابعًا مُعطِّلًا block أثناء انتظاره للطلب؛ لأن البرنامج -أو بتعبير أدق الخيط thread الذي اِستدعَى التابع- لا يستطيع فعل ذلك بأي شيءٍ آخر، بينما تستطيع الخيوط الآخرى أن تُكمِل عملها بصورةٍ طبيعية. يُمكِنك استدعاء accept() مرةً بعد أخرى لتَستقبِل عدة طلبات اتصال، وسيستمر كائن الصنف ServerSocket بالاستماع إلى طلبات الاتصال إلى أن يُغلَق باستدعاء تابعه close()، أو إلى أن يَحدُث خطأ، أو أن ينتهي البرنامج بطريقةٍ ما. لنفترض أننا نريد إنشاء خادمٍ يَستمِع إلى رقم المَنفَذ "1728"، ويستمر باستقبال طلبات الاتصال طوال فترة تشغيل البرنامج. إذا كان provideService(Socket) تابعًا مسؤولًا عن معالجة اتصالٍ مع عميلٍ واحد، يُمكِننا أن نَكْتُب برنامج الخادم التالي: try { ServerSocket server = new ServerSocket(1728); while (true) { Socket connection = server.accept(); provideService(connection); } } catch (IOException e) { System.out.println("Server shut down with error: " + e); } من جهة العميل، يُمكِننا إنشاء كائنٍ من النوع Socket باستخدام أحد البُناة constructors المُعرَّفة بالصنف Socket. يُمكِننا استخدام الباني التالي لنتمكَّن من الاتصال بخادمٍ معين نَعرِف الحاسوب الذي يَعمَل عليه وكذلك رقم المَنفَذ الذي يَستمِع إليه: public Socket(String computer, int port) throws IOException بإمكاننا تمرير اسم المجال domain name أو عنوان IP على أنه قيمةٌ للمعامل الأول بالباني السابق. سيُعطِّل block هذا الباني التنفيذ حتى يُنشَئ الاتصال أو حتى يَحدُث خطأً. إذا كان لدينا مقبسٌ مُتصِلٌ بغض النظر عن طريقة إنشائه، يُمكِننا استدعاء أيٍّ من توابع الصنف Socket، مثل التابعين getInputStream() و getOutputStream() الذين يعيدان كائناتٍ من النوع InputStream و OutputStream على الترتيب، وبذلك، نكون قد حَصلنا على مجاري تدفق بإمكاننا اِستخدَامها لنقل المعلومات عبر هذا الاتصال. تُوضِح الشيفرة التالية الخطوط العريضة لتابع يُجرِي اتصالًا من طرف العميل: // 1 void doClientConnection(String computerName, int serverPort) { Socket connection; InputStream in; OutputStream out; try { connection = new Socket(computerName,serverPort); in = connection.getInputStream(); out = connection.getOutputStream(); } catch (IOException e) { System.out.println( "Attempt to create connection failed with error: " + e); return; } . . // استخدم المجريين in و out لتبادل المعلومات مع الخادم . try { connection.close(); // قد تعتمد على الخادم لغلق الاتصال بدلًا من غلقه بنفسك } catch (IOException e) { } } // end doClientConnection() [1] افتح اتصالًا مع الحاسوب ورقم المَنفَذ المُخصَّصين للخادم، ثم انقل المعلومات عبر الاتصال. تُسهِّل كل تلك الأصناف من التعقيدات الكثيرة التي ينطوي عليها نقل المعلومات عبر الشبكات؛ أما إذا كنت تَجِد هذا صعبًا بالفعل، فإنه إذًا أكثر صعوبة. إذا كانت الشبكات جديرةً بالثقة كليًا، فلربما كان الأمر بنفس السهولة التي وُصِفت بها هنا، ولكنها ليست كذلك؛ ولهذا ينبغي كتابة برامج الشبكات بصورةٍ متينة robust تُمكِّنها من التعامل مع أخطاء الشبكات والبشر، ولكننا لن نناقش هذا هنا. لقد شرحنا الأفكار الأساسية لبرمجة الشبكات عمومًا، وسنكتفي بما تعرَّضنا له من تطبيقاتٍ بسيطة. لنفحص الآن أمثلةً قليلة على برمجة الخادم / العميل. برنامج عميل/خادم بسيط يتكوَّن المثال الأول من برنامجين تتوفَّر شيفرتهما بالملفين DateClient.java و DateServer.java؛ حيث يُمثِّل الأول عميلًا شبكيًا network client بسيطًا؛ بينما يُمثِّل الآخر خادمًا server. يُنشِئ العميل اتصالًا مع الخادم، ويقرأ سطرًا نصيًا واحدًا منه، ثم يَعرِضه على الشاشة؛ حيث يتكوَّن هذا السطر من التاريخ والتوقيت الحالي بالحاسوب الذي يَعمَل عليه الخادم. يجب بالطبع أن يَعرِف العميل أي حاسوبٍ يَعمَل عليه الخادم، وكذلك رقم المَنفَذ port الذي يَستمِع إليه الخادم حتى يتمكَّن من إنشاء اتصالٍ معه. يُمكِن أن يقع رقم المنفذ بين "1025" و "65535" عمومًا (الأرقام الواقعة بين "1" و "1024" محجوزةٌ للخدمات القياسية، ولا ينبغي استخدامها للخوادم الأخرى)، ولا يُحدِِث ذلك أي فرقٍ بشرط أن يَستخدِم الخادم والعميل نفس رقم المنفذ، ولنفترض أن الخادم بهذا المثال يَستمِع إلى رقم المنفذ "32007". يُمكِنك تمرير اسم أو عنوان IP الخاص بحاسوب الخادم مثل وسيط سطر أوامر للبرنامج DateClient أثناء تشغيله؛ فإذا كان الخادم مثلًا مُشغَّلًا على حاسوبٍ اسمه "math.hws.edu"، يُمكِنك أن تُشغِّل العميل باستخدام الأمر java DateClient math.hws.edu. في حالة عدم تخصيص حاسوب الخادم على أنه وسيط بسطر الأوامر، سيطلب البرنامج منك أن تُدْخِله. انظر الشيفرة الكاملة لبرنامج العميل: import java.net.*; import java.util.Scanner; import java.io.*; // 1 public class DateClient { public static final int LISTENING_PORT = 32007; public static void main(String[] args) { String hostName; // اسم حاسوب الخادم Socket connection; // رقم منفذ الاتصال مع الخادم BufferedReader incoming; // لقراءة البيانات من الاتصال // اقرأ حاسوب الخادم من سطر الأوامر if (args.length > 0) hostName = args[0]; else { Scanner stdin = new Scanner(System.in); System.out.print("Enter computer name or IP address: "); hostName = stdin.nextLine(); } // أجرِ الاتصال ثم اقرأ سطرًا نصيًا واعرضه try { connection = new Socket( hostName, LISTENING_PORT ); incoming = new BufferedReader( new InputStreamReader(connection.getInputStream()) ); String lineFromServer = incoming.readLine(); if (lineFromServer == null) { // 2 throw new IOException("Connection was opened, " + "but server did not send any data."); } System.out.println(); System.out.println(lineFromServer); System.out.println(); incoming.close(); } catch (Exception e) { System.out.println("Error: " + e); } } // end main() } //end class DateClient حيث: [1]: يَفتَح هذا البرنامج اتصالًا مع الحاسوب المُخصَّص مثل وسيطٍ أول بسطر الأوامر؛ وإذا لم يكن مخصَّصًا بعد، اطلب من المُستخدِم أن يُدْخِل الحاسوب الذي يرغب في الاتصال به. يُجرى البرنامج الاتصال على رقم المنفذ LISTENING_PORT، ويقرأ سطرًا نصيًا واحدًا من الاتصال ثم يُغلق الاتصال، ويُرسِل أخيرًا النص المقروء إلى الخرج القياسي. الهدف من هذا البرنامج هو استخدامه مع البرنامج DateServer الذي يُرسِل كُلًا من التوقيت والتاريخ الحاليين بالحاسوب الذي يَعمَل عليه الخادم. [2]: أعاد التابع incoming.readLine() القيمة الفارغة مما يُعدّ إشارةً إلى وصوله إلى نهاية المجرى. لاحِظ أن الشيفرة المسؤولة عن الاتصال مع الخادم مُضمَّنةٌ داخل تعليمة try..catch، حتى تَلتقِط استثناءات النوع IOException، والتي يُحتمَل وقوعها أثناء فتح الاتصال أو غلقه أو قراءة البيانات من مجرى الدْخَل. أحطنا مجرى الدْخَل الخاص بالاتصال بكائن من النوع BufferedReader، والذي يحتوي على التابع readLine()؛ وهذا يُسهّل قراءة سطرٍ واحدٍ من المُدْخَلات. إذا أعاد التابع القيمة null، يكون الخادم قد أغلق الاتصال دون أن يُرسِل أي بيانات. حتى يَعمَل هذا البرنامج دون أخطاء، ينبغي أن تُشغِّل برنامج الخادم أولًا على الحاسوب الذي يُحاوِل العميل الاتصال به، حيث يُمكِنك أن تُشغِّل برنامجي العميل والخادم على نفس الحاسوب. على سبيل المثال، افتح نافذتي سطر أوامر، ثم شغِّل الخادم بإحداها، وشغِّل العميل بالنافذة الأخرى. علاوةً على ذلك، تَستطِيع أغلب الحواسيب استخدام اسم المجال "localhost" وعنوان IP التالي "127.0.0.1" للإشارة الى ذاتها، ولهذا يُمكِنك استخدام الأمر java DateClient localhost لتطلب من البرنامج DateClient الاتصال مع الخادم المُشغَّل على نفس الحاسوب؛ وإذا لم يَنجَح معك الأمر، جرِّب الأمر java DateClient 127.0.0.1. أطلقنا اسم DateServer على برنامج الخادم المقابل لبرنامج العميل DataClient، حيث يُنشِئ برنامج DateServer مقبسًا socket من النوع ServerSocket للاستماع إلى طلبات الاتصال برقم المنفذ "32007". ويَستمِر بعد ذلك بتنفيذ حلقة تكرار loop لا نهائية تَستقبِل طلبات الاتصال وتُعالِجها. تستمر حلقة التكرار بالعمل حتى ينتهي البرنامج بطريقةٍ ما، مثل كتابة CONTROL-C بنافذة سطر الأوامر التي شغَّلت الخادم منها. عندما يَستقبِل الخادم طلب اتصالٍ من عميلٍ معين، فإنه يَستدعِي برنامجًا فرعيًا لمعالجة هذا الاتصال، حيث يلتقط البرنامج الفرعي أي استثناءات من النوع Exception حتى لا ينهار الخادم، وهذا أمرٌ منطقي؛ فلا ينبغي للخادم أن يُغلَق لمجرد أن اتصالًا واحدًا مع عميل معين قد فشل لسببٍ ما؛ فقد يكون العميل هو سبب الخطأ أساسًا. بخلاف التقاطه للاستثناءات، فإنه يُنشِئ كائنًا من النوع PrintWriter لإرسال البيانات عبر الاتصال، ويُرسِل تحديدًا التاريخ والتوقيت الحالي إلى ذلك المجرى، ثم يُغلِق الاتصال؛ حيث يَستخدِم البرنامج الصنف القياسي java.util.Date للحصول على التوقيت الحالي، وتُمثِل كائنات الصنف Date تاريخًا وتوقيتًا محددًا، ويُنشِئ الباني الافتراضي new Date() كائنًا يُمثِّل توقيت إنشائه. انظر الشيفرة الكاملة لبرنامج الخادم: import java.net.*; import java.io.*; import java.util.Date; // 1 public class DateServer { public static final int LISTENING_PORT = 32007; public static void main(String[] args) { ServerSocket listener; // يستمع إلى طلبات الاتصال Socket connection; // للتواصل مع البرنامج المتصل // 2 try { listener = new ServerSocket(LISTENING_PORT); System.out.println("Listening on port " + LISTENING_PORT); while (true) { // استقبل طلب الاتصال التالي وعالِجه connection = listener.accept(); sendDate(connection); } } catch (Exception e) { System.out.println("Sorry, the server has shut down."); System.out.println("Error: " + e); return; } } // end main() // 3 private static void sendDate(Socket client) { try { System.out.println("Connection from " + client.getInetAddress().toString() ); Date now = new Date(); // التوقيت والتاريخ الحالي PrintWriter outgoing; // مجرى لإرسال البيانات outgoing = new PrintWriter( client.getOutputStream() ); outgoing.println( now.toString() ); outgoing.flush(); // تأكّد من إرسال البيانات client.close(); } catch (Exception e){ System.out.println("Error: " + e); } } // end sendDate() } //end class DateServer حيث: [1] يُمثِّل هذا البرنامج خادمًا يستمع إلى طلبات الاتصال على رقم المَنفَذ المخصَّص بواسطة الثابت LISTENING_PORT. عند فتح اتصال، يُرسِل البرنامج التوقيت الحالي إلى المقبس المُتصِل، ويستمر البرنامج في تسلُّم طلبات الاتصال ومعالجتها حتى يُغلِق عن طريق الضغط على CONTROL-C على سبيل المثال. ملاحظة: يعالِج الخادم طلبات الاتصال عند وصولها بدلًا من إنشاء خيطٍ thread منفصلٍ لمعالجتها. [2] اِستقبِل طلبات الاتصال وعالِجها للأبد أو إلى حين وقوع خطأ. ملاحظة: يلتقط البرنامج sendDate() الأخطاء الواقعة أثناء نقل البيانات مع برنامجٍ مُتصِل ويعالجها حتى لا ينهار الخادم. [3] يُمثِّل المعامل client مقبسًا متصلًا بالفعل مع برنامجٍ آخر، لذلك احصل على مجرى خرج لهذا الاتصال، وأرسل إليه التوقيت الحالي، ثم أغلق الاتصال. عندما تُشغِّل البرنامج DateServer بواجهة سطر الأوامر، فسيستمر بانتظار طلبات الاتصال ويُبلِّغ عنها عندما يتسلّمها. في المقابل، إذا أردت إتاحته على الحاسوب باستمرار، فينبغي أن تُشغِّله مثل عفريت daemon (وهو أمرٌ لن نناقش طريقة تنفيذه هنا)؛ وهو مثل برنامجٍ يَعمَل خفيةً وباستمرار على الحاسوب بغض النظر عن المُستخدِم. يُمكِنك ضبط الحاسوب ليبدأ بتشغيل هذا البرنامج تلقائيًا بمجرد أن تُشغِّله -أي الحاسوب-، وسيَعمَل بذلك البرنامج بالخلفية حتى إذا كنت تَستخدِم الحاسوب لأغراضٍ أخرى. تُشغِّل الحواسيب المسؤولة عن إتاحة بعض الصفحات على شبكة الويب العالمية مثلًا عفريتًا يستمع إلى طلبات العملاء لتلك الصفحات ثم ينقلها إليهم، ويشبه ذلك البرنامج DateServer، كما يُمكِن تشغيله يدويًا ببساطة لاختباره؛ فكل تلك الأمثلة بالنهاية غير متينة كفاية ولا تتمتع بخاصياتٍ مكتملة كفاية لتشغيلها خوادمًا فعلًا. بالمناسبة، تُعدّ كلمة "daemon" تهجئةً مختلفةً لكلمة "demon" وتُنطَق بنفس الطريقة. ملاحظة: بعد أن يَستدعِي البرنامج التابع outgoing.println() لإرسال سطرٍ واحدٍ من البيانات إلى العميل؛ يَستدعِي أيضًا التابع outgoing.flush() المُتاح بجميع أصناف مجاري الخرج، ليتأكَّد من إرسال البيانات التي كتبها بالمجرى بالفعل إلى مقصدها. في العموم، عليك استدعاء تلك الدالة بكل مرةٍ تَستخدِم بها مجرى خرج لإرسال بياناتٍ عبر اتصالٍ شبكي؛ لأنك إن لم تَفعَل ذلك، فقد يستمر المجرى بتخزين البيانات إلى أن يَصِل حجمها إلى القدر الكافي، ثم يُرسِلها بهدف رفع الكفاءة، ولكنه قد يؤدي إلى بعض التأخير غير المقبول إذا كان العميل ينتظر الرد، بل ربما حتى لا تُرسَل بعض البيانات عند غلق المقبس socket؛ ولذلك من الضروري استدعاء flush() قبل غلق أي اتصال. يُعدّ هذا واحدًا من الحالات التي قد تتصرف خلاله تنفيذات جافا المختلفة بطرائقٍ مختلفة. في العموم، إذا لم تستدعي التابع flush مع مجاري تدفق الخرج، فلربما سيَعمَل تطبيقك على بعض أنواع الحواسيب ولكنه قد لا يَعمَل أيضًا على بعضها الآخر. برنامج محادثة عبر الشبكة كان الخادم بالبرنامج السابق DateServer يُرسِل المعلومات إلى العميل ليقرأها. في الواقع، يُمكِننا أيضًا إنشاء اتصالٍ ثنائي الاتجاه بين العميل والخادم. سنفحص أولًا برنامجًا بسيطًا مُكوَّنًا من عميل وخادم؛ حيث سيُمكِّن البرنامج المُستخدِمين بطرفي الاتصال من إرسال الرسائل إلى بعضهما بعضًا. يَعمَل البرنامج ببيئة سطر الأوامر command-line environment، حيث يستطيع كل مُستخدِم كتابة رسائله، وسينتظر الخادم -بهذا المثال- طلب اتصالٍ من عميلٍ واحد فقط، ثم سيتوقَّف عن الاستماع إلى أي طلبات اتصال جديدة؛ أي لن يتمكَّن أي عميلٍ آخر غير العميل الأول من الاتصال بالخادم. بعد أن يَتصِل الخادم والعميل معًا، سيَعمَل البرنامج بكلا الطرفين بنفس الطريقة تقريبًا. سيَكتُب المُستخدِم بطرف العميل رسالةً ويُرسِلها إلى الخادم الذي سيَعرِضها بدوره إلى المُستخدِم بطرف الخادم، ثم سيَكْتُب المُستخدِم بطرف الخادم رسالةً لتنتقل بعدها إلى العميل الذي يُمكِنه كتابة رسالةٍ أخرى، وهكذا. سيستمر الأمر إلى أن يُقرِّر أي مُستخدِمٍ منهما كتابة كلمة "quit" برسالة؛ وعندما يحدث ذلك، يُغلَق الاتصال وينتهي البرنامج بكلا الطرفين. يتشابه برنامجا الخادم والعميل إلى حدٍ كبير، بينما يختلفان فقط بطريقة فتح الاتصال؛ فبرنامج العميل مُصمَّم ليُرسِل أول رسالةٍ؛ بينما يُصمَّم الخادم لاستقبالها. يُمكِنك الإطلاع على برنامجي الخادم والعميل بالملفين CLChatClient.java و CLChatServer.java، حيث يُشير الاسم "CLChat" إلى اختصارٍ لعبارة "command-line chat"، أي محادثة عبر سطر الأوامر. تَعرِض الشيفرة التالية برنامج الخادم (برنامج العميل مشابه): import java.net.*; import java.util.Scanner; import java.io.*; // 1 public class CLChatServer { // رقم المنفذ الافتراضي الذي ينبيغ الاستماع إليه إذا لم يُخصِّصه المُستخدم static final int DEFAULT_PORT = 1728; // 2 static final String HANDSHAKE = "CLChat"; // يَسبِق هذا المحرف جميع الرسائل المُرسَلة static final char MESSAGE = '0'; // يُرسَل هذا المحرف إلى البرنامج المتصل عندما يغلق المستخدم الاتصال static final char CLOSE = '1'; public static void main(String[] args) { int port; // رقم المنفذ الذي يستمع إليه الخادم ServerSocket listener; // يستمع إلى طلبات الاتصال Socket connection; // للتواصل مع العميل BufferedReader incoming; // مجرى لاستقبال البيانات من العميل PrintWriter outgoing; // مجرى لإرسال البيانات إلى العميل String messageOut; // رسالة ينبغي إرسالها إلى العميل String messageIn; // رسالة ينبغي استقبالها من العميل // مغلِّف للكائن System.in لقراءة أسطر مدخلة من المستخدم Scanner userInput; // 3 if (args.length == 0) port = DEFAULT_PORT; else { try { port= Integer.parseInt(args[0]); if (port < 0 || port > 65535) throw new NumberFormatException(); } catch (NumberFormatException e) { System.out.println("Illegal port number, " + args[0]); return; } } // 4 try { listener = new ServerSocket(port); System.out.println("Listening on port " + listener.getLocalPort()); connection = listener.accept(); listener.close(); incoming = new BufferedReader( new InputStreamReader(connection.getInputStream()) ); outgoing = new PrintWriter(connection.getOutputStream()); outgoing.println(HANDSHAKE); // Send handshake to client. outgoing.flush(); messageIn = incoming.readLine(); // Receive handshake from client. if (! HANDSHAKE.equals(messageIn) ) { throw new Exception("Connected program is not a CLChat!"); } System.out.println("Connected. Waiting for the first message."); } catch (Exception e) { System.out.println("An error occurred while opening connection."); System.out.println(e.toString()); return; } // 5 try { userInput = new Scanner(System.in); System.out.println("NOTE: Enter 'quit' to end the program.\n"); while (true) { System.out.println("WAITING..."); messageIn = incoming.readLine(); if (messageIn.length() > 0) { // 6 if (messageIn.charAt(0) == CLOSE) { System.out.println("Connection closed at other end."); connection.close(); break; } messageIn = messageIn.substring(1); } System.out.println("RECEIVED: " + messageIn); System.out.print("SEND: "); messageOut = userInput.nextLine(); if (messageOut.equalsIgnoreCase("quit")) { // 7 outgoing.println(CLOSE); outgoing.flush(); // تأكّد من إرسال البيانات connection.close(); System.out.println("Connection closed."); break; } outgoing.println(MESSAGE + messageOut); outgoing.flush(); // تأكَّد من إرسال البيانات if (outgoing.checkError()) { throw new IOException("Error occurred while transmitting message."); } } } catch (Exception e) { System.out.println("Sorry, an error has occurred. Connection lost."); System.out.println("Error: " + e); System.exit(1); } } // end main() } //end class CLChatServer حيث: [1] يُمثِّل هذا البرنامج أحد طرفي برنامج محادثةٍ بسيط عبر سطر الأوامر، حيث يَعمَل البرنامج كأنه خادم ينتظر طلبات الاتصال من البرنامج CLChatClient. يُمكِن تخصيص رقم المنفذ الذي يستمع إليه الخادم مثل وسيط بسطر الأوامر؛ ويَستخدِم البرنامج في حالة عدم تخصيصه رقم المنفذ الافتراضي المُخصَّص عبر الثابت DEFAULT_PORT. ملاحظة: يستمع الخادم إلى أي رقم منفذٍ متاح، في حالة تخصيص العدد صفر رقمًا للمنفذ. يدعم هذا البرنامج اتصالًا واحد فقط؛ فبمجرد فتح الاتصال، يتوقف مقبس الاستماع، ويُرسِل طرفي الاتصال بعد ذلك رسالةً نصيةً لتحقيق الاتصال إلى بعضهما بعضًا، ليتأكّد كلا الطرفين من أن البرنامج على الطرف الآخر من النوع الصحيح، وفي تلك الحالة، يبدأ البرنامجان المتصلان بتبادل الرسائل. لا بُدّ أن يُرسِل برنامج العميل الرسالة الأولى، وبإمكان المُستخدِم بأيٍّ من الطرفين إغلاق الاتصال بإدخال السلسلة النصية "quit". ملاحظة: لا بُدّ أن يكون المحرف الأول بأي رسالةٍ نصيةٍ مُرسَلة عبر الشبكة مساويًا للقيمة "0" أو "1"، حيث يُفسَّر على أنه أمر. [2] سلسلة نصية لتحقيق الاتصال؛ حيث يرسل طرفي الاتصال تلك الرسالة النصية إلى بعضهما بمجرد فتح الاتصال لنتأكّد من أن الطرف الآخر هو برنامج CLChat. [3] اقرأ رقم المنفذ من سطر الأوامر أو اِستخدِم رقم المنفذ الافتراضي إذا لم يُخصِّصه المُستخدم. [4] انتظر طلب اتصال؛ وعندما يَصِل طلب اتصال، أغلق المستمع، وأنشِئ مجاري تدفق لتبادل البيانات والتحقق من الاتصال. [5] تبادل الرسائل مع الطرف الآخر من الاتصال حتى يُغلِق أحدهما الاتصال. ينتظر لخادم الرسالة الأولى من العميل، ويتبادل بعد ذلك الطرفان الرسائل جيئةً وذهابًا. [6] يعد المِحرف الأول من الرسالة أمرًا. إذا كان الأمر هو إغلاق الاتصال، أغلقه؛ أما إذا لم يَكن كذلك، اِحذِف محرف الأمر من الرسالة وأكمل المعالجة. [7] يرغب المُستخدِم بإغلاق الاتصال. بلِّغ الطرف الآخر وأغلق الاتصال. يُعدّ هذا البرنامج أكثر متانةً robust نوعًا ما من البرنامج DateServer؛ لأنه يُجرِي تحقيق اتصال handshake ليتأكّد من أن العميل الذي يحاول الاتصال به هو بالفعل البرنامج CLChatClient. يُجرَى تحقيق الاتصال بتبادل بعض المعلومات بين العميل والخادم على أنه جزءٌ من عملية إنشاء الاتصال قبل إرسال أي بياناتٍ فعلية، ويُرسِل طرفا الاتصال في تلك الحالة سلسلةً نصيةً إلى الطرف الآخر لتعريف هويته؛ حيث يُعدّ تحقيق الاتصال جزءًا من بروتوكول إجراء اتصال -أنشأه الكاتب- بين البرنامجين CLChatClient وCLChatServer. يُعدّ أي بروتوكول توصيفًا مفًصَّلًا لما يُمكِن تبادله من بياناتٍ ورسائلٍ عبر اتصالٍ معين، وكذلك طريقة تمثيل تلك البيانات، وبأي ترتيبٍ ينبغي إرسالها. ويُعدّ تصميم البروتوكول جانبًا مهمًا بتطبيقات الخوادم/العملاء. ينطوي بروتوكول "CLChat" على جانبٍ آخر بالإضافة إلى تحقيق الاتصال؛ حيث يَنُصّ على أن المحرف الأول بأي سطرٍ نصي يُرسَل عبر الاتصال هو أمر. إذا كان المِحرف الأول يُساوِي "0"، فيُمثِّل السطر رسالةً من مُستخدمٍ لآخر؛ أما إذا كان يُساوِي "1"، فسيُشير السطر إلى أن أحدهما قد أدخَل الأمر "quit"، مما يؤدي إلى غلق الاتصال. ترجمة -بتصرّف- للقسم Section 4: Networking من فصل Chapter 11: Input/Output Streams, Files, and Networking من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: معالجة الملفات في جافا الإنترنت وما بعده وعلاقته بجافا واجهة المستخدم الحديثة في جافا فهم نموذج التواصل بين المضيفين في الشبكات
-
سنناقش في هذه المقالة تنفيذًا جديدًا للواجهة Map يُعرَف باسم شجرة البحث الثنائية binary search tree. يشيع استخدام هذا التنفيذ عند الحاجة إلى الاحتفاظ بترتيب العناصر. ما هي مشكلة التعمية hashing؟ يُفترَض أن تكون على معرفةٍ بالواجهة Map، وبالصنف المُنفِّذ لها HashMap الذي تُوفِّره جافا. إذا كنت قد قرأت مقالة تحسين أداء الخرائط المُنفَّذة باستخدام التعمية التي نفّذنا بها نفس الواجهة باستخدام جدول hash table، فيُفترَض أنك تَعرِف الكيفية التي يَعمَل بها الصنف HashMap، والسببَ الذي لأجله تَستغرِق توابع ذلك التنفيذ زمنًا ثابتًا. يشيع استخدام الصنف HashMap بفضل كفاءته العالية، ولكنه مع ذلك ليس التنفيذ الوحيد للواجهة Map، فهناك أسبابٌ عديدةٌ قد تدفعك لاختيار تنفيذٍ آخرَ، منها: قد تستغرق عملية حساب شيفرة التعمية زمنًا طويلًا. فعلى الرغم من أن عمليات الصنف HashMap تستغرق زمنًا ثابتًا، فقد يكون ذلك الزمن كبيرًا. تَعمَل التعمية بشكلٍ جيّدٍ فقط عندما تُوزِّع دالةُ التعميةِ hash function المفاتيحَ بالتساوي على الخرائط الفرعية، ولكنّ تصميمَ دوالِّ التعمية لا يُعدّ أمرًا سهلًا، فإذا احتوت خريطةٌ فرعيّةٌ معيّنةٌ على مفاتيحَ كثيرةٍ، تقل كفاءة الصنف HashMap. لا تُخزَّن المفاتيح في الجدول وفقًا لترتيبٍ معيّنٍ، بل قد يتغير ترتيبها عند إعادة ضبط حجم الجدول وإعادة حساب شيفرات التعمية للمفاتيح. بالنسبة لبعض التطبيقات، قد يكون الحفاظ على ترتيب المفاتيح ضروريًا أو مفيدًا على الأقل. من الصعب حل كل تلك المشكلات في الوقت نفسه، ومع ذلك، تُوفِّر جافا التنفيذ TreeMap الذي يُعالِج بعضًا منها: لا يَستخدِم ذلك الصنفُ دالّةَ تعميةٍ، وبالتالي، يتجنَّب الزمن الإضافي اللازم لحساب شيفرات التعمية، كما يُجنّبُنا صعوباتِ اختيار دالّةِ تعميةٍ مناسبة. تُخزَّن المفاتيح في الصنف TreeMap بهيئة شجرةِ بحثٍ ثنائيّةٍ، مما يُسهِّل من اجتياز المفاتيح وفقًا لترتيبٍ معيّنٍ وبزمنٍ خطّي. يتناسب زمن تنفيذ غالبيّة توابع الصنف TreeMap مع log(n)، والتي رغم أنها ليست بكفاءة الزمن الثابت، ولكنها ما تزال جيدةً جدًا. سنشرح طريقة عمل أشجار البحث الثنائية في القسم التالي ثم سنستخدِمها لتنفيذ الواجهة Map، وأخيرًا، سنُحلّل أداء التوابعِ الأساسيّةِ في الخرائط المُنفَّذة باستخدام شجرة. أشجار البحث الثنائية شجرة البحث الثنائية عبارةٌ عن شجرةٍ تحتوي كلُّ عقدةٍ فيها على مفتاحٍ، كما تتوفّر فيها "خاصية BST" التي تنص على التالي: إذا كان لأي عقدةٍ أبٍ عقدةٌ ابنةٌ يسرى، فلا بُدّ أن تكون قيم جميع المفاتيح الموجودة في الشجرة الفرعية اليسرى أصغرَ من قيمة مفتاح تلك العقدة. إذا كان لأي عقدةٍ أبٍ عقدةٌ ابنةٌ يمنى، فلا بُدّ أن تكون قيم جميع المفاتيح الموجودة في الشجرة الفرعية اليمنى أكبرَ من قيمة مفتاح تلك العقدة. تَعرِض الصورة السابقة شجرة أعدادٍ صحيحةٍ تُحقِّق الشروطَ السابقة. هذه الصورة مأخوذةٌ من مقالةِ ويكيبيديا موضوعها أشجار البحث الثنائية، والتي قد تفيدك لحل هذا التمرين. لاحِظ أن مفتاح عقدة الجذر يساوي 8. يُمكِنك التأكّد من أن مفاتيح العقد الموجودة على يسار عقدة الجذر أقلّ من 8 بينما مفاتيح العقد الموجودة على يمينها أكبرَ من 8. تأكّد من تحقّق نفس الشرط للعقد الأخرى. لا يَستغرِق البحث عن مفتاحٍ ما ضمن شجرةِ بحثٍ ثنائيّةٍ زمنًا طويلًا، لأنك غير مُضطرّ للبحث في كامل الشجرة، وإنما عليك أن تبدأ من جذر الشجرة، ومن ثمّ، تُطبِّق الخوارزميةَ التالية: افحص قيمة المفتاح الهدفtarget الذي تبحث عنه وطابقه مع قيمة مفتاح العقدة الحالية. فإذا كانا متساويين، فقد انتهيت بالفعل. أمّا إذا كان المفتاح target أصغرَ من المفتاح الحاليِّ، ابحث في الشجرة الموجودة على اليسار، فإذا لم تكن موجودةً، فهذا يَعنِي أن المفتاح target غيرُ موجودٍ في الشجرة. وأمّا إذا كان المفتاح target أكبرَ من المفتاح الحاليِّ، ابحث في الشجرة الموجودة على اليمين. فإذا لم تكن موجودة، فهذا يَعنِي أنّ المفتاح target غير موجود في الشجرة. يَعنِي ما سبق أنك مضطرٌّ للبحث في عقدةٍ ابنة واحدةٍ فقط لكل مستوىً ضمن الشجرة. فعلى سبيل المثال، إذا كنت تبحث عن مفتاح target قيمته تساوي 4 في الرسمة السابقة، فعليك أن تبدأ من عقدة الجذر التي تحتوي على المفتاح 8، ولأن المفتاح المطلوب أقلَّ من 8، فستذهب إلى اليسار، ولأنه أكبر من 3، فستذهب إلى اليمين، ولأنه أقل من 6، فستذهب إلى اليسار، ثم ستعثر على المفتاح الذي تبحث عنه. تطلّب البحث عن المفتاح في المثال السابق 4 عملياتِ موازنةٍ رغم أنّ الشجرة تحتوي على 9 مفاتيح. يتناسب عدد الموازنات المطلوبة في العموم مع ارتفاع الشجرة وليس مع عدد المفاتيح الموجودة فيها. ما الذي نستنتجه من ذلك بخصوص العلاقة بين ارتفاع الشجرة h وعدد العقد n؟ إذا بدأنا بارتفاعٍ قصيرٍ وزدناه تدريجيًّا، فسنحصل على التالي: إذا كان ارتفاع الشجرة h يساوي 1، فإن عدد العقد n ضمن تلك الشجرة يساوي 1. وإذا كان ارتفاع الشجرة h يساوي 2، فيُمكِننا أن نضيف عقدتين أُخرَيَيْنِ، وبالتالي، يصبح عدد العقد n في الشجرة مساويًا للقيمة 3. وإذا كان ارتفاع الشجرة h يساوي 3، فيُمكِننا أن نضيف ما يصل إلى أربعِ عقدٍ أخرى، وبالتالي، يصبح عدد العقد n مساويًا للقيمة 7. وإذا كان ارتفاع الشجرة h يساوي 4، يُمكِننا أن نضيف ما يصل إلى ثماني عقدٍ أخرى، وبالتالي، يصبح عدد العقد n مساويًا للقيمة 15. ربما لاحظت النمط المشترك بين تلك الأمثلة. إذا رقَّمنا مستويات الشجرة تدريجيًّا من 1 إلى h، فإن عدد العقد في أيّ مستوىً i يَصِل إلى 2i-1 كحدٍّ أقصى، وبالتالي، يكون عددُ العقدِ الإجماليُّ في عدد h من المستويات هو 2h-1. إذا كان: n = 2h - 1 بتطبيق لوغاريتم الأساس 2 على طرفي المعادلة السابقة، نحصل على التالي: log2 n ≈ h إذًا، يتناسب ارتفاع الشجرة مع log(n) إذا كانت الشجرة ممتلئةً؛ أي إذا كان كل مستوىً فيها يحتوي على العدد الأقصى المسموح به من العقد. وبالتالي، يتناسب زمنُ البحثِ عن مفتاحٍ ضمن شجرةِ بحثٍ ثنائيّةٍ مع log(n). يُعدّ ذلك صحيحًا سواءٌ أكانت الشجرةُ ممتلئة كلّيًّا أم جزئيًا، ولكنه ليس صحيحًا في المطلق، وهو ما سنراه لاحقًا. يُطلَق على الخوارزميات التي تَستغرِق زمنًا يتناسب مع log(n) اسم "خوارزمية لوغاريتمية"، وتنتمي إلى ترتيب النمو O(log(n)). تمرين 10 ستكتب في هذا التمرين تنفيذًا للواجهة Map باستخدام شجرةِ بحثٍ ثنائيّةٍ. انظر إلى التعريفِ المبدئيِّ للصنف MyTreeMap: public class MyTreeMap<K, V> implements Map<K, V> { private int size = 0; private Node root = null; يحتفظ متغيّرُ النسخةِ size بعدد المفاتيح بينما يحتوي root على مرجع reference يشير إلى عقدة الجذر الخاصّةِ بالشجرة. إذا كانت الشجرة فارغةً، يحتوي root على القيمة null وتكون قيمة size مساويةً للصفر. انظر إلى الصنف Node المُعرَّف داخل الصنف MyTreeMap: protected class Node { public K key; public V value; public Node left = null; public Node right = null; public Node(K key, V value) { this.key = key; this.value = value; } } تحتوي كل عقدةٍ على زوج مفتاح/قيمة وعلى مراجعَ تشير إلى العقد الأبناء left و right. قد تكون إحداهما أو كلتاهما فارغة أي تحتوي على القيمة null. من السهل تنفيذ بعض توابع الواجهة Map مثل size و clear: public int size() { return size; } public void clear() { size = 0; root = null; } من الواضح أن التابع size يَستغرِق زمنًا ثابتًا. قد تظن للوهلة الأولى أن التابع clear يستغرق زمنًا ثابتًا، ولكن فكر بالتالي: عندما تُضبَط قيمة root إلى القيمة null، يستعيد كانسُ المهملات garbage collector العقدَ الموجودة في الشجرة ويَستغرِق لإنجاز ذلك زمنًا خطيًا. هل ينبغي أن يُحسَب العمل الذي يقوم به كانس المهملات؟ ربما. ستكتب في القسم التالي تنفيذًا لبعض التوابع الأخرى لا سيّما التابعين الأهمّ get و put. تنفيذ الصنف TreeMap ستجد ملفات الشيفرة التالية في مستودع الكتاب: MyTreeMap.java: يحتوي على الشيفرة المُوضَّحة في الأعلى مع تصورٍ مبدئيٍّ للتوابع غير المكتملة. MyTreeMapTest.java : يحتوي على اختبارات وحدةٍ للصنف MyTreeMap. نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant MyTreeMapTest. قد تفشل بعض الاختبارات لأنّ هناك بعض التوابع التي ينبغي عليك إكمالها أولًا. وفَّرنا تصورًا مبدئيًا للتابعين get و containsKey. يَستخدِم كلاهما التابع findNode المُعرَّف باستخدام المُعدِّل private، لأنه ليس جزءًا من الواجهة Map. انظر إلى بداية تعريفه: private Node findNode(Object target) { if (target == null) { throw new IllegalArgumentException(); } @SuppressWarnings("unchecked") Comparable<? super K> k = (Comparable<? super K>) target; // TODO: FILL THIS IN! return null; } يشير المعامل target إلى المفتاح الذي نبحث عنه. إذا كانت قيمة target تساوي null، يُبلِّغ التابع findNode عن اعتراضٍ exception. في الواقع، بإمكان بعض تنفيذات الواجهة Map معالجة الحالات التي تكون فيها قيمة المفتاح فارغة، ولكن لأننا في هذا التنفيذ نَستخدِم شجرة بحثٍ ثنائيّةٍ، فلا بُدّ أن نتمكّن من موازنةِ المفاتيح، ولذلك، يُشكِّل التعامل مع القيمة null مشكلةً، ولذا ولكي نُبسِّط الأمور، لن نَسمَح لهذا التنفيذ باستخدام القيمة null كمفتاح. تُوضِّح الأسطر التالية كيف يمكِننا أن نوازن قيمة المفتاح target مع قيمة مفتاحٍ ضمن الشجرة. تشير نسخةُ التابعين get و containsKey إلى أن المُصرِّف يتعامل مع target كما لو أنه ينتمي إلى النوع Object، ولأننا نريد موازنته مع المفاتيح، فإننا نحوِّل نوع target إلى النوع Comparable<? super K> لكي يُصبِح قابلًا للموازنة مع كائنٍ من النوع K أو أيٍّ من أصنافه الأعلى superclass. يُمكِنك قراءة المزيد عن أنواع محارف البدل (باللغة الإنجليزية). ليس المقصودُ من هذا التمرين احترافَ التعامل مع نظام الأنواع في لغة جافا، فدورك فقط هو أن تُكمِل التابع findNode. إذا وجد ذلك التابع عقدةً تحتوي على قيمة target كمفتاح، فعليه أن يعيدها، أما إذا لم يجدها، فعليه أن يعيد القيمة null. ينبغي أن تنجح اختبارات التابعين get و containsKey بعد أن تنتهي من إكمال هذا التابع. والآن، عليك أن تُكمِل التابع containsValue، ولمساعدتك على ذلك، وفَّرنا التابع المساعد equals الذي يُوازِن بين قيمة target وقيمة مفتاحٍ معيّنٍ. على العكس من المفاتيح، قد لا تكون القيم المُخزَّنة في الشجرة قابلةً للموازنة، وبالتالي، لا يُمكِننا أن نَستخدِم معها التابع compareTo، وإنما علينا أن نَستدعِيَ التابعَ equals بالمتغير target. بخلاف التابع findNode، سيضطرّ التابع containsValue للبحث في كامل الشجرة، أي يتناسب زمن تشغيله مع عدد المفاتيح n وليس مع ارتفاع الشجرة h. والآن، أكمل متنَ التابع put. وفَّرنا له شيفرةً مبدئيةً تعالج الحالات البسيطة فقط: public V put(K key, V value) { if (key == null) { throw new IllegalArgumentException(); } if (root == null) { root = new Node(key, value); size++; return null; } return putHelper(root, key, value); } private V putHelper(Node node, K key, V value) { // TODO: Fill this in. } إذا حاولت استخدَام القيمة الفارغة null كمفتاحٍ، سيُبلّغ put عن اعتراض؛ وإذا كانت الشجرة فارغةً، سيُنشِئ التابع put عقدةً جديدةً، ويُهيِّئُ المتغير root المُعرَّف فيها؛ أما إذا لم تكن فارغةً، فإنه يَستدعِي التابع putHelper المُعرَّف باستخدام المُعدِّل private لأنه ليس جزءًا من الواجهة Map. أكمل متنَ التابع putHelper واجعله يبحث ضمن الشجرة وفقًا لما يلي: إذا كان المفتاح key موجودًا بالفعل ضمن الشجرة، عليه أن يَستبدِل القيمة الجديدة بالقيمة القديمة، ثم يعيدها. إذا لم يكن المفتاح key موجودًا في الشجرة، فعليه أن يُنشِىء عقدةً جديدةً، ثم يضيفها إلى المكان الصحيح، وأخيرًا، يعيد القيمة null. ينبغي أن يَستغرِق التابع put زمنًا يتناسب مع ارتفاع الشجرة h وليس مع عدد العناصر n. سيكون من الأفضل لو بحثت في الشجرة مرةً واحدةً فقط، ولكن إذا كان البحث فيها مرّتين أسهلَ بالنسبة لك، فلا بأس. سيكون التنفيذ أبطأ، ولكنّه لن يؤثر على ترتيب نموه. وأخيرًا، عليك أن تُكمِل متن التابع keySet. يعيد ذلك التابع -وفقًا لـللتوثيق (باللغة الإنجليزية)- قيمة من النوع Set بإمكانها المرور عبر جميع مفاتيح الشجرة بترتيبٍ تصاعديٍّ وفقًا للتابع compareTo. كنا قد اِستخدَمنا الصنف HashSet في مقالة استخدام خريطة ومجموعة لبناء مُفهرِس Indexer. يُعدّ ذلك الصنف تنفيذًا للواجهة Set ولكنه لا يحافظ على ترتيب المفاتيح. في المقابل، يتوفَّر التنفيذ LinkedHashSet الذي يحافظ على ترتيب المفاتيح. يُنشِئ التابع keySet قيمةً من النوع LinkedHashSet ويعيدها كما يلي: public Set<K> keySet() { Set<K> set = new LinkedHashSet<K>(); return set; } عليك أن تُكمِل هذا التابع بحيث تجعلُه يضيفُ المفاتيح من الشجرة إلى المجموعة set بترتيبٍ تصاعديّ. ينبغي أن تنجح جميع الاختبارات بعد أن تنتهي من إكمال هذا التابع. سنَعرِض حل هذا التمرين ونفحص أداء التوابع الأساسية في الصنف في مقالٍ آخر من هذه السلسلة. ترجمة -بتصرّف- للفصل Chapter 12: TreeMap من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: استخدام أشجار البحث الثنائية والأشجار المتزنة balanced trees لتنفيذ الخرائط المقال السابق: تحسين أداء الخرائط المنفذة باستخدام التعمية HashMap في جافا تنفيذ الخرائط باستخدام التعمية hashing في جافا تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا الخرائط Maps في جافا
-
سنفحص خلال هذا القسم بعض الأمثلة البرمجية على تعامل البرامج مع الملفات باستخدام التقنيات المُوضحَّة التي عرضناها في المقالين السابقين، مقال قنوات الدخل والخرج وعمليتي القراءة والكتابة في جافا ومقال مدخل إلى التعامل مع الملفات في جافا. نسخ الملفات سنناقش الآن برنامج سطر أوامرٍ بسيط لنسخ الملفات، حيث تُعدّ عملية نسخ الملفات واحدةً من أكثر العمليات شيوعًا، ولهذا تُوفِّر جميع أنظمة التشغيل أمرًا مُخصَّصًا لتلك العملية بالفعل، ولكن ما يزال من المفيد من الناحية التعليمية أن ننظر إلى طريقة تنفيذ ذلك باستخدام برنامج جافا. تتشابه غالبية العمليات على الملفات مع عملية نسخ ملف، باستثناء اختلاف طريقة معالجتها لبيانات الملف المُدْخَلة قبل إعادة كتابتها إلى ملف الخرج؛ أي يُمكِن كتابة برامج لكل تلك العمليات بنفس الكيفية تقريبًا. تعرَّضنا في فصل المعاملات (parameters) في جافا لبرنامجٍ يَستخدِم الصنف TextIO لنسخ ملفاتٍ نصية، بينما يَعمَل البرنامج بالأسفل مع جميع أنواع الملفات. ينبغي أن يتمكَّن البرنامج من نسخ أي ملف، وبالتالي لا يُمكِن للملف أن يكون بصيغةٍ مقروءة، وهذا يَعنِي أننا سنضطّر لمعالجته باستخدام أصناف مجاري البايتات InputStream و OutputStream. يَنسَخ البرنامج البيانات من مجرًى من النوع InputStream إلى مجرًى آخر من النوع OutputStream، بحيث يَنسَخ بايتًا واحدًا في كل مرة. إذا كان source متغيرًا يُشير إلى مجرى الدخل من الصنف InputStream، فستقرأ الدالة source.read() بايتًا واحدًا. تعيد تلك الدالة القيمة "-1" بعد الانتهاء من قراءة كلِّ البايتات الموجودة بملف الدْخَل. بالمثل، إذا كان copy مُتغيّرًا يُشير إلى مجرى الخرج من الصنف OutputStream، فستكتب الدالة copy.write(b) بايتًا واحدًا في ملف الخرج. يُمكِننا بناءً على ما سبق كتابة البرنامج بهيئة حلقة while محاطةً بتعليمة try..catch؛ نظرًا لإمكانية عمليات الدْخَل والخرج في التبليغ عن اعتراضات: while(true) { int data = source.read(); if (data < 0) break; copy.write(data); } يَستقبِل أمر نسخ الملفات بنظام تشغيل، مثل UNIX وسطاء سطر الأوامر command line arguments لتخصيص أسماء الملفات المطلوبة، حيث يستطيع المُستخدِم مثلًا كتابة أمرٍ، مثل copy original.dat backup.dat، لينسخ ملفًا موجودًا اسمه "original.dat" إلى ملفٍ اسمه "backup.dat". تستطيع برامج جافا استخدام وسطاء سطر الأوامر بنفس الطريقة؛ حيث تُخزَّن قيمها ضمن مصفوفةٍ من السلاسل النصية اسمها args، والتي يَستقبِلها البرنامج main() مثل معاملٍ، ويستطيع بذلك البرنامج استرجاع القيم المُمرَّرة للوسطاء (انظر فصل المعاملات (parameters) في جافا). على سبيل المثال، إذا كان "CopyFile" هو اسم البرنامج، وشَغّله المُستخدِم بكتابة الأمر التالي: java CopyFile work.dat oldwork.dat فستُساوِي قيمة args[0] بالبرنامج السلسلة النصية "work.dat"؛ أما قيمة args[1] فستُساوِي السلسلة النصية "oldwork.dat". تُشير قيمة args.length إلى عدد الوسطاء المُمرَّرين. يَحصُل برنامج CopyFile.java على اسمي الملفين من خلال وسطاء سطر الأوامر، ويَطبَع رسالة خطأ إن لم يَجِدهما. هناك طريقتان لاستخدام البرنامج، هما: أولًا، قد يحتوي سطر الأوامر ببساطةٍ على اسمي ملفين، ويَطبَع البرنامج في تلك الحالة رسالة خطأ وينتهي إذا كان ملف الخرج المُخصَّص موجودًا مُسبقًا؛ لكي لا يَكْتُب بملفٍ مهمٍ عن طريق الخطأ. ثانيًا، قد يحتوى سطر الأوامر على ثلاثة وسطاء، ولا بُدّ في تلك الحالة أن يكون الوسيط الأول هو الخيار "-f"؛ أما الثاني والثالث فهما اسما الملفين. تُعدّل كتابة الوسيط "-f" من سلوك البرنامج، حيث يُفسِّره البرنامج على أنه رُخصةً للكتابة بملف الخرج حتى لو كان موجودًا مُسبقًا. لاحِظ أن "-f" هي في الواقع اختصار لكلمة "force"؛ نظرًا لأنها تجبر البرنامج على نسخ الملف حتى في الحالات التي كان البرنامج سيتعامل معها كما لو كانت خطأً بصورةٍ افتراضية. يُمكِنك الاطلاع على شيفرة البرنامج لترى طريقة تفسيره لوسطاء سطر الأوامر: import java.io.*; // 1 public class CopyFile { public static void main(String[] args) { String sourceName; // اسم ملف المصدر كما خُصّص بسطر الأوامر String copyName; // اسم ملف النسخة المُخصَّص InputStream source; // مجرًى للقراءة من ملف المصدر OutputStream copy; // مجرًى للكتابة بنسخة الملف // اضبطها إلى القيمة true إذا كان الخيار "f-" موجودًا بسطر الأوامر boolean force; int byteCount; // عدد البايتات المنسوخة حتى الآن // 2 if (args.length == 3 && args[0].equalsIgnoreCase("-f")) { sourceName = args[1]; copyName = args[2]; force = true; } else if (args.length == 2) { sourceName = args[0]; copyName = args[1]; force = false; } else { System.out.println( "Usage: java CopyFile <source-file> <copy-name>"); System.out.println( " or java CopyFile -f <source-file> <copy-name>"); return; } /* أنشئ مجرى الدخل، وأنهِ البرنامج في حالة حدوث خطأ */ try { source = new FileInputStream(sourceName); } catch (FileNotFoundException e) { System.out.println("Can't find file \"" + sourceName + "\"."); return; } // 4 File file = new File(copyName); if (file.exists() && force == false) { System.out.println( "Output file exists. Use the -f option to replace it."); return; } /* أنشئ مجرى الخرج وأنهِ البرنامج في حالة حدوث خطأ */ try { copy = new FileOutputStream(copyName); } catch (IOException e) { System.out.println("Can't open output file \"" + copyName + "\"."); return; } // 3 byteCount = 0; try { while (true) { int data = source.read(); if (data < 0) break; copy.write(data); byteCount++; } source.close(); copy.close(); System.out.println("Successfully copied " + byteCount + " bytes."); } catch (Exception e) { System.out.println("Error occurred while copying. " + byteCount + " bytes copied."); System.out.println("Error: " + e); } } // end main() } // end class CopyFile حيث يُقصد بـ: [1]: أنشئ نسخةً من ملف. يجب تخصيص كُلٍ من اسم الملف الأصلي، واسم ملف النسخة على أنهما وسائطٌ بسطر الأوامر. يُمكِننا بالإضافة إلى ذلك كتابة الخيار "-f" على أنه وسيطٌ أول، وسيكتب البرنامج في تلك الحالة على الملف الذي يحمل اسم ملف النسخة في حالة وجوده مسبقًا؛ أما إذا لم يكن هذا الخيار موجودًا، فسيبلِّغ البرنامج عن خطأ وينتهي إذا كان الملف موجودًا. يُبلِّغ البرنامج أيضًا عن عدد البايتات التي نسخها من الملف. [2]: احصل على أسماء الملفات من سطر الأوامر وافحص فيما إذا كان الخيار "-f" موجودًا. إذا لم يكن الأمر بأيٍّ من الصيغ المحتملة، اطبع رسالة خطأ وأنهِ البرنامج. [3]: اِنسَخ بايتًا واحدًا بكل مرة من مجرى الدخل إلى مجرى الخرج حتى يعيد التابع read() القيمة "-1"، والتي تُعدّ إشارةً إلى الوصول إلى نهاية المجرى. إذا حدث خطأٌ، اطبع رسالة خطأ، وكذلك اطبع رسالةً في حالة نسخ الملف بنجاح. [4]: إذا كان ملف الخرج موجودًا بالفعل، ولم يُخصِّص المُستخدِم الخيار "-f"، اطبع رسالة خطأ وأنهِ البرنامج. لا تَعمَل عملية نسخ بايتٍ واحدٍ بكل مرة بالكفاءة المطلوبة، حيث يُمكِن تحسينها باستخدام نسخٍ أخرى من التابعين read() و write()، والتي بإمكانها قراءة وكتابة عدة بايتات بنفس الوقت (انظر واجهة برمجة التطبيقات لمزيدٍ من التفاصيل). يُمكننا بدلًا من ذلك أن نحيط مجاري تدفق الدخل والخرج بكائناتٍ من النوع BufferedInputStream و BufferedOutputStream، والتي يُمكِنها قراءة أو كتابة كتلٍ من البيانات من وإلى الملف مباشرةً، ويتطلّب ذلك تعديل سطرين فقط من البرنامج المسؤول عن إنشاء مجاري التدفق. فمثلًا، يُمكِننا أن نُنشِئ مجرى الدخل على النحو التالي: source = new BufferedInputStream(new FileInputStream(sourceName)); وبذلك يُمكِننا استخدام المجرى المُدعَّم بخاصية التخزين المؤقت buffered stream بنفس طريقة استخدام المجرى العادي. يُمكِنك الإطلاع على البرنامج التوضيحي CopyFileAsResources.java، والذي يُنجز نفس مهمة البرنامج CopyFile، ولكنه يَستخدِم نمط المورد resource pattern ضمن تعليمة try..catch؛ ليتأكَّد من غلق المجاري بجميع الحالات، وهو ما ناقشناه بنهاية القسم "تعليمة Try" من فصل الاستثناءات exceptions وتعليمة try..catch في جافا. البيانات الدائمة بمجرد انتهاء برنامجٍ معينٍ من العمل، تُلغَى جميع البيانات التي خزَّنها البرنامج بمتغيراتٍ أو كائناتٍ أثناء تنفيذه، مع أننا قد نرغب أحيانًا في الإبقاء على بعض من تلك البيانات بحيث تظل متاحةً للبرنامج عند تنفيذه مرةً أخرى. يطرح ذلك السؤال التالي: كيف يُمكِننا الاحتفاظ بالبيانات وإتاحتها للبرنامج مرةً أخرى؟ الإجابة ببساطة هي بتخزينها بملف، أو قاعدة بيانات database لبعض التطبيقات، رغم أننا إذا شئنا الدقة فهي تُعدّ ملفات أيضًا؛ حيث تكون البيانات الموجودة ضمن قاعدة بيانات مُخزَّنةً بالنهاية ضمن ملفات. لنأخذ مثالًا على ذلك، وهو برنامج "دليل هاتف" يَسمَح للمُستخدِم بالاحتفاظ بقائمةٍ من الأسماء وأرقام الهواتف. لن يكون للبرنامج أي معنًى إذا اضطّر المُستخدِم لإعادة إنشاء القائمة من الصفر بكل مرةٍ يُشغِّل فيها البرنامج، وإنما ينبغي أن نُفكِر بدليل الهاتف كما لو أنه تجميعةٌ دائمة persistent من البيانات، وأن نفكر بالبرنامج على أنه مجرد واجهةٍ لتلك التجميعة. سيَسمَح البرنامج للمُستخدِم بالبحث بدليل الهاتف من خلال الاسم، وكذلك بإدخال بياناتٍ جديدة. وينبغي بالطبع الاحتفاظ بأي تغييراتٍ يُجريها المُستخدِم لما بعد انتهاء البرنامج. يُعد البرنامج PhoneDirectoryFileDemo.java تنفيذًا implementation بسيطًا لتلك الفكرة. لاحِظ أنه صُمِّم ليكون فقط مثالًا على طريقة توظيف الملفات ضمن برنامج، فلا تُحملّه أكثر من حجمه، فهو ليس برنامجًا حقيقيًا. يُخزِّن البرنامج بيانات دليل الهاتف بملفٍ اسمه ".phonebookdemo" بالمجلد الرئيسي للمُستخدِم، والذي يُحدِّده البرنامج بالاستعانة بالتابع System.getProperty() الذي ذكرناه في مقال مدخل إلى التعامل مع الملفات في جافا المشار إليه في الأعلى. عندما يبدأ البرنامج بالعمل، فإنه يَفحَص أولًا فيما إذا كان الملف موجودًا بالفعل؛ فإذا كان موجودًا، فإنه يحتوي بالضرورة على بيانات دليل الهاتف الخاصة بالمُستخدِم، والتي خُزِّنت أثناء تشغيله لنفس البرنامج بمرةٍ سابقة، ويقرأ البرنامج في تلك الحالة بيانات الملف، ويُخزِّنها بكائنٍ اسمه phoneBook من النوع TreeMap؛ حيث يُمثِّل هذا الكائن دليل الهاتف أثناء تشغيل البرنامج (انظر القسم "واجهة تمثيل الخرائط" من فصل الخرائط Maps في جافا). علينا الآن الاتفاق على طريقة تمثيل بيانات دليل الهاتف قبل تخزينها بملف. سنختار تمثيلًا بسيطًا، يُمثِّل فيه كل سطرٍ ضمن الملف مُدْخَلًا واحدًا مكوَّنًا من اسم ورقم هاتف يَفصِل بينهما علامة النسبة المئوية %. تقرأ الشيفرة التالية ملف بيانات دليل الهاتف إذا كان موجودًا ومكتوبًا وفقًا لطريقة التمثيل المُتفَق عليها: File userHomeDirectory = new File( System.getProperty("user.home") ); File dataFile = new File( userHomeDirectory, ".phone_book_data" ); // A file named .phone_book_data in the user's home directory. if ( ! dataFile.exists() ) { System.out.println("No phone book data file found. A new one"); System.out.println("will be created, if you add any entries."); System.out.println("File name: " + dataFile.getAbsolutePath()); } else { System.out.println("Reading phone book data..."); try( Scanner scanner = new Scanner(dataFile) ) { while (scanner.hasNextLine()) { // اقرأ سطرًا واحدًا من الملف يحتوي على زوج اسم ورقم هاتف String phoneEntry = scanner.nextLine(); int separatorPosition = phoneEntry.indexOf('%'); if (separatorPosition == -1) throw new IOException("File is not a phonebook data file."); name = phoneEntry.substring(0, separatorPosition); number = phoneEntry.substring(separatorPosition+1); phoneBook.put(name,number); } } catch (IOException e) { System.out.println("Error in phone book data file."); System.out.println("File name: " + dataFile.getAbsolutePath()); System.out.println("This program cannot continue."); System.exit(1); } } بعد ذلك، يَسمَح البرنامج للمُستخدِم بإجراء عدّة عملياتٍ على دليل الهاتف، بما في ذلك تعديل محتوياته؛ فإذا عدَّل المُستخدِم أيًا من بيانات دليل الهاتف بينما البرنامج مُشغَّل، فسيُجرى هذا التعديل فقط على كائن الصنف TreeMap. وعندما يحين موعد انتهاء البرنامج، يُمكِننا عندها كتابة تلك البيانات المُعدَّلة بالملف باستخدام الشيفرة التالية: if (changed) { System.out.println("Saving phone directory changes to file " + dataFile.getAbsolutePath() + " ..."); PrintWriter out; try { out = new PrintWriter( new FileWriter(dataFile) ); } catch (IOException e) { System.out.println("ERROR: Can't open data file for output."); return; } for ( Map.Entry<String,String> entry : phoneBook.entrySet() ) out.println(entry.getKey() + "%" + entry.getValue() ); out.flush(); out.close(); if (out.checkError()) System.out.println("ERROR: Some error occurred while writing data file."); else System.out.println("Done."); } ينتج عن ذلك أن جميع البيانات -بما في ذلك التعديلات التي أجراها المُستخدِم- ستكون متاحةً بالمرة التالية التي يُنفَّذ خلالها البرنامج. عرضنا بالأعلى شيفرة البرنامج المُتعلِّقة بمعالجة الملف فقط، ولكن يُمكِنك بالطبع الإطلاع على باقي أجزاء البرنامج من هنا. حفظ الكائنات بملف عندما نرغب بحفظ أي بياناتٍ ضمن ملف، يجب أن نقرّر أولًا صيغة تمثيل تلك البيانات. نظرًا لاتِّباع كُلٍ من برامج الخرج المسؤولة عن كتابة البيانات وبرامج الدْخَل المسؤولة عن قرائتها نفس الصيغة المُقرَّرة، فستُصبح الملفات قابلةً لإعادة الاستخدام. ربما سيكون البرنامج بذلك مكتوبًا كتابةً صحيحة correctness، ولكن لا يُعدّ ذلك الأمر الهام الوحيد، وإنما يجب أيضًا أن تكون طريقة تمثيل البيانات بالملفات متينة (انظر الفصل مقدمة إلى صحة البرامج ومتانتها في جافا). سنناقش طرائقًا مختلفةً لتمثيل نفس البيانات لفهم ما يعنيه ذلك. سيعتمد المثال الذي سنُناقشه على المثال SimplePaint2.java من القسم "البرمجة باستخدام ArrayList" من فصل مفهوم المصفوفات الديناميكية (ArrayLists) في جافا (قد ترغب بتشغّيله لكي تتذكَّر إمكانياته)، حيث يُمكِّن هذا البرنامج المُستخدِم من استخدام الفأرة لرسم بعض الرسوم، وسنُضيف إليه الآن إمكانية القراءة من والكتابة إلى ملف؛ وسيَسمَح ذلك للمُستخدِم بحفظ رسمة معينة بملف، وقراءتها لاحقًا من نفس الملف، مما يُمكِّنه من إكمال العمل عليها لاحقًا. يتطلّب ذلك حفظ جميع البيانات المُتعلّقة بالرسمة ضمن ملف، لكي يتمكَّن البرنامج من إعادة رسمها بالكامل مرةً أخرى بعد قراءته للملف الخاص بالرسمة. يُمكِنك الإطلاع على النسخة الأحدث من البرنامج بملف الشيفرة المصدرية SimplePaintWithFiles.java، والتي أضفنا إليها قائمة "File" تُنفِّذ الأمرين "Save" و "Open"؛ لحفظ بيانات البرنامج بملف وكذلك قراءة البيانات المحفوظة بملف مرةً أخرى إلى البرنامج على الترتيب. تتكوّن بيانات الرسمة من لون الخلفية، وقائمةً بالمنحنيات التي رسمها المُستخدِم. تَتكوَّن بيانات كل منحنًى منها من قائمة نقاطٍ من النوع Point2D المُعرَّف بحزمة javafx.geometry؛ فإذا كان pt متُغيِّرًا من النوع Point2D، فسيُعيد تابعي المُتغيّر pt.getX() و pt.getY() قيمًا من النوع double تُمثِّل إحداثيات تلك النقطة بالمستوى xy. يُمكِن تخصيص لون كل منحنًى على حدى، كما يُمكِن للمنحنى أن يكون "متماثلًا symmetric"؛ بمعنى أنه بالإضافة إلى رسم المنحنى نفسه، تُرسَم انعكاسات المنحنى الأفقية والرأسية أيضًا. تُخزَّن بيانات كل منحنًى ضمن كائنٍ من النوع CurveData المُعرَّف بالبرنامج على النحو التالي: // 1 private static class CurveData { Color color; // لون المنحنى boolean symmetric; // هل ينبغي رسم الانعكاسات الأفقية والرأسية؟ ArrayList<Point2D> points; // النقاط الموجودة على المنحنى } حيث أن [1] هو كائنٌ من النوع CurveData يُمثِّل البيانات المطلوبة لإعادة رسم إحدى المنحنيات التي رسمها المُستخدِم. سنَستخدِم قائمةً من النوع ArrayList<CurveData> لحمل بيانات جميع المنحنيات التي رَسَمَها المُستخدِم. لنفكر الآن بالطريقة التي سنَحفَظ بها بيانات الرسمة ضمن ملفٍ نصي. في العموم، علينا تخزين جميع البيانات الضرورية لإعادة رسم الرسمة بملف خرجٍ ووفقًا لصيغةٍ مُحدّدة. بعد ذلك، ينبغي أن يتبِّع التابع المسؤول عن قراءة الملف نفس الصيغة تمامًا أثناء قرائته للبيانات، حيث سيتعيّن عليه اِستخدَام تلك البيانات لإعادة بناء بنى البيانات data structures التي تُمثِّل نفس الرسمة بينما البرنامج مُشغَّل. سنضطّر عند كتابة البيانات إلى التعبير عنها باستخدام قيم بياناتٍ بسيطة، مثل سلسلةٍ نصية أو قيمةٍ تنتمي لأيٍّ من الأنواع الأساسية primitive types؛ حيث يُمكِننا مثلًا التعبير عن اللون باستخدام ثلاثة أعدادٍ تُمثِّل مكوّنات اللون الأحمر والأخضر والأزرق. قد تكون الفكرة الأولى التي تخطر بذهنك هو مجرد طباعة كل البيانات الضرورية وفقًا لترتيبٍ محدّد، وهي في الواقع ليست الفكرة الأفضل. لنفترض أن out كائنٌ من النوع PrintWriter المُستخدَم لكتابة البيانات بالملف، يُمكِننا إذًا كتابة ما يلي: Color bgColor = getBackground(); // اكتب لون الخلفية إلى الملف out.println( bgColor.getRed() ); out.println( bgColor.getGreen() ); out.println( bgColor.getBlue() ); out.println( curves.size() ); // اكتب عدد المنحنيات for ( CurveData curve : curves ) { // لكل منحنًى، اكتب ما يلي out.println( curve.color.getRed() ); // لون المنحنى out.println( curve.color.getGreen() ); out.println( curve.color.getBlue() ); out.println( curve.symmetric ? 0 : 1 ); // خاصية تماثل المنحنى out.println( curve.points.size() ); // عدد النقاط الموجودة على المنحنى for ( Point2D pt : curve.points ) { // إحداثيات كل نقطة out.println( pt.getX() ); out.println( pt.getY() ); } } سيتمكَّن التابع المسؤول عن معالجة الملف من قراءة بياناته، وإعادة إنشاء ما يُكافئها من بنية بيانات. إذا كان التابع يَستخدِم كائنًا من النوع Scanner، وليَكُن اسمه هو scanner لقراءة بيانات الملف، يُمكِننا إذًا كتابة ما يلي: Color newBackgroundColor; // اقرأ لون الخلفية double red = scanner.nextDouble(); double green = scanner.nextDouble(); double blue = scanner.nextDouble(); newBackgroundColor = Color.color(red,green,blue); ArrayList<CurveData> newCurves = new ArrayList<>(); int curveCount = scanner.nextInt(); // عدد المنحنيات المقروءة for (int i = 0; i < curveCount; i++) { CurveData curve = new CurveData(); double r = scanner.nextDouble(); // اقرأ لون المنحنى double g = scanner.nextDouble(); double b = scanner.nextDouble(); curve.color = Color.color(r,g,b); int symmetryCode = scanner.nextInt(); // اقرأ خاصية تماثل المنحنى curve.symmetric = (symmetryCode == 1); curveData.points = new ArrayList<>(); int pointCount = scanner.nextInt(); // عدد النقاط الموجودة على المنحنى for (int j = 0; j < pointCount; j++) { int x = scanner.nextDouble(); // اقرأ إحداثيات النقطة int y = scanner.nextDouble(); curveData.points.add(new Point2D(x,y)); } newCurves.add(curve); } curves = newCurves; // اضبط بنى البيانات الجديدة setBackground(newBackgroundColor); ينبغي أن يقرأ تابع الدْخَل البيانات الموجودة بالملف بنفس الترتيب الذي اِستخدَمه تابع الخرج أثناء كتابتها. في حين تَفِي تلك الطريقة بالغرض، لا يكون ملف البيانات الناتج مفهومًا للقارئ على الإطلاق، تمامًا كما لو كنا قد كتبنا الملف بالصيغة الثنائية binary format؛ فهو مكوّنٌ فقط من سلسلةٍ طويلةٍ من الأعداد. يجعل ذلك الملف هشًا؛ حيث سيؤدي أي تعديلٍ بسيطٍ على طريقة تمثيل البيانات ضمن إصدار أحدث من البرنامج، مثل إضافة خاصيةٍ جديدة إلى المنحنيات، إلى إهدار الملفات القديمة، إلا إذا وفَّر الملف معلومةً عن إصدار البرنامج المُستخدَم لإنشائه. ولهذا، قررنا الاعتماد على صيغة بياناتٍ أكثر تعقيدًا ولكنها ستُعطِي معنًى أكثر وضوحًا، فبدلًا من الاكتفاء بكتابة مجموعةٍ من الأعداد، اخترنا إضافة كلماتٍ إليها تُمثِّل معنى تلك الأعداد. نَعرِض فيما يلي مثالًا على ملف بيانات قصيرٍ نوعًا ما، لكنه يُبيّّن جميع الخاصيات المُدعَّمة حاليًا. ستتمكَّن غالبًا من فهم معناه بالكامل بمجرد قراءته: SimplePaintWithFiles 1.0 background 0.4 0.4 0.5 startcurve color 1 1 1 symmetry true coords 10 10 coords 200 250 coords 300 10 endcurve startcurve color 0 1 1 symmetry false coords 10 400 coords 590 400 endcurve يشير السطر الأول إلى البرنامج المسؤول عن إنشاء ملف البيانات، وسيتمكَّن بذلك البرنامج من إجراء اختبارٍ بسيطٍ على الملف الذي اختار المُستخدِم فتحَه، بفحص أول كلمةٍ موجودةٍ به، ويُمكِنه بناءً على ذلك التأكُّد مما إذا كان الملف من النوع الصحيح. بالإضافة إلى ذلك، يحتوي السطر الأول على رقم إصدار 1.0، والذي ينبغي أن يتغيّر إلى أرقام إصدارٍ أعلى في حال تغيّرت صيغة الملف في الإصدارات الأحدث من البرنامج. يستطيع البرنامج بذلك أن يَفحَص رقم إصدار الملف؛ فإذا كان البرنامج قادرًا على معالجة الملفات المكتوبة وفقًا للإصدار 1.0 فقط، ووجد أن صيغة الملف مكتوبةً وفقًا لإصدارٍ آخر، مثل 1.2، يُمكنِه أن يُوضِح للمُستخدِم أن عليه استخدام نسخةٍ أحدث من البرنامج ليتمكَّن من قراءة ملف البيانات ذاك. يُخصِّص السطر الثاني من البرنامج لون خلفية الصورة، وهو ما يَتضَح ببساطة من خلال كلمة "background" ببداية السطر، وتُمثِّل الأعداد الثلاثة مكوِّنات اللون الأحمر والأخضر والأزرق على الترتيب؛ بينما يُمثِّل الباقي من الملف بيانات المنحنيات المرسومة بالصورة. تَفصِل الكلمتان "startcurve" و "endcurve" بيانات كل منحنًى عن الآخر؛ والتي تتكوَّن من خاصيات اللون والتماثل وكذلك إحداثيات النقاط الواقعة على المنحنى. يُمكِننا إنشاء هذا النوع من الملفات يدويًا وتعديلها بسهولة، لأن معناها واضح، وقد أنشأنا ملف البيانات بالأعلى بواسطة محرر نصوص لا بواسطة البرنامج. يُمكِننا إضافة المزيد من الخيارات بسهولة؛ فقد تدعَم الإصدارات الأحدث من البرنامج مثلًا خاصية "السُمْك thickness" لرسم منحنياتٍ بخطوط عرضٍ مختلفة، ويُمكِنها أيضًا دعم رسم أشكالٍ أخرى، مثل المستطيلات والأشكال البيضاوية بنفس السهولة. من السهل أيضًا كتابة هذا النوع من البيانات عن طريق برنامج. لنفترض مثلًا أن out من النوع PrintWriter، وأننا سنَستخدِمه لكتابة بيانات الرسمة بملف، فستُجرِي الشيفرة التالية ذلك ببساطة: out.println("SimplePaintWithFiles 1.0"); // Version number. out.println( "background " + backgroundColor.getRed() + " " + backgroundColor.getGreen() + " " + backgroundColor.getBlue() ); for ( CurveData curve : curves ) { out.println(); out.println("startcurve"); out.println(" color " + curve.color.getRed() + " " + curve.color.getGreen() + " " + curve.color.getBlue() ); out.println( " symmetry " + curve.symmetric ); for ( Point2D pt : curve.points ) out.println( " coords " + pt.getX() + " " + pt.getY() ); out.println("endcurve"); } يَستخدِم التابع doSave() -ضمن هذا البرنامج- الشيفرة بالأعلى، وهو يُشبه كثيرًا التابع الذي عرضناه في الفصل السابق. لاحِظ أن هذا التابع يَستخدِم صندوق نافذة اختيار ملف ليَسمَح للمُستخدِم باختيار ملف الخرج. قد تكون قراءة بيانات الملف أصعب بعض الشيء؛ حيث ينبغي للبرنامج المسؤول عن قراءة الملف أن يتعامل مع كل تلك الكلمات الزائدة الموجودة به. اخترنا كتابة ذلك البرنامج بطريقةٍ تَسمَح بتبديل ترتيب ظهور البيانات ضمن الملف، فسيَسمَح البرنامج مثلًا بتخصيص لون الخلفية بنهاية الملف بدلًا من بدايته، كما سيَسمَح بعدم تخصيصها من الأساس، وسيَستخدِم البرنامج في تلك الحالة اللون الأبيض لونًا افتراضيًا للخلفية. تمكَّننا من إجراء ذلك بسبب عنونة كل عنصرٍ من البيانات بكلمةٍ تَصِف معناه، واعتمد البرنامج بالتالي على تلك الكلمات لاستنتاج ما ينبغي فعله. سيَقرأ ذلك التابع ملفات البيانات التي أنشأها التابع doSave()، وسيَستخدِم الصنف Scanner أثناء عملية القراءة. تَعرِض الشيفرة التالية شيفرة التابع بالكامل، والمُعرَّف ببرنامج SimplePaintWithFiles.java: private void doOpen() { FileChooser fileDialog = new FileChooser(); fileDialog.setTitle("Select File to be Opened"); fileDialog.setInitialFileName(null); // No file is initially selected. if (editFile == null) fileDialog.setInitialDirectory(new File(System.getProperty("user.home"))); else fileDialog.setInitialDirectory(editFile.getParentFile()); File selectedFile = fileDialog.showOpenDialog(window); if (selectedFile == null) return; // User canceled. Scanner scanner; try { scanner = new Scanner( selectedFile ); } catch (Exception e) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, but an error occurred\nwhile trying to open the file."); errorAlert.showAndWait(); return; } try { String programName = scanner.next(); if ( ! programName.equals("SimplePaintWithFiles") ) throw new IOException("File is not a SimplePaintWithFiles data file."); double version = scanner.nextDouble(); if (version > 1.0) throw new IOException("File requires a newer version of SimplePaintWithFiles."); Color newBackgroundColor = Color.WHITE; ArrayList<CurveData> newCurves = new ArrayList<CurveData>(); while (scanner.hasNext()) { String itemName = scanner.next(); if (itemName.equalsIgnoreCase("background")) { double red = scanner.nextDouble(); double green = scanner.nextDouble(); double blue = scanner.nextDouble(); newBackgroundColor = Color.color(red,green,blue); } else if (itemName.equalsIgnoreCase("startcurve")) { CurveData curve = new CurveData(); curve.color = Color.BLACK; curve.symmetric = false; curve.points = new ArrayList<Point2D>(); itemName = scanner.next(); while ( ! itemName.equalsIgnoreCase("endcurve") ) { if (itemName.equalsIgnoreCase("color")) { double r = scanner.nextDouble(); double g = scanner.nextDouble(); double b = scanner.nextDouble(); curve.color = Color.color(r,g,b); } else if (itemName.equalsIgnoreCase("symmetry")) { curve.symmetric = scanner.nextBoolean(); } else if (itemName.equalsIgnoreCase("coords")) { double x = scanner.nextDouble(); double y = scanner.nextDouble(); curve.points.add( new Point2D(x,y) ); } else { throw new Exception("Unknown term in input."); } itemName = scanner.next(); } newCurves.add(curve); } else { throw new Exception("Unknown term in input."); } } scanner.close(); backgroundColor = newBackgroundColor; curves = newCurves; redraw(); editFile = selectedFile; window.setTitle("SimplePaint: " + editFile.getName()); } catch (Exception e) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, but an error occurred while\ntrying to read the data:\n" + e); errorAlert.showAndWait(); } } لقد ناقشنا صيغ الملفات على هذا النحو المُفصَّل لنُحفِّزك على التفكير بمشكلة تمثيل البيانات المُعقّدة بصيغٍ يُمكِن تخزينها ضمن ملف، وسنتعرَّض لنفس المشكلة أثناء نقل البيانات عبر الشبكات. لا يُمكِننا في الواقع أن نقول أن حلًا معينًا هو الحل الصحيح لتلك المشكلة في العموم، ولكن بالطبع تُعدّ بعض الحلول أفضل من الأخرى، وسنُناقش في فصل لاحق واحدًا من أكثر الحلول شيوعًا لمشكلة تمثيل البيانات عمومًا. بالإضافة إلى قدرة البرنامج SimplePaintWithFiles على حفظ بيانات الرسوم بصيغٍ نصية، فإنه قادرٌ أيضًا على حفظها مثل ملفات صورٍ يُمكِن طباعتها أو وضعها بصفحة إنترنت على سبيل المثال. يُعدّ ذلك مثالًا عامًا على تقنيات معالجة الصور، والتي سنناقشها في جزئية لاحقة من هذه السلسلة، والتي تَستخدِم تقنيات أخرى لم نتعرَّض لها بعد. ترجمة -بتصرّف- للقسم Section 3: Programming With Files من فصل Chapter 11: Input/Output Streams, Files, and Networking من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: مدخل إلى التعامل مع الملفات في جافا كتابة أصناف وتوابع معممة في جافا الواجهات Interfaces في جافا التعاود recursion في جافا
-
كتبنا تنفيذًا للواجهة Map باستخدام التعمية hashing في مقالة تنفيذ الخرائط باستخدام التعمية hashing، وتوقَّعنا أن يكون ذلك التنفيذ أسرع لأن القوائم التي يبحث فيها أقصر، ولكن ما يزال ترتيب نمو order of growth ذلك التنفيذ خطّيًّا. إذا كان هناك عدد مقداره n من المُدْخَلات وعدد مقداره k من الخرائط الفرعية sub-maps، فإن حجم تلك الخرائط يُساوِي n/k في المتوسط، أي ما يزال متناسبًا مع n، ولكننا لو زدنا k مع n، سنتمكَّن من الحدِّ من حجم n/k. لنفترض على سبيل المثال أننا سنضاعف قيمة k في كلّ مرّةٍ تتجاوز فيها n قيمةَ k. في تلك الحالة، سيكون عدد المُدْخَلات في كلّ خريطةٍ أقلّ من 1 في المتوسط، وأقلّ من 10 على الأغلب بشرط أن تُوزِّع دالّةُ التعميةِ المفاتيحَ بشكلٍ معقول. إذا كان عدد المُدْخَلات في كلّ خريطةٍ فرعيّةٍ ثابتًا، سنتمكَّن من البحث فيها بزمنٍ ثابت. علاوة على ذلك، يَستغرِق حساب دالة التعمية في العموم زمنًا ثابتًا (قد يعتمد على حجم المفتاح، ولكنه لا يعتمد على عدد المفاتيح). بناءً على ما سبق، ستَستغرِق توابع Map الأساسية أي put و get زمنًا ثابتًا. سنفحص تفاصيل ذلك في التمرين التالي. تمرين 9 وفَّرنا التصور المبدئي لجدول تعمية hash table ينمو عند الضرورة في الملف MyHashMap.java. انظر إلى بداية تعريفه: public class MyHashMap<K, V> extends MyBetterMap<K, V> implements Map<K, V> { // متوسط عدد المُدْخَلات المسموح بها في كل خريطة فرعية قبل إعادة حساب شيفرات التعمية private static final double FACTOR = 1.0; @Override public V put(K key, V value) { V oldValue = super.put(key, value); // تأكّد مما إذا كان عدد العناصر في الخريطة الفرعية قد تجاوز الحد الأقصى if (size() > maps.size() * FACTOR) { rehash(); } return oldValue; } } يمتدّ الصنف MyHashMap من الصنف MyBetterMap، وبالتالي، فإنه يَرِث التوابع المُعرَّفة فيه. يعيد الصنف MyHashMap تعريفَ التابع put، فيَستدعِي أولًا التابع put في الصنف الأعلى superclass -أي يَستدعِي النسخة المُعرَّفة في الصنف MyBetterMap، ثم يَفحَص ما إذا كان عليه أن يُعيّد حساب شيفرة التعمية. يعيد التابع size عدد المُدْخَلات الكلية n بينما يعيد التابع maps.size عدد الخرائط k. يُحدّد الثابت FACTOR -الذي يُطلَق عليه اسم عامل الحمولة load factor- العدد الأقصى للمُدْخَلات وسطيًّا في كل خريطة فرعية. إذا تحقَّق الشرط n > k * FACTOR، فهذا يَعنِي أن الشرط n/k > FACTOR مُتحقِّق أيضًا، مما يَعنِي أن عدد المُدْخَلات في كلّ خريطةٍ فرعيّةٍ قد تجاوز الحد الأقصى، ولذلك، يكون علينا استدعاء التابع rehash. نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant MyHashMapTest. ستفشل الاختبارات لأن تنفيذ التابع rehash يُبلِّغ عن اعتراضٍ exception، ودورك هو أن تكمل متن هذا التابع. إذًا أكمل متن التابع rehash بحيث يُجمِّع المُدْخَلات الموجودة في الجدول. بعد ذلك، عليه أن يضبُط حجم الجدول، ويضيف المُدْخَلات إليه مرةً أخرى. وفَّرنا تابعين مساعدين هما MyBetterMap.makeMaps و MyLinearMap.getEntries. ينبغي أن يُضاعِف حلك عدد الخرائط k في كل مرة يُستدعَى فيها التابع. تحليل الصنف MyHashMap إذا كان عدد المُدْخَلات في أكبرِ خريطةٍ فرعيّةٍ متناسبًا مع n/k، وكانت الزيادة بقيمة k متناسبةً مع n، فإن العديد من التوابع الأساسية في الصنف MyBetterMap تُصبِح ثابتة الزمن: public boolean containsKey(Object target) { MyLinearMap<K, V> map = chooseMap(target); return map.containsKey(target); } public V get(Object key) { MyLinearMap<K, V> map = chooseMap(key); return map.get(key); } public V remove(Object key) { MyLinearMap<K, V> map = chooseMap(key); return map.remove(key); } يَحسِب كلّ تابعٍ شيفرةَ التعمية للمفتاح، وهو ما يَستغِرق زمنًا ثابتًا، ثم يَستدعِي تابعًا على خريطةٍ فرعيّةٍ، وهو ما يَستغِرق أيضًا زمنًا ثابتًا. ربما الأمورُ جيدةٌ حتى الآن، ولكن ما يزال من الصعب تحليل أداء التابع الأساسي الآخر put، فهو يَستغرِق زمنًا ثابتًا إذا لم يضطرّ لاستدعاء التابع rehash، ويَستغرِق زمنًا خطيًا إذا اضطرّ لذلك. بتلك الطريقة، يكون هذا التابع مشابهًا للتابع ArrayList.add الذي حللنا أداءه في مقالة "تحليل زمن تشغيل القوائم المُنفَّذة باستخدام مصفوفة". ولنفس السبب، يتضَّح أن التابع MyHashMap.put يَستغرِق زمنًا ثابتًا إذا حسبنا متوسط زمنِ متتاليةٍ من الاستدعاءات. يعتمد هذا التفسير على التحليل بالتسديد amortized analysis الذي شرحناه في نفس المقالة. لنفترض أن العدد المبدئيَّ للخرائط الفرعية k يساوي 2، وأن عامل التحميل يساوي 1، والآن، لنفحص الزمن الذي يَستغرِقه التابع put لإضافة متتاليةٍ من المفاتيح. سنَعُدّ عدد المرات التي سنضطرّ خلالها لحساب شيفرة التعمية لمفتاحٍ وإضافته لخريطةٍ فرعيّةٍ، وسيكون ذلك بمنزلةِ وحدةِ عملٍ واحدة. سيُنفِّذ التابع put عند استدعائه لأوّل مرةٍ وحدة عملٍ واحدةً من وحدات العمل، وسيُنفِّذ أيضًا عند استدعائه في المرة الثانية وحدةَ عملٍ واحدةٍ. أمّا في المرة الثالثة، فسيضطرّ لإعادة حساب شيفرات التعمية، وبالتالي، سيُنفِّذ عدد 2 من وحدات العمل لكي يَحسِب شيفرات تعمية المفاتيح الموجودة بالفعل بالإضافة إلى وحدة عملٍ أخرى لحساب شيفرة تعمية المفتاح الجديد. والآن، أصبح حجم الجدول 4، وبالتالي، سيُنفِّذ التابع put عند استدعائه في المرة التالية وحدة عملٍ واحدةٍ، ولكن، في المرة التالية التي سيضطرّ خلالها لاستدعاء rehash، فإنه سيُنفِّذ 4 وحدات عملٍ لحساب شيفرات تعميةِ المفاتيح الموجودة ووحدة عملٍ إضافيةٍ للمفتاح الجديد. تُوضِّح الصورة التالية هذا النمط، حيث يظهر العمل اللازم لحساب شيفرة تعمية مفتاحٍ جديدٍ في الأسفل بينما يَظهَر العمل الإضافي كبرج. إذا أنزلنا الأبراج كما تقترح الأسهم، سيملأ كلّ واحدٍ منها الفراغ الموجود قبل البرج التالي، وسنحصل على ارتفاعٍ منتظمٍ يساوي 2 وحدة عمل. يُوضِّح ذلك أن متوسط العمل لكل استدعاءٍ للتابع put هو 2 وحدة عمل، مما يَعنِي أنه يَستغرِق زمنًا ثابتًا في المتوسط. يُوضِح الرسم البياني مدى أهمية مضاعفة عدد الخرائط الفرعية k عندما نعيد حساب شيفرات التعمية؛ فلو أضفنا قيمةً ثابتةً إلى k بدلًا من مضاعفتها، ستكون الأبراج قريبة جدًا من بعضها، وستتراكم فوق بعضها، وعندها، لن نحصل على زمن ثابت. مقايضات رأينا أن التوابع containsKey و get و remove تَستغرِق زمنًا ثابتًا، وأن التابع put يَستغرِق زمنًا ثابتًا في المتوسط، وهذا أمرٌ رائعٌ بحق، فأداء تلك العمليات هو نفسه تقريبًا بغض النظر عن حجم الجدول. يَعتمِد تحليلنا لأداء تلك العمليات على نموذج معالجةٍ بسيطٍ تَستغرِق كلّ وحدةٍ عمل فيه نفس مقدار الزمن، ولكن الحواسيب الحقيقية أعقدُ من ذلك بكثير، فتبلغ أقصى سرعتها عندما تتعامل مع هياكل بيانات صغيرة بما يكفي لتُوضَع في الذاكرة المخبئية cache، وتكون أبطأ قليلًا عندما لا يتناسب حجم هياكل البيانات مع الذاكرة المخبئية ولكن مع إمكانية وضعها في الذاكرة، وتكون أبطأ بكثيرٍ إذا لم يتناسب حجم الهياكل حتى مع الذاكرة. هنالك مشكلةٌ أخرى، وهي أن التعمية لا تكون ذات فائدةٍ في هذا التنفيذ إذا كان المُدْخَل قيمةً وليس مفتاحًا، فالتابع containsValue خطّيٌّ لأنه مضطرّ للبحث في كل الخرائط الفرعية، فليس هناك طريقةٌ فعالةٌ للبحث عن قيمة ما والعثور على مفتاحها المقابل (أو مفاتيحها). بالإضافة إلى ما سبق، فإن بعض التوابع التي كانت تَستغرِق زمنًا ثابتًا في الصنف MyLinearMap قد أصبحت خطّيّةً. انظر إلى التابع التالي على سبيل المثال: public void clear() { for (int i=0; i<maps.size(); i++) { maps.get(i).clear(); } } يضطرّ التابع clear لتفريغ جميع الخرائط الفرعيّةِ التي يتناسب عددها مع n، وبالتالي، هذا التابعُ خطّيٌّ. لحسن الحظ، لا يُستخدَم هذا التابع كثيرًا، ولذا فما يزال هذا التنفيذ مقبولًا في غالبية التطبيقات. تشخيص الصنف MyHashMap سنفحص أولًا ما إذا كان التابع MyHashMap.put يستغرق زمنًا خطيًا. نفِّذ الأمر ant build لتصريف ملفات الشيفرة، ثم نفِّذ الأمر ant ProfileMapPut. يقيس الأمر زمن تشغيل التابع HashMap.put (الذي توفِّره جافا) مع أحجام مختلفةٍ للمشكلة، ويَعرِض زمن التشغيل مع حجم المشكلة بمقياس لوغاريتمي-لوغاريتمي. إذا كانت العملية تستغرق زمنًا ثابتًا، ينبغي أن يكون الزمن الكليُّ لعدد n من العمليات خطيًّا، ونحصل عندها على خطٍّ مستقيمٍ ميله يساوي 1. عندما شغَّلنا تلك الشيفرة، كان الميل المُقدَّر قريبًا من 1، وهو ما يتوافق مع تحليلنا للتابع. ينبغي أن تحصل على نتيجةٍ مشابهة. عدِّل الصنف ProfileMapPut.java لكي يُشخِّص التنفيذ MyHashMap الخاص بك وليس تنفيذ الجافا HashMap. شغِّل شيفرة التشخيص مرةً أخرى، وافحص ما إذا كان الميل قريبًا من 1. قد تضطرّ إلى تعديل قيم startN و endMillis لكي تعثر على نطاقٍ مناسبٍ من أحجام المشكلة يبلغ زمن تشغيلها أجزاءَ صغيرةً من الثانية، وفي نفس الوقت لا يتعدى بضعة آلاف. عندما شغّلنا تلك الشيفرة، وجدنا أن الميلَ يساوي 1.7 تقريبًا، مما يشير إلى أن ذلك التنفيذ لا يستغرق زمنًا ثابتًا. في الحقيقة، هو يحتوي على خطأٍ برمجيٍّ مُتعلِّقٍ بالأداء. عليك أن تعثر على ذلك الخطأِ وتصلحَه وتتأكَّد من أن التابع put يستغرق زمنًا ثابتًا كما كنا نتوقّع قبل أن تنتقل إلى القسم التالي. إصلاح الصنف MyHashMap تتمثل مشكلة الصنف MyHashMap بالتابع size الموروث من الصنف MyBetterMap. انظر إلى شيفرته فيما يلي: public int size() { int total = 0; for (MyLinearMap<K, V> map: maps) { total += map.size(); } return total; } كما ترى يضطرّ التابع للمرور عبر جميع الخرائط الفرعية لكي يحسب الحجم الكلّيّ. نظرًا لأننا نزيد عدد الخرائط الفرعية k بزيادة عدد المُدْخَلات n، فإن k يتناسب مع n، ولذلك، يستغرق تنفيذ التابع size زمنًا خطّيًّا. يجعل ذلك التابع put خطّيًّا أيضًا لأنه يَستخدِم التابع size كما هو مُبيَّنٌ في الشيفرة التالية: public V put(K key, V value) { V oldValue = super.put(key, value); if (size() > maps.size() * FACTOR) { rehash(); } return oldValue; } إذا تركنا التابع size خطّيًّا، فإننا نهدر كل ما فعلناه لجعل التابع put ثابتَ الزمن. لحسن الحظ، هناك حلٌّ بسيطٌ رأيناه من قبل، وهو أننا سنحتفظ بعدد المُدْخَلات ضمن متغير نسخة instance variable، وسنُحدِّثه كلما استدعينا تابعًا يُجرِي تعديلًا عليه. ستجد الحل في مستودع الكتاب في الملف MyFixedHashMap.java. انظر إلى بداية تعريف الصنف: public class MyFixedHashMap<K, V> extends MyHashMap<K, V> implements Map<K, V> { private int size = 0; public void clear() { super.clear(); size = 0; } بدلًا من تعديل الصنف MyHashMap، عرَّفنا صنفًا جديدًا يمتدّ منه، وأضفنا إليه متغير النسخة size، وضبطنا قيمتَه المبدئيَّة إلى صفر. أجرينا أيضًا تعديلًا بسيطًا على التابع clear. استدعينا أولًا نسخة clear المُعرَّفة في الصنف الأعلى (لتفريغ الخرائط الفرعية)، ثم حدثنا قيمة size. كانت التعديلات على التابعين remove و put أعقد قليلًا؛ لأننا عندما نستدعي نسخها في الصنف الأعلى، فإننا لا نستطيع معرفة ما إذا كان حجم الخرائط الفرعيّة قد تغيّر أم لا. تُوضِّح الشيفرة التالية الطريقة التي حاولنا بها معالجة تلك المشكلة: public V remove(Object key) { MyLinearMap<K, V> map = chooseMap(key); size -= map.size(); V oldValue = map.remove(key); size += map.size(); return oldValue; } يَستخدِم التابعُ remove التابعَ chooseMap لكي يعثر على الخريطة المناسبة، ثم يَطرَح حجمها. بعد ذلك، يَستدعِي تابع الخريطة الفرعية remove الذي قد يُغيّر حجم الخريطة، حيث يعتمد ذلك على ما إذا كان قد وجد المفتاح فيها أم لا، ثم يضيف الحجم الجديد للخريطة الفرعية إلى size، وبالتالي تصبح القيمة النهائية صحيحة. أعدنا كتابة التابع put باتباع نفس الأسلوب: public V put(K key, V value) { MyLinearMap<K, V> map = chooseMap(key); size -= map.size(); V oldValue = map.put(key, value); size += map.size(); if (size() > maps.size() * FACTOR) { size = 0; rehash(); } return oldValue; } واجهنا نفس المشكلة هنا: عندما استدعينا تابع الخريطة الفرعية put، فإننا لا نعرف ما إذا كان قد أضاف مدخلًا جديدًا أم لا، ولذلك استخدمنا نفس الحل، أي بطرح الحجم القديم، ثم إضافة الحجم الجديد. والآن، أصبح تنفيذ التابع size بسيطًا: public int size() { return size; } ويستغرق زمنًا ثابتًا بوضوح. عندما شخَّصنا أداء هذا الحل، وجدنا أن الزمنَ الكلّيَّ لإضافة عدد n من المفاتيح يتناسب مع n، ويعني ذلك أن كلّ استدعاءٍ للتابع put يستغرق زمنًا ثابتًا كما هو مُتوقّع. مخططات أصناف UML كان أحد التحديات التي واجهناها عند العمل مع شيفرة هذا المقال هو وجود عددٍ كبيرٍ من الأصناف التي يعتمد بعضها على بعض. انظر إلى العلاقات بين تلك الأصناف: MyLinearMap يحتوي على LinkedList ويُنفِّذ Map. MyBetterMap يحتوي على الكثير من كائنات الصنف MyLinearMap ويُنفِّذ Map. MyHashMap يمتد من الصنف MyBetterMap، ولذلك يحتوي على كائناتٍ تنتمي إلى الصنف MyLinearMap ويُنفِّذ Map. MyFixedHashMap يمتد من الصنف MyHashMap ويُنفِّذ Map. لتسهيلِ فهمِ هذا النوع من العلاقات، يلجأ مهندسو البرمجيات إلى استخدامِ مخططاتِ أصنافِ UML -اختصارً إلى لغة النمذجة الموحدة Unified Modeling Language. تُعدّ مخططات الأصناف class diagram واحدةً من المعايير الرسومية التي تُعرِّفها UML. يُمثَّل كل صنفٍ في تلك المخططات بصندوق، بينما تُمثَل العلاقات بين الأصناف بأسهم. تَعرِض الصورة التالية مخطط أصناف UML للأصناف المُستخدَمة في التمرين السابق، وهي مُولَّدةٌ تلقائيًّا باستخدام أداة yUML المتاحة عبر الإنترنت. تُمثَّل العلاقات المختلفة بأنواعٍ مختلفة من الأسهم: تشير الأسهم ذات الرؤوس الصلبة إلى علاقات من نوع HAS-A. على سبيل المثال، تحتوي كلّ نسخةٍ من الصنف MyBetterMap على نسخٍ متعددةٍ من الصنف MyLinearMap، ولذلك هي متصلة بأسهم صلبة. تشير الأسهم ذات الرؤوس المجوفةِ والخطوطِ الصلبة إلى علاقات من نوع IS-A. على سبيل المثال، يمتد الصنف MyHashMap من الصنف MyBetterMap، ولذلك ستجدهما موصولين بسهم IS-A. تشير الأسهم ذات الرؤوس المجوّفة والخطوط المتقطّعة إلى أن الصنف يُنفِّذ واجهة. تُنفِّذ جميع الأصناف في هذا المخطط الواجهةَ Map. تُوفِّر مخططات أصناف UML طريقةً موجزةً لتوضيح الكثير من المعلومات عن مجموعةٍ من الأصناف، وتُستخدَم عادةً في مراحل التصميم للإشارة إلى تصاميمَ بديلة، وفي مراحل التنفيذ لمشاركة التصور العام عن المشروع، وفي مراحل النشر لتوثيق التصميم. ترجمة -بتصرّف- للفصل Chapter 11: HashMap من كتاب Think Data Structures: Algorithms and Information Retrieval in Java. اقرأ أيضًا المقال التالي: تحليل زمن تشغيل الخرائط المنفذة باستخدام شجرة بحث ثنائية TreeMap في جافا المقال السابق: تنفيذ الخرائط باستخدام التعمية hashing في جافا تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا استخدام خريطة ومجموعة لبناء مفهرس Indexer
-
ظل البيانات والبرامج المُخزَّنة بذاكرة الحاسوب الرئيسية main memory متوفرةً طوال فترة تشغيله، ولكن يجب الاستعانة بالملفات للإبقاء عليها بصورة دائمة؛ حيث تمثِّل مجموعةً من البيانات المُخزَّنة بقرصٍ صلب hard disk، أو بشريحة ذاكرة USB، أو بقرصٍ مضغوط CD-ROM، أو بأي نوعٍ آخر من أجهزة التخزين. تُنظَّم الملفات داخل مجلدات، وبإمكان كل مجلدٍ أن يحتوي على مجلداتٍ أخرى إلى جانب الملفات، كما يَملُك كل مجلدٍ وملف اسمًا يُعرِّف هويته. تستطيع البرامج عمومًا قراءة البيانات من ملفاتٍ موجودة، وكذلك إنشاء ملفاتٍ جديدة وكتابة البيانات بها، وتعتمد جافا على مجاري تدفق streams الدْخل والخرج لفعل ذلك؛ حيث تُستخدَم الكائنات المنتمية للصنف FileReader -وهو صنفٌ فرعيٌ من الصنف Reader- لقراءة البيانات المحرفية المهيأة للقراءة human-readable من ملفٍ معين؛ وتُستخدَم بالمثل الكائنات المنتمية للصنف FileWriter -وهو صنفٌ فرعيٌ من الصنف Writer- لكتابة البيانات المهيأة للقراءة بملف. يُستخدَم الصنفان FileInputStream و FileOutputStream للتعامل مع الملفات التي تُخزِّن البيانات بصيغةٍ مهيأة للآلة. سنناقش خلال هذا المقال الأصناف التي تتعامل مع الملفات بالصيغة المحرفية فقط، أي الصنفين FileReader و FileWriter، ولكن تذكَّر أن الصنفين FileInputStream و FileOutputStream يُستخدمان في العموم بنفس الطريقة. لاحِظ أن كلَّ تلك الأصناف مُعرَّفةٌ في حزمة java.io. قراءة الملفات والكتابة بها يستقبل باني constructor الصنف FileReader اسم ملف معين مثل معاملٍ parameter، ويُنشِئ مجرى stream مُدْخَلات لقراءة محتويات ذلك الملف؛ فإذا لم يَكُن الملف المُخصَّص موجودًا، فسيُبلِّغ الباني عن استثناء exception من النوع FileNotFoundException. بفرض لدينا ملفٌ اسمه "data.txt"، ونريد قراءة البيانات الموجودة به، يُمكِننا إذًا إنشاء مجرى مُدْخَلات لذلك الملف بكتابة ما يلي: FileReader data; // 1 try { data = new FileReader("data.txt"); // أنشِئ المجرى } catch (FileNotFoundException e) { ... // عالج الخطأ المُحتمَل } حيث تعني [1]: صرِّح عن المُتغيّر قبل تعليمة try؛ وإلا سيُصبِح محليًا ضمن كتلة try، ولن تتمكَّن من اِستخدَامه بالبرنامج لاحقًا. يُمكِننا في الواقع ضبط تعليمة try...catch بالأعلى، بحيث تلتقط استثناءات الصنف IOException؛ لأن الصنف FileNotFoundException هو بالنهاية صنفٌ فرعيٌ subclass من الصنف IOException، ويُمكِننا في العموم التقاط أي خطأٍ يحدث أثناء عمليات الدخل والخرج باستخدام عبارة catch خُصِّصت لمعالجة الاستثناءات من النوع IOException. يُمكِننا أن نبدأ بقراءة البيانات من كائنات الصنف FileReader بمجرد إنشائها، ولكن نظرًا لعدم تضمُّنها سوى بعض التوابع البسيطة، فسنضطّر عادةً إلى تغليفها ضمن كائنٍ من النوع Scanner أو النوع BufferedReader أو أي صنفٍ مُغلِّف آخر. انظر المقال السابق لمزيدٍ من المعلومات عن الصنفين BufferedReader و Scanner. تُنشِئ الشيفرة التالية كائنًا من النوع BufferedReader لقراءة بيانات ملفٍ اسمه "data.dat": BufferedReader data; try { data = new BufferedReader( new FileReader("data.dat") ); } catch (FileNotFoundException e) { ... // عالج الاستثناء } يُسهِل تغليف كائنات الصنف Reader بكائناتٍ تنتمي للصنف BufferedReader من قراءة أسطر الملفات، كما تُعزِّز خاصية التخزين المؤقت buffering من كفاءتها. يُمكِننا بنفس الكيفية إنشاء كائنٍ من الصنف Scanner لقراءة بيانات ملفٍ معين، مع أننا نلجأ عادةً في مثل تلك الحالات إلى إنشاء كائنٍ من النوع File مباشرةً (سنناقش ذلك بالأسفل): Scanner in; try { in = new Scanner( new File("data.dat") ); } catch (FileNotFoundException e) { ... // عالِج الاستثناء } ينطبق الأمر نفسه على ملفات الخرج؛ حيث ينبغي في تلك الحالة إنشاء كائنٍ من النوع FileWriter، والذي نلجأ عادةً إلى تغليفه ضمن كائنٍ من النوع PrintWriter. قد يُبلِّغ باني الصنف FileWriter عن استثناء من النوع IOException؛ ولهذا ينبغي أن نحيطه بتعليمة try..catch. لنفترض مثلًا أننا نريد كتابة بياناتٍ معينة بملفٍ اسمه "result.dat"، يُمكِننا أن نَستخدِم الشيفرة التالية: PrintWriter result; try { result = new PrintWriter(new FileWriter("result.dat")); } catch (IOException e) { ... // عالِج الاستثناء } كما هو الحال مع الصنف Scanner، نُمرِّر عادةً في تلك الحالات معاملًا من النوع File لباني الصنف PrintWriter، ويؤدي ذلك إلى تغليف كائن الصنف File تلقائيًا ضمن كائنٍ ينتمي للصنف FileWriter، ثم يُنشِئ الحاسوب بعدها كائنًا من الصنف PrintWriter. انظر الشيفرة التالية: PrintWriter result; try { result = new PrintWriter(new File("result.dat")); } catch (IOException e) { ... // عالِج الاستثناء } بإمكاننا أيضًا أن نُمرِّر للباني سلسلةً نصيةً من النوع String، ويَعُدُّها الباني في تلك الحالة اسمًا لملف؛ بينما لو مرَّرنا سلسلةً نصيةً من النوع String إلى باني الصنف Scanner، فإنه لا يَعُدّها اسمًا لملف، وإنما يقرأ محارف السلسلة النصية ذاتها. في حالة عدم وجود ملفٍ اسمه "result.dat"، يُنشَئ ملفٌ جديدٌ بنفس الاسم؛ أما إذا كان موجودًا بالفعل، تَحِلّ البيانات التي يُفترض من البرنامج كتابتها بالملف محلّ محتوياته الحالية. لاحِظ أنك لن تتلقَّ أي تحذيرٍ بشأن ذلك. إذا أردت تجنُّب حدوث ذلك، عليك أن تفحص أولًا فيما إذا كان هناك ملفٌ بنفس الاسم قبل إنشاء مجرًى له كما سنناقش لاحقًا. قد يُبلِّغ باني الصنف PrintWriter عن استثناء من النوع IOException، إذا حاولت إنشاء ملفٍ داخل قرصٍ غير مسموحٍ بالكتابة به، أي لا يُمكِن تعديله. عندما تُنهِي عملك مع كائن من الصنف PrintWriter، يجب أن تستدعِي تابعه flush() بكتابة شيءٍ مثل result.flush()؛ وذلك حتى تتأكَّد من إرسال الخرج بالكامل إلى مقصده؛ وإذا نسيت أن تَستدعِيه، قد لا يظهر بالملف بعض البيانات التي أرسلتها إليه. بعد أن تُنهِي تعاملك مع ملفٍ معين، يُفضَّل أن تغلقه؛ بمعنى أن تُبلِّغ نظام التشغيل أنك انتهيت من اِستخدَامه. يُمكِنك أن تَستدعِي التابع close() المُعرَّف بالصنف PrintWriter، أو BufferedReader، أو Scanner حتى تغلق الملف. بمجرد إغلاق ملفٍ معين، لا يُمكِنك أن تقرأ بياناته، أو أن تُرسِل إليه أية بيانات، إلا إذا أعدت فتحه مرةً أخرى بإنشاء مجرًى جديد. قد يُبلِّغ التابع close() بغالبية أصناف المجاري -بما في ذلك الصنف BufferedReader- عن حدوث استثنناء من النوع IOException، والذي لا بُدّ من معالجته. يُعيد لحسن الحظ الصنفان PrintWriter و Scanner تعريف override ذلك التابع لمنعه من التبليغ عن مثل تلك الاستثناءات. إذا نسيت إغلاق ملفٍ معين، فإنه يُغلَق أوتوماتيكيًا بعد انتهاء البرنامج أو قد يُغلّق قبل ذلك بواسطة كانس المهملات garbage collection، ولكن لا يُفضَّل الاعتماد على ذلك. يقرأ البرنامج التالي أعدادًا من ملفٍ اسمه "data.dat"، ثم يعيد كتابة نفس تلك الأعداد، ولكن بترتيبٍ معاكس إلى ملفٍ آخر اسمه "result.dat". (ملاحظة: يَفترِض البرنامج احتواء الملف "data.dat" على أعدادٍ حقيقية فقط). يَستخدِم هذا البرنامج الصنف Scanner لقراءة ملف الدْخَل، كما يعتمد على معالجة الاستثناءات لفحص المشكلات المحتملة. قد لا يكون البرنامج التالي مفيدًا تمامًا، ولكنه يُظهِر على الأقل أساسيات التعامل مع الملفات بوضوح: import java.io.*; import java.util.ArrayList; import java.util.Scanner; // 1 public class ReverseFileWithScanner { public static void main(String[] args) { Scanner data; // لقراءة البيانات PrintWriter result; // مجرى محارف خرج لإرسال البيانات ArrayList<Double> numbers; // قائمة لحمل البيانات numbers = new ArrayList<Double>(); try { // أنشئ مجرى دخل data = new Scanner(new File("data.dat")); } catch (FileNotFoundException e) { System.out.println("Can't find file data.dat!"); return; // أنهِ البرنامج بالعودة من البرنامج } try { // أنشِئ مجرى خرج result = new PrintWriter("result.dat"); } catch (FileNotFoundException e) { System.out.println("Can't open file result.dat!"); System.out.println("Error: " + e); data.close(); // أغلق الملف return; // أنهِ البرنامج } while ( data.hasNextDouble() ) { // اقرأ الملف حتى نهايته double inputNumber = data.nextDouble(); numbers.add( inputNumber ); } // اطبع الأعداد بترتيبٍ معكوس for (int i = numbers.size()-1; i >= 0; i--) result.println(numbers.get(i)); System.out.println("Done!"); data.close(); result.close(); } // end of main() } // end class ReverseFileWithScanner حيث أن [1] يقرأ البرنامج الأعداد من ملفٍ اسمه "data.dat"، ثم يكتبها إلى ملفٍ اسمه "result.dat" بترتيبٍ معكوس. لا بُدّ أن يحتوي الملف المُدْخَل على أعدادٍ حقيقيةٍ فقط. يتوقف البرنامج السابق عن قراءة بيانات الملف بمجرد قراءته لمُدْخَلٍ غير عددي، ولا يَعُدّه خطأً. كما ذكرنا بنهاية مقال الاستثناءات exceptions وتعليمة try..catch في جافا، يَشيع نمط إنشاء "موردٍ resource" معينٍ أو فتحه، ثم اِستخدَامه، وغلقه، وهو نمطٌ مدعومٌ من قِبَل تعليمة try..catch. بحسب هذا السياق، تُعدّ الملفات بمثابة مواردٍ مثلها مثل أصناف Scanner و PrintWriter وغيرها من مجاري جافا للدْخَل والخرج. تُعرِّف جميع تلك الموارد التابع close()، ويُفضَّل طبعًا إغلاقها بعد الانتهاء من اِستخدَامها. نظرًا لأن تلك الأصناف تُنفِّذ الواجهة AutoCloseable، تُعدُّ جميعها مواردًا بحسب تعليمة try..catch، ولهذا يُمكِننا إذًا أن نَستخدِم تلك التعليمة لإغلاق الموارد أوتوماتيكيًا بمجرد انتهاء تنفيذ التعليمة دون الحاجة إلى إغلاقها يدويًا ضمن تعليمة finally، وذلك بفرض أنك فتحت المورد واِستخدَمته ضمن نفس تعليمة try..catch. يُعدّ البرنامج التوضيحي ReverseFileWithResources.java نسخةً أخرى من المثال الذي تعرَّضنا له بالأعلى، حيث يَستخدِم البرنامج تعليمات try..catch لقراءة البيانات من ملف، وكتابتها إلى ملفٍ آخر. كنا قد فتحنا الملف ضمن تعليمة try، واِستخدَمناه ضمن تعليمة try أخرى بالنسخة الأصلية من البرنامج. يتطلّب في المقابل نمط المورد حدوث الخطوتين ضمن تعليمة try واحدة، ولهذا علينا إعادة ترتيب الشيفرة، وهو ما قد يُصعِّب معرفة مصدر الاستثناء. تتضمَّن الشيفرة التالية تعليمة try..catch واحدةً مسؤولةً عن فتح ملف المُدْخَلات وقرائته وغلقه أتوماتيكيًا: try( Scanner data = new Scanner(new File("data.dat")) ) { // اقرأ الأعداد وأضِفها إلى المصفوفة while ( data.hasNextDouble() ) { // اقرأ حتى تصل إلى نهايته double inputNumber = data.nextDouble(); numbers.add( inputNumber ); } } catch (FileNotFoundException e) { // قد يحدث إذا لم يكن الملف موجودًا أو لا يُمكِن قراءته System.out.println("Can't open input file data.dat!"); System.out.println("Error: " + e); return; // عند حدوث خطأmain() العودة من } يُنشِئ السطر الأول المورد data. تتضمَّن قواعد الصيغة syntax لتعليمة try التصريح عن المورد وإعطائه قيمةً مبدئية داخل أقواسٍ بعد كلمة try. يُمكِننا أن نُصرِّح عن عدة مواردٍ يَفصِل بينها فاصلةٌ منقوطة، وتُغلَق جميعها بترتيبٍ معاكسٍ لترتيب التصريح عنها. الملفات والمجلدات هناك بعض الجوانب الأخرى المُتعلِّقة بأسماء الملفات، والتي لم نذكرها حتى الآن. بدايةً، إذا أردنا أن نُشير إلى ملفٍ مُحدَّد بوضوح، فلا بُدّ أن نوفِّر معلوماتٍ كافيةً عن كُلٍ من اسم الملف واسم المجلد الواقع به؛ لأنك إذا استخدمت اسم ملفٍ بسيطٍ، مثل "data.dat"، أو "result.dat"، فسيَفترِض الحاسوب وجود ذلك الملف بمجلدٍ يُعرَف باسم "المجلد الحالي current directory أو المجلد الافتراضي أو مجلد العمل"، والذي لا يُمثِل مكانًا ثابتًا، فقد يُغيِّره المُستخدِم أو حتى البرنامج. ولهذا، إذا أردت أن تُشير إلى ملفٍ معين، وكان ذلك الملف موجودًا بمجلدٍ غير المجلد الحالي، فيجب أن تشير إليه بواسطة مساره؛ أي بتوفير معلوماتٍ عن كُلٍ من اسم الملف، واسم المجلد الواقع به الملف. يتوفَّر نوعان من أسماء المسارات، وهو ما قد يُعقِّد الأمور قليلًا، وهما: أسماء مطلقة للمسارات absolute path names، وأسماء نسبية للمسارات relative path names؛ حيث يُحدِّد الاسم المطلق للمسار اسم ملفٍ واحدٍ فقط من بين جميع الملفات المُتاحة بالحاسوب بوضوح، بسبب احتواء اسم المسار في تلك الحالة على كافة المعلومات المُتعلِّقة باسم الملف وبالمجلد المُتضمِّن له؛ بينما يُوضِّح الاسم النسبي للمسار الكيفية التي يستطيع الحاسوب بها العثور على الملف بدءًا من المجلد الحالي. تختلف مع الأسف قواعد صيغة كُلٍ من أسماء الملفات والمسارات من حاسوبٍ إلى آخر إلى حدٍ ما. ألقِ نظرةً على بعض الأمثلة على ذلك: data.dat: يُمثِّل ملفًا اسمه "data.dat" مع فرض وجوده بالمجلد الحالي. ينطبق ذلك على أي حاسوب. /home/eck/java/examples/data.dat: يُمثِّل الاسم المطلق لمسارٍ معين بأنظمة تشغيل UNIX، بما في ذلك Linux و Mac OS X، ويُشير إلى ملفٍ اسمه "data.dat"، موجودٍ بمجلدٍ اسمه "examples"، موجودٍ بدوره بمجلدٍ اسمه "java"، وهكذا. C:\eck\java\examples\data.dat: يُمثِّل الاسم المطلق لمسارٍ معيّن بأنظمة تشغيل Windows. examples/data.dat: يُمثِّل الاسم النسبي لمسارٍ معيّن بأنظمة تشغيل UNIX، حيث يُمثِّل "examples" اسم مجلدٍ يُفترَض وجوده بالمجلد الحالي؛ أما "data.dat" فهو اسم ملفٍ موجودٍ ضمن المجلد "examples". الاسم النسبي المكافئ لذلك المسار بأنظمة تشغيل Windows هو "examples\data.dat". ../examples/data.dat: يُمثِّل الاسم النسبي لمسارٍ معيّنٍ بأنظمة تشغيل UNIX، ويَعنِي ما يلي: اذهب إلى المجلد المُتضمِّن للمجلد الحالي.، حيث ستَجِد هناك مجلدًا اسمه "examples"، اذهب إليه وستعثُر على ملفٍ اسمه "data.dat". تعني ".." عُدّ مجلدًا واحدًا للوراء. يُمثِّل "..\examples\data.dat" نفس المسار بأنظمة Windows. إذا كنت تتعامل مع الملفات من خلال برنامج سطر أوامر، وكانت أسماء الملفات بسيطةً نوعًا ما، ومُخزَّنة أيضًا بنفس مجلد البرنامج، فقد تسير الأمور على ما يرام. سنرى لاحقًا في هذا المقال طريقةً أفضل تَسمَح للمُستخدِم باختيار الملفات من خلال برنامج واجهة مُستخدِم رسومية، وهو ما يساعد على تجنُّب مشكلات أسماء المسارات تمامًا. تستطيع برامج جافا الإشارة إلى الاسم المطلق لمساري مجلدين مهمين، هما المجلد الحالي والمجلد الرئيسي للمُستخدِم؛ حيث تُعدّ أسماء تلك المجلدات خاصياتٍ بالنظام، ويُمكِن قراءتها باستدعاء الدوال التالية: System.getProperty("user.dir"): تعيد قيمةً من النوع String تُمثِّل الاسم المطلق لمسار المجلد الحالي. System.getProperty("user.home"): تعيد قيمةً من النوع String تُمثِّل الاسم المطلق لمسار المجلد الرئيسي للمُستخدِم. تُوفِّر جافا الصنف java.io.File، والذي يُجنِّبنا لحسن الحظ كثيرًا من المشكلات المتعلّقة بالاختلافات بين أسماء المسارات بالمنصات المختلفة. لا يُمثِّل الكائن المنتمي لهذا الصنف أي ملفٍ تمثيلًا فعليًا، وإنما يُمثِّل "اسم الملف"، ومن الممكن أن يكون الملف الذي يُشير إليه الاسم موجودًا أو غير موجود. ينطبِق الأمر ذاته على المجلدات؛ أي يُمكِن لكائنٍ من النوع File أن يُمثِّل مجلدًا معينًا بنفس الكيفية التي يُمكِنه بها أن يُمثِّل ملفًا. يَستقبِل الباني new File(String) المُعرَّف بطبيعة الحال بالصنف File اسمًا لمسارٍ معين، ويُنشِئ كائنًا من النوع File يُشير إلى الملف الموجود بذلك المسار. يُمكِن لاسم المسار المُمرَّر أن يكون بسيطًا أو نسبيًا أو مُطلقًا. على سبيل المثال، يُنشِئ الباني new File("data.dat") كائنًا من النوع File يُشير إلى ملفٍ اسمه "data.dat" بالمجلد الحالي. يتوفَّر باني آخر هو new File(File,String)، والذي يَستقبِل مُعاملين: الأول هو كائنٌ من النوع File يُشير إلى مجلدٍ معين، أما الثاني فيُمكِنه أن يكون اسمًا لملفٍ موجودٍ ضمن المجلد المُخصَّص، أو مسارًا نسبيًا من ذاك المجلد إلى الملف المطلوب. تتضمَّن كائنات الصنف File توابع نسخ instance methods مفيدة. بفرض أن file هو مُتغيّرٌ من النوع File، يُمكِننا أن نَستخدِم أيًا من التوابع التالية: file.exists(): يُعيد القيمة المنطقية true إذا كان الملف الذي يُخصِّصه الكائن file موجودًا. اِستخدِم هذا التابع إذا أردت تجنُّب كتابة بياناتك بينما تُنشِئ مجرى خرجٍ جديد على ملفٍ موجودٍ مُسبقًا. تعيد الدالة file.canRead() القيمة true إذا كان الملف موجودًا وكان البرنامج يَملُك صلاحيةً لقرائته؛ بينما تعيد الدالة file.canWrite() القيمة true إذا كان البرنامج يَملُك صلاحيةً للكتابة بذلك الملف. file.isDirectory(): يُعيد القيمة المنطقية true إذا كان file يشير إلى مجلدٍ ما؛ بينما يعيد القيمة false إذا كان الكائن يشير إلى ملفٍ سواءً كان ذلك الملف موجودًا أم لا. file.delete(): يحذِف الملف إذا كان موجودًا، ويعيد قيمةً منطقيةً للدلالة على نجاح عملية الحذف أو فشلها. file.list(): إذا كان file يشير إلى مجلد، فستُعيد الدالة مصفوفةً من النوع String[] تحتوي على أسماء الملفات الموجودة بذلك المجلد؛ أما إذا لم يَكن كذلك، فستُعيد القيمة الفارغة null. يعمل التابع file.listFiles() بنفس الطريقة باستثناء أنه يعيد مصفوفةً عناصرها من النوع File وليس String. يُنشِئ البرنامج التالي قائمةً بأسماء جميع الملفات الموجودة بمجلدٍ معين يُخصِّصه المُستخدِم. لاحِظ أننا اِستخدَمنا الصنف Scanner من أجل قراءة مُدخَلات المُستخدِم: import java.io.File; import java.util.Scanner; // 1 public class DirectoryList { public static void main(String[] args) { String directoryName; // اسم المجلد الذي أدخله المُستخدِم File directory; // كائنٌ يشير إلى المجلد String[] files; // مصفوفة بأسماء الملفات الموجودة بالمجلد Scanner scanner; // لقراءة سطرٍ مُدْخل واحد أدخله المُستخدِم scanner = new Scanner(System.in); // للقراءة من الدخل القياسي System.out.print("Enter a directory name: "); directoryName = scanner.nextLine().trim(); directory = new File(directoryName); if (directory.isDirectory() == false) { if (directory.exists() == false) System.out.println("There is no such directory!"); else System.out.println("That file is not a directory."); } else { files = directory.list(); System.out.println("Files in directory \"" + directory + "\":"); for (int i = 0; i < files.length; i++) System.out.println(" " + files[i]); } } // end main() } // end class DirectoryList حيث تعني [1]: يَعرِض هذا البرنامج قائمةً بالملفات الموجودة بالمجلد الذي خَصَّصه المُستخدِم. يطلب البرنامج من المُستخدِم كتابة اسم المجلد، فإذا لم يكن الاسم المُدْخَل مجلدًا، يطبع البرنامج رسالةً ويُغلق. تتضمَّن جميع الأصناف المُستخدَمة للقراءة من الملفات والكتابة بها بُناة constructors كائن، حيث تَستقبِل تلك البُناة كائنًا من النوع File مثل معاملٍ. على سبيل المثال، إذا كان file متغيرًا من النوع File، وكنت تريد قراءة محارف من ذلك الملف، يُمكِنك إنشاء كائنٍ من النوع FileReader بكتابة new FileReader(file). صناديق نوافذ التعامل مع الملفات تحتاج الكثير من البرامج إلى طريقةٍ تَسمَح بها للمُستخدِم باختيار ملفٍ معين، بحيث يُمكِنها بعد ذلك استخدام الملف المُخصَّص أثناء عمليات الدخل والخرج. إذا سَمحَنا للمُستخدِم بكتابة اسم الملف يدويًا، فإننا بذلك نفترض فهمه لطريقة عمل الملفات والمجلدات. في المقابل، إذا دعَّمنا البرنامج بواجهة مُستخدمٍ رسومية، فإننا سنُمكِّن المُستخدِم من اختيار الملف من خلال صندوق نافذة ملف file dialog box؛ حيث يُعدُّ هذا الصندوق نافذةً يستطيع البرنامج أن يفتحها إذا أراد أن يَسمَح للمُستخدِم باختيار ملفٍ معينٍ للدخل أو للخرج. توفِّر مكتبة جافا إف إكس JavaFX الصنف FileChooser ضمن حزمة javafx.stage، والذي يُمكِنه عرض صندوق نافذة للتعامل مع الملفات، وذلك بصورةٍ مُستقلة عن المنصة التي يَعمَل عليها البرنامج. يَعرِض صندوق نافذة فتح ملف للمُستخدِم قائمةً بالملفات والمجلدات الفرعية الموجودة ضمن مجلدٍ معين، مما يَسمَح له بأن يختار بسهولةٍ ملفًا معينًا ضمن ذلك المجلد، كما تُمكِّنه من التنقل بين المجلدات. لا يستقبل باني الصنف FileChooser أية معاملات. لاحِظ أن صندوق النافذة لا يظهر تلقائيًا على الشاشة بمجرد إنشاء كائنٍ من النوع FileChooser، وإنما ينبغي استدعاء تابعٍ مُعرَّفٍ بهذا الكائن لإظهار صندوق النافذة. ستَستدعِي عادةً بعض توابع النسخ الأخرى المُعرَّفة بالكائن، والخاصة بضَبْط بعض خاصيات صندوق النافذة قبل أن تَعرِضها، حيث يمكن مثلًا تخصيص قيمةٍ افتراضيةٍ مثل اسم للملف. قد يكون لصندوق النافذة "مالك owner" يمثّل نافذةً، أي كائنًا من النوع Stage بمكتبة جافا إف إكس JavaFX. لا يَستطيع المُستخدِم أن يتفاعل مع النافذة المالكة حتى يُنهِي تعامله مع صندوق النافذة المفتوح؛ إما بغلقه؛ أو باختيار ملف. يُمكِننا أن نُخصِّص مالك صندوق نافذةٍ معينة بتمريره معاملًا للتابع المسؤول عن عرض صندوق النافذة؛ كما يُمكِن للمالك أن يكون فارغًا، وعندها لا يتوقف تفاعل المُستخدِم مع أي نوافذ أثناء عرض صندوق النافذة. هناك نوعان من صناديق نوافذ الملفات: صندوق نافذة فتح ملف وصندوق نافذة حفظ ملف؛ حيث يَسمَح الأول للمُستخدِم بتخصيص إحدى الملفات الموجودة مُسبقًا لفتحها وقراءتها بالبرنامج؛ أما الثاني فيَسمَح للمُستخدِم بتخصيص ملفٍ قد يكون موجودًا أو لا لكتابة بعض البيانات به. يُعرِّف الصنف FileChooser تابعي نسخة لعرض أيٍّ من الصندوقين على الشاشة. إذا كان fileDialog مُتغيرًا من النوع FileChooser، فإنه يُوفِّر التوابع التالية: fileDialog.showOpenDialog(window): يَعرِض صندوق نافذة فتح ملف على الشاشة، حيث يَستقبِل مُعاملًا يُمثِّل مالك صندوق النافذة المُفترَض فتحها. لا يعيد التابع أي قيمةٍ حتى يختار المُستخدِم ملفًا أو يَغلِق النافذة بدون اختيار أي ملف؛ حيث يعيد التابع في الحالة الأولى قيمةً من النوع File تُمثِّل الملف الذي اختاره المُستخدِم؛ بينما يُعيد في الحالة الثانية القيمة الفارغة null. fileDialog.showSaveDialog(window): يَعرِض صندوق نافذة حفظ ملف، مالكها هو المعامل window. يعمل كلٌ من معامل التابع والقيمة المعادة منه بنفس أسلوب التابع showOpenDialog()؛ فإذا اختار المُستخدِم ملفًا موجودًا بالفعل، فسيسأله النظام أوتوماتيكيًا فيما إذا كان يريد بالفعل استبدال ذلك الملف، ويُمكِنك في تلك الحالة تخزين البيانات بالملف المُخصَّص دون القلق بشأن أي خطأٍ غير متوقَّع. fileDialog.setTitle(title): يَستقبِل التابع سلسلةً نصيةً مثل معاملٍ لتخصيص عنوانٍ يَظهَر بشريط عنوان صندوق النافذة. ينبغي أن تَستدعِي هذا التابع قبل عرض صندوق النافذة. fileDialog.setInitialFileName(name): يَضبُط اسمًا افتراضيًا يظهر بصندوق مُدْخَلات اسم الملف. لاحِظ أن المعامل هو سلسلةٌ نصية؛ فإذا كانت القيمة المُمرَّرة للمعامل فارغة، فإن صندوق الإدخال يكون بدوره فارغًا. ينبغي استدعاء هذا التابع قبل عرض صندوق النافذة المعنيّة. fileDialog.setInitialDirectory(directory): يََضبُط أي مجلدٍ ينبغي عرضه مبدئيًا عند فتح صندوق نافذة فتح الملف. لاحِظ أن المعامل الذي يَستقبِله التابع يَكون من النوع File؛ فإذا كانت القيمة المُمرَّرة للمعامل فارغة، يعتمد المجلد المبدئي على الإعدادات الافتراضية للنظام (قد يكون المجلد الذي شَغلّت البرنامج منه)؛ أما إذا لم تَكن القيمة المُمرَّرة فارغة، فلا بُدّ أن تكون كائنًا من النوع File يُمثِّل مجلدًا لا ملفًا، وإلا سيقع خطأ. ينبغي استدعاء هذا التابع قبل عرض صندوق النافذة المعنيّة. يتضمَّن أي برنامجٍ نموذجي يتعامل مع الملفات الأمرين "افتح" و "احفظ"؛ فعندما يختار المُستخدِم ملفًا معينًا لفتحه أو لحفظ البيانات به، يُمكِننا أن نُخزِّن كائن الصنف File الذي يُمثِّل الملف الذي اختاره المُستخدِم بمتغير نسخة instance variable، بحيث نَستخدِمه بعد ذلك لضبط المجلد المعروض مبدئيًا، أو حتى لضبط اسم الملف بالمرة التالية التي نُنشِئ خلالها صندوق نافذة ملف. إذا كان editFile مُتغيّر نسخة يحتوي على الملف الذي اختاره المُستخدِم، وإذا لم يَكُن ذلك الملف فارغًا، فسيُعيد الاستدعاء editFile.getName() سلسلةً نصيةً من النوع String وتُمثِّل اسم الملف؛ في حين سيُعيد الاستدعاء editFile.getParent() كائنًا من النوع File يُمثِّل المجلد المُتضمِّن لذلك الملف. ننتقل الآن للسؤال التالي: ما الذي ينبغي فعله في حالة حدوث خطأ بينما نقرأ الملف المَعنِيّ أو نكْتُب به؟ ينبغي عمومًا التقاط ذلك الخطأ وتبليغ المُستخدِم عن حدوثه؛ فإذا كان البرنامج مُدعَّمًا بواجهة مُستخدِم رسومية، فيُعرَض عادةً للمُستخدِم صندوق نافذةٍ آخر يحتوي على رسالة الخطأ مع زر "OK" لغلق الصندوق. لم نتعرَّض لصناديق النوافذ سابقًا، ولكن يُمكِننا في العموم إنشاء كائناتٍ من الصنف Alert المُعرَّف بحزمة javafx.scene.control بسهولة لنَعرِض بعضًا من أكثر صناديق النوافذ البسيطة شيوعًا. تَعرِض الشيفرة التالية طريقة عرض صندوق نافذة يحتوي على رسالة خطأ: Alert errorAlert = new Alert( Alert.AlertType.ERROR, message ); errorAlert.showAndWait(); يُمكِننا أن نُجمِّع كل ما سبق لنكتُب البرنامج الفرعي النموذجي التالي المسؤول عن حفظ البيانات بملف، حيث يَستخدِم البرنامج الصنف FileChooser لاختيار الملف، والصنف PrintWriter لكتابة البيانات بصيغةٍ نصية: private void doSave() { FileChooser fileDialog = new FileChooser(); if (editFile == null) { // 1 fileDialog.setInitialFileName("filename.txt"); fileDialog.setInitialDirectory( new File( System.getProperty("user.home")) ); } else { // 2 fileDialog.setInitialFileName(editFile.getName()); fileDialog.setInitialDirectory(editFile.getParentFile()); } fileDialog.setTitle("Select File to be Saved"); File selectedFile = fileDialog.showSaveDialog(mainWindow); if ( selectedFile == null ) return; // لم يختر المستخدم ملفًا // 3 PrintWriter out; try { FileWriter stream = new FileWriter(selectedFile); out = new PrintWriter( stream ); } catch (Exception e) { // لا يملُك المُستخدِم على الأغلب صلاحيةً للكتابة بالملف Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, but an error occurred while\n" + trying to open the file for output."); errorAlert.showAndWait(); return; } try { . . // اكتب النص إلى الملف باستخدام PrintWriter //WRITE TEXT TO THE FILE, using the PrintWriter . out.flush(); // هل هي ضرورية؟ ربما ستُنجز من خلال الأمر ()out.close out.close(); if (out.checkError()) // افحص الأخطاء المحتملة (need to check for errors in PrintWriter) throw new IOException("Error check failed."); editFile = selectedFile; } catch (Exception e) { Alert errorAlert = new Alert(Alert.AlertType.ERROR, "Sorry, but an error occurred while\n" + "trying to write data to the file."); errorAlert.showAndWait(); } } حيث: [1]: لم يُعدَّل أي ملف. اضبط اسم الملف إلى "filename.txt" واسم المجلد إلى المجلد الرئيسي للمُستخدِم. [2]: استرجع اسم الملف والمجلد لصندوق النافذة من الملف الذي يُعدِّله المُستخدِم حاليًا. [3]: ملاحظة: لقد اختار المُستخدِم ملفًا، وفي حال وجود ملفٍ بنفس الاسم، فإنه قد أكّد بالفعل على رغبته بحذف الملف الموجود. يُمكِننا تطبيق نفس الفكرة على الملفات غير النصية، مع استخدام نوعٍ مختلف من مجاري الخرج. تَعمَل قراءة البيانات من ملفٍ معينٍ بنفس الأسلوب، ولهذا لن نناقش التابع المكافئ doOpen(). يُمكِنك مع ذلك الإطلاع على البرنامج التوضيحي TrivialEdit.java، حيث ستَجِد برامجًا فرعية subroutines مسؤولةً عن فتح الملفات النصية وحفظها، كما يَسمَح هذا البرنامج للمُستخدِم بتعديل بعض الملفات النصية الصغيرة، وتعديل البرامج الفرعية المُعرَّفة ضمنه، وإعادة اِستخدَامها ضمن برامج واجهات مُستخدِم رسومية GUI أخرى تتعامل مع الملفات. ترجمة -بتصرّف- للقسم Section 2: Files من فصل Chapter 11: Input/Output Streams, Files, and Networking من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال التالي: معالجة الملفات في جافا المقال السابق: قنوات الدخل والخرج وعمليتي القراءة والكتابة في جافا كتابة أصناف وتوابع معممة في جافا الواجهات Interfaces في جافا التعاود recursion في جافا
-
تُصبِح البرامج عديمة الفائدة إذا لم تكن قادرةً على التعامل مع العالم الخارجي بشكلٍ أو بآخر، حيث يُشار إلى تعامل البرامج مع العالم الخارجي باسم "الدْخَل والخرج أو I/O". يُعدّ توفير إمكانياتٍ جيدةٍ لعمليات الدْخَل والخرج واحدًا من أصعب التحديات التي تواجه مُصمِّمي اللغات البرمجية، حيث يَستطيِع الحاسوب الاتصال مع أنواعٍ مختلفةٍ كثيرة من أجهزة الدخل والخرج. إذا اضطّرت لغة البرمجة للتعامل مع كل نوعٍ منها على حدة، لكان الأمر غايةً في التعقيد، ولهذا يُعدّ التمثيل المُجرّد لأجهزة الدخل والخرج واحدًا من أعظم الإنجازات بتاريخ البرمجة، ويُطلَق على ذلك التمثيل المُجرّد بلغة جافا اسم مجاري تدفق الدْخَل والخرج I/O streams. تتوفَّر تجريداتٌ أخرى، مثل الملفات والقنوات، ولكننا سنناقش مجاري التدفق فقط، حيث يُمثِّل كل مجرًى مصدرًا يُقرَأ منه الدْخَل أو مقصدًا يُرسَل إليه الخرج. مجاري تدفق البايتات Byte Streams ومجاري تدفق المحارف Character Streams عندما تتعامل مع المُدْخَلات والمخرجات، تذكَّر أن هناك نوعان من البيانات في العموم؛ بياناتٌ مُهيأةٌ للآلة؛ وبياناتٌ مهيأةٌ لنا بمعنى أنها قابلةٌ للقراءة. تُكتَب الأولى بالصيغة الثنائية binary بنفس الطريقة التي تُخزَّن بها البيانات داخل الحاسوب، أي بهيئة سلاسلٍ نصيةٍ مُكوَّنةٍ من "0" و "1"؛ بينما تُكتَب الثانية بهيئة محارف. فعندما تقرأ عددًا، مثل "3.141592654"، فأنت في الواقع تقرأ متتاليةً من المحارف، ولكنك تُفسِّرها عددًا؛ بينما يُمثِّل الحاسوب نفس ذلك العدد بهيئة سلسلةٍ نصيةٍ من البتات أي أنك لن تتمكَّن من تمييزها. تُوفِّر جافا نوعين من مجاري التدفق streams للتعامل مع البيانات المُمثَلة بالصيغتين السابقتين: مجرى بايتات byte streams للبيانات المُهيأة للآلة، ومجرى محارف character streams للبيانات القابلة للقراءة. ستَجِد أصنافًا مُعرَّفةً مُسبقًا تُمثِّل المجاري من كلا النوعين. تنتمي الكائنات المُرسِلة للبيانات إلى مجرى بايت إلى أحد الأصناف الفرعية subclasses المُشتقَّة من الصنف المُجرَّد OutputStream؛ بينما تنتمي الكائنات القارئة للبيانات من هذا النوع من المجاري إلى أحد الأصناف الفرعية المُشتقَّة من الصنف المُجرَّد InputStream. إذا أرسلت أعدادًا إلى كائنٍ من الصنف OutputStream، لن تتمكَّن من قراءة البيانات الناتجة بنفسك. في المقابل، ما يزال بإمكان الحاسوب قرائتها مُجدَّدًا عبر كائنٍ من الصنف InputStream. تعمَل عمليتي قراءة البيانات وكتابتها في تلك الحالة بكفاءة لعدم استخدامهما أي ترجمة؛ حيث تُنسَخ فقط البتات المُمثِلة للبيانات بالحاسوب من مجاري التدفق وإليها. في المقابل، يتولَّى الصنفان المجرَّدان Reader وWriter قراءة البيانات القابلة للقراءة وكتابتها على الترتيب، فجميع أصناف مجارى المحرف هي مجرد أصنافٍ فرعيةٍ مُشتقَّةٍ من هذين الصنفين. إذا أرسلت عددًا إلى مجرًى من النوع Writer، فيجب أن يُترجمها الحاسوب إلى متتاليةٍ من المحارف المُمثِلة لذلك العدد والقابلة للقراءة؛ بينما تنطوي عملية قراءة عددٍ من مجرًى من النوع Reader، وتخزينها بمُتغيِّرٍ عددي على عملية ترجمةٍ من متتالية محارف إلى سلسلة بتاتٍ مناسبة. حتى لو كانت البيانات التي تتعامل معها مُكوَّنةً من محارفٍ بالأساس، مثل بعض الكلمات من برنامج معدِّل نصوص، من الممكن أن يتضمَّن الأمر بعضًا من الترجمة أيضًا. يُخزِّن الحاسوب المحارف على انها قيم يونيكود Unicode من 16 بت، وتُخزّن حروف الأبجدية الإنجليزية عمومًا بملفات بشيفرة ASCII، التي تَستخدِم 8 بتات للمحرف الواحد. يتولى الصنفان Reader وWriter أمر تلك الترجمة، كما يمكنهما معالجة الحروف الأبجدية الأخرى، وكذلك المحارف من غير الحروف الأبجدية المكتوبة بلغاتٍ، مثل الصينية. تُستَخدَم مجاري تدفق البايتات للاتصال المباشر بين الحواسيب، كما أنها تكون مفيدةً أحيانًا لتخزين البيانات ضمن ملفات، بالأخص عندما نحتاج إلى تخزين أحجامٍ هائلةٍ من البيانات بطريقةٍ فعالة؛ كما هو الحال مع قواعد البيانات الضخمة. ومع ذلك، تُعدّ البيانات الثنائية هشة نوعًا ما، فهي لا تعبُر بذاتها عن معناها. عندما تتعامل مع سلسلةٍ طويلةٍ من العددين صفر وواحد، ينبغي أن تُعرِّف أولًا نوعية المعلومات المُفترَض لتلك السلسلة أن تُمثِّلها، وكذلك أن تَعرِّف الكيفية التي رُمزَّت بها المعلومات قبل أن تتمكَّن من تفسيرها. ينطبق الأمر نفسه بالطبع على البيانات المحرفية نوعًا ما؛ فالمحارف بالنهاية مثلها مثل أي نوعٍ من البيانات، وينبغي أن تُرمَّز مثل أعدادٍ ثنائية حتى يتمكَّن الحاسوب من تخزينها ومعالجتها، ولكن الترميز الثنائي للبيانات المحرفية على الأقل مُوحدٌ ومفهوم، بل حتى يُمكِننا أن نجعل البيانات بصيغتها المحرفية ذات معنًى للقارئ. يتجه التيار العام إلى استخدام البيانات المحرفية، وتمثيلها بطريقةٍ تجعلها مُفسَّرةً ذاتيًا قدر الإمكان، وسنناقش إحدى تلك الطرائق في مقال مقدمة مختصرة للغة XML. لا يدعم الإصدار الأصلي من جافا مجاري المحارف، حيث يُمكِن لمجاري البايتات أن تحلّ محل مجاري المحارف عند التعامل مع البيانات المُرمزَّة بشيفرة ASCII. يُعدُّ مجريا الدخل القياسي System.in والخرج القياسي System.out مجاري بايتات، وليس مجاري محارف؛ ومع ذلك يُحبَّذ استخدام الصنفين Reader وWriter على الصنفين InputStream وOutputStream عند التعامل مع البيانات المحرفية، وحتى عند التعامل مع مجموعة محارف ASCII القياسية. تقع أصناف مجاري الدخل والخرج القياسية -والتي سنناقشها ضمن هذا المقال - بحزمة java.io بالإضافة إلى عددٍ من الأصناف الأخرى. يجب أن تستورد import أصناف تلك الحزمة إذا أردت اِستخدَامها ضمن البرنامج؛ أي إما أن تستورد الأصناف المطلوبة بصورةٍ فردية؛ أو أن تْكْتُب المُوجِّه import java.io.*; في بداية الملف المصدري. تُستخدَم مجاري الدخل والخرج عند التعامل مع الملفات، وعند الاتصال الشبكي، وكذلك للاتصال بين الخيوط المُتزامنة concurrent threads. تتوفَّر أيضًا أصناف مجاري لقراءة البيانات وكتابتها من وإلى ذاكرة الحاسوب. تَكْمُن فعالية المجاري وأناقتها بكونها تُجرِّد عملية كتابة البيانات؛ حيث تُصبِح عملياتٍ مثل كتابة بياناتٍ إلى ملف أو إرسالها عبر شبكةٍ بنفس سهولة طباعة تلك البيانات على الشاشة. تُوفِّر أصناف الدخل والخرج Reader وWriter وInputStream وOutputStream العمليات الأساسية فقط، حيث يُصرِّح الصنف InputStream مثلًا عن تابع النسخة instance method المُجرَّد التالي: public int read() throws IOException يقرأ هذا التابع بايتًا واحدًا من مجرى دْخَلٍ بهيئة عددٍ يقع بنطاقٍ يتراوح بين "0" و "255"، ويُعيد القيمة "-1" عند وصوله إلى نهاية المجرى. إذا حدث خطأٌ أثناء عملية الدخل، يقع استثناء exception من النوع IOException، ونظرًا لكونه من الاستثناءات المُتحقَّق منها checked exceptions، لا بُدّ من استخدام التابع read() ضمن تعليمة try أو ببرنامجٍ فرعي subroutine يتضمَّن تصريحه عبارة throws IOException. انظر مقال الاستثناءات exceptions وتعليمة try..catch في جافا للمزيد من المعلومات عن الاستثناءات المُتحقَّق منها والمعالجة الاجبارية للاستثناءات. يُعرِّف الصنف InputStream أيضًا توابعًا لقراءة عدة بايتات من البيانات ضمن خطوةٍ واحدة، وتخزينها بمصفوفة بايتات، وهو ما يُعدّ أكثر كفاءة بكثير من قرائتها بصورةٍ إفرادية؛ ولكنه -أي الصنف InputStream- مع ذلك لا يُوفِّر أي توابعٍ لقراءة أنواعٍ أخرى من البيانات، مثل int وdouble من مجرى. لا يُمثِل ذلك مشكلة؛ حيث من النادر أن تستخدِم كائناتٍ من النوع InputStream، وإنما ستعتمد على أصنافٍ فرعية منه. تُعرِّف تلك الأصناف توابع دْخَلٍ إضافية إلى جانب الإمكانيات الأساسية للصنف InputStream، كما يُعرِّف بالمثل الصنف OutputStream تابع الخرج التالي لكتابة بايت واحد إلى مجرى خرج: public void write(int b) throws IOException لاحِظ أن المعامل parameter من النوع int، وليس من النوع byte، ولكنه يُحوَّل type-cast إلى النوع byte قبل كتابته، وهو ما يؤدي إلى إهمال جميع بتات المعامل b باستثناء البتات الثمانية الأقل رتبة. عمليًا، ستَستخدِم دائمًا أصنافًا فرعية مُشتقَّة من الصنف OutputStream، والتي تُعرِّف عمليات خرجٍ إضافية عالية المستوى. يُوفِّر الصنفان Reader وWriter توابعًا منخفضة المستوى مشابهة لعمليتي read وwrite. وكما هو الحال مع أصناف مجاري البايتات، ينتمي كلٌ من معامل التابع write(c) المُعرَّف بالصنف Writer، والقيمة المعادة من التابع read() المُعرَّف بالصنف Reader إلى النوع int، ولكن ما يزال هناك اختلاف؛ حيث تُجرَى بتلك الأصناف المُخصَّصة بالأساس للمحارف عمليتي الدخل والخرج على المحارف، وليس على البايتات. يعيد التابع read() القيمة "-1" عند وصوله إلى نهاية المجرى، أما قبل ذلك، فيجب أن نُحوَّل القيمة المعادة منه إلى النوع char لنَحصُل على المحرف المقروء. عمليًا، ستَستخدِم عادةً أصنافًا فرعيةً مُشتقَّةً من الصنفين Reader وWriter، والتي تُعرِّف عمليات دْخَل وخَرْج إضافية عالية المستوى، كما سنناقش فيما يلي. الصنف PrintWriter تُمكِّنك حزمة جافا للدخل والخرج من إضافة إمكانياتٍ جديدة إلى مجاري التدفق من خلال تغليفها wrapping ضمن كائنات مجاري تدفقٍ أخرى تُوفِّر تلك الإمكانيات. يكون الكائن المُغلِّف مجرًى أيضًا؛ أي يُمكِنك أن تقرأ منه أو تكتب به، ولكن عبر عملياتٍ أكثر فعالية من تلك المتاحة بمجاري التدفق الأصلية. يُعدّ الصنف PrintWriter على سبيل المثال صنفًا فرعيًا من الصنف Writer، ويُوفِّر توابعًا لإخراج جميع أنواع البيانات الأساسية بلغة جافا بصيغة محارف مقروءة. إذا كان لديك كائنٌ منتميٌ إلى الصنف Writer أو أيٍّ من أصنافه الفرعية، وأردت استخدام توابع الصنف PrintWriter لعمليات الخرج الخاصة بذلك الكائن؛ فكل ما عليك فعله هو تغليف كائن الصنف Writer بكائن الصنف PrintWriter، وذلك بتمريره إلى باني الكائن constructor المُعرَّف بالصنف PrintWriter. بفرض أن charSink من النوع Writer، يُمكِنك كتابة ما يَلِي: PrintWriter printableCharSink = new PrintWriter(charSink); يُمكِن للمعامل المُمَّرر إلى الباني أن يكون من النوع OutputStream أو النوع File، وهذا ما سنناقشه في المقال التالي؛ حيث يُنشِئ الباني في العموم كائنًا من النوع PrintWriter، والذي يكون بإمكانه الكتابة إلى مقصد الخرج الخاص بالكائن المُمرَّر إليه. عندما تُرسِل بيانات خرجٍ إلى printableCharSink عبر إحدى توابع الخرج عالية المستوى المُعرَّفة بالصنف PrintWriter، فستُرسَل تلك البيانات إلى نفس المقصد الذي يُرسِل charSink البيانات إليه؛ فكل ما فعلناه هو توفير واجهة أفضل لنفس مقصد الخرج، وهذا يَسمَح لنا باستخدام توابع الصنف PrintWriter لإرسال البيانات إلى ملفٍ أو عبر اتصالٍ شبكي مثلًا. إذا كان out مُتغيّرًا من النوع PrintWriter، فإنه إذًا يُعرِّف التوابع التالية: out.print(x): يُرسِل قيمة المعامل x بهيئة سلسلةٍ نصيةٍ من المحارف إلى مجرى الخرج، ويُمكِن للمعامل x أن يكون تعبيرًا expression من أي نوع، بما في ذلك الأنواع الأساسية primitive types والأنواع الكائنية؛ حيث يُحوِّل التابع أي كائنٍ إلى سلسلةٍ نصيةٍ عبر تابعه toString(). تُمثَّل القيمة الفارغة null بالسلسلة النصية "null". out.println(): يُرسِل مِحرف سطرٍ جديد إلى مجرى الخرج. out.println(x): يُرسِل قيمة x متبوعةً بسطرٍ جديد، وهو ما يُكافِئ استدعاء التابعين out.print(x) وout.println() على التوالي. out.printf(formatString, x1, x2, ...): يُرسِل خرجًا مُنسَّقًا للمعاملات المُمرَّرة x1 وx2 و .. وهكذا إلى مجرى الخرج. يمثِّل المعامل الأول سلسلةً نصيةً تُخصِّص صيغة الخرج المطلوبة. إلى جانب ذلك، يَستقبِل التابع أي عددٍ من المعاملات الإضافية التي يُمكِنها أن تنتمي لأي نوع، بشرط أن تتوافق مع صيغة الخرج المُخصَّصة بالمعامل الأول. ألقِ نظرةً على قسم الخرج البسيط والخرج المنسق من مقال المدخلات والمخرجات النصية في جافا للمزيد من المعلومات عن الخرج المُنسَّق فيما يتعلَّق بمجرى الخرج القياسي System.out، ويُوفِّر التابع out.printf نفس الوظيفة. out.flush(): يتأكَّد من كتابة المحارف المُرسلة عبر أيٍّ من التوابع السابقة إلى مقصدها بصورةٍ فعليّة. يكون استدعاء هذا التابع ضروريًا في بعض الحالات بالأخص عند إرسال الخرج إلى ملفٍ أو عبر شبكة، وذلك لضمان ظهور الخرج بالمقصد المُحدَّد. لا تُبلِّغ أيٌ من التوابع السابقة عن استثناءٍ من النوع IOException نهائيًا. بدلًا من ذلك، يتضمَّن الصنف PrintWriter التابع التالي: public boolean checkError() يعيد هذا التابع القيمة true في حالة حدوث خطأٍ أثناء عملية الكتابة بمجرى؛ حيث يلتقط الصنف PrintWriter أي استثناءات من النوع IOException، ثم يَضبُط قيمة رايةٍ flag داخليةٍ معينةٍ للإشارة إلى وجود خطأ. يُمكِنك إذًا استخدام التابع checkError() لفحص قيمة تلك الراية، وذلك من خلال استخدام توابع الصنف PrintWriter دون الحاجة لالتقاط أي استثناءات؛ ومع ذلك، إذا كنت تريد كتابة برنامج متين تمامًا، فيجب أن تستدعي التابع checkError() عند استخدام أيٍّ من توابع الصنف PrintWriter لتَتأكَّد من عدم وقوع أي أخطاءٍ مُحتمَلة. مجاري تدفق البيانات Data Streams عندما نَستخدِم الصنف PrintWriter لإرسال بياناتٍ إلى مجرًى معيّن، فسيُحوِّل البيانات إلى متتاليةٍ مقروءةٍ من المحارف المُمثِّلة لتلك البيانات. ماذا لو أردنا إرسال البيانات بصيغةٍ ثنائيةٍ مهيأةٍ للآلة؟ في الواقع، تتضمَّن حزمة java.io الصنف DataOutputStream المُمثِّل لمجرى بايتات، والذي يُمكِننا استخدامه لإرسال البيانات إلى المجاري بهيئةٍ ثنائية. تُعدّ العلاقة بين الصنفين DataOutputStream وOutputStream مشابهةً لتلك الموجودة بين الصنفين PrintWriter وWriter؛ فبينما يَملُك الصنف OutputStream توابع الخرج المُخصَّصة للبايتات فقط؛ يملك الصنف DataOutputStream التابع writeDouble(double x) لقيم الخرج من النوع double، والتابع writeInt(int x) لقيم الخرج من النوع int، وهكذا. علاوةً على ذلك، من الممكن أيضًا تغليف أي كائنٍ من النوع OutputStream ضمن كائنٍ من النوع DataOutputStream؛ لنتمكَّن من استخدام توابع الخرج عالية المستوى المُعرَّفة به. إذا كان byteSink من النوع OutputStream مثلًا، يُمكِن كتابة ما يَلي لتغليفِه ضمن كائنٍ من النوع DataOutputStream: DataOutputStream dataSink = new DataOutputStream(byteSink); تُوفِّر حزمة java.io الصنف DataInputStream بالنسبة للمُدْخلات المُهيأة للآلة، مثل تلك التي يُنشئها DataOutputStream عند اِستخدَامه للكتابة. يُمكِنك تغليف كائنٍ من النوع InputStream ضمن كائنٍ من النوع DataInputStream؛ لتُمكِّنه من قراءة أي نوعٍ من البيانات من مجرى بايتات. أسماء توابع الصنف DataInputStream المسؤولة عن قراءة البيانات الثنائية هي: readDouble() وreadInt() وهكذا. يكْتُب الصنف DataOutputStream البيانات بصيغةٍ يُمكِن للصنف DataInputStream أن يقرأها بالضرورة، حتى لو أنشأ حاسوبٌ من نوعٍ معين المجرى، وكان المطلوب أن يقرأه حاسوبٌ من نوعٍ آخر. تُوفِّر البيانات الثنائية توافقًا compatibility عبر المنصات، ويُعدُّ هذا أحد الجوانب الأساسية لاستقلالية منصة جافا. قد ترغب في بعض الحالات بقراءة محارفٍ من مجرًى من النوع InputStream، أو كتابة محارفٍ إلى مجرًى من النوع OutputStream، ولا يُمثِل ذلك مشكلةً لأن المحارف مثلها مثل جميع البيانات؛ فهي تُمثَّل بهيئة أعدادٍ ثنائيةٍ، على الرغم أنه من الأفضل في تلك الحالة استخدام الصنفين Reader وWriter، بدلًا من InputStream وOutputStream. مع ذلك، تستطيع فعل ذلك بتغليف مجرى البايتات ضمن مجرى محارف. إذا كان byteSource متغيرًا من النوع InputStream وكان byteSink مُتغيرًا من النوع OutputStream، تُنشِئ التعليمات التالية مجاري محارف بإمكانها قراءة المحارف وكتابتها من وإلى مجاري بايتات. Reader charSource = new InputStreamReader( byteSource ); Writer charSink = new OutputStreamWriter( byteSink ); يُمكِننا تحديدًا تغليف مجرى الدخل القياسي System.in، المُنتمي إلى الصنف InputStream لأسبابٍ تاريخية، ضمن كائنٍ من النوع Reader، لتسهيل قراءة المحارف من الدخل القياسي كما يلي: Reader charIn = new InputStreamReader( System.in ); لنأخذ مثالًا آخر؛ حيث تُعدّ مجاري الدخل والخرج المُرتبطِة باتصالٍ شبكي مجاري بايتات لا مجاري محارف، ويُمكننا مع ذلك تغليف مجاري البايتات بمجاري محارف للتسهيل من إرسال البيانات المحرفية واستقبالها عبر الشبكة. سنناقش عمليات الدخل والخرج عبر الشبكة لاحقًا. تتوفَّر طرائقٌ مختلفة لترميز المحارف بهيئة بياناتٍ ثنائية، حيث يُطلَق مُصطلح "طقم محارف charset" على أي ترميز محارف، ويَملُك اسمًا قياسيًا، مثل "UTF-16" و "UTF-8" و "ISO-8859-1"؛ حيث يُرمِّز "UTF-16" المحارف بهيئة قيم يونيكود Unicode مُكوَّنةٍ من "16 بت"، وهو الترميز المُستخدَم داخليًا بجافا؛ بينما يُعدّ "UTF-8" أسلوبًا لترميز محارف اليونيكود بتخصيص "8 بت" لمحارف ASCII الشائعة في مقابل عدد بتاتٍ أكثر للمحارف الأخرى؛ أما ترميز "ISO-8859-1" المعروف أيضًا باسم "Latin-1"، فهو مكوَّنٌ من "8 بت"، ويتضمَّن محارف ASCII إلى جانب محارفٍ أخرى مُستخدَمةٍ ضمن عدة لغاتٍ أوروبية. يَعتمِد الصنفان Reader وWriter على طقم المحارف الافتراضي ضمن الحاسوب المُشّغلان عليه، إلا إذا خصَّصت طقم محارفٍ معين بتمريره عبر الباني على النحو التالي: Writer charSink = new OutputStreamWriter( byteSink, "ISO-8859-1" ); يؤدي اختلاف ترميزات أطقم المحارف وكثرتها إلى تعقيد عملية معالجة النصوص، وهو ما يُعدّ أمرًا سيئًا للمتحدثين بالإنجليزية، ولكنه ضروري لغيرهم ممن يَستخدِمون أطقم محارفٍ مختلفة. لا حاجة للقلق عمومًا بشأن أيٍّ من ذلك، إنما عليك فقط أن تتذكَّر أن هناك أطقم محارفٍ مختلفة إذا واجهت بياناتٍ نصيةٍ مُرمزَّة بطريقةٍ غير اعتيادية. قراءة النصوص تُجرَى كثيرٌ من عمليات الدخل والخرج على محارفٍ مقروءة، ومع ذلك، لا توفِّر جافا صنفًا قياسيًا يُمكِنه قراءة المحارف بإمكانياتٍ متكافئة مع ما يُوفِّره الصنف PrintWriter لإخراج المحارف. قد يَكون الصنف Scanner -الذي تعرَّضنا له في مقال المدخلات والمخرجات النصية في جافا، والذي سنناقشه تفصيليًا فيما يلي مكافئًا نوعًا ما، ولكنه ليس صنفًا فرعيًا من أي صنف مجرى؛ ما يعني أنه لا يتناسب مع إطار عمل مجاري التدفق. هناك مع ذلك حالةٌ بسيطةٌ بإمكان الصنف القياسي BufferedReader معالجتها بسهولة. يتضمَّن هذا الصنف التابع التالي: public String readLine() throws IOException يقرأ هذا التابع سطرًا نصيًا واحدًا من المُدْخلات، ويقرأ خلال ذلك مؤشر نهاية السطر أيضًا، ولكن لا يكون هذا المؤشر جزءًا من السلسلة النصية التي يعيدها التابع؛ بينما يُعيد التابع القيمة null عند وصوله إلى نهاية المجرى. تَستخدِم الأنواع المختلفة من مجاري الدْخَل محارفًا مختلفةً للإشارة إلى نهاية السطر، ولكن يُمكِن للتابع readLine التعامُل مع أغلب الحالات الشائعة. تَستخدِم حواسيب Unix، بما في ذلك Linux و Mac OS X عادةً محرف سطرٍ جديد '\n' للإشارة إلى نهاية السطر؛ بينما يستخدم Macintosh محرف العودة إلى بداية السطر '\r'؛ أما Windows فيَستخدِم المحرفين "\r\n". تستطيع الحواسيب العصرية عمومًا التعامل مع كل تلك الاحتمالات. يُعرِّف الصنف BufferedReader إضافةً إلى ذلك تابع النسخة lines()، والذي يعيد قيمةً من النوع Stream<String> يُمكِن استخدامها مع واجهة برمجة تطبيقات stream API -انظر مقال مقدمة إلى واجهة برمجة التطبيقات Stream API في جافا-. بفرض أن reader متغيرٌ من النوع BufferedReader، ستكون الطريقة الأمثل لمعالجة جميع الأسطر التي قرأها بتطبيق العامل forEach() على مجرى الأسطر على النحو التالي: reader.lines().forEachOrdered(action) حيث تمثِّل action مُستهلِك سلاسلٍ نصية، والذي يُكتَب عادةً بصيغة تعبيرات لامدا lambda expression. تشيع معالجة الأسطر واحدًا تلو الآخر، لذلك يُمكِننا تغليف wrap أي كائنٍ من النوع Reader ضمن كائنٍ من النوع BufferedReader لتسهيل قراءة الأسطر النصية بالكامل. بفرض أن reader من النوع Reader، يُمكِننا تغليفه باستخدام كائنٍ من النوع BufferedReader على النحو التالي: BufferedReader in = new BufferedReader( reader ); كما يُمكِننا مثلًا استخدامه مع الصنف InputStreamReader المذكور بالأعلى لقراءة أسطرٍ نصيةٍ من كائنٍ من النوع InputStream، أو قد نُطبقه على System.in على النحو التالي: BufferedReader in; // BufferedReader for reading from standard input. in = new BufferedReader( new InputStreamReader( System.in ) ); try { String line = in.readLine(); while ( line != null ) { processOneLineOfInput( line ); line = in.readLine(); } } catch (IOException e) { } تقرأ الشيفرة السابقة أسطرًا من الدخل القياسي، وتعالجها حتى الوصول إلى نهاية المجرى. تَعمَل مؤشرات نهاية المجرى حتى مع المُْدْخَلات التفاعلية، حيث يُولِّد النقر على زر Control-D ببعض الحواسيب على الأقل مثلًا مؤشر نهاية مجرى بمجرى الدخل القياسي. تُعدُّ معالجة الاستثناءات إلزامية نظرًا لإمكانية تبليغ التابع readLine عن استثناءاتٍ exception من النوع IOException، ولهذا كان من الضروري إحاطة التابع بتعليمة try..catch. يُمكِننا بدلًا من ذلك كتابة عبارة throws IOException بتصريح التابع المُتضمِّن للشيفرة بالأعلى. يجب أن تُستورد الأصناف الآتية من حزمة java.io: BufferedReader. InputStreamReader. IOException. على الرغم من تسهيل الصنف BufferedReader عملية قراءة الأسطر النصية، فإن هذا ليس الغرض الأساسي من وجوده، حيث تعمَل بعض أجهزة الدخل والخرج بأعلى كفائتها عند قراءة أو كتابة قدرٍ كبيرٍ من البيانات دفعةً واحدةً بدلًا من مجرد قراءة بايتاتٍ أو محارفٍ مفردة. يُوفِّر الصنف BufferedReader تلك الإمكانية، حيث يُمكِنه قراءة دفعةٍ من البيانات، وتخزينها ضمن ذاكرةٍ داخلية، تُعرَف باسم المخزن المؤقت buffer. عندما تقرأ من كائنٍ من الصنف BufferedReader، فإنه في الواقع يستعيد البيانات من المخزن المؤقت إذا كان ذلك ممكنًا، أي إذا لم يَكن المخزن فارغًا؛ حيث يضطّر تلك الحالة فقط من التعامل مع مصدر الدْخل مرةً أخرى لجلب المزيد من البيانات. يتوفَّر أيضًا الصنف المكافئ BufferedWriter، بالإضافة إلى وجود أصناف مجاري تدفق في مخزنٍ مؤقت للعمل مع مجاري البايتات. اِستخدمنا الصنف غير القياسي TextIO سابقًا لقراءة المُدْخَلات من المُستخدِمين والملفات؛ حيث يتميز ذلك الصنف بسهولة قراءة البيانات المنتمية لأي نوعٍ من الأنواع الأساسية primitive types، ولكنه لا يستطيع مع ذلك القراءة من أكثر من مصدر دخلٍ واحد بنفس الوقت، وهو بذلك لا يَتّبِع نفس نمط أصناف جافا القياسية المبنية مُسبقًا للدْخَل والخَرْج. إذا أعجبك أسلوب الصنف TextIO في التعامل مع المُدْخَلات، يُمكِنك إلقاء نظرةٍ على الصنف TextReader.java، الذي يُنفِّذ implement أسلوبًا مشابهًا بطريقةٍ أكثر كائنية object-oriented. لم نَستخدِم الصنف TextReader ضمن هذا الإصدار من الكتاب، ولكننا أشرنا إليه ضمن بعض الإصدارات السابقة. الصنف Scanner لم تُوفِّر جافا بإصداراتها الأولى دعمًا مبنيًا مسبقًا للمُدْخلات البسيطة، حيث اعتمد الدعم الذي وفِّرته على بعض التقنيات المتقدمة نوعًا ما، ووفَّرت بعد ذلك الصنف Scanner المُعرَّف بحزمة java.util لتسهيل قراءة المُدْخَلات من الأنواع البسيطة، وهو ما يُعدّ تَحسُنًا كبيرًا، ولكنه لم يَحِل المشكلة بالكامل. تعرَّضنا للصنف Scanner في المقال مقدمة إلى واجهة برمجة التطبيقات Stream API في جافا، ولكننا لم نَستخدِمه بعدها، ولهذا سنعتمد بغالبية الأمثلة التالية على الصنف Scanner بدلًا من TextIO. يُعرِّف الصنف البرامج المسؤولة عن عمليات الدْخَل على هيئة توابع نسخ instance methods؛ أي ينبغي أن نُنشِئ كائنًا منه إذا أردنا أن نَستخدِمها. يَستقبِل باني الصنف constructor المصدر الذي ينبغي أن تُقرَأ منه المحارف؛ أي أنه يَعمَل مثل مُغلِّف لذلك المصدر. يُمكِن للمصدر أن يكون من الصنف Reader، أوInputStream، أوString، أوFile، أو غيرها من الاحتمالات الأخرى. إذا اِستخدَمنا النوع String مصدرًا للمُدْخَلات، فسيقرأ الصنف Scanner محارف السلسلة النصية ببساطة من بدايتها إلى نهايتها بنفس الكيفية التي كان سيتعامل بها مع متتالية محارفٍ مصدرها مجرى، حيث يُمكِننا مثلًا استخدام كائنٍ من النوع Scanner للقراءة من الدْخَل القياسي بكتابة ما يلي: Scanner standardInputScanner = new Scanner( System.in ); وبفرض أن charSource من النوع Reader، يُمكِننا بالمثل أن نُنشِئ كائنًا من الصنف Scanner للقراءة منه بكتابة ما يَلي: Scanner scanner = new Scanner( charSource ); يُعالِج الصنف Scanner المُدَْخَلات عادةً وحدةً token تلو الأخرى؛ حيث يُقصَد بالوحدة سلسلةً نصيةً من المحارف لا يُمكِن تقسيمها إلى وحداتٍ أصغر، وإلا ستفقد معناها وفقًا للمهمة المعنية بها. يُمكِن للوحدة أن تكون كلمةً مفردةً مثلًا أو سلسلةً نصيةً مُمثِّلةً لقيمةٍ من النوع double. يحتاج الصنف Scanner أيضًا لوجود "فاصلٍ delimiter" بين تلك الوحدات، والذي يُمثَّل عادةً ببضعة فراغات، مثل محارف الفراغ، أو محارف tab، أو مؤشرات نهاية السطر. يُهمِل الصنف Scanner تلك الفراغات، حيث يقتصر الهدف من وجودها على الفصل بين الوحدات. يتضمَّن الصنف توابع نسخٍ لقراءة مختلف أنواع الوحدات. لنفترض أن scanner كائنٌ من النوع Scanner، يكون لدينا التوابع التالية: scanner.next(): يقرأ الوحدة التالية من مصدر المُدْخَلات، ويعيد قيمةً من النوع String. scanner.nextInt() وscanner.nextDouble() وغيرها: يقرأون الوحدة التالية من مصدر المُدْخَلات، ويحاولون تحويلها إلى قيمةٍ من النوع int وdouble وغيرها. تتوفَّر توابعٌ لقراءة جميع الأنواع الأساسية. scanner.nextLine(): يقرأ سطرًا كاملًا من المُدْخَلات حتى يَصِل إلى مؤشر نهاية السطر، ثم يعيد السطر على أنه قيمةٌ من النوع String. في حين يقرأ التابع مؤشر نهاية السطر، فإنه لا يُضمُّنه بالقيمة التي يُعيدها، كما أنه لا يعتمد على مفهوم الوحدات؛ فهو يعيد سطرًا كاملًا بما قد يحتويه من أية فراغات. يُمكِن أن تكون القيمة المُعادة من التابع مجرد سلسلةٍ نصيةٍ فارغة. يُمكِن للتوابع السابقة أن تُبلِّغ عن بعض أنواع الاستثناءات، حيث يمكنها على سبيل المثال التبليغ عن استثناءٍ من النوع NoSuchElementException عند محاولتها القراءة من مصدرٍ تجاوزت نهايته بالفعل. كما تُبلِّغ توابعٌ، مثل scanner.getInt() عن حدوث استثناءٍ من النوع InputMismatchException، إذا لم تكُن الوحدة token التالية من النوع المطلوب. لا تُعدّ معالجة الاستثناءات التي تُبلِّغ عنها تلك التوابع إلزامية. يتمتع الصنف Scanner بإمكانياتٍ جيدة لفحص المُدْخَلات دون قرائتها؛ حيث يُمكِنه مثلًا أن يُحدِّد فيما إذا كان هناك المزيد من الوحدات للقراءة، أو إذا كانت الوحدة التالية من نوعٍ معين. إذا كان scanner كائنًا من النوع Scanner، يُمكِننا استخدام التوابع التالية: scanner.hasNext(): يُعيد القيمة المنطقية true في حالة وجود وحدةٍ واحدةٍ على الأقل بمصدر المُدْخَلات. scanner.hasNextInt() وscanner.hasNextDouble()، وهكذا: يعيدون القيمة المنطقية true إذا كان هناك وحدةً واحدةً على الأقل بمصدر المُدْخَلات، وكانت تلك الوحدة قيمةً من النوع المطلوب. scanner.hasNextLine(): يعيد القيمة المنطقية true في حالة وجود سطرٍ واحدٍ على الأقل بمصدر المُدْخَلات. تَحِد ضرورة اِستخدَام فاصلٍ بين الوحدات من فعالية الصنف Scanner نوعًا ما، لكنه رغم ذلك سهل الاستخدام، ومناسبٌ للعديد من التطبيقات المختلفة. نظرًا لوجود الكثير من الأصناف المسؤولة عن عمليات الدْخَل، مثل BufferedReader وTextIO وScanner، قد تُصيبك الحيرة لإختيار الأنسب للاستخدام. يُفضَّل عمومًا اِستخدام الصنف Scanner إلا إذا كان هناك سببٌ واضحٌ يدفعك لتفضيل أسلوب الصنف TextIO. في المقابل، يُعدّ الصنف BufferedReader بديلًا بسيطًا، إذا كان كل ما تحتاجه هو مجرد قراءة أسطرٍ نصيةٍ كاملةٍ من مصدر المُدْخَلات. لاحِظ أنه من الممكن تغيير الفاصل الذي يَعتمِد عليه الصنف Scanner للفصل بين الوحدات tokens، ولكن يتطلَّب ذلك التعامل مع ما يُعرَف باسم التعبيرات النمطية regular expression، والتي قد تكون معقدةً بعض الشيء، وهي عمومًا ليست ضمن أهداف هذا الكتاب، ولكن سنأخذ مثالًا بسيطًا عنها؛ ولنفترض مثلًا أننا نريد وحداتٍ مؤلفةً من كلماتٍ مُكوَّنةٍ فقط من أحرف الأبجدية الإنجليزية. يُمكِن في تلك الحالة للفاصل أن يَكون أي محرفٍ من غير تلك الأحرف؛ فإذا كان لدينا كائنٌ من الصنف Scanner اسمه scnr، فإننا نستطيع كتابة scnr.useDelimiter("[^a-zA-Z]+") لجعله يَستخدِم هذا النوع من الفواصل، وستكون بذلك الوحدات المعادة من scnr.next() مُكوَّنةً بالكامل من أحرف الأبجدية الإنجليزية. تُعدّ السلسلة النصية [^a-zA-Z]+ تعبيرًا نمطيًا، وهي في الواقع أداةً مهمةً لأي مبرمج، وعليك أن تشرُع بتعلُّمها إذا واتتك الفرصة لذلك. إدخال وإخراج الكائنات المسلسلة Serialized تَسمَح لنا الأصناف الآتية: PrintWriter. Scanner. DataInputStream. DataOutputStream. بمعالجة الدْخَل والخَرْج من جميع أنواع جافا الأساسية، ولكن ماذا لو أردنا أن نقرأ أو نكتب كائنات؟ سنحتاج بالضرورة إلى العثور على طريقةٍ ما لترميز الكائنات، وتحويلها إلى متتاليةٍ من القيم المنتمية لأي من الأنواع الأساسية، والتي يُمكِن بعد ذلك إرسالها على أنها خَرْجٌ بهيئة بايتات أو محارف. يُطلَق على تلك العملية اسم سَلسَلة serialize الكائن. سنضطّر من الناحية الأخرى لقراءة البيانات المُسَلسَلة، ثم اِستخدَامها لإعادة بناء الكائن الأصلي. إذا كان الكائن معقدًا بعض الشيء، فسيضطّرنا ذلك إلى الكثير من العمل الذي هو في أساسه مجرد عمل روتيني. تُوفِّر جافا لحسن الحظ الصنفين ObjectInputStream وObjectOutputStream؛ لتحمُّل عبء غالبية ذلك العمل. لاحِظ أنهما صنفان فرعيان من الصنفين InputStream وOutputStream، ويُمكِنهما العمل مع الكائنات المُسَلسَلة. يُعدّ الصنفان ObjectInputStream وObjectOutputStream أصنافًا مغلِّفة؛ أي يُمكِنها أن تُغلِّف مجارٍ streams من النوعين InputStream وOutputStream على الترتيب، وهو ما يَسمَح بإدْخال وإخراج الكائنات عبر أي مجرى بايتات؛ حيث يتضمَّن الصنف ObjectInputStream التابع readObject()؛ بينما يتضمَّن الصنف ObjectOutputStream التابع writeObject(Object obj)، ويُمكِنهما التبليغ عن استثناءاتٍ من النوع IOException. يتضمَّن الصنف ObjectOutputStream التوابع writeInt() وwriteDouble() وما يُشبهها، لإرسال قيمٍ منتميةٍ لأي من الأنواع الأساسية إلى مجرى الخرج، كما يتضمَّن الصنف ObjectInputStream توابعًا مكافئةً لقراءة قيمٍ منتميةٍ لأي من الأنواع الأساسية. لاحِظ أنه من الممكن إرسال كائناتٍ أثناء إرسال قيم تنتمي لأي من الأنواع الأساسية، حيث تُمثَّل القيم المنتمية للأنواع الأساسية بصيغتها الثنائية binary الداخلية عند تخزينها بملف. تُعدّ مجاري الكائنات بمثابة مجاري بايتات؛ حيث تُمثَّل الكائنات بصيغةٍ ثنائيةٍ مهيأة للآلة. في حين يُعزز ذلك من كفائتها، فإنه يتسبَّب بنفس الهشاشة التي تُعاني منها البيانات الثنائية في العموم. ونظرًا لأن الصيغة الثنائية للكائنات مُهيأةٌ للغة جافا، لا يكون من السهل إتاحة بيانات مجاري الكائنات للبرامج المكتوبة بلغاتٍ برمجيةٍ مختلفة. بناءً على ذلك، يُفضَّل اِستخدَام مجاري الكائنات فقط عند الحاجة إلى تخزينها تخزينًا مؤقتًا، أو إلى نَقْلها عبر اتصالٍ شبكي بين برنامجي جافا؛ أما بالنسبة للتخزين طويل الأمد أو الاتصال مع برامج مكتوبة بلغات آخرى، فهناك طرائقٌ بديلةٌ أفضل لسَلسَلة الكائنات (ألقِ نظرةً على المقال مقدمة مختصرة للغة XML لطريقةٍ معتمدةٍ على المحارف). يَعمَل الصنفان ObjectInputStream وObjectOutputStream مع الكائنات التي تُنفِّذ الواجهة Serializable فقط، كما يجب أن تكون جميع متغيرات النسخ المُضمَّنة بتلك الكائنات قابلةً للسَلسَلة. لا تنطوي عملية جعل كائنٍ معينٍ قابلًا للسَلسَلة على أي عملٍ تقريبًا؛ حيث لا تُصرّح الواجهة Serializable حقيقةً عن أي توابع، وإنما هي موجودةٌ فقط مثل إشارةٍ للمُصرِّف على أن الكائن المُنفِّذ لها قابل للكتابة والقراءة. يَعنِي ذلك أن كل ما علينا فعله هو إضافة الكلمات implements Serializable إلى تعريف الصنف. لاحِظ أن الكثير من أصناف جافا القياسية مُصرَّح عنها بحيث تكون قابلة للسَلسَلة بالفعل. تنبيه عن استخدام الصنف ObjectOutputStream: أُعدَّت مجاري ذلك الصنف لتجنَّبنا إعادة كتابة نفس الكائن أكثر من مرة، ولهذا، إذا واجه المجرى كائنًا معينًا للمرة الثانية، فإنه في الواقع يَستخدِم مرجعًا reference إلى كائن المرة الأولى أثناء الكتابة. يَعنِي ذلك أنه في حالة كان الكائن قد عُدّل بين المرتين الأولى والثانية، فإننا لن نَحصُل على البيانات الجديدة؛ لأن القيمة المُعدَّلة لا تُرسَل بصورةٍ صحيحة إلى المجرى. يَرجِع ذلك إلى أن مجاري الصنف ObjectOutputStream قد أُعدّت بالأساس للعمل مع الكائنات الثابتة immutable التي لا يُمكِن تعديلها بعد إنشائها، مثل السلاسل النصية من النوع String. ومع ذلك، إذا أردت حقًا أن تُرسِل كائنًا مُتغيرًا mutable إلى هذا النوع من المجاري، وكان من المحتمل أن تُرسِل نفس الكائن أكثر من مرة، فيُمكِنك في تلك الحالة أن تضمَن إرسال النسخة الصحيحة من الكائن باستدعاء تابع المجرى reset() قبل إرسال الكائن إليه. ترجمة -بتصرّف- للقسم Section 1: I/O Streams, Readers, and Writers من فصل Chapter 11: Input/Output Streams, Files, and Networking من كتاب Introduction to Programming Using Java. اقرأ أيضًا التعامل مع المدخلات وإظهار المخرجات في لغة جافا كيفية قراءة البرامج لمدخلات المستخدم القوائم lists والأطقم sets في جافا الواجهات Interfaces في جافا
-
تُمثِّل واجهة برمجة التطبيقات stream API واحدةً من الخاصيات الجديدة الكثيرة المُقدّمة في الإصدار 8 من جافا، حيث تُعد أسلوبًا جديدًا للتعبير عن العمليات على تجميعاتٍ collections من البيانات. كان الدافع وراء هذا التوجه الجديد هو أن يتمكَّن مُصرِّف جافا من تنفيذ العمليات على التوازي parallelize؛ أي تقسيمها إلى أجزاءٍ يُمكِن تشغيلها بواسطة عدّة معالجات processors بنفس الوقت، مما يُسرِّع العملية إلى حدٍ كبير. سنناقش البرمجة المتوازية parallel programming في جزئية قادمة باستخدام الخيوط thread، وهي في الواقع صعبةٌ بعض الشيء وتنطوي على كثيرٍ من الأخطاء المُحتمَلة. تُمكَّنك واجهة برمجة التطبيقات stream API من تشغيل بعض أنواع العمليات على التوازي وبصورةٍ آمنة وعلى نحوٍ تلقائي، وقد أثارت جلبًا كبيرًا بالفعل. ستَجِد الأصناف والواجهات interfaces المُعرَّفة بواجهة برمجة التطبيقات stream API ضمن حزمة java.util.stream. لاحِظ أن Stream هي واجهة ضمن تلك الحزمة، وتُمثِّل مجاري التدفق streams، كما تُعرِّف بعض العمليات الأساسية عليها. يُعدّ المجرى stream بمثابة متتاليةٍ من القيم البيانية أو بمعنى أدق تدفق من البيانات كما هو المجرى بالضبط (مثل مجرى الماء)، ويُمكِننا إنشاءه من تجميعةٍ من النوع Collection، أو من مصفوفةٍ، أو من مختلف مصادر البيانات. تُوفِّر واجهة برمجة التطبيقات stream API أيضًا بعض العوامل operators للتعامل مع مجاري التدفق streams. سنناقش تلك الواجهة ضمن هذا الفصل لكونها تَستخدِم وبكثافة مفاهيم البرمجة المُعمَّمة generic programming والأنواع ذات المعاملات غير مُحدَّدة النوع parameterized types. لتنفيذ عمليةٍ حسابيةٍ معينةٍ باستخدام الواجهة stream API، ينبغي إنشاء مجرًى لقراءة البيانات من مصدرٍ معين، ثم تطبيق متتاليةٍ من العمليات عليه، والتي تنتج بالنهاية القيمة المطلوبة. بمجرد اِستهلاكك لمجرًى معين، لا يُمكِنك إعادة اِستخدَامه؛ وإذا أردت استخدامه لإجراء عملية حسابية أخرى، تستطيع بالتأكيد إنشاء مجرًى آخر من نفس مصدر البيانات. يتطلّب التعبير عن العمليات الحسابية بصيغة متتاليةٍ من عمليات المجرى stream operations نوعًا مختلفًا من التفكير، وستحتاج إلى بعض الوقت لتعتاد عليه. لنفترض مثلًا أن stringList قائمةٌ ضخمةٌ من النوع ArrayList<String>، جميع عناصرها غير فارغة، وأننا نريد معرفة متوسط طول السلاسل النصية الموجودة بالقائمة. يُمكِننا تنفيذ ذلك ببساطةٍ بالاستعانة بحلقة for-each بسيطة على النحو التالي: int lengthSum = 0; for ( String str : stringList ) { lengthSum = lengthSum + str.length(); } double average = (double)lengthSum / stringList.size(); يُمكِننا أن نفعل الشيء نفسه باستخدام واجهة برمجة التطبيقات stream API على النحو التالي: int lengthSum = stringList.parallelStream() .mapToInt( str -> str.length() ) .sum(); double average = (double)lengthSum / stringList.size(); يُنشِئ التابع stringList.parallelStream() في النسخة الثانية مجرًى يحتوي على جميع عناصر القائمة؛ ونظرًا لكونه مجرًى متوازٍ parallelStream، يُصبِح من الممكن تنفيذ العملية الحسابية على التوازي parallelize. يُطبِق التابع mapToInt() عملية ربط map على مجرى السلاسل النصية؛ بمعنى أنه يقرأ سلسلةً نصيةً من المجرى stream، ويُطبِّق عليها دالة function؛ حيث تَحسِب الدالة في هذا المثال طول السلسلة النصية، وتعيد قيمةً من النوع int. وبذلك، يَكون ناتج العملية map مجرًى جديدًا، ولكنه مُكوَّنٌ من أعدادٍ صحيحةٍ هي نفسها الأعداد العائدة من تلك الدالة. بالنهاية، تَحسِب العملية الأخيرة sum() حاصل مجموع جميع الأعداد الموجودة بمجرى الأعداد الصحيحة، وتعيد الناتج. بناءً على ما سبق، تكون المُحصِّلة هي جمع أطوال جميع السلاسل النصية ضمن القائمة. نظرًا لإمكانية تنفيذ العملية على التوازي، ربما تكون الشيفرة المُستخدِمة للمجرى أسرع من تلك التي تَستخدِم حلقة تكرار loop، ولكن تنطوي عملية إنشاء المجرى عمليًا ومعالجته حملًا overhead إضافيًا، ولذلك يجب أن تكون القائمة ضخمةً بما يكفي حتى تتمكّن من ملاحظة سرعتها؛ وإذا كانت القائمة صغيرةً، سيستغرق المجرى حتمًا وقتًا أطول من تلك المُستخدِمة للحلقة. تُعدّ واجهة برمجة التطبيقات stream API معقدةً بعض الشيء، ولهذا سنكتفي بنبذةٍ بسيطة ولكنها ستُعطي لمحةً كافيةً عنها. واجهات نوع الدالة المعممة Generic Functional Interfaces تُمرَّر عادةً مُعامِلات عوامل المجرى stream operators بصيغة تعبيرات لامدا lambda expressions، فعلى سبيل المثال، يَستقبِل العامل mapToInt() في المثال السابق مُعامِله parameter بهيئة دالةٍ function تَستقبِل بدورها سلسلةً نصيةً من النوع String، وتُعيد عددًا صحيحًا من النوع int. ينتمي هذا المعامل إلى واجهة نوع الدالة functional interface ToIntFunction<T>، المُعرَّفة بحزمة java.util.function، والتي تَملُك معاملًا غير مُحدَّد النوع parameterized. تُمثِل تلك الواجهة الفكرة العامة لدالةٍ تَستقبِل مُدْخلًا من النوع T، وتُعيد خَرْجًا من النوع int. إذا فحصت تعريف تلك الواجهة، ستَجِد ما يَلي: public interface ToIntFunction<T> { public int applyAsInt( T x ); } تُعدّ Stream<T> كذلك مثالًا على واجهةٍ ذات معاملات غير مُحدَّدة النوع parameterized interface؛ حيث ينتمي stringList بالمثال السابق إلى النوع ArrayList<String>؛ بينما ينتمي المجرى stream الذي تُنشِئه الدالة stringList.parallelStream() إلى النوع Stream<String>. يتوقَّع العامل mapToInt() عند تطبيقه على ذلك المجرى stream معامًلا من النوع ToIntFunction<String>. يَربُط map تعبير لامدا str -> str.length() سلسلةً نصيةً من النوع String بعددٍ صحيحٍ من النوع int، وهو يمثِّل بذلك قيمةً عدديةً من النوع الصحيح. لحسن الحظ، لست مُضطّرًا للتفكير بكل تلك التفاصيل لتتمكَّن من اِستخدَام واجهة برمجة التطبيقات stream API، وإنما كل ما ينبغي معرفته هو أنه إذا أردت استخدام mapToInt لتحوِّل مجرًى من السلاسل النصية stream of strings إلى مجرًى من الأعداد الصحيحة، يجب أن تُوفِّر دالةً function تَربُط map سلاسلًا نصيةً بأعدادٍ صحيحة. ولكن، إذا أردت قراءة توثيق الواجهة API، فستتعامل حتمًا مع معاملات أنواع parameter types، مثل ToIntFunction. تتضمَّن حزمة java.util.function عددًا كبيرًا من واجهات نوع الدالة المُعمَّمة generic functional interfaces، والتي يُعدّ الكثير منها، مثل ToIntFunction أنواعًا ذات معاملاتٍ غير مُحدَّدة النوع parameterized types، كما أنها جميعًا مُعمَّمة؛ مما يَعنِي أنها تُمثِل دوالًا مُعمَّمة بلا معنى مُحدَّد. تُمثِل واجهة نوع الدالة DoubleUnaryOperator على سبيل المثال الفكرة العامة لدالةٍ تَستقبِل مُدْخلًا من النوع double، وتُعيد خَرْجًا من النوع double. تُشبِه تلك الواجهة المثال التوضيحي FunctionR2R، الذي تعرَّضنا له في مقال تعبيرات لامدا (Lambda Expressions) في جافا، باستثناء اختلاف اسم الدالة المُعرَّفة، وهو عادةً أمرٌ غير ذي صلة. تُخصِّص الواجهات المُعرَّفة بحزمة java.util.function معاملات الأنواع parameter types الخاصة بكثيرٍ من عوامل المجرى stream operators؛ وكذلك الخاصة ببعض الدوال المبنية مُسبقًا built-in functions بواجهة برمجة التطبيقات جافا Java API؛ كما يُمكِنك استخدِامها بكل تأكيد لتُخصِّص معاملات أنواع parameter types للبرامج الفرعية subroutines التي تَكْتُبها بنفسك. سنناقش هنا بعضًا منها، أما البقية فهي بالغالب مجرد نسخٍ مُتباينةٍ منها. يُشير مصطلح جملة خبرية predicate إلى الدوال function التي تُعيد قيمةً منطقيةً من النوع boolean، حيث تُعرِّف واجهة نوع الدالة Predicate<T> الدالة test(t)، والتي تَستقبِل معاملًا من النوع T، وتُعيد قيمةً منطقية. يُمكِننا استخدام تلك الواجهة بمثابة معامل نوعٍ parameter type للتابع removeIf(p) المُعرَّف بجميع التجميعات من النوع Collection. إذا كان strList قائمةً من النوع LinkedList<String> على سبيل المثال، ستَحذِف التعليمة التالية جميع القيم الفارغة null الموجودة بها: strList.removeIf( s -> (s == null) ); ينتمي المعامل إلى النوع Predicate<String>، ويَفحَص فيما إذا كانت القيمة المُدْخَلة إليه s تُساوِي null. يَحذِف التابع removeIf() جميع عناصر القائمة التي تؤول قيمة جملتها الخبرية predicate إلى القيمة المنطقية true. يُمكِننا استخدام النوع Predicate<Integer> لتمثيل جملة خبرية predicate تَفحَص قيمًا عدديةً من النوع int، ولكن سيتسبَّب التغليف التلقائي autoboxing لكل عددٍ صحيحٍ من النوع int داخل مُغلِّفٍ wrapper من النوع Integer بزيادة الحمل overhead. يُمكِننا تجنُّب ذلك لحسن الحظ؛ حيث تُوفِّر حزمة java.util.function واجهة نوع الدالة IntPredicate، والتي تتضمَّن الدالة test(n)؛ حيث تَستقبِل معاملًا n من النوع int، وتعيد قيمةً منطقية. تُوفِّر الحزمة أيضًا الواجهتين DoublePredicate و LongPredicate. يُعدّ هذا مثالًا نموذجيًا على طريقة تعامل واجهة برمجة التطبيقات stream API مع الأنواع الأساسية primitive types؛ فهي تُعرِّف النوع IntStream مثلًا لتمثيل مجرى أعداد صحيحة stream of ints مثل بديلٍ أكثر كفاءةً من النوع Stream<Integer>. تُعرِّف واجهة نوع الدالة Supplier<T> الدالة get() بدون أية معاملات وبقيمةٍ مُعادةٍ return type من النوع T، والتي تَعمَل بمثابة مصدر source لقيمٍ من النوع T؛ بينما تُعرِّف الواجهة المرافقة Consumer<T> الدالة accept(t)، التي تَستقبِل مُعاملًا من النوع T، ولا تُعيد أي قيمة void. سنناقش لاحقًا عدّة أمثلةٍ على تلك الواجهتين. تتوفَّر كذلك نُسخٌ مُخصَّصةٌ لكل نوعٍ أساسي primitive types، مثل IntSupplier و IntConsumer و DoubleSupplier و DoubleConsumer. تُمثِّل واجهة نوع الدالة Function<T,R> دوالًا functions من قيمٍ تنتمي للنوع T إلى قيم تنتمي للنوع R؛ وتُعرِّف الدالة apply(t)، التي تَستقبِل معاملًا t من النوع T، وتُعيد قيمةً من النوع R. تُعدّ الواجهة UnaryOperator<T> مثالًا على النوع Function<T,T>، وهو ما يُفيد بأن مُدْخَلها ومُخرَجها من نفس النوع. لاحِظ أن الواجهة DoubleUnaryOperator هي نسخةٌ مُخصَّصةٌ من UnaryOperator<Double>، كما تتوفَّر أيضًا الواجهة IntUnaryOperator. أخيرًا، تُعرِّف واجهة نوع الدالة BinaryOperator<T> وأنواعها المُخصَّصة، مثل IntBinaryOperator الدالة apply(t1,t2)، والتي تَستقبِل المعاملين t1 و t2 من النوع T، وتُعيد قيمةً من النوع T أيضًا. تتضمَّن العوامل الثنائية binary operators أشياءً، مثل إضافة عددين أو ضم concatenation سلسلتين نصيتين. إنشاء مجاري التدفق Streams سنناقش أولًا طريقة إنشاء مجرًى stream، ثم ننتقل إلى الحديث عن واجهة برمجة التطبيقات stream API، حيث تُوفِّر جافا طرائقًا كثيرةً لإنشاء مجرى. يُمكِنك إنشاء نوعين من مجاري التدفق streams: مجرًى متتالٍ sequential، أو مجرًى متوازٍ parallel؛ حيث يُمكِننا عمومًا تطبيق العمليات على قيم مجرًى متوازٍ بنفس الوقت؛ في حين يحب مُعالَجة قيم مجرى متتالي على التوالي دائمًا ضمن عمليةٍ واحدةٍ كما لو كانت داخل حلقة for. ربما لا يَكون الهدف من وجود مجاري التدفق المتتالية واضحًا، ولكنها مهمة؛ فلن تتمكَّن في بعض الأحيان من تنفِّيذ عملياتٍ معينة على نحوٍ متوازٍ وبصورةٍ آمنة. يُمكِن تحويل أي مجرى من أحد النوعين إلى النوع الآخر، فإذا كان لدينا مثلًا مُتغيّر stream من النوع Stream، يُمثِّل stream.parallel() نفس مجرى القيم بعد تحويلها إلى مجرًى متوازٍ parallel stream إذا لم تَكُن كذلك فعلًا. في المقابل، يُمثِل stream.sequential() نفس مجرى القيم بهيئة مجرى متتالي sequential. إذا كان c تمثِّل أي تجميعةٍ من النوع Collection، سيكون c.parallelStream() مجرى stream متوازي وقيمه هي القيم الموجودة بتلك التجميعة؛ بينما يُنشِئ التابع c.stream() مجرى متتالي بنفس القيم. يُمكِنك تطبيق ذلك على أي تجميعة collection، بما في ذلك القوائم lists والأطقم sets، ويُمكِنك أيضًا الحصول على مجرى متوازٍ باستدعاء c.stream().parallel(). لا تتضمن أي مصفوفةٍ التابع stream()، وتستطيع مع ذلك إنشاء مجرى stream من مصفوفةٍ باستدعاء التابع الساكن static التالي الُمعرَّف بالصنف Arrays ضمن حزمة java.util. إذا كانت A مصفوفة، فستُعيد التعليمة التالية مجرى متتالي sequential stream يحتوي على جميع قيم المصفوفة: Arrays.stream(A) اِستخدِم Arrays.stream(A).parallel() للحصول على مجرى متوازي parallel stream، حيث يَعمَل التابع السابق مع مصفوفات الأنواع الأساسية، مثل int و double و long، وكذلك مع مصفوفات الكائنات. إذا كان A من النوع T[]، وكان T يمثِّل نوع كائن، فسيكون المجرى من النوع Stream<T>؛ أما إذا كان A مصفوفة أعداد صحيحة من النوع int، فإننا نَحصل على مجرى من النوع IntStream، وينطبق الأمر نفسه على النوعين double و long. إذا كان supplier من النوع Supplier<T>، سيُنشِئ التابع supplier.get() مجرى قيمٍ من النوع T عند استدعائه مرةً بعد أخرى. يُمكِننا في الواقع إنشاءَ المجرى على النحو التالي: Stream.generate( supplier ) لاحِظ أن هذا المجرى متتاليٌ ولا نهائي؛ بمعنى أنه سيُنتِج قيمًا للأبد أو إلى أن يتسبَّب ذلك بحدوث خطأ. وبالمثل، يُنتِج التابع IntStream.generate(s) مجرى قيم عددية من النوع int من مُورّدٍ من النوع IntSupplier؛ بينما يُنشِئ التابع DoubleStream.generate(s) مجرى قيم عددية من النوع double من مُورِّد من النوع DoubleSupplier. ألقِ نظرةً على المثال التالي: DoubleStream.generate( () -> Math.random() ) يُنشِئ الاستدعاء السابق مجرى لا نهائي infinite stream من الأعداد العشوائية. يُمكِنك في الواقع استخدام مُتغيّرٍ من النوع Random (انظر مقال البرمجة في جافا باستخدام الكائنات (Objects)) لإنشاء مجرًى مماثل. إذا كان المتُغيِّر اسمه rand، يعيد الاستدعاء rand.doubles() مجرًى لا نهائي من الأعداد العشوائية التي تتراوح قيمتها من 0 إلى 1؛ فإذا كنت تريد عددًا مُحدَّدًا من الأعداد العشوائية، اِستخدِم rand.doubles(count). يُوفِّر الصنف Random توابعًا methods أخرى لإنشاء مجاري تدفق streams مُكوَّنةٍ من أعدادٍ صحيحة عشوائية من النوع int، أو أعدادٍ حقيقيةٍ عشوائية من النوع double. بالإضافة إلى ذلك، تُوفِّر مختلف أصناف جافا القياسية standard classes توابعًا أخرى لإنشاء مجاري تدفق. تُعرِّف الواجهة IntStream تابعًا method لإنشاء مجرى stream يحتوي على أعدادٍ صحيحةٍ ضمن نطاقٍ معينٍ من القيم. تُعيد التعليمة التالية: IntStream.range( start, end ) مجرى متتالي sequential يحتوي على القيم start و start+1 وصولًا إلى end-1. لاحِظ أن قيمة end غير مُتضمَّنة. وفَّرت الإصدارات الأحدث من جافا توابعًا methods أخرى إضافية لإنشاء مجاري streams. لنفترض مثلًا أن لدينا مُتغيِّر input من النوع Scanner، سينشئ التابع input.tokens() (المُتوفِّر منذ الإصدار 9 من جافا) مجرًى stream يَحتوِي على جميع السلاسل النصية strings التي كان التابع input.next() سيُعيدها، إذا استُدعِي مرةً بعد أخرى. لنفترض مثلًا أنه لدينا مُتغيِّر سلسلة نصية str من النوع String مُكوَّنة من عدة أسطر، فسينشئ التابع str.lines() (المُتوفِّر منذ الإصدار 11 من جافا) مجرًى stream يحتوي على أسطر السلسلة النصية string. العمليات على مجاري التدفق streams تُنتِج بعض العمليات عند تطبيقها على مجرى stream مجرًى آخرًا، ويُطلَق عليها في تلك الحالة اسم العمليات الوسيطة intermediate operations؛ حيث سيتعيّن عليك تطبيق عمليةٍ أخرى على المجرى قبل الحصُول على النتيجة النهائية. تُنتِج في المقابل بعض العمليات الأخرى عند تطبيقها على مجرًى النتيجة النهائية مباشرةً، وليس مجرًى آخرًا، ويُطلَق عليها في تلك الحالة اسم العمليات النهائية terminal operations. عندما تتعامل مع مجرى، فستَتَبِع في العموم النمط التالي: ستُنشِئ مجرى stream، ثم تُطبِّق عليه متتاليةً من العمليات الوسيطة intermediate، ثم ستُطبِق عليه عمليةً نهائية terminal للحصول على النتيجة النهائية المطلوبة. كانت mapToInt() في المثال الذي تعرَّضنا له ببداية هذا المقال عمليةً وسيطةً حوَّلت مجرى سلاسلٍ نصيةٍ من النوع string إلى مجرى أعدادٍ صحيحةٍ من النوع int؛ بينما كانت sum() عمليةً نهائيةً حصَّلت مجموع الأعداد الموجودة بمجرى الأعداد الصحيحة. تُعدّ filter و map اثنتين من أبسط العمليات الوسيطة intermediate operations على المجاري؛ حيث تُطبِق filter جملة خبرية predicate من النوع Predicate على مجرى stream، وتُنتِج مجرًى جديدًا يتضمَّن القيم الموجودة بالمجرى الأصلي التي آلت جملتها الخبرية إلى القيمة true. على سبيل المثال، إذا كانت الدالة isPrime(n) تختبر فيما إذا كان عدد صحيح n عددًا أوليًّا أم لا، وتُعيد قيمةً منطقية تُمثِل ذلك، فإن: IntSteam.range(2,1000).filter( n -> isPrime(n) ) يُنشِئ مجرًى يحتوي على جميع الأعداد الأوليّة الواقعة بنطاقٍ يتراوح بين 2 و 1000. لاحِظ أن التعليمة السابقة ليست بالضرورة طريقةً جيدةً إذا أردت إنتاج تلك الأعداد. تُطبِّق map دالةً من النوع Function على جميع قيم مجرًى معين، وتُنتِج مجرًى جديدًا يحتوي على خرج الدالة لكل قيمة. لنفترض أن strList قائمة سلاسلٍ نصيةٍ من النوع ArrayList<String>، وأننا نريد مجرًى يحتوي على كل السلاسل النصية غير الفارغة الموجودة ضمن تلك القائمة، ولكن بعد تحويل أحرفها إلى حالتها الصغيرة lower case، يُمكِننا إذًا استخدام ما يَلِي: strList.stream().filter( s -> (s != null) ).map( s -> s.toLowerCase() ) تَربُط map العمليتان mapToInt() و mapToDouble() مجرًى من النوع Stream إلى النوع IntStream و DoubleStream على الترتيب. نََستعرِض فيما يلي بعضًا من العمليات الوسيطة intermediate الممكن تطبيقها على مجرى S: أولًا، تُنشِئ S.limit(n) مجرًى stream يحتوي على أول عدد n (قيمة من النوع integer) من قيم المجرى S؛ وفي حال احتوى المجرى S على قيمٍ عددها أقل من n، فستكون النتيجة مُطابقةً للمجرى الأصلي S. ثانيًا، تُنشِئ S.distinct() مجرًى مُكوَّنًا من قيم المجرى S بعد حذف المُكرَّر منها؛ أي تكون قيم المجرى الجديد مختلفة بالكامل. ثالثًا، تُنشِئ S.sorted() مجرًى جديدًا يحتوي على نفس قيم المجرى S بعد ترتيبها. إذا لم يَكن هناك ترتيب بديهي للعناصر، ينبغي تمرير معاملٍ من النوع Comparator للتابع sorted(). ألقِ نظرةً على مقال مفهوم البرمجة المعممة Generic Programming لمزيدٍ من المعلومات عن الصنف Comparator. يتعيّن عليك تطبيق عمليةٍ نهائيةٍ terminal معينة على المجرى للحصول على شيءٍ مفيد. يُطبِّق العامل forEach(c) مُستهلِكًا c من النوع Consumer على جميع عناصر المجرى. وبسبب عدم إنتاج المُستهلِك consumer أي قيمٍ، فإننا لا نَحصل بالنهاية على مجرى؛ وإنما يَكْمُن تأثير S.forEach(c) على مجرى S بما يُطبََق على كل قيمةٍ ضمن المجرى. تَطبَع الشيفرة التالية على سبيل المثال جميع السلاسل النصية الموجودة ضمن قائمة list بطريقةٍ جديدةٍ تمامًا: stringList.stream().forEach( s -> System.out.println(s) ); عندما نُطبِق دالة مُستهلِك consumer على قيم المجرى المتوازي، فإنها لا تُطبَق بالضرورة على قيم المجرى بنفس ترتيب حدوثها؛ فإذا أردت أن تَضمَن تطبيقها بنفس الترتيب، اِستخدِم forEachOrdered(c) بدلًا من forEach(c). إذا أردنا طباعة بعضٍ من السلاسل النصية، مثل تلك التي يَصِل طولها إلى 5 أحرف على الأقل، بحيث تَكون مُرتَّبة، يُمكِننا تطبيق العمليات التالية: stringList.stream() .filter( s -> (s.length() >= 5) ) .sorted() .forEachOrdered( s -> System.out.println(s) ) تُخرِج بعض العمليات النهائية قيمةً واحدةً فقط، حيث يعيد S.count() على سبيل المثال عدد القيم الموجودة بمجرى S. يتضمَّن كُلًا من IntStream و DoubleStream العملية sum()، التي تَحسِب حاصل مجموع كل قيم المجرى. إذا أردنا مثلًا اختبار مولِّد أعدادٍ عشوائي بتوليد 10000 عدد، ثم عَدّ تلك التي قيمتها أقل من 0.5، يُمكِننا كتابة ما يَلي: long half = DoubleStream.generate( Math::random ) .limit(10000) .filter( x -> (x < 0.5) ) .count(); تُعيد count() قيمةً من النوع long وليس int. لاحِظ أننا قد اِستخدَمنا المرجع التابعي Math::random بدلًا من تعبير لامدا lambda expression المكافئ () -> Math.random(). إذا واجهت صعوبةً بقراءة الشيفرات المماثلة، تذكَّر دومًا نمط التعامل مع المجاري، وهو على النحو التالي: أنشِئ مجرًى، ثم طَبِق بعض العمليات الوسيطة intermediate، وأخيرًا، طَبِق عمليةً نهائيةً terminal. يُنشِئ المثال السابق مجرًى لا نهائيًا infinite stream من الأعداد العشوائية باستدعاء Math.random() مرارًا وتكرارًا. نلاحِظ استخدام limit(10000) لتقطيع ذلك المجرى إلى 10000 قيمة، وهذا يَعنِي أننا أنتجنا 10000 قيمةٍ فقط؛ كما تَسمَح عملية filter() فقط للأعداد x التي تُحقِّق الشرط x < 0.5؛ ويُعيد count() في النهاية عدد عناصر المجرى الناتج. يتضمَّن Stream<T> بعضًا من العمليات النهائية، مثل min(c) و max(c)، حيث تُعيد كلتاهما أقل وأكبر قيمةٍ بالمجرى على الترتيب. يَستقبِل كُلًا منهما مُعاملًا c من النوع Comparator<T> للموازنة بين القيم، ويُعيدان قيمةً من النوع Optional<T>. قد يكون ذلك النوع غير مألوف بالنسبة لك، ولكن تمثِّل قيمه قيمةً من النوع T، والتي ربما تكون موجودةً أو لا. إذا كان لديك مثلًا مجرًى فارغًا، فإنه بطبيعة الحال لا يحتوي على قيمةٍ صغرى أو عظمى، مما يَعنِي أنها غير موجودة. يتضمَّن الصنف Optional التابع get()، الذي يعيد قيمة من النوع Optional إذا كانت موجودة؛ أما إذا كانت فارغة، فإنه يُبلِّغ عن اعتراض exception. بفرض أن words تجميعةٌ من النوع Collection<String>، فستُعيد الشيفرة التالية أطول سلسلةٍ نصيةٍ string موجودةٍ فيها: String longest = words.parallelStream() .max( (s1,s2) -> s1.length() - s2.length() ) .get(); وتُبلِّغ الشيفرة السابقة في ذات الوقت عن اعتراضٍ، إذا كانت التجميعة فارغة؛ حيث يُعيد التابع isPresent() بالصنف Optional قيمةً منطقيةً تُمثِل فيما إذا كانت القيمة موجودةً أم لا. وبالمثل، تُعيد العمليتان النهائيتان min() و max() في النوعين IntStream و DoubleStream قيمًا من النوع OptionalInt و OptionalDouble على الترتيب. تَستقبِل العوامل النهائية allMatch(p) و anyMatch(p) جملة خبرية predicate بمثابة معامل، وتعيد قيمةً منطقية؛ حيث تكون قيمة العامل allMatch(p) مساويةً للقيمة true، إذا كانت الجملة الخبرية p مُحقِّقةً لكل قيم المجرى stream الذي طُبِّقت عليه؛ بينما تكون قيمة العامل anyMatch(p) مساويةً للقيمة true، إذا تحقَّقت الجملة الخبرية p لقيمةٍ واحدةٍ على الأقل ضمن المجرى الذي طُبِّقت عليه. يُمكِننا التعبير عن معظم العمليات النهائية terminal operations، التي تَحسِب قيمةً وحيدةً بالاستعانة بعمليةٍ أكثر شمولية تُدعى reduce؛ والتي تَستخدِم عاملًا ثنائيًا من النوع BinaryOperator لدمج عدة عناصر، حيث يُمكِننا مثلًا حسِاب حاصل مجموع أعدادٍ بتطبيق عملية reduce بعامل جمعٍ ثنائي. يجب عمومًا أن يكون العامل الثنائي binary operator مترابطًا associative؛ أي ألا يكون لترتيب تطبيق العامل أي تأثير. لا تُوفِّر جافا عاملًا نهائيًا لحساب حاصل ضرب مجموعةٍ من القيم الموجودة بمجرى stream، وهو ما يُمكِننا حسابه مباشرةً باستخدام reduce. لنفترض مثلًا أن A مصفوفة أعدادٍ حقيقية من النوع double، تَحسِب الشيفرة التالية حاصل ضرب جميع عناصر المصفوفة غير الصفرية: double multiply = Arrays.stream(A).filter( x -> (x != 0) ) .reduce( 1, (x,y) -> x*y ); يَربُط العامل الثنائي بالأعلى زوجًا من الأعداد (x,y) بحاصل ضربهما x*y؛ حيث يُمثِّل معامل عملية reduce() الأول مُعرِّفًا identity للعملية الثنائية، أي قيمة تُحقِّق الشرط 1*x = x لكل قيم x. بالمثِل، يُمكِننا حسِاب أكبر قيمةٍ ضمن مجرى أعدادٍ حقيقيةٍ من النوع double بتطبيق reduce(Double.NEGATIVE_INFINITY, Math::max). تُعدّ العملية العامة collect(c) واحدةً من أهم العمليات النهائية terminal؛ حيث تُجمِّع القيم الموجودة ضمن مجرًى ببنيةٍ بيانيةٍ data structure، أو نتيجةٍ مُلخّصة واحدة من نوعٍ معين. يُطلَق على العامل c في تلك العملية اسم مُجمِّع collector؛ وهو ما تَسترجِعه عادةً من خلال إحدى الدوال functions الساكنة المُعرَّفة بالصنف Collectors. سنناقش هنا بعض الأمثلة البسيطة، ولكن يُمكِن للأمور أن تتعقد أكثر من ذلك بكثير. تعيد الدالة Collectors.toList() مُجمِّعًا من النوع Collector، والذي يُمكِننا تمريره لعملية collect() لوضع جميع قيم المجرى بقائمةٍ من النوع List. لنفترض مثلًا أن A مصفوفةٌ تحتوي على سلاسلٍ نصيةٍ غير فارغة not null من النوع String، وأننا نريد قائمةً بكل سلاسلها النصية التي تبدأ بكلمة "Fred": List<String> freds = Arrays.stream(A) .filter( s -> s.startsWith("Fred") ) .collect( Collectors.toList() ); تُعَد الشيفرة الموضحة أعلاه سهلةً للغاية. تتوفَّر أيضًا مُجمِّعات collectors أخرى تُجمِّع عناصر المجرى وفقًا لمقياسٍ معين، حيث يَستقبِل المُجمِّع Collectors.groupingBy(f) مُعاملًا f ينتمي نوعه إلى واجهة نوع الدالة Function<T,S>، أي أنه دالةً تَستقبل مُدْخَلًا من النوع T، وتُعيد خرجًا من النوع S. عند اِستخدَام ذلك المُجمِّع مع عملية collect()، فإنها تُعالِج مجرًى من النوع Stream<T>، وتُقسِّم عناصره إلى مجموعاتٍ بحسب قيمة الدالة f عند تطبيقها على كل عنصر. وبتعبيرٍ آخر، لا بُدّ أن تَتَساوى قيمة الدالة (f(x لجميع العناصر x الموجودة ضمن مجموعةٍ معينة. تُعيد collect() في تلك الحالة خريطةً من النوع Map<S,List<T>>، تتضمَّن مفاتيحها keys قيم الدالة (f(x لكل قيم x؛ بينما تكون القيمة المربوطة بمفتاحٍ معين على هيئة قائمة list بجميع عناصر المجرى التي آلت الدالة f عند تطبيقها عليها إلى ذلك المفتاح. سنعرض مثالًا لتَضِح الأمور أكثر. بفرض لدينا مصفوفة أشخاص، بحيث يَملُك كل شخصٍ اسمًا أول واسمًا أخيرًا؛ فإذا أردنا وضع هؤلاء الأشخاص ضمن مجموعات، بحيث تتكوَّن كل مجموعةٍ من كل الأشخاص الذين يَملكون نفس الاسم الأخير. سنَستخدِم كائنًا من صنفٍ اسمه Person، ويحتوي على مُتغيري نسخة firstname و lastname لتمثيل كل شخص. بفرض أن population مُتغيِّرٌ من النوع Person[]، فستُعيد Arrays.stream(population) مجرًى stream يحتوي على أشخاصٍ من النوع Person. يُمكِننا أن نُجمِّع هؤلاء الأشخاص بحسب أسمائهم الأخيرة على النحو التالي: Map<String, List<Person>> families; families = Arrays.stream(population) .collect(Collectors.groupingBy( person -> person.lastname )); يُمثِّل تعبير لامدا person -> person.lastname المبيّن أعلاه الدالة المُجمِّعة، حيث تَستقبِل الدالة كائنًا من النوع Person بمثابة مُدْخَلٍ، وتُعيد سلسلةً نصيةً من النوع String. يُمثِّل كل مفتاحٍ key بالخريطة المُعادة الاسم الأخير لأحد الأشخاص الموجودين ضمن المصفوفة؛ بينما ستكون القيمة المرتبطة بذلك الاسم الأخير قائمةً من النوع List تحتوي على جميع الأشخاص الذين لهم نفس ذلك الاسم الأخير. تَطبَع الشيفرة التالية المجموعات: for ( String lastName : families.keySet() ) { System.out.println("People with last name " + lastName + ":"); for ( Person name : families.get(lastName) ) { System.out.println(" " + name.firstname + " " + name.lastname); } System.out.println(); } على الرغم من أن الشرح طويلٌ قليلًا، إلا أن النتيجة سهلة الفهم. تجربة ناقشنا حتى الآن عدَّة أمثلة ٍعلى استخدام مجاري التدفق streams، ولكنك قد ترى أنها ليست عمليةً بالدرجة الكافية؛ فقد كان بإمكاننا في غالبية تلك الحالات استخدام حلقة تكرار for بسيطة، وسهلة الكتابة، وربما حتى أكثر كفاءة، وهذا صحيح لأننا اعتمدنا على مجاري التدفق المتتالية sequential بأغلب الأمثلة؛ وكان من غير الممكن دائمًا تنفيذها على التوازي وبكفاءة. تُعدّ عملية reduce استثناءً لتلك القاعدة؛ فهي تُنفَّذ بالأساس على التوازي وبكفاءة عالية. لنفحص مثالًا نُطبِّق خلاله واجهة برمجة التطبيقات stream API على مسألةٍ لحساب مجموع ريمان Riemann sum. لا تحتاج لفهم ما تعنيه تلك المسألة بالتحديد؛ فهي في الواقع عملية حسابية كبيرة، وسيَكون للتنفيذ على التوازي parallelization دورًا فعالًا بتحسين أدائها. تَعرِض الشيفرة التالية الطريقة التقليدية لحساب المجموع المطلوب: /** * استخدم حلقة for لحساب مجموع ريمان * @param f الدالة المطلوب جمعها * @param a الحد الأيسر للفترة المطلوب حساب مجموعها * @param b الحد الأيمن * @param n عدد التقسيمات الجزئية ضمن الفترة * @return حاصل مجموع ريمان */ private static double riemannSumWithForLoop( DoubleUnaryOperator f, double a, double b, int n) { double sum = 0; double dx = (b - a) / n; for (int i = 0; i < n; i++) { sum = sum + f.applyAsDouble(a + i*dx); } return sum * dx; } بما أن المعامل parameter الأول للتابع المُعرَّف بالأعلى ينتمي إلى واجهة نوع دالة functional interface، يُمكِننا استدعاؤه على النحو التالي على سبيل المثال: reimannSumWithForLoop( x -> Math.sin(x), 0, Math.PI, 10000 ) والآن، كيف سنُطبِق واجهة تطوير التطبيقات stream API؟ سنحتاج أولًا إلى محاكاة حلقةٍ تكراريةٍ، مثل for، لذلك سنَستخدِم IntStream.range(0,n) لتولِيد أعدادٍ صحيحةٍ بدايةً من الصفر وحتى n بهيئة مجرًى stream متتالي. ينبغي علينا الآن تحويل ذلك المجرى إلى مجرًى متوازٍ parallel باستدعاء عملية .parallel()، لنتمكَّن من تفعيل خاصية التنفيذ على التوازي. بعد ذلك، سنُحوِّل مجرى الأعداد الصحيحة، الذي ولَّدناه للتو إلى مجرى أعدادٍ حقيقيةٍ من النوع double بربط map كل عددٍ صحيحٍ i بالقيمة f.applyAsDouble(a+i*dx). أخيرًا، يُمكِننا تطبيق العملية النهائية sum(). تَعرِض الشيفرة التالية نسخةً أخرى من تابع حساب مجموع ريمان Riemann sum، ولكن باستخدام مجرًى متوازٍ parallel stream: private static double riemannSumWithParallelStream( DoubleUnaryOperator f, double a, double b, int n) { double dx = (b - a) / n; double sum = IntStream.range(0,n) .parallel() .mapToDouble( i -> f.applyAsDouble(a + i*dx) ) .sum(); return sum * dx; } يُمكِننا كْتُب التابع riemannSumWithSequentialStream() بدون العامل parallel(). ستَجِد النسخ الثلاثة من البرنامج بالملف RiemannSumStreamExperiment.java. يَستدعِي البرنامج main التوابع الثلاثة بقيم n مختلفة، ويَحسِب الزمن الذي يَستغرِقه كل تابعٍ منها لحساب المجموع المطلوب، ويَطبَع النتائج. وجدنا -كما هو مُتوقَّع- أن النسخة المُستخدِمة لمجرى متتالي sequential stream هي الأبطأ من بين النسخ الأخرى؛ فهي تشبه كثيرًا نسخة البرنامج المُستخدِم لحلقة for مُضافًا إليه الحمل overhead الزائد الناتج عن إنشاء مجرًى ومعالجته. أما بالنسبة للمجاري المتوازية parallel، فكان الأمر أكثر تشويقًا؛ حيث اعتمدت النتائج على الحاسوب المُستخدَم لتنفيذ البرنامج. على سبيل المثال، اِستخدَمنا جهازًا قديمًا يحتوي على 4 معالجات processors، ووجدنا أن نسخة البرنامج المُستخدِم للحلقة for أسرع عندما كانت n تُساوِي 100,000؛ بينما كانت النسخة المتوازية أسرع عندما كانت n تُساوِي 1,000,000 أو أكثر. اِستخدَمنا جهازًا آخرًا، ووجدنا أن النسخة المتوازية من البرنامج أسرع عندما كانت n تُساوِي 10,000 أو أكثر. لاحِظ أن هناك حدٌ أقصى للسرعة التي يُمكِن للنسخة المتوازية أن تَصِلها بالموازنة مع النسخ الأخرى؛ فبالنسبة لجهازٍ مُكوَّن من عدد K من المعالجات، لا يمكن للنسخة المتوازية أن تَكون أسرع من النسخة المتتالية بما يفوق عدد K من المرات، بل أنه سيكون أبطأ من ذلك عمليًا. ربما عليك أن تُجرِّب البرنامج على حاسوبك الشخصي أيضًا. يُفترَض أن يُمكِّن جهازك جافا من تنفيذ الشيفرة المتوازية على بطاقة الرسوميات graphics card، وهذا هو على الأقل المغزى من واجهة تطوير التطبيقات stream API، بحيث تتمكَّن من اِستخدَام معالجاتها processors الكثيرة، وربما ستَجِد زيادةً كبيرةً بالسرعة في تلك الحالة. ترجمة -بتصرّف- للقسم Section 6: Introduction the Stream API من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: كتابة أصناف وتوابع معممة في جافا مدخل إلى الواجهات البرمجية API الواجهات Interfaces في جافا
-
تعلّمنا حتى الآن كيفية استخدام الأصناف والتوابع المُعمّمة generic المُعرَّفة بإطار جافا للتجميعات Java Collection Framework. حان الوقت الآن لنتعلَّم كيفية كتابة أصنافٍ وتوابعٍ مُعمَّمة جديدةٍ من الصفر، حيث تنتُج البرمجة المُعمّمة شيفرةً عامةً جدًا وقابلةً للاستخدام، ولهذا، يجب على المبرمجين الراغبين بكتابة مكتباتٍ برمجية software libraries قابلةٍ لإعادة الاستخدام أن يتعلّموا أسلوب البرمجة المُعمّمة generic programming؛ لأنها ستُمكِّنهم من كتابة شيفرةٍ قابلةٍ للاستخدام بالكثير من المواقف المختلفة. في حين لا يحتاج كل مبرمجٍ لكتابة مكتباتٍ برمجية، ينبغي على كل مبرمجٍ أن يعرف طريقة برمجتها على الأقل. ستحتاج عمومًا إلى معرفة بعض قواعد الصيغة syntax التي سنتعرَّض لها بهذا المقال، حتى تتمكَّن من قراءة توثيق Javadoc الخاص بأصناف جافا القياسية المُعمَّمة. لن نتعرَّض بالتأكيد لكل التفاصيل الدقيقة الخاصة بالبرمجة المُعمَمة ضمن لغة جافا بهذا المقال، إنما سنُوضِح ما يكفي لفهم أغلب الحالات الشائعة. أصناف معممة بسيطة لنبدأ بمثالٍ بسيطٍ لتوضيح أهمية البرمجة المُعمَّمة. ذَكَرنا في مقال القوائم lists والأطقم sets في جافا أنه من الأسهل تنفيذ الرتل queue باستخدام قائمةٍ مترابطةٍ من النوع LinkedList. وللتأكُّد من أننا سنُطبِق فقط إحدى عمليات الأرتال، مثل enqueue و dequeue و isEmpty على القائمة، يُمكِننا إنشاء صنفٍ جديدٍ وإضافة مُتغيِّر نسخة خاص private instance variable إليه، يُمثِّل القائمة المترابطة. تُعرِّف الشيفرة التالية مثلًا صنفًا لتمثيل رتلٍ من السلاسل النصية من النوع String: class QueueOfStrings { private LinkedList<String> items = new LinkedList<>(); public void enqueue(String item) { items.addLast(item); } public String dequeue() { return items.removeFirst(); } public boolean isEmpty() { return (items.size() == 0); } } يُعدّ الصنف المُعرَّف بالأعلى مفيدًا بالتأكيد، ولكن إذا كانت تلك هي الطريقة التي يُمكِننا بها كتابة أصناف رتل، فماذا لو أردنا رتلًا من النوع Integer، أو من النوع Double، أو من النوع Color، أو من أي نوعٍ آخر، فهل يَعنِي ذلك أننا سنضطّر لكتابة صنفٍ مختلفٍ لكل نوع؟ لن يَكون لذلك في الواقع أي معنىً بما أن شيفرة كل تلك الأصناف ستكون نفسها تقريبًا، ويُمكِننا لحسن الحظ أن نتجنَّب ذلك بكتابة صنفٍ مُعمّمٍ يُمثِّل أرتالًا من أي نوعٍ من الكائنات. توضِح الشيفرة التالية الصياغة المُستخدَمة لكتابة صنفٍ مُعمَّمٍ generic، وهي في الواقع بسيطةٌ للغاية؛ حيث ينبغي فقط استبدال معامل نوع type parameter، مثل T بالنوع String، كما ينبغي بالطبع إضافة معامل النوع إلى اسم الصنف على النحو التالي: class Queue<T> { private LinkedList<T> items = new LinkedList<>(); public void enqueue(T item) { items.addLast(item); } public T dequeue() { return items.removeFirst(); } public boolean isEmpty() { return (items.size() == 0); } } لاحظ أننا نُشير إلى معامل النوع T ضمن تعريف الصنف كما لو كان اسم نوعٍ عادي؛ حيث اسُتخدِم للتصريح عن النوع المُعاد من التابع dequeue، وتخصيص نوع المُعامِل الصوري formal parameter item الذي يَستقبِله التابع enqueue، واسُتخدم كذلك مثل معامل نوع type parameter فعليّ باسم النوع LinkedList<T>. والآن، بعد أن عرَّفنا صنفًا ذا معاملاتٍ غير مُحدَّدة النوع parameterized types، يُمكِننا استخدام أسماء أصنافٍ، مثل Queue<String>، أو Queue<Integer>، أو Queue<Color> بنفس الكيفية التي نَستخدِم بها الأصناف المُعمَّمة المبنية مُسبقًا built-in، مثل LinkedList و HashSet. يُمكِنك استخدام أي اسمٍ وليس بالضرورة T لمعامل النوع type parameter أثناء تعريف الصنف المُعمَّم، كما تفعل مع المعاملات الصورية formal parameters، وهو ما نُعرِِّفه أثناء كتابة التابع لنتمكَّن من الإشارة إليه بالتابع؛ أما المعامل الفعلي actual parameter فهو القيمة الفعلية الممررة للتابع بتعريفات البرامج الفرعية subroutines. عندما تَستخدِم ذلك الصنف للتصريح عن مُتغيّرٍٍ، أو إنشاء كائنٍ object، فسيَحلّ اسم النوع الفعليّ الذي تُخصِّصه محلّ الاسم الموجود تعريف definition الصنف. إذا كنت تُفضِّل أن يكون اسم معامل النوع type parameter ذا معنىً، يُمكِنك أن تُعرِّف الصنف Queue على النحو التالي مثلًا: class Queue<ItemType> { private LinkedList<ItemType> items = new LinkedList<>(); public void enqueue(ItemType item) { items.addLast(item); } public ItemType dequeue() { return items.removeFirst(); } public boolean isEmpty() { return (items.size() == 0); } } لا يؤثر اختيارك للاسم "T"، أو للاسم "ItemType" على تعريف الصنف Queue أو على الطريقة التي يَعمَل بها نهائيًا. يُمكِنك تعريف الواجهات المُعمَّمة generic interfaces بنفس الكيفية، وتستطيع كذلك تعريف أصنافٍ أو واجهاتٍ interfaces مُعمَّمة بمُعاملي نوعٍ أو أكثر، مثل الواجهة القياسية Map<K,V>، بنفس الكيفية والسهولة. يتَضمَّن تعريف الصنف Pair بالشيفرة التالية على سبيل المثال كائنين objects من أي نوع: class Pair<T,S> { public T first; public S second; public Pair( T a, S b ) { // بانٍ first = a; second = b; } } والآن، تَستطيع التصريح عن مُتغيِّراتٍ من النوع Pair المُعرَّف بالأعلى، أو إنشاء كائناتٍ منه على النحو التالي: Pair<String,Color> colorName = new Pair<>("Red", Color.RED); Pair<Double,Double> coordinates = new Pair<>(17.3,42.8); ملاحظة: اِستخدَمنا الاسم "Pair" بتعريف الباني constructor بالأعلى دون تخصيص معاملات نوع type parameters. ربما توقَّعت أن نَستخدِم شيئًا، مثل "Pair"، ولكن في الحقيقة، اسم الصنف هو "Pair" وليس "Pair"؛ فليس الاسمان "T" و"S" ضمن تعريف الصنف أكثر من مجرد أسماءٍ تُشير للأنواع الفعلية المُخصَّصة. في العموم، لا تُخصَّص أبدًا معاملات الأنواع type parameters بأسماء التوابع methods والبواني constructors، وإنما فقط بأسماء الأصناف والواجهات. ,s>,s> توابع معممة بسيطة تُوفِّر جافا أيضًا ما يُعرَف باسم التوابع المُعمَّمة generic methods، ويُعدّ التابع Collections.sort() مثالًا عليها؛ حيث يُمكِنه أن يُرتِّب تجميعات كائناتٍ من أي نوع. لنَكْتُب الآن تابًعا غير مُعمَّم non-generic يَستقبِل سلسلةً نصيةً، ثم يَعُدّ عدد مرات حدوث تلك السلسلة ضمن مصفوفة من السلاسل النصية. ألقِ نظرةً على الشيفرة التالية: // 1 public static int countOccurrences(String[] list, String itemToCount) { int count = 0; if (itemToCount == null) { for ( String listItem : list ) if (listItem == null) count++; } else { for ( String listItem : list ) if (itemToCount.equals(listItem)) count++; } return count; } [1] يُعيد عدد مرات حدوث itemToCount بالقائمة. يُستخدَم التابع itemToCount.equals() لاختبار فيما إذا كان كل عنصرٍ ضمن القائمة يُساوِي itemToCount، إلّا إذا كان itemToCount فارغًا. أصبح لدينا الآن شيفرةٌ تَعمَل فقط مع النوع String، ويُمكِننا أن نتخيل كتابة نفس الشيفرة تقريبًا مرةً بعد أخرى إذا أردنا اِستخدَامها مع أنواعٍ أخرى من الكائنات. وفي المقابل، إذا كتبنا التابع بصيغةٍ مُعمَّمة، فيُمكِننا أن نُعرِّفه مرةً واحدةً فقط، ونَستخدِمه مع كائناتٍ منتميةٍ لأي نوع، حيث سنستبدل فقط اسم معامل النوع، وليكن "T"، باسم النوع String ضمن تعريف التابع. إذا اكتفينا بهذا التغيير، سيَظّن المُصرِّف أن الاسم "T" يُمثِل اسم النوع الفعليّ، ولهذا يجب أن نخبره بأن 'T' هي معامل نوعٍ type parameter لا أكثر. بالنسبة للأصناف المُعمَّمة، كنا قد أضفنا '' بتعريفه، مثل class Queue<T> { ...؛ أما بالنسبة للتوابع المُعمَّمة generic methods، فينبغي إضافة "" قبل اسم النوع المُعاد return type من التابع على النحو التالي: public static <T> int countOccurrences(T[] list, T itemToCount) { int count = 0; if (itemToCount == null) { for ( T listItem : list ) if (listItem == null) count++; } else { for ( T listItem : list ) if (itemToCount.equals(listItem)) count++; } return count; } يُشير '' إلى كون التابع مُعمَّمًا generic، كما يُخصِّص اسم معامل النوع type parameter الذي سنَستخدِمه داخل التعريف definition. يُمكِنك بالطبع أن تختار أي اسمٍ آخر غير "T" اسمًا لمعامل النوع. قد يبدو موضع "" غريبًا بعض الشيء، ولكن في جميع الأحوال، كان من الضروري وضعه بموضعٍ ما، وكان هذا الموضع هو الذي اتفق عليه مُصمِّمي لغة جافا. بعد أن عرَّفنا التابع المُعمَّم بالأعلى، يُمكِننا الآن تطبيقه على أي كائناتٍ من أي نوع. إذا كان wordList مُتغيرًا من النوع String[] مثلًا، وكان word مُتغيِّرًا من النوع String، فسيَعُدّ الاستدعاء التالي عدد مرات حدوث word بالمصفوفة wordList: int ct = countOccurrences( wordList, word ); وبالمثل، إذا كان palette مُتغيِّرًا من النوع Color[]، وكان color مُتغيِّرًا من النوع Color، فسيَعُدّ الاستدعاء التالي عدد مرات حدوث color بالمصفوفة palette: int ct = countOccurrences( palette, color ); وبالمثل أيضًا، إذا كان numbers مُتغيِّرًا من النوع Integer[]، فسيَعُدّ الاستدعاء التالي عدد مرات حدوث العدد 17 بالمصفوفة numbers: int ct = countOccurrences( numbers, 17 ); يَستخدِم المثال الأخير خاصية التغليف التلقائي autoboxing؛ أي يُحوِّل الحاسوب العدد 17 تلقائيًا إلى قيمةٍ من النوع Integer. يُمكِن أن يمتلك تابعٌ مُعمَّمٌ generic method معامل نوعٍ واحدٍ أو أكثر، مثل T بالتابع countOccurrences. عندما نَستدعِي تابعًا مُعمَّمًا، مثل countOccurrences(wordlist, word)، فإننا لا نذكُر صراحةً النوع الفعليّ الذي استُبدل بمعامل النوع؛ حيث يَستنتجه المُصرِّف من نوع المُعاملات الفعليّة المُمرَّرة بتعليمة الاستدعاء. يُدرِك المُصرِّف تلقائيًا في تعليمة الاستدعاء countOccurrences(wordlist, word) مثلًا أن عليه استبدال النوع String بمعامل النوع T ضمن التعريف؛ لأن wordlist من النوع String[]. يختلف ذلك عن الأصناف المُعمَّمة generic classes، مثل Queue<String>، حيث ينبغي أن نُخصِّص اسم معامل النوع المطلوب صراحةً. يُعالِج التابع countOccurrences مصفوفةً؛ وبالتالي يُمكِننا كتابة تابعٍ مشابهٍ لعدّ عدد مرات حدوث كائنٍ ضمن أي نوعٍ من التجميعات على النحو التالي: public static <T> int countOccurrences(Collection<T> collection, T itemToCount) { int count = 0; if (itemToCount == null) { for ( T item : collection ) if (item == null) count++; } else { for ( T item : collection ) if (itemToCount.equals(item)) count++; } return count; } بما أن Collection<T> مُعمَّم النوع فإن التابع المذكور بالأعلى مُصمّمٌ ليكون عامًا جدًا؛ فيُمكِنه مثلًا العمل مع قائمةٍ من النوع ArrayList تتضمِّن كائناتٍ من النوع Integer؛ أو مع طقمٍ من النوع TreeSet يتضمِّن كائناتٍ من النوع String؛ أو مع قائمةٍ مترابطةٍ من النوع LinkedList تتضمِّن كائناتٍ من النوع Button. أنواع البدل Wildcard Types في الحقيقة، هناك نوعٌ من التقييد بنوعية الأصناف والتوابع المُعمَّمة التي تعاملنا معها حتى الآن؛ حيث يُمكِن لأي معامل نوع type parameter، أي ما نُسميه T عادةً، أن ينتمي لأي نوعٍ على الإطلاق، وهو أمرٌ جيدٌ في أغلب الأحوال، ولكنه يَعنِي في نفس الوقت أن الأشياء التي يُمكِننا أن نُطبِّقها على T هي فقط تلك الأشياء التي بإمكاننا أن نُجرِيها على كل الأنواع؛ وأن الأشياء التي يُمكِننا أن نُطبِقها على كائناتٍ من النوع T هي فقط تلك الأشياء التي بإمكاننا أن نُجرِيها على كل الكائنات؛ بمعنى أنه لا يُمكِنك مثلًا كتابة تابعٍ مُعمَّمٍ يُوازن بين الكائنات باستخدام التابع compareTo()، لأنه غير مُعرَّفٍ بكل الكائنات، وإنما بالواجهة Comparable. بناءً على ذلك، نحتاج أن نُوضِح للحاسوب بطريقةٍ ما أنه يمكن تطبيق صَنْفٍ أو تابعٍ مُعمَّمٍ معين فقط على كائنٍ من النوع Comparable وليس على أي كائنٍ عشوائي، وهو ما سيُمكِّننا من استخدام تابعٍ، مثل compareTo() بتعريف صنفٍ أو تابعٍ مُعمَّم. تُوفِّر جافا قاعدتي صياغة syntax لتخصيص هذا النوع من التقييد بالأنواع المُستخدَمة للبرمجة المُعمَّمة generic programming. تعتمد الطريقة الأولى على ما يُعرَف باسم معاملات الأنواع المُقيَّدة bounded، والتي تُستخدَم مثل معاملات نوعٍ صورية formal ضمن تعريفات الأصناف والتوابع المُعمَّمة؛ حيث يَحِلّ معامل النوع المُقيَّد bounded محل معامل النوع البسيط، مثل T، عند تعرّيف صنفٍ، مثل class GenericClass<T> ...، أو تعريف تابعٍ، مثل public static <T> void genericMethod(.... تعتمد الطريقة الثانية على ما يُعرَف باسم أنواع البدل wildcard types، والتي تُستخدَم مثل معاملات نوع أثناء التصريح عن كُلٍ من المُتْغيِّرات والمعاملات الصُوريّة formal parameters ضمن تعريفات التوابع؛ حيث يَحِلّ نوع بدلٍ محلّ معامل نوعٍ، مثل String بتعليمة تصريح، مثل List<String> list;، أو بقائمة معاملات صوريّة، مثل void concat(Collection<String> c). سنناقش أولًا أنواع البدل wildcard types، ثم سننتقل إلى مناقشة الأنواع المُقيَّدة bounded types لاحقًا. سنبدأ بمثالٍ بسيط يُوضِح الغرض من أنواع البدل. لنفترض أن الصنف Shape يُعرِّف التابع public void draw()، وأن الصنفين Rect و Oval مُشتقان منه، وأننا نريد كتابة تابع ليَرسِم جميع الأشكال المُخزَّنة ضمن تجميعة كائناتٍ من النوع Shape. يُمكِننا تعريف التابع على النحو التالي: public static void drawAll(Collection<Shape> shapes) { for ( Shape s : shapes ) s.draw(); } سيَعمَل التابع المُعرَّف بالأعلى جيدًا إذا طبقناه على مُتغيّرٍ من النوع Collection<Shape>، أو من النوع ArrayList<Shape>، أو أي صنف تجميعةٍ collection آخر بمعامل نوع Shape. والآن، لنفترض أن لدينا قائمة list كائناتٍ من النوع Rect مُخزَّنةً بمُتغيّرٍ اسمه rectangles من النوع Collection<Rect>. نظرًا لأن كائنات الصنف Rect هي بالنهاية من النوع Shape، ربما تظن أن بإمكانك إجراء الاستدعاء drawAll(rectangles)، ولكن هذا للأسف غير صحيح؛ حيث تختلق تجميعة كائناتٍ من النوع Rect عن تجميعة كائناتٍ من النوع Shape، وبالتالي لا يُمكِنك اسنادد المُتغيِّر rectangles للمعامل الصوري shapes. يُمكِننا تجاوز تلك المشكلة بتعديل معامل النوع المُستخدَم بالتصريح عن shapes من النوع Shape إلى نوع البدل ? extends Shape على النحو التالي: public static void drawAll(Collection<? extends Shape> shapes) { for ( Shape s : shapes ) s.draw(); } يَعنِي نوع البدل ? extends Shape النوع Shape أو الأنواع الفرعية subclass منه. إذا صرَّحنا عن المعامل الصوري shapes على أنه من النوع Collection<? extends Shape>، يُمكِننا عندها أن نُمرِّر مُعاملًا فعليًا من النوع type Collection<Rect> للتابع drawAll؛ لأن Rect صنفٌ مُشتَقٌ من الصنف Shape، أي أنه متوافقٌ مع نوع البدل wildcard type المطلوب. بإمكاننا أيضًا تمرير معاملاتٍ فعلية من النوع ArrayList<Rect>، أو Set<Oval>، أو List<Oval>، وما يزال أيضًا بإمكاننا تمرير مُتغيِّراتٍ من النوع Collection<Shape> و ArrayList<Shape>؛ لأن الصنف Shape متوافقٌ مع ? extends Shape. بفضل أنواع البدل wildcard type، نكون قد استفدنا من التابع المُعرَّف بأقصى حدٍ ممكن. على الرغم من أن كائنات الصنف Rect هي بالنهاية من النوع Shape، فإنه من غير الممكن -كما ذكرنا للتو- استخدام تجميعة كائناتٍ من النوع Rect كما لو كانت تجميعة كائناتٍ من النوع Shape. قد يثير فضولك معرفة السبب الذي دفَع جافا لمنع ذلك. لنفحص المثال التالي عن تابعٍ يُضيِف كائنًا من النوع Oval إلى قائمة كائناتٍ من النوع Shape: static void addOval(List<Shape> shapes, Oval oval) { shapes.add(oval); } إذا كانت rectangles من النوع List<Rect>، فإننا لا نستطيع استدعاء addOval(rectangles,oval)؛ نتيجةً للقاعدة التي تنص على عدم عدّ قائمةٍ من النوع Rect قائمةً من النوع Shape. إذا أسقطنا تلك القاعدة، وتمكَّنا من استدعاء addOval(rectangles,oval)؛ نكون قد أضفنا كائنًا من النوع Oval إلى قائمة كائناتٍ من النوع Rect، وهو أمرٌ سيءٌ للغاية. بما أن الصنف Oval غير مُشتقَّ من Rect، لا تُعدّ كائنات الصنف Oval من النوع Rect، وعليه لا ينبغي أبدًا لقائمة كائناتٍ من النوع Rect أن تتضمَّن كائنًا من النوع Oval؛ ولهذا لا يكون للاستدعاء addOval(rectangles,oval) أيّ معنى، ولا ينبغي السماح به. لنناقش الآن مثالًا آخر، بحيث تتضمَّن الواجهة Collection<T> التابع addAll()، الذي كنا قد وصفناه بمقال مفهوم البرمجة المعممة Generic Programming، وذَكَرَنا أنه من أجل تجميعةٍ coll من النوع Collection<T>، سيضيف الاستدعاء coll.addAll(coll2) جميع كائنات التجميعة coll2 إلى coll. يُمكِن أن يُمثِّل المعامل coll2 أي تجميعةٍ من النوع Collection<T>، ولكنه قد يَكون أعمُ من ذلك. إذا كان T صنفًا على سبيل المثال، وكان S صنفًا فرعيًا subclass من T، فيُمكِن للمتغيّرcoll2 أن يكون من النوع Collection<S>، وهو أمرٌ منطقي؛ لأن أي كائنٍ من النوع S هو بالضرورة كائنٌ من النوع T، ويُمكِن إضافته إلى coll. إذا فكرت للحظة، ستَجِد أن هذا الوصف يُعدّ بطريقةٍ أو بأخرى اِستخدَامًا لأنواع البدل wildcard types؛ فنحن لا نريد للمُتغيِّر coll2 أن يكون تجميعة كائناتٍ من النوع T، وإنما نُريد أن نَسمَح له بأن يكون أي تجميعة كائنات، طالما كانت تنتمي لصَنْفٍ مُشتَقٍ من T. لنرى كيف يُمكِننا إضافة التابع addAll() إلى الصنف المُعمَّم Queue الذي عرَّفناه ببداية هذا المقال، حتى تُصبِح الأمور أكثر وضوحًا: class Queue<T> { private LinkedList<T> items = new LinkedList<T>(); public void enqueue(T item) { items.addLast(item); } public T dequeue() { return items.removeFirst(); } public boolean isEmpty() { return (items.size() == 0); } public void addAll(Collection<? extends T> collection) { // أضِف جميع عناصر التجميعة إلى نهاية الرتل for ( T item : collection ) enqueue(item); } } يُعدّ T بهذا المثال معامل نوعٍ type parameter ضِمْن تعريف صنفٍ مُعمَّم، ويُمكِنك أن ترى أننا اِستخدَمنا نوع بدل wildcard class داخل الصنف المُعمَّم generic class. تُستَخدَم T داخل التعريف كما لو كانت نوعًا مُخصَّصًا، ولكنه غير معروف بعد. يَعنِي نوع البدل ? extends T أي نوعٍ يُساوِي النوع المُخصَّص T أو يتوسَّع extend منه، وبالتالي، عندما نُنشِئ رتلًا queue من النوع Queue<Shape>، ستُشير T إلى Shape؛ في حين سيُشير نوع البدل ? extends T ضمن تعريف الصنف إلى ? extends Shape. يضمَن لنا ذلك إمكانية تطبيق التابع addAll على تجميعة كائناتٍ من النوع Rect، أو Oval بالإضافة إلى Shape بالتأكيد. تَستخدِم حلقة التكرار for-each الموجودة بتعريف التابع addAll المُتغيّر item من النوع T للمرور عبر التجميعة collection، إذ يُمكِن للتجميعة الآن أن تكون من النوع Collection<S>، حيث S صنفٌ مُشتَقٌ من T. نظرًا لأن المُتغيّر item من النوع T، وليس النوع S، هل يُشكِّل ذلك مشكلة؟ لا؛ فطالما كان S صنفًا فرعيًا من T، فإننا نستطيع اسناد أي قيمةٍ من النوع S إلى مُتغيِّر من النوع T؛ حيث يضمَن نوع البدل wildcard type المُستخدَم أن يَعمَل كل شيءٍ على النحو الصحيح. يُضيف التابع addAll جميع العناصر الموجودة ضمن تجميعةٍ معينة إلى رتل. لنفترض الآن أننا نريد إجراء تلك العملية بصورةٍ معكوسة؛ أي أن نُضيف جميع العناصر الموجودة ضمن رتلٍ إلى تجميعةٍ معينة. إذا عرَّفنا تابع نسخة instance method على النحو التالي، فسيَعمَل فقط مع التجميعات التي يساوي نوعها الأساسي base type النوع T تحديدًا: public void addAllTo(Collection<T> collection) وهذا أمرٌ مُقيِّدٌ للغاية، لذلك ربما من الأفضل استخدام نوع بدل wildcard type، حيث تَستخِدم الشيفرة التالية نوع البدل ? extends T، ولكنه لن يَعمل أيضًا: public void addAllTo(Collection<? extends T> collection) { // احذف جميع العناصر الموجودة حاليًا بالرتل، وأضِفها إلى التجميعة while ( ! isEmpty() ) { T item = dequeue(); // احذف عنصرًا من الرتل collection.add( item ); // أضفه إلى التجميعة، غير صالح ! } } تَكْمُن المشكلة في عدم التمكُّن من إضافة عنصرٍ من النوع T إلى تجميعةٍ قد يَكون بإمكانها حَمْل عناصرٍ تنتمي فقط إلى صنفٍ فرعي subclass من T، مثل S، حيث من الضروري لعنصرٍ من النوع T أن يكون من النوع S. لنفترض مثلًا أن لدينا رتلًا queue من النوع Queue<Shape>، فلن يكون لعملية إضافة عناصر ذلك الرتل إلى تجميعةٍ من النوع Collection<Rect> أيُّ معنى؛ حيث لا تنتمي كل كائنات النوع Shape إلى الصنف Rect بالضرورة. في المقابل، إذا كان لدينا رتلًا من النوع Queue<Rect>، تُصبِح عملية إضافة عناصر الرتل إلى تجميعةٍ من النوع Collection<Shape>، أو حتى من النوع Collection<S> ذات معنى، حيث S صنفٌ أعلى superclass من Rect. سنحتاج إذًا إلى نوعٍ آخرٍ من أنواع البدل للتعبير عن تلك العلاقة، وهو ? super T؛ الذي يَعنِي T ذاته أو أي صنفٍ أعلى superclass من T. على سبيل المثال، يَتماشَى النوع Collection<? super Rect> مع أنواعٍ، مثل Collection<Shape> و ArrayList<Object> و Set<Rect>. في الواقع، يُمثِل نوع البدل ذاك ما نحتاجه تمامًا بالتابع addAllTo، وبإجراء هذا التغيير، سيُصبِح الصنف المُعمَّم Queue جاهزًا على النحو التالي: class Queue<T> { private LinkedList<T> items = new LinkedList<T>(); public void enqueue(T item) { items.addLast(item); } public T dequeue() { return items.removeFirst(); } public boolean isEmpty() { return (items.size() == 0); } public void addAll(Collection<? extends T> collection) { // أضِف جميع عناصر التجميعة إلى نهاية الرتل for ( T item : collection ) enqueue(item); } public void addAllTo(Collection<? super T> collection) { // احذف جميع العناصر الموجودة حاليًا بالرتل، وضِفها إلى التجميعة while ( ! isEmpty() ) { T item = dequeue(); // اِحذف عنصر من الرتل collection.add( item ); // أضف العنصر إلى التجميعة } } } قد يشير T باسم نوع بدلٍ wildcard type معينٍ، مثل ? extends T، إلى واجهةٍ interface بدلًا من صنفٍ. لاحِظ أننا سنستمر باستخدام كلمة "extends" وليس "implements" باسم نوع البدل حتى لو كان T واجهةً بالأساس. لنفحص مثالًا على ذلك: كنا قد تعرَّضنا للواجهة Runnable، والتي تُعرِّف التابع public void run(). يُمكِننا إذًا أن نُطبِق التابع method التالي على جميع تجميعة كائناتٍ من النوع Runnable باستدعاء التابع run() المُعرَّف بكل كائنٍ منها: public static runAll( Collection<? extends Runnable> runnables ) { for ( Runnable runnable : runnables ) { runnable.run(); } } تُستخدَم أنواع البدل Wildcard types فقط بمثابة معاملات أنواع type parameters بالأنواع ذات المعاملات غير مُحدَّدة النوع parameterized types، مثل Collection<? extends Runnable>، وستَجدِها غالبًا ضمن قوائم المعاملات الصوريّة؛ حيث تُستخدَم للتصريح declaration عن نوع معاملٍ صوريٍ formal parameter معين. كما ستَجِدها مُستخدَمةٌ أيضًا بعدة أماكنٍ أخرى، مثل تعليمات التصريح عن المتغيرات variable declaration. ملاحظة أخيرة: يُعدّ <?> نوع بدلٍ مكافئٍ تمامًا لنوع البدل <? extends Object>، ويُقصَد منه مطابقة أي نوعٍ مُحتمَل. تُصرِّح الواجهة المُعمَّمة Collection<T> على سبيل المثال عن التابع removeAll على النحو التالي: public boolean removeAll( Collection<?> c ) { ... والذي يَعني أن التابع removeAll يُمكِن تطبيقه على أي تجميعة كائناتٍ تنتمي بدورها لأي نوع. الأنواع المقيدة Bounded Types لا تحلّ أنواع البدل wildcard types جميع مشاكلنا، وإنما تَسمَح لنا بتعميم تعريفات التوابع؛ بحيث نتمكّن من اِستخدَامها مع تجميعات كائناتٍ من أنواعٍ مختلفةٍ، بدلًا من تقييدها بنوعٍ واحدٍ فقط؛ بينما لا تَسمَح لنا بتقييد أنواع معاملات الأنواع type parameters المسموح بها ضمن تعريفات الأصناف والتوابع المُعمَّمة generic، وهذا هو الغرض من وجود الأنواع المُقيَّدة bounded types. لنبدأ بمثالٍ صغير وإن كان غير واقعيٍ بعض الشيء. بفرض أننا نريد إنشاء مجموعةٍ من المكوِّنات لواجهة مُستخدِم رسومية GUI باستخدام صنفٍ مُعمّم اسمه ControlGroup؛ فيُمثِل النوع ControlGroup<Button> مثلًا مجموعةً من الأزرار؛ بينما يُمثِل ControlGroup<Slider> مجموعةً من المزالج. سيتضمَّن الصنف توابعًا لتطبيق بعض العمليات المُحدَّدة على جميع مُكوِّنات المجموعة بنفس الوقت. فمثلًا، قد نُعرِّف تابع النسخة instance method التالي: public void disableAll() { . . // 1 . } حيث تعني [1]: اِستدعِ c.setDisable(true) لكل مكوِّن c ضمن المجموعة. توجد مشكلةٌ في أن التابع setDisable() مُعرَّفٌ فقط للكائنات من النوع Control، ولا يَعمل لجميع أنواع الكائنات؛ أي لا يُعدّ السماح بوجود أنواع، مثل ControlGroup<String> و ControlGroup<Integer> أمرًا منطقيًا؛ لعدم تضمُّن السلاسل النصية من النوع String والأعداد الصحيحة من النوع Integer للتابع setDisable(). نحتاج إذًا إلى طريقةٍ لتقييد معامل النوع T بالنوع ControlGroup<T>، بحيث نَسمَح لقيم المعاملات الفعلية actual parameters بالانتماء فقط إما إلى الصنف Control أو إلى الأصناف الفرعية subclasses المُشتقَّة منه، وهو ما يُمكِننا تطبيقه باستخدام النوع المُقيَّد T extends Control بدلًا من النوع T ضمن تعريف الصنف على النحو التالي: public class ControlGroup<T extends Control> { private ArrayList<T> components; // من أجل ترتيب المكونات في هذه المجموعة public void disableAll( ) { for ( Control c : components ) if (c != null) c.setDisable(true); } } public void enableAll( ) { for ( Control c : components ) if (c != null) c.setDisable(false); } } public void add( T c ) { // أضِف قيمة c من النوع T إلى المجموعة components.add(c); } . . // توابع وبناء إضافية . } يمنعنا التقييد extends Control الذي فرضناه على معامل النوع T من إنشاء أنواعٍ ذات مُعاملات غير مُحدَّدة النوع، مثل ControlGroup<String> و ControlGroup<Integer>؛ لأنه من الضروري لمعامل النوع type parameter الفعلي الذي سيحلّ محل T أن ينتمي للنوع Control نفسه أو إلى صنفٍ فرعي منه. باِستخدامنا لهذا التقييد، أصبح المُصرِّف على علمٍ بأن مُكوِّنات المجموعة تنتمي إلى النوع Control، وُتصبِح العملية c.setDisable() مُعرَّفةً لأي مُكوِّنٍ c ضمن المجموعة. عندما نَستخدِم معامل نوعٍ مقُيَّد، مثل T extends SomeType، فإننا نَعنِي في العموم النوع T الذي إما أن يُساوِي SomeType، أو يُساوِي صنفًا فرعيًا من SomeType، ويترتَّب على ذلك عدُّ أي كائنٍ من النوع T من النوع SomeType أيضًا، وتُصبِح أي عمليةٍ مُعرَّفةٍ لكائنات النوع SomeType مُعرَّفةً أيضًا لكائنات النوع T. لا ينبغي أن يكون SomeType اسمًا لصنفٍ بالضرورة؛ حيث يُمكِن أن يكون أي اسمٍ يُمثِل النوع الفعليّ للكائن، فقد يَكون واجهة interface مثلًا، أو حتى نوعًا ذا مُعاملاتٍ غير مُحدَّدة النوع parameterized type. في حين تتشابه الأنواع المُقيَّدة bounded types مع أنواع البدل wildcard types، فإنها تُستخدَم بطرائقٍ مختلفة؛ حيث يُستخدَم النوع المُقيَّد عادةً مثل معامل نوعٍ صوري formal type parameter بتعريفٍ مُعمَّم generic لتابعٍ، أو صنفٍ، أو واجهة؛ بينما يُستخدَم نوع البدل wildcard type كثيرًا للتصريح عن نوع معاملٍ صوريٍ معينٍ ضمن تابع، ولا يُمكِن أن يُستخدَم مثل معامل نوعٍ صوري. إضافةً إلى ذلك، لا يُمكِن لمعاملات الأنواع المُقيَّدة bounded type parameters استخدام "super" نهائيًا، وإنما تَستخدِم "extends" فقط بخلاف أنواع البدل wildcard types. تُستخدَم معاملات الأنواع المُقيَّدة أثناء التصريح عن التوابع المُعمَّمة. على سبيل المثال، بدلًا من استخدام الصنف المُعمَّم ControlGroup، يُمكِننا كتابة التابع الساكن static method المُعمّم التالي لتعطيل أي تجميعة كائناتٍ من النوع Control: public static <T extends Control> void disableAll(Collection<T> comps) { for ( Control c : comps ) if (c != null) c.setDisable(true); } نتيجةً لاستخدامنا معامل النوع الصوري <T extends Control>، لا يُمكِننا الآن استدعاء التابع إلا مع تجميعةٍ نوعها الأساسي base type يُساوِي Control، أو أي صنفٍ فرعيٍ مشتق منه، مثل Button أو Slider. لاحِظ أننا لا نحتاج بالضرورة إلى معامل نوعٍ مُعمَّم generic type parameter بتلك الحالة، حيث نستطيع أيضًا كتابته باستخدام نوع بدل wildcard type على النحو التالي: public static void disableAll(Collection<? extends Control> comps) { for ( Control c : comps ) if (c != null) c.setDisable(true); } ربما من الأفضل في هذا الموقف استخدام نوع بدل، لأن تنفيذه أبسط، ولكن لن نتمكَّن دائمًا من إعادة كتابة تابعٍ مُعمَّمٍ بمعامل نوع مُقيَّد bounded type parameter باستخدام نوع بدل wildcard type؛ حيث يُعطِي معامل النوع المُعمَّم اسمًا، مثل T للنوع غير المعروف؛ بينما لا يُعطي نوع البدل اسمًا له. في الواقع، يُساعدنا تخصيص الاسم في الإشارة إلى النوع غير المعروف بمتن التابع الذي نُعرِّفه، وبالتالي، إذا كنا سنَشير إلى اسم النوع المُعمَّم بتعريف التابع المُعمَّم أكثر من مرة؛ أو كنا سنُشير إليه خارج قائمة المعاملات الصورية formal parameter الخاصة بالتابع، سيُصبح استخدام نوع معمَّمٍ ضروريًا؛ لأننا لن نتمكَّن من استبداله بنوع بدل wildcard type. لنفحص الآن مثالًا يَلزَم معه استخدام معامل نوع مُقيَّد bounded type parameter. كنا قد تعرَّضنا بمقال القوائم lists والأطقم sets في جافا المشار إليه سابقًا؛ لشيفرةٍ لإدخال سلسلةٍ نصيةٍ إلى قائمةٍ مُرتَّبةٍ من السلاسل النصية بحيث تَظِل القائمة مُرتَّبة. ستَجِد نفس الشيفرة فيما يلي، ولكن بهيئة تابع وبدون تعليقات: static void sortedInsert(List<String> sortedList, String newItem) { ListIterator<String> iter = sortedList.listIterator(); while (iter.hasNext()) { String item = iter.next(); if (newItem.compareTo(item) <= 0) { iter.previous(); break; } } iter.add(newItem); } يَعمل هذا التابع جيدًا مع قائمةٍ من السلاسل النصية، ولكن سيكون من الأفضل لو تمكَّنا من كتابة تابعٍ مُعمَّم generic method يُمكِن تطبيقه على قوائمٍ من أنواعٍ مختلفةٍ من الكائنات. تكمن المشكلة في افتراض الشيفرة بأن التابع compareTo() مُعرَّفٌ بالكائنات الموجودة ضمن القائمة، وبالتالي يَعمَل التابع فقط مع القوائم التي تتضمَّن كائناتٍ تُنفِّذ الواجهة Comparable، ولا يُمكِننا إذًا استخدام نوع بدل wildcard type لفرض هذا التقييد. لنفترض حتى أننا حاولنا فعل ذلك بكتابة List<? extends Comparable> بدلًا من List<String>: static void sortedInsert(List<? extends Comparable> sortedList, ???? newItem) { ListIterator<????> iter = sortedList.listIterator(); ... سنقع بمشكلةٍ على الفور، لأننا لا نملُك اسمًا للنوع غير المعروف الذي يُمثِّله نوع البدل، ونحن بنفس الوقت بحاجةٍ إلى ذلك الاسم؛ لأنه من الضروري لكُلٍ من newItem و iter أن يكونا من نفس نوع عناصر القائمة. يُمكِننا لحسن الحظ حل تلك المشكلة إذا كتبنا تابعًا مُعمَّمًا بمعامل نوع مُقيَّد bounded type parameter؛ حيث سيتوفَّر لنا اسمٌ للنوع غير المعروف بهذه الطريقة. ألقِ نظرةً على شيفرة التابع المُعمَّم: static <T extends Comparable> void sortedInsert(List<T> sortedList, T newItem) { ListIterator<T> iter = sortedList.listIterator(); while (iter.hasNext()) { T item = iter.next(); if (newItem.compareTo(item) <= 0) { iter.previous(); break; } } iter.add(newItem); } ما يزال هناك أمرٌ واحدٌ ينبغي معالجته ضمن هذا المثال، وهو أن النوع Comparable هو نوعٌ ذو معاملاتٍ غير محدَّدة النوع parameterized type، ولكننا لم نُخصِّصها بعد. من الممكن في الواقع فعل ذلك، فهو ليس خطأً، وسيكتفي المُصرِّف compiler برسالةٍ تحذيريةٍ عن استخدام نوع خام raw type. بالنسبة لهذا المثال، ينبغي للكائنات الموجودة بالقائمة أن تُنفِّذ الواجهة Comparable<T>، لأننا ننوي موازنتها مع عناصرٍ من النوع T، إذًا كل ما علينا فعله هو استخدام Comparable<T> مثل معامل نوع مُقيَّد بدلًا من Comparable على النحو التالي: static <T extends Comparable<T>> void sortedInsert(List<T> sortedList, ... ترجمة -بتصرّف- للقسم Section 5: Writing Generic Classes and Methods من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: البرمجة باستخدام إطار جافا للتجميعات JFC الأصناف المتداخلة Nested Classes في جافا بيئات البرمجة (programming environment) في جافا الكائنات (Objects) وتوابع النسخ (Instance Methods) ومتغيرات النسخ (Instance Variables) في جافا
-
سنناقش خلال هذا المقال أمثلةً برمجيةً تَستخدِم أصنافًا من إطار جافا للتجميعات Java Collection Framework؛ وهذا الإطار سهل الاستخدام بالموازنة مع ما ستواجهه من صعوبة إذا أردت برمجة بنى بيانات data structures جديدةٍ من الصفر. جداول الرموز سنبدأ بتطبيقٍ مهمٍ على الخرائط maps. فعندما يقرأ المُصرِّف الشيفرة المصدرية source code لأي برنامج، فسيواجه تعريفاتٍ لمتغيراتٍ variables، أو لبرامجٍ فرعية subroutines، أو لأصناف classes، وقد يَستخدِم البرنامج أسماء أي من تلك الأشياء لاحقًا، ولذلك يجب أن يتذكَّر المُصرِّف تعريف كل اسم؛ وبالتالي عندما يُقابِل المُصرِّف بعد ذلك اسمًا خلال البرنامج، فإنه يُطبِق تعريف definition ذلك الاسم. يُعدّ ذلك تطبيقًا مباشرًا على الخرائط من النوع Map؛ بحيث يُمثِّل كل اسمٍ مفتاحًا key ضمن الخريطة؛ في حين يُمثِّل تعريف ذلك الاسم قيمته value. عند اِستخدَام خريطةٍ لذلك الغرض، يُطلَق عليها اسم جدول الرموز symbol table. يتعامل المُصرِّف عمومًا مع أنواعٍ مختلفةٍ من الأسماء، كما أنه يُخزِّن نوعًا مختلفًا من المعلومات لكل نوعٍ من الأسماء، ولهذا تَكون القيم values المُخزَّنة بجدول الرموز symbol table معقدةً نوعًا ما، لذلك سنَستخدِم مثالًا مُبسطًا ضمن سياقٍ آخر. لنفترض أننا نريد كتابة برنامجٍ يُحصِّل قيمة التعبيرات expressions المُدْخَلة من قِبَل المُستخدِم، وأن التعبيرات قد تحتوي على مُتغيِّراتٍ إلى جانب العوامل operators والأعداد والأقواس. هذا يعني أننا سنحتاج إلى طريقةٍ ما لإسناد assign القيم للمتغيرات؛ بحيث نتمكَّن من استرجاع قيمة مُتغيِّرٍ معينٍ عندما يُشير إليه تعبيرٌ ما فيما بعد. يُمكِننا تخزين تلك البيانات بجدول رموز symbol table من النوع Map<String,Double>؛ بحيث تُمثِّل مفاتيحه أسماء المتغيرات؛ بينما تُمثِّل قِيمهُ values القيم المُسندة لتلك المتغيرات، والتي ستكون من النوع double. تذكَّر أنه لا يُمكِن استخدام نوعٍ أساسي primitive types، مثل double على أنه معامل نوعٍ type parameter، وإنما يجب استخدام صنف تغليف wrapper، مثل Double (انظر مقال مفهوم البرمجة المعممة Generic Programming). سنَستخدِم برنامجًا بسيطًا يُمكِّن المُستخدِم من كتابة أوامرٍ مشابهة لما يَلي: let x = 3 + 12 print 2 + 2 print 10*x +17 let rate = 0.06 print 1000*(1+rate) يُعد هذا البرنامج مُفسِّرًا interpreter للغةٍ بسيطةٍ جدًا، حيث يُفهم من البرنامج أمرين بسيطين، هما print و let؛ فعند تنفيذ الأمر print، يُحصِّل الحاسوب قيمة التعبير، ثم يعرض قيمته. إذا احتوى التعبير على مُتغيِّر، سيبحث الحاسوب عن قيمة ذلك المتغير بجدول الرموز symobl table؛ أم الأمر let فهو يسمح للمُستخدِم بإسناد قيمةٍ معينةٍ إلى متغير، ويجب أن يُخزِّن الحاسوب عندها قيمة المُتغيِّر بجدول الرموز. لاحِظ أن المتغيرات هنا ليست متغيراتٌ ببرنامج جافا ذاته، حيث ينفّذ البرنامج فقط ما يُمكِن عدّه برنامجًا بسيطًا أدْخله الُمستخدِم، ونقصد بالمتغيرات تلك المتغيرات الموجودة ببرنامج المُستخدِم. بما أنه يجب على المُستخدِم أن يكون قادرًا على اختيار أسماء متغيراته، فلا يمكن برنامج جافا نفسه أن يعرف مُقدمًا المُتغيِّرات التي سيُدخِلها المُستخدِم. كنا قد كتبنا البرنامج SimpleParser2.java في مقال تصميم محلل نموذجي تعاودي بسيط Recursive Descent Parser في جافا لتحصيل قيم تعبيراتٍ expressions لا تحتوي على متغيرات. سنكتب الآن البرنامج التوضيحي SimpleInterpreter.java المبني على البرنامج القديم، ولهذا لن نناقش سوى الأجزاء المُتعلِّقة بجدول الرموز symbol table. يَستخدِم البرنامج خريطةً من النوع HashMap لتمثيل جدول الرموز. من الممكن استخدام الصنف TreeMap، ولكننا في الواقع لا نحتاج إلى تخزين المُتغيِّرات على نحوٍ مرتب؛ لأن البرنامج لا يَسترجعها وفقًا لترتيبها الأبجدي. عرَّفنا مُتغيِّرًا اسمه symbolTable من النوع HashMap<String,Double> ليُمثِّل الجدول الرمزي على النحو التالي: symbolTable = new HashMap<>(); يُنشِيء الأمر السابق خريطةً فارغةً لا تحتوي على أية ارتباطات مفتاح/قيمة. ليتمكَّن البرنامج من تنفيذ الأمر let، سيَستدعِي تابع جدول الرموز put() لرَبْط قيمةٍ معينةٍ باسم مُتغيّرٍ معين. إذا كان اسم المُتغيِّر مُخزَّنًا بمُتغيّرٍ varName من النوع String مثلًا، وكانت قيمته مُخزَّنةً بمُتغيِّر val من النوع double، فسيضيف الأمر التالي الارتباط المقابل لذلك المُتغيِّر بجدول الرموز symbol table: symbolTable.put( varName, val ); ستَجِد ما سَبَق مُعرّفًا بالتابع doLetCommand() بالبرنامج SimpleInterpreter.java. لاحِظ أن القيمة المُخزَّنة فعليًا بجدول الرموز هي كائنٌ object من النوع Double، رغم أننا مرَّرنا المُتغير val من النوع double للتابع put؛ وذلك لأن جافا تُجرِي تحويلًا تلقائيًا من النوع double إلى النوع Double إذا اقتضت الضرورة. تُوفِّر جافا الثابتين Math.PI و Math.E لتمثيل الثابتين الرياضين π و e على الترتيب، ولنتيح لمُستخدِم البرنامج الإشارة إليهما، أضفنا مُتغيِّرين pi و e إلى جدول الرموز symbol table باستخدام الأوامر التالية: symbolTable.put( "pi", Math.PI ); symbolTable.put( "e", Math.E ); عندما يواجه البرنامج مُتغيرًا أثناء تحصيله لقيمة تعبيرٍ ما، فإنه يَستدعِي تابع جدول الرموز get() لاسترجاع قيمة ذلك المُتغيِّر؛ حيث تُعيد الدالة symbolTable.get(varName) قيمةً من النوع Double، ولكنها أيضًا قد تُعيد القيمة الفارغة null إذا لم تُسند من قبل أي قيمةٍ للمُتغيِّر varName بجدول الرموز. من المهم فحَص تلك الاحتمالية؛ لأنها تَعني أن المُستخدِم يُحاوِل الإشارة إلى مُتغيّرٍ لم يُعرِّفه بعد، ولا بُدّ أن يَعُدّها البرنامج خطأً error. يُمكِننا كتابة ذلك على النحو التالي: Double val = symbolTable.get(varName); : if (val == null) { ... // بلغ عن اعتراض : متغير غير مُعرَّف } // القيمة المربوطة بالمتغير varName هي ()val.doubleValue ستَجِد ما سَبَق ضمن التابع primaryValue() بالبرنامج SimpleInterpreter.java. ربما تَكون قد أدركت الآن أهمية الخرائط وسهولة استخدامها. أطقم ضمن خريطة يُمكِن للكائنات الواقعة ضمن تجميعةٍ collection، أو خريطةٍ map أن تَكون من أي نوع، ويُمكِنها أن تكون هي ذاتها تجميعة. سنفحص الآن مثالًا على تخزين أطقمٍ sets داخل خريطة. إذا أردنا مثلًا إنشاء فهرسٍ لكتاب؛ حيث يتكون الفهرس من قائمة المُصطلحات المُستخدَمة بالكتاب، ويُكتَب إلى جانب كل مُصطلحٍ قائمةً بالصفحات التي ظهر بها. بناءً على ذلك التوصيف، سنحتاج إلى بنية بياناتٍ قادرةٍ على حمل قائمة المصطلحات مرفَقَةً بقائمة الصفحات الخاصة بكل مُصطلح. من المهم أن تَكون عملية الإضافة ذات كفاءة عالية وأن تكون أيضًا سهلة؛ أم فيما يتعلَّق بطباعة الفهرس، فينبغي أن تكون عملية استرجاع المصطلحات بحسب ترتيبها الأبجدي سهلةً نسبيًا. نستطيع تنفيذ ذلك بطرائقٍ كثيرة، ولكننا سنَستخدِم هنا بنية بيانات مُعمّمة generic. يُمكِننا التفكِير بالفهرس مثلُ خريطةٍ من النوع Map تَربُط كل مصطلحٍ بقائمة مراجع الصفحات الخاصة به؛ أي سيَعمَل المصطلح بمثابة مفتاحٍ للخريطة؛ بينما ستكون قيمته قائمةً بمراجع الصفحات الخاصة بالمصطلح. من الممكن لخريطةٍ من النوع Map أن تكون من الصنف TreeMap أو HashMap، لكن سيُسهِل الصنف TreeMap استرجاع المصطلحات وفقًا للترتيب المطلوب. والآن، ينبغي أن نختار طريقة تمثيل قائمة مراجع الصفحات. إذا فكرنا قليلًا، سنَجِد أنها لا تُعدّ قائمة list بحسب مفهوم أصناف جافا المُعمَّمة، وإنما هي أقرب لأن تكون طقمًا set منها لقائمة؛ فلا يُمكِن لقائمة مراجع الصفحات أن تحتوي على عناصرٍ مُكرَّرة مثلًا؛ كما تُطبَع قوائم مراجع الصحفات دائمًا مُرتَّبة ترتيبًا تصاعديًا، ولهذا فإن استخدام طقمٍ مُرتَّب sorted set هو الحل الأمثل. سنَستخدِم إذًا طقمًا من النوع TreeSet لتمثيل كل قائمة صفحات، وستَكون القيم المُخزَّنة ضمن الطقم من النوع int. وكما ذكرنا مُسبقًا، لا يُمكِن لبنى البيانات المُعمَّمة generic data structures أن تَحمِل شيئًا ليس بكائن object، ولهذا يجب أن نَستخدِم صنف التغليف Integer. تلخيصًا لما سَبَق، سنَستخدِم خريطةً من النوع TreeMap لتمثيل الفهرس، بحيث تَعمَل أسماء المصطلحات بمثابة مفاتيح keys من النوع String، وتكون القيم values أطقمًا من النوع TreeSet تحتوي على أعدادٍ صحيحةٍ مُمثِلةٍ لأرقام الصفحات؛ أي ستكون الأطقم من النوع TreeSet<Integer>، وستَكون الخريطة المُمثِلة لكل لفهرس (مفاتيح من النوع String وقيم من النوع TreeSet<Integer>) من النوع: TreeMap< String, TreeSet<Integer> > يُعدّ هذا النوع مثالًا عاديًا على النوع TreeMap<K,V>، حيث K=String و V=TreeSet<Integer>. قد تبدو الأسماء المُركَّبة للأنواع (كما في المثال السابق) مُربِكةً نوعًا ما، ولكن إذا فكرت ببنية البيانات المطلوبة، ستَجِد أن الاسم منطقي تمامًا. سنبدأ لإنشاء الفهرس بخريطةٍ map فارغةٍ من النوع TreeMap، ثم سنَمُرّ عبر الكتاب بحيث نُضيف إلى الخريطة أي مَرجعٍ نريده ضمن الفهرس، وسنطبع أخيرًا بيانات الخريطة. سنؤجل حديثنا عن طريقة العثور على المراجع المطلوب إضافتها، ونُركِّز أولًا على الكيفية التي سنَستخدِم بها خريطةً من النوع TreeMap. سنُنشِئ بدايةً الخريطة باستخدام الأوامر التالية: TreeMap<String,TreeSet<Integer>> index; // صرِّح عن المتغير index = new TreeMap<>(); // أنشِئ كائن الخريطة ملاحظة: يُمكِنك حذف معاملات الأنواع من الباني حتى لو كانت مُركَّبة. لنفترض الآن أننا قد وجدنا إشارةً لمصطلحٍ ما (من النوع String) بصفحةٍ ما pageNum (من النوع int)، وأننا نحتاج الآن إلى إضافتها إلى الفهرس. ينبغي إذًا استدعاء index.get(term) لنسترجِع قائمة مراجع الصفحات الخاصة بذلك المصطلح؛ حيث سيُعيد التابع القيمة الفارغة null، أو طقمًا يحتوي على مراجع الصفحات التي أضفناها مُسبقًا. إذا كانت القيمة المعادة تُساوِي null، فإن هذا المرجع هو الأول لذلك المصطلح، ولهذا ينبغي أن نُضيِف المصطلح للفهرس مصحوبًا بطقمٍ يحتوي على مرجع الصفحة الذي وجدناه للتو؛ أما إذا كانت القيمة المُعادة غير فارغة، فسيكون لدينا بالفعل طقمًا يحتوي على عدَّة مراجع، وبالتالي ينبغي فقط أن نُضيف إليه مرجع الصفحة الجديد. ألقِ نظرةً على الشيفرة التالية المسؤولة عن تحقيق ما ذُكر للتو: /** * أضف مرجع صفحة إلى الفهرس */ void addReference(String term, int pageNum) { TreeSet<Integer> references; // الطقم المتضمن لأرقام الصفحات التي // ظهر بها `term` حتى الآن references = index.get(term); if (references == null){ // 1 TreeSet<Integer> firstRef = new TreeSet<>(); firstRef.add( pageNum ); // pageNum is "autoboxed" to give an Integer! index.put(term,firstRef); } else { // 2 references.add( pageNum ); } } [1] هذا هو المرجع الأول الذي نعثُر عليه لتلك الكلمة، ولذلك أنشِئ طقمًا جديدًا يحتوي على رقم الصفحة، وأضِفه إلى الفهرس index بمفتاحٍ يُساوِي الكلمة ذاتها. [2] تحتوي references على طقمٍ بمراجع الصفحات التي وجدناها حتى الآن لهذه الكلمة. أضِف رقم الصفحة الجديد إلى الطقم، حيث ربطنا هذا الطقم بتلك الكلمة من قبل بالفهرس index. يتبقَّى لنا الآن طباعة الفهرس، ولهذا سنحتاج إلى المرور عبر المصطلحات الموجودة بالفهرس واحدًا تلو الآخر، لنَطبَع كُلًا منها مع مراجع الصفحات الخاصة بها. يُمكِننا استخدام مُكرّرٍ من النوع Iterator للمرور عبر عناصر الفهرس، إلا أن استخدام حلقة تكرار for-each سيَكون أسهل نوعًا ما؛ حيث ستُمكِّننا من المرور عبر الارتباطات المُخزَّنة بالخريطة، والتي تمثِّل أزواجًا (مفتاح/قيمة key/value pair)؛ حيث يُشير المفتاح إلى مصطلحٍ معين؛ بينما تُشير القيمة إلى طقمٍ يحتوي على مراجع الصفحات التي أشارت لذلك المصطلح. سنَطبَع بداخل كل حلقة تكرار مجموعة الأعداد الصحيحة الموجودة بالطقم، وهو ما يُمكِننا فعله باستخدام حلقة for-each أخرى، ونكون بذلك قد اِستخدَمنا حلقتي تكرار for-each متداخلتين nested. يُمكِنك أن تُجرِّب فعل الأمر نفسه باستخدام المُكرِّرات iterators، لتُدرِك السهولة التي وفَّرتها لك حلقة التكرار for-each. ألقِ نظرةً على الشيفرة التالية المسؤولة عن طباعة فهرس المصطلحات: /** * اطبع جميع محتويات الفهرس */ void printIndex() { for ( Map.Entry<String,TreeSet<Integer>> entry : index.entrySet() ) { String term = entry.getKey(); TreeSet<Integer> pageSet = entry.getValue(); System.out.print( term + ": " ); for ( int page : pageSet ) { System.out.print( page + " " ); } System.out.println(); } } ربما يَكون اسم النوع Map.Entry<String,TreeSet<Integer>> المُبين بالأعلى هو أكثر الأشياء تعقيدًا بالشيفرة، ولكن تذكَّر أن الارتباطات المُخزَّنة بخريطةٍ من النوع Map<K,V> تكون من النوع Map.Entry<K,V>، ولهذا نَسخَنا ببساطةٍ معاملات النوع Map.Entry<String,TreeSet<Integer>> من تعليمة التصريح عن index. لاحِظ أيضًا أننا اِستخدَمنا مُتغيرًا مُتحكِّمًا بالحلقة loop control variable، اسمه page من النوع int للمرور عبر عناصر الطقم pageSet من النوع TreeSet<Integer>. ربما توقَّعت أن يكون المُتغيِّر page من النوع Integer، وليس int، حيث يُمكِنك استخدام النوع Integer هنا، ولكن يُمكِنك أيضًا استخدام النوع int؛ حيث تَسمَح خاصية التحويل التلقائي للأنواع type conversion بإسناد قيمةٍ من النوع Integer إلى مُتغيّرٍ من النوع int. تُعدّ الشيفرة السابقة صغيرةً بالموازنة مع كمية العمليات وتعقيدها. سنتعرَّض بتمرين 10.6 لمسألةٍ مماثلةٍ تمامًا لمسألة الفهرس بالأعلى. ربما من الأفضل قليلًا طباعة قائمة المراجع بحيث تَفصِل فاصلةٌ بين الأعداد الصحيحة المُمثِلة لأرقام الصفحات، حيث يستخدِم التابع printIndex() المُعرَّف بالشيفرة السابقة مسافةً فارغةً للفصل بين أرقام الصفحات، وربما لاحظت ذلك كونه يَطبَع مسافةً فارغةً إضافيةً بعد آخر مرجع صفحة ضمن القائمة، ولكنها غير ظاهرةٍ عمومًا، وبالتالي لا يُمثِل ذلك أية مشكلة. في المقابل، إذا طَبَعَنا فاصلةً بدلًا من المسافة الفارغة، فسيَكون ظهور الفاصلة بالنهاية أمرًا سخيفًا. ينبغي إذًا أن نَعرِض القائمة على النحو "17,42,105" وليس "17,42,105,"، ولكن كيف يُمكِننا تَجنُّب طباعة الفاصلة الأخيرة؟ لسوء الحظ، ليس من السهل فعل ذلك إذا كنا نَستخدِم حلقة التكرار for-each؛ وربما من الأفضل استخدام مُكرّرٍ iterator لحل تلك المشكلة على النحو التالي: Iterator<Integer> iter = pageSet.iterator(); int firstPage = iter.next(); // نعرِف أن الطقم يحتوي على عنصرٍ واحدٍ على الأقل بهذا البرنامج System.out.print(firstPage); while ( iter.hasNext() ) { int nextPage = iter.next(); System.out.print("," + nextPage); } يُمكِننا بدلًا من ذلك الاعتماد على حقيقة كون الصنف TreeSet يَتضمَّن تابعًا اسمه first()، وهو مسؤولٌ عن إعادة أول عنصرٍ ضمن الطقم، وهو العنصر الأصغر بحسب ما يَعنيه الترتيب المُستخدَم للموازنة بين عناصر ذلك الطقم. يُمكِننا إذًا حل المشكلة باستخدام ذلك التابع مع حلقة التكرار for-each على النحو التالي: int firstPage = pageSet.first(); // اعثر على أول رقم صفحة بالطقم for ( int page : pageSet ) { if ( page != firstPage ) System.out.print(","); // اطبع فاصلةً إذا لم تكن هذه هي الصفحة الأولى System.out.print(page); } أخيرًا، تَستخدِم الشيفرة التالية عرضًا view لجزءٍ من الشجرة (انظر المقال السابق): int firstPage = pageSet.first(); // استرجع العنصر الأول الذي نعرف بوجوده System.out.print(firstPage); // اطبع العنصر الأول بدون فاصلة for ( int page : pageSet.tailSet( firstPage+1 ) ) // معالجة باقي العناصر. System.out.print( "," + page ); استخدام الموازنة هناك نقطةٌ نريد مناقشتها بخصوص مسألة الفهرس السابقة؛ وهي تتمحور حول إمكانية وجود أحرفٍ بالحالة الكبيرة upper case والصغيرة lower case في المصطلحات الموجودة بالفهرس، فسيتسبَّب ذلك بحدوث مشكلة؛ لأن المصطلحات لن تَكون مُرتَّبة ترتيبًا أبجديًا بعد الآن. لا يعتمد الصنف String في الواقع على الترتيب الأبجدي، وإنما على شيفرة اليونيكود Unicode codes لمحارف السلسلة النصية. تُعدّ شيفرة الأحرف الأبجدية بحالتها الكبيرة أقل من شيفرة الأحرف بحالتها الصغيرة، ولذلك تَسبِق المُصطلحات البادئة بالحرف "Z" مثلًا تلك البادئة بالحرف "a". في المقابل، إذا تَضمَّنت المصطلحات أحرفًا بالحالة الصغيرة فقط أو بالحالة الكبيرة فقط، فستَكون مُرتَّبةً بحسب الترتيب الأبجدي. لنفترض أننا نريد السماح بكلتا الحالتين، وفي نفس الوقت نريد للعناصر أن تكون مُرتَّبة ترتيبًا أبجديًا؛ فلا يُمكِن في هذه الحالة الاعتماد على الترتيب الافتراضي للصنف String، وإنما ينبغي أن نُخصِّص تابعًا آخرًا للموازنة بين المفاتيح الموجودة بالخريطة، وهو ما يُمثِّل الاستخدام الأمثل للواجهة Comparator. لا بُدّ لأي كائنٍ يُنفِّذ الواجهة Comparator<T> أن يُعرِّف define التابع التالي للموازنة بين كائنين من النوع T: public int compare( T obj1, T obj2 ) يُعيد التابع السابق عددًا صحيحًا سالبًا، أو صفرًا، أو موجبًا، إذا كان obj1 أقل من obj2، أو يُساوِيه، أو أكبر منه على الترتيب. إذا أردنا الموازنة بين سلسلتين نصيتين من النوع String بحيث نتجاهل حالة أحرف السلاسل، وهو ما يُنفِّذه بالفعل التابع compareToIgnoreCase() المُعرِّف بالصنف String؛ فسنحتاج إذًا إلى كائنٍ من النوع Comparator<String>؛ ليُمكِّن الصنف TreeMap من إجراء موازنةٍ غير تلك الموازنة الافتراضية. بما أن الواجهة Comparator هي بالأساس واجهة دالة functional interface، يُمكِننا أن نُخصِّص قيمتها باستخدام تعبير لامدا lambda expression على النحو التالي: (a,b) -> a.compareToIgnoreCase(b) تبقَّى لنا الآن تمرير الكائن المنتمي للنوع Comparator إلى باني الصنف TreeMap، وبذلك نكون قد حَلِلنا مشكلة الفهرس: index = new TreeMap<>( (a,b) -> a.compareToIgnoreCase(b) ); بما أن تعبير لامدا lambda expression المُستخدَم مجرد استدعاءٍ لتابعٍ موجودٍ بالفعل ضمن الصنف String، فإنه من الممكن أيضًا تخصيصه باستخدام مرجع تابعي method reference (انظر مقال تعبيرات لامدا (Lambda Expressions) في جافا) على النحو التالي: index = new TreeMap<>( String::compareToIgnoreCase ); ستؤدي الشيفرة بالأعلى الغرض منها، ولكنها تُعانِي من مشكلةٍ واحدة. لنفترض أن البرنامج قد اِستدَعى التابع addReference("aardvark",56)، ثم اِستدَعى addReference("Aardvark",102) لاحقًا. نلاحظ تشابه الكلمتان "aardvark" و"Aardvark" تمامًا مع اختلافٍ واحدٍ فقط يتعلَّق بحالة حرفهما الأول، وبالتالي عندما نُحوِّل أحرف الكلمة الأولى إلى حالتها الصغيرة، ستُصبِح الكلمتان متطابقتين. والآن، عندما نُدْخِلهما إلى الفهرس، هل سيتعامل معهما على أساس كونهما مصطلحين مختلفين أم لا؟ تعتمد الإجابة على الطريقة التي يَفْحَص بها الصنف TreeMap تَساوِي الكائنات؛ حيث لا يَستخدِم الصنفان TreeMap و TreeSet التابع equals() للموازنة، وإنما يَستخدِمان عادةً كائنًا من النوع Comparator، أو التابع compareTo. يُعيد كائن الصنف Comparator المُستخدَم بهذا المثال القيمة صفرًا عند موازنة كلمتين مثل "aardvark" و"Aardvark" أي سيَعُدّهما نفس الشيء، وستَندْمِج بالتالي قائمة مراجع الصفحات الخاصة بالكلمتين ضمن قائمةٍ واحدة، وسيتضمَّن الفهرس عند طباعته نسخة الكلمة التي قابلها البرنامج أولًا. قد يَكون ذلك سلوكًا مقبولًا بهذا البرنامج؛ أما إذا لم يَكْن كذلك، فينبغي استخدام طريقةٍ أخرى لترتيب المصطلحات بحسب الترتيب الأبجدي. عد الكلمات سنَكتُب الآن البرنامج الأخير ضمن هذا المقال؛ والذي سيُخزِّن معلوماتٍ عن مجموعةٍ من الكلمات. سيتعيَّن علينا تحديدًا إنشاء قائمةٍ بكل الكلمات الموجودة ضمن ملفٍ يختاره المُستخدِم مصحوبةً بعدد مرات حدوث تلك الكلمة ضمن الملف؛ وسيتكوَّن خَرْج البرنامج من قائمتين تتضمَّنان جميع الكلمات الموجودة بالملف مع عدد مرات حدوث كل كلمة. سنُرتِّب القائمة الأولى بحسب الترتيب الأبجدي للكلمات؛ بينما سنُرتِّب القائمة الثانية بناءً على عدد مرات حدوث كل كلمة، بحيث تَكون الكلمات الأكثر استخدامًا بأعلى القائمة، والكلمات الأقل استخدِامًا بأسفلها. يُعدّ هذا البرنامج تعميمًا لتمرين 7.6 الذي أنشأنا خلاله قائمةً مُرتَّبة بجميع الكلمات الموجودة ضمن ملف، ولكن بدون عدّ مرات حدوثها. يُمكِنك الاطلاع على برنامج عدّ الكلمات بالملف WordCount.java. بينما يقرأ البرنامج الملف المُدْخَل، يتوجب عليه الاحتفاظ بعدد مرات حدوث كل كلمةٍ يُقابلها. في حين تستطيع أن تَضَعَ جميع كلمات الملف، بما في ذلك المُكرَّر منها، ضمن قائمةٍ ثم تَعُدّها لاحقًا؛ فإن ذلك سيتطلَّب مساحة تخزينٍ إضافية، ولن تَكون كفاءة البرنامج أفضل ما يُمكِن. من الأفضل إذًا الاحتفاظ بعدّادٍ counter لكل كلمة؛ وعندما يُقابِل البرنامج كلمةً معينةً لأول مرة، فسيَضبُط عداد الكلمة إلى القيمة 1، ثم يُزيدها بمقدار الواحد بكل مرةٍ يُقابِل فيها تلك الكلمة لاحقًا. تَستعرِض الشيفرة التالية تعريف الصنف المُتداخِل الساكن static nested الذي سيَستخدِمه البرنامج للاحتفاظ بالمعلومات الخاصة بكل كلمة: // 1 private static class WordData { String word; int count; WordData(String w) { // 2 word = w; count = 1; // The initial value of count is 1. } } // WordData نهاية الصنف [1] يُمثِّل هذا الصنف البيانات التي نحتاجها عن كل كلمة؛ أي الكلمة ذاتها، وعدد مرات حدوثها. [2] الباني المسؤول عن إنشاء كائنٍ من النوع WordData عندما تحدُث الكلمة للمرة الأولى. ينبغي على البرنامج تخزين جميع كائنات النوع WordData ضمن بنية بياناتٍ مناسبة، حيث ينبغي عمومًا أن نكون قادرين على إضافة الكلمات الجديدة بكفاءة؛ فعندما نُقابِل كلمةً جديدةً، سنَفْحَص أولًا فيما إذا كان هناك بالفعل كائنٌ من النوع WordData لتلك الكلمة، وينبغي في تلك الحالة أن نَعثُر عليه، ونزِيد قيمة عدّاده. بناءً على ذلك، ربما يُمكِننا استخدام خريطةٍ من النوع Map لتنفيذ تلك العمليات، لأننا سنبحث عن الكائن المطلوب بالاعتماد على الكلمة؛ حيث يُمثِّل مفتاح الخريطة الكلمة؛ بينما تُمثِّل القيمة كائنًا من الصنف WordData. لاحِظ أن الصنف يُخزِّن الكلمة ذاتها المُستخدَمة مثل مفتاح بمُتغيِّر نسخة instance variable، وهو ما قد يبدو غريبًا، ولكن يحدث ذلك عادةً؛ حيث يجب على الكائن المُستخدَم بمثابة قيمة أن يتضمَّن جميع المعلومات الخاصة بالكلمة، والتي يُعدّ المفتاح واحدًا منها؛ أي أننا نَستخدِم معلومةً جزئيةً بمثابة مفتاح لنسترجِع كامل المعلومات. بما أننا سنحتاج إلى طباعة الكلمات بترتيبها الأبجدي بعد الانتهاء من قراءة الملف، فسيكون الصنف TreeMap أنسب من الصنف HashMap. سيُحوِّل البرنامج كل الكلمات إلى حالتها الصغيرة lower case؛ ويمكننا بالتالي الاعتماد على الترتيب الافتراضي الذي يَستخدِمه الصنف String. تُنشِئ التعليمة التالية مُتغيِّرًا اسمه words من النوع TreeMap<String,WordData>، المُمثِل للخريطة المُستخدَمة لتخزين البيانات: TreeMap<String,WordData> words = new TreeMap<>(); عندما يقرأ البرنامج كلمةً من ملف، فإنه يَستدعِي words.get(word) ليَفحَص إذا كانت الكلمة موجودةً بالفعل ضمن الخريطة؛ فإذا أعاد التابع القيمة الفارغة null، فيَعنِي ذلك أنها المرة الأولى التي يرى فيها البرنامج تلك الكلمة، وسينشِئ بالتالي كائنًا من النوع WordData، ويُدْخِله بالخريطة باستدعاء التعليمة words.put(word, new WordData(word)). في المقابل، إذا أعاد التابع words.get(word) قيمةً غير فارغة، فإنها ستَكون بطبيعة الحال كائنًا من النوع WordData، وسيُزيِد البرنامج قيمة عدّاد counter الكائن بمقدار الواحد. يَستخدِم البرنامج التابع readNextWord()، الذي كتبناه بتمرين 7.6، لقراءة كلمةٍ واحدةٍ من الملف، حيث يُعيد القيمة null عند وصوله إلى نهاية الملف. ألقِ نظرةً على الشيفرة الخاصة بقراءة الملف وحساب المعلومات المطلوبة: String word = readNextWord(); while (word != null) { word = word.toLowerCase(); // حوِّل word إلى حالة الأحرف الصغيرة WordData data = words.get(word); if (data == null) words.put( word, new WordData(word) ); else data.count++; word = readNextWord(); } بعد انتهاء البرنامج من قراءة الكلمات وطباعتها بحسب ترتيبها الأبجدي، يجب أن يُرتِّبها بحسب عدد مرات تكرارها، ثم يَطبَعها مُجددًا. سنَستخدِم خوارزميةً مُعمَّمة generic algorithm للترتيب، حيث يُمكِننا مثلًا أن ننسخ كائنات الصنف WordData إلى قائمةٍ أخرى، ثم نُطبِق عليها خوارزميةً مُعمَّمة، مثل التابع Collections.sort(list,comparator)، والذي يَستقبِل كائنًا من النوع comparator بمثابة معاملٍ parameter ثانٍ، كما يُمكِننا تمريره بصيغة تعبير لامدا lambda expression. يُخزِّن مُتغيِّر الخريطة words كائنات الصنف WordData التي نرغب بترتيبها. سنَستدعِي إذًا التابع words.values() لنَسترجِع تجميعةً من النوع Collection مُكوَّنةً من جميع القيم الموجودة بالخريطة. بما أن باني الصنف ArrayList يَستقبِل تجميعةً، ويَنسخَ عناصرها إلى القائمة التي يُنشِئها، فإننا سنَستخدِم الأوامر التالية لإنشاء قائمةٍ من النوع ArrayList<WordData> تحتوي على البيانات الخاصة بالكلمات، ولنُرتِّبها بعد ذلك بحسب عدد مرات حدوث كل كلمة: ArrayList<WordData> wordsByFrequency = new ArrayList<>( words.values() ); Collections.sort( wordsByFrequency, (a,b) -> b.count - a.count ); يُلخِّص سطري الشيفرة بالأعلى الكثير من الشيفرة. ستتمكَّن من التفكير بأسلوب الخوارزميات وبنى البيانات المُعمَّمة generic بعد ممارستها قليلًا، وسيَكون المردود كبيرًا فيما يتعلُّق بالوقت والجهد الذي ستوفِّره لاحقًا. ينبغي الآن طبَاعة بيانات جميع كائنات الصنف WordData مرتين، بحيث تَكون مُرتَّبة ترتيبًا أبجديًا بالمرة الأولى، ومُرتَّبة بحسب عدد مرات حدوث كل كلمة بالمرة الثانية. ستَجِد البيانات مُرتَّبةً ترتيبًا أبجديًا بالفعل ضمن الخريطة (ضمن قيم values الخريطة بصورةٍ أدق) المُنتمية للنوع TreeMap، ويُمكِننا بالتالي استخدام حلقة التكرار for-each لطباعة بيانات التجميعة المُعادة من الاستدعاء words.values()، وسترى أنها مُرتّبةً أبجديًا بالفعل. سنَستخدِم بالإضافة إلى ذلك حلقة تكرار for-each أخرى، لطباعة بيانات القائمة wordsByFrequency، وسترى أن الكلمات مُرتّبةً بحسب عدد مرات حدوث كل كلمة ترتيبًا تنازليًا. ألقِ نظرةً على الشيفرة التالية: TextIO.putln("List of words in alphabetical order" + " (with counts in parentheses):\n"); for ( WordData data : words.values() ) TextIO.putln(" " + data.word + " (" + data.count + ")"); TextIO.putln("\n\nList of words by frequency of occurrence:\n"); for ( WordData data : wordsByFrequency ) TextIO.putln(" " + data.word + " (" + data.count + ")"); يُمكِنك الإطلاع على شيفرة البرنامج بالكامل بالملف WordCount.java. لاحظ اعتماد البرنامج على إمكانيات الصنف TextIO.java الذي ناقشناه في مقال المدخلات والمخرجات النصية في جافا فيما يتعلَّق بقراءة الملفات والكتابة بها. بالمناسبة، إذا طَبقت البرنامج WordCount على ملفٍ كبيرٍ نسبيًا، وفَحصت الخَرْج الناتج عنه، فستُلاحِظ شيئًا بخصوص التابع Collections.sort()؛ حيث نَعلَم أن قائمة الكلمات الثانية مُرتّبةً بحسب عدد مرات حدوث كل كلمة، ولكن إذا دققت النظر إلى أي مجموعةٍ معينةٍ من الكلمات التي صَدَف وأن تكرَّرت نفس العدد من المرات، فستُلاحِظ أنها مُرتَّبةٌ أبجديًا. في الواقع، كانت الكلمات مُرتَّبة أبجديًا بالفعل قبل تطبيق التابع Collections.sort() لإعادة ترتيبها بحسب عدد مرات حدوثها. ونظرًا لأن التابع Collections.sort() لا يُغيِّر ترتيب الكلمات التي تكرَّرت نفس العدد من المرات، ستظل مجموعة الكلمات التي تكررت نفس العدد من المرات مُرتَّبة أبجديًا. تُعدّ إذًا خوارزمية الترتيب sorting المُستخدَمة بالتابع Collections.sort() مُستقرةً stable؛ أي أنها تَستوفِي الشرط التالي: إذا كانت الخوارزمية تُرتِّب قائمةً وفقًا لخاصيةٍ معينةٍ مُتعلِّقةٍ بعناصرها، فلا ينبغي أن تُغيِّر ترتيب العناصر التي تَملُك نفس القيمة لتلك الخاصية. إذا أتى العنصر B بعد العنصر A مثلًا ضمن قائمةٍ قبل ترتيبها، وكانت قيمة الخاصية التي سيُجرَى على أساسها ترتيب العناصر مُتساويةً للعنصرين A و B، فيجب أن يأتي العنصر B بعد العنصر A بعد ترتيب القائمة. لا تَقَع خوارزميتا الترتيب الانتقائي SelectionSort والترتيب السريع QuickSort ضمن تصنيفات الترتيب المستقرة stable؛ بينما تَقَع خوارزمية الترتيب بالإدراج insertion sort ضمنه، ولكنها ليست سريعةً كفاية؛ وتتميز خوارزمية الترتيب بالدمج merge sort التي يَستخدِمها التابع Collections.sort() بالسرعة والاستقرار. نأمل أن تَكون الأمثلة التوضيحية التي تعرَّضنا لها خلال هذا المقال قد اقنعتك بأهمية إطار جافا للتجميعات Java Collection Framework ومدى فعاليتها. ترجمة -بتصرّف- للقسم Section 4: Programming with the Java Collection Framework من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا تعلم البرمجة البرمجة في جافا باستخدام الكائنات (Objects) بيئات البرمجة (programming environment) في جافا