Chapter 02

ムーブセマンティクスと右辺値

dec9ue
dec9ue
2021.12.30に更新

前提:C++のオブジェクトの生存期間

ムーブセマンティクスを語る前に、前提知識となる、C++のオブジェクトの生存期間について確認します。

C++においてオブジェクトを確保したとき、そのライフサイクルは構文の側面から見てざっくり2種類あります。

左辺値と右辺値です。

左辺値(lvalue)

名前のついたオブジェクトです。名前のスコープが切れたところでデストラクタが呼ばれます。

.左辺値の例

#include <vector>

void func(){
    std::vector<int> v;
    // なにか処理1
    v.push_back(1);
    // なにか処理2
    v.push_back(2);
    // スコープが切れるところでvectorのデストラクタが呼ばれる
}

右辺値(rvalue)

名前のついていない、いわゆる一時オブジェクトです。式全体の評価が終わったときにデストラクタが呼ばれます。右辺値の代表的な例として、演算結果、(参照でない)関数の戻り値、リテラルなどが挙げられます。

右辺値は「はかない」オブジェクトです。名前で束縛しない限り、その式の中で消え去ってしまいます。

.右辺値の例

#include <vector>

void func(){
    int i = (std::vector<int>{1,2,3}).pop();
    // pop()の処理が終わったときにvectorのデストラクタが呼ばれる
    // なにか処理1
    // vectorにアクセスするための名前はない。デストラクタもコール済み。    
    i = i + 1;
    // なにか処理2
    i = i + 3;

    return;
}

この「はかない」右辺値がムーブセマンティクスと密接に関わってきます。

コピーとムーブ

ムーブセマンティクスのコアとなる、コピーとムーブの概念についてざっくり説明します。

次のような大きなデータを含む構造があったとします。

.大きなvectorの例

std::vector<int> someBigVector{0,1,2,..<<中略>>.., 1999999};

このような大きなデータでも複製は可能で、代入演算子やコピーコンストラクタを使用して複製します。

.vectorの複製

std::vector<int> otherBigVector0 = someBigVector;
std::vector<int> otherBigVector1(someBigVector);

一方で、 vector の内部のデータを複製せず、ポインタの挿げ替えだけをした方が効率が良い場合が多々あります。たとえば、データ構造の構築を行うファクトリ関数とデータの受け渡しをする場合などです。この場合、データを作成した関数の側ではもうそのオブジェクトを使用しないので、中身をそのまま「移動」してあげたほうが効率がいいということになります。

過去のC++では、こういうときに次のような方法がありました。

  • 呼び出し元で領域を確保し、参照に対してデータを書き込む。
  • newauto_ptr を使用してオブジェクト全体をダイナミックに確保してポインタ渡しする。
  • コピーコンストラクタ、代入演算子の複製版と移動版を作成して使い分ける。

このような方法では、記述や処理そのものが冗長になる、オブジェクトの種類によってコピーコンストラクタや代入演算子の意味合いが変わってくるなど混乱を招くことが問題になっていました。

C++11以降では「移動」に関わる処理はムーブコンストラクタ、ムーブ代入演算子というカテゴリを別途設けることで「コピー」とは処理を明確に区別します。こういう整理をしたほうが世界がシンプルになることがわかったという大進歩なのです。

ムーブコンストラクタ、ムーブ代入演算子の例

ムーブコンストラクタ、ムーブ代入演算子の記述例を示します。

.ムーブコンストラクタ、ムーブ代入演算子の例

class BigArray{
  private:
    char* big_data; // 説明のための生ポインタ、許せ

  public:
    BigArray() // デフォルトコンストラクタ
    :big_data(new char[BIG_SIZE]) // 説明のための(略
    {}

    BigArray(BigArray&& old) // ムーブコンストラクタ
    :big_data(old.big_data)
    {
        old.big_data = nullptr;
    }

    operator=(BigArray&& old) // ムーブ代入演算子
    {
        this.big_data = old.big_data;
        old.big_data = nullptr;
    }
};

大きな配列を確保するオブジェクトを持つ場合、むやみに複製を行うのは効率低下の原因になるため、ムーブコンストラクタ、ムーブ代入演算子が役に立ちます。

ムーブコンストラクタ、ムーブ代入演算子は新たにヒープ上のデータを確保するわけではなく、既存のデータをすげ替えることだけをします。移動のもととなった old オブジェクトは実質的に機能しなくなります。(以降、本ガイドではこれを「old が破壊された」と表現します。)

