C++11 時代のクラス設計に関する提案

先日,ついに C++11 の主要な機能を一通り実装した GCC-4.8.1 がリリースされた
もう一方の主要な C++ コンパイラである Clang++ でも C++11 の機能は既に全て実装されており,
来る 6/05 に最新版の Clang-3.3 がリリースされ, C++11 対応が完了する見通しだ.*1


このような状況においては, C++11 への乗り換えを検討し始めているプロジェクトも多いことだろう.


さて, C++11 では, C++98/03 との互換性を保ちつつ,クラス設計に大きな影響を齎す変化が採用された.
すなわち, Move Semantics である.
この登場により, C++11 で「良い」とされるクラス設計は, C++98/03 時代とは若干 異なったものとなる.


そこで,この記事では,筆者が C++11 において「良い」と考えているクラス設計を提案してみたい.


なお,この記事で行われている主張は,あくまで筆者 個人の考えである.
状況によっては相応しくない場合もあるし,汎用的に使える より良いクラス設計方針が存在する可能性を否定するものではない.
現に, C++11 の標準ライブラリでは,互換性の都合から,これらの原則は必ずしも満たされていない.
しかし, C++11 を使って安全にコードを書く場合には,今 考えられる中では最良に近いと自負している.


* * * * *


さて,その提案であるが,端的に言えば

「クラスの特殊メンバ関数と swap および暗黙の型変換は,例外を投げないようにしよう」

となる.


特殊メンバ関数とは,明示的に定義しなかった場合に自動で生成されるメンバ関数のことであり,

の6つである.


これらと暗黙の型変換は,オブジェクトに対して操作を行う時,何かと呼び出される関数*2であり,
これらの関数が例外を投げうる(つまり,失敗する可能性がある)場合には,コードのパスが複雑になる恐れがある.

struct Hoge {
  Hoge();  // デフォルトコンストラクタ
  Hoge(Hoge const&);  // コピーコンストラクタ
  Hoge& operator=(Hoge const&);  // コピー代入演算子
  Hoge(Hoge &&);  // ムーブコンストラクタ
  Hoge& operator=(Hoge &&);  // ムーブ代入演算子
  ~Hoge();  // デストラクタ

  void swap(Hoge& other);  // swap (メンバ関数版)
  friend void swap(Hoge& one, Hoge& another);  // swap (非メンバ関数版)

  Hoge(int);  // 暗黙の型変換コンストラクタ
  operator double() const;  // 暗黙の型変換演算子

  // ...

};

void f(Hoge);

int main() {
  Hoge x, y;  // デフォルトコンストラクタが呼ばれる
  f(x);  // コピーコンストラクタが呼ばれる
  x = y; // コピー代入演算子
  f( Hoge() );  // (デフォルトコンストラクタと)ムーブコンストラクタ
  x = 0;  // 暗黙の型変換とムーブ代入演算子
  std::vector<Hoge> vec = { 1, 2, 3 };  // 暗黙の型変換
  std::sort( vec.begin(), vec.end(),  // sort 内部で swap
    [] (Hoge const& x, Hoge const& y) {
      double x_ = x, y_ = y;  // 暗黙の型変換
      return x_ < y_;
    }
  );
  // デストラクタはそこらじゅうで呼ばれてる
}

これら全てで例外を意識するのは無理がある*3し,
例外を投げうる操作というのは得てして実行速度が そこまで高速ではない*4ので,
そういう操作がコードからは見えにくい部分で行われるのは あまり良くないんじゃないか,という筆者の主張だ.


個別に解説していこう.


まずデストラクタ. 例外を投げるデストラクタは害悪である.
これは当たり前だし,探せば解説は いくらでもあるので,特に解説しない.
少なくとも,例外を投げうるデストラクタが存在した時点で,そのクラスは
標準ライブラリではマトモに使えなくなるし,そんな代物を俺々ライブラリで扱える訳がない.
というか,殆どの場合,デストラクタは暗黙の例外指定を持つため,
デストラクタで例外が投げられた場合には,デストラクタの外に伝搬せず,
std::terminate が呼ばれて プログラムは即座に終了してしまう.
そんなこんなで,例外を投げるデストラクタはあってはならないものなのだ.


