- Nếu giá trị của
floatsau khi bỏ phần thập phân nằm ngoài phạm vi của kiểu số nguyên đích, sẽ xảy ra hành vi không xác định (UB), và cả chuyển đổi ngầm định, ép kiểu theo hàm, lẫnstatic_castđều bị ảnh hưởng -Wallvà-Wextrakhông cảnh báo điều này, còn-Wconversioncũng chỉ phát hiện chuyển đổi ngầm định nên rất dễ bị bỏ sót- Hàm chuyển đổi thu hẹp an toàn
gsl::narrowcủa Microsoft GSL cũng có thể gây UB với một số đầu vào chuyển từ số thực dấu chấm động → số nguyên, nên không giữ được hành vi trong tài liệu là ném ngoại lệ khi gặp giá trị không thể biểu diễn CVTTSS2SItrên x86 xử lý giá trị không thể biểu diễn thànhINT_MIN, nhưngFCVTZStrên AArch64 lại chuyển đổi bão hòa và đổi NaN thành 0, nên kết quả có thể khác nhau tùy phần cứng- Để chuyển đổi an toàn, cần kiểm tra phạm vi trước khi cast, và có thể dùng tùy chọn UBSan
-fsanitize=float-cast-overflowcủa Clang·GCC để phát hiện vấn đề
Quy tắc chuyển đổi và giới hạn của việc phát hiện
- Theo quy tắc chuyển đổi giữa số thực dấu chấm động và số nguyên trong C++, sau khi bỏ phần thập phân, nếu giá trị không nằm trong kiểu số nguyên đích thì sẽ trở thành hành vi không xác định
- Dù kiểu đích là unsigned thì phép toán modulo cũng không được áp dụng
int i0 = f,int(f),static_cast<int>(f)đều có thể gây UB với một số đầu vào
- Chỉ dựa vào cảnh báo trình biên dịch thông thường thì khó tìm hết vấn đề
-Wallvà-Wextrakhông cảnh báo cả ba kiểu chuyển đổi này-Wconversionchỉ cảnh báo chuyển đổi ngầm định
- Dù chương trình vẫn tiếp tục chạy trên bộ xử lý và trình biên dịch hiện tại, kết quả vẫn có thể khác nhau giữa các nền tảng
CVTTSS2SItrên x86 ánh xạ đầu vào không thể biểu diễn thànhINT_MINFCVTZStrên AArch64 xử lý bão hòa và ánh xạ NaN thành 0- UB đã được thực thi có thể khiến mã đột ngột hoạt động sai khi trình biên dịch áp dụng các chuyển đổi khác
Trường hợp của GSL và cách ứng phó an toàn
gsl::narrowcủa Microsoft Guidelines Support Library tự nhận là phép chuyển đổi thu hẹp an toàn, ném ngoại lệ với các giá trị không thể biểu diễn bằng kiểu đích- Trên thực tế, chuyển đổi từ số thực dấu chấm động → số nguyên trước tiên có thể thực thi UB với một số đầu vào, nên không khớp với tài liệu
- Phía GSL cho rằng trên nền tảng đích, việc này không chạm tới biểu diễn bẫy của phần cứng nên UB nội bộ là vô hại; lập luận đó đã được phản ánh trong mã nguồn và vấn đề vẫn chưa được sửa
- Cách giải quyết đúng là kiểm tra phạm vi trước khi cast
- Có thư viện proof-of-concept cpp-clamp-cast dựa trên cách chuyển đổi bão hòa của Rust
- Có thể dùng
-fsanitize=float-cast-overflowtrong Undefined Behavior Sanitizer của Clang và GCC để phát hiện UB này- Khuyến nghị kiểm thử toàn bộ mã C++ bằng UBSan
1 bình luận
Ý kiến trên Lobste.rs
Dù đã biết nhiều trường hợp hành vi không xác định tinh vi của C và C++, ví dụ này vẫn rất đáng ngạc nhiên
Rust cũng từng kế thừa cùng quy tắc chuyển đổi số thực dấu chấm động → số nguyên của LLVM IR nên đã có hành vi không xác định trong một thời gian, và mãi đến năm 2020 mới được sửa để tạo ra IR phức tạp hơn. Do chênh lệch hiệu năng khá lớn, Rust cũng cung cấp chuyển đổi số thực dấu chấm động → số nguyên không kiểm tra cho các vòng lặp hiệu năng cao khi biết giá trị là hữu hạn và nằm trong phạm vi của kiểu đích
Thật vô lý khi C++ Core Guidelines Library lại xem nhẹ việc này. LLVM đã dùng thông tin thu được từ phép chuyển đổi để loại bỏ kiểm tra biên, và kết quả là đã có trường hợp vượt phạm vi mảng dù vẫn có kiểm tra biên. Nếu thực sự coi trọng an toàn bộ nhớ và việc tránh hành vi không xác định, thì không thể bỏ qua chuyện này; việc Herb Sutter nói về “hành vi không xác định lành tính” cũng gây thất vọng
Tôi biết C++ có nhiều hành vi không xác định, nhưng cái này đặc biệt gây ngạc nhiên. Tôi tự hỏi có phải người ta muốn làm phép chuyển đổi nhanh nhất có thể, và vì mỗi kiến trúc có lệnh xử lý các giá trị cực trị này khác nhau nên mới để nó thành hành vi không xác định hay không. Có vẻ đây là lý do chính khiến quy tắc này xuất hiện, tương tự như tràn số nguyên có dấu
Hành vi không xác định nên được giới hạn ở những trường hợp không thể đảm bảo kết quả nhất quán ngay cả trên cùng một nền tảng do tác động nằm ngoài máy trừu tượng của C, như use-after-free, hoặc ở những trường hợp có thể trap trên một số đích
Một triển khai tuân thủ tiêu chuẩn có thể tự định nghĩa một số hành vi không xác định, và GCC cũng làm vậy với một vài mục. Nếu có thể đảm bảo ngữ nghĩa ổn định mà không tốn chi phí hiệu năng, thì cũng có thể định nghĩa trực tiếp theo hành vi của lệnh chuyển đổi số thực dấu chấm động → số nguyên trên từng đích, nhưng với các triển khai khác thì nó vẫn là hành vi không xác định
fctiwcủa PowerPC dùng chuyển đổi bão hòa, biến NaN thànhINT_MINvà cũng đặt cờ FPSCR.fctidcủa Power ISA 64-bit cũng xử lý tương tự cho số nguyên lớn hơn, nhưng cả hai đều không khớp với hành vi của AArch64 hay x86Trong trường hợp này, đúng ra nên xử lý thành hành vi do cách triển khai quyết định hoặc giá trị không được chỉ định. Chỉ vì chuyển vô cực sang
intmà toàn bộ chương trình bị đẩy ra ngoài sự kiểm soát của tiêu chuẩn C++ thì thật vô lýC++26 đã loại bỏ một số hành vi không xác định vô lý, và đây rõ ràng cũng là một ứng viên nên bị loại bỏ. Theo thử nghiệm, có vẻ GCC và Clang không dùng hành vi không xác định này cho tối ưu hóa nên ảnh hưởng thực tế có vẻ khá hạn chế
Đây lại là một ví dụ khó chịu khác cho thấy đặc tả không có lý do gì để quy định phép toán này là hành vi không xác định
Lại thêm một lý do để ghét IEEE 754. Nếu không thật sự cần vì thư viện khác hay vì hiệu năng, tôi cố dùng số nguyên thuần, số hữu tỉ với tử số·mẫu số là số nguyên lớn, hoặc số thập phân fixed-point thay cho số dấu chấm động càng nhiều càng tốt
Nếu từng dùng C hay C++, thì khác biệt giữa
float32vàint32, thậm chí cảint64, đáng ra không nên là điều gây ngạc nhiên.float32với số mũ lớn có thể biểu diễn các giá trị nguyên lớn hơnint64rất nhiềuGiữa các kiểu không phải là siêu tập của nhau và có khả năng biểu diễn khác nhau, không có lý do gì để mặc định rằng chuyển đổi số thực dấu chấm động → số nguyên là an toàn, bất kể cú pháp ngôn ngữ ra sao
uint32_tcũng không thể biểu diễn mọi giá trị củauint64_t, nhưng ngữ nghĩa chuyển đổi của nó vẫn được định nghĩa là cắt bớt. Ở đây, vấn đề cốt lõi là với một số đầu vào nhất định, trình biên dịch có thể làm bất cứ điều gì, và đó là chuyện hoàn toàn khácVới ngôn ngữ mức thấp, cũng có thể ánh xạ trực tiếp sang các lệnh assembly như
CVTTSS2SIhayFCVTZS. Vì ngôn ngữ mức thấp vốn gần với assembly và các “hành vi không xác định lành tính” khác cũng đã gây nhiều tranh cãi, nên việc phép chuyển đổi này lại cho phép hành vi không xác định còn đáng ngạc nhiên hơnNhưng việc còn cho phép cả chuyện biên dịch sai những đoạn mã không liên quan, format ổ cứng, hay “làm quỷ bay ra từ mũi” thì mới đáng ngạc nhiên
Khái niệm hành vi không xác định rằng mọi chuyện đều có thể xảy ra là hợp lý với các trường hợp như giải phóng kép hoặc ghi vượt phạm vi mảng, nhưng C và C++ lại lạm dụng nó ngay cả ở những chỗ hoàn toàn có thể đặt ra quy tắc chặt chẽ hơn như giá trị rác do cách triển khai quyết định hoặc dừng chương trình. Rust trap một số trường hợp tràn số nguyên trong chế độ debug và trả về giá trị không được chỉ định trong chế độ release, nhưng không cho phép nó phá hỏng cả những đoạn mã không liên quan
C++ không cần phải trở thành Java hay Rust, nhưng nếu giảm bớt hành vi không xác định không cần thiết thì chắc chắn sẽ là một ngôn ngữ tốt hơn