C言語でTDDを学ぶ?TDDでC言語を学ぶ?

Facebook上で議論になったのですが、C言語でTDDを勉強していると、「よりよい設計」になりにくいという話になりました。

例として整数の区間の最初のほうの課題を考えてみます。

課題1-1
下端点と上端点を与えて閉区間を生成しよう
区間から下端点と上端点を取得しよう

TDDで実装したコードとテスト

    // プロダクションコード
    int upper_point;
    int lower_point;
    
    // テストコード
    TEST(Range, Endpoint) {
        lower_point = 3;
        upper_point = 8;
        EXPECT_EQ(3, lower_point);
        EXPECT_EQ(8, upper_point);
    }

問題:どうしたら関数を導入できるでしょうか?

元のお題はオブジェクト指向を想定しているところがあって、「生成しよう」でオブジェクトを作り、「取得しよう」でオブジェクトから値を取り出すという形になるんですが、C言語では「値を与えて取り出す」のならば、変数があればいいよねと。とりあえずグローバルでも構わない、というのは、お題(要求仕様)に何も書いてないから。(externがないのでモジュール内に閉じていますけれど。逆に、プロダクションコードとテストコードを別モジュールにしたら、グローバルになってしまいますね。)

TDDのやり方に律儀に従うなら、新たなテストによって現在の(グローバル変数だけで生成は関数不要という)設計の限界が明らかになり、よりよい、シンプルな設計が導かれます。区間のお題の続きに「閉区間が別の閉区間と等しいか」があるので、複数の区間を扱うにはグローバル変数では困るよね……と思いきや、こうなります。

    // プロダクションコード
    int upper_point;
    int lower_point;
    
    int range_equal(int lower_point2, int upper_point2) {
        return lower_point == lower_point2 && upper_point == upper_point2;
    }

    // テストコード
    TEST(Range, Endpoint) { /* 略 */ }

    TEST(Range, Equal) {
        lower_point = 3;
        upper_point = 8;
        EXPECT_TRUE(range_equal(3, 8)); // [3,8]と[3,8]は等しいはず
        EXPECT_FALSE(range_equal(1, 9)); // [3,8]と[1,9]は等しくないはず
    }

うん、グローバルに設定したものとパラメータで渡すものを比較すればいいよね……間違ってはいない。

TDDでシンプルな解を追求する

TDDには(ひいてはXPには)「シンプルさ」という価値があります。TDDではシンプルな解をよしとします。ここには「(与えられた問題や制約をすべて充足するなかでもっとも)シンプル」という条件が隠れています。テストコードを眺めて、「比較対象を片方は変数に、片方はパラメータで渡すのは、シンプルでない」と思えば、以下のように書き直せます。

    // プロダクションコード
    int upper_point;
    int lower_point;
    
    int range_equal(int lower_point1, int upper_point1,
                    int lower_point2, int upper_point2) {
        upper_point = upper_point1;
        lower_point = lower_point1;
        return lower_point == lower_point2 && upper_point == upper_point2;
    }
    
    // テストコード
    TEST(Range, Endpoint) { /* 略 */ }
    
    TEST(Range, Equal) {
        EXPECT_TRUE(range_equal(3, 8, 3, 8)); // [3,8]と[3,8]は等しいはず
        EXPECT_FALSE(range_equal(3, 8, 1, 9)); // [3,8]と[1,9]は等しくないはず
    }

しぶといな、グローバル変数

TDDが自動的に良い設計を保証するわけではありません。「TDDを使ってよりよい設計に到達する」のと「TDDを使えばよりよい設計になる」の間には、深い深い溝があります。TDDを使うと設計品質が悪くなるという調査もあります。少なくとも、何が「良い設計」であるか、言語やライブラリやフレームワークのどんな特性を利用すれば「良い設計」になるか、知識がなければ設計として実現できません。

