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

ชนิด​ที่​สร้าง​ค่า​ผิด​ไม่​ขึ้น และ when ที่​นับ​กรณี​ให้​ครบ

บท​ที่ 1 เปิด​ด้วย​ตาราง​ที่​บอก​ว่า Kotlin เปลี่ยน​สาม​อย่าง​จาก​ภาษา​ที่​คุณ​ใช้​อยู่ · แถว​แรก​ของ​ตาราง​นั้น​เขียน​ว่า ชนิด​บอก​เอง​ว่า​รับ null ได้​ไหม แล้ว​ชี้​มา​ที่​บท​นี้

บท​นี้​จ่าย​หนี้​ก้อน​นั้น พร้อม​กับ​สร้าง​ชนิด​ของ orderctl ให้​ครบ​ทั้ง​ชุด

📦 สิ่ง​ที่​บท​นี้​สร้าง

ห้า​ชนิด​ของ​โดเมน

  • Line — หนึ่ง​บรรทัด​ขอ​งอ​อเด​อร์
  • Sku — รหัส​สินค้า
  • Satang — จำนวน​เงิน
  • Discount — ส่วนลด​สาม​แบบ
  • Status — สถานะ​ขอ​งอ​อเด​อร์

ยัง​ไม่มี package และ​ยัง​ไม่มี build tool เหมือน​บท​ที่ 1 · ทุก​อย่าง​ใน​บท​นี้​แปล​ด้วย kotlinc แล้ว​รัน​ด้วย kotlin -cp out ตรงๆ

data class ประกาศ​บรรทัด​เดียว แล้ว​ได้​ของ​มา​ห้า​อย่าง

หัวข้อ​ที่​มีชื่อ​ว่า “data class ประกาศ​บรรทัด​เดียว แล้ว​ได้​ของ​มา​ห้า​อย่าง”

บรรทัด​เดียว​ของ Kotlin ให้​ของ​ครบ​ชุด​ที่ record ของ Java ให้ แล้ว​เพิ่ม componentN กับ copy เข้า​มา​อีก

สร้าง Domain.kt

data class Line(val sku: String, val qty: Int, val unitSatang: Long)
fun main() {
val a = Line("TEA", 2, 3500)
val b = Line("TEA", 2, 3500)
println(a)
println("a == b -> ${a == b}")
println(a.copy(qty = 3))
val (sku, qty, _) = a
println("$sku x $qty")
}
$ kotlinc -Werror -Wextra -jvm-target 25 Domain.kt -d out
$ kotlin -cp out DomainKt
Line(sku=TEA, qty=2, unitSatang=3500)
a == b -> true
Line(sku=TEA, qty=3, unitSatang=3500)
TEA x 2

a == b เป็น true ทั้ง​ที่​เป็น​คนละ object · ใน​ภาษา​ที่ == เทียบ​ที่​อยู่ นี่​คือ​กับ​ดัก แต่ Kotlin แปล == เป็นการ​เรียก equals ให้​เสมอ

นี่​คือ data classdata classclass ที่​ประกาศ property ไว้​ใน​ตัว​สร้าง​หลัก แล้ว​ได้ `equals` `hashCode` `toString` `componentN` และ `copy` มา​ให้ · property ที่​ประกาศ​ใน​ตัว​ถัง​หาย​ไป​จาก​ทั้ง​ห้า​อย่าง สอง​ค่าที่​ถือ​ของ​คนละ​ชุด​จึง​เท่า​กัน​ได้ · เปิด​ดู​ว่า​มัน​สร้าง​อะไร​ให้​บ้าง

$ javap -cp out Line
Compiled from "Domain.kt"
public final class Line {
public Line(java.lang.String, int, long);
public final java.lang.String getSku();
public final int getQty();
public final long getUnitSatang();
public final java.lang.String component1();
public final int component2();
public final long component3();
public final Line copy(java.lang.String, int, long);
public static Line copy$default(Line, java.lang.String, int, long, int, java.lang.Object);
public java.lang.String toString();
public int hashCode();
public boolean equals(java.lang.Object);
}

component1 ถึง component3 คือ​ของ​ที่​ทำให้ val (sku, qty, _) = a ทำงาน​ได้ · copy$default คือ​ตัว​ที่​ทำให้​เขียน a.copy(qty = 3) โดย​ไม่​ต้อง​พิมพ์​อีก​สอง​ค่า​ซ้ำ

ของ​ที่​อยู่​ใน​ตัว​ถัง ไม่​นับ​เป็น​ตัว​ตน​ของ​ค่า

หัวข้อ​ที่​มีชื่อ​ว่า “ของ​ที่​อยู่​ใน​ตัว​ถัง ไม่​นับ​เป็น​ตัว​ตน​ของ​ค่า”

ย้าย property หนึ่ง​ตัว​จาก​วงเล็บ​ของ​ตัว​สร้าง​ลง​ไป​ใน​ปีกกา แล้ว​วัด​ใหม่

data class Line(val sku: String, val qty: Int, val unitSatang: Long) {
var note: String = "ไม่มี"
}
fun main() {
val a = Line("TEA", 2, 3500)
a.note = "ร้อน"
val b = Line("TEA", 2, 3500)
b.note = "เย็น"
println(a)
println("a == b -> ${a == b}")
println("copy().note -> ${a.copy().note}")
}
$ kotlinc -Werror -Wextra -jvm-target 25 Note.kt -d out
$ kotlin -cp out NoteKt
Line(sku=TEA, qty=2, unitSatang=3500)
a == b -> true
copy().note -> ไม่มี

สาม​อย่าง​เกิด​ขึ้น​พร้อม​กัน — toString ไม่​พิมพ์ note ออก​มา​เลย · a == b ยัง​เป็น true ทั้ง​ที่​สอง​ค่า​ถือ note คนละ​ตัว · และ copy() คืน​ค่าที่ note กลับ​ไป​เป็น "ไม่มี"

เส้น​แบ่ง​คือ​วงเล็บ​ของ​ตัว​สร้าง ไม่ใช่​คำ​ว่า `val` หรือ `var`

compiler สร้าง equals hashCode toString componentN และ copy จาก property ที่​ประกาศ​ไว้ใน​วงเล็บ​ของ​ตัว​สร้าง​หลัก​เท่านั้น

ของ​ที่​อยู่​ใน​ตัว​ถัง​ยัง​เป็น property จริง อ่าน​และ​เขียน​ได้​ตาม​ปกติ แต่​มัน​อยู่​นอก​สายตา​ของ​ทั้ง​ห้า​อย่าง​นั้น · ถ้า​ค่า​ใด​เป็น​ส่วน​หนึ่ง​ของ​ตัว​ตน​ขอ​งอ​อเด​อร์ มัน​ต้อง​อยู่​ใน​วงเล็บ

จุด​นี้​คือ​ที่​ที่​คน​มา​จาก Java จะ​เขียน​ผิด​ครั้ง​แรก เพราะ compact constructor ทำได้​ทั้ง​สอง​อย่าง

