LoginSignup
4
4

More than 5 years have passed since last update.

配列のポインタを受け取る

Posted at

他のプログラミング言語の経験がある人が C を使い始めて配列に戸惑っている事例を見ました。 C プログラミングにおける配列は多くの場所でいつの間にかその配列の先頭を指すポインタに置き換わってしまい、配列としての情報 (配列の長さ) が欠落することが様々な場面で面倒な問題 (バッファオーバーランなど) を引き起こします。

大きさが異なる

たとえば、要素数が 5 の配列を受け取って要素の合計を返す関数 accum を定義し、またそれを使おうと考えて以下のようなプログラムを書いた場合にどうなるでしょうか。

#include <stdio.h>

int accum(int ar[5]) {
  int total=0;
  for(int i=0; i<5; i++) total+=ar[i];
  return total;
}

int main(void) {
  int foo[3]={1, 3, 7};
  printf("%d\n", accum(foo)); // 要素数が 3 の配列を渡している
  return 0;
}

配列の要素の数が異なるにもかかわらずエラーにはならずコンパイルできてしまいます。 ですが実行するとデタラメな値が表示されるかクラッシュするかのいずれかになるでしょう。 未定義動作です。

何が起きているのか

このとき、何が起きているのでしょうか。 まずは呼び出しの側から見てみます。 C99 の仕様 (の日本語訳である JISX3010:2003) には 6.3.2.1 の三段落目に書かれている以下の文が関連箇所です。

左辺値が sizeof 演算子のオペランド、単項 & 演算子のオペランド、又は文字配列を初期化するのに使われる文字列リテラルである場合を除いて、型"〜型の配列"をもつ式は、型"〜型へのポインタ"の式に型変換する。 それは配列オブジェクトの先頭の要素を指し、左辺値ではない。 配列オブジェクトがレジスタ記憶域クラスをもつ場合、その動作は未定義とする。

配列 foo の型は int[3] のはずですが、これは式として評価されると暗黙の型変換によって int* になります。 C の考え方では式の結果の型が配列ということはないと考えてよいでしょう。

ちなみに「左辺値」とあるのは lvalue の訳語なのですが、必ずしも代入や比較の左辺として現れるわけではない独特の意味を持った用語なので注意してください。 (個人的にはL値とでも名付けるのが妥当ではないかと思っています。)

そして呼出される側、つまりは仮引数については 6.7.5.3 にこうあります。

仮引数を"〜型の配列"とする宣言は、"〜型への修飾されたポインタ"に型調整する。 そのときの型修飾子は、配列型派生の [ 及び ] の間で指定したものとする。 配列派生の [ 及び ] の間にキーワード static がある場合、その関数の呼出しの際に対応する実引数の値は、大きさを指定する式で指定される数以上の要素をもつ配列の先頭要素を指していなければならない。

仮引数の側でも配列はポインタになるわけです。 int* 型の値を int* 型の仮引数で受けているので当然ながらエラーにならないわけです。

見掛け上は配列を渡して配列を受け取っているように見える書き方なのに、実際にはポインタのやり取りに化けてしまうのです。

それでも配列

どうしても固定長の配列を関数に渡したいという場合、以下のように書くことも出来ます。

#include <stdio.h>

int accum(int (*ar)[5]) {
  int total=0;
  for(int i=0; i<5; i++) total+=*ar[i];
  return total;
}

int main(void) {
  int foo[3]={1, 3, 7};
  int bar[5]={8, 2, 5, 3, 2};
  printf("%d\n", accum(&foo)); // 型が合わないので警告が出るかも
  printf("%d\n", accum(&bar));
  return 0;
}

このとき、 &bar の型は int (*)[5] です。 つまり、要素の型が int で要素数が 5 であるような配列を指すポインタです。 今まで配列の先頭要素を指すポインタを使っていたのを、配列を指すポインタにするということです。 暗黙の型変換で情報が失なわれることなく扱えるようになりました。

関連する規格を順番に見ていきます。

6.5.2.2 関数呼出し
呼び出される関数を表す式が関数原型を含む型をもつ場合、実引数は代入と同じ規則で、対応する仮引数の型に暗黙に変換する。 このとき各仮引数の型は、宣言した型の非修飾版とする。

関数呼出しの実引数と仮引数の関係は代入における右辺と左辺の関係と同じということです。

キャスト無しで代入してもいいものでしょうか。

6.5.4 キャスト演算子
6.5.16.1 の制約で許されるものを除いた、ポインタを含む型変換は、明示的なキャストで指定しなければならない。

逆に言えば 6.5.16.1 で許されているならば暗黙に型変換されるということですね。

6.5.16.1 単純代入
制約 次のいずれかの条件が成立しなければならない。
・ 左オペランドの型が算術型の修飾版又は非修飾版であり、かつ右オペランドの型が算術型である。
・ 左オペランドの型が右オペランドの型に適合する構造体型又は共用体型の修飾版又は非修飾版である。
両オペランドが適合する型の修飾版又は非修飾版へのポインタであり、かつ左オペランドで指される型が右オペランドで指される型の型修飾子をすべてもつ。
・ 一方のオペランドがオブジェクト型又は不完全型へのポインタであり、かつ他方が void の修飾版又は非修飾版へのポインタである。 さらに、左オペランドで指される型が、右オペランドで指される型の型修飾子をすべてもつ。
・ 左オペランドがポインタであり、かつ右オペランドが空ポインタ定数である。
・ 左オペランドの型が _Bool 型であり、かつ右オペランドがポインタである。

両オペランドが適合するかどうかが肝心なわけです。 適合とは何でしょうか。

6.2.7 適合型及び合成型
二つの型が同じ場合、二つの型は適合する (compatible) とする。
(中略)
合成型 (composite type) は、適合する二つの型から構成することができる。 合成型は、二つの型の両方に適合し、かつ次の条件を満たす型とする。
・ 一方の型が既知の固定長を持つ配列の場合、合成型が、その大きさの配列とする。 そうでなく、一方の型が可変長の配列の場合、合成型はその型とする。
・ 一方の型だけが仮引数型並びをもつ関数型 (関数原型) の場合、合成型は、その仮引数型並びをもつ関数原型とする。
・ 両方の型が仮引数型並びをもつ関数型の場合、合成仮引数型並びにおける各仮引数の型は、対応する仮引数の型の合成型とする。
これらの規則は、二つの型が派生される元の型に再帰的に適用する。

この理屈だと int(*)[5]int(*)[3] は適合しませんね。 つまり、キャストが必要な場合ということになります。 が、 GCC や Clang で試してみるとデフォルトでは警告は出ますが、エラーにはならないようです。 -pedantic-errors オプションを付けるようにしましょう。 そこまでしなくても、少なくとも問題がある箇所が通知されるのはありがたいことではないでしょうか。

ついでに念のため、陽にキャストすることは可能な場合なのか調べてみました。

6.3.2.3 ポインタ
オブジェクト型又は不完全型へのポインタは、他のオブジェクト型又は不完全型へのポインタに型変換できる。 その結果のポインタが、被参照型に関して正しく境界調整されていなければ、その動作は未定義とする。 そうでない場合、再び型変換で元の型に戻すならば、その結果は元のポインタと比較して等しくなければならない。 オブジェクトへのポインタを文字列型へのポインタに型変換する場合、その結果はオブジェクトの最も低位のアドレスを指す。 その結果をオブジェクトの大きさまで連続して増分すると、そのオブジェクトの残りのバイトへのポインタを順次生成できる。

動作に未定義な部分はありますが、型変換自体は許されているようです。

4
4
2

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
4
4