ここで、いわゆるコピー系の機能(コピーコンストラクタ、代入演算子)とムーブ系の機能(ムーブコンストラクタ、ムーブ代入演算子)のシグニチャを比較してみましょう。とても頻繁に利用する std::vector のシグニチャの一部を簡略化したものを挙げます。

.簡略化した std::vector のシグニチャの一部

namespace std {
    template <typename T>
    class vector
    {
      public:
        vector(); // デフォルトコンストラクタ
        vector(const vector&); // コピーコンストラクタ
        vector(vector&&); // ムーブコンストラクタ
        vector& operator=(const vector&); // (コピー)代入演算子
        vector& operator=(vector&&); // ムーブ代入演算子
    };
}

std::vector クラスのオブジェクトはコピー系、ムーブ系、両方の演算を持っています。

コピー系とムーブ系の区別として、コンストラクタ/代入演算子ともに、コピー系の処理は引数の型が const vector& になっているのに対し、ムーブ系の処理は引数の型が vector&& になっています。「&& ってなんだ?」となるかも知れませんが、これについて次のセクションでざっくり説明します。

ムーブコンストラクタ、ムーブ代入演算子の呼び出し方

謎の && という記号は右辺値参照と呼ばれるものです。関数の引数に書いた場合、右辺値にのみマッチします。なぜそのようなマッチングを取るのかというと、「破壊しても良い」対象を識別するためです。少なくとも、その式が完了したあとは破壊されるようなオブジェクトは「移動して破壊しても良い」と解釈されるということです。

.ムーブされる右辺値の例1

std::vector v;

v = std::vector{0,1,2,3}; // std::vector{0,1,2,3} は右辺値
// 呼び出される演算子はムーブ演算子 std::vector::operator=(std::vector&&)
// 右辺で生成されたvectorはムーブされ破壊される

左辺値を引数にした場合、右辺値参照にはマッチしません。したがって、コピー代入が行われることになります。

.ムーブされない左辺値の例1

std::vector v;
std::vector v1{0,1,2,3};

v = v1; // v1は名前をつけたので左辺値
// 呼び出される演算子はコピー代入演算子 std::vector::operator=(const std::vector&)
// 右辺にあるv1は左辺値なのでムーブされない

値返しする関数の戻り値も右辺値と認識されるので、ムーブ対象です。

.ムーブされる右辺値の例2

std::vector some_function();

std::vector v;

v = some_function(); // std::vector{0,1,2,3} は右辺値
// 呼び出される演算子はムーブ演算子 std::vector::operator=(std::vector&&)
// 右辺で生成されたvectorはムーブされ破壊される

では、一度名前をつけてしまったオブジェクトは左辺値になるのでムーブできないのか、と言われるとそういうわけではありません。std::move を使うことで、強制的に右辺値の扱いにすることができ、ムーブさせることができます。

ムーブしてしまったあとも左辺値のオブジェクトは残りますが、データとしては通常破壊されています。破壊されていることを型システム上検知する方法はないので、「気をつけて一度だけ実施してください」ということになります。

.ムーブされる左辺値の例1

std::vector v0 = some_function(); // 名前をつけたので左辺値になった

std::vector v;

v = std::move(v0); // 左辺値だがstd::moveでキャストすることで強制的にムーブできる

// ただし、実行がここまで来るとv0の中身はムーブ済みになっていて空っぽである

右辺値参照によるマッチング

関数呼び出し時に右辺値参照でマッチングされたオブジェクトは「破壊して良いオブジェクト」と認識するのがC++の不文律です。破壊するつもりの関数引数には基本的に && をつけて宣言し、std::move を使用してムーブ系の演算を実際に呼び出す関数まで渡すのがよくある記述パターンです。

.ムーブを行う関数の例1

MovableObj mc;

void moving_function1(MovableObj&& obj)
{
    mc = std::move(obj); // ムーブ代入を呼び出す
}

.ムーブを行う関数の例2

std::vector<MovableObj> mv;

void moving_function2(MovableObj&& obj)
{
    mv.push_back(std::move(obj));
    // std::vector<T>::push_back(T&&)は右辺値参照を受け取ると内部でムーブする
}

ここで例に挙げた関数(moving_functionx)は左辺値を引数に与えようとしてもマッチしないことに気をつける必要があります。

関数引数の型マッチングの整理

右辺値参照を引数に持つ関数について説明してきましたが、Modern C++で一般に使われる関数引数の型のパターンについて整理します。

