我们何时需要使用复制构造函数?

95

我知道C++编译器会为类创建一个复制构造函数。在哪些情况下,我们需要编写自定义的复制构造函数?你能给出一些例子吗?


https://dev59.com/AGcs5IYBdhLWcg3wym8h - usman allam
1
编写自己的复制构造函数的情况之一:当您需要进行深度复制时。还要注意,一旦创建了构造函数,除非使用默认关键字,否则不会为您创建默认构造函数。 - gawkface
7个回答

81

编译器生成的拷贝构造函数进行成员逐一复制。有时这是不够的。例如:

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
它执行的不是按位复制,而是成员逐个复制,特别是对于类类型成员将调用其拷贝构造函数。 - Georg Fritzsche
7
不要像那样编写赋值运算符,这样不安全。如果 new 抛出异常,对象将处于未定义状态,并且 store 指向已释放的内存部分(仅在所有可能抛出异常的操作成功完成后才释放内存)。一个简单的解决方案是使用拷贝并交换(copy swap)模式。 - Martin York
@sharptooth 在倒数第三行你写的是 delete stored[];,我认为应该改成 delete [] stored; - Peter Ajtai
4
我知道这只是一个例子,但你应该指出更好的解决方案是使用std::string。一般的想法是只有管理资源的实用类需要重载三个特殊成员函数(拷贝构造函数、赋值运算符和析构函数),而所有其他类都应该使用这些实用类,从而无需定义任何特殊成员函数。 - GManNickG
2
@Martin:我想确保它是铭刻在石头上的。 :P - GManNickG
显示剩余9条评论

49

我有点生气,因为没有引用“五法则”。

这个法则非常简单:

五法则:
当你编写析构函数、复制构造函数、赋值运算符、移动构造函数或移动赋值运算符中任何一种时,你可能需要编写其他四个。

但有一个更通用的指南,你应该遵循,它源于编写安全异常代码的需要:

每个资源都应由专门的对象管理

这里 @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来说,五法则是在三法则的基础上增加了移动构造函数和移动赋值运算符。 - Robert Andrzejuk
1
@Robb:请注意,正如最后一个示例所演示的那样,您通常应该以“零规则”为目标。只有专门的(通用)技术类应该关心处理一个资源,所有其他类都应该使用这些智能指针/容器而不必担心它。 - Matthieu M.
@MatthieuM。同意 :-) 我提到了五个规则,因为这个答案是在C++11之前的,并以“三大法宝”开头,但应该提到现在“五大法宝”是相关的。我不想在被问的情况下对这个答案进行投票,因为它是正确的。 - Robert Andrzejuk
@Robb:说得好,我更新了答案,提到了五法则而不是三大法则。希望现在大多数人已经使用C++11兼容的编译器了(我同情那些还没有的人)。 - Matthieu M.

34

我可以回忆起我的实践经验并想到以下情况,当需要明确声明/定义复制构造函数时,必须处理这些情况。我将这些情况分成两类:

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


正确性/语义

我在这个部分中列出了一些情况,在这些情况下,声明/定义复制构造函数对于使用该类型的程序的正确操作是必要的。

阅读完这个部分后,您将了解到允许编译器自动生成复制构造函数的几个陷阱。因此,正如seand在他的答案中指出的那样,对于一个新类关闭可复制性,并在真正需要时故意启用它总是安全的。

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

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

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

在copy-constructor后面声明=delete


浅拷贝 vs 深拷贝

这是最易理解的情况,实际上也是其他答案中唯一提到的情况。 shaprtooth已经很好地解释了。我只想补充说,深度复制应该专门拥有对象的资源(包括动态分配的内存)类型的任何资源。如果需要,深度复制一个对象还可能需要:

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

自注册对象

考虑一个类,其中所有对象 - 无论它们是如何构造的 - 必须以某种方式进行注册。一些示例:

  • 最简单的例子:维护当前存在对象的总计数。对象注册只涉及增加静态计数器。

  • 更复杂的例子是具有单例注册表,其中存储了该类型所有现有对象的引用(以便可以向它们所有人发送通知)。

  • 参考计数的智能指针可以被认为是该类别中的一个特殊情况:新指针会向共享资源注册自己,而不是在全局注册表中。

