使用 std::string 作为缓冲区有缺点吗?

Are there downsides to using std::string as a buffer?

我最近看到我的一位同事使用 std::string 作为缓冲区:

std::string receive_data(const Receiver& receiver) {
  std::string buff;
  int size = receiver.size();
  if (size > 0) {
    buff.resize(size);
    const char* dst_ptr = buff.data();
    const char* src_ptr = receiver.data();
    memcpy((char*) dst_ptr, src_ptr, size);
  }
  return buff;
}

我猜这家伙想利用返回字符串的自动销毁,这样他就不必担心释放分配的缓冲区。

这对我来说有点奇怪,因为根据 cplusplus.com data() 方法 returns 一个 const char* 指向由字符串内部管理的缓冲区:

const char* data() const noexcept;

Memcpy 到 const char 指针? AFAIK 只要我们知道我们在做什么,这就没有害处,但我错过了什么吗?这很危险吗?

从 C++17 开始,data 可以 return 一个非常量 char *

n4659 草案声明于 [string.accessors]:

const charT* c_str() const noexcept;
const charT* data() const noexcept;
....
charT* data() noexcept;

您可以通过调用适当的构造函数完全避免手动 memcpy

std::string receive_data(const Receiver& receiver) {
    return {receiver.data(), receiver.size()};
}

甚至可以处理字符串中的 [=12=]

顺便说一句,除非内容实际上是文本,否则我更喜欢 std::vector<std::byte>(或等效)。

代码是不必要的,考虑到

std::string receive_data(const Receiver& receiver) {
    std::string buff;
    int size = receiver.size();
    if (size > 0) {
        buff.assign(receiver.data(), size);
    }
    return buff;
}

将完全相同。

不要使用 std::string 作为缓冲区。

使用 std::string 作为缓冲区是一种不好的做法,原因有几个(排名不分先后):

  • std::string 并非旨在用作缓冲区;您需要 double-check class 的描述以确保没有 "gotchas" 会阻止某些使用模式(或使它们触发未定义的行为)。
  • 举个具体的例子:在 C++17 之前,你 can't even write 通过你用 data() 得到的指针——它是 const Tchar *;所以你的代码会导致未定义的行为。 (但是 &(str[0])&(str.front())&(*(str.begin())) 会起作用。)
  • std::string 用于缓冲区会使阅读函数定义的读者感到困惑,他们认为您将使用 std::string 来表示字符串。换句话说,这样做会破坏 Principle of Least Astonishment.
  • 更糟糕的是,这会让可能 使用 你的函数的人感到困惑 - 他们也可能认为你返回的是一个字符串,即有效的 human-readable 文本。
  • std::unique_ptr would be fine for your case, or even std::vector. In C++17, you can use std::byte for the element type, too. A more sophisticated option is a class with an SSO-like feature, e.g. Boost's small_vector(谢谢 @gast128 提到它)。
  • (次要点:)libstdc++ 必须更改其 ABI std::string 以符合 C++11 标准,因此在某些情况下(现在不太可能),您可能 运行 进入一些链接或 运行 时间 issues,你不会为你的缓冲区使用不同的类型。

此外,您的代码可能会进行两次而不是一次堆分配(取决于实现):一次是字符串构造,一次是 resize()ing。但这本身并不是避免 std::string 的真正原因,因为您可以使用 .

中的构造来避免双重分配

Memcpy-ing to a const char pointer? AFAIK this does no harm as long as we know what we do, but is this good behavior and why?

当前代码可能有未定义的行为,具体取决于 C++ 版本。为避免 C++14 及以下版本中的未定义行为,请获取第一个元素的地址。它产生一个 non-const 指针:

buff.resize(size);
memcpy(&buff[0], &receiver[0], size);

I have recently seen a colleague of mine using std::string as a buffer...

这在旧代码中有些常见,尤其是大约 C++03。使用这样的字符串有几个优点和缺点。根据您对代码所做的操作,std::vector 可能有点贫血,您有时会使用字符串代替并接受 char_traits.

的额外开销

例如,std::string 通常是比 std::vector 追加更快的容器,并且您不能从函数中 return std::vector。 (或者在 C++98 中你不能在实践中这样做,因为 C++98 要求在函数中构造向量并复制出来)。此外,std::string 允许您使用更丰富的成员函数进行搜索,例如 find_first_offind_first_not_of。这在搜索字节数组时很方便。

我想你真正want/need是SGI的Rope class, but it never made it into the STL. It looks like GCC's libstdc++可能会提供。


关于这在 C++14 及以下版本中是否合法的讨论很长:

const char* dst_ptr = buff.data();
const char* src_ptr = receiver.data();
memcpy((char*) dst_ptr, src_ptr, size);

我确定它在 GCC 中不安全。我曾经在一些自我测试中做过这样的事情,结果导致了段错误:

std::string buff("A");
...

char* ptr = (char*)buff.data();
size_t len = buff.size();

ptr[0] ^= 1;  // tamper with byte
bool tampered = HMAC(key, ptr, len, mac);

