C ++的默认拷贝构造函数本质上是不安全的? 迭代器从根本上是不安全的?

我曾经认为,遵循最佳实践时,C ++的对象模型是非常强大的。
就在几分钟前,我意识到我以前没有过。

考虑这个代码:

class Foo { std::set<size_t> set; std::vector<std::set<size_t>::iterator> vector; // ... // (assume every method ensures p always points to a valid element of s) }; 

我已经写了这样的代码。 直到今天,我还没有看到它的问题。

但是,再想一想,我意识到这个class破碎:
它的复制构造函数和复制分配复制 vector 的迭代器 ,这意味着它们仍然指向 set ! 毕竟新的不是真的!

换句话说, 即使这个类没有pipe理任何资源(无RAII)我也必须手动实现copy-constructor

这让我感到惊讶。 我从来没有遇到过这个问题,我不知道有什么优雅的方法来解决它。 再想一想,在我看来, 复制构造默认是不安全的 – 实际上,在我看来,类应该是默认可复制的,因为它们的实例variables之间的任何耦合都有可能导致默认的复制-constructor无效

迭代器从根本上不安全的存储? 或者,类应该默认是不可复制的?

下面我想到的解决scheme都是不可取的,因为它们不让我利用自动生成的拷贝构造函数:

  1. 手动实现我写的每个非平凡类的复制构造函数。 这不仅容易出错,而且要写一个复杂的课程也是很痛苦的。
  2. 切勿将迭代器存储为成员variables。 这似乎是严重的限制。
  3. 除非我明确地certificate他们是正确的,否则在我写的所有类上都默认禁用复制。 这似乎完全针对C ++的devise,这是大多数types具有价值语义,因此是可复制的。

这是一个众所周知的问题,如果是的话,它有一个优雅/惯用的解决scheme?

这是一个众所周知的问题吗?

那么,这是众所周知的,但我不会说出名。 同胞指针不会经常出现,而且我在野外看到的大多数实现都是以与你自己完全相同的方式被破坏的。

我相信这个问题很less能够逃脱大多数人的注意。 有趣的是,由于现在我跟着比C ++更多的Rust,因为types系统的严格性(即编译器拒绝这些程序,提示问题),它经常出现在那里。

它有一个优雅/惯用的解决scheme?

兄弟指针的情况有很多types,所以它依赖于,但我知道两个通用的解决scheme:

  • 按键
  • 共享元素

让我们按顺序审查他们。

指向一个类成员,或指向一个可索引的容器,那么可以使用偏移量而不是迭代器。 效率稍低(可能需要查找),但这是一个相当简单的策略。 我已经看到它在共享内存情况下使用效果很好(因为共享内存区域可能被映射到不同的地址,所以使用指针是一个不允许的情况)。

另一个解决scheme由Boost.MultiIndex使用,并且包含一个替代的内存布局。 它来源于侵入性容器的原理:不是将元素放入容器(将其移入内存中),而是使用已插入容器的钩子将其放置在正确的位置。 从那里开始,使用不同的钩子将单个元素连接到多个容器是很容易的,对吧?

那么,Boost.MultiIndex踢了两步:

  1. 它使用传统的容器接口(即移动你的对象),但是对象移动到的节点是一个具有多个钩子的元素
  2. 它在单个实体中使用各种钩子/容器

您可以查看各种示例 ,特别是示例5:序列索引看起来很像您自己的代码。

对于常规值types,C ++复制/移动ctor / assign是安全的。 常规值types的行为与整数或其他“常规”值相同。

它们对于指针语义types也是安全的,只要操作不改变指针“应该”指向的内容即可。 指向“你自己内部”或其他成员,就是失败的一个例子。

对于引用语义types来说,它们是有些安全的,但是在同一个类中混合指针/引用/值语义在实践中往往是不安全的/错误的/危险的。

零的规则是,你使类的行为像常规值types,或指针语义types不需要在复制/移动重置。 那么你不必写复制/移动文件。

迭代器遵循指针语义。

围绕这个的习惯/雅致是将迭代器容器与指向容器紧密耦合,并在那里阻止或写入拷贝。 一旦包含指向另一个的指针,它们并不是真正独立的东西。

是的,这是一个众所周知的“问题” – 每当将指针存储在对象中时,您可能需要某种自定义副本构造函数和赋值运算符来确保指针都是有效的,并指向期望的事物。

由于迭代器只是集合元素指针的抽象,所以它们具有相同的问题。

这是一个众所周知的问题

是。 任何时候你有一个包含指针的类,或像迭代器一样的指针类数据,你必须实现你自己的copy-constructor和assignment-operator,以确保新对象有有效的指针/迭代器。

如果是的话,它有一个优雅/习惯解决scheme吗?

也许并不像你可能喜欢的那样优雅,并且可能不是最好的performance(但是,有时候拷贝有时不是,这就是为什么C ++ 11增加了移动语义),但是也许这样的东西会对你有用(假设std::vector迭代器包含到同一个父对象的std::set中):

 class Foo { private: std::set<size_t> s; std::vector<std::set<size_t>::iterator> v; struct findAndPushIterator { Foo &foo; findAndPushIterator(Foo &f) : foo(f) {} void operator()(const std::set<size_t>::iterator &iter) { std::set<size_t>::iterator found = foo.s.find(*iter); if (found != foo.s.end()) foo.v.push_back(found); } }; public: Foo() {} Foo(const Foo &src) { *this = src; } Foo& operator=(const Foo &rhs) { v.clear(); s = rhs.s; v.reserve(rhs.v.size()); std::for_each(rhs.v.begin(), rhs.v.end(), findAndPushIterator(*this)); return *this; } //... }; 

或者,如果使用C ++ 11:

 class Foo { private: std::set<size_t> s; std::vector<std::set<size_t>::iterator> v; public: Foo() {} Foo(const Foo &src) { *this = src; } Foo& operator=(const Foo &rhs) { v.clear(); s = rhs.s; v.reserve(rhs.v.size()); std::for_each(rhs.v.begin(), rhs.v.end(), [this](const std::set<size_t>::iterator &iter) { std::set<size_t>::iterator found = s.find(*iter); if (found != s.end()) v.push_back(found); } ); return *this; } //... }; 

是的,当然这是一个众所周知的问题。

如果你的类存储了指针,作为一个有经验的开发者,你会直观地知道默认的复制行为可能不足以满足这个类的需求。

你的类存储迭代器,因为它们也是对其他地方存储数据的“句柄”,同样的逻辑也适用。

这并不“令人吃惊”。

Foo不pipe理任何资源的说法是错误的。

除了复制构造函数之外,如果移除了set一个元素,那么在Foo中必须有代码来pipe理vector以便相应的迭代器被移除。

我认为惯用的解决scheme是使用一个容器,一个vector<size_t> ,并在插入之前检查一个元素的数量是否为零。 然后复制和移动默认是好的。

“固有的不安全”

不,你提到的function本质上不是不安全的; 事实上,你认为三个可能的安全解决scheme的问题是证据表明没有“固有的”缺乏安全性,即使你认为解决scheme是不可取的。

是的, 这里 RAII:容器( setvector )正在pipe理资源。 我想你的观点是,RAII是“已经被照顾”的std容器。 但是你需要考虑容器实例本身是“资源”,事实上你的class级正在pipe理它们 。 你没有直接pipe理堆内存是正确的,因为pipe理问题的这个方面由标准库来处理的。 但还有更多的pipe理问题,我将在下面进一步讨论。

“魔术”的默认行为

问题是,你显然希望你可以相信默认的拷贝构造函数在这样的非平凡的情况下“做正确的事情”。 我不知道你为什么期待正确的行为 – 也许你希望记住像“3规则”这样的经验法则将是一个强有力的方法,以确保你不会在脚下自己射击? 当然这很好 (而且,正如另一个答案所指出的,Rust比其他低级语言更加难以使脚注更难),但是C ++根本不是为这种types的“轻率”类devise而devise的, 也不应该是

概念化构造函数的行为

我不打算试图解决这是否是一个“众所周知的问题”的问题,因为我真的不知道“姊妹”数据和迭代器存储的问题有多好。 但是我希望我能说服你,如果你花时间考虑复制构造函数的行为,那么你写的每个类都可以被复制,这应该不是一个令人惊讶的问题。

特别是,当决定使用默认的复制构造函数时, 你必须考虑默认的复制构造函数实际上将做什么:即,它将调用每个非原始的非联合成员的复制构造函数有复制构造函数)和其余的按位复制。

当复制迭代器的vector时, std::vector的copy-constructor是做什么的? 它执行“深层复制”,即复制向量中的数据。 现在,如果vector包含迭代器,这是如何影响的情况? 好吧,这很简单:迭代器由vector存储的数据,所以迭代器本身将被复制。 迭代器的拷贝构造函数是做什么的? 我不打算真的看这个,因为我不需要知道具体细节:我只需要知道迭代器就像这个(和其他方面)的指针,复制一个指针只是复制指针本身 ,而不是指向数据 。 也就是说,迭代器和指针默认情况下没有深度复制。

请注意,这并不奇怪: 当然,迭代器默认情况下不会进行深度复制。 如果他们这样做,你会得到一个不同的,复制每个迭代器的新集 。 而这比最初看起来更没有意义:例如,如果单向迭代器对数据进行深度复制,那么这意味着什么呢? 大概你会得到一个部分拷贝,即所有仍然在迭代器当前位置“前面”的数据,再加上一个指向新数据结构“前面”的新迭代器。

现在考虑复制构造函数无法知道被调用的上下文。 例如,请考虑以下代码:

 using iter = std::set<size_t>::iterator; // use typedef pre-C++11 std::vector<iter> foo = getIters(); // get a vector of iterators useIters(foo); // pass vector by value 

getIters可能会移动返回值,但也可能是复制构造的。 对foo的赋值也会调用复制构造函数,尽pipe这也可能被忽略。 除非useIters通过引用来引用它的参数,那么你有一个拷贝构造函数调用。

在这些情况下,你会期望拷贝构造函数改变std::vector<iter>包含的迭代器指向的std::set吗? 当然不是! 所以自然std::vector的copy-constructor不能被devise成以特定的方式修改迭代器,事实上std::vector的copy-constructor 正是你在大多数情况下所需要的用过的。

然而,假设std::vector 可以像这样工作:假设它有一个特殊的重载“迭代器向量”,可以重新安置迭代器,并且编译器可以“告知”只调用这个特殊的构造函数当迭代器实际上需要被重新安置时。 (请注意,解决scheme“只为包含类的迭代器的基础数据types的实例生成一个默认构造函数时调用特殊的重载”不会工作;如果std::vector迭代器在你的情况下是指向一个不同的标准集,并被简单地视为其他类pipe理的数据的引用 ?嘿,编译器应该怎么知道迭代器是否都指向相同的 std::set ?)忽略这个问题编译器如何知道何时调用这个特殊的构造函数,构造函数代码是什么样的? 让我们来试试吧,使用_Ctnr<T>::iterator作为我们的迭代器types(我将使用C ++ 11 / 14isms,并且有点草率,但总体点应该清楚):

 template <typename T, typename _Ctnr> std::vector< _Ctnr<T>::iterator> (const std::vector< _Ctnr<T>::iterator>& rhs) : _data{ /* ... */ } // initialize underlying data... { for (auto i& : rhs) { _data.emplace_back( /* ... */ ); // What do we put here? } } 

好的,所以我们希望每个新的, 复制的迭代器都被重新定位来引用_Ctnr<T>不同实例。 但是,这些信息从哪里来 ? 请注意,复制构造函数不能将新的_Ctnr<T>作为参数:那么它将不再是复制构造函数。 在任何情况下,编译器如何知道_Ctnr<T>提供? (请注意,对于许多容器,为新容器查找“对应的迭代器”可能是不重要的。)

使用std:: containers进行资源pipe理

这不仅仅是编译器不可能或者应该“聪明”的问题。 这是一个程序员,你需要一个特定的devise,需要一个特定的解决scheme。 特别是,如上所述,你有两个资源,都是std:: containers。 而你们之间有一种关系 。 在这里,我们得到了大部分其他答案已经陈述的内容,到此为止应该非常清楚: 相关类成员需要特别小心,因为C ++默认情况下不pipe理这种耦合。 但是,我希望在这一点上清楚的是,你不应该把这个问题想象成由于数据成员耦合而产生的具体问题。 问题很简单,默认构造不是魔术,程序员必须知道在决定让隐式生成的构造函数处理复制之前正确复制类的要求。

优雅的解决scheme

…现在我们达到美学和意见。 当你没有任何必须手动pipe理的类中的原始指针或数组时,你似乎觉得不得不写一个copy-constructor。

但是用户定义的拷贝构造函数优雅的; 让你编写它们 C ++优雅的解决scheme,写出正确的非平凡类的问题。

无可否认,这似乎是“3规则”不太适用的情况,因为显然需要=delete复制构造函数或自己写,但是对于用户来说,定义的析构函数。 但是,再次,你不能简单地根据经验法则进行编程,并期望一切正常工作,特别是在像C ++这样的低级语言中; 你必须了解(1)你真正想要的细节和(2)如何实现。

所以,鉴于你的std::set和你的std::vector之间的耦合实际上创造了一个不平凡的问题,通过将它们包装在一个正确实现(或简单地删除)复制构造函数的类中来解决问题实际上是一个非常优雅(和惯用)的解决scheme。

明确定义与删除

你提到了一个潜在的新的“经验法则”,在你的编码实践中遵循:“除非我明确地certificate他们是正确的,否则在我写的所有类上默认禁止复制”。 虽然这可能是一个更安全的经验法则(至less在这种情况下)比“3的规则”(特别是当您的“我需要执行3”的标准是检查是否需要删除者),我上面谨慎依靠经验法则依然适用。

但我认为这里的解决scheme实际上比提出的经验法则更简单 。 您不需要正式certificate默认方法的正确性; 你只需要有一个基本的想法,它会做什么,以及你需要做什么。

上面,在分析你的具体情况之后,我进入了很多细节 – 例如,我提出了“深度复制迭代器”的可能性。 您不需要深入这些细节来确定默认的复制构造函数是否可以正常工作。 相反,简单地想象一下你手动创build的拷贝构造函数的样子。 你应该能够很快地分辨出你想象中的明确定义的构造函数和编译器会产生的构造函数有多相似。

例如,包含单个vectordata的类Foo将具有如下所示的复制构造函数:

 Foo::Foo(const Foo& rhs) : data{rhs.data} {} 

即使没有写出来,你也知道你可以依赖隐式生成的,因为它和上面写的完全一样。

现在,考虑你的类Foo的构造函数:

 Foo::Foo(const Foo& rhs) : set{rhs.set} , vector{ /* somehow use both rhs.set AND rhs.vector */ } // ...???? {} 

马上,考虑到简单地复制vector的成员将无法工作,你可以告诉默认的构造函数将无法正常工作。 所以现在你需要决定你的课程是否需要可复制。