การหารที่ปัดคนละทาง และ shard ที่แบ่งไม่ตรงกัน
ระบบออเดอร์ของคุณแบ่งงานออกเป็นสอง shard ด้วยนิพจน์ที่สั้นที่สุดเท่าที่จะเขียนได้ คือ
id % 2 ทุก service ในระบบ copy บรรทัดนี้ไปใช้เหมือนกันทุกตัวอักษร
แล้ววันหนึ่งก็มีออเดอร์ที่ id เป็น -7 เดินเข้ามา ค่าแบบนี้ไม่ควรมี แต่สามบทแรกวัดไปแล้วว่า
จำนวนเต็มติดลบเดินเข้าท่อได้จริง โดยไม่มีใครตั้งใจและไม่มีอะไรแดง
id % 2 ของออเดอร์ใบนั้นก็คือ -7 % 2 ซึ่ง probe วัดไว้ครบทั้งห้าภาษา service ที่เขียนด้วย
Java ได้ -1 ส่วน service ที่เขียนด้วย Python ได้ 1 จากนิพจน์เดียวกันและ id ใบเดียวกัน
คำถามของบทนี้จึงไม่ใช่ว่าใครถูก แต่เป็นว่า ทำไมสองภาษาที่ทำตามสเปกของตัวเองครบทุกข้อ ถึงตอบคนละเครื่องหมาย และคำตอบเป็นกฎข้อเดียวที่ต่างกันแค่ทิศทาง
orderctl คือเครื่องมือประมวลผลออเดอร์ที่คอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ ระหว่างนี้ทุกตัวเลขในบทนี้รันเองได้ด้วย npm run verify:integer-semantics
ซึ่งเรียก python3 scripts/integer-semantics/probe.py · ไม่ต้องมี Docker และไม่ต้องต่อเน็ต
ค่าคู่เดียว ห้าภาษา สิบช่อง
หัวข้อที่มีชื่อว่า “ค่าคู่เดียว ห้าภาษา สิบช่อง”สิบช่องนี้แบ่งเป็นสองแถว และแถวของเศษมีสี่ช่องที่ตรงกัน กับ Python ช่องเดียวที่ต่าง
ทุกช่องข้างล่างถามคำถามเดียวกัน คือเอา -7 ตั้ง แล้วหารด้วย 2 และขอเศษของการหารนั้น
ทรงที่ probe รันจริงไม่เหมือนกันทุกภาษา จึงต้องอ่านรายการนี้ก่อนอ่านตาราง
- Java — ประกาศ
int a = -7, b = 2แล้วเรียกa / bกับa % b - Go — ประกาศ
a, b := -7, 2แล้วเรียกa / bกับa % b - Rust — ประกาศ
let a: i32 = -7กับlet b: i32 = 2ที่ห่อด้วยblack_boxแล้วเรียกa / bกับa % b - Node — เขียน
-7 / 2กับ-7 % 2ลงในนิพจน์ตรงๆ - Python — เขียน
-7 // 2กับ-7 % 2ลงในนิพจน์ตรงๆ · ตัวดำเนินการหารจำนวนเต็ม ของมันคือ//ไม่ใช่/
| การกระทำ | Java | Go | Rust | Node | Python |
|---|---|---|---|---|---|
| การหาร | -3 | -3 | -3 | -3.5 | -4 |
| เศษ | -1 | -1 | -1 | -1 | 1 |
ตารางนี้มีสิบช่อง และควรอ่านมันตามที่มันเป็น ไม่ใช่ตามที่หัวข้อบทชวนให้คิด
- แถวของเศษมีสี่ช่องที่ตอบ
-1เท่ากันหมด และมีช่องของ Python ช่องเดียวที่ตอบ1นี่คือตารางที่มีช่องต่างอยู่ช่องเดียว ไม่ใช่ตารางที่ให้คำตอบห้าแบบ - แถวของการหารมีสามช่องที่ตอบ
-3เท่ากัน ที่เหลือคือ Python ซึ่งตอบ-4และ Node ซึ่งตอบ-3.5อันเป็นค่าที่ไม่ใช่จำนวนเต็มตั้งแต่ต้น
ช่องเดียวที่ต่างในแถวของเศษ คือช่องที่บทนี้ตามไปจนถึงความเสียหาย เพราะ % ต่างหากที่
โปรแกรมเอาไปใช้เป็น index และ index ที่ผิดฝั่งคือ index ที่ชี้ออกนอกช่วง
probe ผูกทรงของ Kotlin ไว้ทรงเดียว คือการลบแบบ UInt ที่บทที่ 2 ใช้ มันไม่ได้รันการหาร
ของค่าลบในภาษานั้นเลย
กติกาของคอร์สนี้คือบทเรียนพิมพ์ได้เฉพาะค่าที่ measured.json ถืออยู่ ช่องที่ probe ไม่ได้วัด
คือช่องที่ไม่มีอะไรผูก บทนี้จึงไม่มี Kotlin อยู่ในตาราง และไม่เดาคำตอบแทนมันด้วย
กฎสองข้อ ที่ต่างกันแค่ทิศของการปัด
หัวข้อที่มีชื่อว่า “กฎสองข้อ ที่ต่างกันแค่ทิศของการปัด”ผลหารทางคณิตศาสตร์ของ
-7กับ2ไม่ใช่จำนวนเต็ม สองกฎนี้เลือกจำนวนเต็มคนละตัวให้มัน
-7 หารด้วย 2 ไม่ลงตัว คำตอบที่เป็นจำนวนเต็มจึงต้องเลือกเอา และมีจำนวนเต็มสองตัวที่ขนาบ
ค่านั้นอยู่ คือ -4 ทางซ้ายกับ -3 ทางขวา สี่ภาษาที่มีการหารจำนวนเต็มเลือกหนึ่งในสองตัวนี้
ส่วน Node ไม่เลือกสักตัว
truncated divisiontruncated divisionการหารจำนวนเต็มที่ปัดเข้าหาศูนย์ · เป็นกฎของ Java Go และ Rust ทั้งสามภาษา ซึ่งตอบ -7/2 ได้ -3 เท่ากันหมด เลือกตัวที่อยู่ใกล้ศูนย์กว่า ซึ่งคือ -3
floored divisionfloored divisionการหารจำนวนเต็มที่ปัดลงหาค่าที่น้อยกว่าเสมอ · Python ใช้กฎนี้ภาษาเดียว จึงตอบ -7//2 ได้ -4 ต่างจากสามภาษาที่ปัดเข้าหาศูนย์อยู่หนึ่งหน่วย ส่วน Node ไม่มีการหารจำนวนเต็มให้เทียบ เลือกตัวที่น้อยกว่าเสมอ ซึ่งคือ -4 ความต่างของ
สองกฎนี้โผล่เฉพาะตอนผลหารติดลบ ตอนที่ผลหารเป็นบวกทั้งสองกฎให้ค่าเดียวกัน
สเปกของทั้งสี่ภาษาที่มีการหารจำนวนเต็ม เขียนกฎของตัวเองไว้ตรงๆ และไม่มีภาษาไหนปล่อยให้ เรื่องนี้เป็นรายละเอียดของการ implement
- JLS หัวข้อ 15.17.2 ระบุว่าการหารจำนวนเต็มปัดเข้าหา
0แล้วนิยามผลหารเป็นค่าที่มีขนาด ใหญ่ที่สุดเท่าที่จะยังทำให้|d ⋅ q| ≤ |n|เป็นจริง - สเปกของ Go หัวข้อ Integer operators ใช้คำว่า truncated towards zero แล้วเรียกกฎนี้ ด้วยชื่อ truncated division ในวงเล็บของมันเอง
- The Rust Reference หัวข้อตัวดำเนินการเลขคณิต เขียนไว้ที่เชิงอรรถของตารางว่าการหาร จำนวนเต็มปัดเข้าหาศูนย์ และเศษของมันนิยามจากการหารแบบตัดเข้าหาศูนย์
- Python Language Reference หัวข้อ 6.7 ระบุว่า
//ให้ผลของการหารทางคณิตศาสตร์ ที่เอาฟังก์ชัน floor ไปครอบ ซึ่งคือการปัดลงเสมอ
flowchart TD
A["ผลหารของ -7 กับ 2 ไม่ลงตัว"] --> B{"เลือกจำนวนเต็มด้วยกฎไหน"}
B -->|"ปัดเข้าหาศูนย์ · Java Go Rust"| C["ผลหาร -3"]
B -->|"ปัดลง · Python"| D["ผลหาร -4"]
B -->|"ไม่เลือกเลย · Node"| E["-3.5 ซึ่งไม่ใช่จำนวนเต็ม"]
C --> F["เศษ -1 · ตามเครื่องหมายของตัวตั้ง"]
D --> G["เศษ 1 · ตามเครื่องหมายของตัวหาร"]
E --> H["Math.trunc ได้ -3 · ส่วน % ได้ -1"]
คำบรรยายภาพ: ช่องที่ตัดสินมีช่องเดียว คือช่องสี่เหลี่ยมข้าวหลามตัดด้านบน สองสาขาที่เลือก จำนวนเต็มไปแล้ว ไม่ได้เลือกเครื่องหมายของเศษแยกอีกข้อ มันตามผลหารที่เลือกไปเท่านั้น ส่วนสาขา ของ Node ไม่มีผลหารที่เป็นจำนวนเต็มให้ตาม เศษของมันจึงมาจากกฎของตัวเอง
เอกลักษณ์ที่ผูกเศษไว้กับผลหาร
หัวข้อที่มีชื่อว่า “เอกลักษณ์ที่ผูกเศษไว้กับผลหาร”สเปกทั้งสองฝั่งผูกเศษไว้กับผลหารด้วยเอกลักษณ์รูปเดียวกัน เศษจึงไม่มีอิสระเหลือเลย
JLS หัวข้อ 15.17.3 เขียนเอกลักษณ์ไว้ว่า (a/b)*b+(a%b) ต้องเท่ากับ a ส่วน Python
Language Reference หัวข้อ 6.7 เขียนของมันว่า x == (x//y)*y + (x%y) สองบรรทัดนี้คือ
สมการรูปเดียวกัน ต่างกันแค่ชื่อตัวดำเนินการ
เมื่อสมการเป็นรูปเดียวกัน และผลหารถูกเลือกไปแล้วคนละตัว เศษก็เหลือค่าที่เป็นไปได้ค่าเดียว ในแต่ละฝั่ง มันไม่ใช่การตัดสินใจอีกข้อที่ภาษาทำแยกจากกัน
- ฝั่งที่ปัดเข้าหาศูนย์ ผลหารเป็น
-3เศษจึงต้องเป็น-1เพราะ-3 * 2 + (-1)กลับไปได้-7 - ฝั่งที่ปัดลง ผลหารเป็น
-4เศษจึงต้องเป็น1เพราะ-4 * 2 + 1กลับไปได้-7เหมือนกัน
สเปกทั้งสองฝั่งยังเขียนผลที่ตามมาของเอกลักษณ์นี้ไว้เป็นประโยคของตัวเองด้วย และสองประโยคนั้น คือนิยามของ เครื่องหมายของเศษmodulo signเครื่องหมายของเศษที่เหลือจากการหาร · มันตามตัวตั้งในเกือบทุกภาษา แต่ตามตัวหารใน Python · id % n จึงให้ค่าคนละฝั่งเมื่อ id ติดลบ และสอง service แบ่ง shard ไม่ตรงกัน ที่บทนี้วัด
- JLS ระบุว่าผลของ
%จะติดลบได้ก็ต่อเมื่อตัวตั้งติดลบ และเป็นบวกได้ก็ต่อเมื่อตัวตั้งเป็นบวก - Python Language Reference ระบุว่า
%ให้ผลที่มีเครื่องหมายเดียวกับตัวถัดมาเสมอ ซึ่งคือตัวหาร หรือไม่ก็เป็นศูนย์
ในนิพจน์ id % 2 ตัวหารเป็นบวกเสมอ ฝั่งหนึ่งจึงคืนค่าตามเครื่องหมายของ id ส่วนอีกฝั่ง
คืนค่าที่ไม่ติดลบเสมอ นี่คือที่มาของ -1 กับ 1 ในตารางข้างบน
Shapes.java main.go และ shapes.rs ใส่ทั้งสองค่าไว้ในตัวแปรก่อนหาร ด้วยเหตุผลเดียวกับ
กับดักของ literal ในบทที่ 1 คือสิ่งที่ compiler พับค่าให้ล่วงหน้าได้ ไม่ใช่สิ่งที่เกิดตอน runtime
ที่นิพจน์นี้ไม่มีการล้นให้ compiler ปฏิเสธ ต่างจาก 10u32 - 25u32 ของบทที่ 1 ทิศของการปัด
จึงเป็นกฎของภาษา ไม่ใช่ผลของธงตอน compile
Node ไม่มีการหารจำนวนเต็มให้ปัด
หัวข้อที่มีชื่อว่า “Node ไม่มีการหารจำนวนเต็มให้ปัด”
/ของ Node ไม่ได้เลือกจำนวนเต็มให้เลย ส่วน%ของมันไปอยู่ฝั่งเดียวกับ Java Go และ Rust
-7 / 2 ใน Node ได้ -3.5 ซึ่งไม่ใช่จำนวนเต็ม ภาษานี้ไม่มีตัวดำเนินการหารจำนวนเต็มแยก
ต่างหาก คนเขียนจึงต้องเลือกกฎการปัดด้วยมือทุกครั้งที่ต้องการจำนวนเต็ม
Math.trunc(-7 / 2) ได้ -3 ซึ่งเป็นค่าเดียวกับที่อีกสามภาษาให้ ตัวที่พาไปอยู่ฝั่งนั้นคือชื่อ
ฟังก์ชันที่คนเขียนพิมพ์เอง ไม่ใช่กฎที่ภาษาบังคับ
-7 % 2 ใน Node ได้ -1 หน้า Remainder ของ MDN อธิบายว่า % ของ JavaScript เป็น
remainder ไม่ใช่ modulo และระบุว่ามันรับเครื่องหมายของตัวตั้งเสมอ ซึ่งตรงกับ -1 ที่วัดได้
หน้าเดียวกันนั้นเขียนความต่างของสองคำนี้ไว้ตรงๆ ว่าเมื่อสองฝั่งมีเครื่องหมายต่างกัน ผลของ modulo จะรับเครื่องหมายของตัวหาร ส่วนผลของ remainder รับเครื่องหมายของตัวตั้ง และสองค่านั้น ต่างกันได้หนึ่งเท่าของตัวหาร
MDN ยังเขียนรูปที่ใช้แทนไว้ให้ด้วย คือ ((n % d) + d) % d สำหรับคนที่ต้องการ modulo จริงๆ
ใน JavaScript รูปนี้คือทางที่ service ฝั่ง Node ใช้ตามฝั่ง Python ได้ ถ้าตกลงกันว่าจะตามฝั่งนั้น
ความเสียหาย · index เดียวกัน สอง service
หัวข้อที่มีชื่อว่า “ความเสียหาย · index เดียวกัน สอง service”ช่องที่ต่างช่องเดียวในตาราง เดินทางไปจบที่ index ของ shard และปลายทางคืนสองคำตอบ
กลับไปที่ id % 2 ของออเดอร์ที่ id เป็น -7 นิพจน์นั้นคือ -7 % 2 ทุกตัวอักษร และปลายทาง
ของมันคือ index ที่ใช้เลือก shard
- service ฝั่ง Java Go Rust หรือ Node ได้
-1ซึ่งอยู่นอกช่วง0ถึง1ที่ shard สองใบมี - service ฝั่ง Python ได้
1ซึ่งอยู่ในช่วง และชี้ไปที่ shard ใบที่สองอย่างเรียบร้อย
ผลคือแถวเดียวกันมีเจ้าของสองคำตอบ ฝั่งหนึ่งบอกว่าเป็นของ shard ใบที่สอง อีกฝั่งบอกว่า ไม่มี shard ไหนรับ ทั้งที่ทั้งสองฝั่งรันนิพจน์ชุดเดียวกันกับ id ใบเดียวกัน
การหารก็ให้รูปเดียวกันเมื่อเอาไปหารบิล ออเดอร์ที่ยอดสุทธิเป็น -7 หน่วย และต้องแบ่งครึ่ง
ให้สองฝั่ง จะได้ฝั่งละ -3 ใน Java Go และ Rust ส่วน Python ได้ฝั่งละ -4
ฝั่ง Node ได้ -3.5 ซึ่งไม่ใช่จำนวนหน่วยที่จ่ายได้เลย แล้วคนเขียนก็ต้องเลือกกฎการปัดเอง
ตรงจุดนั้น โดยที่ไม่มีอะไรบอกว่าอีกสี่ service เลือกกฎไหนไว้
สิ่งที่ทำให้ bug ตัวนี้อยู่ได้นานคือไม่มีฝั่งไหนผิด ทั้งสองฝั่งทำตามสเปกของภาษาตัวเองครบ เทสต์ของแต่ละ service เขียวหมด เพราะภายในภาษาเดียวคำตอบสอดคล้องกับตัวเองเสมอ
ทางแก้ทุกทางมีราคา และราคาของมันคือสิ่งที่ทำให้ทีมเลื่อนการแก้ออกไป
- ครอบด้วยรูปของ MDN คือ
((n % d) + d) % dราคาคือทุกจุดเรียกยาวขึ้น และต้องครอบ ให้ครบทุกภาษาในระบบ ไม่ใช่ครอบเฉพาะภาษาที่จับได้ - บังคับให้ id ไม่ติดลบตั้งแต่ต้นทาง ราคาคือต้องมีด่านที่ขอบของระบบจริงๆ และบทที่ 1 ถึงบทที่ 3 วัดไว้แล้วว่าค่าติดลบเกิดขึ้นเองกลางท่อได้ ไม่ได้เข้ามาทางขอบอย่างเดียว
- ย้ายนิพจน์ไปไว้ที่เดียว เช่นให้ฝั่ง database คำนวณ index ให้ ราคาคือ service ทุกตัว ต้องเรียกข้ามเครือข่ายเพื่อถามว่าแถวนี้เป็นของใคร
ราคาที่แพงที่สุดคือการไม่ทำอะไร เพราะมันจ่ายตอนที่ข้อมูลกระจายผิดไปแล้วหลายเดือน
จุดที่สามในห้าจุด
หัวข้อที่มีชื่อว่า “จุดที่สามในห้าจุด”บทนี้ปิดจุดที่สาม และรูปของมันต่างจากสองจุดแรก เพราะไม่มีใครล้นและไม่มีใครผิด
ธีสิสของคอร์สนี้คือ ค่าจำนวนเต็มค่าหนึ่งเปลี่ยนได้ห้าจุดระหว่างทางผ่านระบบ และทุกจุด ในห้าจุดนั้นเงียบ จุดที่หนึ่งคือตอนความกว้างหมด จุดที่สองคือตอนคำตอบที่ถูกอยู่นอกชนิดเดิม
จุดที่สามคือจุดที่บทนี้เพิ่งวัด และมันเป็นจุดเดียวในสามจุดแรกที่ไม่มีการล้นอยู่ในการคำนวณเลย ทุกค่าอยู่ในช่วงของชนิดสบายๆ สิ่งที่เปลี่ยนคือกฎการปัดที่ภาษาเลือกไว้ให้ตั้งแต่ต้น
สองจุดแรกยังพอมีอะไรส่งเสียงได้บ้าง คือธงของ Rust กับตระกูล Exact ของ Java จุดที่สาม
ไม่มีอะไรแบบนั้นเลย ทุกช่องในตารางของบทนี้คือคำตอบที่ถูกต้องตามสเปกของภาษานั้น
บทที่ 5 ย้ายไปที่จุดถัดไป คือตอนที่ค่าจำนวนเต็มเดินผ่าน double แล้วออกมาไม่เท่าเดิม
คราวนี้ค่าที่เปลี่ยนไม่ได้มาจากกฎที่ภาษาเลือกไว้ แต่มาจากความกว้างของตัวที่รับค่าไว้ระหว่างทาง
-
JLS SE 25 — §15.17.2 Division Operator / และ §15.17.3 Remainder Operator % (เข้าถึง 2026-09-13) — §15.17.2 ระบุว่าการหารจำนวนเต็มปัดเข้าหา
0และนิยามผลหารด้วย เงื่อนไข|d ⋅ q| ≤ |n|· §15.17.3 ระบุเอกลักษณ์(a/b)*b+(a%b)ที่ต้องเท่ากับaแล้วสรุปต่อว่าผลของ%ติดลบได้ก็ต่อเมื่อตัวตั้งติดลบ -
The Python Language Reference — §6.7 Binary arithmetic operations (เข้าถึง 2026-09-13) — ระบุว่า
//ให้ผลของการหารทางคณิตศาสตร์ที่เอาฟังก์ชัน floor ไปครอบ · ระบุว่า%ให้ผลที่มีเครื่องหมายเดียวกับตัวดำเนินการตัวที่สองเสมอหรือไม่ก็เป็นศูนย์ · และผูกทั้งสองตัวไว้ด้วยเอกลักษณ์x == (x//y)*y + (x%y) -
Remainder (%) — MDN (เข้าถึง 2026-09-13) — ระบุว่า
%ของ JavaScript รับเครื่องหมายของตัวตั้งเสมอ · อธิบาย ความต่างระหว่าง remainder กับ modulo ว่าสองค่านั้นต่างกันได้หนึ่งเท่าของตัวหารเมื่อสองฝั่ง มีเครื่องหมายต่างกัน · และเขียนรูป((n % d) + d) % dไว้ให้คนที่ต้องการ modulo -
The Go Programming Language Specification — Integer operators (เข้าถึง 2026-09-13) — ผูก
q = x / yกับr = x % yด้วยx = q*y + rและ|r| < |y|โดยที่x / yถูก truncated towards zero ซึ่งสเปกเรียกด้วยชื่อ truncated division เอง -
The Rust Reference — Arithmetic and Logical Binary Operators (เข้าถึง 2026-09-13) — เชิงอรรถของตารางตัวดำเนินการระบุว่าการหารจำนวนเต็มปัดเข้าหาศูนย์ และว่าเศษของ Rust นิยามจากการหารแบบตัดเข้าหาศูนย์ จึงมีเครื่องหมายเดียวกับตัวตั้ง
-
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/integer-semantics/probe.pyซึ่งวัดทั้งชุดสามรอบ ต่อการเรียกหนึ่งครั้ง · วัดบนrustc 1.97.1go1.27.0JDK25.0.4.1node v24.19.0และPython 3.14.6บน darwin/arm64 เมื่อ 2026-09-12
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3probe วัด `-7` หารด้วย `2` และเศษของมันในทั้งห้าภาษา ฝั่ง Java กับฝั่ง Python ได้อะไร