Có một loại lỗi khó chịu hơn hẳn lỗi làm chương trình sập: lỗi khiến chương trình chạy ngon lành và cho ra một con số sai.
Chương trình sập thì bạn 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) 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.
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ũ.