Std::unique_ptr

本文最后更新于:April 20, 2022 am

本篇为智能指针系列第一篇,介绍std::unique_ptr

系列导航:智能指针

unique_ptr:独享所有权

当希望使用智能指针时,std::unique_ptr通常就是首选。

性能

默认情况下,可以认为std::unique_ptr的大小和裸指针一致,并且对于大多数操作而言(包括解引用dereferencing),它们俩都会执行相同的指令。这意味着即使在内存和时钟周期紧张的情况下,也可以使用之。如果一个裸指针对于你而言已经足够轻巧和快速,那么std::unique_ptr同样会如此。

语义

std::unique_ptr内置的语义是独占式拥有权。一个non-nullstd::unique_ptr总是拥有其指向的资源。对一个std::unique_ptr对象执行移动操作可以将管理权移交给另一个std::unique_ptr对象,同时将源对象置空。

拷贝构造函数是不被允许的,在源码中也已经使用=delete将之禁用,毕竟拷贝操作明显违背了类设计的初衷。如果拷贝成功,将会有两个std::unique_ptr对象指向同一段内存区域,并且每个对象都认为它拥有这段内存,内存也会被析构两次。显然,std::unique_ptr是一个只支持移动的类型。在析构时,std::unique_ptr会对其管辖的资源做回收操作。默认情况下,std::unique_ptr调用delete操作符。

使用举例

std::unique_ptr的一种通常的使用方式是,将之作为在对象继承体系中的一个工厂函数的返回值。参看如下继承体系:

classDiagram
	Investment <|-- Stock
	Investment <|-- Bond
	Investment <|-- RealEstate

对于此体系而言,一个工厂函数在堆上分配一个对象,并且返回指向其的指针,并期待调用者在合适的时候将其释放掉。仔细想想会发现这个场景正是std::unique_ptr的用武之地,因为这里是调用者获得了对象的管理权,并且std::unique_ptr能够在自身析构时自动释放其管理的对象。举例而言,此处的工厂函数可以写为

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

调用者:

1
2
3
4
5
{
...
auto pInvestment = makeInvestment(args);
...
} //destroy pInvestment -> free dynamically allocated resources

实际使用的场景也可以变得更复杂一些,比如此处工厂函数返回的std::unique_ptr被移动到某个容器类中,并且这个容器被移动到了某个对象的内部成员变量上,并且此对象将会在稍微晚些的时候被析构。这种情况下,std::unique_ptr对象依旧会被析构,其管理的内容也会被释放[1]

如果在过程中,拥有权转移链因为异常,或者不寻常的控制流(函数提前返回,或者在循环中执行了break)被打断,std::unique_ptr的析构和其管理的资源的释放也会被保障。

自定义deleter

默认情况下,释放资源会通过调用delete操作符实现。但是在std::unique_ptr对象构造的过程中,也可以手动给它指定一个自定义的资源释放函数(任意函数,或者函数对象,或者lamda表达式),它会在合适的时机调用传入的函数。

举例来说,在这个场景下,如果需要makeInvestment函数所创建的对象,在其释放管理的资源时先打印一句日志,就可以这样来写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
auto delInvmt = [](Investment* pInvestment){
makeLogEntry(pInvestment);
delete pInvesetment;
}
//custom a deleter by lamda expression

template<typename... Ts> std::unique_ptr<Investment, deceltype(delInvmt)> makeInvestment(Ts&... params){
std::unique_ptr<Investment, decltype(delInvmt)>pInv(nullptr,delInvmt);
if(/*应该分配一个Stock类型的对象*/){
pInv.reset(new Stock(std::forward<Ts>(params)));
}
else if(/*应该分配一个Bond类型的对象*/){
pInv.reset(new Bond(std::forward<Ts>(params)));
}
else if(/*应该分配一个RealEstate类型的对象*/){
pInv.reset(new RealEstate(std::forward<Ts>(params)));
}

return pInv;
}

有几点需要说明:

  • delInvmt是一个用户自定义的deleter。所有的自定义的deleter的参数都是一个指向待销毁的对象的裸指针。函数可以做一些自定义的内容,但别忘了调用delete操作符。

    使用lamda表达式创建delInvmt更方便,并且正如后面会讨论的,这也比手写一个传统类型的函数也更高效。

  • 当使用一个自定义的deleter时,需要将deleter的类型也传给std::unique_ptr用于模板参数。记住这一点,这和std::shared_ptr很不同!

  • 基本的实现思路是,先创建一个绑定了资源释放函数的空的std::unique_ptr,再调用reset让其指向一个适当的对象。在第八行里,我们通过将lamda表达式作为std::unique_ptr的构造函数的第二个参数,建立其之间的关联。

  • 直接向std::unique_ptr对象赋予一个裸指针是被禁止的,因为这涉及隐式构造另一个std::unique_ptr对象。C++11禁止了这种操作。此处应该使用reset

  • 每次调用new操作符,我们都使用了std::forward(Item25)来将传入的参数完美转发给makeInvestment。这使得函数调用者提供的所有信息都被一字不差地转发给了我们正在调用的构造函数。

  • 我们在自定义的deleter中,统一使用Investment* 的方式来delete资源,而不管传进来的究竟是哪种子类的指针。熟悉C++的面向对象编程的朋友会知道,此时在继承体系中所有类的析构函数都应该被声明为虚函数。

    1
    2
    3
    4
    class Investment{
    public:
    virtual ~Investment();
    }

在调用者的视角来看上述代码,我们只需要在构造时传入类型参数和释放资源函数,不用在意资源在被释放期间需要额外的处理,也不用关心什么时候应该销毁资源,更不用花费心思确保销毁的代码只被调用一次。

在C++14中,支持函数返回值类型推导(Item3),这意味着makeInvestment可以被实现得更简单:

1
2
3
4
template <typename... Ts> auto makeInvestment(Ts&... params){
...
//all the same
}

上文提到,可以认为std::unique_ptr的大小和裸指针一样,这在自定义delete的情况下不是普遍成立。当一个函数指针作为deleter,这会使得std::unique_ptr实例的大小从1个字涨到2个字。对于函数对象形式的deleter,大小上的开销增量取决于对象内部是否有存储其它状态。无状态的函数对象(比如不捕获内容的lamda表达式)不会额外引起其它的存储开销。当函数对象作为deleter,并且这个对象有很多状态时,这会使得std::unique_ptr的大小增大很多。

1
2
3
4
5
6
7
8
9
10
void deleterBySimpleFunction(Investment* ptr){...}
{
auto deleterByLamda= [](Investment* ptr){...};
//创建对象

std::unique_ptr<Investment, decltype(deleterByLamda)> makeInvestment(Ts&& ...args);// 大小和裸指针一样

std::unique_ptr<Investment, void(*)(Investment*)> makeInvestment(Ts&&... params);// 大小是裸指针+函数指针

}

其它使用情景

工厂函数并不是std::unique_ptr的唯一使用场景。std::unique_ptr更被经常用于

实现Pimpl(Item22)。

std::unique_ptr有两种形式,一种是对于单独的对象(std::unique_ptr<T>),另一种是数组形式std::unique_ptr<T[]>,因此,std::unique_ptr在指向类型上是清晰的。同时,这两种形式都分别重载了不同的操作符,以方便使用,比如前者没有重载[]运算符,而后者没有重载*(解引用)运算符。

这得一提的是,不建议使用std::unique_ptr的数组形式,原因很简单,因为有更好的容器类型(std::array, std::vector, std::string, etc)。对于数组形式的std::unique_ptr,能想到的几乎唯一的用武之地,可能就是已经通过一个C风格的API取得了一个指向堆上的数组的指针时。

std::unique_ptr 是C++11用来表示独占所有权的方式,但是它的一个最吸引人的地方是,它可以非常简单并且高效地被直接转换成一个std::shared_ptr:

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

这也是为什么std::unique_ptr如此适合工厂函数的原因,因为工厂函数不明白调用者究竟需不需要让资源是独占的,但是工厂函数返回的std::unique_ptr实例总是最高效的,并且在std::unique_ptr类的设计上,也没有阻值它被转换成更灵活的std::shared_ptr类型。

总结

std::unique_ptr是一种小型,快速,只能移动的智能指针,管理资源时有独占式语义

默认情况下,资源的释放通过调用delete操作符实现,但也可以自定义deleter。有状态的deleter和函数指针都会引起std::unique_ptr实例的内存占用增加

从std::unique_ptr到std::shared_ptr的转换极为简单


  1. 个别例外并不遵循这个规则,且大多数是由程序异常结束引起的。如果一个异常传递到了线程的第一个函数(比如main)以外,或者noexcept声明被打破(见Item14),局部对象可能不会被析构。如果调用了std::abort,或者某个退出函数(std::exit, std::quick_exit, std::_Exit等等),那局部对象肯定不会被析构。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!