16 KiB
第4章 智能指针
CHAPTER 4 Smart Pointers
诗人和歌曲作家喜欢爱。有时候喜欢计数。很少情况下两者兼有。受伊丽莎白·巴雷特·勃朗宁(Elizabeth Barrett Browning)对爱和数的不同看法的启发(“我怎么爱你?让我数一数。”)和保罗·西蒙(Paul Simon)(“离开你的爱人必须有50种方法。”),我们可以试着枚举一些为什么原始指针很难被爱的原因:
- 它的声明不能指示所指到底是单个对象还是数组。
- 它的声明没有告诉你用完后是否应该销毁它,即指针是否拥有所指之物。
- 如果你决定你应该销毁指针所指对象,没人告诉你该用
delete
还是其他析构机制(比如将指针传给专门的销毁函数)。 - 如果你发现该用
delete
。 原因1说了可能不知道该用单个对象形式(“delete
”)还是数组形式(“delete[]
”)。如果用错了结果是未定义的。 - 假设你确定了指针所指,知道销毁机制,也很难确定你在所有执行路径上都执行了恰为一次销毁操作(包括异常产生后的路径)。少一条路径就会产生资源泄漏,销毁多次还会导致未定义行为。
- 一般来说没有办法告诉你指针是否变成了悬空指针(dangling pointers),即内存中不再存在指针所指之物。在对象销毁后指针仍指向它们就会产生悬空指针。
原始指针是强大的工具,当然,另一方面几十年的经验证明,只要注意力稍有疏忽,这个强大的工具就会攻击它的主人。
智能指针(smart pointers)是解决这些问题的一种办法。智能指针包裹原始指针,它们的行为看起来像被包裹的原始指针,但避免了原始指针的很多陷阱。你应该更倾向于智能指针而不是原始指针。几乎原始指针能做的所有事情智能指针都能做,而且出错的机会更少。
在C++11中存在四种智能指针:std::auto_ptr
,std::unique_ptr
,std::shared_ptr
, std::weak_ptr
。都是被设计用来帮助管理动态对象的生命周期,在适当的时间通过适当的方式来销毁对象,以避免出现资源泄露或者异常行为。
std::auto_ptr
是来自C++98的已废弃遗留物,它是一次标准化的尝试,后来变成了C++11的std::unique_ptr
。要正确的模拟原生指针需要移动语义,但是C++98没有这个东西。取而代之,std::auto_ptr
拉拢拷贝操作来达到自己的移动意图。这导致了令人奇怪的代码(拷贝一个std::auto_ptr
会将它本身设置为null!)和令人沮丧的使用限制(比如不能将std::auto_ptr
放入容器)。
std::unique_ptr
能做std::auto_ptr
可以做的所有事情以及更多。它能高效完成任务,而且不会扭曲自己的原本含义而变成拷贝对象。在所有方面它都比std::auto_ptr
好。现在std::auto_ptr
唯一合法的使用场景就是代码使用C++98编译器编译。除非你有上述限制,否则你就该把std::auto_ptr
替换为std::unique_ptr
而且绝不回头。
各种智能指针的API有极大的不同。唯一功能性相似的可能就是默认构造函数。因为有很多关于这些API的详细手册,所以我将只关注那些API概览没有提及的内容,比如值得注意的使用场景,运行时性能分析等,掌握这些信息可以更高效的使用智能指针。
条款十八:对于独占资源使用std::unique_ptr
Item 18: Use std::unique_ptr
for exclusive-ownership resource management
当你需要一个智能指针时,std::unique_ptr
通常是最合适的。可以合理假设,默认情况下,std::unique_ptr
大小等同于原始指针,而且对于大多数操作(包括取消引用),他们执行的指令完全相同。这意味着你甚至可以在内存和时间都比较紧张的情况下使用它。如果原始指针够小够快,那么std::unique_ptr
一样可以。
std::unique_ptr
体现了专有所有权(exclusive ownership)语义。一个non-null std::unique_ptr
始终拥有其指向的内容。移动一个std::unique_ptr
将所有权从源指针转移到目的指针。(源指针被设为null。)拷贝一个std::unique_ptr
是不允许的,因为如果你能拷贝一个std::unique_ptr
,你会得到指向相同内容的两个std::unique_ptr
,每个都认为自己拥有(并且应当最后销毁)资源,销毁时就会出现重复销毁。因此,std::unique_ptr
是一种只可移动类型(move-only type)。当析构时,一个non-null std::unique_ptr
销毁它指向的资源。默认情况下,资源析构通过对std::unique_ptr
里原始指针调用delete
来实现。
std::unique_ptr
的常见用法是作为继承层次结构中对象的工厂函数返回类型。假设我们有一个投资类型(比如股票、债券、房地产等)的继承结构,使用基类Investment
。
class Investment { … };
class Stock: public Investment { … };
class Bond: public Investment { … };
class RealEstate: public Investment { … };
这种继承关系的工厂函数在堆上分配一个对象然后返回指针,调用方在不需要的时候有责任销毁对象。这使用场景完美匹配std::unique_ptr
,因为调用者对工厂返回的资源负责(即对该资源的专有所有权),并且std::unique_ptr
在自己被销毁时会自动销毁指向的内容。Investment
继承关系的工厂函数可以这样声明:
template<typename... Ts> //返回指向对象的std::unique_ptr,
std::unique_ptr<Investment> //对象使用给定实参创建
makeInvestment(Ts&&... params);
调用者应该在单独的作用域中使用返回的std::unique_ptr
智能指针:
{
…
auto pInvestment = //pInvestment是
makeInvestment( arguments ); //std::unique_ptr<Investment>类型
…
} //销毁 *pInvestment
但是也可以在所有权转移的场景中使用它,比如将工厂返回的std::unique_ptr
移入容器中,然后将容器元素移入一个对象的数据成员中,然后对象过后被销毁。发生这种情况时,这个对象的std::unique_ptr
数据成员也被销毁,并且智能指针数据成员的析构将导致从工厂返回的资源被销毁。如果所有权链由于异常或者其他非典型控制流出现中断(比如提前从函数return或者循环中的break
),则拥有托管资源的std::unique_ptr
将保证指向内容的析构函数被调用,销毁对应资源。(这个规则也有些例外。大多数情况发生于不正常的程序终止。如果一个异常传播到线程的基本函数(比如程序初始线程的main
函数)外,或者违反noexcept
说明(见Item14),局部变量可能不会被销毁;如果std::abort
或者退出函数(如std::_Exit
,std::exit
,或std::quick_exit
)被调用,局部变量一定没被销毁。)
默认情况下,销毁将通过delete
进行,但是在构造过程中,std::unique_ptr
对象可以被设置为使用(对资源的)自定义删除器:当资源需要销毁时可调用的任意函数(或者函数对象,包括lambda表达式)。如果通过makeInvestment
创建的对象不应仅仅被delete
,而应该先写一条日志,makeInvestment
可以以如下方式实现。(代码后有说明,别担心有些东西的动机不那么明显。)
auto delInvmt = [](Investment* pInvestment) //自定义删除器
{ //(lambda表达式)
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 (/*一个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
变量中,那么你将在愉快中忽略在删除过程中需要特殊处理的事实。当然,你确实幸福,因为使用了unique_ptr
意味着你不需要关心什么时候资源应被释放,不需要考虑在资源释放时的路径,以及确保只释放一次,std::unique_ptr
自动解决了这些问题。从使用者角度,makeInvestment
接口很棒。
这个实现确实相当棒,如果你理解了:
-
delInvmt
是从makeInvestment
返回的对象的自定义的删除器。所有的自定义的删除行为接受要销毁对象的原始指针,然后执行所有必要行为实现销毁操作。在上面情况中,操作包括调用makeLogEntry
然后应用delete
。使用lambda创建delInvmt
是方便的,而且,正如稍后看到的,比编写常规的函数更有效。 -
当使用自定义删除器时,删除器类型必须作为第二个类型实参传给
std::unique_ptr
。在上面情况中,就是delInvmt
的类型,这就是为什么makeInvestment
返回类型是std::unique_ptr<Investment, decltype(delInvmt)>
。(对于decltype
,更多信息查看Item3) -
makeInvestment
的基本策略是创建一个空的std::unique_ptr
,然后指向一个合适类型的对象,然后返回。为了将自定义删除器delInvmt
与pInv
关联,我们把delInvmt
作为pInv
构造函数的第二个实参。 -
尝试将原始指针(比如
new
创建)赋值给std::unique_ptr
通不过编译,因为是一种从原始指针到智能指针的隐式转换。这种隐式转换会出问题,所以C++11的智能指针禁止这个行为。这就是通过reset
来让pInv
接管通过new
创建的对象的所有权的原因。 -
使用
new
时,我们使用std::forward
把传给makeInvestment
的实参完美转发出去(查看Item25)。这使调用者提供的所有信息可用于正在创建的对象的构造函数。 -
自定义删除器的一个形参,类型是
Investment*
,不管在makeInvestment
内部创建的对象的真实类型(如Stock
,Bond
,或RealEstate
)是什么,它最终在lambda表达式中,作为Investment*
对象被删除。这意味着我们通过基类指针删除派生类实例,为此,基类Investment
必须有虚析构函数:class Investment { public: … virtual ~Investment(); //关键设计部分! … };
在C++14中,函数的返回类型推导存在(参阅Item3),意味着makeInvestment
可以以更简单,更封装的方式实现:
template<typename... Ts>
auto makeInvestment(Ts&&... params) //C++14
{
auto delInvmt = [](Investment* pInvestment) //现在在
{ //makeInvestment里
makeLogEntry(pInvestment);
delete pInvestment;
};
std::unique_ptr<Investment, decltype(delInvmt)> //同之前一样
pInv(nullptr, delInvmt);
if ( … ) //同之前一样
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( … ) //同之前一样
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( … ) //同之前一样
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv; //同之前一样
}
我之前说过,当使用默认删除器时(如delete
),你可以合理假设std::unique_ptr
对象和原始指针大小相同。当自定义删除器时,情况可能不再如此。函数指针形式的删除器,通常会使std::unique_ptr
的从一个字(word)大小增加到两个。对于函数对象形式的删除器来说,变化的大小取决于函数对象中存储的状态多少,无状态函数(stateless function)对象(比如不捕获变量的lambda表达式)对大小没有影响,这意味当自定义删除器可以实现为函数或者lambda时,尽量使用lambda:
auto delInvmt1 = [](Investment* pInvestment) //无状态lambda的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, decltype(delInvmt1)> //Investment*的大小
makeInvestment(Ts&&... args);
void delInvmt2(Investment* pInvestment) //函数形式的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
}
template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, void (*)(Investment*)> //Investment*的指针
makeInvestment(Ts&&... params); //加至少一个函数指针的大小
具有很多状态的自定义删除器会产生大尺寸std::unique_ptr
对象。如果你发现自定义删除器使得你的std::unique_ptr
变得过大,你需要审视修改你的设计。
工厂函数不是std::unique_ptr
的唯一常见用法。作为实现Pimpl Idiom(译注:pointer to implementation,一种隐藏实际实现而减弱编译依赖性的设计思想,《Effective C++》条款31对此有过叙述)的一种机制,它更为流行。代码并不复杂,但是在某些情况下并不直观,所以这安排在Item22的专门主题中。
std::unique_ptr
有两种形式,一种用于单个对象(std::unique_ptr<T>
),一种用于数组(std::unique_ptr<T[]>
)。结果就是,指向哪种形式没有歧义。std::unique_ptr
的API设计会自动匹配你的用法,比如operator[]
就是数组对象,解引用操作符(operator*
和operator->
)就是单个对象专有。
你应该对数组的std::unique_ptr
的存在兴趣泛泛,因为std::array
,std::vector
,std::string
这些更好用的数据容器应该取代原始数组。std::unique_ptr<T[]>
有用的唯一情况是你使用类似C的API返回一个指向堆数组的原始指针,而你想接管这个数组的所有权。
std::unique_ptr
是C++11中表示专有所有权的方法,但是其最吸引人的功能之一是它可以轻松高效的转换为std::shared_ptr
:
std::shared_ptr<Investment> sp = //将std::unique_ptr
makeInvestment(arguments); //转为std::shared_ptr
这就是std::unique_ptr
非常适合用作工厂函数返回类型的原因的关键部分。 工厂函数无法知道调用者是否要对它们返回的对象使用专有所有权语义,或者共享所有权(即std::shared_ptr
)是否更合适。 通过返回std::unique_ptr
,工厂为调用者提供了最有效的智能指针,但它们并不妨碍调用者用其更灵活的兄弟替换它。(有关std::shared_ptr
的信息,请转到Item19。)
请记住:
std::unique_ptr
是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针- 默认情况,资源销毁通过
delete
实现,但是支持自定义删除器。有状态的删除器和函数指针会增加std::unique_ptr
对象的大小 - 将
std::unique_ptr
转化为std::shared_ptr
非常简单