ソフトウェアII 第1回(2023/11/30)

本日のメニュー

  • テキスト入出力再確認
    • 端末の出力を飾る
    • scanfをうまく利用する
    • FILE型ポインタを用いたテキスト入出力と文字列を対象としたテキスト入出力
  • ライフゲーム

質問(匿名でも可)を受け付けるslidoは講義Slackで提示します。

テキスト入出力再確認

端末の出力を飾る

これまでソフトウェアI での学習などを通して、文字列の入出力について学んできたと思います。端末に文字情報を表示する場合に、例えば\n を使って改行を出力したり、\tを使ってタブを出力したりしました。これらの文字で構成される列はエスケープシーケンス と呼ばれ、通常の文字列では表せない機能を実現しています。ここではいくつかのエスケープ文字、エスケープシーケンスを使って変わった端末の出力を実現してみましょう。以下の関数では\a\r というエスケープ文字を使っています。

#include <stdio.h>
#include <unistd.h> // sleepのためにinclude 

int main (int argc, char **argv){
    for (int i = 10 ; i > 0 ; i--){
        printf("\a% 5d",i);
        fflush(stdout);
        sleep(1);
        printf("\r");
    }
    printf("\n");
    return 0;
}

このプログラムでは、for文の繰り返しごとに「ベルを鳴らして空白で左埋めした数字を表示し、1秒待ってからその行の先頭にカーソルを戻す」という挙動でカウントダウンタイマーを実現しています。途中のfflushは出力をバッファさせないためにバッファをクリアするためのものです。\a はベル文字と呼ばれ文字通り端末でベル音が鳴ります。 \r は復帰文字(Carriage Return ; CR)と呼ばれ、もともとタイプライタのヘッドを先頭に戻す機能でした。一方普段改行として用いている\n は行送り(Line Feed; LF)を行う文字です。歴史的には個々のOSは異なる制御文字の組み合わせで改行機能を実装してきており、Windows のテキストエディタではCR+LF が改行を、現在のmacOSやLinux では LFのみで改行を表しています(改行コード問題)。CRを単体で使い行送りをしないでおくと、以降の文字出力を同じ行に上書きできるため、このようなメータ表示のような機能をさっと実装する際に用いることがあります。

またエスケープを表す文字\e といくつかの文字列の組み合わせは、ANSIエスケープシーケンスとよばれ、カーソル位置の制御や色の変更を行うことができます。これはC言語の機能というよりは、Unixなどの端末の機能です。以下をコマンドラインで打ってみましょう(WindowsかつVisual Studio でコマンドプロンプトを使っている場合は正しく動かない可能性があります)。

printf "\e[31mHello, %s\n\e[0m" Daisuke

