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


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

目次

スポンサーリンク

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

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

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

ムーブコンストラクタはT(T&&)という形式で宣言する。

struct A {
  int* p;
  // ムーブコンストラクタ
  A(A&& v) : p(v.p) { v.p = nullptr; }
  // デフォルトコンストラクタ
  A() : p(new int(9)) {}
  // コピーコンストラクタ
  A(const A& v) : p(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)」と呼ばれている。

ムーブセマンティクスはムーブコンストラクタに限らず、様々な場面での実現が可能となっている。

ムーブ代入

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

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

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

struct A {
  int* p;
  void operator=(A&& v) { // ムーブ代入演算子
    p = v.p;
    v.p = nullptr;
  }
  ~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(A&& v)
    : str(std::move(v.str))
    , ptr(std::move(v.ptr)) {}
};

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

ただ実は、上記の例ではムーブコンストラクタの定義を省略出来る。メンバに対するムーブ処理も自動で施されるようになる。

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

なおムーブコンストラクタのデフォルト定義を明示的に行う事もでき、その場合はA(A&& v) = default;という形でdefault指定すればよい。

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

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

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

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

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

コンテナ対応時の注意点

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

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

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

広告

関連するオススメの記事