[C++]type punningとオブジェクト生存期間

この記事は規格書や提案文書から読み取れる暗黙の気持ちを前提にしている部分があり、必ずしも出典や根拠が明確でない場合があります。

std::bit_cast

C++20で追加されたstd::bit_castはあるオブジェクトのバイト表現(ビット列)を維持したまま別のオブジェクトとして扱うことを可能とするライブラリ機能です。このようなことは、type punningと呼ばれます。

#include <bit>

std::uint64_t punning(double v) {
  // double -> uint64_tのバイト表現を保った変換
  return std::bit_cast<std::uint64_t>(v);
}

よく知られているようにtype punningをしているつもりの多くのコードは未定義動作に陥っており、std::bit_castはそれを合法かつ安全に、しかも定数式でも行うことができる唯一の方法です。

しかし、std::bit_castが未定義動作を起こさない理由はどこにあって、他の方法はなぜ未定義動作となるのでしょうか?

C++20以前の方法

以下の3つの方法のうち、最初の2つは未定義動作となる危険な方法です。

reinterpret_cast

reinterpret_castはこういう場合にまず思いつく方法だと思います。

#include <bit>

std::uint64_t punning(double v) {
  auto* p reinterpret_cast<const std::uint64_t*>(&v);
  return *p;
}

ポインタは単にメモリアドレスであってメモリのどこかを指していてそこから値を読み出すだけなので、これは動作しそうに思えます。不思議ですね。

union

もうひとつよく知られているのは、unionを使用した方法です。

#include <bit>

std::uint64_t punning(double v) {
  union U {
    double d;
    std::uint64_t n;
  };

  // doubleで初期化
  U u{v};

  // uint64_tとして読み出し
  return u.n;
}

これはC言語においては合法ですが、C++においては未定義動作とされます。共用体のメモリレイアウトを考えればu.du.nも同じメモリ領域を参照しているのだから、これも動作しそうに思えます。不思議ですね。

memcpy

これは有名なわりに知名度がない気がするのですが、memcpyを用いた方法があります。

#include <bit>

std::uint64_t punning(double v) {
  std::uint64_t n;

  // nの領域にvの値をコピー
  std::memcpy(&n, &v, sizeof(std::uint64_t));

  return n;
}

なんと、これはC++においても合法です。なんででしょうか、不思議ですね。

std::bit_castmemcpyの方法

memcpyによる方法はC++17までの世界でtype punningを合法的に行う唯一の方法であり、std::bit_castはそれを1つの関数にしたものです。従って、この2つの方法はおおよそ同じことをしていると思うことができます。

memcpyの方法を汎用的に書くと次のようになります(細かい制約は省略します)。

template<typename To, typename From>
To bit_cast_by_memcpy(const From& from) {
  To to;  // デフォルト構築

  std::memcpy(&to, &from, sizeof(To));

  return to;  // 暗黙ムーブ?
}

こうしてみると、変換先の型Toにデフォルト構築可能であることを要求していることが分かりやすくなります。あと多分Toは少なくともムーブ構築可能である必要がありそうです。

この方法が好かれない?のは、memcpyを用いている点でしょう。他の方法はゼロコピーで行えるのに(unionによる方法はよく見るとコピーしてますが)、この方法はmemcpyという明確なコピー操作を伴っています(実際には最適化によって取り除かれうるようです)。また、memcpyconstexprではないことからC++では定数式でこの方法を実行できなくなっています。

std::bit_castmemcpyの方法にあるそれらの問題を解消したものでもあります。その利点とは

  • type punningを関数呼び出しとして1行で書ける
  • 定数式で使用可能
  • Toの型にデフォルト構築可能性を要求しない
  • のぞき穴的最適化が効きやすくなる
    • コピーを削減しやすくなる

などが考えられます。

より正確には、std::bit_castはその実装については何も指定していません。C++23 ワーキングドラフトの[bit.cast]から翻訳すると、戻り値に関して次のように指定されているだけです

(戻り値は)To型のオブジェクト。 結果の型(To)にネストされたオブジェクト(基底クラスやメンバ)を暗黙的に作成する。 結果オブジェクトの値表現の各ビットは、fromオブジェクトの対応するビットと等しくなる。 ...(以下パディングビットの扱いや、ビット表現に対応する値が無い場合、fromのオブジェクト(サブオブジェクト)が生存期間の外にある場合、などについて述べられている)

