为什么将容器的元素分配给容器(不是)定义良好的 C++?

Why is assigning a container's element to the container (not) a well-defined C++?

提问人:yeputons 提问时间:8/9/2023 最后编辑:yeputons 更新时间:8/9/2023 访问量:125

问:

在 C++ 中有一个臭名昭著的自赋值问题:在实现时,必须小心在从 复制数据之前不要破坏数据的情况。operator=(const T &other)this == &otherthisother

然而,并且可能以比成为同一对象更有趣的方式进行交互。也就是说,一个可能包含另一个。请考虑以下代码:*thisother

#include <iostream>
#include <string>
#include <utility>
#include <vector>
struct Foo {
    std::string s = "hello world very long string";
    std::vector<Foo> children;
};
int main() {
    std::vector<Foo> f(4);
    f[0].children.resize(2);
    f = f[0].children;  // (1)
    // auto tmp = f[0].children; f = std::move(tmp);  // (2)
    std::cout << f.size() << "\n";
}

我希望行和是相同的:程序是明确定义的打印。但是,我还没有找到启用行和地址清理器的编译器+标准库组合:GCC + stdlibc ++,Clang + libc ++和Visual Studio + Microsoft STL都崩溃了。(1)(2)2(1)

奇怪的是,禁用 Address Sanitizer 会消除崩溃,程序开始打印。2

为什么在标准 C++ 中禁止或允许此操作?

额外的问题:相同,但有.额外的问题:使用代替 .f[0].children = fstd::anystd::vector<Foo>

C++ 语言-律师 赋值-运算符 复制赋值 别名

评论

0赞 Sebastian 8/9/2023
出于实际目的(我知道这是一个语言律师问题,但这是一个评论,而不是答案),可以 A) 在某些情况下(例如,如果 (1) 中的更改仅在局部变量中)对 .或者 B) 创建一个自定义类,其中赋值运算符 B1) 接受一个值参数,或者 B2) 通过临时变量执行参数的双重移动,或者 B3) 延迟重置,并且还延迟了所包含元素的销毁,例如通过使用交换习语。std::vector<Foo>*fvector<Foo>*this
0赞 Sebastian 8/9/2023
可能必须更改/覆盖(与赋值运算符一起)所有操作,这可能会导致在复制之前调整大小(例如),因为它们也可能在复制开始之前使引用参数无效。push_back
0赞 alagner 8/9/2023
如果使用复制交换或类似的成语,1 和 2 将是等价的,在当前形式中,2 制作了一个中间副本,实际上可以节省一天。将打印输出添加到析构函数 godbolt.org/z/PM98e4Y53,您会看到关闭 asan 时,内存会明显损坏。
4赞 tbxfreeware 8/9/2023
Arthur O'Dwyer 在 CppCon 2019 上谈到了这个问题。他正在写一个副本作业,并使用了一个与你相似的例子。这个链接将带你进入他演讲的中间部分,在那里他讨论了赋值 他提供了这个例子作为在他的复制赋值运算符中使用复制和交换习语的动机,并指出在将 v[1] 的元素复制到 v 之前,通常的针对自我赋值的检查不能保护你不破坏 v(和 v[1] 连同它)。operator=v = v[1];

答:

5赞 paddy 8/9/2023 #1

我不相信 (1) 是明确定义的,因为为了将新值复制到 中,必须首先销毁驻留在该位置的旧对象,或者至少在常契约下进行修改。f[0]

来自 std::vector<T,Allocator>::operator=强调我的):

如果赋值后的分配器与其旧值不相等,则使用旧分配器解除分配内存,然后使用新分配器在复制元素之前分配内存。否则,在可能的情况下,可以重复使用所拥有的内存。在任何情况下,原来属于的元素都可以被销毁或替换为按元素复制分配。*this*this*this

因此,在上述所有情况下,对象可能在被复制之前就被销毁,并且您属于未定义或特定于实现的行为领域。

实际上,为了让向量重用此内存,它通常需要放置删除,然后是放置新建,在这些情况下,被复制的引用对象再次在此过程中被销毁。

即使在最宽松的情况下(“替换为元素复制赋值”),您也要从 invoked on 开始将其替换为 的副本。向量是空的,因此复制将导致两个元素都被销毁,但目标向量的容量(即 2)保持不变。甚至在进入下一个元素之前,最初被复制的元素已被修改,违反了其合同,所有赌注都已关闭。Foo::operator=(const Foo&)f[0]f[0].children[0]f[0].children[0].childrenf[0].childrenconst Foo&

我不认为有任何自动方法可以防止这种情况,除非使用某种自定义垃圾收集分配器。你只需要认识到自我参照问题并避免它。您通过引入副本解决了 (2) 中的问题,并且至少定义明确。可以通过先将数据移出容器来更进一步:

auto tmp = std::move(f[0].children);
f = std::move(tmp);

也许这个问题可以通过仔细应用来解决,因为您的主要问题是您期望仍被引用的数据的破坏。std::shared_ptr

我认为整个 const-object 的契约破坏内容确实是回答您的“额外”问题的关键,而无需太深入的细节。在这种情况下,由于所需的容量增加,可以重新分配,并在这样做时修改本应是常量的。f[0].children = fchildrenf

评论

2赞 François Andrieux 8/9/2023
“在合同下是常客”将对象作为引用参数的参数传递不会创建此类协定。仅要求函数不使用该引用来修改引用的对象。也许该函数还要求参数保持不变,在这种情况下,答案应提供对该要求的引用。constconst
0赞 alagner 8/9/2023
@FrançoisAndrieux请定义“不变”,例如在等式中加上“可变”;)