この記事は、大学のロボット開発サークルの新入生に向けて作成した資料を、Qiita向けに加筆・修正したものとなっています。
はじめに
プログラムを書いていると、「変数に値を入れる」、「関数に引数を渡す」といった操作を当たり前のように行います。これらはすべてメモリに対する操作であり、メモリについて理解していけば、プログラミングもより簡単に理解できるようになると思います。
そのため、この記事ではプログラミングの基礎文法は(ちょっとでも)理解しているが、メモリの概念については不慣れな人に向けて、プログラムの動きとメモリの動きの関係について書いていきます。
私の知識不足なところも、初心者向けに説明を省いているところもあるので、ご了承ください。
1. メモリと変数
メモリとはなにか
コンピュータのメモリ(RAM)は、物理的には1バイト(8ビット)ごとに番号が割り振られた、巨大な一次元の配列です。この番号のことをメモリアドレスと呼びます。
アドレス: [ 0x00 ][ 0x01 ][ 0x02 ][ 0x03 ][ 0x04 ][ 0x05 ] ...
データ: [ 37 ][ 41 ][ 47 ][ 53 ][ 57 ][ 59 ] ...
プログラムが扱う数値、文字、オブジェクトなど、すべてのデータが、最終的にはこの配列のどこかの番地に書き込まれています。
メモリの中からアドレスを指定し、値を読み込んだり書き込んだりすることで、プログラムに書かれた処理が行われていきます。
変数の正体
そんなメモリに対して、「アドレス 0x00 の値を取り出せ」などという命令を毎回書くのは、人間には非常に不便です。そこで登場するのが変数です。
変数とは、大雑把に言えば特定のメモリアドレスに対して、人間が読みやすい名前を付けたものです。int x = 42; という宣言は、コンピュータに対して「メモリに4バイトの場所を確保して、そこに42を書き込み、その場所に x という名前を付ける」という指示を出すということです。
そして x をプログラム中で使えば、そのアドレスの値を読み込むことができます。
#include <iostream>
int main() {
int x = 42;
std::cout << "xの値: " << x << "\n"; // 42
std::cout << "xのアドレス: " << &x << "\n"; // 例: 0x7ffde4b3c1a4
}
&x と書くと、x が格納されているメモリアドレスそのものを取得できます。実際に実行してみると、0x7ffde... のような16進数が表示されるはずです。これが変数 x のアドレスです。
変数: [ x ]
アドレス: [ 0x7ff... ] -> &x で取得
データ: [ 42 ] -> x で取得
アドレスの役割や必要性については後ほど説明します。
型とサイズの関係
変数を宣言する際に指定する型(int、double、char など)は、単にデータの種類を表すだけでなく、確保するメモリのサイズも決定します。
#include <iostream>
int main() {
char c = 'A';
int i = 42;
double d = 3.14;
// sizeof()でその型が何バイトを占めるか確認できる
std::cout << "char のサイズ: " << sizeof(c) << " バイト\n"; // 1
std::cout << "int のサイズ: " << sizeof(i) << " バイト\n"; // 4
std::cout << "double のサイズ: " << sizeof(d) << " バイト\n"; // 8
}
int x = 42; は「4バイトの領域を確保し、そこに42を書き込む」という操作ということです。
先程メモリアドレスは1バイトごとに割り振られると言いましたが、intやdoubleなど、変数が複数のバイトにまたがってメモリを確保する場合、その変数のアドレスは確保された連続領域の先頭のアドレスを指します。
例えば、int型が4バイトの環境で int x = 42; と宣言し、コンピュータがアドレス 0x100 から 0x103 までの4バイトを x のために確保したとします。このとき、&x で取得できるアドレスは先頭の 0x100 のみです。
&x
↓
アドレス: [ 0x100 ~ 0x103 ]
データ: [ 42 ]
コンパイラ: 0x100から4バイト読み込んで、42を返すよ
その上で、コンパイラが「変数 x のアドレスは 0x100で、 int だから4バイト使っているんだなー」と判断してくれます。そのため変数を使うときは、4バイト分の領域をまとめた一つのデータとして読み込んでくれます。
つまり、型を意識するというのは「自分のプログラムが何バイトのメモリを消費しているか」や、「先頭アドレスから何バイト分を読み取るべきか」という、コンピュータに対するデータの読み書きのルールを教えているということです。
2. メモリ領域の種類
プログラムが実行されるとき、使用するメモリは性質の異なる複数の領域に分かれています。今回は特に重要な2つ、スタックとヒープを解説します。
スタック領域
スタック領域は、変数の定義、関数の呼び出しに合わせて自動的に確保・解放されるメモリ領域です。
データを「積み重ねる(push)」「取り出す(pop)」という後入れ先出し(LIFO: Last In, First Out)の構造で管理されています。お皿を積み重ねたときに、一番上のお皿から取り出すイメージです。
void A() {
// 1 [ 関数A ]
int a1 = 42;
// 2 [ 関数A ][ a1 ]
B();
// 5 [ 関数A ][ a1 ]
// ブロック文
{
int a2 = 57;
// 6 [ 関数A ][ a1 ][ a2 ]
}
// 7 [ 関数A ][ a1 ]
}
void B() {
// 3 [ 関数A ][ a1 ][ 関数B ]
int b1 = 1;
// 4 [ 関数A ][ a1 ][ 関数B ][ b1 ]
}
// 1 [ 関数A ]
// 2 [ 関数A ][ a1 ]
// 3 [ 関数A ][ a1 ][ 関数B ]
// 4 [ 関数A ][ a1 ][ 関数B ][ b1 ]
// 5 [ 関数A ][ a1 ]
// 6 [ 関数A ][ a1 ][ a2 ]
// 7 [ 関数A ][ a1 ]
実際にはスタックフレームなどがあるため上のイメージとは違うのですが、かなり大雑把に言えばこんなイメージです。
変数が定義されたり関数が呼び出されたりすると、変数やスタックフレームがスタックに積まれます。関数やブロック文が終了すると、それらは自動的に取り除かれます。この仕組みのおかげで、プログラマがメモリの解放を意識しなくても済みます。
スタックは管理が非常にシンプルなため、確保と解放が非常に高速です。ただし、使用できる容量は数MB程度であり、後述するヒープと比べて小さいという制約があります1。
また、スタック領域に置ける変数はコンパイル時にサイズが確定している必要がある、という制約もあります。
もし可変サイズのものがスタックに確保されてしまったら、要素の追加時に他の変数を上書きしてしまうかもしれないからです。
int main() {
// もしvectorがスタック領域に確保されてしまったら?
std::vector<int> vec(2, 1);
// [ 1 ][ 1 ]
int a = 10;
// [ 1 ][ 1 ][ 10 ]
vec.push_back(2);
// 変数aがvecの要素に上書きされてしまった...
// [ 1 ][ 1 ][ 2 ]
// 実際にはヒープという別のメモリ領域に確保され、スタックにはポインターが保存される
// スタック: [ {vecへのポインター} ][ 10 ]
// ヒープ: ...[ 1 ][ 1 ][ 2 ]...
}
事前に大きな容量を確保しておけば、と思うかもしれませんが、可変サイズである以上、どれだけ大きく確保しても上限を超える可能性がなくなるわけではありません。
そのため、可変サイズの変数はヒープ領域にメモリを確保し、スタックにはそのメモリの場所を表すポインターを積みます。スタックに比べて自由にメモリを確保・解放することができるため、容量の拡張も簡単です。
スタックオーバーフロー
スタックには容量の上限があるため、それを超えるとスタックオーバーフローというエラーが発生し、プログラムが強制終了します。典型的な原因は、再帰関数の呼び出しが深くなりすぎることです。
#include <iostream>
// 終了条件のない再帰関数
// 呼び出されるたびにスタックに積まれ続け、やがて溢れる
void A(int depth) {
std::cout << "深さ: " << depth << "\n";
A(depth + 1); // 永遠に自分自身を呼び出す
}
int main() {
A(0);
// [ 関数A ][ 関数A ][ 関数A ][ 関数A ][ 関数A ]...
// → スタックオーバーフロー発生、プログラムがクラッシュする
}
このコードを実行すると、数万回の呼び出しの後にプログラムが異常終了します。
ヒープ領域
ヒープは、プログラマが実行時に明示的にサイズを指定して確保(allocate)するメモリ領域です。CPUはスタック領域にあるポインターからヒープ領域のメモリにアクセスし、データを参照します。
スタックとは異なり、使用できる容量はシステムの搭載メモリに依存するため非常に大きいです。また、スタックのように実行順にデータが配置されていくということはなく、かなり自由です。そのためスタック領域でアドレスを指定しないと、CPUはデータの場所を見つけることが出来ません。
そして、確保したメモリはプログラマが責任を持って解放する必要があります。解放しなければ、そのメモリは永遠に占有され続けます(これをメモリリークと呼び、第4章で詳しく解説します)。
| 比較項目 | スタック | ヒープ |
|---|---|---|
| 確保・解放のタイミング | 自動(関数呼び出し/終了) | 手動(プログラマが指示) |
| 速度 | 高速 | スタックより遅い |
| 容量 | 小(数MB程度) | 大(システム依存) |
| 管理の手間 | 不要 | 必要 |
3. ポインターの役割
ポインターとは何か
第1章で「変数はメモリアドレスに名前を付けたもの」であり、メモリアドレスそのものは &x とすることで取得できると説明しました。
そしてポインターとは、メモリアドレスを値として持つ変数のことです。
int x = 42;
int* p = &x;
変数: [ x ] [ p ]
アドレス: [ 0x100 ] -> &xで取得 [ 0x104 ] -> &pで取得
データ: [ 42 ] -> xで取得 [ 0x100 ] -> pで取得
ここで、ポインター int* ptr の中身、つまりptr が指し示すアドレスに格納されている値を取り出したいときは、脱参照(デリファレンス)をする必要があります。
#include <iostream>
void func(int* ptr) {
int y = *ptr; // 脱参照して値を読み取り
std::cout << y << std::endl; // 42
}
int main() {
int x = 42;
func(&x);
}
脱参照の記号は * ですが、ポインター型を表す int* の * とは別物です。注意してください。
値渡しとポインター渡し
ポインターの必要性は、関数などにデータを渡すときに表れます。
まず、通常の値渡しを見てみましょう。
#include <iostream>
void addTen(int n) {
n = n + 10; // コピーを書き換えているだけ
std::cout << "スコア加点!: " << n << "\n"; // 110
}
int main() {
int score = 100;
addTen(score);
std::cout << "最終スコア!: " << score << "\n"; // 100(変わっていない)
}
値渡しでは、関数の引数として割り当てられた値はコピーされて渡されます。関数内でそのコピーを書き換えても、呼び出し元の変数には何の影響もありません。
[ main ][ score = 100 ] // int score = 100
[ main ][ score = 100 ][ addTen ][ n = 100 ] // addTen(score)
[ main ][ score = 100 ][ addTen ][ n = 110 ] // n = n + 10;
[ main ][ score = 100 ] // addTen()終了
また、int prev_score = scoreなどと書いても値はscoreからprev_scoreへコピーされます。
(そのためstd::vector<int> scores(1'000'000, 0); std::vector<int> prev_scores = scores;なんてことをやると100万回のコピーが実行されるので注意する必要があります。)
次に、ポインター渡しを見てみましょう。
#include <iostream>
void addTen(int* ptr) {
*ptr = *ptr + 10; // アドレスの先の値を直接書き換える
std::cout << "関数内: " << *ptr << "\n"; // 110
}
int main() {
int score = 100;
addTen(&score); // アドレスを渡す
std::cout << "関数外: " << score << "\n"; // 110
}
アドレスを渡すことで、関数内から呼び出し元の変数を直接操作できます。
[ main ][ score = 100 ] // int score = 100
[ main ][ score = 100 ][ addTen ][ ptr = {scoreのアドレス} ] // addTen(&score)
[ main ][ score = 110 ][ addTen ][ ptr = {scoreのアドレス} ] // *ptr = *ptr + 10;
[ main ][ score = 110 ] // addTen()終了
大きなデータもコピーせずに渡せるため、メモリ効率が良くなり、処理速度も速くなります。
#include <iostream>
#include <vector>
constexpr int N = 10'000'000;
void push_one_copy(std::vector<int> vec) {
vec.push_back(1);
std::cout << vec[N] << std::endl;
}
void push_one_ref(std::vector<int>* vec) {
vec->push_back(1);
std::cout << (*vec)[N] << std::endl;
}
int main() {
std::vector<int> vec(N, 0);
push_one_copy(vec); // N回のコピーが発生
push_one_ref(&vec); // コピーなし
}
参照
ポインターとは少し異なりますが、似た目的で使われるものとして、参照(リファレンス)があります。
参照とは、既存の変数に別名を付ける仕組みです。& を使って宣言します。
#include <iostream>
int main() {
int x = 42;
int& ref = x; // ref は x の別名
std::cout << x << "\n"; // 42
std::cout << ref << "\n"; // 42(同じ値)
ref = 100; // ref を書き換えると x も変わる
std::cout << x << "\n"; // 100
}
ポインターとの大きな違いは3つです。
| 比較項目 | ポインター(int*) |
参照(int&) |
|---|---|---|
| 宣言後に指す先 | 変更できる | 変更できない |
| null になれるか | なれる | なれない |
| アクセス方法 |
*ptr、ptr-> で脱参照が必要 |
変数名のまま使える |
参照は「宣言時に必ず何かと結びつき、途中で変更できない」という制約があります。ポインターより自由度は低いですが、その分誤った使い方が起きにくく、コードもすっきりします。
また、関数の引数としてよく使われます。
#include <iostream>
// ポインター渡し
void addTen_ptr(int* ptr) {
*ptr = *ptr + 10; // 脱参照が必要
}
// 参照渡し
void addTen_ref(int& ref) {
ref = ref + 10; // 変数名のまま書ける
}
int main() {
int a = 100, b = 100;
addTen_ptr(&a); // &を付けてアドレスを渡す
addTen_ref(b); // 変数名のまま渡せる
std::cout << a << "\n"; // 110
std::cout << b << "\n"; // 110
}
どちらを使うべきかは状況によりますが、途中で指す先を変える必要がない・nullになる可能性がない場合は、参照を使うのが現代C++の基本方針です。
ヒープへの手動アクセス
ポインターがどういうものかわかったところで、「ヒープを使う」とはどういう操作なのかを見ていきましょう。
C++では、new 演算子でヒープにメモリを確保し、delete 演算子で解放します。
先述の通り、ヒープ領域に確保されるメモリの場所は自由でCPUも知ることが出来ないため、スタック領域にポインターが保存されます。
#include <iostream>
int main() {
// ヒープに int 型の領域を確保し、そのアドレスを ptr に入れる
int* ptr = new int(42);
std::cout << "ヒープ上の値: " << *ptr << "\n"; // 42
std::cout << "ヒープ上のアドレス: " << ptr << "\n"; // 例: 0x55a3e8c02eb0
*ptr = 100; // ポインターを通じてヒープ上の値を書き換える
std::cout << "書き換え後の値: " << *ptr << "\n"; // 100
delete ptr; // 使い終わったら必ず解放する
}
しかし、new と delete を手動で対応させるこの方法には大きな危険性があります。次の章でその問題を詳しく見ていきます。
4. スコープとヒープ
スコープとは
プログラムにおいて、変数がメモリに残っている範囲のことをスコープと呼びます。C++では、{} で囲まれたブロックがスコープの単位になります。
#include <iostream>
int main() {
int x = 10; // xはここから存在する
// [ x ]
{
int y = 20; // yはこの内側のブロックでのみ存在する
// [ x ][ y ]
std::cout << "ブロック内: x=" << x << ", y=" << y << "\n";
}
// ここでyはスタックから消えている
// [ x ]
std::cout << "ブロック外: x=" << x << "\n";
// std::cout << y << "\n"; // エラー!yはもう存在しない
} // ここでxが消える
スタック上のローカル変数は、スコープを抜ける瞬間に自動的に破棄されます。この性質は非常に便利ですが、ヒープメモリに対しては話が異なります。
ヒープはスコープを超えて残る
ヒープに確保したメモリは、スコープを抜けても自動的には解放されません。これが便利な場面もありますが、管理を誤ると深刻な問題を引き起こします。
メモリリーク
delete を忘れると、ヒープ上のメモリが解放されないまま残り続けます。これをメモリリークと呼びます。
#include <iostream>
void leaky_function() {
int* ptr = new int(42); // ヒープにメモリを確保
std::cout << "値: " << *ptr << "\n";
// delete を忘れた!
// ptr(ポインター変数)はスコープを抜けると消えるが、
// ヒープ上の42は誰も解放できないまま残り続ける
}
int main() {
for (int i = 0; i < 1000000; ++i) {
leaky_function(); // 呼び出すたびにメモリが漏れ続ける
}
// プログラムが使用するメモリがどんどん増え続ける
}
一度や二度の呼び出しではわからないことも多いですが、ループの中でこのような関数を呼び出し続けると、プログラムのメモリ使用量が際限なく増え続け、最終的にはシステム全体がメモリ不足に陥ります。
ダングリングポインター
ダングリングポインターは、すでに解放されたメモリを指したままになっているポインターのことです。
#include <iostream>
int main() {
int* ptr = new int(42);
delete ptr; // メモリを解放した
// この時点でptr変数自体はまだ存在しているが、
// 指す先のメモリは誰のものでもない状態になっている
// 解放済みのメモリにアクセスしている(未定義動作)ため、色々な数字が出てくる。
std::cout << *ptr << "\n";
}
解放済みのメモリに何が入っているかは予測不能です。デバッグが難しく、たまたま正しい値が出ることもあるため、バグの解決が遅れがちです。
ヒープではなくスタックの話ですが、スタック上の変数のアドレスを関数の外に返すのも、ダングリングポインターが発生するため危険です。関数が終了するとスタックの変数は消えるため、返したアドレスはすでに無効になっています。
#include <iostream>
int* dangerous_function() {
// [ main ][ dangerous_function ]
int local_var = 99; // スタック上に確保される
// [ main ][ dangerous_function ][ local_var = 99 ]
int* ptr = &local_var;
// [ main ][ dangerous_function ][ local_var = 99 ][ ptr = {local_varのアドレス} ]
return ptr; // スコープを抜けると local_var は消える
}
int main() {
// [ main ]
int* ptr = dangerous_function();
// [ main ][ ptr = {local_varのアドレス} ]
// この時点でptrが指すスタック領域はすでに解放されている
// [ main ][ ptr = {local_varのアドレス} ][ (ptrが指している場所、今は未定義) ]
std::cout << *ptr << "\n"; // 未定義動作
}
このようなコードを書いた場合、コンパイラが警告を出してくれることもあるので素直に従いましょう。
二重解放
同じメモリを delete で二度解放しようとすると、プログラムがクラッシュします。
#include <iostream>
int main() {
int* ptr = new int(42);
delete ptr; // 1回目の解放(正常)
delete ptr; // 2回目の解放 → クラッシュ(二重解放)
}
手動メモリ管理の危険性
ここまで見てきた問題を整理すると、new / delete を使った手動のメモリ管理には以下の落とし穴があります。
| バグの種類 | 原因 | 症状 |
|---|---|---|
| メモリリーク |
delete 忘れ |
メモリ使用量の増大 |
| ダングリングポインター | 解放後のアクセス | 未定義動作、クラッシュ |
| 二重解放 | 同じポインターを2回 delete
|
クラッシュ |
これらはいずれも、プログラマがメモリをいつ解放するかについて、完全に正確に管理しなければならないことから生じます。次の章では、この問題をC++がどのように解決したかを見ていきます。
5. RAIIとスマートポインター
スタックの利用
第二章で、スタック上のローカル変数はスコープを抜けると自動的に破棄されると述べました。C++では、クラスのオブジェクトが破棄される際にデストラクタという特別な関数が自動的に呼ばれます。
#include <iostream>
class MyClass {
public:
MyClass() { std::cout << "生成されました\n"; } // コンストラクタ
~MyClass() { std::cout << "破棄されました\n"; } // デストラクタ
};
int main() {
std::cout << "スコープに入ります\n";
{
MyClass obj; // ここでコンストラクタが呼ばれる
std::cout << "スコープの中にいます\n";
} // ここでデストラクタが自動的に呼ばれる
std::cout << "スコープを出ました\n";
}
出力
スコープに入ります
生成されました
スコープの中にいます
破棄されました
スコープを出ました
デストラクタは(通常の終了時には)必ず呼ばれるため、デストラクタの中でヒープの解放を行うことで、解放忘れを無くすことが出来ます。
RAII
この考え方に名前を付けたものがRAII(Resource Acquisition Is Initialization: リソースの確保は初期化時に)です。
「リソース(ヒープメモリなど)の確保をコンストラクタで行い、解放をデストラクタに任せる」という設計パターンです。スコープを抜ければ必ずデストラクタが呼ばれるので、解放忘れが構造的に起きなくなります。
RAIIを自分で実装すると以下のようになります。
#include <iostream>
// ヒープ上のintを管理するRAIIクラス
class ManagedInt {
public:
ManagedInt(int value) {
ptr = new int(value); // コンストラクタでヒープを確保
std::cout << "確保: " << *ptr << "\n";
}
~ManagedInt() {
delete ptr; // デストラクタで確実に解放
std::cout << "解放しました\n";
}
int get() const { return *ptr; }
private:
int* ptr;
};
int main() {
{
ManagedInt x(42);
std::cout << "値: " << x.get() << "\n";
} // ここでデストラクタが呼ばれ、自動的にヒープが解放される
}
出力結果:
確保: 42
値: 42
解放しました
例外が発生しても、スコープを抜けた途端にデストラクタが呼ばれることが保証されているため、メモリリークが起きません。これがRAIIの強みです。
スマートポインターと所有権
C++の標準ライブラリには、RAIIでヒープを管理する仕組みとしてスマートポインターが用意されています。#include <memory> で利用できます。
std::unique_ptr
unique_ptrは、ヒープに確保されているメモリが、スタック上に存在する1つの変数のみから参照されている、ということが保証されているポインターです。
#include <iostream>
#include <memory>
class SensorData {
public:
SensorData(int id) : id_(id) {
std::cout << "SensorData " << id_ << " をヒープに確保\n";
}
~SensorData() {
std::cout << "SensorData " << id_ << " をヒープから解放\n";
}
void process() const {
std::cout << "SensorData " << id_ << " を処理中\n";
}
private:
int id_;
};
int main() {
// make_unique で SensorData をヒープに確保
std::unique_ptr<SensorData> data = std::make_unique<SensorData>(1);
// (*data).process()と同じ
data->process();
// 関数を抜ける際、dataが破棄される
// それと同時にunique_ptrのデストラクタが呼ばれ、ヒープ上のSensorDataも自動的に解放される
// そのためdeleteを書く必要がない
}
出力
SensorData 1 をヒープに確保
SensorData 1 を処理中
SensorData 1 をヒープから解放
この例では、dataがヒープ領域に確保されたSensorDataの所有権を持っており、dataがスコープを抜けて破棄されると同時にSensorDataのメモリ領域も解放されます。
なお、data->process()という書き方は(*data).process()と同じです。いちいちカッコでくくって脱参照するのは面倒なので、->という記号が割り当てられました。これを糖衣構文(シンタックスシュガー)と言います。
ここで、unique_ptrはコピーできないということに注意してください。もしポインターをコピーできてしまうと、以下のようなことが起きます。
int main() {
std::unique_ptr<SensorData> data = std::make_unique<SensorData>(1);
{
std::unique_ptr<SensorData> data2 = data; // ポインターがコピーされる(実際はコンパイルエラー)
data2->process(); // data2の破棄と同時にヒープ領域のSensorDataも解放
}
data->process(); // SensorDataは解放済みなので未定義動作
}
もし他の変数へ所有権を移したい場合は std::move() を使います。
std::unique_ptr<SensorData> a = std::make_unique<SensorData>(2);
std::unique_ptr<SensorData> b = std::move(a); // 所有権をaからbへ移す
// この時点でaはnullptrと同等になる
この後にaを使ってしまうとコンパイルエラーではなく未定義動作が起きるので、わかりにくくてデバッグが面倒です。
一方、この所有権を前提にして作られたのがRustです。Rustならムーブされた変数を使うとコンパイルエラーが起きますし、エラーの出力も親切丁寧でわかりやすいです。Rustは良いぞ。
std::shared_ptr
shared_ptr は、複数のポインターが同一のメモリを参照できるポインターです。内部で「何個のポインターがこのメモリを参照しているか(参照カウント)」を管理し、カウントがゼロになった時点で自動的に解放します。
#include <iostream>
#include <memory>
class Resource {
public:
Resource() { std::cout << "Resourceを確保\n"; }
~Resource() { std::cout << "Resourceを解放\n"; }
};
int main() {
std::shared_ptr<Resource> ptr1 = std::make_shared<Resource>();
std::cout << "参照カウント: " << ptr1.use_count() << "\n"; // 1
{
std::shared_ptr<Resource> ptr2 = ptr1; // 所有権を共有(コピーできる)
std::cout << "参照カウント: " << ptr1.use_count() << "\n"; // 2
}
// ptr2がスコープを抜けてカウントが1に戻る(まだ解放されない)
std::cout << "参照カウント: " << ptr1.use_count() << "\n"; // 1
// main関数を抜けてptr1が破棄され、カウントが0になって初めて解放される
}
出力
Resourceを確保
参照カウント: 1
参照カウント: 2
参照カウント: 1
Resourceを解放
どちらを使うべきか
| 状況 | 使うべきポインター |
|---|---|
| 所有者が1箇所に限定できる(ほとんどのケース) | unique_ptr |
| 複数の場所から同じリソースを共有する必要がある | shared_ptr |
生の new / delete を直接使う |
原則として避ける |
現代のC++では、new と delete を直接書く必要はほぼありません。まず unique_ptr の使用を検討し、共有が必要な場合にのみ shared_ptr を選ぶのが基本方針です。
余談: Rustの所有権
C++のスマートポインターは「プログラマが適切に使えば安全」という仕組みであり、使わないことを強制する手段はありません。
一方Rustという言語では、どの変数がメモリの所有権を持っているのかをコンパイラが厳密に検証し、ダングリングポインターやデータ競合をコンパイル時にゼロにする仕組みが言語仕様として組み込まれています。メモリ安全性が特に重要な分野では、Rustが選ばれる場面が増えてきています。
例えばDiscord、Linuxのカーネル、Cloudflare、AWS、Azure、iCloud、Dropboxなどといった世界的なサービスでもRustが使われ始めているようです。
6. 楽しい限界高速化
注: この章はやや発展的な内容です。あんまり重要ではないです。
メモリのアクセス方法が速度を左右する
プログラムの実行速度は、CPUの演算速度だけで決まりません。「CPUにどれだけ速くデータを届けられるか」が、現実のプログラムではしばしばボトルネックになります。
コンピュータには、速さと容量がトレードオフになった複数の記憶領域が存在します。
速い・小さい
│
├─ レジスタ (CPUの演算器の中、数十バイト、1ns以下) <- 全計算はここで行われる
├─ L1キャッシュ (CPUチップ内、数十KB、1ns程度)
├─ L2キャッシュ (CPUチップ内、数百KB〜数MB、数ns)
├─ L3キャッシュ (CPUチップ内、数十MB、十数ns)
├─ メインメモリ (RAM、数GB、数十ns) <- 通常「メモリ」と呼ぶのはここ
├─ ストレージ (SSD/HDD、数TB、十数us~数ms)
│
遅い・大きい
メインメモリへのアクセスは、L1キャッシュへのアクセスと比べて非常に遅いため、CPUがメインメモリのデータを待つ間、何もできずに止まってしまうことがあります。このことを「メモリウォール」と呼びます。
キャッシュと空間的局所性
CPUがメインメモリからL1、L2、L3キャッシュへデータを読み込むとき、要求した1つのデータだけでなく、その周辺のデータも一緒にキャッシュに読み込みます(キャッシュライン)。
この仕組みのおかげで、連続したメモリ領域に順番にアクセスするプログラムは、キャッシュヒット率が高く非常に高速になります。逆に、バラバラなメモリ領域に飛び回るアクセスは、そのたびにメインメモリへの読み込みが発生し、大幅に遅くなります。
以下のコードは、「連続したメモリへのアクセス(std::vector)」と「ばらばらなメモリへのアクセス(ポインターのリスト)」を比較します。
#include <iostream>
#include <vector>
#include <chrono>
#include <numeric>
// ─────────────────────────────────────────
// キャッシュ効率が良い:連続したメモリへのアクセス
// std::vectorはヒープ上に連続してデータを配置する
// ─────────────────────────────────────────
void contiguousAccess(int size) {
std::vector<int> data(size);
std::iota(data.begin(), data.end(), 0); // 0, 1, 2, ... と連番で埋める
auto start = std::chrono::high_resolution_clock::now();
long long sum = 0;
for (int i = 0; i < size; ++i) {
sum += data[i]; // メモリ上で隣同士のデータを順番に読む -> キャッシュヒット率が高い
}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> elapsed = end - start;
std::cout << "連続アクセス (vector): " << elapsed.count() << " ms (合計=" << sum << ")\n";
}
// ─────────────────────────────────────────
// キャッシュ効率が悪い:分散したメモリへのアクセス
// newで個別に確保されたNodeはヒープ上のバラバラな場所に配置される
// ─────────────────────────────────────────
struct Node {
int value;
};
void scatteredAccess(int size) {
std::vector<Node*> nodes(size);
for (int i = 0; i < size; ++i) {
nodes[i] = new Node{i};
}
auto start = std::chrono::high_resolution_clock::now();
long long sum = 0;
for (int i = 0; i < size; ++i) {
sum += nodes[i]->value; // ポインターを経由してバラバラなアドレスへアクセス -> キャッシュミスが多発
}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> elapsed = end - start;
std::cout << "分散アクセス (ポインター経由): " << elapsed.count() << " ms (合計=" << sum << ")\n";
for (auto n : nodes) delete n;
}
int main() {
const int size = 10'000'000; // 1000万要素
contiguousAccess(size);
scatteredAccess(size);
}
出力
連続アクセス (vector): 1.5086 ms (合計=49999995000000)
分散アクセス (ポインター経由): 12.1796 ms (合計=49999995000000)
実行環境によって差は異なりますが、分散アクセスが連続アクセスの数倍〜10倍以上遅くなるケースも珍しくありません。アルゴリズムの計算量が同じでも、メモリアクセスのパターン次第でこれだけの差が生まれます。
パフォーマンスの最適化
これらの知識はパフォーマンスが求められる場面で直接役に立ちます。
構造体の配列(AoS) vs 配列の構造体(SoA)
構造体の配列は例えばstd::vector<Node>で、配列の構造体は例えばstruct Node { std::vector<int> a, b, c }
データの種類やアクセス方法によってどちらが高速になるかは変わってきますが、状況に応じてデータの持ち方を工夫することで、キャッシュヒット率を大きく改善できパフォーマンス向上に繋がります。
行列演算の順序
多次元配列をどの順番でアクセスするかによって、キャッシュ効率が大きく変わります。
例えば二次元配列は[配列へのポインター0, 配列へのポインター1, ...]という構造になっているので、
for (int j = 0; j < M; j++) {
for (int i = 0; i < N; i++) {
sum += vec[i][j];
}
}
よりも
for (int i = 0; i < N; i++) {
for (int j = 0; j < M; j++) {
sum += vec[i][j];
}
}
のほうが高速になります。
他にも長さN*Mの配列を作ってi * M + jでアクセスするという方法もあります。
この程度の単純な処理なら、コンパイラの最適化によって前者を書いても後者と同じ処理にされるかもしれませんが、自動で最適化できないほど複雑になってくると、プログラマーの知識が試されてきます。
限界高速化
不必要なほどにパフォーマンスの最適化を行うことを、限界高速化と言ったりします。
変数のコピーを無くし、データ構造を見直し、メモリアロケーションを最小限まで削って処理速度が10倍、50倍と上がっていく様子を見るのは非常に楽しいです。そのうち可読性も削られてしまい、パフォーマンスと可読性とのバランスで悩むのも楽しいです。
処理時間が短くて困ることはありません。皆さんもぜひやってみてください。
まとめ
この記事で学んだことを振り返ります。
| 章 | 要点 |
|---|---|
| 1章 | メモリは連番の巨大な配列。変数はアドレスへの名前、型はサイズを決める |
| 2章 | スタックは高速・自動管理だが容量小。ヒープは大容量だが手動管理が必要 |
| 3章 | ポインターはアドレスを格納する変数。ヒープへのアクセスはポインターが入口 |
| 4章 | ヒープの手動管理はリーク・ダングリングポインター・二重解放の危険がある |
| 5章 | RAIIとスマートポインター(unique_ptr / shared_ptr)で安全に自動化できる |
| 6章 | メモリの連続性がキャッシュヒット率を左右し、実行速度に直結する |
ポインターやメモリは、最初は難しく感じるかもしれません。しかし「変数はメモリのアドレスに名前を付けたもの」「ポインターはそのアドレス自体を値として持つ変数」と考えると、多くの概念が自然につながって見えてきます。ぜひコードを実際に動かしながら、手を動かして理解を深めてみてください。
最後に、この記事の内容をだいたい理解できたら、次はRustを勉強してみませんか?難しい難しいと言われがちですが、メモリの概念を知っていればそんなに躊躇するようなものでもありませんし、公式のドキュメントも非常に丁寧でわかりやすいです。ぜひ挑戦してみてください。
- The Rust Programming Language 日本語版
- Rust by Example 日本語版
- Rust Playground (Rustを実行できるサイト)
-
プログラムのスタックサイズは
ulimitコマンドの実行や、コンパイルオプションの指定により変更することができます。マイコンのスタックサイズであれば、STM32 CubeMXのProject Manager -> Project -> Linker Settingsから変更できます。 ↩