ソフトウェアII 第2回(2024/12/05)

本日のメニュー

  • 構造体再確認
    • 構造体の基本
    • 構造体ポインタ
    • 構造体のサイズとアライメント
  • 乱数を使ったゲーム: ダーツ
  • 課題について

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

準備

ダウンロード

まずは今日のサンプルをダウンロードしましょう。ダウンロードするファイルは今回共通ですが、そのあとにやる作業が環境ごとに異なります。

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

ダウンロード後は解凍して、そのディレクトリに入ってください。 今回の解凍先は、できればGoogle Driveのマウントポイント等ではなく、ローカルが望ましいです。

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

シンボリックリンクの作成

ここでは少しUnixコマンド的な話です。動作確認のための異なる環境毎のライブラリが同じディレクトリに同梱されています。まずはシンボリックリンクをはる という操作をしてみましょう。シンボリックリンクをはるとは、あるファイルに対する別名を設定する操作です。ファイルをコピーすると同じ内容の別ファイルが作られますが、シンボリックリンクは、「この名前の時はここにアクセスしてね」という情報を付与します。Mac で右クリックした場合の「エイリアスを作成」に相当します。Windows の場合はショートカットに似ていますが、微妙に違います。まずはディレクトリにあるファイルを確認してみましょう。

# .a のファイルになにがあるか表示する
ls *.a 
libdarts-apple-arm64.a	libdarts-apple-x86_64.a	libdarts-linux.a

今回、1種類のライブラリ(libdarts-)が3通りの環境(Linux, IntelMac, AppliSiliconMac)毎に用意されています。プログラムをコンパイルする際は同じファイル名だと解釈したいので、ここではコピーではなくシンボリックリンクを使ってみます。シンボリックリンクを作る一般的なコマンドは以下です。

ln -s [ターゲットになるファイル名] [その別名]

たとえば同じディレクトリ 内にあるA.txt というファイルにB.txt という別名をつけたい場合は以下になります。

ln -s A.txt B.txt

ls -al をすると

total 104
drwxr-xr-x   4 dsk_saito  wheel    128 11 25 11:48 .
drwxrwxrwt  21 root       wheel    672 11 25 11:48 ..
-rw-r--r--   1 dsk_saito  wheel  50000 11 25 11:51 A.txt
lrwxr-xr-x   1 dsk_saito  wheel      5 11 25 11:48 B.txt -> A.txt

のようにB.txtA.txt の別名であることが示されています。B.txt のファイル容量は5B で、これは対象ファイル名のバイト数に対応します。A.txt 自体は50000B です。この操作は一つ上にあるディレクトリ等でも使えます。

ln -s ../A.txt B.txt

この時はB.txt は一つ上の階層のA.txtを指します。異なるディレクトリにあるファイルを指定する場合に、同じファイル名のシンボリックリンクを作る場合は、後ろの引数を省略できます。

# 今いるディレクトリにC.txt がシンボリックリンクとして作られる
ln -s ../C.txt 

さて、今回のディレクトリ構成の場合だと、環境毎に異なるファイルを対象に、同じシンボリックリンク名にします。自身の環境に応じて実行してください。

# Intel Mac
ln -s libdarts-apple-x86_64.a libdarts.a
# Apple M1 Mac
ln -s libdarts-apple-arm64.a libdarts.a
# Linux (WSL2, GCS含む)
ln -s libdarts-linux.a libdarts.a

もしコマンドを誤って貼り間違えた場合は、別名の方を削除します。

rm libdarts.a 
Let's try

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

  • 適当なテキストファイルを作ってシンボリックリンク作成を試してみましょう
  • ls -al で正しくリンクが貼られているか確認してみましょう

ここまでで環境準備ができたので、一旦授業のトピックに行きましょう。

構造体再確認

ソフトウェアIでも扱いましたが、構造体について再確認しましょう。

構造体とは

複数のデータ型をまとめあげ、新たなデータ型を作る仕組みです。まとめあげられたものを構造体と呼びます。 例えば以下は学生の名簿プログラムのようなものを考えた場合に、学生の必要情報をまとめあげた構造体です。

// id: 学生証番号相当の整数
// name: 名前
// age: 年齢。256歳でも大丈夫
// height: 身長
// weight: 体重

struct student {
    int id;
    char name[100];
    int age;
    double height;
    double weight; 
}; // 最後のセミコロンを忘れない

構造体の中の変数はメンバと呼びます。構造体のメンバへのアクセスには. を使います。

struct point {
    int x;
    int y;
};

int main() {
    struct point p1;
    
    p1.x = 10;
    p1.y = 20;
    
    return 0;
}

構造体の初期化や代入についてはソフトウェアIでも扱ったと思いますが、以下のstruct_init.c にまとめてあるので、確認しましょう。

// 構造体の初期化についてまとめました
#include <stdio.h>

// typedef と組み合わせてPoint型の宣言と定義を行います。
typedef struct point {
    double x;
    double y;
} Point;

int main(int argc, char **argv) {
    // 古いタイプの初期化
    Point p1 = { 1.0 , 2.0};
    
    printf("p1: (%3.1f %3.1f)\n", p1.x, p1.y);
    
    // 初期化指示子を用いた場合(こちらが可読性が高くおすすめ)
    Point p2 = {.x = 3.0, .y = 4.0};
    
    printf("p2: (%3.1f %3.1f)\n",p2.x, p2.y);
    
    // 複合リテラル: C99 から。
    // 構造体定数を(type-name){ initializer } で作成できる
    Point p3;
    
    // 一度宣言したあとは、p3 = { .x = 5.0, .y = 6.0}; だとエラー
    // (typename){} を使ってリテラルを代入
    p3 = (Point){ .x = 5.0, .y = 6.0};
    
    printf("p3: (%3.1f %3.1f)\n", p3.x, p3.y);
    
    // p3 に p2 を代入
    p3 = p2;
    
    printf("p3: (%3.1f %3.1f)\n",p3.x, p3.y);
    
    // 通常の配列(可変長でない)は、以下で初期化できる
    Point ps1[2] = { { .x = 1, .y = 2}, {.x = 3, .y = 4} };
    // **静的配列**の場合は、以下で配列サイズを取得可能
    size_t num_ps = sizeof ps1 / sizeof(Point);
    
    for (int i = 0 ; i < num_ps ; i ++)
        printf("ps1[%d]: (%3.1f %3.1f)\n", i,ps1[i].x, ps1[i].y);
    
    
    // 可変長配列の場合は、配列形式初期化が使えない
    size_t num = 2;
    // Point ps2[num] = { {.x=1,.y=1}, {.x=2,.y=2} }; // これはダメ
    Point ps2[num];
    ps2[0] = (Point){ .x = 1, .y = 2};
    ps2[1] = (Point){ .x = 3, .y = 4};
    
    for (int i = 0 ; i < num ; i ++)
        printf("ps2[%d]: (%3.1f %3.1f)\n", i,ps1[i].x, ps1[i].y);
    
    return 0;
}

構造体の宣言、定義とポインタ

構造体変数も単なる変数なので、その変数のアドレスを表すポインタを定義できます。

struct point {
    int x;
    int y;
};

int main(){
    struct point a;
    struct point *p = &a; // 構造体変数のアドレスをポインタに代入

    (*p).x = 10;
    p->x = 10; // アロー演算子(上と同じ意味でよく使う)

    return 0;
}

最後のアロー演算子は構造体ポインタを使う場合頻出の書き方です。よく覚えておきましょう。

構造体ポインタの使い方をいくつか見ておきましょう。代表的なものは関数呼び出しへの使用です。

void increment_age(struct student *s){
    s->age += 1;
}

void some_function() {
    // 構造体自体は、使う関数側で自動変数として確保
    struct student a =
      {
       .id = 1,
       .name = "Mike",
       .age = 21,
       .height = 175,
       .weight = 72 
      };
    // 構造体aが格納されているメモリのアドレスを渡す
    increment_age(&a);
}

もちろん構造体へのポインタではなく構造体を直接渡すことも可能です。ただしC言語の関数は全て値渡し(Call by Value)である という原則に従い、構造体のサイズ分のデータコピーが発生します。そのため大きな構造体を渡すときには注意が必要です。大きな構造体を関数に渡すときは原則ポインタの使用を考えましょう。

void print_age(const struct student *s){
    printf("age = %d\n", s->age);
}

上記の先頭にあるconst は「ポインタを渡すけどその参照先は書き換えちゃダメ」ということを示す修飾子です。これにより例えばs->age = 28; のような変更が関数内で書かれていた場合はコンパイラに怒られます。

冒頭の構造体のプログラムでは宣言定義 を同時に行っています。実は定義(具体的にどういうメンバ変数を組み合わせるか)を除いた以下の記述も問題なく動きます。

struct point; // 宣言のみ。どういう構造体かは定義しない

typedef struct point Student; // typedefと組み合わせて Student型を宣言

定義のない構造体宣言に何の意味があるのかと思われますが、実は構造体のメンバに直接アクセスしてほしくない場合には宣言だけしておくことがあります。この場合には構造体を指すポインタだけを宣言し、このポインタを介して実際のメンバ変数とやりとりします。詳細は分割コンパイル を取り扱う際に述べますが、頭の片隅に置いておいて下さい。以下の例だと Student s; としてStudent型の変数を宣言するとエラーになりますが、Student *s; とStudent型へのポインタは問題なく宣言することができます。