ほとんどの場合、これはコンパイラの組み込み命令(通称コンパイラマジック)によって実装されるでしょう。

合法的type punning手法の境界

よく、reinterpret_castunionによる方法が未定義動作となるのはStrict Aliasing Ruleに抵触しているからだと言われます。たしかにmemcpyの方法は抵触していませんが、std::bit_castはどうなのでしょうか?std::bit_castが合法なのはそういう関数だからでしょうか?それともコンパイラマジックで実装されるから?もちろん、そうではありません。

その境界線を別つのは、オブジェクトの生存期間(lifetime)という概念です。合法で行える方法は常に生存期間内にあるオブジェクトを参照してしかいません。

unionによる方法を見てみましょう

#include <bit>

std::uint64_t punning(double v) {
  union U {
    double d;
    std::uint64_t n;
  };

  // doubleで初期化
  U u{v}; // (1)

  // uint64_tとして読み出し
  return u.n; // (2)
}

C++の共用体オブジェクトが生存期間内にある場合、そのメンバ変数はいずれか1つだけが生存期間内にあります。このメンバのことを特にアクティブメンバと呼びます。(1)の部分で初期化されているのはu.dのみであり、以降この関数内で生存期間内にあるUのメンバ(アクティブメンバ)はu.dのみです。

C++において、生存期間外にあるオブジェクトの操作(値の読み出しや非静的メンバ関数/変数の使用)は未定義動作です([basic.life]/4)。したがって、unionの方法が未定義となるのは(2)で非アクティブなメンバの値を読み出そうとしているところです。

#include <bit>

std::uint64_t punning(double v) {
  union U {
    double d;
    std::uint64_t n;
  };

  U u{v}; // ok、u.dをアクティブメンバとして初期化、u.dの生存期間が開始

  return u.n; // ub、u.nの生存期間は始まっていない(アクティブメンバはu.d)
}

共用体のアクティブメンバを切り替えるには明示的に非アクティブなメンバを初期化する必要がありますが、この関数ではそれは行われていません。

U u{1.0}; // u.dがアクティブメンバ

{
  double d = u.d; // ok
  int n = u.n;    // ub、u.nは生存期間外
}

u.n = 10; // ok、u.dの生存期間は終了しu.nの生存期間が開始される

{
  double d = u.d; // ub、u.dは生存期間外
  int n = u.n;    // ok
}

これを踏まえると、reinterpret_castの方法もポインタの参照先で生存期間内にあるオブジェクトとは別のオブジェクトに対してアクセスしようとしているため、未定義動作を踏んでいることがわかります。

#include <bit>

std::uint64_t punning(double v) {
  auto* p reinterpret_cast<const std::uint64_t*>(&v); // vには当然、double型のオブジェクトが生存期間内にある
  return *p;  // ub、pの参照先ではuint64_tのオブジェクトが生存期間内にない
}

std::bit_castが未定義とならない理由は、その効果が明確にToのオブジェクトを返すと指定されていることによります。これは生存期間内にあるオブジェクトを返すという意味であり、その実装の如何によらず戻り値のオブジェクトは生存期間内にあることが保証され、コンパイラはそう認識します。合法性を保つ重要な点は返されるオブジェクトが入力のバイト表現を保ちながらも生存期間が開始していることが保証されているところにあります。

memcpyの方法が未定義とならない理由は生存期間が既に開始しているオブジェクトに対して、別のオブジェクト由来のバイト表現をコピーしているからです。memcpyによるバイト表現のコピーによる書き換えはその場所にあるオブジェクトの生存期間を終了させないため、コピー前後でオブジェクトは生存期間内にあり続けます。これはmemmoveを使用しても同じ保証が得られますが、それ以外に同様の保証がある関数は無いようです。

余談

ところで、memcpyの方法が真に合法であることを確認するには、バイト表現をmemcpyによってコピーした時でもオブジェクトの有効性は保たれ続けるのかということを確認しなければなりません。巷にある説明(つまりググって出てきたもの)にはその部分に言及しているものが見つからず、memcpyの方法は合法だから合法なのだという感じで終了している気がします。

