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

ทศนิยม​ที่​กลืน​จำนวนเต็ม และ​การ​แปลง​กลับ​ที่​ตัน​อยู่​ที่​ขอบ

ระบบ​ออ​เด​อร์​ของ​คุณ​ออก id ด้วย​ตัว​สร้าง​ที่​ให้​ค่า 64 bit ออ​เด​อร์​ใบ​หนึ่ง​ได้ id เป็น 9007199254740993 ซึ่ง​เป็น​ค่าที่ long ของ Java และ int64 ของ Go เก็บ​ได้​สบายๆ

id ใบ​นั้น​เดิน​ผ่าน service ตัว​หนึ่ง​ที่​รับ​ค่า​ไว้​ใน double ระหว่าง​ทาง ไม่มี​ใคร​เขียน​คำ​สั่ง​ให้​ปัด ไม่มี​การ cast ที่​มอง​เห็น​ได้​ใน​บรรทัด​นั้น และ​ไม่มี error ออก​มา​สัก​ตัว

ปลาย​ทาง​ได้ id เป็น 9007199254740992 ซึ่ง​น้อย​กว่า​ค่าที่​ต้นทาง​ส่ง​มา​อยู่​หนึ่ง และ​มัน​ยัง​เป็น id ที่​หน้าตา​ถูก​ต้อง​ทุก​ประการ ไม่มี​อะไร​ใน​ค่า​นั้น​บอก​ว่า​มัน​เคย​เป็น​ค่า​อื่น​มา​ก่อน

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

📦 orderctl — ของ​ชิ้น​เดียวกัน​กับ​คอร์ส​ภาษา​อื่น​ใน​เสา​นี้

orderctl คือ​เครื่อง​มือ​ประมวล​ผล​ออ​เด​อร์​ที่​คอร์ส​ภาษา​ใน​เสา​นี้​สร้าง​ซ้ำ​ที​ละ​ภาษา repo ตัวอย่าง กำลัง​จัด​ทำ ระหว่าง​นี้​ทุก​ตัวเลข​ใน​บท​นี้​รัน​เอง​ได้​ด้วย npm run verify:integer-semantics ซึ่ง​เรียก python3 scripts/integer-semantics/probe.py · ไม่​ต้อง​มี Docker และ​ไม่​ต้อง​ต่อ​เน็ต

double เก็บ​ส่วน​ของ​ค่า​ไว้ 53 bit จำนวนเต็ม​ที่​ใหญ่​กว่า​ช่วง​นั้น​จึง​ต้อง​ถูก​ปัด​ไป​หา​ค่าที่​แทน​ได้

IEEE 754 doubleIEEE 754 doubleรูป​แบบ​ทศนิยม 64 bit ที่​เก็บ​ส่วน​ของ​ค่า​ได้ 53 bit · จำนวนเต็ม​ที่​ใหญ่​กว่า​ช่วง​นั้น​ถูก​ปัด​ไป​ยัง​ค่าที่​แทน​ได้​ใกล้​ที่สุด โดย​ไม่มี​สัญญาณ​ใด​ออก​มา​เลย กว้าง 64 bit และ​ส่วน​ที่​เก็บ​ตัวเลข​จริง​ของ​มัน​กว้าง 53 bit เมื่อ​รวม bit ที่​ไม่​ได้​เขียน​ลง​ไป​ตรงๆ ด้วย จำนวนเต็ม​ทุก​ค่าที่​อยู่​ใน​ช่วง​นั้น​เก็บ​ได้​ครบ​ทุก​ค่า

พ้น​ช่วง​นั้น​ไป​แล้ว ค่าที่ double แทน​ได้​จะ​เริ่ม​ห่าง​กัน​ที​ละ​สอง แล้ว​ที​ละ​สี่ ค่าที่​ตก​อยู่​ระหว่าง​กลาง ต้อง​ถูก​ปัด​ไป​หา​ค่าที่​แทน​ได้​ใกล้​ที่สุด และ​ช่วง​ที่​เก็บ​ได้​ครบ​ทุก​ค่า​นี้​คือ​สิ่ง​ที่ JavaScript เรียก​ว่า safe integersafe integerช่วง​ของ​จำนวนเต็ม​ที่ double แทน​ได้​ตรง​ตัว​ทุก​ค่า · นอก​ช่วง​นี้​ค่า​สอง​ค่าที่​ต่าง​กัน​กลาย​เป็น​ค่า​เดียวกัน​ได้ และ Node ตอบ​ว่า 2**53 === 2**53+1 เป็น true

probe วัด​สอง​ทรง​นี้​ใน Node และ​ทั้ง​สอง​ทรง​เขียน​ลง​ใน​นิ​พจน์​ตรงๆ ไม่​ได้​อ่าน​ค่า​มา​จาก​ที่ไหน

  • 2 ** 53 === 2 ** 53 + 1 ให้ true
  • Number.isSafeInteger(9007199254740993) ให้ false

ทรง​แรก​คือ​ค่า​ทาง​คณิตศาสตร์​สอง​ค่าที่​ต่าง​กัน​อยู่​หนึ่ง แล้วก​ลาย​เป็น double ตัว​เดียวกัน === ไม่​ได้​ทำ​อะไร​ผิด มัน​เทียบ​สอง​ค่าที่​เท่า​กัน​จริง​หลัง​จาก​ที่​ทั้ง​คู่​ถูก​ปัด​มา​แล้ว

ทรง​ที่​สอง​คือ​ด่าน​ที่​ตอบ​คำถาม​นี้​ได้​ตรงๆ Number.isSafeInteger คืน false ให้ 9007199254740993 ซึ่ง​แปล​ว่า​ค่า​นี้​อยู่​นอก​ช่วง​ที่ double แทน​ได้​ครบ​ทุก​ค่า และ​ไม่มี​ใคร​เรียก​มัน​ถ้า​ไม่​สงสัย​อยู่​ก่อน

MDN อธิบาย​ช่วง​นี้​ไว้​ด้วย​ขนาด​ของ mantissa คือ double เก็บ mantissa ไว้ 52 bit จึง​แทน​จำนวนเต็ม ได้​ตรง​ตัว​เฉพาะ​ระหว่าง -(2**53 - 1) กับ 2**53 - 1 แล้ว​ยก​ตัวอย่าง​ว่าการ​บวก​หนึ่ง​กับ​การ​บวก​สอง เข้าไป​ที่​ขอบ​บน​ของ​ช่วง​นั้น​ให้​ค่าที่​เทียบ​กัน​แล้ว​เท่า​กัน ซึ่ง​เป็น​ผล​ที่​ผิด​ตาม​คณิตศาสตร์

ค่า​เดียวกัน​เดิน​ผ่าน double แล้ว​ออก​มา​ไม่​เท่า​เดิม

หัวข้อ​ที่​มีชื่อ​ว่า “ค่า​เดียวกัน​เดิน​ผ่าน double แล้ว​ออก​มา​ไม่​เท่า​เดิม”

ห้า​แถว​นี้​มา​จาก​สาม​ภาษา และ​สาม​แถว​ล่าง​ที่​ส่ง 9007199254740993 ผ่าน double ให้​ค่า​เดียวกัน​หมด

ทรง​ที่ probe รัน​จริง​เขียน​ไว้​คนละ​ภาษา จึง​ต้อง​อ่าน​รายการ​นี้​ก่อน​อ่าน​ตาราง

  • Node — เขียน 2 ** 53 === 2 ** 53 + 1 กับ Number.isSafeInteger(9007199254740993) ลง​ใน​นิ​พจน์​ตรงๆ
  • Java — ประกาศ long exact = 9007199254740993L แล้ว​เรียก (long) (double) exact ซึ่ง​แปลง​ขึ้น​เป็น double หนึ่ง​ครั้ง​แล้ว​แปลง​กลับ​ลง​มา​หนึ่ง​ครั้ง
  • Java — เรียก (long) Double.parseDouble("9007199254740993") โดย​รับ​ค่า​มา​จาก​สตริง ไม่​ได้​รับ​มา​จาก literal ที่​เป็น​จำนวนเต็ม
  • Go — เรียก strconv.ParseFloat("9007199254740993", 64) แล้ว​พิมพ์​ค่าที่​ได้​ด้วย %.0f
ทรงค่าที่​วัด​ได้
Node 2 ** 53 === 2 ** 53 + 1true
Node Number.isSafeInteger(9007199254740993)false
Java (long) (double) 9007199254740993L9007199254740992
Java (long) Double.parseDouble("9007199254740993")9007199254740992
Go strconv.ParseFloat("9007199254740993", 64) พิมพ์​ด้วย %.0f9007199254740992

สาม​แถว​ล่าง​ให้​ค่า​เดียวกัน​ทุก​ตัว​อักษร ทั้ง​ที่​เขียน​ด้วย​สอง​ภาษา​และ​มา​จาก​สาม​เส้น​ทาง สิ่ง​ที่​ตรง​กัน ไม่ใช่​การ​ตัดสิน​ใจ​ของ​ภาษา แต่​เป็น​ชนิด​ที่​รับ​ค่า​ไว้​ระหว่าง​ทาง ซึ่ง​เป็น double ทั้ง​สาม​เส้น

ค่าที่​ได้​กลับ​มา​คือ 9007199254740992 ซึ่ง​เป็น​ค่าที่ double แทน​ได้​ตรง​ตัว มัน​จึง​เป็น​จำนวนเต็ม ที่​ถูก​ต้อง​ทุก​ประการ และ​ไม่มี​อะไร​ใน​ตัว​มัน​บอก​ว่า​มัน​เคย​เป็น​ค่าที่​มากกว่า​นี้​อยู่​หนึ่ง

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

สอง​แถว​ล่าง​ไม่มี​ใคร​พิมพ์​การ​แปลง​ขึ้น​เป็น double ลง​ไป​เลย ชนิด​ที่​ตัว parse คืน​มา​เป็น​คน​ปัด

แถว​ของ (long) (double) exact มอง​เห็น​ได้​ว่า​มี double อยู่​กลาง​ทาง เพราะ​คน​เขียน​พิมพ์​การ​แปลง​นั้น ลง​ไป​เอง​ทั้ง​ขา​ขึ้น​และ​ขา​ลง

อีก​สอง​แถว​ไม่มี​ใคร​พิมพ์​ขา​ขึ้น​ลง​ไป ค่าที่​เข้า​มา​เป็น​สตริง และ​สิ่ง​ที่​ตัดสิน​ว่า​ค่า​จะ​เหลือ​เท่าไร คือ​ชนิด​ที่​ตัว parse คืน​กลับ​มา ซึ่ง​เป็น double ทั้ง​คู่

Double.parseDouble คืน​ค่า​เป็น double และ strconv.ParseFloat รับ 64 เป็น​ความ​กว้าง ของ​ปลาย​ทาง ทั้ง​สอง​ตัว​ทำงาน​สำเร็จ ไม่​ได้​โยน​อะไร​ออก​มา และ err ของ​ฝั่ง Go ที่ probe วัด​ไว้ เป็น​ค่า​ว่าง คือ​ไม่มี error

JLS §5.1.2 อธิบาย​แถว​ของ (long) (double) exact ไว้​ว่าการ​แปลง​ขึ้น​จาก long ไป double เสีย​ความ​แม่นยำ​ได้ ผล​ที่​ได้​คือ​ค่าที่​ปัด​มา​ถูก​ต้อง​ตาม​กฎ​การ​ปัด​ไป​หา​ค่าที่​ใกล้​ที่สุด และ​การ​แปลง​ขึ้น แบบ​นี้​ไม่​เคย​โยน exception ออก​มา​เลย

สอง​แถว​ของ​ตัว parse ไม่​ได้​เดิน​ผ่าน​การ​แปลง​ขึ้น​นั้น​เลย ค่า​ของ​มัน​ปัด​เพราะ​ชนิด​ที่​ตัว parse คืน​ออก​มา​เป็น double ตั้งแต่​แรก ไม่ใช่​เพราะ​มี​การ​แปลง​ขึ้น​เขียน​อยู่​ใน​บรรทัด ปลาย​ทาง​จึง​เท่า​กัน ทั้ง​สาม​แถว

เส้น​แบ่ง​กับ​บท​ที่ 6 และ​บท​ที่ 7

บท​นี้​เป็น​เจ้าของ​ค่าที่ double กลืน​ไป ไม่​ว่า​ค่า​นั้น​จะ​เดิน​เข้า​มา​ทางการ cast หรือ​ทาง​ตัว parse

บท​ที่ 6 เอา body ของ JSON ใบ​เดียวกัน​เดิน​ไป​สี่​ปลาย​ทาง แล้ว​วัด​ว่า​ชนิด​ปลาย​ทาง​เป็น​คน​ตัดสิน ส่วน​บท​ที่ 7 วัด​นโยบาย​ของ​ตัว parse ที่​คืน​ค่า​เป็น​จำนวนเต็ม ซึ่ง​แยก​ออก​ได้​สี่​แบบ

ค่า​ตั้ง​ต้น​คือ 1e19 ตัว​เดียวกัน แปลง​ลง​ชนิด 32 bit แล้ว​ได้​สอง​คำ​ตอบ​คนละ​ทิศ

ครึ่ง​แรก​ของ​บท​นี้​วัด​จำนวนเต็ม​ที่​เดิน​เข้าไป​ใน double ครึ่ง​หลัง​วัด​ทิศ​กลับ คือ​ทศนิยม​ที่​เดิน​ออก​มา เป็น​จำนวนเต็ม โดยที่​ค่า​ของ​มัน​ใหญ่​เกิน​ช่วง​ของ​ชนิด​ปลาย​ทาง​ไป​แล้ว

ทรง​ที่ probe รัน​มี​สาม​ทรง และ​ทั้ง​สาม​ใช้​ค่า​ตั้ง​ต้น​เดียวกัน

  • Java — ประกาศ double big = 1e19 แล้ว​เรียก (int) big
  • Rust — ประกาศ let big: f64 = black_box(1e19) แล้ว​เรียก big as i32
  • Node — เขียน 1e19 | 0 ลง​ใน​นิ​พจน์​ตรงๆ
ภาษานิพจน์ค่าที่​วัด​ได้กฎ​ที่​สเปก​ของ​ภาษา​เขียน​ไว้
Java(int) 1e192147483647ค่าที่​ใหญ่​เกิน​ไป​ให้​ผล​เป็น​ค่าที่​แทน​ได้​มาก​ที่สุด​ของ int
Rust1e19f64 as i322147483647ค่าที่​ใหญ่​กว่า​ค่า​มาก​ที่สุด​ของ​ชนิด​นั้น​จะ​ตัน​อยู่​ที่​ค่า​มาก​ที่สุด
Node1e19 | 0-1981284352ToInt32 นิยาม​ไว้​เป็นการ​หาร​เอา​เศษ ไม่ใช่​การ​ตัน

สอง​แถว​บน​ตัน​อยู่​ที่​ขอบ และ​ค่าที่​ได้​ยัง​อยู่​ฝั่ง​บวก​เหมือน​ค่า​ตั้ง​ต้น แถว​ล่าง​วน​กลับ​ไป​อีก​ฝั่ง แล้ว​เปลี่ยน​เครื่องหมาย​ของ​ค่า​ไป​ด้วย ทั้ง​ที่​ค่า​ตั้ง​ต้น​เป็น​ค่า​บวก​ทั้ง​สาม​แถว

JLS §5.1.3 เขียน​ขั้น​ตอน​ของ​ฝั่ง Java ไว้​เป็น​สอง​ขั้น ขั้น​แรก​ปัด​ทศนิยม​เข้าหา​ศูนย์​ให้​เป็น​จำนวนเต็ม แล้ว​ถ้า​จำนวนเต็ม​นั้น​ใหญ่​เกิน​กว่า​ที่ int แทน​ได้ ผล​ของ​ขั้น​แรก​คือ​ค่าที่​แทน​ได้​มาก​ที่สุด​ของ int ส่วน​ขั้น​ที่​สอง​ไม่​ได้​ทำ​อะไร​ต่อ​เมื่อ​ปลาย​ทาง​เป็น int อยู่​แล้ว

The Rust Reference เขียน​กฎ​ของ as ไว้​สั้น​กว่า​นั้น คือ​การ cast จาก​ทศนิยม​ไป​เป็น​จำนวนเต็ม​จะ​ปัด เข้าหา​ศูนย์ · NaN ให้ 0 · ค่าที่​ใหญ่​กว่า​ค่า​มาก​ที่สุด​ของ​ชนิด​ปลาย​ทาง​จะ​ตัน​อยู่​ที่​ค่า​มาก​ที่สุด และ​ค่าที่​เล็ก​กว่า​ค่า​น้อย​ที่สุด​จะ​ตัน​อยู่​ที่​ค่า​น้อย​ที่สุด

ฝั่ง JavaScript ไม่มี​การ​ตัน​อยู่​ใน​เส้น​ทาง​ของ | เลย ตัว​ดำเนิน​การ​นี้​ไป​ถึง ToInt32 ผ่าน NumberBitwiseOp ที่ §6.1.6.1.16 ซึ่ง​เรียก ToInt32 ใส่​ทั้ง​สอง​ข้าง​ก่อน​จะ​ทำ​อะไร​กับ bit และ ToInt32 ที่ §7.1.8 ส่ง​ค่า​ต่อ​ให้ ToFixedSizeInteger ที่ §7.1.7

