Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

24.分清universal reference与rvalue reference

Posted on 2018-07-12 | In Effective Modern C++

T&&的两种含义

 
如果需要声明类型T的右值引用,我们应当撰写T&&。但当我们在某些源码中看到T&&,这并不代表它们是rvalue reference:

1
2
3
4
5
6
7
void f(Widget&& param); // rvalue reference
Widget&& var1 = Widget(); // rvalue reference
auto&& var2 = var1; // not rvalue reference
template<typename T>
void f(std::vector<T>&& param); // rvalue reference
template<typename T>
void f(T&& param); // not rvalue reference

事实上,“T &&”有两种不同的含义。当其具备ravlue reference的含义时,它们只与rvalues绑定,以识别可被移动的对象。
“T&&”的另一个含义是既可作为rvalue reference亦可作为lvalue reference。这种引用看起来像右值引用,但其行为却类似于左值引用,它的双重性质允许它绑定到rvalues(如rvalue引用)以及lvalues(如左值引用)。此外,它也可以绑定到const或non-const对象,volatile对象或non-volatile对象,甚至兼具const与volatile的对象。由于这种引用几乎可以绑定一切对象,因此作者将其称为universal reference。


universal reference

 
universal reference出现在两种情况下。最常见的是函数模板参数,例如:

1
2
template<typename T>
void f(T&& param); // param is a universal reference

也可能出现在auto类型推衍之中,例如:
1
auto&& var2 = var1; // var2 is a universal reference

显然,上述实例都离不开类型推衍。也就是说,如果当前场景内不存在类型推衍,那你看到的T&&必然是右值引用:
1
2
void f(Widget&& param); // no type deduction;param is an rvalue reference
Widget&& var1 = Widget(); // no type deduction;var1 is an rvalue reference

由于universal reference依旧是一个reference,因此必须对它们执行初始化操作,并且其初始化操作决定了其表现类型:以右值初始化则表现为右值引用,反之则为左值引用。作为函数形参的universal reference初始化操作发生于调用时:
1
2
3
4
5
template<typename T>
void f(T&& param); // param is a universal reference
Widget w;
f(w); // lvalue passed to f; param's type is Widget& (i.e., an lvalue reference)
f(std::move(w)); // rvalue passed to f; param's type is Widget&& (i.e., an rvalue reference)


universal refernce的判断

 
对于universal reference而言,类型推衍是必要而非充分条件,引用必须被声明为“T&&”形式才能够触发universal reference,试看刚才的实例:

1
2
template<typename T>
void f(std::vector<T>&& param); // param is an rvalue reference

在调用f时会执行类型推衍,但是param的类型声明的形式并非“T &&”,而是“std::vector<T> &&”。 我们之前强调过,universal reference必须是“T &&”才行,因此,param是一个右值引用,如果尝试将左值传入,编译器将会报错:
1
2
std::vector<int> v;
f(v); // error! can't bind lvalue to rvalue reference

即使只有一个const修饰符也足以取消universal reference:
1
2
template<typename T>
void f(const T&& param); // param is an rvalue reference

你可能会认为模板中的参数“T &&”必然是universal reference,但事实并非如此,因为模板也并非一定触发类型推衍,考虑std::vector中的push_back成员函数:

1
2
3
4
5
6
template<class T, class Allocator = allocator<T>> // from C++
class vector { // Standards
public:
void push_back(T&& x);
…
};

尽管该函数中出现了“T &&”这种形式,但由于成员函数在模板实例化之后才会存在,因此在实例化之前该成员函数可视为无效,假若我们当前对该模板执行实例化:
1
std::vector<Widget> v;

这直接导致该模板被实例化为:
1
2
3
4
5
class vector<Widget, allocator<Widget>> {
public:
void push_back(Widget&& x); // rvalue reference
…
};

显然,push_back的成员函数总是一个右值引用,根本不会触发类型推衍。

相比之下,std::vector中的emplace_back成员函数则确实触发了类型推衍:

1
2
3
4
5
6
7
template<class T, class Allocator = allocator<T>> // still from
class vector { // C++
public: // Standards
template <class... Args>
void emplace_back(Args&&... args);
…
};

在该声明中。Args作为一个独立于类型T的参数包,将会在实例化之中仍然执行类型推衍,因此它是一个universal reference,此外这个实例还说明了universal refernce并非一定为“T &&”,也有可能是“Type &&”:
1
2
template<typename MyTemplateType> // param is a
void someFunc(MyTemplateType&& param); // universal reference


universal refernce与auto

 
C++11和C++14中经常会出现“auto &&”,这也代表了universal reference。在C++14中,lambda可以声明auto&&参数,以一个记录函数运行执行时间的lambda为例:

1
2
3
4
5
auto timeFuncInvocation =[](auto&& func, auto&&... params){
start timer;
std::forward<decltype(func)>(func)(std::forward<decltype(params)>(params));
stop timer and record elapsed time;
};

在该lambda中,func是一个可以绑定到任何可调用对象(无论左值右值)的universal reference, args是一个universal reference参数包,它可以绑定到任意类型、任意数量的对象,由于universal reference的存在,timeFuncInvocation几乎可以完成任何函数调用。(Item30说明了为什么是几乎任何)。


总结

  1. 如果参数为“Type &&”形式且触发了类型推衍,又或者存在“auto &&”,则当前为universal reference。
  2. 如果不满足上一条,则为右值引用。
  3. universal refernce被左值初始化时表现为左值,被右值初始化时表现为右值。

23.理解std::move与std::forward

Posted on 2018-07-11 | In Effective Modern C++

前言

 
在开始叙述之前,我们首先需要明确,std::move并不执行移动,std::forward也并不转发,它们在运行期并不执行任何操作,也不生成任何代码。
std::move和std::forward只是执行强制转换的函数(实际上是函数模板)。std::move无条件地将其参数转换为rvalue,而std::forward仅在满足特定条件时才执行此转换,这就是关于它们的全部解释。


std::move

 
C++11中std::move的大致实现如下所示:

1
2
3
4
5
6
template<typename T> // in namespace std
typename remove_reference<T>::type&&
move(T&& param){
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}

函数返回类型的“&&”部分意味着std::move返回一个右值引用,但正如Item28将要提及的,如果类型T碰巧是左值引用,则T&&将成为左值引用。为了防止这种情况发生,将type_traits(见Item9)std::remove_reference应用于T,从而确保将“&&”应用于不是引用的类型,这一操作保证std::move真正返回一个rvalue引用,这一点相当重要,因为函数返回的右值引用是一个右值,最终,move将其参数转为了一个右值。

在C++14中,move的实现可以更加简单(多亏了返回类型推衍与type_traits的福):

1
2
3
4
5
template<typename T> // C++14
decltype(auto) move(T&& param){
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}


move问题实例

 
假设我们正在撰写一个表示注释的类,其构造函数参数为string,按照Item41的要求,我们将其设定为pass-by-value:

1
2
3
4
class Annotation {
public:
explicit Annotation(std::string text);
}

这时候你想到构造函数不会改变text,于是你把它声明为const:
1
2
3
4
5
class Annotation {
public:
explicit Annotation(const std::string text)
…
};

为了避免对data member作无谓的复制,你在构造函数中使用std::move(亦遵从Item 41):
1
2
3
4
5
6
7
class Annotation {
public:
explicit Annotation(const std::string text): value(std::move(text)){ … } // doesn't do what it seems to!
…
private:
std::string value;
};

这段代码能跑,只是运行起来未必如你所愿,事实上它执行了copy而非move。text被强制转换为右值这一点没毛病,但由于text被声明为const string,因此在执行转换之前,text是一个左值const std::string,并且转换完成后它变成了一个右值const std::string,在转换过程中,const一直被保留着。

编译器需要考虑使用哪种string构造函数,一般来说有两种选择:

1
2
3
4
5
6
7
class string {
public:
…
string(const string& rhs); // copy ctor
string(string&& rhs); // move ctor
…
};

在Annotation构造函数的member initialization list中,std::move(text)的结果是const std::string类型的rvalue。该rvalue无法传递给st::string的移动构造函数,因为移动构造函数形参必须是一个rvalue non-const std::string。但该rvalue可以传递给复制构造函数,因为允许将lvalue-reference-to-const绑定到const rvalue。 因此,成员初始化调用std::string中的复制构造函数。理解这一点十分重要,因为移动构造必然会破坏某些对象,因此移动构造的参数不具备const属性。

从该实例中可以得出两条经验:

  1. 如果希望move某对象,不要将其声明为const,这将导致只会触发copy。
  2. std::move并不保证对象被移动,它只能保证传递回一个右值而已。

