C++11のmoveを使ってみる

こんにちはtatsyです。

最近C++11ってすごいなぁと思う出来事が多々ありまして、そのうちの一つをブログに書いておこうと思います。

C++11で新しく追加された昨日の一つにmove semanticsというものがあります。

詳しい説明に入る前に次の単方向連結リストのコードを見てください。

struct ListNode {
    int val;
    ListNode* next;
    ListNode(int val_ = 0, ListNode* next_ = NULL)
        : val(val_)
        , next(next_)
    {
    }
};

class List {
private:
    ListNode* tail;

public:
    ListNode* root;

public:
    List();
    List(const List& list);
    ~List();
    List& operator=(const List& list);
    void add(int val);

private:
    void deleteNode(ListNode* node);
    void copyNode(ListNode* dst, ListNode* src);
};

通常のコピー


さて、このListをコピーするときには、どうすればいいでしょうか?

例えば、operator=の定義は次のようになります。

List& List::operator=(const List& list) {
    deleteNode(root);
    root = copyNode(list.root);
    return *this;
}

ListNode* List::copyNode(ListNode* src) {
    ListNode* ret = NULL;
    if (src != NULL) {
        ret = new ListNode(src->val);
        ret->next = copyNode(src->next);
    }
    return ret;
}

上記のコードでは、ノードを一つ一つ再帰的にコピーしていっているわけですが、リストは別にコピーを作らなくていいんだよね、という場合もあると思います。

でも関数の戻り値としてリストを設定したい場合なんかは、どうしてもコピーせざるを得ない場合もあると思います(もちろん参照やポインタを使うというやり方もあると思います)。

Moveはいつ使うのか?


じゃあ、実態(インスタンス)を1つに保ったまま、値を別の変数に移す方法はないのでしょうか?

C++11以前だったら、例えば自分でshared_ptrみたいに参照の数をカウントする仕組みをクラスに追加するとかでしょうか?そうすれば、実態をコピーすることなく複数の値で同一のオブジェクトを管理することができます。

でも、いちいち多くのクラスに、そんな仕組みを書くのは面倒です。そこでC++11のmove semanticsの出番です。

moveの操作をするためにはstd::moveという関数を使います。例えば次のような感じです。

List cpy = std::move(lst);

moveの操作を自作のクラスで定義するにはList&&というように型名の後にアンド記号を2つつけます。コンストラクタと代入演算子の両方に対してmoveの操作を定義することができます。

List(List&& list);
List& operator=(const List&& list);

このときの間違えやすそうなポイントは2つです。

  • 通常のコピーと違い、引数はconstにしない
  • 代入演算子の場合、戻り値は普通の参照

理由を1つずつ解説していきます。

まず、引数をconstにしない理由です。moveの操作を定義すると具体的には次のようになります。

List& List::operator=(List&& list) {
    deleteNode(root);

    printf("move\n");

    this->root = list.root;
    this->tail = list.tail;

    list.root = nullptr;
    list.tail = nullptr;

    return *this;
}

やっている操作は (1)ポインタをそのままコピーする、(2)コピー元のポインタをnullptrに変更、の二つです。後者のnullptrを代入するためにconstを外しているわけです。

なぜnullptrを代入するかというと、moveの元となる右辺値に対してもデストラクタはしっかり呼ばれるためです。もしnullptrにしないと、同じポインタが2回以上解放されて大変危険なバグを埋め込むことになりますので注意してください。

2つ目に代入演算子の戻り値です。これは若干の僕のおせっかいもあるのですが、戻り値はあくまでオブジェクトへの参照です。アンド記号を2つつけるものはmoveであることを明示するためのものにすぎないので引数にしか使いません。

Move semanticsを使ったコードの例


一通り、moveについて説明したところで次のコードを実行してみます。全体のコードは少し長いので重要なところだけを抜粋して、残りはGistに載せておきます。

List& List::operator=(const List& list) {
    deleteNode(root);

    printf("copy\n");

    root = copyNode(list.root);
    return *this;
}

List& List::operator=(List&& list) {
    deleteNode(root);

    printf("move\n");

    this->root = list.root;
    this->tail = list.tail;

    list.root = nullptr;
    list.tail = nullptr;

    return *this;
}

ListNode* List::copyNode(ListNode* src) {
    printf("copy node\n");
    
    ListNode* ret = NULL;
    if (src != NULL) {
        ret = new ListNode(src->val);
        ret->next = copyNode(src->next);
    }
    return ret;
}
List lst;
lst.add(1);
lst.add(2);
lst.add(3);

List cpy(lst);  // Copy
List mov = std::move(lst);  // Move

実際にやっているのはノードを再帰的にコピーするいわゆる「コピー」の処理と、ポインタを移すだけの「ムーブ」の処理です。それぞれの処理が起こったことが分かるように文字列を出力しています。

実行の結果は次のようになります。

copy
copy node
copy node
copy node

move

このようにコピーの処理の場合にはノードがいちいちコピーされるのに対して、ムーブの場合には単にmoveの代入演算子が呼ばれるだけです(当たり前ですね)。

まとめ


このように、中身のコピーを行うことなく、インスタンスの受け渡しができるのはとても便利です。moveをうまく活用して、分かりやすくかつ実効速度もあるコードがかければ良いなぁと感じている今日この頃です。

それでは、今日の解説はここで終わります。使ったコードは以下のGistのページに載せておきます。
C++11 move semantics

なるべく短く書いたつもりですが、それでも合計120行程度はありますね。パッと理解するのは難しいかもです。ごめんなさい。

それでは、最後までお読みいただきありがとうございました。