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 -O3and 函数生成此代码:

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 的代码和编译器功能之间的交互。这可能会在未来几年发生变化。