Mengapa Java Streaming sekali saja?


239

Tidak seperti C # IEnumerable, di mana pipa eksekusi dapat dieksekusi sebanyak yang kita inginkan, di Jawa stream dapat 'diulang' hanya sekali.

Setiap panggilan ke operasi terminal menutup aliran, menjadikannya tidak dapat digunakan. 'Fitur' ini menghilangkan banyak daya.

Saya membayangkan alasan untuk ini bukan teknis. Apa pertimbangan desain di balik pembatasan aneh ini?

Sunting: untuk menunjukkan apa yang saya bicarakan, pertimbangkan implementasi Quick-Sort di C #:

IEnumerable<int> QuickSort(IEnumerable<int> ints)
{
  if (!ints.Any()) {
    return Enumerable.Empty<int>();
  }

  int pivot = ints.First();

  IEnumerable<int> lt = ints.Where(i => i < pivot);
  IEnumerable<int> gt = ints.Where(i => i > pivot);

  return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt));
}

Sekarang untuk memastikan, saya tidak menganjurkan bahwa ini adalah implementasi cepat yang baik! Namun itu adalah contoh yang bagus dari kekuatan ekspresif ekspresi lambda dikombinasikan dengan operasi aliran.

Dan itu tidak bisa dilakukan di Jawa! Saya bahkan tidak dapat menanyakan aliran apakah itu kosong tanpa menjadikannya tidak dapat digunakan.


4
Bisakah Anda memberikan contoh konkret di mana menutup aliran "menghilangkan kekuatan"?
Rogério

23
Jika Anda ingin menggunakan data dari aliran lebih dari sekali, Anda harus membuangnya ke dalam koleksi. Ini cukup banyak bagaimana memiliki pekerjaan: baik Anda harus mengulang perhitungan untuk menghasilkan sungai, atau Anda harus menyimpan hasil menengah.
Louis Wasserman

5
Oke, tetapi mengulangi perhitungan yang sama pada aliran yang sama terdengar salah. Aliran dibuat dari sumber yang diberikan sebelum perhitungan dilakukan, seperti halnya iterator dibuat untuk setiap iterasi. Saya masih ingin melihat contoh nyata yang nyata; pada akhirnya, saya yakin ada cara yang bersih untuk menyelesaikan setiap masalah dengan aliran use-once, dengan asumsi ada cara yang sesuai dengan enumerables C #.
Rogério

2
Ini membingungkan pada awalnya bagi saya, karena saya pikir pertanyaan ini akan menghubungkan C # s IEnumerabledengan aliranjava.io.*
SpaceTrucker

9
Perhatikan bahwa menggunakan IEnumerable beberapa kali dalam C # adalah pola yang rapuh, sehingga premis pertanyaan mungkin sedikit cacat. Banyak implementasi dari IEnumerable memungkinkannya tetapi beberapa tidak! Alat analisis kode cenderung memperingatkan Anda untuk tidak melakukan hal seperti itu.
Sander

Jawaban:


368

Saya memiliki beberapa ingatan dari desain awal API Streams yang mungkin menjelaskan pemikiran desain.

Kembali pada tahun 2012, kami menambahkan lambdas ke bahasa tersebut, dan kami menginginkan serangkaian operasi yang berorientasi koleksi atau "data massal", diprogram menggunakan lambdas, yang akan memfasilitasi paralelisme. Gagasan operasi rantai malas bersama-sama sudah mapan pada titik ini. Kami juga tidak ingin operasi perantara menyimpan hasil.

Masalah utama yang perlu kami putuskan adalah seperti apa objek dalam rantai itu di API dan bagaimana mereka terhubung ke sumber data. Sumber sering koleksi, tetapi kami juga ingin mendukung data yang berasal dari file atau jaringan, atau data yang dihasilkan saat itu juga, misalnya, dari generator angka acak.

Ada banyak pengaruh pekerjaan yang ada pada desain. Di antara yang lebih berpengaruh adalah perpustakaan Google Guava dan perpustakaan koleksi Scala. (Jika ada yang terkejut tentang pengaruh dari Guava, perhatikan bahwa Kevin Bourrillion , pengembang utama Guava, berada di kelompok ahli JSR-335 Lambda .) Pada koleksi Scala, kami menemukan pembicaraan oleh Martin Odersky ini menjadi minat khusus: Masa Depan- Proofing Scala Collections: dari Mutable ke Persistent hingga Parallel . (Stanford EE380, 2011 Juni).

Desain prototipe kami pada saat itu berbasis di sekitar Iterable. Operasi familiar filter, mapdan sebagainya adalah ekstensi (default) metode pada Iterable. Memanggil satu menambahkan operasi ke rantai dan mengembalikan yang lain Iterable. Operasi terminal seperti countakan memanggil iterator()rantai ke sumber, dan operasi dilaksanakan dalam Iterator setiap tahap.

Karena ini adalah Iterables, Anda dapat memanggil iterator()metode lebih dari sekali. Lalu apa yang harus terjadi?

Jika sumbernya adalah koleksi, ini sebagian besar berfungsi dengan baik. Koleksi-koleksi adalah Iterable, dan setiap panggilan untuk iterator()menghasilkan instance Iterator yang berbeda yang tidak tergantung pada instance aktif lainnya, dan masing-masing melintasi koleksi secara independen. Bagus.

Sekarang bagaimana jika sumbernya adalah sekali pakai, seperti membaca baris dari suatu file? Mungkin Iterator pertama harus mendapatkan semua nilai tetapi yang kedua dan selanjutnya harus kosong. Mungkin nilai-nilai harus disisipkan di antara para Iterator. Atau mungkin setiap Iterator harus mendapatkan semua nilai yang sama. Lalu, bagaimana jika Anda memiliki dua iterator dan satu semakin jauh di depan yang lain? Seseorang harus menyangga nilai-nilai di Iterator kedua sampai mereka membaca. Lebih buruk lagi, bagaimana jika Anda mendapatkan satu Iterator dan membaca semua nilai, dan hanya kemudian mendapatkan Iterator kedua. Dari mana nilai-nilai itu berasal sekarang? Apakah ada persyaratan bagi mereka semua untuk disangga kalau-kalau ada yang menginginkan Iterator kedua?

