前言

在学习c++类和对象之前,我们来讨论下建造房子的过程,首先我们需要明白想要筑造什么样子的房子,是高楼大厦或者乡间小屋亦或者小区楼层,然后我们需要在建造前设计出蓝图,而c++中的就类似于蓝图,由类实例化出对象(相当于根据蓝图找到一块地皮来建造出来毛胚房)。而后面对毛胚房进行精装或者修饰则相当于对对象进行数据结构上的操作。

在这里插入图片描述

C++ 是一种面向对象的编程语言,它支持类(Class)和对象(Object)的概念。面向对象编程(OOP)是一种编程范式,它使用“对象”来设计应用程序和计算机程序。在 C++ 中,类是创建对象的蓝图或模板,它定义了对象的属性(也称为成员变量或数据成员)和方法(也称为成员函数或成员函数)。

一、类的定义

1.1内置类型和自定义类型

在C++中,自定义类型和内置类型(也称为基本数据类型)是两种不同类型的变量或数据结构,它们在定义方式、用途、性能以及可扩展性方面存在显著区别。

内置类型(Basic Types)内置类型是C++语言直接支持的数据类型,它们预定义在语言中,不需要用户自定义。内置类型常用的有(int,char,float,bool,void,指针,等)。

自定义类型(User-Defined Types)自定义类型是由用户根据内置类型或其他自定义类型定义的数据结构。
自定义类型常用的有(类(Classes),结构体(Structs),枚举(Enums),联合体(Unions),等)

定义方式:内置类型由C++语言直接支持,不需要额外定义;自定义类型需要用户通过特定的语法(如struct、class等)来定义。

自定义类型通常会包含内置类型来进行构造,以建筑蓝图的视角来看,内置类型更像是木头,砖头,钢筋这些材料,自定义类型将他们以一种特定的方式作为成员进行结合来组成更为复杂的结构。
用途:内置类型适用于简单的数据存储和运算;自定义类型则适用于复杂的数据结构和行为封装,是实现面向对象编程的基石。

1.2 类定义格式

class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号

实例:

class Date
{
//访问限制符
public:
//类的方法(或者叫做类的成员函数)
 void Init(int year, int month, int day)
 {
 _year = year;
 _month = month;
 _day = day;
 }
 //类的属性(或者叫做类的成员变量)
private:
 // 为了区分成员变量,⼀般习惯上成员变量
 // 会加⼀个特殊标识,如_ 或者 m开头
 int _year; // year_ m_year
 int _month;
 int _day;
};
int main()
{
 Date d;
 d.Init(2024, 3, 31);
 return 0;
}

代码解释
class为定义类的关键字,Date为类的名字,{}中为类的主体,注意类定义结束时后⾯分号不能省略。类体中内容称为类的成员:类中的变量(_year,_month,_day)称为类的属性或成员变量;类中的函数称为类的⽅法或者成员函数。

• C++中struct也可以定义类,C++兼容C中struct的⽤法,同时struct升级成了类,明显的变化是struct中可以定义函数,⼀般情况下我们还是推荐⽤class定义类。

• 定义在类⾯的成员函数默认为inline。当我们在代码块中使用到成员函数时,如果编译器认可成员函数作为内联函数的使用,那么就不需要开辟额外的帧栈来调用函数,而是直接执行这个内联函数。

1.2 访问限定符

在这里插入图片描述

• C++⼀种实现封装的⽅式,⽤类将对象的属性与⽅法结合在⼀块,让对象更加完善,通过访问权限选择性的将其接提供给外部的⽤⼾使⽤。
• public修饰的成员在类外可以直接被访问;protected和private修饰的成员在类外不能直接被访问,protected和private是⼀样的,以后继承才能体现出他们的区别。
• 访问权限作⽤域从该访问限定符出现的位置开始直到下⼀个访问限定符出现时为⽌,如果后⾯没有访问限定符,作⽤域就到}即类结束。
• class定义成员没有被访问限定符修饰时默认为private,struct默认为public。
• ⼀般成员变量都会被限制为private/protected,需要给别⼈使⽤的成员函数会放为public。

1.3 类域

• 类定义了⼀个新的作⽤域,类的所有成员都在类的作⽤域中,在类体外定义成员时,需要使⽤::作⽤域操作符指明成员属于哪个类域。

• 类域影响的是编译的查找规则,下⾯程序中Init如果不指定类域Stack,那么编译器就把Init当成全局函数,那么编译时,找不到array等成员的声明/定义在哪⾥,就会报错。指定类域Stack,就是知道Init是成员函数,当前域找不到的array等成员,就会到类域中去查找。

#include<iostream>
using namespace std;
class Stack
{
public:
 // 成员函数的定义,没有声明
 void Init(int n = 4);
 private:
 // 成员变量
 int* array;
 size_t capacity;
 size_t top;
};
// 成员函数的声明,声明和定义分离,需要指定类域
void Stack::Init(int n)
{
 array = (int*)malloc(sizeof(int) * n);
 if (nullptr == array)
 {
 perror("malloc申请空间失败");
 return;
 }
 capacity = n;
 top = 0;
}

int main()
{
 Stack st;
 st.Init();
 return 0;
}

1.3.1 域(Domain)的扩展

  1. 作用域(Scope):
    • 全局作用域:全局变量和函数定义在全局作用域中,它们在程序的任何地方都是可见的(除了被局部作用域中的同名变量隐藏的情况)。
    • 局部作用域:局部变量定义在函数或代码块内部,它们只在定义它们的函数或代码块内部可见。
    • 命名空间作用域:命名空间提供了一种封装标识符(如变量名、函数名等)的方法,以防止命名冲突。命名空间作用域内的标识符在命名空间外部是不可见的,除非通过命名空间名或using声明/指令来访问。

2.•类域(Class Scope):
• 当你在类中定义成员变量或成员函数时,它们位于类的作用域内。这意味着它们只能通过类的对象(对于非静态成员)或类名(对于静态成员)来访问。

3.作用域解析运算符(::):
• 这不是一个"域"的概念,但它与作用域紧密相关。作用域解析运算符用于指定某个标识符的作用域,特别是在存在命名冲突时。例如,std::vector表示vector是在std命名空间内定义的。

总而言之,域的出现是为了更好的运用C++程序中元素的可见性和生命周期。

二、类的实例化

2.1实例化概念

在这里插入图片描述
• ⽤类类型在物理内存中创建对象的过程,称为类实例化出对象。
• 类是对象进⾏⼀种抽象描述,是⼀个模型⼀样的东西,限定了类有哪些成员变量,这些成员变量只
是声明,没有分配空间,⽤类实例化出对象时,才会分配空间。
• ⼀个类可以实例化出多个对象,实例化出的对象占⽤实际的物理空间,存储类成员变量。打个⽐
⽅:类实例化出对象就像现实中使⽤建筑设计图建造出房⼦,类就像是设计图,设计图规划了有多
少个房间,房间⼤⼩功能等,但是并没有实体的建筑存在,也不能住⼈,⽤设计图修建出房⼦,房
⼦才能住⼈。同样类就像设计图⼀样,不能存储数据,实例化出的对象分配物理内存存储数据

#include<iostream>
using namespace std;
class Date
{
public:
 void Init(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和d2
 Date d1;
 Date d2;
 d1.Init(2024, 3, 31);
 d1.Print();
 d2.Init(2024, 7, 5);
 d2.Print();
 return 0;
}

2.2 对象内存⼤⼩

分析⼀下类对象中有哪些成员呢?类实例化出的每个对象,都有独⽴的数据空间,所以对象中肯定包含
成员变量,那么成员函数是否包含呢?⾸先函数被编译后是⼀段指令,对象中没办法存储,这些指令
存储在⼀个单独的区域(代码段),那么对象中⾮要存储的话,只能是成员函数的指针。再分析⼀下,对
象中是否有存储指针的必要呢,Date实例化d1和d2两个对象,d1和d2都有各⾃独⽴的成员变量
_year/_month/_day存储各⾃的数据,但是d1和d2的成员函数Init/Print指针却是⼀样的,存储在对象
中就浪费了。如果⽤Date实例化100个对象,那么成员函数指针就重复存储100次,太浪费了。这⾥需
要再额外哆嗦⼀下,其实函数指针是不需要存储的,函数指针是⼀个地址,调⽤函数被编译成汇编指
令[call地址]
,其实编译器在编译链接时,就要找到函数的地址,不是在运⾏时找,只有动态多态是在
运⾏时找,就需要存储函数地址。

2.2.1 成员函数的存储设计

当我们实例化多个对象时,它们共有相同的成员函数,我们在VS上调试上面的代码,运行到Init函数执行阶段转到汇编可以看出来d1和d2在调用Init函数时call调用的地址相同
在这里插入图片描述

下面的第二个存储设计方式就是当前运用的存储设计方式
在这里插入图片描述

2.2.2 成员变量的内存对齐原则

上⾯我们分析了对象中只存储成员变量,C++规定类实例化的对象也要符合内存对⻬的规则。

内存对⻬规则
• 第⼀个成员在与结构体偏移量为0的地址处。
• 其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处。
• 注意:对⻬数=编译器默认的⼀个对⻬数与该成员⼤⼩的较⼩值。
• VS中默认的对⻬数为8
• 结构体总⼤⼩为:最⼤对⻬数(所有变量类型最⼤者与默认对⻬参数取最⼩)的整数倍。
• 如果嵌套了结构体的情况,嵌套的结构体对⻬到⾃⼰的最⼤对⻬数的整数倍处,结构体的整体⼤⼩
就是所有最⼤对⻬数(含嵌套结构体的对⻬数)的整数倍。

在这里插入图片描述
当我们用A实例化出对象时
内存大小分析:
第一个成员_ch在定义A类型对象的偏移量为0处,然后int型在为4个字节,小于vs的默认八字节,那么对齐数就是4,接着寻找4的倍数处。最后计算最大对齐数的整数倍为8。

在这里插入图片描述

class B
{
public:
 void Print()
 {
 //...
 }
};
class C
{};

上面代码B/C实例化对象后,无论有没有定义成员变量,都会给1个字节,为什么没有成员变量还要给1个
字节呢?怎么表⽰对象存在过呢!所以这⾥给1字节,纯粹是为了占位标识。

2.2.2.1 为什么使用对齐数(仅作为了解)

对齐数(或称为对齐边界、对齐要求、对齐粒度)的使用在计算机编程和硬件设计中起着至关重要的作用。它们主要用于优化内存访问、提高数据处理的效率以及确保系统的稳定性和性能。以下是几个主要原因:

1.提高内存访问速度:
现代计算机系统中,内存(RAM)通常以字节为单位进行访问,但某些硬件(特别是CPU缓存和内存子系统)可能以更大的块(如4字节、8字节、16字节等)来有效地访问内存。当数据按照这些硬件优化的块大小对齐时,可以减少CPU访问内存的次数,因为每次访问都可以直接加载到CPU寄存器中所需的数据块,而无需额外的内存操作(如读取部分数据然后重新组合)。
2.减少缓存未命中率:
缓存未命中是指CPU在缓存中找不到所需数据而必须从较慢的主内存中检索数据的情况。数据对齐有助于减少这种情况,因为当数据块按缓存行(cache line)对齐时,相关的数据更有可能在同一缓存行中被找到,从而提高了缓存的命中率。
简化硬件设计:
3.在硬件层面,使用对齐数可以简化内存访问和控制逻辑的设计。例如,对齐的内存访问可以减少地址解码器的复杂性,因为不需要处理跨边界的访问请求。此外,对齐的数据还可以使得数据在内存中的布局更加规则,有利于预测和并行处理。
4.提高系统稳定性:
在某些情况下,未对齐的内存访问可能导致硬件异常或错误,尤其是在某些处理器架构中。使用对齐数可以确保所有内存访问都是合法的,从而避免这些问题,提高系统的稳定性和可靠性。
5.跨平台兼容性:
不同的处理器架构和操作系统可能对内存对齐有不同的要求。遵循通用的对齐规范可以确保软件在不同平台上的兼容性和可移植性。

综上所述,对齐数的使用是为了优化内存访问、提高系统性能、减少资源消耗、增加系统稳定性和确保跨平台兼容性。在编程实践中,程序员需要了解并遵循目标平台或编程语言的内存对齐规则,以确保软件的高效运行。

2.3 this指针

this指针的特性

