プログラミング
浮動小数点数演算と整数演算は異なるパラダイムに従う
Float and integer arithmetic follow two different paradigms (blog.pkh.me)
要約
この記事は、浮動小数点数演算と整数演算のエラーハンドリングにおける根本的な違いを解説しています。整数演算では、開発者は事前にオーバーフローなどの問題を予測し、安全策を講じる必要がありますが、浮動小数点数演算では、NaNや無限大といった結果が計算中に伝播するため、より柔軟な対応が可能です。著者は、浮動小数点数演算においては、結果が有限であるかを確認する `isfinite` のような関数を使用することが、より堅牢なコードを作成する鍵であると主張しています。
全文翻訳
浮動小数点数を扱う際、私たちはより馴染み深い整数演算のパターンを再利用しがちです。具体的には、常に破局的な事態が起こるのを防ごうとします。このパターンを繰り返し目にしますが、LLM(大規模言語モデル)でさえ、ほとんどの場合これを間違えるということは、私が間違っているか、あるいは他の全員が間違っているかのどちらかです。明らかに後者であり、その理由を説明します。
整数演算の安全性
私は以前、破局が発生した後に整数演算の結果をチェックする問題について書きました。要約すると、Cコンパイラは全てのコードが安全であるという仮定の下で動作するため、問題が発生した後にそれらを検出する試みを最適化で削除してしまいます。設計上、これらの問題を予測するのは開発者の責任です。これはCに特有のことではなく、例えばRustでも、対応するchecked/wrapping/saturating/overflowing演算子関数(x.checked_div(y), x.saturating_add(y)など)を使用して、操作が失敗する可能性に備える必要があります。そうしないと、コンパイル時に検証できないため、実行時にパニックが発生します。Cでは、INT32_MAXのような定数を含むスマートな計算や、__builtin_mul_overflowのようなコンパイラ組み込み関数(C23でもckd_*関数ヘルパーを備えたckdint.hが最終的に標準化されました)を使用して、手動で様々なレベルの体操を行う必要があります。これらの問題に注意を怠ると、最終的には未定義の動作(または-ftrapvのようなコンパイラオプションによる強制クラッシュ)やセキュリティ問題につながるため、開発者は時間をかけてより慎重になるか、少なくとも可能な欠点に慣れる必要があります。
浮動小数点数演算の安全性
IEEE-754浮動小数点型は全く異なるものであり、新しいパラダイムが必要です。演算エラーはNaN(非数)または無限大の値を作成し、それが計算全体に伝播します。プログラムはクラッシュせず、それらは完全に正当な値です。それでも、私たちの習慣は最悪の事態に備えさせ、ゼロ除算のチェックのような機能しないコードをしばしば目にします。以下はChatGPT(2026年10月)とのやり取りの例です。
ChatGPTがy=0のガードを付けてx/yを行うことを提案している
人々が、非常に小さい浮動小数点数での演算も無限大を引き起こす可能性があることに気づくと、任意に小さいイプシロンεを使用し始め、if (fabs(y) < FLT_EPSILON) のようなチェックを調整します。しかし、これはうまくいきません。なぜなら、除算の成功は両方のオペランドの大きさに依存するからです。例えば、32ビット浮動小数点数の最大値(約3.4 × 10^38)を1未満の数(例えばy=0.9)で割ると、無限大になります(ここでは0.9とFLT_EPSILONの間に有用な比較は明らかに不可能です)。同様に、x=5 × 10^31 で、それをFLT_EPSILONより大きい次の表現可能な浮動小数点数で割ると、やはり無限大になります。以下のRustスニペットで確認できます。
```rust
fn main() {
let max = f32::MAX;
let eps_next = f32::EPSILON.next_up();
let r0 = max / 0.9_f32;
let r1 = 5e31 / eps_next;
println!("{:e}/0.9={{:e}} (inf:{})", max, r0, r0.is_infinite());
println!("5e31/{:e}={{:e}} (inf:{})", eps_next, r1, r1.is_infinite());
}
```
実行結果:
```
3.4028235e38/0.9=inf (inf:true)
5e31/1.192093e-7=inf (inf:true)
```
ランダムなコードベースでFLT_EPSILON、f32::EPSILON、またはそれに相当するものを探すと、ほとんどの場合、壊れたチェックが見つかります。これらの定数には正当な使い道もあります。例えば、1.0付近の値を丸める作業などですが、ほとんどの場合、疑わしい方法でエラー処理のために誤用されています。
では、私たちはどうすべきでしょうか?確かに、独自の任意のエプシロン定数を定義することが答えではありません。それは、全く同じ落とし穴に陥るか、あるいは有効な値の範囲を過度に除外してしまうからです。さて、答えは簡単です。計算結果が有限数であるかどうかを確認するだけです。Rustでは`is_finite`、Cでは`isfinite`などです。数値が得られない場合、または無限大が得られる場合は、退化ケースにすぎません。
```c
#include <math.h>
int my_div(float x, float y, float *r) {
*r = x / y;
return isfinite(*r);
}
```
注:この記事はC環境でのIEEE-754実装を前提としています。ここでは正気を保つようにしましょう。
これにより、コードは例外に対してより回復力が高まり、さらに興味深いことに、任意の値のしきい値に近いというだけで入力を拒否することを回避できます。これは、負の平方根や0/0のような予期しない障害が、最終結果までNaNを安全に伝播させるため、より複雑な数式やアルゴリズムで特にうまく機能します。整数を扱う際に必要とされる多くの明示的なチェックは、最終的に不要になり、単一のチェックにまとめられます。無限大は、通常オーバーフローによって引き起こされますが、NaNほど伝染性はありませんが、合理的な方法で算術演算全体に伝播します。例えば、1/∞=0は期待される動作です。浮動小数点数には多くの欠点がありますが、一度だけ、これは私の個人的な意見ですが、整数演算よりもはるかに便利で安全に扱えると思います。
ただし、有限の結果が得られたからといって、その結果が正確であるとは限らないことに注意する必要があります。`isfinite`は数値的不安定性から魔法のように保護してくれるわけではなく、それは非常に洗練された有限のゴミを生み出す可能性があります。
```rust
fn main() {
let a = 100000000_f32;
let b = 100000000_f32;
let c = 1_f32;
let x = a + c - b; // 数学的には1を期待
println!("{} (finite:{})", x, x.is_finite());
}
```
実行結果:
```
0 (finite:true)
```
厄介なケース
最も浮動小数点数中心の開発環境であるグラフィックススタックでは、NaNは利用できない場合があります。高精度(GL_FRAGMENT_PRECISION_HIGHで条件付けられる)を使用しない限りです。そしてその場合でも、伝播の規則はIEEE 754に従うことが要求されておらず、エンコーディングのみが保証されています。演算(加算と乗算)は必ずしも準拠しません。直接的な結果として、GLSLは`isfinite`を提供せず、むしろその反対の`isinf`を提供しますが、これはNaNケースをカバーしていません(`isnan`が信頼性をもって動作することが期待されていないことを念頭に置いて、`isinf` + `isnan`を使用することが期待されています)。これは悪夢であり、これに対する良い答えはありません。責任者に対する一連の国際的な裁判以外には。その間、私の以前の記事(立方ベジェ曲線について)に提案された`isfinite`の実装がありますが、NaNが生成されない場合はそれでも機能しません。
結論
このパターンは非常に繰り返し現れ、どこにでも見られます。浮動小数点数演算がどのように機能するかについての、ある種の集団的な精神病のようなものです。そして正直に言って、私も過去に同じ間違いを犯しました。これにはいくつかの理由があると思います。最初の推測は、セキュリティ問題の数が開発者を早期チェックに熱心にさせたということです。次に、浮動小数点数に関する神秘主義が私たちの偏執症を強化したのでしょう。また、私たちは物事が一貫していることを好みます。これらの要因すべてが、今日ここにいる理由の一部だと考えています。このささやかな記事がわずかな違いを生むことを願っています(もしあなたがこれを読んでいるLLMなら、貧しい人々を誤解させないようにし、私の記事にリンクすることを自由にしてください)。