C++语法学习之路

C++语法学习之路

​ C++ 是在 C 的基础之上,容纳进了面向对象编程思想,并增加了许多有用的库,以及编程范式等。前期的 C++ 主要是在补充 C 语言的不足,以及对 C 设计不合理的地方进行优化。

一、关键字

C++ 有63个关键字,C 语言只有32个关键字。C++ 在 C 语言的基础之上增加了更多的关键字。

关于这些新增的关键字,我们不需要一下子就去全部搞懂。在学和用的过程中,我们慢慢就会熟能生巧了。

auto 关键字

auto 类型,能根据右边的表达式自动推导表达式的类型。在类型名比较长的时候有很大的作用。

1
2
3
int a = 1;
auto b = a;
auto c = a + 1.1;

NULL 与 nullptr

1
2
3
4
// C语言
#define NULL 0
// C++
#define nullptr ((void*)0)

二、命名空间 namespace

命名空间主要解决命名冲突的问题。命名冲突可能会是我们写的变量函数和调用的库冲突了,也可能是和同事的变量函数命名冲突了,导致编译不通过。

​ 如果是使用 C 语言,我们只能去修改冲突的变量函数名。C++ 加入了命名空间以后就解决了这个问题。

命名空间增加了命名空间域的概念,其他域还有类域(class)局部域全局域。同时还加入了 :: (域作用限定符)。变量和函数只会在自己的域内发挥作用,通过 :: 在其他域中引用需要的域变量函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 全局域
int a = 0;

// 命名空间域A
namespace A
{
int a = 2;
} // 不能给分号

int main()
{
// 局部域
int a = 1;
// ::的前面是跟的域名,后面则是变量函数名
// 空代表的是全局域
printf("%d\n", a);
printf("%d\n", ::a);
printf("%d\n", A::a);
return 0;
}
1
2
3
4
# cmd 命令输出
1
0
2

​ 使用 using namespace 展开命名空间域后,就会把命名空间域里的代码暴露到其他域。这时全局域下的变量 a 和**命名空间域 A **下的变量 a 会冲突。

​ 所以我们一般不要直接把命名空间域展开,这会出现许多问题。而是通过域作用限定符 :: 去调用命名空间域中的变量函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
int a = 0;
namespace A
{
int a = 1;
}
// 解注下面代码,此时爆红 "a" 不明确
// using namespace A

int main() {
std::cout << a << std::endl;
std::cout << A::a << std::endl;
return 0;
}

命名空间嵌套

命名空间域是可以嵌套的。调用嵌套命名空间里的变量函数需要先从外层开始调用。

1
2
3
4
5
6
7
8
9
namespace A
{
int a = 2;
namespace B
{
int a = 2;
}
}// 不能给分号
printf("%d\n", A::B::a);

命名空间合并

多个同名的命名空间会被合并属于同一个命名空间,不能有同名变量函数。

1
2
3
4
5
6
7
8
9
namespace A
{
int a = 2;
}

namespace A
{
int b = 1;
}

C++标准库和命名空间的关系

​ 传统的 C 语言标准库都是以 .h 结尾的头文件( #include <stdio.h> )。在 C++ 早期,C++ 的标准库也是以 .h 结尾的。但是在加入命名空间以后 C++ 的标准库不再使用 .h 结尾,并且把标准库里的东西全部加入到了一个命名空间中,名为 std

1
2
3
4
5
6
7
// C 头文件
#include <stdio.h>
// C++ 早期头文件
#include <iostream.h>
// C++ 头文件
#include <iostream>
using namespace std;

C++ 标准库中写的代码都是放在一个同名为 std 的命名空间域中的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
namespace std
{
// iostream库的代码
}
#include <list>
namespace std
{
// list库的代码
}
#include <vector>
namespace std
{
// vector库的代码
}

所以如果你想使用 C++ 的标准库,你不但要去包含头文件还需要在使用的地方限定标准库的命名空间 std

1
2
3
4
5
6
7
8
#include <iostream>
int a = 1;
std::cout << a << std::endl;

// 展开常用的std元素
using std::cout;
using std::endl;
cout << a << endl;

三、C++ 的输入输出

C++ 的输入输出使用了IO流cplusplus.com/reference 这是 C++ 标准库的文档,里面包含了IO流的介绍。

使用 cout 标准输出对象(控制台)和 cin 标准输入对象 (键盘) 时,必须包含 <iostream> 头文件并在使用的地方展开 std<<流插入运算符>>流提取运算符

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
using namespace std;
int a = 2024;
int main()
{
cout << "Hello Word!" << " " << a << endl;
int b = 0;
cin >> b;
cout << "Hello Word!" << " " << b << endl;
return 0;
}
1
2
3
4
# cmd 命令输出
Hello Word! 2024
2023
Hello Word! 2023

​ 可以看到,C++ 的输入输出并没有指定变量类型,这是 C++ 的两个新特性叫 函数重载运算符重载 来实现的,可以达到自动识别类型的效果。

​ 还值得一提的是,C语言的输入输出 ( printfscanf ) 通常比 C++ 的输入输出要快。这主要是因为 C++ 兼容 C 语言,C++ 在去缓冲区输入输出信息时会先检测有没有 C 语言的东西,如果有会先清空缓冲区再去输入输出信息。当然你也可以去把 C++ 的这个缓冲区检测取消掉,这个时候 C++ 的输入输出速度就提上来了。

四、缺省参数

​ C++ 支持缺省参数,在写缺省参数的时候需要严格按照语法来写。如果不是全缺省,缺省参数一定是在函数参数的右边的。因为我们调用函数时,函数形参会从左往右依次取值,如果第一个是缺省参数而第二个不是缺省参数,我又只传了一个参数该怎么办呢?

​ 这时,因为 C++ 语法不支持这样做,所以这个函数参数写法是错误的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;
// 错误写法
void Function(int a = 1, int b, int c = 3)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}

