第12章 構造体

テキスト『新・明解C言語 入門編 第2版』第12章の内容に関する補足説明を提供します.

12-1 構造体

データの関連性

List 12-1 で行っている, 対応(テキストの言葉では「関連性」)のあるデータのソートは, 構造体を使うと交換用の関数ひとつ(List 12-1 では swap_int と swap_str の2つ)で実現できます. 次のセクション説明しているように, 構造体は同一対象についてのさまざまな情報が1枚に記載されたカードのようなものです. カードのソートを行う関数をひとつ書けばよいのです. これは List 12-7 で学習します.

List 12-1 で <string.h> ヘッダをインクルードしているのは swap_str 関数の定義で strcpy 関数を使うためです. この関数については前章で学習しました(第1版 p.299,第2版 p.318).

List 12-1 の sort 関数で,swap_int 関数と swap_str 関数の引数に注意してください. 身長の値を交換する swap_int 関数は int へのポインタ型の値を引数にとります. そのため,アドレス演算子(&)を用いて,int 型の配列の要素 num[j - 1] および num[j] のアドレスを取得しています. 名前を交換する swap_str 関数は char へのポインタ型の値を引数にとります. ここで,swap_int 関数の引数につられて, &str[j - 1] および &str[j] としないよう注意してください. 配列 str(具体的には,配列 name)は文字列を要素とする配列です. 単に str[j] と書けば,&str[j][0] と同じ意味になります. これは j 番目の文字列の先頭文字へのポインタです.

同様に,main 関数において名前および身長を表示する printf 関数の引数では, name[i] は &name[i][0] と同じ意味です. これは i 番目の文字列(名前)の先頭文字へのポインタです. 第13章末尾の printf 関数の仕様を参照してください. 変換指定子が s のとき,「実引数は,文字型の配列へのポインタ」です. 名前の出力の %-8s という変換指定は, 8文字を左づめで出力するという意味になります.

構造体

構造体は配列と似ていますが,異なっている点もいろいろあります. テキストで順に学習していきます.

まずは,配列は型ではないのに対して,構造体は型であるということに注意してください. 自分が必要とする新しい型を宣言して使うことができます. テキストでは struct student という型を宣言しています. この点では,構造体は第8章で学習した列挙体(第1版 p.220,第2版 p.236)と似ています. 構造体タグ(たとえば,struct student 型での student)は列挙体タグ(たとえば,enum animal 型での animal)に相当します.

宣言と定義は区別されます(第1版 p.146,第2版 p.156の「宣言と定義」を参照). 構造体を Fig.12-3 のように宣言すると, コンパイラは宣言された型(ここでは,struct student 型)の構造がわかります. すると,この型のオブジェクトのためにメモリをどのように確保すればよいかわかります. 定義は,オブジェクトの実体を作り出すこと,すなわち, メモリが割りあてられたオブジェクトが作られることを意味します.

構造体の宣言は関数の中でも外でも行うことができます. 構造体をどの関数からも自由に使えるようにしたければ, 関数の外で宣言します. このテキストのプログラムでは, どの関数よりも前に構造体の宣言が行われています.

このセクションの最後に述べられているように, 構造体の宣言と定義を同時に行うこともできます. しかし,こうした書き方はするべきではないと考えるプログラマもいます. 後のセクションで学習する typedef 宣言と共存できないためと, 宣言と定義は分けるべきという考えのためです.

構造体のメンバと . 演算子

List 12-2 から12-5 では1人の学生のデータを扱います. Fig.12-2 のカードの比喩では,1枚のカードだけを扱うことを意味します. List 12-6 も同様です.

配列の要素には添字と添字演算子を用いてアクセスできました. 構造体のメンバーには添字でアクセスすることはできません. かわりにドット演算子を用います.

メンバの初期化

初期化の方法に関しては,配列と構造体は同じです. 大括弧 { } の中に初期化子をコンマで区切って並べます.

構造体のメンバは記憶域上で宣言順に並びます. たとえば,List 12-3 での構造体型のオブジェクト takao では, メンバは Fig.12-7 のように並びます. 名前を入れるための char 型の配列 name のアドレスが最も小さく, 体重を入れるための double 型の weight のアドレス(第1版では, 奨学金を入れるための long 型の schols のアドレス)が最も大きくなります. これを演習 12-1 で確かめてみてください.

構造体のメンバは隙間なく並んでいるとは限りません. この点は配列と異なりますので注意してください. 配列の要素は記憶域上で隙間なく並んでいました. たとえば,int 型の大きさが 4 の環境で int 型の配列を定義すると, 各要素のアドレスは 4 ずつ離れることになります. この配列が記憶域上で占める大きさは 4 の要素数倍です. 構造体ではメンバの間に隙間があるかもしれません. もしそうなっていると,各メンバの大きさの合計は, 構造体の大きさと一致しないことになります.

