@Delegate dan @Lazy

Komposisi melalui @Delegate dan field termemoisasi dengan @Lazy tanpa boilerplate.

Daftar isi

Komposisi menyusun kelas dari objek kecil yang disimpan di dalamnya, dan dua anotasi Groovy membuat pendekatan itu praktis tanpa kode yang ditulis berulang. @Delegate menghasilkan metode penerus untuk kemampuan objek yang disimpan, sedangkan @Lazy menunda inisialisasi field sampai akses pertama. Keduanya bekerja sebagai transformasi AST pada fase kompilasi dan beralamat di paket groovy.lang, bukan groovy.transform. Topik ini menelusuri perilaku keduanya pada Groovy 5.1.3, mulai dari penerusan metode, penyaringannya, delegasi bertipe interface, pemuatan tunda, hingga kombinasi keduanya. Setiap contoh disertai keluaran asli dari eksekusi nyata.

Komposisi dengan @Delegate#

Kelas yang memegang objek lain sekaligus memaparkan kemampuannya biasanya menulis metode penerus satu per satu. Pola seperti ini disebut komposisi dan sering dipilih karena lebih lentur daripada pewarisan. Harga yang dibayar adalah rangkaian metode penerus yang panjang, padahal isinya tidak membawa logika baru. Contoh berikut menuliskannya secara manual agar perbandingannya terlihat.

groovy Contoh: penerusan metode manual
// Tanpa @Delegate: setiap metode yang diteruskan ditulis satu per satu
class DaftarTugas extends ArrayList<String> {
    String pemilik

    int jumlahSelesai() {
        count { it.startsWith('[selesai] ') }
    }
}

class KantorManual {
    DaftarTugas daftar = new DaftarTugas()

    boolean add(String tugas)  { daftar.add(tugas) }
    int size()                 { daftar.size() }
    boolean contains(Object o) { daftar.contains(o) }
    int jumlahSelesai()        { daftar.jumlahSelesai() }
    // metode ArrayList lainnya masih harus diteruskan satu per satu
}

def kantor = new KantorManual()
kantor.add('cetak undangan rapat')
kantor.add('[selesai] kirim undangan direksi')
kantor.add('siapkan notulen')

println "size          : ${kantor.size()}"
println "jumlahSelesai : ${kantor.jumlahSelesai()}"
println "contains      : ${kantor.contains('siapkan notulen')}"
text Keluaran
size          : 3
jumlahSelesai : 1
contains      : true

Empat metode diteruskan pada contoh di atas, sedangkan kelas ArrayList saja memiliki puluhan metode publik yang juga layak diteruskan. @Delegate mengambil alih bagian itu pada saat kompilasi. Anotasi ini membaca metode publik milik tipe yang didelegasikan, termasuk metode warisannya, lalu menghasilkan metode penerus yang mengarahkan setiap panggilan ke field terkait. Kelas Kantor pada contoh berikut tidak mendeklarasikan satu pun metode penerus.

groovy kantor-delegate.groovy
import groovy.lang.Delegate

// kelas terdelegasi: metode sendiri ditambah warisan ArrayList
class DaftarTugas extends ArrayList<String> {
    String pemilik

    int jumlahSelesai() {
        count { it.startsWith('[selesai] ') }
    }
}

class Kantor {
    @Delegate DaftarTugas daftar = new DaftarTugas()
}

def kantor = new Kantor()
kantor.add('cetak undangan rapat')
kantor.add('[selesai] kirim undangan direksi')
kantor << 'siapkan notulen'
kantor.pemilik = 'ruang-101'

println "size            : ${kantor.size()}"
println "jumlahSelesai   : ${kantor.jumlahSelesai()}"
println "contains        : ${kantor.contains('siapkan notulen')}"
println "instanceof List : ${kantor instanceof List}"
println "pemilik         : ${kantor.pemilik}"
text Keluaran
size            : 3
jumlahSelesai   : 1
contains        : true
instanceof List : true
pemilik         : ruang-101

Keluaran memperlihatkan bahwa add, size, dan contains dapat dipanggil langsung pada objek Kantor, karena ketiganya berasal dari warisan ArrayList milik DaftarTugas. Metode jumlahSelesai yang ditulis sendiri ikut diteruskan, begitu pula properti pemilik beserta getter dan setter-nya. Operator << juga berfungsi pada kantor karena kelasnya kini dianggap List, dan hal itu ditunjukkan pemeriksaan instanceof List yang menghasilkan true. Sumbernya adalah opsi interfaces yang bernilai true secara default, yaitu interface milik tipe terdelegasi turut diimplementasikan oleh kelas pembungkusnya.

