右辺値参照とムーブ | Programming Place Plus Modern C++編【言語解説】 第14章

トップページModern C++編

Modern C++編は作りかけで、更新が停止しています。代わりに、C++14 をベースにして、その他の方針についても見直しを行った、新C++編を作成しています。
Modern C++編は削除される予定です。

この章の概要 🔗

この章の概要です。


右辺値参照 🔗

第12章で参照という機能を説明し、そのうちの1つである左辺値参照を使う具体的な場面を第13章で取り上げました。本章では、参照のもう1つの形である右辺値参照について説明します。右辺値参照は C++11 で追加された新しい機能です。

右辺値参照は名前のとおり、右辺値を参照するものです。右辺値参照は、「参照するものの型」に「&&」を付加して表現します。たとえば、「int&&」は int型の右辺値を参照する右辺値参照の型名です。

int num = 100;      // num は左辺値

int&& ref1 = 100;   // OK。100 は右辺値
int&& ref2 = num;   // コンパイルエラー。左辺値は参照できない
int&& ref3 = ref1;  // コンパイルエラー。右辺値参照自身は左辺値

テンプレート仮引数の型や、第19章で説明する auto に && が付いている場合は、見た目の上では右辺値参照のように見えますが異なる動作をします。

template <typename T>
void f(T&& v);  // v は転送参照

auto&& x = y;   // x は転送参照

この場合は右辺値参照ではなく、転送参照(ユニヴァーサル参照)と呼ばれています。転送参照に関する話題は第29章であらためて取り上げることにしますが、とりあえずは、見た目が同じでも異なる動作をする場面があることを覚えておいてください。

ムーブ 🔗

前章でコピーについて説明しましたが、これに関連してムーブという機能があります。ムーブは C++11 で追加された機能です。

ムーブは、オブジェクトをコピーする際の処理コストを削減するために追加された機能です。a を b にコピーする場合、a の複製を生成して b へ代入しますが、これは場合によっては非常に無駄が大きいです。その最たる例が、ローカル変数をコピーして返却する次のような処理です。

MyClass f()
{
    MyClass c1;
    return c1;
}

int main()
{
    MyClass c2 = f();
}

C++ の挙動では、c1 は f関数のローカル変数なので、f関数を抜け出すときにデストラクタが呼ばれて破棄されるはずです。しかし、戻り値として返却せねばならないので、一時オブジェクトが作られるのでした。
しかし、f関数の呼び出し元で生成しようとしている c2 は実質、c1 とまったく同じになるのですから、「c1 のコピーを作る」「c1 を破棄する」の部分は無駄になっています。そこで、ムーブの出番です。つまり、c1 を破棄せずに c2 のところへ移動(ムーブ)させようというのです。破棄しなければコピーを作る必要もありません。

この場面でムーブを実現するためには、ムーブコンストラクタが必要です。コピーコンストラクタに似ていますが、両者はムーブによって生成するのか、コピーによって生成するのかという点で役割に違いがあります。ムーブコンストラクタについては、次の項で解説します

また、コピー代入演算子に対応して、ムーブ代入演算子というものもあります。これについても、後の項で取り上げます

ムーブコンストラクタ 🔗

仮引数が、自身のクラス型の右辺値参照になっているコンストラクタを、ムーブコンストラクタと呼びます。

#include <iostream>

class MyClass {
public:
    MyClass() = default;
    MyClass(MyClass&& rhs)
    {
        std::cout << "move construtor" << std::endl;
    }
};

MyClass f()
{
    MyClass c1;
    return c1;
}

int main()
{
    MyClass c2 = f();
}

実行結果:

move construtor

ムーブコンストラクタの仮引数は、自身のクラス型の右辺値参照です。コピーコンストラクタと違って、const は付けません。ムーブ元から情報を移動させるという役割を踏まえると、ムーブ元には情報が残らないと考えられるので、const にできたとしても、const ではない方が自然です。

なお、ムーブコンストラクタは例外を送出するべきではありませんから、本来は noexcept を付加するべきです。例外は第18章で解説します。

