[C言語のはじめ方] Part13: 配列とポインタの関係

C言語

こんにちは! C言語学習の旅、順調に進んでいますか? 😊 これまでに「配列」と「ポインタ」という、C言語の強力な機能を学んできましたね。配列は同じ型のデータをたくさんまとめて扱うのに便利でしたし、ポインタはメモリ上のアドレスを直接操作できる強力なツールでした。

実は、C言語において配列とポインタは非常に密接な関係にあります。「似ているようで、ちょっと違う」この二つの関係を理解することは、C言語プログラミングのスキルを一段階引き上げるためにとても重要です。特に、これから学ぶ「関数への配列の受け渡し」や「文字列操作」では、この知識が必須となります。

このセクションでは、「配列とポインタの関係」に焦点を当て、その仕組みと使い方、そして注意すべき点について、順を追って詳しく見ていきましょう! 💪

このセクションで学ぶこと

  • 配列名が実は「アドレス」を指していること
  • ポインタを使って配列の要素にアクセスする方法
  • 配列の添え字 [] とポインタ演算 *() の関係
  • 配列名とポインタ変数の重要な違い

配列名が指し示すもの:先頭要素のアドレス📍

まず最初に理解すべき、最も重要なポイントがあります。それは、配列名を式の中で使うと、ほとんどの場合、その配列の先頭要素のアドレスとして扱われるということです。

思い出してください。配列を宣言すると、指定した型の要素がメモリ上に連続して確保されます。例えば、int arr[5]; と宣言すると、int型(通常4バイト)のデータ5つ分、合計20バイトの連続したメモリ領域が確保されます。

そして、この配列の名前 arr は、この連続したメモリ領域の開始地点、つまり最初の要素 arr[0] が格納されているメモリアドレスを指し示すのです。これは、アドレス演算子 & を使って &arr[0] と書いた場合と全く同じ意味になります。

実際にコードで確認してみましょう。

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};

    // 配列名 arr そのものを %p (ポインタ表示) で出力
    printf("配列名 arr の値 (アドレス):         %p\n", arr);

    // 配列の先頭要素 arr[0] のアドレスを & で取得して出力
    printf("配列の先頭要素のアドレス (&arr[0]): %p\n", &arr[0]);

    // 比較のために、2番目の要素 arr[1] のアドレスも出力
    printf("配列の2番目の要素のアドレス (&arr[1]): %p\n", &arr[1]);

    // 配列の先頭要素の「値」も確認
    printf("配列の先頭要素の値 (arr[0]):       %d\n", arr[0]);
    // 配列名を間接参照してみる (*arr)
    printf("配列名を間接参照した値 (*arr):   %d\n", *arr);


    return 0;
}

実行結果の例 (アドレス値は環境によって異なります):

配列名 arr の値 (アドレス):         0x7ffeed2b49c0
配列の先頭要素のアドレス (&arr[0]): 0x7ffeed2b49c0
配列の2番目の要素のアドレス (&arr[1]): 0x7ffeed2b49c4
配列の先頭要素の値 (arr[0]):       10
配列名を間接参照した値 (*arr):   10

実行結果を見ると、arr をそのまま出力した場合と、&arr[0] を出力した場合で、全く同じアドレスが表示されていることがわかりますね!これは、配列名 arr が、配列の先頭要素のアドレスを表している証拠です。

さらに、*arr というように、配列名をポインタのように間接参照演算子 * でアクセスすると、先頭要素の値 arr[0] と同じ値が取得できています。これも配列名が先頭要素のアドレスを指しているからです。

また、&arr[1] のアドレスが &arr[0] のアドレスよりも4バイト大きいことにも注目してください (int型が4バイトの場合)。これは、配列の要素がメモリ上で連続して配置されていることを示しています。

このように、配列名は配列の先頭要素のアドレスを指す、特別な存在なのです。

ポインタを使って配列の要素にアクセス! ✨

配列名が先頭要素のアドレスを指すのであれば、ポインタ変数にそのアドレスを代入して、ポインタ経由で配列の要素にアクセスできるはずです。やってみましょう!

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    int *p; // int型へのポインタ変数 p を宣言

    // ポインタ変数 p に配列 arr の先頭アドレスを代入
    // p = &arr[0]; と書いても同じ意味
    p = arr;

    printf("ポインタ p が指すアドレス: %p\n", p);
    printf("p が指す先の値 (*p):      %d\n", *p); // arr[0] と同じはず

    // ポインタを1つ進めてみる (次の要素を指すはず)
    p = p + 1; // または p++;

    printf("\nポインタ p を +1 した後のアドレス: %p\n", p);
    printf("p が指す先の値 (*p):           %d\n", *p); // arr[1] と同じはず

    return 0;
}

