在C++中,对象复制看似简单,实际涉及指针成员时很容易埋下隐患。比如一个字符串类内部持有 char* 指针,如果直接使用编译器生成的默认拷贝构造函数,只会复制指针地址,不会复制指针指向的内容。这样两个对象会指向同一块堆内存,析构时对同一地址执行两次 delete[],程序会直接崩溃。

一、浅拷贝:复制了指针却没有复制内存
浅拷贝就是只复制成员变量的值,不复制指针指向的数据。对于基本类型成员,浅拷贝没有问题;但只要类里出现堆内存指针、文件句柄、网络连接等资源,浅拷贝就会留下重复释放、数据相互干扰等风险。
下面这段代码定义了一个简单的字符串类,它没有显式提供拷贝构造函数和赋值运算符。
#include <iostream>
#include <cstring>
class ShallowString {
private:
char* _data;
public:
ShallowString(const char* s = "") {
_data = new char[strlen(s) + 1];
strcpy(_data, s);
}
const char* c_str() const {
return _data;
}
~ShallowString() {
delete[] _data;
}
};
int main() {
ShallowString a("hello");
ShallowString b = a; // 默认拷贝构造,只复制指针
std::cout << a.c_str() << " / " << b.c_str() << std::endl;
return 0;
}
这个程序在编译阶段不会报错,甚至输出也正常,原因是指针确实指向同一块有效内存。但退出 main 函数时,b 先析构并释放内存,随后 a 再次释放同一地址,触发未定义行为。这个场景在稍大的项目中非常常见,尤其是容器扩容、按值传参、返回值优化等过程都可能触发隐式拷贝。
浅拷贝并非一无是处。如果类本身不管理资源,或者资源是只读共享的,浅拷贝可以大幅减少复制开销。问题在于C++无法自动判断一个指针是否代表所有权,所以只要类负责释放资源,就要明确拷贝语义。
二、深拷贝:让每个对象独立持有资源
深拷贝的解决思路很直接:拷贝时不仅复制指针,还为新对象分配一块新内存,再把原内存里的内容复制过去。这样两个对象的指针指向不同的地址,析构时各释放各的,互相没有影响。
实现深拷贝需要同时重写拷贝构造函数和赋值运算符。拷贝构造函数负责从无到有创建独立数据,赋值运算符负责处理已有对象,并且要特别注意自赋值和异常安全。
#include <iostream>
#include <cstring>
class DeepString {
private:
char* _data;
size_t _len;
public:
DeepString(const char* s = "") {
_len = strlen(s);
_data = new char[_len + 1];
strcpy(_data, s);
}
DeepString(const DeepString& other) {
_len = other._len;
_data = new char[_len + 1];
strcpy(_data, other._data);
}
DeepString& operator=(const DeepString& other) {
if (this != &other) {
char* tmp = new char[other._len + 1];
strcpy(tmp, other._data);
delete[] _data;
_data = tmp;
_len = other._len;
}
return *this;
}
~DeepString() {
delete[] _data;
}
char& operator[](size_t i) {
return _data[i];
}
const char& operator[](size_t i) const {
return _data[i];
}
const char* c_str() const {
return _data;
}
};
赋值运算符里先申请新的内存并复制数据,成功后再释放旧内存,这种先新后旧的顺序能避免“新内存分配失败导致原对象数据丢失”的问题。自赋值检查也是必须的,否则 a = a 会先把自己的内容删掉。
深拷贝的缺点是空间和时间成本较高。当对象很大、复制频率很高,而程序中又存在大量只读访问时,每次拷贝都完整复制整块数据并不经济。这也是写时拷贝出现的重要背景。
三、写时拷贝:共享数据,直到真正修改时才复制
写时拷贝(Copy-on-Write, COW)介于浅拷贝和深拷贝之间。它最初只共享同一份数据,并维护一个引用计数;当某个对象需要修改数据时,才为该对象复制一份独立副本,再在副本上修改。这样多个对象只读访问时几乎零开销,写操作时才付出复制成本。
实现写时拷贝的关键是引入引用计数。所有共享对象指向一个堆上的控制块,控制块里保存数据指针和引用数量。拷贝构造时只增加引用计数,不复制数据;析构时减少引用计数,归零时释放数据。修改数据前先检查引用计数,如果大于1则分离出一份新数据。
#include <iostream>
#include <cstring>
class CowString {
private:
struct Data {
char* ptr;
size_t ref_count;
Data(const char* s = "") {
ptr = new char[strlen(s) + 1];
strcpy(ptr, s);
ref_count = 1;
}
~Data() {
delete[] ptr;
}
};
Data* _data;
void release() {
if (_data != nullptr) {
--_data->ref_count;
if (_data->ref_count == 0) {
delete _data;
}
_data = nullptr;
}
}
void copy_on_write() {
if (_data->ref_count > 1) {
Data* old = _data;
Data* fresh = new Data(old->ptr);
--old->ref_count;
_data = fresh;
}
}
public:
CowString(const char* s = "") {
_data = new Data(s);
}
CowString(const CowString& other) {
_data = other._data;
++_data->ref_count;
}
CowString& operator=(const CowString& other) {
if (this != &other) {
++other._data->ref_count;
release();
_data = other._data;
}
return *this;
}
~CowString() {
release();
}
char& operator[](size_t i) {
copy_on_write();
return _data->ptr[i];
}
const char& operator[](size_t i) const {
return _data->ptr[i];
}
const char* c_str() const {
return _data->ptr;
}
};
这段代码里,copy_on_write 函数在引用计数大于1时分离数据。注意常量版本的 operator[] 没有调用它,因为只读访问不需要复制;非常量版本才触发复制。这样设计可以让只读遍历、打印等操作保持共享,而写操作自动获得独立数据。
写时拷贝也不是完美方案。引用计数需要原子操作或加锁来保证多线程安全,这会带来额外开销;并且 char& 返回引用后,外部可能长期持有这个引用,导致后续复制判断变得复杂。现代C++标准库在 std::string 的某些历史实现中曾使用COW,但C++11之后由于移动语义和标准约束,主流实现已经基本放弃COW。尽管如此,理解COW对掌握资源管理、缓存设计和共享不可变数据仍然很有价值。
四、三种策略如何选择
浅拷贝适合不含堆资源或者明确共享同一资源的场景,但需要避免析构时重复释放。深拷贝适合对象较小、复制次数不多、要求完全独立的场景,它的语义最清晰,调试成本最低。写时拷贝适合读多写少的大对象,能显著降低只读复制开销,但实现复杂度上升,需要处理引用计数、线程安全和引用逃逸问题。
C++11之后移动语义提供了另一种优化思路:资源所有权可以直接转移,不需要复制内容。移动构造和移动赋值可以替代很多原本需要深拷贝的临时对象场景,而且实现起来比写时拷贝更简单、更安全。因此实际项目中,优先考虑深拷贝加移动语义,只有当写操作比例极低、共享读占绝对主导时,才考虑写时拷贝。
无论采用哪种策略,拷贝控制函数都是一个整体:拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数需要一起设计。只要类管理资源,就不要依赖编译器默认生成的拷贝行为,否则浅拷贝带来的重复释放会在程序退出时突然爆发,排查起来非常困难。