ข้าม​ไป​ยัง​เนื้อหา

ความ​ผิด​พลาด​ที่​ไม่มี​ใคร​บังคับ​ให้​คุณ​จับ

บท​ที่​แล้ว​ต่อ​กฎ​ส่วนลด​เพิ่ม​โดย​ไม่​แตะ​ของ​เดิม บท​นี้​ถาม​คำถาม​ที่​ตาม​มา​ทันที — เมื่อ​ออ​เด​อร์​ที่​เข้า​มา​ผิด ใคร​เป็น​คน​บอก และ​บอก​กี่​ข้อ

Kotlin ตอบ​คำถาม​นี้​ด้วย​การ​ถอด​ของ​ออก​หนึ่ง​ชิ้น ไม่ใช่​เพิ่ม​เข้า​มา

📦 สิ่ง​ที่​บท​นี้​เพิ่ม​ลง orderctl
  • Failure — ชุด​ปิด​ของ​สิ่ง​ที่​ผิด​ได้
  • OrderException — ตัว​ขน​ที่​พา​รายการ​ทั้ง​ชุด​ข้าม​ชั้น
  • validate — ตัว​ตรวจ​ที่​สะสม​ทุก​ข้อ​ก่อน​โยน
  • exitCode — ที่​เดียว​ที่​แปล​ความ​ผิด​พลาด​เป็น​ตัวเลข

โปร​เจ​กต์ Gradle มา​ตั้งแต่​บท​ที่ 3 แล้ว ทุก​อย่าง​ใน​บท​นี้​จึง​วาง​ลง src/main/kotlin/orderctl/ แล้ว​รัน​ด้วย gradle run เหมือน​เดิม

javac ปฏิเสธ code ที่​ไม่​จัดการ IOException ส่วน kotlinc แปล​ผ่าน​โดย​ไม่​พิมพ์​อะไร​ออก​มา​เลย

เขียน​ตัว​อ่าน file ดิบ​สอง​ภาษา​ให้​เหมือน​กัน​ทุก​บรรทัด แล้ว​ส่ง​เข้า compiler ของ​แต่ละ​ฝั่ง

// Checked.java — ฝั่ง Java ของคอร์สที่แล้ว
package orderctl;
import java.nio.file.Files;
import java.nio.file.Path;
public final class Checked {
public static String readRaw(String path) {
return Files.readString(Path.of(path));
}
}
$ javac -d o orderctl/Checked.java
orderctl/Checked.java:9: error: unreported exception IOException; must be caught or declared to be thrown
return Files.readString(Path.of(path));
^
1 error

ฝั่ง Kotlin เขียน​สิ่ง​เดียวกัน เรียก method ตัว​เดียวกัน​ของ JDK

src/main/kotlin/orderctl/Checked.kt
package orderctl
import java.nio.file.Files
import java.nio.file.Path
fun readRaw(path: String): String = Files.readString(Path.of(path))

แล้ว​บังคับ compiler ให้​เข้ม​ที่สุด​เท่า​ที่​ธง​มี​ให้

$ kotlinc -Werror -Wextra -jvm-target 25 src/main/kotlin/orderctl/Checked.kt
$ echo $?
0

ไม่มี output สัก​บรรทัด -Werror เปลี่ยน​คำ​เตือน​ทุก​ตัว​เป็น​ความ​ล้ม​เหลว และ -Wextra เปิด​คำ​เตือน​ชุด​ที่​ปกติ​เงียบ แต่​ทั้ง​คู่​ไม่มี​อะไร​จะ​พูด​ตรง​นี้

นี่​คือ unchecked exceptionunchecked exceptionKotlin ไม่มี checked exception เลย​สัก​ตัว · เรียก method ของ Java ที่​ประกาศ `throws` ได้​โดย​ไม่​ต้อง​จับ และ `@Throws` มี​ไว้​ให้​ผู้​เรียก​ฝั่ง Java เท่านั้น ไม่​ได้​เปลี่ยน​อะไร​ฝั่ง Kotlin ใน​ความ​หมาย​ที่​แรง​ที่สุด — ไม่ใช่​ว่า IOException ย้าย​ไป​อยู่​อีก​ฝั่ง​ของ​เส้น​แบ่ง แต่​คือเส้น​แบ่ง​นั้น​ไม่มี​อยู่​ใน​ภาษา

เอกสาร​ทางการ​เขียน​ข้อ​นี้​ไว้​เป็น​ข้อดี

“Unchecked exceptions simplify the exception handling process: you can catch exceptions, but you don’t need to explicitly handle or declare them.”

ราคา​ที่​ประโยค​นั้น​ไม่​ได้​พูด​ถึง​คือ ลาย​เซ็น​ของ function ไม่​บอก​อะไร​ผู้​เรียก​อีก​ต่อ​ไป · คอร์ส Java ใช้​ทั้ง​หัวข้อ​พิสูจน์​ว่า throws คือ​คำ​สั่ง​ของ compiler ไม่ใช่​คำ​สัญญา​เรื่อง​ความ​ร้าย​แรง · ที่​นี่​คำ​สั่ง​นั้น​หาย​ไป​ทั้ง​คำ​สั่ง

`@Throws` เปลี่ยน​ฝั่ง Java เท่านั้น · วัด​ทั้ง​สอง​ด้าน​แล้ว

Kotlin มี @Throws ให้​ติด​หัว function ได้ คน​ที่มา​จาก Java มัก​คิด​ว่า​มัน​คือ throws ที่​เขียน​คนละ​ที่ · มัน​ไม่ใช่

