Skip to content

智能指针

移动语义 中,我们大量的使用了智能指针作为解释所有权机制的良好案例。本章中,我们将对智能指针进行进一步的阐述。

指针是 C++ 中用于管理内存的重要工具。长期以来,对资源的释放等操作通常由关键字 new/delete 完成。根据 移动语义 所描述的,其非常容易出现内存泄露、重复释放、悬空指针等问题。智能指针是 C++ 标准库提供的 RAII 工具。他们依然持有一个通常意义下的指针,但是会在生命周期结束时自动执行资源释放操作。总的来说,三种智能指针的核心区别如下:

类型所有权模型是否可复制典型场景
std::unique_ptr<T>独占所有权否,只能移动树结构、成员对象、明确的唯一拥有者
std::shared_ptr<T>共享所有权多个对象共同决定资源寿命
std::weak_ptr<T>非拥有观察回指、缓存、观察者、打破循环引用

常见智能指针及使用场景

std::unique_ptr<T>

unique_ptr 是对于指针的默认选择。他独占了由这种方式创建指针指向资源的所有权。他不能复制,只能移动,所以他所有权的转移一定是显式的:

cpp
auto p = std::make_unique<int>(42);
// auto copy = p;          // 编译错误,unique_ptr 不能复制

auto moved = std::move(p); // 所有权转移给 moved
// p 现在为空

std::move(p) 并不会移动整数本身,而是把 p 转换成右值,让 unique_ptr 的移动构造函数接管资源(参考 移动语义)。移动完成后,p 不再拥有资源,变成空的 nullptr,对于该资源的生命周期管理全权交给 moved

对于树形结构或者家族结构,unique_ptr 是相当合适的选择。一个节点拥有自己的子节点,而子节点不拥有父节点:

cpp
struct Person {
    std::string name;
    std::vector<std::unique_ptr<Person>> children;

    explicit Person(std::string n)
        : name(std::move(n)) {}

    Person& add_child(std::string child_name) {
        children.push_back(
            std::make_unique<Person>(std::move(child_name))
        );

        return *children.back();
    }
};

使用:

cpp
auto family = std::make_unique<Person>("Grandparent");

auto& parent = family->add_child("Parent");
parent.add_child("Child");

当 family 对象被销毁时,family 被析构,从而 parent 被析构,从而 child 被析构。此时,整棵树被自动释放。当 parent 被销毁时,由于其不拥有 family 的所有权,所以父节点不会被释放。

如果子节点需要访问父节点,可以添加一个非拥有指针,体现为传统指针的形式:

cpp
struct Person {
    Person* parent = nullptr;
    std::vector<std::unique_ptr<Person>> children;
};

此时,parent 为借用对象,而不负责释放,所以子节点不需要负责父节点的生命周期管理。

但是,不是所有的对象都应该使用 delete 释放。比如,文件需要 fclose 释放,malloc 需要 free 释放等等。所以可以自定义某些删除器:

cpp
struct FileCloser {
    void operator()(std::FILE* file) const noexcept {
        if (file) {
            std::fclose(file);
        }
    }
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

// Usage
FilePtr file(std::fopen("data.txt", "r"));

我们通过重载 () 运算符实现了对析构函数的重构。作为析构函数,根据原则,其不可以抛出异常,所以添加 noexcept 属性。

在项目实现中,有一种原则称为 PIMPL,即 Pointer to Implementation,指向实现的指针。这是一种利用 unique_ptr 隔离实现细节的设计方式,将公开接口和内部实现拆分开。

假设有一个编辑器类:

cpp
#pragma once

#include <string>
#include <vector>

class Editor {
public:
    void open(const std::string& path);
    void save();

private:
    std::string current_file_;
    std::vector<std::string> lines_;
    int cursor_line_ = 0;
};

此时,程序只要调用了这个头文件,那么就会引入其中所有的成员定义。如果只修改了内部成员,虽然公开函数没有改变,但是所有包含这个头文件的文件都会重新编译,这在大型项目中往往会拖慢构建进度。所以,我们可以利用 PIMPL:

cpp
// editor.h
#pragma once

#include <memory>
#include <string>

class Editor {
public:
    Editor();
    ~Editor();

    Editor(Editor&&) noexcept;
    Editor& operator=(Editor&&) noexcept;

    Editor(const Editor&) = delete;
    Editor& operator=(const Editor&) = delete;

    void open(const std::string& path);
    void save();

private:
    class Impl;
    std::unique_ptr<Impl> impl_;
};

头文件只知道有一个 Editor::Impl 类,但不知道它的成员是什么,而真正的实现放在源文件中:

cpp
// editor.cpp
#include "editor.h"

#include <deque>
#include <fstream>
#include <string>

class Editor::Impl {
public:
    std::string current_file_;
    std::deque<std::string> lines_;
    int cursor_line_ = 0;
};

Editor::Editor()
    : impl_(std::make_unique<Impl>()) {}

Editor::~Editor() = default;

Editor::Editor(Editor&&) noexcept = default;

Editor& Editor::operator=(Editor&&) noexcept = default;

void Editor::open(const std::string& path) {
    impl_->current_file_ = path;
    // 读取文件并写入 impl_->lines_
}

void Editor::save() {
    // 使用 impl_->current_file_ 和 impl_->lines_
}

// Usage
Editor editor;
editor.open("notes.txt");
editor.save();

就可以通过这种方式减少重新编译范围。同时,未使用 PIMPL 时,editor.h 需要包含 <string><vector> 等,这些依赖可能泄露到其他头文件。使用后,头文件只需要引入 <memory> 等引入 Impl 必须的头文件即可。这会降低编译依赖,减少间接包含带来的复杂性。

std::shared_ptr<T>

shared_ptr 表示多个对象可以共享同一个资源。比如:

cpp
auto a = std::make_shared<std::string>("shared");
auto b = a;

现在,a 和 b 均是指向字符串 shared 地址的智能指针,也就是说,a 和 b 共同拥有这一个字符串对象。当 a 离开作用域时,对象不会被释放,因为 b 存在。只有最后一个 shared_ptr 被销毁时,对象才会真正释放。shared_ptr 通常会维护一个控制块,控制块中包含强引用计数、弱引用计数、删除器和被管理对象的信息。强引用计数表示的是有多少个指针指向这个资源,弱引用计数与 weak_ptr 有关。如果还有 weak_ptr,控制块本身可能继续存在,直到最后一个 weak_ptr 也被销毁。

比如多个模块共同使用同一个对象时:

cpp
struct Config {
    int timeout_ms = 1000;
};

auto config = std::make_shared<Config>();

auto client_config = config;
auto logger_config = config;

此时,client 持有一份指针,logger 持有一份指针,只要有任意的模块需要这个指针,他就不会被销毁,对象也不会被释放。但是,根据上文所说的,shared_ptr 应该表达真实的共享所有权。如果实际上只有一个明确的拥有者,那么更合适的设计通常是使用 unique_ptr,其他需要访问的地方使用借用。分清访问和借用的区别。

一个常见的使用共享指针的错误是对于同一个资源创建多个指针,如:

cpp
auto* raw = new int(1);

std::shared_ptr<int> first(raw);
std::shared_ptr<int> second(raw);

为了同一个裸指针 raw,这里创建了两个控制块,他们都认为自己拥有了 raw。这样,可能会对同一块内存执行两次 delete 操作。正确的方式是复制已有的 shared_ptr。

shared_ptr 的引用计数操作通常是线程安全的,例如多个线程同时复制同一个控制块,一般是安全的。但这并不意味着被管理的对象本身是线程安全的。对于以下声明的对象:

cpp
auto value = std::make_shared<int>(0);

如果多个线程同时修改 value 的值,则他们依旧可能存在数据竞态的情况。这时,需要利用互斥锁、原子变量等同步机制。

std::weak_ptr<T>

weak_ptr 指向由 shared_ptr 管理的对象,但不会增加强引用计数。

cpp
std::weak_ptr<Config> observer = config;

此时 observer 可以观察 config,但不能阻止它销毁。如果访问对象,则需要先进行 lock() 操作:

cpp
if (auto current = observer.lock()) {
    current->timeout_ms = 2000;
}

lock() 会返回一个新的 shared_ptr 对象。如果 observer 指向的对象还存在,则返回有效的 shared_ptr,如果对象已经被销毁,那么则返回空的 shared_ptr。lock() 函数同时完成了检查对象存在性、在当前的作用域内暂时延长对象生命周期的问题。

C++ 标准库提供了一个 API expired() 用于检测对象是否存在,但是以下写法有可能会引发问题:

cpp
if (!observer.expired()) {
    // marked
    observer.lock()->some_function();
}

因为在 marked 行,对象可能被销毁了。expired() 函数仅负责检查瞬时的状态,其和使用对象的语句之间存在时间窗口。正确的做法是在判断逻辑直接加锁:

cpp
if (auto object = observer.lock()) {
    object->some_function();
}

shared_ptr 最容易出现的问题是循环引用导致的内存泄露,即两个对象相互依赖,导致均无法释放:

cpp
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> previous;
};

// Usage
auto first = std::make_shared<Node>();
auto second = std::make_shared<Node>();

first->next = second;
second->previous = first;

引用关系如下:

first ──shared_ptr──> second
  ▲                    │
  └────shared_ptr──────┘

即使外部的 firstsecond 被销毁,但是 first 仍被 second->previous 持有,second 仍被 first->next 持有,两者的引用计数都不会降到 0,自然无法被释放。所以,一个比较常用的方式是利用 weak_ptr 来破坏这种循环。我们将不负责生命周期的那条边设置为 weak_ptr

cpp
struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> previous;
};

// Usage
auto first = std::make_shared<Node>();
auto second = std::make_shared<Node>();

first->next = second;
second->previous = first;

此时,first 强引用 secondsecond 弱引用 first。当外部强引用消失后,对象可以正常销毁,因为弱引用不能阻止对象销毁。

一个非常实用的原则是拥有关系用强引用,回指关系用弱引用。

观察者模式是一种“一对多”的通知机制。即一个对象发生变化时,自动通知所有关注它的对象。其中,主题(Subject)为被观察的对象,负责维护观察者列表并发出通知,观察者(Observer)为关注主题变化的对象,收到通知后执行其回调逻辑。例如新闻发布系统:

新闻中心(主题)
 ├── 手机 App(观察者)
 ├── 邮件系统(观察者)
 └── 网站页面(观察者)

在观察者模式中,主题对象通常不应该因为订阅者注册过,就一直持有订阅者。否则,当观察者已经不再使用时,由于主题一直拥有它,就永远无法释放。所以,可以利用 weak_ptr 来保存观察者:

cpp
class Observer {
public:
    virtual ~Observer() = default;
    virtual void update() = 0;
};

class Subject {
private:
    std::vector<std::weak_ptr<Observer>> observers_;

public:
    void add_observer(const std::shared_ptr<Observer>& observer) {
        observers_.push_back(observer);
    }

    void notify() {
        for (auto it = observers_.begin(); it != observers_.end();) {
            if (auto observer = it->lock()) {
                observer->update();
                ++it;
            } else {
                it = observers_.erase(it);
            }
        }
    }
};

主题对象只保存观察关系,不负责观察者的生命周期。观察者自行销毁后,主题下一次通知时可以通过 lock() 发现对象已经不存在,并清理失效的 weak_ptr

智能指针与普通指针

智能指针主要解决的是动态对象所有权的问题,但是这并不意味着所有的指针都适合更改为智能指针。也就是说,函数参数的类型应该说明函数和对象之间的关系,而不只是为了避免写裸指针。全用智能指针会让代码语义变复杂、运行成本增加,甚至掩盖设计问题。

不是所有的对象都需要动态分配。比如下面的案例,对于 print 函数,其只需要进行输出流操作,不需要更加复杂的生命周期管理。所以,用最朴素的指针就可以。

cpp
void print(const Person& person) {
    std::cout << person.name;
}

Person person{"Alice"};
print(person);
cpp
// 不建议的写法
auto person = std::make_shared<Person>("Alice");
print(person);

并且,智能指针会表达错误的所有权语义。比如以下声明:

cpp
void print_name(std::shared_ptr<Person> person);

这个声明暗示我们,这个函数可能会保存 person,并参与管理他的生命周期。但是如果他只是读取对象,那么用最朴素的指针可能会更加合适,比如下面的声明:

cpp
void print_name(const Person& person);

这样的声明语义会更加准确。这个函数只负责借用(读取)对象,而不是负责释放,也不会影响对象 person 的生命周期。如果一个项目中所有的指针都是 shared_ptr,那么通常这些代码没有明确回答对象的拥有者、销毁的责任者,同样,对临时访问、动态分配的必要性也是缺乏考虑的。接口类型本身就是文档。好的接口应当可以表达这些设计意图。

同时,想对于传统的指针,智能指针本身具有运行开销,比如控制块、维护的引用计数(对于 shared_ptr 而言,以及额外的内存分配和为了这些操作所进行的运算开销。

所以,界定在合适的时候使用合适类型的智能指针才是明智的。下面给出一个比较恰当的例子。对于网络服务器中的异步连接管理,我们使用 unique_ptr 来描述服务器独占的连接,因为是服务器自己负责创建和销毁对象。即:

cpp
class Server {
private:
    std::vector<std::unique_ptr<Connection>> connections_;
};

当服务器关闭或移除连接时,连接对象自动释放。这里没有必要使用 shared_ptr,因为连接只有一个明确的拥有者:Server

对于异步请求,我们利用 shared_ptr 来声明。因为,一个请求可能被网络线程读取、被线程池处理、定时器处理、日志模块记录…… 这些任务的执行时间不同,我们无法确认哪个任务最后结束,因此可以让他们共享这块资源,由最后结束的任务来负责销毁。即:

cpp
struct RequestContext {
    Request request;
    Response response;
};

void handle_request(std::shared_ptr<RequestContext> context) {
    thread_pool.submit([context] {
        process_business_logic(context->request);

        send_response(context->response);
    });
}

在上面的案例中,只要有一个异步任务持有 context,那么对象就不会被销毁。

而对于定时器回调类任务,我们一般利用 weak_ptr 来声明。这是因为,如果当连接中断,定时器不应该继续持有连接对象,否则因为定时器无尽,连接永远不会被释放。即:

cpp
class Connection
    : public std::enable_shared_from_this<Connection> {
public:
    void start_timer() {
        std::weak_ptr<Connection> weak_self = shared_from_this();

        timer_.async_wait([weak_self] {
            if (auto self = weak_self.lock()) {
                self->send_ping();
            }
        });
    }

private:
    Timer timer_;
};

这样根据对象和操作的关系即项目设计语境来决定指针类型的方式,清晰的说明了各个对象生命周期和所有权之间的关系,更不容易产生循环引用和内存泄漏等问题。

Released under MIT License