12
6

More than 5 years have passed since last update.

C言語におけるポインタと配列の関係について考察してみる

Last updated at Posted at 2017-12-20

はじめに

この記事は呉高専エンジニア勉強会 Advent Calender 2017、20日目の記事です。

中学生の頃からお世話になってきたCですが、配列とポインタへが同じように扱えてしまい、理解が曖昧だったのでまとめてみました。

配列やポインタの使い方などは詳しく説明しませんのであしからず。

環境

  • Ubuntu 16.04
  • gcc 5.4.0

ポインタについておさらい

ポインタ (pointer) とは、あるオブジェクトがなんらかの論理的位置情報でアクセスできるとき、それを参照する(指し示す)ものである。有名な例としてはC/C++でのメモリアドレスを表すポインタが挙げられる。
(https://ja.wikipedia.org/ より引用)

Cは関数への引数は値渡しになるので、実引数のコピーとして新たに実体を作ることになります。

なので、巨大な構造体などを引数として渡すと、それがプログラムのボトルネックになってしまったりします。

そういう場合にポインタで渡してあげると、ポインタの特性を生かして参照渡しとなり、幸せになれるかもしれません。

配列についておさらい

複数の要素(値)の集合を格納・管理するのに用いられるデータ構造が配列である。数学のベクトルおよび行列に近い概念であり、実際にベクトルおよび行列をプログラム上で表現する場合に配列が使われることが多い。同様に複数要素の集合を管理するデータ構造(コレクションあるいはコンテナ)には連結リストやハッシュテーブルなどがあるが、通常はメモリアドレス上での連続性の違いなどから配列とは区別される。1次元の配列は特に線形配列 (linear array) とも呼ばれる。
(https://ja.wikipedia.org/ より引用)

配列の利点は、同じ型の値を連続して複数保持できることにあります。
連続して確保されるというのはプログラムを組む上でとても便利なことです。
ここで重要なのは、配列はあくまで値を複数保持するという目的で存在しているということです。

配列とポインタ演算

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

main1.c
char ans = 2["ABCDEFG"];

ここでansの値は'C'となります。

もう少しわかりやすい例で見てみましょう。

main2.c
int index = 1;
char array[] = "ABCDEFG";
char ans = index[array];

ここでansの値は'B'となります。

どうして配列名と添え字を入れ替えて動作するのか。
それは配列とポインタに次のような関係があるからです。

array[index] == *(array + index)

配列名はその配列の先頭アドレスを指します。
つまり、右辺の()内の計算は単なる計算ではなく、アドレス演算になります。
アドレス演算における基本単位はそのアドレスが指す型に依存します。

下記に示すコードで、任意の型のサイズを取得することができます。

main3.c
int a;
printf("%ld",sizeof(a));
main3.o
4

実行結果から筆者の環境では、int型のサイズが4バイトだとわかりました。(結果は環境依存になります)
つまりint*に対してアドレス演算を行うと、+1で4バイト進みます。

またcharのサイズが1バイトであることから

main4.c
int array[] = {1,2,3,4,5,6,7,8,9};
printf("%d\n%d\n", *(array+1), *((int*)((char*)array+4)));
main4.o
2
2

キャストを用いれば、このように一時的にarraycharの配列と見なすことが出来ます。
charは1バイトなので、4を足すことによって4バイトを進めることができます。
結果としてどちらも4バイト進めているので、同じ結果が出力されます。

これらを踏まえたうえで、改めてアドレス演算の式を見てみましょう。
*(array + index)は足す順番を変えても同じなので*(index + array)は同じ結果となります。
つまり配列とポインタの関係からarray[index] == index[array]の関係が成り立つことが分かります。

ここでmain2.cを見てみるとarraychar*indexintを渡しています。
char*const char*を渡してもいいので、変数に格納せずに書くと

main5.c
char ans = "ABCDEFG"[2]

と書けます。

配列名と添え字は交換可能なわけですから、交換するとmain1.cとなることがわかります。

また汎用ポインタ型と呼ばれるvoid*というものがあります。void型はありません。
これはどんな型のアドレスでも受け取れるという優れものです。

しかしながら、void*から格納された値を参照するにはキャストをする必要があります。
C++ではキャストは必須ですが、Cでは任意らしいです。
筆者の環境ではerror: invalid use of void expressionとなりました。
使う際は気を付けましょう。

この配列とポインタの関係は、これらが同義であることを示したわけではありません。
どちらも確実に目的が違い、それぞれが期待された方法で使用されるべきです。

昔は配列をfor文で回すとき、ポインタ演算で値への参照を行っていたこともあるそうです。
そのほうが実行にかかる時間が減るからという理由でした。
が、今はPCのスペックも上がって、そんなことを気にする必要は、あまりありませんね。また、最近のコンパイラは最適化により、どちらも同じように評価するそうです。
なので、可読性を意識しましょう。

文字列から1文字ずつ渡す処理をそれぞれで書くと

array.c
for(i=0; i<SIZE; i++){
    func(array[i]);
}
pointer.c
for(p=array; *p!='\0'; p++){
    func(*p);
}

となります。
どちらが可読性が高いかは、一目瞭然ですね。

多次元配列のポインタ演算

2次元配列について、ポインタ演算によって値を参照してみたいと思います。

多次元配列の三原則

  • 多次元配列としての型情報は持っているが、配列の要素を持つ配列、の要素を持つ配列...として多次元配列は成立している。
  • 実際の要素の値としては、1つ次元を落とした型を保持している。
  • これらの多次元配列を例外を除いた、式中のオペランドとして用いた場合、暗黙の型変換によって1次元失った型へのポインタとなる。

これらの三原則については、コメント欄に詳しい説明が載っています。

ということで、実際に例を用いてみましょう。
例えば次のような配列を宣言したとします。

int array[3][4];

これは通常3×4の配列と言われますが、厳密には違います。
これは、要素数4のint型配列の要素数3のint型配列です。
つまりarrayは、配列を要素に持った配列です。ややこしい。

Cの配列は連続であることが保証されています。
ただ、多次元配列においては配列の配列の配列の…となるため、1つ1つの配列が連続であることは保証されていますが、この実体全てが連続である、ということは実は明言されていないのです。
しかし仕様上、この実体全てが連続であることはほぼ確実であるらしいです。(仕様書などを読んでないので詳しくは言えません。間違ってたらごめんなさい)

ということで、多次元配列のポインタ演算をやっていきましょう。原則として

array[index] == *(array + index)

は成り立つわけですから、これに当てはめていけばいけそうですね。

配列の配列ということは、最初の添え字と配列名はまず計算できそうです。
また、要素に値するそれは、1つ次元を落とし保持された配列なわけですから、上式のarrayへ再代入できそうです。再起関数みたいですね。
とりあえず、今の方法で下の配列の7を参照するコードを書いてみます。

main10.c
int array[3][4] = {{1,2,3,4},
                   {5,6,7,8},
                   {9,10,11,12}};
printf("%d\n", *(*(array+1)+2));
main10.o
7

成功です。
この方法なら何次元の配列になってもポインタ演算で参照できそうです。

これを先ほど行った交換を踏まえると

final.c
#include <stdio.h>
int main(void){
    int array[3][4] = {{1,2,3,4},
                       {5,6,7,8},
                       {9,10,11,12}};
    printf("%d\n", *(*(array+1)+2));
    printf("%d\n", array[1][2]);
    printf("%d\n", 1[array][2]);
    printf("%d\n", 2[1[array]]);
    printf("%d\n", 2[array[1]]);
    return 0;
}
final.o
7
7
7
7

となるわけです。

おわりに

多次元配列をポインタ演算で表すと、何故指定する要素を1つ減らすことで、指定した子配列の先頭アドレスが取得出来るのかがよく分かると思います。

また近いうちに書きたいと思います。

備考

こちらの記事でこの記事の再考察をしていただきました。ありがとうございます。

12
6
11

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
6