演習 12-1 で,オブジェクト takao のメンバが隙間なく並んでいるかを調べた結果が下図です. 名前を保存するメンバである char 型の配列 name の大きさは 16 としました(NAME_LEN の値を 16 としました). この環境では,char 型,int型,doule型 の大きさはそれぞれ 1,4,8 でした. 各メンバのアドレスや大きさを見ると, 氏名と身長は隙間なく並んでいますが(アドレスの差が16ある), 身長と体重の間には隙間がある(アドレスの差が4ではなく8となっている)ことがわかります.

pointer to members (ex 12-01)

テキストの第1版では,体重を記録するメンバ weight は float 型であり, 最後のメンバとして奨学金を記録する long 型の schols がありました. 演習 12-1 を行うと,メンバは隙間なく並んでいました(下図).

pointer to members (ex 12-01)

配列ではポインタに整数を加減する演算によって要素間を移動することができました. たとえば,配列の最初の要素を指しているポインタに 1 を加えると, 2番目の要素を指すポインタとなりました. 構造体では各メンバの大きさが異なりますので, このようなポインタ演算によってメンバ間を移動することはできません.

構造体のメンバと -> 演算子

式の中で,配列名はポインタに読み替えられました. 構造体ではこのような読み替えは行われません. 構造体へのポインタが必要なときは構造体名にアドレス演算子(&)を適用します. List 12-4 での関数 set_stdweight(第1版では関数 hiroko)の定義を見てください. 仮引数 s(第1版では std)は struct student へのポインタ型です. List 12-4 の main 関数では,struct student 型の構造体 takao(第1版では sanaka)にアドレス演算子を適用して, この構造体を指すポインタを得ています. このポインタが関数 set_stdweight(第1版では関数 hiroko)の引数となっています.

テキスト p.337(第1版 p.315)で述べられている演算子の優先順位については Table 7-13(第1版 Table 7-11)で確認してください. ドット演算子の優先順位は1,間接演算子の優先順位は2です.

構造体と typedef

構造体は int や char と同じく型なのに, 定義が struct student sanaka のように長くなる(いちいち struct というキーワードが必要になる)ことが面倒に感じられるかもしれません. そう感じるならば,typedef 宣言を用いて型の別名を作ることができます. そのようにしなければならないということではありません. キーワード struct があることで構造体であることが明示されるのだから, struct とタイプする手間ぐらい惜しむべきではないというプログラマもいるようです.

テキストでは typedef 名の最初の文字だけを大文字にしています. オブジェクト形式マクロで定義する記号定数はすべて大文字で表記していたので, こうしておけば, 記号定数と同じ typedef 名をうっかり使ってしまうことを避けられます. 第8章で学習した列挙定数も,最初の文字だけを大文字にしていました.

構造体のタグ名と typedef 名の違いが先頭文字だけ(大文字か小文字の違い)となるのは, テキストではお勧めできないと書かれています. しかし,まったく違う名前を考えることは面倒かもしれません. 意図的に同じ名前を使うことを好むプログラマもいるようです. テキストでは,構造体を定義するときに, タグ名を省略して typedef 名を与えることで, タグ名と typedef 名の衝突を避ける方針のようです. List 12-7 での,学生を表す構造体の定義を参照してください.

テキスト p.333(第1版 p.311)で,構造体の宣言と定義を同時に行うことができるということが述べられていました. その補足説明で述べたように,この方法と typedef 宣言は共存できません.


struct xyz {
    int    x;
    long   y;
    double z;
} a;

と書けば,struct xyz 型の構造体を宣言し, この型のオブジェクト a を定義したことになります. 似ているようでも,


typedef struct xyz {
    int    x;
    long   y;
    double z;
} a;

と書けば,struct xyz 型の構造体を宣言し, この型の別名として a を与えたことになります. まぎらわしいので注意してください.

構造体とプログラム

このセクションについての補足説明は現在のところありません.

集成体型

テキストで説明されているように,構造体は同じ型の構造体に丸ごと代入可能です. これは構造体の大きな利点のひとつです. 配列ではこれができないので,要素をひとつずつ代入しました. 文字の配列の場合には,strcpy 関数あるいは strncpy 関数を使いました.

構造体の値を返却する関数

配列を関数の返却値とすることはできないので, もし実質的にそのようなことがしたければ, 配列の先頭要素へのポインタを返す関数を設計することになります(たとえば,List 11-6). 関数の引数を配列としたい場合も同様です. 配列を引数にとる関数定義を書くことはできますが(たとえば,List 6-11), これは実際にはポインタが使われているのでした.

