Keluarga @Canonical

Satu anotasi @Canonical menghasilkan constructor, equals, hashCode, dan toString.

Daftar isi

Anotasi pada paket groovy.transform menghapus boilerplate kelas data dengan menambahkan anggota kelas pada saat kompilasi. Topik ini membahas keluarga @Canonical, yaitu anotasi gabungan beserta tiga anotasi satuan penyusunnya. Cakupannya meliputi mekanisme transformasi AST (abstract syntax tree) di tingkat bytecode, konstruktor posisional dan named argument, kesetaraan objek sebagai kunci koleksi, serta opsi excludes untuk kebutuhan parsial. Contohnya memakai katalog buku perpustakaan, statistik nilai ujian, dan catatan sensor suhu. Setiap blok kode benar-benar dijalankan, dan keluaran yang tercantum adalah hasil aslinya.

Satu anotasi untuk empat anggota#

@Canonical adalah anotasi gabungan yang menyusun tiga transformasi sekaligus, yaitu @ToString untuk metode toString, @EqualsAndHashCode untuk pasangan equals dan hashCode, serta @TupleConstructor untuk konstruktor posisional. Ketiganya diproses oleh kompilator, sehingga kelas yang semula hanya berisi daftar properti menjadi kelas lengkap di bytecode. Bytecode sendiri adalah kode biner hasil kompilasi yang dijalankan JVM. Konstruktor posisional mengikuti urutan deklarasi properti, sedangkan konstruktor named argument berasal dari konvensi POGO (plain old Groovy object) dan menerima pasangan nama properti beserta nilainya.

Berkas berikut menunjukkan kedua bentuk konstruktor pada kelas Buku beserta toString hasil anotasi.

groovy buku-canonical.groovy
import groovy.transform.Canonical

// @Canonical menggabungkan tiga anotasi sekaligus
@Canonical
class Buku {
    String judul
    String penulis
    int tahun
}

// Konstruktor posisional mengikuti urutan deklarasi properti
def b1 = new Buku('Dasar Statistika', 'Rahmi', 2019)

// Konstruktor named-argument berasal dari konvensi POGO
def b2 = new Buku(judul: 'Pengantar Logika', penulis: 'Bima', tahun: 2021)

println b1.judul
println "${b1.penulis} (${b1.tahun})"
println b2.tahun
println b1.toString()
text Keluaran
Dasar Statistika
Rahmi (2019)
2021
Buku(Dasar Statistika, Rahmi, 2019)

Konstruktor posisional mengisi judul, penulis, dan tahun sesuai urutan deklarasi. Pemanggilan dengan named argument tidak bergantung pada urutan, dan properti yang tidak disebut mempertahankan nilai default-nya. Format toString berupa nama kelas diikuti nilai properti di dalam tanda kurung, sehingga baris terakhir keluaran menampilkan isi ketiga properti meskipun tidak ada satu baris kode toString pun yang ditulis manual.

Mekanisme transformasi AST#

AST adalah representasi pohon dari kode sumber yang dibangun kompilator sebelum bytecode dihasilkan. Anotasi transformasi AST diproses pada fase kompilasi, bukan saat program berjalan, dan setiap anotasi menambahkan atau mengubah simpul milik kelas yang ditempelinya. Konsekuensinya, kode yang ditulis tidak sama dengan bytecode yang dihasilkan. Kelas yang tampak hanya berisi dua properti dapat memuat konstruktor dan beberapa metode tambahan pada hasil kompilasinya. Perbedaan itu dapat dibuktikan dengan refleksi (reflection), yaitu pemeriksaan struktur kelas dari dalam program, tanpa membongkar bytecode.

groovy anggota-bytecode.groovy
import groovy.transform.Canonical

class Polos {
    String judul
    int tahun
}

@Canonical
class Beranotasi {
    String judul
    int tahun
}

println "Metode Polos      : ${Polos.declaredMethods*.name.toSorted().join(', ')}"
println "Metode Beranotasi : ${Beranotasi.declaredMethods*.name.toSorted().join(', ')}"
println "Arity konstruktor Polos      : ${Polos.declaredConstructors.collect { it.parameterTypes.length }.toSorted()}"
println "Arity konstruktor Beranotasi : ${Beranotasi.declaredConstructors.collect { it.parameterTypes.length }.toSorted()}"
text Keluaran
Metode Polos      : $getStaticMetaClass, getJudul, getMetaClass, getTahun, setJudul, setMetaClass, setTahun
Metode Beranotasi : $getStaticMetaClass, canEqual, equals, getJudul, getMetaClass, getTahun, hashCode, setJudul, setMetaClass, setTahun, super$1$equals, super$1$hashCode, super$1$toString, toString
Arity konstruktor Polos      : [0]
Arity konstruktor Beranotasi : [0, 1, 2]