Jelas, memungkinkan beberapa Iterator atas sumber sekali pakai menimbulkan banyak pertanyaan. Kami tidak memiliki jawaban yang baik untuk mereka. Kami menginginkan perilaku yang konsisten dan dapat diprediksi untuk apa yang terjadi jika Anda menelepon iterator()dua kali. Ini mendorong kami untuk melarang beberapa jalur, membuat jalur pipa satu arah.

Kami juga mengamati orang lain menabrak masalah ini. Di JDK, sebagian Iterables adalah koleksi atau objek seperti koleksi, yang memungkinkan banyak traversal. Itu tidak ditentukan di mana pun, tetapi tampaknya ada harapan tidak tertulis bahwa Iterables mengizinkan beberapa traversal. Pengecualian penting adalah antarmuka NIO DirectoryStream . Spesifikasinya mencakup peringatan yang menarik ini:

Sementara DirectoryStream memperluas Iterable, itu bukan tujuan umum Iterable karena hanya mendukung Iterator tunggal; menggunakan metode iterator untuk mendapatkan iterator kedua atau selanjutnya melempar IllegalStateException.

[tebal aslinya]

Ini tampak tidak biasa dan cukup tidak menyenangkan sehingga kami tidak ingin membuat sejumlah Iterables baru yang mungkin hanya sekali saja. Ini mendorong kami untuk menggunakan Iterable.

Tentang saat ini, sebuah artikel oleh Bruce Eckel muncul yang menggambarkan tempat masalah yang dia alami dengan Scala. Dia menulis kode ini:

// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)

Cukup mudah. Ini mem-parsing baris teks menjadi Registrantobjek dan mencetaknya dua kali. Kecuali bahwa itu sebenarnya hanya mencetaknya sekali. Ternyata dia mengira registrantsitu koleksi, padahal sebenarnya itu iterator. Panggilan kedua untuk foreachmenemukan iterator kosong, dari mana semua nilai telah habis, sehingga tidak mencetak apa pun.

Pengalaman semacam ini meyakinkan kami bahwa sangat penting untuk memiliki hasil yang dapat diprediksi secara jelas jika dicoba beberapa kali traversal. Ini juga menyoroti pentingnya membedakan antara struktur seperti pipa yang malas dari koleksi aktual yang menyimpan data. Ini pada gilirannya mendorong pemisahan operasi pipa malas ke antarmuka Stream baru dan hanya menjaga operasi mutatif yang penuh semangat langsung pada Koleksi. Brian Goetz telah menjelaskan alasannya.

Bagaimana dengan memungkinkan beberapa traversal untuk jaringan pipa berbasis pengumpulan tetapi melarangnya untuk jaringan pipa non-koleksi? Ini tidak konsisten, tetapi masuk akal. Jika Anda membaca nilai dari jaringan, tentu saja Anda tidak dapat melintasinya lagi. Jika Anda ingin melintasi mereka beberapa kali, Anda harus menariknya ke dalam koleksi secara eksplisit.

Tapi mari kita jelajahi untuk memungkinkan beberapa traversal dari jaringan pipa berbasis koleksi. Katakanlah Anda melakukan ini:

Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);

( intoOperasi sekarang dieja collect(toList()).)

Jika sumber adalah koleksi, maka into()panggilan pertama akan membuat rantai Iterator kembali ke sumber, menjalankan operasi pipa, dan mengirim hasilnya ke tujuan. Panggilan kedua untuk into()akan membuat rantai Iterator lain, dan menjalankan operasi pipa lagi . Ini jelas tidak salah, tetapi memang memiliki efek melakukan semua operasi filter dan pemetaan untuk kedua elemen. Saya pikir banyak programmer akan terkejut dengan perilaku ini.

Seperti yang saya sebutkan di atas, kami telah berbicara dengan pengembang Guava. Salah satu hal keren yang mereka miliki adalah Makam Ide di mana mereka menggambarkan fitur yang mereka memutuskan untuk tidak menerapkan bersama dengan alasannya. Gagasan koleksi malas terdengar sangat keren, tapi inilah yang mereka katakan tentang itu. Pertimbangkan List.filter()operasi yang mengembalikan List:

Kekhawatiran terbesar di sini adalah bahwa terlalu banyak operasi menjadi proposisi waktu linear yang mahal. Jika Anda ingin memfilter daftar dan mendapatkan daftar kembali, dan bukan hanya Koleksi atau Iterable, Anda dapat menggunakan ImmutableList.copyOf(Iterables.filter(list, predicate)), yang "menyatakan di muka" apa yang dilakukannya dan seberapa mahal harganya.

Untuk mengambil contoh spesifik, berapa biayanya get(0)atau size()pada Daftar? Untuk kelas yang umum digunakan seperti ArrayList, mereka O (1). Tetapi jika Anda memanggil salah satu dari ini pada daftar yang difilter dengan malas, ia harus menjalankan filter di atas daftar dukungan, dan tiba-tiba semua operasi ini adalah O (n). Lebih buruk lagi, harus melintasi daftar dukungan pada setiap operasi.

Bagi kami ini sepertinya terlalu banyak kemalasan. Ini adalah satu hal untuk mengatur beberapa operasi dan menunda eksekusi yang sebenarnya sampai Anda jadi "Go". Merupakan hal lain untuk mengatur hal-hal sedemikian rupa sehingga menyembunyikan sejumlah besar potensi perhitungan ulang.

Dalam mengusulkan untuk melarang aliran yang tidak linier atau "tidak dapat digunakan kembali", Paul Sandoz menggambarkan konsekuensi potensial yang memungkinkan mereka menimbulkan "hasil yang tidak terduga atau membingungkan." Dia juga menyebutkan bahwa eksekusi paralel akan membuat segalanya lebih rumit. Akhirnya, saya akan menambahkan bahwa operasi pipa dengan efek samping akan menyebabkan bug yang sulit dan tidak jelas jika operasi tersebut dieksekusi secara tak terduga beberapa kali, atau setidaknya beberapa kali berbeda dari yang diharapkan oleh programmer. (Tapi programmer Java tidak menulis ekspresi lambda dengan efek samping, bukan? LAKUKAN MEREKA ??)

Jadi itulah dasar pemikiran untuk desain Java 8 Streams API yang memungkinkan one-shot traversal dan yang membutuhkan pipa yang benar-benar linier (tanpa bercabang). Ini memberikan perilaku yang konsisten di berbagai sumber aliran yang berbeda, itu jelas memisahkan operasi malas dari bersemangat, dan menyediakan model eksekusi langsung.


Berkenaan dengan IEnumerable, saya jauh dari ahli tentang C # dan .NET, jadi saya akan sangat menghargai dikoreksi (dengan lembut) jika saya menarik kesimpulan yang salah. Tampaknya, bagaimanapun, yang IEnumerablememungkinkan beberapa traversal untuk berperilaku berbeda dengan sumber yang berbeda; dan itu memungkinkan struktur percabangan IEnumerableoperasi bersarang , yang dapat mengakibatkan beberapa perhitungan ulang yang signifikan. Sementara saya menghargai bahwa sistem yang berbeda menghasilkan pengorbanan yang berbeda, ini adalah dua karakteristik yang kami coba hindari dalam desain Java 8 Streams API.

Contoh quicksort yang diberikan oleh OP menarik, membingungkan, dan saya minta maaf untuk mengatakan, agak mengerikan. Panggilan QuickSortmembutuhkan IEnumerabledan mengembalikan IEnumerable, jadi tidak ada penyortiran yang benar-benar dilakukan hingga final IEnumerabledilalui. Apa yang tampaknya dilakukan oleh panggilan itu, adalah membangun struktur pohon IEnumerablesyang mencerminkan partisi yang akan dilakukan quicksort, tanpa benar-benar melakukannya. (Bagaimanapun, ini adalah perhitungan malas.) Jika sumber memiliki elemen N, pohon akan menjadi elemen N lebar di terluas, dan itu akan menjadi level lg (N).

Bagi saya - dan sekali lagi, saya bukan pakar C # atau .NET - bahwa ini akan menyebabkan panggilan tertentu yang tampak tidak berbahaya, seperti pemilihan pivot via ints.First(), menjadi lebih mahal daripada yang terlihat. Pada level pertama, tentu saja, itu O (1). Tetapi pertimbangkan sebuah partisi jauh di dalam pohon, di tepi kanan. Untuk menghitung elemen pertama dari partisi ini, seluruh sumber harus dilalui, operasi O (N). Tetapi karena partisi di atas malas, mereka harus dihitung ulang, membutuhkan perbandingan O (lg N). Jadi memilih pivot akan menjadi operasi O (N lg N), yang semahal seluruh jenis.

Tapi kami tidak benar-benar menyortir sampai kami melintasi yang kembali IEnumerable. Dalam algoritma quicksort standar, setiap level partisi menggandakan jumlah partisi. Setiap partisi hanya setengah ukuran, sehingga setiap level tetap pada kompleksitas O (N). Pohon partisi adalah O (lg N) tinggi, sehingga total pekerjaan adalah O (N lg N).

Dengan pohon malas IEnumerables, di bagian bawah pohon ada N partisi. Komputasi setiap partisi membutuhkan lintasan elemen N, yang masing-masing membutuhkan perbandingan lg (N) di atas pohon. Untuk menghitung semua partisi di bagian bawah pohon, maka, membutuhkan perbandingan O (N ^ 2 lg N).

(Apakah ini benar? Saya hampir tidak bisa mempercayainya. Seseorang tolong periksa ini untuk saya.)

Bagaimanapun, itu memang keren yang IEnumerabledapat digunakan dengan cara ini untuk membangun struktur komputasi yang rumit. Tetapi jika itu memang meningkatkan kompleksitas komputasi seperti yang saya kira, kompleksitas pemrograman seperti ini adalah sesuatu yang harus dihindari kecuali seseorang sangat berhati-hati.


35
Pertama-tama, terima kasih atas jawaban yang bagus dan tidak merendahkan! Sejauh ini, inilah penjelasan yang paling akurat dan langsung ke pokok permasalahan yang saya dapatkan. Sejauh contoh QuickSort berjalan, Tampaknya Anda benar tentang int. Pertama kembung saat tingkat rekursi tumbuh. Saya percaya ini dapat dengan mudah diperbaiki dengan menghitung 'gt' dan 'lt' dengan penuh semangat (dengan mengumpulkan hasil dengan ToArray). Yang sedang berkata, itu tentu mendukung pendapat Anda bahwa gaya pemrograman ini mungkin menimbulkan harga kinerja yang tidak terduga. (Lanjutkan dalam komentar kedua)
Vitaliy

18
Di sisi lain, dari pengalaman saya dengan C # (lebih dari 5 tahun) saya dapat mengatakan bahwa membasmi perhitungan 'redundan' tidak terlalu sulit setelah Anda mencapai masalah kinerja (atau mendapat larangan, Jika seseorang membuat yang tidak terpikirkan dan memperkenalkan sisi mempengaruhi di sana). Sepertinya saya bahwa terlalu banyak kompromi dibuat untuk memastikan kemurnian API, dengan mengorbankan kemungkinan seperti C #. Anda pasti telah membantu saya menyesuaikan sudut pandang saya.
Vitaliy

7
@Valiy Terima kasih atas pertukaran ide yang berpikiran adil. Saya belajar sedikit tentang C # dan .NET dari menyelidiki dan menulis jawaban ini.
Stuart Marks