这种自注册操作必须由类型的任何构造函数执行,而复制构造函数也不例外。


具有内部交叉引用的对象

某些对象可能具有非平凡的内部结构,并且其不同子对象之间存在直接交叉引用(实际上,只需要一个这样的内部交叉引用即可触发此情况)。编译器提供的复制构造函数将打破内部对象内的关联,并将它们转换为对象间关联。

例如:

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 必须在自注册中获得)。

当新对象不得继承源对象的历史记录,而是要以单个历史记录项"从<OTHER_OBJECT_ID>于<TIME>复制"开始时,对象的历史记录(例如撤销/重做堆栈)的历史记录。

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


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

编译器提供的复制构造函数的签名取决于子对象可用的复制构造函数。如果至少有一个子对象没有真正的复制构造函数(通过常量引用接收源对象),而是具有变异复制构造函数(通过非常量引用接收源对象),那么编译器将别无选择,只能隐式声明并然后定义一个变异复制构造函数。

现在,如果子对象类型的"变异"复制构造函数实际上没有改变源对象(仅由不知道const关键字的程序员编写)呢?如果我们不能通过添加缺少的const来修复该代码,则另一个选项是声明我们自己的用户定义的复制构造函数,其中包含正确的签名,并犯罪使用const_cast


写时复制 (COW)

一种已经放弃其内部数据的COW容器必须在构造时进行深度复制,否则它可能会像引用计数句柄一样行为。

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



优化

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


复制过程中的结构优化

考虑支持元素删除操作的容器,但可能通过简单地将已删除的元素标记为已删除并稍后重新使用其插槽来执行此操作。当复制这样的容器时,将幸存数据压缩而不是保留"已删除"插槽可能是有意义的。


跳过复制非可观察状态

对象可以包含不属于其可观察状态的数据。通常,这是在对象的生命周期中积累的缓存/记忆数据,以加速对象执行的某些慢查询操作。跳过复制该数据是安全的,因为当(并且如果!)执行相关操作时,它将被重新计算。复制这些数据可能是不合理的,因为如果对象的可观察状态(从中派生缓存数据)由变异操作修改(如果我们不打算修改对象,那么为什么要创建深度复制?)则可能会很快失效。

只有在辅助数据相对于表示可观察状态的数据很大的情况下,才可以证明这种优化是合理的。


禁用隐式复制

C++ 允许通过声明复制构造函数为 explicit 来禁用隐式复制。然后该类的对象将无法按值传递到函数中或从函数中返回。这个技巧可以用于看起来轻量级但实际上非常昂贵的类型的情况(虽然,使其准可复制可能是更好的选择)。

在 C++03 中,声明复制构造函数需要同时定义它(当然,如果您想要使用它的话)。因此,仅出于正在讨论的原因而去使用这样的复制构造函数意味着您必须编写编译器会自动为您生成的相同代码。

C++11 和更新的标准允许声明特殊成员函数(默认和复制构造函数、复制赋值运算符和析构函数)并使用 默认实现的显式请求(只需将声明结尾处添加 =default )。



待办事项

可以通过以下方式改进此答案:

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

6
如果您有一个包含动态分配内容的类。例如,您将书的标题存储为char *并使用new设置标题,则复制将无法正常工作。
您需要编写一个复制构造函数,其中包括title = new char[length+1],然后是strcpy(title, titleIn)。复制构造函数只会进行“浅层”复制。

2

当对象被按值传递、按值返回或显式复制时,将调用复制构造函数。如果没有复制构造函数,C++会创建一个默认的复制构造函数,它会进行浅拷贝。如果对象没有指向动态分配内存的指针,则浅拷贝就足够了。


0
通常情况下,禁用复制构造函数和赋值运算符是一个好主意,除非类明确需要它们。这可以防止效率低下的情况,例如当引用被预期时却通过值传递参数。此外,编译器生成的方法可能无效。

-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();输出垃圾数据,因为创建了一个用户定义的复制构造函数,但没有编写显式复制数据的代码。因此编译器不会创建相同的函数。

只是想与大家分享这个知识,虽然你们中的大多数人已经知道了。

干杯... 愉快的编码!!!


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接