おそらく赤い文字でHello, Daisuke と出力されたと思います。ここで使ったprintf はUnixのコマンドの一つで、C言語のprintf と同様、フォーマットを整えた文字列を端末に出力するコマンドです。上記で\e[31mそれ以降の文字出力を赤色に変更するシーケンス\e[0m元に戻すシーケンス です。ANSIエスケープシーケンスの例はこちらのサイトこちらのサイトをチェックしてみましょう。特に複数行カーソルを戻す機能を駆使することで端末上で擬似的にコマ送りを実現することができます。

scanfをうまく利用する

scanfprintfと対になる関数で、C言語の入門書などにキーボード入力からの値取得などの例で用いられてます。一方scanf は癖があり、何も考えずに適当に使うと意図した挙動をしないため、「とにかく使うな」といった指導も時々あります。まずはscanf を何も考えずに使うとどうなるのか、一例を示します。

#include <stdio.h>

int main(){
    int a = 10000;
    int b = 20000;
    printf("input a:");
    scanf("%d",&a);
    printf("input b:");
    scanf("%d",&b);
    printf("[%d %d]\n",a,b);
    
    return 0;
}

上記をコンパイルして、input a: と表示されているときに例えば10 c と意図しない入力を後ろにつけるとどうなるでしょうか?

上記の挙動は実はscanfは特段改行を特別視しない と、scanfは読み込みが止まった時点で文字列がストリームに残存する ということから起きます。前者はプログラムを書く側からするとテキストを打つ上で改行は特別な区切りのように感じますが、少なくともデフォルトのscanf にとってはスペース、タブと同じ空白文字の一種 でしかありません。scanfで改行文字までを読み取る実装をする場合は例えば以下のような書き方になります。

char buf[10];
scanf("%[^\n]%*1[\n]",buf);

scanf の仕様を細かくみてみると[] に囲われた文字のみを受け付けたり弾いたりすることが可能です。また%* ではじまるフォーマットはメモリに割り当てずに読み捨てることができます。これによって改行以外を全て読み取ってそのあと改行を読み取って捨てるとことで確実に一行を捕まえることができます。また実はscanf入力に成功した変数が何個か を返り値として返します。これを利用することで正しく読み取れたかをある程度検証することができます。

%*のあとにある数字は最大フィールド幅を表し、今回の場合改行1文字読み取ることを表します。ここに数字がない場合、例えば次の行が空行だったりすると続く空行を全て読み飛ばします。先行して空行を読んで欲しいかどうかに依存して書きわけます。今回は1行ごとに取り扱う想定のコードになっています。

一方もう一点scanf の別の問題点がわかる例を示します。

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

// 名前入力
// なぜこんなことがおこるのか?

typedef struct person{
    char name[10];
    unsigned char age;
} Person;

int main(int argc, char **argv) {
    
    Person p = { .name = "hoge", .age = 28 };
    
    // printf でプロンプトを表示
    // scanf は改行以外の文字をまとめて読むように指示
    // その後、代入抑止 %* で、代入せずに読み込む
    // scanf はデフォルトでは空白文字(スペース、タブ、改行)を読み飛ばしてしまう
    // 改行のみを区切り文字にするため、
    printf("Input the name: ");
    scanf("%[^\n]%*1[\n]",p.name);
    
    printf("age: %d\n",(int)p.age);
    printf("name: %s\n",p.name);
    
    return EXIT_SUCCESS;
}

上記のプログラムを写経してコンパイルしてみましょう。その上でDaisuke Saito と入力すると何がおきるでしょうか?

上記ではバッファオーバーラン と呼ばれる現象が起きています。ユーザ入力を伴うプログラムでは通常プログラマが想定していない入力に対して、ある程度頑健に対応する必要がありますが、想定するメモリ領域から溢れる現象が設計次第で起こることを念頭にいれておくことが重要です。scanf についてはWikipediaの記述も比較的合理的なことが書かれていると思いますので、一読してみるとよいと思います。

Let's try

以下をやってみましょう。

  • エスケープシーケンスで少し遊んでみましょう
    • 今日のライフゲームの描画でも用いています
  • scanf のバッファオーバランを防ぐように少し書き換えてみましょう

FILE型ポインタを用いたテキスト入出力と文字列を対象としたテキスト入出力

FILE型を導入することでファイル名をもとにこれを取り扱う仕組みを使うことができます。FILE型は多くの場合は構造体として実装されており、実際はFILE型へのポインタを用いてファイルに対しての入出力を行います。特にテキスト形式のファイル入出力のためには以下の関数があります(fopenおよびfcloseはバイナリ形式の場合でも使う)。

FILE *fopen(char *name, char *mode); // ファイルを開く
int fprintf(FILE *fp, const char *format); // ファイルfpへ書式付で文字列を出力
int fputc(int c, FILE *fp); // ファイルfpへ文字cを出力
int fclose(FILE *fp); // ファイルを閉じる

これまで標準入力および標準出力をベースにした入出力関数としてgetcharputcharprintfscanf が紹介されてきました。これらの関数にはそれぞれのFILE型ポインタバージョンchar*バージョン と呼べるものが存在します。

stdバージョン FILE* バージョン char * バージョン
一文字ずつ入力 getchar fgetc / getc 配列アクセス
一文字ずつ出力 putchar fputc / putc 配列アクセス
一行ずつ入力 gets fgets 強いて言えば(strtok)
char*を出力 puts fputs 強いて言えば(strcpy)
フォーマット入力 scanf fscanf sscanf
フォーマット出力 printf fprintf sprintf

このうち標準入出力で一行ずつ入力する機能を提供するgets は悪名高い関数で前述のバッファーオーバーラン を原理的に防ぐことができないため、非推奨になっています。stdバージョンとFILE* バージョンとの関係はFILE*バージョンのFILE型ポインタにstdin, stdoutをセットする ことで基本的に等価な関係になります。ただしgetsfgets (およびputsfputs)は改行文字の取り扱いに差があるため、 そこを考慮しつつfgets を適切に用いることでバッファーオーバーランを防ぎつつ行単位の入力が可能になります。

FILE型ポインタを用いた入力例を以下に示します (readfile.c)

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


int main(int argc, char **argv) {
    const size_t bufsize = 500;
    char buf[bufsize];
    
    if (argc != 2){
        fprintf(stderr,"usage: %s filename\n",argv[0]);
        exit(EXIT_FAILURE);
    }
    
    FILE *fp;
    
    if ((fp = fopen(argv[1], "r")) == NULL) {
        fprintf(stderr, "error: can't open %s\n", argv[1]);
        exit(EXIT_FAILURE);
    }
    
    while (fgets(buf, bufsize, fp) != NULL) {
        size_t len = strlen(buf) - 1;
        printf("%zd\n", len);
    }
    
    fclose(fp);
    
    return EXIT_SUCCESS;
}
Let's try

上記の入出力を写経して理解を深めてください。

ライフゲーム

ここからは少しだけ大きな規模のシミュレーションプログラムの実装を行ってみましょう。扱う題材はライフゲームというものです。

  • イギリスの数学者 John Conway が提案
  • 生命の誕生・衰亡・変化のシミュレーション

非常に単純なルールながら多様な変化を観察することができ、人工生命の研究なども行われています。

ライフゲームの更新則

ライフゲームは以下のルールに基づいて時刻ごとに盤面の状態が変化します。

  • 2次元グリッド上にセル(cell)が存在
  • 次の世代
    • 生きているセルが有る場所
      • 周囲に生きているセルが2個または3個存在するならばそのまま
      • そうでなければセルは消滅
    • 生きているセルが無い場所
      • 周囲に生きているセルがちょうど3個存在するならばセルが誕生
      • そうでなければそのまま

上記の時間発展をプログラムでシミュレーションします。

サンプルコードの入手

コマンドラインに慣れるため、サンプルコードをコマンドラインで入手してみましょう。まずは今回作業するディレクトリ を適当な名前(以下ではlifegame)で作成し、そちらに移動します。

mkdir lifegame
cd lifegame 

コマンドラインでウェブにアクセスするツールとして有名なものにwget および curl があります。Macの場合はcurlが標準で入っています。Linux や WSL2でもし入っていない場合はaptyum などそれぞれのディストリビューションのパッケージ管理ツールを使ってインストールしてください。余談ですが、wgetcurl の高速版としてaxelaria2などもあります。興味があれば調べてみてください。

また環境によってダウンロードすべきファイルが異なりますので注意してください。 どのようなOSやカーネルかを確認するUnixコマンドとしてunameがあります。

# OSやカーネルの情報を表示する
uname -a

環境といった場合、(仮想であれ実機であれ)コンパイルする環境を指します。特に間違えやすいのがGoogle Cloud Shell と WSL2 です。Google Cloud Shell を使っている場合、手元のマシンがどのOSであってもGoogle Cloud Shell はクラウド上にあるLinuxになっているので、Linux環境といえます。またWSL2はWindows上でLinux環境を再現する技術のうち、実際にLinuxの実行バイナリをそのまま動かしているためこちらもLinux環境といえます。

# wget + linux (WSL2およびGoogle Cloud Shell 含む) の場合
wget https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-linux.tar.gz
# wget + cygwin/minGWの場合
wget https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-win.tar.gz
# wget + macOS (Intel)の場合
wget https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-mac.tar.gz
# wget + macOS (Apple M1)の場合
wget https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-mac-m1.tar.gz
# curl + linux (WSL2およびGoogle Cloud Shell含む) の場合
curl -O https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-linux.tar.gz 
# wget + cygwin/minGWの場合
curl -O https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-win.tar.gz 
# wget + macOS (Intel)の場合
curl -O https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-mac.tar.gz
# wget + macOS (Apple M1) の場合
curl -O https://www.gavo.t.u-tokyo.ac.jp/~dsk_saito/lecture/software2/resource/soft2-lec01-mac-m1.tar.gz 

解凍

# まずは現在いるディレクトリ を確認: 先ほど作ったlifegame 
pwd
/home/hoge/lifegame
# ls するとダウンロードしたファイルがある : soft2-lec01-mac.tar.gz
ls
soft2-lec01-mac.tar.gz
# tar で解凍する
tar xvzf soft2-lec01-mac.tar.gz
# 解凍後のディレクトリ に入る
cd soft2-lec01-mac

コンパイル

いきなり、これまでと全然違うようなコンパイルがでてきて、焦ったひともいるかもしれませんが、読み解いていくとそれほど難しくはないはずです。

# キャリブレーション用のプログラムのコンパイル
gcc -o calibration calibration.c 
# ライフゲーム用のプログラムのコンパイル
gcc -o lifegame -Wall life.c -L. -lgol

まず-oオプションですが、これをつけないとa.out が実行ファイルになります。だんだん大きなアプリケーションを作る際は、-o で明示的に実行ファイル名を指定した方がよいケースが増えます。

次に-Wall は警告(エラーではない)を出せるだけ出すオプションです。この書き方まずいよとかを出してくれます。

-L. は現在のディレクトリ. をライブラリを探す対象にしてくださいという意味です。今まで皆さんが使ってきた標準ライブラリであればコンパイラはその実装・実体がどこにあるのかを知っていますが、こちらが用意した場合は知ることができません。このオプションでコンパイラにライブラリの探すべき在り処を教えてあげています。

-lgolは libgol.a を 読み込むことを指示しています。Linux でコーディングしている人は-lm をかいたことはあると思います。この場合はlibm.a(数学ライブラリの実体)を指定しています。-l??? を書く場所については注意が必要で、必須(依存性がある)のものから順に後ろから並べる というのを頭の片隅においてください。

サンプルコードを動かす

サンプルコードを動かしてみましょう。 まず表示領域を調整するために先ほどコンパイルしたcalibrationというプログラムを実行します。

./calibration

ターミナルのサイズをマウスで調整(主に縦に大きく)して、グリッド全体が表示されるように調整してください。

次に、lifegame を実行します。

# 引数なしで実行するとあらかじめ決められた初期配置になる
./lifegame
# 引数をあたえると、そのファイルから配置情報を読み取る
./lifegame gosperglidergun.lif

実行を停止する場合はCtrl-c を押してください。 引数で与えるファイルはLife1.06 と呼ばれる形式です。こちらに情報があります。ただし簡易実装のため、用意した盤面外の座標が入力にあるとバグる可能性が高いです。

関数のプロトタイプ宣言とヘッダファイル

これまではひとつのファイルにmain以外の複数の関数を実装する際にも、プロトタイプ宣言を用いていました。今回は、gol.h に関数プロトタイプが記載されており、libgol.a に(ソースコードはないが)関数の実装がある状態です。#include 宣言は標準で用意されているヘッダファイル以外にも、自作のヘッダファイルを読み込むことができ、その場合は#include "gol.h" のように"" でファイル名を囲います。

プログラム概観

ざっくりとプログラムを眺めてみましょう。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> // sleep()関数を使う
#include "gol.h"

int main(int argc, char **argv) {
    FILE *fp = stdout;
    const int height = 40;
    const int width = 70;
    
    int cell[height][width];
    for(int y = 0 ; y < height ; y++){
        for(int x = 0 ; x < width ; x++){
            cell[y][x] = 0;
        }
    }
    
    /* ファイルを引数にとるか、ない場合はデフォルトの初期値を使う */
    if ( argc > 2 ) {
        fprintf(stderr, "usage: %s [filename for init]\n", argv[0]);
        return EXIT_FAILURE;
    }
    else if (argc == 2) {
        FILE *lgfile;
        if ( (lgfile = fopen(argv[1],"r")) != NULL ) {
            init_cells(height,width,cell,lgfile); // ファイルによる初期化
        }
        else{
            fprintf(stderr,"cannot open file %s\n",argv[1]);
            return EXIT_FAILURE;
        }
        fclose(lgfile);
    }
    else{
        init_cells(height, width, cell, NULL); // デフォルトの初期値を使う
    }
    
    print_cells(fp, 0, height, width, cell); // 表示する
    sleep(1); // 1秒休止
    fprintf(fp,"\e[%dA",height+3);//height+3 の分、カーソルを上に戻す(壁2、表示部1)
    
    /* 世代を進める*/
    for (int gen = 1 ;; gen++) {
        update_cells(height, width, cell); // セルを更新
        print_cells(fp, gen, height, width, cell);  // 表示する
        sleep(1); //1秒休止する
        fprintf(fp,"\e[%dA",height+3);//height+3 の分、カーソルを上に戻す(壁2、表示部1)
    }
    
    return EXIT_SUCCESS;
}

本日の目標

gol.h に記載されている関数を自前で実装し最終的に以下の形式でコンパイルできるようになるのが目標です。このとき#include "gol.h" はコメントアウトしてOKとなるはずです。

gcc -o mylifegame -Wall mylife.c

実装方針

以下の関数を実装しましょう。

void my_init_cells(const int height, const int width, int cell[height][width], FILE* fp);
void my_print_cells(FILE* fp, int gen, const int height, const int width, int cell[height][width]);
void my_update_cells(const int height, const int width, int cell[height][width]);

各関数がどのような機能を期待されているかはmain関数、およびgol.h内のコメントを読んでみてください。 関数を一つ実装したら、他の関数を既存のものに固定して動かし逐次動作確認しながら進めてください。

いくつか気をつけるポイントをあげます。

  • ファイル引数がNULLのときのinit_cells はdefault.lif と同じ配置を実現するが、このファイルに依存しないこと(このファイルに記載されたものと同じ情報を直接関数内で設定する)。
  • 周辺のセルの数を数える際には配列インデックスの境界条件に注意する。
  • セルの状態は互いに依存関係があるため、一時変数となる配列に保存後、一斉に更新するのが望ましい。
Let's try

gol.h に記載された関数に相当するmy_*cells 関数とmy_count_adjacent_cells 関数を実装し、ライブラリなしでコンパイルできるようにしてみましょう。

課題(締切: 12/13)

  1. 引数がない場合の初期配置がランダムになるようにlife.cを修正せよ。
  • 実行のたびに異なる結果となるように工夫せよ。
  • 各世代で存在するセルの比率を表示するようにした上で、ランダム初期化の際に生きたセルの割合がおよそ10%になるようにすること。セル比率の表示場所は世代番号のあととする。
  • プログラム名はmylife1.cとする。
  1. 100世代ごとの盤面の状態をgen0100.lif(100世代目の状態)、gen0200.lif(200世代目の状態)... のようにLife1.06形式でファイルに出力するようにプログラムを修正せよ。
  • 出力ファイル名はgen%04d.lif のように4桁で左0埋めとする。10000世代以上ではファイル出力しない。
  • プログラム名はmylife2.c とする。
  1. 初期配置パターンをRLEフォーマットのファイルから読み込めるように拡張せよ。
  • Life1.06形式のファイルも読み込める状態でフォーマットに応じて読み込みが変わるようにする。
  • RLEフォーマットについてはこちらを参照する。
  • プログラム名はmylife3.cとする
  1. [発展課題] 適宜機能を追加し、プログラムを発展させよ
  • たとえばライフゲームのルールを適当に変更し振る舞いを確認する。生死判定の変更のほか、生物種を増やす、地形効果の導入等、急激な環境変化大幅な変更でもよい。
  • また盤面状態を画像、動画などで保存する方法を模索してもよい
  • どのようなルール変更、機能追加を行い、その結果どうなったかを簡単に説明せよ
  • プログラム名はmylife4.cとする

なお基本課題についてはmylife3.c に課題1および課題2の機能を実装し提出してもよい。その場合README.md にその旨も記載すること。発展課題については使用方法等が変わる可能性が高いため必ず別ファイルとする。

課題に取り組むにあたって

  • ライフゲームの本家Wiki(こちら)に入力できるサンプルがあるので、これをテストに用いるとよいでしょう。たとえばPlaintext 形式からLife 1.06 への変換等を試してみてください。
  • RLEフォーマットのx, y はオブジェクトのサイズを表します。これによって盤面サイズを大きくするわけではありません。もし大きなオブジェクトを試したい場合は、盤面サイズを変更してください。その場合はREADME.md に記載してください(対応するcaliblation.c を同梱するとより親切です)。

提出方法

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