类和对象
1. 面向过程和面向对象的初步认识
- C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
- C++是基于面向对象的,关注的是对象,将一件事拆分成不同的对象,靠对象之间的交互完成。
就外卖系统来看看面向过程和面向对象之间的区别:
- 面向过程,关注点应该是用户下单、骑手接单以及骑手送餐这三个过程。
- 面向对象,关注点应该就是客户、商家以及骑手这三个类对象之间的关系。
2. 类的定义
2.1 类定义格式
class 为定义类的关键字,className为类的名字(由程序员自行决定)同样也是类的类型,{} 中为类的主体,注意类定义结束时后面分号不能省略。
类体中的成员称为类的成员:类中的变量称为属性或成员变量;类中的函数称为方法或成员函数。
class className
{
//类体:由成员变量和成员函数组成
}; //注意后面的分号
补充:
-
为了区分成员变量,一般习惯上成员变量会加一个特殊标识,如成员变量前面或后面加 m 开头,注意 C++ 中这个并不是强制的,只是一些惯例,要看具体的要求。
class Stack { int* _array; size_t _capacity; size_t _top; }; -
C++ 中 struct 也可以定义类,C++ 兼容 C 中 struct 的用法,同时 struct 升级成了类,明显的变化是 struct 中可以定义函数,一般情况下我们还是推荐用 class 定义类。
struct Person { public: void Init(const char* name, int age, int tel) { strcpy(_name, name); _age = age; _tel = tel; } void Print() { cout << "姓名:" << _name << endl; cout << "年龄:" << _age << endl; cout << "电话:" << _tel << endl; } private: char _name[10]; int _age; int _tel; //... };
2.1.1 类的两种定义方式:
- 声明和定义全部放在类体中。需要注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。

- 声明放在头文件(.h)中,定义放在源文件(.cpp)中。

2.2 访问限定符和封装
2.2.1 类的访问限定符
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。

【访问限定符说明】
1. public修饰的成员可以在类外直接被访问。
2. protected和private修饰的成员在类外不能直接被访问。
3. 访问权限从该访问限定符出现的位置开始直到下一个访问限定符出现时为止;如果后面没有访问权限符,作用域到}即类结束。
4. class的默认访问权限为private,struct为public(因为struct要兼容C)。
5. 一般成员变量都会被限制为private/protected,需要给别人使用的成员函数会放为public。
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
2.2.2 类的封装
**封装:**将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
同样上面设置访问限定符的本质也是封装,程序员有所选择的隐藏成员变量或者是部分细节,仅仅将某些函数作为对外公开的接口用来和对象进行交互。封装本质上是一种管理。
2.3 类域
类定义了一个新的作用域,类的所有成员都在类的作用域中,在类体外定义成员时,需要使用 :: 操作符指明成员属于哪个类域。
#include<iostream>
using namespace std;
class Stack
{
public:
void Init(int n = 4);
private:
int* _a;
int _top;
int _capacity;
};
//这里需要指定Init属于Stack这个域
void Stack::Init(int n)
{
_a = (int*)malloc(sizeof(int) * n);
if (nullptr == _a)
{
perror("mallocռʧ");
return;
}
_capacity = n;
_top = 0;
}
因为同一个域中不可以有同名变量,不同的域中可以有同名变量。
就像如果存在多个数据结构的类,Stack、Heap、queue等,他们都在类体外写一个叫做Init的函数用于初始化,那么如何进行分类,这时候看需要指定哪个函数是属于那个类域的。
3. 类的实例化
3.1 实例化的概念
- 用类类型在物理内存中创建对象的过程,称为类实例化出对象。
类是对象进行一种抽象描述,是一个模型一样的东西,限定了类有那些成员变量,这些成员变量只有说明,没有分配空间,用类实例化出对象时,才会分配空间。(判断一个变量是声明还是定义,就看有没有为其分配空间即可)
一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量。
例如:类实例化出对象就像是现实中使用建筑设计图纸建造房子,类就像是设计图,但是并没有实际的建筑存在,也不能住人,用设计图建造房子,房子才能住人。同样类就像设计图一样,不能存储数据,实例化出的对象才在物理内存中存储数据。

#include<iostream>
using namespace std;
class Date
{
public:
void Init(int year, int mouth, int day)
{
_year = year;
_mouth = mouth;
_day = day;
}
void Printf()
{
cout << _year << "-" << _mouth << "-" << _day << endl;
}
//这里只是声明,没有开辟空间
private:
int _year;
int _mouth;
int _day;
};
int main()
{
//Date类实例化出对象d1, d2
Date d1;
Date d2;
d1.Init(2025, 5, 3);
d1.Print();
d2.Init(2024, 7, 5);
d2.Print();
return 0;
}
3.2 类对象模型
3.2.1 类对象的大小计算
#include<iostream>
using namespace std;
class Date
{
public:
void Init(int year, int mouth, int day)
{
_year = year;
_mouth = mouth;
_day = day;
}
void Printf()
{
cout << _year << "-" << _mouth << "-" << _day << endl;
}
//这里只是声明,没有开辟空间
private:
int _year;
int _mouth;
int _day;
};
int main()
{
//Date类实例化出对象d1, d2
Date d1;
Date d2;
d1.Init(2025, 5, 3);
d2.Init(2024, 7, 5);
cout << sizeof(d1) << endl;
cout << sizeof(d2) << endl;
return 0;
}
运行结果:
12
12
从上述程序运算中可以看出,类在内存中的存储仅仅是将了成员变量存储进对象里面,并没有存储成员函数。其实这也很好理解,这里的d1和d2这两个对象的差异也仅仅是体现在成员变量有所不同,其如果调用函数Init其实本质调用的是一个函数,所以如果再将成员函数存储到对象中则是冗余的。所以可以将成员函数存储到一个公共区域即可。
通过汇编语句可以看出,这里的两个对象调用的函数是同一个函数:

3.2.2 类对象的存储方式
上面简单介绍了类实例化后所占据空间的大小,就是其中成员变量的大小,成员函数并没有被存储,下面会详细介绍这样设计的原因。
**方式一:**对象中包含类的各种成员(成员变量与成员函数)

这种方法将类中的成员变量和成员函数都进行存储,因为变量可以直接存储在内存中,但是函数是如何存储呢?要弄懂这种存储方式,首先要了解函数的存储方式。
补充:成员函数的存储
函数被编译之后是一段指令,对象中没有办法存储,这些指令存储在一个单独的区域(代码段),那么对象中非要存储的话,只能是存储成员函数的指针。调用函数被编译成汇编指令[call 地址],编译器在编译链接时,就要找到函数的地址,不是在运行时找,只需动态多态是在运行时找,就需要存储函数地址。
经过上面的介绍之后,可知同一个类实例化出的不同对象使用的是同一个函数,但是同一个类的不同对象不能共用同一个成员变量,因为每一个对象的成员变量的值都是不同的;另外,通常我们把这段指令中第一条指令的地址作为函数的地址,而类对象中存储的就是这个地址。
**缺点:**此方法虽然保存了该变量特有的成员变量的地址,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份函数的地址,相同地址保存多次,浪费空间。
**方式二:**只保存成员变量,成员函数存放在公共的代码段

这种方法改进了方式一种每个对象都需要保存一份相同的地址,造成的空间浪费。
那下面结合上述代码解释为什么做这种修改。
Date实例化d1和d2两个对象,d1和d2都有各自独立的成员变量_year/_month/_day存储各自的数据,但是d1和d2的成员函数Init/Print指针是一样的,存储在对象中就浪费了。如果用Date实例化100个对象,那么成员函数指针就重复存储100次,太浪费了。
补充:空类和类中只有成员函数的情况
class B
{
public:
void Print()
{
//...
}
};
class C
{};
int main()
{
B b;
C c;
cout << sizeof(b) << endl;
cout << sizeof(c) << endl;
return 0;
}
运行结果:
1
1
上面的程序运行后,看到没有成员变量B和C类对象的大小是1,为什么没有成员变量还要给1个字节呢?因为如果一个字节都不给,那么如何表示对象存在过呢!所以这里给1字节,纯粹是为了占位,标识对象存在。
3.2.3 内存对齐规则
- 第一个成员在与结构体偏移量为0的地址处。(即结构体的首地址处,即对齐到0处)
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 结构体大小计算:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍,结构体的整体大小就是所有最大对齐(包含嵌套结构体的对齐)的整数倍。
注意:对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。
VS中默认的对齐数为8。
4. this指针
4.1 问题引入
#include<iostream>
using namespace std;
class Date
{
public:
void Init(int year, int mouth, int day)
{
_year = year;
_mouth = mouth;
_day = day;
}
void Printf()
{
cout << _year << "-" << _mouth << "-" << _day << endl;
}
private:
int _year;
int _mouth;
int _day;
};
int main()
{
//Date类实例化出对象d1, d2
Date d1;
Date d2;
d1.Init(2025, 5, 3);
d2.Init(2024, 7, 5);
d1.printf();
d2.printf();
return 0;
}
运行结果:
2025-5-3
2025-7-5
上述代码中Date类中有Init与Print两个成员函数,函数体中没有关于不同对象的区分,那么当d1调用Init和Print函数时,该函数是如何知道应访问的是d1对象还是d2对象呢?
那么这里就要看到C++给了一个隐含的this指针(隐含形参)来解决这个问题
编译器编译后,类的成员函数默认会在函数签名中为每个位置,增加一个当前类类型的指针(会将对象的地址当做成员函数第一个参数),叫做this指针。比如Date类的Init的真实原型为,
void Init(Date* const this, int year, int month, int day)类的成员函数访问成员变量,本质都是通过this指针访问的,如Init函数中给_year赋值,
this->_year = year;C++现在不能在实参和形参的位置显示的写this指针(编译时编译器会处理),但是可以在函数体内显式使用this指针。
//void Init(Date* const this, int year, int mouth, int day)(取消注释报错) void Init(int year, int mouth, int day) { _year = year; _mouth = mouth; _day = day; } //void Printf(Date* const this)(取消注释报错) void Printf() { cout << _year << "-" << _mouth << "-" << _day << endl; //cout << this->_year << "-" << this->_mouth << "-" << this->_day << endl;(取消注释不报错) }
4.2 相关面试题
1、this指针存在哪里?
this 指针作为函数形参,存在于函数的栈帧中,而函数栈帧在栈区上开辟空间,所以 this 指针存在于栈区上;
不过VS这个编译器对 this 指针进行了优化,直接使用 ecx 寄存器保存 this 指针(使用寄存器存储效率更高速度更快)
2、this 指针可以为空吗?
this 指针作为参数传递时是可以为空的,但是如果成员函数中使用到了 this 指针,那么就会造成对空指针的解引用。下面的两道代码例题就是基于这个问题做深入考察。
3、下面这两个程序编译运行的结果分别是什么?
//下面两段程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A //程序1
{
public:
void PrintA()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
//***********************************//
class A //程序2
{
public:
void PrintA()
{
cout << "Print()" << endl;
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
程序1:正常运行
原因:
虽然程序用空指针访问了成员函数Print,但是由于成员函数并不存在于对象中,而是存在于代码段中,所以编译器并不会通过类的指针p去访问成员函数,即并不会对p进行解引用;
程序2:运行崩溃
原因:
程序调用了成员函数PrintA,这里并不会产生什么错误(理由同上),但是PrintA函数中打印了成员变量_a,成员变量_a只有通过对this指针进行解引用才能访问到,而this指针此时接收的是nullptr,对空进行解引用必然会导致程序的崩溃。
5. C语言和C++实现Stack的对比
5.1 C语言实现
typedef int DataType;
typedef struct Stack
{
DataType* array;
int capacity;
int top;
}Stack;
void StackInit(Stack* ps)
{
assert(ps);
ps->array = (DataType*)malloc(sizeof(DataType) * 4);
if (NULL == ps->array)
{
perror("malloc fail\n");
exit(-1);
}
ps->capacity = 4;
ps->top = 0;
}
void StackDestroy(Stack* ps)
{
assert(ps);
if (ps->array)
{
free(ps->array);
ps->array = NULL;
ps->capacity = 0;
ps->top = 0;
}
}
void CheckCapacity(Stack* ps)
{
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity * 2;
DataType* temp = (DataType*)realloc(ps->array,newcapacity * sizeof(DataType));
if (temp == NULL)
{
perror("realloc fail\n");
exit(-1);
}
ps->array = temp;
ps->capacity = newcapacity;
}
}
void StackPush(Stack* ps, DataType data)
{
assert(ps);
CheckCapacity(ps);
ps->array[ps->top] = data;
ps->top++;
}
int StackEmpty(Stack* ps)
{
assert(ps);
return 0 == ps->top;
}
void StackPop(Stack* ps)
{
if (StackEmpty(ps))
return;
ps->top--;
}
DataType StackTop(Stack* ps)
{
assert(!StackEmpty(ps));
return ps->array[ps->top - 1];
}
int StackSize(Stack* ps)
{
assert(ps);
return ps->top;
}
int main()
{
Stack s;
StackInit(&s);
StackPush(&s, 1);
StackPush(&s, 2);
StackPush(&s, 3);
StackPush(&s, 4);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackPop(&s);
StackPop(&s);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackDestroy(&s);
return 0;
}
在用C语言实现时,Stack相关操作函数有以下共性:
- 每个函数的第一个参数都是Stack*;
- 函数中必须要对第一个参数检测,因为该参数可能会为NULL;
- 函数中都是通过Stack*参数操作栈的;
- 调用时必须传递Stack结构体变量的地址;
结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出错。
5.2 C++实现
typedef int DataType;
class Stack
{
public:
void Init(int N = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * N);
if (NULL == _array)
{
perror("malloc fail\n");
exit(-1);
}
_capacity = N;
_top = 0;
}
void Push(DataType data)
{
CheckCapacity();
_array[_top] = data;
_top++;
}
void Pop()
{
if (Empty())
return;
_top--;
}
DataType Top()
{
return _array[_top - 1];
}
int Empty()
{
return 0 == _top;
}
int Size()
{
return _top;
}
void Destroy()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_top = 0;
}
}
void CheckCapacity()
{
if (_top == _capacity)
{
int newcapacity = _capacity * 2;
DataType* temp = (DataType*)realloc(_array, newcapacity *sizeof(DataType));
if (temp == NULL)
{
perror("realloc fail\n");
exit(-1);
}
_array = temp;
_capacity = newcapacity;
}
}
private:
DataType* _array;
int _capacity;
int _top;
};
int main()
{
Stack s;
s.Init();
s.Push(1);
s.Push(2);
s.Push(3);
s.Push(4);
printf("%d\n", s.Top());
printf("%d\n", s.Size());
s.Pop();
s.Pop();
printf("%d\n", s.Top());
printf("%d\n", s.Size());
s.Destroy();
return 0;
}
相比于C语言而言,C++中通过类可以将数据以及操作数据的方法封装到了一起,类里面既有方法也有数据,通过访问权限符可以控制哪些方法在类外可以被调用,这种方式就是封装。
5.3 差异总结
数据和方法的关系
-
C语言中的数据和方法是分散开的,也是十分自由的;但是C++中的数据和方法是封装在类里面的,并且利用访问限定符限定用户对其核心数据的访问,仅仅是将能够实现功能的函数作为接口进行使用。
虽然说C语言更加自由听上去不错,但是实际可能产生很多问题。例如,上述代码中,C语言中的
top变量实际指向的是栈顶元素的上一个,如果用户在调用的时候没有了解代码中的细节则有可能出错;但是C++限制了对数据的访问,仅仅需要知道成员函数的功能学会调用接口即可。虽然一定程度上限制了用户,但是从整体的程序上来说封装的本质是一种更严格规范的管理,避免出现乱修改导致的问题。
便捷的语法
- C++中有一些相对方便的语法,比如
Init给的缺省参数函数会方便很多;函数每次不需要传对象地址,因为this指针隐含的传递了,方便了很多;使用类型不再需要typedef用类名就很方便。
6. 类的默认成员函数
在使用C语言练习初阶数据结构,即线性表、链表、栈、队列、二叉树、排序等内容时,可能会经常犯两个错误:
- 在使用数据结构创建变量时忘记对其进行初始化操作而直接进行插入等操作;
- 在使用完毕后忘记对动态开辟的空间进行释放而直接返回;
而C++为了减少上述错误的出现,引入了默认成员函数的概念。
默认成员函数的概念:用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。
一个类,程序员不写的情况下编译器会默认生成以下6个默认成员函数,需要注意的是这6个中最重要的是前4个,最后两个取址重载不重要,稍微了解一下即可。
其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个后面再讲解。

如上的6中成员函数中的构造函数和析构函数是可以解决前文提到的两个问题,其他四个则是为了适应其他场景的需求。
6.1 构造函数
6.1.1 基本概念
构造函数是特殊的成员函数,需要注意的是,构造函数虽然称为构造,但其主要任务并不是开空间创建对象(我们常使用的局部对象是栈创建的,空间就开好了,不需要构造函数再为其开辟),而是对象实例化时初始化对象。类比一下,构造函数的本质是替代前文中在Stack和Date类中写的Init函数的功能,构造函数自动调用的特点就完美替代了Init。
构造函数的特点:
- 函数名与类名相同。
- 无返回值。(返回值都不需要给,也不需要写void,不要结束,C++规定如此)
- 对象实例化时会自动调用对应的构造函数。
- 构造函数可以重载。
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
- 构造函数对内置类型不一定做处理(看编译器),对自定义类型调用其本身的默认构造(没有则报错)。
- 无参的构造函数、全缺省的构造函数和不写构造函数由编译器自己生成的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
6.1.2 特点分析 – 自动调用和重载
代码示例:
#include<iostream>
using namespace std;
class Date {
public:
// 1. 无参构造函数
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
// 2. 带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;//调用无参构造函数
Date d2(2025, 1, 1); // 调用带参构造函数
d1.Print();
d2.Print();
return 0;
}
运行结果:
1/1/1
2025/1/1
从最后的结果可以看到,在创建对象的时候编译器自动调用构造函数,完成了初始化功能,并且可以完全替代之前的Init函数。
注意事项:
1、构造函数虽然支持重载和缺省参数,但是无参构造函数和有参全缺省构造函数虽然构成函数重载但是不能同时出现,因为在调用时会产生歧义;

但是在这里日期类的初始化全缺省构造函数单独使用十分方便,因为这一种就可以构造就可以代表很多种参数情况;

2、当调用无参构造函数或者全缺省构造函数来初始化对象时,不要在对象后面带括号,这样使得编译器分不清这是在实例化对象还是函数声明;
6.1.3 特点分析 – 不同类型成员变量的处理
说明:C+++把类型分为成内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如:int/char/double/指针等,自定义类型就是使用class/struct等关键字自己定义的类型,如:Stack/Queue/Date。
默认构造的行为:不写的时候,编译器默认生成的构造(默认构造),对内置类型成员变量的初始化没有要求,也就是说是否初始化是不确定的,看编译器。对于自定义类型成员变量,要调用这个成员变量的默认构造初始化。如果这个成员变量,没有默认构造函数,那就会报错,要初始化这个成员变量,需要用初始化列表才能解决。
并结合构造函数特点中的第六点:构造函数对内置类型不一定做处理,对自定义类型调用其本身的默认构造。
为了更好理解这个特点,下面使用Stack和Myqueue这两个类进行理解
6.1.3.1 示例分析
Stack:
class Stack
{
public:
Stack(int n = 4)
{
_a = (int*)malloc(sizeof(int) * n);
if (nullptr == _a)
{
perror("malloc申请内存失败");
return;
}
_capacity = n;
_top = 0;
}
private:
int* _a;
int _capacity;
int _top;
};
Queue:
// 两个Stack实现队列
class MyQueue
{
public:
// 编译器默认在MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化
private:
Stack _pushst;
Stack _popst;
};
首先这里可以看到,Stack 的成员变量全部为内置类型,所以当不显式定义构造函数时,编译器自动生成一个默认构造函数,但默认生成的构造函数并不会对内置类型进行处理,所以这里看到的是随机值;Date 类的情况也是如此:

所以在VS2020这款编译器底层是不会对内置类型进行初始化的,所以编译器自动生成的构造函数不能满足需求,则需要手动定义构造函数:

(重点!!)但是对于MyQueue来说,它的成员变量全部为自定义类型,自定义类型编译器会去找其成员变量的自定义类型的默认构造函数;如果不提供构造函数时,编译器就会报错。
针对C++构造函数行为解析:
首先按照成员变量声明的顺序自动调用成员对象的构造函数
然后再执行该类自己的构造函数体中的代码
这里的MyQueue根据成员变量的声明
- 首先调用
_pushst这个成员变量的自定义类型所对应的构造函数也就是Stack的构造函数,Stack因为前面已经写了构造函数,这里会调用前面的构造函数进行初始化。 - 然后调用
_popst这个成员变量的自定义类型所对应的构造函数也是Stack所以同理。 - 最后因为MyQueue没有构造函数,所以不需要调用。

6.1.4 特点分析 – 默认构造函数
根据构造函数特点中的第七点:无参的构造函数、全缺省的构造函数和不写构造函数由编译器自己生成的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
默认构造的概念: 无参构造函数**、全缺省构造函数,不写构造时编译器默认生成的构造函数,都叫做默认构造函数**。 但是这三个构造函数有且只有一个存在,不能同时存在。
要注意很多学习者会默认构造是编译器默认生成那个叫默认构造,实际上无参构造、全缺省构造函数也默认构造,总结一下就是不传实参就可以调用的构造就叫默认构造。
当类中只有一个带参默认构造函数时,此时类中就没有默认构造函数。
6.1.4.1 示例分析
如果类中没有默认构造函数,那么实例化对象时就必须传递参数:

如果存在默认构造函数,则会在程序运行时自动调用默认构造:
默认构造:全缺省构造

默认构造:无参构造

默认构造:编译器自动构造

6.2 析构函数
6.2.1 基本概念
析构函数与构造函数功能相反,构造函数不是完成对象本身的销毁,比如局部对象是存在栈帧的,构造函数结束栈帧销毁,就自动释放了,不需要管理,C++规定对象在销毁时会自动调用析构函数,完成对象资源的清理释放工作。
析构函数的功能类似之前用Stack实现的Destroy功能,而像Date没有Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的。但是如果在对象生命周期结束的时候有什么特殊要求,也可以在析构函数中实现,一切向需求看齐。
构造函数的特点:
- 析构函数名是类名加上字符 ~。
- 无参函数无返回值。(这里构造函数类似,也不需要加void)
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
- 对象生命周期结束时,系统会自动调用析构函数。
- 对内置类型成员不做处理,自定义类型成员会调用他的析构函数。
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数;如果默认生成的析构就可以用,也就不需要显示写析构;但是有资源申请时,一定要自己写析构,否则会造成资源泄漏。
- 一个局部域的多个对象,C++规定后定义的先析构。
6.2.2 特点分析 – 自动调用

6.2.3 特点分析 – 不同类型成员变量的处理
依据特性第5点:对内置类型成员不做处理,自定义类型成员会调用他的析构函数
跟构造函数类似,不写编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用他析构函数。
6.2.3.1 示例分析
基本的情况和构造函数类似,不做过多赘述。
针对C++析构函数行为解析:
首先调用该对象自己的析构函数
然后按照成员变量声明的逆序自动调用成员对象的析构函数
如下图代码,
- 首先需要调用MyQueue的析构函数,因为其没有析构函数则跳过。
- 再根据成员变量声明的逆序,首先调用
_popst变量的自定义类型的析构函数,也就是Stack的析构函数。 - 最后再调用
_pushst变量的自定义类型的析构函数,同样是Stack,同理。