元々のお題にはありませんが、このようなグローバル変数を使った設計に対しては、別の課題を設定してあげるという手を思いつきました。グローバル変数を避けるべき理由として「いつの間にか値が書き換わって危険」という知識があります。range_equal()を呼ぶと書き換わってしまうので、自明でない挙動をする。そこで「比較しても値が書き換わらないようにする」という課題が考えられます。それをテストで表現すれば、TDDの流れで設計を改善できます。

    // テストコード ※失敗する
    TEST(Range, Equal_DoesNotChange) {
        lower_point = 3;
        upper_point = 8;
        range_equal(0, 5, 3, 8);
        EXPECT_EQ(3, lower_point);  // range_equal()を呼んでも変化しないはず
        EXPECT_EQ(8, upper_point);
    }

このテストは失敗するので、グリーンになるように実装を直します。目指すのはグローバル変数の除去ですが、そうすると実装を直す過程で上端・下端を生成したり取得したりというテストが、成立しなくなってしまいます。おまけに今書いたばかりのテストも、中身がなくなってしまいます(EXPECT_EQで比較するものがなくなってしまう)。

    // プロダクションコード
    int range_equal(int lower_point1, int upper_point1,
                    int lower_point2, int upper_point2) {
        return lower_point1 == lower_point2 && upper_point1 == upper_point2;
    }
    
    TEST(Range, Endpoint) {
        // なくなってしまった
    }
    
    TEST(Range, Equal) {
        EXPECT_TRUE(range_equal(3, 8, 3, 8)); // [3,8]と[3,8]は等しい
        EXPECT_FALSE(range_equal(3, 8, 1, 9)); // [3,8]と[1,9]は等しくない
    }
    
    TEST(Range, Equal_DoesNotChange) {
        // なくなってしまった
    }

なくなってしまったテストは、消してしまうのがいいでしょう。万が一あとで復活させたくなっても、一度書いたことはきっと憶えているはずです(それにバージョン管理してますよね?)

よい設計を目指すTDD

さて、話を前に戻して、「比較しても変化しないこと」という課題は、グローバル変数の除去を意図して導入しました。なぜグローバル変数を除去したかったのか?不用意にグローバル変数を用いるのは「悪い」設計で、しかしTDDのサイクルの中でグローバル変数を止める自明な方法が見つからなかったためです。ここではいくつかの議論が考えられます。Facebook上の組み込みTDD勉強会(クローズドグループ)での議論を踏まえています。みなさんありがとうございます。

  • グローバル変数の利用のような初歩的な設計上の問題は、設計判断を持ち出すまでもなく常識的に取り除く(最初から使わない)べきだ。そうしたレベルならコーディング標準で制限できるし、そういうレベルのプログラマはしっかり教育したりレビューで改善すべきだ。
  • このタイミングで無理にグローバル変数を除去する根拠はない。お題を進めて仕様を拡充していけば、自然にグローバル変数を止めたくなるときが来るはず。そのときにこそ、最適な設計判断ができるのであって、いま強引に変えるのは拙速で、YAGNIでムダだ。
  • 特にC言語ではデータ構造の自由度が(純粋なオブジェクト指向プログラミングに比べると)高く、初期に十分検討しないと後でリファクタリングするのに苦労する。TDDでもC言語を使うときは事前設計をもっと念入りにやったほうがいい。
  • 「シンプルにする」というTDDの方針だけでは、必ずしもよい設計に到達できない。十分な知識と広い視点で、より望ましい設計を考え、そちらに向かって(やや強引であっても)TDDで進んでいけばいい。

いずれの言い分も妥当ですし、環境や状況や人によって重視すべき事項は変わってきます。私自身は最後の考え方がわりと好きで、よりよい設計を仮説として(できればテストに書き)、TDDで実証するというアプローチを選ぶことが多いです。

グローバル変数の話は、これでいったん片付いたことにして、データ構造を整理することを考えてみます。上端と下端は常にペアで使うものなので、ひとまとめにしたいですね。方法はいくつか考えつきます。

  • 構造体を作る
  • 長さ2の配列を作る
  • 1個のINT32の上位16bitと下位16bitに格納する
  • など

