0.1 + 0.2 ≠ 0.3

หนังสยองขวัญของ dev ทุกคน — ผ่าโครงสร้าง float64 บิตต่อบิตให้ดูกันจะๆ

Episode 01ตอนที่ 1: ผลลัพธ์ที่ไม่มีใครอยากเห็น

ลองบวกเลขง่ายๆ ดูสิ

กด = แล้วดูว่า JavaScript ตอบว่าอะไร — เดี๋ยวจะรู้ว่าทำไม dev ทุกคนเคยเจอบั๊กนี้อย่างน้อยครั้งหนึ่งในชีวิต

JavaScript ตอบว่า0.1 + 0.2 = 0.30000000000000004
ค่าที่เก็บจริงในเครื่อง (exact, ทุกหลัก)0.3000000000000000444089209850062616169452667236328125

ไม่ตรง! คุณคาดว่าจะได้ 0.3 แต่ได้ 0.30000000000000004 — นี่คือบั๊กสยองที่ทั้งบทความนี้จะอธิบาย

Episode 02ตอนที่ 2: ผ่าพิสูจน์ 64 บิต

พิมพ์เลขอะไรก็ได้ แล้วดูโครงกระดูกข้างใน

ตัวเลขทุกตัวใน JavaScript คือ IEEE 754 double (float64) — 64 บิตที่แบ่งเป็น 3 ส่วน: เครื่องหมาย (1) เลขชี้กำลัง (11) และแมนทิสซา (52) แตะบิตไหนก็ได้เพื่อดูความหมาย

sign (1 บิต)exponent (11 บิต)mantissa (52 บิต)

แตะหรือชี้เมาส์ไปที่บิตไหนก็ได้ เพื่อดูว่าบิตนั้นมีความหมายว่าอะไร

Hex (raw 64-bit pattern)0x3fb999999999999a
ค่าที่เก็บจริงแบบ exact decimal (คำนวณด้วย BigInt เอง ไม่ใช่ toString)0.1000000000000000055511151231257827021181583404541015625
Episode 03ตอนที่ 3: สวิตช์ในห้องใต้ดิน

กดบิตเปิด/ปิด แล้วไล่เข้าหา 0.1 ดูสิ

เลขฐานสองเก็บเศษส่วนได้แค่ 1/2, 1/4, 1/8, 1/16, ... (ยกกำลังสองในตัวหาร) รวมกันกี่บิตก็ตาม 0.1 ไม่มีทางรวมได้พอดี — ได้แค่ "ใกล้" ขึ้นเรื่อยๆ

ผลรวมตอนนี้ (exact, ไม่มีปัดเศษ)0

ยังห่างจาก 0.1 อยู่ 0.100000 — ไม่ว่าจะเปิดบิตเพิ่มอีกกี่ตัว (ในจำนวนจำกัด) ก็ไม่มีวันถึง 0.1 พอดี

เทียบกับ 1/3 ในเลขฐานสิบ

1/3 = 0.333333... ไม่จบไม่สิ้นในฐานสิบ เพราะ 3 ไม่ใช่ตัวประกอบของ 10 — เลขฐานสองก็เจอปัญหาเดียวกันกับ 0.1 เพราะ 10 (ตัวหารของ 1/10) ไม่ใช่เลขยกกำลังสอง คอมพิวเตอร์เก็บเลขฐานสองเท่านั้น เลยเก็บ 0.1 แบบ "ปัดเศษที่ใกล้ที่สุด" แทนที่จะเก็บค่าจริง เหมือนที่เราเขียน 1/3 ≈ 0.333 แล้วปัดทิ้ง

Episode 04ตอนที่ 4: แฟ้มคดีที่ปิดไม่ลง

เมื่อบั๊กตัวเลขไม่ได้แค่ทำให้หน้าเว็บเพี้ยน

บั๊กตระกูล "ตัวเลขไม่ตรงกับที่คาด" เคยฆ่าคน ล้างมูลค่าตลาดหุ้น และทำจรวดระเบิดมาแล้ว

Classifiedการสะสมของ error จากเลข 0.1 ที่เก็บไม่ตรง

Patriot Missile — Dhahran

25 กุมภาพันธ์ 1991

ระบบป้องกันขีปนาวุธ Patriot ที่ Dhahran ซาอุดีอาระเบีย พลาดสกัด Scud missile เพราะนาฬิกาภายในคลาดเคลื่อน ตัวระบบนับเวลาด้วยรีจิสเตอร์ fixed-point 24 บิต แล้วคูณด้วยค่าประมาณของ 0.1 วินาทีต่อ tick — 0.1 ไม่มีทางเก็บได้ตรงในเลขฐานสอง (เหตุผลเดียวกับที่คุณเพิ่งเห็นใน Episode 1-3 เป๊ะ ต่างแค่เป็น fixed-point ไม่ใช่ float) หลังระบบทำงานต่อเนื่องกว่า 100 ชั่วโมง ความคลาดเคลื่อนสะสมถึงราว 0.34 วินาที มากพอให้คำนวณตำแหน่งเป้าหมายพลาดไปหลายร้อยเมตร ผลคือมีผู้เสียชีวิต 28 นาย

Classifiedtruncate แทน round — ไม่ใช่บั๊ก float โดยตรง แต่โชว์ว่า error เล็กๆ สะสมได้เละแค่ไหน

Vancouver Stock Exchange Index

1982–1983

ดัชนีหุ้น Vancouver Stock Exchange เริ่มที่ 1,000 จุด แต่ระบบคำนวณใหม่ราว 3,000 ครั้งต่อวัน โดยใช้ floor() (ตัดทิ้งทศนิยมตำแหน่งที่ 3) แทนการปัดเศษ ทำให้ทุกครั้งสูญค่าน้อยๆ สะสมไปเรื่อยๆ ผ่านไป 22 เดือน ดัชนีที่ควรอยู่ราว 1,009.811 กลับเหลือแค่ 524.811 — เกือบครึ่งเดียว ก่อนจะแก้ไขและปรับดัชนีใหม่ในปลายเดือนพฤศจิกายน 1983

Classifiedfloat→int overflow — คนละบั๊กกับ 0.1+0.2 แต่ตระกูล numeric เดียวกัน

Ariane 5 Flight 501

4 มิถุนายน 1996

จรวด Ariane 5 ระเบิดหลังปล่อยตัวได้ 37 วินาที ต้นเหตุคือระบบนำร่องแปลงค่า horizontal bias ซึ่งเป็น float64 ไปเป็นจำนวนเต็ม 16 บิต แต่ Ariane 5 บินเร็วกว่า Ariane 4 (ที่โค้ดถูกเขียนมาสำหรับ) ค่าจึงเกินขอบเขตที่ integer 16 บิตเก็บได้ เกิด overflow exception ทั้งระบบหลักและสำรองพังพร้อมกันเพราะรันโค้ดเดียวกัน — หมายเหตุ: นี่ไม่ใช่บั๊กความแม่นยำของทศนิยมแบบ 0.1+0.2 แต่เป็นปัญหา "ตัวเลขไม่พอดีกับที่ที่เก็บมัน" ในตระกูล numeric เดียวกัน

Episode 05 · Status: Survivedตอนที่ 5: รอดชีวิต

รอดจากบั๊กนี้ยังไง

1. อย่าเทียบ float ด้วย === ใช้ Number.EPSILON

+

0.1 + 0.2 === 0.3false
Math.abs(sum - 0.3) < Number.EPSILONtrue

Number.EPSILON = 2.220446049250313e-16 — ช่องว่างที่เล็กที่สุดระหว่าง 1 กับเลข float64 ตัวถัดไป ใช้เป็น "ระยะยอมรับ" เวลาเทียบผลลัพธ์จากการคำนวณ float (ไม่ใช่เทียบ input ดิบๆ)

2. เงินบาท: เก็บเป็นสตางค์ (integer) มุมไทย

+บาท

บวกแบบ float ตรงๆ: 0.30000000000000004 บาท
บวกผ่านสตางค์ (10 + 20 สตางค์ ซึ่งเป็น int ล้วนๆ): 0.30 บาท (=0.3)

ระบบเงินบาทมีหน่วยเล็กสุดคือสตางค์ (1 บาท = 100 สตางค์) เก็บทุกยอดเป็น "จำนวนสตางค์แบบ integer" แล้วค่อยหาร 100 ตอนแสดงผลเท่านั้น — จำนวนเต็มบวกลบกันไม่มีทางคลาดเคลื่อนแบบ float

3. toFixed() ใช้แสดงผลเท่านั้น อย่าใช้ตัดสินใจ

(0.1 + 0.2).toFixed(2) ได้ "0.30" — ดูเหมือนถูก แต่ค่าจริงข้างในยังเป็น 0.30000000000000004 อยู่ ถ้าเอาไปเทียบ === ต่อจะพังเหมือนเดิม ใช้ตอน "โชว์ตัวเลขให้คนอ่าน" เท่านั้น

4. เมื่อไหร่ต้องพก BigInt / decimal library

งานบัญชี/การเงินที่ต้องแม่นสัมบูรณ์, จำนวนเต็มเกิน 2^53, หรือคำนวณวิทยาศาสตร์ที่สะสม error ไม่ได้เลย — ใช้ BigInt (ถ้าเป็นจำนวนเต็มล้วนๆ) หรือ decimal library เช่น decimal.js / big.js(ถ้าต้องมีทศนิยมแม่นยำสูง) งานทั่วไปที่ error เล็กน้อยยอมรับได้ (กราฟิก, เกม, UI) ใช้ float ตามปกติได้สบายๆ

Cheat sheet (copy ได้)

// 1) อย่าเทียบ float ด้วย ===
if (Math.abs(a - b) < Number.EPSILON) { /* ใกล้พอ ถือว่าเท่ากัน */ }

// 2) เงิน: เก็บเป็นหน่วยเล็กสุด (satang) เป็น integer
const satang = Math.round(baht * 100);        // เก็บ/บวก/ลบด้วย int
const bahtBack = satang / 100;                 // แปลงกลับตอนโชว์ผลเท่านั้น

// 3) toFixed() = แค่จัดการแสดงผล ไม่แก้ค่าจริงที่เก็บ
(0.1 + 0.2).toFixed(2); // "0.30" (ดูตรง แต่ค่าจริงยังเพี้ยนอยู่ข้างใน)

// 4) ต้องแม่นสัมบูรณ์ (บัญชี, วิทยาศาสตร์, จำนวนเต็มใหญ่)?
//    ใช้ BigInt (จำนวนเต็มเท่านั้น) หรือ decimal library (เช่น decimal.js)