複雑な C 宣言は、「右から左へ読む」という一言だけでは確実に読めません。より堅実なのは、標準の文法に沿って宣言を宣言指定子と宣言子に分け、識別子から始めて隣接する配列・関数の接尾部を先に、次にポインタ層を処理する方法です。括弧は結合順序を変えます。最後に基本型、修飾子、記憶域クラスを組み合わせます。
ここでの規則は WG14 の C23 草案に基づきます。例は広く普及したツールチェーンで再現できるよう C17 モードでコンパイルします。構文と型制約は C 言語の規則ですが、オブジェクト配置、ポインタ表現、呼出規約、シンボル規則は個別の実装と ABI の規則です。両者を混同してはいけません。
Table of Contents
1つの宣言を構成する2つの部分
次の宣言を考えます。
static const unsigned long *samples[8];
static const unsigned longは宣言指定子列です。staticは記憶域クラス指定子、constは型修飾子、unsigned longは型指定子です。samples[8]は宣言子です。samples[8]が先に結合するため、samplesは8要素の配列であり、は各要素がポインタであることを示します。- 合わせると、
samplesは「const unsigned longへのポインタを8個持つ配列」です。
宣言子の形は式に似ており、a[i]、f()、*p の組合せ方が宣言にも反映されます。ただし、これは読み方の補助であり、最終的な根拠は宣言子の文法です。
再現可能な読み取り手順
- 現在の宣言子にある識別子を探します。括弧で囲まれていれば、その内側から始めます。
- 識別子またはグループ化済み宣言子に直接付く接尾部を先に読みます。
[]は「……の配列」、()は「……を返す関数」です。 - 次に、その層の左にある
を読みます。個々のの直後にあるconst、volatile、restrict、_Atomicは、そのポインタ層を修飾します。 - 括弧の外へ出て、宣言子を読み終えるまで接尾部とポインタの手順を繰り返します。
- 最後に、宣言指定子にある基本型と修飾子を組み合わせます。
typedef名はテキストマクロとして展開せず、1つの完全な型名として扱います。
「右を先に、左を後に」は接尾部が強く結合することを思い出す助けにはなりますが、括弧と文法の代わりにはなりません。特に関数ポインタ、配列へのポインタ、typedef 名では機械的な左右走査が失敗しやすくなります。
対照表:括弧が型を変える
| 宣言 | 読み方 | 決め手となる結合 |
|---|---|---|
int *items[4] | items は int へのポインタを4個持つ配列 | [] が先に items と結合 |
int (*row)[4] | row は4個の int を持つ配列へのポインタ | 括弧が *row を先にグループ化 |
int *make(void) | make は引数を取らず int * を返す関数 | make(void) が先に結合 |
int (*callback)(void) | callback は引数を取らず int を返す関数へのポインタ | 括弧が *callback を先にグループ化 |
int (*handlers[3])(double) | handlers は3個の関数ポインタを持つ配列。各関数は double を受け取り int を返す | 配列、ポインタ、関数の順 |
int (producer)(void) | producer は関数ポインタ。その関数は引数を取らず int * を返す | ポインタ、関数、戻り値のポインタの順 |
C では関数が配列型または関数型を返すことはできず、配列要素を関数型にすることもできません。それらの型へのポインタが合法な形です。
警告なしでコンパイルできる総合例
次のファイルは、配列とポインタ、関数ポインタ、関数型の typedef、ポインタの typedef、const の位置をまとめて確認します。
#include <stddef.h>
typedef char *char_ptr;
typedef int comparator(const void *, const void *);
static int compare_ints(const void *left, const void *right)
{
const int a = *(const int *)left;
const int b = *(const int *)right;
return (a > b) - (a < b);
}
static int *make_value(void)
{
static int value = 7;
return &value;
}
static void declarations(void)
{
char buffer[8] = {0};
const char *read_only = buffer;
char *const fixed = buffer;
const char *const both = buffer;
char_ptr const alias_fixed = buffer;
int *items[4] = {0};
int matrix[3][4] = {{0}};
int (*row)[4] = &matrix[0];
comparator *cmp = compare_ints;
int *(*producer)(void) = make_value;
(void)read_only;
fixed[0] = 'A';
(void)both;
alias_fixed[1] = 'B';
(void)items;
(*row)[0] = cmp(&matrix[0][0], &matrix[0][1]);
(void)producer;
}
int main(void)
{
declarations();
return 0;
}
comparator は関数型であり、関数ポインタ型ではありません。comparator cmp で初めてその関数型へのポインタになります。producer は、int を返す関数へのポインタとして直接宣言されています。
const はどの層を修飾するか
const char p と char const p は同じ意味です。p は通常のポインタで、指し先の char はこの式を通じて変更できません。char const p では const が の直後にあるため、p 自体を再代入できませんが、指し先の char は変更できます。const char *const p は両方の層を修飾します。
歴史的原文の3つの宣言は、正確には次のように読めます。
| 宣言 | p 自体 | p が指すオブジェクト | 最内層の文字 |
|---|---|---|---|
const char **p | 再代入できるポインタ | 再代入できる const char * ポインタ | この型を通じて読み取り専用 |
char const p | 再代入できるポインタ | 再代入できない char * ポインタ | 書込み可能 |
char **const p | 再代入できないポインタ | 再代入できる char * ポインタ | 書込み可能 |
「const 修飾された左辺値を通じて変更できない」ことは、基底オブジェクトがどこからも変更されないという意味ではありません。オブジェクト自体が最初から const 修飾型で定義されていなければ、別の非修飾別名から合法的に変更される場合があります。const は所有権、寿命、スレッド安全性も表しません。
多段ポインタでは const が自動的に伝播しない
char を暗黙に const char として扱うのは制約違反です。これが許されると、呼出先が後者を通じて本物の const char へのポインタを格納し、元の char * からその定数オブジェクトを変更しようとできてしまいます。
次は意図的に不正な診断用コードです。
static void rejected_conversion(void)
{
char *mutable = 0;
const char **slot = &mutable;
(void)slot;
}
キャストで診断を消してはいけません。インターフェースの目的に応じて、1段の const char * ビューへコピーする、引数の多段構造を設計し直す、または呼出側と呼出先で本当に互換性のある型を使います。
typedef は宣言子の形を隠す
typedef は型名を作るもので、テキスト置換マクロではありません。
typedef char *char_ptr;
char buffer[8] = {0};
char_ptr const fixed = buffer;
ここで const は char_ptr が表す完全なポインタ型を修飾します。そのため fixed は const char ではなく char const です。同様に次を考えます。
typedef int operation(double);
operation *handler;
operation は関数型で、handler は関数ポインタです。レビューでは、まず typedef の定義を探します。ポインタ別名を明確に命名すれば誤読を減らせますが、言語規則は変わりません。
配列と関数の仮引数は調整される
関数の仮引数宣言に限り、配列型で書かれた仮引数はポインタへ、関数型で書かれた仮引数は関数ポインタへ調整されます。この規則によって通常の配列オブジェクトがポインタになるわけではありません。
#include <stddef.h>
static int double_value(int value)
{
return value * 2;
}
static int apply(int operation(int), int value)
{
return operation(value);
}
static int sum4(const int values[static 4])
{
return values[0] + values[1] + values[2] + values[3];
}
static void clear_first(size_t rows, int matrix[static rows][4])
{
matrix[0][0] = 0;
}
int main(void)
{
int matrix[2][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8}
};
const int total = sum4(matrix[0]);
const int doubled = apply(double_value, 3);
clear_first(2, matrix);
return total == 10 && doubled == 6 && matrix[0][0] == 0 ? 0 : 1;
}
- 仮引数の
int operation(int)はint (*operation)(int)へ調整されます。公開 API ではポインタ形式を明記する方が通常は明確です。 const int values[static 4]はconst intへのポインタへ調整されます。static 4はさらに、呼出しごとに少なくとも4要素へアクセスできることを要求します。int matrix[static rows][4]の最外層配列は、4個のintを持つ配列へのポインタへ調整されます。内側の長さ4は指し先の型に残ります。- 仮引数の角括弧内にある修飾子は調整後のポインタを修飾し、要素の修飾子は基本型側に書きます。
- 仮引数は調整済みなので、関数内でそれに
sizeofを使うと呼出側配列の大きさではなくポインタの大きさになります。長さは明示的に渡します。
配列式も多くの式コンテキストで先頭要素へのポインタへ変換されますが、これは仮引数型の調整とは関連しつつ別の規則です。
_Atomic:並行処理の意味が必要なときだけ使う
_Atomic は型修飾体系に属しますが、宣言の練習用装飾ではありません。この例では層を示す目的だけに使います。
#include <stdatomic.h>
static _Atomic(int) counter;
static int * _Atomic head;
static _Atomic(int) *pointer_to_atomic;
int main(void)
{
int value = 0;
atomic_store(&counter, 1);
atomic_store(&head, &value);
pointer_to_atomic = &counter;
return atomic_load(pointer_to_atomic) == 1
&& *atomic_load(&head) == 0
? 0
: 1;
}
counter は原子 int オブジェクト、head は通常の int を指す原子ポインタ、pointer_to_atomic は原子 int を指す通常のポインタです。実際の並行コードでは同期関係、メモリ順序、オブジェクト寿命も定義し、対象実装でロックフリーかを確認する必要があります。宣言に _Atomic を置くだけでは設計は完了しません。
restrict と volatile にも固有の意味制約があります。restrict はアクセス関連に基づく最適化契約であり、volatile はスレッド同期プリミティブではありません。これらを const の変種として扱わないでください。
言語制約は ABI 保証ではない
標準文法から int (*callback)(void) が関数ポインタだと分かります。しかし、次の実装詳細は保証されません。
int、オブジェクトポインタ、関数ポインタの具体的な大きさ、配置、表現。- 構造体配置、パディング、バイト順、ビットフィールド配置。
- プラットフォームの呼出規約、レジスタ渡し、名前修飾、動的リンク規則。
- 任意のオブジェクトポインタと関数ポインタが相互変換できること。
- 外部ライブラリの所有権、null 許容性、バッファ長、コールバック寿命の契約。
FFI、プラグイン境界、ドライバ API、ネットワーク形式、ディスク形式では、対象 ABI とライブラリ文書も確認します。ローカルで一度得た sizeof、一度成功したリンク、キャストから移植可能な保証を推論してはいけません。配置を固定する場合は対象仕様が定める型とコンパイル時検査を使い、対応する全ターゲットで検証します。
生成ツールの結果にもレビューが必要な理由
宣言生成器、ヘッダー生成器、コンパイラ AST は括弧構造の確認に役立ちますが、次の判断は代行できません。
- 選ばれた方言が C17、C23、コンパイラ拡張モードのどれか。
- マクロと条件コンパイルの後にどの宣言が残るか。
typedefがポインタ、関数、ターゲット依存型のどれを隠しているか。- 呼出規約、可視性、パッキング属性、ABI が一致するか。
const、restrict、配列境界、所有権、寿命、スレッド契約が実際の API を表しているか。- 生成宣言がライブラリバイナリ、ヘッダーバージョン、ターゲットトリプルと一致するか。
生成結果を最小のヘッダーと呼出箇所に置き、対象コンパイラで解析して、人が意味をレビューします。コンパイル成功が示すのは、1つのコンパイラが1つのモードで受理したことだけで、API の正しさや ABI をまたぐ移植性ではありません。
一時ディレクトリでコンパイル検査を再現する
3つの完全な例と1つの失敗期待例を GCC 13.3.0 で検証しました。有効なファイルには次を使いました。
cc --version
cc -std=c17 -Wall -Wextra -Wpedantic -Werror -fsyntax-only declarators.c
cc -std=c17 -Wall -Wextra -Wpedantic -Werror -fsyntax-only parameter-adjustment.c
cc -std=c17 -Wall -Wextra -Wpedantic -Werror -fsyntax-only atomic-declaration.c
失敗を期待するファイルには次を使いました。
if cc -std=c17 -Wall -Wextra -Wpedantic -Werror -fsyntax-only bad-const.c; then
echo "unexpected success"
exit 1
fi
echo "expected constraint diagnostic observed"
-std=c17 は言語モードを固定し、-Wpedantic は ISO が要求するものとコンパイラが追加する関連診断を要求し、-Werror は警告をエラーにし、-fsyntax-only はオブジェクトファイルを生成しません。GCC マニュアルも、-Wpedantic がすべての非 ISO 構文を発見した証明ではないと明記しています。C23 を対象にするプロジェクトでは、使用機能を完全にサポートするコンパイラで -std=c23 も別に実行し、コンパイラ版とターゲットを記録してください。
レビューチェックリスト
- 宣言指定子と宣言子を別々に印付けする。
- 識別子から始め、括弧を尊重し、隣接する接尾部をポインタ層より先に読む。
- 個々の
*の直後にある修飾子を記録する。 typedefをテキストではなく概念として展開し、別名がオブジェクト、ポインタ、配列、関数のどの型を表すか確認する。- 配列へのポインタとポインタの配列、ポインタを返す関数と関数ポインタを区別する。
- 仮引数では配列・関数の調整、最小長契約、残る内側次元を記録する。
- 互換性のない多段ポインタ修飾をキャストで回避しない。
- 所有権、寿命、null 許容性、バッファ長、スレッド意味論を型の外側のインターフェース契約へ記す。
- コンパイラ方言を固定して厳格な診断を有効にし、公開境界は少なくとも2つの対象コンパイラまたはプラットフォームで確認する。
- FFI とバイナリ API では ABI を別途検証する。C 構文検証だけでは不十分。
標準とコンパイラ資料
- WG14 N3096:ISO/IEC 9899:2023 作業草案。特に宣言、型修飾子、宣言子、配列宣言子、関数宣言子、型定義の各条項。
- WG14 N1570:C11 委員会草案。
_Atomicと広く導入済みの C11/C17 規則の照合に利用できます。 - GCC が対応する言語標準
- GCC の警告オプション
- Clang ユーザーマニュアル:言語モードと診断
歴史的原文アーカイブ(来歴確認専用)
source_exportの可視本文を完全かつ逐語的に収録しています。削除、改稿、空白正規化、プライバシーまたは安全上の墨消しはありません。原文の「右から左へ読む」は限定的な記憶法にすぎず、標準の宣言子文法の代わりにはなりません。区画全体は不活性なプレーンテキストです。説明を現在の完全な規則として扱わないでください。
例如:
const char **p;
char const p;
char **const p;
阅读变量声明,要从右往左阅读。例如其中的
char const p
char *const *p前面有*表示p是一个指针,char *const *p表示指针p指向的内容为const型的。char *const *p表示const *p所指向的内容为指针,char *const *p 表示‘指针p’指向的’const型指针’所指向的内容为char型的。
