اذهب إلى المحتوى

مقدمة إلى واجهة برمجة التطبيقات Stream API في جافا


رضوى العربي

تُمثِّل واجهة برمجة التطبيقات 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.
اقتباس

ملاحظة: تُستخدَم S.limit(n)‎ لاقتطاع truncate مجاري التدفق التي ستصبح -إن لم نقتطعها- لا نهائية infinite stream، مثل مجاري التدفق streams التي يُنتجِها مورِّد من النوع Supplier.

يتعيّن عليك تطبيق عمليةٍ نهائيةٍ 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 لقيمةٍ واحدةٍ على الأقل ضمن المجرى الذي طُبِّقت عليه.

اقتباس

ملاحظة: يُوقِف anyMatch()‎ المعالجة، ويُعيد القيمة true في الخرج بمجرد عثوره على قيمةٍ تُحقِّق الجملة الخبرية predicate؛ بينما يُوقِف allMatch()‎ المعالجة، إذا عثر على قيمةٍ واحدةٍ لا تُحقِّق الجملة الخبرية.

يُمكِننا التعبير عن معظم العمليات النهائية 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.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...