บท​ที่ 2 ของ คอร์ส Java เขียน compact constructor ที่​ทั้ง​โยน​ทิ้ง​เมื่อ qty ไม่​ถูก และ เขียน​ทับ sku ด้วย sku = sku.trim().toUpperCase(Locale.ROOT) ใน​ก้อน​เดียวกัน

ลอง​ท่า​เดียวกัน​ใน Kotlin

// ❌ แปลไม่ผ่าน
data class Line(val sku: String, val qty: Int, val unitSatang: Long) {
init {
sku = sku.trim()
}
}
$ kotlinc -Werror -Wextra -jvm-target 25 Domain.kt -d out
Domain.kt:4:9: error: 'val' cannot be reassigned.
sku = sku.trim()
^^^

sku ใน init คือ property ที่​ประกาศ​เป็น val ไป​แล้ว ไม่ใช่​ตัวแปร​ชั่วคราว​ของ​ตัว​สร้าง · ครึ่ง​ที่​ทำได้​คือ​ครึ่ง​ที่​ปฏิเสธ

data class Line(val sku: String, val qty: Int, val unitSatang: Long) {
init {
require(sku.isNotBlank()) { "sku ว่างไม่ได้" }
require(qty > 0) { "qty ต้องมากกว่า 0 แต่ได้ $qty" }
require(unitSatang >= 0) { "ราคาติดลบไม่ได้" }
}
}
fun main() {
println(Line("TEA", 2, 3500))
try {
Line("CAKE", 0, 12000)
} catch (e: IllegalArgumentException) {
println("ถูกปฏิเสธ: ${e.message}")
}
}
$ kotlinc -Werror -Wextra -jvm-target 25 Domain.kt -d out
$ kotlin -cp out DomainKt
Line(sku=TEA, qty=2, unitSatang=3500)
ถูกปฏิเสธ: qty ต้องมากกว่า 0 แต่ได้ 0

requirerequireด่าน​ตรวจ​ต้นทาง​ที่​ล้ม​ด้วย `IllegalArgumentException` · มัน​ประกาศ contract ไว้ compiler จึง​แคบ​ชนิด​ให้​เอง​หลัง​ผ่าน​ด่าน ซึ่ง `Objects.requireNonNull` ของ Java ทำให้​ไม่​ได้ ล้ม​ด้วย IllegalArgumentException และ lambda ที่​ต่อ​ท้าย​ทำงาน​เฉพาะ​ตอน​ที่​ล้ม · ข้อความ​จึง​ประกอบ​ค่า​จริง​เข้าไป​ได้​โดย​ไม่​เสีย​อะไร​ใน​เส้น​ทาง​ปกติ

ทาง​อ้อม​ที่​ดู​เข้า​ท่า แล้ว​ทำให้ `equals` โกหก

ท่า​ที่​คน​คิดถึง​เป็น​อันดับ​แรก​คือ​เก็บ​ค่า​ดิบ​ไว้​ใน​ตัว​สร้าง แล้ว​คำนวณ​ค่าที่​จัด​รูป​แล้ว​ใน​ตัว​ถัง

data class Line(val rawSku: String, val qty: Int) {
val sku: String = rawSku.trim().uppercase()
}
fun main() {
val a = Line(" tea ", 2)
val b = Line("TEA", 2)
println("a.sku=${a.sku} b.sku=${b.sku}")
println(a)
println("a == b -> ${a == b}")
}
$ kotlinc -Werror -Wextra -jvm-target 25 Derived.kt -d out
$ kotlin -cp out DerivedKt
a.sku=TEA b.sku=TEA
Line(rawSku= tea , qty=2)
a == b -> false

a.sku กับ b.sku เป็น TEA เท่า​กัน แต่ a == b เป็น false เพราะ equals มอง​เห็น​แต่ rawSku · และ toString ก็​พิมพ์​ค่าที่​มี​ช่อง​ว่าง​ติด​หัว​ท้าย​ออก​มา​ให้​ดู​ด้วย

ทาง​ที่​เหลือ​คือ​ย้าย​การ​จัด​รูป​ออก​ไป​ไว้​ก่อน​ถึง​ตัว​สร้าง

data class Line(val sku: String, val qty: Int) {
init { require(qty > 0) { "qty ต้องมากกว่า 0 แต่ได้ $qty" } }
}
fun lineOf(sku: String, qty: Int) = Line(sku.trim().uppercase(), qty)
fun main() {
println(lineOf(" tea ", 2))
println(Line(" tea ", 2))
}
$ kotlinc -Werror -Wextra -jvm-target 25 Factory.kt -d out
$ kotlin -cp out FactoryKt
Line(sku=TEA, qty=2)
Line(sku= tea , qty=2)

บรรทัด​ที่​สอง​คือ​ราคา​ที่​คุณ​จ่าย · lineOf เป็น​ทาง​ที่​ถูก แต่​มัน​ไม่ใช่​ทาง​เดียว ตัว​สร้าง​ยัง​เปิด​อยู่ และ​ไม่มี​ใคร​ห้าม​ใคร​เรียก​มัน​ตรงๆ

ทาง​ปิด​ตัว​สร้าง​มี​อยู่ และ​มัน​มี​เงื่อนไข​ที่​ต้อง​อ่าน​ก่อน​ใช้

Kotlin ปิด​ตัว​สร้าง​หลัก​ได้ แต่ copy ของ data classdata classclass ที่​ประกาศ property ไว้​ใน​ตัว​สร้าง​หลัก แล้ว​ได้ `equals` `hashCode` `toString` `componentN` และ `copy` มา​ให้ · property ที่​ประกาศ​ใน​ตัว​ถัง​หาย​ไป​จาก​ทั้ง​ห้า​อย่าง สอง​ค่าที่​ถือ​ของ​คนละ​ชุด​จึง​เท่า​กัน​ได้ มี​พฤติกรรม​ของ​ตัว​เอง​ที่​ต้อง​รู้​ก่อน และ 2.5 กำลัง​จะ​เปลี่ยน​พฤติกรรม​นั้น

เรื่อง​นี้​มี​บทความ​ของ​ตัว​เอง​บน​ไซต์​นี้​แล้ว พร้อม​ด่าน​ตรวจ​ที่​รัน​จริง​ใน​ระบบ​ตรวจ — อ่าน copy() ของ data class กับ constructor ส่วน​ตัว

ความ​ต่าง​ไม่​ได้​อยู่​ที่​คำ​เตือน มัน​อยู่​ที่​ระบบ​ชนิด​ปฏิเสธ​การ​กำหนด​ค่า​ตั้งแต่​ตอน​แปล

สร้าง Nulls.kt ที่​ยัด null ลง​ชนิด​ธรรมดา

// ❌ แปลไม่ผ่าน
fun main() {
val sku: String = null
println(sku)
}
$ kotlinc -Werror -Wextra -jvm-target 25 Nulls.kt -d out
Nulls.kt:3:23: error: null cannot be a value of a non-null type 'String'.
val sku: String = null
^^^^

