ساختمان داده Collection در جاوا — به زبان ساده
در این مقاله قصد داریم در مورد ساختمان داده Collection در جاوا توضیح بدهیم. Collection-ها در گام نهایی پردازش یک «استریم» (Stream) مورد استفاده قرار میگیرند. اگر با استریمها در جاوا آشنایی ندارید، پیشنهاد میکنیم ابتدا این مقاله را مطالعه کنید:
متد ()Stream.collect
()Stream.collect یکی از متدهای ترمینال API استریم در جاوا 8 محسوب میشود. این متد به ما امکان میدهد که عملیات تبدیل تغییرپذیر را روی عناصر دادهای موجود در یک وهله از استریم اجرا کنیم. این دسته عملیات شامل بستهبندی مجدد عناصر در برخی ساختمانهای داده و اعمال نوعی منطق اضافی، الحاق آنها به همدیگر و مواردی از این دست میشود.
راهبرد مورد نیاز برای این عملیات از طریق پیادهسازی اینترفیس Collector ارائه شده است.
کلاس Collectors
همه پیادهسازیهای از پیش تعریفشده را میتوان در کلاس Collectors یافت. استفاده از ایمپورت استاتیک زیر با بهرهگیری از این متدها برای افزایش خوانایی کد رویه معمولی به حساب میآید:
1import static java.util.stream.Collectors.*;
همچنین در برخی موارد collector-های مورد نظر به صورت تکتک ایمپورت میشوند:
1import static java.util.stream.Collectors.toList;
2import static java.util.stream.Collectors.toMap;
3import static java.util.stream.Collectors.toSet;
در کدهای بعدی مثالهایی با استفاده مجدد از لیست زیر طرح خواهند شد:
1List<String> givenList = Arrays.asList("a", "bb", "ccc", "dd");
متد ()Collectors.toList
کلکتور ToList میتواند برای گردآوری همه عناصر استریم در یک وهله از List مورد استفاده قرار گیرد. نکته مهم که باید به خاطر سپرد، این است که نمیتوان از هر پیادهسازی خاص List با این متد استفاده کرد. اگر میخواهید کنترل بیشتری روی آن داشته باشید، به جای آن از toCollection استفاده کنید. در ادامه یک وهله از Stream ایجاد میکنیم که نماینده یک دنباله از عناصر است و آنها را در یک وهله از List گردآوری میکند:
1List<String> result = givenList.stream()
2 .collect(toList());
متد ()Collectors.toSet
کلکتور toSet برای گردآوری همه عنصر استریم در یک وهله از Set مورد استفاده قرار میگیرد. نکته مهم در مورد آن این است که نمیتوانیم هر پیادهسازی خاص از Set را با این متد استفاده کنیم. اگر میخواهید کنترل بیشتری روی آن داشته باشید به جای آن از toCollection استفاده کنید. در ادامه یک وهله از استریم میسازیم که بازنمایی از یک دنباله از عناصر بوده و آنها را در یک وهله از Set گردآوری میکند:
1Set<String> result = givenList.stream()
2 .collect(toSet());
Set شامل عناصر تکراری نیست. اگر یک کالکشن شامل عناصری برابر با همدیگر باشد، آن عناصر در Set نهایی تنها یک بار ظاهر خواهند شد:
1List<String> listWithDuplicates = Arrays.asList("a", "bb", "c", "d", "bb");
2Set<String> result = listWithDuplicates.stream().collect(toSet());
3assertThat(result).hasSize(4);
متد ()Collectors.toCollection
چنان که احتمالاً تاکنون متوجه شدهاید، زمانی که از کلکتورهای toSet و toList استفاده میکنیم، نمیتوانیم در مورد پیادهسازی آنها تصمیمی بگیریم. اگر میخواهید از پیادهسازیهای خاصی استفاده کنید، باید از کلکتور toCollection با یک کالکشن منتخب استفاده کنید. در ادامه وهلهای از استریم میسازیم که بازنمایی یک دنباله از عناصر است و آنها را در یک وهله از LinkedList گردآوری میکند:
1List<String> result = givenList.stream()
2 .collect(toCollection(LinkedList::new))
توجه کنید که این کد روی هر نوع کالکشن «تغییرناپذیر» (immutable) عمل نمیکند. در چنین حالتی باید یا یک پیادهسازی سفارشی از کلکتور ایجاد کنید و یا از collectingAndThen استفاده کنید.
متد ()Collectors.toMap
کلکتور toMap میتواند برای گردآوری عناصر استریم در یک وهله از Map مورد استفاده قرار گیرد. به این منظور باید دو تابع ارائه کنیم:
- keyMapper
- valueMapper
keyMapper برای استخراج یک کلید Map از یک عنصر Stream مورد استفاده قرار میگیرد و valueMapper نیز برای استخراج یک مقدار مرتبط با کلید مفروض استفاده میشود. در ادامه این عناصر را در یک Map گرداوری میکنیم تا رشتهها به عنوان کلید و طول آنها به عنوان مقدار ذخیره شود:
1Map<String, Integer> result = givenList.stream()
2 .collect(toMap(Function.identity(), String::length))
()Function.identity یک میانبر برای تعریف تابعی است که مقدار یکسانی را گرفته و بازگشت میدهد. اینک سؤال این است که اگر کالکشن ما شامل عناصر تکراری باشد، چه اتفاقی میافتد؟ به طور عکس toSet، در مورد toMap عناصر تکراری به صورت خاموش فیلتر میشوند. این موضوع قابل درکی است، چون در غیر این صورت نمیتوان فهمید که کدام مقدار متعلق به کدام کلید است.
1List<String> listWithDuplicates = Arrays.asList("a", "bb", "c", "d", "bb");
2assertThatThrownBy(() -> {
3 listWithDuplicates.stream().collect(toMap(Function.identity(), String::length));
4}).isInstanceOf(IllegalStateException.class);
توجه کنید که toMap حتی این که مقادیر یکسان هستند یا نه را هم بررسی نمیکند. در این حالت اگر کلیدهای یکسان وجود داشته باشند، بیدرنگ یک استثنای IllegalStateException صادر میشود. در چنان مواردی که «تصادم کلید» (key collision) وجود دارد، باید از toMap با امضای دیگری استفاده کنیم:
1Map<String, Integer> result = givenList.stream()
2 .collect(toMap(Function.identity(), String::length, (item, identicalItem) -> item));
آرگومان سوم در این امضا یک «عملگر باینری» (BinaryOperator) است که در آن میتوان شیوه مدیریت تصادم را تعریف کرد. در این حالت صرفاً یکی از مقادیر متصادم را انتخاب میکنیم، زیرا میدانیم که رشتههای یکسان، همواره طول یکسانی دارند.
متد ()Collectors.collectingAndThen
CollectingAndThen یک کلکتور خاص است که امکان اجرای کارهای دیگر را روی یک نتیجه درست پس از پایان گردآوری فراهم میسازد. در ادامه عناصری از استریم را در یک وهله از List گردآوری کرده و سپس نتیجه را به یک وهله از ImmutableList تبدیل میکنیم:
1List<String> result = givenList.stream()
2 .collect(collectingAndThen(toList(), ImmutableList::copyOf))
متد ()Collectors.joining
کلکتور joining میتواند برای الحاق عناصر Stream<String> استفاده شود. بدین ترتیب میتوان آنها را به صورت زیر با هم الحاق کرد:
1String result = givenList.stream()
2 .collect(joining(" "));
که نتیجه زیر حاصل میشود:
"abbcccdd"
همچنین میتوان کاراکترهای جداساز، پیشوندها و پسوندهای سفارشی تعیین کرد:
1String result = givenList.stream()
2 .collect(joining(" "));
که نتیجه زیر را حاصل میآورد:
"a bb ccc dd"
همچنین میتوان کدی مانند زیر نوشت:
1String result = givenList.stream()
2 .collect(joining(" ", "PRE-", "-POST"));
که نتیجه زیر به دست میآید:
"PRE-a bb ccc dd-POST"
متد ()Collectors.counting
Counting یک کلکتور ساده است که امکان شمارش همه عناصر استریم را فراهم میسازد. برای مثال میتوان کدی مانند زیر نوشت:
1Long result = givenList.stream()
2 .collect(counting());
متد ()Collectors.summarizingDouble/Long/Int
SummarizingDouble/Long/Int یک کلکتور است که کلاس خاصی را شامل اطلاعاتی آماری در مورد دادههای عددی در یک استریم از عناصر استخراجشده بازگشت میدهد. بدین ترتیب میتوان اطلاعاتی در مورد طول رشته با کد زیر به دست آورد:
1DoubleSummaryStatistics result = givenList.stream()
2 .collect(summarizingDouble(String::length));
در این حالت، کد زیر صحیح خواهد بود:
1assertThat(result.getAverage()).isEqualTo(2);
2assertThat(result.getCount()).isEqualTo(4);
3assertThat(result.getMax()).isEqualTo(3);
4assertThat(result.getMin()).isEqualTo(1);
5assertThat(result.getSum()).isEqualTo(8);
متد ()Collectors.averagingDouble/Long/Int
AveragingDouble/Long/Int کلکتوری است که میانگین عناصر استخراجشده را بازگشت میدهد:
1Double result = givenList.stream()
2 .collect(summingDouble(String::length));
متد ()Collectors.summingDouble/Long/Int
SummingDouble/Long/Int کلکتوری است که مجموع عناصر استخراجشده را بازگشت میدهد: بدین ترتیب میتوانیم مجموع طول رشتهها را به صورت زیر به دست آوریم:
1Double result = givenList.stream()
2 .collect(summingDouble(String::length));
متد ()Collectors.maxBy()/minBy
کلکتورهای MaxBy/MinBy به ترتیب بزرگترین/کوچکترین عنصر یک رشته را بر اساس وهله Comparator ارائه شده بازگشت میدهد. به این ترتیب میتوانیم بزرگترین عنصر را با کد زیر به دست آوریم:
1Optional<String> result = givenList.stream()
2 .collect(maxBy(Comparator.naturalOrder()));
توجه کنید که مقدار بازگشتی درون یک وهله Optional قرار گرفته است. این وضعیت ما را ملزم میکند که در مورد حالت خاص کالکشن خالی تأمل کنیم.
متد ()Collectors.groupingBy
کلکتور groupingBy برای گروهبندی اشیا برحسب نوعی مشخصه و ذخیره نتایج در یک وهله Map مورد استفاده قرار میگیرد. آنها را میتوان برحسب طول رشته گروهبندی کرده و نتیجه گروهبندی را در وهلههای Set ذخیره کرد:
1Map<Integer, Set<String>> result = givenList.stream()
2 .collect(groupingBy(String::length, toSet()));
بدین ترتیب کد زیر صحیح خواهد بود:
1assertThat(result)
2 .containsEntry(1, newHashSet("a"))
3 .containsEntry(2, newHashSet("bb", "dd"))
4 .containsEntry(3, newHashSet("ccc"));
توجه داشته باشید که آرگومان دوم متد groupingBy یک کلکتور است و میتوانید از هر کلکتوری که دوست دارید در آن استفاده کنید.
متد ()Collectors.partitioningBy
partitioningBy یک حالت خاص از groupingBy است که وهلهای از Predicate را پذیرفته و عناصر استریم را در یک وهله از Map گردآوری میکند که مقادیر بولی را به صورت کلید و کالکشنها را به عنوان مقدار ذخیره میسازد. زیر کلید true میتوان کالکشنهایی از عناصری که با Predicate مفروض مطابقت دارند یافت و زیر کلید flase نیز میتوان عناصری را یافت که با Predicate مفروض مطابقت نیافتهاند. به مثال زیر توجه کنید:
1Map<Boolean, List<String>> result = givenList.stream()
2 .collect(partitioningBy(s -> s.length() > 2))
کد فوق Map زیر را ارائه میکند:
{false=["a", "bb", "dd"], true=["ccc"]}
متد ()Collectors.teeing
در این بخش تلاش میکنیم کمینه و بیشینه مقادیر یک استریم مفروض را با استفاده از کلکتورهایی که تاکنون آموختهایم بیابیم:
1List<Integer> numbers = Arrays.asList(42, 4, 2, 24);
2Optional<Integer> min = numbers.stream().collect(minBy(Integer::compareTo));
3Optional<Integer> max = numbers.stream().collect(maxBy(Integer::compareTo));
4// do something useful with min and max
در این کد از دو کلکتور متفاوت استفاده میکنیم و سپس نتیجه این دو را برای ایجاد چیزی معنیدار با هم ترکیب میکنیم. تا پیش از جاوا 12 برای پوشش چنین حالتهایی باید دو بار روی استریم عملیات اجرا میکردیم و نتایج میانی را در متغیرهای موقتی ذخیره میکردیم و سپس نتایج را ترکیب میکردیم.
خوشبختانه جاوا 12 یک کلکتور داخلی دارد که این مراحل را به نیابت از شما انجام میدهد. در این حالت تنها کاری که باید انجام دهیم این است که دو کلکتور و یک تابع combiner ارائه کنیم:
1numbers.stream().collect(teeing(
2 minBy(Integer::compareTo), // The first collector
3 maxBy(Integer::compareTo), // The second collector
4 (min, max) -> // Receives the result from those collectors and combines them
5));
کلکتورهای سفارشی
اگر میخواهید پیادهسازی کلکتور بنویسید، باید اینترفیس کلکتور را پیادهسازی کنید و سه پارامتر ژنریک آن را تعیین نماید:
1public interface Collector<T, A, R> {...}
- T – نوع اشیایی که برای کالکشن ها وجود دارد.
- A – نوع شیء تجمیعکننده تغییرپذیر است.
- R – نوع نتیجه نهایی است.
در ادامه یک کلکتور نمونه برای گردآوری عناصر در یک وهله از ImmutableSet مینویسیم. کار خود را با تعیین انواع صحیح آغاز میکنیم:
1private class ImmutableSetCollector<T>
2 implements Collector<T, ImmutableSet.Builder<T>, ImmutableSet<T>> {...}
از آنجا که به یک کالکشن تغییرپذیر برای مدیریت عملیات داخلی کالکشن نیاز داریم، نمیتوانیم از ImmutableSet در این حالت استفاده کنیم. بنابراین باید از نوعی کالکشن تغییرپذیر دیگر یا هر کلاس دیگر که امکان تجمیع موقت اشیا را برای ما داشته باشد بهره بگیریم.
در این مورد از یک ImmutableSet.Builder استفاده میکنیم و باید از 5 متد متفاوت پیادهسازی کنیم:
- ()Supplier<ImmutableSet.Builder<T>> supplier
- ()BiConsumer<ImmutableSet.Builder<T>, T> accumulator
- ()BinaryOperator<ImmutableSet.Builder<T>> combiner
- ()Function<ImmutableSet.Builder<T>, ImmutableSet<T>> finisher
- ()Set<Characteristics> characteristics
متد ()supplier یک وهله از Supplier بازگشت میدهد که یک وهله accumulator خالی ایجاد میکند و از این رو در این حالت میتوانیم به سادگی کد زیر را بنویسیم:
1@Override
2public Supplier<ImmutableSet.Builder<T>> supplier() {
3 return ImmutableSet::builder;
4}
متد ()accumulator یک تابع بازگشت میدهد که برای افزودن عناصر جدید به شیء accumulator موجود استفاده میشود. بنابراین صرفاً از متد add در Builder استفاده میکنیم:
1@Override
2public BiConsumer<ImmutableSet.Builder<T>, T> accumulator() {
3 return ImmutableSet.Builder::add;
4}
متد ()combiner یک تابع بازگشت میدهد که برای ادغام این دو accumulator با هم استفاده میشود:
1@Override
2public BinaryOperator<ImmutableSet.Builder<T>> combiner() {
3 return (left, right) -> left.addAll(right.build());
4}
متد ()finisher یک تابع بازگشت میدهد که برای تبدیل کردن یک accumulator به نوع نتیجه نهایی استفاده میشود. از این رو در این مورد میتوانیم از متد Build مربوط به Builder استفاده کنیم:
1@Override
2public Function<ImmutableSet.Builder<T>, ImmutableSet<T>> finisher() {
3 return ImmutableSet.Builder::build;
4}
متد ()characteristics برای ارائه استریم با برخی اطلاعات اضافی که برای بهینهسازیهای داخلی کاربرد دارد استفاده میشود. در این مورد به ترتیب عناصر موجود در Set توجهی نداریم و از این رو از Characteristics.UNORDERED استفاده میکنیم. برای به دست آوردن اطلاعات بیشتر در مورد این شیء میتوانید به JavaDoc مربوط به Characteristics مراجعه کنید.
1@Override public Set<Characteristics> characteristics() {
2 return Sets.immutableEnumSet(Characteristics.UNORDERED);
3}
پیادهسازی کامل
در کد زیر پیادهسازی کامل را همراه با کاربردش میبینید:
1public class ImmutableSetCollector<T>
2 implements Collector<T, ImmutableSet.Builder<T>, ImmutableSet<T>> {
3
4@Override
5public Supplier<ImmutableSet.Builder<T>> supplier() {
6 return ImmutableSet::builder;
7}
8
9@Override
10public BiConsumer<ImmutableSet.Builder<T>, T> accumulator() {
11 return ImmutableSet.Builder::add;
12}
13
14@Override
15public BinaryOperator<ImmutableSet.Builder<T>> combiner() {
16 return (left, right) -> left.addAll(right.build());
17}
18
19@Override
20public Function<ImmutableSet.Builder<T>, ImmutableSet<T>> finisher() {
21 return ImmutableSet.Builder::build;
22}
23
24@Override
25public Set<Characteristics> characteristics() {
26 return Sets.immutableEnumSet(Characteristics.UNORDERED);
27}
28
29public static <T> ImmutableSetCollector<T> toImmutableSet() {
30 return new ImmutableSetCollector<>();
31}
کاربرد آن در عمل نیز چنین است:
1List<String> givenList = Arrays.asList("a", "bb", "ccc", "dddd");
2
3ImmutableSet<String> result = givenList.stream()
4 .collect(toImmutableSet());
سخن پایانی
در این مقاله به بررسی تفصیلی Collector-ها در جاوا 8 پرداختیم و شیوه پیادهسازی آنها را نشان دادیم. هم نمونه کدهای مطرحشده در این راهنما در این رپیوی گیتهاب (+) ارائه شدهاند.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی جاوا
- مجموعه آموزشهای برنامهنویسی
- گنجینه آموزشهای جاوا (Java)
- زبان برنامهنویسی جاوا (Java) — از صفر تا صد
- ۱۰ مفهوم اصلی زبان جاوا که هر فرد مبتدی باید بداند
==