std::forward

 
forward是一种条件式转换,仅仅在满足条件时才会将参数转为rvalue。要了解它何时进行转换以及何时不转换,只需要想想我们一般在何时使用std::forward。

问题实例

最常见的场景是一个函数模板,其形参为universal reference,该参数将被传递给另一个函数:

1
2
3
4
5
6
7
8
void process(const Widget& lvalArg); // process lvalues
void process(Widget&& rvalArg); // process rvalues
template<typename T> // template that passes
void logAndProcess(T&& param){
auto now = std::chrono::system_clock::now();
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}

考虑下述两个函数调用,其中一个实参是左值,另一个是右值:
1
2
3
Widget w;
logAndProcess(w); // call with lvalue
logAndProcess(std::move(w)); // call with rvalue

在logAndProcess中,参数param被传递给函数process。当我们使用左值调用logAndProcess时,我们自然希望将左值作为左值转发到process,当我们用rvalue调用logAndProcess时,我们期望调用process的rvalue重载版本。

但是,与所有函数参数一样,param是一个左值。因此,对logAndProcess内的每个process调用都将调用左值重载版本。 为了防止这种情况,我们需要一种机制来将param强制转换为rvalue。这解释了为何forward是一种条件转换:只有当它的参数用rvalue初始化时才转换为rvalue。

你可能想知道std::forward是如何知道它的参数是否用rvalue初始化。简单地来说,该信息在logAndProcess的模板参数T中被编码,该参数传递给std::forward,后者恢复编码信息,具体可见Item28。


move与forward

 
鉴于std::move和std::forward的本质均为强制转换,唯一的区别在于std::move总是执行强制转换,而std::forward则为条件式转换,似乎总可以用forward代替move,从技术层来说确实如此,但却并无必要,毕竟真要取消的话不如干脆都写cast好了。

std::move的优势在于便捷,减少错误发生的可能性以及使代码更加清晰。假设当前我们需要跟踪移动构造函数的调用次数,那我们只需要增加一个static计数器。更进一步地,假设类中唯一的非静态数据是std::string,以std::move实现移动构造有如下实现:

1
2
3
4
5
6
7
class Widget {
public:
Widget(Widget&& rhs):s(std::move(rhs.s)){ ++moveCtorCalls; }
private:
static std::size_t moveCtorCalls;
std::string s;
};

如果以forward替换move,则代码将如下所示:
1
2
3
4
5
class Widget {
public:
Widget(Widget&& rhs):s(std::forward<std::string>(rhs.s)){ ++moveCtorCalls;}
…
};

可以看出,std::move只需要一个函数参数(rhs.s),而std::forward需要一个函数参数(rhs.s)和一个模板类型参数(std::string)。此外,我们传递给std::forward的类型应当是non-reference,因为这是编码的约定(见Item28)。总之,这些意味着move比forward在某些场合更加好用。
总而言之,std::move表示将参数无条件转为rvalue,而std::forward仅表示在传递参数时保持参数原有的左右值属性,此二者完全不同,因此需要两种不同的名称加以区分。


总结

  1. std::move表示将参数无条件转为rvalue,并不执行任何移动操作。
  2. std::forward将绑定至右值的参数转为右值。
  3. move与forward均不在运行期执行任何操作。

右值引用、移动语义、完美转发

Posted on 2018-07-11 | In Effective Modern C++

移动语义使编译器可以用移动操作来替换花销较大的复制操作。与拷贝构造函数和拷贝赋值运算符类似,开发者可以自由定制移动操作的一切行为。移动语义还允许创建move-only类型,例如std::unique_ptr,std::future和std::thread。

完美转发使得编写形参为任意参数的函数模板成为可能,并且能够在转发参数时确保接收函数与发送函数保持一致。

Rvalue引用是将这两个不同的特性联系在一起的粘合,它是使移动语义和完美转发成为可能的底层语言机制。

对这些功能的体验越多,开发者就越意识到这些功能妙用无穷。移动语义,完美转发和右值引用的世界比它们展示出来的更加微妙。举例而言,std::move不移动任何东西,完美转发并不完美。移动操作并不总是拷贝制开销更低; tyep&& 也并不总是代表右值引用等等。

在本章需要明确的是,参数必然是一个左值,即使它被声明为一个右值形式:

1
void f(Widget&& w);

即w是一个左值,即使其类型为rvalue-reference-to-Widget。

22.在使用Pimpl时,记得在实现文件中定义special function

Posted on 2018-07-11 | In Effective Modern C++

Pimpl技术

 
Pimpl技术在Effective C++中多有涉及,详见Effective C++ Item31。

Pimpl声明

举例而言,假设Widget定义如下:

1
2
3
4
5
6
7
8
9
class Widget { // in header "widget.h"
public:
Widget();
…
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget is some user-defined type
};

因为Widget的数据成员是std::string,std::vector和Gadget,这意味着Widget客户端程序必须#include<string>,<vector>和gadget.h。

在C++ 98中应用Pimpl Idiom可以让Widget用原始指针替换其数据成员,该指针指向已声明但尚未定义的结构:

1
2
3
4
5
6
7
8
9
class Widget { // still in header "widget.h"
public:
Widget();
~Widget(); // dtor is needed—see below
…
private:
struct Impl; // declare implementation struct
Impl *pImpl; // and pointer to it
};

已声明但未定义的类型称为incomplete类型,Widget::Impl就是这种类型。使用不完整类型可以做的事情非常少,但至少可以声明一个指向它的指针。由于Widget不再涉及std::string,std::vector和Gadget,因此客户端代码不再需要#include上述头文件。这不仅仅加快了编译速度,这也意味着如果这些头文件的某些内容发生变化,Widget客户端不会受到影响(编译分离)。


Pimpl实现

Pimpl Idiom关于data member分配和释放代码在实现文件中,对于widget.cpp中的Widget,有:

1
2
3
4
5
6
7
8
9
10
11
#include "widget.h" // in impl. file "widget.cpp"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // definition of Widget::Impl
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget():pImpl(new Impl){}
Widget::~Widget(){ delete pImpl; }

我们可以看到,原先的头文件依存性仍然存在,只不过现在由Widget.cpp执行#include语句,Widget.cpp仅由其实现者使用,而Widget.h由客户端代码使用,这保证了编译分离性质,此外,Widget析构必须释放impl对象,这是pimpl的必要条件。但上述都是C++98中的实现,现在看来,原始指针似乎过于粗糙了一些,因此我们理当追随时代的进步,以std::unique_ptr取代raw pointer:
1
2
3
4
5
6
7
8
class Widget { // in "widget.h"
public:
Widget();
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl; // use smart pointer
};

实现文件如下所示:
1
2
3
4
5
6
7
8
9
10
#include "widget.h" // in "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():pImpl(std::make_unique<Impl>()){} // C++14

显然。智能指针的引入至少帮助我们省去了写析构函数的烦恼。
上述代码可以编译,但客户端代码在使用时会出现问题:
1
2
#include "widget.h"
Widget w; // error!

一般来说,报错内容关于无法将sizeof或delete应用于incomplete type。但众所周知,unique_ptr支持incomplete type且Pimpl是unique_ptr大展身手的最佳场景之一,让上述代码能够工作很容易,但我们需要加深对该问题的理解。


问题分析

 
问题之所以会产生在于析构Widget时存在问题。根据Item17所述的通用规则,编译器应当在用户并未声明析构函数时为我们生成析构函数。在该析构函数中,编译器插入代码以调用Widget的数据成员pImpl的析构函数。pImpl是一个std::unique_ptr<Widget::Impl>,即使用默认删除器的std :: unique_ptr。默认删除器是一个在std ::unique_ptr内的原始指针上使用delete的函数,其会使用C++11中的static_assert确保原始指针并未指向一个incomplete类型。当编译器生成用于析构Widget的代码时,它会遇到一个失败的static_assert,而这正是导致错误的原因。


问题解决

 
要解决上述问题,只需要确保在生成析构std::unique_ptr<Widget::Impl>的代码时,Widget::Impl是一个complete类型。一个类型将会在编译器看到其定义时变为complete,且我们在widget.cpp中定义了Widget::Impl。 那么,成功编译的关键在于让编译器在Impl已被声明后,在Widget.cpp中看到Widget的析构函数(在其中编译器会生成析构unique_ptr的代码)已被定义。那么解决方案来了:在Widget.h中声明析构函数,但不作出定义:

1
2
3
4
5
6
7
8
9
class Widget { // as before, in "widget.h"
public:
Widget();
~Widget(); // declaration only
…
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};

在定义了Widget::Impl后在Widget.cpp中定义析构函数:
1
2
3
4
5
6
7
8
9
10
11
#include "widget.h" // as before, in "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():pImpl(std::make_unique<Impl>()){}
Widget::~Widget(){}

