この記事は C++ Advent Calendar 2021の5日目の記事です。
2021年ももうすぐ終わりそうですが、みなさんはC++20を使っているでしょうか?
C++20では、符号付き整数型のビット表現が二の補数であると規定されました。
また、ビット表現を保ったまま別の型に変換する関数であるstd::bit_cast
が標準ライブラリに実装されました。
これら二つの機能追加により、値のビット表現に依存したプログラムを書くことが非常に容易になりました。
この記事では、なぜstd::bit_cast
を使わなければならないかを説明します。
もちろん、それ以外の時にstd::bit_cast
を使うべきではないということではありません。
ビット表現を保ったまま別の型に変換したい時にはいつでもstd::bit_cast
を使う、という方針もありでしょう。
std::bit_cast
を使わなければならない場合
std::bit_cast
を使わなければいけない場合は、以下の二つです*1。
- タイプパンニングによる未定義動作を防ぐため
- 符号付き整数のオーバーフローによる未定義動作を防ぐため
以下では、これら二つを詳しく説明していきます。
タイプパンニングによる未定義動作
タイプパンニングとは、本来の型とは互換性のない型としてデータにアクセスすることです。
たとえば、float
のビット表現を得たいのでfloat f = 1.0f; std::uint32_t u = *(std::uint32_t*)&f;
とかやってしまうことです。
これは strict aliasing rules に反する未定義動作になります。
こういう場合は、std::uint32_t u = std::bit_cast<std::uint32_t>(1.0f);
とやると良いでしょう。
std::bit_cast
の代わりに使ってしまいがちな、以下の二つはいずれも未定義動作になります。
- 共用体(
union
)の片方のメンバに代入し、もう片方のメンバで読み出す- C言語の場合は未定義動作ではないが未規定(C17の規格書で確認)
float f = 1.0f; return *(std::uint32_t*)&f;
のように互換性のないポインタに無理やりキャストして読みだす
なお、以下のようにstd::memcpy
を使うというのは問題ありません。
float f = 1.0f; uint32_t u; std::memcpy(&u, &f, sizeof u);
しかし、std::memcpy
を使うのではなくstd::bit_cast
を使ったほうがよいです。
std::bit_cast
を使うべき理由は、以下の三つです。
- 意図が明瞭になる
- 型のサイズがあっているかのチェックが働く
- 移植不可能な場合はコンパイルエラーになってくれる
- 未初期化変数があらわれない
const
変数に束縛できる- デフォルト構築可能でなくても使える
constexpr
文脈でも使える
std::memcpy
ではできるのにstd::bit_cast
ではできないのは以下の四つの場合ですが、いずれもそもそもやるべきではないものばかりです。
- 変換元、変換先、それらの中に含まれるメンバ等が……
- 共用体(
union
)の場合- 最後にアクセスしたメンバの種類、というのが明瞭ではなくなる
- ポインタ(メンバポインタ含む)や参照の場合
- 継承関係のナビゲーションがされなくなる。
static_cast
やdynamic_cast
、どうしても必要な時であってもreinterpret_cast
やconst_cast
を使うべき
- 継承関係のナビゲーションがされなくなる。
volatile
変数の場合
- 共用体(
- コピーが自明でない場合
符号付き整数のオーバーフローによる未定義動作
符号付き整数のオーバーフローは未定義動作です。 一方、符号無し整数の計算は2Nを法とした計算になると定義されており、未定義動作になりません。 したがって、二の補数表現であることを利用したオーバーフローを含む計算をしたい時、以下の手順を踏むことで未定義動作を回避することを考えます。
- 一旦符号無し整数に変換する
- 符号無し整数でオーバーフローを含む計算を行う
- 答えを符号付き整数に変換する
ここで、手順1と手順2は未定義動作を含みません。
しかし、手順3はstatic_cast
などを使ってしまうと未定義動作になる可能性があります。
こういうとき、std::bit_cast
を使うと良いでしょう。
なお、対称性を高めるために手順1にもstd::bit_cast
を使うというのもよいでしょう。
以下のような方法を使っても未定義動作が発生しないという意味において問題ありませんが、「私は二の補数表現を前提に符号無し整数に変換することを意図している」という情報がくみ取れなくなるかもしれません。
- 暗黙変換を使う(読解が困難なうえに意図がわかりづらいのでやめてほしい)
- 符号無し変数への代入
- 符号無し整数との整数演算・ビット毎論理演算
- 関数の引数に渡す
- Cスタイルキャストを使う(短くてわかりやすいが、Cスタイルキャストは邪悪と考えるプログラマもいる)
static_cast
を使う(仰々しいと感じるが、Cスタイルキャストが厳禁の場合に)std::memcpy
を使う(まずやらないと思いますが)- タイプパンニングを使う(
signed
とunsigned
の違いは互換性があるので問題ない。まさかやることはないと思いますが)
まとめ
- 未定義動作を防ぐため、
std::bit_cast
を使おう std::bit_cast
が有用なのは以下の場合- 使う必要性がなくても、「ビット表現を保ったまま別の型に変換したい」という意思表明に利用してもよいかもしれない
- 二の補数表現を前提に符号付き整数を符号無し整数に変換したい場合など
*1:私はこの二つしか知りません。ほかにありましたら、コメント等をいただけると助かります