C言語の復習とデバッガの利用 - 情報処理IIレジュメ(8)

1. はじめに

来週からの配列の学習に入る前に、昨年度の配列とポインタの復習を行う。 また、私の授業内での表記法についても解説する。

2. 復習用のプログラム

今回は、以下に示すプログラムで配列とポインタの関係について、復習していく。 まず、arrayTest というフォルダを作成する。

mkdir -p $HOME/Documents/arrayTest

ここに arrayTest.c を作成する。タイプするのは大変なので、moodle からダウンロードできるようにしておく。

#include <stdio.h>
#define IN 5
#define DN 3
int calcIntSumAndInt(int *xp, int n, int base);
double calcDoubleSumAndDouble(double *xp, int n, double base);

int main(int argc, const char * argv[]) {
    int a = 3;
    double b = 3.14;
    int c[IN] = {3, 1, 4, 1, 5};
    double d[DN] = {3.14, 1.59, 2.65};
    int e = calcIntSumAndInt(c, IN, a);
    double f = calcDoubleSumAndDouble(d, DN, b);

    printf("e = %d\n", e);
    printf("f = %f\n", f);
    return 0;
}

int calcIntSumAndInt(int *xp, int n, int base) {
    int i;
    int sum = base;
    for (i = 0; i < n; i++) {
        sum += xp[i];
    }
    return sum;
}

double calcDoubleSumAndDouble(double *xp, int n, double base) {
    int i;
    double sum = base;
    for (i = 0; i < n; i++) {
        sum += xp[i];
    }
    return sum;
}

また、前回の fact から Makefile をコピーする。Windows の場合は以下のようにする。

copy ..\fact\Makefile .

macOS の場合は以下のようにする。

cp ../fact/Makefile .

コピーした Makefile の先頭を以下のように変える。今回はテストがないので、TARGET のみを記載する。

# makefile for arrayTest

#### 頻繁に変更が必要なもの
# 実装のためのヘッダー(プロトタイム宣言、構造体宣言、定数定義を含む)
HEADER=

# 作成したい関数が入ったファイル
OBJECTS= 

# 関数のテストが記載されたファイル(3年前期では使わない)
TEST_OBJECTS=

# 最終実行ファイル(名前を修正したら .gitignore も修正すること)
TARGET=arrayTest

# テスト実行ファイル(名前を修正したら .gitignore も修正すること)
TEST_TARGET=

make とすると、arrayTest.c をコンパイルして、結果が表示される。Windows の場合の結果は以下のようになる。

y> make
gcc -Wall -g   -c -o arrayTest.o arrayTest.c
gcc -o arrayTest.exe -Wall -g arrayTest.o  -lm
./arrayTest.exe
e = 17
f = 10.520000

3. 授業での表記方法

3.1 変数の表記方法

私の授業では変数は四角で表現する。変数名は箱の左側に、変数の型は箱の上に記載する。

f:id:hkob:20200531163444p:plain
int a = 3;
f:id:hkob:20200531163451p:plain
double b = 3.14;

また、配列は縦に並べた箱で表現する。配列名は一番上の箱の左に記載する。配列の型は単一の変数の場合と同様に箱の上に記載する。

f:id:hkob:20200531164110p:plain
int c[IN] = {3, 1, 4, 1, 5, 9};
f:id:hkob:20200531164240p:plain
double d[DN] = {3.14, 1.59, 2.65};

3.2 関数の表記方法

私の授業では関数の入出力の関係を四角で表現する。関数名は箱の上に、返り値の型は箱の右側に、引数は型の中に変数として記載する。 この図はプロトタイプ宣言と一対一に対応するので、互いに変換できるようにしておくこと(試験やレジュメでは図だけが書かれている)。

f:id:hkob:20200531165258p:plain
int calcSumAndInt(int *xp, int n, int base);

3.3 関数の呼び出し

関数を呼び出す時には、呼び出し側と関数の引数の対応を意識することが必要である。また、この時のポインタ変数と配列名との関係はどうなっているのか理解しておこう。

f:id:hkob:20200531182226p:plain
int e = calcSumAndInt(c, IN, a);