  1. this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
  2. 只能在“成员函数”的内部使用
  3. this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
  4. this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传 递,不需要用户传递
    ————————————————

2.3.1 this指针的使用

• 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指针。

class Date
{
public:
 // void Init(Date* const this, int year, int month, int day)
 void Init(int year, int month, int day)
 {
 // 编译报错:error C2106: “=”: 左操作数必须为左值
 // this = nullptr;
 // this->_year = year;
 _year = year;
 this->_month = month;
 this->_day = day;
 }
 void Print()
{
 cout << _year << "/" << _month << "/" << _day << endl;
 }
private:
 // 这⾥只是声明,没有开空间
 int _year;
 int _month;
 int _day;
};
int main()
{
 // Date类实例化出对象d1和d2
 Date d1;
 Date d2;
 // d1.Init(&d1(隐函数指针), 2024, 3, 31);
 d1.Init(2024, 3, 31);
 d1.Print();
 d2.Init(2024, 7, 5);
 d2.Print();
 return 0;
}

在这里插入图片描述

2.3.2 this指针存储方式

其实编译器在生成程序时加入了获取对象首地址的相关代码。编译器有并把获取的首地址存放在了寄存器ECX中(VC++编译器是放在ECX中,其它可能不同)。也就是成员函数的其它参数正常都是存放在栈中。而this指针参数则是存放在寄存器中。类的静态成员函数因为没有this指针这个参数,所以类的静态成员函数也就无法调用类的非静态成员变量。

this可以为空,当我们在调用函数的时候,如果函数内部并不需要使用到this,也就是不需要通过this指向当前对象并对其进行操作时才可以为空(当我们在其中什么都不放或者在里面随便打印一个字符串),如果调用的函数需要指向当前对象,并进行操作,则会发生错误(空指针引用)就跟C中一样不能进行空指针的引用。

如下面例子:

#include<iostream>
using namespace std;
class A
{
public:
 void Print()
 {
 cout << "A::Print()" << endl;
 cout << _a << endl;
 }
private:
 int _a;
};
int main()
{
 A* p = nullptr;
 p->Print();
 return 0;
}

当我们注释掉上面的cout << _a << endl;时可以正常运行,否则会运行崩溃。

此外我们需要知道this指针因为是函数参数,所以它存在于栈区的。

三、类的默认成员函数

默认成员函数就是⽤⼾没有显式实现,编译器会⾃动⽣成的成员函数称为默认成员函数。
⼀个类,我们不写的情况下编译器会默认⽣成以下6个默认成员函数,需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解⼀下即可。其次就是C++11以后还会增加两个默认成员函数移动构造和移动赋值。默认成员函数很重要,也⽐较复杂,我们要从两个⽅⾯去学习:
• 第⼀:我们不写时,编译器默认⽣成的函数⾏为是什么,是否满⾜我们的需求。
• 第⼆:编译器默认⽣成的函数不满⾜我们的需求,我们需要⾃⼰实现,那么如何⾃⼰实现?

在这里插入图片描述

3.1 构造函数

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并
不是开空间创建对象
(我们常使⽤的局部对象是栈帧创建时,空间就开好了),⽽是对象实例化时初始化
对象
。构造函数的本质是要替代我们以前Stack和Date类中写的Init函数的功能,构造函数⾃动调⽤的
特点就完美的替代的了Init。

构造函数的特点:

  1. 函数名与类名相同。
  2. ⽆返回值。(返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)
  3. 对象实例化时系统会⾃动调⽤对应的构造函数。
  4. 构造函数可以重载。
  5. 如果类中没有显式定义构造函数,则C++编译器会⾃动⽣成⼀个⽆参的默认构造函数,⼀旦⽤⼾显
    式定义编译器将不再⽣成。
  6. ⽆参构造函数、全缺省构造函数、我们不写构造时编译器默认⽣成的构造函数,都叫做默认构造函
    数。但是这三个函数有且只有⼀个存在,不能同时存在。⽆参构造函数和全缺省构造函数虽然构成
    函数重载,但是调⽤时会存在歧义。要注意很多同学会认为默认构造函数是编译器默认⽣成那个叫
    默认构造,实际上⽆参构造函数、全缺省构造函数也是默认构造,总结⼀下就是不传实参就可以调
    ⽤的构造就叫默认构造。
  7. 我们不写,编译器默认⽣成的构造,对内置类型成员变量的初始化没有要求,也就是说是是否初始
    化是不确定的,看编译器。对于⾃定义类型成员变量,要求调⽤这个成员变量的默认构造函数初始
    化。如果这个成员变量,没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要⽤
    初始化列表才能解决,初始化列表。

代码例子如下:

#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;
 }
 // 3.全缺省构造函数
 /*Date(int year = 1, int month = 1, int day = 1)
 {
 _year = year;
 _month = month;
 _day = day;
 }*/
 void Print()
 {
 cout << _year << "/" << _month << "/" << _day << end;
 }
private:
 int _year;
 int _month;
 int _day;
};
int main()
{
 // 如果留下三个构造中的第⼆个带参构造,第⼀个和第三个注释掉
 // 编译报错:error C2512: “Date”: 没有合适的默认构造函数可⽤
 Date d1; // 调⽤默认构造函数
 Date d2(2025, 1, 1); // 调⽤带参的构造函数
 
 // 注意:如果通过⽆参构造函数创建对象时,对象后⾯不⽤跟括号,否则编译器⽆法
 // 区分这⾥是函数声明还是实例化对象
 // warning C4930: “Date d3(void)”: 未调⽤原型函数(是否是有意⽤变量定义的?)
 //-》Date d3();当我们这样定义是就会出现歧义
 
 return 0;
}

【注意】:当自动生成的默认构造函数的默认值(函数重载)是随机值

3.1.1 初始化的作用(仅供了解)

C++对象初始化的作用是多方面的,它确保了对象在创建时具有一个明确、预期的状态,这对于程序的正确性和稳定性至关重要。下面列举了几个关键的作用:

• 设定初始值:
对象初始化最直接的作用是为其成员变量设置初始值。如果不对对象进行初始化,那么其成员变量的值将是未定义的(在栈上分配的局部变量)或者根据内存中的当前值而定(如动态分配的内存或全局/静态变量,尽管静态/全局变量通常会被初始化为零或特定的默认值,但这并不总是你所期望的)。通过初始化,可以确保对象在创建时就已经处于一个有意义的、已知的状态。
• 避免未定义行为:
未初始化的对象可能导致未定义行为(Undefined Behavior, UB),这意味着程序的行为可能无法预测,包括但不限于程序崩溃、数据损坏或安全漏洞。通过初始化对象,可以显著减少这类问题的发生。
提高代码的可读性和可维护性:
初始化对象可以明确表达对象的初始状态,这对于阅读和维护代码的人来说非常有用。它使得代码更加清晰,易于理解,并有助于避免由于未定义或意外状态导致的错误。
• 性能优化:
在某些情况下,合理的初始化可以优化程序的性能。例如,通过初始化避免在后续操作中不必要的检查或赋值操作。虽然这通常不是初始化的主要目的,但在某些特定场景下,它确实可以带来性能上的好处。
• 构造函数的使用:
在C++中,对象的初始化通常是通过调用构造函数来完成的。构造函数不仅可以初始化对象的成员变量,还可以执行其他必要的初始化操作,如资源分配、状态设置等。这使得对象在创建时就能够完成必要的准备工作,从而确保对象在后续的使用中能够正常工作。
• 支持常量和引用成员:
如果对象包含常量(const)成员或引用(&)成员,那么这些成员必须在对象的构造函数初始化列表中进行初始化,而不能在构造函数体内进行赋值。这是因为常量成员和引用成员一旦初始化后就不能再被修改,而构造函数体中的赋值操作实际上是对已经初始化的成员进行再赋值,这在语法上是不允许的。