6.2.4 特点分析 – 是否需要书写析构的条件
依据特点第6条:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数;如果默认生成的析构就可以用,也就不需要显示写析构;但是有资源申请时,一定要自己写析构,否则会造成资源泄漏。
6.2.4.1 示例代码
类中没有申请资源 – Date:
class Date
{
public:
Date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
Date 类没有进行资源的申请 (malloc 内存、fopen 文件等操作),所以可以不用显式定义析构函数,直接使用编译器自动生成的构造函数即可。(虽然自动生成的构造函数对内置类型不处理,但本来Date类就不需要做任何处理)
类中申请了资源 – Stack:
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
_top = 0;
_capacity = capacity;
cout << "Stack 构造" << endl;
}
~Stack()
{
free(_a);
_a = NULL;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
而 Stack 类中的成员变量_a指向了一块动态开辟的空间,如果使用自动生成的析构函数,那么析构函数对内置类型 int* _a 不进行处理,就会造成内存泄露,所以需要显式定义析构函数。
默认生成的析构即可用 – MyQueue:
// 两个Stack实现队列
class MyQueue
{
public:
// 编译器默认在构造MyQueue的构造函数时调用了Stack的构造,释放的Stack内部的资源
private:
Stack pushst;
Stack popst;
};
int main()
{
MyQueue mq;
return 0;
}
MyQueue 的两个成员变量 pushST 与 popST 都是自定义类型,所以编译器会调用它们的析构函数,即 ~Stack,所以MyQueue动态开辟的空间也会得到释放,不需要我们手动定义析构函数,使用系统默认生成的即可。
6.2.5 总结
一般情况下显示申请了资源,才需要自己实现析构,其他情况都不需要写析构。
6.3 拷贝构造函数
6.3.1 基本概念
在C++中如果想创建一个与已存在对象一摸一样的新对象或者**类类型变量的传参(传返回值)**是就可以借助拷贝构造函数来实现。
拷贝构造函数:如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造函数是一个特殊的构造函数。
拷贝构造的特点:
拷贝构造函数是构造函数的一个重载,当使用拷贝构造函数实例化对象的时候,编译器就不会再调用构造函数了。
拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。
拷贝构造函数也可以有多个参数,但第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。
若未显式定义,编译器会生成默认的拷贝构造函数。
自动生成的默认拷贝构造函数对内置类型成员变量进行拷贝,自定义类型成员变量会调用他的拷贝构造函数。
C++规定了类类型对象进行拷贝行为的时候(传参数和传返回值)必须调用拷贝构造。

6.3.2 特点分析 – 构造重载
基于特点第1条:拷贝构造函数是构造函数的一个重载,当使用拷贝构造函数实例化对象的时候,编译器就不会再调用构造函数了。
6.3.2.1 示例代码

6.3.3 特点分析 – 引用参数
基于特点第2条:拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。拷贝构造函数也可以有多个参数,但第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。
6.3.3.1 无穷递归的原因分析
解释:为什么使用传值方式编译器直接报错会发生无穷递归的情况?
首先要解释以上问题基于第6条特点解释:类类型对象进行拷贝行为的时候(传参数和传返回值)必须调用拷贝构造。
如果是这样设定,如下图,在main函数中调用了拷贝构造函数,但是这里因为拷贝构造函数的传参形式是类类型的传值传参,根据上面的规定,还需要继续调用Date类中的拷贝构造函数,但是这里的传参形式仍然是类类型的传值传参,也就形成无穷递归。

所以就规定让拷贝构造函数的第一个参数为引用传参,这样就可以避免以上问题。
补充1:那这里的第一个参数可不可以使用指针作为参数,使用传址传参呢?
这里也是可以的,因为指针是内置类型,不是类类型不受上面那条规定的约束,但是这样写的话也就不满足拷贝构造函数的定义,此时的函数就是构造函数了。
//拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//构造函数
Date(Date* p)
{
_year = p->_year;
_month = p->_month;
_day = p->_day;
}
补充2:为什么上面的示例代码需要加上const修饰?
#include<iostream>
using namespace std;
class Date
{
public:
//构造函数
Date(int year = 2025, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数(没有const修饰)
Date(Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
Date f()
{
Date ret;
//..
return ret;
}
int main()
{
Date d1(f());
Date d2 = f();
return 0;
}
运行结果:

示例代码如上,运行之后存在报错,这里仍然涉及上篇文章介绍的权限放大问题。这里的函数f()将返回ret返回的时候是存储在一个临时变量中,临时变量具有常性,这时如果没有const修饰就会存在权限放大的问题,为了避免这种情况的发生,所以使用const修饰第一个参数。
并且使用const修饰之后还可以防止被拷贝的对象中的数据被更改,更加安全。
6.3.3 特点分析 – 深浅拷贝
基于特点第4条:自动生成的默认拷贝构造函数对内置类型成员变量进行拷贝,自定义类型成员变量会调用他的拷贝构造函数。
在进行内置类型成员变量拷贝的时候涉及值拷贝/浅拷贝(一个字节一个字节的拷贝)。
6.3.3.1 Stack
对于深浅拷贝,以Stack为例:

如上图,因为没有显式定义 Stack 的拷贝构造函数,那么编译器会自动生成一个拷贝构造,并且因为Stack中的成员变量都是内置类型会将 _a、_top、_capacity 按字节拷贝到d2对象中。
目前来看好像没有什么问题,但是仔细思考会有以下问题:
-
编译器按字节将d1中的内容拷贝到d2中,但成员变量_a指向的是一块动态内存,即_a中存放的是动态空间的起始地址,那么将d1的_a拷贝给d2的_a后,二者指向同一块空间,而main调用完毕时会销毁d1和d2对象,此时编译器会自动调用 Stack 的析构函数,这就造成 _a 指向的同一块空间被释放了两次,从而引发异常。
-
同时,如果此时在d2中插入一个数据3,d1和d2中的_top都会发生变化。这样与我们一开始的需求并不符合

那么正确的拷贝方式应该是深拷贝:为d2的_a单独开辟一块空间,并将d1中_a指向空间的内容拷贝到该空间中,其余内置成员变量再按字节拷贝
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
free(_a);
_a = NULL;
_top = _capacity = 0;
}
//拷贝构造函数
Stack(const Stack& st)
{
//重新开辟大小相同的空间
_a = (int*)malloc(sizeof(int) * st._capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
//将空间中的内容也拷贝过去
memcpy(_a, st._a, sizeof(int) * st._capacity);
//成员变量直接深拷贝
_top = st._top;
_capacity = st._capacity;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack st1;
Stack st2(st1);
return 0;
}


6.3.3.2 Date和MyQueue
对于 Date 类来说,其成员变量全是内置类型,且没有资源申请,所以可以直接使用编译器默认生成的拷贝构造,直接浅拷贝:

对于 MyQueue 类来说,它的成员变量全部是自定义类型
针对C++ 拷贝构造函数行为解析:
首先按照成员变量声明的顺序自动调用成员对象的拷贝构造函数,将源对象的对应成员复制到新对象中
然后执行该类自己的拷贝构造函数体中的代码
所以成员变量_pushst和_popst会调用其自身的拷贝构造,即 Stack 的拷贝构造,而 Stack 的拷贝构造虽然需要深拷贝,但已经显式定义,所以也不需要再提供拷贝构造,最后因为MyQueue中没有定义拷贝构造所以不需要调用:

6.3.4 特点分析 – 类类型对象的拷贝
基于特点第5条:C++规定了类类型对象进行拷贝行为的时候(传参数和传返回值)必须调用拷贝构造。
6.3.4.1 限制原因
防止多次析构
之所以有第5条规定的限制,原因解释如下:
如下代码,如果没有这个规定,在传值调用的时候对象st1仅仅是进行浅拷贝,将自己的内容完整的拷贝给形参st,那么就会导致两个指针指向同一块空间,最后析构的时候形参st释放一次,st1也还会释放一次,就会发生对同一块空间释放两次的问题。
#include<iostream>
using namespace std;
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
free(_a);
_a = NULL;
_top = _capacity = 0;
}
Stack(const Stack& st) //拷贝构造
{
_a = (int*)malloc(sizeof(int) * st._capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
memcpy(_a, st._a, sizeof(int) * st._capacity);
_top = st._top;
_capacity = st._capacity;
}
private:
int* _a;
int _top;
int _capacity;
};
void Func(Stack st)
{
}
int main()
{
Stack st1;
Func(st1);
return 0;
}

6.3.4.1 限制后果
角度1:引用传参
自定义类型的对象进行函数传参时,一般推荐使用引用传参。因为这样可以避免再生成一份类类型对象的拷贝,节省空间。
但是使用传值传参也可以,但每次传参时都会调用拷贝构造函数,借助拷贝构造函数将实参拷贝给形参。
角度2:引用传返回值
自定义类型的对象作函数返回值时,一般也推荐使用引用传参。因为函数返回值本质也是将返回值临时存储在一个临时变量中再返回回去,存在空间的浪费。使用传引用返回,返回的是对象的别名(引用),没有产生拷贝,也就没有空间的浪费。
但是如果返回回对象是一个当前函数局部区域的局部对象,函数结束就错误了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。传引用可以减少拷贝,但一定要确保返回回对象,在当前函数结束后还在,才能用引用返回。
6.3.5 总结
如果类中没有资源申请,则不需要手动实现拷贝构造函数,直接使用编译器自动生成的即可;如果类中有资源申请,就需要自己定义拷贝构造函数,实现深拷贝,否则就可能出现浅拷贝以及同一块空间被析构多次的情况;
其实,拷贝构造和函数析构函数在资源管理方面有很大的相似性,可以理解为需要写析构函数就需要写拷贝构造,不需要写析构函数就不需要写拷贝构造;
拷贝构造的经典使用场景:
使用已存在对象创建新对象;
函数参数类型为类类型对象;
函数返回值类型为类类型对象;
6.4 赋值运算符重载
在介绍赋值运算符重载之前需要先介绍一下运算符重载。
6.4.1 运算符重载
6.4.1.1 运算符重载的引入
对于C/C++编译器来说,它知道内置类型的运算规则,比如整形+整形、指针+整形、浮点型+整形;但是它不知道自定义类型的运算规则,比如日期+天数 、日期直接比较大小、日期-日期;我们要进行这些操作就只能去自行定义函数,比如AddDay、SubDay;但是这些函数的可读性始终是没有 + - > < 这些符号的可读性高的,而且不同程序员给定的函数名称也不一样相同;
所以为了增强代码的可读性,C++为自定义类型引入了运算符重载。
运算符重载函数的特征:
- 运算符重载是具有特殊函数名的函数,其函数名为关键字operator+需要重载的运算符符号,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
- 重载运算符函数的参数个数和运算符作用的运算符数目一样。一元运算符有一个参数,二元运算符有两个参数,三元运算符的左侧运算符对传给第一个参数,右侧运算符对传给第二个参数。
换句话说,运算符重载函数只有函数名特殊,其他方面与普通函数一样;这里以比较日期<为例:
//运算符<的重载
bool operator< (Date& x1, Date& x2)
{
if (x1._year < x2._year)
{
return true;
}
else if (x1._year == x2._year
&& x1._month < x2.month)
{
return true;
}
else if (x1._year == x2._year
&& x1._month == x2.month
&& x1.day < x2.day)
{
return true;
}
return false;
}
int main()
{
Date d1(2024, 8, 9);
Date d2(2024, 8, 10);
//两种调用方式都可以
bool ret1 = operator<(d1, d2);
bool ret2 = d1 < d2;
return 0;
}
其实运算符重载本质就是程序员自己根据类的特征,重新定义运算符。
6.4.1.2 运算符重载函数的位置
将上面的代码复制到编译器中编译运行,会发现会有很多报错。

这是因为运算符重载函数中调用的成员变量在类中都是私有的,在类外是没有办法访问修改的。为了解决这个问题有以下几个解决方法
- 成员放公有(不推荐)
- 提供对应的getxxx函数
- 友元(后续章节介绍)
- 重载为成员函数
这里采用第三种方法,将运算符重载函数放到类中:

产生报错是因为成员函数都一个隐形的形参this指针,它指向类的某一个具体对象,且 this 不能显示传递,也不能显示写出,但是可以在函数内部显示使用.
然而operator<要求重载函数参数数量与运算符作用的运算符数目一样。但是由于为了使用类的成员变量将重载函数放在了类内部,所以编译器自动传递了对象的地址,并且在函数中使用一个 this 指针来接收,导致函数参数变成了三个,所以出现了 “此运算符的参数太多” 这个报错。
那么为了解决这个问题,在定义 运算符重载函数时,就只显式的传递一个参数 (即右操作数),而左操作数由编译器自动传递,当在函数内部需要操作左操作数时,也直接操作 this 指针即可;
所以需要对运算符重载函数和调用方式进行更改。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
bool operator<(const Date& d)
{
if (_year < d._year)
{
return true;
}
else if (_year == d._year
&& _month < d._month)
{
return true;
}
else if (_year == d._year
&& _month == d._month
&& _day < d._day)
{
return true;
}
return false;
}
private:
int _year;
int _month;
int _day;
}
int main()
{
Date d1(2024, 8, 9);
Date d2(2024, 8, 10);
bool ret2 = d1.operator<(d2);
// 转换成调用对应的运算符重载函数
bool ret3 = d1 < d2;
cout << ret2 << endl;//输出true
cout << ret3 << endl;//输出true
}
如上修改即可达成既可以使用私有成员变量,也可以正常时候重载后的运算符。
6.4.1.3 运算符重载函数的特点
不能通过连接语言中没有的符号来创建新的操作符:比如operator@。
重载运算符至少有一个类型型参数 (因为运算符重载只能对自定义类型使用)
**注意:**不能通过运算符重载改变内置类型对象的含义,如:
int operator+(int x, int y)。作为类类的成员函数重载时,其形参看起来比操作数数目少1,是因为成员函数的第一个参数为隐藏的 this。
.*(调用成员函数函数指针)、::(预作用限定符号)、sizeof、?:(三目运算符)、.(取类对象)注意以上5个运算符不能重载。运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。
6.4.1.4 常见的运算符重载
常见的运算符重载有:
operator+ (+)、operator- (-)、operator* (*)、operator/(/)、operator+= (+=)、operator-= (-=)、operator== (==)、operator= (=)、operator> (>)、operator< (<)、operator>= (>=)、operator<= (<=)、operator!= (!=)、operator++ (++)、operator-- (–)等;
其中,对于 operator++ 和 operator-- 来说有一些不一样的地方,因为 ++ 和 – 分为前置和后置,二者虽然都能让变量自增1,但是它们的返回值不同。但是由于 ++ 和 – 只有一个操作数,且这个操作数还会由编译器自动传递,所以正常的 operator++ 和 operator-- 并不能对二者进行区分。
前置++/后置++的实现
//前置++
Date& operator++()
{
*this += 1;
return *this;
//出了作用于*this还在,使用传引用返回,节省拷贝空间,提高效率
}
//后置++
Date operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp;
//出了作用域tmp空间被销毁,实际返回的是tmp的拷贝,使用传值返回
}
最终,C++规定:后置++/–重载时多增加一个int类型的参数,此参数在调用函数时不传递,由编译器自动传递。
6.4.1.5 流操作运算符重载
以下示例均在Date类中实现:
6.4.1.5.1 流插入操作符<<
在类域中重载:
void Date::operator<<(ostream& out)
{
out << _year << "年" << _month << "月" << _day <<"日" << endl;
}

在类域中定义了流插入运算符的重载函数,在主函数进行调用之后产生报错,并说明二元“<<”: 没有找到接受“Date”类型的右操作数的运算符(或没有可接受的转换),这是因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象(ostream类型的cout)。
所以此处的语句cout << d1,实际的含义是将d1这个Date类型的数据传给这里的ostream类型的数据,所以产生报错。
如果想运行这段代码应该改为d1 << cout,但是这样写不符合使用习惯和可读性。所以需要将流插入运算符重载为全局函数。
在全局域中重载:
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
但是如果在全局域中重载的话,需要获取类域中的私有成员变量,此处可以使用友元,在类域中将以下的函数声明添加进去,即可允许此函数访问类域中的私有的成员变量。
friend ostream& operator<<(ostream& out, const Date& d);
然后即可正常使用流插入运算符执行相应功能。

6.4.1.5.1 流提取操作符>>
流提取运算符也是同理,只是需要将数据类型从ostream改为istream即可。
//友元声明
friend istream& operator>>(istream& in, Date& d);
//函数实现
istream& operator>>(istream& in, Date& d)
{
cout << "请输入年 月 日:>";
in >> d._year >> d._month >> d._day;
return in;
}

6.4.2 赋值运算符重载
6.4.2.1 基础知识
赋值运算符重载是一个默认成员函数,用于完成两个已存在对象直接的拷贝运算。
这里要注意跟拷贝构造函数区分,拷贝构造用于一个对象拷贝初始化给另一个要创建的对象。
赋值运算符重载的特点:
- 赋值重载的格式规范。
- 赋值运算符只能重载成类的成员函数不能重载成全局函数。
- 若未显式定义,编译器会生成默认的赋值重载函数。
- 默认的赋值重载函数对内置类型以字节为单位直接进行浅拷贝,对自定义类型调用其自身的赋值重载函数。
6.4.2.2特点分析 – 格式要求
使用引用作参数,并以const修饰
使用传值传参时函数形参是实参的一份临时拷贝,所以传值传参还会调用拷贝构造函数。使用引用传参的时候,形参是实参的别名,减少了调用拷贝构造函数在时间和空间上的消耗。
另外,赋值重载只会改变被赋值对象,而不会改变赋值对象,所以使用 const 来防止函数内部的误操作。
void operator=(const Date& d);
若有返回值也推荐写成传引用返回
在程序的编写中程序员可以对内置类型进行连续赋值,比如 int i,j; i = j = 0;
那么对于自定义类型来说,也可以使用运算符重载来让其支持连续赋值,所以赋值运算符重载函数就必须具有返回值。同时重载函数的执行是基于一个已经存在于重载函数外部作用域的对象进行的(重载函数基于便令obj1执行,但是obj1并不是在重载函数中),所以重载函数调用结束后该对象(obj1)仍然存在,那么就可以使用引用作为函数的返回值,从而减少一次返回值的拷贝,提高程序效率。
Date& operator=(const Date& d);
Date 类的赋值重载函数如下:
//赋值重载
Date& operator=(const Date& d)
{
//自我赋值
if (this == &d)
{
return *this;
}
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}

6.4.2.3 特点分析 – 重载为成员函数
基于特点第2点:赋值运算符只能重载成类的成员函数不能重载成全局函数。
这是因为赋值重载函数作为六个默认成员函数之一,如果不显示实现,编译器会默认生成。此时用户如果再在类外自行实现一个全局的赋值运算符重载,就会和编译器在类中生成的默认赋值运算符重载冲突,从而造成链接错误。
6.4.2.4 特点分析 – 深浅拷贝
基于特点第3条:默认的赋值重载函数对内置类型以字节为单位直接进行浅拷贝,对自定义类型调用其自身的赋值重载函数。
赋值重载函数的特性和拷贝构造函数非常类似,如果没有显示定义赋值重载函数,编译器就会自动生成,自动生成的赋值重载重载函数,对与内置数据类型会执行浅拷贝操作,对于自定义类型会去调用自身的赋值重载函数。
Date类:
所以对于没有资源申请的类来说,我们不用自己去写赋值重载函数,直接使用默认生成的即可,因为这种类只需要进行浅拷贝 (值拷贝),比如 Date 类:

注意:
Date d2 = d1;是拷贝构造,用于一个已经存在对象,拷贝初始化给另一个要创建的对象。
d3 = d1;是赋值拷贝,用于完成两个已经存在的对象的直接拷贝。
Stack类:
而对于有资源申请的类来说,如果依靠编译器默认生成的赋值重载函数,其主要进行的是浅拷贝,会出现各种问题,所以必须自己手动实现赋值重载函数,来完成深拷贝工作。

如图:这里的情况和 Stack 默认析构函数的情况很类似,但是比它要严重一些。自动生成的赋值重载函数进行浅拷贝,使得 st1._a 和 st2._a 指向同一块空间,而 st1 和 st2 对象销毁时编译器会自动调用析构函数,导致 st2._a 指向的空间被析构两次;同时,st1._a 原本指向的空间并没有被释放,所以还发生了内存泄漏。
所以,对于有资源申请的类都需要显式定义赋值重载函数。Stack 类的赋值重载函数如下:
//赋值重载
Stack& operator=(const Stack& st)
{
//先释放左操作数栈的指向的空间
free(_a);
//在开辟和右操作数栈的指向的空间大小一样的空间给左操作数的栈
_a = (int*)malloc(sizeof(int) * st._capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
memcpy(_a, st._a, sizeof(int) * st._capacity);
_top = st._top;
_capacity = st._capacity;
return *this;
}
详细解释:这里先释放左操作数栈的指向的空间,而不是直接针对被赋值栈(左操作数栈)进行扩容是为了防止内存泄漏。因为如果不释放被赋值栈所指向的空间,而是直接使用realloc对被赋值栈的空间进行调整,就会存在下面两种情况。情况一是realloc直接在被赋值栈的原地址出进行空间调整;情况二是realloc在新的地址调整被赋值栈的空间,这样的话被赋值栈的原地址就没有被释放,就存在内存泄漏的问题。所以为了避免情况二的发生,这里选择开始直接对被赋值栈的空间进行释放再使用malloc重新开辟。
现在为 Stack 类显示定义了赋值重载函数,可以再来运行一个新的测试用例:

发现,当我们使用 st2 自己给自己赋值时,st2._a 中的数据变成了随机值。
原因如下:operator= 函数首先会将 st2._a 指向的空间释放,然后再为其申请新空间,但是由于 st2 自己给自己赋值,所以使用 memcpy 拷贝的是新开辟的空间中的数据,即为随机值。

所以还需等会上面实现的栈的赋值重载函数进行修改,要让其首先检查自我赋值。
Stack& operator=(const Stack& st) //赋值重载
{
//自我赋值
if (this == &st)
{
return *this;
}
free(_a);
_a = (int*)malloc(sizeof(int) * st._capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
memcpy(_a, st._a, sizeof(int) * st._capacity);
_top = st._top;
_capacity = st._capacity;
return *this;
}
MyQueue类:
和拷贝构造一样,并不是说只要有资源申请就必须写赋值重载函数,比如 MyQueue 类,不显示定义编译器调用默认生成的赋值重载函数,而默认生成的对于自定义类型会去调用它们自身的赋值重载函数。
6.4.2.5 总结
像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的默认运算符重载就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型成员,编译器自动生成的默认运算符重载会调用Stack的默认运算符重载,也不需要显示实现拷贝构造。
由编译器默认生成的赋值重载函数对成员变量的处理规则和析构函数一样,对内置类型以字节方式按值拷贝,对自定义类型调用其自身的赋值重载函数。可以理解为:需要写析构函数的类就需要写赋值重载函数,不需要写析构函数的类就不需要写赋值重载函数;
6.5 取地址运算符重载
6.5.1 const成员函数
- C++中将 const 修饰的 “成员函数” 称之为 const 成员函数。const修饰成员函数放在成员函数参数列表的后面。
- const 修饰类成员函数实际上修饰该成员函数隐含的 this 指针所指向的对象,表明在该成员函数中不能对 this 指向的类中的任何成员变量进行修改。
示例:
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 7, 5);
//可以正常调用
d1.Print();
const Date d2(2024, 8, 5);
//无法正常调用,会报错
d2.Print();
return 0;
}

如上代码运行,d1因为没有被const修饰所以可以正常运行,但是d2因为被const调用会有报错。
**报错的原因:**类成员函数的第一个参数默认是 this 指针,而 this 指针的类型是
Date* const,而第一个参数即 d2 的类型是const Date*;将一个只读变量赋值给一个可读可写的变量时权限扩大,导致编译器报错。**注意:**成员函数默认第一个参数为
Date* const this,这里的const别放在*号后面,修饰的是this本身,表示this不能被修改,而this指向的内容即d2可以被修改。只有const修饰指向内容的时候才设计权限放大缩小的问题。
**解决方法:**为了解决上面这个问题,C++ 允许定义 const 成员函数,即在函数最后面使用 const 修饰,该 const 本质只修饰函数的第一个参数this,即使得 this 指针的类型变为 **const Date* const**类型。
将成员函数的 this 指针类型修饰为 const Date* const 后,不仅 const Date 的类型的对象可以调用相应成员函数;正常的 Date 类型的对象也可以调用,因为权限虽然不能扩大,但能缩小。
void Print() const
//本质: void Print(const Date* const this) const
//第一个const:保证 this 指向的内容不被改变,缩小了权限
//第二个const:保证 this 指针本身内容(地址)不被改变
注意:
在成员函数后添加了 this 之后不管对象有没有被 const 修饰均可以调用此成员函数,看似好像增强了普适性,但是并不可以所有成员函数都加上 const 。因为这里也是付出了一定代价的,成员函数加上了 const 之后,其中如果需要调用类中的成员变量就无法改变,因为这些成员变量都是通过 this 调用,但是 this 因为被 const 修饰,其地址中的成员变量也就无法改变了。
总结:
const 修饰成员函数应该是应加尽加,因为这样不仅普通对象可以调用此成员函数,被 const 修饰的对象也可以调用此成员函数;而且还可以保证对象中的成员变量不会被修改更加安全。
6.5.2 取地址运算符重载和const取地址重载
const 取地址重载和const取地址重载也是C++的默认六个成员函数之二,它是重载函数,其作用是返回对象的地址。
如果没有显式定义取地址重载和 const 取地址重载函数,那么编译器会自动生成,因为这两个默认成员函数十分固定,所以大多数情况下直接使用编译器默认生成的即可,不必自己定义。
Date* operator&()
{
return this;
}
const Date* operator&()const
{
return this;
}

在某些极少数的特殊情况下需要自己实现取地址重载与 const 取地址重载函数,比如不允许获取对象的地址,那么在函数内部直接返回 nullptr 即可:
//取地址重载
Date* operator&()
{
return nullptr;
}
//const 取地址重载
const Date* operator&() const
{
return nullptr;
}

7. Date日期类的实现
在了解了上面四个类的默认成员函数之后,现在手动实现一个完整的日期类来加强对四个默认成员函数的认识。
其中在日期类的实现中分为Date.c和Date.h分别进行实现,这样设计便于模块化管理。
补充:原来的六个默认成员函数中的移动构造函数和移动赋值运算符重载函数是在C++11及以后版本引入的,放在后面的文章进行介绍。
7.1 成员变量和成员函数
//Date.h
#pragma once
#include<iostream>
#include<assert.h>
class Date
{
public:
//构造函数
Date(int year = 1900, int month = 1, int day = 1);
//打印函数
void Print() const;
// 日期的大小关系比较
bool operator<(const Date& d) const;
bool operator>(const Date& d) const;
bool operator<=(const Date& d) const;
bool operator>=(const Date& d) const;
bool operator==(const Date& d) const;
bool operator!=(const Date& d) const;
// 日期+=天数
Date& operator+=(int day);
// 日期+天数
Date operator+(int day) const;
// 日期-=天数
Date& operator-=(int day);
// 日期-天数
Date operator-(int day) const;
// 前置++
Date& operator++();
// 后置++
Date operator++(int);
// 前置--
Date& operator--();
// 后置--
Date operator--(int);
// 日期-日期
int operator-(const Date& d) const;
private:
int _year;
int _month;
int _day;
};
补充:
- 对于日期类的析构,拷贝构造,赋值重载可以不写,直接用默认生成的即可
7.2 构造函数
进入构造函数,对日期进行初始化,首先对日期的合法性进行检查,之后当日期合法之后再进行后续操作。
//Date.c
// 获取月份天数函数
inline int GetMonthDay(int year, int month) const
{
// 数组存储平年每个月的天数
static int dayArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
int day = dayArray[month];
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
{
//闰年2月的天数
day = 29;
}
return day;
}
// 构造函数
Date::Date(int year, int month, int day)
{
// 检查日期的合法性
if (year >= 0
&& month >= 1 && month <= 12
&& day >= 1 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
// 严格来说抛异常更好
cout << "非法日期" << endl;
cout << year << "年" << month << "月" << day << "日" << endl;
}
}
补充:
- GetMonthDay函数中的三个细节:
- 该函数在后面会被多次调用,所以将其设置为内联函数,提高效率。
- 函数中存储每月天数的数组是用static修饰,存储在静态区,避免每次调用该函数都需要重新开辟数组,提高程序运行效率。
if判断逻辑中逻辑与中先判断month == 2是否为真,因为当不是2月的时候就不必判断是不是闰年。从逻辑层面减少不必要的逻辑判断,提高程序效率。
- 由于构造函数是在源文件中定义的,所以需要再函数名前写上类域名进行限制。
- 由于此处构造函数的声明在头文件中,定义在源文件中,所以在声明时注明缺省参数即可,定义时不能标出缺省参数。
7.3 打印函数
按结构打印出此时的日期在屏幕上,便于程序的测试。
//Date.c
//打印函数
void Date::Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
7.4 日期大小关系比较
虽然其中涉及到六个关系运算符,但是并不需要一一实现,只需要实现一个< 和一个 == 即可,其他情况直接对这两个进行复用即可实现功能。
注意:进行日期的大小比较,并不会改变传入对象的值,所以这6个运算符重载函数都应该被const所修饰。
//Date.c
bool Date::operator<(const Date& d) const
{
if (_year < d._year)
{
return true;
}
else if (_year == d._year
&& _month < d._month)
{
return true;
}
else if (_year == d._year
&& _month == d._month
&& _day < d._day)
{
return true;
}
return false;
}
bool Date::operator==(const Date& d) const
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
//复用<=
bool Date::operator>(const Date& d) const
{
return !(*this <= d);
}
//复用<和==
bool Date::operator<=(const Date& d) const
{
return *this < d || *this == d;
}
//复用<
bool Date::operator>=(const Date& d) const
{
return !(*this < d);
}
//复用==
bool Date::operator!=(const Date& d) const
{
return !(*this == d);
}
7.5 日期 += 天数
对于+=运算符,先将需要加的天数加到日上面,然后判断日期是否合法,若不合法,则通过不断调整,直到日期合法为止。
调整日期的思路:
- 若日已满,则日减去当前月的天数,月加一
- 若月已满,则将年加一,月重新置为1
循环执行1和2,直到日期合法为止。
//Date.c
// 日期+=天数
Date& Date::operator+=(int day)
{
if (day < 0)
{
return *this -= -day;
}
_day += day;
while(_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month > 12)
{
_year++;
_month = 1;
}
}
return *this;
}
补充:
- +=运算符的重载函数采用的是引用返回,因为出了函数作用域,this指针指向的对象没有被销毁。
- 补充
_day小于0的逻辑,复用-=运算符即可。
7.5 日期 + 天数
对于+运算符的重载,可以复用上面已经实现的+=运算符的重载函数。
注意:虽然返回的是加了之后的值,但是对象本身的值并没有改变。就像a = b + 1,b + 1的返回值是b + 1,但是b的值并没有改变。所以这里需要保证执行+之后的*this中的日期不能改变。
同时用 const 对该函数进行修饰,防止函数内部改变了 this 指针指向的对象。
//Date.c
// 日期+天数
Date Date::operator+(int day) const
{
//利用拷贝构造,用于返回
Date tmp(*this);
tmp += day;
return tmp;
}
补充:
- +运算符的重载函数的返回值只能是传值返回,因为出了函数作用域,对象tmp就被销毁了,不能使用引用返回。
7.6 日期 -= 天数
对于-=运算符,先用日减去需要减的天数,然后判断日期是否合法,若不合法,则通过不断调整,直到日期合法为止。
调整日期的思路:
- 若日为负数,则月减一
- 若月为0,则年减一,月置为12
- 日加上当前月的天数
循环执行1、2和3,直到日期合法为止。
//Date.c
// 日期-=天数
Date& Date::operator -= (int day)
{
if (day > 0)
{
return *this += -day;
}
_day -= day;
while (_day < 0)
{
_month--;
if (_month == 0)
{
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
7.6 日期 - 天数
对于-运算符,重载思路和+一样,复用上面已经实现的-=即可。
//Date.c
// 日期-天数
Date Date::operator-(int day) const
{
Date tmp(*this);
tmp -= day;
return tmp;
}
补充:
- 同样
-运算符的重载函数的返回值只能是传值返回,也是由于-运算符重载函数中的tmp对象出了函数作用域被销毁了,所以不能使用引用返回。
7.7 前置++
对于前置++,仍可以复用+=运算符的重载函数。
//Date.c
// 前置++
Date& Date::operator++()
{
*this += 1;
return *this;
}
7.8 后置++
由于前置++和后置++的运算符均为++,为了区分它们的运算符重载,给后置++的运算符重载的参数加上一个int型参数,使用后置++时不需要给这个int参数传入实参。
//Date.c
// 后置++
Date Date::operator++(int)
{
Date tmp(*this);// 拷贝构造tmp,用于返回
*this += 1;
return tmp;
}
7.9 前置–
对于前置-,仍可以复用-=运算符的重载函数。
//Date.c
// 前置--
Date& Date::operator--()
{
// 复用operator-=
*this -= 1;
return *this;
}
7.10 后置–
对于后置–需要注意的事项和后置++是一样的,不做过多解释。
//Date.c
// 后置--
Date Date::operator--(int)
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator-=
*this -= 1;
return tmp;
}
7.11 日期 - 日期
日期 - 日期,即计算传入的两个日期相差的天数。
如果要实现这个目标是没有办法直接将这两个日期相减,因为直接相减得到的年份、月份所对应的具体天数是没有办法确定的。
所以只需要让较小的日期的天数一直加一,直到最后和较大的日期相等即可,这个过程中较小日期所加的总天数便是这两个日期之间差值的绝对值。若是第一个日期大于第二个日期,则返回这个差值的正值,若第一个日期小于第二个日期,则返回这个差值的负值。
//Date.c
// 日期-日期
int Date::operator-(const Date& d) const
{
Date max = *this;// 假设第一个日期较大
Date min = d;// 假设第二个日期较小
int flag = 1;// 表示最终结果应该为正数
if (*this < d)
{
// 假设错误,更正
max = d;
min = *this;
flag = -1;// 表示最终结果应该为负数
}
int n = 0;// 记录所加的总天数
while (min != max)
{
min++;// 较小的日期++
n++;// 总天数++
}
return n*flag;
}
8. 初始化列表
8.1 问题引入
在前文中学习了构造函数,其用于对对象进行初始化。即在创建对象时,编译器会自动调用构造函数,给对象中各个成员变量一个合适的初始值。按照前文的格式定义构造函数并且调用之后,对象的成员变量中已经有了一个初始值,但是这不能将其称为对对象进行初始化。构造函数函数体中的语句只能将其称为赋初值,而不能称作初始化;因为初始化只能初始化一次,而构造函数体内可以进行多次赋值。
赋值和初始化的区别:
- **初始化:**发生在对象的声明周期开始时,是对象创建过程的一部分。初始化发生在进入构造函数之前。
- 对于类类型,这意味着调用调用构造函数来建立对象的初始状态。
- 对于内置类型,这意味着为变量赋予一个初始值。
- **赋值:**发生在变量已经存在之后,通过赋值运算符(
=)改变对象的状态。赋值发生在函数体内部。
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;// 第一次赋值
_year = 2022;// 第二次赋值
//...
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
如上就会对成员变量 _year 进行赋值,但是其中涉及了变量的多次赋值,这是赋值并不是初始化。所以在构造函数中没有办法真正的实现初始化。
C++类对象中的成员变量在初始化列表处进行定义与初始化。
**初始化列表的格式:**初始化列表以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
class Date
{
public:
Date(int year = 1970, int month = 1, int day = 1)
: _year(year) //初始化列表
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};

8.2 初始化列表的意义(重要)
因为前文中一直使用的构造函数用来实现成员变量的 “初始化” (本质就是通过赋值改变变量的最终状态罢了),但是这种 ”初始化“ 是存在缺陷的。
-
缺陷一:
正如上面一直强调的在构造函数中的 “初始化” 一直都是赋值操作罢了,但是对于一些内置类型的成员变量,其在定义的时候就会经历默认初始化(如果是局部变量,值是不确定的;如果是全局/静态变量,值是零)。所以在构造函数中对这些成员变量赋值会覆盖初始值,从而达成所谓的初始化。
但是对于引用成员变量、const成员变量,这两种变量要求必须在定义的时候级初始化并且后续不可以更改,这并不符合构造函数中的 “赋值初始化” 思想。所以这两种变量必须在初始化列表中定义初始化。
-
缺陷二:
并且C++还规定了进入一个对象的构造函数的前提是对象所包含的所有成员必须都已经被初始化过。
这个规定对于内置类型并无影响因为其在定义的时候会被比编译器默认初始化。但是针对无默认构造的类类型成员变量来说,因为其没有默认构造,也因为这个规定导致无法进入构造函数进行初始化。所以其也只能放在初始化列表进行初始化。
因为以上这两个缺陷所以引出了初始化列表这个概念,初始化列表能够确保成员变量在对象构造时以最直接、高效的方式被正确初始化,使得代码具有一致性,提高了效率。
补充:构造函数的本质作用
所以其实构造函数本质是在在初始化列表执行之后才开始执行的。其本质作用是用于对象构建完成后的进一步设置、资源分配和复杂逻辑处理。初始化列表和构造函数是对象构造过程中前后相继的两个阶段,共同完成对象的创建。
8.3 初始化列表的特点
- 初始化列表是每个成员变量定义和初始化的地方,所以无论是否在初始化列表处写每个成员变量 (内置类型和自定义类型) 都一定会走初始化列表。
其中每个成员变量在初始化列表中只能出现一次。
成员变量如果没有在初始化列表写,那么对于内置类型的成员变量,编译器会使用随机值来初始化,对于自定义类型的成员变量,编译器会调用自定义类型的默认构造函数来初始化,如果没有默认构造编译器就会报错。
引用成员变量、const成员变量和没有默认构造的类类型变量,必须放在初始化列表进行初始化,否则会报错。
成员变量初始化尽量都是用初始化列表初始化,因为无论否使用初始化列表,对象的成员变量都会先使用初始化列表进行初始化。
成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
C++11中对于内置类型打的补丁 – 内置类型成员变量可以在声明的时候给定一个缺省值,其在初始化列表处起作用,作为备选参与初始化。
8.3.1 特点分析 – 初始化列表的定义
本条分析,基于初始化列表的特点中的第一点。


对于内置类型 _two,如果没有显式在初始化列表初始化,编译器会使用随机值来初始化;而对于自定义类型 aa,如果没有显式定义编译器会调用自定义类型的默认构造函数来初始化,因为上面的A无默认构造函数,发生报错。
8.3.2 特点分析 – 必须使用初始化列表的变量
本条分析,基于初始化列表的特点中的第二点。
一般情况下在构造函数中使用赋值操作对成员变量一个个赋值初始化,和使用初始化列表在函数外进行初始化都是可以的。但是以下三种成员变量(引用成员变量、const成员变量和没有默认构造的类类型成员变量)必须要使用初始化列表在构造函数外进行初始化才可以。
引用成员变量和const成员变量:

这是因为引用是一个变量的别名,它必须在定义的时候初始化,并且一旦引用了一个变量就不能再去引用另一个变量;同样,const 作为只读常量,也必须在定义的时候初始化,且初始化之后不能在其他地方修改。所以都无法在构造函数内通过赋值进行初始化,只可以在构造函数外通过初始化列表进行初始化。

没有默认构造的类类型成员变量:
class Stack
{
public:
Stack(int capacity)//含参构造,无默认构造
:_top(0)
, _capacity(capacity)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
}
private:
int* _a;
int _top;
int _capacity;
};
class MyQueue
{
public:
MyQueue()
{}
private:
Stack _pushST;
Stack _popST;
};
int main()
{
MyQueue mq1;
return 0;
}

这里的 Stack 类提供的是带参构造,并没有给缺省值,所以此时不会调用Stack的构造函数初始化变量 _pushst 和 _popst ,因为在进入MyQueue的构造函数之前 _pushst 和 _popst 没有初始化,所以编译器会直接报错。

在初始化列表中进行初始化即可成功初始化并成功运行程序。
8.3.3 特点分析 – 所有变量都会经过初始化列表
本条分析,基于初始化列表的特点中的第三点。
例如如下 MyQueue 类 (此处的 Stack 具有默认构造函数):

如上,这里显式定义的构造函数什么也没有写,_pushST 和 _popST 也完成了初始化工作。
所以无论是否在初始化类比处显示写,类的成员变量都会走初始化列表,其中类的自定义类型会调用它的默认构造来完成初始化工作,如果没有默认构造则会报错;如果是内置类型则会由编译器自动进行初始化操作。
8.3.4 特点分析 – 初始化列表的初始化顺序
本条分析,基于初始化列表的特点中的第四点。
面试题:
class A
{
public:
A(int a)
:_a1(a)
, _a2(_a1)
{}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
private:
int _a2;
int _a1;
};
int main()
{
A aa(1);
aa.Print();
return 0;
}

由于在类中 _a2 的声明在 _a1 之前,所以在初始化列表处 _a2(_a1) 语句被先被执行,而此时 _a1 还是一个随机值,所以最终 _a2 输出随机值。
为了避免这种错误,所以建议声明顺序和初始化列表顺序保持一致。
8.3.5 特点分析 – C++11的补充:内置类型的声明缺省值
本条分析,基于初始化列表的特点中的第五点。
因为在C++中对于内置类型的默认初始化是不确定的,所以如果没有在初始化列表中初始化内置类型的成员变量的话,其中的值是不确定的,就和没有初始化一样。
所以C++11引入了一个新的方法,可以在变量声明的时候给一个缺省值,如果在初始化列表没有对此变量进行初始化,则会自动调用此缺省值进行初始化。

8.4 初始化列表总结
- 无论是否显示写初始化列表,每个构造函数都有初始化列表。
- 无论是否在初始化列表显示初始化成员变量,每个成员变量都要走初始化列表初始化。

有1先走1,没有1再走2。
9. 隐式类型转换
对于转换任何数据类型的转换的前提就是这两种数据类型是有关联的,例如下面的示例就是将内置类型转换成自定义类型,但是这二者之间能够转换的前提也是要保证自定义类型的构造函数的参数是内置类型,这样就构建起了联系。
9.1 问题引入
隐式类型转换是指当两个不同类型的变量之间进行运算时,编译器会自动将其中一个变量的类型转换为另一个变量的类型。比如:
int main()
{
int a = 0;
double b = a;
const double& rb = a;
const int& c = 1;
}
解释:
- 第4行:将
int的a赋值给double的b,则a不会被直接赋值给b,编译器会先根据a创建一个double类型的临时变量,然后将这个临时变量中的值赋值给b。- 第5行:对于
rb这个double类型的引用来说也是一样的,只不过rb是引用类型,而引用和指针需要考虑权限的问题,所以用引用类型的变量rb去引用 a 生成的临时变量需要使用const修饰 (临时变量具有常性)。- 第6行:由于数字
1只存在于指令中,在内存中并不占用空间,所以当我们对其进行引用时,1会先赋给一个临时变量,然后再对这个临时变量进行引用;同时由于临时变量具有常性,所以需要使用const修饰;
9.2 构造函数的类型转换
class Date
{
public:
//构造函数
Date(int year)
:_year(year)
{
cout << "Date 构造" << endl;
};
//拷贝构造
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
cout << "Date 拷贝构造" << endl;
}
private:
int _year;
int _month;
int _day;
};


