1 ポイント 投稿者 GN⁺ 2 시간 전 | 1件のコメント | WhatsAppで共有
  • floatの小数部を切り捨てた値が対象の整数型の範囲を外れると、未定義動作(UB) が発生し、暗黙の変換・関数形式のキャスト・static_castのいずれも影響を受ける
  • -Wall-Wextraはこれを警告せず、-Wconversion暗黙の変換だけを検出するため見落としやすい
  • Microsoft GSLの安全な縮小変換関数gsl::narrowも、一部の浮動小数点数→整数入力でUBを引き起こし、表現できない値に例外を投げるというドキュメント上の動作を満たせない
  • x86のCVTTSS2SIは表現不可能な値をINT_MINとして扱うが、AArch64のFCVTZSは飽和変換し、NaNを0に変えるため、ハードウェアごとに結果が異なる可能性がある
  • 安全に変換するには、キャストの前に範囲を検査する必要があり、Clang・GCCのUBSanオプション-fsanitize=float-cast-overflowで問題を検出できる

変換規則と検出の限界

  • C++の浮動小数点数-整数変換規則によると、小数部を切り捨てた後の値が対象の整数型に収まらない場合、未定義動作になる
    • 対象がunsignedであってもモジュラー算術は適用されない
    • int i0 = fint(f)static_cast<int>(f)はいずれも一部の入力でUBを引き起こす
  • 一般的なコンパイラ警告だけでは、問題をすべて見つけるのは難しい
    • -Wall-Wextraは3つの変換すべてについて警告しない
    • -Wconversionは暗黙の変換だけを警告する
  • 現在のプロセッサとコンパイラでプログラムが実行を続けたとしても、結果はプラットフォームごとに異なる可能性がある
    • x86のCVTTSS2SIは、表現できない入力をINT_MINにマッピングする
    • AArch64のFCVTZSは飽和処理し、NaNを0にマッピングする
    • 実行されたUBは、コンパイラが別の変換を適用したときにコードが突然誤動作する原因になり得る

GSLの事例と安全な対応

  • Microsoft Guidelines Support Librarygsl::narrowは、対象の型で表現できない値に例外を投げる安全な縮小変換を標榜している
    • 実際の浮動小数点数→整数変換は、一部の入力で先にUBを実行するため、ドキュメントと一致しない
    • GSL側は、対象プラットフォームでハードウェアのトラップ表現に触れないため内部のUBは無害だと判断しており、この論理がコードに反映されたまま問題は修正されていない
  • 正しい解決策は、キャストの前に範囲を検査すること
  • ClangとGCCのUndefined Behavior Sanitizer-fsanitize=float-cast-overflowを使うと、このUBを検出できる
    • すべてのC++コードをUBSanでテストする方法を推奨する