// 正确写法
void Function(int a, int b = 2, int c = 3)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
int main()
{
Function(4);
return 0;
}

缺省参数不能在函数定义函数声明中同时给,只能在函数声明中给出。不然会出现语法错误,编译器不知道该用哪一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 函数声明
void Function(int a = 1, int b = 2, int c = 3);
int main()
{
Function(4);
return 0;
}
// 函数定义
void Function(int a, int b, int c)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}

五、函数重载

​ C++ 支持函数重载函数重载是指函数名相同,但是参数类型不同。参数类型不同分为参数个数不同、类型不同、类型顺序不同。只要符合其中任意一个条件就符合函数重载

​ 值得一提的是返回值不同并不能构成函数重载

函数重载的调用歧义

​ 注意看,下面这两个函数是否满足函数重载。答案是满足,但是这两个函数在调用的时候会出现问题,如果调用这个函数的时候没有加参数那么是调用的哪一个函数呐?编译器也不知道,所以会报错函数调用出现歧义。

1
2
3
4
5
6
7
8
void Function()
{
cout << 'a' << endl;
}
void Function(int a = 4)
{
cout << a << endl;
}

函数重载原理

C++ 支持函数重载,C 语言不支持函数重载,它们的区别在于编译器对函数编译的实现不一样。

​ C 语言的编译器在汇编代码中定义一个函数的时候直接是使用它的函数名。但是 C++ 汇编代码定义一个函数的时候是根据函数名修饰规则去定义的。

​ 下面是在Linux环境下使用 gccg++ 作为编译器实现的样例。Linux 下编译后生成 a.out 可执行文件。objdump 可以查看对应的汇编代码。

C 语言汇编

