Topik ini membahas modul Stream, yaitu pasangan Enum yang bekerja secara lazy, yakni menunda perhitungan hingga hasilnya diminta. Pembahasan dimulai dari perbedaan evaluasi eager dan lazy yang diperlihatkan melalui urutan pencetakan, dilanjutkan dengan perilaku stream yang baru berjalan ketika fungsi Enum memintanya, pengolahan rentang sangat besar, dan aliran tak terhingga. Setelah topik ini dibaca, keputusan kapan menggunakan Enum dan kapan menggunakan Stream dapat diambil dengan dasar yang jelas.
Evaluasi eager dan lazy#
Enum bersifat eager: setiap fungsi langsung memproses seluruh elemen dan mengembalikan koleksi baru sebelum fungsi berikutnya berjalan. Rantai Enum.map dan Enum.filter dengan demikian membuat koleksi perantara pada setiap langkahnya. Pendekatan ini mudah diprediksi, tetapi menggunakan memori sebesar koleksi perantara terbesarnya, dan biaya itu terasa ketika datanya besar.
Stream bersifat lazy: Stream.map dan Stream.filter tidak menghitung apa pun saat dipanggil. Keduanya hanya menyusun deskripsi transformasi, dan hasilnya berupa stream, yakni nilai yang siap dieksekusi elemen demi elemen. Perhitungan baru berjalan ketika sebuah fungsi Enum menerima stream tersebut. Perbedaan Enum dan Stream paling mudah dilihat dari urutan pencetakan berikut, yang menaruh IO.puts di dalam setiap langkah transformasi.
# Urutan kerja eager versus lazy diperlihatkan dengan pencetakan
nilai = [78, 85, 92, 60]
IO.puts("Enum (eager):")
hasil_enum =
nilai
|> Enum.map(fn n ->
IO.puts(" map memproses #{n}")
n * 2
end)
|> Enum.filter(fn n ->
IO.puts(" filter memproses #{n}")
n > 150
end)
IO.inspect(hasil_enum, label: "hasil eager")
IO.puts("Stream (lazy):")
hasil_stream =
nilai
|> Stream.map(fn n ->
IO.puts(" map memproses #{n}")
n * 2
end)
|> Stream.filter(fn n ->
IO.puts(" filter memproses #{n}")
n > 150
end)
|> Enum.to_list()
IO.inspect(hasil_stream, label: "hasil lazy")Enum (eager):
map memproses 78
map memproses 85
map memproses 92
map memproses 60
filter memproses 156
filter memproses 170
filter memproses 184
filter memproses 120
hasil eager: [156, 170, 184]
Stream (lazy):
map memproses 78
filter memproses 156
map memproses 85
filter memproses 170
map memproses 92
filter memproses 184
map memproses 60
filter memproses 120
hasil lazy: [156, 170, 184]Pada bagian Enum, seluruh pemetaan selesai lebih dahulu, kemudian seluruh penyaringan berjalan pada hasil pemetaan tersebut. Pada bagian Stream, setiap elemen mengalir melewati map dan filter satu per satu, sehingga pencetakan keduanya berselang-seling. Hasil akhirnya sama persis, sedangkan urutan kerjanya berbeda. Data mengalir per elemen, bukan per tahap, dan itulah ciri utama stream.
Stream baru berjalan saat Enum memintanya#
Membangun stream hampir tidak melakukan pekerjaan. Fungsi transformasi di dalamnya belum dipanggil sekali pun, sehingga efek samping seperti pencetakan juga belum terjadi. Yang tersimpan hanyalah sumber beserta daftar fungsi transformasinya. Eksekusi baru berjalan saat sebuah fungsi Enum, misalnya Enum.to_list/1 atau Enum.count/1, menarik elemen dari stream tersebut. Akibat praktisnya, melewatkan pemanggilan pemicu membuat seluruh perhitungan tidak pernah berjalan.
aliran =
[78, 85, 92]
|> Stream.map(fn n ->
IO.puts(" memproses #{n}")
n * 2
end)
IO.puts("Aliran sudah dibangun, belum ada yang dihitung.")
IO.inspect(Enum.to_list(aliran), label: "hasil")Aliran sudah dibangun, belum ada yang dihitung.
memproses 78
memproses 85
memproses 92
hasil: [156, 170, 184]Fungsi Enum yang menarik elemen juga hanya mengambil sebanyak yang dibutuhkan. Apabila Enum.take/2 diminta mengambil tiga elemen pertama dari sebuah stream, hanya tiga elemen yang diproses meskipun sumbernya jauh lebih besar. Batas penarikan inilah yang membedakan stream dari Enum, yang selalu memproses seluruh sumbernya. Untuk hasil yang tidak perlu dikumpulkan, Stream.run/1 cukup dipanggil agar seluruh aliran dieksekusi tanpa membentuk koleksi. Stream yang sudah dieksekusi dapat ditarik lagi, dan hasilnya dihitung ulang dari sumbernya, sehingga koleksi hasil perlu disimpan apabila data akan digunakan beberapa kali.
Rentang besar dan aliran tak terhingga#
Sifat per-elemen stream membuat rentang sangat besar dapat diolah tanpa membangun listnya lebih dahulu, karena elemen dihitung hanya ketika ditarik. Pada contoh berikut, hanya tiga elemen pertama yang diambil dari rentang berisi sepuluh juta elemen. Rentang tersebut tidak pernah dilewati seluruhnya, dan prosesnya selesai seketika.
IO.inspect(1..10_000_000 |> Stream.take(3) |> Enum.to_list(), label: "tiga pertama")tiga pertama: [1, 2, 3]Rentang 1..10_000_000 tidak dialokasikan seluruhnya karena rentang merupakan enumerable yang menghitung elemennya saat ditarik, sedangkan Stream.take/2 menghentikan penarikan begitu tiga elemen tercapai. Fungsi pemotong lain seperti Stream.drop/2 bekerja dengan cara yang sama dan tetap memerlukan pembatas sebelum hasilnya dikumpulkan. Pembatas yang sama juga diperlukan pada Stream.cycle/1 yang mengulang sumbernya tanpa akhir. Pendekatan yang sama membuat pipeline untuk berkas besar atau daftar yang diterima bertahap tetap hemat memori.
Sifat lazy yang sama memungkinkan sumber tak terhingga. Stream.iterate/2 menghasilkan deret tanpa akhir dari sebuah nilai awal beserta fungsi penerusnya, dan pipeline berikut hanya aman karena Stream.take/2 membatasi banyaknya elemen yang ditarik. Penggunaan sumber semacam itu tanpa pembatas membuat program berjalan tanpa berhenti.
lima_genap =
Stream.iterate(0, &(&1 + 2))
|> Stream.take(5)
|> Enum.to_list()
IO.inspect(lima_genap, label: "lima genap")lima genap: [0, 2, 4, 6, 8]Stream.chunk_every dan pipeline panjang#
Stream menyediakan padanan untuk sebagian besar fungsi Enum dengan nama yang sama, sehingga pipeline panjang dapat ditulis tanpa keluar dari modul tersebut. Stream.chunk_every/2 memecah aliran menjadi potongan berukuran tetap, sedangkan Stream.filter/2 menyaring elemen yang mengalir. Potongan terakhir boleh lebih pendek apabila banyaknya elemen tidak habis dibagi ukuran potongannya. Contoh berikut menghitung rata-rata suhu tiap dua pembacaan sensor, kemudian menyimpan pasangan yang rata-ratanya mencapai tiga puluh derajat atau lebih.
# Rata-rata suhu tiap dua pembacaan sensor, pasangan hangat disaring
suhu = [27, 29, 31, 33, 32, 30, 28, 26]
hangat =
suhu
|> Stream.chunk_every(2)
|> Stream.map(fn pasangan -> Enum.sum(pasangan) / length(pasangan) end)
|> Stream.filter(&(&1 >= 30))
|> Enum.to_list()
IO.inspect(hangat, label: "rata-rata pasangan hangat")rata-rata pasangan hangat: [32.0, 31.0]Hasil pipeline lazy selalu sama dengan padanan eager-nya, karena keduanya menjalankan transformasi yang sama terhadap data yang sama. Contoh berikut menjumlahkan satu juta bilangan dengan dua cara tersebut dan mencetak hasil yang identik. Perbedaannya terletak pada koleksi perantara, yaitu list satu juta elemen pada tahap map dan setengah juta elemen pada tahap filter, sedangkan versi Stream hanya memegang satu elemen pada satu waktu. Untuk data sebesar ini selisihnya belum terasa, sedangkan pada data yang jauh lebih besar perbedaan memori dan waktu menjadi nyata.
jumlah_eager =
1..1_000_000
|> Enum.map(&(&1 * 3))
|> Enum.filter(&(rem(&1, 2) == 0))
|> Enum.sum()
jumlah_lazy =
1..1_000_000
|> Stream.map(&(&1 * 3))
|> Stream.filter(&(rem(&1, 2) == 0))
|> Enum.sum()
IO.inspect(jumlah_eager, label: "eager")
IO.inspect(jumlah_lazy, label: "lazy")eager: 750001500000
lazy: 750001500000Kesalahan umum#
Kesalahan pertama adalah menganggap stream sebagai koleksi yang sudah berisi data. Stream yang tidak pernah diteruskan ke fungsi Enum tidak melakukan apa pun, tanpa pesan error sekalipun, karena yang tersimpan hanya deskripsi transformasinya. Kelalaian semacam ini sulit terdeteksi karena program tetap berakhir normal.
Kesalahan kedua adalah memeriksa isi stream dengan IO.inspect. Stream yang diperiksa menampilkan sumber dan daftar fungsi transformasinya, bukan hasil perhitungannya, sehingga kebiasaan yang benar adalah memeriksa keluaran Enum.to_list/1 atau fungsi Enum pemicu lainnya. Perilaku ini berbeda dari koleksi biasa yang langsung menampilkan isinya.
Kesalahan ketiga adalah menganggap Stream selalu lebih cepat. Untuk koleksi kecil, Enum biasanya lebih cepat karena stream menambah lapisan pemanggilan fungsi pada setiap elemen. Keunggulan Stream muncul ketika data besar, ketika sumbernya tak terhingga, atau ketika hanya sebagian kecil elemen yang dibutuhkan. Apabila ragu, mulailah dengan Enum, lalu pindah ke Stream setelah ukuran atau sumber datanya menuntut.
Ringkasan#
- Enum dieksekusi per tahap dan membentuk koleksi perantara, sedangkan Stream menyusun deskripsi transformasi yang dieksekusi per elemen saat fungsi Enum memintanya.
Stream.take/2membatasi banyaknya elemen yang ditarik, sehingga rentang sangat besar dan aliran tak terhingga dapat diolah dengan aman.Stream.chunk_every/2,Stream.map/2, danStream.filter/2menyusun pipeline panjang yang hasilnya identik dengan padanan Enum-nya.- Stream tanpa fungsi Enum pemicu tidak melakukan apa pun, dan keunggulannya baru terasa pada data besar atau sumber tak terhingga.
Referensi#
- Modul Stream — Dokumentasi Elixir — Daftar fungsi penyusun stream beserta catatan perilaku lazy-nya.
- Modul Enum — Dokumentasi Elixir — Fungsi-fungsi yang mengeksekusi stream dan mengumpulkan hasilnya.