前言
之前我们在 类和对象(上)中了解了关于类的定义、对象的创建等一些基本知识:
https://developer.aliyun.com/article/1637204?spm=a2c6h.13262185.profile.8.204b2c70t7pAjo
今天,我们深入学习类和对象中默认成员函数相关的内容。
什么是默认成员函数
所谓默认成员函数,就是在类当中我们没有显式实现,但是编译器自动生成的成员函数称之为默认成员函数。在c++11之前,默认成员函数一共有六个:
接下来我们会根据它们的特点,使用规则以及自实现方面逐一讲解。
一、构造函数
构造函数的主要作用是:在对象被创建时,调用该函数对其成员变量进行初始化。就像我们在实现栈和队列时写的Init函数一样,会对它的成员先赋初值。
它的特点如下:
1. 构造函数的函数名与类名相同。
2. 构造函数无返回值。(连void都不写)
3. 构造函数可以重载。
4. 当对象被创建时,自动调用构造函数。
代码示例:
using namespace std; class MyClass { public: //这里我们手动创建一个构造函数 MyClass(int a = 0, int b = 0, int c = 0)//不传参时给个默认值为0 { _a = a; _b = b; _c = c; } void Print() { cout << _a << endl; cout << _b << endl; cout << _c << endl; } private: int _a; int _b; int _c; }; int main() { MyClass a; //打印一下数据 a.Print(); return 0; }
运行结果:
可以看到,三个成员变量的值被初始化为0。这说明对象在创建时构造函数是自动调用的。接下来我们尝试给构造函数传参:
int main() { //可以用类似函数调用的方式传参 MyClass a(1, 2, 3); //也可以使用类似结构体初始化的方式传参 MyClass b = { 4,5,6 }; //打印一下数据 a.Print(); b.Print(); return 0; }
运行结果:
它还有以下三点特性:
5. 当我们在类中没有显示地定义构造函数时,编译器会自动生成一个无参的构造函数,用于创建对象时的初始化。一旦用户显示定义了构造函数之后,编译器则不会生成。
6. 显示定义的无参构造函数、全缺省构造函数,以及编译器自动生成的构造函数统称为默认构造函数。在一个类当中,这三种函数必须且只能存在一个。总的来说,不传参就可以调用的构造函数称之为默认构造函数。
7. 对于编译器自动生成的构造函数,当其对对象成员变量进行初始化时,如果成员是内置类型,则编译器通常不会为其赋初值;如果成员是由class或者struct创建的自定义类型(也就是类嵌套的情况),则会自动调用该自定义类型的默认构造函数。如果该成员没有默认构造函数,就会报错。这也就是默认构造函数必须存在的原因。
总结
构造函数就是用于对创建的对象进行初始化的函数。我们在创建对象时,编译器会自动调用构造函数对成员变量进行初始化,这样我们就不需要单独定义或者使用Init函数对某个类进行初始化了。
二、析构函数
与构造函数相反,析构函数是在对象销毁时调用的,它的作用是在对象被销毁时完成对对象生成的资源的清理释放工作。就像我们在实现队列时使用的Destroy函数一样,完成对数据的销毁。
它的特点如下:
1. 析构函数的函数名是在类名之前加一个波浪号(~)。
2. 析构函数无返回值(void也不写),且不能加入参数。
3. 一个类当中只能有一个析构函数。
4. 当一个对象的生命周期结束之时,会自动调用析构函数。
5. 当我们没有在类中显示定义析构函数时,编译器会自动生成一个析构函数,供对象调用。
代码示例:
using namespace std; class MyClass { public: //构造函数 MyClass(int a = 0, int b = 0, int c = 0) { _a = a; _b = b; _c = c; } //析构函数 ~MyClass() { _a = 0; _b = 0; _c = 0; } void Print() { cout << _a << endl; cout << _b << endl; cout << _c << endl; } private: int _a; int _b; int _c; }; int main() { MyClass a(1, 2, 3); return 0; }
调试观察:
可以看到,程序中我们创建对象时,给三个成员变量分别赋初值1、2、3,而当程序运行结束时,这三个成员变量的值已经变为了0,这说明对象销毁时确实自动调用了析构函数。
6. 与构造函数类似,对于编译器自己生成的析构函数,当其对象被销毁时,内置类型成员变量通常不被处理;对于自定义类型成员变量,则会调用其析构函数。
7. 对于一个局部域中的多个对象在进行销毁时,c++规定后创建的对象先析构。
那么我们什么时候该显示写析构函数呢?来看一段代码:
class A { public: //... private: int _a; char _c; }; class B { public: B(int n = 4)//初始化时在堆区申请内存空间 { _p = (int*)malloc(n * sizeof(int)); if (_p == nullptr) { perror("malloc"); exit(1); } _n = n; } private: int* _p; int _n; };
对于类A,他所创建的对象并没有申请额外的内存空间,在销毁时不会造成内存泄漏,此时我们就不需要手动写析构函数;对于类B,由于它在创建时在堆区申请了空间,它在销毁时编译器自己生成的析构函数并不会将这部分空间销毁掉,需要我们手动释放,所以此时就需要我们显示地写析构函数。
总的来说,如果类中没有申请资源,一般不需要手动写析构函数;如果申请了资源,就需要写析构函数,否则会造成内存泄漏。
三、拷贝构造函数
拷贝构造函数是构造函数的一个重载,它用于完成对象的拷贝。它的特点如下:
1. c++规定对象只要发生拷贝行为,就必须调用拷贝构造,包括对象传参或者做返回值,都需要产生一份临时拷贝。
2. 拷贝构造函数的第一个参数必须是类类型的引用,而不是对象的值。因为对象在传值传参的时候需要调用拷贝构造,如果拷贝构造的参数带有对象的临时拷贝,那就会再次调用拷贝构造,以至于发生无限递归。
3. 如果我们没有显示定义拷贝构造函数,编译器会自动生成一个拷贝构造。这个自动生成的拷贝构造在完成拷贝工作时,对内置类型会完成它的浅拷贝,对类类型则会调用该类的拷贝构造函数。
接下来我们尝试写一个拷贝构造函数并且使用它:
using namespace std; class MyClass { public: //构造函数 MyClass(int a = 0, int b = 0, int c = 0) { _a = a; _b = b; _c = c; } //拷贝构造函数 MyClass(const MyClass& m)//确保源数据不被修改,在引用之前加上const { //逐一完成成员变量的复制 _a = m._a; _b = m._b; _c = m._c; } //析构函数 ~MyClass() { _a = 0; _b = 0; _c = 0; } void Print() { cout << _a << endl; cout << _b << endl; cout << _c << endl; } private: int _a; int _b; int _c; }; int main() { MyClass a1(1, 2, 3);//创建对象a1并对其初始化 MyClass a2(a1);//调用拷贝构造,将a1拷贝给a2 //打印一下a2 a2.Print(); return 0; }
运行结果:
可以看到,我们通过拷贝构造函数将a1拷贝给了a2。
那么我们什么时候需要显示写拷贝构造函数供我们使用呢?之前我们提到,编译器自动生成的拷贝构造完成的是浅拷贝。这就意味着如果我们在类中有向堆区申请内存空间的方法,浅拷贝就无法达到预期效果。
所以对于这种情况(类中有额外申请资源),我们就需要手动去写一个拷贝构造函数,实现深拷贝,将申请的内存也复制一份出来。
小技巧:是否需要显示写拷贝构造函数,就看类中是否有显示写析构函数。如果有写析构函数,那么通常需要写拷贝构造。
当我们在某个函数当中将对象作为返回值时,由于这个返回值是一份临时拷贝,所以会自动调用拷贝构造函数,造成运行效率的下降。所以此时我们可以考虑返回该对象的引用,避免发生拷贝,提高运行效率。需要注意的是:一定要确保该对象在函数栈帧销毁后仍然存在,避免出现悬挂引用。
四、赋值重载
在了解赋值重载之前,我们先学习一个概念:运算符重载。
1. 运算符重载
所谓运算符重载,指的就是当对象在使用一些运算符时,我们可以为该运算符设定新的含义。而这种含义的实现方式就是通过定义函数,该函数就叫做运算符重载。
当对象在使用运算符时,如果没有对应的运算符重载,就会发生报错。
它的定义方式如下:
(返回值类型) operator(运算符)(函数参数)
{
(函数体)
}
这里的operator是一个关键字,与需要定义的运算符相连接,构成函数名。
关于运算符重载,有以下要注意的几点:
1. 运算符重载的参数个数与该运算符的操作数一样多。例如 + 号进行重载时,第一个参数表示左操作数,第二个参数表示右操作数。如果这个运算符重载是成员函数,一定要注意成员函数第一个位置已经有一个参数是this指针,所以我们要少写一个参数。
2. 当我们使用一个运算符重载时,要注意该运算符本来的优先级和结合性是不变的。
3. 不能以“莫须有”的方式去重载本来就没有的运算符,例如operator@。
4. 这五个运算符不能重载: .* : : sizeof ? : .
5. 我们在定义运算符重载时,必须要有类类型的参数,否则就会与重载的本意相悖。
6. 对于++和--运算符的重载,由于前置和后置无法区分,所以c++规定:对于后置++/--,需要在函数的参数中增加一个哑元(通常是int类型),这个参数不在函数体中使用,但是有了这个参数就表示重载的是后置++/--。
小知识
第 4 点中有一个运算符 “ .* ”,有很多人可能没有接触过这个运算符,我们来介绍一下它。
首先让我们创建一个类,这个类当中只有一个成员函数:
class A { public: void fun() { cout << "Hello World" << endl; } };
接下来,我们将该函数的地址存储在一个函数指针当中:
int main() { void (A::*pf)() = &A::fun; }
可以看到,以上代码非常奇怪。实际上,对于类的成员函数,我们在声明它的类型时,要表明它所在的类域。其次,对于类的成员函数,想要得到它的地址,需要加上&符号,而普通函数是否加&都表示它的地址。
接下来,我们创建一个A类对象,并通过该指针调用函数fun:
int main() { void (A::*pf)() = &A::fun; A a; (a.*pf)(); }
运行结果:
可以看到,运行成功了。这里我们在调用函数时,就使用到了“ .* ”运算符,它用于通过函数指针调用类的成员函数。
接下来,我们针对MyClass类,尝试实现运算符重载:+ 。
using namespace std; class MyClass { public: //构造函数 MyClass(int a = 0, int b = 0, int c = 0) { _a = a; _b = b; _c = c; } //拷贝构造函数 MyClass(const MyClass& m) { _a = m._a; _b = m._b; _c = m._c; } //析构函数 ~MyClass() { _a = 0; _b = 0; _c = 0; } //+号重载 //我们定义一个含义:对象加上一个整数,该对象的所有成员变量都加上这个整数 MyClass operator+(int a) { MyClass tmp(*this);//将该对象的内容拷贝给临时变量tmp tmp._a += a; tmp._b += a; tmp._c += a; return tmp;//返回tmp的临时拷贝,表示的值就是加后的值,并且原对象未发生改变 } void Print() { cout << _a << endl; cout << _b << endl; cout << _c << endl; } private: int _a; int _b; int _c; };
接着,我们来使用这个运算符重载:
int main() { MyClass a;//创建对象a //将a与数字相加的值拷贝给其他对象 MyClass b(a + 1);//可以直接使用运算符 MyClass c(a.operator+(3));//也可以使用函数调用的方式 b.Print(); cout << endl; c.Print(); return 0; }
运行结果:
可以看到,运算符重载的编写成功了。注意:不管是用什么方式去使用运算符重载,本质都是函数调用。
2. 赋值运算符重载
了解了运算符重载的概念、特性、定义方法以及使用方法之后,我们切入正题--赋值重载。
顾名思义,赋值重载就是对赋值运算符的重载函数,这个函数有点类似于拷贝构造,它的功能是完成已经存在的对象的拷贝赋值,这一点要和拷贝构造区分。
它的特点如下:
1. 赋值重载是运算符重载中的一种,必须重载为成员函数。一般情况下,它的参数和返回值都是当前类类型的引用,这样会减少拷贝提高效率。
2. 当我们没有显示写出赋值重载时,编译器会自动生成。自动生成的赋值重载会对内置类型成员变量完成浅拷贝,对于自定义类型成员变量,则会调用其赋值重载函数。
3. 与拷贝构造相同,如果我们的类中申请了资源,则需要自己显示写赋值重载来完成深拷贝;若没有申请资源,则可直接使用自动生成的赋值重载。
小技巧:是否需要显示写赋值重载函数,就看类中是否有显示写析构函数。如果有写析构函数,那么通常需要写赋值重载。
接下来我们针对MyClass类实现一个简单的赋值重载:
using namespace std; class MyClass { public: //构造函数 MyClass(int a = 0, int b = 0, int c = 0) { _a = a; _b = b; _c = c; } //拷贝构造函数 MyClass(const MyClass& m) { _a = m._a; _b = m._b; _c = m._c; } //析构函数 ~MyClass() { _a = 0; _b = 0; _c = 0; } //+号重载 MyClass operator+(int a) { MyClass tmp(*this); tmp._a += a; tmp._b += a; tmp._c += a; return tmp; } //赋值重载 MyClass& operator=(MyClass& src) { _a = src._a; _b = src._b; _c = src._c; return *this;//返回当前对象的引用可以完成连续赋值 } void Print() { cout << _a << endl; cout << _b << endl; cout << _c << endl; } private: int _a; int _b; int _c; }; int main() { MyClass a(1, 2, 3); MyClass b; MyClass c; c = b = a; b.Print(); cout << endl; c.Print(); return 0; }
运行结果:
可以看到,我们成功将a的内容赋值给了b和c。
总结
今天我们学习了四个类的默认成员函数以及它们的特点、使用方法:构造函数、析构函数、拷贝构造函数和赋值重载,它们能够确保资源的正确管理和对象状态的正确维护。之后博主会和大家分享其余的两个默认成员函数和其他知识。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