为啥使用三元运算符返回字符串与在等效 if/else 块中返回的代码有很大不同?

Posted

技术标签:

【中文标题】为啥使用三元运算符返回字符串与在等效 if/else 块中返回的代码有很大不同?【英文标题】:Why does using the ternary operator to return a string generate considerably different code from returning in an equivalent if/else block?为什么使用三元运算符返回字符串与在等效 if/else 块中返回的代码有很大不同? 【发布时间】:2020-11-29 07:06:17 【问题描述】:

我在使用 Compiler Explorer 时,偶然发现了三元运算符在使用类似这样的东西时的一个有趣行为:

std::string get_string(bool b)

    return b ? "Hello" : "Stack-overflow";

编译器为此生成的代码(clang trunk, with -O3)是这样的:

get_string[abi:cxx11](bool):                 # @get_string[abi:cxx11](bool)
        push    r15
        push    r14
        push    rbx
        mov     rbx, rdi
        mov     ecx, offset .L.str
        mov     eax, offset .L.str.1
        test    esi, esi
        cmovne  rax, rcx
        add     rdi, 16 #< Why is the compiler storing the length of the string
        mov     qword ptr [rbx], rdi
        xor     sil, 1
        movzx   ecx, sil
        lea     r15, [rcx + 8*rcx]
        lea     r14, [rcx + 8*rcx]
        add     r14, 5 #< I also think this is the length of "Hello" (but not sure)
        mov     rsi, rax
        mov     rdx, r14
        call    memcpy #< Why is there a call to memcpy
        mov     qword ptr [rbx + 8], r14
        mov     byte ptr [rbx + r15 + 21], 0
        mov     rax, rbx
        pop     rbx
        pop     r14
        pop     r15
        ret
.L.str:
        .asciz  "Hello"

.L.str.1:
        .asciz  "Stack-Overflow"

但是,编译器为以下 sn-p 生成的代码要小得多,并且不调用 memcpy,并且不关心同时知道两个字符串的长度。它跳转到 2 个不同的标签

std::string better_string(bool b)

    if (b)
    
        return "Hello";
    
    else
    
        return "Stack-Overflow";
    

编译器为上面的 sn-p (clang trunk with -O3) 生成的代码是这样的:

better_string[abi:cxx11](bool):              # @better_string[abi:cxx11](bool)
        mov     rax, rdi
        lea     rcx, [rdi + 16]
        mov     qword ptr [rdi], rcx
        test    sil, sil
        je      .LBB0_2
        mov     dword ptr [rcx], 1819043144
        mov     word ptr [rcx + 4], 111
        mov     ecx, 5
        mov     qword ptr [rax + 8], rcx
        ret
.LBB0_2:
        movabs  rdx, 8606216600190023247
        mov     qword ptr [rcx + 6], rdx
        movabs  rdx, 8525082558887720019
        mov     qword ptr [rcx], rdx
        mov     byte ptr [rax + 30], 0
        mov     ecx, 14
        mov     qword ptr [rax + 8], rcx
        ret

当我使用三元运算符时,结果相同:

std::string get_string(bool b)

    return b ? std::string("Hello") : std::string("Stack-Overflow");

我想知道为什么第一个示例中的三元运算符会生成该编译器代码。我认为罪魁祸首在于const char[]

P.S:在第一个示例中,GCC 确实调用了 strlen,但 Clang 没有。

编译器资源管理器示例的链接:https://godbolt.org/z/Exqs6G

感谢您的宝贵时间!

对不起,代码墙

【问题讨论】:

三元的结果类型是const char*,而字符串单独是const char[N]s,想必编译器可以对后者进行更多优化 @kmdreko:编译器仍然知道它是一个 const char* 指向两个可能的已知常量字符串文字之一。这就是为什么 clang 能够在无分支版本中避免 strlen 的原因。 (GCC 错过了优化)。甚至 clang 的无分支版本也没有得到很好的优化;可能会更好,例如2x cmov 在常量之间进行选择,也许是 cmov 来选择要存储的偏移量。 (所以两个版本都可以进行 2 次部分重叠的 8 字节存储,写入 8 或 14 字节的数据,包括尾随零。)这比调用 memcpy 更好。 或者因为它无论如何都是从内存中加载常量,所以使用 SSE2 movdqa 加载并将布尔值转换为向量掩码以在它们之间进行选择。 (这种优化依赖于编译器知道始终将 16 个字节存储到 retval 对象中是安全的,即使 C++ 源代码可能会留下一些未写入的尾随字节。由于线程安全,发明写入通常对编译器来说是一个很大的禁忌。) 【参考方案1】:

这里的主要区别在于第一个版本是无分支

16 不是这里任何字符串的长度(较长的字符串,NUL,只有 15 个字节长);它是返回对象的偏移量(其地址在 RDI 中传递以支持 RVO),用于指示正​​在使用小字符串优化(注意缺少分配)。长度为 5 或 5+1+8 存储在 R14 中,存储在 std::string 中并传递给 memcpy(连同 CMOVNE 选择的指针)以加载实际的字符串字节。

另一个版本有一个明显的分支(尽管 std::string 结构的一部分已被提升到它上面)并且实际上确实有明确的 5 和 14,但是由于字符串字节已被包含为立即值这一事实而被混淆了(表示为整数)各种大小。

至于为什么这三个等效函数会产生两种不同版本的生成代码,我所能提供的只是优化器是迭代和启发式算法;他们无法独立于起点可靠地找到相同的“最佳”装配。

【讨论】:

值得注意的是,在这种情况下,应该注意内存写入的优化要复杂得多——即使memcpy 是内部固有的,优化器仍然需要推理潜在的副作用写迟早会发生。在第一个 sn-p 中,评估三元表达式,然后 然后 发生写入,在第二个中,写入作为三元表达式评估的一部分发生。 我同意,它不应该,但正如你提到的,因为优化器是迭代和启发式的,......它确实并不奇怪:) 无分支在这里是一条红鲱鱼。于尔根的答案是正确的。区别在于执行选择的类型(std::string vs. char*),以及是否需要使用选择结果调用构造函数。 @cmaster-reinstatemonica:无分支只是在一种情况下对生成的程序集的描述(这有助于理解其他差异)。在所有情况下,构造函数都是完全内联的(“在编译时评估”); return 语句操作数的类型绝不是对生成代码的约束(因为没有任何字符串文字的地址转义)。 如果编译器能够执行常数值传播分析,它应该在所有三种情况下生成完全相同的输出。但事实并非如此。所以,显然,它没有完成分析。显然它被以下事实所抛弃,即在第一种情况下它必须使用两个可能的参数之一构造一个对象,而在其他情况下它需要选择两个不同对象的构造。第一个和最后一个代码示例的行为之间的比较具有指导意义。【参考方案2】:

第一个版本返回一个字符串对象,该对象使用一个非常量表达式初始化,产生一个字符串字面量,因此构造函数的运行与任何其他可变字符串对象一样,因此 memcpy 进行初始化。

其他变体返回一个用字符串字面量初始化的字符串对象或另一个用另一个字符串字面量初始化的字符串对象,两者都可以优化为由不需要 memcpy 的常量表达式构造的字符串对象。

所以真正的答案是:第一个版本在初始化对象之前对 char[] 表达式操作 ?: 运算符,而其他版本对已经初始化的字符串对象进行操作。

其中一个版本是否无分支无关紧要。

【讨论】:

No memcpy 在无分支汇编中也确实需要;这是一个错过的优化与在立即操作数上使用更多 cmov 指令或 SSE2 比较。不过,您的回答确实解释了为什么源代码会引导编译器朝着它前进的方向前进;编译器远非完美。 请注意,在 OP 的 Godbolt 链接中,所有 3 个版本均未注释,godbolt.org/z/597Kzd、return b ? std::string("Hello") : std::string("Stack-Overflow"); 编译为带有 GCC 和 clang 的分支(与 if 版本相同),尽管有机会常量传播以生成 const string 对象。

以上是关于为啥使用三元运算符返回字符串与在等效 if/else 块中返回的代码有很大不同?的主要内容,如果未能解决你的问题,请参考以下文章

为啥在三元运算符中使用“0”会返回第一个值?

三元运算符可以等效于与逻辑运算符的短路吗?

为啥带赋值的三元运算符不返回预期的输出?

三元运算符为啥以及何时返回左值?

为啥 Swift 零合并三元运算符不返回未包装类型?

C# - 为啥我不能在字符串中使用三元运算符? [复制]