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

ความ​ผิด​พลาด — exception ที่​กระโดด เทียบ error ที่​เป็น​ค่า

บท​ที่ 5 ทำให้ orderctl ประกอบ​กฎ​ส่วนลด​ได้ แต่​มัน​ยัง​เชื่อ​ทุก​อย่าง​ที่​อ่าน​เข้า​มา

บท​นี้​เติม​ชั้น​ที่​ขาด — ตัว​ตรวจ​ที่​บอก​ว่า ผิด​กี่​ข้อ ข้อ​ไหน​บ้าง ไม่ใช่​หยุด​ที่​ข้อ​แรก​แล้ว​เงียบ

📦 orderctl ได้​อะไร​เพิ่ม​ใน​บท​นี้
  • Failure แบบ sealed — ชุด​ปิด​ของ​สิ่ง​ที่​ผิด​ได้ พร้อม exit code ประจำ​กรณี
  • OrderException ที่​ถือ​ทุก​ข้อ​ที่​ผิด​ไว้ และ​เก็บ​ซ้ำ​ลง getSuppressed()
  • Orders.validate ที่​รายงาน​ครบ​ใน​รอบ​เดียว
  • สำนวน​การ​ห่อ​พร้อม​บริบท ที่​ทำให้ Caused by: มี​ของ​จริง​อยู่​ข้าง​ใน

เทสต์​ตัว​แรก​ของ​คอร์ส​ยัง​ไม่​อยู่​ใน​บท​นี้ · JUnit ต้อง​รอ​ถึง​บท​ที่ 7

checked กับ unchecked แยก​กัน​ที่​สาย​พันธุ์ ไม่ใช่​ที่​ความ​ร้าย​แรง

หัวข้อ​ที่​มีชื่อ​ว่า “checked กับ unchecked แยก​กัน​ที่​สาย​พันธุ์ ไม่ใช่​ที่​ความ​ร้าย​แรง”

สเปก​นิยาม​สอง​กลุ่ม​นี้​ด้วย​ตำแหน่ง​ใน class hierarchy แล้ว​อธิบาย​เหตุผล​ของ​การ​ยกเว้น​ไว้​ต่าง​หาก

คน​ส่วน​ใหญ่​จำ​เส้น​แบ่ง​นี้​ผิด​เป็น “ร้าย​แรง​กับ​ไม่​ร้าย​แรง” ซึ่ง​อธิบาย​อะไร​ไม่​ได้​เลย

JLS §11.1.1 เขียน​ไว้​ตรงๆ ว่า “The unchecked exception classes are the run-time exception classes and the error classes. The checked exception classes are all exception classes other than the unchecked exception classes.”

flowchart TD
  T["Throwable"] --> E["Exception"]
  T --> ER["Error<br/>โปรแกรมทั่วไปไม่ต้องกู้คืน"]
  E --> RE["RuntimeException<br/>unchecked · ไม่ต้องประกาศ"]
  E --> CE["Exception ตัวอื่น<br/>checked · ต้องประกาศหรือจับ"]
  RE --> NPE["NullPointerException"]
  RE --> IAE["IllegalArgumentException"]
  CE --> IOE["IOException"]
  IOE --> NSF["NoSuchFileException"]
  ER --> OOM["OutOfMemoryError"]

คำ​บรรยาย​ภาพ: เส้น​แบ่ง​ลาก​ตาม​สาย​พันธุ์ ไม่ใช่​ตาม​ความ​ร้าย​แรง · RuntimeException กับ Error อยู่​ฝั่ง unchecked ส่วน​ที่​เหลือ​ทั้งหมด​เป็น checked

checked exceptionchecked exceptionความ​ผิด​พลาด​ที่ compiler บังคับ​ให้​ประกาศ​หรือ​จับ · `throws` เป็น​สัญญา​ส่วน​หนึ่ง​ของ API แต่​ข้าม​เข้าไป​ใน lambda ไม่​ได้ จึง​ไม่ใช่​คำ​สัญญา​ว่า​เรื่อง​นี้​ร้าย​แรง​กว่า มัน​คือ​คำ​สั่ง​ของ compiler ที่​บังคับ​ให้​คุณ​เขียน​อะไร​สัก​อย่าง​ออก​มา

package orderctl;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class RawLoader {
private RawLoader() {}
// checked · ต้องประกาศ throws ออกมา ไม่งั้น javac ปฏิเสธตั้งแต่ตอนแปล
public static String read(Path p) throws IOException {
return Files.readString(p);
}
// unchecked · โยนได้โดยไม่ต้องประกาศอะไรเลย ลายเซ็นไม่บอกอะไรผู้เรียก
public static int table(String raw) {
return Integer.parseInt(raw);
}
}

ลบ throws IOException ออก​จาก read แล้ว​สั่ง​แปล จะ​ได้ error ตัว​แรก​ที่​ผู้​อ่าน​คอร์ส​นี้​ต้อง​เจอ

RawLoader.java:13: error: unreported exception IOException; must be caught or declared to be thrown
return Files.readString(p);
^
1 error

table โยน NumberFormatException ได้​ทุก​วัน แต่​ลาย​เซ็น​ของ​มัน​เงียบ​สนิท เพราะ NumberFormatException สืบสาย RuntimeException

