安全で便利なstd::bit_castを使おう

この記事は 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_castdynamic_cast、どうしても必要な時であってもreinterpret_castconst_castを使うべき
    • volatile変数の場合
  • コピーが自明でない場合

符号付き整数のオーバーフローによる未定義動作

符号付き整数のオーバーフローは未定義動作です。 一方、符号無し整数の計算は2Nを法とした計算になると定義されており、未定義動作になりません。 したがって、二の補数表現であることを利用したオーバーフローを含む計算をしたい時、以下の手順を踏むことで未定義動作を回避することを考えます。

  1. 一旦符号無し整数に変換する
  2. 符号無し整数でオーバーフローを含む計算を行う
  3. 答えを符号付き整数に変換する

ここで、手順1と手順2は未定義動作を含みません。 しかし、手順3はstatic_castなどを使ってしまうと未定義動作になる可能性があります。 こういうとき、std::bit_castを使うと良いでしょう。

なお、対称性を高めるために手順1にもstd::bit_castを使うというのもよいでしょう。 以下のような方法を使っても未定義動作が発生しないという意味において問題ありませんが、「私は二の補数表現を前提に符号無し整数に変換することを意図している」という情報がくみ取れなくなるかもしれません。

  • 暗黙変換を使う(読解が困難なうえに意図がわかりづらいのでやめてほしい)
    • 符号無し変数への代入
    • 符号無し整数との整数演算・ビット毎論理演算
    • 関数の引数に渡す
  • Cスタイルキャストを使う(短くてわかりやすいが、Cスタイルキャストは邪悪と考えるプログラマもいる)
  • static_castを使う(仰々しいと感じるが、Cスタイルキャストが厳禁の場合に)
  • std::memcpyを使う(まずやらないと思いますが)
  • タイプパンニングを使う(signedunsignedの違いは互換性があるので問題ない。まさかやることはないと思いますが)

まとめ

  • 未定義動作を防ぐため、std::bit_castを使おう
  • std::bit_castが有用なのは以下の場合
    • タイプパンニングを行いたい場合
    • 符号付き整数のオーバーフローを含む計算を行いたい場合
      • C++20で符号付き整数型のビット表現が二の補数であると規定されたため移植性が上がった
  • 使う必要性がなくても、「ビット表現を保ったまま別の型に変換したい」という意思表明に利用してもよいかもしれない
    • 二の補数表現を前提に符号付き整数を符号無し整数に変換したい場合など

*1:私はこの二つしか知りません。ほかにありましたら、コメント等をいただけると助かります