12. 类与对象(下):拷贝构造函数;拷贝赋值运算符;移动语义;RAII原则
好,咱们继续往下聊。上一章我们把构造函数和析构函数讲透了,这一章要聊的这几个东西,说实话,是C++里最容易踩坑的地方。我自己带团队的时候,代码Review里有一半的问题都出在这几个概念上。
拷贝构造、拷贝赋值、移动语义、RAII。这四个东西,你搞明白了,C++就算入门了。搞不明白,写出来的代码就是定时炸弹。
12.1 拷贝构造函数:什么时候会被调用?
拷贝构造函数,说白了就是用一个已经存在的对象去初始化另一个新对象。它的签名长这样:
class MyClass {
public:
MyClass(const MyClass& other); // 拷贝构造函数
};
什么时候会触发?我列几个常见场景:
- 用一个对象初始化另一个对象:
MyClass b = a; - 函数传参时按值传递:
void func(MyClass obj); - 函数返回时按值返回:
MyClass createObj();
嗯,这里要注意:MyClass b = a; 和 MyClass b(a); 其实是一回事,都是拷贝构造,不是赋值。
核心要点:如果你没有显式定义拷贝构造函数,编译器会帮你生成一个。但这个默认版本是浅拷贝——只复制成员变量的值,不复制指针指向的资源。
12.2 拷贝赋值运算符:和拷贝构造有什么区别?
拷贝赋值运算符处理的是两个都已经存在的对象之间的赋值。签名是:
MyClass& operator=(const MyClass& other);
区别在哪?我举个例子你就明白了:
MyClass a;
MyClass b = a; // 拷贝构造:b还没出生
MyClass c;
c = a; // 拷贝赋值:c已经存在了
写拷贝赋值的时候,有个经典陷阱——自赋值。我曾经在项目里见过一个bug,就是因为没处理自赋值,导致对象把自己释放了然后又去读自己的数据,直接崩溃。
避坑指南:写拷贝赋值运算符时,一定要检查自赋值。标准写法是:
MyClass& operator=(const MyClass& other) {
if (this == &other) return *this; // 自赋值检查
// 释放旧资源
// 拷贝新资源
return *this;
}
12.3 移动语义:为什么需要它?
你想想看,有时候我们拷贝一个对象,其实源对象马上就要被销毁了。比如函数返回一个临时对象,或者把一个vector里的元素移到另一个位置。这时候做深拷贝,完全是浪费性能。
移动语义就是干这个的。它把源对象的资源偷过来,然后把源对象置为空。C++11引入了右值引用 && 来支持这个机制。
class MyString {
public:
// 移动构造函数
MyString(MyString&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 把源对象置空
other.size_ = 0;
}
// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept {
if (this == &other) return *this;
delete[] data_; // 释放自己的旧资源
data_ = other.data_; // 偷走对方的资源
size_ = other.size_;
other.data_ = nullptr; // 把对方置空
other.size_ = 0;
return *this;
}
};
个人经验:移动构造函数和移动赋值运算符最好加上 noexcept。为什么?因为标准库的容器(比如vector)在重新分配内存时,如果移动操作是noexcept的,它会优先使用移动而不是拷贝。否则,为了保证异常安全,它只能老老实实做拷贝。这个性能差距,在大量数据时非常明显。
12.4 RAII原则:资源管理的核心思想
RAII,全称是Resource Acquisition Is Initialization。名字很绕,但思想很简单:资源在构造函数中获取,在析构函数中释放。
说白了,就是让对象的生命周期来管理资源。比如智能指针 std::unique_ptr,它在构造时获取内存,在析构时自动释放。你不需要手动调用delete。
我在项目中遇到过这样一个案例:一个老同事写了一段代码,打开文件后忘记关闭,导致文件句柄泄漏。后来我帮他改成RAII风格,用一个FileGuard类来管理文件句柄,问题就再也没出现过。
RAII的核心原则:
- 资源获取即初始化:在构造函数中获取资源
- 资源释放即析构:在析构函数中释放资源
- 禁止拷贝或正确管理拷贝:要么禁用拷贝,要么实现深拷贝
- 支持移动:允许资源所有权的转移
12.5 知识体系图
下面这张图把本章的核心逻辑串起来了,你可以对照着看:
12.6 综合示例:一个完整的RAII类
最后,我给你看一个完整的例子。这个类管理一块动态内存,实现了拷贝构造、拷贝赋值、移动构造、移动赋值,遵循RAII原则:
class Buffer {
public:
// 构造函数:获取资源
explicit Buffer(size_t size) : size_(size), data_(new char[size]) {}
// 析构函数:释放资源
~Buffer() { delete[] data_; }
// 拷贝构造:深拷贝
Buffer(const Buffer& other)
: size_(other.size_), data_(new char[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
// 拷贝赋值:深拷贝 + 自赋值检查
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = new char[size_];
std::copy(other.data_, other.data_ + size_, data_);
return *this;
}
// 移动构造:偷资源
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 移动赋值:偷资源 + 自赋值检查
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
return *this;
}
private:
size_t size_;
char* data_;
};
这个类,你拿去用,不会出现内存泄漏,不会出现双重释放,不会出现野指针。这就是RAII的力量。
我的建议:写C++类的时候,先想清楚这个类需不需要管理资源。如果需要,那就老老实实把拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数这五个函数(C++11里叫"五法则")都实现好。别偷懒,偷懒的代价就是线上bug。
公众号:蓝海资料掘金营,微信deep3321