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

collection ของ Kotlin ทำ​ครบ​ที​ละ​ขั้น จนกว่า​คุณ​จะ​ขอ​เป็น​อย่าง​อื่น

บท​ที่ 2 ให้​ชนิด​ของ orderctl มา​ครบ และ​บท​ที่ 3 ให้​โปร​เจ​กต์ Gradle ที่​อ่าน JSON ได้

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

📦 บท​นี้​เทียบ​ตรง​กับ​บท​ที่ 4 ของ​อีก​สอง​คอร์ส

คอร์ส Java แก้​โจทย์​เดียวกัน​นี้​ด้วย Stream และ คอร์ส Go แก้​ด้วย for กับ map

ผล​ของ Totals ใน​บท​นี้​ตรง​กับ​ของ​คอร์ส Java ทุก​บรรทัด เพราะ​เป็น​ออ​เด​อร์​ชุด​เดียวกัน · ที่​ต่าง​คือ​ทาง​ที่​ใช้​ไป​ถึง​มัน

ห้อง​ทดลอง​อื่น​ของ​สอง​บท​นี้​วัด​คนละ​เรื่อง​กัน ตัวเลข​ของ​มัน​จึง​เทียบ​กัน​ตรงๆ ไม่​ได้

ห้อง​ทดลอง​ทุก​อัน​ใน​บท​นี้​รัน​แบบ​เดียวกัน — เปลี่ยน​บรรทัด mainClass ใน build.gradle.kts ที่​บท​ที่ 3 เขียน​ไว้ ให้​ชี้​ไป​ที่ file class ของ​ห้อง​นั้น แล้ว​สั่ง gradle -q run

แก้ Line ครั้ง​เดียว เพื่อ​ให้​มัน​รู้​ว่า​ตัว​เอง​อยู่​หมวด​ไหน

หัวข้อ​ที่​มีชื่อ​ว่า “แก้ Line ครั้ง​เดียว เพื่อ​ให้​มัน​รู้​ว่า​ตัว​เอง​อยู่​หมวด​ไหน”

คำถาม “หมวด​ไหน​กี่​สตางค์” ตอบ​ไม่​ได้​เลย​ถ้า Line ยัง​ไม่มี​ช่อง​สำหรับ​หมวด

Domain.kt ของ​บท​ที่ 2 ยัง​อยู่​นอก​โปร​เจ​กต์ Gradle · ย้าย​มัน​เข้า​มา​ที่ src/main/kotlin/orderctl/ เติม package orderctl ที่​หัว file แล้ว​แก้​สาม​ที่

  • เพิ่ม​ช่อง category ให้ Line
  • เพิ่ม argument category ให้ lineOf ด้วย ไม่​งั้น​มัน​เรียก Line ด้วย​ค่า​ไม่​ครบ
  • ลบ fun main ของ​บท​ที่ 2 ทิ้ง เพราะ main ของ​โปร​เจ​กต์​อยู่​ที่ Main.kt ตั้งแต่​บท​ที่​แล้ว
src/main/kotlin/orderctl/Domain.kt
package orderctl
@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 category: String, val qty: Int, val unit: Satang) {
init { require(qty > 0) { "qty ต้องมากกว่า 0 แต่ได้ $qty" } }
fun subtotal(): Satang = Satang(unit.v * qty)
}
fun lineOf(sku: String, category: String, qty: Int, unitSatang: Long) =
Line(Sku(sku.trim().uppercase()), category.trim().lowercase(), qty, Satang(unitSatang))

block ข้าง​บน​ตัด Discount Status และ apply ของ​บท​ที่ 2 ออก​ไป​เพื่อ​ให้​สั้น · ทั้ง​สาม​ยัง​อยู่​ใน Domain.kt ต่อ​ไป​ตาม​เดิม และ​บท​นี้​ไม่​แตะ​มัน

หัวข้อ​นี้​สร้าง Totals ซึ่ง​เป็น​ของ​ชิ้น​เดียว​ที่​บท​นี้​เพิ่ม​เข้า​โปร​เจ​กต์​จริง

src/main/kotlin/orderctl/Totals.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมีชนิดของ Domain อยู่ข้างกัน
package orderctl
object Totals {
fun grossSatang(lines: List<Line>): Long =
lines.sumOf { it.subtotal().v }
fun byCategory(lines: List<Line>): Map<String, Long> =
lines.groupingBy { it.category }
.fold(0L) { acc, line -> acc + line.subtotal().v }
.toSortedMap()
fun report(lines: List<Line>): List<Line> =
lines.sortedWith(compareBy<Line> { it.category }.thenByDescending { it.sku.code })
fun receiptLine(lines: List<Line>): String =
"${lines.size} รายการ รวม ${grossSatang(lines)} สตางค์"
}

