我们什么时候必须使用复制构造函数?


87

我知道C ++编译器会为一个类创建一个副本构造函数。在哪种情况下,我们必须编写用户定义的副本构造函数?你能举一些例子吗?



1
编写自己的copy-ctor的情况之一:当您必须进行深层复制时。还要注意,一旦创建了一个ctor,就不会为您创建默认的ctor(除非您使用default关键字)。
harshvchawla

Answers:


75

编译器生成的副本构造函数执行成员级复制。有时这还不够。例如:

class Class {
public:
    Class( const char* str );
    ~Class();
private:
    char* stored;
};

Class::Class( const char* str )
{
    stored = new char[srtlen( str ) + 1 ];
    strcpy( stored, str );
}

Class::~Class()
{
    delete[] stored;
}

在这种情况下,成员的成员级复制stored将不会复制缓冲区(仅会复制指针),因此共享缓冲区的第一个要销毁的副本将delete[]成功调用,而第二个将遇到未定义的行为。您需要深度复制副本构造函数(以及赋值运算符)。

Class::Class( const Class& another )
{
    stored = new char[strlen(another.stored) + 1];
    strcpy( stored, another.stored );
}

void Class::operator = ( const Class& another )
{
    char* temp = new char[strlen(another.stored) + 1];
    strcpy( temp, another.stored);
    delete[] stored;
    stored = temp;
}

10
它不执行按位复制,而是按成员复制,特别是为类类型成员调用copy-ctor。
Georg Fritzsche'7

7
不要像这样写分配运算符。它也不例外安全。(如果新方法引发异常,则该对象将处于未定义状态,并且存储指向内存中已释放的部分(仅在所有可以抛出的操作成功完成后才释放内存))。一个简单的解决方案是使用副本交换Idium。
马丁·约克

@sharptooth从底部开始的第三行delete stored[];,我相信应该是delete [] stored;
Peter Ajtai

4
我知道这只是一个示例,但是您应该指出更好的解决方案是使用std::string。通常的想法是,只有管理资源的实用程序类需要重载三巨头,而其他所有类都应仅使用这些实用程序类,而无需定义任何三巨头。
GManNickG

2
@马丁:我想确保它是刻在石头上的。:P
GManNickG 2010年

46

我对Rule of Five未引用的规则感到有点恼火。

这个规则很简单:

五法则
无论何时编写析构函数,复制构造函数,复制赋值运算符,移动构造函数或移动赋值运算符之一,您可能都需要编写另外四个。

但是,您应该遵循一个更通用的准则,该准则源于编写异常安全代码的需要:

每个资源应由专用对象管理

这里@sharptooth的代码(大部分)仍然可以正常工作,但是,如果他要将第二个属性添加到他的类中,那就不是。考虑以下类别:

class Erroneous
{
public:
  Erroneous();
  // ... others
private:
  Foo* mFoo;
  Bar* mBar;
};

Erroneous::Erroneous(): mFoo(new Foo()), mBar(new Bar()) {}

如果new Bar抛出该怎么办?如何删除指向的对象mFoo?有解决方案(功能级别try / catch ...),它们只是无法扩展。

处理这种情况的正确方法是使用适当的类而不是原始指针。

class Righteous
{
public:
private:
  std::unique_ptr<Foo> mFoo;
  std::unique_ptr<Bar> mBar;
};

使用相同的构造函数实现(或实际上使用make_unique),我现在可以免费获得异常安全!!!令人兴奋吗?最重要的是,我不再需要担心适当的析构函数!我需要写我自己的Copy ConstructorAssignment Operator,但因为unique_ptr没有定义这些操作...但它并不重要在这里;)

因此,sharptooth我们将再次上课:

class Class
{
public:
  Class(char const* str): mData(str) {}
private:
  std::string mData;
};

我不认识你,但我发现我更容易;)


对于C ++ 11-5的规则,将3的规则与Move Constructer和Move Assignment运算符相加。
罗伯特·安德列祖克

1
@Robb:请注意,实际上,如上一个示例所示,您通常应将目标定为零规则。只有专门的(通用)技术类应该关心处理一种资源,所有其他类都应该使用那些智能指针/容器,而不必担心。
Matthieu M.

@MatthieuM。同意:-)我提到了“五规则”,因为此答案在C ++ 11之前,并且以“三巨头”开头,但是应该指出的是,现在“五巨头”是相关的。我不想对此答案投反对票,因为在所要求的上下文中它是正确的。
罗伯特·安德热祖克

@Robb:好点,我更新了答案,提到了五法则而不是三巨头。希望到现在为止,大多数人已经转向了具有C ++ 11功能的编译器(我很遗憾那些还没有编译的人)。
Matthieu M.

32

我可以从我的实践中回想起,并想到以下情况,当必须处理显式声明/定义副本构造函数时。我将案例分为两类

  • 正确性/语义-如果您不提供用户定义的复制构造函数,则使用该类型的程序可能无法编译,或者可能无法正常工作。
  • 优化-为编译器生成的副本构造函数提供一个很好的替代方法,可以使程序更快。


