記事一覧

複数のプログラミング言語から学ぼう

ここまでC言語を中心に、プログラミングについて考えてきたわね。
はい。
そろそろ、ほかのプログラミング言語を勉強してみるのもいいかもよ?
そうなんですか?C言語だけでも、まだまだ知らないことがたくさんありそうですけど……
ふふふ。だからこそ、って考えてみたらどうかしら。
だからこそ……ですか?
うん。例えばね、Swiftという言語があるわよね。これが誕生したのは2014年なのだけど……
えっと、スマホアプリとかの開発に使われてる言語ですよね。
そうそう。で、C言語が誕生したのが1972年頃だから、なんと40年以上も間があるの。
ええっ!40年以上?
びっくりよね……ということは、Swiftにはその分の知恵が詰まっていてもおかしくないわね。
たしかにっ!
もちろんC言語だって進化しているのだけど、何か新しい言語を知るのもプラスになると思うのよ。
なるほどー。そういうことなら勉強してみようかなぁ。
じゃあ、そのための練習を少しだけやってみましょうか。
わーい。お願いします!

C言語とSwiftのif文を比べてみよう

if文は、ほとんどのプログラミング言語に取り入れられている基本の文法です。C言語とSwiftのif文を比べてみると、何か分かることがあるでしょうか?

例えば、こんなC言語のif文があったとすると……
  int x = 123;

  if (x > 0)
    puts("Positive value");
  else
    puts("Negative value or Zero");
同じことをSwiftでやるには、こう!
  let x = 123

  if x > 0 {
    print("Positive value")
  } else {
    print("Negative value or Zero")
  }

いろいろと細かい違いはありますが、ここではif文の書き方にだけ注目することにしましょう。すると、次のことが分かります。

  • C言語では括弧(())が必須だけれど、Swiftでは不要
  • Swiftでは中括弧({})が必須だけれど、C言語では不要
へぇー。if文みたいな基本の文法でも、けっこう違うんですね!
うん。でも、ただ文法が違うだけだと思ってしまうのは、ちょっともったいないわね。
そうなんですか?
意外とこういうところに、先人の知恵が詰まっているものなのよ。
何だろう……僕、40年も生きてないし……
あはは。じゃあ、どうしてSwiftでは中括弧が必須になっているのか、理由を想像してみましょうか。

いつでもブロックを使おう

C言語のif文に、中括弧は必須ではありません。でも、中括弧を書かないと、プログラムが意図したとおりに動かない場合があります。

例えば、このif文は……
  int x = 123;

  if (x > 0)
    puts("Positive value"); /* こちらが実行されるように見えますが…… */
  else
    puts("Negative value");
    puts("or Zero");
実行結果(意図と違う)
Positive value
or Zero
そんなバカなっ!"or Zero"の行も実行されちゃいましたよ!
中括弧を書かなかったせいね。
上のプログラムは……実はこう書いたのと同じ!
  int x = 123;

  if (x > 0) {
    puts("Positive value");
  } else {
    puts("Negative value");
  }
  puts("or Zero");
あー、そうなっちゃうんですか……思ってたのと違いますね。
そうよね。元のプログラムのインデント(字下げ)を見れば、意図と違うのは明らかね。
もし最初から中括弧を書いていれば、プログラムはこうなっていたはず。
  int x = 123;

  if (x > 0) {
    puts("Positive value");
  } else {
    puts("Negative value");
    puts("or Zero");
  }
実行結果(意図したとおり)
Positive value

このように、C言語のif文では、いつも中括弧を書くようにするのがおすすめです。どこからどこまでがifelseに含まれる行なのかが明確になるため、思わぬ間違いを減らせます。

このとき、中括弧はif分の文法で定められたものではなく、ブロックだということを覚えておきましょう。本来は1つの文しか書けないところに、ブロックでまとめることによって複数の文を置いているのです。

ふむふむ。if文の中括弧はブロックだったんですね。えっと……じゃあ、for文やwhile文はどうなんですか?
うん。if文と同じことがいえるわね。
例えば、C言語でfor文をこう書くと……
  for (int i=0; i<10; i++)
    printf("%d\n", i);
    printf("--------\n");
これと同じ意味になってしまうから……
  for (int i=0; i<10; i++) {
    printf("%d\n", i);
  }
  printf("--------\n");
やっぱり中括弧が必要!
  for (int i=0; i<10; i++) {
    printf("%d\n", i);
    printf("--------\n");
  }
なるほどー。
ここがポイント!
ifforwhileなどでは、中括弧を省かないようにしましょう。意図と違う動作になるのを防げます。
さてさて。ここでSwiftの文法を思い出してみましょうか。もしif文に中括弧を書かなかったら……
えっと……Swiftでは中括弧が必須だから、文法エラーになっちゃうってことですか?
そう。C言語でやってしまいがちな間違いが、Swiftでは起こらないようになっているわけね。
あ!そっか、そういうことですか!
ありがたいことよね。

else ifって何だろう?

ところで、「C言語にはelse ifがない」って言ったらどう思う?
やだなぁ、あるじゃないですかー。
ふふふ、そう思うわよね。例えば……
C言語のelse ifはこんな感じで書くと思うけど……
  if (x > 0) {
    puts("Positive value");
  } else if (x < 0) {
    puts("Negative value");
  } else {
    puts("Zero");
  }
そうですよ!今まで何度も書いたことありますよ?
でも、C言語にelse ifっていう文法はないのよ。
はい?
これはね、文法じゃなくて、中括弧を省略できることを利用したテクニックなの。
さっきのelse ifは、実はこう書くのと同じ!
  if (x > 0) {
    puts("Positive value");
  } else { /* elseのあとの中括弧を省略したらelse ifになる */
    if (x < 0) {
      puts("Negative value");
    } else {
      puts("Zero");
    }
  }
えっ!elseの中にifがネストしてるのと同じってことですか?
そういうことね。よーく見たら分かるんじゃないかしら。
えぇーっと……たしかに同じかも……
ということは、else forなんていう書き方もできるわね。
例えば、これは……
  if (x > 0) {
    puts("Positive value");
  } else for (int i=x; i<0; i++) {
    printf("%d\n", i);
  }
こう書くのと同じ!
  if (x > 0) {
    puts("Positive value");
  } else {
    for (int i=x; i<0; i++) {
      printf("%d\n", i);
    }
  }
else forなんて、はじめて見ましたよー!
この先も見ないと思うわ。とても不自然な書き方だもの。
なーんだ。そういうことですか……
でも、else ifは自然だし、便利だからよく使うでしょう?
そうですね。完全に、そういう文法だと思ってましたもん。
そのへんもね、Swiftではしっかり整備されてるのよ。
Swiftのelse ifは、こんな感じ。
  if x > 0 {
    print("Positive value")
  } else if x < 0 {
    print("Negative value")
  } else {
    print("Zero")
  }
ふむふむ。C言語とそれほど変わらないですね……あれ?ちょっと待ってくださいよ……
ふふふ。気付いたかしら?
えっと、Swiftのif文は中括弧が必須でしたよね?
そうね。
だったら、どうしてelse ifが使えるんですか?elseifの間に中括弧がないとダメなのでは?
うん。これはね、Swiftにはelse ifっていう文法がちゃんとあるからなのよ。
わぁ、なるほど!へぇー。
ちなみに、else forはないわよ。
あ、それはいらないでーす!
ここがポイント!
プログラミング言語は、互いに影響を与えながら進化しています。新しい言語について知れば、C言語をもっと深く理解できるようになるかも知れませんね。

さて。

ここまで読んでいただき、ありがとうございました!C言語を中心にプログラミングにまつわる読み物をお届けしてきましたが、いかがだったでしょうか?少しでも「役に立った!」と思ってもらえたら、とても嬉しいです。

本シリーズは、基本的な内容からはじまって、だんだんと深掘りしていくような構成になっています。もし「途中から読んだよ」という場合は、ぜひ「記事一覧」に戻って、最初のほうもチェックしてみてください!

僕ももう1回、最初の記事から見直してみたいです!
そうね。同じ内容でも2回目には、何か新しい発見ができるかもしれないわね!

ポインタの初期化とクリア

モジュールごとに自分自身のヘッダーファイルをインクルードするっていうのを教わりましたけど……
うん。
これは定番の手法だっていう話でしたよね?
そうね。簡単にできて効果があるから、基本ルールといってもいいと思うわ。
基本ルールですか!えっと……そういうのって、ほかにもあったりします?
あー、そういうことね。いろいろあるわよー。
やっぱり!
それじゃあ、簡単で効果バツグンのやつを、いくつか紹介しちゃおうかしら。
わーい。ぜひぜひ!

まずは、次の短いプログラムを見てみましょう。

  int result, a = 2, b = 5;
  result = a + b;

abの値を足して、resultに格納しています。その途中で、一時的にではありますが、resultに無意味な値が格納されているのが分かるでしょうか?

ひとまず、1行で3つも変数を宣言しているところがよくないわね……
  int result, a = 2, b = 5;
変数宣言は、1つずつ行を分けるのがおすすめよ!
  int result;
  int a = 2;
  int b = 5;

このように行を分けて書くと、変数resultが最初に登場したとき、初期化されていないことがよく分かります。値が確定するのは、足し算の値を代入するときです。それまでの間、resultの値は不定(どのような値が格納されているのか分からない状態)になっているということです。

  int result; /* 初期化していないので、値が不定 */
  int a = 2;
  int b = 5;
  result = a + b; /* ここで、はじめて値が確定 */
ほんの短い間なのだけど、resultの値がどうなっているか分からない区間ができてしまっているわね。
それって、ダメなんですか?
ダメってことはないのよ。どこで値が不定になるのかを完璧に制御できるなら、とくに危険なことはないわ。
完璧に制御……それはちょっと無理かなぁ……
そうね。いつも完璧でいるのは難しいから、変数は初期化するっていうのを原則にするのがおすすめ。
反対に、初期化すると何かいいことがあったりするんですか?
セキュリティ上のリスクを減らせるはずよ。間違って不定な値をプログラムに読み込んでしまうことが少なくなるからね。
なるほどー。セキュリティのためですか。

では、変数resultが初期化されるようにプログラムを修正しましょう。といっても、次のように初期値(ここでは0)を書いておくだけです。

  int result = 0; /* 初期化 */
  int a = 2;
  int b = 5;
  result = a + b;

これで、変数に不定な値が格納されることはなくなりました。

ただ、result0で初期化したあと、その値を一度も使うことなくa + bで上書きするというのは、少し無駄がありますね。気になるようなら、次のように手直しすればいいでしょう。

  int a = 2;
  int b = 5;
  int result = a + b;
ここがポイント!
変数を初期化して、不定な値が格納されないようにしましょう。この基本ルールで、セキュリティ上のリスクを減らせます。

ポインタを初期化する