Kelas Polos hanya memuat pasangan getter dan setter dari konvensi properti POGO beserta perangkat metakelas seperti getMetaClass. Kelas Beranotasi menambahkan equals, hashCode, toString, metode pendamping canEqual, dan tiga metode jembatan super$1$ yang memanggil versi superclass. Arity, yaitu banyaknya argumen yang diterima sebuah konstruktor, juga berubah dari [0] menjadi [0, 1, 2]. Artinya, @TupleConstructor membuat konstruktor tanpa argumen, satu argumen, dan dua argumen sekaligus. Seluruh anggota ini tidak tampak pada kode sumber karena disisipkan pada fase kompilasi, setelah kode diuraikan menjadi AST dan sebelum bytecode final dibentuk.

Kesetaraan dan penggunaan sebagai kunci#

@EqualsAndHashCode membandingkan nilai properti satu per satu, sehingga dua objek dengan isi sama dianggap sama oleh operator ==. Operator tersebut di Groovy dipetakan ke pemanggilan equals, sedangkan metode is tetap memeriksa identitas memori. Pasangan equals dan hashCode yang konsisten membuat objek @Canonical aman dipakai sebagai anggota HashSet dan sebagai kunci (key) HashMap, karena pencarian koleksi berbasis hash mengandalkan kedua metode itu.

groovy katalog-set.groovy
import groovy.transform.Canonical

@Canonical
class Buku {
    String judul
    String penulis
    int tahun
}

def b1 = new Buku('Dasar Statistika', 'Rahmi', 2019)
def b2 = new Buku('Dasar Statistika', 'Rahmi', 2019)

println "Sama isi : ${b1 == b2}"
println "Identik  : ${b1.is(b2)}"

// dua objek dengan isi sama hanya tercatat satu kali dalam HashSet
def rak = [] as Set
rak << b1
rak << b2
rak << new Buku('Pengantar Logika', 'Bima', 2021)
println "Buku unik: ${rak.size()}"

// objek baru dengan isi sama berfungsi sebagai kunci HashMap
def lokasi = [(new Buku('Pengantar Logika', 'Bima', 2021)): 'R-05']
println "Lokasi   : ${lokasi[new Buku('Pengantar Logika', 'Bima', 2021)]}"
text Keluaran
Sama isi : true
Identik  : false
Buku unik: 2
Lokasi   : R-05

Objek b1 dan b2 dibuat terpisah tetapi berisi data sama, sehingga perbandingan isinya bernilai true sementara identitasnya tetap false. Kedua objek hanya tercatat satu kali dalam HashSet, dan pencarian memakai objek ketiga yang baru dibuat tetap menemukan nilai R-05. Pola ini memungkinkan data katalog dicari berdasarkan isi tanpa perlu menyimpan dan mengoper objek aslinya ke seluruh bagian program.

Anotasi satuan untuk kebutuhan parsial#

Kebutuhan kelas tidak selalu mencakup seluruh anggota hasil @Canonical. Kelas yang hanya perlu tampilan teks cukup diberi anotasi @ToString, sedangkan kelas yang hanya membutuhkan konstruktor memakai @TupleConstructor saja. Kedua anotasi satuan ini dapat digabung bebas, misalnya @TupleConstructor bersama @EqualsAndHashCode tanpa @ToString. Opsi includeNames menampilkan nama properti di dalam keluaran toString, dan opsi includeFields ikut mencantumkan field privat yang berada di luar daftar properti biasa.

groovy Contoh: @ToString dengan includeNames dan includeFields
import groovy.transform.ToString

@ToString(includeNames = true, includeFields = true)
class StatistikNilai {
    int jumlah = 0
    private int total = 0

    void catat(int nilai) {
        jumlah += 1
        total += nilai
    }

    double rataRata() {
        jumlah == 0 ? 0d : total / (double) jumlah
    }
}

def s = new StatistikNilai()
[80, 70, 90].each { s.catat(it) }
println "Rata-rata: ${s.rataRata()}"
println s.toString()
text Keluaran
Rata-rata: 80.0
StatistikNilai(jumlah:3, total:240)

Metode rataRata menghitung nilai dari field privat total dan properti jumlah. Keluaran toString memuat keduanya, termasuk total:240 yang tidak akan tampil tanpa includeFields, dan setiap nilai didahului nama propertinya karena includeNames aktif. Kelas semacam ini menggambarkan statistik nilai ujian yang hanya perlu dicetak, tanpa konstruktor posisional maupun perbandingan isi.

Contoh kedua memakai catatan sensor suhu yang memerlukan konstruktor dan kesetaraan, tetapi tidak memerlukan pencetakan.

groovy Contoh: @TupleConstructor dan @EqualsAndHashCode terpisah
import groovy.transform.EqualsAndHashCode
import groovy.transform.TupleConstructor

@EqualsAndHashCode
@TupleConstructor
class Sensor {
    String id
    double celsius
}

def s1 = new Sensor('S1', 25.0)
def s2 = new Sensor('S1', 25.0)
def s3 = new Sensor('S2', 25.0)

println "s1 == s2 : ${s1 == s2}"
println "s1 == s3 : ${s1 == s3}"
println "Hash sama: ${s1.hashCode() == s2.hashCode()}"
text Keluaran
s1 == s2 : true
s1 == s3 : false
Hash sama: true

