ソフトウェアII 第3回(2023/12/14)

本日のメニュー

  • 動的メモリ確保
    • mallocとfree
  • データ構造
    • 線形リスト
  • アプリケーション: ペイントソフト
  • 課題について

本日の講義のSlidoは講義Slackで提示します。

準備

サンプルコードをダウンロードしておきましょう。

# wget の場合
wget https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec03-src.tar.gz
# curl の場合
curl -O https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec03-src.tar.gz

ダウンロード後は解凍して、そのディレクトリに入ってください。

tar xvzf soft2-lec03-src.tar.gz
cd soft2-lec03-src

動的メモリ確保

メモリ領域の大別

ポインタと組み合わせて最もよく使われる動的メモリ確保について学びましょう。動的メモリ確保を考える前に、C言語のプログラムで使われるメモリ領域について理解しましょう。C言語のプログラムで宣言された変数は以下に示すいずれかのメモリ領域に確保されます。

静的領域(スタティック領域)
プログラム開始時から終了まで確保される固定的な領域。グローバル変数を宣言するとこの領域に確保される。
スタック領域
関数が実行される時に使われる領域。今までグローバル変数以外の変数宣言の場合は、この領域に確保されていた。関数が終了するとこの領域に確保されていた変数は自動的に解放される。
ヒープ領域
プログラム開始時から存在するが、必要に応じて変数に割り当てたり解放したりが可能な領域。

これまでのプログラムでは主に上記の2つを用いてきました。以下は一般的なプログラム構成ですが、関数外で定義されたグローバル変数が静的領域に、関数内で宣言、定義された変数はスタック領域に確保されます。配列も同様です。

#include <stdio.h>

// グローバル変数。これはスタティック領域に確保される
int global_variable = 10; 

void func(int val);

int main(int argc, char **argv) {
    // 以下のval, i , array は mainが呼び出された時にスタック領域に確保される
    int val;
    int array[100];
    for (int i = 0 ; i < 100 ; i++){
        val += i * i;
    }
    printf("gval: %p\n",&global_variable); // グローバル変数のアドレスを表示
    printf("val: %p\n",&val); // val のアドレスを表示

    func(val); // func を呼び出す
    return 0;
}

void func(int val) {
    // 以下のval, i は func が呼び出された時にスタック領域に確保される
    for (int i = 1 ; i < 100 ; i++){
        val %= i;
    }
    printf("val: %p\n",&val); // val のアドレスを表示
    return;
}

上記のメモリ配置の関係から、今までのC言語仕様のいくつかを読み解くことができます。

  • グローバル変数は関数の終了とは無関係にプログラム実行中ずっと確保されているので、どの関数からでもアクセス可能。
  • 関数中の変数の値を外から参照できないのは、各関数ごとに実行時にスタック領域に確保され、関数の終了と共にその領域が解放されるから。

今回は上記の領域に加えて、ヒープ領域にメモリを確保することを考えます。

ヒープ領域に必要なメモリを確保する関数 : malloc()

なぜヒープ領域という3種類目のメモリ領域を使うのかは後述しますが、C言語でこのヒープ領域からメモリを確保する関数がmalloc です。mallocstdlib.h に宣言されている関数です。

#include <stdlib.h>

// malloc 関数 : ヒープ領域から指定されたバイト数のメモリを確保しその先頭アドレスを返す
// 以下では100バイトを確保してその先頭アドレスをint型変数のポインタで受け取っている
int *p = malloc(100);

ポインタはアドレスを扱えるので、自身の使いたい型のポインタで受け取ることで、動的に配列サイズを決めて配列を確保する ような用途で用いることができます。なおもしメモリを使いすぎてヒープ領域に空きがないような場合には(滅多におきませんが...)、NULL が返ります。

まずはスタック領域に配列を確保する場合との違いを以下のコードで見てみましょう。

// このコードは関数func()のスタック領域に確保したメモリ領域のアドレスを返すため、
// 関数を抜けた後にアクセスできない領域ということでNG

#include <stdio.h>
#include <stdlib.h>

int *func(int n) {
    // 可変長配列: この関数内だけ有効
    int a[n];
    
    for (int i = 0 ; i < n ; i++){
	a[i] = i+1;
    }
    
    return a;
 }

int main() {
    int *b = func(3);
    
    printf("%d\n",b[0]);
    printf("%d\n",b[1]);
    printf("%d\n",b[2]);
    
    return 0;
}

// このコードは関数func()内でmallocを使い
// ヒープ領域に確保したメモリ領域のアドレスを返すため、
// 関数を抜けた後でもにアクセス可能: OK

#include <stdio.h>
#include <stdlib.h>

int *func(int n) {
    int *a = (int*)malloc(n * sizeof(int));

    // 実際は a に正しく領域が確保されたか確認する必要があるが
    // ここでは一旦省略
    
    for (int i = 0 ; i < n ; i++){
	    a[i] = i+1;
    }
    
    return a;
}

int main(){
    int *b = func(3);
    
    printf("%d\n",b[0]);
    printf("%d\n",b[1]);
    printf("%d\n",b[2]);
    
    return 0;
}

Let's try

上記のコードを写経して実行してみましょう。

最近のコンパイラは賢いのでおそらく上の方のコードでは警告が出てきたかと思います。ただしコンパイル自体は通っていてプログラムは想定した挙動をしないはずです。

使い終わった領域を解放する関数: free()

一方、mallocを使って割り当てたメモリ領域を「もう使わないよ」と解放するのがfree関数です。この操作によって保持されていた領域を再利用可能になります。アプリケーション全体を通してmallocfreeは対になっています。動的メモリ確保を使った典型的な構文は以下です。

// 確保したい型のサイズをsizeof演算子で取得し、個数をかけて確保
// 型のキャストは必須ではないがこのようにすることが多い
int *ptr = (int *)malloc(10 * sizeof(int));

// 確保に失敗していないかチェックする
if (ptr == NULL){
    exit(1); // return EXIT_FAILURE; の別の書き方: exit関数で返り値1 でプログラム終了
}

// 確保した領域は配列同様に使用できる
ptr[0] = 123;
ptr[1] = 551;
ptr[2] = 334;
ptr[3] = 114514;

// do something; ...
//(中略)

// 使い終わったら解放する
free(ptr);

スタック領域とヒープ領域の比較

確保や解放の方法と、容量の観点でスタック領域とヒープ領域を比較すると以下のようになります。

スタック ヒープ
確保と解放 変数宣言/関数終了で自動的に確保解放 malloc/freeを使う
容量変更 事後的に決められない 確保する時に動的に指定
容量 (相対的に)小さい (相対的に)大きい

スタック領域は関数の終了とともに解放されるため、複数の関数を跨いで共通のデータをやり取りする場合には不向きです。一方で関数内で一時的な処理をする場合には、関数終了時に解放されるので意識せずに利用できます。スタック領域の容量はヒープに比べると小さいため(だいたい数MBぐらいのことが多い)、多くのメモリをスタック領域に確保するようなケースは不向きです。

ヒープ領域は、確保や解放はmalloc/free を用いてプログラマが行う必要があり、面倒ですが、サイズを動的に決定でき、容量もスタック領域より大きいです。また複数の関数を跨ぐデータのやりとりはmallocした領域のアドレスをポインタでやりとりすることで実現します。例えばスタック領域に大規模な配列を確保した以下のようなプログラムは他に何もしていませんが、セグメンテーションフォルトで落ちます。これはスタック領域へのメモリ確保に失敗した例です。

#include <stdio.h>
int main() {
    char a[100000000]; // 100MB 確保!
    printf("Do something\n");
    return 0;
}

一方ヒープ領域への確保は問題なく動くと思います(使ってるマシンに積んでいるメモリが100MBを切っていて、スワップもない場合は失敗しますが、2023年現在このケースはレアかと思います)。

// ヒープ領域に大規模なメモリを確保しようとした場合
#include <stdio.h>
#include <stdlib.h>
int main() {
    char *a = malloc(sizeof(char)*100000000);
    if (a != NULL){
        printf("確保成功\n");
    }
    return 0;
}
Let's try

上記のコードを写経し実行してみましょう。

エラーの捕捉

先ほどmalloc を実行後のエラーチェックとして、簡易にNULL との一致を確認していました。 最近のマシンはメモリも多く、メモリ不足によるエラーは再現しにくいのですが、以下のコードで、malloc 失敗ケースを再現してみましょう。エラーを捕捉する場合、mallocの返り値を検査するのが第一ですが、errno.h という標準ライブラリヘッダを読みこむことで追加の情報を取得できます。

// 色々詰め込んだプログラム
/*
  - errno によるエラー情報の詳細
  - (-1) を size_t でキャストしたら....
  - malloc が失敗するとき
 */
#include <stdio.h>
#include <stdlib.h>
#include <errno.h> // エラー情報を取得するための標準ライブラリ
#include <string.h>  // strerror を使う
#include <stdint.h> // **SIZE_MAX のために追記**

int main() {
    // errno.h で定義されるグローバル変数
    // エラーが起きた場合、エラーの種類に対応する値がセットされる
    errno = 0;
    // まずは -1 を size_t にキャストすると何がみえるでしょう
    printf("OUTPUT (-1): %zu\n",(size_t)-1);

    // SIZE_MAX という値も出力してみましょう
    printf("OUTPUT (SIZE_MAX): %zu\n",SIZE_MAX);
    
    // malloc は size_t を引数にとります。それ以外は型変換されます
    int *a = (int*)malloc(-1);

    if (a == NULL){

	// stdio.h に定義されたperrorを用いる場合
	perror("PERROR OUTPUT: ");

	// string.h に定義されたstrerrorを用いる場合

	fprintf(stderr,"ERROR at %s : %s\n", __func__, strerror(errno));

	exit (EXIT_FAILURE);
    }

    free(a);
    return 0;
}

まず-1 を size_t にキャストするという特殊な処理をしています。実はこれによりSIZE_MAX という、size_t がとりうる最大値を取得できます。この値は十分巨大であるので、今回malloc に無理をさせるのに使います。

perror は現在のerrno の値から(グローバル変数であるため引数なしで取得可能)、エラーの情報を標準エラー出力に出力します。このとき引数に、その前に追加する文字列を設定できます。strerrorerrno を引数にとり、エラー情報の文字列へのポインタを返します。なおerrno は、その後何かの関数が正常終了しても値がリセットされないため、複数回使う場合は適宜0を代入しなおします(今回はexitするつもりなので特に関係なし)。

__func__ はC99 で追加されたマクロの一つで、現在の関数の名前に置き換わります。デバッグに有用な情報を出力できます。

実際にエラー処理まで考慮すると初期化部分が複雑になることがあります。実際にアプリケーションで用いる場合は、生のmallocとエラー検査を含んだ初期化関数を作ると見通しよく記述できます。ただし今日の講義のサンプルコードでは、簡単のためエラー検査を省略しているコードがありますが、ご容赦ください。

Let's try

上記のコードを写経し実行してみましょう。

構造体とmalloc

malloc は構造体にも使用可能です。特に構造体を新たに確保して様々な関数で利用する場合はこの使い方が一般的です。

#include <stdio.h>
#include <stdlib.h>

// 構造体の確保と初期化をmalloc で行う例

typedef struct point {
    int x;
    int y;
} Point;

// Point構造体の初期化関数: 関数内でmalloc し、そのアドレスを返している
Point *init_point(int x, int y){
    Point *ptr = (Point*) malloc(sizeof(Point));
    
    ptr->x = x;
    ptr->y = y;
    
    return ptr;   
}

int main() {
    Point *p = init_point(10, 20);
    
    printf("(%d %d)\n", p->x, p->y);
    
    free(p);
    
    return 0;
}

上記の書き方は構造体で規定されたデータを新たにmalloc等で確保する時の基本的な書き方です。 これに初期化指示子と複合リテラルを組み合わせると以下のようになります。ここでmalloc で確保したアドレスが指す領域を*ptr で指定し、ここに複合リテラルで構成された構造体をそのまま代入していることに気をつけてください。講義冒頭の例と同様、スタック領域に構造体を確保してそのアドレスを返すのではNGです。

// Point構造体の初期化関数: 関数内でmalloc し、そのアドレスを返している
// 初期化指示子と複合リテラルを利用
Point *init_point(int x, int y){
    Point *ptr = (Point*) malloc(sizeof(Point));
    // 最初の *を忘れないこと
    *ptr = (Point){ .x = x, .y = y};
    
    return ptr;
}

以下はNGコードです。