ここで、配列を関数に渡す場合の流れを地主と小作人を例にしたロールプレイで説明してみる。ここで、地主(main関数)は土地はあるが仕事はしたくない。そのため、小作人に収穫を依頼することにする。一方、小作人は地主から住所(ポインタ)を紙に書いて教えてもらい,その紙を通行手形として地主の土地に入り込む。C 言語では変数は各関数のスコープ内のものにしかアクセスできないが、ポインタという通行手形を使うことで、アクセスを可能とする。

f:id:hkob:20200531184417p:plain
配列とポインタの関係

4. デバッガによる確認

上記の仕組みをデバッガを用いて、確認してみる。デバッガとはプログラムの動作が不明な時に、プログラムの途中で止めたり、変数を表示したりして、プログラムのバグを取り去る作業(デバッグという)をサポートするツールである。

macOS では clang というコンパイラを用いているので、lldb というデバッガを利用する。一方、Windows では gcc というコンパイラを用いているので、gdb というデバッガを利用する。それぞれコマンド体系が異なるので、別々に説明する。ただ、コマンド名が違うくらいなので、表の形で左側に lldb、右側に gdb のコマンドを記載することにする。

4.1 デバッガの起動とブレークポイントの設定

lldb (macOS)、gdb (Windows) 共にコマンドの後ろに実行ファイル名を記載する。

lldb (macOS) gdb(Windows)
lldb arrayTest gdb arrayTest

デバッガが起動したら、プログラムを止めたい場所にブレークポイントを設定する。今回は、main の先頭で止めることにする。コマンドは以下の通りである。

lldb (macOS) gdb(Windows)
breakpoint set --name main b main

ブレークポイントが設定できたら、プログラムを起動する。デバッガのコマンドは基本的に何度もタイプすることになるので、省略形が用意されている。どちらのデバッガでもコマンド名は「run」であるが、省略形「r」で実行できる。

lldb (macOS) gdb(Windows)
r r

macOS の場合には、「r」の後に以下のようなダイアログが表示される。パスワードを入れて、許可すること。認証画面が出た場合、「r」が一度失敗する場合がある。その時には再度「r」を実行する。

f:id:hkob:20200531194239p:plain
確認画面

実行すると変数を確認できる。ローカル変数を全て表示するには以下のコマンドを利用する。

lldb (macOS) gdb(Windows)
fr v i locals

また、個別の変数の値を表示するには、どちらも p コマンドを利用できる。例えば変数 a の値を表示するには以下のようにする。

lldb (macOS) gdb(Windows)
p a p a

macOS の場合のここまでの画面を示しておく。ちゃんと main の頭の部分で止まっていることが確認できる。実行前なので変数には全てゴミが入っている。