综上所述,C++对象初始化的作用是确保对象在创建时具有一个明确、预期的状态,这对于提高程序的正确性、稳定性、可读性和可维护性都是非常重要的。

3.2 析构函数

析构函数与构造函数功能相反,析构函数不是完成对对象本⾝的销毁,⽐如局部对象是存在栈帧的,
函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会⾃动调⽤析构函数,完成对
象中资源的清理释放⼯作。析构函数的功能类⽐我们之前Stack实现的Destroy功能,⽽像Date没有
Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的。

3.2.1 析构函数的特点

析构函数的特点:

  1. 析构函数名是在类名前加上字符~。
  2. ⽆参数⽆返回值。(这⾥跟构造类似,也不需要加void)
  3. ⼀个类只能有⼀个析构函数。若未显式定义,系统会⾃动⽣成默认的析构函数。
  4. 对象⽣命周期结束时,系统会⾃动调⽤析构函数。
  5. 跟构造函数类似,我们不写编译器⾃动⽣成的析构函数对内置类型成员不做处理,⾃定类型成员会调⽤他的析构函数。
  6. 还需要注意的是我们显⽰写析构函数,对于⾃定义类型成员也会调⽤他的析构,也就是说⾃定义类
    型成员⽆论什么情况都会⾃动调⽤析构函数。7. 如果类中没有申请资源时,析构函数可以不写,直接使⽤编译器⽣成的默认析构函数,如Date;如果默认⽣成的析构就可以⽤,也就不需要显⽰写析构,如MyQueue;但是有资源申请时,⼀定要⾃⼰写析构,否则会造成资源泄漏,如Stack。
  7. ⼀个局部域的多个对象,C++规定后定义的先析构。
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
 Stack(int n = 4)
 {
 _a = (STDataType*)malloc(sizeof(STDataType) * n);
 if (nullptr == _a)
 {
 perror("malloc申请空间失败");
 return;
 }
 _capacity = n;
 _top = 0;
 }
 ~Stack()
 {
 cout << "~Stack()" << endl;
 free(_a);
 _a = nullptr;
 _top = _capacity = 0;
 }
private:
 STDataType* _a;
 size_t _capacity;
 size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
 //编译器默认⽣成MyQueue的析构函数调⽤了Stack的析构,释放的Stack内部的资源 
 // 显⽰写析构,也会⾃动调⽤Stack的析构
 /*~MyQueue()
 {}*/
private:
 int data;
 Stack pushst;
 Stack popst;
};
int main()
{
 Stack st;
 MyQueue mq;
 return 0;
}

关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器 生成的默认析构函数,对自定类型成员调用它的析构函数。

代码解释
在main方法中根本没有直接创建Stack类的对象,为什么最后会调用Stack类的析构函数?

因为:main方法中创建了MyQueue对象mq,而mq中包含3个成员变量,其中data是 内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;而 pushst ,popst是Stack类对象,所以在mq销毁时,要将其内部包含的Stack类的pushst,popst对象销毁,所以要调用Stack类的析构函数。但是:main函数中不能直接调用Stack类的析构函数,实际要释放的是MyQueue类对象,所以编译器会调用MyQueue类的析构函数,而MyQueue没有显式提供,则编译器会给MyQueue类生成一个默认的析构函数,目的是在其内部调用Stack类的析构函数,即当MyQueue对象销毁时,要保证其内部每个自定义对象都可以正确销毁main函数中并没有直接调用Stack类析构函数,而是显式调用编译器为MyQueue类生成的默认析构函数
注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数

3.3 拷贝构造函数

概念:
如果⼀个构造函数的第⼀个参数是⾃⾝类类型的引⽤,且任何额外的参数都有默认值,则此构造函数
也叫做拷⻉构造函数,也就是说拷⻉构造是⼀个特殊的构造函数。

3.3.1 拷⻉构造的特点

  1. 拷⻉构造函数是构造函数的⼀个重载。

  2. 拷⻉构造函数的参数只有⼀个且必须是类类型对象的引⽤,使⽤传值⽅式编译器直接报错,因为语
    法逻辑上会引发⽆穷递归调⽤(传值传参会发生拷贝构造)。
    在这里插入图片描述

  3. C++规定⾃定义类型对象进⾏拷⻉⾏为必须调⽤拷⻉构造,所以这⾥⾃定义类型传值传参和传值返回都会调⽤拷⻉构造完成。

  4. 若未显式定义拷⻉构造,编译器会⽣成⾃动⽣成拷⻉构造函数。⾃动⽣成的拷⻉构造对内置类型成员变量会完成值拷⻉/浅拷⻉(⼀个字节⼀个字节的拷⻉),对⾃定义类型成员变量会调⽤他的拷⻉构造。(所以当我们进行资源的调用时,一定要使用拷贝构造)
    在这里插入图片描述我们发现aa1和aa2里面的成员变量_a的值是相同的,指向同一块地址,此时明显是不妥当的

  5. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器⾃动⽣成的拷⻉构造就可以完成需要的拷⻉,所以不需要我们显⽰实现拷⻉构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器⾃动⽣成的拷⻉构造完成的值拷⻉/浅拷⻉不符合我们的需求,所以需要我们⾃⼰实现深拷⻉(对指向的资源也进⾏拷⻉)。像MyQueue这样的类型内部主要是⾃定义类型Stack成员,编译器⾃动⽣成的拷⻉构造会调⽤Stack的拷⻉构造,也不需要我们显⽰实现MyQueue的拷⻉构造。这⾥还有⼀个⼩技巧,如果⼀个类显⽰实现了析构并释放资源,那么他就需要显⽰写拷⻉构造,否则就不需要。

  6. 传值返回会产⽣⼀个临时对象调⽤拷⻉构造,传值引⽤返回,返回的是返回对象的别名(引⽤),没有产⽣拷⻉。但是如果返回对象是⼀个当前函数局部域的局部对象,函数结束就销毁了,那么使⽤引⽤返回是有问题的,这时的引⽤相当于⼀个野引⽤,类似⼀个野指针⼀样。传引⽤返回可以减少拷⻉,但是⼀定要确保返回对象,在当前函数结束后还在,才能⽤引⽤返回。