เหตุผล​ของ​การ​ยกเว้น อยู่​ใน §11.2 ไม่ใช่ §11.1.1

สเปก​ให้​เหตุผล​ของ​สอง​กลุ่ม​แยก​กัน · ฝั่ง Error เขียน​ว่า​โปรแกรม​ที่​ประกาศ​มัน​จะ​รก โดย​ไม่​ได้​อะไร​กลับ​มา

ส่วน​ฝั่ง run-time exception เขียน​ว่า “Run-time exception classes are exempted because, in the judgment of the designers of the Java programming language, having to declare such exceptions would not aid significantly in establishing the correctness of programs.”

แล้ว​ต่อ​ด้วย​เหตุผล​ชั้น​ที่​สอง คือ​ข้อมูล​และ​ระดับ​การ​วิเคราะห์​ที่ compiler มี ยัง​ไม่​พอ​จะ​พิสูจน์​ว่า ความ​ผิด​พลาด​พวก​นี้​เกิด​ไม่​ได้

constructor ของ record ประกาศ throws ไม่​ได้​ทั้ง​สอง​รูป การ​ตรวจ​ของ​โดเมน​จึง​เหลือ​ทาง​เดียว

Line ที่​บท​ก่อน​หน้า​ส่ง​ต่อ​มา​ตรวจ​ตัว​เอง​ด้วย compact constructor · การ​ตรวจ​นั้น​ล้ม​ด้วย unchecked เสมอ และ​ไม่ใช่​เพราะ​ผู้​เขียน​เลือก​แบบ​นั้น

package orderctl;
import java.util.Locale;
import java.util.Objects;
public record Line(String sku, String category, int qty, long unitSatang) {
public Line {
Objects.requireNonNull(sku, "sku");
Objects.requireNonNull(category, "category");
if (qty <= 0) {
throw new IllegalArgumentException("qty ต้องมากกว่า 0 แต่ได้ " + qty);
}
if (unitSatang < 0) {
throw new IllegalArgumentException("ราคาติดลบไม่ได้ แต่ได้ " + unitSatang);
}
sku = sku.trim().toUpperCase(Locale.ROOT);
}
public long subtotalSatang() {
return qty * unitSatang;
}
}

เติม import java.io.IOException; ที่​หัว file แล้ว​เติม throws IOException ที่ constructor · ทั้ง​สอง​รูป​ตอบ​กลับ​เหมือน​กัน และ constructor ทั้ง​สอง​รูป​อยู่​ที่​บรรทัด 9 ของ file นั้น

