每种类型都定义了对象的创建、复制、复制、销毁阶段需要做什么操作。这些是通过类型的复制构造函数、赋值操作符、析构函数来控制行为的。如果我们没有显示的定义这几类函数,编译器会为我们生成一组这些函数的实现。
1.复制构造函数
复制构造函数是一种特殊的构造函数,只有一个形参,形参的类型是当前类型的引用。当新定义一个对象,并用一个已存在的对象初始化时,就会显示调用复制构造函数。将该类型传递给函数或者作为函数的返回时,都会隐式调用复制构造函数。
1.应用场景
1.对象初始化
C++支持两种初始化形式,直接初始化和复制初始化。直接初始化把初始化式放在圆括号,复制初始化使用操作符=。对类类型对象两者的操作步骤不同,直接初始化直接调用对应的构造函数,复制初始化先根据参数调用对应的构造函数创建一个临时对象,然后再调用复制构造函数创建最终对象。我们来看一下例子
string s1 = "empty";
string s2("empty")
s2用const char*的构造函数完成直接初始化。s1先用const char*的构造函数(隐式转换函数)创建一个临时对象,再用复制构造函数创建最终对象对s1进行初始。理论上不使用复制构造函数的情况性能会更好,有的编译器会优化复制构造函数的调用。对于不支持复制操作的类型(如IO流),就无法使用复制初始化。
理论上很清晰,实际测试下来和理论略有差异,我在Visual C++ 2022里实测,定义两个构造函数,一个通过string构造,一个复制构造函数
class Person {
private:
std::string name;
unsigned age;
public:
Person(const std::string& s = "empty") : name(s), age(0) {
std::cout << "construct by string" << std::endl;
}
Person(Person& p) : name(p.name), age(p.age) {
std::cout << "copy initial" << std::endl;
}
};
// 3组测试
Person p1("randy"); // 输出construct by string
Person p2 = p1; // 输出copy initial
Person p3 = std::string("randy"); // 输出 construct by string
我做了3个测试,理论上p1一个输出construct by string,p2输出copy initial,p3应该先调用string的构造函数,然后再调用复制构造函数,输出两句文本,实测下来,只输出了construct by string。具体原因还待研究,欢迎对此有了解的同学解答。
2.出入参
将出入参为非引用类型时,编译器会自动调用复制构造函数。display函数形参p是非引用类型,会自动调用复制构造函数;response函数因为形参p是引用类型,不需要调用复制构造函数,而出差Person是非引用类型,自动调用复制构造函数。
void display(Person p) {
std::cout << "p: " << sizeof(p) << std::endl;
}
Person response(Person& p) {
std::cout << "response" << std::endl;
return p;
}
int main() {
Person p("randy"); // construct by string
display(p); // 实参复制,输出copy initial
response(p); // 返回只复制,输出copy initial
}
3.容器和数组
当我们定义指定大小的容器时,如果没有指定初始值。比如下面这个例子,定义了一个有5个元素的vector。编译器先用默认构造函数string("")构造一个临时对象,然后再用复制构造函数,复制5个元素,初始化整个vector。这也是为什么我们推荐创建一个空的容器对象,再将已知的元素插入到容器中,因为默认的初始值大多数情况下都是无意义的。
vector<string> vec(5);
数组的初始化略有不同,如果没有指定初始化式,使用元素的默认构造器初始化每个元素。如下面的例子,会调用5次默认构造函数
Person ps[5];
如果带有初始化式,理论上应该和对象初始化的逻辑一致,先调用string形参的构造器,在调用复制构造函数,实际有差异,不过我觉得这个输出更符合逻辑,可能是书本出版后,C++对实现有改动。
Person ps[] = {std::string("randy")}; // 输出 construct by string
2.定义函数
1.合成复制构造函数
合成复制构造函数的实现复制每一个非static的数据成员,直接复制内置类型成员的值,调用复制构造函数复制类类型成员的值,数组类型有些特殊,会复制数组中的每一个元素。可以把合成复制构造函数理解为一个所有数据元素都在构造函数初始化列表中的构造函数。以我们的Person类为例
class Person {
private:
std::string name;
unsigned age;
}
Person(const Person& p) : name(p.name), age(p.age) {
}
2.自定义复制构造函数
复制构造函数其实就是接收单个当前类型的引用形参的构造函数,通过引用会定义为const,但并不是强制要求。如果要自定义复制构造函数,只有满足这个要求定义即可。如果一个类只包含类类型成员、内置类型(不包括指针类型),无需自定义复制构造函数。
当我们有指针类型的数据成员、构造器中分配的资源,或者创建对象时有某些特定工作时,才考虑自定义复制构造函数。
某些类(如iostream)不支持复制,需要禁用复制构造函数,只要显示的将复制构造函数定义为private即可。比如下面的例子,显示的定义复制构造函数,并将其设置为私有,将导致非引用类型的Person不能参数和返回值。
class Person {
private:
std::string name;
unsigned age;
public:
Person(const std::string& s = "empty") : name(s), age(0) {
std::cout << "construct by string" << std::endl;
}
private:
Person(Person& p) : name(p.name), age(p.age) {
std::cout << "copy initial" << std::endl;
}
};
2.析构函数
构造函数的反面,当对象超出作用域或动态分配的对象被删除时,自动调用析构函数。析构函数可以用于释放对象生命周期内获取的额外资源。不管我们是否自定义析构函数,编译器都会自动执行非static成员的析构函数。
1.执行时机
对象超出作用域范围或者动态分配的对象被删除时,会执行析构函数。动态分配的对象如果没有做删除操作,对象就会一直都在,导致内存泄漏。
Person* p = new Person();
{
Person b(*p);
delete p; // 删除p指针,对p指向的对象指向析构
} // b 失效,执行析构函数
容器和数组失效时,会依次调用每一个元素的析构函数,调用顺序和存储数据相反,从size()-1的元素开始析构,再执行size()-2的元素,直接下标0的元素执行析构为止。
2.合成析构函数
合成析构函数按数据成员的逆序撤销每一个非static成员。对于类类型的数据成员,合成析构函数调用该成员的析构函数来撤销。析构函数并不撤销成员指针指向的对象。
3.自定义析构函数
对象生命周期内分配了资源的类,一般需要自定义析构函数。析构函数名是~后接一个类名,没有形参,没有返回值。和构造函数的一个重要区别是,即使自定了析构函数,合成的析构函数仍然会执行。如果我们为Person定义一个析构函数
class Person {
private:
std::string name;
unsigned age;
public:
Person(const std::string& s = "empty") : name(s), age(0) {
std::cout << "construct by string" << std::endl;
}
Person(Person& p) : name(p.name), age(p.age) {
std::cout << "copy initial" << std::endl;
}
Person& operator=(const Person& p1) {
std::cout << "operator=" << std::endl;
return *this;
}
~Person() {
std::cout << "de construct" << std::endl;
}
};
int main() {
Person* p = new Person();
{
Person b(*p);
delete p;
}
std::cout << "exit" << std::endl;
}
3.赋值操作符
与复制构造函数一样,如果没有自定义赋值操作符,编译器会自动生成一个。比如下面的例子,第一行调用默认构造函数初始化了a/b两个对象,第2行将b赋值给a。
Person a, b;
a = b;
赋值操作符是通过操作符=定义的,每个都能重载操作符,函数名用operator接一个操作符组成,我们可以这样重新定义Person类的赋值操作,执行Person对象的赋值操作时就会调用该函数,this绑定做操作,第一个形参绑定右操作数。
Person& operator=(const Person& p1) {
std::cout << "operator=" << std::endl;
return *this;
}
1.合成赋值操作
合成赋值操作和合成复制构造器类似,将右操作数的每一个数据成员赋值给左操作数,内置类型直接赋值,类类型调用合成或自定义的赋值操作,数组依次赋值每一个元素。复制构造函数和赋值操作常常成对出现,如果需要自定义复制构造函数,往往也需要自定义赋值操作。