// Point構造体の初期化関数の失敗例
// 関数内で構造体を宣言し、複合リテラルで初期化した上で、*そのアドレス*を返す
// これだとこの関数のスタック領域のアドレスのため、関数終了時に自動解放されてしまう
Point *init_point(int x, int y) {
    Point pt = (Point){ .x = x, .y = y};
    return &pt;
}

配列との関係・違い

上にあった例のようにmallocによりメモリ領域を確保し、ポインタでアドレスを受け取ることで、配列と同じように使うことができます。ただし何度もでてきたsizeof を使った要素数取得のテクニックは使えません。例えば、以下のコードの結果はどのようになるでしょうか?

#include <stdio.h>
#include <stdlib.h>

int main() {
    int *ptr = (int *)malloc(10 * sizeof(int));
    
    if (ptr == NULL) exit(1);
    
    printf("%lu\n", sizeof(ptr) / sizeof(ptr[0]));
    
    return 0;
}

Let's try

上記のコードを写経し実行してみましょう。

sizeof演算子を配列に適用して配列全体のメモリ領域のサイズを返す構文は同一関数内で宣言された配列(スタック領域の変数)か静的領域での配列についてのみ有効です。関数で配列を渡す場合はポインタになるという原則から、他の関数に対して配列サイズも一緒に渡していたのを思い出しましょう。

初期化について

malloc はヒープ領域から使えるメモリ領域を確保してきてその先頭ポインタを返します。使える領域だとしてもそこにどのような値が入っているかは決まっていません。前回使用した状態で散らかっていることも十分考えられます。以下のプログラムで確保した領域がどのような状態か確認してみましょう

// この例は一度ヒープを確保して値を代入、free後に、もう一度malloc しています
// どの領域が確保されるかは状況に依存しますが、
// 同じアドレスの場合は前の値が残っていることがわかります。
#include <stdio.h>
#include <stdlib.h>

int main() {
    // 一旦確保して散らかす
    int *ptr = (int*)malloc(10 * sizeof(int));
    if (ptr == NULL) exit(1);
    
    printf("%p\n", ptr);
    
    for(int i = 0 ; i < 10 ; i++){
        ptr[i] = i*i;
    }
    free(ptr);
    
    // ここからが本番
    ptr = (int*)malloc(10 * sizeof(int));
    if (ptr == NULL) exit(1);
    
    printf("%p\n", ptr);
    
    // 初期化せずに表示すると...
    for(int i = 0 ; i < 10 ; i++){
	printf("%d: %d\n", i, ptr[i]);
    }
}

ヒープに確保した領域をとりあえず全てのビットを0で埋めたい場合は、malloc の親戚であるcalloc を使います。

ptr = calloc(size_t count, size_t size);
Let's try

上記を写経し確かめた上で、後半部分のメモリ確保をcallocで置き換えてみましょう。

free() について

free() はmalloc() で割り当てられたヒープ領域を解放する関数ですが、以下の点に注意しましょう。

  • スタック領域や代入前のポインタは絶対にfreeしない
  • NULLをfreeするのは問題ない
  • 二重解放(同じアドレスを2回free)は危険なので、解放後にNULL代入をするのは有効

以下にOK/NG の例を示します。

int *a = (int *)malloc(10 * sizeof(int));
int *b;
int c[10];

free(a); // OK
free(b); // NG
free(c); // NG
free(NULL); // OK

メモリリークについて

確保されたアドレスをfree前に忘れると、そのメモリ領域へのアクセス手段がなくなります。複数回のmalloc を繰り返すようなプログラムでこのような状態になると、場合によってはメモリが枯渇してしまうことがあります。例えば以下のコードはmallocされた領域へのアクセス手段を失っており、メモリリークの原因となります。

int number = 10;
int *a = (int *)malloc(number * sizeof(int));
a = &number; // ここで先ほど確保したアドレス情報を失う
// 以後最初にmallocされた領域へのアクセスはできない

データ構造

データ構造とはデータの集まりを一定の形式に系統立てて格納する際の、その格納形式のことを言います。これまで扱ってきた配列も立派なデータ構造です。データ構造としての配列の特徴を整理すると以下が挙げられます。

  • ランダムアクセスが簡単(配列のインデックスを指定すればよい)
  • 要素の削除や挿入のコストが高い
  • (C言語では)一般に要素数に制限がある

今日は基本的なデータ構造の一つである線形リストについて学びましょう

線形リスト

線形リストは「データ」と「参照」をセットにした要素によって構成されます。各要素はデータを持っており、それに加えて次にどの要素にアクセスすべきかのアドレスをセットで持っておくことで、順々にデータにアクセスすることができます。もう後ろにデータがないという状態は、NULLなどの特殊なアドレスを参照することで実現できます。

連結リスト

線形リストには参照が一方向のみの片方向リスト と二方向を参照する双方向リスト がありますが、ここでは片方向リストを扱います。線形リストのデータ構造としての特徴は以下です。

  • 要素の削除、挿入のコストが小さい
    • 前後のポインタをつなぎかえるだけでよい
  • 要素数の制限がない
    • 増える場合には新たにヒープ領域に確保するだけでよい
  • ランダムアクセスのコストは高い

線形リストの実装法

C言語には言語仕様自体には線形リストは実装されていませんが、線形リストの実装には自己参照構造体 と呼ばれる構造体を定義します。自己参照構造体とは文字通り、自分自身と同じ型の構造体を指すポインタをメンバに有する構造体です。例えば山手線の駅をつなぐ片方向リストを作成する場合、以下のような構造体を定義します。

struct station{
    char name[20];
    int birthyear;
    struct station *next;
};

ここで重要となるのは最後のメンバであるポインタです。このポインタに次にさすstruct station のアドレスを格納することで、要素を順々につなぐことができます。

線形リストの操作例1(先頭への挿入)

線形リストに新たにデータを加えるための基本操作は

  1. 加えるデータを確保する領域をヒープに確保
  2. ポインタのアドレスを適切に張り替える

の2つです。まずは先頭にデータを挿入する場合は以下の図のようになります。

先頭に挿入01 先頭に挿入02

線形リストの操作例2(途中への挿入)

途中挿入の場合も基本的な操作は同じです。

途中に挿入01 途中に挿入02

線形リストの操作例3(要素の削除)

要素を削除する場合は、

  1. 先に新しい接続関係を確保しておく
  2. その上で削除する要素を解放する

