閉じる

【C言語】「配列の先頭アドレスって何?」ポインタとのつながりを直感的に理解しよう

C言語を学び始めると、必ずといってよいほど出てくるのが「配列の先頭アドレス」や「ポインタと配列の関係」という話です。

しかし、用語だけ聞いてもイメージしにくく、何となく動くコードを書いてしまいがちです。

この記事では、図や具体例、サンプルコードを使ってポインタと配列のつながりを直感的に理解できるよう、丁寧に解説していきます。

C言語の配列とは何かをおさらい

配列とは

まずは配列について、初心者の方にも分かりやすくおさらいします。

配列とは、同じ型の変数を「番号付きで並べたもの」です。

例えば、テストの点数を5人分保存したいとき、個別に変数を5個作る代わりに、配列を1つ用意して、その中に5つの要素を番号で管理します。

配列を使わない書き方の例

C言語
#include <stdio.h>

int main(void) {
    // 配列を使わない場合: 5人分の点数を別々の変数で管理
    int score1 = 80;
    int score2 = 75;
    int score3 = 90;
    int score4 = 60;
    int score5 = 88;

    int sum = score1 + score2 + score3 + score4 + score5;
    double avg = sum / 5.0;

    printf("平均点(配列なし) = %.1f\n", avg);

    return 0;
}

このように個別に変数を書くと、人数が増えるたびにコードが膨れ上がってしまいます。

配列を使った書き方の例

C言語
#include <stdio.h>

int main(void) {
    // 配列を使って5人分の点数をまとめて管理
    int score[5] = {80, 75, 90, 60, 88};  // 要素数5のint配列

    int i;
    int sum = 0;

    // 配列の要素をループで順に取り出して合計
    for (i = 0; i < 5; i++) {
        sum += score[i];  // i番目の要素にアクセス
    }

    double avg = sum / 5.0;

    printf("平均点(配列あり) = %.1f\n", avg);

    return 0;
}
実行結果
平均点(配列あり) = 78.6

このように配列を使うと、多数の同じ種類のデータをまとめて扱えるため、処理がシンプルになります。

配列のメモリ配置とインデックスの関係

配列を理解するうえで重要なのが「メモリ上でどう並んでいるか」というイメージです。

例えば、次のような配列を考えます。

C言語
int a[4] = {10, 20, 30, 40};

メモリ上では、4つのintが連続して並んでいるイメージになります。

インデックスa[0]が一番最初、その次がa[1]、というように右へ順に並びます。

重要なポイントは、C言語の配列のインデックスは0から始まるという点です。

  • a[0]: 1番目の要素
  • a[1]: 2番目の要素
  • a[3]: 4番目の要素

ここで「インデックス番号は、先頭から何個進んだか」を表していると考えると、後で出てくるポインタ演算との対応が理解しやすくなります。

配列の宣言と初期化の基本

配列の基本的な書き方を整理します。

配列の宣言

配列の宣言は、次の形になります。

型名 配列名[要素数];

例えば、intの要素を10個持つ配列は次のように宣言します。

C言語
int a[10];  // int型の要素を10個持つ配列a

このときまだ中身の値は決まっていません(ローカル変数の場合、初期化されていないゴミ値が入ります)。

配列の初期化

宣言と同時に値を入れることを「初期化」と呼びます。

C言語
int a[4] = {10, 20, 30, 40};  // 4つすべて指定
int b[4] = {1, 2};            // 残りは0で初期化される
int c[]  = {5, 6, 7};         // 要素数を省略すると、初期化子の数から決まる(ここでは3要素)

配列の要素にアクセスするときはa[0]a[1]のように、インデックスを使います。

C言語のポインタとは何かを理解する

ポインタとは

次に、初心者が苦手にしやすいポインタの基本を整理します。

ポインタとは「アドレス(メモリ上の場所)を保存する変数」です。

普通の変数は「値そのもの」を持っていますが、ポインタ変数は値が入っている場所(番地)を持っている、という違いがあります。

具体的なイメージ

  • 普通のint変数int x = 10;
    → xという箱の中に10という値が入っている
  • intへのポインタint *p;
    → pという箱の中には「どこかのint変数の住所(アドレス)」が入っている

このようにポインタは「○○がどこにあるか」という情報を持っていると考えると分かりやすいです。

