简单了解了类之后,我们接下来要更加深入地去学习类,本篇主要讲的是类的默认成员函数。默认成员函数是指用户没有自己实现,编译器会自动生成的函数是默认成员函数。
构造函数
什么是构造函数
当我们声明一个整型变量时,如果不进行初始化,那么该整型变量的值便是随机值,对象也有这个烦恼。当我们定义对象后,如果不进行相应的初始化,那么对象的成员变量的值以及成员函数的运行结果很难符合我们的预期。
在C语言中,我们一般会写一个初始化函数对结构体变量进行初始化。不过时常忘记初始化,同时每次定义变量时的初始化令我们颇为头痛,所以构造函数应运而生。
构造函数是一个特殊的成员函数,它会在类实例化对象的时候自动调用,以保证对象有一个合适的初始值。它的功能是初始化对象,并不是创建对象。
构造函数的特点
了解什么是构造函数后,我们接下来就得试着自己定义构造函数,在这之前,我们首先需要了解一下构造函数的几个特点:
- 构造函数的函数名和类名相同
- 构造函数无返回值,也没有返回值类型
- 对象实例化时编译器会自动调用对应的构造函数
接下来我们将以日期类为例,自己定义一个构造函数:
#include <iostream>
using namespace std;
class Date
{
public:
Date()
{
cout << "Date()" << endl;
_year = 1;
_month = 1;
_day = 1;
}
void print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.print();
return 0;
}
我们可以看到其运行结果如下:
Date()
1-1-1
现在我们可以写一个简单的构造函数了。但是如果我们想指定成员变量的初始值该怎么办呢?很简单,让构造函数带上参数就可以了。
Date(int year, int month, int day)
{
cout << "Date(int year, int month, int day)" << endl;
_year = year;
_month = month;
_day = day;
}
而且,我们并不需要将之前的无参的构造函数剔除掉,因为构造函数是能够进行重载的。 这是构造函数第四个特点。
但是,既然编译器可以自动生成构造函数,那么我们为什么还需要自己定义构造函数呢,让编译器替我们解决不香吗?我们可以通过程序观察一下:
我们可以看到,虽然编译器会自动生成构造函数,但是成员变量的初始值却是一个随机值!这不仅没有任何帮助,还很可能会为我们带来麻烦,例如当成员变量中有一个指针,且我们打算动态分配一块内存时,如果将这种对象的构造函数交给编译器又会怎样呢?
我们可以看到,在VS2022中,每个成员变量初始化是一个随机值,而在vscode中初始值则如下:
一般来说,编译器自动生成的构造函数,如果成员变量是内置(基本)类型则不做处理,像int/char/double/指针等等,如果成员变量是自定义类型,那么便会调用它的默认构造函数。当然,我们会发现有的编译器会对基本类型做处理,但这种行为是C++标准并未规定的,所以我们还是认为对基本类型不做处理。
不过,C++11为此打了一个小布丁,即我们可以在类声明的时候给予成员变量缺省值,这些缺省值会在调用编译器自动生成的默认构造函数时使用。需要注意的是,即使与变量初始化非常相似,这并不是初始化,因为在类中成员变量并没有分配空间。例如:
class Date
{
public:
Date()
{
cout << "Date()" << endl;
_year = 2025;
_month = 1;
_day = 17;
}
void print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
由此看来,当类中成员变量都给了缺省值或者当类中成员变量都是自定义类型且其默认构造函数正确定义的情况下我们可以不写默认构造函数,否则最好自己定义构造函数。
此外,只有当我们用户未定义构造函数的时候,编译器才会自动生成一个无参的默认构造函数。我们一旦定义构造函数,编译器便不会自动生成,因此有时候我们会犯下面的错误:
我们定义了带参的构造函数,没有定义不带参的,在这种情况下编译器也不会自己生成,因此我们无法在定义对象时不带参数。那么我们之后定义该类对象的时候都传递参数不就行了?这明显是一个错误的想法,因为当该类的对象成为另一个类的成员变量的时候,就会出现调用该类默认构造函数的情况,程序仍会出错。
不知道你是否注意到,前面有提到一个词:默认构造函数。这是比较特别的构造函数的一个总称,即当我们无参定义对象时程序调用的构造函数就被称作默认构造函数。无参构造函数,全缺省构造函数以及编译器自动生成的构造函数都可以被称作默认构造函数,更重要的是,默认构造函数只能有一个。首先,当我们自己定义构造函数后,系统就不会自动生成了;其次,虽然在语法上无参函数和全缺省函数构成函数重载,当时函数调用时却会引发歧义。
构造函数到此告一段落,解决了初始化的问题,我们接下来考虑的便是清理对象所占的内存资源了。
析构函数
什么是析构函数
提到清理对象所占的内存资源,不知道你会不会想到C语言中的动态内存管理。如果你学习过C语言,那么相必曾因为时常忘记释放内存而烦恼。在程序设计中,内存泄露是一个非常严重的问题,偶尔忘记释放一下内存,也许就会为程序崩溃埋下伏笔。在C++中,析构函数就是应对这个问题的。对象在销毁时会自动调用析构函数,从而清理对象所占的内存资源。
需要提一句,只有动态分配的内存才需要我们自己主动去释放,其余的交给系统就行了。例如局部变量,它存储在栈中,随着函数栈帧的销毁而自动释放,不需要我们程序员担心。
定义一个析构函数
在定义析构函数之前,我们得先了解一下析构函数的基本特点:
- 析构函数名是在类名前加上~
- 析构函数无参数,也没有返回值以及返回值类型
了解了上面这两点,我们就可以写一个简单的析构函数了,程序还是沿用上文的日期类:
~Date()
{
_year = _month = _day = 1;
}
定义好了之后,程序就会调用我们所写的析构函数。不过,析构函数并不需要我们自己去调用,我们也无法主动调用,这就与析构函数的另一个特点有关:在对象生命周期结束的时候,C++编译器会自动调用析构函数。我们可以写一个程序验证这一点:
我们如果不显式定义析构函数,这时系统会自动生成一个默认的析构函数。 需要注意的是,因为析构函数要求不能有参数,所以析构函数不能重载。
所以,系统生成的默认析构函数能否满足我们的要求呢?答案是否定的。系统生成的默认析构函数对成员变量的处理规则和构造函数的处理规则类似,对于内置(基本)类型,不做任何处理;对于自定义类型,会调用其相应的析构函数。我们审视一下上文所写的析构函数,就会发现该析构函数并没有释放动态申请的内存,而且日期类的成员也不需要申请动态内存。
总结一下:对于成员需要释放动态内存的类,我们需要自己定义析构函数;如果成员变量是基本类型或者需要释放动态内存的成员是自定义类型,我们就不需要自己定义,当然是在自定义类型的析构函数正确的情况下,例如:
class B
{
B()
{
_a = (int*)malloc(sizeof(int) * 4);
if (nullptr == _a)
{
perror("malloc fail");
return;
}
_size = 0;
_capacity = 4;
}
~B()
{
if (_a)
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
}
private:
int* _a;
int _size;
int _capacity;
};
class C
{
private:
int c;
B b1;
};
我们已经实现的日期类并不需要自己定义析构函数,系统自动生成的析构函数足矣;而上面的类B因为成员中_a需要释放内存,所以我们需要自己定义析构函数; 对于类C,因为成员b1是自定义类型,且自定义类型B我们已经定义了析构函数,所以我们也不需要自己定义类C的析构函数了。
拷贝构造函数
在用已存在的对象创建新对象时由编译器自动调用的构造函数被称为拷贝构造函数。由此可知,拷贝构造函数是构造函数的一个重载形式,同时,拷贝构造函数的参数只有一个且参数是类的引用。
那么为什么参数必须是引用而不能是一个类的实例化对象呢?
对于这个问题,我们首先需要知道一个知识点:C++标准规定,在函数传参的时候如果参数是基本类型,那就进行值拷贝,类似于memcpy,如果是自定义类型,那就需要调用该类型的拷贝构造函数。如果拷贝构造函数的形参是一个类的实例化对象,那在使用拷贝构造函数时首先进行传参,调用拷贝构造函数,调用拷贝构造函数时又需要继续传参,这就造成了拷贝构造函数的无穷递归。而要解决拷贝构造函数的无穷递归,我们可以使用引用。同时,为了避免在拷贝的过程中改变待拷贝的对象,一般使用const修饰该形参。下面我们定义一个简单的拷贝构造函数:
#include <iostream>
using namespace std;
class example
{
public:
example()
{
_array = (int*)malloc(sizeof(int) * 4);
if (nullptr == _array)
{
_size = _capacity = 0;
perror("malloc fail");
return;
}
_size = 0;
_capacity = 4;
}
~example()
{
if (_array)
{
free(_array);
_array = nullptr;
_size = _capacity = 0;
}
}
example(const example& e)
{
_array = (int*)malloc(sizeof(int) * e._size);
if (nullptr == _array)
{
_size = _capacity = 0;
perror("malloc fail");
return;
}
_size = e._size;
_capacity = e._capacity;
}
private:
int* _array;
int _size;
int _capacity;
};
int main()
{
example e1;
example e2(e1);
return 0;
}
与前文两个默认成员函数相同,当程序员未显式定义拷贝构造函数时,编译器会自动生成拷贝构造函数。自动生成的拷贝构造函数对成员变量的处理如下:如果成员变量是基本类型,那就按照字节方式进行拷贝,也被称为浅拷贝,而对于自定义类型,就调用该类型的拷贝构造函数。
对于日期类而言,系统自动生成的拷贝构造函数非常合适,但是,如果类中的成员变量是个指针,且指针指向一块动态内存,如果仅仅依靠自动生成的拷贝构造,会导致新创建的对象中的指针指向同一块空间,从而在对象生命周期结束时,会调用两次析构函数,这会导致同一块内存空间被释放两次,从而使程序运行崩溃。
由此看来,当类的成员变量不需要申请动态内存或者类的成员变量是自定义类型,程序员可以不显式定义拷贝构造函数,前提是自定义类型的拷贝构造函数是合适的;如果需要申请动态内存,那就需要自己实现拷贝构造函数,实现深拷贝。
对于默认成员函数的学习暂且到此为止,更多内容下篇再见。