C 中的短路布尔运算符(再次):默认情况下它不会产生低效代码吗?
Short Circuit Boolean operators in C (again): doesn't it produce inefficient code by default?
我知道“短路运算符”这个话题在 StackExchange 上已经讨论了很多。但是 w.r.t 特别是 'C' 语言,有一个方面困扰了我一段时间。
考虑一些 C 代码,例如
if (b && f(&s)) {...}
其中 b 是布尔类型,s 是具有某种或多或少复杂结构类型的变量,f 是结构的 getter。更具体的示例可能如下所示:
if (active && isEmpty(&list)) {...}
通常,我希望任何像样的编译器都会内联 isEmpty
函数并将整个表达式优化为单个 AND
汇编指令第一个操作数已经保存在数据寄存器中,第二个操作数保存在内存中,通过 'address register with offset' 寻址模式(或类似方式)访问。
但是,由于编译器被强制(通过语言规则)生成短路形式,这种优化不能进行,最终会产生非常低效的代码.我很难相信这是真的!
优化的 C 编译器通常会生成什么版本的汇编代码?
是的,它有点低效,但不是因为内联,而是因为指令 re-ordering 是不可能的。如果左操作数最多execution-heavy,还是要先执行
例如考虑 search_algorithm() && bool_flag
,必须始终执行慢速函数,即使 bool 标志检查要快得多,并且如果它的计算结果为零,则整个表达式为假。编译器对此无能为力,我们不得不通过交换操作数来依赖手动优化。
无论函数调用发生在表达式中的何处,都可能发生内联。阻塞的不是内联本身,而是函数中代码的执行。
&&
/||
运算符也可能意味着在机器代码中引入了一个额外的分支,因为 if(a && b)
等同于 if(a) { if(b) { ... } }
.
x86 的内联示例:
#include <stdio.h>
int afunc (int a)
{
puts(__func__);
return a;
}
int bfunc (int b)
{
puts(__func__);
return b;
}
_Bool and (int a, int b)
{
return afunc(a) && bfunc(b);
}
gcc -O3
为 and
函数生成此代码:
afunc:
push r12
mov r12d, edi
mov edi, OFFSET FLAT:__func__.1
call puts
mov eax, r12d
pop r12
ret
bfunc:
push r12
mov r12d, edi
mov edi, OFFSET FLAT:__func__.0
call puts
mov eax, r12d
pop r12
ret
and:
push rbp
mov ebp, esi
push rbx
mov ebx, edi
mov edi, OFFSET FLAT:__func__.1
sub rsp, 8
call puts
xor eax, eax
test ebx, ebx
jne .L12
add rsp, 8
pop rbx
pop rbp
ret
.L12:
mov edi, OFFSET FLAT:__func__.0
call puts
test ebp, ebp
setne al
add rsp, 8
pop rbx
pop rbp
ret
__func__.0:
.string "bfunc"
__func__.1:
.string "afunc"
相关部分是and:
函数:
- 内联函数调用,直接将
"afunc"
对应的字符串地址OFFSET FLAT:__func__.1
加载到一个寄存器中,然后调用puts
.
- 它在这里检查
a
的值:test ebx, ebx
然后根据结果引入额外的分支 jne .L12
。 .L12
是正确的操作数评估。
两个函数都是内联的。它们被执行 left-to-right 并且仅在“跳转不等于零”导致跳转的情况下才评估右操作数,否则函数 returns。 clang 和 icc 给出了几乎相同的机器代码。
What version of assembler code will an optimizing C compiler usually generate?
这是一个不断发展的问题,多年来编译器变得越来越好。优秀的程序员熟悉他们的编译器的功能并编写利用它的代码。因此,如果 active && isEmpty(&list)
与 isEmpty(&list) && active
是一个问题,一个好的程序员会选择更适合他们情况的那个,and/or 他们可能会深入实施 isEmpty
和看看是否可以对此进行改进。
However, since the compiler is enforced (by the language rules) to generate the short circuit form,…
这是真的,也不是真的。 C 标准指定了两个级别的行为。 C 的语义是使用抽象计算机的模型来指定的,该模型明确地执行标准所说的内容。对于 &&
,它说“...... && 运算符保证 left-to-right 评估......”,所以这确实是抽象计算机在模型中所做的。
但是 C 实现不必按字面意思实现该模型。在“真正实现了什么”层面,而不是在模型层面,C 实现只需匹配模型的 可观察行为 ,即 (C 2018 5.1.2.3 6 ):
- 对volatile对象的访问严格按照抽象机的规则进行评估。
- 在程序终止时,所有写入文件的数据应与根据抽象语义执行程序所产生的结果相同。
- 交互设备的输入和输出动态应按照 7.21.3 中的规定进行。这些要求的目的是尽快出现无缓冲或 line-buffered 输出,以确保提示消息实际出现在程序等待输入之前。
现在假设 C 编译器可以看到 isEmpty
的实现,就像内联它时一样。在这种情况下,它可以看到active && isEmpty(&list)
的所有效果。如果 active && isEmpty(&list)
不包含对易失性对象的访问,也不包含对文件的写入或与设备的交互,那么表达式的实际实现可以是任何顺序,包括 isEmpty
内任何子部分的任何顺序,提供重新排序计算正确的结果。因此,在实际实现中,编译器必须保证 left-to-right 顺序是不正确的;它只需要保证相同的结果。
正确的结果是个问题。也许 active
保证 isEmpty(&list)
的计算不会使用一些空指针,所以 active
需要“首先”计算。但是,如果编译器正在内联 isEmpty
,它可以首先评估 isEmpty
的部分,只要它确保 none 它的评估在 active
为假时会导致问题。
可以阻止此类优化的一件事是 isEmpty
可能会使用编译器没有完整视图的其他函数或对象,因此编译器被迫更字面地实现抽象计算机在某些方面。这又回到了优秀程序员的问题——如果程序的这一部分很重要,那么优秀的程序员会考虑他们在 isEmpty
的代码和编译器功能之间的交互。这可能会在未来几年发生变化。
我知道“短路运算符”这个话题在 StackExchange 上已经讨论了很多。但是 w.r.t 特别是 'C' 语言,有一个方面困扰了我一段时间。
考虑一些 C 代码,例如
if (b && f(&s)) {...}
其中 b 是布尔类型,s 是具有某种或多或少复杂结构类型的变量,f 是结构的 getter。更具体的示例可能如下所示:
if (active && isEmpty(&list)) {...}
通常,我希望任何像样的编译器都会内联 isEmpty
函数并将整个表达式优化为单个 AND
汇编指令第一个操作数已经保存在数据寄存器中,第二个操作数保存在内存中,通过 'address register with offset' 寻址模式(或类似方式)访问。
但是,由于编译器被强制(通过语言规则)生成短路形式,这种优化不能进行,最终会产生非常低效的代码.我很难相信这是真的!
优化的 C 编译器通常会生成什么版本的汇编代码?
是的,它有点低效,但不是因为内联,而是因为指令 re-ordering 是不可能的。如果左操作数最多execution-heavy,还是要先执行
例如考虑 search_algorithm() && bool_flag
,必须始终执行慢速函数,即使 bool 标志检查要快得多,并且如果它的计算结果为零,则整个表达式为假。编译器对此无能为力,我们不得不通过交换操作数来依赖手动优化。
无论函数调用发生在表达式中的何处,都可能发生内联。阻塞的不是内联本身,而是函数中代码的执行。
&&
/||
运算符也可能意味着在机器代码中引入了一个额外的分支,因为 if(a && b)
等同于 if(a) { if(b) { ... } }
.
x86 的内联示例:
#include <stdio.h>
int afunc (int a)
{
puts(__func__);
return a;
}
int bfunc (int b)
{
puts(__func__);
return b;
}
_Bool and (int a, int b)
{
return afunc(a) && bfunc(b);
}
gcc -O3
为 and
函数生成此代码:
afunc:
push r12
mov r12d, edi
mov edi, OFFSET FLAT:__func__.1
call puts
mov eax, r12d
pop r12
ret
bfunc:
push r12
mov r12d, edi
mov edi, OFFSET FLAT:__func__.0
call puts
mov eax, r12d
pop r12
ret
and:
push rbp
mov ebp, esi
push rbx
mov ebx, edi
mov edi, OFFSET FLAT:__func__.1
sub rsp, 8
call puts
xor eax, eax
test ebx, ebx
jne .L12
add rsp, 8
pop rbx
pop rbp
ret
.L12:
mov edi, OFFSET FLAT:__func__.0
call puts
test ebp, ebp
setne al
add rsp, 8
pop rbx
pop rbp
ret
__func__.0:
.string "bfunc"
__func__.1:
.string "afunc"
相关部分是and:
函数:
- 内联函数调用,直接将
"afunc"
对应的字符串地址OFFSET FLAT:__func__.1
加载到一个寄存器中,然后调用puts
. - 它在这里检查
a
的值:test ebx, ebx
然后根据结果引入额外的分支jne .L12
。.L12
是正确的操作数评估。
两个函数都是内联的。它们被执行 left-to-right 并且仅在“跳转不等于零”导致跳转的情况下才评估右操作数,否则函数 returns。 clang 和 icc 给出了几乎相同的机器代码。
What version of assembler code will an optimizing C compiler usually generate?
这是一个不断发展的问题,多年来编译器变得越来越好。优秀的程序员熟悉他们的编译器的功能并编写利用它的代码。因此,如果 active && isEmpty(&list)
与 isEmpty(&list) && active
是一个问题,一个好的程序员会选择更适合他们情况的那个,and/or 他们可能会深入实施 isEmpty
和看看是否可以对此进行改进。
However, since the compiler is enforced (by the language rules) to generate the short circuit form,…
这是真的,也不是真的。 C 标准指定了两个级别的行为。 C 的语义是使用抽象计算机的模型来指定的,该模型明确地执行标准所说的内容。对于 &&
,它说“...... && 运算符保证 left-to-right 评估......”,所以这确实是抽象计算机在模型中所做的。
但是 C 实现不必按字面意思实现该模型。在“真正实现了什么”层面,而不是在模型层面,C 实现只需匹配模型的 可观察行为 ,即 (C 2018 5.1.2.3 6 ):
- 对volatile对象的访问严格按照抽象机的规则进行评估。
- 在程序终止时,所有写入文件的数据应与根据抽象语义执行程序所产生的结果相同。
- 交互设备的输入和输出动态应按照 7.21.3 中的规定进行。这些要求的目的是尽快出现无缓冲或 line-buffered 输出,以确保提示消息实际出现在程序等待输入之前。
现在假设 C 编译器可以看到 isEmpty
的实现,就像内联它时一样。在这种情况下,它可以看到active && isEmpty(&list)
的所有效果。如果 active && isEmpty(&list)
不包含对易失性对象的访问,也不包含对文件的写入或与设备的交互,那么表达式的实际实现可以是任何顺序,包括 isEmpty
内任何子部分的任何顺序,提供重新排序计算正确的结果。因此,在实际实现中,编译器必须保证 left-to-right 顺序是不正确的;它只需要保证相同的结果。
正确的结果是个问题。也许 active
保证 isEmpty(&list)
的计算不会使用一些空指针,所以 active
需要“首先”计算。但是,如果编译器正在内联 isEmpty
,它可以首先评估 isEmpty
的部分,只要它确保 none 它的评估在 active
为假时会导致问题。
可以阻止此类优化的一件事是 isEmpty
可能会使用编译器没有完整视图的其他函数或对象,因此编译器被迫更字面地实现抽象计算机在某些方面。这又回到了优秀程序员的问题——如果程序的这一部分很重要,那么优秀的程序员会考虑他们在 isEmpty
的代码和编译器功能之间的交互。这可能会在未来几年发生变化。