記事一覧

バグを減らすための正しい努力とは

はぁ……
どうしたの、レオ君。ため息なんかついちゃって。
あ、ユキ先輩。実は、僕が作ったプログラムにバグがあることが判明して……
あらあら。そんなに落ち込まなくてもいいと思うけど。
だって、すごく頑張って作ったプログラムだったんですよー。

バグをゼロにする方法はあるか

バグを発生させない方法が一つだけあるのだけど。レオ君は知ってるかしら?
わぁ、そんな方法があるんですか?教えてください!
それはね……プログラムを作らないことよ。
えー、からかわないでくださいよー。
ふふふ。からかってるわけじゃないのよ。

プログラミングをしなければ、バグが発生することもないでしょう。でも、プログラムを作る仕事をする以上は、間違いの頻度はゼロにはなりません。例えば、長いプログラムを書くときに、1度もコンパイルエラーを出さずに済むとは考えにくいですね。できたとしても、一発で期待通りに動作するケースは稀です。

これは、絶対に落ちない飛行機や絶対に沈まない船、絶対に衝突しない自動車を作れないのと似たようなものです。絶対に間違えないプログラムの書き方というのは、存在しません。

誰にでも間違いはあるものよね。だったら、プログラミングを頑張っている人だけがバグを経験できるんだって考えてみるのはどうかしら?
そっかぁ。そうですよねー。
だからバグがあっても胸を張って、もっといい方法を考えればいいのよ。

バグを減らすための「つらい」方法

誰も好んで間違えようとしているわけでもないのに、プログラムを作り続ける限り、バグは何度でも発生します。では、バグの発生頻度を減らすにはどうすればいいでしょうか。

バグはどうすれば減らせると思う?
リリース前に、たくさんテストをすればいいんじゃないですか?
うん、その心意気は買わせてもらうわ。でも、それだとバグの発生を抑えたことにはならないわね。
あ、そっか。

たしかに、リリース前にプログラムをテストするのは重要なことです。細部まできっちりテストを実施すれば、実害のあるバグはほぼ発見できるでしょう。しかし、これはバグの発生を抑えることとは違います。

それに、リリース前のテストは、プログラムが想定どおりに機能しているかどうかの確認作業として行いたいものです。バグの発見が主目的にすり替わってしまうのは、あまり健全とはいえません。

じゃあ、もう一歩踏み込んで考えてみましょうか。そもそもバグを発生させないようにするために、何かできることはないかしら?
そうですね……もっと慎重にやればバグは減るとは思うんですけど……
ふむふむ。ということは?
あ、レビューを増やすっていうのはどうですか?厳しくレビューしながら開発を進めたら、バグは減らせそうな気がします。
うんうん。それも間違ってはいないわ。ただ、時間をかけて頑張る方向に発想が向かいがちなのがちょっと気になるところね。

テストと同様、レビューも重要であることは間違いありません。実装を始める前に設計方針をレビューしたり、実装中にコードレビューを実施したりすれば、早い段階からバグを防げます。これは、チームで知恵を出し合って、ベストだと思える選択をすることにより得られる効果です。

でも、バグを減らそうとするあまり、レビューを厳しくしすぎるのは考えものです。それは、時間がいくらあっても足りない方法だからです。「もっと慎重に」とレビューにかける時間を増やし続けていけば、しだいに開発のペースは落ちていくことになるでしょう。

時間をかけてレビューを頑張ればバグは減るかもしれないけれど、生産性も落ちてしまったら……
そっか。それは、やりたいこととは違う気がします。
ちなみに、生産性が落ちたら、その結果としてもバグは減るわね。
えっ?
だって、バグを発生させない唯一の方法は、何も作らないことだったでしょう?
あ、はい……なるほど。作る量が減ったら、その分バグも減るはずですね。
そうなったら、バグが減った理由が「レビューを増やしたから」なのか「作る量が減ったから」なのか、分からなくなってしまいそうじゃない?
たしかに……うーん。それは嫌だなぁ……
そうよね。そういう努力のしかたは、あまり長続きしないと思うわ。

バグには「仕組み」で対処しよう

どれだけバグが減っても、作れなくなってしまったら意味がありません。では、どうすれば生産性を犠牲にすることなく、バグの発生に対処できるでしょうか。

結局、バグを減らすのは難しいってことですか?
まぁ、簡単ではないわね。それじゃあ、発想の転換をしましょうか。
発想の転換……ですか?
そう。考え方を「バグがあったら直す」から、「バグを積極的にあぶり出す」に変えるのよ。
んんん?どう違うんです?
バグの原因は、プログラムに埋め込まれるものよね。
はい。
でも、問題として表面化するまでには、タイムラグがあるでしょう?
タイムラグ……そっか!原因を作ってしまったからといって、すぐに問題が発生するとは限らないんですね!
そういうこと。バグには潜伏期間があるということよ。
なるほどー。
で、長く潜伏していたバグほど、直すときには苦労しがちよね。
そうかも。作ってから時間が経っちゃうと、思い出すのが大変ですもんね。

プログラム中に埋め込まれてしまったバグは、すぐに発見されるとは限りません。それが問題として表面化して「バグがあるぞ」と認識するまでには、ある程度のタイムラグがあるのが普通です。また、タイムラグが大きいほど、原因究明に苦労するケースが多くなります。

反対に、作ってからすぐにバグを発見できた場合は、すんなり直せることが多いでしょう。これは、問題点を早期にあぶり出すための「仕組み」があれば、生産性を損わずにバグに対処できることを意味しています。

生産性のことを考えるなら、問題が表面化するより前に原因を摘み取れたらベストね。
それが「バグを積極的にあぶり出す」っていう考え方なんですね。
そういうこと。そのためには、時間や労力を節約できる「仕組み」を考えることが大切なの。
ここがポイント!
バグをゼロにすることはできません。バグをなくすために時間を使いすぎるより、バグを早期発見するための仕組みづくりにフォーカスしましょう。
ふむふむ。「仕組み」を考えることが大切と……でも、具体的にどうすればいいんですか?
まずはテストとレビューについて考え直すことかしらね。いつ、何をチェックすれば、どんな問題を最速で取り除けるか。
やっぱり、テストとレビューが基本ってことには変わりないんですね。
それはそうよ。まぁ、でもね……ここはソフトウェアエンジニアらしく、「デバッグコード」の書き方を覚えるのはどうかしら。
デバッグコードって何ですか?
プログラムを作るときに、バグを捕らえるための「罠」を仕掛けるテクニックのことよ。まだ記憶に新しいうちにデバッグコードを書いておくことで、問題に早く気付けるようになるわ。
わぁ、なんかすごそうですね!ぜひ教えてください!

引数に込めた意味

ユキ先輩。if文for文の使い方について、いろいろと教えてもらったところなんですけど……
うん。どうだった?
いやぁ。基本だからもう大丈夫って思ってたところも、意外に奥が深いんだなぁって感じました。
ふふふ。そこを分かってもらえて嬉しいわ。
えへへ。そういうの、もっと知りたいです!
それじゃあ、次は引数の話をしましょうか。
やったー!お願いしまーす。

次のような、ただ足し算をするだけの関数があったとします。

int Add(int a, int b) {
  return a + b;
}

「こんな関数は要らないでしょ!」というツッコミが聞こえてきそうですが……話を単純にするための例だと思ってください。

さて、この関数の実装が、もしも次のようになっていたとしたらどうでしょうか。

int Add(int a, int b) {
  a += b;
  return a;
}

足し算の結果をいったん引数aに格納してから、それを返しています。関数の内部的な実装の話なので、こうした実装の違いが呼び出し側に影響を与えることはありません。つまり、最初の例も2つ目の例も、同様に足し算を実行できる関数です。