เติม​บรรทัด @Throws(IOException::class) ไว้​เหนือ readRaw แล้ว​แปล​ใหม่​ทั้ง​สอง​ฝั่ง โดย​มี​ผู้​เรียก​ฝั่ง Java อยู่​ด้วย​หนึ่ง​ตัว

// FromJava.java — ผู้เรียกฝั่ง Java ของ readRaw ตัวเดียวกัน
import orderctl.CheckedKt;
public class FromJava {
static String go() {
return CheckedKt.readRaw("order.json");
}
}
สิ่ง​ที่​แปลไม่มี @Throwsมี @Throws
kotlinc -Werror -Wextra บน Checked.ktรหัส​ออก 0 เงียบรหัส​ออก 0 เงียบ
ผู้​เรียก​ฝั่ง Kotlin ที่​ไม่​จับ​อะไร​เลยรหัส​ออก 0 เงียบรหัส​ออก 0 เงียบ
javac บน FromJava.javaรหัส​ออก 0 เงียบรหัส​ออก 1

ข้อความ​ที่ javac ตอบ​ใน​ช่อง​ล่าง​ขวา

FromJava.java:6: error: unreported exception IOException; must be caught or declared to be thrown
return CheckedKt.readRaw("order.json");
^
1 error

สังเกต​แถว​กลาง · ผู้​เรียก​ฝั่ง Kotlin ยัง​เงียบ​สนิท​ทั้ง​ที่ annotation อยู่​ตรง​นั้น · เอกสาร​ทางการ​อธิบาย​ว่า​มัน​มี​ไว้​เพื่อ​ลด​ความ​ต่าง​ระหว่าง Kotlin กับ​ภาษา​อย่าง Java, Swift และ Objective-C คือ​มัน​เตือน​ผู้​เรียก ที่​อยู่​นอก​ภาษา

ช่อง​ล่าง​ซ้าย​คือ​ข้อ​ที่​เจ็บ​ที่สุด​สำหรับ​ทีม​ผสม — library ของ Kotlin ที่​ไม่​ติด @Throws ทำให้​ผู้​เรียก​ฝั่ง Java แปล​ผ่าน​เงียบๆ แล้วไป​เจอ IOException ตอน​รัน

require กับ check เป็น​ด่าน​สอง​ตัว​ที่​ต่าง​กัน​ที่​ชนิด​ของ​สิ่ง​ที่​มัน​โยน และ​ตัว​แรก​ยัง​แคบ​ชนิด​ให้​ด้วย

เมื่อ compiler ไม่​บังคับ​อะไร​แล้ว สิ่ง​ที่​เหลือ​คือ​ด่าน​ที่​เรา​วาง​เอง · Kotlin ให้​มาส​อง​ตัว​ใน standard library

src/main/kotlin/orderctl/RequireDemo.kt
package orderctl
fun tableOf(raw: String?): Int {
require(raw != null) { "ต้องระบุหมายเลขโต๊ะ" }
val n = raw.trim().toInt()
check(n in 1..40) { "โต๊ะ $n อยู่นอกช่วง 1 ถึง 40" }
return n
}
fun main() {
println(tableOf(" 12 "))
for (raw in listOf(null, "99")) {
try {
tableOf(raw)
} catch (e: Throwable) {
println("${e::class.simpleName}: ${e.message}")
}
}
}

ชี้​บรรทัด mainClass ใน build.gradle.kts ไป​ที่ orderctl.RequireDemoKt แล้ว​สั่ง​รัน

$ gradle -q run
12
IllegalArgumentException: ต้องระบุหมายเลขโต๊ะ
IllegalStateException: โต๊ะ 99 อยู่นอกช่วง 1 ถึง 40

สอง​บรรทัด​ล่าง​คือ​เส้น​แบ่ง​ของ​ด่าน​สอง​ตัว​นี้

  • requirerequireด่าน​ตรวจ​ต้นทาง​ที่​ล้ม​ด้วย `IllegalArgumentException` · มัน​ประกาศ contract ไว้ compiler จึง​แคบ​ชนิด​ให้​เอง​หลัง​ผ่าน​ด่าน ซึ่ง `Objects.requireNonNull` ของ Java ทำให้​ไม่​ได้ ล้ม​ด้วย IllegalArgumentException ใช้​กับ​สิ่ง​ที่​ผู้​เรียก​ส่ง​เข้า​มา
  • check ล้ม​ด้วย IllegalStateException ใช้​กับ​สถานะ​ของ​อ็อบเจ็กต์​เอง

แต่​ของ​แถม​ของ require อยู่​ที่​บรรทัด​ถัด​จาก​มัน · raw ประกาศ​เป็น String? แล้ว​บรรทัด​ต่อ​มา​เรียก raw.trim() ตรงๆ ไม่มี !! และ​ไม่มี ?.

compiler รู้​ว่า​ผ่าน​ด่าน​มา​แล้ว raw เป็น null ไม่​ได้ จึง​แคบ​ชนิด​ให้​เอง · Objects.requireNonNull ของ Java ทำ​แบบ​นี้​ไม่​ได้ เพราะ​ชนิด​ของ Java ไม่มี​ที่​ให้​เขียน​ว่า “ตัว​นี้​รับ null ได้” ตั้งแต่​ต้น

sealed interface ทำให้ compiler นับ​กิ่ง​ให้​ครบ และ​รหัส​ออก​ของ​โปรแกรม​ผูก​อยู่​กับ​ชุด​กิ่ง​นั้น

