我曾经认为,当遵循最佳实践时,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
!毕竟新的不是真正的副本!
换句话说,我必须手动实现copy-constructor ,即使这个类没有管理任何资源(没有RAII)!
这令我惊讶。我以前从未遇到过这个问题,我也不知道有什么优雅的方法来解决它。考虑到这一点,在我看来,复制构造默认是不安全的 - 实际上,在我看来,类默认情况下不的类应该是可复制的,因为它们的实例变量之间的任何耦合都有可能导致默认的复制构造函数无效。
迭代器是否从根本上说不安全?或者,默认情况下类是否真的不可复制?
下面我能想到的解决方案都是不可取的,因为它们不会让我利用自动生成的复制构造函数:
答案 0 :(得分:21)
C ++ copy / move ctor / assign对于常规值类型是安全的。常规值类型的行为类似于整数或其他“常规”值。
它们对指针语义类型也是安全的,只要操作不改变指针“应该”指向的内容即可。指向“在你自己内部”或其他成员的东西,就是它失败的一个例子。
它们对于引用语义类型有些安全,但在同一类中混合指针/引用/值语义在实践中往往是不安全/错误/危险。
零规则是您创建的行类似于常规值类型,或者不需要在复制/移动时重新设置的指针语义类型。然后你不必写复制/移动ctors。
迭代器遵循指针语义。
围绕它的惯用/优雅是将迭代器容器与指向容器紧密耦合,并在那里阻止或写入副本。一旦包含指向另一个的指针,它们就不是真正独立的东西。
答案 1 :(得分:18)
是的,这是一个众所周知的问题&#34; - 无论何时将指针存储在对象中,您可能都需要某种自定义复制构造函数和赋值运算符,以确保指针全部有效并指向预期的事物。
由于迭代器只是集合元素指针的抽象,因此它们具有相同的问题。
答案 2 :(得分:14)
这是一个众所周知的问题吗?
嗯,众所周知,但我不会说众所周知的。 兄弟姐妹指针不经常发生,我在野外看到的大多数实现都以与你的完全相同的方式被破坏。
我认为这个问题很少发生在大多数人的注意之中;有趣的是,由于我现在遵循比C ++更多的Rust,因为类型系统的严格性(即,编译器拒绝这些程序,提示问题),它经常出现在那里。
它有优雅/惯用的解决方案吗?
有很多类型的兄弟指针情况,所以它真的取决于,但我知道两个通用的解决方案:
让我们按顺序查看它们。
指向类成员或指向可索引容器,然后可以使用偏移或键而不是迭代器。效率略低(并且可能需要查找),但这是一个相当简单的策略。我已经看到它在共享内存情况下使用效果很好(因为共享内存区域可能映射到不同的地址,因此使用指针是禁止的。)
另一种解决方案由Boost.MultiIndex使用,并包含在另一种内存布局中。它源于侵入式容器的原理:不是将元素放入容器(在内存中移动),而是一个侵入式容器使用已经在元素内部的钩子将它连接到正确的位置。从那里开始,很容易使用不同的钩子将单个元素连接到多个容器中,对吗?
好吧,Boost.MultiIndex进一步推动了两步:
您可以查看various examples,特别是Example 5: Sequenced Indices看起来很像您自己的代码。
答案 3 :(得分:9)
这是一个众所周知的问题
是。每当你有一个包含指针的类或像迭代器这样的指针式数据时,你必须实现自己的copy-constructor和assignment-operator来确保新对象具有有效的指针/迭代器。
如果是的话,它是否有优雅/惯用的解决方案?
也许不像你想的那么优雅,可能不是性能最好的(但有时副本不是,这就是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;
}
//...
};
答案 4 :(得分:7)
是的,当然这是一个众所周知的问题。
如果您的类存储了指针,那么作为一名经验丰富的开发人员,您会直观地知道默认的复制行为可能不足以满足该类。
您的类存储迭代器,并且由于它们也是存储在其他位置的数据的“句柄”,因此适用相同的逻辑。
这很难“惊人”。
答案 5 :(得分:5)
Foo
未管理任何资源的断言是错误的。
除了复制构造函数之外,如果删除了set
的元素,则Foo
中必须有管理vector
的代码,以便删除相应的迭代器。
我认为惯用解决方案是只使用一个容器vector<size_t>
,并在插入之前检查元素的计数是否为零。然后复制和移动默认值都没问题。
答案 6 :(得分:3)
不,您提到的功能本身并不安全;事实上,你想到了解决这个问题的三个可能的安全解决方案,就是证明没有&#34;固有的&#34;这里缺乏安全,即使你认为解决方案是不可取的。
是的,这里 RAII:容器(set
和vector
)正在管理资源。我认为你的观点是RAII已经被照顾到了#34;由std
容器。但是,您需要将容器实例本身视为&#34; resources&#34;,实际上您的类正在管理它们。您已经纠正了不直接管理堆内存,因为标准库会为您解决管理问题的这一方面。但管理问题还有其他问题,我将在下面再谈一谈。
问题是你显然希望你可以信任默认的拷贝构造函数来做正确的事情&#34;在诸如此类的非平凡案例中。我不确定你为什么期望正确的行为 - 也许你希望记住经验法则,例如3&#34;&#34;规则3&#34;将是一个有力的方法,以确保你不会在脚下射击自己?当然那将是很好(并且,正如在另一个答案中所指出的,Rust比其他低级语言更加努力地使得射击更加困难),但C ++根本就没有设计为了&#34;轻率的&#34;那种类设计,也不应该是。
我不会试图解决这是否是一个众所周知的问题的问题,因为我真的不知道这个问题有多好。 #34;姐姐&#34;数据和迭代器存储是。但我希望我可以说服你,如果你花时间考虑你编写的每个可复制的类的复制构造函数行为,这不应该是一个令人惊讶的问题
特别是,当决定使用默认的复制构造函数时,你必须考虑默认的复制构造函数实际会做什么:即,它将调用每个非复制构造函数的复制构造函数原始的,非联合成员(即具有复制构造函数的成员)并按位复制其余成员。
复制vector
迭代器时,std::vector
的复制构造函数有什么作用?它执行&#34;深拷贝&#34;,即 in 内的数据被复制。现在,如果向量包含迭代器,那么它会如何影响这种情况呢?好吧,它很简单:迭代器是向量存储的数据,因此迭代器本身将被复制。迭代器的复制构造函数有什么作用?我不打算实际查看,因为我不需要知道具体细节:我只需要知道迭代器就像指针(和其他方面)一样,复制指针只是复制指针本身,而不是指向的数据。即,默认情况下 not 的迭代器和指针都有深度复制。
请注意,这并不奇怪:当然迭代器默认情况下不进行深度复制。如果他们这样做了,那么您将为每个迭代器复制一个不同的新集。这比最初看起来更没有意义:例如,如果单向迭代器制作了数据的深层副本,它实际意味着什么呢?据推测,您可以获得部分副本,即所有剩余的数据仍然在&#34;前面。迭代器的当前位置,加上一个指向&#34;前面&#34;的新迭代器。新数据结构。
现在考虑复制构造函数无法知道被调用的上下文。例如,请考虑以下代码:
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
通过引用获取其参数,否则您还在那里获得了一个复制构造函数调用。
在这些情况的任何中,您是否希望复制构造函数更改 {{1}包含的迭代器指向的 std::set
}?当然不是!因此,自然std::vector<iter>
的复制构造函数不能被设计为以特定方式修改迭代器,事实上std::vector
的复制构造函数是在您实际使用它的大多数情况下,确切地说您需要。
但是,假设std::vector
可以这样工作:假设它有一个特殊的重载&#34; vector-of-iterators&#34;可以重新安置迭代器,并且编译器可以以某种方式被告知&#34;只有在迭代器实际需要重新安装时才调用此特殊构造函数。 (注意&#34;的解决方案仅在为包含类的默认构造函数调用特殊重载时,也包含迭代器的实例&#39;底层数据类型&#34; wouldn&# 39;工作;如果您的案例中的std::vector
迭代器指向不同的标准集,并且被简单地视为引用到数据管理,该怎么办?通过其他一些类?哎呀,编译器如何知道迭代器是否都指向相同的 std::vector
?)忽略编译器如何知道何时到调用这个特殊的构造函数,构造函数代码是什么样的?让我们尝试一下,使用std::set
作为我们的迭代器类型(我将使用C ++ 11/14主义并且有点草率,但总的来说应该是明确的):
_Ctnr<T>::iterator
好的,我们希望重新安置每个新的复制的迭代器,以引用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>
? (另请注意,对于许多容器,找到&#34;对应的迭代器&#34;对于新容器来说可能并非易事。)
_Ctnr<T>
容器这不仅仅是编译器不是&#34; smart&#34;因为它可能或应该是。这是一个实例,您的程序员需要特定的设计,需要特定的解决方案。特别是,如上所述,您有两个资源,都是std::
个容器。并且他们之间有关系。在这里,我们得到了大多数其他答案已经陈述的内容,并且到目前为止应该非常非常清楚:相关的类成员需要特别小心,因为 C ++不管理这种耦合默认情况下。但我希望还明确表示,你不应该将问题视为由于数据成员耦合而产生的问题;问题很简单,默认构造并不神奇,程序员在决定让隐式生成的构造函数处理复制之前必须知道正确复制类的要求。
......现在我们得到了美学和观点。你似乎发现当你的班级中没有任何必须手动管理的原始指针或数组时,被迫编写复制构造函数是不合适的。
但是用户定义的副本构造函数是优雅的;允许你编写它们是 C ++对编写正确的非平凡类的问题的优雅解决方案。
不可否认,这似乎是3&#34;的规则。并不适用,因为明确需要std::
复制构造函数或自己编写,但对用户定义的析构函数还没有明确的需要。但同样,你不能简单地根据经验法则进行编程,并期望一切都能正常工作,特别是在C ++这样的低级语言中;你必须了解(1)你真正想要的东西和(2)如何实现的细节。
因此,鉴于您的=delete
和std::set
之间的耦合实际上会产生一个非平凡的问题,通过将它们包装在一个正确实现(或简单删除)的类中来解决问题copy-constructor实际上是一个非常优雅(和惯用)的解决方案。
你提到了一个潜在的新的经验法则&#34;遵循你的编码习惯:&#34;默认情况下禁用我写的所有课程的复制,除非我能明确证明它们是正确的。&#34;虽然这可能是一个更安全的经验法则(至少在这种情况下),而不是3&#34;的规则。 (特别是当你的标准为&#34;我是否需要实施3&#34;是检查是否需要删除器时),我上面提醒我不要依赖经验法则。
但我认为这里的解决方案实际上比提出的经验法则更简单。您不需要正式证明默认方法的正确性;你只需要了解它会做什么,以及你需要它做什么。
上面,在我对你的特定情况的分析中,我进行了很多细节 - 例如,我提出了深度复制迭代器的可能性&#34;。您不需要深入了解这些细节以确定默认的复制构造函数是否可以正常工作。相反,简单地想象一下你手动创建的复制构造函数的样子;你应该能够很快地告诉你想象中明确定义的构造函数与编译器生成的构造函数的相似程度。
例如,包含单个向量std::vector
的类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 */ } // ...????
{}
的成员无法正常工作,您可以告诉默认构造函数无法正常工作。所以现在你需要决定你的课程是否需要复制。