GCC 将单个字节 'A' 放入寄存器 AL。高 3 字节是垃圾,所以 32 位寄存器是 0xXXXXXX41。当我在 ptr[0] 处取消引用时,GCC 取消引用垃圾地址 0xXXXXXX41.

我的两个take-aways是,不要写half-ass自我测试,也不要试图使data()成为non-const指针。

我在这里要研究的最大优化机会是:Receiver 似乎是某种支持 .data().size() 的容器。如果您可以使用它,并将其作为右值引用 Receiver&& 传递,您也许可以使用移动语义,而无需制作任何副本!如果它有一个迭代器接口,您可以将它们用于 range-based 构造函数或 <algorithm>.

中的 std::move()

在 C++17 中(如 Serge Ballesta 和其他人所提到的),std::string::data() returns 指向 non-const 数据的指针。 std::string 已保证连续存储其所有数据多年。

写的代码有点味道,虽然这并不是程序员的错:那些 hack 在当时是必要的。今天,您至少应该将 dst_ptr 的类型从 const char* 更改为 char*,并将第一个参数中的类型转换为 memcpy()。您还可以 reserve() 缓冲区的字节数,然后使用 STL 函数移动数据。

正如其他人所提到的,std::vectorstd::unique_ptr 将是更自然的数据结构。

一个缺点是性能。 .resize 方法会将 default-initialize 所有新字节位置设为 0。 如果您随后要用其他数据覆盖 0,则不需要进行初始化。

我确实觉得 std::string 是管理“缓冲区”的合法 竞争者;它是否是最佳选择取决于一些因素...

您的缓冲区内容本质上是文本的还是二进制的?

您的决定的一个主要输入应该是缓冲区内容是否本质上是文本。如果将 std::string 用于文本内容,代码的读者可能不会感到困惑。

char 不是存储字节的好类型。 请记住,C++ 标准让每个实现决定是否 char将被签名或未签名,但对于二进制数据的通用黑盒处理(有时甚至将字符传递给具有未定义行为的函数 std::toupper(int) 时,除非参数在 unsigned char 范围内或等于EOF) 你可能想要无符号数据:如果它是不透明的二进制数据,你为什么要假设或暗示每个字节的第一位是符号位?

正因为如此,不可否认,将 std::string 用于“二进制”数据 有点 hackish。您可以使用 std::basic_string<std::byte>,但这不是问题所要问的,并且您会因使用无处不在的 std::string 类型而失去一些不可操作性的好处。

使用 std::string 的一些潜在好处

首先是几个好处:

  • 它体现了我们都知道和喜爱的 RAII 语义

  • 大多数实现都具有短字符串优化 (SSO) 功能,这确保如果字节数小到足以直接放入字符串对象,则可以避免动态 allocation/deallocation(但每次访问数据时可能会多出一个分支)

    • 这对于传递读取或写入的数据副本可能更有用,而不是对于缓冲区,缓冲区应预先调整大小以在可用时接受合适的数据块(通过处理更多 [来提高吞吐量) =128=]一次)
  • 有大量的 std::string 成员函数和非成员函数,旨在与 std::strings(包括例如 cout << my_string)配合使用:如果您客户端代码会发现它们对 parse/manipulate/process 缓冲区内容很有用,然后你就可以顺利开始了

  • 大多数 C++ 程序员都非常熟悉 API

喜忧参半

  • 作为一种熟悉的、无处不在的类型,您与之交互的代码可能具有针对 std::string 的特化,更适合您对缓冲数据的使用,或者这些特化可能更差:评估

关心

正如 Waxrat 所观察到的,API 明智地缺乏有效增加缓冲区的能力,因为 resize() 将 NULs/'\0's 写入添加的字符,如果你即将“接收”值到该内存中。这与正在制作接收数据副本且大小已知的 OP 代码无关。

讨论

解决 einpoklum 的问题:

std::string was not intended for use as a buffer; you would need to double-check the description of the class to make sure there are no "gotchas" which would prevent certain usage patterns (or make them trigger undefined behavior).

虽然 std::string 最初并非为此目的,但其余的主要是 FUD。标准通过 C++17 的非 const 成员函数 char* data() 对这种用法做出了让步,并且 string 始终支持嵌入的零字节。大多数高级程序员都知道做什么是安全的。

备选方案

  • 静态缓冲区(C char[N] 数组或 std::array<char, N>)大小为某个最大消息大小,或每次调用传送数据片段

  • 手动分配缓冲区 std::unique_ptr 以自动销毁:让您进行精细的大小调整,并自行跟踪分配的大小与使用中的大小;总体上更容易出错

  • std::vector(对于元素类型可能是 std::byte;被广泛理解为暗示二进制数据,但 API 更具限制性并且(为了更好或更糟)它不能期望有任何等同于短字符串优化的东西。

  • Boost 的 small_vector:也许,如果 SSO 是唯一阻碍您使用 std::vector 的因素,并且您很乐意使用 boost。

  • 返回一个允许延迟访问接收到的数据的仿函数(前提是您知道它不会被释放或覆盖),推迟客户端代码选择如何存储它