Tech Racho エンジニアの「?」を「!」に。
  • 開発

C++: コンパイル時テストのススメ

皆さん、C++のテストフレームワークは何を使っていますか?
最近はBoost.Testやgoogletestあたりが一般的ですね。私は個人的に IUTest というものを使ったりもします。
しかし、C++はコンパイル時計算を行う言語です(個人の感想です)。
だったらテストだってコンパイル時にやりたいと思いませんか? 思いますよね。やりましょう。

コンパイル時テストのやり方

C++にはstatic_assertという機能があります。コンパイル時に式を評価して、falseになった場合コンパイルエラーにしてエラーメッセージを出してくれる機能です。
高機能なテストフレームワークを使うのも便利ですが、コンパイル時に解決できるテストはこれを使って書くことができます。

static_assertの文法は単純です。任意のboolに変換可能な定数式をexpとすると、

static_assert(exp, "message");

これだけです。第二引数はC++17から省略することもできます。expが定数式であることが重要で、実行時に計算される値を指定すると常にエラーになります。

static_assertは単純な文法ですが、それ故に複雑な式を判定しようとすると大変です。
特に、一時的に状態を持つオブジェクトを扱おうとすると、他のconstexpr関数に切り出すなどしないといけませんでした。C++14までは。
そうです。C++14まではラムダ式をconstexprにすることができませんでした。
C++17から、ラムダ式はconstexprにできるようになりました。むしろ、constexpr関数の条件を満たしている場合、暗黙的にconstexprになりました。つまりどういうことかというと、

static_assert([]{
    return true;
}(), "always success.");

こんなことができるようになったのです。
ラムダ式の中は普通の関数の中と同じようにローカル変数を定義することもできます。全体として定数式であれば、どんなことをしても大丈夫です。

コンパイル時テストの利点

コンパイル時テストを行うことで、以下のようなメリットを得られます。

  • 副作用がないことが証明される
  • 未定義動作を起こしていないことが証明される
  • 自然とconstexprにする習慣が付く
  • テストを実装の隣に書ける
  • テストが失敗している状態でリリースすることはできない
  • 副作用のある機能とない機能を切り分けするようになる

副作用がないことが証明される

コンパイル時テストは定数式でないと成功しません。
C++14以降のconstexprの制限緩和により一時的に変数の書き換えなどの副作用のある式を扱えるようにはなりましたが、定数式は最終的には外部に副作用をもたらすことはありません。
つまり、static_assertの中に書いた式は、どこで書いても同じ結果になる(副作用がない)ということになります。
副作用がないことが証明されたconstexpr関数は、実行時に同じ値が渡された時も同じ値を返しますし、定数を渡せばコンパイル時に結果が決まるので実行時コストが0になります。

未定義動作を起こしていないことが証明される

C++の規格では、未定義動作は定数式になり得ません。
未定義動作を含む式をstatic_assertに渡した場合、定数式にならないのでコンパイルエラーです。
これにより、関数内部でうっかり未定義動作を使ってしまっていた場合などが検出できます。

自然とconstexprにする習慣が付く

constexprは市民の義務ですが、キーワードが長すぎるとかよく分かっていないとか、様々な理由でconstexprを書こうとしないC++プログラマも少なくないでしょう。本来ならとりあえずすべてconstexprにして、上手くいかない部分だけ外すくらいでいいのですが。
副作用を含まない関数はすべてコンパイル時テストを行うことができます。ただし、そのためにはconstexprを付けないといけません。コンパイル時テストを行えるすべての関数を網羅するテストを書けば、それらの関数にはすべてconstexprがついているはずです。

テストを実装の隣に書ける

static_assertが実行ファイルの中身に影響を与えることはありません。いくらコンパイル時テストを書いても、テストが通れば書かなかった場合と同じコンパイル結果が得られます。
ただ、コンパイル時間は当然増えてしまうので、場合によってはテストだけ分割した方が良くなるかもしれません。

テストが失敗している状態でリリースすることはできない

せっかくテストをしても、テストが失敗していることを無視するような雑な人は必ずいます。gitでテストが通らないとマージできないようにするなど、何らかのシステムでそういうことは絶対に起こらないようにするべきです。
コンパイル時テストを実装の隣に書いておけば、コンパイル時テストの失敗はコンパイルエラーなので、少なくとも無視しようとしてもリリースができません。テストケースをコメントアウトされてしまえばそれまでですが

副作用のある機能とない機能を切り分けするようになる

コンパイル時テストだけでは実行時に副作用を生む機能をテストすることはできないので実行時テストも必要ですが、実行時テストはなるべく少なくして、コンパイル時テストを増やしたいものです。
副作用のある関数でも、良く切り分けてみれば副作用のない部分を抽出することができることもあります。もちろん機能の切り分けの単位はそれだけで決めるものではありませんが、副作用のある操作とない操作を切り分ければ、実行時エラーの原因を掴みやすくなります。

コンパイル時テストの欠点

逆に、コンパイル時テストのデメリットとしては、以下のようなものが考えられます。

  • コンパイル時間が増える
  • テストできる範囲が限定される
  • カバレッジを出せない
  • C++17以降じゃないと使いづらい

コンパイル時間が増える

コンパイル時テストで重い処理を行えば行うほどコンパイル時間は増大していきます。
開発中、コンパイルする度に長いテストが実行されると効率が落ちるので、テストはテストで切り分けた方が良くなるかもしれません。

テストできる範囲が限定される

実行時に副作用を生む機能はテストできないので、結局実行時テストも併用することになります。

カバレッジを出せない

さすがにコンパイラがstatic_assertのカバレッジを出してくれたりはしないので、実行時テストと同じようなカバレッジを出すことができません。
また、副作用のある式を扱えないことから、原理的にすべての実装に対してテストを書くのは不可能なので、たとえそういう機能があったとしても100%にはできません。

C++17以降じゃないと使いづらい

上述したように、static_assertの中でラムダ式を使うにはC++17対応のコンパイラが必要です。
そのうち安定してくるとは思いますが、今のところまだC++17に完全対応できたコンパイラはありませんし、個人プロジェクトならともかく、製品コードでC++17を使うのは難しい職場も多いでしょう。
いずれ開発環境がC++17に移行したら、積極的に導入していきましょう。

関連記事

CMakeをより便利にするライブラリ “CMakeSupports” がオープンソースになりました

[C++] イテレーターを作る【BPS Advent Calendar: 12/20】


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。