どれが望ましいでしょうか。C言語の適用範囲を考えると。いずれも妥当な解法になり得ますが、ここでも「シンプルに」、つまり「与えられた仕様を満たす範囲でできるだけシンプルに」考えたいです。メモリや処理時間の制約がなければ、構造体が素直でしょうか。

    // プロダクションコード
    struct range {
        int lower_point;
        int upper_point;
    };
    
    int range_equal(struct range range1, struct range range2) {
        return range1.lower_point == range2.lower_point && range1.upper_point == range2.upper_point;
    }
    
    TEST(Range, Endpoint) {
        // 復活!
        struct range range1;
        range1.lower_point = 3;
        range1.upper_point = 8;
        EXPECT_EQ(3, range1.lower_point);
        EXPECT_EQ(8, range2.upper_point);
    }
    
    TEST(Range, Equal) {
        struct range range1;
        range1.lower_point = 3;
        range1.upper_point = 8;
        struct range range2;
        range2.lower_point = 3;
        range2.upper_point = 8;
        EXPECT_TRUE(range_equal(range1, range2)); // [3,8]と[3,8]は等しい
        range2.lower_point = 1;
        range2.upper_point = 9;
        EXPECT_FALSE(range_equal(range1, range2)); // [3,8]と[1,9]は等しくない
    }

構造体を導入したコードを見ると、どうもテストコードが煩雑で、これなら前のほうが良かったように見えますね。rangeを簡潔に生成する方法を導入すれば改善できそうですが、本稿では省略します。

知らない解法を使えるか

また話を少し戻して、構造体導入の判断を考えてみます。構造体を使おうと思うには、少なくとも構造体を知らないといけない。カジュアルにグローバル変数を使っちゃうプログラマーが構造体を知っているか、知っていても適切に使えるかは疑問です。

知らない解法を使うことは誰にもできません。ここでは構造体を取り上げていますが、「知ってはいるけどちゃんと使ったことのない○○○(任意の手法・技法)」と考えれば誰でも遭遇し得る状況です。こうした状況で、TDDは無力なのでしょうか。きちんと勉強した熟達プログラマしかTDDは活用できないのでしょうか。

その逆で、TDDは新しい技術を使いこなせるようになったり、未知の技術を発見する役に立ちます。それ自体がシンプルな解の探究になるためです。いまある問題に対してよりシンプルな解に到達するには、以下の4レベルを上がっていく必要があります。

  1. 動く
  2. よりシンプルに書き直す
  3. 他の書き方を複数試して、一番シンプルなものを選ぶ
  4. さらにシンプルな書き方を勉強してくる

グローバル変数の例ではレベル2を取り上げました。構造体を知っているけれどうまく使えないのであれば、レベル3にチャレンジする必要があります。先のコードでは構造体を導入したものの、かえって煩雑になってしまいました。

よりよい設計、よりシンプルな解法を見つけるには、レベル3の試行錯誤が必要になってきます。試してみる、他のアプローチで試してみる、リファクタリングでいろいろなパターンを見比べてみる。その中で一番よいと思えるものを残す。TDDの回転は一直線ではありません。行きつ戻りつしながら少しずつ上がっていくのです。

試行錯誤で最善と思える結果が出ても、まだよくなる可能性があるかもしれない。そう思えたときが、レベル4になってまったく新しい解法を探しに行くタイミングです。レベル3までをきちんとこなしていれば、問題について十分に理解できているはずです。いまある解法の効用と限界も知っているでしょう。それならば、新しい解法を見つけても適切に評価できます。中途半端な解法に飛びついたり、不適切な解法を盲目的にコピーしてしまうこともありません。

レベル3までは問題を深く理解するためのステップで、レベル4が本当の問題解決だと見ることもできるかもしれませんね。