代入演算子のエレガントな定義方法と,その不満点

ユーザ定義されたクラスに対して 代入演算子を定義する場合,
コンパイラの生成するデフォルトの代入演算子では,例外安全の強い保証を満たせない場合がある.

struct Hoge {
  std::vector<int> x, y;

  Hoge() = default;
  Hoge(std::vector<int> x_, std::vector<int> y_)
    : x(std::move(x_)), y(std::move(y_)) {}

};

int main() {
  Hoge a = {{1, 2, 3}, {4, 5}};
  Hoge b;

  try {
    b = a;  // 例外安全の強い保証を満たせない
    // なぜなら,この操作は
    // b.x = a.x;
    // b.y = a.y;
    // と同じであり, b.y = a.y; で例外が投げられた場合
    // b.x は代入されたが b.y は代入されないまま残るからである
  } catch(...) {
    // ここで b は中途半端な状態になっているかもしれない
  }
}


無論, C++11 を使う場合には,例外を投げうる代入演算子は書くべきではないのだが,
人の書いたコードを使う場合など,例外はいくらでもあるし,
事情により C++03 を使わざるを得ない場合には, move が使えない以上,どうしようもない.


このような場合に例外安全な代入演算子を定義する方法として,かの Exceptional C++ では,
「copy and swap」技法という手法が提案されていた.
これは,例外を投げない swap を実装し,それを使って代入演算子を定義する方法である.

struct Hoge {
  std::vector<int> x, y;

  Hoge() = default;
  Hoge(std::vector<int> x_, std::vector<int> y_)
    : x(std::move(x_)), y(std::move(y_)) {}

  Hoge(Hoge const&) = default;
  Hoge(Hoge &&) = default;

  Hoge& operator=(Hoge const& rhs) {
    Hoge(rhs).swap(*this);  // copy and swap
    return *this;
  }
  Hoge& operator=(Hoge&&) = default;  // これは例外安全なので問題ない
                                      // ただし move 代入が例外を投げうるケースは極希に存在するため,
                                      // そのような場合には Hoge(std::move(rhs)).swap(*this); で実装する必要あり

  void swap(Hoge& other) noexcept {
    using std::swap;
    swap(x, other.x);
    swap(y, other.y);
  }
  friend void swap(Hoge& l, Hoge& r) noexcept {
    l.swap(r);
  } 
};

これは極めて優秀な方法であるが,問題がないわけではなく,
C++11 の場合,うっかり move 代入演算子の定義を忘れてしまうといったケース*1が考えられる.


その問題に対処する方法として,代入演算子の引数を値渡しにする,というものがある.

struct Hoge {
  std::vector<int> x, y;

  Hoge() = default;
  Hoge(std::vector<int> x_, std::vector<int> y_)
    : x(std::move(x_)), y(std::move(y_)) {}

  Hoge(Hoge const&) = default;
  Hoge(Hoge &&) = default;

  Hoge& operator=(Hoge rhs /*pass by val, copy/move is done here*/) noexcept {
    this->swap(rhs);
    return *this;
  }
  // Hoge& operator=(Hoge&&) = default;  // もはや必要ない

  void swap(Hoge& other) noexcept {
    using std::swap;
    swap(x, other.x);
    swap(y, other.y);
  }
  friend void swap(Hoge& l, Hoge& r) noexcept {
    l.swap(r);
  } 
};


上記のコードは C++11 スタイルで書いたが,この書き方は C++03 でも通用する*2ため,
この書き方は,例外安全性のみならず,移植性にも優れている.


また,オリジナルのコードでは operator= が noexcept 指定されていない(関数内部で例外が投げられうる)のに対し,
値渡しを使ったコードでは, operator= が noexcept 修飾されている(関数内部で例外は投げられない).
これは,実際のコピー代入処理で例外が起きる可能性が存在しなくなった,という意味ではなく,
例外の投げられうる処理(コピーコンストラクタ呼び出し)が関数の引数部分に追い出された,ということであり,
noexcept 演算子等を使ってコピー代入時に例外が投げられるか否かを判別した場合には,正しく判断される.


この挙動は,実装者にとって,極めて都合のいいものである.
特にテンプレートを扱った場合には,代入演算子に対する noexcept 指定を行うのは かなり面倒なので((noexcept(std::is_nothrow_copy_assignable::value) とか書く必要がある)),
それを避けられるだけでも,この手法を使う価値は十二分にあると言っていいだろう.


総じて,この「値渡しを使った copy and swap 」は,オリジナルの const 参照を使った方法より優れており,
例外安全などの理由で代入演算子を自前で用意しなければいけない場合には,こちらを使う方が良いと考えられる.




