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

`by` ให้ forwarder ฟรี แล้ว​เก็บ​กับ​ดัก​ไว้​ใน​ที่​ที่​มอง​ไม่​เห็น

บท​ที่ 4 ทำให้ orderctl คิด​ยอด​ได้ บท​นี้​ทำให้​มัน​รับ​กฎ​ส่วนลด​ที่​ยัง​ไม่มี​ใคร​เขียน

Kotlin ให้​เครื่อง​มือ​สอง​อย่าง​ที่ Java ไม่มี​เลย คือ by กับ extension function · ทั้ง​สอง​อย่าง​ดู​เหมือน​ลัด​ทาง​ได้ และ​ทั้ง​สอง​อย่าง​มี​ที่​ที่​มัน​เงียบ​สนิท

📦 บท​นี้​ต่อ​จาก build ของ​บท​ที่ 3

build.gradle.kts กับ settings.gradle.kts ที่​บท​ที่ 3 เขียน​ไว้ ยัง​ใช้​ตัว​เดิม​ทั้งหมด · บท​นี้​ไม่​เพิ่ม dependency สัก​บรรทัด

ห้อง​ทดลอง​ของ​บท​นี้​อยู่​ใน src/main/kotlin/orderctl/RulesDemo.kt · ชี้​บรรทัด mainClass ใน build.gradle.kts ไป​ที่ orderctl.RulesDemoKt แบบ​เดียว​กับ​บท​ที่ 4

ทุก​ผลลัพธ์​ใน​บท​นี้​มา​จาก gradle -q run · ธง -q ตัด​รายงาน​ของ Gradle ออก เหลือ​เฉพาะ​สิ่ง​ที่​โปรแกรม​พิมพ์​เอง

interface ตัว​เดียว​ถือ​ของ​สี่​อย่าง สอง​อย่าง​ที่​ผู้ implement ต้อง​เขียน และ​สอง​อย่าง​ที่​ได้​มา​ฟรี

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

src/main/kotlin/orderctl/DiscountRule.kt
package orderctl
interface DiscountRule {
fun name(): String
fun applySatang(subtotalSatang: Long): Long
fun describe(): String = "กฎ " + name()
fun applyTwice(subtotalSatang: Long): Long = applySatang(applySatang(subtotalSatang))
}

describe() กับ applyTwice() มี​เนื้อ​ความ​ติด​มา​ด้วย ผู้ implement จึง​ไม่​ต้อง​เขียน​สอง​ตัว​นี้ · Kotlin ไม่​ต้อง​มี​คำ​ว่า default นำ​หน้า เพราะ interface ของ Kotlin มี​เนื้อ​ความ​ได้​ตั้งแต่​วัน​แรก

ส่วน Java ต้อง​พิมพ์​คำ​ว่า default กำกับ และ​ความ​สามารถ​ข้อ​นั้น​เพิ่ง​มา​ที่ Java 8 · บท​ที่ 5 ของ​คอร์ส Java ไล่​เส้น​เวลา​ของ​มัน​ไว้​สาม​ข้อ

ผู้ implement สอง​ตัว​แรก​เขียน​ความ​ผูกพัน​ไว้​ที่​ตัว​เอง​ตรงๆ ด้วย​เครื่องหมาย​ทวิภาค

src/main/kotlin/orderctl/PercentOff.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี DiscountRule อยู่ในโปรเจกต์ด้วย
package orderctl
class PercentOff(private val pct: Int) : DiscountRule {
init { require(pct in 0..100) { "pct ต้องอยู่ใน 0..100 แต่ได้ $pct" } }
override fun name() = "percent-$pct"
override fun applySatang(subtotalSatang: Long) = subtotalSatang - subtotalSatang * pct / 100
}
class FlatOff(private val offSatang: Long) : DiscountRule {
override fun name() = "flat-$offSatang"
override fun applySatang(subtotalSatang: Long) = maxOf(0L, subtotalSatang - offSatang)
}

override ของ Kotlin เป็น​คำ​ใน​ภาษา ไม่ใช่ annotation ที่​เติม​หรือ​ไม่​เติม​ก็ได้​แบบ @Override ของ Java ซึ่ง​บท​ที่ 5 ของ​คอร์ส​นั้น​เขียน​เอง​ว่า “ไม่​ได้​บังคับ”

ลืม​พิมพ์​แล้ว​การ​แปล​แดง​ทันที​ด้วย 'name' hides member of supertype 'DiscountRule' and needs an 'override' modifier.