1件のコメント

 
GN⁺ 2 시간 전
Lobste.rs のコメント
  • CとC++の微妙な未定義動作は数多く知っていたが、この例は驚きだった。
    RustもLLVM IRの同じ浮動小数点→整数変換規則を受け継いでしばらく未定義動作があり、2020年になってようやく、より複雑なIRを生成するよう修正した。性能差が大きいため、値が有限で対象型の範囲内にあると分かっている高性能ループ向けに、チェックなしの浮動小数点→整数変換も提供している。
    C++ Core Guidelines Libraryがこれを軽く見ているのはばかげている。LLVMは変換から得た情報を使って境界チェックを削除し、その結果、境界チェックがあったのに配列範囲外アクセスになった事例がある。メモリ安全性と未定義動作の回避を真剣に考えるなら、これを無視することはできないし、Herb Sutterが「良性の未定義動作」と言ったのも残念だ。

  • C++に未定義動作が多いことは知っていたが、これは特に驚きだ。変換をできるだけ高速にしようとして、アーキテクチャごとにこの極端値を異なる形で扱う命令があるために未定義動作にしたのか気になる。符号付き整数オーバーフローと同様、こうした規則が生まれた主な理由のように思える。

    • その理由なら、未定義動作ではなく実装定義動作であるべきだ。ゼロ除算は一部アーキテクチャでトラップするので未定義動作なのは理解できる。
      未定義動作は、解放後使用のようにC抽象機械の外にある効果のため同一プラットフォームでも一貫した結果を保証できない場合や、一部ターゲットでトラップし得る場合に限定すべきだ。
      標準準拠実装は未定義動作を独自に定義でき、GCCも一部項目でそうしている。安定した意味論を性能コストなしで保証できるなら、ターゲットごとの浮動小数点→整数命令の挙動をそのまま定義してもよいが、他の実装では依然として未定義動作になる。
    • おそらくそうだろう。PowerPCのfctiw系は飽和変換を行い、NaNをINT_MINに変え、FPSCRフラグも設定する。64ビットPower ISAのfctidもより大きな整数に対して同じ処理をするが、どちらもAArch64やx86の挙動とは一致しない。
  • こういう場合は実装定義動作か未規定の値として扱うのが適切だ。無限大をintに変換したというだけで、プログラム全体がC++標準の統制外になるのは理にかなわない。
    C++26はいくつかの不合理な未定義動作を削減しており、これも明らかに削除候補だ。実験上、GCCとClangはこの未定義動作を最適化に活用していないようなので、実際の影響は限定的に見える。

  • 仕様がこの演算を未定義動作と規定する理由がない、また一つの不快な事例だ。

  • IEEE 754を嫌う理由がまた一つ増えた。他のライブラリや性能のためにどうしても必要でない限り、浮動小数点の代わりに純粋な整数、分子・分母が大きな整数の有理数、固定小数点の十進数をできるだけ使っている。

    • 今回の件はIEEE 754とは無関係で、完全にC++の問題だ。
  • CやC++を使ってきたなら、float32int32、さらにはint64の違いも驚くべきことではないはずだ。指数の大きいfloat32は、int64よりはるかに大きい整数値を表せる。
    互いに包含関係になく表現能力の異なる型の間で、言語構文とは無関係に浮動小数点→整数変換が安全だと仮定する理由はない。

    • 要点を外している。浮動小数点のすべての値を保持できないことと、未定義動作であることは別だ。
      uint32_tuint64_tのすべての値を表せないが、変換の意味は切り詰めとして定義されている。ここでは、特定の入力に対してコンパイラが何でもできることが本質的に異なる問題だ。
    • 表現範囲が異なるからといって、安全な変換を定義できないわけではない。Rustは浮動小数点→整数変換を明示的に定義しており、Java 26言語仕様は5.1.3節で変換手順を詳しく定めている。
      低水準言語なら、CVTTSS2SIFCVTZSのようなアセンブリ命令に対応づけるやり方も可能だ。低水準言語がアセンブリに近く、他の「良性」未定義動作も論争が大きいことを考えると、この変換が未定義動作を許している事実はむしろより驚きだ。
    • ハードウェアによって実装ごとのゴミ値を返したり、ハードウェアや検査ツールがトラップしてプログラムを停止したりするのは、そこまで驚くことではない。
      しかし、無関係なコードを奇妙に誤コンパイルしたり、ハードドライブをフォーマットしたり、「鼻から悪魔が飛び出す」ことまで許されるのは驚きだ。
      二重解放や配列範囲外書き込みについては何が起きてもおかしくないという未定義動作の考え方は妥当だが、CとC++は、実装定義のゴミ値やプログラム停止のような、もっと厳格な規則を置ける場面にまでこれを乱用している。Rustは一部の整数オーバーフローをデバッグモードでトラップし、リリースモードでは未規定の値を返すが、無関係なコードまで壊せないようにしている。
      C++がJavaやRustになる必要はないが、不必要な未定義動作を減らせば、確実により良い言語になる。
    • 私には新しい話だった。表現できない浮動小数点を整数に変換すると、結果の整数におかしな値が入る程度だと思っていたのであって、プログラム全体が永続的に汚染されてC++標準の統制外に出るとは知らなかった。