f関数の戻り値は、一時オブジェクト、つまり右辺値です。そのため、c2 を生成するときに、右辺値を引数に取るムーブコンストラクタが使われます。しかし、これまで同じ場面においては、コピーコンストラクタが使われるという話でした。なぜ今回はムーブコンストラクタが呼ばれるのでしょうか。ポイントが2点あります。

1点目として、コピーコンストラクタの仮引数は const左辺値参照(第12章)ですが、これは右辺値を参照することも可能なのでした。実際、C++03以前においては、右辺値を参照する手段はこれしかありませんでした。そのため、右辺値を使ってインスタンス化を行おうとするときに、コピーコンストラクタを使うこと自体、何もおかしな点はありません。

2点目として、前章までの話の中では、単にムーブコンストラクタがなかったということです。いつものように、ムーブコンストラクタも、コンパイラが自動生成することがありますが(これについては後述しています)、コピーコンストラクタやコピー代入演算子あるいは、デストラクタが明示的に実装されていると、ムーブコンストラクタは生成されなくなります。

インスタンス化を行うときに使う実引数が右辺値の場合に、コピーコンストラクタとムーブコンストラクタが両方あるのならば、ムーブコンストラクタの方がより正確に型が一致するので、ムーブコンストラクタが選択されますが、一方しか存在していないのならば、単にそれが選択されます。そのため、これまでは同じような場面でコピーコンストラクタが呼ばれたが、今回のサンプルではムーブコンストラクタが呼ばれたということです。

ここまでの話を総合して、すべてのクラスがムーブ可能であるというわけではないという点も理解しておいてください。ムーブできない場合にムーブしようとすると、コピーとして動作します。もちろん、コピーが禁止されていたら(第13章)、それすらもできません。

ムーブコンストラクタは、各メンバ変数を1つ1つムーブ、あるいはコピーするように実装します。

メンバ変数にポインタが含まれていると、コピーの場合と同様に問題が起こる可能性があります第13章)。コピーの場合は、動的に確保された領域を指すポインタ変数が2つできてしまい、後から解体される方のデストラクタで二重解放が起きてしまうのでした。

ムーブの場合は、ムーブ元になった右辺値は、ムーブ直後に消えてしまうのが普通なので、ムーブ直後にデストラクタが呼ばれて解放されてしまいます。結果的にムーブ先の方が持っているポインタ変数が指し示す先には、もう何もないという状態になります。

この問題を解決するためには、ムーブ時に、ムーブ元が持っているポインタ変数に nullptr を代入します。

#include <iostream>

class Name {
public:
    Name(const char* name)
    {
        mName = static_cast<char*>(std::malloc(std::strlen(name) + 1));
        std::strcpy(mName, name);
    }

    Name(Name&& rhs) : mName(rhs.mName)
    {
        rhs.mName = nullptr;
    }

    ~Name()
    {
        std::free(mName);
    }


    const char* GetName() const
    {
        return mName;
    }

private:
    char*  mName;
};

Name f()
{
    Name name = "Ken";
    return name;
}

int main()
{
    Name name = f();
    std::cout << name.GetName() << std::endl;
}

実行結果:

Ken

Name::mName は動的に確保された領域を指すポインタです。ムーブコンストラクタでは、ポインタそのものをコピーした後、ムーブ元の方には nullptr を代入しています。ヌルポインタに対する std::free関数は何も起こりませんから、ムーブ直後に呼び出される一時オブジェクトに対するデストラクタでは何も起こらなくなり、正しくムーブ先に受け継ぐことができます。

Name::mName は単なるポインタであり、コピーしてもムーブしても差は無いため、ここではコピーをしていますが、ムーブした方が効率的な型のメンバは、明示的にムーブするようにします。明示的にムーブさせるためには、標準ライブラリの std::move関数 を使用します。std::move関数は <utility> という標準ヘッダに含まれています。

std::move関数など、標準ライブラリに含まれているいくつかのユーティリティを、【標準ライブラリ】第2章で取り上げていますので、そちらも参照してください。

次のサンプルは、Nameクラスのオブジェクトをメンバ変数に持つ Studentクラスを定義しています。Studentクラスのムーブコンストラクタでは、std::move関数を使って、Nameクラスのオブジェクトをムーブしています。

class Student {
public:
    Student() = default;
    Student(const char* name) : mName(name)
    {
    }

