软件断点
软件断点就是INT3指令
- 机器码为1字节,内存ASCII码为0xCC,经典的“烫烫烫烫”;
- IDE下断点,就是在那条指令的位置插入INT3,或者那个字节替换为0xCC;
- 软件断点没有数量限制,很有灵活性
- 软件断点的局限性:
- 由于软件断点是插入或者修改指令,对于在ROM(只读存储器)中执行的程序比如BIOS或者其他固件程序,无法动态增加软件断点,只能用硬件断点;
- 属于代码类断点(可以让CPU执行到代码段内的某个地址时停下来),所以不适用于数据段和I/O空间;
软件断点就是INT3指令
WindowsSDK,中选择下载安装程序,下载下来一个【winsdksetup.exe】,选择第二种Download下载方式,以安装包的形式下载(第一种是在线安装,无法保留安装包)。
点击两次next以后会出现一个界面,这里是选择下载哪些工具,只需要选择第二个【Debugging Tools for Windows】,这个就是需要的windbg。
下载完成后在下载目录中会出现一个文件夹,找到【x64 Debuggers And Tools-x64】和【x86 Debuggers And Tools-x64】点击安装即可。
Windbg Preview的功能和windbg的功能是一样的,但是安装更加方便,只需要在Microsoft Store商店中搜索即可。
"SRV*c:\mysymbol* http://msdl.microsoft.com/download/symbols",其中“c:”是我们自定义的文件夹路径,windbg在加载符号的时候,会把符号下载到这个目录。enum提供了创建常量的方式,可以替代const。使用#define和const可以创建符号常量,使用enum不仅可以创建符号常量,还能定义新的数据类型。
枚举类型的声明和定义:
1 | //声明,创建一个数据类型,还没有分配存储空间 |
1 | #include <iostream> |
使用细节:
enum只是定义了一个常量集合,里面没有元素,在内存中是当作int存储的。sizeof的值为4.
结构体与数组有两点不同:结构体可以在一个结构中声明不同的数据类型;相同的结构体可以相互赋值,但数组不行。
联合体(共用体)都是由不同的数据类型成员组成,但联合体同一时间只存放一个被选中的成员。如果对联合体的不同成员赋值,将会对其他成员重写。共用体的用途是当数据项使用多种格式单不会同时使用时,可以节省空间。
结构体和联合体的在内存中是小端存储的,从低地址开始存放。
1 | //结构体 |
有些信息在存储时,并不需要占用一个完整的字节,只需要占用几个或一个二进制位。位了节省存储空间,C预言提供了一种数据结构,称为“位域”或“位段”。
C/C++允许指定占用特定位数的结构成员。字段的类型应该为整形或枚举,接下来是冒号,冒号后面指定使用的位数,且可以使用没有名称的字段来提供间距。每个成员都被称为位字段。赋值时不能超过位域的允许范围,如果超过,仅将等号右侧值的低位赋给位域。
1 | struct reg{ |
1 | #include <string.h> |
联合体内部公用一块内存空间,所以内部占用空间按照最大的数据类型来存储,联合体适合内存受限的场景,比如嵌入式系统。也因为这个原因,同一时间只有一个成员有效,写入一个成员时会覆盖其他成员。
空结构体(不含数据成员)的大小是1,不是0。编译器必须要为其分配一个存储空间用于占位。
1 | struct s1 |

可以看到,如果结构体中存在一个double,则会按照8字节来对齐。然后所有成员按照顺序依次分配内存,编译器会在成员之间插入填充字节,确保下一个成员满足对齐要求。如果存在结构体嵌套,也按照这个对齐规则,所有结构体中的最大成员值来进行内存对齐。结构体的总大小波许是最大成员对齐值的整数倍。
有一点需要注意,如果结构体中存放了一个数组,数组是按照单个变量一个一个摆放的,不要把数组视为一个整体。
程序是可以修改默认编译选项的,修改结构体内存分配,如果设置为1,那就是连续的内存分配布局:
1 | Visual C++: |

if语句练习:实现一个函数:输入一个年号,判断是否是闰年。闰年判断:1、能够被400整除;2、能被4整除但不能被100整除。
1 | #include <iostream> |
if语句练习:判断一个整数是否是另一个整数的倍数:
1 | #include <iostream> |
1 | switch(表达式) |
1 | #include <iostream> |
为了比较两种分支语句的区别,我们可以看更底层的汇编代码,拿上面例子举例:
1 | // 多分支条件的if |
在分支不是很多的情况下,两者差异不大,但如果分支很多的话,switch的效率更高。从汇编看switch更像一个表结构,if是个树结构。
使用场景的区别:
性能比较:
C++中一共提供了三种循环语句:while、do while、for
1 | //计算1 + 2 + …… + 99 + 100的和 |
1 | 三种汇编代码的底层逻辑: |
从底层逻辑上看,do while循环的效率最高,for循环的效率最低。
请输出所有形如aabb的四位完全平方数:
1 | #include <iostream> |
一个C++程序是由若干个源程序文件构成,一个源程序是由若干函数构成,函数将一段逻辑封装起来,便于复用;
从用户角度看,函数分成:
函数的组成部分:
函数的参数传递分为形参和实参。形参出现在函数定义中,在整个函数体内都可以使用,离开函数体则不能使用。实参出现在主调函数中,进入被调函数中,实参变量也不可以使用。形参和实参的功能是做数据传送。发生函数调用时,主调函数把实参的值传送给被调函数的形参,从而实现主调函数向被调函数的数据传送。
C语言的传递方式有两种:值传递和指针传递;C++新增了引用传递。
给函数传递实参遵循变量初始化的规则。非引用类型的形参以相应实参的副本初始化,需要给形参分配存储单元,对形参的任何修改仅作用于副本,并不影响实参本身。如果传递的是对象,还将调用拷贝构造函数。
为了避免传递副本的开销,可以将形参指定为引用类型。传递引用不会再内存中产生实参的副本,是直接对实参操作。因此当传递的参数数据较大时,用引用比普通参数传递的效率和空间占用都要好。对引用形参的任何修改会直接影响实参本身,所以如果不需要修改相应实参,可以将引用形参定义为const。
使用指针作为函数的实参虽然也能达到使用引用的效果,但在被调函数中同样要给形参分配存储单元,且需要重复使用“*指针变量名”进行运算。这很容易产生错误且程序阅读性较差。且在主调函数的调用点必须传递实参的地址,相比较引用更复杂不清晰。
不过我们可以传递指针的引用:
1 | //两个数交换: |
在函数声明或定义时,直接对参数赋值就是默认参数。在函数调用时没有指定与形参相对应的实参时就自动使用默认参数。
1 | int mal(int a, int b = 3, int c = 6, int d = 8); |
默认参数只可以在函数声明中设定一次。只有在无函数声明时,才可以在函数定义中设定。
默认参数定义的顺序为自右到左。如果一个参数设定了默认值,那么其右边的参数都要有默认值。
默认值可以是全局变量、全局常量,甚至是一个函数,但不可以是局部变量。因为默认参数的调用是在编译器确定的,局部变量的位置与默认值在编译时无法确定。
编程时可能会遇到一些参数可变的函数,比如printf()函数,原型如下,除了有一个参数format固定外,后面跟的参数的个数和类型是可变的,用三个点“...”做参数占位符
1 | int printf(const char* format, ...); |
1 | //接受可变参数的函数实现多个数相加 |
函数重载Overload,函数的名称一样,但参数列表不同:
1 | int test(int a); |
程序是怎么选择调用哪个函数的呢?这就引入了Name Mangling,给重载的函数不同的签名,以避免调用时的二义性调用。
1 | #include <iostream> |
观察编译生成的.obj中间代码,可以看到有三个test函数:
1 | //?test@@YAHH@Z |
我们用VS自带的undname.exe工具,观察其中的一个可以看到:

即在程序中存储的是程序的签名。需要注意函数重载和函数的返回值无关,只和参数列表有关,返回值类型不影响函数的重载。如果参数类型相同,返回值不同,编译器会报错,这不是重载。
函数重载减少了函数命名的数量,避免了名字空间的污染。C++中类的构造函数全同名,如果没有重载机制实例化不同对象将非常麻烦。操作符重载本质上也是函数重载,大大丰富了已有操作符的含义。
指针的功能很强大,我们可以让指针指向任意的地址空间,所以我们可以让指针指向一个函数。
1 | #include <iostream> |
要注意区别指向函数的指针和返回指针的函数:
函数指针一般形式:数据类型(*指针变量名)(参数表)
1 | #include <iostream> |
上文的例子中,bool ProcessNum(int x, int y, int(*p)(int a, int b))参数有函数指针,我们把函数名传递进去,真正的调用是在这个函数体内部的。我们把这样的调用方式称为回调函数。
开发过程中,可能会出现相同的函数签名,但内部实现不一样的情况。为了解决这个问题,可以使用命名空间。
命名空间这个概念,可作为附加信息来区分不同库中相同名称的函数、类、变量等,命名空间即定义了上下文。本质上,命名空间就是定义了一个范围。
关键词:using和namespace的使用。
1 | #include <iostream> |
在开发中,命名空间的使用非常广泛,尤其是使用第三方库的时候。程序中常用的cout就属于std命名空间。
内联函数用inline修饰,分为两种:
类中定义的成员函数默认全是内联的,可以显式的加上inline标识符,也可以不加。如果类中声明的函数没加inline,但类外定义时加了inline,该成员函数也是内联函数。
通常在编译时,调用内联函数的地方将不使用函数调用,而是使用函数体直接替换调用处的函数名,形式类似宏替换,这种替换称为内联扩展。内联扩展可以消除函数调用时的时间开销。inline对编译器来说只是一个建议,编译器可以选择忽略。一般来说内联机制适用于优化小的、只有几行的但被经常调用的函数。大多数的编译器都不支持递归函数的内联。
1 | inline int MaxValue(int x,int y) |
递归是重要的程序设计方法。如果在一个函数、过程或数据结构的定义中又调用了它自身,那么这个函数、过程或数据结构称为是递归定义的。
数学归纳法是证明当n等于任意一个自然数时某命题成立。证明步骤分两步:
递归背后的数学逻辑就是数学归纳法。
1 | 斐波那契数列:1,1,2,3,5,8,13,21,34,…… |
递归是一种重要的编程思想,很多重要的算法都包含递归的思想;但递归在时间和空间上都有很大的缺陷:空间上需要开辟大量的栈空间;时间上可能需要大量的重复运算。
递归优化思路:
1 | // 使用循环来代替递归调用 |
1 | //尾递归 |
1 | //动态规划 |
在C++程序中调用被C编译器编译后的函数,需要加extern "C"。
C++语言是一种面向对象编程语言,为了支持重载机制,在编译器生成的汇编代码中,要对函数的名字进行一些处理,加入比如函数的返回类型等信息。在C语言中,值是简单的函数名字而已,不会加入其他的信息,也就是说:C++和C语言对产生的函数名字的处理是不一样的。这样在链接阶段若是按照C++的函数命名规则去查找C编译器编译的函数,就会出现链接错误,
extern "C"告诉编译器该函数是用C编译器编译的,请用C的方式来链接它们,从而解决了此问题。
1 | //用法1: |
内存由很多内存单元组成。这些内存单元用于存放各种类型的数据。为了标识内存单元,计算机对内存的每个单元都进行了编号,这个编号就称为内存地址,内存地址决定了内存单元在内存中的位置。程序员并不需要记住这些内存地址,C++的编译器让我们可以通过名字来访问这些内存位置。
指针本身就是一个变量,其符合变量定义的基本形式,它存储的值是内存地址。对于一个基本类型T,T* 是“到T的指针”类型,一个类型为T*的变量能够保存一个类型为T的对象的地址。
通过一个指针访问它所指向的地址的过程称为间接访问(indirection)或者引用指针(dereferencing the point)。这个用于执行间接访问的操作符是单目操作符*。
1 | int main() |
变量、地址和指针变量总结:
一个变量具有三个重要的信息:
指针变量是一个专门用来记录变量地址的变量,通过指针变量可以间接访问另一个变量的值,这里的另一个变量也可以是个指针,这就是多级指针的问题;
字符串可以用字符数组表示,也可以用指针来表示:
1 | int main() |
strHello不可改变,strHello[index]的值可以改变;pStrHello可以改变,pStrHello[index]的值能否改变取决于所指的存储区域是否可变。这里就涉及到了左值与右值的概念,左值与右值是相对赋值运算符"="来说的,左边需要一个存储单元,右边需要一个值:
左值最常见的情况就是函数和数据成员变量的名字;右值是没有标识符、不可取地址的表达式,一般也称为“临时对象”。
比如:a = b + c;&a是允许的操作而&(b+c)不能通过编译,因此a是一个左值,(b+c)是一个右值;
T是一个泛型,泛指任何一种类型
1 | int i = 4; |
不论T是什么类型,T*这个指针的内存空间都是一样的,为4个字节
指针的数组 T* t[]:指针的数组仍然是数组,里面每个值是个指针(array of pointers)
数组的指针 T(*t)[] :一个指针,指向一个数组(a pointer to an array)
1 | int* a[4]; //一个数组,每个元素都是int指针 |
1 | int main() |
如何确定const修饰的内容:
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最终会指向哪里。运气好的话定位到一个非法地址(程序不能访问的地址),程序会出错从而崩溃终止。最坏的情况下,a定位到了一个可以访问的地址,这样我们就无意间修改了它,这样的错误难以捕捉,引发的错误与原先用来操作的代码毫不相干,我们根本无法定位。
用指针进行间接访问之前,一定要确保它已经初始化,并且被恰当的赋值。
NULL指针是一个特殊的指针变量,表示不指向任何东西。
1 | int* a = NULL; |
NULL指针的概念非常有用,它给了一种方法,来表示特定的指针目前未指向任何东西。
在早期的C语言中,编译器定义:#define NULL ((void*)0)。void*是一个很万能的指针,可以转换任意的类型。参数传递时经常用到,函数编写时无法预测将来要传递什么信息,就用void*代替。
C++语言诞生时没有把NULL定义为void*,而是定义为了0:
1 | #ifndef NULL |
C++中NULL表示为int而不是指针,和C语言的差异会导致问题,所以C++11中用nullptr来代替(void*)0,NULL只表示0。在新的C++标准中,空指针尽量使用nullptr来表示。
1 | void func(void* i) |
函数指针是指指向函数而非指向对象的指针。像其他指针一样,函数指针也指向某个特定的函数类型,函数类型由其返回类型以及形参列表确定,与函数名无关:
1 | bool (*pf)(const string&, const string&) |
函数指针类型相当冗长。使用typedef为指针类型定义同义词,可以将函数指针的使用大大简化。对函数指针初始化时,可以直接使用函数名。直接引用函数名等效于在函数名上应用取地址操作符。指向函数的指针可用于调用它所指向的函数。
1 | typedef bool (*cmpFcn)(const string&, const string&); |
函数的形参也可以是指向函数的指针,可以用以下两种方式编写:
1 | void useBigger(const string&, const string&, bool(const string&,const string&)); |
函数的返回值也可以返回指向函数的指针,但是写出这种返回类型相当不容易:
1 | int (*ff(int))(int*, int); |
野指针是指向“垃圾”内存的指针。if等判断对它们不起作用,因为没有置为NULL,它存有值,但是我们用不了;
一般情况下有三种情况被称为野指针: 1. 指针变量没有初始化; 2. 已经释放不用的指针没有置为NULL,如delete和free之后的指针; 3. 指针操作超越了变量的作用域范围(指针指向具有一定生命周期的空间);
没有初始化的,不用的或者超出范围的指针,请一定置为NULL
C/C++常常把地址当作整数处理,但不意味着程序员可以对地址进行各种算术操作。指针可以做的运算有限,像指针与其他变量的乘除、两个指针之间的乘除、两个指针相加都是没有意义、不被编译器接受的。
指针加上一个整数的结果是另一个指针。当一个指针和一个整数量进行算术运算时,整数在执行加法运算前始终会根据合适的大小进行调整,这个“合适的大小”指的是指针所指向的类型的大小。假设某台机器float占据4字节,则float类型指针+3时,这个3将根据float的大小变为12,即指针增加3个floag的大小。
也可以进行指针之间的算数运算,但只有两个指针指向同一个数组中的元素时才允许这种运算,如果不是同一个数组中的元素,相减的结果是未定义的。减法的运算表示的是两个指针在内存中的距离,以数组元素的长度为单位,而不是以字节为单位
1 | char ch = 'a'; |

&操作符不能做左值,&操作编译器做是事情是把变量的地址位置取出来,然后放在内存空间中。但是他本身并不是变量自身,仅仅是一块空间存储着变量地址,这块空间的地址我们的程序是没办法获取到的。就像上图,&ch操作拿到的是ch变量的地址,但取出的信息不会像cp这个变量一样有一块能获取地址的内存空间来存储。一定要注意,虽然cp是ch的地址,&ch也是ch的地址,但这俩不是一个概念,只是恰好存的东西一样。

1 | *cp = 'b'; //左值,取的是空间 |
间接引用操作当用作左值的时候,实际的操作是把变量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++中,栈是一种很常见的结构,我们一般性的变量都在栈上,函数也会在栈上处理。
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指向的地址也很接近,可知也是堆区内。
对存储区域做一个总结,如下图:

程序通常需要牵扯到三个内存管理器的操作:
这个回收策略需要实现性能、实时性、额外开销等各方面的平衡,很难有统一和高效的做法。C++语言使用了1和2;Java使用了1和3。
| stack | heap | |
|---|---|---|
| 作用域 | 函数体内,语句块{}作用域,超出后被系统回收 | 整个程序范围内,由new、malloc开始,delete、free结束 |
| 编译期间大小确定 | 变量大小范围确定 | 需要运行期间才能确定 |
| 大小范围 | Windows默认1M,Linux默认8M或10M,注意空间很小,不要分配大内存变量 | 所有系统的堆空间上限接近内存(虚拟内存)总大小(有一部分被OS占用) |
| 内存分配方式 | 地址由高到底减少 | 地址由低到高增加 |
| 内容是否可变 | 可变 | 可变 |
| 全局静态存储区 | 常量存储区 | |
|---|---|---|
| 存储内容 | 全局变量,静态变量 | 常量 |
| 编译期间大小是否确定 | 确定 | 确定 |
| 内容是否可变 | 可变 | 不可变 |
内存泄漏指的是程序中已经动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果;
内存泄漏主要发生在堆内存分配方式中,即“配置了内存后,所有指向该内存的指针都遗失了”。如果缺乏垃圾回收机制,这样的内存片就无法归还系统;
因为内存泄漏属于程序运行中的问题,无法通过编译识别,所以只能在程序运行过程中来判别和诊断。
使用指针是非常危险的行为,可能存在空指针,野指针的问题,并可能造成内存泄漏问题。可是指针又非常高效,所以我们希望以更安全的方式来使用指针。一般有两种典型方案:
C++推出了四种常见的智能指针:unique_ptr、shared_ptr、weak_ptr和C++11中已经废弃(deprecated)的auto+ptr,C++17中auto_ptr已经被正式删除。
auto_ptr是一种简单直接的智能指针,可以指向一个泛型对象。我们由new获得的对象在堆区中,这不是一个特别合理的行为,因为指针指向对象不是一种强关联的关系。
需要注意,auto_ptr的析构函数中删除指针用的是delete而不是delete[],所以不应该用auto_ptr来管理指针数组。同时因为STL标准容器需要用到大量的拷贝赋值操作,auto_ptr也不能用于STL中。
所有权转移:有一个auto_ptr指向一个对象,如果我们不小心把对象传递给另外的智能指针(即有另一个auto_ptr指向了原来的对象),原来的指针就不再拥有这个对象了。这个操作是通过C++中的拷贝构造和赋值完成的,会直接剥夺指针对源对象内存的控制权。被剥夺后,对象内存的所有权转移给新指针,然后将原对象指针置为nullptr。这个问题影响很大,如果将auto_ptr作为函数参数按值传递,因为函数在调用过程中会产生一个局部对象接收传入的auto_ptr(拷贝构造),会导致传入的实参失去对原有对象的控制权,原对象在函数退出后被局部auto_ptr删除。因为这个问题,导致auto_ptr存在很大的安全隐患,这是被废弃的重要原因。

1 | #include <string> |
1 | //xmemory文件 |
auto_ptr提供了自动管理内存的一个方法,但是它和对象的耦合性太紧了,如果多方操作对象很容易出问题,所以推出了unique_ptr。unique_ptr是专属所有权,所以被unique_ptr管理的内存,只能被一个对象持有,不支持复制(参数传递)和赋值(=)操作。
移动语义:虽然unique_ptr禁止了拷贝语义,但有时候我们也需要能够转移所有权,于是提供了移动语义,即可以使用std::move()进行所有权的转移。

1 | #include <memory> |
unique_ptr在同一时间只能由一个指针持有对象,使用上具有局限性。所以推出了shared_ptr。
shared_ptr通过一个引用计数共享一个对象,在这个机制上提供了可以共享所有权的智能指针,当然引用计数需要额外的开销。当引用计数为0时,说明该对象没有被使用,可以进行析构。

1 | #include <iostream> |
引用计数也会带来一个严重问题:循环引用。即存在一种情况,有两个对象,对象A内部有shared_ptr指针指向B,B中也有shared_ptr指向A,当A使用完毕打算回收内存空间时,会检查内部变量pA,此时会去尝试清理B,但B中也有pB指向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中的指针就是引用)
在指针使用上,我们会遇到一些问题:
使用引用,我们可以避免这些问题:
sizeof(引用)得到的是所指向的变量或者对象的大小,sizeof(指针)得到的是指针本身的大小。如果返回动态分配的对象或内存,千万要使用指针,使用引用很可能引起内存泄漏。
1 | int x1 = 1,x2 = 3; |
1 | #include <iostream> |
1 | class ConstRef |
C++为什么要同时存在指针和引用?在java语言中我们直接使用引用,传统C语言我们都使用指针。C++可以认为是夹在C和java之间的一种。之所以要使用引用是为了支持函数的运算符重载。而C++为了兼容C语言不能摒弃指针。
在函数传递参数的时候,对于内置基础类型(int、double等)而言,在函数中传递值更高效(pass by value);在面向对象中自定义类型而言,在函数中传递const引用更高效(pass by reference to const)。
字符串是由零个或多个字符组成的有限串行。
子串的定义:串中任意个连续的字符组成的子序列,并规定空串是任意串的子串,任意串是其自身的子串
C风格的字符串包括两种:字符串常量和末尾添加了'\0'的字符数组。无论怎么表示,都以'\0'结尾。
1 | const char* pStrHelloWorld = "helloworld"; |
表示一个char型的指针变量,指向内存中一个存储字符串“helloworld”的地址。
定义字符串有两种方式,char[]和char*,要区分以下两个概念:
1 | int main() |
char* pstr是指针,pstr本身可变,pstr[index]是否可变取决于指向的存储区间是否可变;
char str[]是字符串数组,str本身的值不允许改变,但可以改变str[index];
头文件:string.h
C/C++字符串处理函数,传递给这些标准库函数例程的指针必须具有非零值,并且指向以null结束的字符数组中的第一个元素。其中一些标准库函数会修改传递给它的字符串,这些函数将假定它们所修改的字符串具有足够大的空间接收新字符串,程序员需要保证目标字符串足够大。
一般来说,我们使用指针的算数操作来编译C风格的字符串,每次对指针进行测试并递增1,直到到达结束符null为止:
1 | const char *cp = "helloworld"; |
1 | strlen(s); //返回字符串s的长度,要注意字符串的长度不包含'\0'。 |
1 | //自定义实现strlen |
1 | strcmp(s1,s2); |
两个字符串自左向右逐个字符相比,按照ASCII值大小来比较,直到出现不同字符或遇到'\0'为止;
1 | //自定义实现strcmp: |
1 | strcpy(s1,s2); //赋值字符串s2到字符串s1的地址空间 |
要注意一个陷阱,一定要保证字符串s1的内存大小能存的下要拷贝的s2的大小
1 | //自定义实现strcpy |
拷贝还可以使用内存拷贝函数:
1 | //从src所指向的内存地址起始位置开始拷贝n个字节到dest所指的内存地址的起始位置中。返回指向dest的指针 |
memcpy和strcpy的区别:
1 | strcat(s1,s2); //将字符串s2拼接到s1后面(覆盖s1的'\0'),返回s1 |
1 | //自定义实现strcat: |
这个更加要注意,s1的大小要足够存放拼接后的s1+s2字符串的大小
两个char*不能直接相加,字符串拼接必须要用这个函数。(指针相加是什么操作啊(#`O′))
1 | strchr(s1,ch); //指向字符串s1中字符ch第一次出现的位置 |
1 | //将s中的前n个字节用ch替换并返回s |
这个函数的作用是在一块内存中填充某个给定的值,是对较大的结构体或者数组进行清零的最快方法。
C语言的字符串处理有其漏洞所在。上面的所有方法都无法做边界检查,如果不注意就会发生缓冲区溢出的问题,这个问题在编码时是很严重的事故。。现在我们往往在程序中使用更加安全的API函数:
上述所有API函数都有其_s版本,如strcpy_s(),在执行操作的同时要告诉系统当前可使用的缓冲区的大小。 strlen()的安全版本是strnlen_s(),传入字符串的同时要传入一个最大的边界,以防止某些字符串没有'\0'结束标志。
C++标准库中提供了string类型专门表示字符串
1 | #include<string> |
使用string可以更加方便和安全的管理字符串,对性能要求不是特别高的场景可以使用。
1 | string s; //定义空字符串 |
1 | cout<<s1.length()<<endl; //输出字符串长度 |
直接使用运算符== != < > >= < = 即可,通过ASCII码来比较。
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};
数组声明:
使用注意:
数组优点:
考虑一个问题,假定整数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 | //推荐的方式: |
结论--C语言设计数组下标的原则:从0开始,使用非对称区间[,);让下界可以取到值,上界取不到值;这样设计的好处:
定义数组时,可以为其元素提供一组用逗号分隔的初值,用{}括起来,称为初始化列表。数组元素初始化时若没有显式提供元素初值,则元素会被向普通变量一样初始化:
1 | //下标访问 |
1 | //寻找a[]中第一个值为3的目标: |
二维数组是包含行列两个维度的数组。二维数组的初始化既可以按行初始化,也可以顺序初始化:
1 | int ia[3][4] = { |
二维数组访问:
1 | int a[2][4] = {{1,2,3,4},{5,6,7,8}}; |
二维数组我们一般按照一行一行的遍历,虽然可以进行一列一列的遍历的,但是实际操作中不建议这么做:因为循环需要满足“空间局部性”:在一个小的时间窗口内,访问的地址越接近越好,这样执行的速度快;也就是说我们一般把最长的循环放在内层,最短的循环放在最外层,以减少CPU跨切循环的次数。
本质上所有数组在内存中都是一维线性的。不同的语言采用的存储方式不同,有的采用行优先存储,有的采用列优先存储。C/C++中采用行优先顺序存储。
二维数组动态声明:
1 | //a[m][n] |
Vector是面向对象方式的动态数组。
动态是指容量不固定:我们经常使用的简单的数组,因为定义的时候就有固定容量,所以无法实现动态扩容插入元素。使用vector容器,可以轻松实现动态扩容添加元素。
面向对象是C++的特性,vector封装了一系列的方法,我们创建vecor的对象可以方便的使用。
使用vector容器,可以轻松实现动态扩容插入元素,传统的C数组容量有限,vector可以动态管理扩容。
1 | #include<vector> |
1 | for(int index = 0;index < vec.size(); ++index) |
我们可以使用vector中的capacity()方法来查看vector当前的容量,用size()的方法来查看已经存储的元素的个数。这两者有区别,容量是指容器可以容纳元素的个数,数量是已经存储的数据个数;
要注意如果打算使用eraser的方法进行尾删除,要注意end()的位置。数组的区间往往是左闭右开,end()指向的是尾元素的后面的地址,并不是尾元素自身。
1 | vec.pop_back(); |
编写代码时,我们总会做出一些假设,断言就是用在代码中捕获这些假设。断言表示为一些布尔表达式,我们相信在程序中的某个特定点该表达式的值为真,可以在任意时刻启用和禁用断言来验证。
举个例子,在离散数学中有个德摩根定律,我们在C++语言中可以验证:

1 | #include<iostream> |
这种代码方式在开发中并不常见,而是使用以下断言的方式:
1 | #include<iostream> |
在这个程序中,并不会有任何输出结果,但是程序正常运行。
假如assert()函数中表达式出错(非真),整个程序在运行的时候就会报错,并指出哪里出了问题。
所以今后在我们开发的时候可以运用这一特性,完成开发测试用例的编写:
1 | assert(函数返回值 == 预期结果); |
只要函数运行符合预期结果,程序正常运行,一旦不符合就会出错。
注意assert()不是函数,而是一个宏定义。
运算符是一种告诉编译器执行特定的数学或逻辑操作的符号。C++内置了丰富的运算符,并提供了以下类型的运算符:
在程序中,运算符是用来操作数据的,因此,这些数据也被称作操作数。使用运算符将操作数连接而成的式子称为:表达式。 表达式的特点:
A=10,B=20
| 运算符 | 描述 | 实例 |
|---|---|---|
| + | 两个操作数相加 | A+B=30 |
| - | 第一个操作数减去第二个操作数 | A-B=-10 |
| * | 两个操作数相乘 | A*B=200 |
| / | 分子除以分母 | B/A=2 |
| % | 取模运算符,整除后的余数 | B%A=0 |
| ++ | 自增运算符,整数值增加1 | ++A = 11 |
| -- | 自减运算符,整数值减少1 | - -A=9 |
除法运算中整数和浮点数的运算结果不太一样,整数除的话结果就是整数,要想输出完整整型数:需要用浮点数去除:
1 | int A = 10; |
自增/自减运算符放在变量的前面和后面是不一样的。放在变量前面称为前缀运算,先取变量的地址,做运算后再把值放入寄存器(先变后用);放在变量后面称为后缀运算,先取变量的地址中的内容放入寄存器,然后对内存中的信息做运算(先用后变)。因为前缀运算返回的是操作数本身,所以前缀运算可以作为左值表达式;后缀运算返回的是临时变量,只能用于后缀表达式。
自增和自减运算符只能用于变量,不能用于表达式:6++、(i+j)++、(&p)++这些都是不合法的。
C/C++编译器对程序编译时,从左到右尽可能多的将字符组成一个运算符或者标识符,因此"i+++j++"是合法的,因为编译器会理解为"(i++)+(j++)",这两个自增运算符作用的对象都是变量;但是"++i+++j"是不合法的,编译器会理解为"++(i++)+j",其中第一个自增运算符作用的对象是"i++",这是一个表达式,不合法。
1 | int A = 10; |
A=10 ;B=20
| 运算符 | 描述 | 实例 |
|---|---|---|
| == | 检查两个操作数是否相等,如果相等则条件为真 | (A == B)不为真 |
| != | 检查两个操作数是否相等,如果不相等则条件为真 | (A!=B)为真 |
| > | 检查左操作数是否大于右操作数,如果是则条件为真 | (A>B)不为真 |
| < | 检查左操作数是否小于右操作数,如果是则条件为真 | (A<B)为真 |
| >= | 检擦左操作数是否大于等于右操作数,如果是则为真 | (A>=B)不为真 |
| <= | 检查左操作数是否小于等于右操作数,如果是则条件为真 | (A<=b)为真 |
关系运算符返回的都是bool类型的值。千万不要连起来用:"i< j < k"这种写法有问题,"i < j"运算完成后直接返回true或false,是拿这个true或false来和k做比较的。
A = true; B = false;
| 运算符 | 描述 | 实例 |
|---|---|---|
| && | 逻辑与运算符。如果两个操作数不为零,则条件为真 | (A && B)为假 |
| || | 逻辑或运算。两个操作数任意一个非零,则条件为真 | (A || B)为真 |
| ! | 逻辑非运算,用来逆转操作数的逻辑状态,是单目运算符 | !(A && B)为真 |
可以用逻辑运算符来表示德摩根律:\(\neg(A \land B)\) 等价于 \(\neg A \lor \neg B\); \(\neg(A \lor B)\) 等价于 \(\neg A \land \neg B\)
1 | cout << (!(A || B) == (!A && !B)) << endl; |
逻辑运算符本身也是有优先级的 1
2auto a = true || true && false; //true
auto b = (true || true) && false; //false
逻辑与和逻辑或求值策略都是“短路求值”:先计算其左边的操作数,只有再仅靠左操作数无法确定逻辑表达式的值的时候,才会求解右操作数。
把右侧的值赋给左侧。注意左侧的值一定要是一个变量。
| 运算符 | 描述 |
|---|---|
| = | 赋值运算 |
| += | 加且赋值运算符 |
| -= | 减且赋值运算符 |
| *= | 乘且赋值运算符 |
| /= | 除且等于运算符 |
| %= | 求模且等于运算符 |
| <<= | 左移且赋值运算符 |
| >>= | 右移且赋值运算符 |
| &= | 按位与且赋值运算符 |
| ^= | 按位异或且赋值运算符 |
| |= | 按位或且赋值运算符 |
位运算符作用于位(bit),并逐位进行操作。
| p | q | p&q | p | q | p^q |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 1 | 1 | 1 | 0 |
| 1 | 0 | 0 | 1 | 1 |
位运算符还包括取反操作"~"和移位运算"<<"和">>",都是以bit为单位运算。
与、或、异或都是双目运算符,结合性从左到右,优先级高于逻辑运算符,低于关系运算符,且从高到低为&、^、|
使用移位运算符需要注意,左移运算还比较简单,移走的位自动填充0,但右移运算有两种情况,逻辑右移时移走的位填充0,但算数右移时移走的位与符号位有关。底层到底是逻辑右移还是算数右移取决于编译器,而不是程序员,所以对于有符号数,尽可能不要使用右移运算!!!
1 | // 位运算 |
异或操作有特性:两个相同的数异或后结果为0,且满足交换律,即"A ^ B ^ C ^ D ^ F ^ B"等价于"A ^ C ^ D ^ E ^ F"。这个特性常用来寻找成对出现的数据时缺失的哪一个数:如对于一组数【A、B、C、D、A、B、C】,直接做异或运算"A ^ B ^ C ^ D ^ A ^ B ^ C = D"找出缺失的D。
异或操作还可以交换两个变量的值,利用了异或操作的自反性(a ^ a = 0)、恒等性(a ^ 0 = a)以及交换律(a ^ b = b ^ a、(a ^ b) ^ c = a ^ (b ^ c))。不过要注意两个变量不能相等,相等后结果为0:
1 | a = a ^ b; //步骤1 |
| 运算符 | 描述 |
|---|---|
| sizeof | sizeof运算符,返回变量的大小,即这个变量的类型所占用的byte的长度 |
| Condition ?X:Y | 条件运算符,唯一的三目运算符。如果Condition为真,则值为X,否则值为Y |
| , | 逗号运算符,会执行一系列运算,整个逗号运算符表达式的值是以逗号分隔的列表中的最后一个表达式的值 |
| .(点)和->(箭头) | 成员运算符,用于引用类、结构体和共用体的成员 |
| Cast | 强制类型转换运算符,把一种数据类型转换成另一种数据类型,如int(2.20)将返回2。C++中不建议使用强制转换 |
| & | 指针运算符&,返回变量的地址 |
| * | 指针运算符*,指向一个变量 |
一定要注意,sizeof是运算符,不要理解为函数!!!sizeof的操作数可以是一个表达式或者是类型名。需要牢记sizeof的计算发生在编译时期,所以它可以被当作常量表达式使用,且会忽略括号内的运算。比如sizeof(a++)中的++不会执行。
如果sizeof中传递的是个函数调用,则返回函数返回类型的大小,函数不会调用。这里只能传递函数调用,不能传递函数名(Func()这个是函数调用,Func是函数名)。且如果函数返回的是void,不能确定类型,也不可以使用sizeof运算符。
下表从高到低列出各个运算符,较高的运算符会被优先计算:
| 类别 | 运算符 | 结合性 |
|---|---|---|
| 后缀 | () [] ++ -- | 从左到右 |
| 一元 | + - ! ~ ++ -- (type)* & sizeof() | 从右到左 |
| 乘除 | * / % | 从左到右 |
| 加减 | + - | 从左到右 |
| 移位 | << >> | 从左到右 |
| 关系 | < <= > >= | 从左到右 |
| 相等 | == != | 从左到右 |
| 位与AND | & | 从左到右 |
| 位异或XOR | ^ | 从左到右 |
| 位或OR | | | 从左到右 |
| 逻辑与 | && | 从左到右 |
| 逻辑或 | || | 从左到右 |
| 条件 | ?: | 从右到左 |
| 赋值 | = += -= *= = %= >>= <<= ^= |= | 从右到左 |
| 逗号 | , | 从左到右 |
只需要记住两点: