操作系统概念

概念:

  1. 系统资源的管理者,既管理软件又管理硬件;
  2. 向上层提供方便易用的服务;
  3. 最接近硬件的一层软件;

功能:

  • 资源的管理者
    • 处理器管理
    • 存储器管理
    • 文件管理
    • 设备管理
  • 为用户和计算机硬件提供接口
    • 给用户使用的
      • GUI
      • 命令接口:
        • 联机命令接口:用户发送一个命令,系统执行一个命令,主要特点是交互性
        • 脱机命令接口:用户一次性发送命令清单,系统按照清单执行,中途不能干预
    • 给软件/程序员使用的:系统调用接口
  • 对计算机资源的扩充
    • 裸机:没有任何软件支持的计算机
    • 扩充机器/虚拟机:覆盖了软件的机器

操作系统特征

  • 并发
  • 共享
  • 虚拟
  • 异步

并发: 需要区分并发和并行:

  1. 并发是指两个或多个事件在同一时间间隔内发生。宏观上同时发生,微观上交替发生;
  2. 并行:两个或多个事件在同一时刻同时发生;

单核CPU同一时刻只能执行一个程序,各个程序只能并发执行;多核CPU同一时刻可以同时执行多个程序,多个程序可以并行执行;

共享:

资源共享,系统中的资源可供内存中多个并发执行的进程共同使用。

两种资源共享方式:

  • 互斥共享: 系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源;
  • 同时共享:系统中的某些资源,允许一个时间段内由多个进程”同时“对它们进行访问。(注意这个同时是宏观上的,微观上可能是交替的进行资源访问)

虚拟:

虚拟是指把一个物理上的实体变为若干逻辑上的对应物。物理实体是实际存在的,逻辑对应物是用户感知到的。

  • 空分复用技术:如虚拟存储器,系统只有4g内存,但可以同时允许很多软件
  • 时分复用技术:如处理器的分时共享:单核CPU在用户看来也可以同时运行很多软件

异步:

异步是指在多道程序环境下,允许多个程序并发执行。但是由于资源有限,进程的执行不是一贯到底的,而是走走停停,以不可预知的速度向前推进。

并发和共享互为存在条件:如果失去并发性,则系统中只有一个程序正在运行,则共享性失去意义;如果失去共享性,那么多个程序无法实现同时访问资源,就无法并发执行。

并发和共享是最基本的两个性质,没有并发和共享,就谈不上虚拟和异步:如果失去了并发性,则一个时间段内指运行一个程序,那么就失去了实现虚拟性的意义了;如果失去了并发性,那么系统只能串行地运行各个程序,那么每个程序的执行只会一贯到底,没有异步可言。

操作系统发展历程

操作系统 定义 特点 优点 缺点
手工操作系统 纸袋机 用户独占全机、人机速度矛盾导致资源利用率极低
单道批处理系统 引入脱机输入(外围机+磁带),并由监督程序负责控制作业的输入、输出
操作系统雏形
自动性、顺序性、单道性 缓解了一定程度的人机速度矛盾,资源利用率有所提升 内存中仅有一道程序运行,运行结束后才能执行下一道。
cpu有大量时间在等待IO完成
多道批处理系统 操作系统正式诞生
多个用户将若干作业提交给计算机系统集中处理
多道,共享性,宏观上并行,微观上串行 资源利用率高,系统吞吐量大,CPU利用率高,IO设备利用率高 用户响应时间长
没有人机交互,批处理作业时用户无法干预
分时操作系统 计算机以时间片为单位轮流为各个用户。 同时性、交互性、独立性、及时性 提供人机交互能力
运行多个用户同时使用一台计算机
不能优先处理一些紧急任务
实时操作系统 硬实时:必须严格时间内完成处理
软实时:能偶尔接受违反时间规定
及时性、可靠性 能优先响应一些紧急任务
网络操作系统 网络中的各个计算机邮寄结合起来,实现数据传输、资源共享(如Windows NT)
分布式操作系统 系统中各个计算机地位相同,任何工作都可以分布在这些计算机上,由它们并行、协同完成 分布性、并行性
个人计算机操作系统

操作系统运行机制

程序的运行原理:高级语言编写代码,然后转为机器指令。程序运行的过程,其实就是CPU一条一条执行机器指令的过程。

两种程序

内核程序应用程序

我们写的程序就是“应用程序”;

操作系统的程序就是“内核程序”。很多操作系统程序组成了“操作系统内核”,简称“内核”。内核是操作系统中最重要最接近核心的部分,也是最接近硬件的部分。

两种指令

特权指令非特权指令

非特权指令:允许用户直接使用的指令,不能直接访问系统中的软硬件资源,只限于访问用户地址空间;

特权指令不允许用户直接使用,必须由操作系统来使用;

在CPU设计和生产的时候就划分了特权指令和非特权指令,因此CPU执行一条指令前就能判断出其类型。

两种处理器状态

内核态用户态

CPU处于内核态(管态)时,说明此时正在运行的是内核程序,此时可以执行特权指令;

CPU处于用户态(目态)时,说明此时正在运行的是应用程序,此时只能执行非特权指令;

CPU内部有程序状态寄存器(PSW),其中有一个二进制位分别表示当前处于内核态还是用户态。

  • 内核态->用户态:执行一条特权指令--修改PSW的标志位为用户态,这个动作意味着操作系统将主动让出CPU使用权;
  • 用户态->内核态:由“中断”引发,硬件自动完成变态过程,触发中断信号意味着操作系统将强行抢夺回CPU使用权。但凡需要操作系统介入的地方,都会触发中断信号;

中断和异常

CPU由用户态切换到内核态是中断完成的,中断是让操作系统夺回CPU使用权的唯一途径。中断也是操作系统并发的前提,如果没有中断,那么CPU就会一直运行一个应用程序。

中断分为内中断和外中断

内中断

内中断与当前执行的指令有关,中断信号来源于CPU内部。

内中断例子:

  1. 用户态程序执行非法指令时;
  2. 应用程序想要请求操作系统内核服务,此时会执行一条特殊指令--陷入指令。执行陷入指令意味着应用程序主动地将CPU控制权还给操作系统内核。

内中断一般被叫做异常,狭义的中断不包括内中断,异常分为:

  1. 陷阱、陷入:由陷入指令引发,是应用程序故意引发的
  2. 故障:由错误条件引起的,可能被内核程序修复。内核程序修复故障后会把CPU使用权还给应用程序。如缺页故障;
  3. 终止:致命错误引起,内核程序无法修复,因此一般不再把CPU使用权还给应用程序,而是直接终止应用程序。如除法操作除0

外中断

外中断于当前执行的指令无关,中断信号来源于CPU外部。

外中断例子:

  1. 时钟中断;
  2. I/O设备请求中断;

狭义的中断就是指外中断。

中断基本原理

不同的中断信号,需要用不同的中断处理程序来处理。当CPU检测到中断信号后,会根据中断信号的类型取查询“中断向量表”,以此来找响应的中断处理程序在内存中的存放位置。

中断处理程序是内核程序,运行在内核态。

内中断在CPU执行指令过程中就会检查是否有异常发生;

外中断在每个指令周期末尾才会检擦是否有外中断信号需要处理;

系统调用

操作系统提供了命令接口和程序接口。命令接口是给用户使用的,而“系统调用”是操作系统提供给应用程序使用的接口,应用程序可以通过系统调用来请求获得操作系统内核的服务。

系统调用不是库函数,我们当然可以直接通过汇编来访问系统调用,但我们通常调用编程语言的库函数,由库函数来调用系统调用接口。

应用程序通过系统调用来请求操作系统的服务。系统中的各种共享资源都由操作系统内核统一掌管,因此凡是与共享资源有关的操作,都必须通过系统调用的方式向操作系统内核提出服务请求,由操作系统内核代为完成。这样可以保证系统的稳定性和安全性,防止用户非法操作。

分类:

  • 设备管理
  • 文件管理
  • 进程控制
  • 进程通信
  • 内存管理

操作系统体系结构

内核是操作系统最基本,最核心的部分。实现操作系统内核功能的那些程序就是内核程序。

内核组成:

  • 时钟管理:实现计时功能;
  • 中断处理:负责实现中断机制;
  • 原语:
    • 是一种特殊的程序;
    • 处于操作系统最底层,最接近硬件的部分;
    • 这种程序运行具有原子性——其运行只能一气呵成,不能中断;
    • 运行时间较短、调用频繁;
  • 对系统资源进行管理的功能:
    • 进程管理;
    • 存储器管理;
    • 设备管理;

