Effective Modern C++读书笔记(二)
智能指针
首先,raw pointer有着诸多缺陷
- 指向对象的不明确(单个对象or数组?)
- 析构方式的不明确(
delete
,delete[]
还是一个特殊的析构机制,比如调用某个函数) - 析构的时机、次数容易出错
- 容易出现dangling pointer
所以,c++11有了智能指针。
Item 18
资源紧缺的机器上也可以用
std::unique_ptr
,因为当它使用默认或者stateless lambda作为删除器的时候,和raw pointer大小一样。而且对于很多操作(比如解引用),它们执行相同的指令。std::unique_ptr
体现了独占所有权(exclusive ownership)的语义。只能move,不能copy。对于层级继承关系来说,可以用工厂函数返回
std::unique_ptr
。并且调用方可以用std::shared_ptr
来接收工厂函数的返回值。std::unique_ptr
有两种形式,一个是单独的对象(std::unique_ptr<T>
),另一个是数组(std::unique_ptr<T[]>
)。不过对于后者来说,c++中已经有了足够多足够好的替代品(比如说std::array
、std::vector
、std::string
)。最好在使用返回一个裸指针的C-like API的时候才使用std::unique_ptr<T[]>。
Item 19
std::shared_ptr
性能影响:- 大小是raw pointer的两倍。因为还有一个指向control block的raw pointer。
- 引用计数的内存是动态分配的。
- 引用计数增加和减少是原子操作。可能会降低速度。
- control block用到了虚函数。
std::shared_ptr
的移动操作比拷贝操作快。因为移动操作会直接让源std::shared_ptr置null,这意味着旧的std::shared_ptr引用计数不用操作。对于
std::unique_ptr
来说,删除器是它类型的一部分;而对于std::shared_ptr
来说却不是。而且改变std::shared_ptr
的删除器不会改变它的大小(因为分配在堆上)。std::shared_ptr
包含两个部分:Ptr to T和Ptr to Control Block。 Ptr to T指向T Object;
Ptr to Control Block指向:
- Reference Count
- Weak Count
- Other Data(e.g.,custon deleter,allocto,etc)
一些关于control block的规则:
std::make_shared
总是会创建一个control block。- 当一个
std::shared_ptr
用unique_ptr或者auto_ptr构造的时候,也会创建一个control block。 - 用一个raw pointer构造
std::shared_ptr
的时候,也会创建一个control block。
基于以上的规则,用同一个raw pointer构造多个
std::shared_ptr
是未定义行为。会有多个control block,导致对象被多次析构。std::shared_ptr
不同于std::unique_ptr
,不能很好地工作于数组。没有std::shared_ptr<T[]>
的版本。所以默认删除器用的delete
,而且没有重载oparetor[]
。而且对于单个对象,std::shared_ptr
允许从派生类向基类的转化;当作用在数组上时,可能会有奇怪的行为。所以尽量用std::array
或者std::vector
代替build-in array。
Item 20
std::weak_ptr
不能解引用。所以一般还得用std::shared_ptr
。一种形式
1
2
3
4//从前有一个wpw是std::weak_ptr
std::shared_ptr<Widget> spw1 = wpw.lock(); //如果wpw悬垂,spw1是空指针。
auto spw2 = wpw.lock(); //同上。
//其他操作另一种形式
1
std::shared_ptr<Widget> spw3(wpw); //如果wpw悬垂,抛出std::bad_weak_ptr异常
使用
std::weak_ptr
的例子:由于某个类构造开销比较大,所以设计了cache factory function,将不同的对象构造并缓存,返回
std::shared_ptr
1
2
3
4
5
6
7
8
9
10
11
12std::shared_ptr<const Widget> fastLoadWidget(WidgetId id)
{
static std::unordered_map<WidgetID, std::weak_ptr<const Widget> cache;
auto objPtr = cache[id].lock(); //objPtr是指向缓存对象的std::shared_Ptr(当然,对象无缓存就是null了)
if (!objPtr)
{ //如果没缓存,
objPtr = loadWidget(id); //构造它
cache[id] = objPtr; //缓存它
}
return objPtr;
}观察者设计模式(Observer design pattern)中,subject(状态会变化的对象)通常有一个成员是指向observer(观察者,观察subject的变化)的指针。这个指针对于控制observer的生命周期不感兴趣(即不需要std::shared_ptr),但是需要确定指向的observer是否已经被析构,所以用
std::weak_ptr
。三个对象A,B,C。A和C通过std::shared_ptr指向B,即共享B的所有权。B需要有一个指针也指向A。有三个选择:
- Raw pointer。如果A被析构,就悬垂了。
- std::shared_ptr。A和B都用std::shared_ptr指向对方,这个嵌套会导致A和B都不会被析构。
- std::weak_ptr。这是个最佳选择,不会有上述的问题。
ps:用std::weak_ptr阻止
std::shared_ptr
循环的方法不是很常见。对于有严格分层的数据机构,比如说树来说,子节点属于父节点,父节点析构,子节点也应该被析构。父节点指向子节点用std::unique_ptr
,子节点指向父节点用raw pointer。因为子节点总是会随着父节点被析构,不会出现子节点指向的父节点悬垂的情况。
std::weak_ptr
和std::shared_ptr
大小相同。std::weak_ptr
不参与对象的所有权,因此不会影响指向对象的reference count。
Item 21
有三个make函数,作用都是完美转发参数然后构造某个对象,并让智能指针指向它。分别是
std::make_unique
.c++14才加入了标准库。不过自己也可以写个类似很简单的实现1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
````
* `std::make_shared`。和`std::make_unique`大同小异。
* `std::allocate_shared`。和`std::make_shared`作用几乎相同。但是它的第一个参数是一个allocator对象。
* 用make函数的好处
* 一是不用重复类型
```cpp
auto upw1(std::make_unique<Widget>()); //with make func
std::unique_ptr<Widget> upw2(new Widget); //without make func而是异常安全。考虑以下这个代码:
1
2
3
4
5//从前有两个函数
void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());将会导致可能的内存泄露。因为上述最后一行代码的参数完成操作需要:
- new Widget,即一个Widget对象被创建在堆上。
- std::shared_ptr
被构造。 - computePriority必须执行一次。
构造
std::shared_ptr<Widget>
肯定会发生在new Widget
之后,毕竟后者作为前者的参数。但是computePriority
函数执行的时间不确定。编译器可能会让computePriority
正好在new Widget
之后被调用。如果在运行期间,computePriority
函数抛出异常,那么就内存泄漏了。所以应该用make函数1
2
3
4
5//两个函数同上
void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();
processWidget(std::make_shared<Widget>(), computePriority);
用make函数性能上也可能会更好。考虑一下代码
1
std::shared_ptr<Widget> spw(new Widget);
它总共分配了两次空间。一次是
new Widget
,另一个是构造std::shared_ptr<Widget>
时,分配cotrol block。而对于make函数
1
auto spw = std::make_shared<Widget>();
只需要申请一次空间。因为会一次分配能容纳Widget对象和control block大小之和的空间,然后在分别初始化它们。不仅如此,根据cpu cache局部性,以后缓存命中率还可能更高。
不过make函数也有一些局限性:
不能指定删除器。而直接用智能指针的构造函数却可以。
当你小要用花括号作为对象构造函数的时候,不能用make函数。或者这么用:
1
2
3
4//create std::initalizer_list
auto initList = { 10, 20 };
//createstd::vector using std::initializer_list ctor
auto spv = std::make_shared<std::vector<int>>(initList);对于
std::unique_ptr
,它的make函数有以上缺点。但是对于std::shared_ptr
和它的make函数来说,还有另外两个缺点(都是极端情况如果一个对象的类型有自定义版本的operator new和operator delete,那么使用make函数来创建他们通常是一个糟糕的想法。
上面也提到过,当使用make函数的时候,会一次分配能容纳Widget对象和control block大小之和的空间。而control block有reference count和weak count。只要weak count大于0,引用快就必须存在。那么显然,会有对象所占的内存无法及时释放的情况出现:
1
2
3
4
5
6
7
8
9
10class ReallyBigType{...};
auto pBigObj = std::make_shared<ReallyBigType>(); //通过make_shared创建一个很大的对象
... //创建若干个指向此对象的std::shared_ptr和std::weak_ptr并使用它们
... //最后一个指向此对象的std::shared_ptr在这被销毁。但是std::weak_ptr仍然保持
... //在这期间,先前创建的大对象所占的内存仍然没有被回收
... //最后一个指向此对象的std::weak_ptr在这被销毁。对象和control所占的内存才被释放。这种情形直接用new反而更好。
1
2
3
4
5
6
7
8
9
10class ReallyBigType{...}; //和之前一样
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType); //通过new创建一个很大的对象
... //和之前一样,创建若干个指向此对象的std::shared_ptr和std::weak_ptr并使用它们
... //最后一个指向此对象的std::shared_ptr在这被销毁。但是std::weak_ptr仍然保持。为对象分配的空间已经被释放
... //在这期间,只有control block的空间还没释放
... //最后一个指向此对象的std::weak_ptr在这被销毁。control block所占的内存才被释放。
Item 22
Pimpl(“pointer to implementation”) Idiom是医用用来缩短编译时间的技术。用一个指向某个class(or struct)的指针来代替具体的成员对象。举个例子
1 | //in header "widget.h" |
每次Gadget.h改变,都得重新编译。所以运用Pimpl Idiom
:
1 | //still in header "widget.h" |
每次Gadget.h改变,Widget不受影响。
不过学了智能指针,你可能互相到把raw pointer换成std::unique_ptr。就想下面这样:
1 | // in "widget.h" |
1 | // in "widget.cpp" |
可能注意到了,由于用智能指针管理资源,似乎用编译器生成的析构函数就能正常工作了的样子。编译没问题。不过,当你写下WIdget w;
,想要使用它的时候,却会编译出错。
问题出在试图析构w,生成代码的时候。此时,析构函数将会被调用。根据编译器生成特殊成员函数的规则(详情见Item 17),编译器试图生成一个析构函数。这个生成的析构函数会调用plmpl(一个使用默认删除器的std::unique_ptr)的析构函数。在这个指针的析构函数中,将会直接用delete
作用于一个raw pointer。要注意到,在这时,Impl还是个不完全类型。但是,由于在实现在实现作用,默认deleter通常会用c++11的static_assert来确保指针指向完整类型。所以当编译器生成析构函数的时候,它就碰到了一个失败的static_assert。
要解决这个问题也很简单,做到在析构的时候Impl是完整类型就可以了。
1 | // in file "widget.h" |
1 | // in file "widget.cpp" |
如果想要强调编译器生成的析构函数就可以工作(声明它的唯一理由就是为了在Widget的实现中生成),你也可以这么干:
1 | Widget::~Widget() = default; //same effect as above |
处于同样的考量,还能加上move和copy操作。完整代码如下:
1 | // in file "widget.h" |
1 | // in file "widget.cpp" |
可以看到,如果Pimpl采用std::unique_ptr
,即使编译器生成的函数能工作,也需要明确的把比声明和实现分开来。
但是如果采用std::shared_ptr
,上述的建议就不需要了。直接用编译器生成的函数就能够工作。
造成不同的根本原因是它们支持custom deleter的不同方式。
- 对于
std::unique_ptr
,deleter是类型的一部分,使得编译器能够生成更小的运行期数据结构和更快的运行期代码。这个带来的后果就是,当编译器生成特殊函数的时候,指向的类型必须完整。 - 对于
std::shared_ptr
,deleter不是类型的一部分,使得需要更大的运行期数据结构和更慢的代码。但是当编译器产生特殊函数的时候,指向的类型不需要是完整的。