構造体は型なので,関数の引数とすることも(たとえば,List 12-8), 関数の返却値とすることもできます. テキストの説明では, 構造体は「代入可能だから」関数の返却値型として利用できると書かれていますが, 単に int 型やポインタ型と同様の「型だから」と理解した方が簡単でしょう. 一方,配列は int 型やポインタ型のような型ではありません. テキストでの「代入可能だから」という説明は, 関数の返却値は同じ型のオブジェクトに代入できるという意味だと思われます.

構造体は関数の引数にできますが, 大きな構造体ではそうした関数を書かない方がよいかもしれません. C言語では実引数のコピーが関数の仮引数に渡されるのでした(第1版 p.140,第2版 p.150). 関数に構造体を渡すとき,構造体全体がコピーされます. 大きな構造体ではコピーのために時間と記憶域を少なからず使うことになります. それならば,構造体を渡すのではなく, 構造体へのポインタを渡した方がよいかもしれません.

関数に構造体を渡すか,構造体へのポインタを渡すかは, 構造体の内容を書き換えるかどうかにも依存します. 構造体を渡した関数で構造体の内容を書き換えたいのならば, ポインタを渡すしかありません. コピーを渡しても,もとの構造体の内容は変化しないからです. 内容を書き換えを行わないならば, 関数に渡すのは構造体そのものでもポインタでもよいでしょう. あとで学習する List 12-9 で定義されている move 関数は, 構造体の内容を書き換えるため, 構造体へのポインタを受け取ります.

名前空間

テキストで学習した範囲では,タグ名を使うのは列挙体と構造体, メンバ名を使うのは構造体だけです. このテキストから先に学習を進めていくと,「共用体」というものにタグとメンバがあります.

ラベル(第1版では「名札」)とは「プログラムの飛び先を示す目印」(第1版 p.65,第2版 p.67)のことです. 第3章で学習した switch 文(第1版 p.64,第2版 p.66)では, case および default というラベル(名札)を使いました(case ラベルは定数と組み合わせて case 1 のように使いました). ラベルでは識別子のあとにコロン(:)をつけます. たとえば,switch 文では,case 1: とか default: というように書きました. テキスト p.341(第1版 p.319)で例示されている main 関数でのラベル x は, この位置に飛ぶ処理が何も書かれていないので, 実質的な意味はありません.

テキスト p.341(第1版 p.319)で例示されている main 関数で, 「一般的な識別子」に該当するのは変数名 x です.

構造体の配列

List 12-7 は List 12-1 とよく比較してください. そうすれば理解できるはずです.

派生型

このセクションについての補足説明は現在のところありません.

12-2 メンバとしての構造体

座標を表す構造体

このセクションについての補足説明は現在のところありません.

構造体のメンバをもつ構造体

List 12-9 の put_info 関数は構造体を受け取ります. 構造体へのポインタを受け取るようにこの関数を書き換えると, 以下のようになります.


void put_info(Car *c)
{
  printf("現在位置:(%.2f, %.2f)\n", c->pt.x, c->pt.y);
  printf("残り燃料:%.2fリットル\n", c->fuel);
}

List 12-9 の move 関数は,構造体の内容を書き換えるため, 構造体へのポインタを受け取ります. 構造体そのものを引数とすると, 関数に渡されるのは構造体のコピーとなるため, 内容を書き換えられません.

Liat 12-9 の move 関数で,移動距離が燃料を超過している場合, つまり,[2] の if 文の制御式 d > c->fuel が真となる場合には, 0 が返されてこの関数による処理は終わります. [3] および [4] は実行されないので,構造体の書き換えは行われません. こうした動作は,move 関数を以下のように書いた方がわかりやすいかもしれません.


int move(Car *c, Point dest)
{
  double d = distance_of(c->pt, dest);

  if (d > c->fuel)
    return 0;
  else {
    c->pt = dest;
    c->fuel -= d;
    return 1;
  }
}

List 12-9 の main 関数での while 文は,制御式の値が 1 ですので, 無限ループです.このループを抜けるのは select の値が 1 でないとき, つまり,「移動しますか」という問いに対する回答が Yes ではなかったときです.

List 12-9 の main 関数では,while ループの中の最後の if 文で, Car 型の構造体 mycar へのポインタと,Point 型の構造体 dest を move 関数に渡しています. 移動距離が燃料を超過している場合には, 自動車の位置の書き換えは行われず,move 関数は値 0 を返します. すると,main 関数は「燃料不足で移動できません」というメッセージを表示し, 次の while ループに入ります.