ก่อน​จะ​รายงาน​ความ​ผิด​พลาด ต้อง​มีชื่อ​เรียก​มัน​ก่อน · บท​ที่ 2 สร้าง sealed interface ไว้​แล้ว บท​นี้​เอา​มา​ใช้​กับ​สิ่ง​ที่​ผิด​ได้

src/main/kotlin/orderctl/Failure.kt
package orderctl
sealed interface Failure {
data class BadField(val field: String, val value: String, val reason: String) : Failure
data class NoLines(val orderId: String) : Failure
data class Io(val path: String, val reason: String) : Failure
}
fun Failure.describe(): String = when (this) {
is Failure.BadField -> "field $field = \"$value\": $reason"
is Failure.NoLines -> "ออเดอร์ $orderId ไม่มีรายการ"
is Failure.Io -> "อ่าน $path ไม่ได้: $reason"
}
fun Failure.exitCode(): Int = when (this) {
is Failure.BadField -> 2
is Failure.NoLines -> 2
is Failure.Io -> 4
}

when สอง​ก้อน​นี้​ไม่มี else และ​นั่น​คือ​ทั้งหมด​ที่​ต้องการ · ชุด​กิ่ง​ของ Failure ปิด​แล้ว compiler จึง​พิสูจน์​ได้​ว่า​ครบ

ทีนี้​ลอง​เพิ่ม​กิ่ง​ที่​สี่​แล้ว​ลืม​ตาม​ไป​แก้ when ตัว​ล่าง

