目录
一、 类的六个默认成员函数
1.1 框架图
1.2 具体介绍
重点关注前四个默认成员函数,对于最后两个即对普通对象取地址和对const普通对象取地址用编译器默认生成的即可。
(1)构造函数
作用: 构造函数可以帮助我们完成类的初始化的工作,如对成员变量进行赋值,开辟空间等。
特性: 当我们没有自己写构造函数时,编译器将默认生成一个构造函数,该构造函数无需参数。
该构造函数将具有以下功能:
- 对于类内的自定义类型,会调用他们的默认构造函数;对于内置类型不会进行初始化。
- 自定义类型:如
class
,struct
… - 内置类型:语言原生定义的类型,如
int
,char
,double
,指针…,
补充:对默认构造函数的全面认识
默认构造函数并不仅仅指代我们不写,编译器自动生成的构造函数。事实上有三个默认构造函数,对默认构造函数更准确的理解为:不用参数就可以调用的构造函数。
三个默认构造函数分别为:
- 自己写的无参构造函数
- 自己写的全缺省构造函数
- 我们没写编译器默认生成的构造函数
以上三个都可以认为是默认构造函数。除了我们没写编译器默认生成的构造函数之外,无参的构造函数和全缺省的构造函数也是默认构造函数。注意:以上三个默认构造函数同时只能存在一个。
综上:写构造函数最好写一个全缺省的,这样既是默认构造函数,又可以传递参数进行构造。
(2)析构函数
作用: 完成资源清理的工作。备注:析构函数不是完成对象的销毁,对象的销毁是编译器完成的,当对象超出生命周期将由编译器进行销毁对象,而对象在销毁时会自动调用析构函数。
特性: 当我们没有自己写析构函数时,编译器将默认生成一个析构函数。编译器自动生成的析构函数,会调用自定义类型成员的析构函数,而对内置类型成员不进行操作。
补充:构造和析构顺序
因为对象是定义在函数中,函数调用会建立栈帧,栈帧中的构造和析构函数符合先进后出。即析构顺序和构造顺序是反着的。
(3)拷贝构造函数
作用: 用于将一个已存在的对象拷贝创建一个新对象。如果没有定义任何拷贝构造函数,编译器将生成一个默认的拷贝构造函数。默认的拷贝构造函数执行浅拷贝,即复制对象的所有成员变量。
特性: 当我们自己没有写拷贝构造函数时,编译器将默认生成一个拷贝构造函数。对于对象的自定义类型,会去调它的拷贝构造函数,对于对象的内置类型成员变量,则进行浅拷贝。
注意:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。
(4)赋值运算符重载函数
作用: 用于将一个已存在的对象的内容赋值给另一个已经存在的对象。
特性: 当我们没有自己写赋值运算符时,编译器将生成一个默认的赋值运算符。它对内置类型进行浅拷贝,对于自定义类型会调用它们赋值运算符重载函数。
注意:
- 赋值运算符重载的返回值是该类对象的引用,目的是支持连续赋值。
- 赋值运算符重载的参数只有一个,推荐使用引用传参,使用传值方式可以避免调用拷贝构造。
- 若自己实现该函数,需要确保赋值操作不会出现对象的自我复制(即对象不能等于本身)。
初始化和赋值的区分
赋值运算符重载只能用于已经存在的对象,而初始化则是在创建对象时进行的。示例代码如下:
class MyClass {
public:
MyClass(int n = 0) : num(n) {}
private:
int num;
};
int main() {
MyClass obj1(1); // 构造函数
MyClass obj2(2); // 构造函数
MyClass obj3(obj1); // 拷贝构造函数
obj2 = obj1; // 赋值运算符重载
return 0;
}
归纳我们不写,编译器默认生成了什么:
- 无参构造函数
- 拷贝构造函数
- 赋值运算符函数
- 默认析构函数
二、深入学习初始化列表
要先要明确一个结论:
初始化列表是每一个成员变量定义的地方
这个结论将贯穿这一章节的内容。
2.1 作用
构造函数初始化有两种方式
- 函数体内初始化(有些情况下发生的是赋值)
- 初始化列表初始化
为什么要有初始化列表的方式,因为当类中包含以下成员,它们必须在定义的时候初始化。
- 引用成员变量
- const成员变量
- 没有默认构造函数的自定义类型成员
代码如下:
class MyClass2 {
public:
MyClass2(const int i) : m_i(i) {}
private:
int m_i;
};
class MyClass {
public:
MyClass(int n, int& ref, const int c) : m_ref(ref), m_c(c), m_myClass2(10)//correct
{
m_num = n;//correct
// 以下初始化方式都是错误的,不能在函数体内初始化
/*m_ref = ref;
m_c = c;
m_myClass2(10);*/
}
private:
int m_num;
int& m_ref;
const int m_c;
MyClass2 m_myClass2;
};
理解为什么有些变量的初始化必须在初始化列表中完成
首先要再次强调一个结论:初始化列表是每一个成员变量定义的地方
- 对于const变量,引用都必须在定义的时候初始化,而初始化列表就是定义的地方,因此要在初始化列表那初始化
- 初始化列表对成员变量做的事情叫做定义,而构造函数体内对成员变量做的事情就叫做赋值
- 对于自定义类型
- 假设它无需参数可完成初始化,那么初始化列表处自动调用默认构造函数即可完成对象的定义,无需在函数体内初始化
- 假设它需要参数初始化且没写在初始化列表中,那么若该类没有默认构造函数(即不需要参数的构造函数),在初始化列表处自动调用默认构造函数时就会报错。即是提供了默认构造函数使得不报错,则此时调用了一次默认构造函数,在函数体内还需先定义一个新对象,再赋值给目标对象,使得函数体内初始化比初始化列表多调用一次构造函数和赋值函数。
class MyClass2 {
public:
MyClass2(const int i) : m_i(i) {}
private:
int m_i;
};
class MyClass {
public:
MyClass(int n) : m_myClass2(10) // 使用初始化列表,调用一次构造函数
{
// 不使用初始化列表,等于 调用一次构造函数 + operator=
/*MyClass2 tmp(10);
m_myClass2 = tmp;*/
}
private:
MyClass2 m_myClass2;
};
2.2 关于初始化顺序
成员变量在在初始化列表中的初始化顺序就是在类中的声明次序,与其在初始化列表中的先后次序无关
建议:类中成员的声明次序应该与初始化列表中的初始先后顺序写的一致
三、单参数的构造函数支持隐式类型转换
单参数的构造函数可以隐式地将参数转换为对象;如果单参数的构造函数声明为 explicit
(显式),则必须显式地调用该构造函数才能将参数转换为对象。
假设我们有以下类定义:
class Person {
public:
Person(int age) : m_age(age) {}
private:
int m_age;
};
在没有explict
关键字修饰下,支持隐式类型转换
int main() {
Person p = 18; // 单参数的构造函数支持隐式类型转换
return 0;
}
实际编译器背后会用18构造一个无名对象Person(18)
,最后用无名对象给p对象进行赋值。
当上述代码中的构造函数被声明为 explicit
,则无法进行隐式类型转换,即无法通过(对象=参数)来赋值。这意味着,以下代码将无法通过编译:
class Person {
public:
explicit Person(int age) : m_age(age) {}
private:
int m_age;
};
int main() {
Person p = 18; // 隐式类型转换,编译错误
}
单参数的构造函数的赋值模式的应用,价值
string
类生成对象可直接等号vector
类无需先创建对象,再push
对象,可直接push
参数进行单参数隐式类型转换构造对象,写起来方便。
代码示例:
int main() {
vector<string> vec;
string s1 = "Jack"; // string类生成对象可直接等号
vec.push_back(s1); // 先创建对象再push麻烦
vec.push_back("Mike"); // 直接push方便,本质是因为支持单参数的隐式类型转换
}
a. 对于“Jack”
,先调用string的有参构造函数,再被引用
b. 对于“Mike”
,参数被直接创建临时对象,该临时对象被const
引用
四、C++11 的成员初始化新特性
C++11
支持非静态成员变量在声明时进行初始化赋值。注意这里不是初始化,这里是给声明的成员变量缺省值。
class MyClass2 {
private:
int m_i;
};
class MyClass {
private:
int num = 10; // 给缺省值
MyClass2 m_myclass2 = 20;
};
五、编译器的三个优化场景
前提:
设已经有一个Date
类,代码如下
-
第一个优化场景:传参场景:当在函数调用传一个匿名对象(临时对象)过去,再用这个对象拷贝给形参对象,编译器可能会优化,将这两个对象合二为一,构造出一个对象。说明如下:
可以看到,main
函数调用func
时传递匿名对象,理论上应该此处调用构造函数,然后该匿名对象拷贝给func
函数形参d
,触发一次拷贝构造函数。但实际上这个过程只有一次构造函数,所以实参和形参的对象用的是同一个,编译器优化了。即出现了传参场景下的优化。 -
第二个优化场景:单参数的构造函数支持隐式类型转换
先看现象:
在上面的代码中,Date d=3
理论上应该先用3
定义个临时对象Date tmp(3)
,再用tmp
拷贝构造d
即Date d(tmp)
,但是运行结果并没有调用拷贝构造,原因是现在的的编译器会优化,直接用3
调用构造函数,相当于Date d(3)
。更准确地说,Date d=3
先用3
定义个匿名对象 Date(3)
,再用该匿名对象拷贝构造d
。此时优化变为第一个场景,故没有出现拷贝构造函数被调用。
- 第三个优化场景:返回值场景
以下是优化场景演示:
构造后马上去拷贝构造或者拷贝构造再马上拷贝构造,编译器可能会优化。
在一个表达式中,连续多个构造函数(包括无参,有参,拷贝构造),可能会被编译器优化为一次构造。如上图自己的代码,当func
函数返回时,理论上是先用d
拷贝一个临时对象,再把临时对象拷贝给dd
,共两次拷贝构造函数,即蓝色箭头逻辑。但实际上只发生了一次拷贝,即红色箭头逻辑,dd
直接用d
进行拷贝构造。
补充:拷贝构造出的对象马上赋值给已存在的对象不能被优化,如下图,d
还是先拷贝构造生成tmp
,tmp
赋值给dd
。
总结:如果编译器要优化,只有构造和拷贝构造才会被优化合并,且需在表达式中(函数参数传递和返回值返回也视作连续的表达式),优化掉的是临时对象或匿名对象。更多细节见《深度探索C++对象模型》