更新2018-01-21:添加了与断言C预处理器缺乏上下文无关性相关的澄清。
丑闻被定义为涉及有问题的道德原则,引起公众愤怒的事情。这将使“丑闻”这个词成为描述C预处理器行为的极好的限定词。
当我在我的C编译器上工作时,我意识到了C预处理器的许多这些特性。*预处理器不是100%完成,但它支持递归函数宏。具体地说,它目前能够成功地预处理在C预处理器中实现的Brainfuck解释器的第三方实现。“您可以亲自试用演示。
以下是我在使用编译器时发现的关于C预处理器的7件令人不快的事情:
C99标准大约有500页,但其中只有19页专门描述C预处理器应该如何工作。大多数规范都是高级定性描述,旨在给编译器实现者留下很大的自由。很可能本规范中的这种含糊是故意的,这样就不会导致现有的主流编译器(和代码)变得不符合标准。设计自由也有利于让人们创造新奇的优化,但太多的自由可能会导致相互竞争的解释。
Bjarne Stroustrup甚至指出,该标准并不清楚在函数宏递归中应该发生什么[1]。*关于一个具体的例子,他说,问题是在这个序列的最后一行使用nil是否有资格根据引用的案文不进行替换。*如果是这样的话,结果将是零(42)。如果不是,结果就是42.";。2004年,委员会决定让该标准保持模棱两可的状态:委员会的决定是,任何现实的程序都不会涉足这一领域,试图减少不确定性的尝试不值得冒着改变实现或程序的一致性状态的风险。";委员会的决定是,任何现实的程序都不会冒险进入这一领域,试图减少不确定性是不值得冒着改变实现或程序的一致性状态的风险的。";委员会的决定是,没有任何现实的程序会冒险进入这一领域,试图减少不确定性是不值得的。
2018年01月21日增加的注意事项:我把它放在我的待办事项清单上已经有一段时间了,现在我要回去修改我在这里做出的声称C预处理器是上下文敏感的断言。“我现在认为这个断言是错误的,C预处理器不是上下文敏感的,但是我将把这一节留在这里,因为它仍然是关于C预处理器的一般信息。总有一天,我可能会找到时间回去,多回顾一下,以某种方式正式证明这一点。
乍一看,C预处理器看起来像是可以使用上下文无关文法来描述语言:但是当您考虑到预处理指令时,这很快就会分崩离析:预处理器指令是非常行敏感的。指令的第一个非空格、非注释字符必须是';#';字符。*指令始终在下一个换行符(或文件末尾)结束。*可以编写跨越多行的预处理器指令,但必须使用行续续符才能做到这一点。*这是可行的,因为在考虑Include指令之前会处理行续续符:
许多语言首先被标记化,然后在程序的进一步处理过程中,标记列表不会改变。在C预处理器中,可以在运行时创建新的令牌!这使得不可能提前构建解析树,因为您不知道最终的树中将包括哪些令牌。例如:
此外,';令牌的概念可以作为参数传递,然后稍后用于构造不同的';代码';:
其中y是传递的任何参数(恰好是一个';(';字符),因此您得到。
在下面的示例中,额外的空格完全改变了function_mac定义的含义:
/*这是一个函数宏*/#定义Function_Mac()某物/*这实际上是一个具有值';()某物';*/#定义Function_Mac()某物的对象宏。
但是稍后,当我们想要调用function_mac时,空格的存在根本无关紧要:
我还在GCC的预处理器中遇到了空格不一致(在较小程度上也是如此),我从未完全能够对其进行反向工程。例如,这里是GCC将删除宏定义中的标记之间的空格的一个例子。据我所知,GCC正在删除标记之间的空格,但只有在传入的标识符是函数宏的标识符的情况下才会删除,此外,在这种情况下,它似乎也利用了作为参数的一部分传入的空格。这似乎与GCC的文档[1]相矛盾,后者说删除每个参数中的前导空格和尾随空格,并将参数标记之间的所有空格缩减为一个空格。
#定义stringify_间接(X)#x#定义stringify(X)stringify_indirect(X)#定义put_side_by_side(x,y,z)x y z#定义a(X)x int main(Void){printf(stringify(put_side_by_side(a,a,a);printf(stringify(put_side_by_side(a,a,a);printf(stringify(put_side_by_side(a,a,a);printf(stringify(put_side_by_side(a,a,a)。printf(stringify(put_side_by_side(b,b,b);}
C预处理器中的函数宏与您在C或您可能遇到的大多数其他语言中看到的函数不同。其中一个显著的区别是,您不能通过简单地计算内部宏的结果,然后将此结果替换到外部函数调用中来推断函数宏的结果(就像在C中可以的那样)。
通常,在计算函数宏体时,需要同时考虑参数的预展开版本和为该参数传递的未触及的标记。此行为与C参数和函数的求值方式不同,因为在C中,您始终可以将表达式描述的参数替换为该表达式的结果,并且具有相同的含义(忽略任何副作用)。
跟踪每个函数宏调用的这两个不同的上下文可能很难理解,因为C预处理器在参数预展开阶段从外向内求值,然后在替换函数参数时从内到外求值,同时引用非预展开的参数进行字符串化和令牌连接。
C预处理器中复杂性的主要来源来自函数宏,特别是在处理递归时。C预处理器不允许无界递归,因此一旦我们在递归中第二次遇到函数调用,这些标记就会被禁用,不能进行将来的扩展。这条用于禁用宏和令牌的额外规则甚至增加了递归的复杂性。
下面的示例是我在预处理器中发现的涉及宏禁用的错误的最小测试用例。这个例子应该说明在实践中对函数宏进行推理是多么困难。我已经跳过了许多最详细的步骤,比如对于琐碎的函数宏调用的所有宏禁用、重新启用和参数预扩展阶段。
#定义递归4(C,T,E)C-T-E#定义递归3(X)[X]#定义递归2(C,X)递归4(C(X),,),)|C|#定义递归1(F,X)F(递归3,X)递归1(递归2,递归1)。
我们要做的第一件事是收集递归1(.)内的令牌。这为我们提供了:
接下来,我们在这些标记上执行参数预展开,以计算函数调用内部的任何宏。通常,我们需要注意我们想要预展开的确切标记,因为函数宏体可以利用完全参数预展开版本,也可以利用传入的文字标记。例如,stringify运算符(#)将创建作为函数宏参数传递的令牌的字符串文字,而不需要首先预先展开它们。
对于宏,我们可以看到recur2表示函数宏的名称。因为标记';recur2';后面没有括号,所以这并不代表对函数宏的调用,所以我们只需保留该标记即可。接下来我们可以考虑。
检查此函数宏调用的内容,我们会发现它包含以下标记:
由于';recur2';是函数宏(而不是对象宏)的标识符,我们可以看到(Abc)的完全参数预展开参数只有以下几个:
我们现在可以将(4)和(5)替换到(3)中所称的递归1的定义中。还请注意,因为我们能够完成(3)的参数预扫描,所以我们现在在评估宏时禁用它,这为我们提供了。
(6)Recur2(Recur3,1)**';Recur1';当前禁用。当前面的令牌被使用时,我们可以重新启用此宏。
这再次给了我们一些需要进行宏观扩展的东西。在这种情况下预先展开的参数是。
(9)递归4(递归3(1),递归4(递归3(1),,),)|递归3|*';当前禁用的递归1';当使用前面的令牌时,我们可以重新启用此宏。**';Recur2';当前禁用。当前面的令牌被使用时,我们可以重新启用此宏。
我稍后会更多地谈到上面提到的残疾。在结果中搜索要展开的宏需要我们展开
(14)[1]/*第一个参数至(13)*/(15)/*第二个参数至(13)*/(16)/*第三个参数至(13)*/。
现在,我们可以使用(12)和(17)说明来自(11)的整个预先展开的参数列表:
(19)[1]/*第一参数*/(20)[1]--/*第二参数*/(21)/*第三参数*/。
(23)也是(6)的完全宏展开版,可以代入(2):
既然我们已经完全执行了(1)的参数预扫描,并且我们正在使用以下等效项,那么我们已经完成了一半的工作:
(27)recur4(recur3([1]-[1]-|recur3|),,),)|recur3|。
现在,我们重新扫描替换的可以评估的宏,发现只有这一个:
(28)recur4(recur3([1]-[1]-|recur3|)、recur4(recur3([1]-[1]-|recur3|),),)
我们需要将我们的老朋友参数预扩展应用于';recur4';内部的令牌:
(29)递归3([1]-[1]-|递归3|)、递归4(递归3([1]-[1]-|递归3|),),
查看(29),我们可以看到有3个参数需要求值才能得到(28)的结果:
(30)递归3([1]-[1]-|递归3|)/*参数1*/(31)递归4(递归3([1]-[1]-|递归3|),,)/*参数2*/(32)/*参数3*/。
(34)recur3([1]-[1]-|recur3|)/*arg 1*/(35)/*arg 2*/(36)/*arg 3*/。
由于(35)和(36)为空,我们现在可以使用';递归4';的定义对(31)求值如下:
由于(38)是(31)的求值形式,我们现在可以使用';recur4';的定义计算(28):
(39)[[1]-[1]-|递归3|]-[1]-[1]-|递归3|]
由于(28)是(27)中唯一的宏,因此我们可以将(28)的求值形式替换为(27):
(40)[[1]-[1]-|recur3|]-[1]-[1]-|recur3|。
(40)是(27)的评价表,它是(26)的评价表,它是(25)的评价表,它是(1)的评价表。*因此,中国的宏观扩张。
[[1]-[1]-|recur3|]-[1]-[1]-|recur3|]-|recur3。
这个测试用例的预处理器中最终出现的错误是评估传递给函数宏调用的参数、预先扫描的令牌。*如果被调用的函数宏包含与被调用的函数宏具有相同标识符的令牌,但并未实际调用该函数宏,则会错误地禁用该令牌。此缺陷与另一个通过引用复制令牌的缺陷相结合,将意味着禁用一个令牌可能会在其他地方禁用它,因此外部有效宏调用的令牌将被禁用,并且再也无法扩展。但是这个错误大约在步骤(34)左右。