概述
本文用于分析总结C++11新特性:
- 深度剖析智能指针
- 右值引用、转移语义和完美转发
- lambda表达式与函数对象
- 四种强制类型转换机制
- explicit关键字
C++11各种技巧
-
=delete
C++11中,当我们定义一个类的成员函数时,如果后面使用"=delete"修饰,那么就表示这个函数被定义为deleted,也就意味着这个成员函数不能再被调用,否则会出错。下面介绍一个"=delete"的巧妙用法:#include <iostream> class Test{ public: void func(int data) {std::cout << data << endl;} void func(double data) {std::cout << data << endl;} }; int main() { Test t; t.func(100); t.func(100.0); return 0; }
如果我们不加第二个成员函数的话,当把100.0传给t.func()时会发生隐式类型转换,由double转为int。有时我们不希望发生这样的转换,就希望传进来的参数和规定的类型一致,那么此时可以使用=delete来达到这个目的。
-
auto与decltype的区别
- auto让编译器通过初始值来进行类型推演,所以说auto定义的变量必须有初始值;
//auto会通过表达式b+c的值推导a的类型,然后再用表达式的值去初始化变量a auto a = b + c;
- 有时候我们还会遇到这种情况,我们希望从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量,在此时auto就有点无力了。因此,C++11又引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器只是分析表达式并得到它的类型,却不进行实际的计算表达式的值。
decltype(f()) sum = x; //sum的类型就是f()的返回值类型
-
后置返回值类型(trailing return type)
后置返回值类型(尾随)主要用于模板函数中,需要两种C++11的类型说明符auto和decltype配合使用;template <typename T, typename U> auto add(T t, U u) ->decltype(t + u){ return t + u; }
切记auto不能声明函数的返回值,此时auto的作用并不是用来声明函数的返回值而是返回值占位符。
由于函数有一个后置的返回类型,auto可以出现在函数声明中的返回值位置。在这种情况下,auto并不是告诉编译器去推断返回类型,而是指引编译器去函数的末端寻找返回值类型。为了进一步说明这个语法,再看另一个例子:int& foo(int &i); float foo(float &f); template<typename T> auto func(T &val) ->decltype(foo(val)) { return foo(val); }
1.深度剖析智能指针
本文通过分析部分源码和一些博客文章,希望能深入了解智能指针的实现原理,文章链接如下:
-
智能指针原理剖析(总结向)
1.1 智能指针概述
- 首先提出一个问题:通过普通指针来管理动态内存有什么劣势?
- 忘记释放动态内存
在使用new/malloc分配动态内存时,需要使用delete/free手动释放内存。但程序员容易忘记释放内存,从而造成内存泄漏; - 动态内存释放时机不对
在尚有指针引用内存的情况下,程序员释放了内存从而产生悬空指针。典型案例就是:在多线程编程中,线程A和B分别有指针p1、p2指向对象object,若某个时刻线程A通过p1将object销毁了(释放了object所占有的内存),那么线程B中p2引用object就会产生内存错误。
- 忘记释放动态内存
普通指针会造成程序员管理动态内存的困难,这促使了智能指针的产生。智能指针可以自动隐式地释放动态内存,大大减少了内存错误,更方便对动态内存进行管理。
- 又引出下一个问题:智能指针是如何实现自动释放堆内存的?
- 首先智能指针并不是一个常规指针,它是一个类模板。由智能指针实例化出来的对象具有和常规指针相似的行为比如说解引用和调用成员,但是它能够自动释放所指向的对象,因此我们称之为"智能指针";
- 对于编译器来说,智能指针实际上是一个栈对象,在栈对象生命期即将结束时,智能指针通过析构函数释放由它管理的动态内存;
- 其实,智能指针就是利用栈对象出作用域自动析构的特点,把资源释放的代码封装在智能指针的析构函数中。所谓"智能"二字,就是体现在开发者无需关注资源的释放,无论程序如何编写,逻辑怎样组织,在资源到期的时候一定会被释放;
- C++11为我们提供了带引用计数和不带引用计数的智能指针,包括auto_ptr, scoped_ptr, unique_ptr, shared_ptr, weak_ptr。
1.2 auto_ptr
- 特点
- 独占性指针,即同一时刻只允许一个智能指针拥有资源所有权;
- 拷贝构造或拷贝赋值时通过转移指针所有权来避免浅拷贝带来的堆内存重复释放问题;
- auto_ptr是智能指针的初始版本,其设计原则为“严禁一物二主”。auto_ptr的设计中存在不合理之处,即提供了拷贝构造、拷贝赋值操作,却会置空原对象指针成员,这样显得拷贝毫无意义,而且在使用中极易误操作原对象,从而出现错误且很难发现。
1.3 scoped_ptr
- 特点
- 禁止使用拷贝构造函数和赋值运算符函数的重载(声明为private);
- scoped_ptr和auto_ptr不同的是:auto_ptr可以转让资源所有权,在转让的时候原对象失效,而scoped_ptr则是不允许转让使用权。
1.4 unique_ptr
-
特点
- unique_ptr是auto_ptr的进化版本,也是一种独占式指针,但是合理性更强。unique_ptr禁止了拷贝构造和赋值重载,通过右值引用的方式来进行移动构造和移动赋值,同时也比auto_ptr多了一个删除器;
- unique_ptr的实现比较复杂,unique_ptr利用移动构造函数和移动赋值函数来实现资源所有权的转移,即通过右值引用的方式把内存资源的所有权转移给新对象。原对象销毁后,程序员将无法再对原对象进行操作,这也避免了auto_ptr的缺点,因此现在C++标准库推荐使用unique_ptr;
- unique_ptr与auto_ptr的另一个不同点是:auto_ptr直接在析构函数中释放内存资源;unique_ptr中定义了一个删除器,会在析构函数中调用删除器,再通过删除器释放资源。
-
源码剖析
- 源码实在晦涩和复杂,等日后精进了C++模板技术后再品读;
- 这里从源码角度总结一下unique_ptr和auto_ptr的区别:
- auto_ptr允许使用左值拷贝构造和赋值重载;unique_ptr禁止使用一般的拷贝构造和赋值,但提供了带右值引用的拷贝构造和赋值重载函数(即移动构造/赋值函数);
- auto_ptr直接利用析构函数来释放其占有的资源;而unique_ptr利用删除器来释放资源,可以使用默认删除器或自定义删除器;
- auto_ptr无法管理数组资源,但unique_ptr可以管理数组资源;
在C++11中,unique_ptr完全替代了auto_ptr,这归功于“移动语义”的出现。其中std::move可以让程序设计更加灵活,可以尽可能的提高程序效率,但是必须要注意的是"移动"过后原对象不再持有资源,我们不应该试图去访问它。
1.5 shared_ptr
智能指针可以分为两类:
-
独占式指针: 在同一时刻,只有一个智能指针拥有并管理资源,如auto_ptr和unique_ptr;
-
共享式指针: 允许多个智能指针持有同一资源,并且当没有智能指针持有该资源时就自动销毁该资源,比如shared_ptr和weak_ptr。
为了实现 “共享和智能” ,就需要用到一个计数器,来记录某个资源被多少个对象持有,当计数器为0时就销毁该资源。
在shared_ptr和weak_ptr中,这样的计数器实际上一个单独的类。它主要负责资源引用计数的增减和资源的释放。
实现一个简单的shared_ptr
- 参考文章: 带引用计数的智能指针
带引用计数的智能指针设计的精妙之处在于,多个智能指针共享同一个计数器,智能指针本身并不直接管理资源,计数器对象才是真正的资源管理者。
因此我们设计的智能指针存在两个类: Referenced(计数器类)和Ref_ptr(智能指针类),下面是这两个类的类图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0ObZHHXL-1630128769206)(Referenced.drawio.png)]
-
简化版shared_ptr
#include <iostream> #include <string> using namespace std; class Referenced { public: Referenced(int *ptr) { refcount = 1; p = ptr; } int add_ref() { return ++ refcount; } int sub_ref() { return -- refcount; } int count() { return refcount; } ~Referenced() { cout << "delete resource" << endl; delete p; } private: int refcount; //引用计数 int *p; //共享的资源 }; class Ref_ptr { public: Ref_ptr(int *p):ptr(new Referenced(p)) {} //将资源交由计数器来管理 Ref_ptr(const Ref_ptr &r) { ptr = r.ptr; ptr->add_ref(); } Ref_ptr& operator=(const Ref_ptr &r) { if(&r == this) return *this; if(ptr->sub_ref() == 0) { cout << "当前不存在智能指针引用该资源,释放资源" << endl; delete ptr; } ptr = r.ptr; ptr->add_ref(); //当前对象引用了赋值对象的资源 return *this; } ~Ref_ptr() { if(ptr->sub_ref() == 0) { cout << "当前不存在智能指针引用该资源,释放资源" << endl; delete ptr; } } private: Referenced *ptr; //指向计数器对象 };
从上面的代码我们可以总结几个关键点:
- 调用Ref_ptr的拷贝构造函数,赋值重载函数和析构函数都会让引用计数(refcount)发生变化;
- 拷贝构造函数由于有一个新对象引用到了资源,因此引用计数加1;
- 赋值重载函数由于原对象引用到了一个新资源,那么原对象指向的资源对应的计数器引用计数就要减1(如果减至0就释放资源),新资源对应的计数器引用计数就加1;
- Ref_ptr对象调用析构函数时,Ref_ptr对象即将被销毁,其对应资源的计数器引用计数减1(如果减至0就释放资源)。
- 调用Ref_ptr的拷贝构造函数,赋值重载函数和析构函数都会让引用计数(refcount)发生变化;
shared_ptr源码剖析
-
shared_ptr的设计
-
计数器
-
与之前我们实现的简化版shared_ptr不同的是: 存在两个计数变量_Uses和_Weaks;
-
因为存在两种带引用计数的智能指针shared_ptr和weak_ptr,两者对应的计数器有相同点也有不同点。这里设计了一个_Ref_count_base,存在两个计数变量:_Uses和_Weaks(强引用计数和弱引用计数),通过源码似乎可以看出,当_Uses减至0资源会被释放,但计数器资源暂时不会释放直到_Weaks减至0才释放;
-
_Ref_count_base是一个抽象基类,其子类有三种: _Ref_count、_Ref_count_del和_Ref_count_del_alloc。这三种子类从前往后功能不断扩展,主要体现在计数、删除器和分配器三个方面;
-
_Ref_count_base的三个子类不同点主要体现在其重写的_Destroy和_Delete_this各有区别;
-
-
_ptr_base
-
其成员变量为:
private: _Ty *_Ptr; //资源指针 _Ref_count_base_ *_Rep; //计数器指针
其中,_Rep是指向一个计数器实例的指针,它的类型是计数器基类可以指向其不同的子类计数器对象,这也是C++11中的一个多态的应用。
-
-
shared_ptr
-
shared_ptr继承自_Ptr_base,也就有了基类中的资源指针_Ptr和计数器指针_Rep,除此之外shared_ptr中并未定义任何成员变量,只定义了两种类型_Myt和_Mybase;
-
shared_ptr的构造函数
- 一般参数构造
即用非完整对象来进行构造,如资源指针、删除器实例和分配器实例的一个或多个组合参数来构造。
- 此类构造函数内部都会调用一个_Resetp函数,_Resetp函数有三个重载版本,定义如下:
template<class _Ux> void _Resetp(_Ux *_Px) { ... _Resetp0(_Px, new _Ref_count<_Ux>(_Px)); ... } template<class _Ux, class _Dx> void _Resetp(_Ux *_Px, _Dx _Dt) { ... _Resetp0(_Px, new _Ref_count_del<_Ux, _Dx>(_Px, _Dt)); ... } template<class _Ux, class _Dx, class _Alloc> void _Resetp(_Ux *_Px, _Dx _Dt, _Alloc _Ax) { ... _Refd *_Ptr = _Al.allocate(1); :new (_Ptr) _Refd(_Px, _Dt, _Al); _Resetp0(_Px, _Ptr); }
_Resetp函数的三个重载版本,分别对应了三种计数器,每一种_Resetp函数中都会用相应的计数器实例作为参数调用_Reset0()函数,_Reset0定义如下:
template<class _Ux> void _Reset0(_Ux *_Px, _Ref_count_base *_Rx) { this->_Reset0(_Px, _Rx); _Enable_shared(_Px, _Rx); }
这里的_Reset0函数是在基类_Ptr_base中定义的,用来设置_Ptr和_Rep。到这里,这一类构造函数的构造过程也就清楚了,举个例子:如果构造时只用一个资源指针_Ptr构造,那么就会通过_Resetp分配一个_Ref_count计数器实例,然后用传入的资源指针_Ptr和_Ref_count来构造当前shared_ptr对象;如果构造时还传入了删除器,同理就分配_Ref_count_del计数器实例,然后进行构造;如果构造时还传入了分配器,就用_Ref_count_del_alloc来构造。不管是哪种构造方式,构造结束后计数器中的计数变量都为1。
不管是什么情况,shared_ptr对象一开始不是从无参构造得到,就是通过这类构造函数得到,从_Resetp的几个版本函数中都可以看到,shared_ptr本身持有的_Ptr和与之绑定的计数器的_Ptr是相同的,也就是说,在正常情况下,shared_ptr和它所对应的计数器所持有的资源指针,是相同的。并且,计数器所对应的资源指针,一经构造,则不可更改。
-
移动构造
移动构造函数中,多处用到了move/forward,其作用是为了保持参数的右值型,让这些参数在内部调用时也能以右值的类型传入(完美转发的一个应用)。移动构造相当于把参数对象的资源指针和计数器指针转移到构造对象上,资源引用计数应该不变。
<br/>
-
赋值重载
shared_ptr同时提供了左值和右值版本的赋值函数,这里的赋值实现的非常巧妙,我们用其中一种赋值函数做例子说明:
//用相同类型的shared_ptr进行赋值 _Myt& operator=(const _Myt& _Right) { shared_ptr(_Right).swap(*this); return (*this); }
这些赋值函数内部实现都有一个共同点: 先用参数对象构造一个临时的shared_ptr对象,然后再将当前对象与这个临时对象swap。swap函数作用,就是交换当前对象和临时对象的_Ptr和_Rep。那么,为什么要这么做呢?
既然要实现赋值,对shared_ptr而言就需要做三件事情:- 将参数对象的_Ptr和_Rep赋值给当前对象;
- 增加参数对象持有资源的引用计数;
- 减少当前对象持有资源的引用计数。
//用参数对象构造一个临时对象,这就相当于让参数对象持有资源的引用计数+1; //由于临时对象的_Ptr和_Rep就是参数对象的_Ptr和_Rep,通过临时对象和当前对象交换,相当于将_Ptr和_Rep赋值给当前对象; //临时对象是一个栈上对象,当前语句结束后会自动析构临时对象,临时对象会让原来的当前对象持有资源的引用计数-1;
通过shared_ptr().swap(*this),使得本来较复杂的三步操作被巧妙的转化成了一条语句。
-
-
循环引用计数问题
shared_ptr看起来是一种完美的智能指针,引用计数解决了多个shared_ptr对象引用同一个资源的问题,但是同样由于引用计数的存在也会引发相应的问题。
现在假设有4个shared_ptr对象A、B、C、D,其中A和B分别持有资源M和N,而C和D又恰好分别属于资源M和N,更离谱的是: C持有资源N,而D持有资源M,就像下面这样:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7nW6mUfJ-1630128769208)(循环引用计数.drawio.png)]
接下来我们看一下如何才能正常地释放资源M和N:- 为了释放资源M,那么对象A和D必须正常析构,但对象D析构的前提是资源N被正常释放,等价于资源M释放的前提是资源N的释放;
- 为了释放资源N,那么对象B和C必须正常析构,但对象C析构的前提是资源M被正常释放,等价于资源N释放的前提资源M的释放;
这样就引起了类似于死锁的问题: 资源M需要资源N释放才能释放,而资源N需要资源M释放才能释放,那么最终只能导致资源M和N都无法正常释放。
这个问题的根本,就是对象C和D的存在增加了对应资源M和N的引用计数,那么有没有什么办法既可以让对象C和D分别持有资源N和M,同时又不增加相应资源的引用计数呢?有两个办法可以实现:一是裸指针,另一个就是接下来要介绍的weak_ptr。
-
1.6 weak_ptr
-
强引用计数和弱引用计数
这两个值分别对应计数器基类中的_Uses和_Weaks,当强引用计数减至0时销毁资源,当弱引用计数减至0时销毁计数器对象。因为同一资源的弱引用计数一定大于等于强引用计数,如果此时弱引用计数都为0了就说明没有任何weak_ptr再持有这个资源,当然也不可能有shared_ptr持有这个资源,直接销毁计数器对象即可。
-
重要成员
-
expired()
weak_ptr相较于裸指针的优势,就是可以随时查看它所持有的资源是否有效,即资源是否已经被释放。
-
lock()
这个函数会使用weak_ptr的资源指针和计数器去构造一个shared_ptr,如果其持有的资源被释放,就返回一个无参构造的shared_ptr;如果资源还未释放,通过lock()会将weak_ptr提升为一个shared_ptr。
-
1.7 总结
应当遵从RAII原则,资源的获取时刻就是智能指针的初始化,而不应当用原生指针去构造shared_ptr对象。让我们看看下面这么做会出现哪些问题:
int * p = new A();
std::shared_ptr<A>sp(p); //应当直接std::shared_ptr<A>sp(new A());
这样容易引起两个问题:
- 指针p暴露在外面,这是智能指针所不允许的,因为随时有可能在某个地方delete p;从而影响智能指针的管理;
- 一不小心就会用p去构造多个shared_ptr,比如说sp和sp1都用p来构造,按理来说应当是p这个资源的引用计数为2,但实际上不是的,因为每当一个shared_ptr通过非完整对象构造出来,都会分配一个新的计数器给它,这就相当于sp和sp1的计数器是不同的,相当于有两个计数器管理同一资源,当sp析构,导致sp对应的计数器的引用计数为0,此时就会释放资源p,等到sp1析构的时候,又会使得sp1对应的计数器引用计数为0,此时又会释放资源p,这样就引起了多次析构同一资源。为了避免这一问题,就应当遵从RAII原则,避免原生指针和智能指针同时存在;
2.右值引用与移动语义、引用重叠和完美转发
说一点个人对std::move()的理解: (type/value category)
这个体会主要是针对std::move()在unique_ptr中移动构造和移动赋值的使用, 显然这些移动构造和移动赋值需要一个右值作为参数才会调用。右值对象是一个临时对象因此右值传入后就会立即销毁,可见在后续的语句中我们也没有机会去用到它了,这个点就是unique_ptr比auto_ptr更合理的原因,auto_ptr接受左值对象进行拷贝构造或赋值,一旦我们在后续代码中使用了原左值对象就会产生非法行为。但unique_ptr比较奇怪的点是:它也允许接受一个左值进行拷贝或赋值,这样一看auto_ptr的缺点不是依然存在吗?其实并不是这样的,考虑到有这样一类左值对象,它们后续都不会在被使用直到脱离作用域自动析构,这样的左值对象似乎具有"临时对象"的特质,std::move()的意义就体现在这里,此函数会将那些后续不再被使用的左值对象转化为右值引用从而实现了“左值对象也可以进行右值构造或赋值”。至于这样的左值对象在后续中是否还被用到是程序员指定的,C++11仅仅是为我们提供了这样灵活的使用方式,程序员需要保证此对象在后续不在被使用否则也会出现和auto_ptr一样的错误。
2.1 左值/右值引用与移动语义
常左值引用绑定右值: https://www.cnblogs.com/catch/p/3251937.html
对于std::move()而言,其功能是将左值对象转化为右值对象,此对象的生存期会发生变化吗?
-
左值/右值引用
-
左值和右值的概念
C++中的对象类型分为两大类:Type和Value Category,其中Type就是数据类型,而值类型就是左值/右值类型;
C++对左值和右值没有标准定义,但存在一个被广泛认同的说法:可以取地址的(在内存中有确定的存储地址),有名字的,非临时的就是左值;不能取地址的,没有名字的,临时的就是右值;
可见,立即数、函数返回值等都是右值;而非匿名对象、函数返回的引用,const对象等都是左值;
从本质上理解,创建和销毁由编译器幕后控制,程序员只能确保在本行代码有效的,就是右值;而用户创建的,通过作用域规则可知其生存期的,就是左值。
-
左值引用
左值引用就是传统的我们所熟知的引用,左值引用的底层是一根常指针,C++的语法层面上可以将其当作是某一个具名变量的别名,但编译器是将其当作指针来使用的。int a = 0; int &b = a; // 编译器: int* const b = &a; b = 4; // 编译器: *b = 4; printf("%d\n", b);
-
右值引用
右值引用是C++11引入的特性,用于绑定一个右值,绑定到右值以后本来会被销毁的右值的生存期会延长至绑定到它的右值引用的生存期(右值引用是一个左值);
当然,右值引用的引入并不是为了替代左值引用,我们主要利用右值(临时对象)的构造来减少对象的构造和析构操作以提高程序效率。
接下来,我们实现一个简单的顺序栈,以此来理解右值引用是如何提高程序效率的:class Stack { public: // construct Stack(int size = 1000) :msize(size), mtop(0) { cout << "Stack(int)" << endl; mpstack = new int[size]; } // Destroy ~Stack() { cout << "~Stack()" << endl; delete[]mpstack; mpstack = nullptr; } // 拷贝构造 Stack(const Stack &src) :msize(src.msize), mtop(src.mtop) { cout << "Stack(const Stack&)" << endl; mpstack = new int[src.msize]; for (int i = 0; i < mtop; ++i) { mpstack[i] = src.mpstack[i]; } } // 赋值重载 Stack& operator=(const Stack &src) { cout << "operator=" << endl; if (this == &src) return *this; delete[]mpstack; msize = src.msize; mtop = src.mtop; mpstack = new int[src.msize]; for (int i = 0; i < mtop; ++i) { mpstack[i] = src.mpstack[i]; } return *this; } int getSize() { return msize; } private: int *mpstack; int mtop; int msize; }; Stack GetStack(Stack &stack) { Stack tmp(stack.getSize()); return tmp; }
我们为了解决浅拷贝问题做了深拷贝,即为类提供了自定义的拷贝构造函数和赋值运算符重载函数,但是这两个函数内部先是开辟了较大的内存空间,然后将数据逐个复制,这显然是非常消耗时间和资源的。下面我们用这个类做最基本的拷贝赋值操作:
int main() { Stack s; s = GetStack(s); return 0; }
运行结果如下:
Stack(int) //构造s Stack(int) //构造tmp Stack(const Stack&) //tmp拷贝构造main函数栈帧上的临时对象 ~Stack() //tmp析构 operator= //临时对象赋值给s ~Stack() //临时对象析构 ~Stack() //s析构
分析上述运行结果,有两处分别使用了拷贝构造函数和赋值重载函数,即tmp拷贝构造main栈帧上的临时对象和临时对象赋值给s,我们可以发现tmp和临时对象在各自的操作结束后便销毁了,它们所维护的资源也随之释放,从这里就可以看出一些值得优化之处: 拷贝构造和赋值既然是维护和传入对象一样的资源,如果传入对象是临时对象,那我们是否可以将其维护的资源转移给新对象呢?这样似乎可以避免大量的空间创建和数据搬运工作。
在C++11中,可以通过提供带右值引用参数的拷贝构造函数和赋值运算符重载函数来解决上述问题。// 带右值引用参数的拷贝构造函数 Stack(Stack &&src) :msize(src.msize), mtop(src.mtop) { cout << "Stack(Stack&&)" << endl; /*此处没有重新开辟内存拷贝数据,把src的资源直接给当前对象,再把src置空*/ mpstack = src.mpstack; src.mpstack = nullptr; } // 带右值引用参数的赋值运算符重载函数 Stack& operator=(Stack &&src) { cout << "operator=(Stack&&)" << endl; if(this == &src) return *this; delete[]mpstack; msize = src.msize; mtop = src.mtop; /*此处没有重新开辟内存拷贝数据,把src的资源直接给当前对象,再把src置空*/ mpstack = src.mpstack; src.mpstack = nullptr; return *this; }
此时作为临时量的tmp和临时对象会自动匹配右值引用版本的成员方法,大大提高了程序效率。
-
-
移动语义
带右值引用参数的拷贝构造函数和赋值重载函数又叫做移动构造函数和移动赋值函数,这里的“移动”非常形象,是指将临时对象的资源所有权转移给了当前对象,临时对象就不再持有资源了为nullptr。
- std::move
如果存在一些具名对象,可以确定它们在后续的程序中不再被使用,我们也希望这样的左值对象在进行拷贝或赋值时调用移动构造函数和移动赋值函数,但编译器只对右值才能调用移动构造/赋值函数,标准库提供了函数std::move,这个函数内部会将左值对象的类型强制转换为右值引用。 - 说一点个人对std::move()的理解: (type/value category)
这个体会主要是针对std::move()在unique_ptr中移动构造和移动赋值的使用, 显然这些移动构造和移动赋值需要一个右值作为参数才会调用。右值对象是一个临时对象因此右值传入后就会立即销毁,可见在后续的语句中我们也没有机会去用到它了,这个点就是unique_ptr比auto_ptr更合理的原因,auto_ptr接受左值对象进行拷贝构造或赋值,一旦我们在后续代码中使用了原左值对象就会产生非法行为。但unique_ptr比较奇怪的点是:它也允许接受一个左值进行拷贝或赋值,这样一看auto_ptr的缺点不是依然存在吗?其实并不是这样的,考虑到有这样一类左值对象,它们后续都不会在被使用直到脱离作用域自动析构,这样的左值对象似乎具有"临时对象"的特质,std::move()的意义就体现在这里,此函数会将那些后续不再被使用的左值对象转化为右值引用从而实现了“左值对象也可以进行右值构造或赋值”。至于这样的左值对象在后续中是否还被用到是程序员指定的,C++11仅仅是为我们提供了这样灵活的使用方式,程序员需要保证此对象在后续不在被使用否则也会出现和auto_ptr一样的错误。
- std::move
2.2 引用重叠和完美转发
-
引用折叠
所谓折叠就是将左值引用和右值引用两种类型进行排列组合,存在如下四种情况:
T& & T& && T&& & T&& &&
无论两种引用类型如何进行排列,所有的引用折叠最终都代表一个引用,要么是左值引用要么是右值引用,规则为:如果其中任一引用为左值引用,则结果为左值引用,否则为右值引用(即两个都是右值引用)。
需要注意的是,引用折叠主要配合模板技术实现完美转发,且不允许我们在代码中随意使用,可以将其理解为编译器的一种规则。
-
万能引用
所谓“万能引用”并非C++的语法特性,而是利用C++的语法规则实现的一个功能。因为这个功能既可以接受左值类型的参数,又可以接受右值类型的参数,因此称其为“万能”。
万能引用的形式如下:template<typename T> void func(T&& param) { //函数实现 }
这个模板函数为什么既能接受左值参数又能接受右值参数呢?下面分别将左值和右值对象传入上述函数:
int a = 0; func(a); //传入左值 func(int(1)); //传入右值
- 对于传入的左值a,首先编译器会将T推导为int&类型,将int&替换到T后就会得到int& &&。根据引用折叠,int& &&等价于int&,因此实例化的函数为void func(int ¶m),因此它能接受左值参数;
- 对于传入的右值,编译器将T推导为int类型,替换后得到int &&。显然实例化后的func也可以接受一个右值参数。
总结一下,所谓万能引用就是利用模板推导和引用折叠的相关规则,生成不同的实例化模板来接收传进来的参数。
-
完美转发(精确传递)
- 转发: 函数所接受的实参传递给形参,在函数体内可能会将此形参传入给另一个函数;
- 完美转发: 如果在转发过程中调用到了正确的函数版本,则称之为完美转发。何为“正确”,即调用函数的参数类型和实参类型相匹配。
// 万能引用,转发接收到的参数 param template<typename T> void PrintType(T&& param) { f(param); // 将参数param转发给函数 void f() } // 接收左值的函数 f() template<typename T> void f(T &) { cout << "f(T &)" << endl; } // 接收右值的函数f() template<typename T> void f(T &&) { cout << "f(T &&)" << endl; } int main(int argc, char *argv[]) { int a = 0; PrintType(a);//传入左值 PrintType(int(0));//传入右值 }
注意,在函数内部将形参param传递给另一个函数时,此时的param是被当作左值进行传递的(因为这里的param是一个具名对象)。也就是说万能引用函数的内部形参都变成了左值,那万能引用也就没有任何意义了。 但我们还有一些其他的手段,比如使用std::forward(类比std::move),请看下面对std::forward的分析。
-
std::forward
从上文我们了解到模板中的T保存着传递进来的实参的信息,std::forward正是利用T的信息来进行强制类型转换,使得param和传入实参的类型保持一致。
std::forward的源码形式大致如下:template<typename T> T&& forward(T ¶m) { return static_cast<T&&>(param); }
我们可以看到,不管T是值类型,还是左值引用,还是右值引用,T&经过引用折叠,都将是左值引用类型。也就是forward以左值引用的形式接收参数 param, 然后 通过将param进行强制类型转换 static_cast<T&&>(),最终再以一个T&&返回。
-
总结
通过引用折叠,我们实现了万能模板。在万能模板内部,利用forward函数,本质上是又利用了一遍引用折叠,实现了完美转发。其中,模板推导扮演了至关重要的角色。
3. lambda表达式与函数对象
lambda表达式和函数对象的联系与区别
lambda表达式可以编写内嵌的匿名函数,其广泛应用于STL提供的泛型算法,仅使用一条语句就替换了独立函数(函数指针)或函数对象,使得代码更加简洁易读;
但从本质上来说,lamda表达式只是一种语法糖,因为所有其能够完成的工作都可以用其它稍微复杂的代码来实现,从广义上说,lambda产生的是函数对象。
如果在类中重载了函数调用运算符(),那么类实例化出的对象会具有类似于函数的行为,我们称这样的对象为函数对象或仿函数。相比于lambda表达式,函数对象有自己独特的优势。
lambda表达式基本用法
lambda表达式的完整形式比较复杂一般情况下用不到,下面给出常用的简化形式:
[capture list] (params) ->ret {body}
[capture list] (params) {body}
[capture list] {body}
- capture list(捕捉列表)
捕捉列表,不可省略。捕捉列表是lambda表达式的开始标志,编译器可以根据该“标志”来判断该函数是否作为lamda函数。同时,捕捉列表能够捕捉上下文中的变量给lambda函数使用。函数体除了使用参数列表中的参数以外,还可以使用所有捕获到的变量。
捕捉的方可以是引用也可以是复制,有以下几种情况来捕捉在作用域的变量:
[]:默认不捕获任何变量;
[=]:捕获外部作用域中所有变量,并拷贝一份在函数体中使用(值捕获)
[&]:捕获外部作用域中所有变量,并作为引用在函数体中使用(引用捕获)
[x]:仅以值捕获x,其它变量不捕获;
[&x]:仅以引用捕获x,其它变量不捕获;
[=, &x]:捕获外部作用域中所有变量,默认是值捕获,但是x是例外,通过引用捕获;
[&, x]:默认以引用捕获所有变量,但是x是例外,通过值捕获;
[this]:通过引用捕获当前对象(其实是复制指针);
[*this]:通过传值方式捕获当前对象;
- lambda表达式底层原理(闭包)
每当定义一个lambda表达式时,编译器会自动生成一个匿名类(其中重载了()运算符),我们称为闭包类型;
在运行时期,这个lambda表达式就会返回一个匿名的闭包实例(一个右值)。闭包的一个强大之处是可以通过传值或引用的方式捕捉其封装在作用域内的变量,前面的[]就是用来定义捕捉模式和变量,我们又将其称为lambda捕捉块;
捕捉方式分为: 复制捕捉和引用捕捉;
-
复制捕捉
对于复制传值捕捉方式,lambda表达式生成的闭包类中会添加对应类型的非静态数据成员。在运行时,会用复制的值去初始化这些类成员变量,从而生成闭包实例。闭包一般如下:
class ClosureType { public: // ... ReturnType operator(params) const { body }; }
我们可以看到函数调用运算符重载函数是const修饰的,这意味着lambda表达式无法修改通过复制形式捕捉的变量。如果你想修改通过传值方式捕捉的变量,需要在参数列表和函数体之间加mutable关键字。
-
引用捕捉
对于引用捕捉方式,无论是否加mutable,都可以在lambda表达式中修改捕捉的变量的值。但闭包类中的成员是何种形式的,C++标准给出的答案是: 不确定的。还有一点需要注意,lambda表达式是不能被赋值的。
避免使用[=]和[&]默认捕获所有变量
-
默认引用捕获所有变量出现的问题
在函数内部使用时,可能会出现悬挂引用(类似悬空指针),因为引用捕获不会延长引用的变量的生命周期:
std::function<int(int)> add_x(int x) { return [&](int a) { return x + a; }; }
因为参数x仅是一个临时变量,函数调用后就被销毁,但返回的lambda表达式却引用了该变量,当调用这个表达式时,引用的是一个垃圾值,所以会产生没有意义的结果。可以通过值捕获的方式来解决上面的问题:
std::function<int(int)> add_x(int x) { return [=](int a) { return x + a; }; }
-
默认值捕获所有变量出现的问题
采用默认值捕获所有变量可以避免悬挂引用问题,但仍然存在风险,如下例:
class Filter { public: Filter(int divisorVal): divisor{divisorVal}{} std::function<bool(int)> getFilter() { return [=](int value) {return value % divisor == 0; }; } private: int divisor; };
上述Filter类中有一个成员方法,可以返回一个lambda表达式,这个表达式使用了类数据成员divisor,但数据成员divisor对lambda表达式并不可见,可用下面的代码进行验证:
// 类的方法,下面无法编译,因为divisor并不在lambda捕捉的范围 std::function<bool(int)> getFilter() { return [divisor](int value) {return value % divisor == 0; }; }
那么原来的代码为什么能够捕捉到呢?这是因为每个非静态成员函数都有一个this指针,利用this指针你可以接近任何成员变量,所以第一段代码实际上lambda表达式捕捉的是this指针的副本,因此原来的代码等价于:
std::function<bool(int)> getFilter() { return [this](int value) {return value % this->divisor == 0}; }
我们可以发现,尽管是以值方式捕获但捕捉到的是指针,其实相当于以引用的方式捕捉到了当前类对象,所以lambda表达式的闭包与一个类对象绑定到一起了,这也很危险,因为你仍然有可能在类对象析构后使用这个lambda表达式,那么类似“悬挂引用”的问题也会产生。所以,采用默认值捕获所有变量仍然是不安全的,主要是可能会出现指针变量的复制,实际上还是按引用传值。
C++11为何引入lambda表达式?(lambda表达式带来哪些好处)
- 其定义和使用是在同一个地方进行的,便于查阅代码;
- lambda表达式可访问作用域内的任何变量;
- C++引入lambda表达式的主要目的是: 能够将类似于函数的表达式直接用作函数的参数;
- 典型的lambda表达式是测试表达式或比较表达式,可编写一条返回语句;
- lambda表达式能够自动推断返回类型。
函数对象
函数对象是一个高级抽象的概念,因为所有具有函数行为的对象都可以称为函数对象,我们不关心对象到底是什么,只要其具有函数行为。所谓的函数行为指的是可以使用()调用并且传递参数。
函数对象的优势
本质上,函数对象是类对象,因此它可以实现更强大的功能:
-
函数对象带有状态
函数对象相对于普通函数而言是“智能函数”,这就如同智能指针相较于传统指针。因为函数对象除了提供函数调用符重载以外,还可以拥有其他方法和数据成员,所以说函数对象是有状态的。即使同一个类实例化的不同函数对象的状态也不同,这是普通函数无法做到的。而且函数对象可以在运行时创建。
-
每个函数对象都有自己的类型
函数对象的类型是其类的类型,这意味着函数对象可以用于模板参数,这对泛型编程意义重大。
-
函数对象一般快于普通函数
因为函数对象一般用于模板参数,模板一般会在编译时做一些优化。
【注】for_each算法的返回值竟然是一个函数对象,这是我之前没想到的…
4. C++的四种强制类型转换
dynamic_cast运算符
除了dynamic_cast以外的转换,其转换行为都是在编译时期就得以确定的,转换是否成功并不依赖于被转换的对象。
而dynamic_cast则不然,在进行类型转换时dynamic_cast会检查source对象是否真的可以转换成target类型,这种检查不是语法上的,而是真实情况的检查。
因此,dynamic_cast依赖于RTTI信息,而RTTI信息储存于虚函数表中,这就导致了我们在使用dynamic_cast时有一个前提条件: 当我们将dynamic_cast用于某种类型的指针或引用时,只有该类型含有虚函数时,才能进行这种转换。否则,编译器会报错(传回一个空指针)。
- dynamic_cast的主要用途
将基类的指针或引用安全地转换为派生类的指针或引用,并用派生类的指针或引用调用非虚函数。
下面来分析一下这个用途,因为看起来实在是诡异,我们都知道如果想调用派生类的虚函数,可以利用多态直接调用并不需要类型转换。实际上我们可以将所有想调用的派生类的函数都声明为虚函数,然后在基类中也写对应的接口。
接下来想一想,如何利用基类指针调用派生类的非虚函数呢?多态是不可能实现了,此时只能将基类指针指向的对象转换为派生类类型。首先需要保证这种转换是一种upcast,这样程序才不会出错,也正因为dynamic_cast想实现这个用途所以一定要进行安全类型检查。
因为限制了这种转换为upcast,所以基类指针一定要指向派生类对象,但是想要检查出指针指向对象的实际类型是一种“运行时类型识别”,简称RTTI。RTTI信息存放于虚函数表中,这也是为什么要求类型必须含有虚函数的原因。
-
dynamic_cast能够成功转换的三种情况
- 派生类向基类转换(upcast)
- 基类指针或引用指向派生类对象
- 相同类型之间一定可以转换成功
const_cast运算符
const_cast一般用于修改指针:
- 常量指针被转化为非常量指针,并且仍然指向原来的对象;
- 常量引用被转化为非常量引用,并且仍然指向原来的对象。
注意,如果指向的对象是一个常量,那么即使通过const_cast转化后也无法修改对象的值。
static_cast运算符
用于任何隐式类型转换,但没有动态类型检查,是不安全的:
- 用于类层次结构中基类和派生类之间指针或引用的转换。注意,上行转换是安全的,但下行转换由于没有动态类型检查,所以是不安全的;
- 用于基本数据类型之间的转换,但这种转换的安全性需要开发者来维护;
- C++primer中有句话: C++的任何隐式类型转换都是用static_cast来实现的。
reinterpret_cast运算符
用于处理无关类型转换,通常为操作数的位模式提供较低层次的重新解释:
- reinterpret_cast仅仅是重新解释了给出对象的比特模型,并没有进行二进制的转换;
- 用于任意指针,引用间的转换,指针和足够大的int型之间的转换,整数到指针的转换。
5. explicit关键字
explicit意为“显式的”,该关键字主要用途是用于防止类构造函数出现隐式类型转换的情况,而且仅适用于含有一个参数的构造函数。
【注】为什么explicit仅适用于一个参数的构造函数呢?因为隐式类型转换发生在赋值的时候,只可能赋一个值而不可能赋多个值。
构造函数是如何发生隐式转换的?
来看这个例子:
class A
{
public :
A(int a)
{
cout << "Constructor called and param is "<<a<<" ! " << endl;
m = a;
}
A()
{
cout << "Default Constructor called !" << endl;
};
~A()
{
cout << "Destructor called ! " << endl;
}
int m;
};
int main()
{
A a;
a=10;
cout << "m = "<<a.m << endl;
system("pause");
return 0;
}
其中“a=10;”一句中,a为类对象,将10赋值给一个类对象,这显然是不合理的。但是在这种情况下,编译器不会报错,下面是这段代码的运行结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KbGrCYBl-1630128769209)(explicit防隐式转换.png)]
可以看到,这里调用了两次构造函数和一次析构函数,其中第一次是调用无参构造来构造对象a,而对象a还未脱离作用域显然不可能调用析构函数去析构a,那么这多出来的一次有参构造和析构函数是什么调用的呢?
实际上,这里的“a=10;”等价于“A temp(10); a=temp;”,也就是说当编译器发现右操作数和左值类型不匹配时,会查看左值类型是否有含有右操作数类型的构造函数,如果存在这样的构造函数,那么就以右操作数为参数构造一个临时对象,再将临时对象赋值给a。当然,以上这些操作都是编译器在底层去完成的,在可见的代码层面就好像是进行“类型转换”一样,因此称为“隐式类型转换”。
因此一旦出现了上述情况,编译器并不会报错,由此也可能造成最终意想不到的结果以及构造临时对象带来的效率降低。
explicit的引入
为了避免以上情况的发生,除了开发人员自行注意以外,还可以借助explicit关键字来防止隐式转换的发生。只需要在构造函数前加上该关键字,即explicit A(int a){…},那么就表示这个构造函数只能显式调用,编译器就会对上述情况报错。