说实话,我第一次在别人代码里见到(struct point){ .x = 10, .y = 20 }这种写法时,第一反应是“这玩意是什么?C语言什么时候能这样写了?”查了标准才发现,这是 C99 引入的复合字面量(compound literal),它解决了一个从 C89 时代就存在的尴尬问题:标量有字面量、字符串有字面量,但数组和结构体不能在表达式里“随手”创建一个临时实例。这篇文章我就把复合字面量这东西从语法、存储期、典型场景到踩坑经验完整拆开讲一遍,适合正在学 C 语言的学生、写嵌入式固件的工程师,以及经常做代码评审的人。
我见过不少项目里,明明一个结构体只在函数调用的瞬间用一次,也要先声明局部变量、逐字段初始化,再传进去。代码能跑,但读起来非常啰嗦。复合字面量是 C99 专门为这种场景补上的语法糖,能把“定义变量”和“传参”压缩成一行。不过它背后涉及自动存储期、左值、常量表达式这些 C 语言核心概念,用不好会留下隐蔽的悬垂指针问题。下面我从“为什么需要它”开始讲。
1. 这个特性解决的是哪个老问题
1.1 表达式和变量之间的“最后一公里”
C 语言从诞生起就有一个不平衡:基本类型有自己的字面量表达方式,1、0.5f、'a'写出来就是一个值,字符串也有"hello"这种字面量。但数组和结构体只能在声明语句里用初始化列表一次性构造,不能在表达式里“匿名”生成一个值。
举一个最普通的例子。你写一个绘制矩形函数:
struct rect { int x, y, w, h; }; void fill_rect(struct rect r);在 C89 时代,想调用fill_rect,你必须先写:
struct rect r = { 10, 10, 100, 50 }; fill_rect(r);两行。看起来还行,但如果你一个函数要接收三个结构体参数呢?或者你只是想在一个循环里用不同的坐标调用同一个绘图函数呢?变量声明会铺满整个函数开头,而且这些变量往往只为一个调用点服务,纯粹是“为传参而存在”。C 标准委员会显然也注意到了这个问题,所以在 C99 中引入了复合字面量,让复杂类型也能像标量一样,在表达式里直接写出一个临时实例:
fill_rect((struct rect){ 10, 10, 100, 50 });这一行等价于上面两行,而且不污染当前作用域的名字空间。
1.2 C99 补齐这一块的现实背景
C99 是一次规模很大的标准修订,引入了//注释、设计初始化器、可变长数组、stdint.h等一大堆东西。复合字面量在其中的定位很容易被忽略,但它其实是和设计初始化器配合得最紧密的一个特性。
当时 C 语言已经在嵌入式、操作系统、图形库这些领域大规模使用,代码风格逐渐从“大量全局变量”转向“用结构体封装配置和状态”。很多 API 开始接受一个config结构体指针,而不是三五个独立参数。这种 API 设计更优雅,但调用方被迫先构造一个具名结构体,然后再调用。复合字面量让调用方可以用“内联对象”的方式直接传参,API 设计者可以继续保留结构体参数带来的扩展性,调用方又不用忍受多余的临时变量。
本质上,它是 C 语言在“表达式”和“聚合类型”之间补上了一块缺失的拼图。
1.3 标准条文里的定义与约束
C11 标准在 6.5.2.5 一节描述了复合字面量,语法非常简单:
( type-name ) { initializer-list }左边括号里是类型名,右边是初始化列表。有几个关键约束值得注意:
- 类型名必须指定一个对象类型,不能是不完整类型。
- 类型名不能是变长数组类型。
- 如果类型名是“未知大小数组”,数组大小由初始化列表的元素个数推导。
- 初始化列表的意义和普通变量初始化完全一致,可以使用位置初始化,也可以使用指定的字段名初始化。
这里的“对象类型”包括结构体、联合体、数组、标量。所以(int){ 42 }也是合法复合字面量,只是没人这么写罢了。真正有实际价值的是结构体和数组场景。
2. 语法拆解与存储期逻辑
2.1 从“类型名加初始化列表”理解基本写法
最简单的用法就是把原本放在变量声明中的初始化列表,前面套上类型名括号。下面三种写法是等价的:
// 传统声明初始化 struct point p = { 3, 4 }; // 复合字面量初始化一个具名变量 struct point p = (struct point){ 3, 4 }; // 直接用复合字面量传参 draw_point((struct point){ 3, 4 });第三种写法里,(struct point){ 3, 4 }本身就是一个匿名的struct point对象。这个对象有真实的存储空间,不是临时寄存器里的值。这是理解它和“类型转换”之间区别的关键:(struct point){ ... }不是把某个值转换成struct point,而是真的在内存里构造了一个struct point对象。
数组也一样:
int *first3 = (int[]){ 1, 2, 3 };(int[]){ 1, 2, 3 }会创建一个包含三个int的匿名数组,first3指向它的首元素。如果指定完整大小,也可以写(int[3]){ 1, 2, 3 }。元素个数和类型名大小不匹配时,多出来的检查规则和普通数组初始化一致。
2.2 块作用域与文件作用域的生命周期差异
这是复合字面量最重要的细节,百分之九十的事故都出在这。
标准规定:如果复合字面量出现在函数体外部,也就是文件作用域,那么它拥有静态存储期,整个程序运行期间都会存在。如果它出现在某个块内部,那么它拥有自动存储期,生命周期会持续到当前块结束。
看这段:
struct point *ptr; void setup(void) { ptr = &(struct point){ 3, 4 }; } // 问题:块结束了,ptr 指向对象已经销毁在setup函数里,(struct point){ 3, 4 }在栈上分配了一个临时对象,地址赋给了全局指针ptr。但这个临时对象的生命周期在setup返回时就结束了。之后任何对ptr的访问都是未定义行为。这种代码编译不报错,运行时也未必立即崩溃,因为栈内存里的旧数据可能还在,但下一次调用其他函数后,这块内存就会被覆盖,于是出现那种“偶发、和调用顺序相关、难复现”的诡异 bug。
反之,下面这种写法是安全的:
struct point *glob = &(struct point){ 3, 4 };因为(struct point){ 3, 4 }出现在文件作用域,对象是静态存储期,地址在程序运行期间始终有效。
2.3 可修改的左值:一个反直觉但重要的性质
在 C 语言里,“字面量”这个词容易让人联想到42这样的常量。但复合字面量不同,它不是右值,而是一个可修改的左值。只要类型名没有const限定,你就可以直接修改它:
(struct point){ 1, 2 }.x = 100;这行代码合法,只是没什么实用价值。真正有用的地方在于,你可以把复合字面量的地址传给一个需要指针参数的函数,这个函数在内部可以修改结构体内容。比如你写了一个向量归一化函数:
void normalize(struct vec *v); normalize(&(struct vec){ 3.0, 4.0 });函数返回后,那个匿名向量也完成了使命。这种“临时构造一个对象,就地修改,用完即弃”的模式,在 C 语言里是很自然的高效写法。
如果你不希望被修改,可以主动加上const:
const struct point *p = &(const struct point){ 3, 4 };这样创建的对象是const struct point类型,任何写入尝试都会在编译期被拒绝。
3. 实际项目里最常用的场景与写法
3.1 给函数传一整个临时结构体
我在嵌入式项目里最常用到的场景,是初始化外设驱动。比如配置一个 UART,传统写法是:
uart_cfg_t cfg; cfg.baudrate = 115200; cfg.data_bits = 8; cfg.parity = UART_PARITY_NONE; cfg.stop_bits = 1; uart_init(&cfg);这个cfg变量只在uart_init这一行代码前有意义,后面的代码根本不会再用到它。用复合字面量,可以压成一行:
uart_init(&(uart_cfg_t){ .baudrate = 115200, .data_bits = 8, .parity = UART_PARITY_NONE, .stop_bits = 1 });这里有两个关键点值得展开:
第一,把临时变量的作用域从“整个函数”缩小到“表达式内部”,函数里就不会堆着一堆一次性变量。代码改动时,不用翻几屏去找某个结构体到底在哪一行被使用。
第二,我建议在实际项目里配合指定初始化器(designated initializer)使用,而不是位置初始化。原因很简单:结构体字段顺序一旦调整,位置初始化代码就可能静默地给错误字段赋值;用.字段名的方式指定,字段顺序就算变了,语义也不会错。上面的uart_cfg_t例子就是典型,用.baudrate = ...比直接写{ 115200, 8, ... }清晰得多。
3.2 与指定初始化器搭配,写可读性更高的配置表
复合字面量还有一个非常实用的场景:在文件作用域构造配置表。比如你要维护一个支持多个设备的 GPIO 配置列表:
static const gpio_cfg_t gpio_configs[] = { (gpio_cfg_t){ .port = GPIOA, .pin = 0, .mode = GPIO_MODE_AF }, (gpio_cfg_t){ .port = GPIOB, .pin = 12, .mode = GPIO_MODE_OUTPUT }, (gpio_cfg_t){ .port = GPIOC, .pin = 3, .mode = GPIO_MODE_INPUT }, };这里的复合字面量出现在文件作用域,存储期是静态的,所以这个数组里的每个元素在程序启动时就完成了初始化,并且整个程序生命周期内都有效。由于数组本身是const,这些配置会进入只读区域,对嵌入式环境来说还能省下宝贵的 RAM。
如果不使用复合字面量,你就得逐个写出三个具名结构体变量,然后在数组里引用它们,代码量多出一倍,而且名字本身没有任何意义,属于纯粹的噪声。复合字面量的匿名性在这里反而成了优点。
3.3 在宏里隐藏类型样板
复合字面量在很多 C 项目中广泛用于宏定义。它可以让你写出一种“看起来像调用函数,实际上是构造临时结构体”的宏。
比如你封装一个设备访问接口:
#define I2C_REG_WRITE(addr, reg, val) \ i2c_write(&(i2c_msg_t){ .addr = (addr), .reg = (reg), .data = (val) })调用时,I2C_REG_WRITE(0x50, 0x1A, 0xFF)会自动创建匿名结构体并传给i2c_write。这种宏用起来非常顺手,因为参数名是显式写出来的,读代码时能看到字段含义,而不是一串位置参数。
还可以和 GNU C 的语句表达式组合,比如实现一个带临时变量的容器操作宏:
#define push_front(list, val) do { \ node_t *n = malloc(sizeof(node_t)); \ *n = (node_t){ .data = (val), .next = *(list) }; \ *(list) = n; \ } while (0)这里(node_t){ .data = ... }创建了一个临时结构体,然后赋值给n指向的内存。注意宏内部的结构体生命周期在do-while块结束时终止,但赋值已经完成,所以没有问题。
4. 这些年我见过的坑:生命周期、常量和宏求值
4.1 函数返回指向复合字面量的指针:经典悬垂
这是我在代码评审中拦住过好几次的坑。有人写了一个工厂函数,想直接返回一个结构体对象的地址:
struct point *make_point(int x, int y) { return &(struct point){ x, y }; }写法很简洁,但完全错了。(struct point){ x, y }是make_point函数块内的自动存储期对象,块结束对象销毁,返回的指针是一个悬垂指针。调用者拿着这个指针去读x和y,行为未定义。
这个 bug 的隐蔽之处在于:在大多数平台上,函数返回后栈内存暂时还没被覆盖,调用者立即用这个指针可能还能读到正确的值。但一旦中间穿插了其他函数调用,栈帧被复用,数据就被破坏。于是程序表现为“这次运行正常,下次运行随机出错”,定位起来非常痛苦。
类似的问题也发生在字符串场景:
const char *get_msg(void) { return (const char[]){ "hello" }; }同样会悬垂。如果需要一个长期有效的返回值,方案是用静态存储期对象,比如:
static const char hello[] = "hello"; const char *get_msg(void) { return hello; }或者干脆返回字符串字面量"hello",因为字符串字面量本身具有静态存储期。但要注意,复合字面量没有这个待遇,所以在函数返回值场景里使用复合字面量时要格外小心。
4.2 “复合字面量不是常量表达式”导致的静态初始化问题
C 语言里,一些初始化的要求非常严格。标准规定:静态存储期对象的初始式必须是常量表达式,结构或数组常量的地址在某些上下文里可以作为静态初始化的一部分,但复合字面量本身并不被视为常量表达式。
我见过有人这样写:
static int *p = &(int){ 42 }; // 在大多数编译器上会报错编译时 GCC 会给出 “initializer element is not constant” 的错误。原因是&(int){ 42 }不能作为常量表达式用于静态存储期对象的初始化。即使在文件作用域,复合字面量对象确实具有静态存储期,它的地址在运行时是固定的,但标准仍然不把这种地址归类为常量表达式。
解决办法很简单:改成具名静态变量。
static const int number = 42; static int *p = (int *)&number;如果你的项目坚持严格 C99,我建议别在这上面纠结,直接避让。
4.3 宏参数被重复求值的隐患
复合字面量本身不会带来额外的求值顺序问题,但如果你把它用在宏里,就需要小心宏参数重复求值的经典陷阱。
看这个宏:
#define MOVE_TO(x, y) \ set_position(&(point_t){ (x), (y) })调用MOVE_TO(read_sensor(), read_sensor())时,两个参数正好对应两个字段,每个参数只用了一次,没问题。但如果你写:
#define SCALE(p, factor) \ (&(point_t){ (p).x * (factor), (p).y * (factor) })调用SCALE(pos, random_scale())时,factor在初始化列表里出现了两次,那么random_scale()会被调用两次,得到两个可能不同的值。这和你写f(a, a)可能调用两次a的求值方式无关,而是宏展开直接复制了参数文本。
我在处理这类宏时,如果参数有副作用,会先通过 GNU C 的语句表达式把参数求值一次并保存到局部变量:
#define SCALE(p, factor) \ ({ double _f = (factor); \ &(point_t){ (p).x * _f, (p).y * _f }; })如果项目不依赖 GNU 扩展,最稳妥的办法是在调用宏之前先把参数存到变量里,或者干脆不要在宏里使用可能带副作用的参数。
4.4 数组复合字面量和变长数组的边界
复合字面量对数组类型有一些特殊规则。
假设你要临时创建一个数组并传给函数,可以写:
process_ints((int[]){ 1, 2, 3, 4 }, 4);如果类型名是(int[]),编译器会根据初始化列表推断数组大小为 4。如果写(int[4])则显式指定长度为 4,这些都合法。
但标准和早期编译器都不允许把变长数组类型直接用于复合字面量,也就是(int[n]){ ... }在可移植的 C 代码里是不合法的。你需要创建 n 个元素的普通数组:
int buf[n]; fill(buf, n);另一个容易搞错的地方是:复合字面量并不能让数组变成可赋值类型。C 语言中数组本身不能直接赋值,arr2 = arr1不合法,arr2 = (int[]){1,2,3}同样不合法。如果你想把一个匿名数组复制到已有数组里,要用memcpy:
int arr[3]; memcpy(arr, (int[]){ 1, 2, 3 }, sizeof(arr));这里(int[]){ 1, 2, 3 }在语句结束后生命周期终止,但memcpy在语句内完成,所以不会悬垂。
5. 用顺手之后的取舍:怎么在代码里平衡风格
5.1 什么时候不要用复合字面量
复合字面量虽好,但并不是所有场景都适用。我总结了几种应该避免的情况,都是实际工作中验证过的。
第一,对象要被长期持有时。比如你要把某个配置指针存入全局链表、队列或驱动上下文里,之后还有异步回调或其他模块会访问它。这时候用复合字面量非常危险,因为你很难保证生命周期覆盖所有使用者。正确做法是定义一个具名静态变量,让所有指针都指向这个变量。
第二,对象的构造有复杂逻辑时。如果初始化列表里有大量函数调用、条件判断,或者需要循环填充,复合字面量的可读性会急剧下降。这时候就应该拆成普通变量,用函数返回值初始化。
第三,性能敏感且对象很大时。把一个大结构体作为复合字面量放在高频调用路径里,每次调用都会在栈上构造并初始化。编译器通常能优化纯初始化操作,但如果初始化表达式里有函数调用,每次调用都逃不掉。测量后确有瓶颈,就应该改为复用同一个具名对象。
第四,C++ 代码里不要使用。复合字面量是 C99 特性,C++ 标准并不支持。GCC 和 Clang 作为扩展允许在 C++ 中使用,但这会让代码失去跨编译器可移植性。C++ 有自己的初始化方式和临时对象生命周期规则,直接用 C++ 的方式就好。
5.2 从可读性角度排一排优先级
我在代码评审时,对“什么时候用复合字面量”有一套自己的判断顺序。
如果结构体只有两三个字段,且调用点只有一处,复合字面量绝对是加分项。比如draw_point((point_t){ x, y }),一行读完,不需要往上找变量定义。如果字段比较多,超过四五个,我就强烈建议使用指定初始化器,比如开头的gpio_cfg_t例子。指定初始化器不仅能自描述每个字段的含义,还能避免字段顺序变化时的隐性错误。
如果同一个配置对象要被两个函数使用,或者会在后续逻辑里被再次读取,那就不要再写第二份复合字面量了,具名变量更合适。匿名对象的优势在于“用完即焚”,一旦需要重复使用,匿名就反而成了阻碍。我常跟同事说:给对象取名字,本质上是承诺它的生命周期是有意为之的;复合字面量适合那些不需要承诺的场景。
5.3 我维护代码时的一个小检查习惯
最后分享一个小习惯,也是我很久以前踩了悬垂指针的坑以后养成的:每次在代码里看到&(开头的复合字面量,我都会先问一个问题——它的地址会不会逃逸出当前完整表达式?
如果只是作为函数参数传进去,函数内部用完就返回,安全。如果存进全局指针、塞进队列、加入链表、赋给调用者返回字符串,那就要停下来仔细检查生命周期。我的默认态度是:复合字面量的匿名地址一旦需要“给外边用”,这个代码就应该重写成具名对象。这条规则不复杂,但挡住了很多隐蔽故障。
我自己在实际项目里,现在写一次性传参时几乎都会用复合字面量加指定初始化器,尤其在驱动配置、图形坐标、数据包构造这些场景里,代码会明显干净很多。但它毕竟只是一个工具,不是银弹。理解了它的存储期和左值性质,你就能在合适的位置用它、在不合适的位置果断避开它,这一点比记住语法本身更重要。