Skip to main content

Object Reference, Mutability, dan copy() di Kotlin

Saat mengerjakan project Sentinel, saya menemukan kasus ketika sebuah object berubah setelah diteruskan ke function, padahal object tersebut tidak dikembalikan dan variable awal tidak pernah di-assign ulang.

Perilaku ini sering disebut sebagai efek pointer memory. Istilah yang lebih tepat di Kotlin adalah shared object reference: beberapa variable memegang reference menuju object yang sama, sehingga perubahan melalui salah satu reference terlihat melalui reference lainnya.

Konsep utamanya

Kotlin selalu menggunakan pass-by-value saat mengirim argument ke function. Namun, nilai yang disalin berbeda berdasarkan jenis datanya:

  • Untuk primitive-like value seperti Int dan Boolean, nilai datanya yang disalin.
  • Untuk object, nilai reference menuju object yang disalin.

Artinya, caller dan parameter function mempunyai dua variable reference yang berbeda, tetapi keduanya dapat menunjuk ke object yang sama di heap.

caller.user ───────┐
├──> User object di heap
function.user ─────┘

Kotlin tidak memberi akses langsung untuk mengolah pointer seperti C/C++. Akan tetapi, reference tetap membuat mutasi terhadap object yang sama dapat diamati dari banyak tempat.

Contoh: object asli ikut berubah

data class User(
val name: String,
var isActive: Boolean,
)

fun deactivate(user: User) {
user.isActive = false
}

val sentinelUser = User(
name = "Kevin",
isActive = true,
)

deactivate(sentinelUser)

println(sentinelUser.isActive) // false

Function deactivate() tidak melakukan return dan tidak mengubah variable sentinelUser. Namun, property isActive berubah karena parameter user menunjuk ke instance yang sama.

Re-assign parameter berbeda dengan memutasi object

fun replace(user: User) {
// Hanya variable lokal yang diarahkan ke object baru.
// Variable milik caller tidak berubah.
var localUser = user
localUser = User("Guest", false)
}

Mengganti reference lokal tidak mengubah reference milik caller. Yang memengaruhi caller adalah mutasi terhadap object bersama, bukan re-assignment variable lokal.

val bukan berarti object immutable

val hanya mencegah sebuah variable diarahkan ke object lain. val tidak otomatis membuat isi object menjadi immutable.

val user = User("Kevin", true)

// Tidak boleh: reference `user` tidak bisa diganti.
// user = User("Guest", false)

// Boleh: instance User masih memiliki property mutable.
user.isActive = false

Jika ingin object benar-benar sulit dimutasi, gunakan val untuk property-nya dan hindari collection mutable.

data class User(
val name: String,
val isActive: Boolean,
)

Membuat perubahan tanpa memutasi object asli

Untuk data class, gunakan copy() agar perubahan menghasilkan instance baru.

data class User(
val name: String,
val isActive: Boolean,
)

fun deactivate(user: User): User {
return user.copy(isActive = false)
}

val original = User("Kevin", true)
val updated = deactivate(original)

println(original.isActive) // true
println(updated.isActive) // false
println(original === updated) // false: instance berbeda

Pendekatan ini membuat alur perubahan data lebih eksplisit: input tetap, hasil perubahan dikembalikan sebagai object baru.

Hati-hati: copy() bersifat shallow copy

data class.copy() hanya membuat salinan pada level terluar. Object atau collection di dalamnya masih dapat menggunakan reference yang sama.

data class Project(
val name: String,
val members: MutableList<String>,
)

val original = Project(
name = "Sentinel",
members = mutableListOf("Kevin"),
)

val copied = original.copy()
copied.members += "Android Team"

println(original.members)
// [Kevin, Android Team]

Meskipun original !== copied, nilai original.members dan copied.members menunjuk ke MutableList yang sama.

Jika nested data memang perlu dipisahkan, salin bagian mutable tersebut secara eksplisit.

val safelyCopied = original.copy(
members = original.members.toMutableList(),
)

Pilihan yang lebih aman adalah menyimpan collection sebagai read-only List dan menghasilkan list baru saat ada perubahan.

data class Project(
val name: String,
val members: List<String>,
)

val updated = original.copy(
members = original.members + "Android Team",
)

List di Kotlin merupakan interface read-only, bukan jaminan deep immutability. Jangan membocorkan backing MutableList yang sama ke bagian lain aplikasi.

Equality yang berguna saat debugging

Kotlin menyediakan dua jenis pemeriksaan equality:

OperatorMemeriksaContoh penggunaan
==Structural equality melalui equals()Apakah isi dua data class sama?
===Referential equalityApakah keduanya instance yang sama?
val first = User("Kevin", true)
val second = first
val third = first.copy()

println(first == third) // true: isi sama
println(first === second) // true: instance sama
println(first === third) // false: instance berbeda

Saat sebuah object berubah secara tidak terduga, === dapat membantu menemukan apakah dua variable ternyata memakai instance yang sama.

Dampaknya pada Android development

Shared mutable state mudah menimbulkan bug ketika data melewati beberapa layer, misalnya dari Repository ke UseCase, ViewModel, lalu UI.

StateFlow dan UI state

Gunakan immutable UI state dan buat instance baru saat update.

data class SentinelUiState(
val isLoading: Boolean = false,
val items: List<String> = emptyList(),
)

private val _uiState = MutableStateFlow(SentinelUiState())
val uiState = _uiState.asStateFlow()

fun addItem(item: String) {
_uiState.update { currentState ->
currentState.copy(
items = currentState.items + item,
)
}
}

Pola ini memberi StateFlow instance state baru dan tidak memodifikasi list yang mungkin sedang dibaca UI.

Jetpack Compose

Compose lebih mudah mendeteksi perubahan jika state diganti dengan nilai atau instance baru. Memutasi object biasa di dalam state tanpa mengganti reference dapat membuat recomposition tidak berjalan sesuai harapan.

// Disarankan
uiState = uiState.copy(isLoading = true)

// Berisiko jika property mutable dan tidak dikelola sebagai observable state
uiState.isLoading = true

ListAdapter dan DiffUtil

Jangan mengubah item dari list lama lalu mengirim instance yang sama. DiffUtil membandingkan list lama dan baru; shared mutable object dapat membuat kondisi lama ikut berubah sehingga perbedaan sulit dideteksi.

val updatedItems = currentItems.map { item ->
if (item.id == selectedId) {
item.copy(isSelected = true)
} else {
item
}
}

adapter.submitList(updatedItems)

Boundary antar-layer

Waspadai object mutable yang dibagikan melewati boundary berikut:

  • DTO API ke domain model
  • Entity database ke UI model
  • Cache Repository ke caller
  • Argument yang dipakai beberapa coroutine
  • Singleton atau object global yang menyimpan mutable state

Mapping ke model baru pada boundary penting dapat mencegah satu layer mengubah data milik layer lain secara tidak sengaja.

Checklist saat menemukan perubahan data misterius

  1. Cari property var, MutableList, MutableMap, atau MutableSet.
  2. Periksa apakah beberapa variable mempunyai instance yang sama dengan ===.
  3. Cari function yang menerima object lalu memodifikasi property-nya.
  4. Ingat bahwa data class.copy() hanya shallow copy.
  5. Periksa nested object dan backing collection yang mungkin masih dibagikan.
  6. Untuk state Android, utamakan immutable model dan update dengan copy().
  7. Tambahkan test yang memastikan input tidak berubah setelah diproses.
@Test
fun `deactivate returns updated user without changing original`() {
val original = User("Kevin", true)

val result = deactivate(original)

assertTrue(original.isActive)
assertFalse(result.isActive)
assertNotSame(original, result)
}

Ringkasan

  • Kotlin selalu pass-by-value.
  • Saat argument berupa object, value yang disalin adalah reference menuju object tersebut.
  • Mutasi melalui salah satu reference akan terlihat melalui reference lain yang menunjuk ke instance yang sama.
  • val menjaga reference agar tidak di-assign ulang, tetapi tidak membuat object otomatis immutable.
  • data class.copy() membuat instance baru, tetapi hanya melakukan shallow copy.
  • Untuk Android UI state, gunakan model immutable, collection read-only, dan update menggunakan copy() agar perubahan data eksplisit serta mudah diamati.

Inti pembelajarannya: jika sebuah function tidak seharusnya mengubah input, jangan mutasi object yang diterima. Kembalikan object baru dan salin nested mutable data bila diperlukan.