さてさて。C言語といえば、ポインタよね。
はい?
次は、ポインタの値が不定になってしまう場合について考えるわよ。
あー、そういうことですか!

次の短いプログラムを見てみましょう。ポインタ経由で、整数の値を123に書き換えています。

  int *p, n = 0;
  p = &n;
  *p = 123;
ひとまず、変数宣言が1行にまとまってしまっているから、行を分けて見やすくしましょうか。
ええと、この行だな……あれ?
  int *p, n = 0;
あのー、nはポインタですか?
この書き方だと、ちょっと混乱するわよね。pはポインタだけど、nは普通の整数型よ。
……と、いうことは、こう?
  int *p;
  int n = 0;
うん、正解!

これで、ポインタpが初期化されていないことが分かりました。pの値、つまりアドレスが不定なので、メモリー内のどこを指しているのかが分からない状態です。

  int *p; /* 初期化していないので、アドレスが不定 */
  int n = 0;
  p = &n; /* ここでアドレスが代入されて、nを指すことが確定 */
  *p = 123;

このような値が不定なポインタは、「ワイルドポインタ(wild pointer)」と呼ばれています。この例のpは、宣言されてからnのアドレスが代入されるまでの間が、ワイルドポインタになっています。

ワイルドポインタは、うっかりそのまま使ってしまうと危険です。プログラムのクラッシュや、セキュリティ上の問題を引き起こす恐れがあるためです。ポインタは必ず初期化して、不定なアドレスを指さないようにしましょう。

  int *p = 0; /* 初期化 */
  int n = 0;
  p = &n;
  *p = 123;

ここでは、0で初期化しました(NULLと書いても同じです)。これによりpに不定なアドレスが格納されるのを避けつつ、「メモリー内のどこも指していない」という意味をもたせています。

なお、p0で初期化したあと、その値を一度も使うことなくnのアドレスで上書きするのは少し無駄ですね。ここは、次のように手直ししてもいいでしょう。

  int n = 0;
  int *p = &n;
  *p = 123;
へぇー、ワイルドポインタっていうんですね。「野生のポインタ」ってことですか?
たぶんね。そう呼ばれてるってだけだから、正確な意味があるわけじゃないのだけど……「人が手を加えていない」から、どんな値(アドレス)が入っているか分からないっていうことね。
なるほどー。
ここがポイント!
ポインタは、宣言と同時に初期化しましょう。この基本ルールで、ワイルドポインタを防げます。

使い終わったポインタをクリアする

ワイルドポインタといえば、ぜひセットで覚えておきたい「ダングリングポインタ(dangling pointer)」というのもあるわね。
ダングリング……「宙ぶらりんのポインタ」ってことですか?
うん。さっきまで何かを指していたのに、どこを指しているのか分からなくなってしまったポインタのことよ。
んんー、ちょっとイメージわかないです……
じゃあ、例を見てみましょうか。

次のプログラムでは、メモリーを一時的に確保して、そこに123という値を格納しようとしています。でも、ポインタが「宙ぶらりん」になってしまっています。

  int *p = malloc(sizeof(int));
  free(p);
  *p = 123;

最初の行では、malloc()でメモリーを確保して、そのアドレスをポインタpに格納しています。このポインタを経由してメモリーに123を格納したいのですが、その前にfree()でメモリーを解放してしまっていますね。

解放されたメモリーは、もうプログラムの管理下にはありません。ところが、pにはまだ解放する前のアドレスが残っています。つまり、引き続き安全に使うことのできない、無効なメモリーを指してしまっている状態です。

もう有効なメモリーではないのに、そのアドレスだけが残ってしまったわけね。
なるほど。それが「宙ぶらりん」のイメージってことですか。
そういうこと!

ダングリングポインタは、メモリーが解放されたときに発生します。そして、メモリーが解放されると同時に使い終わったはずのポインタが、そのままの状態で残ってしまうことが問題です。

この問題を回避するには、使い終わったポインタを、もう使えない状態にすればいいですね。次のように、0(NULL)を代入してクリアしてしまいましょう。

  int *p = malloc(sizeof(int));
  free(p);
  p = 0; /* 使い終わったのでクリア */
  *p = 123;
えー、この修正はおかしくないですか?
言いたいことは分かるわ。これだとクラッシュするものね。
そうですよー。

上の例では、pをクリアしたあとに使おうとしているため、実行するとクラッシュするはずです。しかし、これはポインタをクリアしていること自体が間違っているわけではありません。もともと潜伏していたダングリングポインタのバグが、ポインタのクリアによって表面化したのです。

では、正しく動作するように修正しておきましょう。今回は、次の2点を修正すればいいでしょう。

  • malloc()を呼び出したあと、メモリーを確保できたかどうかチェックしていない
  • free()の呼び出しと、123を格納する処理の順序が逆
こんな感じに直せばいいわね!
  int *p = malloc(sizeof(int));
  if (p) {
    *p = 123;

    free(p);
    p = 0;
  }
または、これでもOK!
  int *p = malloc(sizeof(int));
  if (p) {
    *p = 123;
  }

  free(p);
  p = 0;
え、2つ目の直し方でもOKなんですか?なんか、free()に0を渡してしまう気が……
スルドイ!malloc()がメモリーの確保に失敗したときのことね。
えっと、そうですね……それで、pが0になった場合です。
実はね、free()には「引数が0の場合は何もしない」っていう仕様があるの。だから大丈夫なのよ。
そうなんですね!知りませんでしたー。
まぁ1つ目の直し方のほうが無駄がないし、プログラムとしても分かりやすいんじゃないかしら。
ここがポイント!
使い終わったポインタは、0(NULL)を代入してクリアしましょう。この基本ルールで、ダングリングポインタを防げます。

プロトタイプ宣言の食い違いをなくすには

ここまで、プログラムのバグを減らす方法について、いろいろと教えてもらいましたよね。
そうね。役に立ってるかしら?
もちろん!どれも意味を理解するのは簡単じゃなかったですけど……
うんうん。
実際にやることは、そんなに難しくないのが多いですよね。
ふふふ。そこが重要なのよ!
難しくないことが重要なんですか?
だって、つらい方法ばかりじゃ長続きしないでしょう?
そうですねぇ。
バグには「仕組み」で対処しようと考えるのが、正しい努力のしかたなのよ。
そっか。「仕組み」が大事なんでしたね!
それじゃあ、次はC言語でリンクのミスを防ぐ方法について考えてみましょうか。
リンクのミスですか?
そう。プログラムが複数のモジュールでできている場合に起こるかもしれない問題ね。

分割コンパイルとリンク

ある程度の大きさがあるプログラムは、複数のモジュールに分けて作るのが通常です。その場合は分割コンパイル(各モジュールのソースファイルを別々にコンパイル)したあとで、最終的な実行ファイルにリンク(コンパイル結果を1つに結合)します。

例えば、「メイン」と「サブ」の2つのモジュールがあったとしましょう。「サブ」のソースファイル「sub.c」は、次のようになっています。

sub.c
void Example(int n) { /* 関数の定義 */
  ……
}

Example()という名前の関数が「定義」されていることが分かります。これを「メイン」から呼び出すことを考えてみましょう。こちらのソースファイルは「main.c」です。

main.c
#include <stdlib.h>

int main(void) {
  Example(12345); /* エラー:いきなり「サブ」モジュールにある関数は呼び出せない! */

  return EXIT_SUCCESS;
}

このように「サブ」にある関数をいきなり使おうとしても、コンパイルエラーになってしまいます。これは、関数がまだ「宣言」されていないためです。

関数の「宣言」というのは、「この関数が、どこかにあるよ」っていうのをコンパイラに知らせることね。
関数の「定義」とは違うんですか?
「定義」は関数の実体。「この関数は、こういう処理をするよ」っていう、関数の中身まで書いたもののことね。だから関数ごとに1つしかないの。
なるほど。関数の処理を書くっていうのは、「定義」をしているわけですね。
そういうことね。で、「宣言」のほうは、これから使う関数の形を表しているだけなのね。だから何回書いてもいいのだけど……
ふむふむ。
やっぱり1つしか書かないことが多いわね。そのためにヘッダーファイルを使うのよ。

宣言は、モジュールごとのヘッダーファイルに書くのが一般的です。「サブ」のヘッダーファイル「sub.h」は、次のようになっています。

sub.h
#ifndef sub_h
#define sub_h

void Example(int n); /* 関数の宣言 */

#endif /* sub_h */

Example()がどのような形の関数なのかが書かれていますね。これが宣言、もう少し詳しくいえば「プロトタイプ宣言」です。プロトタイプ宣言を見れば、引数の個数や型、戻り値の有無などが分かります。

では、「メイン」を修正してみましょう。「main.c」で次のようにヘッダーファイルをインクルードすれば、コンパイルエラーは発生しなくなります。

main.c
#include <stdlib.h>
#include "sub.h" /* 「サブ」モジュールから宣言を取り込む */

int main(void) {
  Example(12345); /* 宣言があれば、「サブ」モジュールの関数でも呼び出せる */

  return EXIT_SUCCESS;
}
ヘッダーファイルがモジュール同士の間を取り持ってくれる感じですかね。
そうね。ここではインクルードした「宣言」が、「サブ」の関数を使うためのインターフェースになっているということよ。

これで分割コンパイルができるようになりました。ソースファイル「main.c」と「sub.c」をそれぞれコンパイルして、その結果をリンクすれば実行可能なファイルになるはずです。

プロトタイプ宣言の食い違い

ここで、今の状況を詳しく確認してみましょう。まずは「メイン」について。

  • 「main.c」で関数Example()を使うために、「sub.h」をインクルードしています
  • 「sub.h」には、関数Example()のプロトタイプ宣言があります

次に「サブ」について。

  • 「sub.c」に関数Example()の実体が定義されています
  • 「sub.c」では「sub.h」を使用していません
さてさて。今の状況が、けっこう危なっかしいっていうのが分かるかしら?
え?どこがです?
それはね……「main.c」では、Example()がどういう形の関数なのか「sub.h」を見て判断しているでしょう?
はい。「sub.h」のプロトタイプ宣言を見てるってことですね。
じゃあ「sub.c」のほうはどうなっているかというと、「sub.h」を見ていないのよ。
「sub.c」にはExample()の定義(=実体)があるわけだから、宣言を見てもしょうがないですもんね。
うん、そういうことなんだけどね。もし宣言と定義が食い違っていたら、どうなると思う?

関数Example()は、「sub.h」で次のように宣言されています。

void Example(int n); /* 関数の宣言(引数がint型) */

もし、「sub.c」での定義が次のようになっていたら何が起こるでしょうか?

void Example(char n) { /* 関数の定義(引数がchar型) */
  ……
}

引数の型がintcharで食い違っていますね。ところが、これでも分割コンパイルは可能です。「メイン」をコンパイルするときは「sub.h」をインクルードするだけなので「sub.c」を使わず、「サブ」をコンパイルするときは「sub.c」があれば十分なので「sub.h」を使いません。「sub.h」の宣言と「sub.c」の定義が、照合される機会がないのです。

さらに、このような状況でもC言語ではリンクまでできてしまいます。宣言と定義が食い違っているにもかかわらず、実行可能なファイルを作れるのです。

それって、実行したらどうなるんですか?
そこは環境にもよるし、なんとも言えないの。ただ、正常に動かないってことだけは間違いないわね。
うーん……分割コンパイルに、そんな問題があったとは。
ちなみに、C++の場合はリンクしようとするとエラーになるから、問題があれば気付けるわ。
じゃあ、もう全部C++にしちゃいますかねぇ。
あはは、大丈夫よ。簡単な「仕組み」で対処できる方法が、ちゃんとあるからね。
そうなんですね!よかったぁ。

プロトタイプ宣言を定義と突き合わせる

宣言と定義の食い違いは、プログラムを動作させてみて「正常に動いてないぞ」って分かるまで気付けないところが問題ね。
えっと、C言語の場合……ですよね。
そうそう。C++ではリンクエラーが出るからね。
ふむふむ。つまり、C言語でも確実にミスに気付けないのかと……
そういうこと。でね、宣言と定義が食い違っていたら、コンパイルの時点で分かる方法があるの。

リンクのミスは、コンパイルの時点で宣言と定義の食い違いを見過ごしてしまっているために起こります。コンパイルするときに両者を突き合わせれば、もっと確実に問題に気付くことができるでしょう。

そのためには、各モジュールから自分自身のヘッダーファイルをインクルードするのが簡単です。具体的には、「sub.c」から「sub.h」をインクルードするということです。すると、コンパイル時に宣言と定義が照合されて、正確に一致していない限りコンパイルエラーが発生するようになります。

sub.h
#include "sub.h" /* 宣言を取り込んで突き合わせる */

void Example(int n) { /* 関数の定義 */
  ……
}
え?それだけですか?
うん。それだけ。
わぁ、簡単っ!
でしょう?これで、宣言と定義に食い違いがあれば、コンパイルエラーで分かるわね。
そうですねー。
あとは……例えば定義のほうで引数の型を変更したときに、宣言の側を直し忘れてしまったとしても……
そのあとコンパイルするはずだから、ミスしたことがすぐに分かりそうですね。
そういうこと!いつもの作業のなかで、ほぼ自動的にミスを検出できるところがポイントよ。
なるほど……そういえば、この「自分自身のヘッダーファイルをインクルードする」っていうやり方は、今までも見たことがあるかも……
けっこう定番になってる手法だから、そうかもね。たしか、ファイルスコープの話をしたときのプログラムでも、同じことをしていたと思うわ。
ほんとですか?(思い出してみよう……)
ここがポイント!
分割コンパイルをするときは、各モジュールから自分自身のヘッダーファイルをインクルードしておきましょう。宣言と定義に食い違いがあった場合に、確実に検出できるようになります。

エラーチェックを忘れないための工夫

ユキ先輩、実行時エラーのチェックは手を抜かないっていう話でしたけど……
うん。それが大切よ。
はい。でも、いくら手を抜かないといっても、完璧にはできないですよね?
そうね。うっかりエラーチェックを忘れてしまうこともあるわよね。
ですよね。そういうミスって、なくす方法ないんですか?
ミスをなくすのは無理ね。でも、減らす方法ならあるわよ?

「実行時エラー(ランタイムエラー)」とは、プログラムを実行しているときの状態によって起こるかもしれないエラーのことでした。例えばメモリー不足やファイル入出力の失敗、通信エラーなどが考えられます。

ここでは、「設定をファイルから読み込む」という動作について考えてみましょう。設定項目は、次のような構造体にまとめられているとします。

/* 設定項目をまとめた構造体 */
typedef struct {
  ……
} Configuration;

これを読み込む関数は、例えば次のような形になるでしょう。読み込みに成功したかどうかを、bool型の戻り値で返します。正常に読み込めた場合は、引数のポインタ経由で設定値を受け取れます。

bool LoadConfiguration(Configuration *pConfiguration) {
  ……
}
覚えてるかしら?ここでは前に説明したテクニックを使っているわよ。
えっ?
ほら、戻り値をbool型にして……
あ、もしかして……エラーチェックが必要なことを思い出しやすくするっていう、あれですか?
そうそう。

この関数の呼び出し側は、次のようになるでしょう。エラーがないかどうかをif文でチェックして、処理を振り分けています。

  Configuration conf;
  if (LoadConfiguration(&conf)) {
    /* 通常の処理 */
    ……
  } else {
    /* エラー処理:設定を読み込めなかった! */
    ……
  }

しかし、エラーチェックが必要なことを忘れて、うっかり次のように書いてしまうかもしれません。

  Configuration conf;
  LoadConfiguration(&conf);

  /* 通常の処理(エラーチェックを忘れている!) */
  ;

こういったミスは、いくら注意していても完全になくせるわけではありません。そこで、どうすればミスに気付けるかを考えることが大切になってきます。例えば、コードレビューを実施するのは、ミスの早期発見に効果的な方法の一つです。

いずれにしても、ミスを見つけるにはレビューやテストが基本ということよ。
ミスのないプログラムに、近道はないってことですね!
ん?近道ならあるけど?
ええっ!いいこと言ったつもりだったんですけどっ!

失敗しない関数をつくる

それじゃあ、さっきの「設定をファイルから読み込む関数」について考えてみましょうか。
はい。
この関数を呼び出しているところがね、もし1カ所しかなかったとしたらどう思う?
そうですね……エラーチェックを忘れる可能性は低いと思います。
じゃあ反対に、呼び出している場所がたくさんあったら?
その場合は……チェックを忘れることもありそうですね。
そうよね。だとしたら、よく使う関数はエラーを返さないほうがいいわね。
いやいや、どうしたってエラーは起こるじゃないですか。
エラーを「起こさない」じゃなくて、「返さない」ってことよ。

上記の「設定をファイルから読み込む関数」は、どのような場合に実行時エラーを起こすでしょうか。例えば、以下のようなケースが考えられます。

  • ファイルが存在しなかった
  • ファイルは存在するが、読み込めなかった
  • ファイルを読み込めたが、そのデータが破損していた

関数内でこうした問題が発生したとき、ほんとうにエラーを返す必要があるのかどうか考えてみましょう。もし、関数が自力でエラーに対処できるのであれば、それで十分かもしれません。よく使う関数ほど、エラーを返さない仕様を検討してみる価値は高いといえるでしょう。

エラーを返さない関数は、例えば次のような形になります。読み込んだ設定値を戻り値として返すだけの、ごく単純なつくりです。

Configuration LoadConfiguration(void) {
  ……
}

エラーチェックが不要なため、呼び出し側もシンプルになります。

  Configuration conf = LoadConfiguration();

  /* 通常の処理 */
  ;

ただし、ほんとうにエラーが発生しなくなったわけではなく、発生しても関数内で対処するよう工夫されているのです。つまり、この関数はあたかもエラーなどないかのように振る舞うため、呼び出し側でエラーチェックについて気にする必要がなくなったということです。

関数内でエラーに対処する方法としては、例えば以下が考えられます。

  • 設定ファイルを復旧して読み込み直す
  • ファイルからの読み込みをあきらめて、デフォルトの設定値を返す
  • エラーログを出力して、プログラムを緊急終了する(呼び出し元には戻らない)

もちろん、実際にどのような工夫でエラーに対処するのがベストなのかは、何を作っているのかによって変わってきます。毎日バックアップをとっているなら設定ファイルを復旧するのが簡単かもしれないし、緊急終了はどうしても避けたいという場合もあるでしょう。

エラーを「起こさない」と「返さない」の違いは分かったかしら?
はい。エラーがあっても呼び出し元に返さないっていうのは、けっこう意外な考え方でした。
でしょ。エラーにどう対処すべきかはケースバイケースだから、よく検討してからやってみてね。
はーい!
ここがポイント!
関数が自力で実行時エラーに対処できるように工夫すれば、呼び出し側でのエラーチェックは不要になります。よく使う関数ほど、この方法を検討してみる価値があるでしょう。

呼び出し側のバグとみなす

もう1つ、エラーチェックを不要にするテクニックを紹介しちゃおうかしら。
わーい。お願いします!

次のように、表情の種類が定義されているとします。「幸せな顔」と「悲しい顔」です。

typedef enum {
  Happy,
  Sad
} FaceType;

この値を受け取って、顔文字を表示する関数を考えてみましょう。

bool PrintFace(FaceType type) {
  bool bSuccess = true;

  switch (type) {

    case Happy:
      puts("(^o^)");
      break;

    case Sad:
      puts("(T_T)");
      break;

    default:
      bSuccess = false;
  }

  return bSuccess;
}
ふむふむ。引数がHappySadかによって、対応する顔文字を表示するんですね。
引数がそのどちらでもないときは?
えっと……その場合はdefaultのところが実行されるから、falseが返されます!
そう。つまり関数がエラーを返すということね。

この関数は、正常に処理できたかどうかを戻り値として返します。次のような方針で作られているためです。

  • 引数には、ひとまずどのような値でも受け入れる
  • 処理できない値が引数に与えられたときは、エラーを返す

つまり、引数の値によっては実行時エラーを返すため、呼び出し側でのエラーチェックが必要な関数になっています。でも、引数は列挙型(enum)ですね。そもそも、どのような値でも受け入れる必要があるでしょうか?

実際のところ、列挙型に定義されていない値をこの関数に渡したいケースは考えられません。もしもそのような値を渡しているとしたら、どこかで論理(ロジック)が破綻しているのだといえます。

そこで、次のような方針で関数のつくりを考え直してみることにしましょう。

  • 引数には、列挙型で定義された値しか受け付けない

これは、関数の定義域を明確にするということです。これにより、エラーを返さない関数として書き換えることができます。具体的には、次のようになるでしょう。

void PrintFace(FaceType type) {
  switch (type) {

    case Happy:
      puts("(^o^)");
      break;

    case Sad:
      puts("(T_T)");
      break;

    default:
      assert(false); /* ここに到達したらバグ! */
  }
}
戻り値を返さない関数になったんですね。
うん。あと、引数がHappyでもSadでもない呼び出し方が、禁止になったのが分かるかしら?
えっと……もしも、それ以外の値を渡したとしたら、バグだってことですかね。
呼び出し側のバグということね。だとしたら、それを検出する方法があるわよね。
そっか。assertですね!
そのとおり!defaultのところに書かれているでしょう?
ほんとだ、なるほどー。あ、でもassert(false)って、どういう意味ですか?
これはね、条件式がfalseになっているから、絶対に成立しないassertね。つまり、この行が実行されたらバグだっていう意味なのよ。

