C 语言中检测表达式是否为常量

2025年4月21日

这里有一个有趣的语言难题:实现一个宏,它接受一个(整数或浮点)表达式作为参数,并且:

有很多方法可以解决这个问题,具体取决于你使用的 C 标准以及是否允许编译器扩展。以下是我遇到的一些方法以及一些优点和缺点。

constexpr 复合字面量

如果你使用的是 C23 或更高版本,则可以为复合字面量指定存储持续时间。结合 typeofconstexpr,两者也在 C23 中标准化,你可以使用以下代码:

#define C(x) ( (constexpr typeof(x)){x} )

这保持了类型不变,并且由于 constexpr 存储持续时间的初始化器需要是常量表达式,因此编译器将确保 x 是常量表达式。

缺点:

本文的早期版本使用 static 代替 constexpr。由于 static 存储持续时间要求初始化器为常量表达式,因此编译器会确保它。

但是,constexpr 在这里具有稍微更好的语义,因为与 static 不同,constexpr 强制要求_结果_表达式(不仅仅是初始化器)也将是一个常量表达式。

__builtin_constant_p

如果使用 GNU 扩展没有问题,那么你可以使用 __builtin_constant_p,当表达式为常量时,它返回 true,并结合 __builtin_choose_expr 进行选择。

__attribute((error("not constant"))) int notconst(void);
#define C(x) __builtin_choose_expr(__builtin_constant_p(x), (x), notconst())

__builtin_constant_p 返回 true 时,__builtin_choose_expr 选择第一个表达式。否则,它会调用一个使用 error attribute声明的虚拟不存在的函数,从而导致编译错误。

与三元运算符不同,__builtin_choose_expr 不受类型提升规则的约束,因此保持表达式的类型不变。

缺点:

static_assert

这个技巧是由 constxdIRC 频道中向我展示的,它使用 C11+ static_assert 来确保表达式是静态的。但是我们如何“返回”表达式呢?好吧...使用一点 sizeof + 匿名 struct 魔法:

#define C(x) ((x) + 0*sizeof( \
  struct { _Static_assert((int)(x) || 1, ""); char tmp; } \
))

这看起来很古怪。为什么 static_assert 允许在 struct 声明中使用?这是因为标准将 static_assert 分类为声明,该声明不声明任何内容(不要问我为什么)。因此,从语法上讲,你可以将其放入 struct 中。

0*sizeof(...) 的加法实际上是一个空操作,使值保持不变,但类型可能会因提升而改变。

缺点:

sizeof + 具有数组类型的复合字面量

这使用与上述类似的技巧,但它没有使用 static_assert,而是使用复合字面量数组类型来确保表达式是常量:

#define C(x) ( (x) + 0*sizeof( (char [(int)(x) || 1]){0} ) )

需要注意以下几点:

  1. C99 以来,数组(和数组类型)可以是可变长度的。但是复合字面量不接受可变长度类型,这为我们进行了验证。
  2. 标准 C(不幸的是)禁止零长度数组,因此使用 || 1
  3. 超过一定限制的数组大小无法声明(即使没有分配存储空间)。在我的 64 位 PC 上,gcc 拒绝大于 PTRDIFF_MAX (263-1) 字节的大小,而 clang 甚至更保守,拒绝大于 61 位的 大小。除了支持 0 之外,|| 1 还将数组大小限制为 1。

static_assert 相比,这种方法的唯一优点是它不需要 C11,并且可以在 C99 中使用。除此之外,它继承了 static_assert 的所有问题:

sizeof + enum 常量

因为 enum 常量必须是整数常量表达式,所以我们可以使用它们而不是复合字面量:

#define C(x) ( (x) + 0*sizeof( enum { tmp = (int)(x) } ) )

但是,这存在一个明显的问题:与复合字面量不同,enum 常量会“泄漏出去”。这意味着你不能多次使用此宏。你可以尝试使用预处理器连接来附加行号(__LINE__),但是你不能在同一行上多次使用此宏。

这是我想出的一个巧妙(或诅咒)的小解决方案:在函数参数中声明 enum,使其具有“作用域”:

#define C(x) ( (x) + 0*sizeof(void (*)(enum { tmp = (int)(x) })) )

这有效。但是 gccclang 都会警告 enum 是匿名的……即使这_正是_我想要做的。并且无法使用 #pragma 静默此警告,因为它是一个宏,因此警告发生在调用宏的位置。

实际上,没有太多理由使用它,但它是 C89 兼容的,如果你关心这一点的话。

缺点:

逗号运算符

所有使用 (x) + 0*sizeof(...) 技巧的宏都存在类型可能改变的问题。有一个简单而优雅的解决方案,将 sizeof 放在一个单独的表达式中,并使用逗号运算符_忽略_它:

#define C(x) (sizeof(...), (x))

但问题是你将收到一堆关于“逗号表达式的左侧操作数无效”的警告,即使这_正是_想要的效果。

更新:多位读者指出,将 sizeof 的结果强制转换为 void 可以消除未使用的警告(duh!)。

GCC 的怪异之处

最初,我没有为我的 __builtin_constant_p 解决方案使用 error 属性,而是使用负大小的数组(包装在禁止 VLA 的结构中)来触发编译错误:

#define C(x) ((__typeof__(x)) ((x) + 0*sizeof( \
  struct { char tmp[__builtin_constant_p(x) ? 1 : -1]; } \
)))

正如预期的那样,当数组大小为 -1 时,Clang 会报错。然而,GCC接受了它,并且只用一个警告就放过了你(双重糟糕)。因此,改用 error 属性(由 Arsen 建议),它应该可以抵御这种类型的怪异之处。

结论

事实证明,这比我最初预期的要大得多。嘈杂的警告也很烦人,但因为我想在库中使用此宏,所以简单地关闭警告对我来说不是一个选择。而且我宁愿保持库没有警告,而不是告诉用户关闭警告。

如果有任何其他(希望更好的)解决方案,我很想知道。

更新[u/P-p-H-d](https://nrk.neocities.org/articles/<https:/old.reddit.com/r/C_Programming/comments/1k4jh8u/detecting_if_an_expression_is_constant_in_c/moaqh3s/>) 指出了这个解决方案,它使用 _Generic、三元运算符和空指针常量规则来确定整数常量表达式。需要 C11 并且仅适用于整数,不幸的是不适用于浮点表达式。

标签: [ c ]