本記事の一部表現(配列とポインタの関係)に不正確な点があり、現在修正中です。
詳細はコメント欄の指摘をご参照ください。後日、仕様ベースの説明に修正予定です。
この記事の対象者
この記事は、主にPythonやJavaScriptといった動的型付け言語を扱ってきた人が、C言語のポインタに配列を入れるという暴力的な操作に疑問を感じた人に向けて、少しでも理解の手助けになればと思い作成した。
以下の知識があることを前提に話を進める。
- C言語で配列を作成できる
- 配列内の任意の値を取得できる
- ある値のポインタを得られる(
&value) - あるポインタから値を取得できる(
*pointer)
私のようにC言語と決別する人が少しでも減ってくれることを願っている。
要約(TL;DR ?)
- 配列の値をポインタに代入しようとすると、コンパイラが勝手に配列の先頭要素のポインタを与える。つまり、
int a[] = {1, 2, 3}; int *p = a;とint a[] = {1, 2, 3}; int *p = &a[0];は同じ振る舞いをする ポインタに対する添え字は、メモリ空間上を添え字に応じて移動させたアドレスの値を取得するということである- ポインタに対して添え字を与えるということは、メモリ空間上にてポインタを添え字の分だけ移動させ、移動先に存在する値を得るということ。すなわち、添え字はインデックスではなく、ポインタの移動量である
- 上記2つにより、以下のコードのように、ポインタが配列のような振る舞いを行える
int main(){
int a[] = {1, 2, 3};
int *p = a;
printf("%d", p[1]);
}
@NyancoRitter 様からご指摘を頂きました。
「メモリ空間上を添え字に応じて移動させたアドレスの値を取得するということ」という記述は、取得した値がアドレス値そのものであると誤解を招く表現であったため、アドレス値ではなく、アドレス値に存在する値を取得するということが明確になる表現に変更いたしました。
背景
二年ほど前、授業で初めてCを学んだある日のこと。以下のコードがどうしても理解できず、ポインタが嫌いになり、私はC言語とは決別を誓った。
char *str = "aiueo";
char型のポインタに配列を代入……?
当時の私は、既にJavaやPythonに触れていたため、浅い知識で配列とはどんなものかを知っていたのが問題だったと思う。裏で配列がどのように保存されているのかも知らないのに……。
あれから二年の月日が流れ、データ構造やメモリアドレスについての基礎知識を蓄えてきた(Rustを触ったのは良い経験だった)。
先日、研究室の先輩とC言語の配列の話になり、以下のコードが動作するということを聞き、私は本当に宇宙猫になった。
int main(){
int a[] = {1, 2, 3};
int *p = a;
printf("%d", p[1]); // 実行結果: 2
}
ポインタに配列が入っているだと……。
二年前と全く変わらない私。先輩に理由を尋ねても、「そういうもの」ということだった。研究者にとってプログラミングは本当に単なるツールに過ぎず、なんとなくでC言語を使用している人も多いそうだ。
ここからは、なぜ上記のコードが想定通りに実行されるのか、その仕組みを記録しておこうと思う。
近年、最初に学ぶ言語としてPythonが選ばれることも多いようだ。私の大学もその一つであり、私もPythonが一番最初に深く学んだ言語である。しかし、Pythonは手軽な分、技術的に勘違いしやすい要素が多く、Pythonのリストもその一つである。この記事によって、C言語をやめてしまう人が一人でも減ってくれれば嬉しい。C言語からは逃げられないのだから……。
暗黙的なポインタの変換
ここからの項においても、先程のコードをもとに話を進めていく。
int main(){
int a[] = {1, 2, 3};
int *p = a;
printf("%d", p[1]); // 実行結果: 2
}
C言語初学者である私が最初に疑問となったコードは int *p = a; であった。
まず、大前提として、 int a[] = {1, 2, 3}; と定義したとき、変数 a には配列そのものが入っている[注1]。しかし、そうしたときこの部分には致命的な相違がある。
int *p は整数値のポインタであるはずなのに、 a は配列そのものなのである。
一見すると、代入の左右で型が食い違っているように見える。もしかして、配列とポインタは同じ概念なのだろうか?もちろん、そんなことはない。
ではなぜ int *p = a; が成立するのか。
調べてみると多くの式において、配列は暗黙的に先頭の値のポインタに変換されるということが分かった。
これは少なくともPythonのリストやタプルには見られない挙動だ。
int *p = a; が実行されたときにおいても、Cのコンパイラは暗黙的に配列 a を配列の先頭値のポインタ &a[0] に変換する処理を行う。つまり、上記のコードと以下のコードは同じ実行結果を出力する。
int main(){
int a[] = {1, 2, 3};
// int *p = a;
int *p = &a[0];
printf("%d", p[1]); // 実行結果: 2
}
つまり、 p には配列 a の先頭の整数値のポインタが渡されている。当然、配列とポインタはノットイコールである。
配列は暗黙的に先頭の値のポインタに変換される
添え字が表すこと
前項では、 p には配列 a の先頭のオブジェクトのポインタが入っている、すなわち、 p は単なる int 値のポインタであることがわかった。ここで、私にはもう一つの疑問が生じた。
なぜ整数値 p に対して p[1] のように、添え字を用いることでさも配列かのような記述が行えるのか。
int main(){
int a[] = {1, 2, 3};
int *p = a;
printf("%d", p[1]); // この部分。pは配列でないのに添え字指定ができる
}
それを解決するためには、C言語における添え字演算子の意味を理解する必要がある。
私の知っている添え字とは、あくまで コレクションオブジェクトのインデックスを示す値だ。しかし、C言語においては、 先頭オブジェクトからアドレスをどれだけ動かすか であった。
p[1] は内部的に *(p + 1) として処理される。ここで、ポインタ p に 1 を足すと、メモリ空間上の1つ隣の値にポインタが移る。つまり、 p[1] が示すことは p の一つ隣のアドレスの値を取得することなのである[注2]。
そして、C言語では int a[] = {1, 2, 3} 実行時に、メモリ空間上に連続する値が隣り合うようにメモリが確保される。前述の通り p には配列 a の先頭に入っている整数値のポインタが与えられているため、 p[1] を実行すると、 a[0] の隣である a[1] が得られる。
要するに、 p[1] とは配列にアクセスしているのではなく、ただ a[0] の隣の値を見ているに過ぎない。この仕組みによって p は整数値のポインタであるにも関わらず、まるで配列かのように添え字つきで値を取得できるのである。
以上のことを踏まえると、以下のような「同じ配列を参照していて添字が異なるのに、値は同じ」というとんでもないコードがかけてしまう。
int main(){
int a[] = {0, 1, 2, 3, 4};
int *p = &a[2];
printf("%d", p[1])
// 実行結果: 3
// p[1] = *(p + 1) = *(&a[2] + 1) = 3
}
また、Pythonのように添え字として負の数を指定することができることも理解できる。もちろん、これが示すのは、配列の最後から何番目ではなく、メモリ空間におけるいくつか前の値である。
int main(){
int a[] = {1, 2, 3};
int *p = &a[1];
printf("%d", p[-1]);
// 出力結果: 1
// p[-1] = *(p - 1) = *(&a[1] - 1) = a[0]
}
ちなみに、添え字によるポインタの移動範囲は、配列内ではなくメモリ空間全体であるため、C言語では p[10] がコンパイルエラーにならない場合がある。これはC言語のメモリ安全の弱点につながる。
ポインタと配列は同じ?
ここまで理解した私は、改めて同じ疑問にぶつかった。
ポインタに対して添え字を付与できるなら、やはりポインタと配列は本質的に同じなのではないだろうか?
もちろん、わざわざ名称を分けているのだから、ポインタと配列は別物だ。
ではなぜ配列にも添え字で要素を指定できるのか。その答えも配列の暗黙的な変換にある。
「暗黙的なポインタへの変換の項」でも扱ったように、多くの式において配列は先頭要素のポインタに変換される。これは配列内要素の添え字指定にも適用される。すなわち、 a[2] と *(&a[0] + 2) は本質的には等価である。
従って、配列は厳密にポインタとは異なるが、暗黙的に先頭要素のポインタに変換してポインタの仕組みを活用することで、配列内の要素にインデックスを用いることでアクセスすることを可能にしている。しかし、Pythonのリストに慣れた私に対して、「C言語も単純にインデックスによって配列要素をしている」という誤解を生む要因となった。私と同じ疑問を抱いた者は、この添え字演算の仕組みをよく理解すると良いと思う。
まとめ
- 式中の配列は暗黙的に配列内の先頭要素のポインタに変換される
- Pythonなどのインデックスによって値を取り出すコレクションと、C言語の配列は、根本的な仕組みが異なる
- C言語の配列はメモリ空間上で値が隣り合って配置されるため、安易に配列の大きさを変更することはできない
- 配列内の要素の取得はポインタの移動の仕組みによって実現されている
特にPythonやJavaScriptといった動的型付け言語からCに入るプログラマは、C言語の学習と並行して、配列などの値のデータ構造についても勉強していくと、より理解が進むかもしれない。
教訓
以上のことから、私は個人的に以下の教訓を得た。
- 言語によって見た目は同じでも内部動作は全く異なるデータ構造が存在するので、同じく添え字で参照できるとしても、同じものだと考えてはいけない
- C言語を理解するためには、Python以上にデータ構造などのコンピュータの基礎知識が非常に重要である
ポインタは自由すぎる暴れ馬である
注釈
- 注1: 厳密には、各要素を並べたメモリ空間。Pythonのリストやタプルとは根本的に別物
- 注2: ここで言う「一つ隣」とは、1バイト先ではなく、
実際にはその要素に対して言語処理系が定義しているメモリ分だけ先であるint型が確保するメモリ分
追記(2026/04/12)
@angel_p_57 様より、一つの要素が確保するメモリの量についてご指摘を頂きました。
元々は要素の型そのもののメモリ量を確保する記述を行っていましたが、実際には環境ごとに異なるため、環境によるというよう修正しました。