ここでの処理がdefaultに到達するのは、引数がHappyでもSadでもないとき、つまり呼び出し側にバグがあった場合です。このような場所には、assert(false)と書いておきましょう。そうすれば、本来は到達してはならない行が実行されてしまったことを検出できます。

これは、switch文のdefaultでよく使うテクニックなのよ。覚えておくといいわねー。
そうなんですね!
ていうか、たしか前にもちょっとだけ出てきていたわね。if文の話をしたときだったかしら?
なんとっ!(あとで復習しとこう……)
ここがポイント!
関数が引数で受け取れる値を制限すると、エラーを返す必要がなくなる場合があります。このとき、範囲外の値を渡すのは呼び出し側のバグとみなせるので、assertで検出しましょう。

実行時エラーをチェックするには

ここまで、ちょっと難しい話が続いたかしらね。
そうですねー。デバッグコードの話とか、けっこう難しかったです。
じゃあ、今回はもう少し単純なプログラムからはじめましょうか。
はーい!

次は、C言語のプログラムの一部です。整数1つ分のメモリーを確保して数値を代入し、表示しています。

  int *p = malloc(sizeof(int));
  *p = 123;

  printf("%d\n", *p);
これを実行したら、どんな表示になるかしら?
これは……123って表示されると思います。
そうね。でも、このプログラムには少し問題があるの。メモリー不足になるとmalloc()は……
あ、それ分かるかも!
たしかmalloc()を使うときは、こんな感じでif文が必要なはず……
  int *p = malloc(sizeof(int));
  if (p) {
    *p = 123;

    printf("%d\n", *p);
  }
正解よ。えらい!
えへん!malloc()if文のセットは、よく出てきますよね。いつもこんな書き方になってるなーって思って。
うんうん。それじゃあ、どうしてif文が必要なのかは説明できるかしら?
えっと……malloc()でちゃんとメモリーを確保できたかチェックするため、ですかね。
そのとおり。メモリーが足りないときは0(NULL)を返すっていうのが、malloc()の仕様ね。
だから次の処理をする前に、0(NULL)じゃないことを確認しないといけないんですね。

メモリーは、限られたリソースです。ほかのプログラムがたくさん動いているときなどは、必要なだけ確保できないことも考えられます。

このような、プログラムを実行しているときの状態によって起こるかもしれないエラーを「実行時エラー(ランタイムエラー)」といいます。実行時エラーは、必ずチェックしなければなりません。malloc()も、チェックが必要な関数の一つだということです。

とはいえ、上記のプログラムが実際にメモリーの確保に失敗するケースはほとんどないでしょう。現代的なOSには、「仮想メモリー」という仕組みが搭載されているためです。物理的なメモリーにハードディスクなどを組み合わせてデータの記憶場所をやりくりすることで、自由に使える仮想的なメモリー空間を作り出しているのです。

昔のパソコンではね、malloc()が実行時エラーを起こすことはけっこうあったのよ。
へぇー。そうだったんですか。
だからね、if文を書き忘れるっていうのはシビアな問題だったの。
メモリーが少ないと動かないプログラムになっちゃうってことですか?
動かないっていうより……大事なときに限ってクラッシュするプログラムになるのよ。
うわぁ。……でも、今のパソコンだったら安心なんですよね。
昔に比べればね。でも、やっぱりエラーのチェックが必要なことには変わりがないのよ。

エラーの発生頻度が低いからといって、それがエラーチェックをしなくてもいい理由にはなりません。むしろ頻度が低いエラーほど、実際に起こったときには重大なトラブルを引き起こすとも考えられます。

メモリーの確保以外にも、ファイルへの入出力や通信などは、いつ実行時エラーが発生してもおかしくない操作です。何かエラーを返す可能性のある関数を呼び出したら、必ず結果をチェックするよう心がけましょう。

assertと実行時エラー

ところで最近、assertの使い方を覚えたでしょう?
はい。どんどん使ってます!
ふふふ。そしたらね、malloc()のチェックにもassertは使えると思う?
えっ?

結論から言うと、malloc()が起こすような実行時エラーのチェックに、assertを使うことはできません。assertはプログラムに含まれる欠陥、つまりバグを検出するためのものだからです。assertが必要なのは開発中だけなので、リリース用のバージョンでは無視されます。

これに対して、実行時エラーはリリース後にもチェックする必要があります。実行時エラーは「メモリーが足りなくなった」のような、プログラムを実行中の状況によって発生するエラーだからです。何かassertとは別の方法で、エラーの有無をチェックしなければなりません。

assertは、リリース後には「なかったこと」になるんだったわよね。
そっか。だから、この書き方はダメで……
  int *p = malloc(sizeof(int));
  assert(p);
  ……
やっぱりif文でチェックするのがいいんですね。
そういうこと。具体的にはこんな感じで……
  int *p = malloc(sizeof(int));
  if (p) {
    /* 通常の処理 */
    ……
  } else {
    /* エラー処理 */
    ……
  }
通常はこう、でもエラーが発生したときはこう……っていうように、処理を分岐させるのが基本ね。
ここがポイント!
assertは開発中にバグを検出するためのもの。リリース後にも必要な実行時エラーのチェックには、if文など別の方法を使いましょう。
んー、assertに比べると、if文は行数が多くなっちゃいますね……
そうねぇ。少しめんどうだけど、そこはしっかり書いたほうがいいんじゃないかしら。
そうなんですか?
ちゃんとしたプログラムにしたかったら、エラー処理は手を抜けないところなのよ。
そういうものですかー。
ふふふ。じゃあ、もしも手を抜いてしまったらどうなるか?っていうのを考えてみましょうか。

エラーチェックで手を抜くとどうなる?

次のプログラムは、「謎のテキスト」を処理して「結果のテキスト」を抜き出すというものです。実行すれば、おそらく問題なく動作するでしょう。でも、エラーチェックをしていないために、いつクラッシュしてもおかしくないところが2カ所もあります。

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

/* 謎のテキストから結果のテキストを抜き出して返す。
 * - pSecretText: 謎のテキスト
 * 結果のテキストは、呼び出し側がfree()で解放すること。
 * 結果のテキストを抜き出せない場合は、0(NULL)を返す。
 * pSecretTextが0(NULL)の場合の動作は未定義。
 */
char *ProcessSecretText(const char *pSecretText) {
  assert(pSecretText);

  char *pText = 0;
  int textLength = 0;

  /* 謎のテキストを1文字ずつ処理 */
  for (const char *pLetter=pSecretText; *pLetter!='\0'; pLetter++) {

    /* 数字は読み飛ばす */
    if (isdigit(*pLetter)) {
      continue;
    }

    /* 結果のテキストに1文字追加 */
    pText = realloc(pText, textLength + 2);
    pText[textLength] = *pLetter;
    pText[textLength + 1] = '\0';
    textLength += 1;
  }

  return pText;
}

int main(void) {
  char *pText = ProcessSecretText("47H5e3ll2o9!18");
  puts(pText);

  free(pText);
  pText = 0;

  return EXIT_SUCCESS;
}
ProcessSecretText()関数に「謎のテキスト」を渡しているのが分かるかしら?
えっと、"47H5e3ll2o9!18"ですね。ちょっと実行してみていいですか?
もちろん!
(カタカタカタ……)
実行結果
Hello!
わぁ!Hello!って出ましたよ!
「謎のテキスト」から数字を取り除いたものが、「結果のテキスト」として取り出されたわけね。
おもしろいですね!
ふふふ。でも、直すべきところが2カ所もあるのよ。

1カ所目のエラーチェック

まずは、関数のコメントに注目してみましょう。「結果のテキストを抜き出せない場合は、0(NULL)を返す。」と書かれています。これこそ、実行時エラーですね。

ところが、呼び出し側ではこのエラーをチェックしていません。

ProcessSecretText()の実行結果をチェックしていない!
  char *pText = ProcessSecretText("47H5e3ll2o9!18");
  puts(pText);

  free(pText);
  pText = 0;

このままでは、puts()関数に0(NULL)を渡してしまう恐れがあります。ProcessSecretText()関数の戻り値を、次のようにif文でチェックする必要があるでしょう。

ProcessSecretText()の実行結果をif文でチェック!
  char *pText = ProcessSecretText("47H5e3ll2o9!18");
  if (pText) {
    puts(pText);

    free(pText);
    pText = 0;
  } else {
    /* 結果のテキストを抜き出せなかった! */
    puts("Error");
  }

2カ所目のエラーチェック

次は、ProcessSecretText()関数の中身に注目しましょう。「結果のテキストに1文字追加」とコメントがある部分です。

ここでは、realloc()関数が使われていますね。これは、サイズを指定してメモリーを確保し直す標準関数です。malloc()関数と同様に、失敗すると0(NULL)を返すのがこの関数の仕様です。

ところが、この戻り値がチェックされていません。

realloc()の実行結果をチェックしていない!
    /* 結果のテキストに1文字追加 */
    pText = realloc(pText, textLength + 2);
    pText[textLength] = *pLetter;
    pText[textLength + 1] = '\0';
    textLength += 1;

このままでは、メモリーを確保できなかったのに文字を書き込んでしまう恐れがあります。ここでもif文を使って、realloc()関数の戻り値をチェックする必要があるでしょう。

realloc()の実行結果をif文でチェック!
    /* 結果のテキストに1文字追加 */
    {
      char *p = realloc(pText, textLength + 2);
      if (p) {
        pText = p;
      } else {
        /* メモリーを確保できなかった! */
        free(pText);
        pText = 0;

        break;
      }
    }
    pText[textLength] = *pLetter;
    pText[textLength + 1] = '\0';
    textLength += 1;

エラーチェックは「今やる」が基本

さて、2カ所にエラーチェックを追加すると、プログラムは次のようになります。これで「良いプログラムになった」といえるでしょうか?

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

/* 謎のテキストから結果のテキストを抜き出して返す。
 * - pSecretText: 謎のテキスト
 * 結果のテキストは、呼び出し側がfree()で解放すること。
 * 結果のテキストを抜き出せない場合は、0(NULL)を返す。
 * pSecretTextが0(NULL)の場合の動作は未定義。
 */
char *ProcessSecretText(const char *pSecretText) {
  assert(pSecretText);

  char *pText = 0;
  int textLength = 0;

  /* 謎のテキストを1文字ずつ処理 */
  for (const char *pLetter=pSecretText; *pLetter!='\0'; pLetter++) {

    /* 数字は読み飛ばす */
    if (isdigit(*pLetter)) {
      continue;
    }

    /* 結果のテキストに1文字追加 */
    {
      char *p = realloc(pText, textLength + 2);
      if (p) {
        pText = p;
      } else {
        /* メモリーを確保できなかった! */
        free(pText);
        pText = 0;

        break;
      }
    }
    pText[textLength] = *pLetter;
    pText[textLength + 1] = '\0';
    textLength += 1;
  }

  return pText;
}

int main(void) {
  char *pText = ProcessSecretText("47H5e3ll2o9!18");
  if (pText) {
    puts(pText);

    free(pText);
    pText = 0;
  } else {
    /* 結果のテキストを抜き出せなかった! */
    puts("Error");
  }

  return EXIT_SUCCESS;
}
直す前のプログラムよりも、ちょっと行数が増えちゃいましたね。
そうね。エラーチェックは必要なものだから、それでいいのよ。
でも……なんだかデバッグした気がしないというか……これって、本当に直ったんですか?
じゃあ、またプログラムを実行してみてくれる?
はい。(カタカタカタ……)
実行結果
Hello!
あ、やっぱり!ぜんぜん最初と変わってないですよ!
そのとおり。直したのはエラーが発生した場合の動作だけだから、正常時の動作は何も変わっていないように見えるはずよね。
んんっ?そっか……そりゃそうですね。
きちんと直ったかどうか確かめるには、実際にエラーを発生させてみないとね。
えー。それって、すごく大変なのでは……
うん、すごく大変。エラーチェックで手を抜いてしまったプログラムを、あとから直すのは手間がかかるのよ。
はぁ。
かといって、直さずに放置しておくのも爆弾を抱えているようなものよね。
いつクラッシュしてもおかしくない状況ってことですもんね……
だからエラーチェックは最初から手を抜かない!それが大切よ。

実行時エラーのチェックを、あとからプログラムに追加するのは手間のかかる作業です。どうすれば「直った」といえるのか、よく考えなければならないためです。例えばメモリー不足のような、あまり発生しない状況をどうにかして作り出さなければなりません。

しかし、直すのが大変だからといって放置しておくと、いつか本当にエラーが発生してトラブルになってしまうかもしれません。リスクを避ける最善の方法は、常にエラーチェックを怠らないことだといえるでしょう。

エラーチェックは「今やる」が基本です。「ここはチェックが必要だぞ」と気付いたら、すぐにif文を書きましょう。これは、いつか起こるトラブルを減らすためにも、大切な心がけです。

ここがポイント!
実行時エラーをチェックしないプログラムは、爆弾を抱えているようなもの。エラーチェックで手を抜かないように、いつも心がけておきましょう!

デバッグに便利なassertとは

さてと。ここまではデバッグコードでバグを検出する練習をしてきたわけだけれど……
はい。
デバッグコードの書き方には、だいたいのパターンがあることが分かったんじゃないかしら。
ざっと抜き出すと、こんなパターンになってるはず。
#ifndef NDEBUG
if (満たすべき条件) {
  ; /* OK */
} else {
  printf("NG:%s (%d)\n", __FILE__, __LINE__);
  abort();
}
#endif
そうですね。けっこう同じような形に収まるんだなって分かりました。
デバッグコードごとに大きく変わってくるのは、if文の条件式の部分ね。
そうみたいですね。
ここまでパターンが決まってくると、いつも同じように書くのはメンドウだと思わない?
実はそれ、ちょっと思ってました。
というわけで、assertの出番よ。assertを使えば、同じ目的のデバッグコードをもっと短く書けるの。
こんな感じで。
assert(満たすべき条件);
なんとっ!1行にできちゃうんですか?
そうなの。デバッグコードごとに変化する、条件式の部分に注目した書き方ができるってわけ。
いつも同じになっちゃう部分は、これで省略できるってことですかね。
うん。これならデバッグコードをどんどん増やしても、プログラムを見やすい状態に保てそうでしょう?
そうですねー。すごくいいかも!

デバッグコードはassertでコンパクトになる

assertは、「明言する」や「主張する」などの意味をもつ言葉です。プログラミングでは条件式を明記して、「ここでは、この条件が成立するのだ」と主張します。もし、条件に合わない状況に遭遇したら、それはバグだというわけです。

C言語では、次のようにインクルードを1つ書くだけでassertを使えるようになります。

#include <assert.h>

ここからは、デバッグコードの練習に使ったバブルソートの関数を題材にして、引き続きassertの練習をしていきましょう。関数は次のようなものでした。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* 引数をチェック */
  #ifndef NDEBUG
  if (pNumbers) {
    ; /* OK */
  } else {
    printf("NG:%s (%d)\n", __FILE__, __LINE__);
    abort();
  }
  #endif

  /* バブルソート */
  int moves = -1;
  while (moves != 0) {
    moves = 0;

    for (size_t i=1; i<numberCount; i++) {
      if (pNumbers[i - 1] > pNumbers[i]) {
        int temp = pNumbers[i - 1];
        pNumbers[i - 1] = pNumbers[i];
        pNumbers[i] = temp;

        moves += 1;
      }
    }
  }

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
  }
  #endif
}
それじゃあ、ここまでに書いたデバッグコードをassertに置き換えてみましょうか。
はい。この関数内にある、2つのデバッグコードですね。
1つ目は、引数のチェックをしているところね。
えっと、ここだな……
  /* 引数をチェック */
  #ifndef NDEBUG
  if (pNumbers) {
    ; /* OK */
  } else {
    printf("NG:%s (%d)\n", __FILE__, __LINE__);
    abort();
  }
  #endif
たしかに、完全にパターンどおりですね。
そうね。条件式に注目してassertで書くと……
あ、ちょっと待ってくださいね。
ええと、こうかなぁ……
  /* 引数をチェック */
  #ifndef NDEBUG
  assert(pNumbers);
  #endif
おしい!#ifndefも要らなくなるのよ。
ええっ?それじゃあ、これでいいの……かな?
  /* 引数をチェック */
  assert(pNumbers);
うん、正解!じゃ、2つ目はどうかしら?ソートの結果をチェックしている場所よ。
結果のチェック……と。
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
  }
  #endif
こっちは、ちょっとパターンと違いますね。なんとなくでよければ、assertにできそうな気がしますけど。
あはは。なんとなくじゃなくて、内側にも#ifndef NDEBUGがあると考えてみて。
つまり、こんな感じ。
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {

    #ifndef NDEBUG
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
    #endif

  }
  #endif
なるほど!そう考えればパターンどおりなんですね。
じゃあ、さっきと同じようにassertに置き換えられるから……
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    assert(pNumbers[i - 1] <= pNumbers[i]);
  }
  #endif
うん、正解よ!
やったー!でも、全部のデバッグコードがassertで1行にまとまるわけじゃないんですね。
そうね。ここでは配列の各要素をチェックしたいから、forループは残しておかないとね。
ふむふむ。
で、このループは開発中にだけ使うものだから、#ifndef NDEBUGもそのまま残すわけ。
デバッグコードは、リリース後には必要ないってことですね。
そういうことね!これは、デバッグコードの基本が分かっているからこそ、できる書き方なのよ。

ここまでで、2つのデバッグコードがコンパクトになりました。関数がどうなったか、全体を確認してみましょう。なお、1つ目のassertの前にあった「引数をチェック」というコメントは、見れば分かるので削除しました。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {
  assert(pNumbers);

  /* バブルソート */
  int moves = -1;
  while (moves != 0) {
    moves = 0;

    for (size_t i=1; i<numberCount; i++) {
      if (pNumbers[i - 1] > pNumbers[i]) {
        int temp = pNumbers[i - 1];
        pNumbers[i - 1] = pNumbers[i];
        pNumbers[i] = temp;

        moves += 1;
      }
    }
  }

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    assert(pNumbers[i - 1] <= pNumbers[i]);
  }
  #endif
}

全体的に、見通しのよいソースコードになったのではないでしょうか。このように、assertを使うとデバッグコードが書きやすくなり、また読みやすくもなります。

ところでassertの正体とは?

あの、assertの使い方は分かったんですけど……
疑問があるのね?
はい。assertって、どういう仕組みになってるんですか?
あー、そっかそっか。たしかに、ちょっと得体が知れないところがあるわよね。

assertには、以下の性質があります。

  • 条件が成立しないときは、ソースファイル名と行番号を表示して異常終了する
  • ただし開発中にだけ有効で、リリース後は無視される

この性質は、次のように書いたassertでも……

assert(満たすべき条件);

または、次のようなassertに置き換える前のデバッグコードでも同じです。

#ifndef NDEBUG
if (満たすべき条件) {
  ; /* OK */
} else {
  printf("NG:%s (%d)\n", __FILE__, __LINE__);
  abort();
}
#endif

では、assertの中身は、具体的にどのような仕組みになっているのでしょうか?これは、標準ライブラリの開発元それぞれがassertをどのように実装したかという話なので、決まった答えはありません。

でも、assertと同じものを自作することはできます。ここでは、自分でassertを定義する過程を通して、C言語が標準で備えているassertの仕組みを理解していくことにしましょう。

まず、assertに置き換える前のデバッグコードはお決まりのパターンなので、中身を関数に置き換えることにします。

#ifndef NDEBUG
DoAssert(満たすべき条件, __FILE__, __LINE__);
#endif

ここで、DoAssert()は自分で次のように作った関数です。呼び出す必要があるのは開発中のみなので、こちらも#ifndef NDEBUG#endifで囲みました。

#ifndef NDEBUG
void DoAssert(bool bCondition, const char *pFile, int line ) {
  if (bCondition) {
    ;
  }
  else {
    printf("NG:%s (%d)\n", pFile, line);
    abort();
  }
}
#endif

次に、デバッグコードを次のように変形させることを考えましょう。

#ifndef NDEBUG
assert(満たすべき条件);
#endif

これは、関数DoAssert()の呼び出しに置き換わる、次のようなassert()マクロを定義すれば可能です。

#ifndef NDEBUG
  #define assert(C) DoAssert((C),__FILE__,__LINE__)
#endif

最後に、デバッグコードで#ifndef NDEBUGを省略できるようにします。

assert(満たすべき条件);

これは、次のようにassert()マクロを改良すればできますね。開発中はDoAssert()を呼び出しますが、リリース後は無視されるようにしました。

#ifndef NDEBUG
  #define assert(C) DoAssert((C),__FILE__,__LINE__)
#else
  #define assert(C)
#endif

というように、assertはマクロとして自作することができます。標準ライブラリに含まれるassertも、おおよそ同じような仕組みだと考えていいでしょう。もちろん、具体的な実装はこれとは異なりますが、マクロでできているという点は同じです。

えっと。つまり……assertの正体はマクロだったんですね。
そのとおり!NDEBUGが未定義のとき、つまり開発中は、assertの部分が有効なデバッグコードに展開されるわけね。
ふむふむ。
で、反対にNDEBUGが定義されているとき、つまりリリース用にコンパイルしたときは……
assertの部分は「なかったこと」になると。
そういうこと。assertは、よくあるデバッグコードを短く書けるようにしたマクロだってことが分かったんじゃないかしら?
なるほどー。そういう仕組みだったんですね!
ここがポイント!
assertは、よくあるデバッグコードを書きやすくしてくれる仕組みです。

