1 điểm bởi GN⁺ 2 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Nếu giá trị của float sau 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ẫn static_cast đều bị ảnh hưởng
  • -Wall-Wextra không cảnh báo điều này, còn -Wconversion cũ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::narrow củ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
  • CVTTSS2SI trên x86 xử lý giá trị không thể biểu diễn thành INT_MIN, nhưng FCVTZS trê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-overflow củ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 đề
    • -Wall-Wextra không cảnh báo cả ba kiểu chuyển đổi này
    • -Wconversion chỉ 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
    • CVTTSS2SI trên x86 ánh xạ đầu vào không thể biểu diễn thành INT_MIN
    • FCVTZS trê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::narrow củ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ể dùng -fsanitize=float-cast-overflow trong 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

    • Nếu vì lý do đó thì đáng ra phải là hành vi do cách triển khai quyết định chứ không phải hành vi không xác định. Chia cho 0 là hành vi không xác định thì còn dễ hiểu vì trên một số kiến trúc nó sẽ trap
      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
    • Có lẽ là vậy. Dòng lệnh fctiw của PowerPC dùng chuyển đổi bão hòa, biến NaN thành INT_MIN và cũng đặt cờ FPSCR. fctid củ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 x86
  • Trong 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 int mà 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

    • Vụ này không liên quan đến IEEE 754 mà hoàn toàn là lỗi của C++
  • Nếu từng dùng C hay C++, thì khác biệt giữa float32int32, thậm chí cả int64, đáng ra không nên là điều gây ngạc nhiên. float32 với số mũ lớn có thể biểu diễn các giá trị nguyên lớn hơn int64 rất nhiều
    Giữ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

    • Bạn đang bỏ lỡ điểm chính. Việc không thể bảo toàn mọi giá trị dấu chấm động khác với hành vi không xác định
      uint32_t cũng không thể biểu diễn mọi giá trị của uint64_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ác
    • Khác biệt về phạm vi biểu diễn không có nghĩa là không thể định nghĩa một phép chuyển đổi an toàn. Rust định nghĩa tường minh phép chuyển đổi số thực dấu chấm động → số nguyên, và đặc tả ngôn ngữ Java 26 quy định chi tiết quy trình chuyển đổi ở mục 5.1.3
      Vớ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ư CVTTSS2SI hay FCVTZS. 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ơn
    • Tùy phần cứng mà sinh ra giá trị rác theo từng triển khai, hoặc để phần cứng/công cụ kiểm tra trap và dừng chương trình, thì không phải điều quá bất ngờ
      Như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
    • Đây là thông tin mới với tôi. Tôi cứ nghĩ rằng khi chuyển một số dấu chấm động không thể biểu diễn sang số nguyên thì cùng lắm chỉ khiến biến số nguyên nhận giá trị linh tinh, chứ không ngờ toàn bộ chương trình lại bị ô nhiễm vĩnh viễn và rơi ra ngoài sự kiểm soát của tiêu chuẩn C++