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
, map
dan sebagainya adalah ekstensi (default) metode pada Iterable
. Memanggil satu menambahkan operasi ke rantai dan mengembalikan yang lain Iterable
. Operasi terminal seperti count
akan 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 Registrant
objek dan mencetaknya dua kali. Kecuali bahwa itu sebenarnya hanya mencetaknya sekali. Ternyata dia mengira registrants
itu koleksi, padahal sebenarnya itu iterator. Panggilan kedua untuk foreach
menemukan 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);
( into
Operasi 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 IEnumerable
memungkinkan beberapa traversal untuk berperilaku berbeda dengan sumber yang berbeda; dan itu memungkinkan struktur percabangan IEnumerable
operasi 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 QuickSort
membutuhkan IEnumerable
dan mengembalikan IEnumerable
, jadi tidak ada penyortiran yang benar-benar dilakukan hingga final IEnumerable
dilalui. Apa yang tampaknya dilakukan oleh panggilan itu, adalah membangun struktur pohon IEnumerables
yang 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 IEnumerable
dapat 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.