こんにちは、ナナです。
int型やlong型などC言語にあらかじめ用意された型を「組み込み型」と呼びます。それに対して皆さんが、独自に定義できる型を「ユーザー定義型」と呼びます。
ユーザー定義型の1つである「構造体」を紹介しましょう。
本記事では次の疑問点を解消する内容となっています。
では、構造体の使い方を学んでいきましょう。
「構造体」ってそもそも何?捉え方を学ぼう
むしゃむしゃ、弁当うまっ!腹が減っては戦はできぬ。忍者たるもの常に戦える状態にしておくべしっ。これは修行の一環だから仕方ないのだ。
何か食べてるね。早弁してるよね。食べながらでもいいから聞いてね。
今回の内容は構造体だよ。複数の情報をパッケージ化する方法だね。君が食べている幕の内弁当も見方によっては構造体だね。
幕の内弁当って色々な種類の具材で構成されていますよね。いろいろな具材が集まって、全体として「幕の内弁当」を作り上げていると言えます。
具材のパッケージ化 = 幕の内弁当
私たちの周りにあるものって、多くのものが何かの集合体で出来上がっています。
「情報」というものも、まとめてひとつで扱いたい!そんな時に使うのが構造体です。
「構造体」は複数のデータをパッケージ化できる
皆さんはここまで変数を定義して様々なデータを管理してきました。その情報は単独で管理されるものでした。
しかし、世の中には次のような複数の情報から構成されるデータが存在します。
C言語ではこのような複数の情報をパッケージ化し、独自の型として定義することができます。これを「構造体定義」と呼び、情報を階層構造で管理することができるようになります。
「構造体」を使った型定義と変数定義の方法
エネルギー満タンになったところで、師匠!今日の講義はなんでしょうか?
なぜか、今ならどんな技でも扱える気がしますっ!
うん、理由はいっぱい食べたからだよ。でも、食べることに集中しすぎて何も聞いてないから、おさらいしておいてね。
じゃあ、構造体の定義方法を解説するよ。「型の定義」と「変数の定義」の違いを意識しながら聞いてね。
「型定義」と「変数定義」の違いを理解する
構造体を学ぶ時に、この2つがごっちゃになって理解ができない方が結構います。この違いを理解しましょう。
int型やshort型といった「データ型」とは変数を作り出す枠組みを示すものです。データ型によって情報の枠組みが決定します。
しかし、型とはあくまでも型でしかなく、型だけあっても情報を記憶することはできません。型を元にメモリ上に変数を作り出すことで、変数が情報を記憶できるようになるのです。
「構造体の型定義」とは、新しく「データ型」という鉄板を作ることです。まず、ここをはっきりと意識して、この続きを読んでください。
構造体の型定義の方法とは
「構造体の型定義」とは次のように行います。例として「座標情報」を構造体にしてみます。
構造体を示すstructキーワードの後に、「構造体タグ名」を付けて型定義を行います。構造体として管理したい項目は、「構造体メンバ」として並べます。
「構造体タグ名」と「構造体メンバ名」は、皆さんが任意の名前を付けることができます。もちろん、構造体メンバはいくつでも並べて追加することができます。
もう一度言いますが、この型定義とは「構造体の型定義」という鉄板を作っただけであり、変数を作ったわけではありません。
構造体の変数定義とアクセス方法
ここまで次のように変数を定義して、数値の読み書きを行ってきましたね。
long num; // 変数定義
num = 100; // 変数への書き込み
メモリ上に変数を作るためには、型定義とは別に「変数定義」が必要になります。変数を作ることで初めてメモリへの読み書きができます。
構造体も同じようにデータ型を元に変数定義を行うことで、情報の読み書きができるようになります。
変数定義と変数へのアクセスは、プログラムで次のように記述します。
変数定義は「struct + 構造体タグ名」でデータ型を指定し、続いて構造体の変数名を書きます。
構造体とは構造体メンバの集合体です。そのため構造体メンバには「構造体変数名.構造体メンバ名」のように、ドット演算子を使ってアクセスを行います。
「構造体の型」と「構造体の変数」の違いを明確に意識してくださいね。
構造体の定義で利用する「typedef」の役割
師匠!構造体の変数を作り出すのに「struct」という印を結ぶ手順が必要ですよね。もっと高速に技を繰り出したいんです。何か、何か方法はないんですか?
structの印を省略できるテクニック…実はあるんですっ!伝授しましょう。
それは、「typedef」を使って型定義を行うことです。
構造体の変数定義では、「struct」というキーワードを記述する必要性があります。これは今までの変数定義では必要なかったものでした。
long num; // long型
struct Coordinate pos; // Coordinate型
「typedef」を使うことで、変数定義時に「struct」の記述をなくすことができます。
「typedef」の役割と使い方
「typedef」キーワードを使うと、とあるデータ型名に、別のデータ型名を名付けることができます。
書き方
typedef 既存のデータ型名 新しいデータ型名;
使用例
typedef signed char S8;
実際の開発でもよく使われるのが、次のようなtypedefによる型定義です。
typedef signed char S8;
typedef unsigned char U8;
typedef signed short S16;
typedef unsigned short U16;
typedef signed long S32;
typedef unsigned long U32;
このように別名で型定義をすると、いったいどのようなメリットがあるのかを紹介しましょう。
新しく定義された型名を使うと、次のように変数定義をすることができます。
// 型名が長くて使いづらい
unsigned short num1;
// unsigned shortと同じ型だが記述が楽
U16 num2;
num1とnum2は本質的には同じunsigned short型ですが、U16という型名指定は簡単にできるのがわかるでしょう。これがtypedefの効果です。
「typedef」の効果とは
データ型に、別の「あだ名」をつけること
です。
「unsigned short型」というデータ型に「U16」というあだ名をつけて簡単に表現できるようになったということです。
私いつも「unsigned」の綴りがわからなくなるんです。長いしややこしいし、書きたくないんです!
そんな時に「typedef」を使うと便利ですね!
typedefを使った構造体定義と変数定義
さぁ、それでは構造体の記事で「typedef」が登場した理由を示しましょう。
構造体の型定義は「typedef」を利用して行うのが一般的だからです。実践的な構造体の型定義は、次のように定義します。
// typedefを使った座標構造体の型定義
typedef struct
{
double latitude; // 緯度
double longitude; // 経度
} Coordinate;
Coordinateが新しく定義した構造体のデータ型名となります。typedefを使用する場合は、「構造体タグ名」は省略します。
続いて、typedefで定義した構造体の変数定義は、次のように実施できます。
// 座標変数の作成
// structの指定が不要となる
Coordinate pos;
構造体でtypedefを使用するメリットは、変数定義でstructを記述する必要がなくなることです。
これは非常に小さなことに感じるかもしれませんが大事なことです。
変数定義は関数の引数などにも使用されるため、皆さんが思っているよりも、多くの場所で登場します。そのためtypedefを使用してstructと記述するコストを削減するのです。
私の場合、構造体を定義する際は指が自然と「typedef struct」と打ち込みます。それくらい構造体定義には「typedef」が使われるのが一般的なんです。
「構造体」に関する知っておくべきルール
師匠!構造体の「型定義」と「変数定義」に関しては習得しましたよ。でも、まだまだ使いこなせていません。私はもっと、もっと、もっと構造体の技を深く知りたいのですっ!
君は向上心が強いね。それはプログラミングを学ぶ上ですごく大事なことだよ。じゃあ、構造体に関するちょっとしたテクニックやルールを紹介するよ。
私たち開発者にとって言語機能の詳細を知るということは、武器の扱い方を知るということです。武器の扱い方を知ることが、戦い方をより有利な状況にしてくれます。
プログラミングを学ぶ時は、この姿勢を持つ人と持たない人では成長度が断然変わります。
構造体変数の初期化方法
配列と同様に構造体は変数定義をすると同時に、初期化することができます。初期化は次のように行います。
// 全メンバを初期化
Coordinate pos1 = {34.6461, 134.9991};
// 最初のメンバのみ初期化
Coordinate pos2 = {34.6461};
{}を使ってカンマ区切りで先頭から順に値を指定します。
必ず先頭からなので、2番目の構造体メンバのみ初期化したいということはできません。
配列と同じように全てのメンバを初期化しない場合、残りのメンバは0で初期化されます。
構造体変数同士のデータのコピー
構造体の情報をコピーする時に便利な書き方があります。この方法を知らない人は構造体メンバをひとつひとつコピーすることになります。
#include <stdio.h>
// 座標構造体の型定義
typedef struct
{
double latitude; // 緯度
double longitude; // 経度
} Coordinate;
int main(void)
{
Coordinate pos1 = {34.6461, 134.9991};
Coordinate pos2; // コピー先
//構造体情報の一括コピー
pos2 = pos1;
return 0;
}
このように同一の構造体変数は、お互いを直接代入することが可能です。
全ての構造体メンバの値が、そのままコピーされます。もし構造体メンバを追加した場合も、このコピー方法ならば修正することなくコピーができるのです。
構造体変数の配列定義とアクセス方法
通常の変数と同様に、構造体の変数も配列として定義することができます。
#include <stdio.h>
// 座標構造体の型定義
typedef struct
{
double latitude; // 緯度
double longitude; // 経度
} Coordinate;
int main(void)
{
//構造体変数の配列定義
Coordinate pos[3] =
{
//初期化も可能
{34.646186, 134.999142},
{38.259621, 140.882061},
{43.068551, 141.350975},
};
//配列とドット演算子でアクセス
pos[0].latitude = 33.521499;
pos[0].longitude = 130.534813;
return 0;
}
各配列要素と構造体メンバにアクセスするためには、このように配列インデックスとドット演算子を組み合わせて行います。
構造体を使った関数の引数と戻り値
関数定義の際に、引数と戻り値という入力と出力の情報が定義できました。
引数と戻り値の型には、「構造体の型名」を指定することが可能です。構造体の型を引数で受け渡す例を示しましょう。
#include <stdio.h>
// 座標構造体の型定義
typedef struct
{
double latitude; // 緯度
double longitude; // 経度
} Coordinate;
// 座標情報を画面に出力する
void printCoordinate(Coordinate pos)
{
printf("lat:%lf\n", pos.latitude);
printf("lon:%lf\n", pos.longitude);
}
int main(void)
{
Coordinate pos = {34.6461867, 134.9991424};
// 座標の表示
printCoordinate(pos);
return 0;
}
このように構造体の引数を持つ関数を定義することができます。
緯度と経度という本来2つの情報を引数で渡さなければならないのに、座標という1つの情報でまとめて渡すことができました。
構造体として複数の情報を1つにまとめるとは、関数の入出力情報を簡略化できるメリットがあります。
この例は引数ですが、戻り値の型も構造体にすることができます。構造体にすることで、複数の情報を戻り値で提供することもできるんです。
アラインメントとパディング
構造体変数に関する、メモリの配置ルールに関して押さえておきましょう。
実は、複数バイトで構成されるshort型やlong型の変数は、メモリ上に配置される際にとあるルールがあります。
それは、型サイズの倍数のメモリ番地に配置されるということです。このルールをアラインメント(境界調整)と呼びます。
short型 2の倍数の番地に配置される
long型 4の倍数の番地に配置される
このルールを踏まえた上で、構造体に話を戻します。
構造体として定義された構造体メンバは、記述した順番通りにメモリに配置されることになっています。
そのため、メモリ配置ルールを守るために、構造体メンバの型の並びによって「パディング」と呼ばれる詰め物が発生することがあります。
パディングが発生しない構造体メンバの定義
このように定義した構造体メンバは、型と番地の関係が配置ルールと合致しているため、パディングが発生しません。
では次のように構造体メンバの定義順を変更します。
パディングが発生する構造体メンバの配置
num2とnum4の手前に「空き」というメモリの空間ができてしましました。この現象をリアルに可視化するため、次のプログラムを動かしてみてください。
#include <stdio.h>
typedef struct
{
char num1;
short num2;
char num3;
long num4;
} S_NUM;
int main(void)
{
S_NUM num;
// 型サイズ表示
printf("size:%d\n", sizeof(S_NUM));
// 各メンバの番地表示
printf("num1:0x%p\n", &num.num1);
printf("num2:0x%p\n", &num.num2);
printf("num3:0x%p\n", &num.num3);
printf("num4:0x%p\n", &num.num4);
return 0;
}
このプログラムは、実際の構造体サイズと配置された構造体メンバのメモリ番地を表示するものです。動かすと次のような結果が表示されることでしょう。
size:12
num1:0x00EFFA2C
num2:0x00EFFA2E
num3:0x00EFFA30
num4:0x00EFFA34
構造体のサイズをsizeof演算子で算出した結果、12バイトであることがわかります。
num2とnum4の番地をよく考察してください。アラインメントの影響により、直前にパディングが発生しています。
※注意:%pにより表示したメモリ番地は動作させる度に変化します。
パディング確認問題
それでは皆さん、次の構造体がどのようにメモリに配置されるかイメージできますか?
実際に動かして確認してみましょう。皆さんが想像したものと異なるものがあるかもしれません。
パディング問題①
typedef struct
{
char num1;
short num2;
char num3;
} S_NUM1;
パディング問題②
typedef struct
{
long num1;
short num2;
long num3;
} S_NUM2;
パディング問題③
typedef struct
{
long num1;
short num2;
} S_NUM3;
問題③の謎が皆さん解けますか?ヒントは配列の記事に書かれています。
アロー演算子を使ったポインタからの構造体参照
「ポインタ」をすでに学習されている方は、構造体のアロー演算子について学んでおくとよいでしょう。
アロー演算子については『C言語 アロー演算子の使い方【ポインタから構造体を使う】』にて解説していますので、こちらを合わせて読むとよいでしょう。
カリキュラムを順番に進めている方は、気にせず進んでいけばよいです。
Q&A:構造体でよくある質問
構造体に関する質問していいですよ。
Q:typedefを使った構造体定義の書き方が、書式に従っているように見えないがなんで?
師匠!typedefを利用した印の省略の原理がわかりません。typedefはこの書き方ですよね。構造体定義の書き方ってなんか違いませんか?
書き方
typedef 既存のデータ型名 新しいデータ型名;
typedef struct
{
double latitude;
double longitude;
} Coordinate;
// なんか書き方が違いませんか?
書き方が沿ってないように見えるのは目の錯覚だよ。ちゃんとあってるんだよ。書き方の書式に合わせて、わかりやすく分解してみるよ。
構造体のtypedefは少しわかりづらいですが、typedefの書式に沿っています。改行をなくしてみますよ。
typedef struct { double latitude; double longitude; } Coordinate;
既存のデータ型名に違和感を感じるかもしれません。しかし、構造体タグ名を省略した構造体のことを「無名構造体」と呼び、これで型を示すことが可能なのです。
ね、あってますよね。C言語はフリーフォーマットの言語なので改行はあってもなくても関係ないんです。
Q:パディング確認問題③が解けません。なんでサイズが8バイトなの?
師匠!私を罠に嵌めましたね。この問題③からぷんぷんと匂うんですよ、怪しさが!忍者の私に罠を仕掛けるとは、いつやられても文句は言えませんよ、シュパッ。
痛っ!ちょ、危ないから名刺を投げないでっ。
問題③のポイントは、データを単独で見るんじゃなくて配列的に観察することだよ。
問題③はひっかけ問題にしてあります。皆さん謎は解けたでしょうか?
typedef struct
{
long num1;
short num2;
} S_NUM3;
このように1つのデータとして、観察するとアラインメントはきっちりと守られていますね。
しかし、この構造体を配列として定義した場合はどうなるでしょうか?
S_NUM3 tmp[2]; // 配列としてメモリを確保
配列には、あるルールが適用されるのでした。
それは「配列要素間にはメモリの隙間はないこと」です。これを知っていると、この問題は簡単に解けますね。
2つ目のデータを配置する際にlong型のtmp[1].num1を4の倍数の番地に配置するためパディングを2バイト詰めているのです。
名刺は人に向けて投げる道具じゃないよ!やっちゃダメ、絶対。
課題:構造体を学べたかを確認しよう
課題1
課題内容
次の構造体を定義せよ。typedefを利用すること。
次の関数を定義せよ。
次のプログラムに上記の構造体と関数を追加し、出力期待結果が表示されることを確認せよ。
#include <stdio.h>
int main(void)
{
S_Time time = {18, 28, 6};
printTime(time);
return 0;
}
出力期待結果
18時28分6秒
main.c
#include <stdio.h>
// 時刻構造体定義
typedef struct
{
unsigned char hour;
unsigned char min;
unsigned char sec;
} S_Time;
void printTime(S_Time time)
{
// ドット演算子でアクセス
printf("%d時%d分%d秒\n",
time.hour, time.min, time.sec);
}
int main(void)
{
S_Time time = {18, 28, 6};
// 構造体を引数で渡す
printTime(time);
return 0;
}
構造体はtypedefで定義する、これは身に付けておきましょう。
構造体変数の初期化は{}を使う、メンバへのアクセス方法はドット演算子を使う、ここがポイントですね。
課題2
課題内容
課題1のプログラムにさらに次の関数を作成せよ。
main関数は次のものに差し替えて動作させよ。
#include <stdio.h>
int main(void)
{
S_Time time;
// ケース①
time = setTime(18, 28, 6);
// ケース②
// time = setTime(18, 60, 6);
printTime(time);
return 0;
}
ケース①と②を切り替えてそれぞれ出力期待結果が表示されることを確認せよ。
出力期待結果
ケース①を選択
18時28分6秒
ケース②を選択
0時0分0秒
main.c
#include <stdio.h>
S_Time setTime(unsigned char hour, unsigned char min, unsigned char sec)
{
S_Time time = { 0 };
// 不正時刻チェック
if (hour >= 24 || min >= 60 || sec >= 60)
{
return time;
}
// 構造体に詰める
time.hour = hour;
time.min = min;
time.sec = sec;
// 構造体を返却
return time;
}
int main(void)
{
S_Time time;
// ケース①
time = setTime(18, 28, 6);
// ケース②
// time = setTime(18, 60, 6);
printTime(time);
return 0;
}
関数の戻り値の型として構造体を利用できます。この場合、構造体は複数の情報を管理しているため、複数の情報を戻り値で返却することができます。
構造体を引数や戻り値にする方法は実践でもよく利用されます。