3. 関数利用によるプログラムの構造化

【計算機序論2・実習(2012年度)】 目次, 計算機序論2, 授業科目, www.kameda-lab.org 2012/10/29c

本節では、大規模なプログラム作成に必要な技術を学習してもらいます。
例として、標準入力から数値を読み取って計算する電卓プログラムを考えます。

大規模なプログラムを一度に書き切るのは無理ですから、作業を分割して考える必要があります。

プログラムはアルゴリズムの反映ですから、まずアルゴリズムをよく考えて、作業をどこで区切れば作業概念としてわかりやすいかを考える必要があります。
この部分は概念設計と呼ばれます。
(最初はメモ書きでも構いませんが、とにかく形にすることを勧めます。)

概念設計が済むと、それをC言語に翻訳していく作業となります。
長い手順になると、main()関数だけで書くことには無理がでてきます。
C言語では、作業の分割は関数によって実現されます。

関数を使って作業を分割し、サブルーチン化することで、プログラムの開発効率を上げること(プログラム部品の再利用)ができるようになります。
また、作業の区切れごとにサブルーチン化することで、プログラムを書くときに、逐一詳細まで記述しなくてもよくなります(情報隠蔽)。
この情報隠蔽もまた、プログラミング開発効率で重要です。

関数によるサブルーチン化は、人間がアルゴリズムをプログラミング言語で記述する上で必須の技術です。
(このことは、日本語で作業手順書を章立てしたり項目ごとに分けて整理して記述するのと変わりません。)
(だらだらとした手順書では、例えばあとで作業を追加したいときに、どこに挿入してよいか探すのも一苦労ですよね?)
(プログラムでも全く同じことが言えます。)


03.1. 簡易電卓プログラムの設計

簡易電卓プログラムでは、1回について1つの加減乗除のみを実行できるプログラムを目標とします。

アルゴリズムを考える際には、大局的な立場から作業を分割していくと、間違いや二度手間を避けやすくなります。

【処理1】数値、演算子、数値の3つ組を標準入力より1行でまとめて受取
【処理2】入力が正当かどうかを確認
【処理3】演算子に合わせて計算
【処理4】計算結果を表示
【処理5】終了の合図があるまで処理1〜処理4を繰返

注意点
(1)数値は浮動小数点を含み、負の値も認める
(2)演算子は加減乗除の4種類
(3)数値と演算子との間は1つ以上のスペースで区切る
(4)計算時にはエラーチェックも行う

処理例
入力計算結果
3.4 + 1.25.6
-2.2 - -2 -0.2
-2 * -3 6
-0.3 / 0.17 -1.7647058823
3 / 0 エラー

このプログラムでは、
fgets()関数
sscanf()関数
strcmp()関数
という3つの新しいライブラリ関数を用います。

演習

03-02-ex1: fgets()、sscanf()、strcmp()の man ページを端末上で表示しなさい。
03-02-ex2: fgets()、sscanf()、strcmp()のそれぞれに必要なヘッダファイルを示しなさい。


03.2. fgets()の試運転

fgets()関数は、ファイルディスクリプタ(第1引数)で示されるファイルから1行分の入力を読み取って、それを第3引数で示されるメモリ空間にコピーしてくれる関数です。

第1引数には、通常fopen()の返値であるファイルディスクリプタを使います。
ただ、ここでは標準入力を使うので、標準入力を表す特別なディスクリプタstdinを指定します。

fgets()は、読み取った1行分のデータを第3引数で指定されたメモリ空間にコピーするとき、第2引数で指定したバイト数までしかコピーをしません。
なおかつ、コピーしたバイト列の最後に'\0'を書き込んで、文字列の終端としてくれます。
つまり、第3引数用に用意するメモリ空間のバイト数-1を第2引数に与えておくとバッファオーバーランせずに済みます。

「読み取る1行分」には、改行文字('\n')自身も含まれます。
それゆえに、実質的には1度に1行で読み込める文字数は、用意したバッファのバイト数-1('\n')-1('\0')ということになります。

03-2-fgets.c

まずはこのプログラムを読んでから、コンパイルして実行してみましょう。
このプログラムでは、fgets()の第2引数を、第3引数のsizeof()で求めることでサイズを一致させています。
sizeof()は、固定長配列名に適用した場合には、その総バイト数を返してくれます。

このプログラムでは敢えて用意するバッファ(oneline)を8バイトしか確保してません。
つまり、 8-1-1 = 6 バイトを越えると、バッファサイズ限界に達して、もう一度 while文 が回ってその先の文字列を再度取得しに行くことになります。
(while文が回ったかどうかは、"Total: ..."という行が表示されるかどうかで分かりますね)

また、文字列の最後のバイトの表示が常に改行になっていることもわかると思います(改行文字もユーザが入力した文字です!)。

なお、電卓入力受付中に、Ctrl+c以外で修了させたい時は、Ctrl+dを押しましょう。
Ctrl+dは端末で強制的にeof(end of file)という特殊命令を発生させます。
(sttyというコマンドで制御できます。man sttyして、Special charactersのセクション参照。一覧はstty -aと実行。)

余談
ではfgets()の第2引数と第3引数を意図的にずらすと、本当にバッファオーバーランが起きるのでしょうか?
これはコンパイラの挙動にも依存するので必ずとは言えませんが、次のプログラムは、潜在的にバッファオーバーランしてしまうようにできています。

03-2-fgets-BufferOverRun.c

ここでは、dummylineという8バイト文字列配列を確保して、そこに予め"abcdefg"という7文字(8文字目は'\0')を入れてあります。
onelineには8バイトしか確保してないのに、fgets()には4バイト余分にあるように敢えて嘘をついています。
dummylineを途中で操作はしないので、プログラムの最後までこの内容は変更ないはずですが‥さて、どうなりますか?
この、踏み潰されかねない4バイトのはみ出し領域に別の変数が存在すると‥
(実際にどうバッファオーバーランが起きるかはコンパイラに依るので、指定環境以外で起きなくても責任?は負いかねます)
(プログラムで表示しているi, oneline, dummylineのアドレスをみれば何が起きそうか見当はつくことでしょう)
(ちなみにfのつかないgets()関数はfgets()とほぼ同じ操作を標準入力に対して行う関数ですが、悪名高いことで有名です。これのためにあちこちで昔から多数のバグが‥皆さんは必ずfgets()を使うように)
(悪名が高い理由はgets()のmanページに書いてあります)

演習

03-02-ex1: gets()関数には致命的な欠陥があります。その欠陥を説明しなさい。


03.3. sscanf()の試運転

標準入力に対するscanf()はすでに習っていると思います。
sscanf()はその親戚で、標準入力に対してではなく、指定した文字配列に対して同じことをします。
scanf()もsscanf()も返値によって、何個読み込みに成功したか正確にわかるので、それによってエラー処理を行うことができます。
(というか、エラー処理すべきです。ユーザは何をしてくるかわかりませんから。)
引数の間のスペースを忘れると期待通りに動かないので、気をつけてください。

sscanf()でも潜在的にバッファオーバーランの危険はあります。
文字列を読み込むときには、用意したバッファのバイト数-1以下の数字を、%sのところで指定するように。
下の例では、operator[5]には5バイトしか用意してないので、%4sとして、バッファオーバーランを防ぎます。

03-3-sscanf.c
03-2(バグなし)からの差分

演習

03-03-ex1: (C言語で)文字列の終端はどのように処理されるか述べなさい。
03-03-ex2: ここで使うコンパイル環境での、'\0'の実際の整数値を、テスト用プログラムを作成して実際に調べなさい。
03-03-ex3: NULLポイントとは何か説明しなさい。
03-03-ex4: ここで使うコンパイル環境での、NULLの実際の整数値を、テスト用プログラムを作成して実際に調べなさい。


03.4. 簡易電卓

ここまでくれば、あとは演算子ごとに計算をするだけです。
この異なる演算に対して、それぞれ別のユーザ定義関数を割り当てて作成しておきます。

ユーザが指定した演算子が何と一致するかをstrcmp()関数で確認して、対応するユーザ定義関数を呼び出します。
このとき、除算だけは0で割り算をしないように、内部で条件分岐を設けます。
呼び出しが済んだら、演算結果を表示してその回は終了です。

このようなプログラムであれば、将来演算子を増やしたくなった時に、それまでの演算子と同じ要領でプログラムを拡張すればすぐに対応できます。
(まだ美しいとは言えませんが、教える内容とのバランスから言えば今はこれぐらいが限界かと。)
実際ここでは abs という演算子を追加してみました。
これは、2つの数字の差の絶対値を取ってくれるものです。

皆さんも同じ要領で、max という演算子を追加してみてください。
これは2つの数字のうち、大きい方の値を教えてくれる演算子です。
(さらに同じ要領で min もできますね)

03-4-SimpleCalculator.c
03-3からの差分

演習

03-04-ex1: 上記の簡易電卓プログラムに以下の機能拡張をしなさい。
・演算子 "max" 。その前の数値Aとその後の数値Bに対して、"A max B" で AかBの数値のうち大きい方を求める。
・演算子 "min" 。その前の数値Aとその後の数値Bに対して、"A min B" で AかBの数値のうち小さい方を求める。
・これらの演算子に対応する実装は、演算子 abs と同様に、ソースファイル内で関数として記述すること。


kameda[at]iit.tsukuba.ac.jp.