دانستنی‌ها

راهنمای عملی Stream API در جاوا

در این مقاله قصد داریم شما را با API مهمی از زبان جاوا که در نسخه ۸ جاوا معرفی شد، یعنی stream API (که از این به بعد با نام جویبار می‌شناسیم) آشنا کنیم. این API رویکرد «برنامه‌نویسی اعلانی» را برای پیمایش و انجام عملیات روی یک مجموعه‌ (collection) ارائه داد. تا نسخه۷ جاوا حلقه‌های for و foreach  تنها گزینه‌های موجود برای پیمایش و انجام عملیات روی مجموعه‌ها بودند که رویکرد برنامه‌نویسی دستوری‌ (imperative) داشتند. در این مقاله ضمن آشنا کردن شما با API جویبار (stream API)، توضیح خواهیم داد که چگونه عملیات متداولی که روی مجموعه‌ها انجام می‌دادیم توسط جویبارها انتزاعی‌سازی می‌شوند.

در برنامه‌نویسی دستور‌ی (imperative programming) توسعه‌دهنده از ساختارهای موجود در زبان استفاده می‌کند تا دو چیز را بیان کند: 

  • چه کاری باید انجام شود؟ (what to do)
  • این کار چگونه انجام می‌شود؟ (how to do)

این در حالی است که در برنامه‌نویسی اعلانی (declarative programming)، توسعه‌دهنده تنها روی کاری که باید انجام شود (what to do) تمرکز می‌کند و مدیریت قسمت «چگونگی اجرا» بر عهده زبان یا چارچوب مورد نظر خواهد بود. به همین خاطر کدهای نوشته‌شده به سبک اعلانی، مختصر هستند و استعداد کمتری برای بروز خطا دارند.

عملیاتی که معمولا روی یک مجموعه انجام می‌شوند را می‌توان به صورت زیر دسته‌بندی کرد. اگرچه دسته‌بندی زیر کامل نیست اما بیشتر مواردی که در برنامه‌نویسی روزانه با آن‌ها  سروکار داریم را پوشش می‌دهد. ما هم در این مقاله برای معرفی جویبار‌ها عملیات زیر را در مثال‌ها پیاده‌سازی می‌کنیم:

  • نگاشت (map)
  • فیلتر (filter)
  • جستجو (search)
  • مرتب‌سازی (sort)
  • خلاصه‌سازی (summary)
  • گروه‌بندی (group)

در مثال‌ها از مجموعه‌ای از اشیای کلاس Person استفاده می‌کنیم. برای فهم راحت، پیاده‌سازی ساده‌ این کلاس به صورت زیر است:

public class Person {
    private final String name;
    private final Gender gender;
    private final int age;

    public Person (String name, int age, Gender gender) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }

    public String getName () { return name; }

    public int getAge () { return age; }

    public Gender getGender () { return gender; }

}

public enum Gender{
    MALE, FEMALE, OTHER
}

معرفی اجمالی جویبارها

پیش از معرفی دقیق توابع جویبارها، اجازه دهید ابتدا برای درک خودِ API جویباری مثالی بزنیم:

List<Person> people = ...

// bulding a stream
List<String> namesOfPeopleBelow20 = people.stream()  
    // pipelining a computation
    .filter(person -> person.getAge() < 20)  
    // pipelining another computation
    .map(Person::getName)  
    // terminating a stream
    .collect(Collectors.toList());

در مثال بالا، چندین عملیات برای تشکیل چیزی شبیه به یک خط‌ لوله پردازش در کنار هم قرارگرفته‌اند. این همان چیزی است که ما تحت عنوان Stream Pipeline (خط لوله‌ جویبار) می‌شناسیم. هر خط لوله‌ جویبار از سه قسمت زیر تشکیل شده‌است:

۱. ساخت جویبار

در مثال فوق مجموعه‌ای از اشیا کلاس Person با نام people داریم. برای ساخت جویباری از اشیا people، متد ()stream روی مجموعه people فراخوانی شده است؛ این متد از نسخه ۸ به واسط Collection اضافه شده است.

به غیر از واسط Collection، می‌توان با متدهای زیر هم stream تولید کرد:

  • متد stream از کلاس Arrays 
  • توابع ایجادکننده‌ی جویبار مانند ()Stream.iterate و()Stream.generate (*)
  • متد range از کلاس‌ IntStream

(*) توضیح مترجم: برای مطالعه بیش‌تر می‌توانید در مورد iterate  و generate  و range در منابع معرفی‌شده بیش‌تر بخوانید.

۲. عملیات میانی (intermediate operation)

بعد از ایجاد شیِ جویبار، شما می‌توانید مثل الگوی طراحی builder، صفر، یک یا چندین عملیات میانی را در کنار هم قرار دهید و آن‌ها را بر روی جویبار اعمال کنید. همه‌ متدهایی که در مثال بالا مشاهده کردیم اعم از map و filter، متدهایی از واسط Stream هستند. این متدها برای فراهم کردن امکان استفاده از قابلیت chaining (زنجیره‌سازی)، همگی نمونه‌ای از جنس Stream برمی‌گردانند. از آن‌جا که این عملیات نمونه‌ای از جنس خودِ Stream برمی‌گردانند، عملیات میانی خوانده می‌شوند. 

۳. عملیات پایانی (terminal operations)

پس از انجام تمامی محاسبات میانی، شما باید با اعمال یک عملگر پایانی، به خط لوله پایان دهید و از نتیجه عملیات استفاده کنید. مشابه عملیات میانی، عملیات پایانی هم متدهایی از واسط Stream هستند اما در خروجی، نوعی به غیر از Stream برمی‌گردانند. در مثال فوق، متد collect(Collectors.toList()) نمونه‌ای از واسط List برمی‌گرداند. بر این اساس که کدام عمل پایانی استفاده می‌شود، نوع خروجی می‌تواند خود یک مجموعه باشد یا نباشد.

هم اکنون بیایید نگاهی داشته باشیم به تعدادی عملیات که می‌توان با جویبار آن‌ها را پیاده‌سازی کرد. عملیاتی که در مثال‌ها یاد می‌گیریم می‌توانند به تنهایی بر روی جویبار اعمال شوند و یا برای مقاصد پیچیده‌تر می‌توانیم چند عملیات را ترکیب کنیم.

  • نگاشت (map)

   نگاشت یا map به معنای تبدیل هر مقدار از عناصر در مجموعه به مقدار دیگری است. فرض کنید می‌خواهیم اسامی مجموعه‌ای از افراد را استخراج کنیم؛ در چنین موردی برای تبدیل «افراد» به «اسامی» متناظر آن‌ها باید از عملیات نگاشت استفاده کنیم.

در مثال زیر هر عنصر از جنس People را به عنصری از نوع رشته که نشان‌دهنده نام همان فرد است، نگاشت می‌کنیم.

توضیح:‌ عبارت Person::getName یک ارجاع به متد است که با عبارت ()person -> person.getName  برابر بوده و نمونه‌ای از جنس واسط Function است. 

List<String> namesOfPeople = people.stream()
    .map (Person::getName)
    .collect (Collectors.toList());
  • فیلتر (filter)

 همان‌طور که از نام آن برمی‌آید، عملیات پالایش یا به اصطلاح فیلتر کردن را روی مجموعه‌ای از اعضا انجام می‌دهد. همه‌ی اشیای جویبار وارد فیلتر می‌شوند ولی تنها اشیایی از آن عبور کرده (و در جویبارِ خروجی باقی می‌مانند) که شرط گذاشته‌شده در فیلتر (که توسط نمونه‌‌ای از Predicate بیان می‌شود) را برآورده کنند.

در مثال زیر، برای ساخت مجموعه‌ای متشکل از افراد زیر ۲۰ سال، از عبارت person -> person.getAge() < 20 استفاده شده است.

// filtering using predicate
List<Person> listOfPersonBelow20 = people.stream()
    .filter (person -> person.getAge() < 20)
    .collect (Collectors.toList());

انتخاب تعدادی از عناصر بر اساس تعداد هم می‌تواند نوعی عملیات فیلترینگ باشد. به همین منظور عملگرهای skip و limit در جویبارها پیاده‌سازی شده‌اند. در مثال زیر، ۲ نفر ابتدایی نادیده گرفته و ۱۰ نفر بعدی انتخاب می‌شوند.

// count based filtering
List<Person> smallerListOfPeople = people.stream()
    .skip (2)
    .limit (10)
    .collect (Collectors.toList());
  • جست‌وجو (search)

هدف جستجو در مجموعه، پیدا کردن یک عنصر خاص (find) یا صرفا اطلاع از وجود آن عنصر خاص در مجموعه است (match). این جستجوها بر اساس یک «شاخص» صورت می‌گیرد که این شاخص نیز نمونه‌ای از Predicate است.

در ازای جستجو (find) ممکن است مقداری برگردد یا اصلا هیچ مقداری برنگردد، به همین خاطر در ازای جستجوی یک عنصر، نمونه‌ای از نوع Optional خروجی داده می‌شود.

در طرف مقابل، نوع داده برگشتی در ازای جستجو برای وجود یک عنصر (match) از نوع boolean خواهد بود که نشان‌دهنده این است که آیا عنصری با مشخصات خواسته‌شده پیدا شد یا خیر. 

در مثال‌های زیر، جستجو برای یک عنصر خاص (find) توسط متد ()findAny و جستجو برای اطلاع از وجود یک عنصر (match) توسط متد ()anyMatch انجام می‌شود.

// searching for an element
Optional<Person> any = people.stream()
    .filter (person -> person.getAge() < 20)
    .findAny();

// searching for existence
boolean isAnyOneInGroupLessThan20Years = people.stream()
    .anyMatch (person -> person.getAge() < 20);
  •   مرتب‌سازی (sort)

اگر می‌خواهید عناصر موجود در جویبار را مرتب کنید، می‌توانید از عملگر میانی sorted استفاده کنید. اگر اشیا داخل جویبار به طور پیش‌فرض قابل مقایسه نباشند (رابط comparable را پیاده‌سازی نکنند)، می‌توانید به این متد یک Comparator هم پاس دهید. برای اطلاعات بیش‌تر این لینک را مطالعه کنید.

در مثال زیر، مجموعه نهایی بر اساس سن و به ترتیب نزولی مرتب شده است.

 List<Person> peopleSortedOldestToYuong = people.stream()
    .sorted(Comparator.comparing (Person::getAge).reversed())
    .collect(Collectors.toList());

بر خلاف سایر عملیاتی که تا به حال دیده‌ایم، عملیات sorted یک عمل stateful است. یعنی قبل از اینکه عملگر، نتیجه مرتب‌سازی را در اختیار عملگر بعدی قرار دهد، باید همه‌ عناصر موجود در جویبار را دریافت کرده‌باشد. متد distinct مثال دیگری از این نوع عملیات است.

  •   خلاصه‌سازی (summary)

گاهی اوقات نیاز دارید اطلاعاتی را از مجموعه استخراج کنید. برای مثال، استخراج مجموعِ سن افراد. این کار با استفاده از عملیات پایانی در جویبار ممکن می‌شود. متدهای ()reduce و()collect از انواع عملیات پایانی هستند که به منظور استخراج اطلاعات فراهم شده‌اند.

هم‌چنین عملیات سطح بالاتری مانند sum و count و summaryStatistics و… وجود دارند که بر پایه‌ی عملیات reduce و collect ایجاد شده‌اند.

// calculating sum using reduce terminal operator
people.stream()
    .mapToInt(Person::getAge)
    .reduce(0, (total, currentValue) -> total + currentValue);

// calculating sum using sum terminal operator
people.stream()
    .mapToInt(Person::getAge)
    .sum();

// calculating count using count terminal operator
people.stream()
    .mapToInt(Person::getAge)
    .count();

// calculating summary
IntSummaryStatistics ageStatistics = people.stream()
    .mapToInt(Person::getAge)
    .summaryStatistics();
ageStatistics.getAverage();
ageStatistics.getCount();
ageStatistics.getMax();
ageStatistics.getMin();
ageStatistics.getSum();
  • گروه‌بندی (grouping)

 گاهی اوقات نیاز داریم مجموعه‌ای را به چندین گروه تقسیم‌بندی کنیم، جویبار این قابلیت را به ما می‌دهد تا یک جویبار را بر اساس یک فاکتور معین گروه‌بندی کنیم. ساختمان داده‌ای که توسط گروه‌بندی ایجاد می‌شود، Map است که «فاکتور گروه‌بندی» به عنوان کلیدها و «صفات مربوط به هر گروه خاص» به عنوان مقادیر کلیدهای این Map هستند.  API جویبار، متد ()Collectors.groupingBy را برای این کار ارائه کرده است.

در مثال‌های زیر، گروه‌بندی بر اساس جنسیت انجام شده و تفاوت در مقادیر کلیدها است.

در مثال اول مجموعه‌ای از اشیا Person برای هر گروه ایجاد شده است.

// Grouping people by gender
Map<Gender, List<Person>> peopleByGender = people.stream()
    .collect(Collectors.groupingBy(
        Person::getGender,Collectors.toList())
     );

در مثال دوم، برای استخراج نام هر Person و ایجاد مجموعه‌ای از این اسامی، از متد ()Collectors.mapping استفاده شده است.

// Grouping person names by gender
Map<Gender, List<String>> nameByGender = peopleJavaCupIR@.stream()
    .collect(Collectors.groupingBy(
        Person::getGender,
        Collectors.mapping(Person::getName, Collectors.toList())));

در مثال سوم، سن هر Person استخراج شده و گروه‌بندی بر اساس میانگین سنی صورت گرفته‌ است.

// Grouping average age by gender
Map<Gender, Double> averageAgeByGender = people.stream()
    .collect(Collectors.groupingBy(
        Person::getGender,
        Collectors.averagingInt(Person::getAge)
    ));

جمع‌بندی

در این مقاله دیدیم که جویبارها  از طریق پیاده‌سازی مکانیزم خط لوله (pipeline)، امکانات زیادی برای کار روی مجموعه‌ها ارائه می‌دهند. این رابط برنامه‌نویسی بر پایه‌ برنامه‌نویسی اعلانی (declarative) ایجاد شده و همین مسئله باعث می‌شود کدهای نوشته‌شده با جویبارها دقیق‌تر، مختصر و کم‌خطاتر باشند. امیدواریم با مطالعه‌ این مقاله به اهمیت و قدرت جویبارها پی برده باشید و در برنامه‌نویسی روزانه خود از آن‌ها استفاده کنید.

منبع:‌ praveergupta.in

مترجم: امیرحسین زارعی

.

.

با ما همراه باشید

آدرس کانال تلگرام: JavaCupIR@

آدرس اکانت توییتر: JavaCupIR@

آدرس صفحه اینستاگرام: javacup.ir

آدرس گروه لینکدین: Iranian Java Developers

نوشته های مشابه

‫2 دیدگاه ها

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا