回到顶部 暗色模式

EffectiveModernCpp(3):智能指针

        智能指针包裹了原始指针,行为类似于被包裹的原始指针,但是避免了原始指针的很多陷阱。C++11中存在四种智能指针:

        它们都是设计于帮助管理动态分配的对象生命周期的,会在适当的时间通过适当的方式销毁对象,避免出现资源泄露或者异常行为。
        $std::auto_-ptr$ 是C++98的遗留,C++11使用 $std::unique_-ptr$ 替代了它。$std::unique_-ptr$ 在 $std::auto_-ptr$ 的基础上可以做更多的事情,在任何方面都比 $std::auto_-ptr$ 好。

1. unique_ptr

        当你需要一个智能指针的时候,$std::unique_-ptr$ 通常是最适合的。默认情况下,$std::unique_-ptr$ 等同于原始指针,并且对于大部分操作,它们的操作完全相同。
        $unique_-ptr$ 体现了专有语义,一个非空的 $unique_-ptr$ 始终持有其指向的内容。在这个前提下,移动拷贝会转移指针的所有权,拷贝操作则是完全不允许的。当 $unique_-ptr$ 销毁时,它会调用其关联的析构函数。
        $unique_-ptr$ 的常见用法是作为对象工厂函数的返回值。

class Investment {};
class Sock: public Investment {};
class Bond: public Investment {};
class RealEstate: public Investment {};

template <typename ...Ts>
std::unique_ptr<Investment>
makeInvestment(Ts&&... params);

        默认情况下,$unique_-ptr$ 持有指针的销毁通过 $delete$ 进行,但是也可以自定义一个析构函数。如果创建的对象需要在析构前写一条日志,可以这样实现:

auto delInvmt = [](Investment *pInvestment) {
  makeLogEntry(pInvestment);
  delete pInvestment;
};

template <typename ...Ts>
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvestment(Ts&&... params) {
  std::unique_ptr<Investment, decltype(delInvmt)>
  pInv(nullptr, delInvmt);

  if (/* a Stock object should be created */)
    pInv.reset(new Stock(std::forward<Ts>(params)...));
  else if (/* a Bond object should be created */)
    pInv.reset(new Bond(std::forward<Ts>(params)...));
  else if (/* a RealEstate ojbect should be created */)
    pInv.reset(new RealEstate(std::forward<Ts>(params)...));
  return pInv;
}

        在上述代码中我们通过基类指针删除子类对象,这意味着基类的析构函数必须是虚析构函数。
        当使用默认删除器时,我们可以假设 $unique_-ptr$ 的大小和原始指针相同。以函数指针的方式指定删除器时,通常会使 $unique_-ptr$ 的大小从一个字长增长为两个字长。对于删除器函数对象来说,函数中存储的状态数决定了其大小。如果一个函数没有状态 ( 比如没有捕获对象的lambda表达式 ) ,那么它也没有大小。从而如果我们使用无捕获的lambda函数来声明删除器,$unique_-ptr$ 的大小还是一个字长。

auto delInvmt = [](Investment *pInvestment) {
  makeLogEntry(pInvestment);
  delete pInvestment;
};

// unique_ptr为Investment *大小
template <typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvestment(Ts &&...params);

void delInvmt2(Investment *pInvestment) {
  makeLogEntry(pInvestment);
  delete pInvestment;
}

// unique_ptr为Investment *大小加上至少一个函数指针的大小
template <typename... Ts>
std::unique_ptr<Investment, void (*)(Investment *)>
makeInvestment(Ts &&..params);

        具有很多状态的删除器会导致 $unique_-ptr$ 变得很大,如果这种情况发生,你需要修改删除器函数。
        $unique_-ptr$ 还支持数组形式 ( $unique_-ptr$<$T[\ ]$> ),并且会自动匹配,比如数组形式就可以使用 $[\ ]$ 运算符。但是理论上这种形式不会被使用,因为我们不应该使用原始数组,而应该使用 $std::vector$ 等容器。使用原始数组的唯一情况是作为C API的入参或者返回值。
        $unique_-ptr$ 还有一个功能是可以轻松地转为 $std::shared_-ptr$ :

std::shared_ptr<Investment> sp = makeInvestment(arguments);

        这也是为什么 $unique_-ptr$ 适合作为工厂函数的返回类型,因为它们并不知道调用者想要怎么使用返回值,而 $unique_-ptr$ 给予了调用者足够的灵活度。

2. shared_ptr

        通过 $std::shared_-ptr$ 访问的对象生命周期由指向它的指针们所共享,所有指向它的 $shared_-ptr$ 都能相互合作确保在它不再使用的时候析构。$shared_-ptr$ 通过引用计数来确保当前指针是最后一个指向资源的指针。引用计数暗示着性能问题:

        类似于 $unique_-ptr$ ,$shared_-ptr$ 也可以指定自定义的删除器。但是对于 $unique_-ptr$ 来说,删除器是类型的一部分,$shared_-ptr$ 不是。

auto loggingDel = [](Widget *pw) {
  makeLogEntry(pw);
  delete pw;
};

std::unique_ptr<Widget, decltype(logginDel)>
upw(new Widget, loggingDel);
std::shared_ptr<Widget> spw(new Widget, loggingDel);

        $shared_-ptr$ 的设计更为灵活,它可以让同类型的指针使用不同的删除器函数。
        另一个与 $unique_-ptr$ 不同的是,自定义的删除器不会影响 $shared_-ptr$ 的大小。不管删除器是什么形式,$shared_-ptr$ 永远都是两个指针的大小。
        $shared_-ptr$ 对象包含了所指对象的引用计数,它是一个更大的数据结构,通常称为控制块 ( $control$ $block$ )。控制块包含除了引用计数值之外的一个自定义删除器的拷贝,如果用户还指定了自定义的分配器,控制器也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,比如次级引用计数 $weak$ $count$ 等。
        当 $shared_-ptr$ 被创建,对象控制块也就被创建了。通常,对于一个创建指向对象的 $shared_-ptr$ 函数来说无法知道是否有其他的 $shared_-ptr$ 已经指向了那个对象。所以控制块的创建会遵循以下规则:

        这些规则的存在使得一个原始指针对象可能关联多个控制块,从而存在多个引用计数,更糟糕的是,这可能会导致对象被销毁多次。所以,这也给 $shared_-ptr$ 的使用衍生出来两条法则:

  1. 避免通过原始指针构造 $shared_-ptr$ ,应该尽量使用 $make_-shared$ 代替;
  2. 当存在另一个 $shared_-ptr$ 时,通过另一个 $shared_-ptr$ 对象来构造新的 $shared_-ptr$ 对象。

        在这里要注意一个特殊情况,那就是 $this$ 指针。

class Widget {
public:
  // ...
  void process();
};

std::vector<std::shared_ptr<Widget>> processedWidgets;

void Widget::process() {
  processedWidget.emplace_back(this);  // 错误用法
}

        上面的代码错误在于使用 $this$ 传递,由原始指针构造的 $shared_-ptr$ 会创建一个控制块。为了解决这个问题,C++引入了 $std::enable_-shared_-from_-this$ ,它是一个用作基类的模版类,可以从该类型的 $this$ 对象上安全创建 $shared_-ptr$ 。

class Widget: public std::enable_shared_from_this<Widget> {
public:
  // ...
  void process();
};

void Widget::process() {
  processedWidgets.emplace_back(shared_from_this());
}

        通过 $shared_-from_-this$ ,我们可以在不创建新控制块的前提下创建指向当前对象的 $shared_-ptr$ 。
        为了避免客户端在创建第一个 $shared_-ptr$ 之前就调用 $shared_-from_-this$ ,我们可以通过工厂模式创建对象。

class Widget: public std::enable_shared_from_this<Widget> {
public:
  // ...
  template <typename... Ts>
  static std::shared_ptr<Widget>
  create(Ts &&...params);

  void process();

private:
  Widget();
};

        控制块的实现比你想象的更复杂一些,它使用了继承,包含虚析构函数,这意味着使用 $shared_-ptr$ 也会带来使用虚函数的成本。这么一看,$shared_-ptr$ 的开销要比你想的大得多。但是这是可以接受的,因为它提供的功能比这些开销的价值更大。在通常情况下,$shared_-ptr$ 创建控制块会使用默认删除器和默认分配器,从而控制块只需要三个字长,它的分配基本上是无开销的。对 $shared_-ptr$ 进行解引用操作的开销不会比原始指针高。对引用计数进行原子操作需要承担一两个原子操作的开销;对于每个 $shared_-ptr$ 指向的对象来说,控制块中的虚函数机制产生的开销只需要承受一次,即对象被销毁的时候。通过这些开销的代价,我们得到了自动管理动态分配资源生命周期的功能。在大多数时候,我们都可以使用 $shared_-ptr$ 来管理动态资源。
        与 $unique_-ptr$ 不同,$shared_-ptr$ 无法处理数组,它从设计之初就是用于单个对象的。我们应该使用 $std::vector$ 等容器来代替原始数组。

3. weak_ptr

        $std::weak_-ptr$ 是一个类似于 $std::shared_-ptr$ 的指针,但是不参与资源所有权的共享,即不影响引用计数。这种类型的指针用于解决 $shared_-ptr$ 无法解决的问题——悬垂指针。
        $weak_-ptr$ 不能解引用,也不能测试是否为空,因为它不是一个独立的智能指针,而是 $shared_-ptr$ 的增强。$weak_-ptr$ 通常从 $shared_-ptr$ 上创建,并且不会影响 $shared_-ptr$ 的引用计数。

auto spw = std::make_shared<Widget>();
std::weak_ptr<Widget> wpw(spw);
spw = nullptr;
if (wpw.expired()) {
  // ...
}

        $weak_-ptr$ 通过 $expired$ 来判断是否悬垂。但是通常,我们需要在 $weak_-ptr$ 未失效时进行解引用操作,可惜的是,并没有这样的函数。引入解引用会导致竞态条件:在 $expired$ 和解引用操作之间,如果其它线程的操作导致指向对象的析构,解引用会产生未定义行为。从这个角度来看,$weak_-ptr$ 应该从 $shared_-ptr$ 中创建,并且只用于判断指针是否无效。
        为了满足解引用的需求,我们可以从 $weak_-ptr$ 中创建一个 $shared_-ptr$ 。

// if wpw is expired, spw1 is nullptr
std::shared_ptr<Widget> spw1 = wpw.lock();
// same as above
auto spw2 = wpw.lock();
// if wpw is expired, throw std::bad_weak_ptr
std::shared_ptr<Widget> spw3(wpw);

        以上三种方式都可以创建一个 $shared_-ptr$ 。
        单从效率的角度来看,$weak_-ptr$ 和 $shared_-ptr$ 基本相同。两者的大小相同,使用相同的控制块。要注意 $weak_-ptr$ 也有引用计数,即之前提过的 $weak$ $count$ 。$weak_-ptr$ 也会对引用计数进行操作,同样的,这些操作是原子的。控制块只会在 $weak$ $count$ 为 $0$ 的时候析构。

4. make_uniquemake_shared

        $std::make_-shared$ 是C++11标准的一部分,$std::make_-unique$ 则是C++14加入的。但是一个C++11版本的 $make_-unique$ 也是很容易实现的:

template <typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts &&...params) {
  return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

        $make_-unique$ 负责的只是将参数完美转发到 $unique_-ptr$ 的构造函数中,虽然这种形式不支持数组和自定义析构。
        除了 $std::make_-shared$ 和 $std::make_-unique$ 之外,还有第三个函数 $std::allocate_-shared$ ,它和 $std::make_-shared$ 一样,除了第一个参数是分配器。

auto upw1(std::make_unique<Widget>());
std::unique_ptr<Widget> upw2(new Widget);
auto spw1(std::make_shared<Widget>());
std::shared_ptr<Widget> spw2(new Widget);

        可以看到,相比于使用 $new$ 的版本,$make$ 版本不会重复声明类型。而且不使用 $make_-shared$ 函数构造 $shared_-ptr$ 的版本实际上进行了两次内存分配,一次是 $Widget$ ,另一次则是控制块。相比之下,$make_-shared$ 只会分配一块内存,同时容纳 $Widget$ 和控制块。

void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();

// potential resource leak
processWidget(std::shared_ptr<Widget>(new Widget, computePriority());

        上面这段代码可能会发生泄露。在 $processWidget$ 开始运行之前,程序必须先动态分配 $Widget$ 对象、构造 $shared_-ptr$ 和运行 $computePriority$ 。但是要注意,编译器不需要按序执行代码,除了 $shared_-ptr$ 必须在 $Widget$ 创建之后构造。如果程序在创建了 $Widget$ 之后,运行 $computePriority$ ,并且发生异常,那么就会导致 $Widget$ 泄露,因为它永远不会被释放。这个问题的解决是使用 $make_-shared$ :

processWidget(std::make_shared<Widget>(), computePriority());

        $make_-shared$ 把之前的创建 $Widget$ 对象和构造 $shared_-ptr$ 合为一步,从而避免了泄露。
        倾向于使用 $make$ 函数并不意味着所有地方都要使用,比如在需要指定删除器时,显然就不应该使用 $make$ 函数。除此之外,$make$ 函数也有其单一概念语法的限制。

auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);

        我们在统一初始化的章节讲过,对于使用 $std::initializer_-list$ 的构造函数,花括号初始化和小括号初始化行为不同。但是对于 $make$ 函数,它们的行为是确定的,上面的代码都会生成长度为 $10$ 的 $vector$ 。但是当我们想要使用花括号初始化时,我们只能使用 $new$ ,或者手动创建一个初始化列表。

auto initList = {10, 20};
auto spv = std::make_shared<std::vector<int>>(initList);

        一些类重载了 $new$ 和 $delete$ 运算符,但是这些运算符往往只会精确地分配指定对象大小的内存。但是对于智能指针来说,它们往往需要大于对象内存大小的空间,所以使用 $make$ 函数创建重载了 $new$ 和 $delete$ 运算符的对象也是不合理的。
        但是要注意,在使用 $new$ 构造智能指针时,一定要确保尽快将结果传递到智能指针构造函数中,以避免潜在的泄露可能。

void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();
void cusDel(Widget *ptr);

std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(std::move(spw), computePriority());

        通常情况下,$std::move$ 也是不需要的,但是当你确定了这个对象仅作为右值使用时,就可以通过 $std::move$ 提升性能。

5. Pimpl惯用法

        $Pimpl$ ( $Pointer$ $to$ $implementation$ ) 惯用法是一种将类数据成员指向一个包含具体实现的类的指针,并将主类的数据成员移动到实现类的办法。

class Widget {
  // ...
private:
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

        在上面这个类中,为了编译,我们需要包含 <$string$> 、<$vector$> 以及自定义头文件 $gadget.h$ ,这会增加 $Widget$ 的编译时间,并且如果后续有修改,也要重新编译。C++98中可以使用 $Pimpl$ 惯用法修改代码:

class Widget {
  // ...
private:
  struct Impl;
  Impl *pImpl;
};

        这样可以避免包含许多头文件。这里使用了一个未完成类型 ( $incomplete$ $type$ ),它被声明,但没有被定义。我们在 $widget.h$ 头文件中声明 $Impl$ ,并在 $widget.cpp$ 文件中实现这个结构。

#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget() : pImpl(new Impl) {}

Widget::~Widget() { delete Impl; }

        我们可以使用智能指针修改上面的代码:

class Widget {
  // ...
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};

#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget()
  : pImpl(std::make_unique<Imple>()) {}

        通过智能指针,我们就不需要析构函数了,因为智能指针会负责释放这块区域。但是如果你尝试着使用 $Widget$ ,会发现它报错了。因为编译器给我们自动生成了一个析构函数,在这个析构函数里,编译器会调用 $unique_-ptr$ 的析构函数,后者的默认行为会在 $delete$ 之前调用 $static_-assert$ 确保原始指针不会指向一个不完整类型。问题也恰好出现在这里,$Widget$ 的析构函数并不知道 $Impl$ 的定义,便认为它是一个不完整类型。因为这个析构函数是隐式 $inline$ 的,并不会在 $widget.cpp$ 文件中寻找其定义,自然也就发现不了 $Impl$ 的定义。
        解决这个问题的办法是让编译器在调用析构函数前发现 $widget.cpp$ 中定义的结构体,所以只要把析构函数的定义搬到 $widget.cpp$ 文件中即可。

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

private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};

#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget()
  : pImpl(std::make_unique<Impl>()) {}

Widget::~Widget() = default;

        这样,编译器就会在寻找析构函数的定义之前,发现 $Impl$ 结构体的成员。
        同样的,这个问题也会影响到所有自动生成的特殊函数。我们的解决办法也是一样的。

class Widget {
public:
  Widget();
  ~Widget();
  Widget(Widget &&rhs);
  Widget &operator=(Widget &&rhs);

private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};

#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget()
  : pImpl(std::make_unique<Impl>()) {}

Widget::~Widget() = default;

Widget::Widget(Widget &&rhs)
  : pImpl(std::make_unique<Impl>(*rhs.pImpl)) {}

Widget &Widget::operator=(Widget &&rhs) {
  *pImpl = *rhs.pImpl;
  return *this;
}

        要注意 $unique_-ptr$ 并不允许拷贝构造,所以我们需要进行深拷贝。
        如果我们在 $Widget$ 中使用的不是 $unique_-ptr$ ,而是 $shared_-ptr$ ,我们会发现上面的问题不再存在了。这是由于 $unique_-ptr$ 和 $shared_-ptr$ 存储删除器的方式不同,$unique_-ptr$ 的删除器是它的一部分,从而 $unique_-ptr$ 的删除器必须在编译期时确定,而 $shared_-ptr$ 不需要。

EffectiveModernCpp(3):智能指针