cppreferenceのstd::memcpyのページには、以前(2015)から次のような記述があります

Where strict aliasing prohibits examining the same memory as values of two different types, std::memcpy may be used to convert the values.

これはCのリファレンスから持ってきたもののようで、おそらくCからずっとmemcpyにはこういう効果があったのでしょう。

じゃあこれを裏付ける規定は規格書のどこにあるのか?というと見つかりませんでした・・・

近しい規定は[basic.types.general]/2

Trivially Copyableな型Tの任意のオブジェクト(potentially-overlappingサブオブジェクトを除く)は、そのオブジェクトが型Tの有効な値を保持しているかどうかに関わらず、基礎となるバイトをchar, unsigned char, std::byteの配列にコピーすることができる。
その配列の内容が元のオブジェクトにコピーし直された場合、オブジェクトはその後元の値を保持する。

constexpr std::size_t N = sizeof(T);
char buf[N];
T obj;                          // objは元の値で初期化されている
std::memcpy(buf, &obj, N);      // この2つのmemcpy呼び出しの間にobjが変更されたとしても
std::memcpy(&obj, buf, N);      // この時点で、objのスカラ型のサブオブジェクトは元の値を保持する

及び[basic.types.general]/3に見つけることができます。

Trivially Copyableな型Tの異なる2つのオブジェクトobj1, obj2obj1obj2potentially-overlappingサブオブジェクトではない)は、obj1を構成する基礎となるバイトがobj2にコピーされると、その後obj2obj1と同じ値を保持する。

T* t1p;
T* t2p;
    // t2pは初期化済のオブジェクトを指しているとする
std::memcpy(t1p, t2p, sizeof(T));
    // この時点で、*t1pのTrivially Copyableな型の全てのサブオブジェクトには、*t1pの対応するサブオブジェクトと同じ値が含まれる

ここに書かれているのは、まず、Trivially Copyableな任意の型Tのオブジェクトについてバイト表現をバイト列(char, std::byte等の配列)としてコピーすることができて、別の場所にコピーしたものを後で書き戻した後で、そのオブジェクトはコピーした時点と同じ値を持つということです。そして、同じTrivially Copyableな型Tのオブジェクトの間でバイト表現を直接コピーすることもできて、その場合はコピー元とコピー先オブジェクトは同じ値を持つということです。

これら2つの規定からまず読み取れることは、memcpyによるバイト表現の上書きコピーはオブジェクトの生存期間を終了させないということです。

そして、これら2つのケースにおいてコピー元バイト列の出所を気にしないようにすると(すなわち、バイト列のコピーとはいつもそのバイト列に対応する値からのコピーであると思うようにすると)、memcpyによるtype punningの合法性が導けます。

ただし、std::bit_castの規定にあるように、不定値やそれを含むオブジェクトからのコピーや、コピーしたバイト表現に対応する値をコピー先オブジェクトが持たない場合、結果は未規定ないし未定義動作になるでしょう。

とはいえ、これらの推測は明確に書かれていることではないので間違っているかもしれません(ご指摘歓迎です)。しかし、memcpyによるtype punningが合法であるのは間違いないはずです。

制約について

ここまで特に触れていませんが、ここまで書かれていることは型がTrivially Copyableであることを前提としています。std::bit_castmemcpyによるtype punningを合法的に行うにはToFromの型が共にTrivially Copyableでありサイズが同じでなければなりません。例えば、std::vectorのような型(確実にTrivially Copyableではない)を合法的にtype punningする方法は存在しないし、int32_tdouble(通常8バイト)としてpunningすることもできないわけです。

前項のmemcpyの規定に関しても、型がTrivially Copyableである場合にのみ保証されています。

おわり

ここまで読むと、type punningの合法性についてStrict Aliasing Ruleに抵触しているからという理由は間違っているのか?と思うかもしれませんが、そうではありません。この記事の最終的な主張はここで述べていることとStrict Aliasing Ruleによることは同じ事について別の側面から見たものにすぎず、同じことを言っているという事です。

実のところ、これはそのような主張のポエムの序文です。次回は、ここでのreinterpret_castによるtype punningのコードから出発して、オブジェクト生存期間とポインタについての独自研究が展開される予定です。

参考文献

この記事のMarkdownソース