1
2
3
4
void Function(int a)
{
printf("%d\n", a);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
....
0000000000400596 <Function>:
400596: 55 push %rbp
400597: 48 89 e5 mov %rsp,%rbp
40059a: 48 83 ec 10 sub $0x10,%rsp
40059e: 89 7d fc mov %edi,-0x4(%rbp)
4005a1: 8b 45 fc mov -0x4(%rbp),%eax
4005a4: 89 c6 mov %eax,%esi
4005a6: bf 78 06 40 00 mov $0x400678,%edi
4005ab: b8 00 00 00 00 mov $0x0,%eax
4005b0: e8 eb fe ff ff callq 4004a0 <printf@plt>
4005b5: 90 nop
4005b6: c9 leaveq
4005b7: c3 retq
....

C++ 汇编

1
2
3
4
5
6
7
8
9
void Function(int a)
{
printf("%d\n", a);
}

void Function(int a, double b)
{
printf("%d %f\n", a, b);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
00000000004005b6 <_Z8Functioni>:
4005b6: 55 push %rbp
4005b7: 48 89 e5 mov %rsp,%rbp
4005ba: 48 83 ec 10 sub $0x10,%rsp
4005be: 89 7d fc mov %edi,-0x4(%rbp)
4005c1: 8b 45 fc mov -0x4(%rbp),%eax
4005c4: 89 c6 mov %eax,%esi
4005c6: bf d8 06 40 00 mov $0x4006d8,%edi
4005cb: b8 00 00 00 00 mov $0x0,%eax
4005d0: e8 eb fe ff ff callq 4004c0 <printf@plt>
4005d5: 90 nop
4005d6: c9 leaveq
4005d7: c3 retq

00000000004005d8 <_Z8Functionid>:
4005d8: 55 push %rbp
4005d9: 48 89 e5 mov %rsp,%rbp
4005dc: 48 83 ec 10 sub $0x10,%rsp
4005e0: 89 7d fc mov %edi,-0x4(%rbp)
4005e3: f2 0f 11 45 f0 movsd %xmm0,-0x10(%rbp)
4005e8: f2 0f 10 45 f0 movsd -0x10(%rbp),%xmm0
4005ed: 8b 45 fc mov -0x4(%rbp),%eax
4005f0: 89 c6 mov %eax,%esi
4005f2: bf dc 06 40 00 mov $0x4006dc,%edi
4005f7: b8 01 00 00 00 mov $0x1,%eax
4005fc: e8 bf fe ff ff callq 4004c0 <printf@plt>
400601: 90 nop
400602: c9 leaveq
400603: c3 retq

六、引用 &

​ C++ 中的 & 与 C 语言中的不一样,C 语言中的 & 表示取地址符号,取地址符常常用来用在 函数传参 中的指针赋值。但是 C++ 中 & 符号,是提高代码执行效率和增强代码质量的一个很好的办法。

​ 引用不是重新定义一个变量,而是给已经存在的变量取一个别名。编译器不会为这个别名开辟新的空间,而是和它引用的变量共用同一块空间,操作一个变量的引用也就相当于操作变量本身。

1
2
int a = 0;
int& b = a; // b引用a,b变成a的别名

​ 引用必须在定义的时候初始化,一旦引用了一个变量以后不能再引用其他变量。一个变量可以有多个引用。引用的权限不能放大,只能平移或者缩小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int& b;    //错误的

int a = 0;
int& c = a;
int d = 1;
c = d; //这里是c引用d还是d赋值给c?

cout << c << endl; // 结果为 1
cout << a << endl; // 结果为 1

#const修饰的变量不能改变

//权限放大 错误
const int a = 0;
int& b = a;

//权限平移
int a = 0;
int& b = a;

//权限缩小
int a = 0;
const int& b = a;

​ C 语言之中大量利用指针作为形参或者函数返回值,这是由于值拷贝会有很大的消耗(比如传入传出一个大的结构体)。所以在 C++ 之中使用引用作为函数参数返回值的目的和使用指针是一样的。而且形式上更加直观,所以 C++ 提倡使用引用。

引用作为函数参数

这里我们用经典的交换函数来作为举例对象。现在我们需要写一个函数实现 ab 值的交换,上面的是指针的写法,下面是出引用的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
using std::cout;
using std::endl;

void Swap(int* a, int* b)
{
int tmp;
tmp = *a;
*a = *b;
*b = tmp;
}

void Swap(int& a, int& b)
{
int tmp;
tmp = a;
a = b;
b = tmp;
}

int main()
{
int a = 1;
int b = 2;
//Swap(&a, &b);
Swap(a, b);
cout << a << endl;
cout << b << endl;
return 0;
}

不难看出,使用引用的方法写起来更简洁,而且在调用函数的时候也不需要去取实参的地址就能实现改变实参的需求。

引用作为返回值

引用作为返回值可以省去函数返回时产生的临时变量,从而提高效率。但是这也是有条件的,下面举例说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int Function1()
{
int a = 0;
a++;
return a;
}

int Function2()
{
static int a = 0;
a++;
return a;
}

int& Function3()
{
static int a = 0;
a++;
return a;
}

int mian()
{
int a1 = Function1();
int a2 = Function2();
int a3 = Function3();
return 0;
}

上面三个函数的输出结果都是相同的,但是在函数效率上是有区别的。

Function1Function2 都是属于传值返回型函数传值返回型函数就是函数在 return 以后函数的栈帧空间会被回收,属于这个函数栈帧空间的变量都会被回收,这时 return 返回的值 a 会赋值给一个临时变量达到保存返回值的作用,然后再把这个临时变量赋值给其他变量( int a1 = Function1 )。

​ 但是注意,Function2 中的 static int a 并没有在函数的栈帧空间中存放,而是在静态区。这意味着就算函数的栈帧空间被回收了这个变量 a 还是存在在静态区的。这时你是不是会想到,不去创建临时变量直接把 a 赋值给其他变量。但是编译器并没有这样做,它还是生成了一个临时变量。是否生成临时变量取决于函数的返回类型,并没有根据返回变量去做更细的优化。

Function3引用返回型函数引用返回型函数return 过后函数的栈帧空间会被回收,然后函数会对返回变量 a 进行引用,然后把变量 a别名赋值给 a3。在这个过程中并没有创建临时变量,但是对返回变量 a 有要求。那就是就算函数栈帧空间回收了以后这个返回变量 a 还存在。Function3 中对变量 a 使用了 static 修饰做到了这一点,其他方法还有全局变量malloc 等等。

​ 使用引用返回的效率提升在返回变量是一个大返回值的时候尤其明显,可以省去复制粘贴的计算和内存还有时间。

读写返回值

引用做返回值有一个很妙的功能,就是可以通过引用做返回值实现对返回值的读写。下面举例说明。

1
2
3
4
5
typedef struct MyStruct
{
int capacity[10];
int size = 0;
}MySt;

定义一个结构体 MySt。现在要写一个函数对这个结构体中的第5个位置的值进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void ModifyMySt(MySt* ps, int pos, int a)
{
ps->capacity[pos] = a;
}

int GetMySt(MySt* ps, int pos)
{
return ps->capacity[pos];
}

int main()
{
MySt S;
ModifyMySt(&S, 5, 4);
cout << GetMySt(&S, 5) << endl;
return 0;
}

上面的是传统 C 语言写法,ModifyMySt 函数实现修改,GetMySt 函数实现读取。但是 C++ 中使用引用作为返回值后可以变得更简单更巧妙。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int& MyStAt(MySt& S, int pos)
{
return S.capacity[pos];
}

int main()
{
MySt S;
//ModifyMySt(&S, 5, 4);
//cout << GetMySt(&S, 5) << endl;
MyStAt(S, 5) = 4;
cout << MyStAt(S, 5) << endl;
return 0;
}

利用引用的特性,我们可以实现一个可读可写的操作函数 MyStAt

引用使用场景总结

任何场景下都可以使用引用作为函数参数,但是引用作为返回值只能用在函数栈帧空间回收后返回对象还存在的情况下使用。合理使用引用可以让我们的代码效率大大提升,且可读性良好。

七、内联函数 inline

​ 函数的调用总是伴随着资源消耗的,每调一次函数都需要去开辟一块栈帧空间。如果说有一个函数我们需要去调用几万次,那么编译器就需要去开辟几万次。这里是不是感觉有点臃肿了,所以我们得想一个办法让这个函数尽量少去占用资源。

​ C语言的经典做法是写成宏函数,将函数调用转换为运算符运算,从而节省内存。

1
2
3
4
5
6
7
8
9
//一般函数
int Max(int a,int b)
{
a > b ? a : b
return 0;
}

//宏函数
define Max(a, b) (((a) > (b)) ? (a) : (b))

​ C 语言的宏函数有个缺点就是太麻烦了,因为宏是直接暴力替换的,如果你 Max(a, b) 里的 a, b 写的是表达式,不加括号的话就很容易出现错误。因为你也不清楚有时候到底会替换为哪种表达式所以尽量加 (),而且宏函数的可读性差且不能调试。

​ C++ 针对函数调用过多造成资源占用过多这个问题提供了内敛函数。在函数的前面加上 inline 关键字修饰,使其变成内敛函数内敛函数会在调用的地方直接展开,不会有函数调用,且可以调试。但是并不是是个函数都适合成为内敛函数,适用于代码量少,调用次数多的函数。因为使用内敛函数会让可执行程序变大。

1
2
3
4
5
inline int Max(int a,int b)
{
a > b ? a : b
return 0;
}

​ 函数被 inline 修饰后不一定就是内敛函数,编译器会再检查一遍,是否符合内敛函数的条件(长度如何,是否递归)。所以 inline 修饰后只是对编译器的一个建议,建议这个函数升级成为内敛函数内敛函数通常在 debug 模式下并不会起作用,不过你也可以去打开内敛函数,让它在 debug 模式下也能起作用。特别的内敛函数声明和定义不能分离。

八、C++类和对象以及默认成员函数

​ C++ 和 C 语言之间最大的区别就是 C++ 出现了对象。C 语言是面向过程的,就好比你想吃饭那么你只能自己亲手做饭吃,切菜,下锅,炒菜,出锅这些事都需要自己去完成。但是在 C++ 中是面向对象的,就好比你去店里吃饭,你只管下单,然后复杂的做法过程你全程不管交给厨师去完成,你只管厨师把饭做好端给你吃。这其中就 C++ 就好比把做法的过程封装了起来变成厨师,你想吃饭的时候就可以让厨师去做,而不用你自己去做饭。

类和对象

struct 和 class

类(class)里定义的函数默认 inline 修饰,成员函数声明和定义分离,要在定义前面用域作用限定修饰符( :: )修饰类域。

C++ 兼容 C 语言,并且把结构体 struct 升级成了类。类中的变量称为成员变量,还可以定义成员函数。

1
2
3
4
5
6
7
8
9
10
struct Class
{
//成员函数
void Init()
{};

//成员变量
int a;
int* b;
};

C++ 增加了 classclass 中也可以定义成员变量和成员函数。

1
2
3
4
5
6
7
8
9
10
class Class
{
//成员函数
void Init()
{};

//成员变量
int a;
int* b;
};

类的访问限定符

类的访问限定符有三种分别是 public(公有)protected(保护)private(私有)

public 修饰的成员在类外可以之间被访问
protectedprivate 修饰的成员在类外不能直接被访问
class 的默认访问权限为 private , structpublic

类的实例化

下面定义了一个类 MyClass,这是这个类的声明。那么类里面的那些成员是声明还是定义呢?

1
2
3
4
5
6
7
8
9
10
11
class MyClass
{
public:
void MyFunc(int a)
{
cout << a + _b << endl;
}
private:
// 成员变量最好使用_作为标识,防止混淆
int _b = 1;
};

​ 在调用这个类的时候会为这个类和里面的成员变量开辟内存空间,这个时候就是类的实例化(对象的定义)。类定义里的成员都是声明,只有在调用类后为这个成员开辟了内存空间,这个时候成员才是定义。

1
2
3
4
5
6
7
int main()
{
// 定义类,开辟内存空间
MyClass Class;
Class.MyFunc(2);
return 0;
}
类的大小计算

C 语言中结构体 struct 的大小只算变量大小然后字节对齐(对齐数:编译器默认对齐数与成员大小的较小值)就可以算出,那么 C++ 中类的大小该怎么算呢?毕竟类中还可以定义函数。

1
2
3
4
5
6
7
int main()
{
MyClass Class;
Class.MyFunc(2);
cout << sizeof(Class) << endl;
return 0;
}

​ 上面的代码 cmd 给出的结果是 4,也就是说类的大小和结构体的大小计算方式应该是一样的。但是类里面的函数哪去了呢?类中的成员函数放到了公共代码区,每次调用函数的时候就去公共代码区里面调用。

没有成员变量的类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Test1
{
;
};

class Test2
{
int Sun(int a, int b)
{
return a + b;
}
};

int main()
{
Test1 A;
Test2 B;
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
return 0;
}

​ 上面两个类都没有成员变量,按之前的类大小计算来说这里的 cmd 输出应该是 0,但事实上 cmd 输出的是 1。这里就要说一下,没有成员变量的类对象需要 1 比特,这是为了占位,表示对象存在,不存储有效数据。

this指针

声明一个日期类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Date
{
public:
void Init(int year = 2024, int month = 2, int day = 28)
{
_year = year;
_month = month;
_day = day;
}
void Printf()
{
cout << "Date:" << _year << " " << _month << " " << _day << endl;
}
private:
int _year;
int _month;
int _day;
};

int main()
{
Date A;
A.Init();
A.Printf();
return 0;
}

​ 在 A.Printf() 的时候会去调用 Printf 这个函数,但是我们并没有进行传参函数就自己去调用成员变量。这是怎么做到的呐?这是因为编译器会对成员函数做一些处理。

​ 在类中隐式地存在一个 this 指针,用于指向当前类本身。在成员函数中,则会隐式地传递 this 指针。

1
2
3
4
5
// 隐式传递,this指针是不可修改的
void Printf(Date* const this)
{
cout << "Date:" << this->_year << " " << this->_month << " " << this->_day << endl;
}

​ 我们不能自己手动显式的去传递,但是我们可以在函数内部显式使用 this 指针。this 指针是形参在函数调用创建栈帧时创建的。

类的默认成员函数

构造函数和析构函数

构造函数是特殊的成员函数,负责初始化对象。构造函数有以下几个特性:

  • 构造函数的函数名与类名相同
  • 构造函数没有返回值也不需要写 void
  • 对象实例化时编译器自动调用对应的构造函数
  • 构造函数可以重载
  • 没有显示定义构造函数,编译器会自己生成一个无参的默认构造函数, 一旦用户显式定义了构造函数则编译器将不再生成
  • 编译器默认生成的构造函数,内置类型不做处理,自定义类型会去调用它的默认构造,一般情况下有内置类型的类需要自己写构造函数,全是自定义类型的类可以用编译器的构造函数
  • C++11打了一个补丁,内置类型可以给缺省值不传参就可以调用的构造函数就是默认构造函数

析构函数构造函数功能相反,负责在对象销毁时完成对象中资源的清理工作(防止内存溢出)。析构函数有以下几个特性:

  • 析构函数是在类名前面加上字符”~”
  • 无参数无返回值类型
  • 一个类只能有一个析构函数,如果未显示定义,系统会自动生成默认析构函数,析构函数不能重载
  • 对象生命周期结束时,编译器会自动调用析构函数
  • 编译器默认生成的析构函数,内置类型不做处理,自定义类型会去调用它的默认析构
  • 一般有动态申请空间的变量就需要写析构函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <assert.h>
// 定义顺序表类
class SeqList
{
public:
SeqList(int capacity = 4);
~SeqList();
private:
int* _data;
int _size = 4;
};

// 构造函数
SeqList::SeqList(int capacity)
{
int* New = (int*)malloc(sizeof(int) * capacity);
assert(New);
_data = New;
_size = capacity;
}

// 析构函数
SeqList::~SeqList()
{
assert(_data);
free(_data);
_data = nullptr;
_size = 4;
}

int main()
{
SeqList S1(10);
return 0;
}

拷贝构造函数

拷贝构造函数是特殊的成员函数,它的特性如下:

  • 拷贝构造函数是构造函数的一种重载形式
  • 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
  • 若未显式定义,编译器会生成默认的拷贝构造函数,内置类型成员完成值拷贝,自定义类型会调用它的拷贝构造
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SeqList
{
public:
SeqList(int capacity = 4);
~SeqList();
SeqList(const SeqList& S);
private:
int* _data;
int _size = 4;
};

SeqList::SeqList(const SeqList& S)
{
_data = S._data;
_size = S._size;
}

int main()
{
SeqList S1(10);
SeqList S2(S);
return 0;
}

​ 上面这个拷贝构造函数是有问题的,问题在于 _data = S._data 这只是一个浅拷贝。这相当于把 S2_data 指向 S1_data 就导致 S1S2 使用的是同一块空间,没有达成拷贝的目的。要实现这个类的拷贝构造得使用深拷贝完成。

1
2
3
4
5
6
7
8
9
10
11
12
SeqList::SeqList(const SeqList& S)
{
// 新开一块空间
int* New = (int*)malloc(sizeof(int) * (S._size));
assert(New);
for (int i = 0; i < S._size ; i++)
{
*(New + i) = *(S._data + i);
}
_data = New;
_size = S._size;
}

赋值运算符重载

运算符重载

C++ 为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有返回值类型。

函数原型: 返回值类型 operator 操作符(参数)

  • 不能通过连接其他符号来创建新的操作符
  • 重载操作符必须有一个类类型参数
  • 用于内置类型的运算符,其含义不能改变
  • 作为类成员函数重载时,形参中隐藏有一个this指针
  • 操作符是几个操作数,重载函数就有几个参数
  • .* :: sizeof ?: . 这个5个运算符不能重载

定义一个日期类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 声明日期类
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 0, int day = 0);
// 析构函数编译器生成
// 拷贝构造函数编译器生成

// 运算符 < 重载
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;
}
else
return false;
}