Menyaring metode yang diteruskan#

Tidak semua metode publik layak dipaparkan ke pengguna kelas pembungkus. Opsi excludes menerima nama metode yang tidak boleh dihasilkan, dan sisanya tetap diteruskan sebagaimana biasa. Pada contoh berikut, metode jumlahSelesai milik DaftarTugas disengaja tidak diteruskan ke Kantor, sedangkan metode warisan ArrayList dibiarkan utuh.

groovy Contoh: excludes pada metode kelas sendiri
import groovy.lang.Delegate

class DaftarTugas extends ArrayList<String> {
    String pemilik

    int jumlahSelesai() {
        count { it.startsWith('[selesai] ') }
    }
}

// jumlahSelesai tidak diteruskan, sedangkan metode ArrayList tetap ada
class Kantor {
    @Delegate(excludes = 'jumlahSelesai') DaftarTugas daftar = new DaftarTugas()
}

def kantor = new Kantor()
kantor.add('cetak undangan rapat')
kantor.add('[selesai] kirim undangan direksi')

println "size : ${kantor.size()}"
try {
    kantor.jumlahSelesai()
} catch (MissingMethodException e) {
    println "Gagal : ${e.getClass().simpleName}"
    println "Metode: ${e.method}"
}
text Keluaran
size : 2
Gagal : MissingMethodException
Metode: jumlahSelesai

Panggilan kantor.jumlahSelesai() kini jatuh ke penanganan metode tak dikenal dan melempar MissingMethodException, karena metode itu memang tidak dihasilkan pada Kantor, sedangkan size tetap berfungsi. Satu keterbatasan penting menyangkut asal metode. Penyaringan bekerja mulus untuk metode milik kelas terdelegasi sendiri, dan juga untuk metode warisan yang tidak menjadi bagian interface mana pun seperti ensureCapacity. Sebaliknya, apabila metode yang disaring diwajibkan oleh interface yang ikut diimplementasikan secara otomatis, kompilasi langsung gagal. Kasus kedua ini dibedah lengkap beserta pesan errornya pada bagian Kesalahan umum.

Delegasi bertipe interface#

Field yang didelegasikan tidak harus bertipe kelas. Apabila tipenya interface, @Delegate meneruskan metode yang dideklarasikan oleh interface tersebut dan kelas pembungkus ikut mengimplementasikan interface itu selama opsi interfaces bernilai true. Bentuk ini berguna ketika kelas pembungkus seharusnya hanya memaparkan kontrak, bukan kelas implementasinya. Contoh berikut memakai interface PenyimpanArsip beserta kelas implementasi ArsipBerkas.

groovy Contoh: delegasi bertipe interface
import groovy.lang.Delegate

interface PenyimpanArsip {
    void simpan(String nama)
    int jumlah()
}

class ArsipBerkas implements PenyimpanArsip {
    private List<String> berkas = []

    void simpan(String nama) { berkas << nama }
    int jumlah() { berkas.size() }
}

class Laporan {
    @Delegate PenyimpanArsip arsip = new ArsipBerkas()
}

class LaporanRingkas {
    @Delegate(interfaces = false) PenyimpanArsip arsip = new ArsipBerkas()
}

def laporan = new Laporan()
laporan.simpan('rekap-q1.csv')
laporan.simpan('rekap-q2.csv')

println "jumlah     : ${laporan.jumlah()}"
println "laporan    : ${laporan instanceof PenyimpanArsip}"
println "ringkas    : ${new LaporanRingkas() instanceof PenyimpanArsip}"
text Keluaran
jumlah     : 2
laporan    : true
ringkas    : false

Nilai true pada baris kedua keluaran menunjukkan Laporan memenuhi kontrak PenyimpanArsip tanpa pernah menuliskan implements, sedangkan LaporanRingkas yang mematikan opsi interfaces tidak lagi dianggap PenyimpanArsip. Metode yang diteruskan mengikuti tipe yang dideklarasikan pada field, yaitu PenyimpanArsip, sehingga metode tambahan milik ArsipBerkas yang berada di luar kontrak tidak ikut diteruskan, dan panggilannya melempar MissingMethodException. Untuk kebutuhan menambahkan kemampuan langsung pada kelas pemakai, Groovy menyediakan trait sebagai alternatif yang menyisipkan implementasi ke dalam kelas itu sendiri, sedangkan @Delegate tetap menjaga objek terdelegasi sebagai objek terpisah.

Pemuatan tunda dengan @Lazy#