#include<iostream>
using namespace std;
class Date
{
public:
 Date(int year = 1, int month = 1, int day = 1)
 {
 _year = year;
 _month = month;
 _day = day;
 }
 // 编译报错:error C2652: “Date”: ⾮法的复制构造函数: 第⼀个参数不应是“Date”
 //错误:Date(Date d)
// 正确:Date(const Date& d)传引用
Date(const Date& d)
 {
 _year = d._year;
 _month = d._month;
 _day = d._day;
 }
 Date(Date* d)
 {
 _year = d->_year;
 _month = d->_month;
 _day = d->_day;
 }
 void Print()
 {
 cout << _year << "-" << _month << "-" << _day << endl;
 }
private:
 int _year;
 int _month;
 int _day;
};
void Func1(Date d)
{
 cout << &d << endl;
 d.Print();
}
// Date Func2()
Date& Func2()
{
 Date tmp(2024, 7, 5);
 tmp.Print();
 return tmp;
}
int main()
{
 Date d1(2024, 7, 5);
 // C++规定⾃定义类型对象进⾏拷⻉⾏为必须调⽤拷⻉构造,所以这⾥传值传参要调⽤拷⻉
构造
 // 所以这⾥的d1传值传参给d要调⽤拷⻉构造完成拷⻉,传引⽤传参可以较少这⾥的拷⻉
 Func1(d1);
 cout << &d1 << endl;
 // 这⾥可以完成拷⻉,但是不是拷⻉构造,只是⼀个普通的构造
 Date d2(&d1);
 d1.Print();
 d2.Print();
 //这样写才是拷⻉构造,通过同类型的对象初始化构造,⽽不是指针
 Date d3(d1);
 d2.Print();
 // 也可以这样写,这⾥也是拷⻉构造
 Date d4 = d1;
 d2.Print();
 
 // Func2返回了⼀个局部对象tmp的引⽤作为返回值
 // Func2函数结束,tmp对象就销毁了,相当于了⼀个野引⽤
 Date ret = Func2();
 ret.Print();
 
 return 0;
}

注意:当我们有自定义的类型或者指针在类中时,一定要自己写拷贝构造函数,否则

3.3.1.1 传引用(仅做了解)

在C++中,引用传递是一种非常有用的参数传递方式,它允许函数直接访问和操作传递给它的变量的原始数据,而不是数据的副本。这意味着在函数内部对参数所做的任何修改都会反映到原始数据上。与值传递(通过复制数据)相比,引用传递通常更高效,因为它避免了不必要的数据复制。

声明引用
在C++中,引用是通过在变量类型后加上&符号来声明的。例如,假设我们有一个int类型的变量x,我们可以声明一个对x的引用y如下:

cpp
int x = 10;  
int& y = x; // y 是对 x 的引用

现在,对y的任何操作都会直接反映在x上。

引用传递示例
下面是一个简单的例子,展示了如何在函数中使用引用传递来修改传入的参数:

cpp
#include <iostream>  
  
void swap(int& a, int& b) {  
    int temp = a;  
    a = b;  
    b = temp;  
}  
  
int main() {  
    int x = 5, y = 10;  
    std::cout << "Before swap: x = " << x << ", y = " << y << std::endl;  
    swap(x, y);  
    std::cout << "After swap: x = " << x << ", y = " << y << std::endl;  
    return 0;  
}

在这个例子中,swap函数通过引用接收两个int类型的参数a和b。函数内部,我们交换了a和b的值,由于a和b是对调用swap函数时传入的x和y的引用,因此这些修改也会反映到x和y上。

注意事项
引用必须被初始化,并且一旦被初始化后就不能再指向另一个对象。
引用通常用于函数参数和返回值,以减少数据复制和提供对原始数据的直接访问。
引用不能为空,它必须始终引用某个对象。
引用传递可以传递常量引用,这允许函数读取但不能修改原始数据。例如,void print(const int& a)中的a是一个对int的常量引用,你不能通过a修改原始数据。
总的来说,引用传递是C++中一个强大且高效的特性,它允许函数直接操作传递给它的数据,而无需复制数据。

3.3.1.2 传值调用(仅做了解)

在C++(以及许多其他编程语言中)中,函数的传值调用(也称为按值传递)是一种将参数传递给函数的方式。当函数被调用时,函数的每个参数都会被创建一份参数的副本(或称为拷贝),这份副本被存储在函数的调用栈上。这个过程中,原始参数(即调用函数时提供的参数)保持不变,函数内部对参数所做的任何修改都只会影响这个副本,而不会影响到原始参数。

void test3() {
	typedef int DataType;
	class Stack
	{
	public:
		Stack(size_t capacity = 10)
		{
			_array = (DataType*)malloc(capacity * sizeof(DataType));
			if (nullptr == _array)
			{
				perror("malloc申请空间失败");
				return;
			}
			_size = 0;
			_capacity = capacity;
		}
		void Push(const DataType& data)
		{
			// CheckCapacity();
			_array[_size] = data;
			_size++;
		}

		~Stack()
		{
			if (_array)
			{
				free(_array);
				_array = nullptr;
				_capacity = 0;
				_size = 0;
			}
		}

	private:
		DataType* _array;
		size_t _size;
		size_t _capacity;
	};

	
	Stack s1;
	s1.Push(1);
	s1.Push(2);
	s1.Push(3);
	s1.Push(4);
	Stack s2;
	s2 = s1;
}
int main()
{
	test3();

	return 0;
}

上面的代码块运行后会发生如下情况
在这里插入图片描述
在这里插入图片描述

具体来说,传值调用会发生以下几步:

1.参数复制:函数被调用时,会为每个参数创建一个新的副本。这个副本包含了调用时传递给函数的参数的值。
2.执行函数:函数体使用这些参数的副本进行执行。在函数体内,可以对这些副本进行修改,但这些修改不会影响到原始参数。
3.返回结果(如果函数有返回值):函数执行完毕后,会返回一个值(如果函数声明了返回值类型的话)。这个返回值被返回给调用者,但不影响传递给函数的参数。
4.参数销毁:当函数执行完毕并返回时,函数栈帧被销毁,包括函数参数在内的所有局部变量都会被销毁。这意味着在函数内部创建的参数副本将不再存在。
需要注意的是,对于大型对象或结构体作为参数进行传值调用时,由于需要复制整个对象或结构体,这可能会导致较大的性能开销。在这种情况下,可以考虑使用引用传递(通过引用或指针)来避免不必要的复制,从而提高效率。

此外,对于基本数据类型(如int、float等)和小的结构体,传值调用的性能开销通常是可以接受的,因为这些类型的数据复制起来相对较快。然而,对于大型数据结构,如动态分配的数组、链表、树等,使用引用传递通常是更好的选择。

3.4 赋值运算符重载

运算符重载的作用
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其 返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。(相当于提供一个特殊功能的函数名,自己可以改变一些参数和返回值)

运算符重载函数的使用:在这里插入图片描述

3.4.1 运算符重载概念与定义

• 当运算符被⽤于类类型的对象时,C++语⾔允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使⽤运算符时,必须转换成调⽤对应运算符重载若没有对应的运算符重载,则会编译报错。
• 运算符重载是具有特名字的函数,他的名字是由operator和后⾯要定义的运算符共同构成。和其他函数⼀样,它也具有其返回类型和参数列表以及函数体。
• 重载运算符函数的参数个数和该运算符作⽤的运算对象数量⼀样多。⼀元运算符有⼀个参数,⼆元运算符有两个参数,⼆元运算符的左侧运算对象传给第⼀个参数,右侧运算对象传给第⼆个参数。
• 如果⼀个重载运算符函数是成员函数,则它的第⼀个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数⽐运算对象少⼀个。
• 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持⼀致。
• 不能通过连接语法中没有的符号来创建新的操作符:⽐如operator@。
• (.* )(::)( sizeof ?)( :)( .) 注意以上5个运算符不能重载。(选择题⾥⾯常考,⼤家要记⼀
下)
• 重载操作符⾄少有⼀个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: int operator+(int x, int y)
• ⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,⽐如Date类重载operator-就有意义,但是重载operator+就没有意义。• 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,⽆法很好的区分。
C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,⽅便区分。
• 重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位
置,第⼀个形参位置是左侧运算对象,调⽤时就变成了对象<<cout,不符合使⽤习惯和可读性。重载为全局函数把ostream/istream放到第⼀个形参位置就可以了,第⼆个形参位置当类类型对象。

#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;
};
// 重载为全局的⾯临对象访问私有成员变量的问题
// 有⼏种⽅法可以解决:
// 1、成员放公有 
// 2、Date提供getxxx函数
// 3、友元函数
// 4、重载为成员函数
bool operator==(const Date& d1, const Date& d2)
{
 return d1._year == d2._year
 && d1._month == d2._month
 && d1._day == d2._day;
}
int main()
{
 Date d1(2024, 7, 5);
 Date d2(2024, 7, 6);
 // 运算符重载函数可以显⽰调⽤
 operator==(d1, d2);
 // 编译器会转换成 operator==(d1, d2);
 d1 == d2;
 return 0;
}

当我们对上面代码最后cout<<d1==d2时会输出false。

四 日期类的实现

//Date头文件
#pragma once
#include<iostream>
#include<assert.h>
using namespace std;
class Data {
public:
	//友元函数
	friend ostream& operator<<(ostream& out, const Data& d);
	friend istream& operator>>(istream& in, Data& d);

	//构造函数
	Data(int year = 2024, int mouth = 7, int day = 12) {
		_year = year;
		_mouth = mouth;
		_day = day;
	}
	//拷贝构造函数
	Data(const Data& d) {
		_year = d._year;
		_mouth = d._mouth;
		_day = d._day;
	}

	void Print() const //后面的const相当于修饰隐函数指针->this,
		//作用是表示该函数不会修改类的任何非静态成员变量
	{
		cout << this << endl;
		cout << this->_year << "/" << this->_mouth << "/" << this->_day << endl;
	}
//这个函数可以得到月份的天数
	int GetMouthDay(int year, int month) {
		//assert(month < 13 && month > 0);用于判断月份是否不合规定

		static int MonthDayArray[13] = { -1, 31, 28, 31, 30, 31, 30,31, 31, 30, 31, 30, 31 };
		if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year
			% 400 == 0)) {
			return 29;
		}
		else return MonthDayArray[month];
	};

	Data& operator+=(int day);
	
	Data operator+(int day);

	Data& operator-=(int day);
	Data operator-(int day);

	bool operator<(Data d);
	bool operator<=(Data d);
	bool operator>(Data d);
	bool operator==(Data d2);
	bool operator!=(Data d);
	Data& operator++();
	int operator-(const Data& d);
	
private:
	int _year;
	int _mouth;
	int _day;
};
#define _CRT_SECURE_NO_WARNINGS 1
#include"Data.h"
Data& Data::operator+=(int day) {
	if (day < 0) {
		return *this -= ( - day);
	}
	_day += day;
	
	while (_day > GetMouthDay(_year,_mouth)) {
		_day -= GetMouthDay(_year, _mouth);
		_mouth++;
		if (_mouth == 13) {
			_year++;
			_mouth = 1; 
		}
	}
	return *this;
};

Data Data::operator+(int day) {
//构造临时变量用于值返回
	Data tmp = *this;
	tmp += day;
	return tmp;
}

Data& Data::operator-=(int day) {
	if (day < 0) {
		return *this += (-day);
	}
	_day -= day;
	while (_day <= 0) {
		--_mouth;
		if (_mouth == 0) {
			_mouth = 12;
			--_year;
		}
		_day += GetMouthDay(_year, _mouth);
	}
	return *this;
}

Data Data::operator-(int day) {
	Data tmp = *this;
	tmp -= day;
	return tmp;
}

bool Data::operator<(Data d) {
	if (_year < d._year) {
		return true;
	}
	else if (_year == d._year) {
		if (_mouth < d._mouth) {
			return true;
		}
		else if (_mouth == d._mouth) {
			return _day < d._day;
		}
	}
	return false;
}
bool Data::operator<=(Data d) {
	return *this < d || *this == d;
}
bool Data::operator>(Data d) {
	return !(*this <= d);
}
Data& Data::operator++() {
	*this += 1;
	return *this;
}
bool Data::operator!=(Data d) {
	return !(*this == d);
}
bool Data::operator==(Data d2) {
	return _year == d2._year && _mouth == d2._mouth && _day == d2._day;
}
int Data::operator-(const Data& d) {
	int count = 0;
	int flag = -1;
	Data min = *this;
	Data max = d;
	if (max < min) {
		max = *this;
		min = d;
		flag = 1;
	}
	while (min != max) {
		++min;
		count++;
	}
	return flag*count;
}

ostream& operator<<(ostream& out, const Data& d)
{
	out << d._year << "年" << d._mouth << "月" << d._day << "日" << endl;
	return out;
} 
istream& operator>>(istream& in, Data& d) {
	in >> d._year  >> d._mouth  >> d._day ;
	return in;
}

注意:当我们对++经行运算符重载时,为了区分前置跟后置,构成重载,给后置++,会强⾏增加了⼀个int形参

五 扩展

5.1 再探构造函数

• 之前我们实现构造函数时,初始化成员变量主要使⽤函数体内赋值,构造函数初始化还有⼀种⽅式,就是初始化列表,初始化列表的使⽤⽅式是以⼀个冒号开始,接着是⼀个以逗号分隔的数据成员列表,每个"成员变量"后⾯跟⼀个放在括号中的初始值或表达式。
• 每个成员变量在初始化列表中只能出现⼀次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地⽅。
• 引⽤成员变量,const成员变量,没有默认构造的类类型变量,必须放在初始化列表位置进⾏初始化,否则会编译报错。
解释代码如下:

#include<iostream>
using namespace std;
class Time
{
public:
 Time(int hour)
 :_hour(hour)
 {
 cout << "Time()" << endl;
 }
private:
 int _hour;
};
class Date
{
public:
 Date(int& x, int year = 1, int month = 1, int day = 1)
 :_year(year)
 ,_month(month)
 ,_day(day)
 ,_t(12)
 ,_ref(x)
 ,_n(1)
 {
 // error C2512: “Time”: 没有合适的默认构造函数可⽤
 // error C2530 : “Date::_ref” : 必须初始化引⽤
 // error C2789 : “Date::_n” : 必须初始化常量限定类型的对象
 }
 void Print() const
 {
 cout << _year << "-" << _month << "-" << _day << endl;
 }
private:
 int _year;
 int _month;
 int _day;
 Time _t; // 没有默认构造
 int& _ref; // 引⽤
 const int _n; // const 
};
int main()
{
 int i = 0;
 Date d1(i);
 d1.Print();
 return 0;
}

• C++11⽀持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显⽰在初始化列表初始化的成员使⽤的。
代码解释如下:

class Date
{
public:
 Date()
 :_month(2)
 {
 cout << "Date()" << endl;
 }
 void Print() const
 {
 cout << _year << "-" << _month << "-" << _day << endl;
 }
private:
 // 注意这⾥不是初始化,这⾥给的是缺省值,这个缺省值是给初始化列表的
 // 如果初始化列表没有显⽰初始化,默认就会⽤这个缺省值初始化
 int _year = 1;
 int _month = 1;
 int _day;
 Time _t = 1;
 const int _n = 1;
 int* _ptr = (int*)malloc(12);
}



• 尽量使⽤初始化列表初始化,因为那些你不在初始化列表初始化的成员也会⾛初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会⽤这个缺省值初始化。如果你没有给缺省值,对于没有显⽰在初始化列表初始化的内置类型成员是否初始化取决于编译器,C++并没有规定。对于没有显⽰在初始化列表初始化的⾃定义类型成员会调⽤这个成员类型的默认构造函数,如果没有默认构造会编译错误。

• 初始化列表中按照成员变量在类中声明顺序进⾏初始化,跟成员在初始化列表出现的的先后顺序⽆关。建议声明顺序和初始化列表顺序保持⼀致。

面试题:

下⾯程序的运⾏结果是什么()
A.输出1 1
B.输出2 2
C.编译报错
D.输出1 随机
E.输出出1 2
F.输出2 1

#include<iostream>
using namespace std;
class A
{
public:
 A(int a)
 :_a1(a)
 , _a2(_a1)
 {}
 void Print() {
 cout << _a1 << " " << _a2 << endl;
 }
private:
 int _a2 = 2;
 int _a1 = 2;
};
int main()
{
 A aa(1);
 aa.Print();
}

答案:D
解释:

• 初始化列表中按照成员变量在类中声明顺序进⾏初始化,跟成员在初始化列表出现的的先后顺序⽆关。建议声明顺序和初始化列表顺序保持⼀致。

我们先定义了_a2,当我们在初始化列表初始化的时候会先对_a2进行操作,此时_a2(_a1)中_a1还没有初始化,故相当于会给个随机值。之后再对_a1初始化。

5.2 类型转换

概念:

• C++⽀持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数
• 构造函数前⾯加explicit就不再⽀持隐式类型转换

代码解释:

#include<iostream>
using namespace std;
class A
{
public:
 // 构造函数explicit就不再⽀持隐式类型转换
 // explicit A(int a1)
 A(int a1)
 :_a1(a1)
 {}
 //explicit A(int a1, int a2)
 A(int a1, int a2)
 :_a1(a1)
 ,_a2(a2)
 {}
 void Print()
 {
 cout << _a1 << " " << _a2 << endl;
 }
private:
 int _a1 = 1;
 int _a2 = 2;
};
int main()
{
 // 1构造⼀个A的临时对象,再⽤这个临时对象拷⻉构造aa3
 // 编译器遇到连续构造+拷⻉构造->优化为直接构造
 A aa1 = 1;
 aa1.Print();
 const A& aa2 = 1;
 // C++11之后才⽀持多参数转化
 A aa3 = { 2,2 };
 return 0;
}

5.3 static成员

概念:

• ⽤static修饰的成员变量,称之为静态成员变量,静态成员变量⼀定要在类外进⾏初始化。

• 静态成员变量为所有类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区。

• ⽤static修饰的成员函数,称之为静态成员函数,静态成员函数没有this指针。
• 静态成员函数中可以访问其他的静态成员,但是不能访问⾮静态的,因为没有this指针。
• ⾮静态的成员函数,可以访问任意的静态成员变量和静态成员函数。
• 突破类域就可以访问静态成员,可以通过类名::静态成员或者对象.静态成员来访问静态成员变量和静态成员函数。• 静态成员也是类的成员,受public、protected、private访问限定符的限制。
• 静态成员变量不能在声明位置给缺省值初始化,因为缺省值是个构造函数初始化列表的,静态成员变量不属于某个对象,不⾛构造函数初始化列表。

请用以下题目来理解上面概念:
设已经有A,B,C,D4个类的定义,程序中A,B,C,D构造函数调⽤顺序为?()
设已经有A,B,C,D4个类的定义,程序中A,B,C,D析构函数调⽤顺序为?()

A:D B A C
B:B A D C
C:C D B A
D:A B D C
E:C A B D
F:C D A B

C c;
int main()
{
 A a;
 B b;
 static D d;
 return 0}

答案:E B
代码解释:

全局或命名空间作用域的对象:C c; 首先被构造,因为它在main函数之前就已经声明了。
main函数内的局部变量:按照它们在main函数中出现的顺序被构造。首先是A a;,然后是B b;。
静态局部变量:static D d; 在main函数内第一次调用时构造,但其构造发生在main函数内的所有非静态局部变量之后。

因此,构造函数的调用顺序是:
C 的构造函数(C c;)
A 的构造函数(A a;)
B 的构造函数(B b;)
D 的构造函数(static D d;)

析构函数调用顺序
析构函数的调用顺序与构造函数的调用顺序相反。因此,析构函数的调用顺序将是:
D 的析构函数(static D d;)
A 的析构函数(A a;)
B 的析构函数(B b;)
C 的析构函数(C c)

