Penyusunan future

For-comprehension menggantikan piramida callback dalam menyusun alur asinkron.

Daftar isi

Menyusun beberapa tahap asinkron berarti menyusun flatMap di dalam flatMap, dan for-comprehension menjaga susunan itu tetap terbaca sebagai blok vertikal. Topik ini membahas for-comprehension atas future yang tahapannya saling bergantung, perbedaannya dengan future yang sudah dibuat sebelumnya, penggabungan future yang independen dengan zip dan Future.sequence, serta penanganan kegagalan di dalam alur. Setelah membacanya, alur asinkron bertahap dapat ditulis tanpa piramida callback, dan kondisi yang menuntut eksekusi berurutan atau bersamaan dapat dinyatakan dengan sengaja.

Generator bergantung menjadi flatMap berurutan#

For-comprehension atas future mengikuti desugaring yang sama dengan for-comprehension atas koleksi, yaitu satu generator menjadi map dan generator berganda menjadi flatMap bersarang. Perbedaannya terletak pada waktu: sisi kanan generator berikutnya baru dievaluasi setelah generator sebelumnya selesai, karena sisi kanannya boleh menggunakan ikatan yang sudah tersedia. Apabila sisi kanan itu berupa pemanggilan fungsi yang membangun future dari hasil generator sebelumnya, eksekusinya benar-benar berurutan. Program berikut menyusun dua tahap yang keduanya bergantung, yaitu membaca data lalu menghitung rata-ratanya.

scala alur.scala
// Tahap kedua baru dibuat setelah tahap pertama selesai, karena datanya bergantung
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.*

@main def alur(): Unit =
  val bacaData: Future[List[Int]] = Future(List(80, 65, 90, 55, 70))

  def rataRata(nilai: List[Int]): Future[Int] =
    Future(nilai.sum / nilai.size)

  val laporan: Future[String] =
    for
      data <- bacaData
      rata <- rataRata(data)
    yield s"Rata-rata nilai ujian: $rata"

  println(Await.result(laporan, 5.seconds))
bash
# menjalankan program alur.scala
scala-cli run alur.scala
text Keluaran
Rata-rata nilai ujian: 72

Baris rata <- rataRata(data) adalah inti contoh ini, karena rataRata tidak dapat dipanggil sebelum data tersedia. Desugaring dari for di atas adalah pemanggilan berikut. Dalam bentuk bersarang inilah piramida callback yang dikenal sulit dibaca.

scala Contoh: padanan flatMap bersarang dari for di atas
bacaData.flatMap(data =>
  rataRata(data).map(rata =>
    s"Rata-rata nilai ujian: $rata"
  )
)

Setiap tahap tambahan menambah satu lapis bersarang pada bentuk flatMap, sedangkan bentuk for menambah satu baris generator. Angka 72 pada keluaran berasal dari pembagian bulat 360 dengan 5, yaitu jumlah kelima nilai ujian dibagi dengan banyaknya peserta. Pencetakan dilakukan dari thread utama setelah Await.result kembali, sehingga keluaran tetap sama persis meskipun kedua tahap berjalan pada thread yang berbeda-beda.

Future yang sudah dibuat tidak menunggu for#

Generator berganda tidak selalu berarti eksekusi berurutan. Apabila kedua sisi kanan generator adalah future yang sudah dibuat sebelumnya, keduanya sudah berjalan bersamaan sejak pembuatannya, dan for hanya mengurutkan pengambilan hasilnya menjadi satu nilai akhir. Kode berikut menggambarkan kondisi itu.

scala Contoh: future yang sudah dibuat berjalan sejak pembuatannya
val jumlahA: Future[Int] = Future(hitungA())   // langsung dijadwalkan
val jumlahB: Future[Int] = Future(hitungB())   // langsung dijadwalkan

for
  a <- jumlahA
  b <- jumlahB
yield a + b

Kedua future pada contoh itu mulai dijadwalkan pada baris val-nya, bukan pada baris generatornya, sehingga tidak ada pekerjaan yang ditunda oleh for. Aturan yang membedakannya sederhana: generator yang kanannya merujuk val yang sudah ada berjalan bersamaan, sedangkan generator yang kanannya membangun future baru dari ikatan sebelumnya berjalan berurutan. Keduanya sah, tetapi keputusannya perlu disengaja, karena salah membacanya membuat alur yang seharusnya berurutan berjalan tidak berurutan, atau sebaliknya membuang kesempatan menjalankan pekerjaan secara bersamaan.

Menggabungkan future yang independen#

Apabila hasil-hasil yang dibutuhkan tidak saling bergantung, zip dan Future.sequence adalah penyusunnya. Metode zip menggabungkan dua future menjadi Future[(A, B)], sedangkan Future.sequence mengumpulkan List[Future[A]] menjadi Future[List[A]], dan keduanya menghasilkan future gagal apabila salah satu elemennya gagal. Bentuk for juga sah untuk dua future yang sudah dibuat, tetapi zip menyatakan niatnya secara eksplisit, sedangkan Future.sequence tetap ringkas untuk jumlah future yang berubah-ubah. Program berikut menjalankan dua penghitungan independen sekaligus, lalu mengumpulkan tiga future bernilai tetap.