操作系统的内核需要运行在内核态,非内核功能运行在用户态。

特性 优点 缺点
分层结构 内核分多层,每层可单向调用更低一层的接口 1、便于调试和验证,自底向上逐层验证
2、易扩充易维护,各层之间接口调用清晰固定
1、仅可调用相邻低层,难以合理定义各层的边界
2、效率低,不可跨层调用,系统调用执行时间长
模块化 将内核划分为多个模块,各个模块相互协作
内核=主模块+可加载内核模块
主模块:只负责核心功能,如进程调度、内存管理;
可加载内核模块:可以动态加载新模块到内核,无需重新编译整个内核
1、模块间逻辑清晰易于维护,确定模块间接口后可多模块同时开发
2、支持动态加载新的内核模块,增强OS适应性
3、任何模块都可以直接调用其他模块,无需采用消息传递进行通信,效率高
1、模块间的接口定义未必合理、实用;
2、模块间相互依赖,更难调试和验证
大内核(宏内核) 所有系统功能都放在内核里 性能高,内核内部各功能都可以直接互相调用 1、内核庞大功能复杂,难以维护
2、大内核中某个功能模块出错,就可能导致整个系统崩溃
微内核 只把中断、原语、进程通信等最核心的功能放入内核。进程管理、文件管理、设备管理等功能以用户态进程形式运行在用户态 1、内核功能小、易于维护,内核可靠性高
2、内核外的某个功能模块出错不会导致整个系统崩溃
1、性能低,需要频繁切换用户态/和心态。
2、用户态下各功能模块不能互相调用,只能通过内核的消息传递来间接通信
外核 内核负责进程调度、进程通信等功能,外核负责为用户进程分配未经抽象的硬件资源,且由外核负责保证资源使用安全 1、外核可以直接给用户进程分配“不虚拟、不抽象”的硬件资源,使用户进程可以更灵活的使用硬件资源
2、减少了虚拟硬件资源的“映射层”,提升效率
1、降低了系统的一致性
2、使系统变的更复杂

操作系统引导

引导过程:

  1. CPU从一个特定主存地址开始,取指令,执行ROM中的引导程序(先进行硬件自检,再开机);
  2. 将磁盘的第一块——主引导记录 读入内存,执行磁盘引导程序,扫描分区表;
  3. 从活动分区(又称主分区,即安装了操作系统的分区)读入分区引导记录,执行其中的程序;
  4. 从根目录下找到完整的操作系统初始化程序(即启动管理器)并执行,完成“开机”的一系列动作;

虚拟机

虚拟机:使用虚拟化技术,将一台物理机器虚拟化为多台虚拟机器,每个虚拟机器都可以独立运行一个操作系统。

同义术语:虚拟机管理程序/虚拟机监控程序

第一类VMM 第二类VMM
对物理资源的控制权 直接运行在硬件之上,能直接控制和分配物理资源 运行在Host OS上,依赖Host OS为其分配物理资源
资源分配方式 在安装Guest OS时,VMM要在原本的硬盘上自行分配存储空间,类似于“外核”的分配方式,分配未经抽象的物理硬件 Guest OS拥有自己的虚拟磁盘,该盘实际上是Host OS文件系统中的一个大文件。Guest OS分配到的内存是虚拟内存
性能 性能好 性能差,需要Host OS作为中介
可支持的虚拟机数量 更多,不需要和Host OS竞争资源,相同的硬件资源可以支持更多的虚拟机 更少,Host OS本身需要使用物理资源,Host OS上运行的其他进程也需要物理资源
虚拟机的可迁移性 更差 更好,只需要导出虚拟机镜像文件即可迁移到另一台HostOS上,商业应用更广泛
运行模式 第一类VMM运行在最高特权指令(Ring 0)可以执行最高特权指令 第二类VMM部分运行在用户态,部分运行在内核态。GuestOS发出的系统调用会被VMM截获,并转化为VMM对HostOS的系统调用

基本概念

数据结构的基本概念

数据

  信息的载体。计算机程序加工的原料。

数据元素

  数据的基本单位;由若干数据项组成;

数据对象

  相同性质的数据元素的集合;

数据类型

  值的集合和此集合的一组操作的总称。 以bool为例:值的集合:true和false;操作:与、或、非

  • 原子类型:不可再分:如int、bool;
  • 结构类型:可以再分,如struct;
  • 抽象数据类型:抽象数据组织和相关操作。ADT,只考虑结构和运算,不考虑存储结构。

数据结构

  相互之间存在一种或多种特定关系的数据元素的集合。数据结构只关心数据之间的关系,不关心内容。

数据结构的三要素

逻辑结构

   描述元素之间的逻辑关系,独立于计算机。

  • 集合:没有什么关系
  • 线性结构:一对一
  • 树形结构:一对多
  • 图状结构:多对多

存储结构

  逻辑结构的计算机实现。

  顺序存储必须连续,非顺序存储可以离散;存储结构影响空间分配的方便程度(增删改);存储结构影响对数据的运算(查询);

  • 顺序存储
  • 链式存储
  • 索引存储
  • 散列存储

运算

  包括定义和实现;定义针对逻辑结构,表示功能(增删改查);实现针对存储结构

算法

算法的概念

程序=数据+算法

  • 数据:描述问题,存入计算机;
  • 算法:高效处理数据,是求解问题的步骤;

算法的五大特性:

  1. 有穷性:算法必须有穷,程序可以无穷
  2. 确定性:相同输入必须相同输出
  3. 可行性
  4. 输入:有零个或多个输入
  5. 输出:有一个或多个输出

时间复杂度

评价时间开销与问题规模n之间的关系。

时间复杂度的计算

  语句的频度就是该语句在算法中被重复执行的次数。算法中所有语句的频度之和就是T(n)。时间复杂度就是分析的数量级。O代表同阶。

计算步骤:

  1. 找到一个基本操作:基本操作指的是最深层循环;
  2. 分析该基本操作的执行次数x与问题规模n的关系
  3. x的数量级就是算法时间复杂度

常用技巧

  1. 顺序执行的代码只影响常数项,可以忽略;
  2. 只需要挑循环中的一个基本操作,分析它的执行次数x与n的关系;
  3. 多层嵌套循环,只关注最深层循环循环了几次;

加法规则:只保留最高阶,且系数变为1。

   

乘法规则:多项相乘,都保留

时间复杂度常见大小排序:常对幂指阶

时间复杂度不仅依赖问题的规模,还与输入数据的性质有关。可以分为最好时间复杂度、平均时间复杂度、最坏时间复杂度。一般只考虑最坏时间复杂度。

例题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//例1:
void test(int n)
{
int i=1; //1次
while(i<=n){
i++; //循环内执行n次
printf("test"); //循环内执行n次
}
}

int main()
{
test(n);
}


第一句顺序执行代码走1次,深层循环内执行了2n次,所以T(n)=2n+1。根据加法规则,只保留最高阶,且系数变为1.

1
2
3
4
5
6
7
8
9
10
11
12
//例2:
void test2(int n)
{
int i=1;
while(i<=n){
i++;
for(int j=1;j<=n;j++){
printf("test2");
}
}
}

最深层循环走次,所以

1
2
3
4
5
6
7
//例3
void test3(int n)
{
while(i<=n){
i=i*2; //每次翻倍
}
}

设最深层循环的语句频度为,则每次变化后,i=,所以由循环条件可得,循环结束时,满足。所以

1
2
3
4
5
6
7
8
9
10
//例4
void fun(int n)
{
int i = 1;
while (i * i * i <= n)
{
i++;
}
}

设执行次数为t,则,则,所以

1
2
3
4
5
6
7
8
9
10
11
12
// 例5
void test(inf flag[],int n)
{
for(int i=0;i<n;i++){ //从第一个元素开始查找
if(flag[i] == n){
printf("find!!");
break;
}
}
}

int flag[n]={1,2,...} //乱序存放1~n

因为是乱序存放。所以要考虑不同情况:

  • 最好时间复杂度:元素n在第一个位置。;
  • 最坏时间复杂度:元素n在最后有个位置。
  • 平均时间复杂度:元素n在任意一个位置的概率相同,都是

1
2
3
4
5
6
7
8
9
10
11
//例6
void fun(int n)
{
int count = 0;
for (int k = 1; k <= n; k *= 2) {
for (int j = 1; j <= n; j++)
{
count++;
}
}
}

内层循环j<=n与外层循环的变量无关,各自独立。所以对于内层循环来说,每次内层循环都执行n次。

对于外层循环k<=n,假设循环t次,则循环结束时满足,即

