やり直しC言語:配列はファーストクラスではなかった

Posted by Hiraku on 2015-09-23

C言語を久しぶりに触ってやっと理解できたのが、配列は第一級オブジェクト(ファーストクラスオブジェクト) ではない 、ということです。

第一級オブジェクト - Wikipedia

第一級オブジェクト

Wikipediaによると、第一級オブジェクトと呼ばれるものは以下の様な特徴を持つそうな。

  • 無名のリテラルとして表現可能である。
  • 変数に格納可能である。
  • データ構造に格納可能である。
  • それ自体が独自に存在できる(名前とは独立している)。
  • 他のものとの等値性の比較が可能である。
  • プロシージャや関数のパラメータとして渡すことができる。
  • プロシージャや関数の戻り値として返すことができる。
  • ...

C言語だと、int(1, 2)やchar('a', 'b')、floatやboolなどがこれに相当します。また、任意の型へのポインタも、同じ性質を持っています。

私はC言語を真面目に勉強する前にJavaScriptを勉強したので、jsにおける関数が第一級オブジェクトだと聞いて「なるほどー、確かにC言語の関数は第一級オブジェクトではないな」という理解をした記憶があります。

しかし結構最初の方に学ぶはずの配列が、第一級オブジェクトではないというのは中々気づけなかった。

思い返してみれば、C言語の配列は色々な制限がありました。

C言語の配列にできないこと

配列型の変数宣言は普通にできるのに、まとめて扱うことがとにかくできないです。

代入できない

char str1[6] = "abcde";
char str2[6];

str2 = str1; /* エラー */

配列変数の識別子は、式中では「配列の先頭要素のアドレス」の意味だと解釈されます。(ただし、&やsizeofのオペランドの時は例外的に配列の実体のような挙動を示します。ややこしい!)

アドレスであってポインタではないので、そこに代入することはできません。右辺値と呼ばれることもあります。

結果として、配列型の変数は要素一つ一つなら代入できますが、=で丸ごとコピーすることはできません。そのため、大抵は専用に関数を用意してコピーを行います。

比較できない

式中で配列の識別子は「配列の先頭要素のアドレス」になってしまうので、==で比較すると大抵意図した結果になりません。というかたぶん、コンパイラがエラーを検知してコンパイルできないと思います。

これも、通常はstrncmp()などの専用の関数を使って比較します。

関数の引数で渡せない

配列は引数として渡すことが不可能です。
配列は引数として渡すことが不可能です。

大事なことなので2回言いました。

void do_something(char str[]) {
  /* ... */
}

こういうシグネチャの関数を作ることができますが、関数の引数宣言の時だけ、char str[]char *strと全く同じ意味になります。配列記法がポインタの意味になるという挙動は、C言語ではここでだけ発生します。

そもそも、配列は式中では「配列の先頭要素のアドレス」と解釈されてしまい、大抵の場合「配列そのもの」に触ることはできません。

char str[] = "hello";
do_something(str); /* この書き方をした時点で、strはアドレスであって配列そのものではない */

というわけで、どう頑張っても配列そのものを関数に渡すことはできないのです。

ポインタなら渡せるので、配列は常にポインタを介して渡す形になります。

ただし、ポインタとして渡されると、配列の要素数を知るすべがありません。もう一つ引数を用意して要素数を教えてもらうか、末尾に特別な値を置いて検知可能にするなど、工夫が必要になります。

関数の戻り値として返せない

これも同じく。どう頑張っても直接配列の実体を返すことはできません。

return str; /* この書き方をした時点で、strはアドレスであって配列そのものではない */

ポインタとして返すことはできます。ただ、自動変数の挙動との兼ね合いもあり、配列を扱う関数は、「引数として渡してもらった配列を指すポインタに、詰めて返す」というインターフェースにすることが多いようです。

こういう「引数返し」みたいなインターフェースは、式として書けなくなってしまうので使い勝手が悪いと感じるのですが、C言語では仕方ないですね。。

配列型変数の解釈

配列型変数は、式中の書かれ方で異なる解釈がされるという仕様になっています。もう一回まとめておこうと思います。

通常は、先頭要素のアドレス

大抵の場合、先頭要素のアドレスとして解釈されます。先頭要素であって配列全体ではないことに注意です。

int arr[2] = {1,2};
int *p;
p = arr; /* int*の変数に代入できる */
  • intの配列なら、変数の解釈は「intへのアドレス」であり、int*型の変数に代入できる
  • charの配列なら、変数の解釈は「charへのアドレス」であり、char*型の変数に代入できる
  • intの配列の配列なら、変数の解釈は「intの配列へのアドレス」であり、int(*)[要素数]型の変数に代入できる

配列が入れ子になっている場合が少しややこしいですね。

[]という演算子について

配列の添字を書く時に使う[]という演算子は、ただのシンタックスシュガーです。次のように解釈されます。

a[2]
↓
*(a+2)

ポインタ演算で2つアドレスをずらしたところにある実体、ということですね。

シンタックスシュガーなので、aが配列型変数であっても、ポインタであっても同じように[]を作用させることができます。

わざわざこの構文が用意されている理由は、記法の簡便さもありますが、特に入れ子の配列が登場した時にすごく書きにくいからだと思います。

a[2][3]
↓
*(*(a+2)+3)

配列の実体を指す場合

少し述べましたが、&sizeofのオペランドの時だけ、配列の実体を指しているかのような挙動になります。

int arr[3] = {1,2,3};
int (*arrp)[3];

/* arrp = arr; はエラーになる。何故ならarrはintへのアドレスと解釈されるから */
arrp = &arr; /* 何故かこれは平気!! */

arrが常にアドレスとして解釈されるならば、&を作用させるのはナンセンスなはず。 ポインタ型変数でもないので、コンパイルエラーになるはず!

しかし、なぜか&を作用させることができ、この場合は配列全体のアドレスを返します。

たぶん、「配列全体のアドレス」と「配列の先頭要素のアドレス」は同じ数字が返ってくると思いますが、型が異なることに注意が必要です。

sizeof演算子に配列型変数を渡すと、全体の大きさが返ってきます。これを利用して配列のサイズを計算することができます。でも、固定長配列しか無いし、普通はベタ書きするんじゃないでしょうか。

int arr[3] = {1,2,3};
printf("%lu\n", sizeof arr / sizeof (int)); /* 要素数3 が出力されるはず */

まとめると

宣言 a[0]の型 aを代入できるポインタ型 &aを代入できるポインタ型
int a[10] int int *p int (*p)[10]
char a[10] char char *p char (*p)[10]
int a[2][3] int[3] int (*p)[3] int (*p)[2][3]

雑感

原則としてスカラ値しか扱えないという仕様は、古い言語にはよくあったそうですが、今見るとただただつらいです。。

codingの最新記事