Sebagian data membutuhkan persiapan yang mahal, misalnya rekap statistik yang harus dibaca dari berkas, padahal tidak selalu dipakai pada setiap jalannya program. Di sinilah @Lazy berperan dengan menunda inisialisasi field sampai akses pertama melalui getter. Caranya, transformasi memindahkan ekspresi nilai awal ke dalam getter yang dihasilkannya, menamai ulang field menjadi $rekap, dan membiarkan field itu kosong saat objek dibuat. Contoh berikut memakai refleksi untuk membuktikan urutan tersebut.

groovy lazy-statistik.groovy
import groovy.lang.Lazy
import java.lang.reflect.Modifier

class RekapUjian {
    String kelas

    @Lazy List<Integer> rekap = muatRekap()

    private List<Integer> muatRekap() {
        println "Memuat rekap ${kelas}..."
        [78, 85, 90, 72]
    }
}

def ujian = new RekapUjian(kelas: '10A')

// field hasil transformasi bernama $rekap dan masih null sebelum akses pertama
def field = RekapUjian.getDeclaredField('$rekap')
field.trySetAccessible()

println "modifiers    : ${Modifier.toString(field.modifiers)}"
println "sebelum akses: ${field.get(ujian)}"
println "akses 1      : ${ujian.rekap}"
println "akses 2      : ${ujian.rekap}"
println "sesudah akses: ${field.get(ujian)}"
text Keluaran
modifiers    : private
sebelum akses: null
Memuat rekap 10A...
akses 1      : [78, 85, 90, 72]
akses 2      : [78, 85, 90, 72]
sesudah akses: [78, 85, 90, 72]

Keluaran menegaskan tiga hal. Field $rekap bersifat privat dan bernilai null sebelum akses pertama. Tanda pemuatan hanya tercetak sekali, tepat sebelum hasil akses pertama tersedia, dan akses berikutnya langsung memperoleh nilai yang tersimpan. Refleksi di akhir menunjukkan daftar yang sudah terisi, yang berarti nilai berpindah ke field hasil transformasi itu.

Akses properti dari dalam kelas sendiri juga melewati getter yang sama, sehingga inisiasi tunda tetap berjalan tanpa memandang bentuk nilai awalnya. Nilai awal @Lazy tidak terbatas pada panggilan metode. Literal, ekspresi apa pun, dan closure yang langsung dipanggil sama-sama dipindahkan ke dalam getter. Bentuk closure dipilih ketika persiapan membutuhkan beberapa pernyataan, dan isi closure tetap baru dieksekusi pada akses pertama.

groovy Contoh: nilai awal berbentuk closure
import groovy.lang.Lazy

class StatistikUjian {
    // closure langsung dipanggil, tetapi dieksekusi hanya pada akses pertama
    @Lazy Map<String, Double> rataRata = {
        println "Menghitung rata-rata..."
        [matematika: 84.5, ipa: 88.0]
    }()
}

def statistik = new StatistikUjian()
println "akses 1 : ${statistik.rataRata}"
println "akses 2 : ${statistik.rataRata}"
text Keluaran
Menghitung rata-rata...
akses 1 : [matematika:84.5, ipa:88.0]
akses 2 : [matematika:84.5, ipa:88.0]

Pada contoh di atas, pemanggilan closure baru terjadi ketika rataRata pertama kali dibaca, dan akses kedua tidak mengulang perhitungannya. Untuk akses antar-thread, deklarasi field dengan modifier volatile mengubah getter yang dihasilkan ke pola double-checked locking, yaitu pemeriksaan nilai yang diulang di dalam blok terkunci agar inisiasi tetap sekali saja. Field biasa diperiksa tanpa penguncian, sedangkan field static diinisiasi melalui pola holder class. Rincian ketiganya tertulis pada kode sumber transformasi yang dirujuk di bagian Referensi.

Menggabungkan @Delegate dan @Lazy#

Kedua anotasi dapat menumpuk pada satu field yang sama. Susunan ini cocok ketika objek yang didelegasikan sendiri mahal untuk disiapkan, misalnya arsip cadangan yang dibuka hanya saat pertama kali dibutuhkan. Pada contoh berikut, field arsip di kelas LayananArsip membawa keduanya sekaligus.

groovy arsip-lazy.groovy
import groovy.lang.Delegate
import groovy.lang.Lazy

class ArsipCadangan {
    @Delegate List<String> berkas = []

    String terbaru() {
        isEmpty() ? '(arsip kosong)' : get(size() - 1)
    }
}

class LayananArsip {
    @Lazy @Delegate ArsipCadangan arsip = siapkanArsip()

    private ArsipCadangan siapkanArsip() {
        println "Menyiapkan arsip..."
        new ArsipCadangan()
    }
}