という手順を踏みます。

削除01 削除02

実装例(先頭への挿入)

先頭への挿入の実装例を以下に示します。

#include <stdio.h>
#include <stdlib.h>

struct student {
    int id;
    struct student *next;
};

typedef struct student Student;

Student *push_front(Student *p, int id) {
    Student *q = malloc(sizeof(Student));
    
    *q = (Student){.id = id, .next = p};
    
    return q;
}

int main() {
    Student *begin = NULL;
    begin = push_front(begin, 1);
    begin = push_front(begin, 2);
    begin = push_front(begin, 3);
    
    // ポインタをたどることで次の要素にアクセスする
    for (Student *p=begin; p != NULL; p = p->next){
        printf("%d\n", p->id);
    }
    
    return 0;
}

線形リストの実習

線形リストのサンプルプログラムlist.c を使って線形リストの実装に触れてみましょう。 サンプルプログラムlist.c の処理内容は

  • 標準入力から1行ずつ読み込み、文字列を線形リストに保存
  • 線形リストを先頭から順にたどり、文字列を標準出力に書きだす

となっています。以下でコンパイルしましょう。

gcc -o linearlist list.c

実行する際にはファイルからリダイレクトします。yamanote.txt は山手線の駅名が一行一駅書かれたファイルです。

./linearlist < yamanote.txt

線形リストを操作する際に使う変数および関数は以下のようになっています。線形リストは先頭ノードさえわかれば順に辿れることができるので、この操作時にはこのアドレスだけ考慮します。

Node *begin; // 先頭ノードへのポインタ
Node *push_front(); // 先頭に要素を追加する関数
Node *push_back(); // 末尾に要素を追加する関数
Node *pop_front(); // 先頭の要素を削除
Tip

pop や push はデータ構造へのデータの出し入れを表すのによく使われる単語です。特に今回の関数名は、C++のstd::listのメソッドと同様の名前になっています。

プログラム解説

冒頭では、ノードに対応する自己参照構造体を定義しています。

typedef struct node Node;
struct node {
    char *str;
    Node *next;
};

ここでのポイントは

  • データは文字列(charポインタ)のみ
  • 文字列そのものは保持しない: Node生成時はmalloc と strcpy を使う
  • typedef による型名宣言を最初に独立して行い、その後struct の定義に入ることで、struct の定義中に Node という型名を使うことを可能にしています。自己参照構造体の場合、typedefと構造体定義を同時にすることはできません(struct node *next のように書き下せばOKです)。

push_front() は先頭に要素を挿入する関数です。

Node *push_front(Node *begin, const char *str) {   
    Node *p = (Node *)malloc(sizeof(Node));
    char *s = (char *)malloc(strlen(str) + 1);
    strcpy(s, str);
    
    *p = (Node){.str = s , .next = begin};
    
    return p; 
}

この関数は

  • 入力:リストの先頭へのポインタ、格納する文字列
  • 出力:(挿入後の)リストの先頭へのポインタ

となっており、malloc で、ノードおよび文字列のメモリを確保し、データ(文字列)を保存した後、ポインタを張り替えています。文字列確保時に+1 があるのは文字列終端の\0 のためです。

pop_front()は先頭の要素を削除する関数です。

Node *pop_front(Node *begin) {
    assert(begin != NULL);
    Node *p = begin->next;
    
    free(begin->str);
    free(begin);
    
    return p;
}

この関数は

  • 入力:リストの先頭へのポインタ
  • 出力:(削除後の)リストの先頭へのポインタ

となっており、freeを使ってノードおよび文字列のメモリを解放しています。先頭のassert はNULLが引数の場合はbegin->next のアクセスができないためについています。構造体へのポインタがNULLを指していないかの確認は重要なので 、プログラムの挙動がおかしい時のチェックポイントの一つと思ってください。

push_back() は末尾に要素を追加する関数です。

Node *push_back(Node *begin, const char *str) {
    if (begin == NULL) { 
	return push_front(begin, str);
    }
    
    Node *p = begin;
    while (p->next != NULL) {
	p = p->next;
    }
    
    Node *q = (Node *)malloc(sizeof(Node));
    char *s = (char *)malloc(strlen(str) + 1);
    strcpy(s, str);
    
    *q = (Node){.str = s, .next = NULL};
    p->next = q;
    
    return begin;
}

この関数では、末尾の要素に行きつくまでリストを先頭からたどり、その後ろに新たな要素を追加 しています。ただしリストが空の場合のみ特別扱いとしています。

Let's try

以下に取り組んでみましょう。適宜yamanote.txt を使ってください。

  • list.c に末尾の要素を削除するpop_back() 関数を追加してください。
    • 特に新たに末尾となる要素の処理に注意してください
  • 与えられたNode構造体ポインタの次に、要素を挿入する関数Node * insert(Node * p, const char *str) を追加してください。
    • NULLを引数に取る場合は何もしない。返り値は引数と同じポインタとします。
    • 適切な場所に巣鴨をinsertしてください。
  • main関数内でいろいろなpop/push の関数を変えてどのような結果になるか確認してください。
    • 例えば高輪ゲートウェイをpush_backしてください。
  • 先頭へのNode構造体へのポインタをメンバにもつ新たな構造体List を定義し、前述の関数群をこれを操作するように書き換えてください。

list_comment.cにプログラムの解説を記載しているので、コメントなしでわかりにくい場合は読んでみてください。 Let's try の最後ですが、

typedef struct list{
    Node *begin;
} List;

のようなものを考えます。これは先頭ノードをリストとして取り扱うよりも、「リスト」に要素がない場合でも比較的安全に操作することができるので、推奨されます。

Let's try 一例 (線形リスト)

[講義後追記] pop_back()の実装の一例とListへの書き換えの例を示します。コメント中にポイントを記載しました。Listを用いた実装では先頭がNULLかどうかを意識せず(assertなしで)取り扱えていることがわかります。

