跳转至

第4章 智能指针

原始指针的缺点:

  1. 它的声明不能指示所指到底是单个对象还是数组。
  2. 它的声明没有告诉你用完后是否应该销毁它,即指针是否拥有所指之物。
  3. 如果你决定你应该销毁指针所指对象,没人告诉你该用 delete还是其他析构机制(比如将指针传给专门的销毁函数)。
  4. 如果你发现该用 delete。 原因1说了可能不知道该用单个对象形式(“delete”)还是数组形式(“delete[]”)。如果用错了结果是未定义的。
  5. 假设你确定了指针所指,知道销毁机制,也很难确定你在所有执行路径上都执行了恰为一次销毁操作(包括异常产生后的路径)。少一条路径就会产生资源泄漏,销毁多次还会导致未定义行为。
  6. 一般来说没有办法告诉你指针是否变成了悬空指针(dangling pointers),即内存中不再存在指针所指之物。在对象销毁后指针仍指向它们就会产生悬空指针。

原始指针是强大的工具,当然,另一方面几十年的经验证明,只要注意力稍有疏忽,这个强大的工具就会攻击它的主人。

智能指针smart pointers)是解决这些问题的一种办法。智能指针包裹原始指针,它们的行为看起来像被包裹的原始指针,但避免了原始指针的很多陷阱。你应该更倾向于智能指针而不是原始指针。几乎原始指针能做的所有事情智能指针都能做,而且出错的机会更少。

在C++11中存在四种智能指针:std::auto_ptrstd::unique_ptrstd::shared_ptrstd::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 {  };

item18_fig1

这种继承关系的工厂函数在堆上分配一个对象然后返回指针,调用方在不需要的时候有责任销毁对象。这使用场景完美匹配 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::_Exitstd::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,然后指向一个合适类型的对象,然后返回。为了将自定义删除器 delInvmtpInv关联,我们把 delInvmt作为 pInv构造函数的第二个实参。
  • 尝试将原始指针(比如 new创建)赋值给 std::unique_ptr通不过编译,因为是一种从原始指针到智能指针的隐式转换。这种隐式转换会出问题,所以C++11的智能指针禁止这个行为。这就是通过 reset来让 pInv接管通过 new创建的对象的所有权的原因。
  • 使用 new时,我们使用 std::forward把传给 makeInvestment的实参完美转发出去(查看Item25)。这使调用者提供的所有信息可用于正在创建的对象的构造函数。
  • 自定义删除器的一个形参,类型是 Investment*,不管在 makeInvestment内部创建的对象的真实类型(如 StockBond,或 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::arraystd::vectorstd::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非常简单

条款十九:对于共享资源使用 std::shared_ptr

Item 19: Use std::shared_ptr for shared-ownership resource management

使用带垃圾回收的语言的程序员指着C++程序员笑看他们如何防止资源泄露。“真是原始啊!”他们嘲笑着说:“你们没有从1960年的Lisp那里得到启发吗,机器应该自己管理资源的生命周期而不应该依赖人类。”C++程序员翻白眼:“你们得到的所谓启示就是只有内存算资源,而且资源回收的时间点是不确定的?我们更喜欢通用,可预料的销毁,谢谢你。”但我们的虚张声势可能底气不足。因为垃圾回收真的很方便,而且手动管理生命周期真的就像是使用石头小刀和兽皮制作RAM电路。为什么我们不能同时有两个完美的世界:一个自动工作的世界(像是垃圾回收),一个销毁可预测的世界(像是析构)?

C++11中的 std::shared_ptr将两者组合了起来。一个通过 std::shared_ptr访问的对象其生命周期由指向它的有共享所有权(shared ownership)的指针们来管理。没有特定的 std::shared_ptr拥有该对象。相反,所有指向它的 std::shared_ptr都能相互合作确保在它不再使用的那个点进行析构。当最后一个指向某对象的 std::shared_ptr不再指向那(比如因为 std::shared_ptr被销毁或者指向另一个不同的对象),std::shared_ptr会销毁它所指向的对象。就垃圾回收来说,客户端不需要关心指向对象的生命周期,而对象的析构是确定性的。

std::shared_ptr通过引用计数(reference count)来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少 std::shared_ptr指向该资源。std::shared_ptr构造函数递增引用计数值(注意是通常——原因参见下面),析构函数递减值,拷贝赋值运算符做前面这两个工作。(如果 sp1sp2std::shared_ptr并且指向不同对象,赋值“sp1 = sp2;”会使 sp1指向 sp2指向的对象。直接效果就是 sp1引用计数减一,sp2引用计数加一。)如果 std::shared_ptr在计数值递减后发现引用计数值为零,没有其他 std::shared_ptr指向该资源,它就会销毁资源。

引用计数暗示着性能问题:

  • std::shared_ptr大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个指向资源的引用计数值的原始指针。(这种实现法并不是标准要求的,但是我(指原书作者Scott Meyers)熟悉的所有标准库都这样实现。)
  • 引用计数的内存必须动态分配。 概念上,引用计数与所指对象关联起来,但是实际上被指向的对象不知道这件事情(译注:不知道有一个关联到自己的计数值)。因此它们没有办法存放一个引用计数值。(一个好消息是任何对象——甚至是内置类型的——都可以由 std::shared_ptr管理。)Item21会解释使用 std::make_shared创建 std::shared_ptr可以避免引用计数的动态分配,但是还存在一些 std::make_shared不能使用的场景,这时候引用计数就会动态分配。
  • 递增递减引用计数必须是原子性的,因为多个reader、writer可能在不同的线程。比如,指向某种资源的 std::shared_ptr可能在一个线程执行析构(于是递减指向的对象的引用计数),在另一个不同的线程,std::shared_ptr指向相同的对象,但是执行的却是拷贝操作(因此递增了同一个引用计数)。原子操作通常比非原子操作要慢,所以即使引用计数通常只有一个word大小,你也应该假定读写它们是存在开销的。

我写道 std::shared_ptr构造函数只是“通常”递增指向对象的引用计数会不会让你有点好奇?创建一个指向对象的 std::shared_ptr就产生了又一个指向那个对象的 std::shared_ptr,为什么我没说总是增加引用计数值?

原因是移动构造函数的存在。从另一个 std::shared_ptr移动构造新 std::shared_ptr会将原来的 std::shared_ptr设置为null,那意味着老的 std::shared_ptr不再指向资源,同时新的 std::shared_ptr指向资源。这样的结果就是不需要修改引用计数值。因此移动 std::shared_ptr会比拷贝它要快:拷贝要求递增引用计数值,移动不需要。移动赋值运算符同理,所以移动构造比拷贝构造快,移动赋值运算符也比拷贝赋值运算符快。

类似 std::unique_ptr(参见Item18),std::shared_ptr使用 delete作为资源的默认销毁机制,但是它也支持自定义的删除器。这种支持有别于 std::unique_ptr。对于 std::unique_ptr来说,删除器类型是智能指针类型的一部分。对于 std::shared_ptr则不是:

auto loggingDel = [](Widget *pw)        //自定义删除器
                  {                     //(和条款18一样)
                      makeLogEntry(pw);
                      delete pw;
                  };

std::unique_ptr<                        //删除器类型是
    Widget, decltype(loggingDel)        //指针类型的一部分
    > upw(new Widget, loggingDel);
std::shared_ptr<Widget>                 //删除器类型不是
    spw(new Widget, loggingDel);        //指针类型的一部分

std::shared_ptr的设计更为灵活。考虑有两个 std::shared_ptr<Widget>,每个自带不同的删除器(比如通过lambda表达式自定义删除器):

auto customDeleter1 = [](Widget *pw) {  };     //自定义删除器,
auto customDeleter2 = [](Widget *pw) {  };     //每种类型不同
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

因为 pw1pw2有相同的类型,所以它们都可以放到存放那个类型的对象的容器中:

std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

它们也能相互赋值,也可以传入一个形参为 std::shared_ptr<Widget>的函数。但是自定义删除器类型不同的 std::unique_ptr就不行,因为 std::unique_ptr把删除器视作类型的一部分。

另一个不同于 std::unique_ptr的地方是,指定自定义删除器不会改变 std::shared_ptr对象的大小。不管删除器是什么,一个 std::shared_ptr对象都是两个指针大小。这是个好消息,但是它应该让你隐隐约约不安。自定义删除器可以是函数对象,函数对象可以包含任意多的数据。它意味着函数对象是任意大的。std::shared_ptr怎么能引用一个任意大的删除器而不使用更多的内存?

它不能。它必须使用更多的内存。然而,那部分内存不是 std::shared_ptr对象的一部分。那部分在堆上面,或者 std::shared_ptr创建者利用 std::shared_ptr对自定义分配器的支持能力,那部分内存随便在哪都行。我前面提到了 std::shared_ptr对象包含了所指对象的引用计数的指针。没错,但是有点误导人。因为引用计数是另一个更大的数据结构的一部分,那个数据结构通常叫做控制块control block)。每个 std::shared_ptr管理的对象都有个相应的控制块。控制块除了包含引用计数值外还有一个自定义删除器的拷贝,当然前提是存在自定义删除器。如果用户还指定了自定义分配器,控制块也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,正如Item21提到的,一个次级引用计数weak count,但是目前我们先忽略它。我们可以想象 std::shared_ptr对象在内存中是这样:

item19_fig1

当指向对象的 std::shared_ptr一创建,对象的控制块就建立了。至少我们期望是如此。通常,对于一个创建指向对象的 std::shared_ptr的函数来说不可能知道是否有其他 std::shared_ptr早已指向那个对象,所以控制块的创建会遵循下面几条规则:

  • std::make_shared(参见Item21)总是创建一个控制块。它创建一个要指向的新对象,所以可以肯定 std::make_shared调用时对象不存在其他控制块。
  • 当从独占指针(即 std::unique_ptr或者 std::auto_ptr)上构造出 std::shared_ptr时会创建控制块。独占指针没有使用控制块,所以指针指向的对象没有关联控制块。(作为构造的一部分,std::shared_ptr侵占独占指针所指向的对象的独占权,所以独占指针被设置为null)
  • 当从原始指针上构造出 std::shared_ptr时会创建控制块。如果你想从一个早已存在控制块的对象上创建 std::shared_ptr,你将假定传递一个 std::shared_ptr或者 std::weak_ptr(参见Item20)作为构造函数实参,而不是原始指针。用 std::shared_ptr或者 std::weak_ptr作为构造函数实参创建 std::shared_ptr不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。

这些规则造成的后果就是从原始指针上构造超过一个 std::shared_ptr就会让你走上未定义行为的快车道,因为指向的对象有多个控制块关联。多个控制块意味着多个引用计数值,多个引用计数值意味着对象将会被销毁多次(每个引用计数一次)。那意味着像下面的代码是有问题的,很有问题,问题很大:

auto pw = new Widget;                           //pw是原始指针

std::shared_ptr<Widget> spw1(pw, loggingDel);   //为*pw创建控制块

std::shared_ptr<Widget> spw2(pw, loggingDel);   //为*pw创建第二个控制块

创建原始指针 pw指向动态分配的对象很糟糕,因为它完全背离了这章的建议:倾向于使用智能指针而不是原始指针。(如果你忘记了该建议的动机,请翻到本章开头)。撇开那个不说,创建 pw那一行代码虽然让人厌恶,但是至少不会造成未定义程序行为。

现在,传给 spw1的构造函数一个原始指针,它会为指向的对象创建一个控制块(因此有个引用计数值)。这种情况下,指向的对象是 *pw(即 pw指向的对象)。就其本身而言没什么问题,但是将同样的原始指针传递给 spw2的构造函数会再次为 *pw创建一个控制块(所以也有个引用计数值)。因此 *pw有两个引用计数值,每一个最后都会变成零,然后最终导致 *pw销毁两次。第二个销毁会产生未定义行为。

std::shared_ptr给我们上了两堂课。第一,避免传给 std::shared_ptr构造函数原始指针。通常替代方案是使用 std::make_shared(参见Item21),不过上面例子中,我们使用了自定义删除器,用 std::make_shared就没办法做到。第二,如果你必须传给 std::shared_ptr构造函数原始指针,直接传 new出来的结果,不要传指针变量。如果上面代码第一部分这样重写:

std::shared_ptr<Widget> spw1(new Widget,    //直接使用new的结果
                             loggingDel);

会少了很多从原始指针上构造第二个 std::shared_ptr的诱惑。相应的,创建 spw2也会很自然的用 spw1作为初始化参数(即用 std::shared_ptr拷贝构造函数),那就没什么问题了:

std::shared_ptr<Widget> spw2(spw1);         //spw2使用spw1一样的控制块

一个尤其令人意外的地方是使用 this指针作为 std::shared_ptr构造函数实参的时候可能导致创建多个控制块。假设我们的程序使用 std::shared_ptr管理 Widget对象,我们有一个数据结构用于跟踪已经处理过的 Widget对象:

std::vector<std::shared_ptr<Widget>> processedWidgets;

继续,假设 Widget有一个用于处理的成员函数:

class Widget {
public:
    
    void process();
    
};

对于 Widget::process看起来合理的代码如下:

void Widget::process()
{
                                           //处理Widget
    processedWidgets.emplace_back(this);    //然后将它加到已处理过的Widget
}                                           //的列表中,这是错的!

注释已经说了这是错的——或者至少大部分是错的。(错误的部分是传递 this,而不是使用了 emplace_back。如果你不熟悉 emplace_back,参见Item42)。上面的代码可以通过编译,但是向 std::shared_ptr的容器传递一个原始指针(this),std::shared_ptr会由此为指向的 Widget*this)创建一个控制块。那看起来没什么问题,直到你意识到如果成员函数外面早已存在指向那个 Widget对象的指针,它是未定义行为的Game, Set, and Match(译注:一部关于网球的电影,但是译者没看过。句子本意“压倒性胜利;比赛结束”)。

std::shared_ptrAPI已有处理这种情况的设施。它的名字可能是C++标准库中最奇怪的一个:std::enable_shared_from_this。如果你想创建一个用 std::shared_ptr管理的类,这个类能够用 this指针安全地创建一个 std::shared_ptrstd::enable_shared_from_this就可作为基类的模板类。在我们的例子中,Widget将会继承自 std::enable_shared_from_this

class Widget: public std::enable_shared_from_this<Widget> {
public:
    
    void process();
    
};

