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

我最近看到我的一个同事使用 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.comdata()方法返回一个指向字符串内部管理的缓冲区的 const char*:

const char* data() const noexcept;

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

8805 次浏览

从 C + + 17开始,data可以返回一个非常数 char *

草案 n4659在[ string.accors ]处声明:

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()};
}

它甚至在字符串中处理 \0

顺便说一句,除非内容实际上是文本,我更喜欢 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不是用来作为缓冲区的,你需要仔细检查类的描述,以确保没有“陷阱”来阻止特定的使用模式(或者让它们触发未定义行为)。
  • 举个具体的例子: 在 c + + 17之前,你通过使用 data()得到的指针 都不会写字-它是 const Tchar *,所以你的代码会导致未定义行为。(但是 &(str[0])&(str.front())或者 &(*(str.begin()))会起作用。)
  • 使用 std::string作为缓冲区会使函数定义的读者感到困惑,因为他们认为您将使用 std::string作为字符串。换句话说,这样做会破坏 最小惊讶原则
  • 更糟糕的是,无论谁可能 使用你的函数都会感到困惑——他们也可能认为你返回的是一个字符串,也就是有效的人类可读的文本。
  • std::unique_ptr 对于您的情况来说是可以的,甚至 std::vector也可以。在 C + + 17中,也可以对元素类型使用 std::byte。一个更复杂的选项是具有类似于 SSO的特性的类,例如 Boost 的 small_vector(谢谢@Gast128提到它)。
  • (次要观点:) libstdc + + 必须改变 std::string的 ABI 以符合 C + + 11标准,所以在某些情况下(现在看来是不太可能的) ,你可能会遇到一些链接或运行时 问题,你不会为你的缓冲区使用不同的类型。

此外,您的代码可以进行两次堆分配,而不是一次堆分配(依赖于实现) : 一次在字符串构造时进行,另一次在 resize()ing 时进行。但是这本身并不是避免 std::string的真正原因,因为您可以使用 @ Jarod42的回答中的构造来避免双重分配。

对常量字符指针进行 Memcpy 处理?AFAIK 只要我们知道自己在做什么,这并没有什么坏处,但这是好的行为吗? 为什么?

当前代码可能有未定义行为,这取决于 C + + 版本。为了避免 C + + 14及以下的未定义行为,请使用第一个元素的地址。它生成一个非常量指针:

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

我最近看到我的一个同事使用 std::string作为缓冲器..。

这在以前的代码中比较常见,尤其是 circa C + + 03。使用这样的字符串有几个好处和坏处。取决于您使用代码所做的事情,std::vector可能有点无力,有时您使用字符串代替,并接受了 char_traits的额外开销。

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

我认为你真正想要/需要的是 SGI 的 绳索课,但它从未进入 STL。看起来海湾合作委员会的 Libstdc + + 可以提供。


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

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

我很确定在海湾合作委员会是不安全的。我曾经在一些自我测试中做过类似的事情,结果导致了一个错误:

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的引用。

对我来说,有两个要点是,不要编写半吊子的自我测试,不要试图使 data()成为一个非常数指针。

我在这里要研究的最大的优化机会是: Receiver似乎是某种支持 .data().size()的容器。如果您可以使用它,并将它作为右值引用 Receiver&&传递进来,那么您就可以使用 move 语义,而不需要复制任何副本!如果它有一个迭代器接口,您可以将其用于基于范围的构造函数或 <algorithm>中的 std::move()

在 C + + 17中(正如 Serge Ballesta 和其他人提到的) ,std::string::data()返回一个指向非常数数据的指针。多年来,std::string一直保证连续存储其所有数据。

编写的代码闻起来有点臭,尽管这并不是程序员的错: 那些黑客在当时是必要的。现在,您至少应该将 dst_ptr的类型从 const char*更改为 char*,并将第一个参数中的强制转换删除为 memcpy()。您也可以 reserve()一个字节数的缓冲区,然后使用一个 STL 函数来移动数据。

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

缺点之一是性能。 Resize 方法将默认将所有新字节位置初始化为0。 如果要用其他数据覆盖0,那么这种初始化是不必要的。

我确实觉得 std::string是一个合法的 竞争者管理“缓冲区”,它是否是最好的选择取决于一些事情..。

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

决策的一个主要输入应该是缓冲区内容本质上是否为 原文。如果将 std::string用于文本内容,那么对于代码的读者来说,可能不会那么容易混淆。

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

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

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

首先是一些好处:

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

  • 大多数实现都采用短字符串优化(short-string Optimation,SSO) ,它确保如果字节数足够小,可以直接放入字符串对象中,就可以避免动态分配/释放(但是每次访问数据时可能会有一个额外的分支)

    • 这对于传递读或写的数据副本可能更有用,而不是对于应该预先调整大小以接受可用数据块的缓冲区(通过一次处理更多 I/O 来提高吞吐量)
  • 有大量的 std::string成员函数和非成员函数可以很好地与 std::string协同工作(包括 cout << my_string) : 如果你的客户端代码发现它们对解析/操作/处理缓冲区内容很有用,那么你就可以开始了

  • API 对于大多数 C + + 程序员来说是非常熟悉的

喜忧参半

  • 作为一个熟悉的,无处不在的类型,您交互的代码可能有专门的 std::string,更适合您使用缓冲数据,或者那些专业可能更糟: 做评估

关心

正如 Waxrat 所观察到的,缺乏 API 智慧的是有效增长缓冲区的能力,因为 resize()在添加的字符中写入 NULs/’0,如果你准备在内存中“接收”值,这是没有意义的。这与运营商代码无关,因为在运营商代码中,接收数据的副本正在生成,并且大小已经知道。

讨论

解决恩波克拉姆的担忧:

std::string不是用来作为缓冲区的,你需要仔细检查类的描述,以确保没有“陷阱”来阻止特定的使用模式(或者让它们触发未定义行为)。

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

替代品

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

  • 使用 std::unique_ptr手动分配的缓冲区来自动销毁: 让您精确地调整大小,并自己跟踪分配的和正在使用的大小; 总体上更容易出错

  • std::vector(可能是元素类型的 std::byte; 被广泛理解为意味着二进制数据,但是 API 更具限制性,而且(不管是好是坏)它不能指望有任何等同于短串优化的东西。

  • Boost 的 small_vector: 也许,如果 SSO 是唯一一个阻止你使用 std::vector的东西,并且你很高兴使用 Boost。

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

使用 C + + 23的 string::resize_and_overwrite

Https://en.cppreference.com/w/cpp/string/basic_string/resize_and_overwrite

Https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p1072r10.html

[[nodiscard]] static inline string formaterr (DWORD errcode) {
string strerr;
    

strerr.resize_and_overwrite(2048, [errcode](char* buf, size_t buflen) {
// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-formatmessage
return FormatMessageA(
FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr,
errcode,
0,
buf,
static_cast<DWORD>(buflen),
nullptr
);
});
    

return strerr;
}