目录
0. 什么是构造函数
在定义类的成员函数的时候,一般的成员函数与普通的全局函数没有太大的区别,在定义函数的时候都需要说明要返回的类型,比如:
int fun() {}; //返回int类型
void fun() {}; //什么都不返回
但是,类中有一种函数比较特殊,函数名与类名相同,且没有返回值类型,这中函数称为构造函数,它承担着类初始化的工作,非常重要。
常用的构造函数有默认构造函数、一般构造函数、拷贝构造函数、转换构造函数、移动构造函数。
1. 默认构造函数
首先要有一个概念,一个类时必有构造函数的,即便程序员没有手动定义构造函数,编译器也会创建一个默认构造函数,具体形式如下:
ClassName(){};
这是一个空的函数,即什么都不做。当然,实际上,默认构造函数也不是空的,这里只是简化一下,不作深究。
还值得注意的是,默认构造函数不止有一个,编译器还会创建其他类型的构造函数,比如,拷贝构造函数,这些后边再说。
所以,现在有一个认识就行,那就是如果程序员不手动定义构造函数,编译器会自动定义默认构造函数,即便这个构造函数可能什么都不做。
2. 一般构造函数
当程序员手动定义构造函数之后,编译器便不再生成默认构造函数。构造函数一般承担初始化工作,比如对成员变量初始化,看个栗子:
#include <iostream>
using namespace std;
class Person {
public:
int age;
char* name;
Person(int age, char* name) {
this->age = age;
this->name = name;
}
};
int main(){
Person p(12,"kang");
cout<<p.age<<endl;
cout<<p.name<<endl;
}
运行结果:
构造函数支持重载,也就是说,可以有多个同名构造函数,不过他们的形参需要有区别。看栗子:
#include <iostream>
using namespace std;
class Person {
public:
int age;
char* name;
Person();
Person(int age, char* name);
Person(char* name);
};
Person::Person() {
this->age = -1;
this->name = "-1";
}
Person::Person(int age, char* name) {
this->age = age;
this->name = name;
}
Person::Person(char* name) {
this->age = -1;
this->name = name;
}
int main(){
Person p1(12,"kang");
cout<<p1.age<<" "<<p1.name<<endl;
Person p2;
cout<<p2.age<<" "<<p2.name<<endl;
Person p3("kang");
cout<<p3.age<<" "<<p3.name<<endl;
}
运行结果:
3. 拷贝构造函数
类在实例化的时候,有两步操作:
- 给对象分配内存,此时内存没有经过初始化,成员变量大多都是垃圾值;
- 对内存进行初始化,也就是调用构造函数对成员变量进行初始化。
对于一般构造函数,初始化的值来自于构造函数的输入参数,如果某个构造函数的输入参数是同类的对象,则称该构造函数为拷贝构造函数。
说白了,拷贝构造函数就是用别的对象来初始化新对象的内存。看一个栗子:
#include <iostream>
using namespace std;
class Person {
public:
int age;
char* name;
Person(int age, char* name); //一般构造函数
Person(const Person &p); //拷贝构造函数
};
Person::Person(int age, char* name) {
this->age = age;
this->name = name;
}
Person::Person(const Person &p) {
this->age = p.age;
this->name = p.name;
}
int main(){
Person p1(12,"kang"); //调用一般构造函数
Person p3(14,"wang");
Person p2 = p1; //调用拷贝构造函数,等效于 Person p2(p1);
cout<<p1.age<<" "<<p1.name<<endl;
cout<<p2.age<<" "<<p2.name<<endl;
p2 = p3;
}
运行结果:
注意!27行的 Person p2 = p1 语句不是赋值,是在创建对象,因此要调用拷贝构造函数,而31行是对象的赋值,调用的是运算符“=”的重载函数。所以要注意,这俩东西调用是不一样的,类似与变量创建和赋值的区别。
如果程序员没有手动创建拷贝构造函数,编译器会自动创建一个默认拷贝构造函数,如果手动创建了,编译器就不会再创建了。默认拷贝构造函数很简单,就是用老对象”的成员变量对“新对象”的成员变量进行一一赋值,和上面 Person 类的拷贝构造函数非常类似。所以,上边那个例子,不定义拷贝构造函数,程序也是没问题的,因为编译器帮我们创建了一个默认构造函数。
可能会有疑问,既然编译器自己会创建拷贝构造函数,那为什么还需要用户手动创建呢?答案是,默认构造函数里边只是简单的浅拷贝,碰到指针就G了。对于指针指向的数据,如果用默认构造函数,那么“新对象”的指针跟“老对象”的指针指的是同一块内存,”老对象“的修改会影响到“新对象”,所以这时候就需要用户手动创建拷贝构造函数,使用深拷贝创建对象。
4. 转换构造函数
对于类型转换,大家可能并不陌生,C++自带很多类型转换规则,比如int转double,(int*)转(float*),以及向上型转等等,除了这些自带的转换规则之外,用户还可以自定义转换规则,转换构造函数就是将其他类型转化为当前类。
借助前边的例子,如果我的对象创建语句是这么写的:
Person p = "Kang";
这个代码是编译不过的,因为Person类没有对应的构造函数,但是如果定义一个转换构造函数,自定义一下转换规则,那么这条语句就是可行的,看一个详细的例子:
#include <iostream>
#include <string.h>
using namespace std;
class Person {
public:
int age;
char* name;
Person(int age, char* name); //一般构造函数
Person(char* name); //转换构造函数
};
Person::Person(int age, char* name) {
this->age = age;
this->name = name;
}
Person::Person(char* name) { //转换构造函数
this->name = name;
this->age = 0;
}
int main(){
Person p1(12,"kang"); //调用一般构造函数
Person p2 = "Tom"; //调用转换构造函数,等效于 Person p2(“Tom”);
cout<<p1.age<<" "<<p1.name<<endl;
cout<<p2.age<<" "<<p2.name<<endl;
p2 = "Jack"; //调用转换构造函数,因为=右边不是Person类型,所以不会调用运算符=的重载函数
cout<<p2.age<<" "<<p2.name<<endl;
}
运行结果:
可以看到,Person p2 = "Tom" 是没有问题的,正常来说,"Tom"是一个字符串,无法用他对 p2 进行初始化的,但是编译器会判断类型转换规则能不能用,再查看类中是否有转换构造函数,然后将“Tom”通过转换构造函数转化为Person类的对象,然后再调用拷贝构造函数对p2进行初始化。相当于中间用转换构造函数创建了一次匿名类,然后再使用拷贝构造函数。不得不说,编译器真的很努力了~
对于 p2 = "Jack" ,则先调用转换构造函数,再调用了赋值运算符重载函数。
需要强调的是,转换构造函数用于标准类型向自定义类转换,因此转换构造函数的形参只能有一个,或者有多个形参但最多只有一个不是默认参数。
Person::Person(char* name) { //转换构造函数
this->name = name;
this->age = 0;
}
Person::Person(char* name, int age = 0) { //转换构造函数
this->name = name;
this->age = age;
}
Person::Person(int age, char* name) { //不是转换构造函数
this->age = age;
this->name = name;
}
5. 移动构造函数
拷贝构造函数可以用其他对象初始化新对象,但是,如果成员指针变量指向的数据量很大,再进行深拷贝,那么拷贝函数的时间开销是巨大的。而移动构造函数的诞生,就是为了解决拷贝构造函数时间开销大的问题。
借助一个例子来说明移动构造函数是如何工作的:
Person p = Person();
这条语句会先调用一般构造函数或者默认构造函数(取决于有没有手动定义一般构造函数),创建一个匿名对象;再调用拷贝构造函数。如果Person成员指针变量指向的数据量很大,那么时间开销是很大的。
移动构造函数的优化思路是:既然一般构造函数创建出来的是一个匿名对象,我们不妨把新对象的指针直接指向匿名对象指向的空间,说简单一些就是,指针简单拷贝,然后将匿名对象的指针置空,因为匿名对象是无法被用户调用的,如此一来,匿名对象成员指针指向的数据都归新对象的指针变量了。有点鸠占鹊巢的意思==>
移动构造函数的声明形式如下:
ClassName(CalssNmae &&obj);
可以看到,为了能够引用匿名对象,移动构造函数的输入是右值引用。
光说不直观,看一个例子:
#include <iostream>
#include <string.h>
#include <chrono>
using namespace std;
class demo{
public:
demo(){ //一般构造函数
num = new int[100000000];
cout<<"constructor"<<endl;
}
demo(const demo& de) { //拷贝构造函数
num = new int[100000000];
memcpy(this->num, de.num, 100000000);
cout<<"copy constructor"<<endl;
}
// demo(demo &&de) { //移动构造函数
// num = de.num;
// de.num = NULL;
// cout<<"move constructor"<<endl;
// }
private:
int *num;
};
demo get_demo(){
return demo();
}
int main(){
auto start = std::chrono::system_clock::now();
demo a = demo();
auto finish = std::chrono::system_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(finish-start);
std::cout<<double(duration.count())<< "ms" <<std::endl;
return 0;
}
这个代码不能用IDE去编译运行,因为IDE会进行优化,我们就无法观察程序原本的运行轨迹了。
在Linux系统下,找到cpp所在文件夹,打开终端,输入:
g++ main.cpp -fno-elide-constructors -o main
其中,g++是选择的编译器,main.cpp是源文件,-fno-elide-constructors是禁用构造函数优化。然后,当前目录下会生成一个名为main的可执行程序,运行便可得到程序输出。
运行结果:
可以看到,拷贝构造函数为了复制长度100000000的数组,运行了35ms。
现在我们将17-21行的注释去掉,也就是定义了移动构造函数,再次编译运行,结果为:
可以看到,没有了大块的内存拷贝,运行时间已经不在ms量级了(大约0.1ms),这样就大大降低了拷贝构造函数的时间开销。
从这个代码也能看出来:
当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。