目录
一、构造函数补充
1、初始化列表
1.1、初始化列表概念
1.2、初始化列表性质
2、explicit关键字
二、static成员
1、概念及使用
2、性质总结
三、友元
1、友元函数
2、友元类
四、内部类
五、拷贝对象时的一些编译器优化
一、构造函数补充
在《类和对象(二)》中,我们已经学习了关于构造函数的大部分内容,然而这些内容还不足以解决全部的问题。
例如,当我们写下如下代码时,运行程序:
class Test
{
public:
private:
int _a;
int _b;
};
int main()
{
Test t;
return 0;
}
程序运行成功:
但是我们再在成员变量中增加一个 const 类型变量时:
程序报错了,这是因为我们在定义 const 类型变量时,必须要进行初始化。但是由于默认生成的构造函数对于内置类型不做处理,也就无法对 const 类型变量进行初始化,因此而报错。
虽然这里的问题可以通过 C++11 新增的针对内置类型成员打的补丁来解决,即:内置类型成员变量在类中声明时可以给默认值。但是在 C++11 之前这个问题是如何解决的呢?
为了解决这个问题,我们引入一个新的概念:初始化列表。
1、初始化列表
1.1、初始化列表概念
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个 "成员变量" 后面跟
一个放在括号中的初始值或表达式。
形如:
class Test
{
public:
Test()
:_a(1) //初始化列表
, _b(2)
, _c(3)
{
//构造函数的函数体
}
private:
int _a;
int _b;
const int _c;
};
int main()
{
Test t;
return 0;
}
初始化列表是成员变量定义的地方。不管我们写没写初始化列表,编译器都会自己过一遍初始化列表。编译器会把我们在初始化列表中写了的成员变量按照我们写的来进行初始化,把我们没在初始化列表中写的成员变量初始化成默认值。
对于普通的内置类型成员变量是可以初始化成默认值的,因为之后还可以随意修改。但是对于 const 类型的成员变量因为无法再修改,所以必须要被初始化为一个有意义的初始值。
与之同理的还有引用类型的成员变量:
除了以上两类成员变量,还有一类成员变量也必须要放在初始化列表位置进行初始化,那就是自定义类型成员(且该类没有默认构造函数时)。
因为该类没有默认构造函数,所以在初始化时必须要传参,否则会报错。这里可以结合《类和对象(二)》中默认构造函数对于自定义类型成员变量会调用它的默认构造函数来理解,原因就是编译器会自动走一遍初始化列表,遇到自定义类型成员变量会做出对应的处理。
总结:类中包含以下成员,必须放在初始化列表位置进行初始化:
- 引用成员变量
- const成员变量
- 自定义类型成员(且该类没有默认构造函数时)
对于其他类型的成员变量,则就算不在初始化列表中写出来也不会报错。
1.2、初始化列表性质
我们推荐尽量使用初始化列表进行初始化。因为不管你是否使用初始化列表,编译器对于所有成员变量都一定会走一遍初始化列表,并使用初始化列表进行初始化。
每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)。
我们来看如下代码:
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();
}
大家可以看出最终的结果是什么吗?
运行程序后发现 _a1 被初始化为了 1 ,但是 _a2 却被初始化为了随机值。这是为什么呢?
原因是在初始化列表中,变量初始化的顺序并不是看在初始化列表中排列的顺序,而是看在类中声明的顺序。因为在类中是先声明的 _a2 ,所以在变量初始化时就先初始化了 _a2 。又因为在初始化列表中,_a2 是使用 _a1 来初始化的,而此时 _a1 尚且还是一个随机值,所以就导致 _d2 被初始化成了随机值。
2、explicit关键字
我们来看如下代码:
class A
{
public:
A(int a)
:_a1(a)
{}
void Print()
{
cout << _a1 << endl;
}
private:
int _a1;
};
int main()
{
A aa1(1);
A aa2 = 1;
aa1.Print();
aa2.Print();
}
在编译器中运行:
可以发现对象 aa1 、 aa2 的成员变量都被初始化为了 1 。
这是不是说明这两种写法的意义是一样的呢?其实不是的,第一种写法是调用了构造函数来初始化的。而第二种写法实际上是一个隐式类型转换,把整型数字 1 进行类型转换,转换成类类型存储到类类型临时变量中,再把该临时变量赋值给 aa2 。
写一行代码来证明一下:
A& ref = 10;
代码报错,显示 int 无法转换为 A& 类型,这是因为临时变量具有常性,不可更改,所以我们要把 ref 改为 const 类型:
const A& ref = 10;
程序运行成功。
如果我们不希望发生这种隐式类型转换,则可以使用关键字: explicit 。
此时,第二种写法就已经不被允许了。用 explicit 修饰构造函数,将会禁止构造函数的隐式转换。
补充说明: 类型转换针对的是单参数构造函数,C++98 不支持多参数构造函数。但是在C++11 中对此进行了拓展,使多参数构造函数也支持隐式类型装换了。形如:
A aa3 = {2, 2};
二、static成员
1、概念及使用
声明为 static 的类成员称为类的静态成员。用 static 修饰的成员变量,称之为静态成员变量。用
static 修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化。
静态成员不属于某个对象,而是属于所有对象,属于整个类。
例如,我们实现了一个类,现在想要计算程序中创建出了多少个类对象,就可以使用静态成员变量来计算:
class A
{
public:
A(int a = 0)
{
++count;
}
A(const A& aa)
{
++count;
}
//读取私有的成员变量 count
int GetCount()
{
return count;
}
private:
static int count; //声明
};
int A::count = 0; //定义初始化
int main()
{
A aa1;
A aa2(aa1);
return 0;
}
静态变量是被存放在静态区的,被所有类对象共用。又因为静态变量 count 是在类域中声明的,所以变量名也不会与外界的变量名相互冲突。
需要注意的是,静态变量的初始化不能在类内进行,只能放在类外。这是因为 count 作为静态变量被所有对象共用,不应该在初始化列表中被初始化,在初始化列表中进行初始化的变量是单独属于某个对象的。
所以静态变量的声明放在类内,而定义是放在全局的。在全局定义的时候要加上 域名: : 。
因为我们实例化了两个对象 aa1 、aa2 ,所以 count 的值为 2 ,所有对象都共用一个 count 。
因为静态变量 count 是存放在静态区的,而不是对象内,所以蓝色方框框起的 "->" 符号没有访问到对象内部的数据,只起到了提示域名的作用,不属于解引用。具体相关知识可以参考《类和对象(一)》。
但是如果当前函数作用域内没有对象的话,使用起来就会有些麻烦,像下面这样:
由于 main 函数中没有对象,也就无法通过对象调用 GetCount 函数来读取 count 的值。于是只能专门实例化出一个对象来读取,同时还要把读取到的 count 减去一,去掉这个我们新定义出来的没有其他实际意义的对象。
补充内容:因为我们实例化出对象 aa ,仅仅只是为了在这个地方使用一次,一次过后就不会再去使用它。所以这里可以使用一个特殊的对象:匿名对象来简化代码。
在《类和对象(二)》中,我提到过,在实例化对象时,不可以写成这种形式:
A aa();
因为编译器无法区分这段代码是一个函数的声明,还是调用默认构造函数。但是下面这种写法是可以的:
A();
意为实例化了一个匿名对象,他的特点是生命周期只存在于这一行,刚好符合我们只调用一次的需求,所以读取 count 的值时,我们也可以这样写:
但是这样写起来的话还是太过于麻烦,也不够优雅。为了解决这个问题,我们再来学习一个东西:静态成员函数。
静态成员函数是在成员函数前面使用 static 修饰。他没有 this 指针,于是我们可以直接调用该函数:
同时,由于静态成员函数没有 this 指针,也就没有办法访问非静态成员。可以说静态成员函数就是为了静态成员变量而生的。
有了上面的知识,我们来看一下下面这段代码创建了多少了对象:
void func()
{
A aa1;
A aa2(aa1);
A aa3[10];
}
int main()
{
func();
cout << A::GetCount() << endl;
return 0;
}
答案是 12 个,因为 aa3[10] 是一个容量为 10 的自定义类型数组,也就调用了 10 次构造函数。
2、性质总结
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
- 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
- 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制
三、友元
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以
友元不宜多用。友元分为:友元函数和友元类。
1、友元函数
问题:现在尝试去重载 operator<< ,然后发现没办法将 operator<< 重载成成员函数。因为 cout 的输出流对象和隐含的 this 指针在抢占第一个参数的位置。 this 指针默认是第一个参数也就是左操作数了。但是实际使用中 cout 需要是第一个形参对象,才能正常使用。所以要将 operator<< 重载成全局函数。但又会导致类外没办法访问成员,此时就需要友元来解决。 operator>> 同理。
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在
类的内部声明,声明时需要加 friend 关键字。
关于友元函数的说明:
- 友元函数可访问类的私有和保护成员,但不是类的成员函数
- 友元函数不能用const修饰
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 一个函数可以是多个类的友元函数
- 友元函数的调用与普通函数的调用原理相同
2、友元类
除了函数可以是类的友元之外,类也可以是类的友元。友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
关于友元类的说明:
- 友元关系是单向的,不具有交换性。
比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。 - 友元关系不能传递
如果C是B的友元, B是A的友元,则不能说明C时A的友元。 - 友元关系不能继承,在继承位置再给大家详细介绍
四、内部类
内部类的概念:如果一个类定义在另一个类的内部,这个内部的类就叫做内部类。
我们写一个内部类来观察一下:
B类定义在A类内部,但是A类对象 aa 的大小为 4 个字节,只占了一个整型的空间。这是因为A类里面只有 a ,而没有 b 。
其实内部类仅仅只是定义在了另一个类的里面而已,和定义在全局并没有什么区别,只是内部类受到了外面这个类的类域的限制。
如果想要使用内部类,则需要在前面说明内部类的域:
A::B bb;
如果内部类是外部类的私有类型,则无法直接使用内部类:
需要注意的是:内部类是外部类的友元类,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元类。
五、拷贝对象时的一些编译器优化
首先是我们上面介绍过的隐式类型转换:
按照正常的逻辑,编译器会首先调用构造函数来创建一个类类型的临时变量,然后再调用拷贝构造函数使 aa1 变为类类型临时变量的拷贝。为了简化过程,编译器把拷贝构造 + 构造优化为了直接构造。
需要注意的是,这种优化只能存在于同一表达式,构造完成之后直接把获得的临时变量用于拷贝构造的情况。
适用这种情况的除了赋值、连续赋值之外,还有一些其他的情况,如传值传参:
这两行代码中,传值传参时所进行的构造 + 拷贝构造被优化为直接构造。
传引用传参则因为无需拷贝而无需优化。
当拷贝构造用于返回值时,例如以下场景:
class A
{
public:
A(int a = 1)
:_a(a)
{}
private:
int _a;
};
A func()
{
A aa;
return aa;
}
int main()
{
func();
A aa1 = func();
return 0;
}
按照正常逻辑,这里共需要一次构造,两次拷贝构造:
而编译器进行优化时,会把这个过程优化为一个构造加一个拷贝构造,去除同一表达式中的冗余部分。
如果我们直接返回匿名对象时,例如:
编译器同样会把同一表达式中多余的步骤优化掉。
从以上各种例子中,我们可以知道在传参和传返回值的过程中,一般编译器会做一些优化,减少对象的拷贝,这个在一些场景下还是非常有用的。
同学们需要注意的是,这里有一个容易弄混的地方:
这两种方式是不同的,第一个方框框起的代码属于拷贝构造,而第二个方框框起的属于赋值重载。编译器可以优化第一种而没有办法优化第二种。
了解了以上知识,我们日后写代码时就可以有意识的遵守三点规则:
- 接收返回值对象,尽量用拷贝构造方式接收,而不要赋值重载方式接收。
- 函数中返回对象尽量返回匿名对象。
- 传参时尽量使用传引用传参。
关于类和对象的相关知识就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!