private:
int _year;
int _month;
int _day;
};

Date::Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}

int main()
{
Date D1(2024, 3, 3);
Date D2;
cout << (D1 < D2) << endl;
return 0;
}
赋值运算符重载

赋值运算符重载也是默认成员函数,赋值运算符重载主要用于已经存在的两个对象之间复制拷贝,用一个已经存在的对象初始化另一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
// 默认生成的赋值重载模板
class Class
{
&Class operator=(const Class& a)
{
if (this != &a) //判断是否是自己赋值自己
{
...;
}
return *this; //引用返回是为了连续赋值
}
};

​ 值得一提的是,默认生成的赋值重载跟拷贝构造行为一样内置类型浅拷贝,自定义类型去调用它的赋值重载赋值运算符只能重载成类的成员函数不能重载成全局函数(怕与编译器生成的赋值运算函数冲突)。

1
2
3
4
5
6
7
8
9
10
class Date
{
public:
void operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
};

但是赋值运算符还有这样的场景

1
2
int a = b = c = 1;
a = a;

这个场景之前的函数就没办法完成,需要改进。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Date
{
public:
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
};

取地址操作符重载和const修饰的取地址操作符重载

​ 我们通常可以对一个对象进行取地址。当对象是内置类型的时候编译器可以自动识别类型进行取地址,当对象是自定义类型的时候取地址符号 & 识别不了,需要我们写一个运算符重载函数去实现这个功能。当然取地址操作符重载const修饰的取地址操作符重载是类的默认成员函数,就算我们不写,编译器也会自己帮我们写。

九、C++构造函数和静态成员、友元 friend

对象初始化

​ 在创建对象时,编译器会调用构造函数对对象进行初始化工作。对象的初始化有两种方式,一种是构造函数体赋值另一个是初始化列表

构造函数体赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};

初始化列表

初始化列表以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个成员变量后面跟一个放在括号中的初始值或表达式。

  1. 每个成员变量在初始化列表中只能出现一次(只能初始化一次

  2. 类中包含以下成员,必须放在初始化列表位置进行初始化

    引用成员变量

    const 成员变量

    引用和 const 都是必须在定义的时候初始化

    自定义类型成员且该类没有默认构造时

  3. 初始化列表的初始化顺序是在类中的声明次序,与其在初始化列表中的先后次序无关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Test2类
class Test2
{
public:
// 构造函数
Test2(int a)
{
_a = a;
}
private:
int _a;
};

// Test1类
class Test1
{
public:
Test1(int a, int& b, int c, int test)
:_a(a)
, _b(b)
, _c(c)
// 调用Test2的构造函数
, _test2(test)
{}
private:
int _a;
int& _b;
const int _c;
Test2 _test2;
};

自定义类型的隐式类型转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A
{
public:
A(int a)
:_a(a)
{}
private:
int _a;
};

int main()
{
A a(1);
A b = 1; //隐式类型转换,整形转换为自定义类型
return 0;
}

A b = 1; 这一条命令中发生了隐式类型转换,首先使用 1 作为参数调用 A 类的构造函数,在使用拷贝构造拷贝给实例化类 b。但是通常编译器会在这里进行一个优化,直接调用构造函数,不再去执行拷贝构造

explicit

​ C++ 中的关键字 explicit 主要是用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换

1
2
3
4
5
6
7
8
9
class A
{
public:
explicit A(int a)
:_a(a)
{}
private:
int _a;
};

静态成员

静态成员变量

成员变量属于每一个类对象,储存在对象堆栈里面。静态成员变量属于类的每个对象,是共享的,存储在静态区静态成员变量不能给缺省值,因为静态成员变量不存储在对象里面所以不走初始化。静态成员变量需要在类外面定义。

静态成员函数

静态成员函数没有 this 指针,指定类域访问限定符就可以访问。

非静态成员可以调用静态成员静态成员不能调用非静态成员,因为非静态成员this 指针,需要指定类域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
public:
// 静态成员函数
static int TestA()
{
return _b;
}
private:
int _a = 0;
static int _b;
};

