0%

Effective Modern C++读书笔记(三)

本文包含《Effective Modern C++》中第4章内容的个人笔记,讨论现代C++中智能指针使用问题。

第4章 智能指针

c++11标准增加了新版的智能指针unique_ptr、shared_ptr、weak_ptr,同时废弃了旧标准中的auto_ptr,本章着重介绍的就是这些新增智能指针需要注意的地方。

条款18: 使用unique_ptr管理具备专属所有权的资源

unique_ptr是高效、小巧、只移型别的智能指针,描述专属所有权语义。
在使用默认析构器时,可以认为std::unique_ptr和裸指针尺寸相同。在使用自定义析构器时,如果析构器是函数指针,unique_ptr的尺寸一般会增加一到两个字长,如果析构器是函数对象,则带来的尺寸变化取决于函数对象中存储了多少状态,无状态的函数对象不会浪费任何存储尺寸。
由于unique_ptr可以方便高效的转换成share_ptr,使得其十分适合作为工厂函数的返回值。
存在两种形式的unique_ptr,即unique_ptr<T>和unique_ptr<T[]>,第二种形式用于管理C风格数组。

条款19: 使用std::shared_ptr管理具备共享所有权的资源

shared_ptr的尺寸是裸指针的两倍,其包含一个指涉到实际资源的裸指针,和一个指涉到堆上控制块的裸指针,该控制块包含引用计数、弱计数、自定义删除器和分配器等数据。
一个控制块由创建首个指涉到该对象的std::share_ptr的函数来确定,控制块的创建遵循以下规则:

  • std::make_shared总是创建一个控制块
  • 从具备专属所有权的指针(unique_ptr或auto_ptr)出发构造一个std::shared_ptr时,会创建一个指针
  • 当std::shared_ptr构造函数使用裸指针作为实参来调用时,它会创建一个控制块

所以从单一裸指针出发构造两个shared_ptr的情况会构造两个不同控制块的shared_ptr,从而在析构时出现double free的问题。
此外还需要注意的是成员函数中需要使用shared_ptr的场景,从this出发构造shared_ptr会构造一个新控制块的shared_ptr,这显然与外部使用的shared_ptr会造成上面所说的double free问题,这里如果想要正常获取指向当前对象的shared_ptr需要用到enable_shared_from_this:

1
2
3
4
5
6
7
8
9
10
std::vector<std::shared_ptr<Widget>> proessedWidgets;
class Widget : public std::enable_shared_from_this<Widget>
{
public:
    …
    void process()
    {
        processedWidgets.emplace_back(shared_from_this());
    }
};

当然如果没有指涉到当前对象的shared_ptr,shared_from_this的行为未定义,通常结果是抛出异常(书中说法)。
shared_ptr从空间上只增加了一个控制块,性能上只比裸指针增加了引用计数的原子化操作,得到的却是动态分配资源的自动生存期管理。
与unique_ptr不同的是,不存在shared_ptr<T[]>用于兼容c风格数组。

条款20: 对于类似std::shared_ptr但有可能空悬的指针使用std::weak_ptr

在shared_ptr之上构造的weak_ptr不会引起引用计数的增长,weak_ptr修改的是控制块中的弱引用计数。
如需确认weak_ptr是否失效了通过expired方法,如需确认weak_ptr是否失效的同时访问指涉对象可通过lock或者从weak_ptr构造shared_ptr的方法来获得shared_ptr,两种方法的区别是如果已经空悬后者会抛异常而lock不会。
weak_ptr常用于缓存、观察者列表等指针可空悬的场景,也可用于避免shared_ptr指针环路。

条款21: 优先选用std::make_unique和std::make_shared,而非直接使用new

11标准中加入了make_shared和allocate_shared,14标准中加入了make_unique,使用make系列函数通常可以减少代码量、提高异常安全性;对于shared_ptr,由于make_shared和allocate_shared在实现上做了优化(使用一块内存分配对象和控制块)使得生成的目标代码更小且速度更快。但同时也由于这两个函数做了如此优化,使得该块内存直到最后一个使用该控制块的weak_ptr析构后才能被释放。这就造成如果weak_ptr和shared_ptr析构间隔较久,内存将延迟这个间隔时间才会被释放。而改为使用shared_ptr构造则不会出现该问题,所以该场景下不适宜使用make系列函数。
make系列函数不能指定删除器,所以需要制定删除器的场景下只能调用智能指针构造来完成;make系列函数内部使用圆括号来传递对象构造实参,所以直接传递大括号初始化不会被解释为initializer_list。
自定义operator new、operator delete的类型不建议使用make系列函数,由于如上所说的make系列函数在实现上做了优化使用同一块内存。

条款22: 使用Pimpl习惯用法时,将特殊成员函数的定义放到实现文件中

pimpl作为常用的减少模块间依赖的方法,在换成使用智能指针的情况时会存在一个小问题,即形如以下的代码在编译时将会报错:

1
2
3
4
5
6
7
8
class Widget {
public:
    Widget();
    //...
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

原因其实也简单,编译器编译以上代码时会在头文件处生成一个inline析构函数,这个析构函数会调用pImpl这个unique_ptr的析构,unique_ptr的析构会对所包含的型别做一次static_assert检查,如果是incomplete型别则会出错。也就是说以上代码编译出错的原因和以下代码类似:

1
2
3
4
5
6
7
8
class Widget {
public:
    Widget();
    ~Widget() { delete pImpl; }
private:
    struct Impl;
    Impl* pImpl;
};

该代码在编译时同样会报对不完全类型使用delete非法问题。这是由于c++规定,对于void*或者不完全型别(前置声明的型别)使用delete将会导致未定义行为。
所以,解决如上问题的方式即为在Widget::Impl的定义之后声明Widget的析构函数,而根据新标准中的“大五律”,如果自行声明了析构函数,最好是声明全部五个特殊成员函数,即复制构造、复制操作符、移动构造、移动操作符,即使编译器自行生成的特殊函数拥有正确的行为,也依然需要如此声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Widget.h
class Widget/
{
public:
    Widget();
    ~Widget();

    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs);
    Widget(Widget&& rhs);
    Widget& operator=(Widget&& rhs);
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Widget.cpp
struct Widget::Impl
{
public:
    // ...
};

Widget::Widget() = default;

Widget::~Widget() = default;

Widget::Widget(const Widget& rhs)
   : pImpl(new Impl(*rhs.pImpl))
{}

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

Widget::Widget(Widget&& rhs) = default;

Widget& Widget::operator=(Widget&& rhs) = default