上述程序已经很好地完成了需求,如果你想要强调析构函数只需为默认生成的即可,你可以使用=default修饰之:
1
Widget::~Widget() = default; // same effect as above


Pimpl与move

 
使用Pimpl Idiom的类简直与move操作天生一对,在底层unique_ptr上执行move操作既高效又优雅。但正如Item 17所述,Widget中的析构函数声明阻止了编译器生成移动操作,因此如果要移动支持,则必须自己声明这些函数。鉴于默认生成的move操作没毛病,因此你可能会这样声明它们:

1
2
3
4
5
6
7
8
9
10
11
class Widget { // still in "widget.h"
public:
Widget();
~Widget();
Widget(Widget&& rhs) = default; // right idea, wrong code!
Widget& operator=(Widget&& rhs) = default;
…
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};

这种方法导致的问题与产生问题的根本原因都类似于之前。编译器生成的move operator=需要在重新分配之前销毁pImpl指向的对象,但在Widget头文件中,pImpl指向不完整的类型。而移动构造函数的问题在于编译器通常会生成代码以在移动构造函数内部出现异常时析构pImpl,但析构pImpl需要Impl为complete。

由于这个问题与之前完全一样,因此它们的解决方案也一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget { // still in "widget.h"
public:
Widget();
~Widget();
Widget(Widget&& rhs); // declarations
Widget& operator=(Widget&& rhs); // only
…
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};
#include <string> // as before,in "widget.cpp"
…
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与copy

 
编译器不会为具备move-only data member的class生成copy操作,因此我们应当手动完成声明(就算编译器真的完成了拷贝,它针对unique_ptr执行的也是浅拷贝(仅仅复制指针),而我们需要的是深拷贝(复制unique_ptr所指向的对象))。

照样画葫芦,我们在头文件中对copy操作完成声明,在原文件中完成定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget { // still in "widget.h"
public:
… // other funcs, as before
Widget(const Widget& rhs); // declarations only
Widget& operator=(const Widget& rhs);
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};
#include "widget.h" // as before,
… // in "widget.cpp"
struct Widget::Impl { … }; // as before
Widget::~Widget() = default; // other funcs, as before
Widget::Widget(const Widget& rhs):pImpl(std::make_unique<Impl>(*rhs.pImpl)){}
Widget& Widget::operator=(const Widget& rhs){
*pImpl = *rhs.pImpl;
return *this;
}

值得注意的是,我们在copy操作中调用的是impl所具备的copy操作,从而确保了拷贝的正确性,此外,在构造函数中使用了make_unique以取代new。


Pimpl与shared_ptr

 
unique_ptr应用于Pimpl十分自然,这体现了资源的独占性,但如果我们将shared_ptr应用于Pimpl,则不必理会本小节所提倡的“将special function在头文件中声明,源文件中定义”,也就是说,下述代码是完全正确的:

1
2
3
4
5
6
7
8
class Widget { // in "widget.h"
public:
Widget();
… // no declarations for dtor or move operations
private:
struct Impl;
std::shared_ptr<Impl> pImpl; // std::shared_ptr instead of std::unique_ptr
};

客户端执行下述程序并不会报错:
1
2
3
Widget w1;
auto w2(std::move(w1)); // move-construct w2
w1 = std::move(w2); // move-assign w1

造成这一行为的原因很简单:unique_ptr的删除器是自身的一部分,而shared_ptr不是。为了保证高效性,unique_ptr删除器必须保证raw pointer为complete,而shared_ptr无需这种保证。


总结

  1. Pimpl可以保证编译分离。
  2. Pimpl中若是使用unique_ptr,则必须在头文件中声明special function,在源文件中定义special function。
  3. 第2条规则并不适用于shared_ptr。

21.以make_unique或make_shared取代直接使用new

Posted on 2018-07-06 | In Effective Modern C++

三大make函数

 
C++11加入了make_shared,C++14加入了make_unique,如果你处于C++11环境也不必担心,因为make_unique很容易实现:

1
2
3
4
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只是将其参数完美转发给待创建对象的构造函数,并返回一个由原始指针初始化得到的unique_ptr。 这种实现形式并不支持数组或自定义删除器,但它至少表明实现make_unique并不困难。

std::make_unique和std::make_shared是三个make函数中的两个:接受任意参数集,并将它们完美转发给动态分配对象的构造函数,并返回指向该对象的智能指针。第三个make函数是std::allocate_shared,其作用类似于std::make_shared,不过它的第一个参数是一个用于动态内存分配的allocator对象。


make函数的优越性

代码明晰程度

就算不看性能,光看代码也能看出make函数较为优越:

1
2
3
4
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的版本需要陈述两次对象类型,这违背了软件工程的基本原则:应尽量避免代码重复。源代码中的重复会增加编译时间,从而导致生成庞大的目标码,并且通常会使代码难以使用。它经常会生成不一致的代码,而这种不一致性是某些bug的起源所在。最后,使用make函数打字也省力一些。


异常安全性(可见Effective C++ Item 17)

make函数的优越性也与异常安全有关。假设我们当前有一个函数负责处理某些具备优先级的Widget:

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

值传递std::shared_ptr看起来似乎很奇怪,但是Item41中解释说,如果processWidget总是复制std::shared_ptr(例如将其存储在某个跟踪已处理Widget的数据结构中),这是一个合理的选择。
更进一步地假设我们有一个计算优先级的函数:
1
int computePriority();

我们将其返回值与new操作得到的shared_ptr作为参数传递给processWidget:
1
processWidget(std::shared_ptr<Widget>(new Widget),computePriority()); // potential resource leak

为何可能会发生资源泄露与编译器将源代码转换为目标代码的过程有关。在运行期必须保证一个函数的参数在调用该函数前被明确evaluate,因此在调用processWidget之前,必须执行以下操作:

  1. new Widget必然会执行,届时会在堆上创建一个Widget对象。
  2. shared_ptr的构造函数必然会执行,其以new的返回值为初始值。
  3. computePriority必然会执行。

编译器并不保证按照上述顺序执行程序。 显然,必须在调用std::shared_ptr构造函数之前执行”new Widget”,但是computePriority可以发生在任何时期,比如说在步骤1之前,又或者步骤1、2之间。假设编译器按照以下顺序执行程序:

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

如果在运行时,computePriority抛出了异常,则步骤1中动态分配的Widget将被泄漏,因此它将永远无法被访问到。
若使用std::make_shared则可以完美避开上述问题:

1
processWidget(std::make_shared<Widget>(),computePriority()); // no potential resource leak

在上述代码运行时,将首先调用make_shared或computePriority。如果首先调用了make_shared,则动态分配的资源必然已被置入智能指针,此时computePriority抛出异常会导致shared_ptr发生析构,资源被合理释放。若是先调用computePriority,则make_shared不会执行,资源泄露无从谈起。make_unique亦是同理。


效率

std::make_shared相较于new而言效率更高,使用std::make_shared允许编译器生成更小,更快的代码,使用更精简的数据结构。若我们直接使用new operator:

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

显然,上述程序需要执行内存分配操作,但实际上它执行了两次内存分配。在Item19中我们曾经提及每个std::shared_ptr都指向一个control block,其中包含指向对象的引用计数。 此control block的内存在std::shared_ptr构造函数中分配。此外,new operator将为Widget对象分配一块内存,因此一共执行了两次内存分配。
我们现以make_shared函数替换new:
1
auto spw = std::make_shared<Widget>();

如此一来,一次内存分配足矣。std::make_shared一次性分配了Widget与control block的内存,这种优化减少了程序的静态大小(因为代码只包含一个内存分配调用),并且它增加了可执行代码的速度,因为内存只分配了一次。此外,使用std::make_shared不需要control block中的某些bookkeeping information,可能会减少程序的总内存占用量。std::allocate_shared效率分析亦类似于std::make_shared。


make函数的不足

 
尽管make函数无论是代码可读性、异常安全性、效率均优于new,但其存在使用条件,在某些情况下make函数无法使用。

自定义删除器

举例而言,make函数不支持自定义删除器,若当前存在删除器如下所示:

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