根据乘法原则,T(n)为内层循环乘外层循环,得

1
2
3
4
5
6
7
//例7
int fun(int n)
{
int i = 0, sum = 0;
while (sum < n) sum += ++i;
return i;
}

拆基本运算sum += ++i,等价为++i;sum=sum+i; 每执行一次循环,i增1.

i=1时,sum=0+1;

i=2时,sum=0+1+2;

i=3时,sum=0+1+2+3;

所以sum=。所以循环次数t满足sum < n,即,所以时间复杂度为

1
2
3
4
5
6
7
8
9
10
11
//例8
void fun(int n)
{
int sum = 0;
for (int i = 1; i < n; i *= 2)
{
for (int j = 0; j < i; j++) {
sum++;
}
}
}

先拆外层循环,每次自乘2,所以当循环k次后,,需要保证

对于内层循环,i取每一个值,内层j都有i个取值,所以内层只需要把每层值相加即可:

  1. i=1时,内层循环1次
  2. i=2时,内层循环2次,总共循环1+2
  3. i=3时,内层循环3次,总共循环1+2+3
  4. i=k时,内层循环k次,总共循环1+2+3+...+,用等比数列求和公式,总共有

所以当外层循环k次时,内层循环执行次,根据外层循环,得。总共的执行范围在2n以内,所以

//例9

,求这个函数的时间复杂度。n为问题规模。为了方便,设n为2的整数次幂。

,代入后得,由此可得一般递推公式:,进而。即。根据加法规则,

循环的时间复杂度总结

  • 每一层循环变量都是++:将每一层变量的最大遍历次数相乘
  • 不是每一层循环的变量都是++:对于外层循环每一个值i,求出内层遍历次数,然后求和
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//每一层循环变量都是++:
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
for (int k = 0; k < n; k++) {

}
//这种直接n*n*n

for(int i=0;i<n;i++)
for(int j=0;j<i;j++)
for (int k = 0; k < j; k++) {

}
//虽然内部j、k的取值和第一层循环有关,时间复杂度应该是n*j*k。可以直接放缩,把j和K都放大到n,最终还是n*n*n

1
2
3
4
5
6
//不是每一层循环的变量都是++:
for (int i = 1; i < n; i *= 2) {
for (int j = 0; j < i; j++) {

}
}

空间复杂度

评价空间开销与问题规模n之间的关系。

  • 普通程序:
    1. 找到所占空间大小与问题规模相关的变量;
    2. 分析所占空间x与问题规模n的关系
    3. x的数量级就是算法的空间复杂度S(n);
  • 递归程序:
    1. 找到递归调用的深度x与问题规模n的关系;
    2. x的数量级就是算法的空间复杂度S(n);
    3. 注:考研大概率空间复杂度就是递归调用的深度。但有的算法各层函数所需存储空间不同,分析方法略有区别
  • 空间复杂度也遵循加法规则、乘法规则

1
2
3
4
5
6
7
void test(int n)
{
int i=1;
while(i<=n){
i++;
}
}

这个代码无论问题规模n有多大,算法所需要的运行空间都是固定的常量。算法空间复杂度是。这种常数阶的空间复杂度算法称为原地工作算法

1
2
3
4
5
6
void test2(int n)
{
int flag[n];
...

}

。只关注存储空间大小与问题规模相关的变量。

1
2
3
4
5
6
7
void test3(int n)
{
if(n>1){
test3(n-1);
}

}

。递归大概率和递归深度有关。每次递归,系统都要开辟一片内存空间存储参数、局部变量等等。

1
2
3
4
5
6
7
8
void test4(int n)
{
int flag[n];
if(n > 1){
test4(n-1);
}

}

flag[n]的空间大小,会随着递归而发生变化。是个等差数列。

枚举

使用#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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
int main()
{
enum wT{Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday}; // 声明wT类型
wT weekday;
weekday = Monday;
weekday = Tuesday;
//weekday = 1; // 错误 不能直接给int值,只能赋值成wT定义好的类型值
//可以写成weekday = wT(1),但是涉及类型转换的写法不推荐
cout << weekday << endl;
//Monday = 0; // 错误 类型值不能做左值,不是有存储空间的变量
int a = Wednesday;
cout << a << endl;

return 0;
}

使用细节:

  • 枚举值不能做左值;
  • 非枚举变量不可以赋值给枚举变量;
  • 枚举变量可以赋值给非枚举变量;

结构体和联合体

1
2
3
4
5
6
7
8
9
10
11
12
struct Student
{
char name[6];
int age;
Score s;
};

union Score
{
double sc;
char level;
};

存储结构与结构体数据对齐

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
#include <string.h>
#include <iostream>
using namespace std;

int main()
{
union Score
{
double ds; //8字节
char level; //1字节
};
struct Student
{
char name[6]; //6字节
int age; //32位环境 4字节
Score s;
};

cout << sizeof(Score) << endl; // 8

Student s1;
strcpy_s(s1.name, "lili");
s1.age = 16;
s1.s.ds = 95.5;
s1.s.level = 'A';

cout << sizeof(Student) << endl; // 24


return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct s1
{
char x;
int z;
short y;
};

sizeof(s1) = 12;

struct s2
{
char x;
short y;
int z;
};

sizeof(s2) = 8;

数据对齐原则--缺省对齐

  • 32位CPU
    • char:任何地址
    • short:偶数倍地址
    • int:4的整数倍
    • double:8的整数倍

程序是可以修改默认编译选项的,修改结构体内存分配:

1
2
3
4
5
6
7
Visual C++:
#pragma pack(n)


g++:
__attribute__(aligned(n))
__attribute__(__packed__)

三种基本结构

分支结构

if分支语句

  • 单一语句:在任何一个表达式后面加上分号;
  • 符合语句:用一对花括号{}括起来的语句块,在语法上等效一个单一的语句;
  • if语句:if语句是最常用的一种分支语句,也称为条件语句。

if语句练习:实现一个函数:输入一个年号,判断是否是闰年。闰年判断:1、能够被400整除;2、能被4整除但不能被100整除。

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
#include <iostream>
using namespace std;

bool isLeapYear(unsigned int year)
{
// 双分支if
//if ( (year % 400 == 0) || (year%4==0 && year%100 != 0) )
//这里有个if的优化,因为||操作符,第一个条件满足不看后面的,&&操作符第一个条件不满足不看后面的
//所以按下面的方式效率更高,因为涉及命中率问题,被4整除的概率更高
if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))
{
return true;
}
else
{
return false;
}
}

int main()
{
// 是否是闰年
cout << isLeapYear(2000) << endl;
cout << isLeapYear(2020) << endl;
cout << isLeapYear(2021) << endl;
return 0;
}

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
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
#include <iostream>
using namespace std;

typedef enum __COLOR
{
RED,
GREEN,
BLUE,
UNKOWN
}COLOR;

