C++11中COW std::string实现的合法性

130

在我理解中,使用COW(写时复制)方式实现符合C++11标准的std::string是不可行的,但最近讨论到这个话题时,我发现自己无法直接支持这种说法。

我想问一下,C++11是否允许使用基于COW的方式实现std::string

如果不允许,这个限制是否在新标准中明确说明了(在哪里)?

还是这个限制是暗示性的,也就是说,它是新要求对std::string的综合影响,使得基于COW的std::string实现不可能。在这种情况下,我希望能够引用相关章节和文章来证明 'C++11 effectively prohibits COW based std::string implementations'。


5
GCC的COW字符串Bug是http://gcc.gnu.org/bugzilla/show_bug.cgi?id=21334#c45。跟踪libstdc++中新的C++11兼容实现std::string的一个错误是http://gcc.gnu.org/bugzilla/show_bug.cgi?id=53221。 - user7610
7个回答

129

根据标准21.4.1 p6,只有以下情况才允许迭代器/引用失效:

— 作为任何标准库函数的参数,该函数接受对非const basic_string的引用。

— 调用非const成员函数,除了operator[]、at、front、back、begin、rbegin、end和rend。

对于一个COW字符串,调用非const的operator[]将需要进行复制(并使引用失效),这被上述段落所禁止。 因此,在C++11中不再允许有COW字符串。


4
一些论据:N2534 - M.M
8
逻辑不成立。在进行复制操作时,没有任何可能被使无效的引用或迭代器,这也正是执行复制的原因,即获得此类引用或迭代器,所以复制是必要的。但是,C++11可能仍然禁止COW实现。 - Cheers and hth. - Alf
12
如果允许COW,那么逻辑可以从以下代码中看到:std::string a("something"); char& c1 = a[0]; std::string b(a); char& c2 = a[1]; 这时,c1是对a的引用。然后你“复制”了a。接着,当你尝试第二次获取引用时,它必须进行复制以获得一个非const引用,因为有两个指向同一个缓冲区的字符串。这将使得第一次取出的引用失效,并违反了上面引用的部分。 - Dave S
10
根据这份文件,至少GCC的COW实现确实符合DaveS所说的内容。因此,标准禁止了至少这种风格的COW。 - Tavian Barnes
5
这个答案认为非常量operator[]必须制作一份副本,而且这样做是不合法的。你不同意这两点中的哪一个?从你的第一个评论来看,似乎在满足此要求的情况下,实现可以共享该字符串,直到访问它的时候,但是读写访问都需要取消共享。这是你的推理吗? - Ben Voigt
显示剩余11条评论

56

Dave Sgbjbaanb的回答是正确的。(虽然Luc Danton的回答也是正确的,但这更像是禁止COW字符串的副作用,而不是原始规则禁止它。)

为了消除一些困惑,我将增加一些进一步的阐述。各种评论链接到我在GCC bugzilla上的一个评论,该评论给出了以下示例:

std::string s("str");
const char* p = s.data();
{
    std::string s2(s);
    (void) s[0];
}
std::cout << *p << '\n';  // p is dangling
那个例子的目的是展示为什么GCC的引用计数(COW)字符串在C++11中无效。C++11标准要求该代码能够正确运行。在代码中没有任何东西允许在C++11中使

失效。

使用GCC旧的引用计数std::string实现,该代码具有未定义的行为,因为p失效了,变成了一个悬挂指针。(当构造s2时,它与s共享数据,但通过s[0]获得非常量引用需要取消数据共享,因此s执行了“写时复制”,因为引用s[0]可能会被用来写入s,然后s2超出范围,销毁由p指向的数组)。
C++03标准在21.3 [lib.basic.string] p5中明确允许这种行为,在调用data()之后,第一次调用operator[]()可能会使指针、引用和迭代器失效。因此,GCC的COW字符串是一个有效的C++03实现。
C++11标准不再允许这种行为,因为没有调用operator[]()可以使指针、引用或迭代器失效,无论它们是否跟随data()的调用。
因此,上面的例子必须在C++11中工作,但libstdc++的COW字符串类型不起作用,因此这种COW字符串类型在C++11中是不允许的。

3
调用.data()时进行取消共享实现(并在每次指针、引用或迭代器返回时取消共享)不会受到该问题的影响。也就是说(不变量),缓冲区在任何时候都未被共享,否则与没有外部引用共享。我认为您打算用这个示例作为非正式的Bug报告评论,非常抱歉误解了!但是,通过考虑我在此描述的实现,可以看出此示例在C++11中表现良好,当忽略noexcept要求时,示例并未提及正式内容。如果您需要,我可以提供代码。 - Cheers and hth. - Alf
9
如果你在几乎每次访问该字符串时都取消共享,则会失去所有共享带来的好处。为了让标准库使用COW实现作为std::string,它必须是实用的,我真的怀疑您能否展示出满足C++11失效要求的有用,高效的COW字符串。因此,我坚持认为最后一刻添加的noexcept规范是禁止COW字符串的结果,而不是其根本原因。N2668似乎非常清楚,为什么您继续否认委员会在其中概述的明确意图呢? - Jonathan Wakely
2
现在我正在拼凑一些代码,我发现有129个basic_string成员函数,加上自由函数。抽象成本:这个即兴的、非优化的、新的零版本代码,在g++和MSVC中都比较慢,速度降低了50%到100%。它不支持线程安全(使用shared_ptr很容易实现),而且仅足以支持对字典进行排序以进行计时,但是除了错误之外,它证明了一个引用计数的basic_string是被允许的,除了C++的noexcept要求。 https://github.com/alfps/In-principle-demo-of-ref-counted-basic_string/ - Cheers and hth. - Alf
1
是的,你可以使用它们来原子地更新shared_ptr,现在所有的字符串都共享一个全局互斥池,每次调用'data()'、'operator[]'或'begin()'时都需要使用它们,即使对于一个const字符串也是如此。嗨,竞争!而且在'Refcounted_string_::ensure_unique_buffer()'中仍然会有一个TOCTTOU竞争。如果你的std::lib实现愿意毁掉程序的性能,那么你可能能够证明C++11 COW字符串是可行的。但回到现实世界,这不是一个选择。 - Jonathan Wakely
1
让我们在聊天中继续这个讨论 - Jonathan Wakely
显示剩余11条评论

21

CoW是一种用于加速字符串处理的可接受机制...但是...

当使用大量字符串时,它会导致多线程代码变慢(所有锁定操作都会检查是否只有您正在写入数据,这会降低性能)。这是多年前CoW被淘汰的主要原因。

其他原因是[]运算符将返回字符串数据,没有任何保护机制来防止您覆盖其他人期望保持不变的字符串。对c_str()data()也同样适用。

根据快速搜索结果,多线程问题基本上是使其被有效禁止的原因(虽然没有明确说明)。

提案内容如下:

提案

我们建议使所有迭代器和元素访问操作可以在并发情况下安全执行。

我们正在增加操作的稳定性,即使在顺序代码中也是如此。

这个改变有效地禁止了Copy-on-write实现。

接下来是:

从Copy-on-write实现切换带来的最大潜在性能损失是对于那些有非常大的只读字符串的应用程序,会增加内存消耗。然而,我们认为对于这些应用程序,Rope是更好的技术解决方案,并建议将Rope提案考虑纳入Library TR2。

Ropes是STLPort和SGIs STL的一部分。


2
operator[]问题并不是真正的问题。const变量确实提供了保护,而非const变量总是有在那个时候进行CoW的选项(或者非常疯狂地设置页面错误来触发它)。 - Christopher Smith
+1 转到问题。 - Cheers and hth. - Alf
6
没错,没有包括std::cow_string类并具有lock_buffer()等功能确实有些愚蠢。有很多情况我知道线程不是问题,而事实上更多时候也确实如此。 - Erik Aronesty
我喜欢提出另一种选择的建议,例如绳索。我想知道是否还有其他可用的替代类型和实现方式。 - Voltaire

5

从21.4.2基本字符串构造函数和赋值运算符[string.cons]:

basic_string(const basic_string<charT,traits,Allocator>& str);

[...]

2 效果:按表64中所示的方式构造类basic_string的对象。[...]

表64有助于记录,通过此(复制)构造函数构造对象后,this->data()的值为:

指向分配的副本的第一个元素的指针,该副本的第一个元素由str.data()指向

其他类似构造函数也有类似的要求。