pop_back()の一例
Node *pop_back(Node *begin){
    // NULLだった場合はすでに要素がない
    // そのままreturn してもよいが
    // pop_front同様ここではassert で終了
    assert(begin != NULL);
    
    Node *p = begin;
    Node *q = NULL; // pがp->nextに進む前に直前を保存するポインタ
    
    while (p->next != NULL){
	// nextにいく前に今いるポインタを記憶する
	q = p;
	p = p->next;
    }
    
    // whileを抜けた時点でpは終端ノード、qは一つ前のノード
    if (q == NULL){
	// qがNULLの場合は1つだけの要素だったものが削除されるケース
	// この時に線形リストに要素がなくなったことを示すためにNULLをreturnする
	return NULL;
    }
    // 上のケース以外はq->next が触れる
    // pを削除した時にqは最後のノードになるので、
    // q->next に NULLをセットする
    q->next = NULL;
    
    // pの削除
    free(p->str);
    free(p);
    
    return begin;
}
Listへの書き換え
// ヘッダ, maxlen とNodeの定義は省略
// 新たに先頭ノードへのポインタをメンバにもつList を定義
// この構造体のポインタを関数引数にすることで先頭ノードが書き換わるケースを返り値の受け取りなしで扱える。
// 今回の例の実装の場合は先頭ノードのポインタを返り値で受け取るので問題なかったが、
// 例えばpop関数で、削除の代わりにノード取り出しを行いたい場合は
// 取り出すノードが関数の返り値になる
// このときに先頭ノードがなくなると、引数がノードのアドレスだけの場合、
// その領域はfreeされているので、関数終了後に不整合がおきる


typedef struct list{
    Node *begin; // 線形リストの先頭ノードへのポインタ
} List;


// リストを構造体とした場合は、先頭ノードの変化はList構造体内のメンバが担保するので、
// 返り値には自由度ができる
// 以下では挿入したノードへのポインタとしている
// list->begin = p; として先頭ノードを書き換えている
Node *push_front(List *list, const char *str){
    Node *p = (Node *)malloc(sizeof(Node));
    char *s = (char *)malloc(strlen(str) + 1);
    strcpy(s, str);
    
    *p = (Node){.str = s, .next = list->begin};
    list->begin = p;
    
    return p;
}

// 返り値はpopしたデータを指すように変更
// 削除の場合はこれまでと同様freeする
// これまでは新たな先頭を返していたが、今回は値を取り出しつつ先頭の変更が可能
Node *pop_front(List *list){
    Node *p = list->begin;
    
    if (p != NULL){
	// 次の要素を線形リストの先頭に設定
	list->begin = p->next;
	
	// 今回はこれまで先頭だったデータが欲しい
	p->next = NULL;
	
	// 削除の場合は以下を行う
	// free(p->str)
	// free(p);
    }
    
    return p;
}

// こちらも新たに生成したノードをreturn するように変更
Node *push_back(List *list, const char *str){
    Node *p = list->begin;
    
    if (p == NULL) return push_front(list,str);
    
    while (p->next != NULL) {
	p = p->next;
    }
    
    Node *q = (Node *)malloc(sizeof(Node));
    char *s = (char *)malloc(strlen(str) + 1);
    strcpy(s, str);
    
    *q = (Node){.str = s, .next = NULL};
    p->next = q;
    
    return q;
}

// pop_back の実装
Node *pop_back(List *list) {
    Node *p = list->begin;
    Node *q = NULL;
    
    if (p == NULL) return NULL;
    
    while (p->next != NULL){
	// nextにいく前に今いるポインタを記憶する
	q = p;
	p = p->next;
    }
    
    if ( q == NULL ) return pop_front(list);
    // 新たな末尾の設定
    if ( q != NULL) q->next = NULL;
    
    return p;
    // 削除の場合は以下の処理
    //free(p->str);
    //free(p);
}


void remove_all(List *list){
    Node *p;
    while ((p = pop_front(list))) 
	; // Repeat pop_front() until the list becomes empty
    return;
}

int main() {
    // 構造体自体はmainのスタック領域に定義
    // push/pop関数にはアドレスで渡す
    List list = (List){ .begin = NULL};
    
    char buf[maxlen];
    while (fgets(buf, maxlen, stdin)) {
	push_front(&list, buf);
	//push_back(&list, buf);
    }
    
    Node *p;
    p = pop_front(&list);
    printf("%s", p->str);
    printf("==\n");
    
    p = pop_back(&list);
    printf("%s", p->str);
    printf("==\n");
    
    for (const Node *p = list.begin; p != NULL; p = p->next){
	printf("%s", p->str);
    }
    return EXIT_SUCCESS;
}

アプリケーション: ペイントソフト

今日扱った動的メモリ確保や線形リストを扱うアプリケーションとして、コマンド入力によるペイントソフトを扱います。通常のペイントソフトはGUIによる入力ですが、コマンドラインで端末上に描画するアプリケーションを考えます。特にコマンド履歴を取り扱う際に線形リストを使用してみます。

サンプルプログラム: 配列で履歴管理を実装したプログラム

サンプルプログラムはchar** の変数をメンバに持つ構造体をHistory構造体として、コマンドに相当する文字列を順次保存していく形で履歴を管理するペイントプログラムになっています。

ペイント用に現状実装されているコマンドは以下です

line
4つの整数を引数にとり2点間に線をひくコマンド。
save
現状のコマンド履歴を保存するコマンド。引数に保存するファイル名を入れることもできる。省略するとhistory.txtとなる。
undo
直前の描画コマンド(現状lineのみ)を取り消すコマンド。
quit
描画プログラムを終了する。

なお履歴は5つまで保存され、それを超えるとプログラムが終了します。またCtrl-D などでEOFが投げ込まれた場合も一応ちゃんと終了します。 早速コンパイルして実行してみましょう。

gcc -o paint paint_arrayhistory.c
# w = 80, h = 40 で実行。引数なしで実行すると引数指定の方法が表示される
./paint 80 40 
# 描画領域に合わせてターミナルを調整し、一度quit で終了しましょう。
0> quit

改めて実行します。

./paint 80 40
0> line 10 10 20 10
1> line 10 10 20 20
2> line 10 10 10 20
3> undo
2> save
2> quit

history.txt が生成され、最初の2つのコマンドがテキスト形式で保存されていることが確認できます。

Let's try

適当に遊んでみましょう。現状意味不明な文字列に対してはunknownコマンドとして弾くはずです。

プログラム解説

ここでは特に今日の講義に関連する部分や難しそうな部分について解説します。