ยอด​ทุก​ตัว​คืน​เป็น Long ไม่ใช่ Satang · เหตุผล​อยู่​ใน​บท​ที่ 2 ซึ่ง​วัด​ไว้​แล้ว​ว่า value class เข้า​กล่อง​ทันที​ที่​ไป​อยู่​ใน generic · Map<String, Satang> จึง​ห่อ​ทุก​ค่าที่​มัน​เก็บ

คอร์ส Java เขียน​ของ​ชิ้น​เดียวกัน​เป็น public final class Totals ที่​มี constructor ส่วน​ตัว ไว้​กัน​ไม่​ให้​ใคร​สร้าง instance · Kotlin มี​คำ​สำหรับ​เรื่อง​นี้​อยู่​แล้ว​คือ object

ห้อง​ทดลอง​ที่​เรียก​มัน​ทั้ง​สี่​ตัว

src/main/kotlin/orderctl/TotalsDemo.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมีชนิดของ Domain กับ Totals อยู่ข้างกัน
package orderctl
fun main() {
val lines = listOf(
lineOf("tea", "drink", 2, 3500),
lineOf("coffee", "drink", 1, 6000),
lineOf("cake", "dessert", 1, 12000),
)
println("รวม ${Totals.grossSatang(lines)} สตางค์")
for ((cat, sum) in Totals.byCategory(lines)) {
println(" $cat $sum")
}
println(Totals.report(lines).map { it.sku.code })
println(Totals.receiptLine(lines))
}
$ gradle -q run
รวม 25000 สตางค์
dessert 12000
drink 13000
[CAKE, TEA, COFFEE]
3 รายการ รวม 25000 สตางค์

สี่​ตัว​นี้​ใช้​เครื่อง​มือ​คนละ​ชั้น​กัน และ​ความ​ต่าง​ของ​มัน​คือ​เนื้อหา​ที่​เหลือ​ของ​บท

  • sumOf รับ lambda ที่​คืน Long แล้ว​บวก​ให้ ไม่มี​ตัวแปร​ให้​ประกาศ​ไว้​ก่อน
  • groupingBy แล้ว fold สะสม​ยอด​ต่อ​คีย์​โดย​ไม่​ต้อง​ตั้ง​ค่า​เริ่ม​ต้น​ให้​คีย์​ใหม่​เอง
  • sortedWith รับ​ตัว​เทียบ​ที่​ต่อ​กัน​ด้วย thenByDescending ได้ที​ละ​กุญแจ
  • toSortedMap คือ​บรรทัด​ที่​ทำให้​ผล​ไหล​ออก​ไป​ใน​ลำดับ​ที่​ทำ​ซ้ำ​ได้

หัวข้อ​นี้​แสดง​ว่า List ปิด​ทาง​เขียน​ได้​จริง​ตอน​แปล แต่​ตอน​รัน​มัน​ไม่มี​ใคร​ยืน​เฝ้า

Kotlin แยก​ชนิด​ของ collection เป็น​คู่ — ตัว​อ่าน​อย่าง​เดียว​กับ​ตัว​ที่​เขียน​ได้ · เอกสาร​ทางการ​เรียก​ตัว​แรก​ว่า “A read-only interface that provides operations for accessing collection elements”

เรียก add บน List แล้ว compiler ปฏิเสธ​ตั้งแต่​ตอน​แปล

src/main/kotlin/orderctl/AddLine.kt
// ❌ แปลไม่ผ่าน — ตั้งใจ · List ไม่มี method สำหรับเขียน
package orderctl
fun addLine(lines: List<Line>, extra: Line) {
lines.add(extra)
}
AddLine.kt:5:11: error: unresolved reference 'add'.
lines.add(extra)
^^^

นี่​คือ read-only collectionread-only collection`List` ที่​ไม่มี method สำหรับ​เขียน แต่​ไม่ใช่​ของ​ที่​แก้​ไม่​ได้ · การ​มอง​ผ่าน `List` เป็นการ​ตั้ง​ชื่อ​ใหม่​ให้​ของ​เดิม ไม่ใช่​การ​ถ่าย​สำเนา คน​ที่​ถือ `MutableList` ตัว​เดียวกัน​ยัง​เติม​ของ​แล้ว​เรา​เห็น · ต้อง `toList()` ถึง​จะ​ได้​สำเนา​จริง · ข้อ​ที่​คน​มา​จาก Java เดา​ผิด​คือ​คำ​ว่า read-only ไม่​ได้​แปล​ว่า​ของ​ข้าง​ใน​แก้​ไม่​ได้ มัน​แปล​ว่าหน้าต่าง​บาน​นี้​ไม่มี​ปุ่ม​เขียน

