1.const 成员
若定义了一个 const 的对象,然后访问其成员函数,会报错,这是为什么?
因为在传参时,d2 的地址 &d2 会被传递给 Print() ,作为隐藏的参数 this 指针:
void Print(Date* const this) // this 指针隐藏 { cout << _year << '-' << _month << '-' << _day << endl; }
对于 Date d1 ,传递过去的 &d 是 Date* ;而 const Date d2 ,传递过去的 &d2 是 const Date* .
对于 this 指针本身是 Date* const this ,此刻 const 修饰的是 this ,this 不可改,但是 * this 是可改的 。
而传参时传过来的 &d2 为 const Date* ,这时 const 修饰指针指向的内容,即对象本身不可改了。
但对于Print()函数的 this 来说,*this,也就是指向的内容,即对象本身是可改的,但是现在由于 const 使得指向内容不可改,对于权限来说,只能对等和缩小,但是const Date d2在传递时权限放大了,所以报错 。
为了解决这一问题,C++ 引入了 const 成员 ,在该成员后加上 const :
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
void Print() const { cout << _year << '-' << _month << '-' << _day << endl; }
这时 this 指针的类型变为 const Date* const this ,权限对等了,这时 this 指针不能改,且 *this ,即 this 指向的对象也不能改,和 const Date d2 的目的相同:不可改 d2 。 这时,就不会报错了。
而对于 d1 对象,它虽然没有 const ,但是也只是 权限缩小,使得 d1 在 Print() 成员函数中不可修改而已,也是没问题的。
总结:成员函数加上 const 是好的,建议能加上 const 都加上。这样普通对象和 const 对象,都可以调用。但是如果对于要对 对象 进行修改的成员函数不要加上,不然就完成不了目的了。
注:对于构造和析构不能加上const修饰。
2.取地址与const取地址操作符重载
我们知道,对于自定义类型成员来说,平常的操作符需要重载后才能对对象进行操作。但是对于自定义类型的对象来说,如果不写这两个成员函数,使用默认的成员函数照样也可以完成目的:所以一般不写,但是写的话也可以:
class Date { public: Date* operator&() { return this; } const Date* operator&() const { return this; } };
就只要返回 this 就可以;对于 const 取地址操作符,则要加上 const 成员,并且返回的指针也要加上 const 修饰。
取地址与const取地址操作符重载:
可以直接取出成员的地址,一般不自己写
运用场景:使其取不到地址
总结
: 对于六个默认成员函数,前四个最重要:构造、析构、拷贝构造、运算符重载。后两个有一定作用,但是作用不大。
3. 再谈构造函数
3.1 构造函数体赋值
在创建对象时,编译器会通过调用构造函数,给对象中的各个成员变量一个合适的初始值:
class Date { public: // 构造函数 Date(int year = 0, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; }; class Date
注意
:虽然通过调用上述的构造函数后,对象中的每个成员变量都有了一个初始值,但是构造函数中的语句只能将其称作为赋初值,而不能称作为初始化。因为初始化只能初始化一次,而构造函数体内可以进行多次赋值。
class Date { public: // 构造函数 Date(int year = 0, int month = 1, int day = 1) { _year = year;// 第一次赋值 _year = 2022;// 第二次赋值 //... _month = month; _day = day; } private: int _year; int _month; int _day; };
3.2 初始化列表
3.2.1 定义
以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个成员变量后面跟一个放在括号中的初始值或表达式。初始化列表:对象的成员定义的位置
class Date { public: // 构造函数 Date(int year = 0, int month = 1, int day = 1) :_year(year) , _month(month) , _day(day) {} private: int _year; int _month; int _day; };
3.2.2 注意事项
:
1.每个成员变量在初始化列表中最多只能出现一次
因为初始化只能进行一次,所以同一个成员变量在初始化列表中不能多次出现。2.类中包含以下成员,必须放在初始化列表进行初始化
2.1 引用成员变量
引用类型的变量在定义时就必须给其一个初始值,所以引用成员变量必须使用初始化列表对其进行初始化。
int a = 10; int& b = a;// 创建时就初始化
2.2 const成员变量
被const修饰的变量也必须在定义时就给其一个初始值,也必须使用初始化列表进行初始化。
const int a = 10;//correct 创建时就初始化 const int b;//error 创建时未初始化
2.3 自定义类型成员(该类没有默认构造函数)
若一个类没有默认构造函数,那么我们在实例化该类对象时就需要传参对其进行初始化,所以实例化没有默认构造函数的类对象时必须使用初始化列表对其进行初始化。
默认构造函数是指不用传参就可以调用的构造函数:
1.编译器自动生成的构造函数。
2.无参的构造函数。
3.全缺省的构造函数。
class A //该类没有默认构造函数class A //该类没有默认构造函数 { public: A(int val) //注:这个不叫默认构造函数(需要传参调用) { _val = val; } private: int _val; }; class B { public: B() :_a(2021) //必须使用初始化列表对其进行初始化 {} private: A _a; //自定义类型成员(该类没有默认构造函数) };
总结:在定义时必须进行初始化的变量类型,就必须放在初始化列表进行初始化。
三、尽量使用初始化列表初始化
因为初始化列表实际上就是当实例化一个对象时,该对象的成员变量定义的地方,所以无论是否使用初始化列表,都会走这么一个过程(成员变量需要定义出来)
严格来说:
1.对于内置类型,使用初始化列表和在构造函数体内进行初始化实际上是没有差别的,其差别就类似于如下代码:
// 使用初始化列表 int a = 10 // 在构造函数体内初始化(不使用初始化列表) int a; a = 10;
2.对于自定义类型,使用初始化列表可以提高代码的效率
class Time { public: Time(int hour = 0) { _hour = hour; } private: int _hour; }; class Test { public: // 使用初始化列表 Test(int hour) :_t(12)// 调用一次Time类的构造函数 {} private: Time _t; };
当我们要实例化一个Test类的对象时,我们使用了初始化列表,在实例化过程中只调用了一次Time类的构造函数。
我们若是想在不使用初始化列表的情况下,达到我们想要的效果,就不得不这样写了:
class Time { public: Time(int hour = 0) { _hour = hour; } private: int _hour; }; class Test { public: // 在构造函数体内初始化(不使用初始化列表) Test(int hour) { //初始化列表调用一次Time类的构造函数(不使用初始化列表但也会走这个过程) Time t(hour);// 调用一次Time类的构造函数 _t = t;// 调用一次Time类的赋值运算符重载函数 } private: Time _t; };
这时,当我们要实例化一个Test类的对象时,在实例化过程中会先在初始化列表时调用一次Time类的构造函数,然后在实例化t对象时调用一次Time类的构造函数,最后还需要调用了一次Time类的赋值运算符重载函数,效率就降下来了。
3.初始化列表虽好,但有些地方还是需要函数体赋值,比如判断开辟空间是否成功·还要一些工作是初始化列表做不完的,比如动态开辟二维数组
四、成员变量在类中声明的次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后顺序无关:
#include <iostream> using namespace std; int i = 0; class Test { public: Test() :_b(i++) ,_a(i++) {} void Print() { cout << "_a:" << _a << endl; cout << "_b:" << _b << endl; } private: int _a; int _b; }; int main() { Test test; test.Print(); //打印结果test._a为0,test._b为1 return 0; }
代码中,Test类构造函数的初始化列表中成员变量_b先初始化,成员变量_a后初始化,按道理打印结果test._a为1,test._b为0,但是初始化列表的初始化顺序是成员变量在类中声明次序,所以最终test._a为0,test._b为1。
例题:
答案:D,按申明的顺序,先初始化_a2,为随机值,后初始化_a1,为1
所以在写程序时要尽量按照申明的顺序初始化,否则容易入坑:运行以上程序,会崩溃,因为按申明的顺序,先初始化_a,但此时capacity还未初始化,为随机值,开辟的空间太大,程序崩溃。
五、到底是否使用初始化列表,具体问题具体分析
3.3 explicit关键字
构造函数不仅可以构造和初始化对象,对于单个参数的构造函数,还支持隐式类型转换。
#include <iostream> using namespace std; class A { public: A(int a) //单个参数的构造函数 :_a(a) { cout << "A(int a)" << endl; } private: int _a; }; int main() { A aa1 (1); A aa2 = 2;//隐式类型转换 return 0; }
在语法上,代码中A aa2 = 2等价于以下两句代码:
Date tmp(2); //先构造 Date aa2(tmp); //再拷贝构造
在早期的编译器中,当编译器遇到 A aa2 = 2 这句代码时,会先构造一个临时对象(临时对象具有常性),再用临时对象拷贝构造 aa2;但是现在的编译器已经做了优化,当遇到 A aa2 = 2这句代码时,会按照 A aa2 (2)这句代码处理,这就是隐式类型转换。实际上,我们早就接触了隐式类型转换:
int i = 10; double d = i; //隐式类型转换
在这个过程中,编译器会先构建一个double类型的临时变量接收i的值,然后再将该临时变量的值赋值给d。这就是为什么函数可以返回局部变量的值,因为当函数被销毁后,虽然作为返回值的变量也被销毁了,但是隐式类型转换过程中所产生的临时变量并没有被销毁,所以该值仍然存在。
但是,对于单参数的自定义类型来说,A aa2 = 2 这种代码的可读性不是很好,我们若是想禁止单参数构造函数的隐式转换,可以用关键字explicit来修饰构造函数。