全体構造は

  • ヘッダファイルのinclude
  • 構造体定義
    • 盤面を表す2次元配列にサイズ情報と描画用の文字種を保存する変数がセットになったCanvas構造体
    • 履歴を保存するための文字列の配列に最大配列サイズと現在の配列位置の情報がついたHistory構造体
  • 今回実装する関数のプロトタイプ宣言
    • Canvas構造体に関連するもの(主に描画関係)
    • ANSIエスケープシーケンスによる巻き戻しや描画クリアに関するもの
    • ペイントソフトとしての関数
  • main関数および各関数の実際の実装

のようになっています。

main関数の流れ

main関数内の流れは

  1. History構造体を初期化
  2. ユーザの入力サイズに応じてCanvas構造体をヒープ領域に確保
  3. while文で履歴が一定数になるまで繰り返す
    • ユーザから1行うけとる。EOFならbreakする。
    • 入力を解釈する
    • 解釈結果が標準コマンドの場合は履歴に追加
    • 描画領域を調整する
  4. while を抜けたら片付けをしてプログラムを終了する

のようになっています。ユーザの入力サイズを受け取る際に今回はatoi ではなくstrtol という関数を用いています。これはatoi がユーザの想定外の入力に対応するのが難しいためです。詳しくは別記事で解説します。

Canvas関係

Canvas構造体の定義は以下です。

typedef struct {
    int width;
    int height;
    char **canvas;
    char pen;
} Canvas;

今回のプログラムではキャンバスを動的に確保しているので、構造体自体にはポインタポインタをメンバとし、構造体を確保する際に必要な領域をmallocします。この実装がinit_canvas です。

Canvas *init_canvas(int width,int height, char pen) {
    Canvas *new = (Canvas *)malloc(sizeof(Canvas));
    new->width = width;
    new->height = height;
    new->canvas = (char **)malloc(width * sizeof(char *));
    
    char *tmp = (char *)malloc(width*height*sizeof(char));
    memset(tmp, ' ', width*height*sizeof(char));
    for (int i = 0 ; i < width ; i++){
	new->canvas[i] = tmp + i * height;
    }
    
    new->pen = pen;
    return new;
}

init_canvas で使われているmalloc と対になる形でfree_canvas も実装されています。

History関係

こちらの実装ではchar ** で複数のコマンド文字列を保存していき、次の空きインデックスがどこかを hsize で記録するような実装になっています。

typedef struct {
    size_t max_history;
    size_t bufsize;
    size_t hsize;
    char **commands;
} History;

History構造体については、main関数内で宣言されているのでスタック領域に保存されていますが、その内部のcommands についてはmalloc で必要なサイズを確保しています。

コマンドによるペイント : draw_line()

線を引く関数については、離散化した端末上で線を引くために、長い方の軸方向にn等分して点を打つ実装になっています。

void draw_line(Canvas *c, const int x0, const int y0, const int x1, const int y1)
{
    const int width = c->width;
    const int height = c->height;
    char pen = c->pen;
    
    const int n = max(abs(x1 - x0), abs(y1 - y0));
    if ( (x0 >= 0) && (x0 < width) && (y0 >= 0) && (y0 < height))
        c->canvas[x0][y0] = pen;
   for (int i = 1; i <= n; i++) {
	const int x = x0 + i * (x1 - x0) / n;
	const int y = y0 + i * (y1 - y0) / n;
	if ( (x >= 0) && (x< width) && (y >= 0) && (y < height))
	    c->canvas[x][y] = pen;
    }
}

コマンドを解釈して実行する関数

interpret_command() によって、ユーザから入力されたコマンドを解釈します。冒頭部分を見てみます。

Result interpret_command(const char *command, History *his, Canvas *c) {
    char buf[his->bufsize];
    strcpy(buf, command);
    buf[strlen(buf) - 1] = 0; // remove the newline character at the end
    
    const char *s = strtok(buf, " ");
    
    // .... 中略
}

冒頭部分では引数で受け取ったユーザ入力の文字列を関数内で用意したバッファにコピーしたのち、strtok という関数を用いて空白区切りで最初の単語を取り出しています。strtok() は区切り文字(デリミタ)を指定して、そのデリミタで順番に区切って文字列を取り出していく関数です。少し特殊な使い方をするので別記事で解説しています。

これにより先頭の単語が取り出せるので、interpret_command の後半ではその単語(例えばsave, quit など)に応じて実行を変化させています。

Undoの実装

今回直前のコマンドを取り消すundo が実装されていますが、実装方針は以下です。

  • キャンバスを初期化し、最初のコマンドから直前のコマンドまでを実行しなおす
  • コマンドの履歴の長さを1減らす

列挙型について

列挙型について学びましょう。列挙型は「定数のリスト」を作ることのできる特殊な変数です。例えば何パターンかの情報を区別したいが、その値そのものはなんでもいい場合に用います。使い方は

enum tag { name01, name02, name03, name04 } variable;

のように使います。変数variableは、name01,name02,name03,name04 のいずれかの値をとるものとします。これらの定数は実際には整数ですが、整数の値そのものには意味はありません。

今回のプログラムではinterpret_command関数が読み取ったコマンドがどのような種類のものだったかを表す5つの定数を返すようにしています。列挙型も構造体と同様にtypedef と組み合わせて別名をつけることができます。今回はResult型という名前になっています。

typedef enum res{ EXIT, LINE, UNDO, SAVE, UNKNOWN, ERRNONINT, ERRLACKARGS} Result;

