やり直しC言語:複雑な宣言の読み方

Posted by Hiraku on 2015-09-22

C言語は宣言文が非常に読みにくいことで有名で、後発のGo言語はこれを批判して宣言の構文を変えています。私もずっと読むのが苦手だったのですが、私の頭が悪いのではなく、C言語の仕様がヘン、ということらしい。

今まで飽きるほどこの手の解説は書かれてきてるわけですが、自分なりにまとめないと覚えた気がしないので、あえてまとめておきます。ここに書いてある内容は、「C言語ポインタ完全制覇」に詳しく書いてあります。

型の派生

C言語では、int, char, floatなどの基本型から、配列やポインタを派生していくことができます。対象を並べたものが配列で、対象を指し示すのがポインタです。

配列やポインタからも配列やポインタを派生できるので、派生パターンは無限に存在します。

  • int
  • int の配列
  • int の配列 の配列 ...
  • int へのポインタ
  • int へのポインタ へのポインタ ...
  • int へのポインタ の配列
  • int の配列 へのポインタ

単体配列とか単体ポインタとかいうものはなくて、必ず派生元の型とセットで存在します。void*は単体のポインタみたいな感じもありますが、まあ特殊パターンですよね。

ちなみに、C言語で関数は第一級オブジェクトではありませんが、関数も似たような形式を取ります。対象を返す関数、という形で書きます。

  • int
  • int を返す関数(引数はvoid)
  • int を返す関数(引数はvoid) へのポインタ
  • int を返す関数(引数はvoid) へのポインタ を返す関数(引数はvoid)
  • int の配列 へのポインタ を返す関数(引数はvoid)
  • int を返す関数(引数はvoid) へのポインタ の配列 へのポインタ を返す関数(引数はvoid)

C言語の関数では関数や配列そのものを返すことが不可能ですが、それぞれのポインタなら返すことができます。(関数を返す関数、配列を返す関数、は作成できないから、そういった宣言は考慮しなくてよい)

ここまで、日本語で書いていたら別に変な仕様ではないと思うんですが、C言語で書くとすごく読みづらくなります。

ポインタを表す*は識別子の左から修飾し、関数を表す()や配列を表す[]は識別子の右から修飾する、というヘンテコな規則のせいです。なんでも、宣言と実際の使用時の構文を似せたかったらしいですが…

/* int */
int a;
/* int の配列 */
int a[10];
/* int の配列 の配列 */
int a[10][20];
/* int へのポインタ */
int *a;
/* int へのポインタ へのポインタ */
int **a;
/* int へのポインタ の配列 */
int *a[10];
/* int の配列 へのポインタ */
int (*a)[10];

配列へのポインタ、でカッコがでてきて怪しさが出てきました。宣言文では、関数のカッコの他に、演算子(?)の結合順を変更するためにもカッコを使います。

なぜ同じ記号を使ったし! おかげで関数宣言でポインタや配列が乱舞する場合は、超絶読みにくくなります。

/* int */
int a;
/* int を返す関数(引数はvoid) */
int a(void);
/* int を返す関数(引数はvoid) へのポインタ */
int (*a)(void);
/* int を返す関数(引数はvoid) へのポインタ を返す関数(引数はvoid) */
int (*a(void))(void);
/* int の配列 へのポインタ を返す関数(引数はvoid) */
int (*a(void))[10];
/* int を返す関数(引数はvoid) へのポインタ の配列 へのポインタ を返す関数(引数はvoid) */
int (*(*a(void))[10])(void);

実際はここまでの派生型を扱うことは滅多にないと思うんですが、…目眩がしてきます。

配列はそもそもポインタ返ししないのが普通なのでたぶん問題になりませんが、関数ポインタを扱う関数はそれなりに登場すると思います。

宣言の読み方

読みづらくても、規則があるからこそコンパイルできるわけで、規則通りにゆっくり辿れば必ず読めます。

外側から読む人もいるそうですが、言語公式には内側から渦を巻くように読むのが正解っぽい。Spiral Ruleと言うそうな。

