ชนิดที่สร้างค่าผิดไม่ขึ้น และ 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 DomainKtLine(sku=TEA, qty=2, unitSatang=3500)a == b -> trueLine(sku=TEA, qty=3, unitSatang=3500)TEA x 2a == b เป็น true ทั้งที่เป็นคนละ object · ในภาษาที่ == เทียบที่อยู่ นี่คือกับดัก
แต่ Kotlin แปล == เป็นการเรียก equals ให้เสมอ
นี่คือ data classdata classclass ที่ประกาศ property ไว้ในตัวสร้างหลัก แล้วได้ `equals` `hashCode` `toString` `componentN` และ `copy` มาให้ · property ที่ประกาศในตัวถังหายไปจากทั้งห้าอย่าง สองค่าที่ถือของคนละชุดจึงเท่ากันได้ · เปิดดูว่ามันสร้างอะไรให้บ้าง
$ javap -cp out LineCompiled 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 NoteKtLine(sku=TEA, qty=2, unitSatang=3500)a == b -> truecopy().note -> ไม่มีสามอย่างเกิดขึ้นพร้อมกัน — toString ไม่พิมพ์ note ออกมาเลย · a == b ยังเป็น true
ทั้งที่สองค่าถือ note คนละตัว · และ copy() คืนค่าที่ note กลับไปเป็น "ไม่มี"
compiler สร้าง equals hashCode toString componentN และ copy จาก property
ที่ประกาศไว้ในวงเล็บของตัวสร้างหลักเท่านั้น
ของที่อยู่ในตัวถังยังเป็น property จริง อ่านและเขียนได้ตามปกติ แต่มันอยู่นอกสายตาของทั้งห้าอย่างนั้น · ถ้าค่าใดเป็นส่วนหนึ่งของตัวตนของออเดอร์ มันต้องอยู่ในวงเล็บ
init ปฏิเสธค่าผิดได้ แต่ซ่อมมันไม่ได้
หัวข้อที่มีชื่อว่า “init ปฏิเสธค่าผิดได้ แต่ซ่อมมันไม่ได้”จุดนี้คือที่ที่คนมาจาก 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 outDomain.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 DomainKtLine(sku=TEA, qty=2, unitSatang=3500)ถูกปฏิเสธ: qty ต้องมากกว่า 0 แต่ได้ 0requirerequireด่านตรวจต้นทางที่ล้มด้วย `IllegalArgumentException` · มันประกาศ contract ไว้ compiler จึงแคบชนิดให้เองหลังผ่านด่าน ซึ่ง `Objects.requireNonNull` ของ Java ทำให้ไม่ได้ ล้มด้วย IllegalArgumentException และ lambda ที่ต่อท้ายทำงานเฉพาะตอนที่ล้ม
· ข้อความจึงประกอบค่าจริงเข้าไปได้โดยไม่เสียอะไรในเส้นทางปกติ
ท่าที่คนคิดถึงเป็นอันดับแรกคือเก็บค่าดิบไว้ในตัวสร้าง แล้วคำนวณค่าที่จัดรูปแล้วในตัวถัง
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 DerivedKta.sku=TEA b.sku=TEALine(rawSku= tea , qty=2)a == b -> falsea.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 FactoryKtLine(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 ส่วนตัว
T? กับ T เป็นชนิดคนละตัวจริงๆ
หัวข้อที่มีชื่อว่า “T? กับ T เป็นชนิดคนละตัวจริงๆ”ความต่างไม่ได้อยู่ที่คำเตือน มันอยู่ที่ระบบชนิดปฏิเสธการกำหนดค่าตั้งแต่ตอนแปล
สร้าง Nulls.kt ที่ยัด null ลงชนิดธรรมดา
// ❌ แปลไม่ผ่านfun main() { val sku: String = null println(sku)}$ kotlinc -Werror -Wextra -jvm-target 25 Nulls.kt -d outNulls.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 NullsKtnull0ไม่พบ3ถูกปฏิเสธ: sku ห้ามเป็น nullบรรทัด return sku.length ในนั้นไม่มี ?. และไม่มี !! เลย ทั้งที่ sku ประกาศเป็น
String? · requirerequireด่านตรวจต้นทางที่ล้มด้วย `IllegalArgumentException` · มันประกาศ contract ไว้ compiler จึงแคบชนิดให้เองหลังผ่านด่าน ซึ่ง `Objects.requireNonNull` ของ Java ทำให้ไม่ได้ พก contract ไปบอก compiler ว่าถ้าเลยบรรทัดนั้นมาได้ แปลว่าเงื่อนไขจริง
เขียนด่านเดียวกันด้วยของจาก 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 outNonNull.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 ได้เปล่าๆ ส่วนบรรทัดถัดจากอีกตัวเขียนไม่ได้
ค่าที่ข้ามมาจาก Java ไม่มีใครกำกับให้
หัวข้อที่มีชื่อว่า “ค่าที่ข้ามมาจาก Java ไม่มีใครกำกับให้”ระบบชนิดของ 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 PlatformKtException 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 outPlatform.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
@JvmInlinevalue class Sku(val code: String) { init { require(code.isNotBlank()) { "sku ว่างไม่ได้" } }}
@JvmInlinevalue 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 MoneyKtSatang(v=4700)Satang(v=1200)Sku(code=TEA)ถูกปฏิเสธ: ราคาติดลบไม่ได้ระบบชนิดแยกสองตัวนี้ออกจากกันจริง · เขียน Mix.kt เพื่อลองพัง
// ❌ แปลไม่ผ่าน@JvmInlinevalue 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 outMix.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): Satang | public static final long add-<hash>(long, long) |
addOrZero(a: Satang?, b: Satang): Satang | public static final long addOrZero-<hash>(Satang, long) |
ตัวบนไม่มี object เลย เข้าเป็น long สองตัว ออกเป็น long · ตัวล่างมี Satang
ตัวจริงเป็น argument แรก เพราะ Satang? ต้องมีที่ให้ null ยืน และ long ไม่มีที่นั้น
ค่าจะถูกห่อกลับเป็น object เมื่อมันเป็น nullable เมื่อมันอยู่ใน generic เช่น List<Satang>
และเมื่อมันถูกมองผ่าน interface · สามข้อนี้ครอบสิ่งที่โปรแกรมจริงทำเกือบตลอดเวลา
ที่ไม่หายไปไหนคือด่าน · Satang(-1) ยังถูก requirerequireด่านตรวจต้นทางที่ล้มด้วย `IllegalArgumentException` · มันประกาศ contract ไว้ compiler จึงแคบชนิดให้เองหลังผ่านด่าน ซึ่ง `Objects.requireNonNull` ของ Java ทำให้ไม่ได้ ปฏิเสธเหมือนเดิม
แม้ลายเซ็นจะเป็น long ดิบ เพราะ compiler เรียก constructor-impl ให้ก่อนเสมอ
ประโยชน์ของ value class จึงเป็นเรื่องระบบชนิดเป็นหลัก ส่วนการไม่เข้ากล่องเป็นของแถม
ที่ได้เฉพาะในเส้นทางที่ไม่แตะสามข้อข้างบน
บทที่ 2 ของ คอร์ส Go สร้าง type Baht int64
ด้วยเหตุผลเดียวกันเป๊ะ — กันเงินไปบวกกับจำนวนชิ้น · และผูก method String() เข้ากับมันได้ด้วย
ที่ต่างคือ Baht ของ Go เป็นชนิดจริงตลอดทาง ไม่มีจังหวะไหนที่มันกลายเป็นอย่างอื่น
ส่วน Satang ของ Kotlin หายไปจาก bytecode ในเส้นทางปกติ แล้วโผล่กลับมาเมื่อเจอสามข้อข้างบน
Go ไม่มีค่าใช้จ่ายของกล่องให้ต้องคิด แต่ Go ก็ไม่มี init ให้แขวนด่านไว้เหมือนกัน
· Baht(-1) สร้างได้เสมอ
sealed interface กับ when ที่นับกรณีให้ครบ
หัวข้อที่มีชื่อว่า “sealed interface กับ when ที่นับกรณีให้ครบ”ปิดรายชื่อ subtype ไว้ตอนแปล แล้ว
whenที่ครอบครบทุกกรณีจึงไม่ต้องมีelse
ส่วนลดของ orderctl มีสามแบบ คิดเป็นเปอร์เซ็นต์ หักเป็นจำนวนเงิน และไม่ลด
· แต่ละแบบพก data คนละชุด
สร้าง Discounts.kt
@JvmInlinevalue 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 DiscountsKt17100019000สามบรรทัดนี้เป็นตัวเลขชุดเดียวกับที่คอร์ส Java พิมพ์ออกมาในบทเดียวกัน เพราะเป็นโดเมนเดียวกัน
when ก้อนนั้นไม่มี else และนั่นตั้งใจ · นี่คือ exhaustive whenexhaustive when`when` ที่ครอบทุกกรณีของ sealed หรือ enum จนไม่ต้องมี `else` · ข้อความตอนขาดกรณีบอกชื่อกิ่งที่ต้องเพิ่มให้ด้วย ต่างจาก `switch` ของ Java ที่บอกแค่ว่ายังไม่ครบ
· compiler รู้ว่า Discount มี subtype แค่สามตัว จึงพิสูจน์เองได้ว่าสามกิ่งนี้ครบแล้ว
หลัง is Discount.Percent -> เขียน d.pct ได้เลยโดยไม่ต้องแปลงชนิดก่อน
· การถามชนิดกับการแคบชนิดเกิดในนิพจน์เดียวกัน
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 outDiscounts.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.javaDiscounts.java:9: error: the switch expression does not cover all possible input values return switch (d) { ^1 errorerror: compilation failedทั้งสองภาษาแดงตอนแปลเหมือนกัน และแดงทุกจุดที่แยกกรณีเหนือชนิดนั้นพร้อมกันในรอบเดียว · ที่ต่างคือฝั่งหนึ่งบอกชื่อกิ่งให้แล้ว อีกฝั่งบอกแค่ว่ายังไม่ครบ
มีข้ออ้างที่เจอบ่อยว่า 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.javaStatements.java:8: error: the switch statement does not cover all possible input values switch (d) { ^1 errorerror: 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 outEnumStmt.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 outDiscounts.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.java17100Exception 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 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}ราคาที่ Kotlin จ่าย — ชุดกรณีปิดแล้ว ไม่มีใครนอก file นี้เพิ่มส่วนลดแบบใหม่ได้อีก
และทุกครั้งที่รับค่าที่อาจหายไป คุณต้องเขียนกิ่ง null ออกมาให้เห็น
ราคาที่ Java จ่าย — null ไปได้ทุกที่และไม่มีชนิดไหนปฏิเสธมันได้
ด่านที่ Kotlin ให้ฟรีจึงต้องเขียนเองด้วย Objects.requireNonNull
ฝั่ง Go ไม่มีที่ให้แขวนด่านความครบถ้วนเลย · บทที่ 2 ของ
คอร์ส Go เขียน type Status int
คู่กับ iota ซึ่งแปลว่า Status(9) เป็นค่าที่ถูกกฎหมาย และบทนั้นเขียน String()
รองรับค่านอกช่วงไว้ด้วย · เมื่อชุดค่าไม่ปิด compiler จึงพิสูจน์ไม่ได้ว่ากรณีไหนครบแล้ว
enum class เมื่อกรณีไม่ต้องพก data ต่างกัน
หัวข้อที่มีชื่อว่า “enum class เมื่อกรณีไม่ต้องพก data ต่างกัน”
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 StatusesKtNEW ใหม่ closed=falsePAID จ่ายแล้ว closed=trueCANCELLED ยกเลิก closed=truePAIDถูกปฏิเสธ: No enum constant Status.paidwhen เหนือ enum ได้ความครบถ้วนแบบเดียวกับเหนือ sealed interface · ลบกิ่ง
Status.CANCELLED ออกจาก next แล้วแปลใหม่
$ kotlinc -Werror -Wextra -jvm-target 25 Statuses.kt -d outStatuses.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 value | field ได้ ตัวแปรเฉพาะที่ไม่ได้ | ต้องกำหนดค่าก่อน และชนิดบอกเองว่ารับ null ไหม |
| ห้ามสร้างค่าที่ผิดกฎได้ไหม | ไม่ได้ ต้องตรวจเองทีหลัง | ได้ ด้วย compact constructor | ได้ ด้วย init แต่แก้ค่าไม่ได้ |
| ลืมกรณีตอนแยกชนิด รู้ตัวเมื่อไร | ตอนรัน ถ้ารู้ตัว | ตอนแปล แต่ไม่บอกชื่อกิ่ง | ตอนแปล พร้อมชื่อกิ่งที่ขาด |
| ลืมกิ่งของค่าที่หายไป รู้ตัวเมื่อไร | ไม่มีชุดให้ลืม nil อยู่นอกชุด | ตอนรัน case null เขียนได้แต่ไม่บังคับ | ตอนแปล ถ้าชนิดของ subject รับ null ได้ |
แถวที่สองบอกว่า Kotlin ได้ครึ่งเดียวของสิ่งที่ Java ได้ · นั่นคือข้อที่แลกมา
ความสม่ำเสมอของ val ที่แก้ค่าไม่ได้จริงทั้งโปรแกรม แลกกับความสะดวกของการซ่อมค่าในด่านเดียว
แถวที่สี่คือแถวที่คอร์สนี้ทั้งคอร์สหมุนรอบ · null ของ Kotlin ไม่ใช่ค่าพิเศษที่หลุดผ่านทุกด่าน
มันเป็นกรณีหนึ่งที่ระบบชนิดนับด้วย
รวมทุกอย่างเข้า Domain.kt
หัวข้อที่มีชื่อว่า “รวมทุกอย่างเข้า Domain.kt”ห้าชนิดของ
orderctlอยู่ใน file เดียว และยังไม่ต้องมี build tool
@JvmInlinevalue class Sku(val code: String) { init { require(code.isNotBlank()) { "sku ว่างไม่ได้" } }}
@JvmInlinevalue 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 DomainKtLine(sku=Sku(code=TEA), qty=2, unit=Satang(v=3500))gross=19000percent10=17100none=19000null=19000status=ใหม่ closed=falseLine ถือ 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.`