不必要清空移出的 std::string

Unnecessary emptying of moved-from std::string

libstdc++ 和 libc++ 都使从 std::string 移出的对象为空,即使原始存储的字符串很短并且应用了短字符串优化。在我看来,这种清空会造成 额外且不必要的运行时开销 。例如,这里是来自 libstdc++ 的 std::basic_string 的移动构造函数:

basic_string(basic_string&& __str) noexcept
  : _M_dataplus(_M_local_data(), std::move(__str._M_get_allocator())) {
    if (__str._M_is_local()) 
      traits_type::copy(_M_local_buf, __str._M_local_buf, _S_local_capacity + 1);
    else {
      _M_data(__str._M_data());
      _M_capacity(__str._M_allocated_capacity);
    }
    _M_length(__str.length());
    __str._M_data(__str._M_local_data());  // (1)
    __str._M_set_length(0);                // (2)
  }

(1) 是一个赋值,在短字符串 的情况下 无用,因为 data 已经设置为 本地数据,所以我们只需为一个指针分配与之前分配相同的值。

(2) 清空字符串设置字符串大小并重置本地缓冲区中的第一个字符,据我所知,标准不要求

通常,库实现者会尝试尽可能高效地实现标准(例如,已删除的内存区域不会清零)。 我的问题是是否有任何特殊原因可以清空移出的字符串,即使它不是必需的并且增加了不必要的开销。其中,可以很容易地消除,例如:

basic_string(basic_string&& __str) noexcept
  : _M_dataplus(_M_local_data(), std::move(__str._M_get_allocator())) {
    if (__str._M_is_local()) {
      traits_type::copy(_M_local_buf, __str._M_local_buf, _S_local_capacity + 1);
      _M_length(__str.length());
    }
    else {
      _M_data(__str._M_data());
      _M_capacity(__str._M_allocated_capacity);
      _M_length(__str.length());
      __str._M_data(__str._M_local_data());  // (1)
      __str._M_set_length(0);                // (2)
    }
  }

在libc++的情况下,字符串移动构造函数确实清空了源代码,但并非没有必要。事实上,这个字符串实现的作者就是领导 C++11 移动语义提案的人。 ;-)

这个libc++字符串的实现其实是从move成员向外设计的!

这是省略了一些不必要的细节(如调试模式)代码的代码:

template <class _CharT, class _Traits, class _Allocator>
basic_string<_CharT, _Traits, _Allocator>::basic_string(basic_string&& __str)
        _NOEXCEPT
    : __r_(_VSTD::move(__str.__r_))
{
    __str.__zero();
}

简而言之,此代码复制源的所有字节,然后将源的所有字节归零。需要立即注意的一件事:没有分支:此代码对长字符串和短字符串执行相同的操作。

长字符串模式

在"long mode"中,布局是3个字,一个数据指针和两个整数类型来存储大小和容量,long/short标志减去1位。加上分配器的 space(针对空分配器进行了优化)。

所以这会复制 pointer/sizes,然后清空源以释放指针的所有权。这还将源设置为 "short mode",因为 short/long 位表示在零状态下短路。此外,短模式中的所有零位表示一个零大小、非零容量的短字符串。

短字符串模式

当源是一个短字符串时,代码是相同的:字节被复制过来,源字节被清零。在短模式下没有自引用指针,因此复制字节是正确的算法。

现在确实 "short mode" 中的 3 个词的归零可能 似乎 没有必要,但要做到这一点必须检查long/short 位和零字节,在长模式下。由于偶尔的分支预测错误(破坏管道),执行此检查和分支实际上比仅将 3 个单词归零更昂贵。

这是为 libc++ string 移动构造函数优化的 x86(64 位)程序集。

std::string
test(std::string& s)
{
    return std::move(s);
}

__Z4testRNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE: ## @_Z4testRNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE
    .cfi_startproc
## %bb.0:
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    movq    16(%rsi), %rax
    movq    %rax, 16(%rdi)
    movq    (%rsi), %rax
    movq    8(%rsi), %rcx
    movq    %rcx, 8(%rdi)
    movq    %rax, (%rdi)
    movq    [=11=], 16(%rsi)
    movq    [=11=], 8(%rsi)
    movq    [=11=], (%rsi)
    movq    %rdi, %rax
    popq    %rbp
    retq
    .cfi_endproc

(没有分支!)

<aside>

短字符串的内部缓冲区大小也针对移动成员进行了优化。内部缓冲区是 "union'ed","long mode" 需要 3 个字,因此 sizeof(string) 不需要比在长模式下更多的 space。尽管如此紧凑 sizeof(3 个主要实现中最小的),libc++ 在 64 位架构上享有最大的内部缓冲区:22 char.

sizeof 转化为更快的移动成员,因为所有这些成员所做的就是复制和零字节的对象布局。

有关内部缓冲区大小的更多详细信息,请参阅 this Whosebug answer

</aside>

总结

所以总而言之,在 "long mode" 中需要将源设置为空字符串以转移指针的所有权,并且 在短模式下也需要 避免管道损坏的性能原因。

我对 libstdc++ 实现没有任何评论,因为我没有编写该代码,而且你的问题已经很好地完成了。 :-)

我知道我在实现 libstdc++ 版本时考虑过是否将移出的字符串置零,但我不记得我决定将其置零的原因。我想我可能决定将移出的字符串留空将遵循最小惊讶原则。移出字符串的大多数 "obvious" 状态是空的,即使 有时 为非空会稍微好一些。

正如评论中所建议的那样,它避免了破坏任何(可能是无意中)依赖于字符串为空的代码。我不认为这是我的考虑之一。依赖于 COW 字符串语义的 C++11 代码不仅会被非空的移动字符串破坏。

值得注意的是,在 -O2,与您建议的替代方案相比,当前的 libstdc++ 代码编译成更少的指令。然而,像这样的东西编译得更小,而且可能更快(虽然我没有测量它,甚至没有测试它是否有效):

  basic_string(basic_string&& __str) noexcept
  : _M_dataplus(_M_local_data(), std::move(__str._M_get_allocator()))
  {
    memcpy(_M_local_buf, __str._M_local_buf, sizeof(_M_local_buf));
    _M_length(__str.length());
    if (!__str._M_is_local())
      {
        _M_data(__str._M_data());
        __str._M_data(__str._M_local_data());
        __str._M_set_length(0);
      }
  }