assertの力を借りて実装を置き換えてみよう

では、ソートする関数の例に戻りましょう。ここまでで、次の2カ所にデバッグコードを挿入しました。

  • 関数が呼ばれた直後:引数をチェックしている
  • 関数が終了する直前:ソートの実行結果をチェックしている

つまり、関数の入り口と出口でバグを検出しています。ということは、その間の実装を手直ししたとき、何か間違えてもすぐに気付ける可能性が高いといえます。

ここ重要なとこなんだけど、分かるかしら?
えっと……なんか重要そうだなぁっていう雰囲気は分かります!
あはは。実装を改良するときに、安心して作業できるっていうことよ。
改良って、ソートのやり方を直すってことですか?
そうね。例えば、今のバブルソートの実装は、チューニングすればもう少し高速になるはずよ。
そっか!そのチューニングで何か間違えたときは、プログラムを動かしてみればassertに引っかかるってことですね。
その可能性が高いわね。だから、デバッグコードがない場合に比べると、かなり安心感があるわけ。
安心できるって、いいですねぇ。
でしょー。さらに、ソートのアルゴリズムを別のものに置き換える作業も、比較的安全にできるわね。
えっ!バブルソートを別のソートに変えるってことですか?
そうよ。例えば、もっと高速な「マージソート」や「クイックソート」に作り直すとかね。
な、なるほど……
ふふふ。バグに対処するには「仕組み」が大事っていう話も、そろそろリアルになってきたんじゃないかしら。
はい。だいぶ分かってきた気がします!
じゃあ、あとは慣れるだけね。どんどん練習してみるのがいいと思うわ。

では、練習問題をやってみましょう!

今、ソートする関数の中身はバブルソートで実装されています。これを、何か別のアルゴリズムに置き換えてみてください。目標は、次の2点です。

  • 今よりも高速なソートにすること
  • ソートの結果が、今の実装から変わらないこと

参考として、「マージソート」に置き換える場合の解答例を以下に掲載しておきます。マージソートとは、配列を左右半分ずつに分けてそれぞれをソートしたあと、再び1つの配列に融合(マージ)させる手法です。

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

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {
  assert(pNumbers);

  /* マージソート */
  if (numberCount >= 2) {

    /* 左半分をソート */
    size_t leftNumberCount = numberCount / 2;
    SortNumbers(pNumbers, leftNumberCount);

    /* 右半分をソート */
    size_t righNumberCount = numberCount - leftNumberCount;
    SortNumbers(pNumbers + leftNumberCount, righNumberCount);

    /* 左右をマージ */
    size_t sortedNumberCount = 0;
    while ((leftNumberCount > 0) && (righNumberCount > 0)) {
      if (pNumbers[sortedNumberCount] <= pNumbers[sortedNumberCount + leftNumberCount]) {
        leftNumberCount -= 1;
      } else {
        int headNumber = pNumbers[sortedNumberCount + leftNumberCount];
        for (size_t i=leftNumberCount; i>0; i--) {
          pNumbers[sortedNumberCount + i] = pNumbers[sortedNumberCount + i - 1];
        }
        pNumbers[sortedNumberCount] = headNumber;

        righNumberCount -= 1;
      }

      sortedNumberCount += 1;
    }
  }

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    assert(pNumbers[i - 1] <= pNumbers[i]);
  }
  #endif
}

int main(void) {

  /* ソートを実行 */
  int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};
  size_t count = sizeof(numbers) / sizeof(int);
  SortNumbers(numbers, count);

  return EXIT_SUCCESS;
}

assertでデバッグの「苦しいところ」を減らそう

ふぅ……ソートって難しいなぁ。
練習問題をやってみてるのね。調子はどうかしら?
はい。難しいですけど……assertがあるおかげで、なんとかなりそうです!
うんうん。慣れるまでは、しょっちゅうassertにひっかかってしまうと思うけど……
あ、今そんな感じです!
そうよね。でも、悩む時間は減ったんじゃないかしら?
そうなんですよ!たぶん、どこで間違えたのかを調べやすいからだと思います。
すばらしいわね!assertに慣れたら、プログラミングがもっと上達すると思うわよ。
ほんとですか!がんばろー。

assertは、どんどん使ってみるのがおすすめです。はじめのうちは、自分で書いたassertでプログラムが止まってしまうことも多くなるかもしれません。でも、それはプログラムが想定と違う動きをしたということです。つまり、バグが検出されたわけですね。

このとき、「どこから調べればいいのか見当が付かない」ということが起こらないのがassertの特徴です。デバッグのための、あの苦しい時間を減らせるメリットは大きいのではないでしょうか。assertを使い慣れるほど、プログラミングが楽しくなっていくかもしれません!

ここがポイント!
assertを使いこなせば、プログラミングで悩む時間を減らせます。どんどん使って、慣れていきましょう!

デバッグコードを書いてみよう

デバッグコードはバグを検出するための仕組みだと分かったところで、実戦でも使える書き方を練習しましょう。ここまで例として使ってきた次の関数を、今回も使うことにします。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* ここに、ソートの実装 */

}
んんー、これだけだと、あんまり雰囲気がでないかしらね……
はーい、提案です!実際にソートが動く関数を使ったら、練習になると思います!
そうね。そうしましょう。ちょっと待っててね……
(カタカタカタ……)
/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* バブルソート */
  int moves = -1;
  while (moves != 0) {
    moves = 0;

    for (size_t i=1; i<numberCount; i++) {
      if (pNumbers[i - 1] > pNumbers[i]) {
        int temp = pNumbers[i - 1];
        pNumbers[i - 1] = pNumbers[i];
        pNumbers[i] = temp;

        moves += 1;
      }
    }
  }

}
関数の中身を、シンプルな「バブルソート」で実装してみたわ。
早っ!バブルソートって、シンプルなソートなんですか?
そうね。動作は遅いけど、アルゴリズムが分かりやすいソートなのよ。

バブルソートは、先頭から順に隣り合う値を比較して、大小関係が逆だったら入れ替えていくソートです。この操作を繰り返し、入れ替えが1度も発生しなくなればソート完了です。値がフワフワと少しずつ移動していく様子が泡のようなので、「バブルソート」と呼ばれています。

ソートのアルゴリズムは今回の主題ではないので詳しい説明は省きますが、上の例は実際に動作するC言語の関数です。ぜひ、練習に使ってみてください。

バグが目立つようにする

じゃあ、始めるわよ。
お願いします!
まずは、ソートの関数内にここまでのデバッグコードを入れて、実際に動かせるプログラムにしましょうか。
はい。やってみます!
main.c
#include <stdio.h>
#include <stdlib.h>

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* 引数をチェック */
  if (pNumbers) {
    ; /* OK */
  } else {
    ; /* NG(pNumbersが0になっている) */
  }

  /* バブルソート */
  int moves = -1;
  while (moves != 0) {
    moves = 0;

    for (size_t i=1; i<numberCount; i++) {
      if (pNumbers[i - 1] > pNumbers[i]) {
        int temp = pNumbers[i - 1];
        pNumbers[i - 1] = pNumbers[i];
        pNumbers[i] = temp;

        moves += 1;
      }
    }
  }

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      ; /* NG(期待どおりにソートできていない) */
    }
  }

}

int main(void) {

  /* ソートを実行 */
  int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};
  size_t count = sizeof(numbers) / sizeof(int);
  SortNumbers(numbers, count);

  return EXIT_SUCCESS;
}
できました!ソートする関数を実際に呼び出すプログラムにしてみたんですけど、どうですか?
うん。いい感じよ。ここまでに考えたことを、うまく組み込めたみたいね。
えへへ。
さてと。このプログラムには、バグが検出されるケースが2通りあるわね。
はい。「NG」ってコメントがある2つですね。
そう。開発中は、この2つのケースに引っかかったら気付くようにしたいわね。そのためには……
printfでエラーメッセージを表示するとか?
そうそう。そんな感じで、バグが目立つようにしたいのよ。あとは、プログラムを異常終了させちゃいましょう。
え、異常終了ですか?
うん。そしたら「ぎゃー!」ってなるから、バグを直さざるをえなくなるでしょ?
な、なるほど……わざと異常終了させるなんて、考えたことなかったかも。
ふふふ。C言語ならabort()、C++を使っているならterminate()を使えばいいわね。1つやってみてくれる?
えっと、エラーメッセージを表示して異常終了と……
  /* 引数をチェック */
  if (pNumbers) {
    ; /* OK */
  } else {
    printf("NG:pNumbersが0です!\n");
    abort();
  }
そうそう。じゃあ、せっかくだから、バグの検出を体験しておきましょうか。

ここまでできたら、実際にバグがあった場合に何が起こるのか体験しておくといいでしょう。やり方は、実際に「NG」になる呼び出し方をしてみるだけです。

今回の例では、第1引数を0(NULL)にしてSortNumbers()を呼び出してみればいいですね。実行すると、エラーメッセージが表示されて、プログラムが停止する様子を確認できます。

おおぅ……異常終了しましたよ。これ、急に出たらほんとに「ぎゃー!」って声が出るかも。
あはは、そうかもね。じゃ、次のテクニックいくわよ。
わ、次があるんですね!

これで、バグが検出されたことが目立つようになりました。それがどのようなバグなのかも、メッセージを読めば分かります。でも、このメッセージの内容は、直前にあるif文の条件式を見れば明らかですね。

そのため、ここで重要となる情報は「どのようなバグなのか」ではありません。代わりに、「どこで検出されたバグなのか」が表示されるように改良してみましょう。C言語では、__FILE____LINE__を使うのがおすすめです。

  • __FILE__:いま見ているソースコードのファイル名を表すマクロ
  • __LINE__:いま見ているソースコードの行番号を表すマクロ
バグが検出されたときのメッセージを、__FILE____LINE__の表示に直してみるわね。
例えば、こんな感じ。
  /* 引数をチェック */
  if (pNumbers) {
    ; /* OK */
  } else {
    printf("NG:%s (%d)\n", __FILE__, __LINE__);
    abort();
  }
へぇー、こんなマクロがあるんですね。
便利でしょ。これで、バグがどのファイルの何行目で検出されたのかが分かるようになるわ。
なるほどって感じです。メッセージを見たら、すぐにバグの調査にとりかかれるんですね!
そういうこと!じゃあ、2つ目のデバッグコードも、同じように直してみましょうか。
えっと、ソート結果をチェックしてるところですね……
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
  }
やってみて気付いたんですけど、エラーメッセージを表示して異常終了するところは、まったく同じように書けるんですね。
うん。そこはバグを検出したあとの処理だから一緒なのよね。
そっか。違うのは「どうなったらバグか」っていう条件のところだけなんですね。
そういうことね。
ここがポイント!
デバッグコードで検出したバグは、とにかく目立つようにしましょう。プログラムを異常終了させる方法がおすすめです。