class ของ Kotlin ปิด​การ​สืบทอด​ไว้​โดย​ปริยาย

PercentOff ข้าง​บน​ไม่มี​คำ​ว่า open การ​สืบทอด​จึง​ทำ​ไม่​ได้​เลย

// ❌ แปลไม่ผ่าน
package orderctl
class CappedPercentOff(pct: Int) : PercentOff(pct)
e: file:///…/src/main/kotlin/orderctl/Probe.kt:4:36 This type is final, so it cannot be extended.

Java กลับ​ด้าน​กับ​ข้อ​นี้ — class Base เปล่าๆ ที่​ไม่มี​คำ​ว่า final ถูก​สืบทอด​ได้​เลย javac ของ JDK 25 คืน​รหัส​ออก 0 · Kotlin เลือก​ให้การ​เปิด​เป็น​เรื่อง​ที่​ต้อง​ประกาศ

ผล​คือ​เครื่อง​มือ​ที่​เหลือ​ให้​ต่อ​ของ​เข้า​กับ​กฎ​เดิม​มี​สอง​อย่าง คือ interface กับ​การ​ห่อ

extension function เติม method ให้​ชนิด​ของ​คน​อื่น​ได้ แต่​พา​เข้า interface ไม่​ได้

หัวข้อ​ที่​มีชื่อ​ว่า “extension function เติม method ให้​ชนิด​ของ​คน​อื่น​ได้ แต่​พา​เข้า interface ไม่​ได้”

ชนิด​ที่​มี method ครบ​ทุก​ตัว​ตาม​รูป​ร่าง ยัง​ไม่ใช่​สมาชิก​ของ interface ถ้า​ไม่​ได้​ประกาศ​ไว้

สมมติ​ว่า Coupon มา​จาก library ของ​ทีม​อื่น เรา​แก้ file ต้นทาง​ของ​มัน​ไม่​ได้

extension functionextension functionfunction ที่​เรียก​เหมือน​เป็น​สมาชิก​ของ​ชนิด​ที่​เรา​ไม่​ได้​เป็น​เจ้าของ · compiler เลือก​ตัว​ไหน​ตัดสิน​ตอน​แปล​จาก​ชนิด​ที่​ประกาศ ไม่ใช่​ตอน​รัน​จาก​ชนิด​จริง สมาชิก​ใน `List<T>` จึง​ได้​ตัว​ของ `T` เสมอ เติม name() กับ applySatang() เข้าไป​ได้​ทั้ง​สอง​ตัว แล้ว​เรียก​ได้​เหมือน​เป็น​สมาชิก​จริง · แต่​พอ​เอา​ไป​วาง​ใน​ที่​ที่​ต้องการ DiscountRule มัน​แดง

// ❌ แปลไม่ผ่าน · src/main/kotlin/orderctl/Coupon.kt
package orderctl
class Coupon(val code: String, val offSatang: Long)
fun Coupon.name(): String = "coupon-" + code
fun Coupon.applySatang(subtotalSatang: Long): Long = maxOf(0L, subtotalSatang - offSatang)
val songkran: DiscountRule = Coupon("SONGKRAN", 3_000L)
e: file:///…/src/main/kotlin/orderctl/Coupon.kt:10:28 Initializer type mismatch: expected 'DiscountRule', actual 'Coupon'.

นี่​คือ nominal typingnominal typingการ​เข้า​กัน​ของ​ชนิด​ตัดสิน​ด้วย​ชื่อ​ที่​ประกาศ​ไว้ ไม่ใช่​ด้วย​รูป​ร่าง · Kotlin อยู่​ฝั่ง​เดียว​กับ Java ไม่ใช่​ฝั่ง Go และ extension function ก็​ไม่​ช่วย compiler ตอบ​ว่า `initializer type mismatch` · การ​เข้า​กัน​ของ​ชนิด​ตัดสิน​จาก​ชื่อ​ที่​ประกาศ​ไว้ ไม่ใช่​จาก​รูป​ร่าง​ของ method ที่​มี​อยู่

Kotlin ยืน​ฝั่ง​เดียว​กับ Java ไม่ใช่​ฝั่ง Go

