拷贝语义:深拷贝与浅拷贝
学习浅拷贝和深拷贝的区别,如何为拥有资源的类实现正确的拷贝操作,以及拷贝并交换惯用法。
为什么拷贝语义很重要
当你在 C++ 中拷贝一个对象时,究竟拷贝了什么?对于简单类型,答案很直接 ── 所有成员逐位拷贝。但当对象通过指针拥有资源时,默认(编译器生成的)拷贝构造函数执行浅拷贝:它拷贝指针的值,而不是它指向的数据。这意味着两个对象现在共享同一资源,导致双重释放错误和数据损坏。
理解拷贝语义至关重要,因为拷贝在许多地方隐式发生:按值传递、从函数返回、插入容器以及赋值。
浅拷贝问题
编译器生成的拷贝构造函数拷贝每个成员。对于指针,这拷贝的是地址 ── 而不是指向的数据。这是浅拷贝,对于拥有资源的类来说是危险的。
#include <iostream>
#include <cstring>
class BrokenString {
char* data_;
std::size_t len_;
public:
BrokenString(const char* s)
: len_{std::strlen(s)}
, data_{new char[std::strlen(s) + 1]}
{
std::strcpy(data_, s);
}
~BrokenString() {
delete[] data_; // frees the buffer
}
// No copy constructor defined!
// Compiler generates shallow copy: copies the pointer
void print() const {
std::cout << data_ << '\n';
}
};
int main() {
BrokenString a{"Hello"};
{
BrokenString b = a; // shallow copy: b.data_ == a.data_
b.print(); // "Hello" — works for now
} // b destroyed: delete[] b.data_ (same pointer as a.data_!)
// a.data_ is now a dangling pointer — UNDEFINED BEHAVIOR
a.print(); // may crash, print garbage, or appear to work
return 0;
}实现深拷贝
深拷贝分配新内存并拷贝实际数据。这确保每个对象都拥有自己独立的资源副本。
#include <iostream>
#include <cstring>
#include <algorithm>
class SafeString {
char* data_;
std::size_t len_;
public:
explicit SafeString(const char* s)
: len_{std::strlen(s)}
, data_{new char[std::strlen(s) + 1]}
{
std::strcpy(data_, s);
}
// Deep copy constructor
SafeString(const SafeString& other)
: len_{other.len_}
, data_{new char[other.len_ + 1]}
{
std::strcpy(data_, other.data_);
}
// Deep copy assignment with self-assignment check
SafeString& operator=(const SafeString& other) {
if (this == &other) return *this; // self-assignment guard
// Allocate new resource BEFORE releasing old one
char* new_data = new char[other.len_ + 1];
std::strcpy(new_data, other.data_);
// Release old resource
delete[] data_;
// Adopt new resource
data_ = new_data;
len_ = other.len_;
return *this;
}
~SafeString() { delete[] data_; }
void print() const {
std::cout << data_ << " (len=" << len_ << ")\n";
}
};
int main() {
SafeString a{"Hello"};
SafeString b = a; // deep copy: b gets its own buffer
SafeString c{"World"};
c = a; // deep copy assignment
a.print(); // Hello (len=5)
b.print(); // Hello (len=5)
c.print(); // Hello (len=5)
return 0;
}拷贝并交换惯用法
拷贝并交换惯用法提供了一种优雅的、异常安全的拷贝赋值运算符实现。它复用拷贝构造函数和 swap 函数,以最少的代码重复提供强异常安全保证。
#include <iostream>
#include <cstring>
#include <utility>
class Buffer {
char* data_;
std::size_t size_;
public:
explicit Buffer(std::size_t size)
: size_{size}, data_{new char[size]{}} {}
Buffer(const Buffer& other)
: size_{other.size_}, data_{new char[other.size_]}
{
std::memcpy(data_, other.data_, size_);
}
~Buffer() { delete[] data_; }
// Copy-and-swap: take parameter by VALUE (invokes copy ctor)
Buffer& operator=(Buffer other) { // copy happens here
swap(*this, other); // swap resources
return *this;
} // 'other' destroyed here, freeing our old resource
// Non-throwing swap — essential for strong exception safety
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
char& operator[](std::size_t i) { return data_[i]; }
std::size_t size() const { return size_; }
};
int main() {
Buffer a{5};
a[0] = 'H'; a[1] = 'i';
Buffer b{10};
b = a; // copy-and-swap: exception-safe, self-assignment-safe
std::cout << b[0] << b[1] << " (size " << b.size() << ")\n";
return 0;
}禁用拷贝
某些类型不应该是可拷贝的 ── 例如,表示唯一资源的类型,如文件句柄、网络连接或线程。使用 =delete 来清晰地传达并强制执行这一点:
class UniqueConnection {
public:
UniqueConnection(const UniqueConnection&) = delete;
UniqueConnection& operator=(const UniqueConnection&) = delete;
// Move operations can still be enabled
};在 C++11 之前,这通过将拷贝操作声明为 private 且不提供定义来实现 ── 这是一种令人困惑的做法,=delete 优雅地取代了它。
一种朴素的拷贝赋值实现 ── 先删除旧资源再分配新资源 ── 在两方面是有缺陷的:(1)自赋值(a = a)会先删除数据然后尝试从中拷贝,(2)如果 new 在 delete 之后抛出异常,对象会处于无效状态。拷贝并交换惯用法解决了这两个问题:拷贝在任何修改之前完成,而 swap 是 noexcept 的。
避免拷贝错误的最佳方式是永远不要直接管理原始资源。使用 std::string 而不是 char*,使用 std::vector 而不是原始数组,使用 std::unique_ptr 而不是拥有所有权的原始指针。当你的所有成员都有正确的拷贝语义时,编译器生成的拷贝操作自动就是正确的。这就是零法则在实践中的体现。
- 编译器生成的拷贝是浅拷贝 ── 它拷贝指针的值,而不是指向的数据
- 拥有资源的类需要深拷贝构造函数和赋值运算符
- 拷贝并交换惯用法免费提供异常安全和自赋值安全
- 使用
=delete显式禁止唯一资源类型的拷贝 - 最佳策略是零法则:使用 RAII 封装器,这样你就永远不需要自定义拷贝逻辑
测验 — 检验你的知识
(15 经验值)1. 当你对一个通过原始指针拥有堆内存的对象进行浅拷贝时,会发生什么?
2. 为什么拷贝并交换惯用法按值传递参数?
3. 推荐使用哪种方法来完全避免编写自定义拷贝操作?