C++の初期化と=とcopyとmove

最終更新: 2017-06-10 23:52

メモ:
なんで = が呼び出されないんだ?https://t.co/ITr4HnZ2Ad

— あどみす ✘╹◡╹✘ (@ADMIS_Walker) 2017年6月5日

というツイートを見て調べたことのまとめ。

問題のコード

冒頭のツイートは以下のコード(オリジナルからは改変してあります)のData c = func()で演算子=がオーバーロードされないことを問題にしていました。

#include <iostream>

struct Data {
    Data& operator=(const Data& rhs) {
        std::cout << "= is overloaded" << std::endl;
        return *this;
    }
};
 
Data func(){
    Data a;
    return a;
}
 
int main() {
    Data a, b;
    std::cout << "a = b" << std::endl;
    a = b;           // (a) overloaded

    std::cout << "c = func" << std::endl;
    Data c = func(); // (b) not overloaded

    Data d;
    std::cout << "d = func" << std::endl;
    d = func();      // (c) overloaded
}

実行結果はC++03, C++11ともに

a = b
= is overloaded
c = func
d = func
= is overloaded

となります。

=を用いた初期化

Initialization - cppreference.comの(2)にあるように=は初期化の構文の一つです。コード例の(a), (c)では代入を行っているので= is overloadedが表示されるが、(b)では初期化を行っているので表示されない、というとりあえずの結論を出しました。

copyとmove

これで終わりにしてもよかったのですが、ここで行っているのはcopy initializationの(1)です。
C++11では初期化内容がrvalueのときはmove constructorが呼ばれる(If other is an rvalue expression, move constructor will be selected by overload resolution and called during copy-initialization. There is no such term as move-initialization.) と書いてあるのでそこを確認したくなりました。

参考リンク:

#include <iostream>

class Data {
public:
    // default constructorを明示的にdefault宣言しないといけないとエラーが出た
    /* If some user-defined constructors are present, the user may
       still force the automatic generation of a default constructor by
       the compiler that would be implicitly-declared otherwise with the
       keyword default.
       (http://en.cppreference.com/w/cpp/language/default_constructor) 
       に該当するケースだろう */
    Data() = default;

    Data(const Data&) {
        std::cout << "copy constructor" << std::endl;
    }

    Data(Data&&) {
        std::cout << "move constructor" << std::endl;
    }

    Data& operator=(const Data& other) {
        std::cout << "= is overloaded: copy" << std::endl;
        return *this;
    }

    Data& operator=(Data&& other) {
        std::cout << "= is overloaded: move" << std::endl;
        return *this;
    }

};
 
Data func(){
    Data a;
    return a;
}
 
int main() {
    Data a, b;
    std::cout << "b = a" << std::endl;
    b = a;           // copy assignment

    std::cout << "b = func()" << std::endl;
    b = func();      // move assignment

    std::cout << "d = a" << std::endl;
    Data d = a;      // copy initialization (with copy constructor)

    std::cout << "e = func()" << std::endl;
    Data e = func(); // copy initialization (with move constructor) ??
}

実行結果は

b = a
= is overloaded: copy
b = func()
= is overloaded: move
d = a
copy constructor
e = func()

となり最後の部分でmove constructorが呼ばれないという予想に反する結果になりました。

(Named) Return Value Optimization

c++11 Return value optimization or move?の解答にあるように、C++ standard draft N3337の12.8にこのような場合にはmove constructorの呼び出しを省略しても良いと書いてあるので効率化のために呼び出していないっぽいです。

Copy elision - cppreference.com

If a function returns a class type by value, and the return statement's expression is the name of a non-volatile object with automatic storage duration, which isn't the function parameter, or a catch clause parameter, and which has the same type (ignoring top-level cv-qualification) as the return type of the function, then copy/move (since C++11) is omitted. When that local object is constructed, it is constructed directly in the storage where the function's return value would otherwise be moved or copied to. This variant of copy elision is known as NRVO, "named return value optimization".

が該当する記述だと思います。

コンパイル時に-fno-elide-constructorsを指定するとこの挙動を抑制できます。その結果は

b = a
= is overloaded: copy
b = func()
move constructor
= is overloaded: move
d = a
copy constructor
e = func()
move constructor
move constructor

となりました。

func()を呼んだときに"move constructor"が表示されるのはreturnで一時オブジェクトが生成されているからだろうと思います。("Returning by value may involve construction and copy/move of a temporary object, unless copy elision is used." return - cppreference.com)

なお、@SaitoAtsushiさんの指摘を受けて知ったのですが、C++17ではcopy抑制が必須になっています

copy initializationとdirect initializationの違い

さらに=を使った初期化と(){}を使った初期化は同じなのかという疑問が生じましたが、それはIs there a difference in C++ between copy initialization and direct initialization?に解答がありました。

=を使った場合は暗黙の型変換を行ったうえでの初期化が行われ、(){}ではコンストラクタ呼び出しが行われる(必要に応じて型変換を行う)という違いがあるようです。

As you see, copy initialization is in some way a part of direct initialization with regard to possible implicit conversions: While direct initialization has all constructors available to call, and in addition can do any implicit conversion it needs to match up argument types, copy initialization can just set up one implicit conversion sequence.

その解答にあったコードをちょっと改変したのが以下です。

#include <iostream>
struct B;
struct A { 
  operator B();
};

struct B { 
  B() { }
  B(A const&) { std::cout << "direct initialization" << std::endl; }
};

A::operator B() { std::cout << "copy initialization" << std::endl; return B(); }

int main() { 
  A a;
  B b1(a);  // direct initialization
  B b2{a};  // direct initialization
  B b3 = a; // copy initialization
}

の実行結果は

direct initialization
direct initialization
copy initialization

のようになりました。

解答では他にもconstの数によって暗黙の型変換とコンストラクタのどちらが優先されるかが変わるなどの詳細な議論がありますが、詳細は解答を読んでください。

ということでとりあえず自分の疑問は全て解決しました。