scala gabungan.scala
// Future yang independen digabung dengan zip, daftar future dikumpulkan sequence
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.*

@main def gabungan(): Unit =
  val jumlahGenap: Future[Int] = Future((2 to 100 by 2).sum)
  val jumlahGanjil: Future[Int] = Future((1 to 99 by 2).sum)

  val berpasangan: Future[(Int, Int)] = jumlahGenap.zip(jumlahGanjil)

  val satuan: List[Future[Int]] = List(Future(2), Future(4), Future(6))
  val terkumpul: Future[List[Int]] = Future.sequence(satuan)

  val (genap, ganjil) = Await.result(berpasangan, 5.seconds)
  println(s"Genap $genap, ganjil $ganjil, total ${genap + ganjil}")
  println(Await.result(terkumpul, 5.seconds))
bash
# menjalankan program gabungan.scala
scala-cli run gabungan.scala
text Keluaran
Genap 2550, ganjil 2500, total 5050
List(2, 4, 6)

Angka pada keluaran dapat dihitung ulang, yaitu 2550 untuk jumlah bilangan genap 2 sampai 100, 2500 untuk jumlah bilangan ganjil 1 sampai 99, dan 5050 untuk totalnya. Baris kedua adalah hasil Future.sequence yang mengembalikan bentuk Future[List[Int]]. Pasangan hasil zip diurai kembali melalui pola val (genap, ganjil), sehingga kedua penghitungan yang berjalan bersamaan tetap diperiksa satu per satu dari thread utama. Untuk perbandingan, bentuk for di bawah ini menghasilkan nilai akhir yang sama, karena kedua future-nya memang sudah dibuat sebelumnya.

scala Contoh: for atas dua future yang sudah dibuat
for
  genap  <- jumlahGenap
  ganjil <- jumlahGanjil
yield genap + ganjil

Pilihan di antara keduanya hanya masalah keterbacaan pada jumlah kecil, tetapi begitu future yang harus dikumpulkan bertambah banyak atau berasal dari daftar, Future.sequence menjadi satu-satunya bentuk yang ringkas.

Penanganan kegagalan dalam for-comprehension#

Kegagalan pada satu tahap diteruskan ke hasil akhir for-comprehension, sehingga generator setelahnya tidak dijalankan dan alur selesai lebih awal dengan kegagalan itu. Perilakunya sama dengan flatMap pada Try, yaitu sisa rantai dilewati begitu ada kegagalan. Karena hasil akhirnya adalah future biasa, recover ditempelkan di ujung alur untuk memulihkan kegagalan apa pun yang berasal dari tahap mana pun. Program berikut membuat alur dua tahap dengan transformasi yang gagal, lalu memulihkannya.

scala kegagalan.scala
// Kegagalan pada satu tahap membuat seluruh alur for gagal, lalu dipulihkan di ujungnya
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.*

@main def kegagalan(): Unit =
  val bacaData: Future[List[String]] = Future(List("12", "30", "tujuh", "5"))

  val alur: Future[Int] =
    for
      teks  <- bacaData
      angka  = teks.map(a => a.toInt).sum
    yield angka

  val aman: Future[Int] = alur.recover { case _: NumberFormatException => -1 }

  println(s"Jenis error: ${Await.result(alur.failed, 5.seconds).getClass.getSimpleName}")
  println(s"Hasil pemulihan: ${Await.result(aman, 5.seconds)}")
bash
# menjalankan program kegagalan.scala
scala-cli run kegagalan.scala
text Keluaran
Jenis error: NumberFormatException
Hasil pemulihan: -1

Baris angka = teks.map(a => a.toInt).sum menggunakan tanda =, bukan <-, sehingga baris itu adalah ikatan biasa yang disisipkan ke dalam desugaring tanpa menambah generator. Konversi “tujuh” dengan toInt melempar NumberFormatException di dalam map, sehingga future alur selesai dengan kegagalan, dan recover di ujungnya mengubah kegagalan tersebut menjadi nilai -1. Letak recover menentukan cakupannya, karena recover yang ditempel pada bacaData hanya menangkap kegagalan tahap pertama, sedangkan kegagalan pada transformasi berikutnya tetap lolos. Dengan menempelkan recover pada hasil akhir, satu penanganan menutup seluruh alur, dan pencetakan tetap dilakukan dari thread utama setelah Await.result kembali.

Ringkasan#

  • For-comprehension atas future diterjemahkan menurut aturan desugaring menjadi flatMap dan map bersarang, sehingga bentuknya setara dengan piramida callback tetapi terbaca vertikal.
  • Generator yang kanannya membangun future dari hasil generator sebelumnya berjalan berurutan, sedangkan future yang sudah dibuat sebelumnya berjalan bersamaan sejak pembuatannya.
  • zip menggabungkan dua future independen menjadi pasangan, dan Future.sequence mengumpulkan daftar future menjadi satu future berisi daftar.
  • Kegagalan pada satu tahap diteruskan ke hasil akhir for, dan recover yang ditempel di ujung alur memulihkannya menjadi nilai.

Referensi#