String กับ String? เป็น​ชนิด​คนละ​ตัว และ​ตัว​หลัง​กว้าง​กว่า​ตัว​แรก​อยู่​หนึ่ง​ค่า · ทุก API ที่​คุณ​เขียน​จึง​ประกาศ​ออก​มา​ได้​ว่า​มัน​รับ​ค่าที่​หาย​ไป​หรือ​ไม่

fun lengthOf(sku: String?): Int {
require(sku != null) { "sku ห้ามเป็น null" }
return sku.length
}
fun main() {
val found: String? = null
println(found?.length)
println(found?.length ?: 0)
println(found ?: "ไม่พบ")
println(lengthOf("TEA"))
try {
lengthOf(null)
} catch (e: IllegalArgumentException) {
println("ถูกปฏิเสธ: ${e.message}")
}
}
$ kotlinc -Werror -Wextra -jvm-target 25 Nulls.kt -d out
$ kotlin -cp out NullsKt
null
0
ไม่พบ
3
ถูกปฏิเสธ: sku ห้ามเป็น null

บรรทัด return sku.length ใน​นั้น​ไม่มี ?. และ​ไม่มี !! เลย ทั้ง​ที่ sku ประกาศ​เป็น String? · requirerequireด่าน​ตรวจ​ต้นทาง​ที่​ล้ม​ด้วย `IllegalArgumentException` · มัน​ประกาศ contract ไว้ compiler จึง​แคบ​ชนิด​ให้​เอง​หลัง​ผ่าน​ด่าน ซึ่ง `Objects.requireNonNull` ของ Java ทำให้​ไม่​ได้ พก contract ไป​บอก compiler ว่า​ถ้า​เลย​บรรทัด​นั้น​มา​ได้ แปล​ว่า​เงื่อนไข​จริง

`Objects.requireNonNull` ทำ​แบบ​เดียวกัน​ไม่​ได้ · วัด​แล้ว

เขียน​ด่าน​เดียวกัน​ด้วย​ของ​จาก java.util แล้ว​บรรทัด​ถัด​ไป​แดง​ทันที

// ❌ แปลไม่ผ่าน
import java.util.Objects
fun lengthOf(sku: String?): Int {
Objects.requireNonNull(sku, "sku")
return sku.length
}
$ kotlinc -Werror -Wextra -jvm-target 25 NonNull.kt -d out
NonNull.kt:6:15: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
return sku.length
^

ทั้ง​สอง​ตัว​หยุด​โปรแกรม​ที่​จุด​เดียวกัน​ตอน​รัน แต่​ไม่​ได้​โยน​ของ​อย่าง​เดียวกัน · require โยน IllegalArgumentException ส่วน Objects.requireNonNull โยน NullPointerException

ที่​ต่าง​กว่า​นั้น​คือ​ตัว​หนึ่ง​ประกาศ contract ไว้​ให้ compiler อ่าน และ​อีก​ตัว​ไม่​ได้​ประกาศ · บรรทัด​ถัด​จาก require จึง​เขียน sku.length ได้​เปล่าๆ ส่วน​บรรทัด​ถัด​จาก​อีก​ตัว​เขียน​ไม่​ได้

ระบบ​ชนิด​ของ Kotlin หยุด​ที่​ชายแดน และ​ตรง​ชายแดน​นั้น​มัน​เลือก​ที่​จะ​เชื่อ​คุณ

สร้าง Catalog.java ที่​คืน null ออก​มา​โดย​ไม่​บอก​ใคร

public class Catalog {
public static String skuOf(String raw) {
return null;
}
}

แล้ว​เรียก​มัน​จาก Kotlin โดย​รับ​เป็น​ชนิด​ที่​ห้าม null

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Catalog ที่แปลแล้วอยู่ใน out ก่อน
fun main() {
val sku: String = Catalog.skuOf("tea")
println("ผ่านบรรทัดที่กำหนดค่ามาแล้ว")
println(sku.length)
}
$ javac -Xlint:all -d out Catalog.java
$ kotlinc -Werror -Wextra -jvm-target 25 -cp out Platform.kt -d out
$ kotlin -cp out PlatformKt
Exception in thread "main" java.lang.NullPointerException: skuOf(...) must not be null
at PlatformKt.main(Platform.kt:3)
at PlatformKt.main(Platform.kt)

การ​แปล​ทั้ง​สอง​รอบ​เงียบ​สนิท ไม่มี​คำ​เตือน​สัก​บรรทัด​แม้​เปิด -Xlint:all กับ -Wextra ไว้​ทั้ง​คู่

นี่​คือ platform typeplatform typeชนิด​ที่มา​จาก Java โดย​ไม่มี​คำ​กำกับ​ว่า​รับ null ได้​ไหม · Kotlin ยอม​ให้​รับ​เป็น​ชนิด​ที่​ห้าม null ได้​เงียบๆ แล้ว​โยน `NullPointerException` ที่​บรรทัด​ที่​กำหนด​ค่า ไม่ใช่​บรรทัด​ที่​ใช้​ค่า · และ​สิ่ง​ที่​ต้อง​อ่าน​ให้​ดี​คือเลข​บรรทัด​ใน stack trace

Platform.kt:3 คือ​บรรทัด​ที่​กำหนด​ค่า ไม่ใช่​บรรทัด​ที่​ใช้​ค่า · ข้อความ ผ่านบรรทัดที่กำหนดค่ามาแล้ว ไม่​เคย​ถูก​พิมพ์​ออก​มา​เลย เพราะ compiler แทรก​ด่าน​ตรวจ​ไว้​ตรง​จุด​ที่​ค่า​ข้าม​ชายแดน

ปิด​รู​ได้​สอง​ทาง และ​ทาง​ที่​สอง​ย้าย​ความ​ผิด​พลาด​ไป​ตอน​แปล

ทาง​แรก — รับ​เป็น String? แล้ว​จัดการ​เอง ซึ่ง​เป็น​ทาง​ที่​ใช้ได้​เสมอ

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Catalog ที่แปลแล้วอยู่ใน out ก่อน
fun main() {
val sku: String? = Catalog.skuOf("tea")
println(sku ?: "ไม่พบ sku")
}
$ kotlinc -Werror -Wextra -jvm-target 25 -cp out Platform.kt -d out
$ kotlin -cp out PlatformKt
ไม่พบ sku

ทาง​ที่​สอง — ติด​คำ​กำกับ​ให้​ฝั่ง Java แล้วก​ลับ​ไป​ใช้ Platform.kt ตัว​แรก​เหมือน​เดิม · org.jetbrains.annotations มา​ใน​กล่อง​ของ kotlinc อยู่​แล้ว​ที่ annotations-13.0.jar

import org.jetbrains.annotations.Nullable;
public class Catalog {
public static @Nullable String skuOf(String raw) {
return null;
}
}
$ javac -cp /opt/homebrew/opt/kotlin/libexec/lib/annotations-13.0.jar -d out Catalog.java
$ kotlinc -Werror -Wextra -jvm-target 25 -cp "out:/opt/homebrew/opt/kotlin/libexec/lib/annotations-13.0.jar" Platform.kt -d out
Platform.kt:3:21: error: initializer type mismatch: expected 'String', actual 'String?'.
val sku: String = Catalog.skuOf("tea")
^

