C言語を学ぶ上で、多くのプログラマーが最初に突き当たる大きな壁が「ポインタ」です。
ポインタの概念を理解した後に現れる「ポインタのポインタ(ダブルポインタ)」は、さらに複雑な構造に感じられるかもしれません。
しかし、ダブルポインタはC言語において高度なメモリ操作や柔軟なデータ構造を実現するために不可欠な技術です。
本記事では、ポインタのポインタがどのような仕組みで動いているのか、基礎から実用的な活用シーンまで詳しく解説します。
この記事を通じて、ダブルポインタに対する苦手意識を払拭し、プログラムの自由度を一段階引き上げましょう。
ポインタのポインタ(ダブルポインタ)とは何か?
ポインタのポインタとは、端的に言えば「ポインタ変数のアドレスを格納する変数」のことです。
通常のポインタ変数は、int型やchar型といった通常の変数が置かれているメモリ上のアドレスを指し示します。
これに対し、ポインタのポインタはその「アドレスを保持している変数自体のアドレス」を値として持ちます。
C言語では、変数の型にアスタリスク(*)を2つ付けることで、ダブルポインタを宣言します。
例えば、int **pp;という宣言は、int型へのポインタを指すポインタを定義したことになります。
この多段階の参照構造を理解することが、C言語のメモリ管理をマスターする第一歩となります。
変数のアドレスとポインタの関係をおさらい
ダブルポインタを理解する前に、まずは通常のポインタの動きを整理しておきましょう。
通常の変数には必ずメモリ上の「住所(アドレス)」が割り当てられています。
ポインタ変数とは、そのアドレスをデータとして書き込むための専用の箱です。
ポインタ変数そのものもメモリ上に配置されているため、ポインタ変数自身にも必ずアドレスが存在します。
この「ポインタ変数のアドレス」を代入できるのが、ダブルポインタの役割です。
ダブルポインタの構造図解
イメージとしては、荷物が入った「箱A」があるとします。
「箱B」には「箱Aの場所」が書かれたメモが入っています(これが通常のポインタです)。
そして「箱C」には「箱Bの場所」が書かれたメモが入っています(これがダブルポインタです)。
「箱C」から「箱B」を経由して、最終的に「箱A」の中身にアクセスするというのが、ダブルポインタによる間接参照の仕組みです。
ダブルポインタの基本構文と使い方
それでは、実際のコードを用いてダブルポインタの記述方法を確認していきましょう。
以下のサンプルコードでは、通常の変数、ポインタ、そしてダブルポインタの関係をコンソールに出力しています。
#include <stdio.h>
int main() {
int value = 100; // 通常の整数型変数
int *p = &value; // valueのアドレスを指すポインタ
int **pp = &p; // pのアドレスを指すダブルポインタ
printf("valueの値: %d\n", value);
printf("valueのアドレス: %p\n", (void*)&value);
printf("pが指しているアドレス: %p\n", (void*)p);
printf("p自身のアドレス: %p\n", (void*)&p);
printf("ppが指しているアドレス: %p\n", (void*)pp);
printf("*pp(ppを経由してpの値)を表示: %p\n", (void*)*pp);
printf("**pp(ppを経由してvalueの値)を表示: %d\n", **pp);
return 0;
}
valueの値: 100
valueのアドレス: 0x7ffee1234560
pが指しているアドレス: 0x7ffee1234560
p自身のアドレス: 0x7ffee1234568
ppが指しているアドレス: 0x7ffee1234568
*pp(ppを経由してpの値)を表示: 0x7ffee1234560
**pp(ppを経由してvalueの値)を表示: 100
実行結果を見ると、ppにはpのアドレスが格納されていることが分かります。
また、*ppと記述することでpの中身(valueのアドレス)にアクセスできます。
さらに**ppと2回間接参照を行うことで、最終的なデータの本体であるvalueの値を取得できています。
活用テクニック1:関数内でポインタの指し先を書き換える
ダブルポインタが最も実力を発揮する場面の一つが、「関数内で呼び出し元のポインタ変数を書き換えるとき」です。
C言語の引数は基本的に「値渡し」であるため、関数にポインタを渡しても、そのポインタが指すアドレス自体を関数内で変更することはできません。
ポインタの指し先(格納されているアドレス)自体を永続的に変更したい場合、ポインタのアドレスを渡す必要があります。
ここでダブルポインタが登場します。
ポインタの書き換えが必要なケース
例えば、関数内でメモリを動的に割り当て、その先頭アドレスを呼び出し元のポインタにセットしたい場合を考えます。
通常のポインタを引数にすると、関数内で確保したアドレスはローカルなコピーにしか反映されず、呼び出し元には伝わりません。
この問題を解決するために、ポインタのポインタを引数として受け取る設計が一般的に用いられます。
#include <stdio.h>
#include <stdlib.h>
// ダブルポインタを使用して、ポインタの指し先を書き換える関数
void allocateMemory(int **ptr) {
// ヒープ領域にメモリを確保し、そのアドレスを*ptr(呼び出し元のポインタ)に代入
*ptr = (int *)malloc(sizeof(int));
if (*ptr != NULL) {
**ptr = 500; // 確保したメモリに値を代入
}
}
int main() {
int *p = NULL; // 初期状態では何も指していない
// pのアドレスを渡す(ダブルポインタとして受け取らせる)
allocateMemory(&p);
if (p != NULL) {
printf("関数で割り当てられた値: %d\n", *p);
free(p); // 忘れずにメモリを解放
}
return 0;
}
このコードでは、&pを渡すことで、関数側で直接pの中身を書き換えることに成功しています。
これは、大規模なプログラムで「リソースの初期化関数」などを作成する際に多用されるテクニックです。
活用テクニック2:動的な2次元配列の作成
ダブルポインタのもう一つの重要な用途は、「動的な2次元配列」の実現です。
C言語でint matrix[3][4];のように宣言する配列は、コンパイル時にサイズが決まっている必要があります。
しかし、実行時に入力された行数や列数に基づいて配列を作りたい場合は、ダブルポインタを活用したメモリ確保が必要です。
これは「ポインタの配列」を動的に作り、それぞれの要素に「データの配列」を割り当てるという2段階の手順を踏みます。
2次元配列のメモリ確保手順
まず、各行の先頭アドレスを保持するための「ポインタの配列」を確保します。
次に、ループ処理を使用して、各行に対して必要な列数分のメモリを確保していきます。
この方法で作成された配列は、matrix[i][j]という形式で通常の配列と同じようにアクセス可能です。
#include <stdio.h>
#include <stdlib.h>
int main() {
int rows = 3;
int cols = 4;
// 1. 各行のアドレスを保持するポインタの配列を確保(ダブルポインタ)
int **matrix = (int **)malloc(sizeof(int *) * rows);
// 2. 各行に対して、実際のデータ領域を確保
for (int i = 0; i < rows; i++) {
matrix[i] = (int *)malloc(sizeof(int) * cols);
}
// データの代入
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
matrix[i][j] = i * cols + j;
}
}
// データの表示
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
printf("%2d ", matrix[i][j]);
}
printf("\n");
}
// 3. メモリの解放(確保した逆の順序で行う)
for (int i = 0; i < rows; i++) {
free(matrix[i]);
}
free(matrix);
return 0;
}
このようにダブルポインタを使用すれば、柔軟なサイズの表形式データを扱うことが可能になります。
特に画像処理や行列演算など、データサイズが不定な分野では必須の知識となります。
ダブルポインタを使用する際の注意点
非常に便利なダブルポインタですが、その分扱いには細心の注意を払う必要があります。
まず、「参照の深さ」を正しく把握することが重要です。
*の数を間違えると、全く意図しないメモリ領域にアクセスしてしまい、セグメンテーションフォールト(強制終了)の原因となります。
また、動的にメモリを確保した場合は、必ず解放(free)を行う必要がありますが、ダブルポインタの場合は解放の順序も重要です。
先に親(ダブルポインタ側)を解放してしまうと、子(各行のポインタ)にアクセスできなくなり、メモリリークが発生します。
NULLチェックの徹底
ポインタを扱う際の鉄則として、mallocなどでメモリを確保した直後のNULLチェックが挙げられます。
ダブルポインタによる多段階確保では、どこかの段階でメモリ確保に失敗する可能性があります。
各ループ内で戻り値を確認し、失敗した場合にはそれまでに確保した領域を適切に解放して終了するようなエラー処理を記述しましょう。
堅牢なプログラムを書くためには、このような地道なチェックが欠かせません。
文字列の配列とダブルポインタ
C言語における「文字列」は、実際にはchar型の配列です。
そのため、「複数の文字列を管理する配列」を扱いたい場合、必然的にダブルポインタが登場します。
コマンドライン引数を受け取るint main(int argc, char **argv)という記述を見かけたことがあるでしょう。
このchar **argvこそが、各引数文字列へのポインタを格納した配列の先頭を指すダブルポインタです。
文字列のリストをソートしたり、動的に追加したりするようなアルゴリズムを実装する際にも、この構造の理解が不可欠です。
まとめ
ポインタのポインタ(ダブルポインタ)は、最初は複雑に見えますが、本質は「住所の住所」を扱っているに過ぎません。
本記事では、ダブルポインタの定義から、関数引数での利用、動的な2次元配列の作成方法までを解説しました。
重要なポイントを振り返ると、以下の通りです。
- ダブルポインタは、ポインタ変数のアドレスを格納する。
- 関数内で呼び出し元のポインタ自体を書き換える際に必須となる。
- 動的な2次元配列を実現するために多用される。
- メモリ解放の際は、確保した時と逆の順序で正しく行う必要がある。
これらの概念をマスターすることで、C言語の柔軟なメモリ制御を最大限に活かせるようになります。
一歩ずつコードを書き、メモリの状態を意識しながら練習を重ねてみてください。
ダブルポインタを自在に操れるようになれば、あなたのC言語プログラミングスキルは大きく飛躍するはずです。
