摘要:在本章中涉及C++最核心的内容,本文以C++中两种基础的衍生数据结构:结构体和类作为引子,从C++的封装、继承与多态三大特性全面讲述如何在类这种数据结构进行体现。在封装中,我们讲解了类和结构体的相似性;在继承中,我们讲解了一般的继承方式和多重继承,进一步地,我们讨论了如何通过虚继承的方式避免继承过程中产生的钻石继承问题;在多态中,我们解释为什么吧多态分为静态多态和动态多态,针对静态多态,我们讲解了函数重载和模板函数两种实现方式,在动态多态中,我们讲解了虚函数的概念;最后,我们就构造函数来讲解类的内存管理问题,具体地,我们主要讲解了:构造函数、拷贝构造函数、拷贝赋值构造函数、移动构造函数、移动赋值构造函数五种类型,在什么情况下会被调用和如何编写这五种构造函数。
关键词:多重继承、钻石继承与虚继承、虚函数、函数重载、模板函数、构造函数、右值引用
1. 结构体与类(封装)
结构体在C语言里面早有现身,而类时C++里面加入的。直观来讲,两者的相似性在于:两者都可以看作一个组/容器(此处与STL中的Vector无关,只是直观理解),这个组/容器可以将不同的数据格式的变量和函数打包放在一起。如:
#include<iostream>
using namespace std;
struct Mystruture // 结构体:默认全部为公有成员
{
string name = "SturctureObj";
void print() {cout << "I am a structure!" << endl;};
};
class MyClass{ // 类:默认全部为私有成员
private:
string name = "ClassObj";
public:
void getname() {return name;};
void print() {cout << "I am a class!" << endl;};
};
int main(){
Mystruture objStrut;
cout << "objStrut name: " << objStrut.name << endl; // objStrut name: SturctureObj
objStrut.print(); // I am a structure!
MyClass objClass;
// cout << "objClass name: " << objClass.name << endl; // 无法访问类的私有成员;
cout << "objClass name: " << objClass.getname() << endl; // objClass name: ClassObj
objClass.print(); // I am a class!
}
倘如只利用结构体/类作为变量/函数的容器 (封装的意义1:将属性和行为作为一个整体,表现生活中的事物),可以从上述例子看到,两者的用法除了某些关键字(如struct/class等)其他的用法几乎一致。其唯一的区别在于:默认方式控制,结构体struct默认是公有的(public),而类class默认是私有的(private) (封装的意义2:将属性和行为加以权限控制)。
- 公有的:使用关键字 public: 进行定义,表示类的内部和外部都可以访问该类型的变量;
- 私有的:使用关键字 private: 进行定义,表示类的内部自己可以访问,派生类和外部都不可以访问;
- 受保护的:使用关键字 protected: 进行定义,表示类的内部和派生类可以访问,外部不能访问;
但是,倘若只把类当作一个具有权限差异容器,那简直是一种奢侈的浪费,围绕着类这个东西:构造/析构函数中提供了关于如何去初始化和释放类;继承提供了如何在一个新的类实现旧类的代码重用问题;多态提供了如何在一个新的类实现旧类的代码重用问题实现代码的修改问题。当然,在C++语言中,struct对C语言中的strcut进行了扩充,已经不仅仅是一个包含不同数据类型的数据结构体了,在C++语言中,strcut可以包含成员函数,可以实现继承,可以实现多态。但是结构体的使用频率远远没有类高。
2. 继承
继承,顾名思义:获取前人的遗产(类代码)供自己使用。能大大提高代码的重用,从而提升开发效率。
2.1 继承与多重继承
在类的继承中,我们把原来的旧类叫做 基类,我们把从旧类继承产生的新类叫做 派生类。派生类可以使用基类的任意成员函数/变量。在C++中,继承是通过使用冒号来实现的 :后面跟着基类的访问说明符和基类名称。其基础语法如下所示:
class 派生类名称 : 访问说明符 基类名称{
// 类的核心代码
}
其中,访问说明符包括:public、protected 和 private 三种类型,与类封装中对成员函数/变量权限约束不同的是:
- 公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
- 保护继承(protected): 当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员。
- 私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员。
当然,一个派生类不仅仅可以来自于一个基类,也可以来自于多个基类,这被称作多重继承或者多继承,其继承的基础语法也极其相似:
class 派生类名称 : 访问说明符1 基类名称1, 访问说明符2 基类名称2, ..., 访问说明符n 基类名称n {
// 类的核心代码
}
以下一个例子展示,如何使用基础的类继承:
#include<iostream>
using namespace std;
// 基类1:动物类
class animal{
public:
void eat() { cout << "I can eat!" << endl;};
};
// 基类2:哺乳动物类
class mamal{
public:
void breathe() { cout << "I can breathe!" << endl;};
};
// 派生类1:鸟类,继承动物类
class bird : public animal{
public:
void fly() { cout << "I can fly!" << endl;};
};
// 派生类2:狗类,同时继承动物类和哺乳动物类
class dog : public animal, public mamal{
public:
void bark() { cout << "I can bark!" << endl;};
};
int main(){
bird mybird; //鸟类对象,继承动物类,可以调用动物类的吃函数
mybird.eat(); //I can eat!
mybird.fly(); //I can fly!
dog mydog; //鸟类对象,同时继承动物类和哺乳动物类,可以调用动物类的吃函数和哺乳动物类的呼吸函数
mydog.eat(); //I can eat!
mydog.breathe(); //I can breathe!
mydog.bark(); //I can bark!
}
2.2 钻石继承
钻石继承说的当然不是从基类上来继承钻石,成为百万富翁。而是说出现下图情况,存在 基类A 同时被 类B 和 类C继承,而 派生类B 和 派生类C 又被 派生类D 多重继承。那么这就会造成一个问题:当 派生类D 调用 基类A 的成员函数/变量时,会引起一些歧义。实际上,派生类D分别通过派生类B和派生类C继承了两份独立的基类A,这导致无法得知应该调用返回哪一份的结果。以下通过一个简单的例子展示这个问题。
//diamond.cpp
#include<iostream>
using namespace std;
class A{ // 基类 A
public:
A (int x) : m_x(x) {} // 基类 A 构造函数为 m_x 成员变量赋值
int m_x;
};
// 派生类B,继承于基类A,set 函数的功能为给基类 A 的 m_x 成员变量赋值
class B : public A {
public:
B (int x) : A(x) {} // 基类 B 构造函数,传递变量 x 到基类 A 的构造函数中
void set(int x) {
this -> m_x = x;
}
};
// 派生类C,继承于基类A,get 函数的功能为查看基类 A 的 m_x 成员变量赋值
class C : public A {
public:
C (int x) : A(x) {} // 基类 C 构造函数,传递变量 x 到基类 A 的构造函数中
int get(void) {
return this -> m_x;
}
};
// 派生类D,继承于基类B,C
class D : public B,public C {
public:
D (int x) : B(x),C(x) {} // 基类 D 构造函数,传递变量 x 到基类 B,C 的构造函数中
};
int main(void) {
D d(10); // 实际上,b.x = 10, c.x=10
d.set(20); // b.x = 20, c.x = 10
cout << d.get() << endl; // 输出 10
// cout << d.m_x << endl; // 报错,歧义,无法输出
return 0;
}
为了解决钻石继承带来的问题,C++中在继承 访问说明 符前使用 vitual 关键字提供了虚继承的解决方法,它能够确保在最终派生类中只继承基类的一个副本,从而解决了菱形继承问题。其基础语法为:
class 派生类名称 : virtual 访问说明符 基类名称{
// 类的核心代码
}
在上述砖石继承的例子中,只需要在 派生类B 和 派生类C 的声明中添加关键字 virtual 即可,并在派生类D的构造函数中添加基类A(x),则可以通过 派生类D 的对象 d.m_x 直接访问而不造成歧义。
#include<iostream>
using namespace std;
class A{ // 基类 A
//code...
};
// 派生类B,继承于基类A,set 函数的功能为给基类 A 的 m_x 成员变量赋值
class B : virtual public A {
//code...
};
// 派生类C,继承于基类A,get 函数的功能为查看基类 A 的 m_x 成员变量赋值
class C : virtual public A {
//code...
};
// 派生类D,继承于基类B,C
class D : public B, public C {
public:
D (int x) : A(x), B(x),C(x) {} // 基类 D 构造函数,传递变量 x 到基类 A,B,C 的构造函数中
};
int main(void) {
D d(10); // 实际上,B::m_x = 10, C::m_x=10
d.set(20); // B::m_x = 20, B::m_x = 20
cout << d.get() << endl; // 输出 20
cout << d.m_x << endl; // 输出 20, 无歧义
return 0;
}
3. 多态
多态并不是类/结构体特有的一个概念。根据 什么时候将函数实现和函数调用关联起来 这个基本问题,将多态分为静态多态和动态多态。静态多态是指在编译期间就可以确定函数的调用地址,并生产代码,这就是静态的,也就是说地址是早绑定。静态多态往往也被叫做静态联编。动态多态则是指函数调用的地址不能在编译器期间确定,需要在运行时确定,属于晚绑定,动态多态往往也被叫做动态联编。
3.1 静态多态
静态多态是指在编译期间就可以确定函数的调用地址,其具体的实现形式包括:
- 函数重载:函数重载是一种创建多个名称相同但参数列表不同的函数的方法。编译器根据调用函数时使用的参数的类型和数量来确定要调用的正确函数。
- 模板函数:模板(template)是允许您创建通用函数或类。特定类型的实际代码是在编译时生成的。
#include<iostream>
using namespace std;
// 函数重载:定义多个同名函数,但不同的参数类型或列表
void printer(int n){
cout << "Int printer: " << n << endl;
}
void printer(char* s){
cout << "Char printer: " << s << endl;
}
void printer(double num){
cout << "Double printer: " << num << endl;
}
// 模板函数定义的语法:
// 通过 template关键词 创建一个模板名称为 T 的模板类型
template<typename T>
void MyPrinter(T s){
cout << "Template printer: " << s << endl;
}
int main(){
// 函数重载:编译器根据调用函数时使用的参数的类型和数量来确定要调用的正确函数
printer(666); // Int printer: 666
printer("hello!"); // Char printer: hello!
printer(3.14); // Double printer: 3.14
// 模板函数:创建通用函数或类。特定类型的实际代码是在编译时生成的。
MyPrinter(666); // Template printer: 666
MyPrinter("hello!"); // Template printer: hello!
MyPrinter(3.14); // Template printer: 3.14
};
3.2 动态多态
动态多态指函数调用的地址不能在编译器期间确定,需要在运行时确定,属于晚绑定,动态多态往往也被叫做动态联编。动态多态往往与类的继承问题相挂钩,并通过虚函数进行实现。
首先,为什么需要动态多态?
场景是:我已有一个类,类里面大多数的成员函数都是我所需要的,但是有部分函数的功能我是需要修改的,那该怎么办?最勤奋的办法就是复制一下整个类,并手动给类换个名字,然后把需要修改的函数在新复制的类中修改。再懒一点的方法就是利用前面说的类的继承,继承出一个新的派生类,并在新派生类中使用其他函数名添加需要修改的函数。那问题是能不能在新的派生类直接重写基类中需要修改的 同名函数 呢?答案当然是:能,需要使用虚函数。
那么,怎么借助虚函数在派生类中重写基类中的同名函数呢?
- 在基类中找到需要被重写的函数,并在其函数类型前添加 virtual 关键字;
- 在派生类中重新编写基类中的同名函数,并在其函数声明后添加 override 关键字;
#include<iostream>
using namespace std;
// 基类1:动物类
class animal{
public:
virtual void eat() { cout << "吃吃吃!" << endl;}; // 在其函数类型前添加 virtual 关键字
virtual void speak() { cout << "烫烫烫!" << endl;}; // 在其函数类型前添加 virtual 关键字
};
// 派生类1:鸟类,继承动物类
class bird : public animal{
public:
void speak() override { cout << "叽叽叽!" << endl;}; // 在其函数声明后添加 override 关键字
};
// 派生类2:狗类,继承与鸟类
class dog : public bird{
public:
void speak() override { cout << "旺旺旺!" << endl;}; // 在其函数声明后添加 override 关键字
};
int main(){
animal* p1; // 基类指针可以指向派生类,并调用派生类中和基类所共有的函数;
p1 = new animal;
p1->speak(); // 烫烫烫!
p1 = new bird;
p1->speak(); // 叽叽叽!
p1 = new dog;
p1->speak(); // 旺旺旺!
}
接着,最后一个问题是:C++是如何在运行时动态地选择哪一个虚函数的重写版本进行运行的?
通过虚函数表(virtual tables, Vtable),当一个类包含一个虚拟函数时,编译器会为该类创建一个虚函数表。此表包含指向类中定义的虚拟函数的函数指针。该类的每个对象都有一个指向其虚拟表(vptr,虚拟指针)的指针,该指针在对象构造过程中由编译器自动初始化。
如上例中的 bird 类中,编译器会自动为其创建一个虚函数表,表中包含两个函数指针指向虚函数,分别是:animal::eat // 从基类继承
和 bird::speak
,bird类实例化的对象的_vptr_指针会自动指向该类对应的虚函数表,从而选择正确的虚函数版本。
4. 构造函数
类的构造/析构函数表明了一个类是如何实例化/删除一个对象的,其核心是交待如何去给对象的成员变量分配内存或进行初始化。根据构造方法的不同,可以把构造函数分为以下五类:(默认)构造函数、拷贝构造函数、拷贝赋值构造函数、移动构造函数、移动赋值构造函数。
4.1 (默认)构造函数
class MyClass{ // 创建一个类,但是没有显式地给出构造函数;
public:
char* m_name; // 同学的姓名
int m_nameLength; // 同学的姓名的长度
static int numPeople; // 记录班级总人数
};
如果创建一个类没有显式地提供该类的构造函数,则编译器会自动创建默认的构造函数,这个构造函数既不接受任何参数也不做任何的操作。
//默认的构造函数
MyClass::Myclass(){};
当然,也可以手动创建一个与类名同名的自定义构造函数,使用该构造函数来实现类对象的初始化问题,如下所示:
#include<iostream>
#include<string.h>
using namespace std;
class MyClass{
public:
char* m_name; // 同学的姓名
int m_nameLength; // 同学的姓名的长度
static int numPeople; // 记录班级总人数
// 自定义构造函数(与类同名)
MyClass(char* name){ // 为了区分类成员和传入参数,通常在类成员名字前加入 m_
m_nameLength = strlen(name);
m_name = new char[m_nameLength + 1]; // 通过 new[] 动态分配内存
strcpy(m_name, name);
numPeople++; // 记录班级总人数 + 1
cout << "Welcome, " << m_name << "!" << endl;
cout << numPeople << " students in class!" << endl;
};
// 自定义析构函数,析构函数的名称为:~类名
~MyClass(){
cout << "Goodbye, " << m_name << "!" << endl;
delete[] m_name; // 通过 new[] 动态分配内存,需要手动 delete[] 释放
numPeople--; // 记录班级总人数 - 1
cout << numPeople << " students in class!" << endl;
}
};
int MyClass::numPeople = 0; // 类的静态成员变量不属于任何一个对象,而是属于整个类
int main(){
MyClass jacket("jacket");
}
该程序的输出如下,因为当实例化一个对象时,就会自动调用构造函数,当mian主函数推出后,会自动调用析构函数。
// 实例化一个对象时,就会自动调用构造函数
Welcome, jacket!
1 students in class!
// main 主函数退出
Goodbye, jacket!
0 students in class!
4.2 拷贝构造函数
拷贝构造函数,又称复制构造函数。上述的普通构造函数通常传入的是类成员变量的取值作为参数。
而在什么时候需要拷贝构造函数呢?
答案是:当传入的参数是指向类对象的引用时,即用一个类对象去初始化另一个类对象时,就会调用拷贝构造函数。如:
MyClass Tony("Tony"); // 存在一个类对象
MyClass Jacket(Tony); // 用已存在的类对象 Tony 去初始化新的类对象 Jacket
MyClass Jacket = Tony;
MyClass Jacket = MyClass Tony;
MyClass* Jacket = new MyClass Tony("Tony");
那么该如何编写拷贝构造函数呢?
类似于上述普通的构造函数,如果不对拷贝构造函数进行显式的声明,那么就会执行一个默认的拷贝构造函数。这将导致程序运行时存在一个错误提醒。如将不改动上述的 MyClass 类,在main函数里面用 jacket 对象初始化类对象:
int main(){
MyClass jacket("jacket");
MyClass tony(jacket); // 在main函数里面用 jacket 对象初始化类对象
}
程序的运行输出结果如下:
// 初始化 jacket 对象
Welcome, jacket!
1 students in class!
// 调用拷贝构造函数初始化 tony 对象
// main 主函数退出,调用 tony 对象的析构函数
Goodbye, jacket!
0 students in class!
// 调用 jacket 对象的析构函数,出现乱码并报错
Goodbye, �$%4!
报错的原因是析构函数中重复释放 m_name,因为在 jacket 对象和 tony 对象中的 m_name 共用同一块内存,这就导致该内存在调用 tony 对象的析构函数时被释放,再调用 jacket 对象的析构函数时 m_name 已经被释放,所以出现重复释放的报错。究其原因,是由于默认的拷贝复制函数采用了浅拷贝的方式,因此,再手动编写拷贝复制函数时需要注意对某些数据类型采用深拷贝的模式,如上述例子中添加拷贝构造函数如下:
class MyClass{
// Same Code...
// 自定义拷贝构造函数
MyClass(const MyClass& obj){ // 我们并不希望 obj 对象受到改动,故而设为 const
m_nameLength = obj.m_nameLength;
m_name = new char[m_nameLength+1];
strcpy(m_name, obj.m_name);
numPeople++; // 记录班级总人数 + 1
cout << "Welcome, " << m_name << "!" << endl;
cout << numPeople << " students in class!" << endl;
}
}
这样运行的代码就没有问题了,以下是代码的打印输出:
Welcome, jacket!
1 students in class!
Welcome, jacket!
2 students in class!
Goodbye, jacket!
1 students in class!
Goodbye, jacket!
0 students in class!
4.3 拷贝赋值构造函数
要了解拷贝赋值构造函数,首先要区分什么是赋值,什么是声明,什么定义。简单地,可以用以下例子理解:
//声明
int number1;
//定义=声明+初始化
int number2 = 5;
//赋值:针对两个已经声明的变量
number1 = number2
理解了什么是赋值后,那么下一个问题就是:什么时候会调用拷贝赋值构造函数?
答案是:当把一个已经声明的对象赋值给另一个已经声明的对象时调用。如:
MyClass Tony;
MyClass Jacket;
// 调用拷贝赋值构造函数
Tony = Jacket;
而在初始化调用的是上述的拷贝构造函数,如:
MyClass Tony;
// 调用拷贝构造函数
MyClass Jacket = Tony;
那么按照惯例,下一个问题是:该如何编写拷贝赋值构造函数呢?
拷贝赋值构造函数的编写,本质上就是重载运算符+拷贝构造函数的结合体,其基本的代码框架如下:
类名& operator=(const 类名& 对象形参){ // 我们并不希望 形参对象 受到改动,故而设为 const
if(this == &对象形参){
return *this;
}
//拷贝过程
}
不妨依然使用上面例子,在上面例子中添加拷贝赋值构造函数如下:
#include<iostream>
#include<string.h>
using namespace std;
class MyClass{
// Same Coding ...
// 自定义拷贝赋值构造函数
MyClass& operator=(const MyClass& obj1){
if(this == &obj1){
return *this;
}
delete[] m_name;
m_nameLength = obj1.m_nameLength;
m_name = new char[m_nameLength+1];
strcpy(m_name, obj1.m_name);
cout << "Welcome, " << m_name << "!" << endl;
cout << numPeople << " students in class!" << endl;
return *this;
};
};
int MyClass::numPeople = 0; // 类的静态成员变量不属于任何一个对象,而是属于整个类
int main(){
MyClass jacket("jacket");
MyClass tony("tony");
tony = jacket; // 调用拷贝赋值构造函数
}
这样运行的代码就没有问题了,以下是代码的打印输出:
// 定义两个对象,Tony 和 jacket
Welcome, jacket!
1 students in class!
Welcome, tony!
2 students in class!
Welcome, jacket!
// 调用拷贝赋值构造函数
2 students in class!
// main 函数退出,自动调用析构函数
Goodbye, jacket!
1 students in class!
Goodbye, jacket!
0 students in class!
4.3 移动构造函数
可能很多同学也意识到了,拷贝构造函数或者拷贝赋值构造函数都存在着一个问题:很多时候,我们面对的场景是需要把一个旧对象的内容直接转移到新对象内容上,然后把旧对象给删除掉。如果使用拷贝构造函数的话,需要把对象的成员逐个进行拷贝,这将浪费大量的时间,能否直接使用指针把旧对象成员变量的所有权直接交给新对象,而不做多余的复制呢?
答案当然是可以的,只不过需要使用的移动构造函数/移动赋值构造函数。而移动构造函数/移动赋值构造函数的传入参数类型必须是右值引用,所以我们首先需要对右值引用有一个初步的了解。
4.3.1 右值引用
首先,熟悉C++基本操作的我们都知道,在C++的赋值操作中,等号的左边通常是一个表示数据的表达式(如变量名或者指针名称),程序可以直接获取该表达式的地址。而等号的右边通常是一些常量、运算的表达式或者带有返回值的函数。
// 合法的表达式
int a = 12;
int b = function(50);
// 不合法的表达
int c + d = 12;
我们不妨把合法表达式等号左边的表达式称为左值,等号右边的表达式称为右值。那么我们常见的引用如int &a = b;
都是在引用等号左边的内容,我们可以将其称为左值引用。那么很自然地,我们引用等号右边的内容则被称为右值引用。右值引用通过符号 &&
表示。如:
// 代码程序
int a = 10;
int b = 20;
int&& c = a + b; // 右值引用
cout << "Before change, c = " << c << ", loaction is: " << &c << endl;
a, b = 10, 10;
cout << "After change, c = " << c << ", loaction is: " << &c << endl;
// 输出结果
Before change, c = 30, loaction is: 0x2347ffcbc
After change, c = 30, loaction is: 0x2347ffcbc
跟左值引用类似,我们也可以获取右值引用变量的地址,而且上述例子可以看到,右值引用变量c的地址关联到的是30,即使后面改变了a和b的取值,也不会影响到变量c。
4.3.2 移动构造函数
在了解完右值引用后,我们进行接着介绍移动构造函数。按照管理,第一个问题是:
什么时候会调用移动构造函数?
答案是传入参数是右值引用时,会自动调用移动构造函数,其模式与拷贝构造函数极其相似,如:
class MyClass{};
MyClass func(MyClass obj);
MyClass jacket("jacket");
// 调用 移动构造函数
MyClass tony(func(jacket));
// 调用 拷贝构造函数
MyClass lily(jacket);
可以观察到,jacket 对象属于左值,如果直接使用 jacket 作为参数初始化的话,会调用拷贝构造函数。而 func(jacket) 是MyClass类型函数的返回值,属于右值,故而调用移动构造函数。
很自然地,我们很多时候并没有 func 这个函数,能不能直接使用右值对象也能调用移动构造函数?
答案是可以的,通过强制转换,可以通过 static_cast<>
或 std::move()
(需要引入 utility 头文件)把左值的对象强制转化为右值对象,如:
#include<utility>
class MyClass{}
MyClass jacket("jacket");
MyClass jacket_new = std::move(jacket);
接下来第三个问题是:如何编写移动构造函数?
与拷贝构造函数非常类似,其基本格式为:
类名 (类名&& 右值对象){
// 指针权限移动
}
它和拷贝构造函数的差异有两点:1. 不使用 const 修饰传入对象,因为在移动构造函数通常我们会把传入对象指针设置为空指针,避免造成重复释放的问题,而在拷贝构造函数我们希望传入对象不发生变换;2. 传入对象类型为右值引用,而非左值引用;以下编写一下与上述相同的例子:
#include<iostream>
#include<string.h>
#include<utility>
using namespace std;
class MyClass{
public:
char* m_name;
int m_nameLength;
static int numPeople; // 记录班级总人数
// 自定义构造函数(与类同名)
MyClass(char* name){ // 为了区分类成员和传入参数,通常在类成员名字前加入 m_
m_nameLength = strlen(name);
m_name = new char[m_nameLength + 1]; // 通过 new[] 动态分配内存
strcpy(m_name, name);
numPeople++; // 记录班级总人数 + 1
cout << "Welcome, " << m_name << "!" << endl;
cout << numPeople << " students in class!" << endl;
};
// 自定义析构函数,析构函数的名称为:~类名
~MyClass(){
cout << "Goodbye, " << m_name << "!" << endl;
delete[] m_name; // 通过 new[] 动态分配内存,需要手动 delete[] 释放
numPeople--; // 记录班级总人数 - 1
cout << numPeople << " students in class!" << endl;
}
// 自定义拷贝构造函数
MyClass(const MyClass& obj){
cout << "Oh shit, wrong way!" << endl;
}
MyClass(MyClass&& obj){
m_name = obj.m_name; // 转移传入对象的成员指针
obj.m_name = nullptr; // 把传入对象的成员指针设置为空指针,避免重复释放;
m_nameLength = obj.m_nameLength;
numPeople++; // 记录班级总人数 + 1
cout << "Welcome, " << m_name << "!" << endl;
cout << numPeople << " students in class!" << endl;
}
};
int MyClass::numPeople = 0; // 类的静态成员变量不属于任何一个对象,而是属于整个类
int main(){
MyClass jacket("jacket");
MyClass tony (std::move(jacket));
}
最后的输出如下:
// 创建 jacket 类对象,调用普通的构造函数
Welcome, jacket!
1 students in class!
// 创建 tony 类对象,调用移动构造函数
Welcome, jacket!
2 students in class!
// 调用 tony 类对象析构函数
Goodbye, jacket!
1 students in class!
// 调用 jacket 类对象的析构函数,由于在 tony 类对象调用移动构造函数把传入的 jacket 对象的姓名指针指向空指针,
// 故而无法继续完整输出 Goodbye, jacket!和 班级剩余人数(0 students in class!)
Goodbye,
4.4 移动赋值函数
移动赋值函数与拷贝赋值函数的定义方式非常类似,但是其传入的参数是非 const 类型的右值引用对象,并且需要把传入对象的指针成员设置为空指针,避免重复释放的问题。用相同的例子如下:
#include<iostream>
#include<string.h>
#include<utility>
using namespace std;
class MyClass{
// Same Code ...
MyClass& operator=(MyClass&& obj1){
if(this == &obj1){
return *this;
}
delete[] m_name;
m_nameLength = obj1.m_nameLength;
m_name = obj1.m_name;
obj1.m_name = nullptr;
cout << "Welcome, " << m_name << "!" << endl;
cout << numPeople << " students in class!" << endl;
return *this;
};
};
int MyClass::numPeople = 0; // 类的静态成员变量不属于任何一个对象,而是属于整个类
int main(){
MyClass jacket("jacket");
MyClass tony("tony");
tony = std::move(jacket);
}
5. 总结
在本章中涉及C++最核心的内容,本文以C++中两种基础的衍生数据结构:结构体和类作为引子,从C++的封装、继承与多态三大特性全面讲述如何在类这种数据结构进行体现。在封装中,我们讲解了类和结构体的相似性;在继承中,我们讲解了一般的继承方式和多重继承,进一步地,我们讨论了如何通过虚继承的方式避免继承过程中产生的钻石继承问题;在多态中,我们解释为什么吧多态分为静态多态和动态多态,针对静态多态,我们讲解了函数重载和模板函数两种实现方式,在动态多态中,我们讲解了虚函数的概念;最后,我们就构造函数来讲解类的内存管理问题,具体地,我们主要讲解了:构造函数、拷贝构造函数、拷贝赋值构造函数、移动构造函数、移动赋值构造函数五种类型,在什么情况下会被调用和如何编写这五种构造函数。