ค่าที่​เข้าไป​ถึง ToFixedSizeInteger แล้ว​ไม่ใช่​อนันต์ จะ​เดิน​ผ่าน​สอง​ขั้น​นี้

  • ขั้น​แรก​เอา​ค่า​มา​หาร​เอา​เศษ​ด้วย 2 ยก​กำลัง​ความ​กว้าง ซึ่ง​กรณี​นี้​คือ 2 ยก​กำลัง 32
  • ขั้น​ที่​สอง​ถ้า​เศษ​ที่​ได้​ไม่​น้อย​กว่า 2 ยก​กำลัง 31 ให้​ลบ 2 ยก​กำลัง 32 ออก​จาก​มัน

ขั้น​ที่​สอง​คือ​ขั้น​ที่​พา​ค่า​ข้าม​ไป​ฝั่ง​ลบ และ​มัน​เป็นการ​ย้าย​การ​อ่าน bit ชุด​เดิม ไม่ใช่​การ​คำนวณ​ใหม่ ปลาย​ทาง​ของ | จึง​อยู่​ใน​ช่วง 32 bit ที่​มี​เครื่องหมาย​เสมอ ไม่​ว่า​ค่าที่​ใส่​เข้าไป​จะ​ใหญ่​แค่​ไหน

ทำไม Go ไม่มี​แถว​อยู่​ใน​ตาราง​นี้

สเปก​ของ Go เขียน​กฎ​ของ​การ​แปลง​นี้​ไว้​ตรงๆ ใน​หัวข้อ Conversions และ​คำ​ที่​มัน​ใช้​คือ implementation-dependent

The Go Programming Language Specification · Conversions
In all non-constant conversions involving floating-point or complex values, if the result
type cannot represent the value the conversion succeeds but the result value is
implementation-dependent.

เครื่อง​ที่ probe รัน​คือ go1.27.0 บน darwin/arm64 และ​ค่าที่​วัด​ได้​บน​เครื่อง​นั้น​คือ int32(1e19) ได้ 2147483647 กับ int64(1e19) ได้ 9223372036854775807

probe เก็บ​สอง​ค่า​นี้​ไว้​ใน​ช่อง architectureDependent ของ measured.json แล้ว​ไม่​ผูก​มัน​เป็น​ข้อ​อ้าง สัก​ข้อ สถาปัตยกรรม​อื่น​ตอบ​ต่าง​ออก​ไป​ได้​ตาม​สเปก และ CI ของ​ไซต์​นี้​รัน​บน linux/amd64 ซึ่ง​เป็น​คนละ สถาปัตยกรรม​กับ​เครื่อง​ที่​วัด

ค่าที่​สเปก​ไม่​ได้​ผูก​ไว้​จึง​ไม่ใช่​คำ​ตอบ​สากล และ​ตาราง​ของ​การ​แปลง 1e19 ลง​ชนิด 32 bit ข้าง​บน เป็น​ตาราง​ของ​คำ​ตอบ​สากล Go จึง​ไม่มี​แถว​อยู่​ใน​นั้น ไม่ใช่​เพราะ probe ไม่​ได้​วัด แต่​เพราะ​สิ่ง​ที่​วัด​ได้ เป็น​ของ​เครื่อง​หนึ่ง​เครื่อง

flowchart TD
  A["จำนวนเต็มเดินเข้ามาหา double"] --> B{"อยู่ในช่วงที่ double แทนได้ครบทุกค่าไหม"}
  B -->|"อยู่ในช่วง"| C["ค่าเดิมทุกบิต"]
  B -->|"เกินช่วง"| D["ปัดไปหาค่าที่แทนได้ใกล้ที่สุด · ไม่มีสัญญาณ"]
  D --> E{"แปลงกลับลงชนิดจำนวนเต็มไหม"}
  C --> E
  E -->|"ค่าอยู่ในช่วงของปลายทาง"| F["ตัดทศนิยมเข้าหาศูนย์"]
  E -->|"ค่าเกินช่วงของปลายทาง"| G{"สเปกของภาษาบอกว่าอย่างไร"}
  G -->|"Java และ Rust · ตันที่ขอบ"| H["2147483647"]
  G -->|"JavaScript · ToInt32 วนกลับ"| I["-1981284352"]
  G -->|"Go · implementation-dependent"| J["สเปกไม่ผูกค่า · ขึ้นกับเครื่อง"]

คำ​บรรยาย​ภาพ: ทาง​เดิน​สอง​ท่อน​ต่อ​กัน​อยู่​ใน​ภาพ​เดียว ท่อน​บน​คือ​การ​เสีย​ความ​แม่นยำ​ที่​ไม่มี​สัญญาณ ท่อน​ล่าง​คือ​การ​แปลง​กลับ​ที่​ปลาย​ทาง​แคบ​กว่า และ​ช่อง​ขวา​ล่าง​สุด​คือ​ช่อง​ที่​ไม่มี​ค่า​เขียน​อยู่ เพราะ​สเปก ของ​ภาษา​นั้น​ไม่​ได้​ผูก​ค่า​ไว้​ให้

ค่าที่​เปลี่ยน​ไป​ยัง​เป็น id ที่​หน้าตา​ถูก​ต้อง และ​มัน​เป็น id ขอ​งอ​อเด​อร์​ใบ​ที่​มี​อยู่​จริง​ได้

กลับ​ไป​ที่ id 9007199254740993 ที่​เปิด​บท​นี้ ค่าที่​ปลาย​ทาง​ได้​รับ​คือ 9007199254740992 ซึ่ง​เป็น​ค่าที่​ตัว​สร้าง id ชุด​เดียวกัน​ออก​ให้​ออ​เด​อร์​อีก​ใบ​ได้​เหมือน​กัน

  • ถ้า​ออ​เด​อร์​ใบ​ที่​ถือ id 9007199254740992 มี​อยู่​จริง คำ​สั่ง​อัปเดต​จะ​ลง​ที่​ออ​เด​อร์​ใบ​นั้น​แทน
  • ถ้า​ไม่มี ปลาย​ทาง​จะ​ตอบ​ว่า​หาไม่​เจอ แล้ว​ทีม​จะ​ไป​หา​สาเหตุ​ที่​ฝั่ง​ที่​ส่ง ซึ่ง​ไม่​ได้​ทำ​อะไร​ผิด​เลย

ทั้ง​สอง​ทาง​ไม่มี error ที่​ชี้​กลับ​มา​ที่​จุด​ที่​ค่า​เปลี่ยน เพราะ​จุด​นั้น​ไม่​ได้​คืน error อะไร​ออก​มา​เลย ตั้งแต่​แรก และ Number.isSafeInteger ก็​ตอบ​คำถาม​นี้​ให้​ได้​เฉพาะ​คน​ที่​เรียก​มัน

ฝั่ง​ยอด​เงิน​เป็น​รูป​เดียวกัน​แต่​คม​กว่า เพราะ​ยอด​ที่​เก็บ​เป็น​จำนวนเต็ม​ของ​หน่วย​ย่อย​จะ​โต​ขึ้น​เรื่อยๆ ตาม​จำนวน​หลัก​ของ​สกุล​เงิน​และ​ขนาด​ของ​บิล

คน​ที่​เขียน JavaScript แล้ว​อยาก​บังคับ​ให้​ค่า​เป็น​จำนวนเต็ม​มัก​เติม | 0 ต่อ​ท้าย​นิพจน์ ซึ่ง​สั้น​และ ได้​ผล​ตาม​ที่​คิด​ตราบ​ใด​ที่​ค่า​ยัง​เล็ก กฎ​ที่​สเปก​เขียน​ไว้​บอก​ว่า​ปลาย​ทาง​ของ​ตัว​ดำเนิน​การ​นี้​อยู่​ใน​ช่วง 32 bit เสมอ ค่าที่​วัด​ได้​จริง​จึง​เป็น 1e19 | 0 ได้ -1981284352 ซึ่ง​เป็น​ยอด​ติดลบ​จาก​ยอด​บวก

ราคา​ของ​การ​ไม่​ให้​ค่า​เดิน​ผ่าน double

ทาง​แก้​ทุก​ทาง​มี​ราคา และ​ราคา​คือ​สิ่ง​ที่​ทำให้​ทีม​เลือก​ไม่​แก้

  • ส่ง id เป็น​สตริง ราคา​คือ​ทุก​ฝั่ง​ต้อง​เปลี่ยน​ชนิด​พร้อม​กัน และ​การ​เรียง​ลำดับ​กับ​การ​เทียบ​ช่วง ของ id จะ​ไม่ใช่​การ​เทียบ​ตัวเลข​อีก​ต่อ​ไป
  • ใช้​ชนิด 64 bit ให้​ครบ​ทุก​ฝั่ง ราคา​คือ​ฝั่ง JavaScript ต้อง​ใช้ BigInt ซึ่ง​ผสม​กับ Number ใน​นิพจน์​เดียวกัน​ไม่​ได้ และ​ทุก​จุด​ที่​รับ​ค่า​เข้า​มา​ต้อง​แปลง​เอง
  • ตรวจ​ที่​ขอบ​ด้วย Number.isSafeInteger ราคา​คือ​ต้อง​รู้​ว่า​ขอบ​อยู่​ตรง​ไหน​บ้าง​ก่อน และ​ด่าน ที่​ตรวจ​หลัง​จาก​ค่า​เดิน​ผ่าน double มา​แล้ว​บอก​ได้​แค่​ว่า​ค่าที่​อยู่​ตรง​หน้า​ไม่​ปลอดภัย มัน​บอก​ไม่​ได้ ว่า​ค่า​เดิม​คือ​อะไร เพราะ​ค่า​เดิม​หาย​ไป​ก่อน​ที่​ด่าน​จะ​ได้​เห็น​มัน

