在C代码的函数调用中传递简单类型的变量是没什么问题的,如int、float的变量,也就几个字节,多拷贝几次都不会有太大的资源开销。但在工程应用上,我们的代码充满了业务相关的结构体的变量,这些结构体可能很复杂。
下面以图形编程中的矩阵运算为例,我们先实现一个简单的矩阵类型及配套的加法函数:
//矩阵运算V1.0:C传值版本structMatrix{floatdata[3][3];};Matrixadd(Matrixma,Matrixmb){Matrixmr;for(inti=0;i3*3;i++)mr.data=ma.data+mb.data;returnmr;}intmain(){Matrixa={{{1,2,3},{4,5,6},{7,8,9}}};Matrixb={{{2,2,2},{3,3,3},{4,5,6}}};Matrixc=add(a,b);return0;}
在定义Matrix数据结构时,为了简化模型便于理解,我们使用了固定3*3的静态内存分配floatdata[3][3],所以当我们用Matrix定义一个局部变量时,整个矩阵数据data[3][3]都将在函数调用栈上进行分配。虽然整个Matrix仅占用=36B内存,但显然比基本类型的变量大很多了,如果矩阵的维数是*,一个Matrix变量占用的内存将达到近10KB,这种情况下,我们调用add(a,b)这个函数,a和b分别被拷贝了一份给ma和mb,然后把加法结果返回给c时,又会产生一次临时变量并分别拷贝了两次,总计额外拷贝了4次共40KB,对于我们的业务来说,这些内存拷贝操作是昂贵的,而且是多余的。
为什么编译器要帮我们自动创建和拷贝两次临时变量?函数传参好理解,因为调用语义就是传值调用,肯定要重新分配一个新的空间来容纳传入的值;函数返回值稍复杂一些,我们知道,局部变量(这里指Matrix对象)分配在函数调用的栈空间上,当函数调用add(a,b)退出时,本次调用的栈空间也被自动释放了(栈是后进先出),那当你返回一个局部变量时,编译器肯定得在外层函数的栈空间上临时给你分配一块新空间,并把刚才函数退出前的要return的栈变量所占的存储空间的值拷贝一份过去,然后才敢放心地执行下一行代码,等效于临时变量=add(a,b)。因为不这样通过临时及时保护现场的话,下一行代码调用新的函数,原来函数调用的栈空间很快就会被新的调用给征用,原来的局部变量搞不好就被新的变量覆盖。然后才执行到Matrixc=临时变量,编译器才会把这个没有名字的临时变量赋值给外层函数的局部变量c,出现第二次拷贝。虽然现在的编译器大都可以把这种重复拷贝构建的情形优化掉,但在某些情况,编译器的确还弄不清楚具体的逻辑,不敢擅自优化,所以编译器的优化选项不是总是有效。
怎么解决这个问题?使用指针!指针就是一个变量的地址(占用内存才几个字节),我们完全可以传递大变量的地址给函数,计算结果也放入指定的地址中:
//矩阵运算V1.1:C传指针版本voidadd(Matrix*ma,Matrix*mb,Matrix*mr){for(inti=0;i3*3;i++)mr-data=ma-data+mb-data;}intmain(){Matrixa={{{1,2,3},{4,5,6},{7,8,9}}};Matrixb={{{2,2,2},{3,3,3},{4,5,6}}};Matrixresult;add(a,b,result);return0;}
传指针的开销几乎可以忽略不计,所以整体效率终于提上去了,但像add(a,b,result)的书写代码的形式,以及通过指针访问内部变量-的符号实在不太优雅。而且关键是,编程的思维方式变了,我们时刻想着要传指针,要提前准备好空变量(result)让函数内操作它,这种函数内部操作外部资源的扭曲思想,确实不是一个好法子。
C++函数调用:引用传递和操作符重载
为了解决V1.1版本代码书写难看、思想扭曲的问题,C++在诞生之初,就把这个问题列入关键问题,提出了引用类型。所谓引用,就是绑定到某个对象的别名,相当于一个对象有两条名,我们无论使用哪一条名,效果是一样。我们在声明一个引用时,就必须初始化它,把它绑定到一个对象上。一旦一条别名绑定了一个对象,后续该别名不能再绑定到另一个对象。
Matrixa,b;Matrix*pb=b;Matrixra=a;//把ra绑定到a上,ra就是a//终于可以愉快地使用.访问成员了,抛弃了丑陋的-ra.data[1][1]=2.5f;ra=b;//ra是a的一个别名,所以a的值也跟着改变ra=*pb//指针版还得使用丑陋的*转化为对象
看见了吗,上述代码中ra就是a,它们是同一个对象,只不过有两条名字而已。引用定义的变量ra本质上是一个指针(编译器在底层就是这么干的),但它抛弃了指针那一套操作符号(*和-),和对象的使用完全没两样,代码上直观多了。
可能有人会说,为了这点直观,引入引用这个新的机制,大大增加了语言的复杂性,得不偿失。我们换一个角度,C++诞生之初,完全可以抛弃指针,只用引用那一套,做得像java一样,也是完全可以的。java中自定义类型都是对象,对象可以传来传去,但要显式使用new来创建。java的对象类型就是C++的引用类型的加强版。这样的话,就不需要指针那一套了,是不是让语言机制更简化?虽然真实世界是C++要兼容C还要保留指针,但我们可以不学它不用它,只学引用,行不行?
事实上,实际项目通常是不会写出上面这样的代码的,这里只是为了说明引用的概念及基本操作。引用更多是应用在函数的传入传出上:
//矩阵运算V2.0:C++传引用版本classMatrix{private:float*data_;//数据introws_;//行数intcols_;//列数public://构造一个空矩阵Matrix():rows_(0),cols_(0),data_(nullptr){}//构造一个rows*cols的矩阵Matrix(introws,intcols):rows_(rows),cols_(cols){data_=newfloat[rows_*cols_];}//拷贝构造一个矩阵,深拷贝Matrix(constMatrixm):rows_(m.rows_),cols_(m.cols_){data_=newfloat[rows_*cols_];memcpy(data_,m.data_,rows_*cols_*sizeof(float));}~Matrix(){if(data_)delete[]data_;data_=nullptr;rows_=cols_=0;}//下标操作符重载,返回第i个元素的引用floatoperator[](inti){returndata_;}//在当前矩阵上加上矩阵mvoidadd(constMatrixm){for(inti=0;im.rows_*m.cols_;i++)data_=data_+m.data_;}//+操作符重载,返回a+b的值friendMatrixoperator+(constMatrixa,constMatrixb){Matrixtemp(a.rows_,a.cols_);for(inti=0;itemp.rows_*temp.cols_;i++)temp.data_=a.data_+b.data_;returntemp;}};intmain(){Matrixa(8,9);//构建一个8*9的矩阵aa[18]=5.5f;//给第18个元素赋值,返回引用的简洁优雅Matrixb(a);//构建一个与a拥有一样数据的新矩阵bb.add(a);//相当于b=b+a,传引用效率高Matrixc(a);//构建一个与a拥有一样数据的新矩阵cMatrixd=b+c;//重载+并传递引用后,代码极其简洁高效return0;}
在定义Matrix数据结构时,区别于C版本的固定3*3的对象内存分配floatdata[3][3],我们在C++版本中使用了堆内存data_来保存具体的矩阵数据。为了管理好这个堆内存,我们需要为其编写析构函数,以便在对象被销毁时能正确释放这块堆内存;同时我们为其提供拷贝构造函数,以实现深拷贝,避免编译器默认为我们直接拷贝指针造成指向同一块内存,以致于对象释放时被多次释放同一块内存。
对上述代码几次使用引用场景的解析如下:
拷贝构建函数Matrix(constMatrixm)传入了一个(常量)引用作为构建新对象的模板。在该函数内部,形参m是一个引用,被绑定到了实参a中,m就是a的一个别名,编译器并不会为m在栈中分配内存,因为m仅仅是一个别名而已,底层是一个指针,所以传递效率非常高。因为操作符重载operator函数返回了float的引用,虽然返回的引用我们没有取名字,是匿名的,但它实现在在绑定到了第i个元素,所以我们可以给它赋值:a[18]=5.5f;可以看到引用机制和操作符重载的加入,我们才能写出如此优雅简洁的C++代码,Java都不行!因为add(constMatrixa)函数的形参是Matrix的(常量)引用,所以我们在调用b.add(a)时,传入的a仅仅相当于传了一个指针,避免了局部对象的创建引起的深度拷贝。函数operator+(a,b)也是传了两个引用,避免了两个局部对象的创建;而且因为重载了+操作符,最终的调用代码d=b+c;及其优雅美观简洁大方,与内置数据类型一致。只是这个函数在返回时仍然只能返回局部变量,造成了编译器为其在上层函数调用栈拷贝构建了一个临时对象,然后这个临时对象给d赋值,再一次调用拷贝构建函数,把临时对象深度拷贝给d,最终造成额外的两次拷贝构造。理想的设计是,这里应该返回引用,但在C++11推出右值引出前,我们返回一个局部变量的引用是非法的,因为局部变量在函数返回后即自动销毁,使用它的引用会产生不可预料的后果。所以目前只能允许这个不和谐的声音继续存在。
可以看到,对于C++98的引用,我们用得更多的是函数传入参数,以避免临时对象的产生和深度拷贝开销;而在函数传出(返回值)上,只能返回成员对象的引用,应用面很窄。其实,在函数传出上还有一些特殊的应用场景:
返回*this当前对象的引用,实现链式初始化的编程效果;返回静态对象的引用,实现单例设计模式;
返回引用:链式调用和链式初始化
还记得我们照着教程写的第一个C++程序吗?
intmain(){stringname,age;cout"请输入你的名字:"endl;cinnameage;cout"你的名字叫"nameendl"只要我想可以到天荒地老";return0;}
我们为什么可以连续不断链式追加或来输入输出多个变量?因为STL的内置对象cin和cout的iostream类中实现了操作符或的重载,关键是,这些重载的函数中,返回了this对象的引用。我们自己的普通函数(非操作符重载),也可以返回当前对象的引用也实现链式调用的效果,比如这样:
//矩阵运算V2.1:C++通过返回引用实现链式初始化的编程方法classMatrix{//...省略V2.0原来的Matrix成员函数和变量//新增加如下几个返回本对象引用的函数Matrixrows(intrs){rows_=rs;return*this;}Matrixcols(intls){cols_=ls;return*this;}Matrixbuild(){data_=newfloat[rows_*cols_];return*this;}Matrixset(floatinitValue){memset(data_,initValue,rows_*cols_*sizeof(float));return*this;}};intmain(){Matrixa(8,9);//构建初始化:一个8*9的矩阵aMatrixb;//空矩阵//链式初始化:设置b的维数为8*9,然后构建,然后设置所有元素为1b.rows(8).cols(9).bulid().set(1.0f);//传统settor初始化Matrixc;c.rows(8);c.cols(9);c.build();c.set(1.0f);return0;}
大家仔细品一下构造初始化、传统settor初始化、链接初始化的优劣势。
构造函数初始化,必须按照定义的顺序传值,看代码时没有明确的提示,且不能缺省中间某一个参数;传统settor初始化虽然可以灵活的根据需求设置特定某些值,设置的值的意义也很明确,但要占用多行代码;链式初始化既简洁、又灵活、设置的值的意思还非常明确,特别是要设置的参数很多时,优势更明显;
实现链式初始化的代价,仅仅是在传统的settor函数的最后,返回当前对象的引用即可。性能完全不用担心,因为都是inline函数,编译器会自动帮我们优化。
返回静态对象的引用:最简单的单例模式
在C++11之前,实现单例的设计模式会稍微麻烦点,但在C++11之后,因为保证了static成员对象构造的原子操作语义,直接返回局部静态对象的引用,即可实现赖汉模式的单例(在第一次调用时才构建),而且因为返回了引用,我们在使用这个单例对象时,用.代替了-,代码看起来更加简洁分明。
classFileManager{private://构建函数被声明为private,外部不能构造新对象FileManager()=default;public:staticFileManagerinstance(){//获取唯一的对象staticFileManagermanager;//懒汉,C++11及以上线程安全returnmanager;}voidcreateFile(stringpath);//业务函数};intmain(){FileManager::instance().createFile("C:\\temp\\haha.txt");return0;}
如果你有耐心读到这里,相信你已经把C++的引用机制的由来、优势和典型应用场景等掌握得差不多了,是不是很简单?实际软件工程应用上,引用的应用场景也就这么些了,如果你能融汇贯通上面的知识点并加以应用,已经比很多C++程序员牛了。