Có hai loại cân hỏng. Loại thứ nhất kêu "Err" và không hiện số — bạn biết ngay là hỏng, đem đi sửa. Loại thứ hai tệ hơn nhiều: nó vẫn hiện một con số trông đàng hoàng, nhưng lệch đi vài gram, và bạn chỉ phát hiện khi cuối ngày kiểm kê thấy thiếu hàng. Số học máy tính là cái cân loại hai: nó không bao giờ kêu "lỗi", chỉ lặng lẽ cho ra con số sai. Và lỗi cho-số-sai khó chịu hơn hẳn lỗi làm chương trình sập — chương trình sập thì có stack trace, có dòng lỗi, có chỗ để bắt đầu; còn con số sai thì nằm im trong báo cáo suốt mấy tháng, tới khi kế toán hỏi vì sao tổng tiền lệch mười nghìn đồng.

Bốn chỗ dưới đây là nơi Java lặng lẽ cho ra kết quả sai nhiều nhất. Cả bốn đều không phải lỗi của Java — chúng là hệ quả trực tiếp của cách máy tính biểu diễn số — nhưng nếu không biết thì bạn sẽ gặp chúng theo cách khó chịu nhất.

Chia hai số nguyên thì mất phần thập phân

int a = 7, b = 2;
System.out.println(a / b);

In ra 3, không phải 3.5. Khi cả hai toán hạng đều là số nguyên, Java làm phép chia nguyên và cắt bỏ phần thập phân — không làm tròn, cắt thẳng.

Cái bẫy thật không nằm ở ví dụ trên mà ở đây:

double tyLe = soDaXong / tongSo;      // cả hai đều là int

Vế phải tính xong mới gán vào double. Nếu soDaXong = 3 và tongSo = 4 thì phép chia cho ra 0, rồi mới chuyển thành 0.0. Kết quả: thanh tiến độ của bạn đứng yên ở 0% cho tới khi hoàn thành hẳn.

Sửa bằng cách ép kiểu trước khi chia:

double tyLe = (double) soDaXong / tongSo;   // 0.75

Chỉ cần ép một vế, vế kia tự động được nâng theo.

Số âm chia không như bạn nghĩ

Đây là chỗ ngay cả người viết Java lâu năm cũng hay nhầm:

 7 /  2 = 3
-7 /  2 = -3
-7 %  2 = -1
Math.floorDiv(-7,2) = -4
Math.floorMod(-7,2) = 1

Java cắt về phía số không, chứ không cắt xuống. Nên -7 / 2 ra -3 chứ không phải -4. Và phép lấy dư giữ dấu của số bị chia, nên -7 % 2 ra -1 chứ không phải 1.

Chuyện này gây lỗi thật ở đâu? Ở mọi chỗ dùng % để chia nhóm theo vòng:

// Lấy phần tử kế tiếp theo vòng tròn — hỏng khi chỉ số âm
int ke = (viTri + buoc) % danhSach.size();
danhSach.get(ke);       // IndexOutOfBoundsException nếu ke âm

Với buoc âm, ke có thể âm và get nổ ngay. Dùng Math.floorMod thì kết quả luôn không âm:

int ke = Math.floorMod(viTri + buoc, danhSach.size());

Tôi coi đây là một trong những phương thức bị dùng ít hơn mức nó xứng đáng nhất trong thư viện chuẩn.

Tràn số: cộng hai số dương ra số âm

MAX_VALUE + 1 = -2147483648

int có 32 bit. Cộng vượt giá trị lớn nhất thì nó quay vòng về giá trị nhỏ nhất, im lặng, không ngoại lệ, không cảnh báo.

Nghe có vẻ chỉ là chuyện học thuật cho tới khi bạn nhìn dòng mã kinh điển này:

int giua = (thap + cao) / 2;      // tìm kiếm nhị phân

Với mảng lớn, thap + cao vượt quá hai tỷ và tràn thành số âm, giua thành số âm, và tìm kiếm nhị phân nổ. Lỗi này từng nằm trong chính java.util.Arrays của JDK suốt chín năm trước khi có người phát hiện năm 2006. Cách viết đúng:

int giua = thap + (cao - thap) / 2;

Khi cần biết chắc là không tràn, dùng nhóm phương thức Math.*Exact:

Cộng có kiểm  = ArithmeticException: integer overflow

Math.addExact, subtractExact, multiplyExact ném ngoại lệ thay vì quay vòng lặng lẽ. Với mã tính tiền, tính số lượng tồn kho, hay bất cứ thứ gì mà một con số sai gây hậu quả thật, tôi luôn dùng chúng. Đắt hơn một chút, nhưng đổi lại lỗi hiện ra ngay tại chỗ thay vì hiện ra ở báo cáo cuối tháng.

Một mẹo nhỏ: long cũng tràn, chỉ là ngưỡng cao hơn nhiều. Nếu đang tính mili giây, int tràn sau khoảng 24 ngày — đủ để một dịch vụ chạy êm cả tháng rồi bắt đầu cho ra số âm.

Số thực: 0.1 + 0.2 không bằng 0.3

0.1 + 0.2      = 0.30000000000000004
0.1+0.2 == 0.3 : false

Không phải lỗi của Java. double tuân theo chuẩn IEEE 754, biểu diễn số dưới dạng nhị phân, và 0,1 trong hệ nhị phân là một số vô hạn tuần hoàn — hệt như 1/3 trong hệ thập phân. Cắt bớt ở bit thứ 53 thì sai số xuất hiện. Mọi ngôn ngữ dùng IEEE 754 đều thế, Python và JavaScript cũng vậy.

Hệ quả thực tế:

Đừng bao giờ so sánh số thực bằng ==. So bằng sai số cho phép:

if (Math.abs(a - b) < 1e-9) { ... }

Đừng dùng double cho tiền. Cộng vài nghìn giao dịch là sai số tích lại tới mức nhìn thấy được trên hoá đơn. Dùng BigDecimal:

BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b));            // 0.3 — chính xác

Chú ý là tôi truyền chuỗi vào hàm khởi tạo. new BigDecimal(0.1) nhận vào một double đã sai sẵn, nên nó chép lại nguyên cả sai số — kết quả in ra là 0.1000000000000000055511151231257827021181583404541015625. Đây là cái bẫy nằm ngay bên trong giải pháp cho cái bẫy trước đó.

Và với BigDecimal, so sánh cũng có bẫy riêng: equals so cả số chữ số thập phân, nên new BigDecimal("1.0").equals(new BigDecimal("1.00")) là false. Muốn so giá trị thì dùng compareTo(...) == 0.

float thì còn tệ hơn. Nó chỉ có 24 bit phần định trị, tức khoảng 7 chữ số có nghĩa. (float) 16777217 cho ra 1.6777216E7 — con số bị làm tròn mất đúng 1 đơn vị. Trong ứng dụng nghiệp vụ, gần như không có lý do gì để dùng float thay vì double.

Ép kiểu: hai hướng, hai mức độ nguy hiểm

Nới rộng thì tự động, vì không mất mát gì:

byte → short → int → long → float → double

Thu hẹp thì phải ép tay, vì có thể mất dữ liệu:

int lon = 300;
byte nho = (byte) lon;    // -44, vì byte chỉ chứa tới 127

Java không cảnh báo. Nó tin rằng bạn viết dấu ngoặc ép kiểu ra tức là bạn biết mình đang làm gì.

Có hai chỗ trong bảng trên đáng ngạc nhiên: int → float và long → double là ép nới rộng tự động, nhưng vẫn mất chính xác, vì float không đủ bit để giữ mọi giá trị int. Đó chính là ví dụ 16777217 ở trên. Java xếp chúng vào loại tự động vì không mất độ lớn, chỉ mất độ chính xác.

Còn char thì đặc biệt: nó là số không dấu 16 bit, nên chuyển sang int được, nhưng int sang char phải ép tay.

Toán tử ba ngôi và một cái bẫy nữa

Integer x = dieuKien ? 1 : layGiaTri();   // layGiaTri() trả về Integer, có thể null

Nếu layGiaTri() trả về null, đoạn này ném NPE — dù bạn chỉ đang gán vào một Integer vốn nhận null bình thường. Lý do: hai nhánh của toán tử ba ngôi có kiểu khác nhau (int và Integer), nên Java nâng cả hai về int, và việc mở hộp null gây lỗi.

Sửa bằng cách cho hai nhánh cùng kiểu:

Integer x = dieuKien ? Integer.valueOf(1) : layGiaTri();

Đây là họ hàng gần của cái bẫy autoboxing hôm qua, và nó khó thấy hơn nhiều vì nhìn qua chẳng có chỗ nào mở hộp cả.

Danh sách kiểm tra ngắn

Khi đọc lại mã có tính toán, tôi rà đúng năm câu hỏi này:

Phép chia nào có cả hai vế là số nguyên mà kết quả mong đợi là số thực?

Có phép cộng hay nhân nào chạy trên int mà giá trị có thể vượt hai tỷ?

Có chỗ nào so sánh double bằng == không?

Tiền bạc có đang nằm trong double không?

Có phép % nào mà toán hạng trái có thể âm không?

Năm câu đó bắt được phần lớn lỗi số học tôi từng gặp trong mã thật, kể cả mã của chính mình.

Muốn tự thấy cái cân lệch thì mở jshell gõ ba dòng: 0.1 + 0.2, Integer.MAX_VALUE + 1, -7 % 2. Ba kết quả là 0.30000000000000004, một số âm, và -1 — không dòng nào báo lỗi, không dòng nào cảnh báo. Nếu dự án của bạn có tiền bạc nằm trong double, hoặc có phép % mà toán hạng trái có thể âm, ba mươi giây vừa rồi đã chỉ ra hai chỗ cần sửa.

Mẫu số chung

Điều an ủi — và cũng đáng sợ — là bốn cái bẫy này không phải của riêng Java. Chúng là hệ quả của phần cứng: số nguyên có độ rộng cố định và số thực theo chuẩn IEEE 754. Nên gần như ngôn ngữ nào cũng dính, và cách mỗi ngôn ngữ xử lý cho thấy một triết lý khác nhau về "an toàn hay tốc độ".

  • Python xoá được tràn số nguyên: int của Python là số lớn vô hạn, 2**100 chạy ngon. Nhưng nó không thoát được 0.1 + 0.2, vì float của Python vẫn là IEEE 754 — gõ thử trong Python cho ra đúng 0.30000000000000004. Và phép % của Python dễ chịu hơn Java: nó luôn trả dư cùng dấu với số chia, nên -7 % 2 ra 1, đúng cái Math.floorMod của ta.
  • JavaScript chỉ có một kiểu số Number (là double), nên không có chuyện "chia nguyên mất phần dư", nhưng mọi cái bẫy float thì nguyên vẹn, cộng thêm chuyện số nguyên lớn hơn 2^53 mất chính xác — lý do BigInt ra đời.
  • Go và C tràn số nguyên quay vòng lặng lẽ y như Java. Rust đáng học nhất: ở bản debug nó panic khi tràn (bắt được lỗi ngay lúc test), ở bản release thì quay vòng để chạy nhanh — và bắt bạn chọn tường minh wrapping_add/checked_add, đúng tinh thần Math.addExact.
  • Về tiền bạc thì lời khuyên giống hệt nhau ở mọi ngôn ngữ: đừng dùng số thực nhị phân — Java dùng BigDecimal, Python có decimal.Decimal, C# có kiểu decimal dựng sẵn, SQL có NUMERIC.

Sợi chỉ chung đáng mang theo: máy tính không làm toán như bạn học ở trường — nó làm toán trong một hộp có kích thước cố định, và khi tràn hộp hoặc không biểu diễn nổi, nó cho số gần đúng chứ không kêu cứu. Hai câu hỏi để tự cứu đúng ở mọi ngôn ngữ: con số này có thể lớn tới mức tràn kiểu của nó không (nếu có, dùng kiểu rộng hơn hoặc phép có-kiểm-tra), và đây có phải tiền hay thứ cần chính xác tuyệt đối không (nếu phải, bỏ số thực nhị phân, dùng kiểu thập phân). Java phơi bày rõ bốn cái bẫy vì nó có nhiều kiểu số; ngôn ngữ ít kiểu hơn chỉ giấu chúng kỹ hơn, không xoá được chúng.

Ngày mai ta chuyển sang câu lệnh điều kiện và vòng lặp — nhẹ nhàng hơn nhiều, nhưng cũng có vài thứ mới trong Java hiện đại đáng dùng thay cho switch kiểu cũ.