int main()
{
// 多分支条件的if
// if, else if, else
COLOR color0;
color0 = BLUE;
int c0 = 0;
if (color0 == RED) {
//cout << "red" << endl;
c0 += 1;
}
else if (color0 == GREEN) {
//cout << "green" << endl;
c0 += 2;
}
else if (color0 == BLUE) {
//cout << "blue" << endl;
c0 += 3;
}
else {
//cout << "unknown color" << endl;
c0 += 0;
}

// 多分支条件的switch
// switch,case,default
COLOR color1;
color1 = GREEN;
int c1 = 0;
switch (color1)
{
case RED:
{
//cout << "red" << endl;
c1 += 1;
break;
}
case GREEN:
{
//cout << "green" << endl;
c1 += 2;
break;
}
case BLUE:
{
//cout << "blue" << endl;
c1 += 3;
break;
}
default:
{
//cout << "unknown color" << endl;
c1 += 0;
break;
}
}


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
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是个树结构。

使用场景的区别:

  1. switch只支持常量值固定相等的分支判断;
  2. if可以做区间范围判断;
  3. switch能做的if都可以,但反之不行。

性能比较:

  1. 分支少的时候,差别不是很大,分支多时,switch性能较高;
  2. if开始处几个分支效率高,之后效率递减;
  3. switch的所有case速度几乎一样。

循环结构

C++中一共提供了三种循环语句:while、do while、for

三种循环结构

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
//计算1 + 2 + …… + 99 + 100的和

#include <iostream>
using namespace std;
int main()
{ // TODO: 1+2+3+4...+100
// while语句
int sum = 0;
int index = 1;
while (index <= 100)
{
sum += index;
index += 1;
}
cout << sum << endl;

// for语句
index = 1;
sum = 0;
for (index = 1; index <= 100; ++index)
{
sum += index;
}
cout << sum << endl;

// do-while语句
sum = 0;
index = 1;
do
{
sum += index;
index += 1;
} while (index <= 100);
cout << sum << 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
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
三种汇编代码的底层逻辑:
// while语句
int sum = 0;
002A17BE mov dword ptr [sum],0
int index = 1;
002A17C5 mov dword ptr [index],1
while (index <= 100)
002A17CC cmp dword ptr [index],64h
002A17D0 jg main+46h (02A17E6h)
{
sum += index;
002A17D2 mov eax,dword ptr [sum]
002A17D5 add eax,dword ptr [index]
002A17D8 mov dword ptr [sum],eax
index += 1;
002A17DB mov eax,dword ptr [index]
002A17DE add eax,1
002A17E1 mov dword ptr [index],eax
}
002A17E4 jmp main+2Ch (02A17CCh)
//cout << sum << endl;

// for语句
//index = 1;
sum = 0;
002A17E6 mov dword ptr [sum],0
for (index = 1; index <= 100; ++index)
002A17ED mov dword ptr [index],1
002A17F4 jmp main+5Fh (02A17FFh)
002A17F6 mov eax,dword ptr [index]
002A17F9 add eax,1
002A17FC mov dword ptr [index],eax
002A17FF cmp dword ptr [index],64h
002A1803 jg main+70h (02A1810h)
{
sum += index;
002A1805 mov eax,dword ptr [sum]
{
sum += index;
002A1808 add eax,dword ptr [index]
002A180B mov dword ptr [sum],eax
}
002A180E jmp main+56h (02A17F6h)
//cout << sum << endl;

// do-while语句
sum = 0;
002A1810 mov dword ptr [sum],0
index = 1;
002A1817 mov dword ptr [index],1
do
{
sum += index;
002A181E mov eax,dword ptr [sum]
002A1821 add eax,dword ptr [index]
002A1824 mov dword ptr [sum],eax
index += 1;
002A1827 mov eax,dword ptr [index]
002A182A add eax,1
002A182D mov dword ptr [index],eax
} while (index <= 100);
002A1830 cmp dword ptr [index],64h
002A1834 jle main+7Eh (02A181Eh)
//cout << sum << endl;

从底层逻辑上看,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++系统提供;
  • 用户自定义函数:需要用户自定义后使用

函数的组成部分:

  1. 返回类型:一个函数可以返回一个值;
  2. 函数名称:函数的实际名称,函数名和参数列表一起构成了函数签名;
  3. 参数:参数列表包括函数参数的类型、顺序、数量。参数是可选的,即函数可以不包含参数;
  4. 函数主体:函数主体包含一组定义函数执行任务的语句。

函数重载与Name Mangling

函数重载Overload,函数的名称一样,但参数列表不同:

1
2
3
int test(int a);
int test(double a);
int test(int a,double b);

程序是怎么选择调用哪个函数的呢?这就引入了Name Mangling,给重载的函数不同的签名,以避免调用时的二义性调用。

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
#include <iostream>
using namespace std;
#include <math.h>

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 result = test(1);
result = test(2.0);
result = test(1, 2.0);

return 0;
}

观察编译生成的.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
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 <iostream>
using namespace std;

int MaxValue(int x, int y)
{
return (x > y) ? x : y;
}

int MinValue(int x, int y)
{
return (x < y) ? x : y;
}

int Add(int x, int y)
{
return x + y;
}

bool ProcessNum(int x, int y, int(*p)(int a, int b)) //参数中有函数指针
{
cout << p(x, y) << endl;
return true;
}

int main()
{
int x = 10, y = 20;

//直接用函数名做参数,只要保证函数的参数列表是两个int,返回值是int就可以
ProcessNum(x, y, MaxValue);
ProcessNum(x, y, MinValue);
ProcessNum(x, y, Add);

return 0;
}

上文的例子中,bool ProcessNum(int x, int y, int(*p)(int a, int b))参数有函数指针,我们把函数名传递进去,真正的调用是在这个函数体内部的。我们把这样的调用方式称为回调函数。

命名空间

开发过程中,可能会出现相同的函数签名,但内部实现不一样的情况。为了解决这个问题,可以使用命名空间。 命名空间这个概念,可作为附加信息来区分不同库中相同名称的函数、类、变量等,命名空间即定义了上下文。本质上,命名空间就是定义了一个范围。 关键词:using和namespace的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
using namespace std;

int test(int a)
{
return a;
}

namespace AA
{
int test(int a)
{
return a + 1;
}
}

int main()
{
cout << test(1) << endl;

cout << AA::test(1) << endl;

return 0;
}

在开发中,命名空间的使用非常广泛,尤其是使用第三方库的时候。程序中常用的cout就属于std命名空间。

内联函数

如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。

1
2
3
4
inline int MaxValue(int x,int y)
{
return (x > y) ? x : y;
}

引入内联函数的目的是为了解决程序中函数调用的效率问题,即空间换时间; 注意:内联函数内部不能有太复杂的逻辑,比如复杂的循环判断或者递归。编译器有时会有自己的优化策略,所以内联不一定起作用。VS中记得把C/C++设置中的内联优化打开。

递归

数学归纳法

数学归纳法是证明当n等于任意一个自然数时某命题成立。证明步骤分两步:

  1. 证明当n=1时命题成立;
  2. 假设n=m时成立,那么可以推导出在n=m+1时命题也成立(m为任意自然数)

递归背后的数学逻辑就是数学归纳法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
斐波那契数列:112358132134,……
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
int a = 1;
float b = 3.14;
//指针定义
int* c = &a; //c存储a的内存地址
float* d = &b;

cout << c << endl; //输出a变量的地址

//间接访问
cout << (*c) << endl; //等同于cout<<a<<endl;
cout << (*d) << endl;//等同于cout<<b<<endl;

return 0;
}

左值与右值

字符串本身就是一个字符数组,但是字符串还可以用指针来表示:

1
2
3
4
5
6
7
8
9
10
int main()
{
//两种字符串表示
char strHello[] = { "hello" };
const char* pStrHello = "hello";

pStrHello = strHello; //正确,指针变量的值可以改变
strHello = pStrHello; //错误,数组变量的值不允许改变
return 0;
}
strHello不可改变,strHello[index]的值可以改变 pStrHello可以改变,pStrHello[index]的值能否改变取决于所指的存储区域是否可变 这里就涉及到了左值与右值的概念

  • 左值:编译器为其单独分配了一块存储空间,可以取其地址的,左值可以放在赋值运算符左边;
  • 右值:指的是数据本身,不能取到自身地址,右值只能放在赋值运算符右边

左值最常见的情况就是函数和数据成员的名字。 右值是没有标识符、不可取地址的表达式,一般也称为“临时对象”。

a = b + c; &a是允许的操作,a是一个左值 &(b+c)不能通过编译,b+c是一个右值

C++原始指针

一般类型指针T*

T是一个泛型,泛指任何一种类型

1
2
3
4
5
6
7
int i = 4;
int* iP = &i;
cout<<(*iP)<<endl;

double d = 3.14;
double* dP =&d;
cout<<(*dP)<<endl;

不论T是什么类型,T*这个指针的内存空间都是一样的,为4个字节

指针的数组 与 数组的指针

指针的数组 T*t[]:指针的数组仍然是数组,里面每个值是个指针(arrao of pointers) 数组的指针 T(*t)[] :一个指针,指向一个数组(a pointer to an array)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int* a[4];

int c[4] = {1,2,3,4};
int(*b)[4];
b = &c; //数组的个数一定要匹配
//注意,[]的优先级比较高


for(unsigned int i = 0;i<4;i++)
{
a[i] = &(c[i]);
}



cout<<*(a[0])<<endl; //先取数组下标得到地址,然后做间接访问
cout<<(*b)[3]<<endl; //b是指针,先间接访问取值得到数组,然后取数组下标

const pointer && pointer to const

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main()
{
char strHello[] = { "helloworld" };

char const* pStr1 = "helloworld"; //修饰char,指针指向的地址可变,但是存储的区域中的内容不可变
char* const pStr2 = "helloworld"; //修饰char*,指针指向的地址不可变。在最新的VS中编译不过,因为"helloworld"是常量,必须要用const指针
char const* const pStr3 = "helloworld"; //地址和空间中的内容都不允许改变

pStr1 = strHello;
//pStr2 = strHello; //错误,pStr2地址不可改
//pStr3 = strHello; //错误,pStr3地址不可改

//pStr1[1] = 'a'; //错误,存储区内的char不可改变
pStr2[1] = 'a';
//pStr3[1] = 'a'; //错误,存储区内的char不可变

return 0;
}

关于const修饰:

  • 看左侧最近的部分
  • 如果左侧没有,则看右侧

pointer to pointer

1
2
3
int a = 123;
int* b = &a;
int** c = &b;

*操作符具有从右向左的结合性,**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
2
int* a;
*a = 12;//错误!!

  上述操作并没有对指针a进行初始化,也就是说我们并不知道a最终会指向哪里。运气好的话定位到一个非法地址(程序不能访问的地址),程序会出错从而终止。最坏的情况下,a定位到了一个可以访问的地址,这样我们就无意间修改了它,这样的错误难以捕捉,引发的错误与原先用来操作的代码毫不相干,我们根本无法定位。

用指针进行间接访问之前,一定要确保它已经初始化,并且被恰当的赋值。

NULL指针

NULL指针是一个特殊的指针变量,表示不指向任何东西。

1
int* a = NULL;

NULL指针的概念非常有用,它给了一种方法,来表示特定的指针目前未指向任何东西。

对于一个指针,如果已经知道将被初始化为什么地址,那么请给他赋值,否则请把它设置为NULL,这样可以有效避免不可确定性访问的问题。

在对一个指针间接引用前,先判断这个指针的值是否为NULL。

指针使用完成后也请重新赋值为NULL。

野指针

野指针是指向“垃圾”内存的指针。if等判断对它们不起作用,因为没有置为NULL,它存有值,但是我们用不了;

一般情况下有三种情况被称为野指针 1. 指针变量没有初始化 2. 已经释放不用的指针没有置为NULL,如delete和free之后的指针 3. 指针操作超越了变量的作用域范围(指针指向具有一定生命周期的空间)

没有初始化的,不用的或者超出范围的指针,请一定置为NULL

指针的基本运算

1
2
char ch = 'a';
char* cP = &ch;

&操作符不能做左值,&操作编译器做是事情是把变量的地址位置取出来,然后放在内存空间中。但是他本身并不是变量自身,仅仅是一块空间存储着变量地址,这块空间我们的程序是没办法获取到的。

间接引用操作当用作左值的时候,实际的操作是把变量ch当前的位置取出来(取空间),这种操作我们可以对这块空间进行操作,比如赋值操作。 当我们把他当作右值时,实际的操作取的就不是存储空间,而是存储空间中的值。

*cp + 1首先得到cp中的值,得到a,做+1操作就是对ASCII码进行操作,得到b。但是这个操作还是由编译器创造一块空间取值,我们得不到这个变量的地址,不能做左值。这个+1的操作是按照cp的类型来做加法的,移动的是cp这个类型的大小。

*(cp+1)操作我们先做了+1,而cp本身是个指针,我们做的是指针的加法,得到的是ch这个变量的地址的后面那个地址(做这个操作前要确定cp指向的地址后面的内容是可以访问的)。这个操作也是可以用作左值和右值,左值就是取地址,右值就是取空间中存储的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main()
{
char ch = 'a';
//&操作符
//&ch = 97; //错误,&ch左值不合法
char* cp = &ch; //&ch右值
//&cp = 97; //错误,&左值不合法
char** cpp = &cp; //&cp右值


//*操作符
*cp = 'a'; //*cp左值取变量ch的位置
char ch2 = *cp; //*cp右值取变量ch存储的值
//*cp + 1 = 'a'; //错误,*cp+1左值不合法的位置
ch2 = *cp + 1; //*cp+1右值取到的字符做ASCII码+1操作
*(cp + 1) = 'a'; //左值,语法上合法,访问到cp后面的位置,赋值为a.一定要保证这个位置是可以访问的,这种操作有风险
ch2 = *(cp + 1); //右值操作,取ch后面的位置的值
}

指针的++ 与 --

1
2
3
4
5
6
7
char* cp2 = ++cp;
//汇编代码:
mov eax,dword ptr [cp] //eax是寄存器,dwptr存储cp指针。把指针内容放置寄存器内
add eax,1 //寄存器数据+1
mov dword ptr [cp],eax //把寄存器内容存回cp中
mov ecx,dword ptr [cp] //把cp的内容放置在ecx寄存器
mov dword ptr [cp2],ecx //把寄存器ecx内容放置在cp2中
1
2
3
4
5
6
7
char* cp3 = cp++;
//汇编代码:
mov eax,dword ptr[cp] //把cp指针内容放置在eax寄存器中
mov dword ptr[cp3],eax //把eax内容直接放在cp3指针
mov ecx,dword ptr[cp] //把cp信息放置在exc寄存器中
add ecx,1 //ecx+1操作
mov dword ptr[cp],ecx //ecx内容写入cp指针

前置操作先做加法再赋值,后置操作先赋值后做加法操作。 自减操作符和自增操作符相同,前置操作先做减法再赋值,后置操作先赋值再做减法。

自增自减操作获得的地址不能当作左值,它只是个地址的副本,没有明确存储的位置。

++操作符优先级高于*

++++和----等运算符

  编译器程序分解符号的方法是:一个字符一个字符的读入,如果该字符可能组成一个符号,那么读入下一个字符,一直到读入的字符不能组成一个有意义的符号。这个处理过程称为“贪心法”。

1
2
3
4
5
int a = 1,b=2
int c;
int d;
c = a+++b; //相当于a++ +b。连续读取,当读取到两个+号时不能再组成新符号了
d = a++++b; //相当于a++ ++b。这个是错误的 ,不构成任何运算

C++程序的存储区域划分

栈和队列

  数据结构中有两种常见的结构,一种是栈结构,先进入的数据会被压在栈底,后进入的数据会被放在栈顶。是一种先进后出的结构;还有一种是队列结构,和栈相反,类似于生活中的队列,先进入的数据会先出队列。在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
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
using namespace std;

int a = 0; //GVAR全局初始化区
int* p1; //BSS全局未初始化区

int main() //text 代码区
{
int b = 1; //stack栈区
char s[] = "abc"; //stack栈区
int* p2 = NULL; //stack栈区

const char* p3 = "123456"; //"123456\0"在常量区,p3在stack栈区

static int c = 0; //GVAR全局(静态)初始化区

p1 = new int(10); //heap堆区变量
p2 = new int(20); //heap堆区变量

char* p4 = new char[7]; //heap堆区变量
strcpy_s(p4, 7, "123456"); //text代码区

if (p1 != NULL)
{
delete p1;
p1 = NULL;
}

if (p2 != NULL)
{
delete p2;
p2 = NULL;
}

//p3指向常量区,由内存接管

if (p4 != NULL)
{
delete[] p4;
p4 = NULL;
}

return 0;
}

通过调试上面的代码,可以观察到一些程序中的地址分布:

上图是栈区变量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指向的地址也很接近,可知也是堆区内。

对存储区域做一个总结,如下图:

动态分配资源--堆区

  1. 从现代编程语言的观点来看,使用堆,或者说使用动态内存分配,是一件很自然的事情;
  2. 动态内存带来了不确定性:内存分配耗时需要多久(分配大空间不好控制)?失败了怎么办?在实时性要求很高的场合,入嵌入式设备,这些不确定性是很严重的;
  3. 一般而言,当我们在堆上分配内存时,很多语言会使用new这样的关键字,也有些语言是隐式分配,不使用new的语义,但使用的是new的方式。在C++中new对应词是delete,因为C++是允许程序员完全接管内存的分配释放的。

分配和回收动态内存的原则

程序通常需要牵扯到三个内存管理器的操作:

  1. 分配一个某大小的内存块;
  2. 释放一个之前分配的内存块;
  3. 垃圾收集操作,寻找不再使用的内存块并给予释放;

这个回收策略需要实现性能、实时性、额外开销等各方面的平衡,很难有统一和高效的做法。 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)问题

  内存泄漏指的是程序中已经动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果;

