我知道 C++ 编译器为一个类创建了一个复制构造函数。在这种情况下,我们必须编写用户定义的复制构造函数吗?你能举一些例子吗?
7 回答
编译器生成的复制构造函数进行成员复制。有时这还不够。例如:
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;
}
我有点生气的Rule of Five
是没有引用规则。
这条规则很简单:
五法则:
每当您编写析构函数、复制构造函数、复制赋值运算符、移动构造函数或移动赋值运算符之一时,您可能需要编写其他四个。
但是您应该遵循一个更通用的准则,它源于编写异常安全代码的需要:
每个资源都应由专用对象管理
Here@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 Constructor
,Assignment Operator
虽然,因为unique_ptr
没有定义这些操作......但在这里没关系;)
因此,sharptooth
的班级重新审视:
class Class
{
public:
Class(char const* str): mData(str) {}
private:
std::string mData;
};
我不了解你,但我发现我的更容易;)
我可以从我的实践中回想一下,当一个人必须处理显式声明/定义复制构造函数时,我会想到以下情况。我将案例分为两类
- 正确性/语义- 如果您不提供用户定义的复制构造函数,则使用该类型的程序可能无法编译,或者可能无法正常工作。
- 优化- 为编译器生成的复制构造函数提供一个很好的替代方案,可以使程序更快。
正确性/语义
我在本节中介绍了声明/定义复制构造函数对于使用该类型的程序的正确操作是必要的情况。
阅读完本节后,您将了解允许编译器自行生成复制构造函数的几个陷阱。因此,正如sead在他的回答中指出的那样,关闭新类的可复制性并在以后真正需要时故意启用它总是安全的。
如何在 C++03 中使类不可复制
声明一个私有的复制构造函数并且不为它提供一个实现(这样即使该类型的对象被复制到类自己的范围内或由它的朋友复制,构建也会在链接阶段失败)。
如何在 C++11 或更高版本中使类不可复制
=delete
用结尾声明复制构造函数。
浅拷贝与深拷贝
这是最好理解的案例,实际上是其他答案中唯一提到的案例。shaprtooth已经很好地覆盖了它。我只想补充一点,应该由对象独占的深度复制资源可以应用于任何类型的资源,其中动态分配的内存只是一种。如果需要,深度复制对象可能还需要
- 复制磁盘上的临时文件
- 打开单独的网络连接
- 创建一个单独的工作线程
- 分配一个单独的 OpenGL 帧缓冲区
- ETC
自注册对象
考虑一个类,其中所有对象——无论它们是如何构造的——都必须以某种方式注册。一些例子:
最简单的例子:维护当前存在对象的总数。对象注册只是增加静态计数器。
一个更复杂的例子是有一个单例注册表,其中存储了对该类型的所有现有对象的引用(以便可以将通知传递给所有对象)。
引用计数的智能指针可以被认为只是这个类别中的一个特例:新指针将自己“注册”到共享资源而不是全局注册表中。
这种自注册操作必须由该类型的任何构造函数执行,复制构造函数也不例外。
具有内部交叉引用的对象
一些对象可能具有非平凡的内部结构,它们的不同子对象之间有直接的交叉引用(事实上,只有一个这样的内部交叉引用就足以触发这种情况)。编译器提供的复制构造函数将破坏内部对象内关联,将它们转换为对象间关联。
一个例子:
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 必须在自注册行为中获得)。
当新对象不能继承源对象的历史,而是从单个历史项“ Copied at <TIME> from <OTHER_OBJECT_ID> ”开始时,对象的历史(例如撤消/重做堆栈)。
在这种情况下,复制构造函数必须跳过复制相应的子对象。
强制复制构造函数的正确签名
编译器提供的复制构造函数的签名取决于哪些复制构造函数可用于子对象。如果至少一个子对象没有真正的复制构造函数(通过常量引用获取源对象)而是具有变异复制构造函数(通过非常量引用获取源对象),那么编译器将别无选择但是隐式声明然后定义一个变异的复制构造函数。
现在,如果子对象类型的“变异”复制构造函数实际上并没有改变源对象(并且只是由不了解const
关键字的程序员编写)怎么办?如果我们不能通过添加 missing 来修复该代码const
,那么另一个选择是使用正确的签名声明我们自己的用户定义的复制构造函数,并犯下转向 a 的罪过const_cast
。
写时复制 (COW)
一个 COW 容器如果直接引用了其内部数据,则必须在构造时进行深度复制,否则它可能表现为引用计数句柄。
虽然 COW 是一种优化技术,但复制构造函数中的这种逻辑对其正确实现至关重要。这就是为什么我把这个案例放在这里而不是放在我们接下来要去的“优化”部分。
优化
在以下情况下,出于优化考虑,您可能希望/需要定义自己的复制构造函数:
复制过程中的结构优化
考虑一个支持元素删除操作的容器,但可以通过简单地将已删除元素标记为已删除来实现,并稍后回收其插槽。当制作这样一个容器的副本时,压缩幸存的数据而不是保留“已删除”的插槽可能是有意义的。
跳过复制不可观察的状态
一个对象可能包含不属于其可观察状态的数据。通常,这是在对象的生命周期内累积的缓存/记忆数据,以加速对象执行的某些慢速查询操作。跳过复制该数据是安全的,因为它将在执行相关操作时(如果!)重新计算。复制这些数据可能是不合理的,因为如果对象的可观察状态(从中派生缓存数据)被变异操作修改(如果我们不打算修改对象,为什么我们要创建一个深然后复制?)
仅当辅助数据与表示可观察状态的数据相比较大时,这种优化才是合理的。
禁用隐式复制
C++ 允许通过声明复制构造函数来禁用隐式复制explicit
。然后该类的对象不能按值传递给函数和/或从函数返回。这个技巧可以用于看起来很轻量但复制起来确实非常昂贵的类型(尽管,使其准可复制可能是更好的选择)。
在 C++03 中声明复制构造函数也需要定义它(当然,如果您打算使用它)。因此,仅仅出于所讨论的问题而使用这样的复制构造函数意味着您必须编写编译器会自动为您生成的相同代码。
C++11 和更新的标准允许声明特殊成员函数(默认和复制构造函数、复制赋值运算符和析构函数),并明确请求使用默认实现 (只需用 结束声明
=default
)。
待办事项
这个答案可以改进如下:
- 添加更多示例代码
- 说明“具有内部交叉引用的对象”案例
- 添加一些链接
如果您有一个具有动态分配内容的类。例如,您将一本书的标题存储为 char * 并将标题设置为新的,副本将不起作用。
您必须编写一个复制构造函数,title = new char[length+1]
然后strcpy(title, titleIn)
. 复制构造函数只会做一个“浅”的复制。
当对象按值传递、按值返回或显式复制时,将调用复制构造函数。如果没有复制构造函数,c++ 会创建一个默认的复制构造函数来进行浅拷贝。如果对象没有指向动态分配内存的指针,那么浅拷贝就可以了。
禁用复制 ctor 和 operator= 通常是个好主意,除非该类特别需要它。这可以防止效率低下,例如在打算引用时按值传递 arg。编译器生成的方法也可能无效。
让我们考虑下面的代码片段:
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();
给出垃圾输出,因为创建了一个用户定义的复制构造函数,没有编写任何代码来显式复制数据。所以编译器不会创建相同的。
只是想与大家分享这些知识,尽管你们中的大多数人已经知道了。
干杯...快乐编码!