うーん。でも、なんとなく気持ち悪い感じがします……
あら、なかなかスルドイじゃない。

引数への代入を避ける

識別子(この場合は引数の名前)を次のように変えれば、違和感の理由が浮き彫りになるでしょう。

int Add(int LHS, int RHS) {
  LHS += RHS;
  return LHS;
}

命名法のところでも紹介しましたが、LHSは「Left Hand Side(左側)」の略、RHSは「Right Hand Side(右側)」の略です。LHS + RHSのように、足し算記号の左側・右側に置かれる値を意味する名前ですね。

このように、識別子に具体的な名前を付けてあげると、それぞれの意味が明確になります。そして、どの識別子にも意味があるのだとしたら、それを無視した値を割り当てると矛盾が生じてしまうはずです。

上の例では左側という意味のLHSに、足し算の結果を割り当ててしまっているわね。
なるほど。だから違和感があったんですね。

この例ほど小さな関数であれば、実際のところは大した問題ではないかもしれません。でも、もっと行数が増えていくと、そうも言っていられなくなります。

int Add(int LHS, int RHS) {
  LHS += RHS; /* ← 名前の意味を無視して代入 */

  ……

  printf("LHS = %d\n", LHS); /* ← まさか名前と違う値が入っているとは! */
  printf("RHS = %d\n", RHS);

  ……

  return LHS;
}

上記の例では、関数の途中で引数LHSRHSの値を表示させています。このとき、引数の名前から、それぞれ足し算の左側と右側にくる値だと考えているわけです。ところが実際には、ここより上のほうでLHSの値が変わってしまっています。

名前は、プログラミングをする際の重要な手がかりです。わざわざ具体的な名前を付けておいて、そこに違うものを割り当てるのは、必要以上にプログラムを複雑にする行為だといえるでしょう。

つまり、引数に別の値を代入するとミスリードになっちゃうんですね。
そういうこと!
ここがポイント!
引数には意味が込められています。意味と中身が矛盾するのを避けるために、引数には別の値を代入しないようにしましょう。

代入を必要最小限にするためのコツ

ここで、名前を付け直す前の例に戻りましょう。

int Add(int a, int b) {
  a += b; /* ← 意味を無視して代入 */
  return a;
}

これらの引数(ab)は、意味を簡単にイメージできるような名前をもっていません。でも、たとえ名前が曖昧でも、よく見れば意味が隠れているものです。そのため、やはり引数への代入はおすすめしません。

代わりに、次のようにすればいいでしょう。

int Add(int a, int b) {
  int result = a + b;
  return result;
}
計算結果を新しい変数に割り当てているのが分かるかしら?
はい。こうすれば名前と値が矛盾することはないんですね!
考え方としてはね、頭の中でconstを付けてみるといいわよ。
え?それってどういうことですか?

プログラム中に現れる変数(引数を含む)のうち、初期化したあとで値を変える必要があるものは、それほど多くはありません。そこで、「実はほとんどの変数が不変なのだ」とイメージしてみましょう。C言語では、「不変(constant)」を意味するconstを付けた変数は、値の変更ができなくなります。

例えば、次のようにabconstを付けたと考えると……

int Add(const int a, const int b) {
  a += b; /* ← エラー */
  return a;
}

引数に代入しようとしている行でコンパイルエラーが発生します。

一方、計算結果を新しい変数に代入する場合はエラーにはなりません(計算結果のresultも不変なのでconstが付いています)。

int Add(const int a, const int b) {
  const int result = a + b;
  return result;
}
なるほど。「実はほとんどの変数が不変」って、分かりやすくていいですね!
でしょ。
でも、どうしてconstを付けるのは「頭の中で」なんですか?実際のプログラムでも、どんどん付ければいいと思ったんですけど……
付けてもいいのよ。でも、実際にやってみると、constの数が多すぎて目がチカチカしてくるんじゃないかしら。
そっか。ほとんどの変数がconstになってしまったら、ちょっと読みづらそうですね。
だからね、constは意味があるところにだけ付けるのがおすすめよ。

実際のプログラム中でconstを使いすぎると、どれが本当に大事なconstなのかが分からなくなってしまいます。constを付けるのは、メリットを感じられる場所に限定するのがいいでしょう。その際には、標準関数がお手本になります。

例えば、文字列をコピーするstrncpy()は、次のような形をしています。

char *strncpy(char *dest, const char *src, size_t n);

第1引数のdestは、「ポインタ渡し」になっています。これは、関数を呼び出す側にあるバッファに、文字列のコピーを書き戻してもらうためのアドレスです。値を書き込む必要があるので、constは付いていません。

第2引数のsrcも「ポインタ渡し」ですが、こんどはconstが付いています。こちらはコピーする文字列を関数に渡すためのものであり、このアドレスには何も書き込んでほしくはありません。constがあれば「この関数はsrcの文字列を変更することはないんだな」と分かるので、意図を理解して使えます。

第3引数のnは、コピーする文字数です。この値も変更してほしいものではありませんが、constがありませんね。これは、引数nが「値渡し」になっているためです。「値渡し」の引数は関数呼び出しの際に作られる一時的な変数なので、たとえ関数の内部で値が変更されたとしても、関数を呼び出す側には影響がありません。つまり、nconstが付いているかどうかは、この関数を呼び出す側の関心事ではないということです。

ここで、関数は作る回数よりも、使う回数の方が多いことを思い出しましょう。関数を使うときのことを中心に考えるなら、第3引数のnconstを付けてもメリットがありません。また、nconstを付けないほうが、第2引数のsrcに付いている大事なconstが際立ちます。

これをふまえて、最初に出てきた足し算をする関数の引数にはconstが要るかしら?
えっと。abはどっちも「値渡し」になってるから……
int Add(int a, int b) {
  return a + b;
}
呼び出し側にメリットがないから、constは要らないです!
そのとおり!でも関数を作るときは、頭の中でconstを付けて「不変」だと思うのがコツなのよ。
引数に別の値を代入しないように、気を付けやすくなるってわけですね。
ここがポイント!
プログラム中に現れる変数(引数も含む)は、実はほとんどが不変!そう考えれば、名前と中身の矛盾を避けやすくなります。

あやしいfor文

デバッグしやすくするにはif文を整理して分岐を減らしておくといいって教えてもらいましたけど、ループを書くときもコツってあるんですか?
そうねぇ……まずはif文と同じで、「やりがちな間違い」について知っておくのがいいかしら。
ループにも「やりがちな間違い」があるんですか?
いろいろとね。例えば……

次のfor文では変数xを「10」で初期化して、それが「0」になるまで「-2」で割るという処理をしようとしています。

  for (int x=10; x=!0; x/=-2) {
    printf("%+d\n", x);
  }

もし、これが正しいプログラムなら次のように表示されるはずです。

期待される実行結果
+10
-5
+2
-1

ところが、実際に実行してみると「+1」が表示され続けてしまい、いつまでもループが終わりません。

実際の実行結果(バグ)
+1
+1
+1
……(ループが止まらない!)
どこかを書き間違えているせいなのだけど、分かるかしら?
ええーっと、ちょっと待ってくださいね……
ふふふ。シンキングタイムね。
……ん?あぁ、分かりました!x=!0のところが書き間違いですね。
そうそう。正しくは?
x!=0です!
正解!ちなみに、今のはfor文だったけれど、while文にも同じ間違いはありそうね。
例えばこう書いても、やっぱりループは止まらない。
  int x = 10;
  while (x =! 0) {
    printf("%+d\n", x);
    x /= -2;
  }
なるほど。同じ動きになりそうですね……って、あれれ?
どうかした?
これって、!==!と書き間違えたんだから、そもそも文法エラーで実行できないのでは?
あー、それはね……

念のため、なぜx=!0がコンパイルできてしまうのか確認しておきましょう。

C言語に=!という演算子はありません。でも=!はありますね。そのため、この式はx = !0と解釈されているのです。!は否定を表すので!0の部分は1と書くのと同じであり、式全体としてはx = 1と代入するのと同じ意味になります。

また、代入を式と見たときの計算結果は、代入した値そのものになります。この式の場合は、計算結果が常に1になるということです。その結果、いつまでもループが終わらなくなってしまっていたのでした。

……と、原因を突き詰めていくと、どうしても細かい話になるのよね。
うーん。本当の原因は、単純に書き間違えたことだと思うんですけど。
そのとおりね。だから大切なのは、「動作がおかしいぞ」って気付いたときに……
そっか。どこが間違っているのか考えてデバッグしやすいように、プログラムを整理しておくことが大切なんでしたね。
そういうこと!

for文の使いどころ

さっき、for文のところをwhile文で書いたらこう、っていう例を見せてくれましたよね。この2つは何が違うんですか?
うん、そこは疑問に思うところよね。それじゃあ、for文とwhile文の違いについて確認しておきましょうか。
お願いします!

C言語のfor文は、だいたい次のような形をしています。

for (ループ変数を初期化; ループ条件をチェック; ループ変数を更新) {
  ……
}

これは、while文の「ループ条件」のすぐ近くに、「初期化」と「状態の更新」も書けるようにしたような文法だといえます。これと同等の意味をもつwhile文は、次のように書けます。

ループ変数を初期化;
while (ループ条件をチェック) {
  ……
  ループ変数を更新;
}
実際のところ、while文がこういう形に収まることは多いんじゃないかしら。
はい。そんな気がします。
その場合は、for文に置き換えられるはずね。

whileでもforでも、どちらを使ってもプログラムを書けるという状況は意外によくあります。これは、プログラムが分かりやすくなりそうなほうを選べるということです。

基本的には、for文を使ったほうが分かりやすいケースが多いでしょう。なぜなら、ループ変数に関する処理をまとめておけるからです。これにより、次の2つを区別しやすくなります。

  • どういう条件でループするのか
  • ループ中で何をするのか

視覚的に書くと、ループをこういう構造にできるということですね。

for (どういう条件でループするのか) {
  ループ中で何をするのか;
}
逆のことをいえば、上の構造を意識せずに書いたfor文は分かりづらくなりがちね。
例えば、このfor文はちょっとあやしい……
int sum;
int i;
for (i=1, sum=0; i<=10; sum+=i, i++);
うーん、なんだか分かりにくく感じます。行数は少ないのに……
そうよね。for文には、もっとプログラムを分かりやすくできるポテンシャルがあるの。それを引き出してあげましょう。
例えば、こう手直ししたらどうかしら?
int sum = 0;
for (int i=1; i<=10; i++) {
  sum += i;
}
わー、こんどは読みやすくなりましたよ。でも、なんで?

読みやすく手直ししたfor文では、括弧(())の内側にループ変数に関する処理だけが書かれています。変数sumはループ変数ではないので、中括弧({})の中に移動しました。これで、何をしているのかが読み取りやすいプログラムになったのではないでしょうか。

もしかすると、「これって、そんなに重要なこと?」と思ったかもしれません。でも、何十行もあるループを想像してみてください。プログラムが長く複雑になるほど、こういう小さな工夫によってデバッグしやすいプログラムになるものです。

ここがポイント!
while文がfor文でも書けそうなときは、プログラムが分かりやすくなりそうなほうを選びましょう。

for文と「無限ループ」

for文は、無限ループを書くときにもよく使われます。具体的には、次のような書き方をします。

for (;;) { /* ← 無限ループ */
  ……

  if (ループ条件をチェック) {
    break; /* ← ここでループ終了 */
  }

  ……
}

中括弧で囲まれた処理の、なかほどでbreakしていますね。無限ループでは、ここのif文の判定がループ条件にあたります(正確には、ループを終了する条件ですが)。

この書き方は、ループの入り口でループ条件を判定できない場合に便利ね。
そっか。だから「なかほどでbreak」なんですね。

ちなみに、こういうループは「n+1/2ループ(nと2分の1ループ)」などと呼ばれることもあります。通常のループが処理をn回繰り返すのだとしたら、こちらはあと1/2回多く処理するという意味ですね。

ループとunsignedの罠

ところで、signedunsignedの比較は要注意だっていうのは聞いたことあるかしら?
え?なんの話ですか?
例えばintは「符号あり」の値を表すからsignedな型ね。
はい。
で、unsigned intは「符号なし」よね。この2つの値を比較すると、思ったのと違う結果になることがあるのよ?
まさか、「-1」が「1」より大きいとか……いわないですよね?
あら、スルドイじゃない。

次の関数を見てください。for文を使って、引数xから「10」までの値を列挙しようとしています。

void valuesUpTo10(int x) {
  unsigned int y = 10;

  printf("Values:");
  for (int i=x; i<=y; i++) {
    printf(" %d", i);
  }
  printf("\n");
}

この関数をvaluesUpTo10(5);と呼び出すと、出力は次のようになります。

実行結果
Values: 5 6 7 8 9 10
ふむふむ。xが「5」でyが「10」だから、forループでこうなりますね。
それじゃあ、valuesUpTo10(-5);と呼び出したらどうなるかしら?
なんだか嫌な予感がするなぁ……こんどはxが「-5」だから、こうじゃないんですか?
期待される実行結果
Values: -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10
それがね、こうなってしまうのよ。
実際の実行結果(バグ)
Values:
1回もループしないってことですか?
そう。iyの値を比較している部分に問題があるの。
ちなみに、for文の代わりにwhile文を使っても結果は一緒。
void valuesUpTo10(int x) {
  unsigned int y = 10;

  printf("Values:");
  int i = x;
  while (i <= y) {
    printf(" %d", i);
    i += 1;
  }
  printf("\n");
}
無限ループに書き換えて比較の部分をif文にしても、やっぱり結果は変わらない。
void valuesUpTo10(int x) {
  unsigned int y = 10;

  printf("Values:");
  int i = x;
  for (;;) {
    if (i > y) {
      break;
    }
    printf(" %d", i);
    i += 1;
  }
  printf("\n");
}
つまり、forとかwhileとかifとかの違いは関係ないんですね。
signedunsignedを比較しているところが問題ってことね。
ええと。yunsigned intになってますね。ちょっと直してみていいですか?
yintにしてみたらどうかな。
void valuesUpTo10(int x) {
  int y = 10; /* ← unsigned を削除してみた */

  printf("Values:");
  for (int i=x; i<=y; i++) {
    printf(" %d", i);
  }
  printf("\n");
}
あ、出ましたよ!
実行結果
Values: -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10
うん、直ったわね。何が原因だったかというと……

この関数では、変数iyの値を比較しています。前者の型はint、後者の型はunsigned intです。でも型が違うままでは値を比較できません。そこで、実際には比較する前に、両辺の値が同じ型に揃えられます。

このとき、intunsigned intに変換する処理が実行されます。そのため、intの符号が失われてしまうのです。具体的には、次のようになります。

  • intの値が0以上なら、unsigned intに変換後も同じ値になる
  • intの値がマイナスの場合は、unsigned intに変換すると、intで表現できる最大値よりも大きな値になってしまう

これにより、例えば-5 <= 10という比較をしているつもりが、4294967291 <= 10のような予想外な動きになっていたのです。

