Hiếm có hiện tượng khác thường nào trong khoa học máy tính lại gây ra nhiều sự bối rối cho các lập trình viên web như biểu thức nổi tiếng 0.1 + 0.2 !== 0.3. Trong môi trường runtime JavaScript, việc tính toán 0.1 + 0.2 sẽ trả về 0.30000000000000004.
Dù thường bị hiểu nhầm là một lỗi của ngôn ngữ, hành vi này thực chất là hệ quả trực tiếp và hoàn toàn có thể dự đoán được về mặt toán học từ Tiêu chuẩn Số học Dấu phẩy động IEEE 754 (IEEE 754 Standard for Floating-Point Arithmetic). Các JavaScript engine (như V8, JavaScriptCore và SpiderMonkey) đều triển khai tiêu chuẩn IEEE 754 cho mọi giá trị thuộc kiểu dữ liệu nguyên thủy number.
Việc thấu hiểu cấu trúc nhị phân chuẩn xác của số dấu phẩy động 64-bit sẽ làm sáng tỏ lý do tại sao các phân số thập phân cơ số 10 không thể kết thúc hữu hạn trong hệ nhị phân, cách các chế độ làm tròn tạo ra sai số nhỏ nhưng tích tụ dần, nguyên nhân khiến triệt tiêu thảm họa (catastrophic cancellation) phá hủy độ chính xác trong tính toán khoa học, và phương pháp xây dựng các engine toán học độ chính xác tùy ý sử dụng các bộ tính toán phía client-side.
1. Cấu trúc giải phẫu của một số IEEE 754 độ chính xác kép (Float64)
Đặc tả ECMAScript quy định rằng mọi giá trị number tiêu chuẩn trong JavaScript đều được lưu trữ dưới dạng giá trị dấu phẩy động nhị phân 64-bit độ chính xác kép theo chuẩn IEEE 754 (binary64).
Một thanh ghi dấu phẩy động 64-bit được chia thành ba trường bit (bitfield) chức năng: bit dấu (sign bit), số mũ phân vị (biased exponent), và phần định trị (significand, thường được gọi là fraction hoặc mantissa).
+-----------------------------------------------------------------------+
| IEEE 754 Double-Precision (64-bit) Register |
+-----------------------------------------------------------------------+
| Bit 63 Bits 62 - 52 (11 bits) Bits 51 - 0 (52 bits) |
| [ Sign (S) ] [ Biased Exponent (E) ] [ Fraction / Mantissa (M) ]|
| 1 bit 11 bits 52 bits |
+-----------------------------------------------------------------------+Phân tích chi tiết trường bit và giới hạn
- Bit dấu (): 1 bit (Bit 63). Xác định dấu toán học của giá trị. biểu diễn số dương, trong khi biểu diễn số âm.
- Số mũ phân vị (): 11 bits (Bit 62 đến 52). Mã hóa hệ số tỷ lệ theo lũy thừa của 2. Với 11 bit không dấu, . Một độ lệch (bias) là 1023 () được áp dụng, ánh xạ dải số mũ thực tế từ đến . Các giá trị cực trị và được dành riêng cho các biểu diễn đặc biệt (số 0, subnormal, vô cực và NaN).
- Phần định trị / Mantissa (): 52 bits (Bit 51 đến 0). Mã hóa các chữ số thập phân phân số của phần định trị đã chuẩn hóa. Vì các số đã chuẩn hóa luôn có chữ số nhị phân dẫn đầu là 1 (), bit 1 dẫn đầu này là bit ngầm định ("hidden bit") và không cần lưu trong bộ nhớ, mang lại độ chính xác hiệu dụng lên tới 53 bit nhị phân.
Công thức giải mã giá trị toán học
Đối với các số dấu phẩy động đã chuẩn hóa (), giá trị số thực được mã hóa bởi mẫu bit được xác định bởi:
Trong đó đại diện cho giá trị bit tại chỉ số phân số .
Công thức này đem lại độ chính xác tương đương khoảng 15.95 chữ số thập phân:
Bất kỳ phép tính thập phân nào yêu cầu độ chính xác vượt quá 15 đến 17 chữ số thập phân có nghĩa đều vượt quá giới hạn lưu trữ vật lý của một thanh ghi IEEE 64-bit.
2. Nghịch lý chuyển đổi phân số nhị phân: Tại sao 0.1 không thể biểu diễn hữu hạn
Để hiểu tại sao 0.1 + 0.2 !== 0.3, chúng ta phải truy nguyên quá trình các số phân số cơ số 10 được chuyển đổi sang phân số nhị phân cơ số 2.
Trong cơ số 10, một phân số có thể biểu diễn hữu hạn khi và chỉ khi các thừa số nguyên tố của mẫu số là ước của cơ số. Vì , các phân số như , và kết thúc hữu hạn, trong khi và lặp vô hạn tuần hoàn.
Trong cơ số 2 (nhị phân), thừa số nguyên tố duy nhất là 2. Do đó, một phân số kết thúc hữu hạn khi và chỉ khi mẫu số của nó là lũy thừa của 2 (). Những phân số có mẫu số chứa các thừa số nguyên tố khác ngoài 2 sẽ tuần hoàn vô hạn trong hệ nhị phân.
Chuyển đổi số thập phân 0.1 sang nhị phân
Việc chuyển đổi sang nhị phân được thực hiện bằng cách nhân liên tiếp với 2, trích xuất phần nguyên ở mỗi bước:
0.1 * 2 = 0.2 -> 0 (integer) remainder 0.2
0.2 * 2 = 0.4 -> 0 (integer) remainder 0.4
0.4 * 2 = 0.8 -> 0 (integer) remainder 0.8
0.8 * 2 = 1.6 -> 1 (integer) remainder 0.6
0.6 * 2 = 1.2 -> 1 (integer) remainder 0.2 <-- Loop begins here
0.2 * 2 = 0.4 -> 0 (integer) remainder 0.4
0.4 * 2 = 0.8 -> 0 (integer) remainder 0.8
0.8 * 2 = 1.6 -> 1 (integer) remainder 0.6
0.6 * 2 = 1.2 -> 1 (integer) remainder 0.2Dãy số rơi vào một chu kỳ lặp tuần hoàn vô hạn:
Khi chuẩn hóa sang ký hiệu khoa học với bit 1 ngầm định ở đầu, ta được:
+-----------------------------------------------------------------------+
| Truncation of Decimal 0.1 at 53 Bits |
+-----------------------------------------------------------------------+
| Stored Mantissa (52 bits + 1 hidden bit): |
| 1 . 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 |
| |
| Next bits discarded during rounding: 10011... |
| Because bit 54 is 1 followed by non-zero bits, the value rounds UP: |
| |
| Resulting 64-bit IEEE representation of 0.1: |
| 0.1000000000000000055511151231257827021181583404541015625 |
+-----------------------------------------------------------------------+Bởi vì thanh ghi chỉ chứa được 53 bit độ chính xác, chuỗi vô hạn này buộc phải được làm tròn. Theo quy tắc chuẩn làm tròn đến số gần nhất với trường hợp hòa hướng về số chẵn (round-to-nearest-ties-to-even) của IEEE 754, các bit phía sau buộc giá trị phải làm tròn lên.
Phép cộng số học: Từng bước tính (0.1 + 0.2)
Thực hiện phép cộng nhị phân đòi hỏi phải căn chỉnh số mũ của cả hai toán hạng:
Decimal 0.1 in float64:
Exponent: 2^-4 (biased E = 1019)
Mantissa: 1.1001100110011001100110011001100110011001100110011010_2
Decimal 0.2 in float64:
Exponent: 2^-3 (biased E = 1020)
Mantissa: 1.1001100110011001100110011001100110011001100110011010_2
Shift 0.1 mantissa right by 1 bit to match exponent 2^-3:
0.1100110011001100110011001100110011001100110011001101 (0) * 2^-3
+ 1.1001100110011001100110011001100110011001100110011010 * 2^-3
----------------------------------------------------------------------
= 10.0100110011001100110011001100110011001100110011001111 * 2^-3
Normalize sum: Shift right 1 bit and increment exponent to 2^-2:
= 1.0010011001100110011001100110011001100110011001100111 (1) * 2^-2Bit thứ 54 mang giá trị 1, khiến bit thấp nhất phải làm tròn lên:
Khi chuyển đổi ngược lại sang số thập phân, giá trị nhị phân này tương đương:
Trong khi đó, giá trị thập phân lý tưởng 0.3 khi làm tròn lại có dạng:
Vì hai giá trị xấp xỉ riêng biệt này chiếm các mẫu bit khác nhau trong bộ nhớ, biểu thức so sánh 0.1 + 0.2 === 0.3 cho kết quả chính xác là false.
3. Các giá trị biên đặc biệt: Số Subnormal, Số 0 có dấu, Vô cực và NaN
Đặc tả IEEE 754 định nghĩa các mẫu số mũ dành riêng để biểu diễn các trạng thái biên, hiện tượng tràn dưới dần dần (gradual underflow), các vô cực toán học và các giá trị bất định.
+-----------------------------------------------------------------------+
| IEEE 754 Special Value Encoding |
+-----------------------------------------------------------------------+
| Exponent (E) Fraction (M) Interpreted Value |
|-----------------------------------------------------------------------|
| 00000000000_2 All Zeros (0) Signed Zero (+0 or -0) |
| 00000000000_2 Non-Zero Subnormal (Denormalized) Number |
| 00000000001_2 Any Normalized Value |
| ... Any Normalized Value |
| 11111111110_2 Any Normalized Value |
| 11111111111_2 All Zeros (0) Signed Infinity (+Infinity / -Inf)|
| 11111111111_2 Non-Zero (MSB = 1) Quiet NaN (qNaN) |
| 11111111111_2 Non-Zero (MSB = 0) Signaling NaN (sNaN) |
+-----------------------------------------------------------------------+Số Subnormal: Hiện tượng tràn dưới dần dần
Khi các phép tính tạo ra các giá trị nhỏ hơn số dấu phẩy động dương đã chuẩn hóa nhỏ nhất (), một triển khai đơn giản sẽ lập tức ép giá trị về 0 (flush-to-zero). Điều này gây ra sự mất mát độ chính xác đột ngột khi trừ hai số nhỏ gần bằng nhau.
IEEE 754 giải quyết vấn đề này thông qua các số subnormal (số phi chuẩn) khi . Ở trạng thái này, bit dẫn đầu ngầm định chuyển từ 1 thành 0:
Các số subnormal cho phép tràn dưới từ từ xuống đến Number.MIN_VALUE (), dù việc xử lý các phép toán subnormal trên một số pipeline phần cứng có thể làm giảm hiệu năng.
Ý nghĩa vật lý của số 0 có dấu
Do IEEE 754 dành riêng một bit cho dấu, trong bộ nhớ tồn tại hai số không riêng biệt: số 0 dương (+0) và số 0 âm (-0).
Mặc dù ECMAScript định nghĩa +0 === -0 là true, hành vi của chúng lại khác nhau khi thực hiện phép chia:
console.log(1 / +0); // +Infinity
console.log(1 / -0); // -Infinity
console.log(Object.is(+0, -0)); // falseSố không có dấu đóng vai trò thiết yếu trong vật lý ứng dụng và giải tích phức, nơi việc tiếp cận một đường tiệm cận hoặc một nhánh cắt từ phía âm bắt buộc phải bảo toàn thông tin về pha.
Quiet NaN so với Signaling NaN
Khi một phép toán tạo ra kết quả toán học không xác định (chẳng hạn như hoặc ), IEEE 754 sẽ trả về NaN (Not-a-Number).
Một giá trị NaN được mã hóa với toàn bộ các bit số mũ bằng 1 () cùng phần định trị khác không. Nếu bit phân số có trọng số cao nhất (MSB) là 1, nó đại diện cho một Quiet NaN (qNaN), giá trị này lan truyền thầm lặng qua các biểu thức mà không kích hoạt ngắt phần cứng. Nếu bit đó là 0, nó đại diện cho một Signaling NaN (sNaN), được thiết kế để kích hoạt các bẫy ngắt phần cứng (hardware traps).
Bởi vì IEEE 754 quy định rằng NaN không bằng bất kỳ thứ gì, tính chất phản xạ của phép so sánh bằng sẽ thất bại:
const x = NaN;
console.log(x === x); // false
console.log(Number.isNaN(x)); // true4. Các sự cố trong thực tế: Trôi dạt tài chính và sai số tích lũy do làm tròn
Những sai lệch nhỏ trong số dấu phẩy động có thể tích tụ thành những sai số vận hành và tài chính nghiêm trọng khi mã nguồn phụ thuộc vào các kiểu dữ liệu nguyên thủy number tích hợp sẵn cho việc hạch toán kế toán hoặc các mô phỏng vật lý có số vòng lặp lớn.
+-----------------------------------------------------------------------+
| Financial Precision Drift Over Time |
+-----------------------------------------------------------------------+
| Transaction Loop (Adding $0.10 cents across 10,000,000 orders) |
| |
| Expected Balance: $1,000,000.00 |
| Actual float64: $1,000,000.000133288... |
| |
| Accumulated Drift: +$0.133 (Systemic accounting ledger imbalance) |
+-----------------------------------------------------------------------+Hiện tượng triệt tiêu thảm họa (Catastrophic Cancellation)
Hiện tượng triệt tiêu thảm họa xảy ra khi trừ hai số dấu phẩy động gần bằng nhau. Nếu cả hai toán hạng có chung 45 bit đầu tiên về độ chính xác, phép trừ sẽ triệt tiêu các bit dẫn đầu đó, đẩy các sai số làm tròn trước đây ở các bit đuôi trở thành những chữ số có nghĩa quan trọng nhất của kết quả.
Hãy xem xét việc tính giá trị với giá trị lớn :
function naiveDifference(x) {
return Math.sqrt(x + 1) - Math.sqrt(x);
}
console.log(naiveDifference(1e14)); // 5.000000413701855e-8
// True mathematical result: 5.000000000000001e-8
// Severe cancellation error: ~8.2% relative errorBiến đổi lại biểu thức theo phương pháp đại số để tránh phép trừ sẽ loại bỏ hoàn toàn hiện tượng triệt tiêu:
function stableDifference(x) {
return 1 / (Math.sqrt(x + 1) + Math.sqrt(x));
}
console.log(stableDifference(1e14)); // 5.000000000000001e-8 (Exact)Ranh giới số nguyên an toàn (Safe Integer Boundary)
Các số nguyên trong JavaScript được biểu diễn trong cùng phạm vi phân số 53 bit. Ranh giới mà tại đó các số nguyên liên tiếp không còn có thể được biểu diễn một cách duy nhất là:
Vượt qua giá trị này, khoảng cách giữa các số nguyên có thể biểu diễn được sẽ tăng gấp đôi lên 2, 4, 8, và xa hơn nữa:
const maxSafe = Number.MAX_SAFE_INTEGER;
console.log(maxSafe + 1 === maxSafe + 2); // true! Both evaluate to 9007199254740992Các hệ thống xử lý mã định danh cơ sở dữ liệu 64-bit hoặc các giao dịch khối lượng lớn tuyệt đối không được deserialize các giá trị này thành kiểu number mặc định của JavaScript.
5. Thiết kế kiến trúc toán học chính xác trên trình duyệt: Engine PetexSpace
Để loại bỏ hoàn toàn hiện tượng thiếu chính xác của số dấu phẩy động, engine máy tính với độ chính xác tùy ý của PetexSpace bỏ qua các biểu diễn xấp xỉ nhị phân IEEE 754. Bạn cũng có thể kiểm tra các cấu trúc dữ liệu số để phân tích trực tiếp các trường bit thô ngay trong trình duyệt của mình.
+-----------------------------------------------------------------------+
| PetexSpace Rational Arithmetic Engine |
+-----------------------------------------------------------------------+
| User Math Expression: "0.1 + 0.2 * 3.5" |
| | |
| v |
| [ Shunting-Yard Lexer & Tokenizer ] |
| | |
| v |
| [ Abstract Syntax Tree (AST) ] |
| | |
| v |
| [ Rational Number Evaluation Pipeline ] |
| - Every number stored as Fraction: { n: BigInt, d: BigInt } |
| - 0.1 --> Fraction(1n, 10n) |
| - 0.2 --> Fraction(2n, 10n) = Fraction(1n, 5n) |
| - 3.5 --> Fraction(35n, 10n) = Fraction(7n, 2n) |
| | |
| v |
| [ Exact Algebraic Operations ] |
| Addition: (a/b) + (c/d) = (a*d + b*c) / (b*d) |
| Multiplication: (a/b) * (c/d) = (a*c) / (b*d) |
| Simplification: Divide by Greatest Common Divisor (Euclid GCD) |
| | |
| v |
| Output: Exact Fraction -> Exact Decimal String (Zero Rounding Error) |
+-----------------------------------------------------------------------+Biểu diễn phân số hữu tỉ
Thay vì chuyển đổi số thập phân thành các xấp xỉ nhị phân cơ số 2, PetexSpace mô hình hóa mọi giá trị số dưới dạng một phân số hữu tỉ chính xác tuyệt đối gồm hai số nguyên BigInt có độ dài tùy ý: một tử số () và một mẫu số ().
// Pure TypeScript implementation of arbitrary-precision rational arithmetic
export class Rational {
public readonly n: bigint; // Numerator
public readonly d: bigint; // Denominator
constructor(numerator: bigint, denominator: bigint = 1n) {
if (denominator === 0n) {
throw new RangeError('Division by zero in Rational fraction');
}
// Preserve sign exclusively on numerator
const sign = (denominator < 0n) ? -1n : 1n;
const absD = denominator < 0n ? -denominator : denominator;
const absN = numerator < 0n ? -numerator : numerator;
const gcdValue = Rational.gcd(absN, absD);
this.n = (numerator * sign) / gcdValue;
this.d = absD / gcdValue;
}
// Euclidean algorithm for Greatest Common Divisor
private static gcd(a: bigint, b: bigint): bigint {
while (b !== 0n) {
const temp = b;
b = a % b;
a = temp;
}
return a;
}
// Parse decimal literal string into exact rational fraction
public static fromDecimalString(val: string): Rational {
const trimmed = val.trim();
const parts = trimmed.split('.');
if (parts.length === 1) {
return new Rational(BigInt(parts[0]), 1n);
}
const integerPart = parts[0];
const fractionPart = parts[1];
const denominator = 10n ** BigInt(fractionPart.length);
const numerator = BigInt(integerPart) * denominator + BigInt(fractionPart);
return new Rational(numerator, denominator);
}
public add(other: Rational): Rational {
// (a/b) + (c/d) = (a*d + b*c) / (b*d)
const num = this.n * other.d + other.n * this.d;
const den = this.d * other.d;
return new Rational(num, den);
}
public multiply(other: Rational): Rational {
// (a/b) * (c/d) = (a*c) / (b*d)
return new Rational(this.n * other.n, this.d * other.d);
}
public toString(decimalPlaces: number = 20): string {
const integerPart = this.n / this.d;
let remainder = this.n % this.d;
if (remainder === 0n) {
return integerPart.toString();
}
let result = `${integerPart.toString()}.`;
remainder = remainder < 0n ? -remainder : remainder;
for (let i = 0; i < decimalPlaces && remainder !== 0n; i++) {
remainder *= 10n;
result += (remainder / this.d).toString();
remainder %= this.d;
}
return result;
}
}Tính toán 0.1 + 0.2 với độ chính xác tuyệt đối
Khi người dùng nhập 0.1 + 0.2, engine sẽ thực thi:
const a = Rational.fromDecimalString("0.1"); // 1/10
const b = Rational.fromDecimalString("0.2"); // 2/10 -> simplified to 1/5
const sum = a.add(b); // (1*5 + 1*10)/(50) = 15/50 -> 3/10
console.log(sum.toString()); // Exactly "0.3"Bởi vì các phép tính được thực thi bằng số học số nguyên thuần túy trên các cấu trúc BigInt có độ dài tùy ý, hiện tượng sai số làm tròn, cắt cụt định trị và độ lệch số mũ hoàn toàn không thể phát sinh.
Việc vận hành các engine toán học ngay tại client-side trang bị cho các công cụ khoa học và tài chính độ chính xác toán học tuyệt đối, loại bỏ hoàn toàn độ trễ truyền tải qua server, đồng thời đảm bảo sự riêng tư trọn vẹn cho các phép tính nhạy cảm.