アドレス演算子(&)と間接演算子(*)の意味

ポインタを扱うときに必ず出てくるのが&*という2つの演算子です。

それぞれの意味を整理します。

アドレス演算子(&)

&変数名「その変数のアドレス(場所)」を求める演算子です。

C言語
#include <stdio.h>

int main(void) {
    int x = 10;
    int *p;

    p = &x;  // xのアドレスをpに代入

    printf("xの値 = %d\n", x);
    printf("xのアドレス = %p\n", (void *)&x);
    printf("pに入っているアドレス = %p\n", (void *)p);

    return 0;
}

出力例(アドレスは環境によって変わります):

txt
xの値 = 10
xのアドレス = 0x7ffde7b7c8ac
pに入っているアドレス = 0x7ffde7b7c8ac

このようにpの中身は「xのいる場所」になっています。

間接演算子(*)

*ポインタ変数「そのアドレス先にある値」を取り出す演算子です。

よく「ポインタを経由して値を見る」イメージと説明されます。

C言語
#include <stdio.h>

int main(void) {
    int x = 10;
    int *p = &x;  // xのアドレスをpに代入

    printf("xの値        = %d\n", x);
    printf("*pの値(間接) = %d\n", *p);  // pが指す先の値を参照

    // ポインタ経由で値を書き換え
    *p = 20;

    printf("xの値(書き換え後) = %d\n", x);
    printf("*pの値(書き換え後) = %d\n", *p);

    return 0;
}
実行結果
xの値        = 10
*pの値(間接) = 10
xの値(書き換え後) = 20
*pの値(書き換え後) = 20

pを書き換えると、xの中身が変わることが分かります。

この2つの演算子&をセットで覚えると、ポインタと変数の関係が理解しやすくなります。

ポインタの宣言と使い方の基本

ポインタ変数を宣言するときは型名 *変数名;のように書きます。

C言語
int *p;        // int型の値を指すポインタ
double *dp;    // double型の値を指すポインタ
char *cp;      // char型の値(文字)を指すポインタ

ここで重要なのは「pはintを指す」「dpはdoubleを指す」といった型の情報が付いていることです。

この型情報は、後で出てくるポインタ演算でとても重要になります。

ポインタの基本的な使い方の流れ

  1. 通常の変数を用意する
  2. その変数のアドレスを&で取得する
  3. そのアドレスをポインタ変数に代入する
  4. ポインタを*で参照して値を読む・書く

この流れを簡単なコードで確認してみます。

C言語
#include <stdio.h>

int main(void) {
    int x = 100;     // 通常のint変数
    int *p = &x;     // xのアドレスをpに代入

    printf("xの値       = %d\n", x);
    printf("pが指す値   = %d\n", *p);

    // ポインタを使って値を変更
    *p = 200;

    printf("xの値(変更後)     = %d\n", x);
    printf("pが指す値(変更後) = %d\n", *p);

    return 0;
}
実行結果
xの値       = 100
pが指す値   = 100
xの値(変更後)     = 200
pが指す値(変更後) = 200

ポインタと配列の関係を図で直感的に理解する

ここからがこの記事の本題です。

「配列名」と「ポインタ」の関係を、順を追って直感的に理解していきます。

配列名aは「配列の先頭アドレス」を表す

まず最初に覚えておきたい重要な性質があります。

配列名(例:a)は、多くの場面で「配列の先頭要素のアドレス」に自動変換されるという性質です。

これを「配列がポインタに暗黙変換される」と説明することもあります。

次のコードを見てください。

C言語
#include <stdio.h>

int main(void) {
    int a[3] = {10, 20, 30};

    printf("aの値(先頭アドレス)    = %p\n", (void *)a);
    printf("&a[0]の値(先頭要素のアドレス) = %p\n", (void *)&a[0]);

    return 0;
}
実行結果
aの値(先頭アドレス)    = 0x7ffd4d5a49e0
&a[0]の値(先頭要素のアドレス) = 0x7ffd4d5a49e0

aと&a[0]が同じ値になっていることが分かります。

この「配列名は先頭アドレスとして振る舞う」という性質が、ポインタとの関係を理解するうえでの出発点になります。

aと&a[0]の違いと共通点

先ほどの例から、aと&a[0]は「値としては同じアドレスを指している」ことが分かります。

