はじめに
このC++ Qiita Advent Calendarの記事を読んでいただいて、本当にありがとうございます。今回は初めての投稿になります。今年の6 月にTokyo C++ Meetupで会った方にアドベントカレンダーイベントを紹介してもらいました。この場を借りてありがとうを伝わせていただきます。
私のC++歴は10年以上で、その内の約5年は仕事として使っています。今の仕事ではPythonとRustもよく使います。C++以外で気に入った言語はScheme、具体的にShiro Kawaiさんが作ったGaucheというSchemeの方言です。1
この記事では浮動小数点数型(floating-point number)の値を持つ変数を整数型(integral number)変数に変換する方法についてお話ししたいです。ただし、丸め(ラウンディング)の事については話しません。丸めの後に、宛先の変数が収まれない場合について話します。
浮動小数点数の演算が信号処理、物理モデリング、3D グラフィックスといった分野では日常茶飯事ではないだろうか。こういった浮動小数点を使う時、他の表現法に変更しないといけないシチュエーションがたまには現れています。その原因は収納することから他のアルゴリズムに入力にするのつもりで処理することに至るまでです。
私は信号処理の分野の仕事をしていて、最もよく使用しているのはC++です。私にとって浮動小数点型から整数型への変換は生活の一部であり、あまり意識していませんでした、先日私がある処理をデバッグしていた時に突然以下のエラーメッセージが表示されるまでは。
main.cpp:2:26: runtime error: -5 is outside the range of representable values of type 'unsigned short'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.cpp:2:26 in
このエラーはなかなか興味を引きました。浮動小数点数型から整数型への変換は必ずしも成功するわけではないことはわかっていたのに、このエラーを見るまでこの状態から何をすればいいのかと真剣に考えたことはありませんでした。
何が起きたのか?
C++標準規格ではこのようなケースについてどう言及しているでしょうか?2
A prvalue of a floating-point type can be converted to a prvalue of an integer type. The conversion truncates; that is, the fractional part is discarded. The behavior is undefined if the truncated value cannot be represented in the destination type.
これがすべてです。それはかなり明確です。切り捨てについての注意(重要ですが、今はそこに焦点を当てません)を脇に置いておくと、この文章は、特定の型変換(前述のfloat -2.0f
からuint16_t
への変換)の未定義性を明確にしています。もちろん、これは、NaN(シグナリングおよび非シグナリング)、inf
、-inf
など、ほとんどの整数型に対応するものがない他の浮動小数点型の値も、これらの型に変換しようとすると未定義動作を引き起こすことを意味します。 もちろん、故にinf
とか、NaN
(signalingとquiet)、それに面白い浮動小数点変数がもちえる値も例外なく、表現できないインテグラルタイプに変更とするのが未定義の動作(恐るべしundefined behavior)になります。
では、この問題に対してどう対処すべきでしょうか?未定義動作が発生し得るので、それを避けたい場合、自分でそれを検出し回避するための予めにチェックを書かなければなりません。「予め」のポイントは重要です。なぜかというと、未定義の動作は事後に検出が駄目ですから。 前にJeanHeyd Meneideが書いたSledgehammer Principleは参考になります。3
Whenever you commit undefined behavior, you have to imagine that you’ve taken a sledgehammer and smashed it into a large, very expensive vase your mother (or father, or grandmother, or whoever happens to be closest to you) owns. ... This means that, before you swing your sledgehammer around, you check before you commit the undefined behavior, not afterwards.
一応概念実証を載せてみましたが、周辺条件を一つ一つ丁寧にキャッチしないといけないとはいい感じと言えません。基準ライブラリがこういう機能を実装してくれればよかったではないだろうか… (死亡フラグ)4
#include <cmath>
#include <cstdint>
#include <limits>
#include <type_traits>
template <typename Unsigned>
Unsigned __attribute__((noinline)) safe_cast(auto in) {
static_assert(std::is_unsigned_v<Unsigned>, "Output type must be unsigned");
if constexpr (std::numeric_limits<decltype(in)>::has_quiet_NaN ||
std::numeric_limits<decltype(in)>::has_signaling_NaN) {
if (std::isnan(in)) {
return std::numeric_limits<Unsigned>::min(); // 大丈夫かな?
}
}
if (in > std::numeric_limits<Unsigned>::max()) {
return std::numeric_limits<Unsigned>::max();
} else if (in < std::numeric_limits<Unsigned>::min()) {
return std::numeric_limits<Unsigned>::min();
}
return static_cast<Unsigned>(in);
}
#include <iostream>
int main() {
std::cout << safe_cast<uint64_t>(std::nan("1")) << std::endl;
std::cout << safe_cast<uint64_t>(std::numeric_limits<double>::infinity()) << std::endl;
std::cout << safe_cast<uint64_t>(-std::numeric_limits<double>::infinity()) << std::endl;
std::cout << safe_cast<uint64_t>(NAN) << std::endl;
return 0;
}
ちなみにこれ、無効入力を処理すると飽和演算を施行します。後に話しますが、飽和キャストが数点のいいところあります。違う行為が欲しい際はreturn命令を編集するができます。
CPU命令では何が起こりますか?
少しだけ話しますが、好奇心を持っている読者にはこの一例が興味あると思っています。5
#include <cstdint>
template<typename Out>
Out __attribute__ ((noinline)) yolo_cast(float in) {
return static_cast<Out>(in);
}
int main() {
return yolo_cast<uint16_t>(-6.f);
}
備考:「何が起こったか」は同じコンパイラ、同じプラットフォーム、同じコンパイラオプション等の場合に限り有意義であります。それは未定義動作だからです。これは言語の行動より、コンパイラ実装詳細を勉強になることです。
Rustの場合はどうされますか?
この興味深い疑問について調べているうちに、Rustの民はどうやってこの問題を扱えるのだろうと思い始めました。浮動小数点型と積分型はRustに内蔵されており、変換演算子as
はOptionやResultでラップされおらず、その型の有効な結果を返さなければならない言語キーワードです。
その答えは、表現不可能な値は出力の表現可能な最小値あるいは最大値に飽和されるということです。予想以上に意見が分かれているように思えたので、これには少々驚いたが、それでもセマンティクスはきちんと定義されており、一貫していた。しかしコンパイラとコードジェネレーションの視線から何が起こっているのかを理解するために、もう少し調べたいと思いました。
Rustコンパイラとフロントエンドが浮動小数点型を整数型に変換する必要があるときに発行する命令は、llvm.fptosi.sat
とllvm.fptoui.sat
という特別なLLVM IR命令です。6この命令は不思議なことにRust言語の標準ルールに似たセマンティクスを持っています。実は、Rust言語委員会がこのルールのあり方を決定した上で追加されたものです。数年間、これは確かにRust言語の未定義の動作であり、標準化委員会はこれを「健全性の穴」("soundness hole")とみなしていたようです。7しかし、これまで考えてきたような制約があるため、これをどうすべきかについて明確な決定は下せませんでした。最終的に、「マシ」な解決策として飽和変換に落ち着き、アプリケーション開発者がこの機能をオンにしたコードをベンチマークするよう公募した後、言語標準に成文化されました。8
(GCCのRustフロントエンドであるgccrs
がこれをどのように処理するのか不思議に思うかもしれないが、その答えは今のところ処理しないことだ。)9
浮動小数点例外を使ったら?
残念ながら、この話題を詳しく取り上げるには十分な時間がありませんでしたが、指摘しておかなければ損です。C++標準ライブラリには、CPUの浮動小数点例外レジスタの状態をチェックするためのユーティリティ関数が多数継承されています。この関数はもともとC標準に規定されていたもので、ほとんど変更されることなくC++に引き継がれた。これらの関数を上記の解決策に適応させるのは魅力的かもしれませんが、これらは最後の浮動小数点演算の状態しかチェックできないため、その演算が未定義の動作でもあった場合には役に立ちません。プラットフォームによっては動作するかもしれませんが、ポータブルとされていない。
参考文献:fenv.h
10
まとめ
勉強になりましたポイントです。
- UBSan11を有効にする。UBSanを有効にしていなければ私達のアプリケーションにこれがあることを知ることはなかったでしょう。
- 浮動小数点数型と整数型は非常に異なり、可能な限り、これらの間の境界を非常に明確にするようにアプリケーションを構築すべきです。
- C++標準規格は未定義の動作が多すぎること。多くの場合、未定義動作のままにしておくよりも仕様を決めたほうが良いです。一方、他の実装の可能性を排除することも問題です。
-
https://thephd.dev/c-undefined-behavior-and-the-sledgehammer-guideline ↩
-
https://llvm.org/docs/LangRef.html#saturating-floating-point-to-integer-conversions ↩
-
https://internals.rust-lang.org/t/help-us-benchmark-saturating-float-casts/6231 ↩
-
https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html ↩