البحث في الموقع
المحتوى عن 'مدخل إلى جافا'.
-
تَسمَح بعض اللغات البرمجية -بل غالبيتها في الواقع- بتعريف البرامج الفرعية (subroutines) بصورة مستقلة خارج أي صَنْف، لكن تختلف لغة الجافا عنهم في تلك النقطة، حيث لابُدّ أن يُعرَّف أيّ برنامج فرعي (subroutine) بلغة الجافا ضِمْن صَنْف (class). الهدف من الصَنْف عمومًا هو تجميع البرامج الفرعية والمُتَغيِّرات المُرتبطة معًا ضِمْن وحدة واحدة. نظرًا لأن البرامج المكتوبة بلغة الجافا عادة ما تَستخدِم عددًا ضخمًا من البرامج الفرعية المَكْتوبة بواسطة مبرمجين مختلفين، فإن احتمالية الخلط بين أسمائها يَكُون كبيرًا، لذا حَرَص مُصمِّمي الجافا على وَضع تقييد حازم على الطرق التي يُمكِن بها تسمية الأشياء، ومن هنا كانت ضرورة تعريف البرامج الفرعية وتجميعها ضِمْن أصناف (classes) لها اسم وبالتبعية تجميع تلك الأصناف ضِمْن حزم (packages) لها اسم أيضًا كما سنرى لاحقًا. تُميز لغة الجافا بشكل واضح بين البرامج الفرعية الساكنة (static) وغَيْر الساكنة (non-static). يُمكِن لأي صَنْف أن يَتضمَّن كِلا النوعين، ولكن كيفية اِستخدَامهما مختلفة تمامًا. تُعدّ البرامج الفرعية الساكنة (static subroutine) عمومًا أسهل في الفهم، فببساطة أي برنامج فرعي ساكن هو عضو (member) ينتمي للصنف ذاته المُعرَّف بداخله. في المقابل، تعريف البرامج الفرعية غَيْر الساكنة (non-static subroutine) موجود فقط ضِمْن الصَنْف حتى تَتَمكَّن كائنات (objects) ذلك الصنف -عند إنشائها- من اِستخدَام تلك البرامج الفرعية، وتُصبِح عندها تلك البرامج أعضاء بتلك الكائنات (objects). يُمكِن تطبيق ذلك الاختلاف على المُتَغيِّرات (variables) الساكنة وغَيْر الساكنة بنفس الكيفية، بل وعلى أي شيء آخر قد يَقع ضمن تعريف الأصناف (class definition). سنتناول في هذا الفصل كُلًا من البرامج الفرعية الساكنة والمُتَغيِّرات الساكنة فقط، وسننتقل بالفصل التالي إلى البرمجة كائنية التوجه (object-oriented programming) وعندها سنستطيع مناقشة الأعضاء غَيْر الساكنة. عادة ما يُطلَق مصطلح "التابع (method)" على البرامج الفرعية (subroutines) المُعرَّفة ضِمْن صَنْف، وهو الاسم الذي يُفضِّله غالبية مبرمجي الجافا مع أن المصطلحان عمومًا مترادفان خاصة فيما يَتعلَّق بلغة الجافا. ستَجِدْ أيضًا البعض يَستخدِم مصطلحات آخرى للإشارة إلى البرامج الفرعية مثل "الإجراء (procedure)" أو "الدالة (function)". يُفضِّل الكاتب الاستمرار باِستخدَام المصطلح الأعم "البرنامج الفرعي (subroutine)" ضِمْن هذا الفصل للإشارة إلى البرامج الفرعية الساكنة على الأقل، ولكنه سيبدأ في اِستخدَام مصطلح "التابع (method)" من حين لآخر، كما أنه سيَستخدِم مصطلح "الدالة (function)" فقط للإشارة إلى البرامج الفرعية التي تَحسِب قيمة وتُعيدها مع أن بعض اللغات البرمجية الآخرى تَستخدِم ذلك المصطلح للإشارة إلى البرامج الفرعية (subroutines) بمفهومها الأعم. تعريف البرامج الفرعية ينبغي أن يُعرَّف (define) أيّ برنامج فرعي (subroutine)، تَنوِي اِستخدَامه، بمكان ما بالبرنامج، بحيث يَتضمَّن ذلك التعريف (subroutine definition) كُلًا من الآتي: اسم البرنامج الفرعي (subroutine name)، والمعلومات اللازمة لاستدعائه (subroutine call)، وأخيرًا، الشيفرة الفعليّة المطلوب تَّنْفيذها مع كل عملية استدعاء. يُكتَب تعريف البرنامج الفرعي (subroutine definition) بالجافا بالصياغة التالية: <modifiers> <return-type> <subroutine-name> ( <parameter-list> ) { <statements> } قد تكون الصيغة بالأعلى مألوفة بالنسبة لك؛ فقد تَعرَّضنا بالفعل خلال الفصول السابقة لبعض البرامج الفرعية، مثل البرنامج main() المُعرَّف بأيّ برنامج، والبرنامج drawFrame() ببرامج التحريكة (animation) بالقسم ٣.٩. ومع ذلك، لاِستيعاب ما تَعنيه تلك الصيغة تفصيليًا، سنحتاج إلى مُناقشتها باستفاضة، وهو ما سنفعله بغالبية هذا الفصل. أولًا، تُكوِّن التَعْليمَات -الواقعة بين القوسين المعقوصين {}- مَتْن البرنامج الفرعي (subroutine body)، وتُمثِل الجزء التَّنْفيذي (implementation) أو الداخلي "للصندوق الأسود (black box)" كما ذَكَرنا بالقسم السابق. يُنفِّذ الحاسوب تلك التَعْليمَات عند إجراء عملية اِستدعاء لذلك البرنامج (أو التابع [method]). لاحظ أن تلك التَعْليمَات قد تَتكوَّن من أي تَعْليمَة قد تَعرَّضنا لها خلال الفصلين الثاني والثالث. ثانيًا، تُكتَب المُبدِّلات ببداية التعريف، وهي عبارة عن كلمات تُستخدَم لضَبْط بعض خاصيات البرنامج الفرعي، مثل ما إذا كان ساكنًا (static) أم غير ساكن (non-static). تُوفِّر لغة الجافا عمومًا ستة مُبدِّلات (modifiers) يُمكنك اِستخدَامها، ولقد تَعرَّضنا لاثنين منها فقط حتى الآن، وهي المُبدِّل الساكن static، والمُبدِّل العام public. ثالثًا، يُستخدَم النوع المُعاد لتَخْصِيص نوع القيمة المُعادة من البرنامج الفرعي. تحديدًا، إذا كان البرنامج الفرعي بالأساس هو عبارة عن دالة (function) تَحسِب قيمة معينة، عندها قد تَستخدِم أي نوع، مثل String أو int أو حتى أنواع المصفوفة مثل double[]. أما إذا كان البرنامج الفرعي ليس بدالة، أيّ لا يُعيد قيمة، فعندها تُستخدَم القيمة الخاصة void، والتي تُشير إلى عدم وجود قيمة مُعادة، أيّ أنها إِما أن تَكُون فارغة (empty) أو غَيْر موجودة. سنتناول الدوال (functions)، والأنواع المُعادة (return types) تفصيليًا بالقسم ٤.٤. وأخيرًا، قائمة المُعامِلات الخاصة بالتابع (method). تُعدّ المُعامِلات جزءًا من وَاجهة البرنامج الفرعي (subroutine interface)، وتُمثِل المعلومات المُمرَّرة (passed) إليه من الخارج، والتي قد يَستخدِمها لحِسَاب بعض العمليات الداخلية الخاصة به. فمثلًا، بفَرْض وجود الصَنْف Television، والذي يَتضمَّن التابع changeChannel(). في تلك الحالة، يُفْترَض أن يُطرَح سؤالًا تلقائيًا عن رقم القناة التي تُريد من البرنامج الفرعي الانتقال إليها، وهنا يأتي دور المُعامِلات (parameter)؛ حيث يُمكِن اِستخدَام مُعامِل للإجابة على مثل هذا السؤال، ولمّا كان رقم القناة هو عدد صحيح، فسيَكُون نوع ذلك المُعامِل (parameter type) هو int، ولهذا يُمكِنك التَّصْريح (declaration) عن التابع changeChannel() كالتالي: public void changeChannel(int channelNum) { ... } يُشير التَّصْريح بالأعلى إلى أن التابع changeChannel() لديه مُعامِل يَحمِل اسم channelNum من النوع العددي الصحيح int. لاحظ أن قيمة المُعامِل channelNum غير مُتوفِّرة حتى الآن، وإنما تَتوفَّر عند عملية الاستدعاء الفعليّ للتابع الفرعي، على سبيل المثال: changeChannel(17);. قد تتكوَّن قائمة المُعامِلات (parameter list) -بتعريف البرنامج الفرعي- من تَّصْريح عن مُعامِل (parameter declaration) واحد أو أكثر، وذلك على الصورة ، بحيث يَتكوَّن كل تَّصْريح من مُعامِل وحيد، كما يُفصَل بين كل تَّصْريح والذي يليه باستخدام فاصلة (comma). على سبيل المثال، في حالة أردت التَّصْريح عن مُعامِلين من النوع double، فستضطر إلى كتابة double x, double y وليس double x, y. لاحِظ أن قائمة المُعامِلات قد تَكُون فارغة أيضًا. سنتناول المُعامِلات (parameters) تفصيليًا بالقسم التالي. ها هي التعريفات الخاصة ببعض البرامج الفرعية (subroutine definitions)، ولكن بدون الجزء التَّنْفيذي (implementation) منها، أي بدون التَعْليمَات الفعليّة التي تُعرِّف ما تُنفِّذه تلك البرامج الفرعية: public static void playGame() { // المبدلات هي public و static // النوع المعاد هو void // اسم البرنامج الفرعي هو playGame // قائمة المعاملات فارغةً } int getNextN(int N) { // لا يوجد مبدلات // النوع المعاد هو int // اسم البرنامج الفرعي هو getNextN // قائمة المعاملات تتضمن معامل اسمه N من النوع int } static boolean lessThan(double x, double y) { // المبدلات هي static // النوع المعاد هو boolean // اسم البرنامج الفرعي هو lessThan // قائمة المعاملات تتضمن معاملين x و y كلاهما من النوع double } بالمثال الثاني بالأعلى، لمّا كان تعريف التابع getNextN لا يَتضمَّن المُبدل static، فإنه يُعدّ بصورة افتراضية تابعًا غَيْر ساكن (non-static method)، ولذلك لن نَفْحصه بهذا الفصل! اُستخدِم المُبدل public بالمثال الأول، والذي يُشير إلى إِمكانية استدعاء التابع (method) من أي مكان بالبرنامج، حتى من خارج الصَنْف (class) الذي عُرِّف فيه هذا التابع. في المقابل، يَتوفَّر المُبدل private، والذي يُشير إلى إمكانية استدعاء التابع من داخل نفس الصَنْف فقط. يُطلَق على كُلًا من المُبدلين public و private مُحدِّدات الوصول أو مُبدِّلات الوصول (access specifiers/access modifier). يَتوفَّر مُبدل وصول (access modifier) أخير، هو protected، والذي ستتضح أهميته عندما ننتقل إلى البرمجة كائنية التَوجه (object-oriented programming) بالفصل الخامس. في حالة عدم تخصيص مُحدِّد وصول لتابع معين، فإنه، وبصورة افتراضية، يُصبِح قابلًا للاستدعاء من أيّ مكان بالحزمة (package) التي تَتضمَّن صَنْفه، ولكن ليس من خارج تلك الحزمة. سنُناقش الحزم (packages) خلال هذا الفصل، تحديدًا بالقسم ٤.٦. يَتَّبِع البرنامج main()، المُعرَّف بأي برنامج، نفس قواعد الصيغة (syntax rules) المُعتادة لأي برنامج فرعي: public static void main(String[] args) { ... } بفَحْص التعريف بالأعلى، تَجد أن المُبدلات المُستخدَمة هي public و static، أما النوع المُعاد فهو void، أما اسم البرنامج الفرعي فهو main، وأخيرًا، قائمة المعاملات هي String[] args، أيّ أن نوع المُعامِل المُمرَّر هو نوع المصفوفة String[]. إذا كنت قد قرأت الفصول السابقة، فلديك بالفعل الخبرة الكافية لكتابة الجزء التَّنْفيذي من البرنامج الفرعي (implementation of a subroutine). في هذا الفصل، سنتعلَّم كتابة التَعرِيف بالكامل بما في ذلك جزء الواجهة (interface). استدعاء البرامج الفرعية يُعدّ تعريف البرنامج الفرعي (subroutine definition) بمثابة إعلام للحاسوب بوجود ذلك البرنامج الفرعي وبالوظيفة التي يُؤديها، لكن يُرجئ التَّنْفيذ الفعليّ للبرنامج الفرعي إلى حين استدعائه (call). يَنطبِق ذلك حتى على البرنامج (routine) main()، والذي يَستدعيه النظام (system) -لا أنت- عند تَّنْفيذه للبرنامج (program) ككل. تُستخدَم، مثلًا، تَعْليمَة استدعاء البرنامج الفرعي (subroutine call) التالية؛ لاستدعاء التابع playGame() المذكور بالأعلى: playGame(); يُمكن عمومًا كتابة تَعْليمَة الاستدعاء بالأعلى بأيّ مكان داخل نفس الصَنْف (class) الذي عَرَّف التابع playGame()، سواء كان ذلك بالتابع main()، أو بأيّ برنامج فرعي آخر. علاوة على ذلك، لمّا كان التابع playGame() مُعرَّف باِستخدَام المُبدل public، وهو ما يَعنِي كَوْنه تابعًا عامًا، فإنه من الممكن لأيّ صَنْف آخر استدعائه أيضًا، ولكن ينبغي أن تُعلِم الحاسوب، في تلك الحالة، باسم التابع كاملًا أثناء الاستدعاء، أيّ بذكر الصَنْف الذي ينتمي إليه ذلك التابع. ولأن التابع playGame() مُعرَّف باِستخدَام المُبدل static، وهو ما يَعنِي كَوْنه تابعًا ساكنًا، فإن اسمه الكامل يَتضمَّن اسم الصنف ذاته المُعرَّف بداخله. على سبيل المثال، إذا كان التابع playGame() مُعرَّف بالصَنْف Poker، تُستخدَم التَعْليمَة التالية لاستدعائه من خارج هذا الصَنْف: Poker.playGame(); يُعلِم اسم الصَنْف -بالأعلى- الحاسوب بأيّ صَنْف ينبغي له أن يَجِد التابع. بالإضافة إلى ذلك، يُساعدنا وجود اسم الصَنْف أثناء الاستدعاء على التمييز بين التابع Poker.playGame() وأي توابع اخرى لها نفس الاسم ومُعرَّفة بأصناف آخرى، مثل Roulette.playGame() أو Blackjack.playGame(). تُكتَب عمومًا تَعْليمَة استدعاء أي برنامج فرعي ساكن static (static subroutine call) بالجافا بالصيغة التالية إذا كان البرنامج الفرعي المُستدعَى مُعرَّفًا بنفس الصَنْف (class): <subroutine-name>(parameters); أو كالتالي إذا كان البرنامج الفرعي مُعرَّفًا بصَنْف آخر: <class-name>.<subroutine-name>(parameters); يَختلف ذلك عن التوابع غَيْر الساكنة (non-static methods) -سنتناولها لاحقًا-، والتي تنتمي إلى كائنات (objects) وليس أصناف (classes)، ولهذا يُستدعَى ذلك النوع من التوابع من خلال الكائنات (objects) ذاتها لا من خلال أسماء الأصناف. لاحظ أنه في حين يُمكِن لقائمة المُعامِلات (parameter list) أن تَكُون فارغة (empty)، كما هو الحال بالمثال playGame()، فإن كتابة الأقواس (parentheses) بتَعْليمَة الاستدعاء ما تزال ضرورية حتى مع كَوْن ما بينها فارغًا. أما في حالة تخصيص مُعامِل (parameter) أو أكثر بقائمة المُعامِلات (parameter list) بالتعريف الخاص ببرنامج فرعي ما (subroutine definition)، فينبغي عمومًا أن يَتطابَق عدد المُعامِلات المُمرَّرة أثناء استدعاء ذلك البرنامج الفرعي مع العَدَدَ المُخصَّص بذلك التعريف، كما لابُدّ بطبيعة الحال أن تَتطابَق أنواع تلك المُعامِلات المُمرَّرة بتَعْليمَة الاستدعاء مع نوعها المُناظِر بنفس ذلك التعريف. البرامج الفرعية بالبرامج سنُعطي الآن مثالًا عما قد يبدو عليه البرنامج (program) عند تَضمُّنه لبرنامج فرعي آخر غَيْر البرنامج main(). سنَكتُب تحديدًا برنامجًا للعبة تخمين، يَختار فيه الحاسوب عددًا عشوائيًا بين العددين ١ و ١٠٠، ثُمَّ يُحاول المُستخدِم تخمين ذلك العدد، ليُخبره الحاسوب بَعْدها عما إذا كان تخمينه أكبر أو أقل أو يُساوِي العَدَد الصحيح، وبحيث يَفوز المُستخدِم بالمباراة إذا تَمكَّن من تخمين العدد الصحيح خلال ٦ تخمينات كحد أقصى. أخيرًا، يَستطيع المُستخدِم اختيار الاستمرار بلعب مباراة إضافية بنهاية كل مباراة. لمّا كانت كل مباراة هي بالنهاية مُهِمّة مُترابطة مُفردة، كان بديهيًا كتابة برنامج فرعي playGame() بهدف لعب مباراة تخمين واحدة مع المُستخدِم. سيَستخدِم البرنامج main() حَلْقة تَكْرار (loop)، والتي ستَستدعِي ذلك البرنامج الفرعي مع كل مرة يختار فيها المُستخدِم الاستمرار بلعب مباراة إضافية. نحتاج الآن إلى تصميم البرنامج الفرعي playGame() وكتابته، وفي الواقع، يُصمَّم أي برنامج فرعي بنفس طريقة تَصْميم البرنامج main()، أيّ نبدأ بكتابة توصيف للخوارزمية (algorithm)، ثم نُطبِق التَصْميم المُتدرج (stepwise refinement). اُنظر الخوارزمية التالية لبرنامج لعبة التخمين مكتوبًا بالشيفرة الوهمية: // اختر عددًا عشوائيًا Pick a random number // طالما لم تنته المباراة while the game is not over: // اقرأ تخمين المستخدم Get the user's guess // اخبر المستخدم عما إذا كان تخمينه صحيحًا أم أكبر أم أقل Tell the user whether the guess is high, low, or correct. يُعدّ الاختبار "طالما لم تنته المباراة" معقدًا نوعًا ما؛ وذلك لأن المباراة قد تنتهي لسببين: إما لأن تخمين المُستخدِم كان صحيحًا أو لوصوله للحد الأقصى من عدد التخمينات المُمكنة، أيّ ٦. أحد أسهل الطرائق للقيام بذلك هو اِستخدَام حَلْقة تَكْرار لا نهائية while (true) تحتوي على تَعْليمَة break؛ بهدف إِنهاء الحَلْقة في الوقت المناسب. لاحِظ أننا سنحتاج إلى الاحتفاظ بعَدَد تخمينات المُستخدِم؛ حتى نَتمكَّن من إِنهاء المباراة في حالة وصول المُستخدِم للحد الأقصى من التخمينات. تُصبِح الخوارزمية كالتالي بعد إِجراء التعديلات: // أسند قيمة عشوائية بين 1 و 100 إلى المتغير computersNumber Let computersNumber be a random number between 1 and 100 // اضبط قيمة العداد المستخدم لعدّ عدد تخمينات المستخدم Let guessCount = 0 // استمر بتنفيذ الآتي while (true): // اقرأ تخمين المستخدم Get the user's guess // أزد قيمة العداد بمقدار الواحد Count the guess by adding 1 to guess count // إذا كان تخمين المستخدم صحيحًا if the user's guess equals computersNumber: // بلغ المستخدم بفوزه بالمباراة Tell the user he won // اخرج من الحلقة break out of the loop // إذا وصل العداد للحد الأقصى 6 if the number of guesses is 6: // بلغ المستخدم بخسارته للمباراة Tell the user he lost // اخرج من الحلقة break out of the loop // إذا كان كان تخمين المستخدم أقل من العدد if the user's guess is less than computersNumber: // بلغ المستخدم بكَوْن التخمين أقل من العدد Tell the user the guess was low // إذا كان كان تخمين المستخدم أعلى من العدد else if the user's guess is higher than computersNumber: // بلغ المستخدم بكَوْن التخمين أكبر من العدد Tell the user the guess was high يُستخدَم التعبير (int)(100 * Math.random()) + 1 لاختيار عدد عشوائي يقع بين العددين ١ و ١٠٠. اُنظر الشيفرة التالية بلغة الجافا، والتي تَتضمَّن تعريف البرنامج playGame() بعد التَّصْريح عن المُتَغيِّرات (variable declarations): static void playGame() { int computersNumber; // العدد العشوائي int usersGuess; // إحدى تخمينات المستخدم int guessCount; // عدد تخمينات المستخدم computersNumber = (int)(100 * Math.random()) + 1; guessCount = 0; System.out.println(); System.out.print("What is your first guess? "); while (true) { usersGuess = TextIO.getInt(); // اقرأ تخمين المستخدم guessCount++; if (usersGuess == computersNumber) { System.out.println("You got it in " + guessCount + " guesses! My number was " + computersNumber); break; // انتهت المباراة بفوز المستخدم } if (guessCount == 6) { System.out.println("You didn't get the number in 6 guesses."); System.out.println("You lose. My number was " + computersNumber); break; // انتهت المباراة بخسارة المستخدم } // بلغ المستخدم عما إذا كان تخمينه أكبر أم أصغر من العدد if (usersGuess < computersNumber) System.out.print("That's too low. Try again: "); else if (usersGuess > computersNumber) System.out.print("That's too high. Try again: "); } System.out.println(); } // end of playGame() الآن، وبعد انتهائنا من كتابة تعريف البرنامج الفرعي بالأعلى، فإننا سنحتاج إلى معرفة المكان الذي يُفْترَض أن نضع به هذا التعريف؟ ينبغي أن نضعه عمومًا ضِمْن نفس الصَنْف المُتضمِّن للبرنامج main()، ولكن ليس داخل البرنامج main ذاته؛ فمن غَيْر المسموح كتابة برنامج فرعي ضِمْن آخر (nested). لمّا كانت لغة الجافا لا تَشترِط أيّ ترتيب معين للبرامج الفرعية المُعرَّفة ضِمْن نفس الصَنْف، فيُمكِنك وضع تعريف playGame() قَبْل البرنامج main() أو بَعْده. لاحِظ أن البرنامج main() سيَستدعِي البرنامج الفرعي playGame()، وهو ما يَعنِي مُجرد تَضمُّنه لتَعْليمَة استدعاء (call statement) لذلك البرنامج الفرعي، لا تَضمُّنه لتعريفها الكامل (definition). يتبقَّى لنا الآن كتابة البرنامج main، وهو ما قد تراه أمرًا بغاية السهولة خاصة مع رؤيتنا لكثير من الأمثلة المشابهة مُسْبَّقًا. سيبدو البرنامج كاملًا كالتالي مع مُراعاة إِمكانية إضافة المزيد من التعليقات (comments): import textio.TextIO; public class GuessingGame { public static void main(String[] args) { System.out.println("Let's play a game. I'll pick a number between"); System.out.println("1 and 100, and you try to guess it."); boolean playAgain; do { playGame(); // اِستدعي البرنامج الفرعي لإجراء مباراة System.out.print("Would you like to play again? "); playAgain = TextIO.getlnBoolean(); } while (playAgain); System.out.println("Thanks for playing. Goodbye."); } // نهاية main static void playGame() { int computersNumber; // العدد العشوائي int usersGuess; // إحدى تخمينات المستخدم int guessCount; // عدد تخمينات المستخدم computersNumber = (int)(100 * Math.random()) + 1; guessCount = 0; System.out.println(); System.out.print("What is your first guess? "); while (true) { usersGuess = TextIO.getInt(); // اقرأ تخمين المستخدم guessCount++; if (usersGuess == computersNumber) { System.out.println("You got it in " + guessCount + " guesses! My number was " + computersNumber); break; // انتهت المباراة بفوز المستخدم } if (guessCount == 6) { System.out.println("You didn't get the number in 6 guesses."); System.out.println("You lose. My number was " + computersNumber); break; // انتهت المباراة بخسارة المستخدم } // بلغ المستخدم عما إذا كان تخمينه أكبر أم أصغر من العدد if (usersGuess < computersNumber) System.out.print("That's too low. Try again: "); else if (usersGuess > computersNumber) System.out.print("That's too high. Try again: "); } System.out.println(); } // نهاية البرنامج الفرعي playGame } // نهاية الصنف GuessingGame اِستغرِق الوقت الكافي لقراءة شيفرة البرنامج بالأعلى، وحَاول اِستيعاب طريقة عملها. رُبما تَكُون قد لاحَظت أن تَقسيم البرنامج إلى تابعين (methods) قد جَعل البرنامج عمومًا أسهل في القراءة، وربما حتى في الكتابة حتى مع كَوْنه بسيطًا، أما إذا لم تَلحَظ ذلك، فحاول إقناع نفسك به في الوقت الحالي. المتغيرات الأعضاء (Member Variables) قد تَتضمَّن الأصناف (classes) أعضاء (members) آخرى، غير البرامج الفرعية، كالمُتَغيِّرات (variables)، فيُمكِن لأي صَنْف التَّصْريح عن مُتَغيِّر ما (variable declaration)، ولا يُقصَد بذلك تلك المُتَغيِّرات المُعرَّفة داخل برنامج فرعي معين، والمَعروفة باسم المُتَغيِّرات المحليّة (local variable)، وإنما تلك المُعرَّفة بمكان يقع خارج أيّ برنامج فرعي ضِمْن الصَنْف. تُعرَف تلك المُتَغيِّرات باسم المُتَغيِّرات العامة (global variable) أو المُتَغيِّرات الأعضاء (member variables)؛ وذلك لكَوْنهم أعضاء (members) ضِمْن الصنف (class). مثلما هو الحال مع البرامج الفرعية، يُمكِن لأي مُتَغيِّر عضو (member variable) أن يُعرَّف بعدّه عضوًا ساكنًا (static) أو عضوًا غير ساكن (non-static). سنقتصر في هذا الفصل على الساكن منها. بدايةً، ينتمي أي مُتَغيِّر عضو، مُعرَّف باِستخدَام المُبدل static، إلى الصَنْف ذاته المُعرَّف بداخله -لا إلى كائنات ذلك الصَنْف-، فعندما يُحَمِّل مُفسر الجافا (Java interpreter) صَنْف معين، فإنه يُخصِّص مساحة بالذاكرة لكل مُتَغيِّر عضو ساكن ضِمْن ذلك الصَنْف. تُعدِّل أي تَعْليمَة إِسْناد (assignment) إلى واحد من تلك المُتَغيِّرات، وبغض النظر عن مكانها بالبرنامج، من محتوى نفس المساحة بالذاكرة، وكذلك يَلج (access) أي تعبير (expression) يَتضمَّن واحدًا من تلك المُتَغيِّرات، وبغض النظر عن مكانه بالبرنامج، إلى نفس المساحة بالذاكرة ويُعيد نفس القيمة، أيّ تتشارك البرامج الفرعية الساكنة المُعرَّفة بصَنْف ما قيم المُتَغيِّرات الأعضاء الساكنة المُعرَّفة ضِمْن نفس ذلك الصَنْف، فيُمكِن لبرنامج فرعي مُعين ضَبْط قيمة مُتَغيِّر عضو ساكن ما، بحيث يَستخدِمها برنامج فرعي آخر، وهو ما يَختلِف عن المُتَغيِّرات المحليّة المُعرَّفة (local variable) داخل أحد البرامج الفرعية؛ حيث تَتوفَّر فقط بينما يُنفَّذ ذلك البرنامج الفرعي، ثم لا يَعُدْ بالإمكان الوُلوج إليها (inaccessible) من خارج ذلك البرنامج الفرعي. تتشابه تَعْليمَة التَّصْريح (declaration) عن مُتَغيِّر عضو (member variable) مع تلك المَسئولة عن التَّصْريح عن أي مُتَغيِّر محليّ تقليدي باستثناء شيئين. الأول هو وقوع التَّصْريح عن المُتَغيِّر العضو بمكان خارج أي برنامج فرعي (مع ذلك ما يزال التَّصْريح ضِمْن الصَنْف نفسه)، والثاني هو إمكانية اِستخدَام المُبدلات (modifiers) مثل static و public و private ضِمْن التَّصْريح. يقتصر هذا الفصل على الأعضاء الساكنة فقط، ولهذا ستَتضمَّن أي تَعْليمَة تَّصْريح عن مُتَغيِّر عضو (member variable) المُبدل static، وربما قد يُستخدَم أيًا من المُبدلين public أو private. على سبيل المثال، انظر التالي: static String usersName; public static int numberOfPlayers; private static double velocity, time; إذا لم تَستخدِم المُبدل private ضِمْن تَعْليمَة التَّصْريح عن مُتَغيِّر عضو ساكن معين، فإنه يُعامَل افتراضيًا كعضو عام public، أي يُمكن الوُلوج إليه بأي مكان سواء من داخل الصَنْف المُعرَّف به أو من خارج ذلك الصنف. ولكن لاحِظ أنك ستحتاج إلى اِستخدَام مُعرِّف (identifier) مُركَّب على الصورة . عند محاولة الإشارة إليه من خارج الصَنْف. فمثلًا، يحتوي الصَنْف System على مُتَغيِّر عضو ساكن عام اسمه هو out، لذلك تستطيع الإشارة إلى ذلك المُتَغيِّر باِستخدَام System.out بأيّ صَنْف خاص بك. مثال آخر هو المُتَغيِّر العضو الساكن العام Math.PI بالصَنْف Math. مثال أخير، وبفَرْض أن لدينا الصَنْف Poker، والذي يُصَرِّح عن مُتَغيِّر عضو ساكن عام، وليَكُن numberOfPlayers، فإنه يُمكِن الإشارة إلى ذلك المُتَغيِّر داخل الصَنْف Poker ببساطة باِستخدَام numberOfPlayers، في المقابل، يُمكِن الإشارة إليه باِستخدَام Poker.numberOfPlayers من خارج الصَنْف. والآن، لنضيف عدة مُتَغيِّرات أعضاء ساكنة إلى الصَنْف GuessingGame الذي كتبناه مُسْبَّقًا بهذا القسم: أولًا، المُتَغيِّر العضو gamesPlayed بهدف الاحتفاظ بعَدَدَ المباريات التي لعبها المُستخدِم إجمالًا. ثانيًا، المُتَغيِّر العضو gamesWon بهدف الاحتفاظ بعَدَدَ المباريات التي كَسَبها المُستخدِم. يُصَرَّح عن تلك المُتغيرات كالتالي: static int gamesPlayed; static int gamesWon; ستَزداد قيمة المُتَغيِّر gamesPlayed بمقدار الواحد دائمًا مع كل عملية اِستدعاء للبرنامج playGame()، بينما ستَزداد قيمة المُتَغيِّر gamesWon بمقدار الواحد في حالة فوز المُستخدِم بالمباراة فقط، ثم تُطبَع قيمة المُتَغيِّرين بنهاية البرنامج main(). لمّا كان ضروريًا لكِلا البرنامجين الفرعيين playGame() و main() أن يَلجا إلى نفس قيمتي المُتَغيِّرين، فإنه يَستحِيل اِستخدَام المُتَغيِّرات المحليّة للقيام بنفس الشيء؛ ففي الواقع، يَقتصِر الولوج إلى قيمة مُتَغيِّر محليّ معين على برنامج فرعي وحيد، هو البرنامج الفرعي الذي عَرَّفه، وضِمْن نفس الاستدعاء؛ حيث تُسنَد قيمة جديدة للمُتَغيِّرات المحليّة (local variables) مع كل عملية استدعاء للبرنامج الفرعي الشامل لها، وهو ما يَختلِف عن المُتَغيِّرات العامة (global variables)، والتي تَحتفِظ بنفس قيمها بين كل استدعاء والاستدعاء الذي يَليه. تُهيَئ (initialized) المُتَغيِّرات الأعضاء تلقائيًا بقيم افتراضية بعد التَّصْريح عنها، وهو ما يَختلِف عن المُتَغيِّرات المحليّة ضِمْن البرامج الفرعية، والتي لابُدّ من إِسْناد قيمة لها صراحةً قَبْل اِستخدَامها. تلك القيم الافتراضية هي ذاتها القيم الافتراضية لعناصر المصفوفات، فتُسنَد القيمة صفر افتراضيًا إلى المُتَغيِّرات العددية، بينما تُسنَد القيمة false للمُتَغيِّرات من النوع boolean، في حين يُسنَد المحرف المقابل لقيمة ترميز اليونيكود (Unicode code) \u0000 للمُتَغيِّرات من النوع المحرفي char، أما القيمة الافتراضية المبدئية للكائنات (objects)، كالسَلاسِل النصية من النوع String، فهي القيمة الفارغة null. لمّا كان المُتَغيِّرين gamesPlayed و gamesWon من النوع int، فإنهما يُهيَئا أتوماتيكيًا إلى القيمة المبدئية صفر، وهو ما تَصادَف أن يَكُون القيمة المبدئية المناسبة لمُتَغيِّر يُنوَى اِستخدَامه كعَدَّاد (counter). مع ذلك، إذا لم تُناسبك القيمة المبدئية الافتراضية أو حتى إذا كنت تُريد مُجرد التَّصْريح عن نفس القيمة المبدئية لكن بصورة أكثر وضوحًا، فما يزال بإمكانك إِجراء عملية إِسْناد إلى أي من تلك المُتَغيِّرات ببداية البرنامج main(). اُنظر نسخة البرنامج GuessingGame.java بَعْد التعديل: import textio.TextIO; public class GuessingGame2 { static int gamesPlayed; // العدد الإجمالي للمباريات static int gamesWon; // عدد المباريات التي فاز فيها المستخدم public static void main(String[] args) { gamesPlayed = 0; gamesWon = 0; // لا فائدة فعلية من ذلك لأن الصفر هو القيمة الافتراضية System.out.println("Let's play a game. I'll pick a number between"); System.out.println("1 and 100, and you try to guess it."); boolean playAgain; do { playGame(); // استدع البرنامج الفرعي للعب مباراة System.out.print("Would you like to play again? "); playAgain = TextIO.getlnBoolean(); } while (playAgain); System.out.println(); System.out.println("You played " + gamesPlayed + " games,"); System.out.println("and you won " + gamesWon + " of those games."); System.out.println("Thanks for playing. Goodbye."); } // end of main() static void playGame() { int computersNumber; // العدد العشوائي int usersGuess; // إحدى تخمينات المستخدم int guessCount; // عدد تخمينات المستخدم gamesPlayed++; // أزد العدد الإجمالي للمباريات computersNumber = (int)(100 * Math.random()) + 1; guessCount = 0; System.out.println(); System.out.print("What is your first guess? "); while (true) { usersGuess = TextIO.getInt(); // اقرأ تخمين المستخدم guessCount++; if (usersGuess == computersNumber) { System.out.println("You got it in " + guessCount + " guesses! My number was " + computersNumber); gamesWon++; break; // انتهت المباراة بفوز المستخدم } if (guessCount == 6) { System.out.println("You didn't get the number in 6 guesses."); System.out.println("You lose. My number was " + computersNumber); break; // انتهت المباراة بخسارة المستخدم } // بلغ المستخدم عما إذا كان تخمينه أكبر أم أصغر من العدد if (usersGuess < computersNumber) System.out.print("That's too low. Try again: "); else if (usersGuess > computersNumber) System.out.print("That's too high. Try again: "); } System.out.println(); } // نهاية البرنامج الفرعي playGame } // نهاية الصنف GuessingGame2 بالمناسبة، لم يُستخدَم أي من المُبدلين public و private مع البرامج الفرعية أو المُتَغيِّرات الساكنة static بالأعلى، فما الذي يَعنيه ذلك؟ في الواقع، إذا لم يُخصَّص أي مُبدل وصول (access modifier) لمُتَغيِّر عام (global variable) أو لبرنامج فرعي (subroutine)، فإنه يُصبِح قابلًا للوصول بأيّ مكان يقع ضِمْن حزمة (package) الصَنْف الحاضن له، ولكن ليس بأي حزم آخرى. تقع الأصناف التي لا تُصَرِّح عن وجودها ضِمْن حزمة معينة بالحزمة الافتراضية (default package)، لذا تستطيع جميع الأصناف ضِمْن الحزمة الافتراضية -وهو ما يَشمَل أغلبية الأصناف بهذا الكتاب- الوصول إلى كِلا المُتَغيِّرين gamesPlayed و gamesWon وكذلك استدعاء البرنامج الفرعي playGame(). مع ذلك، يُعدّ اِستخدَام المُبدل private أثناء التَّصْريح عن كُلًا من المُتَغيِّرات الأعضاء والبرامج الفرعية عمومًا واحدًا من الممارسات الجيدة إلا إذا كان هناك سببًا يَدعوك لمخالفة ذلك، كما يُفضَّل تَجَنُّب اِستخدَام الحزمة الافتراضية (default package). ترجمة -بتصرّف- للقسم Section 2: Static Subroutines and Static Variables من فصل Chapter 4: Programming in the Large I: Subroutines من كتاب Introduction to Programming Using Java.
- 1 تعليق
-
- 1
-
لقد ناقشنا حتى الآن الكثير من الجوانب الأساسية المُستخدَمة ببرمجة واجهات المُستخدِم الرسومية (GUI)، وتَعلَّمنا ما يَكفِي لكتابة بعض البرامج الشيقة، ولهذا سنَفْحَص مثالين كاملين يُطبقان ما درسناه حتى الآن عن برمجة الواجهات الرُسومية (GUI) إلى جانب ما درسناه عن البرمجة في العموم بالفصول السابقة. ما يزال هنالك الكثير من الأمور الآخر لتَعْلُّمها، ولهذا سنعود للحديث عن نفس الموضوع بالفصل ١٣. لعبة ورق بسيطة برنامجنا الأول عبارة عن نُسخة من برنامج سطر الأوامر HighLow.java من القسم الفرعي ٥.٤.٣، ولكنها ستَكُون مُدعَّمة بواجهة مُستخدِم رُسومية (GUI). بنُسخة البرنامج الجديدة HighLowGUI.java، ينبغي أن تَنظُر إلى ورقة لعب ثم تَتَوقَّع ما إذا كانت قيمة ورقة اللعب التالية ستَكُون أكبر أو أقل من قيمة ورقة اللعب الحالية. إذا كان تَوقُّعك خاطئًا، فستَخسَر المباراة أما إذا كان صحيحًا لثلاث مرات متتالية، فستَفوز بالمباراة. بَعْد انتهاء أية مباراة، يُمِكنك النَقْر على "New Game" لبدء مباراة جديدة. تُوضِح الصورة التالية ما سيَبدُو عليه البرنامج خلال المباراة: شيفرة البرنامج بالكامل مُتاحَة بالملف HighLowGUI.java. حاول تَصْرِيفها وتَشْغِيلها. ملحوظة: يَتَطلَّب البرنامج كُلًا من الملفات Card.java و Deck.java و Hand.java من القسم ٥.٤ حيث تُعرِّف أصنافًا (classes) مُستخدَمة ضِمْن البرنامج كما أنه يَتَطلَّب ملف صورة ورق اللعب cards.png الذي اِستخدَمناه بالبرنامج RandomCards.java بالقسم الفرعي ٦.٢.٤. ربما من السهل تَخْمِين التخطيط (layout) المُستخدَم بالبرنامج: يَستخدِم HighLowGUI حاوية من النوع BorderPane كمُكوِّن جذري (root) لمبيان المشهد (scene graph). تَتَضمَّن تلك الحاوية حاوية آخرى من النوع Canvas مرسُوم عليها ورق لعب ورسالة وتَقَع بمنتصف الحاوية الخارجية. يَتَضمَّن المَوْضِع السفلي من الحاوية الخارجية حاوية آخرى من النوع HBox تَحتوِي بدورها على ثلاثة أزرار ضُبِطَت بحيث يَكُون لها جميعًا نفس قيمة العَرْض بغَرْض مَلْئ الحاوية بالكامل كما أَوضَحنا بالقسم الفرعي ٦.٥.٣. يُنفِّذ التابع start() التالي ما سبق: public void start(Stage stage) { cardImages = new Image("cards.png"); // حمل صورة ورق اللعب board = new Canvas(4*99 + 20, 123 + 80); // مسافة أربعة أوراق Button higher = new Button("Higher"); // أنشئ المفاتيح higher.setOnAction( e -> doHigher() ); // وجهز معالجات الأحداث Button lower = new Button("Lower"); lower.setOnAction( e -> doLower() ); Button newGame = new Button("New Game"); newGame.setOnAction( e -> doNewGame() ); HBox buttonBar = new HBox( higher, lower, newGame ); // اضبط كل زر لكي يحصل على ثلث العرض المتاح higher.setPrefWidth(board.getWidth()/3.0); lower.setPrefWidth(board.getWidth()/3.0); newGame.setPrefWidth(board.getWidth()/3.0); BorderPane root = new BorderPane(); // أنشئ المكون الجذري لمبيان المشهد root.setCenter(board); root.setBottom(buttonBar); doNewGame(); // جهز المباراة الأولى Scene scene = new Scene(root); // Finish setting up the scene and stage. stage.setScene(scene); stage.setTitle("High/Low Game"); stage.setResizable(false); stage.show(); } // end start() تَستدعِي مُعالِجات الأحداث (event handlers) بالأعلى توابعًا مُعرَّفة بمكان آخر من البرنامج مثل التابع doNewGame(). تُعدّ برمجة تلك التوابع (methods) تمرينًا جيدًا على التفكير وفقًا لما يُعرَف باسم "آلة الحالة (state machine)": ينبغي أن تُفكِر بالحالات (states) التي يُمكِن للمباراة أن تَقَع ضِمْنها، وبالكيفية التي قد تَتَغيَّر بها من حالة لآخرى، وكذلك بالكيفية التي ستَعتمِد بها مُعالجات الأحداث (events) على تلك الحالات. لاحِظ أن الأسلوب الذي اتِبعَناه بالنسخة الأصلية النصية من البرنامج بالقسم الفرعي ٥.٤.٣ غَيْر مُناسِب هنا حيث ستُربكَك محاولة التفكير بالمباراة تفصيليًا خطوة بخطوة من البداية وحتى النهاية أكثر مما ستُساعِدك. تَتَضمَّن حالة المباراة ورق لعب (cards) مُمثَل بكائن (object) من النوع Hand بالإضافة إلى رسالة مُمثَلة بكائن من النوع String. تُخزَّن تلك القيم ضِمْن مُتْغيِّرات نُسخ (instance variables). هنالك جانب آخر أقل وضوحًا سنُضْمّنه بحالة (state) المباراة: في بعض الأحيان، يَكُون هنالك مباراة قَيْد التّنْفِيذ ونَكُون بانتظار اختيار المُستخدِم لتَوقُّعه عن ورقة اللعب التالية. في بعض الأحيان الآخرى، نَكُون للتو قد انتهينا من مباراة وبانتظار نَقْر المُستخدِم على الزر "New Game". لذلك، ربما من الجيد أن نُضيِف عنصرًا جديدًا إلى حالة المباراة يُميِّز ذلك الاختلاف البسيط. يَستخدِم البرنامج مُتْغيِّر نُسخة (instance variable) من النوع boolean اسمه gameInProgress لذلك الغرض. عندما يَنقُر المُستخدِم على أحد الأزرار، فقد تَتَغيَّر حالة المباراة. يُعرِّف البرنامج ثلاثة توابع (methods) للاستجابة على أحداث الضَغْط على زر هي: doHigher() و doLower() و newGame()، والتي يُوكَل إليها مُهِمّة مُعالِجة الأحداث (event-handling). لأننا لا نُريد أن نَسمَح للمُستخدِم ببدء مباراة جديدة طالما كان هنالك مباراة آخرى قَيْد التّنْفِيذ بالفعل، فإن استجابة التابع newGame() ستَختلِف بناءً على قيمة مُتْغيِّر الحالة gameInProgress. إذا كانت قيمته تُساوِي true أي أن هنالك مباراة قَيْد التّنْفِيذ، فينبغي أن يُضبَط مُتْغيِّر النُسخة message إلى رسالة خطأ معينة. في المقابل، إذا كانت قيمة المُتْغيِّر تُساوِي false أي ليس هنالك أي مباراة، فينبغي ضَبْط جميع مُْتْغيِّرات الحالة (state variables) إلى القيم المناسبة لبدء مباراة جديدة. بجميع الأحوال، لابُدّ من إعادة رَسْم الرقعة (board) حتى يَرَى المُستخدِم أن الحالة قد تَغيَّرت. يُمكِننا تَعرِيف التابع newGame() على النحو التالي: private void doNewGame() { if (gameInProgress) { // إذا لم تنته المباراة الحالية بعد، اعرض رسالة خطأ message = "You still have to finish this game!"; drawBoard(); return; } deck = new Deck(); // أنشئ مجموعة ورق اللعب واليد hand = new Hand(); deck.shuffle(); hand.addCard( deck.dealCard() ); // اسحب ورقة اللعب الأولى message = "Is the next card higher or lower?"; gameInProgress = true; drawBoard(); } // end doNewGame() التابعان doHigher() و doLower() مُتطابقان تقريبًا، وربما كان بإِمكَاننا دَمْجهُما ضِمْن تابع واحد يَستقبِل مُعاملًا (parameter) واحدًا. عندما يَنقُر المُستخدِم على الزر "Higher"، يُستدعَى البرنامج doHigher()، ولأن ذلك الاستدعاء ليس له أي معنى إلا في حالة وجود مباراة قَيْد التّنْفِيذ، يَفْحَص التابع قيمة مُتْغيِّر الحالة gameInProgress أولًا. إذا كانت قيمته تُساوِي false، فإنه يَعرِض رسالة خطأ أما إذا كانت قيمته تساوي true أي توجد مباراة قَيْد التّنْفِيذ، فإنه يُضيِف ورقة لعب جديدة إلى اليد (hand) ثم يَفْحَص تَوقُّع المُستخدِم. قد تنتهي المباراة في تلك اللحظة بفوز المُستخدِم أو بخسارته، وعندها يََضبُط التابع قيمة مُتْغيِّر الحالة gameInProgress إلى القيمة false لأن المباراة قد انتهت ببساطة. بجميع الأحوال، يُعاد رَسْم الرُقعة (board) لضمان عَرْض الحالة (state) الجديدة. اُنظر التابع doHigher(): private void doHigher() { if (gameInProgress == false) { // إذا كانت المباراة قد انتهت، فمن الخطأ النقر // على زر "Higher" لذلك اعرض رسالة خطأ وانهي المعالجة message = "Click \"New Game\" to start a new game!"; drawBoard(); return; } hand.addCard( deck.dealCard() ); // اسحب ورقة لعب إلى اليد int cardCt = hand.getCardCount(); Card thisCard = hand.getCard( cardCt - 1 ); // تم سحب ورقة لعب Card prevCard = hand.getCard( cardCt - 2 ); if ( thisCard.getValue() < prevCard.getValue() ) { gameInProgress = false; message = "Too bad! You lose."; } else if ( thisCard.getValue() == prevCard.getValue() ) { gameInProgress = false; message = "Too bad! You lose on ties."; } else if ( cardCt == 4) { gameInProgress = false; message = "You win! You made three correct guesses."; } else { message = "Got it right! Try for " + cardCt + "."; } drawBoard(); } // end doHigher() يَعتمِد التابع drawBoard() على قيم مُتْغيِّرات الحالة (state variables) لتَقْرِير ما ينبغي له أن يَرسِمه ضِمْن الحاوية (canvas)، فيَعرِض السِلسِلة النصية المُخزَّنة بالمُتْغيِّر message ويَرسِم ورق اللعب ضِمْن اليد hand. وأخيرًا، إذا كانت المباراة قَيْد التّنْفِيذ، فإنه يَرسِم ورقة لعب إضافية مقلوبة تُمثِل ورقة اللعب التالية بمجموعة ورق اللعب (deck). ملحوظة: لقد ناقشنا التقنية المُستخدَمة لرَسْم ورقة لعب (card) مُفرّدة بالقسم ٦.٢. القوائم Menu وشريط القوائم MenuBar سنَعرِض الآن برنامج رَسْم اسمه "MosaicDraw". تَتَوفَّر شيفرة البرنامج بالملف MosaicDraw.java كما يَتَطلَّب الصَنْف MosaicCanvas.java. تُظهِر الصورة التالية رسمة مَصنُوعة باِستخدام البرنامج: عندما يَنقُر المُستخدِم على الفأرة ويَسحَبها داخل مساحة الرسم (drawing area)، فإن الفأرة تَترُك أثرًا مُكوَّنًا من مربعات صغيرة مُلوَّنة عشوائيًا. بالإضافة إلى ذلك، يُوفِّر البرنامج شريط قوائم (menu bar) أعلى مساحة الرسم. أولًا، تحتوي قائمة "Control" على أوامر لمَلْئ مساحة الرسم وتنظيفها مع خيارات آخرى تُؤثِر على مَظهَر الصورة. ثانيًا، تَسمَح قائمة "Color" بتَخْصِيص اللون المُستخدَم أثناء الرسم. ثالثًا، تُؤثِر قائمة "Tools" على سلوك الفأرة، فعندما نَستخدِم أداة "Draw" الافتراضية، تَترُك الفأرة أثرًا مُكوَّنًا من مُربعات مُفردّة، ولكن عندما نَستخدِم أداة "Draw 3x3"، فإنها تترُك أثرًا يَبلُغ عَرْضه ثلاثة مربعات. تَتَوفَّر أيضًا أداة "Erase" لإعادة ضَبْط المربعات إلى اللون الأسود الافتراضي. سنُمثِل مساحة الرسم (drawing area) داخل البرنامج باستخدام الصَنْف الفرعي MosaicCanvas المُشتَق من الصَنْف Canvas المُعرَّف بالملف MosaicCanvas.java. لا يُدعِّم ذلك الصَنْف "رسوم الفسيفساء" مباشرةً، ولكن بإمكاننا اِستخدَامه لمحاكاتها عن طريق ضَبْط لون كل مربع على حدى. أَعدّ البرنامج MosaicDraw معالجين لحدثيّ الفأرة MousePressed و MouseDragged الواقعين ضِمْن الحاوية (canvas) بحيث تَستجِيب تلك المُعالجات بتطبيق الأداة (tool) المُختارة حاليًا على مَوضِع مُؤشر الفأرة عند النَقْر. يُعدّ هذا البرنامج في العموم مثالًا جيدًا على تَطبيق مُستمع حَدَث (event listeners) على كائن (object) مما يَسمَح له بالقيام بأمور لم يَكُن هو ذاته مُبرمَجًا للقيام بها. ستَجِد شيفرة البرنامج بالكامل بالملف MosaicDraw.java. لن نُناقش جميع تفاصيلها هنا، ولكن يُفْترَض أن تَكُون قادرًا على فهمها بالكامل بَعْد قراءة هذا القسم. في المقابل، تَعتمِد شيفرة الملف MosaicCanvas.java على بعض التقنيات التي لن تَتَمكَّن من استيعابها في الوقت الحالي، ومع ذلك، يُمكِنك قراءة التعليقات الموجودة بالملف لكي تَتَعلَّم المزيد عن واجهة برمجة التطبيقات (API) الخاصة بالصَنْف MosaicCanvas. سنَستخدِم مُكوِّن شريط القوائم (menu bar) للمرة الأولى ضِمْن البرنامج MosaicDraw. تَسمَح لك مكتبة جافا إف إكس (JavaFX) بإضافة قائمة إلى البرنامج بسهولة عن طريق استخدام كائنات (objects) تَنتمِي للصَنْف MenuItem أو إلى أي من أصنافها الفرعية (subclasses) الُمعرَّفة بحزمة javafx.scene.control لتَمثيِل عناصر القائمة. تَعمَل عناصر القائمة تقريبًا بنفس الكيفية التي تعمل بها الأزرار (buttons). يُوفِّر الصَنْف MenuItem باني كائن (constructor) بمُعامل واحد عبارة عن نص عنصر القائمة. بإمكانك استخدامه لإنشاء كائن من ذلك الصَنْف كالتالي: MenuItem fillCommand = new MenuItem("Fill"); نظرًا لأن عناصر القائمة قد تَتَكوَّن من صورة ونص مثل الأزرار، يُوفِّر الصَنْف باني كائن (constructor) آخر بمُعاملين لتَخْصِيصهما. عندما يختار المُستخدِم عنصر قائمة من النوع MenuItem من قائمة، يَقَع حَدَث من النوع ActionEvent. يُمكِنك إضافة مُستمِع حَدَث (event listener) إليه باِستخدَام تابعه setOnAction(handler). بالإضافة إلى ذلك، قد تَستخدِم التابع setDisable(disabled) لتَفْعيل عنصر قائمة أو تَعْطِيله، وكذلك التابع setText() لتَغْيِير النص المعروض. يختلف عنصر القائمة (menu item) عن الزر (button) بكَوْنه يَظهَر ضِمْن قائمة (menu). في الواقع، أي عنصر قائمة هو عبارة عن عقدة من النوع Node تَظهَر عادةً ضِمْن قائمة، ولكنها قد تَظهَر أيضًا بأي مكان آخر ضِمْن مبيان المشهد (scene graph). تُمثَل القوائم (menus) بمكتبة جافا إف إكس (JavaFX) باِستخدَام الصَنْف Menu. لمّا كان ذلك الصَنْف صنفًا فرعيًا مُشْتقًا من الصَنْف MenuItem، فإنه من المُمكن إضافة قائمة ضِمْن قائمة آخرى كعنصر، وتَكُون عندها قائمة فرعية (submenu) من القائمة الأصلية المُضافة إليها. أي قائمة من الصَنْف Menu لها اسم يُمكِنك تَخْصِيصه من خلال باني الكائن (constructor). يُعيد تابع النسخة getItems() المُعرَّف بالصَنْف Menu قائمة (list) بعناصر القائمة (menu items)، ويُمكِنك أن تُضيِف عنصر قائمة جديد بإضافته إلى تلك القائمة. تُوضِح الشيفرة التالية طريقة إضافة عناصر (items) إلى قائمة: Menu sampleMenu = new Menu("Sample"); sampleMenu.getItems().add( menuItem ); // اضف عنصر قائمة إلى القائمة sampleMenu.getItems().addAll( item1, item2, item3 ); // أضف عدة عناصر ينبغي أن تضيف القائمة (menu) بَعْد إنشائها إلى شريط قوائم (menu bar) يُمثَل باِستخدَام الصنف MenuBar. لا يملك شريط القوائم اسمًا، فهو يعمل فقط بمثابة حاوي (container) لعدة قوائم. يمكنك استدعاء باني الكائن (constructor) الخاص به بدون أي معاملات أو بقائمة مُعاملات (parameter list) تحتوي على القوائم (menus) المطلوب إضافتها إليه. يعيد تابع النسخة getMenus() قائمة من القوائم (menus). يُعيد تابع النسخة getMenus() المُعرَّف بالصَنْف MenuBar قائمة (list) بالقوائم (menus) ضمن الشريط، ويستخدم كُلًا من التابعين add() و addAll() لإضافة قوائم (menus) جديدة إلى الشريط. يَستخدَم برنامج MosaicDraw ثلاثة قوائم هي controlMenu و colorMenu و toolMenu. تُنشِئ التَعْليمَات التالية شريط قوائم (menu bar) وتُضيِف إليه عدة قوائم (menus): MenuBar menuBar = new MenuBar(); menuBar.getMenus().addAll(controlMenu, colorMenu, toolMenu); أو قد نُمرِّر القوائم (menu) مباشرةً إلى الباني (constructor) كالتالي: MenuBar menuBar = new MenuBar(controlMenu, colorMenu, toolMenu); تَبقَّى لنا إضافة شريط القوائم (menu bar) إلى مبيان المشهد (scene) الخاص بالبرنامج. عادةً ما يَكُون شريط القوائم أعلى النافذة، ولكن من الممكن وَضْعُه بأي مكان آخر. عادةً ما تَستخدِم البرامج -المُحتويّة على شريط قوائم- كائن حاوية من النوع BorderPane يَعمَل كمُكوِّن جذري (root) لمبيان المشهد (scene graph). يَظهَر شريط القوائم (menu bar) بالمُكوِّن الموجود أعلى الحاوية أما المُكوِّنات الرسومية (GUI components) الآخرى فتَظهَر بالمواضع الأربعة الآخرى. يُمكِنك إذًا إنشاء أي قائمة على النحو التالي: أولًا، اِنشِئ شريط قوائم (menu bar). ثانيًا، اِنشِئ قوائم (menus) وأَضِفها إلى ذلك الشريط. ثالثًا، اِنشِئ عناصر قائمة (menu items) وأَضِفها إلى تلك القوائم. أَضِف مُستمعِي أحداث للأحداث (events) الصادرة عن تلك العناصر لمُعالجتها. أخيرًا، ضَعْ شريط القوائم (menu bar) بأعلى مُكوِّن الحاوية من النوع BorderPane. تُوفِّر مكتبة جافا إف إكس (JavaFX) أنواعًا آخرى من عناصر القوائم (menu items)، والتي يُمكِن بطبيعة الحال إضافتها إلى أي قائمة. تلك العناصر مُعرَّفة كأصناف فرعية (subclasses) من الصَنْف MenuItem. على سبيل المثال، يَستخدِم المثال التالي عنصر قائمة من النوع SeparatorMenuItem لإضافة خط بين عناصر القائمة الأخرى: menu.getItems().add( new SeparatorMenuItem() ); يتوفَّر أيضًا الصنفين الفرعيين CheckMenuItem و RadioMenuItem. يُمثِل الصَنْف CheckMenuItem عنصر قائمة يُمكِنه أن يَكُون بواحدة من حالتين: مُختار (selected) أو غير مُختار (not selected). تَتَبدَّل حالة العنصر عندما يختاره المُستخدِم من القائمة الحاوية له. يَعمَل ذلك العنصر بنفس الطريقة التي تَعمَل بها مُربعات الاختيار من الصَنْف CheckBox (اُنظر القسم الفرعي ٦.٤.٣). يَستخدِم البرنامج MosaicDraw ثلاثة عناصر من النوع CheckMenuItems بقائمة "Control": يُستخدَم إحداها لتَفْعِيل ميزة الألوان العشوائية للمربعات وإلغاؤها. يُستخدَم آخر لغَلْق خاصية التَماثُل (symmetry) وفَتْحها. عند تَفْعِيل تلك الخاصية، تَنعكِس الرسمة أفقيًا ورأسيًا لإنتاج نمط مُتماثِل. أخيرًا، يَعرِض عنصر القائمة الثالث ما يُعرَف بـ"الحشو (grouting)" ويُخفِيه. يَتَكوَّن ذلك الحشو من خطوط رمادية مرسومة حول كل مربع داخل الرسمة. اُنظر التَعْليمَات التالية المُستخدَمة لإضافة عنصر القائمة المُمثِل للخيار "Use Randomness" إلى قائمة "Control": useRandomness = new CheckMenuItem("Use Randomness"); useRandomness.setSelected(true); // شغل العشوائية افتراضيًا controlMenu.getMenus().add(useRandomness); // أضف عنصر قائمة إلى القائمة لم نُضِف مُعالجًا للحدث ActionEvent الصادر عن عنصر القائمة useRandomness حيث يَفْحَص البرنامج حالته باستدعاء useRandomness.isSelected() عند حاجته إلى تلوين أي مربع لكي يُقرِّر ما إذا كان سيُضيف بعض العشوائية للون أم لا. في المقابل، عندما يختار المُستخدِم مربع الاختيار "Use Grouting"من القائمة، فلابُدّ أن يُعِيد البرنامج رَسْم الحاوية (canvas) مباشرةً حتى تَعكِس حالتها الجديدة. تُضيِف الشيفرة التالية معالجًا (handler) إلى عنصر القائمة useGrouting المُمثِل لذلك الخيار لكي يَستدعِي التابع المناسب: useGrouting.setOnAction( e -> doUseGrouting(useGrouting.isSelected()) ); تحتوي قائمة "Tools" وقائمة "Color" بالبرنامج على عناصر قائمة من النوع RadioMenuItem تَعمَل بنفس الطريقة التي تَعمَل بها أزرار الانتقاء من الصَنْف RadioButton (اُنظر القسم الفرعي ٦.٤.٣). يُمكِن لأي عنصر قائمة من ذلك النوع أن يَكُون مختارً (selected) أو غير مختار، ولكن إذا أضفت عدة عناصر منها إلى كائن من الصَْنْف ToggleGroup، فلن تَتَمكَّن من اختيار أكثر من عنصر واحد فقط ضِمْن تلك المجموعة. يستطيع المُستخدِم أن يختار الأداة التي يَرغَب باِستخدَامها من قائمة "Tools"، ولأنه سيختار بطبيعة الحال أداة واحدة بكل مرة، كان من البديهي تَمثيِل تلك الأدوات بهيئة عناصر قائمة من الصَنْف RadioMenuItem، ومن ثَمَّ إضافتها جميعًا إلى كائن من الصَنْف ToggleGroup. يُؤشَر بعلامة على عنصر القائمة المُمثِل للأداة المُختارة حاليًا بالقائمة، وعندما يختار المُستخدِم أداة جديدة، يَتبدَّل مكان ذلك التأشير كاِستجابة مرئية تُوضِح الأداة المُختارَة حاليًا. بالإضافة إلى ذلك، تَملُك الكائنات من الصَنْف ToggleGroup خاصية "قابلة للمراقبة (observable)" تُمثِل الخيار المُحدَّد حاليًا (اُنظر القسم ٦.٣.٧). يُضيِف البرنامج مُستمِعًا (listener) إلى تلك الخاصية ويُخصِّص لها مُعالِج حَدَث (event handler) يُستدعَى تلقائيًا بكل مرة يختار فيها المُستخدِم أداة جديدة. اُنظر الشيفرة المسئولة عن إنشاء قائمة "Tools": Menu toolMenu = new Menu("Tools"); ToggleGroup toolGroup = new ToggleGroup(); toolGroup.selectedToggleProperty().addListener( e -> doToolChoice(toolGroup.getSelectedToggle()) ); addRadioMenuItem(toolMenu,"Draw",toolGroup, true); addRadioMenuItem(toolMenu,"Erase",toolGroup, false); addRadioMenuItem(toolMenu,"Draw 3x3",toolGroup, false); addRadioMenuItem(toolMenu,"Erase 3x3",toolGroup, false); وبحيث يُعرَّف التابع addRadioMenuItem على النحو التالي: private void addRadioMenuItem(Menu menu, String command, ToggleGroup group, boolean selected) { RadioMenuItem menuItem = new RadioMenuItem(command); menuItem.setToggleGroup(group); menu.getItems().add(menuItem); if (selected) { menuItem.setSelected(true); } } الشيفرة المسئولة عن إنشاء شريط القوائم (menua bar) مُعرَّفة بالكامل ضِمْن تابع (method) اسمه هو createMenuBar(). Scene و Stage قبل أن ننتهي من هذه المقدمة المُختصَرة عن برمجة واجهات المُستخدِم الرسومية (GUI)، سنَفْحَص صَنْفين أساسيين على نحو أكثر تفصيلي: Scene من حزمة package javafx.scene و Stage من حزمة package javafx.stage. يُمثِل أي مشهد من الصَنْف Scene مساحة المحتويات ضِمْن نافذة، ويَحمِل المُكوِّن الجذري لمبيان المشهد (scene graph) الخاص بها. يُعرِّف الصَنْف Scene بواني كائن (constructors) كثيرة تَستقبِل جميعها المُكوِّن الجذري لمبيان المشهد كمُعامل (parameters) إلى جانب عدة معاملات آخرى. على سبيل المثال، يَستقبِل الباني new Scene(root) مُعاملًا واحدًا فقط يُمثِل المُكوِّن الجذري، ويُعدّ أكثر بواني الصنف شيوعًا. يُمكِنك أيضًا أن تُمرِّر عَرْض المشهد وارتفاعه كمُعاملات للباني باِستخدَام Scene(root,width,height). إذا كان المُكوِّن الجذري عبارة عن حاوية من النوع Pane، يُضبَط حجم الحاوية بحيث يُساوِي حجم المشهد وتُعرَض محتوياتها بناءً على ذلك الحجم أما إذا لم يُخصَّص حجم المشهد، فإنه يُضْبَط إلى القيمة المفضلة لحجم المشهد. في المقابل، إذا كان المُكوِّن الجذري من الصَنْف Group المُعرَّف بحزمة package javafx.scene، فإنه لا يُضْبَط إلى نفس حجم المشهد وإنما يُقْصّ أي لا يَظهَر أي جزء يَقَع خارج المشهد (scene). لا تستطيع البرامج ضَبْط عَرْض أو ارتفاع مشهد من الصَنْف (Scene)، ولكن إذا تَغيَّر حجم المرحلة (stage) -من الصَنْف Stage- الحاوية لمشهد (scene)، فإن حجم المشهد يَتَغيَّر أتوماتيكيًا ليُطابِق الحجم الجديد لمساحة المُحتويات بالمرحلة (stage) كما يَتَغيَّر حجم المُكوِّن الجذري (root) للمشهد (scene) إذا كان من النوع Pane. يُمكِنك أن تُخصِّص لون مَلْئ (fill) -من النوع Paint- لخلفية مشهد (scene) عن طريق باني الكائن (constructor). مع ذلك، لا تَكُون خلفية المشهد مرئية في العموم لكَوْنها مغطاة بخلفية المُكوِّن الجذري (root). يُضْبَط لون خلفية المُكوِّن الجذري إلى الرمادي الفاتح افتراضيًا، ولكن قد تَضبُطه ليُصبِح شفافًا (transpatent) إذا أردت رؤية خلفية المشهد. تُمثِل أي مرحلة من الصَنْف Scene -المُعرَّف بحزمة javafx.stage- نافذة على شاشة الحاسوب. يَملُك أي تطبيق جافا إف إكس (JavaFX) مرحلة (stage) واحدة على الأقل يُطلَق عليها اسم "المرحلة الرئيسية (primary stage)". يُنشِئ النظام تلك المرحلة ويُمرِّرها كمُعامل (parameter) إلى التابع start() الخاص بالتطبيق. تَستخدِم البرامج في العادة أكثر من مجرد نافذة واحدة كما يُمكِنها أن تُنشِئ كائنات مرحلة جديدة من النوع Stage. سنعُود للحديث عن ذلك بالفصل ١٣. تحتوي أي مرحلة (stage) على مشهد (scene) يَملَئ مساحة المحتويات (content area) الخاصة بها. يستخدم تابع النسخة stage.setScene(scene) لضَبْط المشهد (scene) الخاص بمرحلة (stage). يُمكِنك عَرْض مرحلة لا تحتوي على أي مشهد، وستَظهَر عندها مساحة محتوياتها على هيئة مستطيل فارغ. علاوة على ذلك، تَحتوِي أي مرحلة (stage) على شريط عنوان (title bar) يَظهَر فوق مساحة محتوياتها (content area). يَتَكوَّن ذلك الشريط من عنوان نافذة إلى جانب مجموعة من أزرار التَحكُّم التي يستطيع المُستخدِم أن يَنقُر عليها للقيام بأشياء مثل غَلْق النافذة أو تكبيرها. يُضيِف نظام التشغيل (operating system) -لا الجافا- ذلك الشريط تلقائيًا، ويَتَحكَّم بتصميمه الخارجي. يُستخدَم تابع النسخة stage.setTitle(string) لضَبْط النص المَعرُوض بشريط العنوان، وتستطيع استدعائه بأي وقت. افتراضيًا، يستطيع المُستخدِم أن يَضبُط حجم أي مرحلة (stage) بسَحْب حوافها أو أركانها. مع ذلك، يُمكِنك استدعاء stage.setResizable(false) لكي تَمنَعه من ذلك. لاحِظ أنه حتى بَعْد استدعاء ذلك التابع على مرحلة، فما تزال شيفرة البرنامج ذاتها قادرة على تَغْيِير حجم تلك المرحلة باستدعاء توابع النسخة stage.setWidth(w) و stage.setHeight(h). عادةً ما يُحدَّد الحجم المبدئي لأي مرحلة بحجم المشهد (scene) الموجود داخلها، ولكن بإمكانك استدعاء setWidth() و setHeight() لضَبْط قيمة الحجم المبدئي قَبْل عَرْض النافذة. عندما يَكُون حجم مرحلة (stage) قابلًا للضَبْط من قِبَل المُستخدِم، فإن بإِمكانه تَصغِير النافذة أو تَكْبيرها إلى أي قيمة عشوائية افتراضيًا. يُمكِنك مع ذلك اِستخدَام توابع النسخ stage.setMinWidth(w) و stage.setMaxWidth(w) و stage.setMinHeight(h) و stage.setMaxHeight(h) لوَضْع بعض القيود على القيم المسموح بها. تُطبَق تلك القيود على ما يستطيع المُستخدِم القيام به أثناء سَحْبه لحواف النافذة أو أركانها. يُمكِنك تَغْيِير موضع مرحلة (stage) على الشاشة -عادةً قبل عَرْضها- باستدعاء توابع النُسخ stage.setX(x) و stage.setY(y). تُخصِّص الإحداثيات x و y مَوضِع الركن الأيسر العلوي من النافذة وفقًا لنظام إحداثيات الشاشة. أخيرًا، تَذكَّر أن أي مرحلة (stage) تَظلّ غَيْر مرئية إلى أن تَعرِضها على الشاشة صراحةً باستدعاء تابع النسخة stage.show(). عادةً ما يَكُون إظهار "المرحلة الرئيسية (primary stage)" هو آخر ما يُنفِّذه التابع start() الخاص بالتطبيق. إنشاء ملفات Jar الملف المُنتهِي بالامتداد .jar هو عبارة عن ملف جافا أرشيفي (java archive) يَتَضمَّن عددًا من ملفات الأصناف (class files) والموارد (resource files) المُستخدَمة ضِمْن البرنامج. عندما تُنشِئ برنامجًا يَعتمِد على أكثر من ملف واحد، يُنصَح عمومًا بوَضْع جميع الملفات التي يَتَطلَّبها ذلك البرنامج ضِمْن ملف جافا أرشيفي، وعندها لن يحتاج المُستخدِم لأكثر من ذلك الملف حتى يَتَمكَّن من تَشْغِيل البرنامج. يُمكِنك أيضًا أن تُنشِئ ما يُعرَف باسم "ملف جافا أرشيفي تّنْفِيذي (executable jar file)"، والذي يستطيع المُستخدِم أن يُشغِّله بنفس الكيفية التي يُشغِّل بها أي تطبيق آخر أي بالنَقْر المُزدوج على أيقونة الملف، ولكن لابُدّ من وجود نُسخة مُثبتَّة وصحيحة من الجافا على حاسوبه كما ينبغي أن تَكُون إعدادات حاسوبه مَضْبُوطَة بطريقة معينة. عادةً ما تُضبَط تلك الإعدادات تلقائيًا عند تثبيت الجافا بنظامي ويندوز وماك على الأقل. تَعتمِد طريقة إنشاء ملفات الجافا الأرشيفية (jar file) على بيئة البرمجة (programming environment) المُستخدَمة. لقد ناقشنا بالفعل نوعين أساسين منها هما: بيئة سطر الأوامر (command line) وبيئة التطوير المُتكاملة (IDE) بالقسم ٢.٦. تُوفِّر أي بيئة تطوير مُتكاملة للجافا (Java IDE) أمرًا لإنشاء ملفات الجافا الأرشيفية (jar files). اِتبِع مثلًا الخطوات التالية ببيئة تَطوِير إكلبس (Eclipse): اُنقُر بزر الفأرة الأيمن على المشروع داخل نافذة مُتصفِح الحزم (Package Explorer) ثُمَّ اِختَر "Export" من القائمة. ستَظهَر نافذة، اِختَر منها "JAR file" ثُمَّ اُنقُر على "Next". ستَظهَر نافذة أخرى، أَدْخِل اسمًا للملف الأرشيفي بصندوق الإدخال "JAR file" (يُمكنِك أيضًا النَقْر على زر "Browse" المجاور لذلك الصندوق لاختيار اسم الملف من خلال نافذة). لابُدّ أن يَنتهِي اسم الملف بالامتداد .jar. إذا كنت تُنشِئ ملفًا أرشيفيًا (jar file) عاديًا لا ملفًا تنفيذيًا، تستطيع أن تَنقُر على "Finish" لإنشاء الملف. أما إذا كنت تريد أن تُنشِئ ملفًا أرشيفيًا تنفيذيًا (executable)، اُنقُر على زر "Next" مرتين إلى أن تَصِل إلى شاشة "Jar Manifest Specification". ستَجِد صندوق الإدخال "Main class" بنهاية الشاشة. ينبغي أن تُدْخِل به اسم الصَنْف المُتْضمِّن للبرنامج main() المُراد تَشْغِيله عند تّنْفِيذ الملف الأرشيفي. إذا ضَغطَت على زر "Browse" المجاور للصندوق "Main class"، ستَظهَر قائمة بالأصناف المُحتويّة على برنامج main() والتي يُمكِنك الاختيار من بينها. بَعدّ اختيار الصَنْف، اُنقُر على زر "Finish" لإنشاء الملف التّنْفِيذي. بالنسبة لبيئة سطر الأوامر، تَتَضمَّن عُدّة تطوير جافا (Java Development Kit - JDK) برنامج سطر الأوامر jar المُستخدَم لإنشاء ملفات جافا أرشيفية (jar files). إذا كانت جميع الأصناف (classes) واقعة ضِمْن الحزمة الافتراضية (default package) كغالبية الأمثلة التي تَعرَّضنا لها، يَكُون اِستخدَام الأمر jar سهلًا نوعًا ما. فمثلًا، لكي تُنشِئ ملفًا أرشيفيًا غَيْر تّنفِيذي بسطر الأوامر، اِنتقِل إلى المجلد الذي يَحتوِي على ملفات الأصناف المطلوب إضافتها إلى الملف الأرشيفي، ثُمَّ اِستخدِم الأمر التالي: jar cf JarFileName.jar *.class حيث JarFileName هو اسم ملف الجافا الأرشيفي الذي تَرغَب بإنشائه. لاحِظ أن المحرف * بـ *.class هو عبارة عن محرف بدل (wildcard) يَجعَل *.class تُطابِق جميع أسماء ملفات الأصناف (class files) الموجودة بالمجلد الحالي أي سيَحتوِي الملف الأرشيفي (jar file) على كل ملفات الأصناف ضِمْن المجلد. إذا كان البرنامج يَستخدِم ملفات موارد (resource files) مثل الصور، فلابُد من أن تُضيفها إلى الأمر jar أيضًا. أما إذا كنت تريد أن تُضيِف ملفات أصناف مُحدَّدة فقط، فينبغي أن تَذكُر أسمائها صراحةً بحيث تَفصِل مسافة فارغة بين كل اسم والذي يليه. إذا كانت ملفات الأصناف والموارد مُعرَّفة بحزم غَيْر الحزمة الافتراضية (default package)، تُصبِح الأمور أكثر تعقيدًا، ففي تلك الحالة، لابُدّ أن تَقَع ملفات الأصناف بمجلدات فرعية داخل المجلد الذي تُنفِّذ به الأمر jar. اُنظُر القسم الفرعي ٢.٦.٧. في المقابل، إذا كنت تريد أن تُنشِئ ملفًا أرشيفيًا تنفيذيًا من خلال سطر الأوامر، فالأمر ربما أعقد قليلًا. فلابُدّ من وجود طريقة لتَخْصِيص الصَنْف المُتضمِّن للبرنامج main() مثلًا بإنشاء ملف نصي يحتوي على سطر وحيد كالتالي: Main-Class: ClassName يُشير ClassName -بالأعلى- إلى اسم الملف المُحتوِي على البرنامج main(). على سبيل المثال، إذا كان البرنامج main() مُعرَّف ضِمْن الصنف MosaicDraw، فينبغي أن يَحتوِي الملف على العبارة "Main-Class: MosaicDraw". يُمكِنك أن تُسمِي الملف بأي اسم تريده، ولكن لابُدّ من أن تَضعُه داخل نفس المجلد الذي تُنفِّذ به الأمر jar كما ينبغي أن تَكتُب الأمر jar بالصياغة التالية: jar cmf ManifestFileName JarFileName.jar *.class يستطيع الأمر jar تّنْفِيذ الكثير من العمليات المختلفة. يخصص مُعامله (parameter) الأول cf أو cmf العملية المطلوب تّنْفِيذها. بالمناسبة، إذا استطعت إنشاء ملف أرشيفي تّنْفِيذي (executable jar file)، يُمكِنك اِستخدَام الأمر java -jar لتَشْغِيله عبر سطر الأوامر كالتالي: java -jar JarFileName.jar ترجمة -بتصرّف- للقسم Section 6: Complete Programs من فصل Chapter 6: Introduction to GUI Programming من كتاب Introduction to Programming Using Java.
-
سنتناول خلال هذا المقال برنامجًا معقدًا بعض الشئ عما رأيناه مسبقًا، فقد كانت غالبية الأمثلة التي تعرَّضنا إليها مجرد أمثلة بسيطة هدفها توضيح تقنية برمجية أو اثنتين على الأكثر؛ أما الآن، فقد ان الوقت لتوظيف كل تلك الأفكار والتقنيات معًا ضمن برنامجٍ واحدٍ حقيقي. سنناقش أولًا البرنامج وتصميمه بصورةٍ مُبسطة، وبينما نفعل ذلك، سنتحدث سريعًا عن بعض خاصيات جافا التي لم نتمكَّن من الحديث عنها أثناء المقالات السابقة؛ إذ تتضمَّن تلك الخاصيات أمورًا يُمكِن تطبيقها على جميع البرامج وليس فقط على برامج واجهات المُستخدم الرسومية 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. اقرأ أيضًا المقال السابق: النوافذ وصناديق النافذة في جافا الخاصيات والارتباطات في جافا كيفية كتابة برامج صحيحة باستخدام لغة جافا مقدمة إلى صحة البرامج ومتانتها في جافا
-
تحدثنا بالقسم السابق عن الأنواع الأساسية (primitive types) الثمانية بالإضافة إلى النوع String. هنالك فارق جوهري بينهما، وهو أن القيم من النوع String عبارة عن كائنات (objects). على الرغم من أننا لن نناقش الكائنات تفصيليًا حتى نَصِل إلى الفصل الخامس، فما يزال من المفيد أن نَطَّلِع قليلًا عليها وعلى مفهوم الأصناف (classes) المُرتبِط بها إلى حد كبير. لن يُمكِّننا استيعاب مفاهيم أساسية كالكائنات (objects) والأصناف (classes) على اِستخدَام السَلاسِل النصية من النوع String فقط، وإنما سيَفتَح لنا الباب لاستيعاب مفاهيم برمجية آخرى مُهِمّة مثل البرامج الفرعية (subroutines). الدوال (functions) والبرامج الفرعية (subroutines) المبنية مسبقًا تَذَكَّر أن البرنامج الفرعي (subroutine) ما هو إلا مجموعة من التَعْليمَات (instructions) مُضمَّنة معًا تحت اسم معين، ومُصمَّمة لتكون مسئولة عن إنجاز مُهِمّة واحدة مُحدَّدة. سنتعلَّم أسلوب كتابة البرامج الفرعية (subroutines) بالفصل الرابع، ومع ذلك يُمكِنك أن تُنجز الكثير فقط باستدعاء البرامج الفرعية التي كُتبَت بالفعل بواسطة مبرمجين آخرين. يُمكِنك أن تَستدعِي برنامجًا فرعيًا ليُنجز المُهِمّة المُوكَلة إليه باِستخدَام تَعْليمَة استدعاء برنامج فرعي (subroutine call statement). يُعرَّف أي برنامج فرعي بالجافا ضِمْن صَنْف (class) أو كائن (object)، وتَتَضمَّن بعض الأصناف القياسية بلغة الجافا برامجًا فرعية مُعرَّفة مُسْبَقًا يُمكِنك اِستخدَامها. فمثلًا، تحتوي القيم من النوع String التي هي عبارة عن كائن على برامج فرعية مبنية مُسْبَقًا يُمكِنها معالجة السَلاسِل النصية (strings)، والتي يُمكِنك أن تَستدعِيها دون فهم طريقة كتابتها أو كيفية عملها. في الواقع، هذا هو الغرض الأساسي منها: أي برنامج فرعي هو صندوق أسود (black box) يُمكِن اِستخدَامه بدون مَعرِفة ما يحدث داخله. لنَفْحَص أولًا البرامج الفرعية (subroutines) التي تُعدّ جزءًا من صَنْف (class). يُستخدَم أي صَنْف عمومًا لتجميع بعض المُتْغيِّرات (variables) والبرامج الفرعية (subroutines) المُعرَّفة بذلك الصنف معًا، ويُعرَف كلاهما باسم "أعضاء الصَنْف الساكنة (static members)". لقد رأينا مثالًا لذلك بالفعل: إذا كان لدينا صنف يُعرِّف برنامجًا، فإن البرنامج main() هو عضو ساكن (static member) بذلك الصَنْف. ينبغي أن تُضيف الكلمة المحجوزة static عندما تُعرِّف عضو ساكن مثل الكلمة static بالتَصْرِيح public static void main.... عندما يحتوي صنف (class) معين على مُتْغيِّر أو برنامج فرعي ساكن (static)، يُعدّ اسم الصنف جزءًا من الاسم الكامل لذلك المُتْغيِّر أو لذلك البرنامج الفرعي. على سبيل المثال، يحتوي الصنف القياسي System على البرنامج الفرعي exit لذا يُمكِنك أن تُشير إليه باستخدام الاسم System.exit والذي يَتَكوَّن من كُلًا من اسم الصنف المُتْضمِّن للبرنامج الفرعي متبوعًا بنقطة ثم باسم البرنامج الفرعي نفسه. يَستقبِل البرنامج الفرعي exit مُعامِلًا من النوع int، لذلك يُمكِنك اِستخدَامه بكتابة تَعْليمَة استدعاء برنامج فرعي (subroutine call statement) كالتالي: System.exit(0); يُنهِي الاستدعاء System.exit البرنامج ويُغلِق آلة جافا الافتراضية (Java Virtual Machine)، لذا يُمكِنك أن تَستخدِمه إذا كنت تُريد إنهاء البرنامج قبل نهاية البرنامج main(). تُشير قيمة المُعامِل المُمرَّرة إلى سبب إغلاق البرنامج، فإذا كانت تُساوِي القيمة ٠، يَعنِي ذلك أن البرنامج قد انتهى بشكل طبيعي. في المقابل، تَعنِي أي قيمة آخرى انتهاء البرنامج لوجود خطأ مثل الاستدعاء System.exit(1). تُرسَل قيمة المُعامِل المُمرَّرة إلى نظام التشغيل والذي عادةً ما يتجاهلها. يُعدّ الصَنْف System واحدًا فقط من ضِمْن مجموعة من الأصناف القياسية التي تأتي مع الجافا. كمثال آخر، يَتَضمَّن الصنف Math مُتْغيِّرات ساكنة (static variables) مثل Math.PI و Math.E والتي قيمها هي الثوابت الرياضية π و e على الترتيب كما يُعرِّف عددًا كبيرًا من الدوال (functions) الحسابية. يُنفِّذ أي برنامج فرعي (subroutine) في العموم مُهِمّة مُحدّدة. لبعض البرامج الفرعية، تَحسِب المُهِمّة قيمة إحدى البيانات ثم تُعيدها، وفي تلك الحالة، تُعرَف تلك البرامج الفرعية باسم الدوال (functions)، ونقول عندها أن تلك الدالة تُعيد (return value) قيمة، والتي يُفْترَض استخدامها بطريقة ما ضِمْن البرنامج المُستدعِي للدالة. لنَفْترِض مثلًا مسالة حِسَاب الجذر التربيعي (square root)، تُوفِّر لغة الجافا دالة (function) لهذا الغرض اسمها هو Math.sqrt. تُعدّ تلك الدالة عضو برنامج فرعي ساكن (static member subroutine) بالصنف Math. إذا كانت x هي أي قيمة عددية، فإن الاستدعاء Math.sqrt(x) يَحسِب الجذر التربيعي (root) لتلك القيمة ثم يُعيدها. لمّا كانت الدالة Math.sqrt(x) تُمثِل قيمة، فليس هناك أي مغزى من استدعائها بمفردها بتَعْليمَة استدعاء برنامج فرعي (subroutine call statement) كالتالي: Math.sqrt(x); لا يَفعَل الحاسوب أي شيء بالقيمة المُعادة من الدالة (function) بالأعلى، أي أنه يَحسِبها ثم يتجاهلها، وهو أمر غَيْر منطقي، لذا يُمكِنك أن تُخبره مثلًا بأن عليه طباعتها على الأقل كالتالي: System.out.print( Math.sqrt(x) ); // اعرض الجذر التربيعي أو قد تَستخدِم تَعْليمَة إِسْناد (assignment statement) لتُخبره بأن عليه تَخْزِينها بمُتْغيِّر كالتالي: lengthOfSide = Math.sqrt(x); يُمثِل استدعاء الدالة Math.sqrt(x) قيمة من النوع double أي يُمكِنك كتابة ذلك الاستدعاء أينما أَمْكَن اِستخدَام قيمة عددية مُصنَّفة النوع (numeric literal) من النوع double. يُمثِل x مُعامِلًا (parameter) يُمرَّر إلى البرنامج الفرعي (subroutine) والذي قد يَكُون مُتْغيِّرًا (variable) اسمه x أو قد يَكُون أي تعبير آخر (expression) بشَّرط أن يُمثِل ذلك التعبير قيمة عددية. على سبيل المثال، يَحسِب الاستدعاء Math.sqrt(2) قيمة الجذر التربيعي للعَدَد ٢ أما الاستدعاء Math.sqrt(a*a+b*b) فهو صالح تمامًا طالما كانت قيم a و b مُتْغيِّرات من النوع العددي. يحتوي الصَنْف Math على العديد من الدوال الأعضاء الساكنة (static member functions) الأخرى. اُنظر القائمة التالية والتي تَعرِض بعضًا منها: Math.abs(x): تَحسِب القيمة المطلقة للمُعامِل x. الدوال المثلثية العادية (trigonometric functions) Math.sin(x) و Math.cos(x) و Math.tan(x): تُقاس جميع الزوايا بوحدة قياس راديان (radians) وليس بوحدة الدرجات (degrees). الدوال المثلثية العكسية (inverse trigonometric functions) Math.asin(x) و Math.acos(x) و Math.atan(x): تُقاس القيمة المُعادة (return value) من تلك الدوال بوحدة قياس راديان وليس بوحدة الدرجات. تَحسِب الدالة الأسية Math.exp(x) قيمة العدد e مرفوعة للأس x أما دالة اللوغاريتم الطبيعي Math.log(x) فتَحسِب لوغاريتم x بالنسبة للأساس e. Math.pow(x,y) تحسب قيمة x مرفوعة للأس y. Math.floor(x): تُقرِّب x لأكبر عدد صحيح أقل من أو يُساوِي x. تَحسِب تلك الدالة عددًا صحيحًا بالمفهوم الرياضي، ولكنها مع ذلك تُعيد قيمة من النوع double بدلًا من النوع int كما قد تَتَوقَّع. فمثلًا يُعيد استدعاء الدالة Math.floor(3.76) القيمة 3.0 بينما يُعيد استدعاء الدالة Math.floor(-4.2) القيمة -5. علاوة على ذلك، تَتَوفَّر أيضًا الدالة Math.round(x) والتي تُعيد أقرب عدد صحيح للمُعامِل x وكذلك الدالة Math.ceil(x) والتي تُقرِّب x لأصغر عدد صحيح أكبر من أو يُساوِي x. Math.random() تُعيد قيمة عشوائية من النوع double ضِمْن نطاق يتراوح من ٠ إلى ١. تَحسِب تلك الدالة أعدادًا شبه عشوائية (pseudorandom number) أي أنها ليست عشوائية تمامًا وإنما إلى درجة كافية لغالبية الأغراض. سنكتشف خلال الفصول القادمة أن تلك الدالة لها استخدامات أخرى كثيرة مفيدة. تَستقبِل الدوال (functions) بالأعلى مُعامِلات (parameters) -أي x أو y داخل الأقواس- بأي قيم طالما كانت من النوع العددي أما القيم المُعادة (return value) من غالبيتها فهي من النوع double بغض النظر عن نوع المُعامِل (parameter) باستثناء الدالة Math.abs(x) والتي تَكُون قيمتها المُعادة من نفس نوع المُعامِل x، فإذا كانت x من النوع int، تُعيد تلك الدالة قيمة من النوع int أيضًا وهكذا. على سبيل المثال، تُعيد الدالة Math.sqrt(9) قيمة من النوع double تُساوِي 3.0 بينما تُعيد الدالة Math.abs(9) قيمة من النوع int تُساوِي 9. لا تَستقبِل الدالة Math.random() أي مُعامِلات (parameter)، ومع ذلك لابُدّ من كتابة الأقواس حتى وإن كانت فارغة لأنها تَسمَح للحاسوب بمَعرِفة أنها تُمثِل برنامجًا فرعيًا (subroutine) لا مُتْغيِّرًا (variable). تُعدّ الدالة System.currentTimeMillis() من الصنف System مثالًا آخرًا على برنامج فرعي (subroutine) ليس له أي مُعامِلات (parameters). عندما تُنفَّذ تلك الدالة، فإنها تُعيد الوقت الحالي مُقاس بحساب الفارق بين الوقت الحالي ووقت آخر قياسي بالماضي (بداية عام ١٩٧٠) بوحدة المللي ثانية. تَكُون القيمة المعادة من System.currentTimeMillis() من النوع long (عدد صحيح ٦٤ بت). يُمكِنك اِستخدَام تلك الدالة لحِساب الوقت الذي يَستَغْرِقه الحاسوب لتّنْفيذ مُهِمّة معينة. كل ما عليك القيام به هو أن تُسجِّل كُلًا من الوقت الذي بدأ فيه التّنْفيذ وكذلك الوقت الذي انتهى به ثم تَحسِب الفرق بينهما. للحصول على توقيت أكثر دقة، يُمكِنك اِستخدَام الدالة System.nanoTime() والتي تُعيد الوقت الحالي مُقاس بحساب الفارق بين الوقت الحالي ووقت آخر عشوائي بالماضي بوحدة النانو ثانية. لكن لا تَتَوقَّع أن يَكُون الوقت دقيقًا بحق لدرجة النانوثانية. يُنفِّذ البرنامج بالمثال التالي مجموعة من المهام الحسابية ويَعرِض الوقت الذي يَستَغْرِقه البرنامج لتّنْفيذ كُلًا منها: /** * This program performs some mathematical computations and displays the * results. It also displays the value of the constant Math.PI. It then * reports the number of seconds that the computer spent on this task. */ public class TimedComputation { public static void main(String[] args) { long startTime; // وقت البدء بالنانو ثانية long endTime; // وقت الانتهاء بالنانو ثانية long compTime; // زمن التشغيل بالنانو ثانية double seconds; // فرق الوقت بالثواني startTime = System.nanoTime(); double width, height, hypotenuse; // جوانب المثلث width = 42.0; height = 17.0; hypotenuse = Math.sqrt( width*width + height*height ); System.out.print("A triangle with sides 42 and 17 has hypotenuse "); System.out.println(hypotenuse); System.out.println("\nMathematically, sin(x)*sin(x) + " + "cos(x)*cos(x) - 1 should be 0."); System.out.println("Let's check this for x = 100:"); System.out.print(" sin(100)*sin(100) + cos(100)*cos(100) - 1 is: "); System.out.println( Math.sin(100)*Math.sin(100) + Math.cos(100)*Math.cos(100) - 1 ); System.out.println("(There can be round-off errors when" + " computing with real numbers!)"); System.out.print("\nHere is a random number: "); System.out.println( Math.random() ); System.out.print("\nThe value of Math.PI is "); System.out.println( Math.PI ); endTime = System.nanoTime(); compTime = endTime - startTime; seconds = compTime / 1000000000.0; System.out.print("\nRun time in nanoseconds was: "); System.out.println(compTime); System.out.println("(This is probably not perfectly accurate!"); System.out.print("\nRun time in seconds was: "); System.out.println(seconds); } // نهاية main() } // نهاية الصنف TimedComputation الأصناف (classes) والكائنات (objects) بالإضافة إلى استخدام الأصناف (classes) كحاويات للمُتْغيِّرات والبرامج الفرعية الساكنة (static). فإنها قد تُستخدَم أيضًا لوصف الكائنات (objects). يُعدّ الصنف ضِمْن هذا السياق نوعًا (type) بنفس الطريقة التي تُعدّ بها كلًا من int و double أنواعًا أي يُمكِننا إذًا أن نَستخدِم اسم الصنف للتَصْرِيح (declare) عن مُتْغيِّر (variable). تَحمِل المُتْغيِّرات في العموم نوعًا واحدًا من القيم يَكُون في تلك الحالة عبارة عن كائن (object). ينتمي أي كائن إلى صنف (class) معين يُبلِّغه بنوعه (type)، ويُعدّ بمثابة تجميعة من المُتْغيِّرات والبرامج الفرعية (subroutines) يُحدِّدها الصنف الذي ينتمي إليه الكائن أي تتشابه الكائنات (objects) من نفس الصنف (class) وتحتوي على نفس تجميعة المُتْغيِّرات والبرامج الفرعية. على سبيل المثال، إذا كان لدينا سطح مستو، وأردنا أن نُمثِل نقطة عليه باِستخدَام كائن، فيُمكِن إذًا لذلك الكائن المُمثِل للنقطة أن يُعرِّف مُتْغيِّرين (variables) x و y لتمثيل إحداثيات النقطة. ستُعرِّف جميع الكائنات المُمثِلة لنقطة قيمًا لكُلًا من x و y والتي ستكون مختلفة لكل نقطة معينة. في هذا المثال، يُمكِننا أن نُعرِّف صَنْفًا (class) اسمه Point مثلًا ليُعرِّف (define) البنية المشتركة لجميع الكائنات المُمثِلة لنقطة بحيث تَكُون جميع تلك الكائنات (objects) قيمًا من النوع Point. لنَفْحَص الاستدعاء System.out.println مرة آخرى. أولًا، System هو عبارة عن صَنْف (class) أما out فهو مُتْغيِّر ساكن (static variable) مُعرَّف بذلك الصنف. ثانيًا، يشير المُتْغيِّر System.out إلى كائن (object) من الصَنْف القياسي PrintStream و System.out.println هو الاسم الكامل لبرنامج فرعي (subroutine) مُعرَّف بذلك الكائن. يُمثِل أي كائن من النوع PrintStream مقصدًا يُمكِن طباعة المعلومات من خلاله حيث يَتَضمَّن برنامجًا فرعيًا println يُستخدَم لإرسال المعلومات إلى ذلك المقصد. لاحِظ أن الكائن System.out هو مُجرد مقصد واحد محتمل، فقد تُرسِل كائنات (objects) آخرى من النوع PrintStream المعلومات إلى مقاصد أخرى مثل الملفات أو إلى حواسيب آخرى عبر شبكة معينة. يُمثَل ذلك ما يُعرَف باسم البرمجة كائنية التوجه (object-oriented programming): عندما يَتَوفَّر لدينا مجموعة من الأشياء المختلفة في العموم والتي لديها شيئًا مشتركًا مثل كَوْنها تَعمَل مقصدًا للخَرْج، فإنه يُمكِن اِستخدَامها بنفس الطريقة من خلال برنامج فرعي مثل println. في هذا المثال، يُعبِر الصَنْف PrintStream عن الأمور المُشتركة بين كل تلك الأصناف (objects). تلعب الأصناف (classes) دورًا مزدوجًا وهو ما قد يَكُون مُربِكًا للبعض، ولكن من الناحية العملية تُصمَّم غالبية الأصناف لكي تؤدي دورًا واحدًا منها بشكل رئيسي أو حصري. لا تقلق عمومًا بشأن ذلك حتى نبدأ في التعامل مع الكائنات (objects) بصورة أكثر جديّة بالفصل الخامس. لمّا كانت أسماء البرامج الفرعية (routines) دائمًا متبوعة بقوس أيسر، يَصعُب خَلْطها إذًا مع أسماء المُتْغيِّرات. في المقابل، تُستخدَم أسماء كُلًا من الأصناف (classes) والمُتْغيِّرات (variables) بنفس الطريقة، لذا قد يَكُون من الصعب أحيانًا التَمْييز بينها. في الواقع، تَتَّبِع جميع الأسماء المُعرَّفة مُسْبَقًا بالجافا نَمْط تسمية تبدأ فيه أسماء الأصناف بحروف كبيرة (upper case) بينما تبدأ أسماء المُتْغيِّرات والبرامج الفرعية (subroutines) بحروف صغيرة (lower case). لا يُمثِل ذلك قاعدة صيغة (syntax rule)، ومع ذلك من الأفضل أن تَتَّبِعها أيضًا. كملاحظة عامة أخيرة، يُستخدَم عادةً مصطلح "التوابع (methods)" للإشارة إلى البرامج الفرعية (subroutines) بالجافا. يَعنِي مصطلح "التابع (method)" أن برنامجًا فرعيًا (subroutine) مُعرَّف ضِمْن صنف (object) أو كائن (object). ولأن ذلك يُعدّ صحيحًا لأي برنامج فرعي بالجافا، فإن أيًا منها يُعدّ تابعًا. سنميل عمومًا إلى اِستخدَام المصطلح الأعم "البرنامج الفرعي (subroutine)"، ولكن كان لابُدّ من إعلامك بأن بعض الأشخاص يُفضِّلون اِستخدَام مصطلح "التابع (method)". العمليات على السلاسل النصية من النوع String يُمثِل String صنفًا (class)، وأي قيمة من النوع String هي عبارة عن كائن (object). يَحتوِي أي كائن في العموم على بيانات (data) وبرامج فرعية (subroutines). بالنسبة للنوع String، فإن البيانات مُكوَّنة من متتالية محارف السِلسِلة النصية (string) أما البرامج الفرعية فهي في الواقع مجموعة من الدوال (functions) منها مثلًا الدالة length والتي تَحسِب عدد المحارف (characters) بالسِلسِلة النصية. لنَفْترِض أن advice هو مُتْغيِّر يُشير إلى String، يُمكِننا إذًا أن نُصرِّح عنه ونُسنِد إليه قيمة كالتالي: String advice; advice = "Seize the day!"; الآن، يُمثِل التعبير advice.length() استدعاءً لدالة (function call)، والتي ستُعيد في تلك الحالة تحديدًا عدد محارف السِلسِلة النصية "Seize the day!" أي ستُعيد القيمة ١٤. في العموم، لأي مُتْغيِّر str من النوع String، يُمثِل الاستدعاء str.length() قيمة من النوع int تُساوِي عدد المحارف بالسِلسِلة النصية (string). لا تَستقبِل تلك الدالة (function) أي مُعامِلات (parameters) لأن السِلسِلة النصية المطلوب حِسَاب طولها هي بالفعل قيمة المُتْغيِّر str. يُعرِّف الصَنْف String البرنامج الفرعي length ويُمكِن اِستخدَامه مع أي قيمة من النوع String حتى مع أي سِلسِلة نصية مُجرّدة (string literals) فهي في النهاية ليست سوى قيمة ثابتة (constant value) من النوع String. يُمكِنك مثلًا كتابة برنامج يَحسِب عدد محارف السِلسِلة النصية "Hello World" كالتالي: System.out.print("The number of characters in "); System.out.print("the string \"Hello World\" is "); System.out.println( "Hello World".length() ); يُعرِّف الصَنْف String الكثير من الدوال (functions) نَستعرِض بعضًا منها خلال القائمة التالية: s1.equals(s2): تُوازن تلك الدالة بين السِلسِلتين s1 و s2، وتُعيد قيمة من النوع boolean تُساوِي true إذا كانت s1 و s2 مُكوّنتين من نفس متتالية المحارف، وتُساوِي false إن لم تَكُن كذلك. s1.equalsIgnoreCase(s2): هي دالة من النوع المنطقي (boolean-valued function). مثل الدالة السابقة، تَفْحَص تلك الدالة ما إذا كانت السِلسِلتان s1 و s2 مُكوّنتين من نفس السِلسِلة النصية (string)، ولكنها تختلف عن الدالة السابقة في أن الحروف الكبيرة (upper case) والصغيرة (lower case) تُعدّ متكافئة. فمثلًا، إذا كانت s1 تحتوي على السِلسِلة النصية "cat"، فستُعيد الدالة s1.equals("Cat") القيمة false أما الدالة s1.equalsIgnoreCase("Cat") فستُعيد القيمة true. s1.length(): هي دالة من النوع الصحيح (integer-valued function)، وتُعيد قيمة تُمثِل عدد محارف السِلسِلة النصية s1. s1.charAt(N): حيث N هو عدد صحيح. تُعيد تلك الدالة قيمة من النوع char تُساوي قيمة محرف السِلسِلة النصية برقم المَوضِع N. يبدأ الترقيم من الصفر، لذا يُمثِل استدعاء الدالة s1.charAt(0) المحرف الأول أما استدعاء الدالة s1.charAt(1) فيُمثِل المحرف الثاني، وهكذا حتى نَصِل إلى المَوضِع الأخير s1.length() - 1. مثلًا، يُعيد "cat".charAt(1) القيمة 'a'. لاحظ أنه إذا كانت قيمة المُعامِل المُمرَّرة أقل من صفر أو أكبر من أو تُساوِي s1.length، فسيَحدُث خطأ. s1.substring(N,M): حيث N و M هي أعداد صحيحة. تُعيد تلك الدالة قيمة من النوع String مُكوَّنة من محارف السلسلة النصية بالمواضع N و N+1 و .. حتى M-1 (المحرف بالمَوضِع M ليس مُضمَّنًا). تُعدّ القيمة المُعادة "سلسلة جزئية (substring)" من السِلسِلة الأصلية s1. يُمكِنك ألا تُمرِّر قيمة للمُعامِل M كالتالي s1.substring(N) وعندها ستُعيد تلك الدالة سِلسِلة جزئية من s1 مُكوَّنة من محارف السلسلة النصية بدايةً من N إلى النهاية. s1.indexOf(s2): تُعيد عددًا صحيحًا. إذا كانت s2 هى سِلسِلة جزئية (substring) من s1، تُعيد تلك الدالة مَوضِع المحرف الأول من السِلسِلة الجزئية s2 بالسِلسِلة الأصلية s1. أما إذا لم تَكُن جزءًا منها، فإنها تُعيد القيمة -١. تَتَوفَّر أيضًا الدالة s1.indexOf(ch) حيث ch عبارة عن محرف من النوع char، وتُستخدَم للبحث عنه بسِلسِلة نصية s1. تُستخدَم أيضًا الدالة s1.indexOf(x,N) لإيجاد أول حُدوث من x بعد موضع N بسِلسِلة نصية، وكذلك الدالة s1.lastIndexOf(x) لإيجاد آخر حُدوث من x بسِلسِلة نصية s1. s1.compareTo(s2): هي دالة من النوع العددي الصحيح (integer-valued function) تُوازن بين سلسلتين نصيتين s1 و s2، فإذا كانت السِلسِلتان متساويتين، تُعيد الدالة القيمة ٠ أما إذا كانت s1 أقل من s2، فإنها تُعيد عددًا أقل من ٠، وأخيرًا إذا كانت s1 أكبر من s2، فإنها تُعيد عددًا أكبر من ٠. إذا كانت السلسلتان s1 و s2 مُكوّنتين من حروف صغيرة (lower case) فقط أو حروف كبيرة (upper case) فقط، فإن الترتيب المُستخدَم بموازنات مثل "أقل من" أو "أكبر من" تُشير إلى الترتيب الأبجدي. أما إذا تَضمَّنتا محارف آخرى فسيَكُون الترتيب أكثر تعقيدًا. تَتَوفَّر دالة آخرى مشابهة هي s1.compareToIgnoreCase(s2). s1.toUpperCase(): هي دالة من النوع النصي (String-valued function) تُعيد سِلسِلة نصية جديدة تُساوِي s1 ولكن بَعْد تَحْوِيل أي حرف صغير (lower case) بها إلى حالته الكبيرة (upper case). فمثلًا، تُعيد الدالة "Cat".toUpperCase() السِلسِلة النصية "CAT". تَتَوفَّر دالة آخرى مشابهة هي s1.toLowerCase. s1.trim(): هي دالة من النوع النصي (String-valued function) تُعيد سِلسِلة نصية جديدة (string) تساوي s1 ولكن بَعْد حَذْف أي محارف غَيْر مطبوعة -كالمسافات الفارغة (spaces)- من بداية السِلسِلة النصية (string) ونهايتها. فمثلًا إذا كانت s1 هي السِلسِلة النصية "fred "، فستُعيد الدالة s1.trim() السِلسِلة "fred" بدون أي مسافات فارغة بالنهاية. لا تُغيِّر الدوال s1.toUpperCase() و s1.toLowerCase() و s1.trim() قيمة s1، وإنما تُنشِئ سِلسِلة نصية جديدة (string) تُعَاد كقيمة للدالة يُمكِن اِستخدَامها بتَعْليمَة إِسْناد (assignment statement) مثلًا كالتالي smallLetters = s1.toLowerCase();. إذا كنت تُريد تَعْدِيل قيمة s1 نفسها، فيُمكِنك ببساطة أن تَستخدِم تَعْليمَة الإِسْناد التالية s1 = s1.toLowerCase();. يُمكِنك أن تَستخدِم عَامِل الزيادة (plus operator) + لضم (concatenate) سِلسِلتين نصيتين (strings)، وينتج عنهما سِلسِلة نصية جديدة مُكوَّنة من كل محارف السِلسِلة النصية الأولى متبوعة بكل محارف السِلسِلة النصية الثانية. فمثلًا، يؤول التعبير "Hello" + "World" إلى السِلسِلة النصية "HelloWorld". إذا كنت تُريد مسافة فارغة (space) بين الكلمات، فعليك أن تُضيفها إلى أي من السِلسِلتين كالتالي Hello " + "World". لنَفْترِض أن name هو مُتْغيِّر من النوع String يُشير إلى اسم مُستخدِم البرنامج. يُمكِنك إذًا أن تُرحِّب به بتّنْفيذ التَعْليمَة التالية: System.out.println("Hello, " + name + ". Pleased to meet you!"); يُمكِنك حتى أن تَستخدِم نفس العَامِل + لكي تَضُمُّ (concatenate) قيم من أي نوع آخر إلى سِلسِلة نصية (string). ستَتَحوَّل تلك القيم إلى سِلسِلة نصية (string) أولًا كما يَحدُث عندما تَطبَعها إلى الخَرْج القياسي (standard output) ثم ستُضَمّ إلى السِلسِلة النصية الآخرى. فمثلًا، سيؤول التعبير "Number" + 42 إلى السِلسِلة النصية "Number42". اُنظر التعليمات التالية: System.out.print("After "); System.out.print(years); System.out.print(" years, the value is "); System.out.print(principal); يُمكِنك اِستخدَام التَعْليمَة المُفردة التالية بدلًا من التَعْليمَات بالأعلى: System.out.print("After " + years + " years, the value is " + principal); تُعدّ النسخة الثانية أفضل بكثير ويُمكِنها أن تَختصِر الكثير من الأمثلة التي عَرَضَناها خلال هذا الفصل. مقدمة إلى التعدادات (enums) تُوفِّر الجافا ثمانية أنواع أساسية مَبنية مُسْبَقًا (built-in primitive types) بالإضافة إلى مجموعة ضخمة من الأنواع المُعرَّفة باِستخدَام أصناف (classes) مثل النوع String، ولكنها ما تزال غَيْر كافية لتغطية جميع المواقف المُحتمَلة والتي قد يحتاج المُبرمج إلى التَعامُل معها. لهذا، وبالمثل من غالبية اللغات البرمجية الآخرى، تَمنَحك الجافا القدرة على إنشاء أنواع (types) جديدة، والتي غالبًا ما تَكُون بهيئة أصناف (classes)، وهو ما سنَتَعلَّمه بالفصل الخامس. تَتَوفَّر مع ذلك طريقة آخرى وهي التعدادات (enumerated types - enums) سنناقشها خلال هذا القسم. تقنيًا، يُعدّ أي تعداد (enum) نوعًا خاصًا من صَنْف (class)، ولكن هذا غَيْر مُهِمّ في الوقت الحالي. سنَفْحَص خلال هذا القسم التعدادات بصيغتها البسيطة (simplified form)، والمُستخدَمة عمليًا في غالبية الحالات. أي تعداد (enum) هو عبارة عن نوع (type) يَتَضمَّن قائمة ثابتة مُكوَّنة من قيمه المُحتمَلة تُخصَّص عند إِنشاء نوع التعداد. بشكل ما، تُشبه أنواع التعداد (enum) النوع boolean والتي قيمه المحتملة هي true و false فقط. لكن لاحِظ أن النوع boolean هو نوع أساسي (primitive type) أما أنواع التعداد (enums) فليست كذلك. يُكْتَب تعريف تعداد (enum definition) معين بالصيغة التالية: enum <enum-type-name> { <list-of-enum-values> } لا يُمكِنك كتابة ذلك التعريف (definition) داخل أي برنامج فرعي (subroutine)، لذا قد تَضَعه خارج البرنامج main() أو حتى بملف آخر مُنفصِل. تُشير إلى اسم نوع التعداد (enum) بنفس الطريقة التي تُشير بها كلمات مثل "boolean" و "String" إلى النوع boolean والنوع String على الترتيب. يُمكِننا أن نَستخدِم أي مُعرّف بسيط (simple identifier) كاسم لنوع التعداد. أما فتُشير إلى قيم التعداد المُحتمَلة وتَتَكوَّن من قائمة من المُعرّفات (identifiers) يَفصِل بينها فاصلة (comma). يُعرِّف المثال التالي نوع تعداد (enum) اسمه هو Season وقيمه المُحتمَلة هي أسماء فصول السنة الأربعة: enum Season { SPRING, SUMMER, FALL, WINTER } عادةً ما تُكْتَب القيم المُحتمَلة لنوع التعداد (enum) بحروف كبيرة (upper case)، لكنه ليس أمرًا ضروريًا فهو ليس ضِمْن قواعد الصيغة (syntax rules). تُعدّ القيم المُحتمَلة لتعداد معين ثوابتًا (constants) لا يُمكِن تعديلها، وعادةً ما يُطلَق عليها اسم ثوابت التعداد (enum constants). لأن ثوابت التعداد لنوع مثل Season مُعرَّفة داخله، لابُدّ من أن نُشير إليها باستخدام أسماء مركبة مُكوَّنة من اسم النوع المُتْضمِّن ثم نقطة ثم اسم ثابت التعداد كالتالي: Season.SPRING و Season.SUMMER و Season.FALL و Season.WINTER. بمُجرّد إنشاء نوع تعداد (enum)، تستطيع أن تَستخدِمه للتَصْرِيح (declare) عن مُتْغيِّرات بنفس الطريقة التي تُصرِّح بها عن مُتْغيِّرات من أي أنواع آخرى. يمكنك مثلًا أن تُصرِّح عن مُتْغيِّر اسمه vacation من النوع Season باستخدام التَعْليمَة التالية: Season vacation; بعد التَصْرِيح عن مُتْغيِّر، يُمكِنك أن تُسنِد (assign) إليه قيمة باستخدام تَعْليمَة إِسْناد (assignment statement). يُمكن لتلك القيمة -على يمين عامل الإِسْناد- أن تَكُون أي من ثوابت التعداد من النوع Season. تَذَكَّر أنه لابُدّ من اِستخدَام الاسم الكامل لثابت التعداد بما في ذلك اسم النوع Season. اُنظر المثال التالي: vacation = Season.SUMMER; يُمكِنك أن تَستخدِم تَعْليمَة طباعة عادية مثل System.out.print(vacation) لطباعة قيمة تعداد، وتَكُون القيمة المطبوعة عندها عبارة عن اسم ثابت التعداد (enum constant) بدون اسم نوع التعداد أي سيَكُون الخَرْج في هذه الحالة هو "SUMMER". لأن التعداد (enum) هو عبارة عن صَنْف (class) تقنيًا، فلابُدّ إذًا من أن تَكُون قيم التعداد (enum value) عبارة عن كائنات (objects) وبالتالي يُمكِنها أن تَحتوِي على برامج فرعية (subroutines). أحد البرامج الفرعية المُعرَّفة بأي قيمة تعداد من أي نوع هي ordinal() والتي تُعيد العدد الترتيبي (ordinal number) لتلك القيمة بقائمة القيم المُحتمَلة لذلك التعداد. يُشير العدد الترتيبي ببساطة إلى مَوضِع القيمة بالقائمة، فمثلًا، يُعيد Season.SPRING.ordinal() قيمة من النوع int تُساوِي صفر أما Season.SUMMER.ordinal() فيُعيد ١ أما Season.FALL.ordinal() فيُعيد ٢ وأخيرًا Season.WINTER.ordinal() يُعيد ٣. يمكنك بالطبع أن تَستخدِم التابع (method) ordinal() مع مُتْغيِّر من النوع Season مثل vacation.ordinal(). يُساعد اِستخدَام أنواع التعداد (enums) على كتابة شيفرة مقروءة؛ لأنك ستَستخدِم بطبيعة الحال أسماءً ذات مغزى لقيم التعداد. علاوة على ذلك، يَفْحَص المُصرِّف (compiler) ما إذا كانت القيم المُسنَدة لمُتْغيِّر تعداد معين قيمًا صالحة أم لا مما يُجنِّبك أنواعًا مُحدَّدة من الأخطاء. وفي العموم، ينبغي أن تُدرك أهمية التعدادات (enums) بعدّها الطريقة الأولى لإنشاء أنواع (types) جديدة. يُوضِح المثال التالي كيفية اِستخدَام أنواع التعداد ضِمْن برنامج كامل: public class EnumDemo { // عرف نوعين تعداد خارج البرنامج main enum Day { SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY } enum Month { JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC } public static void main(String[] args) { Day tgif; // صرح عن متغير من النوع Day Month libra; // صرح عن متغير من النوع Month tgif = Day.FRIDAY; // أسند قيمة من النوع Day إلى tgif libra = Month.OCT; // أسند قيمة من النوع Month إلى libra System.out.print("My sign is libra, since I was born in "); // قيمة الخرج ستكون OCT System.out.println(libra); System.out.print("That's the "); System.out.print( libra.ordinal() ); System.out.println("-th month of the year."); System.out.println(" (Counting from 0, of course!)"); System.out.print("Isn't it nice to get to "); // قيمة الخرج ستكون: FRIDAY System.out.println(tgif); System.out.println( tgif + " is the " + tgif.ordinal() + "-th day of the week."); } } كما ذَكَرنا مُسْبَقًا، يُمكِنك أن تُعرِّف التعدادات (enum) بملفات مُنفصِلة. لاحظ أن البرنامج SeparateEnumDemo.java هو نفسه البرنامج EnumDemo.java باستثناء أن أنواع التعداد المُستخدَمة قد عُرِّفت بملفات مُنفصلة هي Month.java و Day.java. ترجمة -بتصرّف- للقسم Section 3: Strings, Classes, Objects, and Subroutines من فصل Chapter 2: Programming in the Small I: Names and Things من كتاب Introduction to Programming Using Java. اقرأ أيضًا كيف تتعلم البرمجة
-
عند تخزين بياناتٍ معينة بملفٍ معين أو نقلها خلال شبكةٍ معينة، سيكون من الضروري تمثيلها بطريقةٍ ما بشرط أن تَسمَح تلك الطريقة بإعادة بناء البيانات لاحقًا عند قراءة الملف أو عند تَسلُّم البيانات. رأينا بالفعل أسبابًا جيدةً تدفعنا لتفضيل التمثيلات النصية المُعتمِدة على المحارف، ومع ذلك هناك طرائقٌ كثيرةٌ لتمثيل مجموعةٍ معينةٍ من البيانات تمثيلًا نصيًا. سنُقدِّم بهذا المقال لمحةً مختصرةً عن أحد أكثر أنواع تمثيل البيانات 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 اكتب برنامجك الأول بلغة جافا
-
تتكوَّن جميع برامج واجهات المُستخدِم الرسومية 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 في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا
-
تضيف الخيوط 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 في جافا كيفية إنشاء عدة خيوط وفهم التزامن في جافا كتابة أصناف وتوابع معممة في جافا
-
يمكن للحواسيب إنجاز عدة مهامٍ مختلفة بنفس الوقت؛ فإذا كان الحاسوب مُكوَّنًا من وحدة معالجةٍ واحدة، فإنه لا يستطيع حرفيًا إنجاز شيئين بنفس الوقت؛ ولكن ما يزال بإمكانه تحويل انتباهه بين عدة مهامٍ باستمرار. تتكوَّن معظم الحواسيب العصرية من أكثر من مجرد وحدة معالجةٍ واحدة، وبإمكانها تنفيذ عدة مهامٍ بنفس الوقت حرفيًا، وغالبًا ما تكون زيادة قدرة المعالجة للحواسيب ناتجةً عن إضافة عددٍ أكبر من المعالجات إليها بدلًا من زيادة سرعة كل معالجٍ على حدى. حتى تتمكّن البرامج من تحقيق الاستفادة القصوى من الحواسيب متعددة المعالجات، لا بُدّ أن تكون مُبرمَجَةً على التوازي 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 واستعمالها في تطبيقات جافا معالجة الملفات في جافا تواصل تطبيقات جافا عبر الشبكة
-
لا تُعدّ الشبكات -بقدر ما يَهُمّ البرامج- أكثر من مجرد مصدرٍ يُمكِن قراءة البيانات منه، أو مقصدٍ يُمكِن إرسال البيانات إليه. يُسطِّح ذلك الأمور إلى درجةٍ كبيرة؛ فليس التعامل مع الشبكات بالتأكيد بنفس سهولة التعامل مع الملفات مثلًا، وتُمكِّنك لغة جافا مع ذلك من إجراء الاتصالات الشبكية باستخدام مجاري تدفق 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. اقرأ أيضًا المقال السابق: معالجة الملفات في جافا الإنترنت وما بعده وعلاقته بجافا واجهة المستخدم الحديثة في جافا فهم نموذج التواصل بين المضيفين في الشبكات
-
سنفحص خلال هذا القسم بعض الأمثلة البرمجية على تعامل البرامج مع الملفات باستخدام التقنيات المُوضحَّة التي عرضناها في المقالين السابقين، مقال قنوات الدخل والخرج وعمليتي القراءة والكتابة في جافا ومقال مدخل إلى التعامل مع الملفات في جافا. نسخ الملفات سنناقش الآن برنامج سطر أوامرٍ بسيط لنسخ الملفات، حيث تُعدّ عملية نسخ الملفات واحدةً من أكثر العمليات شيوعًا، ولهذا تُوفِّر جميع أنظمة التشغيل أمرًا مُخصَّصًا لتلك العملية بالفعل، ولكن ما يزال من المفيد من الناحية التعليمية أن ننظر إلى طريقة تنفيذ ذلك باستخدام برنامج جافا. تتشابه غالبية العمليات على الملفات مع عملية نسخ ملف، باستثناء اختلاف طريقة معالجتها لبيانات الملف المُدْخَلة قبل إعادة كتابتها إلى ملف الخرج؛ أي يُمكِن كتابة برامج لكل تلك العمليات بنفس الكيفية تقريبًا. تعرَّضنا في فصل المعاملات (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 في جافا
-
ظل البيانات والبرامج المُخزَّنة بذاكرة الحاسوب الرئيسية 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) في جافا
-
يُمكِننا التفكير بمصفوفةٍ مكونةٍ من N عنصر كما لو كانت طريقةً لربط عنصرٍ معينٍ بالأعداد الصحيحة 0، و 1، وصولًا إلى N-1. إذا كان i أحد تلك الأعداد الصحيحة، يُمكِننا استرجاع get القيمة المرتبطة بالعدد i، كما يُمكِننا وضع put عنصرٍ جديدٍ في الموضع i، حيث تُعرِّف العمليتان get و put ماهية المصفوفة. تُعدّ الخرائط maps نوعًا عامًا من المصفوفة؛ حيث يُمكِننا تعريفها باستخدام عمليتي get و put، إلا أنّ هذه العمليات لا تَكون مُعرَّفةً للأعداد الصحيحة 0، و 1، وصولًا إلى N-1، وإنما تَكون مُعرَّفةً لكائناتٍ objects عشوائية من نوع T، كما يرتبط بكل كائنٍ منها كائنٌ من نوعٍ آخر مختلف S. تستخدِم بعض لغات البرمجة مصطلح المصفوفة الارتباطية associative array بدلًا من مصطلح الخريطة، كما تَستخدِم نفس الترميز مع المصفوفات العادية والارتباطية. فقد ترى ترميزًا، مثل A["fred"] للإشارة إلى العنصر المرتبط بالسلسلة النصية "fred" بمصفوفةٍ ارتباطية A. لا تَستخدِم جافا نفس الترميز العادي مع الخرائط، ولكن الفكرة تبقى واحدةً بالنهاية؛ حيث تُشبه الخريطة أي مصفوفة، ولكن تكون فهارسها indices كائناتٍ objects وليس أعدادًا صحيحة. يُطلَق على الكائن الذي يعمل مثل فهرسٍ index ضمن خريطة اسم مفتاح key؛ أما العنصر المرتبط بالمفتاح، فيُطلَق عليه اسم قيمة value. يُقابل كل مفتاحٍ قيمةً واحدةً على الأكثر، ولكن يُمكِن لنفس القيمة الارتباط بعدة مفاتيحٍ مختلفة. يُمكنك أن تنظر للخريطة على أنها مجموعةٌ من الارتباطات associations، حيث يُمثِّل كل ارتباطٍ زوج مفتاحٍ وقيمة key/value pair. واجهة تمثيل الخرائط تُوفِّر جافا الواجهة java.util.Map لتمثيل الخرائط، حيث تتضمَّن تلك الواجهة التابعين get و put بالإضافة إلى عدة توابعٍ أخرى للعمل مع الخرائط في العموم. تُعدّ الواجهة Map<K,V> من الأنواع ذات المعاملات غير محدَّدة النوع parameterized، وتَملُك تحديدًا معاملي نوع، الأول هو K، والثاني هو V؛ حيث يُخصِّص K نوع الكائن المُستخدَم مثل مفتاح بالخريطة؛ بينما يُخصِّص V نوع الكائن المُستخدَم مثل قيمة. على سبيل المثال، تَربُط خريطةٌ من النوع Map<Date,Button> قيمًا من النوع Button بمفاتيحٍ من النوع Date؛ بينما تَربُط خريطةٌ من النوع Map<String,String> قيمًا بمفاتيحٍ من نفس النوع String. نستعرِض فيما يلي بعضًا من التوابع المتاحة لمُتغيّر map يُمثِل خريطةً من النوع Map<K,V> لنوعين K و V: map.get(key): يُعيد كائنًا من النوع V يُمثِّل القيمة المرتبطة بالمفتاح key؛ ويُعيد القيمة الفارغة null إذا لم تحتوي الخريطة على قيمةٍ مقابلةٍ للمفتاح المُمرَّر، أو في حالة كانت القيمة الفارغة مرتبطةً صراحةً بذلك المفتاح. يُشبه كثيرًا استدعاء map.get(key) لخريطة map استخدام A[key] مع مصفوفة A، ولكن لا يحدث اعتراضٌ exception من النوع IndexOutOfBoundsException في حالة الخرائط. map.put(key,value): يَربُط قيمة value المُمرَّرة مع المفتاح key، حيث يجب أن يكون key من النوع K، وأن يَكون value من النوع V. إذا كانت الخريطة تَربُط بالفعل قيمةً ما مع نفس المفتاح المُخصَّص، يَستبدِل التابع القيمة الجديدة بالقيمة القديمة، ويُشبه ذلك الأمر A[key] = value المُستخدَم مع المصفوفات. map.putAll(map2): إذا كانت map2 خريطةً أخرى من النوع Map<K,V>، فسينسخ التابع جميع القيم الموجودة بها إلى map. map.remove(key): إذا كانت map تَربُط قيمةً معينةً بالمفتاح key، فسيحذف التابع هذا الارتباط من الخريطة map. map.containsKey(key): يُعيد القيمة المنطقية true إذا كانت الخريطة map تَربُط قيمةً معينةً بالمفتاح المُمرَّر key. map.containsValue(value): يُعيد القيمة المنطقية true إذا كانت الخريطة map تَربُط القيمة المُمرَّرة value بأي مفتاحٍ ضمن الخريطة. map.size(): يُعيد قيمةً من النوع int تُمثِّل عدد الارتباطات بين المفاتيح والقيم الموجودة بالخريطة map. map.isEmpty(): يُعيد القيمة المنطقية true إذا كانت الخريطة map فارغةً، أي لا تَربُط أي قيمٍ بأي مفاتيح. map.clear(): يحذف جميع الارتباطات الموجودة بالخريطة map. يُعدّ التابعان put و get أكثر التوابع استخدامًا من بين التوابع الأخرى المُعرَّفة بالواجهة Map، حيث يقتصر استخدام الكثير من التطبيقات للخرائط على هذين التابعين فقط دون غيرهما، ويكون عندها استخدام الخريطة بنفس سهولة استخدام أي مصفوفةٍ عادية. تُوفِّر جافا الصنفين TreeMap<K,V> و HashMap<K,V> المُنفِّذين للواجهة Map<K,V>، حيث تُخزِّن الخرائط من الصنف TreeMap ارتباطات المفاتيح بالقيم key/value associations ضمن شجرة tree، وتكون الارتباطات مُرتَّبةً بحسب مفاتيحها. يَعنِي ذلك ضرورة إمكانية موازنة مفتاحٍ بآخر، أي يجب أن تُنفِّذ أصناف المفاتيح الواجهة Comparable<K>، أو أن نُوفِّر كائنًا من النوع Comparator لإجراء الموازنة من خلال تمريره معاملًا لباني الصنف TreeMap. تستخدم الخرائط من النوع TreeMap التابع compareTo()، أو compare() كما هو الحال مع الأطقم من النوع TreeSet للموازنة بين مفتاحين، وهو ما قد يَتسبَّب بنتائجٍ غير مُتوقَّعة إذا لم يَكُن التابع compareTo() مُعرَّفٌ بما يتوافق مع مفهوم التساوي. لا تُخزِّن الخرائط من النوع HashMap الارتباطات وفقًا لأي ترتيبٍ معين، ولذلك ليس من الضروري لأصناف المفاتيح المُستخدَمة أن تكون قابلة للموازنة، لكن يتوجب عليها تعريف التابعين equals() و hashCode() تعريفًا ملائمًا، وهو ما تَضمَنه غالبية أصناف جافا القياسية. تُعدّ غالبية العمليات على الخرائط من الصنف HashMap أكثر كفاءةً عمومًا بالموازنة مع نظيراتها بالصنف TreeMap، لذلك اِستخدِم الصنف HashMap، خاصةً إذا كان استخدامك للخريطة مقتصرًا على التابعين put و get؛ واِستخدِم الصنف TreeMap إذا كنت تحتاج إلى خاصية الترتيب. لنفحص الآن مثالًا على استخدام الخرائط. تعرَّضنا في مقال البحث والترتيب في المصفوفات Array في جافا للصنف PhoneDirectory المُستخدَم لربط أرقام الهواتف بأسماء الأشخاص، حيث يُعرِّف ذلك الصنف العمليتين التاليتين: addEntry(name,number) getNumber(name) حيث name و number من النوع String. يشبه الصنف PhoneDirectory خريطةً يؤدي تابعيها addEntry و getNumber دور عمليتي put و get على الترتيب.، ولا نُعرِّف عادةً بأي تطبيقٍ حقيقي مثل ذلك الصنف، وإنما نَستخدِم ببساطةٍ خريطةً من النوع Map<String,String> على النحو التالي: Map<String,String> directory = new TreeMap<>(); لاحِظ أننا استخدمنا الصنف TreeMap حتى تكون أرقام الهواتف مُرتَّبةً بحسب أسماء الأشخاص، ويُمكِننا الآن ببساطة إضافة رقم هاتف إلى الخريطة باستدعاء directory.put(name,number) أو استرجاع رقم الهاتف المرتبط باسمٍ معينٍ باستدعاء directory.get(name). العروض والأطقم الجزئية والخرائط الجزئية لا تُعدّ الخرائط من النوع Map تجميعاتٍ من النوع Collection، لعدم تنفيذ الخرائط جميع العمليات المُعرَّفة بالتجميعات.لا تحتوي الخرائط مثلًا على مُكرِّرات iterators، ولكن قد نحتاج في بعض الأحيان إلى المرور عبر جميع الارتباطات الموجودة ضمن خريطةٍ معينة، وهو ما تُوفِّره جافا لحسن الحظ. بفرض أن map مُتغيّرٌ من النوع Map<K,V>، فسيُعيد التابع التالي طقمًا يحتوي على جميع الكائنات المُمثِلة لمفاتيح الارتباطات ضمن الخريطة map: map.keySet() تَكون القيمة المعادة كائنًا مُنفِّذًا للواجهة Set<K>، تُمثِّل عناصره مفاتيح الخريطة. قد تظن أن التابع keySet() يُنشِئ طقمًا جديدًا، ويُضيف إليه جميع مفاتيح الخريطة، ثم يُعيده، ولكن هذا غير صحيح؛ فليس الكائن الذي يُعيده الاستدعاء map.keySet() كائنًا مستقلًا، وإنما هو بمثابة عرض view للكائنات الفعلية المُخزَّنة بالخريطة. على الرغم من تنفيذ العرض للواجهة Set<K>، إلا إنه يُنفِّذها بحيث تشير التوابع المُعرَّفة ضمنه إلى مفاتيح الخريطة مباشرةً. إذا حذفت مفتاحًا من عرضٍ على سبيل المثال، فسيُحذف أيضًا مع قيمته value المرتبط بها من الخريطة. في المقابل، لا يُمكِنك إضافة كائنٍ إلى عرض؛ لأن عملية إضافة مفتاح بدون تخصيص قيمته المرتبط بها لا يكون لها معنى. بناءً على ما سبق، يَعمَل التابع map.keySet() بكفاءةٍ عاليةٍ حتى مع الخرائط الكبيرة. إذا كان لديك طقمٌ من النوع Set، يُمكِنك بسهولةٍ الحصول على مُكرّرٍ من النوع Iterator، واستخدامه للمرور عبر جميع عناصر ذلك الطقم واحدًا تلو الآخر؛ وتستطيع كذلك استخدِام مُكرِّرٍ للطقم المُمثِّل لمفاتيح خريطة للمرور عبر جميع الارتباطات الموجودة بها. فإذا كانت map خريطةً من النوع Map<String,Double>، يُمكِننا كتابة ما يَلي: Set<String> keys = map.keySet(); // The set of keys in the map. Iterator<String> keyIter = keys.iterator(); System.out.println("The map contains the following associations:"); while (keyIter.hasNext()) { String key = keyIter.next(); // استرجع المفتاح التالي Double value = map.get(key); // استرجع قيمة ذلك المفتاح System.out.println( " (" + key + "," + value + ")" ); } أو قد نتجنَّب الاستخدام الصريح للمُكرّر باستخدام حلقة التكرار for-each على النحو التالي: System.out.println("The map contains the following associations:"); for ( String key : map.keySet() ) { // "for each key in the map's key set" Double value = map.get(key); System.out.println( " (" + key + "," + value + ")" ); } إذا كانت map من النوع TreeMap، تكون مفاتيحها مُرتّبةً بالطقم، ويَمُرّ المُكرِّر بناءً على ذلك على المفاتيح بحسب ترتيبها التصاعدي؛ أما إذا كانت من النوع HashMap، يمر بها المُكرِّر مرورًا عشوائيًا غير مُتوقَّع. تُعرِّف الواجهة Map عرضين views آخرين. إذا كان map مُتغيِّرًا من النوع Map<K,V>، سيعيد التابع التالي تجميعةً من النوع Collection<V> تحتوي على جميع قيم الارتباطات المُخزَّنة بالخريطة: map.values() نظرًا لأن الخريطة قد تَربُط نفس القيمة بأكثر من مجرد مفتاحٍ واحد، كان من الضروري أن تَكون القيمة المعادة من النوع Collection وليس من النوع Set؛ لأن الأول قادرٌ على تخزين عناصرٍ مُكرَّرة بخلاف الثاني. ألقِ نظرةً على التابع التالي، الذي يُعيد طقمًا يحتوي على جميع الارتباطات الموجودة بالخريطة. map.entrySet() لاحِظ أن عناصر الطقم هي كائناتٌ تنتمي للواجهة Map.Entry<K,V> المُعرَّفة مثل واجهةٍ ساكنة static nested داخل الواجهة Map<K,V>، ولهذا يَحتوِي اسمها على نقطة، وهذا يَعنِي أن القيمة المُعادة من التابع map.entrySet() هي من النوع Set<Map.Entry<K,V>>. في تلك الحالة، يكون معامل النوع type parameter ذاته نوعًا ذا معاملات غير محدَّدة النوع parameterized type. قد يبدو ذلك مُربِكًا في البداية، ولكنه يَعنِي ببساطة أن عناصر الطقم هي نفسها من النوع Map.Entry<K,V>. لا تختلف المعلومات المُخزَّنة بالطقم المُعاد من استدعاء map.entrySet() عن تلك المُخزَّنة بالخريطة ذاتها، حيث يُوفِّر الطقم فقط عرضًا مختلفًا لنفس المعلومات، كما يُوفِّر بعض العمليات الآخرى. يَحتوِي كل كائنٍ من النوع Map.Entry على زوج مفتاح/قيمة، ويُعرِّف التابعين getKey() و getValue() لاسترجاعهما، كما يُعرِّف التابع setValue(value) لضبط القيمة. عند استدعاء التابع setValue على كائنٍ من النوع Map.Entry، تُعدَّل قيمته بالخريطة أيضًا كما لو كنا قد استدعينا التابع put المُعرَّف بالخريطة. يُمكِننا استخدام طقم الارتباطات المُعاد من التابع لطباعة جميع القيم والمفاتيح الموجودة بالخريطة، ويُعدّ هذا أكثر كفاءةً من استخدام طقم المفاتيح لطباعة نفس المعلومات (كما فعلنا بالمثال السابق)؛ لأننا لن نضطّر لاستدعاء التابع get() لمعرفة القيمة المرتبطة بكل مفتاح. تَنفِّذ الشيفرة التالية ذلك بفرض أن map خريطةٌ من النوع Map<String,Double>: Set<Map.Entry<String,Double>> entries = map.entrySet(); Iterator<Map.Entry<String,Double>> entryIter = entries.iterator(); System.out.println("The map contains the following associations:"); while (entryIter.hasNext()) { Map.Entry<String,Double> entry = entryIter.next(); String key = entry.getKey(); // استرجع المفتاح من entry Double value = entry.getValue(); // استرجع القيمة System.out.println( " (" + key + "," + value + ")" ); } أو قد نَستخدِم حلقة التكرار for-each لشيفرةٍ أكثر وضوحًا: System.out.println("The map contains the following associations:"); for ( Map.Entry<String,Double> entry : map.entrySet() ) { System.out.println( " (" + entry.getKey() + "," + entry.getValue() + ")" ); } يُعدّ هذا مثالًا جيدًا على استخدام var للتصريح عن المتغيرات (انظر مقال مفهوم التصريحات (declarations) في جافا)، ويُمكِّننا هذا من كتابة الشيفرة على النحو التالي: var entries = map.entrySet(); var entryIter = entries.iterator(); System.out.println("The map contains the following associations:"); while (entryIter.hasNext()) { . . . ملاحظة: تتطلَّب تلك الشيفرة الإصدار 10 من جافا على الأقل. تُستخدَم العروض بأماكنٍ أخرى غير الخرائط، حيث تُعرِّف الواجهة List<T> مثلًا قائمةً جزئيةً sublist مثل عرضٍ view لجزءٍ من القائمة الأصلية. بفرض أن list تُنفِّذ الواجهة List<T>، ألقِ نظرةً على الشيفرة التالية: list.subList( fromIndex, toIndex ) حيث أن fromIndex و toIndex أعدادٌ صحيحة. يعيد التابع عرضًا يُمثِل ذلك الجزء من القائمة المُتضمِّن للعناصر الواقعة بين الموضعين fromIndex و toIndex، متضمنًا الأول دون الثاني، مما يَسمَح بإجراء أيٍّ من العمليات المُعرَّفة بالقوائم على جزءٍ معينٍ من قائمة. ليست القوائم الجزئية sublists قوائمًا مستقلةً؛ أي أنه في حال إجراء أي تعديلٍ عليها، فسيُنفَّذ أيضًا على القائمة الأصلية. يُمكِننا كذلك الحصول على عرضٍ لتمثيل طقمٍ جزئي subset من طقمٍ معين. إذا كان set طقمًا من النوع TreeSet<T>، فسيعيد الاستدعاء التالي: 7set.subSet(fromElement,toElement) 7 طقمًا من النوع Set<T> يحتوي على جميع عناصر الطقم set الواقعة بين fromElement و toElement. يجب أن يكون المعاملان fromElement و toElement كائنين من النوع T. فإذا كان words طقمًا من النوع TreeSet<String> على سبيل المثال، وكانت جميع عناصره سلاسلًا نصيةً مُكوَّنةً من أحرفٍ أبجدية بحالةٍ صغيرة lower case، فسيحتوي الطقم الجزئي subset المُعاد من الاستدعاء words.subSet("m","n") على جميع عناصر الطقم الأصلي البادئة بالحرف "m". يُعدّ الطقم الجزئي عرضًا view لجزءٍ معينٍ من الطقم الأصلي، حيث لا يتضمَّن إنشاءه نَسْخًا لأي عنصرٍ من العناصر الأصلية؛ أي إذا عدَّلت الطقم الجزئي بإضافة عناصرٍ إليه أو بحذفها، ستُعدَّل عناصر الطقم الأصلي أيضًا. يُعيد الاستدعاء set.headSet(toElement) عرضًا view مُكوَّنًا من جميع عناصر الطقم set الأقل من قيمة toElement؛ بينما يُعيد الاستدعاء set.tailSet(fromElement) عرضًا مُكوَّنًا من جميع عناصر الطقم set الأكبر من قيمة fromElement. يُعرِّف الصنف TreeMap<K,V> ثلاثة عروضٍ لتمثيل خرائطٍ جزئية submaps، والتي هي أيضًا خريطةٌ من النوع Map تحتوي على جزءٍ من مفاتيح الخريطة الأصلية إلى جانب قيمها المرتبطة بها. إذا كان map مُتغيرًا من النوع TreeMap<K,V>، وكان fromKey و toKey من النوع K، فسيُعيد الاستدعاء map.subMap(fromKey,toKey) عرضًا يحتوي على جميع مفاتيح وقيم الخريطة map بشرط وقوع المفتاح بين fromKey و toKey. يتوفَّر أيضًا التابعين map.headMap(toKey) و map.tailMap(fromKey) المُشابهين تمامًا للتابعين headSet و tailSet. لنفترض أن phoneBook خريطةٌ من النوع TreeMap<String,String>، حيث تُمثِّل مفاتيحها أسماء أشخاص، بينما تُمثِّل قيمها values أرقام هواتف هؤلاء الأشخاص. تطبع الشيفرة التالية أرقام هواتف الأشخاص الموجودين بالخريطة phoneBook شرط أن تبدأ أسماؤهم بالحرف "M": Map<String,String> ems = phoneBook.subMap("M","N"); // 1 if (ems.isEmpty()) { System.out.println("No entries beginning with M."); } else { System.out.println("Entries beginning with M:"); for ( Map.Entry<String,String> entry : ems.entrySet() ) System.out.println( " " + entry.getKey() + ": " + entry.getValue() ); } [1] تحتوي هذه الخريطة الجزئية على الارتباطات، التي مفتاحها أكبر من أو يُساوِي "M" وأقل من "N". يُمكِننا التفكير بالأطقم الجزئية subsets والخرائط الجزئية submaps كما لو كانت عملية بحثٍ مُعمَّمةٍ تُمكِّننا من العثور على جميع العناصر الواقعة ضمن نطاقٍ معينٍ من القيم بدلًا من مجرد العثور على قيمةٍ واحدة. إذا خزَّنا مثلًا قاعدة بياناتٍ database لمجموعةٍ من المناسبات events ضمن خريطةٍ من النوع TreeMap<Date,Event>، بحيث يُمثِّل المفتاح تاريخ توقيت المناسبة. بفرض أردنا عرض قائمة المناسبات الواقعة بتاريخٍ معين، مثل July 4, 2018، يُمكِننا ببساطة الحصُول على خريطةٍ جزئيةٍ تحتوي على جميع المفاتيح الواقعة من التاريخ 12:00 AM, July 4, 2018 حتى التاريخ 12:00 AM, July 5, 2018، ثم طباعة جميع الارتباطات الموجودة بتلك الخريطة الجزئية، ويُعرَف هذا النوع من البحث باسم الاستعلام ضمن نطاقٍ جزئي subrange query، وهو شائعٌ جدًا. جداول Hash والشيفرات المعماة تُنفِّذ جافا الصنفين HashMap و HashSet باستخدام بنية بياناتٍ data structure تُعرَف باسم جدول hash. لا نحتاج في العموم لفهم طريقة عمل تلك الجداول لنتمكَّن من استخدام الصنفين HashSet و HashMap، لكن يجب أن يكون كل مبرمجٍ على اطلاعٍ بطريقة عملها. تُعدّ جداول Hash حلًا فعالًا لمشكلة البحث، فهي تُخزِّن أزواجًا من المفاتيح keys والقيم values مثل الصنف HashMap، وإذا كان لدينا مفتاحٌ معين، يُمكِننا البحث عن القيمة المقابلة له ضمن الأزواج المخزَّنة بالجدول؛ بينما لايكون هناك أي قيمٍ، إذا اِستخدَمنا جدول hash لتنفيذ طقمٍ، ويكون السؤال الوحيد هو: هل المفتاح موجودٌ بالطقم أم لا؟ ويبقى علينا البحث عن المفتاح لاختبار إذا كان موجودًا أم لا. بالنظر إلى غالبية خوارزميات البحث، حيث يَكون الغرض هو العثور على عنصرٍ معين، فستَجِد أنها تضطّر للمرور عبر مجموعةٍ من العناصر الأخرى، والتي نحن في الحقيقة غير مهتمين بها إطلاقًا. إذا أردنا مثلًا العثور على قيمةٍ معينةٍ ضمن قائمةٍ list غير مُرتَّبة، فسنمر على جميع عناصر القائمة واحدًا تلو الآخر حتى نعثُر على ذلك العنصر الذي نبحث عنه؛ أما إذا كان لدينا شجرة بحثٍ ثنائية binary search tree، فسنبدأ من جذر الشجرة root، ثم نستمر بالتحرُّك إلى أسفل الشجرة حتى نعثر على العنصر المطلوب؛ بينما إذا أردت البحث عن زوج مفتاح/قيمة ضمن جدول hash، نستطيع الذهاب مباشرةً إلى موضع العنصر المطلوب دون الحاجة للمرور عبر أي عناصرٍ اخرى؛ حيث يُستخدَم المفتاح لحساب الموضع المُخزَّن به العنصر. ربما تتساءل الآن عن كيفية فعل بذلك. لنفترض أن مفاتيح جدولٍ معينٍ مُكوَّنةٌ من الأعداد الصحيحة الواقعة بين 0 و 99، فيُمكِننا إذًا تخزين أزواج المفاتيح والقيم key/value pairs ضمن مصفوفةٍ A مُكوَّنةٍ من 100 عنصر. بناءً على ذلك، يكون الزوج ذو المفتاح K مُخزَّنًا بعنصر المصفوفة A[K]. يَعنِي ذلك، أننا نستطيع الذهاب مباشرةً إلى الموضع المُتضمِّن لزوجٍ معين بناءً على مفتاحه. تَكْمُن المشكلة في وجود عددٍ كبيرٍ جدًا من المفاتيح المُحتمَلة لدرجةٍ يَستحيل معها استخدام مصفوفةٍ بموضعٍ لكل مفتاحٍ مُحتمَل. قد يكون المفتاح أي قيمةٍ من النوع int، وعندها سنحتاج إلى مصفوفةٍ تحتوي على أكثر من 4 بليون موضع، وهو ما سيُمثِل هدرًا كبيرًا للمساحة إذا كنا سنُخزِّن بالنهاية بضعة آلافٍ من العناصر فقط. وقد يكون المفتاح أي سلسلةٍ نصية string بأي طول، وسيكون في تلك الحالة عدد المفاتيح المُحتمَلة لا نهائيًا، وسيَستحِيل عندها من الأساس استخدام مصفوفة بموضعٍ لكل مفتاحٍ مُحتمَل. بالرغم من ذلك، تُخزِّن جداول hash البيانات ضمن مصفوفة، حيث يَعتمِد فهرس index مفتاحٍ معينٍ على المفتاح ذاته؛ أي لا يكون الفهرس هو نفسه المفتاح، ولكنه يُحسَب على أساسه. يُطلَق على فهرس مفتاح معين اسم الشيفرة المُعمَّاة hash code لذلك المفتاح؛ بينما يُطلَق اسم دالة التعمية hash function على الدالة المُستخدَمة لحساب الشيفرة المعمَّاة hash code لمفتاحٍ معين. إذا أردنا العثور على مفتاحٍ معينٍ ضمن جدول hash، سنحتاج فقط إلى حساب الشيفرة المعمَّاة الخاصة بذلك المفتاح، ثم سنذهب مباشرةً إلى موضع المصفوفة المُخصَّص لتلك الشيفرة. على سبيل المثال، إذا كانت الشيفرة المعمَّاة تُساوِي 17، علينا فحَص موضع المصفوفة رقم 17. نظرًا لوجود مواضع مصفوفة أقل من المفاتيح المُحتمَلة، قد يؤدي ذلك إلى محاولة تخزين مفتاحين أو أكثر بنفس موضع المصفوفة، وهو ما يُعرَف باسم التصادم collision. لا يُعدّ التعارض خطأً error؛ لأنه لا يُمكِننا رَفض مفتاحٍ معينٍ لمجرد وجود مفتاحٍ آخر صَدَفَ أن كان له نفس الشيفرة المعمَّاة hash code. ومع ذلك، يجب أن يَكون جدول hash قادرًا على معالجة التعارضات بطريقةٍ معقولة. بلغة جافا: يَحمِل كل موضع مصفوفة قائمةً مترابطةً linked list من أزواج المفاتيح والقيم key/value pairs؛ وفي حال وجود عنصرين بنفس الشيفرة المعمَّاة، فسيُخزَّن كلاهما بنفس القائمة المترابطة. يوضح الشكل التالي جدول hash. يوجد بالشكل الموضح بالأعلى عنصران لهما نفس الشيفرة المعمَّاة 0؛ بينما لا يوجد أي عنصرٍ بشيفرة معمَّاة تُساوي 1؛ في حين يوجد عنصرٌ واحدٌ فقط بشيفرةٍ معمَّاة تُساوِي 2، وهكذا. إذا كان جدول hash مُصمَّمًا تصميمًا مناسبًا، يجب أن يكون طول غالبية القوائم المترابطة linked lists مُساويًا للصفر أو للواحد، وأن يكون طولها في المتوسط أقل من الواحد. على الرغم من أنه ليس من الضروري للشيفرة المعمَّاة لمفتاحٍ معين أن تأخذك مباشرةً إلى ذلك المفتاح، فليس هناك أكثر من مجرد عنصرٍ واحدٍ أو اثنين تحتاج للمرور بهما قبل العثور على المفتاح المطلوب، حيث يجب أن يكون عدد العناصر الموجودة بالجدول أقل من عدد مواضع المصفوفة ليعمَل ذلك بالشكل المناسب في العموم. بلغة جافا: عندما يجتاز عدد العناصر 75% من حجم المصفوفة، فإنها تُستبدَل بواحدةٍ جديدةٍ أكبر منها، وتُنقَل بالطبع جميع العناصر من المصفوفة القديمة إلى المصفوفة الجديدة، ولهذا يتسبَّب أحيانًا إدخال عنصرٍ واحد بالجدول إلى تَغيُّر ترتيب عناصره تمامًا. سنوضِح الآن طريقة الحصول على الشيفرات المعمَّاة hash codes، حيث يَملُك كل كائنٍ بلغة جافا شيفرةً معمَّاة، ويُعرِّف الصنف Object التابع hashCode() الذي يُعيد قيمةً من النوع int. عندما نُخِّزن كائنًا، وليَكُن اسمه obj، بجدول hash يَحتوي على عدد N من المواضع، فسنحتاج إلى شيفرةٍ معمَّاةٍ تقع بين 0 وN-1؛ حيث تُحسَب تلك الشيفرة باستخدام الآتي: Math.abs(obj.hashCode()) % N أي أنها تُساوِي باقي قسمة القيمة المُطلَقة المُعادة من obj.hashCode() على N. لاحِظ ضرورة استخدام Math.abs؛ لأن قيمة obj.hashCode() قد تكون سالبة، ونحن بالتأكيد نريد فهرس مصفوفةٍ موجب. لتعمل التعمية hashing على النحو الصحيح، يجب أن يَكون لأيِّ كائنين objects متساويين وفقًا للتابع equals() نفس الشيفرة المعمَّاة، ويَستوفِي الصنف Object ذلك الشرط لحسن الحظ؛ لأن التابعين equals() و hashCode() معتمدان على عنوان موضع الذاكرة الخاص بالكائن. يعيد مع ذلك كثيرٌ من الأصناف classes تعريف التابع equals()، كما رأينا بمقال مفهوم البرمجة المعممة Generic Programming؛ فإذا أعدت تعريف التابع equals() ضمن صنفٍ معين، وكنت تَنوِي استخدام كائناته مفاتيحًا ضمن جداول hash، فلا بُدّ من إعادة تعريف التابع hashCode() ضمن ذلك الصنف أيضًا. يُعيد الصنف String على سبيل المثال تعريف التابع equals() ليَضمَن تَساوِي كائنين من النوع String فيما إذا كانا يحتويان على نفس متتالية المحارف، كما يُعيد تعريف التابع hashCode() ليَحسِب الشيفرة المعمَّاة من محارف السلسلة النصية بدلًا من حسابها بناءً على موضعها بالذاكرة. لا يوجى داعٍ للقلق بشأن أصناف جافا القياسية Java's standard classes؛ فهي تُعرِّف التابعين على النحو الصحيح. اهتم فقط بالأصناف التي تكتبها بنفسك واحرص على تعريف التابعين معًا إذا أردت تعريف إحداهما. تشبه كتابة دوال التعمية hash function الفن؛ فمن أجل كتابة دالة تعميةٍ جيدة، ينبغي لها أن تُوزِّع المفاتيح المُحتمَلة بالتساوي على طول الجدول، وإلا فقد تتركَّز عناصره ضمن جزءٍ معينٍ فقط من المواضع المتاحة، وسينمو عندئذٍ حجم القوائم المترابطة linked lists الخاصة بتلك المواضع بصورةٍ كبيرة، مما يؤدي إلى الحد من كفاءة الجدول، والذي هو السبب الرئيسي لوجودها أساسًا. لن نُغطِي التقنيات المُستخدَمة لإنشاء دوال التعمية، فهي لا تُعدّ جزءًا أساسيًا من موضوع الكتاب. ترجمة -بتصرّف- للقسم Section 3: Maps من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: القوائم lists والأطقم sets في جافا تحليل زمن تشغيل الخرائط المنفذة باستخدام مصفوفة في جافا استخدام خريطة ومجموعة لبناء مفهرس Indexer
-
اطلَّعنا بالمقال السابق على الخواص العامة لعناصر التجميعات بلغة جافا، وحان الآن الوقت لنفحص بعضًا من تلك الأصناف، ونتعرَّف على طريقة استخدامها، حيث يُمكِننا تقسيم تلك الأصناف في العموم إلى مجموعتين رئيسيتين، هما القوائم lists والأطقم sets؛ حيث تتكوَّن أي قائمةٍ من متتاليةٍ من العناصر المُرتَّبة خطيًا، بمعنى أنها مُرتَبة وفقًا لترتيب معين لا يُشترَط له أن يَكون ترتيبًا تصاعديًا؛ أما الطقم set فهو تجميعةٌ لا تحتوي أي عناصرٍ مُكرَّرة، وربما تكون العناصر مُرتّبةً بترتيبٍ مُحدَّد أو لا. سنناقش سريًعا نوعًا آخرًا من التجميعات إضافةً الى النوعين السابقين، يُعرَف باسم أرتال الأولوية priority queue؛ وهي بنيةٌ بيانيةٌ data structures مثل الأرتال ولكن عناصرها ذات أولوية. أصناف تمثيل القوائم تعرّفنا في مقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا ومقال بنى البيانات المترابطة Linked Data Structures على طريقتين لتمثيل القوائم، هما المصفوفات الديناميكية dynamic array والقوائم المترابطة linked list. تُوفِّر جافا الصنفين java.util.ArrayList و java.util.LinkedList ضمن إطار عمل جافا للتجميعات Java Collection Framework لتمثيلهما بصيغةٍ مُعمَّمة generic، حيث يُنفِّذ كلاهما الواجهة List<T>، وبالتالي الواجهة Collection<T> أيضًا. يُمثِل كائنٌ من النوع ArrayList<T> متتاليةً مُرتَّبةً من الكائنات المُنتمية إلى النوع T، والمُخزَّنة ضمن مصفوفةٍ يزداد حجمها تلقائيًا عند الضرورة،؛ بينما يُمثِل كائنٌ من النوع LinkedList<T> متتاليةً مُرتّبةً من الكائنات المُنتمية إلى النوع T، والمُخزَّنة -بخلاف المصفوفة- بعُقدٍ nodes تَربُطها مؤشرات pointers ببعضها بعضًا. يدعم الصنفان السابقان عمليات القوائم الأساسية المُعرَّفة بالواجهة List<T>، كما يُعرَّف أي نوع بيانات مُجرَّد abstract data type بعملياته وليس طريقة تمثيله representation. قد تتساءل: لماذا نحتاج إلى تعريف صنفين بدلًا من تعريف صنف قائمةٍ وحيد له نفس طريقة التمثيل؟ المشكلة هي أننا لن نتمكَّن من تمثيل القوائم بطريقةٍ واحدة، وتكون كفاءة جميع عمليات القوائم على ذلك التمثيل بنفس الدرجة؛ حيث تَكون كفاءة عمليات معينة أفضل دائمًا عند تطبيقها على القوائم المترابطة linked lists بالموازنة مع المصفوفات؛ بينما ستَكون كفاءة عمليات أخرى أفضل عند تطبيقها على المصفوفات. يعتمد أي تطبيق application عمومًا على مجموعةٍ معينة من عمليات القوائم أكثر من غيرها، ولذلك ينبغي اختيار التمثيل الذي تَبلغ فيه كفاءة تلك العمليات أقصى ما يُمكِن. يُعدّ الصنف LinkedList المُمثِّل للقوائم المترابطة مثلًا أكثر كفاءةً بالتطبيقات التي تعتمد بكثرة على إضافة العناصر إلى بداية القائمة ومنتصفها، أو حذفها من نفس تلك المواضع؛ حيث تتطلَّب نفس تلك العمليات عند تطبيقها على مصفوفة تحريك عددٍ كبيرٍ من العناصر مسافة موضعٍ واحدٍ إلى الأمام أو الخلف لإتاحة مساحةٍ للعنصر الجديد المطلوب إضافته، أو لملئ الفراغ الذي تسبَّب به حذف عنصر. إن زمن التشغيل المطلوب لإضافة عنصرٍ إلى بداية أو منتصف مصفوفة وفقًا لمصطلحات التحليل المقارب asymptotic analysis (انظر مقال تحليل الخوارزميات في جافا) يُساوِي Θ(n)، حيث تمثّل n عدد عناصر المصفوفة؛ أما بالنسبة للقوائم المترابطة، تقتصر عمليتي الإضافة والحذف على ضبط عددٍ قليل من المؤشرات، ويكون زمن التشغيل المطلوب لإضافة عقدةٍ node إلى أي موضعٍ ضمن القائمة أو حذفها من أي موضع مساوٍ إلى Θ(1)، أي تَستغرِق العملية مقدارً ثابتًا من الزمن بغض النظر عن عدد العناصر الموجود بالقائمة. من الجهة الأخرى، يُعدّ الصنف ArrayList المُمثِل للمصفوفات أكثر كفاءةً عندما تَكون عملية الجَلْب العشوائي random access للعناصر أمرًا ضروريًا؛ وهي ببساطة عملية قراءة قيمة العنصر الموجود بموضعٍ معين k ضمن القائمة، وتُستخدَم تلك العملية عند محاولة قراءة أو تعديل القيمة المُخزَّنة بموضعٍ معين ضمن القائمة. تُعدّ تلك العمليات أمرًا في غاية البساطة بالنسبة للمصفوفة، وتحديدًا بالنسبة لزمن التشغيل الذي يساويΘ(1)؛ أما بالنسبة للقوائم المترابطة linked list، فتَعنِي تلك العمليات البدء من بداية القائمة، ثم التحرك من عقدةٍ إلى أخرى على طول القائمة بعدد خطواتٍ يصل إلى k، ويَكون زمن التشغيل هو Θ(k). تتساوى كفاءة عملية الترتيب sorting وإضافة عنصرٍ إلى نهاية القائمة لكلا النوعين السابقين. تٌنفِّذ جميع أصناف القوائم توابع الواجهة Collection<T>، التي ناقشناها بالمقال السابق، مثل size() و isEmpty() و add(T) و remove(Object) و clear()؛ حيث يضيف التابع add(T) الكائن إلى نهاية القائمة؛ بينما يحاول التابع remove(Object) العثور أولًا على الكائن المطلوب حَذْفه باستخدام خوارزمية البحث الخطي linear search، التي لا تتميز بالكفاءة نهائيًا لأي نوعٍ من القوائم لأنها تتضمَّن المرور عبر جميع عناصر القائمة من بدايتها إلى نهايتها لحين العثور على الكائن. تحتوي الواجهة List<T> على عدة توابعٍ أخرى للوصول إلى عناصر القائمة عبر مواضعها العددية. لنفترض أن list كائنٌ من النوع List<T>، سيُصبِح لدينا التوابع التالية: list.get(index): يُعيد الكائن الموجود بالموضع index ضمن القائمة، حيث أن index هو عددٌ صحيح. لاحِظ أن العناصر مُرقَّمةٌ على النحو التالي: 0، 1، 2، .. إلى list.size()-1، وبالتالي، لا بُدّ أن تَقَع القيمة المُمرَّرة مثل مُعامِلٍ ضمن ذلك النطاق، وإلا سيَحدُث اعتراضٌ من النوع IndexOutOfBoundsException. list.set(index,obj): يَستبدِل الكائن obj بالكائن الموجود حاليًا بالموضع index ضمن القائمة، وبالتالي لا يُغيِّر التابع عدد عناصر القائمة، كما أنه لا يُحرِّك أيًا من عناصرها الأخرى. يجب أن يكون الكائن obj من النوع T. list.add(index,obj): يُدخِل الكائن obj إلى الموضع index ضمن القائمة؛ أي يُزيِد التابع عدد عناصر القائمة بمقدار الواحد؛ كما أنه يُحرِّك جميع العناصر الواقعة بعد الموضع index مسافة موضعٍ واحدٍ إلى الأمام لإتاحة مساحةٍ للعنصر الجديد. يجب أن يَكون الكائن obj من النوع T، كما يجب أن تتراوح قيمة index بين 0 و list.size()، وإذا كان index يُساوي list.size()، فسيُضيِف التابع الكائن obj إلى نهاية القائمة. list.remove(index): يَحذِف الكائن الموجود بالموضع index، ثم يعيده قيمةً للتابع؛ حيث يُحرِّك التابع العناصر الواقعة بعد ذلك الموضع مسافة موضعٍ واحدٍ إلى الخلف لملء الفراغ الذي تسبَّب به حذف العنصر، ويَقِل بذلك عدد عناصر القائمة بمقدار الواحد. يجب أن تتراوح قيمة index بين 0 و list.size()-1. list.indexOf(obj): يُعيد قيمةً عدديةً من النوع int تُمثِّل موضع الكائن obj بالقائمة في حال وجوده بها؛ بينما يُعيد القيمة -1 إذا لم يَكُن موجودًا. يُمكِن للكائن obj أن يَكون من أي نوع وليس فقط T. إذا كان الكائن obj موجودًا أكثر من مرةٍ ضمن القائمة، فسيُعيد التابع موضع أول حدوثٍ له. لاحِظ أن التوابع المذكورة أعلاه مُعرَّفةٌ لكلا الصنفين ArrayList<T> و LinkedList<T> على الرغم من أن كفاءة بعضها، مثل get و set مقتصرةٌ على الصنف ArrayList. يُعرِّف الصنف LinkedList<T> عدة توابع إضافية أخرى غير مُعرَّفةٍ بالصنف ArrayList. إذا كان linkedlist كائنًا من النوع LinkedList<T>، فسنحصل على التوابع التالية: linkedlist.getFirst(): يُعيد قيمةً من النوع T تُمثِّل أول عنصرٍ ضمن القائمة دون إجراء أيّ تعديلٍ عليها. سيحدث اعتراضٌ exception من النوع NoSuchElementException، إذا كانت القائمة فارغةً، وينطبق ذلك على التوابع الثلاثة التالية أيضًا. linkedlist.getLast(): يُعيد قيمةً من النوع T تُمثِّل آخر عنصرٍ ضمن القائمة دون إجراء أيّ تعديلٍ عليها. linkedlist.removeFirst(): يحذف الصنف أول عنصرٍ ضمن القائمة، ويُعيده قيمةً للتابع. التابعان linkedlist.remove() و linkedlist.pop() مُعرَّفان أيضًا، ولهما نفس دلالة التابع removeFirst(). linkedlist.removeLast(): يَحذِف آخر عنصرٍ ضمن القائمة، ويُعيده قيمةً للتابع. linkedlist.addFirst(obj): يُضيف الكائن obj إلى بداية القائمة، والذي يجب أن يكون من النوع T. التابع linkedlist.push(obj) مُعرَّفٌ أيضًا، وله نفس الدلالة. linkedlist.addLast(obj): يُضيف الكائن obj إلى نهاية القائمة، والذي يجب أن يكون من النوع T. يعمل بصورة مشابهة تمامًا للتابع linkedlist.add(obj)؛ فهو بالنهاية مُعرَّفٌ فقط للتأكد من الحصول على أسماء توابعٍ مُتسقّة consistent. ستلاحِظ وجود بعض التكرار ضمن الصنف LinkedList، لتسهيل استخدامه كما لو كان مكدسًا stack، أو رتلًا queue (انظر مقال المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT). يُمكِننا على سبيل المثال استخدام قائمةٍ مترابطة من النوع LinkedList مثل مكدسٍ باستخدام التوابع push() و pop()، أو مثل رتلٍ باستخدام التوابع add() و remove() لتنفيذ عمليتي الإدراج enqueue والسحب dequeue. إذا كان الكائن list قائمةً من النوع List<T>، فسيعيد التابع list.iterator() المُعرَّف بالواجهة Collection<T> مُكرّرًا iterator من النوع Iterator، والذي يُمكِننا استخدامه لاجتياز traverse القائمة من البداية حتى النهاية. يتوفَّر أيضًا نوعٌ آخر من مُكرِّرات القوائم ListIterator يتميز بخواصٍ إضافية. لاحِظ أن الواجهة ListIterator<T> مُوسعَّةٌ من الواجهة Iterator<T>، ويُعيد التابع list.listIterator() كائنًا من النوع ListIterator<T>. تتضمَّن الواجهة ListIterator توابع المُكرِّرات العادية، مثل hasNext() و next() و remove()، ولكنها تحتوي أيضًا على توابعٍ أخرى، مثل hasPrevious() و previous() و add(obj) و set(obj)، والتي تُساعد على التحرُّك إلى الخلف ؛ وإضافة عنصرٍ بالموضع الحالي للمُكرِّر؛ واستبدال أحد عناصر القائمة على الترتيب. فكِّر بالمُكرِّرات كما لو كانت تُشير إلى موضعٍ بين عنصرين ضمن القائمة، أو إلى بداية القائمة، أو نهايتها لتتمكَّن من فِهم طريقة عمل التوابع السابقة. تُظهر الصورة التالية العناصر على هيئة مربعات، بحيث تُشير تلك الأسهم إلى المواضع المحتملة للمُكرِّر iterator: إذا كان iter مُكرِّرًا من النوع ListIterator<T>، فسيحركه التابع iter.next() مسافة موضعٍ واحدٍ إلى يمين القائمة، ويعيد العنصر الذي مرّ به المُكرِّر أثناء تحركه؛ ويُحرِّك التابع iter.previous() المُكرِّر مسافة موضعٍ واحد إلى يسار القائمة، ويعيد العنصر الذي مرّ به. يَحذِف التابع iter.remove() أحدث عنصرٍ مرّ به المُكرِّر أثناء تحركُّه أي بعد استدعاء التابع iter.next() أو التابع iter.previous(). يَعمَل التابع iter.set(obj) بنفس الطريقة، أي يستبدل obj بنفس العنصر الذي يفترض للتابع iter.remove() أن يَحذِفه عند استدعائه. يتوفَّر أيضًا التابع iter.add(obj) المسؤول عن إضافة الكائن obj من النوع T إلى الموضع الحالي للمُكرِّر، والذي من الممكن أن يكون في بداية القائمة، أو نهايتها، أو بين عنصرين موجودين مُسبَقًا ضمن القائمة. تُعدّ القوائم المُستخدَمة بالواجهة LinkedList<T> قوائمًا مترابطةً مزدوجة doubly linked lists، حيث تحتوي كل عقدةٍ node ضمن القائمة على مؤشرين pointers، يُشير أحدهما إلى العقدة التالية بالقائمة، بينما يشير الآخر إلى العقدة السابقة، ويُمكِّننا هذا من تنفيذ التابعين next() و previous() بأحسن كفاءةٍ ممكنة. كما يحتوي الصنف LinkedList<T> على مؤشر ذيل tail pointer للإشارة إلى آخر عقدةٍ ضمن القائمة، ويُمكِّننا ذلك من تنفيذ التابعين addLast() و getLast() بكفاءة. سنَدرِس الآن مثالًا عن كيفية استخدام مُكرِّرٍ من النوع ListIterator. لنفترض أننا نريد معالجة قائمةٍ من العناصر مع مراعاة الإبقاء عليها مُرتَّبةً ترتيبًا تصاعديًا. عند إضافة عنصرٍ إلى القائمة، سيَعثُر المُكرِّر من النوع ListIterator أولًا على الموضع الذي ينبغي إضافة العنصر إليه، ثم سيَضعُه به. يبدأ المُكرِّر ببساطةٍ من بداية القائمة، ثم يتحرَّك إلى الأمام بحيث يَمُر بجميع العناصر التي تقل قيمتها عن قيمة العنصر المطلوب إضافته، ويُضيِف التابع add() العنصر إلى القائمة عند هذه النقطة. إذا كان stringList مُتغيِّرًا من النوع List<String> مثلًا، وكان newItem السلسلة النصية التي نريد إضافتها إلى القائمة، وبِفَرض كانت السلاسل النصية الموجودة حاليًا ضمن القائمة مُرتَّبةً ترتيبًا تصاعديًا بالفعل، يُمكِننا إذًا استخدام الشيفرة التالية لوضع العنصر newItem بموضعه الصحيح ضمن القائمة بحيث نُحافِظ على ترتيبها: ListIterator<String> iter = stringList.listIterator(); // 1 while (iter.hasNext()) { String item = iter.next(); if (newItem.compareTo(item) <= 0) { // 2 iter.previous(); break; } } iter.add(newItem); حيث أن: [1] تعني حرِّك المُكرِّر بحيث يُشير إلى موضع القائمة الذي ينبغي إضافة newItem إليه؛ فإذا كان newItem أكبر من جميع عناصر القائمة، فستنتهي حلقة التكرار while عندما تُصبِح قيمة iter.hasNext() مُساويةً للقيمة false، أي عندما يَصِل المُكرِّر إلى نهاية القائمة. [2] تشير إلى يجب أن يأتي newItem قبل item. حرِّك المُكرِّر خطوةً للوراء، بحيث يُشير إلى موضع الإدخال الصحيح، وأنهي الحلقة. قد يكون stringList من النوع ArrayList<String>، أو النوع LinkedList<String>. لاحِظ أن كفاءة الخوارزمية المُستخدَمة لإدخال newItem إلى القائمة مُتساويةٌ لكليهما، كما أنها ستَعمَل مع أي أصنافٍ أخرى طالما كانت تُنفِّذ الواجهة List<String>. قد تجد أنه من الأسهل تصميم خوارزمية الإدراج باستخدام الفهرسة indexing على هيئة توابعٍ، مثل get(index) و add(index,obj)، ولكن ستكون كفائتها سيئةً للغاية بالنسبة للقوائم المترابطة LinkedList؛ لأنها لا تَعمَل بكفاءةٍ عند الجلب العشوائي random access. ملاحظة: ستَعمَل خوارزمية الإدراج insertion حتى لو كانت القائمة فارغة. الترتيب نظرًا لأن عملية ترتيب sorting القوائم من أكثر العمليات شيوعًا، كان من الضروري حقًا أن تُعرِّف الواجهة List تابعًا مسؤولًا عن تلك العملية، إلا أنه غير موجود؛ ربما لأن عملية ترتيب قوائم أنواعٍ معينة من الكائنات ليس لها معنى. بالرغم من ذلك، يتضمَّن الصنف java.util.Collections توابعًا ساكنة static methods للترتيب، كما يحتوي على توابعٍ ساكنةٍ أخرى للعمل مع التجميعات collections؛ وهي توابعٌ من النوع المُعمَّم generic، أي أنها تعمل مع تجميعات أنواعٍ مختلفة من الكائنات. لنفترض أن list قائمةً من النوع List<T>، يُمكِن للأمر التالي ترتيب القائمة تصاعديًا: Collections.sort(list); يجب أن تُنفِّذ عناصر القائمة الواجهة Comparable<T>. سيعمل التابع Collections.sort() على قوائم السلاسل النصية من النوع String، وكذلك لقوائم أي نوعٍ من الأصناف المُغلِّفة، مثل Integer و Double. يتوفَّر أيضًا تابع ترتيبٍ آخرٍ يَستقبِل معاملًا ثانيًا إضافيًا من النوع Comparator: Collections.sort(list,comparator); يُوازن المعامل الثاني comparator بين عناصر القائمة في تلك الحالة. كما ذكرنا بالمقال السابق، تُعرِّف كائنات الصنف Comparator التابع compare() الذي يُمكِننا من استخدِامه لموازنة كائنين. سنفحص مثالًا على استخدام الصنف Comparator في مقال قادم. يَعتمِد التابع Collections.sort() على خوارزمية الترتيب بالدمج merge sort بزمن تشغيل run time يساوي Θ(n*log(n)) لكُلٍّ من الحالة الأسوأ worst-case والحالة الوسطى average-case، حيث n هو حجم القائمة. على الرغم من أن زمن التشغيل لتلك الخوارزمية أبطأ قليلًا في المتوسط من خوارزمية الترتيب السريع QuickSort (انظر مقال التعاود recursion في جافا لمزيد من التفاصيل)، إلا أن زمن تشغليها في الحالة الأسوأ أفضل بكثير. تتميز خوارزمية الترتيب بالدمج MergeSort علاوةً على ذلك بخاصية الاستقرار stability، التي سنناقشها بمقال لاحق. يتضمَّن الصنف Collection تابعين آخرين مفيدين على الأقل لتعديل القوائم؛ حيث يُنظِم التابع الآتي: Collections.shuffle(list) عناصر القائمة بحيث تكون مُرتبةً ترتيبًا عشوائيًا؛ بينما يعكس التابع Collections.reverse(list) ترتيب عناصر القائمة، بحيث ينتقل آخر عنصرٍ في القائمة إلى مقدمتها، وثاني آخر عنصرٍ إلى الموضع الثاني بالقائمة، وهكذا. نظرًا لأن الصنف List يُوفِّر لنا بالفعل تابع ترتيب ذا كفاءة عالية، فلا حاجة لكتابته بنفسك. أصناف الأطقم TreeSet و HashSet يُعدّ الطقم set تجميعة كائنات، لا يتكرَّر فيها أي عنصرٍ أكثر من مرة. تُنفِّذ الأطقم جميع توابع الواجهة Collection<T> بطريقةٍ تَضمن عدم تكرار أي عنصرٍ مرتين؛ فإذا كان set كائن تجميعةٍ من النوع Set<T>، وكان يَحتوي على عنصرٍ obj، فلن يكون لاستدعاء التابع set.add(obj) أي تأثيرٍ على set. توفِّر جافا صنفين لتنفيذ الواجهة Set<T>، هما java.util.TreeSet و java.util.HashSet. بالإضافة إلى كون الصنف TreeSet من النوع Set، فإن عناصره تكون مُرتّبةً دائمًا ترتيبًا تصاعديًا، أي ستجتاز مُكرِّرات الأطقم من النوع TreeSet العناصر دائمًا بحسب ترتيبها التصاعدي. لا يُمكِن للأطقم من النوع TreeSet أن تحتوي على أية كائنات عشوائيًا؛ حيث لا بُدّ من معرفة الطريقة التي ينبغي على أساسها ترتيب تلك الكائنات؛ أي ينبغي لأي كائنٍ موجودٍ ضمن طقم من النوع TreeSet<T> أن يُنفِّذ الواجهة Comparable<T>، بحيث يَكون للاستدعاء obj1.compareTo(obj2) لأي كائنين obj1 و obj2 ضمن الطقم معنى. يُمكِننا بدلًا من ذلك تمرير كائنٍ من النوع Comparator<T> مثل معاملٍ للباني constructor عند إنشاء طقمٍ من النوع TreeSet، ويُستخدَم في تلك الحالة التابع compare() المُعرَّف ضمن Comparator لموازنة الكائنات المضافة إلى الطقم. لا تَستخدِم الأطقم من النوع TreeSet التابع equals() من أجل اختبار تساوي كائنين معينين، وإنما تَستخدِم التابع compareTo()، أو التابع compare()، وهذا قد يُحدِث مشكلة؛ لأن التابع compareTo() (كما ناقشنا بالمقال السابق) قد يُعامِل كائنين غير متساويين كما لو كانا كذلك لغرض الموازنة comparison، مما يَعنِي إمكانية وقوع أحدهما فقط ضمن طقمٍ من النوع TreeSet. لنفترض مثلًا أن لدينا طقمًا يحتوي على مجموعةٍ من عناوين البريد، وكان التابع compareTo() مُعرَّفٌ بحيث يوازن فقط الأرقام البريدية لتلك العناوين، وبالتالي يُمكِن للطقم أن يحتوي على عنوانٍ واحدٍ فقط لكل رقمٍ بريدي، وهو بالتأكيد أمرٌ غير منطقي. يجب إذًا الانتباه دومًا لدلالة الأطقم من النوع TreeSet، والتأكُّد من أن التابع compareTo() مُعرَّفٌ بطريقةٍ منطقية للكائنات المُتوقَّع إضافتها لهذا النوع من الأطقم، ويَنطبِق ذلك على السلاسل النصية من النوع String، والأعداد الصحيحة من النوع Integer، وغيرها من الأنواع الأخرى المبنية مُسبَقًا built-in؛ حيث يُعامِل التابع compareTo() الكائنات بتلك الأنواع على أنها متساوية إذا كانت فعلًا كذلك. تُخزَّن عناصر الأطقم من النوع TreeSet داخل ما يُشبِه أشجار الترتيب الثنائية binary sort tree (انظر مقال الأشجار الثنائية Binary Trees في جافا)، حيث تكون بنية البيانات data structure مُتزِّنةً؛ أي تكون جميع أوراق leaves الشجرة الثنائية على نفس البعد تقريبًا من جذر الشجرة root، مما يضمَن تنفيذ جميع العمليات الأساسية، مثل الإدْخال والحذف والبحث بكفاءة، وبزمن تشغيلٍ للحالة الأسوأ worst-case run time مساوٍ Θ(log(n))، حيث n هو عدد عناصر الطقم. كما ذكرنا مُسبقًا، تكون عناصر الأطقم من النوع TreeSet مُرتّبةً وغير مُكرَّرة، وهذا يجعلها مناسبةً لبعض التطبيقات. تَضمَّن تمرين 7.6 على سبيل المثال كتابة برنامجٍ يقرأ ملفًا ثم يَطبَع قائمة الكلمات الموجودة ضمن ذلك الملف بعد حذف جميع الكلمات المُكرَّرة، وبحيث تَكون مُرتّبةً أبجديًا. كنا قد اِستخدَمنا مصفوفةً من النوع ArrayList، وعليه كان من الضروري التأكُّد من كون عناصر المصفوفة مُرتّبةً وغير مُكرَّرة. يُمكننا في الواقع استخدام طقمٍ من النوع TreeSet لتخزين العناصر بدلًا من استخدام قائمة، وسيُبسِّط ذلك الحل كثيرًا؛ لأنه سيَحذِف العناصر المُكرَّرة تلقائيًا، كما سيجتاز مُكرِّر الطقم العناصر على نحوٍ مُرتّبٍ تلقائيًا. يُمكِننا كتابة الحل باستخدام الصنف TreeSet على النحو التالي: TreeSet<String> words = new TreeSet<String>(); // طالما ما يزال هناك بيانات أخرى بملف الدخل while there is more data in the input file: // أسنِد الكلمة التالية بالملف إلى word Let word = the next word from the file // حوِّل word إلى الحالة الصغيرة Convert word to lower case // أضِف word إذا لم تكن موجودةً بالفعل words.add(word) for ( String w : words ) // words في w من أجل كل سلسلة نصية Output w // تُطبَع الكلمات مُرتبة يُمكِنك أيضًا الاطلاع على الشيفرة الكاملة للبرنامج بالملف WordListWithTreeSet.java. لنفحص مثالًا آخرًا، بفرض أن coll تجميعةٌ من السلاسل النصية من النوع String، يُمكِننا استخدام طقمٍ من النوع TreeSet لترتيب عناصر التجميعة coll، ولحَذْف أي عناصر مُكرَّرة بكتابة الشيفرة التالية: TreeSet<String> set = new TreeSet<String>(); set.addAll(coll); تُضيِف التعليمة الثانية جميع عناصر التجميعة إلى طقم، وبما أنه من النوع Set، فسيتجاهل العناصر المُكرَّرة تلقائيًا، ونظرًا لكونه من النوع TreeSet تحديدًا، ستكون العناصر مُرتَّبة. إذا أردت تخزين بيانات طقمٍ معينٍ داخل بنية بيانات data structure مختلفة، يُمكِنك ببساطة نسخها من الطقم. تَنسَخ الشيفرة التالية عناصر طقمٍ إلى مصفوفةٍ من النوع ArrayList: TreeSet<String> set = new TreeSet<String>(); set.addAll(coll); ArrayList<String> list = new ArrayList<String>(); list.addAll(set); تَستقبل بناة constructors جميع الأصناف المُمثِلة للتجميعات ضمن لغة جافا تجميعةً من النوع Collection؛ وعند استدعاء إحداها، ستُضَاف جميع عناصر التجميعة المُمرَّرة إلى التجميعة الجديدة المُنشَئة. إذا كان coll من النوع Collection<String> مثلًا، يُنشِئ الاستدعاء new TreeSet<String>(coll) طقمًا من النوع TreeSet يحتوي على نفس العناصر الموجودة بالتجميعة coll بعد حذف أي عناصرٍ مُكرَّرة، كما أنها تكون مُرتَّبة. يُمكِننا بناءً على ذلك إعادة كتابة الأسطر الأربعة السابقة على النحو التالي: ArrayList<String> list = new ArrayList<>( new TreeSet<>(coll) ); تُنشِيء التعليمة السابقة قائمةً مُرتبةً من العناصر غير المُكرَّرة ضمن التجميعة coll. يُبيّن المثال السابق مدى فعالية البرمجة المُعمَّمة generic programming. لاحِظ أنه من غير الضروري كتابة معامل النوع String بالبانيين السابقين؛ لأن المُصرِّف compiler قادرٌ على استنتاجهما بالفعل. تُخزِّن الأطقم من النوع HashSet عناصرها ضمن بنيةٍ بيانية تُعرَف باسم جدول hash table، وسنتناول تلك البنية البيانية في المقال الموالي. تَعمَل عمليات البحث والإضافة والحذف على الجداول بكفاءة عالية، وأعلى حتى من الصنف TreeSet. بخلاف الصنف TreeSet، لا تُخزِّن الأطقم من النوع HashSet عناصرها وفقًا لأي ترتيبٍ مُحدَّد، وبالتالي لا تَكون مُضطّرةً لتنفيذ الواجهة Comparable؛ ولكن ينبغي في المقابل أن تُعرِّف شيفرة تعمية hash code مناسبة كما سنرى بالمقال التالي. يُحدِّد التابع equals() فيما إذا كان من الممكن عدّ كائنين بطقمٍ من النوع HashSet متساويين، حيث تَمرّ مُكرِّرات أطقم النوع HashSet عبر عناصرها مرورًا عشوائيًا، بل قد يتغيَّر ترتيب مرورها بالعناصر مع إضافة عنصرٍ جديد. اِستخدِم الصنف HashSet بدلًا من الصنف TreeSet إذا لم تَكْن العناصر قابلة للموازنة، أو إذا لم يَكْن ترتيبها مُهمًا، أو إذا كنت مهتمًا بكفاءة العمليات على العناصر أكثر من أي شيءٍ آخر. ملاحظة: يُطلق على عناصر الأطقم وفقًا لنظرية المجموعات set theory الحسابية أعضاء members أو عناصر elements. وتتضمَّن العمليات الهامة على تلك الأطقم ما يلي: إضافة عنصرٍ إلى مجموعة، وحذف عنصرٍ من مجموعة، وفحص فيما إذا كانت قيمةٌ ما عنصرًا ضمن مجموعة. إذا كان لدينا طقمين، يُمكِننا إجراء العمليات التالية عليهما: توحيد union طقمين، وتقاطع intersection بين طقمين، والفرق بين طقمين. تُوفِّر جافا تلك العمليات للأطقم من النوع Set، ولكن بأسماءٍ مختلفة. بفرض أن لدينا طقمين A و B، فإن: A.add(x): يُضيف العنصر x إلى الطقم A. A.remove(x): يحذف العنصر x من الطقم A. A.contains(x): يفحص إذا كانت x عنصرًا بالطقم A. A.addAll(B): يحسب اتحاد الطقمين A و B. A.retainAll(B): يحسب التقاطع بين الطقمين A و B. A.removeAll(B): يحسب الفرق بين الطقمين A - B. تختلف الأطقم بمفهومها الحسابي عن الأطقم بلغة جافا بالنقاط التالية: يجب أن تكون الأطقم نهائية finite، بينما تكون المجموعات الحسابية عادةً لا نهائية. قد تحتوي المجموعات الحسابية على عناصر عشوائية، بينما تكون الأطقم من نوعٍ محدد مثل Set<T>، ولا يُمكِنها أن تحتوي على أية عناصر غير مُنتمية للنوع T. تُعدِّل العملية A.addAll(B) قيمة A، بينما تَحسب عملية الاتحاد بين الطقمين A وB طقمًا جديدًا دون أن تُعدِّل من قيمة أيٍّ من الطقمين الأصليين. سنتعرض بالتمرين 10.2 لمثالٍ عن العمليات الحسابية على الأطقم. أرتال الأولوية يُعدّ رتل الأولوية priority queue نوعًا بيانيًا مجردًا abstract data type يُمثِّل تجميعة عناصر، حيث يُسنَد إلى كل عنصرٍ منها أولوية priority معينة، وهو ما يَسمَح بالموازنة بينها. تتضمَّن العمليات على أرتال الأولوية ما يلي: عملية add المسؤولة عن إضافة عنصرٍ إلى التجميعة. عملية remove المسؤولة عن حذف العنصر ذو الأولوية الأقل من التجميعة وإعادته قيمةً لعملية الحذف ذاتها. عملية الحذف remove بحيث تَحذف العنصر ذا الأولوية الأقل، ولكن من الممكن نظريًا حذف العنصر ذي الأولوية القصوى. يُمكنِنا تنفيذ رتل الأولوية باستخدام قائمةٍ مترابطة linked list لتخزين العناصر بحيث تكون مُرتبةً تصاعديًا وفقًا لترتيب أولوياتها. تَحذِف remove في تلك الحالة أول عنصرٍ ضمن القائمة وتُعيده؛ بينما يجب على عملية add إضافة العنصر الجديد بموضعه الصحيح ضمن القائمة، وهو ما يستغرق زمن تشغيل وسطي قدره Θ(n)، حيث n هي عدد عناصر القائمة. يُمكِننا أيضًا تنفيذ رتل الأولوية بطريقةٍ أكثر كفاءةً بحيث يكون زمن تشغيل عمليتي add و remove مُساويًا Θ(log(n))؛ وتعتمد تلك الطريقة على استخدام بنية بياناتٍ تُعرَف باسم الكومة heap، وهي مختلفةٌ عن قسم الكومة بالذاكرة الذي تُنشأ فيه الكائنات objects. يُنفِّذ الصنف PriorityQueue<T> ذو المعاملات غير مُحدَّدة النوع parameterized رتل أولوية للكائنات من النوع T، كما يُنفِّذ الواجهة Collection<T>. فإذا كان pq رتل أولويةٍ من النوع PriorityQueue، فسيحتوي على جميع التوابع methods المُعرَّفة ضمن تلك الواجهة interface. سنستعرِض فيما يلي أكثرها أهمية: pq.add(obj): يُضيف obj إلى رتل الأولوية. يجب أن يكون obj كائنًا من النوع T. pq.remove(): يَحذِف أقل العناصر أولوية، ويعيدها أي تكون القيمة المُعادة كائنٌ من النوع T، وإذا كان الرتل فارغًا، يَحدُث اعتراض exception. pq.isEmpty(): يَفْحَص إذا كان رتل الأولوية فارغًا. سنفحص الآن الطريقة التي تتحدَّد على أساسها أولوية العناصر ضمن رتل أولوية، وهي تشبه عملية الترتيب، ولهذا يجب أن نكون قادرين على موازنة أي عنصرين داخل الرتل. قد نواجه موقفًا من اثنين: إما أن تكون العناصر مُنفِّذة للواجهة Comparable، ويُستخدَم عندها التابع compareTo() المُعرَّف بتلك الواجهة لموازنة العناصر؛ أو أن نُمرِّر كائنًا من النوع Comparator مثل معاملٍ لباني الصنف PriorityQueue ويُستخدَم في تلك الحالة التابع compare المُعرَّف بالنوع Comparator للموازنة. يُمكِننا استخدام الأصناف المُنفِّذة للواجهة Comparable، مثل String و Integer و Date مع أرتال الأولوية. فعلى سبيل المثال، قد نَستخدِم رتل أولوية من السلاسل النصية PriorityQueue<String> لنُرتِّبها ترتيبًا أبجديًا على النحو التالي: سنُضيِف جميع السلاسل النصية إلى رتل الأولوية، ثم نَحذِفها واحدةً تلو الأخرى. وبما أن عناصر أرتال الأولوية تُحذَف بحسب أولويتها، فستَجِد أنها تُحذَف بحسب ترتيبها الأبجدي. كنا قد أوضحنا سابقًا استخدام طقمٍ من النوع TreeSet لترتيب تجميعةٍ من العناصر، وكذلك لحذف المُكرَّر منها، ويُمكِننا بالمثل استخدام الصنف PriorityQueue لترتيب عناصر تجميعة، ولكن بدون حذف أي عنصرٍ حتى المُكرَّر منها. إذا كانت coll مثلًا تجميعةً من النوع Collection<String>، فستطبع الشيفرة التالية جميع عناصرها بما في ذلك المُكرَّر منها: PriorityQueue<String> pq = new PriorityQueue<>(); pq.addAll( coll ); while ( ! pq.isEmpty() ) { System.out.println( pq.remove() ); } ملاحظة: لا يُمكِن اِستخدَام مُكرِّر iterator أو حلقة for-each لطباعة العناصر بالمثال السابق، لأنها لا تجتاز عناصر أرتال الأولوية priority queue وفقًا لترتيبها التصاعدي. يُنشِئ البرنامج التوضيحي WordListWithPriorityQueue.java قائمةً مُرتّبةً من الكلمات الموجودة بملفٍ معين دون أن يَحذِف أيّ كلماتٍ مُكرَّرة، حيث يُخزِّن البرنامج الكلمات برتل أولوية. يُمثِل هذا البرنامج تعديلًا بسيطًا على البرنامج الأصلي WordListWithTreeSet.java. تُستخدَم أرتال الأولوية في تطبيقاتٍ أخرى غير الترتيب، مثل تنظيم عملية تنفيذ الحاسوب لعدة وظائف jobs ذات أولوياتٍ مختلفة، وبحيث يكون ترتيب التنفيذ من الوظائف ذات الأقل أولوية فالأعلى. يُمكِننا بناءً على ذلك تخزين الوظائف برتل أولوية؛ وعندما يَحذِف الحاسوب وظيفةً من الرتل لينفِّذها، سيَحذِفها وفقًا للترتيب التصاعدي لأولويتها. ترجمة -بتصرّف- للقسم Section 2: Lists and Sets من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا تحليل زمن تشغيل القوائم المنفذة باستخدام مصفوفة تحليل زمن تشغيل القوائم المنفذة باستخدام قائمة مترابطة مفهوم المصفوفات الديناميكية (ArrayLists) في جافا
-
تَسمَح بعض اللغات البرمجية كائنية التوجه (object-oriented programming)، مثل C++، للصَنْف بأن يَتمدَّد (extend) من أكثر من مُجرّد صَنْف أعلى (superclass) واحد، وهو ما يُعرَف باسم الوراثة المُتعدّدة (multiple inheritance). بالرسم التالي مثلًا، يَتمدَّد الصنف E من صنفين أعليين (superclasses) مباشرةً، هما الصنفين A و B، بينما يَتمدَّد الصنف F من ثلاثة أصناف أعلين (superclasses) مباشرةً: أراد مُصمِّمي الجافا أن يجعلوا اللغة بسيطة على نحو معقول، ولمّا وجدوا أن مزايا الوراثة المُتعدّدة (multiple inheritance) لا تَستحِقّ ما يُقابِلها من تعقيد مُتزايد، فإنهم لم يُدعِّموها باللغة. ومع هذا، تُوفِّر الجافا ما يُعرَف باسم الواجهات (interfaces) والتي يُمكِن اِستخدَامها لتحقيق الكثير من أهداف الوراثة المُتعدّدة. لقد تَعرَّضنا -بالقسم ٤.٥- لواجهات نوع الدالة (functional interfaces) وعلاقتها بتعبيرات لامدا (lambda expressions)، ورأينا أنها تُخصِّص تابعًا (method) وحيدًا. في المقابل، يُمكِن للواجهات (interfaces) أن تَكُون أكثر تعقيدًا بمراحل كما أن لها استخدامات آخرى كثيرة. من غَيْر المُحتمَل أن تحتاج إلى كتابة واجهات (interfaces) خاصة بك حاليًا؛ فهي ضرورية فقط للبرامج المُعقَّدة نسبيًا، ولكن هنالك عدة واجهات (interfaces) مُستخدَمة بحزم جافا القياسية (Java's standard packages) بطرائق مُهِمّة وتحتاج إلى تَعلُّم طريقة اِستخدَامها. تعريف الواجهات (interfaces) وتنفيذها (implementation) لقد تَعرَّضنا لمصطلح "الواجهة (interface)" ضِمْن أكثر من سياق، سواء فيما يَتَعلَّق بالصناديق السوداء (black boxes) في العموم أو فيما يَتَعلَّق بالبرامج الفرعية (subroutines) على وجه الخصوص. تَتكوَّن واجهة أي برنامج فرعي (subroutine interface) من اسمه، ونوعه المُعاد (return type)، وعدد مُعامِلاته (parameters) وأنواعها. تُمثِل تلك المعلومات كل ما أنت بحاجة إلى مَعرِفته لكي تَتَمكَّن من استدعاء البرنامج الفرعي. بالإضافة إلى ذلك، يَمتلك أي برنامج فرعي جزءًا تّنْفيذيًا (implementation)، هو كتلة الشيفرة المُعرِّفة له (defines) والتي تُنفَّذ عند استدعاءه. بلغة الجافا، كلمة interface هي كلمة محجوزة تَحمِل معنًى تقنيًا إضافيًا. وفقًا لهذا المعنى، تَتكوَّن الواجهة من مجموعة من واجهات توابع النُسخ (instance method interfaces) بدون أجزائها التّنفيذية (implementations). يستطيع أي صنف أن يُنفِّذ (implement) واجهة معينة بتوفير الأجزاء التّنْفيذية (implementation) لجميع التوابع المُخصَّصة ضِمْن تلك الواجهة. اُنظر المثال التالي لواجهة (interface) بسيطة جدًا بلغة الجافا: public interface Strokeable { public void stroke(GraphicsContext g); } تبدو الشيفرة بالأعلى مشابهة لتعريف صنف (class definition) باستثناء حَذْف الجزء التّنْفيذي (implementation) للتابع stroke(). إذا أراد صنف معين أن يُنفِّذ تلك الواجهة Strokeable، فلابُدّ له من أن يُوفِّر جزءًا تّنْفيذيًا للتابع stroke() كما قد يَتَضمَّن أي توابع أو متغيرات آخرى. اُنظر الشيفرة التالية على سبيل المثال: public class Line implements Strokeable { public void stroke(GraphicsContext g) { . . . // ارسم خطًا } . . . // توابع ومتغيرات وبواني آخرى } لكي يُنفِّذ صنف واجهةً (interface) معينةً، ينبغي عليه أن يَفعَل أكثر من مُجرّد توفير الأجزاء التّنْفيذية (implementation) لجميع التوابع ضِمْن تلك الواجهة، فعليه تحديدًا أن يُعلن صراحةً عن تّنفيذه (implements) لتلك الواجهة باستخدام الكلمة المحجوزة implements كالمثال بالأعلى. لابُدّ لأي صنف حقيقي (concrete class) يَرغَب بتّنْفيذ الواجهة Strokeable من أن يُعرِّف تابع نسخة اسمه stroke()، لذا سيَتضمَّن أي كائن (object) مُنشَئ من هذا الصنف التابع stroke(). يُعدّ الكائن مُنفِّذًا (implement) لواجهة معينة إذا كان ينتمي لصنف يُنفِّذ (implements) تلك الواجهة، فمثلًا، يُنفِّذ أي كائن من النوع Line الواجهة Strokeable. في حين يستطيع الصنف أن يَتمدَّد (extend) من صنف واحد فقط، فإنه في المقابل يستطيع أن يُنفِّذ (implements) أي عدد من الواجهات (interfaces). وفي الواقع، يُمكِن للصنف أن يَتمدَّد (extend) من صنف آخر، وأن يُنفِّذ واجهة واحدة أو أكثر بنفس ذات الوقت، لذلك نستطيع كتابة التالي مثلًا: class FilledCircle extends Circle implements Strokeable, Fillable { . . . } على الرغم من أن الواجهات (interfaces) ليست أصنافًا (classes)، فإنها تُشبهها إلى حد كبير. في الواقع، أي واجهة (interface) هي أَشْبه ما تَكُون بصنف مُجرّد (abstract class) لا يُستخدَم لإنشاء كائنات، وإنما كقاعدة لإنشاء أصناف فرعية (subclasses). تُعدّ البرامج الفرعية (subroutines) ضِمْن أي واجهة توابعًا مجردةً (abstract methods)، والتي لابُدّ لأيّ صنف حقيقي (concrete class) يَرغَب بتّنْفيذ تلك الواجهة من أن يُنفِّذها (implement). تستطيع الموازنة بين الواجهة Strokeable والصَنْف المُجرّد (abstract class) التالي: public abstract class AbstractStrokeable { public abstract void stroke(GraphicsContext g); } يَكمُن الفرق بينهما في أن الصنف الذي يَتمدَّد (extend) من الصنف AbstractStrokeable لا يُمكِنه أن يَتَمدَّد من أي صنف آخر. أما الصنف الذي يُنفِّذ الواجهة Strokeable يستطيع أن يَتَمدَّد من أي صنف آخر كما يستطيع أن يُنفِّذ (implement) أي واجهات (interfaces) آخرى. بالإضافة إلى ذلك، يُمكِن لأي صنف مُجرّد (abstract class) أن يَتَضمَّن توابعًا غير مُجرّدة (non-abstract) وآخرى مُجرّدة (abstract). في المقابل، تستطيع أي واجهة (interface) أن تَتَضمَّن توابعًا مُجرّدة فقط، لذا فهي أَشْبه ما تَكُون بصنف مُجرّد نقي (pure). ينبغي أن تُصرِّح عن التوابع ضِمْن أي واجهة (interface) على أساس كَوْنها -أي التوابع- عامة public ومُجردّة abstract. ولمّا كان هذا هو الخيار الوحيد المُتاح، فإن تَخْصِيص هذين المُبدِّلين (modifiers) ضِمْن التّصْريح (declaration) ليس ضروريًا. إلى جانب التّصريح (method declarations) عن التوابع، يُمكِن لأي واجهة (interface) أن تُصرِّح عن وجود مُتْغيِّرات (variable declarations)، وينبغي عندها أن تُصرِّح عنها على أساس كَوْنها عامة public، وساكنة static، ونهائية final، ولذا فإنها تَصيِر عامة وساكنة ونهائية بأي صنف يُنفِّذ (implements) تلك الواجهة. ولمّا كان هذا هو الخيار الوحيد المُتاح للتّصْريح عنها، فإن تَخْصِيص تلك المُبدِّلات (modifiers) ضِمْن التّصْريح (declaration) ليس ضروريًا. اُنظر المثال التالي: public interface ConversionFactors { int INCHES_PER_FOOT = 12; int FEET_PER_YARD = 3; int YARDS_PER_MILE = 1760; } هذه هي الطريقة المناسبة لتعريف (define) ثوابت مُسماة (named constants) يُمكِن اِستخدَامها بعدة أصناف. يُمكِن لأي صنف يُنفِّذ (implements) الواجهة ConversionFactors أن يَستخدِم الثوابت المُعرَّفة بتلك الواجهة (interface) كما لو كانت مُعرَّفة بالصنف. لاحِظ أن أي مُتْغيِّر مُعرَّف ضِمْن واجهة (interface) هو بالنهاية ثابت (constant) وليس مُتْغيِّرًا على الإطلاق. وفي العموم، لا يُمكِن لأي واجهة (interface) أن تُضيف مُتْغيِّرات نُسخ (instance variables) إلى الأصناف التي تُنفِّذها (implement). يُمكِن لأي واجهة (interface) أن تَتَمدَّد (extend) من واجهة واحدة أو أكثر. على سبيل المثال، إذا كان لدينا الواجهة Strokeable المُعطاة بالأعلى، بالإضافة إلى الواجهة Fillable والتي تُعرِّف التابع fill(g)، نستطيع عندها تعريف الواجهة التالية: public interface Drawable extends Strokeable, Fillable { // المزيد من التوابع أو الثوابت } ينبغي لأي صَنْف حقيقي (concrete class) يُنفِّذ الواجهة Drawable من أن يُوفِّر الأجزاء التّنْفيذية (implementations) لكُلًا من التابع stroke() من الواجهة Strokeable، والتابع draw() من الواجهة Fillable، بالإضافة إلى أي توابع مُجرّدة (abstract methods) آخرى قد تُخصِّصها الواجهة Drawable مباشرة. عادة ما تُعرَّف (define) الواجهة (interface) ضِمْن ملف .java الخاص بها، والذي لابُدّ أن يَكُون له نفس اسم الواجهة. فمثلًا، تُعرَّف الواجهة Strokeable بملف اسمه Strokeable.java. وبالمثل من الأصناف (classes)، يُمكِن للواجهة (interface) أن تقع ضِمْن حزمة (package)، كما يُمكِنها أن تَستورِد (import) أشياءً من حزم آخرى. التوابع الافتراضية (default methods) بداية من الإصدار الثامن من الجافا، تستطيع الواجهات (interfaces) أن تَتَضمَّن ما يعرف باسم "التوابع الافتراضية (default methods)"، والتي تَملُك جزءًا تّنْفيذيًا (implementation) بعكس التوابع المُجرّدة (abstract methods) المُعتادة. تُورَث التوابع الافتراضية من الواجهات (interfaces) إلى أصنافها المُنفِّذة (implement) بنفس الطريقة التي تُورَث بها التوابع العادية من الأصناف إلى أصنافها الفرعية. لذا عندما يُنفِّذ (implement) صنف معين واجهةً تَحتوِي على توابع افتراضية، فإنه لا يَكُون مضطرًا لأن يُوفِّر جزءًا تّنْفيذيًا (implementation) لأي تابع افتراضي (default method) ضِمْن الواجهة، مع أن بإمكانه القيام بذلك إذا كان لديه تّنْفيذًا (implementation) مُختلفًا. تَدفَع التوابع الافتراضية (default methods) لغة الجافا خطوة للأمام بطريق دَعْم الوراثة المُتعدّدة (multiple inheritance)، ولكنها مع ذلك ليست وراثة مُتعدّدة بحق؛ لأن الواجهات لا تستطيع تعريف مُتْغيِّرات نُسخ (instance variables). تستطيع التوابع الافتراضية (default methods) استدعاء التوابع المجردة (abstract methods) المُعرَّفة بنفس الواجهة، لكنها لا تستطيع الإشارة إلى أي مُتْغيِّر نسخة (instance variable). ملحوظة: تستطيع واجهات نوع الدالة (functional interfaces) أيضًا أن تَحتوِي على توابع افتراضية (default methods) بالإضافة إلى التابع المُجرّد (abstract method) الوحيد الذي بإمكانها تَخْصِيصه. ينبغي أن تُصرِّح عن التوابع الافتراضية (default methods) على أساس كَوْنها عامة public، ولمّا كان ذلك هو الخيار الوحيد المُتاح، فإن تَخْصِيص المبدل public ضِمْن التّصْريح (declaration) ليس ضروريًا. في المقابل، لابُدّ من كتابة المُبدِّل default بشكل صريح أثناء التّصْريح عن أي تابع افتراضي (default method). اُنظر المثال التالي: public interface Readable { // تمثل مصدر إدخال public char readChar(); // اقرأ المحرف التالي المُدْخَل default public String readLine() { //اقرأ حتى نهاية السطر StringBuilder line = new StringBuilder(); char ch = readChar(); while (ch != '\n') { line.append(ch); ch = readChar(); } return line.toString(); } } لابُدّ لأي صنف حقيقي (concrete class) يُنفِّذ الواجهة (interface) -بالأعلى- من أن يُوفِّر تّنْفيذًا (implementation) للتابع readChar(). في المقابل، سيَرِث ذلك الصنف تعريف readLine() من الواجهة، ولكنه قد يُوفِّر تعريفًا (definition) جديدًا إذا كان ذلك ضروريًا. عندما يَتَضمَّن صنف معين تّنفيذًا (implementation) لتابع افتراضي (default method)، فإن ذلك التّنْفيذ الجديد يُعيد تعريف (overrides) التابع الافتراضي الموجود بالواجهة (interface). بالمثال السابق، يَستدعِي التابع الافتراضي readLine() التابع المُجرّد readChar()، والذي يَتوفَّر تعريفه (definition) فقط من خلال الأصناف المُنفِّذة للواجهة، ولهذا تُعدّ الإشارة إلى readChar() مُتعدِّدة الأشكال (polymorphic). كُتب التّنْفيذ الافتراضي للتابع readLine() بحيث يَكُون ملائمًا لأي صنف يُنفِّذ الواجهة Readable. اُنظر الشيفرة التالية والتي تَتَضمَّن صنفًا يُنفِّذ الواجهة Readable كما يَحتوِي على البرنامج main() لاختبار الصَنْف: public class Stars implements Readable { public char readChar() { if (Math.random() > 0.02) return '*'; else return '\n'; } public static void main(String[] args) { Stars stars = new Stars(); for (int i = 0 ; i < 10; i++ ) { // اِستدعي التابع الافتراضي String line = stars.readLine(); System.out.println( line ); } } } تُوفِّر التوابع الافتراضية (default methods) إمكانية شبيهة لما يُعرَف باسم "المخلوط (mixin)" المُدعَّم ببعض اللغات البرمجية الآخرى، والتي تَعنِي المقدرة على خَلْط وظائف مصادر آخرى إلى داخل الصنف. لمّا كان بإمكان أي صنف أن يُنفِّذ أي عدد من الواجهات (interfaces)، فإنه يستطيع خلط وظائف عدة مصادر آخرى مختلفة. الواجهات كأنواع كما هو الحال مع الأصناف المُجرّدة (abstract classes)، لا يُمكِنك إنشاء كائن فعليّ من واجهة (interface)، ولكن تستطيع التّصْريح (declare) عن مُتْغيِّر نوعه عبارة عن واجهة. لنَفْترِض مثلًا أن لدينا الواجهة Strokeable المُعرَّفة بالأعلى، ويُنفِّذها كُلًا من الصنفين Line و Circle، يُمكِنك عندها كتابة التالي: // صرح عن متغير من النوع Strokeable والذي يمكنه الإشارة إلى أي // كائن ينفذ تلك الواجهة Strokeable figure; figure = new Line(); // يشير إلى كائن من الصنف Line figure.stroke(g); // اِستدعي التابع stroke() من الصنف Line figure = new Circle(); // يشير الآن إلى كائن من الصنف Circle figure.stroke(g); // اِستدعي التابع stroke() من الصنف Circle يُمكِن لأي مُتْغيِّر من النوع Strokeable أن يُشير إلى أي كائن طالما كان صَنْفه يُنفِّذ الواجهة Strokeable. لمّا كان figure مُتْغيِّرًا من النوع Strokeable، ولأن أي كائن من النوع Strokeable يَحتوِي على التابع stroke()، فحتمًا سيَحتوِي الكائن الذي يُشير إليه المُتْغيِّر figure على التابع stroke()، ولهذا فإن التَعْليمَة figure.stroke(g) صالحة تمامًا. تُستخدَم الأنواع (types) في العموم إما للتّصْريح (declare) عن مُتْغيِّر، أو لتَخْصِيص نوع معامل برنامج فرعي (routine)، أو لتَخْصِيص النوع المُعاد (return type) من دالة (function). النوع في العموم إما أن يَكُون صنفًا أو واجهة (interface) أو أحد الأنواع البسيطة (primitive) الثمانية المَبنية مُسْبَقًا (built-in). ليس هنالك من أيّ احتمال آخر، ربما باستثناء بعض الحالات الخاصة كأنواع التعداد (enum) والتي هي بمثابة نوع خاص من الأصناف. من بين كل تلك الأنواع، الأصناف هي الوحيدة التي يُمكِن اِستخدَامها لإنشاء كائنات (objects). يُمكِنك أيضًا اِستخدَام الواجهات (interface) لتَخْصِيص النوع الأساسي (base type) لمصفوفة. فمثلًا، تستطيع أن تُصرِّح عن مُتْغيِّر أو أن تُنشِئ مصفوفة باستخدام نوع المصفوفة Strokeable[]، وفي تلك الحالة، يُمكِن لعناصر تلك المصفوفة الإشارة إلى أي كائن طالما كان يُنفِّذ الواجهة Strokeable. اُنظر الشيفرة التالية: Strokeable[] listOfFigures; listOfFigures = new Strokeable[10]; listOfFigures[0] = new Line(); listOfFigures[1] = new Circle(); listOfFigures[2] = new Line(); . . . تَملُك جميع عناصر تلك المصفوفة التابع stroke()، مما يَعنِي إمكانية كتابة تعبيرات مثل listOfFigures.stroke(g). ترجمة -بتصرّف- للقسم Section 7: Interfaces من فصل Chapter 5: Programming in the Large II: Objects and Classes من كتاب Introduction to Programming Using Java.
-
تُشير البرمجة المُعمَّمة generic programming إلى كتابة شيفرةٍ يُمكِن تطبيقها على أنواعٍ كثيرة من البيانات. كنا قد تعرَّضنا بمقال معالجة المصفوفات Arrays في جافا للمصفوفات الديناميكية، والتي يُمكِن عدّها بديلًا عن البرمجة المُعمَّمة، وكتبنا شيفرةً تَعمَل مع مصفوفةٍ ديناميكية من الأعداد الصحيحة. في الحقيقة، لا يُمكِن لتلك الشيفرة أن تَعمَل مع أي نوعٍ غير النوع int، رغم أن الشيفرة المصدرية للمصفوفات الديناميكية من النوع double أو String أو Color أو أي نوع بياناتٍ آخر هي نفسها تقريبًا باستثناء تبديل اسم النوع؛ وبالتالي يبدو من الحماقة إعادة كتابة نفس الشيفرة مرارًا وتكرارًا. تُقدِّم جافا حلًا لتلك المشكلة يُعرَف باسم الأنواع ذات المعاملات غير مُحدَّدة النوع parameterized types. كما رأينا بمقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا، يُنفِّذ الصنف ArrayList المصفوفات الديناميكية، ونظرًا لكونه نوعًا ذا معاملاتٍ غير مُحدَّدة النوع، فستَجِد أنواعًا؛ مثل النوع ArrayList<String> لتمثيل مصفوفةٍ ديناميكيةٍ من السلاسل النصية من النوع String؛ والنوع ArrayList<Color> لتمثيل مصفوفة ديناميكية من الألوان؛ والنوع ArrayList<T> لأي نوع كائن T. لاحِظ أن ArrayList هي صنفٌ واحدٌ فقط، ولكنه مُعرَّف ليَعمَل مع أنواعٍ مختلفةٍ كثيرة، وهذا هو صميم البرمجة المُعمَّمة. الصنف ArrayList هو في الواقع مجرد صنفٍ واحدٍ ضمن عددٍ كبيرٍ من أصناف جافا القياسية standard classes التي تَستخدِم البرمجة المُعمَّمة. سنناقش خلال المقالات الثلاثة التالية بعضًا من تلك الأصناف، ونتعرَّف على طريقة استخدامها، كما سنتعرَّض لواجهات interfaces وتوابع methods مُعمَّمة. لاحِظ أن جميع الأصناف والواجهات التي سنناقشها خلال تلك الأقسام مُعرَّفةٌ بحزمة package java.util، لذلك ستحتاج إلى كتابة التعليمة import ببداية برامجك لتتمكّن من استخدامها. سنرى في مقال لاحق من هذه السلسلة إمكانية تعريف define أصنافٍ وواجهاتٍ وتوابعٍ مُعمَّمة، ولكن لحين وصولنا إلى تلك الجزئية، سنكتفي بأصناف جافا المُعمَّمة المُعرَّفة مُسبقًا. أخيرًا، سنفحص مجاري التدفق streams بالقسم قادم أيضًا؛ وهي خاصيةٌ جديدةٌ نسبيًا بلغة جافا، وتَستخدِم البرمجة المُعمَّمة بكثرة. لا يُعدّ تصميم مكتبةٍ للبرمجة المُعمَّمة أمرًا سهلًا؛ في حين يتميز التصميم الذي تقترحه جافا لتنفيذ هذا النوع من البرمجة بالكثير من الخاصيات الرائعة، إلا أنه ليس الأسلوب الوحيد الممكن، وهو بالتأكيد ليس الحل الأمثل؛ حيث يُعاني -من وجهة نظر الكاتب- من بعض الخاصيات الشاذة، والتي قد تكون مع ذلك أفضل ما يمكن تحقيقه بالنظر إلى التصميم العام للغة جافا. سنَمُرّ الآن سريعًا على بعض المناهج الأخرى لتنفيذ البرمجة المُعمّمة، لتُكوِّن منظورًا عامًا عنها. البرمجة المعممة بلغة Smalltalk تُعدّ لغة Smalltalk واحدةً من أولى لغات البرمجة كائنية التوجه object-oriented، حيث لا تزال مُستخدَمةً حاليًا، ولكنها ليست شائعة. على الرغم من عدم وصولها إلى شعبية لغاتٍ، مثل Java و ++C، إلا أنها ألهمت الكثير من الأفكار المُستخدَمة بتلك اللغات. تُعد البرمجة بلغة Smalltalk مُعمَّمة بصورةٍ أساسيية نظرًا لتمتُّعها بخاصيتين أساسيتين، هما: ليس المُتغيَّرات بلغة Smalltalk نوعًا؛ أي أن قيم البيانات لها نوعٌ مثل عددٍ صحيح أو سلسلة نصية، ولكن المُتغيَّرات ليس لها نوع، حيث يُمكِن لمُتغيِّر مُعين أن يَحمِل قيمًا بيانيةُ من أي نوع. وبالمثل، لا يوجد للمُعاملات parameters نوعًا، أي يُمكِن تطبيق برنامجٍ فرعي subroutine معينٍ على قيم معاملاتٍ من أي نوع؛ كما يُمكِن لبنيةٍ بيانيةٍ data structure مُعينةٍ أن تَحمِل قيمًا بيانيةً من أي نوع. حيث يمكنك مثلًا وبمجرد تعريف بنية شجرةٍ ثنائيةٍ معينة بلغة Smalltalk، استخدامها مع الأعداد الصحيحة، أو السلاسل النصية، أو التواريخ، أو أي بياناتٍ من أي نوعٍ آخر؛ أي ليس هناك حاجةً لكتابة شيفرةٍ جديدةٍ لكل نوعٍ بياني. تُعدّ أي قيمةٍ بيانيةٍ بمثابة كائن object، كما أن جميع العمليات المُمكِن تطبيقها على الكائنات مُعرَّفةٌ مثل توابع methods ضِمْن صنفٍ معين، ويَنطبِق الأمر نفسه على جميع الأنواع الأساسية primitive بلغة جافا، مثل الأعداد الصحيحة. يعني ذلك أنه عند استخدِام العامل + مثلًا لجمع عددين صحيحين، تُنفَّذ العملية باستدعاء التابع المُعرَّف ضمن الصنف المُمثِل للأعداد الصحيحة. وبالمثل، عندما تُعرِّف صنفًا جديدًا، يُمكِنك تعرّيف العامل + ضمنه، ويمكنك بالتالي كتابة a + b لجمع كائناتٍ تنتمي إلى ذلك الصنف تمامًا مثلما تجمع أعدادًا. لنفترض الآن أنك تَكتُب برنامجًا فرعيًا يَستخدِم العامل + لجمع العناصر الموجودة ضمن قائمة list، حيث تستطيع أن تُطبِق ذلك البرنامج على قائمة أعدادٍ صحيحة أو على قائمةٍ من أي نوعٍ آخر يَتَضمَّن تعريفًا للعامل +. يكون الأمر نفسه إذا عرَّفت برنامجًا فرعيًا يَستخدِم العامل > لترتيب قائمة، حيث يُمكِنك تطبيقه على قوائمٍ تحتوي على أي نوعٍ من البيانات طالما يَتَضمَّن ذلك النوع تعريفًا للعامل >، أي ليس هناك حاجةً لكتابة برنامجٍ فرعي لكل نوعٍ مختلفٍ من البيانات. إذا توفَّرت الخاصيتان السابقتان ضمن لغةٍ معينة، يُمكِننا تطبيق الخوارزميات وبنى البيانات على أي نوعٍ من البيانات طالما كانت العمليات المناسبة مُعرَّفةً ضمن تلك الأنواع، حيث يُمثِل ذلك ماهية البرمجة المُعمَّمة. ربما ترى أن ذلك أمرًا مفيدًا دائمًا، وهو ما قد يدفعك إلى طرح السؤال التالي: لما لا تعمل كل اللغات البرمجية ببساطةٍ بنفس الأسلوب؟ في الحقيقة، في حين يُسهِل ذلك النوع من الحرية من كتابة البرامج، إلا أنه يُصعِّب مهمة كتابتها كتابةً صحيحةً متينة. إذا كان لديك مثلًا بنيةً بيانيةً قادرةً على حمل أي نوعٍ من البيانات، ستَجِد أنه من الصعب التأكُّد من كونها تَحمِل فقط النوع المطلوب من البيانات؛ وبالمثل، إذا كان لديك برنامجًا فرعيًا يُمكِنه ترتيب أي نوعٍ من البيانات، ستَجِد أنه من الصعب التأكُّد من تطبيقه فقط على تلك الأنواع التي تتضمَّن تعريفًا للعامل >. ليس هناك طريقةً تُمكِّن المُصرِّف compiler من التأكُّد من مثل تلك الأشياء، وعليه ستَظهَر المشكلة فقط أثناء زمن التشغيل run time، أي عند محاولة تطبيق عمليةٍ معينةٍ على نوع بياناتٍ لا يتضمَّن تعريفًا لتلك العملية، وعندها سينهار crash البرنامج. البرمجة المعممة بلغة C++ تُعدّ لغة C++ -بخلاف Smalltalk- لغةً صارمةً في تحديد النوع strongly typed، حيث يوجد لكل متغيرٍ variable نوعًا معينًا، ويُمكنه أن يَحمِل فقط قيمًا بيانيةً تنتمي إلى ذلك النوع؛ يعني ذلك أنه يستحيل تطبيق البرمجة المعمَّمة بنفس الكيفية المتوفرة بلغة Smalltalk. تتوفّر خاصية القوالب templates بلغة C++ مما يُمكِّنها من تطبيق نظامٍ آخر من البرمجة المُعمَّمة، حيث يُمكِنك ببساطةٍ كتابة قالب برنامجٍ فرعي subroutine template واحدٍ، بدلًا من كتابة برنامجٍ فرعي subroutine مختلف لترتيب كل نوعٍ من البيانات. لاحِظ أن القالب ليس برنامجًا فرعيًا، وإنما هو أشبه بمصنعٍ لإنشاء البرامج الفرعية. اُنظر المثال التالي: template<class ItemType> void sort( ItemType A[], int count ) { //1 for (int i = count-1; i > 0; i--) { int position_of_max = 0; for (int j = 1; j <= i ; j++) if ( A[j] > A[position_of_max] ) position_of_max = j; ItemType temp = A[i]; A[i] = A[position_of_max]; A[position_of_max] = temp; } } حيث تعني [1]: رتِّب عناصر المصفوفة A ترتيبًا تصاعديًا، حيث تُرتَّب العناصر الموجودة بالمواضع 0 و 1 و 2 وصولًا إلى count-1، وتُدعى الخوارزمية المُستخدَمة للترتيب باسم الترتيب الانتقائي selection sort. تُعرِّف الشيفرة بالأعلى قالب برنامجٍ فرعي؛ فإذا حذفت السطر الأول template<class ItemType>، واستبدلت كلمة int بكلمة ItemType بباقي شيفرة القالب، فستَكون النتيجة برنامجًا فرعيًا subroutine بإمكانه ترتيب مصفوفاتٍ من الأعداد الصحيحة. يُمكِنك في الواقع استبدال أي نوعٍ بالنوع ItemType بما في ذلك الأنواع الأساسية primitive types، حيث يمكنك مثلًا استبدال كلمة string بكلمة ItemType للحصول على برنامج فرعي يُرتِّب مصفوفات سلاسل نصية. هذه ببساطةٍ الطريقة التي يتعامل بها المُصرِّف مع أي قالب؛ فإذا كتبت sort(list,10) بالبرنامج، حيث list مصفوفة أعدادٍ صحيحة من النوع int، فسيستخدم المُصرِّف القالب لتوليد برنامجٍ فرعي لترتيب مصفوفة أعدادٍ صحيحة؛ إذا كتبت sort(cards,10)، حيث cards هي مصفوفة كائناتٍ objects من النوع Card، فسيولّد المُصرِّف برنامجًا فرعيًا لترتيب مصفوفة كائناتٍ من النوع Card. يَستخدِم القالب العامل > للموازنة بين القيم؛ فإذا كان ذلك العامل مُعرَّفًا للقيم من النوع Card، فسينجَح المُصرِّف بتوليد برنامجٍ فرعي لترتيب كانئات النوع Card بالاستعانة بالقالب؛ أما إذا لم يَكُن العامل > مُعرَّفًا للصنف Card، فسيَفشَل المُصرِّف أثناء زمن التصريف compile time وليس أثناء زمن التشغيل run time، مما يتسبَّب بانهيار البرنامج كما هو الحال بلغة Smalltalk. يُمكِنك كتابة تعريفاتٍ لعوامل مثل > لأي نوعٍ بلغة C++، أي يُمكِن للعامل > أن يَعمَل مع قيمٍ من النوع Card. توفَّر C++ قوالبًا للأصناف بالإضافة إلى قوالب البرامج الفرعية subroutine templates؛ فإذا كتبت قالبًا لصنف شجرة ثنائية، فسيُمكِنك استخدِامه لإنشاء أصناف أشجارٍ ثنائيةٍ مُكوَّنةٍ من أعدادٍ صحيحةٍ من النوع int، أو سلاسل نصية من النوع string، أو تواريخ، أو غير ذلك وجميعها بنفس القالب. تأتي الإصدارات الأحدث من C++ مصحوبةً بعددٍ كبيرٍ من القوالب المكتوبة مُسبقًا، فيما يُعرَف باسم مكتبة القوالب القياسية Standard Template Library -أو اختصارًا STL-، والتي يراها الكثير معقدةً للغاية، ولكنها تُعدُّ مع ذلك واحدةً من أكثر خاصيات C++ تشويقًا. البرمجة المعممة بلغة جافا Java مرّت البرمجة المُعمَّمة بلغة جافا بعدة مراحلٍ من التطوير، وفي حين لم تتضمَّن الإصدارات الأولى من اللغة خاصية الأنواع ذات المعامِلات غير محدَّدة النوع parameterized types، إلا أنها وفَّرت أصنافًا تُمثِل بنى البيانات data structures الشائعة؛ حيث صُمِّمت تلك الأصناف لتعمل مع النوع Object أي يُمكِنها أن تَحمِل أي نوعٍ من الكائنات objects. لم تكن هناك أي طريقةٍ لتخصيص أو قصر أنواع الكائنات المسموح بتخزينها ضمن بنيةٍ بيانيةٍ معينة، حيث لم يَكُن الصنف ArrayList مبدئيًا ضمن الأنواع ذات المعاملات غير محدَّدة النوع، أي كان من الممكن لأي مُتغيِّر من الصنف ArrayList أن يحمل أي نوعٍ من الكائنات. إذا كان list مثلًا متغيرًا من النوع ArrayList، فسيعيد list.get(i) قيمةً من النوع Object، وبالتالي إذا استخدم المبرمج المُتغيِّر list لتخزين سلاسلٍ نصية من النوع String، فسيتوجب عليه تحويل نوع type-cast القيمة المعادة من list.get(i) إلى سلسلةٍ نصية من النوع String على النحو التالي: String item = (String)list.get(i); يقع ذلك تحت تصنيف البرمجة المُعمَّمة؛ حيث يُمكِننا بالنهاية استخدام صنفٍ واحدٍ فقط للعمل مع أي نوعٍ من الكائنات، ولكنه في الواقع أشبه بلغة Smalltalk منه بلغة C++. وكما هو الحال مع لغة Smalltalk، سينتج عن ذلك حدوث الأخطاء أثناء زمن التشغيل لا زمن التصريف compile time؛ فإذا افترض مبرمجٌ مثلًا أن جميع العناصر الموجودة ضمن بنيةٍ بيانيةٍ معينة هي سلاسلٌ نصية strings، وحاول معالجتها بناءً على ذلك الأساس؛ فسيقع خطأٌ أثناء زمن التشغيل run time إذا احتوت تلك البنية على نوعٍ آخر من البيانات. عندما يحاول البرنامج أن يُحوِّل type-cast نوع قيمةٍ واقعةٍ ضمن بنيةٍ بيانيةٍ data structure إلى النوع String، سيقع خطأ من النوع ClassCastException بلغة جافا إذا لم تكن تلك القيمة من النوع String بالأساس. أضاف الإصدار الخامس من جافا خاصية الأنواع ذات المعاملات غير محدَّدة النوع، مما ساعد على إنشاء بنى بياناتٍ مُعمَّمة generic data structures يُمكِن فحص نوعها أثناء زمن التصريف لا زمن التشغيل. إذا كانت list مثلًا قائمةٌ من النوع ArrayList<String>، فسيَسمَح المُصرِّف بإضافة الكائنات التي تنتمي إلى النوع String فقط إلى تلك القائمة list. علاوةً على ذلك، تَكون القيمة المعادة من استدعاء list.get(i) من النوع String، ولهذا ليس من الضروري تحويلها إلى النوع الفعلي type-casting. تُعدّ الأصناف ذات المعاملات غير محدَّدة النوع بلغة جافا شبيهةً نوعًا ما بأصناف القوالب template classes بلغة C++، على الرغم من اختلاف طريقة التنفيذ. سنعتمد خلال هذا الفصل على تلك الأصناف، ولكن عليك أن تتذكَّر بأن استخدام تلك المعاملات ليس إلزاميًا عند التعامل مع تلك الأصناف، فما يزال بإمكانك استخدام صنفٍ ذو معاملاتٍ غير محدَّدة النوع كما لو لم يَكُن كذلك، مثل كتابة ArrayList، حيث يُمكِن لأي كائنٍ أن يُخزَّن ضمنه في تلك الحالة؛ وإذا كان ذلك ما تريده حقًا، فمن الأفضل كتابة النوع ArrayList<Object>. لاحِظ وجود فرقٍ كبير بين الأصناف ذات المعاملات غير محدَّدة النوع بلغة جافا Java وبين أصناف القوالب بلغة C++؛ حيث لا تُعدّ أصناف القوالب بلغة C++ أصنافًا من الأساس، ولكنها بمثابة مصانعٍ لإنشاء الأصناف. في كل مرةٍ نَستخدِم خلالها صنف قالبٍ template class معين مع نوعٍ جديد، فسيُصرِّف المُصرِّف صنفًا جديدًا؛ أما بالنسبة للغة جافا، فهناك ملف صنف مُصرَّف وحيد لكل صنفٍ ذو معاملاتٍ غير مُحدَّدة النوع، حيث يوجد مثلًا ملف صنفٍ وحيدٍ اسمه ArrayList.class للصنف ArrayList، وتَستخدِم أنواعٌ، مثل ArrayList<String> و ArrayList<Integer> نفس ذلك الملف المُصرَّف كما هو الحال مع النوع ArrayList العادي. يقتصر دور معامل النوع type parameter، مثل String أو Integer في الواقع على تبليغ المُصرِّف بوجوب تقييد نوع الكائنات المسموح بتخزينها ضمن تلك البنية البيانية، وليس له أي تأثيرٍ خلال زمن التشغيل، حيث يُقال عندها أن معلومة النوع قد حُذِفَت أثناء زمن التشغيل run time، مما يؤدي إلى الكثير من الأمور الغريبة نوعًا ما. لا يُمكِنك مثلًا فحص اختبارٍ مثل: if (list instanceof ArrayList<String>) لأن قيمة العامل instanceof تُحصَّل أثناء زمن التشغيل، ولا يوجد سوى الصنف العادي ArrayList أثناء زمن التشغيل. ولا يُمكِنك أيضًا إجراء تحويلٍ بين الأنواع type-cast إلى ArrayList<String>، بل حتى لا تستطيع استخدام العامل new لإنشاء مصفوفةٍ نوعها الأساسي base type هو ArrayList<String>، كأن تَكْتُب: new ArrayList<String>[N] لأن العامل new يُحصَّل أثناء زمن التشغيل، ولا يكون هناك شيءٌ اسمه ArrayList<String> في ذلك الوقت كما ذكرنا مُسبَقًا، وإنما فقط النوع العادي ArrayList بدون معاملاتٍ غير مُحدَّدة النوع non-parameterized. على الرغم من عدم القدرة على إنشاء مصفوفةٍ من النوع ArrayList<String>، يُمكِنك إنشاء قائمةٍ من النوع ArrayList تحتوي على قائمةٍ أخرى من النوع ArrayList<String>، ويُكتَب النوع على النحو التالي: ArrayList<ArrayList<String>> تظهَر تلك المشكلات لحسن الحظ فقط بالبرمجة المتقدمة نوعًا ما، حيث لا يُواجِه غالبية المبرمجين الذين يَستخدِمون الأصناف ذات المعاملات غير محدَّدة النوع تلك المشكلات، ويُمكِنهم الاستفادة من نموذج البرمجة المُعمَّمة وبأنواع بياناتٍ آمنة type-safe دون أي صعوبة. لاحِظ أنه في حالة كان المُصرِّف قادرًا على استنتاج اسم معامل النوع type parameter المُستخدَم بصنفٍ ذي معاملاتٍ غير مُحدَّدة النوع، يمكن عندها حَذْف اسم معامل النوع. نظرًا لأن المصفوفة المُنشأة في المثال التالي ستكون حتمًا من النوع ArrayList<String> لتتوافق مع نوع المُتغيِّر، فمن الممكن حَذْف كلمة String بتعليمة الباني constructor على النحو التالي: ArrayList<String> words = new ArrayList<>(); إطار جافا للتجميعات تُوفِّر جافا عدة أنواعٍ ذات معاملاتٍ غير مُحدَّدة النوع لتمثيل بنى البيانات الشائعة، حيث يُشار عادةً إلى تلك المجموعة من الأصناف والواجهات interfaces باسم إطار عمل جافا للتجميعات Java Collection Framework -أو اختصارًا JCF-، والتي سنناقشها خلال الأقسام القليلة التالية. يُمكِننا تقسيم بنى البيانات المُعمَّمة generic بإطار عمل جافا للتجميعات إلى تصنيفين، هما التجميعات collections والخرائط maps؛ حيث تشير التجميعة ببساطة إلى تجميعةٍ من الكائنات؛ أما الخريطة فهي تَربُط كائنات مجموعة بكائنات مجموعةٍ أخرى بنفس الأسلوب الذي يَربُط به القاموس التعريفات بالكلمات، أو يَربُط به دليل الهاتف أرقام الهواتف بأسماء الأشخاص. تُشبِه الخريطة القوائم المترابطة association list، التي ناقشناها بمقال البحث والترتيب في المصفوفات Array في جافا؛ حيث تُمثِل الواجهتين Collection<T> و Map<T,S> ذواتا المعاملات غير مُحدَّدة النوع التجميعات والخرائط بلغة جافا، بحيث تُشير T و S إلى أي نوعٍ باستثناء الأنواع الأساسية. تُعدّ الواجهة Map<T,S> مثالًا على الأنواع ذات المعاملات غير مُحدَّدة النوع، ويَملُك تحديدًا معاملي نوع type parameters، هما T و S. سنتناول خلال هذا المقال التجميعات، بينما سنناقش الخرائط تفصيليًا في مقال قادم. تُقسم التجميعات بدورها إلى نوعين، هما القوائم lists والأطقم sets؛ حيث تُخزِّن القوائم الكائنات الموجودة بها وفقًا لتسلسلٍ خطيٍ معين، وهذا يَعنِي أنه يُمكِننا الإشارة إلى العنصر الأول أو الثاني الموجود ضمن قائمةٍ معينة. علاوةً على ذلك، لا بُدّ أن يَتبَع أي عنصرٍ ضمن القائمة باستثناء العنصر الأخير عنصرًا آخرًا. في المقابل، لا يُمكِن لأي طقمٍ set أن يتضمَّن نفس الكائن أكثر من مرة، ولا تُعدّ العناصر الموجودة به مُرتَّبةً وفقًا لأي ترتيب، أي لا ينبغي أن تفكر بها على هذا الأساس. تُمثِل الواجهتان List<T> و Set<T> القوائم والأطقم على الترتيب، وهما مُشتقتان من الواجهة Collection<T>، وهذا يَعنِي أن الكائنات المُنفِّذة للواجهة List<T> أو Set<T> تُنفِّذ الواجهة Collection<T> أيضًا على نحوٍ تلقائي. تُخصِّص الواجهة Collection<T> العمليات العامة المُمكن تطبيقها على أي تجميعة؛ بينما تُخصِّص الواجهتان List<T> أو Set<T> أي عملياتٍ إضافيةٍ أخرى ضروريةً للقوائم والأطقم على الترتيب. لاحِظ أن أي كائن فعليّ سواءٌ كان تجميعةً، أو قائمةً، أو طقمًا set، لا بُدّ أن ينتمي إلى صنفٍ حقيقي concrete class يُنفِّذ الواجهة المقابلة. يُنفِّذ الصنف ArrayList<T> الواجهة List<T> على سبيل المثال، ويُنفِّذ بالتالي Collection<T>؛ وهذا يعني أننا نستطيع استخدام جميع التوابع المُعرَّفة بواجهتي القوائم والتجميعات مع النوع ArrayList. سنفحص أصنافًا مختلفة تُنفِّذ واجهتي الطقم والقائمة بالمقال التالي، ولكن قبل أن نفعل ذلك، سنناقش سريعًا بعضًا من العمليات العامة المتاحة بأي تجميعة. تُخصِّص الواجهة Collection<T> توابعًا لإجراء عددٍ من العمليات الأساسية على أي تجميعةٍ من الكائنات. بما أن التجميعة مفهومٌ عام، فإن العمليات التي يُمكِن تطبيقها عليها في العموم عامةٌ أيضًا؛ وهذا يعني أنها عملياتٌ مُعمَّمة أي قابلة للتطبيق على أنواعٍ مختلفة من التجميعات التي تحتوي بدورها على أنواعٍ مختلفة من الكائنات. فإذا كان coll كائنًا يُنفِّذ الواجهة Collection<T> على سبيل المثال، تَكُون العمليات التالية مُعرَّفةً له: coll.size(): تُعيد عددًا صحيحًا من النوع int يُمثِل عدد الكائنات الموجودة بالتجميعة. coll.isEmpty(): تُعيد قيمةً من النوع المنطقي boolean، حيث تَكُون مساويةً للقيمة true إذا كان حجم التجميعة يُساوي الصفر. coll.clear(): تَحذِف جميع الكائنات الموجودة بالتجميعة. coll.add(tobject): تُضيف tobject إلى التجميعة. لا بُدّ أن يَكون المعامل من النوع T، وإلا سيَحدُث خطأ في بناء الجملة syntax error أثناء زمن التصريف compile time. إذا كان T صنف، فإنه يَشمَل جميع الكائنات التي تنتمي لأي صنفٍ فرعي subclass مُشتقٍّ من T؛ أما إذا كان T واجهة، فإنه يَتضمَّن أي كائنٍ مُنفّذٍ لتلك الواجهة T. يُعيد التابع add() قيمةً من النوع المنطقي تُمثِّل فيما إذا كان التابع قد أجرى تعديلًا فعليًا على التجميعة أم لا؛ فإذا أضفت كائنًا إلى طقمٍ set معين، وكان ذلك الكائن موجودًا بالفعل ضمن ذلك الطقم، لا يكون للتابع أي تأثير. coll.contains(object): تُعيد قيمةً من النوع المنطقي، والتي تكون مساويةً القيمة true إذا كان object موجودًا بالتجميعة. لاحِظ أنه من غير الضروري أن يكون المُعامل object من النوع T؛ فقد ترغب بفحص ما إذا كان object موجودًا ضمن التجميعة بغض النظر عن نوعه. بالنسبة لعملية اختبار التساوي، تُعدّ القيمة الفارغة null مُساويةً لنفسها؛ أما بالنسبة للكائنات غير الفارغة، يختلف المقياس الذي يُحدَّد على أساسه تَساوِي تلك الكائنات من عدمه من نوع تجميعةٍ إلى آخر. coll.remove(object): تَحذِف object من التجميعة إذا كان موجودًا بها، وتُعيد قيمةً من النوع المنطقي تُحدِّد فيما إذا كان التابع قد عثر على object ضمن التجميعة أم لا. ليس من الضروري أن يكون المعامل object من النوع T. تُجرَى عملية اختبار التساوي بنفس الطريقة المُتبعَّة بالتابع contains. coll.containsAll(coll2): تُعيد قيمةً منطقيةً تَكُون مساويةً للقيمة true إذا كانت جميع كائنات التجميعة coll2 موجودةً أيضًا بالتجميعة coll، ويُمكِن للمعامل coll2 أن ينتمي لأي نوع تجميعة. coll.addAll(coll2): تُضيف جميع كائنات التجميعة coll2 إلى coll، حيث يُمكِن للمعامل coll2 أن يُمثِل أي تجميعةٍ من النوع Collection<T>، ولكنه قد يكون أيضًا أعم من ذلك. على سبيل المثال، إذا كان T صنف و S صنفٌ فرعي subclass مُشتقٌ من T، فقد يكون coll2 من النوع Collection<S>، وهو أمرٌ منطقي لأن أي كائن من النوع S هو بالضرورة من النوع T، وبالتالي يُمكِن إضافته إلى coll. coll.removeAll(coll2): تَحذِف أي كائنٍ من التجميعة coll إذا كان موجودًا أيضًا بالتجميعة coll2، حيث يُمكِن للمعامل coll2 أن ينتمي لأي نوع تجميعة. coll.retainAll(coll2): تَحذِف أي كائنٍ من التجميعة coll إذا لم يَكُن موجودًا بالتجميعة coll2؛ أي أنها تُبقِي فقط الكائنات غير الموجودة بالتجميعة coll2، ويُمكِن للمعامل coll2 أن ينتمي لأي نوع تجميعة. coll.toArray(): تُعيد مصفوفةً من النوع Object[] تحتوي على جميع عناصر التجميعة. نلاحظ أن النوع المُعاد من التابع هو Object[] وليس T[]. هناك في الواقع نسخةٌ أخرى من نفس التابع coll.toArray(tarray)، والتي تَستقبِل مصفوفةً من النوع T[] مثل مُعامل وتُعيد مصفوفةً من النوع T[] تحتوي على جميع عناصر التجميعة. في حال كانت المصفوفة المُمرَّرة tarray كبيرةً بما يكفي لحَمْل جميع عناصر التجميعة، فسيُخزَّن التابع العناصرأيضًا بنفس المصفوفة المُمرَّرة ويُعيدها مثل قيمة للتابع؛ أما إذا لم تَكُن المصفوفة كبيرة بما يكفي، يُنشِئ التابع مصفوفةً جديدةً لحمل تلك العناصر، ويَقتصِر في تلك الحالة دور المصفوفة المُمرَّرة tarray على تحديد نوع المصفوفة المُعادة فقط. يُمكِننا مثلًا استدعِاء coll.toArray(new String[0]) إذا كان coll تجميعةً من السلاسل النصية من النوع String، وسيُعيد الاستدعاء السابق بناءً على ذلك مصفوفةً جديدةً من النوع String. طالما أنّ التوابع السابقة مُعرَّفةٌ ضمن الواجهة Collection<T>، فإنها بطبيعة الحال مُعرَّفةٌ ضمْن كل كائنٍ يُنفِّذ تلك الواجهة، ولكن هناك مشكلة، حيث لا يُمكِن تغيير حجم بعض أنواع التجميعات بعد إنشائها، وبالتالي لا يَكون للتوابع المسؤولة عن إضافة الكائنات وحذفها معنىً بالنسبة لتلك التجميعات على الرغم من أنه ما يزال من الممكن استدعائها، ويَحدُث في تلك الحالة اعتراضٌ exception من النوع UnsupportedOperationException أثناء زمن التشغيل. إضافةً لذلك ونظرًا لأن Collection<T> هي واجهة interface وليست صنفًا حقيقيًا concrete class، فإن التنفيذ الفعلي للتابع متروكٌ للأصناف المُنفِّذة للواجهة؛ مما يعني عدم امكانية ضمَان توافق الدلالة الفعلية لتلك التوابع مع ما شرحناه بالأعلى لجميع تجميعات الكائنات من خارج إطار عمل جافا للتجميعات Java Collection Framework. بالنسبة لكفاءة تلك العمليات، فليس من الضروري أن تعمل عمليةٌ معينةٌ بنفس كفاءة أنواعٍ مختلفةٍ من التجميعات؛ حيث أنها بالنهاية مُعرَّفةٌ بكل تجميعةٍ على حدى. يَنطبِق ذلك حتى على أبسط التوابع مثل size()التي فقد تختلف كفائتها تمامًا من تجميعةٍ لأخرى؛ حيث من الممكن أن يتضمَّن تحصيل قيمة التابع size() عَدّ العناصر الموجودة بالتجميعة بالنسبة لبعض أنواع التجميعات، ويكون عندها عدد خطوات العملية مُساويًا لعدد عناصر التجميعة؛ وقد يحتفظ نوعٌ آخر من التجميعات بمتغيرات نسخ instance variables تُحدِّد حجمها الحالي، وعندها يقتصر تحصيل قيمة التابع size() على إعادة قيمة مُتغيِّر، أي يَستغرِق تنفيذ العملية خطوةً واحدةً فقط بغض النظر عن عدد عناصر التجميعة. بناءً على ما سبق، لا بُدّ من الانتباه دائمًا لكفاءة العمليات، واختيار التجميعة بحيث تكون العمليات التي ستجريها أكثر من غيرها ذات الكفاءة الأعلى، وسنرى عدة أمثلة على ذلك في المقالين التاليين. المكررات وحلقات التكرار for-each تُعرِّف الواجهة Collection<T> بعض الخوارزميات المُعمَّمة البسيطة، ولكن كيف يختلف ذلك عن كتابة خوارزميةٍ مُعمَّمةٍ خاصةٍ جديدة؟ لنفترض مثلًا أننا نريد طباعة جميع العناصر الموجودة ضمن التجميعة. لنُنفِّذ ذلك تنفيذًا مُعمَّمًا، نحتاج إلى طريقةٍ ما للمرور عبر جميع عناصر التجميعة واحدًا تلو الآخر. رأينا طريقة فعل ذلك لبعض بنى البيانات data structure؛ فإذا كان لدينا مصفوفةٌ مثلًا، فإننا نستطيع ببساطة استخدام حلقة التكرار for للمرور عبر جميع فهارسها indices. تُعدُّ القائمة المترابطة linked list مثالًا آخر، حيث يُمكِننا المرور عبر عناصرها باستخدام حلقة التكرار while، بحيث نُحرِّك ضمن تلك الحلقة مؤشرًا على طول القائمة. بالنسبة للشجرة الثنائية binary tree، يُمكِننا استخدام برنامجٍ فرعيٍ تعاودي recursive لإجراء ما يُعرَف باسم اجتياز في الترتيب inorder traversal؛ أما بالنسبة للتجميعة collection، فيمكننا تمثيلها بأيٍ مما سبق، وبالتالي علينا الإجابة عن السؤال التالي: كيف سنستطيع كتابة تابعٍ مُعمَّمٍ واحدٍ يُمكِنه العمل مع تجميعاتٍ يُمكِن تخزينها بصيغٍ مختلفةٍ تمامًا؟ يَكْمُن حل تلك المشكلة فيما يُعرَف باسم المُكرِّرات iterators؛ وهو ببساطةٍ كائنٌ يُمكِن استخدامه لاجتياز تجميعة. تختلف طريقة تنفيذ المُكرِّر بحسب نوع التجميعة، لكنها تستخدَم جميعًا بنفس الأسلوب. وبالتالي، أيُّ خوارزميةٍ يعتمد اجتيازها لعناصر التجميعة على وجود مُكرِّر هي خوارزمية مُعمَّمة؛ لأننا ببساطة سنتمكَّن من تطبيقها على أي نوعٍ من التجميعات. قد تبدو فكرة المُكرِّرات غريبةً نوعًا ما خاصةً إذا كانت هذه هي المرة الأولى التي تتعرَّض خلالها للبرمجة المُعمَّمة، ولكن عليك أن تدرك أنها ستساعدك على حل بعض أصعب المشكلات بطريقةٍ أنيقة. تُعرِّف الواجهة Collection<T> تابعًا يُمكِننا استخدامه للحصول على المُكرِّر iterator لأي تجميعة. إذا كانت coll تجميعة، فسيعيد coll.iterator() مُكرِّرًا يُمكِننا اِستخدَامه لاجتياز عناصر التجميعة. يُمكِنك التفكير بالمُكرِّر كما لو كان نوعًا عامًا من المؤشرات يبدأ من مقدمة التجميعة، وبإمكانه التحرُّك على طول التجميعة من عنصرٍ إلى آخر. تُعرَّف المُكرِّرات عن طريق واجهةٍ ذات معاملات غير مُحدَّدة النوع parameterized interface اسمها Iterator<T>. إذا نفِّذت coll الواجهة Collection<T> للنوع T، فسيعيد استدعاء coll.iterator() مُكرِّرًا من النوع Iterator<T>، حيث تُشير T إلى معامل النوع type parameter. تُعرِّف الواجهة Iterator<T> ثلاثة توابع فقط. إذا كان tier يُشير إلى كائن مُنفِّذ للواجهة Iterator<T>، يكون لدينا التوابع التالية: iter.next(): يُعيد العنصر التالي، ويُقدِّم المُكرِّر خطوةً للأمام، وتكون القيمة المُعادة من النوع T. يسمح لك التابع بفحص أحد عناصر التجميعة. لاحِظ أنه لا توجد طريقةٌ لفحص عنصرٍ دون أن يَمُر المُكرِّر عبره خطوةً للأمام. إذا استدعينا هذا التابع ولم يَكن هناك أي عناصرٍ متبقية ضمن التجميعة، فسيحدث اعتراضٌ من النوع NoSuchElementException. iter.hasNext(): يُعيد قيمةً منطقيةً تُمثِل فيما إذا كان هناك عناصرٌ جاهزةٌ متبقيةٌ للمعالجة. ينبغي استدعاء هذا التابع عمومًا قبل استدعاء iter.next(). iter.remove(): إذا استدعيت هذا التابع بعد iter.next()، فسيحذِف العنصر الذي رأيته للتو من التجميعة. لا يستقبل هذا التابع أي مُعاملات، ويَحذِف آخر عنصرٍ أعاده التابع iter.next()، مما قد يؤدي إلى اعتراضٍ من النوع UnsupportedOperationException في حال لم تدعم تلك التجميعة حذف العناصر. نستطيع كتابة شيفرة لطباعة كل العناصر الموجودة بأي تجميعة بالاستعانة بالمُكرِّرات iterators. لنفترض مثلًا أن coll من النوع Collection<String>، وبالتالي سيعيد التابع coll.iterator() قيمةً من النوع Iterator<String>، ويُمكِننا كتابة ما يلي: Iterator<String> iter; // صرِّح عن المُكرِّر iter = coll.iterator(); // استرجع مُكررًا للتجميعة while ( iter.hasNext() ) { String item = iter.next(); // اقرأ العنصر التالي System.out.println(item); } ستَعمَل الصيغة العامة السابقة مع أي أنواعٍ أخرى من المعالجة، حيث تَحذِف الشيفرة التالية مثلًا جميع القيم الفارغة null من أي تجميعةٍ من النوع Collection<Color>، طالما كانت التجميعة تدعم حذف القيم: Iterator<Color> iter = coll.iterator(): while ( iter.hasNext() ) { Color item = iter.next(); if (item == null) iter.remove(); } لاحِظ أنه عند استخدامنا أنواعًا، مثل Collection<T>، أو Iterator<T>، أو أي نوعٍ آخر ذا معاملاتٍ غير مُحدَّدة النوع ضمن شيفرةٍ فعلية، فإننا نستخدمها دائمًا مع أنواعٍ فعليةٍ، مثل String، أو Color في موضع معامل النوع الصوري T؛ حيث يُستخدَم مثلًا مُكرِّرٌ من النوع Iterator<String> للمرور عبر عناصر تجميعة سلاسلٍ نصيةٍ من النوع String؛ بينما يُستخدَم مُكرِّرٌ من النوع Iterator<Color> للمرور عبر عناصر تجميعةٍ من النوع Color وهكذا. تُستخدَم المُكرِّرات عادةً لتطبيق نفس العملية على جميع عناصر تجميعةٍ معينة، ولكن يمكننا استخدام حلقة التكرار for-each بدلًا من المُكرِّر في كثيرٍ من الحالات. كنا قد ناقشنا طريقة استخدام حلقة for-each مع المصفوفات بمقال تعرف على المصفوفات (Arrays) في جافا، ومع النوع ArrayList بمقال المشار إليه سلفًا، ويُمكِنها أن تُستخدَم أيضًا للمرور عبر عناصر أي تجميعة. على سبيل المثال، إذا كان coll تجميعةً من النوع Collection<T>، تُكْتَب حلقة for-each بالصياغة التالية: for ( T x : coll ) { // لكل كائن x من النوع T بالتجميعة coll // عالج x } تمثِّل x بالأعلى مُتغيِّرًا مُتحكِّمًا بالحلقة loop control variable، وسيُسند كل كائنٍ بالتجميعة coll إلى x، وسيُطبَق متن body الحلقة عليه. صرَّحنا عن x لتَكون من النوع T، لأن الكائنات الموجودة بالتجميعة coll من النوع T. إذا كانت namelist تجميعةً من النوع Collection<String> مثلًا، يُمكِننا طباعة جميع الأسماء الموجودة بالتجميعة على النحو التالي: for ( String name : namelist ) { System.out.println( name ); } يُمكِننا بالطبع كْتابة حلقة while مع مُكرِّر بدلًا من حلقة for-each، ولكن الأخيرة أسهل بالقراءة. التساوي Equality والموازنة Comparison تتضمَّن الواجهة Collection عدة توابعٍ methods لفحص تَساوِي كائنين. يبحث التابعان coll.contains(object) و coll.remove(object) على سبيل المثال عن عنصرٍ يُساوِي object ضمن التجميعة. لا يُعد اختبار تساوي كائنين أمرًا بسيطًا كما قد تظنّ، ولا يُعطِي العامل == دائمًا إجاباتٍ معقولةً عند تطبيقه على الكائنات؛ لأنه يَفحَص فيما إذا كان الكائنان متطابقين أي إذا كانا بنفس موضع الذاكرة memory location، وهو ما لا نعنيه عادةً عندما نرغب بفْحَص تَساوِي كائنين، وإنما نَعنِي ما إذا كانا يحملان نفس القيمة، وهو أمرٌ مختلفٌ كليًا. إذا كان لدينا مثلًا قيمتين من النوع String، فلا بُدّ أن نَعُدّهما متساويين إذا تضمَّنا نفس متتالية المحارف بغض النظر عن وجودهما بنفس موضع الذاكرة من عدمه؛ وإذا كان لدينا قيمتين من النوع Date، فلا بُدّ أن نَعُدّهما متساويين إذا كانا يُمثِلان نفس التوقيت. يُعرِّف الصنف Object تابعًا اسمه equals(Object) بهدف فَحْص تساوي كائنين، وبحيث يؤدي نفس دور الاختبار التالي obj1 == obj2، ثم يُعيد قيمةً من النوع المنطقي boolean. تَستخدم كثيرًا من أصناف التجميعات ذلك التابع، ومع ذلك، لا يُعدّ هذا التعريف مناسبًا لكثيرٍ من الأصناف الفرعية المُشتقَّة من الصنف Object، وبالتالي يَنبغي أن يُعاد تعريفه overridden، حيث يُعيد الصنف String مثلًا تعريف التابع equals() بحيث تَكون قيمة str.equals(obj) لسلسلةٍ نصية str مساويةً للقيمة المنطقية true إذا كان obj من النوع String وكان يحتوي على نفس متتالية المحارف الموجود بالسلسلة النصية str. إذا أضفت صنفًا جديدًا، فينبغي أن يحتوي تعريفه على إعادة تعريفٍ للتابع equals() لتحصل على السلوك المطلوب عند فَحص تساوي كائنين من ذلك الصنف. يُمكِننا على سبيل المثال تعريف صنف Card على النحو التالي لنتمكَّن من اِستخدَامه داخل تجميعة collection: public class Card { // صنف لتمثيل ورق اللعب private int suit; // عدد من 0 إلى 3 لتمثيل بطاقات الكوبة والبستوني // والسباتي والديناري private int value; // عدد من 1 إلى 13 لتمثيل قيمة الورقة public boolean equals(Object obj) { try { Card other = (Card)obj; // Type-cast obj to a Card. if (suit == other.suit && value == other.value) { // 1 return true; } else return false; } catch (Exception e) { // 2 return false; } } . . // توابع أخرى وبناةٌ آخرين . } حيث تعني كل من: [1] تملك الورقة الأخرى نفس القيمة والرمز الخاص بهذه الورقة، لذلك يُمكن عدّهما متساويين. [2] سيلتقط الاعتراض NullPointerException الذي يَحدُث إذا كان obj فارغًا، وكذلك الاعتراض ClassCastException الذي يَحدُث إذا لم يَكُن obj من النوع Card. في تلك الحالات، لا يكون obj مُساوٍ لورقة اللعب 'Card'، لذلك أعِد false. لاحِظ أنه في حالة عدم وجود التابع equals() داخل الصنف، لن تعمل توابعٌ، مثل contains() و remove() المُعرَّفة بالواجهة Collection<Card> على النحو المُتوقَّع. يَنطبِق الأمر نفسه على عملية ترتيب العناصر الموجودة ضمن تجميعة، أي عملية ترتيبها تصاعديًا وفقًا لمعيارٍ معين. ليس هناك مفهومٌ بديهيٌ لمعنى الترتيب التصاعدي لعدِّة كائناتٍ objects عشوائية، ولهذا لا بُدّ من إضافة تابعٍ آخر قبل محاولة ترتيب تلك العناصر، بحيث يكون ذلك التابع مسؤولًا عن الموازنة بين تلك العناصر. يجب أن يُنفِّذ أيُّ كائنٍ تَنوِي استخدامه ضمن عملية موازنة الواجهة java.lang.Comparable. لاحِظ أن تلك الواجهة مُعرَّفةٌ مثل واجهةٍ ذات معاملات غير محدَّدة النوع Comparable<T>، مما يعني إمكانية الموازنة مع كائنٍ من النوع T. تُعرِّف الواجهة Comparable<T> التابع التالي: public int compareTo( T obj ) يُعيد الاستدعاء obj1.compareTo(obj2) قيمةً سالبةً، إذا كان obj1 يَأتِي قبل obj2 عندما تكون الكائنات مُرتَّبة ترتيبًا تصاعديًا؛ ويُعيد قيمةً موجبةً، إذا كان obj2 يَأتِي قبل obj1؛ وإذا كان الكائنان مُتساوِيين وفقًا للغرض من الموازنة، يُعيد التابع صفرًا. لا يعني ذلك بالضرورة أن الكائنين مُتساويان وفقًا للتابع الآخر obj1.equals(obj2). فإذا كانت الكائنات قيد الموازنة من النوع Address على سبيل المثال، فقد ترغب بترتيبها وفقًا للرقم البريدي، بحيث تَكُون العناوين ذات نفس الرقم البريدي مُتساوية، ولا يَعنِي ذلك أن تلك العناوين هي نفسها. يُنفِّذ الصنف String الواجهة Comparable<String>، ويُعرِّف التابع compareTo بحيث يُعيد صفرًا فقط إذا كانت السلسلتان النصيتان قيد الموازنة متساويتين. إذا عرَّفت صنفًا خاصًا جديدًا، وكنت ترغب بترتيب الكائنات المنتمية لذلك الصنف، فينبغي أن تَفعَل الشيء نفسه. اُنظر المثال التالي: // 4 public class FullName implements Comparable<FullName> { private String firstName, lastName; // الاسم الأول والأخير غير الفارغين public FullName(String first, String last) { // الباني if (first == null || last == null) throw new IllegalArgumentException("Names must be non-null."); firstName = first; lastName = last; } public boolean equals(Object obj) { try { FullName other = (FullName)obj; // Type-cast obj to type FullName return firstName.equals(other.firstName) && lastName.equals(other.lastName); } catch (Exception e) { return false; // إذا كان `obj` فارغًا أو لم يكن من النوع FullName } } public int compareTo( FullName other ) { if ( lastName.compareTo(other.lastName) < 0 ) { // 1 return -1; } else if ( lastName.compareTo(other.lastName) > 0 ) { // 2 return 1; } else { // 3 return firstName.compareTo(other.firstName); } } . . // توابع أخرى . } وتشير العناصر الآتية إلى: [1]: إذا جاء lastName قبل الاسم الأخير للكائن الآخر، فسيأتي FullName لهذا الكائن قبل FullName للكائن الآخر، ولذلك أعِد قيمةً سالبة. [2]: إذا جاء lastName بعد الاسم الأخير للكائن الآخر، فسيأتي FullName لهذا الكائن بعد FullName للكائن الآخر، ولذلك أعد قيمةً موجبة. [3]: الاسم الأخير لكلا الكائنين هو نفسه، ولذلك سنوازن بين أسمائهما الأولى باستخدام التابع compareTo المُعرَّف بالصنف String. [4]: يمثِّل هذا الصنف الاسم الكامل المُكوَّن من اسمٍ أول واسمٍ أخير. لاحِظ أن الصنف مُعرَّفٌ على النحو التالي class FullName implements Comparable<FullName>، وقد يبدو استخدام كلمة FullName مثل معامل نوع type parameter ضمن اسم الواجهة غريبًا بعض الشيء ولكنه صحيح؛ حيث يعني أننا ننوي موازنة الكائنات المنتمية إلى الصنف FullName مع كائناتٍ أخرى من نفس النوع. قد ترى أنه من البديهي أن تكون عملية الموازنة مع كائنٍ من نفس النوع، ولكنها ليست كذلك بالنسبة لمُصرِّف جافا، ولهذا أضفنا معامل النوع إلى اسم الواجهة على النحو التالي Comparable<FullName>. تُوفِّر جافا طريقةً أخرى لموازنة الكائنات عبر إضافة كائنٍ آخر يكون قادرًا على إجراء الموازنة، ويجب على ذلك الكائن تنفيذ الواجهة Comparator<T>، حيث T هي نوع الكائنات المطلوب موازنتها. تُعرِّف تلك الواجهة التابع التالي: public int compare( T obj1, T obj2 ) يُوازن التابع السابق كائنين من النوع T، حيث يُعيد قيمةً سالبةً أو موجبةً أو صفرًا اعتمادًا على ما إذا كان obj1 يَسبِق obj2، أو إذا كان obj1 يَلحَق obj2، أو إذا كان يُمكِن عَدّهما مُتساويين فيما يتعلق بالموازنة. تُستخدَم تلك الواجهة عادةً لموازنة الكائنات التي لا تُنفِّذ الواجهة Comparable، وكذلك لتخصيص أساليب ترتيبٍ مختلفة لنفس تجميعة الكائنات. لاحِظ أنه نظرًا لأن Comparator هو واجهة من نوع دالة functional interface، وتُستخدَم غالبًا تعبيرات لامدا lambda expressions لتعريفها (انظر مقال تعبيرات لامدا (Lambda Expressions) في جافا). سنناقش خلال المقالين التاليين طريقة استخدام Comparable و Comparator بالتجميعات والخرائط. الأنواع المعممة والأصناف المغلفة لا يُمكِننا تطبيق نموذج البرمجة المُعمَّمة generic programming بلغة جافا على الأنواع الأساسية primitive types كما ذكرنا بمقال مفهوم المصفوفات الديناميكية (ArrayLists) في جافا. أثناء حديثنا عن الصنف ArrayList؛ حيث يمكن لبنى البيانات data structures المُعمَّمة أن تَحمِل كائناتٍ فقط وليست الأنواع الأساسية كائنات. تستطيع في المقابل الأصناف المُغلِّفة wrapper classes، التي تعرَّضنا لها بمقال مفهوم المصفوفات الديناميكية ArrayLists في جافا أن تتجاوز ذلك القيد إلى حدٍ بعيد. يقابل كل نوعٍ أساسي صنفًا مُغلّفًا wrapper class، حيث يوجد لدينا مثلًا الصنف Integer للنوع int؛ والصنف Boolean للنوع boolean؛ والصنف Character للنوع char، وهكذا. يحتوي أي كائنٍ من النوع Integer على قيمةٍ من النوع int، حيث يعمل الكائن ببساطة مثل مغلِّف wrapper لقيمة النوع الأساسي، ويسمح هذا باستخدام النوع الأساسي ضمن سياقاتٍ تتطلّب بالأساس كائناتٍ مثل بنى البيانات المُعمَّمة generic data structures. يُمكِننا على سبيل المثال تخزين قائمة أعدادٍ صحيحة من النوع Integer بمُتغيّرٍ من النوع ArrayList<Integer>، وستكون واجهاتٌ، مثل Collection<Integer> و Set<Integer> مُعرَّفة. يُعرِّف الصنف Integer علاوةً على ذلك التوابع التالية: equals(). compareTo(). toString(). حيث تُنفِّذ ما ينبغي إجراؤه بما يتناسب مع النوع الأساسي المقابل. تنطبق الأمور نفسها على جميع الأصناف المُغلِّفة wrapper classes. تُجري لغة جافا تحويلًا تلقائيًا بين الأنواع الأساسية primitive types وما يُقابِلها من أنواعٍ مُغلِّفة. يعني ذلك أنه بمجرد إنشاء بنية بياناتٍ مُعمَّمة تحمل كائناتٍ تنتمي إلى إحدى الأصناف المُغلَّفة، فمن الممكن استخدام بنى البيانات كما لو كان بإمكانها حَمل قيمٍ من النوع الأساسي. إذا كان numbers مُتغيّرًا من النوع Collection<Integer>، فبإمكانك كتابة numbers.add(17)، أو numbers.remove(42)، ولا يُمكِنك حرفيًا إضافة قيمةٍ من النوع الأساسي مثل 17 إلى numbers؛ وإنما تُحوِّل جافا تلك القيمة تلقائيًا إلى كائنٍ مُغلِّف مُقابِل، أي Integer.valueOf(17)، ثم تُضيف ذلك الكائن إلى التجميعة. تُؤثِر عملية إنشاء كائنٍ جديدٍ على كلٍ من الوقت والذاكرة المُستهلَكين أثناء العملية، وهو ما ينبغي أن تَضعُه بالحسبان تحديدًا إذا كنت مهتمًا بكفاءة البرنامج. تُعدّ مصفوفةٌ من النوع int عمومًا أكثر كفاءةً من مصفوفةٍ من النوع ArrayList<Integer>. ترجمة -بتصرّف- للقسم Section 1: Generic Programming من فصل Chapter 10: Generic Programming and Collection Classes من كتاب Introduction to Programming Using Java. اقرأ أيضًا المقال السابق: تصميم محلل نموذجي تعاودي بسيط Recursive Descent Parser في جافا واجهة المستخدم الحديثة في جافا الأصناف المتداخلة Nested Classes في جافا