复制构造函数和赋值运算符:告诉我规则!






3.86/5 (14投票s)
我有时会被一些刚接触 C++ 的经验丰富的程序员问到这个问题。市面上有很多关于这个主题的好书,但我发现网上没有一套清晰简洁的规则,适合那些不想理解语言的每个细微差别——只想知道事实的人。
引言
我有时会被一些刚接触 C++ 的经验丰富的程序员问到这个问题。市面上有很多关于这个主题的好书,但我发现网上没有一套清晰简洁的规则,适合那些不想理解语言的每个细微差别——只想知道事实的人。
因此,这篇文章应运而生。
复制构造函数和赋值运算符的目的很容易理解,当你意识到即使你不写它们,它们也总在那里,并且它们有一个你可能已经理解的默认行为。每个 struct
和 class
都有一个默认的复制构造函数和赋值运算符方法。看看这些的简单用法。
从一个名为 Rect
且包含几个字段的 struct
开始
struct Rect {
int top, left, bottom right;
};
是的,即使是像这样简单的 struct
也有复制构造函数和赋值运算符。现在,看看这段代码
1: Rect r1 = { 0, 0, 100, 200 };
2: Rect r2( r1 );
3: Rect r3;
4: r3 = r1;
第 2 行调用 r2
的默认复制构造函数,将 r1
的成员复制到其中。第 3 行做类似的事情,但调用 r3
的默认赋值运算符,将 r1
的成员复制到其中。两者之间的区别在于,当源对象在目标对象被构造时传入时(如第 2 行所示),目标对象的复制构造函数会被调用。当目标对象已经存在时(如第 4 行所示),则调用赋值运算符。
看看默认实现产生了什么,检查第 4 行最终做了什么
1. r3.top = r1.top;
2. r3.left = r1.left;
3. r3.bottom = r1.bottom;
4. r3.right = r1.right;
那么,如果默认的复制构造函数和赋值运算符为你做了这一切,为什么有人会实现自己的呢?默认实现的麻烦在于,简单地复制成员可能不适合克隆对象。例如,如果其中一个成员是指向由类分配的指针呢?仅仅复制指针是不够的,因为现在你将有两个对象拥有相同的指针值,并且当它们析构时,两个对象都会尝试释放与该指针关联的内存。看看一个示例类
class Contact {
char* name;
int age;
public:
Contact( const char* inName, inAge ) {
name = new char[strlen( inName ) + 1];
strcpy( name, inName );
age = inAge;
}
~Contact() {
delete[] name;
}
};
现在,看看一些使用这个类的代码
Contact c1("Fred", 40 );
Contact c2 = c1;
问题是,c1
和 c2
的 "name
" 字段将具有相同的指针值。当 c2
离开作用域时,它的析构函数将被调用,并删除在 c1
被构造时分配的内存(因为两个对象的 name 字段都具有相同的指针值)。然后,当 c1
析构时,它将尝试删除指针值,从而发生“二次释放”。最坏的情况下,堆会捕获问题并报告错误。最坏的是,相同的指针值可能已经分配给了另一个对象,delete 会释放错误的内存,这会在代码中引入一个难以查找的 bug。
你要解决这个问题的方法是向类中添加显式的复制构造函数和赋值运算符,如下所示
Contact( const Contact& rhs ) {
name = new char[strlen( rhs.name ) + 1];
strcpy( name, rhs.name );
age = rhs.age;
}
Contact& operator=( const Contact& rhs ) {
char* tempName = new char[strlen( rhs.name ) + 1];
delete[] name;
name = tempName;
strcpy( name, rhs.name );
age = rhs.age;
return *this;
}
现在,使用该类的代码将正常工作。请注意,上面复制构造函数和赋值运算符之间的区别在于,复制构造函数可以假定对象的字段尚未设置(因为对象才刚刚被构造)。然而,赋值运算符必须处理字段已经具有有效值的情况。赋值运算符在赋值新 string
之前会删除现有 string
的内容。你可能会问为什么使用 tempName
局部变量,以及为什么代码不如下面这样写
delete[] name;
name = new char[strlen( rhs.name ) + 1];
strcpy( name, rhs.name );
age = rhs.age;
这段代码的问题在于,如果 new
操作符抛出异常,对象将处于错误状态,因为 name
字段已经由前一条指令释放了。通过先执行所有可能失败的操作,然后在没有异常发生风险的情况下替换字段,代码就是异常安全的。
注意:赋值运算符返回对象引用的原因是,以便像下面的代码能够工作
c1 = c2 = c3;
有人可能会认为,当类或结构体包含指针字段时,需要显式复制构造函数和赋值运算符。情况并非如此。在上面的例子中,需要显式方法是因为字段指向的数据由对象拥有。如果指针是“反向”(或弱)指针,或者是指向类不负责释放的另一个对象的引用,那么多个对象共享指针字段中的值可能是完全有效的。
有时,类字段实际上指向一个无法复制的实体,或者复制它没有意义。例如,如果该字段是一个指向它创建的文件的句柄怎么办?复制对象可能需要创建另一个文件,该文件有自己的句柄。但是,对于给定的对象,可能无法创建多个文件。在这种情况下,类不可能有有效的复制构造函数或赋值运算符。正如你之前所见,简单地不实现它们并不意味着它们不会存在,因为当未指定显式版本时,编译器会提供默认版本。解决方案是在类中提供复制构造函数和赋值运算符,并将它们标记为 private
。只要没有代码尝试复制对象,一切都会正常工作,但一旦引入尝试复制对象的代码,编译器就会指示复制构造函数或赋值运算符无法访问的错误。
要创建 private
复制构造函数和赋值运算符,不需要为这些方法提供实现。只需在类定义中声明它们就足够了。
示例
private:
Contact( const Contact& rhs );
Contact& operator=( const Contact& rhs );
这将禁用 C++ 为所有类提供的默认复制语义。
有些人希望 C++ 在程序员没有提供隐式复制构造函数和赋值运算符时,不提供它们。为了模拟这种愿望,这些程序员在定义新类时总是定义一个 private
复制构造函数和赋值运算符,因此上面三行是一个常见的模式。当使用时,这种模式将阻止任何人复制他们的对象,除非他们显式支持此类操作。
这是一个好习惯:除非你明确需要支持实例的深度复制,否则请使用上述技术禁用复制。
(禁用复制的另一个优点是 auto_ptr
可用于管理数据成员的生命周期,但这可能是另一篇文章的内容。)
结论
许多 C++ 程序员对复制构造函数和赋值运算符的语义细节不感兴趣。这篇文章应该对他们有用。它提供了足够的信息,以及一种他们可以使用的模式,以确保他们做正确的事情。
如果任何人觉得这篇文章仍然太复杂,请告诉我如何简化它。
历史
- 2008 年 2 月 9 日:首次发布