3.2完美转发
在《C++ Move Semantics The Complete Guide》一书中,它将完美转发放在了第三部分Move Semantics in Generic Code,也就是说完美转发是同时涉及到移动语义和泛型编程
的一个概念。
1)为什么需要完美转发
“转发”的含义是一个函数把自己的形参传递给另一个函数(即调用另一个函数),但是在引入右值后,这些转发可能需要花费一些精力:
比如现有3个版本的foo()函数:
class X{ public: X() {a = 1;} int a ; }; void foo(const X& x) {// 绑定所有只读变量 // do some read only job cout << "foo(const X& x) called\n"; } void foo(X& x) { // 绑定左值引用 // do a lot of job, can modify x cout << "foo(X& x) called\n"; } void foo(X&& x) { // 绑定右值引用 // do a lot of job, can modify x, even can move x since x is rvalue references cout << "foo(X&& x) called\n"; // std::move(x) is valid! }
假如要通过另一个函数callFoo调用foo函数,那么为了区分参数类型,callFoo也应该要写三个重载版本达成"完美转发"
的效果:
void callFoo(const X& x) { foo(x); // 调用void foo(const X&) } void callFoo(X& x) { foo(x);// 调用void foo(X&) } void callFoo(X&& x) { foo(std::move(x));// 调用void foo(X&&), 注意std::move, x在callFoo函数域中是一个左值 // 在调用foo前,需要将其转化为右值 }
注意第三个重载版本,在调用foo前必须对x进行std::move
,因为“move semantics is not automatically passed through”(见上一章的源码实例)
注意到我们编写三个callFoo函数,能否使用泛型只写一个函数模板?恐怕很难。假设你只编写下面callFoo函数的泛型版本
template<typename T > void callFoo(T x) { foo(x); }
在main函数中这样使用它:
int main() { const X const_x; X x; callFoo(const_x); callFoo(x); callFoo(X()); }
输出是:
foo(X& x) called foo(X& x) called foo(X& x) called
三个callFoo全部都调用了foo(X& x) 函数,没有实现完美转发。原因与模板推导有关,因为void callFoo(T x)
表示值传递,因此参数始终被推导为X,不会有引用性,也不会保留const属性,详见《effective modern C++》条款1。
如果你想打个补丁:
void callFoo(T& x) void callFoo(T&& x)
g++编译器会报错Call to 'callFoo' is ambiguous
。况且,即时哪种编译器能通过编译,这样的写法一点都不"泛型", 你都写了这么多重载的泛型函数了,为什么还用泛型?而且,如果函数参数有2个,那么需要编写9个版本,如果参数有3个则要编写27个重载版本,可以预见,需要提供的重载版本数随着泛型参数的增加呈现指数级增长。
因此C++11 引入了两种特殊的机制,以在泛型编程中达成上述的“完美转发”效果:
- 万能引用
- std::forward
具体代码如下:
template<typename T> void callFoo(T&& arg) { // 这是一个万能引用,而不是右值引用 foo(std::forward<T>(arg)); // 使用std::forward保持参数的类型:如果arg在传入callFoo时是左值,则让其保持左值;否则将其转化为右值 }
只需要编写以上一个泛型版本的callFoo即可完成对foo函数参数的完美转发效果!
2)万能引用和std::forward
template<typename T> void callFoo(T&& arg) // 右值引用? 不,是万能引用
在泛型编程中,T&&
看上去像是右值引用,但它其实是万能引用
,它能够绑定所有的对象(包括const、non-const,左值、右值),以下调用都是合法的,而且,它们能够保持参数的常量性和值的类型(左值\右值)。
X v; const X c; callFoo(v); // arg的型别 是 X& callFoo(c); // arg的型别 是 const X& callFoo(X{}); // arg的型别 是 X&& callFoo(std::move(v)); // arg的型别 是 X&& callFoo(std::move(c)); // arg的型别 是 const X&&
概括而言,如果调用函数时传递的参数类型是左值,那么万能引用就绑定到一个左值,如果传递的参数是右值,那么万能引用就绑定到一个右值
。
注意 : 区分万能引用和右值引用(详见modern effective C++条款24)并不是形如 T&&的引用就是万能引用,T必须涉及类型推导
时,T&&才是万能引用,典型场景就是在泛型编程中的T&&。且即时在泛型编程场景下,const T&&
并 不是万能引用,它只能绑定 const X&&auto&& 也是一个万能引用,它也涉及型别推导一句话:万能引用必须涉及型别推导
其余能够绑定任何类型的引用则是const&, 但是它没有保存参数是否是const的信息,而万能引用能保存参数是否为const的信息
为什么还需要std::forward呢?这与万能引用能够“绑定任何类型的对象”的特性有关:
- 右值引用只能绑定可移动的对象,因此函数编写者100%确定他使用的函数参数能够被作用于std::move。
void callFoo(X&& x) { //能够调用这个函数的参数一定也是右值引用 foo(std::move(x)); // 因此能够毫无顾虑的调用std::move将其再次转化为右值 } - 然而万能引用能够绑定任何对象,因此函数编写者
不能确定他使用的参数是否在被std::move作用后是否保持原来的引用类型
(“原来的类型”指的是函数作用域外,用来传递给函数形参的对象的类型),要实现完美转发不能使用std::move,只能使用std::forward。
template void callFoo(T&& arg) { // 这是一个万能引用,任何参数都能调用这个函数 1. foo(std::move(arg)); // 如果使用std::move,则无条件将参数转化为右值,这是不对的! 2. foo(std::forward(arg));// 这样才合适,会先将arg转化为对应的类型,然后调用对应的函数版本 }
std::forward的功能如下所述:
std::forward(arg)是一个有条件的std::move(arg), 即
- 如果arg是一个右值引用,则std::forward(arg)将会等效为std::move(arg)
- 如果arg是一个左值引用,则std::forward(arg)将会等效为 arg
通过万能引用和std::forward,我们就可以在泛型编程中实现完美转发:
template<typename T> void callFoo(T&& arg) { // 这是一个万能引用,而不是右值引用 foo(std::forward<T>(arg)); // 使用std::forward保持参数的类型:如果arg在传入callFoo时是左值,则让其保持左值;否则将其转化为右值 } // 调用的函数foo有3个重载版本,见上小节 X v; const X c; callFoo(v); // std::forward<T>(arg) => arg, 调用 foo(X&) callFoo(c); // std::forward<T>(arg) => arg, 调用 foo(const X&) callFoo(X{}); // std::forward<T>(arg) => std::move(arg), 调用foo(X&&) callFoo(std::move(v)); // std::forward<T>(arg) => std::move(arg), 调用foo(X&&) callFoo(std::move(c)); // std::forward<T>(arg) => std::move(arg), 调用foo(cosnt X&)
接下来,将阐述完美转发能够运行的原理。
3)引用折叠
引用折叠是完美转发能够起作用的底层机制,但是在理解引用折叠之前,需要再了解一些模板型别推导的知识。
因为这里主要涉及完美转发,因此只讨论涉及万能引用的函数模板型别推导。比如这样的函数声明:
template<typename T> void callFoo(T&& arg);
若以某个表达式expr调用它:
callFoo(expr);
编译器会进行两处类型推导,一是推导T的型别,二是推导T&&的型别(即arg的型别)
。
且由于函数参数使用的是万能引用,因此会对左值类型的expr有特殊处理方法。
①expr是右值的情景
,编译器是这样对T进行型别推导的:
- 若expr有引用型别,则先将引用部分忽略
- 然后,对expr的型别和 T&& 进行模式匹配,来决定T的型别
比如callFoo(1)
,此时expr为1,它是一个右值,它的类型为 int&&, 在与T&&进行模式匹配后,得到T的类型为int。最后自然地得到arg的型别为T&&,即arg是一个右值引用。
②但如果expr是一个左值
,编译器会将T推导为左值
(至于为什么,我不是很清楚,个人倾向于将其解释为标准的规定)。
然后会将T&&的型别也就是arg的型别推导为左值! 例如:
int x = 1; callFoo(x); //expr型别为 int&, T的型别为 int&, arg的型别也是 int&
等等,T的型别被推导为int&, 那为什么arg的型别也是int&, 不应该是int& && 吗?
这就是引用折叠
发挥作用的地方了,C++没有“引用的引用”这样的型别。因此如果你脑补了一个类的型别出现了3个或3个以上&符号,那么就一定得把它们转化成左值或者右值,具体的规则由C++标准如下规定:
这里主要观察第二个规则,该规定就决定了上例的arg的型别被推导为左值引用,从int& && 折叠为 int&。
做个总结,当使用万能引用的模板参数时,编译器有一套特殊的类型推导规则:
- 如果传递的参数是一个
右值
,T的推导结果就是非引用型别,arg的推导结果就是右值引用
型别 - 如果传递的参数是一个
左值
,T的推导结果就是左值引用型别,又由于"引用折叠"这个规定,于是arg的推导结果也是左值引用
型别
个人看来,虽说引用折叠是完美转发的底层机制,但这其实就是C++标准会的一系列规定,是从需求出发的定制的一系列规定。
有关模板类型推导的其余内容请参考《effecive modern C++》条款1。
4)std::forward原理解析
有了引用折叠的这个概念后,理解std::forward的原理也就不难了。
下面从《effecive modern C++》条款28种摘录的代码片段,它展示了一种不完全符合C++标准的std::forward实现,但用来理解原理已经足够:
template<typename T> T&& forward(typename remove_reference<T>::type& param){ return static_cast<T&&>(param); }
看到std::forward的底层实现就是一个static_cast,于此同时万能引用与引用折叠在这里默默起了很大的作用。下面,分别阐述使用左值和右值进行forward调用的参数推导过程。
仍然用上一节的例子进行说明:
template<typename T> void callFoo(T&& arg){ foo(std::forward<T>(arg)); } // 情况一,传递左值 int x = 1; callFoo(x); // 情况二, 传递右值 callFoo(1)
①如果传递给callFoo的参数原本为左值引用的int类型
,那么按照上一节的参数推导规则,T将被推导为 int&,注意这里的类型推导指callFoo这个函数的类型推导,forward将不进行类型推导,因为在执行forwar调用时已经指明了具体类型(尖括号中的T)。将int& 插入forward模板中得到下面的代码:
int& && forward(typename remove_reference<int&>::type& param) { return static_cast<int& &&>(param); }
其中的remove_reference::type,看名字就可以知道这就是将<>内的型别去掉引用部分后得到的型别。在这里就是int,最后加上末尾的&,那么param的型别就被推导为int&。
最后再加上引用折叠的规则,我们得到:
int& forward(int& param) { return static_cast<int&>(param) // static_cast 将参数转化为左值引用,实际上没什么作用,因为param已经是左值引用了 }
②如果传递给callFoo的参数原本为右值引用的int类型
,T将被推导为int,它不是一个引用类型,将其插入forward模板得到:
int&& forward(int& param) { return staric_cast<int&&>(param); // static_cast 将左值引用类型的参数转化为右值引用 }
这里没有发生引用折叠。
总结:
- 当传递参数为左值引用时,forward将返回左值引用
- 当传递参数为右值值引用时,forward将返回右值引用
这恰好就是完美转发需要的组件!
3.3智能指针
1)总览
C++ 11 总共有4种智能指针, std::auto_ptr std::unique_ptr std::shared_ptr std::weak_ptr
std::auto_ptr 是个从C++98残留下来的特性,在C++17中,已经被声明为depracated了
std::unique_ptr 借助右值引用使得移动操作成为可能,解决了auto_ptr的问题
std::weak_ptr则可以用来解决std::shared_ptr的循环引用的问题。
2)std::auto_ptr
首先看看 auto_ptr, 了解我们为什么C++弃用它,它有什么不足之处。
我们把动态分配的堆内存的释放任务交给这些类,当这些类的生命周期结束时会自动调用析构函数,析构函数常常有delete之类的操作释放这些动态分配的内存。这样的好处是,指针管理维护对我们造成的心智负担会大大减少。
我们写一个Auto_Ptr类,模拟指针的操作,并且在析构函数中 对自己维护的指针进行delete
template <typename T> class Auto_Ptr { public: Auto_Ptr(T* ptr) : ptr_(ptr){ } ~Auto_Ptr() { delete ptr_; } // 重载下面两个运算符,使得类能够像指针一样运作 T& operator*() { return *ptr_; } T* operator->() { return ptr_; } private: T* ptr_; }; class A { public: A() { std::cout <<"class A construct!\n"; } ~A() { std::cout << "class A destroyed"; } int attr_a = 2; };z int main() { Auto_Ptr<A> autp (new A()); std::cout << autp->attr_a << std::endl;// autO的行为就像是一个指针 std::cout << (*autp).attr_a << std::endl; return 0; // Auto_Ptr类自动delete,释放动态分配的内存 }
能够得到下面的输出信息 :
class A construct! 2 2 class A destroyed
这样一个能够自动释放动态内存的类就与智能指针类的思想类似,但是Auto_ptr现在有两个问题
- 不能pass by value , 否则,意味有两个以上的autp_ptr中的
指针指向了同一块内存
,这两个autp_ptr结束生命周期时一定会调用析构函数,但无论以哪种顺序调用析构函数,都会在同一个指针上调用两次以上delete操作,segment fault!
。我们可以手动禁止Auto_Ptr的复制函数, 这样倒是可以解决这个的问题。 - 但禁止Auto_Ptr的复制函数后,如何编写一个返回Auto_Ptr对象的函数?:
Auto_Ptr generateResource() // delete了Auto_Ptr的复制构造函数后,不能这样写了 { Resource* r{ new Resource() }; return Auto_ptr1(r);// 编译器报错 }
好,那我们不删除复制函数,而是改进它: 复制函数不仅仅简单拷贝指针, 而是将指针的所有权
从源对象“转移”到目标对象
template <typename T> class Auto_Ptr { public: ... Auto_Ptr( Auto_Ptr& source) { ptr_ = source.ptr_; source.ptr_ = nullptr; } Auto_Ptr& operator=(Auto_Ptr& source) { if (&source == this) { return *this; } delete ptr_; ptr_ = source.ptr_; source.ptr_ = nullptr; // 将源对象的指针进行delete return *this; } ... bool isNull() const { return ptr_ == nullptr; } };
至少我们现在能够 对函数参数进行passby value 了, 但是我们很容易又造成访问野指针的错误,因为传统观念来看,值传递的语义就是“复制”,但是我们改造了复制函数,实际上执行是“移动”。
而且从函数的声明可以看到,我们传入的是non-const参数,表示我们要修改它,这和传统的拷贝函数大不相同!
void DoSomeThing(Auto_Ptr<A> s) { // pass by value 并进行相应操作 std::cout << s->attr_a; } int main() { Auto_Ptr<A> res1 (new A()); DoSomeThing(res1); // 按值传递,成功。但是res1这个变量已经被"移动"了 std::cout << res1->attr_a <<std::endl; //再次使用res1,crash ! }
总结
autpptr是C++尝试“移动语义”的开始,但是总是
表现出将资源从一个object转移到另一个object的行为
autp_ptr的缺点:
- 使用复制构造\赋值函数模拟移动语义,非常容易造成野指针现象。也不能和标准库很好地一起工作,比如一个存放auto_ptr的vector容器,对它使用std::sort函数,sort函数在某步骤中会选取序列中的某一个并保存一个局部副本
... value_type pivot_element = *mid_point; ...
算法认为在这行代码执行完之后,pivot_element 和 *mid_point是相同的,但是因为auto_ptr的拷贝操作是对移动操作的模仿,当执行完这行代码后,mid_point所指向的内存是不确定的。最后算法正确性就受到了破坏 - auto_ptr中的析构函数总是使用delete ,所以它不能对动态分配的数组做出正确的释放操作(而unique_ptr可以自定义deleter)
核心问题 :
- 如果我们在想让对象在拷贝的时候能够被拷贝,移动的时候能够被转移控制权,那么就一切好办了。这就是为什么C++提出了“移动语义”(好家伙,C++11的新特性好多都和移动语义相关)
- C++11提出右值引用,很方便地表达了移动语义,以此带来了
表示独占的、只能被移动而不能被拷贝的unique_ptr
3)std::unique_ptr
unique_ptr的大小与裸指针相同(如果不使用函数指针自定义删除器),这是智能指针中最常用的。
关于unique_ptr的大小,库函数使用了
空基类优化
的技巧,具体实现方式可以参考这篇文章
C++11引进了移动语义,能够将object的移动或拷贝以更清楚的方式区分
,也多出了两种特殊的成员函数, 移动构造和移动赋值。下面用新的成员函数改造之前的Auto_Ptr。其实逻辑和之前实现的拷贝函数是一样的,但这里的逻辑是移动逻辑,不应该放在拷贝函数中。
... // 参数是右值引用, 且非const Auto_Ptr( Auto_Ptr&& source) { ptr_ = source.ptr_; source.ptr_ = nullptr; } // 参数是右值引用,非const Auto_Ptr& operator=( Auto_Ptr&& source) { if (&source == this) { return *this; } delete ptr_; ptr_ = source.ptr_; source.ptr_ = nullptr; return *this; } ...
参数是non-const的右值引用,因为是右值引用,所以不用加const 属性
, “右值”表示这个值的生命周期很短暂,无所谓我们改不改变它。
最后我们删除拷贝函数
Auto_Ptr(const Auto_Ptr& source) = delete; Auto_Ptr& operator=(const Auto_Ptr& source) = delete;
这样的AutoPtr类就非常类似标准库的unique_ptr
了
unique_ptr只允许从右值转移资源,但不能从左值拷贝资源,我们使用std::move将左值转变为右值后就可以了。但是被转变的值已经不能使用了,既然你已经move了他,那就说明被move的值可以被转移,编译器是假设程序员知道这件事的,所以我们之后再使用已经被move的变量而后导致未定义行为,责任在程序员而不是编译器。
Auto_Ptr<A> getResource() { A* res_f = new A(); return Auto_Ptr<A>(res_f); } int main() { Auto_Ptr<A> res1 (new A()); //Auto_Ptr<A> res2 (res1);// 报错 Auto_Ptr<A> res2 (std::move(res1)); // 将左值cast为右值,编译通过 Auto_Ptr<A> res3(getResource()); // 传递临时对象,即一个右值,编译通过 DoSomeThing(getResource()); DoSomeThing(std::move(res3)); // 也能值传递了 , 但是 res3 在这行之后就已经被转移了 std::cout << "res3 is " << (res3.isNull() ? "null\n" : "not null\n"); // res3 is null std::cout <<(*res3).attr_a << std::endl; // 使用已经被转移的变量, crash! return 0; }
最后将上面的代码修改整合,得到一份简单的Unique_Ptr实现:
template<typename T> class Unique_Ptr { private: // 原始指针 T* resource_; public: // unique_ptr是只移的,因此删除赋值函数 Unique_Ptr(const Unique_Ptr&) = delete; Unique_Ptr& operator=(const Unique_Ptr&) = delete; // 构造函数 explicit Unique_Ptr(T* raw_ptr): resource_(raw_ptr) { } // explicit防止隐式转换 // 移动构造函数 Unique_Ptr(Unique_Ptr&& other):resource_(other.resource_) { other.resource_ = nullptr; } // 移动赋值函数 Unique_Ptr& operator=(Unique_Ptr&& other) { if (&other != this) { // 注意自赋值的情况 delete resource_; resource_ = other.resource_; other.resource_ = nullptr; } return *this; } // 析构函数 ~Unique_Ptr() { if (resource_) { delete resource_; resource_ = nullptr; } } // 解引用符号 * 重载 T& operator*() const{ return *resource_; } // ->符号重载 T* operator->() const{ return resource_; } };
unique_ptr的使用场景
作为工厂函数的返回值,unique_ptr能够方便高效地、无感地转换成shared_ptr。工厂函数并不知道调用者是对器返回的对象采取专属所有权好,还是共享所有权更合适。
// 函数声明返回unique_ptr template<typename...TS> std::unique_ptr<Investment> makeInvestment(Ts&&... param); // 用户程序可以取得一个shared_ptr<Investment>, 其中的转换会默认进行 std::shared_ptr<Investment> a = makeInvestment(...);
4)std::shared_ptr
与unique_ptr不同,share_ptr对象能够与其他share_ptr对象共同指向同一个指针,内部维护一个引用计数,每多一个对象管理原指针,引用计数(reference count)就加一,每销毁一个share_ptr,引用计数减一,最后一个被销毁的shared_ptr对象负责对原始指针进行delete操作
从底层数据结构看(下图源自《effective modern c++》),shared_ptr除了保存原始指针外,还会保存一个指向控制块的指针,所以一般情况下(unique_ptr没有使用函数指针当作自定义删除器)shared_ptr的大小会比unique_ptr大两倍
。控制块是一动态分配在堆内存中的,其中有引用计数、弱计数、以及其他数据(比如自定义deleter、原子操作相关的数据结构),弱计数是统计指向T object 的weak_ptr数量,这个计数不影响T object的析构,当引用计数 = 0时,T object 就会被销毁,不会管弱计数(weak count)。
shared_ptr 能够被移动也能够被拷贝,被拷贝时引用计数+1,这个引用计数使用原子变量保证线程安全(但仅仅保证RefCount的线程安全性),被移动时则不需要。因此考虑效率时,如果能够移动构造一个shared_ptr那就使用移动,不要使用拷贝。
sharedptr的线程安全性?
sharedptr使用atomic变量使得计数器的修改是原子的(即上图的RefCount是原子的),但是sheared_ptr这个类本身不是线程安全的
,因为整个SharedPtr对象有两个指针,复制这两个指针的操作不是原子的!更别说sharedptr管理的对象(上图的T Object)是否有线程安全性了,除非这个对象本身有锁保护,否则不可能通过只套一层sharedptr的封装来实现线程安全性。
关于std::atomic?
C++能够提供原子操作是因为多数硬件提供了支持,比如x86的lock指令前缀,它能够加在INC XCHG CMPXCHG等指令前实现原子操作。
std::atomic比std::mutex快,是因为std::mutex的锁操作会涉及到系统调用,比如在linux上会调用futex系统调用,在某些情况下可能陷入内核。
从效率上考虑,优先使用make_shared而不是直接new创建shared_ptr
shared_ptr类有两个指针,一个指向要管理的对象,一个指向控制块。
如果使用new来创建shared_ptr:
std::shared_ptr<SomeThing> sp(new SomeThing);
编译器则会进行两次内存分配操作,一次为SomeThing的对象分配,一次为控制块分配内存。
如果使用make_shared创建:
auto sp(std::make_shared<SomeThing>())
编译会只会进行一次内存分配,对象与控制块是紧挨着的。
实现一个简单的Shared_Ptr, 其余测试代码见github仓库
// 模拟控制块类 class Counter { public: std::atomic<unsigned int> ref_count_; Counter():ref_count_(0){} Counter(unsigned int init_count):ref_count_(init_count){ } }; // Shared_Ptr模板类 template<typename T> class Shared_Ptr{ private: Counter* count_; T* resource_; void release() { if (count_ && resource_) { // 注意这里应该判断count_是否为nullptr,可能已经被移走了 if (--count_->ref_count_== 0) { delete resource_; delete count_; resource_ = nullptr; count_ = nullptr; } } } public: // 构造函数 explicit Shared_Ptr():count_(new Counter(0)),resource_(nullptr) { } explicit Shared_Ptr(T* raw_ptr):count_(new Counter(1)),resource_(raw_ptr) { } Shared_Ptr(std::nullptr_t nPtr) { release(); resource_ = nPtr; count_ = nPtr; } // 析构函数 ~Shared_Ptr() { release(); } // 复制构造函数 Shared_Ptr(const Shared_Ptr& other) { resource_ = other.resource_; count_ = other.count_; count_->ref_count_++; } // 赋值构造函数 Shared_Ptr& operator=(const Shared_Ptr& other) { if (&other != this) { // delete resource_; // 这里有问题,能直接delete吗? // delete count_; release(); resource_ = other.resource_; count_ = other.count_; count_->ref_count_++; } return *this; } // 移动构造函数 // 注意将被移动对象的资源置空 Shared_Ptr(Shared_Ptr&& other):resource_(other.resource_), count_(other.count_) { other.resource_ = nullptr; other.count_ = nullptr; } // 移动赋值函数 Shared_Ptr& operator=(Shared_Ptr&& other) { // 注意将被移动对象的资源置空 if (this != &other) { release(); // 释放资源 resource_ = other.resource_; other.resource_ = nullptr; count_ = other.count_; other.count_ = nullptr; } return *this; } };
5)std::weak_ptr
std::weak_ptr是std::shared_ptr的一种补充,它不是独立出现的,std::weak_ptr通常通过unique_ptr来初始化,使用了与std::shared_ptr同一个控制块,但是不会增加refcout只会增加weakcount。它既不能执行提领操作,也没有->操作.
可以通过weak_ptr来构造shared_ptr(调用lock成员函数),如果shared_ptr所指涉的对象已经被销毁,那么转换为空指针。这样在使用某个智能指针前,可以先使用weakptr检测智能指针所指涉的对象是否已经被销毁(调用expire成员函数), 这是weak_ptr操作原对象的唯一方法(即转换成shared_ptr)
关于控制块与智能指针所管理的对象的内存释放时机
- 如果使用make_shared来创建sharedptr,由于只进行了一次内存分配,那么得等到weakcount = 0时才会回收这块内存
- 如果使用new来创建sharedptr,这里分别进行了两次内存分配,那么当refcount = 0时,智能指针所管理的对象的内存可以立即回收,但是控制块的内存还是得等到weakcount = 0时才会回收
弱指针的应用场景
- 解决循环引用的资源泄漏问题
- 带有缓存的工厂函数:函数返回sharedptr,工厂内部使用weak_ptr指涉客户所要创建的对象
- 观察者设计模式
为一个类设计一个成员函数,返回一个shared_ptr智能指针,指针指向自己?
错误的做法是:
struct Bad { std::shared_ptr<Bad> getptr() { return std::shared_ptr<Bad>(this); } ~Bad() { std::cout << "Bad::~Bad() called\n"; } };
为什么?因为getptr成员函数会再分配一个控制块来管理Bad的某个对象,如果这个对象已经被一个shareptr管理的话,那么就可能发生double free运行时错误。具体一点,就如下面这段代码:
// Bad, each shared_ptr thinks it's the only owner of the object std::shared_ptr<Bad> bad0 = std::make_shared<Bad>(); std::shared_ptr<Bad> bad1 = bad0->getptr(); // UB: double-delete of Bad
第一个语句调用make_shared会分配一个控制块,第二个语句调用通过成员函数再次分配一个控制块,但是这两个控制块都控制同一个对象指针,最后一定会对对象进行两次的free,从而引发double free错误。
正确的做法是继承std::enable_shared_from_this,调用它提供的父类方法来获取指向自身的sharedptr:
class Good : public std::enable_shared_from_this<Good> { public: std::shared_ptr<Good> getptr() { return shared_from_this(); } }; // 正确的食用方式: std::shared_ptr<Good> good0 = std::make_shared<Good>(); // 注意必须已经有一个sharedptr才可以,否则抛异常,详见cppreference的对应代码 std::shared_ptr<Good> good1 = good0->getptr();
那么enable_shared_from_this是怎么样避免double free错误的呢?猜一下就能知道它可能使用了weakptr:
template<class _Tp> class _LIBCPP_TEMPLATE_VIS enable_shared_from_this { mutable weak_ptr<_Tp> __weak_this_; // ...
3.4lambda表达式
1)本质
lambda的本质是一个仿函数(functor),编译器看到lambda表达式后会产生一个匿名class,这个class重载了()操作符
。
比如下面这个仿函数:
class X { int a = 1; public: void operator()(int b) { printf("a + b = %d\n", a + b); } }; X x_functor;
它的作用效果与下面lambda表达式相同:
auto x_lambda = [a = 1](int b) {printf("a + b = %d\n", a + b);};
两者的调用方式和调用一个函数的方式相同:
x_functor(1); x_lambda(1);
编译期,编译器遇到lambda表达式则会生成一个匿名仿函数类型
(closure type);运行期,当使用lambda表达式时,则根据编译器生成的匿名仿函数类型创建一个对象
,该对象本质就是functor对象。
2)语法
lambda表达式的语法如下:
[捕获值] (参数列表) ->返回类型 {函数体}
捕获值
- 能够捕获本lambda表达式所处作用域中的
局部变量
(不包括类的成员变量)或this指针,使其能够在{}内的函数体中可以被使用 - 捕获方式有
按值和按引用
两种 - 可以空着,这相当于生成了一个没有成员变量的仿函数
-> 返回类型
通常可不写,编译器从函数体中自动推导
其中关于捕获的注意点最多:按值和按引用捕获的区别
int main() { int x = 42; auto byvalue = [x] ( ) // 按值捕获局部变量x,记住当lambda表达式被evaluated时,值就已经被捕获了 { std::cout << "Hello from a lambda expression, value = " << x << std::endl; }; auto byref = [&x] ( ) // 按引用捕获局部变量x { std::cout << "Hello from a lambda expression, value = " << x << std::endl; }; x = 7; byvalue(); // 42, 按值捕获且在lambda表达式被创建时就被捕获,因此不受影响 byref(); // 7 , 按引用捕获因此受影响 }
按值捕获的变量是只读
的,如果要修改它,则应该在参数列表后加上mutable关键字
auto myLamb = [x] ( ) mutable { return ++x; };
避免默认捕获模式,详见effecttive modern C++条款31
- 按引用的默认捕获方式容易造成指针空悬
- 看似能够捕获成员变量,实际上则是捕获了this指针,因此也容易造成指针空悬
默认捕获不能捕获全局变量!
int g = 10; auto kitten = [=]() { return g+1; }; // 默认按值捕获,但是编译器发现g是全局变量,根本不需要捕获 auto cat = [g=g]() { return g+1; }; // 广义的按值捕获则可能得到预期结果 int main() { g = 20; printf(%d %d\n", kitten(), cat());// 21 11 }
最好都是写成广义捕获的形式,这是C++14支持的特性
auto cat = [g=g]() { return g+1; }; // 按值捕获g auto dog = [&g=g]() { return g+1; }; // 按引用捕获g
注意,= 号两边的g是不同的,左边的g是lambda表达式所处作用域的局部变量,右边的g则是编译器为lambda表达式生成的functor中的成员变量
3.5四大转换
C++相比于C语言多出了4种转换,并且也兼容C风格的转换。C风格的转换几乎可以转换任何类型,简单方便的同时增大了出错地可能性。
// 两种通用的转换方式,容易出错 double x = 10.3; int y; // C++存在两种通用类型的转换,第二种则是C风格的转换,第一种和第二种的作用相同 y = int (x); // functional notation, y = (int) x; // c-like cast notation
C风格的转换能够做以下所有的转换 :
- Between two arithmetic types
- Between a pointer type and an integer type
- Between two pointer types
- Between a cv-qualified and cv-unqualified type (简单说就是const类型与非const类型的转换)
- A combination of (4) and either (1), (2), or (3)
C风格转换的缺点 :
- They allows casting practically any type to any other type, leading to lots of unnecessary trouble - even to creating source code that will compile but not to the intended result.
- The syntax is the same for every casting operation, making it impossible for the compiler and users to tell the intended purpose of the cast.
- Hard to identify in the source code.
C++提供了另外四种转换:
1)dynamic_cast
dynamic_cast
:只能转换指向class的指针或引用
(通常涉及多态),能够确保转换的结果指向目标指针类型的完整对象
( Its purpose is to ensure that the result of the type conversion points to a valid complete object of the destination pointer type.)。
1.dynamic_cast能够将类指针向上转型(派生类指针指向基类指针),这和static_cast相似,不需要被转换的类拥有虚函数,而且C++标准规定在这种情况下产生与static_cast一致的底层代码。如下所示,没有产生编译错误:
class A { }; class B : public A{ }; int main() { B* b = new B(); A* d = dynamic_cast<A*>(b); // 子类指针转向父类指针 }
也可以将执行向下转型(将基类型指针转换成派生类型的指针),但是满足两个条件转换才能成功 :
基类必须有虚函数
,即只对那些展现“多态”的类型,才可能执行向下转换。否则编译器报错:
class A{ }; class B : public A { }; int main() { A* a = new A(); B* c = dynamic_cast<B*>(a); // 编译器报错: cannot dynamic_cast ‘a’ (of type ‘class A*’) to type ‘class B*’ (source type is not polymorphic) }
最起码,父类具有虚函数才可以,这样父子类都有了虚函数,也就都有个运行时类信息,才能通过编译:
class A { public: virtual ~A() { } }; class B : public A{ }; int main() { A* a = new A(); B* c = dynamic_cast<B*>(a); }
2.但是通过编译不代表转换成功,如果转换后的对象指针确实是目标对象的指针
,那么转换成功。但如果dynamic_cast向下转换失败则会返回nullptr(指针之间的转换)或者抛出异常(引用之间的转换)。程序员通过检查指针,就可以知道向下转型是否成功。
class A { public: virtual ~A() {} }; class B : public A{ }; class C { public: virtual ~C() {} }; int main() { C* c_ptr = new C(); A* a = dynamic_cast<A*>(c_ptr); printf("a = %p\n", a); // a = (nil), 说明转换不成功 }
dynamici_cast使用场景:
using namespace std; class Base { virtual void dummy() {} }; class Derived: public Base { int a; }; int main () { try { Base * pba = new Derived; Base * pbb = new Base; Derived * pd; pd = dynamic_cast<Derived*>(pba); // 转换成功 if (pd==0) cout << "Null pointer on first type-cast.\n"; pd = dynamic_cast<Derived*>(pbb); // 这个转换不会成功但不会抛出异常,只会返回nullptr if (pd==0) cout << "Null pointer on second type-cast.\n"; } catch (exception& e) {cout << "Exception: " << e.what();} return 0; } // 结果 Null pointer on second type-cast.
关于dynamic_cast的实现原理,看了《深度理解C++对象模型》
后了解到编译器会将对象的运行时类型信息(RTTI)指针连同虚函数指针一起放在虚函数表中(RTTI的指针在函数指针的上方),这也就是为什么不具多态意图的class不能执行dynamic_cast的原因,因为这些类没有虚函数,也就没有虚函数表,那也没有地方存放类型信息。
2)static_cast
static_cast
: 能够做与dynamic_cast
相似的工作(即类层次指针间向上/向下转型),但是编译器不会在运行期检查
(向下)转换后的object指针是否为目标object指针,因此转换是否成功是由开发人员自己保证的。static_cast用于有直接或间接关系的指针或引用之间转换。
没有继承关系的指针不能用static_cast转换,可以考虑使用reinterpret_cast。
当然static_cast除了可以做类层次结构指针之间的转换外还可以做其他很多其他类型的转换:
- 将void指针转换成任何其他类型的指针,但是
会检查void*指针是否由同一类型的指针转换而来
(存疑!)(C风格的转换和reinterpret_cast不会检查) - 用于基本数据类型之间的转换
static_cast转换两个没有关系的类指针时会产生编译错误:
class A { }; class B { }; int main() { A* a = new A(); B* b = new B(); B* c = static_cast<B*>(a); // compiler error ! invalid static_cast from type ‘A*’ to type ‘B*’ A* d = static_cast<A*>(b); // compiler error ! }
如果B继承自A或者A继承自B,就不会产生编译时错误
class B : public A{ };
“子类指针转换成父类指针,使用static_cast、dynamic_cast两种中的任意一种都会产生相同的代码”。接下来验证这件事
为了不至于太简单,我在B中加了一个虚函数,这样当子类转化成父类时,编译器将调整this指针跳过vptr
class A { public: int a = 1; }; class B : public A{ public: int b = 1; virtual int fun1() { return 1; }; }; int main() { B* b_ptr = new B(); A* a_ptr = static_cast<A*>(b_ptr); A* a_ptr2 = dynamic_cast<A*>(b_ptr); }
实验的编译器版本为g++7.5:
g++ cast.cpp -o cast && objdump -d cast > cast.asm
然后找到返汇编文件中的关于cast的相关代码,我删除了一些无关代码:
8e3: 48 8b 45 d8 mov -0x28(%rbp),%rax # 使用static_cast进行转换 8e7: 48 83 c0 08 add $0x8,%rax 8f2: 48 89 45 e0 mov %rax,-0x20(%rbp) 8fd: 48 8b 45 d8 mov -0x28(%rbp),%rax # 使用dynamic_cast进行转换 901: 48 83 c0 08 add $0x8,%rax 90c: 48 89 45 e8 mov %rax,-0x18(%rbp)
可以看出,dynamic_cast进行转换的逻辑与static_cast相同,换句话说,这里的dynamic_cast根本没有进行“动态”转换。
2)reinterpret_cast
reinterpret_cast
能够将任何类型的指针转换成任意类型,即使这两个类型没有任何关系(主要是没有继承关系)。它只是在两个指针之间简单地执行二进制拷贝,不会进行任何检查
。也可以将指针转换成整型。
reinterpret_cast几乎与C风格的转换可以做同样多的事,但它依然不能将const的类型的object转换成non const, 不止reinterpret_cast,以上三种C++的类型转换都不能将object的const属性去除
(但是C风格的转换不管,这也是它不安全的原因之一),唯一能够将const对象转换成非const的C++风格的转换是下面的const_cast
3)const_cast
如上所说,这是C++提供的4种转换种的唯一
一个可以"抹除"object const属性的转换方式
4)实战示例
来自CMU15445lab
reinterpret_cast在lab源码中出现的频率很高, 比如 :
reinterpret_cast<Page *>(bucket_page)->WLatch(); // modify bucket reinterpret_cast<Page *>(bucket_page)->WUnlatch();
BucektPage 与 Page根本没有继承关系所以使用reinterpret_cast转换,但是这对Page中的成员的顺序由要求
。
下面是Page的成员组成:
class Page { ... /** The actual data that is stored within a page. */ char data_[PAGE_SIZE]{}; /** The ID of this page. */ page_id_t page_id_ = INVALID_PAGE_ID; /** The pin count of this page. */ int pin_count_ = 0; /** True if the page is dirty, i.e. it is different from its corresponding page on disk. */ bool is_dirty_ = false; /** Page latch. */ ReaderWriterLatch rwlatch_; }
其中data_就是实际page的开始地址,我们使用reinterpret_cast把char* 转换为 BucketPage*
bucket_page = reinterpret_cast<HASH_TABLE_BUCKET_TYPE *>(buffer_pool_manager_->FetchPage(bucket_page_id)->GetData());
按照Struct成员再内存中的分布,我们可以得到下面的示意图
编译器由低地址向高地址取得内存中的内容并将它解释为对应的类,无论是Page还是BucketPage都是合法的不会产生错误。
但如果 data_声明在最后会怎样?
因为使用reinterpret_cast,所以编译器不会进行任何检查,只会从低地址一直向上解释 length of data_ 个字节数为BucketPage, 很显然这是错误的。
精品文章推荐阅读: