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

interface ที่​ต้อง​เอ่ย​ชื่อ และ​กฎ​ส่วนลด​ที่​ต่อ​เพิ่ม​ได้

orderctl คิด​ยอด​รวม​และ​สรุป​ตาม​หมวด​ได้​แล้ว​ตั้งแต่​บท​ที่ 4 แต่​ยอด​ที่​ได้​ยัง​เป็น​ยอด​เต็ม​เสมอ

บท​นี้​เติม​ส่วนลด และ​ส่วนลด​คือ​ที่​ที่​ทุก​ภาษา​หยิบ​เครื่อง​มือ abstraction ออก​มา​ใช้ Java มี​เครื่อง​มือ​นั้น​สอง​ตัว และ​ตัว​แรก​ทำงาน​กลับ​ด้าน​กับ​ที่ คอร์ส Go สอน​ไว้​ทั้ง​บท

🧪 ฉาก​ทดลอง​ของ​บท​นี้​อยู่​ใน package `orderctl.lab`

บท​นี้​มี​สอง​ชุด · ชุด​แรก​คือ code จริง​ของ orderctl ที่​อยู่​ใน package orderctl; ตาม​โครง Maven ของ​บท​ที่ 3 · ชุด​ที่​สอง​คือ​ฉาก​ทดลอง​ที่​วาง​ไว้​ใน package orderctl.lab;

ฉาก​ทดลอง​แต่ละ​อัน​เป็น file เดียว​ที่​แปล​ได้​เดี่ยวๆ จึง​คัด​สำเนา​ย่อ​ของ DiscountRule มา​ไว้​เป็น​ชนิด​ซ้อน​ข้าง​ใน · แยก package ไว้​ต่าง​หาก​เพื่อ​ไม่​ให้​ชื่อ​ชน​กับ​ของ​จริง

รัน​ฉาก​ทดลอง​ด้วย​รูป​เดียว​กับ​บท​ที่ 3 คือ mvn -q compile exec:java -Dexec.mainClass=orderctl.lab.<ชื่อ>

ชนิด​ที่​มี method รูป​ร่าง​ตรง​เป๊ะ​แต่​ไม่​ได้​เขียน implements ไม่ใช่​สมาชิก​ของ interface นั้น

nominal typingnominal typingการ​เข้า​กัน​ของ​ชนิด​ตัดสิน​ด้วย​ชื่อ​ที่​ประกาศ​ไว้ ไม่ใช่​ด้วย​รูป​ร่าง · Java ต้อง​เขียน `implements` ออก​มา​ตรงๆ ซึ่ง​ตรง​ข้าม​กับ interface โดย​ปริยาย​ของ Go คือ​กฎ​ข้อ​เดียว​ที่​ทำให้​ทั้ง​บท​นี้​เป็น​อย่าง​ที่​มัน​เป็น ความ​เข้า​กัน​ของ​ชนิด​ตัดสิน​จาก​ชื่อ​ที่​ประกาศ​ผูก​ไว้ ไม่​ได้​ตัดสิน​จาก method ที่​มี​อยู่​จริง

ฉาก​ข้าง​ล่าง​คือ​ชนิด​ที่​มี apply(long) รูป​ร่าง​ตรง​ทุก​ตัว​อักษร แต่​ไม่มี​บรรทัด​ผูกพัน

// ❌ รูปที่ผิด · orderctl/lab/Shape.java
static final class Foreign {
public long apply(long subtotalSatang) {
return subtotalSatang - 2_000L;
}
}
DiscountRule rule = new Foreign();

javac คาย​ข้อความ​นี้​ออก​มา

error: incompatible types: Foreign cannot be converted to DiscountRule
DiscountRule rule = new Foreign();
^

การ​ติด @Override ลง​ไป​เฉยๆ ไม่​ได้​สร้าง​ความ​ผูกพัน​นั้น​ขึ้น​มา มัน​แค่​ทำให้​ความ​ผิด​พลาด​ย้าย​ที่

// ❌ รูปที่ผิด · orderctl/lab/Ann.java · ติด @Override ให้ Foreign.apply
error: method does not override or implement a method from a supertype
@Override
^
ข้อความ​เดียวกัน​นี้​เมื่อ​ผ่าน Maven หน้าตา​ไม่​เหมือน​กัน

บท​นี้​ยก​ข้อความ​จาก javac ตรงๆ ทุก​ครั้ง เพราะ​มัน​ชี้​ตำแหน่ง​ด้วย​ลูก​ศร​ใต้​บรรทัด​ที่​ผิด

mvn -q compile ส่ง​ข้อความ​เดียวกัน​ผ่าน maven-compiler-plugin ซึ่ง​เขียน​มัน​ใหม่​ทั้ง​บรรทัด คือ​ตัด​ลูก​ศร​ทิ้ง แล้ว​เติม​ชื่อ​เต็ม​ของ​ชนิด​เข้าไป​แทน:

[ERROR] .../src/main/java/orderctl/lab/Shape.java:[16,29] incompatible types: orderctl.lab.Shape.Foreign cannot be converted to orderctl.lab.Shape.DiscountRule

เลข​ใน​วงเล็บ​เหลี่ยม​คือ​บรรทัด​กับ​ตำแหน่ง​ตัว​อักษร · ส่วน​คำ​ว่า error: หาย​ไป​เพราะ Maven ย้าย​มัน​ไป​ไว้​ที่​ป้าย [ERROR] ข้าง​หน้า​แทน

ผล​ตาม​มา​มี​ข้อ​เดียว แต่​มัน​เปลี่ยน​วิธี​ทำงาน​ทั้ง​โปร​เจ​กต์ — ที่​ที่​ต้อง​ไป​แก้​คือ file ของ​ผู้​สร้าง​ชนิด ไม่ใช่ file ของ​ผู้​ใช้ ซึ่ง​เป็น​สิ่ง​ที่​คอร์ส Go ไม่​เคย​ต้อง​ทำ​เลย​สัก​ครั้ง

Go ต้อง​เขียน​ด่าน​นั้น​เอง · Java ได้​มา​ฟรี

บท​ที่ 5 ของ​คอร์ส Go สอน​ให้​เขียน var _ Rule = PercentOff{} ไว้​ใน package ของ​ผู้​สร้าง​ชนิด เพราะ​การ​สะกด​ชื่อ method ผิด​หนึ่ง​ตัว​ยัง​เป็น code ที่​ถูก​ต้อง มัน​แค่​เงียบๆ เลิก​เป็น Rule

Java ไม่มี​วัน​ลืม​ข้อ​นี้​ได้ เพราะ​บรรทัด implements DiscountRule คือ​ด่าน​นั้น​ใน​ตัว​มัน​เอง และ​เครื่อง​มือ​ไล่​หา​ผู้ implement ทั้งหมด​ได้​จาก​ชื่อ

ราคา​ที่ Java จ่าย​อยู่​คนละ​ที่ และ​หนัก​กว่า​ใน​โปร​เจ​กต์​ที่​พึ่ง library — ดู​หัวข้อ “ชนิด​ที่​แก้ file ไม่​ได้” ข้าง​ล่าง

interface method เดียว​หนึ่ง​ตัว กับ record สอง​ตัว​ที่​ประกาศ​ความ​ผูกพัน​ไว้​ตรงๆ

DiscountRule รับ​ยอด​หนึ่ง​ก้อน​แล้ว​คืน​ยอด​ใหม่ หน่วย​เป็น​สตางค์​ตาม​ที่​บท​ที่ 2 ตกลง​ไว้

src/main/java/orderctl/DiscountRule.java
package orderctl;
@FunctionalInterface
public interface DiscountRule {
/** คืนยอดใหม่หลังหักส่วนลด · หน่วยเป็นสตางค์เสมอ */
long apply(long subtotalSatang);
/** เพิ่มทีหลังได้เพราะเป็น default · ผู้ที่ implement ไว้แล้วไม่ต้องแก้อะไรเลย */
default DiscountRule andThen(DiscountRule next) {
return subtotalSatang -> next.apply(apply(subtotalSatang));
}
/** กฎที่ไม่ลดอะไรเลย · ใช้เป็นจุดตั้งต้นของการต่อกฎ */
static DiscountRule none() {
return subtotalSatang -> subtotalSatang;
}
}

interface ตัว​นี้​ใช้​ของ​สอง​อย่าง​ที่​คน​ซึ่ง​เคย​เขียน Java ก่อน​รุ่น 8 จะ​ไม่​คาด​หวัง คือ default กับ static เพราะ​ก่อน​หน้า​นั้น interface บรรจุ​เนื้อ​ความ​อะไร​ไม่​ได้​เลย

เส้น​เวลา​ของ​สิ่ง​ที่ interface ได้​มา​ทีหลัง​มี​สาม​ข้อ

  • default method มา​ที่ Java 8
  • static method ใน interface มา​ที่ Java 8 เช่น​กัน
  • private method ที่​ใช้​แชร์​เนื้อ​ความ​ระหว่าง default มา​ที่ Java 9 · DiscountRule ยัง​ไม่​ต้อง​ใช้​ตัว​นี้ เพราะ default ตัว​เดียว​ไม่มี​อะไร​ให้​แชร์

ผู้ implement สอง​ตัว​แรก​เป็น record ซึ่ง​เขียน​ความ​ผูกพัน​ไว้​ที่​ตัว​เอง​ตรงๆ

src/main/java/orderctl/PercentOff.java
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
package orderctl;
public record PercentOff(int pct) implements DiscountRule {
public PercentOff {
if (pct < 0 || pct > 100) {
throw new IllegalArgumentException("pct ต้องอยู่ใน 0..100 แต่ได้ " + pct);
}
}
@Override
public long apply(long subtotalSatang) {
return subtotalSatang - subtotalSatang * pct / 100;
}
}
// ─────────── src/main/java/orderctl/FlatOff.java ───────────
package orderctl;
public record FlatOff(long satang) implements DiscountRule {
@Override
public long apply(long subtotalSatang) {
return Math.max(0L, subtotalSatang - satang);
}
}

@Override ตรง​นี้​ไม่​ได้​บังคับ และ​ตราบ​ใด​ที่ apply ยัง​เป็น abstract มัน​ก็​ไม่​ได้​เพิ่ม​ด่าน​ให้​เลย · ลบ​บรรทัด​นั้น​ทิ้ง​แล้ว​เปลี่ยน​ชื่อ method ใน interface การ​แปล​ก็​ยัง​แดง​อยู่ดี ด้วย error: PercentOff is not abstract and does not override abstract method applyTo(long) in DiscountRule

ที่​ที่ @Override ทำงาน​จริง​คือ​วัน​ที่ apply กลาย​เป็น default · ตอน​นั้น​ชื่อ​ที่​สะกด​ผิด​จะ​แปล​ผ่าน​เงียบ​สนิท แล้ว​ผู้​เรียก​ได้​พฤติกรรม​ของ default แทน​ของ PercentOff · มี @Override อยู่​แล้ว​มัน​แดง​ทันที ด้วย error: method does not override or implement a method from a supertype

เมื่อ​ชนิด​นั้น​มา​จาก jar ของ​คน​อื่น ทาง​ที่​เหลือ​คือ lambda กับ method reference

นี่​คือ​ราคา​ที่ Java จ่าย​ให้ nominal typing · ชนิด​ที่​คุณ​แก้ file ไม่​ได้ จะ​ไม่มี​วัน​กลาย​เป็น DiscountRule ด้วย​ตัว​มัน​เอง

ทางออก​ไม่​ได้​อยู่​ที่​การ​แก้ file ต้นทาง แต่​อยู่​ที่​ปลาย​ทาง​ซึ่ง​เป็น​ของ​คุณ​เอง