次, swap .
これは Exceptional C++ を読めば分かる通り,例外を投げない swap がないと,
例外安全を満たしたコードを書くのが困難になる.
とはいえ, C++11 では, swap を使っていた技法の一部は Move Semantics に置き換えられるし,
そもそも swap 自体が Move Semantics から自動生成されるようになったので,ここでは特に解説しない.
興味が有る方は Exceptional C++ を読むといいだろう.


その次,ムーブコンストラクタとムーブ代入演算子
これは本当に頻繁に呼ばれるものなので,これらが例外を投げるとコーディングは かなり困難になる.
特に, Move 周りが例外を投げうる場合,複数のオブジェクトを Move しようとした場合に
例外安全の強い保証を満たせなくなってしまう,という問題点があり,
特に std::vector のようなコンテナの場合には,複数のオブジェクトを移動する際に Move が使えず,*5
代わりにコピーが行われることで,かなり効率が悪くなってしまうケースがある.
これは,内部の処理で Move が行われるか否かが,外から見て区別できないため,
非効率的な状態に気付けない,という意味で,非常に厄介な挙動である.
また, Move が例外を投げうる場合, swap も例外を投げうるということになり,
色々な操作に対して,例外安全の強い保証を満たすことが困難になる,という問題もある.


更に次,暗黙の型変換.
これは型変換コンストラクタと型変換演算子の二種類があるが,どちらも例外を投げるべきではない.
C++ というのは本当に自然に暗黙の型変換が行われる言語なので,
それらの処理の負荷が大きい場合,気付かないうちにプログラムのコストを上げてしまう.

template<class T>
struct my_vector {
  std::vector<T> data;

  // src の中身を変換する型変換コンストラクタ
  // サイズによっては当然 重い処理になる
  template<class U>
  my_vector( my_vector<U> const& src )
    : data( src.data.begin(), src.data.end() ) {}

};

void f(my_vector<std::string> const&);

int main() {
  my_vector<char const*> vec;
  vec.data = 〜;
  f(vec);  // 暗黙の型変換が行われ,要素ごとにメモリ確保が行われる
           // vec の型が my_vector<std::string> だった場合と比べて効率悪い
}

もちろん,

void f(std::string);
f("hoge");  // 動的メモリ確保を行う(例外を投げうる)暗黙の型変換だが,読みやすさの点では優れている
// f(std::string{"hoge"});  // こう書くのは いかにもダサい

のような便利なケースもあるので,一概に否定はできないが,
それでも,自分で設計する時は,こういう暗黙の型変換も避けるのが良いだろう.


次,デフォルトコンストラクタ.
これは特に強い理由はない.
強いて言えば,デフォルトコンストラクタは,スコープの関係などで
「とりあえず変数を定義しておく」場合に よく使われるので,その処理の負荷が大きいと困る,程度だ.

// とりあえず変数作って
std::function<void()> f;
{
  // あとで代入
  auto p = std::make_shared<Hoge>();
  f = [p] { p->do_something(); };
}
// 使う
f();

上記のような例も,ラムダ式を使って

auto f = []() -> std::function<void()> {
  auto p = std::make_shared<Hoge>();
  return [p] { p.do_something(); };
};
f();

と書けば良いし, C++14 においては std::optional の存在も有るので,特に神経質になることはない.
が,デフォルトコンストラクタに対して例外を投げないようにするのは割と楽なので,
どうせなら守るのも手では有ると思う. 少なくとも損はしない.


最後に,コピーコンストラクタとコピー代入演算子. この記事の本題である.


std::vector や std::string , std::function のように, C++ には
コピー時とムーブ時で処理の効率が大きく違うクラスが多いが,これは問題が有ると筆者は考える.


