T.TAO
Back to Blog
/13 min read/Others

C++ Programming #2 構造体と共用体

C++ Programming #2 構造体と共用体

本ノートは主に C++ における struct 構造体と同様の union 共用体の使用、およびそれぞれのメモリ上の実際の占有についてです。この種のデータ構造は異種データ構造(Heterogeneous Data Structure)とも呼ばれます。

Struct

宣言

struct の宣言は簡単です。StructName という名前の構造体を定義する構文は以下の通りです。

Plain Textstruct StructName {
    DataType1 member1;
    DataType2 member2;
    // ...
};

構造体変数を宣言する方法は以下の通りです。

Plain TextStructName myStruct;

ここで、構造体のメンバーには int、float、char などの異なる型のデータメンバー、または他の構造体、配列、ポインタを含めることができます。

構造体のメンバーにアクセスするにはドット演算子(.)を使用します。例:myStruct.member1;

初期化

宣言時に初期化できます。

Plain TextStructName myStruct = {value1, value2};

構造体へのポインタを宣言することもできます。その後、アロー演算子(->)でそのポインタが指す構造体のメンバーにアクセスします。

Plain TextStructName myStruct = {value1, value2};
StructName *myStructPtr = &myStruct;
std::cout << (myStructPtr -> member1); // not myStructPtr.member1 !

メモリアラインメント

構造体において、メモリアラインメントは重要な戦略であり、メモリ上で構造体のメンバーをどのように配置してアクセス速度を最適化し、メモリ空間の無駄を減らすかに関わります。

メモリアラインメントの基本原則は以下の通りです:

  1. 構造体の開始アドレスをアラインする。例えば、構造体の最大メンバーが uint32_t で 4 バイトを占有する場合、構造体の開始アドレスは 4 バイト境界にアラインされることが多い。
  2. 構造体メンバーのアラインメント。構造体内の各メンバーは、構造体の開始アドレスに対して、そのメンバーの型の自然アラインメント境界にアラインされる。例えば、4 バイトの int メンバーは通常 4 バイト境界にアラインされる。
  3. 構造体の総サイズのアラインメント。構造体の総サイズは、最大メンバーのアラインメント境界に合わせてパディングされる。つまり、構造体のサイズは全メンバーのサイズの合計より大きくなる可能性がある。

上記のアラインメント基本原則を満たすため、コンパイラは構造体メンバー間や構造体の末尾に追加の未使用メモリ(パディング)を挿入することがある。このメモリパディングは、各メンバーが適切なメモリアドレスに配置されることを保証する。

以下のような構造体定義があると仮定する。

Plain Textstruct SomeExample {
	char a; // 1 byte
	int b;  // 4 bytes
	char c; // 1 byte
}

アラインメント規則に従い、この構造体のメモリ上のレイアウトは以下のようになる:

  • メモリ上で最大のメンバーは int で 4 バイトを占有するため、4 バイト境界でアラインする。
  • char a:1 バイトを占有。3 バイトのパディング。
  • int b:4 バイトを占有。
  • char c:1 バイトを占有。3 バイトのパディング。

このとき、構造体全体のサイズは 12 バイトである。開始位置が 4 の倍数であれば、終了位置の次の位置も常に 4 の倍数になる。

構造体では、コンパイラは通常メンバーの宣言順にメンバーを配置し、各メンバーのアラインメント要件に応じて必要なパディングを追加する。したがって、構造体の宣言順序を調整する場合:

Plain Textstruct SomeExample {
	char a; // 1 byte
	char c;  // 1 byte
	int b; // 4 bytes
}

メモリアラインメント規則に従い、この構造体のメモリ上のレイアウトは以下のようになる:

  • char a:1 バイトを占有。パディングなし。
  • char c:a の直後に続き、1 バイトを占有。2 バイトのパディング。
  • int b:4 バイトを占有。パディングなし。

このとき、構造体全体のサイズは 8 バイトのみで、以前より 1/3 少なくなる。かつメモリアラインメントの要件を満たしている。

場合によっては、プログラマはポインタ演算の問題でアラインメント戦略を調整することがあり、必ずしも上記の2番目のケースが1番目より優れているわけではない。1番目のケースでは、int のサイズで開始位置を調整すれば各メンバーのアドレスを素早く得られるが、2番目のケースではポインタの移動ステップが 1 のときもあれば 2 や 4 のときもある。最終的には、プロジェクトの具体的なニーズに応じて構造体の宣言を調整する。

メモリの位置

C++ では、データ型は宣言方法と生存期間に応じて異なるメモリ領域に格納される。クラスと構造体も、宣言方法によってメモリ上の異なる位置に格納される可能性がある。