src/main/kotlin/orderctl/ViewLab.kt
package orderctl
fun main() {
val backing = mutableListOf("tea", "cake")
val view: List<String> = backing
val copy: List<String> = backing.toList()
backing.add("coffee")
println("view = $view")
println("copy = $copy")
val sneak = listOf("tea", "cake") as MutableList<String>
println("sneak class = ${sneak.javaClass.name}")
sneak[0] = "TEA"
println("sneak set = $sneak")
try {
sneak.add("coffee")
} catch (e: UnsupportedOperationException) {
println("sneak add = ${e.javaClass.simpleName}")
}
}
$ gradle -q run
view = [tea, cake, coffee]
copy = [tea, cake]
sneak class = java.util.Arrays$ArrayList
sneak set = [TEA, cake]
sneak add = UnsupportedOperationException

อ่าน​ผล​นี้​เป็น​สอง​ข้อ

  • view เห็น coffee ที่​เพิ่ม​ทีหลัง เพราะ​การ​มอง​ผ่าน List เป็นการ​ตั้ง​ชื่อ​ใหม่​ให้​ของ​เดิม ไม่ใช่​การ​ถ่าย​สำเนา · copy ที่มา​จาก toList() ต่าง​หาก​ที่​เป็น​สำเนา​จริง
  • บรรทัด cast ไม่มี​คำ​เตือน​สัก​ตัว แม้​แปล​ด้วย -Werror -Wextra และ set ก็​สำเร็จ​จริง · ที่​ล้ม​คือ add เพราะ​ของ​ที่ listOf คืน​มา​เป็น​หน้าต่าง​บน array ที่​ขยาย​ไม่​ได้
`listOf` ของ Kotlin กับ `List.of` ของ Java ให้​สัญญา​คนละ​ชั้น

List.of("tea", "cake") ของ Java คืน java.util.ImmutableCollections$List12 ซึ่ง​โยน UnsupportedOperationException ตั้งแต่ set

listOf("tea", "cake") ของ Kotlin คืน java.util.Arrays$ArrayList ซึ่ง set ผ่าน

Kotlin เก็บ​ความ​ปลอดภัย​ไว้​ที่​ชั้น​ชนิด ส่วน Java เก็บ​ไว้​ที่​ชั้น​ตอน​รัน · ผล​คือ Kotlin จับ​ความ​ผิด​พลาด​ได้​เร็ว​กว่า​เมื่อ​คุณ​เขียน​ชนิด​ออก​มา​ตรงๆ แต่​จับ​ไม่​ได้​เลย​เมื่อ​มี​คน cast ข้าม​หน้า​ไป

รูป​ที่​ปลอดภัย​ทั้ง​สอง​ชั้น​คือ​คืน toList() ออก​ไป ซึ่ง​เป็น​สำเนา​ที่​ผู้​เรียก​แก้​แล้ว​ไม่​ทะลุ​ถึง​ต้นทาง

หัวข้อ​นี้​เทียบ​ลำดับ​การ​วน map ของ​สาม​ภาษา และ Kotlin เป็น​ภาษา​เดียว​ที่​เอกสาร​รับปาก​ไว้

src/main/kotlin/orderctl/MapLab.kt
package orderctl
fun main() {
val ordered = mapOf("drink" to 1, "dessert" to 2, "food" to 3)
val hashed = hashMapOf("drink" to 1, "dessert" to 2, "food" to 3)
println("mapOf = ${ordered.keys}")
println("hashMapOf = ${hashed.keys}")
println("mapOf class = ${ordered.javaClass.name}")
println("hashMapOf class = ${hashed.javaClass.name}")
}
$ gradle -q run
mapOf = [drink, dessert, food]
hashMapOf = [dessert, drink, food]
mapOf class = java.util.LinkedHashMap
hashMapOf class = java.util.HashMap

mapOf คืน​ลำดับ​ที่​ใส่​เข้าไป และ​เอกสาร​ของ​มัน​เขียน​ไว้​ว่า “Entries of the map are iterated in the order they were specified.”

