引言

上一节中我们举了这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
class MyClass{
public:
int _a;
char _b;
int add(){
return (_a+_b);
}
};
int main(){
MyClass m,n;
m._a=1;
m._b=2;
n._a=3;
n._b=4;
std::cout<<m.add();
return 0;
}

我们是一个一个地将对象的成员进行初始化的这样不仅麻烦 ,而且使代码显得太长

或许我们可以在类中定义一个初始化的函数

1
2
3
4
5
6
7
8
9
10
class ClassName{
public:
void init(int a,char b){
_a=a;
_b=b;
}
private:
int _a;
char _b;
};

然后我们再调用init函数就能将对象进行初始化。但这样不免还是有些不智能

我们更希望在实例化对象的同时赋予一些值,就能进行类的初始化,就像int a=1这样,而不需要再手动调用初始化函数进行赋值。

C++中,我们就要用到构造函数。


构造函数

构造函数的定义

构造函数是一个特殊的成员函数 ,名字和类名相同。实例化类时,在生成相应的对象时编译器会自动调用该函数 ,保证每个对象都有一个合适的初始值。它在对象的生命周期内只调用一次

我们要注意 定义中的这几点:

  1. 构造函数的函数名与类名相同。
  2. 无返回值,所以不写返回值。
  3. 编译器会在实例化时自动调用。

下面我们使用构造函数来简化上面例子中对象的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
class MyClass{
public:
int _a;
char _b;
int add(){
return (_a+_b);
}
MyClass(int a,char b){
_a=a;
_b=b;
}
};
int main(){
MyClass m(1,2),n(3,4);
std::cout<<m.add();
return 0;
}

值得注意的是,构造函数必须是public成员 ,不然编译器会报错!

当然,构造函数不仅可以进行成员变量的初始化。任何你想在实例化类时进行的操作都可以放在构造函数里面。

使用初始化列表

对类中的成员进行初始化,上面的构造函数还是有些不简洁。使用初始化列表将初始化类中的成员和其他操作分开,可以提高程序的简洁性和可读性

还是上面的例子,下面是使用初始化列表的构造函数:

1
MyClass(int a,char b):_a(a),_b(b){/*其他操作*/}

这样构造函数看起来就很简洁。

使用初始化列表要注意初始化的顺序只和成员变量在类中声明的顺序有关 ,和列表中变量的顺序无关,如下面的程序:

上面的程序按理来说应该输出1,但是实际结果与预想的不同。

先想想为什么

由于_a在类中先声明,所以先被初始化的应该是_a。这时候将还没有初始化_b的值赋给_a,所以最后输出的就是初始化前_b的值,是一个随机的值。

重载和缺省参数

构造函数是支持重载和缺省参数的,这使得它的用法更加灵活。

构造函数的重载

我们可以定义多个构造函数,它们必须有不同的形参列表

1
2
3
4
5
6
7
8
class MyClass{
public:
int _a;
char _b;
MyClass(int a,char b):_a(a),_b(b){}
MyClass(int a):_a(a),_b(2){}
MyClass(char b):_a(1),_b(b){}
};

这样编译器会根据实参表来判断应该调用哪个构造函数

构造函数缺省参数

我们也可以给某些形参一个默认值

构造函数的重载和缺省参数与一般的函数一样,很容易理解,所以不再赘述。当两者都能达到目的时,一般情况下还是推荐使用重载,因为省略参数还对参数列表的顺序有要求。

注意事项

  1. 如果我们没有定义构造函数,编译器会自动加上一个空的构造函数。然而没什么用。

  2. 如果实例化类时,调用的构造函数的形参列表为空,那么就不能打括号! 如下:

1
2
3
4
5
6
7
8
class MyClass{
public:
int _a;char _b;
MyClass():_a(1),_b(2){}
};
int main(){
MyClass m;//MyClass m();
}

读者可以先想一想上面代码存在的问题。

答案

MyClass m()似乎是一个合乎情理的写法,然而我们仔细观察一下,就会发现:这不就是函数声明吗! 是的,编译器会将该语句理解为一个作用域在main函数内的函数的声明,而不会将m识别为一个类类型的变量。当我们使用m时,编译器报错:m具有非类类型。

我们正确的做法应该不加括号!


析构函数

同构造函数类似,析构函数在销毁对象时会被编译器自动调用。

析构函数的定义

析构函数(destructor) 与构造函数相反,当对象被销毁时 ,如对象所在的函数已调用完毕,系统就会自动执行析构函数。析构函数往往用来做“清理善后” 的工作。

但是注意析构函数不是用来销毁对象的,而是在对象的生命周期结束,对象被销毁的时候用来清理残余资源的。

析构函数的特征如下

  1. 析构函数的函数名时类名前面加上~
  2. 无返回值。
  3. 析构函数不能有参数!
  4. 编译器在对象销毁时自动调用。

析构函数的实例

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
class MyClass{
public:
int _a;char _b;
MyClass():_a(1),_b(2){}
~MyClass(){std::cout<<"调用了析构函数";}
};
int main(){
MyClass m;
return 0;
}
程序结束时(即main函数结束,所有资源被释放),输出调用了析构函数

这个例子可以体现对象销毁时自动调用析构函数,但还不能体现出清理资源这一点。
我们再看看下面的例子(以教材的实现string类为例):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <cstdlib>
class String{
private:
char* _p;
int _size;
public:
String(int n);
~String();
};
String::String(int n=5):_size(n){
std::cout<<"调用了构造函数"<<'\n';
_p = (char*)malloc(n*sizeof(char));
}
String::~String(){
std::cout<<"调用了析构函数"<<'\n';
free (_p);
}
int main(){
String s;
return 0;
}

输出:

1
2
调用了构造函数
调用了析构函数

这个例子就比较完善了。我们在实例化string类时,调用构造函数,为它分配了空间;string对象销毁时,自动调用析构函数清理分配的空间

析构函数的意义

析构函数的意义就是释放多余的空间。

我们给对象的成员动态分配了空间。即使这个对象被销毁,如果我们在之前没有手动释放这片空间的话,之前在堆上的分配空间仍然是存在的。但是指向它的指针已经被销毁,所以这片内存相当于悬空,成为内存垃圾。如果程序一直这样运行下去,内存垃圾越来越多,程序就会运行地越来越慢,最后可能会导致电脑死机。所以我们需要析构函数来帮我们自动化地解决这个问题。