読者です 読者をやめる 読者になる 読者になる

(C++)constexpr & static_assertによるコンパイル時テストの用途

 これは ソフトウェアテスト Advent Calendar 2016 - Qiita の4日目の記事です。

 C++では、C++11から以下の言語仕様が追加されました。
・違反するとコンパイルを失敗させる表明構文:static_assert
・指定対象をコンパイル時に処理させる指定子:constexpr

 このstatic_assertとconstexprを組み合わせると、コンパイル時テストを柔軟に構築できるようになります。
 コンパイル時テストは、コンパイルの際に実行され、テストに失敗したらコンパイルエラーを発生させるものです。これはC++のようなコンパイラ方式言語で、テストの選択肢を広げる助けとなります。今回は組み込み向けを想定して、コンパイル時テストの用途を紹介したいと思います。

コンパイル時テストの用途1:コンパイル時処理のテスト

 constexprで記述されたコンパイル時処理なら、大抵コンパイル時にテストできます。そのテストをコンパイル時テストとして書けば、テストを失敗させるコードをコンパイル不能にして、不具合混入を防止できます。
 単純な例ですが、例えば以下のような2つの値の差の絶対値を計算する、constexpr関数get_diffを考えます。

template <typename T>
constexpr auto get_diff(T x, T y) {
    return (x < y) ? y - x: x - y;
}
//今回のエントリのコードは全体的にC++14依存です。動作確認時は「clang++ -std=c++14 ソースファイル」などで

 ここで以下のように、static_assertとconstexprで記述したテストを用意します。

constexpr void test_get_diff(void) {
    static_assert(3 == get_diff(5, 2),"test get_diff: arg_left > arg_right, int");
    static_assert(3 == get_diff(2, 5),"testget_diff: arg_left < arg_right, int");
    static_assert(0.4 == get_diff(0.6, 0.2),"test get_diff:double");
}

 すると、上記test_get_diffのテストを失敗させるようなコードはコンパイルエラーとなります。
 組み込みではC++14を使える環境は少ないですが、コンパイル時処理の強化はC++の言語やライブラリの時流となっています。そのため今後活用の余地が増えていくと思います。

コンパイル時テストの用途2:テストの効率性の改善

 組み込みでは「処理時間(プロセッサリソースの使用量)」「RAM/ROM使用量」「コードの可読性」がしばしばトレードオフの関係になります。ターゲット環境向けに大量のテストデータを扱うテストを実装する際には、そのトレードオフの折り合いをどうするか、悩まされる事があります(テストが遅すぎて処理タイミングが変わったり、ROM/RAM上限を超えたり、と言った感じです)。

 例えば、解説しやすい例として、sin計算テストの期待値生成の実装を考えます。
 sin計算の実装の一つとして、FPGA等でたまにある、以下のように入出力テーブルを用意する方法があります。

//sin計算テストの期待値を生成
double get_expected_sin_value(const int degree) 
{
    const double sin_table[360] = {
        0.0,
        0.017452392,
        0.034899467,
        0.052335912,
        0.069756415,
        0.087155669,
        //略。359まで続く
    };
    return sin_table[degree % 360];
}

void test_my_sin(void) //sin計算のテスト
{
    ...
    EXPECT_EQ(my_sin(5), get_expected_sin_value(5));
    ...
}

 もう一つの実装としては、アセンブラ言語等で定番の、マクローリン展開を行って実装する方法があります。以下のようなコードです。

const double pi = 3.14159;
double taylor_numer(const int n) {
    auto result {1.0};
    for (auto i = 2.0; i <= n; i++) {result *= i;}
    return result;
}

double taylor_denom(const double x, const int n) {
    auto result {x};
    for (auto i = 0; i < n; i++) {result *= x;}
    return result;
}

//sin計算テストの期待値を生成
double get_expected_sin_value(const int degree) {
    auto result {0.0};
    auto rad {(double)(degree % 360) * pi / 180.0};
    
    for (auto n = 0; n <= 8; n++) {
        auto const taylor_summand {taylor_denom(rad, 2 *n) / taylor_numer(2 * n + 1)};
        result += ((n % 2) ? -taylor_summand : taylor_summand);
        if (taylor_summand < 0.000001) {
            break;
        }
    }
    return result;
}

 上記の2つでは、処理時間(プロセッサリソースの使用量)、RAM/ROM使用量、コードの可読性がトレードオフの関係にあります。

  • テーブル方式ですと、処理時間を短くできます。RAM/ROM使用量が増大します。(sinでなくもっと複雑な計算式を使う場合に問題化しますが)テーブル格納値の導出方法がコードから読み取れず、コードの可読性が悪くなることがあります。
  • マクローリン展開方式ですと、処理時間が長くなります。RAM/ROM使用量は少ないです。sin計算の過程(マクローリン展開で、8項あるいは1項0.000001未満になるまで計算)がコードから読み取れるようになります。

 ここで、これらテストの記述をconstexpr指定すれば、処理時間、RAM/ROM使用量、コードの可読性のトレードオフを壊せる場合があります。
 例えばマクローリン展開方式を以下のようにconstexprで実装します。

constexpr double pi = 3.14159;
constexpr double taylor_numer(const int n) {
    auto result {1.0};
    for (auto i = 2.0; i <= n; i++) {result *= i;}
    return result;
}

constexpr double taylor_denom(const double rad, const int n) {
    auto result {rad};
    for (auto i = 0; i < n; i++) {result *= rad;}
    return result;
}

//sin計算テストの期待値を生成
constexpr double get_expected_sin_value(const int degree) {
    auto result {0.0};
    auto rad {(double)(degree % 360) * pi / 180.0};
    
    for (auto n = 0; n <= 8; n++) {
        auto const taylor_summand {taylor_denom(rad, 2 * n) / taylor_numer(2 * n + 1)};
        result += ((n % 2) ? -taylor_summand : taylor_summand);
        
        if (taylor_summand < 0.000001) {
            break;
        }
    }
    return result;
}

 こうすると、sin計算はコンパイル時で済まされます。実行環境ではマクローリン展開の処理時間がなくなり、テーブルのようなROM/RAM使用量も不要になります。期待値の計算過程もコードで表現できます。処理時間、RAM/ROM使用量、可読性の3方面を改善しています(テーブル方式をconstexprで実装しても同様です。テーブル生成をconstexprで行えば、テーブル値の計算過程の明示も可能になります)。

 このように、コンパイル時テストやコンパイル時処理はテスト効率化の一手段となります。
 なおコンパイル時処理を行う場合、各種パラメータはコンパイル時に確定できなければならないという制約がつきます。ただテストデータであればその制約がクリアされる場合が多いため、活用しやすいです。

コンパイル時テストの用途3:契約による設計での、事前条件の達成の確認

 コンパイル時テストは契約による設計と親和性が高いです。事前条件の契約遵守のチェックをコンパイル時テストで記述すれば、(テスト範囲限定ですが)契約違反コードをコンパイル不能にします。契約による設計のアプローチ通り、以降の実行やテストでは、事前条件が満たされた場合の処理に集中できるようになります。


 例えばオープンアーキテクチャのAUTOSARを例にとります。AUTOSARは契約による設計を全面的に取り入れています。
 その一つの例ですが、AUTOSARではリンク時、プレビルド時に確定する静的コンフィグコードと本体コードを分けています。そこでは本体コードにとって、静的コンフィグコードは正しいことが事前条件となっています(コンフィグコードが間違っていると、不正メモリアクセスやNULL参照などが発生します)。
 その事前条件では、結構複雑なコンフィグの一貫性が求められます。例えば以下のようなコードで「BlockDescriptorList::NvBlockNumの値は、Ea_BlockConfigData::EaBlockNumで定義されているものを使用しなければならない」といったようなルールがたくさん出てきます。

const NvM_BlockDescriptorType BlockDescriptorList[] = {
    {
        ...
        0x16,//NvBlockNum  
        ...
    },
    {
        ...
        0x20,//NvBlockNum
        ...
    },
};
const Ea_BlockConfigType Ea_BlockConfigData[] = {
    {
        ...
        0x01, //EaBlockNum
        ...
    },
    ...
};

 一応、コンフィグの正しさについては、DETエラーチェックによって、一般的な表明構文と同じように事前条件チェックを行う仕組みを規定しています。ただDETエラーチェックはかなり簡易的である上、以下のような制約を持ちます。

  • 製品版とDETエラーチェック版でビルドが別れる。
  • 各関数呼び出しタイミングで、入力値が事前条件を満たしているかしか見ない。実行時に事前条件違反を発生させるような条件を実現しないと、不具合を検出できない。


 そこで、チェックを以下のようにコンパイル時テストとして記述しておくと、複雑で包括的な静的コンフィグコードのチェックを行えます。静的コンフィグコードに契約違反があればそもそもコンパイル不能になるため、あとは契約による設計の意図通り、本体コードではコンフィグコードが正しい時の処理にフォーカスできます。

constexpr bool check_memory_block_num_config()
{
    auto iea {0};
    for (auto invm = 0; invm < num_nvm_block; invm++) {
        for (; iea < num_ea_block; iea++) {
            if (Ea_BlockConfigData[iea].EaBlockNum == BlockDescriptorList[invm].NvBlockNum) {
                break;
            }
        }
        if (iea == num_ea_block) {
            return false;
        }
    }
    return true;
}

constexpr void test_memory_config()
{
    ...
    static_assert(check_memory_block_num_config(), "test_memory_config: memory block number");
    ...
}