C++的默认复制构造函数本质上是不安全的吗?迭代器从根本上也是不安全的吗?

Is C++'s default copy-constructor inherently unsafe? Are iterators fundamentally unsafe too?

本文关键字:不安全 迭代器 从根本上 默认 复制 C++ 构造函数 本质上      更新时间:2023-10-16

我曾经认为,当遵循最佳实践时,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)
};

我写过这样的代码。直到今天,我还没有发现任何问题

但是,仔细想想,我意识到这个类非常坏了:
它的复制构造函数和复制赋值复制vector内部的迭代器,这意味着它们仍然会指向旧的set!新的毕竟不是真的复制品!

换句话说,我必须手动实现复制构造函数,即使这个类没有管理任何资源(没有RAII)

这让我大吃一惊。我以前从未遇到过这个问题,也不知道有什么优雅的方法来解决它。仔细想想,在我看来,复制构造默认情况下是不安全的——事实上,在我来说,类默认情况下应该而不是可复制,因为它们的实例变量之间的任何类型的耦合都有导致默认复制构造函数无效的风险

迭代器的存储根本不安全吗或者,类在默认情况下真的应该是不可复制的吗?

下面我能想到的解决方案都是不可取的,因为它们不让我利用自动生成的副本构造函数:

  1. 为我编写的每个非平凡类手动实现一个复制构造函数。这不仅容易出错,而且为复杂的类编写也很痛苦
  2. 永远不要将迭代器存储为成员变量。这似乎是严重的限制
  3. 默认情况下,在我编写的所有类上禁用复制,除非我能明确证明它们是正确的。这似乎完全违背了C++的设计,因为大多数类型都具有值语义,因此是可复制的

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

C++复制/移动ctor/assign对于常规值类型是安全的。正则值类型的行为类似于整数或其他"正则"值。

只要操作不改变指针"应该"指向的内容,它们对指针语义类型也是安全的。指向"自己内部"或另一个成员就是失败的一个例子。

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

零的规则是,您可以创建行为类似于常规值类型或指针语义类型的类,这些类型在复制/移动时不需要重新密封。那么您就不必编写复制/移动ctor了。

迭代程序遵循指针语义。

这方面的惯用/优雅方法是将迭代器容器与指向容器紧密耦合,并在那里阻塞或写入复制ctor。一旦其中一个包含指向另一个的指针,它们就不是真正独立的东西。

是的,这是一个众所周知的"问题"——无论何时在对象中存储指针,都可能需要某种自定义的复制构造函数和赋值运算符来确保指针都是有效的,并指向预期的东西。

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

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

这是众所周知的,但我不会说是众所周知的兄弟指针并不经常出现,我在野外看到的大多数实现都以与您完全相同的方式被破坏。

我相信这个问题很少发生,以至于大多数人都没有注意到;有趣的是,随着我现在关注的Rust比C++更多,它经常出现在那里,因为类型系统的严格性(即编译器拒绝这些程序,从而引发问题)。

它有一个优雅/惯用的解决方案吗?

有很多类型的同级指针情况,所以这实际上取决于,但我知道两种通用的解决方案:

  • 共享元素

让我们按顺序复习一下。

指向类成员,或指向可索引容器,则可以使用偏移,而不是迭代器。它的效率稍低(可能需要查找),但它是一个相当简单的策略。我已经看到它在共享内存的情况下发挥了很大的作用(其中使用指针是不可以的,因为共享内存区域可以映射到不同的地址)。

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

好吧,Boost.MultiIndex将其向前推进了两步:

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

您可以查看各种示例,尤其是示例5:Sequenced Indices看起来很像您自己的代码。

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

是。任何时候,只要你有一个包含指针的类,或者像迭代器这样的类似指针的数据,你就必须实现你自己的复制构造函数和赋值运算符,以确保新对象具有有效的指针/迭代器。

如果是,它是否有一个优雅/惯用的解决方案?

