Stream API احتمالاً دومین ویژگی مهم اضافهشده به Java SE 8 پس از عبارات lambda است. به زبان ساده، Stream API دربارهی ارائهی پیادهسازی الگوریتم شناختهشدهی map-filter-reduce به JDK است.
Collections Framework دربارهی ذخیرهسازی و سازماندهی دادهها در حافظهی JVM شماست. میتوانید Stream API را به عنوان یک frameworks مکمل برای Collections Framework ببینید، که این دادهها را به روشی بسیار کارآمد پردازش میکند. در واقع، میتوانید یک استریم روی یک collection باز کنید تا دادههای آن را پردازش کنید.
اما اینها تنها کاربردها نیست: Stream API کارهای بسیار بیشتری از فقط پردازش دادههای collection های شما برایتان انجام میدهد. JDK راههای متعددی برای ایجاد استریم روی منابع دیگر، از جمله منابع I/O به شما میدهد. علاوه بر این، میتوانید منابع دادهی خود را با کمترین تلاش و متناسب با نیازهایتان بسازید.
وقتی Stream API را به خوبی یاد بگیرید، میتوانید کد بسیار گویا و خوانا بنویسید. یک نمونه کد کوچک که با import های static مناسب قابل کامپایل است به صورت زیر است:
گروهبندی رشتهها در سه مرحله انجام میشود.
String::length گروهبندی میکند.counting() میشمارد.Map<Integer, Long> برای ذخیرهی نتیجه ایجاد میکند.اجرای این کد نتیجه زیر را تولید میکند.
3 :: 2
4 :: 1
5 :: 1
حتی اگر با Stream API آشنا نباشید، خواندن کدی که از آن استفاده میکند در نگاه اول به شما ایدهای میدهد دربارهی اینکه چه کاری انجام میدهد.
پیش از ورود به خود Stream API، بیایید عناصر الگوریتم map-filter-reduce را که به آنها نیاز خواهید داشت بررسی کنیم.
این الگوریتم یک الگوریتم بسیار کلاسیک برای پردازش دادههاست. بیایید مثالی بزنیم. فرض کنید مجموعهای از اشیاء Sale با سه ویژگی دارید: یک تاریخ، یک مرجع محصول و یک مقدار. برای سادگی، فرض میکنیم مقدار فقط یک عدد صحیح است. کلاس Sale شما به صورت زیر است. برای سادگی آن را به صورت record مینویسیم. میتوانید دربارهی کلاسهای Record در این صفحه بیشتر یاد بگیرید.
فرض کنید باید مقدار کل فروشها در ماه اکتبر را محاسبه کنید. احتمالاً کد زیر را مینویسید.
میتوانید سه مرحله در این الگوریتم سادهی پردازش داده ببینید.
مرحله اول شامل در نظر گرفتن فقط فروشهایی است که در اکتبر انجام شدهاند. شما برخی عناصری که پردازش میکنید را بر اساس یک معیار مشخص فیلتر میکنید. این دقیقاً مرحلهی filtering است.
مرحله دوم شامل استخراج یک ویژگی از شیء sale است. شما به کل شیء علاقهمند نیستید؛ آنچه نیاز دارید ویژگی amount آن است. شما شیء sale را به یک مقدار، یعنی یک مقدار int نگاشت (map) میکنید. این مرحلهی mapping است؛ شامل تبدیل اشیایی است که پردازش میکنید به اشیاء یا مقادیر دیگر.
مرحله آخر شامل جمعبندی تمام این مقادیر در یک مقدار واحد است. اگر با زبان SQL آشنا باشید، میبینید که این مرحله شبیه یک تجمع (aggregation) است. در واقع همین کار را انجام میدهد. این جمعبندی یک کاهش (reduction) از مقادیر فردی به یک مقدار واحد است.
به هر حال، زبان SQL کار بسیار خوبی در بیان این نوع پردازش به صورت خوانا انجام میدهد. کد SQL مورد نیاز واقعاً بسیار خوانا است:
select sum(amount)
from Sales
where extract(month from date) = 10;
میبینید که در SQL، آنچه مینویسید یک توصیف نتیجهای است که نیاز دارید: مجموع مقادیر تمام فروشهایی که در ماه مارس انجام شدهاند. مسئولیت یافتن روش محاسبهی کارآمد آن بر عهدهی سرور پایگاه دادهی شماست.
کد جاوا که این مقدار را محاسبه میکند، یک توصیف گامبهگام نحوهی محاسبهی آن است. به صورت دقیق و به روش imperative توصیف شده و فضای کمی برای بهینهسازی این محاسبه توسط runtime جاوا باقی میگذارد.
دو هدف Stream API عبارتاند از: امکان ایجاد کد خواناتر و گویاتر، و دادن فضای مانور به runtime جاوا برای بهینهسازی محاسبات شما.
مرحلهی اول الگوریتم map-filter-reduce، مرحلهی نگاشت (mapping) است. یک نگاشت شامل تبدیل اشیاء یا مقادیری است که پردازش میکنید. نگاشت یک تبدیل one-to-one است: اگر لیستی از 10 شیء را نگاشت کنید، لیستی از 10 شیء تبدیلشده دریافت خواهید کرد.
در Stream API، مرحلهی نگاشت یک محدودیت بیشتر اضافه میکند. فرض کنید در حال پردازش collection ای از اشیاء مرتب هستید. میتواند یک لیست باشد یا منبع دیگری از اشیاء مرتب. وقتی این لیست را نگاشت میکنید، اولین شیءای که دریافت میکنید باید نگاشت اولین شیء از منبع باشد. به عبارت دیگر: مرحلهی نگاشت ترتیب اشیاء شما را رعایت میکند؛ آنها را به هم نمیریزد.
یک نگاشت نوع اشیاء را تغییر میدهد؛ تعداد آنها را تغییر نمیدهد.
یک نگاشت توسط رابط تابعی Function مدلسازی میشود. در واقع، یک تابع میتواند هر نوع شیءای بگیرد و شیءای از نوع دیگر برگرداند. علاوه بر این، توابع تخصصی میتوانند اشیاء را به انواع اولیه و بالعکس نگاشت کنند.
از طرف دیگر، فیلتر کردن به اشیایی که پردازش میکنید دست نمیزند. فقط تصمیم میگیرد برخی را انتخاب کند و بقیه را حذف کند.
یک فیلتر تعداد اشیاء را تغییر میدهد؛ نوع آنها را تغییر نمیدهد.
یک فیلتر توسط رابط تابعی Predicate مدلسازی میشود. در واقع، یک predicate میتواند هر نوع شیء یا نوع اولیهای بگیرد و مقدار boolean برگرداند.
مرحلهی کاهش پیچیدهتر از آن چیزی است که به نظر میرسد. فعلاً با این تعریف کنار بیایید که همان نوع چیزی است که در SQL انجام میدهید. به COUNT، SUM، MIN، MAX، AVERAGE فکر کنید. به هر حال تمام این تجمعها توسط Stream API پشتیبانی میشوند.
فقط برای اینکه اشارهای کنم چه چیزهایی در این مسیر در انتظار شماست: مرحلهی کاهش به شما امکان میدهد ساختارهای پیچیدهای با دادههایتان بسازید، از جمله لیستها، مجموعهها، نقشههای هر نوع، یا حتی ساختارهایی که خودتان میتوانید بسازید. به اولین مثال این صفحه نگاه کنید: میتوانید فراخوانی متد collect() را ببینید، که شیئی ساختهشده توسط متد factory groupingBy() را میگیرد. این شیء یک Collector است. کاهش میتواند شامل جمعآوری دادههایتان با یک collector باشد. Collector ها بعداً در این آموزش به صورت جزئی پوشش داده میشوند.
بیایید مثال دیگری بزنیم. فرض کنید مجموعهای از شهرها دارید. هر شهر توسط کلاس City مدلسازی شده، که دو ویژگی دارد: یک نام و یک جمعیت، یعنی تعداد افرادی که در آن زندگی میکنند. باید جمعیت کل ساکنان شهرهایی که بیش از 100 هزار نفر جمعیت دارند را محاسبه کنید.
بدون استفاده از Stream API، احتمالاً کد زیر را مینویسید.
میتوانید پردازش دیگر map-filter-reduce روی لیست شهرها را تشخیص دهید.
اکنون یک آزمایش فکری کوچک انجام دهیم: فرض کنید Stream API وجود ندارد و متدهای map() و filter() روی رابط Collection موجودند، همچنین متد sum().
با این متدهای (فرضی)، کد قبلی میتواند به صورت زیر درآید.
از نظر خوانایی و گویایی، این کد بسیار آسان قابل فهم است. پس شاید بپرسید: چرا این متدهای map و filter به رابط Collection اضافه نشدهاند؟
بیایید عمیقتر بررسی کنیم: نوع بازگشتی این متدهای map() و filter() چیست؟ از آنجا که در Collection Framework هستیم، بازگرداندن یک collection طبیعی به نظر میرسد. پس میتوانید این کد را به این صورت بنویسید.
Collection<Integer> populations = cities.map(city -> city.getPopulation());
Collection<Integer> filteredPopulations = populations.filter(population -> population > 100_000);
int sum = filteredPopulations.sum();
حتی اگر زنجیرهای کردن فراخوانیها خوانایی را بهبود بخشد، این کد همچنان باید درست باشد.
اکنون بیایید این کد را تحلیل کنیم.
چون این کد یک collection برمیگرداند، تمام شهرها را نگاشت میکند و سپس collection عدد صحیح حاصل را فیلتر میکند. این بسیار متفاوت از حلقهی for ای است که در ابتدا نوشتید عمل میکند. ذخیرهی این collection عدد صحیح واسطهای میتواند overhead زیادی ایجاد کند، به خصوص اگر تعداد زیادی شهر برای پردازش داشته باشید. حلقهی for این overhead را ندارد: مستقیماً اعداد صحیح را در نتیجه جمع میکند، بدون ذخیرهی آنها در ساختاری واسطهای.
این overhead بد است و مواردی وجود دارد که میتواند بدتر هم بشود. فرض کنید باید بدانید آیا شهرهایی با بیش از 100 هزار نفر ساکن در collection وجود دارد یا نه. شاید اولین شهر collection چنین شهری باشد. در آن صورت میتوانید تقریباً بدون هیچ زحمتی نتیجه تولید کنید. اول ساختن collection تمام جمعیتها از شهرهایتان، سپس فیلتر کردن آن و بررسی خالی بودن یا نبودن نتیجه مسخره خواهد بود.
به دلایل آشکار عملکردی، ایجاد متد map() که روی رابط Collection یک Collection برگرداند راه درستی نیست. در نهایت ساختارهای واسطهی غیرضروری با overhead زیاد هم در حافظه و هم در CPU ایجاد خواهید کرد.
به همین دلیل متدهای map() و filter() به رابط Collection اضافه نشدهاند. در عوض، روی رابط Stream ایجاد شدهاند.
الگوی صحیح به صورت زیر است.
Stream<City> streamOfCities = cities.stream();
Stream<Integer> populations = streamOfCities.map(city -> city.getPopulation());
Stream<Integer> filteredPopulations = populations.filter(population -> population > 100_000);
int sum = filteredPopulations.sum(); // in fact this code does not compile; we'll fix it later
رابط Stream از ایجاد ساختارهای واسطهای برای ذخیرهسازی اشیاء نگاشت یا فیلتر شده جلوگیری میکند. در اینجا متدهای map() و filter() همچنان استریمهای جدید برمیگردانند. پس برای اینکه این کد کار کند و کارآمد باشد، هیچ دادهای نباید در این استریمها ذخیره شود. استریمهای ایجادشده در این کد، streamOfCities، populations و filteredPopulations باید همه شیءهای خالی باشند.
این به یک ویژگی بسیار مهم استریمها منجر میشود:
یک استریم شیئی است که هیچ دادهای ذخیره نمیکند.
Stream API به گونهای طراحی شده که تا زمانی که هیچ شیء غیر-استریمی در الگوی استریم ایجاد نکنید، هیچ محاسبهای روی دادههای شما انجام نمیشود. در مثال قبلی، مجموع عناصر پردازششده توسط استریم خود را محاسبه میکنید.
عملیات sum این محاسبه را آغاز میکند: تمام اشیاء لیست cities یکییکی از تمام عملیاتهای استریم کشیده میشوند. ابتدا نگاشت شده، سپس فیلتر شده، و اگر از مرحلهی فیلتر عبور کنند، جمع میشوند.
یک استریم دادهها را به همان ترتیبی پردازش میکند که یک حلقهی for معادل آن را انجام میدهد. به این ترتیب overhead حافظهای وجود ندارد. علاوه بر این، مواردی وجود دارد که میتوانید بدون عبور از تمام عناصر collection خود نتیجه تولید کنید.
استفاده از استریمها دربارهی ایجاد پایپلاینهایی از عملیاتهاست. در نقطهای دادههای شما از این پایپلاین عبور خواهد کرد و تبدیل، فیلتر، و سپس در تولید نتیجه شرکت داده خواهد شد.
یک پایپلاین از مجموعهای از فراخوانیهای متدها روی یک استریم ساخته شده. هر فراخوانی استریم دیگری تولید میکند. سپس در نقطهای، آخرین فراخوانی یک نتیجه تولید میکند. عملیاتی که استریم دیگری برمیگرداند، عملیات میانی (intermediate operation) نامیده میشود. از طرف دیگر، عملیاتی که چیز دیگری برمیگرداند، از جمله void، عملیات پایانی (terminal operation) نامیده میشود.
یک عملیات میانی عملیاتی است که استریم دیگری برمیگرداند. فراخوانی چنین عملیاتی یک عملیات دیگر به پایپلاین عملیاتهای موجود اضافه میکند بدون پردازش هیچ دادهای. توسط متدی مدلسازی میشود که استریم برمیگرداند.
یک عملیات پایانی عملیاتی است که استریم برنمیگرداند. فراخوانی چنین عملیاتی مصرف عناصر منبع استریم را آغاز میکند. این عناصر سپس توسط پایپلاین عملیاتهای میانی، یکییکی پردازش میشوند.
عملیات پایانی توسط متدی مدلسازی میشود که هر چیزی غیر از استریم برمیگرداند، از جمله void.
نمیتوانید بیش از یک متد میانی یا پایانی روی یک نمونهی استریم مشخص فراخوانی کنید. اگر این کار را انجام دهید، یک IllegalStateException با پیام «stream has already been operated upon or closed» دریافت خواهید کرد، مانند مثال زیر. نمیتوانید متد toList() را روی stream فراخوانی کنید، چون قبلاً map() روی آن فراخوانی کردهاید.
Stream API چهار رابط به شما میدهد.
اولی Stream است که میتوانید برای تعریف پایپلاینهای عملیات روی هر نوع شیء استفاده کنید.
سپس سه رابط تخصصی برای مدیریت استریمهای اعداد دارید: IntStream، LongStream و DoubleStream. این سه استریم از انواع اولیه برای اعداد به جای انواع wrapper استفاده میکنند تا از boxing و unboxing جلوگیری کنند. تقریباً متدهای یکسانی با Stream دارند، با چند استثنا. چون با اعداد سروکار دارند، چندین عملیات پایانی دارند که در Stream وجود ندارند:
sum(): برای محاسبهی مجموعmin()، max(): برای محاسبهی حداقل یا حداکثر عدد یک استریمaverage(): برای محاسبهی مقدار میانگین اعدادsummaryStatistics(): این فراخوانی شیء خاصی تولید میکند که چندین آمار را حمل میکند، همه در یک نوبت روی دادههای شما محاسبه شده. این آمارها عبارتاند از: تعداد عناصر پردازششده توسط آن استریم، حداقل، حداکثر، مجموع و میانگین.همانطور که دیدید، فقط مجاز به فراخوانی یک متد روی یک استریم هستید، حتی اگر آن متد میانی باشد. بنابراین ذخیرهی استریمها در فیلدها یا متغیرهای محلی بیفایده و گاهی خطرناک است. نوشتن متدهایی که استریم را به عنوان آرگومان بگیرند نیز میتواند خطرناک باشد، چون نمیتوان مطمئن بود استریمی که دریافت میکنید قبلاً پردازش نشده است. یک استریم باید در همان لحظه ایجاد و مصرف شود.
یک استریم شیئی متصل به یک منبع است. عناصری را که پردازش میکند از این منبع میکشد. این منبع نباید توسط خود استریم تغییر کند. انجام این کار به نتایج نامعلوم منجر میشود. در برخی موارد، این منبع غیرقابل تغییر یا فقط خواندنی است، بنابراین نمیتوانید این کار را انجام دهید، اما مواردی هم هست که میتوانید.
متدهای زیادی در رابط Stream موجود است و بیشتر آنها را در این آموزش خواهید دید. نوشتن عملیاتی که متغیرها یا فیلدهای خارج از خود استریم را تغییر دهد ایدهی بدی است که همیشه میتوان از آن اجتناب کرد. یک استریم نباید effect جانبی (side effect) داشته باشد.
این محتوا کاملا رایگان توسط تیم کدلپر ترجمه شده و در اختیار شما کاربران عزیز قرار گرفته است، هر گونه کپی برداری برای مقاصد غیر رایگان و بدون ذکر منبع، مورد پیگیری قانونی قرار میگیرد.
ترجمه شده از منبع: https://dev.java/learn/