+1 解释了C++11(至少部分地)禁止了COW。 - Cheers and hth. - Alf
抱歉,我有点累了。它并没有解释更多的内容,只是说明如果缓冲区当前被共享,调用.data()必须触发COW复制。尽管如此,这仍然是有用的信息,所以我让赞成保持不变。 - Cheers and hth. - Alf

4

C++11及以后版本中是否禁止了基于COW的std::string实现?

关于此问题:

我是否正确,C++11不支持使用COW实现std::string

是的。

关于此问题:

如果是这样,这种限制在新标准中是否有明确说明(在哪里)?

几乎直接地说明了。因为一些操作需要具有常数时间复杂度,并且在COW实现中需要O(n)物理拷贝字符串数据。

例如,对于成员函数:

auto operator[](size_type pos) const -> const_reference;
auto operator[](size_type pos) -> reference;

在COW实现中,这将会触发字符串数据的复制以取消共享字符串值。C++11标准要求此操作时间复杂度为常数级别(constant time)。
C++03通过不具备常数时间复杂度的要求,并在特定限制条件下允许调用operator[]()、at()、begin()、rbegin()、end()或rend()来使字符串项的引用、指针和迭代器失效,即可能导致COW数据复制来支持COW实现。但在C++11中,这种支持被移除了。
另外,在C++11中,即使是const item accessors也需要触发数据复制,因为它们允许客户端代码形成引用或指针,而这些引用或指针不能通过可以触发COW数据复制的操作后再次失效。
正确的C++11 COW实现应在任何可能失效引用之前进行COW数据复制转换,使其不再共享字符串值。一个实例开始时拥有Sharable策略,然后可以切换到Unique策略来独占值。在多个实例共享所有权的情况下,这就需要复制字符串数据。在切换回Sharable策略之前,需要进行一些使引用失效的操作,例如赋值。
因此,该答案的结论正确,即排除了COW字符串,但所提供的推理是不正确和极其误导的。我怀疑这种误解的原因是C++11附录C中的一个非规范性注释:
C++11 §C.2.11 [diff.cpp03.strings]关于§21.3:
“更改”:basic_string要求不再允许使用引用计数字符串 “基本原理”:引用计数字符串的失效略有不同。这个改变为这个国际标准规范了行为。 “对原始功能的影响”:有效的C++ 2003代码在这个国际标准下可能会执行不同的操作
这里的基本原理解释了为什么要删除C++03特殊的COW支持。这个基本原理(why)并不是标准有效地禁止COW实现的方法(how)。标准通过O(1)的要求禁止了COW。
简而言之,C++11的失效规则并没有排除使用COW实现std::basic_string,但它们确实排除了像g ++标准库实现中至少一个C++03风格的COW实现那样合理高效且没有限制。特殊的C++03 COW支持允许实现实际效率,尤其是使用const项访问器,但代价是复杂的失效规则。
这些规则非常复杂微妙,我怀疑许多程序员都无法给出精确的摘要,包括我自己。

如果不考虑O(1)的要求呢?

如果忽略了C ++11对例如operator[]的恒定时间要求,则对于basic_string 而言,COW在技术上是可行的,但难以实现。

可以在不产生COW数据复制的情况下访问字符串内容的操作包括:

  • 通过+进行连接。
  • 通过<<进行输出。
  • basic_string 用作标准库函数的参数。

后者是因为标准库允许依赖于实现特定的知识和构造。

此外,实现可以提供各种非标准函数来访问字符串内容而不触发COW数据复制。

主要的复杂因素是,在C ++11中,项目访问必须触发数据复制(取消共享字符串数据),但需要不抛出异常,例如,C ++11 §21.4.5 /3“ Throws: Nothing。”。因此,它不能使用普通动态分配来创建新缓冲区以进行COW数据复制。解决这个问题的一种方法是使用一个特殊的堆,其中内存可以保留而不实际分配,然后为每个逻辑引用字符串值保留所需数量。在这样的堆中保留和取消保留可以是恒定时间O(1),在已经保留了所需的数量之后,分配可以noexcept。为了符合标准的要求,使用这种方法似乎需要为每个不同的分配器提供一个基于预留的特殊堆。


注:
¹由于它允许客户端代码获取对数据的引用或指针,并且不允许通过稍后由非const项目访问触发的数据复制使其无效,因此const项目访问器会触发COW数据复制。