สัญญา​เรื่อง​ลำดับ
mapOf ของ Kotlinเอกสาร​รับปาก​ว่า​วน​ตาม​ลำดับ​ที่​ใส่
Map.of ของ Javaไม่​รับปาก และ​คอร์ส Java วัด​แล้ว​ว่า​ลำดับ​ที่​ได้​ไม่​ตรง​กัน​ข้าม​รอบ​รัน
map ของ Goruntime สุ่ม​จุด​เริ่ม​โดย​ตั้งใจ

hashMapOf ไม่​ได้​รับปาก​อะไร​เลย · ลำดับ​ที่​เห็น​ข้าง​บน​คงที่​บน​เครื่อง​นี้​เพราะ​กุญแจ​เป็น String ที่​คำนวณ hash แบบ​เดิม​ทุกร​อบ ไม่ใช่​เพราะ​มี​ใคร​สัญญา​ไว้

คำ​สัญญา​เรื่อง​ลำดับ​ที่​ใส่ ยัง​ไม่ใช่​คำ​สัญญา​เรื่อง​ลำดับ​ที่​เรียง

byCategory ใช้ groupingBy ซึ่ง​คืน LinkedHashMap · ลำดับ​ที่​ได้​จึง​เป็น​ลำดับ​ที่​หมวด​นั้น โผล่​มา​ครั้ง​แรก​ใน​รายการ ซึ่ง​วัด​แล้ว​ได้ {drink=13000, dessert=12000}

นั่น​ทำ​ซ้ำ​ได้ แต่​มัน​ไม่ใช่​ลำดับ​ที่​เรียง · toSortedMap() ต่าง​หาก​ที่​ให้ {dessert=12000, drink=13000} ซึ่ง​ตรง​กับ​ที่​คอร์ส Java ขอ​ด้วย TreeMap::new

กฎ​เดียวกัน​ทั้ง​สาม​ภาษา — อะไร​ที่​ไหล​ออก​ไป​เป็น output ต้อง​ถูก​เรียง​ก่อน​เสมอ · Kotlin ทำให้​คุณ​ลืม​กฎ​นี้​ได้​ง่าย​กว่า เพราะ​ลำดับ​ที่​มัน​ให้​มา​ดู​เหมือน​ถูก​อยู่​แล้ว

ค่า​ปริยาย​ของ Kotlin คือ​ทำ​แต่ละ​ขั้น​ให้​ครบ​ก่อน

หัวข้อ​ที่​มีชื่อ​ว่า “ค่า​ปริยาย​ของ Kotlin คือ​ทำ​แต่ละ​ขั้น​ให้​ครบ​ก่อน”

หัวข้อ​นี้​พิมพ์​ลำดับ​การ​ทำงาน​จริง​ของ map ต่อ filter ออก​มา​ทั้ง​สอง​รูป

Stream ของ Java lazy มา​ตั้งแต่​บรรทัด​แรก · List ของ Kotlin ไม่ใช่

src/main/kotlin/orderctl/OrderLab.kt
package orderctl
fun main() {
val xs = listOf(1, 2, 3)
print("list : ")
val a = xs.map { print("m$it "); it * 2 }
.filter { print("f$it "); it > 2 }
println("=> $a")
print("seq : ")
val b = xs.asSequence()
.map { print("m$it "); it * 2 }
.filter { print("f$it "); it > 2 }
.toList()
println("=> $b")
print("first: ")
val c = xs.asSequence()
.map { print("m$it "); it * 2 }
.first { print("f$it "); it > 2 }
println("=> $c")
}
$ gradle -q run
list : m1 m2 m3 f2 f4 f6 => [4, 6]
seq : m1 f2 m2 f4 m3 f6 => [4, 6]
first: m1 f2 m2 f4 => 4

แถว​แรก​ทำ map ให้​ครบ​ทั้ง​สาม​ตัว​ก่อน แล้ว​ค่อย​เริ่ม filter · แถว​ที่​สอง​สลับ​กัน​ไป​ที​ละ​ตัว

เอกสาร​ของ Kotlin อธิบาย​สอง​รูป​นี้​ไว้​ตรงๆ ว่า “Sequence performs all the processing steps one-by-one for every single element” ส่วน​ฝั่ง Iterable นั้น “each processing step completes and returns its result – an intermediate collection”

flowchart LR
  A["listOf(1, 2, 3)"] --> B["map"]
  B --> C["List กลาง<br/>2 4 6"]
  C --> D["filter"]
  D --> E["List ผลลัพธ์<br/>4 6"]
  A -.->|"asSequence()"| F["map แล้ว filter<br/>ทีละสมาชิก"]
  F -.-> G["toList()<br/>สร้าง List ใบเดียวตอนจบ"]

คำ​บรรยาย​ภาพ: เส้น​ทึบ​สร้าง List ขึ้น​มา​หนึ่ง​ใบ​ต่อ​หนึ่ง​ขั้น · เส้น​ประ​ไม่​สร้าง​ใบ​กลาง​เลย และ​ปลาย​ทาง​เป็น​ตัว​ที่​ตัดสิน​ว่า​งาน​จะ​เริ่ม​เมื่อไร

SequenceSequenceท่อ​ที่​คำนวณ​ที​ละ​สมาชิก​จนถึง​ตัว​ปิด​ท้าย ไม่​ทำให้​ครบ​ที​ละ​ขั้น​แบบ `List` · เดิน​ซ้ำ​ได้ ต่าง​จาก `Stream` ของ Java ที่​ใช้​ครั้ง​เดียว และ​ต้อง​ขอ `constrainOnce()` เอง​ถ้า​อยาก​ห้าม ซื้อ​อะไร วัด​ด้วย​จำนวน​ครั้ง​ที่ lambda ถูก​เรียก

หัวข้อ​ที่​มีชื่อ​ว่า “Sequence ซื้อ​อะไร วัด​ด้วย​จำนวน​ครั้ง​ที่ lambda ถูก​เรียก”

หัวข้อ​นี้​นับ​จำนวน​ครั้ง​ที่ map ทำงาน​จริง​เมื่อ​ปลาย​ทาง​ต้องการ​คำ​ตอบ​เดียว

ห้อง​ทดลอง​นี้​มี​หนึ่ง​พัน​รายการ และ​ถาม​คำถาม​ที่​ตอบ​ได้​ตั้งแต่​รายการ​ที่​สาม

src/main/kotlin/orderctl/CountLab.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมีชนิดของ Domain อยู่ข้างกัน
package orderctl
fun main() {
val lines = (1..1000).map { lineOf("SKU$it", "drink", 1, it.toLong()) }
var eagerCalls = 0
val a = lines.map { eagerCalls++; it.subtotal().v }.first { it >= 3 }
var lazyCalls = 0
val b = lines.asSequence().map { lazyCalls++; it.subtotal().v }.first { it >= 3 }
println("list ได้ $a เรียก map $eagerCalls ครั้ง")
println("seq ได้ $b เรียก map $lazyCalls ครั้ง")
}
$ gradle -q run
list ได้ 3 เรียก map 1000 ครั้ง
seq ได้ 3 เรียก map 3 ครั้ง

คำ​ตอบ​เท่า​กัน งาน​ที่​ทำ​ต่าง​กัน​หนึ่ง​พัน​ครั้ง​เทียบ​กับ​สาม​ครั้ง

อย่า​เติม `asSequence()` ลง​ทุก​สาย · เอกสาร​ของ Kotlin เตือน​ไว้​เอง

หน้า​เอกสาร Sequences เขียน​ว่า “the lazy nature of sequences adds some overhead which may be significant when processing smaller collections or doing simpler computations”

Totals ทั้ง​สี่​ตัว​ใน​บท​นี้​จึง​ไม่มี​ตัว​ไหน​ใช้ asSequence() เลย · ออ​เด​อร์​หนึ่ง​ใบ​มี​ไม่​กี่​รายการ และ​ทุก​สาย​จบ​ด้วย​การ​กิน​สมาชิก​ทุก​ตัว​อยู่​แล้ว

เส้น​แบ่ง​ที่​ใช้​ตัดสิน​ได้​จริง​มี​สอง​ข้อ

  • ปลาย​ทาง​กิน​สมาชิก​ทุก​ตัว​หรือ​ไม่ · ถ้า​กิน​ครบ Sequence ไม่​ได้​ลด​งาน​ลง​เลย
  • สาย​ยาว​กี่​ขั้น · ทุก​ขั้น​บน List สร้าง List ใบ​ใหม่​หนึ่ง​ใบ ส่วน Sequence ไม่​สร้าง​สัก​ใบ

หัวข้อ​นี้​แสดง​ข้อ​ที่​กลับ​ด้าน​กับ Stream ของ Java และ​ข้อ​ยกเว้น​ที่​ตาม​มา​ด้วย

