一、什么是对象拷贝?
C++ 中经常会出现"把一个对象交给另一个对象"的情况,例如:
MyString a("hello");
// 使用 a 创建一个新对象 b
MyString b(a);
// 对象 c 已经存在,再把 a 的内容赋给 c
MyString c("world");
c = a;
上面两种写法看起来都像"复制对象",但本质不同:
MyString b(a); → 拷贝构造函数
c = a; → 赋值运算符重载函数
简单理解:
创建新对象时进行复制:调用拷贝构造函数。
对象已经存在,再覆盖原内容:调用赋值运算符。
二、拷贝构造函数和赋值运算符什么时候调用?
1. 拷贝构造函数
拷贝构造函数用于:使用一个已有对象创建一个新对象。
MyString a("hello");
// 用 a 创建 b,调用拷贝构造函数
MyString b(a);
// 这也是初始化新对象,概念上同样属于拷贝构造
MyString c = a;
拷贝构造函数的常见写法:
MyString(const MyString& other);
其中:
const MyString& other
表示按常量引用传参。
这样做有两个好处:
1. 使用引用,避免再次拷贝对象。
2. 使用 const,保证不会修改原对象。
2. 赋值运算符
赋值运算符用于:左边对象已经存在,再把右边对象的数据赋给它。
MyString a("hello");
MyString b("world");
// b 已经创建完成,现在把 a 的内容赋给 b
b = a;
赋值运算符重载通常写成:
MyString& operator=(const MyString& other);
这里返回 MyString&,是为了支持连续赋值:
a = b = c;
执行过程可以理解为:
先执行 b = c,
再把 b 的引用作为结果返回,
然后执行 a = b。
三、什么是浅拷贝?
浅拷贝指的是:只复制成员变量本身,不复制成员变量指向的实际资源。
例如一个类中有指针成员:
class BadString {
public:
char* data;
BadString(const char* text) {
int len = strlen(text);
// 在堆区申请空间保存字符串
data = new char[len + 1];
strcpy(data, text);
}
~BadString() {
// 释放 data 指向的堆区内存
delete[] data;
}
};
如果没有自己写拷贝构造函数和赋值运算符,编译器会默认生成。
默认生成的拷贝逻辑通常只是把成员变量逐个复制:
BadString a("hello");
// 编译器默认复制 data 指针中的地址
BadString b = a;
此时:
a.data 和 b.data 保存的是同一个地址。
a 和 b 实际上指向同一块堆区内存。
可以理解成:
a.data ───┐
├──> "hello"
b.data ───┘
这样会产生两个严重问题。
1. 修改一个对象可能影响另一个对象
因为两个对象共用同一块数据,一个对象修改内容,另一个对象看到的内容也可能变化。
2. 可能发生重复释放
当 a 和 b 生命周期结束时,它们都会调用析构函数:
delete[] data;
但是这块内存只能释放一次。
如果第一次释放后,第二个对象又尝试释放同一地址,就可能导致:
重复释放内存
程序崩溃
未定义行为
这就是浅拷贝在"类中管理堆区资源"时最常见的问题。
四、什么是深拷贝?
深拷贝指的是:不仅复制成员变量,还要重新申请一块独立资源,把原对象的数据复制过去。
也就是说:
a.data ───> "hello"
b.data ───> "hello"
虽然两个对象内容相同,但它们分别拥有自己的内存,互不影响。
下面通过一个完整示例,说明如何实现深拷贝。
#include <iostream>
#include <cstring>
using namespace std;
class MyString {
private:
char* data;
public:
// 普通构造函数:在堆区申请空间并保存字符串
MyString(const char* text = "") {
int len = strlen(text);
// 多申请 1 个字符位置,用来保存字符串结尾的 '\0'
data = new char[len + 1];
// 将 text 的内容复制到 data 指向的内存中
strcpy(data, text);
cout << "普通构造函数:" << data << endl;
}
// 拷贝构造函数:使用 other 创建一个新对象
MyString(const MyString& other) {
int len = strlen(other.data);
// 重点:重新申请一块新的堆区内存
data = new char[len + 1];
// 复制 other 对象中的字符串内容,而不是只复制指针地址
strcpy(data, other.data);
cout << "拷贝构造函数:" << data << endl;
}
// 赋值运算符重载:把 other 的内容赋给当前对象
MyString& operator=(const MyString& other) {
// 防止自己给自己赋值,例如:a = a;
if (this == &other) {
return *this;
}
int len = strlen(other.data);
// 先申请新空间
// 这样即使申请失败,也不会破坏当前对象原有的数据
char* newData = new char[len + 1];
// 将 other 的内容复制到新空间
strcpy(newData, other.data);
// 再释放当前对象原来管理的内存
delete[] data;
// 让当前对象指向新的内存
data = newData;
cout << "赋值运算符:" << data << endl;
// 返回当前对象本身,支持连续赋值
return *this;
}
// 输出字符串内容
void print() const {
cout << "当前内容:" << data << endl;
}
// 析构函数:释放当前对象自己管理的堆区内存
~MyString() {
cout << "析构函数释放:" << data << endl;
delete[] data;
data = nullptr;
}
};
int main() {
// 调用普通构造函数
MyString a("hello");
// 使用 a 创建新对象 b,调用拷贝构造函数
MyString b(a);
// c 已经创建完成
MyString c("world");
// 把 a 的内容赋给 c,调用赋值运算符
c = a;
// 自赋值,不应该出现问题
c = c;
a.print();
b.print();
c.print();
return 0;
}
这段代码中:
MyString b(a);
会调用拷贝构造函数。
c = a;
会调用赋值运算符。
由于拷贝构造函数和赋值运算符都重新申请了内存,所以 a、b、c 各自拥有独立的数据,析构时不会重复释放。
五、为什么赋值运算符中要判断自赋值?
自赋值指的是:
a = a;
也就是当前对象给自己赋值。
在赋值运算符中:
if (this == &other) {
return *this;
}
其中:
this:当前对象的地址。
&other:传入对象的地址。
如果两者相同,说明当前对象和传入对象是同一个对象,不需要继续执行复制操作。
如果没有处理自赋值,并且写成下面这种不安全的逻辑:
delete[] data;
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
当 other 就是当前对象本身时,delete[] data 会先把自己的数据释放。
后面再访问:
other.data
就相当于访问已经释放的内存,可能导致程序崩溃。
因此,赋值运算符中一般都要考虑自赋值问题。
六、拷贝构造函数和赋值运算符的区别
| 对比项 | 拷贝构造函数 | 赋值运算符 |
|---|---|---|
| 作用 | 用已有对象创建新对象 | 给已存在对象重新赋值 |
| 典型写法 | MyString b(a); |
b = a; |
| 左侧对象状态 | 左侧对象还没有创建 | 左侧对象已经存在 |
| 是否需要释放旧资源 | 不需要,因为对象是新创建的 | 需要,避免旧资源泄漏 |
| 常见形式 | MyString(const MyString&) |
MyString& operator=(const MyString&) |
可以这样记:
拷贝构造:新对象出生时,参考另一个对象创建自己。
赋值运算符:对象已经存在,把自己的旧内容换成另一个对象的内容。
七、Rule of Three、Rule of Five 和 Rule of Zero
1. Rule of Three
C++ 中有一个常见经验:
如果一个类需要自己管理资源,并且自己定义了以下任意一个函数:
析构函数
拷贝构造函数
拷贝赋值运算符
那么通常也需要认真考虑另外两个函数是否应该自己实现。
这就是 Rule of Three,也就是"三法则"。
原因是:这三个函数都与资源所有权和对象拷贝有关。
例如类中有:
char* data;
并且析构函数中写了:
delete[] data;
那么默认浅拷贝通常就不安全,因此通常需要自己实现拷贝构造函数和赋值运算符。
2. Rule of Five
C++11 引入右值引用和移动语义后,又多了两个和资源转移有关的函数:
移动构造函数
移动赋值运算符
所以现在常说 Rule of Five:
析构函数
拷贝构造函数
拷贝赋值运算符
移动构造函数
移动赋值运算符
这部分在初学阶段不需要一下子写得很复杂,但要知道:
管理资源的类,除了要考虑复制,还要考虑资源是否可以高效转移。
3. Rule of Zero
现代 C++ 中更推荐的思想是 Rule of Zero。
意思是:尽量不要自己手动管理资源,而是使用已经实现好资源管理的类型。
例如:
std::string
std::vector
std::unique_ptr
std::shared_ptr
这样一般不需要自己写析构函数、拷贝构造函数和赋值运算符。
例如使用 std::string:
#include <iostream>
#include <string>
using namespace std;
class Student {
private:
string name;
public:
Student(const string& n) : name(n) {}
void print() const {
cout << "name = " << name << endl;
}
};
int main() {
Student s1("Tom");
// string 自己会正确处理拷贝
Student s2 = s1;
s1.print();
s2.print();
return 0;
}
std::string 已经帮我们处理好了内存申请、释放和深拷贝问题。
所以实际项目中:
能使用 std::string,就尽量不要自己用 char* 管理字符串。
能使用 vector,就尽量不要手写动态数组。
能使用智能指针,就尽量不要手动 new/delete。
八、面试高频问题整理
1. 什么是浅拷贝?
浅拷贝是指只复制对象成员变量本身,不复制成员变量指向的实际资源。
如果类中有指针成员,默认拷贝通常只会复制指针地址,导致多个对象指向同一块堆区内存。这样可能出现数据互相影响、重复释放和程序崩溃等问题。
2. 什么是深拷贝?
深拷贝是指重新申请独立资源,再把原对象的数据复制到新资源中。
深拷贝后,两个对象内容相同,但各自拥有独立内存,修改一个对象不会影响另一个对象,也不会发生重复释放。
3. 拷贝构造函数和赋值运算符有什么区别?
拷贝构造函数用于使用已有对象创建新对象,例如:
MyString b(a);
赋值运算符用于对象已经存在后重新赋值,例如:
b = a;
拷贝构造时对象没有旧资源需要释放;赋值时对象可能已经管理了旧资源,因此需要先正确处理旧资源,避免内存泄漏。
4. 为什么拷贝构造函数参数通常写成 const 引用?
常见写法是:
MyString(const MyString& other);
使用引用可以避免传参时再次调用拷贝构造函数。
使用 const 可以保证函数内部不修改原对象,也可以让该函数接收常量对象。
5. 为什么赋值运算符要返回引用?
赋值运算符通常返回当前对象的引用:
MyString& operator=(const MyString& other);
这样可以支持连续赋值:
a = b = c;
同时返回引用可以避免返回对象副本带来的额外拷贝。
6. 为什么赋值运算符要处理自赋值?
因为可能出现:
a = a;
如果没有处理自赋值,并且先释放当前对象资源,再从 other 中读取数据,可能会访问已经释放的内存。
因此通常会先判断:
if (this == &other) {
return *this;
}
7. 什么是 Rule of Three?
Rule of Three 指的是:如果一个类自己管理资源,并且定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个,那么通常需要考虑是否应该同时定义另外两个。
因为这三个函数共同决定了资源如何释放和复制。
九、总结
深拷贝、浅拷贝、拷贝构造函数和赋值运算符是 C++ 面试中非常常见的知识点。
浅拷贝只复制指针地址,多个对象可能指向同一块内存。当类中存在动态申请的资源,并且析构函数负责释放资源时,浅拷贝可能导致重复释放和程序崩溃。
深拷贝会重新申请独立内存,并复制原对象的数据。这样每个对象都拥有自己的资源,修改和析构互不影响。
拷贝构造函数用于创建新对象时的复制,赋值运算符用于已存在对象之间的赋值。赋值运算符需要注意旧资源释放、自赋值和返回引用等问题。
简单记忆:
浅拷贝:复制地址,多个对象共用资源,容易出问题。
深拷贝:重新申请资源,多个对象各自独立。
拷贝构造:新对象从旧对象创建。
赋值运算符:已有对象覆盖旧内容。
Rule of Three:析构、拷贝构造、拷贝赋值通常要一起考虑。
Rule of Zero:优先使用 string、vector、智能指针等现成类型管理资源。
实际开发中,优先使用 std::string、std::vector 和智能指针,尽量减少手写 new/delete。只有在确实需要自己管理底层资源时,才需要手动实现深拷贝和相关特殊成员函数。