裸指针有着诸多缺点:裸指针的声明中看不出它指向的是单个对象还是数组;裸指针的声明中也无法看出使用完它指向的对象后是否需要删除,也就是声明中看不出裸指针是否拥有其指向的对象;即使知道要析构裸指针指向的对象,也不可能知道如何析构才是恰当的;即使确知要使用delete来析构,也无法判定到底使用delete还是delete[];使用裸指针也无法保证在程序多个代码路径上仅执行一次析构,不执行析构会造成内存泄漏,多次析构又会产生未定义行为;无法判断出裸指针是否是空悬指针。
智能指针的提出,就是用来解决裸指针的诸多问题的。它对裸指针进行包装,行为上类似于裸指针,但是却避免了使用裸指针时会遭遇的陷阱。C++11中共有四种智能指针:std::auto_ptr,std::unique_ptr,std::shared_ptr,以及std::weak_ptr,而std::auto_ptr是C++98遗留下来的弃用特性,应该使用std::unique_ptr代替它,std::unique_str可以做std::auto_ptr能做的任何事。
18:使用std::unique_ptr管理具备专属所有权的资源
默认情况下,std::unique_ptr有着和裸指针相同的尺寸,并且对于大多数操作(包括解引用),它们都是精确的执行了相同的指令。这意味你甚至可以在内存和时钟周期紧张的场合下使用std::unique_ptr。
std::unique_ptr实现的是专属所有权的语义。一个非空的std::unique_ptr总是拥有其指向的对象,移动一个std::unique_ptr会将所有权从源指针移至目标指针,且源指针被置空。std::unique_ptr不允许复制,它是一个move-only类型。在执行析构时,由非空的std::unique_ptr析构其资源,默认的析构行为是对std::unique_ptr内部的裸指针实施delete。
std::unique_ptr常见用法是在对象继承层级中作为工厂函数的返回类型,工厂函数通常是在堆上分配一个对象,并返回一个指向该对象的指针,当不再需要该对象时,由调用者负责删除对象。如果工厂函数返回std::unique_ptr,调用者无需关心删除对象的问题:
class Investment{ ... }; class Stock : public Investment { ... }; class Bond : public Investment { ... }; class RealEstate : public Investment { ... }; template<typename... Ts> std::unique_ptr<Investment> makeInvestment(Ts&&... params);
调用者可以在单个作用域内像下面这样使用返回的std::unique_ptr:
{ // pInvestment is of type std::unique_ptr<Investment> auto pInvestment = makeInvestment( arguments ); … } // destroy *pInvestment
当一个由工厂函数返回的std::unique_ptr被移动至一个容器中,该容器的元素又被移动到对象的数据成员中,稍后该对象被析构时,该对象的std::unique_ptr数据成员将随着对象主体的析构而析构,从而引发工厂函数返回的资源也被析构。
默认情况下,析构时通过delete运算符实现的,也可以自定义析构资源时要调用的函数(或者是函数对象,包括lambda产生的)。比如,要在makeInvestment创建的对象析构时加入一条日志,可以这样:
auto delInvmt = [](Investment* pInvestment) // custom deleter { 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 ( /* a Stock object should be created */ ) { pInv.reset(new Stock(std::forward<Ts>(params)...)); } else if ( /* a Bond object should be created */ ) { pInv.reset(new Bond(std::forward<Ts>(params)...)); } else if ( /* a RealEstate object should be created */ ) { pInv.reset(new RealEstate(std::forward<Ts>(params)...)); } return pInv; }
delInvmt是自定义析构器。所有的自定义删除函数都接受一个指向欲删除对象的裸指针;将一个裸指针直接赋值给std::unique_ptr是不能通过编译的,这会形成从裸指针到智能指针的隐式类型转换,这种转换会大有问题,因此C++11中的智能指针禁止这样的操作,因此需要使用reset来让pInv获取从new出来的对象的所有权。
自定义析构器接收一个类型为Investment*的形参,不管在makeInvestment内创建的对象类型是什么,它终会在析构器内作为一个Investment*对象被删除,因此,基类Investment中必须是虚析构函数。此时,使用makeInvestment就无需关心使用的资源需要在析构时加以特殊处理这一事情。
在C++14中,由于有了函数返回类型推导,因此makeInvestment可以实现的更简单:
template<typename... Ts> auto makeInvestment(Ts&&... params) // C++14 { auto delInvmt = [](Investment* pInvestment) // this is now inside { makeLogEntry(pInvestment); delete pInvestment; }; std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt); if ( … ) // as before { 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; // as before }
之前提到过,使用默认析构器,可以认为std::unique_str和裸指针的尺寸相同。而使用自定义析构器,若析构器是个函数指针,则std::unique_ptr尺寸一般会增加1到2个字长;若析构器是个函数对象,则尺寸的变化取决于函数对象中存储了多少状态:无状态的函数对象(无捕获的lambda表达式)不会浪费任何存储尺寸,因此,当一个析构器既可以用函数实现,又可以用无捕获的lambda表达式实现时,lambda表达式是更好的选择。如果析构器采用带有大量状态的函数对象实现,则可能使得std::unique_ptr对象增加可观的尺寸。
std::unique_ptr除了用于工厂函数,它更广泛的用于实现Pimpl机制。
std::unique_ptr以两种形式提供,一种是单个对象:std::unique_ptr<T>,另一种是数组:std::unique_ptr<T[]>。std::unique_ptr的API也被设计成与使用形式相匹配的,比如单个对象形式的std::unique_ptr不提供operator[],而数组形式不提供operator*和operator->。
实际上,数组形式的std::unique_ptr仅作为知识进行了解就够了,因为std::array,std::vector和std::string这些数据结构总是比裸数组更好。
std::unique_ptr还有一个十分吸引人的特性,就是它可以方便高效的转换成std::shared_ptr:
// converts std::unique_ptr to std::shared_ptr std::shared_ptr<Investment> sp = makeInvestment( arguments );
这一特性使得std::unique_ptr更加的适合工厂函数的返回类型。通过返回std::unique_ptr,工厂函数不需要关心调用者是对其返回对象采用专属所有权好,还是共享所有权好。
19:使用std::shared_ptr管理具备共享所有权的资源
通过std::shared_ptr访问的对象采用共享所有权来管理其生存期,当最后一个指向该对象的std::shared_ptr不再指向它时(该std::shared_ptr被析构,或者其指向另一个对象),该std::shared_ptr会析构其指向的对象。正如垃圾回收机制一样,用户无需操心如何管理被指向对象的生存期。
std::shared_ptr通过访问某资源的引用计数来确定自己是否是最后一个指向该资源的std::shared_ptr。引用计数是个与资源关联的值,用来跟踪指向该资源的std::shared_ptr的数量。引用计数的存在会带来一些性能影响:std::shared_ptr的尺寸是裸指针的两倍,因为他们内部既包含指向资源的裸指针,也包含指向引用计数的裸指针(实际上是指向包含引用计数的控制块);引用计数的内存必定是动态分配的;引用计数的递增和递减必须是原子操作,原子操作一般比非原子操作要慢。
std::shared_ptr的构造函数通常会增加其指向对象的引用计数,然而也有例外,当调用std::shared_ptr的移动构造函数时,从一个std::shared_ptr移动构造一个新的std::shared_ptr会将源std::shared_ptr置空,因此新的std::shared_ptr产生后原有的std::shared_ptr将不再指向其资源,因此无需进行任何引用计数操作。因此,移动std::shared_ptr比复制更快。
与std::unique类似,std::shared_ptr也使用delete作为默认资源析构机制,它同样支持自定义析构器,然而自定义析构器的机制又与std::unique_ptr不同。对于std::unique_ptr而言,自定义析构器的类型是std::unique_ptr类型的一部分,而对于std::shared_ptr而言,却并非如此:
auto loggingDel = [](Widget *pw) { 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>,它们各有自己的自定义析构器:
auto customDeleter1 = [](Widget *pw) { … }; auto customDeleter2 = [](Widget *pw) { … }; std::shared_ptr<Widget> pw1(new Widget, customDeleter1); std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
由于pw1和pw2具有同一类型,因此它们可以放置在同类型的容器中:
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
而且它们也可以相互赋值,也可以传递至要求std::shared_ptr<Widget>类型形参的函数中。这些对于std::unique_ptr都是不可能的。
每一个std::shared_ptr管理的对象都有一个控制块,控制块中除了包含引用计数之外,还可以包含自定义析构器的一个复制,如果指定了自定义内存分配器的话,还可以包含它的一份复制;控制块还可以包含其他数据,比如弱引用计数:
对象的控制块由创建首个指向该对象的std::shared_ptr的函数来确定:std::make_shared总是创建一个控制块;从具备专属所有权的指针(std::unque_ptr或std::auto_ptr)出发构造一个std::shared_ptr时,会创建一个控制块;当std::shared_ptr构造函数使用裸指针作为实参时,会创建一个控制块,而如果通过std::shared_ptr或std::weak_ptr作为实参调用std::shared_ptr构造函数时不会创建新的控制块。
因此,从同一个裸指针出发构造不止一个std::shared_ptr是一种未定义行为,因为这种情况下,指向的对象会有多个控制块,多个控制块意味着多个引用计数,也就意味着该对象会被析构多次。因此,下面的代码是错误的:
auto pw = new Widget; // pw is raw ptr std::shared_ptr<Widget> spw1(pw, loggingDel); std::shared_ptr<Widget> spw2(pw, loggingDel);
从这段代码中,我们至少习得两个教训:尽可能避免将裸指针传递给std::shared_ptr构造函数,常用的替代手法是使用std::make_shared,然而如果需要自定义析构器的话,无法使用std::make_shared;如果必须传递裸指针到构造函数的话,就直接传递new的结果,而非传递一个裸指针变量。
有一种造成多个控制块的情况,使用对象的this指针作为std::shared_ptr的实参:
std::vector<std::shared_ptr<Widget>> processedWidgets; class Widget { public: … void process(); }; void Widget::process() { … // process the Widget processedWidgets.emplace_back(this); }
在Widget::process函数中,将this传入了一个std::shard_ptr容器,由此构造的std::shared_ptr将创建一个新的控制块。然而,如果在Widget::process外部,已经有std::shared_ptr指向该Widget了,这就造成了多控制块的情况。
然而某些场景下又必须在Widget成员函数内部将this调用std::shared_ptr构造函数,std::shared_ptr的API为这种情况提供了一种解决方案:std::enable_shared_from_this,它是一个基类模板。当你希望能从this安全的创建std::shared_ptr时,就需要继承继承该模板定义的基类:
class Widget: public std::enable_shared_from_this<Widget> { public: … void process(); };
std::enable_shared_from_this定义了一个成员函数shared_from_this,它会创建一个std::shared_ptr指向当前对象,但同时又不会重复创建控制块。每当需要一个和this指针指向相同对象的std::shared_ptr时,就可以在成员函数中使用它:
void Widget::process() { … // add std::shared_ptr to current object to processedWidgets processedWidgets.emplace_back(shared_from_this()); }
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的工厂函数来创造对象:
class Widget: public std::enable_shared_from_this<Widget> { public: // factory function that perfect-forwards args to a private ctor template<typename... Ts> static std::shared_ptr<Widget> create(Ts&&... params); … void process(); // as before private: … // ctors };
一个控制块的尺寸通常只有几个字长,尽管自定义析构器和内存分配器可能会使其变得更大,通常的控制块的实现要比预期的更加复杂,因为涉及到继承、虚函数。这意味着使用std::shared_ptr也会带来控制块用到的虚函数带来的成本。
典型情况下,在使用默认析构器和默认内存分配器,且std::shared_ptr是由std::make_shared创建的话,控制块的尺寸只有三个字节长,并且分配操作实际上没有任何成本,且对std::shared_ptr解引用并不比解引用裸指针花费更多。
从std::unique_ptr升级到std::shared_ptr很容易,因为std::shared_ptr可以由std::unique_ptr创建而来。但是反之不成立,一旦将资源的生存期托管给了std::shared_ptr,就不能在更改主意,即便引用计数为1,也不能回收该资源的所有权,并让一个std::unique_ptr来管理它。
还有一些std::shared_ptr不能做的事情,比如处理数组,与std::unique_ptr不同的是,std::shared_ptr的API就被设计用来处理指向到单个对象的指针,并没有所谓的std::shared_ptr<T[]>。尽管有时误打误撞发现可使用std::shard_ptr<T>来指向一个数组,并通过制定一个自定义析构器来完成数组的删除操作,但是这是一个糟糕的主意。C++提供了丰富多彩的内置数组选择,比如std::array,std::vector等,这样的前提下还需要声明一个智能指针指向一个非智能的数组,通常标志着设计的拙劣。
20:对于类似std::shared_ptr但有可能空悬的指针,使用std::weak_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一般是通过std::shared_ptr来创建的,当使用std::shared_ptr完成初始化std::weak_ptr的时刻,两者就指向相同的位置,但是std::weak_ptr并不影响对象的引用计数:
// spw 构造成功之后,指向Widget的引用计数为1 auto spw = std::make_shared<Widget>(); … //wpw指向相同的Widget,引用计数保持为1 std::weak_ptr<Widget> wpw(spw); … //引用计数变为0,Widget被析构,wpw变为空悬 spw = nullptr;
std::weak_ptr可以测试是否空悬:
if (wpw.expired()) … // if wpw doesn't point to an object…
但是,一般的场景是:检验std::weak_ptr是否已经失效,如果未失效,则访问它指向的对象。由于std::weak_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::weak_ptr为实参调用std::shared_ptr的构造函数,如果std::weak_ptr已失效,则抛出异常:
std::shared_ptr<Widget> spw1 = wpw.lock(); // if wpw's expired, spw1 is null auto spw2 = wpw.lock(); // same as above, but uses auto std::shared_ptr<Widget> spw3(wpw); // if wpw's expired, throw std::bad_weak_ptr
std::weak_ptr的应用场景之一:一个工厂函数,根据唯一ID,返回一个指向只读对象的智能指针。该工厂函数创建对象的成本较高(文件或数据库IO),并且ID会频繁使用,因此,工厂函数中需要使用缓存,缓存已创建的对象。对于这个带缓存的工厂函数而言,返回std::unique_ptr不合适。调用工厂函数的客户端获取指向缓存中对象的智能指针,而且由客户端来决定这些对象的生存期;然而缓存管理器也需要一个指向这些对象的指针,这些指针需要有检验何时空悬的能力。因此,应该缓存std::weak_ptr,工厂函数返回std::shared_ptr,因为只有当对象的生存期托付给std::shared_ptr时,std::weak_ptr才能检测空悬。下面是一个粗糙的实现版本:
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) { static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache; //objPtr是指向缓存对象的std::shared_ptr,当对象不再缓存中时,它是null auto objPtr = cache[id].lock(); if (!objPtr) { objPtr = loadWidget(id); // load it cache[id] = objPtr; // cache it } return objPtr; }
这个实现有个问题,就是当Widget不再使用时,缓存中失效的std::weak_ptr会不断的积累。因此这个实现还可以优化。
这种需求还可以有第二种实现方法:观察者模式。该模式包括的组件有:主题(subject,可能改变状态的对象)和观察者(observer,对象状态改变后要通知的对象)。在多数实现中,主题中包含了一个数据成员,该成员持有指向其观察者(们)的指针,使得主题状态发生变化时能发出通知。然而主题不会控制其观察者的生存期(不关心它们合适被析构),但需要能够检测出其观察者已经被析构,从而不再去访问它。因此,合理的设计就是让每个主题持有一个容器,该容器存放指向其观察者的std::weak_ptr,以便主题使用它时能检测出其是否已经空悬。
std::weak_ptr另一种使用场景是:A、B、C三个对象,A和C共享B的所有权,因此各持有一个指向B的std::shared_ptr。假设需要有一个指针从B指回A,则这个指针应该是什么类型呢?
裸指针:此种情况下,若A被析构,C仍然指向B。B还保存着指向A的空悬指针,但是B检测不出其是否空悬,一旦空悬后还使用该指针,就是未定义行为;
std::shared_ptr:这种设计中,A和B相互保存着指向对方的std::shared_ptr,造成了std::shared_ptr的环路,这种环路使得A和B无法析构。即使程序中其他数据结构已经不能再访问A和B了,两者也会保持着彼此的引用计数为一,这实际上已经是发生了内存泄漏,因为程序已无法访问A和B,但它们的资源得不到回收;
std::weak_ptr,这避免了上面的两个问题,若A已析构,B的指回指针能够检测到空悬;尽管A和B指向彼此,但是B的指针并不会增加A的引用计数,因此当std::shared_ptr不再指向A时,不会妨碍A被析构。
实际上,这种环路的场景不太常见(但也会出现),在类似树这种严格继承关系的数据结构中,子节点通常只被父节点拥有,当父节点被析构后,子节点也应该被析构,因此,父节点到子节点的指针可以使用std::unique_ptr,而子节点指回父节点的指针用裸指针即可,因为子节点的生存期不会比父节点更长,所以不会出现子节点解引用空悬指针的情况。
从效率角度来看,std::weak_ptr和std::shared_ptr本质上而言是一致的。std::weak_ptr和std::shared_ptr尺寸相同,它们和std::shared_ptr有着同样的控制块。其构造、析构和赋值都包含了对引用计数的原子操作,这里的引用计数是指控制块中的弱引用计数。
21:优先使用std::make_unique和std::make_shared,而非直接使用new
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系列函数可以持有任意数量的实参,然后将这些实参完美转发给动态创建对象的构造函数,并返回一个指向该对象的指针。make系列函数除了std::make_shared和std::make_unique,还有一个是std::alocate_shared,它行为和std::make_shared一样,但是它的第一个实参是个用以动态分配内存的分配器对象。
优先使用make系列函数的理由之一:
auto upw1(std::make_unique<Widget>()); // with make func std::unique_ptr<Widget> upw2(new Widget); // without make func auto spw1(std::make_shared<Widget>()); // with make func std::shared_ptr<Widget> spw2(new Widget); // without make func
使用new的版本需要重复书写创建对象的类型,而make系列函数没有。重复代码可能造成延长编译时间、目标代码臃肿;可能会演化成不一致的代码。
优先使用make系列函数的理由之二,异常安全。比如下面的代码:
void processWidget(std::shared_ptr<Widget> spw, int priority); processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
这段代码可能会造成内存泄漏。其原因在《Effective C++》条款17中已经解释过了。原因就在于编译器生成的计算顺序可能是:new Widget,执行computePriority,运行std::shared_ptr构造函数。当computePriority发生异常时,就会发生内存泄漏。
如果使用make_shared就可以避免该问题:
processWidget(std::make_shared<Widget>(), computePriority());
如果make_shared先调用,则指向动态分配的Widget的裸指针会在computePriority调用之前就安全的存储在std::shared_ptr对象中;如果computePriority先调用,则更不会发生内存泄漏了。
上面的场景,把std::shared_ptr和std::make_shared替换为std::unique_ptr和std::make_unique,推理过程完全相同。使用make系列函数能够保证异常安全性。
std::make_shared另一个优势在于,它能带来性能的提升。比如std::shared_ptr<Widget> spw(new Widget);这条语句会引发两次内存分配,一次是为Widget分配内存,一次是为控制块分配内存。而如果使用make_shared,则只有一次内存分配操作,因为std::make_shared会分配单块内存既保存Widget对象,又保存与其关联的控制块。
对std::make_shared的性能分析同样适用于std::allocated_shared,因此std::make_shared的优势同样适用于后者。
某些情况下,不能使用make系列函数。比如所有的make系列函数都不允许使用自定义析构器。
make系列函数的第二个限制源于其实现的一个语法细节。之前提到过,如果对象的构造函数中,有的具有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个元素,初始值为20的std::vector。这说明,在make系列函数中,对形参进行完美转发的代码使用的是()而非{}。
因此如果确实需要调用带std::initializer_list的构造函数,必须使用new。如果使用make系列函数,则有一种变通的方法,就是使用auto:
auto initList = { 10, 20 }; // create std::initializer_list // create std::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,通常情况下,类自定义的这俩函数被设计成仅用来分配和释放该类精确尺寸的内存块,比如Widget自定义了operator new和operator delete,它们用于分配和释放大小为sizeof(Widget)的内存块。这样的场景就不适合std::shared_ptr自定义分配器(通过std::allocate_shared)和自定义析构器的情况,因为std::aoolcate_shared所要求的内存大小并不等于动态分配对象的尺寸,而是要加上控制块的尺寸。因此,使用make系列函数去为带有自定义operator new 和operator delete的类创造对象是个坏主意。
使用std::make_shared之所以对于使用new有尺寸和速度上的优势,是因为前者将std:;shared_ptr的控制块和对象在同一内存块上分配,当对象引用计数为0时,对象被析构,然而对象所占用的内存要等待与其关联的控制块也被析构时才会被释放。控制块中除了包含引用计数跟踪有多少个std::shared_ptr指向该控制块,还有一个弱引用计数,它对指向该控制块的std::weak_ptr进行计数。std::weak_ptr通过检查控制块里的引用计数来校验是否已失效,如果引用计数为0,则std::weak_ptr是失效的,否则未失效。因此,因为std::weak_ptr会指向控制块,那么该控制块肯定会持续存在,因而包含它的内存也就存在,这样一来,通过std::shared_ptr的make系列函数分配的内存,在最后一个std::shared_ptr和最后一个std::weak_ptr都被析构之前,它是无法释放的。如果对象的尺寸很大,而且最后一个std::shraed_ptr被析构和最后一个std::weak_ptr析构之间的时间间隔很长,则在对象的析构和内存的释放之间,就存才延迟:
class ReallyBigType { … }; auto pBigObj = std::make_shared<ReallyBigType>(); … // 创建多个指向ReallyBigType对象的std::shared_ptrs和std::weak_ptrs, 并使用它们操作对象 … // 最后一个指向该对象的std::shared_ptr在此被析构,但指向该对象的若干std::weak_ptr依然存在 … // 在此阶段,大对象所占的内存扔处于分配未回收状态 … // 最后一个std::weak_ptr在此析构,控制块和对象所占用的同意内存块在此被释放
如果是直接使用new,则ReallyBitType对象的内存可以在最后一个指向它的std::shared_ptr析构时被释放:
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType); … // 创建多个指向ReallyBigType对象的std::shared_ptr和std::weak_ptr, 并使用它们操作对象 … // 最后一个指向该对象的std::shared_ptr在此析构,但std::weak_ptr仍存在,该对象的内存在此被释放 … // 在此阶段,仅控制块的内存处于分配未回收状态 … // 最后一个指向该对象的std::weak_ptr在此被析构,控制块所占用内存在此释放
一旦发现自己处在不能或不适合使用std::make_shared的场景中,就需要确保避免之前提到过的异常安全问题。最好的办法是确保直接使用new表达式的时候,立即将该表达式的结果传递给智能指针的构造函数,并且在这样一条语句中不做其他的事,只有这样,才能阻止编译器在new表达式和调用智能指针构造函数之间释放出异常来,比如下面的代码就是非异常安全的:
processWidget(std::shared_ptr<Widget>(new Widget, cusDel), computePriority());
由于使用了自定义析构器,因此无法使用std::make_shared,保证异常安全的代码是:
std::shared_ptr<Widget> spw(new Widget, cusDel); processWidget(spw, computePriority()); // 正确,但不是最优的
这样的代码中,即使spw的构造函数发生异常(比如分配控制块内存时抛出异常),它仍能保证cusDel针对new Widget返回的指针进行析构。
上面异常安全的代码还存在一个小问题。在非异常安全的代码中,传递给processWidget的是一个右值,而在异常安全的调用中,传递的是个左值。由于processWidget的std::shared_ptr形参是按值传递,从右值出发来构造仅需要一次移动,而从左值触发构造则需要一次复制,对于std::shared_ptr而言,这个区别还是很大的,这是因为复制一个std::shared_ptr涉及到引用计数的原子递增操作,而移动一个std::shared_ptr则不需要对引用计数做任何操作,如果希望异常全的代码达到非异常安全的代码同样的性能,就需要对spw加上std::move,将它转换为一个右值:
rocessWidget(std::move(spw), computePriority()); //既有效率,又异常安全
22:使用pimpl技巧时,在实现文件中定义特殊成员函数
Pimpl,pointer to implementation,这种技巧就是把某类的数据成员用一个指向实现类的指针代替,然后把原来在主类中的数据成员放置到实现类中,并通过指针间接访问这些数据成员。比如:
// in header "widget.h" class Widget { public: Widget(); … private: std::string name; std::vector<double> data; Gadget g1, g2, g3; };
Widget的数据成员有std::string,std::vector和Gadget等类型,因此这些类型相应的头文件必须存在,Widget才能编译通过。这就说明Widget的客户必须#include <string>,<vector>以及gadget.h。这些头文件增加了Widget的客户编译时间,此外,它们也使得客户依赖于这些头文件内容,如果某个头文件内容发生了变化,Widget的客户必须重新编译。
在C++98的Pimpl实现技巧中,可以用一个指向已声明但未定义的结构的裸指针来替换Widget的数据成员:
// "widget.h" class Widget { public: Widget(); ~Widget(); // dtor is needed—see below … private: struct Impl; Impl *pImpl; };
由于Widget不再涉及std::string,std::vector和Gadget类型,因此Widget客户不再需要#include这些类型的头文件。这会使编译速度提升,同时也意味着即使这些头文件的内容发生了变化,Widget的客户也不会受到影响。
Widget的实现文件widget.cpp的内容如下:
// "widget.cpp" #include "widget.h" #include "gadget.h" #include <string> #include <vector> struct Widget::Impl { std::string name; std::vector<double> data; Gadget g1, g2, g3; }; Widget::Widget() : pImpl(new Impl) // this Widget object {} Widget::~Widget() { delete pImpl; }
对std::string,std::vector和Gadget所对应的头文件的总体依赖依然存在,但是这种依赖已经从widget.h(对Widget客户可见并由他们使用)转移到了widget.cpp中(只对Widget实现者可见并被使用)。
自从C++11有了智能指针std::unique_ptr,上面的代码可以写成这样:
// "widget.h" class Widget { public: Widget(); … private: struct Impl; std::unique_ptr<Impl> pImpl; }; // "widget.cpp" #include "widget.h" #include "gadget.h" #include <string> #include <vector> struct Widget::Impl { std::string name; std::vector<double> data; Gadget g1, g2, g3; }; Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
这段代码本身能够通过编译,但是客户的代码却无法编译成功:
#include "widget.h" Widget w; // error!
具体错误信息视编译器而定,而错误信息通常会提及在非完整类型(struct Impl)上实施了sizeof和delete运算符,而这些属于不可以实施于非完整类型的操作之列。比如在GCC 5.4.0 20160609上的打印如下:
In file included from /usr/include/c++/5/memory:81:0, from widget.h:1, from widgetuse.cpp:1: /usr/include/c++/5/bits/unique_ptr.h: In instantiation of ‘void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = Widget::Impl]’: /usr/include/c++/5/bits/unique_ptr.h:236:17: required from ‘std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = Widget::Impl; _Dp = std::default_delete<Widget::Impl>]’ widget.h:3:7: required from here /usr/include/c++/5/bits/unique_ptr.h:74:22: error: invalid application of ‘sizeof’ to incomplete type ‘Widget::Impl’ static_assert(sizeof(_Tp)>0,
该问题产生的原因是:编译器编译Widget的客户代码,生成目标文件。客户代码声明了一个Widget类型的对象w,这条语句会调用Widget构造函数,当离开作用域时,w需要被析构,这又会调用析构函数。编译器发现Widget的定义中没有声明析构函数,因此它会自己生成一个析构函数,该析构函数中会调用成员pimpl的析构函数,pimpl的类型为std::unique_ptr<Widget::Impl>,并且使用默认的析构器,默认析构器在std::unique_ptr内部的裸指针上执行delete。在delete之前,典型的实现会使用C++11中的static_assert去确保裸指针未指向非完整类型:
static_assert(sizeof(_Tp)>0, "can't delete pointer to incomplete type");
因此,当编译器为析构w产生的代码,通常会遭遇static_assert的失败,从而导致错误发生。
为了解决该问题,只需要确保析构std::unique_ptr<Widget::Impl>时,Widget::Impl是个完整类型即可。而Widget::Impl的定义位于widget.cpp内,因此,成功编译的关键在于让编译器看到Widget析构函数的函数体(即编译器会生成代码析构std::unique_ptr数据成员之处)的位置在widget.cpp内部的Widget::Impl定义之后。如果Widget声明了析构函数,则编译生成目标文件时,发现Widget定义中已经声明了析构函数,因此它不会生成析构函数,在用到了析构函数的代码处使用占位,等待链接时去链接真正的析构函数:
// "widget.h" class Widget { public: Widget(); ~Widget(); // declaration only … private: struct Impl; std::unique_ptr<Impl> pImpl; };
在Widget.cpp中的Widget::Impl定义之后,再定义它的实现:
// "widget.cpp" #include "widget.h" #include "gadget.h" #include <string> #include <vector> struct Widget::Impl { std::string name; std::vector<double> data; Gadget g1, g2, g3; }; Widget::Widget() : pImpl(std::make_unique<Impl>()) {} Widget::~Widget() {}
这段代码已经能够正确工作。但是为了强调由编译器生成的析构函数会做正确的事,而声明它的唯一理由就是想要使其定义出现在Widget的实现文件中,那么可以使用”=default”来定义析构函数的函数体:
Widget::~Widget() = default; // same effect as above
使用了Pimpl技巧的类,因为声明析构会阻止编译器产生移动操作,如果需要支持移动操作,就必须自己声明该函数,既然编译器产生的版本时正确的,则可以:
// "widget.h" class Widget { public: Widget(); ~Widget(); Widget(Widget&& rhs) = default; // 想法正确,但是代码是错误的 Widget& operator=(Widget&& rhs) = default; … private: // as before struct Impl; std::unique_ptr<Impl> pImpl; };
这种写法会导致和类中没有声明析构函数一样的问题,产生该问题的原因也是相同的。编译器产生的移动赋值操作需要在重新赋值前析构pImpl指向的对象,但是在Widget的头文件里pImpl指向的是个非完整类型;移动构造函数出问题的原因有所不同,原因在于编译器会在移动构造函数内抛出异常的事件中生成析构pImpl的代码,而对pImpl的析构要求Impl是完整类型。
产生的原因相同,相应的解决办法也一样,就是把移动操作的定义放入到实现文件中:
// "widget.h" class Widget { public: Widget(); ~Widget(); Widget(Widget&& rhs); Widget& operator=(Widget&& rhs); … private: struct Impl; std::unique_ptr<Impl> pImpl; }; // "widget.cpp" #include <string> … struct Widget::Impl { … }; // as before Widget::Widget() : pImpl(std::make_unique<Impl>()) {} Widget::~Widget() = default; // as before Widget::Widget(Widget&& rhs) = default; Widget& Widget::operator=(Widget&& rhs) = default;
Pimpl技巧是一种可以在类实现和类使用者之间减少编译依赖的方法,但是概念上而言,Pimpl技巧并不能改变类所代表的事务。最初的Widget类包含了std::string,std::vector和Gadget数据成员,如果假设Gadget可以复制,那么Widget也就支持复制操作。使用了Pimpl手法之后,我们需要自己编写复制操作函数,这是因为编译器不会为std::unique_ptr这样的move only类型产生复制操作,即使编译器能生成,其生成的函数也只能复制std::unique_ptr(浅复制),而我们希望的则是复制指针所指向的内容(深复制)。
// "widget.h" class Widget { public: … // other funcs, as before Widget(const Widget& rhs); Widget& operator=(const Widget& rhs); private: // as before struct Impl; std::unique_ptr<Impl> pImpl; }; // "widget.cpp" #include "widget.h" … struct Widget::Impl { … }; // as before Widget::~Widget() = default; // other funcs, as before Widget::Widget(const Widget& rhs) // copy ctor : pImpl(std::make_unique<Impl>(*rhs.pImpl)) {} Widget& Widget::operator=(const Widget& rhs) // copy operator= { *pImpl = *rhs.pImpl; return *this; }
实现Pimpl手法,使用std::unique_ptr是天经地义的,因为在对象内部的pImpl指针拥有相应的对象的专属所有权。然而如果使用std::shared_ptr实现pImpl,则本条款的建议不再适用。因此,无需再Widget中声明析构函数,因而也就无需声明移动操作函数:
// widget.h" class Widget { public: Widget(); … private: struct Impl; std::shared_ptr<Impl> pImpl; };
使用#include widget.h的客户端代码如下:
Widget w1; auto w2(std::move(w1)); // move-construct w2 w1 = std::move(w2); // move-assign w1
std::unique_ptr和std::shared_ptr这两种智能指针在实现pImpl指针行为的不同,源于他们对于自定义析构器的支持的不同。对于std::unique_ptr而言,析构器类型是智能指针类型的一部分,这使得编译器会产生更小尺寸的运行期数据结构以及更快的运行期代码,这样带来的后果是,如果要使用编译器生成的特种函数,就要求其指向的类型必须是完整类型。而对于std::shared_ptr而言,析构器的类型并非智能指针的一部分,这就需要更大尺寸的运行期数据结构以及更慢的目标代码,但在编译器生成的特种函数中,其指向的类型却不要求完整类型。
就Pimpl习惯用法而言,使用std::unique_ptr是完成任务的合适工具,因为Widget和Widget::Impl这样的类之间是专属所有权。