我们可以说再见复制构造函数吗?

问题描述:

复制构造函数传统上在C++程序中无处不在。不过,我怀疑自从C++ 11以来是否有充分的理由。我们可以说再见复制构造函数吗?

即使在程序逻辑并不需要复制对象,拷贝构造函数(通常为默认值。)往往包括了对象再分配的唯一目的。如果没有复制构造函数,则无法将对象存储在std::vector中,甚至不能从函数返回对象。

但是,自从C++ 11以来,移动构造函数一直负责对象的重新分配。

复制构造函数的另一个用例是简单地创建对象的克隆。不过,我很相信,一个.copy().clone()方法更适合这个角色不是一个拷贝构造函数,因为...

  1. 复制对象是不是真的司空见惯。当然有时候对象的接口有时需要包含一个“自己创建一个副本”的方法,但有时候只是这样。当情况是这样的时候,显式比隐式更好。

  2. 有时对象可能会暴露几种不同的.copy()类似的方法,因为在不同的上下文中,可能需要以不同的方式创建副本(例如,更浅或更深)。

  3. 在某些情况下,我们希望.copy()方法可以做与程序逻辑有关的非平凡事情(增加一些计数器,或者为副本生成一个新的唯一名称)。我不会接受任何复制构造函数中具有非显而易见的逻辑的代码。

  4. 最后但并非最不重要的一个.copy()方法可以是虚拟的,如果需要,允许解决slicing的问题。


那里其实我想用一个拷贝构造函数的唯一情况是:

  • 可复制资源的RAII手柄(很明显)
  • 结构,其意在像内置类型一样使用,如数学向量或矩阵 -
    仅仅是因为它们经常被复制,并且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中对象重新分配的责任吗?当一个对象需要在内存中的其他位置移动而不改变其状态时,我正在非正式地使用“重新分配”。

+3

“复制对象并不是很平常。” - 我认为考虑到C++默认使用值语义是很常见的。具有相同值的两个对象应表示相同的事物。 – 2013-05-08 19:04:46

+3

默认禁用复制可能有一定的优点;但为什么你会通过成员函数启用它,而不是复制构造函数的惯用使用呢? – 2013-05-08 19:09:18

+0

@sftrabbit:不一定:值不是多态的,引用和指针是。如果你想在C++中使用运行时多态性,你可以通过地址来标识对象,而不是值(它只表示“状态”,而不是“标识”)。两个具有相同'm_name'的'Person's不是相同的'Person'。 – 2013-05-09 06:28:34

短前面回答

是上述推理正确的还是我失去了为什么逻辑对象的实际需要或以某种方式从拷贝构造函数中受益任何好的理由?

自动生成的拷贝构造函数在分离资源管理和程序逻辑方面有很大的好处;实现逻辑的类不需要担心分配,释放或复制资源。

在我看来,任何替代品都需要做同样的事情,并且为指定功能做这件事感觉有点奇怪。

龙答案

在考虑复制语义,它以类型划分为四类有用:

  • 原始类型,由语言定义的语义;
  • 资源管理(或RAII)类型,有特殊要求;
  • 聚合类型,它简单地复制每个成员;
  • 多态类型。

原始类型是他们是什么,所以他们超出了问题的范围;我假设对语言进行彻底改变,打破几十年的遗留代码,将不会发生。多态类型不能被复制(同时保持动态类型),没有用户定义的虚函数或RTTI shenanig,所以它们也超出了问题的范围。

所以建议是:要求RAII和聚合类型实现一个命名函数,而不是复制构造函数,如果它们应该被复制。

这对RAII类型没有什么区别;他们只需要声明一个不同名称的复制函数,而用户只需稍微冗长些。

但是,在当前的世界中,聚合类型根本不需要声明显式的拷贝构造函数;会自动生成一个以复制所有成员,或者如果有任何成员是不可复制的,将被删除。这确保了只要所有成员类型都可以正确复制,聚合也是如此。

在你的世界里,有两种可能性:

  • 无论是语言知道你的复制功能,并能自动生成一个(也许只有在明确要求,即T copy() = default;,因为你要明确性)。在我看来,在其他类型中自动生成基于相同命名函数的命名函数,与目前生成“语言元素”(构造函数和操作符重载)的方案相比,更像是魔法,但也许这只是我的偏见而已。
  • 或者它留给用户来正确实现聚合的复制语义。这很容易出错(因为你可以添加一个成员并忘记更新函数),并且打破了资源管理和程序逻辑之间当前清晰的分离。

,并解决您做出点主张:

  1. 复制(非多态性)对象司空见惯,但像你说的,现在是不常见的,它们可以被移动时可能。这只是你的意见,“明确更好”或T a(b);不明确比T a(b.copy());
  2. 同意,如果一个对象没有明确定义的复制语义,那么它应该有命名的功能,以涵盖它提供的任何选项。我不明白这是如何影响如何复制普通对象的。
  3. 我不知道为什么你认为复制构造函数不应该被允许做一个命名函数可以做的事情,只要它们是定义的复制语义的一部分。你认为不应该使用复制构造函数,因为你自己对它们进行了人为限制。
  4. 复制多态对象是完全不同的一堆鱼。强制所有类型使用命名函数是因为多态性函数必须不会赋予您似乎争论的一致性,因为返回类型必须不同。多态副本将需要动态分配并通过指针返回;非多态副本应该按值返回。在我看来,使这些不同的操作看起来相似而不可互换是没有价值的。
+0

谢谢,迈克!我重视自动复制ctors作为编写重要或不重要的复制例程的帮助。我的建议是1)将自定义副本留给RAII类型,2)将公共缺省副本留给聚合,3)在其他地方提供副本()或虚拟副本()而不是公共副本。有一件事我忽略了多态拷贝需要依赖动态分配的内存。现在我可以看到,提供命名复制方法仍然无法统一多态与非多态非聚集之间的复制接口(或者如前所述的“逻辑对象”)。 – Kos 2013-05-11 14:51:43

+0

如果程序逻辑要求以非平凡的方式复制对象,我仍然主张的建议是使用命名方法。在这种情况下,我仍然坚持要有一个有名的方法,并且没有公开的副本。为什么?当唯一的意图是重新分配而不是逻辑相关的重复时,犯这个错误并使用copy ctor而不是移动ctor会比较容易。它可以简单地省略一个“noexcept”。复制构造函数由于它们的双重角色(逻辑相关和重新分配相关)而仍然很棘手 - 这个事实让我陷入了整个思路。 – Kos 2013-05-11 14:52:08

+0

这实际上有一个有趣的延续 - 是*任何*逻辑相关的非聚集实际“微不足道”的副本?也许不是;我需要消化。如果我拿出一个令人信服的例子,我想回到你身边。 – Kos 2013-05-11 15:01:51

问题是什么是“对象”这个词是指什么。

如果对象是变量是指(如在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"; 

现在是空的,因为它的价值(更好的,包含它的动态内存)已经“转移”到b。然后b的值被加密,并丢弃旧的"Aa"

从本质上说,此举只是工作,如果显式调用,或者如果正确的说法是“暂时的”,就像在

a = b+c; 

其中显然不是分配后需要通过operator+返回的资源保持,因此,将其移动到a,而不是将其复制到另一个a的持有位置,并删除它更有效。

移动和复制是两回事。移动不是“复制的替代品”。它是一种更有效的方法,只有在对象不是需要才能生成其自身的克隆的所有情况下避免复制。

+0

谢谢!对象当然不同于变量,但C++为变量的* value *提供了罕见的(和有用的)选项作为对象 - 将对象的存储与变量的统一。你的例子显示了2个值类型的变量,这意味着2个实际的不同的对象应该在那里,一个可能是另一个的副本。是的,我希望'string b = a'在编译时失败,'string b = a.copy()'成功。 – Kos 2013-05-09 06:39:42

+0

@Kos:你实际上可以做到这一点。但是C++并没有强加给你。如果你想要一个所有对象都在堆上的框架,并且只有引用包装器/智能指针在堆栈上,你当然可以做到这一点。但是这和java没有太大的区别。那么为什么不使用Java呢?不要回答。考虑一下,然后决定。上下文 - 在这种情况下 - 比其他事情更重要。但从语言的角度来看,那些“引用包装器”或“智能指针”本身就是...值类。所以语言离不开它! – 2013-05-09 06:45:58

+0

@Kos:从一个惯用的角度来看,'strring b = copy(a)'应该可以更好地与'string b = move(a)'配对。复制和移动扮演“操纵者”的角色,而不是“成员”。 – 2013-05-09 06:50:14

复制构造函数有用的一种情况是在执行强壮异常保证时。

为了说明这一点,我们考虑函数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::vectorresize()调用之前的状态保存。

如果T没有移动构造函数,那么我们将默认为复制构造函数。在这种情况下,如果复制构造函数抛出异常,我们仍然可以提供强大的异常保证:我们只需deletenewData数组,并且已经完成对std::vector的处理。但是,如果我们使用T的移动构造函数,并且抛出异常,那么我们有一堆T s被移入newData数组中。回滚这个操作并不是直截了当的:如果我们试图将它们移回m_data数组,移动构造函数T可能会再次抛出异常!

要解决此问题,我们有std::move_if_noexcept函数。如果该函数被标记为noexcept,则该函数将使用T的移动构造函数,否则将使用复制构造函数。这使我们能够以提供强有力的例外保证的方式实施std::vector::resize

为了完整起见,我应该提到C++ 11 std::vector::resize在所有情况下都不提供强大的异常保证。根据www.cplusplus.com我们有以下保证:

如果n小于或等于容器的大小,函数永远不会抛出异常(无丢包保证)。 如果n较大并且重新分配发生,如果元素的类型是可复制或无法移动的,则在异常情况下(强保证),容器中不会有任何更改。 否则,如果引发异常,容器将保留有效状态(基本保证)。

+0

谢谢,这是你在这里提出的一个重要观点。但是它是否真的正常(或者:是否永远是正确的)抛出'swap',移动ctors并移动op = s?我甚至看到了一个[提案](http://www.open-std。org/jtc1/sc22/wg21/docs/papers/2009/n2855.html#nothrowmove)在这里强制使用nothrow。 – Kos 2013-05-09 07:05:06

+0

@Kos:不幸的是,它处于C++的当前状态。 – Puppy 2013-05-09 08:41:32

这是事情。移动是新的默认值 - 新的最低要求。但是复制仍然是一项非常有用和便利的操作。

没有人应该向后弯曲以提供复制构造函数。但如果您可以简单地提供它,它对于您的用户具有可复制性仍然很有用。我承认,对于我自己的类型,只有当它变得清晰时,我才会添加它们,而不是立即使用这些构造函数。到目前为止,这是非常非常少的类型。