为什么将字符串初始化为 “” 比默认构造函数更有效?

Why is initializing a string to "" more efficient than the default constructor?

提问人:Jan Schultke 提问时间:6/26/2023 更新时间:9/23/2023 访问量:4479

问:

通常,默认构造函数应该是创建空容器的最快方法。 这就是为什么我惊讶地发现它比初始化为空字符串文字更糟糕:

#include <string>

std::string make_default() {
    return {};
}

std::string make_empty() {
    return "";
}

这编译为:(clang 16, libc++)

make_default():
        mov     rax, rdi
        xorps   xmm0, xmm0
        movups  xmmword ptr [rdi], xmm0
        mov     qword ptr [rdi + 16], 0
        ret
make_empty():
        mov     rax, rdi
        mov     word ptr [rdi], 0
        ret

请参阅 Compiler Explorer 中的实时示例

请注意,返回总共将 24 个字节归零,但返回仅将 2 个字节归零。为什么会好得多?{}""return "";

C clang 编译器优化 stdstring libc++

评论

3赞 Nelson 6/26/2023
我质疑“高效”到底是什么意思。人们认为拥有大量可用RAM的计算机是“好的”,但这是愚蠢的。可用 RAM 是未使用的 RAM,坐在那里不做任何事情。RAM 的最佳状态是它正在被使用,但很容易用于要求更高的应用程序。
6赞 Jan Schultke 6/27/2023
@Nelson在这两种情况下,我们使用的内存量是相同的。对于我们的架构,A 在 libc++ 中总是占用 24 个字节。函数之间的区别在于,这些内存中有更多的内存不确定,而将其全部归零。std::stringmake_empty()make_default()
2赞 Stef 6/27/2023
我知道的还不够多,无法写出答案,但 和 之间的这种差异看起来与 C 非常相似。在 C 语言中,如果你编写 ,你会得到一个 100 个字符的数组,其中前 6 个元素,其余 94 个元素未初始化。但是如果你写 ,剩下的 94 个元素被初始化为 0。{}""char s[100] = "Hello";'h', 'e', 'l', 'l', 'o', '\0'char s[100] = {'h', 'e', 'l', 'l', 'o', '\0'};

答:

52赞 Jan Schultke 6/26/2023 #1

这是 libc++ 实现 时有意做出的决定。std::string

首先,有所谓的小字符串优化(SSO),这意味着对于非常短(或空)的字符串,它将直接将其内容存储在容器内,而不是分配动态内存。 这就是为什么我们在这两种情况下都看不到任何分配的原因。std::string

在 libc++ 中,a 的“短表示”包括:std::string

尺寸 (x86_64) 意义
1 位 “short flag”表示它是短字符串(零表示是)
7 位 字符串的长度,不包括 null 终止符
0 字节 填充字节以对齐字符串数据(无basic_string<char>)
23 字节 字符串数据,包括 null 终止符

对于空字符串,我们只需要存储两个字节的信息:

  • 一个零字节用于“短标志”和长度
  • 一个零字节用于 null 终止符

接受 a 的构造函数将只写入这两个字节,这是最小的字节。 默认构造函数“不必要地”将包含的所有 24 个字节归零。不过,总体上这可能更好,因为它使编译器可以发出 std::memset 或其他 SIMD 并行方式批量清零字符串数组。const char*std::string

有关完整说明,请参见下文:

初始化为/调用""string(const char*)

为了理解会发生什么,让我们看一下 std::basic_string 的 libc++ 源代码

// constraints...
/* specifiers... */ basic_string(const _CharT* __s)
  : /* leave memory indeterminate */ {
    // assert that __s != nullptr
    __init(__s, traits_type::length(__s));
    // ...
  }

这最终调用 ,其中 是字符串的长度,从:__init(__s, 0)0std::char_traits<char>

// template head etc...
void basic_string</* ... */>::__init(const value_type* __s, size_type __sz)
{
    // length and constexpr checks
    pointer __p;
    if (__fits_in_sso(__sz))
    {
        __set_short_size(__sz); // set size to zero, first byte
        __p = __get_short_pointer();
    }
    else
    {
        // not entered
    }
    traits_type::copy(std::__to_address(__p), __s, __sz); // copy string, nothing happens
    traits_type::assign(__p[__sz], value_type()); // add null terminator
}

__set_short_size最终只会写入一个字节,因为字符串的简短表示形式是:

struct __short
{
    struct _LIBCPP_PACKED {
        unsigned char __is_long_ : 1; // set to zero when active
        unsigned char __size_ : 7;    // set to zero for empty string
    };
    char __padding_[sizeof(value_type) - 1]; // zero size array
    value_type __data_[__min_cap]; // null terminator goes here
};

编译器优化后,将 、 和 1 个字节编译为:__is_long___size___data_

mov word ptr [rdi], 0

初始化为/调用{}string()

相比之下,默认构造函数更浪费:

/* specifiers... */ basic_string() /* noexcept(...) */
  : /* leave memory indeterminate */ {
    // ...
    __default_init();
}

这最终会调用 ,它这样做:__default_init()

/* specifiers... */ void __default_init() {
    __r_.first() = __rep(); // set representation to value-initialized __rep
    // constexpr-only stuff...
}

a 的值初始化结果为 24 个零字节,因为:__rep()

struct __rep {
    union {
        __long  __l; // first union member gets initialized,
        __short __s; // __long representation is 24 bytes large
        __raw   __r;
    };
};

结论

如果你想为了一致性而到处进行值初始化,不要让它阻止你。不必要地将几个字节归零并不是您需要担心的大性能问题。

事实上,它在初始化大量字符串时很有帮助,因为可能会使用或其他一些 SIMD 方式将内存归零。std::memset

评论

0赞 PatientPenguin 6/26/2023
就你的观点而言,我继续在 Godbolt 中测试了这一点。使用编译器将继续在一定数量的元素(在我的测试中为 11 个)后使用,如下所示std::stringmemset""mov ptr addr, 0
0赞 Jan Schultke 6/26/2023
@PatientPenguin我也得到了类似的结果。我不确定 clang 是否选择将其转换为该版本的循环。即使在 1024 个字符串时,它也只会发出 1024 条指令:godbolt.org/z/5x6E7rz1s。这可能与 更相关,它会在调整大小时手动开始循环中的生存期。在这里演示: godbolt.org/z/Y7xW4j7E7""movstd::vector
4赞 Matt 6/26/2023
另一个实验 godbolt.org/z/rjzTvvrPa 它似乎证明了你的观点,即默认构造函数在初始化时如何更有效。std::vector<std::string>