使用new来创建一个具备自定义删除器的智能指针很容易:
1
2
std::unique_ptr<Widget, decltype(widgetDeleter)> upw(new Widget, widgetDeleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

make函数却不支持这种行为。

大括号初始化器

make函数的第二个限制源于其实现的语法细节。在Item7中我们曾经明确说明,若一个对象存在参数为initializer_list类型的构造函数,那么当构造实参带有大括号时几乎必然会调用该函数,而带有小括号的构造实参则会调用其他构造函数。make函数负责将实参完美转发给智能指针的构造函数,但这些实参是带有大括号还是小括号make函数却一无所知。对于某些类型而言,构造时使用大括号还是小括号将导致完全不同的结果,例如:

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

到底应该是生成一个内含10个元素,其值均为20的vector,还是生成内含10、20两个元素的vector?又或者说结果是不确定的?有一个好消息和一个坏消息在等着我们。
好消息是答案并非不确定:两个调用都将创建一个智能指针指向一个内含10个元素的vector,这意味着在make函数中完美转发使用小括号而不是大括号。坏消息是如果你要使用大括号实参构造指向对象,则必须直接使用new opertaor来完成。由此看来,使用make函数需要支持完美转发的大括号初始化,但Item30中将会提及,大括号初始化并不支持完美转发。但Item30描述了一种解决方案:使用auto类型推衍利用大括号初始化器创建std::initializer_list对象,然后通过make函数传递auto对象(显式地以initializer_list作为构造实参):
1
2
3
4
// create std::initializer_list
auto initList = { 10, 20 };
// create std::vector using std::initializer_list ctor
auto spv = std::make_shared<std::vector<int>>(initList);

对于unique_str而言,make函数仅在上述两个场景下存在些许问题,但对于shared_ptr而言,还有两个问题需要解决。

自定义内存管理

对于某些自定义了opertaor new与operator delete的class而言,使用全局new与delete似乎不太合适。一般而言,new与delete负责分配或释放指定大小(通常为sizeof(Widget))的内存,但shared_ptr并非如此,它所需要分配与释放的不仅仅是对象的大小,还需要加上一个control block的大小。因此,使用make函数创建具备自定义内存管理的对象是一个十分糟糕的想法。

std::make_shared的速度优势来源于在分配对象所需要的内存时control block所需要的内存也被一并放置,但我们所需要明确的是,当对象被析构时其所占内存并未立即释放,因为此时control block所占用的内存尚未被释放。

内存释放延迟

正如之前指出,control block中含有两个引用计数相关信息,其中第二个被称为弱引用计数,当一个weak_ptr检查其是否处于空悬状态时,它通过检查第一个引用计数(而非若引用计数)来确保正确性,如果引用计数为0,weak_ptr即为空悬,否则不是。但control block所占用内存的释放与弱引用计数有关,也就是说,即使对象已经析构,但只要仍然存在一个weak_ptr指向它(弱引用计数不为0),该control block的内存便需要一直存在,直到最后一个指向shared_ptr的weak_ptr被析构。如果对象非常大,销毁最后一个shared_ptr与weak_ptr之间又存在着大量时间,那么可能在内存释放期间会出现延迟:

1
2
3
4
5
6
class ReallyBigType { … };
auto pBigObj =std::make_shared<ReallyBigType>();
… // create std::shared_ptrs and std::weak_ptrs to large object, use them to work with it
… // final std::shared_ptr to object destroyed here,but std::weak_ptrs to it remain
… // during this period, memory formerly occupied by large object remains allocated
… // final std::weak_ptr to object destroyed here;memory for control block and object is released

使用new则可以完成瞬间内存释放。


在无法使用make函数时的替代

 
如果当前并不适合使用std::make_shared,我们需要考虑异常安全性问题。最好的解决方案方法是确保直接使用new时,立即将结果传递给智能指针构造函数,该语句不执行任何其他操作(即Effective C++ Item17所述)。仍以具备自定义删除其的Widget为例:

1
2
void processWidget(std::shared_ptr<Widget> spw,int priority);
void cusDel(Widget *ptr); // custom deleter

以下为一个不具备异常安全性的调用:
1
processWidget(std::shared_ptr<Widget>(new Widget, cusDel),computePriority());

如果我们将其改写为:
1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); // correct, but not optimal; see below

这段代码具备异常安全性,因为即使spw构造失败(例如无法为control block分配内存),仍然能够通过自定义删除器析构刚刚构造的widget对象,只是其性能还可以被改善。

在刚才不具备异常安全性的程序中,我们试图将一个右值传递给ProcessWidget,但在异常安全的版本中,我们传递的是一个左值。因为processWidget中的参数pass-by-value,因此右值的构造只需要移动,而来自左值的构造需要复制,这一点对于std::shared_ptr可能会带来很大的差异,因为复制std::shared_ptr时其引用计数自增是原子操作,而移动std::shared_ptr则根本不需要关心引用计数。我们将原先的左值转为右值可以大幅度提升性能:

1
processWidget(std::move(spw),computePriority());


总结

  1. 与直接使用new相比,make函数不存在代码重复且具备异常安全性,std::make_shared和std::allocate_shared可以生成更快更短的目标码。
  2. 在需要自定义删除器及以大括号初始化时make函数无能为力。
  3. 对于shared_ptr,具备自定义内存管理与weak_ptr生存期比shared_ptr长的情况亦不可使用make函数。

20.类shared_ptr但可空悬的智能指针——std::weak_ptr

Posted on 2018-07-05 | In Effective Modern C++

前言

 
有时我们会想,如果有一种行为类似于shared_ptr,但不会参与资源共享权的智能指针该有多好。换而言之,我们需要一种不改变引用计数的shared_ptr。这种智能指针主要负责解决一样问题:指出shared_ptr指向的资源是否已遭到破坏。一个真正智能的指针可以通过跟踪它何时空悬来处理这个问题,这正是weak_ptr的精义所在。你可能想知道std::weak_ptr究竟有何用途,但你在查看其API时会发现它既不能解引用也不能检测资源是否为NULL,因为weak_ptr并非是一个独立的智能指针,它是shared_ptr的扩充形式。


weak_ptr

 
weak_ptr通常由shared_ptr创建,它们指向同样的资源,其区别在于weak_ptr不会改变该资源的引用计数:

1
2
3
auto spw = std::make_shared<Widget>();// RC is 1
std::weak_ptr<Widget> wpw(spw);// RC remains 1
spw = nullptr;// RC goes to 0, and the Widget is destroyed.wpw now dangles

你可以通过weak_ptr的成员函数来查看其是否空悬:
1
if (wpw.expired()) … // if wpw doesn't point to an object…

但通常我们不仅仅想要查看其是否处于空悬状态,而是需要如果其不处于空悬状态,则直接使用它所指向的资源。但说起来容易做起来难,原因在于weak_ptr不存在解引用操作,就算它有解引用操作,如上文一般将空悬检查和解引用相分离也可能会导致问题产生:例如当前存在另一个线程对指向资源的最后一个std::shared_ptr进行了赋值或析构操作,从而导致资源释放。在此环境下,解引用操作将导致未定义行为。

为了确保线程安全,我们真正需要完成的是一个原子操作,它检查std::weak_ptr是否空悬,如果没有,则访问它指向的对象。 访问对象这一行为可以通过从std::weak_ptr创建std::shared_ptr来完成。该操作有两种形式,具体采用哪一种形式取决于你在明确指针空悬后会采取何种措施。
一种形式是std::weak_ptr::lock,它返回一个std::shared_ptr。如果std::weak_ptr已经空悬,则std::shared_ptr为null:

1
2
std::shared_ptr<Widget> spw1 = wpw.lock(); // if wpw's expired,spw1 is null
auto spw2 = wpw.lock(); // same as above,but uses auto

另一种形式是以weak_ptr为实参构造shared_ptr,如果weak_ptr已然空悬,则抛出异常:

1
std::shared_ptr<Widget> spw3(wpw); // if wpw's expired,throw std::bad_weak_ptr


问题实例一(工厂模式)

 
我们将以实例分析和探讨weak_ptr的真正使用场景,假设当前一个工厂函数,它根据唯一ID生成只读对象的智能指针。根据Item 18关于工厂函数返回类型的建议,它返回一个std::unique_ptr:

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

如果loadWidget调用成本较为昂贵(例如执行文件或数据库I/O),并且重复使用ID这一行为较为常见(即经常需要获取指定ID的对象),那么我们可以采用一种合理的优化方式:cache。将所有Widget都置入缓存也可能会导致性能问题,因此我们也应当保证在适当时段删除缓存。
对于缓存式工厂函数,其返回值仍采用unique_ptr已然不再合适,调用者当然需要指向资源的智能指针,但调用者也需要了解资源是否已经遭到释放,cache里存放的智能指针应当具备检测空悬的能力(以便及时地清空缓存),因此缓存中存储的智能指针应当为weak_ptr类型,而工厂函数返回值应当为shared_ptr类型,因为weak_ptr只能检测shared_ptr所管理资源的空悬性,这是loadWidget的缓存版本的快速实现:
1
2
3
4
5
6
7
8
9
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id){
static std::unordered_map<WidgetID,std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) {// not in cache
objPtr = loadWidget(id);//load it
cache[id] = objPtr;//cache it
}
return objPtr;
}

