举例说明 C 预处理器中 # 和 ## 的未指定相对计算顺序

Example illustrating the unspecified relative evaluation order of # and ## in the C preprocessor

提问人:Lover of Structure 提问时间:10/8/2023 最后编辑:Lover of Structure 更新时间:10/21/2023 访问量:207

问:

关于已接受答案的一些评论在本问题帖子的底部。


问题陈述

根据 C 标准(C17 草案,6.10.3.2 ¶2):

[the] 和运算符的计算顺序未指定。###

我正在寻找一个示例,其中此评估顺序很重要,并且没有其他未定义行为的实例,也没有错误。

在花了一些时间讨论这个问题之后,我怀疑以下方法可能会起作用:

#define PRECEDENCETEST(a, b, c)  # a ## b

PRECEDENCETEST(c, , d)

(请注意,预处理器可以按如下方式运行:或 (GCC)、(MSVC);有关可编译的虚拟示例,请参阅下文。另请注意,空宏参数仅在 C99 之后才合法。cppgcc -Ecl /E

我的问题是:根据 C 标准,这是否真的可以作为 # 和 ## 的相对评估顺序产生合法输出的示例?正如我在这篇文章底部所解释的那样,如果我理解正确的话,答案可能取决于标准是否允许之后的令牌最终与最初指定的令牌不同。#

如果答案是“是(因为...)”,那么我们找到了一个例子!如果答案是“不,你的例子不起作用(因为......)”,那么我稍后会想出一种方法来征求更好的例子。

(请注意,该标准不要求编译器对 and 运算符具有绝对相对计算顺序。顺序可以是:从左到右,从右到左,遵循其他逻辑,或者完全随机。###

文档

较旧的 GCC 文档(似乎最高为 6.5 版)指出

该标准没有规定“”运算符链的计算顺序,也没有规定“”是在“”之前、之后还是与“”同时计算。因此,您不应编写任何依赖于任何特定顺序的代码。如果需要,可以通过适当使用嵌套宏来保证排序。#####

例如,粘贴参数 ''、'' 和 ''。这对于从左到右的粘贴来说很好,但从右到左的粘贴会产生无效的标记 ''。1e-2e-2

GCC 3.0 同时计算 '' 和 '',严格从左到右。旧版本首先计算所有 '' 运算符,然后以不可靠的顺序计算所有 '' 运算符。######

(对于中间段落中的 -only 示例(即:):不是有效的浮点常数(C17 draft,6.4.4.2),但它是有效的 pp 数(“预处理”;C17 草案,6.4.8),因为鞋底是有效的标识符非数字。(预处理数字的存在是为了“将预处理器与数字常量的全部复杂性隔离开来”;参见 GNU 文档了解其 C 预处理器。也就是说,一个更好的例子是(对从左到右有效,但不适用于从右到左的令牌连接),改编自这个 MISRA 讨论##1##e##-21ee2##.##e3

就其价值而言,维基百科在其关于C预处理器的文章中声称以下内容:

类似宏扩展的扩展发生在以下阶段:

  1. 字符串化操作将替换为其参数替换列表的文本表示形式(不执行扩展)。
  2. 参数将替换为其替换列表(不执行扩展)。
  3. 串联运算将替换为两个操作数的串联结果(不扩展生成的标记)。
  4. 源自参数的令牌将展开。
  5. 生成的令牌将照常展开。

但是,我在 C 标准或 GNU 的 CPP(C 预处理器,GCC 的一部分)文档中找不到对这种特定评估顺序的支持,截至提出这个问题时,其最新文档(GCC 13.2)在这里。

最重要的是,上述来源(包括 C17 标准)都没有提供类似函数的宏的示例,该宏的计算结果会根据宏替换列表中 # 和 ## 的相对优先级而有所不同

我正在寻找不会导致其他未定义行为或错误的示例,因为看似有效的宏是难以发现的错误的潜在来源。在这方面,重要的是以下两个约束:

  • “如果 [来自运算符] 的替换不是有效的字符串文字,则行为未定义。”(C17 草案,6.10.3.2 ¶2)#
  • “如果 [令牌连接] 的结果不是有效的预处理令牌,则行为未定义。”(C17 草案,6.10.3.3 ¶3)##

查找示例

寻找合适的例子结果出乎意料地棘手。

首先,字符串文字(C17 draft,6.4.5)——我们正在考虑,因为它们是应用的结果——几乎不能与其他任何内容连接起来:###

  • ##不能用于连接两个字符串文字,因为类似的东西不是有效的预处理标记(C17 草案,6.4 ¶1)。这里需要注意的是,基于标记的串联与翻译阶段 6(C17 草案,5.1.1.2 ¶1)中的字符串文字串联不同,后者将合并并合并为 ."abc""def"##"abc""def""abcdef"
  • 字符串文字可以选择以编码前缀 (, , , ) 开头,但编写这样的替换列表会导致有效的预处理标记需要 s 的微妙平衡(除了启动预处理指令或在字符串或字符文字之外,它只能作为预处理标记和它们本身的一部分存在),我无法实现。例如,在任一计算顺序下生成(假设字符串化运算符可以合法地从应用 中得到),我不确定这个示例是否可以根据计算顺序演变成一个产生两个不同的有效结果。u8uUL[...] ## # b####
    #define TEST(a, b)  a ## # b
    TEST(, c)
    
    "c"###

此外,类似的东西不起作用,因为在这个表达式中,“”和“”部分是独立的。a ## b # ca ## b# c

但是,似乎以下方法可能有效:

#include <stdio.h>

#define PRECEDENCETEST(a, b, c)  # a ## b

int main(void) {
    printf("%s\n", PRECEDENCETEST(c, , d));

    return 0;
}

案例 A:对于 GCC 和 MSVC,我得到的输出对应于 #-before-## 的评估顺序c

PRECEDENCETEST(c, , d)
# a ## b
"c" ## b
"c" ## <placemarker>
"c"

(地标预处理标记表示与 相邻的空宏参数。(C17 草案,6.10.3.3 ¶2))##

案例 B:##-before-# 评估顺序将给出以下内容:

PRECEDENCETEST(c, , d)
# a ## b
# c ## <placemarker>
# c
"d"

也就是说,程序的输出必须是 。或者会吗?最后一步假定它不仅可以对原始替换列表中的参数进行操作,还可以对应用 .需要注意的是,以下约束(C17 草案,6.10.3.2 ¶1)d###

类似函数的宏的替换列表中的每个 # 预处理标记后应跟一个参数,作为替换列表中的下一个预处理标记。[这不适用于类似对象的宏。

没有被违反 - 只是在这个例子中,实际参数最终是与替换列表()中指定的参数不同的参数()。#ca


关于接受答案的评论:

  • 我相信公认的答案代表了对标准最明智的解释。事实上,该标准的编写方式应该迫使任何读者得出相同的结论。

  • 但是,我确实相信该标准的作者没有考虑清楚。究其原因,是这样的:结合

    1. 接受的答案
    2. 我的思考(在我的问题帖子的“寻找示例”部分开头的项目符号中)关于两个字符串文字的串联##

    相对接近于证明

    不存在以下情况:以两种不同的方式解析相同的输入,仅在应用顺序上有所不同,从而导致两种不同的输出可能性,这些输出既不调用错误(例如违反预处理器约束),也不调用未定义的行为。###

    因为,如果确实没有这样的情况,C标准的作者可以简单地规定“之前”,因为添加这样的规定将无法影响现有的有效/非UB程序。(有关更多详细信息/要点,请参阅我与回答者的讨论。###

    同样,如果 C 标准像公认的答案所暗示的那样清晰,为什么 GCC 维护者和文档作者(他们显然对这个问题进行了一些思考)没有提供具有类似结论(或其他对比示例)的相关评论?

language-lawyer c-preprocessor operator-precedence token-pasting-operator

评论

0赞 John Bollinger 10/9/2023
请将范围缩小到一个具体的问题。
0赞 Lover of Structure 10/9/2023
@JohnBollinger 我已经编辑掉了最后的附带问题。我认为剩下的或多或少是一个问题。我本可以问“什么是有效的例子?”,但这可能被认为太宽泛了(尽管我认为它很有价值,因为我在网上找不到任何例子)。现在我的问题是:“我的例子有效吗(以及为什么或为什么不有效)?如果这太简单了,就会有一个隐含的“如果我的例子不起作用,你有更好的例子吗?”但这看起来像是两个问题。无论如何,如果您有任何建议,请告诉我。
0赞 Lover of Structure 10/9/2023
@JohnBollinger,我本可以直接询问之后的令牌是否可以是其他预处理器操作的结果,例如 ,但这有点没有意义,因为这将依赖于标准明确未指定的特定评估顺序。(虽然它不是UB,但假设编译器选择了评估顺序,我猜。###
1赞 Ruud Helderman 10/9/2023
情况 B(-before-评估顺序)不应导致 ,因为这将涉及两次替换传递。任何明智的实施都不应该这样做。###"d"
1赞 Lover of Structure 10/9/2023
@Neil 自 C99 起,明确允许使用空宏参数。

答:

6赞 John Bollinger 10/9/2023 #1

[the] 和运算符的计算顺序未指定。###

我正在寻找一个示例,其中此评估顺序很重要并且 其中没有其他未定义行为的实例,并且没有 错误。

我认为你的意思是,你正在寻找一个案例,其中两个不同的评估顺序产生有效(没有错误)但在语义上不同(顺序很重要)的结果。

我怀疑以下方法可能有效:

#define PRECEDENCETEST(a, b, c)  # a ## b

PRECEDENCETEST(c, , d)

[...]这是否实际上可以作为示例,其中任何一个相对 # 和 ## 的计算顺序产生合法输出,根据 C 标准?

不。

您需要特别注意宏参数处理的规范和 and 运算符的行为。展开类似函数的宏时,参数名称在宏的替换列表中的每次出现都有三种可能的情况:###

  1. 参数 [name] 前面既不是 # 或 ## 预处理令牌,也不是 ## 预处理令牌 (C17 6.10.3.1/1)。在这种情况下,对应参数的预处理标记序列被完全宏展开,然后参数被结果替换。

  2. 参数 [name] 前面紧跟一个 # 预处理标记 (C17 6.10.3.2/2)。在这种情况下,对应参数的预处理标记序列进行字符串化,然后将 和 参数替换为结果。#

  3. 参数 [name] 紧跟在 ## 预处理标记 (C17 6.10.3.3/2) 的前面或后面。在这种情况下,参数首先替换为相应参数的预处理标记序列或占地标记标记(视情况而定)。然后,在重新扫描之前,但(隐式)在本段要求的参数替换之后,将适当的令牌粘贴应用于替换列表中每个标记的预处理标记。##

如果规范在这些子句中说“参数”,则它谈论的是替换列表中包含参数名称的单个预处理令牌,而不是相应参数的预处理标记。因此,这些情况中只有一种可以应用于参数的任何给定外观。一旦根据这些规则之一将该外观替换为其他内容,则不再有要根据其他规则之一替换的参数。

您的示例涉及一个参数,该参数前面是 a,后跟 ,因此可以根据情况 (2) 或情况 (3) 执行其替换。我们可以争辩说,规范没有定义在这两种情况都适用的情况下会发生什么(因此行为未定义),但假设我们不去那里,而是查看评估顺序。然后###

  • 首先应用字符串化。和替换为字符串文字标记。的操作数不必是参数,因此没有参数作为 的左操作数是可以的。替换为(当时或更早)的地标标记,并在重新扫描之前的某个时间执行串联,从而生成 .#a"c"####b"c"

  • 首先应用操作员要求的令牌替换不起作用。执行该替换后,在操作评估期间不再需要替换参数。因此,选择此评估顺序的结果是未定义的行为。###

旁注:即使我们在区分参数及其相应的参数序列方面要宽松得多,也不可能期望您的示例产生。参数不会出现在宏的替换列表中,因此其对应的参数对生成的扩展没有贡献。即使我们在替换列表的末尾加上了 a,也没有理由认为字符串化运算符的范围会扩展到那么远,无论宏的第二个参数是什么。"d"cc


总的来说,如果在 和 之间有一个有意义的评估顺序选择,那么这个选择就是在有条件的未定义行为(从第一个字符串化)到无条件的未定义行为(或至少首先执行该行为的参数替换部分)之间。由于即使是字符串化优先的情况也只有在运算符的另一个操作数是其对应参数为空序列的参数时才有效,因此形成此类结构似乎意义不大。#####

评论

0赞 Lover of Structure 10/10/2023
关于你所说的核心(“规范说”参数“[...],它谈论的是替换列表中的单个预处理令牌[...],而不是相应参数的预处理标记。具体来说,C17草案一方面使用措辞(6.10.3.3)“替换列表中预处理令牌的每个实例(不是来自参数)”(¶3)和......##
0赞 Lover of Structure 10/10/2023
...“产生一个新标记,由两个相邻的尖锐符号组成,但这个新标记不是 ## 的运算符”(¶4),但另一方面在 6.10.3.2 中没有# 进行类似的澄清。类似地指出,运算符及其参数参数都不能源自参数或基于串联的结果,这很容易,即使它的一部分来自标准,也建议说明这一点。...hash_hash#####
0赞 Lover of Structure 10/10/2023
...因此,我无法判断您的解释(尽管可能合理)是否符合标准的意图,或者编写它的人根本没有考虑过。
0赞 John Bollinger 10/10/2023
@LoverofStructure,“参数”和“参数”是规范中定义的术语(分别为 3.3 节和 3.16 节),它们的使用与第 6.10 节中的定义一致。这些定义需要对我提出的 6.10.3 进行解释。你只需要一如既往地阅读规范的实际内容——它的措辞是经过仔细选择的,尤其是当涉及到定义的术语时。
0赞 John Bollinger 10/10/2023
至于你所观察到的6.10.3.2和6.10.3.3中的散文之间的区别,我认为你对一个简短的括号评论提出了一大堆疑问——或者实际上没有这样的评论。但是,请注意,有关的额外说明特定于该运算符的行为:首先替换宏替换列表中的参数(这可能会引入标记),然后粘贴标记。没有模拟涉及,因为其相应的参数在一个步骤中被替换在一起######