というのも,コピーという操作は, C++ ではごく自然に行われる操作であり,
ちょっと std::move を忘れれば即座にコピーが行われ,
しかもコピーが行われたことはコードを注意して読み直さない限り気付けない からだ.


以下のコードを見てほしい.

void f( std::vector<int> const& x );
void g( std::vector<int> x );

int main() {
  std::vector<int> vec( 1000000 ); // でっかい vector
  f( vec );  // const 参照渡し,効率的
  g( vec );  // 値渡し,効率悪い
}

fg の関数呼び出しは,呼び出す側からは全く同じに見えるが,
実際の処理は(もちろん関数の中身にもよるが)かなり効率が違ってくる.
このような場合に「うっかりコピーしないよう」気を付けるのは,かなりストレスの元になるし,
せっかく C++ を使っているのだから,そういうのはコンパイル時に検出したいのだ.


もし仮に, std::vector が今回の提案に従い,コピー時に例外を投げないようにしていた場合,
そもそもコピーが定義されなくなる*6ので,

void f( std::vector<int> x );

int main() {
  std::vector<int> vec( 1000000 ); // でっかい vector
  f( vec );  // 値渡し,効率悪い
}

のようなケースはコンパイルエラーにすることが可能になる.
もしコピーを行いたい場合には,明示的にコピーを行う関数を用意すればいい.

void f( std::vector<int> x );

int main() {
  std::vector<int> vec( 1000000 ); // でっかい vector
  f( duplicate(vec) );  // 明示的にコピーする
}

値渡しを行う場合は, duplicate か move を使い分けることになる.

void f( std::vector<int> x );

int main() {
  std::vector<int> vec( 1000000 ); // でっかい vector
  f( duplicate(vec) );  // 明示的にコピーする
  f( move(vec) );  // 明示的にムーブする
}

これなら間違いも減るだろうし,
先ほどの「Move 周りが例外を投げうる場合, Move の代わりにコピーが行われて効率が悪くなる」ケースも,
コピーの際に効率が劣化するような場合はコンパイルエラーにできるので,色々と都合がいい.


というわけで,このルール,興味ある人は守ってみたら如何だろうか.
明示的なコピーが言語側でサポートされてないので,微妙に扱いにくい部分はあるが,
慣れてくると間違いが減らせて非常に便利なのは間違いない. 特に暗黙変換とコピー.


ちなみに,例外を投げうるようなコピーや暗黙変換を禁止した場合,
std::string や std::function が自由にコピーできなくなって地味に面倒なのだが,
これらのクラスは immutable なクラスとして再設計すれば,コピー時に例外を投げなくなる.

auto s = string{"hoge"};  // メモリ確保を伴うので暗黙変換は禁止
auto t = s;  // OK, 浅いコピーを行う
s[0] = 'a'; // NG, immutable なので変更できない

auto f = function<void()>( []{} );
auto g = f;  // OK, g と f は内部の関数オブジェクトを共有する
int count = 0;
auto h = function<void()>( [count] () mutable { ++count; } );  // NG, mutable な関数オブジェクトは格納できない

ただ,暗黙変換とか絡むと, string はともかく function は少々面倒なので,ちょっとどうしたものか.

*1:ただし,日本時間では日付が変わっている可能性は普通にある.

*2:「関数」より「機能」の方が相応しい表現かもしれない. なお,どちらも英語では function である.

*3:もちろん RAII を使えば大抵のケースでは問題ないが, RAII に頼れないケースも意外と多く,その場合には見落としは致命的になる.

*4:例えば動的メモリ確保は例外を投げうる操作の典型例である. とはいえ動的メモリ確保自体は そこまで効率の悪い処理ではない(むしろ,その後,動的に確保した領域に書き込む操作が問題になる)が,スタック上の変数操作に比べたら遅いのは間違いない.

*5:参考: http://d.hatena.ne.jp/gintenlabo/20110117/1295281933

*6:std::vector のコピーをメモリ確保なしに行うのは無理である