我曾经认为在C99中,即使函数f和g的副作用相互干扰,且表达式f() + g()不包含序列点,f和g仍然包含一些序列点,因此行为会是未指定的:要么先调用f(),要么先调用g()。
但现在我不太确定了。如果编译器内联这些函数(即使这些函数没有声明为inline,编译器可能决定进行内联优化),并重新排序指令,那么是否可能得到与上述两种结果不同的结果呢?换句话说,这是否是未定义的行为?
我之所以问这个问题,并不是因为我打算编写这类代码,而是为了在静态分析器中为这种语句选择最佳标签。
我曾经认为在C99中,即使函数f和g的副作用相互干扰,且表达式f() + g()不包含序列点,f和g仍然包含一些序列点,因此行为会是未指定的:要么先调用f(),要么先调用g()。
但现在我不太确定了。如果编译器内联这些函数(即使这些函数没有声明为inline,编译器可能决定进行内联优化),并重新排序指令,那么是否可能得到与上述两种结果不同的结果呢?换句话说,这是否是未定义的行为?
我之所以问这个问题,并不是因为我打算编写这类代码,而是为了在静态分析器中为这种语句选择最佳标签。
f() + g()
至少包含4个序列点;一个在调用f()
之前(在评估其所有零个参数之后);一个在调用g()
之前(在评估其所有零个参数之后);一个在调用f()
返回时;以及一个在调用g()
返回时。此外,与f()
相关的两个序列点要么都在g()
相关的两个序列点之前,要么都在其之后。你无法确定序列点发生的顺序 - 即f-points先于g-points还是反之。f()
和g()
的计算顺序是未指定的。但其他事情都相当干净。
在评论中,supercat问道:
我希望源码中的函数调用保持为序列点,即使编译器自行决定将其内联。对于声明为 "inline" 的函数是否仍然如此?或者编译器是否会获得额外的灵活性?
我认为 "好像" 规则适用,并且编译器不能因为使用显式 inline
函数而省略序列点。认为如此的主要原因(懒得查找标准的确切措辞)是编译器可以根据其规则内联或不内联函数,但程序的行为不应更改(除了性能)。
此外,关于
(a(),b()) + (c(),d())
的顺序,有什么可以说的吗?是否可能让c()
和/或d()
在a()
和b()
之间执行,或让a()
或b()
在c()
和d()
之间执行?
显然,a在b之前执行,c在d之前执行。我相信,在a和b之间执行c和d是可能的,尽管编译器生成这样的代码的可能性很小;同样地,在c和d之间执行a和b也是可能的。虽然我在“c和d”中使用了“and”,但它也可以是“or”,即任何这些操作序列都满足约束条件:
我相信这涵盖了所有可能的序列。请参见Jonathan Leffler和AnArrayOfFunctions之间的聊天-要点是AnArrayOfFunctions根本不认为“可能允许”的序列被允许。
在内联函数和宏之间存在显着差异,但我认为表达式中的顺序不是其中之一。也就是说,任何函数a、b、c或d都可以被替换为一个宏,并且宏体的相同排序可以发生。我认为主要的区别是,在内联函数中,函数调用处有保证的序列点-如主要答案中所概述的-以及逗号运算符处的序列点。使用宏,您将失去与函数相关的序列点。(所以,也许这是一个重大的区别...)然而,在很多方面,这个问题有点像关于多少天使能在针尖上跳舞的问题-在实践中并不是非常重要。如果有人向我展示表达式(a(),b()) + (c(),d())在代码审查中,我会告诉他们重写代码以使其清晰明了:如果这种情况是可能的,那就意味着内联函数和宏之间存在重大差异。
a();
c();
x = b() + d();
假设在b()
和d()
之间没有关键的顺序要求。
a()
等是内联函数还是宏,都可能出现执行顺序如acdb
或cabd
的情况。差异的一个例子是,如果x
是全局变量,并且我们有#define a() x++
和#define c() x++
,那么(a(),b()) + (c(),d())
将导致UB:x
最终可能会成为任何值,但最有可能的是根据读取-修改-写入指令如何交错而增加一次或两次;而如果我们有void a() { x++; } void c() { x++; }
(可选inline
),则没有UB,x
将被增加两次。 - j_random_hackerc()
之后和d()
之前执行(a(),b())
,这可能不符合“仿佛”规则。 - AnArrayOfFunctions@dmckee
嗯,这不适合放在注释中,但是这就是事情:
首先,你要编写一个正确的静态分析器。“正确”在这里的意思是,如果分析的代码有任何可疑之处,它不会保持沉默,因此在这个阶段,你可以将未定义行为和未指定行为混为一谈。它们都是不好的,在关键代码中是不可接受的,并且你对它们进行了正确的警告。
但是你只想为一个可能的 bug 发出一次警告,而且你知道当与其他可能不正确的分析器进行比较时,你的分析器将根据“精度”和“召回率”进行评估,因此你不能为同一个问题两次发出警告......无论是真实的还是虚假的(你不知道哪个是真的,哪个是假的,否则就太容易了)。
因此,你想发出一个单一的警告:
*p = x;
y = *p;
因为在第一条语句中,只要p
是一个有效的指针,就可以假定它在第二条语句中仍然是一个有效的指针。如果不这样推断,将会降低您在精度指标上的得分。
所以,您需要教导您的分析器,在上述代码中第一次发出警告后,假定p
是一个有效的指针,以便您不会在第二次发出警告。更一般地说,您需要学会忽略与您已经发出警告的内容相对应的值(和执行路径)。
然后,您意识到并不是很多人都在编写关键代码,因此您为其余人制定了其他轻量级分析,基于初始正确分析的结果。比如,一个C程序切片器。
您告诉他们:“您不必检查第一次分析发出的所有(可能经常是错误的)警报。只要没有触发其中任何一个,切片程序的行为就与原始程序相同。切片器生成的程序对于“定义”的执行路径的切片标准是等效的。”
用户愉快地忽略警报并使用切片器。
然后你意识到可能存在误解。例如,大多数memmove
实现(你知道的,处理重叠块的那个)在使用不指向同一块的指针调用时实际上会调用未指定的行为(比较不指向同一块的地址)。而你的分析器忽略了两条执行路径,因为两者都是未指定的,但实际上两条执行路径是等效的,一切都很好。
因此,在警报的含义上不应该有任何误解,如果有人打算忽略它们,只有明显的未定义行为应该被排除。
这就是你对区分未指定行为和未定义行为产生浓厚兴趣的原因。没有人会责怪你忽略后者。但程序员会毫不考虑地编写前者,当你说你的切片器排除了程序的“错误行为”时,他们不会感到自己受到影响。
这就是一个故事的结尾,它绝对不适合放在评论中。对于阅读到这里的任何人,我表示歉意。
p
在这里无效?”这个问题的回答,其他的稍后再说。 - Pascal Cuoq<
、>
来比较指针可能是未定义的。关于这个主题,我的其他问题有很多好答案:https://dev59.com/Bm865IYBdhLWcg3wCp9A - Pascal Cuoq<
和>
运算符在提供不相关指针时以任意方式行为的事实,并不意味着作者认为编译器应该被视为适用于系统编程,而没有能力比较所有情况下硬件可以轻松处理的指针。如果人们不希望标准定义实现适用于所有目的所需的一切,那么即使标准未能强制执行程序员依赖的常见行为,实现也会支持它,这通常并不重要。 - supercat
(*pf[f1()]) (f2(), f3() + f4())
。如果它只说f3
和f4
中的副作用会干扰,那么我就有了答案,但它更关注的是在调用(*pf[f1()])
之前所有副作用都已完成的事实。 - Pascal Cuoq