内存泄漏发生的原因和排查方式:

  1. 内存泄漏主要发生在堆内存分配方式中,即“配置了内存后,所有指向该内存的指针都遗失了”。如果缺乏垃圾回收机制,这样的内存片就无法归还系统;
  2. 因为内存泄漏属于程序运行中的问题,无法通过编译识别,所以只能在程序运行过程中来判别和诊断。

比指针更安全的解决方案

  使用指针是非常危险的行为,可能存在空指针,野指针的问题,并可能造成内存泄漏问题。可是指针又非常高效,所以我们希望以更安全的方式来使用指针。一般有两种典型方案:

  • 使用更安全的指针:智能指针;
  • 使用安全的方式:引用;

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
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
#include <string>
#include <iostream>
#include <memory> //智能指针头文件
using namespace std;
int main()
{
{// 确定auto_ptr失效的范围
// 对int使用
auto_ptr<int> pI(new int(10)); //关注pI的生命周期范围和指向的堆区空间的声明周期范围
cout << *pI << endl; // 10

// auto_ptr C++ 17中移除 拥有严格对象所有权语义的智能指针
// auto_ptr原理:在拷贝 / 赋值过程中,直接剥夺原对象对内存的控制权,转交给新对象,
// 然后再将原对象指针置为nullptr(早期:NULL)。这种做法也叫管理权转移。
// 他的缺点不言而喻,当我们再次去访问原对象时,程序就会报错,所以auto_ptr可以说实现的不好,
// 很多企业在其库内也是要求不准使用auto_ptr。

//对字符串数组使用
auto_ptr<string> languages[5] = {
auto_ptr<string>(new string("C")),
auto_ptr<string>(new string("Java")),
auto_ptr<string>(new string("C++")),
auto_ptr<string>(new string("Python")),
auto_ptr<string>(new string("Rust"))
};
cout << "There are some computer languages here first time: \n";
for (int i = 0; i < 5; ++i)
{
cout << *languages[i] << endl;
}
auto_ptr<string> pC;
pC = languages[2]; // languges[2] loses ownership. 将所有权从languges[2]转让给pC!!!
//此时languges[2]不再引用该字符串从而变成空指针
cout << "There are some computer languages here second time: \n";
for (int i = 0; i < 2; ++i)
{
cout << *languages[i] << endl;
}
cout << "The winner is " << *pC << endl;
//下面会报错
//cout << "There are some computer languages here third time: \n";
//for (int i = 0; i < 5; ++i)
//{
// cout << *languages[i] << endl;
//}
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//memory文件

//观察auto_ptr的模板可以看到它是怎么实现资源释放和所有权转移的

//资源释放:是通过析构来完成的
~auto_ptr() noexcept {
delete _Myptr;
}


//所有权转移,观察赋值的重载:
auto_ptr& operator=(auto_ptr& _Right) noexcept {
reset(_Right.release()); //保存原有的地址,把原有保存这个地址的指针置成Nullptr
return *this;
}

unique_ptr

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <memory>
#include <iostream>
using namespace std;
int main()
{
// 在这个范围之外,unique_ptr被释放
{
auto i = unique_ptr<int>(new int(10));
cout << *i << endl;
}

// unique_ptr
auto w = std::make_unique<int>(10);
cout << *(w.get()) << endl; // 10。get()方法返回的就是指针

//auto w2 = w; // 编译错误!!如果想要把 w 复制给 w2, 是不可以的。
// 因为复制从语义上来说,两个对象将共享同一块内存。

// unique_ptr 只支持移动语义, 即如下
auto w2 = std::move(w); // w2 获得内存所有权,w 此时等于 nullptr
cout << ((w.get() != nullptr) ? (*w.get()) : -1) << endl; // -1
cout << ((w2.get() != nullptr) ? (*w2.get()) : -1) << endl; // 10
return 0;
}

shared_ptr和weak_ptr

  unique_ptr在同一时间只能由一个指针持有对象,使用上具有局限性。所以推出了shared_ptr。   shared_ptr通过一个引用计数共享一个对象,在这个机制上提供了可以共享所有权的智能指针,当然这需要额外的开销。当引用计数为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
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <memory>
using namespace std;
int main()
{
// shared_ptr
{
//shared_ptr 代表的是共享所有权,即多个 shared_ptr 可以共享同一块内存。
auto wA = shared_ptr<int>(new int(20));
{
auto wA2 = wA; //shared_ptr可以赋值,同时指向对象
cout << ((wA2.get() != nullptr) ? (*wA2.get()) : -1) << endl; // 20
cout << ((wA.get() != nullptr) ? (*wA.get()) : -1) << endl; // 20
cout << wA2.use_count() << endl; // 打印引用计数 2
cout << wA.use_count() << endl; // 打印引用计数 2
}
//cout << wA2.use_count() << endl;
cout << wA.use_count() << endl; // wA2消亡,引用计数为1
cout << ((wA.get() != nullptr) ? (*wA.get()) : -1) << endl; // 20
//shared_ptr 内部是利用引用计数来实现内存的自动管理,每当复制一个 shared_ptr,
// 引用计数会 + 1。当一个 shared_ptr 离开作用域时,引用计数会 - 1。
// 当引用计数为 0 的时候,则 delete 内存。
}
//跳出作用域,wA也消亡,同时内存会被释放

// shared_ptr也支持move()语法
auto wAA = std::make_shared<int>(30);
auto wAA2 = std::move(wAA); // 此时 wAA 等于 nullptr,wAA2.use_count() 等于 1
cout << ((wAA.get() != nullptr) ? (*wAA.get()) : -1) << endl; // -1
cout << ((wAA2.get() != nullptr) ? (*wAA2.get()) : -1) << endl; // 30
cout << wAA.use_count() << endl; // 0
cout << wAA2.use_count() << endl; // 1
//将 wAA 对象 move 给 wAA2,意味着 wAA 放弃了对内存的所有权和管理,此时 wAA对象等于 nullptr。
//而 wAA2 获得了对象所有权,但因为此时 wAA 已不再持有对象,因此 wAA2 的引用计数为 1。

return 0;
}

  引用计数也会带来一个严重问题:循环引用。即存在一种情况,有两个对象,对象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
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
#include <string>
#include <iostream>
#include <memory>
using namespace std;

struct B;
struct A {
shared_ptr<B> pb;
~A()
{
cout << "~A()" << endl;
}
};
struct B {
shared_ptr<A> pa;
~B()
{
cout << "~B()" << endl;
}
};

// pa 和 pb 存在着循环引用,根据 shared_ptr 引用计数的原理,pa 和 pb 都无法被正常的释放。
// weak_ptr 是为了解决 shared_ptr 双向引用的问题。
struct BW;
struct AW
{
shared_ptr<BW> pb;
~AW()
{
cout << "~AW()" << endl;
}
};
struct BW
{
weak_ptr<AW> pa;
~BW()
{
cout << "~BW()" << endl;
}
};

void Test()
{
cout << "Test shared_ptr and shared_ptr: " << endl;
shared_ptr<A> tA(new A());
shared_ptr<B> tB(new B());
cout << tA.use_count() << endl; //1
cout << tB.use_count() << endl; //1
tA->pb = tB;
tB->pa = tA;
cout << tA.use_count() << endl; // 2
cout << tB.use_count() << endl; // 2
}
void Test2()
{
cout << "Test weak_ptr and shared_ptr: " << endl;
shared_ptr<AW> tA(new AW());
shared_ptr<BW> tB(new BW());
cout << tA.use_count() << endl; // 1
cout << tB.use_count() << endl; // 1
tA->pb = tB;
tB->pa = tA; //weak_ptr不会对tA的引用计数产生影响
cout << tA.use_count() << endl; // 1
cout << tB.use_count() << endl; // 2
}

int main()
{
Test();
Test2();


return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
上面代码输出:
Test shared_ptr and shared_ptr:
1
1
2
2
Test weak_ptr and shared_ptr:
1
1
1
2
~AW()
~BW()


可以看到weak_ptr不会对引用计数产生影响,而产生循环引用的地方不会发生析构

引用

引用在本质上仍然是是指针,只不过自身比较特殊,是不允许修改的指针。(我们常说java中没有指针,其实java中的指针就是引用)

在指针使用上,我们会遇到一些问题:

  1. 空指针
  2. 野指针
  3. 不知不觉改变了指针的值,我们却仍然在使用

使用引用,我们可以避免这些问题:

  1. 不存在空引用;
  2. 引用必须被初始化;
  3. 一个引用永远指向它初始化的那个对象,不允许被修改。

引用可以认为是指定变量的别名,使用时可以认为是变量本身:

1
2
3
4
5
6
int x1 = 1,x2 = 3;
int& rx = x1; //定义引用,可以认为rx是x1的别名
rx = 2;
cout<<x1<<rx<<endl; //x1和rx都是2
rx = x2; //引用一旦被初始化就不能更改,所以这里不是赋值rx为x2,而是x1=x2(别名直接替换)
cout<<x1<<x2<<endl; //都是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
31
32
#include <iostream>
#include <assert.h>
using namespace std;

// 编写一个函数,输入两个int型变量a,b
// 实现在函数内部将a,b的值进行交换。
void swap(int& a, int& b)
{
int tmp = a;
a = b;
b = tmp;
}
void swap2(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}

int main()
{
// 交换变量的测试
int a = 3, b = 4;
swap(a, b);
assert(a == 4 && b == 3);

a = 3, b = 4;
swap2(&a, &b);
assert(a == 4 && b == 3);

return 0;
}

  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main()
{
char strHello[11] = { "helloworld" };
//strHello是数组变量,它本身的值是不允许改变的,但是它指向的区域的值strHello[index]是可以改变的
strHello[2] = '6'; //strHello = he6loworld
//strHello = { "helloworld" }; //不允许改变了,编译不通过


//ptrHello是指针变量,它本身是可以改变的,但是ptrHello[index]指向的值可变与否要取绝与所指向的区间是否可以改变
const char* ptrHello_1 = "helloworld"; //指针指向常量区(必须要加const),ptrHello[index]不可改变,但本身指向的地址可以变。
ptrHello_1 = "change";

char tmp[7] = "change";
char* ptrHello_2 = strHello; //指针指向数组变量,ptrHello[index]可以改变,本身也可以变
ptrHello_2[2] = 'l';
ptrHello_2 = tmp;

return 0;
}

字符串操作

头文件:string.h

字符串长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
strlen(s);    //返回字符串s的长度,要注意字符串的长度不包含'\0'。

sizeof(s); //返回字符串所占用的空间,包含'\0'


int main()
{
char strA[11] = "helloworld";
auto n1 = sizeof(strA); //11
auto n2 = strlen(strA); //10


wchar_t strB[11] = L"helloworld";
auto n3 = sizeof(strB); //22
auto n4 = wcslen(strB); //10

return 0;
}

字符串比较

1
2
3
4
5
strcmp(s1,s2); 
wcscmp(s1,s2);
//s1 == s2,返回0
//s1 < s2 ,返回值小于0
//s1 > s2 ,返回值大于0

两个字符串自左向右逐个字符相比,按照ASCII值大小来比较,直到出现不同字符或遇到'\0'为止

字符串拷贝

1
2
3
strcpy(s1,s2);   //赋值字符串s2到字符串s1

strncpy(s1,s2,n); //将字符串s2中的前n个字符拷贝到s1中

要注意一个陷阱,一定要保证字符串s1的内存大小能存的下要拷贝的s2的大小

字符串拼接

1
strcat(s1,s2);   //将字符串s2拼接到s1后面

这个更加要注意,s1的大小要足够存放拼接后的s1+s2字符串的大小 两个字符串指针不能直接相加,拼接必须要用这个函数。(指针相加是什么操作啊(#`O′))

字符串查找

1
2
strchr(s1,ch);   //指向字符串s1中字符ch第一次出现的位置
strstr(s1,s2); //指向字符串s1中字符串s2的第一次出现的位置

安全函数

以上程序都需要我们进行边界检查以防止内存问题。现在我们往往在程序中使用更加安全的API函数

上述所有API函数都有其_s版本,如strcpy_s(),在执行操作的同时要告诉系统当前可使用的缓冲区的大小。 strlen()的安全版本是strnlen_s(),传入字符串的同时要传入一个最大的边界,以防止某些字符串没有\0结束标志 。

字符串操作重点

  1. C原始字符串的操作在安全性和效率中存在一定问题: 1. 一定要注意不要缓冲区溢出。 2. strlen的效率可以提升,当字符串很长的时候strlen的效率不高,我们可以用空间换时间,在定义字符串的时候用变量记住它的长度,不使用strlen函数来计算
  2. 目前有一个很好的开源库Redis字符串:Redis库

string类

C++标准库中提供了string类型专门表示字符串

1
2
#include<string>
using namespace std;

使用string可以更加方便和安全的管理字符串,对性能要求不是特别高的场景可以使用。

定义

1
2
3
4
string s;   //定义空字符串
string s = "helloworld"; //定义并初始化
string s("helloworld");
string s = string("helloworld");

字符串长度

1
2
3
cout<<s1.length()<<endl;    //输出字符串长度
cout<<s1.size()<<endl; //本质和上面一样
cout<<s1.capacity()<<endl; // 字符串的容量

字符串比较

直接使用== != < > >= < = 即可,比较ASCII码来比较。

转换为C风格字符串

1
2
string s1 = "hello";
const char* c_str1 = s1.c_str();

随机访问

字符串本身可以使用下标的方式访问

1
2
string s = "hello";
cout<<s[0]<<endl;

字符串拷贝

不需要使用函数,直接使用=赋值

1
2
string s1 = "hello";
string s2 = s1;

字符串拼接

string重载了+和+=,不需要使用函数

1
2
3
string s1 = "hell0",s2 = "world";
string s3 = s1+s2;
s1 += s2;

数组

声明和数组下标

数组代表内存里一组连续的同类型的存储区,可以用来把多个存储区合并成一个整体。 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可能的取值有多少?

  1. 首先考虑最简单的情况(特例),然后将结果外推
  2. 仔细计算边界问题

特例: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
2
3
4
5
6
7
8
9
10
11
//推荐的方式:
for(int index=0;index < 10;++index)
{
cout<<a[index]<<" ";
}

//不推荐的方式
for(int index=0;index <= 9;++index)
{
cout<<a[index]<<" ";
}

增删改查

  • 在尾部添加和删除操作,时间复杂度为O(1)。只需要操作尾部元素即可,对与其他元素没有干扰。

  • 在数组中间进行添加和删除操作,时间复杂度为O(n)。在中间操作,会对其他元素造成影响,操作元素后面的每一个元素都需要进一步操作

  • 数组的访问用遍历很高效,时间复杂度为O(1);

1
2
3
4
5
6
//下标访问
a[2] = 5;

//指针访问
int* p = a;
*(p+2) = 5;

数组的查找时间复杂度并不低,一般取绝与数组自身容量(数组容量很大的时候并不适合遍历查找,时间复杂度太高,可以用二分查找):

1
2
3
4
5
6
7
8
9
//寻找a[]中第一个值为3的目标:
int len = sizeof(a)/sizeof(a[0]);
for(int index = 0;index<len;++index)
{
if(a[index] == 3)
{
return index;
}
}

二维数组

二维数组包含了两个维度的数组。

二维数组访问:

1
2
3
4
5
6
7
8
9
int a[2][4] = {{1,2,3,4},{5,6,7,8}};
for(int row=0;row<2;++row)
{
for(int col = 0;col<4;++col)
{
cout<<a[row][col]<<" ";
}
cout<<endl;
}

  二维数组我们一般按照一行一行的遍历,虽然可以进行一列一列的遍历的,但是实际操作中不建议这么做:因为在一个小的时间窗口内,访问的地址越接近越好,这样执行的速度快;即我们一般把时间最长的循环放在内层,最短的循环放在外层,以减少CPU跨切循环的次数。

std::vector

Vector是面向对象方式的动态数组。

我们经常使用的简单的数组,因为定义的时候就有固定容量,无法实现动态扩容插入元素。

使用vector容器,可以轻松实现动态扩容添加元素。

添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<vector>
using namespace std;

int main()
{
vector<int> vec = { 1,2,3,4 };

//尾添加
vec.push_back(5); //尾添加,不会出现下标越界 {1,2,3,4,5}


//指定位置添加
//insert方法,传入位置和值
vec.insert(--vec.end(), 4); //{1,2,3,4,4,5}
return 0;
}

遍历

1
2
3
4
for(int index = 0;index < vec.size();++index)
{
cout<<vec[index]<<endl;
}

我们可以使用vector中的capacity()方法来查看vector当前的容量,用size()的方法来查看已经存储的元素的个数。这两者有区别。

删除

  • vec.pop_back(); //尾删除
  • vec.eraser(pos); //指定位置删除

要注意如果打算使用eraser的方法进行尾删除,要注意end()的位置。数组的区间往往是左闭右开,end()指向的是尾元素的后面的地址,并不是尾元素自身。

1
2
vec.pop_back();
vec.eraser(vec.end()-1); //这两者等价

  编写代码时,我们总会做出一些假设,断言就是用在代码中捕获这些假设。断言表示为一些布尔表达式,我们相信在程序中的某个特定点该表达式的值为真,可以在任意时刻启用和禁用断言来验证。

  举个例子,在离散数学中有个德摩根定律,我们在C++语言中可以验证:

1
2
3
4
5
6
7
8
9
10
#include<iostream>
using namespace std;
int main()
{
bool A = false;
bool B= true;
//德摩根率:
cout<<(!(A || B) == (!A && !B))<<endl; //输出1
cout<<(!(A && B) == (!A || !B))<<endl; //输出1
}

这种代码方式在开发中并不常见,而是使用以下断言的方式:

1
2
3
4
5
6
7
8
9
10
11
#include<iostream>
#include<assert.h>
using namespace std;
int main()
{
bool A = false;
bool B= true;
//德摩根率:
assert( !(A || B) == (!A && !B) );
assert( !(A && B) == (!A || !B) );
}

在这个程序中,并不会有任何输出结果,但是程序正常运行。

假如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
2
3
int A = 10;
cout<<15/A<<endl; //输出1
cout<<15.0/A<<endl; //输出1.5

自增/自减运算符放在变量的前面和后面是不一样的。放在变量前面就是先做运算再出结果;放在后面就是先出结果再运算。

关系运算符

A=10 ;B=20

运算符 描述 实例
== 检查两个操作数是否相等,如果相等则条件为真 (A == B)不为真
!= 检查两个操作数是否相等,如果不相等则条件为真 (A!=B)为真
> 检查左操作数是否大于右操作数,如果是则条件为真 (A>B)不为真
< 检查左操作数是否小于右操作数,如果是则条件为真 (A<B)为真
>= 检擦左操作数是否大于等于右操作数,如果是则为真 (A>=B)不为真
<= 检查左操作数是否小于等于右操作数,如果是则条件为真 (A<=b)为真

关系运算符返回的都是bool类型的值。

逻辑运算符

A = 1; B = 0;

运算符 描述 实例
&& 逻辑与运算符。如果两个操作数不为零,则条件为真 (A && B)为假
|| 逻辑或运算。两个操作数任意一个非零,则条件为真 (A || B)为真
! 逻辑非运算,用来逆转操作数的逻辑状态 !(A && B)为真

逻辑运算符本身也是有优先级的

1
2
auto 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

与、或、异或都是双目运算符,结合性从左到右,优先级高于逻辑运算符,低于关系运算符,且从高到低为&、^、|

其他运算符

运算符 描述
sizeof sizeof运算符,返回变量的大小,即这个变量的类型所占用的byte的长度
Condition ?X:Y 条件运算符。如果Condition为真,则值为C,否则值为Y
逗号运算符,会执行一系列运算,整个逗号运算符表达式的值是以逗号分隔的列表中的最后一个表达式的值
.(点)和->(箭头) 成员运算符,用于引用类、结构体和共用体的成员
Cast 强制类型转换运算符,把一种数据类型转换成另一种数据类型,如int(2.20)将返回2。C++中不建议使用强制转换
& 指针运算符&,返回变量的地址
* 指针运算符*,指向一个变量

运算符优先级

下表从高到低列出各个运算符,较高的运算符会被优先计算:

类别 运算符 结合性
后缀 () [] ++ -- 从左到右
一元 + - ! ~ ++ -- (type)* & sizeof() 从右到左
乘除 * / % 从左到右
加减 + - 从左到右
移位 << >> 从左到右
关系 < <= > >= 从左到右
相等 == != 从左到右
位与AND & 从左到右
位异或XOR ^ 从左到右
位或OR | 从左到右
逻辑与 && 从左到右
逻辑或 || 从左到右
条件 ?: 从右到左
赋值 = += -= *= = %= >>= <<= ^= |= 从右到左
逗号 从左到右

只需要记住两点:

  1. 一般来说,一元运算符优先级高于对应的二元运算符;
  2. 弄不清就加括号

编译型语言

编程语言可以分为四个层次,从上到下,语言会更接近人类使用语言的方式,但相对应的程序运行效率逐渐降低

  1. 机器语言、汇编语言:这些语言执行效率很高,但移植性很差;
  2. 编译型语言:C/C++。这种类型的语言更接近人类的使用方式,在不同的机器上也可以跨平台使用。但跨平台有一些难度;
  3. 解释型语言:Basic、Python。这种类型的语言通过解释器作为中间层,跨平台性很强;
  4. 脚本语言:bash、csh。不同的平台拥有不同的脚本,本身的功能并不强大,但是很容易把各种语言融合在一起。

C++属于编译型语言,需要经历编译和链接的过程,才能变成真正的可执行程序: 源程序➡编译器➡目标程序➡链接器➡可执行程序

数据类型

C++中的每个变量都有其数据类型,数据类型决定这个变量所占用内存空间的大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。 在计算机中,1byte=8bit。我们在说数据类型的大小的时候一般以byte作为单位来计算。

名称 字节数 描述 范围
char 1 存储字符或者整数,8位(bit) 有符号:-128~127;无符号:0 ~ 255
short 2 短整数,16位 有符号:-32768~32767;无符号0 ~65535
long 4 长整数,32位 有符号:-2147483648~2147483647;无符号0 ~4294967295
int 4 整数 同上
float 4 浮点数
double 8 双精度浮点数
long double 8 长双精度浮点数
bool 1 布尔值,只能是真值或假值 true或false
wchar_t 2 宽字符,这是为存储两字节长的国际字符而设计的类型
1
2
3
4
5
6
7
8
9
10
//常见的数据类型的定义
char a[10] = "a";
short int s = 97;
int m = 97;
long int n = 97;
float f = 97.0f;
double d = 97.0;
long double k = 97.0;
bool b = true;
wchar_t w[10] = L"a";

标识符和关键字

C++中的标识符是用来标识变量、函数、类、模块,或者任何其他用户自定义项目的名字;

  • 一个标识符通常以字母A~Z或a ~z或下划线_开始,后面跟零个或者多个字母、下划线和数字;
  • 一个标识符不允许使用数字开头;
  • 一个标识符内不允许出现标点字符,如@、&等等;
  • 不能混淆大小写,C++是区分大小写的编程语言;
  • 不能使用C++关键字作为标识符,原则上不允许长度超过32位;

标识符命名规则:

  • 不要试图发明最好的命名规则,要制定一个让大部分人都满意的命名并在项目组中贯彻执行;
  • 标识符应该直观,可以望文知意,尽量使用英文单词组合,不要使用拼音;
  • 标识符长度要符合“min-length&max-information”原则,长度尽可能短,信息尽可能多
  • 变量名字尽可能使用“名词”或“形容词+名词”形式,尽量避免出现数字;函数名尽量使用“动词+名词”
  • 可借鉴的命名规则:
    • 匈牙利命名法:开头字母用变量类型缩写,其余部分用变量的英文或者英文缩写,要求第一个字母大写,如int iMyAge;
    • Camel(驼峰)命名法:第一个单词首字母小写,后面单词首字母大写,如int myAge;
    • Pascal命名法:每个单词的第一个字母都大写,如int MyAge;

关键字

C++的关键字

变量与常量

变量

  • 变量就是在程序运行过程中,值可以改变的量;
  • 变量在程序的执行中可以进行赋值造作,发生变化;
  • 变量有一个名字,并在使用之前要说明其类型,一经说明,就在内存中占据与其类型相应的存储单元;

变量定义:

  • 首先是类型说明符,随后紧跟一个或者多个变量名组成的列表,其中变量名以逗号分隔,最后以分号结束:int m = 1,n = 1;
  • 当变量在创建时获得了一个特定的值,我们说这个变量被初始化了。用于初始化变量的值可以是任意复杂的表达式;
  • 当一次定义了一个或多个变量时,变量的名字随着定义就可以使用了,如:int m=1,n=1; int sum = m + n;

常量

  • 在程序运行过程中,其值一直保持不变的量为常量;
  • 常量也区分不同的数据类型;

常量在C++中有两种定义的方法:

  1. 使用#define,如:#define PI 3.14
  2. 使用const,如:const double PI = 3.14

尽量使用const形式定义变量,因为#define不会出现在编译时期,在编译出错时不容易排错。

整数常量

整数常量可以是十进制、八进制或者十六进制的常量:

  • 前缀指定基数:0x或者0X表示十六进制,0表示八进制,不带前缀则默认十进制;
  • 整数常量也可以带一个后缀,后缀是U和L的组合,U表示无符号整数,L表示长整数。后缀可以是大写也可以小写,不区分顺序。

布尔常量

布尔常量共有两个,他们都是C++关键字,true代表真,false代表假。

字符常量

  • 字符常量是括在单引号中的。如果常量以L开头,则表示宽字符常量。如果是宽字符常量则必须保存在wchar_t类型的变量中;
  • 字符常量可以是一个普通字符(‘x’)、一个转义序列(’)、或者一个通用字符(’2C0’)。
  • 需要表达一些符号的时候需要用到转义字符