5.3 友元

概念:

• 友元提供了⼀种突破类访问限定符封装的⽅式,友元分为:友元函数和友元类,在函数声明或者类声明的前⾯加friend,并且把友元声明放到⼀个类的⾥⾯。
• 外部友元函数可访问类的私有和保护成员,友元函数仅仅是⼀种声明,他不是类的成员函数。
• 友元函数可以在类定义的任何地⽅声明,不受类访问限定符限制。
• ⼀个函数可以是多个类的友元函数。
• 友元类中的成员函数都可以是另⼀个类的友元函数,都可以访问另⼀个类中的私有和保护成员。
• 友元类的关系是单向的,不具有交换性,⽐如A类是B类的友元,但是B类不是A类的友元。
• 友元类关系不能传递,如果A是B的友元,B是C的友元,但是A不是B的友元。
• 有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多⽤。

代码示例:

// 前置声明,都则A的友元函数声明编译器不认识B
class B;
class A
{
 // 友元声明
 friend void func(const A& aa, const B& bb);
private:
 int _a1 = 1;
 int _a2 = 2;
};
class B
{
 // 友元声明
 friend void func(const A& aa, const B& bb);
private:
 int _b1 = 3;
 int _b2 = 4;
};
void func(const A& aa, const B& bb)
{
 cout << aa._a1 << endl;
 cout << bb._b1 << endl;
}

5.4 内部类

概念:

• 如果⼀个类定义在另⼀个类的内部,这个内部类就叫做内部类。内部类是⼀个独⽴的类,跟定义在全局相⽐,他只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类。
• 内部类默认是外部类的友元类。
• 内部类本质也是⼀种封装,当A类跟B类紧密关联,A类实现出来主要就是给B类使⽤,那么可以考虑把A类设计为B的内部类,如果放到private/protected位置,那么A类就是B类的专属内部类,其他地⽅都⽤不了。

题目理解:不使用乘除法来解决1+2+3+4+…+n 来自牛客网
使用上面概念点进行解:

class Solution {
 // 内部类
 class Sum
 {
 public:
 Sum()
 {
 _ret += _i;
 ++_i;
 }
 };
 static int _i;
 static int _ret;
public:
 int Sum_Solution(int n) {
 // 变⻓数组
 Sum arr[n];
 return _ret;
 }
};
int Solution::_i = 1;
int Solution::_ret = 0;

代码解释:
这段代码定义了一个名为 Solution 的类,其中包含一个内部类 Sum 和两个静态成员变量 _i 与 _ret。内部类 Sum 的构造函数会递增 _ret 成员变量,并使其等于 _i 的当前值,然后递增 _i。外部类 Solution 的 Sum_Solution 方法用于创建一个长度为 n 的 Sum 类型数组,并返回累加在 _ret 中的值,_ret的值就是所要求的答案。

5. 5 匿名对象

概念:

• ⽤类型(实参)定义出来的对象叫做匿名对象,相⽐之前我们定义的类型对象名(实参)定义出来的叫有名对象
• 匿名对象⽣命周期只在当前⼀⾏,⼀般临时定义⼀个对象当前⽤⼀下即可,就可以定义匿名对象。

代码理解:

class A
{
public:
 A(int a = 0)
 :_a(a)
 {
 cout << "A(int a)" << endl;
 }
 ~A()
 {
 cout << "~A()" << endl;
 }
private:
 int _a;
};
class Solution {
public:
 int Sum_Solution(int n) {
 //...
 return n;
 }
};
int main()
{
 A aa1;
 // 不能这么定义对象,因为编译器⽆法识别下⾯是⼀个函数声明,还是对象定义,这个在一开始就有过解释
 //A aa1();
 // 但是我们可以这么定义匿名对象,匿名对象的特点不⽤取名字,
// 但是他的⽣命周期只有这⼀⾏,我们可以看到下⼀⾏他就会⾃动调⽤析构函数
 A();
 A(1);
 A aa2(2);
 // 匿名对象在这样场景下就很好⽤,当然还有⼀些其他使⽤场景,这个我们以后遇到了再说
 Solution().Sum_Solution(10);
 return 0;
}

5.6 对象拷⻉时的编译器优化

概念:

• 现代编译器会为了尽可能提⾼程序的效率,在不影响正确性的情况下会尽可能减少⼀些传参和传参过程中可以省略的拷⻉。
• 如何优化C++标准并没有严格规定,各个编译器会根据情况⾃⾏处理。当前主流的相对新⼀点的编译器对于连续⼀个表达式步骤中的连续拷⻉会进⾏合并优化,有些更新更"激进"的编译还会进⾏跨⾏跨表达式的合并优化。

代码理解;

class A
{
public:
 A(int a = 0)
 :_a1(a)
 {
 cout << "A(int a)" << endl;
 }
 A(const A& aa)
 :_a1(aa._a1)
 {
 cout << "A(const A& aa)" << endl;
 }
 A& operator=(const A& aa)
 {
 cout << "A& operator=(const A& aa)" << endl;
 if (this != &aa)
 {
 _a1 = aa._a1;
 }
 return *this;
 }
 ~A()
 {
 cout << "~A()" << endl;
 }
private:
 int _a1 = 1;
};
void f1(A aa)
{}
A f2()
{
 A aa;
 return aa;
}
int main()
{
 // 传值传参
 A aa1;
 f1(aa1);
 cout << endl;
 // 隐式类型,连续构造+拷⻉构造->优化为直接构造
 f1(1);
 // ⼀个表达式中,连续构造+拷⻉构造->优化为⼀个构造
 f1(A(2));
 cout << endl;
 cout << "***********************************************" << endl;
 // 传值返回
 // 返回时⼀个表达式中,连续拷⻉构造+拷⻉构造->优化⼀个拷⻉构造 (vs2019)
 // ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,直接变为构造。(vs2022)
 f2();
 cout << endl;
 // 返回时⼀个表达式中,连续拷⻉构造+拷⻉构造->优化⼀个拷⻉构造 (vs2019)
// ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,直接变为构造。(vs2022)
 A aa2 = f2();
 cout << endl;
 // ⼀个表达式中,连续拷⻉构造+赋值重载->⽆法优化
 aa1 = f2();
 cout << endl;
 return 0;
}

解释:通俗的讲优化过程相当于把一些通向相同结果步骤合并,逻辑上是需要执行每一步的,生成结果的过程上却省略了一些冗杂的步骤,所以设计编译器的人员还是很厉害的

总结

类与对象的学习开始代表着进入面向编程的世界,希望能坚持学下去。

参考书籍:《c++ prime(第五版) 》《 计算机组成原理(微课版)》
参考资料:csdn byte课件

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