デバッグコードを開発中に限定する

さてと。これでバグを目立たせるテクニックは覚えたわね。
はい。
そしたら、もう1つやりたいことがあったと思うのだけど、覚えてるかしら?
あれですよね、リリース後はバグがあっても目立たないようにするっていう……
そうそう。ちゃんと覚えてたわね。

ソフトウェア開発には、矛盾する2つの願いがあります。

  • 開発中は問題があったら早く直したいので、バグは目立つようにしたい
  • リリース後は、もしバグが残っていたとしても目立たないようにしたい

これは、ソフトウェアには2つのバージョンが必要だということを意味しています。「開発中のバージョン」と、「リリース用のバージョン」です。といっても、同じような開発を2回行わなければならないという意味ではありません。ソースファイルをコンパイルするときに、どちらのバージョンの実行ファイルを生成するか選べるようにするということです。

IDEを使っている人なら、開発中は「デバッグビルド」、最終的には「リリースビルド」を行えばいいと知っているのではないでしょうか。これは、2つのバージョンを選択するための仕組みです。

ここで、マクロをもう1つ覚えましょう。

  • NDEBUG:リリース用のバージョンをコンパイルするときにだけ定義される

NDEBUGは、C言語で標準的に利用できるマクロです。頭に「N」が付いているので、「Not DEBUG」という意味ですね。これと、条件付きコンパイルを組み合わせると、開発中バージョンにだけデバッグコードを付加できます。

具体的には、次のようにします。

  #ifndef NDEBUG
  ここにデバッグコード
  #endif

ここで、#ifdefではなくて#ifndefを使っている点に注目しましょう。こちらも「n」が付いているので、「if not defined」という意味ですね。つまり、「もしNDEBUGが定義されていないなら」という条件により、開発中のバージョンのみでデバッグコードが有効になるのです。

C言語では「#ifndef NDEBUG」っていうお決まりのフレーズで、開発中にだけ有効なコードを埋め込めるということね。
なるほどー。なんか、いかにも「テクニック」っていう感じで、やってみたくなりますね。
いいわね。じゃ、やってみましょうか。
はーい!
えっと、1つ目のデバッグコードは……
  /* 引数をチェック */
  #ifndef NDEBUG
  if (pNumbers) {
    ; /* OK */
  } else {
    printf("NG:%s (%d)\n", __FILE__, __LINE__);
    abort();
  }
  #endif
そうそう。あってるわよ。
じゃあ、2つ目のデバッグコードも同じように……
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
  }
  #endif
うん、バッチリね。デバッグコードにあたる部分を、#ifndef NDEBUG#endifで囲むだけでいいの。
簡単ですね!
でしょ。こうしておけば、リリース後はデバッグコードが無効になるということね。

このようなデバッグコードは、リリース用のバージョンでは「なかったこと」になります。そのため……

  • たとえバグが残っていたとしても、目立ちにくくなる

という点のほかに、次のようなメリットも得られます。

  • デバッグコードが、リリース後の実行速度を遅くする心配はない
  • デバッグコードにより、リリース用の実行ファイルが大きくなる心配はない
なるほど。行数が増えた分だけプログラムも大きくなっちゃう気がしてたんですけど。
デバッグコードは、リリース用のバージョンでは条件付きコンパイルで無視されるから……
最終的には影響が出ないんですね!
そういうこと!ただ、ちょっとだけ気を付けたいところもあるのよ。

条件付きコンパイルでデバッグコードを書くときは、注意すべき点もあります。それは、リリース後も必要となる機能の一部を、デバッグコードに含めてはならないということです。

もしも必要な機能を含めてしまうと、その部分がリリース用のバージョンで無視され、機能の欠損を招いてしまいます。デバッグコードはあくまでバグを検出するためのものと考え、それ以外のコードは含めないようにしましょう。

デバッグコードがあってもなくても、プログラムは同じように機能しなければならないということね。
ふむふむ。デバッグコードが機能の一部になってしまうと問題なんですね。
そういうことね。まぁ現実的には、「バグを捕まえる」っていう本来の目的を忘れなければ、だいたい大丈夫だと思うわよ。
ここがポイント!
デバッグコードは、リリース後の動作に影響を与えないように書きましょう。そのためには、条件付きコンパイルを使うのが簡単です。

デバッグコードを書きやすくするには?

さてさて。デバッグコードの基本的な書き方は分かったかしら。
はい!ていうか、もう実戦でどんどん使ってみたいと思ってますけど……
あー、待って待って。あと1つ、ぜひ覚えておきたいテクニックがあるのよ。
そうなんですか?(あれれ?この流れ、前にもあったような……)
次は、デバッグコードをもっとスマートに書く方法、assertを伝授するわ。
やったー!

デバッグコードとは

それじゃあ、バグを早期発見するには仕組みが重要だと分かったところで、「デバッグコード」のテクニックを覚えていくのだけど……
はい。バグを捕まえる「罠」をプログラムに仕掛けるんでしたよね。
そう。そのためには、「バグとは何か」を知っておかなきゃね。
やだなぁ。それくらいは知ってますよー。
まぁ知ってるわよね。でも、ちゃんと言葉で説明できるかしら?
そうですね、バグとは……プログラムの間違い……とか?
うん。プログラムの間違い、つまり「欠陥」のことね。じゃあ、「欠陥とは何か」って聞かれたらどうする?
えっと。なんとなく分かってはいるつもりなんですけど、どう説明したらいいものか……

あらためて「バグとは何か」を考える

欠陥というのはね、「理想的な状態」と「実際の状態」のギャップのことよ。
ギャップ……ですか?
そう。例えばプログラミングするときは、「どんなプログラムにしたいか」っていうのがあるわよね。
はい。
で、そのとおりに作れれば理想的だけれど、実際には思ったとおりに動かないこともあるでしょう?
ありますねぇ。
それがプログラムの欠陥、つまり「バグ」ね。

「理想的な状態」と「実際の状態」のギャップ(差)のことを「欠陥」といいます。欠陥は、ソフトウェア開発のいろいろな場面にみられるものです。プログラムに限らず仕様書や設計書などのドキュメント、あるいは開発計画などに欠陥が見つかることもあるでしょう。

「バグ」というのは、おもにプログラムの欠陥のことを指す比喩的な表現です。「欠陥」という言葉には少しキツイ印象があるので、「仕様のバグだね」なんていう言い方をすることもありますが。もともとは、コンピューターに「虫(bug)」が入り込んで悪さをしているせいで、プログラムの動作がおかしくなってしまったという意味です。

プログラムがおかしいのを虫のせいにしたくなる気持ち、分かるなぁ……
そうよねー。でも結局、バグは自分の力で直すしかないわね。
はい。
そのためには「理想的な状態」、つまりどんなプログラムを作ろうとしてたのかを認識できるかどうかがカギなのよ。
ええと……そっか。何を作ろうとしてたのかが分からなかったら、直しようがないですもんね。
そういうことね。

テストは正常に動作していることを確認する作業

じゃあ、次はテストについて考えましょうか。
あのー、デバッグコードの話をしていたのでは?
まぁまぁ。これもデバッグコードについて理解するためなのよ。
そうなんですね。
というわけで、「テストとは何か」を言葉で説明できるかしら?
えっと……動作確認のこと……ですかね。
うん。プログラムを動作させてみて、正常かどうかを確認するのよね。

テストとは、作ったプログラムを実際に動作させてみて、正常に振る舞うかどうかをチェックする作業のことです。これは、プログラムが「理想的な状態」であることを確認しているのだといえます。

そのためには、「どんなプログラムを作ろうとしていたか」を認識することが重要です(バグを直すときも、そうでしたね)。この認識がなければ、そもそも何をテストすればいいのか分かりません。

テストっていうのは、プログラムが正常だと確認するためのものなの。バグを見つけることが目的ではないっていうのを意識しておくといいわね。
えっ?だって、テストしたらデバッグしますよね?
それはテストに「不合格」だった場合ね。デバッグしたらまたテストして、直ったかどうか確認するでしょう?
はい。
つまり、テストではいつも、動作が正常なことを確認しているのよ。確認が取れたら「合格」になって、テスト完了というわけね。
なるほど、分かったような気がします……けど、いまいち現実のプログラミングと結び付かないというか……
じゃあ、もう少し具合的な例を見てみましょうか。

例として、整数の配列を昇順(小さい順)にソートする関数があるものとしましょう。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* ここに、ソートの実装 */

}

この関数が何をするためのものなのかは、コメントに書かれています。具体的には、次のようなことを理解できるでしょう。

  • 整数の配列pNumbersと要素数numberCountを渡すと、配列の要素がソートされる
  • pNumbersに0(NULL)を指定する呼び出し方は禁止

この関数をテストするためのテストコード、つまり単体テスト(ユニットテスト)は、どのように書けるでしょうか。

関数がコメントのとおりに動くか確認したいっていうことよ。
ふむふむ。それが正常な動作だってことですね。
そういうこと。それが、この関数の仕様だからね。
そっか!仕様ですね。
ん?
つまり、プログラムのテストって、仕様どおりに動作するか確認することなんじゃないかなって。
うんうん。仕様が明記されているなら、そのとおりよ。関数の仕様は今回みたいにコメントに書かれていることもあれば、仕様書になっている場合もあるわね。
やっぱり!
反対に、仕様がよく分からない関数の場合は、何が正常な動作なのか不明だから……
テストできないってわけですね。
うん、そういうこと。それじゃあ、今回の関数はテストできそうね。

では、具体的なテストコードについて考えていきましょう。

まず、実際にプログラムを動かしてソートの動作を確認するために、テストデータとして「ソート前の配列」を用意する必要があります。例えば、次のようなものです。

  int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};

これをソートした結果が正しいかどうかをチェックするには、「ソート後の配列」も必要です。次のような、ソートの「正解」を表すデータです。

  const int expectedNumbers[] = {-34, -20, 0, 2, 2, 5, 16, 48, 55, 56, 976};

numbersを実際にソートして、結果がexpectedNumbersと等しくなっていることをチェックすれば、テストに合格したといえるでしょう。そのためのテストコードは、ざっと次のような形になります。

  int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};
  size_t count = sizeof(numbers) / sizeof(int);
  SortNumbers(numbers, count);

  const int expectedNumbers[] = {-34, -20, 0, 2, 2, 5, 16, 48, 55, 56, 976};
  for (size_t i=0; i<count; i++) {
    if (numbers[i] == expectedNumbers[i]) {
      ; /* OK */
    } else {
      ; /* NG(期待どおりにソートできていない) */
    }
  }

このようなテストを実施すれば、関数がだいたい期待どおりに動作していることを確認できます。