path ของ jar เปลี่ยน​ตาม​วิธี​ติด​ตั้ง · มัน​อยู่​ใน lib ของ​ที่​ที่ kotlinc ถูก​ติด​ตั้ง ข้างๆ kotlin-stdlib.jar ที่​บท​ที่ 1 ใช้

ความ​ผิด​พลาด​ตัว​เดิม​ย้าย​จาก​รหัส​ออก 1 ตอน​รัน มา​เป็น​รหัส​ออก 1 ตอน​แปล · คำ​กำกับ​หนึ่ง​คำ​ที่​ฝั่ง Java เป็น​ตัว​ที่​ย้าย​มัน​มา

value class แยก​เงิน​ออก​จาก​รหัส​สินค้า​โดย​ไม่​จ่าย​ค่า​กล่อง

หัวข้อ​ที่​มีชื่อ​ว่า “value class แยก​เงิน​ออก​จาก​รหัส​สินค้า​โดย​ไม่​จ่าย​ค่า​กล่อง”

ชนิด​ใหม่​ที่​ระบบ​ชนิด​แยก​ออก​จาก​กัน​จริง แต่​ใน​ลาย​เซ็น​ของ bytecode มัน​กลับ​เป็น long ดิบ

unitSatang: Long ใน​ตัว​สร้าง​ของ Line บวก​กับ Long ตัว​อื่น​ใน​โปรแกรม​ได้​เงียบ​สนิท ไม่​ว่า​อีก​ฝั่ง​จะ​เป็น​ส่วนลด จำนวนนับ หรือ​รหัส​อะไร​ก็ตาม

value classvalue classชนิด​ที่​ห่อ​ค่า​เดียว​แล้ว​ถูก​คลี่​เป็น​ค่า​ดิบ​ตอน​แปล บน JVM ต้อง​ติด `@JvmInline` · `javap` แสดง​ลาย​เซ็น​เป็น `long` ล้วน แต่​มัน​กลับ​เข้า​กล่อง​เมื่อ​เป็น nullable อยู่​ใน generic หรือ​ถูก​มอง​ผ่าน interface แก้​ข้อ​นี้ · สร้าง Money.kt

@JvmInline
value class Sku(val code: String) {
init { require(code.isNotBlank()) { "sku ว่างไม่ได้" } }
}
@JvmInline
value class Satang(val v: Long) {
init { require(v >= 0) { "ราคาติดลบไม่ได้" } }
}
fun add(a: Satang, b: Satang) = Satang(a.v + b.v)
fun addOrZero(a: Satang?, b: Satang) = Satang((a?.v ?: 0L) + b.v)
fun main() {
println(add(Satang(3500), Satang(1200)))
println(addOrZero(null, Satang(1200)))
println(Sku("TEA"))
try {
Satang(-1)
} catch (e: IllegalArgumentException) {
println("ถูกปฏิเสธ: ${e.message}")
}
}
$ kotlinc -Werror -Wextra -jvm-target 25 Money.kt -d out
$ kotlin -cp out MoneyKt
Satang(v=4700)
Satang(v=1200)
Sku(code=TEA)
ถูกปฏิเสธ: ราคาติดลบไม่ได้

ระบบ​ชนิด​แยก​สอง​ตัว​นี้​ออก​จาก​กัน​จริง · เขียน Mix.kt เพื่อ​ลอง​พัง

// ❌ แปลไม่ผ่าน
@JvmInline
value class Satang(val v: Long)
fun add(a: Satang, b: Satang) = Satang(a.v + b.v)
fun main() {
println(add(Satang(3500), 1200))
println(Satang(3500) + 1200)
}
$ kotlinc -Werror -Wextra -jvm-target 25 Mix.kt -d out
Mix.kt:8:31: error: argument type mismatch: actual type is 'Int', but 'Satang' was expected.
println(add(Satang(3500), 1200))
^^^^
Mix.kt:9:26: error: unresolved reference 'plus' for operator '+'.
println(Satang(3500) + 1200)
^

Satang ไม่​ได้​รับ operator ของ Long มา​ด้วย · ถ้า​อยาก​บวก​กัน​ตรงๆ ต้อง​ประกาศ operator fun plus ใน​ตัว​มัน​เอง ซึ่ง​บท​นี้​ไม่​ทำ และ​ใช้ add แทน

รัน javap -p -cp out MoneyKt แล้ว​ดู​ลาย​เซ็น​ของ​สอง function นั้น · compiler เติม hash ต่อ​ท้าย​ชื่อ​ของ function ที่รับ value class เป็น argument เพื่อ​กัน​ลาย​เซ็น​ชน​กัน เลข​ที่​คุณ​เห็น​จึง​ต่าง​กัน​ไป​ตาม argument · function ที่​มี value class อยู่​เฉพาะ​ชนิด​ที่​คืน ไม่​ได้ hash เลย

ที่​เขียน​ใน sourceที่ javap แสดง
add(a: Satang, b: Satang): Satangpublic static final long add-<hash>(long, long)
addOrZero(a: Satang?, b: Satang): Satangpublic static final long addOrZero-<hash>(Satang, long)

ตัว​บน​ไม่มี object เลย เข้า​เป็น long สอง​ตัว ออก​เป็น long · ตัว​ล่าง​มี Satang ตัว​จริง​เป็น argument แรก เพราะ Satang? ต้อง​มี​ที่​ให้ null ยืน และ long ไม่มี​ที่​นั้น

กล่อง​กลับ​มา​สาม​ที่ และ `init` ทำงาน​ทุก​ครั้ง​ไม่​ว่า​จะ​เข้า​กล่อง​หรือ​ไม่

ค่า​จะ​ถูก​ห่อ​กลับ​เป็น object เมื่อ​มัน​เป็น nullable เมื่อ​มัน​อยู่​ใน generic เช่น List<Satang> และ​เมื่อ​มัน​ถูก​มอง​ผ่าน interface · สาม​ข้อ​นี้​ครอบ​สิ่ง​ที่​โปรแกรม​จริง​ทำ​เกือบ​ตลอด​เวลา

ที่​ไม่​หาย​ไป​ไหน​คือ​ด่าน · Satang(-1) ยัง​ถูก requirerequireด่าน​ตรวจ​ต้นทาง​ที่​ล้ม​ด้วย `IllegalArgumentException` · มัน​ประกาศ contract ไว้ compiler จึง​แคบ​ชนิด​ให้​เอง​หลัง​ผ่าน​ด่าน ซึ่ง `Objects.requireNonNull` ของ Java ทำให้​ไม่​ได้ ปฏิเสธ​เหมือน​เดิม แม้​ลาย​เซ็น​จะ​เป็น long ดิบ เพราะ compiler เรียก constructor-impl ให้​ก่อน​เสมอ