これからやること(Let's tryにむけて)

現在の履歴管理はあらかじめ配列を確保しておき、どのインデックスまで書き込んだかを別変数で保存する形式をとっています。この後の実習ではこれを線形リスト による管理に置き換えます。巨大なプログラムですが、実際に書き換える方針を以下に示します。

  • 構造体名History は変更しない
  • メンバは変更する
  • 線形リストの各要素はコマンド文字列をデータに持ち、次のコマンドへのポインタを持つ
  • リストの終端が最新のコマンドに対応する

つまり以下のような構造体設計に置き換えます

// 最大履歴と現在位置の情報は持たない
typedef struct command Command;
struct command{
    char *str;
    size_t bufsize;
    Command *next;
};

// コマンドリストの先頭へのポインタをメンバに持つ構造体としてHistoryを考える。
// 履歴がない時点ではbegin = NULL となる。
typedef struct{
    Command *begin;
    size_t bufsize;
} History;

これにより書き換える必要があるのは

  • main 関数冒頭のHistory構造体部分 : 初期化法が変わる
  • while文 : 履歴の上限はなし
  • 現在、History構造体へのポインタを引数に持つ以下の関数(同じプロトタイプで実装可能)
    • interpret_command();
    • save_history();
  • hsize相当のものは実装しなくてよいが、もし再現したければ、線形リストを先頭からスキャンしてリストの長さを測る関数を作る。

となります。

Let's try

線形リストでの実装

  • paint_arrayhistory.c をもとに履歴の管理を線形リストで再実装しましょう(ファイル名はpaint.c とします)
    • 履歴の追加は末尾追加になります。適宜list.cを参考にしましょう
    • 今回は片方向リストでの実装を想定していますが、余裕があれば双方向リストで実装してみても構いません。

Let's try の実装例

[講義後追加] Let's try の実装例を示します。"[*]" という文字列を検索すると変更点を確認できます。いきなり書くのはなかなか難しいかもしれませんが、読んでみて理解を深めてみてください。

線形リスト版paint
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <errno.h> // for error catch

// Structure for canvas
typedef struct {
    int width;
    int height;
    char **canvas;
    char pen;
} Canvas;

// Command 構造体と History構造体
// [*]
typedef struct command Command;
struct command{
    char *str;
    size_t bufsize;
    Command *next;
};

typedef struct
{
    Command *begin;
    size_t bufsize; 
} History;

// functions for Canvas type
Canvas *init_canvas(int width, int height, char pen);
void reset_canvas(Canvas *c);
void print_canvas(Canvas *c);
void free_canvas(Canvas *c);

// display functions
void rewind_screen(unsigned int line); 
void clear_command(void);
void clear_screen(void);

// enum for interpret_command results
typedef enum res{ EXIT, LINE, UNDO, SAVE, UNKNOWN, ERRNONINT, ERRLACKARGS, NOCOMMAND} Result;
// Result 型に応じて出力するメッセージを返す
char *strresult(Result res);

int max(const int a, const int b);
void draw_line(Canvas *c, const int x0, const int y0, const int x1, const int y1);
Result interpret_command(const char *command, History *his, Canvas *c);
void save_history(const char *filename, History *his);

// [*] list.c のpush_backと同じ
Command *push_command(History *his, const char *str);

int main(int argc, char **argv) {
    //for history recording
    
    const int bufsize = 1000;
    
    // [*]
    History his = (History){ .begin = NULL, .bufsize = bufsize};
    
    int width;
    int height;
    if (argc != 3){
	fprintf(stderr,"usage: %s <width> <height>\n",argv[0]);
	return EXIT_FAILURE;
    } else{
	char *e;
	long w = strtol(argv[1],&e,10);
	if (*e != '\0'){
	    fprintf(stderr, "%s: irregular character found %s\n", argv[1],e);
	    return EXIT_FAILURE;
	}
	long h = strtol(argv[2],&e,10);
	if (*e != '\0'){
	    fprintf(stderr, "%s: irregular character found %s\n", argv[2],e);
	    return EXIT_FAILURE;
	}
	width = (int)w;
	height = (int)h;
    }
    char pen = '*';
    

    char buf[bufsize];

    Canvas *c = init_canvas(width,height, pen);
    
    printf("\n"); // required especially for windows env

    while(1){
	// [*]
	// hsize はひとまずなし
	// 作る場合はリスト長を調べる関数を作っておく
	print_canvas(c);
	printf("* > ");
	if(fgets(buf, bufsize, stdin) == NULL) break;
	
	const Result r = interpret_command(buf, &his, c);

	if (r == EXIT) break;

	// 返ってきた結果に応じてコマンド結果を表示
	clear_command();
	printf("%s\n",strresult(r));
	// LINEの場合はHistory構造体に入れる
	if (r == LINE) {
	    // [*]
	    push_command(&his,buf);
	}
	
	rewind_screen(2); // command results
	clear_command(); // command itself
	rewind_screen(height+2); // rewind the screen to command input
	
    }
    
    clear_screen();
    free_canvas(c);
    
    return 0;
}

Canvas *init_canvas(int width,int height, char pen)
{
    Canvas *new = (Canvas *)malloc(sizeof(Canvas));
    new->width = width;
    new->height = height;
    new->canvas = (char **)malloc(width * sizeof(char *));
    
    char *tmp = (char *)malloc(width*height*sizeof(char));
    memset(tmp, ' ', width*height*sizeof(char));
    for (int i = 0 ; i < width ; i++){
	new->canvas[i] = tmp + i * height;
    }
    
    new->pen = pen;
    return new;
}

void reset_canvas(Canvas *c)
{
    const int width = c->width;
    const int height = c->height;
    memset(c->canvas[0], ' ', width*height*sizeof(char));
}


void print_canvas(Canvas *c)
{
    const int height = c->height;
    const int width = c->width;
    char **canvas = c->canvas;
    
    // 上の壁
    printf("+");
    for (int x = 0 ; x < width ; x++)
	printf("-");
    printf("+\n");
    
    // 外壁と内側
    for (int y = 0 ; y < height ; y++) {
	printf("|");
	for (int x = 0 ; x < width; x++){
	    const char c = canvas[x][y];
	    putchar(c);
	}
	printf("|\n");
    }
    
    // 下の壁
    printf( "+");
    for (int x = 0 ; x < width ; x++)
	printf("-");
    printf("+\n");
    fflush(stdout);
}

void free_canvas(Canvas *c)
{
    free(c->canvas[0]); //  for 2-D array free
    free(c->canvas);
    free(c);
}

void rewind_screen(unsigned int line)
{
    printf("\e[%dA",line);
}

void clear_command(void)
{
    printf("\e[2K");
}

void clear_screen(void)
{
    printf( "\e[2J");
}


int max(const int a, const int b)
{
    return (a > b) ? a : b;
}
void draw_line(Canvas *c, const int x0, const int y0, const int x1, const int y1)
{
    const int width = c->width;
    const int height = c->height;
    char pen = c->pen;
    
    const int n = max(abs(x1 - x0), abs(y1 - y0));
    if ( (x0 >= 0) && (x0 < width) && (y0 >= 0) && (y0 < height))
	c->canvas[x0][y0] = pen;
    for (int i = 1; i <= n; i++) {
	const int x = x0 + i * (x1 - x0) / n;
	const int y = y0 + i * (y1 - y0) / n;
	if ( (x >= 0) && (x< width) && (y >= 0) && (y < height))
	    c->canvas[x][y] = pen;
    }
}

void save_history(const char *filename, History *his)
{
    const char *default_history_file = "history.txt";
    if (filename == NULL)
	filename = default_history_file;
    
    FILE *fp;
    if ((fp = fopen(filename, "w")) == NULL) {
	fprintf(stderr, "error: cannot open %s.\n", filename);
	return;
    }
    // [*] 線形リスト版
    for (Command *p = his->begin ; p != NULL ; p = p->next){
	fprintf(fp, "%s", p->str);
    }
    
    fclose(fp);
}

Result interpret_command(const char *command, History *his, Canvas *c)
{
    char buf[his->bufsize];
    strcpy(buf, command);
    buf[strlen(buf) - 1] = 0; // remove the newline character at the end
    
    const char *s = strtok(buf, " ");
    if (s == NULL){ // 改行だけ入力された場合
	return UNKNOWN;
    }
    // The first token corresponds to command
    if (strcmp(s, "line") == 0) {
	int p[4] = {0}; // p[0]: x0, p[1]: y0, p[2]: x1, p[3]: x1 
	char *b[4];
	for (int i = 0 ; i < 4; i++){
	    b[i] = strtok(NULL, " ");
	    if (b[i] == NULL){
		return ERRLACKARGS;
	    }
	}
	for (int i = 0 ; i < 4 ; i++){
	    char *e;
	    long v = strtol(b[i],&e, 10);
	    if (*e != '\0'){
		return ERRNONINT;
	    }
	    p[i] = (int)v;
	}
	
	draw_line(c,p[0],p[1],p[2],p[3]);
	return LINE;
    }
    
    if (strcmp(s, "save") == 0) {
	s = strtok(NULL, " ");
	save_history(s, his);
	return SAVE;
    }
    
    if (strcmp(s, "undo") == 0) {
	reset_canvas(c);
	//[*] 線形リストの先頭からスキャンして逐次実行
	// pop_back のスキャン中にinterpret_command を絡めた感じ
	Command *p = his->begin;
	if (p == NULL){
	    return NOCOMMAND;
	}
	else{
	    Command *q = NULL; // 新たな終端を決める時に使う
	    while (p->next != NULL){ // 終端でないコマンドは実行して良い
		interpret_command(p->str, his, c);
		q = p;
		p = p->next;
	    }
	    // 1つしかないコマンドのundoではリストの先頭を変更する
	    if (q == NULL) {
		his->begin = NULL;
	    }
	    else{
		q->next = NULL;
	    }
	    free(p->str);
	    free(p);	
	    return UNDO;
	}  
    }
    
    if (strcmp(s, "quit") == 0) {
	return EXIT;
    }
    return UNKNOWN;
}


// [*] 線形リストの末尾にpush する
Command *push_command(History *his, const char *str){
    Command *c = (Command*)malloc(sizeof(Command));
    char *s = (char*)malloc(his->bufsize);
    strcpy(s, str);
    
    *c = (Command){ .str = s, .bufsize = his->bufsize, .next = NULL};
    
    Command *p = his->begin;
    
    if ( p == NULL) {
	his->begin = c;
    }
    else{
	while (p->next != NULL){
	    p = p->next;
	}
	p->next = c;
    }
    return c;
}

char *strresult(Result res){
    switch(res) {
    case EXIT:
	break;
    case SAVE:
	return "history saved";
    case LINE:
	return "1 line drawn";
    case UNDO:
	return "undo!";
    case UNKNOWN:
	return "error: unknown command";
    case ERRNONINT:
	return "Non-int value is included";
    case ERRLACKARGS:
	return "Too few arguments";
    case NOCOMMAND:
	return "No command in history";
    }
    return NULL;
}

課題(締切: 12/27)

課題の実装にあたり、最後の実習に相当する「コマンド履歴の線形リストによる実装」を行っていることを前提とする(最後のLet's try は講義終盤に一例を提示するので焦る必要はない)。

  1. paint.c に長方形を描くコマンドrect と 円を描くコマンドcircle を実装せよ。
    • rect は 4つの引数を取り、rect x0 y0 width height と左上の座標と横幅、高さを指定するものとする。塗りつぶさず線でかく。辺は軸と平行としてよい。
    • circle は3つの引数を取り、circle x0 y0 r で中心座標と半径を指定するものとする。塗りつぶさず線でかく。描画領域の性質上、見た目は正円に見えないが、その点は無視する。
    • ファイル名はpaint1.c とする。
  2. ファイルに保存されたコマンド履歴を読み込み絵を再描画するコマンドload を追加せよ。
    • 引数なしの場合はhistory.txt から読まれ、引数がある場合はそのファイルから読まれるようにする
    • ファイルが存在しない場合はエラーを表示できるようにする
    • 読み込んだ履歴は線形リストに追加すること
    • ファイル名はpaint2.c とする。
    • 実行可能な履歴ファイルをpaint2.txtとして同梱せよ(絵の芸術性は不問)
  3. 以降の描画の文字種を変更するchpen コマンドを追加せよ.
    • 引数の1文字をとり、その文字種に変更する。
    • それ以前の描画の文字種は変更せず、以降の描画の文字種が変更されるようにする。
    • chpen コマンドは履歴の線形リストに追加する(これにより文字種を変えながらのペイントの履歴が保存できる)。
    • ファイル名はpaint3.c とする。
  4. [発展課題] paint.c に、他に有用なコマンド(redo、塗りつぶし、エフェクトをかける、コピー&ペースト、色変更、BMPなど画像形式で保存、など)を追加せよ。複数実装しても構わない。
    • ファイル名はpaint4.cとする。
    • 追加したコマンドについてREADME.mdにて簡単に説明すること。

提出方法

  • ITC-LMS にて提出する
  • 全てのプログラム/ファイルをまとめ、zip またはtar.gzで圧縮し提出
  • 必ずREADME.md を同梱し、やった内容を簡潔に記述する
  • ファイル名はSOFT-12-14-NNNNNNNN.zip または SOFT-12-14-NNNNNNNN.tar.gz とする
    • NNNNNNNN部分は学籍番号(ハイフンは除く)
    • JについてはJ??????? のようにする
  • 課題について
  • 基本課題は毎回提出
  • 発展課題は成績計算に全6回中上位3回分を採用する