ความผิดพลาดที่ไม่มีใครบังคับให้คุณจับ
บทที่แล้วต่อกฎส่วนลดเพิ่มโดยไม่แตะของเดิม บทนี้ถามคำถามที่ตามมาทันที — เมื่อออเดอร์ที่เข้ามาผิด ใครเป็นคนบอก และบอกกี่ข้อ
Kotlin ตอบคำถามนี้ด้วยการถอดของออกหนึ่งชิ้น ไม่ใช่เพิ่มเข้ามา
Failure— ชุดปิดของสิ่งที่ผิดได้OrderException— ตัวขนที่พารายการทั้งชุดข้ามชั้นvalidate— ตัวตรวจที่สะสมทุกข้อก่อนโยนexitCode— ที่เดียวที่แปลความผิดพลาดเป็นตัวเลข
โปรเจกต์ Gradle มาตั้งแต่บทที่ 3 แล้ว ทุกอย่างในบทนี้จึงวางลง
src/main/kotlin/orderctl/ แล้วรันด้วย gradle run เหมือนเดิม
Kotlin ถอด checked exception ออกทั้งชั้น
หัวข้อที่มีชื่อว่า “Kotlin ถอด checked exception ออกทั้งชั้น”
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.javaorderctl/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
package orderctl
import java.nio.file.Filesimport 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 ไม่ใช่คำสัญญาเรื่องความร้ายแรง
· ที่นี่คำสั่งนั้นหายไปทั้งคำสั่ง
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
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 run12IllegalArgumentException: ต้องระบุหมายเลขโต๊ะ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 ได้” ตั้งแต่ต้น
Failure — ชุดปิดของสิ่งที่ผิดได้
หัวข้อที่มีชื่อว่า “Failure — ชุดปิดของสิ่งที่ผิดได้”
sealed interfaceทำให้ compiler นับกิ่งให้ครบ และรหัสออกของโปรแกรมผูกอยู่กับชุดกิ่งนั้น
ก่อนจะรายงานความผิดพลาด ต้องมีชื่อเรียกมันก่อน · บทที่ 2 สร้าง sealed interface
ไว้แล้ว บทนี้เอามาใช้กับสิ่งที่ผิดได้
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 ตัวล่าง
// ❌ แปลไม่ผ่าน — เพิ่มกิ่งที่สี่แล้วลืมตามไปแก้ whenpackage 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 |
Failure เป็นชนิดของข้อมูล ไม่ใช่ชนิดของผลลัพธ์ · มันบอกว่าอะไรผิด
ไม่ได้บอกว่าการเรียกครั้งนี้สำเร็จหรือไม่
ResultResultชนิดที่ห่อ "สำเร็จหรือพัง" ไว้เป็นค่า ทำให้ความผิดพลาดเดินทางแบบเดียวกับ Go ได้ · `runCatching` ที่สร้างมันจับถึง `Throwable` จึงกลืน `OutOfMemoryError` ไปด้วยโดยไม่บอกใคร ห่อได้ทีละหนึ่ง แต่ผู้ใช้ที่กรอกผิดสามช่องต้องเห็นทั้งสามช่อง
· หัวข้อถัดไปจึงประกอบตัวขนของขึ้นเอง แล้วค่อยกลับมาที่ Result
ในหัวข้อที่มันเหมาะจริง
OrderException พารายการทั้งชุดข้ามชั้น
หัวข้อที่มีชื่อว่า “OrderException พารายการทั้งชุดข้ามชั้น”ตัวขนของต้องพารายการความผิดพลาดขึ้นไปให้ผู้เรียก โดยที่คนอ่าน log อย่างเดียวยังเห็นมันด้วย
เปิดดูทุก class ใน kotlin-stdlib.jar ที่ชื่อลงท้ายด้วย Exception หรือ Error
ได้สิบห้าตัว และไม่มีตัวไหนรวมความผิดพลาดหลายข้อเข้าด้วยกันเลย
· คนที่มาจาก Go จะหา errors.Join แล้วไม่เจอ เหมือนที่คอร์ส Java หาไม่เจอ
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี 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
Throwable ทุกตัวเป็น Serializable · คอร์ส Java จึงเจอคำเตือนสองข้อจาก
-Xlint:serial ทันทีที่เขียน class แบบนี้ คือไม่ได้ประกาศ serialVersionUID
และประกาศชนิดของ field เป็น List ที่ไม่ได้สืบสาย Serializable
OrderException ข้างบนไม่มีทั้งสองอย่าง และ kotlinc -Werror -Wextra
ให้รหัสออก 0 โดยไม่พิมพ์อะไรเลย
อย่าอ่านความเงียบนี้ว่าเป็นความปลอดภัย ปัญหาเดิมยังอยู่ครบ สิ่งที่หายไปคือคนบอก · ฝั่ง Java มีธงที่เปิดแล้วเห็น ส่วนฝั่งนี้ไม่มีธงตัวนั้น
validate สะสมทุกข้อ แล้วโยนครั้งเดียว
หัวข้อที่มีชื่อว่า “validate สะสมทุกข้อ แล้วโยนครั้งเดียว”ตัวตรวจเก็บความผิดพลาดทั้งหมดไว้ในรายการก่อน แล้วค่อยตัดสินใจตอนจบว่าจะโยนไหม
ผู้ใช้ที่กรอกผิดสามช่องควรเห็นทั้งสามช่องในรอบเดียว ไม่ใช่แก้ทีละรอบ
· buildList ทำให้การสะสมอ่านเป็นก้อนเดียวจบ
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Failure, OrderException และ Line อยู่ในโปรเจกต์ด้วยpackage orderctl
import java.nio.file.Pathimport 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) }ชั้นนอกสุดจับตัวเดียว แล้วกางรายการออกมาเอง
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี 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 → รหัสออก 4cause: 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 เป็นที่เดียวที่แปลความผิดพลาดเป็นตัวเลข
กับดักชื่อซ้ำของ kotlin.io
หัวข้อที่มีชื่อว่า “กับดักชื่อซ้ำของ kotlin.io”
catchที่เขียนถูกตามสายตา แปลผ่านทุกธง แล้วไม่เคยทำงานสักครั้ง
load ข้างบนเขียนชื่อเต็มว่า java.nio.file.NoSuchFileException ซึ่งดูเยิ่นเย้อ
· ลองตัดให้สั้นตามสัญชาตญาณดู
package orderctl
import java.nio.file.Pathimport 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.NoSuchFileExceptioncatch ชั้นในไม่ทำงาน · เพราะชื่อเปล่าๆ ตัวนั้นไม่ใช่ชนิดที่ readText() โยน
kotlin.io เป็น default import ของทุก file · ชื่อ NoSuchFileException ที่ไม่มี
package นำหน้าจึงหมายถึงตัวของ kotlin.io เสมอ และมันเป็นคนละสายพันธุ์กับตัวของ
java.nio.file โดยสิ้นเชิง
| ชื่อเปล่า | ชนิดที่ Kotlin เลือกให้ | class แม่ | ชนิดที่ java.nio.file มีชื่อเดียวกัน |
|---|---|---|---|
NoSuchFileException | kotlin.io.NoSuchFileException | kotlin.io.FileSystemException | java.nio.file.NoSuchFileException |
FileAlreadyExistsException | kotlin.io.FileAlreadyExistsException | kotlin.io.FileSystemException | java.nio.file.FileAlreadyExistsException |
AccessDeniedException | kotlin.io.AccessDeniedException | kotlin.io.FileSystemException | java.nio.file.AccessDeniedException |
FileSystemException | kotlin.io.FileSystemException | java.io.IOException | java.nio.file.FileSystemException |
สี่ชื่อ ไม่ใช่ชื่อเดียว และไม่มีคู่ไหนเป็นบรรพบุรุษของอีกตัว
· จุดที่ทั้งสองสายมาบรรจบกันอยู่สูงถึง java.io.IOException
ชนิดตระกูลนั้นมีคนโยนจริง คือชุด extension function ที่ทำงานบน java.io.File
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 runcopyTo โยน: kotlin.io.NoSuchFileExceptionmessage: no-such.json: The source file doesn't exist.เส้นแบ่งจึงอยู่ที่ API ที่คุณเรียก ไม่ใช่ที่ชื่อของชนิด
· File.copyTo ให้ตัวของ kotlin.io ส่วน Path.readText() ให้ตัวของ java.nio.file
ทางออกมีสองทาง เลือกอย่างใดอย่างหนึ่งแล้วอย่าสลับ
- เขียนชื่อเต็มลงไปตรงที่
catchแบบที่loadทำ - จับ
java.io.IOExceptionซึ่งครอบทั้งสองสายพันธุ์ แล้วแยกกรณีข้างใน
แปลงข้อยกเว้นกลับเป็นค่าด้วย Result
หัวข้อที่มีชื่อว่า “แปลงข้อยกเว้นกลับเป็นค่าด้วย Result”
runCatchingย้ายความผิดพลาดจากทางเดินพิเศษกลับมาเป็นค่าคืน แล้วเก็บทุกอย่างที่มันจับได้ไว้ข้างใน
บางจุดของโปรแกรมไม่ต้องการให้ความผิดพลาดกระโดดข้ามชั้น มันแค่อยากได้คำตอบสองทาง · Kotlin มี ResultResultชนิดที่ห่อ "สำเร็จหรือพัง" ไว้เป็นค่า ทำให้ความผิดพลาดเดินทางแบบเดียวกับ Go ได้ · `runCatching` ที่สร้างมันจับถึง `Throwable` จึงกลืน `OutOfMemoryError` ไปด้วยโดยไม่บอกใคร ให้ตั้งแต่ standard library
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 runSuccess(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 ที่ล้มเหลวธรรมดาตัวหนึ่ง แทนที่จะพาโปรแกรมลงไป
เขียนบรรทัดนี้ทิ้งไว้เฉยๆ แล้วส่งเข้า compiler ด้วยธงเข้มที่สุด
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี 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 ไม่ได้บอกอะไรเลยว่าความผิดพลาดตอนปิดไปอยู่ที่ไหน · ข้อนี้ต้องวัดเอง
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 runA รับ หัวรายงาน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 เป็น inline function ที่คลี่เป็น try กับ finally ตอนแปล
· มันจึงอยู่ใต้กฎเดียวกับ finally ทุกข้อ
บทที่ 7 จะกลับมาที่ข้อนี้อีกครั้ง เพราะ exitProcess ข้าม finally ทั้งก้อน
และแปลว่ามันข้าม use ไปด้วย
เทียบสามภาษาที่บรรทัดเดียวกัน
หัวข้อที่มีชื่อว่า “เทียบสามภาษาที่บรรทัดเดียวกัน”ทั้งสามภาษาปล่อยให้ทิ้งความผิดพลาดได้เงียบๆ เท่ากัน ต่างกันแค่ว่าใครบังคับให้พิมพ์อะไรก่อน
orderctl ตัวเดียวกันนี้ถูกเขียนมาแล้วสองรอบ ตารางนี้เทียบกับสิ่งที่บทที่ 6
ของ คอร์ส Go และ
คอร์ส Java เขียนไว้
· ยกเว้นแถวเรื่องการปิดทรัพยากรฝั่ง Go ซึ่งบทที่ 6 ของคอร์สนั้นไม่แตะ
และมาจาก บทที่ 7 ของคอร์ส Go แทน
| เรื่อง | Go | Java | Kotlin |
|---|---|---|---|
| compiler บังคับให้จัดการไหม | ไม่ · error เป็นค่าคืนที่ไม่มีใครบังคับให้อ่าน | บังคับเฉพาะ checked | ไม่บังคับเลยสักตัว |
| ตัวรวมความผิดพลาดหลายข้อ | errors.Join ใน standard library | ไม่มี ต้องประกอบเอง | ไม่มี ต้องประกอบเอง |
| ด่านตรวจต้นทางที่แคบชนิดให้ | ไม่มี ต้องเขียน if เอง | Objects.requireNonNull แคบไม่ได้ | require แคบให้ |
| ปิดทรัพยากรแล้วเห็นความผิดพลาดตอนปิด | defer f.Close() ทิ้ง error ของ Close | try-with-resources | use ผลเท่ากับ 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 หลุดออกไปถึงชั้นนอก เพราะอะไร