Kode yang kita tulis diproses dalam dua fase berbeda: saat kompilasi dan saat program berjalan. Pada chapter ini kita akan membedah apa yang diputuskan kompilator Kotlin pada fase compile-time versus apa yang baru diketahui JVM pada saat runtime. Memahami pembagian ini penting karena sebagian galat ditangkap kompilator (type mismatch, ambiguitas overload), sementara sebagian lain baru muncul saat program berjalan (dynamic dispatch, reflection).

Apa yang Diputus Compile-Time #

Kompilator Kotlin memproses kode sumber menjadi bytecode JVM dan menetapkan banyak hal yang tidak akan berubah saat runtime:

  • Tipe setiap variabel dan expression — val x: Int = 5 menetapkan tipe Int pada bytecode; runtime tidak menyimpan info ini sebagai metadata variabel.
  • const val di-inline langsung — nilai konstanta compile-time disematkan langsung ke bytecode pada setiap titik pemakaian.
  • Type erasure pada generic — List<String> dan List<Int> menjadi List mentah saat runtime; info tipe parameter hilang.
  • Overload resolution — saat kita memanggil println(42), kompilator memilih versi println(Int) di antara banyak overload yang ada.
  • Null-safety check — kompilator menolak val x: String = null saat kompilasi, sebelum kode dijalankan.

Apa yang Baru Diketahui Runtime #

Sebaliknya, sebagian besar nilai dan keputusan eksekusi baru diketahui saat program berjalan:

  • Nilai variabel — val x = readLine() hanya diketahui setelah user mengetik input.
  • Dynamic dispatch — pemanggilan method pada objek yang dideklarasikan sebagai open dipilih berdasarkan tipe aktual objek saat runtime.
  • Reflection — obj::class.simpleName membaca nama class saat runtime melalui refleksi JVM.
  • Exception — NumberFormatException baru dilempar saat parsing input yang tidak valid dijalankan.

Contoh yang Memperlihatkan Kedua Fase #

Berikut program yang memadukan keputusan compile-time dan runtime:

kotlin
// FASE KOMPILASI:
// - Tipe variabel ditentukan: x:Int, daftar:List<String>
// - const val di-inline langsung ke bytecode (tidak ada field saat runtime)
// - Generic dihapus: List<String> menjadi List raw saat runtime (type erasure)
// - Overload resolution: println(Int) vs println(String) dipilih saat kompilasi

const val BATAS_ATAS: Int = 100

open class Hewan {
    open fun suara(): String = "suara generik"
}

class Kucing : Hewan() {
    override fun suara(): String = "meong"
}

class Anjing : Hewan() {
    override fun suara(): String = "guk guk"
}

fun main() {
    // Tipe 'x' ditentukan saat kompilasi sebagai Int
    val x = 50
    println("Nilai x = $x, tipe compile-time: Int")

    // const val di-inline; saat runtime tidak ada lookup field BATAS_ATAS
    if (x > BATAS_ATAS) {
        println("x melebihi batas")
    } else {
        println("x dalam batas wajar: $BATAS_ATAS")
    }

    // Dynamic dispatch saat runtime: pemanggilan method dipilih berdasar tipe aktual objek
    val daftar: List<Hewan> = listOf(Kucing(), Anjing(), Hewan())
    for (hewan in daftar) {
        println("Suara: ${hewan.suara()}")
    }

    // Type erasure: List<String> dan List<Int> menjadi List yang sama saat runtime
    val daftarString: List<String> = listOf("Budi", "Siti")
    val daftarInt: List<Int> = listOf(1, 2, 3)
    println("Class daftarString: ${daftarString::class.simpleName}")
    println("Class daftarInt: ${daftarInt::class.simpleName}")
}

Output yang Dihasilkan:

text
Nilai x = 50, tipe compile-time: Int
x dalam batas wajar: 100
Suara: meong
Suara: guk guk
Suara: suara generik
Class daftarString: ArrayList
Class daftarInt: ArrayList