ประโยชน์​ของ value class จึง​เป็น​เรื่อง​ระบบ​ชนิด​เป็น​หลัก ส่วน​การ​ไม่​เข้า​กล่อง​เป็น​ของ​แถม ที่​ได้​เฉพาะ​ใน​เส้น​ทาง​ที่​ไม่​แตะ​สาม​ข้อ​ข้าง​บน

เทียบ​กับ `type Baht int64` ของ Go

บท​ที่ 2 ของ คอร์ส Go สร้าง type Baht int64 ด้วย​เหตุผล​เดีย​วกันเป๊ะ — กัน​เงิน​ไป​บวก​กับ​จำนวน​ชิ้น · และ​ผูก method String() เข้า​กับ​มัน​ได้​ด้วย

ที่​ต่าง​คือ Baht ของ Go เป็น​ชนิด​จริง​ตลอด​ทาง ไม่มี​จังหวะ​ไหน​ที่​มัน​กลาย​เป็น​อย่าง​อื่น ส่วน Satang ของ Kotlin หาย​ไป​จาก bytecode ใน​เส้น​ทาง​ปกติ แล้ว​โผล่​กลับ​มา​เมื่อ​เจอ​สาม​ข้อ​ข้าง​บน

Go ไม่มี​ค่า​ใช้​จ่าย​ของ​กล่อง​ให้​ต้อง​คิด แต่ Go ก็​ไม่มี init ให้​แขวน​ด่าน​ไว้​เหมือน​กัน · Baht(-1) สร้าง​ได้​เสมอ

ปิด​ราย​ชื่อ subtype ไว้​ตอน​แปล แล้ว when ที่​ครอบ​ครบ​ทุก​กรณี​จึง​ไม่​ต้อง​มี else

ส่วนลด​ของ orderctl มี​สาม​แบบ คิด​เป็น​เปอร์เซ็นต์ หัก​เป็น​จำนวน​เงิน และ​ไม่​ลด · แต่ละ​แบบ​พก data คนละ​ชุด

สร้าง Discounts.kt

@JvmInline
value class Satang(val v: Long) {
init { require(v >= 0) { "ราคาติดลบไม่ได้" } }
}
sealed interface Discount {
data class Percent(val pct: Int) : Discount
data class Fixed(val amount: Satang) : Discount
data object None : Discount
}
fun apply(gross: Satang, d: Discount): Satang = when (d) {
is Discount.Percent -> Satang(gross.v - gross.v * d.pct / 100)
is Discount.Fixed -> Satang(maxOf(0L, gross.v - d.amount.v))
Discount.None -> gross
}
fun main() {
val gross = Satang(19000)
println(apply(gross, Discount.Percent(10)).v)
println(apply(gross, Discount.Fixed(Satang(25000))).v)
println(apply(gross, Discount.None).v)
}
$ kotlinc -Werror -Wextra -jvm-target 25 Discounts.kt -d out
$ kotlin -cp out DiscountsKt
17100
0
19000

สาม​บรรทัด​นี้​เป็น​ตัวเลข​ชุด​เดียว​กับ​ที่​คอร์ส Java พิมพ์​ออก​มา​ใน​บท​เดียวกัน เพราะ​เป็น​โดเมน​เดียวกัน

when ก้อน​นั้นไม่มี else และ​นั่น​ตั้งใจ · นี่​คือ exhaustive whenexhaustive when`when` ที่​ครอบ​ทุก​กรณี​ของ sealed หรือ enum จน​ไม่​ต้อง​มี `else` · ข้อความ​ตอน​ขาด​กรณี​บอก​ชื่อ​กิ่ง​ที่​ต้อง​เพิ่ม​ให้​ด้วย ต่าง​จาก `switch` ของ Java ที่​บอก​แค่​ว่า​ยัง​ไม่​ครบ · compiler รู้​ว่า Discount มี subtype แค่​สาม​ตัว จึง​พิสูจน์​เอง​ได้​ว่า​สาม​กิ่ง​นี้​ครบ​แล้ว

หลัง is Discount.Percent -> เขียน d.pct ได้​เลย​โดย​ไม่​ต้อง​แปลง​ชนิด​ก่อน · การ​ถาม​ชนิด​กับ​การ​แคบ​ชนิด​เกิด​ใน​นิพจน์​เดียวกัน

`data object` ไม่ใช่ `data class` ที่​ไม่มี property

None ไม่​พก data อะไร​เลย มัน​จึง​ไม่​ต้อง​เป็น class · data object สร้าง​ตัว​เดียว​ใช้​ทั้ง​โปรแกรม และ​เขียน​ใน​กิ่ง​ของ when ได้​โดย​ไม่​ต้อง​มี is นำ​หน้า

สังเกต​ความ​ต่าง​ใน​สอง code ข้าง​บน — is Discount.Percent -> มี is ส่วน Discount.None -> ไม่มี · และ​ข้อความ error ตอน​ลืม​กิ่ง​ก็​ต่าง​ตาม​นั้น​ด้วย ซึ่ง​เป็น​สิ่ง​ที่​หัวข้อ​ถัด​ไป​จะ​แสดง​ให้​เห็น

แทรก data class Capped(val cap: Satang) : Discount ต่อ​จาก​บรรทัด​ของ None แล้ว​อย่า​แตะ when เลย