เหตุผล​อยู่​ใน​ตัว​คำ​ว่า extension เอง — มัน​ไม่​ได้​เป็น​สมาชิก​ของ​ชนิด มัน​เป็น​ฟังก์ชัน​ที่ compiler เลือก​ให้​ตอน​แปล แล้ว​วาง​เนื้อ​ความ​ไว้​ที่​จุด​เรียก · ตาราง method ของ Coupon ไม่​เคย​เปลี่ยน

จุด​เทียบ​สาม​ภาษา​อยู่​ที่​บรรทัด​เดียวกัน​นี้

ภาษาชนิด​ที่​มี method ครบ​แต่​ไม่​ได้​ประกาศ​ความ​ผูกพัน
Goเข้า​กับ interface ได้​ทันที ไม่มี​ที่​ให้​เขียน​ความ​ผูกพัน​ด้วย​ซ้ำ
Javaไม่​เข้า ต้อง​พิมพ์ implements
Kotlinไม่​เข้า ต้อง​พิมพ์​เครื่องหมาย​ทวิภาค และ extension function ไม่​ช่วย

บท​ที่ 5 ของ​คอร์ส Go ตั้ง​ชื่อ​หัวข้อ​แรก​ไว้​ว่า “ไม่มี​ที่​ให้​ประกาศ​ว่า implement” ส่วน​คอร์ส Java ตั้ง​ว่า “ไม่มี​การ​เข้า​กัน​โดย​บังเอิญ” · บท​นี้​อยู่​ข้าง​หลัง

`fun interface` ไม่ใช่​ทางออก​ของ​กรณี​นี้

Kotlin มี fun interface ที่​ทำให้ lambda กลาย​เป็น​ชนิด​นั้น​ได้​เลย​ผ่าน SAM conversion · แต่​มัน​มี​เงื่อนไข​ข้อ​เดียว​ที่ DiscountRule ผ่าน​ไม่​ได้

// ❌ แปลไม่ผ่าน
package orderctl
fun interface Rule2 {
fun name(): String
fun applySatang(subtotalSatang: Long): Long
}
e: file:///…/src/main/kotlin/orderctl/Probe.kt:4:1 Functional interface must have exactly one abstract function.

DiscountRule มี method ที่​ยัง​ไม่มี​เนื้อ​ความ​สอง​ตัว ประตู​นี้​จึง​ปิด​ตั้งแต่​ตอน​ประกาศ

เมื่อ extension function พา​ชนิด​เข้า interface ไม่​ได้ ทาง​ที่​เหลือ​คือ​เขียน​ชนิด​ใหม่​มา​ห่อ​มัน

CouponRule ถือ Coupon ไว้​เป็น property แล้ว​ประกาศ​ความ​ผูกพัน​ที่​ตัว​มัน​เอง

CouponRule.kt ข้าง​ล่าง​ประกาศ Coupon ไว้​ด้วย เพื่อ​ให้​คัด​ลอก​ไป​แปล​ได้​ทันที · ใน​โปร​เจ​กต์​จริง​บรรทัด​นั้น​มา​จาก jar ของ​ทีม​อื่น และ​ไม่​ได้​อยู่​ใน​มือ​เรา

src/main/kotlin/orderctl/CouponRule.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี DiscountRule อยู่ในโปรเจกต์ด้วย
package orderctl
class Coupon(val code: String, val offSatang: Long)
class CouponRule(private val coupon: Coupon) : DiscountRule {
override fun name() = "coupon-" + coupon.code
override fun applySatang(subtotalSatang: Long) = maxOf(0L, subtotalSatang - coupon.offSatang)
}

ราคา​ที่​จ่าย​คือ​ชนิด​เพิ่ม​มา​หนึ่ง​ตัว​และ object เพิ่ม​มา​หนึ่ง​ใบ​ต่อ coupon หนึ่ง​ใบ · ที่​ได้​กลับ​มา​คือ CouponRule เข้า​กล่อง List<DiscountRule> ได้​เหมือน​กฎ​ตัว​อื่น​ทุก​ประการ

ตัว​ไหน​ถูก​เรียก​ขึ้น​กับ​ชนิด​ที่​เขียน​ไว้​ใน​การ​ประกาศ ไม่ใช่​ชนิด​ของ object ที่​อยู่​ใน​นั้น​จริง

ประกาศ extension ชื่อ​เดียวกัน​สอง​ตัว ตัว​หนึ่ง​บน interface อีก​ตัว​บน class ที่​แคบ​กว่า

src/main/kotlin/orderctl/Labels.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · Labels.kt ต้องมี DiscountRule กับ PercentOff อยู่ข้างกัน
package orderctl
fun DiscountRule.label(): String = "rule:" + name()
fun PercentOff.label(): String = "percent:" + name()
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · เนื้อของ fun main ใน RulesDemo.kt
val p = PercentOff(10)
println(p.label())
val asRule: DiscountRule = p
println(asRule.label())
val all: List<DiscountRule> = listOf(p, FlatOff(5_000L))
for (r in all) println(r.label())
$ gradle -q run
percent:percent-10
rule:percent-10
rule:percent-10
rule:flat-5000

บรรทัด​แรก​กับ​บรรทัด​ที่​สอง​ถือ object ใบ​เดียวกัน แล้ว​ให้​คำ​ตอบ​คนละ​คำ · ต่าง​กัน​แค่​ชนิด​ที่​เขียน​ไว้​ตอน​ประกาศ​ตัวแปร

ที่​ที่​มัน​เงียบ​สนิท​คือ `List<DiscountRule>`

พอ​ค่า​เข้าไป​อยู่​ใน​กล่อง List<DiscountRule> ชนิด​ที่ compiler มอง​เห็น​ของ​ทุก​สมาชิก คือ DiscountRule เท่านั้น

Rules.chain ที่​เรา​จะ​เขียน​ใน​หัว​ข้อ​ถัดๆ ไป รับ List<DiscountRule> พอดี · extension ที่​เขียน​ไว้​ให้ PercentOff จึง​ไม่มี​วัน​ถูก​เรียก​จาก​ใน​นั้น

และ​ไม่มี​คำ​เตือน​สัก​บรรทัด · -Werror -Wextra ก็​ไม่​จับ เพราะ​ไม่มี​อะไร​ผิด​ใน​สายตา​ของ​ภาษา

member function ไม่มี​ปัญหา​นี้ · name() ที่​อยู่​ใน interface กระจาย​แบบ virtual ตาม​ปกติ ทุก​สมาชิก​ใน list จึง​ได้​ตัว​ของ​ชนิด​จริง​เสมอ

by เขียน forwarder ให้​ทุก​ตัว​ที่​เรา​ไม่​ได้​เขียน​ทับ

หัวข้อ​ที่​มีชื่อ​ว่า “by เขียน forwarder ให้​ทุก​ตัว​ที่​เรา​ไม่​ได้​เขียน​ทับ”

ประกาศ​ครั้ง​เดียว​แล้ว compiler สร้าง method ส่ง​ต่อ​ให้​ครบ​ทั้ง interface

CountingRule ต้องการ​นับ​ว่า​กฎ​ที่​มัน​ห่อ​อยู่​ถูก​เรียก​กี่​ครั้ง และ​เปลี่ยน​ชื่อ​ให้​เห็น​ว่า​มี​ตัว​ห่อ · ส่วน​ที่​เหลือ​ของ interface ไม่​ต้องการ​เปลี่ยน​อะไร​เลย

นี่​คือ​งาน​ของ class delegationclass delegation`class A(b: B) : B by b` สั่ง​ให้ compiler สร้าง method ส่ง​ต่อ​ทุก​ตัว​ที่​ไม่​ได้​เขียน​ทับ · แต่​ตัว​ที่​เขียน​ทับ​ไม่​ถูก​เรียก​จาก default method ที่​ทำงาน​อยู่​บน​ตัว​ถูก​มอบ​หมาย เพราะ​มัน​ไม่​เคย​รู้จัก​ตัว​ห่อ

src/main/kotlin/orderctl/CountingRule.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี DiscountRule อยู่ในโปรเจกต์ด้วย
package orderctl
class CountingRule(private val inner: DiscountRule) : DiscountRule by inner {
var seen: Int = 0
private set
override fun name() = "counting(" + inner.name() + ")"
override fun applySatang(subtotalSatang: Long): Long {
seen++
return inner.applySatang(subtotalSatang)
}
}

: DiscountRule by inner อ่าน​ว่า “เป็น DiscountRule โดย​ให้ inner ทำ​แทน” · method ที่​เรา​ไม่​ได้​เขียน​ทับ compiler สร้าง​ตัว​ส่ง​ต่อ​ให้​เอง

Go มี​ของ​ที่​ให้​ผล​คล้าย​กัน​ชื่อ embedding แต่​มัน​ฝัง​ทั้ง​ชนิด​เข้า​มา​เป็น field ไม่มี​ชื่อ · ส่วน by ของ Kotlin ผูก​กับ interface ตัว​ที่​ประกาศ​ไว้​ตรง​บรรทัด​นั้น​เท่านั้น

เรียก applySatang หนึ่ง​ครั้ง​แล้ว applyTwice อีก​หนึ่ง​ครั้ง ตัว​นับ​ควร​เป็น 3 แต่​ไม่ใช่

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · เนื้อของ fun main ใน RulesDemo.kt
val counting = CountingRule(PercentOff(10))
println(counting.applySatang(20_000L))
println(counting.seen)
println(counting.applyTwice(20_000L))
println(counting.seen)
println("name(): " + counting.name())
println("describe(): " + counting.describe())
$ gradle -q run
18000
1
16200
1
name(): counting(percent-10)
describe(): กฎ percent-10

ตัวเลข​เงิน​ถูก​ทั้ง​สอง​ตัว · 20,000 สตางค์​ลบ​สิบ​เปอร์เซ็นต์​ได้ 18,000 และ​ลบ​สอง​รอบ​ได้ 16,200

ผิด​อยู่​สอง​ที่ และ​ทั้ง​สอง​ที่​เป็น method ที่​มี​เนื้อ​ความ​ติด​มา​กับ interface

  • seen ค้าง​ที่ 1 ทั้ง​ที่ applyTwice เรียก applySatang ไป​อีก​สอง​ครั้ง
  • describe() ตอบ กฎ percent-10 ทั้ง​ที่ name() ของ​ตัว​เดียวกัน​ตอบ counting(percent-10)

บรรทัด​สอง​บรรทัด​สุดท้าย​คือ​หลัก​ฐาน​ที่​ชัด​ที่สุด · object ใบ​เดียวกัน ถาม​สอง​คำถาม​ที่​พึ่ง name() เหมือน​กัน แล้ว​ได้​ชื่อ​คนละ​ชื่อ

forwarder ถือ reference ไป​หา​ตัว​ข้าง​ใน ไม่ใช่​ไป​หา​ตัว​เอง

หัวข้อ​ที่​มีชื่อ​ว่า “forwarder ถือ reference ไป​หา​ตัว​ข้าง​ใน ไม่ใช่​ไป​หา​ตัว​เอง”

describe() ของ CountingRule มี​อยู่​จริง​ใน​รูป bytecode และ​บรรทัด​แรก​ของ​มัน​คือ​การ​หยิบ inner

javap -c ของ CountingRule ที่​เขียน by inner ให้​เนื้อ​ของ describe() แบบ​นี้

เขียน by inner
public java.lang.String describe();
Code:
0: aload_0
1: getfield #23 // Field inner:Lorderctl/DiscountRule;
4: invokeinterface #56, 1 // InterfaceMethod orderctl/DiscountRule.describe:()Ljava/lang/String;
9: areturn

ลบ​คำ​ว่า by inner ทิ้ง​แล้ว​แปล​ใหม่ เนื้อ​ของ method ชื่อ​เดียวกัน​เปลี่ยน​ไป​ทั้ง​ก้อน

ไม่มี by
public java.lang.String describe();
Code:
0: aload_0
1: invokespecial #56 // InterfaceMethod orderctl/DiscountRule.describe:()Ljava/lang/String;
4: areturn

รูป​แรก​มี​บรรทัด​ที่​รูป​หลัง​ไม่มี คือ getfield inner แล้ว​มัน​เรียก​บน​ตัว​ที่​หยิบ​ออก​มา​นั้น​ด้วย invokeinterface · รูป​หลัง​ไม่​หยิบ​อะไร​เลย มัน​เรียก​บน this ตรงๆ ด้วย invokespecial

พอ​เนื้อ​ความ​ปริยาย​ทำงาน​อยู่​บน inner การ​เรียก name() ข้าง​ใน​มัน​จึง​ไป​ถึง PercentOff · inner ไม่​เคย​รู้​ว่า​มี​ใคร​ห่อ​มัน​อยู่

flowchart LR
  CALL["counting.applyTwice(20000)"] --> FWD["CountingRule.applyTwice<br/>forwarder ที่ by สร้าง"]
  FWD -->|"getfield inner"| DEL["PercentOff.applyTwice<br/>เนื้อความปริยายของ interface"]
  DEL --> IN["PercentOff.applySatang<br/>คืน 16200"]
  OVR["CountingRule.applySatang<br/>seen++"] -.-> DEAD["ไม่มีเส้นไหนวิ่งมาถึง<br/>seen ค้างที่ 1"]

คำ​บรรยาย​ภาพ: เส้น​ทาง​จริง​เลี้ยว​ออก​จาก​ตัว​ห่อ​ตั้งแต่​ก้าว​แรก · ตัว​ที่​เรา​เขียน​ทับ​ยืน​อยู่​นอก​เส้น​ทาง​ทั้ง​เส้น

`by` คือ​การ​ประกอบ ไม่ใช่​การ​สืบทอด

ประโยค​เดียว​ที่​ควร​จำ​จาก​บท​นี้ — by ไม่​สร้าง​สาย​การ​สืบทอด มัน​สร้าง method ส่ง​ต่อ

บท​ที่ 5 ของ​คอร์ส Go สาธิต​ข้อ​เดียวกัน​ด้วย Printer กับ Fancy ที่​พิมพ์ <p:base> ไม่ใช่ <p:fancy> · Go เรียก​มัน​ว่า embedding Kotlin เรียก​มัน​ว่า by แต่​กติกา​ข้อ​นี้​ตรง​กัน

Java ไม่มี​ของ​แบบ​นี้​เลย · บท​ที่ 5 ของ​คอร์ส Java ต้อง​เขียน apply ส่ง​ต่อ​ออก​มา​เอง ซึ่ง​แลก​ความ​ยาว​กับ​การ​ที่​ไม่มี​อะไร​ถูก​สร้าง​ลับ​หลัง

ทาง​แรก​เลิก​ใช้ by ทาง​ที่​สอง​ย้าย​เนื้อ​ความ​ปริยาย​ออก​จาก interface

ทาง​แรก — ลบ by inner ทิ้ง แล้ว​รับ​เนื้อ​ความ​ปริยาย​มา​ตาม​ปกติ

src/main/kotlin/orderctl/CountingRule.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี DiscountRule อยู่ในโปรเจกต์ด้วย
// แทนที่ฉบับก่อนหน้าในบทนี้
package orderctl
class CountingRule(private val inner: DiscountRule) : DiscountRule {
var seen: Int = 0
private set
override fun name() = "counting(" + inner.name() + ")"
override fun applySatang(subtotalSatang: Long): Long {
seen++
return inner.applySatang(subtotalSatang)
}
}
$ gradle -q run
18000
1
16200
3
name(): counting(percent-10)
describe(): กฎ counting(percent-10)

ตัว​นับ​เป็น 3 และ describe() ตอบ​ชื่อ​ของ​ตัว​ห่อ​แล้ว · ที่​นี่ by ไม่​ได้​ประหยัด​อะไร​ให้​เลย เพราะ method ที่​มัน​สร้าง​ให้​คือ​สอง​ตัว​ที่​มี​เนื้อ​ความ​อยู่​แล้ว

ทาง​ที่​สอง — ย้าย describe กับ applyTwice ออก​ไป​เป็น extension function

src/main/kotlin/orderctl/DiscountRule.kt
// แทนที่ฉบับก่อนหน้าในบทนี้
package orderctl
interface DiscountRule {
fun name(): String
fun applySatang(subtotalSatang: Long): Long
}
fun DiscountRule.describe(): String = "กฎ " + name()
fun DiscountRule.applyTwice(subtotalSatang: Long): Long = applySatang(applySatang(subtotalSatang))

รูป​นี้​ให้​ผล​ชุด​เดียว​กับ​ทาง​แรก คือ 3 กับ กฎ counting(percent-10) ทั้ง​ที่ by inner ยัง​อยู่​ครบ

เหตุผล​ย้อน​กลับ​ไป​ที่​หัวข้อ​เรื่อง extension · การ​เลือก​ว่า extension ตัว​ไหน​ถูก​เรียก ตัดสิน​ตอน​แปล แต่ name() ที่​อยู่ ข้าง​ใน เนื้อ​ความ​ของ​มัน เป็นการ​เรียก member ตาม​ปกติ ซึ่ง​กระจาย​ตอน​รัน

ราคา​ของ​ทาง​ที่​สอง​คือ​ไม่มี​ใคร​เขียน​ทับ describe() ได้​อีก​เลย · extension เขียน​ทับ​ไม่​ได้​ตาม​นิยาม

กติกา​ที่​ใช้​ตัดสิน​หน้า​งาน

ใช้ by เมื่อ​สิ่ง​ที่​คุณ​เขียน​ทับ ไม่มี method ตัว​อื่น​ใน interface เรียก​ใช้​อยู่

ถ้า​มี​เนื้อ​ความ​ปริยาย​ใน interface ที่​พึ่ง method ซึ่ง​ตัว​ห่อ​จะ​เขียน​ทับ ให้​เขียน forwarder เอง · ยาว​กว่า แต่​เส้น​ทาง​ที่​ได้​คือ​เส้น​ทาง​ที่​คุณ​อ่าน​เห็น​ด้วย​ตา

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

Rules เป็น​ที่​รวม​ของ​การ​ต่อ​กฎ · chain รับ list แล้ว​พับ​เข้า​ด้วย​กัน​เป็น​กฎ​ตัว​เดียว

src/main/kotlin/orderctl/Rules.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · Rules.kt ต้องมี DiscountRule อยู่ในโปรเจกต์ด้วย
package orderctl
private class Chained(
private val first: DiscountRule,
private val second: DiscountRule,
) : DiscountRule {
override fun name() = first.name() + "+" + second.name()
override fun applySatang(subtotalSatang: Long) = second.applySatang(first.applySatang(subtotalSatang))
}
object Rules {
val none: DiscountRule = object : DiscountRule {
override fun name() = "none"
override fun applySatang(subtotalSatang: Long) = subtotalSatang
}
fun chain(rules: List<DiscountRule>): DiscountRule =
if (rules.isEmpty()) none else rules.reduce { acc, r -> Chained(acc, r) }
}

List ของ Kotlin ประกาศ​ตัว​ชนิด​ไว้​เป็น out การ​ส่ง List<PercentOff> เข้า chain จึง​ผ่าน โดย​ไม่​ต้อง​แตะ​ลาย​เซ็น · Java ต้อง​เขียน List<? extends DiscountRule> และ Go ต้อง​วน​คัด​ลอก​ที​ละ​ตัว

ภาษาส่ง list ของ​ชนิด​ลูก​เข้า​ที่​ที่​ต้องการ list ของ​ชนิด​แม่
Goทำ​ไม่​ได้ ต้อง​สร้าง slice ใหม่​แล้ว​คัด​ลอก​ที​ละ​ตัว
Javaได้ ถ้า​ผู้​เขียน​ลาย​เซ็น​ใส่ ? extends ไว้​ล่วง​หน้า
Kotlinได้​ทันที เพราะ List<out E> ประกาศ​ไว้​ที่​ตัว interface เอง

ราคา​ที่​จ่าย​อยู่​ที่​อีก​ฝั่ง · MutableList เขียน​ได้​จึง​เป็น invariant และ​ปิด​ประตู​เดียวกัน​นี้

// ❌ แปลไม่ผ่าน
package orderctl
fun total(rules: MutableList<DiscountRule>): Long = Rules.chain(rules).applySatang(20_000L)
val offers: MutableList<PercentOff> = mutableListOf(PercentOff(10))
val sum: Long = total(offers)
e: file:///…/src/main/kotlin/orderctl/Probe.kt:8:23 Argument type mismatch: actual type is 'MutableList<PercentOff>', but 'MutableList<DiscountRule>' was expected.

เส้น​แบ่ง​จึง​ไม่ใช่ “Kotlin ใจ​กว้าง​กว่า” แต่​เป็น “ชนิด​ที่​อ่าน​อย่าง​เดียว​ยอม​ได้ ชนิด​ที่​เขียน​ได้​ยอม​ไม่​ได้”

กฎ​สาม​ตัว​จาก​สาม​ชนิด ต่อ​เป็น​สาย​เดียว แล้ว​ห่อ​ด้วย​ตัว​นับ​ที่​แก้​แล้ว

src/main/kotlin/orderctl/RulesDemo.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · RulesDemo.kt ต้องมีกฎทุกตัวของบทนี้อยู่ในโปรเจกต์ด้วย
package orderctl
fun main() {
val offers: List<PercentOff> = listOf(PercentOff(10), PercentOff(5))
println(Rules.chain(offers).applySatang(20_000L))
val mixed = listOf(PercentOff(10), FlatOff(2_000L), CouponRule(Coupon("SONGKRAN", 3_000L)))
val counting = CountingRule(Rules.chain(mixed))
println(counting.name())
println(counting.applySatang(20_000L))
println(counting.applyTwice(20_000L))
println(counting.seen)
println(counting.describe())
}
$ gradle -q run
17100
counting(percent-10+flat-2000+coupon-SONGKRAN)
13000
6700
3
กฎ counting(percent-10+flat-2000+coupon-SONGKRAN)

