collection กับ stream — คิดยอดและสรุปตามหมวด
บทที่ 3 ทำให้ orderctl อ่าน JSON เข้ามาเป็นชนิดของโดเมนได้แล้ว
ตอนนี้ในหน่วยความจำมีรายการสินค้ากองอยู่ และคำถามที่ลูกค้าถามจริงมีสามข้อ — รวมกี่บาท แต่ละหมวดกี่บาท และใบเสร็จเรียงอย่างไรให้เหมือนเดิมทุกครั้ง
บทที่ 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
mvn -q compile exec:java -Dexec.mainClass=orderctl.TotalsDemoแก้ Line ครั้งเดียว เพิ่มหมวดของสินค้า
หัวข้อที่มีชื่อว่า “แก้ Line ครั้งเดียว เพิ่มหมวดของสินค้า”คำถาม “แต่ละหมวดกี่บาท” ตอบไม่ได้ถ้า
Lineไม่รู้ว่าตัวเองอยู่หมวดไหน
Line ของบทที่ 3 มีสาม component · บทนี้เพิ่ม category เข้าไปเป็นตัวที่สอง
แล้วบังคับว่าห้ามเป็น null ด้วยกลไกเดิมที่บทที่ 2 สอนไว้
// src/main/java/orderctl/Line.java — ฉบับแก้ของบทที่ 4package 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.FrozenLabcopy=[tea] view=[tea, cake]asList=[coffee, cake]List.of ปฏิเสธ null แม้ตอนถามjava.lang.IllegalArgumentException: duplicate element: teaArrays.asList ต่อท้ายไม่ได้บรรทัดแรกของผลคือหัวใจของหัวข้อนี้ · copy ไม่เห็น cake ส่วน view เห็น
ทั้งที่ทั้งคู่ถูกสร้างจาก base ตัวเดียวกันในบรรทัดติดกัน
| รูป | ให้อะไร | ยังเห็นการแก้ที่ต้นทาง | เพิ่มหรือลบ | เปลี่ยนค่าในตำแหน่งเดิม |
|---|---|---|---|---|
List.of(...) | ของใหม่ที่ตรึงแล้ว | ไม่มีต้นทาง | ไม่ได้ | ไม่ได้ |
List.copyOf(x) | สำเนา | ไม่เห็น | ไม่ได้ | ไม่ได้ |
Collections.unmodifiableList(x) | หน้าต่าง | เห็น | ไม่ได้ | ไม่ได้ |
Arrays.asList(...) | หน้าต่างบน array | เห็น | ไม่ได้ | ได้ |
List.of("a").contains(null) โยน NullPointerException ออกมา ทั้งที่คุณไม่ได้ใส่อะไรลงไปเลย
สเปกของ Collection.contains ทำเครื่องหมายพฤติกรรมนี้ไว้ว่าเป็นทางเลือกของ implementation
· ArrayList เลือกตอบ false ส่วนตระกูล List.of เลือกโยน
ผลคือ code ที่เขียนว่า if (allowed.contains(input)) ทำงานถูกมาตลอด แล้วระเบิดวันที่ input
เป็น null เป็นครั้งแรก · ตำแหน่งที่ระเบิดไม่ใช่ตำแหน่งที่ผิด
ยอดของ orderctl เขียนด้วย stream
หัวข้อที่มีชื่อว่า “ยอดของ orderctl เขียนด้วย stream”หัวข้อนี้สร้าง
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 ที่จบแล้วเอากลับมาใช้ซ้ำไม่ได้
มีคำแนะนำที่ค้นเจอบ่อยว่าให้แทน 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 ของทั้งสองตัว
ชี้ไปหาเองเมื่อผู้เรียกต้องการคุมของที่ได้กลับมา
javadoc ระบุเองว่าไม่รับประกันว่า toList() คืน implementation ตัวไหน
· สิ่งที่รับประกันคือสัญญาเรื่องการแก้ไข ไม่ใช่ชนิด
เทสต์ที่เขียนว่า assertInstanceOf(ImmutableCollections.ListN.class, result) จึงเป็นเทสต์
ที่ผูกกับรายละเอียดที่ JDK เปลี่ยนได้ทุกรุ่น · เทสต์ที่ถูกคือ assertThrows(UnsupportedOperationException.class, () -> result.add(x))
Comparator ที่ .reversed() กลับให้ทั้งสาย
หัวข้อที่มีชื่อว่า “Comparator ที่ .reversed() กลับให้ทั้งสาย”
.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]-21474836481wrong ตั้งใจจะเรียงหมวดขึ้นแล้วเรียง 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เมื่อกุญแจเป็นตัวเลข
sequenced collection กับสามชื่อที่ javadoc แนะให้เปลี่ยน
หัวข้อที่มีชื่อว่า “sequenced collection กับสามชื่อที่ javadoc แนะให้เปลี่ยน”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.SeqLabtea .. 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()) ออกมาตรงๆ
คุณจะเจอสามชื่อนี้ใน code เก่าและในคำตอบบนเน็ต · เอกสารของ JDK เองเขียนตัวที่ควรใช้แทนไว้ให้แล้ว
| ชื่อเก่า | javadoc แนะให้ใช้ |
|---|---|
Stack | Deque หรือ ArrayDeque |
Vector | ArrayList |
Hashtable | HashMap หรือ 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.gather(...) เปิดทางให้เขียนขั้นกลางของ stream เองได้ เช่นการซอยเป็นหน้าต่างทีละ N ตัว
มันเสร็จสมบูรณ์ที่ JDK 24 (JEP 485) · คอร์สนี้ตรึงไว้ที่ 25 จึงใช้ได้ แต่ทีมที่ยังอยู่บน LTS ตัวก่อนคือ 21 จะไม่มีมัน
บทนี้จึงไม่ใช้มันในของจริงของ orderctl · ยกมาเพื่อให้คุณรู้ว่ามันมีอยู่เท่านั้น
autoboxing ทำให้ == และ remove ตอบไม่ตรงกับที่อ่าน
หัวข้อที่มีชื่อว่า “autoboxing ทำให้ == และ remove ตอบไม่ตรงกับที่อ่าน”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.BoxLabtrue false[10, 30][10]012500Cannot 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
หัวข้อที่มีชื่อว่า “สามกรณีที่ลูปธรรมดาชนะ stream”stream แพ้ชัดสามกรณี — lambda ที่ต้องโยน checked exception, งานที่ต้องหยุดกลางคัน และการเขียนลงของที่ใช้ร่วมกันแบบขนาน
กรณีที่หนึ่ง — lambda โยน checked exceptionchecked exceptionความผิดพลาดที่ compiler บังคับให้ประกาศหรือจับ · `throws` เป็นสัญญาส่วนหนึ่งของ API แต่ข้ามเข้าไปใน lambda ไม่ได้ ไม่ได้ และการที่ method ที่ล้อมอยู่ประกาศ
throws ไว้แล้วก็ไม่ช่วย
// ❌ แปลไม่ผ่าน — ตั้งใจ · 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 ทั้งสองภาษาให้กฎเดียวกัน แต่ด้วยเหตุผลคนละข้อ
| Go | Java | |
|---|---|---|
| ลำดับการวน map | runtime สุ่มจุดเริ่มโดยตั้งใจ | HashMap ไม่ให้สัญญาไว้ ส่วน Map.of เปลี่ยนได้ข้ามรอบรัน |
| ทางแก้ตอนพิมพ์ | slices.Sorted(maps.Keys(m)) | ขอ TreeMap::new ที่ groupingBy |
| ตัวเทียบตอนเรียง | cmp.Compare คืน int | Comparator.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())` อย่างไร