実行結果の例 (アドレス値は環境によって異なります):

ポインタ p が指すアドレス: 0x7ffee1c3c9b0
p が指す先の値 (*p):      10

ポインタ p を +1 した後のアドレス: 0x7ffee1c3c9b4
p が指す先の値 (*p):           20

期待通り、ポインタ変数 p に配列名 arr (先頭要素のアドレス) を代入できました。そして *p でアクセスすると、配列の最初の要素 arr[0] の値である 10 が得られました。

次に注目すべきは p = p + 1; の部分です。ポインタに 1 を加算すると、アドレスが 1 バイト増えるのではなく、ポインタが指す型 (この場合は int 型) のサイズ分だけアドレスが増えます。int型が4バイトの環境であれば、アドレスは4バイト進みます。実行結果でも、アドレスが 0x...b0 から 0x...b4 に4バイト増えているのが確認できますね。そして、*p でアクセスすると、次の要素 arr[1] の値である 20 が得られました。

この「ポインタ演算」を使うと、forループと組み合わせて配列の全要素にアクセスできます。

#include <stdio.h>

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

    printf("ポインタ演算を使って配列要素にアクセス:\n");
    for (i = 0; i < 5; i++) {
        // *(p + i) は、p が指すアドレスから i 要素分進んだアドレスにある値
        printf("*(p + %d) = %d  (アドレス: %p)\n", i, *(p + i), p + i);
    }

    printf("\n比較: 配列の添え字を使ってアクセス:\n");
    for (i = 0; i < 5; i++) {
        printf("arr[%d] = %d    (アドレス: %p)\n", i, arr[i], &arr[i]);
    }

    return 0;
}

実行結果の例 (アドレス値は環境によって異なります):

ポインタ演算を使って配列要素にアクセス:
*(p + 0) = 10  (アドレス: 0x7ffeea5c79a0)
*(p + 1) = 20  (アドレス: 0x7ffeea5c79a4)
*(p + 2) = 30  (アドレス: 0x7ffeea5c79a8)
*(p + 3) = 40  (アドレス: 0x7ffeea5c79ac)
*(p + 4) = 50  (アドレス: 0x7ffeea5c79b0)

比較: 配列の添え字を使ってアクセス:
arr[0] = 10    (アドレス: 0x7ffeea5c79a0)
arr[1] = 20    (アドレス: 0x7ffeea5c79a4)
arr[2] = 30    (アドレス: 0x7ffeea5c79a8)
arr[3] = 40    (アドレス: 0x7ffeea5c79ac)
arr[4] = 50    (アドレス: 0x7ffeea5c79b0)

どうでしょうか? *(p + i) という書き方で、arr[i] と全く同じ要素に、同じアドレスでアクセスできているのがわかりますね!

💡 ちょっと面白い話:arr[i] の正体

実は、C言語のコンパイラは、arr[i] という配列の添え字を使ったアクセスを、内部的に *(arr + i) というポインタ演算の形に変換して処理しています。つまり、普段私たちが使っている arr[i] という便利な記法は、ポインタ演算の「糖衣構文(シンタックスシュガー)」、つまり、より分かりやすく書けるように用意された書き方だったのです! 😮

このことを知っていると、配列とポインタの関係性がより深く理解できますね。

でも、配列名とポインタ変数は違うもの! ⚠️

ここまで見てくると、「結局、配列名ってポインタ変数と同じようなものなの?」と思ってしまうかもしれません。確かに、配列名はアドレスを指し、ポインタ演算のように *(arr + i) と書くこともできます。しかし、配列名とポインタ変数は明確に異なるものです。いくつかの重要な違いを見ていきましょう。

違い1:代入の可否

最も大きな違いは、配列名には代入できないという点です。配列名は、その配列がメモリ上に確保された場所の「先頭アドレス」を指す固定された名前(ラベル)のようなものです。一度決まった場所を指し示すものであり、後から別の場所を指すように変更することはできません。

一方、ポインタ変数は、アドレス値を格納するための変数です。そのため、後から別のアドレスを代入し直すことができます。

#include <stdio.h>

int main(void) {
    int arr1[5] = {1, 2, 3, 4, 5};
    int arr2[5] = {10, 20, 30, 40, 50};
    int *p;

    // ポインタ変数には代入可能
    p = arr1; // OK! p は arr1 の先頭アドレスを指す
    printf("p が指すアドレス (arr1): %p\n", p);

    p = arr2; // OK! p は arr2 の先頭アドレスを指すように変更できる
    printf("p が指すアドレス (arr2): %p\n", p);

    // 配列名への代入はコンパイルエラー!
    // arr1 = arr2; // エラー: assignment to expression with array type
    // arr1 = p;    // エラー: assignment to expression with array type

    // エラーになる行をコメントアウトしないとコンパイルできません
    printf("\n配列名 arr1 のアドレス: %p (変更不可)\n", arr1);

    return 0;
}

