このノートでは C++ の数値型とメモリ管理を扱います。
変数の型
本稿で 1 byte とは 8 ビットのメモリ単位、すなわち 1 byte = 8 bits を指します。
単純な変数
分類
- 整数型:char (1 byte), short (2 bytes), int (4 bytes), long (4 bytes), long long (8 bytes)。
- 浮動小数点型:float (4 bytes), double (8 bytes), long double (12 または 16 bytes)。
真偽型 (bool) は true と false のみを取ります。C++ は 0 を false、0 以外のあらゆる値を true と解釈します。
C++11 は固定幅の整数型を提供します。int16_t, int32_t, int64_t で、それぞれ 16 bits, 32 bits, 64 bits(つまり 2/4/8 bytes)です。
表現方法
整数の表現:
- 符号付き (signed) の場合、第 0 ビットが常に符号を表します(1 = 負、0 = 正)。残りのビットが値の大きさを表します。
- 符号なし (unsigned) の場合、すべてのビットが値の大きさを表します。
float (32 bits) の表現:
- 第 0 ビット / 符号部 (1 bit):符号を表す。1 = 負、0 = 正。
- 第 1–8 ビット / 指数部 (8 bits):範囲を表す。
- 残りのビット / 仮数部 (23 bits):精度を表す。
例として、-15.5 を 2 進のビット列で表してみます。
-
符号:負なので最初のビットは 1。
-
絶対値:15.5 = 15(整数部) + 0.5(小数部)
-
2 進変換:15 = (1111)_2、0.5 = 1/2 = 2^(-1) = (0.1)_2
-
オフセット:15.5 = (1111.1)_2 = 1.1111 * 2^3、offset = 3+127 = 130 = (10000010)_2
-
仮数:1.1111 → .1111 →(23 bits まで詰める)(11110000000000000000000)_2
すべてを組み合わせると、-15.5 = (1 10000010 11110000000000000000000)_2
double (64 bits) の表現:
- 計算手順は float と同じです。
- 符号部 1 bit、指数部 11 bits、仮数部 52 bits。
型変換
C++ では、ある型の値を別の型の変数へ代入できます。
一般に、より広い型への代入は問題を起こしません(int から long など)。しかし大きな long を float へ変換すると精度が失われることがあります。float の有効数字はおよそ 6 桁しかないためです。
広い範囲の値を、その型の範囲を超えた状態でより小さな型へ変換すると、通常は右端のバイトだけがコピーされます。
メモリ
メモリとは、コンピュータがプログラムの命令、データ、状態を保持する空間です。計算機システムの主記憶は、M 個の連続したバイト単位のセルからなる配列として構成され、各セルは一意の物理アドレスを持ちます。
記憶は連続したセルの列でできており、各セルは 0/1 の状態しか持たない 1 bit を表します。8 bits で 1 byte を構成します。byte はメモリアドレッシングの最小単位で、各 byte が一意のアドレスを持つため、コンピュータはアドレスを介して正しい byte にアクセスできます。
メモリアドレス空間
アドレス指定可能なすべてのメモリアドレスの範囲が、そのコンピュータのアドレッシング可能な範囲です。このアドレスの集合をメモリアドレス空間と呼びます。アドレス空間はシステムが 32 ビットか 64 ビットかに依存します。32 ビットシステムは 2^32 bytes = 4GB をアドレス指定でき、32 ビット機に 4GB を超える RAM を挿しても超過分は使えません。
変数の正体
C/C++ で変数を定義するのは、構文上はごく簡単です。
C++int a = 999;
char c = 'c';
変数を宣言するとは、それを格納するためのメモリの一部を要求することです。たとえば int は 4 bytes を占めるので、4 bytes が必要になります。数値は補数形式で格納され、999 の補数表現は 0000 0011 1110 0111 で、4 bits ずつが 1 byte に収まります。
byte の並びには 2 通りがあります。0000 0011 1110 0111 のまま置くか、逆順に置くかです。上位バイトを低位アドレスに置くのが big endian(ビッグエンディアン)、その逆が little endian(リトルエンディアン)です。big endian は人間の読み方に沿っています。この順序をバイトオーダーと呼びます。
システムのバイトオーダーは C++ のコードで判定できます。
C++int main() {
int num = 1;
char* ptr = reinterpret_cast<char*>(&num);
if(*ptr==1) {
cout << "Little-endian" << endl;
} else {
cout << "Big-endian" << endl;
}
return 0;
}
これが成り立つのは、整数 num が 1 (0x0000 0001) に初期化されており、そのポインタを int* から char* へキャストすることで最初の byte にアクセスできるからです。リトルエンディアンなら最初の byte は 1、そうでなければ 0 になります。最初の byte を調べればバイトオーダーが分かります。
一般に PC と Mac はリトルエンディアン、ネットワーク転送(特に TCP/IP)はビッグエンディアンを使います。Linux は場合によります。
メモリの領域
プログラムが動作するとき、コードやデータはそれぞれ異なるメモリ領域に置かれます。論理的には、コードセグメント、グローバル/静的記憶領域、スタック、ヒープ、定数領域に分かれます。
コードセグメント
.text セグメントとも呼ばれます。プログラムのバイナリコードを保持し、実行時に誤って書き換えられないよう読み取り専用です。文字列リテラルのような読み取り専用の定数を含むこともあります。
グローバル/静的記憶領域
グローバル変数と静的変数を格納します。この領域のメモリは、ほぼプログラムの寿命いっぱい存続します。
C++int globalVar = 0;
void function() {
static int staticVar = 0;
staticVar ++;
cout << staticVar << endl;
}
int main() {
function();
function();
return 0;
}
ここで globalVar はグローバル変数、staticVar は静的変数で、どちらもグローバル/静的記憶領域に置かれます。
static キーワード
static キーワードについていくつか。
- 関数の内部:void function() 内の static int のように、関数内で宣言された静的変数はプログラムの寿命を通じて値を保持します。関数が戻っても値は失われず、次の呼び出しでも前回の値を持ち続けます。上のプログラムでは function() を 2 回呼ぶとそれぞれ 0 と 1 を出力します。静的でないローカル変数なら、最初の呼び出しの後に値は失われていたはずです。
- クラスの中(変数の修飾):クラス内で宣言された静的メンバ変数は共有され、そのクラスのすべてのオブジェクトが同じ変数にアクセスします。静的メンバはクラス内で宣言しますが、定義と初期化はクラス外で行う必要があります。これによりシングルトンパターンに適した性質を持ちます。下のクラスでは health が static int として宣言されているため、GameManager のインスタンスを作らずに main() から health を変更・使用できます。シングルトンは getInstance() の中で静的な GameManager インスタンスを作ることで実現しています。変数が関数内で静的であるため、以降の呼び出しで再生成されません。
C++#include <iostream>
class GameManager {
public:
static int health;
static GameManager& getInstance() {
static GameManager instance;
return instance;
}
GameManager(const GameManager&) = delete;
GameManager& operator = (const GameManager&) = delete;
void show() {
std::cout << "This is the GameManager singleton instance." << std::endl;
}
static void showHealth() {
std::cout << "Health:" << health << std::endl;
}
private:
GameManager() {}
};
int GameManager::health = 0;
int main() {
GameManager::health = 80;
GameManager::getInstance().show();
GameManager::showHealth();
return 0;
}
- クラスの中(メソッドの修飾):静的メンバ関数はオブジェクトを実体化せずに呼び出せます。静的メンバ関数がアクセスできるのは静的メンバだけで、非静的メンバには触れません。上のコードで、(非静的な)show() と静的な showHealth() の違いに注目してください。
いずれの場合も、静的変数はグローバル/静的記憶領域に置かれます。これは覚えておいてください。
スタック
スタックはローカル変数、関数の引数、そして関数呼び出し時の戻りアドレスを保持します。
関数が戻ると、割り当てられていたスタック領域は自動的に解放されます。
C++#include <iostream>
void function(int a, int b) {
int s = a + b;
std::cout << s << std::endl;
}
int main() {
function(3, 4);
return 0;
}
ここで s はローカル変数、a と b は関数の引数なので、いずれのデータもスタック上にあります。function() が戻ると、対応するスタック領域は回収されます。
ヒープ
ヒープは動的メモリ確保に使われます。C++ の new(C の malloc)で確保したメモリはヒープに置かれます。これらは手動で解放しなければならず、さもないとメモリリークの危険があります。
C++#include <iostream>
int main () {
int* array = new int[10];
delete[] array;
return 1;
}
定数領域
文字列リテラルやその他のコンパイル時定数は定数記憶領域に置かれ、この領域は通常読み取り専用です。
メモリ管理
動的メモリ確保
C/C++ のプログラムが実行時に追加の仮想メモリを必要とするとき、動的メモリアロケータを使って新しいメモリブロックを要求します。
動的メモリアロケータはプロセスの仮想メモリ領域、すなわちヒープを管理します。アロケータはヒープを大きさの異なるブロックの集合として扱い、各ブロックは連続した仮想メモリの一片で、割り当て済みか空きかのいずれかです。
割り当て済みのブロックはアプリケーションが使用中で、空きブロックは利用可能です。あるブロックが不要になったら解放しなければなりません。そうすることで、以後の要求がそれを使えるようになります。解放には次の二種類があります。
- 明示的 (Explicit):C の free、C++ の delete など
- 暗黙的 (Implicit):Java や C# のガベージコレクタなど
プログラムがブロックを必要とするとき、動的メモリアロケータに割り当てを要求します。割り当ては常に明示的です(C の malloc、C++ の new)。
メモリの断片化
空きメモリの総量としては malloc/new の要求を満たせるのに、空きブロックが物理的にヒープ中へ散らばって(あるいは割り当て済みブロックに隔てられて)しまい、new が十分な大きさの連続領域を得られないことがあります。これがメモリの断片化です。
これは外部断片化とも呼ばれます。内部断片化も起こりえます。アロケータが最小ブロックサイズの要件を満たすために大きめのブロックを割り当てた結果、割り当て済みブロックがそのペイロードより大きくなる場合です。
フリーリスト
フリーリストは、アロケータが空きブロックを見つけて使うためのデータ構造です。実装には多くの方法があります。
暗黙的フリーリスト (Implicit Free List)
単純なブロック構造は次のようになります。
- ヘッダ:ブロックサイズ(先頭 29 bits)と割り当て状態(末尾 3 bits、001 = 割り当て済み、000 = 空き)。
- ペイロード:malloc/new が要求した実体。
- パディング(任意):アラインメントその他の要件を満たすために使われることがあります。
空きブロックはヘッダのサイズフィールドによって暗黙的に連結されます(ヒープの先頭から辿り、割り当てビットが 0 のブロックを探してサイズを比較する)。
利点は概念と実装が単純なこと。欠点は遅いことです。
明示的フリーリスト (Explicit Free List)
明示的フリーリストでは、各空きブロックがヘッダのあとに pred と succ のポインタを持ち、前後の空きブロックを指します。割り当て済みブロックは暗黙的な場合と同じです。割り当て時間は全ブロック数に対して線形だったものが、空きブロック数に対して線形になります。解放時間は、リストを LIFO 順で持つかアドレス順で持つかによって O(N) にも O(1) にもなります。
明示的リストは first-fit の時間を最適化しますが、最小ブロックサイズが大きくなり(空きブロックはヘッダとポインタを格納する必要がある)、それが断片化を増やすことがあります。
分離フリーリスト (Segregated Free Lists)
フリーリストを 1 本だけ持つのではなく、サイズクラスごとに複数持ち、対応するサイズクラスのリストの中を探索します。
参考資料
- 编程指北: https://csguide.cn/cpp/memory/what_is_memory.html
- C++ Primer Plus.