ข้อ​สุดท้าย​คือ​ข้อ​ที่​พลาด​กัน​บ่อย​ที่สุด ด่าน​ต้อง​อยู่​ที่​จุด​ที่​ค่า​ยัง​เป็น​สตริง​หรือ​ยัง​เป็น​จำนวนเต็ม 64 bit ไม่ใช่​ที่​จุด​ที่​มัน​เป็น Number ไป​แล้ว ด่าน​ที่​ย้าย​มา​อยู่​หลัง double ไม่​ได้​เงียบ แต่​มัน​เตือน​สาย​เกิน​ไป จน​เหลือ​แต่​ค่าที่​ปัด​แล้ว​ให้​ดู

บท​นี้​ปิด​จุด​ที่​สี่ และ​ครึ่ง​แรก​ของ​มัน​เป็น​ครั้ง​แรก​ที่​ค่า​เปลี่ยน​ทั้ง​ที่​ชนิด​ปลาย​ทาง​กว้าง​พอ

ธี​สิ​ส​ของ​คอร์ส​นี้​คือ ค่า​จำนวนเต็ม​ค่า​หนึ่ง​เปลี่ยน​ได้​ห้า​จุด​ระหว่าง​ทาง​ผ่าน​ระบบ และ​ทุก​จุด​ใน​ห้า​จุด​นั้น เงียบ จุด​ที่​หนึ่ง​คือ​ตอน​ความ​กว้าง​หมด จุด​ที่​สอง​คือ​ตอน​คำ​ตอบ​ที่​ถูก​อยู่​นอก​ชนิด​เดิม จุด​ที่​สาม​คือ ตอน​ที่​แต่ละ​ภาษา​เลือก​ทิศ​การ​ปัด​ของ​การ​หาร​ไว้​ไม่​ตรง​กัน

จุด​ที่​สี่​คือ​จุด​ที่​บท​นี้​เพิ่ง​วัด และ​ครึ่ง​แรก​ของ​มัน​ต่าง​จาก​สาม​จุด​แรก​ตรง​ที่​ปลาย​ทาง​กว้าง​พอ long ของ Java เก็บ 9007199254740993 ได้​สบาย สิ่ง​ที่​แคบ​คือ​ชนิด​ที่​รับ​ค่า​ไว้​ระหว่าง​ทาง

ครึ่ง​หลัง​กลับ​ไป​มี​รูป​คล้าย​จุด​ที่​หนึ่ง คือ​ค่า​เกิน​ช่วง​ของ​ปลาย​ทาง​จริงๆ แต่​คำ​ตอบ​แยก​ออก​เป็น​สอง​แบบ ที่​ไม่​เหมือน​การ​ล้น​ของ​จำนวนเต็ม คือ​การ​ตัน​อยู่​ที่​ขอบ กับ​การ​วน​กลับ​ที่​สเปก​เขียน​ไว้​เป็นการ​หาร​เอา​เศษ

จุด​นี้​ยัง​เพิ่ม​ของ​อีก​อย่าง​ที่​สาม​จุด​แรก​ไม่มี คือ​ช่อง​ที่​สเปก​ของ​ภาษา​ไม่​ผูก​ค่า​ไว้​ให้​เลย ค่าที่​วัด​ได้ ใน​ช่อง​แบบ​นั้น​เป็น​ของ​เครื่อง​ที่​วัด ไม่ใช่​ของ​ภาษา และ​การ​เอา​มัน​ไป​วาง​ใน​ตาราง​ของ​คำ​ตอบ​สากล คือ​การ​อ้าง​เกิน​สิ่ง​ที่​พิสูจน์​ได้

