[C++]メンバに参照型を持つクラス(構造体)の取り扱い

C++のクラスはそのメンバとして任意の参照型を持つことができます。その初期化はコンストラクタ初期化子のみで行えますが、それ以降参照そのものを変更することはできません(その変数に対する代入等の操作は全て参照先に対する操作になるため)。そのようなクラスの取り扱いについてのまとめです。
以下のような簡単な実装で考えていきます。

struct Test {
    Test(int& value) : m_ref{value}
    {}
    
    operator int() const {
        return m_ref;
    }
        
private:
    int& m_ref;
};

特性

先ほど定義したTest型はコンストラクタのみを明示的に定義していますが、その他の特殊関数を宣言も定義もしていないため、コンパイラによって暗黙的に定義されていることが期待されます。
しかし、その場合に構築や代入は何が許されて何が許されないのでしょうか?type_traitsヘッダのメタ関数を用いて先ほどのTest型に対して以下のような特性を見てみます。

    std::cout << std::is_trivially_copyable_v<Test> << std::endl;
    std::cout << std::is_trivial_v<Test> << std::endl;
    std::cout << std::is_pod_v<Test> << std::endl;
    std::cout << std::is_copy_constructible_v<Test> << std::endl;
    std::cout << std::is_move_constructible_v<Test> << std::endl;
    std::cout << std::is_copy_assignable_v<Test> << std::endl;
    std::cout << std::is_move_assignable_v<Test> << std::endl;

コードと実行結果
[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

結果の一覧

特性 type_traits 結果
トリビアルコピー可能 is_trivially_copyable true
トリビアル is_trivial false
POD型 is_pod false
コピー構築可 is_copy_constructible true
ムーブ構築可 is_move_constructible true
コピー代入可 is_copy_assignable false
ムーブ代入可 is_move_assignable false

構築に関してはコピー/ムーブともに可能ですが、代入は一切できません。自分で定義したとしてもコピー/ムーブコンストラクタではコンストラクタ初期化子を使えるので、言われてみれば納得の結果です。
また、デフォルトコンストラクタが無いのでトリビアル型(コンストラクタとコピーコンストラクタを自分で定義していない型、default指定はok)にはなりえず、そのためPOD型(C言語のstruct/unionと互換性のある型)にもなれないのも分かります。
トリビアルコピー可能(memcopyでコピーしても問題ない)がtrueになっているのはその条件を満たしているためですが、どう考えてもmemcopyしない方が良いでしょう。
トリビアル~、というのはコンパイラが暗黙的に生成してくれる各特殊関数について、自分で定義していない状態の事です。ただし、default指定をした場合はトリビアルであるとして扱われます。

コードにすれば以下のようになります。

int n = 1024;
Test t1{n};
Test t2{t1};   //コピー構築、ok
Test t3{std::move(t1)};   //ムーブ構築、ok

int m = 2048;
Test t4{m};
t4 = t3;   //コピー代入、ng
t4 = std::move(t3);   //ムーブ代入、ng
集成体(Aggregate)の場合

参照型メンバの初期化はコンストラクタ初期化子のみで行えると言いましたが、実は集成体にすることもできます。

struct Test2 {
    int& ref;
    
    operator int() const {
        return ref;
    }
};

//集成体初期化が可能
int v = 1;
Test2 t2 = {v};

こうしたとしても性質はほとんど変わりません。

特性 type_traits 結果
トリビアルコピー可能 is_trivially_copyable true
トリビアル is_trivial true
POD型 is_pod false
コピー構築可 is_copy_constructible true
ムーブ構築可 is_move_constructible true
コピー代入可 is_copy_assignable false
ムーブ代入可 is_move_assignable false
集成体 is_aggregate true
デフォルト構築可 is_default_constructible false

コードと実行結果
[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

構築や代入に関してはそのままです。集成体なので、is_aggregateがtrueになります。そして、コンストラクタを一切定義していないためトリビアル型であると判定されるようになりますが、参照型が初期化必須のためデフォルト構築不可、という不思議な状態になります。そして、トリビアル型ではありますがPOD型にはなれません、C言語のライブラリ等には渡さないようにしましょう。

注意点

以上のように、メンバとして参照を持つ場合、何も考えずに定義しても少し変わった扱いになる事が分かります。
そして、そこに罠が潜んでいたりするものです・・・

コンストラクタで参照を初期化する場合の引数の型

これは罠というほどではない軽度な物ですが、割とミスりやすいものです。
先ほどのTest型で言うなら以下のようにしてしまった場合です。

struct Test {
    Test(int value) : m_ref{value}
    {}
    //省略
};

コンストラクタ引数を参照で受け取らずに初期化を試みています。多分コンパイルは通るでしょう。しかし実行されたとき、コンストラクタのvalueは渡された変数がコピーされて構築されます、そしてその参照でm_refを初期化します。ところが、valueはこのコンストラクタ内のローカル変数ですので、コンストラクタの処理が終わればその寿命も尽きます。つまり、m_refは不正な参照になってしまいます。これは未定義動作ですので何が起こるかはわかりません。

そのストレージ領域を再利用する場合(placement new)

こちらは完全に罠です、知らなければ気付きようもありません。しかし、これを行おうと思う事はめったにないのではないかとも思います。
placement newを使うとすでに存在している変数の領域を再初期化することが出来ます。その際にはコンストラクタを呼び出して初期化します。
これはどのような型の領域に対しても可能ですが、例外があります。
非静的メンバにconst修飾されたメンバ変数、もしくは参照型を持つ型に対するplacement new は未定義動作となります。すなわち、何が起こるかわかりません・・・
なぜでしょうか?
それは、この二つはコンストラクタで初期化されて以降は内容が変化しないとみなすことが出来るからです。それは最適化に利用されます。つまり、constもしくは参照型のメンバは値が変わらないため、定数として埋め込むことが出来ます。その結果、placement newで値を変更したつもりでも、埋め込まれた定数が帰り続けることが起こりえます。

int n = 256;
Test* t1 = new Test{n};   //通常のnew

int m = 512;
new(t1) Test{m};   //placement new、戻り値を捨てる

n = 1024;
m = 2048;

//ここで、t1はnとmのどちらを指すことになるかは未定義。
std::cout << t1 << std::endl;

これを解決する手段は主に二つあります。
一つは、placement newの戻り値を正しく利用することです。

int n = 256;
Test* t1 = new Test{n};   //通常のnew

int m = 512;
t1 = new(t1) Test{m};   //placement new、戻り値で更新(値としては変わらない)

n = 1024;
m = 2048;

//t1はmを指す。
std::cout << t1 << std::endl;

これはC++のバージョンに関係なく利用可能です、可能ならこうするべきです。

もう一つはC++17で追加されたstd::launderを利用することです。

int n = 256;
Test* t1 = new Test{n};   //通常のnew

int m = 512;
new(t1) Test{m};   //placement new、戻り値は捨てる。

n = 1024;
m = 2048;

//t1はmを指す。
std::cout << *std::launder(t1) << std::endl;

std::launderはポインタを受け取りそのポインタをそのまま返します。役割はコンパイラへ最適化抑制を指示することです。constメンバを含む型に対しても同様に利用できます。

自分の手でこんなことをすることはないでしょうが、もしかしたら知らぬ間にハマる危険があります。
例えば、デフォルトでないアロケータを指定された標準コンテナを利用する場合や、中身のよく分かっていないライブラリにこのような型を渡す場合等、自分のあずかり知らない所で引っかかるかもしれません。
その場合上記の解決策ではどうしようもないわけですが・・・