Số thực IEEE 754 — vì sao 0.1 + 0.2 không bằng 0.3
Cách máy lưu số thực bằng dấu/mũ/định trị, vì sao 0.1 không biểu diễn chính xác, và vì sao không bao giờ dùng float cho tiền. Kèm cách so sánh float đúng.
TL;DR: Máy lưu số thực theo chuẩn IEEE 754: chia bit thành 3 phần — dấu, mũ, định trị (mantissa). Vấn đề cốt lõi: 0.1 trong nhị phân là phân số tuần hoàn vô hạn, phải cắt ngắn → sai số nhỏ xuất hiện → tích luỹ qua nhiều phép tính. Hệ quả trực tiếp: không bao giờ so sánh float bằng ==, và không bao giờ dùng float/double cho tiền. Bài này giải thích cơ chế bên dưới để bạn hiểu vì sao — không chỉ biết luật.
Bạn mở Python, gõ 0.1 + 0.2, kỳ vọng 0.3. Kết quả hiện ra là 0.30000000000000004. Bạn thử JavaScript, Java, C — cùng kết quả kỳ lạ đó. Tệ hơn: một hệ thống thu tiền dồn 10.000 giao dịch mỗi ngày, mỗi giao dịch lệch 0.000000000000001 đồng — sau một năm sổ sách không khớp, kế toán tá hoả.
Bài này giải thích vì sao 0.1 + 0.2 không bằng 0.3, cơ chế bên dưới chuẩn IEEE 754, và cách lập trình viên xử lý số thực đúng trong code thực tế.
1. Analogy — bút chì và thước kẻ có giới hạn
Thử biểu diễn 1/3 bằng số thập phân: 0.333333... — vô hạn chữ số. Nếu tờ giấy chỉ có chỗ cho 10 chữ số, bạn viết 0.3333333333 rồi dừng. Giá trị đã mất chính xác một chút.
Nhân 0.3333333333 với 3 không ra đúng 1, mà ra 0.9999999999. Sai số tích luỹ từ lần cắt ngắn ban đầu.
Máy tính gặp đúng vấn đề đó với số thực — nhưng trong hệ nhị phân. Một số như 0.1 trong nhị phân là phân số tuần hoàn vô hạn, phải cắt tại giới hạn bit, dẫn đến sai số nhỏ không thể tránh.
| Đời thường | Số thực trong máy |
|---|---|
| Biểu diễn 1/3 dạng thập phân | Biểu diễn 0.1 dạng nhị phân |
| Phải cắt sau chữ số thứ 10 | Phải cắt sau bit thứ 52 (double) |
| Kết quả 0.3333333333, không phải 1/3 chính xác | Kết quả xấp xỉ 0.1, không phải 0.1 chính xác |
| Nhân lại × 3 không ra đúng 1 | Cộng 0.1 + 0.2 không ra đúng 0.3 |
Không phải lỗi của ngôn ngữ hay CPU — đây là giới hạn toán học. Nhị phân không thể biểu diễn chính xác mọi số thập phân, giống thập phân không thể biểu diễn chính xác 1/3.
2. Số thực được lưu thế nào trong bộ nhớ?
Chuẩn IEEE 754 (1985, cập nhật 2008) định nghĩa cách lưu số thực nhị phân. Ý tưởng: viết số dạng ký hiệu khoa học nhị phân, rồi lưu từng thành phần riêng.
Ký hiệu khoa học nhị phân:
(-1)^dau x 1.dinh_tri x 2^mu
(với số normalized; số subnormal xem phần Đào sâu)
Ví dụ: số thập phân 6.5 = 110.1 nhị phân = 1.101 × 2² → dấu=0, mũ=2, định trị=101.
Float 32-bit (single precision):
bit 31
bit 30–23
bit 22–0
Double 64-bit (double precision):
bit 63
bit 62–52
bit 51–0
| Loại | Tổng bit | Dấu | Mũ | Định trị | Chữ số thập phân chính xác |
|---|---|---|---|---|---|
| float (single) | 32 | 1 | 8 | 23 | ~7 chữ số |
| double | 64 | 1 | 11 | 52 | ~15–16 chữ số |
Mũ được lưu dạng biased exponent: cộng thêm một hằng số (127 với float, 1023 với double) để tránh cần bit dấu riêng cho mũ. Double có mũ 11 bit → biểu diễn được số cực lớn (lên đến khoảng 1.8 × 10³⁰⁸, tức DBL_MAX) và cực nhỏ (gần 0).
3. Vì sao 0.1 không biểu diễn chính xác
Đây là cơ chế quan trọng nhất của bài. Khi chuyển 0.1 sang nhị phân, bạn nhân phần thập phân liên tục với 2 và lấy phần nguyên:
0.1 × 2 = 0.2 → lay 0
0.2 × 2 = 0.4 → lay 0
0.4 × 2 = 0.8 → lay 0
0.8 × 2 = 1.6 → lay 1
0.6 × 2 = 1.2 → lay 1
0.2 × 2 = 0.4 → lay 0 (lap lai!)
...
Kết quả: 0.1 thập phân = 0.0001100110011... nhị phân — tuần hoàn vô hạn. IEEE 754 double chỉ có 52 bit cho định trị, phải cắt tại đó. Giá trị được làm tròn về số nhị phân gần nhất có thể biểu diễn được.
Số thực gần nhất với 0.1 mà double lưu được là:
0.1000000000000000055511151231257827021181583404541015625
Tương tự, số gần nhất với 0.2 là xấp xỉ 0.2 + sai số nhỏ, và khi cộng hai xấp xỉ đó lại, kết quả là xấp xỉ 0.3 nhưng không phải chính xác — mà là 0.30000000000000004.
# Minh hoa: 0.1 + 0.2 trong Python
x = 0.1
y = 0.2
print(x + y) # 0.30000000000000004
print(x + y == 0.3) # False -- so sanh sai
# In chuoi binary float thuc su luu trong RAM
import struct, binascii
packed = struct.pack('d', 0.1)
print(binascii.hexlify(packed)) # 9a9999999999b93f (little-endian)
// Tuong tu trong Java
double a = 0.1;
double b = 0.2;
System.out.println(a + b); // 0.30000000000000004
System.out.println(a + b == 0.3); // false
// JavaScript -- cung engine V8
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
4. Hệ quả: không so sánh float bằng ==
Vì mọi phép tính float đều có thể tích luỹ sai số, so sánh bằng == là bẫy phổ biến nhất với số thực.
// SAI -- co the false du nhin co ve bang nhau
if (a + b == 0.3) {
System.out.println("bang");
}
// DUNG -- so sanh voi epsilon (tolerance)
double eps = 1e-9; // nguong sai so chap nhan duoc
if (Math.abs((a + b) - 0.3) < eps) {
System.out.println("xap xi bang");
}
# Python -- tuong tu
import math
if math.isclose(a + b, 0.3, rel_tol=1e-9):
print("xap xi bang")
Chọn epsilon phù hợp với bài toán: tính vật lý (~1e-9), tính tiền (~1e-10), tính đồ hoạ (~1e-6). Không có epsilon "đúng cho mọi trường hợp".
5. Giá trị đặc biệt: NaN, Infinity, âm không
IEEE 754 dành một số tổ hợp bit cho các giá trị đặc biệt:
NaN (Not a Number): kết quả của phép tính không xác định như 0.0 / 0.0, Math.sqrt(-1), Infinity - Infinity. Đặc điểm kỳ lạ nhất: NaN không bằng chính nó.
double nan = Double.NaN;
System.out.println(nan == nan); // false (!)
System.out.println(Double.isNaN(nan)); // true -- cach kiem tra dung
import math
nan = float('nan')
print(nan == nan) # False
print(math.isnan(nan)) # True
Infinity và -Infinity: kết quả của 1.0 / 0.0 (trong Java/C — không phải exception với float!). Lan truyền qua phép tính: Infinity + 1 == Infinity.
System.out.println(1.0 / 0.0); // Infinity
System.out.println(-1.0 / 0.0); // -Infinity
System.out.println(1.0 / 0.0 > 1e308); // true
-0.0 (âm không): 0.0 == -0.0 trả về true trong hầu hết ngôn ngữ, nhưng 1.0 / 0.0 != 1.0 / -0.0 (khác dấu Infinity). Hiếm khi quan trọng trong thực tế nhưng cần biết.
flowchart LR
A["Phep tinh float"] --> B{"Ket qua?"}
B -->|"0/0 hoac sqrt(-1)"| C["NaN\n(khong bang chinh no)"]
B -->|"x/0 (x != 0)"| D["Infinity / -Infinity"]
B -->|"binh thuong"| E["float huu han\n(co sai so lam tron)"]
B -->|"qua nho gan 0"| F["Subnormal\n(mat precision)"]6. float vs double: khi nào đủ chính xác
float (32-bit) cho khoảng 7 chữ số thập phân chính xác; double (64-bit) cho khoảng 15–16.
| Dùng float (32-bit) khi | Dùng double (64-bit) khi |
|---|---|
Đồ hoạ 3D, shader, GPU (vec3 trong GLSL) | Tính khoa học, tài chính (nhưng xem bên dưới) |
| Lưu mảng hàng triệu điểm (tiết kiệm RAM) | Mặc định trong hầu hết ngôn ngữ |
| Sai số 0.001% chấp nhận được | Cần độ chính xác cao hơn float |
Trong Java và C#, double là kiểu mặc định cho literal số thực (như 3.14). Float phải thêm suffix f (vd 3.14f). Hầu hết CPU hiện đại tính double nhanh ngang float, nên không có lý do dùng float trừ khi tiết kiệm bộ nhớ là ưu tiên.
Số subnormal (denormal): IEEE 754 dành một dải đặc biệt cho số rất gần 0 mà không thể biểu diễn dạng chuẩn 1.xxx × 2^n. Subnormal mất dần các bit định trị (precision degrades gracefully) thay vì underflow thẳng về 0. Tuy nhiên, tính toán với subnormal chậm hơn đáng kể trên nhiều CPU (10–100× chậm hơn) vì phải xử lý phần mềm.
Catastrophic cancellation (triệt tiêu thảm hoạ): khi trừ hai số rất gần nhau, hầu hết bit có nghĩa triệt tiêu nhau, chỉ còn lại các bit nhiễu. Ví dụ: 1000000.1 - 1000000.0 = 0.1 nhưng cả hai số đều tích luỹ sai số → kết quả mất hoàn toàn chính xác.
# Vi du catastrophic cancellation
a = 1000000.1
b = 1000000.0
print(a - b) # 0.09999847412109375 -- chi con ~5 chu so chinh xac
Absorption (hấp thu): 1e16 + 0.1 == 1e16 trong double — số nhỏ 0.1 "biến mất" vì không có bit nào trong định trị đủ nhỏ để biểu diễn nó khi mũ đã lớn như vậy.
7. Áp dụng vào code của bạn
Tuyệt đối không dùng float/double cho tiền
Đây là lỗi có thể gây thiệt hại tài chính thật. Sai số nhỏ trong mỗi giao dịch tích luỹ thành số lệch sổ sách.
// SAI -- float/double cho tien
double price = 19.99;
double qty = 3;
double total = price * qty;
System.out.println(total); // 59.97 -- co ve dung, nhung...
double sum = 0.0;
for (int i = 0; i < 10; i++) sum += 0.1;
System.out.println(sum); // 0.9999999999999999 -- khong phai 1.0
System.out.println(sum == 1.0); // false
// DUNG -- dung BigDecimal cho tien
import java.math.BigDecimal;
import java.math.RoundingMode;
BigDecimal price = new BigDecimal("19.99"); // PHAI dung String, khong dung 19.99 literal!
BigDecimal qty = new BigDecimal("3");
BigDecimal total = price.multiply(qty);
System.out.println(total); // 59.97 -- chinh xac
BigDecimal sum = BigDecimal.ZERO;
for (int i = 0; i < 10; i++) {
sum = sum.add(new BigDecimal("0.1"));
}
System.out.println(sum); // 1.0 -- chinh xac
Lưu ý: new BigDecimal(19.99) — truyền double vào constructor — vẫn sai (double đã mất chính xác trước khi vào BigDecimal). Luôn dùng new BigDecimal("19.99") với chuỗi.
Cách khác phổ biến hơn cho hệ thống tiền: lưu bằng đơn vị nhỏ nhất (xu/cents) dưới dạng số nguyên (long). Tính toán hoàn toàn trên số nguyên, chỉ chuyển về đơn vị lớn khi hiển thị.
// Luu so du bang xu (cents), kieu long
long balanceCents = 199900L; // 1999.00 VND
long priceCents = 1999L; // 19.99 VND
long qty = 3;
long totalCents = priceCents * qty; // 5997 cents = 59.97 VND -- chinh xac tuyet doi
So sánh float đúng cách
// SAI
if (a == b) { ... }
// DUNG -- epsilon tuong doi cho so lon
double eps = Math.abs(a) * 1e-9 + 1e-15; // relative + absolute floor
if (Math.abs(a - b) < eps) { ... }
// Hoac trong Java -- cho so thuong
if (Double.compare(a, b) == 0) { ... } // xu ly NaN va -0.0 dung
# Python
import math
math.isclose(a, b, rel_tol=1e-9) # True neu |a-b| / max(|a|,|b|) < 1e-9
math.isclose(a, b, abs_tol=1e-12) # them absolute tolerance
// C# decimal -- tuong tu BigDecimal
decimal price = 19.99m; // suffix m cho decimal literal
decimal qty = 3m;
decimal total = price * qty; // 59.97 -- chinh xac
8. Liên hệ các bài khác
- Bài 01 — Bit, byte và hệ cơ số: hex giúp đọc các byte của float trong hex dump; nền tảng "n bit hữu hạn".
- Bài 02 — Số nguyên và tràn số: cùng gốc "số bit hữu hạn" — số nguyên gặp tràn số, số thực gặp mất chính xác (precision loss), hai mặt của một giới hạn.
- Bài 05 — Byte order và thao tác bit: endianness ảnh hưởng thứ tự byte khi đọc/ghi float vào file binary.
9. Tóm tắt
- IEEE 754 lưu số thực bằng 3 phần: dấu (1 bit) + mũ + định trị. Float 32-bit (1+8+23), double 64-bit (1+11+52).
- 0.1 trong nhị phân là phân số tuần hoàn vô hạn — phải cắt → sai số nhỏ không thể tránh. Đây là giới hạn toán học, không phải lỗi phần mềm.
- Float chính xác ~7 chữ số thập phân, double ~15–16. Sai số tích luỹ qua nhiều phép tính.
- Không so sánh float bằng
==— dùng epsilon (tolerance). Python:math.isclose, Java:Math.abs(a-b) < eps. - Giá trị đặc biệt:
NaN(không bằng chính nó — kiểm tra bằngisNaN()),Infinity/-Infinity,-0.0. - Tuyệt đối không dùng float/double cho tiền: dùng
BigDecimal(Java/C#) với constructor String,decimal(C#/Python), hoặc số nguyên đơn vị nhỏ nhất (cents). - Double là mặc định an toàn cho tính toán khoa học; float chỉ khi tiết kiệm bộ nhớ là ưu tiên (đồ hoạ, mảng điểm lớn).
10. Tự kiểm tra
Q1Vì sao 0.1 + 0.2 không bằng 0.3 trong hầu hết ngôn ngữ lập trình?▸
Q2IEEE 754 double (64-bit) chia 64 bit đó thành những phần nào? Mỗi phần bao nhiêu bit?▸
Q3Đoạn Java sau in gì? Giải thích tại sao.
System.out.println(Double.NaN == Double.NaN);▸
System.out.println(Double.NaN == Double.NaN);NaN theo chuẩn IEEE 754: NaN không bằng bất kỳ giá trị nào, kể cả chính nó. Lý do: NaN biểu diễn "kết quả không xác định" (0/0, sqrt(-1)...) và có nhiều bit pattern khác nhau đều là NaN — không có định nghĩa "NaN này bằng NaN kia". Hệ quả thực tế: không bao giờ kiểm tra NaN bằng ==; phải dùng Double.isNaN(x) (Java) hoặc math.isnan(x) (Python).Q4Tại sao new BigDecimal(19.99) vẫn sai, dù đã dùng BigDecimal?▸
19.99 trong Java là kiểu double. Trước khi vào constructor BigDecimal, giá trị đã bị làm tròn sang số nhị phân gần nhất (khoảng 19.9899999999999999289457264239899814128875732421875). BigDecimal nhận và lưu chính xác cái giá trị sai đó. Đúng cách phải dùng new BigDecimal("19.99") — truyền String để BigDecimal tự parse từ chuỗi thập phân, tránh bước chuyển đổi double sai lệch ở giữa.Q5Hệ thống thanh toán lưu số dư tài khoản bằng double. Vấn đề gì có thể xảy ra với 10 triệu giao dịch/ngày?▸
BigDecimal với String constructor, hoặc lưu bằng số nguyên đơn vị nhỏ nhất (xu/cents) dưới dạng long.Q6Khi nào nên dùng float (32-bit) thay vì double (64-bit)?▸
vec3 float vì GPU tối ưu cho 32-bit), mảng hàng triệu điểm đo lường (giảm một nửa RAM so với double). CPU máy tính để bàn hiện đại tính double nhanh ngang float, nên không có lợi về tốc độ. Tài chính, khoa học, mặc định hàng ngày: dùng double — hoặc tốt hơn nữa, BigDecimal cho tiền.Q7Vì sao 1e16 + 0.1 == 1e16 trong double?▸
Bài tiếp theo: Văn bản: Unicode và UTF-8
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên