C 语言中检测表达式是否为常量表达式
C 语言中检测表达式是否为常量表达式
2025 年 4 月 21 日
这是一个有趣的小语言难题:实现一个宏,它接受一个表达式作为参数,并且:
- 验证表达式是否为常量表达式(即,在编译时已知),否则中止编译。
- “返回”相同的值。
- (可选)返回的值具有与原始表达式相同的类型。
有很多方法可以解决这个问题,具体取决于你使用的 C 标准以及是否允许编译器扩展。以下是我遇到的一些方法,以及它们的优缺点。
static 复合字面量
如果你使用的是 C23 或更高版本,那么你可以为复合字面量指定存储持续时间,并结合 typeof
(也在 C23 中标准化)。
#define C(x) ( (static typeof(x)){x} )
这保持了相同的类型,并且由于 static
存储持续时间的初始化器需要是常量表达式,因此编译器将确保 x
是一个常量表达式。
缺点:
- 需要 C23,截至撰写本文时,C23 尚未得到广泛支持。
- Clang (v20) 似乎还不支持复合字面量中的
static
(GCC v14.2 似乎可以工作)。
__builtin_constant_p
如果使用 GNU 扩展没有问题,那么你可以使用 __builtin_constant_p
,当表达式为常量时,它返回 true。
__attribute((error("not constant"))) int notconst(void);
#define C(x) ((__typeof__(x)) \
((x) + __builtin_constant_p(x) ? 0 : notconst()) \
)
当 __builtin_constant_p
返回 true 时,它将 0 添加到该值,使其保持不变。否则,它会调用一个使用 error
属性声明的虚拟不存在的函数,从而导致编译错误。
但是,由于加法最终会执行通常的整数提升,因此结果的类型可能会有所不同。这就是为什么有一个 __typeof__(x)
强制转换,以保持相同的类型。
缺点:
- 需要 GNU 扩展。
static_assert
这个技巧是由 constxd 在一个 IRC 频道中向我展示的,它使用 C11+ static_assert
来确保表达式是静态的。但是我们如何“返回”表达式呢?好吧……借助一些 sizeof
+ 匿名 struct
魔法:
#define C(x) ((x) + 0*sizeof( \
struct { _Static_assert((int)(x) || 1, ""); char tmp; } \
))
这看起来非常奇怪。为什么 static_assert
允许在结构声明中?这是因为标准将 static_assert
分类为不声明任何内容的声明(别问我为什么)。所以从语法上讲,你可以把它放在一个结构体里面。
缺点:
- 由于加法运算,可能会更改表达式的类型,这会受到典型的整数提升规则的影响。(但请参阅逗号运算符部分以获取解决方案)。
- 标准规定
static_assert
的表达式需要是整数常量表达式,尽管 gcc 和 clang 似乎也接受浮点表达式,但会发出警告。(注意:立即int
强制转换使 浮点常量(例如1.1
)可以工作,但不适用于1.1 + 2.2
之类的东西,而不会发出警告)。
sizeof + 具有数组类型的复合字面量
这使用与上述类似的技巧,但不是使用 static_assert
,而是使用复合字面量数组类型来确保表达式是常量:
#define C(x) ( (x) + 0*sizeof( (char [(int)(x) || 1]){0} ) )
需要注意的几点:
- 自 C99 以来,数组(和数组类型)可以是可变长度的。但是复合字面量不接受可变长度类型,这为我们进行了验证。
- 标准 C(不幸的是)禁止零长度数组,因此是
|| 1
。 - 超过一定限制的数组大小无法声明(即使没有分配存储)。在我的 64 位 PC 上,gcc 拒绝大于
PTRDIFF_MAX
(263-1) 字节的大小,而 clang 甚至更保守,拒绝大于 61 位的尺寸。除了支持0
之外,|| 1
还会将数组大小限制为 1。
与 static_assert
相比,这种方法的唯一优点是它不需要 C11,可以在 C99 中使用。除此之外,它继承了 static_assert
带来的所有问题:
- 类型可能会更改。
- 不支持浮点表达式(并且与
static_assert
不同,gcc 完全拒绝编译浮点表达式)。
sizeof + 枚举常量
因为枚举常量必须是整数常量表达式,所以我们可以使用它们来代替复合字面量:
#define C(x) ( (x) + 0*sizeof( enum { tmp = (int)(x) } ) )
但是,这存在一个明显的问题:与复合字面量不同,枚举常量会“泄露”。这意味着你不能多次使用这个宏。你可以尝试使用预处理器连接来附加行号(__LINE__
),但是你不能在同一行上多次使用此宏。
这是我想出的一个巧妙(或诅咒的)小解决方案:在函数参数中声明枚举,使其具有“作用域”:
#define C(x) ( (x) + 0*sizeof(void (*)(enum { tmp = (int)(x) })) )
这行得通。但是 gcc 和 clang 都会警告枚举是匿名的……即使这 正是 我想要做的。而且这不能用 #pragma
静默,因为它是一个宏,所以警告发生在调用宏的位置。
实际上没有太多理由使用它,但它是 C89 兼容的,如果你关心的话。
缺点:
- 类型可能会更改。
- 不支持浮点表达式。
- 对于每次宏调用,gcc 和 clang 都会警告枚举是匿名的。
逗号运算符
所有使用 (x) + 0*sizeof(...)
技巧的宏都存在类型可能更改的问题。对此有一个简单而优雅的解决方案,将 sizeof
放在一个单独的表达式中,并用逗号运算符 忽略 它:
#define C(x) (sizeof(...), (x))
但问题是,你会收到一堆关于“逗号表达式的左侧操作数不起作用”的警告,即使这 正是 想要的效果。
GCC 的怪异之处
最初,我没有为我的 __builtin_constant_p
解决方案使用 error
属性,而是使用负大小的数组(包装在禁止 VLA 的结构中)来触发编译错误:
#define C(x) ((__typeof__(x)) ((x) + 0*sizeof( \
struct { char tmp[__builtin_constant_p(x) ? 1 : -1]; } \
)))
Clang 在数组大小为 -1
时如预期般报错。 然而,GCC 将其吞噬,并仅用警告让你脱身(双重叹息)。 因此,Arsen 建议使用 error 属性代替,它应该可以防止这种类型的怪异现象。
结论
事实证明,这是一个比我最初预期的更大的兔子洞。恼人的警告也很烦人,但因为我想在库中使用这个宏,所以简单地关闭警告对我来说不是一个选择。 我宁愿保持库没有警告,也不愿告诉用户关闭警告。
如果有我遗漏的任何其他(希望更好的)解决方案,我将很感兴趣。
更新:u/P-p-H-d 指出了此解决方案,该解决方案使用 _Generic
、三元运算符和空指针常量规则来确定整数常量表达式。 需要 C11,并且仅适用于整数,不幸的是不适用于浮点表达式。
标签:[c]