正如我所说,std::enable_shared_from_this是一个基类模板。它的模板参数总是某个继承自它的类,所以 Widget继承自 std::enable_shared_from_this<Widget>。如果某类型继承自一个由该类型(译注:作为模板类型参数)进行模板化得到的基类这个东西让你心脏有点遭不住,别去想它就好了。代码完全合法,而且它背后的设计模式也是没问题的,并且这种设计模式还有个标准名字,尽管该名字和 std::enable_shared_from_this一样怪异。这个标准名字就是奇异递归模板模式(The Curiously Recurring Template PatternCRTP))。如果你想学更多关于它的内容,请搜索引擎一展身手,现在我们要回到 std::enable_shared_from_this上。

std::enable_shared_from_this定义了一个成员函数,成员函数会创建指向当前对象的 std::shared_ptr却不创建多余控制块。这个成员函数就是 shared_from_this,无论在哪当你想在成员函数中使用 std::shared_ptr指向 this所指对象时都请使用它。这里有个 Widget::process的安全实现:

void Widget::process()
{
    //和之前一样,处理Widget
    
    //把指向当前对象的std::shared_ptr加入processedWidgets
    processedWidgets.emplace_back(shared_from_this());
}

从内部来说,shared_from_this查找当前对象控制块,然后创建一个新的 std::shared_ptr关联这个控制块。设计的依据是当前对象已经存在一个关联的控制块。要想符合设计依据的情况,必须已经存在一个指向当前对象的 std::shared_ptr(比如调用 shared_from_this的成员函数外面已经存在一个 std::shared_ptr)。如果没有 std::shared_ptr指向当前对象(即当前对象没有关联控制块),行为是未定义的,shared_from_this通常抛出一个异常。

要想防止客户端在存在一个指向对象的 std::shared_ptr前先调用含有 shared_from_this的成员函数,继承自 std::enable_shared_from_this的类通常将它们的构造函数声明为 private,并且让客户端通过返回 std::shared_ptr的工厂函数创建对象。以 Widget为例,代码可以是这样:

class Widget: public std::enable_shared_from_this<Widget> {
public:
    //完美转发参数给private构造函数的工厂函数
    template<typename... Ts>
    static std::shared_ptr<Widget> create(Ts&&... params);
    
    void process();     //和前面一样
    
private:
                       //构造函数
};

现在,你可能隐约记得我们讨论控制块的动机是想了解有关 std::shared_ptr的成本。既然我们已经知道了怎么避免创建过多控制块,就让我们回到原来的主题。

控制块通常只占几个word大小,自定义删除器和分配器可能会让它变大一点。通常控制块的实现比你想的更复杂一些。它使用继承,甚至里面还有一个虚函数(用来确保指向的对象被正确销毁)。这意味着使用 std::shared_ptr还会招致控制块使用虚函数带来的成本。

了解了动态分配控制块,任意大小的删除器和分配器,虚函数机制,原子性的引用计数修改,你对于 std::shared_ptr的热情可能有点消退。可以理解,对每个资源管理问题来说都没有最佳的解决方案。但就它提供的功能来说,std::shared_ptr的开销是非常合理的。在通常情况下,使用默认删除器和默认分配器,使用 std::make_shared创建 std::shared_ptr,产生的控制块只需三个word大小。它的分配基本上是无开销的。(开销被并入了指向的对象的分配成本里。细节参见Item21)。对 std::shared_ptr解引用的开销不会比原始指针高。执行需要原子引用计数修改的操作需要承担一两个原子操作开销,这些操作通常都会一一映射到机器指令上,所以即使对比非原子指令来说,原子指令开销较大,但是它们仍然只是单个指令上的。对于每个被 std::shared_ptr指向的对象来说,控制块中的虚函数机制产生的开销通常只需要承受一次,即对象销毁的时候。

作为这些轻微开销的交换,你得到了动态分配的资源的生命周期自动管理的好处。大多数时候,比起手动管理,使用 std::shared_ptr管理共享性资源都是非常合适的。如果你还在犹豫是否能承受 std::shared_ptr带来的开销,那就再想想你是否需要共享所有权。如果独占资源可行或者可能可行,用 std::unique_ptr是一个更好的选择。它的性能表现更接近于原始指针,并且从 std::unique_ptr升级到 std::shared_ptr也很容易,因为 std::shared_ptr可以从 std::unique_ptr上创建。

反之不行。当你的资源由 std::shared_ptr管理,现在又想修改资源生命周期管理方式是没有办法的。即使引用计数为一,你也不能重新修改资源所有权,改用 std::unique_ptr管理它。资源和指向它的 std::shared_ptr的签订的所有权协议是“除非死亡否则永不分开”。不能分离,不能废除,没有特许。

std::shared_ptr不能处理的另一个东西是数组。和 std::unique_ptr不同的是,std::shared_ptr的API设计之初就是针对单个对象的,没有办法 std::shared_ptr<T[]>。一次又一次,“聪明”的程序员踌躇于是否该使用 std::shared_ptr<T>指向数组,然后传入自定义删除器来删除数组(即 delete [])。这可以通过编译,但是是一个糟糕的主意。一方面,std::shared_ptr没有提供 operator[],所以数组索引操作需要借助怪异的指针算术。另一方面,std::shared_ptr支持转换为指向基类的指针,这对于单个对象来说有效,但是当用于数组类型时相当于在类型系统上开洞。(出于这个原因,std::unique_ptr<T[]> API禁止这种转换。)更重要的是,C++11已经提供了很多内置数组的候选方案(比如 std::arraystd::vectorstd::string)。声明一个指向傻瓜数组的智能指针(译注:也是”聪明的指针“之意)几乎总是表示着糟糕的设计。

请记住:

  • std::shared_ptr为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。
  • 较之于 std::unique_ptrstd::shared_ptr对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作。
  • 默认资源销毁是通过 delete,但是也支持自定义删除器。删除器的类型是什么对于 std::shared_ptr的类型没有影响。
  • 避免从原始指针变量上创建 std::shared_ptr

条款二十:当 std::shared_ptr可能悬空时使用 std::weak_ptr

Item 20: Use std::weak_ptr for std::shared_ptr-like pointers that can dangle

自相矛盾的是,如果有一个像 std::shared_ptr(见Item19)的但是不参与资源所有权共享的指针是很方便的。换句话说,是一个类似 std::shared_ptr但不影响对象引用计数的指针。这种类型的智能指针必须要解决一个 std::shared_ptr不存在的问题:可能指向已经销毁的对象。一个真正的智能指针应该跟踪所指对象,在悬空时知晓,悬空(dangle)就是指针指向的对象不再存在。这就是对 std::weak_ptr最精确的描述。

你可能想知道什么时候该用 std::weak_ptr。你可能想知道关于 std::weak_ptr API的更多。它什么都好除了不太智能。std::weak_ptr不能解引用,也不能测试是否为空值。因为 std::weak_ptr不是一个独立的智能指针。它是 std::shared_ptr的增强。

这种关系在它创建之时就建立了。std::weak_ptr通常从 std::shared_ptr上创建。当从 std::shared_ptr上创建 std::weak_ptr时两者指向相同的对象,但是 std::weak_ptr不会影响所指对象的引用计数:

auto spw =                      //spw创建之后,指向的Widget的
    std::make_shared<Widget>(); //引用计数(ref count,RC)为1。
                                //std::make_shared的信息参见条款21

std::weak_ptr<Widget> wpw(spw); //wpw指向与spw所指相同的Widget。RC仍为1

spw = nullptr;                  //RC变为0,Widget被销毁。
                                //wpw现在悬空

悬空的 std::weak_ptr被称作已经expired(过期)。你可以用它直接做测试:

if (wpw.expired())             //如果wpw没有指向对象…

但是通常你期望的是检查 std::weak_ptr是否已经过期,如果没有过期则访问其指向的对象。这做起来可不是想着那么简单。因为缺少解引用操作,没有办法写这样的代码。即使有,将检查和解引用分开会引入竞态条件:在调用 expired和解引用操作之间,另一个线程可能对指向这对象的 std::shared_ptr重新赋值或者析构,并由此造成对象已析构。这种情况下,你的解引用将会产生未定义行为。

你需要的是一个原子操作检查 std::weak_ptr是否已经过期,如果没有过期就访问所指对象。这可以通过从 std::weak_ptr创建 std::shared_ptr来实现,具体有两种形式可以从 std::weak_ptr上创建 std::shared_ptr,具体用哪种取决于 std::weak_ptr过期时你希望 std::shared_ptr表现出什么行为。一种形式是 std::weak_ptr::lock,它返回一个 std::shared_ptr,如果 std::weak_ptr过期这个 std::shared_ptr为空:

std::shared_ptr<Widget> spw1 = wpw.lock();  //如果wpw过期,spw1就为空

auto spw2 = wpw.lock();                     //同上,但是使用auto

另一种形式是以 std::weak_ptr为实参构造 std::shared_ptr。这种情况中,如果 std::weak_ptr过期,会抛出一个异常:

std::shared_ptr<Widget> spw3(wpw);          //如果wpw过期,抛出std::bad_weak_ptr异常

但是你可能还想知道为什么 std::weak_ptr就有用了。考虑一个工厂函数,它基于一个唯一ID从只读对象上产出智能指针。根据Item18的描述,工厂函数会返回一个该对象类型的 std::unique_ptr

std::unique_ptr<const Widget> loadWidget(WidgetID id);

如果调用 loadWidget是一个昂贵的操作(比如它操作文件或者数据库I/O)并且重复使用ID很常见,一个合理的优化是再写一个函数除了完成 loadWidget做的事情之外再缓存它的结果。当每个请求获取的 Widget阻塞了缓存也会导致本身性能问题,所以另一个合理的优化可以是当 Widget不再使用的时候销毁它的缓存。

对于可缓存的工厂函数,返回 std::unique_ptr不是好的选择。调用者应该接收缓存对象的智能指针,调用者也应该确定这些对象的生命周期,但是缓存本身也需要一个指针指向它所缓存的对象。缓存对象的指针需要知道它是否已经悬空,因为当工厂客户端使用完工厂产生的对象后,对象将被销毁,关联的缓存条目会悬空。所以缓存应该使用 std::weak_ptr,这可以知道是否已经悬空。这意味着工厂函数返回值类型应该是 std::shared_ptr,因为只有当对象的生命周期由 std::shared_ptr管理时,std::weak_ptr才能检测到悬空。

下面是一个临时凑合的 loadWidget的缓存版本的实现:

std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
    static std::unordered_map<WidgetID,
                              std::weak_ptr<const Widget>> cache;
                                        //译者注:这里std::weak_ptr<const Widget>是高亮
    auto objPtr = cache[id].lock();     //objPtr是去缓存对象的
                                        //std::shared_ptr(或
                                        //当对象不在缓存中时为null)
    if (!objPtr) {                      //如果不在缓存中
        objPtr = loadWidget(id);        //加载它
        cache[id] = objPtr;             //缓存它
    }
    return objPtr;
}

这个实现使用了C++11的hash表容器 std::unordered_map,但是需要的 WidgetID哈希和相等性比较函数在这里没有展示。

fastLoadWidget的实现忽略了以下事实:缓存可能会累积过期的 std::weak_ptr,这些指针对应了不再使用的 Widget(也已经被销毁了)。其实可以改进实现方式,但是花时间在这个问题上不会让我们对 std::weak_ptr有更深入的理解,让我们考虑第二个用例:观察者设计模式(Observer design pattern)。此模式的主要组件是subjects(状态可能会更改的对象)和observers(状态发生更改时要通知的对象)。在大多数实现中,每个subject都包含一个数据成员,该成员持有指向其observers的指针。这使subjects很容易发布状态更改通知。subjects对控制observers的生命周期(即它们什么时候被销毁)没有兴趣,但是subjects对确保另一件事具有极大的兴趣,那事就是一个observer被销毁时,不再尝试访问它。一个合理的设计是每个subject持有一个 std::weak_ptrs容器指向observers,因此可以在使用前检查是否已经悬空。

作为最后一个使用 std::weak_ptr的例子,考虑一个持有三个对象 ABC的数据结构,AC共享 B的所有权,因此持有 std::shared_ptr

item20_fig1

假定从B指向A的指针也很有用。应该使用哪种指针?

item20_fig2

有三种选择:

  • 原始指针。使用这种方法,如果 A被销毁,但是 C继续指向 BB就会有一个指向 A的悬空指针。而且 B不知道指针已经悬空,所以 B可能会继续访问,就会导致未定义行为。
  • std::shared_ptr。这种设计,AB都互相持有对方的 std::shared_ptr,导致的 std::shared_ptr环状结构(A指向 BB指向 A)阻止 AB的销毁。甚至 AB无法从其他数据结构访问了(比如,C不再指向 B),每个的引用计数都还是1。如果发生了这种情况,AB都被泄漏:程序无法访问它们,但是资源并没有被回收。
  • std::weak_ptr。这避免了上述两个问题。如果 A被销毁,B指向它的指针悬空,但是 B可以检测到这件事。尤其是,尽管 AB互相指向对方,B的指针不会影响 A的引用计数,因此在没有 std::shared_ptr指向 A时不会导致 A无法被销毁。

使用 std::weak_ptr显然是这些选择中最好的。但是,需要注意使用 std::weak_ptr打破 std::shared_ptr循环并不常见。在严格分层的数据结构比如树中,子节点只被父节点持有。当父节点被销毁时,子节点就被销毁。从父到子的链接关系可以使用 std::unique_ptr很好的表征。从子到父的反向连接可以使用原始指针安全实现,因为子节点的生命周期肯定短于父节点。因此没有子节点解引用一个悬垂的父节点指针这样的风险。

当然,不是所有的使用指针的数据结构都是严格分层的,所以当发生这种情况时,比如上面所述缓存和观察者列表的实现之类的,知道 std::weak_ptr随时待命也是不错的。

从效率角度来看,std::weak_ptrstd::shared_ptr基本相同。两者的大小是相同的,使用相同的控制块(参见Item19),构造、析构、赋值操作涉及引用计数的原子操作。这可能让你感到惊讶,因为本条款开篇就提到 std::weak_ptr不影响引用计数。我写的是 std::weak_ptr不参与对象的共享所有权,因此不影响指向对象的引用计数。实际上在控制块中还是有第二个引用计数,std::weak_ptr操作的是第二个引用计数。想了解细节的话,继续看Item21吧。

请记住:

  • std::weak_ptr替代可能会悬空的 std::shared_ptr
  • std::weak_ptr的潜在使用场景包括:缓存、观察者列表、打破 std::shared_ptr环状结构。

条款二十一:优先考虑使用 std::make_uniquestd::make_shared,而非直接使用 new

