“天生不安全”
不,您提到的功能本质上并不是不安全的;您想到了三种可能的安全解决方案这一事实证明了这里没有“固有的”缺乏安全性,即使您认为这些解决方案是不可取的。
是的,这里有RAII:容器 (set
和vector
) 正在管理资源。我认为您的观点是std
容器“已经照顾好”RAII。但是您需要将容器实例本身视为“资源”,实际上您的班级正在管理它们。您没有直接管理堆内存是正确的,因为管理问题的这一方面由标准库为您处理。但是管理问题还有更多,我将在下面详细讨论。
“魔术”默认行为
问题是您显然希望您可以信任默认的复制构造函数在诸如此类的非平凡情况下“做正确的事情”。我不确定您为什么期望正确的行为-也许您希望记住诸如“ 3规则”之类的经验法则将是确保您不会在脚上开枪的可靠方法? 当然那会很好(而且,正如另一个答案中指出的那样,Rust 比其他低级语言更进一步使脚射更加困难),但 C++ 根本不是为那种“轻率”的类设计而设计的,也不应该。
将构造函数行为概念化
我不会尝试解决这是否是“众所周知的问题”的问题,因为我真的不知道“姐妹”数据和迭代器存储问题的特征有多大。但我希望我能说服你,如果你花时间为你编写的每个可以复制的类考虑复制构造函数行为,这应该不是一个令人惊讶的问题。
特别是,在决定使用默认复制构造函数时,您必须考虑默认复制构造函数实际上会做什么:即,它将调用每个非原始、非联合成员的复制构造函数(即具有复制构造函数)并按位复制其余部分。
复制你vector
的迭代器时,std::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
也调用了一个复制构造函数,尽管这也可以被省略。除非useIters
通过引用获取其参数,否则您还会在此处调用复制构造函数。
在任何这些情况下,您是否希望复制构造函数更改由 ? 包含的迭代器 std::set
所指向的std::vector<iter>
?当然不是!因此,自然std::vector
不能将复制构造函数设计为以这种特定方式修改迭代器,事实上,在实际使用它的大多数情况下,std::vector
复制构造函数正是您所需要的。
然而,假设std::vector
可以像这样工作:假设它有一个特殊的“迭代器向量”重载,可以重新安装迭代器,并且编译器可以以某种方式被“告知”只在迭代器实际调用这个特殊构造函数时需要重新就座。(请注意,“仅在为包含迭代器的基础数据类型的实例的包含类生成默认构造函数时才调用特殊重载”的解决方案将不起作用;如果std::vector
您的情况下的迭代器指向一个不同的标准集,并且被简单地视为对其他类管理的数据的引用?哎呀,一样 std::set
?)忽略编译器如何知道何时调用这个特殊构造函数的问题,构造函数代码会是什么样子?让我们尝试一下,使用_Ctnr<T>::iterator
我们的迭代器类型(我将使用 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>
. 但是这些信息是从哪里来的呢?请注意,复制构造函数不能将 new_Ctnr<T>
作为参数:那么它将不再是复制构造函数。无论如何,编译器如何知道_Ctnr<T>
要提供哪个?(也请注意,对于许多容器,为新容器找到“对应的迭代器”可能并非易事。)
std::
容器资源管理
这不仅仅是编译器没有达到应有的“智能”程度的问题。在这种情况下,您(程序员)心中有一个需要特定解决方案的特定设计。特别是,如上所述,您有两个资源,都是std::
容器。和你有他们之间的关系。在这里,我们得到了大多数其他答案都已说明的内容,此时应该非常非常清楚:相关的类成员需要特别注意,因为默认情况下 C++ 不管理这种耦合。但我希望也是这一点很清楚,您不应该认为问题是由于数据成员耦合而引起的;问题只是默认构造并不神奇,程序员在决定让隐式生成的构造函数处理复制之前必须了解正确复制类的要求。
优雅的解决方案
...现在我们开始讨论美学和观点。当您的类中没有任何必须手动管理的原始指针或数组时,您似乎发现被迫编写复制构造函数是不优雅的。
但是用户定义的复制构造函数很优雅;允许您编写它们是C++ 对编写正确的非平凡类问题的优雅解决方案。
诚然,这似乎是“3 规则”并不完全适用的情况,因为显然需要=delete
复制构造函数或自己编写它,但还没有明确需要用户定义的析构函数. 但同样,您不能简单地根据经验法则进行编程并期望一切都能正常工作,尤其是在 C++ 等低级语言中;您必须了解(1)您真正想要什么以及(2)如何实现的细节。
std::set
因此,考虑到您和您之间的耦合std::vector
实际上会产生一个不平凡的问题,通过将它们包装在一个正确实现(或简单地删除)复制构造函数的类中来解决问题实际上是一个非常优雅(和惯用)的解决方案.
显式定义与删除
您在编码实践中提到了一个潜在的新“经验法则”:“在我编写的所有类上默认禁用复制,除非我能明确证明它们是正确的。” 虽然这可能是比“3 规则”更安全的经验法则(至少在这种情况下)(特别是当您的“我是否需要实施 3”的标准是检查是否需要删除器时),我的上面不要依赖经验法则的警告仍然适用。
但我认为这里的解决方案实际上比建议的经验法则更简单。你不需要正式证明默认方法的正确性;你只需要对它会做什么以及你需要它做什么有一个基本的了解。
上面,在我对您的特定案例的分析中,我详细介绍了很多细节——例如,我提出了“深度复制迭代器”的可能性。您不需要深入了解这些细节来确定默认的复制构造函数是否可以正常工作。相反,只需想象您手动创建的复制构造函数会是什么样子;您应该能够很快说出您想象中的显式定义的构造函数与编译器生成的构造函数有多相似。
例如,Foo
包含单个向量的类data
将具有如下所示的复制构造函数:
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
's 的成员是行不通的,您可以说默认构造函数将无法正常工作。所以现在你需要决定你的类是否需要可复制。