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

collection กับ stream — คิด​ยอด​และ​สรุป​ตาม​หมวด

บท​ที่ 3 ทำให้ orderctl อ่าน JSON เข้า​มา​เป็น​ชนิด​ของ​โดเมน​ได้​แล้ว

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

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

บท​ที่ 3 ทิ้ง Line.java ไว้​ให้​แล้ว​ด้วย​สาม component คือ sku qty และ unitSatang พร้อม method subtotalSatang()

บท​นี้​แก้ Line หนึ่ง​ครั้ง เพิ่ม component ที่​สี่​ชื่อ category เพราะ​คำถาม “แต่ละ​หมวด​กี่​บาท” ตอบ​ไม่​ได้​ถ้า​ไม่รู้​ว่า​แต่ละ​รายการ​อยู่​หมวด​ไหน · การ​แก้​ครั้ง​นี้​เป็น การ​แก้​ครั้ง​เดียว​ของ​ทั้ง​คอร์ส และ​บท​หลัง​จาก​นี้​ใช้​รูป​สี่ component ตลอด

สิ่ง​ที่​บท​นี้​เพิ่ม​เข้า​โปร​เจ​กต์​จริง​คือ Line.java ฉบับ​แก้ กับ Totals.java · ส่วน class ที่​ชื่อ​ลงท้าย​ด้วย Lab กับ TotalsDemo เป็น​ห้อง​ทดลอง​แยก file เหมือน KeyProbe ของ​บท​ที่ 3 วาง​ไว้​ที่ src/main/java/orderctl/ ข้าง​กัน​ได้​เลย

แต่ละ class มี main ของ​ตัว​เอง จึง​รัน​ที​ละ​ตัว​ด้วย​รูป​คำ​สั่ง​เดิม​ของ​บท​ที่ 3

Terminal window
mvn -q compile exec:java -Dexec.mainClass=orderctl.TotalsDemo

คำถาม “แต่ละ​หมวด​กี่​บาท” ตอบ​ไม่​ได้​ถ้า Line ไม่รู้​ว่า​ตัว​เอง​อยู่​หมวด​ไหน

Line ของ​บท​ที่ 3 มี​สาม component · บท​นี้​เพิ่ม category เข้าไป​เป็น​ตัว​ที่​สอง แล้ว​บังคับ​ว่า​ห้าม​เป็น null ด้วย​กลไก​เดิม​ที่​บท​ที่ 2 สอน​ไว้

// src/main/java/orderctl/Line.java — ฉบับแก้ของบทที่ 4
package orderctl;
import java.util.Locale;
import java.util.Objects;
public record Line(String sku, String category, int qty, long unitSatang) {
public Line {
Objects.requireNonNull(sku, "sku");
Objects.requireNonNull(category, "category");
if (qty <= 0) {
throw new IllegalArgumentException("qty ต้องมากกว่า 0 แต่ได้ " + qty);
}
if (unitSatang < 0) {
throw new IllegalArgumentException("ราคาติดลบไม่ได้");
}
sku = sku.trim().toUpperCase(Locale.ROOT);
category = category.trim().toLowerCase(Locale.ROOT);
}
public long subtotalSatang() {
return qty * unitSatang;
}
}

order.json ของ​บท​ที่ 3 ต้อง​เติม​คีย์ category ให้​ทุก​รายการ​ด้วย ไม่​งั้น Jackson จะ​ส่ง null เข้า​มา​แล้ว requireNonNull จะ​โยน​ทันที

Java แยก​สอง​อย่าง​นี้​ออก​จาก​กัน ประกาศ​ตัวแปร​เป็น interface แล้ว​เลือก implementation ตอน new

ArrayList ไม่ใช่​ชนิด​ที่​คุณ​ควร​เขียน​ไว้​ที่​ตัวแปร มัน​คือ​ชนิด​ที่​คุณ​เลือก​ใช้​ตอน​สร้าง · สิ่ง​ที่​เขียน​ไว้​ที่​ตัวแปร​ควร​เป็น List ซึ่ง​เป็น​คำ​สัญญา​ว่า “มี​ลำดับ และ​หยิบ​ด้วย​ตำแหน่ง​ได้”

สิ่ง​ที่​ต้องการประกาศ​เป็นสร้าง​ด้วย
รายการ​ที่​มี​ลำดับ หยิบ​ด้วย​ตำแหน่งList<Line>new ArrayList<>()
ชุด​ที่​ไม่​ซ้ำ และ​จำ​ลำดับ​ที่​ใส่Set<String>new LinkedHashSet<>()
ชุด​ที่​ไม่​ซ้ำ ที่​เรียง​ตัว​เอง​เสมอSet<String>new TreeSet<>()
ตาราง​ค้น​ด้วย​คีย์ ที่​ไม่​ถูก​พิมพ์​ออก​ไปMap<String, Long>new HashMap<>()
ตาราง​ค้น​ด้วย​คีย์ ที่​จะ​ถูก​พิมพ์​ออก​ไปMap<String, Long>new TreeMap<>()
คิว​ที่​เติม​และ​ดึง​ได้​สอง​หัวDeque<String>new ArrayDeque<>()

ตาราง​นี้​มี​แถว​ที่​แยก​กัน​ด้วย​คำ​ว่า “ถูก​พิมพ์​ออก​ไป” สอง​แถว · นั่น​ไม่ใช่​เรื่อง​สไตล์ มัน​คือ​กฎ​ที่​หัวข้อ​ท้าย​บท​จะ​กลับ​มา​อธิบาย​ว่า​ทำไม​ทั้ง Java และ Go ต้อง​มี​กฎ​ข้อ​เดียวกัน​นี้

