一.初始化列表
我们之前的构造函数都是在函数体内对数据成员进行赋值
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
然而我们的构造函数还有另一种初始化的方式:初始化列表 ——初始化列表是以参数表后冒号开始,用数据成员后面括号中的变量或者表达式来进行初始化。
Date(int year, int month, int day):
_year(year), _month(month), _day(day) {}
初始化列表也叫做初始值列表,其的特征为:
1.每个数据成员只能在初始化列表中出现一次,初始化列表的作用是初始化成员变量,多次出现重复初始化有什么意义?
2.引用成员变量,const成员变量,以及没有默认构造函数的类对象成员必须使用初始化列表进行初始化。
引用类型在定义的时候就必须得初始化,否则会报错
const成员变量只能初始化,不能赋值,因为其值是不可改变的,所以也必须得在初始化列表中初始化。
那为什么普通的构造函数不可以呢?
因为普通的构造函数进行的是赋值操作,而并非初始化操作。
3.成员的初始化顺序与类中生命的顺序是一致的,与初始化列表中的顺序无关。
如上图所示,初始化成员的顺序并不是按照_day,_month,_year的顺序进行初始化的,而是按照数据成员声明的顺序进行初始化的,先初始_year,_month,_day。
例:
class A { public: A(int ii): _a1(ii),_a2(_a1){} private: int _a2; int _a1; };
当输入ii == 1时,cout<<_a1<<_a2<<的结果是?
结果是:1 随机值
分析:成员的初始化顺序与类中声明的顺序一直,类中先声明_a2,再声明_a1,所以在构造函数中,先对_a2进行初始化,在对_a1进行初始化,由于_a1还没有被初始化所以_a2就是随机值,随后_a1被ii初始化为1.
4.C++11支持在成员变量声明的位置给出缺省值,这个缺省值会给没有显示在初始化列表中初始化的成员进行初始化。(注意,这个缺省值要和函数形参的缺省值区分开。)
在声明成员的时候给出缺省值,并不是给成员变量初始化。
当我们没有在初始化表中显式写出_a2,此时就会使用_a2的缺省值对_a2进行初始化:
5.使用初始化列表的方式对数据成员进行初始化可以提升效率
初始化列表的方式是直接进行初始化操作,而普通的构造函数是先初始化然后再进行赋值操作。
这里给出总结:能用初始化列表就用初始化列表,尽量使初始化列表中的顺序和成员声明的顺序保持一致,另外就是避免用一个成员变量去初始化另一个成员变量
二.类型转换
- C++支持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的的构造函数
- 构造函数前面加上explicit就不再支持隐式类型转换
- 类类型的对象之间也可以进行隐式转换,需要相应的构造函数支持
class A
{
public:
A(int a) :
_a(a)
{}
A(int a,int b):
_a(a),_b(b)
{}
private:
int _a;
int _b;
};
int main()
{
A a1(1);
//1会先隐式类型转换为A类型的临时变量,然后该临时变量在拷贝构造给a2
A a2 = 1;
//C++11之后支持多参数的转化
//但是必须用花括号括起来
A a3(1, 2);
A a4 = { 1,2 };
A a5 = 1, 2;//错误
return 0;
}
三.static成员
1.用static修饰的成员变量,称为静态成员变量,静态成员变量必须在类外进行初始化
class A { private: static int _a; }; int _a = 1;
2.静态成员变量是所有该类对象所公有的,不是属于某一个对象的,不存在对象中,存放在静态区
3.用static修饰的成员函数,称之为静态成员函数,静态成员函数没有this指针,所以静态成员函数不可以访问非静态成员变量,只能访问静态成员变量
static void Show() { std::cout << _a << std::endl;//正确 std::cout << _b << std::endl;//错误,静态成员函数不能访问非静态成员变量 }
4.非静态成员函数可以访问任意的静态成员变量和静态成员函数
void Print() { std::cout << _a << std::endl;//正确,非静态成员函数可以访问静态成员变量 std::cout << _b << std::endl;//正确 }
5.突破类域的限制,就可以访问类中的静态成员,有两种访问方式:
类名::静态成员
对象.静态成员
//静态成员变量 std::cout << A::_a << std::endl; A a(10); std::cout << a._a << std::endl; //静态成员函数 A::Show(); A b(10); b.Show();
需要注意的是,静态成员也是类的成员,依旧受到访问限定符(private,public,protected)的限定作用。如果静态成员是被private修饰的,那么通过上面的访问方式不能访问。
6.因为静态数据成员不属于某一个对象,而是属于该类的,所以它并不是在创建对象时被定义的。这意味着它们不是由类的构造函数初始化的。而成员变量在初始化时都要走初始化表,所以静态成员变量是不走初始化列表。它们要在类外部进行定义和初始化。
7.静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认参数(缺省值)。
非静态数据成员不能作为默认参数,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。
四.友元
一个类中的私有成员只能由该类的成员函数来访问,但是除此之外,C++还可以让外部函数访问一个类的私有成员变量或者私有成员函数,方法是——声明该函数为这个类的友元(friend)函数。
1.友元提供了一种突破访问限定符限制的访问方式,友元分为:友元函数和友元类,其声明方式是在函数声明或者类声明的前面加上friend关键字。
当我们直接用Print访问A的私有成员时,会发生报错
我们只需要在类中对Print进行友元声明,此时Print就可以访问A的私有成员
2.友元函数只能出现在类定义的内部,但是在类内出现的具体位置不限,即友元函数可以在类中的任何地方进行声明,不受访问限定符(private,public,protected)的限制。
这三个位置的效果是相同的,Print依旧是A类的成员函数,可以访问A类中的私有成员。
一般来说,最好在类定义开始或者结束前的位置集中声明友元。
3.友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对该函数进行一次声明。
Note:许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。
4.当一个类成为另一个类的友元类时,该类的所有成员函数都可以访问另一个类的私有成员。
注意:虽然我们已经在类A中声明类B为A的友元类,但是因为B是在A的后面定义的,A并不认识B,所以还得对B进行声明,这是前置声明。
//前置声明 class B; class A { friend B; public: A(int a, int b) : _a(a), _b(b) {} private: int _a; int _b; }; class B { public: void Print(const A& a) { std::cout << a._a << " " << a._b << std::endl; } };
5.还可以将一个类的成员函数声明为另一个类的友元
但是这种声明有条件要求:
- 要先定义内部有函数要作为友元的类,在定义另一种类
- 该成员函数只能在类中声明,且定义在这两个类的后面
只有满足上面的条件,才能使友元函数成立。
现分析下面代码:
B类中的Print函数要作为A类的友元函数,所以要将B类定义到A类的前面。且该Print函数只能在B类中声明,而不能定义。定义要放在这两个类的后面。否则Print就无法访问类A的私有成员。
class B { public: void Print(const A& a); }; class A { friend void B::Print(const A& a); public: A(int a, int b) : _a(a), _b(b) {} private: int _a; int _b; }; void B::Print(const A& a) { std::cout << a._a << " " << a._b << std::endl; }
当我们交换上面类A和类B的顺序时,友元函数无法成立:
当Print在类内定义或者没有定义在这两个类的后面时,友元无法成立:
6.一个函数可以是多个类的友元函数,一个类也可以有多个友元函数
因为A先定义,而在A中声明友元的时候出现了B,所以为了避免编译器不认识B,所以要对B进行前置声明
//前置声明 class B; class A { friend void Print(const A& a, const B& b); private: int _a; int _b; }; class B { friend void Print(const A& a, const B& b); private: char _a; char _b; }; void Print(const A& a, const B& b) { std::cout << a._a << " " << a._b << std::endl; std::cout << b._a << " " << b._b << std::endl; }
7.友元类的关系是单向的 ,比如:A类是B类的友元,但是B不是A的友元(A类对象可以访问B类对象的私有成员,但B对象不可以访问A类对象的私有成员)
8.友元关系不具有传递性,A是B的友元,B是C的友元,但A不是C的友元
五.内部类
如果A类定义在了B类的内部,那么A类就叫做B类的内部类。
1.内部类是一个独立的类,它跟定义在全局的类的区别只在于收到了类域和访问限定符的限制,所以外部类定义的对象,并不包括内部类。
class B { public: class A { private: int _a; int _b; }; };
我们可以验证一下B类的大小,看看是否包含类A。
因为外部类B除了类A外并没有任何成员变量,所以它的大小就是1.从这里也可以看出内部类是不算在外部类中的。
2.内部类受到访问限定符的限制,当内部类被public修饰时,此时该内部类除了外部类可以访问外,其他的类也可以访问。但如果被private或者protected修饰时,就成了外部类的专属内部类,只有该外部类可以访问。
class B { public: class A { void Print(const B& b) { std::cout << b._a << " " << b._b << std::endl; } }; private: int _a; int _b; }; //上面相当于下面 class A { void Print(const B& b) { std::cout << b._a << " " << b._b << std::endl; } }; class B { public: friend A; private: int _a; int _b; };
从这里也可以看出外部类B的大小不包含内部类A
3.内部类默认是外部类的友元,即内部类的成员函数可以访问外部类的私有成员函数或者成员变量。
class B { public: class A { void Print(const B& b) { std::cout << b._a << " " << b._b << std::endl; } }; private: int _a; int _b; };
六.匿名对象
在C++中,匿名对象是指在没有分配给任何变量的情况下创建的临时对象。它们通常用于在需要临时的对象时使用,而不需要将其分配给变量。
其语法格式为:
类名();
与匿名对象相反的就是有名对象,我们可以对这两个对象进行对比,以此来理解匿名对象:
class A
{
public:
A(int a,int b):
_a(a),_b(b)
{}
private:
int _a;
int _b;
};
int main()
{
A a(1,2);//有名对象
A(3,4);//无名对象
return 0;
}
我们从上面就可以看出有名对象和无名对象的最主要区别就在于没有名字。
还有一个区别就是 匿名对象的声明周期只在当前行,当程序运行到这一行时,匿名对象创建,到下一行时,匿名对象就已经销毁了。而有名对象的声明周期是在创建该对象的作用域中。
匿名对象也可以简化我们的调用方式,以往我们调用一个成员函数是必须得是:
对象.成员函数()
而使用匿名对象就可以简化调用方式:
class Solution
{
public:
int sum_Solution()
{
std::cout << "sum_Solution" << std::endl;
}
private:
int _i;
};
int main()
{
Solution s;
s.sum_Solution();//有名方式调用成员函数
Solution().sum_Solution();//匿名对象调用
return 0;
}
这种调用方式在对象名比较长非常方便。
需要注意的是匿名对象和临时对象一样,也具有常性。所以匿名对象在使用期间就有可能造成权限放大的问题。
A& a = A(1, 2);
上面这段代码在编译时就会报错, 因为A(1,2)会创建匿名对象,而匿名对象具有常性,不可修改,而我们将其赋值给了一个该类的引用,其值可以修改,放大了其权限。
为了解决这个问题,我们只需加上const即可,使该引用也变成不可修改。
const A& a = A(1, 2);
但是用const修饰以后,该匿名对象的声明周期就得到了延长,从当前行延长到了程序结束。
七.对象拷贝时的编译器优化
- 现代编译器为了尽可能提高程序的效率,在不影响正确性的情况下会尽可能减少一些传参和传返回值的过程中可以省略的拷贝。
- 如何优化C++标准并没有严格规定,各个编译器会根据情况自行处理。当前主流的相对新一点的编译器对于连续表达式步骤中的连续拷贝会进行合并优化,有些更新更“激进”的编译器还会进行跨行表达式的合并优化。
注意:接下来的分析全都借助于VS2022来分析,不同版本的编译器优化程度不同
1.传值传参
当我们以下面的方式调用f1时,是不会发生编译器优化的,因为调用f1函数时仅仅发生了拷贝构造,编译器只有遇到【拷贝+拷贝构造】时,才会进行合并优化为拷贝。
void f1(A a)
{}
int main()
{
A aa(1,1);
f1(aa);
}
当我们直接传入一个1时,此时会发生优化么?
f1(1);
会!
因为此时1会发生隐式类型转换,转换为该类的临时对象,这里会调用构造,然后传值传参时又会调用拷贝构造,此时构造和拷贝构造连续发生,编译器会优化,直接变为一个构造
当我们传入A(3)时会不会优化呢?
f1(A(3));
会!
A(3)此时会先调用构造函数,然后传值传参时又会调用拷贝构造,两者连续出现会发生优化,直接构造。
2.传引用传参
传引用传参可以减少拷贝,提高运行效率。
void f2(const A& aa)
{}
当我们先创建对象然后调用f2
A a(1);
f2(a);
创建对象时会调用构造函数,在调用f2时不会发生拷贝构造,因为这里aa直接作为a的别名就传给了函数,不需要创建临时变量,也就不需要拷贝构造了。
直接传3,发生隐式类型转换
f2(3);
先发生类型转换调用构造函数生成临时变量,然后aa直接作为该临时变量的别名,不需要再调用拷贝构造。
传A(3)的结果和直接传3的结果是一样的
f2(A(3));
A(3)会先调用构造函数,然后与前面相同,aa直接作为该匿名对象的别名,也不再需要拷贝构造。
总结:在传参时,如果可以传引用的话,最好就传引用,这样可以减少拷贝,增加运行效率
3.传值返回
A f3()
{
A aa(1);
return aa;
}
当我们直接调用f3()时
f3();
在f3函数中,会先调用构造函数,创建对象aa,在返回时会调用拷贝构造生成一个临时对象,此时编译器会直接优化为一个构造
当我们用另一个对象接收f3()的返回值时
A aa = f3();
此时会先发生一个构造生成待返回的对象,然后拷贝构造生成临时对象,在用该临时对象拷贝构造aa,此时发生的过程为【构造+拷贝构造+拷贝构造】,此时编译器会直接优化为一个构造
需要注意的是,连续的拷贝构造和赋值运算符重载也会优化
A aa; aa = f3();
在函数体内创建aa变量之后,应该先拷贝构造生成临时对象,然后用该临时对象通过赋值运算符重载给aa,但是在VS2022环境下,编译器对次做出了优化,优化掉了拷贝构造部分。
但是VS2019的debug环境下,拷贝构造和赋值运算符重载是不会进行优化的:
在VS2019release版本下,优化结果与VS2022相同。
总结:编译器对对象拷贝时的构造优化是不同的,根据编译器的不同,或者同种编译器的不同版本,会进行不同的优化结果。