この問題は、実際の値がマイナスになるときだけ発生するっていうのが気を付けたいポイントね。
なるほど。だからvaluesUpTo10(5);は大丈夫で、valuesUpTo10(-5);とするとおかしな動作になったんですね。
そういうこと。ループ条件には値の大小を比較する式を書くことが多いけど、マイナスの値とunsignedを一緒に使わなければ大丈夫よ。
はーい!
ここがポイント!
マイナスの値を扱うときは、unsignedな値との比較に注意しましょう。

あやしいif文

ユキ先輩。プログラムには流れがあるっていう話を聞いて、if文の使い方が大事なんだなと感じたんですけど……
うん。if文は基本だけれど、意外に奥が深いものよ。
ですよね!そしたら、やっぱりコツみたいなものって、いろいろあるんじゃないですか?
そうね……C言語の解説書なんかでは、「やりがちな間違い」がよく紹介されてるわね。
「やりがちな間違い」ですか?
例えばね……

次のif文では、変数xの値が「-10」と「10」の間かどうかチェックしようとしています。

  int x = 123;
  if (-10 < x < 10) {
    puts("Yes");
  } else {
    puts("No");
  }

もし、これが正しいプログラムなら、Noと表示されるはずですね。でも、実際にはYesと表示されるでしょう。理由は、次のとおりです。

  • このif文は、-10 < x < 10の計算結果がtruefalseかをチェックする
  • 最初に、-10 < xの部分がtrueと判定される(x123なので)
  • 次に、これをもとの式に当てはめると1 < 10となる(C言語ではtrue1false0なので)
  • その結果、式全体の計算結果はtrueとなる

さらによく考えてみると、この式の計算結果はxがいくつでもtrueになることが分かります。-10 < xの部分は必ず10になり、どちらにしても10より小さいためです。

うわぁ。なんだか、思ってたのと違う計算になっちゃってますね……。
これはね、書き方を間違えているのよ。
じゃあ、正しい書き方があるってことですか?
もちろんよ!

上のif文は、次のように書き直せば期待したとおりに動きます。

  if ((-10 < x) && (x < 10)) {
    puts("Yes");
  } else {
    puts("No");
  }

-10 < x < 10-10 < xx < 10の2つの式に分けて、「AND条件(&&)」でつなげればいいんですね。

あ……はい、なるほど。答えを聞いたら、そりゃそうだって思っちゃいました。
そうよね。こういう書き間違いは、まぁいろいろあるわ。
えー、いろいろあるんですか……。
大丈夫、大丈夫よ。ぜんぶ覚えようとしなくてもいいの。
そうなんですか?よかったぁ〜。
ふふふ。こういう書き間違いをしたときって、だいたいは動作がおかしくなるでしょう?
はい、そうだと思います。
動作がおかしければ、「どこが間違ってるんだろう」って考えるんじゃないかしら?
そうですね、考えてデバッグしますね。
つまりね。間違いに気付くことさえできれば、あとは考えて直すだけなのよ。
えっと……そこが難しいところなのでは?
だから少しでもデバッグしやすくなるように、if文を整理するテクニックを覚えましょう!
なるほど!そっちがコツなんですね。

if文のネストを減らす

一般的に、分岐が増えるほどプログラムは複雑になっていきます。デバッグのことを考えれば、if文はできる限り少ないほうがいいでしょう。これは、「必要なif文」と「無駄なif文」を見極められるようになれば可能です。

要るのかどうかあやしいif文って、ネストのところに多いんじゃないかしら。
ほほう。

まずは、if文がネストしている部分に注目してみるのがおすすめです。次のプログラムを見てください。

  int x = 15;
  int y = 5;

  /* xはyで割り切れる? */
  bool isFactor = false;
  if (y != 0) {
    if (x % y == 0) {
      isFactor = true;
    }
  }

  /* 結果を表示 */
  puts(isFactor ? "Yes" : "No");
xyで割り切れるかどうかを、2つのif文で調べているわね。
ふむふむ。割り算をする前に、分母にくるyの値が0じゃないことを1つ目のifで確認してるんですね。
そう。この2つのifには、このとおりの順番で実行する意味があるということよ。
なるほど。

このように、「最初にこちら」「次にこちら」と順番に判定することがあるなら、if文をネストさせるのは自然な流れです。無理をしてif文の数を減らそうとする必要はないでしょう。

では、次のプログラムはどうでしょうか?

  int x = 15;

  /* xは3と5の倍数? */
  bool isMultiple = false;
  if (x % 3 == 0) {
    if (x % 5 == 0) {
      isMultiple = true;
    }
  }

  /* 結果を表示 */
  puts(isMultiple ? "Yes" : "No");
こんどはxが3と5の両方の倍数かどうかを調べているわね。2つのifの順番には……
あ、意味がないんですね。入れ替えてもおんなじだ。
そのとおり!こういうif文はね、1つにまとめてしまえばいいのよ。
もしかして!さっきも出てきた「AND条件(&&)」ですね?
つまり……
  /* xは3と5の倍数? */
  bool isMultiple = false;
  if ((x % 3 == 0) && (x % 5 == 0)) {
    isMultiple = true;
  }
正解よ!if文が1つ減ったわね。
やったー!
ちなみに、もう1つ減らせるのだけど……
いやいや、それだとifがなくなっちゃいますけど。
なくすのよ。
判定結果を変数に格納するだけなら、これでいいわね。
  /* xは3と5の倍数? */
  bool isMultiple = (x % 3 == 0) && (x % 5 == 0);
なんと!そもそもif文が要らない処理だったんですね。
ここがポイント!
「AND条件(&&)」をうまく使うと、if文のネストを減らせる場合があります。

if文で引数を判定する

じゃあ次は、if文と引数との関係について考えてみましょうか。
え?if文と引数ですか?

引数の値で処理を分岐させたいときにも、if文はよく使われます。次の関数を見てください。

/* 引数の値に応じたあいさつを表示する。
 *   1のとき: "Hello!"と表示
 *   2のとき: "Bye!"と表示
 */
void Say(int greetingIdentifier) {
  if (greetingIdentifier == 1) {
    puts("Hello!");
  }
  else {
    puts("Bye!");
  }
}
あやしいところはないかしら?
なんだか、コメントに書かれていることと処理内容がズレてる感じがしますね。

enumを使って次のように書き直してみれば、あやしい部分が浮き彫りになるでしょう。

typedef enum {
  Hello = 1,
  Bye = 2
} GreetingIdentifier;

void Say(GreetingIdentifier greetingIdentifier) {
  if (greetingIdentifier == Hello) {
    puts("Hello!");
  }
  else {
    puts("Bye!");
  }
}

引数が2の場合について、まともに判定していなかったことが分かるのではないでしょうか。これでは、引数が3の場合にも2と同じあいさつが表示されてしまいます。つまり、想定外の値が入力されたときにも、2が入力されたのと同じように動作してしまうのです。

その結果、引数の値に問題があってもなんとなく動作してしまいます。そのため、バグがあると認識するのが遅れることになるでしょう。

これはおそらく、引数の値が1でも2でもないケースについて、考えるのを忘れてしまったことが原因よ。

関数の定義域を明確にして、次のようにすれば状況は改善されます。

/* 引数の値に応じたあいさつを表示する。
 *   1のとき: "Hello!"と表示
 *   2のとき: "Bye!"と表示
 * 上記以外の値は入力禁止とする(入力時の動作は未定義)。
 */
void Say(int greetingIdentifier) {
  assert((greetingIdentifier == 1) || (greetingIdentifier == 2));

  if (greetingIdentifier == 1) {
    puts("Hello!");
  }
  else {
    puts("Bye!");
  }
}

コメントに1文追加したのが分かるでしょうか。これで、3を引数としてこの関数を呼び出してはいけないことが明確になりました。

実装としては、assertが追加されただけですね。
そうね。でも、この1行が書けるようになるとプログラムの品質はぐんと向上するのよ。

こうすれば、たとえ呼び出し側が誤って引数に3を指定したとしても、それが間違いだとすぐに気付くことができるでしょう。関数の誤った呼び出し方を、その入り口でシャットアウトしているからです。

if文のほうは何も変わっていませんね。
そうね。でも、すぐ上にassertがあるのがポイントよ。引数の値が1でも2でもないケースについて、しっかり考慮されていることが伝わるでしょう?
assert()については別のトピックで詳しく説明します。
ここがポイント!
引数をif文でチェックするときは、想定外の値が入力されるケースについても考えましょう。

if文をswitch文に置き換える

ちなみに、さきほどのif文は、次のようにswitch文で置き換えることもできます。

/* 引数の値に応じたあいさつを表示する。
 *   1のとき: "Hello!"と表示
 *   2のとき: "Bye!"と表示
 * 上記以外の値は入力禁止とする(入力時の動作は未定義)。
 */
void Say(int greetingIdentifier) {
  switch (greetingIdentifier) {

    case 1:
      puts("Hello!");
      break;

    case 2:
      puts("Bye!");
      break;

    default:
      assert(false);
  }
}

switch文もif文と同様、プログラムを分岐させるためのものです。複数の分岐を並べて書けるので、うまく使えばプログラムの流れを整理できるでしょう。

先々のことまで考えると、この書き方にはメリットがあるのよ。
あとからcaseを追加しやすいってことですよね。
正解!引数で3や4を受け付けられるように機能を拡張したくなったときも、これなら対応しやすいわね。

この書き方なら、将来の機能拡張が既存部分に与える影響を最小限にできるでしょう。引数の値のバリエーションが実際に「増えそうだな」と予想されるときは、こうしておければベターだといえます。

プログラムには流れがある

ユキ先輩。プログラムを分かりやすく書くために、気を付けると良いことってありますか?
そうねぇ……プログラムの流れを意識することかしら。
プログラムの流れ、ですか?詳しく教えてください!
じゃあ、いくつか例を挙げてみるわね。

まず、プログラム中の処理には、「正常時の処理」と「エラー(異常時の)処理」がありますね。

ソースコード上では、正常時の処理が中心にくるような書き方をするのがおすすめです。なぜなら、エラー処理は「だいたいの場合は発生しない状況」を扱う処理だからです。

正常時の処理を中心に据えておけば、そのプログラムをほかの人が見るときや、あとで自分が見たときに理解しやすいでしょう。

正常時の処理が中心にくるように、ですね。……けど、あまりイメージがわかないです。
そうね。それじゃぁ、次の「悪い例」を見てみましょう。
/* 指定されたファイルにテキストを書き込む。
 * - pText: テキスト
 * - pPathToFile: ファイルのパス
 * 正常に書き込めたらtrue、エラーが発生したらfalseを返す。
 */
bool WriteTextToFile(const char *pText, const char *pPathToFile) {
  assert(pText);
  assert(pPathToFile);

  FILE *fp = fopen(pPathToFile, "w");
  if (fp == 0) {
    return false; /* エラー:ファイルを開けなかった */
  }

  if (fputs(pText, fp) != EOF) {
    fclose(fp);
    return true; /* 正常 */
  }

  fclose(fp);
  return false; /* エラー:テキストを書き込めなかった */
}

この関数は、ファイルに文字列を書き込み、うまくいったらtrueを返すというものです。でも、正常時の流れがどこにあるのかが見つけにくくなってしまっています。

たしかに。なんか、入り組んでいる感じがしますね。
問題点は、いくつか考えられるわね。例えば……
  • エラーの判定に一貫性がありません。fopen()では失敗したかどうかを判定しているのに対し、fputs()では成功したかどうかを判定しています。
  • すべての処理に成功したとき、関数の最後までたどり着くことなくreturnしています。
  • fopen()は1カ所しかないのに、fclose()は2カ所で呼び出されています。
なるほど〜。
だけど、最大の問題は、書き方に「ポリシー」がないことよ!
書き方の「ポリシー」ですか!?

流れが分かりやすい書き方

プログラムの流れが分かりやすくなる、おすすめのポリシーを紹介するわね。

  • 正常でもエラーでも、関数の最後まで処理を到達させる。
  • if文では処理が成功したかどうかを判定し、エラー処理が必要な場合はelseに収める。

このポリシーにしたがうと、上の関数は次のようになります。

bool WriteTextToFile(const char *pText, const char *pPathToFile) {
  assert(pText);
  assert(pPathToFile);

  bool bSuccess = false;

  FILE *fp = fopen(pPathToFile, "w");
  if (fp) {
    if (fputs(pText, fp) != EOF) {
      bSuccess = true;
    }

    fclose(fp);
  }

  return bSuccess;
}

どうでしょう。正常時の流れがよく見えるようになったのではないでしょうか。正常時の流れが見えれば、その関数がどのようなアルゴリズムで書かれているのかが明確になります。

ほんとだ。処理の流れが分かりやすくなりましたね!
それに、複雑さが軽減されて、改造が必要なときも手を付けやすいソースコードになったんじゃないかしら。

上の例でも、ごく自然な流れで、fopen()fclose()の呼び出しが1度ずつになりました。さらに、エラーが発生したとき、実は「falseを返す」以外に何もしていなかったということが明らかになりました。

ポリシーにしたがって書くようにすると、いろいろなメリットがあるんですね。
そうね。でも、これですべてOKってことでもないのよ。

この書き方は、必ずしも万能というわけではありません。判定すべきことが増えてくると、ifのネストが深くなってしまうのです。処理をサブルーチンに分ければネストの問題は解決できますが、ほかの書き方をしたくなるケースも出てくるでしょう。

毎回同じポリシーにするってわけにはいかないんですね。

もう一つの書き方

もう一つ、別のポリシーを紹介するわね。

  • 正常時は、関数の最後まで処理を到達させる。
  • エラーを検出したら、即座にreturnする。

このポリシーにしたがうと、先ほどの関数は次のように書けます。

bool WriteTextToFile(const char *pText, const char *pPathToFile) {
  assert(pText);
  assert(pPathToFile);

  FILE *fp = fopen(pPathToFile, "w");
  if (fp == 0) {
    return false;
  }

  if (fputs(pText, fp) == EOF) {
    fclose(fp);
    return false;
  }

  fclose(fp);

  return true;
}

エラーケースが多数考えられる関数は、こちらのポリシーで書いたほうがシンプルになることがよくあります。そもそも、if文のネストが深くならないのでこちらのほうが好き、という人もいるかもしれません。

ただし、プログラムの「構造化」という面では、returnを何度も書くのは好ましいとはいえません。この例のfclose()ように、同じ処理を2回以上書かなければならない場面も出てきます。

それでも、一定のポリシーにしたがって書かれたプログラムは読みやすくなるものよ。
書き方に「ポリシー」がないと、プログラムが必要以上に複雑になっちゃうってことですね!
そういうこと。

基本的には、1つ目に紹介したポリシーで書くようにするのがおすすめです。それで、ちょっとやりづらいなと感じたときは、2つ目のポリシーを試してみてください。

ここがポイント!
プログラムを書くときは、処理の流れを意識しよう。一定のポリシーにしたがって書けば、読みやすくなります。

第30問の答え

答え

次のように、再帰呼び出しを使うのが正解!
int SumListValues(const ListNode *pNode) {
  if (pNode == 0) {
    return 0;
  }

  return pNode->value + SumListValues(pNode->pNext);
}

解説

これは、「再帰呼び出し(recursive call)」に関する出題でした。再帰呼び出しとは、ある関数の処理中で、その関数を再び呼び出す手法のことです。

問題の関数は、次のような形をしていました。

int SumListValues(const ListNode *pNode) {
  ……
}

この関数は、引数pNodeで連結リストのノードを受け取ります。そして、そのノード以降の値の合計を返すというものです。

値の合計を再帰呼び出しで求めるには、次の2つに分けて考えればいいですね。

  • pNodeの値(pNode->value
  • pNodeの次のノード(pNode->pNext)以降の値の合計

この2つを足せば、pNode以降の値の合計になるはずです。

次のノード以降の値を合計するところで再帰呼び出しを使うのよ。つまり、自分自身を呼び出すということね。
あ、分かったかも!
2つに分けて、自分自身を呼び出す……と、できた!
int SumListValues(const ListNode *pNode) {
  return pNode->value + SumListValues(pNode->pNext);
}
ふむふむ。それじゃあ、実行してみましょうか。
はーい!……ぐはぁ、クラッシュしました。
そうよね。でも、おしいところまできてるのよ。

自分自身を無制限に呼び出し続けようとすれば、いずれプログラムはクラッシュしてしまいます。そのため、再帰呼び出しは「どこでストップするか」が重要です。

今のクラッシュは、ノードが4つしか存在しないのに対し、5回目の呼び出しを行ったために発生したものでした。このとき、引数は0(NULL)になっていたはずです。最後のノードに対するpNode->pNextには、0が格納されているためです。

  ListNode node4 = { 40, 0 }; /* ← 最後のノードのpNextは0(NULL) */
  ListNode node3 = { 30, &node4 };
  ListNode node2 = { 20, &node3 };
  ListNode node1 = { 10, &node2 };
引数pNodeが0だったときに、自分自身を呼び出さないようにすればいいわね。
ふむふむ。じゃあif文を使って……
int SumListValues(const ListNode *pNode) {
  if (pNode == 0) {
    /* ここでストップさせたい! */
  }

  return pNode->value + SumListValues(pNode->pNext);
}
えっと、呼び出しをストップさせるのにbreakは使えないですよね?
うん、ループじゃないものね。ここではreturnが使えるわよ。
そっか!pNodeが0のときは値がないから……
int SumListValues(const ListNode *pNode) {
  if (pNode == 0) {
    return 0;
  }

  return pNode->value + SumListValues(pNode->pNext);
}
こうやって0を返せばいいんですね!
正解!これでクラッシュせずに、合計を求められるようになったはずよ。
なんだか、すごくシンプルなプログラムになっちゃいましたね。
そう見えるのは、レオ君が仕組みを理解したからよ。
えっ?
再帰呼び出しを知らない人から見れば、これは難解なプログラムだと思うわ。
たしかに……じゃあ、普通にループを使ったほうが人に優しいってことになりませんか?
そうかもしれないわね。でもね、やっぱり再帰呼び出しが必要っていう場面もあるのよ。

今回の題材は、連結リスト構造でした。そのノードは、次のような形をしています。

typedef struct ListNode {
  int value;
  struct ListNode *pNext;
} ListNode;

ノードが一列につながるため、ループで処理できることも多いでしょう。

では、データがツリー構造だった場合はどうでしょうか。例えば、つながりが左右2方向に枝分かれする「二分木(binary tree)」のノードは、次のような形をしています。

typedef struct TreeNode {
  int value;
  struct TreeNode *pLeft;
  struct TreeNode *pRight;
} TreeNode;

ツリー内の値を合計したいと思ったとき、whileループやforループを使うのは無理があります。でも、次のように再帰呼び出しを使えば、プログラムはシンプルになります。

int SumTreeValues(const TreeNode *pNode) {
  if (pNode == 0) {
    return 0;
  }

  return pNode->value + SumTreeValues(pNode->pLeft) + SumTreeValues(pNode->pRight);
}

これは、値の合計を次の3つに分けて考え、足し合わせているのです。

  • pNodeの値(pNode->value
  • pNodeの左側のノード(pNode->pLeft)以降の値の合計
  • pNodeの右側のノード(pNode->pRight)以降の値の合計
データが一列に並んでいればループで処理できるけど、枝分かれしているなら再帰呼び出しって感じですかね?
そうね。そういうイメージでいいと思うわ。
ここがポイント!
再帰呼び出しを使いこなせば、いろいろな構造のデータをシンプルに処理できる!

さて。

ここまで読んでいただき、ありがとうございました!C言語にまつわる全30問のクイズ、いかがだったでしょうか?現実の問題解決に役立てられる部分が少しでもあったなら、とても嬉しいです。

本シリーズの問題は、読み物としても楽しんでいただけるよう心がけて制作しました。もし「途中から読んだよ」という場合は、ぜひ「問題一覧」に戻って、ほかの問題にもチャレンジしてみてください!

僕ももう1回、第01問から見直してみたいです!
そうね。時間をあけてから見直すと、何か新しい発見があるかもしれないわね!

修正後のプログラム

main.c
#include <stdio.h>
#include <stdlib.h>

typedef struct ListNode {
  int value;
  struct ListNode *pNext;
} ListNode;

int SumListValues(const ListNode *pNode) {
  if (pNode == 0) {
    return 0;
  }

  return pNode->value + SumListValues(pNode->pNext);
}

int main(void) {
  ListNode node4 = { 40, 0 };
  ListNode node3 = { 30, &node4 };
  ListNode node2 = { 20, &node3 };
  ListNode node1 = { 10, &node2 };

  printf("Sum: %d\n", SumListValues(&node1));

  return EXIT_SUCCESS;
}
実行結果
Sum: 100

第30問

さあレオ君、いよいよ最後の問題よ!
もう最後か〜。けっこう楽しかったのになぁ……
あら、嬉しいことを言うじゃない。
ユキ先輩のおかげで、僕もちょっとレベルアップできた気がします!
よかったよかった。それじゃあラスト1問、はりきっていきましょうか!
はーい!

以下は、第29問の答えのプログラムです。数値の並びを連結リスト(linked list)構造で表現し、その合計を関数SumListValues()で求めています。この関数ではループが使われていますが、ループを使わない方法に置き換えたいと考えています。

どう書き直せばいいか、分かりますか?
main.c
#include <stdio.h>
#include <stdlib.h>

typedef struct ListNode {
  int value;
  struct ListNode *pNext;
} ListNode;

int SumListValues(const ListNode *pNode) {
  int sum = 0;
  for (const ListNode *p = pNode; p; p = p->pNext) {
    sum += p->value;
  }

  return sum;
}

int main(void) {
  ListNode node4 = { 40, 0 };
  ListNode node3 = { 30, &node4 };
  ListNode node2 = { 20, &node3 };
  ListNode node1 = { 10, &node2 };

  printf("Sum: %d\n", SumListValues(&node1));

  return EXIT_SUCCESS;
}
実行結果
Sum: 100
第29問は正しいプログラムを、別の正しいプログラムに書き換える問題だったわよね。
はい、そうでした。
そこで、さらに別の方法で正しいプログラムを書いてみよう!っていうのが今回の問題よ。
なるほど。でも、ループを使わない方法なんて、本当にあります?
あるのよ。数値の並びは10203040でしょう?
はい。つまり、求めたいのは「10203040の合計」ってことですよね。
そうね。それを最初の1つと残りの3つを分けて考えたら、「10」と「203040の合計」の2つになるわよね。
え……はい、なります。
その2つを足したら、求めたい合計になるのよ。
ええっ?それって解決になってます?
あら、何かおかしなことを言ったかしら?
だって、「203040の合計」のほうは、結局ループしないと求められないですよ。
そこはもう1回、最初の1つと残りを分けるのよ。
ん?「20」と「3040の合計」ってことですか?
そう!そうやって2つに分ければ、足し算できるでしょ?
あのー、何をどうすればいいのか、さっぱり分からないです……
うん。ちょっと手応えがあったほうが最終問題っぽいでしょ。
そ、そうですね……
じゃあ、ヒントね。合計を求める関数には何を渡しているかしら?
えっと、呼び出し元はmain()関数にあって……ここだな。
  printf("Sum: %d\n", SumListValues(&node1));
引数としてnode1、つまり1つ目のノードのアドレスを渡してます。
すると、1つ目以降の合計が分かるのよね。そこに2つ目のノードのアドレスを渡したらどうなるかしら?
それって、こういうこと?
  printf("Sum: %d\n", SumListValues(&node2));
そっか。この場合は2つ目以降の合計になるんですね!
そういうこと。だんだん答えに近付いてきたんじゃない?
そうなんですか?もうちょっと考えてみます!
この関数から自分自身を呼び出すようにすれば……

第29問の答え

答え

pがループ変数になっているので、こう書くのが正解!
int SumListValues(const ListNode *pNode) {
  int sum = 0;
  for (const ListNode *p = pNode; p; p = p->pNext) {
    sum += p->value;
  }

  return sum;
}

解説

今回は、「ループ変数」を見極める練習問題でした。ループ変数とは、ループが継続する条件となる変数のことです。その値が一定の条件を満たす間だけ、値を更新しながら処理を繰り返します。

もっとも分かりやすいループ変数は、次のようなものでしょう。

  for (int i=0; i<10; i++) {
    ……
  }

ここでのループ変数はiです。条件(10より小さい)を満たす間だけ、値を更新(1ずつ増加)しながら処理を繰り返しています。

では、これから書き直そうとしているwhileループを確認してみましょう。

int SumListValues(const ListNode *pNode) {
  const ListNode *p = pNode;
  int sum = 0;

  while (p) {
    sum += p->value;
    p = p->pNext;
  }

  return sum;
}
どれがループ変数なのか、分かるかしら?
whileの括弧のところを見れば分かりそうだなー。
  while (p) {
    ……
  }
ズバリ、pじゃないですか?
そのとおり!ここまでは難しくないわね。
はい。大丈夫でした。
それじゃあ、これをどうやってforに書き換えるかってことなのだけど……
わくわく。
それにはwhileforの関係性を理解するのがいいわね。

forは、whileでループする際のよくあるパターンを書きやすく拡張したようなものです。多くの場合、whileループは次のような形をしています。

  ループ変数を初期化;
  while (ループ条件をチェック) {
    ……
    ループ変数を更新;
  }

これをforループで表現すると、次のようになります。

  for (ループ変数を初期化; ループ条件をチェック; ループ変数を更新) {
    ……
  }

このように、ループ変数に関する記述をまとめられるのがforの便利なところです。うまく使えば、プログラムを読みやすくできるでしょう。

ということは、このよくあるforループは……
  for (int i=0; i<10; i++) {
    ……
  }
このwhileループの変形だったのか!
  int i=0;
  while (i < 10) {
    ……
    i++;
  }
分かったみたいね!この2つが完全に同じ処理だとまではいわないけれど、やろうとしていることは一緒なのよ。
なるほど!って感じです。

この考え方を、問題のwhileループにあてはめてみましょう。ループ変数がpだということは、すでに分かっています。したがって、「ループ変数を初期化」「ループ条件をチェック」「ループ変数を更新」という3つの処理は、それぞれ次の部分だといえます。

int SumListValues(const ListNode *pNode) {
  const ListNode *p = pNode; /* ← ループ変数を初期化 */
  int sum = 0;

  while (p) { /* ← ループ条件をチェック */
    sum += p->value;
    p = p->pNext; /* ← ループ変数を更新 */
  }

  return sum;
}
ここまで見極められれば、あとはforに変形できるんじゃないかしら?
そうですね。できそうな気がします。
3つの処理をforの書き方に合わせて……っと。うーん、これでいいのかなぁ……
int SumListValues(const ListNode *pNode) {
  int sum = 0;
  for (const ListNode *p = pNode; p; p = p->pNext) {
    sum += p->value;
  }

  return sum;
}
そう!正解よ。
よかったぁ!あと、やっぱりプログラムが読みやすくなった気がしますね。
ループ変数に関係する部分が1行にまとまったし、行数も減ったものね。
あのー、1つ質問してもいいですか?
もちろん!
じつは、forの「ループ条件をチェック」のところが、これでいいのか自信がなかったんです。
pとだけ書いたところね。これは条件式なのよ。whileifなんかの括弧の中と同じ書き方ね。
じゃあ、ここにはどんな条件でも指定していいってことですか?
いいのよ。もしかして……今までi<10みたいな書き方しかしたことがなかったのかしら?
そうなんです!だって、forは範囲が決まってるときに使うループじゃないですか。
あー、そういうことね。それは逆なのよ。
逆……ですか?
forwhileと同じで、どんなループに使ってもいいの。ただ、あらかじめ範囲が決まっているループはforを使うと書きやすいっていうことね。
なるほどー。逆ってそういうことだったんですね。

さて、ここまではforループのおすすめの書き方を紹介しました。でも、ただwhileforに書き換えるというだけなら、別の方法もあります。

例えば、無限ループとbreakを組み合わせた、次のような書き方が考えられます。

  ループ変数を初期化;
  for (;;) { /* ← 無限ループ */
    if (ループ条件をチェック) {
      break;
    }
    ……
    ループ変数を更新;
  }

これは、「条件を満たす間だけ処理を繰り返す」のではなく、「条件を満たした時点で処理を終える」ということですね。そのため、「ループ条件をチェック」の部分に入る式は、whileの場合の否定形になります。

今回の問題にあてはめると、次のようになるでしょう。

int SumListValues(const ListNode *pNode) {
  const ListNode *p = pNode;
  int sum = 0;

  for (;;) {
    if (p == 0) {
      break;
    }

    sum += p->value;
    p = p->pNext;
  }

  return sum;
}
もともとのwhileより、行数が増えちゃいましたね。
そうね。それにパッと見たときに、どれがループ変数なのか判別しづらいんじゃないかしら。
そうですね。じゃあ、無限ループは使わないほうがいいってことですか?
そんなことはないのよ。breakの前後どちらにも処理が必要なときは、無限ループが便利ね。
つまり、こんな感じ。
  for (;;) {
    前半の処理:

    if (ループ条件をチェック) {
      break;
    }

    後半の処理;
  }
そっか。ループを抜ける条件を、処理の途中で判定したい場合もあるってことですね。
そういうこと。今回の問題では、その必要はなかったわね。

では、とにかく行数を減らすことを優先させたらどうなるでしょうか?例えば、次のように書くことも可能でしょう。

int SumListValues(const ListNode *pNode) {
  int sum = 0;
  for (const ListNode *p = pNode; p; sum += p->value, p = p->pNext);

  return sum;
}
ええっ!これでも同じ結果になるんですか?
そうなのよ。
中括弧がないですけど……
中括弧の中にあった処理は、カンマで区切って括弧の中に入れてあるでしょ。
あ、ほんとだ。それで行数が減ってるんですね。
そうね。でも、その分だけ読みやすくなったといえるかしら?
うーん。どういう処理をしているのかが、ちょっと分かりづらい気がします。
そうよね。頑張って1行にまとめようとしすぎて、かえって読みづらくなってしまった例といえるわね。
そうですねぇ。
ここがポイント!
ループを作るときは、どれがループ変数なのかを見極めよう!

修正後のプログラム

main.c
#include <stdio.h>
#include <stdlib.h>

typedef struct ListNode {
  int value;
  struct ListNode *pNext;
} ListNode;

int SumListValues(const ListNode *pNode) {
  int sum = 0;
  for (const ListNode *p = pNode; p; p = p->pNext) {
    sum += p->value;
  }

  return sum;
}

int main(void) {
  ListNode node4 = { 40, 0 };
  ListNode node3 = { 30, &node4 };
  ListNode node2 = { 20, &node3 };
  ListNode node1 = { 10, &node2 };

  printf("Sum: %d\n", SumListValues(&node1));

  return EXIT_SUCCESS;
}
実行結果
Sum: 100

第29問

数値の並びを連結リスト(linked list)構造で表現し、その合計を求めるプログラムを作りました。プログラム中にwhileループが1つありますが、これをforループで置き換えたいと考えています。

どう書き直せばいいか、分かりますか?
main.c
#include <stdio.h>
#include <stdlib.h>

typedef struct ListNode {
  int value;
  struct ListNode *pNext;
} ListNode;

int SumListValues(const ListNode *pNode) {
  const ListNode *p = pNode;
  int sum = 0;

  while (p) {
    sum += p->value;
    p = p->pNext;
  }

  return sum;
}

int main(void) {
  ListNode node4 = { 40, 0 };
  ListNode node3 = { 30, &node4 };
  ListNode node2 = { 20, &node3 };
  ListNode node1 = { 10, &node2 };

  printf("Sum: %d\n", SumListValues(&node1));

  return EXIT_SUCCESS;
}
実行結果
Sum: 100
ここまでいろいろな問題を出してきたけど……
はい。
目的が同じプログラムでも、書き方は1つじゃないっていうことは分かったかしら?
もちろん!正解はいくつもあるってことですよね。
そのとおり!今回は、それをそのまま問題にしてみたわ。
えっと。つまり、別の正解を考えるってことですか?
そういうこと!……まず、連結リストは分かる?
たしか、「数珠つなぎ」になってるデータ構造だったと思います。
そうね。一つ一つの「数珠」にあたる部分を「ノード(node)」というのだけど……
あ、はい。プログラム中にありますね。
ここにノードが4つあって、それぞれに数値が入ってる……
  ListNode node4 = { 40, 0 };
  ListNode node3 = { 30, &node4 };
  ListNode node2 = { 20, &node3 };
  ListNode node1 = { 10, &node2 };
10203040っていう数値の並びになっているわね。
それって、node1node2node3node4の順番でつながってるってことですか?
そうよ。ノードの構造体を見れば分かると思うけど。
構造体は……これか。
typedef struct ListNode {
  int value;
  struct ListNode *pNext;
} ListNode;
数値をvalueに格納しつつ、pNextがポインタだから、これで次のノードにつながるんですね!
うん。そうやってできる「数珠つなぎ」が、連結リスト構造ね。
なるほど〜!
さてさて。ここまで分かったら、問題にとりかかれるんじゃないかしら?
おっと、そうでした。
whileが使われているのは、この関数か……
int SumListValues(const ListNode *pNode) {
  const ListNode *p = pNode;
  int sum = 0;

  while (p) {
    sum += p->value;
    p = p->pNext;
  }

  return sum;
}
このループをよく見て、forで書く場合の正解を考えてみてね。
はーい。やってみます!
ループの条件になっている変数に注目してみましょう!

利用形態を意識したデザイン

関数を作るときは、使う人の視点で使用目的を考えておくといい」と言ってましたけど、いまひとつピンとこないので詳しく教えてもらえますか?
ああ……それはね、できる限り「使いやすいかどうか」を意識するということよ。関数は作る回数よりも、使う回数の方が多いからね。
でも、どうすれば使いやすくなるんでしょう?
関数のプロトタイプ宣言を見ただけで、使い方のイメージがわくようにデザインできるといいわね。
プロトタイプ宣言で使い方のイメージがわくように……ですか!?
そう。C言語では、関数を使う(呼び出す)側から、その関数の中身(実装)が見えないことも多いでしょ?
……そういうことかぁ。
じゃあ。1つ例をあげてみるわね。

今回は、アルファベットの小文字を大文字にするToUpper()という関数を作るケースについて考えてみましょう。

この場合、例えば次のようなデザインが考えられるのではないでしょうか。

/* codeで、文字コードを受け取る。
 * それがアルファベットの小文字だった場合は、大文字に変換して返却する。
 * そうでない場合は、codeをそのまま返却する。
 */
int ToUpper(int code);
/* pCodeで、変換対象の文字コードのアドレスを受け取る。
 * 文字コードがアルファベットの小文字だった場合は、大文字に変換してpCodeのアドレスに格納する。
 * そうでない場合は、何もしない。
 */
void ToUpper(int *pCode);
/* lowerCodeで、文字コードを受け取る。
 * それがアルファベットの小文字だった場合は、大文字に変換してpUpperCodeのアドレスに格納する。
 * そうでない場合は、lowerCodeをそのままpUpperCodeのアドレスに格納する。
 */
void ToUpper(int lowerCode, char *pUpperCode);

さて、どのデザインを採用すると使いやすくなるでしょうか?

うわぁ。ぜんぜんピンとこないです。
あらら。そうしたら、「どんな風に使いたいか」と考えてみるといいわね。
そっかぁ。やってみます!
だいたいの関数には、使い方のパターンがあるはずよ。
ええと……アルファベットの小文字を大文字にしたいケースって、たぶん文字列ごと変換したいことが多いんじゃないでしょうか。例えば、こんな感じで!
サンプルコード
/* 文字列を大文字に変換する */
void ConvertToUpper(char *pText) {
  size_t length = strlen(pText);

  for (int i=0; i<length; i++) {
    pText[i] = ToUpper(pText[i]);
  }
}

ConvertToUpper()は、文字列に含まれるアルファベットの小文字をすべて大文字に変換する関数です。その実装では、ループ中でToUpper()を使うことになるでしょう。変換前の文字コードを1つずつ渡して、変換後の文字コードを戻り値で返してもらえれば、自然な流れで処理できます。

バッチリね!この関数の場合は、おそらくこのような使い方が典型的といえるわね。
(やった〜!)……というわけで、ToUpper()のデザインは、こちら(1つ目)を採用することになりますね。
/* codeで、文字コードを受け取る。
 * それがアルファベットの小文字だった場合は、大文字に変換して返却する。
 * そうでない場合は、codeをそのまま返却する。
 */
int ToUpper(int code);
うんうん。これ以外(2つ目や3つ目)のプロトタイプだと、使いづらそうよね。
ここがポイント!
関数を作るときは、利用形態(どんな風に使いたいか)を意識してデザインしましょう。

標準関数は使いやすくできている

ちなみに、ToUpper()と同じ目的で使われるものとして、toupper()という標準関数があります。こちらのプロトタイプも、こうなっています。

int toupper(int c);

同じですね。使うときのことを考えてデザインしてくれていると分かります。

へー。標準関数って、ちゃんと使いやすく考えられているんですね!

C++のクラスはどうする?

C++では、関数よりもクラス(class)の単位で機能を提供したいケースが多いでしょう。その場合、使いやすいかどうかはクラス全体のデザインに左右されます。

そのクラスのインスタンスを生成してから破棄するまでの典型的な使い方を想定して、メンバー関数ごとのプロトタイプを決めるのがいいでしょう。

意図した使い方をしてもらうには?

ところで、「きっとこんな風に使いたいだろう」と想定して関数を作ったとしても、みんなが同じように使ってくれるとは限らないですよね?
そうね。典型的な使い方について考えるときはサンプルコードを書くことになると思うから、せっかくなら、それをヘッダーファイルのコメントやドキュメントなんかに掲載しておくといいんじゃないかしら。
なるほど〜。目に付きやすい場所にあれば、実際に使う人がサンプルコードをもとに実装できますね。
予想外の使われ方によるトラブルも避けやすくなるわね。