はじめに
先日コードレビューで、引数に配列をとる関数を作成した際に 「引数の配列型にサイズ情報を持たせて」 と指摘をもらい、改めて調べたところ、配列ポインタ型を使えばコンパイラに要素数の不一致を検出させられることがわかりました。結構初歩的なミスだったため自戒の念をこめて記事にしようと思います。
なぜ配列を渡す際に配列長が渡らないのか
読んでいる方には、一切C言語に触ったことない人もいると思います。そういった方々からしたら、「わざわざ引数にそんな情報もたせなくても、配列の長さはlengthプロパティとかで取得すればいいのでは?」と思うかもしれません。
そうした際に先ほどのコードレビューの流れがいまいちピン来ないと思いますので、簡単なC言語の構文と仕様について触れながら説明していきたいと思います。
C言語の配列と関数引数
まず、C言語で配列を関数に渡す基本的な書き方を見てみましょう。
#include <stdint.h>
#define BUF_SIZE 5
void fill_zero( uint8_t buf[ BUF_SIZE ] )
{
uint8_t i;
for ( i = 0; i < BUF_SIZE; i++ )
{
buf[i] = (uint8_t)0x00U;
}
}
一見、引数の buf[ BUF_SIZE ] で「要素数5の配列を受け取る」と宣言しているように見えます。しかし、C言語の規格では、関数の仮引数における配列宣言は自動的にポインタに変換されます。
本記事で参照している仕様は ISO/IEC 9899:1999(C99)§6.7.5.3 p7 です。
規格書の原本(PDF)は以下から参照できます。
https://www.dii.uchile.cl/~daespino/files/Iso_C_1999_definition.pdf
A declaration of a parameter as "array of type" shall be adjusted to
"qualified pointer to type"
「"array of type" として宣言されたパラメータは "qualified pointer to type" に読み替えられる」という規定です。
残念なことに、C言語には配列が配列長を自ら保持する仕様がないのです。
つまり、コンパイラの目には以下の2つは全く同じに映っています。
void fill_zero( uint8_t buf[ BUF_SIZE ] ) /* 配列風に書いても… */
void fill_zero( uint8_t *buf ) /* ただのポインタと同義 */
「それなら、C99以降で使える static キーワードを使えばどうか」と思う方もいるかもしれません。
void fill_zero( uint8_t buf[static BUF_SIZE] )
これは「この配列には最低 BUF_SIZE 個の要素が存在する」ということをコンパイラに伝えるヒントになります。しかし、これはあくまでコンパイラへの最適化ヒントにとどまり、サイズ情報が型の一部として残るわけではありません。つまり、要素数が異なる配列を渡してもコンパイラはサイズの不一致を検出できません。
[ BUF_SIZE ] の部分は、人間のための"コメント"程度の意味しかなく、コンパイラはサイズ情報を捨ててしまいます。
サイズが消えると何が起こるか
サイズ情報がないということは、要素数が異なる配列を渡しても警告が出ないということです。
#include <stdint.h>
#define BUF_SIZE 5
#define DATA_SIZE 7
void fill_zero( uint8_t buf[ BUF_SIZE ] );
int main( void )
{
uint8_t data[ DATA_SIZE ]; /* 要素数7の配列 */
fill_zero( data ); /* 要素数5を期待する関数に渡しても…警告なし! */
return 0;
}
fill_zero は内部で BUF_SIZE(=5)回ループしますが、渡された配列は DATA_SIZE(=7)個の要素を持っています。今回はたまたま配列の方が大きいので即座にクラッシュはしませんが、逆のケースでは配列の範囲外にアクセスしてしまいます。そして、コンパイラはこのミスを一切指摘してくれません。
配列ポインタ型で型にサイズを持たせる
ここで登場するのが配列ポインタ型 uint8_t (*buf)[ BUF_SIZE ] です。
#include <stdint.h>
#define BUF_SIZE 5
#define DATA_SIZE 7
void fill_zero( uint8_t (*buf)[ BUF_SIZE ] );
int main( void )
{
uint8_t data[ DATA_SIZE ];
fill_zero( &data ); /* ここでコンパイラが警告またはエラーを出す! */
return 0;
}
何が変わったのか
| 項目 | 変更前 uint8_t buf[]
|
変更後 uint8_t (*buf)[]
|
|---|---|---|
| 型の意味 |
uint8_t へのポインタ |
要素数Nの uint8_t 配列へのポインタ |
| サイズ情報 | 消える | 型の一部として残る |
| 呼び出し方 | fill_zero( data ) |
fill_zero( &data ) |
| 要素アクセス | buf[i] |
(*buf)[i] |
uint8_t (*buf)[ BUF_SIZE ] は「要素数 BUF_SIZE の uint8_t 配列を指すポインタ」という型です。これは仮引数でもポインタへの読み替えが起きず、サイズ情報が型の一部として保持されます。
そのため、要素数7の data のアドレス(uint8_t(*)[7] 型)を、要素数5を期待する仮引数(uint8_t(*)[5] 型)に渡すと、型の不一致としてコンパイラが警告またはエラーを出してくれるのです。
関数側の実装
void fill_zero( uint8_t (*buf)[ BUF_SIZE ] )
{
uint8_t i;
for ( i = 0; i < BUF_SIZE; i++ )
{
(*buf)[i] = (uint8_t)0x00U; /* ポインタを一段デリファレンスしてからアクセス */
}
}
(*buf)[i] と書く必要がある点は少し煩雑ですが、この一手間が「コンパイル時に配列サイズの不一致を検出できる」という大きなメリットをもたらします。
まとめ
JavaScriptなら .length、Pythonなら len() で済む配列の長さ管理ですが、C言語では関数に配列を渡した時点でサイズ情報が消えてしまいます。
配列ポインタ型を使えば、型システムの力を借りてサイズの不一致をコンパイル時に検出できます。
「いやいや、読みにくすぎでしょ」 って思いますよね。特にCを書き始めの人が最初に挫折することが多いポインタにがっつり絡んだ話なので、触ったことのない人向けの説明としては不親切すぎますね。
実務で触る予定のない人は「うわぁ面倒な言語やな」って思ってもらえれば幸いです。
C言語にも length プロパティがあれば…