def layanan = new LayananArsip()
println "tambah  : ${layanan.add('log-app.txt')}"
println "tambah  : ${layanan.add('log-db.txt')}"
println "jumlah  : ${layanan.size()}"
println "terbaru : ${layanan.terbaru()}"
text Keluaran
Menyiapkan arsip...
tambah  : true
tambah  : true
jumlah  : 2
terbaru : log-db.txt

Urutan keluarannya menjelaskan cara kerja gabungan itu. Panggilan add pertama menyentuh getter hasil @Lazy, menyiapkan objek ArsipCadangan, kemudian metode penerus hasil @Delegate meneruskan panggilan ke objek yang baru siap itu. Panggilan berikutnya tidak lagi mencetak tanda penyiapan karena field sudah terisi. Perilaku ini sekaligus menunjukkan bahwa metode penerus mengambil objeknya melalui getter, bukan membaca field secara langsung, sehingga penundaan inisiasi tetap bekerja dengan benar.

Kesalahan umum#

Kesalahan pertama adalah mengimpor kedua anotasi dari paket yang salah. Kebiasaan menulis import groovy.transform.Immutable sering membawa anggapan bahwa @Delegate dan @Lazy berada di paket yang sama, padahal pada Groovy 5 keduanya beralamat di groovy.lang. Kegagalan ini terjadi saat kompilasi dengan pesan unable to resolve class, bukan saat program berjalan, sehingga try/catch tidak berperan apa pun. Blok berikut mengompilasi dua sumber bermasalah dalam memori melalui GroovyClassLoader agar pesan errornya dapat ditampilkan.

groovy Contoh: kegagalan kompilasi yang lazim
import org.codehaus.groovy.control.CompilationFailedException

// sumber dikompilasi dalam memori untuk menangkap error kompilasi
def kompilasi = { String sumber ->
    try {
        new GroovyClassLoader().parseClass(sumber)
        return 'lolos'
    } catch (CompilationFailedException e) {
        return e.message.readLines()
            .collect { it.replaceFirst(/^Script\S+: \d+: /, '').trim() }
            .findAll { it.startsWith('unable') || it.startsWith("Can't") }
            .unique()
            .join(' | ')
    }
}

def imporSalah = '''
import groovy.transform.Delegate

class SalahPaket { @Delegate List<String> tugas = [] }
'''
def excludesSalah = '''
import groovy.lang.Delegate

class ArsipTugas extends ArrayList<String> { int jumlahSelesai() { 0 } }
class Pembungkus { @Delegate(excludes = 'clear') ArsipTugas arsip = new ArsipTugas() }
'''

println "impor salah    : ${kompilasi(imporSalah)}"
println "excludes salah : ${kompilasi(excludesSalah)}"
text Keluaran
impor salah    : unable to resolve class groovy.transform.Delegate
excludes salah : Can't have an abstract method in a non-abstract class. The class 'Pembungkus' must be declared abstract or the method 'void clear()' must be implemented.

Baris pertama keluaran adalah pesan unable to resolve class groovy.transform.Delegate, dan perbaikannya hanya mengubah impor menjadi import groovy.lang.Delegate beserta import groovy.lang.Lazy. Kasus kedua memperlihatkan penyaringan yang memang tidak dapat dilakukan, yaitu excludes pada metode clear milik warisan ArrayList. Karena opsi interfaces bernilai true, kelas pembungkus tetap diwajibkan memenuhi kontrak List, sedangkan metode penerus untuk clear tidak dihasilkan. Kompilator pun melaporkan adanya metode yang wajib diimplementasikan. Perbaikannya ada dua arah, yaitu menyaring hanya metode milik kelas terdelegasi sendiri, atau mematikan opsi interfaces apabila kontrak List memang tidak diinginkan.

Ringkasan#

  • @Delegate menghasilkan metode penerus untuk metode publik milik tipe terdelegasi, termasuk metode warisannya, dan mengimplementasikan interface tipe itu selama opsi interfaces tetap true.
  • Opsi excludes menyaring metode milik kelas terdelegasi sendiri, sedangkan metode yang diwajibkan interface tidak boleh disaring karena kompilasinya gagal.
  • @Lazy menunda inisialisasi field sampai akses pertama melalui getter yang dihasilkannya, dan nilai awal berbentuk literal, panggilan metode, maupun closure sama-sama ditunda.
  • Kedua anotasi beralamat di paket groovy.lang dan dapat menumpuk pada satu field, sehingga objek terdelegasi disiapkan hanya ketika metode penerusnya pertama kali dipanggil.

Referensi#