在实际开发中,我们经常会遇到这样的需求:程序里已经有一个创建好的对象,现在想以它为模板,再复制出一个数据相同的新对象。比如已经定义好了一个 Student 类的对象 stu1,接着写下下面这行代码:
Student stu2 = stu1;很多初学的小伙伴第一次看到 Student stu2 = stu1; 时,容易因为中间有一个等号(=),就以为这是普通的赋值操作。
其实并不是这样。在这行代码之前,对象 stu2 还不存在,这里是在创建新对象 stu2 并使用已有的 stu1 初始化它。这种 “使用同类型的已有对象创建并初始化新对象” 的过程,调用的就是 “拷贝构造函数(Copy Constructor)”。
C++ 拷贝构造函数的基本语法
在 C++ 中,拷贝构造函数也是一种特殊的构造函数。因此它的函数名必须与类名相同,也没有返回值类型,只是形参固定写成 const 类名&。
语法:
class 类名 {
public:
类名(const 类名& other) : 成员(other.成员) {
// 函数体
}
};说明:
编写拷贝构造函数时,我们通常会配合前面学过的初始化列表,直接拿来源对象 other 的各个成员去初始化新对象的成员。这里的形参名 other 只是习惯叫法,表示 “另一个已有对象”,你也可以取其他合法的变量名。
对于形参 const 类名& other 来说,有以下 2 个细节需要特别留意:
- 引用符号(&)不能省略:形参必须声明为引用。因为在 C++ 中,把对象按值传给形参时,本身就要调用拷贝构造函数来复制实参。如果拷贝构造函数自己的形参也写成值传递,调用时为了传参又得先调用它自己,就会陷入无限循环,编译器会直接报错。
- 推荐加上 const 修饰:加上
const既能防止在复制过程中不小心修改来源对象的数据,也能保证const常对象和临时对象都可以正常完成复制。
比如对于 Student 类,声明拷贝构造函数时一定要带上引用符号(&):
// 正确方式
Student(const Student& other);
// 错误方式
Student(const Student other);定义好拷贝构造函数后,使用已有对象 stu1 创建新对象时,既可以用括号写成 Student stu2(stu1);,也可以用等号写成 Student stu3 = stu1;。只要左边的对象是在当前行才声明创建的,编译器都会自动调用拷贝构造函数。下面我们来看一个例子。
示例 1:定义与调用拷贝构造函数
#include <iostream>
#include <string>
class Student {
private:
std::string name;
int score;
public:
// 带参构造函数
Student(const std::string& n, int s)
: name(n), score(s) {}
// 拷贝构造函数
Student(const Student& other)
: name(other.name), score(other.score) {
std::cout << "调用拷贝构造函数\n";
}
void setScore(int s) {
score = s;
}
void show() const {
std::cout << name << ",成绩:" << score << "\n";
}
};
int main() {
Student stu1("阿莫", 95);
Student stu2(stu1); // 括号法调用拷贝构造
Student stu3 = stu1; // 等号法调用拷贝构造
stu2.setScore(100); // 修改 stu2 的成绩
stu1.show();
stu2.show();
stu3.show();
return 0;
}运行结果如下。
调用拷贝构造函数
调用拷贝构造函数
阿莫,成绩:95
阿莫,成绩:100
阿莫,成绩:95分析:
在这个例子中,创建 stu1 时传入了姓名和成绩,匹配的是带参构造函数。随后执行 Student stu2(stu1); 和 Student stu3 = stu1; 时,由于 stu2 和 stu3 都是用同类型对象初始化的新对象,因此两次都自动调用了拷贝构造函数。
可能有些小伙伴会觉得奇怪:name 和 score 明明是 private 私有成员,为什么在拷贝构造函数里能直接访问 other.name 和 other.score 呢?
其实原因很简单:C++ 的 private 访问限制是以 “类” 为单位,而不是以 “单个对象” 为单位的。拷贝构造函数本身属于 Student 类的成员函数,因此在它内部可以直接访问同类型另一个对象 other 的私有成员。
此外从运行结果还能看出,调用 stu2.setScore(100); 修改 stu2 的成绩后,stu1 和 stu3 的成绩依然保持 95 不变。这说明复制完成后,它们是内存中 3 个各自独立的对象,并不是同一个对象的别名。
拷贝构造函数的调用时机
除了在定义新对象时写出 Student stu2(stu1); 或 Student stu3 = stu1; 之外,在函数传参和返回结果的过程中,编译器也会在背后自动调用拷贝构造函数。常见的调用时机主要有以下 3 种:
- 使用已有对象初始化新对象:如示例 1 所示,通过括号法
Student stu2(stu1);、等号法Student stu3 = stu1;或花括号Student stu4{stu1};创建新对象时自动调用。 - 对象按值传递给函数形参:当函数的形参是普通对象而不是引用时,调用函数会把实参对象复制一份交给形参,此时会自动调用拷贝构造函数。
- 函数按值返回局部对象:当函数按值返回一个局部对象时,在语法规则上也会通过拷贝或移动把结果交回给调用处。
在这 3 种情况里,第二种在平时写代码时最容易产生多余的复制开销。下面我们通过一个例子,对比一下按值传递与引用传递传入对象时有什么区别。
示例 2:按值传参与按引用传参
#include <iostream>
#include <string>
class Student {
private:
std::string name;
int score;
public:
Student(const std::string& n, int s)
: name(n), score(s) {}
Student(const Student& other)
: name(other.name), score(other.score) {
std::cout << "调用拷贝构造函数\n";
}
void show() const {
std::cout << name << ",成绩:" << score << "\n";
}
};
// 按值传递:触发拷贝构造
void printByValue(Student s) {
s.show();
}
// 按引用传递:不触发拷贝构造
void printByRef(const Student& s) {
s.show();
}
int main() {
Student stu("Jack", 92);
std::cout << "1. 调用 printByValue:\n";
printByValue(stu);
std::cout << "2. 调用 printByRef:\n";
printByRef(stu);
return 0;
}运行结果如下。
1. 调用 printByValue:
调用拷贝构造函数
Jack,成绩:92
2. 调用 printByRef:
Jack,成绩:92分析:
调用 printByValue(stu) 时,由于形参 Student s 采用的是值传递,程序在进入函数时需要在内存中新建形参对象 s,相当于执行了 Student s = stu;,因此控制台多输出了一行 “调用拷贝构造函数”。
而调用 printByRef(stu) 时,形参 const Student& s 只是原对象 stu 的只读引用,不需要创建新对象,自然也就省去了一次拷贝构造函数的调用开销。
因此在实际开发中,如果一个函数只需要读取类对象的数据而不需要修改它,我们通常会将形参写成 const 类名&,既能省去复制对象的开销,又能防止原对象的数据被误改。
返回值优化(RVO)
前面提到拷贝构造函数的第三种调用时机是 “函数按值返回局部对象”。不过小伙伴们如果在本地编写一个返回局部 Student 对象的函数,运行时往往会发现控制台并没有打印 “调用拷贝构造函数”。
这是因为现代 C++ 编译器默认开启了 “返回值优化(Return Value Optimization)”,从 C++17 标准起在许多场景下已成为强制规则。编译器会直接在接收结果的内存位置上构造对象,省去中间临时对象的复制,因此平时我们可以放心地按值返回局部对象。
默认拷贝构造与 = default / = delete
在前面的章节中,我们定义类时并没有手写拷贝构造函数,为什么同样可以使用已有对象创建新对象呢?这是因为编译器会自动生成默认的拷贝构造函数,并且从 C++11 标准起,我们还可以使用 = default 和 = delete 明确控制类的拷贝行为。
1. 默认拷贝构造函数与 = default
如果我们没有在类中显式定义拷贝构造函数,C++ 编译器会自动生成一个公有的 “默认拷贝构造函数”,按照成员变量在类中的声明顺序,把来源对象的每个成员依次复制给新对象。
比如对于包含 x 和 y 两个整型成员的 Point 类,编译器自动生成的默认拷贝构造函数,效果就相当于下面这段代码:
// 编译器自动生成的默认拷贝构造
Point(const Point& other) : x(other.x), y(other.y) {}如果想在代码中明确表达 “使用编译器默认生成的拷贝规则”,我们也可以在类内部显式写上 Point(const Point& other) = default;。
对于只包含基本类型或 std::string、std::vector 等标准库容器成员的类,默认拷贝就已经够用了。只有当类内部直接管理通过 new 分配的指针成员时,才需要手动编写支持深拷贝的拷贝构造函数,这一点后面章节会详细介绍。
2. 使用 = delete 禁止拷贝
在有些场景下,某个类的对象代表独占的资源,比如前面学过的 std::unique_ptr,或者管理唯一网络连接、唯一许可证的类。这时我们希望从语法上直接禁止别人复制这个类的对象。
遇到这种需求时,我们只需要在拷贝构造函数声明的末尾加上 = delete;,告诉编译器禁用该类的拷贝构造函数即可。请看下面例子。
示例 3:控制类的拷贝行为
#include <iostream>
// 普通坐标类:保留默认拷贝构造
class Point {
private:
int x;
int y;
public:
Point(int px, int py) : x(px), y(py) {}
// 显式保留默认拷贝构造
Point(const Point& other) = default;
void show() const {
std::cout << "(" << x << ", " << y << ")\n";
}
};
// 独占许可证类:禁止拷贝
class UniqueLicense {
private:
int id;
public:
UniqueLicense(int n) : id(n) {}
// 禁用拷贝构造
UniqueLicense(const UniqueLicense& other) = delete;
void show() const {
std::cout << "许可证编号:" << id << "\n";
}
};
int main() {
Point p1(10, 20);
Point p2 = p1; // 调用默认拷贝构造
p2.show();
UniqueLicense lic1(1001);
lic1.show();
return 0;
}运行结果如下。
(10, 20)
许可证编号:1001分析:
Point(const Point& other) = default; 表示显式保留编译器默认生成的拷贝构造函数,因此 Point p2 = p1; 可以正常把 p1 的两个坐标值 10 和 20 复制给 p2。
而 UniqueLicense 类通过 = delete 禁用了拷贝构造函数。一旦我们在代码中尝试复制 UniqueLicense 对象,或者把它按值传给函数,编译器就会直接报错:
UniqueLicense lic1(1001);
UniqueLicense lic2 = lic1; // 报错:拷贝构造已被禁用注意 如果一个类原本没写任何构造函数,一旦显式声明了拷贝构造函数,编译器就不再自动生成默认无参构造函数。此时如果还需要无参创建对象,记得一并加上 类名() = default;。
