复杂 C 声明并不是一句“从右往左读”就能可靠解决的问题。更稳妥的方法是按标准语法把一条声明拆成声明说明符和声明符,从标识符出发,先处理紧邻的数组或函数后缀,再处理指针层;括号改变组合顺序。最后再把基础类型、限定符和存储类别合回去。
本文的规则以 WG14 的 C23 草案为准,示例使用 C17 模式编译,以便在常见工具链上复现。语法与类型约束属于 C 语言;对象布局、指针表示、调用约定和符号规则属于具体实现与 ABI,二者不能混为一谈。
Table of Contents
一条声明的两个主要部分
以这条声明为例:
static const unsigned long *samples[8];
static const unsigned long是声明说明符序列:static是存储类别说明符,const是类型限定符,unsigned long是类型说明符。samples[8]是声明符。samples[8]先组合,所以samples是含 8 个元素的数组;每个元素前面的表明元素是指针。- 合起来:
samples是“含 8 个指向const unsigned long的指针的数组”。
声明符的形状近似表达式:a[i]、f() 和 *p 的组合方式会反映在声明中。不过这只是阅读辅助,真正的依据仍是声明符语法。
可重复的阅读步骤
- 找到当前声明符中的标识符;若它在括号内,从括号内开始。
- 先读取紧贴标识符或已成组声明符的后缀:
[]表示“……的数组”,()表示“返回……的函数”。 - 再读取这一层左侧的
;紧跟在某个后的const、volatile、restrict或_Atomic限定的是那一层指针。 - 走出括号,重复后缀与指针步骤,直到声明符读完。
- 最后合并声明说明符中的基础类型和限定符。
- 遇到
typedef名时,把它视为一个完整类型名,不要当作文本宏展开。
“先右后左”可以提示后缀优先,但不能替代括号和语法。尤其是函数指针、指向数组的指针和 typedef,机械地左右扫很容易读错。
对照表:括号改变类型
| 声明 | 读法 | 关键组合 |
|---|---|---|
int *items[4] |
items 是含 4 个“指向 int 的指针”的数组 |
[] 先与 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 两层都有限定。
原文的三种声明可以精确读成:
| 声明 | 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;
}
不要用强制类型转换压掉这个诊断。根据接口意图,可以复制成单层 const char * 视图、重新设计参数层次,或让调用者与被调用者使用真正兼容的类型。
typedef 会隐藏声明符形状
typedef 创建的是类型名,不是文本替换宏:
typedef char *char_ptr;
char buffer[8] = {0};
char_ptr const fixed = buffer;
这里 const 限定的是 char_ptr 所代表的完整指针类型,因此 fixed 是 char const,而不是 const char 。同理:
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、对象指针和函数指针的具体大小、对齐和表示;- 结构体布局、填充、字节序或位域布局;
- 平台调用约定、寄存器传参、名称修饰和动态链接规则;
- 任意对象指针与函数指针可以互转;
- 第三方库对所有权、可空性、缓冲区长度或回调生命周期的约定。
FFI、插件边界、驱动接口和网络/磁盘格式必须同时核对目标 ABI 与库文档。不要通过一次本机 sizeof、一次成功链接或一个强制转换推导可移植保证。需要固定布局时,使用目标规范所定义的类型和编译期检查,并在每个受支持目标上验证。
为什么生成工具的结果仍需审查
声明生成器、头文件生成器和编译器 AST 可以帮助确认括号层次,但不能替你决定:
- 选用的是 C17、C23 还是带编译器扩展的方言;
- 宏和条件编译最终暴露了哪一个声明;
typedef背后是否藏着指针、函数或目标相关类型;- 调用约定、可见性、打包属性和 ABI 是否匹配;
const、restrict、数组长度、所有权、生命周期和线程约束是否表达了真实接口;- 生成声明与库二进制、头文件版本和目标三元组是否一致。
把生成结果放入最小头文件和调用点,用目标编译器解析,并让人审查其语义。编译成功证明“这个编译器在这个模式下接受它”,不是 API 正确性或跨 ABI 可移植性的证明。
在一次性目录中复现编译检查
本文三个完整示例和一个预期失败用例使用 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 C 所要求及编译器额外提供的相关诊断,-Werror 把警告变成错误,-fsyntax-only 不生成目标文件。GCC 手册也明确说明:-Wpedantic 不是查出所有非 ISO 构造的完整证明。若项目目标是 C23,应在完整支持目标功能的编译器上另跑 -std=c23,并记录编译器版本与目标平台。
审查清单
- 分开标出声明说明符和声明符。
- 从标识符开始,尊重括号,先读紧邻后缀,再读指针层。
- 对每个
*单独记录其后的限定符。 - 展开概念而非文本展开
typedef,确认别名究竟代表对象、指针、数组还是函数类型。 - 区分数组的指针与指针的数组、返回指针的函数与函数指针。
- 对参数记录数组/函数调整、最小长度契约与仍保留的内层维度。
- 不用强制转换绕过多级指针限定符不兼容。
- 把所有权、生命周期、可空性、缓冲区长度和线程语义写在类型之外的接口契约中。
- 固定编译方言并启用严格诊断;至少用两个目标编译器或目标平台核对公共边界。
- 对 FFI 和二进制接口另外验证 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型的。
