- 复制对象是什么意思?
- 什么是复制构造函数和复制赋值运算符?
- 我什么时候需要自己申报?
- 如何防止我的对象被复制?
8 回答
介绍
C++ 使用值语义处理用户定义类型的变量。这意味着对象在各种上下文中被隐式复制,我们应该了解“复制对象”的实际含义。
让我们考虑一个简单的例子:
class person
{
std::string name;
int age;
public:
person(const std::string& name, int age) : name(name), age(age)
{
}
};
int main()
{
person a("Bjarne Stroustrup", 60);
person b(a); // What happens here?
b = a; // And here?
}
(如果您对此name(name), age(age)
部分感到困惑,这称为成员初始化器列表。)
特殊成员函数
复制对象是什么意思person
?该main
函数显示了两种不同的复制场景。初始化person b(a);
由复制构造函数执行。它的工作是根据现有对象的状态构造一个新对象。赋值b = a
由复制赋值运算符执行。它的工作通常稍微复杂一些,因为目标对象已经处于某种需要处理的有效状态。
由于我们自己既没有声明复制构造函数也没有声明赋值运算符(也没有析构函数),所以这些都是为我们隐式定义的。引用标准:
[...] 复制构造函数和复制赋值运算符,[...] 和析构函数是特殊的成员函数。[注意:当程序没有显式声明它们时,实现将为某些类类型隐式声明这些成员函数。 如果使用它们,实现将隐式定义它们。[...]尾注] [n3126.pdf 第 12 节 §1]
默认情况下,复制一个对象意味着复制它的成员:
非联合类 X 的隐式定义的复制构造函数执行其子对象的成员复制。[n3126.pdf 第 12.8 节第 16 节]
非联合类 X 的隐式定义的复制赋值运算符执行其子对象的成员复制赋值。[n3126.pdf 第 12.8 节第 30 节]
隐式定义
隐式定义的特殊成员函数person
如下所示:
// 1. copy constructor
person(const person& that) : name(that.name), age(that.age)
{
}
// 2. copy assignment operator
person& operator=(const person& that)
{
name = that.name;
age = that.age;
return *this;
}
// 3. destructor
~person()
{
}
在这种情况下,按成员复制正是我们想要的:
name
并且age
被复制,因此我们得到了一个自包含、独立的person
对象。隐式定义的析构函数始终为空。在这种情况下这也很好,因为我们没有在构造函数中获取任何资源。成员的析构函数在person
析构函数完成后被隐式调用:
在执行析构函数的主体并销毁主体内分配的任何自动对象后,X 类的析构函数调用 X 的直接 [...] 成员的析构函数 [n3126.pdf 12.4 §6]
管理资源
那么我们什么时候应该显式声明这些特殊的成员函数呢?当我们的类管理一个资源时,即当类的一个对象负责该资源时。这通常意味着资源在构造函数中获取(或传递给构造函数)并在析构函数中释放。
让我们回到标准前的 C++。没有这样的东西std::string
,程序员都爱上了指针。该类person
可能看起来像这样:
class person
{
char* name;
int age;
public:
// the constructor acquires a resource:
// in this case, dynamic memory obtained via new[]
person(const char* the_name, int the_age)
{
name = new char[strlen(the_name) + 1];
strcpy(name, the_name);
age = the_age;
}
// the destructor must release this resource via delete[]
~person()
{
delete[] name;
}
};
即使在今天,人们仍然用这种风格编写类并遇到麻烦:“我将一个人推入向量中,现在我得到了疯狂的内存错误! ”请记住,默认情况下,复制对象意味着复制其成员,但复制name
成员只是复制一个指针,而不是它指向的字符数组!这有几个不愉快的影响:
- 通过
a
可以观察到变化b
。 - 一旦
b
被销毁,a.name
就是一个悬空指针。 - 如果
a
被销毁,则删除悬空指针会产生未定义的行为。 - 由于分配没有考虑
name
分配之前指向的内容,因此您迟早会到处出现内存泄漏。
显式定义
由于按成员复制没有达到预期的效果,我们必须显式定义复制构造函数和复制赋值运算符来对字符数组进行深度复制:
// 1. copy constructor
person(const person& that)
{
name = new char[strlen(that.name) + 1];
strcpy(name, that.name);
age = that.age;
}
// 2. copy assignment operator
person& operator=(const person& that)
{
if (this != &that)
{
delete[] name;
// This is a dangerous point in the flow of execution!
// We have temporarily invalidated the class invariants,
// and the next statement might throw an exception,
// leaving the object in an invalid state :(
name = new char[strlen(that.name) + 1];
strcpy(name, that.name);
age = that.age;
}
return *this;
}
注意初始化和赋值之间的区别:我们必须在赋值之前拆除旧状态name
以防止内存泄漏。此外,我们必须防止表单的自我分配x = x
。如果没有该检查,delete[] name
将删除包含源字符串的数组,因为当您写入时x = x
,两者都this->name
包含that.name
相同的指针。
异常安全
new char[...]
不幸的是,如果由于内存耗尽而引发异常,此解决方案将失败。一种可能的解决方案是引入局部变量并重新排序语句:
// 2. copy assignment operator
person& operator=(const person& that)
{
char* local_name = new char[strlen(that.name) + 1];
// If the above statement throws,
// the object is still in the same state as before.
// None of the following statements will throw an exception :)
strcpy(local_name, that.name);
delete[] name;
name = local_name;
age = that.age;
return *this;
}
这也可以在没有明确检查的情况下处理自分配。这个问题的一个更强大的解决方案是copy-and-swap idiom,但我不会在这里详细介绍异常安全性。我只提到例外是为了说明以下几点:编写管理资源的类很困难。
不可复制的资源
某些资源不能或不应该被复制,例如文件句柄或互斥体。在这种情况下,只需将复制构造函数和复制赋值运算符声明为private
不给出定义:
private:
person(const person& that);
person& operator=(const person& that);
或者,您可以继承boost::noncopyable
它们或将它们声明为已删除(在 C++11 及更高版本中):
person(const person& that) = delete;
person& operator=(const person& that) = delete;
三分法则
有时您需要实现一个管理资源的类。(永远不要在一个类中管理多个资源,这只会导致痛苦。)在这种情况下,请记住三原则:
如果您需要自己显式声明析构函数、复制构造函数或复制赋值运算符,您可能需要显式声明所有这三个。
(不幸的是,这个“规则”不是由 C++ 标准或我知道的任何编译器强制执行的。)
五分法则
从 C++11 开始,对象有 2 个额外的特殊成员函数:移动构造函数和移动赋值。实现这些功能的五国规则也是如此。
带有签名的示例:
class person
{
std::string name;
int age;
public:
person(const std::string& name, int age); // Ctor
person(const person &) = default; // 1/5: Copy Ctor
person(person &&) noexcept = default; // 4/5: Move Ctor
person& operator=(const person &) = default; // 2/5: Copy Assignment
person& operator=(person &&) noexcept = default; // 5/5: Move Assignment
~person() noexcept = default; // 3/5: Dtor
};
零法则
3/5 规则也称为 0/3/5 规则。规则的零部分规定,在创建类时,您可以不编写任何特殊成员函数。
建议
大多数时候,您不需要自己管理资源,因为诸如此类的现有类std::string
已经为您完成了。只需将使用std::string
成员的简单代码与使用 a 的复杂且容易出错的替代代码进行比较char*
,您就会被说服。只要您远离原始指针成员,三法则就不太可能涉及您自己的代码。
三巨头的法则如前所述。
一个简单的例子,用简单的英语,它解决了什么样的问题:
非默认析构函数
您在构造函数中分配了内存,因此您需要编写一个析构函数来删除它。否则会导致内存泄漏。
你可能会认为这已经完成了。
问题是,如果您的对象有一个副本,那么该副本将指向与原始对象相同的内存。
有一次,其中一个会删除其析构函数中的内存,另一个将有一个指向无效内存的指针(这称为悬空指针),当它尝试使用它时,事情就会变得棘手。
因此,您编写了一个复制构造函数,以便它为新对象分配它们自己的内存块来销毁。
赋值运算符和复制构造函数
您在构造函数中将内存分配给类的成员指针。当你复制这个类的一个对象时,默认赋值运算符和复制构造函数会将这个成员指针的值复制到新对象。
这意味着新对象和旧对象将指向同一块内存,因此当您在一个对象中更改它时,另一个对象也会更改。如果一个对象删除了这个内存,另一个对象将继续尝试使用它 - eek。
为了解决这个问题,您编写自己版本的复制构造函数和赋值运算符。您的版本为新对象分配单独的内存并复制第一个指针指向的值而不是其地址。
基本上,如果您有一个析构函数(不是默认析构函数),则意味着您定义的类有一些内存分配。假设某些客户端代码或您在外部使用该类。
MyClass x(a, b);
MyClass y(c, d);
x = y; // This is a shallow copy if assignment operator is not provided
如果 MyClass 只有一些原始类型的成员,则默认赋值运算符会起作用,但如果它有一些指针成员和没有赋值运算符的对象,则结果将是不可预测的。因此我们可以说,如果在类的析构函数中有要删除的东西,我们可能需要一个深拷贝操作符,这意味着我们应该提供一个拷贝构造函数和赋值操作符。
复制对象是什么意思?有几种方法可以复制对象——让我们谈谈你最有可能提到的两种——深拷贝和浅拷贝。
由于我们使用的是面向对象的语言(或者至少假设是这样),假设您分配了一块内存。由于它是一种面向对象语言,我们可以轻松地引用我们分配的内存块,因为它们通常是原始变量(整数、字符、字节)或我们定义的由我们自己的类型和原语组成的类。因此,假设我们有一个 Car 类,如下所示:
class Car //A very simple class just to demonstrate what these definitions mean.
//It's pseudocode C++/Javaish, I assume strings do not need to be allocated.
{
private String sPrintColor;
private String sModel;
private String sMake;
public changePaint(String newColor)
{
this.sPrintColor = newColor;
}
public Car(String model, String make, String color) //Constructor
{
this.sPrintColor = color;
this.sModel = model;
this.sMake = make;
}
public ~Car() //Destructor
{
//Because we did not create any custom types, we aren't adding more code.
//Anytime your object goes out of scope / program collects garbage / etc. this guy gets called + all other related destructors.
//Since we did not use anything but strings, we have nothing additional to handle.
//The assumption is being made that the 3 strings will be handled by string's destructor and that it is being called automatically--if this were not the case you would need to do it here.
}
public Car(const Car &other) // Copy Constructor
{
this.sPrintColor = other.sPrintColor;
this.sModel = other.sModel;
this.sMake = other.sMake;
}
public Car &operator =(const Car &other) // Assignment Operator
{
if(this != &other)
{
this.sPrintColor = other.sPrintColor;
this.sModel = other.sModel;
this.sMake = other.sMake;
}
return *this;
}
}
深拷贝是如果我们声明一个对象,然后创建一个完全独立的对象副本……我们最终会在 2 组完整的内存中得到 2 个对象。
Car car1 = new Car("mustang", "ford", "red");
Car car2 = car1; //Call the copy constructor
car2.changePaint("green");
//car2 is now green but car1 is still red.
现在让我们做一些奇怪的事情。假设 car2 要么编程错误,要么故意共享 car1 的实际内存。(这样做通常是一个错误,并且在课堂上通常是它在下面讨论的毯子。)假装每当你询问 car2 时,你真的在解析指向 car1 内存空间的指针......这或多或少是一个浅拷贝是。
//Shallow copy example
//Assume we're in C++ because it's standard behavior is to shallow copy objects if you do not have a constructor written for an operation.
//Now let's assume I do not have any code for the assignment or copy operations like I do above...with those now gone, C++ will use the default.
Car car1 = new Car("ford", "mustang", "red");
Car car2 = car1;
car2.changePaint("green");//car1 is also now green
delete car2;/*I get rid of my car which is also really your car...I told C++ to resolve
the address of where car2 exists and delete the memory...which is also
the memory associated with your car.*/
car1.changePaint("red");/*program will likely crash because this area is
no longer allocated to the program.*/
因此,无论您使用哪种语言编写,在复制对象时都要非常小心您的意思,因为大多数时候您想要一个深层副本。
什么是复制构造函数和复制赋值运算符?我已经在上面使用过它们。当您键入代码时调用复制构造函数,例如Car car2 = car1;
本质上,如果您声明一个变量并在一行中分配它,那就是调用复制构造函数的时候。赋值运算符是使用等号时发生的情况-- car2 = car1;
。通知car2
未在同一语句中声明。您为这些操作编写的两段代码可能非常相似。事实上,典型的设计模式有另一个函数,一旦你对初始复制/分配是合法的感到满意,你就可以调用它来设置一切——如果你看一下我写的速记代码,这些函数几乎是相同的。
我什么时候需要自己申报?如果您不以某种方式编写要共享或用于生产的代码,那么您实际上只需要在需要它们时声明它们。如果您“偶然”选择使用程序语言并且没有使用它,您确实需要了解您的程序语言会做什么 - 即您获得编译器默认值。例如,我很少使用复制构造函数,但赋值运算符覆盖很常见。您是否知道您也可以覆盖加法、减法等的含义?
如何防止我的对象被复制?使用私有函数覆盖允许为对象分配内存的所有方式是一个合理的开始。如果您真的不希望人们复制它们,您可以将其公开并通过引发异常而不复制对象来提醒程序员。
我什么时候需要自己申报?
三法则规定,如果您声明任何
- 复制构造函数
- 复制赋值运算符
- 析构函数
那么你应该声明所有三个。它源于以下观察:接管复制操作含义的需要几乎总是源于执行某种资源管理的类,这几乎总是意味着
在一个复制操作中进行的任何资源管理都可能需要在另一个复制操作中完成,并且
类析构函数也将参与资源的管理(通常是释放它)。要管理的经典资源是内存,这就是为什么所有管理内存的标准库类(例如,执行动态内存管理的 STL 容器)都声明“三巨头”:复制操作和析构函数。
三规则的结果是用户声明的析构函数的存在表明简单的成员明智复制不太可能适合类中的复制操作。反过来,这表明如果一个类声明了一个析构函数,则可能不应该自动生成复制操作,因为它们不会做正确的事情。在采用 C++98 的时候,这种推理的重要性还没有被完全理解,因此在 C++98 中,用户声明的析构函数的存在对编译器生成复制操作的意愿没有影响。在 C++11 中仍然如此,但这只是因为限制生成复制操作的条件会破坏太多遗留代码。
如何防止我的对象被复制?
将复制构造函数和复制赋值运算符声明为私有访问说明符。
class MemoryBlock
{
public:
//code here
private:
MemoryBlock(const MemoryBlock& other)
{
cout<<"copy constructor"<<endl;
}
// Copy assignment operator.
MemoryBlock& operator=(const MemoryBlock& other)
{
return *this;
}
};
int main()
{
MemoryBlock a;
MemoryBlock b(a);
}
在 C++11 以后,您还可以声明复制构造函数和赋值运算符已删除
class MemoryBlock
{
public:
MemoryBlock(const MemoryBlock& other) = delete
// Copy assignment operator.
MemoryBlock& operator=(const MemoryBlock& other) =delete
};
int main()
{
MemoryBlock a;
MemoryBlock b(a);
}
许多现有的答案已经涉及复制构造函数、赋值运算符和析构函数。但是,在 C++11 之后,引入移动语义可能会将其扩展到 3 之外。
最近 Michael Claisse 做了一个涉及这个话题的演讲: http ://channel9.msdn.com/events/CPP/C-PP-Con-2014/The-Canonical-Class
C++中的三规则是设计和开发三个要求的基本原则,即如果以下成员函数之一有明确定义,则程序员应将其他两个成员函数一起定义。即以下三个成员函数是必不可少的:析构函数、复制构造函数、复制赋值运算符。
C++ 中的复制构造函数是一种特殊的构造函数。它用于构建新对象,新对象相当于现有对象的副本。
复制赋值运算符是一种特殊的赋值运算符,通常用于将现有对象指定给其他同类型对象。
有一些简单的例子:
// default constructor
My_Class a;
// copy constructor
My_Class b(a);
// copy constructor
My_Class c = a;
// copy assignment operator
b = a;