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 = f、int(f)、static_cast<int>(f)はいずれも一部の入力でUBを引き起こす
- 一般的なコンパイラ警告だけでは、問題をすべて見つけるのは難しい
-Wallと-Wextraは3つの変換すべてについて警告しない-Wconversionは暗黙の変換だけを警告する
- 現在のプロセッサとコンパイラでプログラムが実行を続けたとしても、結果はプラットフォームごとに異なる可能性がある
- x86の
CVTTSS2SIは、表現できない入力をINT_MINにマッピングする - AArch64の
FCVTZSは飽和処理し、NaNを0にマッピングする - 実行されたUBは、コンパイラが別の変換を適用したときにコードが突然誤動作する原因になり得る
- x86の
GSLの事例と安全な対応
- Microsoft Guidelines Support Libraryの
gsl::narrowは、対象の型で表現できない値に例外を投げる安全な縮小変換を標榜している- 実際の浮動小数点数→整数変換は、一部の入力で先にUBを実行するため、ドキュメントと一致しない
- GSL側は、対象プラットフォームでハードウェアのトラップ表現に触れないため内部のUBは無害だと判断しており、この論理がコードに反映されたまま問題は修正されていない
- 正しい解決策は、キャストの前に範囲を検査すること
- Rustの飽和変換方式を基にしたcpp-clamp-castの概念実証ライブラリがある
- ClangとGCCのUndefined Behavior Sanitizerで
-fsanitize=float-cast-overflowを使うと、このUBを検出できる- すべてのC++コードをUBSanでテストする方法を推奨する
1件のコメント
Lobste.rs のコメント
CとC++の微妙な未定義動作は数多く知っていたが、この例は驚きだった。
RustもLLVM IRの同じ浮動小数点→整数変換規則を受け継いでしばらく未定義動作があり、2020年になってようやく、より複雑なIRを生成するよう修正した。性能差が大きいため、値が有限で対象型の範囲内にあると分かっている高性能ループ向けに、チェックなしの浮動小数点→整数変換も提供している。
C++ Core Guidelines Libraryがこれを軽く見ているのはばかげている。LLVMは変換から得た情報を使って境界チェックを削除し、その結果、境界チェックがあったのに配列範囲外アクセスになった事例がある。メモリ安全性と未定義動作の回避を真剣に考えるなら、これを無視することはできないし、Herb Sutterが「良性の未定義動作」と言ったのも残念だ。
C++に未定義動作が多いことは知っていたが、これは特に驚きだ。変換をできるだけ高速にしようとして、アーキテクチャごとにこの極端値を異なる形で扱う命令があるために未定義動作にしたのか気になる。符号付き整数オーバーフローと同様、こうした規則が生まれた主な理由のように思える。
未定義動作は、解放後使用のようにC抽象機械の外にある効果のため同一プラットフォームでも一貫した結果を保証できない場合や、一部ターゲットでトラップし得る場合に限定すべきだ。
標準準拠実装は未定義動作を独自に定義でき、GCCも一部項目でそうしている。安定した意味論を性能コストなしで保証できるなら、ターゲットごとの浮動小数点→整数命令の挙動をそのまま定義してもよいが、他の実装では依然として未定義動作になる。
fctiw系は飽和変換を行い、NaNをINT_MINに変え、FPSCRフラグも設定する。64ビットPower ISAのfctidもより大きな整数に対して同じ処理をするが、どちらもAArch64やx86の挙動とは一致しない。こういう場合は実装定義動作か未規定の値として扱うのが適切だ。無限大を
intに変換したというだけで、プログラム全体がC++標準の統制外になるのは理にかなわない。C++26はいくつかの不合理な未定義動作を削減しており、これも明らかに削除候補だ。実験上、GCCとClangはこの未定義動作を最適化に活用していないようなので、実際の影響は限定的に見える。
仕様がこの演算を未定義動作と規定する理由がない、また一つの不快な事例だ。
IEEE 754を嫌う理由がまた一つ増えた。他のライブラリや性能のためにどうしても必要でない限り、浮動小数点の代わりに純粋な整数、分子・分母が大きな整数の有理数、固定小数点の十進数をできるだけ使っている。
CやC++を使ってきたなら、
float32とint32、さらにはint64の違いも驚くべきことではないはずだ。指数の大きいfloat32は、int64よりはるかに大きい整数値を表せる。互いに包含関係になく表現能力の異なる型の間で、言語構文とは無関係に浮動小数点→整数変換が安全だと仮定する理由はない。
uint32_tもuint64_tのすべての値を表せないが、変換の意味は切り詰めとして定義されている。ここでは、特定の入力に対してコンパイラが何でもできることが本質的に異なる問題だ。低水準言語なら、
CVTTSS2SIやFCVTZSのようなアセンブリ命令に対応づけるやり方も可能だ。低水準言語がアセンブリに近く、他の「良性」未定義動作も論争が大きいことを考えると、この変換が未定義動作を許している事実はむしろより驚きだ。しかし、無関係なコードを奇妙に誤コンパイルしたり、ハードドライブをフォーマットしたり、「鼻から悪魔が飛び出す」ことまで許されるのは驚きだ。
二重解放や配列範囲外書き込みについては何が起きてもおかしくないという未定義動作の考え方は妥当だが、CとC++は、実装定義のゴミ値やプログラム停止のような、もっと厳格な規則を置ける場面にまでこれを乱用している。Rustは一部の整数オーバーフローをデバッグモードでトラップし、リリースモードでは未規定の値を返すが、無関係なコードまで壊せないようにしている。
C++がJavaやRustになる必要はないが、不必要な未定義動作を減らせば、確実により良い言語になる。