让我们先对 std::make_uniquestd::make_shared做个铺垫。std::make_shared是C++11标准的一部分,但很可惜的是,std::make_unique不是。它从C++14开始加入标准库。如果你在使用C++11,不用担心,一个基础版本的 std::make_unique是很容易自己写出的,如下:

template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
    return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

正如你看到的,make_unique只是将它的参数完美转发到所要创建的对象的构造函数,从 new产生的原始指针里面构造出 std::unique_ptr,并返回这个 std::unique_ptr。这种形式的函数不支持数组和自定义析构(见Item18),但它给出了一个示范:只需一点努力就能写出你想要的 make_unique函数。(要想实现一个特性完备的 make_unique,就去找提供这个的标准化文件吧,然后拷贝那个实现。你想要的这个文件是N3656,是Stephan T. Lavavej写于2013-04-18的文档。)需要记住的是,不要把它放到 std命名空间中,因为你可能并不希望看到升级C++14标准库的时候你放进 std命名空间的内容和编译器供应商提供的 std命名空间的内容发生冲突。

std::make_uniquestd::make_shared是三个make函数 中的两个:接收任意的多参数集合,完美转发到构造函数去动态分配一个对象,然后返回这个指向这个对象的指针。第三个 make函数是 std::allocate_shared。它行为和 std::make_shared一样,只不过第一个参数是用来动态分配内存的allocator对象。

即使通过用和不用 make函数来创建智能指针的一个小小比较,也揭示了为何使用 make函数更好的第一个原因。例如:

auto upw1(std::make_unique<Widget>());      //使用make函数
std::unique_ptr<Widget> upw2(new Widget);   //不使用make函数
auto spw1(std::make_shared<Widget>());      //使用make函数
std::shared_ptr<Widget> spw2(new Widget);   //不使用make函数

我高亮了关键区别:使用 new的版本重复了类型,但是 make函数的版本没有。(译者注:这里高亮的是 Widget,用 new的声明语句需要写2遍 Widgetmake函数只需要写一次。)重复写类型和软件工程里面一个关键原则相冲突:应该避免重复代码。源代码中的重复增加了编译的时间,会导致目标代码冗余,并且通常会让代码库使用更加困难。它经常演变成不一致的代码,而代码库中的不一致常常导致bug。此外,打两次字比一次更费力,而且没人不喜欢少打字吧?

第二个使用 make函数的原因和异常安全有关。假设我们有个函数按照某种优先级处理 Widget

void processWidget(std::shared_ptr<Widget> spw, int priority);

值传递 std::shared_ptr可能看起来很可疑,但是Item41解释了,如果 processWidget总是复制 std::shared_ptr(例如,通过将其存储在已处理的 Widget的一个数据结构中),那么这可能是一个合理的设计选择。

现在假设我们有一个函数来计算相关的优先级,

int computePriority();

并且我们在调用 processWidget时使用了 new而不是 std::make_shared

processWidget(std::shared_ptr<Widget>(new Widget),  //潜在的资源泄漏!
              computePriority());

如注释所说,这段代码可能在 new一个 Widget时发生泄漏。为何?调用的代码和被调用的函数都用 std::shared_ptrs,且 std::shared_ptrs就是设计出来防止泄漏的。它们会在最后一个 std::shared_ptr销毁时自动释放所指向的内存。如果每个人在每个地方都用 std::shared_ptrs,这段代码怎么会泄漏呢?

答案和编译器将源码转换为目标代码有关。在运行时,一个函数的实参必须先被计算,这个函数再被调用,所以在调用 processWidget之前,必须执行以下操作,processWidget才开始执行:

  • 表达式“new Widget”必须计算,例如,一个 Widget对象必须在堆上被创建
  • 负责管理 new出来指针的 std::shared_ptr<Widget>构造函数必须被执行
  • computePriority必须运行

编译器不需要按照执行顺序生成代码。“new Widget”必须在 std::shared_ptr的构造函数被调用前执行,因为 new出来的结果作为构造函数的实参,但 computePriority可能在这之前,之后,或者之间执行。也就是说,编译器可能按照这个执行顺序生成代码:

  1. 执行“new Widget
  2. 执行 computePriority
  3. 运行 std::shared_ptr构造函数

如果按照这样生成代码,并且在运行时 computePriority产生了异常,那么第一步动态分配的 Widget就会泄漏。因为它永远都不会被第三步的 std::shared_ptr所管理了。

使用 std::make_shared可以防止这种问题。调用代码看起来像是这样:

processWidget(std::make_shared<Widget>(),   //没有潜在的资源泄漏
              computePriority());

在运行时,std::make_sharedcomputePriority其中一个会先被调用。如果是 std::make_shared先被调用,在 computePriority调用前,动态分配 Widget的原始指针会安全的保存在作为返回值的 std::shared_ptr中。如果 computePriority产生一个异常,那么 std::shared_ptr析构函数将确保管理的 Widget被销毁。如果首先调用 computePriority并产生一个异常,那么 std::make_shared将不会被调用,因此也就不需要担心动态分配 Widget(会泄漏)。

如果我们将 std::shared_ptrstd::make_shared替换成 std::unique_ptrstd::make_unique,同样的道理也适用。因此,在编写异常安全代码时,使用 std::make_unique而不是 new与使用 std::make_shared(而不是 new)同样重要。

std::make_shared的一个特性(与直接使用 new相比)是效率提升。使用 std::make_shared允许编译器生成更小,更快的代码,并使用更简洁的数据结构。考虑以下对new的直接使用:

std::shared_ptr<Widget> spw(new Widget);

显然,这段代码需要进行内存分配,但它实际上执行了两次。Item19解释了每个 std::shared_ptr指向一个控制块,其中包含被指向对象的引用计数,还有其他东西。这个控制块的内存在 std::shared_ptr构造函数中分配。因此,直接使用 new需要为 Widget进行一次内存分配,为控制块再进行一次内存分配。

如果使用 std::make_shared代替:

auto spw = std::make_shared<Widget>();

一次分配足矣。这是因为 std::make_shared分配一块内存,同时容纳了 Widget对象和控制块。这种优化减少了程序的静态大小,因为代码只包含一个内存分配调用,并且它提高了可执行代码的速度,因为内存只分配一次。此外,使用 std::make_shared避免了对控制块中的某些簿记信息的需要,潜在地减少了程序的总内存占用。

对于 std::make_shared的效率分析同样适用于 std::allocate_shared,因此 std::make_shared的性能优势也扩展到了该函数。

更倾向于使用 make函数而不是直接使用 new的争论非常激烈。尽管它们在软件工程、异常安全和效率方面具有优势,但本条款的建议是,更倾向于使用 make函数,而不是完全依赖于它们。这是因为有些情况下它们不能或不应该被使用。

例如,make函数都不允许指定自定义删除器(见Item1819),但是 std::unique_ptrstd::shared_ptr有构造函数这么做。有个 Widget的自定义删除器:

auto widgetDeleter = [](Widget* pw) {  };

创建一个使用它的智能指针只能直接使用 new

std::unique_ptr<Widget, decltype(widgetDeleter)>
    upw(new Widget, widgetDeleter);

std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

对于 make函数,没有办法做同样的事情。

make函数第二个限制来自于其实现中的语法细节。Item7解释了,当构造函数重载,有使用 std::initializer_list作为参数的重载形式和不用其作为参数的的重载形式,用花括号创建的对象更倾向于使用 std::initializer_list作为形参的重载形式,而用小括号创建对象将调用不用 std::initializer_list作为参数的的重载形式。make函数会将它们的参数完美转发给对象构造函数,但是它们是使用小括号还是花括号?对某些类型,问题的答案会很不相同。例如,在这些调用中,

auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);

生成的智能指针指向带有10个元素的 std::vector,每个元素值为20,还是指向带有两个元素的 std::vector,其中一个元素值10,另一个为20?或者结果是不确定的?

好消息是这并非不确定:两种调用都创建了10个元素,每个值为20的 std::vector。这意味着在 make函数中,完美转发使用小括号,而不是花括号。坏消息是如果你想用花括号初始化指向的对象,你必须直接使用 new。使用 make函数会需要能够完美转发花括号初始化的能力,但是,正如Item30所说,花括号初始化无法完美转发。但是,Item30介绍了一个变通的方法:使用 auto类型推导从花括号初始化创建 std::initializer_list对象(见Item2),然后将 auto创建的对象传递给 make函数。

//创建std::initializer_list
auto initList = { 10, 20 };
//使用std::initializer_list为形参的构造函数创建std::vector
auto spv = std::make_shared<std::vector<int>>(initList);

对于 std::unique_ptr,只有这两种情景(自定义删除器和花括号初始化)使用 make函数有点问题。对于 std::shared_ptr和它的 make函数,还有2个问题。都属于边缘情况,但是一些开发者常碰到,你也可能是其中之一。

一些类重载了 operator newoperator delete。这些函数的存在意味着对这些类型的对象的全局内存分配和释放是不合常规的。设计这种定制操作往往只会精确的分配、释放对象大小的内存。例如,Widget类的 operator newoperator delete只会处理 sizeof(Widget)大小的内存块的分配和释放。这种系列行为不太适用于 std::shared_ptr对自定义分配(通过 std::allocate_shared)和释放(通过自定义删除器)的支持,因为 std::allocate_shared需要的内存总大小不等于动态分配的对象大小,还需要再加上控制块大小。因此,使用 make函数去创建重载了 operator newoperator delete类的对象是个典型的糟糕想法。

与直接使用 new相比,std::make_shared在大小和速度上的优势源于 std::shared_ptr的控制块与指向的对象放在同一块内存中。当对象的引用计数降为0,对象被销毁(即析构函数被调用)。但是,因为控制块和对象被放在同一块分配的内存块中,直到控制块的内存也被销毁,对象占用的内存才被释放。

正如我说,控制块除了引用计数,还包含簿记信息。引用计数追踪有多少 std::shared_ptrs指向控制块,但控制块还有第二个计数,记录多少个 std::weak_ptrs指向控制块。第二个引用计数就是weak count。(实际上,weak count的值不总是等于指向控制块的 std::weak_ptr的数目,因为库的实现者找到一些方法在weak count中添加附加信息,促进更好的代码产生。为了本条款的目的,我们会忽略这一点,假定weak count的值等于指向控制块的 std::weak_ptr的数目。)当一个 std::weak_ptr检测它是否过期时(见Item19),它会检测指向的控制块中的引用计数(而不是weak count)。如果引用计数是0(即对象没有 std::shared_ptr再指向它,已经被销毁了),std::weak_ptr就已经过期。否则就没过期。

只要 std::weak_ptrs引用一个控制块(即weak count大于零),该控制块必须继续存在。只要控制块存在,包含它的内存就必须保持分配。通过 std::shared_ptrmake函数分配的内存,直到最后一个 std::shared_ptr和最后一个指向它的 std::weak_ptr已被销毁,才会释放。

如果对象类型非常大,而且销毁最后一个 std::shared_ptr和销毁最后一个 std::weak_ptr之间的时间很长,那么在销毁对象和释放它所占用的内存之间可能会出现延迟。

class ReallyBigType {  };

auto pBigObj = std::make_shared<ReallyBigType>();//通过std::make_shared创建一个大对象

           //创建std::shared_ptrs和std::weak_ptrs指向这个对象,使用它们

           //最后一个std::shared_ptr在这销毁,但std::weak_ptrs还在

           //在这个阶段,原来分配给大对象的内存还分配着

           //最后一个std::weak_ptr在这里销毁;控制块和对象的内存被释放

直接只用 new,一旦最后一个 std::shared_ptr被销毁,ReallyBigType对象的内存就会被释放:

class ReallyBigType {  };              //和之前一样

std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);
                                        //通过new创建大对象

           //像之前一样,创建std::shared_ptrs和std::weak_ptrs指向这个对象,使用它们

           //最后一个std::shared_ptr在这销毁,但std::weak_ptrs还在;对象的内存被释放

           //在这阶段,只有控制块的内存仍然保持分配

           //最后一个std::weak_ptr在这里销毁;控制块内存被释放

如果你发现自己处于不可能或不合适使用 std::make_shared的情况下,你将想要保证自己不受我们之前看到的异常安全问题的影响。最好的方法是确保在直接使用 new时,在一个不做其他事情的语句中,立即将结果传递到智能指针构造函数。这可以防止编译器生成的代码在使用 new和调用管理 new出来对象的智能指针的构造函数之间发生异常。

例如,考虑我们前面讨论过的 processWidget函数,对其非异常安全调用的一个小修改。这一次,我们将指定一个自定义删除器:

void processWidget(std::shared_ptr<Widget> spw,int priority);     //和之前一样
void cusDel(Widget *ptr);                           //自定义删除器

这是非异常安全调用:

processWidget(                                      //和之前一样,
    std::shared_ptr<Widget>(new Widget, cusDel),    //潜在的内存泄漏!
    computePriority() 
);

回想一下:如果 computePriority在“new Widget”之后,而在 std::shared_ptr构造函数之前调用,并且如果 computePriority产生一个异常,那么动态分配的 Widget将会泄漏。

这里使用自定义删除排除了对 std::make_shared的使用,因此避免出现问题的方法是将 Widget的分配和 std::shared_ptr的构造放入它们自己的语句中,然后使用得到的 std::shared_ptr调用 processWidget。这是该技术的本质,不过,正如我们稍后将看到的,我们可以对其进行调整以提高其性能:

std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority());  // 正确,但是没优化,见下

这是可行的,因为 std::shared_ptr获取了传递给它的构造函数的原始指针的所有权,即使构造函数产生了一个异常。此例中,如果 spw的构造函数抛出异常(比如无法为控制块动态分配内存),仍然能够保证 cusDel会在“new Widget”产生的指针上调用。

一个小小的性能问题是,在非异常安全调用中,我们将一个右值传递给 processWidget

processWidget(
    std::shared_ptr<Widget>(new Widget, cusDel),    //实参是一个右值
    computePriority()
);

但是在异常安全调用中,我们传递了左值:

processWidget(spw, computePriority());              //实参是左值

因为 processWidgetstd::shared_ptr形参是传值,从右值构造只需要移动,而传递左值构造需要拷贝。对 std::shared_ptr而言,这种区别是有意义的,因为拷贝 std::shared_ptr需要对引用计数原子递增,移动则不需要对引用计数有操作。为了使异常安全代码达到非异常安全代码的性能水平,我们需要用 std::movespw转换为右值(见Item23):

processWidget(std::move(spw), computePriority());   //高效且异常安全

这很有趣,也值得了解,但通常是无关紧要的,因为您很少有理由不使用 make函数。除非你有令人信服的理由这样做,否则你应该使用 make函数。

请记住:

  • 和直接使用 new相比,make函数消除了代码重复,提高了异常安全性。对于 std::make_sharedstd::allocate_shared,生成的代码更小更快。
  • 不适合使用 make函数的情况包括需要指定自定义删除器和希望用花括号初始化。
  • 对于 std::shared_ptrs,其他不建议使用 make函数的情况包括(1)有自定义内存管理的类;(2)特别关注内存的系统,非常大的对象,以及 std::weak_ptrs比对应的 std::shared_ptrs活得更久。

条款二十二:当使用Pimpl惯用法,请在实现文件中定义特殊成员函数

Item 22: When using the Pimpl Idiom, define special member functions in the implementation file

如果你曾经与过多的编译次数斗争过,你会对Pimplpointer to implementation惯用法很熟悉。 凭借这样一种技巧,你可以将类数据成员替换成一个指向包含具体实现的类(或结构体)的指针,并将放在主类(primary class)的数据成员们移动到实现类(implementation class)去,而这些数据成员的访问将通过指针间接访问。 举个例子,假如有一个类 Widget看起来如下:

class Widget() {                    //定义在头文件“widget.h”
public:
    Widget();
    
private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;              //Gadget是用户自定义的类型
};

因为类 Widget的数据成员包含有类型 std::stringstd::vectorGadget, 定义有这些类型的头文件在类 Widget编译的时候,必须被包含进来,这意味着类 Widget的使用者必须要 #include <string><vector>以及 gadget.h。 这些头文件将会增加类 Widget使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类 Widget使用者也必须要重新编译。 标准库文件 <string><vector>不是很常变,但是 gadget.h可能会经常修订。

在C++98中使用Pimpl惯用法,可以把 Widget的数据成员替换成一个原始指针,指向一个已经被声明过却还未被定义的结构体,如下:

class Widget                        //仍然在“widget.h”中
{
public:
    Widget();
    ~Widget();                      //析构函数在后面会分析
    

private:
    struct Impl;                    //声明一个 实现结构体
    Impl *pImpl;                    //以及指向它的指针
};

因为类 Widget不再提到类型 std::stringstd::vector以及 GadgetWidget的使用者不再需要为了这些类型而引入头文件。 这可以加速编译,并且意味着,如果这些头文件中有所变动,Widget的使用者不会受到影响。

一个已经被声明,却还未被实现的类型,被称为不完整类型incomplete type)。 Widget::Impl就是这种类型。 你能对一个不完整类型做的事很少,但是声明一个指向它的指针是可以的。Pimpl惯用法利用了这一点。

Pimpl惯用法的第一步,是声明一个数据成员,它是个指针,指向一个不完整类型。 第二步是动态分配和回收一个对象,该对象包含那些以前在原来的类中的数据成员。 内存分配和回收的代码都写在实现文件里,比如,对于类 Widget而言,写在 Widget.cpp里:

#include "widget.h"             //以下代码均在实现文件“widget.cpp”里
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {           //含有之前在Widget中的数据成员的
    std::string name;           //Widget::Impl类型的定义
    std::vector<double> data;
    Gadget g1,g2,g3;
};

Widget::Widget()                //为此Widget对象分配数据成员
: pImpl(new Impl)
{}

Widget::~Widget()               //销毁数据成员
{ delete pImpl; }

在这里我把 #include命令写出来是为了明确一点,对于 std::stringstd::vectorGadget的头文件的整体依赖依然存在。 然而,这些依赖从头文件 widget.h(它被所有 Widget类的使用者包含,并且对他们可见)移动到了 widget.cpp(该文件只被 Widget类的实现者包含,并只对他可见)。 我高亮了其中动态分配和回收 Impl对象的部分(译者注:markdown高亮不了,实际高亮的是 new Impldelete pImpl;两个语句)。这就是为什么我们需要 Widget的析构函数——我们需要 Widget被销毁时回收该对象。

但是,我展示给你们看的是一段C++98的代码,散发着一股已经过去了几千年的腐朽气息。 它使用了原始指针,原始的 new和原始的 delete,一切都让它如此的...原始。这一章建立在“智能指针比原始指针更好”的主题上,并且,如果我们想要的只是在类 Widget的构造函数动态分配 Widget::impl对象,在 Widget对象销毁时一并销毁它, std::unique_ptr(见Item18)是最合适的工具。在头文件中用 std::unique_ptr替代原始指针,就有了头文件中如下代码:

class Widget {                      //在“widget.h”中
public:
    Widget();
    

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;    //使用智能指针而不是原始指针
};

实现文件也可以改成如下:

#include "widget.h"                 //在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {               //跟之前一样
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
};

Widget::Widget()                    //根据条款21,通过std::make_unique
: pImpl(std::make_unique<Impl>())   //来创建std::unique_ptr
{}

你会注意到,Widget的析构函数不存在了。这是因为我们没有代码加在里面了。 std::unique_ptr在自身析构时,会自动销毁它所指向的对象,所以我们自己无需手动销毁任何东西。这就是智能指针的众多优点之一:它使我们从手动资源释放中解放出来。

以上的代码能编译,但是,最普通的 Widget用法却会导致编译出错:

#include "widget.h"

Widget w;                           //错误!

你所看到的错误信息根据编译器不同会有所不同,但是其文本一般会提到一些有关于“把 sizeofdelete应用到不完整类型上”的信息。对于不完整类型,使用以上操作是禁止的。

在Pimpl惯用法中使用 std::unique_ptr会抛出错误,有点惊悚,因为第一 std::unique_ptr宣称它支持不完整类型,第二Pimpl惯用法是 std::unique_ptr的最常见的使用情况之一。 幸运的是,让这段代码能正常运行很简单。 只需要对上面出现的问题的原因有一个基础的认识就可以了。

在对象 w被析构时(例如离开了作用域),问题出现了。在这个时候,它的析构函数被调用。我们在类的定义里使用了 std::unique_ptr,所以我们没有声明一个析构函数,因为我们并没有任何代码需要写在里面。根据编译器自动生成的特殊成员函数的规则(见 Item17),编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类 Widget的数据成员 pImpl的析构函数。 pImpl是一个 std::unique_ptr<Widget::Impl>,也就是说,一个使用默认删除器的 std::unique_ptr。 默认删除器是一个函数,它使用 delete来销毁内置于 std::unique_ptr的原始指针。然而,在使用 delete之前,通常会使默认删除器使用C++11的特性 static_assert来确保原始指针指向的类型不是一个不完整类型。 当编译器为 Widget w的析构生成代码时,它会遇到 static_assert检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象 w销毁的地方出现,因为类 Widget的析构函数,正如其他的编译器生成的特殊成员函数一样,是暗含 inline属性的。 错误信息自身往往指向对象 w被创建的那行,因为这行代码明确地构造了这个对象,导致了后面潜在的析构。

为了解决这个问题,你只需要确保在编译器生成销毁 std::unique_ptr<Widget::Impl>的代码之前, Widget::Impl已经是一个完整类型(complete type)。 当编译器“看到”它的定义的时候,该类型就成为完整类型了。 但是 Widget::Impl的定义在 widget.cpp里。成功编译的关键,就是在 widget.cpp文件内,让编译器在“看到” Widget的析构函数实现之前(也即编译器插入的,用来销毁 std::unique_ptr这个数据成员的代码的,那个位置),先定义 Widget::Impl

做出这样的调整很容易。只需要先在 widget.h里,只声明类 Widget的析构函数,但不要在这里定义它:

class Widget {                  //跟之前一样,在“widget.h”中
public:
    Widget();
    ~Widget();                  //只有声明语句
    

private:                        //跟之前一样
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

widget.cpp文件中,在结构体 Widget::Impl被定义之后,再定义析构函数:

#include "widget.h"                 //跟之前一样,在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {               //跟之前一样,定义Widget::Impl
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
}

Widget::Widget()                    //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget()                   //析构函数的定义(译者注:这里高亮)
{}

这样就可以了,并且这样增加的代码也最少,你声明 Widget析构函数只是为了在 Widget 的实现文件中(译者注:指 widget.cpp)写出它的定义,但是如果你想强调编译器自动生成的析构函数会做和你一样正确的事情,你可以直接使用“= default”定义析构函数体

Widget::~Widget() = default;        //同上述代码效果一致

使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意:对其中的 std::unique_ptr进行移动。 正如Item17所解释的那样,声明一个类 Widget的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,你必须自己声明相关的函数。考虑到编译器自动生成的版本会正常运行,你可能会很想按如下方式实现它们:

class Widget {                                  //仍然在“widget.h”中
public:
    Widget();
    ~Widget();

    Widget(Widget&& rhs) = default;             //思路正确,
    Widget& operator=(Widget&& rhs) = default;  //但代码错误
    

private:                                        //跟之前一样
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

这样的做法会导致同样的错误,和之前的声明一个不带析构函数的类的错误一样,并且是因为同样的原因。 编译器生成的移动赋值操作符,在重新赋值之前,需要先销毁指针 pImpl指向的对象。然而在 Widget的头文件里,pImpl指针指向的是一个不完整类型。移动构造函数的情况有所不同。 移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁 pImpl的代码。然而,销毁 pImpl需要 Impl是一个完整类型。

因为这个问题同上面一致,所以解决方案也一样——把移动操作的定义移动到实现文件里:

class Widget {                          //仍然在“widget.h”中
public:
    Widget();
    ~Widget();

    Widget(Widget&& rhs);               //只有声明
    Widget& operator=(Widget&& rhs);
    

private:                                //跟之前一样
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
#include <string>                   //跟之前一样,仍然在“widget.cpp”中


struct Widget::Impl {  };          //跟之前一样

Widget::Widget()                    //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default;        //跟之前一样

Widget::Widget(Widget&& rhs) = default;             //这里定义
Widget& Widget::operator=(Widget&& rhs) = default;

Pimpl惯用法是用来减少类的实现和类使用者之间的编译依赖的一种方法,但是,从概念而言,使用这种惯用法并不改变这个类的表现。 原来的类 Widget包含有 std::stringstd::vectorGadget数据成员,并且,假设类型 Gadget,如同 std::stringstd::vector一样,允许复制操作,所以类 Widget支持复制操作也很合理。 我们必须要自己来写这些函数,因为第一,对包含有只可移动(move-only)类型,如 std::unique_ptr的类,编译器不会生成复制操作;第二,即使编译器帮我们生成了,生成的复制操作也只会复制 std::unique_ptr(也即浅拷贝(shallow copy)),而实际上我们需要复制指针所指向的对象(也即深拷贝(deep copy))。

使用我们已经熟悉的方法,我们在头文件里声明函数,而在实现文件里去实现他们:

class Widget {                          //仍然在“widget.h”中
public:
    

    Widget(const Widget& rhs);          //只有声明
    Widget& operator=(const Widget& rhs);

private:                                //跟之前一样
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
#include <string>                   //跟之前一样,仍然在“widget.cpp”中


struct Widget::Impl {  };          //跟之前一样

Widget::~Widget() = default;        //其他函数,跟之前一样

Widget::Widget(const Widget& rhs)   //拷贝构造函数
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}

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

两个函数的实现都比较中规中矩。 在每个情况中,我们都只从源对象(rhs)中,复制了结构体 Impl的内容到目标对象中(*this)。我们利用了编译器会为我们自动生成结构体 Impl的复制操作函数的机制,而不是逐一复制结构体 Impl的成员,自动生成的复制操作能自动复制每一个成员。 因此我们通过调用编译器生成的 Widget::Impl的复制操作函数来实现了类 Widget的复制操作。 在复制构造函数中,注意,我们仍然遵从了Item21的建议,使用 std::make_unique而非直接使用 new

为了实现Pimpl惯用法,std::unique_ptr是我们使用的智能指针,因为位于对象内部的 pImpl指针(例如,在类 Widget内部),对所指向的对应实现的对象的享有独占所有权。然而,有趣的是,如果我们使用 std::shared_ptr而不是 std::unique_ptr来做 pImpl指针, 我们会发现本条款的建议不再适用。 我们不需要在类 Widget里声明析构函数,没有了用户定义析构函数,编译器将会愉快地生成移动操作,并且将会如我们所期望般工作。widget.h里的代码如下,

class Widget {                      //在“widget.h”中
public:
    Widget();
                                   //没有析构函数和移动操作的声明

private:
    struct Impl;
    std::shared_ptr<Impl> pImpl;    //用std::shared_ptr
};                                  //而不是std::unique_ptr

这是 #includewidget.h的客户代码,

Widget w1;
auto w2(std::move(w1));     //移动构造w2
w1 = std::move(w2);         //移动赋值w1

这些都能编译,并且工作地如我们所望:w1将会被默认构造,它的值会被移动进 w2,随后值将会被移动回 w1,然后两者都会被销毁(因此导致指向的 Widget::Impl对象一并也被销毁)。

std::unique_ptrstd::shared_ptrpImpl指针上的表现上的区别的深层原因在于,他们支持自定义删除器的方式不同。 对 std::unique_ptr而言,删除器的类型是这个智能指针的一部分,这让编译器有可能生成更小的运行时数据结构和更快的运行代码。 这种更高效率的后果之一就是 std::unique_ptr指向的类型,在编译器的生成特殊成员函数(如析构函数,移动操作)被调用时,必须已经是一个完整类型。 而对 std::shared_ptr而言,删除器的类型不是该智能指针的一部分,这让它会生成更大的运行时数据结构和稍微慢点的代码,但是当编译器生成的特殊成员函数被使用的时候,指向的对象不必是一个完整类型。(译者注:知道 std::unique_ptrstd::shared_ptr的实现,这一段才比较容易理解。)

对于Pimpl惯用法而言,在 std::unique_ptrstd::shared_ptr的特性之间,没有一个比较好的折中。 因为对于像 Widget的类以及像 Widget::Impl的类之间的关系而言,他们是独享占有权关系,这让 std::unique_ptr使用起来很合适。 然而,有必要知道,在其他情况中,当共享所有权存在时,std::shared_ptr是很适用的选择的时候,就没有 std::unique_ptr所必需的声明——定义(function-definition)这样的麻烦事了。

请记住:

  • Pimpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
  • 对于 std::unique_ptr类型的 pImpl指针,需要在头文件的类里声明特殊的成员函数,但是在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。
  • 以上的建议只适用于 std::unique_ptr,不适用于 std::shared_ptr