C++ 进阶特性深度解析:从友元、内部类到编译器优化与常性应用
目录
- 前言
- 一、友元
- 1.1 友元函数
- 1.2 友元类
- 二、内部类
- 三、匿名对象
- 3.1 匿名对象的使用场景
- 四、对象拷贝时的编译器优化
- 4.1 传参优化
- 4.2 传返回值优化和跨行优化
- 结语
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、友元
友元在前面文章IO流的输出输出重载的地方用过一次
1.1 友元函数
- 友元提供了一种突破类访问限定符封装的方式(类的访问限定符在类里可以访问,但是在类外就不能访问了),友元分为:友元函数和友元类(一个类去访问另外一个类中的成员),在函数声明或者类声明的前面加friend,并且把友元声明放到一个类的里面
这里有两个类A,B,此时有一个函数既要访问A,也要访问B,就要使用友元了,一个函数是能够成为多个类的友元的
如图函数内就可以访问A,B的私有或者保护了,但是这里代码编译不通过。
不通过是前置声明的问题,在用任何的变量,函数,类型的时候都要在当前位置的前面声明或定义了后面才能用,因为编译器是向上编译检查的(节省编译时间,同时向上向下查找出处浪费时间),图中A类中的形参B向上找的时候找不到,也就是A,B类之间存在相互依赖。
解决方法就是加一个类的前置声明
1.2 友元类
图中D这个类要大量的访问C这个类的私有。虽然可以把D中的成员函数声明为C的友元,但是成员函数多的时候很麻烦,就有了一种更简单粗暴的方法,友元类
友元不是相互的,友元是一种单向关系,D成为了C的友元,在D中可以访问C,但在C中是无法访问D的,除非在D中加C的友元声明,是可以互相成为友元的
但是这样还是编译不通过的,虽然前置声明了D的类型,但只能解决用D这个类型,但是D里面的细节(如成员变量)还是不能用的(D中有无_b这个成员编译器是不知道的),而且这里也不需要前置声明,已经友元声明D是一个类了,这时候就要做声明和定义分离
上图用声明和定义分离就能很好的解决这个问题了,用任何东西之前,编译器的原则都是在前面找到这个东西,比如:类,函数,变量
- 外部友元函数可访问类的私有和保护成员,友元函数仅仅是一种声明,它不是类的成员函数
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 一个函数可以是多个类的友元函数
- 友元类中的成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员,但是一般不这么干,我这就不演示了。
- 友元类的关系是单向的,不具有交换性,比如A类是B类的友元,但是B类不是A类的友元
- 友元类关系不能传递,如果A是B的友元,B是C的友元,所以A不是C的友元
- 有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用
二、内部类
-
如果一个类定义在另一个类内部,这个内部类就叫做内部类。内部类是一个独立的类,跟定义在全局相比,他只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类
这里只会计算A类型对象的大小(静态成员不存在对象,静态成员存在静态区),A的内部是没有B成员的,所以内部类不是成员 -
内部类默认是外部类的友元类
在类的内部可以用静态成员,内部类也是在A类的内部 -
内部类本质也是一种封装,当A类跟B类紧密关联,A类实现出来主要就是给B类使用,那么可以考虑把A类设计为B的内部类,如果放在private/protected位置,那么A类就是B类的专属内部类,其他地方都用不了
该代码就可以改为内部类,原代码Sum类计算出的结果可以被其定义的对象更改的,或者其他的类定义的对象都可以更改,所以Sum类放到全局不好
所以可以把Sum设计为Solution的一个私有的内部类,这样其他类的对象就无法改变结果了。还可以把Sum里的静态成员变量放到Solution之中,这样Solution之中可以用这两个成员,Sum也可以用(内部类是外部类的友元),很便捷
三、匿名对象
- 用类型(实参)定义出来的对象叫做匿名对象,相比之前定义的类型 对象名(实参)定义出来的叫有名对象
- 匿名对象声明周期只在当前一行,一般临时定义一个对象当前用一下即可,就可以定义匿名对象
有名对象和匿名对象都是调用一个类的构造函数定义出来的对象
3.1 匿名对象的使用场景
图中调用Sum_Soluton这个函数,要用有名对象,而使用匿名对象写成一行更加简洁,对于只使用一次的场景更加方便,第二次结果变大的原因是,第二次的结果是在第一次的结果下累加的
解决办法就是多次调用的时候配一个Clear的函数,每次调用完后把_i和_ret初始化一下
再补充一个点,引用是可以引用匿名对象的,但匿名对象和临时对象类似,都具有常性(const-ness,对象或数据 “不可被修改” 的特性),常性具有以下作用
这里还可以用匿名对象给类类型缺省值,缺省值可以给常量,也可以给全局对象
类类型给缺省参数很多时候就会给匿名对象,这里的const引用就延长了匿名对象的生命周期,此时的匿名对象生命周期就和s一样了,s对象的生命周期就是当前main函数的作用域
以上就是匿名对象最常见的两个场景
四、对象拷贝时的编译器优化
- 现代编译器会为了尽可能提高程序效率,在不影响正确性的情况下会尽可能减少一些传参和传返回值过程中可以省略的拷贝
- 如何优化C++标准并没有严格规定(C++11,C++17才开始部分场景规定),各个编译器会根据情况自行处理。编译器通常在标准规定就尽量优化,其次标准没规定,主流编译器也在优化。当前主流的相对新一点的编译器对于连续一个表达式步骤中的连续拷贝会进行合并优化,有些更新更“激进”的编译器还会进行跨行跨表达式的合并优化
4.1 传参优化
隐式类型转换就是一个经典连续拷贝的优化
接下来看一下传参的优化
匿名对象不优化的情况下:
4.2 传返回值优化和跨行优化
这也是跨行优化,本质上可以理解为aa是aa1的引用,这里不存在先有aa后有aa1的问题,二者的栈帧的空间是一起开好的,aa1的空间在没有调用f2的时候就已经有了
可以看到二者的地址也是一样的,这也是说现在编译器优化非常“激进的”原因,非常极致,直接把中间那么多步骤一把全摘了,gcc9.4的优化也是如此
这里再细说一下优化的核心逻辑,这一块代码严格来说有两个栈帧,分别是main函数和f2。默认情况是不能把aa作为返回值的,调用f2结束之后其栈帧销毁,aa也就跟着销毁了,所以通常是把aa放在一个临时对象
aa的临时对象是有两种存在方式,第一种就是临时对象比较小,一般存在寄存器当中,大一点会放到两个栈帧中间(main提前调f2的时候,压的参数,返回值就会放到中间的栈帧),此时f2的栈帧销毁了,中间这个栈帧还在,等着main函数内接收好了之后,这个临时对象再销毁,再回收栈帧。
编译器优化的时候,会先通过语法分析发现aa最终构造在aa1上,就会直接把中间的临时对象省略,一把拿下
这个优化的名字就叫NRVO,其是在C++17中的标准提出的。N就是name,返回一个有名字的对象,R就是return
相关文档
URVO就是返回一个匿名对象,C++17是规定了URVO的优化,NRVO还没有规定,但是代码中的NRVO是有优化的,所以编译器是超前的
现在来看一下匿名对象的优化
可以看到也是直接构造了,完全不优化的分析就不写了,和前面有名对象的完全不优化一样
最后补充一点,如果像下面这种写法就会打乱优化(Debug)
构造aa1
构造aa
用aa拷贝构造临时对象临时对象
aa销毁
临时对象赋值
临时对象销毁
aa1销毁
Release还是会在临时对象这里做些优化
还是更推荐第一种写法,效率更高