Evaluasi malas dan ketat

lazy val, parameter by-name, dan biaya tersembunyi LazyList.

Daftar isi

Scala mengevaluasi kebanyakan ekspresi secara ketat, yaitu segera pada saat barisnya dijalankan. Bahasa ini juga menyediakan evaluasi malas (lazy evaluation), yaitu ekspresi baru dihitung ketika hasilnya pertama kali dibutuhkan. Topik ini membahas keduanya melalui val ketat, lazy val yang menunda inisialisasi, parameter by-name yang dievaluasi ulang pada setiap penggunaan, serta LazyList sebagai deret malas. Hasil yang diperoleh adalah kemampuan memilih waktu evaluasi secara sengaja, disertai pemahaman atas biaya yang menyertainya.

val ketat dan lazy val#

Kata kunci val mengikat nilai secara ketat. Ekspresi di sebelah kanan tanda sama dengan dihitung segera pada saat pengikatan dieksekusi, sebelum program berpindah ke baris berikutnya. Kata kunci lazy val memindahkan pekerjaan itu, yaitu ekspresinya baru dihitung pada saat nilainya pertama kali dibaca. Setelah dihitung, hasilnya disimpan, sehingga penggunaan berikutnya membaca nilai yang tersimpan tanpa menghitung ulang.

Urutan keduanya mudah diamati melalui efek samping. Program berikut mencetak satu pesan bernomor pada setiap langkah, dan seluruh pesan berasal dari thread utama yang berjalan berurutan.

scala malas-ketat.scala
// Urutan evaluasi val ketat dan lazy val
def hitung(pesan: String): Int =
  println(pesan)
  40 + 2

@main def malasKetat(): Unit =
  println("1. program dimulai")

  val ketat = hitung("2. val ketat dievaluasi")

  lazy val malas = hitung("4. lazy val dievaluasi")

  println("3. sebelum menyentuh malas")
  println("5. malas dipakai: " + malas)
  println("6. malas dipakai lagi: " + malas)
  println("7. hasil ketat: " + ketat)

Jalankan program tersebut dengan scala-cli.

bash
# mengompilasi dan menjalankan program urutan evaluasi
scala-cli run malas-ketat.scala
text Keluaran
1. program dimulai
2. val ketat dievaluasi
3. sebelum menyentuh malas
4. lazy val dievaluasi
5. malas dipakai: 42
6. malas dipakai lagi: 42
7. hasil ketat: 42

Keluaran tersebut menyatakan mekanismenya secara harfiah. val ketat dievaluasi pada baris definisinya, sehingga pesan nomor 2 muncul sebelum pesan nomor 3. lazy val malas baru dievaluasi ketika dibaca pertama kali pada perintah kelima, sehingga pesan nomor 4 muncul di antara pesan nomor 3 dan nomor 5. Penggunaan kedua pada baris berikutnya tidak memicu pesan apa pun, karena hasil perhitungan pertama sudah tersimpan. Properti ini berlaku umum, yaitu lazy val paling banyak dihitung satu kali sepanjang masa hidupnya.

Parameter by-name#

Parameter by-name adalah parameter yang ekspresinya dikirim tanpa dihitung lebih dahulu. Pada tanda tangan fungsi, bentuknya ditulis dengan tanda => sebelum tipenya, misalnya nilai: => Int. Setiap penggunaan parameter ini di dalam badan fungsi menghitung ulang ekspresi asalnya. Perilaku ini berbeda dari parameter biasa, yang ekspresinya dihitung satu kali pada titik pemanggilan sebelum badan fungsi berjalan.

Penghitung lokal memperlihatkan perbedaannya tanpa perlu pengukuran waktu. Fungsi nilaiBerikut menaikkan hitungan setiap dipanggil dan mengembalikan nilainya, sehingga jumlah pemanggilan dinyatakan oleh angka yang tetap.

scala parameter-by-name.scala
// Parameter by-name dievaluasi ulang pada setiap pemakaian
@main def parameterByName(): Unit =
  var hitungan = 0

  def nilaiBerikut(): Int =
    hitungan += 1
    hitungan

  def pakaiKetat(nilai: Int): String =
    s"ketat  : pertama $nilai, kedua $nilai"

  def pakaiByName(nilai: => Int): String =
    s"by-name: pertama $nilai, kedua $nilai"

  println(pakaiKetat(nilaiBerikut()))
  println(pakaiByName(nilaiBerikut()))
  println("nilaiBerikut dipanggil: " + hitungan + " kali")

Jalankan program tersebut.

bash
# mengompilasi dan menjalankan program parameter by-name
scala-cli run parameter-by-name.scala
text Keluaran
ketat  : pertama 1, kedua 1
by-name: pertama 2, kedua 3
nilaiBerikut dipanggil: 3 kali