$ kotlinc -Werror -Wextra -jvm-target 25 Discounts.kt -d out
Discounts.kt:13:49: error: 'when' expression must be exhaustive. Add the 'is Capped' branch or an 'else' branch.
fun apply(gross: Satang, d: Discount): Satang = when (d) {
^^^^

Kotlin เอ่ย​ชื่อ​กิ่ง​ที่​ขาด​ออก​มา​ตรงๆ — Add the 'is Capped' branch · ถ้า subtype ตัว​ใหม่​เป็น data object แทน ข้อความ​จะ​เป็น Add the 'Capped' branch โดย​ไม่มี​คำ​ว่า is

เทียบ​กับ​สิ่ง​ที่ javac 25.0.4.1 ตอบ เมื่อ​เติม record Capped(long capSatang) implements Discount {} เข้าไป​ใน Discounts.java ของ คอร์ส Java แบบ​เดียวกัน

$ 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

ทั้ง​สอง​ภาษา​แดง​ตอน​แปล​เหมือน​กัน และ​แดง​ทุก​จุด​ที่​แยก​กรณี​เหนือ​ชนิด​นั้น​พร้อม​กัน​ใน​รอบ​เดียว · ที่​ต่าง​คือ​ฝั่ง​หนึ่ง​บอก​ชื่อ​กิ่ง​ให้​แล้ว อีก​ฝั่ง​บอก​แค่​ว่า​ยัง​ไม่​ครบ

รูป statement แดง​ทั้ง​คู่​เหนือ `sealed` แต่​กลับ​ด้าน​เหนือ `enum`

มี​ข้อ​อ้าง​ที่​เจอ​บ่อย​ว่า when ของ Kotlin เข้ม​กว่า​เพราะ​รูป statement ที่​ไม่​ได้​เอา​ค่า​ไป​ใช้​ก็​ยัง​แดง ส่วน switch ของ Java ปล่อย​ผ่าน · วัด​บน javac 25.0.4.1 แล้ว​ถูก​ครึ่ง​เดียว

เหนือ sealed interface กิ่ง​ของ Java ต้อง​เป็น pattern และ​ภาษา​บังคับ​ความ​ครบ​ถ้วน​ให้ · เขียน Statements.java ที่​ลืม​กิ่ง​หนึ่ง​แล้ว​มัน​แดง

sealed interface Discount {
record Percent(int pct) implements Discount {}
record Fixed(long satang) implements Discount {}
record None() implements Discount {}
}
void describe(Discount d) {
switch (d) {
case Discount.Percent p -> IO.println("percent");
case Discount.Fixed f -> IO.println("fixed");
}
}
void main() {
describe(new Discount.None());
}
$ java Statements.java
Statements.java:8: error: the switch statement does not cover all possible input values
switch (d) {
^
1 error
error: compilation failed

เหนือ enum กลับ​ด้าน · กิ่ง​เป็น​ค่า​คงที่ ไม่ใช่ pattern และ​ภาษา​ไม่​บังคับ​ความ​ครบ​ถ้วน​ให้​เลย

enum S { NEW, PAID, CANCELLED }
void describe(S s) {
switch (s) {
case NEW -> IO.println("new");
case PAID -> IO.println("paid");
}
}
void main() {
describe(S.CANCELLED);
IO.println("ผ่าน");
}
$ javac -Xlint:all -d out EnumStmt.java
$ java EnumStmt.java
ผ่าน

รหัส​ออก 0 ทั้ง​การ​แปล​และ​การ​รัน · ไม่มี​คำ​เตือน​สัก​บรรทัด​แม้​เปิด -Xlint:all และ​กิ่ง​ที่​ขาด​หาย​ไป​เงียบๆ · รูป​เดียวกัน​ฝั่ง Kotlin แดง​ตั้งแต่​ตอน​แปล

// ❌ แปลไม่ผ่าน
enum class S { NEW, PAID, CANCELLED }
fun describe(s: S) {
when (s) {
S.NEW -> println("new")
S.PAID -> println("paid")
}
}
$ kotlinc -Werror -Wextra -jvm-target 25 EnumStmt.kt -d out
EnumStmt.kt:5:5: error: 'when' expression must be exhaustive. Add the 'CANCELLED' branch or an 'else' branch.
when (s) {
^^^^

ของ​ที่​ต่าง​จริง​จึง​มี​สาม​ข้อ ข้อ​แรก​คือ​ชื่อ​กิ่ง​ใน​ข้อความ ข้อ​ที่​สอง​คือ​รูป statement เหนือ enum ข้อ​ที่​สาม​คือ null ซึ่ง​เป็น​หัวข้อ​ถัด​ไป

null คือ​กรณี​ที่ Kotlin บังคับ​ให้​เขียน และ Java ไม่​บังคับ

หัวข้อ​ที่​มีชื่อ​ว่า “null คือ​กรณี​ที่ Kotlin บังคับ​ให้​เขียน และ Java ไม่​บังคับ”

ทั้ง​สอง​ภาษา​เขียน​กิ่ง null ได้ แต่​มี​ภาษา​เดียว​ที่ compiler ทวงถาม​เมื่อ​คุณ​ลืม

กลับ​ไป​ที่ Discounts.kt แล้ว​เปลี่ยน​ชนิด​ของ argument ตัว​ที่​สอง​จาก Discount เป็น Discount? โดย​ไม่​แตะ​กิ่ง​ใด​เลย · สาม​กิ่ง​เดิม​ยัง​ครบ​ทุก subtype อยู่

$ kotlinc -Werror -Wextra -jvm-target 25 Discounts.kt -d out
Discounts.kt:12:50: error: 'when' expression must be exhaustive. Add the 'null' branch or an 'else' branch.
fun apply(gross: Satang, d: Discount?): Satang = when (d) {
^^^^

ทำ​อย่าง​เดียวกัน​ฝั่ง Java · เขียน Discounts.java ตาม​รูป​ของ​คอร์ส Java แล้ว​ส่ง null เข้าไป

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, null));
}
$ javac -Xlint:all -d out Discounts.java
$ java Discounts.java
17100
Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:220)
at Discounts.apply(Discounts.java:8)
at Discounts.main(Discounts.java:18)

การ​แปล​จบ​ด้วย​รหัส​ออก 0 และ​ไม่​พิมพ์​คำ​เตือน​อะไร​เลย​แม้​เปิด -Xlint:all · โปรแกรม​รัน​ไป​แล้ว​พิมพ์ 17100 ออก​มา​ถูก​ต้อง​หนึ่ง​บรรทัด แล้ว​ถึง​ตาย​ที่​บรรทัด​ถัด​ไป

Java เขียน `case null ->` ได้ · แต่​ไม่มี​ใคร​ทวงถาม​เมื่อ​คุณ​ลืม​เขียน

ตั้งแต่ Java 21 switch รับ case null -> เป็น​กิ่ง​หนึ่ง​ได้ และ​มัน​ทำงาน​ถูก​ต้อง​เมื่อ​เขียน​ไว้ · ที่​วัด​ได้​บน javac 25.0.4.1 คือ compiler ไม่​บังคับ​ให้​เขียน

เส้น​แบ่ง​จึง​ไม่ใช่ “เขียน​ได้​หรือ​ไม่​ได้” แต่​เป็น “ลืม​แล้ว​รู้ตัว​เมื่อไร”

  • Kotlin รู้ตัว​ตอน​แปล​เสมอ เพราะ​ชนิด​ของ subject เป็น​ตัว​บอก
  • Java รู้ตัว​ตอน​ที่ null ตัว​แรก​วิ่ง​เข้า​มา​จริง
flowchart TD
  S["ส่วนลดที่ function รับเข้ามา"] --> Q{"ชนิดของ argument บอกไหมว่ารับ null ได้"}
  Q -->|"Kotlin · Discount"| K1["when ครบสาม subtype<br/>แปลผ่าน รหัสออก 0"]
  Q -->|"Kotlin · Discount?"| K2["error ตอนแปล รหัสออก 1<br/>Add the null branch"]
  Q -->|"Java · Discount"| J1["switch ครบสาม subtype<br/>แปลผ่าน รหัสออก 0 ไม่มีคำเตือน"]
  K2 --> K3["เติมกิ่ง null แล้วแปลผ่าน"]
  J1 --> J2["ส่ง null เข้าไปตอนรัน"]
  J2 --> J3["NullPointerException รหัสออก 1"]

คำ​บรรยาย​ภาพ: ชนิด​ของ argument เป็น​ตัว​ตัดสิน​ว่า​ความ​ผิด​พลาด​จะ​โผล่​ที่ไหน · ฝั่ง Kotlin แดง​ตอน​แปล ฝั่ง Java เงียบ​จนถึง​ตอน​รัน

