Item 18: 用std::unique_ptr来管理具有ex

2017-09-21  本文已影响146人  EVANMORE

总结

unique_ptr指针的一些特性总结

使用场景

  1. 用作工厂函数的返回类型
    适合的原因有两个
#include <iostream>
#include <memory>

class Investment
{
public:
    Investment() {std::cout << "[unique_ptr]: Investment construction" << std::endl; }
    virtual ~Investment() {std::cout << "[unique_ptr]: Investment destruction" << std::endl; }
};

class Stock : public Investment
{
public:
    Stock() { std::cout << "[unique_ptr]: Stock construction" << std::endl; }
    ~Stock() { std::cout << "[unique_ptr]: Stock destruction" << std::endl; }
};


std::unique_ptr<Investment>
makeInvestment()
{
    std::unique_ptr<Investment> pInv{nullptr};
    
    pInv.reset(new Stock());
    
    return pInv;
}

int main(int argc, char* argv[])
{
    {
        auto up = makeInvestment();
        
        std::cout << "[unique_ptr]: up(raw_ptr): " << up.get() << std::endl;
    }
    
    std::cout << "[unique_ptr]: End of main!" << std::endl;
}

运行结果如下

可以看到,离开{}的作用域后,unique指针up的生命周期就结束了,会自动调用所指向对象的析构函数销毁所指向对象。所以类的析构函数调用在End of main这句打印之前。

  1. PIMPL(pointer to implementation)风格

关于unique_ptr指针的move-only特性

参看如下一段代码

#include <iostream>
#include <memory>
#include <vector>

int main(int argc, char* argv[])
{
    std::unique_ptr<int> up = std::make_unique<int>(5);
    
    std::cout << "up before send: " << up.get() << std::endl;

    std::vector<std::unique_ptr<int>> upv;
    
    // move语义在这里会解除up指针对raw指针的所有权,但实际上raw指针所指向对象并没有被释放
    upv.push_back(move(up));
    
    std::cout << "request after send: " << up.get() << std::endl;
}

这段代码里面如果直接把up指针作为push_back函数的入参,编译会报错。只有加上move语义之后才能正常编译,而这个时候up指针已经不再拥有之前它所管理的raw指针的所有权了。raw指针的所有权变更到了vector中的一个unique_ptr类型元素上了。下图是运行的结果

可以看到,运行完push_back语句后,up内保存的raw指针就置为空了。

更多内容请参看原文翻译。

原文翻译

当你刚刚开始接触智能指针的时候,std::unique_ptr应该最容易上手的。有理由相信,默认情况下,std::unique_ptr指针和普通指针拥有一样的大小。并且对于大多数的操作(包括deferencing),std::unique_ptr执行和普通指针一样的指令。这就意味着即使在内存和CPU都很紧张的情况下,你也可以使用这个指针。如果普通指针(raw pointer)对于你来说足够的省内存并且足够的快,那么std::unique_ptr也基本上一样,你可以放心使用。
std::unique_ptr体现了专属所有权语义。一个非空std::unique_ptr指针通常拥有它所指向对象的全部所有权。转移一个std::unique_ptr指针意味着从源指针转移所有权到目的指针(源指针会被置成null)。std::unique_ptr指针的拷贝是非法的,因为如果你可以拷贝一个std::unique_ptr,你就会得到两个std::unique_ptr,而这两个指针指向同一个地方并且都认为自己拥有指向对象的所有权。所以std::unique_ptr是一种move-only的类型。对于析构函数,一个非空的std::unique_ptr销毁自己的资源。默认的,资源的销毁是通过std::unique_ptr内部的delete raw指针来实现的。

std::unique_ptr最常见的一个用法是作为工厂函数的返回类型,这个工厂函数用来生产一个继承结构的类的对象。假设我们有这样一个基类Investement,然后有一些列关于投资的继承类型(例如,股票,物业等等),实现伪代码如下,

class Investment { ... };

class Stock: 
    public Investment { ... };

class Bond: 
    public Invectment { ... };

class RealEstate: 
    public Investment { ... };

