前言
复制构造函数(copy constructor)和拷贝赋值运算符(copy assignment operator)是 C++ 对象复制的两条路径。前者用已有对象初始化一个新对象,后者把一个已有对象的值赋给另一个已经存在的对象。两者都叫「拷贝」,但调用时机、实现方式、异常安全要求都不同。
最常见的 bug 是「浅拷贝」:类里持有裸指针,编译器生成的拷贝函数只复制指针值,于是两个对象指向同一块内存,析构时重复释放(double free)。解决办法是手写深拷贝,而一旦手写了析构、拷贝构造、拷贝赋值中的任何一个,通常三个都要写——这就是「规则三(Rule of Three)」。
另一个常见误解是「拷贝构造和赋值可以互相替代」。它们的调用场景不同,编译器不会用一个代替另一个;只实现其中一个,另一个仍会按默认规则生成,可能悄悄做浅拷贝。
现代 C++ 更进一步:优先用 RAII 成员(std::string、std::vector、智能指针),让编译器生成的版本就是正确的,这叫「规则零(Rule of Zero)」。只有需要管理裸资源时,才手写这些函数。
本文讲清调用时机、默认行为、深拷贝实现、自赋值与异常安全,以及移动语义加入后的「规则五」。
一、调用时机
1.1 复制构造函数
// 适用于 C++17
T a;
T b = a; // 拷贝构造(不是赋值)
T c(a); // 拷贝构造
void f(T v); // 按值传参,拷贝构造
T g() { return a; } // 返回值,可能被省略复制构造函数的签名是T(const T&),也可以有额外带默认值的参数,但第一个参数必须是本类型的引用。参数必须是引用:如果写成T(T other),传参本身又要拷贝,会无限递归。
1.2 拷贝赋值
// 适用于 C++17
T a, b;
b = a; // 拷贝赋值,b 已存在签名是T& operator=(const T&)。返回引用是为了支持链式赋值a = b = c,也符合标准库的惯例。
| 维度 | 复制构造 | 拷贝赋值 |
|---|
| 时机 | 创建新对象时 | 两个对象都已存在 |
| 签名 | T(const T&) | T& operator=(const T&) |
| 是否清理旧资源 | 否,对象刚创建 | 是,必须先释放原有资源 |
| 自赋值风险 | 无 | 有,a = a |
| 返回值 | 无 | T&,通常是*this |
T b = a;虽然用了=,但因为是初始化,调用的是复制构造,不是赋值。区分方法:看对象是不是「正在被创建」。
二、编译器生成的版本
如果没声明,编译器会隐式生成复制构造和拷贝赋值,行为是逐成员拷贝:
- 内置类型成员:按位复制。
- 类类型成员:调用该成员的复制构造 / 拷贝赋值。
- 数组成员:逐个元素复制。
- 指针成员:只复制指针值,不复制所指对象。
对不管理资源的类,逐成员拷贝就是正确的。对持有裸指针的类,它就是浅拷贝,两个对象共享同一块内存,析构时重复delete,是未定义行为。
三、深拷贝与规则三/五/零
以管理字符缓冲的String为例:
// 适用于 C++17
class String {
public:
explicit String(const char* s = "")
: size_(std::strlen(s)), data_(new char[size_ + 1]) {
std::memcpy(data_, s, size_ + 1);
}
// 复制构造:分配新缓冲,复制内容
String(const String& other)
: size_(other.size_), data_(new char[other.size_ + 1]) {
std::memcpy(data_, other.data_, size_ + 1);
}
// 拷贝赋值:先构造副本,再交换
String& operator=(const String& other) {
if (this != &other) {
String tmp(other);
swap(tmp);
}
return *this;
}
~String() { delete[] data_; }
void swap(String& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
private:
std::size_t size_;
char* data_;
};几个关键点:
- 复制构造直接初始化成员,不需要先释放资源(对象是新建的)。
- 拷贝赋值采用「拷贝并交换」(copy-and-swap):先用
other构造一个临时副本tmp,再与*this交换。tmp析构时释放原来的资源。这样即使构造副本时抛异常,*this也没有被修改,满足强异常安全保证。 - 自赋值检查
this != &other不是必须的(copy-and-swap 本身对自赋值安全),但能省掉一次无谓的分配。注意:如果采用「先delete[] data_再分配」的写法,自赋值检查就是必须的,否则会释放掉自己要读的数据。 swap标noexcept,因为交换两个指针和长度不会失败。
3.1 规则三、规则五、规则零
规则三:如果定义了析构函数、复制构造、拷贝赋值中的任何一个,通常三个都需要定义。原因是它们共同管理同一份资源,只定义一部分会导致语义不一致。
规则五:C++11 起加上移动构造和移动赋值,共五个。定义了拷贝操作通常也需要考虑移动操作,否则移动会被抑制(声明了拷贝构造或析构,移动操作不会隐式生成),容器操作可能退化为拷贝。
规则零:最好的做法是不手写任何这五个函数,用 RAII 成员管理资源:
// 适用于 C++17
class String {
public:
explicit String(std::string s = "") : data_(std::move(s)) {}
private:
std::string data_;
};std::string自己正确管理内存,编译器生成的拷贝、移动、析构全部正确。这是现代 C++ 的首选。
四、移动语义
手写资源管理时,还应提供移动构造和移动赋值,避免不必要的深拷贝:
// 适用于 C++17
String(String&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
}
return *this;
}移动构造「窃取」源对象的指针,然后把源对象置空。置空是必须的:源对象析构时会delete[] data_,如果还指向同一块内存,就会重复释放。被移动后的对象处于「有效但未指定」状态,只能赋值或销毁,不应读取其内容。
移动操作应标noexcept。std::vector在扩容时,如果元素的移动构造不是noexcept,为了保证强异常安全,可能改用拷贝构造,性能会下降。
完整示例
// 适用于 C++17
#include <cstddef>
#include <cstring>
#include <iostream>
#include <utility>
class String {
public:
explicit String(const char* s = "")
: size_(std::strlen(s)), data_(new char[size_ + 1]) {
std::memcpy(data_, s, size_ + 1);
}
String(const String& other)
: size_(other.size_), data_(new char[other.size_ + 1]) {
std::memcpy(data_, other.data_, size_ + 1);
}
String& operator=(const String& other) {
if (this != &other) {
String tmp(other);
swap(tmp);
}
return *this;
}
String(String&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
}
return *this;
}
~String() { delete[] data_; }
void swap(String& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
const char* c_str() const { return data_ != nullptr ? data_ : ""; }
std::size_t size() const { return size_; }
private:
std::size_t size_;
char* data_;
};
int main() {
String a("hello");
String b = a; // 复制构造
String c;
c = a; // 拷贝赋值
c = c; // 自赋值,必须安全
String d = std::move(a); // 移动构造
std::cout << b.c_str() << ' ' << c.c_str() << ' ' << d.c_str() << '\n';
std::cout << "a size after move: " << a.size() << '\n';
return 0;
}按标准语义,输出应为hello hello hello,以及a size after move: 0。a被移动后指针为空,c_str()返回空串,size()为 0,但对象本身仍然可以安全析构。
c_str()里判断了data_ != nullptr,是因为移动后的对象指针为空。如果不做这个判断,返回空指针给std::cout是未定义行为。
常见坑点
- ❌ 浅拷贝导致重复释放:
``cpp class S { char* p; }; // 默认拷贝只复制指针`✅ 手写深拷贝,或改用std::string/std::vector`。
- ❌ 复制构造参数按值传递:
``cpp S(S other); // 无限递归`✅S(const S& other);`
- ❌ 拷贝赋值不检查自赋值,且先释放再读取:
``cpp delete[] data_; data_ = new char[other.size_ + 1]; // other 可能就是自己`✅ 用 copy-and-swap,或先判this != &other`。
- ❌ 拷贝赋值返回
void:
``cpp void operator=(const S&);`✅ 返回S&,末尾return *this;`。
- ❌ 析构用
delete释放new[]的数组:
``cpp ~S() { delete data_; } // 必须是 delete[]`✅delete[] data_;`
- ❌ 只写复制构造,不写拷贝赋值:
``cpp S(const S&); // 赋值仍走默认浅拷贝`` ✅ 按规则三补齐,或按规则零用 RAII 成员。
- ❌ 移动后不把源对象置空:
``cpp data_ = other.data_; // other.data_ 仍指向同一内存`✅ 移动后把源对象的指针设为nullptr`。
- ❌ 移动构造没标
noexcept,容器扩容退化。
✅ 不抛异常的移动操作标noexcept。
总结
| 函数 | 签名 | 关键点 |
|---|
| 复制构造 | T(const T&) | 新建对象,分配并复制资源 |
| 拷贝赋值 | T& operator=(const T&) | 先释放旧资源,注意自赋值 |
| 移动构造 | T(T&&) noexcept | 窃取资源,源对象置空 |
| 移动赋值 | T& operator=(T&&) noexcept | 释放旧资源后窃取 |
| 析构 | ~T() | new[]配delete[] |
拷贝控制的本质是「谁拥有资源、何时释放」。能用 RAII 成员解决,就不要手写这五个函数(规则零);必须手写时,五个一起考虑(规则五),并用 copy-and-swap 保证赋值的安全。记住这两条,double free 和自赋值问题基本不会出现。