🤖

C/C++の配列と糖衣構文

2024/09/15に公開
3

はじめに

関数の引数として多次元配列を受け取る時、以下の2つの違いを説明します。

void func1(int a[2][3]) {
}

void func2(int (&a)[2][3]) {
}

配列とポインタと糖衣構文

度々話題になりますが、C/C++言語におけるa[1]のような配列表記は*(a+1)の糖衣構文となっています。

こんな配列宣言があったとします。

  int a[] = {1, 2, 3};

この時、aはこの配列の先頭データへのポインタになります。したがって、アスタリスクをつけることで値を取り出すことができます。

  printf("%d\n", *a);       // => 1

つまり、*aa[0]と同じ意味です。さて、C/C++ではポインタに型が設定されています。ポインタに1を足すと、アドレスがその型のサイズだけずれます。ポインタが指すアドレスはprintf%pで表示できます。

  printf("%p\n", a);     // => 0x7ffd794d272c
  printf("%p\n", a + 1); // => 0x7ffd794d2730

アドレス末尾に注目すると、2cから30、すなわち4バイトズレたことがわかります。なので、ポインタa+1にアスタリスクをつけると、配列の2番目の要素にアクセスできます。

  printf("%d\n", *(a + 1)); // => 2

つまり、*(a + 1)a[1]と等価です。逆にa[1]と書くと、*(a + 1)と解釈され、前者は後者の糖衣構文になっています。

さて、a[1]*(a + 1)になるのですから、1[a]*(1 + a)と解釈されます。明らかにa + 11 + aと等しいので、*(a + 1)*(1 + a)は同じ意味になります。以上から、a[1]1[a]は同じ意味になります。なので、こんなコードも(気持ち悪いですが)全く合法です。

  int a[] = {1, 2, 3};

  for (int i = 0; i < 3; i++) {
    // a[i]とi[a]は同じ意味になる
    printf("%d\n", i[a]); // => 1, 2, 3
  }

要するに、C/C++言語において配列とはポインタ操作の糖衣構文に過ぎません。まとめておくとこんな感じです。

  • a[N]のような宣言をしたら、aは先頭要素へのポインタとなります。
  • ポインタにアスタリスクをつけるとそのアドレスが指す値になります(*aa[0]と等価)
  • ポインタは型を持ち、次の要素が何バイト先(+1された時に、アドレスが何バイトずれるか)を知っています
  • 2番目の要素のアドレスは(a + 1)であり、その値は*(a + 1)です。それをa[1]と表記できます

多次元配列

多次元配列でも、基本的な考え方は同じです。ポインタの型がちょっとややこしくなり、「+1された時にアドレスが何バイトずれるか」が変わるだけです。

例えば、こんな配列を考えます。

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

int型のデータが2x3=6個あるので、全体で24バイトの連続したデータが確保され、aはその先頭を指しています。さて、aにたいしてa+1はどれくらいアドレスがずれるでしょうか?

  printf("%p\n", a);     // => 0x7fff70ae4a40
  printf("%p\n", a + 1); // => 0x7fff70ae4a4c

12バイトだけずれました。これはそれぞれa[0]a[1]と等価です。つまり、多次元配列であっても、ポインタと配列の糖衣構文のフォーマットは変わりません。2次元配列であることを反映して、a[0]a[1]のアドレスが整数3つ分だけずれているだけです。

さて、a[1](a+1)と等価なのですから、a[1][2](*(a + 1))[2]と等価なはずです。

  printf("%d\n", a[1][2]);       // => 6
  printf("%d\n", (*(a + 1))[2]); // => 6

さらに[2]をバラすと、(*(a + 1))[2]*(*(a + 1)) + 2)になります。

  printf("%d\n", a[1][2]);         // => 6
  printf("%d\n", (*(a + 1))[2]);   // => 6
  printf("%d\n", *(*(a + 1) + 2)); // => 6

要するに*(*(a + 1)) + 2)と書くのが面倒だからa[1][2]と書けるようにしましょう、となっているだけです。さらに多次元でも同様です。

関数の引数

配列を関数の引数として受け取った時も、糖衣構文が適用されます。例えば一次元配列を関数の引数として受け取った場合、サイズの情報は失われ、単なるポインタと等価となります。

以下の3つの宣言は等価です。

void func1(int a[N]);
void func2(int a[]);
void func3(int *a);

つまり、int a[N]int *aの糖衣構文であり、サイズNの情報は無視されます。

これが等価であることを調べるには、typeinfoを使うと便利です。

#include <cstdio>
#include <typeinfo>

const int N = 4;

void func1(int a[N]) {
  printf("%s\n", typeid(a).name());
}

void func2(int a[]) {
  printf("%s\n", typeid(a).name());
}

void func3(int *a) {
  printf("%s\n", typeid(a).name());
}

int main() {
  int a[N] = {1, 2, 3, 4};
  func1(a);
  func2(a);
  func3(a);
}

実行結果はこんな感じになります。

Pi
Pi
Pi

全て同じ型(int *)であることがわかります。

コンパイラにサイズNの情報を伝えたい時には、

void func4(int (&a)[N]);

という形で宣言します。こいつのtypeid(a).name()を表示するとA4_i、すなわち整数(i)型の配列であり、サイズ4であることが認識されていることがわかります。

実際、このように宣言するとコンパイラはサイズがわかるので、領域外参照をコンパイル時にチェックできるようになります。

#include <cstdio>
#include <typeinfo>

const int N = 4;

void func1(int a[N]) {
  printf("%s\n", typeid(a).name());
  a[N] = 1;
}

void func2(int a[]) {
  printf("%s\n", typeid(a).name());
  a[N] = 1;
}

void func3(int *a) {
  printf("%s\n", typeid(a).name());
  a[N] = 1;
}

void func4(int (&a)[N]) {
  printf("%s\n", typeid(a).name());
  a[N] = 1;
}

int main() {
  int a[N] = {1, 2, 3, 4};
  func1(a);
  func2(a);
  func3(a);
  func4(a);
}

それぞれの関数で、a[N] = 1と領域外参照をさせています。これをclang++でコンパイルすると、

$ clang++ test2.cc
test2.cc:23:3: warning: array index 4 is past the end of the array (which contains 4 elements) [-Warray-bounds]
  a[N] = 1;
  ^ ~
test2.cc:21:12: note: array 'a' declared here
void func4(int (&a)[N]) {
           ^
1 warning generated.

と、func4だけ領域外参照の可能性を検出しています。

多次元配列と引数

さて、ここまで来ると

void func1(int a[2][3]) {
}

void func2(int (&a)[2][3]) {
}

の違いがわかると思います。こんなコードを書いてみましょう。

#include <cstdio>
#include <typeinfo>

void func1(int a[2][3]) {
  printf("func1: %s\n", typeid(a).name());
}

void func2(int (&a)[2][3]) {
  printf("func2: %s\n", typeid(a).name());
}

void func3(int a[][3]) {
  printf("func3: %s\n", typeid(a).name());
}

int main() {
  int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
  printf("main:  %s\n", typeid(a).name());
  func1(a);
  func2(a);
  func3(a);
}

実行してみます。

main:  A2_A3_i
func1: PA3_i
func2: A2_A3_i
func3: PA3_i

main関数内でint a[2][3]と定義された配列は、A2_A3_i、すなわち整数の配列であり、サイズ2とサイズ3の入れ子になった状態であることがわかります。

しかし、それをfunc1(int a[2][3])という形で受けると、PA3_i、すなわち「サイズ3の整数配列へのポインタ」と認識されています。これがint a[][3]と等価、すなわち、最初のサイズが2であることが無視されていることがわかります。

main関数と同じ型で受けたい場合はfunc2(int (&a)[2][3])と、カッコとアンドマークをつける必要があります。こうするとA2_A3_i、すなわちmain関数と全く同じ型で受け取れていることがわかります。

多次元配列のサイズ情報を全て確定したい時はあまりないのですが、例えばテンプレートによってコンパイル時にサイズが確定するような配列を書きたい時にたまに使います。

#include <cstdio>

template <size_t M, size_t N>
void show(int (&a)[M][N]) {
  for (int i = 0; i < M; i++) {
    for (int j = 0; j < N; j++) {
      printf("%d ", a[i][j]);
    }
    printf("\n");
  }
}

int main() {
  int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
  int b[2][2] = {{1, 2}, {3, 4}};
  printf("a = \n");
  show(a);
  printf("b = \n");
  show(b);
}

実行結果はこんな感じです。

a = 
1 2 3 
4 5 6 
b = 
1 2 
3 4

異なるサイズの配列に対して、同じ関数が適用できていることがわかります。

これを

template <size_t M, size_t N>
void show2(int a[M][N]) {

と書いてしまうと、Mの情報が無視されてしまうため、テンプレートが展開できなくなってエラーになります。

まとめ

C/C++の多次元配列と糖衣構文、そして関数の引数の受け方についてまとめておきました。糖衣構文について知らないと、

void func1(int a[2][3]) {
}

void func2(int (&a)[2][3]) {
}

の違いであったり、

template <size_t M, size_t N>
void show2(int a[M][N]) {

がエラーになる理由がよくわからないと思います。

この記事が令和にもなってC/C++の配列宣言に悩む人の助けになれば幸いです。

GitHubで編集を提案

Discussion

arborvitae666arborvitae666

内容的に*(*(a + 1)) + 2ではなくて、*(*(a + 1) + 2)ではないですか?

ロボ太ロボ太

ありがとうございます。タイポしたのに偶然正しい値になってしまっていました。修正しました🙇‍♂️

タートルタートル

配列とはポインタ操作の糖衣構文

「添字演算子はポインタ操作の糖衣構文」の方が分かりやすいと思われます
配列はサイズ情報を持つオブジェクトの一種で、ポインタはその戻り値なので、厳密には異なります

配列変数がポインタとして振る舞っても、変数自体は配列を保持します
引数では糖衣として扱うことができますが、これは限定的な挙動なので注意が必要です