บรรทัด​แรก​มา​จาก List<PercentOff> ที่​ส่ง​เข้า chain ตรงๆ · 20,000 ลบ​สิบ​เปอร์เซ็นต์​แล้ว​ลบ​ห้า เปอร์เซ็นต์​ได้ 17,100

บรรทัด​ที่​สาม​คือ​กฎ​สาม​ตัว​เรียง​กัน · ลบ​สิบ​เปอร์เซ็นต์​เหลือ 18,000 ลบ 2,000 เหลือ 16,000 ลบ​อีก 3,000 เหลือ 13,000

บรรทัด​ที่​ห้า​เป็น​ตัวเลข​ที่​บอก​ว่า​เรา​แก้​ถูก​แล้ว · seen เป็น 3 เพราะ​เรียก applySatang หนึ่ง​ครั้ง และ applyTwice พา​ไป​อีก​สอง​ครั้ง

DiscountRule ต่อ​กฎ​เพิ่ม​ได้​โดย​ไม่​แตะ​ของ​เดิม และ​คุณ​เห็น​แล้ว​ว่า by ซื้อ​อะไร​และ​ขาย​อะไร

สาม​ข้อ​ที่​บท​นี้​พิสูจน์​ด้วย​การ​รัน

  • extension function พา​ชนิด​เข้า interface ไม่​ได้ compiler ตอบ Initializer type mismatch
  • extension function ตัดสิน​จาก​ชนิด​ที่​ประกาศ สมาชิก​ใน List<DiscountRule> จึง​ได้​ตัว​ของ interface เสมอ
  • by สร้าง forwarder ที่​ทำงาน​อยู่​บน​ตัว​ข้าง​ใน ตัว​ที่​เขียน​ทับ​จึง​อยู่​นอก​เส้น​ทาง​ของ​เนื้อ​ความ​ปริยาย

บท​หน้า​เป็น​เรื่อง​ความ​ผิด​พลาด · จุด​ที่​คน​มา​จาก Java สะดุด​ที่สุด​คือ Kotlin ไม่มี checked exception เลย​สัก​ตัว

ตรง​ที่ javac ตอบ unreported exception IOException; must be caught or declared to be thrown kotlinc เงียบ​สนิท แม้​เปิด -Werror -Wextra


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

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

  • Kotlin — Delegation (เข้า​ถึง 2026-08-28) — by และ​ข้อ​จำกัด​ว่า​สมาชิก​ที่​เขียน​ทับ​ไม่​ถูก​เรียก​จาก​สมาชิก​ของ delegate
  • Kotlin — Extensions (เข้า​ถึง 2026-08-28) — การ​เลือก extension ตัดสิน​ตอน​แปล​จาก​ชนิด​ที่​ประกาศ
  • Kotlin — Fun interfaces (SAM) (เข้า​ถึง 2026-08-28) — เงื่อนไข abstract function ตัว​เดียว
  • Kotlin — Generics: variance (เข้า​ถึง 2026-08-28) — กติกา​ของ out และ in ที่​ประกาศ​ไว้​ที่​ตัว interface
  • Kotlin — kotlin.collections.List (เข้า​ถึง 2026-08-28) — ลาย​เซ็น interface List<out E> : Collection<E> ที่​หัวข้อ​สุดท้าย​อ้าง
  • ผลลัพธ์​ทุก​บรรทัด​ใน​บท​นี้​รัน​เอง​เมื่อ 2026-08-28 บน Kotlin 2.4.10 กับ JDK 25 และ Gradle 9.7.1
  • ข้อความ​ของ compiler ทุก​บรรทัด​ยก​จาก gradle -q run ของ​โปร​เจ​กต์​เดียวกัน · จุด​สาม​จุด​ใน file:///… คือ path เต็ม​ของ​เครื่อง​ที่​รัน

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

ข้อ 1 / 3

`CountingRule(PercentOff(10))` ที่เขียน `: DiscountRule by inner` แล้วเขียนทับ `applySatang` ไว้ · เรียก `applyTwice(20000)` แล้วทำไม `seen` ถึงไม่ขยับ