如上,对于具有单参构造函数的 Date 类,不仅可以使用构造和拷贝构造的方式来实例化对象,还可以通过直接赋值一个整数来实例化对象。但是这其实是隐式类型转换的结果。
解释:
d3:由于2025和d3的类型不同,所以编译器会进行类型转换,即先使用2025来构造一个临时的Date对象,然后用这个临时对象来对d3进行拷贝构造,所以d3是构造+拷贝构造的结果。但是现在的编译器已经对这种情况进行了优化,不再创建临时的Date对象,而是直接使用2025来构造d3,所以看到的现象是创建d3没有调用拷贝构造函数而是直接调用构造函数。而在老版的编译器下是会调用拷贝构造函数的,比如VC 6.0、VS2003等编译器。
d4:d4是Date对象的引用,所以编译器会先用2025来构造一个Date类型的临时对象,然后d4再对这个临时对象进行引用,所以只会调用一次构造函数。同时由于临时对象具有常性,所以需要使用const修饰。
**注意:**单参构造函数并不是指只能有一个参数,而是指在调用时只传递一个参数,所以半缺省和全缺省的构造函数也是可以的。
补充: C++11对上述语法进行了拓展,支持多参数的构造函数,只是传递参数时多个参数之间需要使用花括号,如下:

9.3 explicit 关键字
explicit 关键字用于修饰构造函数,其作用是禁止构造函数的隐式类型转换,如下:
class Date
{
public:
//构造函数
explicit Date(int year = 1970, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date 构造" << endl;
};
//拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
cout << "Date 拷贝构造" << endl;
}
private:
int _year;
int _month;
int _day;
};

9.4 类型转换的意义
构造函数的类型转换在一些特定的地方有着很大的意义。
比如要在一个顺序表尾插一个元素,而这个元素是 string 类型的对象,如下:
int main()
{
string s1("hello");
push_back(s1);
push_back("hello");
}
如上,有了隐式类型转换在插入一个 string 对象时就不必先去构造 s1,然后再传递 s1;而是直接使用 “hello” 做参数即可,其会自动转化为 string 类型。
同时要注意这里的成员函数push_back 的参数需要使用 const 修饰,因为隐式类型转换的时候会创建一个 string 类型的临时变量暂存这里的 hello ,而且临时变量具有常性,需要使用 const 修饰。
有了隐式类型转换,当自定义类型需要进行传参的时候更加方便,不需要采用那种常规的先实例化对象,再通过对象进行传参了,可以直接将内置类型作为参数进行传递。
10. static成员
10. 1 问题引入
面试题:实现一个类,计算程序中创建出了多少个类的对象。
类创建对象一定会调用构造函数或者拷贝构造函数,定义一个静态成员变量,但是因为成员变量的私有性,还需要再写一个获取成员变量的成员函数 GetACount ,然后在构造函数和拷贝构造函数中让调用 GetACount 让静态成员变量其自增即可,代码如下:
class A
{
public:
//构造函数
A()
{
_scount++;
}
//拷贝构造函数
A(const A& a)
{
_scount++;
}
//获取私有成员变量的函数
static int GetACount()
{
return _scount;
}
private:
//在类里面定义
static int _scount;
};
//类外面初始化
int A::_scount = 0;

