@Immutable

Objek nilai immutable dengan @Immutable, termasuk dukungan copyWith.

Daftar isi

Anotasi @Immutable mengubah kelas biasa menjadi kelas nilai (value object) yang tidak dapat diubah (immutable) setelah dibuat. Topik ini membedah hasil transformasinya dari sisi yang paling sering dipakai, yaitu field final tanpa setter, konstruktor posisional dan konstruktor map, perlindungan koleksi, opsi copyWith, serta pemakaian objek sebagai kunci map. Setiap perilaku disertai skrip yang dijalankan pada Groovy 5.1.3 beserta keluaran aslinya. Dengan begitu, batas antara perilaku yang dijamin anotasi dan asumsi yang perlu diuji terlebih dahulu terlihat jelas.

Kelas nilai hasil @Immutable#

@Immutable bekerja pada saat kompilasi dengan menulis ulang struktur kelas menjadi kelas nilai, yaitu kelas yang seluruh datanya ditetapkan sekali ketika objek dibuat. Properti yang ditulis biasa berubah menjadi field final privat tanpa setter, sehingga satu-satunya jalur pengisian data adalah konstruktor. Anotasi ini menghasilkan konstruktor posisional sesuai urutan deklarasi properti, konstruktor map berbasis nama properti, serta metode equals, hashCode, dan toString. Metode tambahan tetap boleh ditulis sebagaimana kelas biasa, seperti metode konversi satuan pada contoh berikut.

Perbedaannya dengan @Canonical terletak pada penegakan sifat tidak berubah. @Canonical menghasilkan konstruktor, equals, hashCode, dan toString yang sama, tetapi propertinya masih memiliki setter dan dapat diubah kapan pun. @Immutable menambahkan lapisan penguncian di atas kemampuan itu, sehingga perubahan sesudah objek dibuat ditolak secara eksplisit.

groovy pengukuran.groovy
import groovy.transform.Immutable

// Kelas nilai: data ditetapkan sekali saat objek dibuat
@Immutable
class PengukuranSuhu {
    String sensor
    double celcius

    // metode tetap boleh ditulis, misalnya konversi satuan
    double keFahrenheit() {
        celcius * 9 / 5 + 32
    }
}

def a = new PengukuranSuhu('suhu-lab', 25.0)
def b = new PengukuranSuhu(sensor: 'suhu-lab', celcius: 25.0)

println a
println "fahrenheit : ${a.keFahrenheit()}"
println "equals     : ${a == b}"
println "hashCode   : ${a.hashCode() == b.hashCode()}"

try {
    a.celcius = 30.0
} catch (ReadOnlyPropertyException e) {
    println "Gagal: ${e.getClass().simpleName}"
}
text Keluaran
PengukuranSuhu(suhu-lab, 25.0)
fahrenheit : 77.0
equals     : true
hashCode   : true
Gagal: ReadOnlyPropertyException

Keluaran di atas menunjukkan dua jalur pembuatan objek yang setara, yaitu konstruktor posisional dengan urutan sesuai deklarasi dan konstruktor map dengan nama properti. Metode keFahrenheit tetap dapat dipanggil karena @Immutable tidak melarang metode, melainkan hanya mengunci datanya. Dua objek yang dibuat terpisah dengan nilai sama dianggap sama oleh equals, dan hashCode keduanya juga sama. Penugasan a.celcius pada akhir skrip gagal dengan ReadOnlyPropertyException karena tidak ada setter yang dihasilkan.

Struktur kelas yang dihasilkan#

Bentuk kelas hasil transformasi dapat dibuktikan dengan refleksi (reflection), yaitu pembacaan metadata kelas saat program berjalan. Pemeriksaan ini bekerja pada kelas yang sudah terkompilasi, sehingga yang terbaca adalah bentuk akhirnya, bukan niat pada kode sumbernya. Hasilnya menegaskan tiga hal sekaligus, yaitu kelas yang dianotasi menjadi final, field-nya menjadi privat final, dan tidak ada satu pun setter yang dihasilkan.

groovy Contoh: refleksi struktur hasil transformasi
import groovy.transform.Immutable
import java.lang.reflect.Modifier

@Immutable
class Titik {
    int x
    int y
}

println "kelas final : ${Modifier.isFinal(Titik.modifiers)}"
def f = Titik.getDeclaredField('x')
println "field       : ${Modifier.toString(f.modifiers)}"
println "ada setter  : ${Titik.methods.any { it.name == 'setX' }}"
text Keluaran
kelas final : true
field       : private final
ada setter  : false

Struktur ini membuat kelas hasil @Immutable berperilaku sama seperti kelas final yang ditulis manual, dan kelas itu tidak dapat diwarisi. Upaya mendeklarasikan kelas turunan dari kelas semacam itu gagal dikompilasi, bukan gagal saat program berjalan, karena sifat final sudah tertulis pada hasil kompilasinya. Pemeriksaan refleksi juga dapat dipakai untuk mengonfirmasi bagian lain dari kontrak anotasi, misalnya daftar konstruktor yang tersedia atau nama field yang benar-benar ada di dalam kelas.

