在完成类和对象(一)以及日期类Date的学习后,我们对类和对象已经有了初步了解,现在我们针对部分没有补充完整的知识点,进一步深入学习。
目录
1.初始化列表
在类与对象1C++入门:类与对象(1)-CSDN博客中我们谈到,默认构造函数必须是不传参就能使用的函数:如果没有自己显式实现,会生成默认的构造函数;如果自己显式实现不传参的构造函数,就直接调用;唯独不能写需要显式传参的构造函数,如下:
public: Stack(size_t capacity ) { _array = (DataType*)malloc(sizeof(DataType) * capacity); if (NULL == _array) { perror("malloc申请空间失败!!!"); return; } _capacity = capacity; _size = 0; }
我们之前讲到,可以通过初始化列表的方式完成对这种需要传参的显式实现的构造函数的构造。
初始化列表:以一个 冒号开始 ,接着是一个以 逗号分隔的数据成员列表 ,每个 " 成员变量 " 后面跟 一个放在括号中的初始值或表达式,作为初始化该成员变量的参数。
手动实现初始化列表:
冒号开始,逗号分割:
、
初始化列表位于函数体{}和函数头之间,用后面的括号表示所传的参数。
内置类型后面括号中的参数给多少就是多少,自定义类型利用给的参数调动自己对应构造函数
易错:
初始化列表作用是用来传参,传完之后后面必须接上函数名的中括号,否则会因为语法错误而报错
在有了初始化列表的概念之后,我们现在又可以将一个类中的变量的创建分为声明和定义两部分了,就像之前的函数一样:在private下的那一部分我们可以理解为“声明”,在构造函数或初始化列表中就被当作“定义”。所以在声明时给的值可以视为一种缺省参数。
初始化列表和函数体中的内容也可以混用:
既然可以混用,那你能试试将_pushst和_popst也放在花括号中初始化吗?
在花括号中再定义一次吗?如下例:
class A {
private:
int _a;
char _c;
int* _arr;
public:
A (int a=1,char c='s',int* arr=nullptr)
:_a(a),
_c(c)
{
_arr = new int[a] {0};
}
};
class MyA {
private:
A _a1;
A _a2;
int _n;
public:
MyA(int n = 10)
/*:_a1(n),
_a2(n)*/
{
_n = n >= 10 ? n : 10;
A _a1(3, 'c');//这是另外一个_a1,MyA中的成员变量_a1并没有被初始化
}
};
正如注释中所写,未能成功初始化,你只是创建了一个新的临时变量。
显式调用构造函数呢? _a1.A();
这样是不被语法允许的,需要使用之后在内存管理中讲到的定位new
class MyA {
private:
A _a1;
A _a2;
int _n;
public:
MyA(int n = 10)
/*:_a1(n),
_a2(n)*/
{
_n = n >= 10 ? n : 10;
//A _a1(3, 'c');//这会生成另外一个_a1,MyA中的成员变量_a1并没有被改为3和c
new(&_a1)A;
}
};
这样就显示调用了_a1的构造函数,关于定位new。会在之后详细讲解。
不过实际上此处的定位new是多此一举。
定位new的实际作用是帮助那些只能开空间但不能初始化值的“开空间函数”去初始化数值,比如malloc和operator new等,像类的构造函数这个地方,他能自己初始化数值,所以完全没有必要使用定位new。其本质依然是在初始化列表中初始化的自定义类型。
哪些成员应该在初始化列表中初始化?
先给结论,引用、const修饰、没有默认构造的自定义类型成员这三类必须要在初始化列表中定义。
const修饰的成员只有一次初始化的机会(因为一旦被确认就不能再被更改),所以说定义的时候必须初始化,因此我们必须在初始化列表中定义这个const int _x
引用 同理(引用在第一次赋值之后就不能改变其代表的变量)。
引用也是一样:
对象初始化才是定义的地方,也就是初始化列表里,
而在class的private下写的那个算是变量的声明。
不手动写初始化列表:
不写初始化列表,编译器也会自动生成(因此刚刚的定位new是没有必要的),并且所有成员都会走一遍该默认生成的初始化列表。
自定义类型成员会走默认构造(编译器悄悄帮你做了,这也是为什么说cpp难学,编译器悄悄做了太多事)
初始化列表和默认构造一起,形成一个逻辑闭环。不写默认构造的时候自动生成默认构造,默认构造在函数名和函数体之间又自动放了初始化列表,每一个成员都会先走一遍,对于自定义类型,又会调用他的构造函数。比如,我们显式写的是Stack _popst(10),他默认生成的初始化列表调用的是Stack _popst(),因为找不到匹配的,所以就会报错
对于内置类型,如果在初始化列表中写了,就不会再去管private下声明处写的缺省参数,如果没在初始化列表中写, 就会使用声明处这个缺省参数。可通过调试观察。
如下图:既有缺省参数,又显式写了初始化列表,则以初始化列表为准(只有初始化列表中找不到时,才去缺省参数处寻找,缺省参数处的数值是给初始化列表用的)
因此,我们可以认为声明处的缺省值其实是给初始化列表用的
此处的“=”只是一个形式,其实际表示意义就是将“=”后面的值作为参数传入初始化列表。
例如 Stack _pushst=10;的意思就是将10作为_pushst中的需要参数的构造函数的参数。
有点类似隐式类型转换的思想
所有的构造函数都满足以下规则:
在实践中,我们希望更多的使用初始化列表:
初始化列表中的参数可以是常数,也可以是如malloc等这类表达式
观察以下代码,思考_x会在哪里被初始化?
_x依然会在初始化列表中被初始化,如果没有显式写
// : _x(20), //
就会使用缺省值10。
class A
{
public:
A(int a)
:_a1(a)
,_a2(_a1)
{}
void Print() {
cout<<_a1<<" "<<_a2<<endl;
}
private:
int _a2;
int _a1;
};
int main() {
A aa(1);
aa.Print();
}
(如果初始化列表中不写_x,又没有缺省值,就会报错)
易错:
成员变量 在类中 声明次序 就是其在初始化列表中的 初始化顺序 ,与其在初始化列表中的先后次序无关
例题:
class A
{
public:
A(int a)
:_a1(a)
,_a2(_a1)
{}
void Print() {
cout<<_a1<<" "<<_a2<<endl;
}
private:
int _a2;
int _a1;
};
int main() {
A aa(1);
aa.Print();
}
实例化aa后,aa中的_a2 和_a1分别是什么值?
由于初始化列表中的真实初始化顺序是其在类中的声明顺序,所以先声明_a2,但是此时的_a1中是随机值,所以_a2是随机值
我们再通过一个初始化列表来理解:
对于类成员_a,先在private修饰下作出声明。若是构造函数(必须传参,否则报错,因为没有实现默认构造),则先走初始化列表,由于_a是内置类型int,所以: _a(a)语句将a赋值给_a(内置类型直接赋值);若是拷贝构造,则我们使用 实参的别名aa来找到其对应的_a,赋值给this对应的_a.
2.自定义类型和内置类型的隐式类型转化
之前在谈论权限问题时,我们提到了如下隐式转化:
int a = 10; double b = a;
a在赋值给b的过程中,先将a拷贝,对a的拷贝进行构造,构造成double类型后赋值给b。
那么类和类、类和常量之间能不能有如此的转换呢?
先将3作为参数,(将3作为参数传参)构造 A类,然后将这个类通过拷贝构造赋值给aa3
隐式转换遇到引用时依然有权限问题:
class A {
private:
int _a;
public:
A(int a)
:_a(a)
{
cout << "A(int a)" << endl;
}
A(const A& a)
:_a(a._a)
{
cout << "A(const A& a)" << endl;
}
};
xx引用的是类型转换中用4构造的临时对象。但是临时对象有常性,所以必须用const修饰
下图的raa同理:
注意:临时对象建立在当前函数栈帧,不要被名字中的“临时”二字迷惑,此处xx引用的和raa引用的临时变量的生命周期都和main函数一致。
3.关键字explict(修饰在一个类之前)
不希望隐式转化发生,就加关键字:explicit
记住一个点即可:explicit函数是用于修饰构造函数的
4.多参数的传参、隐式转换
若我们将上文中的类A的构造函数修改为需要两个参数:
class A {
private:
int _a;
int _b;
public:
A(int a,int b)
:_a(a),
_b(b)
{
cout << "A(int a)" << endl;
}
A(const A& a)
:_a(a._a),
_b(a._b)
{
cout << "A(const A& a)" << endl;
}
};
此时,如何在声明处的缺省参数处传参或者使用隐式转换中的传参呢?
用花括号括起来即可。
问为什么要加const的可以打死了。
“因为用{1,2}作为参数构造的A类具有常性,是只读的”
缺省值传参时也一样,使用中括号:
我们也可以通过类似的方法进行传参:
将push的对象改为一个双参的A类:
上图中的st.Push({1,2});
就是用{1,2}去构造了个A类型变量,push进st中。
如果我们实现了一个单参的构造和一个双参的构造,也可以这样使用:
5.static修饰的静态成员
声明为 static 的类成员 称为 类的静态成员 ,用 static 修饰的 成员变量 ,称之为 静态成员变量 ;用 static 修饰 的 成员函数 ,称之为 静态成员函数 。 静态成员变量一定要在类外进行初始化类中不会存储静态成员变量
静态成员不能给缺省值,因为缺省值是给初始化列表的,但是static修饰过的成员在静态区,不在对象中,因此他不会走初始化列表
(错误,找不到t)
![](https://img-blog.csdnimg.cn/direct/750b5854e3cb4177982aec14af203d5a.png)
必须在外部定义,并且必须既声明又定义,否则报错。每当一个静态成员变量被定义后,他就属于该类型 类 的所有对象。
![](https://img-blog.csdnimg.cn/direct/47742f45e81c41b9b057b125d89d009c.png)
![](https://img-blog.csdnimg.cn/direct/3bdfc1f2d5784157ada309e3dcde073f.png)
6.static修饰的成员函数
该成员函数没有this指针,因此static修饰的函数只能访问静态成员(比如上文中被private修饰的static成员)
![](https://img-blog.csdnimg.cn/direct/a72e7cf6a2b24473bfba1c35815973b3.png)
所以我们希望访问static和private一起修饰的成员变量时,自己实现一个函数即可。
利用一个题目来展示static修饰成员函数的意义:
求1+2+3+...+n_牛客题霸_牛客网 (nowcoder.com)
class Sum{
public:
Sum(){
_ret+=_i;
++_i;
}
static int getret(){
return Sum::_ret;
}
private:
static int _i;
static int _ret;
};
int Sum :: _i=1;
int Sum :: _ret=0;
class Solution {
public:
int Sum_Solution(int n) {
Sum arr[n];
return Sum::getret();
}
};
思路如下:
传统方法不能使用,我们利用构造函数中++,析构函数中--的方法,来操作n个数相加。
但是作为结果的_ret和作为每一步加数的_i , 我们应当使用static修饰他们。为了调用该类共有的函数getret,我们也需要使用static修饰getret,否则需要实例化一个特定的对象来调用getret。
使用static更符合当下情景。
由此我们可以看出,不论static修饰的成员函数还是成员变量,他都让该变量或函数成为一个公用的模块
7.友元
7.1友元函数
友元函数 可访问类的私有和保护成员,但 不是类的成员函数友元函数 不能用 const 修饰友元函数 可以在类定义的任何地方声明, 不受类访问限定符限制一个函数可以是多个类的友元函数友元函数的调用与普通函数的调用原理相同
friend ostream& operator<<(ostream& _cout, const Date& d);
//之前实现的流提取运算符的重载
7.2友元类
![](https://img-blog.csdnimg.cn/direct/8b29357517d647c8b5050d7d662a6dcf.png)
意思就是,time已经声明了,Data是Time的友元, 所以Data中可以访问Time中的元素(包括私有), 但是Time不可以访问Data中的元素,因为没有说明Data是否将Time当作友元类
少用友元,其在一定程度上打破了类的封装性。
8.内部类(较少使用)
概念: 如果一个类定义在另一个类的内部,这个内部类就叫做内部类 。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越 的访问权限。注意: 内部类就是外部类的友元类 ,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。
内部类仅仅受到类域的限制
1.假如A类中有一个B类,如果我们测试A类的大小,则B类的大小不会被计算在内。
2.若B类被分为A类的private成员变量,则不能通过A使用B,如下图:
3.B天生就是A的友元
4.Java中的内部类使用频率高于C++,C++并不经常使用内部类。
内部类可以优化刚刚牛客网的题目。
我们将_i和_ret放在了solution中,又因为内部类是外部类的私有,所以Sum可以自由访问_i以及 _ret
9.匿名对象
顾名思义,定义时不给名字的对象。
有名对象的生命周期在当前作用域,
匿名对象的生命周期只在当前这一行,即用即销毁,调完构造马上调析构。
匿名对象的特点不用取名字
10.对象拷贝的优化
编译器会根据语法,自动对我们写出的代码进行优化,老旧版本之间、release和debug之间都不相同。
我们通过在构造函数和拷贝构造函数中加上打印函数名来观察。
void f1(A aa)
{}
如以上函数,当f1是一个传值传参函数时,形参是实参的拷贝,所以又会调动对aa1的拷贝,来变成aa,最后销毁aa、销毁aa1
此时似乎还观察不出优化。
我们将传值传参改为引用传参。
如果使用 匿名对象传参 或者 拷贝构造隐式转换(先用给的参数隐式转换,再拷贝构造,如:f1(2)) 就会有报错:
匿名对象具有常性,拷贝构造出现的隐式转化也具有常性,所以需要在形参处加一个const修饰
我们观察结果:
因此,引用传参确实可以有效减少拷贝次数。不过此时依然没有发生有效的优化。
我们再将f1的引用传参改回传值传参。
在上一篇中我们提到,构造加拷贝构造会被编译器优化成直接构造。
如下三种:
按理来说,以上三种都应该是先 普通构造、再拷贝构造,但是这样会浪费中间那一层的拷贝。因此,编译器此时只会执行一次直接构造。(vs2022可能观察不出来,其优化开的很高)
再如下:
按理来说应该是构造(aa)+拷贝构造(aa传值返回需要)+拷贝构造(返回值给ret初始化)
但是编译器直接将两次拷贝构造优化为一次拷贝构造。
会在f2结束前,销毁f2对应的函数栈帧之前,直接对ret拷贝构造,省略中间的临时变量。
同理,aa销毁之前直接调用了拷贝构造,在f2结束之前构造ret2
release版本下或者vs2022版本中的优化更大,会存在跨行构造,甚至有的变量直接不在新的栈帧中开辟,直接对调用该函数的部分进行赋值。
11.对象的析构顺序
函数栈帧中,每一个空间都是类似栈的做法,先开的空间后销毁,后开的空间先销毁。
有了这一点能够更好的理解对象的拷贝优化部分。