第8章 いろいろなプログラムを作ってみよう

テキスト『新・明解C言語 入門編 第2版』第8章の内容に関する補足解説を提供します.

8-1 関数形式マクロ

関数形式マクロ

第5章では,ある特定の文字列を別の文字列に機械的に置換する, オブジェクト形式マクロ(objet-like macro)を学習しました. 第8章では,関数と同じ感覚で,しかも型に依存せずに使える, 関数形式マクロ(function-like macro)を学習します.

オブジェクト形式マクロは文字列を単純に置換しました. 関数形式マクロではたいてい引数を使います. 関数と同様,引数は ( ) の中に記述されます. List 8-2 では,引数を x として,


#define sqr(x) ((x) * (x))

という置換を定義しています. 引数 x が何であっても,sqr(x)((x) * (x)) に置換されます. したがって, ソースプログラムで sqr(n) と書いたところは ((n) * (n)) に, sqr(x) と書いたところは ((x) * (x)) に置換されます.

#define 指令は,ソースファイルをコンパイルして実行ファイルを作成するときに, プリプロセッサ(preprocessor)というプログラムによって処理されます. ソースファイルから実行ファイルを作成する手順において, プリプロセッサによる処理は最初に実行されます.

コンパイラとして,clang あるいは gcc を使うのであれば, プリプロセッサによる処理を行ったところでコンパイルのプロセスを止めることができます. List 8-2 でこれを行い,意図した置換が行われていることを確かめてみましょう. このソースファイルが list0802.c という名前で授業用ディレクトリに保存されているとします. ターミナルで,授業用ディレクトリに移動し,


clang -E list0802.c > pre0802

と入力して [Return] キーを押してください. ここで -E というオプションは, プリプロセッサの処理が終わったところでコンパイルのプロセスを止めるものです. ソースファイルに対してプリプロセッサが処理を行った結果を, > pre0802 という命令によって,pre0802 という名前のファイルに出力します. 記号 > の前後には半角スペースを入れてください.

clang -E

ls コマンドで授業用ディレクトリの内容を確認すると, pre0802 というファイルができていることがわかります.

pre0802

このファイル pre0802 の内容を確認しましょう.ターミナルで,


more pre0802

と入力して [Return] キーを押してください(more の後に半角スペースが必要). この more コマンドは,テキストファイルの中身を, 1ページ(ターミナルの1画面分)ずつ表示するコマンドです.

more pre0802

何度かスペースキーを押すと,ファイルの内容が最後まで表示されます.

more pre0802

上図に表示されている,pre0802 の最後の部分を見てください. ソースプログラムで sqr(n) と書いたところが ((n) * (n)) に, sqr(x) と書いたところが ((x) * (x)) に置換されていることがわかります.

プリプロセッサへの指令はC言語とは異なった文法に従います. よくある誤りのひとつは, #define 指令の最後にセミコロン(;)を入れてしまうものです. C言語の文法ではセミコロンで文が終了するのに対して, プリプロセッサへの指令では行端で終了します. セミコロンは不要です.

関数と関数形式マクロ

テキストで説明されているように, 関数形式マクロの最大のデメリットは, 置換の結果が意図しないものになってしまわないよう, 細心の注意が必要であることです. テキストの Column 8-2(第1版では p.215)で述べられているように, 「たとえ必要なくとも,個々の引数と,全体を ( ) で囲んでおく」ようにしましょう.

関数形式マクロは思わぬ副作用を生じやすいので, 使用を避けるべきだという意見もあります(たとえば, 『日経ソフトウェア』2013年7月号特集「そのコードは古い」).

引数のない関数形式マクロ

このセクションについての補足説明は現在のところありません.

関数形式マクロとコンマ演算子

List 8-3 でプリプロセッサが置換を行ったとき, if が制御する文の後に空文があることはエラーではありません. その後にいきなり else 文が来てしまうところがエラーとなります. 第3章で学習したように(第1版 p.59,第2版 p.61), if 文が制御する(条件が満たされたときに実行する)文は1個だけです. 続く空文は if 文とは無関係の文となります. List 8-3 を clang でコンパイルしようとすると, 以下のようなエラーが表示されます.

List 8-3 compile error

List 8-4 では, コンマ演算子(comma operator)でつないだ2つの式 putchar('\a')puts(str) を, ( ) で囲んでいることに注意してください. List 8-3 では複合文を意図して { } でした. なお.この ( ) はなくても正しいプログラムとなりますが, 「たとえ必要なくとも,個々の引数と,全体を ( ) で囲んでおく」というルールに従います. それに,式を ( ) で囲んだものは式になるので, そうした方がわかりやすいプログラムになります.

第11章を学習しないとできませんが,List 8-4 でのマクロは, 以下のように関数として定義することができます.


void puts_alert(char *str)
{
  putchar('\a');
  puts(str);
}

関数形式マクロの使用は避けるべきだという立場では, この方法が推奨されます.

8-2 ソート

ある特定の問題を解く手順を, 単純な計算や操作の組み合わせとして明確に定義したものを, アルゴリズム(algorithm)と呼びます(『IT用語辞典 e-Words』より). 大学でアルゴリズムについて扱う科目を履修すると, ソートのためのアルゴリズムは必ず学習することになるでしょう.

ソートのためのアルゴリズムはいくつかあります. テキストでは バブルソート(bubble sorting)というアルゴリズムを扱っています. 水中から水面に上がってくる泡のようにソート対象が移動していくことから, このような名前がつけられています. テキストの説明をよく読んで,アルゴリズムを理解してください. アルゴリズムが理解で来たら,それがプログラムでどのように実装されているか, List 8-3 の bsort 関数をよく検討してください. この関数に渡される配列 height の要素数が5の場合, 定義における n の値が 5,j の値が 4 となります. 最初は height[3] と height[4] の値が比較されます. 配列の添え字は 0 から始まることに注意してください.

ソートされた結果だけでなく,その過程を表示すると, プログラムの動作が理解しやすいと思います. 下図のように,printf 関数を使って, 比較がされる要素を表示してやりましょう.

List 8-5

コンパイルして実行すると,以下のような出力がなされます.

result of running List 8-5

プログラムが実行される過程を printf 関数で表示してやることは, プログラムの動作を理解するためのよい方略です. エラーの原因を突き止めるためにもよく使われる方法であり, printf デバッグと呼ばれます.

旧版のテキスト『新版 明解C言語 入門編』では, バブルソートを改良したアルゴリズムと, その他いくつかのソートアルゴリズムを学習していました(第12章). 興味があれば,図書館などで旧版のテキストを探し, 学習するとよいでしょう. 第10章で扱うポインタの知識が前提となっていますが, アルゴリズムの部分は理解できると思います.

8-3 列挙体

列挙体

このテキストでは列挙体という名前が前面に出ていますが, C言語の他の入門書では単に「列挙」と書いてあることが多いようです.

列挙体(enumeration)は 列挙定数(enumeration constant)のリストです. 列挙定数にはそれぞれ int 型の値が割り当てられます.これはコンパイラの仕事です. 特に指示しなければ, 最初の列挙定数には 0 が割り当てられ,以下は連番となります. 次のセクション(列挙定数)の冒頭で説明されているように, 割り当てられる数値を自由に設定することもできます.

列挙定数には大文字を使うことが一般的です. これは次のセクションで説明するように, 列挙定数は記号定数(#define 指令によって名前をつけられた定数)に類似しており, 記号定数には大文字を使うことから来たルールだと思われます. しかし,記号定数と列挙定数はプログラム中の別の場所で宣言されるので, 名前が偶然に一致してしまうかもしれません. テキストでは列挙定数の最初の文字だけを大文字にしています. これは良い工夫だと思います. 記号定数をすべて大文字で表記することにしておけば, こうした偶然の一致は避けられます.

列挙体を宣言すると,列挙型の変数を使えるようになります. たとえば,List 8-6 の main 関数内では, enum animal 型の変数 selected が定義されています. この変数がとりうる値は,Dog, Cat, Monkey, Invalid です. 整数値では 0 から 3 までです. 変数に値を代入したければ,たとえば,


selected = Dog;

とします.列挙型の変数に直接に数値を代入することもできますが, このように列挙定数を使った方が, 意味のわかりやすいプログラムになると思います.

List 8-6 ではプログラムの冒頭で enum animal 型の宣言を行っています. これにより,この宣言より後のどこでも,宣言された列挙定数を使うことができます. 実際,main 関数と select 関数の両方で列挙定数が使われています. テキストでは,第12章で学習する構造体も, 列挙体と同様にプログラムの冒頭で宣言するスタイルをとっています.

列挙体を宣言する場所はプログラムの冒頭という決まりがあるわけではありません. もし main 関数の中だけでしか enum animal 型の列挙体を利用しないのであれば, main 関数のブロック冒頭で宣言を行うこともできます,たとえば,


enum animal { Dog, Cat, Monkey, Invalid } selected;

と書くと,enum animal 型の宣言と, この型の変数 selected の定義を同時に行うことができます. 一般的な書き方は,


enum 列挙体タグ { 列挙定数リスト } 変数リスト;

となります. 変数リストでは,複数の変数をコンマで区切って並べることができます,

C言語では列挙型と整数型は互換性があります. このため,int 型の値は列挙型の変数に代入できます. たとえば,List 8-6 での enum animal 型の変数 selected には, select 関数が返した int 型の値が代入されています.

取りうる値を特定範囲の整数値に制限された列挙型の変数に, その範囲外の値を代入しようとするとどうなるでしょうか? たとえば,List 8-6 の enum animal 型の変数 selected に, 整数の 100 を代入しようとすると,どうなるでしょうか? C言語の規格では,これは誤りではありません. ただし,コンパイラによっては警告メッセージを出します.

大学の演習室の環境で,List 8-6 を clang でコンパイルすると, 下図のように「列挙定数 Invalid が switch 文で使われていない」という警告が出ます. これは誤りではないので,実行ファイルは作成され, 問題なく動作します.

warning message when compiling List 8-6

警告が出ないようにするためには, switch 文での case に Invalid を追加するか, 該当する case がない場合に実行される default を追加します.

adding Invalid case to List 8-6 switch sentence

adding default to List 8-6 switch sentence

列挙定数

列挙定数は第5章のオブジェクト形式マクロで学習した記号定数に似ています.


#define NUMBER 5

というプリプロセッサの命令により, NUMBER という記号定数を定義できました. 記号定数はいくつでも定義できるので, p.239(第1版 p.223)で述べられているように, 1年の12の月を表す記号定数を


#define JANUARY 1
#define FEBRUARY 2
/* --- 中略 --- */
#define DECEMBER 12

と定義することができます.しかし,テキストでも述べられているように, これは宣言が何行にもわたり,数値もいちいち与える必要があります. そこで,


enum month {JANUARY, FUBRUARY, MARRCH, APRIL, MAY, JUNE,
  JULY, AUGUST, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER}

と enum month 型を宣言すれば,各月に整数値が自動的に割り当てられます. 1月に割り当てる整数を 0 でなく 1 にしたければ,


enum month {JANUARY = 1, FUBRUARY, MARRCH, APRIL, MAY, JUNE,
  JULY, AUGUST, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER}

とします.

名前空間

このセクションについての補足説明は現在のところありません.

8-4 再帰的な関数

関数と型

テキストの説明では, 再帰的定義(recursive definition)とは何か, 少しあいまいでとらえどころがないと思えるかもしれません. もう少しだけ厳密な定義を述べておきます. 以下の方法による定義を再帰的定義と呼びます.

  1. 定義の準備として,自然数全体,あるいは, 有限個の自然数と同じ大きさの集合 X を考えます. これはつまり,各要素に番号をつけることのできる集合です. 自然数そのもの,あるいはその一部でもいいです.
  2. 定義の最初のステップでは,集合 X の要素のうちいくつか(典型的にはひとつ)について, 概念(正確には,別の集合 Y への関数)を定義します. たとえば,0 以上の整数の集合を考え, その要素である 0 について, 階乗 0 ! 0 ! = 1 と定義します. 関数として表現すると, f 0 = 1 となります.
  3. 次に,その他の要素(すべてでなくともよい)について, すでに定義が行われた要素を用いて定義を行います. このステップを「再帰ステップ」と呼びます. たとえば, 1 ! 1 × 0 ! と定義します. 同様に, 2 ! 2 × 1 ! と定義します. 一般に,自然数 n について, f n = n × f n - 1 と定義されます.
  4. 最後に,必要であれば概念を限定します. たとえば,偶数は, (0) 整数の集合を考える, (1) 0 は偶数である, (2) 偶数に 2 を加減したものは偶数である, と定義できますね. しかし,(1) と (2) は「偶数」を「整数」に置き換えても成立してしまいますので, 偶数は整数と何が違うのかを明示してやる必要があります.そこで, (3) 以上の操作で構成できるものだけが偶数である, という限定をしておきます. 言い換えれば,整数の集合のうち, (1) と (2) で言及されなかった要素は偶数ではないと明示したわけです.

テキストでの自然数の定義は, 自然数という概念がすでに強固にあるので, 再帰的定義としてとらえにくいかもしれません. ここで述べられている自然数の再帰的定義を厳密に述べると, 「ペアノの公理」と呼ばれる,自然数の定義になります.

再帰的定義の方法から, 高校数学で学習する数学的帰納法を連想した人は多いのではないでしょうか? 実際,再帰的定義のことを 帰納的定義(inductive definition)とも呼びます.

階乗値

階乗の再帰的定義では, 0 ! を最初に定義し,次に 1 以上の整数について階乗を再帰的に定義しました. List 8-7 の factorial 関数は階乗の再帰的定義を利用していますが, 少し違いがあります. それは, n = 0 から出発して階乗を順に求めていく前に, 再帰関数呼び出し(recursive function call)を行っていることです. 階乗の再帰的定義が n = 0 から「前に進んでいく」だけであるのに対して, 再帰的な関数は n = 0 まで「戻ってきてから,引き返していく」という動作をします. テキストの Fig. 8-10 を見てください. 階乗の再帰的定義と一致しているプロセスは, 返却値の矢印で表現されている部分です. その前に,引数の値をひとつずつ減らしながら, factorial 関数が繰り返し呼び出しされています. このことを,「"自分と同じ関数"の呼び出し」(第1版 p.227,第2版 p.243)と表現しています. この呼び出しは引数が 0 となったときに終わります. テキストの説明をよく読んで,再帰的な関数の動作をしっかりと理解してください.

再帰関数呼び出しはどこかで必ず止まらないといけません. プログラムをミスすると,呼び出しを延々と続けることになるかもしれません. 第6章で学習したように,もしプログラムが止まらなくなってしまったら, ターミナルで,コントロールキーを押しながら [C] キーを同時に押してください. プロセスを止めることができます

演習 8-8 は ユークリッドの互除法(Euclidean Algorithm)を用いて, 2つの整数の最大公約数を求めています. このアルゴリズムについて,少し説明しておきます.

最大公約数を求めたい2つの整数のうち,大きい方の整数を a , 小さい方の整数を b とします.整数 a b で割った商を Q , あまりを r とします.すなわち, a = b Q + r です.ユークリッドの互除法は,整数 a b の最大公約数 G は, b r の最大公約数 G と一致する,という事実を利用します. つまり, a b の最大公約数を求めるためには, b r の最大公約数を求めればいいわけです. このプロセスは再帰的に続きます.すなわち, b r を,あらためて a b とすれば,また割り算を実行して,次の b r が求められます.この操作はあまりが 0 となるまで続きます. あまりが 0 となったとき,その2数のうち小さい方の数が, もとの2数の最大公約数になります. 演習 8-8 では,最大公約数を求める2つの数は, 22 8 8 6 6 2 と更新されていきます.ここで,6 は 2 で割り切れるので, もとの2数(22 と 8)の最大公約数は 2 とわかります.

整数 a b の最大公約数 G は, b r の最大公約数 G と一致する,という事実を証明しておきます. 整数 a b の最大公約数を G とすると, a = G a b = G b と書けます. 整数 a b で割った商を Q , あまりを r とすると, a = b Q + r です.この式の a b a = G a および b = G b を代入すると, G a = G b Q + r より, r = G ( a - b Q ) となります.したがって,整数 a b の最大公約数 G は,整数 b r の約数でもあることがわかります. ただし,最大公約数であるかどうかはわかりません. 整数 b r の最大公約数を G とすると, G G となります.ここまでと同様の議論を, a b からではなく, b r から始めてみます.整数 b r の最大公約数を G とすると, b = G b および r = G r となります.これらを a = b Q + r に代入して整理すると, a = G ( b Q + r ) となります.したがって,整数 b r の最大公約数 G は,整数 a b の約数でもあることがわかります. ただし,最大公約数であるかどうかはわかりません. したがって, G G となります.先に G G は示しました.よって, G = G となります.

8-5 入出力と文字

getchar 関数と EOF

テキスト p.244(第1版 p.228)の getchar 関数の返却値の説明で, 「読み込んだ文字を返す」と書いてあるのは, 「読み込んだ文字の文字コードを返す」ということです. 文字コードについてはテキストの p.248(第1版 p.232)で説明されます. 文字コードは整数値ですので, getchar 関数の返却値の型は int 型になります. List 8-8 での変数 ch のように, この関数が返す値(文字コード)を入れておく変数は int 型にします. 文字だからといって char 型にしてしまうと, 負の値である EOF が返されたときに困ることがあります.処理系によっては, char 型の表現範囲が 0 から 255 になっていることがあるからです.

多くの Unix 系 OS での標準の設定では, ソースプログラムのコンパイル時に読み込まれるヘッダ <stdio.h> は, ヘッダファイル


/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h

です(以前は /usr/include/stdio.h でした). 大学の演習室の環境(macOS)では,ヘッダファイル stdio.h の中で, EOF の値は -1 として定義されています. 第6章の補足説明で紹介した grep コマンドで EOF の定義を見たのが下図です.

#define EOF (-1)

LIst 8-8 の実行で文字列(たとえば,Hello!)を入力したら, [Return/Enter] キーを押してください.これを行うまで, キーボードから入力された文字列はバッファリング(p.231 の Column 8-4 を参照)されています.

List 8-8 の実行結果を見ると,入力された文字列がそのまま返されていますが, getchar 関数は「文字列」をまとめて読み込んでいるのではなく, 「1文字」の読み込みと出力を繰り返しているのだということに注意してください. たとえば,Hello! と入力すると,H を読み込んで H を出力し, 次に e を読み込んで e を出力するということを行っています.

List 8-8 の実行を終えるには, 大学の演習室の環境(macOS)では [Ctrl] キーを押しながら [D] キーを押してください. この操作は EOF の値を入力するため,while ループを抜けてプログラムが終わります.

Mac OS X のターミナルアプリケーションでは, [Ctrl] キーを押しながら [Z] キーを押す操作は, 画面上で実行されている処理(この場合は, List 8-8 の実行)を一時停止する指令を意味します. 一時停止させた処理を再開するには, jobs コマンドを使って停止された処理(ジョブ)の番号を調べ, fg コマンドで再開します.下図では, 実行プログラム list0808 の処理番号が1番だった([1] と表示されています)ので,


fg 1

として処理を再開しています. List 8-8 では,中断された処理を再開すると, プログラムの実行が終了した状態になります.

fg command

入力から出力へのコピー

List 8-8 での代入と比較を制御式にまとめた while 文のかわりに, 無限ループを使った while 文の別解が紹介されています. もし for 文の無限ループを使いたければ以下のように書きます.


for ( ; ; ) {
  ch = getchar();
  if (ch == EOF) {
    break;
  }
  putchar(ch);
}

プログラムをミスすると,無限ループが止まらないかもしれません. もしプログラムが止まらなくなってしまったら, ターミナルで,コントロールキーを押しながら [C] キーを同時に押してください. プロセスを止めることができます

数字文字のカウント

第3章で学習したように, switch 文の case ラベルの値に使うことができるのは整数の定数です. C 言語では,単一の文字を int 型の定数として扱うので(次のセクションを参照), 文字をラベルの値に使うことができます. List 8-9 では数字文字を使っています.

List 8-8 を使って, Column 8-4 で解説されている リダイレクト を試してみましょう. 適当なテキストファイルを用意します. たとえば,Emacs などのエディタで


test

test

と書いて, test.txt という名前で List 8-8 の実行プログラムと同じ場所に保存します. 実行プログラムの名前が list0808 であるとします. テキストファイル test.txt を入力とし, test.out というファイル名で出力することにします. ターミナルで


list0808 < test.txt > test.out

と入力してください. テキストファイルの内容を表示する cat コマンドを使って,


cat test.out

とターミナルに入力すると,test.out の内容を確認できます. 下図に示すように, 入力した test.txt と同一内容であることがわかるでしょう.

Column 8-4 redirect

文字コードと数字文字

コンピュータの内部で文字を扱うために, 文字にはそれぞれ 文字コード(character code) という非負の整数値が割り振られています. 文字コードの体系にはいくつかの種類があります. テキスト p.248(第1版 p.232)には JIS コードのコード表が示されています. この文字コード体系では, たとえば,大文字の A は10進数の 65 です. Mac OS では ASCII (American Standard Code for Information Interchange) という文字コードが用いられています. ASCII でも,JISコードと同じく, 数字文字 '0' には10進数の 48 という値が割り当てられています.

拡張表記

二重引用符を文字列の中で使用するとき, 拡張表記を使う理由は明確です. たとえば,"AB\"C" と表記すべきところを "AB"C" と書くと, "AB" で文字列が終わってしまいます. 単一引用符を文字定数として使うときに拡張表記が必要な理由も同様です.

まとめ

まとめの最後にあるプログラム summary2.c では, main 関数の中で2つの if 文を使っています.

最初の if 文は,数字文字が入力されたとき, その文字に対応する数値を合計していきます. ここで数字文字から数値を得る ch - '0' という演算は p.249(第1版 p.233)で学習しました.

次の if 文は入力された文字をそのまま出力して改行します. ただし,改行文字が入力されたときだけ, 警報を発して改行します. 入力された文字を出力して改行するという動作は else の後に, 改行文字が入力されたときの動作は if の後に書かれています.