Di balik anotasi tersebut terdapat @ImmutableBase, yaitu transformasi dasar yang menghasilkan field final privat beserta kedua konstruktor, sedangkan @Immutable melengkapinya dengan pemeriksaan tipe, penyalinan defensif, serta equals, hashCode, dan toString.

Perlindungan koleksi#

Properti bertipe koleksi tidak disimpan apa adanya. Konstruktor menyalin koleksi yang diterima, lalu membungkus salinannya dengan versi yang tidak dapat dimodifikasi (unmodifiable), sehingga isi yang tersimpan tidak dapat ditambah, dihapus, atau diganti. Mekanisme ini penting karena referensi koleksi asli tetap berada di tangan pembuat objek dan dapat dimutasi kapan pun.

groovy koleksi-defensif.groovy
import groovy.transform.Immutable

@Immutable
class RekamanSuhu {
    String sensor
    List<Double> bacaan
}

def asli = [21.5, 22.0]
def r = new RekamanSuhu('suhu-ruang', asli)

println "kelas list   : ${r.bacaan.getClass().name}"
asli << 99.9
println "asli berubah : ${asli}"
println "bacaan objek : ${r.bacaan}"

try {
    r.bacaan << 30.0
} catch (UnsupportedOperationException e) {
    println "Gagal: ${e.getClass().simpleName}"
}
text Keluaran
kelas list   : java.util.Collections$UnmodifiableRandomAccessList
asli berubah : [21.5, 22.0, 99.9]
bacaan objek : [21.5, 22.0]
Gagal: UnsupportedOperationException

Baris pertama keluaran memperlihatkan kelas list yang benar-benar tersimpan di dalam objek, yaitu java.util.Collections$UnmodifiableRandomAccessList, bukan list yang dikirim. Penambahan 99.9 pada list asli berjalan tanpa error karena list asli memang masih list biasa, sedangkan bacaan yang tersimpan tidak pernah berubah. Perilaku yang sering mengejutkan adalah mutasi terhadap referensi asli yang tidak gagal, melainkan diam-diam tidak berpengaruh pada objek. Sebaliknya, usaha menambah elemen melalui objek langsung gagal dengan UnsupportedOperationException.

Salinan objek dengan copyWith#

Objek yang sudah dibuat tidak dapat diubah, sedangkan kebutuhan mendapatkan versi dengan satu nilai berbeda muncul terus, misalnya membuat variasi ukuran dari satu konversi satuan yang sama. Untuk kebutuhan itu @Immutable menyediakan opsi copyWith. Opsi ini tidak aktif secara default, sehingga metode copyWith hanya dihasilkan apabila anotasinya ditulis @Immutable(copyWith = true). Metode hasilnya menerima nama properti beserta nilai penggantinya, lalu mengembalikan objek baru, sedangkan objek lama tetap utuh.

groovy copywith.groovy
import groovy.transform.Immutable

// copyWith = true: metode copyWith ikut dihasilkan
@Immutable(copyWith = true)
class Panjang {
    double meter

    double keKaki() {
        meter * 3.28084
    }
}

def asli = new Panjang(2.0)
def salinan = asli.copyWith(meter: 10.0)

println "asli        : $asli"
println "salinan     : $salinan"
println "objek sama  : ${asli.is(salinan)}"
println "kaki salinan: ${salinan.keKaki()}"
text Keluaran
asli        : Panjang(2.0)
salinan     : Panjang(10.0)
objek sama  : false
kaki salinan: 32.8084

Keluaran menunjukkan salinan bernilai 10.0 meter, objek asli tetap 2.0 meter, dan keduanya bukan objek yang sama. Properti yang tidak disebut dalam pemanggilannya tetap disalin dari nilai aslinya, sehingga satu pemanggilan cukup untuk mengganti sebagian field saja. copyWith juga dapat dipanggil berulang kali untuk menghasilkan variasi lain tanpa mengganggu objek yang sudah ada.

Tanpa opsi tersebut, pemanggilan copyWith jatuh ke penanganan metode tak dikenal dan melempar MissingMethodException, karena tidak ada metode bernama copyWith yang dihasilkan pada kelas. Kelas Titik pada contoh dianotasi @Immutable polos, sehingga variasi nilainya hanya dapat dibuat dengan memanggil konstruktor lagi, bukan dengan copyWith. Contoh berikut menangkap kegagalannya dengan try/catch agar skrip tetap berjalan sampai pesannya terlihat.

groovy Contoh: copyWith pada kelas tanpa opsi
import groovy.transform.Immutable

@Immutable
class Titik {
    int x
    int y
}

def t = new Titik(1, 2)
try {
    t.copyWith(x: 9)
} catch (MissingMethodException e) {
    println "Gagal: ${e.getClass().simpleName}"
}
text Keluaran
Gagal: MissingMethodException

hashCode stabil dan kunci map#

hashCode yang dihasilkan @Immutable dihitung dari nilai field yang tidak pernah berubah, sehingga hasilnya stabil dari satu pemanggilan ke pemanggilan berikutnya dan identik untuk objek bernilai sama. Sifat ini menjadi syarat bagi objek yang dipakai sebagai kunci map, karena pencarian kunci bergantung pada konsistensi antara hashCode dan equals. Kelas rekapitulasi statistik nilai ujian berikut memperlihatkan pola tersebut.

groovy Contoh: objek immutable sebagai kunci map
import groovy.transform.Immutable

@Immutable
class NilaiUjian {
    String pelajaran
    int skor
}

def a = new NilaiUjian('matematika', 90)
def b = new NilaiUjian('matematika', 90)

def rekap = [:]
rekap[a] = 'tuntas'

println "hashCode a        : ${a.hashCode()}"
println "hashCode a (ulang): ${a.hashCode()}"
println "hashCode b        : ${b.hashCode()}"
println "rekap[b]          : ${rekap[b]}"
text Keluaran
hashCode a        : -7856819
hashCode a (ulang): -7856819
hashCode b        : -7856819
rekap[b]          : tuntas

Nilai hashCode yang sama tercetak tiga kali, yaitu dua pemanggilan pada objek a dan satu pemanggilan pada objek b yang bernilai sama, dan pencarian rekap[b] menemukan entri yang disimpan melalui a. Apabila hashCode dapat berubah setelah objek menjadi kunci, entri tersebut akan tergantung pada slot yang tidak pernah ditemukan lagi, dan @Immutable menutup kemungkinan itu sejak awal. Kondisi ini berbeda dari objek yang masih punya setter, karena hash-nya ikut berubah begitu salah satu propertinya diatur ulang.

Stabilitas tersebut berjalan berbarengan dengan keamanan pemakaian antar-thread. Field final yang diisi melalui konstruktor membuat objek yang sudah dibagikan ke thread lain selalu terlihat dalam keadaan utuh tanpa sinkronisasi tambahan. Pada struktur data bersama seperti map berkunci objek, sifat ini menyingkirkan sekelompok error klasik akibat perubahan keadaan kunci setelah dimasukkan.

Kesalahan umum#

Kesalahan pertama adalah menaruh tipe kustom sebagai properti kelas immutable tanpa perlindungan apa pun. Deklarasinya lolos kompilasi, tetapi pembuatan objek gagal dengan RuntimeException berpesan Unsupported type, karena transformasi tidak tahu cara menyalin tipe tersebut dengan aman. Kegagalan baru terasa pada saat objek dibuat karena pemeriksaan tipe memang berjalan pada tahap itu, bukan pada tahap penguraian kode, dan pesan errornya menyebut nama tipe bermasalah beserta field penampungnya. Perbaikannya adalah menganotasi kelas properti dengan @KnownImmutable, yaitu penanda bahwa kelas itu tidak memiliki keadaan yang dapat diubah sehingga boleh disimpan di dalam kelas immutable.

groovy Contoh: tipe kustom tanpa @KnownImmutable
import groovy.transform.Immutable

class Alamat {
    String kota
}

@Immutable
class Pelanggan {
    String nama
    Alamat alamat
}

try {
    new Pelanggan('Budi', new Alamat(kota: 'Bandung'))
} catch (RuntimeException e) {
    println "Gagal: ${e.getClass().simpleName}"
    println "Pesan: ${e.message.split('\n')[0]}"
}
text Keluaran
Gagal: RuntimeException
Pesan: Unsupported type (Alamat) found for field 'alamat' while constructing immutable class Pelanggan.
groovy Contoh: tipe kustom dengan @KnownImmutable
import groovy.transform.Immutable
import groovy.transform.KnownImmutable

@KnownImmutable
class Alamat {
    String kota
}

@Immutable
class Pelanggan {
    String nama
    Alamat alamat
}

def p = new Pelanggan('Budi', new Alamat(kota: 'Bandung'))
println "nama : ${p.nama}"
println "kota : ${p.alamat.kota}"
text Keluaran
nama : Budi
kota : Bandung

Kesalahan kedua adalah memanggil copyWith tanpa menampung hasilnya. Metode ini mengembalikan objek baru dan tidak menyentuh objek asli, sehingga nilai kembalian yang dibuang membuat perubahan yang diharapkan hilang tanpa pesan error apa pun. Kesalahan ini sunyi dan sering baru ketahuan ketika nilai yang diharapkan tidak muncul di tempat lain. Perbaikannya adalah menampung hasil pemanggilan ke variabel baru sebagaimana contoh pada bagian copyWith.

Ringkasan#

  • @Immutable menghasilkan field final privat tanpa setter, kelas final, konstruktor posisional dan map, serta metode equals, hashCode, dan toString.
  • Koleksi disalin lalu dibungkus versi unmodifiable dan field Date dikloning, sehingga mutasi terhadap referensi asli tidak pernah menyentuh objek.
  • Metode copyWith hanya dihasilkan dengan @Immutable(copyWith = true), mengembalikan objek baru dengan sebagian field diganti, dan hasilnya harus ditampung.
  • hashCode yang stabil menjadikan objek aman sebagai kunci map dan aman dibagikan antar-thread.

Referensi#