10
Komentar kecil: ReSharper adalah ekstensi Visual Studio yang membantu dengan C #. Dengan kode QuickSort di atas, ReSharper menambahkan peringatan untuk setiap penggunaanints : "Kemungkinan enumerasi ganda dari IEnumerable". Menggunakan hal yang sama IEenumerablelebih dari satu kali itu mencurigakan dan harus dihindari. Saya juga menunjukkan pertanyaan ini (yang telah saya jawab), yang menunjukkan beberapa peringatan dengan pendekatan .Net (selain kinerja yang buruk): Daftar <T> dan perbedaan IEnumerable
Kobi

4
@ Kobi Sangat menarik bahwa ada peringatan seperti itu di ReSharper. Terima kasih atas penunjuk jawaban Anda. Saya tidak tahu C # /. NET jadi saya harus mengambilnya dengan hati-hati, tetapi tampaknya menunjukkan masalah yang mirip dengan masalah desain yang saya sebutkan di atas.
Stuart Marks

122

Latar Belakang

Meskipun pertanyaannya tampak sederhana, jawaban yang sebenarnya membutuhkan latar belakang yang masuk akal. Jika Anda ingin melewatkan kesimpulan, gulir ke bawah ...

Pilih titik perbandingan Anda - Fungsi dasar

Menggunakan konsep dasar, konsep C # IEnumerablelebih dekat hubungannya dengan JavaIterable , yang mampu membuat sebanyak Iterator yang Anda inginkan. IEnumerablesmembuatIEnumerators . IterableBuat JawaIterators

Sejarah setiap konsep serupa, dalam hal keduanya IEnumerable dan Iterablememiliki motivasi dasar untuk memungkinkan perulangan gaya 'untuk-masing-masing' atas anggota kumpulan data. Itu penyederhanaan berlebihan karena mereka berdua memungkinkan lebih dari itu, dan mereka juga tiba pada tahap itu melalui perkembangan yang berbeda, tetapi itu adalah fitur umum yang signifikan.