但是这里为什么需要采用这种静态成员变量和静态成员函数的方法,请看下面static成员的特点分析。
10.2 static成员的特点
10.2.1 static成员变量
static成员变量:
静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区。
静态成员变量必须在类外定义初始化,定义初始化时不添加
static关键字,类中只是声明。同时静态成员变量不能在声明位置给缺省值初始化。静态成员变量的访问受类域与访问限定符的约束。
10.2.1.1 特点分析 – 静态成员变量的位置
由于静态成员变量在静态区 (数据段) 开辟空间,并不在对象里面,所以它不属于某单个对象,而是所有对象共享。

如上图可以直接通过类名+域作用限定符来访问成员变量,这说明_n并不存在于对象里面。
10.2.1.1 特点分析 – 静态成员变量的初始化
用static修饰的成员变量,称之为静态成员变量,静态成员变量一定要在类外进行初始化。
静态成员变量不能再声明位置给缺省值初始化,因为缺省值是构造函数初始化列表的,静态成员变量不属于某个对象,所以不走构造函数初始化列表。

10.2.1.3 特点分析 – 静态成员变量的约束
静态成员变量在访问时和普通的成员变量区别不大,同样受类域和访问限定符的约束,只是因为其不存在于对象中,所以可以通过 A:: 来直接访问。
所以可以将静态成员变量理解为将全局的静态放到类里面去,并且变成类域的专属,还会受到类域和访问限定符的约束,因为其本质是私有的,在类外不可以随意访问。但是其因为在静态区所以其生命周期也是全局的,但是因为在类域中所以会被限制。


10.2.2 static成员函数
static成员函数:
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员。
- 静态成员也是类的成员,同样受类域和访问限定符的约束。
10.2.2.1 特点分析 – 静态成员函数的性质
由于静态成员函数没有隐藏的 this 指针,所以在调用的时候自然也就不需要传递对象的地址,即可以通过类名+域作用限定符直接调用,而不需要先创建对象,再通过对象. 函数名的方式调用函数。
但是相应的,没有了 this 指针也无法去调用非静态的成员变量与成员函数,因为非静态成员变量需要实例化对象来开辟空间,非静态成员函数的调用则需要传递对象的地址。
**注意:**虽然静态成员函数函数不可以调用非静态成员,但是非静态成员函数是可以调用静态成员的 (调用静态成员时编译器不传递对象地址即可)。

10. 3 面试题
10.3.1 例题一
题目链接:求1+2+3+…+n_牛客题霸_牛客网
题目描述:求1+2+3+…+n,要求不能使用乘除法、for、while、if、else、switch、case等关键字及条件判断语句(A?B:C)。
class Sum
{
public:
Sum()
{
_ret += _i;
++-i;
}
static int GetRet()
{
return _ret;
}
private:
static int _i;
static int _ret;
};
int Sum::_i = 1;
int Sum::_ret = 0;
class Solution
{
public:
int Sum_Solution(int n)
{
//变长数组
Sum arr[n];
rerurn Sum::GetRet();
}
};
解释:
-
Sum类(用于实现一次等差数列的求和)
-
成员变量:
_i:等差数列的数据,初始化为1。_ret:用于累计数据和,初始化为0。 -
成员函数:
构造函数:每次创建一个对象都执行一次等差数据的累加逻辑。
静态成员函数:用于获取私有的静态成员变量
_ret。
-
-
Solution类(用于实现n次等差数列的求和)
-
成员函数
普通成员函数:在函数中创建一个大小为n,类型为Sum类的变长数组,相当于创建了n个Sum类类类型的对象,每次创建都会调用上面的构造函数,最后创建好了n个之后,
_ret中的值也就是1~n的数据的和,因为_ret是静态的,并不属于某个单独的类的对象,而是所有Sum对象共享同一份_i和_ret,从而能够实现累加计数。
-
10.3.2 例题二
题目描述:
设已经有A, B, C, D 4个类的定义,程序中A, B, C, D 构造函数的调用顺序为?
设已经有A, B, C, D 4个类的定义,程序中A, B, C, D 析构函数的调用顺序为?
C c; int main() { A a; B b; static D d; return 0; }
答案:
构造函数调用顺序:C, A , B, D
析构函数调用顺序:B, A, D , C
解释:
- 构造函数
- 首先
c为全局变量,先与主函数被构造。 - 对于主函数内的顺序,则记住一个规则局部的静态变量在第一次走到那里的时候才会定义初始化,因为这里
d在a、b后面故最后构造。 - 对于
a、b则是按照顺序进行初始化构造。
- 首先
- 析构函数
a、b作为主函数中的局部变量一定是先析构,根据规定,先构造的后析构。所以这里第一个析构的是b之后是a。c、d分别作为全局变量和静态变量一定是在主函数结束才会析构。然后还有一个规定:局部的静态变量会先于全局变量析构。所以第三个析构的是d之后是c。
11. 友元
前面在介绍流插入和流提取这两个运算符的重载函数的时候,有介绍有关友元的相关知识点,在这里就以这个为问题引入,对友元进行详细介绍。
11.1 问题引入
在C++中,我们使用 cin 和 cout 配合流插入 >> 与流提取 << 符号来完成数据的输入输出,并且它们能自动识别内置类型。