การ​เติม​กิ่ง​เข้าไป​เป็นการ​ตัดสิน​ใจ​ที่​มอง​เห็น​ได้ ไม่ใช่​การ​หลุด

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
fun apply(gross: Satang, d: Discount?): Satang = when (d) {
is Discount.Percent -> Satang(gross.v - gross.v * d.pct / 100)
is Discount.Fixed -> Satang(maxOf(0L, gross.v - d.amount.v))
Discount.None -> gross
null -> gross
}
ราคา​ของ​ทั้ง​สอง​ฝั่ง และ​ที่​ที่ Go ยืน​อยู่

ราคา​ที่ Kotlin จ่าย — ชุด​กรณี​ปิด​แล้ว ไม่มี​ใคร​นอก file นี้​เพิ่ม​ส่วนลด​แบบ​ใหม่​ได้​อีก และ​ทุก​ครั้ง​ที่​รับ​ค่าที่​อาจ​หาย​ไป คุณ​ต้อง​เขียน​กิ่ง null ออก​มา​ให้​เห็น

ราคา​ที่ Java จ่าย — null ไป​ได้​ทุก​ที่​และ​ไม่มี​ชนิด​ไหน​ปฏิเสธ​มัน​ได้ ด่าน​ที่ Kotlin ให้​ฟรี​จึง​ต้อง​เขียน​เอง​ด้วย Objects.requireNonNull

ฝั่ง Go ไม่มี​ที่​ให้​แขวน​ด่าน​ความ​ครบ​ถ้วน​เลย · บท​ที่ 2 ของ คอร์ส Go เขียน type Status int คู่​กับ iota ซึ่ง​แปล​ว่า Status(9) เป็น​ค่าที่​ถูก​กฎหมาย และ​บท​นั้น​เขียน String() รองรับ​ค่า​นอก​ช่วง​ไว้​ด้วย · เมื่อ​ชุด​ค่า​ไม่​ปิด compiler จึง​พิสูจน์​ไม่​ได้​ว่า​กรณี​ไหน​ครบ​แล้ว

sealed ไว้​ใช้​เมื่อ​แต่ละ​กรณี​ถือ​ของ​คนละ​ชุด ส่วน enum ไว้​ใช้​เมื่อ​ทุก​กรณี​ถือ​ของ​หน้าตา​เดียวกัน

สถานะ​ขอ​งอ​อเด​อร์​มี​สาม​ค่า และ​ทุก​ค่า​พก​ของ​หน้าตา​เดียวกัน คือ​ชื่อ​ภาษา​ไทย​กับ​ธง​ว่า​ปิด​แล้ว​หรือ​ยัง

enum class Status(val thai: String, val closed: Boolean) {
NEW("ใหม่", false),
PAID("จ่ายแล้ว", true),
CANCELLED("ยกเลิก", true)
}
fun next(s: Status): Status = when (s) {
Status.NEW -> Status.PAID
Status.PAID -> Status.PAID
Status.CANCELLED -> Status.CANCELLED
}
fun main() {
for (s in Status.entries) {
println("${s.name} ${s.thai} closed=${s.closed}")
}
println(next(Status.NEW))
try {
Status.valueOf("paid")
} catch (e: IllegalArgumentException) {
println("ถูกปฏิเสธ: ${e.message}")
}
}
$ kotlinc -Werror -Wextra -jvm-target 25 Statuses.kt -d out
$ kotlin -cp out StatusesKt
NEW ใหม่ closed=false
PAID จ่ายแล้ว closed=true
CANCELLED ยกเลิก closed=true
PAID
ถูกปฏิเสธ: No enum constant Status.paid

when เหนือ enum ได้​ความ​ครบ​ถ้วน​แบบ​เดียว​กับ​เหนือ sealed interface · ลบ​กิ่ง Status.CANCELLED ออก​จาก next แล้ว​แปล​ใหม่