ただし、型としては少し違うという点がポイントです。

  • a
    多くの場面で「int型の先頭要素へのポインタ」として扱われる(型としてはint *に近い)
  • &a[0]
    「a[0]というint変数のアドレス」で、型はint *

そのため、ポインタとして使う場面ではほぼ同じように扱えると考えて問題ありません。

例えば、次のように書けます。

C言語
#include <stdio.h>

int main(void) {
    int a[3] = {10, 20, 30};
    int *p;

    p = a;        // aは先頭要素のアドレスとして代入できる
    // p = &a[0]; // もちろんこれもOK

    printf("pが指す値 = %d\n", *p);      // a[0]と同じ
    printf("p+1が指す値 = %d\n", *(p+1)); // a[1]と同じ

    return 0;
}
実行結果
pが指す値 = 10
p+1が指す値 = 20

int a[]とint *pの違いと似ている点

C言語を学んでいると、次の2つが似て見えて混乱しがちです。

C言語
int a[3] = {1, 2, 3};
int *p = a;

aは「配列」であり、pは「ポインタ変数」です。

ここで「配列とポインタは同じもの」と考えてしまうと大きな混乱を招きます。

違い

  • 配列a
    メモリ上に3つ分のintの領域をまとめて確保したものです。大きさは固定で変わりません。
  • ポインタp
    「どこかにあるintの場所」を覚えている1つの変数です。指す先を後から変更できます。

似ている点

多くの場面で配列名aはint *として振る舞うため、次のようなアクセスが見た目は同じように書けます。

C言語
a[1]     // 2番目の要素
p[1]     // pが指す配列の2番目の要素として扱われる
*(a + 1) // a[1]と同じ意味
*(p + 1) // p[1]と同じ意味

「配列名は多くの場合、先頭要素へのポインタとして振る舞う」と理解しておくと、後の説明がすんなり入ってきます。

ポインタ演算(p+1)と配列アクセス(a[i])の対応

ここで、ポインタ演算と配列アクセスの関係をはっきりさせておきます。

配列アクセスa[i]は、ポインタ演算*(a + i)と同じ意味です。

C言語
#include <stdio.h>

int main(void) {
    int a[4] = {10, 20, 30, 40};
    int *p = a;  // aの先頭アドレスをpに代入

    printf("a[0] = %d, *(a+0) = %d, *p = %d\n", a[0], *(a+0), *p);
    printf("a[1] = %d, *(a+1) = %d, *(p+1) = %d\n", a[1], *(a+1), *(p+1));
    printf("a[2] = %d, *(a+2) = %d, *(p+2) = %d\n", a[2], *(a+2), *(p+2));

    return 0;
}
実行結果
a[0] = 10, *(a+0) = 10, *p = 10
a[1] = 20, *(a+1) = 20, *(p+1) = 20
a[2] = 30, *(a+2) = 30, *(p+2) = 30

ここで重要なのは「p+1は、pが指す型のサイズ分だけアドレスを進める」という性質です。

intなら通常4バイトですから、p+1pのアドレスに4を足した場所を指します。

これに*を付けると、その場所にある値を取り出せます。

配列とポインタの図解イメージ

文章だけだとイメージしにくいので、簡単な図のイメージで整理します。

例えば次の配列を考えます。

C言語
int a[3] = {10, 20, 30};
int *p = a;

メモリ上のイメージは、次のようになります(アドレスは仮の値です)。

  • a[0] → アドレス 1000 に 10
  • a[1] → アドレス 1004 に 20
  • a[2] → アドレス 1008 に 30
  • p → アドレス 2000 に「1000」という値(先頭アドレス)が入っている

このとき

  • a&a[0] も「1000」というアドレスを表す
  • pには「1000」というアドレスが入っている
  • p+1は「1004」というアドレスを表す
  • *(p+1)は、その場所(1004)にある値「20」を取り出す

配列は「連続したメモリ領域」、ポインタは「その入口の住所を覚えている変数」というイメージを持つと、両者の関係が直感的に理解しやすくなります。

初心者がつまずくポイントと書き方整理ガイド

ここからは、初心者の方がよく混乱するポイントを1つずつ整理していきます。

&a[0] と a と &a の違いを具体例で整理

最初のつまずきポイントが、この3つの違いです。

  • a
  • &a[0]
  • &a

