大学でC++の演習が始まったがムーブには触れないようなので触れさせる

弊学ではC++の演習が始まったが演習では右辺値参照とかムーブとかは全く触れない。ムーブがないC++C++ではなくBetter Cというのはさすがに言い過ぎだが (C++03以前のC++C++ではなくなってしまうため)、ムーブがないC++を書くならわざわざ大学でC++を学ぶ意味はない。ムーブという概念を知るにあたってvalue categoryは非常に重要だ。この記事ではvalue categoryからムーブまでを解説する。

私はこの記事を書くにあたって規格書を少し読んでいる。英語力がクソ雑魚なので翻訳のミスや解釈違いがあればマサカリを投げずに優しく教えてほしい。

rvalue

rvalueはもともと代入演算子の右にある値という意味しか持っていなかった。C++ではもはやそんな単純な話ではなくなってしまった。C++11以降のrvaluexvalueprvalueのことを指す。式は以下の図のような分類でカテゴライズされる。

N4810 7.2.1 Figure 1 - Expression category texonomy

xvalue

An xvalue is a glvalue that denotes an object or bit-field whose resources can be reused (usually because it is near the end of its lifetime).*1

xvalueは、その寿命の終わりが近いためリソースを再利用することができるオブジェクトを指すglvalueだ。xvalueは右辺値参照を返す関数の呼び出す式(1)、右辺値参照へのキャスト式(2)、xvalueの配列に対する添え字演算(3)、オブジェクト式がxvalueである非参照型の非静的データメンバを指定するクラスメンバアクセス式(4)か若しくは最初のオペランドがxvalueで二番目のオペランドがデータメンバへのポインタである.* (pointer-to-member)演算式(5)はxvalueである。

規格書の文言を眺めるよりも実際のソースコードを見たほうが理解しやすいはずだ。

struct A{ int m; };
A&& f();
A a;
A arr[10];
auto m_ptr = &A::m;

// 1)
f();
// 2)
static_cast<A&&>(a);
// 3)
std::move(arr)[0];
// 4)
f().m;
// 5)
f().*m_ptr;

prvalue

N4810ではprvalueについて以下のように書かれている。

A prvalue is an expression whose evaluation initializes an object or a bit-field, or computes the value of an operand of an operator, as specified by the context in which it appears, or an expression that has type cv void.*2

prvalueはその評価がオブジェクトまたはビットフィールドを初期化するか、それが表れる文脈で指定される演算子オペランドを計算する式か、若しくはcv void型をもつ式だ。

prvalueをオペランドに期待する演算子にglvalueが現れたときは常に lvalue-to-rvalue, array-to-pointerもしくは function-to-pointer変換が式をprvalueに変換するため適用される (lvalueをrvalue referenceに束縛するような試みはそのような文脈ではない)。

prvalueは常に完全型かvoid型をもつ。prvalueがクラス型かクラスの (多次元) 配列型を持つ場合、クラスは抽象型ではない。

規格書の文言を見つめるより実際のコードを見たほうが理解しやすいだろう。

int f();
auto il = {0};
auto it = il.begin();

enum e { c = 0 };

// リテラルはprvalue
1; "hoge";
// 参照型でない関数の戻り値はprvalue
f();
// 参照型ではない結果を持つ演算子の結果はprvalue
1+1;
it++;
// 組み込みのアドレス演算子
&il;
// 非参照型へのキャストはprvalue
(int)10;
// 列挙子はprvalue
e::c;
// そのほかthisポインタなど

ムーブとrvalue reference

ある構造体が他の巨大なオブジェクトへのポインタを持っているとしよう。深いコピーを行うようなコピーコンストラクタとコピー代入演算子がプログラムされたその構造体は頻繁に関数の戻り値になったりする。

struct A {
    super_big_type* m;
    A() = default;
    A(const A& src)
        : m{ new super_big_type }
    {
        memcpy(src.m, m, sizeof super_big_type);
    }
    A& operator=(const A& src) & noexcept
    {
        m = new super_big_type;
        memcpy(src.m, m, sizeof super_big_type);
    }
    // デストラクタの定義は省略...
};

A f();

A a;
a = f(); // copy

コピーしかできないA構造体はf()の戻り値のオブジェクトを直接使うことはないのに、いつもいつも別の領域にメモリを確保してsuper_big_typeをコピーしなければならない。ああ、なんと無駄なことか!これがmのコピーだけであったらどんなに動作が速くなることか!

ムーブとはそういう問題を解決する。ところが、いつムーブして、いつコピーすればいいのかを明確に判断するのは従来のC++では難しかった。そこでrvalue referenceが登場した。先に解説したようにrvalueはprvalueとxvalueのことだった。つまりすでに寿命がつきかけているか、リテラルや参照でない関数の戻り値のようなオブジェクトだ。そのようなオブジェクトはもう使用されないことが前提だ。rvalue referenceとはそのようなオブジェクトへの参照だ。

C++ではrvalue referenceを受け取る代入演算子とコンストラクタをムーブ代入演算子/ムーブコンストラクタと呼び、その動作をムーブと呼ぶ。rvalue referenceに束縛されているオブジェクトはこれ以降使われないので、どのように弄繰り回しても問題ない。先ほどの例で言えばrvalue referenceに束縛されているオブジェクトのデータメンバmnullptrを代入したりしてもよい。

struct A {
    super_big_type* m;
    A() = default;
    A(const A& src)
        : m{ new super_big_type }
    {
        memcpy(src.m, m, sizeof super_big_type);
    }
    // ムーブコンストラクタ
    A(A&& src)
        : m{ src.m }
    {
        src.m = nullptr;
    }
    A& operator=(A&& src) & noexcept
    {
        m = src.m;
        src.m = nullptr;
    }
    // コピー代入演算子とデストラクタの定義は省略...
};

A f();

A a;
a = f(); // ムーブ!!

便利な標準ライブラリ関数 std::move std::forward

時にはlvalueをムーブしたいときもあるだろう。lvalueをrvalue referenceにキャストすればその目的はすぐに達成できる。

struct A{};

A a, b;
a = static_cast<A&&>(b); // move

ただし、この記述は冗長だ。標準ライブラリにはオブジェクトをrvalue referenceにキャストしてくれる関数がある。std::move()だ。

a = std::move(b);  // move

std::move()を使えば左辺値だろうが何だろうがとにかく全部rvalue referenceにキャストしてくれる。これにて一件落着....とはいかないのがC++だ。本当にすべてムーブしても良いのだろうか。以下のようなとあるオブジェクトを生成する関数を考えよう。

template<class T, class ...Args>
std::remove_reference_t<T> make(Args&& ...args) {
    return std::remove_reference_t<T>{ std::move(args)... };
}

std::vector<int> a{1, 2, 3};
auto b = make<std::vector<int>>(a);  // 本当はコピーしたい
assert(a == b);

aというlvalueを渡しているのだから、当然abに"コピー"されるのかと思いきや、この場合アサーションは常に失敗する。create関数の中でaをムーブしてしまうからだ。引数がlvalue referenceの時lvalue referenceを返し、それ以外の時はrvalue referenceを返すような仕組みが必要だ。もちろんC++の標準ライブラリにはstd::forwardがその仕組みを持っている。以下はstd::forwardの実装だ

template <class _Ty>
constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept {
    return static_cast<_Ty&&>(_Arg);
}

template <class _Ty>
constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept {
    static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
    return static_cast<_Ty&&>(_Arg);
}

例えばiint型の左辺値だとしてstd::forward<int&>(i)と書けば最初のオーバーロードに解決される。最初のオーバーロードでは_Ty&&は参照の圧縮によってint&と同じになる。

また、int f()があるとき、std::forward<int>(f())と書けば2番目のオーバーロードに解決される。参照型ではない関数の戻り値は左辺値参照になり得ないためだ。この場合はforwardの戻り値はint&&型となる。

通常は起こり得ないが、std::forward<int&>(f())コンパイルエラーとなるはずだ。

先ほどのアサーションが通るようなmakeの実装は以下のようになる。

template<class T, class ...Args>
std::remove_reference_t<T> make(Args&& ...args) {
    return std::remove_reference_t<T>{ std::forward<Args>(args)... }; // argsがlvalue referenceの時はムーブしない
}
std::vector<int> a{1, 2, 3};
auto b = make<std::vector<int>>(a);  // コピーしたい
assert(a == b);
auto c = make<std::vector<int>>(std::move(a)); // ムーブしたい

*1:N4810 7.2.1

*2:N4810 7.2.1