typedef struct student Student;

int main(){
    Student s; // この宣言はStudent型にどれだけメモリを割り当てるかわからないのでダメ
    return 0;
}

// コンパイルに成功する例
typedef struct student Student;

int main(){
    Student *s; // この宣言はStudent型のアドレスを格納するポインタの宣言なのでOK
    return 0;
}

構造体のサイズとアライメント

構造体は複数の変数を組み合わせたものです。構造体を構成した場合は、メンバ変数は連続したメモリ領域に確保されます。このとき構造体のサイズがどうなるかを確認してみましょう。sizeof演算子は型名に適用した場合、その型のサイズを返します。下記のプログラムの実行結果はどのようになるでしょうか?

#include <stdio.h>

struct point1{
    int x;
    int y;
};

struct point2{
    int x;
    int y;
    char c;
};

int main() {
   printf ( "%zu\n", sizeof(struct point1));
   printf ( "%zu\n", sizeof(struct point2));
   return 0;
}
Let's try

上記プログラムを写経して実行してみましょう。

なぜこのような結果になるでしょうか。構造体のメモリ配置には変数のアライメントという概念が関係してきます。CPUがメモリからデータを読み出す際に、まとまった単位(チャンク : 64bitマシンでは通常8B)でやりとりします。そのために特定のメモリアドレスの倍数にデータが配置されるように調整することで性能が改善し、この処理をアライメント といいます。この処理はコンパイラが自動的に行いますが、その過程で、構造体に配置された変数がそのまま連続して並べると条件を満たさない場合に、パディング という処理で空のデータ領域を途中に挿入します。なおx86アーキテクチャ(ほとんどのみなさんが普段使っているCPUアーキテクチャ)やARMアーキテクチャの場合、データサイズと同じ単位(sizeof で得られるサイズ)でデータのアライメントが取られます。なお構造体のアライメントを考える場合はその構造体が配列として連続してメモリ確保されるケースを想定するとよいです。struct point2 の場合、char は1バイトアライメントなので、どのアドレスに設置しても良さそうですが、この構造体の配列を確保した場合に、次のint の先頭アドレスがint のアライメント(4バイト)からずれます。これが起きないようにコンパイラはchar のあとに3バイトのパディングを挿入しています。

Let's try

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

  • 構造体の初期化・代入のサンプルコードであるstruct_init.c を参考に、1000個の要素を含むstruct point の配列から座標平均(=重心)を計算するプログラムを書いてみましょう。各点の座標はランダムに初期化してみてください。
  • 基本型の変数(char int double float など)を複数種類、宣言し、そのアドレスを表示してみてください。アドレスの末尾に着目してみましょう。
  • struct_alignment.c を修正し、定義された構造体のサイズを調べてみましょう。

乱数を使ったゲーム: ダーツ

前回の講義ではライフゲームを進行するためのルール(条件)を実装し、シミュレーションを行いました。今回はG乱数を用いてゲームを行ってみましょう。

ダーツ

ダーツは的にめがけて小さい矢を投げて、一定のルールに基づいて得点を競うものです。ボードや矢の種類によってハードダーツとソフトダーツに大別されますが、数字の配置など大まかな構成は同じです。

サンプルのコンパイル

サンプルプログラムをコンパイル、実行してみましょう。

# サンプルプログラム
gcc -o darts -Wall main.c -L. -ldarts -lm
./darts

コンパイルオプションはほぼ前回と同じ構成ですが、数学関数を使うために最後に-lm をつけています。

全体像

main.c の全体構造は以下のようになっています。

  • darts.h: 今回の構造体の定義と関数プロトタイプ宣言
  • libdarts.a: プロトタイプ宣言で宣言されている関数の実装
  • main.c: main 関数の記述

ボックス・ミュラー法を用いた正規分布乱数

C言語のstdlib.h に実装されているrand() は 0からRAND_MAXまでの整数の一様乱数生成関数です。実際の応用場面においては、一様分布以外の確率分布でシミュレーションを行いたい場合があります。一般には累積分布関数を用いた乱数生成により任意の分布が生成可能ですが、正規分布についてはいくつかよく知られた方法があります。今回はボックスミュラー法による乱数生成を考えます。

区間[0,1] における2つの独立な一様乱数\( U_1 \), \( U_2 \) が与えられた時、以下の\(X, Y\) は 平均0分散1の正規分布に従います。この変換法がボックスミューラー法です。 $$ X = \sqrt{-2\log U_1} \cos (2\pi U_2) $$ $$ Y = \sqrt{-2\log U_1} \sin (2 \pi U_2) $$

