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

record sealed และ enum — ชนิด​ข้อมูล​ที่​สร้าง​สถานะ​ผิด​ไม่​ขึ้น

บท​ที่​แล้ว​จบ​ที่ hello world บท​นี้ orderctl เริ่ม​มี​ของ​จริง

เรา​จะ​สร้าง​ชนิด​ข้อมูล​ขอ​งอ​อเด​อร์​ทั้ง​ใบ — รายการ​อาหาร ส่วนลด และ​สถานะ โดย​ยัง​ไม่มี build tool ไม่มี package และ​ยัง​รัน​ด้วย​คำ​สั่ง​เดียว​เหมือน​เดิม

📦 ทุก​ตัวอย่าง​ใน​บท​นี้​เป็น​หนึ่ง file ที่​รัน​ด้วย​คำ​สั่ง​เดียว

ทุก​ตัวอย่าง​ใน​บท​นี้​เป็น 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 ประกาศ​บรรทัด​เดียว​แล้ว​ได้ accessor equals hashCode และ 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.java
Line[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 — ทศนิยม​ฐาน​สอง​ปัดเศษ​เงิน​ไม่​ตรง

ถ้า​คุณ​มา​จาก C# อย่า​คาด​หวัง​สอง​อย่าง​นี้

record ของ C# มา​พร้อม with expression และ == ที่​เทียบ​ตาม​ค่า · Java ไม่มี​ทั้ง​คู่

== ระหว่าง​ชนิด reference สอง​ตัว​ใน Java ยัง​เทียบ identity เสมอ ไม่​ว่า​ฝั่ง​ซ้าย​จะ​เป็น record หรือ​ไม่ · ต้อง​เรียก equals เอง

และ​การ​ทำ​สำเนา​ที่​แก้​ไป​หนึ่ง component ต้อง​เขียน withStatus(...) ของ​ตัว​เอง​ที​ละ​ตัว · ไม่มี​สำนวน​กลาง​ใน​ภาษา​ให้​ใช้

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.java
Line[sku=TEA, qty=2, unitSatang=3500]
สร้างไม่ขึ้น: qty ต้องมากกว่า 0 แต่ได้ 0

" tea " ออก​มา​เป็น TEA เพราะ​บรรทัด​สุดท้าย​ใน​วงเล็บ​ปีกกา​เขียน​ทับ​ค่า​ก่อน​มัน​ลง field · นี่​คือ​จุด​ที่ normalize ควร​อยู่ ไม่ใช่​ที่​ผู้​เรียก​ทุก​คน

ข้าง​ใน​เขียน `sku = ...` ได้ แต่ `this.sku = ...` ไม่​ได้

ใน compact constructor ชื่อ sku หมาย​ถึง parameter ที่​รับ​เข้า​มา ยัง​ไม่ใช่ field · compiler เป็น​คน​ใส่​บรรทัด this.sku = sku; ให้​ตอน​ท้าย​เอง

เติม this. เข้าไป​หน้า​บรรทัด​สุดท้าย​ของ Domain.java ข้าง​บน​แล้ว​จะ​ได้

$ java Domain.java
Domain.java:6: error: cannot assign a value to final variable sku
this.sku = sku.trim().toUpperCase(Locale.ROOT);
^
1 error
error: 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 เป็น​แบบ​ตื้น

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 out
Domain$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 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.java
17100
0
19000

case 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 แค่​สาม​ตัว จึง​พิสูจน์​ได้​เอง​ว่า​สาม​กรณี​นี้​ครอบคลุม​ครบ​แล้ว

ทำไม record สาม​ตัว​ข้าง​ใน​ไม่​ต้อง​เขียน `permits`

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.java
Discounts.java:9: error: the switch expression does not cover all possible input values
return switch (d) {
^
1 error
error: 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 ยัง​ไม่รู้​จัก การ​แปล​จะ​ล้ม​ก่อน​โปรแกรม​ได้​รัน

สำนวน​เก่า​ที่​คุณ​จะ​เจอ​ตอน google — และ​คู่​ปัจจุบัน​ของ​มัน

สอง​รูป​ข้าง​ล่าง​ทำงาน​เหมือน​กัน แต่​รูป​บน​คือ​สิ่ง​ที่​ตัวอย่าง​ก่อน 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 ของ 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.java
DRAFT (0) ร่าง ปิดแล้ว=false
PAID (1) จ่ายแล้ว ปิดแล้ว=true
CANCELLED (2) ยกเลิก ปิดแล้ว=true
จ่ายแล้ว
No enum constant Statuses.Status.paid

นี่​คือ​คู่​เทียบ​ของ iota ใน​คอร์ส Go แต่​คนละ​ชั้น​กัน · iota ให้​ค่า​คงที่​ที่​เป็น​ตัวเลข แล้ว​คุณ​ต้องหา​ที่​เก็บ​ชื่อ​ไว้​เอง ส่วน enum ของ Java เป็น​ชนิด​จริง​ที่​ถือ data ของ​ตัว​เอง

ข้อความ​ผิด​พลาด​มีชื่อ file อยู่​ใน​นั้น​ด้วย

บรรทัด​สุดท้าย​เขียน​ว่า 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 ไม่​แทรก​ค่า​ให้ ต้อง​เรียก 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 ทันที

`\{name}` ใน text block ไม่ใช่ syntax ที่​พัง​เพราะ​ยัง​ไม่​รองรับ

Java เคย​มี​ของ​ทดลอง​ชื่อ String Templates ที่​เขียน STR."ราคา \{x}" ได้ · มัน​ถูก​ถอน​ออก​ไป​หลัง JDK 22 และ​ไม่มี​ใน​ภาษา​อีก​แล้ว

จุด​ที่​พัง​ไม่ใช่​ชื่อ STR แต่​เป็น backslash · \{ ไม่ใช่ escape ที่ Java รู้จัก · ลอง​เอง​ได้​ใน Tmpl.java ที่​มี var s = """ แล้ว​ตาม​ด้วย​บรรทัด รายการ \{name}

$ java Tmpl.java
Tmpl.java:4: error: illegal escape character
รายการ \{name}
^
1 error
error: compilation failed

ที่​แทรก​ค่า​ลง text block ได้​จริง​มี​สอง​ทาง — .formatted(...) อย่าง​ที่​ตัวอย่าง​ข้าง​บน​ใช้ หรือ String.format(...) · และ ${x} เขียน​ได้​เป็น​ตัว​อักษร​ธรรมดา เพราะ $ ไม่ใช่ escape

การ​ตัด indent ของ text block นับ​จากบรรทัด​ที่​ตื้น​ที่สุด ใน​บรรดา​บรรทัด​เนื้อหา​ที่​ไม่​ว่าง บวก​กับ​บรรทัด​ที่​มี """ ปิด · ใน​ตัวอย่าง​ข้าง​บน​ทุก​บรรทัด​ลึก​แปด​ช่อง​เท่า​กัน จึง​ถูก​ตัด​แปด​ช่อง​ทั้งหมด และ​ผลลัพธ์​ออก​มา​ชิด​ซ้าย

บรรทัด​ปิด​จึง​เป็น​ตัว​คุม​ได้​ก็​ต่อ​เมื่อ​มัน​ไม่​ตื้น​กว่า​เนื้อหา · ถ้า​เผลอ​วาง​เนื้อหา​บรรทัด​หนึ่ง ตื้น​กว่า​นั้น บรรทัด​นั้น​จะ​กลาย​เป็น​ตัว​กำหนด และ​บรรทัด​ที่​เหลือ​จะ​เหลือ​ช่อง​ว่าง​ติด​หัว​มา​ด้วย

ตัวแปร​เฉพาะ​ที่​ของ 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.java
false true
true false
3 null
0

บรรทัด​ที่​สอง​คือ​กับ​ดัก​ที่​ดัง​ที่สุด​ของ​ภาษา​นี้ · 127 == 127 เป็น true แต่ 128 == 128 เป็น false ทั้ง​ที่​เขียน​เหมือน​กัน​ทุก​ตัว​อักษร

คำ​อธิบาย​อยู่​ที่ autoboxingautoboxingการ​ห่อ​ค่า primitive เป็น object โดย​อัตโนมัติ · ทำให้ `==` กลาย​เป็นการ​เทียบ reference และ `map.get` ที่​คืน null ระเบิด​ตอน​คลาย​กล่อง · Integer.valueOf เก็บ object สำเร็จรูป​ไว้​ให้​ค่า ตั้งแต่ −128 ถึง 127 ค่า​ใน​ช่วง​นั้น​จึง​ได้ object เดิม​กลับ​มา​ทุก​ครั้ง ส่วน​ค่า​นอก​ช่วง​ตาม​ค่า​ปริยาย​ได้ object ใหม่ · == เทียบ​ที่​อยู่ ไม่ใช่​ค่า

เส้น 127 ไม่ใช่​ข้อ​กำหนด มัน​คือ​ค่า​ปริยาย​ที่​ย้าย​ได้

เอกสาร​ของ 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

Go ให้ zero value ทุก​ที่ Java ให้​เฉพาะ field

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.java
Exception 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 สาธิต​ไว้​แล้ว

ข้อความ​ของ NullPointerException เปลี่ยน​ตาม​วิธี​แปล

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();` แล้วแปลไม่ผ่าน