复制构造函数传统上在C ++程序中无处不在。但是,我怀疑自从C ++ 11以来是否有充分的理由。
即使程序逻辑不需要复制对象,复制构造函数(usu.default)通常仅用于对象重新分配。如果没有复制构造函数,则无法在std::vector
中存储对象,甚至无法从函数返回对象。
但是,自从C ++ 11以来,移动构造函数一直负责对象的重新分配。
复制构造函数的另一个用例是简单地制作对象的克隆。但是,我确信.copy()
或.clone()
方法比复制构造函数更适合该角色,因为......
复制对象并不常见。当然,对象的界面有时需要包含“自制副本”方法,但有时只是。在这种情况下,显式优于隐式。
有时,对象可能会暴露几种不同的.copy()
类方法,因为在不同的上下文中,副本可能需要以不同的方式创建(例如,更浅或更深)。
在某些情况下,我们希望.copy()
方法执行与程序逻辑相关的非平凡事情(增加一些计数器,或者可能为副本生成新的唯一名称)。我不接受任何在复制构造函数中具有非明显逻辑的代码。
最后但并非最不重要的是,如果需要,.copy()
方法可以是虚拟的,可以解决slicing的问题。
我实际上想要使用复制构造函数的唯一情况是:
vec3 b = a.copy()
过于冗长。旁注:我已经考虑过CAS需要复制构造函数,但
operator=(const T&)
需要CAS,我认为这是基于完全相同的推理的冗余;
如果您真的需要,.copy()
+operator=(T&&) = default
将是首选。)
对我来说,这足以激励默认情况下在任何地方使用T(const T&) = delete
并在需要时提供.copy()
方法。 (也许还有一个private T(const T&) = default
只是为了能够在没有样板的情况下编写copy()
或virtual copy()
。)
问:上述推理是正确的还是我错过了逻辑对象实际需要或以某种方式从复制构造函数中获益的任何正当理由?
具体来说,我是否正确,移动构造函数完全接管了C ++ 11中对象重新分配的责任?当一个对象需要在内存中的其他位置移动而不改变其状态时,我正在非正式地使用“重新分配”。
答案 0 :(得分:4)
问题在于“对象”这个词是指什么。
如果 objects 是变量所引用的资源(比如java或C ++中的指针,使用经典的OOP范例),每个“变量之间的副本”都是“共享”,如果是单一所有权强制执行,“分享”变得“移动”。
如果对象是变量本身,由于每个变量都必须有自己的历史记录,如果你不能/不想强制破坏某个值,就不能“移动”另一个。
Cosider例如std::strings
:
std::string a="Aa";
std::string b=a;
...
b = "Bb";
您是否期望a
的值发生变化,或者该代码无法编译?如果没有,则需要复制。
现在考虑一下:
std::string a="Aa";
std::string b=std::move(a);
...
b = "Bb";
现在a为空,因为它的值(更好,包含它的动态内存)已“移动”到b
。然后对b
的值进行检查,丢弃旧的"Aa"
。
本质上,只有在显式调用或右边的参数是“临时”时,移动才有效,如
a = b+c;
在分配后显然不需要返回operator+
所持有的资源,因此将其移至a
,而不是将其复制到另一个a
所持有的位置,删除它更有效。
移动和复制是两回事。移动不是“复制的替代品”。这是一种更有效的方法,只有在对象不是 required 生成自身克隆的所有情况下才能避免复制。
答案 1 :(得分:2)
Short anwer
上述推理是正确的还是我错过了逻辑对象实际需要或以某种方式从复制构造函数中获益的任何正当理由?
自动生成的拷贝构造函数在将资源管理与程序逻辑分离方面是一个很大的好处;实现逻辑的类根本不需要担心分配,释放或复制资源。
在我看来,任何替换都需要做同样的事情,对命名函数这样做感觉有点奇怪。
答案很长
在考虑复制语义时,将类型分为四类是很有用的:
原始类型就是它们,因此它们超出了问题的范围;我假设对语言的彻底改变,打破了几十年的遗留代码,不会发生。在没有用户定义的虚函数或RTTI恶作剧的情况下,无法复制多态类型(保持动态类型),因此它们也超出了问题的范围。
所以提议是:要求RAII和聚合类型实现命名函数,而不是复制构造函数,如果它们应该被复制。
这对RAII类型没什么影响;他们只需要声明一个不同名称的复制函数,用户只需要稍微冗长一点。
但是,在当前世界中,聚合类型根本不需要声明显式复制构造函数;将自动生成一个以复制所有成员,或者如果任何成员不可复制则删除。这确保了,只要所有成员类型都可以正确复制,聚合也是如此。
在你的世界里,有两种可能性:
T copy() = default;
,因为您需要显式)。在我看来,基于相同命名函数在其他类型中自动生成命名函数感觉更像是当前生成“语言元素”(构造函数和运算符重载)的方法,但也许这只是我的偏见。并提出你赞成的观点:
T a(b);
不如T a(b.copy());
答案 2 :(得分:1)
复制构造函数有用的一种情况是实现强异常保证。
为了说明这一点,我们考虑resize
的{{1}}函数。该功能可大致如下实现:
std::vector
如果void std::vector::resize(std::size_t n)
{
if (n > capacity())
{
T *newData = new T [n];
for (std::size_t i = 0; i < capacity(); i++)
newData[i] = std::move(m_data[i]);
delete[] m_data;
m_data = newData;
}
else
{ /* ... */ }
}
函数具有强大的异常保证,我们需要确保,如果抛出异常,则保留resize
调用之前std::vector
的状态。
如果resize()
没有移动构造函数,那么我们将默认使用复制构造函数。在这种情况下,如果复制构造函数抛出异常,我们仍然可以提供强大的异常保证:我们只需T
delete
数组,并且不会对newData
造成任何损害。
但是,如果我们使用std::vector
的移动构造函数并抛出异常,那么我们会将一堆T
移到T
数组中。回滚这个操作并不简单:如果我们尝试将它们移回newData
数组,m_data
的移动构造函数可能会再次抛出异常!
要解决此问题,我们有T
功能。如果std::move_if_noexcept
标记为T
,则此函数将使用noexcept
的移动构造函数,否则将使用复制构造函数。这使我们能够以提供强大异常保证的方式实施std::vector::resize
。
为了完整起见,我应该提到C ++ 11 std::vector::resize
在所有情况下都不提供强大的异常保证。根据www.cplusplus.com,我们有以下保证:
如果n小于或等于容器的大小,则该函数永远不会抛出异常(无抛出保证)。 如果n更大并且发生重新分配,则在异常情况下(如果元素的类型是可复制的或无法可移动的),容器中没有变化。 否则,如果抛出异常,容器将保留有效状态(基本保证)。
答案 3 :(得分:1)
这就是事情。移动是新的默认值 - 新的最低要求。但复制仍然是一种有用且方便的操作。
没有人应该向后弯腰以提供复制构造函数。但是,如果您可以简单地提供可复制性,那么对您的用户来说仍然有用。
我不会很快抛弃复制构造函数,但我承认,对于我自己的类型,我只在它变得清晰时才需要它们 - 而不是立即。到目前为止,这种类型非常非常少。