บท​ที่ 6 ย้าย​ไป​ที่​จุด​ที่​ห้า คือ​พรมแดน​ของ​การ parse โดย​เอา body ของ JSON บรรทัด​เดียว​เดิน​ไป​สี่ ปลาย​ทาง แล้ว​วัด​ว่า​อะไร​เป็น​คน​ตัดสิน​ค่าที่​ออก​มา


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • ECMAScript® 2027 Language Specification — §7.1.8 ToInt32 และ §7.1.7 ToFixedSizeInteger (เข้า​ถึง 2026-09-13) — §7.1.8 ระบุ​ว่า ToInt32 ส่ง​ค่า​ต่อ​ให้ ToFixedSizeInteger โดย​ระบุ signed กับ​ความ​กว้าง 32 · §7.1.7 เขียน​สอง​ขั้น​ไว้​สำหรับ​ค่าที่​ไม่ใช่​อนันต์ คือ​หาร​เอา​เศษ​ด้วย 2 ยก​กำลัง​ความ​กว้าง แล้ว​ลบ 2 ยก​กำลัง​ความ​กว้าง​ออก เมื่อ​เศษ​นั้น​ไม่​น้อย​กว่า 2 ยก​กำลัง​ความ​กว้าง​ลบ​หนึ่ง

  • Number.MAX_SAFE_INTEGER — MDN (เข้า​ถึง 2026-09-13) — อธิบาย​ว่า double เก็บ mantissa ไว้ 52 bit จึง​แทน​จำนวนเต็ม​ได้​ตรง​ตัว เฉพาะ​ระหว่าง -(2**53 - 1) กับ 2**53 - 1 · และ​ระบุ​ว่า​คำ​ว่า safe ใน​ที่​นี้​หมาย​ถึง​การ​แทน​ค่า ได้​ตรง​ตัว​และ​เทียบ​ค่า​กัน​ได้​ถูก​ต้อง ไม่​ได้​หมาย​ถึง​อย่าง​อื่น

  • JLS SE 25 — §5.1.2 Widening Primitive Conversion และ §5.1.3 Narrowing Primitive Conversion (เข้า​ถึง 2026-09-13) — §5.1.2 ระบุ​ว่าการ​แปลง​ขึ้น​จาก long ไป double เสีย​ความ​แม่นยำ​ได้ ผล​ที่​ได้​คือ​ค่าที่​ปัด​มา​ถูก​ต้อง​แบบ​ปัด​ไป​หา​ค่าที่​ใกล้​ที่สุด และ​การ​แปลง​ขึ้น​ไม่​เคย​โยน exception · §5.1.3 เขียน​การ​แปลง​ลง​จาก​ทศนิยม​ไว้​เป็น​สอง​ขั้น และ​ระบุ​ว่า​ค่าที่​ใหญ่​เกิน​ไป​ให้​ผล​ของ​ขั้น​แรก เป็น​ค่าที่​แทน​ได้​มาก​ที่สุด​ของ int หรือ long

  • The Go Programming Language Specification — Conversions (เข้า​ถึง 2026-09-13) — ที่มา​ของ​ประโยค​ที่​ยก​มา​ใน​บท​นี้​ทั้ง​บรรทัด ซึ่ง​ระบุ​ว่าการ​แปลง​ที่​ไม่ใช่ ค่า​คงที่​และ​เกี่ยว​กับ​ทศนิยม จะ​สำเร็จ​เสมอ​เมื่อ​ชนิด​ปลาย​ทาง​แทน​ค่า​นั้น​ไม่​ได้ แต่​ค่าที่​ได้​เป็น implementation-dependent

  • The Rust Reference — Type cast expressions (เข้า​ถึง 2026-09-13) — ระบุ​ว่า as จาก​ทศนิยม​ไป​เป็น​จำนวนเต็ม​ปัด​เข้าหา​ศูนย์ · NaN ให้ 0 · ค่าที่​ใหญ่​กว่า​ค่า​มาก​ที่สุด​ของ​ชนิด​ปลาย​ทาง​ตัน​อยู่​ที่​ค่า​มาก​ที่สุด และ​ค่าที่​เล็ก​กว่า​ค่า​น้อย​ที่สุด ตัน​อยู่​ที่​ค่า​น้อย​ที่สุด

  • ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก scripts/integer-semantics/measured.json ซึ่ง scripts/integer-semantics/probe.py เขียน​ขึ้น​หลัง​ยิง​ทั้ง​ชุด​สาม​รอบ​ต่อ​การ​เรียก​หนึ่ง​ครั้ง · วัด​บน go1.27.0 openjdk 25.0.4.1 rustc 1.97.1 และ node v24.19.0 บน darwin/arm64 เมื่อ 2026-09-12

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

ข้อ 1 / 3

Node ตอบว่า `2 ** 53 === 2 ** 53 + 1` เป็นอะไร และเพราะอะไร