解析C语言中类型推断声明之险
摘要
这篇博文探讨了C23中涉及`auto`作为类型推断说明符或存储类说明符的解析歧义,展示了当`x`是typedef时,GCC和Clang在解析如`auto x = 67;`这样的声明上的分歧,以及属性如何使情况复杂化。
<p><a href="https://lobste.rs/s/ypgw9x/perils_parsing_type_inference">评论</a></p>
查看缓存全文
缓存时间: 2026/07/25 08:01
# 解析 C 语言类型推断声明的陷阱
来源:https://sebsite.pw/w/20260725-auto.html
这里有个有趣的事情。假设 `x` 在外部作用域中被声明为一个 `typedef`:`typedef int x;`。现在考虑函数体内的如下代码:`auto x y = 67;`。这声明了一个名为 `y` 的自动变量,类型为 `x`。gcc 和 clang 都能正确解析此代码。请珍惜这一刻:这将是本博文中最后一次 gcc 和 clang 在如何解析声明上达成一致。
让我们将声明改成这样:`auto x = 67;`。这(大概)应该声明一个名为 `x` 的变量,类型被推断;新的绑定应该隐藏 `typedef`。这正是 clang 的行为。而 gcc 则会报错:
```
: In function 'main':
:4:12: error: expected identifier or '(' before '=' token
5 | auto x = 67;
|
```
问题在于 `auto` 既可以作为存储类说明符,也可以作为类型说明符的替代品,具体取决于上下文。在这里,gcc 将 `x` 解析为 `typedef`,因此将 `auto` 视为存储类说明符,从而报告语法错误。clang 在决定如何处理 `x` 之前会向前查看,因此它能解析这两种声明。
那么,预期的行为是什么?这是 gcc 的 bug 吗?嗯……很难说。据我所知,标准并没有明确消除这里的歧义。有趣的是,同样的歧义在 ANSI C 中也存在,因为省略类型说明符的声明会被隐式赋予类型 `int`,但 ANSI C 明确消除了歧义:
> 如果 [typedef] 标识符在内部作用域中被重新声明,或者在同一作用域或内部作用域中被声明为结构体或联合体的成员,则内部声明中不得省略类型说明符。
因此根据 ANSI C 的规定,gcc 在此处报错是正确的。但由于我在 C23 标准中找不到任何明确的消歧规则,我的理解是 clang 的行为是正确的,因为它符合语法。而这非常有趣,因为它使得解析变得非常困难。
看起来似乎只需向前查看一个记号就行,但让我们尝试加入一些属性:`auto x [[asdf]] [[ghjk]] y = 67;`。这应该声明类型为 `x` 的 `y`,其中 `x` 被赋予属性 `[[asdf]]` 和 `[[ghjk]]`。这次,只有 gcc 成功解析!clang 给出如下错误信息:
```
:4:10: error: declaration of variable 'x' with deduced type 'auto' requires an initializer
5 | auto x [[asdf]] [[ghjk]] y = 67;
|
:4:20: error: expected ';' at end of declaration
5 | auto x [[asdf]] [[ghjk]] y = 67;
| ^
| ;
```
clang 试图将属性应用于名为 `x` 的绑定,并在看到属性后的 `y` 时报错。
让我们去掉 `y`,看看会发生什么:`auto x [[asdf]] [[ghjk]] = 67;`。现在轮到 gcc 报错了:
```
: In function 'main':
:4:21: error: expected identifier or '(' before '=' token
5 | auto x [[asdf]] [[ghjk]] = 67;
|
```
所以 gcc 看到属性后,试图将其绑定到类型 `x`。无论 gcc 还是 clang 都不会在其假设不正确时回溯。同样,标准从未明确消歧,因此据我所知,“正确”的行为应该是处理两种情况。因此要正确解析,需要解析标识符后的所有属性,然后读取另一个记号来决定标识符是否是类型说明符,然后根据对标识符的解释,将属性回溯性地应用于类型或绑定。
但情况还能更妙:clang 支持带有类型推断的数组、函数和指针声明符。标准规定这是实现定义的:
> 实现可以接受不是 `identifier attribute-specifier-sequenceopt` 形式(可选地括在括号对中)的直接声明符;如果接受了不同形式的直接声明符,则行为是实现定义的。
这有点奇怪,因为“直接声明符”排除了未加括号的指针声明符,这很可能是一个无意的疏忽。但无论如何,由于 clang 选择支持这一点,我相当确定以下声明是完全歧义的:
```
typedef int x, y;
int main() { auto x (y) = 67; }
```
这要么是类型为 `x` 的自动变量 `y`,要么是名为 `x` 的变量,其类型是一个接受 `y` 并返回推断类型的函数(约束违规)。clang 选择将其解析为后者:
```
:4:5: error: 'auto' not allowed in function return type
5 | auto x (y) = 67;
|
:4:10: error: illegal initializer (only variables can be initialized)
5 | auto x (y) = 67;
|
```
尽管标准说行为是实现定义的,但我确实认为应该在此明确消歧,因为这是语法问题,而不是语义问题。
哦对了还有:在标准 C 引入类型推断之前,gcc 和 clang 都支持扩展 `__auto_type`,其语义与 C23 的 `auto` 完全相同。……这是我之前在这个兔子洞之前所认为的。由于 `__auto_type` 不是存储类说明符,它与 `auto` 并不完全等价,因为该标识符永远不会被当作类型说明符。这说得通,但让我思考:`__auto_type` 是否具有与 C23 类型推断相同的作用域语义?
那么,通常情况下,声明在解析完声明符之后、但在初始化器(如果存在)之前被插入作用域。这对于类型推断(或 `constexpr`)来说并不能直接生效。因此为了适应这一点,C23 引入了“欠指定”声明的概念。也就是说,声明在解析完声明符后照常插入作用域,但它没有类型,因此在初始化器中的任何地方使用它都是约束违规:
```
// 正常:x 被初始化为一个毒值
int x = x;
// 错误:x 是欠指定的
auto x = x;
constexpr int x = x;
```
但由于欠指定声明是新事物,`__auto_type` 是否具有相同的语义?让我们试试:
```
int x;
int main() { __auto_type x = x; }
```
这在 gcc 中编译通过,因为 gcc 直到解析完初始化器之后才将新声明插入作用域。而 clang 则报错,因为它使用了与 C23 欠指定声明相同的语义!有趣的是,这种行为分歧在 `__auto_type` 存在于两个编译器中的时候就已经存在了。也就是说,即使在类型推断被标准化之前,clang 也一直在此处报错。
总之,正确解析类型推断声明需要:
- 在向前查找绑定之后,才决定 `typedef` 标识符是否是类型说明符,
- 允许标识符后跟属性,并根据对标识符的解释,将属性回溯性地应用于类型或绑定,
- 以及对于函数声明符和 `__auto_type`,天知道该怎么办。
相似文章
关于C数组类型语义的讨论
本文解释了C数组类型的令人困惑的行为,包括它们退化为指针、sizeof和函数参数等例外情况,并将其与函数类型进行比较,提出了一种数组和指针严格分离的心理模型。
解析,而非验证——在并不鼓励你这样做的语言中
一篇探讨在TypeScript中应用“解析,而非验证”原则的博客文章,展示了如何使用品牌类型(branded types)在解析后保留类型信息,尽管TypeScript的结构类型系统使得这种做法不如在Elm或Haskell等语言中那样自然。
关于C扩展、可移植性和替代编译器
本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。
用C语言搞怪,第&((int*)-8)[3]部分
一篇幽默的教育性文章,涵盖C语言基础知识,如前向声明、运算符优先级、无条件跳转和基本算术运算,并附带有意搞怪的代码示例。
C语言中无法解析整数 (2022)
文章批评了C标准库中用于解析整数的函数(atol、strtol、strtoul、sscanf),解释了为什么大部分函数存在缺陷,只有strtol在仔细进行错误处理的情况下才能正确使用。