このコードをコンパイルしようとすると、arr1 = arr2;arr1 = p; の行でエラーが発生します。配列名 (arr1) は「左辺値 (lvalue)」として代入の対象にできないためです。

この違いは、配列が特定のメモリ領域そのものを表すのに対し、ポインタ変数はあくまでその領域を指し示す「矢印」のような役割を持つ、と考えると理解しやすいかもしれません。矢印の向きは変えられますが、領域そのものを別の領域にすり替えることはできません。

違い2:sizeof 演算子の結果

sizeof 演算子を使ったときの結果も、配列名とポインタ変数では異なります。

  • sizeof(配列名): 配列全体がメモリ上で占める総バイト数を返します。(要素の型サイズ × 要素数)
  • sizeof(ポインタ変数): ポインタ変数自体がメモリ上で占めるサイズを返します。(OSやコンパイラによりますが、通常 32bit 環境なら 4 バイト、64bit 環境なら 8 バイト)
#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50}; // int 型 (4バイト) × 5要素 = 20バイト
    int *p = arr; // ポインタ変数 p

    // sizeof 演算子の結果を確認
    printf("sizeof(arr) = %zu バイト\n", sizeof(arr)); // 配列全体のサイズ
    printf("sizeof(p)  = %zu バイト\n", sizeof(p));   // ポインタ変数自体のサイズ

    // 配列の要素数を計算する一般的な方法
    size_t num_elements = sizeof(arr) / sizeof(arr[0]);
    printf("配列 arr の要素数: %zu\n", num_elements);

    return 0;
}

実行結果の例 (64bit環境の場合):

sizeof(arr) = 20 バイト
sizeof(p)  = 8 バイト
配列 arr の要素数: 5

実行結果からわかるように、sizeof(arr) は配列全体のサイズ (4 * 5 = 20 バイト) を返しますが、sizeof(p) はポインタ変数のサイズ (この環境では 8 バイト) を返します。全く異なる値ですね。

sizeof(配列名) / sizeof(配列の要素の型) という計算で、配列の要素数を求めるテクニックは非常によく使われるので、覚えておくと便利です 👍。

⚠️ 注意:関数に配列を渡すとき

関数に引数として配列を渡す場合、実は配列そのものがコピーされて渡されるのではなく、配列の先頭要素のアドレスだけが渡されます。これは、関数側では配列を「ポインタ」として受け取ることになるためです。

このとき、関数の中で渡された配列(実際にはポインタ)に対して sizeof を使うと、配列全体のサイズではなく、ポインタ変数のサイズ (4バイト or 8バイト) が返ってきてしまいます! これは混乱しやすいポイントなので、注意が必要です。関数へ配列を渡す方法については、次のステップ「ポインタを使った関数呼び出し(参照渡し)」で詳しく学びます。

まとめ:配列とポインタの関係性を理解しよう! 🚀

今回は、C言語における配列とポインタの重要な関係について学びました。ポイントを振り返りましょう。

  • ✅ 配列名は、ほとんどの場合、その配列の先頭要素のアドレスとして扱われる (arr&arr[0] とほぼ同等)。
  • ✅ ポインタ変数に配列の先頭アドレスを代入できる (int *p = arr;)。
  • ✅ ポインタ演算 *(p + i) を使うことで、配列の要素 arr[i] にアクセスできる。
  • ✅ 配列の添え字アクセス arr[i] は、内部的にポインタ演算 *(arr + i) として解釈される。
  • ただし! 配列名とポインタ変数は異なるもの:
    • 配列名には再代入できないが、ポインタ変数にはできる。
    • sizeof(配列名) は配列全体のサイズ、sizeof(ポインタ変数) はポインタ自体のサイズ。

配列とポインタの関係は、最初は少し難しく感じるかもしれませんが、C言語を使いこなす上で避けては通れない重要な概念です。特に、関数に配列を渡したり、文字列(文字の配列)を扱ったりする際には、この知識が直接的に役立ちます。

今回の内容をしっかり理解し、サンプルコードを自分で動かしてみることで、より深く身につけることができます。焦らず、一つずつ確実にステップアップしていきましょう! 次は、「ポインタを使った関数呼び出し(参照渡し)」について学びます。お楽しみに! 🎉

コメント

タイトルとURLをコピーしました