src/main/kotlin/orderctl/ReuseLab.kt
package orderctl
fun main() {
val seq = listOf(1, 2, 3).asSequence().map { it * 2 }
println("รอบแรก = ${seq.toList()}")
println("รอบสอง = ${seq.toList()}")
val once = listOf(1, 2, 3).asSequence().map { it * 2 }.constrainOnce()
println("constrainOnce รอบแรก = ${once.toList()}")
try {
once.toList()
} catch (e: IllegalStateException) {
println("constrainOnce รอบสอง = ${e.javaClass.simpleName}: ${e.message}")
}
}
$ gradle -q run
รอบแรก = [2, 4, 6]
รอบสอง = [2, 4, 6]
constrainOnce รอบแรก = [2, 4, 6]
constrainOnce รอบสอง = IllegalStateException: This sequence can be consumed only once.

Stream ของ Java ตัว​เดียวกัน​นี้​ตอบ IllegalStateException: stream has already been operated upon or closed ตั้งแต่​รอบ​ที่​สอง โดย​ไม่​ต้อง​ขอ​อะไร

คำ​ว่า "เดิน​ซ้ำ​ได้" เป็น​เรื่อง​ของ​ต้นทาง ไม่ใช่​ของ Sequence

เอกสาร​เขียน​ไว้​ครบ​ทั้ง​ประโยค​ว่า “Sequences can be iterated multiple times; however, some sequence implementations might constrain themselves to be iterated only once.”

ตัว​ที่​วัด​แล้ว​ว่า​เดิน​ซ้ำ​ไม่​ได้​คือ Sequence ที่​สร้าง​จาก Iterator — listOf(1, 2, 3).iterator().asSequence() คืน [1, 2, 3] ใน​รอบ​แรก แล้ว​รอบ​สอง​โยน IllegalStateException พร้อม​ข้อความ​เดียว​กับ constrainOnce

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

forEach เป็น inline functioninline functionfunction ที่ compiler คัด​เนื้อ lambda ไป​วาง​ที่​จุด​เรียก · ผล​คือ `return` ข้าง​ใน lambda กระโดด​ออก​จาก function ที่​ห่อ​อยู่​ได้ ซึ่ง lambda ของ Java ทำ​ไม่​ได้​เลย จึง return ออก​ได้

หัวข้อ​ที่​มีชื่อ​ว่า “forEach เป็น inline function จึง return ออก​ได้”

หัวข้อ​นี้​แสดง​ข้อ​ที่ lambda ของ Java ทำ​ไม่​ได้​เลย และ​เป็น​เหตุผล​ที่ Kotlin ไม่​ต้อง​มี break ใน lambda

src/main/kotlin/orderctl/NonLocal.kt
package orderctl
fun findFirstBig(xs: List<Int>): Int? {
xs.forEach { if (it > 10) return it }
return null
}
fun findFirstBigSeq(xs: Sequence<Int>): Int? {
xs.forEach { if (it > 10) return it }
return null
}
fun main() {
println(findFirstBig(listOf(1, 20, 3)))
println(findFirstBigSeq(sequenceOf(1, 20, 3)))
}
$ gradle -q run
20
20

return it อยู่​ข้าง​ใน lambda แต่​มัน​คืน​ค่า​ออก​จาก findFirstBig ทั้ง​ฟังก์ชัน · ทำได้​เพราะ forEach ประกาศ​ไว้​เป็น inline compiler จึง​คัด​เนื้อ lambda ไป​วาง​ที่​จุด​เรียก แล้ว​สิ่ง​ที่​เห​ลือ​คือ​ลูป​ธรรมดา​ที่​มี return อยู่​ข้าง​ใน

และ​มัน​ทำงาน​เหมือน​กัน​ทั้ง​บน List และ​บน Sequence เพราะ forEach ของ​ทั้ง​คู่​เป็น inline

ฝั่ง Java ทำ​แบบ​นี้​ไม่​ได้ · forEach ที่​นั่น​รับ Consumer<T> ซึ่ง accept คืน void · lambda ที่​คืน​ค่า​ออก​มา​จึง​ถูก javac ปฏิเสธ​ด้วย​ข้อความ incompatible types: unexpected return value

รูป​ที่​แปล​ผ่าน​คือ​รูป​ที่​ไม่​ทำ​อะไร​เลย

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · แปลผ่าน แต่คืน null เสมอ
public static Integer findFirstBig(List<Integer> xs) {
xs.forEach(x -> { if (x > 10) return; });
return null;
}

return; เปล่าๆ จบ​แค่​การ​เรียก lambda รอบ​นั้น​รอบ​เดียว · ลูป​เดิน​ต่อ​จน​ครบ แล้ว method คืน null ทั้ง​ที่ 20 อยู่​ใน​รายการ · วัด​แล้ว​บน JDK 25