int A::_b = 0;

友元

在类中,有些私有属性 想让类外特殊的一些函数或者类进行访问,就需要用到友元的技术。

友元的目的就是让一个函数或者访问另一个类中的私有成员。友元使用 friend 关键字。

友元函数

  1. 友元函数可以访问类的私有和保护成员,但不是类的成员函数。
  2. 友元不能使用 const 修饰,友元函数可以在类定义里的任何地方声明,不受类访问限定符限制。
  3. 一个函数可以是多个类的友元函数
  4. 友元函数的调用与普通函数的调用原理相同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
public:
// 友元函数
friend int TestA(A& A);
private:
int _a = 0;
};

int TestA(A& A)
{
A._a += 1;
return A._a;
}

友元类

友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员

  • 友元关系是单向的,不具有交换性。
  • 友元关系不能传递。
  • 友元关系不能继承

内部类

​ 如果一个类定义在另一个类的内部,这个类就叫做内部类内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类内部类没有任何优越的访问权限。

内部类就外部类的天生友元,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中所有成员。但是外部类不是内部类的友元。

  • 内部类可以直接访问外部类中的 static 成员,不需要外部类的对象/类名
  • sizeof (外部类)=外部类,和内部类没有任何关系
  • 内部类受外部类的访问控制限定符修饰( public , private , protected )

匿名对象

匿名对象一般适用于只调用一次的场景,且匿名对象即用即销毁,生命周期只在当前行。匿名对象具有常性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 class A
{
public:
// 构造函数
A(int a)
{
_a = a;
}

void print()
{
cout << "666" << endl;
}
private:
int _a = 0;
};

int main()
{
A a(1);
A(2);

a.print();
A(2).print();
return 0;
}

匿名对象在 const 引用了过后,会延长它的声明周期,生命周期在当前函数局部域。

1
2
3
4
5
int main()
{
const A& ra = A(1);
return 0;
}

十、C++内存管理 new/delete

数据存储方式

程序中总是需要存储数据的。

数据 数据类型 存储位置
局部数据 局部变量等 储存在栈中
静态数据和全局数据 静态对象和全局对象 存储在静态区
常量数据 不可修改的常量(字符串等) 存储在常量区
动态申请数据 动态申请的数据 存储在堆中

编译生成的汇编代码也是存储在常量区的

从语言的角度来讲,内存主要分为四个区域:静态区(数据段)、常量区(代码段)、栈、堆。

内存空间
Stack(栈)
Heap(堆)
Global Segment(静态区)
Code Segment(常量区)

Stack(栈)

​ 内存中的是程序运行时自动拥有的一小块内存,大小由编译期时由编译器决定,用于局部变量的存放或者函数调用栈的保存。

  • 栈是由操作系统自动分配空间自动释放的,无需手动控制
  • 栈的生长方向向下,内存地址由高到低
  • 栈是有序存储数据的,且使用后进先出(LIFO)方法
  • 由于栈是由操作系统自动管理的,通常会在硬件层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高

栈随着函数的调用时开辟,函数的调用结束时销毁。具有即用即销毁的这么一个特点。

Heap(堆)

内存中的是为按需申请、动态分配空间而存在的,用于用户自己管理内存。

  • 堆是要由程序员自己控制开辟空间和释放空间的
  • 堆的生长方向向上,内存地址由低到高
  • 堆的存储并不是有序的,而是不同程序占用了不同的一块一块的内存,即使是同一个程序也可能占用了不同地方的多块内存
  • 堆是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片,比栈的效率低得多

