目录
前言
前面我们介绍了类和对象的基本概念以及类的六个默认成员函数,这些知识已经为我们搭起了一个基本的类框架,不过类和对象中还有一些小细节需要介绍,本节我们将进入类和对象的收尾阶段!
正文
初始化列表
成员变量的定义与初始化
上篇我们学习了构造函数,构造函数是用来初始化成员变量的,而成员变量的定义是在初始化列表,对于一些需要在定义时就赋值的成员,例如 const int x ,这时需要使用初始化列表进行,因为在C++11之前,C++98并不支持在声明列表中给缺省值!
虽然上述构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化,构造函数体中的语句只能将其称为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。
int num = 0; //在定义时赋初值 - 初始化 num = 1; //被定义后赋值 - 赋值
那么初始化列表与构造函数有什么关系呢?构造函数是对象在实例时就调用的一个函数,初始化列表在构造函数中,会随构造函数一起执行,初始化列表最先执行并将指定的值赋给每个成员变量!如果我们没有显示去写初始化列表编译器仍然会执行初始化列表,只不过内置类型初始化为随机值,自定义类型则调用其默认构造函数!
示例1:
class Date { public: //猜猜下面两个构造函数的区别 Date() //初始化列表初始化 :_year(2008) ,_month(12) ,_day(21) { _year = 0; _month = 0; _day = 0; } Date(size_t year, size_t month, size_t day) //函数内赋值 { _year = year; _month = month; _day = day; } private: size_t _year; size_t _month; size_t _day; }; int main() { Date d1; Date d2(1970,1,1); return 0; }
![]()
使用初始化列表的构造函数 ![]()
未使用初始化列表的构造函数 示例2:
class Test { public: Test() //a会被初始化为什么? :a(1) { } private: int a = 2; }; int main() { Test t; return 0; }
![]()
编译器优先使用初始化列表中的初值进行初始化
初始化列表的使用
初始化列表:在函数参数()后,函数体前{},以一个(:)冒号开始,接着是一个以(,)逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式,最后一个列表成员后没有任何符号。
//1.所有对象的成员变量都是在初始化列表被定义的 //2.无论是否显示在初始化列表写,每个变量都会在初始化列表中被定义和初始化 class Date { public: Date(int year = 1970, int month = 1, int day = 1) :_year(year) //初始化列表初始化 , _month(month) , _day(day) { //其他初始化行为 } private: int _year; int _month; int _day; };
注意
- 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
- 类中包含以下成员,必须放在初始化列表位置进行初始化:引用成员变量,const成员变量,自定义类型成员且该类没有默认构造函数时(简而言之就是需要在初始化指定初始值的成员变量)
//错误示例 class Test2 { Test2(int a) {} }; class Test { public: Test() {} private: const int num; //这三种成员变量不使用初始化列表初始化是无法编译的! int& ref; Test2 t; }; int main() { Test t; return 0; }
![]()
上述代码编译报错
因为构造函数中函数体是以赋值的方式初始化的,在执行赋值语句前,变量需要被定义和初始化,编译器默认的初始化手段是赋随机值,const只能在初始化阶段指定初始值,如果const常亮被赋予随机值是不合理的,这里也突出了构造函数的一些缺陷,为了补足这些缺陷,C++使用初始化列表定义和初始化且初始化列表存在与构造函数中!
![]()
节选自C++之父Scott Meyers名书Effective C++ ( 其中成员初值列就是初始化列表) 所以尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化!
变量定义顺序
成员变量既然在初始化列表定义,那定义的顺序是由什么觉得的?
成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关!
class Test { public: Test() //猜猜a会被初始化为1吗? : b(1) , a(b) { cout << a << endl; } private: int a = 0; int b = 0; }; int main() { Test t; return 0; }
![]()
a在b前被定义但是b此时未被初始化所以是随机值 结论:成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
所以这里建议:初始化列表顺序与成员变量声明顺序一致!
explicit关键字
我们在平时写代码中不妨会涉及类型转换,类的实例化也是,实例化对象时也会发生类型转换!
隐式类型转换
对于两个类型不同的变量,如果要赋值则需要进行类型转换。
int i = 0; double d = 1.23456; i = d; //这里会发生隐式类型转换
自定义类型隐式转换
//示例代码 class Test { public: //默认构造函数 Test(int num = 0) :_num(num) { cout << "构造函数" << endl; } //拷贝构造 Test(const Test& t) { _num = t._num; cout << "拷贝构造" << endl; } //赋值重载 Test& operator=(const Test& t) { _num = t._num; cout << "赋值重载" << endl; } private: int _num; }; int main() { //初始化会成功吗? Test t = 10; return 0; }
很显然,初始化成功了,但是我想告诉大家,这次初始化调用了构造函数和拷贝构造和两个函数,进行了隐式类型转换然后初始化了 t 对象!
![]()
实际运行调用 这里大家可能会怀疑,上面解释的明明会调用两个构造函数,但是实际上却只调用了一个构造函数,这里要说明的是:编译器为了优化效率,将10直接对t进行构造(直接构造),这样可以提高效率!
Test t = 10; //这样效率太低了 Test t(10); //编译器优化为直接构造
当然如果类初始化时有多参数参与初始化,也支持多参数构造优化!
class Test { public: Test(int a, int b) : _a(a) , _b(b) { cout << "Test()" << endl; cout << "_a = " << _a << " " << "_b = " << _b << endl; } private: int _a = 0; int _b = 0; }; int main() { Test t = { 1,2 }; return 0; }
explicit 限制转换
铺垫了这么久,重点来了!
有时候我们不想使构造函数方式类型转换进行构造,可以在构造函数前加上 explicit 关键字禁止类型转换!
//示例代码 class Test { public: //默认构造函数 - explicit修饰限制类型转换行为 explicit Test(int num = 0) :_num(num) { cout << "构造函数" << endl; } //拷贝构造 Test(const Test& t) { _num = t._num; cout << "拷贝构造" << endl; } //赋值重载 Test& operator=(const Test& t) { _num = t._num; cout << "赋值重载" << endl; } private: int _num; }; int main() { //初始化会成功吗? Test t = 10; return 0; }
![]()
此时类型转换也不存在了,编译器优化也就不存在了 所以,如果我们想要提高代码的可读和规范性,必要时使用explicit修饰构造函数就能防止实例化时发生类型转换!
关于static
在C语言的学习中,我们知道static是静态修饰,被修饰的变量生命周期增长至程序运行周期!但是我们平时在写代码时static必须得慎用,因为其中有很多细节需要我们考虑!
static声明类成员
声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化。
特性
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区。
- 静态成员变量必须在类外定义和初始化且只能初始化一次,定义时不添加static关键字,类中只是声明。
- 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问。
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员(这样就不需要实例化对象,只需要类名就能访问)。
- 静态成员函数只能访问静态成员,因为没有this指针无法调用其他非静态成员,静态成员函数是为了静态成员变量而生的。
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制。
//示例 class Test { public: //Test() // :a(0) //{} //不能在初始化列表初始化,因为静态成员a不属于任何一个单独的对象,a被所有对象共享 static int a; //声明为静态成员 }; int Test::a = 0; //类外定义和初始化静态成员 - 类型 类名::静态成员 int main() { ++Test::a; //通过类名 Test::a 访问静态成员 cout << "Test::a : " << Test::a << endl; Test t; ++t.a; //通过对象 t.a 访问静态成员 cout << "t.a : " << Test::a << endl; return 0; }
重点总结: 静态成员不能调用非静态成员,因为没有this指针,但是非静态成员可以调用静态成员,因为静态成员具有全局属性!
由静态成员的特性,可以求出实例化了多少个对象!
因为实例化对象每次都会调用构造函数,我们可以定义一个静态成员计数器num,每次调用构造函数num就加1,最后就可以完成对构造函数调用次数的统计!
class Test { public: Test() //默认构造 { cout << "Test() "; ++num; } ~Test() //析构函数 { cout << "~Test() "; cout << "num = " << num << endl; //打印当前num } static int num; }; int Test::num = 0; int main() { for (int i = 0; i < 10; ++i) //循环进行实例对象 { Test t; } return 0; }
友元
概述:有些场景下,某些外部函数需要访问类私有和保护成员,友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元分为:友元函数和友元类。
友元声明关键字: friend
友元函数
将函数使用friend修饰并声明在类中,则此函数称为该类的友元函数!
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明。
//区别示例 class Test { friend void fun1(const Test& t); //声明为友元函数 public: Test(int n = 0) :mynum(n) {} private: int mynum; }; void fun1(const Test& t) { cout << t.mynum << endl; //友元函数可以直接访问对象的内部私有和保护成员 } void fun2(const Test& t) { cout << t.mynum << endl; //这里会报错 }//其他函数只能访问公开成员
特性
- 友元函数可访问类的私有和保护成员,但不是类的成员函数
- 友元函数不能用const修饰
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 一个函数可以是多个类的友元函数
- 友元函数的调用与普通函数的调用原理相同
友元函数特殊使用场景
自定义类型重载流提取和流插入运算符
问题:当我们尝试去重载operator<<,然后发现没办法将operator<<重载成成员函数。因为cout的输出流对象和隐含的this指针在抢占第一个参数的位置。this指针默认是第一个参数也就是左操作数了。但是实际使用中cout需要是第一个形参对象,才能正常使用。所以要将operator<<重载成全局函数。但又会导致类外没办法访问成员,此时就需要友元来解决。operator>>同理。
//日期类重载流插入提取运算符 - 友元函数法 class Date { friend ostream& operator<<(ostream& _cout, const Date& d); friend istream& operator>>(istream& _cin, Date& d); public: Date(int year = 1970, int month = 1, int day = 1) : _year(year) , _month(month) , _day(day) {} private: int _year; int _month; int _day; }; ostream& operator<<(ostream& _cout, const Date& d) { _cout << d._year << "-" << d._month << "-" << d._day; return _cout; } istream& operator>>(istream& _cin, Date& d) { _cin >> d._year; _cin >> d._month; _cin >> d._day; return _cin; }
友元类
使用 friend 修饰类并声明在其他类中可以成为该类的友元类!
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
class Time { friend class Date; // 声明日期类为时间类的友元类,则在日期类中就直接访问Time类中的私有成员变量 public: Time(int hour = 0, int minute = 0, int second = 0) : _hour(hour) , _minute(minute) , _second(second) {} private: int _hour; int _minute; int _second; }; class Date { public: Date(int year = 1900, int month = 1, int day = 1) : _year(year) , _month(month) , _day(day) {} void SetTimeOfDate(int hour, int minute, int second) { // 直接访问时间类私有的成员变量 _t._hour = hour; _t._minute = minute; _t._second = second; } private: int _year; int _month; int _day; Time _t; };
特性
- 友元关系是单向的,不具有交换性(比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行)
- 友元关系不能传递
- 如果C是B的友元, B是A的友元,则不能说明C时A的友元
- 友元关系不能继承
- 友元类可以在类定义的任何地方声明,不受类访问限定符限制
内部类
概述
如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。内部类就是外部类的友元类,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。
class A { public: class B //B称为A的内部类 { private: int Bnum; }; private: int Anum; };
这里B是A的内部类(友元类),A是B的外部类!B可以访问A类的所有成员,但是A不能访问B类的所有成员!
特性
- 内部类可以定义在外部类的public、protected、private都是可以的
- 注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名
- 外部类的字节大小与内部类没有任何关系,两者相互独立
- 与友元类的区别:内部类能受到外类的访问限定符限制
- 内部类可以访问外类中的成员,而外类无法访问内部类的成员
内部类是平时使用极少,一般用于隐藏类!
匿名对象
class Test { Test(int n = 0) :num(n) { cout<<"Test()"<<endl; } ~Test() { cout<<"~Test()"<<endl; } private int num; }; int main() { Test(); //构造匿名对象 return 0; }
通过 类名() 的方式可以构造匿名对象,匿名对象最大的特性就是生命周期只在一行,对于一些对象只用一次的情况,可以使用匿名对象优化性能。
匿名对象的生命周期在某些条件下会延长,例如被const引用变量引用后,其生命周期延长至函数执行结束!
编译器对于自定义类型的一些优化
编译器在实例化,传参和隐式类型转换时并非按正常的流程运行,而是进行了一些优化(简化了执行流程),从而提升执行效率!
//示例代码 - 接下来的测试以此类进行 class Test { public: Test(int n = 0) //构造函数 :num(n) { cout<<"Test(int n = 0)"<<endl; } Test(const Test& t) //拷贝构造函数 { num = t.num; cout<<"Test(const Test& t)"<<endl; } Test& operator=(const Test& t) //赋值重载函数 { num = t.num; cout<<"operator="<<endl; return *this; } private: int num; };
隐式类型转换的优化
int main() { Test t = 10; return 0; }
优化前:int类型转换为Test类型都临时对象,拷贝构造临时对象构造对象 t
//相当于 //10 -> Test tmp(10) -> Test t(tmp) // 先方式类型转换 再拷贝构造
优化后: 直接将10作为构造参数构造对象 t
//相当于 Test t(10);
传参优化
void fun(Test t) {} int main() { fun(10); return 0; }
优化前:int类型转换为Test类型(与以上隐式类型转换相同),然后 t 通过拷贝构造形成对象
//相当于 //10 -> Test tmp(10) -> Test t(tmp) // 先方式类型转换 再拷贝构造
优化后:直接构造形成对象
这里与隐式类型转换的优化相似!
返回值优化
Test fun() { return Test(10); //匿名对象 } int main() { Test t = fun(); return 0; }
优化前:构造匿名对象,返回匿名对象的拷贝临时变量,将临时对象拷贝构造形成对象 t
优化后:通过返回的匿名对象,直接构造对象 t
说明
- 引用传参时,编译器无需优化,因为不会涉及拷贝构造
- 实际编码时,如果能采用匿名构造,就用匿名构造,会加速编译器的优化
- 接收参数时,如果分成两行(先定义、再接收),编译器无法优化,效率会降低
编译器会将不必要的 拷贝构造和构造 步骤去掉,优化为直接构造!
编译器只能在一行语句内进行优化,如果将这些语句拆分为多条语句,编译器则不会优化,因为编译器不能擅自主张。
//例如 int main() { Test t; t = 10; return 0; }
合理使用优化
因为编译器对这些情况有优化,所以我们使用以下技巧可以提高程序效率!
- 接收返回值对象时,尽量拷贝构造方式接收,不要赋值接收
- 函数返回时,尽量返回匿名对象
- 函数参数尽量使用 const& 参数
关于对象的理解
现实生活中的实体计算机并不认识,计算机只认识二进制格式的数据。如果想要让计算机认识现实生活中的实体,用户必须通过某种面向对象的语言,对实体进行描述,然后通过编写程序,创建对象后计算机才可以认识。比如想要让计算机认识洗衣机,就需要:
- 用户先要对现实中洗衣机实体进行抽象---即在人为思想层面对洗衣机进行认识,洗衣机有什么属性,有那些功能,即对洗衣机进行抽象认知的一个过程。
- 经过1之后,在人的头脑中已经对洗衣机有了一个清醒的认识,只不过此时计算机还不清楚,想要让计算机识别人想象中的洗衣机,就需要人通过某种面相对象的语言(比如:C++、Java、Python等)将洗衣机用类来进行描述,并输入到计算机中
- 经过2之后,在计算机中就有了一个洗衣机类,但是洗衣机类只是站在计算机的角度对洗衣机对象进行描述的,通过洗衣机类,可以实例化出一个个具体的洗衣机对象,此时计算机才能洗衣机是什么东西。
- 用户就可以借助计算机中洗衣机对象,来模拟现实中的洗衣机实体了。
在类和对象阶段,大家一定要体会到,类是对某一类实体(对象)来进行描述的,描述该对象具有那些属性,那些方法,描述完成后就形成了一种新的自定义类型,才用该自定义类型就可以实例化具体的对象。
最后
<类和对象 - 下> 的知识介绍到这里就结束了!本篇介绍了类变量的定义在初始化列表以及初始化列表的合理使用,static成员的定义,友元概念和编译器的一些优化,相信这些细节的加持下,我们能加深对类和对象的理解,更加合理的使用类,类和对象的知识。类和对象的知识介绍到这里就结束了,希望小伙伴们都能理解!
本次 <C++类和对象 - 下> 就介绍到这里啦,希望能够尽可能帮助到大家。
如果文章中有瑕疵,还请各位大佬细心点评和留言,我将立即修补错误,谢谢!
🌟其他文章阅读推荐🌟
🌹欢迎读者多多浏览多多支持!🌹