为什么 C++11 使 std::string::data() 添加一个空终止符?
Why did C++11 make std::string::data() add a null terminating character?
以前这是 std::string::c_str()
的工作,但从 C++11 开始,data()
也提供了它,为什么要将 c_str()
的空终止字符添加到std::string::data()
?对我来说,这似乎是在浪费 CPU 周期,在空终止字符根本不相关且仅使用 data()
的情况下,C++03 编译器不必关心终止符,并且不必在每次调整字符串大小时都将 0 写入终止符,但是 C++11 编译器,由于 data()
-null-guarantee,必须浪费周期写入 0每次调整字符串大小时,由于它可能会使代码变慢,我猜他们有某种理由添加该保证,它是什么?
这里有两点需要讨论:
Space 为 null-terminator
理论上,C++03 实现 可以 避免为终止符分配 space and/or 可能需要执行复制(例如 unsharing).
但是,所有理智的实现都为 null-terminator 分配了空间,以便支持 c_str()
开始,否则如果这不是一个微不足道的调用,它实际上将无法使用。
null-terminator本身
确实有人 very (1999), very old implementations (2001) 在每次 c_str()
调用时都写了 [=11=]
。
但是,主要实现 changed (2004) or were already like that (2010) 是为了在 C++11 发布之前避免这种情况,所以当新标准出现时,对于许多用户来说没有任何改变。
现在,C++03 实现是否应该这样做:
To me it seems like a waste of CPU cycles
不是真的。如果您不止一次调用 c_str()
,那么您已经通过多次写入来浪费周期。不仅如此,您还弄乱了缓存层次结构,这在多线程系统中很重要。回想一下,multi-core/SMT CPU 开始出现在 2001 and 2006 之间,这解释了向现代 non-CoW 实现的转变(即使在此之前的几十年有 multi-CPU 系统)。
唯一可以保存任何东西的情况是你从不调用c_str()
。但是,请注意,当您是 re-sizing 字符串时,无论如何您都是 re-writing 一切。一个额外的字节将很难测量。
换句话说,如果不在re-size上写终结符,你就会让自己暴露在更糟糕的环境中performance/latency。通过同时写入 一次,您必须执行字符串的副本,性能行为更可预测,并且如果您最终使用 c_str()
则可以避免性能陷阱,特别是在多线程系统上。
题目前提有问题。
一个字符串 class 必须做很多扩展性的事情,比如分配动态内存、将字节从一个缓冲区复制到另一个缓冲区、释放底层内存等等。
让您不高兴的是一条糟糕的 mov
汇编指令?相信我,这不会影响你的表现,即使是 0.5%。
编写程序语言运行库时,不能对每一条细小的汇编指令都执着。你必须明智地选择你的优化战斗,优化 un-noticable 空终止不是其中之一。
在这种特定情况下,与 C 兼容比空终止更重要。
改动的好处:
当 data
也保证空终止符时,程序员不需要知道 c_str
和 data
之间差异的模糊细节,因此会避免将不保证空终止的字符串传递给需要空终止的函数的未定义行为。这样的函数在C接口中无处不在,C++中也大量使用C接口
下标运算符也已更改为允许读取 str[str.size()]
。不允许访问 str.data() + str.size()
是不一致的。
虽然在调整大小等时不初始化空终止符可能会使该操作更快,但它会强制在 c_str
中进行初始化,这会使该函数变慢¹。被删除的优化案例并不是普遍更好的选择。考虑到第 2 点中提到的变化,这种缓慢也会影响下标运算符,这对于性能来说肯定是不可接受的。因此,空终止符无论如何都会在那里,因此保证它存在不会有任何缺点。
奇怪的细节:str.at(str.size())
仍然抛出异常。
P.S。还有一个变化,那就是保证字符串有连续存储(这就是为什么首先提供 data
的原因)。在 C++11 之前,实现可以使用绳索字符串,并在调用 c_str
时重新分配。没有主要的实现选择利用这种自由(据我所知)。
P.P.S 例如,GCC 的 libstdc++ 的旧版本显然只在 c_str
中设置了空终止符,直到版本 3.4。有关详细信息,请参阅 related commit。
¹ 其中一个因素是在 C++11 中引入语言标准的并发性。并发 non-atomic 修改是 data-race 未定义的行为,这就是允许 C++ 编译器积极优化并将内容保存在寄存器中的原因。因此,用普通 C++ 编写的库实现将具有用于并发调用 .c_str()
的 UB
在实践中(见评论)有多个线程写入相同的不会导致正确性问题,因为真正的 CPU 的 asm 没有 UB。而 C++ UB 规则意味着多个线程实际上 修改 一个 std::string
对象(而不是调用 c_str()
)而没有同步是编译器 + 库可以假定不会发生的事情发生了。
但它会弄脏缓存并阻止其他线程读取它,因此仍然是一个糟糕的选择,尤其是对于可能具有并发读取器的字符串。此外,由于商店 side-effect.
,它还会阻止 .c_str()
基本上优化掉
其实恰恰相反
在 C++11 之前,c_str()
理论上可能有成本 "additional cycles" 以及一个副本,以确保缓冲区末尾存在空终止符。
这很不幸,特别是因为它可以非常简单地修复,实际上没有额外的运行时成本,只需在每个缓冲区的末尾合并一个空字节即可。只需分配一个额外的字节(和一个很小的写入),在使用时没有运行时成本,以换取线程安全和大量的理智。
完成后,根据定义,c_str()
与 data()
完全相同。所以,"change" 到 data()
实际上是免费的。没有人向 data()
的结果添加额外的字节;它已经存在了。
有用的事实是大多数实现已经在 C++03 下完成了这个,以避免归因于 c_str()
的假设运行时成本。
所以,简而言之,这几乎肯定不会让您付出任何代价。
以前这是 std::string::c_str()
的工作,但从 C++11 开始,data()
也提供了它,为什么要将 c_str()
的空终止字符添加到std::string::data()
?对我来说,这似乎是在浪费 CPU 周期,在空终止字符根本不相关且仅使用 data()
的情况下,C++03 编译器不必关心终止符,并且不必在每次调整字符串大小时都将 0 写入终止符,但是 C++11 编译器,由于 data()
-null-guarantee,必须浪费周期写入 0每次调整字符串大小时,由于它可能会使代码变慢,我猜他们有某种理由添加该保证,它是什么?
这里有两点需要讨论:
Space 为 null-terminator
理论上,C++03 实现 可以 避免为终止符分配 space and/or 可能需要执行复制(例如 unsharing).
但是,所有理智的实现都为 null-terminator 分配了空间,以便支持 c_str()
开始,否则如果这不是一个微不足道的调用,它实际上将无法使用。
null-terminator本身
确实有人 very (1999), very old implementations (2001) 在每次 c_str()
调用时都写了 [=11=]
。
但是,主要实现 changed (2004) or were already like that (2010) 是为了在 C++11 发布之前避免这种情况,所以当新标准出现时,对于许多用户来说没有任何改变。
现在,C++03 实现是否应该这样做:
To me it seems like a waste of CPU cycles
不是真的。如果您不止一次调用 c_str()
,那么您已经通过多次写入来浪费周期。不仅如此,您还弄乱了缓存层次结构,这在多线程系统中很重要。回想一下,multi-core/SMT CPU 开始出现在 2001 and 2006 之间,这解释了向现代 non-CoW 实现的转变(即使在此之前的几十年有 multi-CPU 系统)。
唯一可以保存任何东西的情况是你从不调用c_str()
。但是,请注意,当您是 re-sizing 字符串时,无论如何您都是 re-writing 一切。一个额外的字节将很难测量。
换句话说,如果不在re-size上写终结符,你就会让自己暴露在更糟糕的环境中performance/latency。通过同时写入 一次,您必须执行字符串的副本,性能行为更可预测,并且如果您最终使用 c_str()
则可以避免性能陷阱,特别是在多线程系统上。
题目前提有问题。
一个字符串 class 必须做很多扩展性的事情,比如分配动态内存、将字节从一个缓冲区复制到另一个缓冲区、释放底层内存等等。
让您不高兴的是一条糟糕的 mov
汇编指令?相信我,这不会影响你的表现,即使是 0.5%。
编写程序语言运行库时,不能对每一条细小的汇编指令都执着。你必须明智地选择你的优化战斗,优化 un-noticable 空终止不是其中之一。
在这种特定情况下,与 C 兼容比空终止更重要。
改动的好处:
当
data
也保证空终止符时,程序员不需要知道c_str
和data
之间差异的模糊细节,因此会避免将不保证空终止的字符串传递给需要空终止的函数的未定义行为。这样的函数在C接口中无处不在,C++中也大量使用C接口下标运算符也已更改为允许读取
str[str.size()]
。不允许访问str.data() + str.size()
是不一致的。虽然在调整大小等时不初始化空终止符可能会使该操作更快,但它会强制在
c_str
中进行初始化,这会使该函数变慢¹。被删除的优化案例并不是普遍更好的选择。考虑到第 2 点中提到的变化,这种缓慢也会影响下标运算符,这对于性能来说肯定是不可接受的。因此,空终止符无论如何都会在那里,因此保证它存在不会有任何缺点。
奇怪的细节:str.at(str.size())
仍然抛出异常。
P.S。还有一个变化,那就是保证字符串有连续存储(这就是为什么首先提供 data
的原因)。在 C++11 之前,实现可以使用绳索字符串,并在调用 c_str
时重新分配。没有主要的实现选择利用这种自由(据我所知)。
P.P.S 例如,GCC 的 libstdc++ 的旧版本显然只在 c_str
中设置了空终止符,直到版本 3.4。有关详细信息,请参阅 related commit。
¹ 其中一个因素是在 C++11 中引入语言标准的并发性。并发 non-atomic 修改是 data-race 未定义的行为,这就是允许 C++ 编译器积极优化并将内容保存在寄存器中的原因。因此,用普通 C++ 编写的库实现将具有用于并发调用 .c_str()
在实践中(见评论)有多个线程写入相同的不会导致正确性问题,因为真正的 CPU 的 asm 没有 UB。而 C++ UB 规则意味着多个线程实际上 修改 一个 std::string
对象(而不是调用 c_str()
)而没有同步是编译器 + 库可以假定不会发生的事情发生了。
但它会弄脏缓存并阻止其他线程读取它,因此仍然是一个糟糕的选择,尤其是对于可能具有并发读取器的字符串。此外,由于商店 side-effect.
,它还会阻止.c_str()
基本上优化掉
其实恰恰相反
在 C++11 之前,c_str()
理论上可能有成本 "additional cycles" 以及一个副本,以确保缓冲区末尾存在空终止符。
这很不幸,特别是因为它可以非常简单地修复,实际上没有额外的运行时成本,只需在每个缓冲区的末尾合并一个空字节即可。只需分配一个额外的字节(和一个很小的写入),在使用时没有运行时成本,以换取线程安全和大量的理智。
完成后,根据定义,c_str()
与 data()
完全相同。所以,"change" 到 data()
实际上是免费的。没有人向 data()
的结果添加额外的字节;它已经存在了。
有用的事实是大多数实现已经在 C++03 下完成了这个,以避免归因于 c_str()
的假设运行时成本。
所以,简而言之,这几乎肯定不会让您付出任何代价。