คอร์ส Go ไม่มี​การ​แยก​แบบ​นี้​เลย เพราะ map และ slice เป็น​ชนิด​ของ​ภาษา ไม่ใช่​ชนิด​ใน standard library · คุณ​จึง​เขียน map[string]int แล้ว​จบ ไม่มี implementation ให้​เลือก

ราคา​ของ​มัน​อยู่​คนละ​ที่ — บท​ที่ 4 ของ​คอร์ส Go ต้อง​หยุด​เตือน​ว่า var m map[string]int ที่​ยัง​ไม่ make อ่าน​ได้​ตาม​ปกติ​แต่​เขียน​ลง​ไป​แล้ว panic

สี่​รูป​ที่​คนใช้​สลับ​กัน​บ่อย ให้​สัญญา​คนละ​อย่าง และ block ใน​หัวข้อ​นี้​พิมพ์​ผล​ของ​ทั้ง​สี่​ออก​มา

package orderctl;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
public final class FrozenLab {
private FrozenLab() {}
public static void main(String[] args) {
List<String> base = new ArrayList<>(List.of("tea"));
List<String> copy = List.copyOf(base);
List<String> view = Collections.unmodifiableList(base);
base.add("cake");
System.out.println("copy=" + copy + " view=" + view);
List<String> fixed = Arrays.asList("tea", "cake");
fixed.set(0, "coffee");
System.out.println("asList=" + fixed);
try {
System.out.println(List.of("tea").contains(null));
} catch (NullPointerException e) {
System.out.println("List.of ปฏิเสธ null แม้ตอนถาม");
}
try {
System.out.println(Set.of("tea", "tea"));
} catch (IllegalArgumentException e) {
System.out.println(e);
}
try {
fixed.add("water");
} catch (UnsupportedOperationException e) {
System.out.println("Arrays.asList ต่อท้ายไม่ได้");
}
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.FrozenLab
copy=[tea] view=[tea, cake]
asList=[coffee, cake]
List.of ปฏิเสธ null แม้ตอนถาม
java.lang.IllegalArgumentException: duplicate element: tea
Arrays.asList ต่อท้ายไม่ได้

บรรทัด​แรก​ของ​ผล​คือ​หัวใจ​ของ​หัวข้อ​นี้ · copy ไม่​เห็น cake ส่วน view เห็น ทั้ง​ที่​ทั้ง​คู่​ถูก​สร้าง​จาก base ตัว​เดียวกัน​ใน​บรรทัด​ติด​กัน

รูปให้​อะไรยัง​เห็น​การ​แก้​ที่​ต้นทางเพิ่ม​หรือ​ลบเปลี่ยน​ค่า​ใน​ตำแหน่ง​เดิม
List.of(...)ของ​ใหม่​ที่​ตรึง​แล้วไม่มี​ต้นทางไม่​ได้ไม่​ได้
List.copyOf(x)สำเนาไม่​เห็นไม่​ได้ไม่​ได้
Collections.unmodifiableList(x)หน้าต่างเห็นไม่​ได้ไม่​ได้
Arrays.asList(...)หน้าต่าง​บน arrayเห็นไม่​ได้ได้
`List.of` ปฏิเสธ `null` แม้​ตอน​ที่​คุณ​แค่​ถาม

List.of("a").contains(null) โยน NullPointerException ออก​มา ทั้ง​ที่​คุณ​ไม่​ได้​ใส่​อะไร​ลง​ไป​เลย

สเปก​ของ Collection.contains ทำ​เครื่องหมาย​พฤติกรรม​นี้​ไว้​ว่า​เป็น​ทาง​เลือก​ของ implementation · ArrayList เลือก​ตอบ false ส่วน​ตระกูล List.of เลือก​โยน

ผล​คือ code ที่​เขียน​ว่า if (allowed.contains(input)) ทำงาน​ถูก​มา​ตลอด แล้ว​ระเบิด​วัน​ที่ input เป็น null เป็น​ครั้ง​แรก · ตำแหน่ง​ที่​ระเบิด​ไม่ใช่​ตำแหน่ง​ที่​ผิด

หัวข้อ​นี้​สร้าง Totals ที่​มี​สี่ method ซึ่ง​เขียน​ด้วย stream ทั้งหมด และ​เทียบ​กับ​ลูป​ที่​บท​ที่ 3 เขียน​ไว้

บท​ที่ 3 เขียน Order.grossSatang() เป็น​ลูป​ที่​ประกาศ long sum = 0; ไว้​ก่อน แล้ว​บวก line.subtotalSatang() เข้าไป​ที​ละ​แถว

งาน​เดียวกัน​นั้น​ใน​รูป stream คือ lines.stream().mapToLong(Line::subtotalSatang).sum() · ไม่มี​ตัวแปร​ให้​ประกาศ​ไว้​ก่อน จึง​ไม่มี​ตัวแปร​ให้​ลืม​รีเซ็ต​เมื่อ​มี​คน​คัด​ลอก​ลู​ปนี้​ไป​ใช้​ที่​อื่น

Line::subtotalSatang คือ method reference ซึ่ง​เป็น​รูป​ย่อ​ของ lambda (Line l) -> l.subtotalSatang() · บท​นี้​ใช้​มัน​เป็น​เครื่อง​มือ​ไป​ก่อน แล้ว​บท​ที่ 5 จะ​กลับ​มา อธิบาย​ว่า​ทำไม​ของ​สอง​รูป​นี้​ต่อ​เข้า​กับ interface ที่​มี method เดียว​ได้

Totals เป็น class ที่​มี​แต่ static member และ​มี constructor ส่วน​ตัว​กัน​ไม่​ให้​ใคร​สร้าง instance · รูป​นี้​คือ​คู่​เทียบ​ของ package ระดับ pricing ใน​คอร์ส Go

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ชนิด Line อยู่ที่ Line.java ซึ่งบทที่ 3 สร้างไว้แล้ว
package orderctl;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public final class Totals {
private Totals() {}
public static long grossSatang(List<Line> lines) {
return lines.stream().mapToLong(Line::subtotalSatang).sum();
}
public static Map<String, Long> byCategory(List<Line> lines) {
return lines.stream().collect(Collectors.groupingBy(
Line::category, TreeMap::new, Collectors.summingLong(Line::subtotalSatang)));
}
public static List<Line> report(List<Line> lines) {
return lines.stream()
.sorted(Comparator.comparing(Line::category)
.thenComparing(Line::sku, Comparator.reverseOrder()))
.toList();
}
public static String receiptLine(List<Line> lines) {
return lines.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.summingLong(Line::subtotalSatang),
(n, sum) -> n + " รายการ รวม " + sum + " สตางค์"));
}
}

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

  • mapToLong(...).sum() ไม่ใช่ map แล้ว reduce · มัน​เปลี่ยน​ไป​เป็น LongStream ซึ่ง​เก็บ​ค่า​เป็น long ล้วน ไม่​ต้อง​ห่อ​เป็น Long ที​ละ​ตัว
  • groupingBy ต้อง​ขอ TreeMap::new ออก​มา​ตรงๆ เพราะ​รูป​สอง argument คืน HashMap และ javadoc ของ HashMap เขียน​ว่า “This class makes no guarantees as to the order of the map […]”
  • Stream.toListStream.toListตัว​ปิด​ท้าย stream ที่​คืน list ซึ่ง​แก้​ไม่​ได้​ทุก​ทาง ทั้ง `add` `set` และ `sort` · ต่าง​จาก `collect(Collectors.toList())` ที่​ยัง​ไม่​ถูก​เลิก​ใช้​และ​คืน​ของ​ที่​แก้​ได้ คืน list ที่​แก้​ไม่​ได้ และ javadoc รับ​ประกัน​ข้อ​นี้​ไว้​เอง ต่าง​จาก collect(Collectors.toList()) ที่ javadoc ไม่​รับ​ประกัน​ความ​แก้​ได้​ให้​เลย
  • teeing เดิน stream รอบ​เดียว​แล้ว​ให้​สอง​คำ​ตอบ คือ​จำนวน​รายการ​กับ​ยอด​รวม
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Line.java ฉบับแก้ของบทนี้ กับ Totals.java ข้างบนอยู่ข้างกัน
// ห้องทดลองแยก file ไม่ใช่ Main ของบทที่ 3 ซึ่งยังอ่าน order.json ตามเดิม
package orderctl;
import java.util.List;
import java.util.Map;
public final class TotalsDemo {
private TotalsDemo() {}
public static void main(String[] args) {
List<Line> lines = List.of(
new Line("tea", "drink", 2, 3500),
new Line("coffee", "drink", 1, 6000),
new Line("cake", "dessert", 1, 12000));
System.out.println("รวม " + Totals.grossSatang(lines) + " สตางค์");
for (Map.Entry<String, Long> e : Totals.byCategory(lines).entrySet()) {
System.out.println(" " + e.getKey() + " " + e.getValue());
}
System.out.println(Totals.report(lines).stream().map(Line::sku).toList());
System.out.println(Totals.receiptLine(lines));
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.TotalsDemo
รวม 25000 สตางค์
dessert 12000
drink 13000
[CAKE, TEA, COFFEE]
3 รายการ รวม 25000 สตางค์
flowchart LR
  A["รายการ Line ที่อ่านมาจาก JSON"] --> B["stream()"]
  B --> C["mapToLong แล้ว sum<br/>ได้ตัวเลข long ตัวเดียว"]
  B --> D["collect ด้วย groupingBy<br/>ได้ TreeMap ที่แก้ต่อได้"]
  B --> E["sorted แล้ว toList<br/>ได้รายการที่แก้ไม่ได้"]
  E -.->|"เรียกซ้ำอีกรอบ"| F["ต้องเดินของเดิมใหม่ทั้งหมด<br/>stream ใช้ได้ครั้งเดียว"]

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

`Collectors.toList()` ยัง​ไม่​ถูก​เลิก​ใช้ อย่า​ไล่​กวาด​ทิ้ง

มี​คำ​แนะนำ​ที่​ค้น​เจอ​บ่อย​ว่า​ให้​แทน collect(Collectors.toList()) ด้วย toList() ทุก​ที่

สอง​ตัว​นี้​ให้​สัญญา​คนละ​อย่าง · javadoc ของ toList() เขียน​ไว้​เอง​ว่า​ของ​ที่​คืน​มา add set และ sort ไม่​ได้​เลย ส่วน javadoc ของ Collectors.toList() อธิบาย​ว่า​มัน ไม่​รับ​ประกัน​ทั้ง​ชนิด​และ​ความ​แก้​ได้ ของ list ที่​คืน​มา

นั่น​แปล​ว่า Collectors.toList() ไม่ใช่​ทาง​ที่​ถูก​สำหรับ​การ​ขอ​ของ​ที่​แก้​ได้ · JDK ตัว​นี้​คืน ArrayList ให้​จริง แต่​นั่น​เป็น​สิ่ง​ที่​วัด​ได้​จาก​การ​รัน ไม่ใช่​สิ่ง​ที่​สเปก​สัญญา​ไว้

สิ่ง​ที่ Collectors.toList() ยัง​จำเป็น​จริง​คือ​ตำแหน่ง downstream ของ groupingBy หรือ mapping ซึ่ง​เป็น​ตำแหน่ง​ที่ toList() ไป​ไม่​ถึง​เพราะ​มัน​เป็น​ตัว​ปิด​ท้าย ไม่ใช่ collector

ถ้า​อยาก​ได้​ของ​ที่​แก้​ได้​จาก stream ให้​เขียน​ความ​ตั้งใจ​ออก​มา — collect(Collectors.toCollection(ArrayList::new)) ซึ่ง​เป็น​รูป​ที่ javadoc ของ​ทั้ง​สอง​ตัว ชี้​ไป​หา​เอง​เมื่อ​ผู้​เรียก​ต้องการ​คุม​ของ​ที่​ได้​กลับ​มา

ห้าม​เขียน​เทสต์​ที่​ตรวจ​ชื่อ class ที่ `toList()` คืน​มา

javadoc ระบุ​เอง​ว่า​ไม่​รับ​ประกัน​ว่า toList() คืน implementation ตัว​ไหน · สิ่ง​ที่​รับ​ประกัน​คือสัญญา​เรื่อง​การ​แก้ไข ไม่ใช่​ชนิด

เทสต์​ที่​เขียน​ว่า assertInstanceOf(ImmutableCollections.ListN.class, result) จึง​เป็น​เทสต์ ที่​ผูก​กับ​ราย​ละเอียด​ที่ JDK เปลี่ยน​ได้​ทุก​รุ่น · เทสต์​ที่​ถูก​คือ assertThrows(UnsupportedOperationException.class, () -> result.add(x))

.reversed() ท้าย​สาย​กลับ​กุญแจ​ทุก​ตัว ไม่ใช่​ตัว​สุดท้าย และ block ใน​หัวข้อ​นี้​พิมพ์​ผล​ของ​ทั้ง​สอง​รูป​เทียบ​กัน

package orderctl;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class SortLab {
private SortLab() {}
record Row(String category, String sku) {}
public static void main(String[] args) {
List<Row> rows = new ArrayList<>(List.of(
new Row("drink", "TEA"), new Row("drink", "COFFEE"),
new Row("dessert", "CAKE")));
Comparator<Row> wrong = Comparator.comparing(Row::category)
.thenComparing(Row::sku)
.reversed();
Comparator<Row> right = Comparator.comparing(Row::category)
.thenComparing(Row::sku, Comparator.reverseOrder());
System.out.println(rows.stream().sorted(wrong).map(Row::sku).toList());
System.out.println(rows.stream().sorted(right).map(Row::sku).toList());
Comparator<Integer> broken = (a, b) -> a - b;
System.out.println(broken.compare(Integer.MAX_VALUE, -1));
System.out.println(Integer.compare(Integer.MAX_VALUE, -1));
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.SortLab
[TEA, COFFEE, CAKE]
[CAKE, TEA, COFFEE]
-2147483648
1

wrong ตั้งใจ​จะ​เรียง​หมวด​ขึ้น​แล้ว​เรียง sku ลง แต่​ผล​ที่​ได้​คือ​หมวด​ลง​ด้วย · dessert จึง​ตก​ไป​อยู่​ท้าย​แทนที่​จะ​ขึ้น​ก่อน

รูป​ที่​ตรง​กับ​ความ​ตั้งใจ​คือ​ส่งตัว​เทียบ​เข้าไป​ที่​กุญแจ​ตัว​นั้น​ตัว​เดียว — thenComparing(Row::sku, Comparator.reverseOrder())

สอง​บรรทัด​สุดท้าย​ของ​ผล​คือ​กับ​ดัก​คนละ​ตัว · (a, b) -> a - b ตอบ​ว่า Integer.MAX_VALUE น้อย​กว่า -1 เพราะ​การ​ลบ​ล้น​ออก​นอก​ช่วง​ของ int ไป​แล้ว ให้​ใช้ Integer.compare เสมอ

สาย​ที่​ต่อ​กัน​ทำให้​การ​อนุมาน​ชนิด​พัง แต่​ตัว​เดี่ยว​ไม่​พัง

Comparator.comparing(r -> r.category()) เดี่ยวๆ คอม​ไพล์​ผ่าน​ตาม​ปกติ

พอ​ต่อ​สาย​เป็น Comparator.comparing(r -> r.category()).thenComparing(r -> r.sku()) javac หา​ชนิด​ของ r ไม่​เจอ​แล้ว​ปัก​ไว้​ที่ Object

error: cannot find symbol
rows.stream().sorted(Comparator.comparing(r -> r.category())
^
symbol: method category()
location: variable r of type Object

บท​นี้​ยก​ข้อความ​ผิด​พลาด​มา​จาก javac ตรงๆ ทั้ง​สอง​แห่ง เพราะ​มัน​มี​ลูก​ศร​ชี้​ตำแหน่ง​ให้ · mvn -q compile ส่ง​เรื่อง​เดียวกัน​ออก​มา​ใน​รูป [ERROR] <path>:[บรรทัด,ตำแหน่ง] <ข้อความ> ซึ่ง​ตัด​ลูก​ศร​ทิ้ง​และ​เขียน​ชื่อ​ชนิด​แบบ​เต็ม บรรทัด​สุดท้าย​จึง​กลาย​เป็น location: variable r of type java.lang.Object

ทาง​แก้​มี​สาม​ทาง เลือก​ทาง​ใด​ทาง​หนึ่ง

  • เขียน method reference Row::category
  • ระบุ​ชนิด​ที่ lambda ออก​มา​ตรงๆ เป็น (Row r) -> r.category()
  • ใช้ comparingInt หรือ comparingLong เมื่อ​กุญแจ​เป็น​ตัวเลข

Java 21 ให้​ชื่อ​ชุด​เดียวกัน​กับ​ทุก​ชนิด​ที่​มี​ลำดับ · block ใน​หัวข้อ​นี้​แสดง​ว่า reversed() เป็น​หน้าต่าง​ที่​การ​แก้​ต้นทาง​ทะลุ​เข้าไป​ได้

ก่อน Java 21 การ​หยิบ​ตัว​แรก​กับ​ตัว​สุดท้าย​เขียน​คนละ​แบบ​ใน​ทุก​ชนิด · list.get(0) คู่​กับ list.get(list.size() - 1) ส่วน Deque มีชื่อ​ของ​ตัว​เอง​อีก​ชุด​หนึ่ง

sequenced collectionsequenced collectionตระกูล interface ของ Java 21 ที่​ให้ `getFirst` `getLast` `reversed()` กับ​ทุก​ชนิด​ที่​มี​ลำดับ · `reversed()` เป็น​หน้าต่าง ไม่ใช่​สำเนา คือ​ชื่อ​ของ​ตระกูล interface ที่ Java 21 เพิ่ม​เข้า​มา​เพื่อ​รวม​ชื่อ​เหล่า​นั้น ให้​เหลือ​ชุด​เดียว

package orderctl;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.SequencedMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public final class SeqLab {
private SeqLab() {}
public static void main(String[] args) {
List<String> queue = new ArrayList<>(List.of("tea", "coffee", "cake"));
System.out.println(queue.getFirst() + " .. " + queue.getLast());
List<String> back = queue.reversed();
queue.add("water");
System.out.println(back);
SequencedMap<String, Long> seen = new LinkedHashMap<>();
seen.put("drink", 7000L);
seen.put("dessert", 12000L);
System.out.println(seen.firstEntry() + " / " + seen.reversed());
List<String> frozen = Stream.of("tea", "cake").toList();
try {
frozen.add("water");
} catch (UnsupportedOperationException e) {
System.out.println("Stream.toList() ต่อท้ายไม่ได้");
}
List<String> growable = Stream.of("tea", "cake")
.collect(Collectors.toCollection(ArrayList::new));
growable.add("water");
System.out.println(growable);
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.SeqLab
tea .. cake
[water, cake, coffee, tea]
drink=7000 / {dessert=12000, drink=7000}
Stream.toList() ต่อท้ายไม่ได้
[tea, cake, water]

บรรทัด​ที่​สอง​ของ​ผล​คือ​ประเด็น · back ถูก​สร้าง​ก่อน​ที่ water จะ​ถูก​เติม​เข้า queue แต่​มัน​เห็น water เพราะ reversed() คืน​หน้าต่าง ไม่ใช่​สำเนา

ถ้า​คุณ​อยาก​ได้​สำเนา​ที่​หยุด​นิ่ง ให้​เขียน List.copyOf(queue.reversed()) ออก​มา​ตรงๆ

สาม​ชื่อ​ที่ javadoc ของ JDK เอง​แนะ​ให้​ใช้​ตัว​อื่น — และ​ทั้ง​สาม​ยัง​ไม่​ถูก​เลิก​ใช้

คุณ​จะ​เจอ​สาม​ชื่อ​นี้​ใน code เก่า​และ​ใน​คำ​ตอบ​บน​เน็ต · เอกสาร​ของ JDK เอง​เขียน​ตัว​ที่​ควร​ใช้​แทน​ไว้​ให้​แล้ว

ชื่อ​เก่าjavadoc แนะ​ให้​ใช้
StackDeque หรือ ArrayDeque
VectorArrayList
HashtableHashMap หรือ ConcurrentHashMap

ทั้ง​สาม​ตัว​ยัง​ไม่​ถูก​ทำ​เครื่องหมาย​ว่า​เลิก​ใช้ จึง​ห้าม​พูด​ว่า​มัน​ถูก deprecate

LinkedList ไม่​อยู่​ใน​ตาราง​นี้ เพราะ javadoc ของ​มัน​ไม่​ได้​แนะ​ให้​ใช้​ตัว​อื่น​แทน​เลย · ที่​พูด​ถึง​มัน​คือ JEP 431 ซึ่ง​อธิบาย​ว่าการ​ให้​ชนิด​ที่​มี​ลำดับ​ต้อง​รองรับ​การ​หยิบ​ด้วย​ตำแหน่ง ทำให้​การ​หยิบ​นั้น​เป็น O(n) แทนที่​จะ​เป็น O(1) และ​เรียก​ข้อ​นั้น​ว่า​เป็น​ความ​ผิด​พลาด​ของ LinkedList

นั่น​เป็น​เรื่อง​ของ​ราคา​ใน​การ​หยิบ​ด้วย​ตำแหน่ง ไม่ใช่​เรื่อง​ของ​การ​เป็น​ทั้ง List และ Deque

การ​เป็น​สอง interface พร้อม​กัน​เป็น​อีก​ข้อ​หนึ่ง​ที่ JEP 431 ยกไว้​ใน​หัวข้อ Risks and Assumptions · reversed() ของ List กับ​ของ Deque ชน​กัน​จริง แต่ JEP เขียน​ไว้​ด้วย​ว่า​เรื่อง​นี้​ถูก​จัดการ​ไป​แล้ว ด้วย​การ​เติม reversed() ลง LinkedList เอง · ผู้​อ่าน​ที่​ใช้ JDK 21 ขึ้น​ไป​จึง​ไม่​เจอ​ปัญหา​นี้

Stream Gatherers เป็น​ของ Java 24 ผู้​อ่าน​ที่​อยู่ 21 ยัง​ใช้​ไม่​ได้

Stream.gather(...) เปิด​ทาง​ให้​เขียน​ขั้น​กลาง​ของ stream เอง​ได้ เช่น​การ​ซอย​เป็น​หน้าต่าง​ที​ละ N ตัว

มัน​เสร็จ​สมบูรณ์​ที่ JDK 24 (JEP 485) · คอร์ส​นี้​ตรึง​ไว้​ที่ 25 จึง​ใช้ได้ แต่​ทีม​ที่​ยัง​อยู่​บน LTS ตัว​ก่อน​คือ 21 จะ​ไม่มี​มัน

บท​นี้​จึง​ไม่​ใช้​มัน​ใน​ของ​จริง​ของ orderctl · ยก​มา​เพื่อ​ให้​คุณ​รู้​ว่า​มัน​มี​อยู่​เท่านั้น

autoboxingautoboxingการ​ห่อ​ค่า primitive เป็น object โดย​อัตโนมัติ · ทำให้ `==` กลาย​เป็นการ​เทียบ reference และ `map.get` ที่​คืน null ระเบิด​ตอน​คลาย​กล่อง ทำให้ Integer สอง​ตัว​ที่​ค่า​เท่า​กัน​เทียบ​ด้วย == ได้​คนละ​คำ​ตอบ​ตาม​ค่า และ​ทำให้ remove มี​สอง​ความ​หมาย block ใน​หัวข้อ​นี้​แสดง​ทั้ง​สอง​เรื่อง

package orderctl;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class BoxLab {
private BoxLab() {}
public static void main(String[] args) {
Integer small1 = 127, small2 = 127;
Integer big1 = 128, big2 = 128;
System.out.println((small1 == small2) + " " + (big1 == big2));
List<Integer> ids = new ArrayList<>(List.of(10, 20, 30));
ids.remove(1);
System.out.println(ids);
ids.remove(Integer.valueOf(30));
System.out.println(ids);
Map<String, Long> byCat = new HashMap<>(Map.of("drink", 7000L));
System.out.println(byCat.getOrDefault("dessert", 0L));
byCat.merge("dessert", 12000L, Long::sum);
byCat.merge("dessert", 500L, Long::sum);
System.out.println(byCat.get("dessert"));
try {
long n = byCat.get("missing");
System.out.println(n);
} catch (NullPointerException e) {
System.out.println(e.getMessage());
}
}
}
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.BoxLab
true false
[10, 30]
[10]
0
12500
Cannot invoke "java.lang.Long.longValue()" because the return value of "java.util.Map.get(Object)" is null

บรรทัด​แรก​คือ​ผล​ของ​กล่อง​ที่ JVM เก็บ​ไว้​ให้​ใช้​ซ้ำ​ใน​ช่วง −128 ถึง 127 · 127 สอง​ตัว​จึง​เป็น อ็อบเจ็กต์​เดียวกัน ส่วน 128 เป็น​คนละ​ตัว และ == เทียบ​ว่า​เป็น​ตัว​เดียวกัน​หรือ​ไม่​เท่านั้น

กฎ​ที่​ใช้ได้​เสมอ​คือ​เทียบ​ค่า​ด้วย equals หรือ​คลาย​กล่อง​ออก​มา​เป็น int ก่อน ไม่ใช่​จำ​ว่า​ช่วง​ไหน ปลอดภัย เพราะ​ขอบ​ของ​กล่อง​นั้น​ถูก​เลื่อน​ได้​ด้วย flag ตอน​รัน

สอง​บรรทัด​ถัด​มา​คือ remove ที่​มี​สอง​ความ​หมาย · ids.remove(1) เรียก remove(int) ซึ่ง​ลบ ตำแหน่ง ที่​หนึ่ง ส่วน ids.remove(Integer.valueOf(30)) เรียก remove(Object) ซึ่ง​ลบค่า

บรรทัด​สุดท้าย​คือ​กับ​ดัก​ที่​เจอ​บ่อย​ที่สุด​ตอน​สรุป​ยอด · byCat.get("missing") คืน null แล้ว​การ​มอบ​หมาย​ลง long สั่ง​ให้​คลาย​กล่อง​ทันที

  • อ่าน​ค่าที่​อาจ​ไม่มี ให้​ใช้ getOrDefault(key, 0L) ซึ่ง​ไม่​แตะ null เลย
  • สะสม​ยอด​ลง​คีย์​ที่​อาจ​ยัง​ไม่มี ให้​ใช้ merge(key, value, Long::sum) ซึ่ง​ใส่​ค่า​แรก ให้​เอง​แล้ว​รวม​กับ​ของ​เดิม​ใน​ครั้ง​ถัด​ไป
  • อย่า​เขียน map.get(k) + v เพราะ​มัน​พัง​เฉพาะ​ตอน​ที่​คีย์​ยัง​ไม่​เคย​ถูก​ใส่ ซึ่ง​เป็น​กรณี​แรก ของ​ทุก​หมวด​พอดี

stream แพ้​ชัด​สาม​กรณี — lambda ที่​ต้อง​โยน checked exception, งาน​ที่​ต้อง​หยุด​กลาง​คัน และ​การ​เขียน​ลง​ของ​ที่​ใช้​ร่วม​กัน​แบบ​ขนาน

กรณี​ที่​หนึ่ง — lambda โยน checked exceptionchecked exceptionความ​ผิด​พลาด​ที่ compiler บังคับ​ให้​ประกาศ​หรือ​จับ · `throws` เป็น​สัญญา​ส่วน​หนึ่ง​ของ API แต่​ข้าม​เข้าไป​ใน lambda ไม่​ได้ ไม่​ได้ และ​การ​ที่ method ที่​ล้อม​อยู่​ประกาศ throws ไว้​แล้ว​ก็​ไม่​ช่วย

src/main/java/orderctl/ReadAllBad.java
// ❌ แปลไม่ผ่าน — ตั้งใจ · lambda โยน checked exception ไม่ได้
package orderctl;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public final class ReadAllBad {
private ReadAllBad() {}
public static List<String> readAll(List<Path> paths) throws IOException {
return paths.stream().map(p -> Files.readString(p)).toList();
}
}
ReadAllBad.java:15: error: unreported exception IOException; must be caught or declared to be thrown
return paths.stream().map(p -> Files.readString(p)).toList();
^
1 error

นี่​เป็น​ข้อความ​จาก javac อีก​ครั้ง · ผ่าน mvn -q compile คุณ​จะ​เห็น​บรรทัด​เดียว​ที่​ขึ้น​ต้น​ด้วย [ERROR] และ​เขียน​ว่า unreported exception java.io.IOException แทน

throws IOException ที่​บรรทัด​บน​เป็น​สัญญา​ของ readAll ไม่ใช่​ของ lambda · ตัว lambda ไป​ลง​ที่ Function.apply ซึ่ง​ไม่​ประกาศ throws อะไร​ไว้​เลย

ทางออก​คือ​ห่อ​เป็น unchecked ใน​ทุก​จุด​เรียก ซึ่ง​กลบ​ต้นตอ หรือ​เขียน for ธรรมดา​แล้ว​ปล่อย​ให้ IOException ไหล​ออก​ไป​ตาม​ปกติ · ลูป​สั้น​กว่า​และ​ตรง​กว่า​ใน​กรณี​นี้

กรณี​ที่​สอง — ต้อง​หยุด​กลาง​คัน forEach ไม่มี break · takeWhile หยุด​ได้​จริง​แต่​หยุด​ตาม เงื่อนไข​ของ​ค่า ไม่ใช่​ตาม​เหตุการณ์​ข้าง​นอก​อย่าง​การ​อ่าน​ครบ​โควตา​แล้ว

กรณี​ที่​สาม — เขียน​ลง​ของ​ที่​ใช้​ร่วม​กัน​แบบ​ขนาน list.parallelStream().forEach(out::add) ส่วน​ใหญ่​ให้​คำ​ตอบ​ผิด​โดย​ไม่​โยน​อะไร​ออก​มา​เลย เพราะ ArrayList ไม่​ได้​ถูก​ออกแบบ​ให้​หลาย thread เขียน​พร้อม​กัน · บาง​รอบ​มัน​โยน ArrayIndexOutOfBoundsException ออก​มา​ด้วย แต่​รอบ​ที่​เงียบ คือ​รอบ​ที่​อันตราย เพราะ​ไม่มี​อะไร​บอก​ว่า​ผล​ที่​ได้​ขาด​ไป

ถ้า​จะ​ขนาน​จริง​ต้อง​จบ​ด้วย collect ซึ่ง​รวม​ผล​ให้​ถูก​วิธี

Go เขียน​ลูป Java เขียน groupingBy — และ​ทั้ง​คู่​จ่าย​ค่า​คนละ​อย่าง

หัวข้อ​ที่​มีชื่อ​ว่า “Go เขียน​ลูป Java เขียน groupingBy — และ​ทั้ง​คู่​จ่าย​ค่า​คนละ​อย่าง”

หัวข้อ​นี้​เทียบ​ยอด​ต่อ​หมวด​ของ​สอง​คอร์ส​ที่​สร้าง​ของ​ชิ้น​เดียวกัน แล้ว​บอก​ราคา​ของ​ทั้ง​สอง​ฝั่ง

บท​ที่ 4 ของ คอร์ส Go แก้​โจทย์​เดียวกัน​นี้​ที่ internal/pricing/report.go · TotalsByCategory ของ​มัน​ประกาศ map ออก​มา​ก่อน​ด้วย make แล้ว​วน for สะสม​ยอด​ที​ละ Line

ราคา​ที่ Go จ่าย​คือ​คุณ​ต้อง​เขียน​สาม​อย่าง​นี้​เอง​ทุก​ครั้ง — make map ออก​มา​ก่อน, เขียน​ลูป​สะสม​เอง และ​เรียง​คีย์​ก่อน​พิมพ์​ด้วย slices.Sorted(maps.Keys(byCat)) · ไม่มี​ทาง​ลัด​และ​ไม่มี​ของ​สำเร็จ​ให้

การ​ตั้ง​ค่า​เริ่ม​ต้น​ให้​แต่ละ​คีย์​เป็น​ข้อ​ที่ Go ไม่​ต้อง​เขียน · out[l.Category] += ... บวก​ลง​คีย์​ที่​ยัง​ไม่มี​ได้​เลย เพราะ​การ​อ่าน​คีย์​ที่​ไม่มี​ให้ zero value ซึ่ง​เป็น​ข้อ​ที่​ฝั่ง Java ต้อง​ขอ merge ออก​มา​แทน

สิ่ง​ที่ Go ได้​กลับ​มา​คือ​ทุก​บรรทัด​อ่าน​ตรง​ตัว

  • grep หา​คำ​ว่า for แล้ว​เจอ​ที่​ที่​งาน​เกิด​ขึ้น​จริง
  • break ใช้ได้
  • err ไหล​ออก​จา​กลูป​ไป​ตาม​ปกติ​โดย​ไม่​ต้อง​ห่อ​อะไร

ฝั่ง Java ยุ​บทั้งลูป​เหลือ groupingBy บรรทัด​เดียว แต่​ราคา​ของ​มัน​มี​สาม​ข้อ

  • มัน​ซ่อน​การ​จอง​หน่วย​ความ​จำ · groupingBy สร้าง map และ​สร้าง​ที่​เก็บ​ของ​แต่ละ​คีย์​ให้​เอง ซึ่ง​อ่าน​จาก code ที่​เขียน​ไว้​ไม่​เห็น
  • toList() คืน​ของ​ที่​แก้​ไม่​ได้ ผู้​เรียก​ที่​หวัง​จะ sort() ต่อ​จะ​เจอ UnsupportedOperationException ตอน​รัน ไม่ใช่​ตอน​แปล
  • มัน​พัง​ทันที​ที่ lambda ต้อง​โยน checked exception ซึ่ง​เป็น​หัวข้อ​ที่​แล้ว​ทั้ง​หัวข้อ

เรื่อง​ลำดับ​การ​วน map ทั้ง​สอง​ภาษา​ให้​กฎ​เดียวกัน แต่​ด้วย​เหตุผล​คนละ​ข้อ

GoJava
ลำดับ​การ​วน mapruntime สุ่ม​จุด​เริ่ม​โดย​ตั้งใจHashMap ไม่​ให้​สัญญา​ไว้ ส่วน Map.of เปลี่ยน​ได้​ข้าม​รอบ​รัน
ทาง​แก้​ตอน​พิมพ์slices.Sorted(maps.Keys(m))ขอ TreeMap::new ที่ groupingBy
ตัว​เทียบ​ตอน​เรียงcmp.Compare คืน intComparator.comparing แล้ว​ต่อ thenComparing
กฎ​ข้อ​เดียวกัน​ที่​ใช้ได้​กับ​ทั้ง​สอง​ภาษา

อะไร​ที่​ไหล​ออก​ไป​เป็น output ต้อง​ถูก​เรียง​ก่อน​เสมอ ไม่​ว่า map จะ​เล็ก​แค่​ไหน

คอร์ส Go เตือน​ไว้​ตรงๆ ว่า​อย่า​เชื่อ​โมเดล “map เล็ก​มี​ลำดับ​ไม่​กี่​แบบ” เพราะ​เทสต์​ที่​เขียน​ตาม​โมเดล​นั้น จะ​แดง​ใน​เดือน​ถัด​ไป​โดย​ไม่มี​ใคร​แก้​อะไร​เลย

ฝั่ง Java Map.of เป็น​ตัว​ที่​ชัด​ที่สุด · พิมพ์ Map.of("drink", 1, "dessert", 2, "food", 3).keySet() ซ้ำ​หลาย​รอบ​บน​เครื่อง​ที่​เขียน​บท​นี้ (OpenJDK 25.0.4.1 · macOS arm64) แล้ว​ได้​ลำดับ​ที่​ไม่​ตรง​กัน เพราะ​มัน​สุ่ม​ค่า​เกลือ​ของ​ตัว​เอง​ใหม่​ทุก​ครั้ง​ที่ JVM ขึ้น

จำนวน​แบบ​ที่​ได้​เปลี่ยน​ไป​ทุก​ครั้ง​ที่​วัด จึง​ไม่มี​ตัวเลข​ไหน​ที่​ยก​มา​เขียน​ไว้​ตรง​นี้​ได้

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

สิ่ง​ที่​เพิ่ม​เข้า​มา​ใน​โปร​เจ​กต์​คือ Totals ที่​มี​สี่ method และ​ไม่​มี​ลูป​สะสม​ยอด​เหลือ​อยู่​เลย

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

  • ประกาศ​ตัวแปร​ด้วย interface แล้ว​เลือก implementation ตอน new
  • ของ​ที่​แก้​ไม่​ได้​มี​หลาย​แบบ ให้​เลือก​จาก​คำถาม​ว่า​มัน​ต้อง​เห็น​การ​แก้​ที่​ต้นทาง​หรือ​ไม่
  • .reversed() ท้าย​สาย​กลับ​กุญแจ​ทุก​ตัว ส่วน​การก​ลับ​กุญแจ​ตัว​เดียว​ต้อง​ส่งตัว​เทียบ​เข้าไป​ที่​กุญแจ​นั้น
  • อะไร​ที่​ไหล​ออก​ไป​เป็น output ต้อง​ถูก​เรียง​ก่อน

บท​หน้า​เป็น​เรื่อง interface กับ​การ​สืบทอด · นั่น​คือ​แถว​ที่​บท​ที่ 1 บอก​ว่า Java เลือก​ทาง​ตรง​ข้าม กับ Go ชัด​ที่สุด เพราะ Java บังคับ​ให้​พิมพ์​คำ​ว่า implements ออก​มา


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

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

  • javadoc ของ Stream (Java SE 25) (เข้า​ถึง 2026-08-27) — toList() และ​สัญญา​เรื่อง​ความ​แก้​ไม่​ได้ที่​ไม่​ผูก​กับ implementation
  • javadoc ของ Collectors (Java SE 25) (เข้า​ถึง 2026-08-27) — groupingBy, summingLong, teeing, toCollection
  • javadoc ของ HashMap (Java SE 25) (เข้า​ถึง 2026-08-27) — ประโยค​ที่​ระบุ​ว่า​ไม่​รับ​ประกัน​ลำดับ
  • javadoc ของ Comparator (Java SE 25) (เข้า​ถึง 2026-08-27) — thenComparing และ​รูป​ที่​รับ​ตัว​เทียบ​ของ​กุญแจ
  • JEP 431: Sequenced Collections (เข้า​ถึง 2026-08-27) — getFirst/getLast/reversed() ที่ JDK 21 และ​หัวข้อ Risks and Assumptions เรื่อง LinkedList
  • JEP 485: Stream Gatherers (เข้า​ถึง 2026-08-27) — ขั้น​กลาง​ที่​เขียน​เอง​ได้ เสร็จ​สมบูรณ์​ที่ JDK 24
  • Map (Java SE 25) — Unmodifiable Maps (เข้า​ถึง 2026-08-27) — “The iteration order of mappings is unspecified and is subject to change.”
  • java.base/java/util/ImmutableCollections.java ใน lib/src.zip ของ OpenJDK 25.0.4.1 (อ่าน 2026-08-27) — บล็อก static ที่​บรรทัด 75 ตั้ง SALT32L จาก System.nanoTime() แล้ว​ใช้​บิต​ต่ำ​สุด​ตัดสิน REVERSE · ยกเว้น​ตอน​รัน​ด้วย -Xshare:dump ที่ VM ป้อน seed คงที่​ให้
  • บท​ที่ 4 ของ​คอร์ส Go — internal/pricing/report.go ที่​แก้​โจทย์​เดียว​กัน​ด้วย​ลูป

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

ข้อ 1 / 3

`Stream.toList()` ต่างจาก `collect(Collectors.toList())` อย่างไร