// ❌ แปลไม่ผ่าน — เพิ่มกิ่งที่สี่แล้วลืมตามไปแก้ when
package orderctl
sealed interface Failure {
data class BadField(val field: String, val value: String, val reason: String) : Failure
data class NoLines(val orderId: String) : Failure
data class Io(val path: String, val reason: String) : Failure
data class Duplicate(val sku: String) : Failure
}
fun Failure.exitCode(): Int = when (this) {
is Failure.BadField -> 2
is Failure.NoLines -> 2
is Failure.Io -> 4
}
Failure.kt:11:31: error: 'when' expression must be exhaustive. Add the 'is Duplicate' branch or an 'else' branch.
fun Failure.exitCode(): Int = when (this) {
^^^^

ข้อความ​บอก​ชื่อ​กิ่ง​ที่​ขาด​ออก​มา​ตรงๆ ว่า is Duplicate · นี่​คือ​ของ​ที่ else -> 1 ซื้อ​ความ​สงบ​มา​แล้ว​ทิ้ง​ไป​ทันที

รหัส​ออก​ที่​บท​นี้​ตรึง​ไว้​มี​สาม​ตัว บท​ที่ 7 จะ​เอา exitCode() ไป​ต่อ​กับ main จริง

รหัส​ออกความ​หมายมา​จาก​กิ่ง​ไหน
0ผ่าน​การ​ตรวจไม่มี​ความ​ผิด​พลาด​เลย
2ออ​เด​อร์​ไม่​ผ่าน​การ​ตรวจBadField และ NoLines
4อ่าน​หรือ​เขียน file ไม่​สำเร็จIo
ทำไม​ไม่​ใช้ `Result` ตรง​นี้

Failure เป็น​ชนิด​ของข้อมูล ไม่ใช่​ชนิด​ของผลลัพธ์ · มัน​บอก​ว่า​อะไร​ผิด ไม่​ได้​บอก​ว่าการ​เรียก​ครั้ง​นี้​สำเร็จ​หรือ​ไม่

ResultResultชนิด​ที่​ห่อ "สำเร็จ​หรือ​พัง" ไว้​เป็น​ค่า ทำให้​ความ​ผิด​พลาด​เดิน​ทาง​แบบ​เดียว​กับ Go ได้ · `runCatching` ที่​สร้าง​มัน​จับ​ถึง `Throwable` จึง​กลืน `OutOfMemoryError` ไป​ด้วย​โดย​ไม่​บอก​ใคร ห่อ​ได้ที​ละ​หนึ่ง แต่​ผู้​ใช้​ที่​กรอก​ผิด​สาม​ช่อง​ต้อง​เห็น​ทั้ง​สาม​ช่อง · หัวข้อ​ถัด​ไป​จึง​ประกอบ​ตัว​ขน​ของ​ขึ้น​เอง แล้ว​ค่อย​กลับ​มา​ที่ Result ใน​หัวข้อ​ที่​มัน​เหมาะ​จริง

ตัว​ขน​ของ​ต้อง​พา​รายการ​ความ​ผิด​พลาด​ขึ้น​ไป​ให้​ผู้​เรียก โดยที่​คน​อ่าน log อย่าง​เดียว​ยัง​เห็น​มัน​ด้วย

เปิด​ดู​ทุก class ใน kotlin-stdlib.jar ที่​ชื่อ​ลงท้าย​ด้วย Exception หรือ Error ได้​สิบ​ห้า​ตัว และ​ไม่มี​ตัว​ไหน​รวม​ความ​ผิด​พลาด​หลาย​ข้อ​เข้า​ด้วย​กัน​เลย · คน​ที่มา​จาก Go จะหา errors.Join แล้ว​ไม่​เจอ เหมือน​ที่​คอร์ส Java หาไม่​เจอ

src/main/kotlin/orderctl/OrderException.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Failure อยู่ในโปรเจกต์ด้วย
package orderctl
class OrderException private constructor(
message: String,
val failures: List<Failure>,
cause: Throwable?,
) : RuntimeException(message, cause) {
companion object {
fun of(message: String, failures: List<Failure>): OrderException {
val e = OrderException(message, failures.toList(), null)
for (f in failures) e.addSuppressed(IllegalArgumentException(f.describe()))
return e
}
fun wrapping(message: String, failure: Failure, cause: Throwable): OrderException =
OrderException(message, listOf(failure), cause)
}
}

ทาง​เข้า​สอง​ทาง​ทำ​คนละ​หน้าที่ · of รับ​รายการ​ที่​สะสม​มา​จาก​การ​ตรวจ ส่วน wrapping ห่อ​ความ​ผิด​พลาด​ตัว​จริง​ที่มา​จาก​ชั้น​ล่าง แล้ว​เก็บ​มัน​ไว้​เป็น cause

addSuppressed ใน of ทำให้​รายการ​เดียวกัน​โผล่​ใน stack trace ด้วย สำหรับ​คน​ที่​เปิด log อ่าน​โดย​ไม่รู้​จัก failures

สอง​คำ​เตือน​ของ​คอร์ส Java หาย​ไป​ที่​นี่

Throwable ทุก​ตัว​เป็น Serializable · คอร์ส Java จึง​เจอ​คำ​เตือน​สอง​ข้อ​จาก -Xlint:serial ทันที​ที่​เขียน class แบบ​นี้ คือ​ไม่​ได้​ประกาศ serialVersionUID และ​ประกาศ​ชนิด​ของ field เป็น List ที่​ไม่​ได้​สืบสาย Serializable

OrderException ข้าง​บน​ไม่มี​ทั้ง​สอง​อย่าง และ kotlinc -Werror -Wextra ให้​รหัส​ออก 0 โดย​ไม่​พิมพ์​อะไร​เลย

อย่า​อ่าน​ความ​เงียบ​นี้​ว่า​เป็น​ความ​ปลอดภัย ปัญหา​เดิม​ยัง​อยู่​ครบ สิ่ง​ที่​หาย​ไป​คือ​คน​บอก · ฝั่ง Java มี​ธง​ที่​เปิด​แล้ว​เห็น ส่วน​ฝั่ง​นี้​ไม่มี​ธง​ตัว​นั้น

ตัว​ตรวจ​เก็บ​ความ​ผิด​พลาด​ทั้งหมด​ไว้​ใน​รายการ​ก่อน แล้ว​ค่อย​ตัดสิน​ใจ​ตอน​จบ​ว่า​จะ​โยน​ไหม

ผู้​ใช้​ที่​กรอก​ผิด​สาม​ช่อง​ควร​เห็น​ทั้ง​สาม​ช่อง​ใน​รอบ​เดียว ไม่ใช่​แก้​ที​ละ​รอบ · buildList ทำให้​การ​สะสม​อ่าน​เป็น​ก้อน​เดียว​จบ

src/main/kotlin/orderctl/Orders.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Failure, OrderException และ Line อยู่ในโปรเจกต์ด้วย
package orderctl
import java.nio.file.Path
import kotlin.io.path.readText
fun validate(orderId: String, table: Int, lines: List<Line>) {
val failures = buildList {
if (table !in 1..40) {
add(Failure.BadField("table", table.toString(), "ต้องอยู่ระหว่าง 1 ถึง 40"))
}
if (lines.isEmpty()) add(Failure.NoLines(orderId))
}
if (failures.isNotEmpty()) {
throw OrderException.of("ออเดอร์ $orderId ไม่ผ่านการตรวจ ${failures.size} ข้อ", failures)
}
}
fun load(path: String): String =
try {
Path.of(path).readText()
} catch (e: java.nio.file.NoSuchFileException) {
throw OrderException.wrapping("อ่านออเดอร์ไม่สำเร็จ", Failure.Io(path, "ไม่พบ file"), e)
}

ชั้น​นอก​สุด​จับ​ตัว​เดียว แล้ว​กาง​รายการ​ออก​มา​เอง

src/main/kotlin/orderctl/Main.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Failure, OrderException และ Orders อยู่ในโปรเจกต์ด้วย
package orderctl
fun main() {
try {
validate("A-1", 99, emptyList())
} catch (e: OrderException) {
println(e.message)
for (f in e.failures) println(" [${f.exitCode()}] ${f.describe()}")
}
try {
load("no-such.json")
} catch (e: OrderException) {
val f = e.failures.single()
println("${f.describe()} → รหัสออก ${f.exitCode()}")
println("cause: ${e.cause!!::class.qualifiedName}: ${e.cause!!.message}")
}
}
$ gradle -q run
ออเดอร์ A-1 ไม่ผ่านการตรวจ 2 ข้อ
[2] field table = "99": ต้องอยู่ระหว่าง 1 ถึง 40
[2] ออเดอร์ A-1 ไม่มีรายการ
อ่าน no-such.json ไม่ได้: ไม่พบ file → รหัสออก 4
cause: java.nio.file.NoSuchFileException: no-such.json

สาม​บรรทัด​บน​มา​จาก​การ​ตรวจ​ที่​สะสม​สอง​ข้อ ส่วน​สอง​บรรทัด​ล่าง​มา​จาก​ทาง​เดิน​คนละ​เส้น

flowchart TD
  A["validate(orderId, table, lines)"] --> B{"ผิดกี่ข้อ"}
  B -->|"ศูนย์ข้อ"| C["คืนค่าปกติ · รหัสออก 0"]
  B -->|"หนึ่งข้อขึ้นไป"| D["สะสมลง list ของ Failure"]
  D --> E["OrderException.of<br/>failures บวก suppressed"]
  L["load(path)"] -->|"java.nio.file.NoSuchFileException"| W["OrderException.wrapping<br/>Failure.Io บวก cause"]
  E --> F["ชั้นนอกจับตัวเดียว แล้วเรียก exitCode"]
  W --> F
  F --> G["BadField หรือ NoLines · รหัสออก 2"]
  F --> H["Io · รหัสออก 4"]

คำ​บรรยาย​ภาพ: ทาง​เข้า​สอง​ทาง​ของ OrderException มา​บรรจบ​ที่​ชั้น​เดียวกัน · exitCode เป็น​ที่​เดียว​ที่​แปล​ความ​ผิด​พลาด​เป็น​ตัวเลข

catch ที่​เขียน​ถูก​ตาม​สายตา แปล​ผ่าน​ทุก​ธง แล้ว​ไม่​เคย​ทำงาน​สัก​ครั้ง

load ข้าง​บน​เขียน​ชื่อ​เต็ม​ว่า java.nio.file.NoSuchFileException ซึ่ง​ดู​เยิ่นเย้อ · ลอง​ตัด​ให้​สั้น​ตาม​สัญชาตญาณ​ดู

src/main/kotlin/orderctl/TrapDemo.kt
package orderctl
import java.nio.file.Path
import kotlin.io.path.readText
fun main() {
try {
try {
println(Path.of("no-such.json").readText())
} catch (e: NoSuchFileException) { // ⚠️ ตัวนี้คือ kotlin.io.NoSuchFileException ไม่ใช่ java.nio.file.NoSuchFileException
println("catch ชั้นในทำงาน: ${e.message}")
}
} catch (outer: Throwable) {
println("หลุดถึงชั้นนอก: ${outer::class.qualifiedName}")
}
}

kotlinc -Werror -Wextra -jvm-target 25 ให้​รหัส​ออก 0 โดย​ไม่​พิมพ์​อะไร​เลย · ไม่มี import เกิน ไม่มี​ชนิด​ที่​หาไม่​เจอ ไม่มี​อะไร​น่า​สงสัย

$ gradle -q run
หลุดถึงชั้นนอก: java.nio.file.NoSuchFileException

catch ชั้น​ใน​ไม่​ทำงาน · เพราะ​ชื่อ​เปล่าๆ ตัว​นั้น​ไม่ใช่​ชนิด​ที่ readText() โยน

kotlin.io เป็น default import ของ​ทุก file · ชื่อ NoSuchFileException ที่​ไม่มี package นำ​หน้า​จึง​หมาย​ถึง​ตัว​ของ kotlin.io เสมอ และ​มัน​เป็น​คนละ​สาย​พันธุ์​กับ​ตัว​ของ java.nio.file โดย​สิ้น​เชิง

ชื่อ​เปล่าชนิด​ที่ Kotlin เลือก​ให้class แม่ชนิด​ที่ java.nio.file มีชื่อ​เดียวกัน
NoSuchFileExceptionkotlin.io.NoSuchFileExceptionkotlin.io.FileSystemExceptionjava.nio.file.NoSuchFileException
FileAlreadyExistsExceptionkotlin.io.FileAlreadyExistsExceptionkotlin.io.FileSystemExceptionjava.nio.file.FileAlreadyExistsException
AccessDeniedExceptionkotlin.io.AccessDeniedExceptionkotlin.io.FileSystemExceptionjava.nio.file.AccessDeniedException
FileSystemExceptionkotlin.io.FileSystemExceptionjava.io.IOExceptionjava.nio.file.FileSystemException

สี่​ชื่อ ไม่ใช่​ชื่อ​เดียว และ​ไม่มี​คู่​ไหน​เป็น​บรรพบุรุษ​ของ​อีก​ตัว · จุด​ที่​ทั้ง​สอง​สาย​มา​บรรจบ​กัน​อยู่​สูง​ถึง java.io.IOException

ตัว​ของ `kotlin.io` ไม่ใช่​ของ​เสีย มัน​แค่​มา​จาก​คนละ API

ชนิด​ตระกูล​นั้น​มี​คน​โยน​จริง คือ​ชุด extension function ที่​ทำงาน​บน java.io.File

src/main/kotlin/orderctl/CopyDemo.kt
package orderctl
import java.io.File
fun main() {
try {
File("no-such.json").copyTo(File("out.json"))
} catch (e: NoSuchFileException) { // ตัวนี้ถูกแล้ว · copyTo โยน kotlin.io.NoSuchFileException ไม่ใช่ java.nio.file.NoSuchFileException
println("copyTo โยน: ${e::class.qualifiedName}")
println("message: ${e.message}")
}
}
$ gradle -q run
copyTo โยน: kotlin.io.NoSuchFileException
message: no-such.json: The source file doesn't exist.

เส้น​แบ่ง​จึง​อยู่​ที่ API ที่​คุณ​เรียก ไม่ใช่​ที่​ชื่อ​ของ​ชนิด · File.copyTo ให้​ตัว​ของ kotlin.io ส่วน Path.readText() ให้​ตัว​ของ java.nio.file

ทางออก​มี​สอง​ทาง เลือก​อย่าง​ใด​อย่าง​หนึ่ง​แล้ว​อย่า​สลับ

  • เขียน​ชื่อ​เต็ม​ลง​ไป​ตรง​ที่ catch แบบ​ที่ load ทำ
  • จับ java.io.IOException ซึ่ง​ครอบ​ทั้ง​สอง​สาย​พันธุ์ แล้ว​แยก​กรณี​ข้าง​ใน

runCatching ย้าย​ความ​ผิด​พลาด​จาก​ทาง​เดิน​พิเศษ​กลับ​มา​เป็น​ค่า​คืน แล้ว​เก็บ​ทุก​อย่าง​ที่​มัน​จับ​ได้​ไว้​ข้าง​ใน

บาง​จุด​ของ​โปรแกรม​ไม่​ต้องการ​ให้​ความ​ผิด​พลาด​กระโดด​ข้าม​ชั้น มัน​แค่​อยาก​ได้​คำ​ตอบ​สอง​ทาง · Kotlin มี ResultResultชนิด​ที่​ห่อ "สำเร็จ​หรือ​พัง" ไว้​เป็น​ค่า ทำให้​ความ​ผิด​พลาด​เดิน​ทาง​แบบ​เดียว​กับ Go ได้ · `runCatching` ที่​สร้าง​มัน​จับ​ถึง `Throwable` จึง​กลืน `OutOfMemoryError` ไป​ด้วย​โดย​ไม่​บอก​ใคร ให้​ตั้งแต่ standard library

src/main/kotlin/orderctl/ResultDemo.kt
package orderctl
fun parseTable(raw: String): Result<Int> = runCatching { raw.toInt() }
fun main() {
println(parseTable("12"))
println(parseTable("สิบสอง"))
println(parseTable("สิบสอง").getOrElse { -1 })
val r: Result<Int> = runCatching { throw StackOverflowError("ลึกเกินไป") }
println("จับ Error ไว้ด้วยไหม: ${r.isFailure}")
}
$ gradle -q run
Success(12)
Failure(java.lang.NumberFormatException: For input string: "สิบสอง")
-1
จับ Error ไว้ด้วยไหม: true

สาม​บรรทัด​แรก​มา​จาก Result ตัว​เดียวกัน​สาม​ท่า · Success พา​ค่า​มา Failure พา Throwable ตัว​ที่​ถูก​จับ​ไว้​มา​ทั้ง​ตัว และ getOrElse แปลง​ฝั่ง​ที่​ล้ม​กลับ​เป็น​ค่า​ธรรมดา

บรรทัด​สุดท้าย​คือ​ราคา​ของ​มัน · เอกสาร​ของ runCatching เขียน​ไว้​ว่า​มัน​จับ

“catching any Throwable exception that was thrown from the block function execution and encapsulating it as a failure”

Throwable แปล​ว่า​รวม Error ด้วย · StackOverflowError จึง​กลาย​เป็น Result ที่​ล้ม​เหลว​ธรรมดา​ตัว​หนึ่ง แทนที่​จะ​พา​โปรแกรม​ลง​ไป

ไม่มี​ใคร​บังคับ​ให้​คุณ​เปิด `Result` ดู · วัด​แล้ว

เขียน​บรรทัด​นี้​ทิ้ง​ไว้​เฉยๆ แล้ว​ส่ง​เข้า compiler ด้วย​ธง​เข้ม​ที่สุด

src/main/kotlin/orderctl/IgnoreDemo.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี ResultDemo อยู่ในโปรเจกต์ด้วย
package orderctl
fun main() {
parseTable("สิบสอง")
println("โปรแกรมเดินต่อ")
}

kotlinc -Werror -Wextra -jvm-target 25 ให้​รหัส​ออก 0 ไม่มี​คำ​เตือน และ​โปรแกรม​พิมพ์ โปรแกรมเดินต่อ ออก​มา​ตาม​ปกติ · ความ​ผิด​พลาด​ถูก​ทิ้ง​ไป​เงียบ​สนิท เท่ากับ​การ​เขียน catch ว่าง​เปล่า

Kotlin มี annotation ที่​ทำให้​ค่า​คืน​ถูก​ทิ้ง​ไม่​ได้​อยู่​จริง ชื่อ @MustUseReturnValues แต่​ที่ 2.4.10 มัน​ยัง​เป็น​ของ​ทดลอง และ​การ​ติด​มัน​ไว้​โดย​ไม่​เปิด​ตัว​ตรวจ​ของ compiler เป็น error ไม่ใช่​การ​ไม่มี​ผล สถานะ​นี้​อาจ​เปลี่ยน​ที่​รุ่น​ถัด​ไป · คอร์ส​นี้​จึง​ไม่​สร้าง​อะไร​บน​มัน

Arrow มี Either ที่​แก้​เรื่อง​นี้​ได้ แต่​คอร์ส​นี้​ไม่​รับ dependency นอก​สำหรับ​โดเมน เหมือน​สอง​คอร์ส​พี่

use ปิด​ทรัพยากร​โดย​ไม่​ทำลาย​ความ​ผิด​พลาด​ตัว​จริง

หัวข้อ​ที่​มีชื่อ​ว่า “use ปิด​ทรัพยากร​โดย​ไม่​ทำลาย​ความ​ผิด​พลาด​ตัว​จริง”

use เก็บ​ความ​ผิด​พลาด​ที่​เกิด​ตอน​ปิด​ไว้​ครบ และ​ตัว​ไหน​กลาย​เป็น​ตัว​หลัก​ขึ้น​อยู่​กับ​ว่า​เนื้อ​ความ​ล้ม​หรือ​ไม่

finally ที่​เรียก close() เอง​มี​บั๊ก​เก่า​แก่​อยู่​ข้อ​หนึ่ง — ถ้า close() โยน มัน​จะ​กลบ​ความ​ผิด​พลาด​ตัว​จริง​ทิ้ง · use functionuse function`use { }` ปิด​ทรัพยากร​ให้ แล้ว​เก็บ​ความ​ผิด​พลาด​ตอน​ปิด​ไว้​ใน suppressed เหมือน try-with-resources ทุก​ข้อ · แต่​รับ​ได้ที​ละ​ตัว หลาย​ตัว​ต้อง​ซ้อน​กันเอง ปิด​รู​นั้น​ให้

เอกสาร​ทางการ​เขียน​ไว้​สั้น​มาก

“Executes the given block function on this resource and then closes it down correctly whether an exception is thrown or not.”

คำ​ว่า correctly ไม่​ได้​บอก​อะไร​เลย​ว่า​ความ​ผิด​พลาด​ตอน​ปิด​ไป​อยู่​ที่ไหน · ข้อ​นี้​ต้อง​วัด​เอง

src/main/kotlin/orderctl/UseDemo.kt
package orderctl
import java.io.Closeable
class Sink(private val name: String) : Closeable {
fun write(line: String) = println("$name รับ $line")
override fun close(): Unit = throw IllegalStateException("close ของ $name ระเบิด")
}
fun report(fail: Boolean) {
try {
Sink("A").use { a ->
Sink("B").use { b ->
a.write("หัวรายงาน")
b.write("ท้ายรายงาน")
if (fail) throw IllegalArgumentException("งานหลักล้มกลางคัน")
}
}
} catch (e: Throwable) {
println("จับได้: ${e::class.simpleName}: ${e.message}")
println("suppressed: ${e.suppressed.size} ตัว")
for (s in e.suppressed) println(" - ${s::class.simpleName}: ${s.message}")
}
}
fun main() {
report(fail = true)
println("---")
report(fail = false)
}
$ gradle -q run
A รับ หัวรายงาน
B รับ ท้ายรายงาน
จับได้: IllegalArgumentException: งานหลักล้มกลางคัน
suppressed: 2 ตัว
- IllegalStateException: close ของ B ระเบิด
- IllegalStateException: close ของ A ระเบิด
---
A รับ หัวรายงาน
B รับ ท้ายรายงาน
จับได้: IllegalStateException: close ของ B ระเบิด
suppressed: 1 ตัว
- IllegalStateException: close ของ A ระเบิด

อ่าน​ผล​สอง​รอบ​นี้​เทียบ​กัน

  • เนื้อ​ความ​ล้ม ความ​ผิด​พลาด​ของ​เนื้อ​ความ​ชนะ และ​ของ close() ทั้ง​สอง​ตัว​ไป​อยู่​ใน suppressed
  • เนื้อ​ความ​สำเร็จ ไม่มี​ตัว​หลัก​ให้​แล้ว ตัว​ของ close() ที่​เกิด​ก่อน​จึง​ขึ้น​มา​เป็น​ตัว​หลัก​แทน
  • ลำดับ​ใน suppressed เรียง​ตาม​ลำดับ​การ​ปิด คือ​ตัว​ใน​สุด​ปิด​ก่อน B จึง​มา​ก่อน A เสมอ

พฤติกรรม​ทั้ง​สาม​ข้อ​ตรง​กับ try-with-resources ของ Java ทุก​ข้อ · ที่​ต่าง​คือ​รูป — use รับ​ทรัพยากร​ได้ที​ละ​ตัว หลาย​ตัว​ต้อง​ซ้อน​กันเอง ขณะ​ที่ Java ประกาศ​เรียง​กัน​ใน​วงเล็บ​เดียว

`use` ปิด​รู​ของ `finally` แต่​ไม่​ได้​ปิด​รู​ของ `exitProcess`

use เป็น inline function ที่​คลี่​เป็น try กับ finally ตอน​แปล · มัน​จึง​อยู่​ใต้​กฎ​เดียว​กับ finally ทุก​ข้อ

บท​ที่ 7 จะ​กลับ​มา​ที่​ข้อ​นี้​อีก​ครั้ง เพราะ exitProcess ข้าม finally ทั้ง​ก้อน และ​แปล​ว่า​มัน​ข้าม use ไป​ด้วย

ทั้ง​สาม​ภาษา​ปล่อย​ให้​ทิ้ง​ความ​ผิด​พลาด​ได้​เงียบๆ เท่า​กัน ต่าง​กัน​แค่​ว่า​ใคร​บังคับ​ให้​พิมพ์​อะไร​ก่อน

orderctl ตัว​เดียวกัน​นี้​ถูก​เขียน​มา​แล้ว​สอง​รอบ ตาราง​นี้​เทียบ​กับ​สิ่ง​ที่​บท​ที่ 6 ของ คอร์ส Go และ คอร์ส Java เขียน​ไว้ · ยกเว้น​แถว​เรื่อง​การ​ปิด​ทรัพยากร​ฝั่ง Go ซึ่ง​บท​ที่ 6 ของ​คอร์ส​นั้น​ไม่​แตะ และ​มา​จาก บท​ที่ 7 ของ​คอร์ส Go แทน

เรื่องGoJavaKotlin
compiler บังคับ​ให้​จัดการ​ไหมไม่ · error เป็น​ค่า​คืน​ที่​ไม่มี​ใคร​บังคับ​ให้​อ่านบังคับ​เฉพาะ checkedไม่​บังคับ​เลย​สัก​ตัว
ตัว​รวม​ความ​ผิด​พลาด​หลาย​ข้อerrors.Join ใน standard libraryไม่มี ต้อง​ประกอบ​เองไม่มี ต้อง​ประกอบ​เอง
ด่าน​ตรวจ​ต้นทาง​ที่​แคบ​ชนิด​ให้ไม่มี ต้อง​เขียน if เองObjects.requireNonNull แคบ​ไม่​ได้require แคบ​ให้
ปิด​ทรัพยากร​แล้ว​เห็น​ความ​ผิด​พลาด​ตอน​ปิดdefer f.Close() ทิ้ง error ของ Closetry-with-resourcesuse ผล​เท่ากับ Java ทุก​ข้อ
กับ​ดัก​เฉพาะ​ตัว​ของ​บท​นี้typed nil ที่ go vet ไม่​จับlambda กับ checked exceptionชื่อ​ซ้ำ​สี่​ชื่อ​ของ kotlin.io

แถว​แรก​คือ​แถว​ที่​คน​เถียง​กัน​มาก​ที่สุด และ​คำ​ตอบ​ไม่​สวย​สำหรับ​ใคร​เลย

Go คืน error เป็น​ค่าที่​ผู้​เรียก​ไม่​อ่าน​ก็ได้ · Java บังคับ​ให้​พิมพ์​คำ​ว่า catch ออก​มา แล้ว​ปล่อย​ให้​วงเล็บ​ข้าง​ใน​ว่าง​เปล่า​ได้ · Kotlin ไม่​ขอ​อะไร​เลย​ตั้งแต่​ต้น

ทั้ง​สาม​ภาษา​ปล่อย​ให้​ทิ้ง​ความ​ผิด​พลาด​ได้ ต่าง​กัน​ที่​จำนวน​ตัว​อักษร​ที่​ต้อง​พิมพ์​ก่อน​ทิ้ง

orderctl ตรวจ​ออ​เด​อร์​ครบ​ทุก​ข้อ​ใน​รอบ​เดียว แปลง​ความ​ผิด​พลาด​เป็น​รหัส​ออก​ได้ และ​ปิด​ทรัพยากร​โดย​ไม่​ทำลาย​หลัก​ฐาน

คุณ​มี Failure ที่ compiler นับ​กิ่ง​ให้ OrderException ที่​พา​รายการ​ข้าม​ชั้น validate ที่​สะสม​ก่อน​โยน และ exitCode ที่​เป็น​ที่​เดียว​ซึ่ง​แปล​ความ​ผิด​พลาด​เป็น​ตัวเลข

สาม​ข้อ​ที่​ควร​ติดตัว​ไป​จาก​บท​นี้

  • compiler ไม่​ช่วย​ตรง​นี้ ด่าน​ทุก​ตัว​เป็น​ของ​ที่​เรา​วาง​เอง
  • ความ​เงียบ​ไม่ใช่​ความ​ปลอดภัย ทั้ง​เรื่อง Serializable และ​เรื่อง Result ที่​ถูก​ทิ้ง
  • ชื่อ​เปล่าๆ ของ​ชนิด​ที่​เกี่ยว​กับ file ต้อง​สงสัย​ไว้​ก่อน kotlin.io มีชื่อ​ชน​กับ java.nio.file อยู่​สี่​ชื่อ

บท​หน้า​เอา exitCode() ไป​ต่อ​กับ main จริง รับ argument จาก​บรรทัด​คำ​สั่ง เขียน state ลง disk แบบ atomic แล้ว​เขียน​เทสต์​ตัว​แรก​ที่​ชื่อ​เป็น​ภาษา​ไทย · และ​จะ​เจอ​ว่า exitProcess ข้าม use ที่​บท​นี้​เพิ่ง​วาง​ไว้


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • Kotlin — Exceptions (เข้า​ถึง 2026-08-28) — “Unchecked exceptions simplify the exception handling process: you can catch exceptions, but you don’t need to explicitly handle or declare them.” และ​คำ​อธิบาย​ว่า @Throws มี​ไว้​เตือน​ผู้​เรียก​จาก​ภาษา​อย่าง Java, Swift และ Objective-C
  • Kotlin standard library — runCatching (เข้า​ถึง 2026-08-28) — “catching any Throwable exception that was thrown from the block function execution and encapsulating it as a failure”
  • Kotlin standard library — use (เข้า​ถึง 2026-08-28) — “Executes the given block function on this resource and then closes it down correctly whether an exception is thrown or not.” หน้า​นี้​ไม่​ได้​พูด​ถึง suppressed exception เลย ผล​ใน​บท​นี้​จึง​มา​จาก​การ​รัน​จริง
  • ผล​ทุก​บรรทัด​ใน​บท​นี้​วัด​เอง​บน kotlinc-jvm 2.4.10 กับ JDK 25 บน macOS arm64 เมื่อ 2026-08-28 · ฝั่ง Java ใช้ javac ของ JDK 25 ตัว​เดียวกัน
  • จุด​เทียบ​สาม​ภาษา​อ่าน​จาก คอร์ส Go บท​ที่ 6 คอร์ส Go บท​ที่ 7 และ คอร์ส Java บท​ที่ 6 โดยตรง

เช็กความเข้าใจ — บทที่ 6

ข้อ 1 / 3

`try { Path.of("no-such.json").readText() } catch (e: NoSuchFileException) { ... }` แปลผ่านทุกธง แต่ตอนรัน exception หลุดออกไปถึงชั้นนอก เพราะอะไร