record sealed และ enum — ชนิดข้อมูลที่สร้างสถานะผิดไม่ขึ้น
บทที่แล้วจบที่ hello world บทนี้ orderctl เริ่มมีของจริง
เราจะสร้างชนิดข้อมูลของออเดอร์ทั้งใบ — รายการอาหาร ส่วนลด และสถานะ
โดยยังไม่มี build tool ไม่มี package และยังรันด้วยคำสั่งเดียวเหมือนเดิม
ทุกตัวอย่างในบทนี้เป็น compact source filecompact source fileรูปย่อของ source ที่ประกาศ class ครอบไม่ต้อง (JEP 512 final ใน Java 25) · ประกาศ `package` ไม่ได้ และสมาชิกทั้งหมดกลายเป็นสมาชิกของ class โดยปริยาย ที่รันด้วย java <ชื่อ>.java ตรงๆ
คุณจะเห็นชื่อ file เปลี่ยนไปตามหัวข้อ — Domain.java, Discounts.java, Statuses.java,
Receipt.java, Refs.java, Unbox.java และ Tmpl.java — เพราะแต่ละก้อนรันแยกกันได้จริง
ชื่อ file สำคัญกว่าที่คิด ข้อความผิดพลาดบางอันในบทนี้มีชื่อนั้นอยู่ด้วย และเหตุผลจะชัดตอนท้ายหัวข้อที่สอง
record ไม่ใช่ struct
หัวข้อที่มีชื่อว่า “record ไม่ใช่ struct”
recordประกาศบรรทัดเดียวแล้วได้ accessorequalshashCodeและtoStringมาครบ แต่มันยังเป็น reference
recordrecordชนิดข้อมูลที่ประกาศ component แล้วได้ accessor, `equals`, `hashCode`, `toString` มาให้ · เป็น final เสมอ สืบทอดไม่ได้ และยังเป็น reference ไม่ใช่ struct คือรูปประกาศชนิดข้อมูลที่คุณบอก compiler ว่ามี component อะไรบ้าง แล้วมันเขียนส่วนที่เหลือให้
สร้าง Domain.java แล้วใส่แค่นี้
record Line(String sku, int qty, long unitSatang) { long subtotalSatang() { return qty * unitSatang; }}
void main() { var tea = new Line("TEA", 2, 3500); IO.println(tea); IO.println(tea.sku() + " × " + tea.qty() + " = " + tea.subtotalSatang() + " สตางค์"); IO.println(tea.equals(new Line("TEA", 2, 3500)));}$ java Domain.javaLine[sku=TEA, qty=2, unitSatang=3500]TEA × 2 = 7000 สตางค์trueสามอย่างที่เกิดขึ้นโดยไม่ต้องเขียนเอง
- accessor ชื่อ
sku()ไม่ใช่getSku()ธรรมเนียม JavaBean ไม่ถูกใช้กับ record toStringที่พิมพ์ชื่อ component ออกมาด้วย จึงIO.println(tea)แล้วอ่านรู้เรื่องทันทีequalsกับhashCodeที่เทียบตามค่า จึงเอาLineไปเป็นคีย์ของMapได้เลย
ตัวชนิดเองเป็น final เสมอ · class Sub extends Line ตอบว่า
error: cannot inherit from final Line · ส่วนสาม method ข้างบนคุณเขียนทับได้
ถ้าประกาศเองในตัว record — สิ่งที่ compiler สร้างให้เป็นแค่ตัวที่มาเติมเมื่อคุณไม่ได้เขียน
เงินเก็บเป็นสตางค์ในจำนวนเต็ม ไม่ใช่ double เหตุผลเดียวกับที่คอร์ส Go ใช้ Baht int64
— ทศนิยมฐานสองปัดเศษเงินไม่ตรง
record ของ C# มาพร้อม with expression และ == ที่เทียบตามค่า · Java ไม่มีทั้งคู่
== ระหว่างชนิด reference สองตัวใน Java ยังเทียบ identity เสมอ ไม่ว่าฝั่งซ้ายจะเป็น
record หรือไม่ · ต้องเรียก equals เอง
และการทำสำเนาที่แก้ไปหนึ่ง component ต้องเขียน withStatus(...) ของตัวเองทีละตัว
· ไม่มีสำนวนกลางในภาษาให้ใช้
compact constructor — ด่านที่อยู่ในตัวชนิดเอง
หัวข้อที่มีชื่อว่า “compact constructor — ด่านที่อยู่ในตัวชนิดเอง”compact constructorcompact constructorตัวสร้างของ record ที่เขียนแต่วงเล็บปีกกา ใช้ตรวจและปรับค่าก่อนลง field · ประกาศ `throws` ไม่ได้ การตรวจจึงต้องล้มด้วย unchecked เท่านั้น ตรวจและปรับค่าในที่เดียวก่อนลง field ทำให้
Lineที่ผิดกฎสร้างไม่ขึ้นเลย
record ข้างบนยอมรับ qty ติดลบ และยอมรับ sku ที่มีช่องว่างติดหัวท้าย
เราปิดรูทั้งสองด้วย constructor ที่เขียนแต่วงเล็บปีกกา
แก้ Domain.java เป็นแบบนี้
record Line(String sku, int qty, long unitSatang) { Line { Objects.requireNonNull(sku, "sku"); if (qty <= 0) throw new IllegalArgumentException("qty ต้องมากกว่า 0 แต่ได้ " + qty); if (unitSatang < 0) throw new IllegalArgumentException("ราคาติดลบไม่ได้"); sku = sku.trim().toUpperCase(Locale.ROOT); }
long subtotalSatang() { return qty * unitSatang; }}
void main() { IO.println(new Line(" tea ", 2, 3500)); try { IO.println(new Line("cake", 0, 12000)); } catch (IllegalArgumentException e) { IO.println("สร้างไม่ขึ้น: " + e.getMessage()); }}$ java Domain.javaLine[sku=TEA, qty=2, unitSatang=3500]สร้างไม่ขึ้น: qty ต้องมากกว่า 0 แต่ได้ 0" tea " ออกมาเป็น TEA เพราะบรรทัดสุดท้ายในวงเล็บปีกกาเขียนทับค่าก่อนมันลง field
· นี่คือจุดที่ normalize ควรอยู่ ไม่ใช่ที่ผู้เรียกทุกคน
ใน compact constructor ชื่อ sku หมายถึง parameter ที่รับเข้ามา ยังไม่ใช่ field
· compiler เป็นคนใส่บรรทัด this.sku = sku; ให้ตอนท้ายเอง
เติม this. เข้าไปหน้าบรรทัดสุดท้ายของ Domain.java ข้างบนแล้วจะได้
$ java Domain.javaDomain.java:6: error: cannot assign a value to final variable sku this.sku = sku.trim().toUpperCase(Locale.ROOT); ^1 errorerror: compilation failedกฎนี้กลับด้านเมื่อคุณเขียน canonical constructor แบบเต็ม — ตรงนั้นต้องเขียน
this.sku = ... ให้ครบทุก component ไม่งั้นแปลไม่ผ่าน
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์record Line(String sku, int qty, long unitSatang) { Line(String sku, int qty, long unitSatang) { this.sku = Objects.requireNonNull(sku).trim().toUpperCase(Locale.ROOT); this.qty = qty; this.unitSatang = unitSatang; }}toUpperCase(Locale.ROOT) มี Locale.ROOT อยู่ด้วยเสมอ ไม่ใช่ความละเอียดเกินเหตุ
· toUpperCase() เปล่าๆ ใช้ locale ของเครื่อง และในภาษาตุรกี "i" กลายเป็น "İ"
ไม่ใช่ "I" · รหัสสินค้าจึงเปลี่ยนตามเครื่องที่รัน
record Order(String id, List<Line> lines) ป้องกันแค่การชี้ไปที่ list ก้อนใหม่
· ตัว list ที่ชี้อยู่ยังถูก add เพิ่มจากข้างนอกได้ทุกเมื่อ
ทางแก้คือเขียน lines = List.copyOf(lines); ใน compact constructor
· List.copyOf คืนสำเนาที่แก้ไม่ได้ และมันโยน NullPointerException ให้เองถ้ามีสมาชิกเป็น null
ตอนนี้เปิดฝากล่องหนึ่งครั้ง สั่ง javac -d out Domain.java แล้วดูว่าได้อะไรมา
$ javac -d out Domain.java$ ls outDomain$Line.class Domain.classชื่อ class ไม่ใช่ Line.class แต่เป็น Domain$Line.class — เพราะใน compact source file
ทุกอย่างที่คุณเขียนกลายเป็นสมาชิกของ class ที่ compiler ประกาศให้โดยปริยาย
· Line จึงเป็น nested class จริงๆ
ท่อนหน้าคือ Domain เพราะ javac ของ JDK 25 ตั้งชื่อตาม file · แต่ JEP 512 อธิบายไว้เองว่า
ชื่อนี้เป็นของ compiler แต่ละตัว ไม่ใช่ชื่อที่ภาษารับประกัน และห้ามอ้างถึงมันใน code
จุดนี้คือเมล็ดของหัวข้อ “แปลงร่าง” ในบทที่ 3 ซึ่ง Line ต้องแยกออกไปเป็น file ของตัวเอง
sealed interface กับ switch ที่นับกรณีให้ครบ
หัวข้อที่มีชื่อว่า “sealed interface กับ switch ที่นับกรณีให้ครบ”sealed interfacesealed interfaceinterface ที่ระบุรายชื่อ subtype ที่อนุญาต · subtype ต้องปิดตัวเอง (class เขียน `final`, `sealed` หรือ `non-sealed` ส่วน record กับ enum เป็น final อยู่แล้ว) ปิดรายชื่อ subtype ไว้ตอนแปล แล้ว
switchที่ครอบคลุมครบจึงไม่ต้องมีdefault
ส่วนลดของ orderctl มีสามแบบ — คิดเป็นเปอร์เซ็นต์ หักเป็นจำนวนเงิน และไม่ลด
· ทั้งสามแบบพก data คนละชุด
สร้าง Discounts.java
sealed interface Discount { record Percent(int pct) implements Discount {} record Fixed(long satang) implements Discount {} record None() implements Discount {}}
long apply(long grossSatang, Discount d) { return switch (d) { case Discount.Percent(int pct) -> grossSatang - grossSatang * pct / 100; case Discount.Fixed(long satang) -> Math.max(0, grossSatang - satang); case Discount.None ignored -> grossSatang; };}
void main() { long gross = 19000; IO.println(apply(gross, new Discount.Percent(10))); IO.println(apply(gross, new Discount.Fixed(25000))); IO.println(apply(gross, new Discount.None()));}$ java Discounts.java17100019000case Discount.Percent(int pct) แกะ component ออกมาเป็นตัวแปรในบรรทัดเดียว
นี่คือ pattern matching for switchpattern matching for switch`switch` ที่แยกกรณีตามชนิดและแกะ component ออกมาได้ (Java 21) · เหนือ sealed type มันบังคับให้ครอบคลุมครบ ไม่ครบคือแดงตอนแปล และมันทำงานได้เพราะ Percent เป็น record
switch ก้อนนี้ไม่มี default และนั่นตั้งใจ · compiler รู้ว่า Discount
มี subtype แค่สามตัว จึงพิสูจน์ได้เองว่าสามกรณีนี้ครอบคลุมครบแล้ว
sealed ปกติต้องตามด้วย permits Percent, Fixed, None · แต่เมื่อ subtype ทุกตัว
อยู่ใน file เดียวกันกับ interface compiler อ่านรายชื่อออกเอง
และ subtype ทุกตัวต้องปิดตัวเองด้วย · class ธรรมดาต้องเขียน final, sealed
หรือ non-sealed ออกมาตรงๆ ส่วน record กับ enum เป็น final อยู่แล้ว
จึงไม่ต้องเขียนอะไรเลย ซึ่งเป็นสาเหตุที่ทั้งสามบรรทัดข้างบนดูสั้นผิดคาด
ฉากที่ต้องเห็นด้วยตาตัวเอง
หัวข้อที่มีชื่อว่า “ฉากที่ต้องเห็นด้วยตาตัวเอง”เพิ่มส่วนลดแบบที่สี่เข้าไปใน Discounts.java — แทรก
record FirstOrder(long capSatang) implements Discount {} ต่อจากบรรทัดของ None
แล้วอย่าแตะ switch เลย
$ java Discounts.javaDiscounts.java:9: error: the switch expression does not cover all possible input values return switch (d) { ^1 errorerror: compilation failedโปรแกรมไม่ได้รันแล้วให้ผลผิด มันไม่ได้รันเลย · ทุกจุดในโปรแกรมที่ switch
เหนือ Discount จะแดงพร้อมกันทั้งหมดในรอบเดียว
flowchart TD D["sealed interface Discount"] --> P["record Percent"] D --> F["record Fixed"] D --> N["record None"] P --> S["switch เหนือ Discount"] F --> S N --> S S -->|"ครบทุกกรณี"| OK["แปลผ่าน และไม่ต้องมี default"] X["record FirstOrder ที่เพิ่งเพิ่มเข้ามา"] -.->|"switch ยังไม่รู้จัก"| ERR["แดงตอนแปล<br/>does not cover all possible input values"]
คำบรรยายภาพ: ชุดกรณีที่ปิดไว้ทำให้ compiler นับได้ · ทันทีที่มีสมาชิกใหม่ที่ switch ยังไม่รู้จัก การแปลจะล้มก่อนโปรแกรมได้รัน
สองรูปข้างล่างทำงานเหมือนกัน แต่รูปบนคือสิ่งที่ตัวอย่างก่อน Java 16 เขียนกัน
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์// ❌ สำนวนดิบ — ก่อน Java 16 ต้องถามชนิดแล้วแปลงเองอีกทีif (d instanceof Discount.Percent) { Discount.Percent p = (Discount.Percent) d; IO.println(p.pct());}
// ✅ รูปปัจจุบัน — ถามและผูกตัวแปรในนิพจน์เดียวif (d instanceof Discount.Percent p) { IO.println(p.pct());}switch ก็มีสองรูปเช่นกัน · รูปทวิภาคที่เขียน case X: แล้วต้องปิดด้วย break
ยังใช้ได้อยู่ และ ยังไหลลง case ถัดไปเงียบๆ ถ้าคุณลืม break
· รูปลูกศร case X -> ที่บทนี้ใช้ไม่ไหลลงไปไหน
enum ที่มีมากกว่าชื่อ
หัวข้อที่มีชื่อว่า “enum ที่มีมากกว่าชื่อ”
enumของ Java พก field constructor และ method ไปด้วยได้ แต่valueOfแยกตัวพิมพ์และโยนทิ้งเมื่อไม่รู้จัก
สถานะของออเดอร์มีสามค่า และแต่ละค่าต้องพกชื่อภาษาไทยกับข้อมูลว่ามันเป็นสถานะปิดแล้วหรือยัง
สร้าง Statuses.java
enum Status { DRAFT("ร่าง", false), PAID("จ่ายแล้ว", true), CANCELLED("ยกเลิก", true);
private final String thai; private final boolean closed;
Status(String thai, boolean closed) { this.thai = thai; this.closed = closed; }
String thai() { return thai; }
boolean closed() { return closed; }}
void main() { for (Status s : Status.values()) { IO.println(s + " (" + s.ordinal() + ") " + s.thai() + " ปิดแล้ว=" + s.closed()); } IO.println(Status.valueOf("PAID").thai()); try { IO.println(Status.valueOf("paid")); } catch (IllegalArgumentException e) { IO.println(e.getMessage()); }}$ java Statuses.javaDRAFT (0) ร่าง ปิดแล้ว=falsePAID (1) จ่ายแล้ว ปิดแล้ว=trueCANCELLED (2) ยกเลิก ปิดแล้ว=trueจ่ายแล้วNo enum constant Statuses.Status.paidนี่คือคู่เทียบของ iota ในคอร์ส Go แต่คนละชั้นกัน · iota ให้ค่าคงที่ที่เป็นตัวเลข
แล้วคุณต้องหาที่เก็บชื่อไว้เอง ส่วน enum ของ Java เป็นชนิดจริงที่ถือ data ของตัวเอง
บรรทัดสุดท้ายเขียนว่า No enum constant Statuses.Status.paid ไม่ใช่ Status.paid
Statuses ตรงนั้นคือชื่อที่ javac ตั้งให้ class ที่มันประกาศแทนคุณใน
compact source file · Status เป็นสมาชิกข้างใน ชื่อเต็มจึงมีสองท่อน
JEP 512 อธิบายไว้เองว่าชื่อท่อนแรกนั้นเป็นของ compiler แต่ละตัว ไม่ใช่กฎของภาษา · javac ของ JDK 25 ใช้ชื่อ file แต่คุณห้ามพึ่งพาชื่อนี้ใน code
บันทึกข้อนี้ไว้ ตั้งแต่บทที่ 3 ที่เรามี package orderctl; แล้ว ข้อความเดียวกันนี้
จะเปลี่ยนเป็น No enum constant orderctl.Status.paid · ผลที่คุณเห็นขึ้นกับวิธีรันเสมอ
ordinal() คืนลำดับที่ประกาศ และเป็นตัวเลขที่ห้ามเอาไปเก็บลง disk
· วันที่มีคนแทรกสถานะใหม่ไว้กลางรายการ data เก่าทุกแถวจะแปลความหมายผิดทันที
· สิ่งที่ควรเก็บคือ name() ซึ่งเป็น string
var กับ text block
หัวข้อที่มีชื่อว่า “var กับ text block”
varใช้ได้เฉพาะตัวแปรเฉพาะที่ และ text block ไม่แทรกค่าให้ ต้องเรียกformattedเอง
สร้าง Receipt.java
record Line(String sku, int qty, long unitSatang) { long subtotalSatang() { return qty * unitSatang; }}
String baht(long satang) { return "%d.%02d".formatted(satang / 100, satang % 100);}
void main() { var lines = List.of(new Line("TEA", 2, 3500), new Line("CAKE", 1, 12000)); var total = 0L; for (var l : lines) { total += l.subtotalSatang(); }
var receipt = """ ใบเสร็จ %d รายการ รวม %s บาท """.formatted(lines.size(), baht(total)); IO.print(receipt);}$ java Receipt.javaใบเสร็จ 2 รายการรวม 190.00 บาทvar ไม่ได้แปลว่าชนิดหายไป · compiler ยังรู้ว่า lines เป็น List<Line>
และคุณยังเรียก l.subtotalSatang() ได้ตามปกติ
ที่ที่ var ใช้ได้มีสี่ที่เท่านั้น
- ตัวแปรเฉพาะที่ในเนื้อความของ method
- ตัวแปรของ
forและของfor-each - ตัวแปรของ
try-with-resources - parameter ของ lambda
เขียน var บน field, บน parameter ของ method, บน return type หรือบน component ของ record
จะได้ error: 'var' is not allowed here ทันที
Java เคยมีของทดลองชื่อ String Templates ที่เขียน STR."ราคา \{x}" ได้
· มันถูกถอนออกไปหลัง JDK 22 และไม่มีในภาษาอีกแล้ว
จุดที่พังไม่ใช่ชื่อ STR แต่เป็น backslash · \{ ไม่ใช่ escape ที่ Java รู้จัก
· ลองเองได้ใน Tmpl.java ที่มี var s = """ แล้วตามด้วยบรรทัด รายการ \{name}
$ java Tmpl.javaTmpl.java:4: error: illegal escape character รายการ \{name} ^1 errorerror: compilation failedที่แทรกค่าลง text block ได้จริงมีสองทาง — .formatted(...) อย่างที่ตัวอย่างข้างบนใช้
หรือ String.format(...) · และ ${x} เขียนได้เป็นตัวอักษรธรรมดา เพราะ $ ไม่ใช่ escape
การตัด indent ของ text block นับจากบรรทัดที่ตื้นที่สุด ในบรรดาบรรทัดเนื้อหาที่ไม่ว่าง
บวกกับบรรทัดที่มี """ ปิด · ในตัวอย่างข้างบนทุกบรรทัดลึกแปดช่องเท่ากัน
จึงถูกตัดแปดช่องทั้งหมด และผลลัพธ์ออกมาชิดซ้าย
บรรทัดปิดจึงเป็นตัวคุมได้ก็ต่อเมื่อมันไม่ตื้นกว่าเนื้อหา · ถ้าเผลอวางเนื้อหาบรรทัดหนึ่ง ตื้นกว่านั้น บรรทัดนั้นจะกลายเป็นตัวกำหนด และบรรทัดที่เหลือจะเหลือช่องว่างติดหัวมาด้วย
ทุกอย่างเป็น reference และ null ไปได้ทุกที่
หัวข้อที่มีชื่อว่า “ทุกอย่างเป็น reference และ null ไปได้ทุกที่”ตัวแปรเฉพาะที่ของ Java ไม่มีค่าเริ่มต้น
==ระหว่างชนิด reference เทียบ identity เสมอ และmap.getที่ไม่พบคืนnullไม่ใช่ 0
สร้าง Refs.java แล้วรัน
void main() { String a = new String("TEA"); String b = new String("TEA"); IO.println((a == b) + " " + a.equals(b));
Integer small1 = 127, small2 = 127; Integer big1 = 128, big2 = 128; IO.println((small1 == small2) + " " + (big1 == big2));
Map<String, Integer> stock = new HashMap<>(); stock.put("TEA", 3); IO.println(stock.get("TEA") + " " + stock.get("CAKE")); IO.println(stock.getOrDefault("CAKE", 0));}$ java Refs.javafalse truetrue false3 null0บรรทัดที่สองคือกับดักที่ดังที่สุดของภาษานี้ · 127 == 127 เป็น true
แต่ 128 == 128 เป็น false ทั้งที่เขียนเหมือนกันทุกตัวอักษร
คำอธิบายอยู่ที่ autoboxingautoboxingการห่อค่า primitive เป็น object โดยอัตโนมัติ · ทำให้ `==` กลายเป็นการเทียบ reference และ `map.get` ที่คืน null ระเบิดตอนคลายกล่อง · Integer.valueOf เก็บ object สำเร็จรูปไว้ให้ค่า
ตั้งแต่ −128 ถึง 127 ค่าในช่วงนั้นจึงได้ object เดิมกลับมาทุกครั้ง
ส่วนค่านอกช่วงตามค่าปริยายได้ object ใหม่ · == เทียบที่อยู่ ไม่ใช่ค่า
เอกสารของ Integer.valueOf อธิบายว่าค่าตั้งแต่ −128 ถึง 127 ถูกเก็บไว้ใช้ซ้ำเสมอ
และค่าที่นอกช่วงนั้น อาจ ถูกเก็บด้วยก็ได้ · ท่อนหลังคือช่องที่เปิดไว้จริง
สั่ง java -XX:AutoBoxCacheMax=1000 Refs.java แล้วบรรทัดที่สองจะพิมพ์ true true
· เลข 128 ไม่ได้พิเศษอะไร มันแค่อยู่นอกช่วงที่เก็บไว้ให้โดยปริยาย
บทเรียนจึงไม่ใช่ “จำเลข 127” แต่เป็น อย่าเทียบ Integer ด้วย == เลย
· เทียบด้วย equals หรือแกะเป็น int ก่อนแล้วค่อยเทียบ
และมีอีกกฎที่กลับด้าน · ถ้าฝั่งหนึ่งเป็น int ธรรมดา == จะแกะกล่องอีกฝั่งแล้วเทียบค่า
· Integer big1 = 128; int big2 = 128; ให้ big1 == big2 เป็น true
field ของ class มีค่าปริยายจริง — ตัวเลขเป็น 0, boolean เป็น false
และชนิด reference เป็น null
แต่ตัวแปรเฉพาะที่ไม่มี · เขียน int qty; แล้วอ่านค่าทันทีจะได้
error: variable qty might not have been initialized ตั้งแต่ตอนแปล
ข้อนี้เป็นข้อดี · compiler จับให้ก่อนโปรแกรมรัน ซึ่งเป็น bug ที่คอร์ส Go ต้องอธิบายว่า ทำไมออเดอร์ที่ลืมตั้งเลขโต๊ะถึงกลายเป็นออเดอร์ของโต๊ะศูนย์แบบเงียบๆ
stock.get("CAKE") คืน null ไม่ใช่ 0 · ลองรับมันด้วย int ใน Unbox.java ดู
void main() { Map<String, Integer> stock = new HashMap<>(); stock.put("TEA", 3); int n = stock.get("CAKE"); IO.println(n);}$ java Unbox.javaException in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null at Unbox.main(Unbox.java:4)มันแปลผ่านสนิท แล้วระเบิดตอนรัน · ทางแก้คือ getOrDefault("CAKE", 0)
ซึ่งบรรทัดสุดท้ายของ Refs.java สาธิตไว้แล้ว
java <ชื่อ>.java แปลโดยไม่เก็บชื่อตัวแปรเฉพาะที่ไว้ · NPE ที่เกิดกับตัวแปรเฉพาะที่
จึงพิมพ์ว่า because "<local1>" is null แทนชื่อที่คุณตั้ง
ตั้งแต่บทที่ 3 ที่เราใช้ Maven ตัวแปลเปิดการเก็บชื่อไว้ให้โดยปริยาย
ข้อความเดียวกันจะกลายเป็น because "sku" is null
เส้นแบ่งจริงคือ -g ไม่ใช่คำสั่งที่คุณพิมพ์ · javac เปล่าๆ ก็ให้ <local1> เหมือนกัน
ส่วน javac -g ให้ชื่อจริง และ maven-compiler-plugin ตั้ง debug เป็นจริงมาให้แล้ว
อย่าตกใจเมื่อผลไม่ตรงกับที่คุณจำได้ ตัวเลขและชื่อในข้อความผิดพลาดผูกกับวิธีแปล ไม่ใช่กับภาษา
Go ให้ค่าที่แตะได้ Java ให้ค่าที่สร้างผิดไม่ได้
หัวข้อที่มีชื่อว่า “Go ให้ค่าที่แตะได้ Java ให้ค่าที่สร้างผิดไม่ได้”สองภาษาเลือกคนละด้านของเส้นเดียวกัน และแต่ละด้านมีราคาที่จ่ายจริง
บทที่ 2 ของ คอร์ส Go ประกาศ
var l Line แล้วแตะ l.Qty ได้ทันที มันเป็น 0 จริงๆ ไม่ใช่ nil ที่แกล้งเป็นตัวเลข
Go ไม่ได้ไม่มี nil · บทเดียวกันนั้นเขียนไว้เองว่า pointer, slice, map, channel
และ interface มี nil เป็นค่าเริ่มต้น · สิ่งที่ Go ให้คือ struct กับชนิดพื้นฐานที่แตะได้เสมอ
ราคาที่ Go จ่ายคือกฎ “qty ต้องมากกว่า 0” บังคับตั้งแต่จังหวะสร้างไม่ได้ · struct ของ Go ไม่มี constructor ให้แขวนด่านไว้ บทนั้นจึงเขียนเองว่าถ้า 0 เป็นค่าที่ผิดสำหรับ field ไหน คุณต้องมีจุดตรวจของตัวเอง แล้วเลื่อนงานนั้นไปบทที่ 6
Java กลับด้านทั้งสองข้อ · Line ที่ qty เป็น 0 สร้างไม่ขึ้นเลย เพราะ compact constructor
เป็นทางเดียวที่เข้าถึงชนิดนี้ได้ ทุกจุดที่รับ Line มาจึงไม่ต้องเชื่อใจใคร
ราคาที่ Java จ่ายคือ null ไปได้ทุกที่ ทุกชนิด reference เป็น null ได้
รวมถึง Optional เอง · Objects.requireNonNull ในบรรทัดแรกของ compact constructor
ไม่ใช่ของประดับ มันคือด่านที่ Go ไม่ต้องมี
เส้นที่สองอยู่ที่การแยกกรณี · บทที่ 5 ของ คอร์ส Go
เขียน type switch ที่มี default: คืนข้อความว่า “กฎที่ยังไม่รู้จัก”
default: ตัวนั้นไม่ใช่ความมักง่าย มันจำเป็น เพราะ interface ของ Go เปิดให้ใครมาเข้าพวกก็ได้
· ราคาคือวันที่มีคนเพิ่มชนิดส่วนลดตัวที่สี่ โปรแกรมยังแปลผ่านและไหลลง default ไปเงียบๆ
ฝั่ง Java เพิ่ม record FirstOrder แล้วการแปลล้มทันที อย่างที่คุณเพิ่งเห็นกับตาตัวเอง
· ราคาคือชุดกรณีนั้นปิดแล้ว ไม่มีใครนอก file นี้เพิ่มส่วนลดแบบใหม่ได้อีกเลย
| คำถาม | Go ตอบ | Java ตอบ |
|---|---|---|
| ตัวแปรที่ประกาศเปล่า แตะได้ไหม | ได้ทุกตัว | field ได้ ตัวแปรเฉพาะที่ไม่ได้ |
| ห้ามสร้างค่าที่ผิดกฎได้ไหม | ไม่ได้ ต้องตรวจเองทีหลัง | ได้ ด้วย compact constructor |
| ลืมกรณีใน switch แล้วรู้ตัวเมื่อไร | ตอนรัน ถ้ารู้ตัว | ตอนแปล ทุกจุดพร้อมกัน |
| คนนอกเพิ่มชนิดใหม่เข้าชุดได้ไหม | ได้ ไม่ต้องขออนุญาต | ไม่ได้ ชุดปิดตอนแปล |
สองแถวล่างคือแถวเดียวกันมองคนละทาง · ความปลอดภัยของ Java มาจากการปิด และความยืดหยุ่นของ Go มาจากการเปิด
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”โดเมนของ
orderctlครบแล้ว แต่มันยังอยู่ใน file เดียวและยังทำอะไรกับ data จริงไม่ได้
คุณเขียน record ที่ตรวจตัวเองได้ ปิดชุดกรณีด้วย sealed interface ให้ compiler นับ
switch ให้ครบ และรู้แล้วว่า null กับ == วางกับดักไว้ตรงไหน
สิ่งที่ยังไม่มีคือทางเข้าของ data จริง · orderctl ต้องอ่าน order.json ให้ได้
และ JDK ไม่มีตัวอ่าน JSON มาให้เลยสักตัว
บทหน้าเราจึงต้องยอมรับสองอย่างพร้อมกัน — Maven กับ pom.xml และ dependency ตัวแรก
· ระหว่างทาง Line ที่ตอนนี้เป็น Domain$Line จะแยกออกไปเป็น file ของตัวเอง
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- JEP 395: Records (เข้าถึง 2026-08-27) — record เป็น
finalโดยปริยาย และกฎของ compact constructor - JEP 409: Sealed Classes (เข้าถึง 2026-08-27) — เงื่อนไขว่า subtype ต้องอยู่ module เดียวกัน และ package เดียวกันเมื่อเป็น unnamed module
- JEP 441: Pattern Matching for switch (เข้าถึง 2026-08-27) — กฎความครอบคลุมของ
switchเหนือ sealed type - JEP 440: Record Patterns (เข้าถึง 2026-08-27) — การแกะ component ของ record ในรูปแบบ
case Percent(int pct) - JEP 378: Text Blocks (เข้าถึง 2026-08-27) — หัวข้อ Incidental white space ที่นับ indent ตื้นสุดจากบรรทัดเนื้อหาที่ไม่ว่าง บวกบรรทัดปิด
- JEP 512: Compact Source Files and Instance Main Methods (เข้าถึง 2026-08-27) — เหตุผลที่ทุกอย่างใน file กลายเป็นสมาชิกของ class ที่ประกาศให้โดยปริยาย และข้อความที่ระบุว่าชื่อของ class นั้นเป็นของ compiler แต่ละตัว ห้ามพึ่งพาใน code
- Integer.valueOf (เข้าถึง 2026-08-27) — ข้อกำหนดว่าค่าตั้งแต่ −128 ถึง 127 ต้องถูกเก็บไว้ใช้ซ้ำ
- JEP 430: String Templates (Preview) (เข้าถึง 2026-08-27) — Status Closed/Delivered · Release 21 · JEP 459: String Templates (Second Preview) — Release 22 · ไม่มีรอบที่สาม ของทดลองนี้จึงจบที่ 22
- String.toUpperCase(Locale) (เข้าถึง 2026-08-27) — “
"title".toUpperCase()in a Turkish locale returns"T\u0130TLE"” และคำแนะนำให้ใช้toUpperCase(Locale.ROOT)กับข้อความที่ต้องไม่ขึ้นกับ locale
ผลลัพธ์ทุกอันในบทนี้รันจริงบน OpenJDK 25.0.4.1 (macOS arm64) เมื่อ 2026-08-27
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3ใน compact constructor ของ record ทำไมเขียน `this.sku = sku.trim();` แล้วแปลไม่ผ่าน