المُجمِّعات بعمق
بحلول هذا الدرس تعرف بالفعل كيف تستدعي collect(Collectors.toList()) لتحويل stream إلى قائمة. هذا مجرد السطح. تحتوي فئة المساعد java.util.stream.Collectors على أكثر من عشرين طريقة مصنع، وأربع منها — groupingBy وpartitioningBy وjoining وcounting — تغطي معظم أعمال التجميع في التطبيقات الحقيقية. إتقانها يتيح لك استبدال حلقات تمتد لعشرات الأسطر بتعبير واحد مقروء.
counting — أبسط أنواع التجميع
Collectors.counting() مُجمِّع فرعي يحصي العناصر التي تصله. منفردًا لا يثير اهتمامًا كبيرًا — يمكنك استدعاء stream.count() مباشرة — لكنه يصبح قويًا عند تركيبه داخل مُجمِّع آخر.
import java.util.List;
import java.util.stream.Collectors;
List<String> words = List.of("apple", "fig", "banana", "avocado", "blueberry", "date");
long total = words.stream().collect(Collectors.counting());
System.out.println(total); // 6
ستلتقي بـ counting() مرة أخرى حين ندرس groupingBy.
groupingBy — تقسيم stream إلى دلاء
Collectors.groupingBy(classifier) يقسّم عناصر stream إلى Map<K, List<V>> حيث يجتمع كل عنصر ينتج المفتاح ذاته في نفس الدلو.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
record Employee(String name, String department, double salary) {}
List<Employee> employees = List.of(
new Employee("Alice", "Engineering", 95_000),
new Employee("Bob", "Engineering", 88_000),
new Employee("Carol", "Marketing", 72_000),
new Employee("Dave", "Marketing", 68_000),
new Employee("Eve", "HR", 61_000)
);
Map<String, List<Employee>> byDept =
employees.stream()
.collect(Collectors.groupingBy(Employee::department));
byDept.forEach((dept, list) ->
System.out.println(dept + ": " + list.stream()
.map(Employee::name)
.toList()));
// Engineering: [Alice, Bob]
// Marketing: [Carol, Dave]
// HR: [Eve]
تظهر القوة الحقيقية حين تضيف مُجمِّعًا فرعيًا كوسيط ثانٍ. بدلًا من جمع أعضاء الدلو في قائمة يمكنك تجميعهم بطريقة أخرى:
// عدد الموظفين لكل قسم
Map<String, Long> countByDept =
employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.counting()
));
// {Engineering=2, Marketing=2, HR=1}
// متوسط الراتب لكل قسم
Map<String, Double> avgSalaryByDept =
employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
// {Engineering=91500.0, Marketing=70000.0, HR=61000.0}
التجميع متعدد المستويات: يمكن أن يكون المُجمِّع الفرعي بدوره groupingBy آخر. تستطيع إنشاء هياكل Map<String, Map<String, Long>> — مثلًا القسم ثم مستوى الأقدمية — دون أي حلقات إلزامية.
partitioningBy — تقسيم ثنائي
Collectors.partitioningBy(predicate) صورة متخصصة من groupingBy حيث المفتاح دائمًا boolean. النتيجة Map<Boolean, List<T>> بمدخلتين فقط: true وfalse.
Map<Boolean, List<Employee>> highEarners =
employees.stream()
.collect(Collectors.partitioningBy(
e -> e.salary() >= 80_000
));
System.out.println("High earners: " + highEarners.get(true)
.stream()
.map(Employee::name)
.toList());
// High earners: [Alice, Bob]
System.out.println("Others: " + highEarners.get(false)
.stream()
.map(Employee::name)
.toList());
// Others: [Carol, Dave, Eve]
مثل groupingBy، يقبل مُجمِّعًا فرعيًا كوسيط ثانٍ:
Map<Boolean, Long> counts =
employees.stream()
.collect(Collectors.partitioningBy(
e -> e.salary() >= 80_000,
Collectors.counting()
));
// {false=3, true=2}
متى تختار partitioningBy على groupingBy: حين يكون المصنِّف ثنائيًا بطبعه — نشط/غير نشط، ناجح/راسب، فوق العتبة/تحتها. partitioningBy يجعل النية واضحة تمامًا ويضمن دائمًا وجود كلا المفتاحين في الخريطة الناتجة (حتى لو كان أحد الدلوين فارغًا)، في حين يُدرج groupingBy فقط المفاتيح التي تظهر فعلًا في البيانات.
joining — تجميع السلاسل النصية من stream
Collectors.joining() يدمج عناصر stream من نوع String في سلسلة واحدة. توجد ثلاثة أشكال:
joining() — إلحاق مباشر بلا فاصل.
joining(delimiter) — عناصر مفصولة بـ delimiter.
joining(delimiter, prefix, suffix) — يضيف بادئة ولاحقة للنتيجة.
List<String> tags = List.of("java", "streams", "collectors", "functional");
// إلحاق مباشر
String plain = tags.stream().collect(Collectors.joining());
System.out.println(plain); // javastreamscollectorsfunctional
// مفصول بفواصل
String csv = tags.stream().collect(Collectors.joining(", "));
System.out.println(csv); // java, streams, collectors, functional
// جملة SQL من نوع IN
String inClause = tags.stream()
.collect(Collectors.joining("', '", "('", "')"));
System.out.println(inClause); // ('java', 'streams', 'collectors', 'functional')
joining يعمل فقط على streams من نوع String. إذا كان stream يحمل كائنات يجب استدعاء .map(Object::toString) (أو mapper أكثر تحديدًا) قبل التجميع. نسيان ذلك يسبب خطأ في وقت التحويل.
تركيب المُجمِّعات — مثال واقعي
كثيرًا ما يجمع الكود الفعلي هذه المُجمِّعات معًا. افترض أنك بحاجة إلى تقرير يعرض لكل قسم أسماء الموظفين مفصولة بفواصل:
Map<String, String> namesByDept =
employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(
Employee::name,
Collectors.joining(", ")
)
));
namesByDept.forEach((dept, names) ->
System.out.println(dept + " -> " + names));
// Engineering -> Alice, Bob
// Marketing -> Carol, Dave
// HR -> Eve
هنا يُستخدم Collectors.mapping() كمحوّل فرعي: يحوّل أولًا كل Employee إلى اسمه (String)، ثم يمرر هذه الأسماء إلى joining. هذا التركيب الثلاثي المستويات يحل محل حلقة متداخلة مع خريطة من قوائم وحلقة ثانية وStringBuilder.
الخلاصة
المُجمِّعات الأربعة التي تعلمتها في هذا الدرس تفتح لك جوهر أعمال تجميع البيانات في Java:
- counting() — يحصي العناصر، وأفضل استخدام له كمُجمِّع فرعي.
- groupingBy(classifier) — يضع العناصر في دلاء حسب المفتاح؛ أضف مُجمِّعًا فرعيًا لتجميع كل دلو.
- partitioningBy(predicate) — تقسيم ثنائي؛ يضمن دائمًا وجود كلا المفتاحين؛ أوضح من
groupingBy بقيمة boolean.
- joining(delimiter, prefix, suffix) — يجمع streams النصية؛ يتطلب stream من نوع String.
في الدرس التالي سنتناول streams الرقمية — IntStream وLongStream وDoubleStream — والمُجمِّعات الرقمية المتخصصة التي تكملها.