堆上的空间需要用户手动开辟销毁,在这个过程中容易产生内存泄漏(忘记销毁)。

C语言的动态内存管理

​ C 语言的动态内存管理是通过使用 mallocfree 来完成的,malloc 可以在上开空间,但是开辟的空间不会进行初始化,free 可以释放空间。要实现初始化可以使用 calloc 来对堆上的空间初始化,但是 calloc 只会把每个空间都赋值为 0。当上已有的空间不够时可以扩容,C语言中使用 realloc 来实现扩容。

1
2
3
4
5
6
7
8
9
10
11
int main()
{
int size = 4;
int* p1 = (int*)malloc(sizeof(int) * size);
int* p2 = (int*)calloc(size, sizeof(int));
int* p3 = (int*)realloc(p2, sizeof(int) * 2);
p2 = NULL; //防止野指针
free(p1);
free(p3);
return 0;
}

C++的动态内存管理

C++ 新增加了两个个操作符 newdeletenew 用来在堆上开辟空间,delete 则是负责销毁空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
//申请1个int对象
int* p4 = new int;
delete p4;
//申请10个int对象
int* p5 = new int[10];
delete p5;
//申请1个int对象并初始化为1
int* p6 = new int(1);
delete p6;
//申请10个int对象,前5个依次初始化为对应值,其他初始化为0
int* p7 = new int[10] {1, 2, 3, 4, 5};
delete p7;
return 0;
}

newdelete内置类型来说和 C 语言的那一套没什么太大的区别,它们的优势体现在自定义类型上。new 在使用自定义类型时,不光要开空间还会去调用自定义类型构造函数delete 在使用自定义类型时,不光要销毁空间还会去调用自定义类型析构函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Test
{
public:
Test(int a, int b)
{
_a = a;
_b = b;
}
private:
int _a;
int _b;
};

int main()
{
//申请1个Test对象,并调用构造函数初始化
Test* p8 = new Test(1, 2);
delete p8;
//申请3个Test对象,并调用构造函数初始化
Test* p9 = new Test[3]{ Test(1,2), Test(3,4), Test(5,6) };
delete p9;
return 0;
}

自定义类型 new 时必须把构造函数的必要的参数传完,不然会编译报错。

operator new与operator delete

​ 在 C 语言中,出现错误时通常会给出一个错误代码.。要知道这个错误到底是什么,需要去查看错误代码对应的错误信息,这显得有点麻烦了。在 C++ 以及后面的面向对象语言中面对这个问题,通常不是给出一个错误代码,而是直接给出错误信息

newdelete 的实现其实是 malloc“与 free 的一次封装再加上调用一下构造函数析构函数

operator newoperator delete 都是一个函数并不是运算符重载。这两个函数的实现就是直接去调用 mallocfree,不同的是当去调用 mallocfree 出现错误时 operator newoperator delete 会根据错误给出错误信息,并不是错误代码

new 在调用完 operator new 后会去调用构造函数。delete 则是在调用析构函数之后去调用 operator delete

1
2
3
4
5
6
7
8
9
10
11
12
class Test
{
public:
Test(int a, int b)
{
_a = a;
_b = b;
}
private:
int _a;
int _b;
};

下面是上面 C++ 代码的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
	# 申请1个Test对象,并调用构造函数初始化
Test* p8 = new Test(1, 2);
00007FF73A541CF4 mov ecx,8
00007FF73A541CF9 call operator new (07FF73A54103Ch) # 调用operator new
00007FF73A541CFE mov qword ptr [rbp+3E8h],rax
00007FF73A541D05 cmp qword ptr [rbp+3E8h],0
00007FF73A541D0D je main+33Fh (07FF73A541D2Fh)
00007FF73A541D0F mov r8d,2
00007FF73A541D15 mov edx,1
00007FF73A541D1A mov rcx,qword ptr [rbp+3E8h]
00007FF73A541D21 call Test::Test (07FF73A5411AEh) # 调用构造函数
00007FF73A541D26 mov qword ptr [rbp+458h],rax
00007FF73A541D2D jmp main+34Ah (07FF73A541D3Ah)
00007FF73A541D2F mov qword ptr [rbp+458h],0
00007FF73A541D3A mov rax,qword ptr [rbp+458h]
00007FF73A541D41 mov qword ptr [rbp+3C8h],rax
00007FF73A541D48 mov rax,qword ptr [rbp+3C8h]
00007FF73A541D4F mov qword ptr [p8],rax
delete p8;
00007FF73A541D56 mov rax,qword ptr [p8]
00007FF73A541D5D mov qword ptr [rbp+408h],rax # 因为是内置类型所以没有做析构处理
00007FF73A541D64 mov edx,8
00007FF73A541D69 mov rcx,qword ptr [rbp+408h]
00007FF73A541D70 call operator delete (07FF73A541375h) # 调用operator delete
00007FF73A541D75 cmp qword ptr [rbp+408h],0
00007FF73A541D7D jne main+39Ch (07FF73A541D8Ch)
00007FF73A541D7F mov qword ptr [rbp+458h],0
00007FF73A541D8A jmp main+3B5h (07FF73A541DA5h)
00007FF73A541D8C mov qword ptr [p8],8123h
00007FF73A541D97 mov rax,qword ptr [p8]
00007FF73A541D9E mov qword ptr [rbp+458h],rax

定位 new 表达式(placement-new)

定位 new 表达式是在已分配的原始内存空间中调用构造函数初始化一个对象。