さてさて。テストコードについて考えてみたわけだけど、何か感じたことはあったかしら?
はい。具体的なイメージはわきました!でも……
でも?
なんていうか、これでテスト足りてます?
足りてないわ。ソートする関数が入力できる配列のパターンは、もっとたくさんあるもの。

関数が受け付けられる配列のパターンは無数にあるにもかかわらず、上記のテストでは1通りのテストデータでしか動作を確認していません。かといって、テストデータをいくら増やしても、あらゆるパターンをテストするのは不可能です。テストには、限界があるのです。

そこで、どのようなパターンでテストすれば、自信をもって「正しく動作している」といえそうか考える必要があります。今回のテスト対象はソートする関数ですが、ここではデータ数が1のパターンと2のパターンが要注意だと分かるでしょうか。

  • データ数が2以上のときは、配列の中身がソートされる
  • データ数が1以下のときは、ソートしても配列の中身は変化しない

このように、データ数1と2の間に、関数の動作が変化する境目があるのです。こうした境目の値のことを「境界値」といいます。境界値の直前と直後の値で動作を確認すれば、より信頼性の高いテストになるでしょう。

また、さらに極端なケースとして、配列が空のパターンも考えられます。以下の動作についても、併せてチェックしておくとベターです。

  • たとえデータ数が0でも、問題なくソートできる(配列の中身は変化しない)
なるほど。どんなパターンでテストするかが重要なんですね!
そうね。テストのパターンは多いほうが安心できそうな気がするけど、闇雲に増やせばいいってものでもないのよ。
でも、よく考えて重要なパターンをテストできたら、それで十分なんじゃないですか?結局、デバッグコードは要らないような気がしてきましたけど……
じゃあ、関数のコメントをもう一度見てみましょうか。まだテストできていないところがあるのだけど、分かるかしら?
え?まだあるんですか?
えっと。コメントに書かれてたことは……
/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* ここに、ソートの実装 */

}
分かった!pNumbersに0(NULL)を指定したときの動作をテストしてないです!
そのとおり。でも、これはテストできないの。
そうなんですか?
テストは、プログラムの正常な動作を確認するものだったわよね。
はい、そうでした。
で、pNumbersを0(NULL)にして呼び出すことは禁止されているわけだから、正常な動作ではないわね。
そうですね……
つまり、そういう呼び出し方をしたらバグだということね。

繰り返しになりますが、テストとはプログラムが正常だと確認することです。「この場合は、このように振舞うべき」というような「正解」が決まっているパターンだけがテスト可能だといえます。

これに対して、バグはそもそも正常ではない動作です。どのような場合に、どう振舞うかを決めておけないため、テストのパターンとして表現することができません。この点も、テストの限界だといえるでしょう。

例えば今回の関数では、pNumbersに0(NULL)を指定して呼び出したとすると、それは呼び出し側のバグということになります。これをテストのパターンとするには、誤った呼び出し方をテストコードに加えるしかありません。また、その場合の正常な動作も不明です。結果として、ただテストにバグを埋め込んだだけになってしまうのです。

ここがポイント!
テストとは、プログラムの動作が正常だと確認することです。ただし、テスト可能なパターンには限界があるため、完璧を求めることはできません。
なるほどー。テストに限界があるのは理解できたんですけど、それだと、ちょっと不安が残りませんか?
そうよね。そこで使えるのが、デバッグコードのテクニックなのよ。
そうなんですね!わくわく。

デバッグコードはバグを検出するための仕組み

デバッグコードとは、バグを捕まえるためにプログラム中に仕込む「罠」のことでした。デバッグコードを書く動機は、テストとは反対のものだといえます。

  • テストを実施する動機:動作が正常であることを確認したい
  • デバッグコードを書く動機:バグを積極的に検出したい

動機が異なる以上、「テストとデバッグコードはどちらが優れているか」という話ではありません。これら2つを組み合わせることで、最終的にバグのないプログラムにできる可能性が高まると考えましょう。

テストだけでは限界だったところを、デバッグコードで補おうということよ。
ふむふむ。じゃあ、テストコードを書くみたいに、関数をチェックするコードを書くんですか?
ちょっと違うわ。デバッグコードはね、作っているプログラム自体に埋め込むのよ。

では、先ほどのソートする関数に、デバッグコードを追加してみましょう。ここでのポイントは、次の2つです。

  • 関数が予想外の使われ方をしていたら、呼び出し側のバグ
  • 関数の実行結果が期待と違ったら、この関数自体のバグ

関数の使われ方をチェック

関数が予想外の使われ方をしていないかどうか確認するには、引数のチェックが基本です。次のように、関数の入り口にチェックを入れるのがいいでしょう。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* 引数をチェック */
  if (pNumbers) {
    ; /* OK */
  } else {
    ; /* NG(pNumbersが0になっている) */
  }

  ……
}

ここではpNumbersに0(NULL)を指定する使い方はNGなので、それをif文でチェックしています。これで、この関数が呼び出されるときには常に、デバッグコードがはたらきます。

これでテストでは不可能だった、呼び出し側のバグも検出できるようになったわね。
なんだか、意外に簡単ですね。もっと難しいものを想像してましたけど……
簡単なほうがいいのよ。デバッグコードのデバッグをするのは嫌でしょう?
たしかにっ!

ここで、if文には「正常ならこう」という条件式が書かれているのが分かるでしょうか。バグを検出するといっても、実際にできるのは正常な動作の確認だけだという点は、テストと変わりがありません。

では、テストと何が違うのかといえば、チェックが関数の内部ではたらくという点です。これにより、呼び出し側の正常ではない振る舞いも、いとも簡単に検出できるのです。

関数の実行結果をチェック

次は実行結果をチェックしましょう。今回は次のように、配列をソートしたあと、関数を抜ける前にチェックを入れればいいでしょう。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {
  ……

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      ; /* NG(期待どおりにソートできていない) */
    }
  }

}

ここでは、ソート後の配列が、本当に昇順に並んでいるかどうかをチェックしています。

関数が受け付けられる配列のパターンは無数にあるため、テストだけではテストデータを用意するのも限界があったことを思い出しましょう。デバッグコードも、すべてのパターンをチェックできるわけではないのは同じです。しかし、デバッグコードは実際に関数が呼び出された際のデータすべてに対してはたらくので、より多くのパターンをチェックできます。

常に実データに対してチェックが働くところが、合理的よね。
ほんとですね。なんだか、今度はテストのほうが要らないんじゃないかって思えてきました。
その気持ちは理解できるわ。でも、やっぱりテストとデバッグコードは別物なのよ。
そうなんですか?
例えばね、今回のデバッグコードは、簡易的なチェックしかしていないのが分かるかしら。

上の例は、隣り合う要素の大小関係をチェックするだけの簡易的なチェックになっているのが分かるでしょうか。これでは、ソート後のデータを厳密に検証したことにはなりません。例えば、すべてのデータが同じ値に書き換えられてしまった場合でも、正しくソートできたとみなしてしまいます。

とはいえ、このチェックをすり抜けてしまうような「でたらめ」な状況には、デバッグコードがなくても気付けるでしょう。とくに、テストと併用しているなら確実です。

デバッグコードの狙いはバグの検出だから、「正常かどうか」を厳密にチェックしようとするのは力の入れ過ぎといえるわね。
なるほど。「正常かどうか」のチェックはテストでやろうってことですね。
うん。だいたいの場合は、そう考えるのが生産的だと思うわ。
ここがポイント!
バグを検出するためにデバッグコードを書き、動作が正常か確認するためにテストを実施しましょう。2つを組み合わせることで、バグのないプログラムにできる可能性が高まります!
でも、やっぱりデバッグコードで「正常かどうか」を厳密にチェックをしたほうが安心ってこともありません?
あるわね。ちょっと処理が複雑で、「ここはバグが集中しそうだなぁ」って感じるときとかね。
そう考えればいいんですね。えっと……僕にとっては今回のソートが、まさにそうなんですけど。
そっかそっか。その場合は、標準関数のqsortと比較するのがいいわ。
え?ソートする関数が最初からあるってことですか?
そう。汎用性の高い、C言語に標準搭載の関数よ。

ここで補足。もしかすると、「標準関数でソートできるのなら、ソートを自作する意味はないのでは」と思った人もいるかもしれません。

でも、ソートのアルゴリズムには、さまざまな種類があります。また、それぞれ特徴が異なります。

  • アルゴリズムがシンプルか
  • データの個数が増えたとき、ソートにかかる時間はどう変わるか
  • ソートするとき、どれくらいのメモリーを使用するか
  • などなど……

目的や状況に合わせて、アルゴリズムを選びたい場面もあるでしょう。そのため、標準関数を採用せずに、ソートする関数を自作するというのは実際にもあることです。

今回の例は、標準関数のqsortを採用せずに自作するケースになってたんですね。
うん。で、自分でソートした結果はqsortと同じになるのが正しいわけ。
ふむふむ。「qsortと比較する」っていうのは、両方の結果が同じかどうかチェックするっていうことなんですか?
そういうこと!

バグの発生箇所は、プログラム内の特定の範囲に集中しやすい傾向があるといわれています。「バグが集中しそう」と感じたときは、より厳密なチェックをするデバッグコードを書いておくと安心です。

そのためには、別のアルゴリズムを用いたときの結果と照合する方法が考えられます。今回はソートする関数なので、標準関数のqsortが使えます。自分でソートした結果が、qsortでソートした結果と完全に一致することを確認すればいいでしょう。

デバッグコードを実戦で使うには?

デバッグコードの基本的な考え方は分かったかしら。
はい!ていうか、もう具体的な書き方まで分かったと思いますけど……
あー、待って待って。実際の開発に使うテクニックとしては、もうちょっと押さえておきたいことがあるの。
そうなんですか?
そうなのよ。開発中は、デバッグコードでバグを早く発見できたほうが嬉しいわよね。
はい。
つまり、「バグがあったぞー!」っていうのが目立つようにしたいわけ。それが、押さえておきたい1つ目ね。
なるほど。もう1つあるんですか?
うん。その「バグがあったぞー!」は、リリース後には大きな声で言いたくないじゃない?それが2つ目。
ほんとだ!え……そんなことできます?
もちろん!というわけでね、次は実際に使えるテクニックとして、デバッグコードを練習しましょうか。
はーい!

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

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

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

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

プログラミングをしなければ、バグが発生することもないでしょう。でも、プログラムを作る仕事をする以上は、間違いの頻度はゼロにはなりません。例えば、長いプログラムを書くときに、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を付けて「不変」だと思うのがコツなのよ。
引数に別の値を代入しないように、気を付けやすくなるってわけですね。
ここがポイント!
プログラム中に現れる変数(引数も含む)は、実はほとんどが不変!そう考えれば、名前と中身の矛盾を避けやすくなります。