src/main/java/orderctl/lab/Adapt.java
package orderctl.lab;
public final class Adapt {
interface DiscountRule {
long apply(long subtotalSatang);
}
/** ชนิดจาก jar ของคนอื่น · รูปร่างตรงทุกตัว แต่ไม่มีบรรทัด implements */
static final class VendorRule {
long compute(long subtotalSatang) {
return subtotalSatang - 2_000L;
}
}
public static void main(String[] args) {
VendorRule vendor = new VendorRule();
DiscountRule rule = vendor::compute;
System.out.println(rule.apply(20_000L));
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.lab.Adapt
18000

vendor::compute ทำ​หน้าที่ adapter โดย​ไม่​แตะ file ต้นทาง​เลย แต่​สังเกต​ว่า​นี่​ยัง​เป็น nominal อยู่​ทุก​ประการ ชนิด​เป้า​หมาย​มีชื่อ และ​ชื่อ​นั้น​เขียน​ไว้​ที่​ปลาย​ทาง

`@FunctionalInterface` ไม่​ได้​ทำให้ interface เป็น​เป้า​หมาย​ของ lambda

สิ่ง​ที่​ทำให้ interface เป็น​เป้า​หมาย​ของ lambda คือ​กฎ “มี abstract method ตัว​เดียว” เท่านั้น · annotation ตัว​นี้​แค่​เปลี่ยน​กฎ​ข้อ​นั้น​ให้​เป็น​ด่าน​ตอน​แปล

ใส่​มัน​ทั้ง​ที่​มี abstract สอง​ตัว แล้ว javac ตอบ​สาม​บรรทัด:

error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
Rule is not a functional interface
multiple non-overriding abstract methods found in interface Rule

JLS §9.6.4.9 อธิบาย​ว่า​เพราะ interface บาง​ตัว​เป็น functional โดย​บังเอิญ จึง​ไม่​จำเป็น​และ ไม่​พึง​ประสงค์​ที่​จะ​ติด annotation นี้​ให้​ทุก​ตัว

ประโยค​เต็ม​คือ “Because some interfaces are functional incidentally, it is not necessary or desirable that all declarations of functional interfaces be annotated with @FunctionalInterface.”

ทำไม​ไม่​หยิบ `Function<Long, Long>` จาก​ใน​กล่อง​มา​ใช้​เลย

java.util.function มี interface สำเร็จ​อยู่​แล้ว แต่​มัน​ช่วย​ไม่​ได้ที่​นี่​ด้วย​สอง​เหตุผล

เหตุผล​แรก​คือ​ชนิด DR r = f; เมื่อ f เป็น Function<Long, Long> ได้ error: incompatible types: Function<Long,Long> cannot be converted to DR · สอง​ตัว​นี้​ไม่​ได้​มี​ลาย​เซ็น​เดียวกัน​ด้วย​ซ้ำ (Long apply(Long) เทียบ long apply(long))

เหตุผล​ที่​สอง​คือ​ราคา Function<Long, Long> บังคับ​ให้​ห่อ​ค่า​เป็น object ทุก​ครั้ง​ที่​เรียก ซึ่ง​เป็น​สิ่ง​ที่​บท​ที่ 4 เพิ่ง​สอน​ให้​ระวัง · f::apply ผ่าน​ได้​ก็​เพราะ​การ​ห่อ​ค่า​อัตโนมัติ​นั้น เข้า​มา​เชื่อม​ให้​พอดี

andThen ถูก​เติม​ลง interface หลัง​จาก​มี​ผู้ implement ไป​แล้ว และ .class เดิม​ยัง​ใช้ได้

default methoddefault methodmethod ใน interface ที่​มี​เนื้อ​ความ​มา​ด้วย · ทำให้​เพิ่ม​ความ​สามารถ​ลง interface เดิม​ได้​โดย​ไม่​พัง​ผู้​ใช้​เก่า แก้​ปัญหา​เดียว​ที่​เจาะจง​มาก คือ​การ​เพิ่ม​ความ​สามารถ​ลง interface ที่​มี​ผู้​ใช้​อยู่​แล้ว โดย​ไม่​ต้อง​ไล่​แก้​ทุก​คน​ที่ implement ไว้

เอกสาร​ของ Oracle เขียน​เจตนา​ของ​ฟีเจอร์​ไว้​ตรง​ตัว: “Default methods enable you to add new functionality to the interfaces of your libraries and ensure binary compatibility with code written for older versions of those interfaces.”

คำ​ว่า binary compatibility ใน​ประโยค​นั้น​แรง​กว่า​ที่​อ่าน​ผ่านๆ มัน​แปล​ว่า​ไม่​ต้อง​แปล​ใหม่​ด้วย​ซ้ำ การ​ทดลอง​ข้าง​ล่าง​ยืนยัน​ข้อ​นี้​ด้วย checksum ของ .class เดียวกัน​สาม​รอบ

ตั้ง​ต้น​ด้วย DiscountRule รุ่น​ที่ยัง​ไม่มี andThen คือ​ลบ method ตัว​นั้น​ออก​จาก file ข้าง​บน​ก่อน · แล้ว​เขียน​ตัว​เรียก​ที่​รอ​ใช้ andThen ไว้​อีก file หนึ่ง

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// src/main/java/orderctl/Run.java — ตัวเรียกที่ใช้ andThen ซึ่งยังไม่มีตอนแปล PercentOff
package orderctl;
public final class Run {
public static void main(String[] args) {
DiscountRule member = new PercentOff(10);
System.out.println(member.andThen(DiscountRule.none()).apply(10_000L));
}
}
$ javac --release 25 -Xlint:all -d out orderctl/DiscountRule.java orderctl/PercentOff.java
$ md5 -q out/orderctl/PercentOff.class
4b83cf0b97e7e81849180455678a8627
# เติม andThen ลง DiscountRule.java แล้วแปล "เฉพาะ interface" ตัวเดียว
$ javac --release 25 -Xlint:all -d out orderctl/DiscountRule.java
$ md5 -q out/orderctl/PercentOff.class
4b83cf0b97e7e81849180455678a8627
$ javac --release 25 -Xlint:all -cp out -d out orderctl/Run.java
$ md5 -q out/orderctl/PercentOff.class
4b83cf0b97e7e81849180455678a8627
$ java -cp out orderctl.Run
9000

ค่า md5 ตัว​นี้​ผูก​กับ​ตัว​อักษร​ทุก​ตัว​ใน PercentOff.java ของ​คุณ จึง​ไม่ใช่​เลข​ที่​ต้อง​ตรง​กับ​ของ​ผม · สิ่ง​ที่​ต้อง​ตรง​คือ สาม​ค่า​เท่า​กัน​ทั้ง​สาม​รอบ ซึ่ง​แปล​ว่า PercentOff.class ไม่​เคย​ถูก​แตะ​เลย

การ​ทดลอง​นี้​เรียก javac ตรงๆ ไม่​ผ่าน Maven เพราะ​ประเด็น​ของ​มัน​คือ .class ที่​แปล​ไว้​แล้ว · Maven จะ​แปล​ใหม่​ให้​เอง​เมื่อ​เห็น​ว่า source เปลี่ยน ซึ่ง​กลบ​สิ่ง​ที่​เรา​กำลัง​วัด​พอดี

ข้อ​นี้​คือ​ด้าน​ที่ Go จ่าย​แพง​กว่า

คอร์ส Go ยก​กฎ​ของ​ภาษา​มา​ไว้​ตั้งแต่​บท​ที่ 5 ว่า​ชนิด​หนึ่ง​เข้า​กับ interface เมื่อ​มี method ครบ

ผล​อีก​ด้าน​ของ​กฎ​เดียวกัน​คือ การ​เพิ่ม method ลง interface ที่​มี​คนใช้​แล้ว ทำให้​ทุก​คน​ที่ implement ไว้​หลุด​ออก​ทันที และ​ภาษา​ไม่มี​ทาง​แก้​ให้

ท่า​ที่​ชุมชน Go ใช้​จึง​เป็นการ​ตั้ง interface ตัว​ใหม่​แล้ว​ถาม​ชนิด​ตอน​รัน​ว่า​รับ​ได้​ไหม · io.Copy ใน​กล่อง​มาตรฐาน​เขียน​แบบ​นี้​ตรงๆ ด้วย src.(WriterTo) แล้ว​ต่อ​ด้วย dst.(ReaderFrom)

Java เติม​เป็น default แล้ว​จบ · ผู้​ใช้​เก่า​ไม่​ต้อง​แก้​และ​ไม่​ต้อง​แปล​ใหม่

สาม​ข้อ​คือ​การ​ชน​กัน​ที่​แดง​ตอน​แปล การ​บัง​ที่​เงียบ​สนิท และ method ของ Object ที่​ทับ​ไม่​ได้

ข้อ​แรก รับ default ชื่อ​เดียวกัน​จาก​สอง interface แล้ว​แดง​ทันที javac ไม่​เลือก​ให้ และ​ไม่มี​ลำดับ​ความ​สำคัญ​ให้​จำ

// ❌ รูปที่ผิด · ไม่ได้เขียนทับ label() ไว้ที่ตัว Rule
error: types Labelled and Auditable are incompatible;
static final class Rule implements Labelled, Auditable { }
^
class Rule inherits unrelated defaults for label() from types Labelled and Auditable

ทาง​แก้​คือ​เขียน​ทับ แล้ว​เรียก​ตัว​ที่​ต้องการ​ด้วย​รูป Interface.super

src/main/java/orderctl/lab/Both.java
package orderctl.lab;
public final class Both {
interface Labelled {
default String label() { return "กฎ"; }
}
interface Auditable {
default String label() { return "กฎที่ตรวจสอบได้"; }
}
static final class Rule implements Labelled, Auditable {
@Override
public String label() {
return Labelled.super.label() + " / " + Auditable.super.label();
}
}
public static void main(String[] args) {
System.out.println(new Rule().label());
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.lab.Both
กฎ / กฎที่ตรวจสอบได้

ข้อ​ที่​สอง method ของ superclass บัง​ของ interface โดย​ไม่มี​เสียง​เตือน ข้อ​นี้​อันตราย​กว่า​ข้อ​แรก เพราะ​ข้อ​แรก​แดง​ตอน​แปล ส่วน​ข้อ​นี้​เขียว​สนิท

src/main/java/orderctl/lab/ClassWins.java
package orderctl.lab;
public final class ClassWins {
interface Labelled {
default String label() { return "จาก default ของ interface"; }
}
static class Base {
public String label() { return "จาก superclass"; }
}
static final class Rule extends Base implements Labelled { }
public static void main(String[] args) {
System.out.println(new Rule().label());
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.lab.ClassWins
จาก superclass

-Xlint:all ไม่​คาย​อะไร​เลย​กับ file นี้ · กฎ​อยู่​ใน JLS §8.4.8 ตรง​เงื่อนไข “No concrete method inherited by C from its direct superclass type has a signature that is a subsignature of the signature of m as a member of D.”

ข้อ​ที่​สาม default ทับ method ของ Object ไม่​ได้ ซึ่ง​ตัด​สำนวน “ให้ interface แจก toString ให้​ทุก​คน” ทิ้ง​ทั้ง​กอง

// ❌ รูปที่ผิด
error: default method toString in interface Labelled overrides a member of java.lang.Object
default String toString() { return "กฎ"; }
^

interface ถือ state ของ instance ไม่​ได้​และ​มี constructor ไม่​ได้ · ส่วน​แม่​มี​ได้​ตัว​เดียว

abstract classabstract classclass ที่​สร้าง instance ไม่​ได้ มี constructor และ state ร่วม​ให้​ลูก · class ลูก​มี​แม่​ได้​ตัว​เดียว ซึ่ง​เป็น​เส้น​ที่​แยก​มัน​ออก​จาก interface กับ interface ไม่ใช่​เรื่อง​ของ​สไตล์ ถ้า​คุณ​มา​จาก C# ข้อ​นี้​คือ​ข้อ​ที่​ต้อง​ปรับ​ก่อน · ใน Java มัน​มี​เส้น​แบ่ง​ที่​วัด​ได้​สาม​ข้อ

ข้อ​แรก​คือ state ของ instance JLS §9.3 เขียน​ว่า “Every field declaration in the body of an interface declaration is implicitly public, static, and final.” เขียน​ตัว​นับ​ลง interface จึง​ไม่​ได้

// ❌ รูปที่ผิด
error: cannot assign a value to static final variable seen
seen = seen + 1;
^

ข้อ​ที่​สอง​คือ constructor เขียน HasCtor() {} ลง interface แล้ว​ไม่​ได้​ผิด​แค่​กฎ มัน​ไม่​อยู่​ใน​ไวยากรณ์​เลย javac ตอบ​ว่า error: <identifier> expected

ข้อ​ที่​สาม​คือ​จำนวน​แม่ class ลูก​มี​แม่​ได้​ตัว​เดียว ส่วน interface กี่​ตัว​ก็ได้ · เอกสาร​ของ Oracle เขียน​เหตุผล​ไว้​เอง​ว่า “One reason why the Java programming language does not permit you to extend more than one class is to avoid the issues of multiple inheritance of state, which is the ability to inherit fields from multiple classes.”

สอง​ข้อ​แรก​รวม​กัน​ได้​รูป​ที่ interface ทำ​ไม่​ได้ คือ template method ที่​ตรึง​ลำดับ​ไว้​แล้ว​เจาะ​ช่อง​ให้​ลูก​เติม

src/main/java/orderctl/lab/Capped.java
package orderctl.lab;
public final class Capped {
interface DiscountRule {
long apply(long subtotalSatang);
}
abstract static class CappedRule implements DiscountRule {
private final long capSatang; // state ของ instance — interface ถือไม่ได้
protected CappedRule(long capSatang) { // constructor — interface มีไม่ได้
if (capSatang < 0) {
throw new IllegalArgumentException("เพดานส่วนลดติดลบไม่ได้");
}
this.capSatang = capSatang;
}
/** ช่องเดียวที่ class ลูกเติมได้ */
protected abstract long discountSatang(long subtotalSatang);
/** final = ลูกเปลี่ยนลำดับนี้ไม่ได้ */
@Override
public final long apply(long subtotalSatang) {
return subtotalSatang - Math.min(discountSatang(subtotalSatang), capSatang);
}
}
static final class CappedPercentOff extends CappedRule {
private final int pct;
CappedPercentOff(int pct, long capSatang) {
super(capSatang);
this.pct = pct;
}
@Override
protected long discountSatang(long subtotalSatang) {
return subtotalSatang * pct / 100;
}
}
public static void main(String[] args) {
DiscountRule capped = new CappedPercentOff(50, 4_000L);
System.out.println(capped.apply(20_000L));
DiscountRule oneOff = new CappedRule(1_000L) {
@Override
protected long discountSatang(long subtotalSatang) {
return subtotalSatang * 30 / 100;
}
};
System.out.println(oneOff.apply(20_000L));
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.lab.Capped
16000
19000

new CappedRule(4_000L) เปล่าๆ ได้ error: CappedRule is abstract; cannot be instantiated · แต่​รูป​ที่​เห็น​ใน​บรรทัด​ล่าง​เติม​ช่อง​ที่​เจาะ​ไว้​ไป​พร้อม​กัน จึง​สร้าง​ได้​ทันที​โดย​ไม่​ต้อง​ตั้ง​ชื่อ class

constructor ของ​แม่​ห้าม​เรียก method ที่​ลูก​ทับ​ได้ · และ `-Xlint` เตือน​ให้​ไม่​ครบ

CappedRule ตรวจ​ค่า​ใน constructor แต่​ไม่​เรียก discountSatang ที่​นั่น ข้อ​นี้​ตั้งใจ

เหตุผล​อยู่​ที่​ลำดับ · constructor ของ​แม่​ทำงาน​ก่อน field ของ​ลูก​ถูก​ตั้ง​ค่า method ของ​ลูก​ที่​ถูก​เรียก​ตอน​นั้น​จึง​เห็น​ค่า​ศูนย์​กับ null ล้วน

-Xlint:all มี​ด่าน​ชื่อ this-escape ให้​เรื่อง​นี้ แต่​มัน​เตือน​เฉพาะ​ตอน​ที่ class ลูก​เกิด​ขึ้น​จาก นอก package ได้​จริง คือ​ตัว class เป็น public และ constructor เป็น public หรือ protected · รูป​นั้น​จะ​ได้ warning: [this-escape] possible 'this' escape before subclass is fully initialized

แต่​รูป​ใน Capped.java ข้าง​บน​เงียบ​สนิท เพราะ CappedRule ที่​นั่น​เป็น​ชนิด​ซ้อน​ที่​ไม่​ได้​ประกาศ public · ใส่​การ​เรียก discountSatang ลง constructor ของ​มัน​แล้ว -Xlint:all ไม่​คาย​อะไร​เลย · ด่าน​นี้​จึง​ไม่ใช่​ตัว​ที่​กัน​ความ​ผิด​พลาด​ข้อ​นี้​ให้​คุณ คุณ​ต้อง​กันเอง

record สืบทอด​ไม่​ได้​เลย ชนิด​ของ​โดเมน​นี้​จึง​เหลือ​เครื่อง​มือ​เดียว​คือ​การ​ห่อ

CappedRule ข้าง​บน​เป็น​วัตถุ​จัด​แสดง ไม่ใช่​รูป​ที่​คอร์ส​แนะนำ เพราะ​ราคา​ที่​จ่าย​เพื่อ​ได้ template method คือ CappedPercentOff เลิก​เป็น record

ข้อ​นี้​ไม่ใช่​ความ​เห็น มัน​คือ​ไวยากรณ์ · JLS §8.10 เขียน​ว่า “A record declaration does not have an extends clause, so it is not possible to explicitly declare a direct superclass type, even Record.” และ javac ไม่​ตอบ​ด้วย​ข้อความ​เชิง​ความ​หมาย​ด้วย​ซ้ำ

// ❌ รูปที่ผิด
error: '{' expected
record CappedPercent(int pct) extends CappedRule {
^

ลูก​ศร​ชี้​ที่​ช่อง​ว่าง​ถัด​จาก (int pct) ไม่ใช่​ที่​คำ​ว่า extends เพราะ​ตัว​แยก​คำ​เลิก​อ่าน​ต่อ​ตั้งแต่​ตรง​นั้น

Line กับ Order ของ orderctl เป็น record ส่วน Discount เป็น sealed interface ที่​สมาชิก​ทั้ง​สาม​ตัว​ก็​เป็น record · ชนิด​ข้อมูล​ของ​โดเมน​นี้​จึง​ติด​ข้อ​จำกัด​เดียวกัน​ทั้ง​กอง และ​เครื่อง​มือ​ที่​เหลือ​ให้​ใช้​คือ interface กับ​การ​ประกอบ​เท่านั้น

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// src/main/java/orderctl/CountingRule.java — ห่อ DiscountRule ตัวอื่นไว้ข้างใน แทนการสืบทอด
package orderctl;
import java.util.Objects;
public final class CountingRule implements DiscountRule {
private final DiscountRule inner;
private int seen;
public CountingRule(DiscountRule inner) {
this.inner = Objects.requireNonNull(inner, "inner");
}
@Override
public long apply(long subtotalSatang) {
seen++;
return inner.apply(subtotalSatang);
}
public int seen() {
return seen;
}
}
ตัว​นี้​คือ​คู่​เทียบ​ตรง​ตัว​ของ `Counting` ใน​บท​ที่ 5 ของ​คอร์ส Go

Go ฝัง interface ไว้​ใน​โครงสร้าง​แล้ว​ได้ method ส่ง​ต่อ​มา​ฟรี ส่วน Java ต้อง​เขียน apply ออก​มา​เอง · ที่​นี่​เท่า​กัน​เพราะ interface มี method เดียว

สิ่ง​ที่​ต่าง​กัน​จริง​คือ virtual dispatch ที่​ย้อน​กลับ​เข้า​ตัว​ห่อ ซึ่ง Go ไม่มี · บท​ที่ 5 ของ​คอร์ส Go สาธิต​ด้วย Printer กับ Fancy ที่​พิมพ์ <p:base> ไม่ใช่ <p:fancy> เพราะ​ชนิด​ข้าง​ใน​ไม่​เคย​รู้​ว่า​มี​ตัว​ห่อ​อยู่

แปล​ว่า Go เขียน template method ไม่​ได้​เลย ส่วน Java เขียน​ได้ แล้ว​ต้อง​รับ​กฎ​มา​ทั้ง​ชุด · และ​ข้อ​ที่​แพง​ที่สุด​คือ​ชนิด​ที่​รับ template method ต้อง​เลิก​เป็น record

Discount เป็น​ข้อมูล​ชุด​ปิด​ที่ compiler ไล่​ให้​ครบ · DiscountRule เป็น​พฤติกรรม​ชุด​เปิด​ที่​ต่อ​เพิ่ม​ได้

sealed interfacesealed interfaceinterface ที่​ระบุ​ราย​ชื่อ subtype ที่​อนุญาต · subtype ต้อง​ปิด​ตัว​เอง (class เขียน `final`, `sealed` หรือ `non-sealed` ส่วน record กับ enum เป็น final อยู่​แล้ว) ของ​บท​ที่ 2 มา​บรรจบ​กับ interface ของ​บท​นี้​ตรง​นี้​พอดี สอง​อย่าง​นี้​หน้าตา​คล้าย​กัน​แต่​ให้​คนละ​อย่าง

Discount คือข้อมูล ชุด​ปิด · ใคร​ก็​เพิ่ม​กรณี​ไม่​ได้​นอกจาก​เจ้าของ file และ​เมื่อ​เพิ่ม compiler บอก​ทุก​จุด​ที่​ต้อง​ตาม​ไป​แก้

DiscountRule คือพฤติกรรม ชุด​เปิด · ใคร​ก็​ต่อ​กฎ​ใหม่​เข้า​มา​ได้​โดย​ไม่​แตะ code เดิม แต่​จะ​ไม่มี​วัน​ไล่​ราย​ชื่อ​กฎ​ทั้งหมด​ได้

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// src/main/java/orderctl/Rules.java — สะพานจากชุดปิดไปชุดเปิด
package orderctl;
import java.util.List;
public final class Rules {
private Rules() {}
/** ชุดปิด: เพิ่มกรณีที่สี่ลง Discount แล้ว switch นี้แดงตอนแปลทันที */
public static DiscountRule of(Discount d) {
return switch (d) {
case Discount.Percent(int pct) -> new PercentOff(pct);
case Discount.Fixed(long satang) -> new FlatOff(satang);
case Discount.None ignored -> DiscountRule.none();
};
}
/** ชุดเปิด: ต่อกฎกี่ตัวก็ได้ รวมกฎที่คนอื่นเขียนขึ้นทีหลัง */
public static DiscountRule chain(List<? extends DiscountRule> rules) {
DiscountRule chained = DiscountRule.none();
for (DiscountRule r : rules) {
chained = chained.andThen(r);
}
return chained;
}
}

เพิ่ม BuyOneGetOne ลง Discount แล้ว​ไม่​แตะ Rules.of จะ​ได้ error: the switch expression does not cover all possible input values ทันที · ความ​สามารถ​ข้อ​นี้​มา​ที่ Java 21 พร้อม pattern matching ใน switch

flowchart LR
  subgraph closed["ชุดปิด · Discount คือข้อมูล"]
    D1["Discount.Percent"]
    D2["Discount.Fixed"]
    D3["Discount.None"]
  end
  B["Rules.of<br/>switch ที่ compiler บังคับให้ครบ"]
  subgraph open["ชุดเปิด · DiscountRule คือพฤติกรรม"]
    R1["PercentOff"]
    R2["FlatOff"]
    R3["CountingRule"]
    R4["กฎที่คนอื่นเขียนขึ้นทีหลัง"]
  end
  D1 --> B
  D2 --> B
  D3 --> B
  B --> R1
  B --> R2
  R1 --> C["andThen ต่อเป็นสายเดียว"]
  R2 --> C
  R3 --> C
  R4 --> C

คำ​บรรยาย​ภาพ: ราย​ชื่อ​กรณี​ทาง​ซ้าย​ปิดตาย เพิ่ม​กรณี​ใหม่​แล้ว Rules.of แดง​ทันที · ราย​ชื่อ​กฎ​ทาง​ขวา​เปิด​ไว้ ใคร​ต่อ​เพิ่ม​ก็ได้​แต่​ไล่​ราย​ชื่อ​ทั้งหมด​ไม่​ได้ · Rules.of คือ​สะพาน​ที่​แปลง​จาก​ฝั่ง​ซ้าย​ไป​ฝั่ง​ขวา

ประโยค​ที่​ควร​จำ​มี​ข้อ​เดียว — เลือก​ชุด​ปิด​เมื่อ​รายการ​กรณี​นิ่ง​แต่​การก​ระ​ทำ​จะ​เพิ่ม · เลือก​ชุด​เปิด​เมื่อ​การก​ระ​ทำ​นิ่ง​แต่​รายการ​กรณี​จะ​เพิ่ม

List<PercentOff> ส่ง​เข้า​ที่​ที่​ต้องการ List<DiscountRule> ไม่​ได้ แต่ Java แก้​ที่​ลาย​เซ็น​ได้

PercentOff เป็น DiscountRule แต่ List<PercentOff> ไม่ใช่ List<DiscountRule> · ข้อ​นี้​เหมือน​กัน​ทั้ง​สอง​ภาษา

// ❌ รูปที่ผิด · chain รับ List<DiscountRule> ตรงๆ
error: incompatible types: List<PercentOff> cannot be converted to List<DiscountRule>
System.out.println(chain(offers, 20_000L));
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output

Go จบ​ตรง​นี้ · บท​ที่ 5 ของ​คอร์ส Go ต้อง​วน​คัด​ลอก​ที​ละ​ตัว​เข้า​กล่อง interface ใหม่ ซึ่ง​เป็น​ราคา​ที่ Go เลือก​จ่าย​อย่าง​เปิด​เผย

Java แก้​ที่​ลาย​เซ็น​ได้​ด้วย List<? extends DiscountRule> โดย​ไม่​ต้อง​คัด​ลอก​อะไร​เลย

src/main/java/orderctl/lab/Var.java
package orderctl.lab;
import java.util.List;
public final class Var {
interface DiscountRule {
long apply(long subtotalSatang);
}
record PercentOff(int pct) implements DiscountRule {
@Override
public long apply(long subtotalSatang) {
return subtotalSatang - subtotalSatang * pct / 100;
}
}
static long chain(List<? extends DiscountRule> rules, long subtotalSatang) {
long total = subtotalSatang;
for (DiscountRule r : rules) {
total = r.apply(total);
}
return total;
}
public static void main(String[] args) {
List<PercentOff> offers = List.of(new PercentOff(10), new PercentOff(5));
System.out.println(chain(offers, 20_000L));
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.lab.Var
17100

ราคา​ของ​ทาง​นี้​คือ​ผู้​อ่าน​ต้อง​เจอ​เครื่องหมาย ? ตั้งแต่​บท​นี้ ทั้ง​ที่​คอร์ส​ยัง​ไม่​ได้​สอน generics · กฎ​ที่​ใช้ได้​พอ​สำหรับ​ตอน​นี้​คือ ใส่ ? extends ที่​ลาย​เซ็น​เมื่อ​คุณ​แค่อ่านจาก collection นั้น

Main เรียก​ทั้ง​กฎ​เดี่ยว กฎ​ที่​ต่อ​กัน กฎ​ที่มา​จาก​ข้อมูล และ​กฎ​ที่​ห่อ​กฎ​อื่น​ไว้

src/main/java/orderctl/Main.java
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
package orderctl;
import java.util.List;
public final class Main {
public static void main(String[] args) {
long subtotal = 20_000L;
DiscountRule member = new PercentOff(10);
DiscountRule coupon = new FlatOff(3_000L);
System.out.println(member.apply(subtotal));
System.out.println(member.andThen(coupon).apply(subtotal));
DiscountRule fromData = Rules.of(new Discount.Percent(25));
System.out.println(fromData.apply(subtotal));
CountingRule counted = new CountingRule(member);
DiscountRule all = Rules.chain(List.of(counted, coupon));
System.out.println(all.apply(subtotal));
System.out.println(all.apply(10_000L));
System.out.println(counted.seen());
DiscountRule lambda = s -> s - 1_500L; // ไม่มีชื่อ class แต่ยังต้องมีชนิดเป้าหมายที่ประกาศไว้
System.out.println(lambda.apply(subtotal));
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Main
18000
15000
15000
15000
6000
2
18500

exec:java ไม่​ผูก​กับ phase ใด จึง​ต้อง​มี​คำ​ว่า compile นำ​หน้า​เสมอ — กฎ​เดียว​กับ​บท​ที่ 3

บรรทัด​ที่​ควร​ดู​สอง​ครั้ง​คือ​เลข 2 · counted ถูก​เรียก​สอง​รอบ​เพราะ all.apply(...) ถูก​เรียก​สอง​ครั้ง ซึ่ง​พิสูจน์​ว่า chain เก็บ CountingRule ตัว​เดิม​ไว้​จริง ไม่​ได้​คัด​ลอก​มัน​ไป

DiscountRule เสร็จ​แล้ว และ​คุณ​เห็น​ครบ​ทั้ง​ด้าน​ที่ Java ได้​เปรียบ​และ​ด้าน​ที่ Java จ่าย​แพง​กว่า

คุณ​ออกแบบ interface method เดียว ต่อ​กฎ​เข้า​ด้วย​กัน​ด้วย andThen ห่อ​กฎ​ด้วย CountingRule และ​เห็น​แล้ว​ว่า​ทำไม​ชนิด​ของ​โดเมน​นี้​ถึง​เลือก​การ​ประกอบ​แทน​การ​สืบทอด

เรื่องJava จ่าย​อะไรGo จ่าย​อะไร
ผูก​ชนิด​เข้า​กับ interfaceต้อง​แก้ file ของ​ผู้​สร้าง​ชนิด ซึ่ง​บางที​ไม่ใช่ file ของ​คุณไม่​ต้อง​แก้​อะไร​เลย แต่​ต้อง​เขียน​ด่าน var _ Rule = ... เอง ไม่​งั้น​ชื่อ​ที่​สะกด​ผิด​เงียบ​สนิท
เพิ่ม​ความ​สามารถ​ลง interface เดิมเติม​เป็น default แล้ว​จบ ไม่​ต้อง​แปล​ใหม่ผู้ implement เดิม​หลุด​ทันที ต้อง​ตั้ง interface ตัว​ใหม่​แล้ว​ถาม​ชนิด​ตอน​รัน
ตรึง​ลำดับ​แล้ว​เจาะ​ช่อง​ให้​ลูก​เติมเขียน​ได้​ด้วย abstract class แต่​ชนิด​นั้น​ต้อง​เลิก​เป็น recordเขียน​ไม่​ได้​เลย เพราะ​การ​ฝัง​ไม่มี virtual dispatch ย้อน​กลับ
ส่ง collection ของ​ชนิด​ลูกใส่ ? extends ที่​ลาย​เซ็นวน​คัด​ลอก​ที​ละ​ตัว​เข้า​กล่อง interface ใหม่

apply ยัง​มี​รู​โหว่​ที่​บท​นี้​ปิด​ไม่​ได้ — มัน​ไม่มี​ทาง​บอก​ว่า “กฎ​นี้​ใช้​กับ​ออ​เด​อร์​นี้​ไม่​ได้” ทาง​เดียว​ที่​เหลือ​คือ​โยน​ความ​ผิด​พลาด​ออก​ไป

บท​หน้า​ปิด​ท้าย​ด้วย​ข้อ​จำกัด​ที่​ตาม​มา​จาก​ตรง​นั้น​พอดี — lambda โยน checked exception ไม่​ได้ และ​นั่น​ทำให้ interface ทั้ง​กอง​ของ​บท​นี้​ต้อง​ออกแบบ​ใหม่​ทันที​ที่​การ​ตรวจ​ความ​ถูก​ต้อง​เข้า​มา


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

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

  • JLS SE25 · Chapter 9. Interfaces (เข้า​ถึง 2026-08-27) — §9.3 field ใน interface เป็น public static final เสมอ · §9.6.4.9 เหตุผล​ที่​ไม่​ควร​ติด @FunctionalInterface ให้​ทุก​ตัว
  • JLS SE25 · Chapter 8. Classes (เข้า​ถึง 2026-08-27) — §8.4.8 ที่มา​ของ​กฎ “class บัง interface” · §8.10 record ไม่มี extends clause ใน​ไวยากรณ์
  • The Java Tutorials — Default Methods (เข้า​ถึง 2026-08-27) — เจตนา​ของ​ฟีเจอร์​และ​คำ​ว่า binary compatibility · หน้า​นี้​ประทับ​ตัว​เอง​ว่า​เขียน​ไว้​สำหรับ JDK 8 จึง​อ้าง​เฉพาะ​เจตนา ไม่​อ้าง​สำนวน code
  • The Java Tutorials — Multiple Inheritance of State (เข้า​ถึง 2026-08-27) — เหตุผล​ที่ Java ให้​มี​แม่​ได้​ตัว​เดียว
  • JEP 395: Records (JDK 16) · JEP 409: Sealed Classes (JDK 17) · JEP 441: Pattern Matching for switch (JDK 21) — เส้น​เวลา​ของ​ฟีเจอร์​ที่​บท​นี้​ใช้​ร่วม​กัน
  • JEP 213: Milling Project Coin (เข้า​ถึง 2026-08-27) — Status Closed/Delivered · Release 9 · private method ใน interface มา​ถึง​พร้อม​รุ่น​นี้ ส่วน default กับ static มา​ก่อน​แล้ว​ที่ Java 8
  • Go FAQ — Why is there no type inheritance? (เข้า​ถึง 2026-08-27) — “Interfaces can be added after the fact if a new idea comes along or for testing—without annotating the original types.” และ “Because there are no explicit relationships between types and interfaces, there is no type hierarchy to manage or discuss.”
  • บท​ที่ 5 ของ​คอร์ส Go บน​เว็บ​นี้ — หัวข้อ “ห่อ Rule ด้วย Rule แทน template method” และ​กล่อง “ทำไม​ต้อง​เขียน var _ Rule = PercentOff{} ด้วย”

ทุก​ข้อความ error ใน​บท​นี้​ยก​มา​จาก javac 25.0.4.1 บน macOS arm64 เมื่อ 2026-08-27 · code ทั้ง​บท​แปล​ผ่าน​ด้วย javac --release 25 -Xlint:all -Werror โดย​ไม่มี warning และ​ไม่มี --enable-preview · block ที่​ติด​ป้าย ชิ้นส่วน ต้อง​แปล​รวม​กับ file อื่น​ของ​โปร​เจ​กต์ ส่วน block ที่​เหลือ​แปล​เดี่ยว​ได้​ทันที

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

ข้อ 1 / 3

ชนิด `Foreign` จาก jar ของคนอื่นมี `public long apply(long)` ที่รูปร่างตรงกับ `DiscountRule` ทุกตัวอักษร ข้อใดถูก