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

【計算機序論2・実習(2013年度)】 目次, 計算機序論2, 授業科目, www.kameda-lab.org 2014/01/13h

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

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

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

概念設計が済むと、それをC言語に翻訳していく作業となります。
(丁寧に開発する時には、概念設計の次に詳細設計をします。)
(概念設計も詳細設計も、「日本語で(人間の言語で)」書くのが普通です。)
(ところで、「人間の言語(日本語、英語、‥)」とは一般に計算機科学では総称して何と呼ばれてるのでしょうね?習いました?)

長い手順になると、main()関数だけで書こうとすると無理がでてきます。
(例えば"自走式マイクロロボットの製作法"という本があったとして、それが1ページから最後まで何の章・節の区切目もなく書かれていたら、読む方はぞっとしますよね。)

アルゴリズムがきちんと分割されていれば、その反映であるプログラムの可読性も大いに向上します。
(大前提として、概念設計において、きちんと作業上意味が区切れているところでアルゴリズムの分割が行われている、という条件が必要ですが‥)
C言語では、作業の分割は関数によって実現されます。

関数を使って作業を分割し、サブルーチン化することで、プログラムの開発効率を上げること(可読性向上、情報隠蔽、プログラム部品再利用)ができるようになります。
関数によるサブルーチン化は、人間がアルゴリズムをプログラミング言語で記述する上で必須の技術です。
(このことは、日本語で作業手順書を章立てしたり、項目ごとに分けて整理して記述すしたりするのと変わりません。)
(だらだらとした手順書だと、あとで作業を追加したいときに、どこに挿入してよいか探すのが一苦労ですよね?)
(プログラムでも全く同じことが言えます。)

サブルーチン化することで、プログラムを書くときに、そのサブルーチンを使う側はその作業の詳細まで考えなくてもよくなります。
これを情報隠蔽と呼びます。
(プログラミング言語においては「情報隠蔽」は褒められるべき良い意味で使われます。)

この章では、「簡易電卓」プログラムを例として考えていきます。


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

簡易電卓プログラムでは、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 Error

これはソフトウェア工学においては、プログラムテスト・ソフトウェアテストと呼ばれる段階です。

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

演習

03-02-ex1: fgets()、sscanf()、strcmp()の man ページを端末上で表示しなさい。

03-02-ex2: fgets()、sscanf()、strcmp()のそれぞれに必要なヘッダファイルを示しなさい。

03-02-ex3: 上述の「処理例」によるプログラムテストでは十分なテストができているとは言えない。より十分で厳密なテストをするための方策について述べなさい。


03.02. fgets()の試運転

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

第3引数には、通常fopen()の返値であるファイルディスクリプタを使います。
ただ、ここでは標準入力を使うので、標準入力を表す特別なディスクリプタstdinを指定します。
(これに限らず、Unixではファイル以外のものもファイルに見せかけて操作する事例が結構あります)
(例えばデータストリームとか、物理デバイスなども、ファイルに見せかけて操作します)

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

Linuxでは、「読み取る1行分」には、改行文字('\n')自身も含まれます。
それゆえに、実質的には1度に1行で読み込める文字数は、用意したバッファのバイト数-1('\n')-1('\0')ということになります。
(「え、じゃあ最初から第2引数を廃止して、第3引数で用意されたバイト数を自動的に調べて、それ以上アクセスしないような新しいfgets_advanced()みたいなの用意すれば?」と思い至ることができた貴方は優秀です)
(ただ、残念ながらC言語には「第1引数で指定された」文字列ポインタから、そこで何バイトがあるかを確実に知る方法がないのですね。)
(気になる人は、文字列へのポインタ"char *"と固定文字列へのポインタ"const char*"と"char *"との違いを調べてみましょう)

03-02-fgets.c

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

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

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

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

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

03-02-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-02-ex2: 上記03-02-fgets-BufferOverRun.cでは、3変数のアドレスを表示しようとしています。アドレスは何バイトで表記されているか、プログラムを改変して調査しなさい。

03-02-ex3: 上記03-02-ex2のバイト数に従うと、プログラム内では何バイトのアドレス空間を管理できますか。2のべき乗で解答しなさい。

03-02-ex4: 上記03-02-ex3の解答を10のべき乗で表記しなさい。有効精度3桁とします。

03-02-ex5: 上記03-02-ex3の解答バイト分を、現在の市場価格で購入するといくらになりますか。市場価格の根拠(Webページのコピーなどで構いません)と、計算式とともに回答しなさい。
(実際にそれだけのメモリを搭載できるコンピュータがあるかどうかは問いません)

03-02-ex6: 上記03-02-fgets-BufferOverRun.cで、実際にバッファオーバーランが起こるようにプログラムを改変しなさい。またその実行結果を示しなさい。
(プログラムで表示しているi, oneline, dummylineのアドレスを見て、重なるようにプログラム上での変数の宣言順を変更してみましょう)


03.03. sscanf()の試運転

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

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

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

演習

03-03-ex1: C言語で文字列の終端はどのように表現されるか述べなさい。

03-03-ex2: ここで使うコンパイル環境での、'\0'の実際の整数値を、テスト用プログラムを作成して実際に調べなさい。

03-03-ex3: NULLポインタとは何か説明しなさい。

03-03-ex4: ここで使うコンパイル環境での、NULLの実際の整数値を、テスト用プログラムを作成して実際に調べなさい。

03-03-ex5: sscanf()で1つの文字列変数に「空白文字込のフレーズ」(例:"hello world")を渡すための方法について述べなさい。


03.04. 簡易電卓

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

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

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

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

03-04-SimpleCalculator.c
03-03からの差分

演習

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

03-04-ex2: 上記の簡易電卓プログラムにて、numelements や resultvalue 変数の定義時に初期値を与えている。
・実はこの初期値に意味はない。
・では、なぜ初期値を与えているのであろうか?理由を推測してみなさい。
(多分にプログラマの性格が反映されるような回答でしょうね)


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