$ kotlinc -Werror -Wextra -jvm-target 25 Statuses.kt -d out
Statuses.kt:7:31: error: 'when' expression must be exhaustive. Add the 'CANCELLED' branch or an 'else' branch.
fun next(s: Status): Status = when (s) {
^^^^

Status.entries คืน​ราย​ชื่อ​ทั้ง​ชุด​ตาม​ลำดับ​ที่​ประกาศ · ส่วน valueOf แยก​ตัว​พิมพ์​เล็ก​ใหญ่ และ​โยน IllegalArgumentException เมื่อ​ไม่รู้​จัก ซึ่ง​เป็น​พฤติกรรม​เดียว​กับ​ฝั่ง Java เป๊ะ เพราะ​มัน​คือ enum ของ JVM ตัว​เดียวกัน

คำถาม​เดียวกัน​สี่​ข้อ ถาม​กับ​สาม​ภาษา​ที่​เขียน orderctl ตัว​เดียวกัน

คำถามGo ตอบJava ตอบKotlin ตอบ
ตัวแปร​ที่​ประกาศ​เปล่า แตะ​ได้​ไหมได้​ทุก​ตัว ผ่าน zero valuefield ได้ ตัวแปร​เฉพาะ​ที่​ไม่​ได้ต้อง​กำหนด​ค่า​ก่อน และ​ชนิด​บอก​เอง​ว่า​รับ null ไหม
ห้าม​สร้าง​ค่าที่​ผิด​กฎ​ได้​ไหมไม่​ได้ ต้อง​ตรวจ​เอง​ทีหลังได้ ด้วย compact constructorได้ ด้วย init แต่​แก้​ค่า​ไม่​ได้
ลืม​กรณี​ตอน​แยก​ชนิด รู้ตัว​เมื่อไรตอน​รัน ถ้า​รู้ตัวตอน​แปล แต่​ไม่​บอก​ชื่อ​กิ่งตอน​แปล พร้อม​ชื่อ​กิ่ง​ที่​ขาด
ลืม​กิ่ง​ของ​ค่าที่​หาย​ไป รู้ตัว​เมื่อไรไม่มี​ชุด​ให้​ลืม nil อยู่​นอก​ชุดตอน​รัน case null เขียน​ได้​แต่​ไม่​บังคับตอน​แปล ถ้า​ชนิด​ของ subject รับ null ได้

แถว​ที่​สอง​บอก​ว่า Kotlin ได้​ครึ่ง​เดียว​ของ​สิ่ง​ที่ Java ได้ · นั่น​คือ​ข้อ​ที่​แลก​มา ความ​สม่ำเสมอ​ของ val ที่​แก้​ค่า​ไม่​ได้​จริง​ทั้ง​โปรแกรม แลก​กับ​ความ​สะดวก​ของ​การ​ซ่อม​ค่า​ใน​ด่าน​เดียว

แถว​ที่​สี่​คือ​แถว​ที่​คอร์ส​นี้​ทั้ง​คอร์ส​หมุน​รอบ · null ของ Kotlin ไม่ใช่​ค่า​พิเศษ​ที่​หลุด​ผ่าน​ทุก​ด่าน มัน​เป็น​กรณี​หนึ่ง​ที่​ระบบ​ชนิด​นับ​ด้วย

ห้า​ชนิด​ของ orderctl อยู่​ใน file เดียว และ​ยัง​ไม่​ต้อง​มี build tool

@JvmInline
value class Sku(val code: String) {
init { require(code.isNotBlank()) { "sku ว่างไม่ได้" } }
}
@JvmInline
value class Satang(val v: Long) {
init { require(v >= 0) { "ราคาติดลบไม่ได้" } }
}
data class Line(val sku: Sku, val qty: Int, val unit: Satang) {
init { require(qty > 0) { "qty ต้องมากกว่า 0 แต่ได้ $qty" } }
fun subtotal(): Satang = Satang(unit.v * qty)
}
sealed interface Discount {
data class Percent(val pct: Int) : Discount
data class Fixed(val amount: Satang) : Discount
data object None : Discount
}
enum class Status(val thai: String, val closed: Boolean) {
NEW("ใหม่", false),
PAID("จ่ายแล้ว", true),
CANCELLED("ยกเลิก", true)
}
fun apply(gross: Satang, d: Discount?): Satang = when (d) {
is Discount.Percent -> Satang(gross.v - gross.v * d.pct / 100)
is Discount.Fixed -> Satang(maxOf(0L, gross.v - d.amount.v))
Discount.None -> gross
null -> gross
}
fun lineOf(sku: String, qty: Int, unitSatang: Long) =
Line(Sku(sku.trim().uppercase()), qty, Satang(unitSatang))
fun main() {
val lines = listOf(
lineOf(" tea ", 2, 3500),
lineOf("cake", 1, 12000),
)
val gross = Satang(lines.sumOf { it.subtotal().v })
println(lines.first())
println("gross=${gross.v}")
println("percent10=${apply(gross, Discount.Percent(10)).v}")
println("none=${apply(gross, Discount.None).v}")
println("null=${apply(gross, null).v}")
println("status=${Status.NEW.thai} closed=${Status.NEW.closed}")
}
$ kotlinc -Werror -Wextra -jvm-target 25 Domain.kt -d out
$ kotlin -cp out DomainKt
Line(sku=Sku(code=TEA), qty=2, unit=Satang(v=3500))
gross=19000
percent10=17100
none=19000
null=19000
status=ใหม่ closed=false

Line ถือ Sku กับ Satang เป็น property แต่​ใน​ตัว class จริง​ไม่มี object สอง​ใบ​นั้น​อยู่​เลย

$ javap -p -cp out Line | grep 'private final'
private final java.lang.String sku;
private final int qty;
private final long unit;

การ​คลี่​ของ value classvalue classชนิด​ที่​ห่อ​ค่า​เดียว​แล้ว​ถูก​คลี่​เป็น​ค่า​ดิบ​ตอน​แปล บน JVM ต้อง​ติด `@JvmInline` · `javap` แสดง​ลาย​เซ็น​เป็น `long` ล้วน แต่​มัน​กลับ​เข้า​กล่อง​เมื่อ​เป็น nullable อยู่​ใน generic หรือ​ถูก​มอง​ผ่าน interface ทำงาน​ข้าม​เข้าไป​ใน​ตัว data classdata classclass ที่​ประกาศ property ไว้​ใน​ตัว​สร้าง​หลัก แล้ว​ได้ `equals` `hashCode` `toString` `componentN` และ `copy` มา​ให้ · property ที่​ประกาศ​ใน​ตัว​ถัง​หาย​ไป​จาก​ทั้ง​ห้า​อย่าง สอง​ค่าที่​ถือ​ของ​คนละ​ชุด​จึง​เท่า​กัน​ได้ ด้วย · Line หนึ่ง​ตัว​จึง​เป็น object ใบ​เดียว ไม่ใช่​สาม​ใบ

โดเมน​ของ orderctl ครบ​ทั้ง​ห้า​ชนิด​แล้ว แต่​มัน​ยัง​รับ data จาก​ภายนอก​ไม่​ได้

คุณ​เขียน​ชนิด​ที่​ปฏิเสธ​ค่า​ผิด​ได้​ตั้งแต่​จังหวะ​สร้าง แยก​เงิน​ออก​จาก​รหัส​สินค้า​โดย​ไม่​จ่าย​ค่า​กล่อง ใน​เส้น​ทาง​ปกติ และ​ปิด​ชุด​กรณี​จน​ลืม​กิ่ง​แล้ว​การ​แปล​ล้ม​พร้อม​ชื่อ​กิ่ง​ที่​ขาด

สิ่ง​ที่​ยัง​ไม่มี​คือ​ทาง​เข้า​ของ data จริง · orderctl ต้อง​อ่าน order.json ให้​ได้ และ​ไม่มี​อะไร​ใน kotlin-stdlib อ่าน JSON ได้​เลย

บท​หน้า​จึง​ต้อง​รับ​สอง​อย่าง​พร้อม​กัน — Gradle กับ build.gradle.kts และ dependency ตัว​แรก · ระหว่าง​ทาง​จะ​เจอ​ความ​ผิด​พลาด​ชนิด​ที่​สอง​คอร์ส​พี่​ไม่มี คือ build ที่​เขียว รหัส​ออก 0 ไม่มี​คำ​เตือน แล้ว​โปรแกรม​ตาย​ตอน​รัน


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

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

  • Data classes (เข้า​ถึง 2026-08-28) — “The compiler only uses the properties defined inside the primary constructor for the automatically generated functions.”
  • Inline value classes (เข้า​ถึง 2026-08-28) — เงื่อนไข​การ​เข้า​กล่อง​สาม​ข้อ “inline classes are boxed whenever they are used as another type” และ​การ​เติม hash ต่อ​ท้าย​ชื่อ function
  • Sealed classes and interfaces (เข้า​ถึง 2026-08-28) — “All direct subclasses of a sealed class are known at compile time.” และ​ความ​ครบ​ถ้วน​ของ when
  • Null safety (เข้า​ถึง 2026-08-28) — T เทียบ T? ตัว​ดำเนิน​การ ?. และ ?:
  • Calling Java from Kotlin (เข้า​ถึง 2026-08-28) — platform type คำ​กำกับ​ของ org.jetbrains.annotations ที่ compiler อ่าน และ​ข้อ​ที่​ว่า “the compiler will emit an assertion upon assignment”
  • ข้อความ​ของ compiler และ​ผล​รัน​ทุก​บรรทัด​ใน​บท​นี้​วัด​เอง​บน kotlinc-jvm 2.4.10 กับ javac 25.0.4.1 · macOS arm64 เมื่อ 2026-08-28 · รัน​ซ้ำ​ได้​ด้วย​คำ​สั่ง​ที่​ยก​มา​ใน​แต่ละ​หัวข้อ

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

ข้อ 1 / 3

ทำไม `init { sku = sku.trim() }` ใน data class ถึงตอบ `error: 'val' cannot be reassigned.`