ความผิดพลาด — exception ที่กระโดด เทียบ error ที่เป็นค่า
บทที่ 5 ทำให้ 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 errortable โยน NumberFormatException ได้ทุกวัน แต่ลายเซ็นของมันเงียบสนิท
เพราะ NumberFormatException สืบสาย RuntimeException
สเปกให้เหตุผลของสองกลุ่มแยกกัน · ฝั่ง 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 มี ยังไม่พอจะพิสูจน์ว่า ความผิดพลาดพวกนี้เกิดไม่ได้
record ปิดประตูของ checked ไปแล้ว
หัวข้อที่มีชื่อว่า “record ปิดประตูของ checked ไปแล้ว”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()
Throwable เป็น Serializable ทุกตัว ด่านของคอร์สนี้จึงเห็นสองข้อทันทีที่คุณเขียนลวกๆ
ถอดบรรทัด serialVersionUID กับคอมเมนต์ที่อยู่เหนือ field ออก แล้วเปลี่ยนชนิดของ field
เป็น List<Failure> จะได้
OrderException.java:6: warning: [serial] serializable class OrderException has no definition of serialVersionUIDpublic 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.jsonjava.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 เรียก main ผ่าน reflection ในกระบวนการเดียวกับ Maven · stack trace
จึงต่อท้ายด้วยเฟรมของ Maven อีกเจ็ดบรรทัด และบรรทัดสุดท้ายกลายเป็น ... 8 more
ตัวเลขหลัง ... คือจำนวนเฟรมที่ซ้ำ มันจึงเปลี่ยนตามวิธีรัน · หัวข้อนี้แยก
mvn -q compile กับ java -cp target/classes ออกจากกัน เพื่อให้สายที่เห็นเป็นของ
orderctl ล้วน
rootCause เป็นลูปสิบบรรทัดที่คุณต้องเขียนเอง เพราะ Java ไม่มีตัวเดินสายการห่อมาให้
· catch เห็นเฉพาะชนิดของตัวนอกสุดเท่านั้น
สำนวน raise X from e ของ Python ตรงกับ constructor ที่รับสอง argument ของ Java
กับดักที่ซ้อนอยู่คือ ส่ง null เป็น cause ก็ยังนับว่าตั้งไปแล้ว · เรียก initCause
ตามหลังจะได้
java.lang.IllegalStateException: Can't overwrite cause with java.lang.IllegalStateException: laterOrderException.of ข้างบนจึงเรียก addSuppressed ไม่ใช่ initCause และเปิดทาง cause จริง
ไว้ที่ wrapping แทน
catch จับได้แค่ชั้นนอกสุด
หัวข้อที่มีชื่อว่า “catch จับได้แค่ชั้นนอกสุด”
catchไม่รับ pattern และไม่มี guard สำนวนที่ใช้ได้คือจับ class แม่แล้วแยกกรณีด้วยswitchข้างใน
คนที่มาจาก C# จะพิมพ์ catch (Bad b when ...) โดยอัตโนมัติ · javac ไม่ได้ตอบว่าไม่รองรับ
มันตอบว่าอ่านไวยากรณ์ไม่ออก
// ❌ แปลไม่ผ่าน — ตั้งใจ · ไวยากรณ์นี้ไม่มีใน Javapackage 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 จะปฏิเสธเพราะกรณีหลังเข้าไม่ถึง
try-with-resources เก็บของที่ finally เคยทำลายทิ้ง
หัวข้อที่มีชื่อว่า “try-with-resources เก็บของที่ finally เคยทำลายทิ้ง”ทรัพยากรถูกปิดย้อนลำดับการประกาศ และความผิดพลาดของ
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เปิด secondfirst เขียน A-1second เขียน A-2ปิด secondปิด firstตัวจริง : งานหลักล้มกลางคันถูกเก็บไว้: flush ของ second ไม่สำเร็จถูกเก็บไว้: flush ของ first ไม่สำเร็จสองอย่างที่ต้องอ่านจากผลนี้ให้ครบมีดังนี้
- ลำดับการปิดกลับหลัง
secondปิดก่อนfirstเสมอ ไม่ใช่ตามลำดับที่เขียน - ตัวจริงชนะ ความผิดพลาดของเนื้อความยังเป็นตัวที่ผู้เรียกได้รับ ส่วนอีกสองตัวกลายเป็น suppressed exceptionsuppressed exceptionความผิดพลาดตอนปิดทรัพยากรที่ถูกเก็บไว้ใน `getSuppressed()` แทนที่จะหายไป · สำนวนก่อน Java 7 ทำลายตัวจริงทิ้งทั้งดุ้น ที่ยังอ่านได้ครบ
ทรัพยากรที่มีตัวแปรรออยู่แล้ว ไม่ต้องประกาศตัวแปรใหม่ในวงเล็บ · JEP 213 เปิดทางให้ตัวแปร ที่เป็น final หรือ effectively final วางในวงเล็บได้ตรงๆ ตั้งแต่ JDK 9
สำนวนก่อน Java 7 ทำลายตัวจริงทิ้งทั้งดุ้น
หัวข้อที่มีชื่อว่า “สำนวนก่อน Java 7 ทำลายตัวจริงทิ้งทั้งดุ้น”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“งานหลักล้มกลางคัน” หายไปจากโลกนี้แล้ว และไม่มีอะไรบันทึกไว้ว่ามันเคยมีอยู่ · นี่คือเหตุผลที่ตัวอย่างเก่าที่ค้นเจอ ไม่ควรคัดลอกมาทั้งดุ้น
return ใน finally กลืนทุกอย่างเงียบๆ
หัวข้อที่มีชื่อว่า “return ใน finally กลืนทุกอย่างเงียบๆ”// ❌ แปลไม่ผ่าน — ตั้งใจ · ด่านของคอร์สถือว่าคำเตือนคือความล้มเหลว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 และถือว่าคำเตือนคือความล้มเหลว จึงจับข้อนี้ให้ตั้งแต่ตอนแปล
· โปรเจกต์ที่ไม่ได้ตั้งค่าไว้แบบนั้น จะไม่มีใครเห็นอะไรเลย
Optional คือคำตอบของ “ไม่พบ” ไม่ใช่ของ “ผิด”
หัวข้อที่มีชื่อว่า “Optional คือคำตอบของ “ไม่พบ” ไม่ใช่ของ “ผิด””เส้นแบ่งอยู่ที่ความหมายของผลลัพธ์ ไม่ใช่ที่ว่าชนิดไหนดูทันสมัยกว่า
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.Catalog3500 สตางค์ไม่มีในรายการโยนแทน: ไม่มี sku SOUP ในรายการOptional ที่เป็น null เอง: trueOptionalOptionalชนิดที่ใช้เป็นค่าคืนของ "ไม่พบ" · ไม่ใช่ zero value เพราะตัวมันเองเป็น null ได้ และเอกสารไม่แนะนำให้เอาไปเป็น field จึงไม่ได้มาแทน exception · มันมาแทน null ที่คืนออกจาก method
ในกรณีที่ “ไม่พบ” เป็นผลลัพธ์ปกติ
ราคาที่แท้จริงของ checked exception อยู่ที่ lambda
หัวข้อที่มีชื่อว่า “ราคาที่แท้จริงของ checked exception อยู่ที่ lambda”เส้นแบ่งอยู่ที่ลายเซ็นของ interface ที่ lambda ไปลง ไม่ใช่ที่ method ที่ล้อมอยู่
Function.apply ไม่ประกาศ throws อะไรเลย · lambda ที่ไปลงตำแหน่งนั้นจึงโยน checked ไม่ได้
// ❌ แปลไม่ผ่าน — ตั้งใจ · 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 errorreadAll ประกาศ 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ไม่ต้องอ้างความเห็นใคร เปิดดูที่ 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 ครบทั้งโครงเสียก่อน
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- JLS SE 25 §11 Exceptions (เข้าถึง 2026-08-27) — นิยามของ checked กับ unchecked ใน §11.1.1 และเหตุผลของการยกเว้นใน §11.2
- javadoc ของ
java.util.Optional(JDK 25) (เข้าถึง 2026-08-27) — API Note ที่ระบุว่ามันมีไว้เป็นชนิดของค่าที่คืน - JEP 213: Milling Project Coin (เข้าถึง 2026-08-27) — ตัวแปรที่เป็น effectively final ใช้เป็นทรัพยากรใน try-with-resources ได้ตั้งแต่ JDK 9
- บทที่ 6 ของคอร์ส Go จากศูนย์ และ บทที่ 7 — ฝั่งที่เอามาเทียบในตารางข้างบน
ผลการแปลและผลการรันทุกก้อนในบทนี้ วัดบน 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