* * * * *

(以降,マニア向けの内容)

* * * * *


さて,値渡しを使った copy and swap は,極めて便利なのだが,難点も存在する.
それは,値渡しというよりは copy and swap 技法全般に当てはまることであり,
swap を自前で定義しなければいけない,という,言ってしまえば当たり前のものだ.


しかし,当たり前だということは,それが些細な問題であるということを意味しない.
swap を自前で定義することの問題点は,

  • メンバ変数が増えた場合に,その都度 swap メンバ関数を書き換えて対応する必要がある
  • メンバ関数 swap が定義されたクラスを継承した場合に,自前の swap を定義し忘れる可能性がある

という二点なのだが,いずれも厄介な問題なのだ.


これらの問題点への対応を忘れた場合,オブジェクトが部分的に更新されないまま残ることになり,
そうなった場合,往々にして「オブジェクトが たまに奇妙な振る舞いをするが,原因はよく分からない」という状況に陥る.
自動生成された代入演算子では このような事態は起きにくいことを考えると,なかなかに難しい問題である*3


これらの問題を解決するために, C++1y (not C++14) では
コンパイラによって自動で定義される swap 演算子の導入が検討されている.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3553.pdf
この提案が採用されるかどうかは不透明だが, swap は copy and swap 技法以外にも よく使われる為,
個人的には,互換性の許す限り(そこがネックだが),是非とも導入されて欲しいと考えている.


また,それとは全く別に,うろ覚えで この技法を使った場合に起き得る問題点も存在する
具体的には,代入演算子の実装に メンバ関数版の swap ではなく 非メンバ関数版の swap を使った場合,
自前の swap の定義をうっかり忘れてしまうと,無限ループとなる可能性が出てきてしまうのだ.

struct Hoge {
  std::vector<int> x, y;

  Hoge() = default;
  Hoge(std::vector<int> x_, std::vector<int> y_)
    : x(std::move(x_)), y(std::move(y_)) {}

  Hoge(Hoge const&) = default;
  Hoge(Hoge &&) = default;

  Hoge& operator=(Hoge rhs) noexcept {
    using namespace std;
    swap(rhs, *this);  // 汎用の std::swap を呼ぶ. その場合,内部で Hoge::operator= が呼ばれて無限ループ
    return *this;
  }

  // swap の定義を忘れた
};

このような事態を避けるため,この手法を使う場合には,必ずメンバ関数の swap を呼ぶべきである.


ただし,この手法以外の文脈では,一貫して 非メンバ関数 の swap を使うほうがよい.
組み込み型とクラスで同じ書き方が可能である,というだけではなく,
継承を使って派生クラス側で swap の定義を忘れた場合でも,非メンバ関数を使えば,危険は減るからだ.

struct Base {
  std::vector<int> member;

  void swap(Base& x) {
    member.swap(x.member);
  }
  friend void swap(Base& x, Base& y) {
    x.swap(y);
  }
};
struct Derived {
  int additional_member;
  // swap は定義されない
};

int main() {
  Derived x, y;
  // x.swap(y);  // Base::swap が呼ばれる. additional_member は交換されない
  using namespace std;
  swap(x, y);  // std::swap が呼ばれる. 全メンバが交換される
}


この問題点は,現状の C++ では「可能な限り非メンバ関数を使う」ことでしか対処できないが,
新たに言語機能として
「あるメンバ関数に対して,『このメンバ関数は継承されない』*4という旨を宣言できる」
機能が追加されれば,改善される可能性がある.

// 注: このコードは「こう書けたらいいなら」というものであり, C++ コードではない
struct Base {
  std::vector<int> member;

  void swap(Base& x) uninherited {  // 『このメンバは継承されない』. 注: C++ では こうは書けない
    member.swap(x.member);
  }
  friend void swap(Base& x, Base& y) {
    x.swap(y);
  }
};
struct Derived {
  int additional_member;
  // swap は定義されない
};

int main() {
  Derived x, y;
  x.swap(y);  // コンパイルエラー, swap は継承されない
}

こういった機能も, C++1y では是非とも導入されて欲しいものだ.

*1:コンパイルエラーにならないまま効率が悪くなるのでタチが悪い

*2:ただし noexcept 指定は消す必要がある

*3:余談ではあるが,この問題点を解決する方法として, C++11 では copyしてmove することでコピー代入演算子を定義する方法もある. ただ,その場合, noexcept 指定は自前で行わなければならず,しばしば面倒である.

*4:…と書くと曖昧なので,仮に規格に入った場合には『このメンバ関数は派生クラスで暗黙のうちに隠される』となると思われる