Dữ liệu & CPU
4/23
Bài 4 / 23~18 phútCó videoBiểu diễn dữ liệuMiễn phí lượt xem

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ườngSố thực trong máy
Biểu diễn 1/3 dạng thập phânBiểu diễn 0.1 dạng nhị phân
Phải cắt sau chữ số thứ 10Phải cắt sau bit thứ 52 (double)
Kết quả 0.3333333333, không phải 1/3 chính xácKế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 1Cộng 0.1 + 0.2 không ra đúng 0.3
💡 Cách nhớ

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):

Dấu
1 bit
bit 31
Mũ (Exponent)
8 bit
bit 30–23
Định trị (Mantissa)
23 bit
bit 22–0

Double 64-bit (double precision):

Dấu
1 bit
bit 63
Mũ (Exponent)
11 bit
bit 62–52
Định trị (Mantissa)
52 bit
bit 51–0
LoạiTổng bitDấuĐịnh trịChữ số thập phân chính xác
float (single)321823~7 chữ số
double6411152~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.

Thử ngẫmmột vòng lặp cộng dồn 0.1 mười triệu lần bằng double. Sai số cỡ 1e-16 mỗi lần cộng — sau mười triệu lần, kết quả còn tin được không? Bạn kiểm tra bằng cách nào?

Dây chuyền sinh sai số của 0.1: từ 0.1 thập phân, nhân đôi liên tục ra nhóm bit 1001 lặp vô hạn; double chỉ có 52 bit định trị nên cắt tại bit 52 và làm tròn; giá trị thật trong RAM là 0.1000000000000000055511151231257827; cộng với 0.2 cho 0.30000000000000004

Python
# 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)
Java
// 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
// 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.

Java
// 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
# 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ó.

Java
double nan = Double.NaN;
System.out.println(nan == nan);           // false (!)
System.out.println(Double.isNaN(nan));    // true -- cach kiem tra dung
Python
import math
nan = float('nan')
print(nan == nan)       # False
print(math.isnan(nan))  # True

Infinity-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.

Java
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.

Những giá trị này không phải "kết quả lạ" phát sinh lúc chạy — chúng là các mẫu bit được chuẩn giữ chỗ sẵn. Nhìn vào 11 bit mũ là biết một double thuộc loại nào:

Bảng mũ 11 bit và định trị 52 bit quyết định giá trị double: mũ toàn 0 cho không hoặc số subnormal; mũ từ 1 tới 2046 cho số thường; mũ toàn 1 với định trị bằng 0 cho Infinity, với định trị khác 0 cho NaN — họ mẫu bit khiến mọi so sánh với NaN đều false

Thử ngẫmNaN == NaN cho false, nhưng thêm hai giá trị NaN vào một HashSet trong Java, kích thước tập hợp là 1 hay 2? Gợi ý: equals() không dùng ==.

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) khiDù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 đượcCầ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.

📚 Đào sâu (tuỳ chọn) — Số subnormal và catastrophic cancellation

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.

Python
# 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.

Java
// 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
Java
// 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ị.

Java
// 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

Java
// 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
# 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
CSHARP
// 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

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ằng isNaN()), 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

Tự kiểm tra
0/7 câu đã trả lời
  1. Q1
    Vì sao 0.1 + 0.2 không bằng 0.3 trong hầu hết ngôn ngữ lập trình?
  2. Q2
    IEEE 754 double (64-bit) chia 64 bit đó thành những phần nào? Mỗi phần bao nhiêu bit?
  3. Q3
    Đoạn Java sau in gì? Giải thích tại sao.
    System.out.println(Double.NaN == Double.NaN);
  4. Q4
    Tại sao new BigDecimal(19.99) vẫn sai, dù đã dùng BigDecimal?
  5. Q5
    Hệ 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?
  6. Q6
    Khi nào nên dùng float (32-bit) thay vì double (64-bit)?
  7. Q7
    Vì sao 1e16 + 0.1 == 1e16 trong double?

Bài tiếp theo: Văn bản: Unicode và UTF-8

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Đặt 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

Bài tiếp theo

Văn bản: Unicode và UTF-8 — vì sao tiếng Việt thành ô vuông