这个实现显然可以进一步改进,因为我们的cache目前只做到了增加元素,实际上还可以在检测到资源破坏后从cache中删除指定元素,但这个实现与本节主题无关,因此有兴趣者可自行尝试。


问题实例二(观察者模式)

 
让我们现在将目光转向第二个实例:Observer设计模式。该模式的主要组成部分是主体(状态可能发生变化的对象)和观察者(发生状态变化时要通知的对象)。在该模式的大多数实现中,每个主体包含一个数据成员,该成员持有指向其观察者的指针,这使得主体可以轻松发出当前状态已更改的通知。主体对观察者的生命周期毫无兴趣(即主体并不需要了解当前观察者的状态),但我们至少需要撰写某种操作保证当某观察者已被破坏时,主体从观察者列表中将其删除。一个合理的设计是每个主体均持有一个容器,其容器内部元素为指向观察者的std::weak_ptrs,从而使得主体可以判断观察者是否已经失效。


问题实例三

 
考虑一个包含对象A,B,C的数据结构,其中A和C共享B的所有权,因此我们使用shared_ptr:
image_1chkntif01p2nnas112q152r1hc229.png-14.3kB
假设我们又发现B中也需要存在一个指向A的指针,那它应当是何种类型?
image_1chkohht0hdb1p4d71e1rl41c562m.png-20kB
主要有三种选择:

  1. 原始指针
    如果使用原始指针,如果A已被销毁,但仍存在C继续指向B,而B中又包含指向A的指针。B无法确定A是否已经析构,因此可能会触发解引用空悬指针的行为,这将导致未定义后果。
  2. std::shared_ptr
    在此设计中,A和B中包含指向彼此的std::shared_ptrs。这种彼此指向的行为将阻止A和B被析构。即使A和B无法被其他数据结构访问(例如C不再指向B),A与B仍然具备引用计数为1。这种情况也是一种内存泄漏,因为A与B永远无法被访问,但分配给它们的资源将永远无法回收。
  3. std::weak_ptr
    使用weak_ptr完美避免了上述两个问题。如果A已被析构,B指向它的指针将保持空悬状态,并且B能够检测空悬。此外,虽然A和B指向彼此,但B并不对A存有任何引用计数,因此仍然可以析构自如。

值得注意的是,需要使用std::weak_ptr取代std::shared_ptr的常见并不多见。在严格的分层数据结构(例如树)中,子节点通常仅由其父节点拥有。当父节点被销毁时,其子节点也应该被销毁。因此,父母与孩子之间的链接通常最好由std::unique_ptrs表示。此外,从子节点到父节点的反向链接可以安全地使用原始指针表示,因为子节点的生命周期永远不会超过其父节点。 因此,没有子节点解引用空悬父指针的风险。


weak_ptr的效率

 
从效率的角度而言,std::weak_ptr与std::shared_ptr基本保持一致。std::weak_ptr对象与std::shared_ptr对象大小相同,并且它们也涉及control block,构造,析构和赋值等操作也需要atomic引用计数操作。在本节开头我们曾说weak_ptr不使用引用计数,这说明的是weak_ptr不涉及shared_ptr的引用计数,实际上control block中有专门关于weak_ptr的引用计数(详见Item21)。


总结

  1. 你可以将weak_ptr视为可空悬的shared_ptr。
  2. weak_ptrs的使用场合包括cache、观察者模式、以及打破shared_ptr循环。

19.在进行共享式资源管理时使用std::shared_ptr

Posted on 2018-07-05 | In Effective Modern C++

前言

 
有人认为C++的手动内存回收机制过于原始,这钟说辞相当正确,但是否存在一种方式,可以保证C++既可以自动完成内存回收,又能够明确它们释放的时间?


shared_ptr

 
std::shared_ptrs所指向的对象,其生命周期由这些共享其所有权的指针共同管理,不存在某个特定的std::shared_ptr拥有该对象。同时,所有指向该对象的std:: shared_ptrs都将保证在该对象不再需要时对其进行销毁。当指向对象的最后一个std :: shared_ptr停止指向那里时(例如该std::shared_ptr被销毁或被指向另一个对象),此时该std::shared_ptr会析构它指向的对象。类似于内存回收机制,客户端无需关心管理对象的生命周期,但这种行为同时也兼具析构函数的特点,即对象销毁时间是确定的。

std::shared_ptr通过查询资源的引用计数来判断它是否是指向该资源的最后一个sp指针,当执行构造函数时通常会导致引用计数自增,析构时自减,执行copy assignment operator同时执行二者(原有资源引用计数自减,新资源引用计数自增)。当std::shared_ptr在执行递减后发现当前引用计数为0,则表明此时不再有任何std::shared_ptrs指向该资源,那么shared_ptr则会执行析构。


引用计数对性能的影响

 
shared_ptr中的引用计数对性能存在如下影响:

  1. shared_ptr大小是原始指针的两倍
    原因在于shared_ptr持有两个指针,一个指针指向资源,另一个指向引用计数。
  2. 必须动态分配引用计数的内存
    理论上引用计数与资源相关联,但资源自身对此却一无所知,因此,资源不会存储引用计数(这也意味着任何对象,甚至是内置类型的对象也可由shared_ptr管理)。无论哪种方式,引用计数都存储为动态分配的数据。动态分配需要成本,Item21阐明当使用std::make_shared创建std::shared_ptr时可以避免该开销,但并非任何情况都可以使用make_shared。总之,无论采取何种方式,引用计数都必须存储于动态分配的内存中。
  3. 引用计数的自增与自减行为必须是atomic
    不同的线程之中可能同时存在着reader与writer。举例而言,线程A中,指向某资源的std::shared_ptr正在执行析构函数(引用计数自减),而在线程B中,存在一个shared_ptr被复制(引用计数自增)。atomic操作通常慢于non-atomic操作,因此即使引用计数大小通常只是一个word,我们也应当认为其读取和写入操作成本较高。

shared_ptr的创建

 
在前文中我们提到,shared_ptr在创建时通常会自增引用引用,那为什么不是总是自增引用计数呢?原因在于move constructor。在执行移动构造时,源shared_ptr会被置为null,而引用计数不需要发生任何改变。因此,移动std::shared_ptrs比复制它们要快:move construct优于copy construct,move assignment优于copy assignment。


shared_ptr的删除器

 
类似于unique_ptr,shared_ptr也使用delete作为默认删除器,并且支持自定义删除器行为,不过对于这一特性,shared_ptr的设计与unique_ptr的设计颇有不同。对于unique_ptr而言,删除器是智能指针的一部分,但对于shared_ptr而言并非如此:

1
2
3
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<lWidget>,每个都有一个不同类型的自定义删除器(其原因可能是自定义删除器是一个lambda):

1
2
3
4
auto customDeleter1 = [](Widget *pw) { … }; // custom deleters,
auto customDeleter2 = [](Widget *pw) { … }; // each with a different type
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

因为pw1和pw2具备相同的类型,因此它们可以放在同一个容器之中:
1
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

pw1与pw2也可以相互赋值,并且可以都能够作为实参传递给一个形参类型为 std::shared_ptr<Widget>的函数。对于自定义删除器类型不同的std::unique_ptrs,上述操作均无法实现,因为自定义删除器的类型会影响std::unique_ptr的类型。此外,自定义删除器不会更改std::shared_ptr对象的大小。无论采用何种删除器,std::shared_ptr对象的大小始终为两个指针大小。但std::shared_ptr如何在不使用任何额外内存的情况下引用任意大小的删除器?


control block

 
事实上shared_ptr仍然需要额外的内存以放置与使用删除器,只是这块内存并非std::shared_ptr对象的一部分。它位于堆上,或者是allocator分配的内存所在的位置(如果shared_ptr使用了自定义allocator)。前文曾经提到,std::shared_ptr对象包含一个指向引用计数的指针,但实际上该指针其实指向的是一个更大的数据结构control block,引用计数是它的一部分。std::shared_ptrs所管理的每个对象都具备一个control block。除引用计数之外,control block还包含自定义删除程序的副本(如果拟制定了自定义删除器的话)。如果你在定义shared_ptr时指定了自定义分配器,则control block也包含该副本。control block还可以包含附加数据(见Item21),但这一节我们先不做理会。我们可以设想与std::shared_ptr <T>对象相关联的内存看起来如下所示:
image_1chk5vpusl9c187q1pa7o9p16si1p.png-48kB


control block创建规则

 
理论上而言,对象的controol block应当由第一个指向该对象的std::shared_ptr设置,但在实际情况中,构建std::shared_ptr的函数不可能明确是否已经存在其他std::shared_ptr已指向该对象,因此control block有如下创建规则:

  1. std::make_shared(见Item21)总是创建一个control block
    它生成一个需要被指向的新对象,因此在调用std::make_shared时肯定没有该对象的控制块。
  2. 当std::shared_ptr的构造源是唯一所有权指针(即std::unique_ptr或std::auto_ptr)时,创建一个control block
    唯一所有权指针并不需要control block,因此需要创建,此外,当shread_ptr被构建完毕后,唯一所有权指针将会被置为null。
  3. 当以原始指针作为实参调用std::shared_ptr构造函数时,创建一个control block
    如果你想通过一个已有control block的对象创建一个std::shared_ptr,你可能会将std::shared_ptr或std ::weak_ptr(见Item20)作为实参传递给shared_ptr的构造函数,而不是使用原始指针。事实上,当它们作为实参构造shared_ptr时不会构造control block。

这些规则可能会触发一些不良后果,假设你以一个原始指针创建了多个shared_ptr,那么将会产生多个control block,从而触发多次析构:

1
2
3
4
5
auto pw = new Widget; // pw is raw ptr
…
std::shared_ptr<Widget> spw1(pw, loggingDel); // create control block for *pw
…
std::shared_ptr<Widget> spw2(pw, loggingDel); // create 2nd control block for *pw!

为动态创建的对象分配一个原始指针是一种不良行为,这完全违背了本章的主旨:以智能指针取代原始指针。但这充其量只是一种不正确的编程风格,因为它至少没有主动引起未定义行为。在刚才的代码示例中,一个对象有两个相关联的control block,这必然会导致该对象会被析构两次,在第二次析构时将会导致未定义行为。

刚代码示例至少蕴含两个启示:

  1. 应当尽量避免使用原始指针创建shared_ptr
    上述程序之所以不使用make_shared,是因为我们使用了自定义删除器的原因。
  2. 如果一定要以原始指针初始化shared_ptr,则直接以new表达式初始化之
    如果示例中的代码被重写为:
    1
    std::shared_ptr<Widget> spw1(new Widget,loggingDel);
    当我们需要创建第二个shared_ptr时,则通常会选择使用copy construct:
    1
    std::shared_ptr<Widget> spw2(spw1); // spw2 uses same control block as spw1
    如此则不会产生任何问题。

问题实例

 
假设我们的程序使用std::shared_ptrs来管理Widget对象,并且我们有一个数据结构来跟踪被处理的Widgets:

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

进一步地假设Widget具备一个负责处理的成员函数:
1
2
3
4
5
6
class Widget {
public:
…
void process();
…
};

process一种可能的实现方式如下所示:
1
2
3
4
void Widget::process(){
… // process the Widget
processedWidgets.emplace_back(this); // add it to list of processed Widgets
}

关于emplace_back可见Item42.此实现的最大错误在于将this,也就是原始指针作为参数传入容器,从而直接导致关于某Wideget可能存在多个control block。


解决方案(std::enable_shared_from_this)

 
std::shared_ptr早已考虑到这种情况,并为之提供了API,只是名字有点奇怪:std::enable_shared_from_this。如果你希望std::shared_ptrs管理的类能够通过this指针安全地创建std::shared_ptr,那么你必须令你的class继承此基类模板,例如:

1
2
3
4
5
6
class Widget: public std::enable_shared_from_this<Widget> {
public:
…
void process();
…
};

std::enable_shared_from_this是一个基类模板,它的类型参数始终是派生类的名称,基类决定于派生类这一行为十分奇怪,但这段代码完全合法,甚至存在一个与之相关的设计模式Curiously Recurring Template Pattern (CRTP)。(实际上在Effective C++ Item49中已有涉及)

std::enable_shared_from_this定义了一个成员函数,它可为当前对象创建一个std::shared_ptr,但该函数不会造成重复的control block。该成员函数名为shared_from_this,其隐含实参为*this,返回由*this初始化的shared_ptr,其使用示例如下:

1
2
3
4
5
6
void Widget::process(){
// as before, process the Widget
…
// add std::shared_ptr to current object to processedWidgets
processedWidgets.emplace_back(shared_from_this());
}


shared_from_this实现

 
shared_from_this会查找当前对象的control block,并创建一个引用该control block的新std::shared_ptr。显而易见,该函数要求当前必须已经存在一个shared_ptr指向该对象,如果不存在(即当前对象并无相关control block),则其行为未定义(事实上会抛出一个异常)。

为了防止客户在尚未存在std::shared_ptr指向*this之前调用shared_from_this,继承自std::enable_shared_from_this的类通常将其构造函数声明为private,并让客户通过调用返回std::shared_ptrs的工厂函数来创建对象,举例而言,Widget的实现可能如下所示:

1
2
3
4
5
6
7
8
9
10
class Widget: public std::enable_shared_from_this<Widget> {
public:
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
…
void process(); // as before
…
private:
… // ctors
};


shared_ptr使用成本

 
control block的大小通常只有几个word,不过自定义删除器和分配器可能会令它较大一些。一般而言,control block的实现比人们想象地更加复杂,它可能会用到继承甚至虚函数的概念(确保对象可被正确析构),这意味着使用std::shared_ptrs也需要付出虚函数所带来的那些成本(详见More Effective C++ Item24)。

在介绍了如此之多的细节后,你可能会对shared_ptr所产生的control block如此复杂难用而感到不满,但实际上针对它们所完成的那些功能,使用shared_ptr绝对物超所值。此外,当你处于典型应用环境(无自定义删除器及allocator)时,control block的大小约为3个word,并且它通常会被合并到对象所占用的内存空间之中(详见Item21)。

解引用std::shared_ptr并不比解引用原始指针所消耗的成本更高。执行需要引用计数的操作(例如copy construct,copy assignment operator,destructor)需要一到两个atomic 操作,但这些操作仅仅相对于non-atomic而言开销较多。conrtrol block中虽然蕴含虚函数,但虚函数的使用频率并不高(实际上仅在析构时才会被使用)。在付出了上述代价后,你收获了内存回收机制,甚至比它还要更加出色一点,我认为这是相当值得的。


shared_ptr与unique_ptr

 
如果你发现当前资源是独占式的,std::unique_ptr相对于shared_ptr无疑是更好的选择,它的性能与原始指针相差无几,并且从std::unique_ptr到std::shared_ptr的转变十分方便。但这种情况反之却并不成立,一旦你将资源的生命周期管理转换为std::shared_ptr,就再也无法收回成命。也就是说,unique_ptr到shared_ptr总是允许的,但发生转变之后就再也不能后悔了(即使当前对象的引用计数为1你也不能将其转交给unique_ptr)。

shared_ptr与unique_ptr的另一点不同是它无法指向数组,也就是说没有std::shared_ptr<T[]>。有人会想到使用自定义删除器来执行数组删除(即delete []),这种程序可以编译,但毫无价值。首先,std::shared_ptr不提供operator[],因此索引到数组需要基于指针算术。另一方面,std::shared_ptr支持单一对象的派生类指针到基类指针之间的转换,但其无法应用于数组之中。STL存在如此之多的容器,使用数组无疑是一种较为不智的设计。


总结

  1. std::shared_ptr为C++开发者提供了接近内存回收机制般的便利。
  2. 相较于unique_ptr,shared_ptr对象通常较大,并且引入了control block与atomic操作(并且导致了性能下降)。
  3. 自定义删除器对shared_ptr的类型并无任何影响。
  4. 应当尽量避免以原始指针初始化shared_ptr。

18.在进行独占式资源管理时使用std::unique_ptr

Posted on 2018-07-04 | In Effective Modern C++

前言

 
unique_ptr的大小与原始指针相同(在大多数情况),操作起来也类似,这意味着你可以某些内存吃紧与实时性较高的场合(例如嵌入式?)中使用它们。


unique_ptr定义

 
std::unique_ptr代表着独占语义。任何一个非空unique_ptr总是唯一拥有其指向资源。对unique_ptr执行移动操作将导致资源所有权从源指针传递到目标指针(源指针被置为null),unique_ptr不允许copy操作,因为这会导致存在两个指针指向同一份资源,与独占性相违。由此可见,unique_ptr是一种move_only type,在它析构时,默认将对其所拥有的原始指针执行delete操作。

问题实例

 
一般来说,unique_ptr常用于作为工厂函数的返回类型(当然,工厂函数并非std::unique_ptrs的唯一常见用例,它们也经常作为Pimpl手法的实现机制而得到使用,具体可见Item22)。
假设当前有一个代表投资的继承体系如下所示:
image_1chigac1e1b531m0r1cs5o81bmj9.png-17.6kB

1
2
3
4
class Investment { … };
class Stock:public Investment { … };
class Bond:public Investment { … };
class RealEstate:public Investment { … };

工厂函数将在堆上生成一个对象并返回一个指向它的指针,调用者负责在不再需要时删除该对象。这正是std::unique_ptr的职责所在:

1
2
3
template<typename... Ts> // return std::unique_ptr
std::unique_ptr<Investment> // to an object created
makeInvestment(Ts&&... params); // from the given args

调用者可在某作用域内使用工厂函数返回值如下所示:
1
2
3
4
5
{
…
auto pInvestment = makeInvestment(arguments); // std::unique_ptr<Investment>
…
}// destroy *pInvestment

但也可以在所有权迁移场景中使用unique_ptr,例如将工厂函数的返回值置入容器,而容器元素又在其后成为了某个对象的data member,其后该对象被销毁。当发生这种情况时,对象的std::unique data member也将被销毁,并且其销毁将导致工厂函数所返回的资源被释放。如果在上述过程中出现了异常或逻辑错误(函数过早返回或循环由于break退出),那么管资源的std::unique_ptr最终将调用其析构函数,释放其托管的资源。

在默认情况下,该资源释放将通过使用delete完成,但在构造过程中,std::unique_ptr对象可以配置自定义删除器:任意函数、函数对象、lambda等等。在unqie_ptr析构之时,这些删除器将负责完成资源的释放操作。举例而言,如果makeInvestment所创建的对象不应该直接删除,而应首先完成日志记录操作,makeInvestment可实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
auto delInvmt = [](Investment* pInvestment) {makeLogEntry(pInvestment);delete pInvestment;};//lambda
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;
}


实现剖析

  1. delInvmt是工厂函数返回对象的自定义删除器,其接受指向应被销毁的对象的原始指针,然后执行销毁该对象所需的操作。 在本例中,销毁操作时调用makeLogEntry然后应用delete。使用lambda表达式创建delInvmt十分便捷,但实际上它并不止便捷那么简单。
  2. 当需要使用自定义删除器时,必须将其类型指定为std::unique_ptr的第二个模板类型参数。因此在本例中makeInvestment的返回类型是std::unique_ptr<Investment,decltype(delInvmt)>。
  3. 将原始指针(例如从new得到的资源)对std::unique_ptr进行赋值将无法编译,因为这一行为构成了从原始指针到智能指针的隐式转换,C++11禁止这种隐式转换,因此使用reset成员函数。
  4. 每次调用new时,我们都使用std::forward来实现完美转发makeInvestment获得的参数(见Item25)。这一行为使得调用者提供的所有信息都可被正在创建的对象的constructor所采纳。
  5. 为了保证删除器能够正确完成析构操作,基类必须具备virtual析构函数(因为我们是通过一个指向基类的指针来执行delete):
    1
    2
    3
    4
    5
    6
    class Investment {
    public:
    … // essential
    virtual ~Investment(); // design
    … // component!
    };

C++14提供的工厂函数实现

 
C++14支持返回值类型推衍,因此C++14中工厂函数的实现可如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename... Ts>
auto makeInvestment(Ts&&... params){
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 ( … ){
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( … ){
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}


自定义删除器对unique_ptr的影响

 
前文曾经提及,当使用默认删除器(即delete)时,可以合理地假设std::unique_ptr对象与原始指针的大小相同。但一旦存在自定义删除器,那么该假设一般不再成立。如果删除器是函数指针类型,含有自定义删除器的std::unique_ptr大小往往从1个word增长至2个word。如果删除器是函数对象,那么unique_ptr增长的大小视函数中状态的多少而定。无状态函数对象(例如没有捕获的lambda表达式)不会导致unique_ptr的大小发生变化,这意味着当自定义删除器可实现为函数或无捕获的lambda表达式时,lambda更为可取:

1
2
3
4
5
6
7
8
9
10
11
auto delInvmt1 = [](Investment* pInvestment){makeLogEntry(pInvestment);delete pInvestment;}; 
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt1)>
makeInvestment(Ts&&... args);
void delInvmt2(Investment* pInvestment){// function object
makeLogEntry(pInvestment);
delete pInvestment;
}
template<typename... Ts>
std::unique_ptr<Investment,void (*)(Investment*)>
makeInvestment(Ts&&... params);

具有状态的函数对象删除器可能会生产较大的unique_ptr对象。如果你发现自定义删除器std::unique_ptrs大到了不可接受的程度,则可以考虑更改删除器类型。


unique_ptr与数组

 
std::unique_ptr存在两种形式,一种用于单个对象(std::unique_ptr<T>),另一种用于数组(std::unique_ptr<T[]>)。因此,对于std::unique_ptr指向的实体类型从不存在任何歧义。std::unique_ptr的API视当前使用情况而定。例如,指向单一对象时unique_ptr没有operator[],而指向数组时则缺少operator*和operator->。

但指向数组的unique_ptr并无必要,因为STL中的容器比数组好用太多,std::unique_ptr<T[]>唯一有存在价值的情况就是当你使用类C的API时,它会返回一个原始指针,指向你独占的堆数组。


unique_ptr与shared——ptr

 
std::unique_ptr最吸引人的特性之一是它可以轻松高效隐式地转换为std::shared_ptr:

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

这即是std::unique_ptr适合作为工厂函数返回类型的关键原因所在。工厂函数无法知道调用者是否希望对返回的对象使用独占语义,或者共享语义。通过返回std::unique_pt,工厂函数得以保证当调用者需要实现共享语义时可以轻松从unique_ptr转换得到shared_ptr。


总结

  1. std::unique_ptr是一种小型、快速、move-only的智能指针,其在管理资源时具备独占语义。
  2. 在默认情况下,资源释放通过delete完成,但用户亦可自定义删除器。存有状态的删除器和函数指针会增加std::unique_ptr对象的大小。
  3. 将std::unique_ptr转换为std::shared_ptr十分容易。

智能指针概述

Posted on 2018-07-04 | In Effective Modern C++

一般来说,原始指针有以下缺点:

  1. 不能从声明中推断出它到底是指向单一对象还是数组。
  2. 不能从声明中推断出当你使用完毕后是否应当销毁其指向对象(即不能确定是否只有该指针指向那份资源)。
  3. 如果你确定应当销毁指针所指物,你无法明确当以何种方式完成销毁(使用delete还是存在专有销毁函数)。
  4. 如果你明确应当使用delete完成销毁,你也无法确定应当使用delete还是delete[]。
  5. 就算你解决了上述问题,你也无法保证销毁程序能够正确执行,可能它会被执行多次,也可能一次都不会执行。前者会导致未定义行为,后者则将导致资源泄漏。
  6. 无法判断当前指针是否空悬。

无疑,原始指针是相当强大的工具,但多年经验表明,好用者实繁,善用者盖寡,为了解决上述问题,C++引入了智能指针。智能指针是原始指针的外覆器,它们的行为与原始指针非常相似,但可以避开许多陷阱。

C++11有四个智能指针:std::auto_ptr,std::unique_ptr,std::shared_ptr与std :: weak_ptr。这些智能指针均旨在帮助管理动态分配对象的生命周期,即通过保证在适当的时间(包括异常情况下)以适当的方式销毁这些对象来避免资源泄漏。

std::auto_ptr是C++98设计下的失败品,C++11中的std::unique_ptr才是它的完全形态。理论来说,auto_ptr需要使用移动语义,但遗憾的是C++98中移动语义并不存在,因此 std::auto_ptr不得不使用copy来模拟move。这一特性直接导致auto_ptr存在一些奇怪的行为,例如auto_ptr拷贝会将其置为null,因此也无法将auto_ptr置于容器之中。std::unique_ptr能做所有auto_ptr能做的事,而且不存在那些约束,除非你是在C++98编译环境下,否则绝无使用auto_ptr的必要。

不同的智能指针API各不相同,可能唯一的共同点仅在于他们均存在默认构造函数。由于这些API的综合参考资料俯拾皆是,因此本章将重点讨论API概述并不关注之处,例如值得注意的用例,运行成本分析等等。通过掌握此类信息,开发者可以更加了解智能指针之间的区别以及如何高效地使用它们。

17.了解自动生成的特殊成员函数

Posted on 2018-07-04 | In Effective Modern C++

前言

 
按照官方说法,所谓特殊成员函数就是C++自动生成的函数。在C++98中有4种这样的函数(它们自动生成的条件可见Inside the C++ Object Model):

  1. default constructor
  2. default destructor
  3. default copy constructor
  4. default copy assignment operator

一般而言,自动生成的此类特殊函数将隐式地具备public && inline,并且为non-virtual(析构函数存在一个意外,如果该类继承自一个具备virtual destructor的基类,则自动生成的default destructor具备virtual属性)。

上述内容你可能已经完全掌握,但在C++11中它们发生了一些变化,了解这些新的规则对C++高效编程至关重要。


move constructor and move assignment

 
C++11的特殊成员函数相较于C++98多出了两个新人:move constructor与move assignment,它们的签名如下所示:

1
2
3
4
5
6
7
class Widget {
public:
…
Widget(Widget&& rhs); // move constructor
Widget& operator=(Widget&& rhs); // move assignment operator
…
};

生成它们的规则类似于copy,move操作仅在它们需要时才被生成,其行为是对rhs的所有non-static data member执行move操作,此外,它们对rhs中的基类部分同样执行move操作。

严格意义上来说,move constructor与move assignment更像是一种请求,我们无法保证必然会发生move行为。这种原因很简单,C++98中大量的历史遗留类型并不支持move操作,,因此它们实际上通过copy操作完成了move行为。move行为的核心操作是对所有需要移动的数据执行std::move,并且在函数重载期内根据实际情况判断到底是使用copy还是move(Item23 将会对此进行详尽分析)。我们现在所需要了解的就是对所有能move的执行move,不能move的执行copy。


move操作的生成

 
类似于copy,自动生成move操作仅发生于你并未手动地声明它,但其生成条件和copy相比还是略有不同。

两个copy操作相互独立,也就是说声明其中的一个对另一个并无任何影响。如果你声明了一个copy constructor但没有声明copy assignment,那么编译器将会在需要的时候自动定义与声明copy assignment,反之亦是如此。

两个move操作并不独立,其中的任何一个被声明都会阻止编译器生成另外一个。理由很简单,如果你声明了一个移动构造函数,那你至少应当指出如何实现移动构造以及它与编译器默认memberwise move版本的不同之处。如果 memberwise move constructor并不适用于该类,那么几乎必然memberwise move assignment也不适用。同理,声明move assignment也会阻止move constructor的生成。

此外,move操作不会在任何显式声明了copy操作的类中生成。声明copy操作的原因是因为memberwise copy并不适用于该类,因此编译器会认为既然memberwise copy并不适合,那么memberwise move必然也不适合该类。这引发了另一个话题,如果一个类中声明了move操作,那么编译器将禁止自动生成copy操作,原因在于memberwise move无法适用的类想必也无法使用memberwise copy。这听起来可能会破坏C++98中的旧有程序,因为C++11中自动生成copy操作的条件似乎更加苛刻。但实则并非如此,C++98代码中不会出现move操作,因为C++98根本就不存在“move object”。遗留代码只有在经过符合C++11标准的修改之后才会具备移动语义。


三/五法则(C++ Primer P477)

 
也许你曾经听说过一条被称为“Rule of Three”的指导方针,该指导方针规定,copy constructor,copy assignment opertaor或destructor的声明总应该一起出现,不允许出现任何遗漏。该原则的背景在于,手动声明copy操作几乎必然是因为当前类负责资源管理,而资源管理类又存在如下特征:

  1. copy操作几乎不可能只使用一个(copy constructor中的行为也需要被应用于copy assignment opertaor之中)。
  2. 析构函数负责资源的释放。

一般来说需要管理的资源都是指内存,这也就是为何所有负责内存管理的标准库类均声明有这三件套的原因。

自定义destructor表明单纯的memberwise copy行为可能并不适用于该类,因此一旦存在用户自定义destructor,编译器将不会自发生成任何copy操作。在C++98被创建时这条规则尚未得到清楚认知,因此在C++98中用户自定义destructor并不会对编译器生成copy操作造成任何影响,在C++11中自定义destructor仍然不会限制copy操作的生成,但这次并非认知不明,而是出于担心破坏历史遗留代码的原因。

但“Rule of Three”规则依然有效,前文中我们已经明确带有自定义copy操作的类不会自动生成move操作,因此我们也可以推断,带有自定义destructor的class使用默认move操作也是不正确的行为(因为自定义destructor的存在表示当前类需要自定义copy操作)。因此,只有当下述三个条件成立时才会生成move操作:

  1. class中没有任何自定义copy操作。
  2. class中不存在另一个move操作的声明。
  3. class中不存在自定义destructor。

=default声明

 
在某些情况下,类似的规则可能会延伸至copy操作,原因在于C++11很是抵制为含有自定义destructor或copy操作的class生成copy操作。因此如果你曾经依赖于使用编译器自动生成的特殊成员函数,你应当仔细检查并按照“Rule of Three”法则消去其相关性。如果你认为memberwise足以完成工作,那你可以在声明时直接以default注明:

1
2
3
4
5
6
7
8
9
class Widget {
public:
…
~Widget(); // user-declared dtor
…
Widget(const Widget&) = default; // default copy ctor behavior is OK
Widget& operator=(const Widget&) = default;
…
};

=default在多态基类中使用非常普遍。多态基类通常具有虚析构函数,因为如果它们不存在,则某些操作(例如通过基类指针或引用对派生类对象使用delete或typeid)会产生未定义或误导性的结果。除非一个类已经继承了其基类的虚析构函数,否则你最好显式地声明它。前文已述,自定义destructor会抑制move操作的生成,因此你必须使用=default显式说明自己需要memberwise copy。类似地,声明移动操作会禁用copy操作,因此如果需要复制性的话,则需要手动声明copy操作为=default:
1
2
3
4
5
6
7
8
9
class Base {
public:
virtual ~Base() = default; // make dtor virtual
Base(Base&&) = default; // support moving
Base& operator=(Base&&) = default;
Base(const Base&) = default; // support copying
Base& operator=(const Base&) = default;
…
};

此外,结果编译器愿意生成某些操作,也不妨碍我们在类中以=default显式地声明它们,尽管多打了几个字,但可以使接口更加清晰,同时避免一些微妙的错误。举例而言,当前存在一个表示字符串表的类,即一个允许通过整数ID快速查找字符串值的数据结构:
1
2
3
4
5
6
7
8
class StringTable {
public:
StringTable() {}
… // functions for insertion, erasure, lookup,
// etc., but no copy/move/dtor functionality
private:
std::map<int, std::string> values;
};

可以看到该类并未声明copy操作、move操作等等,编译器将会在其需要时将其合成。但考虑如果你日后发现需要在构造和析构对象时做一个日志记录,例如:
1
2
3
4
5
6
7
8
class StringTable {
public:
StringTable() { makeLogEntry("Creating StringTable object"); } // added
~StringTable() { makeLogEntry("Destroying StringTable object"); } // added
… // other funcs as before
private:
std::map<int, std::string> values; // as before
};

析构函数的加入看起来没毛病,但实际上它导致了移动操作不再会被生成,但同时复制操作不受影响。因此,在我们启动移动操作时,实际上使用的copy操作,而我们却对此一无所知(赋值map造成了相当严重的性能下降,这一切只是因为引入了一个自定义destructor)。如果我们在之前曾经手动声明过=default,那一切都不会发生问题。


C++11中特殊函数的规则总结

  • Default constructor
    和C++98一样,仅在不存在自定义constructor时自动生成。
  • Destructor
    类似于C++98,唯一的区别在于编译器自动生成的destructor带有noexcept属性,并且与C++98一致的是,仅有基类destructor为virtual的情况下才会具备virtual属性。
  • Copy constructor
    与C++98行为相同:复制构造所有的non-static data member。仅在class中不存在自定义copy constructor的情况下才会被生成。如果类中声明了移动操作,则不会生成copy constructor。不推荐在任何具备自定义copy操作或自定义destructor的class中依赖此函数。
  • Copy assignment operator
    类似于copy constructor。
  • Move constructor and move assignment operator
    为每一个non-static data member执行move操作。仅在类不包含自定义copy操作,move操作或析构函数时生成。

规则中并没有任何关于模板成员函数阻止其他特殊成员函数生成的说明,因此,如果有一个Widget class如下所示:

1
2
3
4
5
6
7
8
class Widget {
…
template<typename T>
Widget(const T& rhs); // construct Widget from anything
template<typename T>
Widget& operator=(const T& rhs); // assign Widget from anything
…
};

编译器仍将为Widget生成copy和move操作(假设其生成条件已经具备),即使存在可以实例化生成copy操作与move操作的成员函数模板(当T为Widget时)。在某些情况下这种行为可能会产生相当严重的后果,我们将Item26中对其进行详细论述。

<i class="fa fa-angle-left"></i>1…789…27<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4