hkob@hMBP ~/D/arrayTest> lldb arrayTest
(lldb) target create "arrayTest"
Current executable set to '/Users/hkob/Documents/arrayTest/arrayTest' (x86_64).
(lldb) breakpoint set --name main
Breakpoint 1: where = arrayTest`main + 51 at arrayTest.c:8:9, address = 0x0000000100000db3
(lldb) r
error: process exited with status -1 (Error 1)
(lldb) r
Process 63415 launched: '/Users/hkob/Documents/arrayTest/arrayTest' (x86_64)
Process 63415 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x0000000100000db3 arrayTest`main(argc=1, argv=0x00007ffeefbff688) at arrayTest.c:8:9
   5      double calcDoubleSumAndDouble(double *xp, int n, double base);
   6
   7      int main(int argc, const char * argv[]) {
-> 8         int a = 3;
   9          double b = 3.14;
   10         int c[IN] = {3, 1, 4, 1, 5};
   11         double d[DN] = {3.14, 1.59, 2.65};
Target 0: (arrayTest) stopped.
(lldb) fr v
(int) argc = 1
(const char **) argv = 0x00007ffeefbff688
(int) a = 0
(double) b = 0
(int [5]) c = ([0] = 0, [1] = 0, [2] = 0, [3] = 0, [4] = -272632200)
(double [3]) d = ([0] = 2.1219957909652723E-314, [1] = 0, [2] = 0)
(int) e = 0
(double) f = 0
(lldb) p a
(int) $0 = 0

Windows の db の場合も同様に示しておく。こちらも main() 関数の最初で止まっていることがわかる。

PS C:\Users\hkob> cd .\Documents\arrayTest\
PS C:\Users\hkob\Documents\arrayTest> gdb arrayTest
GNU gdb (GDB) 8.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-w64-mingw32".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from arrayTest...done.
(gdb) b main
Breakpoint 1 at 0x401564: file arrayTest.c, line 8.
(gdb) run
Starting program: C:\Users\hkob\Documents\arrayTest\arrayTest.exe
[New Thread 10776.0x2a1c]
[New Thread 10776.0x2a20]

Thread 1 hit Breakpoint 1, main (argc=1, argv=0x6f14c0) at arrayTest.c:8
8           int a = 3;
(gdb) i locals
a = 0
b = 3.5967346613216072e-317
c = {0, 0, 0, 0, 8}
d = {3.9525251667299724e-323, 0, 2.075170573137237e-317}
e = 0
f = 3.9525251667299724e-323
(gdb) p a
$1 = 0

4.2 ステップ実行と変数変更の確認

1行だけ実行するには「skip」コマンドの省略形である「s」をタイプする。その後、「p a」で変数 a が変わったことを確認する。

lldb (macOS) gdb(Windows)
s s
p a p a

同様に処理を続けていき、12行めの関数の呼び出しまで「s」を実行し、変数一覧で全ての変数に値が入っていることを確認してみる。

macOS の最後の画面なこんな感じになる。

(lldb) s
Process 63415 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step in
    frame #0: 0x0000000100000dff arrayTest`main(argc=1, argv=0x00007ffeefbff688) at arrayTest.c:12:37
   9           double b = 3.14;
   10          int c[IN] = {3, 1, 4, 1, 5};
   11          double d[DN] = {3.14, 1.59, 2.65};
-> 12         int e = calcIntSumAndInt(c, IN, a);
   13          double f = calcDoubleSumAndDouble(d, DN, b);
   14
   15          printf("e = %d\n", e);
Target 0: (arrayTest) stopped.
(lldb) fr v
(int) argc = 1
(const char **) argv = 0x00007ffeefbff688
(int) a = 3
(double) b = 3.1400000000000001
(int [5]) c = ([0] = 3, [1] = 1, [2] = 4, [3] = 1, [4] = 5)
(double [3]) d = ([0] = 3.1400000000000001, [1] = 1.5900000000000001, [2] = 2.6499999999999999)
(int) e = 0
(double) f = 0

Window の最後の画面はこんな感じになる。

(gdb) s
12          int e = calcIntSumAndInt(c, IN, a);
(gdb) i locals
a = 3
b = 3.1400000000000001
c = {3, 1, 4, 1, 5}
d = {3.1400000000000001, 1.5900000000000001, 2.6499999999999999}
e = 0
f = 3.9525251667299724e-323

4.3 関数に入る

関数の呼び出しで s を実行すると関数の中に入れる。 ここで変数を表示すると、関数のスコープになるので、変数一覧が変わっていることが確認できる。 gdb では xp の値まで確認できないので p で確認してみる。 また、xp[i] のアクセス,*(xp+i) のアクセスに差がないことを確認する。

lldb (macOS) gdb(Windows)
s s
p xp p xp
p *xp p *xp
p xp[0] p xp[0]
p *(xp+1) p *(xp+1)
p xp[1] p xp[1]

macOS では以下のような感じになる。

(lldb) s
Process 63415 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step in
    frame #0: 0x0000000100000e8e arrayTest`calcIntSumAndInt(xp=0x00007ffeefbff640, n=5, base=3) at arrayTest.c:22:15
   19
   20      int calcIntSumAndInt(int *xp, int n, int base) {
   21          int i;
-> 22         int sum = base;
   23          for (i = 0; i < n; i++) {
   24              sum += xp[i];
   25          }
Target 0: (arrayTest) stopped.
(lldb) fr v
(int *) xp = 0x00007ffeefbff640
(int) n = 5
(int) base = 3
(int) i = 1
(int) sum = 16384
(lldb) p xp
(int *) $2 = 0x00007ffeefbff640
(lldb) p *xp
(int) $3 = 3
(lldb) p xp[0]
(int) $4 = 3
(lldb) p *(xp+1)
(int) $5 = 1
(lldb) p xp[1]
(int) $6 = 1

Windows で以下のような感じになる。

(gdb) s
calcIntSumAndInt (xp=0x61fde0, n=5, base=3) at arrayTest.c:22
22          int sum = base;
(gdb) i locals
i = 0
sum = 0
(gdb) p xp
$2 = (int *) 0x61fde0
(gdb) p *xp
$3 = 3
(gdb) p xp[0]
$4 = 3
(gdb) p *(xp+1)
$5 = 1
(gdb) p xp[1]
$6 = 1

s を実行すると次の行に移る。次にリターンキーやエンターキーだけを押すと、一つ前のコマンドを再度実行する。このまま継続して実施し、return の後に main の e に sum の値が戻ることを確認する。

macOS の場合の最後の実行はこんな感じになる(13行目に→がきた時で停止)。

(lldb)
Process 63415 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step in
    frame #0: 0x0000000100000e16 arrayTest`main(argc=1, argv=0x00007ffeefbff688) at arrayTest.c:13:46
   10          int c[IN] = {3, 1, 4, 1, 5};
   11          double d[DN] = {3.14, 1.59, 2.65};
   12          int e = calcIntSumAndInt(c, IN, a);
-> 13         double f = calcDoubleSumAndDouble(d, DN, b);
   14
   15          printf("e = %d\n", e);
   16          printf("f = %f\n", f);
Target 0: (arrayTest) stopped.
(lldb) p e
(int) $7 = 17

Windows の場合にはこんな感じになる。

(gdb)
main (argc=1, argv=0x6f14c0) at arrayTest.c:13
13          double f = calcDoubleSumAndDouble(d, DN, b);
(gdb) p e
$7 = 17

4.4 関数に入らずに値を得る

関数の呼び出しで n を実行すると関数の中に入らずに実行だけする。 ここで、main の f に値が入ることを確認する。 あとは同様にプログラムの最後まで実行してみよう。

lldb (macOS) gdb(Windows)
n n
p f p f

macOS の場合には、こんな感じになる。

(lldb) n
Process 63415 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
    frame #0: 0x0000000100000e2a arrayTest`main(argc=1, argv=0x00007ffeefbff688) at arrayTest.c:15:24
   12          int e = calcIntSumAndInt(c, IN, a);
   13          double f = calcDoubleSumAndDouble(d, DN, b);
   14
-> 15         printf("e = %d\n", e);
   16          printf("f = %f\n", f);
   17          return 0;
   18      }
Target 0: (arrayTest) stopped.
(lldb) p f
(double) $8 = 10.52

Windows の場合には、こんな感じになる。

(gdb) n
15          printf("e = %d\n", e);
(gdb) p f
$8 = 10.52

5. リファクタリング

私が作るとしたら、calcSumAndInt はこんな感じで書くと思う。

int calcIntSumAndInt(int *xp, int n, int base) {
    while (n-- > 0) {
        base += *xp++;
    }
    return base;
}

このようにとりあえず動くものを作ってから,より効率的なコードに書き換えることを、リファクタリングという。このリファクタリングで考えたことは以下の通りである。

  • 関数(小作人)に渡された変数値は所詮コピーなので壊してもよい
  • n--は n の値を評価した後に n の値を1つ減ずる.すなわち,n > 0 が評価された後で,n の値が一つ減る.
  • xp++ も同じで,xp が計算された後で,xp が1つ増加される
  • *xp の値を常に確認できるので,デバッグしやすい時もある
  • 余計な変数を用意する必要がないのもメリット

6. テストファーストプログラミング

この授業ではテストファーストプログラミングを実施する。それでは、なぜ先にテストをするのだろうか? 以下のようなことが挙げられる。

  • 修正前のコードと修正後のコードが同じ出力を出すのかが心配
  • そもそも元のコードも正しいかは不安
  • 例外値の処理をやっているのか,境界部分で正しく動くのか不安
  • 正しく動くことが保証された関数は道具として使うのに最適

hkob.hatenablog.com