ムーブコンストラクタ|ムーブセマンティクスやコンテナ高速化との関係

本記事ではムーブコンストラクタの概要や仕組み、目的、実装時の注意点について解説する。またムーブセマンティクスの概念も理解できる一石二鳥の内容となっている。後半ではコンテナクラスの動作を高速化するための手段と原理を解説する。

目次

ムーブコンストラクタとは

ムーブコンストラクタ(move constructor)はオブジェクトの内部値を新たなオブジェクトに移動/譲渡するための特殊なコンストラクタである。

コピーコンストラクタが内部表現のコピーを実現するのに対し、ムーブコンストラクタでは内部表現の移動を実現する点が異なる。

ムーブコンストラクタはT(T&&)という形式で宣言する。またムーブコンストラクタは元オブジェクトのコピーではなく移動を実現するため、元オブジェクトのメンバは移動(p(v.p))した後にゼロ化(v.p = nullptr)する必要がある。

struct A {
  int* p;
  // ムーブコンストラクタ
  A(A&& v) : p(v.p) { v.p = nullptr; }
  // デフォルトコンストラクタ
  A() : p(new int(9)) {}
  // コピーコンストラクタ
  A(const A& v) : p(new int(*v.p)) {}
  // デストラクタ
  ~A() { delete p; }
};

ムーブコンストラクタの定義によって、オブジェクトが保有する値の所有権を、他のオブジェクトへ簡単に移転することが可能になる。値をコピーするのではなく、移動することが可能となる。

以下はムーブコンストラクタの使用例である。

A a(std::move(A())); // 一時オブジェクトの値を変数aに移動
printf("%d", *a.p);  // "9"(一時オブジェクトから引き継いだ値)
// 一時オブジェクト`A()`のデストラクタが呼ばれても、
// 変数aにはなんの影響もない(ポインタa.pはまだ解放されていない)

ムーブコンストラクタを呼び出すためには、コンストラクタ引数に対して右辺値参照(&&)を渡す必要がある。そのためにはstd::move()static_cast<A&&>()によるキャスト処理でもって右辺値参照を明示する必要がある。

std::string a("abc");
std::string b(static_cast<std::string&&>(a)); // ムーブコンストラクタ`string(std::string&&)`が呼ばれる

printf("%s: %ld", b.data(), b.size()); // "abc: 3"
printf("%s: %ld", a.data(), a.size()); // ": 0"  // 譲渡されたので空
基本的にムーブコンストラクタが呼び出された場合、譲渡元オブジェクトは空の状態となる。

コピーコンストラクタとの違い

ムーブコンストラクタは一見するとコピーコンストラクタにも似ているが、 コピーコンストラクタの場合はオブジェクトの完全な複製を実現するのに対し、ムーブコンストラクタでは元のオブジェクトの値を完全に譲渡するという違いがある。

なお譲渡の際には、元のオブジェクトのその後振る舞いによってムーブ先のオブジェクトに影響が及ばないようにしなければならない。以下の例では、ムーブ先のポインタが一時オブジェクトのデストラクタ呼び出しによる影響を受けないことをv.p = nullptrによって保証している。

struct A {
  int* p;
  A() : p(new int(9)) {}
  A(A&& v)
      : p(v.p) {   // 値を引き継いだ上で、
    v.p = nullptr; // 値への参照を断ち切る
  }
  ~A() { delete p; }
};

A a(std::move(A())); // 一時オブジェクトの値を変数aに移動
printf("%d", *a.p);  // "9"(一時オブジェクトから引き継いだ値)
// 一時オブジェクト`A()`のデストラクタが呼ばれても、
// 変数aにはなんの影響もない(ポインタa.pはまだ解放されていない)

これで値の所有権は完全に新しいオブジェクトへ移動したことになる。

実際に、一時オブジェクトA()のデストラクタが呼ばれる際のdelete p;処理ではnullptrに対する処理が働くことになり、メモリ解放処理は回避される。そのためオブジェクト変数A aのメンバ変数pは解消処理の影響を受けることなく、ポインタオブジェクトa.pも有効なままとなる。

なおポインタオブジェクトa.pはオブジェクト変数A aのデストラクタが呼ばれるタイミングで解放されることになる。

ムーブセマンティクス

今回説明したサンプルコードは、実はとても単純なことしかしていない。一時オブジェクトA()が保有していたオブジェクトnew int(9)の所有権をオブジェクト変数A aに移動したというだけの話である。そしてこの概念と一連の作法は「ムーブセマンティクス(move semantics)」と呼ばれている。

ことC++のムーブセマンティクスにおいては、所有権の移動を右辺値参照へのキャストとその受け取りという明確な記法と作法を用いて表現している点が特徴となっている。

つまるところ、ムーブセマンティクスとは、所有権の移動を表現するための何かしらの作法のことを指す。そのため、ムーブセマンティクスはムーブコンストラクタに限らず、様々な場面での実現も可能となっている。

ムーブ代入

初期化済みのオブジェクト変数同士でムーブ処理を行うこともできる。

std::string a("a");
std::string b;
b = std::move(a); // 変数`b`側のムーブ代入演算子が呼ばれる
printf("%ld%ld", a.size(), b.size()); // "01"

ムーブ代入演算子は独自に定義することもできる。

struct A {
  int* p;
  A& operator=(A&& v) { // ムーブ代入演算子
    if (&v != this) {   // 自己代入の回避
      delete p;         // 既存のリソースを解放
      p = v.p;
      v.p = nullptr;
    }
    return *this;
  }
  ~A() { delete p; }
};

A a{new int(9)};
A b{nullptr};
b = std::move(a); // ムーブ代入演算子が呼ばれる
assert(a.p == nullptr && b.p != nullptr);

C言語によるムーブセマンティクス

同等のムーブセマンティクスはC言語でも実現できる。

int *a = malloc(sizeof(int));
*a = 99;

// 譲渡
int *b = a;
a = NULL;

a, *b; // NULL, 99

free(a), free(b);

C++におけるムーブコンストラクタや右辺値参照の仕組みはこのムーブセマンティクスを実現しやすくするための仕組みであるとも言える。

コピーコンストラクタによるムーブセマンティクス

なお推奨はされていないが、コピーコンストラクタでムーブセマンティクスを実現することもできる。以下は現在非推奨となっているstd::auto_ptr<T>クラスの実装例である。

template<class T> struct auto_ptr {
  T* p;
  auto_ptr(T* p) : p(p) {}
  auto_ptr(auto_ptr& p) : p(p.release())/*譲渡中*/ {}
  T* release() {
    T* t = p;
    p = nullptr;
    return t;
  }
  ~auto_ptr() { delete p; }
};

auto_ptr<int> a(new int(9));
auto_ptr<int> b(a); // 譲渡
assert(a.p == nullptr && b.p != nullptr);

auto_ptrの危険性 - コピーコンストラクタによるムーブセマンティクスの実現とその危険性

上記の例では、譲渡の方法が一般的なコピーコンストラクタ呼び出しと同じ記法になっている点が少し厄介である。これは注意して使用しないと勘違いやミスの原因になる可能性がある。また以下のように、意図しないタイミングで値の移動が行われてしまう危険性もある。

struct int_ptr {
  auto_ptr<int> ptr;
  int_ptr(int i) : ptr{new int(i)} {}
};
int_ptr a(9);
int_ptr b(a); // コピーのつもりが・・・
// 知らぬ間にメンバ内の値が移動している
assert(a.ptr.p == nullptr && *b.ptr.p == 9);
*a.ptr.p = 8; // クラッシュする

auto_ptrのコピーコンストラクタを未定義にし、代わりにムーブコンストラクタを実装することで、この問題を回避することができる。その場合、ムーブが必要な場面ではstd::moveでムーブセマンティクスを明示しなければならなくなるが、そのほうが明確で安全である。

実は、右辺値参照によるムーブセマンティクスの明示は、所有権の保護と管理を厳格に行うための規範としても機能しているのである。

C++標準ライブラリのstd::unique_ptr<T>クラスはまさにそれらの対策が取り入れられており、現在ではstd::auto_ptr<T>の後継として広く使われている。auto_ptrはこれらの危険性から現在では非推奨の機能となっているため、誤って使用しないように注意しなければならない。

ムーブコンストラクタ定義時の注意

譲渡元オブジェクトのメンバがムーブコンストラクタに対応している場合は、メンバに対してムーブ処理を施さなければならない場面と不要な場面に分かれるため注意が必要である。

メンバに対する明示的なムーブが不要

struct A {
  std::string str;
  std::unique_ptr<int> ptr;
};

A b(std::move(A()));

このように、コピーコンストラクタを定義していないクラスでは、ムーブコンストラクタの定義を省略できる。メンバに対するムーブ処理も自動で施されるようになる。

メンバに対する明示的なムーブが必要

struct A {
  std::string str;
  std::unique_ptr<int> ptr;
  A(const A& v) {} // コピーコンストラクタを独自定義した
  // メンバを明示的にムーブする必要がある
  A(A&& v) : str(std::move(v.str))
           , ptr(std::move(v.ptr)) {}
};

A b(std::move(A()));

加えてクラス側でコピーコンストラクタを独自定義した場合にはムーブコンストラクタの暗黙的な定義(default定義)がされなくなる点に注意しなければならない。コピーコンストラクタのみが定義されている状態でムーブ処理(A b(std::move(A()));)を行うと、ムーブコンストラクタの代わりにコピーコンストラクタが呼ばれるようになる。

struct A {
  std::string str;
  std::unique_ptr<int> ptr;
  A(const A& v) {} // コピーコンストラクタを独自定義した
};

A b(std::move(A())); // コピーコンストラクタが優先的に呼ばれる

なおムーブコンストラクタのデフォルト定義を明示的に行う事もできるため、上記のケースではA(A&&) = default;という形でdefault指定を行えばよい。

struct A {
  std::string str;
  std::unique_ptr<int> ptr;
  A(const A& v) {}
  A(A&&) = default; // 明示的にdefault定義する
};

A b(std::move(A())); // ムーブコンストラクタが呼ばれる

コンテナクラスとの関係と高速化

ムーブコンストラクタを実装すると、std::vector等のコンテナクラスの動作が高速化する場合がある。

多くのコンテナクラスでは要素追加時にバッファ(メモリ)の再確保や要素の再配置が行われるが、その際に各既存要素のコピーコンストラクタが呼ばれてしまうという問題がある。

しかし、要素型の側でムーブコンストラクタを定義することで、このコピーコンストラクタ呼び出しを回避することができ、代わりにより処理コストの低いムーブコンストラクタが呼ばれるようになる。

コピーコンストラクタのコピーコストや処理時間が大きくなるような場合はムーブコンストラクタの実装を検討してみると良い。C++高速化の有効な手段となり得る。

コンテナ対応時の注意点

ただ注意点として、ムーブコンストラクタはnoexceptキーワードを指定した形で定義しなければならない。

struct A {
  int* p;
  A(A&& v) noexcept : p(v.p) { v.p = nullptr; }
};

例外指定は「例外を許可しない」形で指定する必要がある(noexceptまたはnoexcept(true)と記述する)。例外指定を適切に指定しないと、要素の再代入時にムーブコンストラクタが呼ばれなくなり、代わりに従来のコピーコンストラクタ呼び出しが行われるようになってしまう。

広告