可能没有您想要的那么优雅,性能也可能不是最好的(但是,副本有时不是,这就是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没有管理任何资源的断言是错误的。

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

我认为惯用的解决方案是只使用一个容器vector<size_t>,并在插入之前检查元素的计数是否为零。然后复制和移动默认值就可以了。

"本质上不安全的">

否,您提到的功能并非本质上不安全;你想到了三种可能的安全解决方案,这一事实证明;固有的";这里缺乏安全,尽管你认为解决方案是不可取的。

是的,这里有RAII:容器(setvector)正在管理资源。我认为你的观点是RAII是";已经处理好了";通过CCD_ 12容器。但是您需要考虑容器实例本身是";资源";,事实上,您的类正在管理它们。您没有直接管理堆内存,这是正确的,因为管理问题的这一方面由标准库负责。但是还有更多的管理问题,我将在下面详细讨论。

"魔术"默认行为

问题是,你显然希望你可以信任默认的复制构造函数";做正确的事;在这样一个不平凡的情况下。我不知道你为什么期望正确的行为——也许你希望记住经验法则,比如";3"规则;这将是一种强有力的方式来确保你不会朝自己的脚开枪吗?当然,这将是nice(正如在另一个答案中所指出的,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::vector<iter>所包含的迭代器指向的std::set?当然不是!因此,自然地,std::vector的复制构造函数不能被设计成以这种特定的方式修改迭代器,事实上,std::vector的复制构造函数正是您所需要的,在大多数实际使用它的情况下。

然而,假设std::vector可以这样工作:假设它对";迭代器的矢量";这可以重新安置迭代器,并且编译器可以以某种方式";被告知";只在迭代器实际需要重新安置时调用这个特殊的构造函数。(请注意,"仅在为包含类生成默认构造函数时调用特殊重载,该类也包含迭代器底层数据类型的实例"的解决方案不起作用;如果您的案例中的std::vector迭代器指向不同的标准集,并且被简单地视为对som管理的数据的引用e其他班级?见鬼,编译器应该如何知道迭代器是否都指向相同的std::set?)忽略编译器如何知道何时调用这个特殊构造函数的问题,构造函数代码会是什么样子?让我们尝试一下,使用_Ctnr<T>::iterator作为迭代器类型(我会使用C++11/14ism,有点草率,但总的来说应该很清楚):

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::容器的资源管理

这不仅仅是编译器没有"编译"的问题;"聪明";这是一个例子,在这个例子中,程序员有一个特定的设计,需要特定的解决方案。特别是,如上所述,您有两个资源,都是std::容器。他们之间存在关系。在这里,我们得到了大多数其他答案已经说明的内容,到目前为止,这些内容应该非常非常清楚:相关的类成员需要特别小心,因为C++默认情况下不管理这种耦合但我希望在这一点上也清楚的是,你不应该认为这个问题是因为数据成员耦合而产生的;问题很简单,默认构造并不神奇,程序员在决定让隐式生成的构造函数处理复制之前,必须了解正确复制类的要求。

优雅的解决方案

现在我们来谈谈美学和观点。当您的类中没有任何必须手动管理的原始指针或数组时,您似乎发现强制编写复制构造函数是不雅的。

但是用户定义的复制构造函数是优雅的;允许您编写C++对编写正确的非平凡类问题的优雅解决方案。

诚然,这似乎是一个";3"规则;并不完全适用,因为显然需要=delete复制构造函数或自己编写它,但(目前)还没有明确需要用户定义的析构函数。但是,你不能简单地根据经验进行编程,并期望一切都能正常工作,尤其是在C++这样的低级语言中;你必须知道(1)你真正想要什么以及(2)如何实现的细节。

因此,考虑到std::setstd::vector之间的耦合实际上会产生一个不平凡的问题,通过将它们封装在一个正确实现(或简单删除)复制构造函数的类中来解决问题实际上是一个非常优雅(惯用)的解决方案。

明确定义与删除

你提到了一个潜在的新";经验法则;在您的编码实践中遵循:;默认情况下,在我编写的所有类上禁用复制,除非我能明确证明它们是正确的"虽然这可能是比"经验法则"更安全的经验法则(至少在这种情况下);3"规则;(尤其是当你的"我需要实现3吗"的标准是检查是否需要委托人时),我上面关于不要依赖经验法则的警告仍然适用。

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

上面,在我对你的具体案例的分析中,我详细介绍了很多细节——例如,我提出了";深度复制迭代器";。您不需要太多的细节来确定默认的复制构造函数是否能正常工作。相反,简单地想象一下手动创建的副本构造函数会是什么样子;您应该能够很快地判断出您的假想显式定义构造函数与编译器生成的构造函数有多相似。

例如,包含单个向量data的类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的成员是不起作用的,您可以判断出默认的构造函数是不会起作用的。所以现在你需要决定你的类是否需要可复制。