Kelas Sensor memperoleh konstruktor posisional dari @TupleConstructor dan perbandingan isi dari @EqualsAndHashCode tanpa anotasi gabungan. Pencetakan objek sengaja dihindari pada contoh ini karena kelas tidak memiliki toString khusus, sehingga keluarannya memuat hashCode bawaan yang tidak deterministik. Bila kelak diperlukan, anotasi @ToString tinggal ditambahkan tanpa mengubah kedua anotasi yang sudah ada.

Opsi excludes pada @Canonical#

Opsi excludes pada @Canonical menerima nama properti yang dikeluarkan dari seluruh hasil transformasi. Properti yang dikecualikan tidak muncul pada toString, tidak diikutkan dalam equals dan hashCode, dan arity konstruktor posisional ikut memangkasnya. Opsi kebalikannya, includes, membalik arah penyaringan dengan mendaftar properti yang diikutkan saja. Pemilihan excludes berguna untuk properti teknis seperti nomor urut atau stempel waktu yang tidak boleh memengaruhi kesetaraan.

groovy eksklusi-ujian.groovy
import groovy.transform.Canonical

@Canonical(excludes = 'id')
class CatatanUjian {
    int id
    String mapel
    double nilai
}

// Konstruktor posisional hanya menerima dua argumen setelah excludes
def c1 = new CatatanUjian('Matematika', 88.0)
def c2 = new CatatanUjian('Matematika', 88.0)

println c1.toString()
println "Sama: ${c1 == c2}"

try {
    new CatatanUjian(1, 'Matematika', 88.0)
} catch (GroovyRuntimeException e) {
    println "Gagal: ${e.getClass().simpleName}"
}
text Keluaran
CatatanUjian(Matematika, 88.0)
Sama: true
Gagal: GroovyRuntimeException

Konstruktor posisional hanya menerima mapel dan nilai karena id dikeluarkan, sehingga dua objek dengan id berbeda tetap dianggap sama. Properti id tetap ada dan tetap dapat diatur melalui konstruktor named argument sebagaimana properti lain. Pemanggilan dengan tiga argumen tidak menemukan konstruktor yang cocok dan melempar GroovyRuntimeException berpesan Could not find matching constructor. Percobaan tersebut ditangkap try/catch, sedangkan kode tanpa penanganan akan berhenti dengan error itu.

Kesalahan umum#

Kesalahan pertama adalah menuliskan kunci yang tidak cocok dengan nama properti mana pun pada konstruktor named argument. Konstruktor semacam ini melayani penugasan properti, sehingga kunci tersebut diperlakukan sebagai properti yang tidak ada dan memicu MissingPropertyException. Perbaikannya adalah menyamakan ejaan kunci dengan nama properti yang dideklarasikan.

groovy Contoh: kunci map tidak cocok dengan nama properti
import groovy.transform.Canonical

@Canonical
class Sensor {
    String id
    double celsius
}

try {
    new Sensor(nilai: 25.0)
} catch (MissingPropertyException e) {
    println "Gagal: ${e.getClass().simpleName} pada properti ${e.property}"
}
text Keluaran
Gagal: MissingPropertyException pada properti nilai

Pesan error lengkapnya menyebut kunci yang tidak dikenal beserta kelasnya, yaitu No such property: nilai for class: Sensor. Satu huruf pun berpengaruh, misalnya kunci celcius untuk properti bernama celsius memicu error yang sama. Periksa dulu ejaan kunci terhadap daftar properti kelas sebelum mencari penyebabnya di tempat lain.

Kesalahan kedua adalah mencetak kelas tanpa anotasi dan menganggap keluarannya stabil. Kelas polos mewarisi toString dari java.lang.Object yang memuat nilai hashCode berbasis identitas, dan nilai tersebut berbeda-beda antar eksekusi sehingga tidak layak dibandingkan. Bila hanya nama kelas yang dibutuhkan, ambil prefiks sebelum tanda @.

groovy Contoh: prefiks toString kelas tanpa anotasi
class Polos {
    String nama
}

def p = new Polos(nama: 'sensor-a')
println "Prefiks: ${p.toString().split('@')[0]}"
text Keluaran
Prefiks: Polos

Pasang @Canonical atau @ToString apabila keluaran pencetakan memang perlu deterministik dan informatif, sebab kedua anotasi menghasilkan toString yang isinya berasal dari properti kelas.

Ringkasan#

  • @Canonical menggabungkan @ToString, @EqualsAndHashCode, dan @TupleConstructor, dan ketiganya diproses saat kompilasi sehingga bytecode memuat anggota yang tidak tampak pada kode sumber.
  • Konstruktor posisional mengikuti urutan properti dan arity-nya dipangkas oleh excludes, sedangkan konstruktor named argument berasal dari konvensi POGO dan tetap menerima seluruh properti.
  • equals dan hashCode yang konsisten membuat objek layak menjadi anggota HashSet dan kunci HashMap berdasarkan isi, bukan identitas memori.
  • Kebutuhan parsial dilayani anotasi satuan @ToString, @TupleConstructor, dan @EqualsAndHashCode yang dapat digabung bebas, dengan opsi includeNames, includeFields, dan excludes untuk penyetelan.

Referensi#