在上篇文章【C++学习】类与对象(中)里,本喵详细介绍了六大默认成员函数中的四个,构造函数,析构函数,拷贝构造函数,赋值运算符重载,接下来本喵继续介绍剩下的俩个不是很重要的默认成员函数,已经类与对象其他重要内容。
我们知道,const修饰的变量具有常量属性,是不可以被修改的,在C++中,const还可以修饰函数,那么此时的const的作用是什么呢?
class Date
{
public:
Date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void Test1()
{
Date d1(2020, 10, 17);
d1.Print();
}
仍然是以日期类为例,上面的代码成功打印出了当前的日期,下面本喵对其稍加修改:
这里用const修饰日期类对象d2,然后用d2调用成员函数Print的时候,发现不能调用了,这是什么原因?
- 没有用const修饰的对象d1,在调用成员函数的时候,传递的隐藏指针类型是Date* const this类型,与成员函数Print的隐藏参数类型Date* const this一样,所以没有问题。
- 用const修饰的对象d2,在调用成员函数的时候,传递的隐藏指针类型是const Date* const this类型,与成员函数Print的隐藏参数类型Date* const this不一样,相当于在放大权限,这是不被允许的,所以会报错。
那该怎么解决这个问题呢?
通过调试我们可以看到,在d2调用Print成员函数的时候,调用的是类中用const修饰的那个Print成员函数。
- 用const修饰成员函数,此时const的作用就是将this指针用const修饰
- const只修饰this指针,如果成员函数有其他参数是不会被const修饰的。
注意:
总的来说,凡是不改变成员变量的成员函数,都应该用const修饰。
在C++中,打印的时候需要使用cout库函数,而且我们知道,该函数可以自动识别变量的类型,通过前面的学习,我们可以猜测出来,自动识别的原理是函数重载,不同的内置类型就调用不同的成员函数,那么自定义类型cout可以识别出来吗?
继续来看上面的日期类,其中打印函数是一个成员函数:
其中的变量都是内置类型,那么可不可以用cout打印自定义类型呢?此时它还能自动识别吗?
可以看到,此时就直接报错了,因为cout此时不能识别d1的类型了.
上图是C++官方的库,拿cout函数举例,cout其实是一个对象,它是按照类ostream创建的一个对象,而我们流提取运算符是ostream类中的成员函数,本喵拿代码给大家展示:
上图中,类名是ostream,而流提取运算符是C语言中左移操作符<<的重载,
所以我们要想使用<<来打印出自定义类型,就得将运算符<<也重载。
就像上图中的样子,但是这样还是存在一个问题,<<运算符重载是类Date中定义的,只能通过Date创建的对象d1来调用它,也就是说,运算符重载函数<<的第一个操作数只能是d1。
但是此时第一个操作数是cout,调用<<的时候传递就是不是d1的this指针了,所以这样还是不行。
我们继续修改,如上图,将类对象d1当作第一操作数,此时调用<<重载函数的时候,this指针传递就是d1对象的地址,所以<<重载函数的参数要写一个ostream类型的对象,在重载函数内,将内置成员变量输出到控制台上。
此时是实现了我们的目的,但是这个调用怎么看怎么奇怪,和我们日常见的不符合,为了和我们日常见的一样,我们只能将<<重载定义到类外面:
可以看到,此时使用cout就和我们的习惯一样,并且还能够自动识别类型,因为此时将<<运算符重载定义成一个全局函数,该函数有俩个参数,第一个参数对应第一个操作数的类型,是cout,所以类型就是ostream&,第一个参数对应的是第二个操作苏的类型,是d1,所以类型就是Date&。
在域外访问类中的私有变量,这里采用定义几个取值的类成员函数来访问,就像上图中获取年月日的函数Get_year,Get_month,Get_day。
至于为什么将<<运算符重载函数的返回类型写成ostream&呢?这是为了方便输出多个自定义变量:
流插入运算符>>也是相同的道理,有兴趣的小伙伴可以自己试试,需要注意的就是,为了和我们的使用习惯一致,>>运算符重载函数也必须定义成全局函数,不能定义成类中的函数。 &运算符重载同样也是一个默认成员函数,但是它很少使用到。 上面程序的内容就是取地址运算重载和const取地址运算符重载。 所谓const取地址和取地址,仅仅在于类对象是否被const修饰,被修饰了this指针也需要被修饰,运算符中函数也需要被const修饰,并且返回类型也是被const修饰的指针类型。 但是这俩个运算符重载很少需要我们自己去实现,因为编译器自动生成的就完全可以满足需求,所以不需要我们去写。 但是也有应用场景: 但是一般都不这样使用,所以编译器自动生成的完全能够满足我们的需求。 通过前面的学习我们知道,构造函数的作用就是将成员变量初始化,其实从严格意义上来说,不能叫做初始化,只能叫做是赋值。 这也说明,原本的构造函数的作用就是在赋值,而没有给成员变量进行定义,那么成员变量的定义是在哪里进行的呢? 成员变量的定义是在初始化列表中完成的!!! 初始化列表必须按照上面的格式,这是C++标准规定的。 初始化列表在对象创建的时候是一定会有的,和构造函数一样,即使没有显式定义出来,编译器也会有默认生成的。 注意: 引用成员变量: const修饰的成员变量: 运行的时候编译器就报错了,提示栈没有默认构造函数。 因为在类MyQueue中定义Stack对象的时候,编译器自动生成的初始化列表并不会给Stack对象传参,Stack对象也没有默认函数。 此时,自定义类型Stack只能在初始化列表中进行初始化。 但是当自定义类型有默认构造函数的时候,就不用显式写出初始化列表了,编译器自动生成的初始化列表在定义自定义类型栈的时候会调用栈的默认构造函数。 此时我们就可以总结一下了: 上面代码的运行结果是什么?是俩个1嘛? 这是因为,在初始化列表中定义成员变量的时候,是按照成员变量声明的顺序来的,和成员变量在初始化列表中的顺序无关。 构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值的构造函数,还具有类型转换的作用。 但是第三个是什么意思?怎么直接将一个int类型的数据赋值给自定义类型了呢? 说是隐式类型转换,也就是将一个int类型的值给了构造函数来初始化对象。 上图中,将int类型的值2024转换程Date&就不行,编译器就会报错,这个错误应该很熟悉。 引用和指针都回涉及到一个权限问题。 所以,只要用const修饰一下对象d4就可以了。 隐式类型的转换只支持构造函数只有一个形参的时候(不包括缺省值),但是想一次性转换多个数据怎么办呢? 一直都是本喵告诉大家隐式类型转换是构造函数完成的,那么到底是不是它完成的呢? 此时就不让进行隐式类型转换了,我只对构造函数做了处理,所以说,隐式类型转换是构造函数完成的。 我们知道,static修饰的变量是存放在静态区的,它的作用域不回发生变化,但是生命周期发生了变化,只有在程序结束的时候才会结束。 在类中成员也是可以被static修饰的。 这样一段代码,是为了统计创建了多少个类对象的。 静态成员函数没有this指针。 但是静态成员函数没有了this指针,相比普通成员函数,注定它会有一些功能上的缺失。 静态成员函数只能方法静态成员,包括静态成员变量和静态成员函数,非静态成员是不能访问的。 当然,静态成员的访问还可以通过其他方式来访问: 但是,静态成员仍然收到public,private,protected等限制符的限制。 友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。 在流插入和流提取运算符重载的讲解中,我们写了流插入运算符重载函数,但是为了符合使用习惯,使可读性高,我们将这个函数定义成了全局函数,但是在打印对象d1的时候,由于类中对成员变量的封装,无法直接访问,所以就写了三个成员函数来获取成员变量的值。如下图: 此时该函数就可以访问类中的私有成员变量了,因为它是友元函数,编译器认为该函数是这个类的朋友。 说明: 友元类和友元函数类似,一个类的友元类可以方法它内部的任何成员。 在日期类中,声明一下时间类,此时就可以访问时间类中的任何成员了,因为时间类中已经认为日期类是它的友元类了。 说明: 如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。 可以通过内部类的成员函数访问到外部类,如上图中,成功打印出了日期加时间。 说明: 匿名对象,顾名思义就是没有名字的对象,我们之前一直创建的对象都是有对象名的。 那么匿名对象呢?都没有名字,怎么能证明它存在呢? 上图中,创建了一个匿名对象和一个有名对象。 这俩个函数的执行只能是匿名对象创建和销毁时候执行的。 在进行类对象的创建,赋值等操作时候,会自动调用构造函数,拷贝构造函数,如果连续调用构造函数和拷贝函数,系统的开销也非常的大,此时编译器就会自动做一些优化。 创建一个对象aa1,然后调用函数func1,将对象aa1传过去,右边是打印的结果,可以看到在这个过程中调用了构造函数,拷贝构造函数,析构函数,析构函数。 可以看到,就这样一个功能,需要调用这么多的函数,如果这样的操作有很多的话就会有很大的开销,所以编译器对这些做了一些优化。 原本需要调用四个函数(已经优化隐士类型转换的情况下),现在俩个就能解决,而且实现的功能是完全一致的。由于最后都有一个析构函数,所以此时就可得出结论: 这种更像是在写法上的优化,不太能看到编译器的直接优化结果。 上图中,实现的功能合之前是一样的,只是使用了匿名对象。 此时从结果中我们可以看到,只有构造函数和析构函数,同样抛开最后一次的析构函数不看,因为无论哪种方式都会有一次析构函数。 但是一些比较老的编译器就不会进行优化。 现实生活中的实体计算机并不认识,计算机只认识二进制格式的数据。如果想要让计算机认识现实生活中的实体,用户必须通过某种面向对象的语言,对实体进行描述,然后通过编写程序,创建对象后计算机才可以认识。比如想要让计算机认识洗衣机,就需要: 在类和对象阶段,大家一定要体会到,类是对某一类实体(对象)来进行描述的,描述该对象具有哪些属性,哪些方法,描述完成后就形成了一种新的自定义类型,用该自定义类型就可以实例化具体的对象。 类与对象是从C语言迈入C++的一个门槛,它的细节有很多,大部分本喵在这三篇文章中也详细的讲解过了,掌握了这些,就拿下了C++的一血。在后面的学习中也会不断用到这部分知识。
这样的表达式,执行顺序是从左向右的,当执行完cout<取地址运算符(&)和const取地址运算符重载
class Date
{
public:
Date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//取地址运算符重载
Date* operator&()
{
return this;
}
//const取地址运算重载
const Date* operator&()const
{
return this;
}
private:
int _year;
int _month;
int _day;
};
此时,俩个取地址运算符重载函数返回的地址是空地址,因为将取地址运算符进行了显式定义,编译器就不回自动生成了,所以这样写时,意味着该对象的地址是不想被别人获取的。再谈构造函数
初始化列表
我们之前一直写的日期类是这个样子的,其实类中只有对成员变量进行声明和赋值的地方,但是没有定义的地方,不信你来看:
上图中,仅仅是在声明中将成员变量_year用const修饰,就报错该成员变量不可以被修改,因为它具有常属性。
只需要像上图中那样,用初始化列表来给const修饰的成员变量_year进行赋值即可。此时就是在定义的时候进行初始化。
class Date
{
public:
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_day(day)
{}
private:
const int _year;
int _month;
int _day;
};
引用变量是另一个变量的别名,而且一经定义初始化以后就不可以被修改,因为构造函数的功能是给成员函数赋值,所以构造函数中不能操作引用变量,否则就会报错,引用成员变量只能在初始化列表中进行初始化。
const修饰的成员变量具有常量属性,是无法在构造函数中赋值的,所以也必须在初始化列表中进行初始化。
我们用俩个栈实现一个队列的时候,其中俩个栈自定义类型,并且没有默认的构造函数。
上图中,将俩个栈在初始化列表中进行初始化,括号中的初始化值就相当于在给栈的构造函数传参。
上图中没有显式初始化列表,但是可以成功定义并初始化的,因为自定义类型栈有默认构造函数,在创建队列对象的时候,编译器自动生成的初始化列表会定义栈对象,并且调用它的默认构造函数。
内置类型初始化时,有缺省值就用缺省值,没有就初始化为随机数
自定义类型,调用它的默认构造函数,如果没有就报错
使用初始化列表中的定义和初始化
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();
return 0;
}
运行结果是1和一个随机数,很意外吧?
定义_a1的时候,是用的传过来的值1初始化的,并不影响,所以最后的结果就是一个1和一个随机数。explicit关键字
看上图中,创建了3个日期类对象,其中前俩个的初始化方式我们都见过,一个是给默认构造函数只传一个产生,其余俩个用缺省值来初始化。第二个是用赋值运算重载,将对象d1赋值给d2的初始化,其实这也的实质是利用拷贝构造函数来初始化的,大家可以自己调试去看看。
这是它的转换过程。编译器会对这个过程进行优化,后面我们再说怎么优化的。
同样的,在进行类型转换的时候,需要将整型先转换成一个Date类型变量,并且存放在一个临时变量中。
使用const修饰d4以后就没有权限放大的问题了,就可以成功转换类型,又由于调用成员函数Print的时候,此时的指针是const Date* const this,所以成员函数也得用const修饰才行。
static成员
class A
{
public:
//构造函数
A()
{
_count++;
}
//拷贝构造函数
A(A& a)
{
_count++;
}
//获取计数值函数
static int GetCount()
{
return _count;
}
private:
static int _count;
};
//静态成员变量定义初始化
int A::_count = 0;
int main()
{
A a1, a2;
A a3(a1);
cout << A::GetCount() << endl;
return 0;
}
可以看到,结果显式一共创建了3个类对象,那么该函数的原理是什么呢?
成员变量_count是被static修饰的静态成员变量,它是存放在静态区的,但是属于这个类。
无论创建多少个类对象,静态成员变量只有这一个,所有的类对象是共享这一个静态成员变量的。
静态成员变量的定义和初始化是在类外进行的,而且是必须在类外进行。
用static修饰的成员函数叫做静态成员函数,它的存储位置仍然是在公共代码区,和非静态成员一样。但是由于被static修饰,它又有新的特性。
我们知道,在用类对象调用类中的成员函数以及访问成员变量的时候是通过this指针实现的,this指针中的地址就是该对象的地址。
上图中,成员函数中只有_year是静态成员变量,其他俩个都是非静态的。打印函数也是静态成员函数,要打印出这三个成员变量,但是编译器报错非静态成员的非法访问。这就说明,静态成员函数是不能够方法非静态成员的,因为它没有this指针,就无法准确定位到是哪个类对象。
只打印静态成员变量是可以的,因为静态成员函数只能访问静态成员。
可以看到,无论使用这俩种的哪种,即使静态成员变量存在静态区,但是因为它是私有的,所以在类域外是无法访问的。友元
友元函数
将流插入运算符重载函数的函数声明用关键字friend修饰,然后放在类中,可以在类中的任何地方。
友元类
创建一个时间类,在类中将日期类声明为友元类,此时日期类就可以访问时间类中的任何成员。
此时就通过日期类中的成员函数就可以将日期类和时间类中的成员变量都打印出来。
比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。
如果C是B的友元, B是A的友元,则不能说明C时A的友元。内部类
上面程序中,将日期类在时间类中定义,此时日期类就是时间类的内部类。可以通过内部类的成员函数来访问外部类的私有变量,就和上面的友元类一样,此时内部类是外部类的友元类,即日期类是时间类的友元类。
创建内部类的时候,要使用 外部类::内部类 对象名的方式类创建内部类,并且进行初始化,外部类的创建直接使用 外部类::对象名的方式创建。
这里就可以看出来,内部类和外部类其实是俩个独立的类,它们只是存在内部类天生就是外部类友元的关系,其他都是相互独立的。匿名对象
上图中,创建一个有名的日期对象,在构造函数和析构函数中分别加一句打印语句来证明这个对象确实存在过并且销毁。
可以看到,对象d1确实创建了,并且在程序结束的时候被销毁了。
通过调试我们可以看到,此时执行到了要创建有名对象d1的时候,在这之前,匿名对象是已经创建过的,而且控制台也对应着输出了构造函数和析构函数,说明此时已经执行过一次构造函数和一次析构函数了。
拷贝对象时的一些编译器优化
创建一个类如上图中,类中显式定义了构造函数,拷贝构造函数,赋值运算符重载,以及析构函数,并且在每个函数中都加了一个打印语句。
上图中,我们将之前的俩句才能完成的功能写成一句。
再次理解类和对象
总结