参考: Clockwise/Spiral Rule

日本語らしくしたい場合は、最後に順番をひっくり返します。

(1) 識別子を探す。

(2) 識別子の両隣から読み始めるが、次の順番で結合される。

  1. 優先度を表す()
  2. 関数を表す()、配列を表す[]
  3. ポインタを表す*

(3) 配列はarray(要素数) of ...
関数はfunction(引数) returning ...
ポインタはpointer to ...
という風に英語で置き換えてゆく。

(4) 最後に派生元の型(intとかfloatとか)を付けて終わり。

結局何なの?は識別子の両隣からわかる

この規則から、宣言が何を表しているのかは誰でもすぐ解読できます。識別子の両隣を見ればいい。

  • 右隣が[ → 配列
  • 右隣が( → 関数
  • 左隣が* → ポインタ
  • *a[10]*a(void)みたいに両側に何か書いてある時は、配列や関数の方が勝つ。

正確に言えば*との間にconstが挟まることもあり得るけれど、大抵は書かないし大丈夫でしょう。

さっきの面倒くさそうなやつを持ってくると、

int (*(*a(void))[10])(void);

識別子aの両隣になにか書いてあるけれど、関数のほうが優先されるので、 これは関数だ! という感じです。

宣言 結局何なの?
int *a[10]; 配列
int (*a)[10]; ポインタ
int (*a)(void); ポインタ
int (*a(void))(void); 関数

読み解く

あとは訓練訓練。

int (*(*a(void))[10])(void);
  1. 識別子を探して、aを見つける。
  2. 関数のカッコの方が優先順位が高いのでまずそっちを読む。引数定義の終わりまで読んでしまおう
  3. )にぶつかるので、巻き戻ってポインタの*を読む。
  4. (にぶつかるので、さっきの)の先に進んで、配列の[10]を読む。
  5. )にぶつかるので、巻き戻ってポインタの*を読む。
  6. (にぶつかるので、さっきの)の先に進んで、関数の(void)を読む。
  7. 終端に到達したので、最後にintを読む。
  8. 和訳したかったら順番をひっくり返そう。
a is function(void) returning pointer to array(10) of pointer to function(void) returning int.

aは、int を返す関数(void) へのポインタ の配列(要素数10) へのポインタ を返す関数(void)

標準ライブラリにあるsignal関数も読みにくさでは有名ですよね。

void (*signal(int, void (*)(int)))(int);
signal is function(int, pointer to function(int) returning void) returning pointer to function(int) returning void.

signalは、void を返す関数(int) へのポインタ を返す関数(int, voidを返す関数(int) へのポインタ)

どうやら、関数ポインタを返却したい場合は特に読みにくくなるようです。

typedefはマクロじゃないよ

typedefを解説する時に、typedef 元の型 新しい型名; みたいに説明しているのをたまに見かけるのですが、正確な解説とはいえません。

typedef 宣言文と同じ構文(ただし識別子の部分が新たな型名の意味になる);

こんな風に書いたほうがもう少し正確だと思います。

signal関数の宣言は大抵の場合、先にtypedefしてから書かれています。

typedef void callback_t(int);
callback_t* signal(int signum, callback_t* sighandler);   

要するに、引数に関数ポインタを取って、戻り値としても同じシグネチャの関数ポインタを返す、というインターフェースだったのです。typedefしないとわからんわ…

typedefはこういう、識別子の左右両側に書かれた複雑な派生型を、一つの識別子にまとめ直すという能力があります。#define HOGE intのようなマクロとは違って、もっと複雑なことをやってくれるわけです。

だから、複雑な宣言が登場する場合は、こまめにtypedefして読みやすくしていきましょう、という古き良き伝統を書いてこの手の解説は終わります。

…でも、後発の言語を見ても分かる通り、ユーザーが(本来必要でない分の)typedefをしないとまともに読めないなんて、C言語の構文はどうかしてると思うのですよ。


C言語ポインタ完全制覇 (標準プログラマーズライブラリ)

codingの最新記事