メモリに格納できる領域は以下の通りである:

  1. コードセグメント(Text Segment):プログラムの実行可能コード(機械命令)を格納する。通常は読み取り専用で、プログラムの誤った命令の変更を防ぐ。OS がプログラムをロードする際、コードセグメントを読み取り専用メモリにマッピングする。
  2. データセグメント(Data Segment):初期化済みのグローバル変数と静的変数を格納する。データセグメントはプログラム起動時に OS によって初期化され、プログラム終了時に解放される。プログラムの全ライフサイクルにわたって存在する。
  3. 読み取り専用データセグメント(Read-only Data Segment):文字列リテラルや定数などの読み取り専用データを格納する。読み取り専用データセグメントは通常読み取り専用で、これらのデータの誤った変更を防ぐ。
  4. BSS セグメント(Block Started by Symbol):未初期化のグローバル変数と静的変数を格納する。BSS セグメントはプログラム実行開始時にゼロに初期化される。データセグメントと異なり、ファイル内の実際のスペースを占有せず、OS がプログラムロード時に割り当ててゼロクリアする。
  5. ヒープ(heap):動的に割り当てられたメモリを格納する。ヒープメモリはプログラマが管理し、プログラム実行時に動的に割り当て・解放できる。ヒープメモリのサイズは通常スタックより大きいが、手動で解放する必要があり、そうでないとメモリリークの原因になる。
  6. スタック(stack):ローカル変数、関数引数、戻りアドレスなどを格納する。スタックメモリは OS が自動管理し、後入れ先出し(LIFO)の特徴を持つ。関数呼び出しのたびにスタックフレームが割り当てられ、関数が戻るとスタックフレームが解放される。

以下は一般的なデータ型とそのメモリ上の格納位置である。

ローカル変数(スタック)

ローカル変数は通常スタック(stack)に格納され、その生存期間は所属する関数のスコープ内である。関数呼び出し時にスタックフレームが割り当てられ、関数が戻ると解放される。

例えば、

Plain Textvoid foo() {
	int x = 10; // x は foo() のローカル変数で、スタックに格納される
}

グローバル変数と静的変数(データセグメント)

グローバル変数と静的変数はデータセグメントに格納され、これらの変数はプログラム開始時に割り当てられ、プログラム終了時に解放される。

Plain Textint globalVar = 10; // グローバル変数、データセグメントに格納

void foo() {
	static int staticVar = 20; // 静的変数、データセグメントに格納
}

動的割り当て変数(ヒープ)

new や malloc などで動的に割り当てられた変数はヒープ(heap)に格納される。これらの変数の生存期間はプログラマが制御し、delete や free で手動解放する必要がある。

Plain Textvoid foo() {
	int* p = new int(10); // p が指すメモリはヒープに格納される
	delete p; // ヒープメモリを手動解放
}

定数(コードセグメントまたは読み取り専用データセグメント)

文字列リテラルやその他の定数は通常コードセグメントまたは読み取り専用データセグメントに格納される。

Plain Textconst char* str = "Hello World!"; // 文字列リテラルは読み取り専用データセグメントに格納される

上記の説明から、C++ における構造体とクラスオブジェクトの格納位置もその宣言と割り当て方法に依存することがわかる。以下はいくつかの一般的なケースとその格納位置である:

構造体またはクラスオブジェクトが関数内でローカル変数として宣言されている場合、通常スタックに割り当てられる。例えば、

Plain Textvoid foo() {
	struct MyStruct {
		int a;
		int b;
	};

	MyStruct s; // s はスタックに格納される
}

グローバル変数または静的変数(データセグメント)

構造体またはクラスオブジェクトがグローバル変数または静的変数として宣言されている場合、データセグメントに割り当てられる。例えば、

Plain Textstruct MyStruct {
	int a;
	int b;
};

MyStruct globalStruct; // globalStruct はデータセグメントに格納

void foo() {
	static MyStruct staticStruct;  // staticStruct もデータセグメントに格納
}

動的割り当て(ヒープ)

構造体またはクラスオブジェクトが動的割り当て(例えば new 演算子の使用)で作成された場合、ヒープに割り当てられる。例えば、

Plain Textstruct MyStruct {
	int a;
	int b;
};

void foo() {
	MyStruct *p = new MyStruct(); // p が指すオブジェクトはヒープに格納
	delete p; // メモリを手動解放
}

クラスメンバー

クラスオブジェクトが他のクラスや構造体をメンバーとして含む場合、これらのメンバーの格納位置はそれらを含むオブジェクトの格納位置に依存する。例えば、

Plain Textstruct InnerStruct {
	int a;
	int b;
};

struct OuterStruct {
	InnerStruct inner;
};

void foo() {
	OuterStruct outer; // outer と outer.inner はともにスタックに格納
	OuterStruct *p = new OuterStruct(); // p が指す outer と outer.inner はともにヒープに格納
	delete p;
}

これらの規則は C++ のほとんどの場合に適用される。ただし、カスタムメモリアロケータの使用など、特殊な場合は格納位置が異なることがある。