C 复杂声明怎么读:指针、数组、函数、typedef 与 const

复杂 C 声明并不是一句“从右往左读”就能可靠解决的问题。更稳妥的方法是按标准语法把一条声明拆成声明说明符声明符,从标识符出发,先处理紧邻的数组或函数后缀,再处理指针层;括号改变组合顺序。最后再把基础类型、限定符和存储类别合回去。

本文的规则以 WG14 的 C23 草案为准,示例使用 C17 模式编译,以便在常见工具链上复现。语法与类型约束属于 C 语言;对象布局、指针表示、调用约定和符号规则属于具体实现与 ABI,二者不能混为一谈。

一条声明的两个主要部分

以这条声明为例:

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 的组合方式会反映在声明中。不过这只是阅读辅助,真正的依据仍是声明符语法。

可重复的阅读步骤

  1. 找到当前声明符中的标识符;若它在括号内,从括号内开始。
  2. 先读取紧贴标识符或已成组声明符的后缀:[] 表示“……的数组”,() 表示“返回……的函数”。
  3. 再读取这一层左侧的 ;紧跟在某个 后的 constvolatilerestrict_Atomic 限定的是那一层指针。
  4. 走出括号,重复后缀与指针步骤,直到声明符读完。
  5. 最后合并声明说明符中的基础类型和限定符。
  6. 遇到 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、指针 typedefconst 的位置:

#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 pchar 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 所代表的完整指针类型,因此 fixedchar 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 放进声明不能完成这些设计。

restrictvolatile 也有各自的语义约束:restrict 是基于访问关联的优化契约,volatile 也不是线程同步原语。不要把它们当作 const 的变体。

语言约束不等于 ABI 保证

标准语法能够告诉你 int (*callback)(void) 是函数指针,却不会保证以下实现细节:

  • int、对象指针和函数指针的具体大小、对齐和表示;
  • 结构体布局、填充、字节序或位域布局;
  • 平台调用约定、寄存器传参、名称修饰和动态链接规则;
  • 任意对象指针与函数指针可以互转;
  • 第三方库对所有权、可空性、缓冲区长度或回调生命周期的约定。

FFI、插件边界、驱动接口和网络/磁盘格式必须同时核对目标 ABI 与库文档。不要通过一次本机 sizeof、一次成功链接或一个强制转换推导可移植保证。需要固定布局时,使用目标规范所定义的类型和编译期检查,并在每个受支持目标上验证。

为什么生成工具的结果仍需审查

声明生成器、头文件生成器和编译器 AST 可以帮助确认括号层次,但不能替你决定:

  • 选用的是 C17、C23 还是带编译器扩展的方言;
  • 宏和条件编译最终暴露了哪一个声明;
  • typedef 背后是否藏着指针、函数或目标相关类型;
  • 调用约定、可见性、打包属性和 ABI 是否匹配;
  • constrestrict、数组长度、所有权、生命周期和线程约束是否表达了真实接口;
  • 生成声明与库二进制、头文件版本和目标三元组是否一致。

把生成结果放入最小头文件和调用点,用目标编译器解析,并让人审查其语义。编译成功证明“这个编译器在这个模式下接受它”,不是 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 语法。

标准与编译器资料

历史原文存档(仅供溯源)

以下内容逐字保留自 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型的。

Leave a Reply