    Student(Student&& rhs) : mName(std::move(rhs.mName))
    {
    }

    const char* GetName() const
    {
        return mName.GetName();
    }

private:
    Name  mName;
};

int main()
{
    Student s1 = "Ken";
    Student s2 = std::move(s1);

    std::cout << s2.GetName() << std::endl;
}

実行結果:

Ken

std::move関数がしていることは、実引数を右辺値に変換して、右辺値参照を返すことです。名前が少々紛らわしいですが、std::move関数自身がムーブ処理を行うわけではなく、返された右辺値参照を使って、ムーブコンストラクタ(またはムーブ代入演算子)を起動することで、ムーブ処理を実現できます。

繰り返しになりますが、ムーブさせるようなコードを書いたからといって、必ずムーブが行われるわけではありません。ムーブコンストラクタが無く、コピーコンストラクタがあるのなら、結局はコピーになります。


ムーブコンストラクタは、以下を1つも明示的に実装していなければ、コンパイラが自動生成します。

前に書いたことの繰り返しのような話になりますが、コピーコンストラクタを明示的に実装している場合は特に、動作を勘違いしやすいので注意してください。たとえば、以下のプログラムを実行すると分かるように、一見ムーブを行っていそうにみえても、実際はコピーになっていることがあります。

#include <iostream>
#include <utility>

class MyClass {
public:
    MyClass() = default;
    MyClass(const MyClass&)
    {
        std::cout << "copy constructor" << std::endl;
    }
};

int main()
{
    MyClass c1;
    MyClass c2(std::move(c1));  // ムーブコンストラクタを呼ぶ?
}

実行結果:

copy constructor

この場合、ムーブコンストラクタは生成されないため存在せず、コピーコンストラクタは明示したので存在しています。よって、std::move関数を使っていても、コピーコンストラクタが呼び出されます。

なお、ムーブを行った後は、ムーブ元のオブジェクトは使ってはならないことに注意してください。使おうとした場合の動作は未定義です。

ムーブ代入演算子 🔗

ムーブに関連するもう1つの機能がムーブ代入演算子です。ムーブ代入演算子もコピー代入演算子も、使用する演算子は「=」です。したがって、文脈に応じて、コピーになるのかムーブになるのかが決まります。

ムーブ代入演算子を実装する場合は、コピー代入演算子と同様に、operator=()を定義します。仮引数は、自身のクラス型の右辺値参照です。ムーブコンストラクタのところで使った Nameクラスの例に付け足してみます。

#include <iostream>

class Name {
public:
    Name(const char* name)
    {
        mName = static_cast<char*>(std::malloc(std::strlen(name) + 1));
        std::strcpy(mName, name);
    }

    Name(Name&& rhs) : mName(rhs.mName)
    {
        rhs.mName = nullptr;
    }

    ~Name()
    {
        std::free(mName);
    }


    Name& operator=(Name&& rhs)
    {
        if (this != &rhs) {
            mName = rhs.mName;
            rhs.mName = nullptr;
        }
        return *this;
    }


    const char* GetName() const
    {
        return mName;
    }

private:
    char*  mName;
};

int main()
{
    Name name1 = "Ken";
    Name name2 = "John";

    name2 = std::move(name1);
    std::cout << name2.GetName() << std::endl;
}

実行結果:

Ken

ムーブ代入演算子は、各メンバ変数をムーブ(またはコピー)し、*this を返すようにします。ムーブ元のメンバにポインタ変数があるのなら nullptr を代入して、ムーブ直後に右辺値のデストラクタが呼ばれて解放されてしまうことを防ぐように実装する点は、ムーブコンストラクタのところで説明したとおりです。

ムーブ代入演算子でも、コピー代入演算子の場合と同じく(第13章)、自己代入への備えが必要です。ムーブ元のポインタ変数に nullptr を代入していますが、自己代入の場合だと、それは自分のメンバ変数を上書きしていることになりますから、単に情報を失う結果になってしまいます。

また、普通はムーブ代入演算子とムーブコンストラクタの実装は同じ形になりますから、コードの重複を防ぐことも考えましょう。ムーブ代入演算子を使って、ムーブコンストラクタを実装するのが良いです。

class Name {
public:
    Name(Name&& rhs) : mName(nullptr)
    {
        *this = std::move(rhs);
    }

    Name& operator=(Name&& rhs)
    {
        if (this != &rhs) {
            mName = rhs.mName;
            rhs.mName = nullptr;
        }
        return *this;
    }
};

*this = std::move(rhs); の部分で、ムーブ代入演算子が呼び出されています。多少無駄になりますが、安全面や保守の面を考えると、ムーブ前にメンバ変数の初期化を行うようにしておくのが無難です。

なお、ムーブコンストラクタと同様に、ムーブ代入演算子も例外を送出するべきではありませんから、本来は noexcept を付加するべきです。例外は第18章で解説します。


ムーブ代入演算子は、以下を1つも明示的に実装していなければ、コンパイラが自動生成します。

ムーブコンストラクタのところで書いたことと同じような話ですが、コピー代入演算子を明示的に実装していると、ムーブ代入をしているつもりの箇所でも、実はコピー代入が行われるということが起こり得ますから注意してください。

#include <iostream>
#include <utility>

class MyClass {
public:
    MyClass() = default;
    MyClass& operator=(const MyClass&)
    {
        std::cout << "copy" << std::endl;
        return *this;
    }
};

int main()
{
    MyClass c1, c2;
    c2 = std::move(c1);  // ムーブ代入?
}

実行結果:

copy


参照修飾子 🔗

オブジェクトが左辺値なのか右辺値なのかに応じて、呼び出されるメンバ関数を制御できます。

メンバ関数の宣言の末尾に「&」を付加すると、その関数は *this が左辺値の場合にだけ呼び出せます。同様に「&&」を付加すると、*this が右辺値の場合にだけ呼び出せます。なお、これらの記号は、参照修飾子と呼ばれています。

どちらかを単独で使うこともできますし、オーバーロードもできます。また、参照修飾子に const を付加することも可能ですが、ただの constメンバ関数との間でのオーバーロードはできません。

#include <iostream>

class MyClass {
public:
    void f() &
    {
        std::cout << "lvalue reference" << std::endl;
    }
    void f() const &
    {
        std::cout << "const lvalue reference" << std::endl;
    }
    void f() &&
    {
        std::cout << "rvalue reference" << std::endl;
    }
    void f() const &&
    {
        std::cout << "const rvalue reference" << std::endl;
    }
};

MyClass Get()
{
    return MyClass();
}
const MyClass GetConst()
{
    return MyClass();
}

int main()
{
    MyClass a;
    MyClass* b = &a;
    MyClass& c = a;
    MyClass&& d = Get();

    a.f();          // a は左辺値
    b->f();         // *b は左辺値
    c.f();          // c は左辺値
    d.f();          // d は左辺値
    MyClass().f();  // 一時オブジェクトは右辺値
    Get().f();      // Get() の戻り値は右辺値

    const MyClass ca;
    const MyClass* cb = &ca;
    const MyClass& cc = ca;
    const MyClass&& cd = GetConst();

    ca.f();         // ca は const付きの左辺値 
    cb->f();        // *cb は const付きの左辺値 
    cc.f();         // cc は const付きの左辺値 
    cd.f();         // cd は const付きの左辺値 
    GetConst().f(); // GetConst() の戻り値は const付きの右辺値 
}

実行結果:

lvalue reference
lvalue reference
lvalue reference
lvalue reference
rvalue reference
rvalue reference
const lvalue reference
const lvalue reference
const lvalue reference
const lvalue reference
const rvalue reference

呼び分けのポイントは *this が左辺値なのか右辺値なのかです。右辺値参照を使った呼び出しは、右辺値参照自体は左辺値なので、「&」の参照修飾子が付加された方の関数が呼び出されています。


練習問題 🔗

問題① この章で使った Nameクラスを、コピーとムーブに対応した形で完成させてください。


解答ページはこちら

参考リンク 🔗


更新履歴 🔗

 新規作成。



前の章へ (第13章 コピー)

次の章へ (第15章 動的なオブジェクトの生成)

Modern C++編のトップページへ

Programming Place Plus のトップページへ



はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る