C言語でTDDを学ぶ?TDDでC言語を学ぶ?
Facebook上で議論になったのですが、C言語でTDDを勉強していると、「よりよい設計」になりにくいという話になりました。
例として整数の区間の最初のほうの課題を考えてみます。
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レベルを上がっていく必要があります。
- 動く
- よりシンプルに書き直す
- 他の書き方を複数試して、一番シンプルなものを選ぶ
- さらにシンプルな書き方を勉強してくる
グローバル変数の例ではレベル2を取り上げました。構造体を知っているけれどうまく使えないのであれば、レベル3にチャレンジする必要があります。先のコードでは構造体を導入したものの、かえって煩雑になってしまいました。
よりよい設計、よりシンプルな解法を見つけるには、レベル3の試行錯誤が必要になってきます。試してみる、他のアプローチで試してみる、リファクタリングでいろいろなパターンを見比べてみる。その中で一番よいと思えるものを残す。TDDの回転は一直線ではありません。行きつ戻りつしながら少しずつ上がっていくのです。
試行錯誤で最善と思える結果が出ても、まだよくなる可能性があるかもしれない。そう思えたときが、レベル4になってまったく新しい解法を探しに行くタイミングです。レベル3までをきちんとこなしていれば、問題について十分に理解できているはずです。いまある解法の効用と限界も知っているでしょう。それならば、新しい解法を見つけても適切に評価できます。中途半端な解法に飛びついたり、不適切な解法を盲目的にコピーしてしまうこともありません。
レベル3までは問題を深く理解するためのステップで、レベル4が本当の問題解決だと見ることもできるかもしれませんね。