3
“你的例子是一个在C++11实现上不正确的好例子。也许它在C++03中是正确的。” “是的,这就是这个例子的重点。它展示了一种COW字符串,在C++03中是合法的,因为它没有违反旧的迭代器失效规则,在C++11中不合法,因为它违反了新的迭代器失效规则。并且它还与我在上面评论中引用的陈述相矛盾。” - Jonathan Wakely
2
@JonathanWakely:再次假设您的意思是您所写的意思,而不是完全理解事物,请考虑一种实现方式,其中字符串可以在所有权策略之间切换。 COW(写时复制)字符串始于Sharable策略。它可以转换为Unique策略,并且当可能创建项引用时必须这样做,例如通过调用.c_str()(至少如果该调用产生对内部缓冲区的指针)。这通常涉及复制字符串数据。之后,只能通过使所有引用无效的操作来将其转换为Sharable,例如赋值。清楚了吗? - Cheers and hth. - Alf
3
如果你说“可共享”的话,而不是“最初共享”,我就不会争论了。说某个东西最初共享只会让人感到困惑。它是和它自己共享吗?这不是这个词的意思。但我再次重申:你试图争辩 C++11 迭代器失效规则不禁止一些从未在实践中使用过(并且性能不可接受)的假设 COW 字符串时,他们确实禁止在实践中使用的那种 COW 字符串,这有点学术上的无意义。 - Jonathan Wakely
6
您提出的COW字符串很有趣,但我不确定它会有多少用处。 COW字符串的重点是仅在两个字符串需要写入时才复制字符串数据。您建议的实现要求在发生任何用户定义的读操作时进行复制。即使编译器知道它只是一个读取操作,它仍然必须复制。此外,复制唯一字符串将导致其字符串数据的复制(可能到可共享状态),这再次使得COW相当无意义。因此,如果没有复杂性保证,您可以编写......一个非常糟糕的COW字符串。 - Nicol Bolas
5
@JonathanWakely: (1)你引用的话不是问题。这是问题:“我正确地理解了C++11不支持基于COW实现std::string吗?如果是这样,在新标准中是否明确说明了这种限制(在哪里)?”(2)你认为忽略O(1)要求的COW std::string会效率低下,这是你自己的看法。我不知道性能会怎么样,但我认为这种说法更多是为了所表达的感觉,而不是与这个答案有任何相关性的观点。 - Cheers and hth. - Alf
显示剩余13条评论

2

现在字符串已经保证是连续存储的,你也可以获取字符串内部存储的指针(例如 &str[0] 就像对于数组一样工作),所以不可能实现一个有用的 COW 实现。你必须为太多的事情做出副本。甚至只是在非 const 字符串上使用 operator[]begin() 都需要进行复制。


1
我认为在C++11中,字符串被保证是连续存储的。 - mfontanini
4
过去在这些情况下你必须复印,但那不是问题... - David Rodríguez - dribeas
@mfontanini 是的,但它们以前不是。 - Dirk Holsopple
3
尽管 C++11 确保字符串是连续的,但这与禁止 COW 字符串无关。GCC 的 COW 字符串是连续的,因此你声称“没有可能制作一个有用的 COW 实现”的说法是虚假的。 - Jonathan Wakely
@JonathanWakely:如果将两个字符串连接起来并要求结果字符串返回其后备存储,则必须报告一系列连续的字符。对于从未公开过其后备存储的字符串的存储是否有任何要求? - supercat
2
@supercat,请求备份存储(例如通过调用c_str())必须是O(1),不能抛出异常,并且不能引入数据竞争,因此如果您懒惰地连接,则很难满足这些要求。实际上,唯一合理的选择是始终存储连续数据。 - Jonathan Wakely

-1

我一直在想不可变的奶牛:一旦奶牛被创建,只能通过从另一只奶牛赋值来更改,因此它将符合标准。

今天我有时间进行了简单比较测试:一个大小为N,由字符串/奶牛键控的地图,每个节点都保存所有地图中字符串的集合(我们有NxN个对象)。

对于大小约为300字节且N = 2000的字符串,奶牛速度略快,并且使用的内存几乎是数量级的。请参见下文,大小以kb为单位,运行b是使用奶牛。

~/icow$ ./tst 2000
preparation a
run
done a: time-delta=6 mem-delta=1563276
preparation b
run
done a: time-delta=3 mem-delta=186384

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接