Saat sebuah function melempar exception dan tidak menangkapnya, exception tersebut akan naik ke pemanggil. Proses ini disebut exception propagation. Pemanggil dapat memilih: menangkap exception tersebut, membiarkannya naik lagi, atau membungkusnya ke dalam exception domain. Pada chapter ini kita akan melihat bagaimana JVM melakukan stack unwinding, membaca stack trace, dan menerapkan pola re-throw dengan cause. Pemahaman ini krusial untuk merancang lapisan aplikasi yang menangani kegagalan secara konsisten.

Stack Unwinding #

Setiap kali sebuah function dipanggil, JVM menambahkan frame baru ke call stack. Frame menyimpan informasi tentang function yang sedang berjalan, parameter lokal, dan lokasi pemanggilan. Saat exception dilempar dan tidak ada catch di function tersebut, JVM mengeluarkan (pop) frame dari call stack dan memeriksa pemanggil. Proses berlanjut naik sampai menemukan catch yang cocok atau sampai frame puncak (main) habis — dalam kasus terakhir, JVM mencetak stack trace dan menghentikan program.

text
main()
  └─ levelAtas()
       └─ levelTengah()
            └─ levelPalingDalam()  <-- exception dilempar di sini

Saat levelPalingDalam melempar ArithmeticException, JVM memeriksa levelTengah (tidak ada catch), lalu levelAtas (tidak ada catch), lalu main (ada catch). Setiap langkah ini tercatat di stackTrace exception sebagai array dari objek StackTraceElement.

Propagation Sederhana #

Contoh berikut menunjukkan exception yang merambat naik dari function dalam ke function yang menangkapnya di puncak. Kita juga akan mencetak stack trace untuk memvisualisasikan proses unwinding.

kotlin
// Demo exception propagation: stack unwinding dari function dalam ke pemanggil luar.

fun levelPalingDalam(): Int {
    println("[levelPalingDalam] siap membagi")
    val a = 10
    val b = 0
    // b == 0 menyebabkan ArithmeticException
    return a / b
}

fun levelTengah(): Int {
    println("[levelTengah] memanggil levelPalingDalam")
    // Tidak menangkap exception; exception akan naik
    return levelPalingDalam()
}

fun levelAtas(): Int {
    println("[levelAtas] memanggil levelTengah")
    return levelTengah()
}

fun tangkapDiPuncak(): Int {
    return try {
        levelAtas()
    } catch (e: ArithmeticException) {
        println("[tangkapDiPuncak] menangkap ${e::class.simpleName}: ${e.message}")
        // Tampilkan stack trace ringkas: 4 baris pertama
        val jejak = e.stackTrace.take(4).joinToString("\n  ") { elem ->
            "${elem.className}.${elem.methodName}(${elem.fileName}:${elem.lineNumber})"
        }
        println("  Stack trace (4 baris):")
        println("  $jejak")
        -1
    }
}

fun demoReThrowDenganWrapping(): String {
    fun bacaDataInternal(): String {
        // simulasi kegagalan IO
        throw java.io.IOException("koneksi terputus")
    }

    return try {
        bacaDataInternal()
    } catch (io: java.io.IOException) {
        // Bungkus ke exception domain, pertahankan cause
        throw DataLoadException("gagal memuat data pengguna", io)
    }
}

class DataLoadException(pesan: String, cause: Throwable) : RuntimeException(pesan, cause)

fun main() {
    println("=== Propagation sederhana ===")
    val hasil = tangkapDiPuncak()
    println("Hasil akhir: $hasil")

    println()
    println("=== Re-throw dengan wrapping ===")
    try {
        demoReThrowDenganWrapping()
    } catch (e: DataLoadException) {
        println("tangkap ${e::class.simpleName}: ${e.message}")
        println("  cause = ${e.cause?.javaClass?.simpleName}: ${e.cause?.message}")
    }

    println()
    println("=== Tidak ditangkap sama sekali (akan naik ke JVM) ===")
    try {
        // Function dalam melempar; pemanggil langsung menangkap
        val daftar = listOf(1, 2, 3)
        println(daftar[10])
    } catch (e: IndexOutOfBoundsException) {
        println("tangkap di main: ${e::class.simpleName}")
    }
}

Output yang Dihasilkan:

text
=== Propagation sederhana ===
[levelAtas] memanggil levelTengah
[levelTengah] memanggil levelPalingDalam
[levelPalingDalam] siap membagi
[tangkapDiPuncak] menangkap ArithmeticException: / by zero
  Stack trace (4 baris):
  Exception_propagationKt.levelPalingDalam(exception_propagation.kt:8)
  Exception_propagationKt.levelTengah(exception_propagation.kt:14)
  Exception_propagationKt.levelAtas(exception_propagation.kt:19)
  Exception_propagationKt.tangkapDiPuncak(exception_propagation.kt:24)
Hasil akhir: -1

=== Re-throw dengan wrapping ===
tangkap DataLoadException: gagal memuat data pengguna
  cause = IOException: koneksi terputus

=== Tidak ditangkap sama sekali (akan naik ke JVM) ===
tangkap di main: ArrayIndexOutOfBoundsException

Mari kita bedah bagian-bagian penting dari kode di atas:

  1. levelPalingDalam: Melempar ArithmeticException karena pembagian dengan nol. Function ini tidak memiliki catch, sehingga exception langsung naik.
  2. levelTengah dan levelAtas: Hanya mencetak pesan dan meneruskan pemanggilan. Tanpa catch, exception merambat naik tanpa perubahan.
  3. tangkapDiPuncak: Membungkus levelAtas() dalam try dan menangkap ArithmeticException. Stack trace mencatat urutan pemanggilan: levelPalingDalam → levelTengah → levelAtas → tangkapDiPuncak. Pesan di baris terakhir menunjukkan di mana exception ditangkap.
  4. demoReThrowDenganWrapping: Menangkap IOException dari function dalam lalu melempar DataLoadException dengan cause. Pemanggil luar hanya melihat exception domain, namun stack trace asli tetap dapat diakses via e.cause.
  5. Blok main ketiga: Membuktikan bahwa exception yang tidak ditangkap akan terus naik. listOf(1, 2, 3)[10] melempar IndexOutOfBoundsException, yang langsung ditangkap oleh try luar di main.

Membaca Stack Trace #

Stack trace adalah jejak pemanggilan saat exception dilempar. Setiap baris menunjukkan class, method, file, dan nomor baris tempat frame berada. Baris pertama adalah titik pelemparan; baris berikutnya adalah pemanggil berurutan ke atas.

text
Exception_propagationKt.levelPalingDalam(exception_propagation.kt:8)
Exception_propagationKt.levelTengah(exception_propagation.kt:14)
Exception_propagationKt.levelAtas(exception_propagation.kt:19)
Exception_propagationKt.tangkapDiPuncak(exception_propagation.kt:24)

Membaca stack trace dari atas ke bawah memberi tahu kita alur pemanggilan yang mengarah ke titik kegagalan. Saat membungkus exception dengan cause, stack trace cause tetap dapat diakses melalui e.cause?.stackTrace, dan stack trace luar menunjukkan titik pembungkusan.

Tangkap atau Lempar #

Setiap function yang melempar exception memiliki tiga pilihan:

StrategiKapan dipakaiContoh
Tangkap dan pulihkanPunya strategi pemulihan konkretParse gagal → kembalikan nilai default
Tangkap dan bungkusException teknis perlu diterjemahkan ke domainIOException → DataLoadException
Lewati (propagate)Tidak ada konteks untuk memulihkan; biarkan layer atas menanganiFunction utilitas yang tidak tahu tujuan akhir

Pilihan propagate sering paling tepat di layer utilitas dan layer akses data level rendah. Layer aplikasi, sebaliknya, biasanya memilih tangkap dan bungkus agar exception domain yang bermakna sampai ke pemanggil bisnis.

Membungkus dengan Cause #

Saat membungkas exception, selalu teruskan cause asli sebagai parameter kedua konstruktor Throwable. Tanpa cause, stack trace asli tidak terlihat saat debugging, dan akar masalah menjadi sulit ditelusuri. Pola yang lazim di Kotlin adalah menangkap exception teknis di layer bawah (misal SQLException), lalu melempar exception domain dengan cause yang sama:

kotlin Bungkus dengan cause
class RepoError(pesan: String, cause: Throwable) : RuntimeException(pesan, cause)

fun ambilPengguna(id: Int): Pengguna {
    return try {
        db.queryPengguna(id)   // bisa melempar SQLException
    } catch (e: SQLException) {
        throw RepoError("gagal mengambil pengguna $id", e)
    }
}

Pemanggil ambilPengguna hanya melihat RepoError, namun bila terjadi kegagalan, log dapat mencetak e.cause untuk mengetahui akar masalah SQL yang sebenarnya.

Referensi Lanjutan #

Penutup #

Exception propagation memungkinkan kegagalan ditangani di layer yang tepat tanpa memaksa setiap function menangkap. Stack trace memberikan jejak lengkap dari titik kegagalan ke titik penangkapan, dan cause mempertahankan konteks exception asli saat dibungkus. Pemahaman ini mempersiapkan kita untuk membahas perbedaan checked vs unchecked exception dan mengapa Kotlin menghapus pembedaan tersebut — topik yang akan kita dalami pada chapter berikutnya.