2、操作系统的调试支持
1、CPU调试支持
软件断点
软件断点就是INT3指令
- 机器码为1字节,内存ASCII码为0xCC,经典的“烫烫烫烫”;
- IDE下断点,就是在那条指令的位置插入INT3,或者那个字节替换为0xCC;
- 软件断点没有数量限制,很有灵活性
- 软件断点的局限性:
- 由于软件断点是插入或者修改指令,对于在ROM(只读存储器)中执行的程序比如BIOS或者其他固件程序,无法动态增加软件断点,只能用硬件断点;
- 属于代码类断点(可以让CPU执行到代码段内的某个地址时停下来),所以不适用于数据段和I/O空间;
CMD/DOS命令行与批处理
内部命令和外部命令
自定义结构
枚举
使用#define和const创建符号常量,使用enum不仅可以创建符号常量,还能定义新的数据类型。
枚举类型的声明和定义: 1
2
3
4
5
6
7
8
9
10
11
12
13//声明
enum wT{
Monday,
Tuseday,
Wednesday,
Thursday,
Firday,
Saturday,
Sunday
};
//定义
wT weekday;
1 | #include <iostream> |
使用细节:
- 枚举值不能做左值;
- 非枚举变量不可以赋值给枚举变量;
- 枚举变量可以赋值给非枚举变量;
结构体和联合体
1 | struct Student |
存储结构与结构体数据对齐
1 | #include <string.h> |
1 | struct s1 |
数据对齐原则--缺省对齐
- 32位CPU
- char:任何地址
- short:偶数倍地址
- int:4的整数倍
- double:8的整数倍
程序是可以修改默认编译选项的,修改结构体内存分配: 1
2
3
4
5
6
7Visual C++:
#pragma pack(n)
g++:
__attribute__(aligned(n))
__attribute__(__packed__)
C++基础句法
三种基本结构
分支结构
if分支语句
- 单一语句:在任何一个表达式后面加上分号;
- 符合语句:用一对花括号{}括起来的语句块,在语法上等效一个单一的语句;
- if语句:if语句是最常用的一种分支语句,也称为条件语句。
if语句练习:实现一个函数:输入一个年号,判断是否是闰年。闰年判断:1、能够被400整除;2、能被4整除但不能被100整除。
1 | #include <iostream> |
if语句练习:判断一个整数是否是另一个整数的倍数 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20#include <iostream>
using namespace std;
int main()
{
// b是否是a的倍数
int a = 0;
int b = 4;
//短路运算,除数不能为0
if ((a != 0) && b % a == 0)
{
cout << "b是a的倍数" << endl;
}
else
{
cout << "b不是a的倍数" << endl;
}
return 0;
}
switch分支语句
1 | #include <iostream> |
为了比较两种分支语句的区别,我们可以看更底层的汇编代码,拿上面例子举例:
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106// 多分支条件的if
// if, else if, else
COLOR color0;
color0 = BLUE;
001A5D85 mov dword ptr [color0],2
int c0 = 0;
001A5D8C mov dword ptr [c0],0
if (color0 == RED) {
001A5D93 cmp dword ptr [color0],0
001A5D97 jne main+164h (01A5DA4h)
//cout << "red" << endl;
c0 += 1;
001A5D99 mov eax,dword ptr [c0]
001A5D9C add eax,1
001A5D9F mov dword ptr [c0],eax
001A5DA2 jmp main+18Ch (01A5DCCh)
}
else if (color0 == GREEN) {
001A5DA4 cmp dword ptr [color0],1
001A5DA8 jne main+175h (01A5DB5h)
//cout << "green" << endl;
c0 += 2;
001A5DAA mov eax,dword ptr [c0]
001A5DAD add eax,2
001A5DB0 mov dword ptr [c0],eax
001A5DB3 jmp main+18Ch (01A5DCCh)
}
else if (color0 == BLUE) {
001A5DB5 cmp dword ptr [color0],2
001A5DB9 jne main+186h (01A5DC6h)
//cout << "blue" << endl;
c0 += 3;
001A5DBB mov eax,dword ptr [c0]
001A5DBE add eax,3
//cout << "blue" << endl;
c0 += 3;
001A5DC1 mov dword ptr [c0],eax
}
else {
001A5DC4 jmp main+18Ch (01A5DCCh)
//cout << "unknown color" << endl;
c0 += 0;
001A5DC6 mov eax,dword ptr [c0]
001A5DC9 mov dword ptr [c0],eax
}
// 多分支条件的switch
// switch,case,default
COLOR color1;
color1 = GREEN;
001A5DCC mov dword ptr [color1],1
int c1 = 0;
001A5DD3 mov dword ptr [c1],0
switch (color1)
001A5DDA mov eax,dword ptr [color1]
001A5DDD mov dword ptr [ebp-10Ch],eax
001A5DE3 cmp dword ptr [ebp-10Ch],0
001A5DEA je main+1C0h (01A5E00h)
001A5DEC cmp dword ptr [ebp-10Ch],1
001A5DF3 je main+1CBh (01A5E0Bh)
001A5DF5 cmp dword ptr [ebp-10Ch],2
001A5DFC je main+1D6h (01A5E16h)
001A5DFE jmp main+1E1h (01A5E21h)
{
case RED:
{
//cout << "red" << endl;
c1 += 1;
001A5E00 mov eax,dword ptr [c1]
001A5E03 add eax,1
001A5E06 mov dword ptr [c1],eax
break;
001A5E09 jmp main+1E7h (01A5E27h)
}
case GREEN:
{
//cout << "green" << endl;
c1 += 2;
001A5E0B mov eax,dword ptr [c1]
001A5E0E add eax,2
001A5E11 mov dword ptr [c1],eax
break;
001A5E14 jmp main+1E7h (01A5E27h)
}
case BLUE:
{
//cout << "blue" << endl;
c1 += 3;
001A5E16 mov eax,dword ptr [c1]
001A5E19 add eax,3
001A5E1C mov dword ptr [c1],eax
break;
001A5E1F jmp main+1E7h (01A5E27h)
}
default:
{
//cout << "unknown color" << endl;
c1 += 0;
001A5E21 mov eax,dword ptr [c1]
001A5E24 mov dword ptr [c1],eax
break;
}
}
return 0;
001A5E27 xor eax,eax
}
在分支不是很多的情况下,两者差异不大,但如果分支很多的话,switch的效率更高。从汇编看switch更像一个表结构,if是个树结构。
使用场景的区别:
- switch只支持常量值固定相等的分支判断;
- if可以做区间范围判断;
- switch能做的if都可以,但反之不行。
性能比较:
- 分支少的时候,差别不是很大,分支多时,switch性能较高;
- if开始处几个分支效率高,之后效率递减;
- switch的所有case速度几乎一样。
循环结构
C++中一共提供了三种循环语句:while、do while、for
三种循环结构
1 | //计算1 + 2 + …… + 99 + 100的和 |
1 | 三种汇编代码的底层逻辑: |
从底层逻辑上看,do while循环的效率最高,for循环的效率最低。
多层循环与循环优化
请输出所有形如aabb的四位完全平方数: 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
49#include <iostream>
using namespace std;
#include <math.h>
int main()
{
// aabb的完全平方数
//方法一:一个显而易见的方法
int n = 0;
double m = 0;
for (size_t a = 1; a < 10; a++)
{// for1
for (size_t b = 0; b < 10; b++)
{// for 2
n = a * 1100 + b * 11; //aabb
//求平方根,看是否是整数
m = sqrt(n);
if (m - int(m) < 0.00000001) //当差值小于一个极小值即可,这个值和浮点数的精度有关
{
cout << n << endl;
}
}// for 2
}// for1
//方法二:逆向思维
//先保证是平方根,再看是否是aabb类型
//这样做第一不需要双层循环,第二避免了开平方根造成的精度丢失和低速运算
int high, low;
//不需要从1开始,31*31=961,32*32=1024
for (size_t index = 31; ; index++)
{
n = index * index; //n就是我们要构造的完全平方数
if (n < 1000)
continue; // 继续下一次循环
if (n > 9999)
break; // 退出循环
high = n / 100; // 4567/100 = 45
low = n % 100; // 4567%100 = 67
if ((high / 10 == high % 10) && (low / 10 == low % 10)) // 判断aa, bb
{
cout << n << endl;
}
}
return 0;
}
函数
一个C++程序是由若干个源程序文件构成,一个源程序是由若干函数构成,函数将一段逻辑封装起来,便于复用; 从用户角度看,函数分成:
- 库函数:标准函数,由C++系统提供;
- 用户自定义函数:需要用户自定义后使用
函数的组成部分:
- 返回类型:一个函数可以返回一个值;
- 函数名称:函数的实际名称,函数名和参数列表一起构成了函数签名;
- 参数:参数列表包括函数参数的类型、顺序、数量。参数是可选的,即函数可以不包含参数;
- 函数主体:函数主体包含一组定义函数执行任务的语句。
函数重载与Name Mangling
函数重载Overload,函数的名称一样,但参数列表不同: 1
2
3int test(int a);
int test(double a);
int test(int a,double b);
程序是怎么选择调用哪个函数的呢?这就引入了Name Mangling,给重载的函数不同的签名,以避免调用时的二义性调用。
1 | #include <iostream> |
观察编译生成的.obj中间代码,可以看到有三个test函数:
1
2
3//?test@@YAHH@Z
//?test@@YAHN@Z
//?test@@YAHHN@Z
我们用VS自带的undname.exe工具,观察其中的一个可以看到: 1
即在程序中存储的是程序的签名。
函数与指针
指针的功能很强大,我们可以让指针指向任意的地址空间,所以我们可以让指针指向一个函数。
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#include <iostream>
int test(int a)
{
return a;
}
int test(double a)
{
return int(a);
}
int test(int a, double b)
{
return a + b;
}
int main()
{
int(*p)(int); //定义一个指针p,表示参数是int,返回值是int的一个函数
p = test; //指针变量赋值,此时虽然有三个同名函数,但明确指向的是参数为int的函数
int result = (*p)(1); //函数指针的使用,此时*p是间接引用
return 0;
}
要注意区别指向函数的指针和返回指针的函数:
- 每一个函数都占用一段内存单元,我们可以通过内存访问到函数,它们有个起始地址,我们可以用一个指针指向起始地址。指向函数入口地址的指针称为函数指针; 一般形式:数据类型(*指针变量名)(参数表)
- 区分与返回指针的函数的区别
- int(*p)(int); //是指针,指向一个函数的入口地址
- int* p(int); //是函数,返回的值是int指针
1 | #include <iostream> |
上文的例子中,bool ProcessNum(int x, int y, int(*p)(int a, int b))参数有函数指针,我们把函数名传递进去,真正的调用是在这个函数体内部的。我们把这样的调用方式称为回调函数。
命名空间
开发过程中,可能会出现相同的函数签名,但内部实现不一样的情况。为了解决这个问题,可以使用命名空间。 命名空间这个概念,可作为附加信息来区分不同库中相同名称的函数、类、变量等,命名空间即定义了上下文。本质上,命名空间就是定义了一个范围。 关键词:using和namespace的使用。
1 | #include <iostream> |
在开发中,命名空间的使用非常广泛,尤其是使用第三方库的时候。程序中常用的cout就属于std命名空间。
内联函数
如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。
1
2
3
4inline int MaxValue(int x,int y)
{
return (x > y) ? x : y;
}
引入内联函数的目的是为了解决程序中函数调用的效率问题,即空间换时间; 注意:内联函数内部不能有太复杂的逻辑,比如复杂的循环判断或者递归。编译器有时会有自己的优化策略,所以内联不一定起作用。VS中记得把C/C++设置中的内联优化打开。
递归
数学归纳法
数学归纳法是证明当n等于任意一个自然数时某命题成立。证明步骤分两步:
- 证明当n=1时命题成立;
- 假设n=m时成立,那么可以推导出在n=m+1时命题也成立(m为任意自然数)
递归背后的数学逻辑就是数学归纳法。 1
2
3
4
5
6
7
8
9
10
11
12
13
14斐波那契数列:1,1,2,3,5,8,13,21,34,……
1 n=1,2
Fib(n) = Fib(n-1)+Fib(n-2) n>2
程序实现:
int Fib(int n)
{
if(n == 0)
return 0;
else if(n == 1)
return 1;
else
return Fib(n-1)+Fib(n-2);
}
指针与引用
指针
内存由很多内存单元组成。这些内存单元用于存放各种类型的数据。为了标识内存单元,计算机对内存的每个单元都进行了编号,这个编号就称为内存地址,内存地址决定了内存单元在内存中的位置。程序员并不需要记住这些内存地址,C++的编译器让我们可以通过名字来访问这些内存位置。
指针本身就是一个变量,其符合变量定义的基本形式,它存储的值是内存地址。对于一个基本类型T,T* 是“到T的指针”类型,一个类型为T*的变量能够保存一个类型为T的对象的地址。
通过一个指针访问它所指向的地址的过程称为间接访问或者引用指针。这个用于执行间接访问的操作符是单目操作符*。
1 | int main() |
左值与右值
字符串本身就是一个字符数组,但是字符串还可以用指针来表示:
1
2
3
4
5
6
7
8
9
10int main()
{
//两种字符串表示
char strHello[] = { "hello" };
const char* pStrHello = "hello";
pStrHello = strHello; //正确,指针变量的值可以改变
strHello = pStrHello; //错误,数组变量的值不允许改变
return 0;
}
- 左值:编译器为其单独分配了一块存储空间,可以取其地址的,左值可以放在赋值运算符左边;
- 右值:指的是数据本身,不能取到自身地址,右值只能放在赋值运算符右边
左值最常见的情况就是函数和数据成员的名字。 右值是没有标识符、不可取地址的表达式,一般也称为“临时对象”。
a = b + c; &a是允许的操作,a是一个左值 &(b+c)不能通过编译,b+c是一个右值
C++原始指针
一般类型指针T*
T是一个泛型,泛指任何一种类型
1 | int i = 4; |
不论T是什么类型,T*这个指针的内存空间都是一样的,为4个字节
指针的数组 与 数组的指针
指针的数组 T*t[]:指针的数组仍然是数组,里面每个值是个指针(arrao of pointers) 数组的指针 T(*t)[] :一个指针,指向一个数组(a pointer to an array)
1 | int* a[4]; |
const pointer && pointer to const
1 | int main() |
关于const修饰:
- 看左侧最近的部分
- 如果左侧没有,则看右侧
pointer to pointer
1 | int a = 123; |
*操作符具有从右向左的结合性,**c 这个表达式相当于 *(*c),必须从里向外逐层求值 *c得到的是c指向的位置,即b **c相当于 *b,得到变量a的值
表达式 | 值 |
---|---|
a | 123 |
b | &a |
*b | a,123 |
c | &b |
*c | b,&a |
**c | *b,a,123 |
未初始化指针和非法指针
1 | int* a; |
上述操作并没有对指针a进行初始化,也就是说我们并不知道a最终会指向哪里。运气好的话定位到一个非法地址(程序不能访问的地址),程序会出错从而终止。最坏的情况下,a定位到了一个可以访问的地址,这样我们就无意间修改了它,这样的错误难以捕捉,引发的错误与原先用来操作的代码毫不相干,我们根本无法定位。
用指针进行间接访问之前,一定要确保它已经初始化,并且被恰当的赋值。
NULL指针
NULL指针是一个特殊的指针变量,表示不指向任何东西。
1 | int* a = NULL; |
NULL指针的概念非常有用,它给了一种方法,来表示特定的指针目前未指向任何东西。
对于一个指针,如果已经知道将被初始化为什么地址,那么请给他赋值,否则请把它设置为NULL,这样可以有效避免不可确定性访问的问题。
在对一个指针间接引用前,先判断这个指针的值是否为NULL。
指针使用完成后也请重新赋值为NULL。
野指针
野指针是指向“垃圾”内存的指针。if等判断对它们不起作用,因为没有置为NULL,它存有值,但是我们用不了;
一般情况下有三种情况被称为野指针 1. 指针变量没有初始化 2. 已经释放不用的指针没有置为NULL,如delete和free之后的指针 3. 指针操作超越了变量的作用域范围(指针指向具有一定生命周期的空间)
没有初始化的,不用的或者超出范围的指针,请一定置为NULL
指针的基本运算
1 | char ch = 'a'; |
&操作符不能做左值,&操作编译器做是事情是把变量的地址位置取出来,然后放在内存空间中。但是他本身并不是变量自身,仅仅是一块空间存储着变量地址,这块空间我们的程序是没办法获取到的。
间接引用操作当用作左值的时候,实际的操作是把变量ch当前的位置取出来(取空间),这种操作我们可以对这块空间进行操作,比如赋值操作。 当我们把他当作右值时,实际的操作取的就不是存储空间,而是存储空间中的值。
*cp + 1首先得到cp中的值,得到a,做+1操作就是对ASCII码进行操作,得到b。但是这个操作还是由编译器创造一块空间取值,我们得不到这个变量的地址,不能做左值。这个+1的操作是按照cp的类型来做加法的,移动的是cp这个类型的大小。
*(cp+1)操作我们先做了+1,而cp本身是个指针,我们做的是指针的加法,得到的是ch这个变量的地址的后面那个地址(做这个操作前要确定cp指向的地址后面的内容是可以访问的)。这个操作也是可以用作左值和右值,左值就是取地址,右值就是取空间中存储的值。
1 | int main() |
指针的++ 与 --
1 | char* cp2 = ++cp; |
1 | char* cp3 = cp++; |
前置操作先做加法再赋值,后置操作先赋值后做加法操作。 自减操作符和自增操作符相同,前置操作先做减法再赋值,后置操作先赋值再做减法。
自增自减操作获得的地址不能当作左值,它只是个地址的副本,没有明确存储的位置。
++操作符优先级高于*
++++和----等运算符
编译器程序分解符号的方法是:一个字符一个字符的读入,如果该字符可能组成一个符号,那么读入下一个字符,一直到读入的字符不能组成一个有意义的符号。这个处理过程称为“贪心法”。
1 | int a = 1,b=2; |
C++程序的存储区域划分
栈和队列
数据结构中有两种常见的结构,一种是栈结构,先进入的数据会被压在栈底,后进入的数据会被放在栈顶。是一种先进后出的结构;还有一种是队列结构,和栈相反,类似于生活中的队列,先进入的数据会先出队列。在C++中,栈是一种很常见的结构,我们一般性的变量都在栈上,函数也会在栈上处理。
存储区域划分
1 | #include <iostream> |
通过调试上面的代码,可以观察到一些程序中的地址分布:
上图是栈区变量b,s,p2的地址空间,可以看到虽然我们定义变量的顺序是b,s,p2,但是内存空间的地址位置是从高地址到低地址变化的,越早分配的变量,拿到的地址位置越高。
再观察p3变量。看p3本身的地址可以观察到它的地址分配再p2的上面,因为都是栈区变量,但是内部存储的一个地址并不是在栈区,是在常量区。在常量区中的内容,我们是无法修改的。但是如果p2指向的是一个字符数组,那么指向的就是栈区空间,是可以改变的。
继续观察p1,p1是在函数之外声明的,看地址也可以观察到,它的地址和b,s相差很大,可知它并不在栈区。这种定义在函数外的变量属于全局的区域。
当p1和p2执行完new操作后,观察p1和p2指向的地址空间,发现两个区域相邻。而且p1先new,p2后new,地址空间p2指向的地址也比p1要高。new操作会产生新的区域,我们称为堆区,和栈区相反,内存分配方式由低地址向高地址分配。
再看p4,p4本身是在main函数中定义的,是栈区变量。它new的是一个char型的数组,new出的地址和p1和p2指向的地址也很接近,可知也是堆区内。
对存储区域做一个总结,如下图:
动态分配资源--堆区
- 从现代编程语言的观点来看,使用堆,或者说使用动态内存分配,是一件很自然的事情;
- 动态内存带来了不确定性:内存分配耗时需要多久(分配大空间不好控制)?失败了怎么办?在实时性要求很高的场合,入嵌入式设备,这些不确定性是很严重的;
- 一般而言,当我们在堆上分配内存时,很多语言会使用new这样的关键字,也有些语言是隐式分配,不使用new的语义,但使用的是new的方式。在C++中new对应词是delete,因为C++是允许程序员完全接管内存的分配释放的。
分配和回收动态内存的原则
程序通常需要牵扯到三个内存管理器的操作:
- 分配一个某大小的内存块;
- 释放一个之前分配的内存块;
- 垃圾收集操作,寻找不再使用的内存块并给予释放;
这个回收策略需要实现性能、实时性、额外开销等各方面的平衡,很难有统一和高效的做法。 C++使用了1和2;Java使用了1和3。
资源管理方案--RAII(Resource Acquisition Is Initization)
- C++特有的资源管理方式,主流的编程语言中,C++是唯一一个依赖RAII来做资源管理的,核心思想是分配资源的时候就可以管理资源;
- RAII依托栈和析构函数,来对所有的资源--包括堆内存在内进行管理。比如一个对象在构造和析构中就把资源管理起来,当对象生存空间超出后进入析构状态,我们就可以进行资源的释放。堆RAII的使用,使得C++不需要类似于Java哪样的垃圾收集方法也能有效管理内存。
- RAII有些比较成熟的智能指针代表,如std::auto_ptr和boost::stared_ptr。
C++的几种变量的对比
stack | heap | |
---|---|---|
作用域 | 函数体内,语句块{}作用域,超出后被系统回收 | 整个程序范围内,由new、malloc开始,delete、free结束 |
编译期间大小确定 | 变量大小范围确定 | 需要运行期间才能确定 |
大小范围 | Win默认1M,Linux默认8M或10M,注意空间很小,不要分配大内存变量 | 所有系统的堆空间上限接近内存(虚拟内存)总大小(有一部分被OS占用) |
内存分配方式 | 地址由高到底减少 | 地址由低到高增加 |
内容是否可变 | 可变 | 可变 |
全局静态存储区 | 常量存储区 | |
---|---|---|
存储内容 | 全局变量,静态变量 | 常量 |
编译期间大小是否确定 | 确定 | 确定 |
内容是否可变 | 可变 | 不可变 |
内存泄漏(Memory Leak)问题
内存泄漏指的是程序中已经动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果;
内存泄漏发生的原因和排查方式:
- 内存泄漏主要发生在堆内存分配方式中,即“配置了内存后,所有指向该内存的指针都遗失了”。如果缺乏垃圾回收机制,这样的内存片就无法归还系统;
- 因为内存泄漏属于程序运行中的问题,无法通过编译识别,所以只能在程序运行过程中来判别和诊断。
比指针更安全的解决方案
使用指针是非常危险的行为,可能存在空指针,野指针的问题,并可能造成内存泄漏问题。可是指针又非常高效,所以我们希望以更安全的方式来使用指针。一般有两种典型方案:
- 使用更安全的指针:智能指针;
- 使用安全的方式:引用;
C++的智能指针
C++推出了四种常见的智能指针:unique_ptr、shared_ptr、weak_ptr和C++11中已经废弃(deprecated)的auto+ptr,C++17中auto+ptr已经被正式删除。
auto_ptr
auto_ptr是一种简单直接的智能指针,可以指向一个泛型对象。我们由new获得的对象在堆区中,如果auto_ptr指向这个对象,那么在auto_ptr对象销毁的时候,它所管理的对象也会一并delete掉,这不是一个特别合理的行为,因为指针指向对象不是一种强关联的关系。 auto_ptr还要注意所有权转移的问题:有一个auto_ptr指向一个对象,如果我们不小心把对象传递给另外的智能指针,原来的指针就不再拥有这个对象了。这个操作是通过C++中的拷贝构造和赋值完成的,会直接剥夺指针对源对象内存的控制权。被剥夺后,对象内存的所有权转移给新指针,然后将原对象指针置为nullptr。因为这个问题,导致auto_ptr存在很大的安全隐患,这是被废弃的重要原因。
1 | #include <string> |
1 | //memory文件 |
unique_ptr
auto_ptr提供了自动管理内存的一个方法,但是它和对象的耦合性太紧了,如果多方操作对象很容易出问题,所以推出了unique_ptr。unique_ptr是专属所有权,所以被unique_ptr管理的内存,只能被一个对象持有,不持支复制和赋值。 移动语义:虽然unique_ptr禁止了拷贝语义,但有时候我们也需要能够转移所有权,于是提供了移动语义,即可以使用std::move()进行所有权的转移。
1 | #include <memory> |
shared_ptr和weak_ptr
unique_ptr在同一时间只能由一个指针持有对象,使用上具有局限性。所以推出了shared_ptr。 shared_ptr通过一个引用计数共享一个对象,在这个机制上提供了可以共享所有权的智能指针,当然这需要额外的开销。当引用计数为0时,说明该对象没有被使用,可以进行析构。
1 | #include <iostream> |
引用计数也会带来一个严重问题:循环引用。即存在一种情况,有两个对象,对象A内部有shared_ptr指针指向B,B中也有shared_ptr指向A,那么循环引用会导致堆里面的内存无法正常回收,造成内存泄漏。
为了避免这种循环引用,标准库提供了weak_ptr,被用来和shared_ptr共同工作,用一种观察者模式工作。比如两个对象A和B互为关联,但B只是想获取A的一些属性,并不需要A的所有权,那么可以用weak_ptr,指向A但是并不拿A的引用计数。因为B没有A的引用计数,那么A销毁的时候,B也可以同时销毁。这就是观察者模式,观察者意味着weak_ptr只对shared_ptr进行引用,而不改变其引用计数,当被观察的shared_ptr失效后,相应的weak_ptr也失效。
1 | #include <string> |
1 | 上面代码输出: |
引用
引用在本质上仍然是是指针,只不过自身比较特殊,是不允许修改的指针。(我们常说java中没有指针,其实java中的指针就是引用)
在指针使用上,我们会遇到一些问题:
- 空指针
- 野指针
- 不知不觉改变了指针的值,我们却仍然在使用
使用引用,我们可以避免这些问题:
- 不存在空引用;
- 引用必须被初始化;
- 一个引用永远指向它初始化的那个对象,不允许被修改。
引用可以认为是指定变量的别名,使用时可以认为是变量本身:
1 | int x1 = 1,x2 = 3; |
当我们在函数中需要操作形参并且返回时一并返回,这时候我们就可以传递引用。
1 | #include <iostream> |
C++为什么要同时存在指针和引用?在java语言中我们直接使用引用,传统C语言我们都使用指针。C++可以认为是夹在C和java之间的一种。之所以要使用引用是为了支持函数的运算符重载。而C++为了兼容C语言不能摒弃指针。
在函数传递参数的时候,对于内置基础类型(int、double等)而言,在函数中传递值更高效(pass by value);在面向对象中自定义类型而言,在函数中传递const引用更高效(pass by reference to const)。
字符串
字符串变量与常量
字符串变量
- 字符串是一个特殊的字符数组,以空字符\0结尾
- 空字符\0自动添加到字符串的内部表示中
- 在声明字符串变量的时候,要时刻记得为这个空结束符预留一个额外元素的空间:char str[11] = {"helloworld"},十个字符要留11个位置
字符串常量
- 字符串常量就是一对双引号括起来的字符序列:"helloworld"
- 字符串常量中的每个元素可以作为一个数组元素访问
- 字符串常量也是以'\0'结尾的
字符串的指针表示
1 | const char* pStrHelloWorld = "helloworld"; |
表示一个char型的指针变量,指向内存中一个存储字符串“helloworld”的地址。
定义字符串的两种方式,char[]和char*,要区分以下两个概念:
- 区分地址本身和地址存储的信息;
- 区分可变与不可变;
1 | int main() |
字符串操作
头文件:string.h
字符串长度
1 | strlen(s); //返回字符串s的长度,要注意字符串的长度不包含'\0'。 |
字符串比较
1 | strcmp(s1,s2); |
两个字符串自左向右逐个字符相比,按照ASCII值大小来比较,直到出现不同字符或遇到'\0'为止
字符串拷贝
1 | strcpy(s1,s2); //赋值字符串s2到字符串s1 |
要注意一个陷阱,一定要保证字符串s1的内存大小能存的下要拷贝的s2的大小
字符串拼接
1 | strcat(s1,s2); //将字符串s2拼接到s1后面 |
这个更加要注意,s1的大小要足够存放拼接后的s1+s2字符串的大小 两个字符串指针不能直接相加,拼接必须要用这个函数。(指针相加是什么操作啊(#`O′))
字符串查找
1 | strchr(s1,ch); //指向字符串s1中字符ch第一次出现的位置 |
安全函数
以上程序都需要我们进行边界检查以防止内存问题。现在我们往往在程序中使用更加安全的API函数
上述所有API函数都有其_s版本,如strcpy_s(),在执行操作的同时要告诉系统当前可使用的缓冲区的大小。 strlen()的安全版本是strnlen_s(),传入字符串的同时要传入一个最大的边界,以防止某些字符串没有\0结束标志 。
字符串操作重点
- C原始字符串的操作在安全性和效率中存在一定问题: 1. 一定要注意不要缓冲区溢出。 2. strlen的效率可以提升,当字符串很长的时候strlen的效率不高,我们可以用空间换时间,在定义字符串的时候用变量记住它的长度,不使用strlen函数来计算
- 目前有一个很好的开源库Redis字符串:Redis库
string类
C++标准库中提供了string类型专门表示字符串
1 | #include<string> |
使用string可以更加方便和安全的管理字符串,对性能要求不是特别高的场景可以使用。
定义
1 | string s; //定义空字符串 |
字符串长度
1 | cout<<s1.length()<<endl; //输出字符串长度 |
字符串比较
直接使用== != < > >= < = 即可,比较ASCII码来比较。
转换为C风格字符串
1 | string s1 = "hello"; |
随机访问
字符串本身可以使用下标的方式访问
1 | string s = "hello"; |
字符串拷贝
不需要使用函数,直接使用=赋值
1 | string s1 = "hello"; |
字符串拼接
string重载了+和+=,不需要使用函数
1 | string s1 = "hell0",s2 = "world"; |
数组
数组
声明和数组下标
数组代表内存里一组连续的同类型的存储区,可以用来把多个存储区合并成一个整体。 int arr[10] = {1,2,3,4,5,6,7,8};
数组声明:
- int arr[10];
- 类型名int表示数组里所有元素的类型
- 整数10表示数组里包含的元素个数
- 数组元素个数不可以改变,是常量
使用注意:
- 每个元素都有下标,标识一个元素在当前数组容器中的位置。通过下标可以直接访问数组内任意元素
- 下标从零开始
- 超过范围的下标不可以使用,会内存错误。一定要注意,内存溢出是程序中经常出现的BUG
- 数组名和下标可以表示数组里的元素,如a[2]表示第三个元素
数组优点:
- 可以编写循环程序,依次处理数组里的所有元素
- 循环变量依次代表所有元素
数组使用常见错误:off-by-one error(差一错误)
考虑一个问题,假定整数x满足边界条件x>=16且x<=37,那么此范围内的整数x可能的取值有多少?
- 首先考虑最简单的情况(特例),然后将结果外推
- 仔细计算边界问题
特例:x的上界与下界重合,即x>=16与x<=16,显然其中的x个数为1;那么假定下界位low,上界位high;当low与high重合,个数为1,即共有high-low+1个元素。那么外推,这里共有37-16+1=22个元素。 最容易出错的地方就是+1,很容易就仅仅计算high-low。
编程技巧:
- 用数学上的左闭右开[,)区间表示,上述问题可以看作[16,38),即38-16=22
- C++中,我们编程也遵循这个原则,从0开始,使用非对称区间,让下界可以取到值,上界取不到值。这样设计程序,可以直接用上界-下界来取得范围大小;当取值范围为空的时候,上界值=下界值;即使取值范围为空,上界值也永远不可能小于下界值。
1 | //推荐的方式: |
增删改查
在尾部添加和删除操作,时间复杂度为O(1)。只需要操作尾部元素即可,对与其他元素没有干扰。
在数组中间进行添加和删除操作,时间复杂度为O(n)。在中间操作,会对其他元素造成影响,操作元素后面的每一个元素都需要进一步操作
数组的访问用遍历很高效,时间复杂度为O(1);
1 | //下标访问 |
数组的查找时间复杂度并不低,一般取绝与数组自身容量(数组容量很大的时候并不适合遍历查找,时间复杂度太高,可以用二分查找):
1 | //寻找a[]中第一个值为3的目标: |
二维数组
二维数组包含了两个维度的数组。
二维数组访问:
1 | int a[2][4] = {{1,2,3,4},{5,6,7,8}}; |
二维数组我们一般按照一行一行的遍历,虽然可以进行一列一列的遍历的,但是实际操作中不建议这么做:因为在一个小的时间窗口内,访问的地址越接近越好,这样执行的速度快;即我们一般把时间最长的循环放在内层,最短的循环放在外层,以减少CPU跨切循环的次数。
std::vector
Vector是面向对象方式的动态数组。
我们经常使用的简单的数组,因为定义的时候就有固定容量,无法实现动态扩容插入元素。
使用vector容器,可以轻松实现动态扩容添加元素。
添加
1 | #include<vector> |
遍历
1 | for(int index = 0;index < vec.size();++index) |
我们可以使用vector中的capacity()方法来查看vector当前的容量,用size()的方法来查看已经存储的元素的个数。这两者有区别。
删除
- vec.pop_back(); //尾删除
- vec.eraser(pos); //指定位置删除
要注意如果打算使用eraser的方法进行尾删除,要注意end()的位置。数组的区间往往是左闭右开,end()指向的是尾元素的后面的地址,并不是尾元素自身。
1 | vec.pop_back(); |
调试技巧——assert()
编写代码时,我们总会做出一些假设,断言就是用在代码中捕获这些假设。断言表示为一些布尔表达式,我们相信在程序中的某个特定点该表达式的值为真,可以在任意时刻启用和禁用断言来验证。
举个例子,在离散数学中有个德摩根定律,我们在C++语言中可以验证:
1 | #include<iostream> |
这种代码方式在开发中并不常见,而是使用以下断言的方式:
1 | #include<iostream> |
在这个程序中,并不会有任何输出结果,但是程序正常运行。
假如assert()函数中表达式出错(非真),整个程序在运行的时候就会报错,并指出哪里出了问题。
所以今后在我们开发的时候可以运用这一特性,完成开发测试用例的编写:
1 | assert(函数返回值 == 预期结果); |
只要函数运行符合预期结果,程序正常运行,一旦不符合就会出错。
注意assert()不是函数,而是一个宏定义。