那么它们是如何做到输入与输出数据以及自动识别内置类型的呢?答案就是通过运算符重载与函数重载。



可以看到,cin 和 cout 分别是 istream 和 ostream 类的两个全局对象,而 istream 类中对流提取运算符 >> 进行了运算符重载,osteam 中对流插入运算符 << 进行了运算符重载,所以 cin 和 cout 对象能够完成数据的输入输出。同时,istream 和 ostream 在进行运算符重载时还进行了函数重载,所以其能够自动识别数据类型。
那么,对于自行定义的自定义类型,也可以对 << 和 >> 进行运算符重载,使其支持输入与输出数据。这里以Date为例:
class Date
{
public:
Date(int year = 1970, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
//流插入
ostream& operator<<(ostream& out) const
{
cout << _year << "/" << _month << "/" << _day;
return out;
}
//流提取
istream& operator>>(istream& in)
{
in >> _year;
in >> _month;
in >> _day;
return in;
}
private:
int _year;
int _month;
int _day;
};
但是这里有一个问题:如果运算符重载为类的成员函数,那么运算符的左操作数必须是本类的对象,因为 this 指针的类型是本类的类型,也就是说,如果要把 << 和 >> 重载为类的成员函数,那么本类的对象 d 就必须是做操作数,即必须像下面这样调用:
int main()
{
Date d;
d >> cin;
d << cout;
}
但是这样代码的可读性很低,很可能会给函数的使用带来很大的困扰。所以对于 << 和 >> 只能重载为全局函数;
但是重载为全局函数又会出现一个新的问题:在类外部无法访问类的私有数据。但是又不可能将类的私有数据改为共有,这样并不安全,所以这里推荐使用友元的方法。
11.2 友元的特点
11.2.1 友元函数
友元函数的特点:
- 友元函数可访问类的私有和保护成员,其仅仅是一种声明,但不是类的成员函数。
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制。
- 一个函数可以是多个类的友元函数。
- 友元函数的调用与普通函数的调用原理相同。
所以结合上面问题在这里利用友元函数的知识进行处理:
class Date
{
//友元声明 -- 可以放置在类的任意位置
friend istream& operator>>(istream& in, Date& d);
friend ostream& operator<<(ostream& out, const Date& d);
public:
Date(int year = 1970, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
//流插入
ostream& operator<<(ostream& out) const
{
cout << _year << "/" << _month << "/" << _day;
return out;
}
//流提取
istream& operator>>(istream& in)
{
in >> _year;
in >> _month;
in >> _day;
return in;
}
private:
int _year;
int _month;
int _day;
};
//流提取
inline istream& operator>>(istream& in, Date& d)
{
in >> d._year;
in >> d._month;
in >> d._day;
return in;
}
//流插入
inline ostream& operator<<(ostream& out, const Date& d)
{
cout << d._year << "/" << d._month << "/" << d._day;
return out;
}
运行结果

注意:
-
由于流插入和流提取的重载内容较少,且调用频率很高,所以可以把其定义为内联函数,提高代码效率。
-
为了支持连续输入以及连续输出,需要将函数的返回值设置为
istream和ostream对象的引用;
11.2.2 友元类
友元类的特点:
- 友元关系是单向的,不具有交换性。比如A类是B类的友元,但是B类不是A类的友元。
- 友元关系不能传递。如果C是B的友元, B是A的友元,则不能说明C是A的友元。
- 友元关系不能继承,继承的相关知识后续再进行介绍。
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的私有成员。示例代码如下:
class Time
{
//友元类
friend class Date;
public:
Time(int hour = 0, int minute = 0, int second = 0)
: _hour(hour)
, _minute(minute)
, _second(second)
{}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year = 1970, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
void SetTimeOfDate(int hour, int minute, int second)
{
// 可以直接访问Time类私有的成员变量
_t._hour = hour;
_t._minute = minute;
_t._second = second;
}
private:
int _year;
int _month;
int _day;
Time _t;
};

补充:
这里针对特点一进行补充。
上述 Time 类和 Date 类,在 Time 类中声明 Date 类为其友元类,那么可以在 Date 类中直接访问 Time 类的私有成员变量,但想在 Time 类中访问 Date 类中私有的成员变量则不行。
12. 内部类
12.1 概念理解
如果一个类定义在另一个类的内部,这个类就叫做内部类。
内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有访问权限。
class A
{
public:
A(int a = 0)
:_a(a)
{}
//内部类
class B
{
public:
B(int b = 0)
:_b(b)
{}
private:
int _b;
};
private:
int _a;
};
这里 b 作为 a 的内部类,在主函数中不可以直接调用,只能通过类名+域作用限定符的方法进行访问。
int main()
{
//创建一个B类型的对象
A::B b;
return 0;
}
12.2 内部类的特点
内部类的特点:
- 内部类天生就是外部类的友元。所以内部类可以通过外部类的对象参数来访问外部类中的所有成员;但外部类不是内部类的友元。
- 内部类定义在外部类的 public、protected、private 处都是可以的,但是内部类实例化对象时要受到外部类的类域和访问限定符的限制。
- 内部类是一个独立的类,它不属于外部类。所以
sizeof (外部类) == 外部类。
class A
{
public:
A(int a = 0)
:_a(a)
{}
//内部类
class B
{
public:
B(int b = 0)
:_b(b)
{}
void SetA(A& a, int n)
{
a._a = n;//内部类B是外部类A的友元,可以通过对象来访问外部类的私有成员变量
_i = 1;//内部类可以直接访问外部类的静态成员变量,不需要对象作为参数
}
private:
int _b;
};
private:
int _a;
static int _i;
};
int A::_i = 0;
13. 匿名对象
13.1 概念理解
在C++中,除了用类名+对象名创建对象外,还可以直接使用类名来创建匿名对象,匿名对象和正常对象一样,在创建时自动调用构造函数,在销毁时自动调用析构函数。
但是匿名对象的生命周期只有它定义的那一行,下一行就会立马销毁。
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A 构造" << endl;
}
~A()
{
cout << "A 析构" << endl;
}
private:
int _a;
};

13.2 应用场景
使用匿名对象方便直接调用成员函数:

使用匿名对象用于类类型形参给缺省值:

匿名对象可以引用,但是因为其具有常性,需要const修饰:

**注意:**但是使用const引用会延长匿名对象的生命周期,延长到跟着引用名一起。
如上,原来A()这个匿名对象应该在执行完这一行代码之后就销毁,但是因为是引用const,其会让匿名函数的生命周期和引用名r同步。
14. 对象拷贝时的编译器优化
现代编译器会为了尽可能提高程序的效率,在不影响正确性的情况下会尽可能减少一些传参和传回值的过程中可以省略的拷贝。
如何优化C++标准并没有严格规定,各个编译器会根据情况自行处理。当前主流的相对新一点的编译器对于连续一个表达式步骤中的连续拷贝会进行合并优化,有些更新更”激进”的编译器还会进行跨行跨表达式的合并优化。
如下是一些场景实现的前置准备,包括A类中的构造函数、拷贝构造、赋值运算符重载和析构函数,还有一些类的传值传参和传值返回的实例函数。
class A
{
public:
//构造函数
A(int a = 0)
:_a(a)
{
cout << "A 构造" << endl;
}
//拷贝构造
A(const A& aa)
:_a(aa._a)
{
cout << "A 拷贝构造" << endl;
}
//赋值运算符重载
A& operator=(const A& aa)
{
cout << "A 赋值重载" << endl;
if (this != &aa)
{
_a = aa._a;
}
return *this;
}
//析构函数
~A()
{
cout << "A 析构" << endl;
}
private:
int _a;
};
//传值传参
void f1(A aa) {}
//传值返回
A f2()
{
A aa;
return aa;
}
14.1 vs2019下的优化场景
因为不同编译器对于代码的优化程度是不一样的,下面的优化场景都是在VS2019的编译器下进行的。
14.1.1 优化场景1
传参隐式类型转换 – 构造+拷贝构造 --> 直接构造
int main()
{
//隐是转换传参
f1(1);
}
运行结果:
A 构造
A 析构
解释:
调用 f1 函数,并使用1作为参数,由于1和f1的形参不同,所以会发生隐式类型转换,即编译器会先用1去构造一个A类型的临时变量,然后用这个临时变量去拷贝构造aa,所以这里本来应该是构造+拷贝构造,但是编译器将其优化为了直接使用1去构造aa,省去了中间构造临时变量的过程。
即构造+拷贝构造 -> 优化直接构造
14.1.2 优化场景2
匿名对象 – 构造+拷贝构造 --> 直接构造
int main()
{
f1(A(2));
}
运行结果:
A 构造
A 析构
解释:
和场景1类似,本来是先用2来构造一个匿名对象,然后使用这个匿名对象来拷贝构造形参aa,经过编译器优化后变为直接使用2去构造aa。
14.1.3 优化场景3
传值返回 – 拷贝构造+拷贝构造 --> 一次拷贝构造
int main()
{
A aa2 = f2();
}
运行结果:
A 构造
A 析构
解释:
f2 函数返回的是局部的对象,所以编译器会先去拷贝构造一个临时对象,然后再用临时对象来拷贝构造aa2,而编译器优化后变为直接使用f2中的局部对象aa来拷贝构造aa2。
即拷贝构造+拷贝构造 -> 合二为一优化为一次拷贝构造。
14.3 编译器的优化对比
下面会针对vs2019和vs2022这两款编译器,针对优化场景3的传值返回作为样例,进行对比更深层次理解编译器的优化逻辑。

不优化:
f2 函数返回的是局部的对象,所以编译器会先去拷贝构造一个临时对象存放在main函数的栈帧中,之后会跳出f2函数函数栈帧销毁,aa被销毁,然后再用临时对象来拷贝构造aa2,同时因为临时变量的生命周期只有一行,所以拷贝构造完成后临时对象也会被销毁。
vs2019的优化:
直接使用f2中的局部对象aa来拷贝构造aa2,跳过了中间临时变量的生成。
vs2022的优化:
甚至不构造aa,直接构造aa2,局部对象aa直接作为aa2的引用。



被折叠的 条评论
为什么被折叠?



