ความกว้างหมดลง และภาษาที่ไม่มีความกว้างให้หมด
ออเดอร์ใบเดิมจากบทที่ 1 ยังอยู่ ยอดรวมเป็น 10 ส่วนคืนเงินที่ตามมาทีหลังเป็น 25
และ service ยังคำนวณยอดที่ยังต้องเก็บด้วย total - refunded เหมือนเดิมทุกตัวอักษร
สิ่งที่เปลี่ยนคือภาษา บทที่ 1 วัด Rust ภาษาเดียวแล้วพบว่ามันตอบสองอย่างจาก source ชุดเดียว บทนี้เอาการลบอันเดิมไปวางในหกภาษา แล้วดูว่าใครตอบอะไร
คำถามที่คนถามก่อนคือภาษาไหนตอบเท่าไร แต่คำถามที่แยกภาษาออกจากกันจริงๆ คือ
ภาษานั้นมีความกว้างของจำนวนเต็มให้ประกาศหรือเปล่า สองภาษาในหกภาษานี้ไม่มี คือ Python
ที่ int ขยายตัวได้ไม่จำกัด และ Node ที่เก็บค่าไว้ใน double · double เองมีความกว้างของมันอยู่
สิ่งที่ Node ไม่มีคือชนิดจำนวนเต็มความกว้างตายตัวให้คุณประกาศ
orderctl คือเครื่องมือประมวลผลออเดอร์ที่คอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ ระหว่างนี้ทุกตัวเลขในบทนี้รันเองได้ด้วย npm run verify:integer-semantics
ซึ่งเรียกทั้งหกภาษาในเครื่องเดียว · ไม่ต้องมี Docker และไม่ต้องต่อเน็ต
การลบชุดเดียว หกภาษา เจ็ดแถว
หัวข้อที่มีชื่อว่า “การลบชุดเดียว หกภาษา เจ็ดแถว”เจ็ดแถวนี้ให้คำตอบสามแบบ และความต่างไม่ได้อยู่ที่ภาษา แต่อยู่ที่ชนิดปลายทาง
ความกว้างinteger widthจำนวน bit ที่ชนิดหนึ่งใช้เก็บค่า ซึ่งเป็นตัวกำหนดช่วงของมัน · คำถามที่แยกภาษาออกจากกันคือภาษานั้นมีความกว้างหรือไม่ ไม่ใช่ว่ามันตอบเท่าไรตอนความกว้างหมด คือจำนวน bit ที่ชนิดหนึ่งใช้เก็บค่า และมันเป็นตัวกำหนดช่วงของชนิดนั้น
ทุกแถวข้างล่างคำนวณ 10 - 25 เหมือนกันหมด ต่างกันที่ชนิดที่รับผลลัพธ์
| ภาษา | ชนิด | ผลที่วัดได้ | หมายเหตุ |
|---|---|---|---|
| Rust | u32 | 4294967281 หรือ panic | ตัดสินด้วย -C overflow-checks · บทที่ 1 |
| Go | uint32 | 4294967281 | เงียบ และไม่มีธงให้เปลี่ยน |
| Kotlin | UInt | 4294967281 | เงียบ |
| Java | int | -15 | Java ไม่มีชนิด unsigned ที่ 32 bit |
| Java | Integer.toUnsignedString(10 - 25) | 4294967281 | bit ชุดเดิม อ่านคนละแบบ |
| Node | 10 - 25 | -15 · ส่วน (10-25) >>> 0 ได้ 4294967281 | >>> เป็นตัวบังคับการอ่าน |
| Python | 10 - 25 | -15 | int ไม่มีความกว้างเลย · 2**64 ได้ 18446744073709551616 |
ตารางนี้ให้คำตอบสามแบบ ไม่ใช่หกแบบตามจำนวนภาษา
4294967281— Rustu32· Gouint32· KotlinUInt· Java ผ่านtoUnsignedString· และ Node ผ่าน>>> 0-15— Javaint· Node ที่เขียน10 - 25ตรงๆ · และ Pythonint- การหยุดทำงาน — Rust
u32เมื่อเปิด-C overflow-checks
แถวที่ 4 กับแถวที่ 5 แสดงว่า -15 กับ 4294967281 มาจาก bit ชุดเดียวกันได้
Python ไม่มีความกว้างให้หมด
หัวข้อที่มีชื่อว่า “Python ไม่มีความกว้างให้หมด”int ของ Python ขยายตัวไปเรื่อยๆ ตามค่าที่ต้องเก็บ มันจึงไม่มีขอบให้ชน 10 - 25 ของมัน
ตอบ -15 ตามเลขคณิตปกติ อย่างที่แถวสุดท้ายของตารางเขียนไว้ และ 2**64 ตอบ
18446744073709551616 ออกมาตรงๆ คำถามที่ไม่มีความหมายกับมันคือคำถามเรื่องการล้น
ไม่ใช่ทุกคำถามในบทนี้
ราคาที่จ่ายไปแลกกับเรื่องนี้คือค่าหนึ่งค่าไม่มีขนาดที่ตายตัว ตอนเขียนลง database หรือส่งออกไปหาภาษาอื่น ความกว้างจะกลับมาอยู่ดี และจุดนั้นคือที่ที่ค่าจะหาย
Node เก็บค่าไว้ใน double
หัวข้อที่มีชื่อว่า “Node เก็บค่าไว้ใน double”JavaScript ไม่มีชนิดจำนวนเต็ม 32 bit ให้ประกาศ 10 - 25 จึงเป็นการลบของทศนิยม
และผลที่ได้คือ -15 ตามเลขคณิตปกติ
แต่ operator ของการเลื่อน bit บังคับการอ่านแบบ 32 bit ได้ (10-25) >>> 0 จึงได้
4294967281 ทั้งที่ 10 - 25 ได้ -15 นี่คือค่าเดียวกัน อ่านด้วยกฎคนละข้อ
Java มีความกว้าง แต่ไม่มีฝั่ง unsigned ที่ 32 bit
หัวข้อที่มีชื่อว่า “Java มีความกว้าง แต่ไม่มีฝั่ง unsigned ที่ 32 bit”Java ประกาศ unsigned integerunsigned integerชนิดจำนวนเต็มที่ไม่มีฝั่งลบ · Java ไม่มีชนิดนี้ที่ 32 bit จึงต้องอ่าน bit ชุดเดิมผ่าน Integer.toUnsignedString ส่วน Node ต้องบังคับการอ่านด้วย >>> ที่ 32 bit ไม่ได้ int เป็นชนิดมีเครื่องหมาย
เสมอ 10 - 25 จึงได้ -15 และนั่นคือคำตอบที่ถูกต้องของชนิดนั้น
Integer.toUnsignedString(10 - 25) ตอบ 4294967281 โดยไม่ได้คำนวณใหม่สักครั้ง มันอ่าน bit
ชุดเดิมของ -15 ด้วยกฎแบบไม่มีเครื่องหมาย แล้วเขียนออกมาเป็น string
-15 กับ 4294967281 ไม่ใช่คำตอบที่ขัดกัน มันคือ bit 32 ตัวชุดเดียวกันที่ถูกอ่านสองแบบ
Java เลือกอ่านแบบมีเครื่องหมายเป็นค่าตั้งต้น และให้ทางอ่านอีกแบบไว้ในรูปของ method แยก
สิ่งที่ตามมาคือ Java จะไม่มีทางบอกคุณว่าเกิดอะไรขึ้น เพราะในสายตาของมัน 10 - 25 ไม่ได้
ออกนอกช่วงของ int เลยสักนิด ความเสียหายเกิดตอนอีกฝั่งของสายอ่านค่าเดียวกันนี้แบบ unsigned
flowchart TD
A["total - refunded · 10 ลบ 25"] --> B{"ชนิดปลายทางมีความกว้างไหม"}
B -->|"ไม่มี · Python int"| C["-15 · ขยายตัวได้ไม่จำกัด"]
B -->|"เก็บใน double · Node"| D["-15 · และการเลื่อน bit แบบ unsigned อ่านได้ 4294967281"]
B -->|"มี 32 bit"| E{"ชนิดนั้นมีฝั่ง unsigned ไหม"}
E -->|"ไม่มี · Java int"| F["-15 · toUnsignedString อ่าน bit ชุดเดิมได้ 4294967281"]
E -->|"มี"| G{"มีธงที่เปลี่ยนพฤติกรรมตอนล้นไหม"}
G -->|"ไม่มี · Go uint32 และ Kotlin UInt"| H["4294967281 · เงียบ"]
G -->|"มี · Rust u32 กับ -C overflow-checks"| I["4294967281 หรือ panic"]
คำบรรยายภาพ: ช่องตัดสินสามช่องคือความกว้าง ฝั่ง unsigned และธงที่เปลี่ยนพฤติกรรมตอนล้น ไม่มีช่องไหนถามชื่อภาษาเลย Go กับ Kotlin จบที่ช่องเดียวกันเพราะตอบเหมือนกันทั้งสามช่อง ส่วน Rust แยกออกไปที่ช่องล่างสุด ทั้งที่ชนิดปลายทางของมันกว้างเท่ากันและ unsigned เหมือนกัน
probe วัด Kotlin ไว้ทรงเดียวคือ UInt ที่แถวที่ 3 ของตารางข้างบน ช่อง Kotlin ช่องอื่นจึงไม่มี
อะไรผูก และคอร์สนี้จะไม่พิมพ์มันออกมา
ช่องที่ probe ไม่ได้วัด คือช่องที่ไม่มีหลักฐาน ต่อให้เดาถูกก็ตาม
การกระทำที่มียาม
หัวข้อที่มีชื่อว่า “การกระทำที่มียาม”Rust ให้ยามสามแบบ Java ให้ตระกูล Exact ส่วน Go ไม่ให้อะไรเลย
การลบแบบธรรมดาเงียบเพราะมันไม่มีทางรายงานอะไรได้ ผลลัพธ์ของมันมีช่องเดียวคือค่า การกระทำแบบมียามแก้เรื่องนี้ด้วยการเปลี่ยนสิ่งที่คืนออกมา ไม่ใช่ด้วยการเปลี่ยนการคำนวณ
| การเรียก | ค่าที่วัดได้ |
|---|---|
Rust 10u32.checked_sub(25) | None |
Rust 10u32.saturating_sub(25) | 0 |
Rust 10u32.wrapping_sub(25) | 4294967281 |
Java Math.subtractExact(Integer.MIN_VALUE, 1) | โยน java.lang.ArithmeticException: integer overflow |
Java Math.toIntExact(3000000000L) | โยน java.lang.ArithmeticException: integer overflow |
Java (int) 3000000000L | -1294967296 |
Go การลบของ uint32 | ไม่มีรูปแบบที่มียามในภาษา |
สามแถวแรกคือการลบอันเดียวกัน ต่างกันที่สิ่งที่คืนกลับมา
- checked_subchecked arithmeticการคำนวณที่คืนสัญญาณเมื่อผลลัพธ์เกินช่วง แทนที่จะคืนค่าที่ผิด · checked_sub ของ Rust คืน None ส่วนตระกูล Exact ของ Java โยน ArithmeticException ออกมา คืน
Noneซึ่งแปลว่าผลลัพธ์ไม่มีอยู่ในชนิดนี้ - saturating_subsaturating arithmeticการคำนวณที่เมื่อล้นแล้วค้างอยู่ที่ขอบของช่วง แทนที่จะวนไปอีกฝั่ง · 10u32.saturating_sub(25) จึงได้ 0 ไม่ใช่ค่าที่วนกลับ คืน
0ซึ่งเป็นขอบล่างของu32 - wrapping_subwrapping arithmeticการคำนวณที่เมื่อล้นแล้ววนกลับเข้าช่วงของชนิดนั้น แทนที่จะหยุด · เงียบที่สุดในสามแบบ เพราะผลลัพธ์ที่ได้ยังเป็นค่าที่ถูกต้องตามชนิดทุกประการ คืน
4294967281ซึ่งเท่ากับที่rustc -Oตอบในบทที่ 1
checked_sub เป็นแบบเดียวที่บังคับให้คุณเขียนทางแยก เพราะ None ไม่ใช่ตัวเลข อีกสองแบบ
คืนตัวเลขที่ใช้ต่อได้ทันที ราคาของมันคือคุณเลือกคำตอบที่ผิดไว้ล่วงหน้าแล้ว
ตระกูล Exact ของ Java วัดที่อินพุตอื่น และเหตุผลของมัน
หัวข้อที่มีชื่อว่า “ตระกูล Exact ของ Java วัดที่อินพุตอื่น และเหตุผลของมัน”10 - 25 ของ Java ได้ -15 ซึ่งอยู่ในช่วงของ int สบายๆ มันจึงไม่ใช่การล้น และยามก็ไม่มี
อะไรให้จับ probe จึงวัด Math.subtractExact ที่คู่ค่าที่ออกนอกช่วงจริงแทน ส่วน toIntExact
รับอาร์กิวเมนต์ตัวเดียวเป็น long ไม่ใช่คู่ค่า probe จึงป้อน 3000000000L ซึ่งเกินช่วงของ
int เข้าไปตัวเดียว
ทั้ง subtractExact และ toIntExact โยน exception ตัวเดียวกันออกมา คือบรรทัดนี้
java.lang.ArithmeticException: integer overflowแถวสุดท้ายของ Java คือแถวที่บอกราคาของการไม่ใช้ยาม (int) 3000000000L ได้ -1294967296
ออกมาเงียบๆ จากค่าตั้งต้นเดียวกันกับที่ toIntExact ปฏิเสธ
Go ไม่มีรูปแบบที่มียามให้เลือก
หัวข้อที่มีชื่อว่า “Go ไม่มีรูปแบบที่มียามให้เลือก”Go ไม่มีการเรียกแบบ checked หรือ saturating ในภาษาเอง การลบของ uint32 วนกลับ
อย่างเดียว และไม่มีธงระดับ compiler ให้เปลี่ยนพฤติกรรมนั้นแบบที่ rustc มี
สิ่งที่เหลือให้ทำคือเขียนการตรวจเองก่อนลบ ซึ่งแปลว่าความถูกต้องของเรื่องนี้ย้ายจากภาษา ไปอยู่ที่วินัยของคนเขียน และวินัยมองไม่เห็นตอนอ่าน diff
แถวที่สามภาษาตอบตรงกัน
หัวข้อที่มีชื่อว่า “แถวที่สามภาษาตอบตรงกัน”สามช่องนี้เท่ากันหมด และตารางที่ทุกช่องต่างกันไม่ได้จริงกว่าตารางที่มีช่องตรงกัน
การแปลงลงชนิดที่แคบกว่าnarrowing conversionการแปลงค่าไปยังชนิดที่แคบกว่า ด้วยการตัด bit ส่วนบนทิ้งเงียบๆ · Java Go และ Rust ให้ผลตรงกันที่ 44 เมื่อบีบ 300 ลงเหลือ 8 bit คือการบีบค่าลงไปให้พอดีกับความกว้างที่น้อยกว่า
เดิม ทั้งสามภาษาข้างล่างบีบ 300 ลงเหลือ 8 bit แล้วตอบค่าเดียวกัน
| ภาษา | สิ่งที่ probe เขียน | ค่าที่วัดได้ |
|---|---|---|
| Java | (byte) v เมื่อ int v = 300 | 44 |
| Go | int8(v) เมื่อ v := 300 | 44 |
| Rust | v as u8 เมื่อ v เป็น i32 ค่า 300 | 44 |
เหตุผลเดียวกันทั้งสามช่อง คือ 8 bit ล่างของ 300 มีค่าเท่ากับ 44 และทั้งสามภาษาเลือก
ตัด bit ส่วนบนทิ้งโดยไม่รายงานอะไร
ทั้งสามช่องส่งค่าผ่านตัวแปรก่อนเสมอ ไม่มีช่องไหนเขียน 300 ลงไปในการแปลงตรงๆ ด้วยเหตุผล
รูปเดียวกับกับดัก literal ในบทที่ 1 คือค่าที่ compiler มองเห็นล่วงหน้าไม่ใช่ค่าที่มาถึงตอน runtime
Go เป็นช่องที่เห็นความต่างนี้ชัดที่สุด เพราะ int8(300) ที่เขียนตรงๆ ไม่ผ่าน compile เลย
ตารางที่เขียนด้วย literal จึงไม่ใช่ตารางของการตัดทอนตอน runtime แต่เป็นตารางของ compiler
ตารางเปรียบเทียบมักถูกเขียนให้ทุกช่องต่างกัน เพราะตารางแบบนั้นดูเหมือนมีอะไรให้สอน แต่ความตรงกันของสามช่องนี้เป็นสิ่งที่วัดมาได้จริง ไม่ใช่ช่องที่ยังไม่ได้กรอก
ประโยชน์ของมันตรงไปตรงมา คือคุณย้ายการตัดทอนแบบนี้ข้ามสามภาษาได้โดยไม่ต้องจำสามกฎ สิ่งที่ต้องจำแยกคือเรื่องที่บทนี้วัดได้ว่าต่างจริง ไม่ใช่ทุกเรื่องที่ชื่อภาษาต่างกัน
จุดแรก วัดข้ามภาษา
หัวข้อที่มีชื่อว่า “จุดแรก วัดข้ามภาษา”บทนี้ปิดคำถามเรื่องความกว้าง บทถัดไปถามค่าที่แม้แต่ความกว้างที่พอก็ยังตอบผิด
ธีสิสของคอร์สคือค่าจำนวนเต็มค่าหนึ่งเปลี่ยนได้ห้าจุดระหว่างทาง และทุกจุดเงียบ บทที่ 1 วัดจุดแรกในภาษาเดียว บทนี้วัดจุดเดียวกันข้ามหกภาษา แล้วได้ข้อสรุปที่ไม่ใช่เรื่องของภาษา
- ชนิดปลายทางกับธงเป็นคนตัดสิน ไม่ใช่ชื่อภาษา · Go กับ Kotlin ตอบตรงกันเพราะชนิดตรงกัน และไม่มีธงทั้งคู่ ส่วน Rust ถือชนิดเดียวกันแต่แยกออกไปเพราะมีธง
- สองภาษาไม่มีความกว้างของจำนวนเต็มให้หมด คือ Python ที่
intขยายได้ และ Node ที่เก็บค่าไว้ใน double - ยามมีอยู่แค่ในบางภาษา และมีแบบเดียวที่บังคับให้คุณเขียนทางแยก คือ
checked_subที่คืนNoneส่วนsaturating_subกับwrapping_subคืนตัวเลขที่ใช้ต่อได้ทันที
บทที่ 3 ไปที่ค่าที่อยู่ในช่วงเรียบร้อยแล้ว แต่ยังตอบผิดอยู่ดี คือค่าสัมบูรณ์ของ
Integer.MIN_VALUE ที่ Math.abs คืนออกมาเป็นค่าติดลบ โดยไม่มี exception สักตัว
-
Java 25 API —
java.lang.Math(เข้าถึง 2026-09-13) — ที่มาของsubtractExactที่หน้านี้ระบุว่าโยนArithmeticExceptionเมื่อผลลัพธ์ล้นintและของtoIntExactที่โยน exception ตัวเดียวกันเมื่ออาร์กิวเมนต์ล้นint -
Rust std — primitive type
u32(เข้าถึง 2026-09-13) — ที่มาของยามสามแบบ คือchecked_subที่หน้านี้อธิบายว่าคืนNoneเมื่อเกิดการล้น และsaturating_subกับwrapping_subที่อยู่ในรายการ method ของชนิดเดียวกัน -
The Go Programming Language Specification — Numeric types (เข้าถึง 2026-09-13) — ที่มาของการที่ Go ประกาศชนิดความกว้างตายตัวอย่าง
uint32กับint8ไว้ในภาษา และระบุว่าจำนวนเต็ม n bit เก็บด้วยการเข้ารหัสแบบ two’s complement -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/integer-semantics/measured.jsonซึ่งnpm run verify:integer-semanticsสร้างขึ้นใหม่ได้ · วัดบนrustc 1.97.1·go1.27.0·openjdk 25.0.4.1·kotlinc-jvm 2.4.10·node v24.19.0·Python 3.14.6บน darwin/arm64 เมื่อ 2026-09-12
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3`10 - 25` ของ Java ได้ `-15` ส่วน `Integer.toUnsignedString(10 - 25)` ได้ `4294967281` สองค่านี้ต่างกันเพราะอะไร