在C99中,f()+g()是未定义的还是仅仅是未指定的?

54

我曾经认为在C99中,即使函数f和g的副作用相互干扰,且表达式f() + g()不包含序列点,f和g仍然包含一些序列点,因此行为会是未指定的:要么先调用f(),要么先调用g()。

但现在我不太确定了。如果编译器内联这些函数(即使这些函数没有声明为inline,编译器可能决定进行内联优化),并重新排序指令,那么是否可能得到与上述两种结果不同的结果呢?换句话说,这是否是未定义的行为?

我之所以问这个问题,并不是因为我打算编写这类代码,而是为了在静态分析器中为这种语句选择最佳标签。


6.5.2.2的第12段包含示例(*pf[f1()]) (f2(), f3() + f4())。如果它只说f3f4中的副作用会干扰,那么我就有了答案,但它更关注的是在调用(*pf[f1()])之前所有副作用都已完成的事实。 - Pascal Cuoq
真的很重要吗?无论哪种方式,都意味着您不能依赖于已知在FooOS上与BarCC版本X.Y.ZpW一起工作的行为,如果Foo、Bar、X、Y、Z或W中的任何一个发生更改。您所能希望的最好情况是,在坚持严格指定的环境时保持一致性。 - dmckee --- ex-moderator kitten
2
@dmckee 如果你愿意,可以称之为迂腐,但在某些情况下,由于广泛接受的理论原因而必须发出一些虚假警报,我们喜欢区分“如果这是真正的警报,它可能会做任何事情”和“如果这是真正的警报,它可能具有两种不同的明确定义的行为”。我想进一步阐述,但评论限制不允许我这样做。 - Pascal Cuoq
@Pascal:我看到了区别。实际上,未定义行为是立即且无条件的停机器,而仅仅是未指定只会带你到NastilyUnsupportableLand。但是NastilyUnsupportableLand很难处理,所以我只能在极端情况下才能证明它。 - dmckee --- ex-moderator kitten
@dmckee 嗯,这个问题比较长,所以我在下面的回答中进行了解释。 - Pascal Cuoq
@dmckee:如果两个函数都执行“1. 保存全局状态;2. 配置全局状态以供自己使用;3. 使用全局状态;4. 恢复全局状态”等一系列操作,那么它们之间的区别是显著的。如果每个函数在开始后会完整地执行,然后另一个函数才开始执行,那么先后顺序就无关紧要了。但是,如果两个函数都执行前两个步骤,然后再执行后两个步骤,那么事情就不会顺利进行。 - supercat
3个回答

25
表达式f() + g()至少包含4个序列点;一个在调用f()之前(在评估其所有零个参数之后);一个在调用g()之前(在评估其所有零个参数之后);一个在调用f()返回时;以及一个在调用g()返回时。此外,与f()相关的两个序列点要么都在g()相关的两个序列点之前,要么都在其之后。你无法确定序列点发生的顺序 - 即f-points先于g-points还是反之。
即使编译器内联了代码,它也必须遵守"as if"规则 - 代码必须像没有交错函数一样运行。这限制了损坏的范围(假设编译器没有错误)。
因此,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”,即任何这些操作序列都满足约束条件:

    • 绝对允许
    • abcd
    • cdab
    • 可能允许(保留a ≺ b,c ≺ d排序)
    • acbd
    • acdb
    • cadb
    • cabd

     
    我相信这涵盖了所有可能的序列。请参见Jonathan Leffler和AnArrayOfFunctions之间的聊天-要点是AnArrayOfFunctions根本不认为“可能允许”的序列被允许。

如果这种情况是可能的,那就意味着内联函数和宏之间存在重大差异。

在内联函数和宏之间存在显着差异,但我认为表达式中的顺序不是其中之一。也就是说,任何函数a、b、c或d都可以被替换为一个宏,并且宏体的相同排序可以发生。我认为主要的区别是,在内联函数中,函数调用处有保证的序列点-如主要答案中所概述的-以及逗号运算符处的序列点。使用宏,您将失去与函数相关的序列点。(所以,也许这是一个重大的区别...)然而,在很多方面,这个问题有点像关于多少天使能在针尖上跳舞的问题-在实践中并不是非常重要。如果有人向我展示表达式(a(),b()) + (c(),d())在代码审查中,我会告诉他们重写代码以使其清晰明了:
a();
c();
x = b() + d();

假设在b()d()之间没有关键的顺序要求。


1
我期望源代码中的函数调用保持为序列点,即使编译器决定将它们内联。这是否适用于声明为“inline”的函数,或者编译器是否有额外的自由度?此外,对于(a(),b())+(c(),d())的排序有什么可以说的? c()和/或d()是否可能在a()和b()之间执行,或者a()或b()是否可能在c()和d()之间执行?如果这样的事情是可能的,那就意味着内联函数和宏之间存在重大差异。 - supercat
如Jonathan所说,无论a()等是内联函数还是宏,都可能出现执行顺序如acdbcabd的情况。差异的一个例子是,如果x是全局变量,并且我们有#define a() x++#define c() x++,那么(a(),b()) + (c(),d())将导致UB:x最终可能会成为任何值,但最有可能的是根据读取-修改-写入指令如何交错而增加一次或两次;而如果我们有void a() { x++; } void c() { x++; }(可选inline),则没有UB,x将被增加两次。 - j_random_hacker
@j_random_hacker:当a()和c()是宏时,它们的展开不代表一个序列点或排序关系。我的问题是关于声明为内联的函数。如果编译器决定内联一个未声明为内联的函数,我希望它需要保留普通函数调用的语义。如果一个函数被声明为内联,是否可以减轻任何语义要求? - supercat
@supercat:刚看了一下(现在已经过时的)C++标准,确实1.9/17明确指出:“调用函数时(无论函数是否为内联函数),在评估所有函数参数之后,都有一个序列点...”。不确定C99是否也是如此--我似乎记得它对“inline”的语义略有不同--但在这方面以相同的方式行事是有道理的。 - j_random_hacker
如果“任何这些操作序列符合约束条件”,那么就有一个列表,允许例如在执行c()之后和d()之前执行(a(),b()),这可能不符合“仿佛”规则。 - AnArrayOfFunctions
显示剩余8条评论

14
请查看附录C以获取序列点列表。函数调用是序列点,它在所有参数被评估且执行传递到函数之间。正如您所说,未指定哪个函数先调用,但这两个函数中的每一个将要么看到另一个的所有副作用,要么根本不会看到。

1

@dmckee

嗯,这不适合放在注释中,但是这就是事情:

首先,你要编写一个正确的静态分析器。“正确”在这里的意思是,如果分析的代码有任何可疑之处,它不会保持沉默,因此在这个阶段,你可以将未定义行为和未指定行为混为一谈。它们都是不好的,在关键代码中是不可接受的,并且你对它们进行了正确的警告。

但是你只想为一个可能的 bug 发出一次警告,而且你知道当与其他可能不正确的分析器进行比较时,你的分析器将根据“精度”和“召回率”进行评估,因此你不能为同一个问题两次发出警告......无论是真实的还是虚假的(你不知道哪个是真的,哪个是假的,否则就太容易了)。

因此,你想发出一个单一的警告:

*p = x;
y = *p;

因为在第一条语句中,只要p是一个有效的指针,就可以假定它在第二条语句中仍然是一个有效的指针。如果不这样推断,将会降低您在精度指标上的得分。

所以,您需要教导您的分析器,在上述代码中第一次发出警告后,假定p是一个有效的指针,以便您不会在第二次发出警告。更一般地说,您需要学会忽略与您已经发出警告的内容相对应的值(和执行路径)。

然后,您意识到并不是很多人都在编写关键代码,因此您为其余人制定了其他轻量级分析,基于初始正确分析的结果。比如,一个C程序切片器。

您告诉他们:“您不必检查第一次分析发出的所有(可能经常是错误的)警报。只要没有触发其中任何一个,切片程序的行为就与原始程序相同。切片器生成的程序对于“定义”的执行路径的切片标准是等效的。”

用户愉快地忽略警报并使用切片器。

然后你意识到可能存在误解。例如,大多数memmove实现(你知道的,处理重叠块的那个)在使用不指向同一块的指针调用时实际上会调用未指定的行为(比较不指向同一块的地址)。而你的分析器忽略了两条执行路径,因为两者都是未指定的,但实际上两条执行路径是等效的,一切都很好。

因此,在警报的含义上不应该有任何误解,如果有人打算忽略它们,只有明显的未定义行为应该被排除。

这就是你对区分未指定行为和未定义行为产生浓厚兴趣的原因。没有人会责怪你忽略后者。但程序员会毫不考虑地编写前者,当你说你的切片器排除了程序的“错误行为”时,他们不会感到自己受到影响。

这就是一个故事的结尾,它绝对不适合放在评论中。对于阅读到这里的任何人,我表示歉意。


1
@马克,这涉及到一整套其他(具体来说是向后)的技术,恐怕我只能提供“哪些输入可能导致p在这里无效?”这个问题的回答,其他的稍后再说。 - Pascal Cuoq
也许最有帮助的工具是能够提供警告的分层列表的工具?这样第一个关于“p”的警告将开始可见,但点击它将显示其余部分?我想知道是否有任何工具可以做到这一点?此外,当您说memmove是“未指定”的时候,这是否意味着不相关的指针可能会以一种不保证形成一致排名的方式任意比较大于或小于彼此,但这样的比较本身不会导致鼻子恶魔? - supercat
@supercat 大多数静态分析工具都试图提供警告层级和仅显示您感兴趣的警告选项,但它们中没有一个希望来自一个可疑结构的不确定性影响到其余的分析,因此始终存在难以抉择的选择。关于我对"未指定"的使用,经过仔细检查后发现我是错误的:简单地使用 <> 来比较指针可能是未定义的。关于这个主题,我的其他问题有很多好答案:https://dev59.com/Bm865IYBdhLWcg3wCp9A - Pascal Cuoq
我知道这些工具提供了打开和关闭各种警告的选项。我很好奇它们中是否有任何一种以树形控件或可折叠列表的格式提供警告(可能包括脚本化HTML格式的选项),以便可以看到类似“p被'foo'使用了19次,其中可能为null”的内容,并且单击消息会给出这19次是什么,而无需重新运行工具。 - supercat
@PascalCuoq:标准允许<>运算符在提供不相关指针时以任意方式行为的事实,并不意味着作者认为编译器应该被视为适用于系统编程,而没有能力比较所有情况下硬件可以轻松处理的指针。如果人们不希望标准定义实现适用于所有目的所需的一切,那么即使标准未能强制执行程序员依赖的常见行为,实现也会支持它,这通常并不重要。 - supercat
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接