プログラムをどこから作り始めるか

課題プログラムの理解(2) : 目標を分解し,さらに小さな目標を立てよう

(参考:教科書K&R 4.1節)

前回の練習1-1の最後で,以下のように目標を立てました.

プログラミング演習1の前半(第1回~第3回)の目標は,

「キーボード入力を何度も受け付けて,CSV入力と%コマンドを見分けて,何らかの処理をするプログラム」

の作成にしておきましょう.

まずは,メインの処理『キーボード入力を何度も受け付けてxxxをするプログラム』を言語化してみましょう.例えば,以下のような形が,ぱっと思いつきます.

while (標準入力から1行読み込む → 成功) {
    読み込んだ行を処理する;
} // 繰り返し

さて,もう少しプログラムを進めておきたいところです.

前回の動作例を振り返ってみると,CSV入力されたテキストについては,コンマで分割し,分割によって得られる各文字列を情報として得る必要がありそうです.適切に分割した情報をプログラム内で表現できていなければ,%Pで得られる表示のような整形ができなさそうですから.

そこで,「読み込んだ行を処理する」として,「読み込んだ行をコンマで文字列に分割する」と「(確認のために)分割された各文字列を表示する」という処理をさせることにします.

以上を踏まえると,およそ以下のような構造を持った小さなプログラムを書くことになりそうです.

/***** main *****/
while (標準入力から1行読み込む → 成功) {   /* → get_line()関数 */
    読み込んだ行を処理する;
} // 繰り返し

/***** 読み込んだ行を処理する *****/
読み込んだ行をコンマで文字列に分割する;     /* → split()関数 */
(確認のために)分割された各文字列を表示する;

プログラム上の変数とメモリへの理解を深めよう

ポインタと配列の違い

説明のため,前回示したソースコードの例とFigure 4を再掲します.

1
2
3
char *p  = "ABC";
char s[] = "DEF";
int  a   = 3;
../_images/p01-pointer-table4_re.svg

まずは,前回保留としていた&s[0]&sの違いについて理解しておきましょう.両者は,同じ2008というアドレスとしての値を持っていますが,型(ポインタとしての性質)において違いがあります.

&s     // : 配列 char[4] の先頭を指すポインタ
&s[0]  // : 文字 char    を指すポインタ

両者の違いは,「ポインタの指す先に何(どんな型)」があるか,です.ポインタは,何を指すポインタであるかによって挙動が違います.例えば,あるポインタptr+1すると,ptrが指している物(オブジェクト)の次の位置を示すアドレスを返します.具体的には,ある計算機環境では,以下の数式が成り立ちます.

表記

返す値

式の型

解釈

&s[0] + 1

2008 + 1 = 2009

char*

charは1バイト

&s + 1

2008 + 4 = 2012

char (*)[4]

char[4]は 4バイト

p + 1

2000 + 1 = 2001

char*

charは 1バイト

&p + 1

2004 + 4 = 2008

char**

char*は,4バイト

&a + 1

2012 + 4 = 2016

int*

intは 4バイト

注釈

2行目の書き方については,教科書のp.137やp.149も参照してください.

注釈

ポインタ型のサイズ(バイト数)は,実行する計算機によって異なる場合があります.上記は,あくまで例であり,演習室環境や皆さんの自習環境でも成り立つとは限りません.

この性質は,s[n] == *(s + n)の置き換えが成り立つのに必要な性質です.sint s[10]char s[10]の場合で考えてみるといいでしょう.

つまり,型Xがメモリ中でnバイトを占める場合,Xへのポインタは,+1したときに+nだけ増加します.これは,型Xのオブジェクト1個をメモリに格納したい場合,nバイトだけのメモリ確保が必要だから,です.

nを知る方法として,sizeof(X)という演算子が用意されています.

QUIZ(参考):

以下のprintf()内で行われている計算式で得られる数値は何を表しているか,説明できますか?

char s[4];
int  n[4];

printf("%d\n", sizeof(s) / sizeof(s[0]) );
printf("%d\n", sizeof(n) / sizeof(n[0]) );

文字列とポインタ演算

続いて,文字列とポインタ演算への理解を深めておきましょう.

あらかじめ,

$ man 3 strlen

で,strlen()関数の仕様を調べてください.

仕様だけ読んでもいまいちピンと来ないな,という人は,教科書K&R p.48やp.126の実装例も,参考にしてください.

調べ終わったら,以下の例題プログラムについて,考えてみてください.

Listing 5 ex2-1.c : 文字列操作の復習(2)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <stdio.h>
#include <string.h>

int main()
{                // 01234567
    char *p, s[] = "AB,CD,EF";       // ← (0)

    printf("(1) [%s]\n", s);         // ← (1)
    printf("(2) [%d]\n", strlen(s)); // ← (2) ※環境によっては,%d --> %ld
    printf("(3) [%d]\n", sizeof(s)); // ← (3)   以下,同様
    printf("----\n");

    p = s;
    printf("(4) [%s]\n", p);         // ← (4)
    printf("(5) [%s]\n", p++);       // ← (5)
    printf("(6) [%s]\n", ++p);       // ← (6)
    printf("----\n");

    p = s;
    printf("(7) [%c]\n", *p++);      // ← (7)
    printf("(8) [%c]\n", *++p);      // ← (8)
    printf("(9) [%c]\n", *s);        // ← (9)
    printf("----\n");

    p = s; while (*p) { p++; }
    printf("(10) [%d]\n", p-s);      // ← (10)
    p = s; while (*p++);
    printf("(11) [%d]\n", p-s);      // ← (11)
    p = s; while (*++p);
    printf("(12) [%d]\n", p-s);      // ← (12)
    printf("----\n");

    printf("(13) [%c]\n", s[2]);     // ← (13)
    printf("(14) [%s]\n", &s[3]);    // ← (14)
    printf("(15) [%d]\n", strlen(&s[3])); // ← (15)
    printf("----\n");

    p = s + 6; *p = '\n';
    printf("(16) [%s]\n", s);        // ← (16)
    s[2] = '\0';
    printf("(17) [%s]\n", &s[0]);    // ← (17)
    printf("(18) [%s]\n", &s[3]);    // ← (18)
    printf("----\n");

    printf("(19) [%d]\n", strlen(s)); // ← (19)
    printf("(20) [%d]\n", sizeof(s)); // ← (20)
    printf("(21) [%d]\n", strlen(p)); // ← (21)
    printf("(22) [%d]\n", sizeof(p)); // ← (22)
    printf("----\n");
}

コメントで記した (1)~(22) の各printf()で表示される文字列を考えてください.一部は,講義時間中に答えてもらうかもしれません.

以下のように,メモリ内を図示しながら考えることも重要です.途中で書き換え処理が入っていることも忘れずに.

../_images/p02-ex2-1_table6v2.svg

Figure 5 メモリ配置を考えるためのテンプレート(s のマスの数は実際のプログラムに合わせて書き換える必要がある.)

さて,実際の動作結果は,プログラムを実行すれば分かります.メモリ配置の図を使いながら,なぜそのような結果が得られたが,説明できるようにしてください.

文字列操作関数 split() の実装に関する検討

練習2-1として掲載した,split()関数の仕様をまずは把握してください.

int split(char *str, char *ret[], char sep, int max);

この関数は,以下の例のような呼び出しによって,分割された文字列を得ます.

Listing 6 split()の利用例
char *col[3]; // 3つの char* を格納する配列. (col は columnsの略)
              // ここに分割後の各文字列の先頭へのポインタが入る.
char csv[] = "A,B,C";
int n;

n = split(csv, col, ',', 3);

// check split result
printf("got %d piece(s)\n", n);     // got 3 piece(s)
printf("RET[0] = '%s'\n", col[0]);  // RET[0] = 'A'
printf("RET[1] = '%s'\n", col[1]);  // RET[1] = 'B'
printf("RET[2] = '%s'\n", col[2]);  // RET[2] = 'C'
printf("-----\n");

上記例における,split()が呼び出された直後のメモリの状態を示しておきます.

図の左半分はsplit()呼び出しによって確保されたメモリ空間です.したがって,図面左側に描かれたメモリに保持されたデータは,split()呼び出しが終了した時点ですべて破棄されることに注意してください.(参照:Listing 4

../_images/p02-split-pointer-table1v4.svg

Figure 6 split の呼び出し直後

split() の実装案(1): やや難

結果として利用したいcol[]にのみ注目すると,split()実行後には,例えば,次のようになっていれば,良さそうです.

../_images/p02-split1-result-table1.svg

Figure 7 実装案(1)によるsplit 呼び出し後のメモリの様子

この形を目指すならば,さらにmalloc()を用いて右半分に示したようなメモリ(6000, 700, 8000番地相当)を確保する,そして,不要になったらfree()を使ってメモリを開放する操作が必要になります.少し難しいかもしれませんね.

別の案も考えてみましょう.

split() の実装案(2): 容易

プログラム例Listing 5を思い出してみてください.

split()実行後に,Figure 8の右側のようなメモリ配置にすることができていれば,col[]を利用して分割された文字列を得ることができそうです.

col[]は,char型ポインタの配列(char*の配列)ですから,col[0],col[1], ... は,それぞれchar*,つまり,それぞれを文字列として扱えますね.図のようなメモリ配置であれば,printf("%s", col[0])の実行結果としてAが表示されるでしょう.

また,図を使えば,printf("%s", col)や,printf("%s", &col[0])では,所望の結果が得られないことも,簡単に説明できますね.(そもそも,コンパイラも警告を出しているはずです.)

../_images/p02-split2-result-table1v3.svg

Figure 8 実装案(2)による split 呼び出し前後のメモリの変化

split()実行後,呼び出し元(今回の例ならmain()関数)に戻る時点で,仮引数であるstr,ret,sep,maxにはメモリが割当てられていないことに注意してください.

一方,呼び出し時に実引数として与えたcsv[]col[]は,呼び出し元の変数ですから,これらは,split()実行後も,問題なく参照することができます.

この実装案は,比較的実装が容易に見えます.まずは,この形で作成してみるのがよさそうですね.前回のsubst()の実装で考えた基本形を思い出しながら,実装してみてください.

1行入力関数 get_line() の実装に関する検討

標準入力から文字列(文字の配列)として読み込む関数は,いくつかあります.

候補1: scanf()

scanf()を1行入力に利用するには,種々の問題があります.詳細は,以下を参照してください:

候補2: read()

UNIXのシステムコール由来のバイト単位での読み込み方法なので,行指向の入力には適していません.

候補3: gets()

gets()は,入力文字列の文字数を制限する機能がありません.したがって,あらかじめ用意したline用のメモリ領域を越えてgets()が文字列を書込む危険性があります.よほどの理由が無い限り,gets()を実際のプログラムに使うべきではありません.

候補4: fgets()

1行入力のための関数です.今回は,指定したバッファ長1024に収まるように1行入力できれば十分ですから,これがぴったりに見えます.

利用方法は,Linuxのmanコマンド,あるいは,教科書K&Rの巻末索引からfgets()を調べ,該当ページを読んでください.

$ man 3 fgets

これ以外にも,1文字入力関数getc(),fgetc(),getchar()を用いて,1文字読み込みを繰り返す方法も考えられます.しかし,やや煩雑になりますので,本講義では触れないことにします.

実際に,教科書K&Rでは,getchar()を用いた1行読み込みのための実装が紹介されています.発展的な課題として取り組みたい人は,異なる実装も試し,比較してみるのも面白いかもしれませんね.

テストの検討

main()関数実装の方針については,練習1-3 文字列操作関数substの作成も参考にしてください.

プログラムを作成したら,キーボードからCSVのテストケースを入力する代わりに,ダウンロードしたsample.csvを入力としてテストしてみるのもよいでしょう.

$ gcc -Wall -o get_line-test get_line-test.c
$ ./get_line-test < sample.csv