หัวข้อ​นี้​แสดง​ผล​ข้าง​เคียง​ข้อ​ที่​สอง​ของ inline ซึ่ง​เป็น​ข้อ​ที่ Java ปฏิเสธ​ตั้งแต่​ตอน​แปล

src/main/kotlin/orderctl/Capture.kt
package orderctl
fun sumWithForEach(xs: List<Long>): Long {
var acc = 0L
xs.forEach { acc += it }
return acc
}

เขียน​สิ่ง​เดียวกัน​ใน Java แล้ว javac ปฏิเสธ

src/main/java/orderctl/CaptureBad.java
// ❌ แปลไม่ผ่าน — ตั้งใจ · lambda ของ Java แก้ตัวแปรข้างนอกไม่ได้
package orderctl;
import java.util.List;
public final class CaptureBad {
private CaptureBad() {}
public static long sumWithForEach(List<Long> xs) {
long acc = 0L;
xs.forEach(x -> acc += x);
return acc;
}
}
CaptureBad.java:12: error: local variables referenced from a lambda expression must be final or effectively final
xs.forEach(x -> acc += x);
^
1 error

ทางออก​ที่​คน​เขียน Java ใช้​กัน​คือ​ห่อ​ค่า​ไว้​ใน array หนึ่ง​ช่อง แล้ว​เขียน​ลง acc[0] ซึ่ง​เป็นการ​หลบ​กฎ ไม่ใช่​การ​ทำ​ตาม​กฎ

วัด​ที่ bytecode แล้ว lambda ของ Kotlin หาย​ไป​ทั้ง​ตัว

แปล sumOf { it } กับ​ลูป for ที่​เขียน​เอง​ไว้​ใน file เดียวกัน แล้ว​เปิด javap -c ดู · ทั้ง file ได้ class ออก​มา​ใบ​เดียว และ​ไม่มี​คำ​สั่ง invokedynamic สัก​ตัว

javac แปล xs.forEach(x -> ...) ของ Java แล้ว​ได้ invokedynamic หนึ่ง​ตัว ซึ่ง​เป็น​จุด​ที่ JVM สร้าง​อ็อบเจ็กต์​ของ lambda ขึ้น​มา​ตอน​รัน

นี่​คือ​ราคา​ที่ Kotlin ไม่​ต้อง​จ่าย​และ Java ต้อง​จ่าย · แต่​มัน​มา​พร้อม​ข้อ​แลก คือ inline คัด​เนื้อ lambda ไป​วาง​ทุก​จุด​เรียก ของ​ที่​แปล​ออก​มา​จึง​ยาว​ขึ้น​ตาม​จำนวน​จุด​ที่​เรียก​มัน

เหตุผล​สอง​ใน​สาม​ข้อ​ที่​ปก​ป้อง​ลูป​ธรรมดา หาย​ไป​ที่​นี่

หัวข้อ​ที่​มีชื่อ​ว่า “เหตุผล​สอง​ใน​สาม​ข้อ​ที่​ปก​ป้อง​ลูป​ธรรมดา หาย​ไป​ที่​นี่”

หัวข้อ​นี้​กลับ​ไป​อ่าน​สาม​ข้อ​ที่​คอร์ส Java ยก​มา​ปฏิเสธ Stream แล้ว​ดู​ว่า​ข้อ​ไหน​ยัง​ยืน​อยู่

บท​ที่ 4 ของ​คอร์ส Java ปิด​ท้าย​ด้วย​สาม​กรณี​ที่​ลูป​ธรรมดา​ชนะ Stream

ข้อ​แรก​หาย​ไป​ทั้ง​ข้อ — Kotlin ไม่มี checked exception เลย · รูป​ที่ javac ตอบ unreported exception IOException; must be caught or declared to be thrown แปล​ผ่าน​เงียบ​สนิท​ที่​นี่

src/main/kotlin/orderctl/ReadAll.kt
package orderctl
import java.nio.file.Files
import java.nio.file.Path
fun readAll(paths: List<Path>): List<String> =
paths.map { Files.readString(it) }

IOException ยัง​โยน​ออก​มา​ตอน​รัน​เหมือน​เดิม​ทุก​ประการ · ที่​หาย​ไป​คือ​คำ​สั่ง​ของ compiler ให้​พิมพ์​คำ​ว่า catch ก่อน · บท​ที่ 6 กลับ​มา​ที่​ราคา​ของ​ข้อ​นี้

ข้อ​ที่​สอง​หาย​ไป​เช่น​กัน — หัวข้อ​ที่​แล้ว​แสดง​แล้ว​ว่า return ออก​จาก​กลาง forEach ได้​จริง · สาย​ที่​ต้อง​หยุด​กลาง​คัน​จึง​ไม่​ต้อง​ถอย​กลับ​ไป​เขียน for

ข้อ​ที่​สาม​ยัง​อยู่ แต่​เปลี่ยน​รูป — คอร์ส Java เตือน​เรื่อง​การ​เขียน​ลง​ของ​ที่​ใช้​ร่วม​กัน ผ่าน parallelStream() · Sequence ไม่มี​รูป​ขนาน​ให้​เลย กับ​ดัก​นั้น​จึง​ไม่มี​ที่​ให้​เกิด

ราคา​คือ​คุณ​ไป​หา​การ​ทำงาน​พร้อม​กัน​ที่​อื่น · List ของ Kotlin เป็น java.util.List จึง​เรียก .stream() ได้​ตรงๆ และ​อีก​ทาง​คือ coroutine ใน​บท​ที่ 8

บท​นี้​ทำให้ orderctl ตอบ​ยอด​รวม ยอด​ต่อ​หมวด ใบ​รายงาน และ​บรรทัด​ใบ​เสร็จ​ได้ ด้วย Totals ตัว​เดียว

สิ่ง​ที่​เพิ่ม​เข้า​โปร​เจ​กต์​คือ​ช่อง category ใน Line และ object Totals ที่​มี​สี่​ฟังก์ชัน · ไม่มี​ตัวแปร​สะสม​เหลือ​อยู่​เลย​สัก​ตัว

สิ่ง​ที่​ควร​ติดตัว​ไป​มี​สี่​ข้อ

  • List ปิด​ทาง​เขียน​ตอน​แปล ไม่ใช่​ตอน​รัน · ต้องการ​สำเนา​จริง​ให้​เรียก toList()
  • mapOf รับปาก​ลำดับ​ที่​ใส่ แต่​ลำดับ​ที่​ใส่​ยัง​ไม่ใช่​ลำดับ​ที่​เรียง
  • asSequence() คุ้ม​เมื่อ​ปลาย​ทาง​ไม่​กิน​สมาชิก​ทุก​ตัว หรือ​เมื่อ​สาย​ยาว​หลาย​ขั้น
  • forEach เป็น inline จึง return ออก​จาก​ฟังก์ชัน​ที่​ห่อ​อยู่​ได้ ซึ่ง Java ทำ​ไม่​ได้

บท​หน้า​เป็น​เรื่อง interface กับ delegation · Kotlin ได้การ​ส่ง​ต่อ​ฟรี​แบบ embedding ของ Go มา​ด้วย​คำ​เดียว​คือ by แล้ว​ได้​กับ​ดัก​ของ Go ติด​มา​ด้วย​ใน​ที่​ที่​มอง​ไม่​เห็น


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

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

  • Sequences (เข้า​ถึง 2026-08-28) — ประโยค​เรื่อง​ลำดับ​การ​ทำงาน​ที​ละ​สมาชิก ประโยค​เรื่อง​การ​เดิน​ซ้ำ และ​ประโยค​เตือน​เรื่อง​ต้นทุน​ของ​ความ lazy
  • Collections overview (เข้า​ถึง 2026-08-28) — คำ​ที่​เอกสาร​ใช้​เรียก interface อ่าน​อย่าง​เดียว
  • mapOf — kotlin.collections (เข้า​ถึง 2026-08-28) — “Entries of the map are iterated in the order they were specified.”
  • บท​ที่ 4 ของ​คอร์ส Java — สาม​กรณี​ที่​ลูป​ธรรมดา​ชนะ Stream และ​ตัวเลข​ชุด​เดียว​กับ​บท​นี้
  • บท​ที่ 4 ของ​คอร์ส Go — ยอด​ต่อ​หมวด​ที่​เขียน​ด้วย for และ​การ​เรียง​คีย์​ก่อน​พิมพ์
  • ผล​ทุก​บรรทัด​ใน​บท​นี้​วัด​เอง​บน kotlinc-jvm 2.4.10 กับ JDK 25 และ Gradle Kotlin DSL บน macOS arm64 เมื่อ 2026-08-28 · รัน​ซ้ำ​ได้​ด้วย​คำ​สั่ง​ที่​ยก​มา​ใน​แต่ละ​หัวข้อ

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

ข้อ 1 / 3

`val view: List<String> = backing` เมื่อ `backing` เป็น `MutableList` แล้วมีคนเรียก `backing.add("coffee")` ทีหลัง · `view` เห็นอะไร