Mari kita bedah keputusan pada setiap fase:

  1. Baris 1-5 (komentar di atas const val) — kompilator memproses komentar sebagai dokumentasi; komentar tidak memengaruhi bytecode.
  2. Baris 7 (const val BATAS_ATAS: Int = 100) — compile-time: nilai 100 disematkan langsung ke setiap bytecode yang memakai BATAS_ATAS. Saat runtime, tidak ada field BATAS_ATAS yang dapat di-query via reflection.
  3. Baris 9-21 (deklarasi class Hewan, Kucing, Anjing) — kompilator memverifikasi bahwa Kucing dan Anjing sah mewarisi Hewan (method suara() di-override dengan tanda tangan yang sama).
  4. Baris 25 (val x = 50) — compile-time: tipe x disimpulkan sebagai Int dari literal 50. Saat runtime, bytecode hanya menyimpan referensi ke objek Integer.
  5. Baris 28-31 (if (x > BATAS_ATAS)) — BATAS_ATAS sudah di-inline menjadi literal 100, sehingga bytecode membandingkan 50 > 100. Cabang else dipilih saat runtime.
  6. Baris 34-36 (for (hewan in daftar)) — runtime: tipe statis hewan adalah Hewan, tetapi method suara() yang dipanggil bergantung pada tipe aktual objek (Kucing, Anjing, atau Hewan). Inilah dynamic dispatch.
  7. Baris 39-41 (daftarString::class.simpleName) — runtime: refleksi membaca class aktual dari objek. Karena type erasure, kedua daftar menunjukkan ArrayList meskipun tipe compile-time berbeda (List<String> vs List<Int>).

const val vs val Biasa #

Perbedaan penting antara const val dan val biasa terletak pada kapan nilai ditentukan:

kotlin const val vs val
const val MAX_LAYANAN = 5         // di-inline ke bytecode
val VERSI: String = "1.0.0"       // disimpan sebagai field, dibaca saat runtime
  • MAX_LAYANAN dapat dipakai pada anotasi (yang memerlukan konstanta compile-time).
  • VERSI tidak bisa dipakai pada anotasi, meskipun nilainya tidak pernah berubah.
  • Refleksi ::MAX_LAYANAN tidak mengembalikan apa pun — konstanta const val tidak memiliki field runtime.

Type Erasure dan Konsekuensinya #

Generic di Kotlin (seperti Java) mengalami type erasure — tipe parameter hilang saat runtime:

kotlin Type erasure
fun cetak(daftar: List<String>) = println(daftar)
fun cetak(daftar: List<Int>) = println(daftar)  // ERROR: signature sama setelah erasure

Kedua overload di atas tidak dapat hidup berdampingan karena setelah type erasure, keduanya memiliki signature cetak(List). Untuk mengatasinya, Kotlin menyediakan reified type parameter pada inline function:

kotlin Reified type parameter menangkap tipe saat runtime
inline fun <reified T> tipeSaatRuntime(value: T) {
    println("Tipe T: ${T::class.simpleName}")
}

Keyword reified membuat kompilator menyalin function ke setiap pemanggilan dengan tipe konkret, sehingga info tipe tidak hilang. Fitur ini hanya tersedia pada inline function.

Dynamic Dispatch pada Method open #

Method open (yang dapat di-override) dipanggil via dynamic dispatch: JVM menelusuri tabel virtual method saat runtime untuk menemukan implementasi yang sesuai dengan tipe aktual objek.

kotlin Dynamic dispatch
val hewan: Hewan = Kucing()
println(hewan.suara())   // mencetak "meong", bukan "suara generik"

Tipe statis hewan adalah Hewan, tetapi JVM melihat tipe aktual objek Kucing dan memanggil Kucing.suara(). Inilah inti polimorfisme pada pemrograman berorientasi object.

Referensi Lanjutan #

Tabel Ringkasan #

KeputusanFaseCara Memverifikasi
Tipe variabel Int, StringCompile-timeCek bytecode: tipe tidak ada sebagai metadata
const val inlinedCompile-timeRefleksi tidak menemukan field
Overload resolutionCompile-timeLihat bytecode pemanggilan
Generic List<String> erasureCompile-timelist::class.simpleName tetap ArrayList
Null-safetyCompile-timenull ditolak oleh kompilator
Dynamic dispatch method openRuntimeOutput bergantung tipe aktual objek
Reflection ::class.simpleNameRuntimeMembaca metadata class saat runtime
Exception NumberFormatExceptionRuntimeDilempar saat parsing gagal

Pemisahan compile-time dan runtime adalah fondasi pemahaman galat di Kotlin. Galat yang ditangkap kompilator biasanya berupa type mismatch dan resolusi overload, sementara galat runtime mencakup null pointer, parsing gagal, dan dynamic dispatch tak terduga. Kita siap membedah cara membaca pesan kesalahan dari kedua fase tersebut — pembahasan lengkap menanti pada chapter Membaca Pesan Error.