- 复制对象是什么意思?
- 什么是拷贝构造函数和拷贝赋值操作符?
- 什么情况下需要手动声明它们?
- 如何防止对象被复制?
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;
}
这也解决了没有显式检查的自我赋值问题。 更加健壮的解决此问题的方法是拷贝并交换惯用法, 但我不会在这里详细讨论异常安全性。 我只提到异常来说明以下观点:编写管理资源的类很困难。
有些资源不能或不应该被复制,例如文件句柄或互斥量。
在这种情况下,只需将复制构造函数和复制赋值运算符声明为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 开始,一个对象具有两个额外的特殊成员函数: 移动构造函数和移动赋值运算符。五大法则建议同时实现这些函数。
以下是一个函数签名示例:
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
成员的简单代码,
与使用char*
的复杂且容易出错的替代方案即可。
只要避开原始指针成员,三个规则很少涉及您自己的代码。
boost::noncopyable
)继承(私有继承)。这种方法可能更加清晰。我认为 C++0x 和“删除”函数的可能性可以在这里提供帮助,但是我忘记了具体语法 :/ - Matthieu M.noncopyable
是std lib的一部分,否则我不认为它有多大的改进。(哦,如果你忘记了删除语法,那你忘记的比我知道的还要多。 :)
) - sbi“大三定律”就如上所述。
一个简单易懂的例子,是解决以下问题的:
非默认析构函数
在构造函数中分配了内存,因此需要编写析构函数来释放它。否则会导致内存泄漏。
您可能认为这个问题已经解决了。
问题在于,如果复制对象,则副本将指向与原始对象相同的内存。
当其中一个对象在其析构函数中删除内存时,另一个对象将指向无效内存(这称为悬空指针),当尝试使用它时,情况将变得混乱。
因此,您需要编写一个复制构造函数,以使新对象拥有自己的内存块进行管理。
赋值运算符和复制构造函数
在构造函数中为类成员指针分配了内存。当复制此类的对象时,默认的赋值运算符和复制构造函数将把该成员指针的值复制到新对象中。
这意味着新对象和旧对象将指向同一片内存空间,因此在更改其中一个对象时,另一个对象也会被更改。如果其中一个对象删除此内存,则另一个对象将继续尝试使用它 - 哎呀。
为了解决这个问题,您需要编写自己的复制构造函数和赋值运算符版本。您的版本将为新对象分配单独的内存,并复制第一个指针所指向的值,而不是其地址。
基本上,如果您有一个析构函数(不是默认析构函数),那么意味着您定义的类具有某些内存分配。假设该类被某些客户端代码或您自己在外部使用。
MyClass x(a, b);
MyClass y(c, d);
x = y; // This is a shallow copy if assignment operator is not provided
如果MyClass只有一些基本类型的成员,那么默认的赋值运算符就可以工作,但如果它有一些指针成员和没有赋值运算符的对象,结果将是不可预测的。因此,我们可以说如果在类的析构函数中需要删除一些内容,我们可能需要一个深度复制运算符,这意味着我们应该提供一个复制构造函数和赋值运算符。
如果 MyClass 只有一些基本类型的成员,那么默认的赋值运算符就可以工作,但如果它有一些指针成员和没有赋值运算符的对象,结果将是不可预测的。因此,我们可以说如果在类的析构函数中需要删除一些内容,我们可能需要一个深度复制运算符,这意味着我们应该提供一个复制构造函数和赋值运算符。
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;
}
}
深拷贝是指我们声明一个对象,然后创建一个完全独立的对象副本...最终我们得到了两个对象在两组完全不同的内存中。
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++中的三大法则是设计和开发的基本原则,如果以下三个成员函数中有一个有明确的定义,则程序员应该一起定义另外两个成员函数。换句话说,以下三个成员函数是不可或缺的:析构函数、复制构造函数和复制赋值运算符。
复制构造函数是C++中的特殊构造函数。它用于构建一个新对象,该对象等效于现有对象的副本。
复制赋值运算符是一种特殊的赋值运算符,通常用于将一个现有对象指定给同类型的其他对象。
以下是快速示例:
// default constructor
My_Class a;
// copy constructor
My_Class b(a);
// copy constructor
My_Class c = a;
// copy assignment operator
b = a;
c++-faq
标签wiki页面。 - sbi