2015年12月3日木曜日

【読書ノート】Effective Modern C++ - Item 14

O'Reilly Early Releaseで購入したEffective Modern C++の読書記録です。
※この記事はあくまで私の備忘録です。(断りなく自分の解釈や考え・感想を入れたり、理由もなく内容を省略したり、間違いや曲解もあると思います。誤りの指摘は歓迎です。)

Item 14: 例外を投げない場合はnoexceptで関数を宣言しよう

C++98における例外ととC++11における例外

コンパイラは例外の一貫性の維持を手助けしてくれないため、ある関数が投げる例外に変更が加わると呼び出し元のコードを壊す可能性がある。
開発者が関数が発生しうる例外型を記述し、変更の際は呼び出し元のコードも含めて修正を加えなければならない。
C++98において例外は苦労して使用するに値しないと考えられていた。

C++11ではnoexceptと記述することで関数が例外を出さないことを保証するようになった。
最も意味のある情報はその関数が「例外を投げるのか投げないのか」という0か1の情報だというコンセンサスが(C++11策定時に)得られたため。

noexceptをつけるかどうかはインターフェースデザインの範疇

クライアントコードにおいて関数が例外を投げるかどうかは重要な問題。
例外を投げないのにnoexceptをつけない関数は悪いインターフェースデザイン。
constと同じように例外を投げない関数には常にnoexceptを指定すべき。

コンパイラの最適化とパフォーマンスへの影響

noexceptを指定した場合は例外が発生した場合にstd::terminateでプログラムが強制終了されるようになっていて、コールスタックを管理するオーバーヘッドが削減される。
また、例外が発生して関数から出る時にオブジェクトの生成と逆順に破棄する必要もなくなり、コンパイラの最適化がかかりやすい。

RetType function(params) noexcept; // most optimizable
RetType function(params) throw(); // less optimizable
RetType function(params); // less optimizable

vectorの場合はパフォーマンスに影響する典型的な例。

std::vector<Widget> vw;
Widget w;
vw.push_back(w);

vectorはpush_backする時に容量を超えると別のメモリ領域に要素を丸々コピーしてから古いメモリ領域のオブジェクトを破棄する。
これによって強い例外保証を提供していて、コピーの途中で例外が発生してもvectorの元の状態は保存される。
C++11のムーブセマンティクスを適用する場合は元のデータを書き換えてしまうためこの強い例外保証を脅かす。
しかしmove操作がnoexceptであれば安全であり、moveを利用することでパフォーマンスが改善される。
std::vector::reserveやstd::deque::insertなど別のメモリ領域への割当が発生するメソッドについても同様のことが成り立つ。

条件付きnoexcept

noexceptには条件分岐も可能(true/falseを与えてtrueであればnoexcept)。
noexceptに関数呼び出しを与えるとnoexceptの有無がtrue/falseとして解釈されてnoexceptになるかを判定してくれる。
std::swapは以下の様な実装になっている。

template <class T, size_t N>
void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(*a, *b)));

template <class T1, class T2>
struct pair {
    …
    void swap(pair& p) noexcept(noexcept(swap(p.first, p.first)) && noexcept(swap(p.second, p.second)));
    …
};

swapはSTLの様々なアルゴリズムで使用されているため、例外が発生しないならnoexceptを指定して最適化されることが重要。

noexcept指定の注意点

noexceptを指定することは大きなメリットがあるが、もしnoexceptを指定した関数に修正を加えてnoexceptを指定しないように変更した場合はクライアントコードを壊す可能性がある。
noexceptは可能なら常に指定すべきだが、今後もnoexceptであり続けると考えられる関数に限るべき。
なお、全てのメモリ解放関数とデストラクタは暗黙の内にnoexceptとなっている。デストラクタにnoexcept(false)を明示的に指定すれば例外を発生させられるようになるが通常使うべきではない。

自然な実装で例外が発生しない関数に対してnoexcept指定すべきである。
例外を発生させないがために関数内でtry catchを書くことで複雑になってしまうようでは本末転倒。
パフォーマンス上の利点もtry catch文の追加によるオーバーヘッドでかき消されてしまう。

wide contractsとnarrow contracts

wide contracts: どのような引数を与えても未定義動作にならない
narrow contracts: 前提条件が存在し、その条件に反する引数が与えられると未定義動作になる
narrow contractsである関数では引数が不正な場合に未定義動作となってデバッグが困難となる。
そのような場合には例外で引数が不正であることを呼び出し側に伝えるのが親切。
それを分かっている設計者はwide contractsにはnoexceptをつけ、narrow contractsにはnoexceptをつけないことが多い。

関数が本当にnoexceptであるかはコンパイラがチェックしてくれるわけではない

void setup(); // functions defined elsewhere
void cleanup();
void doWork() noexcept
{
    setup(); // set up work to be done
    … // do the actual work
    cleanup(); // perform cleanup actions
}

setupもcleanupもnoexceptではないのにdoWorkはnoexcept指定されているが、コンパイルは通る。
setupとcleanupはCやC++98のライブラリかもしれない。そもそもstd::strlenなどのCの関数もnoexcept指定されていない。
このような関数が多数存在するので融通を利かせるためにコンパイラはチェックしないようにしている。