Baris pertama memanggil pakaiKetat(nilaiBerikut()). Ekspresi nilaiBerikut() dihitung satu kali pada titik pemanggilan dan menghasilkan angka 1, kemudian kedua penggunaan nilai di dalam interpolasi membaca nilai yang sama. Baris kedua memanggil pakaiByName(nilaiBerikut()). Kedua kemunculan nilai di dalam badan fungsi menghitung ulang ekspresi asalnya, yaitu nilaiBerikut() yang menghasilkan 2 dan kemudian 3. Baris ketiga menyatakan totalnya, yaitu tiga pemanggilan.

Parameter by-name tidak menyimpan hasil. Apabila ekspresinya mahal dan digunakan berkali-kali di dalam satu fungsi, biayanya dikalikan dengan jumlah penggunaan tersebut.

LazyList: deret tak hingga dengan #::#

LazyList adalah koleksi malas yang elemennya baru dihitung ketika diminta. Struktur ini tersusun dari simpul (node) yang memuat satu nilai kepala dan ekor berupa thunk, yaitu ekspresi tertunda yang belum dihitung. Operator #:: membangun simpul secara malas, sehingga ekornya dapat merujuk ke deret yang sedang dibangun. Dengan cara ini, deret yang tak berhingga dapat didefinisikan, dan hanya bagian yang diminta yang benar-benar dihitung.

Program berikut mendefinisikan deret pangkat dua tak berhingga, lalu mengambil sebagiannya.

scala lazylist.scala
// Deret pangkat dua tak hingga dengan LazyList
@main def deretPangkatDua(): Unit =
  lazy val pangkatDua: LazyList[BigInt] =
    BigInt(1) #:: pangkatDua.map(n => n * 2)

  println("Sepuluh elemen pertama:")
  println(pangkatDua.take(10).mkString(", "))
  println("Elemen ke-20: " + pangkatDua(19))

Jalankan program tersebut.

bash
# mengompilasi dan menjalankan program deret pangkat dua
scala-cli run lazylist.scala
text Keluaran
Sepuluh elemen pertama:
1, 2, 4, 8, 16, 32, 64, 128, 256, 512
Elemen ke-20: 524288

Nilai pangkatDua didefinisikan sebagai lazy val yang merujuk dirinya sendiri, yaitu kepala berupa 1 dan ekor berupa seluruh deret yang dikalikan dua. Perintah take(10) memaksa sepuluh simpul pertama dihitung, sedangkan elemen-elemen berikutnya tetap tertunda. Perintah pangkatDua(19) menelusuri rantai simpul sampai indeks ke-19 dan menghasilkan 524288, yaitu 2 pangkat 19. Hasil yang sudah dihitung tersimpan pada simpulnya, sehingga pembacaan ulang elemen yang sama tidak menghitung ulang apa pun.

Biaya yang menyertai malas#

Evaluasi malas pada LazyList tidak gratis. Setiap elemen dibungkus oleh satu objek simpul yang memuat kepala, thunk ekor, dan tempat penyimpanan hasil setelah dihitung. Koleksi ketat seperti Vector menyimpan elemen dalam blok yang rapat, sedangkan LazyList membuat satu objek tambahan per elemen, sehingga jejak memorinya lebih besar pada jumlah elemen yang sama. Nilai yang sudah dihitung juga tertahan di memori selama deretnya masih dirujuk, karena hasil itu menjadi bagian dari struktur datanya.

Biaya serupa muncul pada lazy val dan parameter by-name, hanya lebih kecil. lazy val menyimpan hasil beserta pemeriksaan yang menjamin inisialisasi hanya berjalan sekali, sedangkan parameter by-name membungkus ekspresinya menjadi satu metode khusus yang dipanggil pada setiap penggunaan.

Karena itu, pilihan waktu evaluasi mengikuti bentuk datanya. Deret yang besar atau tak berhingga, yang hanya sebagiannya digunakan, cocok dengan LazyList. Perhitungan numerik berat pada data yang sudah lengkap berjalan lebih hemat memori pada koleksi ketat atau perulangan biasa.

Ringkasan#

  • val ketat mengevaluasi ekspresi pada saat pengikatan, sedangkan lazy val menunda evaluasinya sampai penggunaan pertama dan menyimpan hasilnya untuk penggunaan berikutnya.
  • Parameter by-name dievaluasi ulang pada setiap penggunaannya di dalam badan fungsi, tanpa penyimpanan hasil.
  • LazyList membangun deret malas melalui #::, sehingga deret tak berhingga dapat didefinisikan dan sebagian kecilnya dihitung sesuai permintaan.
  • Setiap elemen LazyList terbungkus simpul tersendiri, sehingga struktur ini membutuhkan memori lebih banyak daripada koleksi ketat pada jumlah elemen yang sama.

Referensi#