getoptを用いたオプション引数の解析

argvの解析に際して、unistd.h に実装されているgetopt という関数を用いることで、ハイフンがついたオプション引数を効率的に取り扱えます。講義内で紹介し、後ほどウェブも更新しますが、参考にしてください。

Let's try

以下に取り組んでみましょう。

  • 特に正規分布生成の分散をいじって遊んでみましょう。
  • メイン関数のあるソースを読んで全体像を把握してみましょう。
  • ヘッダファイルを読んでどのような関数が宣言されているか確認しましょう。
  • 全体像を理解した上で、実装すべき関数をmy_????? で実装しましょう。
  • ヘッダファイル内の使わなくなったプロトタイプ宣言の削除
  • -L. および -l???? を除いてコンパイル: -lm は残す

気を付けるポイントをあげます

  • プロトタイプ宣言はdarts.hに書いて問題ありませんが、関数の実装はmain.c に書いてください。
  • 構造体定義を変更するとライブラリが動かなくなります。

課題(締切: 12/18)

  1. struct_alignment.c の3つの構造体について、構造体中のメンバ変数に割り当てられているアドレスを表示するプログラムを作成せよ。

    • ファイル名はstruct_alignment.c のままでよい。
    • 講義でやった変数の型毎のアライメント等をもとにどのようなパディングが起きているかを考察しstruct_alignment.md に記して同梱せよ。
  2. まずはstruct_compare.tar.gz をダウンロードし展開する(以下参照)。このディレクトリにあるstruct_compare.c をコンパイルし、結果を確認せよ。このプログラムは構造体にある細工を行う関数trick を用いている(この関数はlibobj-*.a にある)。細工の前後で起きた変化を確認した上で以下に取り組むこと(考察はすべてstruct_compare.mdに記載し同梱する)。

    • 何が起きているかを説明せよ。
    • 上記の現象から構造体を比較する際になぜメンバを逐次比較する必要があるかについて記載せよ。
    • struct_compare.c にコードを追加して、trickが行った細工 を見つけよ。
    • コードを追加したstruct_compare.c を同梱すること。
  3. main.c をもとに座標情報から得られた得点を計算できるように拡張せよ。

    • ブル、シングル、ダブル、トリプルの範囲については後述する
    • 角度情報をもとに1から20までに範囲を振り分けることになる。
    • ファイル名は my_darts1.c
    • 本課題についてはmy_???? への実装が終わってから取り組むこと。課題の実装にあたって構造体の定義を変更する必要があるため、中途半端にとりかかると事前に同梱したライブラリが機能せずバグります。
  4. 課題3を実装した上で、ユーザの入力によって狙った場所を中心とする正規分布乱数となるように改良せよ。またgetoptを用いて、-v オプションで標準偏差を入力可能にせよ。

    • ユーザの入力はT3(トリプルの3)、D12(ダブルの12)、S3(シングルの3)、B(ブル)として与えられる。インブルとアウトブルはここでは区別しなくてよい。
    • ファイル名は my_darts2.c
  5. [発展課題] 本アプリケーションをさらに発展させよ。

    • ファイル名はmy_darts3.c
    • 発展のさせ方は任意
      • 01やクリケットと呼ばれるルールの実装。
      • 複数人による対戦形式
      • ユーザの入力タイミングで分散を制御
      • etc

課題2について

課題2にあたり、まずは以下の通りstruct_compare.tar.gz をダウンロードする。

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

その後展開し、環境に応じてシンボリックリンクを貼る。

# 展開
tar xvzf struct_compare.tar.gz
cd struct_compare
# 該当する環境のライブラリにシンボリックリンクを貼る
ln -s libobj-apple-arm64.a libobj.a
# コンパイル
gcc -o struct_compare struct_compare.c -L. -lobj
# 実行
./struct_compare

課題3の区分分けについて

ダーツでは領域におうじて点数に倍率がかかります。真ん中周辺はブルと呼ばれ、セパレートルールの場合は中心50点、周囲25点、ファットブルの場合は全て50点です。その外側は、内シングル、トリプル、外シングル、ダブルとなっていて、シングル1倍、ダブル2倍、トリプル3倍の得点となります。今回は、座標の中心からの距離に応じて以下のように設定してください。半径をrとして

  • r <= 1: インブル
  • 1 < r <= 3: アウターブル
  • 3 < r <=11: 内シングル
  • 11 < r <= 13: トリプル
  • 13 < r <= 18: 外シングル
  • 18 < r <= 20: ダブル

提出方法

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