一个相应的工厂函数来在堆(heap)上构造一个这些继承类的对象,然后返回指向这些对象的指针,工厂函数的调用者就需要负责在不需要使用对象的时候销毁这个对象。这应用场景完全匹配指针std::unique_ptr,因为调用者获取了工厂函数返回资源的所有权,当指向资源的std::unique_ptr销毁的时候,智能指针会负责自动销毁它所指向的对象。这个工厂函数定义如下,

template<typename... Ts>      // 利用给定的参数params来创建一个Investment对象,然后返回std::unique_ptr指针
std::unique_ptr<Investment>
makeInvestment(Ts&&.. params)

工厂函数的调用方可以在一个作用域内({}括起来的范围)如下的使用这个返回的指针,

{
...
  auto pInvestment =         // pInvestment 的类型是std::unique_ptr
    makeInvestment(arguments);
...
}    // 销毁*pInvestment 

除此之外,std::unique_ptr指针也可以用在所有权转移的场景下,比如说当工厂函数返回std::unique_ptr指针转移进一个容器,下一步容器元素转移进一个类对象的成员数据,然后这个对象被销毁掉。当这种情况发生时,对象的std::unique_ptr类型的数据成员也会被销毁,析构函数会触发销毁之前工厂函数中分配的资源。如果这样的所有权链由于一些异常被破坏了(比如说,中间某个函数返回了或者从循环中break出来了,指针的转移没有继续往下进行),转移过程中最后一个std::unique_ptr指针始终拥有它所指向资源的管理权,当这个指针被销毁了,那么对应的资源也会被释放。
Note:
这个规则这里还有一些异常场景。大多来自于异常的程序终止,如果异常是从线程的主函数里传递出来的(比如说,main函数,用于程序的初始线程)或者是由于noexcept规范是非法的(参看Item 14),局部对象可能没有被销毁掉。如果是由于调用了std::abort或者是一个exit函数导致的退出,这些指针都不会被销毁。

缺省情况下,std::unique_ptr的析构函数会调用delete来释放raw指针。但是在构造函数过程中,std::unique_ptr对象也可以配置成使用用户自定义的deleters:当指针指向的资源要被释放的时候可以调用任意函数(或者是函数对象,包括lambda表达式)。如果通过调用工厂函数makeInvestment创建的对象不应该被直接delete掉,而是在这之前需要记录一下,这个时候makeInvestment可以按照如下伪代码进行实现(后面会有这段代码的解释)

auto delInvmt = [](Investment* pInvestment)  // 用户自定的deleter,labmda表达式
     {
        makeLogEntry(pInvestment);
        delete pInvestmentl;
     }

template<typename... Ts>
std::unique_ptr<Investment, decltype(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
}

稍等一会儿,我会解释一下这段代码是怎么工作的。但是首先从函数调用者的角度来看看,整个过程是怎么工作的。假如把makeInvestment函数调用的返回结果保存到auto类型的变量,虽然实际上这个指针所指向的资源在销毁过程中需要一些特殊的处理,然而作为调用者完全不需要关心这些。实际上,你可能还沉浸在幸福中,因为使用了std::unique_ptr就意味着你不需要关心在生命周期结束的时候去释放资源,更不需要去保证析构函数在整个程序中只会被调用一次。std::unique_ptr指针会自动管理所有的事情,从用户的角度来说,makeInvestment函数提供的接口非常好。

当你理解了下面这些之后,上述的代码实现就更加完美了,

class Investment {
public:
  ...
  virtual ~Investment();  // 必要的
  ...  
};

在C++14里面,由于引入了函数返回类型推导(参看Item 3),makeInvestment函数的实现可以变得更加简单并且封装得更好:

tempate<typename... Ts>
auto makeInvestment(Ts&&... params)  // C++14的版本
{
  auto delInvmt = [](Investment* pInvestment)   // 自定义函数现在可以挪到工厂函数内部了
  {
    makeLogEntry(pInvestment);
    delete pInvestment;
  };
 
  std::unique_ptr<Investment, decltype(delInvmt)>  pInv(nullptr, delInvmt);  // 和之前一样

  if( ... )
  {    
    pInv.reset(new Stock(std::forward<Ts>(params)...));  
  }  
  else if ( … )                                     // as before
  {    
    pInv.reset(new Bond(std::forward<Ts>(params)...));  
  }  
  else if ( … )                                     // as before  
  {    
    pInv.reset(new RealEstate(std::forward<Ts>(params)...)); 
  }  

  return pInv;                                      
}

我之前说过,当使用默认的指针销毁函数delete的时候,你可以假设std::unique_ptr对象和raw指针的占用内存大小是一样的。当用户自定义的deleter引入之后,这种假设就不再成立了。自定义的deleters实际上是一个函数指针,所有std::unique_ptr的大小会从一个word变成两个。对于类型为函数对象的deleters,增加的大小就取决于函数内部的保存了多少状态了。stateless的函数对象(比如说,lambda表达式并且没有错误捕获)就没有指针大小上的变化。这就意味着如果用户自定的deleter即可以实现成函数,也可以实现成没有错误捕获的lambda表达式的时候,优先选择lambda表达式

auto delInvmt1 = [](Investment* pInvestment)  // 用户自定义deleter实现成stateless的lambda表达式
{
  makeLogEntry(pInvestment);
  delete pInvestment;
}

template<typename... Ts>   // 返回类型和Investment*的大小一致
std::unique_ptr<Investment, decltype(delInvmt1)>
makeInvestment(Ts&&.. args); 

void delInvmt2(Investment* pInvestment)  // 实现成函数的用户自定义deleter 
{
  makeLogEntry(pInvestment);
  delete pInvestment;
}

template<typename... Ts>  // 返回类型的大小 = Investment*的大小 + 至少函数指针的大小
std::unique_ptr<Investment, void(*)(Investment*)>
makeInvestment(Ts&&... params);

如果作为deleters的函数对象里面还有额外的状态,这可能会导致std::unique_ptr指针对象的大小变得很大。如果你发现一个用户自定的deleter使你的指针变得非常之大的时候,你就要考虑是不是是需要对你的代码进行重构。

工厂函数并不是是唯一一个std::unique_ptr指针常用的场景。在进行PIMPL(pointer to implementation)风格实现的时候,std::unique_ptr的使用更加常见。代码实现并不复杂,但是却不是那么直观,所以我建议你去看一下Item 22,哪里会专门讲一下这个场景。

std::unique_ptr指针有两种实现形式,一种用于单一对象(std::unique_ptr<T>),另一种用于数组(std::unique_ptr<T[]>)。这样的结果就是,对于std::unique_ptr所指向的对象到底是啥就不会产生歧义了。并且,对应这两种不同的unique_ptr指针实现也设计实现了不同的API。举个例子来说,对于单一对象(single-object)的形式,就不会有索引操作符“[]”,而数组对象(array)则没有解引用操作(操作符"*"和操作符"->")

因为相较于原始的指针数组,标准库中提供的std::arraystd::vectorstd::string都是更好的选择。所以这里我能想到的唯一的一个适合用std::unique_ptr<T[]>的场景就是,如果你在使用一个C风格的API,返回了一个普通的指针并保存在一个分配在heap上数组中,而这个时候你又需要设定这个数组的所有权。

std::unique_ptr是C++11中用来声明所分配资源专属所有权(exclusive ownership)的一种方式,然而另一个非常具有吸引力的特性是,unique_ptr可以很容易并且很有效的转换成std::shared_ptr:

std::shared_ptr<Investment> sp =         // std::unique_ptr转换成std::shared_ptr
     makeInvestment( arguments );

这也是为什么std::unique_ptr非常适合用作工厂函数的返回类型的一个关键部分。工厂函数没有办法知道调用者是想要使用所创建对象的专属所有权语义(exclusive ownership)呢,还是共享所有权(shared ownership)更加合适。通过使用std::unique_ptr,工厂函数一方面提供给了调用者最高效的智能指针,另一方面它也没有阻碍调用者用它更加灵活的同类(比如说shared_ptr)来替换它。(更多关于std::shared_ptr的内容请参看Item 19)

记住下面几点

上一篇下一篇

猜你喜欢

热点阅读