Line.java:9: error: invalid compact constructor in record Line
public Line throws IOException {
^
(throws clause not allowed for compact constructor)
Line.java:9: error: invalid canonical constructor in record Line
public Line(String sku, String category, int qty, long unitSatang) throws IOException {
^
(throws clause not allowed for canonical constructor)

รูป canonical ต้อง​รับ component ครบ​ทั้ง​สี่​ตัว​จึง​จะ​นับ​เป็น canonical · constructor ที่​รับ​ไม่​ครบ เป็น constructor ธรรมดา ประกาศ throws ได้​ตาม​ปกติ และ​แปล​ผ่าน

นี่​คือ​ข้อ​จำกัด​ของ​ภาษา ไม่ใช่​รสนิยม · เมื่อ​ชนิด​ข้อมูล​ของ​โดเมน​เป็น record ทั้ง​ชุด ความ​ผิด​พลาด​ของ​การ​ตรวจ​ก็​ต้อง​เป็น unchecked ทั้ง​ชุด​ตาม​ไป​ด้วย

JDK ไม่มี​ชนิด​สำหรับ​รวม​ความ​ผิด​พลาด​หลาย​ข้อ บท​นี้​จึง​ประกอบ​ขึ้น​เอง​จาก sealed interface กับ exception ของ​ตัว​เอง

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

JDK ไม่มี​ชนิด​กลาง​สำหรับ​รวม​ความ​ผิด​พลาด​หลาย​ข้อ · คน​ที่มา​จาก C# จะหา AggregateException แล้ว​ไม่​เจอ ส่วน​คน​ที่มา​จาก Go จะหา errors.Join แล้ว​ไม่​เจอ​เหมือน​กัน

ขั้น​แรก​คือ​ชุด​ปิด​ของ​สิ่ง​ที่​ผิด​ได้ · switch เหนือ sealed type ไม่​ต้อง​มี default และ​เพิ่ม​กรณี​ที่​สี่​เมื่อไร compiler จะ​ชี้​ทุก​จุด​ที่​ต้อง​ตาม​ไป​แก้

package orderctl;
import java.io.Serializable;
public sealed interface Failure extends Serializable {
record BadField(String field, String value, String reason) implements Failure {}
record NoLines(String orderId) implements Failure {}
record Io(String path, String reason) implements Failure {}
static int exitCode(Failure f) {
return switch (f) {
case BadField ignored -> 2;
case NoLines ignored -> 2;
case Io ignored -> 4;
};
}
static String describe(Failure f) {
return switch (f) {
case BadField(String field, String value, String reason) ->
"field " + field + " = \"" + value + "\": " + reason;
case NoLines(String orderId) -> "ออเดอร์ " + orderId + " ไม่มีรายการ";
case Io(String path, String reason) -> "อ่าน " + path + " ไม่ได้: " + reason;
};
}
}

ขั้น​ที่​สอง​คือ exception ที่​พา​รายการ​นั้น​ข้าม​ชั้น​ไป​ให้​ผู้​เรียก

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// src/main/java/orderctl/OrderException.java — ต้องมี Failure.java อยู่ข้างกันจึงจะแปลผ่าน
package orderctl;
import java.util.ArrayList;
import java.util.List;
public final class OrderException extends RuntimeException {
private static final long serialVersionUID = 1L;
// ประกาศเป็น ArrayList ไม่ใช่ List เพื่อให้ -Xlint:serial เงียบ
private final ArrayList<Failure> failures;
private OrderException(String message, List<Failure> failures, Throwable cause) {
super(message, cause);
this.failures = new ArrayList<>(failures);
}
public static OrderException of(String message, List<Failure> failures) {
var e = new OrderException(message, failures, null);
for (Failure f : failures) {
e.addSuppressed(new IllegalArgumentException(Failure.describe(f)));
}
return e;
}
public static OrderException wrapping(String message, Failure f, Throwable cause) {
return new OrderException(message, List.of(f), cause);
}
public List<Failure> failures() {
return List.copyOf(failures);
}
}

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

สอง​บรรทัด​นี้​คือ​สิ่ง​ที่ `-Xlint:serial` เตือน

Throwable เป็น Serializable ทุก​ตัว ด่าน​ของ​คอร์ส​นี้​จึง​เห็น​สอง​ข้อ​ทันที​ที่​คุณ​เขียน​ลวกๆ

ถอด​บรรทัด serialVersionUID กับ​คอม​เมน​ต์​ที่​อยู่​เหนือ field ออก แล้ว​เปลี่ยน​ชนิด​ของ field เป็น List<Failure> จะ​ได้

OrderException.java:6: warning: [serial] serializable class OrderException has no definition of serialVersionUID
public final class OrderException extends RuntimeException {
^
OrderException.java:8: warning: [serial] non-transient instance field of a serializable class declared with a non-serializable type
private final List<Failure> failures;
^
2 warnings

ข้อ​ที่​สอง​อ่าน​สับสน​ที่สุด · List เป็น interface ที่​ไม่​ได้​สืบสาย Serializable ส่วน ArrayList สืบ · การ​ประกาศ​ชนิด​ของ field จึง​เปลี่ยน​ผล​ของ​ด่าน​นี้ ทั้ง​ที่​ค่า​จริง​ตัว​เดียวกัน

ขั้น​ที่​สาม​คือ​ตัว​ตรวจ ซึ่ง​สะสม​ทุก​ข้อ​ก่อน แล้ว​ค่อย​โยน​ครั้ง​เดียว​ตอน​จบ

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// src/main/java/orderctl/Orders.java — ต้องมี Failure.java, OrderException.java และ Line.java อยู่ข้างกัน
package orderctl;
import java.util.ArrayList;
import java.util.List;
public final class Orders {
private Orders() {}
public static void validate(String orderId, int table, List<Line> lines) {
var failures = new ArrayList<Failure>();
if (table < 1 || table > 40) {
failures.add(new Failure.BadField("table", String.valueOf(table),
"ต้องอยู่ระหว่าง 1 ถึง 40"));
}
if (lines.isEmpty()) {
failures.add(new Failure.NoLines(orderId));
}
if (!failures.isEmpty()) {
throw OrderException.of("ออเดอร์ " + orderId + " ไม่ผ่านการตรวจ "
+ failures.size() + " ข้อ", failures);
}
}
public static void main(String[] args) {
try {
validate("A-1", 99, List.of());
} catch (OrderException e) {
System.out.println(e.getMessage());
for (Failure f : e.failures()) {
System.out.println(" [" + Failure.exitCode(f) + "] " + Failure.describe(f));
}
}
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Orders
ออเดอร์ A-1 ไม่ผ่านการตรวจ 2 ข้อ
[2] field table = "99": ต้องอยู่ระหว่าง 1 ถึง 40
[2] ออเดอร์ A-1 ไม่มีรายการ

constructor ที่​รับ​สอง argument ทำให้​ได้ Caused by: มา​ฟรี ส่วน​การ​ค้นหา​ชนิด​ใน​สาย​นั้น​ต้อง​เขียน​ลูป​เอง

NoSuchFileException.getMessage() คืน​แค่ path เปล่าๆ ไม่มี​คำ​กริยา​และ​ไม่มี​บริบท · การ​ห่อ​จึง​ไม่ใช่​ของ​ประดับ

package orderctl;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class WrapDemo {
private WrapDemo() {}
static String load(Path p) {
try {
return Files.readString(p);
} catch (IOException e) {
// สอง argument เสมอ · แบบ argument เดียวเอา toString() ของ cause มาเป็นข้อความ
throw new IllegalStateException("อ่านออเดอร์จาก " + p + " ไม่ได้", e);
}
}
static Throwable rootCause(Throwable t) {
Throwable cur = t;
while (cur.getCause() != null && cur.getCause() != cur) {
cur = cur.getCause();
}
return cur;
}
public static void main(String[] args) {
try {
load(Path.of("dinner.json"));
} catch (IllegalStateException e) {
System.out.println("ชั้นนอก : " + e.getMessage());
System.out.println("cause : " + e.getCause());
System.out.println("ข้อความของราก : " + rootCause(e).getMessage());
e.printStackTrace();
}
}
}
$ mvn -q compile
$ java -cp target/classes orderctl.WrapDemo
ชั้นนอก : อ่านออเดอร์จาก dinner.json ไม่ได้
cause : java.nio.file.NoSuchFileException: dinner.json
ข้อความของราก : dinner.json
java.lang.IllegalStateException: อ่านออเดอร์จาก dinner.json ไม่ได้
at orderctl.WrapDemo.load(WrapDemo.java:16)
at orderctl.WrapDemo.main(WrapDemo.java:30)
Caused by: java.nio.file.NoSuchFileException: dinner.json
at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
at java.base/sun.nio.fs.UnixFileSystemProvider.newFileChannel(UnixFileSystemProvider.java:213)
at java.base/sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:244)
at java.base/java.nio.file.Files.newByteChannel(Files.java:357)
at java.base/java.nio.file.Files.newByteChannel(Files.java:399)
at java.base/java.nio.file.Files.readAllBytes(Files.java:2973)
at java.base/java.nio.file.Files.readString(Files.java:3043)
at java.base/java.nio.file.Files.readString(Files.java:3006)
at orderctl.WrapDemo.load(WrapDemo.java:13)
... 1 more

บรรทัด ... 1 more คือ​เฟรม​ที่​ซ้ำ​กับ​สาย​ข้าง​บน ซึ่ง printStackTrace ตัด​ให้​เอง ไม่ใช่​บรรทัด​ที่​หาย​ไป

หัวข้อ​นี้​รัน​สอง​คำ​สั่ง ไม่ใช่ `exec:java`

exec:java เรียก main ผ่าน reflection ใน​กระบวนการ​เดียว​กับ Maven · stack trace จึง​ต่อ​ท้าย​ด้วย​เฟรม​ของ Maven อีก​เจ็ด​บรรทัด และ​บรรทัด​สุดท้าย​กลาย​เป็น ... 8 more

ตัวเลข​หลัง ... คือ​จำนวน​เฟรม​ที่​ซ้ำ มัน​จึง​เปลี่ยน​ตาม​วิธี​รัน · หัวข้อ​นี้​แยก mvn -q compile กับ java -cp target/classes ออก​จาก​กัน เพื่อ​ให้​สาย​ที่​เห็น​เป็น​ของ orderctl ล้วน

rootCause เป็น​ลูป​สิบ​บรรทัด​ที่​คุณ​ต้อง​เขียน​เอง เพราะ Java ไม่มี​ตัว​เดิน​สาย​การ​ห่อ​มา​ให้ · catch เห็น​เฉพาะ​ชนิด​ของ​ตัว​นอก​สุด​เท่านั้น

constructor ที่​รับ cause แล้ว จะ​เรียก `initCause` ทีหลัง​ไม่​ได้

สำนวน raise X from e ของ Python ตรง​กับ constructor ที่​รับ​สอง argument ของ Java

กับ​ดัก​ที่​ซ้อน​อยู่​คือ ส่ง null เป็น cause ก็​ยัง​นับ​ว่า​ตั้ง​ไป​แล้ว · เรียก initCause ตาม​หลัง​จะ​ได้

java.lang.IllegalStateException: Can't overwrite cause with java.lang.IllegalStateException: later

OrderException.of ข้าง​บน​จึง​เรียก addSuppressed ไม่ใช่ initCause และ​เปิด​ทาง cause จริง ไว้​ที่ wrapping แทน

catch ไม่​รับ pattern และ​ไม่มี guard สำนวน​ที่​ใช้ได้​คือ​จับ class แม่​แล้ว​แยก​กรณี​ด้วย switch ข้าง​ใน

คน​ที่มา​จาก C# จะ​พิมพ์ catch (Bad b when ...) โดย​อัตโนมัติ · javac ไม่​ได้​ตอบ​ว่า​ไม่​รองรับ มัน​ตอบ​ว่า​อ่าน​ไวยากรณ์​ไม่​ออก

src/main/java/orderctl/CatchBad.java
// ❌ แปลไม่ผ่าน — ตั้งใจ · ไวยากรณ์นี้ไม่มีใน Java
package orderctl;
public final class CatchBad {
private CatchBad() {}
static int table(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e when e.getMessage().isEmpty()) {
return 0;
}
}
}
CatchBad.java:12: error: ')' expected
} catch (NumberFormatException e when e.getMessage().isEmpty()) {
^

javac คาย​ทั้งหมด​สี่ error ต่อ​เนื่อง​กัน​จาก​บรรทัด​เดียว​นี้ ข้าง​บน​คือ​ข้อ​แรก · สาม​ข้อ​ที่​เหลือ​เป็น​ผล​พวง​ของ​การ​ที่​มัน​หลุด​จาก​ไวยากรณ์​ของ catch ไป​แล้ว

ทาง​ที่​ใช้ได้​คือ​ย้าย​การ​แยก​กรณี​ออก​จาก catch ไป​ไว้​ใน switch ซึ่ง​รับ pattern เต็ม​รูป

package orderctl;
import java.util.List;
public final class CatchDemo {
private CatchDemo() {}
static int table(String raw) {
int t = Integer.parseInt(raw);
if (t < 1 || t > 40) {
throw new IllegalArgumentException("โต๊ะ " + t + " อยู่นอกช่วง 1 ถึง 40");
}
return t;
}
// catch จับได้แค่ชนิดของตัวนอกสุด · การแยกกรณีที่ละเอียดกว่านั้นทำใน switch
static String explain(RuntimeException e) {
return switch (e) {
case NumberFormatException n -> "ไม่ใช่ตัวเลข: " + n.getMessage();
case IllegalArgumentException a -> "ค่าอยู่นอกช่วง: " + a.getMessage();
default -> "ไม่รู้จัก: " + e.getClass().getSimpleName();
};
}
public static void main(String[] args) {
for (String raw : List.of("12", "โต๊ะ", "99")) {
try {
System.out.println("รับออเดอร์ที่โต๊ะ " + table(raw));
} catch (RuntimeException e) {
System.out.println(explain(e));
}
}
try {
List.<String>of().getFirst();
} catch (RuntimeException e) {
System.out.println(explain(e));
}
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.CatchDemo
รับออเดอร์ที่โต๊ะ 12
ไม่ใช่ตัวเลข: For input string: "โต๊ะ"
ค่าอยู่นอกช่วง: โต๊ะ 99 อยู่นอกช่วง 1 ถึง 40
ไม่รู้จัก: NoSuchElementException

ลำดับ​ของ case เป็น​เรื่อง​เป็น​ราว · NumberFormatException สืบสาย IllegalArgumentException ถ้า​สลับ​สอง​บรรทัด​นั้น compiler จะ​ปฏิเสธ​เพราะ​กรณี​หลัง​เข้า​ไม่​ถึง

ทรัพยากร​ถูก​ปิด​ย้อน​ลำดับ​การ​ประกาศ และ​ความ​ผิด​พลาด​ของ close ถูก​เก็บ​ไว้​แทนที่​จะ​กลบ​ตัว​จริง

try-with-resourcestry-with-resourcesรูป​ที่​ประกาศ​ทรัพยากร​ใน​วงเล็บ​แล้ว​ปิด​ให้​เอง​ย้อน​ลำดับ​การ​ประกาศ · ความ​ผิด​พลาด​ของ​เนื้อ​ความ​ชนะ​เสมอ ตัว​ที่​เกิด​ตอน​ปิด​ไม่​กลบ​มัน ไม่​ได้​แค่​ประหยัด​บรรทัด มัน​เปลี่ยน​ว่า​ความ​ผิด​พลาด​ตัว​ไหน​ไป​ถึง​ผู้​เรียก

package orderctl;
public final class CloseDemo {
private CloseDemo() {}
static final class Ledger implements AutoCloseable {
private final String name;
Ledger(String name) {
this.name = name;
System.out.println("เปิด " + name);
}
void write(String s) {
System.out.println(name + " เขียน " + s);
}
@Override
public void close() {
System.out.println("ปิด " + name);
throw new IllegalStateException("flush ของ " + name + " ไม่สำเร็จ");
}
}
public static void main(String[] args) {
try (Ledger first = new Ledger("first"); Ledger second = new Ledger("second")) {
first.write("A-1");
second.write("A-2");
throw new IllegalStateException("งานหลักล้มกลางคัน");
} catch (IllegalStateException e) {
System.out.println("ตัวจริง : " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("ถูกเก็บไว้: " + s.getMessage());
}
}
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.CloseDemo
เปิด first
เปิด second
first เขียน A-1
second เขียน A-2
ปิด second
ปิด first
ตัวจริง : งานหลักล้มกลางคัน
ถูกเก็บไว้: flush ของ second ไม่สำเร็จ
ถูกเก็บไว้: flush ของ first ไม่สำเร็จ

สอง​อย่าง​ที่​ต้อง​อ่าน​จาก​ผล​นี้​ให้​ครบ​มี​ดังนี้

  • ลำดับ​การ​ปิด​กลับ​หลัง second ปิด​ก่อน first เสมอ ไม่ใช่​ตาม​ลำดับ​ที่​เขียน
  • ตัว​จริง​ชนะ ความ​ผิด​พลาด​ของ​เนื้อ​ความ​ยัง​เป็น​ตัว​ที่​ผู้​เรียก​ได้​รับ ส่วน​อีก​สอง​ตัว​กลาย​เป็น suppressed exceptionsuppressed exceptionความ​ผิด​พลาด​ตอน​ปิด​ทรัพยากร​ที่​ถูก​เก็บ​ไว้​ใน `getSuppressed()` แทนที่​จะ​หาย​ไป · สำนวน​ก่อน Java 7 ทำลาย​ตัว​จริง​ทิ้ง​ทั้ง​ดุ้น ที่​ยัง​อ่าน​ได้​ครบ

ทรัพยากร​ที่​มี​ตัวแปร​รอ​อยู่​แล้ว ไม่​ต้อง​ประกาศ​ตัวแปร​ใหม่​ใน​วงเล็บ · JEP 213 เปิด​ทาง​ให้​ตัวแปร ที่​เป็น final หรือ effectively final วาง​ใน​วงเล็บ​ได้​ตรงๆ ตั้งแต่ JDK 9

package orderctl;
public final class OldStyleDemo {
private OldStyleDemo() {}
static final class Ledger implements AutoCloseable {
@Override
public void close() {
throw new IllegalStateException("flush ไม่สำเร็จ");
}
}
public static void main(String[] args) {
// ❌ แบบดิบ ห้ามลอกไปใช้ — สำนวนก่อน Java 7 ที่ยังค้นเจอได้ทั่วอินเทอร์เน็ต
try {
Ledger ledger = new Ledger();
try {
throw new IllegalStateException("งานหลักล้มกลางคัน");
} finally {
ledger.close();
}
} catch (IllegalStateException e) {
System.out.println("ผู้เรียกได้รับ : " + e.getMessage());
System.out.println("จำนวนที่เก็บไว้ : " + e.getSuppressed().length);
}
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.OldStyleDemo
ผู้เรียกได้รับ : flush ไม่สำเร็จ
จำนวนที่เก็บไว้ : 0

“งาน​หลัก​ล้ม​กลาง​คัน” หาย​ไป​จาก​โลก​นี้​แล้ว และ​ไม่มี​อะไร​บันทึก​ไว้​ว่า​มัน​เคย​มี​อยู่ · นี่​คือ​เหตุผล​ที่​ตัวอย่าง​เก่า​ที่​ค้น​เจอ ไม่​ควร​คัด​ลอก​มา​ทั้ง​ดุ้น

src/main/java/orderctl/OldFinally.java
// ❌ แปลไม่ผ่าน — ตั้งใจ · ด่านของคอร์สถือว่าคำเตือนคือความล้มเหลว
package orderctl;
public final class OldFinally {
private OldFinally() {}
public static void run() {
try {
throw new IllegalStateException("งานหลักล้มกลางคัน");
} finally {
return;
}
}
}

javac ยอม​แปล​ให้ และ​คาย​คำ​เตือน​ออก​มา​ก็​ต่อ​เมื่อ​คุณ​สั่ง -Xlint:finally เท่านั้น · คำ​เตือน​ชี้​ที่​ปีกกา​ปิด​ของ finally ไม่ใช่​ที่​บรรทัด return

OldFinally.java:14: warning: [finally] finally clause cannot complete normally
}
^
1 warning

ด่าน​ของ​คอร์ส​นี้​เปิด -Xlint:all และ​ถือว่า​คำ​เตือน​คือ​ความ​ล้ม​เหลว จึง​จับ​ข้อ​นี้​ให้​ตั้งแต่​ตอน​แปล · โปร​เจ​กต์​ที่​ไม่​ได้​ตั้ง​ค่า​ไว้​แบบ​นั้น จะ​ไม่มี​ใคร​เห็น​อะไร​เลย

เส้น​แบ่ง​อยู่​ที่​ความ​หมาย​ของ​ผลลัพธ์ ไม่ใช่​ที่​ว่า​ชนิด​ไหน​ดู​ทัน​สมัย​กว่า

javadoc ของ Optional ใน JDK 25 เขียน​ไว้​ว่า

“Optional is primarily intended for use as a method return type where there is a clear need to represent “no result,” and where using null is likely to cause errors. A variable whose type is Optional should never itself be null; it should always point to an Optional instance.”

ประโยค​หลัง​คือ​คำ​แนะนำ ไม่ใช่​การ​รับ​ประกัน · ตัวแปร​ชนิด Optional เป็น null ได้​จริง ซึ่ง​เป็น​เหตุผล​ที่​มัน​ไม่ใช่​คู่​แทน​ของ zero value ใน Go

package orderctl;
import java.util.Map;
import java.util.Optional;
public final class Catalog {
private static final Map<String, Long> PRICE_SATANG =
Map.of("TEA", 3500L, "CAKE", 12000L);
private Catalog() {}
// "ไม่พบ" คือคำตอบที่ถูกต้องหนึ่งคำตอบ → คืน Optional
public static Optional<Long> priceOf(String sku) {
return Optional.ofNullable(PRICE_SATANG.get(sku));
}
// "พบแต่ผิด" ไม่ใช่คำตอบ มันคือสถานะที่ห้ามเดินต่อ → โยน
public static long requirePrice(String sku) {
return priceOf(sku).orElseThrow(
() -> new IllegalArgumentException("ไม่มี sku " + sku + " ในรายการ"));
}
public static void main(String[] args) {
System.out.println(priceOf("TEA").map(p -> p + " สตางค์").orElse("ไม่มีในรายการ"));
System.out.println(priceOf("SOUP").map(p -> p + " สตางค์").orElse("ไม่มีในรายการ"));
try {
requirePrice("SOUP");
} catch (IllegalArgumentException e) {
System.out.println("โยนแทน: " + e.getMessage());
}
Optional<Long> notInitialised = null;
System.out.println("Optional ที่เป็น null เอง: " + (notInitialised == null));
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Catalog
3500 สตางค์
ไม่มีในรายการ
โยนแทน: ไม่มี sku SOUP ในรายการ
Optional ที่เป็น null เอง: true

OptionalOptionalชนิด​ที่​ใช้​เป็น​ค่า​คืน​ของ "ไม่​พบ" · ไม่ใช่ zero value เพราะ​ตัว​มัน​เอง​เป็น null ได้ และ​เอกสาร​ไม่​แนะนำ​ให้​เอา​ไป​เป็น field จึง​ไม่​ได้​มา​แทน exception · มัน​มา​แทน null ที่​คืน​ออก​จาก method ใน​กรณี​ที่ “ไม่​พบ” เป็น​ผลลัพธ์​ปกติ

เส้น​แบ่ง​อยู่​ที่​ลาย​เซ็น​ของ interface ที่ lambda ไปลง ไม่ใช่​ที่ method ที่​ล้อม​อยู่

Function.apply ไม่​ประกาศ throws อะไร​เลย · lambda ที่​ไป​ลง​ตำแหน่ง​นั้น​จึง​โยน checked ไม่​ได้

src/main/java/orderctl/ReadAllBad.java
// ❌ แปลไม่ผ่าน — ตั้งใจ · lambda โยน checked exception ไม่ได้
package orderctl;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public final class ReadAllBad {
private ReadAllBad() {}
public static List<String> readAll(List<Path> paths) throws IOException {
return paths.stream().map(p -> Files.readString(p)).toList();
}
}
ReadAllBad.java:15: error: unreported exception IOException; must be caught or declared to be thrown
return paths.stream().map(p -> Files.readString(p)).toList();
^
1 error

readAll ประกาศ throws IOException ไว้​แล้ว และ​มัน​ไม่​ช่วย​อะไร​เลย เพราะ​กำแพง​อยู่​ที่ Function.apply ไม่ใช่​ที่ method ของ​คุณ

package orderctl;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class ReadAll {
private ReadAll() {}
// ทางที่หนึ่ง · ลูปธรรมดา checked ไหลออกทาง throws ได้ตามปกติ
public static List<String> withLoop(List<Path> paths) throws IOException {
var out = new ArrayList<String>();
for (Path p : paths) {
out.add(Files.readString(p));
}
return List.copyOf(out);
}
// ทางที่สอง · อยู่ใน stream ต่อไป แล้วเปลี่ยนชนิดของความผิดพลาดเป็น unchecked
public static List<String> withStream(List<Path> paths) {
return paths.stream()
.map(p -> {
try {
return Files.readString(p);
} catch (IOException e) {
throw new UncheckedIOException("อ่าน " + p + " ไม่ได้", e);
}
})
.toList();
}
public static void main(String[] args) {
try {
withStream(List.of(Path.of("dinner.json")));
} catch (UncheckedIOException e) {
System.out.println("ชั้นนอก : " + e.getMessage());
System.out.println("cause : " + e.getCause());
}
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.ReadAll
ชั้นนอก : อ่าน dinner.json ไม่ได้
cause : java.nio.file.NoSuchFileException: dinner.json
หลัก​ฐาน​ว่า​วงการ​เดิน​ออก​จาก checked แล้ว

ไม่​ต้อง​อ้าง​ความ​เห็น​ใคร เปิด​ดู​ที่ jar ก็พอ · javap บน​เครื่อง​ที่​เขียน​บท​นี้​ตอบ​ว่า

$ javap -cp jackson-core-2.19.2.jar com.fasterxml.jackson.core.JacksonException | grep '^public'
public abstract class com.fasterxml.jackson.core.JacksonException extends java.io.IOException {
$ javap -cp jackson-core-3.2.2.jar tools.jackson.core.JacksonException | grep '^public'
public class tools.jackson.core.JacksonException extends java.lang.RuntimeException {

Jackson 2 เป็น checked · Jackson 3 ที่​คอร์ส​นี้​ใช้​ตั้งแต่​บท​ที่ 3 เป็น unchecked

ขณะ​เดียวกัน JLS §11.1.1 ยัง​เขียน​ว่า “it is typical to define most new exception classes as checked exception classes” · เอกสาร​กับ​การ​ปฏิบัติ​จริง​ไม่​ตรง​กัน และ​นั่น​คือ​สิ่ง​ที่​ควร​รู้ ก่อน​ออกแบบ exception ของ​ตัว​เอง

เทียบ​กับ​คอร์ส Go — ทั้ง​สอง​ฝั่ง​จ่าย​คนละ​อย่าง

หัวข้อ​ที่​มีชื่อ​ว่า “เทียบ​กับ​คอร์ส Go — ทั้ง​สอง​ฝั่ง​จ่าย​คนละ​อย่าง”

สี่​แถว​นี้​เทียบ​กับ​สิ่ง​ที่​บท​ที่ 6 และ​บท​ที่ 7 ของ​คอร์ส Go เขียน​ไว้ และ​ทุก​แถว​มี​ราคา​ทั้ง​สอง​ฝั่ง

บท​ที่ 6 ของ​คอร์ส Go สร้าง​ของ​ชิ้น​เดียวกัน​นี้ ด้วย​เครื่อง​มือ​คนละ​ชุด

เรื่องGo จ่าย​อะไรJava จ่าย​อะไร
ค้นหา​ชนิด​ใน​สาย​การ​ห่อได้ errors.Is กับ errors.As มา​ฟรี แต่​การ​ห่อ​ทำให้ == เป็น​เท็จ ผู้​เรียก​ที่​เทียบ​ผิด​วิธี​จะ​พลาด​เงียบๆไม่มี​ทั้ง​คู่ ต้อง​เขียน​ลูป getCause() เอง แต่ checked exception ทำให้ javac ปฏิเสธ​ตั้งแต่​ตอน​แปล ถ้า​ยัง​ไม่มี​ใคร​จับ
รวม​หลาย​ข้อ​เป็น​ก้อน​เดียวมี errors.Join ใน standard library ตั้งแต่ Go 1.20ไม่มี​ชนิด​กลาง​ให้ ต้อง​ประกอบ OrderException เอง​อย่าง​ใน​บท​นี้
สาย​การ​เรียก​ตอน​ตี​สามไม่มี stack trace เลย บริบท​ทุก​ชั้น​เป็น​งาน​ที่​คุณ​ห่อ​เอง ซึ่ง​คอร์ส Go เขียน callout ยอมรับ​ไว้​เองได้ Caused by: พร้อม​เฟรม​ทั้ง​สาย​มา​โดย​ไม่​ต้อง​ทำ​อะไร แต่​ต้อง​จ่าย​ค่า​เก็บ stack ทุก​ครั้ง​ที่​สร้าง exception
การ​ปิด​ทรัพยากรdefer f.Close() ทิ้ง error ของ Close ทุก​ครั้ง · บท​ที่ 7 ของ​คอร์ส Go จึง​เลิก​ใช้ defer ตอน​เขียน​แล้ว​เรียก Close ตรงๆ แทนtry-with-resources เก็บ​ให้​ใน getSuppressed() เอง แต่​คุณ​ต้อง​ประกาศ​ทรัพยากร​ใน​วงเล็บ​ให้​ครบ ไม่​งั้น​ไม่มี​ผล

หลาย​คน​สรุป​ว่า Go บังคับ​ให้​จัดการ error ส่วน Java ปล่อย​ให้​ลอย · ทั้ง​สอง​ครึ่ง​ไม่​จริง

Go บังคับ​ให้​ค่าที่​เป็น error ปรากฏ​ใน​ลาย​เซ็น แต่​ผู้​เรียก​ทิ้ง​มัน​ได้​เงียบ​สนิท · เรียก​เป็น​คำ​สั่ง​ลอยๆ ไม่​ต้อง​เขียน _ ด้วย​ซ้ำ

package main
import (
"fmt"
"os"
)
func main() {
os.Remove("dinner.json")
fmt.Println("จบโดยไม่มีใครรู้ว่าลบไม่สำเร็จ")
}

บน go1.27.0 โปรแกรม​นี้​ผ่าน​ทั้ง go build ./... และ go vet ./... โดย​ไม่มี​ข้อความ​ใด​ออก​มา

ฝั่ง Java ก็​ทิ้ง​ได้​เหมือน​กัน · catch (IOException e) { } ที่​ว่าง​เปล่า​ผ่าน javac --release 25 -Xlint:all โดย​ไม่มี​คำ​เตือน เพราะ javac ไม่มี​หมวด lint สำหรับ catch ว่าง

ความ​ต่าง​ที่​เหลือ​อยู่​จริง​มี​ข้อ​เดียว — Java บังคับ​ให้​คุณ​พิมพ์​คำ​ว่า catch ออก​มา​ก่อน​จะ​ทิ้ง ส่วน Go ไม่​บังคับ​ให้​พิมพ์​อะไร​เลย

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

คุณ​แยก​ได้​แล้ว​ว่า​กรณี​ไหน​ควร​เป็น Optional และ​กรณี​ไหน​ต้อง​โยน · เกณฑ์​คือ “ไม่​พบ” เป็น​คำ​ตอบ​ที่​ถูก​ต้อง​หรือ​ไม่

บท​หน้า​เรา​เอา Failure.exitCode ไป​ใช้​จริง — บรรทัด​คำ​สั่ง สถานะ​บน disk และ​เทสต์​ตัว​แรก​ของ​คอร์ส · JUnit รัน​ร่วม​กับ source launcher ไม่​ได้ มัน​จึง​ต้อง​รอ​จน​มี Maven ครบ​ทั้ง​โครง​เสีย​ก่อน


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

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

ผล​การ​แปล​และ​ผล​การ​รัน​ทุก​ก้อน​ใน​บท​นี้ วัด​บน OpenJDK 25.0.4.1 (macOS arm64) เมื่อ 2026-08-27 ด้วย javac --release 25 -Xlint:all · ส่วน​ก้อน Go วัด​บน go1.27.0 (darwin/arm64) วัน​เดียวกัน

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

ข้อ 1 / 3

อะไรตัดสินว่า exception ตัวหนึ่งเป็น checked หรือ unchecked