それぞれについて、アドレスと型の両方を意識しながら見ていきます。

C言語
#include <stdio.h>

int main(void) {
    int a[3] = {10, 20, 30};

    printf("a      = %p\n", (void *)a);
    printf("&a[0]  = %p\n", (void *)&a[0]);
    printf("&a     = %p\n", (void *)&a);

    printf("sizeof(a)     = %zu\n", sizeof(a));     // 配列全体のサイズ
    printf("sizeof(a[0])  = %zu\n", sizeof(a[0]));  // 要素1つのサイズ
    printf("sizeof(&a)    = %zu\n", sizeof(&a));    // &aのサイズ(ポインタ)
    printf("sizeof(&a[0]) = %zu\n", sizeof(&a[0])); // &a[0]のサイズ(ポインタ)

    return 0;
}

出力例(環境により異なります):

txt
a      = 0x7ffc13c8e2d0
&a[0]  = 0x7ffc13c8e2d0
&a     = 0x7ffc13c8e2d0
sizeof(a)     = 12
sizeof(a[0])  = 4
sizeof(&a)    = 8
sizeof(&a[0]) = 8

ここから分かるポイントを整理します。

共通点:

  • a&a[0]&aは、表示されるアドレスの値は同じ(先頭の場所)

違い:

  • a
    「配列」として扱われる。sizeof(a)は配列全体のサイズ(この例ではint×3=12バイト)
  • &a[0]
    「先頭要素a[0]のアドレス」で、型はint *
  • &a
    「配列全体aのアドレス」で、型はint (*)[3](要素数3のint配列へのポインタ)

値としてのアドレスは同じでも、型が違うため、特に多次元配列や関数引数で扱うときに差が出てきます。

この点を意識しておくと、後で出てくる二次元配列の説明が理解しやすくなります。

関数に配列を渡すときの書き方

次につまずきやすいのが「配列を関数に渡す」ときの書き方です。

基本: 配列は「ポインタとして」渡される

C言語では、関数に配列を渡すとき、配列名は自動的に「先頭要素へのポインタ」に変換されます

つまり、int a[10]を渡すとき、関数側ではint *として受け取ることができます。

C言語
#include <stdio.h>

// 配列を受け取って、要素数nの合計を計算する関数
int sum_array(int *p, int n) {
    int i;
    int sum = 0;

    for (i = 0; i < n; i++) {
        sum += p[i];  // p[i]は*(p+i)と同じ
    }
    return sum;
}

int main(void) {
    int a[5] = {1, 2, 3, 4, 5};

    int result = sum_array(a, 5);  // aは先頭アドレスとして渡される

    printf("合計 = %d\n", result);

    return 0;
}
実行結果
合計 = 15

受け取り側を「配列風」に書くこともできる

同じ関数を、次のように書くこともできます。

C言語
int sum_array_alt(int p[], int n) {
    int i;
    int sum = 0;

    for (i = 0; i < n; i++) {
        sum += p[i];
    }
    return sum;
}

このint p[]は、関数の引数では実質的にint *pと同じ意味になります。

つまり関数の引数において、配列は「ポインタとして渡される」ということです。

文字列リテラルとchar配列とcharポインタの関係

文字列まわりも、配列とポインタで混乱しやすいポイントです。

文字列リテラルとchar配列

C言語
char str1[] = "Hello";

この場合、「H」「e」「l」「l」「o」「\0」という6つのcharがメモリ上に連続して確保されます。

str1は、その配列の名前になります。

文字列リテラルとcharポインタ

C言語
char *str2 = "Hello";

こちらは「どこかにある”Hello”という文字列リテラルの先頭アドレス」を指すポインタです。

文字列リテラルのメモリは、通常は書き換えできない領域に置かれます。

違いをコードで確認してみましょう。

C言語
#include <stdio.h>

int main(void) {
    char str1[] = "Hello";   // 書き換え可能な配列
    char *str2  = "Hello";   // 通常、書き換え不可のリテラルを指すポインタ

    printf("str1 = %s\n", str1);
    printf("str2 = %s\n", str2);

    // 配列str1は書き換え可能
    str1[0] = 'h';
    printf("str1(変更後) = %s\n", str1);

    // str2[0] = 'h';  // 多くの処理系では実行時エラーになる可能性があるので危険

    return 0;
}
実行結果
str1 = Hello
str2 = Hello
str1(変更後) = hello