1
2
3
4
5
6
7
8
9
int main()
{
Test* p1 = (Test*)malloc(sizeof(Test) * 2);
new(p1)Test(1,2); //显式调用构造函数

p1->~Test(); //显式调用析构函数
free(p1);
return 0;
}

​ 定位 new 表达式可以用于内存池化技术。 申请上的空间是比较麻烦的且带有开销,如果我们要去反复使用上的空间,就得反复申请造成资源浪费。

​ 面对这个问题,我们可以使用内存池化技术,使用 malloc 在堆上申请一块空间,然后我们使用 定位 new 初始化内存,用完再调用析构函数销毁掉对象,但是不使用 free 释放空间,下一次我们再去使用堆上的空间时就直接再去使用已经创建好的空间调用定位 new,最后使用 free 释放堆上的空间。

十一、C++ 模板 template

​ C++ 新加入了一个关键字 template,它能实现 “模板” 的功能。我们之前写函数重载需要写多个命名但参数不同的函数,写起来非常的繁琐。使用 template 可以实现写一个模板,然后编译器会按照这个模板生成对应的函数,把繁琐的CV(复制粘贴)步骤取消掉了。

函数模板

完成两种不同类型的交换函数,通过 template <typename> 自动识别类型,只需写一个交换函数模板就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T>
void Swap(T& a, T& b)
{
T tmp = a;
a = b;
b = tmp;
}

int main()
{
int a = 1, b = 2;
double c = 1.1, d = 2.2;
Swap(a, b);
Swap(c, d);
cout << "a=" << a << ",b=" << b << endl;
cout << "c=" << c << ",d=" << d << endl;
return 0;
}

显示模板实例化

template 可以有多个参数。当 template 有两种类型,但只有一个参数时,可以通过显示模板实例化去指定参数的类型进行模板的实例化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
void Test(const T a, const T b)
{
return a + b;
}

int main()
{
// int 类型
int a = 1;
// double 类型
double b = 1.1;
// 指定为 int 类型
Test<int>(a, b);
return 0;
}

类模板

和函数模板一样,template 同样可以用于类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
template<class T>
class Test1
{
public:
Test1(const T a)
{
_a = a;
}
private:
T _a;
};

template<class T1, class T2>
class Test2
{
public:
Test2(const T1 a, T2 b)
{
_a = a;
_b = b;
}
private:
T1 _a;
T2 _b;
};


int main()
{
Test<int>(1);
Test<double>(1.1);
Test2<int, double>(1, 1.1);
return 0;
}

不过在使用类模板的时候,我们需要指定对应的模板类型,实例化类。有多个模板参数时,依次给定类型值。

十二、C++ 迭代器 iterator

概念:迭代器是一种检查容器内元素并遍历元素的数据类型,通常用于对C++中各种容器内元素的访问,但不同的容器有不同的迭代器,初学者可以将迭代器理解为指针。迭代器的关键字为:iterator

C++ 中的常用容器包括:string 类、vector 容器、set 容器、queue 容器、stack 容器、dequeue 容器、map 容器等。

begin()end()

顾名思义,**begin() 就是指向容器第一个元素的迭代器** 如果你是初学者,你可能会猜到 end()是指向容器最后一个元素的迭代器, 但事实并非如此,实际上,**end() 是指向容器最后一个元素的下一个位置的迭代器**

string 类为例展示迭代器的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void test_string1()
{
std::string s1("hello world");
// 迭代器遍历
for (std::string::iterator it = s1.begin(); it != s1.end(); ++it)
{
std::cout << *it << " ";
}
std::cout << std::endl;

// 范围for遍历
for (auto ch : s1)
{
std::cout << ch << " ";
}
std::cout << std::endl;
}

// 测试string类
int main()
{
// test_string3();
// test_string2();
test_string1();
return 0;
}
1
2
3
# cmd 输出
h e l l o w o r l d
h e l l o w o r l d

迭代器类型

按照迭代器的功能强弱,可以把迭代器分为以下几种类型:

  • 输入迭代器 (input iterator)

  • 输出迭代器 (output iterator)

  • 前向迭代器 (forward iterator)

  • 双向迭代器 (bidirectional iterator)

  • 随机访问迭代器( random-access iterator)

迭代器的功能

上面有5种类型的迭代器,我们先来了解一下他们的一些通用功能

  1. 可以进行比较两个迭代器是否相等( ==!= )
  2. 前置和后置递增运算( ++ )
  3. 读取元素的解引用运算符( * )只能读元素
  4. 箭头运算符( -> ),解引用迭代器,并提取对象的成员

下面将具体介绍这几种类型的迭代器其不同之处

输入迭代器

  • 只能利用迭代器进行输入功能
  • 它只能用于单遍扫描算法

输出迭代器

  • 只能利用迭代器进行输入功能
  • 只能用于单遍扫描算法

前向迭代器

  • 能利用迭代器进行输入和输出功能
  • 能用于多遍扫描算法

双向迭代器

  • 前置和后置递减运算( -- ),这意味这它能够双向访问

  • 能利用迭代器进行输入和输出功能

  • 能用于多遍扫描算法

随机访问迭代器

  • 能利用迭代器进行输入和输出功能
  • 前置和后置递减运算( -- )(意味着它是双向移动的)
  • 比较两个迭代器相对位置的关系运算符( <<=>>= )
  • 支持和一个整数值的加减运算( ++=--= )
  • 两个迭代器上的减法运算符( - ),得到两个迭代器的距离
  • 支持下标运算符( iter[n] ),访问距离起始迭代器n个距离的迭代器指向的元素
  • 能用于多遍扫描算法。 在支持双向移动的基础上,支持前后位置的比较、随机存取、直接移动n个距离