我们何时必须使用复制构造函数?
我知道C ++编译器为类创建一个拷贝构造函数。 在这种情况下,我们必须编写一个用户定义的拷贝构造函数吗? 你能举一些例子吗?
编译器生成的复制构造函数执行成员智能复制。 有时候这还不够。 例如:
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
统治的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 Constructor
和Assignment Operator
,因为unique_ptr
没有定义这些操作......但这里没关系;)
因此, sharptooth
的班级重新审视:
class Class
{
public:
Class(char const* str): mData(str) {}
private:
std::string mData;
};
我不了解你,但我觉得我更容易;)
我可以从我的实践中回想起来,并想到以下情况,当需要处理显式声明/定义复制构造函数时。 我已将案例分为两类
正确/语义
我在本节中将声明/定义复制构造函数的情况放在正确操作使用该类型的程序的情况下。
阅读本节后,您将了解到允许编译器自行生成复制构造函数的几个缺陷。 因此,正如他在答复中指出的那样,关闭新课程的可复制性始终是安全的,并在以后真正需要时刻刻启用它。
如何使一个类在C ++ 03中不可复制
声明一个私有的复制构造函数,但不提供它的实现(即使该类型的对象被复制到类的自己的作用域或其朋友中,构建也会在连接阶段失败)。
如何使一个类在C ++ 11或更新版本中不可复制
在末尾用=delete
声明复制构造函数。
浅vs深层复制
这是最好理解的案例,实际上是其他答案中提到的唯一案例。 shaprtooth已经很好地覆盖了它。 我只想补充说,应该由对象专有的复制资源可以应用于任何类型的资源,其中动态分配的内存只是一种。 如果需要,可能还需要深度复制对象
自我注册的对象
考虑一个类,其中所有对象 - 不管它们是如何构建的 - 都必须以某种方式注册。 一些例子:
最简单的例子:维护当前存在的对象的总数。 对象注册只是增加静态计数器。
一个更复杂的例子是拥有一个单例注册表,其中存储对该类型的所有现有对象的引用(以便可以将通知传递给它们中的所有对象)。
引用计数的智能指针可以被认为只是这个类别中的一个特例:新指针“注册”自己与共享资源而不是全局注册表。
这种自注册操作必须由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必须在自行注册的行为中获得)。
在新对象不能继承源对象的历史记录的情况下,对象的历史记录(例如撤消/重做堆栈),而是从单个历史记录项“从<OTHER_OBJECT_ID>的<TIME>复制”开始。
在这种情况下,复制构造函数必须跳过复制相应的子对象。
强制复制构造函数的正确签名
编译器提供的拷贝构造函数的签名取决于哪些拷贝构造函数可用于子对象。 如果至少有一个子对象没有真正的拷贝构造函数 (通过常量引用获取源对象),而是具有变异的拷贝构造函数 (通过非常量引用获取源对象),那么编译器将别无选择但要隐式声明并定义一个变异的复制构造函数。
现在,如果子对象类型的“变异”拷贝构造函数实际上不会改变源对象(并且只是由不知道const
关键字的程序员编写的),那该怎么办? 如果我们不能通过添加缺少的const
修复该代码,那么另一种选择是用正确的签名声明我们自己的用户定义的拷贝构造函数,并承担转向const_cast
。
写时复制(COW)
一个COW容器已经给出了对其内部数据的直接引用,必须在构建时进行深度复制,否则它可能表现为引用计数句柄。
尽管COW是一种优化技术,但拷贝构造函数中的这个逻辑对于它的正确实现是至关重要的。 这就是为什么我把这个案例放在这里,而不是在我们下一步的“优化”部分。
优化
在以下情况下,您可能需要/不需要为优化问题定义自己的拷贝构造函数:
复制期间的结构优化
考虑一个支持元素删除操作的容器,但可以通过简单地将删除的元素标记为已删除元素并稍后回收其插槽来实现。 当制作这样一个容器的副本时,压缩仍然存在的数据可能是有意义的,而不是按原样保留“已删除”的插槽。
跳过复制不可观察状态
一个对象可能包含不属于其可观察状态的数据。 通常,这是在对象的生命周期中累积的缓存/记忆数据,以加速对象执行的某些缓慢查询操作。 跳过复制该数据是安全的,因为它将在执行相关操作时(以及如果!)重新计算。 复制这些数据可能是不合理的,因为如果对象的可观察状态(从中导出缓存数据)通过变异操作被修改(如果我们不打算修改对象,为什么我们要创建一个深层然后复制?)
只有当辅助数据比表示可观察状态的数据大时,这种优化才是合理的。
禁用隐式复制
C ++允许通过声明拷贝构造函数来禁用隐复制explicit
。 然后,该类的对象不能被传入函数和/或通过值返回函数。 这个技巧可以用于看起来很轻量级的类型,但是确实复制起来非常昂贵(尽管如此,使它准可复制也许是更好的选择)。
在C ++ 03中声明一个复制构造函数也需要定义它(当然,如果你打算使用它)。 因此,仅仅从讨论的关注点出发寻找这样的复制构造器意味着您必须编写与编译器自动为您生成的代码相同的代码。
C ++ 11和更新的标准允许声明特殊的成员函数(默认和复制构造函数,复制赋值运算符和析构函数)并使用默认实现的明确请求(仅以=default
结束声明)。
待办事项
这个答案可以改进如下: