C++面向对象进阶:从构造函数到static成员
目录
- 前言
- 一、再探构造函数
- 二、类型转换
- 2.1 类型转换涉及的编译器优化
- 三、static成员
- 3.1 静态成员变量的应用
- 3.2 静态成员相关题目
- 结语
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、再探构造函数
-
之前在实现构造函数时,初始化成员变量主要使用函数体内赋值,构造函数初始化还有一种方式,就是初始化列表,初始化列表的使用是从一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个“成员变量”后面跟一个放在括号中的初始值或表达式(类似10×year)
-
每个成员变量在初始化列表中只能出现一次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地方
C++这既有函数体内赋值初始化,又有初始化列表,看起来乱乱的。这是因为有些成员就必须要在初始化列表内初始化的,否则就会编译报错,这样的成员有三个 -
引用成员变量,const成员变量,没有默认构造的类类型变量,必须放在初始化列表位置进行初始化,否则会编译报错
成员变量的整体定义也可以理解为是d1对象的整体定义
补充一下:声明是不开空间的,定义才会开空间,初始化列表是这三个特殊成员变量定义的地方
初始化成员列表也解决了之前的另一个问题,之前写两个栈实现一个队列的代码时,队列是不用写构造的,因为队列里面两个栈会自动调用栈的构造(只能调用默认构造),但这是一个理想的情况,若栈没有默认构造就会报错
这时候队列就必须自己去显式的写构造,就要借助初始化列表了
-
C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显示在初始化列表初始化的成员使用的
这里调试的时候也比较有意思,可以自己下去试一试 -
尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表(因为初始化列表是每个成员定义的地方),如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显示在初始化列表初始化的内置类型成员是否初始化取决于编译器(通常会初始化一个随机值),C++并没有规定。对于没有显示在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数,如果没有默认构造会编译错误
再展示一个缺省值奇怪的写法,Time _t是自定义类型,初始化时没有默认构造理应要在初始化列表初始化,但是它也可以给缺省值来达到一个初始化的效果
const也类似,给了缺省值就会用缺省值在初始化列表初始化
甚至缺省值还可以调函数,给个表达式
补充:给缺省值和函数参数一样。一般只能给常量对象,或者是全局对象
下面给两个缺省值和在初始化列表初始化混着用的场景
这个场景下不加size就可以不写MyQueue的构造或初始化列表,初始化的时候直接会去调用Stack的默认构造,若加了size,就要加MyQueue的构造了,也可以給size加一个缺省值
这里给_a开辟了一些空间,但是malloc也有可能失败,需要检查,初始化列表就做不了检查的事情,只有函数体才能做。其次,初始化还有可能在开空间后给数组赋值,或者使用memset将数组中的内容全部初始化为0。所以有些场景下初始化列表也不能解决所有问题
最后来看一道题目
正常情况下,如果初始化列表显式初始化了就和缺省值没什么关系了,B,E,F就排除了
想解决这个题还需要补充一个点
- 初始化列表中按照成员变量在类中声明顺序进行初始化(对象的底层是按照声明的顺序排布的,初始化的时候是按照对象的内存分布进行初始化),跟成员在初始化列表出现的先后顺序无关。建议声明顺序和初始化列表顺序保持一致
这里a初始化a1不会先执行,因为声明的时候a2在前面,所以会先会用a1初始化a2,但a1是随机值(对象被整体定义出来的时候空间已经被开出来了,初始化列表只是界定定义的地方,但是a1现在没有值是随机值),所以a2也是随机值了,所以该题选D
二、类型转换
- C++支持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数的支持
C语言规定相近的类型是能进行隐式类型转换,这里整型i隐式转换为浮点型double的时候会产生类型转换,类型转换中间会生成一个临时变量,临时变量具有常性(就像被const修饰一样),所以后续不能直接引用i,否则就是权限的放大
这是内置类型和内置类型之间的转换,类型之间有一定关联才能转换
到这再看C++这里的类型转换
这里A类型对象的初始化有两种写法,一个是语法上调用构造,另一个就是隐式类型转换,这里能进行隐式类型转换的原因就是其构造函数用整型做参数构造了A对象
其语法逻辑上1也是先构造一个临时对象,临时对象再拷贝构造给a2
下面第一个就是正常的引用,第二个引用的是隐式类型转换中间产生的临时对象
这样设计的意义在下面两个场景体现:
C++中类类型在做函数形参的时候就不建议用传值传参了,传值传参会调用拷贝构造是一种性能浪费,要尽可能使用引用,但是普通的引用传不了const对象,会造成权限放大,所以在使用时会形参的引用要尽量加const,但是除此之外还有一个原因,这样形参是A类型的const引用,不仅可以传A类型,也可以传1
另一个场景就是如果在数据结构中存一个自定义类型(类类型),现在往栈中插入A类型的两个数据,也可以用隐式类型转换了
第一种写法就是自己构造一个有名对象,再将有名对象传过去,第二种写法本质上a引用的不是3,而是上面支持用int去构造A
void func(const A& aa = 1)
{
}
这里还可以直接给缺省值更加方便
后面写到STL库的时候会细节讲一个类,string。使用它可以达到用字符串初始化的目的,若在栈里面存一个字符串,参数就改为string,也可以使用隐式类型转换
下面再说一说编译器的优化
2.1 类型转换涉及的编译器优化
由于C++是一门比较注重效率的语言,所以编译器会在这里进行优化,这里构造加拷贝构造会有性能的浪费,所以编译器处理会把这两个步骤省略掉,用2直接构造a2,这个优化是在C++11之后规定的
-
A a1(1);
这是直接初始化,调用构造函数 A(int a1),输出 “A(int a1)”,无优化(本身就是直接构造)。 -
A a2 = 2;
未优化时的逻辑:
先构造临时对象 A(2)(调用 A(int a1)),再用这个临时对象拷贝构造 a2(调用 A(const A& aa))。
编译器优化(拷贝省略):
直接将临时对象的构造 “合并” 为 a2 的构造,省略了拷贝构造的调用,最终只调用 A(int a1) 构造 a2,所以输出中这一步只体现为一次 “A(int a1)”。 -
const A& ref1 = 3;
逻辑:3 会触发构造一个临时 A 对象(调用 A(int a1)),然后 const 引用 ref1 绑定到这个临时对象(C++ 规则:const 引用可以延长临时对象的生命周期)。
这里无法优化拷贝构造,因为必须先构造临时对象才能绑定引用,所以会调用 A(int a1) 构造这个临时对象,输出一次 “A(int a1)”。
第三个没有打印"A(const A& aa)"的原因是引用不是对象,只是对已有对象的 “别名”。绑定引用的过程不会创建新的 A 对象,也就不需要 “用已有对象拷贝构造新对象”,因此完全不会触发拷贝构造函数 A(const A& aa)
- 构造函数前面加explicit就不再支持隐式类型转换
这就有人要问了,a2这串代码不是优化为直接构造吗
但这串代码的语法编译逻辑还是2转换为A,A再去拷贝构造,然后再去优化,所以会报错
多参数隐式类型转换也可以,只不过写法稍有改变
注意,上面A a4 = (1, 1);的写法运行的是第一个的构造逻辑,因为括号内是一个逗号表达式,逗号表达式取逗号右边的值传过去,如果没有单参数的构造函数,编译就会报错
正确写法:
A a3(1, 1);//A(int a1, int a2)
A a4 = { 1, 1 };//A(int a1, int a2)
const A& ref2 = { 1, 1 };//ref2引用的是临时对象Stack st1;
st1.Push(a4);
st1.Push({2,2});
这里最重要的就是要有对应的构造进行支持
- 类类型的对象之间也可以隐式转换,需要相应的构造函数支持
默认情况下A,B对象没有关联是不能转换的,哪怕用强制类型转换也一样,C++的强制也要有一定关联
内置类型之间的关联就是意义上的,内置类型和自定义类型之间是通过构造来关联的(该构造可以使得一个对象去创建初始化另一个对象)
若构造函数的参数是一个类类型的话,这个类类型就可以转换为另外一个类类型了
三、static成员
静态成员本质上可以理解为是类中定义的全局变量,其生命周期是全局的
- 用static修饰的成员变量,称之为静态成员变量,静态成员变量一定要在类外进行初始化
- 静态成员变量为所有类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区,也就是说其声明周期是全局的
A对象之中是没有count的 - 用static修饰的成员函数,称之为静态成员函数,静态成员函数没有this指针
- 静态成员函数中可以访问其他的静态成员,但是不能访问非静态的,因为没有this指针
- 非静态的成员函数可以访问任意的静态成员变量和静态成员函数(突破类域和使用访问限定符访问)
如图直接访问不能访问_count,就可以提供成员函数进行访问
- 突破类域和访问限定符就可以访问静态成员,可以通过类名::静态成员或者对象.静态成员来访问静态成员变量和静态成员函数
- 静态成员也是类的成员,受public、pritected、private访问限定符和类域的限制
补充一下成员函数和对象之间的联系
这里要核心区分一下对象的 “状态”(成员变量) 和对象的 “行为”(成员函数) 的存储逻辑。成员函数并不存储在单个对象的内存空间中,而是由该类的所有对象共享。
对象的本质是 “类的实例”,其内存空间仅用于存储描述对象 “独有状态” 的成员变量(非静态成员变量)。
图中无论用aa.还是aa类型的指针箭头,并不是说这个变量就一定存在这个对象之中,这种写法代表的是一种类域
例如this指针即使为空,也可以用空指针调用成员函数,由于该场景下cout和成员函数并不存在对象之中,所以这里并不存在解引用,程序并不会崩溃
然而581行和582行这里的代码运行会报错的原因是链接不通过,找不到_cout。只有_cout的声明只有编译的时候能通过,相当于告诉了编译器有_cout这个变量,然而这个变量可能在其他文件定义,当前文件定义就不会存在链接了。其他文件定义,当前文件声明,就需要去链接,这就涉及声明与定义的分离了
- 静态成员变量不能在声明位置给缺省值初始化,因为声明的时候给缺省值是给初始化列表,静态成员变量不属于某个对象,不走构造函数初始化列表,构造函数初始化是初始化对象中的成员
3.1 静态成员变量的应用
这里统计一下图中A类型的对象创建了多少个,比如前面隐式类型转换是否优化是不确定的,不同的编译器决策可能不同,这里就能很好的解决
A类型所有的对象一定是构造或拷贝构造出来的,这段代码的流程就是
- A aa1; —— 实例化对象 aa1,调用默认构造函数
- A aa2 = 1; —— 实例化对象 aa2,表面上看,这行代码会经历两个步骤:
- 用1调用构造函数A(int a),创建一个临时对象。
- 用临时对象调用拷贝构造函数A(const A& t),初始化aa2。
但实际上,C++ 编译器会进行 “拷贝构造优化”(返回值优化 RVO),直接将两步合并为一次构造:
直接调用A(int a=1)构造aa2,_a1和_a2被初始化为1。
构造函数体内执行++_count,此时_count从1变为2(若未优化,_count会先因临时对象变为2,再因拷贝构造变为3,但优化后仅 + 1)。
这里统计构造/拷贝构造次数用全局变量的原因是不期望该变量被修改,期望统计个数的变量是该类专属的,是所有对象共享的,所有对象去访问成员函数的时候都是它,就可以用来计数了。如果定义一个成员变量来统计的话,那么每个对象都有一个,就不好统计
3.2 静态成员相关题目
定义n个对象的sum数组,就会调用n次sum类的构造。这个写法在VS中编译不通过,其他编译器支持(如g++),这个语法属于C99标准支持的变长数组,牛客的底层是使用的g++编译器
而要进行累加,调用n次就不断加等第一次的结果,然后调用第n次构造函数时,加等到第n次,_i就变为n了,就实现了1+到n,结果要放在Solution的类中,成员私有不能传过去,用一个公有的成员函数把结果传过去
接下来写一个在VS下的代码,VS下边长数组用不了就用new,new类似malloc,要去堆上动态开辟,只不过它要去调用构造函数。后面的文章我会细讲,new之后要delete,将数组delete释放掉也不会有影响,因为结果_ret存在静态区,不存在对象之中
首先看构造,全局变量是在main函数之前就要初始化的,这里c的初始化就要调用构造,所以第一个必然选C,然后局部的静态变量是在第一次运行它的时候才去初始化,所以第一个选项是E
再看析构,首先b在a之前,c在main函数结束以后才会析构,同时定义在栈中的变量,后定义的先析构,然而D并不在栈中,D在静态区,main函数结束之后,局部的静态的D会先析构,所以选B