Mari kita bandingkan fitur itu: dalam kedua bahasa, jika sebuah kelas mengimplementasikan IEnumerable/ Iterable, maka kelas itu harus mengimplementasikan setidaknya satu metode tunggal (untuk C #, ini GetEnumeratordan untuk Java itu iterator()). Dalam setiap kasus, instance yang dikembalikan dari yang ( IEnumerator/ Iterator) memungkinkan Anda untuk mengakses anggota data saat ini dan selanjutnya. Fitur ini digunakan dalam sintaks untuk-masing-masing bahasa.

Pilih titik perbandingan Anda - Fungsionalitas yang ditingkatkan

IEnumerabledi C # telah diperluas untuk memungkinkan sejumlah fitur bahasa lainnya ( sebagian besar terkait dengan Linq ). Fitur yang ditambahkan termasuk pilihan, proyeksi, agregasi, dll. Ekstensi ini memiliki motivasi yang kuat dari penggunaan dalam teori-set, mirip dengan konsep SQL dan Database Relasional.

Java 8 juga memiliki fungsionalitas yang ditambahkan untuk memungkinkan tingkat pemrograman fungsional menggunakan Streams dan Lambdas. Perhatikan bahwa aliran Java 8 tidak terutama dimotivasi oleh teori himpunan, tetapi oleh pemrograman fungsional. Bagaimanapun, ada banyak persamaan.

Jadi, ini adalah poin kedua. Perangkat tambahan yang dibuat untuk C # diimplementasikan sebagai perangkat tambahan untuk IEnumerablekonsep. Namun di Jawa, peningkatan yang dilakukan diimplementasikan dengan menciptakan konsep dasar baru Lambdas dan Streams, dan kemudian juga menciptakan cara yang relatif sepele untuk mengkonversi dari Iteratorsdan Iterableske Streams, dan sebaliknya.

Jadi, membandingkan IEnumerable dengan konsep Stream Java tidak lengkap. Anda perlu membandingkannya dengan gabungan Streams dan Collections API di Jawa.

Di Jawa, Streaming tidak sama dengan Iterables, atau Iterators

Streaming tidak dirancang untuk menyelesaikan masalah dengan cara yang sama seperti iterator:

  • Iterator adalah cara menggambarkan urutan data.
  • Streaming adalah cara menggambarkan rangkaian transformasi data.

Dengan Iterator, Anda mendapatkan nilai data, memprosesnya, dan kemudian mendapatkan nilai data lainnya.

Dengan Streams, Anda menghubungkan rangkaian fungsi secara bersamaan, lalu Anda mengumpankan nilai input ke stream, dan mendapatkan nilai output dari urutan gabungan. Catatan, dalam istilah Java, setiap fungsi dienkapsulasi dalam satu Streaminstance. Streams API memungkinkan Anda untuk menautkan urutan Streaminstance dengan cara yang mengaitkan urutan ekspresi transformasi.

Untuk menyelesaikan Streamkonsep, Anda membutuhkan sumber data untuk memberi makan aliran, dan fungsi terminal yang mengkonsumsi aliran.

Cara Anda memasukkan nilai ke aliran mungkin sebenarnya dari Iterable, tetapi Streamurutan itu sendiri bukan Iterable, itu adalah fungsi majemuk.

A Streamjuga dimaksudkan untuk menjadi malas, dalam arti bahwa itu hanya berfungsi ketika Anda meminta nilai darinya.

Perhatikan asumsi dan fitur signifikan dari Streaming:

  • A Streamdi Jawa adalah mesin transformasi, ia mengubah item data dalam satu negara, menjadi di negara lain.
  • stream tidak memiliki konsep urutan atau posisi data, hanya mengubah apa pun yang diminta.
  • stream dapat diberikan dengan data dari banyak sumber, termasuk stream lain, Iterator, Iterables, Koleksi,
  • Anda tidak dapat "mengatur ulang" aliran, itu seperti "memprogram ulang transformasi". Mengatur ulang sumber data mungkin yang Anda inginkan.
  • hanya ada 1 item data 'dalam penerbangan' secara logis di arus setiap saat (kecuali jika arusnya adalah aliran paralel, di titik mana, ada 1 item per utas). Ini tidak tergantung pada sumber data yang mungkin memiliki lebih dari item saat ini 'siap' untuk dipasok ke stream, atau pengumpul aliran yang mungkin perlu mengumpulkan dan mengurangi beberapa nilai.
  • Streaming dapat tidak terikat (tak terbatas), hanya dibatasi oleh sumber data, atau kolektor (yang juga bisa tak terbatas).
  • Streaming adalah 'rantai', output dari penyaringan satu aliran, adalah aliran lain. Nilai input ke dan diubah oleh aliran pada gilirannya dapat dipasok ke aliran lain yang melakukan transformasi berbeda. Data, dalam keadaan yang diubah mengalir dari satu aliran ke yang berikutnya. Anda tidak perlu melakukan intervensi dan menarik data dari satu aliran dan pasang ke yang berikutnya.

C # Perbandingan

Ketika Anda menganggap bahwa Java Stream hanya bagian dari sistem pasokan, aliran, dan pengumpulan, dan bahwa Streaming dan Iterator sering digunakan bersama dengan Koleksi, maka tidak mengherankan bahwa sulit untuk berhubungan dengan konsep yang sama yaitu hampir semua tertanam dalam IEnumerablekonsep tunggal dalam C #.

Bagian-bagian dari IEnumerable (dan konsep-konsep terkait erat) tampak jelas di semua konsep Java Iterator, Iterable, Lambda, dan Stream.

Ada hal-hal kecil yang dapat dilakukan konsep Java yang lebih sulit di IEnumerable, dan sebaliknya.


Kesimpulan

  • Tidak ada masalah desain di sini, hanya masalah dalam mencocokkan konsep antar bahasa.
  • Streaming menyelesaikan masalah dengan cara yang berbeda
  • Streaming menambahkan fungsionalitas ke Java (mereka menambahkan cara berbeda dalam melakukan sesuatu, mereka tidak menghilangkan fungsionalitas)

Menambahkan Streaming memberi Anda lebih banyak pilihan saat memecahkan masalah, yang adil untuk diklasifikasikan sebagai 'meningkatkan kekuatan', bukan 'mengurangi', 'mengambil', atau 'membatasi' itu.

Mengapa Java Streaming sekali saja?

Pertanyaan ini salah arah, karena stream adalah urutan fungsi, bukan data. Bergantung pada sumber data yang mengumpan aliran, Anda dapat mengatur ulang sumber data, dan mengumpan aliran yang sama, atau berbeda.

Tidak seperti C # 's IEnumerable, di mana sebuah pipeline eksekusi dapat dieksekusi sebanyak yang kita inginkan, di Java stream dapat' di-iterated 'hanya sekali.

Membandingkan suatu IEnumerableke yang Streamsalah arah. Konteks yang Anda gunakan untuk mengatakan IEnumerabledapat dieksekusi sebanyak yang Anda inginkan, paling baik dibandingkan dengan Java Iterables, yang dapat diulang sebanyak yang Anda inginkan. Java Streammewakili subset dari IEnumerablekonsep, dan bukan subset yang memasok data, dan dengan demikian tidak dapat 'dijalankan kembali'.

Setiap panggilan ke operasi terminal menutup aliran, menjadikannya tidak dapat digunakan. 'Fitur' ini menghilangkan banyak daya.

Pernyataan pertama itu benar, dalam arti tertentu. Pernyataan 'mengambil kekuasaan' tidak. Anda masih membandingkan Streams it IEnumerables. Operasi terminal dalam aliran seperti klausa 'break' dalam for loop. Anda selalu bebas untuk memiliki aliran lain, jika Anda mau, dan jika Anda dapat menyediakan kembali data yang Anda butuhkan. Sekali lagi, jika Anda menganggapnya IEnumerablelebih seperti Iterable, untuk pernyataan ini, Java tidak apa-apa.

Saya membayangkan alasan untuk ini bukan teknis. Apa pertimbangan desain di balik pembatasan aneh ini?

Alasannya teknis, dan untuk alasan sederhana bahwa Stream merupakan bagian dari apa yang dipikirkannya. Subset aliran tidak mengontrol suplai data, jadi Anda harus mengatur ulang suplai, bukan aliran. Dalam konteks itu, tidak aneh.

Contoh QuickSort

Contoh quicksort Anda memiliki tanda tangan:

IEnumerable<int> QuickSort(IEnumerable<int> ints)

Anda memperlakukan input IEnumerablesebagai sumber data:

IEnumerable<int> lt = ints.Where(i => i < pivot);

Selain itu, nilai balik IEnumerablejuga, yang merupakan suplai data, dan karena ini adalah operasi Sortir, urutan suplai itu signifikan. Jika Anda menganggap Iterablekelas Java sebagai pasangan yang cocok untuk ini, khususnya Listspesialisasi Iterable, karena Daftar adalah pasokan data yang memiliki urutan atau pengulangan yang dijamin, maka kode Java yang setara dengan kode Anda adalah:

Stream<Integer> quickSort(List<Integer> ints) {
    // Using a stream to access the data, instead of the simpler ints.isEmpty()
    if (!ints.stream().findAny().isPresent()) {
        return Stream.of();
    }

    // treating the ints as a data collection, just like the C#
    final Integer pivot = ints.get(0);

    // Using streams to get the two partitions
    List<Integer> lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList());
    List<Integer> gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList());

    return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt));
}    

Perhatikan ada bug (yang saya buat ulang), karena jenisnya tidak menangani nilai duplikat dengan anggun, itu adalah jenis 'nilai unik'.

Perhatikan juga bagaimana kode Java menggunakan sumber data ( List), dan konsep aliran pada titik yang berbeda, dan bahwa dalam C # kedua 'kepribadian' dapat diekspresikan hanya IEnumerable. Juga, meskipun saya telah menggunakan Listsebagai tipe dasar, saya bisa menggunakan yang lebih umum Collection, dan dengan konversi iterator-to-Stream yang kecil, saya bisa menggunakan yang lebih umum lagiIterable


9
Jika Anda berpikir untuk 'mengulangi' aliran, Anda salah melakukannya. Aliran mewakili keadaan data pada titik waktu tertentu dalam rantai transformasi. Data memasuki sistem dalam sumber aliran, kemudian mengalir dari satu aliran ke yang berikutnya, mengubah keadaan seiring berjalannya, sampai dikumpulkan, dikurangi, atau dibuang, pada akhirnya. A Streamadalah konsep point-in-time, bukan 'operasi loop' .... (lanjutan)
rolfl

7
Dengan Stream, Anda memiliki data yang masuk ke aliran yang tampak seperti X, dan keluar dari aliran yang tampak seperti Y. Ada fungsi yang dilakukan oleh stream yang melakukan transformasi tersebut f(x). Stream tersebut mengenkapsulasi fungsi, itu tidak merangkum data yang mengalir melalui
rolfl

4
IEnumerablejuga dapat menyediakan nilai acak, tidak terikat, dan menjadi aktif sebelum data ada.
Arturo Torres Sánchez

6
@Vitaliy: Banyak metode yang menerima ekspektasi IEnumerable<T>untuk merepresentasikan koleksi terbatas yang dapat diulang beberapa kali. Beberapa hal yang dapat diubah tetapi tidak memenuhi persyaratan yang diterapkan IEnumerable<T>karena tidak ada antarmuka standar yang sesuai dengan tagihan, tetapi metode yang mengharapkan koleksi terbatas yang dapat diulang berkali-kali cenderung mengalami kerusakan jika diberikan hal yang dapat diubah yang tidak mematuhi kondisi tersebut .
supercat

5
quickSortContoh Anda bisa jauh lebih sederhana jika mengembalikan Stream; itu akan menghemat dua .stream()panggilan dan satu .collect(Collectors.toList())panggilan. Jika Anda kemudian mengganti Collections.singleton(pivot).stream()dengan Stream.of(pivot)kode menjadi hampir dapat dibaca ...
Holger

22

StreamS dibangun di sekitar Spliterators yang merupakan objek stateable, bisa berubah. Mereka tidak memiliki tindakan "reset" dan pada kenyataannya, yang diperlukan untuk mendukung tindakan mundur tersebut akan "mengambil banyak daya". Bagaimana Random.ints()seharusnya menangani permintaan seperti itu?

Di sisi lain, untuk Streams yang memiliki asal yang dapat dilacak, mudah untuk membuat persamaan Streamuntuk digunakan lagi. Cukup letakkan langkah-langkah yang dibuat untuk membangunnya Streammenjadi metode yang dapat digunakan kembali. Ingatlah bahwa mengulangi langkah-langkah ini bukanlah operasi yang mahal karena semua langkah ini adalah operasi yang malas; pekerjaan yang sebenarnya dimulai dengan operasi terminal dan tergantung pada operasi terminal yang sebenarnya sama sekali kode yang berbeda dapat dijalankan.

Terserah Anda, penulis metode seperti itu, untuk menentukan apa yang memanggil metode dua kali menyiratkan: apakah itu mereproduksi urutan yang sama persis, seperti aliran yang dibuat untuk array atau koleksi yang tidak dimodifikasi, atau apakah itu menghasilkan aliran dengan semantik serupa tetapi elemen berbeda seperti aliran int acak atau aliran jalur input konsol, dll.


By the way, kebingungan menghindari, operasi terminal mengkonsumsi tersebut Streamyang berbeda dari penutupan yang Streamseperti memanggil close()di sungai tidak (yang diperlukan untuk aliran setelah sumber daya seperti, misalnya diproduksi oleh terkait Files.lines()).


Tampaknya banyak kebingungan berasal dari perbandingan yang salah IEnumerabledengan Stream. Sebuah IEnumerablemewakili kemampuan untuk memberikan yang sebenarnya IEnumerator, jadi seperti Iterabledi Jawa. Sebaliknya, a Streamadalah jenis iterator dan sebanding dengan IEnumeratorsehingga salah untuk mengklaim bahwa tipe data jenis ini dapat digunakan beberapa kali dalam .NET, dukungannya IEnumerator.Resetadalah opsional. Contoh-contoh yang dibahas di sini lebih menggunakan fakta bahwa a IEnumerabledapat digunakan untuk mengambil yang baru IEnumerator dan yang bekerja dengan Java Collectionjuga; Anda bisa mendapatkan yang baru Stream. Jika pengembang Java memutuskan untuk menambahkan Streamoperasi , itu benar-benar sebanding dan bisa bekerja dengan cara yang sama.Iterable secara langsung, dengan operasi menengah mengembalikan yang lainIterable

Namun, pengembang memutuskan untuk tidak melakukannya dan keputusan tersebut dibahas dalam pertanyaan ini . Poin terbesar adalah kebingungan tentang operasi Collection bersemangat dan operasi Stream malas. Dengan melihat .NET API, saya (ya, secara pribadi) menganggapnya benar. Meskipun terlihat masuk akal melihat IEnumerablesendirian, Koleksi tertentu akan memiliki banyak metode memanipulasi Koleksi secara langsung dan banyak metode mengembalikan malas IEnumerable, sedangkan sifat tertentu dari metode tidak selalu dapat dikenali secara intuitif. Contoh terburuk yang saya temukan (dalam beberapa menit saya melihatnya) adalah List.Reverse()siapa yang namanya cocok persis dengan nama yang diwarisi (apakah ini terminus yang tepat untuk metode penyuluhan?) Enumerable.Reverse()Sambil memiliki perilaku yang sepenuhnya bertentangan.


Tentu saja, ini adalah dua keputusan berbeda. Yang pertama untuk membuat Streamjenis berbeda dari Iterable/ Collectiondan yang kedua untuk membuat Streamsemacam iterator satu kali daripada jenis lain dari iterable. Tetapi keputusan ini dibuat bersama-sama dan mungkin memisahkan kedua keputusan ini tidak pernah dipertimbangkan. Itu tidak dibuat dengan sebanding dengan .NET dalam pikiran.

Keputusan desain API yang sebenarnya adalah untuk menambahkan jenis iterator yang ditingkatkan, the Spliterator. Spliterators dapat disediakan oleh yang lama Iterable(yang merupakan cara bagaimana ini dipasang) atau implementasi yang sepenuhnya baru. Kemudian, Streamditambahkan sebagai front-end level tinggi ke level yang agak rendah Spliterator. Itu dia. Anda dapat mendiskusikan tentang apakah desain yang berbeda akan lebih baik, tetapi itu tidak produktif, itu tidak akan berubah, mengingat cara mereka dirancang sekarang.

Ada aspek implementasi lain yang harus Anda pertimbangkan. Streams bukan struktur data yang tidak berubah. Setiap operasi perantara dapat mengembalikan Streaminstance baru yang mengenkapsulasi yang lama tetapi juga dapat memanipulasi instance miliknya sendiri dan mengembalikannya sendiri (yang tidak menghalangi melakukan keduanya bahkan untuk operasi yang sama). Contoh yang umum dikenal adalah operasi seperti parallelatau unorderedyang tidak menambahkan langkah lain tetapi memanipulasi seluruh pipa). Memiliki struktur data yang bisa berubah dan upaya untuk menggunakan kembali (atau bahkan lebih buruk, menggunakannya berulang kali pada waktu yang sama) tidak berfungsi dengan baik ...


Untuk kelengkapan, berikut adalah contoh quicksort Anda yang diterjemahkan ke Java StreamAPI. Ini menunjukkan bahwa itu tidak benar-benar "mengambil banyak kekuatan".

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {

  final Optional<Integer> optPivot = ints.get().findAny();
  if(!optPivot.isPresent()) return Stream.empty();

  final int pivot = optPivot.get();

  Supplier<Stream<Integer>> lt = ()->ints.get().filter(i -> i < pivot);
  Supplier<Stream<Integer>> gt = ()->ints.get().filter(i -> i > pivot);

  return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s);
}

Dapat digunakan seperti

List<Integer> l=new Random().ints(100, 0, 1000).boxed().collect(Collectors.toList());
System.out.println(l);
System.out.println(quickSort(l::stream)
    .map(Object::toString).collect(Collectors.joining(", ")));

Anda bahkan dapat menulisnya dengan lebih ringkas

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {
    return ints.get().findAny().map(pivot ->
         Stream.of(
                   quickSort(()->ints.get().filter(i -> i < pivot)),
                   Stream.of(pivot),
                   quickSort(()->ints.get().filter(i -> i > pivot)))
        .flatMap(s->s)).orElse(Stream.empty());
}

1
Nah, mengkonsumsinya atau tidak, mencoba mengkonsumsinya lagi melempar pengecualian bahwa alirannya sudah tertutup , tidak dikonsumsi. Adapun masalah dengan mengatur ulang aliran bilangan bulat acak, seperti yang Anda katakan - terserah penulis perpustakaan untuk menentukan kontrak yang tepat dari operasi reset.
Vitaliy

2
Tidak, pesannya adalah "stream telah dioperasikan pada atau ditutup" dan kami tidak berbicara tentang operasi "reset" tetapi memanggil dua atau lebih operasi terminal pada Streamsedangkan pengaturan ulang sumber Spliteratorakan tersirat. Dan saya cukup yakin jika itu mungkin, ada pertanyaan pada SO seperti “Mengapa menelepon count()dua kali pada Streammemberikan hasil yang berbeda setiap kali”, dll ...
Holger

1
Benar-benar valid untuk count () untuk memberikan hasil yang berbeda. count () adalah kueri pada aliran, dan jika aliran itu bisa berubah-ubah (atau lebih tepatnya, aliran mewakili hasil kueri pada koleksi yang bisa diubah) maka itu diharapkan. Lihatlah API C #. Mereka menangani semua masalah ini dengan anggun.
Vitaliy

4
Apa yang Anda sebut "benar-benar valid" adalah perilaku kontra-intuitif. Bagaimanapun, itu adalah motivasi utama untuk bertanya tentang menggunakan aliran beberapa kali untuk memproses hasilnya, diharapkan sama, dengan cara yang berbeda. Setiap pertanyaan pada SO tentang sifat tidak dapat digunakan kembali Streamsejauh ini berasal dari upaya untuk memecahkan masalah dengan memanggil operasi terminal beberapa kali (jelas, jika tidak Anda tidak melihat) yang menyebabkan solusi rusak secara diam-diam jika StreamAPI mengizinkannya dengan hasil berbeda pada setiap evaluasi. Ini adalah contoh yang bagus .
Holger

3
Sebenarnya, contoh Anda dengan sempurna menunjukkan apa yang terjadi jika seorang programmer tidak memahami implikasi dari penerapan beberapa operasi terminal. Pikirkan saja apa yang terjadi ketika masing-masing operasi ini akan diterapkan pada elemen yang sama sekali berbeda. Ini hanya berfungsi jika sumber aliran mengembalikan elemen yang sama pada setiap permintaan tetapi ini adalah asumsi yang salah yang kita bicarakan.
Holger

8

Saya pikir ada sedikit perbedaan di antara keduanya ketika Anda melihat cukup dekat.

Pada wajahnya, sebuah IEnumerabletampaknya menjadi sebuah konstruksi yang dapat digunakan kembali:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

foreach (var n in numbers) {
    Console.WriteLine(n);
}

Namun, kompiler sebenarnya melakukan sedikit pekerjaan untuk membantu kami; itu menghasilkan kode berikut:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext()) {
    Console.WriteLine(enumerator.Current);
}