型のパターン 典型的な使用ケース
T データをコピーして使用する際に使用
const T& 元のデータを読み出す際に使用
T& 元のデータを書き換える際に使用
T&& 元のデータをムーブして破壊する際に使用

T&&const がつくことはその性質上、ありえないと言えます。

T&&const T& / T& の間のオーバーロード関係について、T&& のオーバーロードがない場合、右辺値に対して const T& があればマッチします。T& のオーバーロードがないときに const T& にマッチするのと同じノリだと思ってもらって良いと思います。

気をつけたいのは T&&T& にだけマッチしたい場合はそれぞれのオーバーロードを用意する必要がある点です。もちろん、テンプレートを使えば簡単に実現できますが、それは本ガイドの記述の範囲外とします。

戻り値の指定の仕方

戻り値の値返しの基本

すでに例の中でさんざん使用してしまっていますが、戻り値にもムーブが使えます。というか、戻り値ではコピーよりもムーブが優先されます。具体的に言うと、戻り値では左辺値を書いても右辺値のように扱われるため、std::move を書く必要がありません。return 文の中の式は常にその関数の中の最後の式になるため、破壊してもいいよという感覚なのでしょう。

.リターン時にムーブが優先される例

MovableObj function(){
    MovableObj mc;
    return mc; // ムーブが優先される
}

ムーブできないオブジェクトの場合、コピーを試みます。(ムーブもコピーもできないオブジェクトはスコープから出られないのでコンパイルエラー、ですね)

RVOとNRVO

ただ、ここまでで説明をやめると、四方八方から大量に矢が飛んできます。「RVOとかNRVOがあるだろ!」って。。。これは基礎知識として知っておいたほうがいい気がするので説明します。(かったるそうだなと思ったらこのセクションは一旦読み飛ばしてもらっても大丈夫です)

実は先ほどの例で「ムーブが優先される」と曖昧に書いたのですが、このまま実装するとコピーもムーブもしないコードを生成するコンパイラが多いです。その理由がRVO、NRVOです。まず、RVOから説明します。

RVO(Return Value Optimization)はざっくりいうと「右辺値を値返しするコードを書いたときに、呼び出し元が確保した領域にオブジェクトを構築する仕組み」です。

.RVOの例

MovableObj function()
{
    return MovableObj(); // <- この右辺値が確保されるのは呼び出し元のメモリ領域
}

int main()
{
    MovableObj mc = function(); // <- 最初からここにオブジェクトが割り当てられる
}

MovableObjmain 側に確保すればコピーもムーブもいらないから速いよね。当然じゃん。

C++17以降、RVOはコンパイラのサポートが必須とされました。だから大体のコンパイラはRVOベースでコードを吐いてくれると思います。なお、RVOが効いた場合、コピー/ムーブ系の処理の実装がなくてもコンパイルが通るようです。

この考え方を左辺値にも適用するのがNRVO(Named Return Value Optimization)で、「左辺値を値返しするコードを書いたときに、呼び出し元が確保した領域にオブジェクトを構築する仕組み」です。

.NRVOの例

MovableObj function()
{
    MovableObj m; // <- NRVO有効時、この左辺値が確保されるのは呼び出し元のメモリ領域
    return m;
}

int main()
{
    MovableObj mc = function(); // <- NRVO有効時は最初からここにオブジェクトが割り当てられる
}

が。NRVOはRVOと違ってコンパイラのサポートが必須とされていません。というか、そもそもその性質上、常に適用できるわけではありません。

.NRVOがうまくいかない例

MovableObj function()
{
    MovableObj m1, m2; // <- m1 と m2 どちらに NRVO を適用していいかがオブジェクトの生成時点で決まらない!
    if(some_condition())
    {
        return m1;
    } else {
        return m2;
    }
}

int main()
{
    MovableObj mc = function();
}

NRVOはコンテナの構築のときなどにとても好都合だと思いますが、常に有効なわけではないので、「ここはムーブになっちゃうかもなー、まぁでも、ムーブになってもいいか」くらいの感覚で使うと良いと思います。

.NRVOを期待したmapの初期化

#include <map>

std::map<int,std::string> createMap()
{
    std::map<int,std::string> m;
    // 何かすごーく複雑な初期化をやるつもり
    return m; // mはNRVOかムーブで返却される
}

int main()
{
    std::map<int,std::string> m = createMap();
}

後の解析などを考えるとムーブとRVO/NRVOで挙動に差異が出ないよう注意してコーディングするのが無難だと思います。