C++中的特殊成员函数与智能指针

C++中的特殊成员函数与智能指针

前言:对象其实也有"生命周期"

一个 C++ 对象从出生到消亡,大致会经历下面几件事:

  1. 被创建;
  2. 可能从另一个对象复制而来;
  3. 可能接管另一个对象的资源;
  4. 可能在创建后被重新赋值;
  5. 最后被销毁。

C++ 用一组特殊成员函数控制这些行为。本文重点介绍其中五个:

cpp 复制代码
class T {
public:
    T(const T& other);             // 拷贝构造
    T& operator=(const T& other);  // 拷贝赋值
    T(T&& other);                  // 移动构造
    T& operator=(T&& other);       // 移动赋值
    ~T();                          // 析构函数
};

它们经常被称为"资源管理五件套"。理解它们之后,unique_ptrshared_ptrweak_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_ ──┘

这种只复制指针地址的行为叫作浅拷贝 。当 ab 都认为自己拥有这块内存时,它们析构时会重复释放,程序产生未定义行为。

注意:浅拷贝本身不一定错误。如果指针只是观察外部对象、根本不负责释放,复制地址可能正是合理行为。危险的是"对拥有型裸指针做浅拷贝"。

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_) {}

因此,编译器生成的拷贝对 intstd::stringstd::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 为什么赋值比构造多一步

拷贝构造面对的是一个还没有资源的新对象。拷贝赋值面对的对象可能已经拥有旧资源,因此需要:

  1. 准备新资源;
  2. 释放目标对象的旧资源;
  3. 安装新资源;
  4. 返回 *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)) {}

它做了两件事:

  1. 新对象取得原来的指针和长度;
  2. 原对象放弃资源,被置为空状态。
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_;
};

无需手写析构函数:vectorstring 的析构函数会自动释放各自资源。

如果成员或基类不能被正常析构,隐式析构函数可能被定义为 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 通常包含两部分信息:

  1. 指向对象的指针;
  2. 指向控制块的指针。

控制块概念上保存:

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 对象,而是:

  1. p2 指向和 p1 相同的对象;
  2. p2 指向同一个控制块;
  3. 强引用计数加一。

概念上的实现:

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;

概念上需要:

  1. p2 放弃原来拥有的对象,原控制块强计数减一;
  2. 如果原强计数减到零,销毁原对象;
  3. 指向 p1 的对象和控制块;
  4. 新控制块强计数加一。

一种常见的安全理解方式是先增加新计数,再减少旧计数,从而兼顾别名和异常安全。真实标准库实现会进行更细致的优化。

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);

概念上执行:

  1. p2 释放自己原有的共享所有权;
  2. p2 接管 p1 的对象指针和控制块指针;
  3. p1 变为空;
  4. 新控制块的强引用计数通常不增加。

目标对象原来那一组资源的强计数会减一;如果因此降到零,其对象会被销毁。

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;

离开作用域后:

  • 外部的 alicebob 被销毁;
  • 但 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() 的概念逻辑是:

  1. 查看控制块的强引用计数;
  2. 如果已经是零,返回空 shared_ptr
  3. 如果仍大于零,以线程安全的方式把强计数加一;
  4. 返回拥有对象的临时 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> 销毁后:

  1. Parent 被销毁;
  2. 它持有的 shared_ptr<Child> 被销毁;
  3. Child 的强引用计数归零,Child 被销毁;
  4. 不会泄漏。

13.5 weak_ptr 的拷贝、移动和析构

拷贝 weak_ptr

  • 两者观察同一个控制块;
  • 不增加强引用计数;
  • 增加控制块中的弱引用相关计数。

移动 weak_ptr

  • 转移对控制块的观察关系;
  • 通常不需要增加弱计数;
  • weak_ptr 变为空。

析构 weak_ptr

  • 减少弱引用相关计数;
  • 不会直接销毁被管理对象;
  • 如果对象早已销毁,并且这是最后一个需要控制块的弱观察者,控制块才会被释放。

第十四章:三种智能指针怎么选

类型 是否拥有对象 能否拷贝 典型用途
unique_ptr<T> 是,唯一所有权 否,只能移动 默认选择;树形结构;工厂返回对象;独占资源
shared_ptr<T> 是,共享所有权 是,拷贝增加强计数 生命周期确实由多个平等参与者共同决定
weak_ptr<T> 否,只观察 非拥有关系;缓存;观察者;父子反向引用;打破循环

推荐思路:

  1. 首先考虑直接把对象作为成员;
  2. 需要动态生命周期时,优先考虑 unique_ptr
  3. 只有真正存在共享所有权时才使用 shared_ptr
  4. 需要知道对象但不希望延长其生命周期时,使用 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 仍为 10std::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:我只观察,不拥有,也不决定何时释放。

当成员类型已经正确实现拷贝、移动和析构时,编译器的逐成员操作往往正是需要的行为。因此最值得优先采用的原则仍然是:

用标准容器和智能指针管理资源,让业务类尽量遵循零法则。

相关推荐
_wyt0013 小时前
多重背包问题详解
c++·背包dp
Ljwuhe4 小时前
C++——多态
开发语言·c++
越甲八千5 小时前
STL stack为何没有迭代器
c++
从零开始的代码生活_6 小时前
C++ 继承详解:访问控制、对象模型、菱形继承与设计取舍
开发语言·c++·后端·学习·算法
云小逸6 小时前
【C++ 第七阶段:模板、泛型编程与工程综合详解】
开发语言·c++
GIS阵地7 小时前
QgsSingleBandPseudoColorRenderer 完整详解(QGIS 3.40.13 C++)
开发语言·前端·c++·qt·qgis
王维同学7 小时前
[原创][Windows C++]LSA 认证、安全与通知包的注册表枚举
c++·windows·安全
程序员Rock7 小时前
上位机开发-MODBUS面试常见问题
c语言·c++·面试·职场和发展·上位机
_wyt0018 小时前
完全背包问题详解
c++·背包dp