Variance adalah aturan yang menjelaskan bagaimana hubungan subtype antara tipe dasar memengaruhi hubungan subtype antara tipe generic yang memakainya. Misalnya, String adalah subtype Any, tetapi apakah List<String> otomatis menjadi subtype List<Any>? Jawabannya ternyata tidak — Kotlin memperlakukan tipe generic secara invariant secara default. Pada chapter ini kita akan memahami alasan di balik pilihan desain ini, melihat apa yang terjadi jika invariant dilanggar, dan menyiapkan dasar untuk membahas covariance dan contravariance pada chapter-chapter berikutnya.
Masalah Variance #
Sederhananya, jika A adalah subtype B, apakah Container<A> otomatis subtype Container<B>? Jawabannya bergantung pada apakah Container memproduksi nilai T, mengkonsumsi nilai T, atau keduanya. Mari kita lihat kasus klasik yang menunjukkan masalahnya:
val daftarString: MutableList<String> = mutableListOf("Halo")
val daftarAny: MutableList<Any> = daftarString // jika ini diizinkan...
daftarAny.add(42) // ...kita menyimpan Int ke daftar String!
val ambil: String = daftarString[1] // runtime crash: ClassCastExceptionJika kompilator mengizinkan baris kedua, kita dapat menyimpan Int ke dalam MutableList<String>. Saat kita mengambil elemen tersebut sebagai String, runtime akan melempar ClassCastException. Untuk mencegah hal ini, Kotlin memutuskan bahwa MutableList<String> bukan subtype MutableList<Any>.
Invariant secara Default #
Kotlin memperlakukan setiap class generic sebagai invariant kecuali dinyatakan lain. Artinya, meskipun A adalah subtype B, Container<A> dan Container<B> tidak memiliki hubungan subtype. Aturan ini berlaku terutama ketika T muncul baik di posisi parameter (input) maupun posisi kembalian (output).
class Wadah<T>(var isi: T) // T muncul sebagai var (in & out)Pada class Wadah<T>, properti var isi berarti kita dapat menulis (isi = ...) sekaligus membaca (isi). Karena T muncul di kedua posisi, Kotlin memperlakukan Wadah sebagai invariant. Wadah<String> tidak boleh diperlakukan sebagai Wadah<Any>.
Demonstrasi Invariant #
Mari kita lihat kode yang memperlihatkan bagaimana kita memanggil function terpisah untuk List<String> dan List<Int>:
// Chapter: Variance
// Default behavior: invariant. List<String> BUKAN subtype List<Any>.
class Wadah<T>(var isi: T)
fun main() {
val wadahInt = Wadah(10)
val wadahString = Wadah("Halo")
println("Wadah<Int>: ${wadahInt.isi}")
println("Wadah<String>: ${wadahString.isi}")
val daftarString: List<String> = listOf("Apel", "Mangga", "Jeruk")
val daftarInt: List<Int> = listOf(1, 2, 3)
prosesDaftarString(daftarString)
prosesDaftarInt(daftarInt)
val jumlahString = totalPanjang(daftarString)
println("Total panjang string: $jumlahString")
val jumlahInt = totalNilai(daftarInt)
println("Total nilai int: $jumlahInt")
}
fun prosesDaftarString(daftar: List<String>) {
println("Daftar string berukuran ${daftar.size}, isinya $daftar")
}
fun prosesDaftarInt(daftar: List<Int>) {
println("Daftar int berukuran ${daftar.size}, isinya $daftar")
}
fun totalPanjang(daftar: List<String>): Int {
var total = 0
for (s in daftar) total += s.length
return total
}
fun totalNilai(daftar: List<Int>): Int {
var total = 0
for (n in daftar) total += n
return total
}Output yang Dihasilkan:
Wadah<Int>: 10
Wadah<String>: Halo
Daftar string berukuran 3, isinya [Apel, Mangga, Jeruk]
Daftar int berukuran 3, isinya [1, 2, 3]
Total panjang string: 15
Total nilai int: 6Mari kita telaah baris-baris kode di atas:
class Wadah<T>(var isi: T): class generic invariant karenaTmuncul sebagaivar. Kita dapat menuliswadah.isi = nilaiBarudan membacawadah.isi.val wadahInt = Wadah(10): kompilator menyimpulkanT = Int.wadahInt.isibertipeInt.prosesDaftarString(daftarString): function menerimaList<String>spesifik. Kita tidak bisa meneruskanList<Int>ke function ini karena tipenya berbeda.fun prosesDaftarString(daftar: List<String>): signature function memakai tipe konkret. Hal ini membuat function type-safe tetapi tidak fleksibel untuk tipe lain.totalPanjang(daftar: List<String>): function memanggils.lengthpada setiap elemen. HanyaStringyang memiliki method ini, jadi parameter harus spesifikList<String>.
Mengapa Invariant Penting #
Walaupun invariant terasa membatasi, aturan ini menyelamatkan kita dari galat runtime yang sulit dilacak. Bayangkan jika MutableList<String> bisa dianggap MutableList<Any> — kode yang menambahkan Int ke daftar akan lolos kompilator, lalu runtime crash saat membaca. Invariant memaksa kita menulis kode yang eksplisit tentang tipe yang dipakai.
Variance di Standard Library #
Standard library Kotlin menerapkan variance secara hati-hati. List<E> dideklarasikan sebagai List<out E> (covariant), sehingga List<String> adalah subtype List<Any>. Sebaliknya, MutableList<E> bersifat invariant karena dapat menulis dan membaca. Perbedaan ini menjelaskan mengapa kita dapat meneruskan List<String> ke function yang menerima List<Any>, tetapi tidak MutableList<String> ke MutableList<Any>.
Referensi Lanjutan #
- Generics: Variance — panduan resmi variance dan hubungannya dengan type hierarchy
Pada chapter Covariance kita akan membahas secara mendalam bagaimana keyword out membuat sebuah class generic menjadi covariant dan apa konsekuensinya bagi method yang boleh dideklarasikan di dalam class tersebut.