文字列リテラルを指すポインタを通じて文字を書き換えるのは危険なため、文字列を変更したい場合はchar配列として確保したうえで操作するように意識すると安全です。

二次元配列とポインタ

次のつまずきポイントは二次元配列とポインタです。

ここでは基本的なイメージだけを押さえます。

C言語
int a[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

これは「intが6個連続している配列」ですが、「行ごとに3個」という意味付けがされています。

  • a[0] → 1, 2, 3
  • a[1] → 4, 5, 6

このときaは「要素数3のint配列が2個並んでいるもの」として扱われるため、次のようなポインタ型の関数引数で受け取ることができます。

C言語
#include <stdio.h>

// 2次元配列a[行数][3]を受け取って、すべての要素を表示する関数
void print_matrix(int a[][3], int rows) {
    int i, j;
    for (i = 0; i < rows; i++) {
        for (j = 0; j < 3; j++) {
            printf("%d ", a[i][j]);
        }
        printf("\n");
    }
}

int main(void) {
    int m[2][3] = {
        {1, 2, 3},
        {4, 5, 6}
    };

    print_matrix(m, 2);

    return 0;
}
実行結果
1 2 3
4 5 6

ここでの重要ポイントは、関数側の宣言int a[][3]の「3」が必須という点です。

これは「1行あたりの要素数」をコンパイラに知らせるために必要になります。

よりポインタ寄りに書くと、次のように書くこともできます。

C言語
void print_matrix2(int (*p)[3], int rows) {
    int i, j;
    for (i = 0; i < rows; i++) {
        for (j = 0; j < 3; j++) {
            printf("%d ", p[i][j]);  // p[i]は「int[3]」、p[i][j]はその中の要素
        }
        printf("\n");
    }
}

int (*p)[3]「要素数3のint配列を指すポインタ」という意味です。

ここでも&aが持っていた型の話とつながってきます。

ポインタと配列で混乱しないための考え方のコツ

最後に、初心者の方がポインタと配列で混乱しないための考え方のコツをまとめます。

1. まずは「配列は連続したメモリ」というイメージから

最初に「配列は同じ型の値が、メモリ上に連続して並んでいる」というイメージをしっかり持ちます。

インデックス[i]「先頭からi個先」だと考えます。

2. 「配列名は先頭アドレスとして振る舞う」と覚える

多くの場面で、配列名は「先頭要素へのポインタ」に自動変換される、という性質を1つのルールとして覚えておきます。

  • 関数呼び出しの引数
  • 代入int *p = a;
  • ポインタ演算a + i

などで、この性質が使われます。

3. 「値」と「型」を分けて意識する

アドレスの「値」が同じでも、「型」が違えば意味が変わるという点が非常に重要です。

  • a&a[0]&aはアドレス値は同じでも、型は違う
  • 関数の引数int a[]int *pは実質同じだが、int a[10]というローカル配列とは別物

「この変数(または式)の型は何か」を意識しながらコードを読む習慣をつけると、理解が一気に進みます。

4. 実際にアドレスをprintfで表示してみる

学習の際には、実際に%pでアドレスを表示しながら動きを確かめると、図や説明だけでは分かりにくい感覚がつかめます。

  • a&a[0]の違い
  • pp+1のアドレスの差
  • 二次元配列のaa[0]&aの違い

などを、自分の手で実験してみることをおすすめします。

まとめ

C言語の「配列の先頭アドレス」とポインタの関係は、最初はとても抽象的に感じられますが、「配列は連続したメモリ領域」「配列名は多くの場面で先頭要素へのポインタとして振る舞う」という2つの軸を押さえると、かなり見通しがよくなります。

また、値としてのアドレスと、そのアドレスが持つ「型」の違いを意識することが、二次元配列や関数引数での混乱を防ぐ鍵になります。

ぜひこの記事のサンプルコードを実際に動かしながら、自分の目でアドレスと値の変化を確かめて、直感的な理解を深めてみてください。

この記事を書いた人
エーテリア編集部
エーテリア編集部

プログラミングの基礎をしっかり学びたい方向けに、C言語の基本文法から解説しています。ポインタやメモリ管理も少しずつ理解できるよう工夫しています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!