C++中的特殊成员函数与智能指针
前言:对象其实也有"生命周期"
一个 C++ 对象从出生到消亡,大致会经历下面几件事:
- 被创建;
- 可能从另一个对象复制而来;
- 可能接管另一个对象的资源;
- 可能在创建后被重新赋值;
- 最后被销毁。
C++ 用一组特殊成员函数控制这些行为。本文重点介绍其中五个:
cpp
class T {
public:
T(const T& other); // 拷贝构造
T& operator=(const T& other); // 拷贝赋值
T(T&& other); // 移动构造
T& operator=(T&& other); // 移动赋值
~T(); // 析构函数
};
它们经常被称为"资源管理五件套"。理解它们之后,unique_ptr、shared_ptr 和 weak_ptr 的设计就会变得很自然。
第一章:先分清"构造"和"赋值"
初学时最容易混淆的是下面两行:
cpp
T b = a;
b = a;
它们看起来相似,但含义完全不同。
cpp
T b = a; // b 此前不存在:创建 b,调用拷贝构造
T b;
b = a; // b 已经存在:修改 b,调用拷贝赋值
记忆方法:
- 构造:对象原来不存在,现在让它出生;
- 赋值:对象已经存在,现在更换它的内容。
移动构造和移动赋值也是同样的区别:
cpp
T b = std::move(a); // b 正在出生:移动构造
T b;
b = std::move(a); // b 已存在:移动赋值
第二章:用一个资源类贯穿全文
为了看清复制和移动的区别,先写一个直接管理动态数组的类:
cpp
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t size)
: data_(size ? new int[size]{} : nullptr),
size_(size) {}
~Buffer() {
delete[] data_;
}
private:
int* data_ = nullptr;
std::size_t size_ = 0;
};
这个类中的 data_ 不只是一个地址,它还表达了所有权:
当前
Buffer对象负责在最后执行delete[] data_。
一旦类负责管理资源,复制、移动和销毁就必须围绕"谁拥有资源"来设计。
第三章:拷贝构造
3.1 它什么时候被调用
拷贝构造使用一个已有对象,创建一个新的同类型对象:
cpp
T(const T& other);
常见调用场景:
cpp
T a;
T b(a); // 拷贝构造
T c = a; // 也是拷贝构造,不是赋值
按值传参和按值返回也可能涉及拷贝,不过现代编译器经常通过复制消除直接省掉它。
3.2 浅拷贝为什么危险
如果直接复制裸指针:
cpp
Buffer::Buffer(const Buffer& other)
: data_(other.data_),
size_(other.size_) {}
结果是两个对象保存同一个地址:
text
a.data_ ──┐
├──> 同一块动态数组
b.data_ ──┘
这种只复制指针地址的行为叫作浅拷贝 。当 a 和 b 都认为自己拥有这块内存时,它们析构时会重复释放,程序产生未定义行为。
注意:浅拷贝本身不一定错误。如果指针只是观察外部对象、根本不负责释放,复制地址可能正是合理行为。危险的是"对拥有型裸指针做浅拷贝"。
3.3 深拷贝
正确的拥有型拷贝通常需要重新申请一块内存,再复制内容:
cpp
Buffer(const Buffer& other)
: data_(other.size_ ? new int[other.size_] : nullptr),
size_(other.size_) {
if (size_ != 0) {
std::copy(other.data_, other.data_ + size_, data_);
}
}
现在两个对象内容相同,但内存独立:
text
a.data_ ─────> 动态数组 A
b.data_ ─────> 动态数组 B
修改或销毁其中一个不会影响另一个。
3.4 编译器会不会隐式声明拷贝构造
如果没有用户声明拷贝构造,编译器通常会隐式声明一个。它的行为是逐个复制基类和非静态成员:
cpp
// 概念上的近似形式
Buffer::Buffer(const Buffer& other)
: data_(other.data_),
size_(other.size_) {}
因此,编译器生成的拷贝对 int、std::string、std::vector 等成员通常没问题,但对拥有型裸指针只会复制地址。
如果某个成员不可拷贝,例如 std::unique_ptr,外层类隐式声明的拷贝构造会被定义为 delete:
cpp
class Owner {
std::unique_ptr<int> ptr_;
};
Owner a;
Owner b = a; // 编译错误:unique_ptr 不允许拷贝
第四章:拷贝赋值
4.1 它什么时候被调用
拷贝赋值用于把已有对象改成另一个对象的副本:
cpp
T& operator=(const T& other);
例如:
cpp
T a;
T b;
b = a; // 拷贝赋值
返回 T& 是为了支持连续赋值:
cpp
a = b = c;
4.2 为什么赋值比构造多一步
拷贝构造面对的是一个还没有资源的新对象。拷贝赋值面对的对象可能已经拥有旧资源,因此需要:
- 准备新资源;
- 释放目标对象的旧资源;
- 安装新资源;
- 返回
*this。
cpp
Buffer& operator=(const Buffer& other) {
if (this == &other) {
return *this;
}
int* newData =
other.size_ ? new int[other.size_] : nullptr;
if (other.size_ != 0) {
std::copy(other.data_,
other.data_ + other.size_,
newData);
}
delete[] data_;
data_ = newData;
size_ = other.size_;
return *this;
}
这里先申请并复制新内存,成功后才删除旧内存。如果申请内存或复制失败,原对象仍保持不变,这是一种较好的异常安全策略。
if (this == &other) 用来处理自赋值:
cpp
a = a;
4.3 copy-and-swap 写法
也可以先复制出一个临时对象,再交换资源:
cpp
void swap(Buffer& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
}
Buffer& operator=(const Buffer& other) {
Buffer temp(other);
swap(temp);
return *this;
}
函数结束时,temp 析构并释放目标对象原来的资源。这种写法简洁并且天然支持自赋值,但可能不是性能最优方案。
4.4 编译器会不会隐式声明拷贝赋值
如果没有用户声明拷贝赋值,编译器通常会隐式声明一个,逐成员执行拷贝赋值:
cpp
// 概念上的近似形式
Buffer& Buffer::operator=(const Buffer& other) {
data_ = other.data_;
size_ = other.size_;
return *this;
}
对于拥有型裸指针,这会泄漏目标对象原来的内存,并让两个对象指向同一块内存。
如果成员不可拷贝赋值,外层类对应的隐式拷贝赋值也会被定义为 delete。
第五章:移动构造
5.1 移动不是复制内容,而是转交资源
移动构造用一个即将不再需要其原值的对象创建新对象:
cpp
T(T&& other);
例如:
cpp
Buffer a(1000);
Buffer b = std::move(a);
std::move(a) 自身不会搬运任何资源,它大致只是:
cpp
static_cast<Buffer&&>(a)
它告诉重载决议:"可以把 a 当作右值使用。"真正的资源转移发生在移动构造函数里。
5.2 裸指针资源的移动构造
cpp
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)) {}
它做了两件事:
- 新对象取得原来的指针和长度;
- 原对象放弃资源,被置为空状态。
text
移动前:
a.data_ ─────> 动态数组
移动后:
a.data_ ─────> nullptr
b.data_ ─────> 动态数组
原对象必须仍能被安全析构,因为 std::move(a) 不会让 a 消失;a 离开作用域时仍会执行析构函数。
5.3 移动后的对象还能不能用
标准库类型在被移动后通常处于"有效但状态未指定"的状态:
- 可以析构;
- 可以重新赋值;
- 可以执行没有额外前置条件的操作;
- 不应依赖它仍保存移动前的值;
- 也不要普遍假设它一定为空,除非该类型的接口明确保证。
cpp
std::string a = "hello";
std::string b = std::move(a);
a = "new value"; // 正确:重新赋值
unique_ptr 是一个明确保证移动后源指针为空的类型。
5.4 为什么建议写 noexcept
移动资源通常不需要申请新资源,因此往往不会抛出异常:
cpp
Buffer(Buffer&& other) noexcept;
std::vector 扩容时需要把旧元素搬到新内存。如果元素的移动构造可能抛异常,而拷贝可用,vector 为了保持异常安全,可能选择拷贝;如果移动构造是 noexcept,容器通常可以放心移动。
5.5 编译器何时隐式声明移动构造
当类没有用户声明下面这些函数时,编译器通常才会隐式声明移动构造:
- 拷贝构造;
- 拷贝赋值;
- 移动构造;
- 移动赋值;
- 析构函数。
普通构造函数不会阻止移动构造的生成:
cpp
class T {
public:
explicit T(int value); // 不影响隐式移动构造
};
编译器生成的移动构造会逐成员移动:
cpp
class Example {
std::string name_;
std::vector<int> values_;
};
// 概念上的近似形式
Example::Example(Example&& other)
: name_(std::move(other.name_)),
values_(std::move(other.values_)) {}
如果某个成员或基类无法按要求构造,隐式移动构造可能被定义为 delete。
需要注意:没有移动构造不等于 std::move 一定报错。因为 const T& 能绑定右值,重载决议有时会退回到拷贝构造:
cpp
T b = std::move(a); // 可能移动,也可能实际调用拷贝构造
第六章:移动赋值
6.1 它什么时候被调用
移动赋值让一个已经存在的对象接管另一个对象的资源:
cpp
T& operator=(T&& other);
cpp
Buffer a(100);
Buffer b(200);
b = std::move(a); // 移动赋值
此时 b 已经拥有 200 个元素,所以移动赋值必须先处理 b 的旧资源,再接管 a 的资源。
6.2 典型实现
cpp
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_; // 释放当前对象原有资源
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
return *this;
}
移动赋值也应考虑自移动:
cpp
a = std::move(a);
正常代码很少主动这样写,但对象经过泛型算法或引用别名后,两个参数可能意外指向同一对象。
6.3 编译器何时隐式声明移动赋值
条件与移动构造基本相同:如果类没有用户声明拷贝构造、拷贝赋值、移动构造、移动赋值和析构函数,编译器通常会隐式声明移动赋值。
其行为是逐基类、逐成员移动赋值:
cpp
// 概念上的近似形式
Example& Example::operator=(Example&& other) {
name_ = std::move(other.name_);
values_ = std::move(other.values_);
return *this;
}
如果成员不可移动赋值,例如类含有不可赋值的 const 数据成员或引用成员,隐式移动赋值可能被定义为 delete。
第七章:析构函数
7.1 析构函数负责对象善后
析构函数在对象生命周期结束时自动调用:
cpp
~T();
常见时机包括:
- 局部对象离开作用域;
delete一个动态创建的对象;- 容器销毁其中的元素;
- 临时对象生命周期结束;
- 异常导致栈展开。
cpp
Buffer::~Buffer() {
delete[] data_;
}
析构函数不应抛出异常,否则在异常传播过程中再次抛异常,程序可能直接调用 std::terminate。
7.2 编译器会不会隐式声明析构函数
如果没有用户声明析构函数,编译器会隐式声明一个。它会按照与构造相反的顺序销毁成员和基类。
cpp
class Person {
std::string name_;
std::vector<int> scores_;
};
无需手写析构函数:vector 和 string 的析构函数会自动释放各自资源。
如果成员或基类不能被正常析构,隐式析构函数可能被定义为 delete,但正常业务类型中较少遇到这种设计。
7.3 多态基类的虚析构
如果会通过基类指针删除派生对象,基类析构函数必须是虚函数:
cpp
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base {
// ...
};
Base* p = new Derived;
delete p; // 正确调用 Derived 和 Base 的析构函数
没有虚析构函数时,通过 Base* 删除 Derived 对象会产生未定义行为。
7.4 一个容易忽略的规则
只要用户声明了析构函数,即使写的是:
cpp
~T() = default;
它仍然属于"用户声明的析构函数",会阻止编译器按通常规则隐式声明移动构造和移动赋值。如果确实需要移动,可以明确写出:
cpp
T(T&&) noexcept = default;
T& operator=(T&&) noexcept = default;
第八章:编译器隐式声明规则总表
先区分三个概念:
- 隐式声明:编译器把函数声明出来;
- 隐式定义:真正需要使用时,编译器生成函数体;
- 定义为删除:函数形式上存在,但因为成员不支持相应操作而不能调用。
常用规则可以概括为:
| 特殊成员函数 | 未手写自身时是否通常隐式声明 | 常见的被删除或受抑制原因 |
|---|---|---|
| 拷贝构造 | 是 | 某个成员/基类不可拷贝;用户声明移动操作会使隐式拷贝操作被删除 |
| 拷贝赋值 | 是 | 某个成员/基类不可拷贝赋值;含引用成员或不可赋值的 const 成员;用户声明移动操作 |
| 移动构造 | 有条件 | 用户声明了拷贝、移动或析构中的任意相关函数;成员/基类不能完成所需构造 |
| 移动赋值 | 有条件 | 用户声明了拷贝、移动或析构中的任意相关函数;成员/基类不能移动赋值 |
| 析构函数 | 是 | 某个成员/基类不可析构,例如析构函数不可访问或已删除 |
"用户声明"包括:
cpp
T(const T&) = default;
T(const T&) = delete;
~T() = default;
它们虽然没有手写函数体,仍然属于用户声明。
实际使用时可以借助类型特征检查:
cpp
#include <type_traits>
static_assert(std::is_copy_constructible_v<T>);
static_assert(std::is_copy_assignable_v<T>);
static_assert(std::is_move_constructible_v<T>);
static_assert(std::is_move_assignable_v<T>);
static_assert(std::is_destructible_v<T>);
不过 is_move_constructible_v<T> 的含义是"能否用 T&& 构造",如果只有 T(const T&),它也可能为真,因为 const T& 可以绑定右值。这并不能百分之百证明调用的是独立的移动构造函数。
第九章:零法则、三法则和五法则
9.1 三法则
在 C++11 之前,如果一个类需要自定义以下任意一个,通常三个都需要考虑:
- 析构函数;
- 拷贝构造;
- 拷贝赋值。
因为它往往意味着类直接管理某种资源。
9.2 五法则
C++11 加入移动语义后,需要扩展为五个:
- 析构函数;
- 拷贝构造;
- 拷贝赋值;
- 移动构造;
- 移动赋值。
不一定五个都必须实现,但必须明确考虑它们应当是默认、删除还是自定义。
9.3 零法则:最推荐
更现代的做法是不直接管理资源,让标准库类型管理资源:
cpp
#include <vector>
class Buffer {
public:
explicit Buffer(std::size_t size)
: data_(size) {}
private:
std::vector<int> data_;
};
此时通常一个特殊成员函数也不需要写:
vector的拷贝会深拷贝元素;vector的移动会转移内部资源;vector的析构会释放内存。
这就是零法则:业务类尽量由已经正确管理资源的成员组成,让编译器逐成员生成正确行为。
第十章:unique_ptr------唯一所有权
10.1 它解决了什么问题
unique_ptr 表示一份资源在任意时刻只由一个智能指针负责释放:
cpp
#include <memory>
auto p = std::make_unique<int>(42);
不需要手动执行:
cpp
delete p;
p 析构时会自动释放资源。
10.2 不能拷贝,只能移动
唯一所有权无法复制:
cpp
auto p1 = std::make_unique<int>(42);
// auto p2 = p1; // 编译错误:不能产生两个唯一拥有者
auto p2 = std::move(p1); // 正确:所有权从 p1 转交给 p2
移动后:
cpp
p1 == nullptr; // true
*p2 == 42;
10.3 简化实现原理
忽略删除器、空指针优化、数组特化等细节,一个简化版本大致如下:
cpp
template<class T>
class SimpleUniquePtr {
public:
explicit SimpleUniquePtr(T* ptr = nullptr) noexcept
: ptr_(ptr) {}
SimpleUniquePtr(const SimpleUniquePtr&) = delete;
SimpleUniquePtr& operator=(const SimpleUniquePtr&) = delete;
SimpleUniquePtr(SimpleUniquePtr&& other) noexcept
: ptr_(std::exchange(other.ptr_, nullptr)) {}
SimpleUniquePtr& operator=(SimpleUniquePtr&& other) noexcept {
if (this != &other) {
delete ptr_;
ptr_ = std::exchange(other.ptr_, nullptr);
}
return *this;
}
~SimpleUniquePtr() {
delete ptr_;
}
T* get() const noexcept { return ptr_; }
T& operator*() const { return *ptr_; }
T* operator->() const noexcept { return ptr_; }
T* release() noexcept {
return std::exchange(ptr_, nullptr);
}
void reset(T* ptr = nullptr) noexcept {
T* old = std::exchange(ptr_, ptr);
delete old;
}
private:
T* ptr_ = nullptr;
};
真实的 unique_ptr 还支持自定义删除器:
cpp
std::unique_ptr<FILE, decltype(&std::fclose)>
file(std::fopen("data.txt", "r"), &std::fclose);
它因此不仅能管理 new 出来的对象,还能管理文件、操作系统句柄等需要成对释放的资源。
数组版本使用 delete[]:
cpp
auto array = std::make_unique<int[]>(100);
10.4 为什么包含 unique_ptr 的类一般不用手写移动
cpp
class Buffer {
public:
explicit Buffer(std::size_t size)
: data_(std::make_unique<int[]>(size)),
size_(size) {}
private:
std::unique_ptr<int[]> data_;
std::size_t size_ = 0;
};
编译器生成的移动构造概念上是:
cpp
Buffer(Buffer&& other)
: data_(std::move(other.data_)),
size_(std::move(other.size_)) {}
data_ 的移动会进入 unique_ptr 的移动构造,后者接管地址并清空源指针。外层 Buffer 不需要重复实现所有权逻辑。
但基础类型 size_ 的移动等同于复制,因此移动后可能出现:
text
other.data_ == nullptr
other.size_ == 原来的长度
这对析构是安全的。如果类要求空指针必须对应长度 0,就应自定义移动操作,或者改用 vector,让资源和长度由同一个类型维护。
第十一章:shared_ptr------共享所有权
11.1 为什么需要共享所有权
有时无法确定谁应该最后释放对象。例如一个任务同时被多个模块使用:
cpp
auto task = std::make_shared<Task>();
auto worker1 = task;
auto worker2 = task;
只要还有任何一个 shared_ptr 拥有这个 Task,对象就继续存在;最后一个拥有者销毁或放弃所有权时,Task 才被释放。
11.2 控制块
shared_ptr 通常包含两部分信息:
- 指向对象的指针;
- 指向控制块的指针。
控制块概念上保存:
cpp
struct ControlBlock {
std::size_t strong_count; // shared_ptr 的拥有者数量
std::size_t weak_count; // weak_ptr 的观察者数量及实现所需状态
// 删除器、分配器等信息
};
关系大致如下:
text
shared_ptr A ──┐
├──> 控制块:strong = 2
shared_ptr B ──┘ weak = 1
│
└──> 被管理对象
weak_ptr W ─────────────> 同一个控制块
不同标准库的内部字段和计数细节可能不同,但整体思想一致。
11.3 shared_ptr 的拷贝构造
cpp
std::shared_ptr<T> p1 = std::make_shared<T>();
std::shared_ptr<T> p2 = p1;
拷贝时不会复制 T 对象,而是:
p2指向和p1相同的对象;p2指向同一个控制块;- 强引用计数加一。
概念上的实现:
cpp
SharedPtr(const SharedPtr& other) noexcept
: ptr_(other.ptr_),
control_(other.control_) {
if (control_) {
++control_->strong_count;
}
}
因此:
cpp
auto p1 = std::make_shared<int>(42);
auto p2 = p1;
p1.use_count(); // 通常为 2
p2.use_count(); // 通常为 2
11.4 shared_ptr 的拷贝赋值
cpp
p2 = p1;
概念上需要:
- 让
p2放弃原来拥有的对象,原控制块强计数减一; - 如果原强计数减到零,销毁原对象;
- 指向
p1的对象和控制块; - 新控制块强计数加一。
一种常见的安全理解方式是先增加新计数,再减少旧计数,从而兼顾别名和异常安全。真实标准库实现会进行更细致的优化。
11.5 shared_ptr 的移动构造
cpp
std::shared_ptr<T> p2 = std::move(p1);
移动时不需要增加强引用计数,因为拥有者数量没有增加,只是拥有者换了一个变量:
text
移动前:p1 拥有对象,strong = 1
移动后:p2 拥有对象,strong = 1;p1 为空
概念上的实现:
cpp
SharedPtr(SharedPtr&& other) noexcept
: ptr_(std::exchange(other.ptr_, nullptr)),
control_(std::exchange(other.control_, nullptr)) {}
与拷贝相比,移动通常更便宜,因为不需要修改引用计数。实际引用计数往往涉及原子操作,避免一次计数修改具有实际价值。
11.6 shared_ptr 的移动赋值
cpp
p2 = std::move(p1);
概念上执行:
p2释放自己原有的共享所有权;p2接管p1的对象指针和控制块指针;p1变为空;- 新控制块的强引用计数通常不增加。
目标对象原来那一组资源的强计数会减一;如果因此降到零,其对象会被销毁。
11.7 shared_ptr 析构时发生什么
概念上的析构逻辑:
cpp
~SharedPtr() {
if (!control_) {
return;
}
--control_->strong_count;
if (control_->strong_count == 0) {
destroy_managed_object();
if (control_->weak_count == 0) {
delete control_;
}
}
}
这里有两个不同的销毁时刻:
- 强引用计数变成
0:销毁被管理对象; - 对象已销毁并且控制块也不再被
weak_ptr观察:释放控制块。
控制块必须比对象活得更久,是因为对象销毁后,weak_ptr 仍需要通过控制块判断"对象已经不存在"。
11.8 为什么优先使用 make_shared
cpp
auto p = std::make_shared<T>(args...);
通常优于:
cpp
std::shared_ptr<T> p(new T(args...));
make_shared 通常可以把对象和控制块放进一次内存分配中:
- 分配次数更少;
- 局部性更好;
- 写法更简洁;
- 减少复杂表达式中的资源泄漏风险。
代价是:如果还有 weak_ptr 存在,包含对象存储区域的大块分配通常要等控制块释放时才归还;虽然 T 本身已经在强计数归零时析构。
11.9 线程安全边界
不同 shared_ptr 实例共享同一控制块时,引用计数管理是线程安全的。例如多个线程分别复制或销毁各自的 shared_ptr 通常没有问题。
但是,被管理对象本身不会自动变得线程安全:
cpp
auto p = std::make_shared<std::vector<int>>();
多个线程同时通过不同 shared_ptr 修改这个 vector,仍然需要互斥锁等同步手段。
第十二章:shared_ptr 的循环引用问题
引用计数只能判断"还有多少拥有者",无法判断这些拥有者是否已经形成一个与外界隔绝的环。
cpp
struct Person {
std::string name;
std::shared_ptr<Person> partner;
~Person() {
std::cout << name << " destroyed\n";
}
};
auto alice = std::make_shared<Person>();
auto bob = std::make_shared<Person>();
alice->partner = bob;
bob->partner = alice;
离开作用域后:
- 外部的
alice和bob被销毁; - 但 Alice 仍通过
partner拥有 Bob; - Bob 也通过
partner拥有 Alice; - 两边强引用计数都无法降到零;
- 两个对象发生内存泄漏。
解决方法是让至少一条不需要所有权的关系使用 weak_ptr。
第十三章:weak_ptr------只观察,不拥有,不负责释放
13.1 它的核心作用
weak_ptr 表示:
我想在对象还活着时访问它,但我不想因为自己的存在而延长它的生命周期,也不负责最后释放它。
它通常由 shared_ptr 构造:
cpp
auto owner = std::make_shared<Task>();
std::weak_ptr<Task> observer = owner;
这时:
owner增加强引用计数;observer不增加强引用计数;observer会使控制块继续存在,以便判断对象是否还活着。
13.2 为什么不能直接解引用
对象可能在检查和使用之间被另一个线程或另一段代码销毁,所以 weak_ptr 不提供直接的 operator* 和 operator->。
必须先调用 lock(),尝试获得一个临时 shared_ptr:
cpp
if (auto task = observer.lock()) {
task->run(); // task 在这个作用域内保证对象存活
} else {
// 对象已经被销毁
}
lock() 的概念逻辑是:
- 查看控制块的强引用计数;
- 如果已经是零,返回空
shared_ptr; - 如果仍大于零,以线程安全的方式把强计数加一;
- 返回拥有对象的临时
shared_ptr。
不能简单地先调用 expired() 再访问:
cpp
if (!observer.expired()) {
// 到达这里时对象仍可能已经被其他线程销毁
}
需要访问时应直接使用 lock()。
13.3 "我认识它,但不拥有它"的例子
假设窗口对象由应用程序统一管理,按钮只需要通知窗口,不应该决定窗口何时销毁:
cpp
#include <iostream>
#include <memory>
class Window {
public:
void onButtonClicked() {
std::cout << "button clicked\n";
}
};
class Button {
public:
explicit Button(const std::shared_ptr<Window>& window)
: window_(window) {}
void click() {
if (auto window = window_.lock()) {
window->onButtonClicked();
} else {
std::cout << "window no longer exists\n";
}
}
private:
std::weak_ptr<Window> window_; // 观察窗口,但不拥有窗口
};
使用:
cpp
auto window = std::make_shared<Window>();
Button button(window);
button.click();
window.reset(); // 最后一个拥有者放弃所有权,Window 被销毁
button.click(); // 安全发现 Window 已不存在
如果 Button 保存 shared_ptr<Window>,那么只要按钮存在,窗口就不会被销毁。这可能违背"窗口由应用程序决定生命周期"的设计。
如果 Button 保存裸指针 Window*,窗口销毁后指针会悬空,按钮无法安全判断它是否仍然有效。
weak_ptr 正好位于两者之间:
- 不拥有对象;
- 不阻止对象释放;
- 对象消失后不会变成无法检测的悬空观察关系;
- 使用前可通过
lock()安全尝试获得临时所有权。
13.4 用 weak_ptr 打破循环引用
关系通常有明确的主次方向。例如父对象拥有子对象,而子对象只知道父对象:
cpp
struct Parent;
struct Child {
std::weak_ptr<Parent> parent; // 不拥有父对象
};
struct Parent {
std::shared_ptr<Child> child; // 父对象拥有子对象
};
这样形成的是所有权树,而不是所有权环:
text
Parent ──shared_ptr──> Child
Parent <──weak_ptr──── Child
外部最后一个 shared_ptr<Parent> 销毁后:
Parent被销毁;- 它持有的
shared_ptr<Child>被销毁; Child的强引用计数归零,Child被销毁;- 不会泄漏。
13.5 weak_ptr 的拷贝、移动和析构
拷贝 weak_ptr:
- 两者观察同一个控制块;
- 不增加强引用计数;
- 增加控制块中的弱引用相关计数。
移动 weak_ptr:
- 转移对控制块的观察关系;
- 通常不需要增加弱计数;
- 源
weak_ptr变为空。
析构 weak_ptr:
- 减少弱引用相关计数;
- 不会直接销毁被管理对象;
- 如果对象早已销毁,并且这是最后一个需要控制块的弱观察者,控制块才会被释放。
第十四章:三种智能指针怎么选
| 类型 | 是否拥有对象 | 能否拷贝 | 典型用途 |
|---|---|---|---|
unique_ptr<T> |
是,唯一所有权 | 否,只能移动 | 默认选择;树形结构;工厂返回对象;独占资源 |
shared_ptr<T> |
是,共享所有权 | 是,拷贝增加强计数 | 生命周期确实由多个平等参与者共同决定 |
weak_ptr<T> |
否,只观察 | 是 | 非拥有关系;缓存;观察者;父子反向引用;打破循环 |
推荐思路:
- 首先考虑直接把对象作为成员;
- 需要动态生命周期时,优先考虑
unique_ptr; - 只有真正存在共享所有权时才使用
shared_ptr; - 需要知道对象但不希望延长其生命周期时,使用
weak_ptr。
不要仅仅为了"方便到处传递"就把所有对象都改成 shared_ptr。所有权应该表达真实的生命周期关系。
第十五章:一个完整的五法则示例
下面展示直接管理裸指针时五个特殊成员函数的完整写法。它适合学习原理;实际项目通常更推荐 vector 或智能指针。
cpp
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t size = 0)
: data_(size ? new int[size]{} : nullptr),
size_(size) {}
// 拷贝构造:深拷贝
Buffer(const Buffer& other)
: data_(other.size_ ? new int[other.size_] : nullptr),
size_(other.size_) {
if (size_ != 0) {
std::copy(other.data_, other.data_ + size_, data_);
}
}
// 拷贝赋值:复制新资源,释放旧资源
Buffer& operator=(const Buffer& other) {
if (this == &other) {
return *this;
}
int* newData =
other.size_ ? new int[other.size_] : nullptr;
if (other.size_ != 0) {
std::copy(other.data_,
other.data_ + other.size_,
newData);
}
delete[] data_;
data_ = newData;
size_ = other.size_;
return *this;
}
// 移动构造:接管资源
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)) {}
// 移动赋值:释放旧资源,再接管新资源
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
return *this;
}
// 析构:释放当前拥有的资源
~Buffer() {
delete[] data_;
}
private:
int* data_ = nullptr;
std::size_t size_ = 0;
};
对应的调用:
cpp
Buffer a(100);
Buffer b = a; // 拷贝构造
b = a; // 拷贝赋值
Buffer c = std::move(a); // 移动构造
c = std::move(b); // 移动赋值
// 作用域结束时,a、b、c 都会调用析构函数
第十六章:常见误区
误区一:std::move 会移动对象
不会。它只把表达式转换为右值。是否移动取决于随后选择了哪个构造或赋值函数。
误区二:右值引用变量本身仍是右值
有名字的变量表达式是左值,即使它的类型是 T&&:
cpp
void consume(Buffer&& value) {
Buffer a = value; // value 是有名字的左值:拷贝
Buffer b = std::move(value); // 显式转成右值:移动
}
泛型代码中通常使用 std::forward 保留参数原本的值类别,而不是无条件 std::move。
误区三:移动后源对象已经不存在
源对象仍然存在,仍会析构。只是其资源可能已经转移,原值通常不能再依赖。
误区四:移动基础类型会清零
cpp
int a = 10;
int b = std::move(a);
对 int 而言移动就是复制,通常 a 仍为 10。std::move 不会自动修改源变量。
误区五:shared_ptr 拷贝会复制对象
不会。它只复制对象指针和控制块关系,并增加强引用计数。要复制对象本身,应明确构造:
cpp
auto copy = std::make_shared<T>(*original);
误区六:shared_ptr 能解决所有内存问题
它仍可能产生:
- 循环引用;
- 不清晰或过长的生命周期;
- 对同一个裸指针创建多个独立控制块导致重复释放;
- 多线程同时访问对象内容时的数据竞争。
绝对不要这样做:
cpp
T* raw = new T;
std::shared_ptr<T> a(raw);
std::shared_ptr<T> b(raw); // 错误:两个独立控制块,最终重复 delete
应该复制已有的 shared_ptr:
cpp
auto a = std::make_shared<T>();
auto b = a;
结语
这组知识的核心不是记住五个函数的写法,而是理解所有权:
- 拷贝构造:新对象取得一份独立副本;
- 拷贝赋值:已有对象放弃旧内容,取得一份独立副本;
- 移动构造:新对象接管源对象的资源;
- 移动赋值:已有对象释放旧资源,再接管源对象的资源;
- 析构函数:释放当前对象仍然拥有的资源。
智能指针把所有权规则封装成了类型:
unique_ptr:只有我拥有;shared_ptr:我们共同拥有;weak_ptr:我只观察,不拥有,也不决定何时释放。
当成员类型已经正确实现拷贝、移动和析构时,编译器的逐成员操作往往正是需要的行为。因此最值得优先采用的原则仍然是:
用标准容器和智能指针管理资源,让业务类尽量遵循零法则。