正确/语义

在本节中,我将说明为使用该类型的程序进行正确操作而需要声明/定义复制构造函数的情况。

阅读完本节后,您将了解允许编译器自行生成复制构造函数的几个陷阱。因此,正如肖恩回答中指出的那样,关闭新类的可复制性并在以后真正需要时有意启用它总是安全的。

如何在C ++ 03中使类不可复制

声明一个私有的复制构造函数,并且不为其提供实现(这样,即使该类型的对象在类本身的作用域中或由其朋友复制,该构建在链接阶段也会失败)。

如何在C ++ 11或更高版本中使类不可复制

最后声明副本构造函数=delete


浅拷贝与深拷贝

这是最容易理解的情况,实际上是其他答案中唯一提及的情况。shaprtooth覆盖还挺好。我只想补充一下,应该由对象专有拥有的深度复制资源可以应用于任何类型的资源,其中动态分配的内存只是其中一种。如果需要,还可能需要深度复制对象

  • 复制磁盘上的临时文件
  • 打开单独的网络连接
  • 创建一个单独的工作线程
  • 分配一个单独的OpenGL帧缓冲区
  • 等等

自注册对象

考虑一类所有对象(无论它们如何构造)都必须以某种方式注册的类。一些例子:

  • 最简单的示例:维护当前现有对象的总数。对象注册仅与增加静态计数器有关。

  • 一个更复杂的示例是具有单例注册表,其中存储了对该类型的所有现有对象的引用(以便可以将通知传递给所有这些对象)。

  • 在本类别中,引用计数的智能指针可以被认为只是一种特殊情况:新指针将自己在共享资源中“注册”,而不是在全局注册表中。

这种自注册操作必须由该类型的ANY构造函数执行,并且复制构造函数也不例外。


具有内部交叉引用的对象

一些对象可能具有不平凡的内部结构,在它们的不同子对象之间具有直接的交叉引用(实际上,仅一个这样的内部交叉引用就足以触发这种情况)。编译器提供的副本构造函数将破坏内部的对象内关联,将其转换为对象间的关联。

一个例子:

struct MarriedMan;
struct MarriedWoman;

struct MarriedMan {
    // ...
    MarriedWoman* wife;   // association
};

struct MarriedWoman {
    // ...
    MarriedMan* husband;  // association
};

struct MarriedCouple {
    MarriedWoman wife;    // aggregation
    MarriedMan   husband; // aggregation

    MarriedCouple() {
        wife.husband = &husband;
        husband.wife = &wife;
    }
};

MarriedCouple couple1; // couple1.wife and couple1.husband are spouses

MarriedCouple couple2(couple1);
// Are couple2.wife and couple2.husband indeed spouses?
// Why does couple2.wife say that she is married to couple1.husband?
// Why does couple2.husband say that he is married to couple1.wife?

仅允许复制符合特定条件的对象

可能有些类在某些状态(例如,默认构造状态)下可以安全地复制对象,而在其他状态下则不能安全复制。如果要允许复制安全复制对象,则-如果进行防御性编程-我们需要在用户定义的复制构造函数中进行运行时检查。


不可复制的子对象

有时,应为可复制的类会聚合不可复制的子对象。通常,这种情况发生在状态不可观察的对象上(这种情况将在下面的“优化”部分中详细讨论)。编译器仅有助于识别这种情况。


准可复制子对象

应该是可复制的类可以聚合准可复制类型的子对象。准可复制类型在严格意义上没有提供复制构造函数,但是具有另一个允许创建对象的概念性副本的构造函数。使得类型为准可复制的原因是在关于类型的复制语义没有完全一致的情况下。

例如,在重新讨论对象自注册的情况下,我们可以争辩说,在某些情况下,仅当对象是完整的独立对象时,才必须向全局对象管理器注册对象。如果它是另一个对象的子对象,则管理它的责任在于它的包含对象。

或者,必须同时支持浅层和深层复制(默认情况下都不选择)。

然后,最终的决定权将留给该类型的用户-复制对象时,他们必须(通过附加参数)显式指定预期的复制方法。

在采用非防御性编程方法的情况下,也可能同时存在常规复制构造函数和准复制构造函数。当在大多数情况下应使用单一复制方法,而在极少数但众所周知的情况下,应使用替代复制方法时,这是有道理的。这样,编译器将不会抱怨无法隐式定义副本构造函数;记住并检查是否应通过准复制构造函数复制该类型的子对象是用户的唯一责任。


不要复制与对象身份密切相关的状态

在极少数情况下,对象可观察状态的子集可能构成(或被认为)对象身份的不可分割的一部分,并且不应转移给其他对象(尽管这可能会引起争议)。

例子:

  • 对象的UID(但该对象也从上面属于“自我注册”情况,因为必须通过自我注册的行为获得ID)。

  • 在新对象不能继承源对象的历史记录,而必须继承单个历史记录项“ <TIME>从<OTHER_OBJECT_ID>复制的时间”的情况下,对象的历史记录(例如,撤消/重做堆栈)。

在这种情况下,复制构造函数必须跳过复制相应的子对象。


强制执行复制构造函数的正确签名

编译器提供的复制构造函数的签名取决于哪些复制构造函数可用于子对象。如果至少一个子对象没有真正的副本构造函数(通过常量引用获取源对象),而是具有变异的副本构造函数(通过非常量引用获取源对象),则编译器将没有选择但要隐式声明然后定义一个变异的复制构造函数。

现在,如果子对象类型的“变异”复制构造函数实际上并未使源对象变异(并且仅仅是由不了解const关键字的程序员编写的)怎么办?如果我们无法通过添加缺少的代码来修复该代码const,那么另一种选择是使用正确的签名声明我们自己的用户定义的副本构造函数,并承担转向a的麻烦const_cast


写时复制(COW)

给出直接引用其内部数据的COW容器必须在构造时进行深度复制,否则它可以充当引用计数句柄。

尽管COW是一种优化技术,但复制构造函数中的此逻辑对其正确实现至关重要。这就是为什么我将这种情况放在这里,而不是放在下一步的“优化”部分的原因。



优化

在以下情况下,出于优化考虑,您可能希望/需要定义自己的副本构造函数:


复制期间的结构优化

考虑一个支持元素删除操作的容器,但是可以通过简单地将已删除元素标记为已删除并随后回收其插槽来实现。制作此类容器的副本时,可以压缩剩余的数据而不是照原样保留“已删除”的插槽。


跳过复制不可观察状态

对象可能包含的数据不是其可观察状态的一部分。通常,这是在对象的生存期内累积的缓存/存储的数据,以便加速对象执行的某些慢速查询操作。跳过复制该数据是安全的,因为将在(以及如果执行)相关操作时重新计算该数据。复制此数据可能是不合理的,因为如果通过突变操作修改了对象的可观察状态(从中派生高速缓存的数据)(如果我们不打算修改对象,为什么创建深层对象,则可能很快使该数据无效)然后复制?)

仅当辅助数据与表示可观察状态的数据相比较大时,这种优化才是合理的。


禁用隐式复制

C ++允许通过声明复制构造函数来禁用隐式复制explicit。然后,该类的对象无法按值传递到函数中和/或从函数中返回。此技巧可以用于看起来很轻便但复制确实非常昂贵的类型(尽管将其设为准复制可能是更好的选择)。

在C ++ 03中,声明副本构造函数也需要对其进行定义(当然,如果您打算使用它)。因此,仅出于讨论的考虑而选择这样的副本构造函数意味着您必须编写与编译器将自动为您生成的代码相同的代码。

C ++ 11和更高版本的标准允许使用显式请求声明特殊成员函数(默认和复制构造函数,复制分配运算符和析构函数)以使用默认实现 (以结束声明=default)。



待办事项

可以如下改进此答案:

  • 添加更多示例代码
  • 说明“带有内部交叉引用的对象”的情况
  • 添加一些链接

6

如果您具有动态分配内容的类。例如,将书名存储为char *并将书名设置为new,则复制将不起作用。

您将必须编写一个复制构造函数,title = new char[length+1]然后执行strcpy(title, titleIn)。复制构造函数只会执行“浅”复制。


2

当按值传递对象,按值返回或显式复制对象时,将调用Copy构造函数。如果没有复制构造函数,则c ++创建一个默认的复制构造函数,该构造函数将进行浅表复制。如果对象没有指向动态分配内存的指针,则浅拷贝将起作用。


0

除非类特别需要,否则禁用copy ctor和operator =通常是一个好主意。这样可以避免效率低下的情况,例如在需要参考时按值传递arg。同样,编译器生成的方法可能无效。


-1

让我们考虑下面的代码片段:

class base{
    int a, *p;
public:
    base(){
        p = new int;
    }
    void SetData(int, int);
    void ShowData();
    base(const base& old_ref){
        //No coding present.
    }
};
void base :: ShowData(){
    cout<<this->a<<" "<<*(this->p)<<endl;
}
void base :: SetData(int a, int b){
    this->a = a;
    *(this->p) = b;
}
int main(void)
{
    base b1;
    b1.SetData(2, 3);
    b1.ShowData();
    base b2 = b1; //!! Copy constructor called.
    b2.ShowData();
    return 0;
}

Output: 
2 3 //b1.ShowData();
1996774332 1205913761 //b2.ShowData();

b2.ShowData();之所以给出垃圾输出,是因为创建了一个用户定义的复制构造函数,而没有编写任何代码来显式复制数据。因此,编译器不会创建相同的对象。

只是想与大家共享此知识,尽管大多数人已经知道了。

干杯...编码愉快!

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.