Setiap kali Anda benar-benar akan mengulangi enumerable, kompiler membuat enumerator. Pencacah tidak dapat digunakan kembali; panggilan lebih lanjut ke MoveNexthanya akan mengembalikan false, dan tidak ada cara untuk mengatur ulang ke awal. Jika Anda ingin mengulangi angka-angka lagi, Anda harus membuat instance enumerator lainnya.


Untuk lebih menggambarkan bahwa IEnumerable memiliki (dapat memiliki) 'fitur' yang sama dengan Java Stream, pertimbangkan enumerable yang sumber bilangannya bukan koleksi statis. Sebagai contoh, kita dapat membuat objek enumerable yang menghasilkan urutan 5 angka acak:

class Generator : IEnumerator<int> {
    Random _r;
    int _current;
    int _count = 0;

    public Generator(Random r) {
        _r = r;
    }

    public bool MoveNext() {
        _current= _r.Next();
        _count++;
        return _count <= 5;
    }

    public int Current {
        get { return _current; }
    }
 }

class RandomNumberStream : IEnumerable<int> {
    Random _r = new Random();
    public IEnumerator<int> GetEnumerator() {
        return new Generator(_r);
    }
    public IEnumerator IEnumerable.GetEnumerator() {
        return this.GetEnumerator();
    }
}

Sekarang kita memiliki kode yang sangat mirip dengan enumerable berbasis array sebelumnya, tetapi dengan iterasi kedua berakhir numbers:

IEnumerable<int> numbers = new RandomNumberStream();

foreach (var n in numbers) {
    Console.WriteLine(n);
}
foreach (var n in numbers) {
    Console.WriteLine(n);
}

Kali kedua kita mengulanginya numberskita akan mendapatkan urutan angka yang berbeda, yang tidak dapat digunakan kembali dalam arti yang sama. Atau, kami dapat menulis RandomNumberStreamuntuk melemparkan pengecualian jika Anda mencoba untuk mengulanginya berulang kali, membuat enumerable benar-benar tidak dapat digunakan (seperti Java Stream).

Juga, apa arti penyortiran cepat berbasis enumerable Anda saat diterapkan ke RandomNumberStream?


Kesimpulan

Jadi, perbedaan terbesar adalah bahwa .NET memungkinkan Anda untuk menggunakan kembali IEnumerabledengan secara implisit membuat yang baru IEnumeratordi latar belakang setiap kali diperlukan untuk mengakses elemen dalam urutan.

Perilaku implisit ini sering berguna (dan 'kuat' seperti yang Anda nyatakan), karena kita dapat berulang kali mengulangi koleksi.

Namun terkadang, perilaku tersirat ini justru bisa menimbulkan masalah. Jika sumber data Anda tidak statis, atau mahal untuk diakses (seperti database atau situs web), maka banyak asumsi tentang IEnumerableharus dibuang; penggunaan kembali tidak lurus ke depan


2

Dimungkinkan untuk melewati beberapa perlindungan "jalankan sekali" di Stream API; misalnya kita dapat menghindari java.lang.IllegalStateExceptionpengecualian (dengan pesan "streaming telah dioperasikan atau ditutup") dengan merujuk dan menggunakan kembali Spliterator(alih-alih Streamsecara langsung).

Misalnya, kode ini akan berjalan tanpa membuang pengecualian:

    Spliterator<String> split = Stream.of("hello","world")
                                      .map(s->"prefix-"+s)
                                      .spliterator();

    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);


    replayable1.forEach(System.out::println);
    replayable2.forEach(System.out::println);

Namun output akan terbatas pada

prefix-hello
prefix-world

daripada mengulangi output dua kali. Ini karena ArraySpliteratordigunakan sebagai Streamsumber stateful dan menyimpan posisi saat ini. Ketika kami memutar ulang ini, Streamkami mulai lagi di akhir.

Kami memiliki sejumlah opsi untuk mengatasi tantangan ini:

  1. Kita dapat menggunakan Streammetode pembuatan stateless seperti Stream#generate(). Kami harus mengelola status secara eksternal dalam kode kami sendiri dan mengatur ulang antara Stream"replay":

    Spliterator<String> split = Stream.generate(this::nextValue)
                                      .map(s->"prefix-"+s)
                                      .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    this.resetCounter();
    replayable2.forEach(System.out::println);
  2. Solusi lain (sedikit lebih baik tetapi tidak sempurna) untuk ini adalah dengan menulis sendiri ArraySpliterator(atau Streamsumber serupa ) yang mencakup beberapa kapasitas untuk mereset penghitung saat ini. Jika kami menggunakannya untuk menghasilkan, Streamkami berpotensi memutar ulang mereka dengan sukses.

    MyArraySpliterator<String> arraySplit = new MyArraySpliterator("hello","world");
    Spliterator<String> split = StreamSupport.stream(arraySplit,false)
                                            .map(s->"prefix-"+s)
                                            .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    arraySplit.reset();
    replayable2.forEach(System.out::println);
  3. Solusi terbaik untuk masalah ini (menurut saya) adalah membuat salinan baru dari setiap stateful Spliteratoryang digunakan dalam Streampipa ketika operator baru dipanggil pada Stream. Ini lebih kompleks dan terlibat untuk diterapkan, tetapi jika Anda tidak keberatan menggunakan perpustakaan pihak ketiga, cyclop-react memiliki Streamimplementasi yang melakukan hal ini. (Pengungkapan: Saya adalah pengembang utama untuk proyek ini.)

    Stream<String> replayableStream = ReactiveSeq.of("hello","world")
                                                 .map(s->"prefix-"+s);
    
    
    
    
    replayableStream.forEach(System.out::println);
    replayableStream.forEach(System.out::println);

Ini akan dicetak

prefix-hello
prefix-world
prefix-hello
prefix-world

seperti yang diharapkan.

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.