Stream

Pipeline lazy untuk data besar tanpa koleksi perantara.

Daftar isi

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.

elixir eager_vs_lazy.exs
# 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")
text Keluaran
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.

elixir Contoh: stream belum berjalan sebelum Enum memintanya
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")
text Keluaran
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.

elixir Contoh: mengambil tiga elemen dari rentang sepuluh juta
IO.inspect(1..10_000_000 |> Stream.take(3) |> Enum.to_list(), label: "tiga pertama")
text Keluaran
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.

elixir Contoh: lima bilangan genap dari aliran tak terhingga
lima_genap =
  Stream.iterate(0, &(&1 + 2))
  |> Stream.take(5)
  |> Enum.to_list()

IO.inspect(lima_genap, label: "lima genap")
text Keluaran
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.

elixir rata_rata_suhu.exs
# 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")
text Keluaran
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.

elixir Contoh: hasil identik antara eager dan lazy
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")
text Keluaran
eager: 750001500000
lazy: 750001500000

Kesalahan 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/2 membatasi banyaknya elemen yang ditarik, sehingga rentang sangat besar dan aliran tak terhingga dapat diolah dengan aman.
  • Stream.chunk_every/2, Stream.map/2, dan Stream.filter/2 menyusun 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#