Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

体系结构技术发展

流量强调的是在一定时间内完成的工作量,又称之为带宽;响应时间强调的是在一个请求提出之后得到回复的时间间隔,又称之为延迟。二者的核心内容是时间。

两个不同的计算机,X比Y快n倍,表示一个程序在X上的执行时间比在Y的执行时间快n倍。

  • 墙钟时间,wall time不一定是单调递增的。因为wall-time是指现实中的实际时间,如果系统要与网络中某个节点时间同步、或者由系统管理员觉得这个wall-time与现实时间不一致,有可能任意的改变这个wall-time。
  • response time响应时间
  • elapsed time

benchmark用来评估计算机性能,有五种:真实程序、核心程序(应用内挖出来的典型应用)、简单程序(素数筛选等)、synthetic benchmark(组合程序)、benchmark suites(测试组件)。

最著名的测试组件:SPEC(system performance and evaluation cooperative),是一个benchmark suite,侧重于CPU内部的性能,是研究计算机的人员所侧重的,用来测试Unix工作站。

CPU performance:大部分计算机在一个特定时钟周期下工作,Clock time(CPU时钟)越高越快。

一个程序的CPU时间表示为占用的时钟周期乘以时钟周期数,或者占用的CPU时钟周期/CPU时钟工作频率,得到一共用了多少拍。

Instruction Count(IC)是指令数,与机器的指令系统和编译系统有关。
Cycles Per Instruction(CPI)用(CPU时钟周期数)除以指令数,是每条指令占用的节拍数,与硬件组织有关。IPC是二者倒过来。

所以,CPU时间 = IC * CPI * Clock cycle time,或者CPU time = IC * CPI / clock_rate

3个基本原则:

  • 大概率事件优先,让最常见的事执行的最快。
  • Amdahl定律,可以找到系统的瓶颈在什么地方,且系统的性能是由系统最差的部分决定,一个系统是均衡优化的系统。
  • 程序局部性原理,时间局部性(将来用的东西最大概率是我现在用的)、空间局部性(这次访问的下次可能再访问,出现内存中的热点部分)。

指令系统和基本流水线

计算机指令系统:

  • 所有机器的指令系统相似,但都不一样;
  • 桌面计算、服务器和嵌入式系统的指令系统有差别
    • 桌面系统要求同时有整数和浮点数,对容量的消耗不敏感
    • 服务器中,浮点数运算不如整数运算和字符串处理重要
    • 嵌入式系统对容量和大小更敏感

指令集分类是根据CPU访问存储器的方式分类的

  • 堆栈型,少见,最大的好处是程序员事先组织好数据,之后不需要管;
  • 累加器型,部件少;
  • 寄存器型,有寄存器-存储器、寄存器-寄存器型
  • 存储器型。

指令的四个方面:

  • 存储器地址
  • 操作类型
  • 操作数类型
  • 指令编码

访问内存时,首先要告诉,访问哪个地址,访问地址有多长。注意字节对准,对一个地址的访问需要成块成块的访问。

大端小端:如果数据超过一个字节,则数据的第一个字节放在哪个位置?第一个字节放的是高八位,则是大端。

寻址模式:可以减少指令数,但是增加了复杂性。越丰富的寻址方式给程序设计人员带来更大的便利,过于复杂的寻址方式降低了利用率。

现在所使用的数据有8位的、16位的、32位的、64位的。

常用的指令系统:

  • 逻辑运算指令,ADD,AND,OR
  • 数据传输,LOADS、STORES
  • 控制类指令,指令执行方向的改变所需的指令,jump、call、trap
  • 系统类指令,实模式切换到保护模式等一些系统调用;进程切换时需要cache的清空指令
  • 浮点类指令
  • 字符串类指令
  • 图形类指令,即数字图像处理类指令。MX、MXR等

指令功能设计中,有一类指令是改变控制流的:

  • 条件分支,有条件转移
  • 跳转,无条件转移
  • 子程序调用
  • 子程序返回

这一类指令会影响到系统的性能,导致机器在运行中频繁执行切换,可以:

  • 直接形成条件码,设置特殊标志位
  • 条件特别多,做一个条件寄存器
  • 比较完直接拿结果

操作数问题:最常见的操作数有如下几种:

  • 字符型
  • 整数型,半字、字。
  • 浮点型,32位短浮点和64位长浮点,尽量使用64位的,因为浮点数的误差和累计误差很大
  • 十进制数,不紧缩的可以看成串,紧缩的是按照BCD码调整的,直接进行运算,一个字节放两位十进制数

指令编码:经典的RISC机器都是固定四字节的指令长度,80x86指令长度从8位到48位,译码的时间过长,需要使常用的指令较短。

编译器大概有如下的过程:

  • 前端的语言处理
  • 高级优化,与机器关联,考虑如何优化
  • 全局优化,考虑寄存器分配和全局变量的存储
  • 代码生成,依据机器进行生成代码

一个地址会有很多中表示方法,这叫做“别名”,给编译优化带来了很大的问题。

MIPS实现:

  • MIPS整数部分的子集,还包括存取字,整数ALU,基本浮点功能部件
  • 过程
    • Instruction Fetch:取出指令放到指令寄存器中,生成下一条指令的地址,当前指令地址+4,
    • Instruction Decode:译码,读取寄存器
    • execution/effective address:处理地址。
      • 访存指令,ALU得到地址,将结果放到寄存器中
      • register-register运算类指令, ALU执行操作码指定的运算,将两个寄存器中的值进行运算
      • register-immediate运算类指令,
      • 分支指令,
    • memory access:访存,访存阶段如果是运算类指令,则绕过访存直接进入寄存器,如果是分支类指令则有其他操作。
    • write back:写回,结果写入寄存器中,不管是从memory sytem或者ALU中来的。目标寄存器在两个位置之一(rd或rt),取决于操作码
    • 这是一个五拍的工作过程
  • 流水线MIPS是再流水阶段增加流水线寄存器(锁存器),寄存器名字与它们连接的状态有关:
    • IF/ID — ID/EX — EX/MEM — MEM/WB
    • 共有四个。

提高并行化有三种出路:资源的大量使用、时间重叠、资源的共享。流水线以时间重叠实现并行。

流水线的操作步骤

  • 取指令:
    • 把存储器中的当前需要执行的指令,取出来放到IF/ID寄存器中
    • 如果操作码是分支类指令,并且分支条件为true,把后边流水线计算的结果放到寄存器中,这个地方可能出现等待;否则就PC+4
  • 译码段:
    • 两个源操作数寄存器送到ID/EX
    • 指令和下一个PC从IF/ID传到ID/EX
    • 立即数进行扩展,因为立即数都是8/16位的,需要扩展后参加运算
  • 执行段:
  • 访存段:
    • 对load/store指令,把锁存器的指令传过来
    • 存储器的输出接过来
  • 写回段:
    • 写回到rd或者rt,因为其中一个是register-register指令,一个是register-immediate,所涉及的寄存器不同。

指令流出:指令从译码段进入执行段,并不是所有指令都可以流出,有的译码之后不能执行。

数据冒险:在一个指令执行的时候,所需要的数据(状态)对前边指令产生依赖关系,只有在流水线上才发生如此的依赖关系。

  • 四种可能的组合
    • read after write
    • write after read
    • write after write
    • read after read(不是冒险)

空转(Stall):机器并没有停但是没有干什么,出现冒险时控制器插入stall,避免某些指令的提前,可以通过比较流水线寄存器来检测冒险。

定向(旁路,bypass):为了尽早获得数据,减少因为数据冒险而导致的空转,越早拿到数据,空转的周期越少。

流水线的分支会产生问题,已经有一组指令进来了,但是可能会进入另一个分支,在译码阶段就知道了,将需要进行计算的分支条件进行提前判断。

例外/异常(Exception):

  • IO设备请求
  • 调用操作系统服务,通过和异常类似的方法处理操作系统的使用
  • 断点
  • 整数上下溢出
  • 浮点计算异常
  • 缺页
  • 寄存器访问未对准
  • 使用了非法指令
  • 硬件故障

有一些例外是同步的,有一些是异步的(网络请求,IO请求);
可屏蔽的中断和不可屏蔽的中断;
指令间的和指令内部的;

机器能够在碰到例外后进入一种有序的状态,作为体系结构设计的时候,当发生例外并被处理之后,可以实现状态“可预测”,有很多种策略:

  • 强迫指令流中止,但是如果流水线长且可以乱序执行时难以实现;
  • 不允许产生例外的指令把结果放入寄存器;
  • 例外处理程序将PC保存下来。

精确异常:明确地确定哪一条指令导致了例外,这种情况下称为精确的异常处理。机器内部导致的例外可以精确定位,外部请求导致的例外不用定位。例外和指令处理的各个步骤均相关:

  • IF:取指令时的缺页中断,内存访问的不对齐,存储器保护错误
  • ID:译出来的指令不知道是什么
  • EX:计算意外
  • MEM:页失效,不对准等

流水线中的多周期:有些指令耗时长,有些耗时短,如果指令执行时间差距不大倒还好,可以均切分。指令过长导致流水线出现:指令在执行中间出现机器调度不确定性;导致浮点部件在结构上出现冒险,搞不清楚指令到底执行完了没有,或者在等待结果的时候不知道能不能等到。

如果不能把所有部件设计成等长,则设计成不等长,在MIPS中,把执行段设计为4个部件:整数部件、浮点加、乘、除四个部件,其他不变。

流水线的延迟和执行下一条指令要等多久,这两个时间是流水线的重要属性。

流水线中结构不足所导致的风险,和流水线密度所导致的风险(每一拍所产生的结果与前后都有依赖,具有反馈性,需要保证旁路通道多)

指令结束的次序与指令输入的顺序不一定一样,先写后读的风险大得多。

动态调度和静态调度:静态调度又称为编译器调度,在程序执行之前对程序的指令进行排序;动态调度是开始执行发现执行顺序不好,则重新进行排序,通过硬件办法重新排序减少机器空转,优点是对于程序静态分析时看不出来的情况可以进行调度,编译器也可以简化,且硬件的事可以交给硬件自己去做,但是硬件成本大大增加,复杂性增加。

为实现动态调度,流水线必须具备以下功能:

  • 允许按序取多条指令和发射多条指令——取指(IF)流水级允许按顺序取多条指令进入单口暂存器(single-entry latch)或队列(queue), 指令然后从latch或queue取出,进入ID节拍。
  • 能检查并消除hazards——将ID流水级分为独立的两级:Issue级和Read operand级:
    • Issue级功能——指令译码,检查是否存在结构冲突(即在这一流水级解决结构冲突问题);
    • Read operands级功能——等到无数据冲突(RAW)后, 读出操作数,即在这一流水级解决数据冲突问题。

记分牌算法。需要足够资源和没有数据相关,记分牌是枢纽,所有的指令都要经过它留下执行的记录和依赖条件,如果记分牌决定指令不能立即执行,会将指令进行重排并决定何时可以执行。

记分牌是一集中控制部件,其功能是控制数据寄存器与处理部件之间的数据传送。在记分牌中保存有与各个处理部件相联系的寄存器中的数据装载情况。当一个处理部件所要求的数据都已就绪(装载完毕),记分牌允许处理部件开始执行。当执行完成后,处理部件通知记分牌释放相关资源。记分牌中记录了数据寄存器和多个处理部件状态的变化情况,通过它来检测和消除或减少数据相关性,加快程序执行速度。

如果在MIPS上做记分牌,要在指令的译码阶段检查结构和数据冒险。可以解决:写后读相关,解决乱序结束。把指令译码阶段分成两个部分,指令的结构冒险和数据冒险给它分开,所以要拆成两步。

  • 第一步:指令流出,条件是它所使用的功能部件是空闲的且所要写的目标寄存器没有被别人写,检查了结构冒险和数据冒险中的写后写冒险,因为前边可能有超长的指令还没完成。
  • 第二步:读操作数,指令流出之后,所要的数据还没来,这条指令读操作数就读不出来,就要等结果。
  • 第三步:运算,直到运算完成。
  • 第四步:写回,检查读后写冒险,等别人把数据读走之后再写。

记分牌并没有发挥定向通道的优势,必须读写分开。

总结一下:
动态调度技术需要将ID译码段分成两个阶段:1是发射,2是读取操作数。发射阶段对指令进行译码,检查结构冒险(例如有四个运算器:整数运算、加法器、乘法器、除法器,检查该指令需要使用的运算器是否正在被占用)读取操作数阶段检查数据冒险(读之前检查寄存器的值是否已经写回,或者是否会覆盖了之前的值)。数据冒险的解决方法(读写冒险(RAW):将指令和操作数保存起来,然后只能在读操作数阶段进行读取;写写冒险(WAW):检测是否有其它指令会写回到相同的寄存器(检测到冒险),有则等待,直到其它的完成)

发射阶段:假如检测到没有结构冒险和数据冒险,那么记分板将会将指令发射到相关的运算器,假如结构冒险或者写写冒险发生了,那么该指令将会等待,直到冒险消失为止。我要使用的功能部件忙标志位为否且指令所要写的状态是空。填写三个寄存器(目标寄存器,第一、二操作数寄存器,填写的是寄存器编号)

读取操作数:没有数据相关了以后(之前的指令不会写回源寄存器或者正在写寄存器的情况时,读写冒险),读取操作数。读取操作数后将交给运算器,之后开始运算。发送到运算器的顺序可能是乱序的。

之后就是执行段以及写回段了。没啥好说的。执行段在完成计算以后会通知记分板。记分板直到计算已经完成了,那么它进行读写冒险检验(即写之前是否已经读取了寄存器的值,例如 ADD F10,F0,F8 SUB F8,F8,F14,这里SUB指令写回时要检查ADD指令的F8是否已经读取了,仅此而已)假如检测到冒险,则等待,不然就可以写寄存器了。

记分牌的构成:

  • 指令状态,
  • 功能部件状态,很多个域
    • Busy 标识该器件是否正被使用
    • OP 该器件正在执行的运算 例如 + - * / 等等
    • FI 目标寄存器
    • Fj,Fk:源操作寄存器
    • Qj,Qk: 如果这两个数据没有谁将生成这个数据(源操作寄存器正在被什么单元所处理),如果是NO的话说明已经拿到数据了或者数据尚未准备好
    • Rj, Rk 表示Fj Fk是否准备好的标志位
  • 寄存器状态,标识哪一个存储器将会被写回

这是一种以记分牌电路为核心的设计方法。

指令级并行(ILP)

指令之间有一种特征,可能会并行地执行而不影响结果,正是要挖掘这个特点使指令并行地执行。一种是动态办法(依赖硬件定位并行性),一种是静态办法(依赖软件)。

流水线CPI = 理想流水CPI + structural stalls + data hazard stalls + control stalls

先进的流水线:不区分动态静态和软硬件,所有的技术都与编译器结合。

ILP的概念:

  • 基本块:一个没有分支的指令块
  • 串行代码:只有少量的并行性。
  • 操作系统代码的基本块较长

跨越多个基本块的指令级并行主要是在循环级探讨并行性,这是最常用的提高并行性的方法。需要将循环级并行转成指令级并行。最常用的方法是循环展开,可以通过编译器或者硬件实现。循环的每一次迭代执行可以与其他迭代重叠,需要确保循环中涉及的数据不会干扰。

向量处理器作为专用部件应用在图形处理器中。

数据相关和数据冒险:相关导致冒险,冒险导致空转,空转导致流水线效率下降。数据相关可能会产生冒险,尽可能减少机器的空转。

名相关:分为两条指令都写相同寄存器和读后写两种,读后写可以通过名字的改变消除。

区分数据相关和名相关:是否在指令之间发生了数据传输,数据相关发生了,名相关没发生。克服名相关可以寄存器重命名。

控制依赖的调度有两个基本原则:与分支指令控制相关的指令不能调度到分支指令之前去,与分支指令无关的指令不能调度到分支之后。

数据流前后的依赖关系需要数据依赖和控制依赖的协调,且数据流依赖设计链式依赖。

Tomasulo算法

核心思想:硬件的动态指针技术。动态解决RAW,允许指令乱序流出。

两点显著不同:冒险检测机制不像记分牌一样集中在电路上,而是分布在算法中的。不检查WAR和WAW,因为已经被算法消除了。

重要概念:保留站(一种虚拟功能部件)。就是一个缓冲,每个保留站中保存一条已经流出并等待到本功能部件执行的指令(相关信息)。里边保存了指令和操作数,以及等待执行的所有条件。

在一条指令流出到保留站的时候,如果该指令的源操作数已经在寄存器中就绪,则将之取到该保留站中。如果操作数还没有计算出来,则在该保留站中记录将产生这个操作数的保留站的标识。

也发挥了寄存器重命名的功能,原来访问寄存器a,缓冲以后,不再访问寄存器a,而是去访问缓冲。

记录和检测指令间的相关,操作数一旦就绪就立即执行,把发生RAW冲突的可能性减少到最小;通过寄存器换名来消除WAR冲突和WAW冲突。

过程:

  • 从指令队列的头部取一条指令。
    • 如果其操作数在寄存器中已经就绪,就将这些操作数送入保留站r。
    • 如果其操作数还没有就绪,就把将产生该操作数的保留站的标识送入保留站r。
    • 一旦被记录的保留站完成计算,它将直接把数据送给保留站r。
    • 如果没有空闲的保留站,指令就不能流出。
  • 操作数来了之后运算。两个操作数都就绪后,本保留站就用相应的功能部件开始执行指令规定的操作。
  • 得到结果后放到共用区域(common data bus),共用区域链接所有可能需要数据的部件,cdb发出广播,所有需要这个结果的部件将同时拿到这个结果,这样可以大大减少连线量。在具有多个执行部件且采用多流出(即每个时钟周期流出多条指令)的流水线中,需要采用多条CDB。每个保留站都有一个标识字段,唯一地标识了该保留站。
  • 拿到结果之后结果再消失,数据放到了公共区域,那么要写的目标寄存器也要到公共寄存器去拿,减少了仲裁机构调派部件拿数据的过程。
  • 所有保留站有各种标志,用来进行各种数据状态的检查。

执行步骤:

  • 首先把浮点指令送到指令队列
  • 没有结构风险的时候就把指令流出
  • 根据需要送到确定的运算部件或访存部件
  • load或store把指令送到访存的缓冲中去
  • 如果访存操作数没有被取到,就把产生这个数的浮点功能部件的编号取过来,实现了寄存器到保留站的重命名过程,把寄存器重命名到保留站,消除了同名。
  • 结果有效时,写到cdb中。

每个保留站有以下6个字段:

  • Op:要对源操作数进行的操作。
  • Qj,Qk:将产生源操作数的保留站号。
    • 等于0表示操作数已经就绪且在Vj或Vk中,或者不需要操作数。
  • Vj,Vk:源操作数的值。
    • 对于每一个操作数来说,V或Q字段只有一个有效。
    • 对于load来说,Vk字段用于保存偏移量。
  • Busy:为“yes”表示本保留站或缓冲单元“忙”。
  • A:仅load和store缓冲器有该字段。开始是存放指令中的立即数字段,地址计算后存放有效地址。
  • Qi:寄存器状态表。
    • 每个寄存器在该表中有对应的一项,用于存放将把结果写入该寄存器的保留站的站号。
    • 为0表示当前没有正在执行的指令要写入该寄存器,也即该寄存器中的内容就绪;非全0的时候时保留站的编号。

分支预测

当指令流出速度快时,流水线中的指令是多指令流出,如果遇到分支则会出现问题。前边讲过静态的分支预测,假设分支总是成功/不成功等条件,很好的帮助程序提高性能。动态分支预测使用硬件对程序进行预测,依赖于程序的动态特征和执行过程。在分支预测时,假设分支指令是成功或不成功。

分支预测的精确程度、预测正确和不正确的开销比较都会影响分支预测是否成功。最简单的分支预测方法是看上次的分支结果。

一种办法是BPB(Branch Prediction Buffer),记录分支历史的进入分支数,用于下一次分支特征的预测。记录一个分支指令成功或不成功,放入缓冲中,下一次指令再来的时候查这个缓冲,有多大程序就有多大缓冲,因为程序中哪个是分支不知道。通过指令地址进行记录的同等检索,下次再进入的时候再检查是成功还是不成功。BPB的buffer还是个小的寄存器,用指令的地址进行同等检索,所记录的是分支指令发生还是不发生。

绝大部分指令不是分支指令,如何把分支预测缓冲做的小,比如做到一半那么大。采用地址折半,上半截主存和下半截主存映射到同一块地址,并且实际预测正确率不降低。减到一半还嫌多,那就先做一个512个入口的缓冲,使用指令地址的低位来访问缓冲,效果也不错。这样就是取模了,可能会冲突,如果问题严重的话就加大分支预测缓冲,分支预测错了则将那一位反转。根据被预测指令是否成功画出状态转移图:

上图只有一位,比较浅薄。可以让历史更深一点,再加一个位,构成两位的分支预测。一个预测必须失败两次后才能改变。对4k的缓冲,最高命中率达到82-99%,这么高的命中是因为可能有的程序有很多循环。

浮点指令猜错的概率要比整数指令猜错的概率小,因为浮点计算主要面向科学计算,循环多,所以猜错概率小。

预测器的位数在2位和n位的时候差别不大,所以很多系统只使用2位的分支预测。做4k个入口和更多的入口效果差不多,所以只有4k个入口就够了。

相关分支预测,现在的分支预测是不是成功要根据上一个分支预测是不是成功,实际上是做两级,即做两个1位分支预测器,如果上一次预测成功则使用一个预测器,如果上一次预测不成功则使用另一个预测器,使得本条分支预测的结果基于上一次分支预测。

多指令流出:每个中期发出4-8条指令,必须要使得指令流得到更大的带宽,有三种方法:

  • 分支目标缓冲(BTB),另外一种动态分支预测方法,在取指令阶段对btb进行搜索,如果不在表中就看是不是分支指令,而且是不是成功的分支指令,如果是,但是表中没有,则放进去,如果表中有了就直接拿出来执行;如果不在btb中且不是成功分支(不是分支或不是成功的分支),这种情况下IF没有困难,都是PC+1,。通过查表提前知道对应的pc值和next program counter,只要之前这个指令来过,就记下来分支地址和它的转移目标地址,下一个IF到来的时候就不需要取指令了。
  • 集成化指令分派部件
  • 预测返回地址

分支预测的一个变种:branch folder,不仅有PC和next PC,还把指令放到表中,直接在译码段就开始比对。

两种办法可以使得机器一拍流出一堆指令,使CPI小于1,超级标量处理器,一种是VLIW(very long instruction word)。超标量是标量集合,把彼此不太关联的一些指令组合起来,一次发出的是若干个指令,每拍流出的指令数是变化的。可以采用动静态方法实现超标量,机器不存在在执行中调整指令顺序的能力,编译器对指令顺序进行调配,调整了优化参数打开超标量之后可能会不对!

VLIW指的是一条超长指令字,经典的概念是不允许乱序流出的。它是一个拥有固定数量指令的指令包,有若干种特定类型的指令组成,机器一次发出的是一个指令包。

静态调度超标量:指令有序流出,所有的流水线冒险都必须在编译时预先检查,在流出时如果有冒险就不允许流出。编译器的工作量非常大。

现在使用的超标量是把指令打一个包,让他去执行,这个指令包一般会对指令有要求,指令流出时一拍内流出多条,指令总线做宽一点,大家排好队一起往前走,这样很简单,但是电路实现很复杂,指令要控制好先后顺序,不能出现无序流出。还可以把指令流出这一步切成流水线,把流水线本身的一站作为子流水线。

超标量机器指令取:取指令并用64位译码器译码。

  • 从cache中取两条指令;
  • 确定是没有指令、1条指令还是两条指令;
  • 把它们送到正确的功能部件。

静态超标量不允许乱序流出,有一些特定的顺序不可乱。

指令流出的过程中,允许两个指令同时流出,有一个限制,浮点指令流出时需要一个整数指令寄存器,在进行整数和浮点调度时,不能分开调度,要统筹考虑。

现在所使用的超标量寄存器每个时钟4条以上,包括了上述两种方法。实际上对RISC机器流出4条多。像x86机器可能流出3条,但是这三条CISC指令可以拆出20多条指令。

超标量的时候有一些限制:

  • 浮点功能部件不能被充分利用,需要更少的整数操作;
  • 需要大量的指令级并行(相关性很小的部分);
  • 超标量时一个循环的判断分支带来了不能并行的阻碍。

控制的相关性引发的控制冒险可能会导致指令的“空槽”,超标零的性能被限制,需要前瞻执行,用来克服类似分支指令导致的麻烦。最好能够把分支指令当成普通指令直接扔进去执行。

基于硬件的前瞻和预测:允许指令提前流出执行。必须有动态分支预测,前瞻是一种保证,保证预测不会影响全局。加上动态调度。必须要有undo的功能,来处理前瞻执行不正确的情况,这样实质上是基于大量缓冲的功能。

基于硬件的前瞻性执行做了一个确认段,实行基于数据流的检查,只要数据流是正确的,就可以保证执行是正确的,因此要添加确认段,确认正确了再写进去。这里隐含了一点,指令可以乱序流出执行,但是确认是顺序的,这说明指令在通过指令流出部件的时候被打上了某种标记来标志它的顺序。所有的结果包括例外都要得到确认。

机器基于硬件实现前瞻,采用复杂电路解决的是分支问题(控制相关问题),指令级并行开发的深的话,如果不能提供足够的并行度,则造成浪费。硬件的前瞻执行是分支指令的预测过程,很多指令通过这种办法在控制相关未解决的情况下执行了。确认执行错了之后,可以回退,撤销之前的执行。

执行乱序、确认有序是一个排队等待的过程,要有一种排队机制来支持确认,它实际上是一个缓冲过程,这个排队过程称为ROB(再定序缓冲,Reorder Buffer),保存对机器执行有影响的状态。保存已经执行完但是没有提交的指令的结果,提供额外的寄存器作为保留站。

三个重要的ROB域:

  • 操作域,用来保存指令,例如分支指令、load/store指令、寄存器操作指令;
  • 目标域,记录目标寄存器,可能是寄存器也可能是存储器的地址,这个结果要被写到哪里;
  • 值域,在确认的时候保存结果,直到指令真正被执行。

工作流程:

  • 指令流出
    • 如果有保留站且再定序缓冲有空,指令流出,一条指令流出至少要占用两个资源。如果指令所需数据在寄存器中或再定序缓冲中存在,则取出来送到保留站。
    • 指令流出的时候先做一些分类,之后做译码,决定做什么操作,扔到运算部件去。
    • 进行标志状态的修改
  • 指令执行
    • 如果有操作数未准备好,就监视CDB(common data bus),这个过程检查数据相关;
    • 指令可能会等很多拍
  • 写结果
  • 确认过程

一个store指令的确认:前提是被确认的指令到了再定序缓冲的顶部,要把结果寄存器更新掉,把它从ROB种清除掉。

一个不正确的分支预测表明前瞻执行是错误的,刷新ROB,重新从正确的分支开始执行,一个正确的分支预测则使得这个分支与正常的指令类似。

浮点程序中的分支是有极强的规律性的,整数程序中的分支不明显。

基于存储器地址的前瞻性执行:减少对于顺序地址计算的限制,使用硬件预测依赖。

堆栈、寄存器的使用对ILP的效率影响。
别名分析:对程序执行的并行性有影响。

只有发现依赖之后才前瞻,首先找到相关性,相关性基础上处理器进入前瞻状态。

数据值预测/地址值预测:很难,是一种很精确的前瞻方法,不允许有误差,。如果能进行完美的值预测,则不需要编程啦。

线程级并行:可以以线程的方式组织程序运行,一个线程是一个独立拥有数据和指令的实体。可以根据需要派生线程。多个线程并发执行有一个重要概念:同时多线程(SMT),既能同时执行,也要同步。

编译调度

使用编译技术提高流水线性能,减少因为数据冒险导致的阻塞和分支预测。

假定使用5站流水线,已经完全流水,如果没有相关性则会顺畅地流下去,没有任何阻塞;如果有分支指令则在分支指令及其前一个指令之间有1拍延迟,整数部件load有1拍延迟,整数部件无延迟。

如果是分支指令,取指令1拍,指令译码1拍,产生结果得到分支目标1拍,这个结果不经过任何过程再返回。如果采用锁定机制(发现是分支指令就不取下下一条指令了),这时已经到指令译码了,刚好已经取进来一条了,这就叫做分支延迟槽,再往后的指令先停下,跟进来的这条指令就允许向下流,或者更彻底,只要发现了是分支指令,跟着进来的那个也不管了,这会导致两拍的开销。

循环展开、指令调度、寄存器重命名。

  • 确定指令的调整是否是有效的,移动的指令不影响执行结果;
  • 确定循环和循环之间不存在相关性;
  • 使用不同寄存器避免在使用相同寄存器时的不必要约束;
  • 在循环展开时注意处理结尾的迭代;
  • 明确load和store在循环展开时是否可以交换,不同迭代的load/store相互独立,分析内存地址明确是否是同一地址
  • 如果存在相关性,必须确定和原始代码的相关性一致。

通过使用寄存器重命名,在两次迭代之间减少相关性,而不影响一个迭代内部的相关性,也有代价,比如多用几个寄存器,或者代码体积会增大,编译器更复杂。

有的情况下编译时就能预测分支是否成功,它的成功率分布很离散,从9%到59%。改成基于方向的,如果程序到了分支,如果程序往前走(可能是if),认为不成功的概率居多,如果往回走(循环),成功的概率居多,猜错率在30%-40%。

改一下,使用程序的上下文信息,每次预测的时候使用上一次预测的结果,可能会生成更精确的预测;再进一步地,基于统计信息,先得到一些统计信息,基于统计预测分支的走向,这种方法的指导性很不强。

超长指令字机器,首先要确定机器的最大并行度,全靠软件做,先把指令打包,确定封装包之间的相关性,在所有的指令中间,确定一条指令跟正在被处理的所有指令是否相关。

静态超标量通过编译器调度来帮助处理器达到更高的性能,动态超标量不需要编译器调度,但是需要硬件的开销。

超长指令字是对编译器及其依赖的一种技术,最小化潜在的数据冒险延迟,将一些指令封装进一个流出包中,也不需要检查潜在的相关性,执行中认为去拿的数据一定是对的,如果没有一定的保障,则可能会拿不对的数据,需要编译器进行控制,编译器需要控制指令包内、包之间的相关性,好处就是硬件会很简单,不需要考虑前瞻和相关性,仍然能达到很高的性能,VLIW使用多个独立的功能部件,把一组指令按某种方式组合,构成一个长指令字向外发送。

每一个VLIW功能部件需要16-24个二进制位来表述功能部件完成的工作和寄存器。可能包含七个部件:2个整型部件,2个浮点部件,2个内存,1个分支部件。一个长指令字里出去的指令应该都是无关的,部件之间不存在数据交换通道。

早期超长指令字格式非常死板,这个地方是一个整数就是一个整数,指令部件就是一条一条往运行部件送,代码二进制不兼容,必须要依靠硬件和软件的适配。所以很难见到超长指令字机器。如果打包的时候没有要求的操作往里填,则填空指令,指令槽的利用率可能会比较低。

VLIW问题:

  • 代码数量增加了;
  • 每个指令之间是互锁的,在执行的时候如果一个指令被卡住,后边都会被卡住;
  • 二进制代码不兼容,如果有部件的增删,则要重新编译代码。
  • VLIW对循环展开的次数要求很高,可能不够展开的;
  • 对功能部件利用率比较低,需要插入很多的空指令。

VLIW有压缩的余地:把立即数提出来生成一个立即数域;程序在执行之前可以压缩,从存储器中取出来的时候再解压缩。

互锁机制:所有功能部件操作是同步的,不用判断数据相关,编译器解决,硬件就不用解决了,有一条指令阻塞了,其他的都会被堵住。如果在访存的时候碰到了,访存时间可能很长,指令之间的互锁机制会使性能不可忍受。很多机器在处理时将一些部件从互锁机制解开了,编译器也会解决互锁。对指令流出之后,可以不同步执行。

二进制代码的兼容性跟指令集、流水线结构、功能部件的结构/数量相关,这是超标量机器占主导的主要原因。

代码迁移过程:实用的是对代码进行调整和转换,例如在执行的时候把串行转换成VLIW。

指令的多流出和向量处理器:成本是相当的,向量处理器性能高些;多指令流出对代码要求比较低,不需要向量化,且多存储器的带宽要求比较低。向量往往作为处理器的加速部件。

开发ILP的高级编译技术

通过一部分硬件的支持(前瞻),通过软件技术的方法(编译)提高并行性。

  • 循环级并行:检测和减少迭代之间的依赖,找到并行性。
  • 软件流水线:一种循环展开的过程,解决面向不同应用的问题,不用根据体系结构进行优化。
  • 路径调度:控制指令相关的调度策略,将执行过程看成一个路径。

检测相关性:

  • 进行代码调度
  • 检测循环是否有并行性,检测在执行中时间上的概念
  • 减少名相关

一旦涉及到循环,数组和指针是最头疼的,一个有效工作的循环一般都有数组和指针,也就存在别名(alias)问题,这往往因为数组或向量下标引起的。也需要去找是否存在环状的相关性。

相关发生在两遍循环之间的问题经常存在,一次迭代使用了上一次迭代的结果。只要不存在这种相关性,即使存在其他的相关性,也可以同时流出。

如果一个for循环存在两边循环之间的相关,需要破坏掉相关才能实现并行性,如果没有相关环的话就可以破坏,如果能把上一遍循环的计算拉到这一次循环中计算,这样就能不依赖于另外一次循环。对于两次相关的爹地啊,相关的语句放到一起,不相关的语句拆开放到两次迭代中。语句之间影响并行的因素就清除掉了,但是循环之间必须保存的有序性也要保存。

相关性的检测:可以获得的并行性收到循环次数的限制,循环展开的次数越多越好,有的循环没有那么多次可以供你展开,循环展开也需要更多的硬件资源。需要知道循环不同遍之间是不是访问的相同的地址?更复杂的分析需要知道两次访存是不是请求的相同的(多个)地址。

递归:存在某种相关性,关联很确定,存在比较多的并行性。

两边循环出现循环相关的距离即为相关元素的间隔。相关距离越大,相关的冒险越小,导致机器阻塞的概率也越小,通过循环展开获得的潜在并行性也越大。如果相关距离是5,那可以循环展开得到4个副本,循环距离变为1,如果把循环距离为1的循环展开的话,循环距离不会改变。

如果展开的话可能会增加一些相关性,因为要把一些计算提前,越是循环次数远的,相关性就越长,这样就给并行以机会。

编译器检测相关性:水平极其有限,假设下标函数构成仿射函数,就是一个线性函数,被写成a ( x * i + b)的形式,a和b是常数。

检测循环中是否有相关性,即检测可能数据相关的两个语句所代表的两个仿射函数是否有整数解,如果有,则可能相关。

从理论上说,编译时不能确定认为变量相关,可能会存在一组整数解,但是可能取不到这一组解,可能与加载的运行负载有关。相关检测可能会成本很高,基本就是程序执行的一个过程,每一次迭代之间都可能存在这个问题。

GCD Test:如果不是存在整数解,而在两个仿射函数a ( x * i + b)c ( x * i + d)中,GCD(c, a)能被(d-b)整除,则可能存在相关性。

编译器如何工作:在检测相关性时进行分类,识别名相关并通过重命名或副本技术消除掉,在分析时主要分析真相关(先写后读相关)、输出相关(写后写相关)、反相关(先读后写相关),其他的都是伪相关。

1
2
3
4
5
6
for (i = 1; i <= 100; i++) {
Y[i] = X[i] / c; /* S1 */
X[i] = X[i] + c; /* S2 */
Z[i] = Y[i] + c; /* S3 */
Y[i] = c - Y[i]; /* S4 */
}

Y[i]存在很多相关,写后读、写后写等,S1 S3与S4中的Y[i]存在相关,可以消除,S1中的Y[i]使用中间变量替代;X[i]存在读后写相关,S2中的左边X[i]是最终结果了,不能用临时变量替换,要生成一个临时数组。

1
2
3
4
5
6
for (i = 1; i <= 100; i++) {
T[i] = X[i] / c; /* S1 */
X1[i] = X[i] + c; /* S2 */
Z[i] = T[i] + c; /* S3 */
Y[i] = c - T[i]; /* S4 */
}

现在的结果是都用了数组,其实对Y的处理可以使用临时变量。编译器可以把替换Y的临时变量使用寄存器搞。

编译器可以:做指令的相关性分析,确定访存地址和循环展开的成本;对循环级并行,是不是循环有利于并行;访存是不是存在相关。

软件流水和路径调度

对硬件需求少。
软件流水是一种展开技术,相关性更少一些,得到更大的并行性。是一种对循环的重组技术,从每一遍循环里面提取公用的指令,构成新的循环,这个循环过程中间,从循环体来看,看不到一遍一遍的循环展开,但是从执行上看是在一遍一遍循环执行。

循环的每次迭代是一个指令序列,按照每个循环指令序列平行展开,认为一次循环内的相关指令的相关距离小于两次循环之间的相关指令的相关距离。

竖着的四条指令更可能相关,横着的四条指令相关指令距离更大,相关距离越大则相关冒险更小,所以可以横着实现并行且指令的顺序跟竖着是一样的。对循环重组,横向取指令,总的想法是把原本竖着的循环翻转过来。构成了一个像新的循环像流水线一样的相关距离更大的循环。


把“load、add、store”三条指令展开,开始三句是补偿代码,称为填充期,最后三句称为排空期。循环指令越多,排空期和填充期的指令也就越多。一边循环的指令的长度决定了补偿代码的长度,第一遍循环的最后一条指令作为软件循环的第一条指令,所以第一遍循环的前边所有指令作为补偿代码,类似的,后边作为排空代码。都有一个问题是偏移量的计算都必须要单独计算。

两条指令之间由于一条指令延迟过长,耽误了下一条指令的计算,就可以用常规循环展开进行展开。

软件流水的代码空间比循环展开小一些,没有大量的展开。循环展开有效的减少控制变量造成的损耗,软件流水降低空转、阻塞。

循环展开减少了循环控制变量的修正,如果是多层嵌套的循环,会乘上上一层循环的循环次数,更优化。软件流水主要减少每次循环引发的阻塞,在机器以峰值计算的时候更有效,腾出更多的空间使两条指令的相关距离更大。

基本代码调度:基本块本身是没有分支的程序块,超越基本块研究指令集并行。全局代码调度是跨越分支指令的调度,在循环体内部存在分支指令,调动循环体内部的控制流,从非循环的指令之间也存在并行性,对内部非循环控制流的代码比控制指令调度更复杂。

关键路径:全局代码调度的目的是把代码压缩,压缩到最短的指令序列,不包含分支指令的序列,形成的代码就叫关键路径,这段代码的所有指令会以最大概率从头到尾顺一遍,最可能没有分支指令。

代码的调度:数据的生产消费关系流(写后读)不能改变,代码的例外特征不能改变。在全局指令调度的时候,尽量减少可能产生例外的指令的调度。

全局代码调度实际上需要权衡,是否将一个语句调度到前方,需要进一步分析变量的依赖,这种调度可能会好,可能会坏。

路径调度产生一段可以并行执行的代码,针对分支,首先把程序中的分支抽离,剩下的是路径,这样找到了一个主干,认为主干有并行性,真正执行效果跟程序有关。路径调度比循环展开更进一步,发现跨越分支指令的并行性。路径调度的主要原因是每一拍都要流出大量的指令。

第一步是路径选择,选到一条路分为两步,路径选择大概有一条指令序列可以产生最短指令代码序列完成功能,经过选择的这一段代码就是路径,如果把循环连着控制指令一起展开,如果不成功就出去,呐循环展开本身就是一个关机按路径,产生一个代码序列,如果把控制变量去掉,则变成代码块;第二步是路径压缩,进行代码调整等一系列的修正,得到一串代码,就是可以执行的代码,之后压缩完的代码可以组合成超长指令字的指令包,它是一个全局的代码调度过程,在产生关键路径的中间,保证相关性不被破坏。

路径调度比简单的流水线调度获得更高的并发性,在控制相关上有特点;通过代码的调度跨越非循环的分支指令来预测程序的分支特征。如果对分支指令的信息足够精确,就能够得到非常快的代码。

路径选择第一步需要选择可能正确的路径,认为为真的概率比较高。代码不管经过什么调整,保证结果正确。

路径压缩需要挑出一个指令序列,填满机器所需要的指令。做分支指令调度的时候,有分支指令做好代码补偿,在路径的出入口做好。其中一个关键假设是关键路径执行概率最高,否则做代码补偿就得不偿失。

代码的移动可能导致控制相关的局部特征发生变化,导致某些指令的控制相关性发生变化。

软件ILP策略:

  • 循环展开
  • 软件流水
  • 路径调度

编译器完成指令级并行的实现所需要的硬件支持,把精力集中到分支指令上。软件需要更多了解分支指令的特征,分支指令的特征并不好预测,它的执行是动态的,硬件可以提供一定的支持,例如前瞻性的执行,特别是对指令出故障的时候,对故障断定很精确,也提供一种机制,需要软件来执行这种机制,入条件指令,把if语句转成单条的指令,可以消除相关,把控制相关转成数据相关,在分支指令出现的代码段中也不出现调度问题了,只是数据操作问题,也没有相关问题。最后一种支持是前瞻的,一种是静态的在编译时就处理,设置抑制标志,不让前瞻性的过程扩散,如果扩散了则挂起,或者硬件提供寄存器,软件使用寄存器;动态调度是前边讲的算法。

条件指令是使用编译克服分支相关的一种方法,任何一条指令都带一个条件,如果条件是真的话就执行,是假的话就是一个空操作。这个过程内含了一个控制分支,完成后边数据的操作与前边相关,不再存在控制相关,而是转变成数据相关。第二个是把程序条件处理变成后端。

条件分支指令不允许产生意外,仍然占用运行时间,控制变量必须要预先产生,只有一个条件,只能做很简单的操作,最后是导致机器总体性能受影响。大部分机器支持条件传送指令。

指令作废:程序在执行时,条件应该尽早产生,以避免数据冒险等问题,否则应该作废。

条件指令的限制:

  • 作废的指令依然占用资源
  • 指令的控制变量应该尽早产生
  • 只能对简单的指令采用条件指令
  • 产生性能阻碍

前瞻操作的三种功能:

  • 数据流正确性
  • 例外正确处理
  • 访存的冲突应该被正确识别

执行前瞻的四种方法:

  • 硬件和OS对指令前瞻执行,对某些应用很难实现,需要OS作标志
  • 前瞻指令不允许产生意外,需要编译器在编译时确认哪条指令是前瞻指令
  • 一定范围的指令,如果是前瞻的话,打上指令,抑制影响范围
  • 后援机制,在执行的时候,把结果放到后援存储中,数据也不最终写回,直到前瞻被确认。

指令进行全局调度的时候,例外和相关性不能改变,如果前瞻出现错误且对机器状态产生影响了,那这个前瞻就不能被采纳。

例外大概两种,一种是终止性的,程序不能被继续执行,如访存的保护错误;一种是可恢复的,当例外发生后,机器的机制对其处理,处理完之后可以正常执行,比如缺页。

软硬件联合前瞻,由操作系统和硬件可以处理可恢复的故障。如果前瞻的指令导致了一次终止性的故障,那就返回一个没有定义的值,当OS因为前瞻的故障发现返回一个无意义的值,则认为这是一个不可恢复的故障,进行一系列处理。如果引起终止的指令不是前瞻的,且引起了终止性的意外,那就终止。

既然是前瞻的过程,那最后的时候被前瞻的指令可能会系统忽视掉,它引发的终止性意外也可能被忽视掉。正常情况下一个程序返回一个无定义的值,则会崩掉;但是前瞻执行的时候会加上一个标志,增加确认过程,不会遇到例外就终止。或者加上一个前缀,当加上前缀之后,说明这条指令是前瞻的。或者所有寄存器加上抑制标志,每一条指令都有一条附加标志,告诉系统是不是前瞻的,如果是前瞻的,当碰到例外的时候,这个例外并不是马上就处理,先放一会,一直放到前瞻指令被确认的时候再处理。

这个例外在执行的时候可能会影响到一大批寄存器,也可能带来例外,但是这里的例外只能在指令被确认的时候再处理,所以记录下已经影响到的寄存器,如果一个前瞻指令使用了抑制位被置为抑制的寄存器,即某个操作数被抑制,那这个指令的另一个操作数也会被抑制,这是抑制的传递关系。如果正常指令访问被抑制的寄存器,机器就出故障了。

抑制位带来问题:操作系统需要单独指令控制抑制位。

后援问题:在分支指令之间调整指令,把这条指令定义为前瞻,提供一个reorder buffer,指令执行结束之后进行确认,把结果送到寄存器中。

设置专用check指令,用它顶替要进行前瞻执行的load,这个load就可以到处挪,这个check和load是一对,check检查保存在手里的load的地址和之前的地址,看前边是不是有写后读地址。

多处理器和线程级并行

并行体系结构的分类:

  • single instruction stream, single data stream(SISD)
  • single instruction stream, multiple data stream(SIMD)
  • multiple instruction stream, single data stream(MISD)
  • multiple instruction stream, multiple data stream(MIMD)

多指令流多数据流处理机采用的通用的芯片,提供了一种灵活性,通过软件和硬件的支持,对操作者来讲等价于一个单用户的多处理机,通过多个芯片提供很高的加速比,由于有多个处理器,对n个处理器来说至少有n个线程才能发挥处理能力。

依据互联策略,现有的MIMD有两类,一个是集中共享存储器体系结构,对于处理器和存储器,采用共享特征,多个处理器采用共享的存储器,总线把处理器和存储器联系在一起。由于多个处理器共享存储器,在时间和优先权上是一样的,因此总线采用仲裁机制判断哪个处理器使用了存储器,表现出一种对称。通过对称的策略,把这种机器叫做对称多处理机(SMP),实现了均匀访问,又可以叫做UMA。

把多个存储器分布到节点上,多个节点互联形成机器,带来的系统规模和可扩展性比较好,每个处理器访问存储器的时候大部分时候访问节点上局部的存储器,只有有必要的时候才访问远程存储器,远程访问经过互联网络,有较大时延,每个节点都有处理器、存储器、IO、互连网络接口,形成一个大的存储空间。两个好处:扩大带宽规模,局部存储器的访问是整个系统的大部分,远程访问量小得多,充分利用节点上的带宽,延迟小。缺陷是处理器之间的通信通过互联网络完成,变得比较复杂且有比较大的延迟。

多个处理器之间通信的话,一个是共享地址空间进行通信,多个处理器访问同一个地址单元,假如物理存储器是分布的,则叫做distributed shared memory(DSM),也叫做NUMA,对存储器访问不一样。

对共享地址空间来说,地址空间是共享的,一个空间只有一个地址单元,访问所采用的指令就是直接操作。对于多个地址空间来说,一个逻辑地址可能指的是多个地址空间,实际上是信息传递的多处理器,通过显式的数据传输完成。

对信息传递来说,通过同步方式实现,首先要传送一个请求,才得到一个应答。从另外一种角度,先把数据写过去再发送通知,当处理器之间的通信比较清楚的时候这种方式更简洁,这是异步的方式,提高运行效率。

通讯结构的性能问题:三个影响性能的主要因素:

  • 通信带宽,有处理器、内存和互联机构影响
  • 通信延迟,包括发送开销(数据送上通信的端口)、飞行时间(第一个二进制位从发送端口到接收端口的时间)、传送时间(所有数据除以速率)、接收开销。
  • 通信延迟隐藏,假如是一个串行程序改成并行,在通信延迟期间做其他的事情,把通信延迟隐藏掉,这个隐藏过程是一个重叠过程,把通信延迟的影响降到最低。

算法决定了各个处理器之间的通信量,通常计算和通信之比随着处理器数量增加逐渐降低,处理器数量增加了,通信代价大了。处理的数据增大的时候,计算量增大,通信量也增大,二者之比增大。

小规模时,集中共享存储器是最简单的;围绕处理共享数据解决同步问题。

通过多级cache解决提高整体性能,私有数据一定要进cache,共享数据涉及多个处理器共享,在多个cache都有拷贝,需要解决cache相关性问题,着重从多个备份之间的关系。

如果系统是相关的,必须可以读出最近写入的正确数据,两个方面,相关性(能够读出来哪个值,指的是内容的问题,正确的还是错误的?)和一致性(什么时候能把写进去的值读出来,时间上的问题)。

满足以下条件一个存储系统才是相关的:

  • 一个处理器P对x进行写之后进行读,其他处理器不对x进行写操作,这时返回的数据是P写进去的。
  • 一个处理器对x进行读是在另一个处理器对x进行写之后,两次操作相隔时间很长且没有其他处理器对x进行写操作,读出来的值是另一个处理器写进去的值。
  • 对同一个单元的写必须是串行化的,同一单元被两个不同的处理器执行的两个
    写被所有处理器看上去都是相同的顺序。

相关性(coherence)定义了对同一单元的读和写问题,指内容上的问题,一致性(consistency)定义了读和写相对于其他存储单元的访问的行为问题,指时间上的问题。

读可以是乱序,但是写必须是按照程序规定顺序的。

相关性cache提供了迁移功能,是指数据项能够从远程移动到局部的cache中使用,为了减低延迟和带宽需求。复制指的是把当前的数据拷贝同时在多个cache中存在,也能从降低延迟方面获得好处。

cache相关性协议的关键问题是跟踪任何共享数据的状态,根据状态采取策略,有两类协议。首先是基于目录的方法,专门有一个物理存储器用于保留共享数据状态,查阅目录存储器就能找到共享数据的状态,适合分布式共享存储器结构。snooping(监听)使cache块中既存在数据,又包含了状态,状态是分布式存放的。

通过两种方式完成跟踪,维护相关性,为了保证处理器在对数据进行写之前,进入“专有”状态,把除了我要写的拷贝之外的所有其他拷贝都作废,称为“写作废”,当前要写的处理器对当前要写的数据进行专有访问。由于这是最简单的方法,广泛使用。可以用于监听或目录策略。专有状态保证没有其他的处理器可以读写,所有其他的cache拷贝都作废。通过仲裁把多个要写的请求进行仲裁,把其他的拷贝进行作废,其他的处理器重新读入一个拷贝,基于新写入的数据再写。强制所有的写操作串行化。

写广播(写更新)方式:当新的数据项写入一个拷贝的时候,要更新所有其他的拷贝,这里需要把所有的拷贝都更新。

对于同一个字的多次写,写广播代价大,每次写都要广播,对写作废来说只需要一次广播,这时已经进入独占状态,再次写的话不需要广播了。对于cache块中间的每一个字的写,在写更新协议中多次广播,在写作废中只有第一次需要写。不管是对一个字的多次写还是对多个字的写,写作废都只需要一次广播。

读和写一个数值之间,一个处理器写进入,另一个处理器要看到的话,写广播的延迟比较短,写作废需要重新调入数据块,更慢。

写广播占用带宽多,写作废的带宽需求比较小。

为了完成写作废,首先完成总线的访问,并且把共享数据地址送上总线,让其他的拷贝作废掉,其他处理机监听总线,检测到这个地址在其他处理机的cache内存在,则作废掉。对总线的访问串行化导致了对写的串行化。对共享数据的写要等到获得总线访问权之后。

对于写直达cache,写进数据之后存储器也要生效,如果其他的处理机获得最新值比较容易,从存储器里边取到最新值送到需要的处理器上。

写回cache,大部分最新的数据保存在cache中,存储器和cache不一样。监听总线上的地址,处理器发现总线上的地址和内部的一个cache一致,就把已经修改的数据提供给需要的存储器,因为现在数据已经被修改了,这里的是最新的。

通常的cache块都有自己的tag,标志当前的共享数据状态。有个无效位,说明是不是有效的。增加一个额外的共享状态位表示这个块是否是共享的,增加一个脏位看是否被修改过。

当只有一个拷贝时,这个处理器就是这个cache块的拥有者。cache地址和总线地址需要对比,地址的对比是串行的,cache只能满足一个的请求,所以监听控制器的操作会影响访问速度,如何提高对比速度,分成两份,一份由CPU对比,一份由总线对比;或者多级cache,CPU访问一级cache,总线访问其他cache,CPU和总线互不干扰,提高效率。

如果CPU操作了一级cache,总线可以操作其他cache。基于总线的相关性协议通过有限状态控制器实现的,控制器分布在每一个节点上,相应来自处理器和总线的不同请求,对两边的不同操作进行处理,并比较地址。对写命中和写失效都要出现总线事务,合并起来共同看作写失效的状态,减少要处理的事务。

最重要的是事务的处理是原子性的,操作必须是一气呵成不能中断的,全部过程不能有其他的插入。非原子性的事务可能会引入死锁风险。只要保证原子性的逻辑性,中间可以插入其他操作,只要不改变数据值,特别是写操作。为了充分利用并行性,且保证原子性,执行一些状态转移操作。

把状态变迁用状态转移图表示出来。

处理机之间的通信引起的失效叫相关性失效,可以分成两个部分,真共享失效、假共享失效,区别在于对同一块中间同一个字/不同一个字的共享,因为cache的共享是基于块的。

失效可分为三种:

  • 强制性失效:第一次读入这个块的时候一定会失效
  • 相关性失效:对相关数据处理引起的失效
  • 容量失效:cache容量不足导致替换引起的失效

cache增大失效率降低,块大的话失效率也降低。

在监听协议里,每一个cache的状态分布到每一个cache块中,这种情况对分布可变规模的存储器有影响,因为分布可变的存储器使用互连网络,再使用监听协议就不适合了。一种可能的替代方法是目录存储器,把所有存储器共享的状态存下来。

现有的目录协议将每个块设置一个目录项,目录项的数量是存储块和存储器数量的乘积。为了防止目录成为整个系统的瓶颈,把目录存储器分布到系统中间,每个节点增加一个目录存储器。

目录协议两种必须实现的操作:处理读失效,处理对于共享的干净的cache的写,处理对共享块的写失效是上述二者的结合。目录要跟踪cache块的状态。首先在共享状态下,一个或多个处理器都有拷贝,未缓冲状态没有处理器有这个cache块的拷贝,专有状态是只有一个处理器有这个cache块的拷贝,如果这时候写的话,存储器的拷贝就是旧的,写完之后处理器是这个块的拥有者。

考虑到要写,分布共享存储器结构中,更需要写作废的支持,因为通过互联网络进行广播的话代价更大。共享状态最简单的支持是位向量,当块被共享的时候,每一位都标志出这个处理机是否有这个块的拷贝。

使用互连网络无法使用仲裁功能,仲裁是总线特有的;互连网络是面向信息传递的,总线是面向事务的,因此互连网络必须采用发送确认的方法。

局部节点是请求产生的节点。home节点是请求的存储单元和目录项所在的节点。远程节点是有cache块拷贝的节点。物理地址空间是竞态分布的,存储器地址清楚的话,节点号也清楚了。例如,地址高位代表节点号,低位代表位移。

目录协议的实现:目录存储器中,cache状态是反应数据状态的真实状态的,为了解决相关性,存储器中每个数据项变化时都引起目录项的变化,所以分布共享存储器中目录的操作占了总操作数的一半。送到目录中的信息导致2个操作,首先是更新目录的状态,由共享进入专有等;然后发送相应的信息,以满足请求。存储块可能是未缓冲的,可能是有多个缓冲的,或者专有的。三个状态下,目录所执行的操作不同。

未缓冲时,即在数据块还在存储器中时,读失效的时候,请求的处理器要求存储器将数据送到处理器cache中,置为唯一共享节点,块置为共享状态。当写失效时,首先要送给请求方处理器这个数据块的值,把它在共享集合上置1,变成共享状态。

处于共享状态时:读失效时,请求处理器从存储器中收到数据,请求的处理器被添加到共享集合中,写失效时,请求的处理器要进行写,首先拿到数据,所有在共享集合中的处理器收到失效信息,只留下请求的这个处理器,共享集合仅包含请求的处理器。

在专有状态下,所有处理器中只有一个拷贝。读失效意味着这个数据块将进入共享状态,发送取数据信息到拥有这个块的处理器,导致了块的状态变成共享状态,拥有者把数据发送到存储器,在从存储器把数据发送到请求的处理器,把标识进行更新。数据写回执行把cache中的脏数据写回到存储器中,拥有者把块送回地址所在的节点,这个块成了未缓冲的。写失效意味着专有的写状态要转移到另一个处理器,这个块将有一个新的拥有者,首先要发送信息到老的拥有者,使得cache作废,然后把原来的数值送回到目录,在把数据送回到请求方,请求方拿到了数据,成为新的拥有者。

当块处在专有状态时,读写失效时,先把数据送回到目录,从目录再存储再送到请求的节点。为了提高效率,把数据直接送到请求节点,再送到存储器节点,这个操作可以把间接的变成直接的,但是在实现中增加了复杂性,同时使得死锁可能性增加了,在送给使用方的同时送给存储器。

基于目录的方法用空间换时间,减少了访问量但是增加了目录存储器,大小与系统规模N的平方成正比,为了改进,提出了有限映射和链式结构两种。

  • 有限映射假定在不同cache中的拷贝数量小于一个常数,可以通过比较少的位向量标识块,但是有m个的限制。
  • 链表结构不存在有限映射中m的限制。

数据分布引起整个系统中的带宽使用效率的不同。

同步

多个处理器在时间上协调一致。典型的同步机构时系统在硬件原语的支持下通过软件实现的例程。高竞争状态中同步起到一致协调的作用,也成为系统的瓶颈。

硬件原语是面对用户的一些汇编指令,与基本指令不同的是涉及硬件操作多。实现同步的最主要能力是使用一组硬件原语,自动读取修改共享数据。硬件原语是构成面向用户的同步操作的主要组成。对存储单元的读写需要多条语句,如果在这多条语句中插入其他的指令,可能会造成同步失败,使用原语可以降低出错减少时间。

典型的硬件原语操作是“自动交换”,把寄存器的数据和存储单元的数据交换,通过交换,把存储单元中的数值拿到。假设建立一个锁,0代表这个锁可用,1代表不可用,要实现最后读出来是0,表明得到了锁。

存储单元的读写通过仲裁,只能有一个首先完成,多个处理器竞争单元时不会产生冲突。

使用交换指令使用原语的根源在这个原语的操作是一气呵成的,没有其他间隔打断。读和写这两点在实现同步上是不可缺的,如果没有这两点构成一个同步原语,不能实现的。

test-and-set:首先读出来一个数据,测试是不是满足,如果满足则置入一个新数据。

fetch-and-increment:取出来单元数据,自动加一,然后再写进去。

使用一致性实现一个旋转锁,一个处理器不停的测试看是否能获得锁,直至成功,修改锁为占有状态。旋转的过程通常在用户希望这个锁持有的时间很短,低延迟使用时间短的时候适合旋转锁。

很多处理器竞争一个锁的延迟和复杂度不是线性增长的,几乎是二次方,也造成比较大的流量。串行化是锁开销大的最主要原因。竞争大的时候降低串行化,形成有序的通信。软件实现的方法:所有进程争抢这个锁但是只有一个进程能抢到,第一次获得锁失败的话第二次就要延迟一会再去试探。

或者排队锁方法,通过软件构造等待处理器队列,通过队列进行排序,通过顺序有序使用资源。

组合树方法:在软件上实现对大规模机器同步的方法,把大量的竞争化解为对多个小点的竞争,使用n元树结构,一般来说使用k表示树的扇入(fan-in)。在k元树的最底层开始同步,逐级向上,直至根节点。如16个节点的话,就是一个二层的树,之前的代价是16的平方,现在是两倍的4的平方。

总线上10个处理器,同时完成对锁的竞争,假设每个总线事务100个时钟周期,忽略读写占用时间,只计算在竞争锁的时候的代价,对10个处理器获得锁的时候要占用多少总线事务。当i个处理器在竞争时,有i个链取获得锁、i个条件写来尝试获得锁,1个写,一共2i+1个总线事务,要全部通过要进行累加,对n个处理器来说,一共n(n+1)个总线事务。在同步点上进行同步造成相当长的延迟,同时影响了总线的访问。

栅栏同步(barrier):强制所有进程等待,直到所有进程到达栅栏,再一起释放。这个过程通过两个旋转锁实现,一个是保护计数器,计算到达栅栏的进程数;一个是用于把所有进程卡在这,一旦所有进程都到了就释放。可能会出现进程组中的一个进程永远离不开barrier。

通过sense-reversing区分不同进程到达的barrier是否是同一次进入barrier,如果不是同一次的话可能会造成死锁。

硬件对大规模同步的支持:有些机器通过硬件实现了栅栏同步,类似组合树的方法。保存关于同步的处理器并对其排队,叫做排队锁,硬件上使用位阵列,把先后到达的每一个处理器进行排队,通常是与目录结构结合在一起。

排队锁:当对锁变量第一次失效的时候,这次失效被送同步控制器,如果锁被释放,直接从队列里返回下一个处理器,如果锁不可用则创建一个排队记录。当锁释放的时候,选择下一个处理器进入使用状态,把下一个处理器拿到队列前端。

区别是首次访问锁还是一直在锁里边,这样可以实现排队操作或者释放锁的操作;

另外一个原语是fetch-and-incement,自动取出一个变量并增加数值。这个指令会使barrier指令有改进的空间,因为它将取并增量两个操作结合在一起。现有的MPP机器都采用的是硬件barrier方法。

一致性问题:什么时候能看到被其他处理器更新过的数字,什么时间生效,通常使用共享变量通信。通过读出被写进去的数据来检测是否已经更新过。

两个代码段:

1
2
3
4
5
6
7
P1:
A = 0;
...
A = 1;

L1:
if (B==0) ...

1
2
3
4
5
6
7
P2:
B = 0;
...
B = 1;

L2:
if (A==0) ...

如果这个程序能同步正确执行的话,A和B都在cache里,如果写总是能马上生效,那两个if都绝不可能同时为真,因为如果到达两个if,A和B都被赋值为1了。假设写作废被延迟,处理机允许继续向前推进,可能出现P1和P2两个都没看到作废,这种情况就与预计的正确程序行为相违背。

最简单的一致性模型是顺序一致性,需要任何访存顺序一致的程序运行结果一致,它消除了含糊的执行方式,处理器延迟对任何存储器访问的过程,直到所有的写生效;同样的可以将下次访存延迟直到本次访存结束。

一致性涉及到不同变量和不同时间的问题,因此对两个变量的访问必须按照一定的顺序。

在上边的代码中,必须在写操作完成后在进行读A或B,在顺序一致性中也不能简单地把写放到缓存中而已就继续往下执行。

一个处理机对变量的写和另一个处理机对变量的访问通过一对同步操作进行排序,意味着同步操作把数据引用进行了排序,同步把顺序定下来,这就确定了一致性,两个处理器的操作通过同步确定了顺序。如果没有同步操作,变量在读写期间出现顺序不定的情况,称为数据竞争,因为此时对数据的访问基于处理器之间的相对速度,输出是不可预计的,结果正确性不能保证。

同步原语在实现上提供了较为宽松的顺序一致性,即便系统提供了较为宽松的一致性模型,一个同步的程序也会按照标准顺序一致性那样执行,这实际上提供了一种时间上的重叠,为并行的开发提供了条件。

松弛一致性模型的主要思想是允许读写无序进行,而是使用同步操作来强制实现有序,这样的话处理器的操作就像顺序一致性一样。依据松弛的情况分为3种主要的类型。

写后读:写完全生效之后才能进行读,这叫做全存序模型,或者处理器一致性,因为只保留了写的一致性,许多程序在这个模型下保持顺序一致性。旁路和写缓冲是两种主要方法。写缓冲中如果有要读的地址,就先在缓冲中拿到。

写后写:多次写的一个模型,部分存序,在流水线中,第一个写还没有完成的时候第二个写已经启动,两个写之间存在并行,因为两个写之间存在节拍的差距。

读后读/写:弱排序模型。

顺序松弛可以使处理器获得明显的性能提升,在实现上需要硬件的支撑。

线程级并行

多线程使多个线程共享处理机,处理机必须对每个线程的状态进行复制,便于进程切换,没有硬件支撑条件也谈不上多线程并行。比如,对线程所需要的文件来说,有寄存器文件,分开的PC等,提供对不同线程的切换能力,不同节拍可以切换到不同线程,线程切换也要比进程切换更快。

通常有两种方式实现多线程,一个是细粒度的,在指令之间就能完成线程的切换,使多线程的执行是交错的,切换经常采用时间片轮转的方式,优点是隐藏吞吐率上的损失,充分利用CPU的时间,如果线程执行IO时就可以先被切换先来,但是它的总执行时间被延长了。粗粒度的线程级并行在比较长的停顿出现时才进行切换,比如局部cache的失效,它依赖于程序执行的特点,缺点是受限于吞吐率,在有比较大的输入输出时才切换,没有办法充分利用短停顿。粗粒度多线程经常要填充流水线,产生一个起步时间,存在局限性,只有在停顿时间比较长的时候才有效。

SMT(simultaneous multithreading)是多线程的一种,使用处理器的多流出和动态调度能力,在指令级并行的同时实现线程级并行。SMT的基础是处理器有多个功能部件可以并行执行。寄存器换名和动态调度是为了支持不同线程的指令级并行。同时多线程是在多流出支持下,每拍流出的指令可以是来自多个线程的,第一拍是来自两个不同的线程,第二拍是来自另外的线程,以此类推,在线程和指令两个层面实现并行。

有多少个活跃的线程?有多少缓冲区?取指能力是否能满足流水线的需要?等都是SMT的问题,不能完全百分百的利用每一个流水槽。每个线程都要有自己的寄存器组、缓冲等。各个线程的指令要能够独立提交,结果要回到各个线程本身,这要求在逻辑上要提供每个线程的独立重排序缓冲区。

优先线程基于同时多线程,把执行的时间最小化,在多个线程并行的时候,只要有可能,首先流出的就是优先线程的指令,优先线程调度不出的空槽填充其他线程的指令。为最大化单个线程的性能,优先线程的取指、分支预测等应尽可能往前。如果有两个优先线程的话,两个线程就都要优先,两条指令流都要优先服务。

多线程每个都有寄存器,寄存器文件需要很大,保存多现场;保持系统低开销,优先线程需要优先取指;由SMT引起的cache冲突上需要良好处理,引起系统性能下降反而得不偿失。

交叉问题

许多多处理器使用多级cache较少对全局通信的需求,如果cache提供了多级包含特性,即近一级的cache一定是远一级cache的子集,一级cache中的内容一定是二级cache中的子集。

如果L2是L1容量的4倍,在L2中1个起始地址的块,在L1中是4个块。如果L1中的块大小是b,L2中的块大小是4b,则如果在L2中作废一个块x,需要作废以x,x+b,x+2b,x+3b为起始地址的小块(这在L2中被看作是一个块,在L1中被看作是4个块),在L1中同样要作废起始地址为x的一个块,但是没有作废起始地址为x+b的块(如果有的话),这就违背了包含原则。任何时候都要遵守包含特性。

非封锁cache和延迟隐藏:多处理机的失效处罚比较大,延迟也较大,这意味着有更大的延迟可以被隐藏,还有因为流水线失效的延迟可以被隐藏;cache使用非封锁cache支撑了弱一致性模型实现,弱一致性模型可以实现对访存的重排序,重复利用;非封锁cache对实现预取很有必要,利用尽可能空的时候实现存储的迁移,充分利用时间,实现多端口的并行访问。

非绑定是指一个cache的数值要根据其最新数值的变化而变化,不跟随某一个局部拷贝,对全局来说都是一致的。如果是预取到寄存器中,就是绑定的,因为如果数据进入了寄存器就是脱离了地址空间,跟存储地址空间的数据就没有关系了,存储器里的变化跟寄存器里没有关联了。非绑定是在硬件预取设计中不可获取的,只能在地址空间中实现预取。

有几个问题:局部节点需要对多个未完成的访问进行跟踪,跟踪预取地址;流出请求之前,节点必须保证在流出请求之前,对同一个块没有流出其他请求。

定义一个内存一致性模型的另一个原因是针对共享数据,确定合法的编译优化范围。最简单的来说是实现同步(硬件支撑下的对存储器访问的同步)。

通过虚拟存储器实现共享内存,不必从物理内存上考虑容量。共享数据如何从共享cache块移向更大的单元。通过OS进行调度页面。

Why new programming language

Taichi is a high-performance programming language for computer graphics applications. The goals are:

  • performance
  • productivity
  • spatially sparse computation,空间稀疏计算,CG中的提速需要
  • differentiable programming,可微编程,dl里的导数求解
  • meta programming

design decisions

  • 计算和数据结构的解耦
  • 领域特定编译器自动优化,广义编译器没有领域支持
  • megakernels,我的计算并不是表示成一个kernel中有一个很简单的操作
  • 自动微分中的两个尺度
  • 包到python中。

把python的AST通过TaiChi前端编译到TaiChi的AST,或者说执行一段代码,输出TaiChi的AST。得到前端AST后输入AST Lowering,所有中间变量只复制一次,对CPU做循环向量化等优化,对GPU则不用;之后在CPU上对内存访问优化。自动微分之后到LLVM。把数据结构的信息很显式的使用,因为很多优化如果不知道数据结构就很难优化。

High-Performance Spatially Sparse Computation

之前做类似模拟的时候需要开辟一个大的buffer,即使使用到的只是其中一小部分,这就是空间稀疏性。这里的空间稀疏性在局部比较稠密。

VDB用于处理类似的结构,类似文件系统的B-Tree,一个哈希表,底部是一些指针数组,第一层的指针数组有64个孩子,第二层的指针数组有16个孩子,降低访问延迟。

SPGrid使用了Virtral Memory中的TLB做了硬件的Hash Table。

使用稀疏的数据结构是很难的:

  • Boundary Conditions,边界条件对么
  • Maintaining,维持这个数据结构而存在的
  • 内存管理
  • 并行和负载均衡
  • 数据结构的开销
    • 可能会去查哈希表
    • 可能会使用指针,cache miss
    • 节点的分配,barrier
    • 分支预测,misprediction

底层的工程减少了数据结构的开销,但是降低了生产了,把算法和数据结构耦合在一起,让不同数据结构的使用产生了困难。稀疏数据结构
的开销比核心计算更大,cache miss更多,先过Hash Table,然后去某个数组查询。

TaiChi的方法:

Decouple computation from data structures

提供了命令式的编程语言,转换成中间表示,并做优化,然后有一套runtime system做内存管理。

如何描述数据结构?

  • dense:固定长度连续数组
  • hash:使用哈希表维护坐标映射
  • dynamic:预定义长度的数组,用来维护块中的粒子

Access Simplification

TaiChi是怎样针对数据结构优化使计算变快的。

access lowering,common subexpression elimination:把端到端的看起来稠密的访问(i到j),分解成比较小的指令并做优化,像传统编译器中的“表达式消除“。例如在AST中时,不需要每次都从root向leaf搜索,在子节点开始搜索,省略不必要的遍历和检查。

对AOS(array of structure)和SOA(structure of array),如果顺序访问的话SOA确实对cache很友好,后来发现AOS更好?数据上是这样的。

vectorized FEM Access Optimization

在做有限元运算时如何从内存中load一些element,比如,在对矩阵进行访问时,预先加载一个块中的数据,在访问时就可直接从块中进行查找,避免多次的访问。

为什么传统的编译器做不了这样的优化?

  • Index analysis,下标分析,利用某些数据结构信息使下标计算满足一些性质,就可以针对这些下标进行预取优化
  • 合适指令粒度,可以把一个访问表示成x[i, j],指令粒度大了存在大量优化空间;也可以把访问表示成更low level的代码指令,这样比较难分析。指令越来越细就越难分析,但是如果指令大,则隐藏潜在优化空间。
  • data access semantics
    • no pointer aliasing: a[x, y] and b[i, j] never overlaps if a != b,pointer alias是阻止编译器进行优化的东西,注意避免,传进参数的时候加上restrict告诉编译器两个指针从来不会overlap
    • all memory accesses are done through sparse_grid[indices]
    • the only way data structures get modified, is through write accesses of form sparse_grid[indices]
    • 读取操作不会修改任何变量。

differentiable programming on Taichi

可微编程,是在Taichi中的一个模块(Reverse Mode Autodiff),比deep learning更general。

ILP

指令级并行(ILP)是用于在同一CPU内核中同时执行多个指令的一组技术。
(请注意,ILP与多核无关。)
问题:CPU内核有很多电路,并且在任何给定时间,大多数都处于空闲状态,这很浪费。 解决方案:让CPU内核的不同部分同时执行不同的操作:如果CPU内核能够一次执行10次操作,则该程序原则上可以运行多达10次。 (尽管实际上并没有那么多)。

指令好像是必须按照程序顺序来执行,但是独立的指令可以同时执行,不会影响程序正确性。

超标量执行:处理器在指令序列中动态选择独立的指令并并行执行他们。

  • 超标量:同时执行多项运算(例如,同时执行加,乘和加运算)。
  • 流水线:开始对一个数据执行操作,同时对另一数据完成相同的操作-同时对不同的操作数集执行同一操作的不同阶段(如组装线)。
  • 超流水线:超标量和流水线的结合–同时执行多个流水线操作。
  • 向量:将多个数据加载到特殊寄存器中,并同时对所有这些数据执行相同的操作。使用SSE,AVX等,一条指令产生多个结果。

编译器优化

Copy Propagation复制传播

1
2
x = y
z = 1 + x

转换成:
1
2
x = y
z = 1 + y

消除数据依赖。

Constant Folding常量折叠

1
2
3
add = 100;
aug = 200;
sum = add + aug;

变为:
1
sum = 300;

注意,sum实际上是两个常量的和,因此编译器可以对其进行预先计算,从而消除了否则会在运行时执行的加法运算。

删除死代码

1
2
3
4
var = 5;
printf("%d", var);
exit(-1);
printf("%d", var * 2);

变为:
1
2
3
var = 5;
printf("%d", var);
exit(-1);

强度降低

1
2
x = pow(y, 2.0);
a = c / 2.0;

变为:
1
2
x = y * y;
a = c * 0.5;

计算一个值的乘方或进行除法要比乘法更昂贵。 如果编译器可以判断出幂是一个小整数,或者分母是一个常数,那么它将使用乘法。

常见子表达消除

1
2
d = c * (a / b);
e = (a / b) * 2.0;

变为:
1
2
3
adivb = a / b;
d = c * adivb;
e = adivb * 2.0;

子表达式(a / b)出现在两个赋值语句中,因此没有必要进行两次计算。通常只有在通用子表达式的计算成本很高的情况下,才值得这样做。

变量重命名

1
2
3
x = y * z;
q = r + x * 2;
x = a + b;

变为:
1
2
3
x0 = y * z;
q = r + x0 * 2;
x = a + b;

原始代码具有输出依赖性,而新代码则没有输出依赖性,但是x的最终值仍然正确。

循环优化

  • 循环内不变的代码称为循环不变式。 不需要一遍又一遍地计算。
  • 我们可以通过剥离特殊的迭代来消除IF
  • 分组迭代消除IF
  • 数组元素a[i][j]a[i][j+1]在内存中彼此靠近,而a[i+1][j]可能很远,因此使j循环为内循环。 (在Fortran中是相反的。)
  • 循环展开。上次我们看到,具有很多操作的循环可以获得更好的性能(在某种程度上),特别是在有很多算术操作但主存储器加载和存储很少的情况下。展开会创建多个操作,这些操作通常从相同或相邻的缓存行加载。 因此,展开的循环可以执行更多的操作,而不会增加太多的内存访问。同样,展开将减少循环计数器变量上比较的次数,并减少到循环顶部的分支数。

循环融合

1
2
3
4
5
6
7
8
9
for (i = 0; i < n; i++) {
a[i] = b[i] + 1;
}
for (i = 0; i < n; i++) {
c[i] = a[i] / 2;
}
for (i = 0; i < n; i++) {
d[i] = 1 / c[i];
}

变为:
1
2
3
4
5
for (i = 0; i < n; i++) {
a[i] = b[i] + 1;
c[i] = a[i] / 2;
d[i] = 1 / c[i];
}

与展开一样,这具有较少的分支。 它还具有较少的总内存引用。

从理论上讲,编译器和硬件可以“理解”所有这些内容,并可以优化您的程序;实际上,他们没有。

  • 他们不会知道与处理器更好的“匹配”的不同算法
  • 但是实际上编译器可能需要您的帮助- 选择其他编译器,优化标志等,其中包括控制单处理器优化的选项:超标量,流水线,矢量化,标量优化,循环优化,内联等。
  • 重新排列代码以使事情变得更明显
  • 使用特殊功能(“固有”)或编写汇编

高级优化:过程间优化 (IPO)

  • ip: 源程序文件内部的过程间优化
  • ipo: 多个源程序的过程间优化
  • 函数内联是ipo中最重要性能优化手段

为什么循环不向量化

  • 独立
  • 循环迭代通常必须独立
  • 一些相关的限定词:
  • 某些依赖循环可以向量化。
  • 大多数函数调用无法向量化。
  • 一些条件分支会阻止矢量化。
  • 循环必须是可数的。
  • 无法对嵌套的外循环进行矢量化处理。
  • 混合数据类型无法向量化

处理内存延迟的方法

  • 通过将值保存在小型快速内存(缓存)中并重新使用它们来消除内存操作
  • 在程序中需要时间局部性
  • 通过获取一块内存并将其保存在小型快速内存(高速缓存)中并使用整个内存块,来利用更好的带宽
  • 带宽改善快于延迟
  • 在程序中需要空间局部性
  • 通过允许处理器一次向存储系统发出多次读取来利用更好的带宽
  • 指令流中的并发,例如 像矢量处理器一样加载整个数组; 或预取
  • 重叠计算和内存操作
  • 预取

如果有两个以上的内存级别怎么办?

  • 需要最小化所有级别之间的沟通
    • 在L1和L2缓存,缓存和DRAM,DRAM和磁盘之间…
  • 算法需要找到合适的块大小
    • 机器相关
    • 需要在最里面的循环中“阻止” b x b矩阵乘法
      • 1级内存->3个嵌套循环(幼稚算法)
      • 2级内存->6个嵌套循环
      • 3级内存->9个嵌套循环…
  • 缓存遗忘算法提供了另一种选择
    • 将nxn矩阵乘法视为一组较小的问题
    • 最终,这些将适合缓存
    • 将最小化在每个内存级别之间移动的#个单词
    • “遗忘”级别的数量和大小

向量化通用准则

  • 优先考虑可计数的单入口和单出口“for”循环。它可以作为外部循环索引的功能,也可以作为嵌套循环中最里面的循环的函数。
  • 编写序列代码(避免使用诸如switch,goto或return语句之类的分支,大多数函数调用或不能视为掩码分配的“if”构造)。
  • 避免循环迭代之间的依赖关系,或者至少避免读后写依赖关系。
  • array首选使用数组表示法而不是指针,尤其是对于C语言。尽可能在数组下标中直接使用循环索引,而不是增加单独的计数器以用作数组地址。
  • 使用有效的内存访问,例如连续访问和对齐访问(16/32字节边界)。并最大程度地减少间接寻址
  • 首选结构阵列(struct of array, SoA)优于结构阵列(array of structure, AoS)
  • 尝试使用矢量化库,包括英特尔®MKL和英特尔®IPP

profiling
profiling意味着收集有关程序执行的数据。两种主要的性能分析是:

  • Subroutine profiling:插装
  • Hardware counters:统计

假设您有一个hot循环,您认为应该进行矢量化,但没有进行矢量化(如在Vtune,SDE或其他热循环工具中发现的那样)。首先使用适当的编译器选项尝试基本的自动矢量化。使用“ -vec-report2”或更高版本可获取有关循环是否正在向量化的调试信息。要为Intel Xeon E5-2680要求AVX自动矢量化,请使用编译器命令行选项“ -xAVX –O3”。要为Intel Xeon Phi本机可执行文件要求Xeon Phi自动矢量化,请使用编译器命令行选项“ -mmic –O3”。离线编译通常应该给您提供–mmic自动矢量化功能,但是如果需要,您可以使用主机编译器选项-offload-option,compiler,mic,“将任何其他选项传递给Xeon Phi编译器。
其他可尝试的内容包括:
1.尝试使用“#pragma vector”来禁用编译器的矢量化成本模型。引入此选项后,请始终检查性能。您需要知道的主要事情是向量化器使用启发式算法。根据定义,启发式方法并不总是正确的。
2.如果您知道没有真正的依赖关系可以阻止矢量化,请尝试使用“ #pragma ivdep”。引入此选项后,请始终检查正确性和可能的​​崩溃。
3.尝试“ #pragma simd”。如果以上两种方法都无法为您提供矢量化代码,请尝试使用此选项。引入此选项后,请始终检查性能,正确性和可能的​​崩溃。
不要忘记通过使用编译器–S选项检查汇编并交叉检查源代码行号来“检查工作”。如果您从未编写过汇编文件,那么“检查汇编”听起来可能是一项艰巨的任务。

向量化建议:

  • 首先找到您的热循环/热基本块(Vtune,SDE等)
  • 确保数组边界对齐(如果可能)
  • 确保您没有不良依赖关系(即算法可向量化)
  • 首先尝试自动矢量化,例如:–O3 –xAVX –vec-report2 –openmp
  • 分析为什么编译器无法进行矢量化,然后尝试:#pragma vector always
  • 然后尝试:#pragma ivdep
  • 使用:#pragma vector aligned,如果编译器未注意到您的数组已对齐。
  • 然后尝试:#pragma simd:如果它是可向量化的循环,通常将对其向量化。
    • 无论安全性如何,都强制进行矢量化:检查正确性并进行彻底测试
  • 在工作时定期查阅编译器的–S程序集列表
  • 您可能需要专门标记减少操作,例如
    • 如果您知道行程计数(循环计数),请使用#pragma loop_count帮助编译器。
    • 在循环周围测试#pragma unroll(N),以查看它是否有助于提高性能。

CP是基本3d形状匹配算法。 ICP基准相对而言一个简单的500行程序,该程序执行ICP算法的复杂度是O(N^2)。由于程序的简单性,我们不仅可以测量单精度和双精度结果,测量阵列结构(structure of array, SoA)和结构阵列(array of structure, AoS)的性能也非常容易。

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
#if defined(AOS)
#define AOSFLAG 1 // 1 for AoS
#else
#define AOSFLAG 0 // 0 for SoA (default)
#endif
#if defined(DOUBLEPREC)
#define FPPRECFLAG 2 // 2 for Double precision
#define FLOATINGPTPRECISION double
#else
#define FPPRECFLAG 1 // 1 for Single precision (default)
#define FLOATINGPTPRECISION float
#endif

// AoS
typedef struct Point3d
{
FLOATINGPTPRECISION x,y,z,t; // use of t padding is optional
} Point3d,*Point3dPtr;

#if AOSFLAG == 1
Point3dPtr org = NULL;
Point3dPtr tfm = NULL;
#endif
#if AOSFLAG == 0 // SoA (set of arrays here, structure of arrays normally)
FLOATINGPTPRECISION *orgx = NULL;
FLOATINGPTPRECISION *orgy = NULL;
FLOATINGPTPRECISION *orgz = NULL;
FLOATINGPTPRECISION *tfmx = NULL;
FLOATINGPTPRECISION *tfmy = NULL;
FLOATINGPTPRECISION *tfmz = NULL;
#endif


源代码如下,因此我们将列出一个简单的循环以显示程序中的代码类型。 点集的简单旋转和平移明确表示如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#if AOSFLAG == 1
#pragma omp parallel for shared(tfm,Rf,Tf,nxfmpts) private(x,y,z)
for(i=0;i<nxfmpts;i++)
{
x = tfm[i].x; y = tfm[i].y; z = tfm[i].z;
tfm[i].x = Rf[0][0]*x + Rf[0][1]*y + Rf[0][2]*z + Tf[0];
tfm[i].y = Rf[1][0]*x + Rf[1][1]*y + Rf[1][2]*z + Tf[1];
tfm[i].z = Rf[2][0]*x + Rf[2][1]*y + Rf[2][2]*z + Tf[2];
}
#endif
#if AOSFLAG == 0
#pragma omp parallel for shared(tfmx,tfmy,tfmz,Rf,Tf,nxfmpts) private(x,y,z)
for(i=0;i<nxfmpts;i++)
{
x = tfmx[i]; y = tfmy[i]; z = tfmz[i];
tfmx[i] = Rf[0][0]*x + Rf[0][1]*y + Rf[0][2]*z + Tf[0];
tfmy[i] = Rf[1][0]*x + Rf[1][1]*y + Rf[1][2]*z + Tf[1];
tfmz[i] = Rf[2][0]*x + Rf[2][1]*y + Rf[2][2]*z + Tf[2];
}
#endif

SoA is better than AoS on “Intel Xeon Phi and Intel Xeon_E5-2680 for both Single and Double Precision.
该代码的属性可帮助实现此应用程序的源代码,如下所示:

  • 几乎所有读和写都发生第1步访问。
  • 所有数组都是64字节对齐的,并且编译器知道它们是64字节对齐的。
  • 编译器成功地向量化了所有热循环。
  • 循环是高速缓存友好的,以减少内存访问。
  • 主循环中没有除法。
  • 循环非常简单。

MPI编程模型:全局地址空间

  • 程序由一组命名线程组成。
    • 通常在程序启动时固定
    • 本地和共享数据,如共享内存模型中一样
    • 但是,共享数据在本地进程中分区
    • 成本模型表明远程数据非常昂贵
  • 示例:UPC,Co-Array Fortran
  • 全局地址空间编程是消息传递和共享内存之间的中间点
    • 线程等全局地址空间(可编程性)
    • SPMD并行性,例如MPI(性能)
    • 本地/全局区别,即布局很重要(性能)

主流并行程序模型-数据并行

  • 单线程模式
    • 并行操作于聚合数据结构上,一般是数组
    • 隐式相互作用,不需要显式同步
    • 隐式数据分配
  • 缺点
    • 相对严格的计算结构要求,不是所有应用模式都适合这种模型
    • 在粗粒度的并行机上难于映射

现代机器中的“自动”并行性

  • 位级并行
    • 在浮点运算等中
  • 指令级并行性(ILP)
    • 每个时钟周期执行多个指令
  • 内存系统并行
    • 内存操作与计算重叠
  • OS并行性
    • 在商品SMP上并行运行多个作业

常见的并行开销有哪些?

  • 创建和销毁并行进程、线程的开销
    • 创建和销毁进程本身是高开销的工作
      • PowerPC 700MHz(每个周期 15ns 执行4flops; 创建一个进程1.4ms,可执行372,000flops)
    • 创建和销毁多个进程的开销在系统中随进程数增加
      • 启动万规模进程需要s级时间
  • 通信开销是并行开销的主要部分
    • 多机间的通信开销相对于计算很大
    • 通信模型参数会因很多因素不同而变化
      • 同时通信的进程数
      • 同时发送的消息数
      • 消息的大小
      • 网络的拓扑结构
      • 网络的拥挤程度
      • 不同的MPI实现
      • 群集消息通信算法
      • 之前发送的消息情况
  • 并行化过程中引入的空间和相应的时间开销
    • 多进程并行化过程中引入的空间和相应的时间开销
      • 消息缓冲区准备
      • 交叠数据的分配和使用

回顾编程模型1:共享内存

  • 程序是控制线程的集合。
  • 可以在某些语言中执行时动态创建
  • 每个线程都有一组私有变量,例如局部堆栈变量
  • 还有一组共享变量,例如静态变量,共享公共块,全局堆。
  • 线程通过读写共享来隐式通信变量。
  • 线程通过共享变量同步来协调。

几个线程库/系统

  • PTHREADS是POSIX标准
    • 相对较低的水平
    • 便携式但可能很慢; 相对较重
  • 用于应用程序级编程的OpenMP标准
    • 支持对共享内存进行科学编程

POSIX线程概述

  • POSIX:便携式操作系统接口
    • 与操作系统实用程序的接口
  • PThreads:POSIX线程接口
    • 系统调用以创建和同步线程
    • 在类似UNIX的OS平台上应该相对统一
  • PThread包含对以下几点的支持
    • 创建并行
    • 同步
    • 不明确支持通信,因为共享内存是隐式的;指向共享数据的指针被传递给线程

OpenMP的主要特点

  • 面向共享存储体系结构,特别是SMP系统
  • 显式并行方法
  • 基于fork-join的多线程执行模型,但同样可以开发SPMD(Single Program Multi-Data )类型的程序
  • 可以进行增量式并行开发( Incremental development ),支持条件编译( Conditional Compilation )和条件并行
  • 允许嵌套的并行性(nested Parallelism )和动态线程
    • 并不是在所有的编译器实现中支持

线程数目的讨论

  • 通常情况下线程组内线程数目由环境变量OMP_NUM_THREADS控制
  • 如果parallel语句有num_threads子句,或者用户调用了omp_set_num_threads函数,线程数目由它们给出,num_threads具有高优先级
  • 上述三种设置方法作用域分别为系统、并行块级以及程序级
  • 这里给出的线程数目可以大于系统中处理器个数,它是一个上限值
  • 系统实际产生的线程数目可能由于资源的限制而比上限值要小

并行结构:Work-sharing Construct(1) - loop

  • 为线程分配了一组独立的迭代
  • 线程必须在工作共享结构的末尾等待
1
2
3
4
#pragma omp parallel
#pragma omp for
for(i = 1, i < 13, i++)
c[i] = a[i] + b[i]

Work-sharing Construct(2): Parallel Sections
section中代码的独立部分可以同时执行。

1
2
3
4
5
6
7
8
9
#pragma omp parallel sections
{
#pragma omp section
phase1();
#pragma omp section
phase2();
#pragma omp section
phase3();
}

Work-sharing Construct(3): Single Construct
表示仅由一个线程执行的代码块

  • 选择第一个到达的线程
  • 隐式障碍
1
2
3
4
5
6
7
8
9
#pragma omp parallel
{
DoManyThings();
#pragma omp single
{
ExchangeBoundaries();
} // threads wait here for single
DoManyMoreThings();
}

分配迭代:schedule子句影响循环迭代如何映射到线程上

  • schedule(static [,chunk])
    • 线程大小为“块”的迭代块
    • 循环分配
    • 默认值= N / t
    • 对于Ni个迭代和Nt个线程,每个线程获得Ni/Nt个循环迭代的一个块:
  • schedule(dynamic[,chunk])
    • 线程获取“块”迭代
    • 完成迭代后,线程将请求下一组请求
    • 默认值= 1
    • 对于Ni个迭代和Nt个线程,每个线程都会获得k个循环迭代的固定大小的块,当特定线程完成其迭代块时,将为其分配新的块。因此,迭代与线程之间的关系是不确定的。
      • 优势:非常灵活
      • 缺点:高开销–关于哪个线程获取每个块的大量决策
  • schedule(guided [,chunk])
    • 动态计划以大块开始
    • 块的尺寸缩小; 不小于“块”
    • 默认值= 1
    • 对于Ni迭代和Nt线程,最初,每个线程都会获得k <Ni/Nt循环迭代的固定大小的块:
    • 每个线程完成其k次迭代的块之后,它将获得k / 2次迭代的块,然后是k / 4个,依此类推。当线程完成其先前的块时,将动态分配块。
      • 优于静态:可处理不平衡负载
      • 动态优势:更少的决策,因此更少的开销
  • schedule(runtime)
    • OMP_SCHEDULE

现在的消息传递系统多使用三种通信模式:

  • 同步的消息传递 (Synchronous Message Passing)
  • 阻塞的消息传递 (Blocking Message Passing)
  • 非阻塞的消息传递 (Nonblocking Message Passing)

非阻塞模式为计算和通信重叠带来机会,但本身也会带来一些额外开销:

  • 作为临时缓冲区用的内存空间
  • 分配缓冲区的操作
  • 将消息拷入和拷出临时缓冲区
  • 执行一个额外的检测和等待函数

消息缓冲(message buffer, 简称buffer), 在不同的消息传递使用场合有不同的含义. 下面给出一些例子:

  • 消息缓冲指的是由程序员定义的应用程序的存储区域, 用于存放消息的数据值.例如, 在Send(A, 16, Q, tag)中, 缓冲A是在用户应用程序中声明的变量.
  • 缓冲的起始地址在消息例程中被使用.
  • 消息缓冲也可以指由消息传递系统(而非用户)创建和管理的一些内存区, 它用于发送消息时暂存消息. 这种缓冲不在用户的应用程序中出现, 有时被称为(消息传递)统消息缓冲(或系统缓冲).
  • MPI允许第三种可能的定义. 用户可以划出一定大小的内存区, 作为出现在其应用中的任意消息的中间缓冲.

用在MPI中的通信模式(communication mode):

  • 同步的(synchronous):直到相应的接收已经启动,发送才返回
    • 阻塞的同步发送:发送缓冲区可用,发送完成
    • 非阻塞的同步发送:它的返回不意味着消息已经被发出! 它的实现不需要在接收端有附加的缓冲, 但需要在发送端有一个系统缓冲. 为了消除额外的消息拷贝, 应使用阻塞的同步发送
  • 缓冲的(buffered):缓冲的发送假定能得到一定大小的缓冲空间, 它必须事先由用户程序分配和管理。通过调用子例程MPI_Buffer_attch(buffer,size)来定义, 由它来分配大小为size的用户缓冲. 这个缓冲可以用MPI_Buffer_detach(*buffer, *size )来实现.无缓冲区时,返回错误.
  • 就绪的(ready):
    • 在肯定相应的接收已经开始才进行发送. 它不像在同步模式中那样需要等待. 这就允许在相同的情况下实际使用一个更有效的通信协议.使用较少,程序员要保证程序正确性
  • 标准的(standard):最常用的模式。发送可以是同步的或缓冲的(系统缓冲), 取决于实现,给予系统以灵活选择的机会;发送的返回意味着消息缓冲区可用

常见错误调试心得

  • 确保栈空间分配的有效性
    • ulimit –s unlimited (可以根据需要调整)
    • export KMP_STACKSIZE=16000000 (可以根据需要调整)
  • 确保串行程序的正确执行(OMP_NUM_THREADS=1)
  • 验证private变量使用的正确性
  • 逐项确保变量的使用了然于胸,特别是f90: SAVE, DATA, default(none), private(…), shared(…)
  • 利用增量级开发的特性进行代码二分查找
  • 确认是否由于舍入误差导致
  • 对于连加等可能由于计算次序导致不同计算结果的操作,不使用reduction子句,将加法部分放到串行区完成
  • 借助Intel Inspector,totalview等工具寻找数据竞争问题

处理器:多核时代

  • 想法1:使用增加的晶体管数量添加更多处理器核心,而不是使用晶体管来增加。先进的处理器逻辑加速单个指令流(例如,乱序和投机操作)
  • 想法2:添加ALU以提高计算能力。摊销跨多个ALU管理指令流的成本/复杂性。SIMD处理,一条指令,多个数据向所有ALU广播相同的指令在所有ALU上并行执行

指令流一致性(“一致性执行”)

  • 相同的指令序列适用于同时操作的所有元素
  • 要有效利用SIMD处理资源,必须执行一致的执行
  • 由于每个内核都具有获取/解码不同指令流的能力,因此对于内核之间的高效并行化而言,一致性执行不是必需的
  • “分散”执行
    • 缺乏指令流一致性

在现代CPU上执行SIMD

  • SSE指令:128位操作:4x32位或2x64位(4宽格式向量)
  • AVX2指令:256位操作:8x32位或4x64位(8宽格式向量)
  • AVX512指令:512位操作:16x32位…
  • 指令由编译器生成
    • 程序员使用内在函数明确要求的并行性
    • 使用并行语言语义传达的并行性(例如,forall示例)
    • 通过对循环的依赖性分析推断出并行性(困难的问题,即使是最好的编译器也不能在任意C / C ++代码上使用)
  • 术语:“显式SIMD”:SIMD并行化在编译时执行

在许多现代GPU上执行SIMD

  • “隐含SIMD”
    • 编译器生成标量二进制(标量指令)
    • 但是程序的N个实例始终在处理器上一起运行execute(my_function,N),执行my_function N次
  • 换句话说,硬件本身的接口是数据并行的
  • 硬件(不是编译器)负责同时从多个实例对SIMD ALU上的不同数据执行同一条指令
  • 大多数现代GPU的SIMD宽度为8到32
  • 分歧可能是个大问题(写得不好的代码可能以机器峰值能力的1/32执行!)

摘要:并行执行

  • 现代处理器中的几种并行执行形式
    • 多核:使用多个处理核
      • 提供线程级并行性:在每个内核上同时执行完全不同的指令流
      • 软件决定何时创建线程(例如,通过pthreads API)
    • SIMD:使用同一指令流(在内核内)控制的多个ALU
      • 高效的数据并行工作负载设计:控制可摊销许多ALU
      • 矢量化可以由编译器(显式SIMD)完成,也可以在运行时由硬件完成
      • [缺乏]依赖关系在执行之前就已经知道(通常由程序员声明,但可以通过高级编译器的循环分析来推断)
    • 超标量:在指令流中利用ILP。 处理来自相同的指令流并行(在内核内)
      • 硬件在执行过程中自动动态发现并行性(程序员看不到)

多线程减少了停顿

  • 想法:对同一核心上的多个线程进行交错处理以隐藏停顿
  • 与预取一样,多线程隐藏了延迟,而不是减少延迟的技术

硬件支持的多线程

  • Core管理多个线程的执行上下文
    • 从可运行线程运行指令(处理器决定每个时钟运行哪个线程,而不是操作系统的运行)
    • 核心仍然具有相同数量的ALU资源:多线程仅在面对诸如内存访问之类的高延迟操作时才有助于更有效地使用它们
  • 交错多线程(也称为时间多线程)
    • 每个时钟,内核都会选择一个线程,并在ALU上运行来自该线程的指令(交织多线程)
  • 同时多线程(SMT,同时多线程)
    • 每个时钟,内核从多个线程中选择指令以在ALU上运行
    • 扩展超标量CPU设计
    • 示例:英特尔超线程(每个内核2个线程)

多线程摘要

  • 优势:更有效地利用核心的ALU资源
    • 隐藏内存延迟
    • 填充超标量架构的多个功能单元(当一个线程的ILP不足时)
  • 劣势
    • 需要额外存储线程上下文
    • 增加任何单线程的运行时间(通常不是问题,我们通常关心并行应用程序中的吞吐量)
    • 需要程序中的其他独立工作(比ALU更加独立!)
    • 严重依赖内存带宽
    • 更多线程→更大的工作集→每个线程更少的缓存空间
    • 可能会更频繁地进入内存,但可以隐藏延迟

带宽是至关重要的资源。高性能并行程序将:

  • 组织计算以减少从内存中获取数据的频率
    • 重用先前由同一线程加载的数据(传统的线程内时间局部性优化)
    • 跨线程共享数据(线程间协作)
  • 减少请求数据的频率(取而代之的是做更多的算术:“免费”)
    • 有用的术语:“算术强度” —指令流中数学运算与数据访问运算的比率
    • 要点:程序必须具有很高的算术强度才能有效利用现代处理器

使用C++11让程序更简洁

类型推导

引入auto和decltype。

auto

1
2
3
4
auto i = 10; // i是int
auto pu = new auto(1); // pu是int*
const auto *v = &i, u = 6; // v是const int*,u是const int
auto s; //错误,无法推断

初始化不能使编译器推导产生二义性,如把u写成u=6.0则不予通过。
使用auto声明的变量必须马上初始化,以让编译器推断出类型并在编译时将auto替换为真正的类型。

1
2
3
4
5
6
7
8
9
10
int x = 0;  //
auto * a = &x; // a是int*,auto被推导为int
auto b = &x; // b是int*,auto被推导为int*
auto & c = x; // c是int&,auto被推导为int
auto d = c; // d是int,auto被推导为int

const auto e = x; // e是const int
auto f = e; // f是int
cosnt auto & g = x; // g是const int&
auto & h = g; // h是const int&

a和c的类型推导结果很明显,auto在编译时被替换为int,b的推导结果表明auto不声明为指针,也可以推导出指针类型;d的推导结果表明当表达式是一个引用类型时,auto会把引用类型抛弃,直接推导成原始类型int。f的推导结果表明表达式带有const时,auto会把const属性抛弃掉,推导成non-const的int。规则如下:

  • 当不声明为指针或引用时,auto的推导结果和初始化表达式抛弃引用和cv限定符(const和volatile)后类型一致
  • 当声明为指针或引用时,auto的推导结果将保留初始化表达式的cv属性。
  • auto不能用为函数参数。
  • auto无法定义数组!

auto的推导和函数模板参数的自动推导有相似之处。

1
2
3
4
5
6
7
8
template <typename T> void func(T x) {} // T -> auto
template <typename T> void func(T *x){} // T -> auto*
template <typename T> void func(T& x){} // T&-> auto&

template <typename T> void func(const T x) {} // const T -> const auto
template <typename T> void func(const T* x){} // const T*-> const auto *
template <typename T> void func(const T& x){} // const T&-> const auto &
注意:auto是不能用于函数参数的。

何时使用auto?看一个例子,在一个unordered_multimap中查找一个范围,代码如下

1
2
3
4
5
6
#include <map>
int main()
{
std::unordered_multimap<int, int> resultMap;
std::pair<std::unordered_multimap<int, int>::iterator, std::unordered_multimap<int, int>::iterator> range = resultMap.equal_range(key);
}

这个 equal_ange返回的类型声明显得烦琐而冗长,而且实际上并不关心这里的具体类型(大概知道是一个std::pair就够了)。这时,通过auto就能极大的简化书写,省去推导具体类型的过程
1
2
3
4
5
6
7
#include <map>
int main()
{
std::unordered_multimap<int, int> map;
auto range_map.equal_range(key);
return 0;
}

decltype

auto所修饰的变量必须被初始化,C++11新增了decltype关键字,用来在编译时推导出一个表达式的类型。decltype(exp),exp是一个表达式。

1
2
3
4
5
6
7
8
9
10
int x = 0;
decltype(x) y = 1; // y -> int
decltype(x + y) z = 0; // z -> int

const int& i = x;
decltype(i) j = y; // j -> const int &

const decltype(z) * p = &z; // *p -> const int, p -> const int *
decltype(z) * pi = &z; // * pi -> int, pi -> int*
decltype(pi) * pp = &pi; // *pp -> int *, pp -> int **

y和z的结果表明,decltype可以根据表达式直接推导出它的类型本身。这个功能和上节的auto很像,但又有所不同。auto只能根据变量的初始化表达式推导出变量应该具有的类型。若想要通过某个表达式得到类型,但不希望新变量和这个表达式具有同样的值,此时auto就显得不适用了。
j的结果表明decltype通过表达式得到的类型,可以保留住表达式的引用及const限定符。实际上,对于一般的标记符表达式(id-expression),decltype将精确地推导出表达式定义本身的类型,不会像auto那样在某些情况下舍弃掉引用和cv限定符。p、pi的结果表明decltype可以像auto一样,加上引用和指针,以及cv限定符。

pp的推导则表明,当表达式是一个指针的时候,decltype仍然推导出表达式的实际类型(指针类型),之后结合pp定义时的指针标记,得到的pp是一个二维指针类型。这也是和auto推导不同的一点。

推导规则:

  • exp是标识符、类访问表达式,decltype(type)和exp的类型一致;
  • exp是函数调用,decltype(type)和函数返回值类型一致;
  • 若exp是一个左值,则decltype(type)是exp类型的左值引用,否则和exp类型一致。
1
2
3
4
5
struct Foo {int x;};
const Foo foo = Foo();

decltype(foo.x) a = 0; // a -> int
decltype((foo.x)) b = a; // b -> const int &

a的类型就是foo.x的类型,foo.x是一个左值,可知括号表达式也是一个左值,decltype的类型是一个左值引用。

在泛型编程中,可能需要通过参数运算获得返回值类型:

1
2
3
4
5
6
7
8
template <typename R, typename T, typename U>
R add(T t, U u) {
return t+u;
}

int a = 1;
float b = 2.0;
auto c = add<decltype(a+b)>(a, b);

改成:
1
2
3
4
template <typename T, typename U>
decltype(T()+U()) add(T t, U u) {
return t+u;
}

考虑到T、U可能是没有无参构造函数的类,可以如下:
1
2
3
4
template <typename T, typename U>
decltype((*(T*)0) + (*(U*)0)) add(T t, U u) {
return t+u;
}

返回类型后置语法通过auto和decltype结合使用,可以写成:

1
2
3
4
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t+u;
}

返回类型后置语法解决了返回值类型依赖于参数而导致难以确定返回值类型的问题。
1
2
3
4
5
6
7
int& foo(int& i);
float foo(float& f);

template <typename T>
auto func(T& val) -> decltype(foo(val)) {
return foo(val);
}

模板的细节改进

模板的右尖括号

尽可能将多个右尖括号解析成模板参数结束符。

模板的别名

重定义一个模板

1
2
3
4
template <typename Val>
using str_map_t = std::map<std::string, Val>;

str_map_t<int> map1;

使用新的using别名语法定义了std::map的模板别名str_map_t。
实际上,using的别名语法覆盖了typedef的全部功能,两种使用方法等效。
1
2
3
4
5
typedef unsigned int uint_t;
using uint_t = unsigned int;

typedef std::map<std::string, int> map_int_t;
using map_int_t = std::map<std::string, int>;

using定义模板别名:

1
2
3
template <typename T>
using func_t = void(*)(T, T);
func_t<int> xx_2;

函数模板的默认模板参数

1
2
template <typename T = int>
void func(void) { ... }

当所有模板参数都有默认参数时,函数模板的调用如同一个普通参数,对于类模板而言,哪怕所有参数都有默认参数,在使用时也要在模板名后跟一个“<>”实例化。

1
2
3
4
5
6
7
8
9
template <typename R = int, typename U>
R func(U val) {
val
}

int main() {
func(123);
return 0;
}

在调用函数模板时,若显式指定模板参数,由于参数填充顺序是从左往右的,因此,像下面这个调用,func<long>(123),func的返回值是long,而不是int。

列表初始化

在C++98/03中的对象初始化方法有多种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int i_arr[3] = {1, 2, 3};

struct A {
int x;
struct B {
int i;
int j;
} b;
} a = { 1, {2, 3} };

int i = 0;
class Foo {
public:
Foo(int) {}
} foo = 123;

C++11中提出了列表初始化的概念。

1
2
3
4
5
Foo a3 = {123};
Foo a4 {123};

int a5 = {3};
int s6 {3};

a3虽然使用了等于号,但是仍然是列表初始化,因此,私有的拷贝构造不会影响到它。
a4和a6的写法是C++98/03不具备的,可以直接在变量名后跟上初始化列表,来进行对象的初始化。

new操作符等可以用圆括号初始化的地方可以使用初始化列表:

1
2
3
int* a = new int {123};
double b = double {123};
int* arr = new int[3] {1, 2, 3};

聚合类型:

  • 类型是一个普通数组
  • 类型是一个类,且
    • 无用户定义的构造函数
    • 无私有或保护的非静态数据成员
    • 无基类
    • 无虚函数
    • 不能有{}和=直接初始化的非静态数据成员

对数组而言,只要该类型是一个普通数组,哪怕数组的元素并非聚合类型,这个数组本身也是一个聚合类型:

1
2
3
4
5
6
int x[] = {1, 3, 5};
float y[4][3] = {
{1, 3, 5},
{2, 4, 6},
{3, 5, 7}
}

当类型是一个类时,首先是存在用户自定义构造函数时,

1
2
3
4
5
6
7
struct Foo {
int x;
double y;
int z;
Foo(int, int) {}
};
Foo foo{1, 2.5, 1}; // ERROR!

这时无法将Foo看成一个聚合类型,必须以自定义构造函数构造对象。
如果受保护(protected)成员是一个static的,则可以不放在初始化列表里。
如果类定义里的成员变量已经有了赋值,则不可以使用初始化列表。

上述不可使用初始化列表的情况可以通过自定义构造函数实现使用初始化列表

初始化列表

任意长度初始化列表

C++11中的stl容器拥有和未显示指定长度的数组一样的初始化能力:

1
2
3
4
int arr[] = {1, 2, 3};
std::map<std::string, int> mm = { {"1", 1}, {"2", 2}, {"3", 3} };
std::set<int> ss = {1, 2, 3};
std::vector<int> arr = {1, 2, 3, 4, 5};

这里arr未显式指定长度,因此它的初始化列表可以是任意长度。
实际上stl中的容器是通过使用std::initializer_list这个类模板完成上述功能的,如果在类Foo中添加一个std::initializer_list构造函数,它也将拥有这种能力。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Foo {
std::vector<int> content;
public:
Foo(std::initializer_list<int> list) {
for(auto it = list.begin(); it != list.end(); it ++){
content.push_back(*it);
}
}
}

class Foo1 {
std::map<int, int> content;
using pair_t = std::map<int, int>::value_type;
public:
Foo1(std::initializer_list<int> list) {
for(auto it = list.begin(); it != list.end(); it ++){
content.insert(*it);
}
}
}

Foo foo = {1, 2, 3, 4, 5, 6};
Foo1 foo1 = { {1, 2}, {2, 3}, {3, 4} };

用来传递同类型的数据集合:
1
2
3
4
5
void func(std::initializer_list<int> list) {
for(auto it = list.begin(); it != list.end(); it ++){
std::cout << *it << std::endl;
}
}

std::initializer_list的一些特点:

  • 轻量级容器类型,内部定义了iterator等;
  • 对于std::initializer_list<T>,可以接受任意长度的初始化列表,但要求必须时同种类型;
  • 有三个成员接口:size()begin()end()
  • 只能被整体初始化或赋值。
  • 只能通过begin和end循环遍历,遍历时取得的迭代器是只读的,因此无法修改其中一个值。
  • 实际上,std::initializer_list非常高效,内部并不负责保存初始化列表中元素的拷贝,而是只储存列表中元素的引用而已。

防止类型收窄

类型收窄指导致数据内容发生变化或精度损失的隐式类型转换,包含以下几种:

  • 从浮点数隐式转换为整型;
  • 从高精度浮点数转换为低精度浮点数,如从long double转换为double或float;
  • 从整型数隐式转换为浮点数,并超过了浮点数表示范围;
  • 从整型数隐式转换为长度较短的整型数。

初始化列表不会允许类型收窄的转换发生。

基于范围的for循环

for循环的新用法

<algorithm>中有for_each可以用于遍历:

1
2
3
4
5
6
7
8
9
10
11
12
#include <algorithm>
#include <iostream>
#include <vector>

void do_cout(int n) {
std::cout << n << std::endl;
}
int main() {
std::vector<int> arr;
std::for_each(arr.begin(), arr.end(), do_cout);
return 0;
}

可以改成:
1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <vector>

int main() {
std::vector<int> arr;
for(auto n : arr) {
std::cout << n << std::endl;
}
return 0;
}

n表示arr中的一个个元素,auto则是让编译器自动推导n的类型,在这里n的类型被自动推导为vector中的元素类型int。
基于范围的for循环对于冒号前边的局部变量声明只要求能支持容器类型的隐式转换。
如果需要在遍历时修改容器中的值,则需要使用引用:
1
2
3
for(auto& n : arr) {
std::cout << n++ << std::endl;
}

基于范围的for循环的细节

auto自动推导出的类型是容器中的value_type,而不是迭代器:

1
2
3
4
5
std::map<std::string, int> mm = { {"1", 1}, {"2", 2}, {"3", 3} };
for(auto ite = mm.begin(); ite != mm.end(); ite ++)
std::cout << ite->first << "->" << ite->second << std::endl;
for(auto& val : mm)
std::cout << ite.first << "->" << ite.second << std::endl;

从这里就可以看出,在基于范围的for循环中每次迭代时使用的类型和普通for循环有何不同。

对基于范围的for循环而言,冒号后边的表达式只会被执行一次

基于范围的for循环倾向于在循环开始之前确定好迭代的范围,而不是在每次迭代之前都调用一次arr.end()

让基于范围的for循环支持自定义类型

基于范围的for循环将以以下方式查找容器的begin和end:

  • 若容器是一个普通的array对象,那么begin将为array的首地址;
  • 若容器是一个类对象,那么range-based for将试图通过查找类的begin()和end()方法来定位begin和end迭代器;
  • 否则range-based for将试图使用全局的begin和end函数定位begin和end;

对于自定义类型来说,实现begin和end方法即可,通过定义一个range对象看看具体的实现方法。
首先需要一个迭代器实现范围取值:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class iterator {
public:
using value_type = T;
using size_type = size_t;

iterator(size_type cur_start, value_type begin_val, value_type step_val);
value_type operator*() const;
bool operator!=(const iterator& rhs);
iterator& operator++(void);
}

构造函数传递三个参数初始化,分别是开始的迭代次数,初始值和迭代步长。operator*用于取得迭代器中的值;operator!=用于和另一个迭代器比较;operator++用于对迭代器做正向迭代。

迭代器类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T>
class iterator {
private:
size_type cursor_;
const value_type step_;
value_type value_;
public:
using value_type = T;
using size_type = size_t;

iterator(size_type cur_start, value_type begin_val, value_type step_val):
cursor_(cur_start), step_(step_val), value_(begin_val) {
value_ += (step_ * cursor_);
}

value_type operator*() const { return value_; }
bool operator!=(const iterator& rhs) const { return (cursor_ != rhs.cursor_); }
iterator& operator++(void) {
value_ += step_;
++ cursor_;
return (*this);
}
}

std::function和bind绑定器

可调用对象

可调用对象有如下几种定义:

  • 是一个函数指针
  • 是一个具有operator()成员函数的类对象
  • 是一个可被转换为函数指针的类对象
  • 是一个类成员指针

应用如下:

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
void func(void) { ... }

struct Foo {
void operator()(void) { ... }
};

struct Bar {
using fr_t = void(*)(void);

static void func(void) { ... }
operator fr_t(void) { return func; }
};

struct A {
int a_;
void mem_func(void) { ... }
};

int main(){
void(* func_ptr)(void) = &func; // 1.函数指针
func_ptr();

Foo foo;
foo(); // 2. 仿函数

Bar bar;
bar(); // 3. 可被转换为函数指针的类对象

void (A::*mem_func_ptr)(void) = &A::mem_func; // 4. 类成员函数指针
int A::*mem_obj_ptr = &A::a_; // 或是类成员指针

A aa;
(aa.*mem_func_ptr)();
aa.*mem_obj_ptr = 123;

return 0;
}

可调用对象包装器-std::function

std::function是可调用对象包装器。它是一个类模板,可以容纳除了类成员指针之外的所有可调用对象。

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>
#include <functional>

void func(void) {
std::cout << __FUNCTION__ << std::endl;
}

class Foo {
public:
static int foo_func(int a) {
std::cout << __FUNCTION__ << "(" << a << ") ->: ";
return a;
}
};

class Bar {
public:
int operator()(int a) {
std::cout << __FUNCTION__ << "(" << a << ") ->: ";
return a;
}
};

int main(){
std::function<void(void)> fr1 = func;
fr1();

std::function<int(int)> fr2 = Foo::foo_func;
std::cout << fr2(123) << std::endl;

Bar bar;
fr2 = bar;
std::cout << fr2(123) << std::endl;
}


结果是:
1
2
3
func
foo_func(123) ->: 123
operator()(123) ->: 123

给std::function填入合适的函数名,它就变成一个可以容纳所有这一类调用方式的函数包装器。

std::function作为函数入参:void call(int x, std::function<void(int)>& f)

std::bind绑定器

std::bind绑定器用来将可调用对象与其参数一起进行绑定,绑定后的结果使用std::function保存,并延迟调用到任何我们需要的时候,用途为:

  • 将可调用对象与其参数一起绑定成为一个仿函数;
  • 将多元可调用对象转成一元或者(n-1)元可调用对象,即只绑定部分参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
auto fr = std::bind(output, std::placeholders::_l);
for( int i = 0; i < 10; i ++) {
call_when_even(i, fr);
}
std::cout << std::endl;

auto fr2 = std::bind(output_2, std::placeholders::_l);
for( int i = 0; i < 10; i ++) {
call_when_even(i, fr);
}
std::cout << std::endl;
return 0;
}

在这里,我们使用了std::bind,在函数外部通过绑定不同的函数,控制了最后的执行结果。我们使用auto fr保存std::bind的返回结果,是因为我们并不关心std::bind真正的返回类型(实际上std::bind的返回类型是一个stl内部定义的仿函数类型),只需要知道它是一个仿函数,可以直接赋值给一个std::function。当然,这里直接使用std::function类型来保存std::bind的返回值也是可以的。

std::placeholders::_1是一个占位符,代表这个位置将在函数调用时,被传入的第一个参数所替代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <functional>

void output(int x, int y) {
std::cout << x << " " << y << std::endl;
}

int main() {
std::bind(output, 1, 2)();
std::bind(output, std::placeholders::_1, 2)(1);
std::bind(output, 2, std::placeholders::_1)(1);
std::bind(output, 2, std::placeholders::_2)(1);
std::bind(output, 2, std::placeholders::_2)(1, 2);
std::bind(output, std::placeholders::_1, std::placeholders::_2)(1, 2);
std::bind(output, std::placeholders::_2, std::placeholders::_1)(1, 2);
return 0;
}

lambda表达式

lambda表达式有如下优点:

  • 声明式编程风格:就地匿名定义目标函数或函数对象,不需要额外写一个命名函数或者函数对象。以更直接的方式去写程序,有更好的可读性和可维护性。
  • 简洁:不需要额外再写一个函数或者函数对象,避免了代码膨胀和功能分散,让开发者更加集中精力在手边的问题,同时也获取了更高的生产率。
  • 在需要的时间和地点实现功能闭包,使程序更灵活。

lambda表达式的概念和基本用法

lambda表达式定义了一个匿名函数,并且可以捕获一定范围内的变量。 lambda表达式的语法形式可简单归纳如下:

1
[capture] (params) opt -> ret { body; }

其中:capture是捕获列表; params是参数表;opt是函数选项;ret是返回值类型;body是函数体。一个完整的lambda表达式看起来像这样:
1
2
auto f = [](int a)-> int { return a + 1; };
std::cout << f(1) << std::endl;

输出:2。可以看到,上面通过一行代码定义了一个小小的功能闭包,用来将输入加1并返回。

在C++11中,lambda表达式的返回值是通过前面介绍的返回值后置语法来定义的。其实很多时候,lambda表达式的返回值是非常明显的,比如上例。因此,C++中允许省略lambda表达式的返回值定义:

1
auto f= [] (int a) return a + 1; };

这样编译器就会根据 return语句自动推导出返回值类型。

另外,lambda表达式在没有参数列表时,参数列表是可以省略的。因此像下面的写法都是正确的:

1
2
3
auto fl =[](){ return 1; };
auto f2 = []{ return 1; };
//省略空参数表

lambda表达式可以通过捕获列表捕获一定范围内的变量:

  • []不捕获任何变量。
  • [&]捕获外部作用域中所有变量,并作为引用在函数体中使用(按引用捕获)。
  • [=]捕获外部作用域中所有变量,并作为副本在函数体中使用(按值捕获)。
  • [=, &foo]按值捕获外部作用域中所有变量,并按引用捕获foo变量。
  • [bar]按值捕获bar变量,同时不捕获其他变量。
  • [this]捕获当前类中的this指针,让lambda表达式拥有和当前类成员函数同样的访问权限。如果已经使用了&或=,就默认添加此选项。捕获this的目的是可以在lamda中使用当前类的成员函数和成员变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A {
public:
int i_ = 0;

void func(int x, int y) {
auto x1 = []{ return i_; }; // error,没有捕获外部变量
auto x2 = [=]{ return i_ + x + y; }; // ok,捕获所有外部变量
auto x3 = [&]{ return i_ + x + y; }; // ok,捕获所有外部变量
auto x4 = [this]{ return i_; }; // ok,捕获this指针
auto x5 = [this]{ return i_ + x + y; }; // error,没有捕获x、y
auto x6 = [this, x, y]{ return i_ + x + y; }; // ok,捕获this指针、x、y
auto x7 = [this]{ return i_ ++; }; // ok,捕获this指针,并修改成员变量。
}
}

int a =0, b=1;
auto fl = [] { return a; }; // error,没有捕获外部变量
auto f2 = [&]{ return a++; }; // OK,捕获所有外部变量,并对a执行自加运算
auto f3 = [=]{ return a;}; //OK,捕获所有外部变量,并返回a
auto f4 = [=]{ return a++;}; //error,a是以复制方式捕获的,无法修改
auto f5 = [a]{ return a + b;}; //error,没有捕获变量b
auto f6=[a,&b]{ return a+(b++);}; //OK,捕获a和b的引用,并对b做自加运算
auto f7=[=,&b]{ return a+(b++);}; //OK,捕获所有外部变量和b的引用,并对b做自加运算

一个容易出错的细节是,

1
2
3
4
5
6
int a = 0;
auto f = [=] {return a;};

a += 1;

std::cout << f() << std::endl;

在这个例子中,lambda表达式按值捕获了所有外部变量,在捕获的一瞬间a的值就被复制到f中了,之后a被修改,但此时f中存储的a的值仍然是捕获时候的值,因此,最终输出结果是0。希望修改这些变量的话,我们需使用引用方式捕获。

按值捕获得到的外部变量值是在lambda表达式定义时的值。此时所有外部变量均被复制了一份存储在lambda表达式变量中。此时虽然修改 lambda表达式中的这些外部变量并不会真正影响到外部,我们却仍然无法修改它们。那么如果希望去修改按值捕获的外部变量应当怎么办呢?这时,需要显式指明lambda表达式为 mutable:

1
2
3
int a = 0;
auto f1 = [=] { return a++; }; // error,修改按值捕获的外部变量
auto f2 = [=]() mutable { return a++; }; // ok

被mutable修饰的lambda表达式就算没有参数也要写明参数列表。

lambda表达式的类型在C++11中被称为“闭包类型”。它是一个特殊的匿名的非nunion的类类型。因此,我们可以认为它是一个带有 operator()的类,即仿函数。因此,我们可以使用std::function和std::bind来存储和操作lambda表达式。

1
2
std::function<int(int)> f1 = [](int a) { return a; };
std: function<int(void)>f2 = std::bind([](int a) { return a;}, 123);

另外,对于没有捕获任何变量的lambda表达式,还可以被转换成一个普通的函数指针:
1
2
3
using func_t= int(*)(int);
func_t f = [](int a){ return a; };
f(123);

lambda表达式可以说是就地定义仿函数闭包的“语法糖”。它的捕获列表捕获住的任何外部变量,最终均会变为闭包类型的成员变量。而一个使用了成员变量的类的 operator(),如果能直接被转换为普通的函数指针,那么lambda表达式本身的this指针就丢失掉了。而没有捕获任何外部变量的 lambda表达式则不存在这个问题。

这里也可以很自然地解释为何按值捕获无法修改捕获的外部变量。因为按照C++标准,lambda表达式的operator默认是const的。一个const成员函数是无法修改成员变量的值的。而mutable的作用,就在于取消operator()的const。需要注意的是,没有捕获变量的lambda表达式可以直接转换为函数指针,而捕获变量的lambda表达式则不能转换为函数指针。看看下面的代码:

1
2
3
4
5
typedef void(*Ptr)(int*);
//正确,没有捕获的lambda表达式可以直接转换为函数指针
Ptr p = [](int* p) {delete p;};
Ptr pl = [&](int* p) {delete p;};
∥错误,有状态的1 ambda不能直接转换为函数指针

上面第二行代码能编译通过,而第三行代码不能编译通过,因为第三行的代码捕获了变量,不能直接转换为函数指针

tuple元组

tuple元组是一个固定大小的不同类型值的集合。

1
tuple<const char*, int> tp = make_tuple(sendPack, nSendSize);

等价于一个结构体:
1
2
3
4
struct A {
char* p;
int len;
};

还有一种方法也可创建元组:

1
2
3
4
int x = 1;
int y = 2;
string s = "aa";
auto tp = std::tie(x, s, y);

tp的类型是std::tuple<int&, string&, int&>

再看看如何获取元组的值:

1
2
3
4
//获取第一个值
const char* data = tp.get<0>();
//获取第二个值
int len = tp.get<1>();

还有一种方法也可以获取元组的值,通过std::tie解包tuple
1
2
3
int x, y;
string a;
std::tie(x, a, y) = tp;

通过tie解包后,tp中3个值会自动赋值给3个变量。解包时,如果只想解某个位置值时,可以用std::ignore占位符来表示不解某个位置的值。比如我们只想解第3个值:
1
std::tie(std::ignore, std::ignore, y) = tp;

还有一个创建右值的引用元组方法: forward_as_tuple
1
2
std::map<int, std::string> m;
m.emplace(std::piecewise_construct, std::forward_as_tuple(10), std::forward_as_tuple (20, 'a));

它实际上创建了一个类似于std::tuple<int&&,std::string&&>类型的tuple。

我们还可以通过tuple_cat连接多个tuple,代码如下:

1
2
3
4
5
6
7
int main() {
std::tuple<int, std::string, float> t1(10, "Test", 3.14);
int n = 7;
auto t2 = std::tuple_cat(t1, std::make_pair("Foo", "bar"), t1, std::tie(n));
n = 10;
print(t2);
}

结果是:
1
(10, Test, 3.14, Foo, bar, 10, Test, 3.14, 10)

总结

本章主要介绍了通过一些C++11的特性简化代码,使代码更方便、简洁和优雅。首先讨论了自动类型推断的两个关键字auto和decltype,通过这两个关键字可以化繁为简,使我们不仅能方便地声明变量,还能获取复杂表达式的类型,将二者和返回值后置组合起来能解决函数的返回值难以推断的难题。

模板别名和模板默认参数可以使我们更方便地定义模板,将复杂的模板定义用一个简短更可读的名称来表示,既减少了烦琐的编码又提高了代码的可读性。

range-based for循环可以用更简洁的方式去遍历数组和容器,它还可以支持自定义的类型,只要自定义类型满足3个条件即可。

初始化列表和统一的初始化使得初始化对象的方式变得更加简单、直接和统一。

std::function不仅是一个函数语义的包装器,还能绑定任意参数,可以更灵活地实现函数的延迟执行。

lambda表达式能更方便地使用STL算法,就地定义的匿名函数免除了维护一大堆函数对象的烦琐,也提高了程序的可读性。

tuple元组可以作为一个灵活的轻量级的小结构体,可以用来替代简单的结构体,它有一个很好的特点就是能容纳任意类型和任意数量的元素,比普通的容器更灵活,功能也更强大。但是它也有复杂的一面, tuple的解析和应用往往需要模板元的一些技巧,对使用者有一定的要求。

使用C++11改进程序性能

右值引用

右值引用标记为T &&

左值是指表达式结束后仍然存在的持久对象,右值是指表达式结束后就不再存在的临时对象。所有的具名变量或对象都是左值,而右值不具名。在C++11中,右值由两个概念构成,一个是将亡值,另一个则是纯右值。比如,非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等都是纯右值。而将亡值是C++1l新增的、与右值引用相关的表达式,如将要被移动的对象、T&&函数返回值,std::move返回值。

C+11中所有的值必属于左值、将亡值、纯右值三者之一,将亡值和纯右值都属于右值。区分表达式的左右值属性有一个简便方法:若可对表达式用&符取址,则为左值,否则为右值。比如,简单的赋值语句int i = 0,在这条语句中,i是左值,0是字面量,就是右值。在上面的代码中,i可以被引用,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
#include <iostream>
using namespace std;

int g_constructCount = 0;
int g_copyConstructCount = 0;
int g_destructCount = 0;
struct A {
A() {
cout << "construct: " << ++g_constructCount << endl;
}
A(const A& a) {
cout << "copy construct: " << ++g_copyConstructCount << endl;
}
~A() {
cout << "destruct: " << ++g_destructCount << endl;
}
};
A getA() {
return A();
}
int main()
{
A a = getA();
return 0;
}

1
2
3
4
5
6
7
8
g++ -fno-elide-constructors -std=c++0x -O0 1.cpp -o 1

construct: 1
copy construct: 1
destruct: 1
copy construct: 2
destruct: 2
destruct: 3

在没有返回值优化的情况下,拷贝构造函数调用了两次,一次是getA()函数内部创建的对象返回后构造一个临时对象产生的,一次是在main构造a对象产生的。得如此的destruct是因为临时对象在构造a对象之后就销毁了。修改程序:
1
2
3
4
5
int main()
{
A&& a = getA();
return 0;
}

输出为:
1
2
3
4
construct: 1
copy construct: 1
destruct: 1
destruct: 2

通过右值引用,少了一次拷贝构造和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了。避免临时对象的拷贝构造和析构,事实上,在C++98/03中,也通过常量左值引用来做性能优化。

实际上T&&并不是一定表示右值,它绑定的类型是未定的,既可能是左值又可能是右值。看看这个例子:

1
2
3
4
5
6
7
8
9
template<typename T>
void f(T&& param);

f(10);
// 10是右值

int x = 10;
f(x);
//x是左值

从这个例子可以看出, param有时是左值,有时是右值,因为在上面的例子中有&&,这表示param实际上是一个未定的引用类型。这个未定的引用类型称为 universal references(可以认为它是一种未定的引用类型),它必须被初始化,它是左值还是右值引用取决于它的初始化,如果&&被一个左值初始化,它就是一个左值;如果它被一个右值初始化,它就是个右值。

需要注意的是,只有当发生自动类型推断时(如函数模板的类型自动推导,或auto关键字),&&才是一个
universal references。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
void f(T&& param);
//这里T的类型需要推导,所以&&是一个 universal references

template<typename T>
class Test {
Test(Test&& rhs); // 已经定义了一个特定的类型,没有类型推断
// &&是一个右值引用
};
void f(Test&& param);
// 已经定义了一个确定的类型,没有类型推断,&&是一个右值引用

由于存在T&&这种未定的引用类型,当它作为参数时,有可能被一个左值引用或右值引用的参数初始化,这时经过类型推导的T&&类型,相比右值引用(&&)会发生类型的变化,这种变化被称为引用折叠:

  • 所有的右值引用叠加到右值引用上还是一个右值引用;
  • 所有的其他引用类型之间的叠加都将变成左值引用。

编译器会将已命名的右值引用视为左值,而将未命名的右值引用视作右值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void PrintValue(int& i) {
std::cout << "lvalue: " << i << std::endl;
}
void PrintValue(int&& i) {
std::cout << "rvalue: " << i << std::endl;
}
void Forward(int&& i) {
PrintValue(i);
}
int main() {
int i = 0;
PrintValue(i);
PrintValue(1);
Forward(2);
return 0;
}

输出:
1
2
3
lvalue: 0
rvalue: 1
lvalue: 2

Forward函数接收的是一个右值,但是在转发给PrintValue时,因为右值i变成一个命名对象,所以变成了左值。

&&的总结如下:

  • 左值和右值是独立于它们的类型的,右值引用类型可能是左值也可能是右值。
  • auto&&或函数参数类型自动推导的T&&是一个未定的引用类型,被称为universal references,它可能是左值引用也可能是右值引用类型,取决于初始化的值类型。
  • 所有的右值引用叠加到右值引用上仍然是一个右值引用,其他引用折叠都为左值引用,当T&&为模板参数时,输入左值,它会变成左值引用;输入右值时变为具名的右值引用。
  • 编译器会将已命名的右值引用视为左值,将未命名的右值引用视为右值。

在编写拷贝函数时,应该提供深拷贝的拷贝构造函数:

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
class A {
public:
A() : m_ptr(new int(0)) {
cout << "construct" << endl;
}
A(const A& a) : m_ptr(new int(*a.m_ptr)) {
cout << "copy construct" << endl;
}
~A() {
cout << "destruct" << endl;
delete m_ptr;
}
public:
int* m_ptr;
};
A get(bool flag) {
A a;
A b;
if (flag)
return a;
else
return b;
}
int main()
{
A a = get(false);
}

输出:
1
2
3
4
5
6
construct
construct
copy construct
destruct
destruct
destruct

这样可以保证拷贝的安全性。但这样的开销很大,get函数返回临时变量,然后通过这个临时变量拷贝构造了新的对象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
30
31
class A {
public:
A() : m_ptr(new int(0)) {
cout << "construct" << endl;
}
A(const A& a) : m_ptr(new int(*a.m_ptr)) {
cout << "copy construct" << endl;
}
A(A&& a) : : m_ptr(a.m_ptr) {
a.m_ptr = nullptr;
cout << "move construct: "<< endl;
}
~A() {
cout << "destruct" << endl;
delete m_ptr;
}
public:
int* m_ptr;
};
A get(bool flag) {
A a;
A b;
if (flag)
return a;
else
return b;
}
int main()
{
A a = get(false);
}

输出:
1
2
3
4
5
6
construct
construct
move construct
destruct
destruct
destruct

上面的代码中没有了拷贝构造,取而代之的是移动构造( Move Construct)。从移动构造函数的实现中可以看到,它的参数是一个右值引用类型的参数A&&,这里没有深拷贝,只有浅拷贝,这样就避免了对临时对象的深拷贝,提高了性能。这里的A&&用来根据参数是左值还是右值来建立分支,如果是临时值,则会选择移动构造函数。移动构造函数只是将临时对象的资源做了浅拷贝,不需要对其进行深拷贝,从而避免了额外的拷贝,提高性能。也就是所谓的移动语义(move语义),右值引用的一个重要目的是用来支持移动语义的。

移动语义可以将资源(堆、系统对象等)通过浅拷贝方式从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,可以大幅度提高C++应用程序的性能,消除临时对象的维护(创建和销毁)对性能的影响。
以代码清单22所示为示例,实现拷贝构造函数和拷贝赋值操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Element {
Element() {}
Element(Element&& other) : m_children(std::move(other.m_children)) {}
Element(const Element& other) : m_children(other.m_children) {}
private:
vector<ptree> m_children;
}

void Test() {
Element t1 = Init();
vector<Element> v;
v.push_back(t1);
v.push_back(std::move(t1));
}

先构造了一个临时对象t1,这个对象中存放了很多对象,数量可能很多,如果直接将这个t1用 push_back插入到vector中,没有右值版本的构造函数时,会引起大量的拷贝,这种拷贝会造成额外的严重的性能损耗。通过定义右值版本的构造函数以及std::move(t1)就可以避免这种额外的拷贝,从而大幅提高性能。

有了右值引用和移动语义,在设计和实现类时,对于需要动态申请大量资源的类,应该设计右值引用的拷贝构造函数和赋值函数,以提高应用程序的效率。需要注意的是,我们般在提供右值引用的构造函数的同时,也会提供常量左值引用的拷贝构造函数,以保证移动不成还可以使用拷贝构造。

关于左值和右值的定义

左值和右值在C中就存在,不过存在感不高,在C++尤其是C++11中这两个概念比较重要,左值就是有名字的变量(对象),可以被赋值,可以在多条语句中使用,而右值呢,就是临时变量(对象),没有名字,只能在一条语句中出现,不能被赋值。

在 C++11 之前,右值是不能被引用的,最大限度就是用常量引用绑定一个右值,如 :

1
const int& i = 3;

在这种情况下,右值不能被修改的。但是实际上右值是可以被修改的,如 :
1
T().set().get();

T 是一个类,set 是一个函数为 T 中的一个变量赋值,get 用来取出这个变量的值。在这句中,T() 生成一个临时对象,就是右值,set() 修改了变量的值,也就修改了这个右值。
既然右值可以被修改,那么就可以实现右值引用。右值引用能够方便地解决实际工程中的问题,实现非常有吸引力的解决方案。

右值引用

左值的声明符号为”&”, 为了和左值区分,右值的声明符号为”&&”。

给出一个实例程序如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

void process_value(int& i)
{
std::cout << "LValue processed: " << i << std::endl;
}

void process_value(int&& i)
{
std::cout << "RValue processed: " << i << std::endl;
}

int main()
{
int a = 0;
process_value(a);
process_value(1);
}

结果如下
1
2
3
4
wxl@dev:~$ g++ -std=c++11  test.cpp
wxl@dev:~$ ./a.out
LValue processed: 0
RValue processed: 1

Process_value 函数被重载,分别接受左值和右值。由输出结果可以看出,临时对象是作为右值处理的。

下面涉及到一个问题:
x的类型是右值引用,指向一个右值,但x本身是左值还是右值呢?C++11对此做出了区分:

Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.

对上面的程序稍作修改就可以印证这个说法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void process_value(int& i)
{
std::cout << "LValue processed: " << i << std::endl;
}

void process_value(int&& i)
{
std::cout << "RValue processed: " << std::endl;
}

int main()
{
int a = 0;
process_value(a);
int&& x = 3;
process_value(x);
}

1
2
3
4
wxl@dev:~$ g++ -std=c++11  test.cpp
wxl@dev:~$ ./a.out
LValue processed: 0
LValue processed: 3

x 是一个右值引用,指向一个右值3,但是由于x是有名字的,所以x在这里被视为一个左值,所以在函数重载的时候选择为第一个函数。

右值引用的意义

直观意义:为临时变量续命,也就是为右值续命,因为右值在表达式结束后就消亡了,如果想继续使用右值,那就会动用昂贵的拷贝构造函数。(关于这部分,推荐一本书《深入理解C++11》)
右值引用是用来支持转移语义的。转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。
转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。
通过转移语义,临时对象中的资源能够转移其它的对象里。
在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。
普通的函数和操作符也可以利用右值引用操作符实现转移语义。

转移语义以及转移构造函数和转移复制运算符
以一个简单的 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class MyString { 
private:
char* _data;
size_t _len;
void _init_data(const char *s) {
_data = new char[_len+1];
memcpy(_data, s, _len);
_data[_len] = '\0';
}
public:
MyString() {
_data = NULL;
_len = 0;
}

MyString(const char* p) {
_len = strlen (p);
_init_data(p);
}

MyString(const MyString& str) {
_len = str._len;
_init_data(str._data);
std::cout << "Copy Constructor is called! source: " << str._data << std::endl;
}

MyString& operator=(const MyString& str) {
if (this != &str) {
_len = str._len;
_init_data(str._data);
}
std::cout << "Copy Assignment is called! source: " << str._data << std::endl;
return *this;
}

virtual ~MyString() {
if (_data)
free(_data);
}
};

int main() {
MyString a;
a = MyString("Hello");
std::vector<MyString> vec;
vec.push_back(MyString("World"));
}

1
2
Copy Assignment is called! source: Hello 
Copy Constructor is called! source: World

这个 string 类已经基本满足我们演示的需要。在 main 函数中,实现了调用拷贝构造函数的操作和拷贝赋值操作符的操作。MyString(“Hello”) 和 MyString(“World”) 都是临时对象,也就是右值。虽然它们是临时的,但程序仍然调用了拷贝构造和拷贝赋值,造成了没有意义的资源申请和释放的操作。如果能够直接使用临时对象已经申请的资源,既能节省资源,有能节省资源申请和释放的时间。这正是定义转移语义的目的。

我们先定义转移构造函数。

1
2
3
4
5
6
7
 MyString(MyString&& str) { 
std::cout << "Move Constructor is called! source: " << str._data << std::endl;
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL;
}

有下面几点需要对照代码注意:

  1. 参数(右值)的符号必须是右值引用符号,即“&&”。
  2. 参数(右值)不可以是常量,因为我们需要修改右值。
  3. 参数(右值)的资源链接和标记必须修改。否则,右值的析构函数就会释放资源。转移到新对象的资源也就无效了。

现在我们定义转移赋值操作符。

1
2
3
4
5
6
7
8
9
10
 MyString& operator=(MyString&& str) { 
std::cout << "Move Assignment is called! source: " << str._data << std::endl;
if (this != &str) {
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL;
}
return *this;
}

这里需要注意的问题和转移构造函数是一样的。
增加了转移构造函数和转移复制操作符后,我们的程序运行结果为 :

由此看出,编译器区分了左值和右值,对右值调用了转移构造函数和转移赋值操作符。节省了资源,提高了程序运行的效率。
有了右值引用和转移语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计转移构造函数和转移赋值函数,以提高应用程序的效率。

但是这几点总结的不错

  • std::move执行一个无条件的转化到右值。它本身并不移动任何东西;
  • std::forward把其参数转换为右值,仅仅在那个参数被绑定到一个右值时;
  • std::move和std::forward在运行时(runtime)都不做任何事。

move语义

std::move将左值转换为右值,从而方便应用移动语义。move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移没有拷贝。

move实际上并不移动任何东西,它唯一的功能是将一个左值强制转换为一个右值引用,是我们可以通过右值引用使用该值,以用于移动语义。

仅仅转移资源的所有者,将资源的拥有者改为被赋值者。假设一个临时容器很大,赋值给另一个容器:

1
2
3
4
5
std::list<std::string> tokens;
std::list<std::string> t = tokens;

std::list<std::string> tokens;
std::list<std::string> t = std::move(tokens);

如果不用std::move,拷贝的代价很大,性能较低,使用move几乎没有任何代价,只是转换了资源的所有权,实际上是将左值转换为右值引用,然后应用move语义调用构造函数,就避免了拷贝。

forward和完美转发

需要一种方法能按照参数原来的类型转发到另一个函数,这种转发被称作完美转发,即在函数模板中,完全依照模板的参数的类型(保持参数的左右值特征),将参数传递给函数模板中调用的另一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void PrintT(int& t) {
cout << "lvalue" << endl;
}

void PrintT(int&& t) {
cout << "rvalue" << endl;
}
template<typename T>
void TestForward(T && t) {
PrintT(t);
PrintT(std::forward<T>(t));
PrintT(std::move(t));
}

int main() {
TestForward(1);
int x = 1;
TestForward(x);
TestForward(std::forward<int>(x));
return 0;
}

输出:
1
2
3
4
5
6
7
8
9
lvalue
rvalue
rvalue
lvalue
lvalue
rvalue
lvalue
rvalue
rvalue

TestForward(1)时,1是右值,所以未定义的引用类型T&& t被一个右值初始化后变成一个右值引用。但是在TestForward中调用PrintT(t)时,t变成一个左值。调用PrintT(std::forward<T>(t))时,std::forward会按照原来的参数类型转发,所以它还是一个右值。

TestForward(x)未定的引用类型T&& t被一个左值初始化后变成一个左值引用,因此,在调用PrintT(std::forward<T>(t))时它会被转发到PrintT(T& t)

emplace_back减少内存拷贝和移动

emplace_back能就地通过参数构造对象,不需要拷贝或者移动内存,相比push_back能更好地避免内存的拷贝与移动,使容器插入元素的性能得到进一步提升。在大多数情况下应该优先使用emplace_back来代替push_back。

所有的标准库容器(array除外,因为它的长度不可改变,不能插入元素)都增加了类似的方法: emplace、 emplace_hint、 emplace_front、emplace_after和emplace_back。

vector的emplace_back的基本用法如下:

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

struct A {
int x;
double y;
A(int a, double b): x(a), y(b);
};
int main() {
vector<A> v;
v.emplace_back(1, 2);
cout<<v.size()<<endl;
return 0;
}

可以看出, emplace_back的用法比较简单,直接通过构造函数的参数就可以构造对象。因此,也要求对象有对应的构造函数,如果没有对应的构造函数,编译器会报错。

其他容器相应的 emplace方法也是类似的。
相对 push_back而言, emplace_back更具性能优势。

在引入右值引用,转移构造函数,转移复制运算符之前,通常使用push_back()向容器中加入一个右值元素(临时对象)的时候,首先会调用构造函数构造这个临时对象,然后需要调用拷贝构造函数将这个临时对象放入容器中。原来的临时变量释放。这样造成的问题是临时变量申请的资源就浪费。
引入了右值引用,转移构造函数(请看这里)后,push_back()右值时就会调用构造函数和转移构造函数。

unordered container无序容器

C++11增加了无序容器 unordered_map/unordered_multimap和unordered_set/unordered_multiset,由于这些容器中的元素是不排序的,因此,比有序容器 map/multimap和set/multiset效率更高。

map和set内部是红黑树,在插入元素时会自动排序,而无序容器内部是散列表( Hash Table),通过哈希(Hash),而不是排序来快速操作元素,使得效率更高。由于无序容器内部是散列表,因此无序容器的key需要提供hash_value函数,其他用法和map/set的用法是一样的。不过对于自定义的key,需要提供Hash函数和比较函数。

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 <unordered_map>
#include <vector>
#include <bitset>
#include <string>
#include <utility>

struct Key {
std::string first;
std::string second;
};

struct KeyHash {
std::size_t operator()(const Key& k) const {
return std::hash<std::string>()(k.first)^(std::hash<std::string>()(k.second) << 1);
}
};
struct keyEqual {
bool operator()(const Key& lhs, const Key& rhs) const {
return lhs.first == rhs.first && lhs.second == rhs.second;
}
};

int main() {
std::unordered_map<std::string, std::string> m1;

std::unordered_map<int, std::string> m2 = {
{1, "foo"},
{2, "bar"},
{3, "baz"},
};

std::unordered_map<int, std::string> m3 = m2;
std::unordered_map<int, std::string> m4 = std::move(m2);

std::vector<std::pair<std::bitset<8>, int>> v = { {0x12, 1}, {0x01, -1} };
std::unordered_map<std::bitset<8>, double> m5(v.begin(), v.end());

// constructor for a custom type
std::unordered_map<Key, std::string, KeyHash, KeyEqual> m6 = {
{ {"john", "doe"}, "example"},
{ {"mary", "Sue"}, "another"},
};
return 0;
}

使用C++11消除重复,提高代码质量

type_traits——类型萃取

type_traits的类型判断功能在编译期就可以检查出是否是正确的类型,以便能编写更安全的代码。

基本的type_traits

在之前的C++中,在类中定义编译期常量的方法是:

1
2
3
4
template<typename T>
struct GetLeftSize {
static const int value = 1;
};

在C++11中定义编译期常量,无需自己定义static const intenum类型,只需要从std::integral_constant派生:

1
2
template<typename T>
struct GetLeftSize : std::integral_constant<int, 1> { };

将编译期常量包装为一个类型的type_trait——integral_constant:

1
2
3
4
5
6
7
template<class T, T v>
struct integral_constant {
static const T value = v;
typedef T value_type;
typedef integral_constant<T, v> type;
operator value_type() { return value;}
}

常见的用法是从integral_constant派生从而可以通过继承得到value

派生的type_traits可用于检查模板类型是否为某种类型,通过这些trait可以获取编译期检查的bool值结果。

1
2
template<class T>
struct is_integral;

这是用来检查T是否为bool、char、int、long、long long等整型类型的,派生于std::integral_constant,因此可以通过std::is_xxx::value是否为true判断模板类型是否为目标类型。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <type_traits>
using namespace std;

int main()
{
cout << "int" << std::is_const<int>::value << endl;
cout << "const int" << std::is_const<const int>::value << endl;
cout << "const int*" << std::is_const<const int*>::value << endl;
cout << "const int&" << std::is_const<const int&>::value << endl;
}

C++提供了判断类型之间的关系的traits:

1
2
3
4
template<class T, class U>
struct is_same // 判断两个类型是否相同
struct is_base_of // 判断base类型是否是derived类型的积累
struct is_convertible // 判断模板参数类型是否能转换

C++提供了类型转换traits,包括对const的修改,引用的移除和添加,指针和数组的修改等。

1
2
3
template <typename T>
struct remove_const
strcut add_const

有时需要添加引用类型,比如从智能指针中获取对象的引用时

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

template<class T>
struct Construct {
typedef typename std::remove_reference<T>::type U;
Construct() : m_ptr(new U) { }

typename std::add_lvalue_reference<U>:: type
Get() const { return *m_ptr.get(); }
private:
std::unique_ptr<U> m_ptr;
};

int main() {
Construct<int> c;
int a = c.Get();
cout << a << endl;
return 0;
}

移除引用和cv符:

1
2
3
4
5
6
template<typename T> 
typename std::remove_cv<typename std::remove_reference<T>::type>::type*
Create() {
typedef typename std::remove_cv<typename std::remove_reference<T>::type>::type U;
return new U();
}

先移除引用,再移除cv符,最终获得原始类型,这样可以解决问题,但是较为繁琐,用decay来简化代码:

1
2
3
4
5
template<typename T> 
typename std::decay<T>::type* Create() {
typedef typename std::decay<T>::type U;
return new U();
}

对于普通类型来说,std::decay是移除引用和cv符,大大简化了我们的书写。除了普通类型之外,std::decay还可以用于数组和函数,具体的转换规则如下:

  • 先移除T类型的引用,得到类型U,U定义为remove_reference<T>::type
  • 如果is_array<U>::value为true,修改类型type为remove_extent<U>::type*
  • 否则,如果is_function<U>::value为true,修改类型type为add_pointer<U>::type
  • 否则,修改类型type为remove_cv<U>::type

根据上面的规则,再对照用法示例,就能清楚地理解std::decay的含义了。下面是std::decay的基本用法:

1
2
3
4
5
6
typedef std::decay<int>::type A; // int
typedef std::decay<int&>::type B; // int
typedef std::decay<int&&>::type C; // int
typedef std::decay<const int&>::type D; // int
typedef std::decay<int[2]>::type E; //int*
typedef std::decay<int(int)>::type F: //int(*)(int)

由于std::decay对于函数来说是添加指针,利用这一点,我们可以将函数变成函数指针类型,从而将函数指针变量保存起来,以便在后面延迟执行。

std::conditional在编译期根据一个判断式选择两个类型中的一个,和条件表达式的语义类似,类似一个三元表达式。它的原型如下:

1
2
template< bool B, class T, class F>
struct conditional

std::conditional模板参数中,如果B为true,则conditional::type为T,否则为F。
std::conditional测试代码如下:

1
2
3
4
5
typedef std::conditional<true, int, float>::type A; // int
typedef std::conditional<false, int, float>::type B, // float

typedef std::conditional<std::is_integral<A>::value, long, int>:: type C; // long
typedef std::conditional<std::is_integral<B>::value, long, int>:: type D; // int

比较两个类型,输出较大的那个类型:

1
2
typedef std::conditional<(sizeof(long long) > sizeof(long double)), long long, long double>::type max_size_t;
cout<<typeid(max_size_t).name()<<endl;

将会输出: long double
我们可以通过编译期的判断式来选择类型,这给我们动态选择类型提供了很大的灵活性,在后面经常和其他的C++11特性配合起来使用,是比较常用的特性之一。

有时要获取函数的返回类型是一件比较困难的事情,C++提供了std::result_of,用来在编译期获取一个可调用对象。

1
std::result_of<A(int)>::type i = 4;

等价于

1
decltype(std::declval<A>()(std::declval<int>()));

std::result_of原型如下:

1
2
template<class F, class ... ArgTypes>
class result_of<F(ArgTypes...)>;

第一个模板参数为可调用对象的类型,第二个模板参数为参数的类型。

1
2
3
4
5
6
7
8
9
10
11
int fn(int) { return int(); }
typedef int(&fn_ref)(int);
typedef int(*fn_ref)(int);
struct fn_class { int operator()(int i) {return i;} };

int main {
typedef std::result_of<decltype(fn)&(int)>:: type A; // int
typedef std::result_of<fn_ref(int)>::type B; // int
typedef std::result_of<fn_ptr(int)>::type C; // int
typedef std::result_of<fn_class(int)>::type D; // int
}

std::result_of<Fn(ArgTypes...)>要求Fn为一个可调用对象(不能是个函数类型,因为函数类型不是一个可调用对象,因此,下面这种方式是错误的:

1
typedef std::result_of<decltype(fn)(int)>::type A;

如果要对某个函数使用std::result_of,要先将函数转换为可调用对象。可以通过以下方式来获取函数返回类型

1
2
3
4
typedef std::result_of<decltype(fn)&(int)>::type A;
typedef std::result_of<decltype(fn)*(int)>::type B;
typedef std::result_of<typename std::decay<decltype(fn)>::type(int)>::type C;
A B C 类型相同

可变参数模板

声明可变参数模板时需要在typename或class后边带上’…’。

  • 声明一个参数包,这个参数包中可以包含0到任意个模板参数
  • 在模板定义的右边,可以把参数包展开成一个一个独立的参数

可变参数模板函数

1
2
3
4
5
6
7
template <class ... T>
void f(T... args) {
cout << sizeof...(args) << endl;
}
f(); // 0
f(1, 2); // 2
f(1, 2.5, ""); // 3

如果要用参数包中的参数,则一定要将参数包展开,有两种展开参数包的方法,一种是递归的模板函数展开,一种是通过逗号表达式和初始化列表方式展开。

通过递归函数展开参数包,需要提供一个参数包展开的函数和一个递归终止函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
//递归终止函数
void print() {
cout << "Empty" << endl;
}
//展开函数
template <class T, class... Args>
void print(T head, Args... rest) {
cout << "parameter " << head << endl;
print(rest...);
}

int main() {
print(1,2,3,4);
return 0;
}

输出:

1
2
3
4
5
parameter 1
parameter 2
parameter 3
parameter 4
Empty

递归终止函数可以写成如下形式:

1
2
3
4
5
6
7
8
template<typename T, typename T1, typename T2>
void print(T t, T1 t1) {
cout << t << " " << t1 << endl;
}
template<typename T, typename T1, typename T2>
void print(T t, T1 t1, T2 t2) {
cout << t << " " << t1 << " " << t2 << endl;
}

另一种方法是:

1
2
3
4
5
6
7
8
9
template <class T> 
void printarg(T t) {
cout << t << endl;
}
template <class ...Args>
void expand(Args... args) {
int arr[] = { (printarg(args), 0)...}
}
expand(1,2,3,4);

这种就地展开参数包的方式关键是逗号表达式,它会按顺序执行逗号前边的表达式。expand()函数中的(printarg(args), 0),先执行printarg(args),再得到逗号表达式的结果0。同时用到了初始化列表,通过初始化列表来初始化一个变长数组。{(printargs(args), 0)...}将会展开成((printargs(arg1), 0), (printargs(arg2), 0), (printargs(arg3), 0), etc...),最终会创建一个所有元素为0的数组int arr[sizeof(Args)],会先执行表达式前面的printarg打印出参数。

可变参数模板类

1
2
template <class... Types>
class tuple;

这个可变参数模板类可以携带任意类型任意个数的模板参数:

1
2
3
std::tuple<int> tp1 = std::make_tuple(1);
std::tuple<int, double> tp2 = std::make_tuple(1, 2.5);
std::tuple<int, double, string> tp3 = std::make_tuple(1, 2.5, "hello");

模板递归和特化方式展开参数包

可变参数模板类的展开一般需要定义2 ~ 3个类,包含类声明和特化的模板类

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename... nums> struct Sum;// 变长模板的声明

template <typename First, typename... last>
struct Sum<first, last...> // 变长模板类
{
static const long val = first * Sum<last...>::val;
};

template<>
struct Sum<> // 边界条件
{
static const long val = 1;
};

一个基本的可变参数模板应用类由三部分组成:

第一个是template<typename... Args> struct Sum,这是前向声明,声明这个类是可变参数模板类

第二个是类的定义,它定义了一个部分展开的可变参数模板类,告诉编译器如何递归展开参数包

1
2
3
4
5
template <typename First, typename... last> 
struct Sum<first, last...> // 变长模板类
{
static const long val = first * Sum<last...>::val;
};`

第三个是特化的递归终止类,这是在展开到0个参数时终止,也可以在展开到2个时终止。

1
2
3
4
5
6
7
8
9
template<>
struct Sum<> // 边界条件
{
static const long val = 1;
};
template<typename First, typename Last>
struct sum<First, Last> {
static const long val = First * Last;
}

可变参数消除重复代码

1
2
3
4
5
6
7
8
9
template<typename T>
void Print(T t) {
cout << t << endl;
}
template<typename T, typename ... Args>
void Print(T t) {
cout << t ;
Print(args...);
}

通过可变模板参数可以消除重复,同时去掉参数个数限制:

1
2
3
4
5
6
template<typename ... Args>
T* Instance(Args... args) {
return new T(args...);
}
A* pa = Instance<A>(1);
B* pb = Instance<B>(1, 2);

上边的代码T* Instance(Args... args)的Args是值拷贝的,存在性能损耗,可以通过完美转发来消除损耗:

1
2
3
4
template<typename ... Args>
T* Instance(Args&&... args) {
return new T(std::forward<Args >(args)...);
}

可变参数模板和type_traits的综合应用

optional的实现

C+14中将包含一个std::optional类,它的功能及用法和boost的optional类似。optional<T>内部存储空间可能存储了T类型的值也可能没有存储T类型的值,只有当optional被T初始化之后,这个optional才是有效的,否则是无效的,它实现了未初始化的概念。

optional可以用于解决函数返回无效值的问题,有时根据某个条件去查找对象时,如果查找不到对象,就会返回一个无效值,这不表明函数执行失败,而是表明函数正确执行了,只是结果不是有用的值。这时就可以返回一个未初始化的optional对象,判断这个optional对象是否是有效对象需要判断它是否被初始化,如果没有被初始化就表明这个值是无效的。boost中的optional就实现了这种未初始化的概念。 boost.optional的基本用法很简单:

1
2
3
4
5
6
optional<int> op;
if(op)
cout << *op << endl;
optional<int> op1 = 1;
if(op1)
cout << *op1 << endl;

第一个op由于没有被初始化,所以它是一个无效值,将不会输出打印信息;第二个op被初始化为1,所以它是一个有效值,将会输出1。optional经常用于函数返回值。

由于optional需要容纳T的值,所以需要一个缓冲区保存这个T,这个缓冲区不可用普通的char数组,需要使用内存对齐的缓冲区std::aligned_storage,原型如下,其中,Len表示所存储类型的size,Align表示该类型内存对齐的大小,通过sizeof(T)可以获取T的size,通过alignof(T)可以获取T内存对齐的大小:

1
2
3
4
5
template< std::size_t Len, std::size_t Align = /* default-alignment */ >
struct aligned_storage;

std::aligned_storage<sizeof(T), alignof(T)> 或
std::aligned_storage<sizeof(T), std::alignment_of<T>::value>

需要注意拷贝和赋值时,内部状态和缓冲区销毁的问题。内部状态用来标示该optional是否被初始化,当已经初始化时需要先将缓冲区清理一下。需要增加右值版本优化效率。

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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
#include <type_traits>

template<typename T>
class Optional
{
using data_t = typename std::aligned_storage<sizeof(T), std::alignment_of<T>::value>::type;
public:
Optional() : m_hasInit(false) {}
Optional(const T& v)
{
Create(v);
}

Optional(T&& v) : m_hasInit(false)
{
Create(std::move(v));
}

~Optional()
{
Destroy();
}

Optional(const Optional& other) : m_hasInit(false)
{
if (other.IsInit())
Assign(other);
}

Optional(Optional&& other) : m_hasInit(false)
{
if (other.IsInit())
{
Assign(std::move(other));
other.Destroy();
}
}

Optional& operator=(Optional &&other)
{
Assign(std::move(other));
return *this;
}

Optional& operator=(const Optional &other)
{
Assign(other);
return *this;
}

template<class... Args>
void emplace(Args&&... args)
{
Destroy();
Create(std::forward<Args>(args)...);
}

bool IsInit() const { return m_hasInit; }

explicit operator bool() const { return IsInit(); }

T& operator*()
{
return *((T*) (&m_data));
}

T const& operator*() const
{
if (IsInit())
{
return *((T*) (&m_data));
}

throw std::exception("is not init");
}

bool operator == (const Optional<T>& rhs) const
{
return (!bool(*this)) != (!rhs) ? false : (!bool(*this) ? true : (*(*this)) == (*rhs));
}

bool operator < (const Optional<T>& rhs) const
{
return !rhs ? false : (!bool(*this) ? true : (*(*this) < (*rhs)));
}

bool operator != (const Optional<T>& rhs)
{
return !(*this == (rhs));
}
private:
template<class... Args>
void Create(Args&&... args)
{
new (&m_data) T(std::forward<Args>(args)...);
m_hasInit = true;
}

void Destroy()
{
if (m_hasInit)
{
m_hasInit = false;
((T*) (&m_data))->~T();
}
}

void Assign(const Optional& other)
{
if (other.IsInit()) {
Copy(other.m_data);
m_hasInit = true;
}
else {
Destroy();
}
}

void Assign(Optional&& other)
{
if (other.IsInit()) {
Move(std::move(other.m_data));
m_hasInit = true;
other.Destroy();
}
else {
Destroy();
}
}

void Move(data_t&& val)
{
Destroy();
new (&m_data) T(std::move(*((T*)(&val))));
}

void Copy(const data_t& val)
{
Destroy();
new (&m_data) T(*((T*) (&val)));
}

private:
bool m_hasInit;
data_t m_data;
};

惰性求值类lazy的实现

惰性求值(Lazy Evaluation)是相对常用的编程语言中的及早求值而言的另一种求值策略,也被称之为按需调用(call-by-need),或者叫延时求值。简单地讲,惰性求值是在谋求一种平衡,一种在节省开发与节约计算资源的一种平衡策略。一个庞大的类实例可能一次只有一小部分会发生更改,如果把其他的东西都盲目的添加进来,就会额外造成不少的计算资源的浪费。因此,在开发时,开发人员不仅要知道高级语言的语法糖,也需要一定的底层 AST 的实现原理,这样能够避免很多不必要的运行时开销。所以,这里的惰性,更多的是指等待的意思:一旦等到了明确的调用命令,自然会把运行结果正确送出。

借助lambda表达式,将函数封装到lambda表达式中,而不是马上求值,是在需要的时候再调用lambda表达式来求值

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
template<typename T>
struct Lazy {
Lazy() {}

template <typename Func, typename ... Args>
Lazy(Func& f, Args && ... args) {
m_func = [&f, &args...]{ return f(args...); };
}

T& value() {
if (!m_value.IsInit()) {
m_vlaue = m_func();
}
return *m_value;
}

bool IsValueCreated() const {
return m_value.IsInit();
}
private:
std::function<T()> m_func;
Optional<T> m_value;
};

template<class Func, typename... Args>
Lazy<typename std::result_of<Func(Args...)>::type> lazy(Func && fun, Args && ... args) {
return Lazy<typename std::result_of<Func(Args...)>::type>(std::forward<Func>(fun), std::forward<Args>(args)...);
}

Lazy类用到了std::function和optional,其中std::function用来保存传入的函数,不马上执行,而是延迟到后面需要使用值的时候才执行,函数的返回值被放到一个optional对象中,如果不用optional,则需要增加一个标识符来标识是否已经求值,而使用optional对象可以直接知道对象是否已经求值,用起来更简便。

通过optional对象我们就知道是否已经求值,当发现已经求值时直接返回之前计算的结果,起到缓存的作用。
代码清单后一部分定义了一个辅助函数,该辅助函数的作用是更方便地使用Lazy,因为Lazy类需要一个模板参数来表示返回值类型,而type_traits中的std::result_of可以推断出函数的返回值类型,所以这个辅助函数结合std::result_of就无须显式声明返回类型了,同时可变参数模板消除了重复的模板定义和模板参数的限制,可以满足所有的函数入参,在使用时只需要传入一个函数和其参数就能实现延迟计算。

Lazy内部的std::function用来保存传入的函数,以便在后面延迟执行,这个function定义是没有参数的,因为可以通过一个lambda表达式去初始化一个function,而lambda表达式可以捕获参数,所以无须定义function的参数,当然还可以通过std::bind绑定器来将N元的入参函数变为sdtd::function

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
struct BigObject {
BigObject() { cout << "lazy load big object" << endl; }
};
struct MyStruct {
MyStruct() {
m_obj = lazy( [] { return std::make_shared<BigObject>(); });
}
void Load() {
m_obj.Value();
}
Lazy< std::shared_ptr<BigObject> > m_obj;
};

int Foo(int x) { return x * 2; }

void TestLazy() {
int y = 4;
auto lazyer1 = lazy(Foo, y);
cout << lazyer1.Value() << endl;

Lazy<int> lazyer2 = lazy([] {return 12;});
cout << lazyer2.Value() << endl;

std::function <int(int)> f = [](int x) { return x + 3; };
auto lazyer3 = lazy(f, 3);
cout << lazyer3.Value() << endl;

MyStruct t;
t.Load();
}

输出:

1
2
3
4
8
12
6
lazy load big object

dll帮助类

如果要按照

1
Ret CallDllFunc(const string& funName, T arg)

这种方式调用,则首先要把函数指针转换成一种函数对象或泛型函数,这样可以用std::function做这件事。

封装GetProcAddress,将函数指针转换成std::function

1
2
3
4
5
template<typename T>
std::function<T> GetFunction(const string& funName) {
FARPROC funAddress = GetProcAddress(m_hMod, funcName.c_str());
return std::function<T>((T*)funAddress)
}

T是std::function的模板参数,即函数类型的签名。

1
2
auto fmax = GetFunction<int(int, int)>("Max");
auto fget = GetFunction<int(int)>("Get");

解决函数返回值与入参不一样的问题,通过result_of和可变参数模板解决:

1
2
3
4
5
6
7
template <typename T, typename ... Args>
typename std::result_of<std::function<T>(Args...)>::type ExecuteFunc(const string& funcName, Args&& ... args) {
return GetFunction<T>(funcName)(args...);
}

auto max = ExecuteFunc<int(int, int)>("Max", 5, 8);
auto ret = ExecuteFunc<int(int)>("Get", 5);

lambda链式调用

将多个函数按照前一个的输出作为下一个输入串起来再推迟到某个时刻计算。

首先创建一个task对象,然后连续调用then的函数,只需要保证前一个函数的输出为后一个的输入即可。最后在需要的时候调用计算结果。

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
template<typename T>
class Task;

template<typename R, typename...Args>
class Task<R(Args...)>
{
public:
Task(std::function<R(Args...)>&& f) : m_fn(std::move(f)) {}
Task(std::function<R(Args...)>& f) : m_fn(f) {}

R run(Args&&... args)
{
return m_fn(std::forward<Args>(args)...);
}

template <typename F>
auto Then(F&& f) -> Task<typename std::result_of<F(R)>::type(Args...)>
{
using return_type = typename std::result_of<F(R)>::type;

auto func = std::move(m_fn);
return Task<return_type(Args...)>([func, &f](Args&&...args) {
return f(func(std::forward<Args>(args)...));
});
}

private:
std::function<R(Args...)> m_fn;
};

void tesk()
{
Task<int(int)> task([](int i) {return i; });

auto f = task
.Then([](int i) {return i + 1; })
.Then([](int i) {return i + 2; })
.Then([](int i) {return i + 3; });

auto result = f.run(0);

cout << "run task result:" << result << endl;
}

输出:

1
run task result:6

any类的实现

boost库有一个Any类,是一个特殊的只能容纳一个元素的容器,他可以擦除类型,给任何类型赋值。

1
2
3
4
5
6
7
8
9
boost::any a = 1;
boost::any a = 1.1;

std::vector<boost::any> v;
v.push_back(a);
v.push_back(b);

int va = boost::any_cast<int>(a); // 1
double vb = boost::any_cast<double>(b); // 2.5

vector中可以存放int和double,因为any擦除了int和double的类型,当通过any_cast取出实际类型时,如果T不是原来的类型,会报错。

any能容纳所有类型的数据,因此,当赋值给any时,需要将值的类型擦除,即以一种通用的方式保存所有类型的数据。这里可以通过继承去擦除类型,基类是不含模板参数的,派生类中才有模板参数,这个模板参数类型正是赋值的类型。在赋值时,将创建的派生类对象赋值给基类指针,基类的派生类携带了数据类型,基类只是原始数据的一个占位符,通过多态的隐式转换擦除了原始数据类型,因此,任何数据类型都可以赋值给它,从而实现能存放所有类型数据的目标。当取数据时需要向下转换成派生类型来获取原始数据,当转换失败时打印详情,并抛出异常。由于向any赋值时需要创建一个派生类对象,所以还需要管理该对象的生命周期,这里用unique_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
75
76
77
78
79
80
81
82
83
84
85
86
87
class Any
{
public:
//默认构造函数
Any() : m_tpIndex(std::type_index(typeid(void))) {}
Any(const Any& other) : m_ptr(other.clone()), m_tpIndex(other.m_tpIndex) {}
Any(Any&& other) : m_ptr(std::move(other.m_ptr)), m_tpIndex(std::move(other.m_tpIndex)) {}

//通用的右值构造
template<typename T, class = typename std::enable_if<!std::is_same<typename std::decay<T>::type, Any>::value, T>::type>
Any(T && value) : m_ptr(new Derived<typename std::decay<T>::type>(std::forward<T>(value)))
, m_tpIndex(std::type_index(typeid(std::decay<T>::type))) {}

//判断是否为空
bool isNull() {
return !bool(m_ptr);
}

//是否可以类型转换
template<class T>
bool is() const {
return m_tpIndex == std::type_index(typeid(T));
}

//类型转换
template<class T>
T& cast()
{
if (!is<T>())
{
cout << "can not cast " << typeid(T).name() << " to "
<< m_tpIndex.name() << endl;
throw bad_cast();
}
auto ptr = dynamic_cast<Derived<T>*>(m_ptr.get());
return ptr->m_value;
}

Any& operator=(const Any& other)
{
if (m_ptr == other.m_ptr)
{
return *this;
}
m_ptr = other.clone();
m_tpIndex = other.m_tpIndex;
return *this;
}

private:
struct Base;
using BasePtr = std::unique_ptr<Base>;

//非模板擦除类型
struct Base
{
virtual BasePtr clone() const = 0;
};

template<typename T>
struct Derived : public Base
{
template<typename...Args>
Derived(Args&&...args) : m_value(std::forward<Args>(args)...)
{
}
BasePtr clone() const
{
return BasePtr(new Derived(m_value));
}

T m_value;
};

//拷贝使用
BasePtr clone() const
{
if (m_ptr)
{
return m_ptr->clone();
}
return nullptr;
}

BasePtr m_ptr; //具体数据
std::type_index m_tpIndex; //数据类型
};

function_traits

可以获得普通函数、函数指针、std::function、函数对象和成员函数的函数类型、返回类型、参数个数和参数的具体类型。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int func(int a, string b);

//获取函数类型
function_traits<decltype(func)>::function_type; //int __cdecl(int, string)

//获取函数返回值
function_traits<decltype(func)>::return_type; //int

//获取函数的参数个数
function_traits<decltype(func)>::arity; //2

//获取函数第一个入参类型
function_traits<decltype(func)>::arg_type<0>; //int

//获取函数第二个入参类型
function_traits<decltype(func)>::arg_type<1>; //string

通过function_traits可以很方便地获取所有函数语义类型丰富的信息,对于实际开发很有用。

实现 function_traits的关键技术

实现function_traits关键是要通过模板特化和可变参数模板来获取函数类型和返回类型。
先定义一个基本的function_traits的模板类

1
2
template<typename T>
struct function traits

再通过特化,将返回类型和可变参数模板作为模板参数,就可以获取函数类型、函数返回值和参数的个数了。基本的特化版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename Ret, typename... Args>
struct function_traits<Ret(Args...)>
{
public:
enum { arity = sizeof...(Args) };
typedef Ret function_type(Args...);
typedef Ret return_type;
using stl_function_type = std::function<function_type>;
typedef Ret(*pointer)(Args...);

template<size_t I>
struct args {
using type = typename std::tuple_element<I, std::tuple<Args...>>::type;
};
};

variant的实现

variant类似于union,它能代表定义的多种类型,允许将不同类型的值赋给它。它的具体类型是在初始化赋值时确定。boost中的variant的基本用法:

1
2
3
4
typedef variant<int,char, double> vt;
vt v = 1;
v = '2';
v = 12.32;

用variant一个好处是可以擦除类型,不同类型的值都统一成一个variant,虽然这个variant只能存放已定义的类型,但这在很多时候已经够用了。 取值的时候,通过get(v)来获取真实值。然而,当T类型与v的类型不匹配时,会抛出一个bad_cast的异常来。boost的variant抛出的异常往往没有更多的信息,不知道到底是哪个类型转换失败,导致发生异常调试时很不方便。因此,就考虑用c++11去实现一个vairiant, 这个variant可以很容易知道取值时,是什么类型转换失败了。

打造variant需要解决的问题

第一,要在内部定义一个char缓冲区。缓冲区用来存放variant的值,这个值是variant定义的多种类型中的某种类型的值,因此,这个缓冲区要足够大,能够存放类型最大(sizeof(Type))的值才可以,这个缓冲区的大小还必须在编译期计算出来。因此需要首先要解决的是variant值存放的缓冲区定义的问题。同时注意内存对齐,使用std::aligned_storage作为variant值存放的缓冲区。

第二,要解决赋值的问题。将值赋给vairiant时,需要将该值的类型ID记录下来,以便在后面根据类型取值。将值保存到内部缓冲区时,还需要用palcement new在缓冲区创建对象。另外,还要解决一个问题,就是赋值时需要检查variant中已定义的类型中是否含有该类型,如果没有则编译不通过,以保证赋值是合法的。

variant的赋值函数要做两件事:第一是从原来的variant中取出缓冲区中的对象;第二是通过缓冲区中取出的对象构造出当前variant中的对象。赋值函数的左值和右值的实现如下:

1
2
3
4
5
6
Variant(Variant<Types...>&& old) : m_typeIndex(old.m_typeIndex) {
Helper_t::move(old.m_typeIndex, &old.m_data, &m_data);
}
Variant(const Variant<Types...>& old) : m_typeIndex(old.m_typeIndex) {
Helper_t::copy(old.m_typeIndex, &old.m_data, &m_data);
}

第三,解决取值的问题,通过类型取值时,要判断类型是否匹配,如果不匹配,将详情打印出来,方便调试。

打造variant的关键技术:

找出最大的typesize。第一个问题中需要解决的问题是如何找出多种类型中,size最大的那个类型的size。看看如何从多种类型中找出最大类型的size。

1
2
3
4
5
6
7
8
template<typename T, typename... Args>
struct MaxType : std::integral_constant<int,
(sizeof(T)>MaxType<Args...>::value ? sizeof(T) : MaxType<Args...>::value) >

{};

template<typename T>
struct MaxType<T> : std::integral_constant<int, sizeof(T) >{};

通过这个MaxType就可以在编译期获取类型中最大的maxsize了:MaxType<Types...>::value

这里通过继承和递归方式来展开参数包,在展开参数包的过程中将第一个参数的size和后面一个参数的size做比较,获取较大的那个size,直到比较完所有的参数,从而获得所有类型中最大的size,比较的过程和冒泡排序的过程类似。内存对齐的缓冲区aligned_storage需要两个模版参数,第一个是缓冲区大小,第二个是内存对齐的大小。 variant中的aligned_storage中的缓冲区大小就是最大类型的sice,我们已经找出,下一步是找出最大的内存对齐大小。我们可以在MaxType的基础上来获取MaxAligin。

1
2
3
4
5
6
7
8
template<typename... Args>
struct MaxAlign : std::integral_constant<int, IntegreMax<std::alignment_of<Args>::value...>::value>{};

enum {
data_size = MaxType<sizeof(Types)...>::value;
align_size = MaxAlign<Types...>::value;
};
using data_t = typename std::aligned_storage<data_size, align_size>::type;

类型检查和缓冲区中创建对象

第二个问题中需要解决两个问题,1.检查赋值的类型是否在已定义的类型中;2.在缓冲区中创建对象及析构;

1
2
3
4
5
6
7
8
9
template < typename T, typename... List >
struct Contains : std::true_type {};

template < typename T, typename Head, typename... Rest >
struct Contains<T, Head, Rest...>
: std::conditional< std::is_same<T, Head>::value, std::true_type, Contains<T, Rest...>>::type{};

template < typename T >
struct Contains<T> : std::false_type{};

通过bool值Contains::vaule就可以判断是否含有某种类型。

再看看如何在缓冲区中创建对象。

通过placement new在该缓冲区上创建对象,new(data) T(value);,其中data表示一个char缓冲区,T表示某种类型。在缓冲区上创建的对象还必须通过~T去析构,因此还需要一个析构vairiant的帮助类:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T, typename... Args>
struct VariantHelper<T, Args...> {
inline static void Destroy(type_index id, void * data) {
if (id == type_index(typeid(T)))
((T*) (data))->~T();
else
VariantHelper<Args...>::Destroy(id, data);
}
};

template<> struct VariantHelper<> {
inline static void Destroy(type_index id, void * data) { }
};

取值问题

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
template<typename T>
typename std::decay<T>::type& Get() {
using U = typename std::decay<T>::type;
if (!Is<U>())
{
cout << typeid(U).name() << " is not defined. " << "current type is " << m_typeIndex.name() << endl;
throw std::bad_cast();
}
return *(U*) (&m_data);
}

template<typename T>
int GetIndexOf() {
return Index<T, Types...>::value;
}

template<typename F>
void Visit(F&& f)
{
  using T = typename function_traits<F>::arg<0>::type;
  if (Is<T>())
    f(Get<T>());
}

template<typename F, typename... Rest>
void Visit(F&& f, Rest&&... rest)
{
  using T = typename function_traits<F>::arg<0>::type;
  if (Is<T>())
    Visit(std::forward<F>(f));
  else
    Visit(std::forward<Rest>(rest)...);
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void TestVariant()
{
typedef Variant<int, char, double> cv;
int x = 10;

cv v =x;
v = 1;
v = 1.123;
v = "";//compile error
v.Get<int>(); //1
v.Get<double>(); //1.23
v.Get<short>(); //exception: short is not defined. current type is int
v.Is<int>();//true
}

ScopeGuard

ScopeGuard的作用是确保资源面对异常时总能被成功释放,就算没有正常返回。惯用法让我们在构造函数里获取资源,当因为异常或者正常作用域结束,那么在析构函数里释放资源。总是能释放资源。如果没有异常抛出则正常结束,只是有异常发生或者没有正常退出时释放资源。

通过局部变量析构函数来管理资源,根据是否正常退出来确定是否需要清理资源。

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
template <typename F>
class ScopeGuard
{
public:
explicit ScopeGuard( F && f) : m_func(std::move(f)), m_dismiss(false){}
explicit ScopeGuard(const F& f) : m_func(f), m_dismiss(false){}

~ScopeGuard() {
if (!m_dismiss && m_func != nullptr)
m_func();
}

ScopeGuard(ScopeGuard && rhs) : m_func(std::move(rhs.m_func)), m_dismiss(rhs.m_dismiss) {rhs.Dismiss();}

void Dismiss() {
m_dismiss = true;
}

private:
F m_func;
bool m_dismiss;

ScopeGuard();
ScopeGuard(const ScopeGuard&);
ScopeGuard& operator=(const ScopeGuard&);
};

template <typename F>
ScopeGuard<typename std::decay<F>::type> MakeGuard(F && f)
{
  return ScopeGuard<typename std::decay<F>::type>(std::forward<F>(f));
}

tuple_helper

std::tuple作为一个泛化的std::pair,它的一个独特特性是能容纳任意个数任意类型的元素。

tuple还需要一些常用操作,比如打印、遍历、根据元素值获取索引位置、反转和应用于函数。

  • 打印:由于tuple中的元素是可变参数模板,外面并不知道内部到底是什么数据,有时调试需要知道其具体值,希望能打印出tuple中所有的元素值。
  • 根据元素值获取索引位置: tuple接口中有根据索引位置获取元素的接口,根据元素值来获取索引位置是相反的做法。
  • 获取索引:在运行期根据索引获取索引位置的元素。
  • 遍历:类似于std::for_each算法,可以将函数对象应用于tuple的每个元素。
  • 反转:将tuple中的元素逆序。
  • 应用于函数:将tuple中的元素进行一定的转换,使之成为函数的入参。

打印tuple

tuple不同于数组和集合,不能通过for循环的方式枚举并打印元素值,需要借助可变参数模板的展开方式来打印出元素值。但是 tuple又不同于可变参数模板不能直接通过展开参数包的方式来展开,因为tuple中的元素需要用std::get<T>(tuple)来获取,展开tuple需要带索引参数。有两种方法可以展开并打印tuple,第一种方法是通过模板类的特化和递归调用结合来展开 tuple;另一种方法是通过一个索引序列来展开tuple。

(1)通过模板特化和递归来展开并打印tuple
因为tuple内部的元素个数和类型是不固定的,如果要打印tuple中的元素,需要在展开tuple时一一打印,展开并打印tuple的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<class Tuple, std::size_t N>
struct TuplerPrinter {
static void print (const Tuple& t) {
TuplerPrinter<Tuple, N - 1 >::print(t);
std::cout << ", " << std::get<N - 1>(t);
}
};

template<class Tuple>
struct TuplerPrinter<Tuple, 1> {
static void print(const Tuple& t) {
std::cout << std::get(0)<t>;
}
};

template<class... Args>
void PrintTuple(const std::tuple<Args...>& t) {
std::cout << "(";
TuplePrinter<decltype(t), sizeof...(Args)>::print(t);
std::cout << ")" << std::endl;
}

模板类TuplePrinter带有一个模板参数std::size_t N,这个N是用来控制递归调用的,每调用一次,这个N就减1,直到减为1为止。 PrintTuple是一个帮助函数,目的是为了更方便地调用TuplePrinter,因为Tupleprinter需要两个参数,一个是tuple,另一个是tuple的size。tuple的size是可以通过sizeof来获取的,在帮助函数中获取tuple的size并调用TuplePrinter,就可以减少外面调用的入参。测试代码如下:

1
2
3
4
void TestPrint() {
std::tuple<int, short, double, char> tp = std: make tuple(1, 2, 3, 'a');
PrintTuple(tp);
}

输出:(1, 2, 3, 'a')

调用过程如下:

1
2
3
4
Tupleprinter<std::tuplecint, short, double, char>, 4>:: print(tp);
TuplePrinter<std::tuple<int, short, double, char>, 3>:: print(tp);
TuplePrintersstd::tuple<int, short, double, char>, 2>:: print(tp);
TuplePrintersstd::tuple<int, short, double, char>, 1>:: print(tp);

当递归终止时,打印第一个元素的值:

1
std::cout << std::get<0>(t);

接着返回上一层递归打印第二个元素:

1
2
3
std::cout << std::get<1>(t);
std::cout << std::get<2>(t);
std::cout << std::get<3>(t);

(2)根据索引序列展开并打印tuple
将tuple变为一个可变参数模板需要一个可变索引序列:

1
2
template<int...>
struct IndexTuple{};

再通过std::get<IndexTuple>(tuple)...来获取参数序列,从而将tuple转换为可变参数模板Args...
先创建一个索引序列,通过这个索引序列来取tuple中对应位置的元素:

1
2
3
4
5
6
7
8
9
10
template<int...>
struct IndexTuple{};

template<int N, int... Indexes>
struct MakeIndexes : MakeIndexes<N-1, N-1, Indexes...>{};

template<int... indexes>
struct MakeIndexes<0, indexes...> {
typedef IndexTuple<indexes...> type;
};

在生成一个元素对应的索引位置序列之后,就可以通过std::get来获取tuple中的所有元素并将其变为可变参数模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
void Print(T t) {
cout << t << endl;
}
template <typename T, typename... Args>
void Print(T t, Args... args) {
cout << t << endl;
Print(args...);
}
template <typename Tuple, int... Indexes>
void Transform(IndexTuple< Indexes... >& in, Tuple& tp) {
Print(get<indexes>(tp)...);
}

int main(){
using Tuple = std::tuple<int, double>;
Tuple tp = std::make_tuple<1, 2>;
Transform(MakeIndexes<std::tuple_size<Tuple>::value>::type(), tp);
}

反转Tuple

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
template<int I, int... Indexes, typename T, typename... Types>
struct make_indexes_reverse_impl<I, IndexTuple<Indexes...>, T, Types...>
{
using type = typename make_indexes_reverse_impl<I-1, IndexTuple<Indexes..., I-1>, Types...>::type;
};

//递归终止
template<int I, int... Indexes>
struct make_indexes_reverse_impl<I, IndexTuple<Indexes...>>
{
using type = IndexTuple<Indexes...>;
};

//类型萃取
//调用方法如:make_indexes<double, char, int>
template<typename... Types>
struct make_reverse_indexes : make_indexes_reverse_impl<sizeof...(Types), IndexTuple<>, Types...>
{};

template<class... Args, int... Indexes>
auto reverse_impl(std::tuple<Args...>&& tup, IndexTuple<Indexes...>&&) ->
decltype(std::make_tuple(std::get<Indexes>(std::forward<std::tuple<Args...>>(tup))...))
{
return std::make_tuple(std::get<Indexes>forward<tuple<Args...>>(tup))...);
}

template<class... Args>
auto tuple_reverse(std::tuple<Args...>&& tup)->
decltype(reverse_impl(std::forward<std::tuple<Args...>>(tup),typename make_reverse_indexes<Args...>::type()))
{
return reverse_impl(std::forward<std::tuple<Args...>>(tup), typename make_reverse_indexes<Args...>::type());
}

应用于函数

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
template<int...>
struct IndexTuple{};

template<int N, int... Indexes>
struct MakeIndexes : MakeIndexes<N-1, N-1, Indexes...>{};

template<int... indexes>
struct MakeIndexes<0, indexes...> {
typedef IndexTuple<indexes...> type;
};

template<typename F, typename Tuple, int... Indexes>
auto apply_helper(F&& f, IndexTuple<Indexes...>&& in, Tuple&& tup)->
decltype(std::forward<F>(f)(std::get<Indexes>(tup)...))
{
return std::forward<F>(f)(std::get<Indexes>(tup)...);
}

void TestF(int a, double b) {
cout << a + b << endl;
}

void Test() {
apply_helper(TestF, MakeIndexes<2>::type(), std::make_tuple(1, 2));
}

输出:3

使用C++11解决内存泄漏的问题

智能指针可以自动删除分配的内存,是存储指向动态分配(堆)对象指针的累,用于生存期控制,能够确保在离开指针所在作用域时能够自动正确地销毁动态分配的对象,防止内存泄漏。它的一种通用实现技术是引用计数,每使用它一次内部的引用计数加一,每析构一次内部的引用计数减一,减为0时,删除所指向的堆内存。

shared_ptr共享的智能指针

shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向同一个内存,在最后一个shared_ptr析构时,内存才被释放。

基本用法

通过构造函数、std::make_shared<T>辅助函数和reset方法来初始化。

1
2
3
4
std::shared_ptr<int> p(new int(1));
std::shared_ptr<int> p2 = p;
std::shared_ptr<int> ptr;
ptr.reset(new int(1));

优先使用make_shared来构造智能指针。
不能将一个原始指针直接赋值给一个智能指针:

1
std::shared_ptr p = new int(1) ;   // 编译报错,不允许直接赋值

通过get方法来返回原始指针

1
2
std::shared_ptr<int> ptr( new int(1) ) ;
int* p = ptr.get() ;

智能指针初始化可以指定删除器

1
2
3
4
void DeleteIntPtr ( int * p ) {
delete p ;
}
std::shared_ptr<int> p( new int , DeleteIntPtr ) ;

当p的引用技术为0时,自动调用删除器来释放对象的内存。删除器也可以是一个lambda表达式,例如:

1
std::shared_ptr<int> p( new int , [](int * p){delete p} ) ;

当我们使用shared_ptr管理动态数组时,需要指定删除器,因为std::shared_ptr默认的删除器不能处理数组对象:

1
std::shared_ptr<int> p(new int[10], [](int* p){delete[] p;});

或者通过封装一个make_shared_array方法来让shared_ptr支持数组:

1
2
3
4
template<typename T>
shared_ptr<T> make_shared_array(size_t size) {
return shared_ptr<T>(new T[size], default_delete<T[]>());
}

不要用一个原始指针初始化多个shared_ptr,以下是错误的。

1
2
3
int* ptr = new int;
shared_ptr<int> p1(ptr);
shared_ptr<int> p2(ptr);

不要在函数实参中创建shared_ptr,在调用函数之前先定义以及初始化它。

不要将this指针作为shared_ptr返回出来,因为this指针是一个裸指针,这样做可能会重复析构。正确返回this的shared_ptr的做法是:让目标类通过派生std::enable_shared_from_this<A>类,然后使用基类的成员函数shared_from_this来返回this的shared_ptr:

1
2
3
4
5
class A : public std::enable_shared_from_this<A> {
std::shared_ptr<A> GetSelf() {
return shared_from_this();
}
}

要避免循环引用,循环引用会导致内存泄漏。

unique_ptr独占的智能指针

unique_ptr是一个独占的智能指针,他不允许其他的智能指针共享其内部的指针,不允许通过赋值将一个unique_ptr赋值给另外一个unique_ptr,虽然不允许复制,但可以通过函数返回给其他的unique_ptr,还可以通过std::move来转移到其他的unique_ptr,这样它本身就不再拥有原来指针的所有权了。

1
2
3
unique_ptr<T> my_ptr(new T);
unique_ptr<T> my_other_ptr = std::move(my_ptr);
unique_ptr<T> ptr = my_ptr; // ERROR

可以自己实现一个make_unique,C++尚未提供这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<class T, class... Args> inline
typename enable_if<!is_array<T>::value, unique_ptr<T>>::type
make_unique(Args&& ... args) {
return unique_ptr<T>(new T(std::forward<Args>(args)...));
}

template<class T> inline
typename enable_if<is_array<T>::value && extent<T>::value==0, unique_ptr<T>>::type
make_unique(size_t size) {
typedef typename remove_extent<T>::type U;
return unique_ptr<T>(new U[size]());
}
template<class T, class... Args>
typename enable_if<extent<T>::value!=0, void>::type
make_unique(Args&&& ...) = delete;

如果不是数组,则直接创建unique_ptr,如果是数组,则先判断是否为定长数组,如果是定长数组则编译不通过,若为非定常数组,则获取数组中的元素类型,再根据入参size创建动态数组的unique_ptr。

unique_ptr还可指向一个数组:

1
2
std::unique_ptr<int []> ptr(new int[10]);
ptr[9] = 9;

unique_ptr指定删除器需要确定删除器的类型:

1
std::unique_ptr<int, void(*)(int*)> ptr(new int(1), [](int* p){ delete p; });

如果lambda表达式没有捕获变量,这样写是对的,因为可以直接转换成函数指针。捕获了变量后:

1
std::unique_ptr<int, std::function<void(int*)>> ptr(new int(1), [&](int* p){ delete p; });

如果希望只有一个智能指针管理资源或管理数组就用unique_ptr,如果希望多个智能指针管理同一个资源就用shared_ptr。

weak_ptr弱引用的智能指针

弱引用的智能指针weak_ptr是用来监视shared_ptr的,不会使引用计数加一,它不管理shared_ptr内部的指针,主要是为了监视shared_ptr的生命周期,更像是shared_ptr的一个助手。

weak_ptr没有重载运算符*->,因为它不共享指针,不能操作资源,主要是为了通过shared_ptr获得资源的监测权,它的构造不会增加引用计数,它的析构不会减少引用计数,纯粹只是作为一个旁观者来监视shared_ptr中管理的资源是否存在。weak_ptr还可以用来返回this指针和解决循环引用的问题。

基本用法

通过use_count()获得当前观测资源的引用计数:

1
2
3
4
shared_ptr<int> sp(new int(10));
weak_ptr<int> wp(sp);

cout << wp.use_count() << endl;

通过expired()方法判断观测的资源是否已经释放:

1
2
3
4
shared_ptr<int> sp(new int(10));
weak_ptr<int> wp(sp);
if (wp.expired())
cout << "weak_ptr useless" << endl;

通过lock方法来获取所监视的shared_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::weak_ptr<int> gw;
void f() {
if (gw.expired()) {
cout << "already expired" << endl;
}
else {
auto spt = gw.lock();
cout << *spt << endl;
}
}

int main() {
{
auto sp = std::make_shared<int>(43);
gw = sp;
f(); // 43
}
f(); // already expired
}

之前提到不能直接将this指针返回为shared_ptr,需要通过派生std::enable_shared_from_this类,并通过其方法shared_from_this来返回智能指针,原因是std::enable_shared_from_this类中有一个weak_ptr,这个weak_ptr用来观测this智能指针,调用shared_from_this()方法时,会调用内部这个weak_ptrlock()方法,将所观测的shared_ptr返回。

1
2
3
4
5
6
7
8
9
10
struct A : public std::enable_shared_from_this<A> {
std::shared_ptr<A> Getself() {
return shared_from_this();
}
~A() {
cout << "A is delete" << endl;
}
};
std::shared_ptr<A> spy(newA);
std::shared_ptr<A> p = spy->Getself();

解决循环引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A;
struct B;
struct A {
std::shared_ptr<B> bptr;
~A() { cout << "A is deleted!" << endl; }
}
struct B {
std::shared_ptr<A> aptr;
~B() { cout << "B is deleted!" << endl; }
}
void TestPtr() {
{
std::shared_ptr<A> ap(new A);
std::shared_ptr<B> bp(new B);
ap->bptr = bp;
bp->aptr = ap;
} // Objects should be destroyed
}

在这个例子中,由于循环引用导致ap和bp的引用计数都是2,离开作用域后减为1,不会去删除指针,导致内存泄漏,通过weak_ptr解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A;
struct B;
struct A {
std::shared_ptr<B> bptr;
~A() { cout << "A is deleted!" << endl; }
}
struct B {
std::weak_ptr<A> aptr; // 改为weak_ptr
~B() { cout << "B is deleted!" << endl; }
}
void TestPtr() {
{
std::shared_ptr<A> ap(new A);
std::shared_ptr<B> bp(new B);
ap->bptr = bp;
bp->aptr = ap;
} // Objects should be destroyed
}

通过智能指针管理第三方库分配的内存

第三方库分配的内存一般需要通过第三方库提供的释放接口才能释放,由于第三方库返回的指针一般都是原始指针,用完之后如果没有调用第三方库的释放接口,就很容易造成内存泄露。

1
2
3
void *p = GetHandle()->Create();
//do something
GetHandle()->Realease(p);

用智能指针来管理第三方库的内存就比较方便,不用担心中途返回或者发生异常导致无法调用释放接口的问题。

1
2
3
void *p = GetHandle()->Create();
//do something
std::shared_ptr<void> sp(p, [this](void* p) {GetHandle()->Realease(p); });

将其提炼成函数

1
2
3
4
5
6
7
8
9
10
std::shared_ptr<void> Guard(void*p)
{
std::shared_ptr<void> sp(p, [this](void* p) {GetHandle()->Realease(p); });
return sp;
}

//在使用时
void* p = GetHandle()->Create();
Guard(p); //危险,这句结束后p就被释放了
//do something

执行Guard();这句后,函数返回的是一个右值,没有被存储,用完就把p释放了。

可以用宏的方式来解决这个问题:

1
2
3
4
5
#define GUARD(p) std::shared_ptr<void> p##p(p, [](void *p){release(p);})

//使用时
void* p = GetHandle()->Create();
GUARD(p); //安全

也可以用unique_ptr来管理第三方的内存:

1
#define GUARD(p) std::unique_ptr<void> p##p(p, [](void *p){release(p);})

对于宏中的##,其实也很好理解,就是将##前后的字符串连接起来

1
2
3
4
5
6
7
#define GUARD(p) std::shared_ptr<void> p##p(p, [](void *p){release(p);})

//使用时
void* p = GetHandle()->Create();
GUARD(p); //安全
//会有一个std::shared_ptr<void> pp的智能指针,不信就进行测试。原因去找刚才的#define中有p##p
std::cout << pp.use_count() << std::endl;

为了验证原作者的这些,写一些demo来帮助理解,也有利于更好掌握:
创建一个Base类:
Base.h文件中:

1
2
3
4
5
6
7
8
9
#pragma once
class Base
{
public:
Base();
~Base();

void print();
};

Base.cpp文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "Base.h"
#include <iostream>

Base::Base()
{
std::cout << "Base constructor" << std::endl;
}

Base::~Base()
{
std::cout << "Base desctructor" << std::endl;
}

void Base::print()
{
std::cout << "print something" << std::endl;
}

在main.cpp中:

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
#include "Base.h"
#include <memory>
#include <iostream>

#define GUARD(p) std::shared_ptr<Base> p##p(p, [](Base*p){release(p);})

Base* create()
{
return new Base();
}

void release(Base* base)
{
delete base;
}

std::shared_ptr<Base> Guard(Base *p)
{
std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
return sp;
}

int main()
{
{
Base* p = create();

std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
//Guard(p);

//GUARD(p);
//std::cout << "sp.use_count():" << sp.use_count() << std::endl;;
p->print();
}

getchar();
return 0;
}

此时的输出为:

1
2
3
Base constructor
print something
Base desctructor

【修改一】 当我们对main()中修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
{
Base* p = create();

//std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
Guard(p);

//GUARD(p);
//std::cout << "sp.use_count():" << sp.use_count() << std::endl;;
p->print();
}

getchar();
return 0;
}

运行结果:

1
2
3
Base constructor
Base desctructor
print something

发现这时候的p被提前释放了,print something已经是在Base类析构之后做的,此时已经出问题了。

【修改二】将main函数进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
{
Base* p = create();

//std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
//Guard(p);

GUARD(p);
std::cout << "pp.use_count(): " << pp.use_count() << std::endl;;
p->print();
}

getchar();
return 0;
}

运行结果:

1
2
3
4
Base constructor
pp.use_count(): 1
print something
Base desctructor

果然如我们所想,一切正常。

使用C++11让多线程开发变得简单

线程

std::thread创建线程非常简单,只需要提供线程函数或者函数对象即可。

1
2
3
4
5
6
7
8
9
10
11
#include <thread>
#include <iostream>
using namespace std;
void func() {
cout << "thread test" << endl;
}
int main() {
thread t(func);
t.join();
return 0;
}

函数func会运行于线程对象t中,join函数会阻塞线程,直到线程函数执行结束,如果线程函数有返回值,返回值被忽略。如果不希望线程被阻塞执行,调用detach将线程和线程对象分离,让线程作为后台线程去执行,当前线程也不会阻塞了。需要注意的是detach()之后就无法再和线程发生联系了,比如detach之后就不能通过join来等待线程执行完,线程何时执行完我们也无法控制了。

线程可以接受任意个数的参数。

1
2
3
4
5
6
7
8
9
void func(int i, double d, const std::string& s) {
std::cout << i << d << s << endl;
}

int main() {
std::thread t(func, 1, 2.0, "heoo");
t.join();
return 0;
}

std::thread出了作用域后会析构,保证线程函数的生命周期在线程变量的生命周期之内

线程不能复制,但是可以移动:

1
2
3
4
5
6
int main() {
std::thread t(func);
std::thread t1(std::move(t));
t.join(); // error
t1.join();
}

线程被移动之后,线程对象t就不再代表任何线程。另外可以通过std::bind和lambda表达式来创建线程:

1
2
3
4
int main() {
std::thread t(std::bind(func));
std::thread t1([](int a, double b){}, 1, 2);
}

可以将线程存放到容器中,保证线程对象的生命周期:

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

vector<thread> g_list;
vector<shared_ptr<thread>> g_list2;

void CreateThread() {
thread t(func);
g_list.push_back(move(t));
g_list2.push_back(make_shared<thread>(func));
}
int main() {
CreateThread();
for (auto& thread : g_list) {
thread.join();
}
for (auto& thread : g_list2) {
thread->join();
}
return 0;
}

线程可以获取当前线程的ID,还可以获取CPU核心数量:

1
2
3
4
5
6
7
8
9
10
void func() {}
int main() {
thread t1(func);
cout << t1.get_id() << endl;

t1.join();
cout << t1.get_id() << endl;//获取当前线程id,0,表示已经执行结束了.
cout << std::thread::hardware_concurrency() << endl;//8核
return 0;
}

互斥量

互斥量是一种同步原语,是一种线程同步的手段,用来保护多线程同时访问的共享数据。

  • std::mutex: 独占的互斥量,不能递归使用.
  • std::timed_mutex: 带超时的独占互斥量,不能递归使用.
  • std::recursive_mutex: 递归互斥量,不带超时功能.
  • std::recursive_timed_mutex: 带超时的递归互斥量.

这些互斥量的基本接口十分相近,都是通过lock()来阻塞线程,直到获得互斥量的所有权为止。在线程获得互斥量并完成任务后,就必须使用unlock()来解除对互斥量的占用,lock和unlock必须成对出现。try_lock()尝试锁定互斥量,成功返回true,失败返回false,他是非阻塞的。

1
2
3
4
5
6
7
8
9
10
11
12
std::mutex g_lock;

void lock_unlock()
{
//上锁
g_lock.lock();
cout << "in id: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::seconds(1));
cout << "out id: " << this_thread::get_id() << endl;
//解锁
g_lock.unlock();
}

使用lock_guard可以简化lock/unlock的写法,因为lock_guard在构造时可以自动锁定互斥量,在退出作用域后进行析构时会自动解锁,从而保证了互斥量的正确操作。

1
2
3
4
5
6
7
8
void f_lock_guard()
{
//lock_guard在构造时会自动锁定互斥量,而在退出作用域后进行析构时就会自动解锁.
lock_guard<std::mutex> lock(g_lock);
cout << "in id: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::seconds(1));
cout << "out id: " << this_thread::get_id() << endl;
}

递归的独占互斥量std::recursive_mutex允许同一线程多次获得该互斥锁,可以用来解决同一线程需要多次获取互斥量时死锁的问题,来获得对互斥量对象的多层所有权,std::recursive_mutex 释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),可理解为 lock() 次数和 unlock() 次数相同,除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Complex {
std::recursive_mutex mutex;
int i;
Complex() : i(0) {}
void mul(int x) {
std::lock_guard<std::recursive_mutex> lock(mutex);
i *= x;
}
void div(int x) {
std::lock_guard<std::recursive_mutex> lock(mutex);
i /= x;
}
void both(int x, int y) {
std::lock_guard<std::recursive_mutex> lock(mutex);
// 因为同一线程可以多次获取同一互斥量,不会发生死锁。
mul(x);
div(y);
}
}

尽量不要使用递归锁,因为:

  • 需要用到递归锁定的多线程互斥处理往往本身就是可以简化的,允许递归互斥很容易放纵复杂逻辑的产生,从而导致一些多线程同步引起的晦涩问题
  • 递归锁比起非递归锁,效率会低一些。
  • 递归锁虽然允许同一个线程多次获得同一个互斥量,可重复获得的最大次数并未具体说明,一旦超过一定次数,再对lock进行调用就会抛出std::system错误。

带超时的互斥量std::timed_mutexstd::recursive_timed_mutexstd::timed_mutex是超时的独占锁,std::recursive_timed_mutex是超时的递归锁,主要用在获取锁时增加超时等待功能,因为有时不知道获取锁需要多久,为了不至于一直在等待获取互斥量,就设置一个等待超时时间,在超时后还可以做其他的事情。

std::timed_mutexstd::mutex多了两个超时获取锁的接口:try_lock_fortry_lock_until,这两个接口是用来设置获取互斥量的超时时间,使用时可以用一个while循环去不断地获取互斥量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::timed_mutex mutex;
void work() {
std::chrono::milliseconds timeout(100);
while (true) {
if (mutex.try_lock_for(timeout)) {
std::cout << std::this_thread::get_id() << ": do work with the mutex" << endl;
std::chrono::milliseconds sleepDuration(250);
std::this_thread::sleep_for(sleepDuration);
mutex.unlock();
std::this_thread_sleep_for(sleepDuration);
}
else {
std::cout << std::this_thread::get_id() << ": do work without the mutex" << endl;
std::chrono::milliseconds sleepDuration(100);
std::this_thread::sleep_for(sleepDuration);
}

条件变量

<condition_variable>头文件主要包含了与条件变量相关的类和函数。相关的类包括std::condition_variablestd::condition_variable_any,还有枚举类型std::cv_status。另外还包括函数std::notify_all_at_thread_exit()

condition_variable配合std::unique_lock<std::mutex>进行wait操作。condition_variable_any,和任意带有lock、unlock语义的mutex搭配使用,比较灵活,但效率比condition_variable差一些。条件变量的使用过程如下:

  • 拥有条件变量的线程获取互斥量。
  • 循环检查某个条件,如果条件不满足,则阻塞直到条件满足;如果条件满足,则向下执行。
  • 某个线程满足条件执行完之后调用notify_onenotify_all唤醒一个或者所有的等待线程。
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
template<typename T> 
class SyncQueue {
bool isFull() const {
return m_queue.size() == m_maxSize;
}
bool isEmpty() const {
return m_queue.empty();
}
public:
SyncQueue(int maxSize) : m_maxSize(maxSize) {}
void Put(const T& x) {
std::lock_guard<std::mutex> locker(m_mutex);
while(isFull()){
m_notFull.wait(m_mutex);
}
m_queue.push_back(x);
m_notEmpty.notify_one();
}

void Take(T& x) {
std::lock_guard<std::mutex> locker(m_mutex);
while(isEmpty()){
m_notEmpty.wait(m_mutex);
}
x = m_queue.front();
m_queue.pop_front();
m_notFull.notify_one();
}

bool Empty() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.empty();
}

bool Full() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size() == m_maxSize;
}

size_t Size() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size();
}

int Count() {
return m_queue.size();
}

private:
std::list<T> m_queue;
std::mutex m_mutex;
std::condition_variable_any m_notEmpty;
std::condition_variable_any m_notFull;
int m_maxSize;
}

这个同步队列在没有满的情况下可以插入数据,如果满了,则会调用m_notFull阻塞等待,待消费线程取出数据之后发一个未满的通知,然后前面阻塞的线程就会被唤醒继续往下执行;如果队列为空,就不能取数据,会调用m_notEmpty条件变量阻塞,等待插入数据的线程发出不为空的通知时,才能继续往下执行。以上过程是同步队列的工作过程。

std::condition_variable对象的某个 wait 函数被调用的时候,它使用std::unique_lock(通过std::mutex) 来锁住当前线程。当前线程会一直被阻塞,直到另外一个线程在相同的std::condition_variable对象上调用了 notification 函数来唤醒当前线程。

std::condition_variable对象通常使用std::unique_lock<std::mutex>来等待,如果需要使用另外的lockable类型,可以使用std::condition_variable_any类,本文后面会讲到std::condition_variable_any的用法。

首先我们来看一个简单的例子

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
#include <iostream>                // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx; // 全局互斥锁.
std::condition_variable cv; // 全局条件变量.
bool ready = false; // 全局标志位.

void do_print_id(int id)
{
std::unique_lock <std::mutex> lck(mtx);
while (!ready) // 如果标志位不为 true, 则等待...
cv.wait(lck); // 当前线程被阻塞, 当全局标志位变为 true 之后,
// 线程被唤醒, 继续往下执行打印线程编号id.
std::cout << "thread " << id << '\n';
}

void go()
{
std::unique_lock <std::mutex> lck(mtx);
ready = true; // 设置全局标志位为 true.
cv.notify_all(); // 唤醒所有线程.
}

int main()
{
std::thread threads[10];
// spawn 10 threads:
for (int i = 0; i < 10; ++i)
threads[i] = std::thread(do_print_id, i);

std::cout << "10 threads ready to race...\n";
go(); // go!

for (auto & th:threads)
th.join();

return 0;
}

执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
10 threads ready to race...
thread 1
thread 0
thread 2
thread 3
thread 4
thread 5
thread 6
thread 7
thread 8
thread 9

好了,对条件变量有了一个基本的了解之后,我们来看看std::condition_variable的各个成员函数。

std::condition_variable提供了两种 wait() 函数。当前线程调用 wait() 后将被阻塞(此时当前线程应该获得了锁(mutex),不妨设获得锁 lck),直到另外某个线程调用notify_*唤醒了当前线程。

在线程被阻塞时,该函数会自动调用lck.unlock()释放锁,使得其他被阻塞在锁竞争上的线程得以继续执行。另外,一旦当前线程获得通知(notified,通常是另外某个线程调用notify_*唤醒了当前线程),wait() 函数也是自动调用lck.lock(),使得 lck 的状态和 wait 函数被调用时相同。

在第二种情况下(即设置了 Predicate),只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞。因此第二种情况类似以下代码:

1
while (!pred()) wait(lck);

请看下面例子(参考):

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
#include <iostream>                // std::cout
#include <thread> // std::thread, std::this_thread::yield
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx;
std::condition_variable cv;

int cargo = 0;
bool shipment_available()
{
return cargo != 0;
}

// 消费者线程.
void consume(int n)
{
for (int i = 0; i < n; ++i) {
std::unique_lock <std::mutex> lck(mtx);
cv.wait(lck, shipment_available);
std::cout << cargo << '\n';
cargo = 0;
}
}

int main()
{
std::thread consumer_thread(consume, 10); // 消费者线程.

// 主线程为生产者线程, 生产 10 个物品.
for (int i = 0; i < 10; ++i) {
while (shipment_available())
std::this_thread::yield();
std::unique_lock <std::mutex> lck(mtx);
cargo = i + 1;
cv.notify_one();
}

consumer_thread.join();

return 0;
}

程序执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
concurrency ) ./ConditionVariable-wait 
1
2
3
4
5
6
7
8
9
10

std::condition_variable::wait_for() 介绍

unconditional (1):

1
2
3
template <class Rep, class Period>
cv_status wait_for (unique_lock<mutex>& lck,
const chrono::duration<Rep,Period>& rel_time);

predicate (2)

1
2
3
template <class Rep, class Period, class Predicate>
bool wait_for (unique_lock<mutex>& lck,
const chrono::duration<Rep,Period>& rel_time, Predicate pred);

与 std::condition_variable::wait() 类似,不过 wait_for 可以指定一个时间段,在当前线程收到通知或者指定的时间 rel_time 超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其他线程的通知,wait_for 返回,剩下的处理步骤和 wait() 类似。

另外,wait_for 的重载版本(predicte(2))的最后一个参数 pred 表示 wait_for 的预测条件,只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞,因此相当于如下代码:

1
return wait_until (lck, chrono::steady_clock::now() + rel_time, std::move(pred));

请看下面的例子(参考),下面的例子中,主线程等待 th 线程输入一个值,然后将 th 线程从终端接收的值打印出来,在 th 线程接受到值之前,主线程一直等待,每个一秒超时一次,并打印一个 “.”:

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
#include <iostream>           // std::cout
#include <thread> // std::thread
#include <chrono> // std::chrono::seconds
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable, std::cv_status

std::condition_variable cv;

int value;

void do_read_value()
{
std::cin >> value;
cv.notify_one();
}

int main ()
{
std::cout << "Please, enter an integer (I'll be printing dots): \n";
std::thread th(do_read_value);

std::mutex mtx;
std::unique_lock<std::mutex> lck(mtx);
while (cv.wait_for(lck,std::chrono::seconds(1)) == std::cv_status::timeout) {
std::cout << '.';
std::cout.flush();
}

std::cout << "You entered: " << value << '\n';

th.join();
return 0;
}

std::condition_variable::wait_until 介绍

unconditional (1)

1
2
3
template <class Clock, class Duration>
cv_status wait_until (unique_lock<mutex>& lck,
const chrono::time_point<Clock,Duration>& abs_time);

predicate (2)

1
2
3
4
template <class Clock, class Duration, class Predicate>
bool wait_until (unique_lock<mutex>& lck,
const chrono::time_point<Clock,Duration>& abs_time,
Predicate pred);

std::condition_variable::wait_for类似,但是 wait_until 可以指定一个时间点,在当前线程收到通知或者指定的时间点 abs_time 超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其他线程的通知,wait_until 返回,剩下的处理步骤和 wait_until() 类似。

另外,wait_until 的重载版本(predicte(2))的最后一个参数 pred 表示 wait_until 的预测条件,只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞,因此相当于如下代码:

1
2
3
4
while (!pred())
if ( wait_until(lck,abs_time) == cv_status::timeout)
return pred();
return true;

std::condition_variable::notify_one() 介绍
唤醒某个等待(wait)线程。如果当前没有等待线程,则该函数什么也不做,如果同时存在多个等待线程,则唤醒某个线程是不确定的(unspecified)。

请看下例(参考):

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>                // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx;
std::condition_variable cv;

int cargo = 0; // shared value by producers and consumers

void consumer()
{
std::unique_lock < std::mutex > lck(mtx);
while (cargo == 0)
cv.wait(lck);
std::cout << cargo << '\n';
cargo = 0;
}

void producer(int id)
{
std::unique_lock < std::mutex > lck(mtx);
cargo = id;
cv.notify_one();
}

int main()
{
std::thread consumers[10], producers[10];

// spawn 10 consumers and 10 producers:
for (int i = 0; i < 10; ++i) {
consumers[i] = std::thread(consumer);
producers[i] = std::thread(producer, i + 1);
}

// join them back:
for (int i = 0; i < 10; ++i) {
producers[i].join();
consumers[i].join();
}

return 0;
}

std::condition_variable::notify_all() 介绍
唤醒所有的等待(wait)线程。如果当前没有等待线程,则该函数什么也不做。请看下面的例子:

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
#include <iostream>                // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx; // 全局互斥锁.
std::condition_variable cv; // 全局条件变量.
bool ready = false; // 全局标志位.

void do_print_id(int id)
{
std::unique_lock <std::mutex> lck(mtx);
while (!ready) // 如果标志位不为 true, 则等待...
cv.wait(lck); // 当前线程被阻塞, 当全局标志位变为 true 之后,
// 线程被唤醒, 继续往下执行打印线程编号id.
std::cout << "thread " << id << '\n';
}

void go()
{
std::unique_lock <std::mutex> lck(mtx);
ready = true; // 设置全局标志位为 true.
cv.notify_all(); // 唤醒所有线程.
}

int main()
{
std::thread threads[10];
// spawn 10 threads:
for (int i = 0; i < 10; ++i)
threads[i] = std::thread(do_print_id, i);

std::cout << "10 threads ready to race...\n";
go(); // go!

for (auto & th:threads)
th.join();

return 0;
}

std::condition_variable_any 介绍
std::condition_variable类似,只不过std::condition_variable_any的 wait 函数可以接受任何 lockable 参数,而std::condition_variable只能接受std::unique_lock<std::mutex>类型的参数,除此以外,和std::condition_variable几乎完全一样。

std::cv_status 枚举类型介绍

cv_status::no_timeout:wait_for 或者 wait_until 没有超时,即在规定的时间段内线程收到了通知。
cv_status::timeout:wait_for 或者 wait_until 超时。

std::notify_all_at_thread_exit
函数原型为:

1
void notify_all_at_thread_exit (condition_variable& cond, unique_lock<mutex> lck);

当调用该函数的线程退出时,所有在 cond 条件变量上等待的线程都会收到通知。请看下例(参考):

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
#include <iostream>           // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void print_id (int id) {
std::unique_lock<std::mutex> lck(mtx);
while (!ready) cv.wait(lck);
// ...
std::cout << "thread " << id << '\n';
}

void go() {
std::unique_lock<std::mutex> lck(mtx);
std::notify_all_at_thread_exit(cv,std::move(lck));
ready = true;
}

int main ()
{
std::thread threads[10];
// spawn 10 threads:
for (int i=0; i<10; ++i)
threads[i] = std::thread(print_id,i);
std::cout << "10 threads ready to race...\n";

std::thread(go).detach(); // go!

for (auto& th : threads) th.join();

return 0;
}

原子变量

C++11提供了一个原子类型std::atomic,可以使用任意类型作为模板参数,C++11内置了整型的原子变量,可以更方便地使用原子变量,使用原子变量就不需要使用互斥量来保护该变量了,因为对该变量的操作保证其是原子的,是不可中断的。

1
2
3
4
5
6
7
8
9
10
11
12
13
int value;
std::mutex mutex;
void increment() {
std::lock_guard<std::mutex> lock(mutex);
++value;
}
void decrement() {
std::lock_guard<std::mutex> lock(mutex);
--value;
}
void get() {
return value;
}

可以改成:

1
2
3
4
5
6
7
8
9
10
std::atmoic<int> value;
void increment() {
++value;
}
void decrement() {
--value;
}
void get() {
return value.load();
}

call_once/once_flag的使用

为了保证在多线程环境中某个函数仅被调用一次,比如,需要初始化某个对象,而这个对象只能初始化一次时,就可以用std::call_once来保证函数在多线程环境中只被调用一次。使用std::call_once时,需要一个once_flag作为call_one的入参,它的用法比较简单。

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

std::once_flag flag;

void do_once()
{
std::call_once(flag,[]{std::cout<<"Called once"<<endl;});
}


int main()
{
std::thread t1(do_once);
std::thread t2(do_once);
std::thread t3(do_once);

t1.join();
t2.join();
t3.join();
}

运行结果:

1
Called once

异步操作类

C++11 提供了异步操作相关的类:

  • std::future作为异步结果的传输通道,用于获取线程函数的的返回值;
  • std::promise用于包装一个值,将数据和future绑定起来,方便线程赋值;
  • std::package_task将函数和future绑定起来,以便异步调用。

std::future

thread库提供了future用来访问异步操作的结果,因为一个异步操作的结果不能马上获取,只能在未来某个时候从某个地方获取,这个异步操作的结果是一个未来的期待值,所以被称为future,future提供了获取异步操作结果的通道。可以以同步等待的方式获取结果,可以通过查询future的状态(future_status)来获取异步操作的结果。future_status有如下3种状态:

  • Deferred:异步操作还没开始
  • Ready:异步操作已经完成
  • Timeout:异步操作超时

我们可以查询future状态,通过它内部的状态可以知道异步任务的执行情况:

1
2
3
4
5
6
7
std::future_status status;
do{
status=future.wait_for(std::chrono::seconds(1));
if(status==std::future_status::deferred){}
else if(status==std::future_status::timeout){}
else if(status==std::future_status::ready){}
}while(status!=std::future_status::ready);

获取future结果有三种方式:

  • get: 等待异步操作结束并返回结果
  • wait:只是等待异步操作完成,没有返回值
  • wait_for:是超时等待返回结果

std::promise

std::promise将数据和future绑定起来,在线程函数中为外面传进来的promise赋值,在线程函数执行完之后就可以通过promise的future获取该值了。取值是间接地通过promise内部提供的future来获取的。

1
2
3
4
std::promise<int> pr;
std::thread t([](std::promise<int> &p){p.set_value_at_thread_exit(9);},std::ref(pr));
std::future<int> f=pr.get_future();
auto f=f.get();

std::packaged_task

std::packaged_task包装了一个可调用对象的包装类(如function、lambda expression、bind expression和another function object),将函数和future绑定起来,以便异步调用,它和std::promise在某种程度上有点像,promise保存了一个共享状态的值,而packaged_task保存的是一个函数。

1
2
3
4
std::packaged_task<int()> task([](){return 7;});
std::thread t1(std::ref(task));
std::future<int> f1=task.get_future();
auto r1=f1.get();

std::promisestd::packaged_taskstd::future三者之间的关系

std::future提供了一个访问异步操作结果的机制,它和线程是一个级别的,属于低层次的对象。std::promisestd::packaged_task,它们内部都有future以便访问异步操作结果,std::packaged_task包装的是一个异步操作,而std::promise包装的是一个值,都是为了方便异步操作的返回值。

std::promise:需要获取线程中的某个值
std::packaged_task:需要获取一个异步操作的返回值

future被promise和packaged_task用来作为异步操作或者异步结果的连接通道,用std::futurestd::shared_future来获取异步调用的结果。future是不可拷贝的,只能移动,shared_future是可以拷贝的,当需要将future放到容器中则需要用shared_future。

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
#include <iostream>
#include <utility>
#include <future>
#include <thread>
#include <vector>
#include <algorithm>
#include <cassert>
#include <random>

namespace parallel
{
template <class InputIt, class T>
InputIt find(InputIt first, InputIt last, const T& value)
{
/*
* 计算合适的线程数
* std::thread::hardware_concurrency()用于返回当前系统支持的并发数
*/
auto count = std::distance(first, last);
auto avaThreadNums = std::thread::hardware_concurrency();
auto perThreadMinNums = 20;
auto maxThreadNums = ((count + (perThreadMinNums - 1)) & (~(perThreadMinNums - 1))) / perThreadMinNums;
auto threadNums =
avaThreadNums == 0 ?
maxThreadNums :
std::min(static_cast<int>(maxThreadNums), static_cast<int>(avaThreadNums));
auto blockSize = count / threadNums;

/* 主线程创建std::promise实例,模板参数是返回值类型 */
std::promise<InputIt> result;
/* 因为不同线程会并发查找,当一个线程找到后其他线程就可以停止查找了,原子变量done用于标记是否找到 */
std::atomic<bool> done(false);
{
std::vector<std::thread> threads;
auto front = first;
for (int i = 0; i < threadNums; ++i)
{
auto back = front;
if (i != threadNums - 1)
std::advance(back, blockSize);
else
back = last;
threads.emplace_back(
[front, back, &value, &result, &done]
{
/* 当一个线程找到后所有线程都会退出,通过done标记管理 */
for (auto it = front; !done && it != back; ++it)
{
if (*it == value)
{
done.store(true);
/* 如果找到,记录找到的值 */
result.set_value(it);
return;
}
}
}
);
}
/* 回收线程资源 */
for (auto &th : threads)
th.join();
}
/* 通过std::promise::get_future获得std::future对象,然后调用get获取结果 */
return done ? result.get_future().get() : last;
}
}

int main()
{
std::vector<int> v(100000000);
int n = 0;
std::generate(v.begin(), v.end(), [&n] { return ++n; });
auto value = std::random_device()() % 65536;
auto it1 = parallel::find(v.begin(), v.end(), value);
auto it2 = std::find(v.begin(), v.end(), value);
assert(it1 == it2);
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
#include <iostream>
#include <utility>
#include <future>
#include <thread>
using namespace std;

int func(int x) { return x + 2; }

int main() {
packaged_task<int(int)> tsk(func);
future<int> fut = tsk.get_future();

thread(move(tsk), 2).detach();

int value = fut.get();
cout << "The result is " << value << endl;

vector<shared_future<int>> v;
auto f = async(launch::async, [](int a, int b){return a + b;}, 2, 3);
v.push_back(f);
cout << "The shared_future result is " << v[0].get() << endl;

return 0;
}

输出:

1
2
The result is 4
The shared_future result is 5

线程异步操作函数async

std::asyncstd::promisestd::package_taskstd::thread更上层,它可以用来直接创建异步的task,异步任务返回的结果保存在future中,当需要获取线程执行的结果,可以通过future.get()来获取,如果不关注异步任务的结果,只是简单的等待任务执行完成,则调用future.wait()即可。

std::async是更高层次的异步操作,使我们不关心线程创建的内部细节,就能方便的获取线程异步执行的结果,还可以指定线程创建策略,更多的时候应该使用 std::async来创建线程,成为异步操作的首选。

std::async原型为

1
std::async(std::launch::async | std::launch::deferred,f,args...)

第一个参数为线程的创建策略,第二个为线程函数,其他的为线程函数的参数。

关于创建策略有两种:

  • std::launch::async:在调用async就开始创建线程;
  • std::launch::deferred:延迟加载的方式创建线程,调用async的时候不创建线程,直到调用了future的get或者wait方法来创建线程。
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 <thread>    
#include <iostream>
#include <mutex>
#include <future>

int main()
{
std::future<int> f1 = std::async(std::launch::async, [](){ return 8; });
std::cout << f1.get() << std::endl; //output 8

std::future<void> f2 = std::async(std::launch::async, [](){ std::cout << 8 << std::endl; });
f2.wait(); //output 8

std::future<int> f3 = std::async(std::launch::async, []()
{
std::this_thread::sleep_for(std::chrono::seconds(3));
return 8;
});

std::cout << "Wating..." << std::endl;
std::future_status status;
do
{
status = f3.wait_for(std::chrono::seconds(1));
if (status == std::future_status::deferred)
{
std::cout << "deferred." << std::endl;
}
else if (status == std::future_status::timeout)
{
std::cout << "timeout." << std::endl;
}
else
{
std::cout << "ready." << std::endl;
}
} while (status != std::future_status::ready);
std::cout << "result:" << f3.get() << std::endl;

return 0;
}
//执行结果:
8
8
Wating...
timeout.
timeout.
ready.
result:8
  • 线程的创建和使用简单方便,可以通过多种方式创建,还可以根据需要获取线程的一些信息及休眠线程。
  • 互斥量可以通过多种方式来保证线程安全,既可以用独占的互斥量保证线程安全,又可以通过递归的互斥量来保护共享资源以避免死锁,还可以设置获取互斥量的超时时间,避免一直阻塞等待。
  • 条件变量提供了另外一种用于等待的同步机制,它能阻塞一个或多个线程,直到收到另外一个线程发出的通知或者超时,才会唤醒当前阻塞的线程。条件变量的使用需要配合互斥量。
  • 原子变量可以更方便地实现线程保护。
  • call_once保证在多线程情况下函数只被调用一次,可以用在在某些只能初始化一次的场景中。
  • future、promise和std::package_task用于异步调用的包装和返回值。
  • async更方便地实现了异步调用,应该优先使用async取代线程的创建。

使用C++11中的便利工具

处理日期和时间的chrono库

chrono库主要包含了三种类型:时间间隔Duration、时钟Clocks和时间点Time point。

记录时长的duration

duration表示一段时间间隔,用来记录时间长度,可以表示几秒钟、几分钟或者几个小时的时间间隔,duration的原型是:

1
template<class Rep, class Period = std::ratio<1>> class duration;

第一个模板参数Rep是一个数值类型,表示时钟个数;第二个模板参数是一个默认模板参数std::ratio,它的原型是:

1
template<std::intmax_t Num, std::intmax_t Denom = 1> class ratio;

它表示每个时钟周期的秒数,其中第一个模板参数Num代表分子,Denom代表分母,分母默认为1,ratio代表的是一个分子除以分母的分数值,比如ratio<2>代表一个时钟周期是两秒,ratio<60>代表了一分钟,ratio<60*60>代表一个小时,ratio<60*60*24>代表一天。而ratio<1, 1000>代表的则是1/1000秒即一毫秒,ratio<1, 1000000>代表一微秒,ratio<1, 1000000000>代表一纳秒。标准库为了方便使用,就定义了一些常用的时间间隔,如时、分、秒、毫秒、微秒和纳秒,在chrono命名空间下,它们的定义如下:

1
2
3
4
5
6
typedef duration <Rep, ratio<3600,1>> hours;
typedef duration <Rep, ratio<60,1>> minutes;
typedef duration <Rep, ratio<1,1>> seconds;
typedef duration <Rep, ratio<1,1000>> milliseconds;
typedef duration <Rep, ratio<1,1000000>> microseconds;
typedef duration <Rep, ratio<1,1000000000>> nanoseconds;

通过定义这些常用的时间间隔类型,我们能方便的使用它们,比如线程的休眠:

1
2
std::this_thread::sleep_for(std::chrono::seconds(3)); //休眠三秒
std::this_thread::sleep_for(std::chrono::milliseconds (100)); //休眠100毫秒

chrono还提供了获取时间间隔的时钟周期个数的方法count(),它的基本用法:

1
2
3
4
5
6
7
8
9
10
11
#include <chrono>
#include <iostream>
int main()
{
std::chrono::milliseconds ms{3}; // 3 毫秒
// 6000 microseconds constructed from 3 milliseconds
std::chrono::microseconds us = 2*ms; //6000微秒
// 30Hz clock using fractional ticks
std::chrono::duration<double, std::ratio<1, 30>> hz30(3.5);
std::cout << "3 ms duration has " << ms.count() << " ticks\n"<< "6000 us duration has " << us.count() << " ticks\n"
}

输出:

1
2
3 ms duration has 3 ticks
6000 us duration has 6000 ticks

时间间隔之间可以做运算,比如下面的例子中计算两端时间间隔的差值:

1
2
3
4
std::chrono::minutes t1( 10 );
std::chrono::seconds t2( 60 );
std::chrono::seconds t3 = t1 - t2;
std::cout << t3.count() << " second" << std::endl; // 540 second

其中,t1 是代表 10 分钟、 t2 是代表 60 秒,t3 则是 t1 減去 t2,也就是 600 - 60 = 540 秒。通过t1-t2的count输出差值为540个时钟周期即540秒(因为每个时钟周期为一秒)。我们还可以通过duration_cast<>()来将当前的时钟周期转换为其它的时钟周期,比如我可以把秒的时钟周期转换为分钟的时钟周期,然后通过count来获取转换后的分钟时间间隔:

1
cout << chrono::duration_cast<chrono::minutes>( t3 ).count() <<” minutes”<< endl;

将会输出:

1
9 minutes

Time point

time_point表示一个时间点,用来获取1970.1.1以来的秒数和当前的时间, 可以做一些时间的比较和算术运算,可以和ctime库结合起来显示时间。time_point必须要clock来计时,time_point有一个函数time_since_epoch()用来获得1970年1月1日到time_point时间经过的duration。下面的例子计算当前时间距离1970年1月一日有多少天:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <ratio>
#include <chrono>

int main ()
{
using namespace std::chrono;
typedef duration<int,std::ratio<60*60*24>> days_type;
time_point<system_clock,days_type> today = time_point_cast<days_type>(system_clock::now());
std::cout << today.time_since_epoch().count() << " days since epoch" << std::endl;
return 0;
}

time_point还支持一些算术元算,比如两个time_point的差值时钟周期数,还可以和duration相加减。下面的例子输出前一天和后一天的日期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <iomanip>
#include <ctime>
#include <chrono>

int main()
{
using namespace std::chrono;
system_clock::time_point now = system_clock::now();
std::time_t last = system_clock::to_time_t(now - std::chrono::hours(24));
std::time_t next= system_clock::to_time_t(now - std::chrono::hours(24));
std::cout << "One day ago, the time was "<< std::put_time(std::localtime(&last), "%F %T") << '\n';
std::cout << "Next day, the time was "<< std::put_time(std::localtime(&next), "%F %T") << '\n';
}

输出:

1
2
One day ago, the time was 2014-3-2622:38:27
Next day, the time was 2014-3-2822:38:27

Clocks

表示当前的系统时钟,内部有time_point, duration, Rep, Period等信息,它主要用来获取当前时间,以及实现time_t和time_point的相互转换。Clocks包含三种时钟:

  • system_clock:从系统获取的时钟;
  • steady_clock:不能被修改的时钟;
  • high_resolution_clock:高精度时钟,实际上是system_clock或者steady_clock的别名。

可以通过now()来获取当前时间点:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <chrono>

int main()
{
std::chrono::steady_clock::time_point t1 = std::chrono::system_clock::now();
std::cout << "Hello World\n";
std::chrono::steady_clock::time_point t2 = std::chrono:: system_clock::now();
std::cout << (t2-t1).count()<<” tick count”<<endl;
}

输出:

1
2
Hello World
20801 tick count

通过时钟获取两个时间点之相差多少个时钟周期,我们可以通过duration_cast将其转换为其它时钟周期的duration:

1
cout << std::chrono::duration_cast<std::chrono::microseconds>( t2-t1 ).count() <<” microseconds”<< endl;

输出:

1
20 microseconds

system_clock的to_time_t方法可以将一个time_point转换为ctime:

1
std::time_t now_c = std::chrono::system_clock::to_time_t(time_point);

而from_time_t方法则是相反的,它将ctime转换为time_point。

steady_clock可以获取稳定可靠的时间间隔,后一次调用now()的值和前一次的差值是不因为修改了系统时间而改变,它保证了稳定的时间间隔。它的用法和system用法一样。

system_clock和std::put_time配合起来使用可以格式化日期的输出,std::put_time能将日期格式化输出。下面的例子是将当前时间格式化输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <chrono>
#include <ctime>
#include <iomanip>
#include <string>
using namespace std;

int main()
{
auto t = chrono::system_clock::to_time_t(std::chrono::system_clock::now());
cout<< std::put_time(std::localtime(&t), "%Y-%m-%d %X")<<endl;
cout<< std::put_time(std::localtime(&t), "%Y-%m-%d %H.%M.%S")<<endl;
return 0;
}

上面的例子将输出:

1
2
2014-3-27 22:11:49
2014-3-27 22.11.49

timer

可以利用high_resolution_clock来实现一个类似于boost.timer的定时器,这样的timer在测试性能时会经常用到,经常用它来测试函数耗时,它的基本用法是这样的:

1
2
3
4
5
6
7
8
9
10
void fun()
{
cout<<"hello word"<<endl;
}
int main()
{
timer t; //开始计时
fun()
cout<<t.elapsed()<<endl; //打印fun函数耗时多少毫秒
}

c++11中增加了chrono库,现在用来实现一个定时器是很简单的事情,还可以移除对boost的依赖。它的实现比较简单,下面是具体实现:

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
#include<chrono>
usingnamespace std;
usingnamespace std::chrono;

classTimer
{
public:
Timer() : m_begin(high_resolution_clock::now()) {}
void reset() { m_begin = high_resolution_clock::now(); }

//默认输出秒
  double elapsed() const
  {
    return duration_cast<duration<double>>(high_resolution_clock::now() - m_begin).count();
  }

//默认输出毫秒
int64_t elapsed() const
{
//return duration_cast<chrono::milliseconds>(high_resolution_clock::now() - m_begin).count();
}

//微秒
int64_t elapsed_micro() const
{
return duration_cast<chrono::microseconds>(high_resolution_clock::now() - m_begin).count();
}

//纳秒
int64_t elapsed_nano() const
{
return duration_cast<chrono::nanoseconds>(high_resolution_clock::now() - m_begin).count();
}

//秒
int64_t elapsed_seconds() const
{
return duration_cast<chrono::seconds>(high_resolution_clock::now() - m_begin).count();
}

//分
int64_t elapsed_minutes() const
{
return duration_cast<chrono::minutes>(high_resolution_clock::now() - m_begin).count();
}

//时
int64_t elapsed_hours() const
{
return duration_cast<chrono::hours>(high_resolution_clock::now() - m_begin).count();
}

private:
time_point<high_resolution_clock> m_begin;
};

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void fun()
{
cout<<”hello word”<<endl;
}

int main()
{
timer t; //开始计时
fun()
cout<<t.elapsed()<<endl; //打印fun函数耗时多少毫秒
cout<<t.elapsed_micro ()<<endl; //打印微秒
cout<<t.elapsed_nano ()<<endl; //打印纳秒
cout<<t.elapsed_seconds()<<endl; //打印秒
cout<<t.elapsed_minutes()<<endl; //打印分钟
cout<<t.elapsed_hours()<<endl; //打印小时
}

数值类型和字符串的相互转换

C++11提供了to_string方法,可以方便地将各种数值类型转换为字符串类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::string to_string(int value);
std::string to_string(long int value);
std::string to_string(long long int value);
std::string to_string(unsigned int value);
std::string to_string(unsigned long long int value);
std::string to_string(float value);
std::string to_string(double value);

std::wstring to_wstring(int value);
std::wstring to_wstring(long int value);
std::wstring to_wstring(long long int value);
std::wstring to_wstring(unsigned int value);
std::wstring to_wstring(unsigned long int value);
std::wstring to_wstring(unsigned long long int value);
std::wstring to_wstring(float value);
std::wstring to_wstring(double value);
std::wstring to_wstring(long double value);

还提供了stoxxx方法,将string转换为各种类型的数据:

1
2
3
4
std::string str = "1000";
int val = std::stoi(str);
long val = std::stol(str);
float val = std::stof(str);

c++11还提供了字符串(char*)转换为整数和浮点类型的方法:

  • atoi: 将字符串转换为 int
  • atol: 将字符串转换为long
  • atoll:将字符串转换为 long long
  • atof: 将字符串转换为浮点数

宽窄字符转换

c++11增加了unicode字面量的支持,可以通过L来定义宽字符。

1
std::wstring wide_str = L"中国人"; //定义了宽字符字符串 

将宽字符转换为窄字符需要用到condecvt库中的std::wstring_convert,它需要如下几个转换器:

  • std::codecvt_utf8,封装了UTF-8与UCS2及UTF-8与UCS4的编码转换;
  • std::codecvt_utf16,封装了UTF-16与UCS2及UTF-16与UCS4的编码转换;
  • std::codecvt_utf8_utf16,封装了UTF-8与UTF-16的编码转换;

std::wstring_convert使std::stringstd::wstring之间的相互转换变得很方便,如代码:

1
2
3
4
5
6
7
8
9
10
11
std::wstring wide_str = L"中国人";
std::wstring_convert<std::condecvt<wchar_t, char, std::mbstate_t>> converter(new std::codecvt<wchar_t, char, std::mbstate_t>("CHS");

std::string narrow_str = converter.to_bytes(wide_str);
std::wstring wstr = converter.from_bytes(narrow_str);
std::cout << narrow_str << std::endl;

wcout.imbue(std::locale("chs"));
std::wcout << wstr << std::endl;
std::cout << wstr.size() << " " << wstr.length() << endl;
std::cout << narrow_str.size() << " " << narrow_str.length() << endl;

输出:

1
2
中国人
中国人

C++11的其他特性

委托构造函数和继承构造函数

委托构造函数允许在同一个类中一个构造函数可以调用另一个构造函数,从而可以在初始化时简化变量的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class class_c {
public:
int max;
int min;
int middle;

class_c() {}
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < my_max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < my_max ? my_min : 1;
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
}

通过委托构造函数简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class class_c {
public:
int max;
int min;
int middle;

class_c() {}
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) : class_c(my_max) {
min = my_min > 0 && my_min < my_max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) : class_c(my_max, my_min) {
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
}

需要注意,如果使用了委托构造函数,则不能使用类成员初始化,比如:

1
2
3
4
5
6
7
8
class A{
public:
A(int a):a_(a){}; //单独使用类成员初始化,可以
A(int a, int b):A(a), b_(b){}; //同时使用了委托构造函数和类成员初始化,错误!
private:
int a_;
int b_;
}

如果一个派生类继承自一个基类,如果其构造函数想要使用和基类相同的构造函数,如果构造函数有多个,则在派生类中要写多个构造函数,每个都用基类构造, 在c++11中,可以使用继承构造函数来简化这一操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base{
public:
Base(int a);
Base(int a, int b);
Base(int a, int b, double c);
~Base();
};
struct Derived : Base {
using Base::Base; //声明使用基类构造函数
};
int main() {
...
}

原始的字面量

原始字面量可以直接表示字符串的实际含义,因为有些字符串带一些特殊字符,比如在转义字符串中,我们往往要专门处理。如windows路径名:D:\A\B\test.txt
在c++11中,使用R"xx(string)xx"来获得括号中的string部分的字符串形式,不需要使用转义字符等附加字符,比如:

1
string a = R"(D:\A\B\test.txt)" 

注意,R"xxx(raw string)xxx",其中原始字符串必须用括号()括起来,括号前后可以加其他字符串,所加的字符串是会被忽略的,而且加的字符串必须在括号两边同时出现。

1
2
string str = R"test(D:A\B\test.test)test";
// 实际上是“D:A\B\test.test”

final和override标识符

c++11中增加了final关键字来限制某个类不能被继承(类似java)或者某个虚函数不能别重写(类似c#中的sealed)。如果修饰函数,final只能修饰虚函数,并且要放到类或者函数的后面。

1
2
3
4
5
6
7
8
9
struct A{
virtual void foo() final; // foo 声明为final的虚函数,不能被重写
void test() final; // 错误,final只能修饰虚函数
};
struct B final : A{ //B声明为final,表示不能被继承
void foo(); // 错误,foo不可被重写
};
struct C : B{ //错误,B不能被继承
};

c++11中还增加了override关键字确保在派生类中声明的重写函数与基类的虚函数有相同的签名,同时也明确表明将会重写基类的虚函数,还可以防止因疏忽把原来想重写基类的虚函数声明为重载。override关键字要放到方法的后面

1
2
3
4
5
6
7
struct A{
virtual void func();
};
struct D:A{
void func() override{
};
};

内存对齐

内存对齐介绍

cpu访问内存的时候,起始地址并不是随意的,例如有些cpu访问内存起始地址要是4的倍数,因为内存总线的宽度为32位,每次读写操作都4个字节4个字节进行。如果某个数据在内存中不是字节对齐的,则会在访问的时候比较麻烦,比如4字节的int32类型,没有4字节对齐,则需要访问两次内存才能读到完整的数据。因此,内存对齐可以提高程序的效率。

因为有了内存对齐,所以数据在内存中的存放就不是紧挨着的,而是会出现一些空隙。C++数据内存对齐的含义是,数据在内存中的起始地址是数据size的倍数。c++结构体内存对齐的原则是:结构体内的每个变量都自身对齐,按照字节对齐,中间加入padding,;整个结构体按照结构体内的最大size变量的对齐方式对齐,比如:

1
2
3
4
5
struct{
int a;
char c;
double d;
};

结构体按照最大size的变量对齐,即按照double的8字节对齐。

堆内存的内存对齐

malloc一般使用当前平台默认的最大内存对齐数对齐内存。当我们需要分配一块特定内存对齐的内存块时,使用memalign等函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <assert.h>

inline void* aligned_malloc(size_t size, size_t alignment) {
// 检查alignment是否是2^N
assert(!alignment & (alignment - 1));
// 计算最大offset
size_t offset = sizeof(void*) + (-- alignment);

// 分配一块带有offset的内存
char* p = static_cast<char*>(malloc(offset + size));
if (!p) return nullptr;

// 通过“&(~alignment)”把多计算的offset减掉
void* r = reinterpret_cast<void*>(reinterpret_cast<size_t>(p + offset) & (~alignment));

// 将r作为一个指向void*的指针,在r当前地址前面放入原始地址
static_cast<void**>(r)[-1] = p;
return r;
}

利用alignas指定内存对齐大小

1
alignas(32) long long a = 0; 

指定a为32字节对齐。 alignas可以将内存对齐改大,而不能改小,因此,可以有 alignas(32) long long a; 而不能有alignas(1) long long a;

1
2
#define XX 1
struct alignas(XX) MyStruct{ }

指定为1字节对齐,因为MyStruct内部没有数据,自然为1字节对齐。如果内部含有int类型数据,则alignas只能将对齐方式改大不能改小,故不能为1字节对齐。

1
alignas(int) char c;

这个char就按照int的方式对齐了。

利用alignof和std::alignment_of获取内存对齐大小

alignof用来获取内存对齐大小,只能返回size_t。

1
2
3
MyStruct xx;
cout << alignof(xx) << endl;
cout << alignof(MyStruct) << endl;

alignment_of继承自std::integral_constant,因此拥有value_type、type和value成员

1
cout << std::alignment_of<MyStruct>::value << std::endl;

内存对齐的类型std::aligned_storage

aligned_storage可以看成一个内存对齐的缓冲区,原型如下:

1
2
template<std::size_t Len, std::size_t Align = /*default-alignment*/>
struct aligned_storage;

Len代表所存储类型的size,Align代表所存储类型的对齐大小,通过sizeof(T)获取T的size,通过alignof(T)获取T内存对齐的大小,所以std::aligned_storage的声明是这样的:std::aligned_storage<sizeof(T), align(T)>或者std::aligned_storage<sizeof(T), std::alignment_of(T)::value>

1
2
3
4
5
6
7
8
9
10
11
struct A{
int a;
double c;
A(int aa, double cc):a(aa), c(cc){};
};
typedef std::aligned_storage<sizeof<A>, alignof(A)>::type Aligned_A;
int main(){
Aligned_A a, b; //声明一块内存对齐的内存
new (&a)A(10, 20.0); //原地构造函数
return 0;
}

为什么要使用std::aligned_storage呢?很多时候需要分配一块单纯的内存块,之后再使用placement new在这块内存上构建对象:

1
2
char xx[32];
::new xx MyStruct;

但是char[32]是1字节对齐的,xx很有可能不在指定的对齐位置上,这是调用placement new构造内存块引起效率问题,所以应该使用std::aligned_storage构造内存块:

1
2
typedef std::aligned_storage<sizeof<A>, alignof(A)>::type Aligned_A;
::new (&Aligned_A) A;

std::max_align_tstd::align操作符

std::max_align_t返回当前平台的最大默认内存对齐类型。通过下面这个方式获得当前平台的默认最大内存对齐数:

1
cout << alignof(std::max_align_t) << endl;

std::align用来在一大块内存中获取一个符合指定内存要求的地址。

1
2
3
4
char buffer[] = "---------------";
void* pt = buffer;
std::size_t space = sizeof(buffer) - 1;
std::align(alignof(int), sizeof(char), pt, space);

在buffer这个大内存中,指定内存对齐为align(int),找一块sizeof(char)大小的内存,并在找到这块内存后把地址放入pt中。

新增的便利算法

all_ofany_ofnone_of

算法库新增了三个用于判断的算法all_of、any_of和none_of:

1
2
3
4
5
6
7
8
template< class InputIt, class UnaryPredicate >
bool all_of( InputIt first, InputIt last, UnaryPredicate p );

template< class InputIt, class UnaryPredicate >
bool any_of( InputIt first, InputIt last, UnaryPredicate p );

template< class InputIt, class UnaryPredicate >
bool none_of( InputIt first, InputIt last, UnaryPredicate p );
  • all_of:检查区间[first, last)中是否所有的元素都满足一元判断式p,所有的元素都满足条件返回true,否则返回false。
  • any_of:检查区间[first, last)中是否至少有一个元素都满足一元判断式p,只要有一个元素满足条件就返回true,否则返回true。
  • none_of:检查区间[first, last)中是否所有的元素都不满足一元判断式p,所有的元素都不满足条件返回true,否则返回false。

下面是这几个算法的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main()
{
vector<int> v = { 1, 3, 5, 7, 9 };
auto isEven = [](int i){return i % 2 != 0;};
bool isallOdd = std::all_of(v.begin(), v.end(), isEven);
if (isallOdd)
cout << "all is odd" << endl;

bool isNoneEven = std::none_of(v.begin(), v.end(), isEven);
if (isNoneEven)
cout << "none is even" << endl;

vector<int> v1 = { 1, 3, 5, 7, 8, 9 };
bool anyof = std::any_of(v1.begin(), v1.end(), isEven);
if (anyof)
cout << "at least one is even" << endl;
}

输出:

1
2
3
all is odd
none is odd
at least one is even

find_if_not

算法库的查找算法新增了一个find_if_not,它的含义和find_if是相反的,即查找不符合某个条件的元素,find_if也可以实现find_if_not的功能,只需要将判断式改为否定的判断式即可,现在新增了find_if_not之后,就不需要再写否定的判断式了,可读性也变得更好。下面是它的基本用法:

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

int main()
{
vector<int> v = { 1, 3, 5, 7, 9, 4 };
auto isEven = [](int i){return i % 2 == 0;};
auto firstEven = std::find_if(v.begin(), v.end(), isEven);
if (firstEven!=v.end())
cout << "the first even is " <<* firstEven << endl;

//用find_if来查找奇数则需要重新写一个否定含义的判断式
auto isNotEven = [](int i){return i % 2 != 0;};
auto firstOdd = std::find_if(v.begin(), v.end(),isNotEven);

if (firstOdd!=v.end())
cout << "the first odd is " <<* firstOdd << endl;

//用find_if_not来查找奇数则无需新定义判断式
auto odd = std::find_if_not(v.begin(), v.end(), isEven);
if (odd!=v.end())
cout << "the first odd is " <<* odd << endl;
}

将输出:

1
2
3
the first even is 4
the first odd is 1
the first odd is 1

可以看到使用find_if_not不需要再定义新的否定含义的判断式了,更简便了。

copy_if

算法库还增加了一个copy_if算法,它相比原来的copy算法多了一个判断式,用起来更方便了,下面是它的基本用法:

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

int main()
{
vector<int> v = { 1, 3, 5, 7, 9, 4 };
std::vector<int> v1(v.size());
//根据条件拷贝
auto it = std::copy_if(v.begin(), v.end(), v1.begin(), [](int i){return i%2!=0;});
//缩减vector到合适大小
v1.resize(std::distance(v1.begin(),it));
for(int i : v1)
{
cout<<i<<" ";
}

cout<<endl;
}

iota

算法库新增了iota用来方便的生成有序序列,比如我们需要一个定长数组,这个数组中的元素都是在某一个数值的基础之上递增的,那么用iota可以很方便的生成这个数组了。下面是它的基本用法:

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

int main()
{
vector<int> v(4) ;
//循环遍历赋值来初始化数组
//for(int i=1; i<=4; i++)
//{
// v.push_back(i);
//}

//直接通过iota初始化数组,更简洁
std::iota(v.begin(), v.end(), 1);
for(auto n: v) {
cout << n << ' ';
}
cout << endl;

std::array<int, 4> array;
std::iota(array.begin(), array.end(), 1);
for(auto n: array) {
cout << n << ' ';
}
std::cout << endl;
}

将输出:

1
2
1 2 3 4
1 2 3 4

可以看到使用iota比遍历赋值来初始化数组更简洁,需要注意的是iota初始化的序列需要指定大小,如果上面的代码中:vector v(4) ;没有指定初始化大小为4的话,则输出为空。

minmax_element

算法库还新增了一个同时获取最大值和最小值的算法minmax_element,这样我们如果想获取最大值和最小值的时候就不用分别调用max_element和max_element算法了,用起来会更方便,minmax_element会将最小值和最大值的迭代器放到一个pair中返回,下面是它的基本用法:

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

int main() {
// your code goes here
vector<int> v = { 1, 2, 5, 7, 9, 4 };
auto result = minmax_element(v.begin(), v.end());

cout<<*result.first<<" "<<*result.second<<endl;

return 0;
}

将输出:

1
1 9

is_sorted和is_sorted_until

算法库新增了is_sorted和is_sorted_until算法,is_sort用来判断某个序列是否是排好序的,is_sort_until则用来返回序列中前面已经排好序的部分序列。下面是它们的基本用法:

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

int main() {
vector<int> v = { 1, 2, 5, 7, 9, 4 };
auto pos = is_sorted_until(v.begin(), v.end());

for(auto it=v.begin(); it!=pos; ++it)
{
cout<<*it<< " ";
}
cout<<endl;

bool is_sort = is_sorted(v.begin(), v.end());
cout<< is_sort<<endl;
return 0;
}

将输出:

1
2
1 2 5 7 9
0

总结:这些新增的算法让我们用起来更加简便,也增强了代码的可读性。

C++11改进我们的模式

改进单例模式

单例模式保证一个类仅有一个实例,并提供一个访问它的全局访问点。在c++11之前,我们写单例模式的时候会遇到一个问题,就是多种类型的单例可能需要创建多个类型的单例,主要是因为创建单例对象的构造函数无法统一,各个类型的形参不尽相同,导致我们不容易做一个所有类型都通用的单例。现在c+11帮助我们解决了这个问题,解决这个问题靠的是c++11的可变模板参数。

将原有的多个构造函数合并:

1
2
3
4
5
6
template <typename T0, typename T1, typename T2, typename T3, typename T4, typename T5>
static T* Instance(T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) {
if (m_pInstance == nullptr)
m_pInstance = new T(arg0, arg1, arg2, arg3, arg4, arg5);
return m_pInstance;
}

改为

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
template <typename T>
class Singleton
{
public:
template<typename... Args>
  static T* Instance(Args&&... args)
  {
if(m_pInstance==nullptr)
m_pInstance = new T(std::forward<Args>(args)...);
return m_pInstance;
}
  static T* GetInstance() {
    if (m_pInstance == nullptr)
      throw std::logic_error("the instance is not init, please initialize the instance first");
    return m_pInstance;
  }
static void DestroyInstance()
{
delete m_pInstance;
m_pInstance = nullptr;
}

private:
Singleton(void);
virtual ~Singleton(void);
Singleton(const Singleton&);
Singleton& operator = (const Singleton&);
private:
static T* m_pInstance;
};

template <class T> T* Singleton<T>::m_pInstance = nullptr;

/*更新说明**/

由于原来的接口中,单例对象的初始化和取值都是一个接口,可能会遭到误用,更新之后,初始化和取值分为两个接口,单例的用法为:先初始化,后面取值,如果中途销毁单例的话,需要重新取值。如果没有初始化就取值则会抛出一个异常。

增加Multiton的实现

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
#include <map>
#include <string>
#include <memory>
using namespace std;

template < typename T, typename K = string>
class Multiton
{
public:
template<typename... Args>
static std::shared_ptr<T> Instance(const K& key, Args&&... args)
{
return GetInstance(key, std::forward<Args>(args)...);
}

template<typename... Args>
static std::shared_ptr<T> Instance(K&& key, Args&&... args)
{
return GetInstance(key, std::forward<Args>(args)...);
}
private:
template<typename Key, typename... Args>
static std::shared_ptr<T> GetInstance(Key&& key, Args&&...args)
{
std::shared_ptr<T> instance = nullptr;
auto it = m_map.find(key);
if (it == m_map.end())
{
instance = std::make_shared<T>(std::forward<Args>(args)...);
m_map.emplace(key, instance);
}
else
{
instance = it->second;
}

return instance;
}

private:
Multiton(void);
virtual ~Multiton(void);
Multiton(const Multiton&);
Multiton& operator = (const Multiton&);
private:
static map<K, std::shared_ptr<T>> m_map;
};

template <typename T, typename K>
map<K, std::shared_ptr<T>> Multiton<T, K>::m_map;

改进观察者模式

观察者模式定义对象间一种一对多关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。和单例模式面临的是同样的问题,主题更新的接口难以统一,很难做出一个通用的观察者模式,还是用到可变模板参数解决这个问题,其次还用到了右值引用,避免多余的内存移动。c++11版本的观察者模式支持注册的观察者为函数、函数对象和lamda表达式,也避免了虚函数调用,更简洁更通用。

主要改进的地方有两个:通过被通知接口参数化和std::function来代替继承,通过可变参数模板和完美转发来消除接口变化产生的影响。直接看代码。

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
class NonCopyable {
protected:
NonCopyable() = default;
~NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator = (const NonCopyable&) = delete;
// 禁用复制构造和赋值构造
};

template<typename Func>
class Events : NonCopyable
{
public:
Events() {}
~Events(){}

int Connect(Func&& f) {
return Assgin(std::forward<Func>(f));
}

int Connect(const Func& f) {
return Assgin(f);
}

void Disconnect(int key) {
m_connections.erase(key);
}

template<typename... Args>
void Notify(Args&&... args) {
for (auto& it : m_connections) {
it.second(std::forward<Args>(args)...);
}
}

int operator += (Func&& f) {
return Connect(std::forward<Func>(f));
}

int operator += (Func& f) {
return Connect(f);
}

template<typename... Args>
void operator()(Args&&... args) {
Notify(std::forward<Args>(args)...);
}

Events& operator -= (int key) {
Disconnect(key);
return *this;
}

void Clear() {
m_connections.clear();
}

private:

template<typename F>
int Assgin(F&& f) {
int index = m_nextKey++;
m_connections.emplace(index, std::forward<F>f);
return index;
}

int m_nextKey;
std::map<int, Func> m_connections;
};

增加了+=和-=运算符,使用法更接近c#,这里+=会返回一个key,这个key用来-=删除委托时用到,这种做法不太好,只是一个简单的处理。如果内部用vector的话,-=时,根据function去删除指定的委托的话,用法就和c#完全一致了,不过,这里遇到的问题是function不支持比较操作,导致将function存入容器后,后面再根据function去删除时就找不到对应的function了。

改进访问者模式

访问者表示一个作用于某对象结构中的各元素的操作,可用于不改变各元素的类的前提下定义作用于这些元素的新操作。

访问者模式需要注意定义对象结构的类很少改变,但经常需要在此结构上定义新的操作。改变对象结构类需要重定义对所有访问者的接口,这可能需要很大的代价。定义一个稳定的访问者接口层,即不会因为增加新的被访问者而修改接口层。通过可变参数模板实现一个稳定的接口层,利用可变参数模板可以支持任意个数的参数的特点,可以让访问者接口层访问任意个数的被访问者。

访问者模式是GOF23个设计模式中比较复杂的模式之一,但是它的功能也很强大,非常适合稳定的继承层次中对象的访问,可以在不修改被访问对象的情况下,动态添加职责,这正是访问者模式强大的地方,但是它的实现又造成了两个继承层次的强烈耦合,这也是被人诟病的地方,可以说是让人爱又让人恨的模式。c++11实现的访问者模式将会解决这个问题。我们将在c++11版本的访问者模式中看到,定义新的访问者是很容易的,扩展性很好,被访问者的继承层次也不用做任何修改。具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename... Types>
struct Visitor;

template<typename T, typename... Types>
struct Visitor<T, Types...> : Visitor<Types...>
{
using Visitor<Types...>::Visit;
virtual void Visit(const T&) = 0;
};

template<typename T>
struct Visitor<T>
{
virtual void Visit(const T&) = 0;
};

上面的代码为每个类型都定义了一个纯虚函数Visit。

下面看看被访问的继承体系如何使用Visitor访问该继承体系的对象。

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
struct stA;
struct stB;

struct Base
{
typedef Visitor<stA, stB> MytVisitor;
virtual void Accept(MytVisitor&) = 0;
};

struct stA: Base
{
double val;
void Accept(Base::MytVisitor& v)
{
v.Visit(*this);
}
};

struct stB: Base
{
int val;
void Accept(Base::MytVisitor& v)
{
v.Visit(*this);
}
};

struct PrintVisitor: Base::MytVisitor
{
void Visit(const stA& a)
{
std::cout << "from stA: " << a.val << std::endl;
}
void Visit(const stB& b)
{
std::cout << "from stB: " << b.val << std::endl;
}
};

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
void TestVisitor()
{
PrintVisitor vis;
stA a;
a.val = 8.97;
stB b;
b.val = 8;
Base* base = &a;
base->Accept(vis);
base = &b;
base->Accept(vis);
}

 测试结果:

1
2
from stA: 8.97
from stB: 8

typedef Visitor<stA, stB> MytVisitor;会自动生成stA和stB的visit虚函数:

1
2
3
4
struct Visitor<stA, stB> {
virtual void Visit(const stA &) = 0;
virtual void Visit(const stB &) = 0;
}

当被访者需要增加stC、stD时,增加就行:

1
`typedef Visitor<stA, stB, stC, stD> MytVisitor;

类型自动生成接口:

1
2
3
4
5
6
struct Visitor<stA, stB, stC, stD> {
virtual void Visit(const stA &) = 0;
virtual void Visit(const stB &) = 0;
virtual void Visit(const stC &) = 0;
virtual void Visit(const stD &) = 0;
}

改进命令模式

命令模式的作用是将请求封装为一个对象,将请求的发起者和执行者解耦,支持对请求排队以及撤销和重做。将请求封装成一个个命令对象,使得我们可以集中处理或延迟处理这些命令请求,而且不同的客户对象可以共享命令,控制请求的优先级、排队、支持请求命令撤销和重做。

命令模式的这些好处是显而易见的,但是,在实际使用过程中它的问题也暴露出来了。随着请求的增多,请求的封装类—命令类也会越来越多,尤其是GUI应用中,请求是非常多的。越来越多的命令类会导致类爆炸,难以管理。关于类爆炸这个问题,GOF很早就意识到了,他们提出了一个解决方法:对于简单的不能取消和不需要参数的命令,可以用一个命令类模板来参数化该命令的接收者,用接收者类型来参数化命令类,并维护一个接收者对象和一个动作之间的绑定,而这一动作是用指向同一个成员函数的指针存储的。具体代码是这样的:
简单命令类的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class Receiver>
class SimpleCommand: public Command {
public :
typedef void (Receiver:: *Action) ();
SimpleCormnand(Receiver* r, Action a) : _receiver (r) , _action (a) { }
virtual void Execute ();
private :
Action _action;
Receiver* _receiver ;
};
template <class Receiver>
void SimpleCommand<Receiver>::Execute() {
(_receiver->*_action)();
}

测试代码如下:

1
2
3
4
5
6
7
8
9
class MyClass {
public:
void Action();
}
void dummy() {
MyClass* receiver = new MyClass;
Command* aCommand = new SimpleCommand<MyClass>(receiver, &MyClass::Action);
aCommand->Execute();
}

通过一个泛型的简单命令类来避免不断创建新的命令类,是一个不错的办法,但是,这个办法不完美,即它只能是简单的命令类,不能对复杂的,甚至所有的命令类泛化,这是它的缺陷,所以,它只是部分的解决了问题。我想我可以改进这个办法缺陷,完美的解决类爆炸的问题。在c++11之前我不知道有没有人解决过这个问题,至少我没看到过。现在可以通过c++11来完美的解决这个问题了。

要完美的解决命令模式类爆炸问题的关键是如何定义个通用的泛化的命令类,这个命令类可以泛化所有的命令,而不是GOF提到的简单命令。我们再回过头来看看GOF中那个简单的命令类的定义,它只是泛化了没有参数和返回值的命令类,命令类内部引用了一个接收者和接收者的函数指针,如果接收者的行为函数指针有参数就不能通用了,所以我们要解决的关键问题是如何让命令类能接受所有的成员函数指针或者函数对象。

我们需要一个函数包装器,它可以接受所有的函数对象、fucntion和lamda表达式等。接受function、函数对象、lamda和普通函数的包装器:

1
2
3
4
5
template< class F, class... Args, class = typename std::enable_if<!std::is_member_function_pointer<F>::value>::type>
void Wrap(F && f, Args && ... args)
{
return f(std::forward<Args>(args)...);
}

接受成员函数的包装器:

1
2
3
4
5
template<class R, class C, class... DArgs, class P, class... Args>
void Wrap(R(C::*f)(DArgs...), P && p, Args && ... args)
{
return (*p.*f)(std::forward<Args>(args)...);
}

通过重载的Wrap让它能接收成员函数。这样一个真正意义上的万能的函数包装器就完成了。现在再来看,它是如何应用到命令模式中,完美的解决类爆炸的问题。

一个通用的泛化的命令类:

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 <functional>
#include <type_traits>
template<typename R=void>
struct CommCommand
{
private:
std::function < R()> m_f;
public:
template< class F, class... Args, class = typename std::enable_if<!std::is_member_function_pointer<F>::value>::type>
void Wrap(F && f, Args && ... args)
{
m_f = [&]{return f(args...); };
}

template<class C, class... DArgs, class P, class... Args>
void Wrap(R(C::*f)(DArgs...) const, P && p, Args && ... args)
{
m_f = [&, f]{return (*p.*f)( args...); };
}

// non-const member function
template<class C, class... DArgs, class P, class... Args>
void Wrap(R(C::*f)(DArgs...), P && p, Args && ... args)
{
m_f = [&, f]{return (*p.*f)( args...); };
}

R Excecute()
{
return m_f();
}
};

测试代码:

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
struct STA
{
int m_a;
int operator()(){ return m_a; }
int operator()(int n){ return m_a + n; }
int triple0(){ return m_a * 3; }
int triple(int a){ return m_a * 3 + a; }
int triple1() const { return m_a * 3; }
const int triple2(int a) const { return m_a * 3+a; }
void triple3(){ cout << "" <<endl; }
};

int add_one(int n) {
return n + 1;
}

void TestWrap() {

CommCommand<int> cmd;
// free function
cmd.Wrap(add_one, 0);

// lambda function
cmd.Wrap([](int n){return n + 1; }, 1);

// functor
cmd.Wrap(bloop);
cmd.Wrap(bloop, 4);

STA t = { 10 };
int x = 3;
// member function
cmd.Wrap(&STA::triple0, &t);
cmd.Wrap(&STA::triple, &t, x);
cmd.Wrap(&STA::triple, &t, 3);

cmd.Wrap(&STA::triple2, &t, 3);
auto r = cmd.Excecute();

CommCommand<> cmd1;
cmd1.Wrap(&Bloop::triple3, &t);
cmd1.Excecute();
}

我们在通用的命令类内部定义了一个万能的函数包装器,使得我们可以封装所有的命令,增加新的请求都不需要重新定义命令了,完美的解决了命令类爆炸的问题。

改进对象池模式

对象池对于创建比较大的对象来说很有意义,为了避免重复创建开销比较大的对象,可以通过对象池来优化,实现创建好一批对象,放到一个集合里,每当程序需要新对象时,就从对象池中获取,程序用完该对象后会把对象归还给对象池。

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
#include <string>
#include <functional>
#include <memory>
#include <map>
using namespace std;

const int MaxObjectNum = 10;

template<typename T>
class ObjectPool : NonCopyable
{
template<typename... Args>
using Constructor = std::function<std::shared_ptr<T>(Args...)>;
public:
//默认创建多少个对象
template<typename... Args>
void Init(size_t num, Args&&... args)
{
if (num<= 0 || num> MaxObjectNum)
throw std::logic_error("object num out of range.");

auto constructName = typeid(Constructor<Args...>).name(); //不区分引用
for (size_t i = 0; i <num; i++)
{
m_object_map.emplace(constructName, shared_ptr<T>(new T(std::forward<Args>(args)...), [this, constructName](T* p) //删除器中不直接删除对象,而是回收到对象池中,以供下次使用
{
m_object_map.emplace(std::move(constructName), std::shared_ptr<T>(p));
}));
}
}

//从对象池中获取一个对象
template<typename... Args>
std::shared_ptr<T> Get()
{
string constructName = typeid(Constructor<Args...>).name();

auto range = m_object_map.equal_range(constructName);
for (auto it = range.first; it != range.second; ++it)
{
auto ptr = it->second;
m_object_map.erase(it);
return ptr;
}

return nullptr;
}

private:
multimap<string, std::shared_ptr<T>> m_object_map;
};

使用C++11实现一个半同步半异步线程池

实际中,主要有两种方法处理大量的并发任务,一种是一个请求由系统产生一个相应的处理请求的线程(一对一);另外一种是系统预先生成一些用于处理请求的进程,当请求的任务来临时,先放入同步队列中,分配一个处理请求的进程去处理任务,线程处理完任务后还可以重用,不会销毁,而是等待下次任务的到来。(一对多的线程池技术)线程池技术,能避免大量线程的创建和销毁动作,节省资源,对于多核处理器,由于线程被分派配到多个cpu,会提高并行处理的效率。线程池技术分为半同步半异步线程池和领导者追随者线程池。

一个半同步半异步线程池分为三层。

  • 同步服务层:它处理来自上层的任务请求,上层的请求可能是并发的,这些请求不是马上就会被处理的,而是将这些任务放到一个同步排队层中,等待处理。
  • 同步排队层: 来自上层的任务请求都会加到排队层中等待处理,排队层实际就是一个std::queue。
  • 异步服务层: 这一层中会有多个线程同时处理排队层中的任务,异步服务层从同步排队层中取出任务并行的处理。

上层只需要将任务丢到同步队列中,主线程也不会阻塞,还能继续发起新的请求。排队曾居于核心地位,实现时,排队曾就是一个同步队列,允许多个线程同时去添加或取出任务。线程池有两个活动过程,一个是往同步队列中添加任务的过程,一个是从同步队列中取任务的过程。

一开始线程池会启动一定数量的线程,这些线程属于异步层,主要用来并行处理排队层中的任务,如果排队层中的任务数为空,则这些线程等待任务的到来,如果发现排队层中有任务了,线程池则会从等待的这些线程中唤醒一个来处理新任务。同步服务层则会不断地将新的任务添加到同步排队层中,这里有个问题值得注意,有可能上层的任务非常多,而任务又是非常耗时的,这时,异步层中的线程处理不过来,则同步排队层中的任务会不断增加,如果同步排队层不加上
限控制,则可能会导致排队层中的任务过多,内存暴涨的问题。因此,排队层需要加上限的控制,当排队层中的任务数达到上限时,就不让上层的任务添加进来,起到限制和保护的作用。

同步队列即为线程中三层结构中的中间那一层,它的主要作用是保证队列中共享数据线程安全,还为上一层同步服务层提供添加新任务的接口,以及为下一层异步服务层提供取任务的接口。同时,还要限制任务数的上限,避免任务过多导致内存暴涨的问题。同步队列的实现比较简单,我们会用到C++11的锁、条件变量、右值引用、std::move以及std::forwardo。move是为了实现移动语义,forward是为了实现完美转发。同步队列的锁是用来线程同步的,条件变量是用来实现线程通信的,即线程池空了就要等待,不为空就通知一个线程去处理;线程池满了就等待,直到没有满的时候才通知上层添加新任务。

这三个层次之间需要使用std::mutex、std::condition_variable来进行事件同步,线程池的实现代码如下。

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
#include <list>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <utility>
#include <iostream>

template<typename T>
class SyncQueue
{
public:
SyncQueue(int maxSize) : m_maxSize(maxSize),m_needStop(false){}

void Put(const T& x) {
Add(x);
}

void Put(T&& x) {
Add(std::forward<T>(x));
}

void Take(std::list<T>& list) {
std::unique_lock<std::mutex> locker(m_mutex);
m_notEmpty.wait(locker,[this]{return m_needStop || NotEmpty();});
if(m_needStop)
return;
list = std::move(m_queue); //move semantics,avoid copy.
m_notFull.notify_one();
}

void Take(T& x) {
std::unique_lock<std::mutex> locker(m_mutex);
m_notEmpty.wait(locker,[this]{return m_needStop || NotEmpty();});
if(m_needStop)
return;
x=m_queue.front();
m_queue.pop_front();
m_notFull.notify_one();
}

void Stop() {
{
std::lock_guard<std::mutex> locker(m_mutex);
m_needStop = true;
}
m_notFull.notify_all();
m_notEmpty.notify_all();
}

bool Empty() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.empty();
}

bool Full() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size() == m_maxSize;
}

std::size_t Size()
{
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size();
}

int Count() {
return m_queue.size();
}
private:
bool NotFull() const {
bool full = m_queue.size() >= m_maxSize;
if(full)
std::cout << "the buffer is full,waiting...\n";
return !full;
}
bool NotEmpty()
{
bool empty = m_queue.empty();
if(empty)
std::cout << "the buffer is empty,waiting...\n";
return !empty;
}

template<typename F>
void Add(F&& x) {
std::unique_lock<std::mutex> locker(m_mutex);
m_notFull.wait(locker, [this]{ return m_needStop || NotFull(); });
if (m_needStop)
return ;
m_queue.push_back(std::forward<F>(x));
m_notEmpty.notify_one();
}

private:
std::list<T> m_queue;
std::mutex m_mutex;
std::condition_variable m_notEmpty;
std::condition_variable m_notFull;
int m_maxSize;
bool m_needStop; //stop flag
};

Take函数先创建一个unique_lock获取,然后再通过条件变量m_notEmpty来等待判断式,判断式由两个条件组成,一个是停止的标志,另一个是不为空的条件,当不满足任何一个条件时,条件变量会释放mutex并将线程置于waiting状态,等待其他线程调用notify_one/notify-all将其唤醒;当满足任何一个条件时,则继续往下执行后面的逻辑,即将队列中的任务取出,并唤醒一个正处于等待状态的添加任务的线程去添加任务。当处于waiting状态的线程被或notify_all唤醒时,条件变量会先重新获取mutex,然后再检查条件是否满足,如果满足,则往下执行,如果不满足,则释放mutex继续等待。

Add函数的过程与Take类似,先获取mutex,不满足条件时,释放继续等待,如果满足条件,则将新的任务插人到队列中,并唤醒取任的线程去取数据。

Stop函数先获取mutex,然后将停止标志置为true。注意,为了保证线程安全,这里需要先获取mutex,在将其标志置为之后,再唤醒所有等待的线程,因为等待的条件是m_needStop,并且满足条件,所以线程会继续往下执行。由于线程在m_needStop为true时会退出,所以所有的等待线程会相继退出。另外一个值得注意的地方是,我们把m_notFull.notify_all()放到lock_guard保护范围之外了,这里也可以将m_notFull.notify_all()放到lock_guard保护范围之内,放到外面是为了做一点优化。因为notify-one或notify-all会唤醒一个在等待的线程,线程被唤醒后会先获取mutex再检查条件是否满足,如果这时被lock_guard保护,被唤醒的线程则需要lock_guard析构释放mutex才能获取。如果在lock_guard之外notify_one或notify_all,被唤醒的线程获取锁的时候不需要等待lock-guard释放锁,性能会
好一点,所以在执行notify-one或notify-all时不需要加锁保护。

线程池:
一个完整的线程池包括三层:同步服务层、排队层和异步服务层,其实这也是一种生产者一消费者模式,同步层是生产者,不断将新任务丢到排队层中,因此,线程池需要提供一个添加新任务的接口供生产者使用;消费者是异步层,具体是由线程池中预先创建的线程去处理排队层中的任务。排队层是一个同步队列,它内部保证了上下两层对共享数据的安全访问,同时还要保证队列不会被无限制地添加任务导致内存暴涨,这个同步队列将使用上一节中实现的线程池。另外,线程池还要提供一个停止的接口,让用户能够在需要的时候停止线程池的运行。

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
const int MaxTaskCount = 100;
class ThreadPool
{
public:
using Task = std::function<void()>;

ThreadPool(int numThreads = std::thread::hardware_concurrency()) :
m_taskQueue(MaxTaskCount)
{
Start(numThreads);
}

~ThreadPool(){ Stop();};
void Stop() {
std::call_once(m_once_flag,[this]{StopThreadGroup();});
}

void AddTask(Task&& task) {
m_queue.Put(std::forward<Task>(task));
}

void AddTask(const Task& task) {
m_taskQueue.Put(task);
}

std::size_t SyncQueueSize() {
return m_taskQueue.Size();
}
private:
void Start(int numThreads) {
m_running = true;
for( int i = 0;i < numThreads; ++i) {
m_threadGrop.push_back(std::make_shared<std::thread>(&ThreadPool::RunInThread,this));
}
}

void RunInThread() {
while(m_running) {
std::list<Task> list;
m_taskQueue.Take(list);

for(auto& task : list) {
if(!m_running)
return;
task();
}
}
return;
}

void StopThreadGroup() {
m_taskQueue.Stop();
m_running = false;
for(auto thread : m_threadGrop) {
if(thread)
thread->join();
}
m_threadGrop.clear();
}
private:
std::list<std::shared_ptr<std::thread>> m_threadGrop; //thread group
SyncQueue<Task> m_taskQueue;
std::atomic_bool m_running;
std::once_flag m_once_flag;
};

C++11实现一个轻量级的AOP框架

AOP(Aspect-Oriented Programming,面向方面编程),可以解决面向对象编程中的一些问题,是OOP的一种有益补充。面向对象编程中的继承是一种从上而下的关系,不适合定义从左到右的横向关系,如果继承体系中的很多无关联的对象都有一些公共行为,这些公共行为可能分散在不同的组件、不同的对象之中,通过继承方式提取这些公共行为就不太合适了。使用AOP还有一种情况是为了提高程序的可维护性,AOP将程序的非核心逻辑都“横切”出来,将非核心逻辑和核心逻辑分离,使我们能集中精力在核心逻辑上,如图所示的这种情况。

在图中,每个业务流程都有日志和权限验证的功能,还有可能增加新的功能,实际上我们只关心核心逻辑,其他的一些附加逻辑,如日志和权限,我们不需要关注,这时,就可以将日志和权限等非核心逻辑“横切”出来,使核心逻辑尽可能保持简洁和清晰,方便维护。这样“横切”的另外一个好处是,这些公共的非核心逻辑被提取到多个切面中了,使它们可以被其他组件或对象复用,消除了重复代码。

AOP把软件系统分为两个部分:核心关注点和横切关注点。业务处理的主要流程是核心关注点,与之关系不大的部分是横切关注点。横切关注点的一个特点是,它们经常发生在核心关注点的多处,而各处都基本相似,比如权限认证、日志、事务处理。AOP 的作用在于分离系统中的各种关注点,将核心关注点和横切关注点分离开来。

实现AOP的技术分为:静态织入和动态织入。静态织入一般采用抓们的语法创建“方面”,从而使编译器可以在编译期间织入有关“方面”的代码,AspectC++就是采用的这种方式。这种方式还需要专门的编译工具和语法,使用起来比较复杂。我将要介绍的AOP框架正是基于动态织入的轻量级AOP框架。动态织入一般采用动态代理的方式,在运行期对方法进行拦截,将切面动态织入到方法中,可以通过代理模式来实现。

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<memory>
#include<string>
#include<iostream>
using namespace std;
class IHello
{
public:

IHello() { }

virtual ~IHello() { }

virtualvoid Output(const string& str) {
}
};

class Hello : public IHello {
public:
void Output(const string& str) override {
cout <<str<< endl;
}
};

class HelloProxy : public IHello {
public:
HelloProxy(IHello* p) : m_ptr(p) { }

~HelloProxy() {
delete m_ptr;
m_ptr = nullptr;
}

void Output(const string& str) final {
cout <<"Before real Output"<< endl;
m_ptr->Output(str);
cout <<"After real Output"<< endl;
}

private:
IHello* m_ptr;
};


void TestProxy()
{
std::shared_ptr<IHello> hello = std::make_shared<HelloProxy>(newHello());
hello->Output("It is a test");
}

测试代码将输出:

1
2
3
Before real Output
It is a test
Before real Output

可以看到我们通过HelloProxy代理对象实现了对Output方法的拦截,这里Hello::Output就是核心逻辑,HelloProxy实际上就是一个切面,我们可以把一些非核心逻辑放到里面,比如在核心逻辑之前的一些校验,在核心逻辑执行之后的一些日志等。

要实现灵活组合各种切面,一个比较好的方法是将切面作为模板的参数,这个参数是可变的,支持1到N(N>0)切面,先执行核心逻辑之前的切面逻辑,执行完之后再执行核心逻辑,然后再执行核心逻辑之后的切面逻辑。这里,我们可以通过可变参数模板来支持切面的组合。AOP实现的关键是动态织入,实现技术就是拦截目标方法,只要拦截了目标方法,我们就可以在目标方法执行前后做一些非核心逻辑,通过继承方式来实现拦截,需要派生基类并实现基类接口,这使程序的耦合性增加了。为了降低耦合性,这里通过模板来做解耦,即每个切面对象需要提供Before(Args…)或After(Args…)方法,用来处理核心逻辑执行前后的非核心逻辑。

为了实现切面的充分解耦合,我们的切面不必通过继承方式实现,而且也不必要求切面必须具备Before和After方法,只要具备任意一个方法即可,给使用者提供最大的便利性和灵活性。实现这个功能稍微有点复杂,复杂的地方在于切面可能具有某个方法也可能不具有某个方法,具有就调用,不具有也不会出错。问题的本质上是需要检查类型是否具有某个方法,在C++中是无法在运行期做到这个事情的,因为C++像不托管语言c#或java那样具备反射功能,然而,我们可以在编译期检查类型是否具有某个方法。

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
#define HAS_MEMBER(member)\
template<typename T, typename... Args>struct has_member_##member\
{\
private:\
template<typename U> static auto Check(int) -> decltype(std::declval<U>().member(std::declval<Args>()...), std::true_type()); \
template<typename U> static std::false_type Check(...);\
public:\
enum{value = std::is_same<decltype(Check<T>(0)), std::true_type>::value};\
};\

HAS_MEMBER(Foo)
HAS_MEMBER(Before)
HAS_MEMBER(After)

#include <NonCopyable.hpp>
template<typename Func, typename... Args>
struct Aspect : NonCopyable
{
Aspect(Func&& f) : m_func(std::forward<Func>(f)) {
}

template<typename T>
typename std::enable_if<has_member_Before<T, Args...>::value&&has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
{
aspect.Before(std::forward<Args>(args)...);//核心逻辑之前的切面逻辑
m_func(std::forward<Args>(args)...);//核心逻辑
aspect.After(std::forward<Args>(args)...);//核心逻辑之后的切面逻辑
}

template<typename T>
typename std::enable_if<has_member_Before<T, Args...>::value&&!has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
{
aspect.Before(std::forward<Args>(args)...);//核心逻辑之前的切面逻辑
m_func(std::forward<Args>(args)...);//核心逻辑
}

template<typename T>
typename std::enable_if<!has_member_Before<T, Args...>::value&&has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
{
m_func(std::forward<Args>(args)...);//核心逻辑
aspect.After(std::forward<Args>(args)...);//核心逻辑之后的切面逻辑
}

template<typename Head, typename... Tail>
void Invoke(Args&&... args, Head&&headAspect, Tail&&... tailAspect)
{
headAspect.Before(std::forward<Args>(args)...);
Invoke(std::forward<Args>(args)..., std::forward<Tail>(tailAspect)...);
headAspect.After(std::forward<Args>(args)...);
}

private:
Func m_func; //被织入的函数
};
template<typenameT> using identity_t = T;

//AOP的辅助函数,简化调用
template<typename... AP, typename... Args, typename Func>
void Invoke(Func&&f, Args&&... args)
{
Aspect<Func, Args...> asp(std::forward<Func>(f));
asp.Invoke(std::forward<Args>(args)..., identity_t<AP>()...);
}

实现思路很简单,将需要动态织入的函数保存起来,然后根据参数化的切面来执行Before(Args…)处理核心逻辑之前的一些非核心逻辑,在核心逻辑执行完之后,再执行After(Args…)来处理核心逻辑之后的一些非核心逻辑。上面的代码中的has_member_Before和has_member_After这两个traits是为了让使用者用起来更灵活,使用者可以自由的选择Before和After,可以仅仅有Before或After,也可以二者都有。

需要注意的是切面中的约束,因为通过模板参数化切面,要求切面必须有Before或After函数,这两个函数的入参必须和核心逻辑的函数入参保持一致,如果切面函数和核心逻辑函数入参不一致,则会报编译错误。从另外一个角度来说,也可以通过这个约束在编译期就检查到某个切面是否正确。

下面看一个简单的测试AOP的例子,这个例子中我们将记录目标函数的执行时间并输出日志,其中计时和日志都放到切面中。在执行函数之前输出日志,在执行完成之后也输出日志,并对执行的函数进行计时。

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
struct TimeElapsedAspect
{
void Before(int i) {
m_lastTime = m_t.elapsed();
}

void After(int i) {
cout <<"time elapsed: "<< m_t.elapsed() - m_lastTime << endl;
}

private:
double m_lastTime;
Timer m_t;
};

struct LoggingAspect
{
void Before(int i) {
std::cout <<"entering"<< std::endl;
}

void After(int i) {
std::cout <<"leaving"<< std::endl;
}
};

void foo(int a) {
cout <<"real HT function: "<<a<< endl;
}

int main()
{
Invoke<LoggingAspect, TimeElapsedAspect>(&foo, 1); //织入方法
cout <<"-----------------------"<< endl;
Invoke<TimeElapsedAspect, LoggingAspect>(&foo, 1);

return 0;
}

使用C++开发一个轻量级的IoC容器

让对象不再直接依赖于外部对象的创建,而是依赖于某种机制,这种机制可以让对象之间的关系在外面组装,外界可以根据需求灵活地配置这种机制的对象创建策略,从而获得想要的目标对象,这种机制被称为控制反转。控制反转就是应用本身不负责依赖对象的创建和维护,而交给一个外部容器来负责。这样控制权就由应用转移到了外部容器,即实现了所谓的控制反转。IoC用来降低对象之间直接依赖产生的耦合性。

具体做法是将对象的依赖关系从代码中移出去,放到一个统一的配置文件中或者在IoC容器中配置这种依赖关系,由容器来管理对象的依赖关系。比如可以这样来初始化:

1
2
3
4
5
6
7
8
9
10
11
12
void IocSample() {
//通过IOC容器来配A和Base对象的关系
IocContainer ioc;
ioc.RegisterType<A, DerivedB>("B");
ioc.RegisterType<A, DerivedC>("C");
ioc.RegisterType<A, DerivedD>("D");

//由IoC容器去初始化A对象
A* a = ioc.Resolve<A>("B");
a->Func();
delete a;
}

在上面的例子中,我们在外面通过IoC容器配置了A和Base对象的关系,然后由IoC容器去创建A对象,这里A对象的创建不再依赖于工厂或者Base对象,彻底解耦了二者之间的关系。

IoC使得我们在对象创建上获得了最大的灵活性,大大降低了依赖对象创建时的耦合性,即使需求变化了,也只需要修改配置文件就可以创建想要的对象,而不需要修改代码了。我们一般是通过依赖注人(Dependency Injection)来将对象创建的依赖关系注人到目标类型的构造函数中。

IoC容器实际上具备两种能力,一种是对象工厂的能力,不仅可以创建所有的对象,还能根据配置去创建对象;另一种能力是可以去创建依赖对象,应用不需要直接创建依赖对象,由IoC容器去创建,实现控制反转。

IoC创建对象

因为IoC容器本质上是为了创建对象及依赖的对象,所以实现loc容器第一个要解决的问题是如何创建对象。IoC容器要创建所有类型对象的能力,并且还能根据配置来创建依赖对象。我们先看看如何实现一个可配置的对象工厂。

一个可配置的对象工厂实现思路如下:先注册可能需要创建的对象类型的构造函数,将其放到一个内部关联容器中,设置键为类型的名称或者某个唯一的标识,值为类型的构造函数,然后在创建的时候根据类型名称或某个唯一标识来查找对应的构造函数并最终创建出目标对象。对于外界来说,不需要关心对象具体是如何创建的,只需要告诉工厂要创建的类型名称即可,工厂获取了类型名称或唯一标识之后就可以创建需要的对象了。由于工厂是根据唯一标识来创建对象,所以这个唯一标识是可以写到配置文件中的,这样就可以根据配置动态生成所需要的对象了,我们一般是将类型的名称作为这个唯一标识。

类型擦除就是将原有类型消除或者隐藏。为什么要擦除类型?因为很多时候我们不关心只体类型是什么或者根本就不需要这个类型。类型擦除可以获取很多好处,比如使得程序有更好的扩展性,还能消除耦合以及消除一些重复行为,使程序更加简洁高效。下面是一些常用的类型擦除方式:

  • 通过多态来擦除类型。
  • 通过模板来擦除类型。
  • 通过某种类型容器来擦除类型。
  • 通过某种通用类型来擦除类型。
  • 通过闭包来擦除类型。

第一种类型擦除方式是最简单的,也是经常用的,通过将派生类型隐式转换成基类型,再通过基类去调用虚函数。在这种情况下,我们不用关心派生类的具体类型,只需要以一种统一的方式去做不同的事情,所以就把派生类型转成基类型隐藏起来,这样不仅可以多态调用,还使程序具有良好的可扩展性。然而这种方式的类型擦除仅是将部分类型擦除,因为基类型仍然存在,而且这种类型擦除的方式还必须继承这种强耦合的方式。正是因为这些缺点,通过多态来擦除类型的方式有较多局限性,并且效果也不好。这时通过第二种方式来擦除类型,可以以解决第一种方式的一些问题。通过模板来擦除类型,本质上是把不同类型的共同行为进行了抽象,这时不同类型彼此之间不需要通过继承这种强耦合的方式去获得共同的行为,仅仅是通过模板就能获取共同行为,降低了不同类型之间的耦合,是一种很好的类型擦除方式。然而,第二种方式虽然降低了对象间的耦合,但是还有一个问题没解决,就是基本类型始终需要指定,并没有消除基本类型,例如,不可能把一个T本身作为容器元素,必须在容器初始化时指定T为某个具体类型。

有时,希望有一种通用的类型,可以让容器容纳所有的类型,作为所有类型的基类,可以当作一种通用的类型。之前实现的Variant类可以把不同的类型抱起来,获得一种统一的类型,而且不同类型之间没有耦合关系。比如,可以通过Variant这样来擦除类型:

1
2
3
4
5
//定义通用的类型,这个类型可能容纳多种类型
typedef Variant<double, int, uint32_t, char*>Value;
vt.pushback(l);
vt.pushback("test");
vt.pushback(1.22);

上面的代码擦除了不同类型,使得不同的类型都可以放到一个容器中了,如果要取出来就很简单,通过Get()就可以获取对应类型的值。这种方式是通过类型容器把类型包起来了,从而达到类型擦除的目的。这种方式的缺点是通用的类型必须事先定义好,它只能容
纳声明的那些类型,是有限的,超出定义的范围就不行了。

通过某种通用类型来擦除原有类型的方式可以消除这个缺点,这种通用类型就是Any类型,下面介绍怎么用Any来擦除类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
vector<Any> v;
v.pushback(1);
v.pushback("test");
v.pushback(2.35);
auto r1 = v[0].AnyCast<int>();
auto r2 = v[1].AnyCast<const char*>();
auto r3 = v[2].AnyCast<double>();

Any a = 1;
if(a.Is<int>()) {
int I = a.AnyCast<int>();
}

在上面的代码中,不需要预先定义类型的范围,允许任何类型的对象都赋值给Any对象,消除了Variant类型只支持有限类型的问题,但是Any的缺点是:在取值的时候仍然需要具体的类型。

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
#include <string>
#include <map>
#include <memory>
#include <functional>
using namespace std;
#include <Any>
#include<NonCopyable>

class IocContainer {
public:
IocContainer(void){}
~IocContainer(void){}

template <class T, typename Depend>
void RegisterType(const string& strKey) {
std::function<T*()> function = []{ return new T(new Depend());};
RegisterType(strKey, function);
}

template <class I>
I* Resolve(string strKey)
{
if (m_creatorMap.find(strKey) == m_creatorMap.end())
return nullptr;

Any resolver = m_creatorMap[strKey];
std::function<I* ()> function = resolver.AnyCast<std::function<I*()>>();
return function();
}

template <class I>
std::shared_ptr<I> ResolveShared(const string& strKey)
{
auto b = Resolve<I>(strKey);
return std::shared_ptr<I>(b);
}
private:
void RegisterType(const string& strKey, Any constructor)
{
if (m_creatorMap.find(strKey) != m_creatorMap.end())
throw std::logic_exception("this key has already exist!");

m_creatorMap.insert(make_pair(strKey, constructor));
}

private:
unordered_map<string, Any> m_creatorMap;
};
int main() {
IocContainer ioc;
ioc.RegisterType<A, DerivedB>("B");
ioc.RegisterType<A, DerivedC>("C");
ioc.RegisterType<A, DerivedD>("D");

auto pa = ioc.ResolveShared<A>("B");
pa->Func();
}

这样仍然不太方便,但是可以改进,可以借助闭包,将一些类型信息保存在闭包中,闭包将类型隐藏起来了,从而实现了类型擦除的目的。由于闭包本身的类型是确定的,所以能放到普通的容器中,在需要的时候从闭包中取出具体的类型。下面看看如何通过闭包来擦除类型,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
void Func(T t) {
cout<<t<<endl;
}
void TestErase() {
int x = 1;
char y = 's';

vector<std::function<void()>> v;
v.push_back([x]{Func(x);});
v.push_back([y]{Func(y);});

for(auto item : v) {
item();
}
}

最后的可变参数模板改进IoC容器,支持带参数对象的创建。

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
#include <string>
#include <map>
#include <memory>
#include <functional>
using namespace std;
#include <Any>
#include<NonCopyable>

class IocContainer : NonCopyable
{
public:
IocContainer(void){}
~IocContainer(void){}

template <class T, typename Depend, typename ... Args>
void RegisterType(const string& strKey)
{
std::function<T*(Args...)> function = [](Args... args){ return new T(new Depend(args...));};
RegisterType(strKey, function);
}

template <class T, typename ... Args>
I* Resolve(const string& strKey, Args ... args)
{
if (m_creatorMap.find(strKey) == m_creatorMap.end())
return nullptr;

Any resolver = m_creatorMap[strKey];
std::function<T*(Args...)> function = resolver.AnyCast<std::function<T*(Args...)>>();
return function(args...);
}

template <class I, typename... Args>
std::shared_ptr<I> ResolveShared(const string& strKey, Args... args)
{
I* i = Resolve<I>(strKey, args...);
return std::shared_ptr<I>(i);
}

private:
void RegisterType(const string& strKey, Any constructor)
{
if (m_creatorMap.find(strKey) != m_creatorMap.end())
throw std::logic_exception("this key has already exist!");

m_creatorMap.emplace(strKey, constructor);
}

private:
unordered_map<string, Any> m_creatorMap;
};

int main() {
IocContainer ioc;
ioc.RegisterType<A, DerivedC>("C");
auto c = ioc.ResolveShared<A>("C");

ioc.RegisterType<A, DerivedB, int, double>("C");
auto b = ioc.ResolveShared<A>("C", 1, 2.0);
b->Func();
}

类型注册分成三种方式注册,一种是简单方式注册,它只需要具体类型信息和key,类型的构造函数中没有参数,从容器中取也只需要类型和key;另外一种简单注册方式需要接口类型和具体类型,返回实例时,可以通过接口类型和key来得到具体对象;第三种是构造函数中带参数的类型注册,需要接口类型、key和参数类型,获取对象时需要接口类型、key和参数。返回的实例可以是普通的指针也可以是智能指针。需要注意的是key是唯一的,如果不唯一,会产生一个断言错误,推荐用类型的名称作为key,可以保证唯一性,std::string strKey = typeid(T).name()。

让自己习惯C++

视C++为一个语言联邦

  1. C语言
  2. 面对对象:构造函数、析构函数、封装、继承、多态、virtual函数
  3. C++模板:template metaprogramming
  4. STL容器:对容器、迭代器、算法以及函数对象的规约有极佳的紧密配合与协调

尽量以const,enum,inline替换#define

const的好处:

  • define直接常量替换,出现编译错误不易定位(不知道常量是哪个变量)
  • define盲目的将宏名替换,导致目标码出现多份
  • define没有作用域,const有作用域提供了封装性

定义常量指针:有必要将指针(而不只是指针所指之物)声明为const:

1
const char* const authorName = "Scott Meyers"

enum的好处:

  • 提供了封装性
  • 编译器肯定不会分配额外内存空间(其实const也不会)

inline的好处:

  • define宏函数容易造成误用(下面有个例子)
1
2
3
4
5
#define MAX(a, b) a > b ? a : b

int a = 5, b = 0;
MAX(++a, b) //a++调用2次
MAX(++a, b+10) //a++调用一次

使用template inline 函数:

1
2
3
4
5
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
f(a > b ? a : b)
}

对单纯常量,最好以const对象或enums替换#define
形似函数的宏,最好改用inline函数替换#define

宏实现工厂模式

需要一个全局的map用于存储类的信息以及创建实例的函数
需要调用全局对象的构造函数用于注册

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

typedef void *(*register_fun)();

class CCFactory{
public:
static void *NewInstance(string class_name){
auto it = map_.find(class_name);
if(it == map_.end()){
return NULL;
}else
return it->second();
}
static void Register(string class_name, register_fun func){
map_[class_name] = func;
}
private:
static map<string, register_fun> map_;
};

map<string, register_fun> CCFactory::map_;

class Register{
public:
Register(string class_name, register_fun func){
CCFactory::Register(class_name, func);
}
};

#define REGISTER_CLASS(class_name); \
const Register class_name_register(#class_name, []()->void *{return new class_name;});

尽可能使用const

const指定一个语义约束,编译器会强制实施这项约束。可以用const在class外部修饰global或namespace作用域中的常量,可以指出指针自身、指针所指物,或者两者都是const。

  • char greeting[] = "hello"
  • char* p = greeting:non-const pointer,non-const data
  • const char* p = greeting:non-const pointer,const data
  • char* const p = greeting:const point,non-const data
  • const char* const p = greeting:const pointer,const data

如果关键字const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量。
STL迭代器系以指针为根据塑模出来,所以迭代器的作用也像是T*指针,声明迭代器为const表示这个迭代器不得指向不同的东西,但它所指的东西的值是可以改动的。

1
2
3
const std::vector<int>::iterator iter = vec.begin()
可以:*iter=10
不可以:++iter

  • const定义接口,防止对返回值误用
  • const成员函数,代表这个成员函数承诺不会改变对象值,可以操作const对象
  • 两个函数如果只是常量值不同,可以被重载
1
2
3
4
5
6
7
8
9
10
11
class CTextBlock {
public:
char& operator[](std::size_t position) const
{ return pText[position]; }
private:
char* pText;
}

const CtextBlock cctb("Hello");
char* pc = &ccb[0];
*pc = 'C'

这个class不适当的将其operator[]声明为const成员函数,但是该函数却返回一个reference指向对象内部值。
上述代码调用了const成员函数,但是允许修改值。

const和non-const成员函数中避免重复

常量性转除:将常量性消除掉,比如const operator[]实现了non-const版本的一切,唯一不同是其返回类型多了一个const资格修饰。转除的方法如下:

1
2
3
char& operator[] (std::size_t position) {
return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]);
}

这份代码有两个转型动作,让non_const operator[]调用其const兄弟,而且明确指出调用的是const operator[],因此第一次为*this添加const,第二次从const operator[]返回值中移除const。

如果在const函数中调用了non-const函数,则打破了不改变其对象的承诺。

const成员只能调用const成员函数(加-fpermissive编译选项就可以了)。
非const成员可以调用所有成员函数

确定对象使用前已被初始化

永远在使用对象之前将其初始化。
对于无任何成员的内置类型,需要在定义时初始化,C++不保证初始化它们。

至于内置类型之外的其他,初始化责任落在构造函数上,C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,因此最好使用初始化序列(序列顺序与声明顺序相同),而不是在构造函数中赋值。

1
2
3
4
5
6
7
8
9
ABEntry::ABEntry(const std::string& name, 
const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{
}

这个版本的构造函数效率较高,基于赋值的构造函数首先调用default构造函数为theName,theAddress等设初值,然后再对他们赋值,成员初值列的做法避免了这一问题。

如果有的变量是const或static的,就一定要赋初值,使用初值列,最简单的做法是使用初值列,又比赋值更为高效。

C++有着固定的成员初始化次序,base calss总是早于其derived class被初始化,而class的成员变量总是以其声明次序被初始化。

不同编译单元内定义的non-local static对象的初始化次序

static对象,其寿命从被构造出来直到程序结束为止,这种对象包括global对象,定义于namespace作用域内的对象,在class内、在函数内被声明为static的对象。函数内的static对象称为local static对象,其他的是non-local static对象。

编译单元是指产出同一目标文件的源码,基本上是单一源码文件加上其所含入的头文件。

如果某编译单元内的某个non-local static对象的初始化动作使用了另一编译单元内的某个non-local static对象,它所用到的这个对象可能未被初始化。

C++对不同编译单元内定义的non-local static对象的初始化次序并无规定。

将每个non-local static对象放入一个函数,该对象在函数中被声明为static,这些函数返回一个reference指向它所含的对象,因为C++保证函数内的local static对象会在“函数被调用期间”“首次遇上该对象之定义式”时被初始化。(Singleton模式)

1
2
3
4
Fuck& fuck(){
static Fuck f;
return f;
}

构造/析构/赋值运算

了解C++默默编调用了哪些函数

如果类中没有定义,程序却调用了,编译器会产生一些函数(public且inline):

  • 一个 default 构造函数
  • 一个 copy 构造函数
  • 一个 copy assignment 操作符
  • 一个析构函数(non virtual)

default构造函数和析构函数主要是给编译器一个地方用来放置“藏身幕后”的代码,编译器产生的析构函数时non-virtual函数。至于copy和copy assignment函数,单纯将来源对象的每一个non-static成员变量拷贝到目标对象。

如果要在一个内含reference成员的class内支持赋值操作,则必须自己定义一个copy assignment操作,因为reference不能随意的重新赋值。因此,含有引用成员变量或者const成员变量不产生赋值操作符。

如果自己构造了带参数的构造函数,编译器不会产生default构造函数。

base class如果把拷贝构造函数或者赋值操作符设置为private,则不会产生这两个函数。

1
2
3
4
5
class Fuck{
private:
std::string& str;//引用定义后不能修改绑定对象
const std::string con_str;//const对象定义后不能修改
};

若不想使用编译器自动生成的函数,就该明确拒绝

将默认生成的函数声明为private,由明确声明一个成员函数,阻止编译器自动生成。

1
2
3
4
5
class Uncopyable{
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator= (const Uncopyable&);
}

为多态基类声明virtual析构函数

当derived class对象经由一个base calss指针被删除,而该base class自带一个non-virtual析构函数,其结果未有定义,实际执行时通常发生的是对象的derived成分未被删除,而derived class的析构函数也未被执行。

因此给多态基类应该主动声明virtual析构函数。非多态基类,没有virtual函数,不要声明virtual析构函数。

欲实现出virtual函数,对象必须携带某些信息用来在运行期决定那一个virtual函数被调用,通常是由一个vptr指针指出,它指向一个由函数指针构成的数组,成为vtbl,每一个带有virtual函数的class都有一个vtbl。

如果class中带有virtual函数,则对象的体积会增加,因此当class内至少一个virtual函数,才为它声明virtual析构函数。

pure virtual函数导致abstract class——也就是不能被实体化的class。为希望成为抽象的那个class提供一个pure virtual析构函数,并为析构函数提供一份定义。

析构函数的运作:最深层派生的那个class其析构函数最早被调用,然后是其每一个base calss的析构函数被调用。

别让异常逃离析构函数

构造函数可以抛出异常,析构函数不能抛出异常。

因为析构函数有两个地方可能被调用。一是用户调用,这时抛出异常完全没问题。二是前面有异常抛出,正在清理堆栈,调用析构函数。这时如果再抛出异常,两个异常同时存在,异常处理机制只能terminate()。

构造函数抛出异常,会有内存泄漏吗?
不会!

1
2
3
4
5
6
7
8
try {
// 第二步,调用构造函数构造对象
new (p)T; // placement new: 只调用T的构造函数
}
catch(...) {
delete p; // 释放第一步分配的内存
throw; // 重抛异常,通知应用程序
}

绝不在构造和析构过程中调用virtual函数

derived calss对象的base class成分会在derived class自身成分被构造之前先妥善构造,如果在构造base class成分之后即调用virtual function,则这个virtual function指向的可能是base class中的function,不是derived class中的function,即在base class构造期间,virtual函数不是virtual函数。

构造和析构过程中,虚表指针指向的虚表在变化。调用的是对应虚表指针指向的函数。

一种可行的做法是:在base class中将函数改为non-virtual函数,然后要求derived class构造函数传递必要信息给base class构造函数,而后那个构造函数会安全地调用non-virtual的函数。

令operator= 返回一个reference to *this

连锁赋值:赋值操作符必须返回一个reference指向操作符的左侧实参。

1
2
3
Widget& operator=(const Widget& rhs) {
return *this;
}

在operator= 里处理自我赋值

传统做法是借由operator=最前面的一个“证同测试”达到“自我赋值”的检验目的

1
2
3
4
5
6
7
Widget& Widget::operator== (const Widget& rhs){
if(this == &rhs) return *this

delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

或者使用copy and swap技术:
1
2
3
4
5
Widget& Widget::operator== (const Widget& rhs) {
Widget temp(rsh);
swap(temp); // 将this同上述复件的副本交换
return *this;
}

其原理是某class的copy assignment操作符可能被声明为“以by value的方式接受实参”;以by value方式传递东西会生成一份复件

复制对象时务忘其每一个成分

记得实现拷贝构造函数和赋值操作符的时候,调用base的相关函数
可以让拷贝构造函数和赋值操作符调用一个共同的函数,例如init()
如果为derived class撰写copying 函数,必须也很小心地复制其base class成分,应该让derived class的copying函数调用相应的base class。

资源管理

以对象管理资源

为了确保资源总是被释放,需要将资源放进对象内,当控制流离开函数,对象的析构函数将自动释放那些资源,这实际上是依赖了C++的“析构函数自动调用机制”。
auto_ptr正是用于在控制流离开函数时释放对象用的,其析构函数自动对其所指的对象调用delete。

1
2
3
void f() {
std::auto_ptr<Investment> pInv(createInvestment());
}

  • 获得资源后立刻放进管理管理对象,createInvestment()返回的资源被当作其管理者auto_ptr的初值,实际上“以对象管理资源”的观念被称为“资源取得时机便是初始化时机(RAII)”
  • 管理对象运用析构函数确保资源被释放。不论控制流如何离开函数,一旦对象被销毁其析构函数自然会被调用,于是资源被释放。
  • 别让多个auto_ptr同时指向同一对象,这样的话对象会被删除一次以上。所以它并不是管理动态分配资源的利器。

auto_ptr的替代方案是“引用计数型智慧指针(RCSP)”,持续追踪共有多少对象指向某笔资源,并在无人指向它时自动删除该对象,类似垃圾回收,但是无法打破环状引用。

shared_ptr是RCSP

1
std::tr1::shared_ptr<Investment> pInv(createInvestment());

auto_ptr和tr1::shared_ptr两者都在其析构函数内做delete而不是delete[]动作,在动态分配而得的array身上使用auto_ptr或tr1::shared_ptr不可以,还是使用vector或者string吧。

在资源管理类小心copy行为

常见的RAII对象copy行为:

  • 禁止copy:可以将copying操作声明为private
  • 引用计数:保有资源直到它的最后一个使用者被销毁

tr1::shared_ptr允许指定所谓的“删除器”,那是一个函数或函数对象,当引用次数为0时便被调用。

  • 深度复制:复制资源管理对象也要复制其包覆的资源
  • 转移底部资源拥有权:某些场景下可能希望确保永远只有一个RAII对象指向一个未加工资源,即使RAII对象被复制之后依然如此。

  • 复制RAII对象必须一并赋值它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。

  • 普遍而常见的RAII class copying行为是:抑制copying,实行引用计数法。

在资源管理类中提供对原始资源的访问

如果需要一个Investment*指针,但是函数返回一个tr1::shared_ptr对象,则需要一个函数将RAII class对象转换为其所含的原始资源。

  • 提供显示调用接口:auto_ptr和tr1::shared_ptr都提供一个get成员函数,用来执行显式转换。
  • 提供隐式转换接口(不推荐):auto_ptr和tr1::shared_ptr也重载了指针取值操作符(operator->operator*

成对使用new和delete要采用相同的格式

当使用new时,两件事发生:内存被分配出来,针对此内存会有多个构造函数被调用。当使用delete时,也有两件事发生:针对此内存会有一个或多个析构函数被调用,然后内存被释放。

分清即将被释放的内存是单一对象还是对象数组?即保证new和delete对应;new []和delete []对应。

1
2
3
4
5
//在分配的内存块前面还分配了4个字节代表数组的个数
int *A = new int[10];

//在分配的内存块前面分配了8个字节,分别代表对象的个数和Object的大小
Object *O = new Object[10];

以独立的语句将newd对象置入智能指针

1
2
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);

processWidget(new Widget, priority())函数中,tr1::shared_ptr需要一个原始指针,但是该构造函数是个explicit构造函数,无法进行隐式转换,将得自new Widget的原始指针转换为processWidget所要求的tr1::shared_ptr。可以写成这样:
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority())

但是在调用processWidget之前,需要做以下三件事:

  • 调用priority()
  • 执行new Widget
  • 调用tr1::shared_ptr构造函数

万一对priority调用导致异常,new Widget返回的指针会遗失,因为它尚未被置入tr1::shared_ptr内。避免这类问题只需要使用分离语句:

  • std::tr1::shared_ptr<Widget> pw(new Widget)
  • processWidget(pw, priority())

设计与声明

让接口容易被正确使用,不易被误用

好的接口很容易被正确使用,不容易被误用。努力达成这些性质(例如 explicit关键字)
明智而审慎地导入新类型对预防“接口被误用”有奇效。例如,一年只有12个有效月份,因此class Month应该反应这一事实,办法之一是利用enum表现月份,或者预先定义所有有效的Month:

1
2
3
4
5
6
7
8
9
10
11
12
class Month {
public:
static Month Jan() { return Month(1); }
static Month Feb() { return Month(2); }
static Month Mar() { return Month(3); }
...
static Month Dec() { return Month(12); }
private:
explicit Month(int m);
...
};
Date d(Month::Mar(), Day(30), Year(1995))

tr1::shared_ptr提供地某个构造函数接受两个实参,一个是被管理的指针,一个是引用次数变为0的时候将被调用的“删除器”,这启发我们创建一个null tr1::shared_ptr并以某函数变为其删除器。

“促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容
“防治误用”包括建立新类型,限制类型上的操作,束缚对象值,以及消除用户的资源管理责任
shared_ptr支持定制deleter,需要灵活使用

设计class犹如设计type

  • 新type的对象应该如何被创建和销毁?构造函数和析构函数应该好好设计
  • 对象的初始化和赋值应该有什么区别?
  • 新type的对象如果被pass-by-value该如何?
  • 什么是新type的合法值?维护约束条件
  • 新type需要配合某个继承图系么?如果继承自某些既有的class,就需要受到那些class设计的限制,特别是受到“他们的函数是virtual或者non-virtual的”
  • 新type需要什么样的转换?是否需要在class T1内写一个class T2的类型转换函数

宁以pass-by-refrence-to-const替换pass-by-value

缺省情况下C++以by-value的方式传递对象到函数,除非另外指定,否则参数都是以实际实参的复件为初值。
尽量以pass-by-reference-to-const替换pass-by-value,比较高效,无需调用额外的copy构造函数或者构造函数/析构函数,加入了const也避免了可能的修改。

避免切割问题:当一个derived class对象以by-value的方法传递并被视为一个base class对象,调用base class的构造函数使得derived class的特性被切割,pass-by-refrence-to-const避免了这一问题。

references往往以指针的形式实现,因此pass-by-refrence-to-const真正传递的是指针。pass-by-value比pass-by-refrence-to-const效率高些,尤其是对内置类型而言。

以上规则并不适用内置类型,以及STL迭代器,和函数对象。它们采用pass-by-value更合适(其实采用pass-by-reference-to-const也可以)

必须返回对象时,别妄想返回其reference

如果定义一个local变量,就是在stack上,不要返回pointer或者reference指向一个on stack对象,在函数返回时就被析构。
不要返回pointer或者reference指向一个on heap对象(需要用户delete,我觉得必要的时候也不是不可以)
不要返回pointer或者reference指向local static对象,却需要多个这样的对象(static只能有一份)

让诸如operator*这样的函数返回reference,只是浪费时间吧。

一个必须返回新对象的函数的正确写法:让那个函数返回一个新对象,例如:

1
2
3
inline const Rational operator*(const Rational &lhs, const Rational &rhs) {
return Rational(lhs.n*rhs.n, lhs.d*rhs.d);
}

当然,这样需要承受构造成本和析构成本。

将成员变量申明为private

切记将成员变量申明为private,使用getter和setter实现对private变量的操作,将成员变量隐藏在函数接口的背后。
protected并不比public更有封装性(用户可能继承你的base class)

宁以non-member,non-friend替换member

作者说多一个成员函数,就多一分破坏封装性,好像有点道理,但是我们都没有这样遵守。直接写member函数方便一些。
面向对象守则要求,数据以及操作数据的那些函数应该捆绑在一起,这意味着建议member函数是合适的,但是提供non-member函数可允许对相关机能有更好的封装性。

若所有参数都需要类型转换,请为此采用non-member函数

如果调用member函数,就使得第一个参数的类失去一次类型转换的机会。
当实现一个Rational类时,(构造函数刻意不为explicit,允许int-to-Rational的隐式转换。

1
2
3
4
5
6
7
8
9
10
class Rational {
public:
Rational(int numerator = 0,
int denominator = 1);
int numerator() const;
int denominator() const;
}

Rational oneEight(1, 8), oneHalf(1, 2);
Rational result = oneHalf * oneEight; // 正确

如果希望能实现混合运算,即:
1
2
result = oneHalf * 2; // 正确
result = 2 * oneHalf; // 错误

上述两式变成:
1
2
result = oneHalf.operator*(2);
result = 2.operator*(oneHalf);

这里第二个式子之所以会出错,是因为发生了隐式类型转换,编译器知道正在传递一个int,但是函数需要的是Rational,而且它也知道只要调用Rational的构造函数并赋予所提供的int即可,但是这样是不对的。

只有当参数被列于参数列表,这个参数才是隐式类型转换的合格参与者。让operator*成为一个non-member函数,允许在每一个实参上执行隐式类型转换。

考虑写一个不抛出异常的swap函数

std::swap置换两对象值,只要类型T支持copying(通过copy构造函数和copy assignment操作符完成)缺省的swap代码就会帮你置换类型为T的对象。

一种方法是“以指针指向一个对象,内含真正数据”,一旦要置换两个对象值,唯一要做的事置换其指针,但缺省的swap函数不知道这一点,将swap函数针对该类特化。

1
2
3
4
5
6
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
swap(a.pImpl, b.pImpl);
}
}

函数一开始的template<>表示它是std::swap的一个全特化版本,函数名称后的代表针对这一类特化。

当std::swap效率不高(std::swap调用拷贝构造函数和赋值操作符,如果是深拷贝,效率不会高),提供一个swap成员函数,并确定不会抛出异常。

1
2
3
4
5
6
7
8
9
10
class Obj{
Obj(const Obj&) {
//深拷贝
}
Obj& operator= (const Obj&) {
//深拷贝
}
private:
OtherClass *p;
};

如果提供一个member swap做置换工作,然后将std::swap特化,令他调用该函数
1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {
public:
void swap(Widget& other) {
std::swap(pImpl, other.pImpl);
}
};

namespace std {
template<>
void swap<Widget> (Widget& a, Widget b) {
a.swap(b);
}
}

调用swap时应该针对std::swap使用using声明式,然后调用swap不带任何”命名空间修饰”
1
2
3
4
5
6
void doSomething(Obj& o1, Obj& o2){
//这样可以让编译器自己决定调用哪个swap,万一用户没有实现针对Obj的swap,还能调用std::swap
using std::swap;

swap(o1, o2);
}

如果swap缺省实现的效率不足,则:

  1. 提供一个public swap成员函数,让它高效地置换那个类型的两个对象值;
  2. 在你的class或namespace所在的命名空间中提供一个non-member swap,并令它调用上述swap成员函数;
  3. 如果正在编写一个class,为class特化一个std::swap,并令他调用你的swap成员函数;
  4. 如果调用swap,请确定包含一个using声明式,以便让std::swap在你的函数内曝光可见,然后不加任何namespace修饰符,赤裸裸的调用swap;
  5. 成员版swap不可抛出异常。

实现

尽可能延后变量定义式出现的时间

C语言推荐在函数开始的时候定义所有变量(最开始的C语言编译器要求,现在并不需要),C++推荐在使用对象前才定义对象,尽量延后变量的定义,直到确实需要它,避免没有用到这个变量但是却承担了构造和析构成本。
不止延后到真正使用这个变量,而且要延后到能够给这个变量一个初值实参为止,如果这样,不仅能避免构造和析构非必要对象,还能避免无意义的default构造行为。

尽量少做转型动作

转型的语法:

旧式转型:

  • (T)expression
  • T(expression)

新式转型:

  • const_cast (expression):用来将对象的常量性移除;
  • dynamic_cast (expression):执行安全向下转型,用来决定对象是不是归属继承体系的某个类型;
  • reinterpret_cast (expression):低级转型,例如将一个pointer to int转型为一个int;
  • static_cast (expression):强迫隐式转换,例如将non-const转为const,将int转为double等,但无法将const转为non-const。

例子:

1
2
Derived d;
Base* pb = &d;

这里建立一个base calss指针指向一个derived class对象,但是有时候上述两个指针并不相同,这时会有一个偏移量在运行期被施加到Derived指针上,用以取得正确的Base指针。因此,单一对象可能拥有一个以上的指针。

如果想要在子类中执行父类的函数,可以如下:

1
2
3
4
5
6
class SpecialWindow : public Window {
public:
virtual void onResize() {
Window::onResize();
}
}

如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_cast。
之所以需要dynamic_cast是因为想要在一个你认为是derived class对象身上执行derived class操作函数,但是你手上只有一个指向base的pointer,有两个一般的方法可以解决这个问题:

  1. 使用容器并在其中直接存储指向derived class对象的指针,如此便消除了通过base class接口处理对象的需要。
  2. 在base class中提供virtual函数做你想对各个派生类做的事。
  • 如果转型是必要的,试着将它隐藏于某个函数后。客户可以随时调用该函数,而不需要将转型放入自己的代码。
  • 使用C++风格的转型。

避免返回handles指向对象内部成分

成员变量的封装性最多等于“返回其reference的函数”的访问级别。
如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。
简单说,就是成员函数返回指针或者非const引用不要指向成员变量,这样会破坏封装性

为“异常安全”而努力是值得的

当异常被抛出时,异常安全性函数会:

  • 不泄露任何资源
  • 不允许数据破坏

“异常安全函数”承诺即使发生异常也不会有资源泄漏。在这个基础下,它有3个级别

  • 基本保证:抛出异常,程序内的任何事物仍然保持在有效状态下,没有对象或数据结构会被破坏,所有对象处于一种内部前后一致的状态。需要用户处理程序状态改变(自己写代码保证这个级别就行了把)
  • 强烈保证:抛出异常,程序状态不改变,如果函数失败,程序状态恢复到调用前;
  • 不抛异常:承诺绝不抛出异常,因为他们总是能完成原先承诺的任务。内置类型的操作就绝不会抛出异常
1
2
3
4
5
6
7
8
class PrettyMenu {
std::tr1::shared_ptr<Image> bgImage;
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc));
++imageChange;
}

上述代码使用一个用于资源管理的智能指针,重新排列了changeBackground的语句次序,使得在更换图像之后才累加imageChanges,一般而言这是个好策略,不要为了表示某件事发生而改变对象状态,除非这件事真的发生了。

另外,使用了Lock使得不需要在末尾手动unlock,在析构函数中已经自动unlock。使用智能指针也不需要再手动delete旧图像。

“强烈保证”往往可以通过copy-and-swap实现,为你打算修改的对象原件做一份副本,然后在那份副本上做修改,若有任何修改动作抛出异常,原对象仍保持未修改状态,待所有修改完成后再将修改后的副本和原对象在一个不抛出异常的操作中置换。

但是”强烈保证”并非对所有函数都具有实现意义

1
2
3
4
5
void doSomething(Object& obj){
Object new_obj(obj);
new_obj++;
swap(obj, new_obj);
}

透彻了解inline函数的里里外外

“免除函数调用成本”
当你inline某个函数,编译器或许可以对函数本体执行语境相关最优化,大部分编译器绝不会对着一个outline函数调用动作执行如此优化。
inline函数将“对此函数的每一个调用都用函数本体替换之”,这样做可能增加目标码的大小,即使拥有虚内存,inline造成的代码膨胀亦会造成额外的换页行为,降低指令高速缓存的命中率,以及伴随而来的效率损失。

inline只是对编译器的一个申请而不是强制命令。这项申请可以隐喻指出,也可以明确提出。隐喻方式是将函数定义于class定义式内:

1
2
3
4
5
6
class Person {
public:
int age() const {return theAge; }
private:
int theAge;
}

明确声明inline的做法则是在其定义式前加上关键字inline:
1
2
3
4
template<typename T>
inline const T& std::max(const T& a, const T& b) {
return a < b ? a : b;
}

inline函数通常被定义在头文件中,因为大多数build环境在编译过程中进行inlining,而为了将一个“函数调用”替换为“被调用函数本体”,编译器必须知道那个函数长啥样,某些build环境可以在链接的时候完成inline。
大部分编译器拒绝将太过复杂的函数inlining,而所有对virtual函数的调用都会使inline落空。
一个表面上看似inline的函数是否真的inline,取决于你的编译环境,主要取决于编译器。
构造函数和析构函数如果inline的话很麻烦。
inline无法随着程序库的升级而升级,换句话说如果f是程序库内的一个inline函数,客户将f函数本体编译进代码,一旦程序库改变,所有用到f的函数都需要重新编译。如果f是non-inline函数,则只需要重新编译f就好。

这里插播一个C++处理定义的重要原则,一处定义原则:

全局变量,静态数据成员,非内联函数和成员函数只能整个程序定义一次
类类型(class,struct,union),内联函数可以每个翻译单元定义一次

将文件的编译依存关系降到最低

C++并没有将接口从实现中分离。在定义文件和其含入文件之间形成了一种编译依存关系。如果头文件中有任何一个被改变或者这些头文件依赖的任何一个头文件改变,则任何使用这个类的文件都需要重新编译。

当编译器看到一个定义式时,它必须知道要给这个定义式分配多少内存才够维持一个对象,这个问题在Java里并不存在,因为Java编译器只分配一个足够指向该对象的指针那么大的空间。

支持”编译依存最小化”的一般构想是:相依于声明式,不要相依于定义式;现实中要让头文件尽可能地自我满足,万一做不到则让它与其他文件中的声明式相依。基于此构想的两个手段是Handle classes(impl对象提供服务)和Interface classes。
其实就是使用前置声明,在main class中只有一个指针指向其实现类,这样的设计使得那些classes的修改都不需要main class重新编译。

  • 如果使用object reference或者object pointer可以完成任务,则就不要使用object
  • 如果能够,尽量以class声明式替换class定义式
  • 为声明式和定义式提供不同的头文件,当然这些文件要保持一致性。

制作handler class的办法是,令基函数成为abstract baseclass, 称为interface class,这种函数的目的是详细叙述derived class的接口,因此它通常不带成员变量,只有一个virtual析构函数和一组pure virtual函数。一个针对Person而写的interface class也许是这样的:

1
2
3
4
5
6
7
class Person {
public:
virtual ~Persion();
virtual std::string name() const = 0;
virtual std::string date() const = 0;
virtual std::string address() const = 0;
}

不可能针对“内含pure virtual函数”的Person class具现出实例。

interface class的客户必须有办法为这种class创建新对象。他们调用一个特殊函数,此函数扮演真正将被具现化的derived class的构造函数的角色,这样的函数通常称为“工厂函数”。他们返回指针,指向动态分配所得对象,而该对象支持interface class的接口,这样的函数又往往在interface class中被声明为static:

1
2
3
4
5
6
7
8
9
10
class Persion {
public:
static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
}

std::string name;
Date dateOfBirth;
Address address;

std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));

支持interface class接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。一切都在virtual构造函数实现码所在的文件内秘密发生。假设interface class Person有个具象的derived class RealPerson,后者提供继承而来的virtual函数的实现。
1
2
3
4
5
6
7
8
9
10
11
12
class RealPerson: public Person {
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr) : theName(name), theBirthDate(birthday), theAddress(addr) {}
virtual ~RealPerson();
std::string name();
std::string date();
std::string address();
private:
std::string theName;
Date theBirthDate;
Address theAddress;
}

有了RealPerson后,写出Person::create就顺理成章了。
1
2
3
std::tr1::shared_ptr<Person> Person::create(onst std::string& name, const Date& birthday, const Address& addr) {
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

在handler class上,成员函数必须通过implementation pointer取得对象数据,那会为每一次访问增加一层间接性,而每一个对象消耗的内存数量必须增加。至于interface class,由于每一个函数都是virtual,必须为每次函数调用付出一个间接跳跃的成本。

下面有个需要注意的点

1
2
3
4
5
6
7
//Obj.h
class ObjImpl;
class Obj{
public:
private:
std::shared_ptr<ObjImpl> pObjImpl;
};

上面的写法会报错,因为编译器会再.h文件里面产生默认的析构函数,
析构函数要调用ObjImpl的析构函数,然后我们现在只有声明式,不能调用ObjImpl的实现。
下面的实现才是正确的
1
2
3
4
5
6
7
8
9
//Obj.h
class ObjImpl;
class Obj{
public:
//声明
~Obj();
private:
std::shared_ptr<ObjImpl> pObjImpl;
};

1
2
3
4
5
//Obj.cpp
//现在可以看到ObjImpl的实现
#include<ObjImpl>
Obj::~Obj(){
}

继承与面对对象设计

确定你的public继承塑模出is-a模型

public继承意味着is-a。适用于base class身上的每一个函数也一定适用于derived class。
令class D以public形式继承class B,便是告诉C++编译器每一个类型为D的对象同时也是一个类型为B的对象,反之不成立。

避免遮掩继承而来的名称

当位于一个derived class成员函数内指涉base class内的某物的时候,编译器可以找到我们所指涉的东西,因为derived class继承了声明于base class的所有东西。实际运作方式是derived class作用域被嵌套进base class作用域内。

如果继承base class并加上重载函数,而你又希望重新定义或覆写其中一部分,那么你必须为那些原本会被遮掩的每个名称引入一个using声明式,否则某些你希望继承的名称会被遮掩。

子作用域会遮掩父作用域的名称。一般来讲,我们可以有以下几层作用域

  • global作用域
  • namespace作用域
  • Base class作用域
  • Derived class作用域
  • 成员函数
  • 控制块作用域

注意:遮掩的是上一层作用域的名称,重载(不同参数)的函数也会直接遮掩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base{
public:
void f1();
}

class Drive{
public:
//会遮掩f1(),子类并没有继承f1()
void f1(int);
}

Drive d;
d.f1(); //错误
d.f1(3); //正确

可以通过using声明式或者inline转交解决这一问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base{
public:
void f1();
}

//using 声明式
class Drive{
public:
using Base::f1;
void f1(int);
}

//inline转交
class Drive{
public:
void f1(){
Base::f1();
}
void f1(int);
}

区分接口继承和实现继承

public继承由两部分组成,一个是函数接口继承,一个是函数实现继承。

1
2
3
4
5
6
7
8
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };

Shape是个抽象类,它的pure virtual函数draw使它成为一个抽象类,所以只能创建其derived class的对象。draw是个纯虚函数,error是个impure virtual函数,objectID是个non-virtual函数。

pure函数必须被任何“继承了它们”的class重新声明,且它们在抽象类中没有定义,所以声明一个纯虚函数的目的是让derived class只继承函数接口。竟然可以为纯虚函数提供定义,只是在调用时要指明。

1
2
3
4
Shape* ps = new Shape;
shape* ps1 = new Rectangle;
ps1->draw();
ps1->Shape::draw();

虚函数会提供一份定义代码,derived class可以覆写它,声明虚函数的目的是让derived class继承该函数的接口和缺省实现。
继承non-virtual函数的目的是让derived class继承函数的接口和一份强制实现。

纯虚函数:提供接口继承
Drived class必须实现纯虚函数
不能构造含有纯虚函数的类

考虑virtual函数以外的选择

借由non-virtual interface实现template method模式

1
2
3
4
5
6
7
8
9
10
class Object{
public:
void Interface(){
···
doInterface();
···
}
private/protected:
virtual doInterface(){}
}

让用户通过调用public non-virtual成员函数间接调用private virtual函数。
优点:

  • 可以在调用虚函数的前后,做一些准备工作(抽出一段重复代码)
  • 提供良好的ABI兼容性
  • 没有必要让这个函数一定是private

借由Function Pointer实现Strategy模式

某个实体的某个功能函数可以在运行期变更,且同一个类的不同实体可以有不同的功能函数。

借由tr1::function完成Strategy模式

可以不再使用函数指针而是使用类型为tr1::function的对象。

聊一聊ABI兼容性

我们知道,程序库的优势之一是库版本升级,只要保证接口的一致性,用户不用修改任何代码。一般一个设计完好的程序库都会提供一份C语言接口,为什么呢,我们来看看C++ ABI有哪些脆弱性。

虚函数的调用方式,通常是 vptr/vtbl 加偏移量调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Object.h
class Object{
public:
···
virtual print(){}//第3个虚函数
···
}

//用户代码
int main(){
Object *p = new Object;
p->print(); //编译器:vptr[3]()
}

//如果加了虚函数,用户代码根据偏移量找到的是newfun函数
//Object.h
class Object{
public:
···
virtual newfun()//第3个虚函数
virtual print(){}//第4个虚函数
···
}

name mangling 名字粉碎实现重载

C++没有为name mangling制定标准。例如void fun(int),有的编译器定为fun_int_,有的编译器指定为fun%int%。

因此,C++接口的库要求用户必须和自己使用同样的编译器(这个要求好过分)

其实C语言接口也不完美
例如struct和class。编译阶段,编译器将struct或class的对象对成员的访问通过偏移量来实现

古典策略模式

用另外一个继承体系替代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Object{
public:
void Interface(){
···
p->doInterface();
···
}
private/protected:
BaseInterface *p;
}

class BaseInterface{
public:
virtual void doInterface(){}
}

绝不重新定义继承而来的non-virtual函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class B {
public:
void mf();
}
class D: public B {
public:
void mf();
}

D x;
B* pb = &x;
D* pd = &x;

pb->mf();
pd->mf();

上边调用的一个是B的mf(),一个是D的mf(),因为mf是在两个类中都有定义的,所以尽管都是x的指针,但是两个调用的mf不一样。non-virtual函数如B::mf()和D::mf()是静态绑定的,由于pb是一个B类的指针,通过pb调用的non-virtual函数永远是B所定义的版本。

virtual函数却是动态绑定的,所以它们不受这个问题困扰,如果mf是个virtual函数,则通过pb还是pd调用到的都是D的mf()。

绝不重新定义继承而来的缺省参数值

virtual函数是动态绑定,而缺省参数值是静态绑定

静态类型是它在程序中被声明时所采用的类型。有缺省参数值的成员函数,不可以在子类中赋予不同的缺省参数值,但是如果在子类中实现这个函数时未赋予缺省参数,则当调用时要指定参数。

1
2
3
4
5
6
7
8
9
10
11
class Shape {
public:
virtual void draw(Shapecolor color = Red) const = 0;
}

class Circle : public Shape {
public:
virtual void draw(Shapecolor color) const;
}
这么写的话当客户调用此函数,一定要指定参数。
因为静态绑定下这个函数并不从其base中继承缺省参数。

缺省参数值是静态绑定
虚函数是动态绑定
遵守这条规定防止出错

动态类型指的是目前所指对象的类型,也就是说这个对象将会有什么行为。动态类型可以在程序执行过程中改变。
我们可能在调用一个定义于derived class中的virtual函数时,使用了base class中为它指定的缺省参数值。

通过复合塑模出has-a或者”根据某物实现出”

复合是当某种类型的对象内含它种类型的对象,如,Person类中有Address类和PhoneNumber类,意味着has-a的关系。
根据某物实现出和is-a的区别:
这个也是什么时候使用继承,什么时候使用复合。复合代表使用了这个对象的某些方法,但是却不想它的接口入侵。

明智而审慎地使用private继承

private继承是根据某物实现出,如果继承关系是private的,则编译器不会自动将一个derived class对象转换为一个base class对象。
由private继承来的所有成员在derived class中都会变成private的,而不管它在base class中是何种。

1
2
3
4
5
class Empty {}
class HoldInt {
int x;
Empty e;
}

C++ 设计者在设计这门语言要求所有的对象必须要有不同的地址(C语言没有这个要求)。C++编译器的实现方式是给让空类占据一个字节。

C++裁定凡是独立的对象都要有非0的大小,所以sizeof(HoldInt) > sizeof(int),一个Empty成员竟然要一些空间。实际上这个Empty类可能会被编译器默默加上一个char,然后由于对齐的缘故要再加上一些内存成为一个int。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base{
public:
void fun(){}
}

//8个字节
class Object{
private:
int a;
Base b;
};

//4个字节
class Object : private Base{
private:
int a;
}

唯一一个使用private继承的理由就是,可以使用空白基类优化技术,节约内存空间
1
2
3
class HoldInt : private Empty {
int x;
}

这样的话sizeof(HoldInt) == sizeof(int),这就是所谓的空白基类最优化

明智而审慎地使用多重继承

程序有可能从一个以上的基类中继承相同名字(函数,typedef等)需要明确的指出调用哪一个基类中的函数,如a.B::bbb()
首先我们来了解一下多重继承的内存布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//包含A对象
class A{

};
//包含A,B对象
class B:public A{

};
//包含A,C对象
class C:public A{

};
//包含A,A,B,C,D对象
class D:public B, public C{

}

由于菱形继承,基类被构造了两次。其实,C++也提供了针对菱形继承的解决方案的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//包含A对象
class A{

};
//包含A,B对象
class B:virtual public A{

};
//包含A,C对象
class C:virtual public A{

};
//包含A,B,C,D对象
class D:public B, public C{

}

使用虚继承,B,C对象里面会产生一个指针指向唯一一份A对象。这样付出的代价是必须再运行期根据这个指针的偏移量寻找A对象。

从正确行为的观点看,public继承应该总是virtual的。规则很简单:任何时候当你使用public继承,请改用virtual public继承。但是正确性并不是唯一观点。为避免继承得来的成员变量重复,编译器必须提供若干幕后戏法,而其后果是:

  • 使用 virtual继承的那些 classes所产生的对象往往比使用 non-virtual继承的兄弟们体积大;
  • 访问 virtual base classes的成员变量时,也比访问 non-virtual base classes的成员变量速度慢;

种种细节因编译器不同而异,但基本重点很清楚:你得为 virtual继承付出代价

virtual继承的成本还包括其他方面。支配“virtual base classes初始化”的规则比起 non-virtual bases的情况远为复杂且不直观。 virtual base的初始化责任是由继承体系中的最低层( most derived) class负责,这暗示:

  1. classes若派生自 virtual bases而需要初始化,必须认知其 virtual bases-不论那些 bases距离多远;
  2. 当一个新的 derived class加入继承体系中,它必须承担其 virtual bases(不论直接或间接)的初始化责任。
  3. 如果必须使用virtual,则尽可能避免在其中放置数据

模板与泛型编程

了解隐式接口和编译期多态

接口:强制用户实现某些函数
多态:相同的函数名,却有不同的实现
继承和模板都支持接口和多态
对继承而言,接口是显式的,以函数为中心,多态发生在运行期;显式接口由函数的签名式(函数名、参数类型、返回类型)构成,
对模板而言,接口是隐式的,多态表现在template具象化和函数重载,隐式接口基于“有效表达式”组成。如:

1
2
3
4
5
6
7
//这里接口要求T必须实现operator >
template<typename T>
void doProcessing(T& w){
if (w.size() > 10 && w != someNastyWidget) {
...
}
}

T的隐式接口提供一下约束:

  • 它必须提供一个名为size的函数,该函数返回一个数字
  • 它必须支持一个operator!=函数,用来比较两个T类型的对象。

加诸于template上的隐式接口,就像加诸于class对象身上的显式接口一样真实,而且二者都在编译期完成检查。

了解typename的双重意义

声明template参数时,前缀关键字class和typename可以互换

1
2
3
template<class T> class Widget;
template<typename T> class Widget;
一致

然而C++并不总是把class和typename看作等价,
1
2
3
4
5
6
7
8
9
template<typename C>
void print2nd(const C& container) {
if (container.size() > 2) {
C::const_iterator iter(container.begin());
++iter;
int value = *iter;
std::cout<<value;
}
}

iter的类型是C::const_iterator,它的类型取决于template参数C。template内出现的名称如果相依于某个参数,则称之为从属参数;如果从属名在class内成嵌套状,则称为嵌套从属名称。如iter。

嵌套从属名称可能造成解析困难。如果C命名空间中有一个变量叫做const_iterator,则就奇怪了。因此上述代码可能会造成错误。iter声明式只在C::const_iterator是个类型时才合理,我们必须告诉C++说C::const_iterator是个类型,只要加上typename即可:

1
2
if (container.size() > 2) {
typename C::const_iterator iter(container.begin());

任何时候如果想要在template中指涉一个嵌套从属类型名称,就必须在紧邻它的前一个位置放上关键字typename
typename只被用来验明嵌套从属类型名称。
1
2
3
template<typaname C>
void f(const C& container, // 不用使用typename
typename C::iterator iter); // 需要使用typename

使用typename表明嵌套类型(防止产生歧义)

学习处理模板化基类内的名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A {
public:
void sendclear(const std::string& msg);
void sendencrypted(const std::string& msg);
};
class B {
public:
void sendclear(const std::string& msg);
void sendencrypted(const std::string& msg);
};
class MsgInfo { ... };

template<typename Company>
class MsgSender {
public:
void sendclear(const MsgInfo& info) {
std::string msg;
Company c;
c.sendclear(msg);
}
void sendencryted(const MsgInfo& info) {
... }
};
1
2
3
4
5
6
7
8
9
template<typename Company>
class LoggingMsgSender: public MsgSender<Company> {
public:
void sendclear(const MsgInfo& info) {
sendClearMsg(info);
}
void sendencryted(const MsgInfo& info) {
... }
};

derived class的信息传送函数有一个不同的名称,避免了遮掩继承而来的名称。问题是,当编译器遭遇class LoggingMsgSender: public MsgSender时,不知道继承的是哪个类,不到LoggingMsgSender具现化的时候,无法确切知道它是什么。

如果有个类Z,

1
2
3
4
class Z {
public:
void sendEncrypted(const std::string& msg);
}

针对Z产生一个特化版,这既不是template也不是class,而是特化版的MsgSender template。在template实参是Z时被使用,这就是所谓的模板全特化。
1
2
3
4
5
template<>
class MsgSender<Z> {
public:
void sendSecret(const MsgInfo& info);
}

考虑derived class LoggingMsgSender,如果在derived class中调用了MsgSender中因为被特化而不存在的函数(sendclear),则可以使用如下两种方法:

  • 在base class函数调用前加上this->
  • 使用using声明式,将被掩盖的base class名称带入一个derived class中。
  • 明白指出被调用的函数在哪:MsgSender<company>::sendclear
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
class Base{
public:
void print(T a) {cout <<"Base "<< a <<endl;};
};

template<typename T>
class Drive : public Base<T>{
public:
void printf(T a){

//error 编译器不知道基类有print函数
print(a);
}
};
//解决方案
//this->print();
//using Base<T>::print
//base<T>::print直接调用

将参数无关代码抽离template

避免使用template导致的代码膨胀问题,其二进制代码带着几乎重复的代码、数据,结果可能使源码看起来合身或整齐,但是目标码却不是那么回事,使用“共性与变形分析”

编写template时,把共同部分抽离。
比如:

1
2
3
4
5
template<typename T, std::size_t n>
class SquareMatrix {
public:
void invert();
}

这个template接受一个类型参数T,还接受一个类型为size_t的参数,那个是个非类型参数,这种参数和类型参数不一样,考虑:
1
2
SquareMatrix<double, 5> sm1;
SquareMatrix<double, 10> sm2;

这会具现两份代码,可以将参数5和10抽象出来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
class SquareMatrixBase {
protected:
void invert(std::size_t n);
}

template<tempname T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
private:
using SquareMatrixBase<T>::invert;
public:
void invert() { this->invert(n); }
}


SquareMatrixBase只对矩阵元素对象的类型进行具象化,不对矩阵的尺寸参数化。derived class的invert调用base class版本时用的inline调用,这些函数使用this->,因为如果若不这样做,模板化基类内的函数名会被derived class掩盖。

如何知道怎么得到数据?令SquareMatrixBase贮存一个指针,指向矩阵数值所在的内存:

1
2
3
4
5
6
7
8
9
template<typename T>
class SquareMatrixBase {
protected:
SquareMatrixBase(std::size_t n, T* pMem) : size(n), pData(pMem) { }
void setDataPtr(T* ptr) { pData = ptr; }
private:
std::size_t size;
T* pData;
}

现在可以用inline的方式调用base class的函数,后者由持有同型元素的所有矩阵共享。不同大小的矩阵只拥有单一版本的invert,可减少执行文件大小,也就因此降低程序的working set,并强化指令高速缓存区的引用集中化。

在大多数平台上,所有指针类型都有相同的二进制表述,因此凡templates持有指针者(例如list<int*>list<const int*>, list<SquareMatrix<long, 3>*>等等)往往应该对每一个成员函数使用唯一一份底层实现。这很具代表性地意味,如果你实现某些成员函数而它们操作强型指针( strongly yped pointers,即T*),你应该令它们调用另一个操作无类型指针(void*)的函数,由后者完成实际工作。
某些C+标准程序库实现版本的确为 vector、deque和1ist等 templates做了这件事。如果你关心你的 templates可能出现代码膨胀,也许你会想让你的 templates也做相同的事情。

非类型模板参数造成的代码膨胀:以函数参数或者成员变量替换
类型模板参数造成的代码膨胀:特化它们,让含义相近的类型模板参数使用同一份底层代码。例如int,long, const int

运用成员函数模版接收所有兼容类型

真实指针做得好的一件事是支持隐式转换:

1
2
3
4
5
6
7
8
9
10
11
class Top { ... };
class Middle: public Top {... };
class Bottom: public Middle { ... };

Top* ptl = new Middle
//将 Middle*转换为Top*
Top* pt2 = new Bottom;
//将 Bottom*转换为Top
const Top* pct2= ptl
//将Top*转换为 const Top*


但如果想在用户自定的智能指针中模拟上述转换,稍稍有点麻烦。我们希望以下代码通过编译:
1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class SmartPtr
public:
explicit SmartPtr(T* reality); //智能指针通常以内置(原始)指针完成初始化
};

Smartptr<Top> ptl = SmartPtr<Middle>(new Middle);
//将 SmartPtr<Middle>转换为SmartPtr<Top>
SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom);
//将 SmartPtr<Bottom>转换为SmartPtr<Top>
SmartPtr<const Top> pct= ptl
//将 Smartptr<Top>转换为Smartptr<const Top>

但是,同一个 template 的不同具现体之间并不存在什么与生俱来的固有关系。

Template和泛型编程

我们来考虑一下智能指针的拷贝构造函数和赋值操作符怎么实现。它需要子类的智能指针能够隐式转型为父类智能指针.
写一个构造模板,叫做member function template,其作用是为class生成函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
class shared_ptr{
public:
//拷贝构造函数,接受所有能够从U*隐式转换到T*的参数
template<typename U>
shared_ptr(shared_ptr<U> const &rh):p(rh.get()){
...
}
//赋值操作符,接受所有能够从U*隐式转换到T*的参数
template<typename U>
shared_ptr& operator= (shared_ptr<U> const &rh):p(rh.get()){
...
}

//声明正常的拷贝构造函数
shared_ptr(shared_ptr const &rh);
shared_ptr& operator= (shared_ptr const &rh);
private:
T *p;
}

以上对任何类型T和U,这里可以根据类型U生成一个类型T的shared_ptr,因为shared_ptr<T>有一个构造函数可以接受一个shared_ptr<U>的参数,根据对象u创建对象t,有时称为泛化copy构造函数。

member function template也常用于赋值操作,例如TR1的shared_ptr支持所有来自兼容之内置指针、tr1::shared_ptr、auto_ptr和tr1::weak_ptr的构造行为,以及所有来自上述各对象的赋值操作。

使用成员函数模版生成“可接受所有兼容类型”的函数
即使有了“泛化拷贝构造函数”和“泛化的赋值操作符”,仍然需要声明正常的拷贝构造函数和赋值操作符
在一个类模版内,template名称可被用来作为作为“template和其参数”的简略表达式

所有参数需要类型转换的时候请为模版定义非成员函数

当我们编写一个模版类,某个相关函数都需要类型转换,需要把这个函数定义为非成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class T>
class Rational
{
public:
Rational(const T& numerator = 0,
const T& denominator = 1);
const T numerator() const;
const T denominator() const;
...
};

template<typename T>
const Rational<T> operator* (const Rational<T>& lhs,
const Rational<T>& rhs)
{ ... }

Rational<int> oneHalf(1,2);
Rational<int> result = oneHalf * 2;

但是模版的类型推导遇见了问题,以oneHalf进行推导,并不困难,operator*的第一参数被声明为Rational<T>,而传递给operator*的第一实参的类型是Rational<int>,所以T一定是int,operator*的第二参数被声明为Rational<T>,而传递给operator*的第二实参的类型是int,无法通过隐式类型转换将2转换成Rational<int>,需要把这个函数声明为友元函数帮助推导。

class Rational声明operator*为friend,模版函数只有声明,编译器不会帮忙具现化,所以我们需要实现的是友元模版函数。friend函数作为一个函数而非函数模板,编译器可以在调用它的时候使用隐式类型转换。

1
2
3
4
5
6
7
8
9
10
11
template <class T>
class Rational
{

friend Rational operator* (const Rational& a, const Rational& b)
{
return Rational (a.GetNumerator() * b.GetNumerator(),
a.GetDenominator() * b.GetDenominator());
}

}

这项技术的一个趣味点是,我们虽然使用friend,却与friend的传统用途“访问class的non-public成分”亳不相干。为了让类型转换可能发生于所有实参身上,我们需要一个 non-member函数;为了令这个函数被自动具现化,我们需要将它声明在class内部;而在class内部声明 non-member函数的唯一办法就是令它成为一个 friend。因此我们就这样做了。

当我们编写一个class template,而它所提供的与此template相关的函数支持所有参数之隐式类型转换时,将那些函数定义为class template内部的friend函数。

请使用traits classes表现类型信息

1
2
template<typename T, typename DistT>
void advance(IterT& iter, DistT d);

advance只做iter+=d的操作,但是只有随机访问的迭代器才支持+=操作。面对其他威力不那么强大的迭代器种类, advance必须反复施行++或—,共d次。

STL共有5种选代器分类,对应于它们支持的操作。

  • Input送代器只能向前移动,一次一步,客户只可读取(不能涂写)它们所指的东西,而且只能读取一次。它们模仿指向输入文件的阅读指针( read pointer);C++程序库中的istream Iterators是这一分类的代表。
  • Output迭代器情况类似,但一切只为输出,它们只向前移动,一次一步,客户只可涂写它们所指的东西,而且只能涂写一次。
    它们模仿指向输出文件的涂写指针( write pointer); ostream iterators是这一分类的代表。这是威力最小的两个迭代器分类。由于这两类都只能向前移动,而且只能读或写其所指物最多一次,所以它们只适合“一次性操作算法”(one-passalgorithms)。
  • 另一个威力比较强大的分类是forward迭代器。这种迭代器可以做前述两种分类所能做的每一件事,而且可以读或写其所指物一次以上。这使得它们可施行于多次性操作算法(muli-pass algorithms)。
  • Bidirectional迭代器比上一个分类威力更大:它除了可以向前移动,还可以向后移动。STL的list迭代器就属于这一分类,set, multiset,map和 multimap的迭代器也都是这一分类;
  • 最有威力的迭代器当属 random access迭代器。它可以执行“迭代器算术”,也就是它可以在常量时间内向前或向后跳跃任意距离。这样的算术很类似指针算术,那并不令人惊讶,因为 random access迭代器正是以内置(原始)指针为榜样,而内置指针也可被当做 random access迭代器使用。 vector,deque和string提供的选代器都是这一分类
    1
    2
    3
    4
    5
    struct input_iterator_tag {}
    struct output_iterator_tag {}
    struct forward_iterator_tag: public input_iterator_tag {}
    struct bidirectional_iterator_tag: public forward_iterator_tag {}
    struct random_access_iterator_tag: public bidirectional_iterator_tag {}

我们希望以这种方式实现advance函数:

1
2
3
4
5
6
7
8
9
10
template<typename T, typename DistT>
void advance(IterT& iter, DistT d) {
if (iter is a random access iterator) {
iter += d;
}
else {
if (d >=0) { while (d--) ++iter;}
else { while (d++) --iter;}
}
}

这种方法必须事先知道iter是否为random access迭代器,这就是traits让你得以进行的事,允许你在编译期间读取某些类型信息。
标准技术是把traits信息放入一个template及其一个或多个特化版本中,这样的templates有多个,其中针对迭代器的被命名为iterator_traits:
1
2
template<typename IterT>
struct iterator_traits;

iterator_traits的运作方式是,针对每一个类型IterT,在struct iterator_traits<IterT>内一定声明某个typedef名为iterator_category,用来确认IterT的迭代器分类。用户自定义的迭代器类型都要嵌套一个typedef,名为iterator_category。例如:
1
2
3
4
5
6
7
8
template< ... >
class deque {
public:
class iterator {
public:
typedef random_access_iterator_tag iterator_category;
};
};

为了支持指针迭代器,iterator_traits特别针对指针类型提供了一个偏特化版本:
1
2
3
4
template<typename IterT>
struct iterator_traits<IterT*> {
typedef random_access_iterator_tag iterator_category;
}

有了iterator_traits,可以对advance实现之前的伪代码:
1
2
3
4
5
6
7
8
9
10
template<typename T, typename DistT>
void advance(IterT& iter, DistT d) {
if (typeid(typename std::iterator_traits<IterT>::iterator_category) == typeid(std::random_access_iterator_tag)) {
iter += d;
}
else {
if (d >=0) { while (d--) ++iter;}
else { while (d++) --iter;}
}
}

利用重载实现编译器核定成功类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T, typename DistT>
void doadvance(IterT& iter, DistT d, std::random_access_iterator_tag) {
iter += d;
}
template<typename T, typename DistT>
void doadvance(IterT& iter, DistT d, std::biredirectional_iterator_tag) {
if (d >=0) { while (d--) ++iter;}
else { while (d++) --iter;}
}
template<typename T, typename DistT>
void advance(IterT& iter, DistT d) {
doadvance(iter, d, typename std::iterator_traits<IterT>::iterator_category());
}

建立一组重载函数(身份像劳工)或函数模板(例如 doAdvance),彼此间的差异只在于各自的traits参数。令每个函数实现码与其接受之 traits信息相应和。
建立一个控制函数(身份像工头)或函数模板(例如 advance),它调用上述那些“劳工函数”并传递 traits class所提供的信息。

模版元编程

本质上就是函数式编程

1
2
3
4
5
6
7
8
//上楼梯,每次上一步或者两步,有多少种
int climb(int n){
if(n == 1)
return 1;
if(n == 2)
return 2;
return climb(n - 1) + climb(n - 2);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//元编程,采用类模版
template<int N>
class Climb{
public:
const static int n = Climb<N-1>::n + Climb<N-2>::n;
};

template<>
class Climb<2>{
public:
const static int n = 2;
};

template<>
class Climb<1>{
public:
const static int n = 1;
};

C++元编程可以将计算转移到编译期,执行速度迅速(缺陷?)

定制new和delete

了解new-handler的行为

STL容器使用的heap内存是由容器所拥有的分配器对象管理,不是被new和delete管理。
new和malloc对比:

  • new构造对象,malloc不会
  • new分配不出内存会抛异常,malloc返回NULL
  • new分配不出内存可以调用用户设置的new-handler,malloc没有。可以为每个类设置专属new handler
1
2
3
4
5
namespace std{
typedef void (*new_handler)();
//返回旧的handler
new_handler set_new_handler(new_handler p) throw();
}

new_handler是个typedef,定义出一个指针指向函数,该函数没有参数也不返回任何东西;set_new_handler则是获得一个new_handler并返回一个new_handler的函数。set_new_handler的参数是个指针,指向operator new无法分配足够内存时该被调用的函数,其返回值也是个指针,指向set_new_handler被调用前正在执行的那个new_handler函数。

当operator new无法满足内存申请时,就会不断调用new_handler函数直到找到足够的内存。
C++不支持class专属new-handler,只需令每个class提供自己的set_new_handler和operator new即可。

1
2
3
4
5
6
7
class Widget {
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
stativ void* operator new(std::size_t size) throw(std::bad_alloc);
private:
static std::new_handler currentHandler;
}

Widget内的set_new_handler将它获得的指针存储起来,然后返回之前存储的指针:
1
2
3
4
5
std::new_handler Widget::set_new_handler(std::new_handler p) throw() {
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}

operator new做以下事情:

  1. 调用标准set_new_handler告知类的错误处理函数;
  2. 调用global operator new执行实际的内存分配,如果分配失败则调用类的new handler,如果global new handler最终无法分配足够内存,会抛出一个bad_alloc异常;
  3. 如果global operator new能够分配足够一个类对象所用的内存,类的operator new则会返回一个指针,指向分配所得。

了解new和delete合理的替换时机

为何要替换编译器提供的operator new和operator delete:

  • 用来检测运用上的错误。如果将new的对象delete掉却不幸失败,会导致内存泄漏,以及其他的写入错误等;
  • 强化效能。对特定应用的内存分配进行优化
  • 收集使用上的统计数据。
  • 增加分配和归还的速度。泛用性分配器比定制性分配器慢。
  • 为了降低缺省内存管理器带来的空间额外开销。泛用性分配器在每一个分配区块上招引某些开销。
  • 为了弥补缺省分配器中的非最佳齐位,编译器自带的operator new并不保证对动态分配而得的double采取8-bytes对齐。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static const int signature = OxDEADBEEF;
typedef unsigned char Byte;
// 这段代码还有若干小错误,详下。
void* operator new(std::size_t size) throw(std::bad_alloc) {
using namespace std;
size_t realSize = size + 2 * sizeof(int);
//增加大小,使能够塞入两个size

void* pMem = malloc(realSize);
//调用 malloc取得内存
if (!pMem) throw bad_alloc();

//将signature写入内存的最前段落和最后段落
*(static_cast<int*>(pMem)) = signature;
*(reinterpret_cast<int*>(static_cast<Byte*>(pMem)+ realSize-sizeof(int)))= signature;
//返回指针,指向恰位于第一个 signature之后的内存位置
return static_cast<Byte*>(pMem) + sizeof(int);
}

这个operator new的缺点主要在于它疏忽了身为这个特殊函数所应该具备的“坚持c++规矩”的态度。
举个例子,条款51说所有operator news都应该内含一个循环,反复调用某个new-handling函数,这里却没有。专注于一个比较微妙的主题:齐位。

许多计算机体系结构要求特定的类型必须放在特定的内存地址上。例如它可能会要求指针的地址必须是4倍数或doubles的地址必须是8倍数。如果没有奉行这个约束条件可能导致运行期硬件异常。
例如 Intel x86体系结构上的doubles可被对齐于任何byte边界,但如果它是8bye齐位,其访问速度会快许多。
C++要求所有operator news返回的指针都有适当的对齐(取决于数据类型)。malloc就是在这样的要求下工作,所以令 operator返回一个得自malloc的指针是安全的。

operator new, operator delete:分配和释放内存
调用构造函数,调用析构函数
替换new和delete的理由,就是需要收集分配内存的资源信息

编写符合常规的new和delete
operator new应该内含一个无穷循环尝试分配内存,如果无法满足,就调用new-handler。class版本要处理“比正确大小更大的(错误)申请”
operator deleter应该处理Null。classz专属版本还要处理“比正确大小更小的(错误)申请”
写了operator new也要写相应的operator delete
我们知道,new一个对象要经历两步。如果在调用构造函数失败,编译器会寻找一个“带相同额外参数”的operator delete,否则就不调用,造成资源泄漏

编写new和delete时需要固守常规

operator new的返回值十分单纯。如果它有能力供应客户申请的内存,就返回一个指针指向那块内存。如果没有那个能力,就遵循条款49描述的规则,并抛出个bad_alloc异常。
然而其实也不是非常单纯,因为operator new实际上不只一次尝试分配内存,并在每次失败后调用new-handling函数。这里假设new- handling函数也许能够做某些动作将某些内存释放出来。只有当指向 new-handling函数的指针是 null, operatornew才会抛出异常。

即使客户要求分配0byte的内存,operator也要返回一个合法指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void* operator new(std::size_t size) throw (std::bad_alloc) {
//你的 operator new可能接受额外参数
using namespace std;
if (size == 0) {
//处理0-byte申请
size = 1;
//将它视为1-byte申请
}
while (true)
// 尝试分配
if (分配成功)
return;
// 分配失败,找出目前的new_handling函数
new_handler globalHandler = set_new_handler(0);
set_new_handler(globanHandler);

if (globalHandler) (*globalHandler)();
else throw std::bad_alloc();
}

operator new内含一个无穷循环,而上述伪码明白表明出这个循环;”while(true)”就是那个无穷循环。退出此循环的唯一办法是内存成功分配或new- handling函数做了一件描述于条款49的事情:让更多内存可用、安装另一个 new-handler、卸除new-handler、抛出bad_a1oc异常(或其派生物),或是承认失败而直接 return。

operator new成员函数会被derived classes继承,这会导致某些有趣的复杂度。上述operator伪码中,函数尝试分配size bytes(除是0)。那非常合理,因为size是函数接受的实参。然而就像条款50所言,写出定制型内存管理器的一个最常见理由是为针对某特定class对象分配提供最优化,却不是为了其derived class,base class的operator new用于derived class时会有问题。

如果你决定写个operator new[],唯一要做的一件事就是分配一块未加工内存,因为你无法对array之内迄今尚未存在的元素对象做任何事情。实际上你甚至无法计算这个array将含多少个元素对象。首先你不知道每个对象多大,毕竟base class的operator new有可能经由继承被调用,将内存分配给“元素为 derived class对象”的array使用。

operator delete的情况更简单,C++保证删除null指针永远安全,所以我们必须兑现这个要求。

写了placement new也要写placement delete

举个例子,假设你写了一个class专属的operator new,要求接受一个ostream,用来志记(logged)相关分配信息,同时又写了一个正常形式的class专属operator delete:

1
2
3
4
5
6
7
class Widget {
public:
static void* operator new (std::size_t size, std::ostream& logstream) throw(std::bad_alloc);
//非正常形式的new
static void operator delete(void* pMemory, std::size_t size) throw();
//正常的 class专属 delete
};

这个设计有问题,但在探讨原因之前,我们需要先绕道,扼要讨论若干术语。
如果operator new接受的参数除了一定会有的那个size_t之外还有其他,这便是个所谓的placement new。因此,上述的operator new是个 placement版本。
众多placement new版本中特别有用的一个是“接受一个指针指向对象该被构造之处”,那样的operator new如下:
1
2
void* operator new(std::size_t, void* pMemory) throw();
//placement new

这个版本的new已被纳入C++标准程序库,你只要#include<new>就可以取用它。这个new的用途之一是负责在vector的末使用空间上创建对象。
实际上它正是这个函数的命名根据:一个特定位置上的new。
大多数时候他们谈的是此一特定版本,也就是“唯一额外实参是个void*”,少数时候才是指接受任意额外实参之operator new。

类似于new的placement版本,operator delete如果接受额外参数,便称为placement delete。

规则很简单:如果一个带额外参数的operator new没有“带相同额外参数”的对应版operator delete,那么当new的内存分配动作需要取消并恢复旧观时就没有任何operator delete会被调用。因此,为了消弭稍早代码中的内存泄漏,Widget有必要声明一个placement delete,对应于那个有志记功能的placement new:

1
2
3
4
5
6
class Widget{
public:
static void* operator new(std::size_t size, std::ostream& logstream) throw(std::bad_alloc);
static void operator delete(void* pMemory) throw();
static void operator delete(void* pMemory, std::ostream& logStream) throw();
}

如果以下语句引发异常,则placement delete自动调用,保证不泄露内存:
1
Widget* pw = new (std::cerr) Widget;

placement delete只有在伴随placement new调用而触发的构造函数出现异常时才会调用。

如果要对所有与placement new相关的内存泄漏宣战,必须同时提供一个正常的operator delete和一个placement delete分别用于构造时有/无异常抛出的情况。

STL使用小细节

为不同的容器选择不同删除方式:
删除连续容器(vector,deque,string)的元素
当c是vector、string,删除value

1
c.erase(remove(c.begin(), c.end(), value), c.end());

判断value是否满足某个条件,删除
1
2
bool assertFun(valuetype);
c.erase(remove_if(c.begin(), c.end(), assertFun), c.end());

有时候我们不得不遍历去完成,并删除
1
2
3
4
5
6
7
8
for(auto it = c.begin(); it != c.end(); ){
if(assertFun(*it)){
···
it = c.erase(it);
}
else
++it;
}

删除list中某个元素
1
c.remove(value);

判断value是否满足某个条件,删除
1
c.remove(assertFun);

删除关联容器(set,map)中某个元素
1
2
3
4
5
6
7
8
9
10
c.erase(value)

for(auto it = c.begin(); it != c.end(); ){
if(assertFun(*it)){
···
c.erase(it++);
}
else
++it;
}

用一句话解释动态规划就是 “记住你之前做过的事”,如果更准确些,其实是 “记住你之前得到的答案”。

我举个大家工作中经常遇到的例子。

在软件开发中,大家经常会遇到一些系统配置的问题,配置不对,系统就会报错,这个时候一般都会去 Google 或者是查阅相关的文档,花了一定的时间将配置修改好。

过了一段时间,去到另一个系统,遇到类似的问题,这个时候已经记不清之前修改过的配置文件长什么样,这个时候有两种方案,一种方案还是去 Google 或者查阅文档,另一种方案是借鉴之前修改过的配置,第一种做法其实是万金油,因为你遇到的任何问题其实都可以去 Google,去查阅相关文件找答案,但是这会花费一定的时间,相比之下,第二种方案肯定会更加地节约时间,但是这个方案是有条件的,条件如下:

之前的问题和当前的问题有着关联性,换句话说,之前问题得到的答案可以帮助解决当前问题

需要记录之前问题的答案

当然在这个例子中,可以看到的是,上面这两个条件均满足,大可去到之前配置过的文件中,将配置拷贝过来,然后做些细微的调整即可解决当前问题,节约了大量的时间。

不知道你是否从这些描述中发现,对于一个动态规划问题,我们只需要从两个方面考虑,那就是 找出问题之间的联系,以及 记录答案,这里的难点其实是找出问题之间的联系,记录答案只是顺带的事情,利用一些简单的数据结构就可以做到。

思考动态规划问题的四个步骤

一般解决动态规划问题,分为四个步骤,分别是

  • 问题拆解,找到问题之间的具体联系
  • 状态定义
  • 递推方程推导
  • 实现

这里面的重点其实是前两个,如果前两个步骤顺利完成,后面的递推方程推导和代码实现会变得非常简单。

这里还是拿 Quora 上面的例子来讲解,“1+1+1+1+1+1+1+1” 得出答案是 8,那么如何快速计算 “1+ 1+1+1+1+1+1+1+1”,我们首先可以对这个大的问题进行拆解,这里我说的大问题是 9 个 1 相加,这个问题可以拆解成 1 + “8 个 1 相加的答案”,8 个 1 相加继续拆,可以拆解成 1 + “7 个 1 相加的答案”,… 1 + “0 个 1 相加的答案”,到这里,第一个步骤 已经完成。

状态定义 其实是需要思考在解决一个问题的时候我们做了什么事情,然后得出了什么样的答案,对于这个问题,当前问题的答案就是当前的状态,基于上面的问题拆解,你可以发现两个相邻的问题的联系其实是 后一个问题的答案 = 前一个问题的答案 + 1,这里,状态的每次变化就是 +1。

定义好了状态,递推方程就变得非常简单,就是 dp[i] = dp[i - 1] + 1,这里的 dp[i] 记录的是当前问题的答案,也就是当前的状态,dp[i - 1] 记录的是之前相邻的问题的答案,也就是之前的状态,它们之间通过 +1 来实现状态的变更。

最后一步就是实现了,有了状态表示和递推方程,实现这一步上需要重点考虑的其实是初始化,就是用什么样的数据结构,根据问题的要求需要做那些初始值的设定。

1
2
3
4
5
6
7
8
public int dpExample(int n) {
int[] dp = new int[n + 1]; // 多开一位用来存放 0 个 1 相加的结果
dp[0] = 0; // 0 个 1 相加等于 0
for (int i = 1; i <= n; ++i) {
dp[i] = dp[i - 1] + 1;
}
return dp[n];
}

你可以看到,动态规划这四个步骤其实是相互递进的,状态的定义离不开问题的拆解,递推方程的推导离不开状态的定义,最后的实现代码的核心其实就是递推方程,这中间如果有一个步骤卡壳了则会导致问题无法解决,当问题的复杂程度增加的时候,这里面的思维复杂程度会上升。

接下来我们再来看看 LeetCode 上面的几道题目,通过题目再来走一下这些个分析步骤。

题目实战

爬楼梯

但凡涉及到动态规划的题目都离不开一道例题:爬楼梯(LeetCode 第 70 号问题)。

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

1
2
3
4
5
6
输入:2
输出:2
解释: 有两种方法可以爬到楼顶。

1. 1 阶 + 1 阶
2. 2 阶

示例 2:
1
2
3
4
5
6
7
输入:3
输出:3
解释: 有三种方法可以爬到楼顶。

1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

题目解析

爬楼梯,可以爬一步也可以爬两步,问有多少种不同的方式到达终点,我们按照上面提到的四个步骤进行分析:

问题拆解:
我们到达第 n 个楼梯可以从第 n - 1 个楼梯和第 n - 2 个楼梯到达,因此第 n 个问题可以拆解成第 n - 1 个问题和第 n - 2 个问题,第 n - 1 个问题和第 n - 2 个问题又可以继续往下拆,直到第 0 个问题,也就是第 0 个楼梯 (起点)

状态定义:
“问题拆解” 中已经提到了,第 n 个楼梯会和第 n - 1 和第 n - 2 个楼梯有关联,那么具体的联系是什么呢?你可以这样思考,第 n - 1 个问题里面的答案其实是从起点到达第 n - 1 个楼梯的路径总数,n - 2 同理,从第 n - 1 个楼梯可以到达第 n 个楼梯,从第 n - 2 也可以,并且路径没有重复,因此我们可以把第 i 个状态定义为 “从起点到达第 i 个楼梯的路径总数”,状态之间的联系其实是相加的关系。

递推方程:
“状态定义” 中我们已经定义好了状态,也知道第 i 个状态可以由第 i - 1 个状态和第 i - 2 个状态通过相加得到,因此递推方程就出来了 dp[i] = dp[i - 1] + dp[i - 2]

实现:
你其实可以从递推方程看到,我们需要有一个初始值来方便我们计算,起始位置不需要移动 dp[0] = 0,第 1 层楼梯只能从起始位置到达,因此 dp[1] = 1,第 2 层楼梯可以从起始位置和第 1 层楼梯到达,因此 dp[2] = 2,有了这些初始值,后面就可以通过这几个初始值进行递推得到。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int climbStairs(int n) {
if (n == 1) {
return 1;
}

int[] dp = new int[n + 1]; // 多开一位,考虑起始位置

dp[0] = 0; dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}

return dp[n];
}

三角形最小路径和

LeetCode 第 120 号问题:三角形最小路径和。

题目描述

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。

例如,给定三角形:

[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

说明:

如果你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题,那么你的算法会很加分。

题目解析

给定一个三角形数组,需要求出从上到下的最小路径和,也和之前一样,按照四个步骤来分析:

问题拆解:
这里的总问题是求出最小的路径和,路径是这里的分析重点,路径是由一个个元素组成的,和之前爬楼梯那道题目类似,[i][j] 位置的元素,经过这个元素的路径肯定也会经过 [i - 1][j] 或者 [i - 1][j - 1],因此经过一个元素的路径和可以通过这个元素上面的一个或者两个元素的路径和得到。

状态定义:
状态的定义一般会和问题需要求解的答案联系在一起,这里其实有两种方式,一种是考虑路径从上到下,另外一种是考虑路径从下到上,因为元素的值是不变的,所以路径的方向不同也不会影响最后求得的路径和,如果是从上到下,你会发现,在考虑下面元素的时候,起始元素的路径只会从[i - 1][j] 获得,每行当中的最后一个元素的路径只会从 [i - 1][j - 1] 获得,中间二者都可,这样不太好实现,因此这里考虑从下到上的方式,状态的定义就变成了 “最后一行元素到当前元素的最小路径和”,对于 [0][0] 这个元素来说,最后状态表示的就是我们的最终答案。

递推方程:
“状态定义” 中我们已经定义好了状态,递推方程就出来了

1
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]

实现

这里初始化时,我们需要将最后一行的元素填入状态数组中,然后就是按照前面分析的策略,从下到上计算即可

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();

int[][] dp = new int[n][n];

List<Integer> lastRow = triangle.get(n - 1);

for (int i = 0; i < n; ++i) {
dp[n - 1][i] = lastRow.get(i);
}

for (int i = n - 2; i >= 0; --i) {
List<Integer> row = triangle.get(i);
for (int j = 0; j < i + 1; ++j) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + row.get(j);
}
}

return dp[0][0];
}

最大子序和

LeetCode 第 53 号问题:最大子序和。

题目描述

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
进阶:

如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。

题目解析

求最大子数组和,非常经典的一道题目,这道题目有很多种不同的做法,而且很多算法思想都可以在这道题目上面体现出来,比如动态规划、贪心、分治,还有一些技巧性的东西,比如前缀和数组,这里还是使用动态规划的思想来解题,套路还是之前的四步骤:

问题拆解:
问题的核心是子数组,子数组可以看作是一段区间,因此可以由起始点和终止点确定一个子数组,两个点中,我们先确定一个点,然后去找另一个点,比如说,如果我们确定一个子数组的截止元素在 i 这个位置,这个时候我们需要思考的问题是 “以 i 结尾的所有子数组中,和最大的是多少?”,然后我们去试着拆解,这里其实只有两种情况:

  • 这个位置的元素自成一个子数组;
  • i 位置的元素的值 + 以 i - 1 结尾的所有子数组中的子数组和最大的值

你可以看到,我们把第 i 个问题拆成了第 i - 1 个问题,之间的联系也变得清晰

状态定义

通过上面的分析,其实状态已经有了,dp[i] 就是 “以 i 结尾的所有子数组的最大值”

递推方程

拆解问题的时候也提到了,有两种情况,即当前元素自成一个子数组,另外可以考虑前一个状态的答案,于是就有了

1
dp[i] = Math.max(dp[i - 1] + array[i], array[i])

化简一下就成了:
1
dp[i] = Math.max(dp[i - 1], 0) + array[i]

实现

题目要求子数组不能为空,因此一开始需要初始化,也就是 dp[0] = array[0],保证最后答案的可靠性,另外我们需要用一个变量记录最后的答案,因为子数组有可能以数组中任意一个元素结尾

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int n = nums.length;

int[] dp = new int[n];

dp[0] = nums[0];

int result = dp[0];

for (int i = 1; i < n; ++i) {
dp[i] = Math.max(dp[i - 1], 0) + nums[i];
result = Math.max(result, dp[i]);
}

return result;
}

上文解释了动态规划的一些基本特性和解题思路,也说了动态规划其实就是记住之前问题的答案,然后利用之前问题的答案来分析并解决当前问题,这里面有两个非常重要的步骤,就是拆解问题定义状态

矩阵类动态规划问题

这次来针对具体的一类动态规划问题,矩阵类动态规划问题,来看看针对这一类问题的思路和注意点。

矩阵类动态规划,也可以叫做坐标类动态规划,一般这类问题都会给你一个矩阵,矩阵里面有着一些信息,然后你需要根据这些信息求解问题。

其实 矩阵可以看作是图的一种,怎么说?你可以把整个矩阵当成一个图,矩阵里面的每个位置上的元素当成是图上的节点,然后每个节点的邻居就是其相邻的上下左右的位置,我们遍历矩阵其实就是遍历图,在遍历的过程中会有一些临时的状态,也就是子问题的答案,我们记录这些答案,从而推得我们最后想要的答案。

一般来说,在思考这类动态规划问题的时候,我们只需要思考当前位置的状态,然后试着去看当前位置和它邻居的递进关系,从而得出我们想要的递推方程,这一类动态规划问题,相对来说比较简单,我们通过几道例题来熟悉一下。

相关题目解析

LeetCode 第 62 号问题:不同路径。

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

例如,一个7 x 3 的网格。有多少可能的路径?

说明: m 和 n 的值均不超过 100。

示例 1:

1
2
3
4
5
6
7
8
输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。

1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右

示例 2:
1
2
输入: m = 7, n = 3
输出: 28

题目解析

给定一个矩阵,问有多少种不同的方式从起点(0,0) 到终点 (m-1,n-1),并且每次移动只能向右或者向下,我们还是按之前提到的分析动态规划那四个步骤来思考一下:

问题拆解:
题目中说了,每次移动只能是向右或者是向下,矩阵类动态规划需要关注当前位置和其相邻位置的关系,对于某一个位置来说,经过它的路径只能从它上面过来,或者从它左边过来,因此,如果需要求到达当前位置的不同路径,我们需要知道到达其上方位置的不同路径,以及到达其左方位置的不同路径

状态定义:
矩阵类动态规划的状态定义相对来说比较简单,只需要看当前位置即可,问题拆解中,我们分析了当前位置和其邻居的关系,提到每个位置其实都可以算做是终点,状态表示就是 “从起点到达该位置的不同路径数目”

递推方程:
有了状态,也知道了问题之间的联系,其实递推方程也出来了,就是

1
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

实现:
有了这些,这道题还没完,我们还要考虑状态数组的初始化问题,对于上边界和左边界的点,因为它们只能从一个方向过来,需要单独考虑,比如上边界的点只能从左边这一个方向过来,左边界的点只能从上边这一个方向过来,它们的不同路径个数其实就只有 1,提前处理就好。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];

for (int i = 0; i < m; ++i) {
dp[i][0] = 1;
}

for (int j = 0; j < n; ++j) {
dp[0][j] = 1;
}

for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}

return dp[m - 1][n - 1];
}

LeetCode 第 63 号问题:不同路径II

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 1 和 0 来表示。

说明:m 和 n 的值均不超过 100。

示例 1:

1
2
3
4
5
6
7
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2

解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:

  1. 向右 -> 向右 -> 向下 -> 向下
  2. 向下 -> 向下 -> 向右 -> 向右

题目解析

在上面那道题的基础上,矩阵中增加了障碍物,这里只需要针对障碍物进行判断即可,如果当前位置是障碍物的话,状态数组中当前位置记录的答案就是 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
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
if (obstacleGrid.length == 0 || obstacleGrid[0].length == 0) {
return 0;
}

if (obstacleGrid[0][0] == 1) {
return 0;
}

int m = obstacleGrid.length, n = obstacleGrid[0].length;
int[][] dp = new int[m][n];

dp[0][0] = 1;

for (int i = 1; i < m; ++i) {
dp[i][0] = obstacleGrid[i][0] == 1 ? 0 : dp[i - 1][0];
}

for (int i = 1; i < n; ++i) {
dp[0][i] = obstacleGrid[0][i] == 1 ? 0 : dp[0][i - 1];
}

for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = obstacleGrid[i][j] == 1 ? 0 : dp[i - 1][j] + dp[i][j - 1];
}
}

return dp[m - 1][n - 1];
}

LeetCode 第 64 号问题:最小路径和

题目描述

给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

示例:

1
2
3
4
5
6
7
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7

解释: 因为路径 1→3→1→1→1 的总和最小。

题目解析

给定一个矩阵,问从起点(0,0) 到终点 (m-1,n-1) 的最小路径和是多少,并且每次移动只能向右或者向下,按之四个步骤来思考一下:

问题拆解:
拆解问题的方式方法和前两道题目非常类似,这里不同的地方只是记录的答案不同,也就是状态不同,我们还是可以仅仅考虑当前位置,然后可以看到只有上面的位置和左边的位置可以到达当前位置,因此当前问题就可以拆解成两个子问题

状态定义:
因为是要求路径和,因此状态需要记录的是 “从起始点到当前位置的最小路径和”

递推方程:
有了状态,以及问题之间的联系,我们知道了,当前的最短路径和可以由其上方和其左方的最短路径和对比得出,递推方程也可以很快写出来:

1
dp[i][j] = Math.min(dp[i - 1][j] + dp[i][j - 1]) + grid[i][j]

实现

实现上面需要重点考虑的还是状态数组的初始化,这一步还是和前面两题类似,这里就不过多赘述

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;

int[][] dp = new int[m][n];

dp[0][0] = grid[0][0];

for (int i = 1; i < m; ++i) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}

for (int i = 1; i < n; ++i) {
dp[0][i] = dp[0][i - 1] + grid[0][i];
}

for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}

return dp[m - 1][n - 1];
}

LeetCode 第 221 号问题:最大正方形。

题目描述

在一个由 0 和 1 组成的二维矩阵内,找到只包含 1 的最大正方形,并返回其面积。

示例:

1
2
3
4
5
6
7
8
输入: 

1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0

输出: 4

题目解析

题目给定一个字符矩阵,字符矩阵中只有两种字符,分别是 ‘0’ 和 ‘1’,题目要在矩阵中找全为 ‘1’ 的,面积最大的正方形。

刚拿道这道题,如果不说任何解法的话,其实并不是特别好想,我们先来看看切题的思路是怎么样的。

首先一个正方形是由四个顶点构成的,如果说我们在矩阵中随机找四个点,然后判断该四个点组成的是不是正方形,如果是正方形,然后看组成正方形的每个位置的元素是不是都是 ‘1’,这种方式也是可行的,但是比较暴力,这么弄下来,时间复杂度是 O((m*n)^4)

那我们就会思考,组成一个正方形是不是必须要四个点都找到?如果我们找出其中的三个点,甚至说两个点,能不能确定这个正方形呢?

你会发现,这里我们只需要考虑 正方形对角线的两个点 即可,这两个点确定了,另外的两个点也就确定了,因此我们可以把时间复杂度降为O((m*n)^2)

但是这里还是会有一些重复计算在里面,我们和之前一样,本质还是在做暴力枚举,只是说枚举的个数变少了,我们能不能记录我们之前得到过的答案,通过牺牲空间换取时间呢,这里正是动态规划所要做的事情!

问题拆解:
我们可以思考,如果我们从左到右,然后从上到下遍历矩阵,假设我们遍历到的当前位置是正方形的右下方的点,那其实我们可以看之前我们遍历过的点有没有可能和当前点组成符合条件的正方形,除了这个点以外,无非是要找另外三个点,这三个点分别在当前点的上方,左方,以及左上方,也就是从这个点往这三个方向去做延伸,具体延伸的距离是和其相邻的三个点中的状态有关

状态定义:
因为我们考虑的是正方形的右下方的顶点,因此状态可以定义成 “当前点为正方形的右下方的顶点时,正方形的最大面积”

递推方程:
有了状态,我们再来看看递推方程如何写,前面说到我们可以从当前点向三个方向延伸,我们看相邻的位置的状态,这里我们需要取三个方向的状态的最小值才能确保我们延伸的是全为 ‘1’ 的正方形,也就是

1
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1

实现

在实现上,我们需要单独考虑两种情况,就是当前位置是 ‘1’,还有就是当前位置是 ‘0’,如果是 ‘0’ 的话,状态就是 0,表示不能组成正方形,如果是 ‘1’ 的话,我们也需要考虑位置,如果是第一行的元素,以及第一列的元素,表明该位置无法同时向三个方向延伸,状态直接给为 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
26
27
28
public int maximalSquare(char[][] matrix) {
if (matrix.length == 0 || matrix[0].length == 0) {
return 0;
}

int m = matrix.length, n = matrix[0].length;

int[][] dp = new int[m][n];

int maxLength = 0;

for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
dp[i][j] = matrix[i][j] == '1' ? 1 : 0;
} else {
dp[i][j] = Math.min(dp[i - 1][j],
Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
}

maxLength = Math.max(dp[i][j], maxLength);
}
}
}

return maxLength * maxLength;
}

序列类动态规划问题

这次再来看一类动态规划问题,序列类动态规划问题,这类动态规划问题较为普遍,分析难度相比之前也略有提升,通常问题的输入参数会涉及数组或是字符串。

在开始之前,先解释一下子数组(子串)和子序列的区别,你可以看看下面这个例子:

输入数组:[1,2,3,4,5,6,7,8,9]
子数组:[2,3,4], [5,6,7], [6,7,8,9], …
子序列:[1,5,9], [2,3,6], [1,8,9], [7,8,9], …
可以看到的是,子数组必须是数组中的一个连续的区间,而子序列并没有这样一个要求。

你只需要保证子序列中的元素的顺序和原数组中元素的顺序一致即可,例如,在原数组中,元素 1 出现在元素 9 之前,那么在子序列中,如果这两个元素同时出现,那么 1 也必须在 9 之前。

为什么要说这个?

不知道你有没有发现,这里的子数组的问题和我们前面提到的矩阵类动态规划的分析思路很类似,只需要考虑当前位置,以及当前位置和相邻位置的关系。

通过这样的分析就可以把之前讲的内容和今天要介绍的内容关联起来了,相比矩阵类动态规划,序列类动态规划最大的不同在于,对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i - 1 个位置的状态,这样的分析思路其实可以从子序列的性质中得出。

对于这类问题的问题拆解,有时并不是那么好发现问题与子问题之间的联系,但是通常来说思考的方向其实在于 寻找当前状态和之前所有状态的关系,我们通过几个非常经典的动态规划问题来一起看看。

题目分析

最长上升子序列

LeetCode 第 300 号问题:最长上升子序列。

题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

1
2
3
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

说明:

可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?

题目解析:
给定一个数组,求最长递增子序列。因为是子序列,这样对于每个位置的元素其实都存在两种可能,就是选和不选,如果我们用暴力的解法,枚举出所有的子序列,然后判断他们是不是递增的,选取最大的递增序列,这样做的话,时间复杂度是 O(2^n),显然不高效。

那这里我们就需要思考用动态规划进行优化,我们按之前的四个步骤来具体分析一下:

问题拆解:
我们要求解的问题是 “数组中最长递增子序列”,一个子序列虽然不是连续的区间,但是它依然有起点和终点,比如:

[10,9,2,5,3,7,101,18]

子序列 [2,3,7,18] 的起始位置是 2,终止位置是 18
子序列 [5,7,101] 的起始位置是 5,终止位置是 101
如果我们确定终点位置,然后去 看前面 i - 1 个位置中,哪一个位置可以和当前位置拼接在一起,这样就可以把第 i 个问题拆解成思考之前 i - 1 个问题,注意这里我们并不是不考虑起始位置,在遍历的过程中我们其实已经考虑过了。

状态定义:
问题拆解中我们提到 “第 i 个问题和前 i - 1 个问题有关”,也就是说 “如果我们要求解第 i 个问题的解,那么我们必须考虑前 i - 1 个问题的解”,我们定义 dp[i] 表示以位置 i 结尾的子序列的最大长度,也就是说 dp[i] 里面记录的答案保证了该答案表示的子序列以位置 i 结尾。

递推方程:
对于 i 这个位置,我们需要考虑前 i - 1 个位置,看看哪些位置可以拼在 i 位置之前,如果有多个位置可以拼在 i 之前,那么必须选最长的那个,这样一分析,递推方程就有了:

1
dp[i] = Math.max(dp[j],...,dp[k]) + 1, 

其中 inputArray[j] < inputArray[i], inputArray[k] < inputArray[i]

实现:
在实现这里,我们需要考虑状态数组的初始化,因为对于每个位置,它本身其实就是一个序列,因此所有位置的状态都可以初始化为 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
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

// dp[i] -> the longest length sequence from 0 - i, and must include nums[i]
int[] dp = new int[nums.length];

Arrays.fill(dp, 1);

int max = 0;

for (int i = 0; i < nums.length; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[j] + 1, dp[i]);
}
}

max = Math.max(max, dp[i]);
}

return max;
}

粉刷房子

LeetCode 第 256 号问题:粉刷房子。

注意:本题为 LeetCode 的付费题目,需要开通会员才能解锁查看与提交代码。

题目描述:

假如有一排房子,共 n 个,每个房子可以被粉刷成红色、蓝色或者绿色这三种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。
当然,因为市场上不同颜色油漆的价格不同,所以房子粉刷成不同颜色的花费成本也是不同的。每个房子粉刷成不同颜色的花费是以一个 n x 3 的矩阵来表示的。
例如,costs[0][0]表示第 0 号房子粉刷成红色的成本花费;costs[1][2]表示第 1 号房子粉刷成绿色的花费,以此类推。请你计算出粉刷完所有房子最少的花费成本。

注意:

所有花费均为正整数。

示例:

1
2
3
4
输入: [[17,2,17],[16,16,5],[14,3,19]]
输出: 10
解释: 将 0 号房子粉刷成蓝色,1 号房子粉刷成绿色,2 号房子粉刷成蓝色。
最少花费: 2 + 5 + 3 = 10。

题目解析

给 n 个房子刷油漆,有三种颜色的油漆可以刷,必须保证相邻房子的颜色不能相同,输入是一个 n x 3 的数组,表示每个房子使用每种油漆所需要花费的价钱,求刷完所有房子的最小价值。

还是按原来的思考方式走一遍:

问题拆解:
对于每个房子来说,都可以使用三种油漆当中的一种,如果说不需要保证相邻的房子的颜色必须不同,那么整个题目会变得非常简单,每个房子直接用最便宜的油漆刷就好了,但是加上这个限制条件,你会发现刷第 i 个房子的花费其实是和前面 i - 1 个房子的花费以及选择相关,如果说我们需要知道第 i 个房子使用第 k 种油漆的最小花费,那么你其实可以思考第 i - 1 个房子如果不用该油漆的最小花费,这个最小花费是考虑从 0 到当前位置所有的房子的。

状态定义:
通过之前的问题拆解步骤,状态可以定义成 dp[i][k],表示如果第 i 个房子选择第 k 个颜色,那么从 0 到 i 个房子的最小花费

递推方程:
基于之前的状态定义,以及相邻的房子不能使用相同的油漆,那么递推方程可以表示成:

1
dp[i][k] = Math.min(dp[i - 1][l], ..., dp[i - 1][r]) + costs[i][k], l != k, r != k

实现:
因为我们要考虑 i - 1 的情况,但是第 0 个房子并不存在 i - 1 的情况,因此我们可以把第 0 个房子的最小花费存在状态数组中,当然你也可以多开一格 dp 状态,其实都是一样的。

对于这道题目,你可能会问这不是和矩阵类动态规划类似吗?

如果单从房子来考虑的确是,但是对于颜色的话,我们必须考虑考虑相邻房子的所有颜色,这就有点序列的意思在里面了。

另外对于题目的分类其实没有严格的限定,主要是为了把相类似的问题放在一起,这样有便于分析问题思路。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int minCost(int[][] costs) {
if (costs == null || costs.length == 0) {
return 0;
}
int n = costs.length;

int[][] dp = new int[n][3];

for (int i = 0; i < costs[0].length; ++i) {
dp[0][i] = costs[0][i];
}

for (int i = 1; i < n; ++i) {
dp[i][0] = Math.min(dp[i - 1][1], dp[i - 1][2]) + costs[i][0];
dp[i][1] = Math.min(dp[i - 1][0], dp[i - 1][2]) + costs[i][1];
dp[i][2] = Math.min(dp[i - 1][0], dp[i - 1][1]) + costs[i][2];
}

return Math.min(dp[n - 1][0], Math.min(dp[n - 1][1], dp[n - 1][2]));
}

粉刷房子II

LeetCode 第 265 号问题:粉刷房子II。

注意:本题为 LeetCode 的付费题目,需要开通会员才能解锁查看与提交代码。

题目描述

假如有一排房子,共 n 个,每个房子可以被粉刷成 k 种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。

当然,因为市场上不同颜色油漆的价格不同,所以房子粉刷成不同颜色的花费成本也是不同的。每个房子粉刷成不同颜色的花费是以一个 n x k 的矩阵来表示的。

例如,costs[0][0] 表示第 0 号房子粉刷成 0 号颜色的成本花费;costs[1][2] 表示第 1 号房子粉刷成 2 号颜色的成本花费,以此类推。请你计算出粉刷完所有房子最少的花费成本。

注意:

所有花费均为正整数。

示例:

1
2
3
4
输入: [[1,5,3],[2,9,4]]
输出: 5
解释: 将 0 号房子粉刷成 0 号颜色,1 号房子粉刷成 2 号颜色。最少花费: 1 + 4 = 5;
或者将 0 号房子粉刷成 2 号颜色,1 号房子粉刷成 0 号颜色。最少花费: 3 + 2 = 5.

进阶:
您能否在 O(nk) 的时间复杂度下解决此问题?

题目解析

上面那道题目的 follow up,现在不是三种油漆,而是 k 种油漆。

其实解题思路还是不变。

对于第 i 个房子的每种颜色,我们对比看第 i - 1 个房子的 k 种油漆,找到不相重的最小值就好,但是这里的时间复杂度是 O(n*k^2)

其实这是可以优化的,我们只需要在第 i - 1 个位置的状态中找到最大值和次大值,在选择第 i 个房子的颜色的时候,我们看当前颜色是不是和最大值的颜色相重,不是的话直接加上最大值,如果相重的话,我们就加上次大值,这样一来,我们把两个嵌套的循环,拆开成两个平行的循环,时间复杂度降至 O(n*k)

参考代码(优化前)

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
public int minCostII(int[][] costs) {
if (costs.length == 0 || costs[0].length == 0) {
return 0;
}

int n = costs.length, k = costs[0].length;
int[][] dp = new int[n][k];

for (int i = 1; i < n; ++i) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
}

for (int i = 0; i < k; ++i) {
dp[0][i] = costs[0][i];
}

for (int i = 1; i < n; ++i) {
for (int j = 0; j < k; ++j) {
for (int m = 0; m < k; ++m) {
if (m != j) {
dp[i][m] = Math.min(dp[i][m], dp[i - 1][j] + costs[i][m]);
}
}
}
}

int result = Integer.MAX_VALUE;
for (int i = 0; i < k; ++i) {
result = Math.min(result, dp[n - 1][i]);
}

return result;
}

参考代码(优化后)
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
public int minCostII(int[][] costs) {
if (costs.length == 0 || costs[0].length == 0) {
return 0;
}

int n = costs.length, k = costs[0].length;
int[][] dp = new int[n][k];

for (int i = 1; i < n; ++i) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
}

for (int i = 0; i < k; ++i) {
dp[0][i] = costs[0][i];
}

for (int i = 1; i < n; ++i) {
// min1 表示的是最大值,min2 表示的是次大值
int min1 = Integer.MAX_VALUE, min2 = Integer.MAX_VALUE;
int minIndex = -1;
for (int l = 0; l < k; ++l) {
if (min1 > dp[i - 1][l]) {
min2 = min1;
min1 = dp[i - 1][l];
minIndex = l;
} else if (min2 > dp[i - 1][l]) {
min2 = dp[i - 1][l];
}
}

for (int j = 0; j < k; ++j) {
if (minIndex != j) {
dp[i][j] = Math.min(dp[i][j], min1 + costs[i][j]);
} else {
dp[i][j] = Math.min(dp[i][j], min2 + costs[i][j]);
}
}
}

int result = Integer.MAX_VALUE;
for (int i = 0; i < k; ++i) {
result = Math.min(result, dp[n - 1][i]);
}

return result;
}

打家劫舍

LeetCode 第 198 号问题:打家劫舍。

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

1
2
3
4
输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:
1
2
3
4
输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。

题目解析

前面那道题目的 follow up,问的是如果这些房子的排列方式是一个圆圈,其余要求不变,问该如何处理。

房子排列方式是一个圆圈意味着之前的最后一个房子和第一个房子之间产生了联系,这里有一个小技巧就是我们线性考虑 [0, n - 2] 和 [1, n - 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
26
27
28
29
30
31
32
33
34
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

if (nums.length == 1) {
return nums[0];
}

int n = nums.length;

return Math.max(
robI(Arrays.copyOfRange(nums, 0, n - 1)),
robI(Arrays.copyOfRange(nums, 1, n))
);
}

public int robI(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int n = nums.length;

int[] dp = new int[n + 1];

dp[1] = nums[0];

for (int i = 2; i <= n; ++i) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}

return dp[n];
}

相关的「股票」算法题

概念

动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。在学习动态规划之前需要明确掌握几个重要概念。

  • 阶段:对于一个完整的问题过程,适当的切分为若干个相互联系的子问题,每次在求解一个子问题,则对应一个阶段,整个问题的求解转化为按照阶段次序去求解。
  • 状态:状态表示每个阶段开始时所处的客观条件,即在求解子问题时的已知条件。状态描述了研究的问题过程中的状况。
  • 决策:决策表示当求解过程处于某一阶段的某一状态时,可以根据当前条件作出不同的选择,从而确定下一个阶段的状态,这种选择称为决策。
  • 策略:由所有阶段的决策组成的决策序列称为全过程策略,简称策略。
  • 最优策略:在所有的策略中,找到代价最小,性能最优的策略,此策略称为最优策略。
  • 状态转移方程:状态转移方程是确定两个相邻阶段状态的演变过程,描述了状态之间是如何演变的。

使用场景

能采用动态规划求解的问题的一般要具有 3 个性质:

  • 最优化:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。子问题的局部最优将导致整个问题的全局最优。换句话说,就是问题的一个最优解中一定包含子问题的一个最优解。
  • 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关,与其他阶段的状态无关,特别是与未发生的阶段的状态无关。
  • 重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)

算法流程

  • 划分阶段:按照问题的时间或者空间特征将问题划分为若干个阶段。
  • 确定状态以及状态变量:将问题的不同阶段时期的不同状态描述出来。
  • 确定决策并写出状态转移方程:根据相邻两个阶段的各个状态之间的关系确定决策。
  • 寻找边界条件:一般而言,状态转移方程是递推式,必须有一个递推的边界条件。
  • 设计程序,解决问题

实战练习

下面的三道算法题都是来源于 LeetCode 上与股票买卖相关的问题 ,我们按照 动态规划 的算法流程来处理该类问题。

股票买卖这一类的问题,都是给一个输入数组,里面的每个元素表示的是每天的股价,并且你只能持有一支股票(也就是你必须在再次购买前出售掉之前的股票),一般来说有下面几种问法:

  • 只能买卖一次
  • 可以买卖无数次
  • 可以买卖 k 次

需要你设计一个算法去获取最大的利润。

买卖股票的最佳时机

题目来源于 LeetCode 上第 121 号问题:买卖股票的最佳时机。题目难度为 Easy,目前通过率为 49.4% 。

题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。

注意你不能在买入股票前卖出股票。

示例 1:

输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。

示例 2:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

题目解析

我们按照动态规划的思想来思考这道问题。

状态:
有 买入(buy) 和 卖出(sell) 这两种状态。

转移方程:
对于买来说,买之后可以卖出(进入卖状态),也可以不再进行股票交易(保持买状态)。

对于卖来说,卖出股票后不在进行股票交易(还在卖状态)。

只有在手上的钱才算钱,手上的钱购买当天的股票后相当于亏损。也就是说当天买的话意味着损失-prices[i],当天卖的话意味着增加prices[i],当天卖出总的收益就是 buy+prices[i] 。

所以我们只要考虑当天买和之前买哪个收益更高,当天卖和之前卖哪个收益更高。

buy = max(buy, -price[i]) (注意:根据定义 buy 是负数)
sell = max(sell, prices[i] + buy)

边界:
第一天 buy = -prices[0], sell = 0,最后返回 sell 即可。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public int maxProfit(int[] prices) {
if(prices.length <= 1)
return 0;
int buy = -prices[0], sell = 0;
for(int i = 1; i < prices.length; i++) {
buy = Math.max(buy, -prices[i]);
sell = Math.max(sell, prices[i] + buy);

}
return sell;
}
}

买卖股票的最佳时机 II

题目来源于 LeetCode 上第 122 号问题:买卖股票的最佳时机 II。题目难度为 Easy,目前通过率为 53.0% 。

题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

1
2
3
4
输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

示例 2:
1
2
3
4
5
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:
1
2
3
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

题目解析

状态:
有 买入(buy) 和 卖出(sell) 这两种状态。

转移方程:
对比上题,这里可以有无限次的买入和卖出,也就是说 买入 状态之前可拥有 卖出 状态,所以买入的转移方程需要变化。

1
2
buy = max(buy, sell - price[i])
sell = max(sell, buy + prices[i] )

边界:
第一天 buy = -prices[0], sell = 0,最后返回 sell 即可。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public int maxProfit(int[] prices) {
if(prices.length <= 1)
return 0;
int buy = -prices[0], sell = 0;
for(int i = 1; i < prices.length; i++) {
sell = Math.max(sell, prices[i] + buy);
buy = Math.max( buy,sell - prices[i]);
}
return sell;
}
}

买卖股票的最佳时机 III

题目来源于 LeetCode 上第 123 号问题:买卖股票的最佳时机 III。题目难度为 Hard,目前通过率为 36.1% 。

题目描述

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

1
2
3
4
输入: [3,3,5,0,0,3,1,4]
输出: 6
解释: 在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。
随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3 。

示例 2:
1
2
3
4
5
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:
1
2
3
输入: [7,6,4,3,1] 
输出: 0
解释: 在这个情况下, 没有交易完成, 所以最大利润为 0。

题目解析

这里限制了最多两笔交易。

状态:
有 第一次买入(fstBuy) 、 第一次卖出(fstSell)、第二次买入(secBuy) 和 第二次卖出(secSell) 这四种状态。

转移方程:
这里可以有两次的买入和卖出,也就是说 买入 状态之前可拥有 卖出 状态,所以买入和卖出的转移方程需要变化。

fstBuy = max(fstBuy , -price[i])
fstSell = max(fstSell,fstBuy + prices[i] )
secBuy = max(secBuy ,fstSell -price[i]) (受第一次卖出状态的影响)
secSell = max(secSell ,secBuy + prices[i] )

边界:
一开始 fstBuy = -prices[0]

买入后直接卖出,fstSell = 0

买入后再卖出再买入,secBuy - prices[0]

买入后再卖出再买入再卖出,secSell = 0

最后返回 secSell 。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public int maxProfit(int[] prices) {
int fstBuy = Integer.MIN_VALUE, fstSell = 0;
int secBuy = Integer.MIN_VALUE, secSell = 0;
for(int i = 0; i < prices.length; i++) {
fstBuy = Math.max(fstBuy, -prices[i]);
fstSell = Math.max(fstSell, fstBuy + prices[i]);
secBuy = Math.max(secBuy, fstSell - prices[i]);
secSell = Math.max(secSell, secBuy + prices[i]);
}
return secSell;

}
}

字符匹配类动态规划

字符匹配类动态规划,你一听名字就知道和字符串匹配相关,这类题型它其实是 序列类动态规划 的一个递进,它有时也被称为 双序列类动态规划。

在 序列类动态规划 中,题目的输入是一个数组或是字符串,然后让你基于这个输入数组或是字符串进行一系列的判断,往往我们拆解问题、分析状态的时候只需要考虑一个维度的状态,比如刷房子和抢房子相关的问题,我们只需要考虑此时的房子和之前考虑过的房子之间的联系,思维始终是在一条线上。

回到字符匹配类动态规划,题目要你分析的是两个序列彼此之间的联系,这里其实有一个动态规划状态维度的提升,在考虑当前子问题的时候,我们要同时考虑两个序列的状态,当然,一般说来,动态规划状态维度的提升,也意味着难度的提升,可能刚从一维变成二维,你会不太习惯,没关系,多思考就好了,对于字符匹配类动态规划,它的题目特征其实特别明显,比如:

输入是两个字符串,问是否通过一定的规则相匹配
输入是两个字符串,问两个字符串是否存在包含被包含的关系
输入是两个字符串,问一个字符串怎样通过一定规则转换成另一个字符串
输入是两个字符串,问它们的共有部分
。。。
另外说一下,这类问题的难点在于问题的拆解上面,也就是如何找到当前问题和子问题的联系。

往往这类问题的状态比较好找,你可以先假设状态 dp[i][j] 就是子问题 str1(0…i) str2(0…j) 的状态。拆解问题主要思考 dp[i][j] 和子问题的状态 dp[i - 1][j],dp[i - 1][j] 以及 dp[i - 1][j - 1] 的联系,因为字符串会存在空串的情况,所以动态规划状态数组往往会多开一格。

当然,对于这类问题,如果你还是没有什么思路或者想法,我给你的建议是 画表格,我们结合实际题目一起来看看。

题目分析

LeetCode 第 1143 号问题:最长公共子序列。

题目描述

给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,”ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0。

示例 1:

1
2
3
输入:text1 = "abcde", text2 = "ace" 
输出:3
解释:最长公共子序列是 "ace",它的长度为 3。

示例 2:
1
2
3
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc",它的长度为 3。

示例 3:
1
2
3
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0。

题目分析

这里还是按之前的四个步骤来思考,当然这只是一个框架用来辅助你思考,不用特别拘泥于这四个步骤:

问题拆解:

我们要求解 str1(0,…m) 和 str2(0,…n) 的最长公共子序列,如果这是最终要求解的问题,那么它的子问题是什么呢?其实是 str1(0,…m-1) 和 str2(0,…n-1),以及 str1(0,…m-1) 和 str2(0,…n),还有 str1(0,…m) 和 str2(0,…n-1),如果要找它们之间的关系,那我们需要思考一个问题就是,这些子问题怎么变成最终要求解的问题,当前的问题考虑当前字符是否相等,很直接的一个发现就是,如果 str1(m)==str2(n),那么我们就可以将子问题中的 str1(0,…m-1) 和 str2(0,…n-1) 后面添加两个相同字符递进成当前问题;如果不相等,我们就需要考虑在三个子问题中选择一个较大值了。

说到这里,如果你还是不太清楚问题之间的联系,那我们一起来画画表格,熟悉一下这个过程:

题目求解 text1 = “abcde”, text2 = “ace” 的最长公共子序列

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 如果其中一个字符串是空串
b 0 那么两个字符不存在公共子序列
c 0 对应的子问题状态初始化为 0
d 0
e 0

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1 text1 = "a" text2 = "a" || text2 = "ac" || text2 = "ace"
b 0 考虑当前状态 dp[i][j] 的时候
c 0 我们可以考虑子状态 dp[i - 1][j - 1]
d 0 dp[i][j - 1]
e 0 dp[i - 1][j]

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1 text1 = "ab" text2 = "a" || text2 = "ac" || text2 = "ace"
c 0
d 0
e 0

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2 text1 = "abc" text2 = "a" || text2 = "ac" || text2 = "ace"
d 0 画到这里,不知道你有没有发现当当前的字符不相同时
e 0 dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2 text1 = "abcd" text2 = "a" || text2 = "ac" || text2 = "ace"
e 0

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2
e 0 1 2 3 text1 = "abcde" text2 = "a" || text2 = "ac" || text2 = "ace"

3 就是我们要返回的答案
状态定义:
dp[i][j] 表示的就是 str1(0,…i) 和 str2(0,…j) 的答案,基本上字符串匹配类动态规划都可以先尝试这样去定义状态

递推方程:
在拆解问题中也说了,有两种情况,就是:

如果 str1(i) != str2(j):
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])

如果 str1(i) == str2(j):
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1] + 1)

因为 dp[i - 1][j - 1] + 1 >= dp[i - 1][j] && dp[i - 1][j - 1] + 1 >= dp[i][j - 1]
所以第二项可以化简:

如果 str1(i) == str2(j):
dp[i][j] = dp[i - 1][j - 1] + 1

实现
通常来说字符相关的问题可以把状态数组多开一格用来存放空串匹配的情况,这道题空串的情况答案都是 0,使用 Java 语言也不需要考虑初始化

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[length1 + 1][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}

return dp[length1][length2];
}

LeetCode 第 72 号问题:编辑距离。

题目描述

给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

插入一个字符
删除一个字符
替换一个字符
示例 1:

1
2
3
4
5
6
输入: word1 = "horse", word2 = "ros"
输出: 3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例 2:
1
2
3
4
5
6
7
8
输入: word1 = "intention", word2 = "execution"
输出: 5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')

题目分析

求解编辑距离,也是经典老题,编辑距离其实在实际工作中也会用到,主要用于分析两个单词的相似程度,两个单词的编辑距离越小证明两个单词的相似度越高。

题目说可以通过增加字符,删除字符,以及 替换字符 这三个操作来改变一个字符串,并且每个操作的 cost 都是 1,问一个单词转换成另一个单词的最小 cost,老样子,四个步骤分析一遍:

问题拆解:
我们考虑求解 str1(0…m) 通过多少 cost 变成 str2(0…n),还是来看看它的子问题,其实还是三个

str1(0…m-1) 通过多少 cost 变成 str2(0…n)
str1(0…m) 通过多少 cost 变成 str2(0…n-1)
str1(0…m-1) 通过多少 cost 变成 str2(0…n-1)

一般字符匹配类问题的核心永远是两个字符串中的字符的比较,而且字符比较也只会有两种结果,那就是 相等 和 不相等,在字符比较的结果之上我们才会进行动态规划的统计和推导。

回到这道题,当我们在比较 str1(m) 和 str2(n) 的时候也会有两种结果,即 相等 或 不相等,如果说是 相等,那其实我们就不需要考虑这两个字符,问题就直接变成了子问题 str1(0…m-1) 通过多少 cost 变成 str2(0…n-1),如果说 不相等,那我们就可以执行题目给定的三种变换策略:

将问题中的 str1 末尾字符 str1(m) 删除,因此只需要考虑子问题 str1(0…m-1),str2(0…n)

将问题中的 str1 末尾字符 str1(m) 替换 成 str2(n),这里我们就只需要考虑子问题 str1(0…m-1),str2(0…n-1)

将问题中的 str1 末尾 添加 一个字符 str2(n),添加后 str1(m+1) 必定等于 str2(n),所以,我们就只需要考虑子问题 str1(0…m),str2(0…n-1)

如果你还不是特别清楚问题之间的关系,那就画图表吧,这里我就略过。

状态定义

dp[i][j] 表示的是子问题 str1(0…i),str2(0…j) 的答案,和常规的字符匹配类动态规划题目一样,没什么特别

递推方程:
问题拆解那里其实说的比较清楚了,这里需要把之前的描述写成表达式的形式:

1
2
3
4
5
6
7
str1(i) == str2(j):
dp[i][j] = dp[i - 1][j - 1]
tip: 这里不需要考虑 dp[i - 1][j] 以及 dp[i][j - 1],因为
dp[i - 1][j - 1] <= dp[i - 1][j] +1 && dp[i - 1][j - 1] <= dp[i][j - 1] + 1

str1(i) != str2(j):
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][i - 1]) + 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
26
27
public int minDistance(String word1, String word2) {
char[] arr1 = word1.toCharArray();
char[] arr2 = word2.toCharArray();

int[][] dp = new int[arr1.length + 1][arr2.length + 1];
dp[0][0] = 0;
for (int i = 1; i <= arr1.length; ++i) {
dp[i][0] = i;
}

for (int i = 1; i <= arr2.length; ++i) {
dp[0][i] = i;
}

for (int i = 1; i <= arr1.length; ++i) {
for (int j = 1; j <= arr2.length; ++j) {
if (arr1[i - 1] == arr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(dp[i - 1][j],
Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
}
}
}

return dp[arr1.length][arr2.length];
}

LeetCode 第 44 号问题:通配符匹配。

题目描述

给定一个字符串 (s) 和一个字符模式 (p) ,实现一个支持 ?* 的通配符匹配。

? 可以匹配任何单个字符。
* 可以匹配任意字符串(包括空字符串)。
两个字符串完全匹配才算匹配成功。

说明:

s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 ?*
示例 1:

1
2
3
4
5
输入:
s = "aa"
p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。

示例 2:
1
2
3
4
5
输入:
s = "aa"
p = "*"
输出: true
解释: '*' 可以匹配任意字符串。

示例 3:
1
2
3
4
5
输入:
s = "cb"
p = "?a"
输出: false
解释: '?' 可以匹配 'c', 但第二个 'a' 无法匹配 'b'。

示例 4:
1
2
3
4
5
输入:
s = "adceb"
p = "*a*b"
输出: true
解释: 第一个 '*' 可以匹配空字符串, 第二个 '*' 可以匹配字符串 "dce".

示例 5:
1
2
3
4
输入:
s = "acdcb"
p = "a*c?b"
输入: false

题目分析:
题目给定两个字符串,一个字符串是匹配串,除了小写字母外,匹配串里面还包含 * 和 ? 这两个特殊字符,另一个是普通字符串,里面只包含小写字母。

题目问这个普通字符串是否和匹配字符串相匹配,匹配规则是 ? 可以匹配单个字符,* 可以匹配一个区间,也就是多个字符,当然也可以匹配 0 个字符,也就是空串。

依然是四个步骤走一遍:

问题拆解:
做多了,你发现这种问题其实都是一个套路,老样子,我们还是根据我们要求解的问题去看和其直接相关的子问题,我们需要求解的问题是 pattern(0…m) 和 str(0…n) 是否匹配,这里的核心依然是字符之间的比较,但是和之前不同的是,这个比较不仅仅是看两个字符相不相等,它还有了一定的匹配规则在里面,那我们就依次枚举讨论下:

pattern(m) == str(n):
问题拆解成看子问题 pattern(0…m-1) 和 str(0…n-1) 是否匹配
pattern(m) == ?:
问题拆解成看子问题 pattern(0…m-1) 和 str(0…n-1) 是否匹配

你发现弄来弄去,子问题依然是那三个:
pattern(0…m-1) 和 str(0…n-1) 是否匹配
pattern(0…m-1) 和 str(0…n) 是否匹配
pattern(0…m) 和 str(0…n-1) 是否匹配

不知道你是否发现了字符匹配类动态规划问题的共性,如果是画表格,你只需要关注当前格子的 左边、上边、左上 这三个位置的相邻元素,因为表格有实际数据做辅助,所以画表格有时可以帮助你找到问题与子问题之间的联系。

状态定义:
还是老样子,dp[i][j] 表示的就是问题 pattern(0…i) 和 str(0…j) 的答案,直接说就是 pattern(0…i) 和 str(0…j) 是否匹配

递推方程:
把之前 “问题拆解” 中的文字描述转换成状态的表达式就是递推方程:

1
2
3
4
5
pattern(i) == str(j) || pattern(i) == '?':
dp[i][j] = dp[i - 1][j - 1]

pattern(i) == '*':
dp[i][j] = dp[i - 1][j] || dp[i][j - 1]

实现
这类问题的状态数组往往需要多开一格,主要是为了考虑空串的情况,这里我就不赘述了。

我想说的是,关于初始化的部分,如果 str 是空的,pattern 最前面有 *,因为 * 是可以匹配空串的,因此这个也需要记录一下,反过来,如果 pattern 是空的,str 只要不是空的就无法匹配,这里就不需要特别记录。

参考代码

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
public boolean isMatch(String s, String p) {
char[] sArr = s.toCharArray();
char[] pArr = p.toCharArray();

boolean[][] dp = new boolean[pArr.length + 1][sArr.length + 1];

dp[0][0] = true;
for (int i = 1; i <= pArr.length; ++i) {
if (pArr[i - 1] != '*') {
break;
} else {
dp[i][0] = true;
}
}

for (int i = 1; i <= pArr.length; ++i) {
for (int j = 1; j <= sArr.length; ++j) {
if (sArr[j - 1] == pArr[i - 1] || pArr[i - 1] == '?') {
dp[i][j] = dp[i - 1][j - 1];
} else if (pArr[i - 1] == '*') {
dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
}
}
}

return dp[pArr.length][sArr.length];
}

LeetCode 第 97 号问题

题目描述

给定三个字符串 s1, s2, s3, 验证 s3 是否是由 s1 和 s2 交错组成的。

示例 1:

1
2
输入: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbcbcac"
输出: true

示例 2:
1
2
输入: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbbaccc"
输出: false

题目分析

题目的输入是三个字符串,问其中两个字符串是否能够交错合并组成第三个字符串,一个字符相对于其他字符的顺序在合并之后不能改变,这也是这道题的难点,不然的话你用一个哈希表就可以做了,三个字符串是否意味着要开三维的状态数组?还是四个步骤来看看:

问题拆解:
在拆解问题之前,我们必须保证前两个字符串的字符的总数量必须正好等于第三个字符串的字符总数量,不然的话,再怎么合并也无法完全等同。这里有一个点,当我们考虑 str1(0…i) 和 str2(0…j) 的时候,其实第三个字串需要考虑的范围也就确定了,就是 str3(0…i+j)。如果我们要求解问题 str1(0…m) 和 str2(0…n) 是否能够交错组成 str3(0…m+n),还是之前那句话,字符串匹配问题的核心永远是字符之间的比较:

如果 str1(m) == str3(m+n),问题拆解成考虑子问题 str1(0…m-1) 和 str2(0…n) 是否能够交错组成 str3(0…m+n-1)
如果 str2(n) == str3(m+n),问题拆解成考虑子问题 str1(0…m) 和 str2(0…n-1) 是否能够交错组成 str3(0…m+n-1)

你可能会问需不需要考虑子问题 str1(0…m-1) 和 str2(0…n-1)?

在这道题目当中,不需要!

千万不要题目做多了就固定思维了,之前说到这类问题可以试着考虑三个相邻子问题是为了让你有个思路,能更好地切题,并不是说所有的字符串匹配问题都需要考虑这三个子问题,我们需要遇到具体问题具体分析。

状态定义:
dp[i][j] 表示的是 str1(0…i) 和 str2(0…j) 是否可以交错组成 str3(0…i+j),这里再补充说明下为什么我们不需要开多一维状态来表示 str3,其实很简单,str3 的状态是由 str1 str2 决定的,str1 str2 定了,str3 就定了

递推方程:
把之前问题拆解中的文字描述转换成状态的表达式就是递推方程:

1
2
3
4
5
str1(i) == str3(i+j)
dp[i][j] |= dp[i - 1][j]

str2(j) == str3(i+j)
dp[i][j] |= dp[i - 1][j]

实现

初始化的时候需要考虑单个字符串能否组成 str3 对应的区间,这个比较简单,直接判断前缀是否相等即可。

参考代码:

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
public boolean isInterleave(String s1, String s2, String s3) {
int length1 = s1.length();
int length2 = s2.length();
int length3 = s3.length();

if (length1 + length2 != length3) {
return false;
}

boolean[][] dp = new boolean[length1 + 1][length2 + 1];

dp[0][0] = true;

char[] sArr1 = s1.toCharArray();
char[] sArr2 = s2.toCharArray();
char[] sArr3 = s3.toCharArray();

for (int i = 1; i <= length1; ++i) {
dp[i][0] = dp[i - 1][0] && sArr1[i - 1] == sArr3[i - 1];
}

for (int i = 1; i <= length2; ++i) {
dp[0][i] = dp[0][i - 1] && sArr2[i - 1] == sArr3[i - 1];
}

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (sArr3[i + j - 1] == sArr1[i - 1]) {
dp[i][j] |= dp[i - 1][j];
}

if (sArr3[i + j - 1] == sArr2[j - 1]) {
dp[i][j] |= dp[i][j - 1];
}
}
}

return dp[length1][length2];
}

背包问题

概述

背包问题是一类比较 特殊的动态规划 问题,这篇文章的侧重点会在答案的推导过程上,我们还是会使用之前提到的解动态规划问题的四个步骤来思考这类问题。

在讲述背包问题之前,首先提及一下,背包类动态规划问题和其他的动态规划问题的不同之处在于,背包类动态规划问题会选用值来作为动态规划的状态,你可以回顾下之前我们讨论过的动态规划问题,基本上都是利用数组或者是字符串的下标来表示动态规划的状态。

针对背包类问题,我们依然可以 画表格 来辅助我们思考问题,但是背包类问题有基本的雏形,题目特征特别明显,当你理解了这类问题的解法后,遇到类似问题基本上不需要额外的辅助就可以给出大致的解法,这也就是说,学习背包类问题是一个性价比很高的事情,理解了一个特定问题的解法,基本上一类问题都可以直接套这个解法。

问题雏形

首先我们来看看这样一个问题:

有 N 件物品和一个容量为 V 的背包。第 i 件物品的体积是 C[i],价值是 W[i]。求解将哪些物品装入背包可使价值总和最大。求出最大总价值

话不多说,我们还是按之前的分析四步骤来看看这个问题:

问题拆解:
我们要求解的问题是 “背包能装入物品的最大价值”,这个问题的结果受到两个因素的影响,就是背包的大小,以及物品的属性(包括大小和价值)。对于物品来说,只有两种结果,放入背包以及不放入背包,这里我们用一个例子来画画表格:

假设背包的大小是 10,有 4 个物品,体积分别是 [2,3,5,7],价值分别是 [2,5,2,5]。

1、如果我们仅考虑将前一个物品放入背包,只要背包体积大于 2,此时都可以获得价值为 2 的最大价值:


图一

2、如果我们仅考虑将前两个物品放入背包,如果背包体积大于或等于 5,表示两个物体都可放入,此时都可以获得价值为 2+5=7 的最大价值,如果不能全都放入,那就要选择体积不超,价值最大的那个:


图二

3、如果我们仅考虑将前三个物品放入背包,如果背包体积大于或等于 10,表示三个物体都可放入,此时都可以获得价值为 2+5+2=9 的最大价值,如果不能全都放入,那就要选择体积不超,价值最大的那个方案:


图三
4、如果我们考虑将所有物品放入背包,我们可以依据前三个物品放入的结果来制定方案:


图四
这样,我们就根据物品和体积将问题拆分成子问题,也就是 “前 n 个物品在体积 V 处的最大价值” 可以由 “前 n - 1 个物品的情况” 推导得到。

状态定义:
在问题拆解中,我们得知问题其实和背包的体积还有当前考虑的物品有关,因此我们可以定义 dp[i][j] 表示 “考虑将前 i 个物品放入体积为 j 的背包里所获得的最大价值”

递推方程:
当我们考虑是否将第 i 个物品放入背包的时候,这里有两种情况

不放入,也就是不考虑第 i 个物品,那么问题就直接变成了上一个子问题,也就是考虑将 i - 1 个物品放入背包中,这样当前问题的解就是之前问题的解:

1
dp[i][j] = dp[i - 1][j]

如果背包体积大于第 i 个物品的体积,我们可以考虑将第 i 个物品放入,这个时候我们要和之前的状态做一个比较,选取最大的方案:
1
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - C[i]] + W[i])

实现
实现这一环节还是主要考虑状态数组如何初始化,你可以看到,我们每次都要考虑 i - 1,另外还要考虑背包体积为 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
public int zeroOnePack(int V, int[] C, int[] W) { 
// 防止无效输入
if ((V <= 0) || (C.length != W.length)) {
return 0;
}

int n = C.length;

// dp[i][j]: 对于下标为 0~i 的物品,背包容量为 j 时的最大价值
int[][] dp = new int[n + 1][V + 1];

// 背包空的情况下,价值为 0
dp[0][0] = 0;

for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= V; ++j) {
// 不选物品 i 的话,当前价值就是取到前一个物品的最大价值,也就是 dp[i - 1][j]
dp[i][j] = dp[i - 1][j];

// 如果选择物品 i 使得当前价值相对不选更大,那就选取 i,更新当前最大价值
if ((j >= C[i - 1]) && (dp[i][j] < dp[i - 1][j - C[i - 1]] + W[i - 1])) {
dp[i][j] = dp[i - 1][j - C[i - 1]] + W[i - 1];
}
}
}

// 返回,对于所有物品(0~N),背包容量为 V 时的最大价值
return dp[n][V];
}

这里还有一个空间上面的优化,如果你回到我们之前画的表格,考虑前 i 个问题的状态只会依赖于前 i - 1 个问题的状态,也就是 dp[i][…] 只会依赖于 dp[i - 1][…],另外一点就是当前考虑的背包体积只会用到比其小的体积。

基于这些信息,我们状态数组的维度可以少开一维,但是遍历的方向上需要从后往前遍历,从而保证子问题需要用到的数据不被覆盖,优化版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int zeroOnePackOpt(int V, int[] C, int[] W) { 
// 防止无效输入
if ((V <= 0) || (C.length != W.length)) {
return 0;
}

int n = C.length;

int[] dp = new int[V + 1];

// 背包空的情况下,价值为 0
dp[0] = 0;

for (int i = 0; i < n; ++i) {
for (int j = V; j >= C[i]; --j) {
dp[j] = Math.max(dp[j], dp[j - C[i]] + W[i]);
}
}

return dp[V];
}

这里,因为物品只能被选中 1 次,或者被选中 0 次,因此我们称这种背包问题为 01 背包问题。

还有一类背包问题,物品可以被选多次或者 0 次,这类问题我们称为 完全背包问题,这类背包问题和 01 背包问题很类似,略微的不同在于,在完全背包问题中,状态 dp[i][j] 依赖的是 dp[i - 1][j] 以及 dp[i][k] k < j,你可以看看下面的实现代码:

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
public int completePack(int V, int[] C, int[] W) {
// 防止无效输入
if (V == 0 || C.length != W.length) {
return 0;
}

int n = C.length;

// dp[i][j]: 对于下标为 0~i 的物品,背包容量为 j 时的最大价值
int[][] dp = new int[n + 1][V + 1];

// 背包空的情况下,价值为 0
dp[0][0] = 0;

for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= V; ++j) {
// 不取该物品
dp[i][j] = dp[i - 1][j];

// 取该物品,但是是在考虑过或者取过该物品的基础之上(dp[i][...])取
// 0-1背包则是在还没有考虑过该物品的基础之上(dp[i - 1][...])取
if ((j >= C[i - 1]) && (dp[i][j - C[i - 1]] + W[i - 1] > dp[i][j])) {
dp[i][j] = dp[i][j - C[i - 1]] + W[i - 1];
}
}
}

// 返回,对于所有物品(0~N),背包容量为 V 时的最大价值
return dp[n][V];
}

类似的,我们还是可以对状态数组进行空间优化,依据我们之前讨论的状态之间的依赖关系,完全背包的空间优化我们直接把状态数组少开一维即可,遍历方式都不需要改变:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public int completePackOpt(int V, int[] C, int[] W) {
if (V == 0 || C.length != W.length) {
return 0;
}

int n = C.length;
int[] dp = new int[V + 1];
for (int i = 0; i < n; ++i) {
for (int j = C[i]; j <= V; ++j) {
dp[j] = Math.max(dp[j], dp[j - C[i]] + W[i]);
}
}

return dp[V];
}

下面,我们就根据这两类背包问题,看看遇到类似的问题我们是否可以套用上面我们介绍的解法。

相关题目实战

LeetCode 第 416 号问题:分割等和子集。

题目来源:https://leetcode-cn.com/problems/partition-equal-subset-sum/

题目描述

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意:

每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:

1
2
3
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].

示例 2:
1
2
3
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.

题目分析

题目给定一个数组,问是否可以将数组拆分成两份,并且两份的值相等,这里并不是说分成两个子数组,而是分成两个子集。

直观的想法是直接遍历一遍数组,这样我们可以得到数组中所有元素的和,这个和必须是偶数,不然没法分,其实很自然地就可以想到,我们要从数组中挑出一些元素,使这些元素的和等于原数组中元素总和的一半,“从数组中找出一些元素让它们的和等于一个固定的值”,这么一个信息能否让你想到背包类动态规划呢?

如果你能想到这个地方,再配上我们之前讲的 01 背包问题 的解法,那么这道题目就可以直接套解法了,这里我就不具体分析了。

参考代码

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
public boolean canPartition(int[] nums) {
if (nums == null || nums.length == 0) {
return false;
}

int sum = 0;

int n = nums.length;

for (int i = 0; i < n; ++i) {
sum += nums[i];
}

if (sum % 2 != 0) {
return false;
}

int target = sum / 2;

boolean[] dp = new boolean[target + 1];

dp[0] = true;

for (int i = 0; i < n; ++i) {
for (int j = target; j >= nums[i]; --j) {
dp[j] |= dp[j - nums[i]];
}
}

return dp[target];
}

LeetCode 第 322 号问题:零钱兑换。

题目来源:https://leetcode-cn.com/problems/coin-change

题目描述

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

1
2
3
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1

示例 2:
1
2
3
4
输入: coins = [2], amount = 3
输出: -1
说明:
你可以认为每种硬币的数量是无限的。

题目分析

题目给定一个数组和一个整数,数组里面的值表示的是每个硬币的价值,整数表示的是一个价值,问最少选择多少个硬币能够组成这个价值,硬币可以重复选择。

虽然这里只有一个输入数组,但是我们还是可以看到背包的影子,这里的整数就可以看作是背包的体积,然后数组里面的值可以看作是物品的体积,那物品的价值呢?

在这里,你可以形象地认为每个物品的价值是 1,最后我们要求的是填满背包的最小价值,因为这里物品是可以重复选择多次的,因此可以归类于 完全背包问题,套用之前的解法就可以解题,唯一要注意的一点是,这里我们不在求最大价值,而求的是最小价值,因此我们需要先将状态数组初始化成无穷大。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];

Arrays.fill(dp, Integer.MAX_VALUE);

dp[0] = 0;

for (int i = 0; i < coins.length; ++i) {
for (int j = coins[i]; j <= amount; ++j) {
if (dp[j - coins[i]] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j - coins[i]] + 1, dp[j]);
}
}
}

return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}

LeetCode 第 518 号问题:零钱兑换II。

题目来源:https://leetcode-cn.com/problems/coin-change-2/

题目描述

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

示例 1:

1
2
3
4
5
6
7
输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:
1
2
3
输入: amount = 3, coins = [2]
输出: 0
解释: 只用面额2的硬币不能凑成总金额3。

示例 3:
1
2
输入: amount = 10, coins = [10] 
输出: 1

注意:

你可以假设:

0 <= amount (总金额) <= 5000
1 <= coin (硬币面额) <= 5000
硬币种类不超过 500 种
结果符合 32 位符号整数

题目分析

这道题目是上一道题目的变形,题目的输入参数还是不变,变的是最后的问题,这里需要求的是 “有多少种组合方式能够填满背包”,我们还是可以套用 完全背包 的解法,只是最后求解的东西变了,那我们动态规划状态数组中记录的东西相应的改变即可,在这道题中,状态数组中记录组合成该价值的方案的个数即可。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];

dp[0] = 1;
for (int i = 0; i < coins.length; ++i) {
for (int j = coins[i]; j <= amount; ++j) {
dp[j] += dp[j - coins[i]];
}
}

return dp[amount];
}

K Sum。

题目描述

给定一个输入数组 array,还有两个整数 k 和 target,在数组 array 中找出 k 个元素,使得这 k 个元素相加等于 target,问有多少种组合方式,输出组合方式的个数。

注:在一种组合方式中,一个元素不能够被重复选择

题目分析

我们之前讲过 Two Sum,也提到过 3 Sum,还有 4 Sum,那这道题是否可以套用之前的解法呢?

这里有一个细节不知道你是否发现,就是 这道题目仅仅是让你输出所有组合方式的个数,并没有让你输出所有的组合方式,这是决定是否使用动态规划很重要的一点。

如果没有这个 k,我相信你会很直接地想到使用 01 背包问题 的解法,那我们可以思考一下,基于原来的解法,如果增加了 k 这个限制,我们需要额外做些什么事情呢?

因为 k 会决定问题的状态,因此我们的状态数组中也要考虑 k,在考虑将第 k 个元素放入背包中,我们需要看的是背包中存放 k - 1 个元素的情况,这么看来,其实相比普通的 01 背包问题,这道题目仅仅是增加了一维状态,没有其他的变化。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int kSum(int[] array, int k, int target) {
int[][] dp = new int[target + 1][k + 1];

dp[0][0] = 1;

for (int i = 0; i < array.length; ++i) {
for (int j = target; j >= array[i]; --j) {
// 和普通 01背包问题 相比,仅仅是多了一层状态需要考虑
// 这层状态记录的是背包里面元素的个数
// 我们放入第 r 个元素的时候,必须确保背包里面已经有 r - 1 个元素
for (int r = 1; r <= k; ++r) {
dp[j][r] += dp[j - array[i]][r - 1];
}
}
}

return dp[target][k];
}

动态规划之背包问题系列

背包问题是一类经典的动态规划问题,它非常灵活,需要仔细琢磨体会,本文先对背包问题的几种常见类型作一个总结,再给出代码模板,然后再看看LeetCode上几个相关题目。

根据维基百科,背包问题(Knapsack problem)是一种组合优化的NP完全(NP-Complete,NPC)问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。NPC问题是没有多项式时间复杂度的解法的,但是利用动态规划,我们可以以伪多项式时间复杂度求解背包问题。一般来讲,背包问题有以下几种分类:

  • 01背包问题
  • 完全背包问题
  • 多重背包问题

此外,还存在一些其他考法,例如恰好装满、求方案总数、求所有的方案等。本文接下来就分别讨论一下这些问题。

01背包

题目

最基本的背包问题就是01背包问题(01 knapsack problem):一共有N件物品,第i(i从1开始)件物品的重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析

如果采用暴力穷举的方式,每件物品都存在装入和不装入两种情况,所以总的时间复杂度是O(2^N),这是不可接受的。而使用动态规划可以将复杂度降至O(NW)。我们的目标是书包内物品的总价值,而变量是物品和书包的限重,所以我们可定义状态dp:

dp[i][j]表示将前i件物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W
那么我们可以将dp[0][0…W]初始化为0,表示将前0个物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]有两种情况:

  • 不装入第i件物品,即dp[i−1][j]
  • 装入第i件物品(前提是能装下),即dp[i−1][j−w[i]] + v[i]

即状态转移方程为

1
dp[i][j] = max(dp[i−1][j], dp[i−1][j−w[i]]+v[i]) // j >= w[i]

关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。

  • 首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。
  • 状态转移方程dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。

dp[0][j]即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。当j >= weight[0]时,dp[0][j]应该是value[0],因为背包容量放足够放编号0物品。代码初始化如下:

1
2
3
4
5
6
7
for (int j = 0 ; j < weight[0]; j++) {  // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
dp[0][j] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}

那么问题来了,先遍历 物品还是先遍历背包重量呢?其实都可以!!但是先遍历物品更好理解。那么我先给出先遍历物品,然后遍历背包重量的代码。

1
2
3
4
5
6
7
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}

先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)例如这样:

1
2
3
4
5
6
7
// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
for(int i = 1; i < weight.size(); i++) { // 遍历物品
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}

为什么也是可以的呢?要理解递归的本质和递推的方向。

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);递归公式中可以看出dp[i][j]是靠dp[i-1][j]dp[i - 1][j - weight[i]]推导出来的。

由上述状态转移方程可知,dp[i][j]的值只与dp[i-1][0,...,j-1]有关,所以我们可以采用动态规划常用的方法(滚动数组)对空间进行优化(即去掉dp的第一维)。需要注意的是,为了防止上一层循环的dp[0,...,j-1]被覆盖,循环的时候 j 只能逆向枚举(空间优化前没有这个限制),伪代码为:

1
2
3
4
5
// 01背包问题伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
dp[j] = max(dp[j], dp[j−w[i]]+v[i])

时间复杂度为O(NW), 空间复杂度为O(W)。由于W的值是W的位数的幂,所以这个时间复杂度是伪多项式时间。

动态规划的核心思想避免重复计算在01背包问题中体现得淋漓尽致。第i件物品装入或者不装入而获得的最大价值完全可以由前面i-1件物品的最大价值决定,暴力枚举忽略了这个事实。

完全背包

题目

完全背包(unbounded knapsack problem)与01背包不同就是每种物品可以有无限多个:一共有N种物品,每种物品有无限多个,第i(i从1开始)种物品的重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析一

我们的目标和变量和01背包没有区别,所以我们可定义与01背包问题几乎完全相同的状态dp:

dp[i][j]表示将前i种物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W
初始状态也是一样的,我们将dp[0][0…W]初始化为0,表示将前0种物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]也有两种情况:

不装入第i种物品,即dp[i−1][j],同01背包;
装入第i种物品,此时和01背包不太一样,因为每种物品有无限个(但注意书包限重是有限的),所以此时不应该转移到dp[i−1][j−w[i]]而应该转移到dp[i][j−w[i]],即装入第i种商品后还可以再继续装入第种商品。
所以状态转移方程为

1
dp[i][j] = max(dp[i−1][j], dp[i][j−w[i]]+v[i]) // j >= w[i]

这个状态转移方程与01背包问题唯一不同就是max第二项不是dp[i-1]而是dp[i]

和01背包问题类似,也可进行空间优化,优化后不同点在于这里的 j 只能正向枚举而01背包只能逆向枚举,因为这里的max第二项是dp[i]而01背包是dp[i-1],即这里就是需要覆盖而01背包需要避免覆盖。所以伪代码如下:

1
2
3
4
5
// 完全背包问题思路一伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = w[i],...,W // 必须正向枚举!!!
dp[j] = max(dp[j], dp[j−w[i]]+v[i])

由上述伪代码看出,01背包和完全背包问题此解法的空间优化版解法唯一不同就是前者的 j 只能逆向枚举而后者的 j 只能正向枚举,这是由二者的状态转移方程决定的。此解法时间复杂度为O(NW), 空间复杂度为O(W)。

分析二

除了分析一的思路外,完全背包还有一种常见的思路,但是复杂度高一些。我们从装入第 i 种物品多少件出发,01背包只有两种情况即取0件和取1件,而这里是取0件、1件、2件…直到超过限重(k > j/w[i]),所以状态转移方程为:

1
2
// k为装入第i种物品的件数, k <= j/w[i]
dp[i][j] = max{(dp[i-1][j − k*w[i]] + k*v[i]) for every k}

同理也可以进行空间优化,需要注意的是,这里max里面是dp[i-1],和01背包一样,所以 j 必须逆向枚举,优化后伪代码为

1
2
3
4
5
6
// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
for k = [0, 1,..., j/w[i]]
dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])

相比于分析一,此种方法不是在O(1)时间求得dp[i][j],所以总的时间复杂度就比分析一大些了,为 O(NWWw¯)级别。

分析三、转换成01背包

01背包问题是最基本的背包问题,我们可以考虑把完全背包问题转化为01背包问题来解:将一种物品转换成若干件只能装入0件或者1件的01背包中的物品。

最简单的想法是,考虑到第 i 种物品最多装入 W/w[i] 件,于是可以把第 i 种物品转化为 W/w[i] 件费用及价值均不变的物品,然后求解这个01背包问题。

更高效的转化方法是采用二进制的思想:把第 i 种物品拆成重量为 wi2k、价值为 vi2k 的若干件物品,其中 k 取遍满足 wi2k≤W 的非负整数。这是因为不管最优策略选几件第 i 种物品,总可以表示成若干个刚才这些物品的和(例:13 = 1 + 4 + 8)。这样就将转换后的物品数目降成了对数级别。具体代码见3.4节模板。

多重背包

题目

多重背包(bounded knapsack problem)与前面不同就是每种物品是有限个:一共有N种物品,第i(i从1开始)种物品的数量为n[i],重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析一

此时的分析和完全背包的分析二差不多,也是从装入第 i 种物品多少件出发:装入第i种物品0件、1件、…n[i]件(还要满足不超过限重)。所以状态方程为:

1
2
// k为装入第i种物品的件数, k <= min(n[i], j/w[i])
dp[i][j] = max{(dp[i-1][j − k*w[i]] + k*v[i]) for every k}

同理也可以进行空间优化,而且 j 也必须逆向枚举,优化后伪代码为

1
2
3
4
5
6
// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
for k = [0, 1,..., min(n[i], j/w[i])]
dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])

分析二、转换成01背包

采用2.4节类似的思路可以将多重背包转换成01背包问题,采用二进制思路将第 i 种物品分成了 O(logni) 件物品,将原问题转化为了复杂度为 O(W∑ilogni) 的 01 背包问题,相对于分析一是很大的改进,具体代码见3.4节。

代码模板

此节根据上面的讲解给出这三种背包问题的解题模板,方便解题使用。尤其注意其中二进制优化是如何实现的。

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
https://tangshusen.me/2019/11/24/knapsack-problem/
01背包, 完全背包, 多重背包模板(二进制优化).
2020.01.04 by tangshusen.

用法:
对每个物品调用对应的函数即可, 例如多重背包:
for(int i = 0; i < N; i++)
multiple_pack_step(dp, w[i], v[i], num[i], W);

参数:
dp : 空间优化后的一维dp数组, 即dp[i]表示最大承重为i的书包的结果
w : 这个物品的重量
v : 这个物品的价值
n : 这个物品的个数
max_w: 书包的最大承重
*/
void zero_one_pack_step(vector<int>&dp, int w, int v, int max_w){
for(int j = max_w; j >= w; j--) // 反向枚举!!!
dp[j] = max(dp[j], dp[j - w] + v);
}

void complete_pack_step(vector<int>&dp, int w, int v, int max_w){
for(int j = w; j <= max_w; j++) // 正向枚举!!!
dp[j] = max(dp[j], dp[j - w] + v);

// 法二: 转换成01背包, 二进制优化
// int n = max_w / w, k = 1;
// while(n > 0){
// zero_one_pack_step(dp, w*k, v*k, max_w);
// n -= k;
// k = k*2 > n ? n : k*2;
// }
}

void multiple_pack_step(vector<int>&dp, int w, int v, int n, int max_w){
if(n >= max_w / w) complete_pack_step(dp, w, v, max_w);
else{ // 转换成01背包, 二进制优化
int k = 1;
while(n > 0){
zero_one_pack_step(dp, w*k, v*k, max_w);
n -= k;
k = k*2 > n ? n : k*2;
}
}
}

其他情形

恰好装满

背包问题有时候还有一个限制就是必须恰好装满背包,此时基本思路没有区别,只是在初始化的时候有所不同。

如果没有恰好装满背包的限制,我们将dp全部初始化成0就可以了。因为任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。如果有恰好装满的限制,那只应该将dp[0,…,N][0]初始为0,其它dp值均初始化为-inf,因为此时只有容量为0的背包可以在什么也不装情况下被“恰好装满”,其它容量的背包初始均没有合法的解,应该被初始化为-inf。

求方案总数

除了在给定每个物品的价值后求可得到的最大价值外,还有一类问题是问装满背包或将背包装至某一指定容量的方案总数。对于这类问题,需要将状态转移方程中的 max 改成 sum ,大体思路是不变的。例如若每件物品均是完全背包中的物品,转移方程即为

1
dp[i][j] = sum(dp[i−1][j], dp[i][j−w[i]]) // j >= w[i]

二维背包

前面讨论的背包容量都是一个量:重量。二维背包问题是指每个背包有两个限制条件(比如重量和体积限制),选择物品必须要满足这两个条件。此类问题的解法和一维背包问题不同就是dp数组要多开一维,其他和一维背包完全一样,例如5.4节。

求最优方案

一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由哪一个策略推出来的,这样便可根据这条策略找到上一个状态,从上一个状态接着向前推即可。

以01背包为例,我们可以再用一个数组G[i][j]来记录方案,设 G[i][j] = 0表示计算 dp[i][j] 的值时是采用了max中的前一项(也即dp[i−1][j]),G[i][j] = 1 表示采用了方程的后一项。即分别表示了两种策略: 未装入第 i 个物品及装了第 i 个物品。其实我们也可以直接从求好的dp[i][j]反推方案:若 dp[i][j] = dp[i−1][j] 说明未选第i个物品,反之说明选了。

LeetCode相关题目

本节对LeetCode上面的背包问题进行讨论。

Partition Equal Subset Sum(分割等和子集)

  1. Partition Equal Subset Sum(分割等和子集)

题目给定一个只包含正整数的非空数组。问是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

由于所有元素的和sum已知,所以两个子集的和都应该是sum/2(所以前提是sum不能是奇数),即题目转换成从这个数组里面选取一些元素使这些元素和为sum/2。如果我们将所有元素的值看做是物品的重量,每件物品价值都为1,所以这就是一个恰好装满的01背包问题。

我们定义空间优化后的状态数组dp,由于是恰好装满,所以应该将dp[0]初始化为0而将其他全部初始化为INT_MIN,然后按照类似1.2节的伪代码更新dp:

1
2
3
4
5
6
int capacity = sum / 2;
vector<int>dp(capacity + 1, INT_MIN);
dp[0] = 0;
for(int i = 1; i <= n; i++)
for(int j = capacity; j >= nums[i-1]; j--)
dp[j] = max(dp[j], 1 + dp[j - nums[i-1]]);

更新完毕后,如果dp[sum/2]大于0说明满足题意。

由于此题最后求的是能不能进行划分,所以dp的每个元素定义成bool型就可以了,然后将dp[0]初始为true其他初始化为false,而转移方程就应该是用或操作而不是max操作。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool canPartition(vector<int>& nums) {
int sum = 0, n = nums.size();
for(int &num: nums) sum += num;
if(sum % 2) return false;

int capacity = sum / 2;
vector<bool>dp(capacity + 1, false);
dp[0] = true;
for(int i = 1; i <= n; i++)
for(int j = capacity; j >= nums[i-1]; j--)
dp[j] = dp[j] || dp[j - nums[i-1]];

return dp[capacity];
}

另外此题还有一个更巧妙更快的解法,基本思路是用一个bisets来记录所有可能子集的和,详见我的Github。

Coin Change(零钱兑换)

  1. Coin Change

题目给定一个价值amount和一些面值,假设每个面值的硬币数都是无限的,问我们最少能用几个硬币组成给定的价值。

如果我们将面值看作是物品,面值金额看成是物品的重量,每件物品的价值均为1,这样此题就是是一个恰好装满的完全背包问题了。不过这里不是求最多装入多少物品而是求最少,我们只需要将2.2节的转态转移方程中的max改成min即可,又由于是恰好装满,所以除了dp[0],其他都应初始化为INT_MAX。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
int coinChange(vector<int>& coins, int amount) {
vector<int>dp(amount + 1, INT_MAX);
dp[0] = 0;

for(int i = 1; i <= coins.size(); i++)
for(int j = coins[i-1]; j <= amount; j++){
// 下行代码会在 1+INT_MAX 时溢出
// dp[j] = min(dp[j], 1 + dp[j - coins[i-1]]);
if(dp[j] - 1 > dp[j - coins[i-1]])
dp[j] = 1 + dp[j - coins[i-1]];
}
return dp[amount] == INT_MAX ? -1 : dp[amount];
}

注意上面1 + dp[j - coins[i-1]]会存在溢出的风险,所以我们换了个写法。

另外此题还可以进行搜索所有可能然后保持一个全局的结果res,但是直接搜索会超时,所以需要进行精心剪枝,剪枝后可击败99%。详见我的Github。

Target Sum(目标和)

  1. Target Sum

这道题给了我们一个数组(元素非负),和一个目标值,要求给数组中每个数字前添加正号或负号所组成的表达式结果与目标值S相等,求有多少种情况。

假设所有元素和为sum,所有添加正号的元素的和为A,所有添加负号的元素和为B,则有sum = A + B 且 S = A - B,解方程得A = (sum + S)/2。即题目转换成:从数组中选取一些元素使和恰好为(sum + S) / 2。可见这是一个恰好装满的01背包问题,要求所有方案数,将1.2节状态转移方程中的max改成求和即可。需要注意的是,虽然这里是恰好装满,但是dp初始值不应该是inf,因为这里求的不是总价值而是方案数,应该全部初始为0(除了dp[0]初始化为1)。所以代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int findTargetSumWays(vector<int>& nums, int S) {
int sum = 0;
// for(int &num: nums) sum += num;
sum = accumulate(nums.begin(), nums.end(), 0);
if(S > sum || sum < -S) return 0; // 肯定不行
if((S + sum) & 1) return 0; // 奇数
int target = (S + sum) >> 1;

vector<int>dp(target + 1, 0);

dp[0] = 1;
for(int i = 1; i <= nums.size(); i++)
for(int j = target; j >= nums[i-1]; j--)
dp[j] = dp[j] + dp[j - nums[i-1]];

return dp[target];
}

Ones and Zeros(一和零)

  1. Ones and Zeroes

题目给定一个仅包含 0 和 1 字符串的数组。任务是从数组中选取尽可能多的字符串,使这些字符串包含的0和1的数目分别不超过m和n。

我们把每个字符串看做是一件物品,把字符串中0的数目和1的数目看做是两种“重量”,所以就变成了一个二维01背包问题,书包的两个限重分别是 m 和 n,要求书包能装下的物品的最大数目(也相当于价值最大,设每个物品价值为1)。

我们可以提前把每个字符串的两个“重量” w0和w1算出来用数组存放,但是注意到只需要用一次这两个值,所以我们只需在用到的时候计算w0和w1就行了,这样就不用额外的数组存放。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int findMaxForm(vector<string>& strs, int m, int n) {
int num = strs.size();
int w0, w1;

vector<vector<int>>dp(m+1, vector<int>(n+1, 0));

for(int i = 1; i <= num; i++){
w0 = 0; w1 = 0;
// 计算第i-1个字符串的两个重量
for(char &c: strs[i - 1]){
if(c == '0') w0 += 1;
else w1 += 1;
}

// 01背包, 逆向迭代更新dp
for(int j = m; j >= w0; j--)
for(int k = n; k >= w1; k--)
dp[j][k] = max(dp[j][k], 1+dp[j-w0][k-w1]);
}

return dp[m][n];
}

总结

本文讨论了几类背包问题及LeetCode相关题目,其中01背包问题和完全背包问题是最常考的,另外还需要注意一些其他变种例如恰好装满、二维背包、求方案总数等等。除了本文讨论的这些背包问题之外,还存在一些其他的变种,但只要深刻领会本文所列的背包问题的思路和状态转移方程,遇到其它的变形问题,应该也不难想出算法。如果想更加详细地理解背包问题,推荐阅读经典的背包问题九讲。

空间优化 - 滚动数组

有一个比较通用的空间优化技巧没有在之前的文章中提到,很多的动态规划题目都可以套用这个技巧,我们就拿之前的 最长公共子序列 这道题目来举例说明,当时我们最终实现的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[length1 + 1][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}

return dp[length1][length2];
}

你仔细观察代码,会发现当前考虑的状态 dp[i][j] 仅仅依赖于 dp[i - 1][j] 和 dp[i][j - 1],如果画出表格,也就是当前行的格子只会和当前行以及前一行的格子有关,因此,保留两行数据就能够满足状态迭代更新的要求,我们可以得到下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[2][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[i%2][j] = dp[(i - 1)%2][j - 1] + 1;
} else {
dp[i%2][j] = Math.max(dp[(i - 1)%2][j], dp[i%2][j - 1]);
}
}
}

return dp[length1%2][length2];
}

这里我们成功将空间的维度降低了一维,当然如果你觉得取模的操作让代码变得不整洁,你也可以参考下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[2][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

int cur = 0, prev = 1;
for (int i = 1; i <= length1; ++i) {
prev = cur; cur = 1 - cur;
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[cur][j] = dp[prev][j - 1] + 1;
} else {
dp[cur][j] = Math.max(dp[prev][j], dp[cur][j - 1]);
}
}
}

return dp[cur][length2];
}

其实滚动数组的思想不难理解,就是只保存需要用到的子问题的答案(状态),覆盖那些不需要用到的子问题的答案,状态在同一块空间中不断翻滚迭代向前。

当然,有些动态规划的实现方式就不太容易使用这类优化,比如 记忆化搜索,还有些动态规划题型,比如 区间类动态规划,状态的更新不是逐行逐列的,使用滚动数组来优化也不是特别容易,因此使用滚动数组优化的时候还是需要结合实际情况考虑。

滚动数组一般来说都可以将状态数组的空间降低一维,比如三维变二维、二维变一维、一维变常数,当然有些具体题型的空间优化也可以做到这个,比如背包类型的动态规划问题中,我们通过改变遍历的顺序,直接就可以做到空间降维,但其实这是这类动态规划问题特有的优化,不属于滚动数组的范畴。

总结

动态规划系列内容算是结束了,虽然有关动态规划的知识点还有很多,但是我相信如果深刻掌握并理解了之前我们讲的内容,基本上 leetcode 上面 90% 以上的动态规划相关问题都可以很好解决。

当然了,要想达到熟能生巧的程度,还是需要多加练习,多思考,多对比,多总结,不然的话,学到的东西很快就会忘记。

最后的最后,希望动态规划不再是你面试中的拦路虎,看到它,也希望你能多一份亲切和自信。

引言

说到 C++ 的内存管理,我们可能会想到栈空间的本地变量、堆上通过 new 动态分配的变量以及全局命名空间的变量等,这些变量的分配位置都是由系统来控制管理的,而调用者只需要考虑变量的生命周期相关内容即可,而无需关心变量的具体布局。这对于普通软件的开发已经足够,但对于引擎开发而言,我们必须对内存有着更为精细的管理。

基础概念

在文章的开篇,先对一些基础概念进行简单的介绍,以便能够更好地理解后续的内容。

内存布局

内存分布(可执行映像)

如图,描述了C++程序的内存分布。

  • Code Segment(代码区)也称Text Segment,存放可执行程序的机器码。
  • Data Segment (数据区)存放已初始化的全局和静态变量, 常量数据(如字符串常量)。
  • BSS(Block started by symbol)存放未初始化的全局和静态变量。(默认设为0)
  • Heap(堆)从低地址向高地址增长。容量大于栈,程序中动态分配的内存在此区域。
  • Stack(栈)从高地址向低地址增长。由编译器自动管理分配。程序中的局部变量、函数参数值、返回变量等存在此区域。

函数栈

如上图所示,可执行程序的文件包含BSS,Data Segment和Code Segment,当可执行程序载入内存后,系统会保留一些空间,即堆区和栈区。堆区主要是动态分配的内存(默认情况下),而栈区主要是函数以及局部变量等(包括main函数)。一般而言,栈的空间小于堆的空间。

当调用函数时,一块连续内存(堆栈帧)压入栈;函数返回时,堆栈帧弹出。

堆栈帧包含如下数据:

  • 函数返回地址
  • 局部变量/CPU寄存器数据备份

函数压栈

全局变量

当全局/静态变量(如下代码中的x和y变量)未初始化的时候,它们记录在BSS段。

1
2
3
4
5
6
7
8
9
10
int x;
int z = 5;
void func()
{
static int y;
}
int main()
{
return 0;
}

处于BSS段的变量的值默认为0,考虑到这一点,BSS段内部无需存储大量的零值,而只需记录字节个数即可。

系统载入可执行程序后,将BSS段的数据载入数据段(Data Segment) ,并将内存初始化为0,再调用程序入口(main函数)。

而对于已经初始化了的全局/静态变量而言,如以上代码中的z变量,则一直存储于数据段(Data Segment)。

内存对齐

对于基础类型,如float, double, int, char等,它们的大小和内存占用是一致的。而对于结构体而言,如果我们取得其sizeof的结果,会发现这个值有可能会大于结构体内所有成员大小的总和,这是由于结构体内部成员进行了内存对齐。

为什么要进行内存对齐

① 内存对齐使数据读取更高效

在硬件设计上,数据读取的处理器只能从地址为k的倍数的内存处开始读取数据。这种读取方式相当于将内存分为了多个”块“,假设内存可以从任意位置开始存放的话,数据很可能会被分散到多个“块”中,处理分散在多个块中的数据需要移除首尾不需要的字节,再进行合并,非常耗时。

为了提高数据读取的效率,程序分配的内存并不是连续存储的,而是按首地址为k的倍数的方式存储;这样就可以一次性读取数据,而不需要额外的操作。

读取非对齐内存的过程示例

② 在某些平台下,不进行内存对齐会崩溃

内存对齐的规则

定义有效对齐值(alignment)为结构体中 最宽成员 和 编译器/用户指定对齐值 中较小的那个。

  1. 结构体起始地址为有效对齐值的整数倍
  2. 结构体总大小为有效对齐值的整数倍
  3. 结构体第一个成员偏移值为0,之后成员的偏移值为 min(有效对齐值, 自身大小) 的整数倍

相当于每个成员要进行对齐,并且整个结构体也需要进行对齐。

示例

1
2
3
4
5
6
7
8
9
10
11
12
struct A
{
int i;
char c1;
char c2;
};

int main()
{
cout << sizeof(A) << endl; // 有效对齐值为4, output : 8
return 0;
}


内存排布示例

内存碎片

程序的内存往往不是紧凑连续排布的,而是存在着许多碎片。我们根据碎片产生的原因把碎片分为内部碎片和外部碎片两种类型:

  1. 内部碎片:系统分配的内存大于实际所需的内存(由于对齐机制);
  2. 外部碎片:不断分配回收不同大小的内存,由于内存分布散乱,较大内存无法分配;


内部碎片和外部碎片

为了提高内存的利用率,我们有必要减少内存碎片,具体的方案将在后文重点介绍。

继承类布局

继承

如果一个类继承自另一个类,那么它自身的数据位于父类之后。

含虚函数的类

如果当前类包含虚函数,则会在类的最前端占用4个字节,用于存储虚表指针(vpointer),它指向一个虚函数表(vtable)。

vtable中包含当前类的所有虚函数指针。

字节序(endianness)

大于一个字节的值被称为多字节量,多字节量存在高位有效字节和低位有效字节 (关于高位和低位,我们以十进制的数字来举例,对于数字482来说,4是高位,2是低位),微处理器有两种不同的顺序处理高位和低位字节的顺序:

  • 小端(little_endian):低位有效字节存储于较低的内存位置
  • 大端(big_endian):高位有效字节存储于较低的内存位置

我们使用的PC开发机默认是小端存储。

一般情况下,多字节量的排列顺序对编码没有影响。但如果要考虑跨平台的一些操作,就有必要考虑到大小端的问题。如下图,ue4引擎使用了PLATFORM_LITTLE_ENDIAN这一宏,在不同平台下对数据做特殊处理(内存排布交换,确保存储时的结果一致)。ue4针对大小端对数据做特殊处理(ByteSwap.h)

用union判断大小端

  • 大端存储:字数据的高字节存储在低地址中
  • 小端存储:字数据的低字节存储在低地址中

了解了大小端存储的方式,如何在代码中进行判断呢?下面介绍两种判断方式:

方式一:使用强制类型转换

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
#include <iostream>
using namespace std;
int main()
{
int a = 0x1234;
//由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分
char c = (char)(a);
if (c == 0x12)
cout << "big endian" << endl;
else if(c == 0x34)
cout << "little endian" << endl;
}
``

方式二:巧用union联合体
```C++
#include <iostream>
using namespace std;
//union联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值
union endian
{
int a;
char ch;
};
int main()
{
endian value;
value.a = 0x1234;
//a和ch共用4字节的内存空间
if (value.ch == 0x12)
cout << "big endian"<<endl;
else if (value.ch == 0x34)
cout << "little endian"<<endl;
}

操作系统

对一些基础概念有所了解后,我们可以来关注操作系统底层的一些设计。在掌握了这些特性后,我们才能更好地针对性地编写高性能代码。

SIMD

SIMD,即Single Instruction Multiple Data,用一个指令并行地对多个数据进行运算,是CPU基本指令集的扩展。

例一:处理器的寄存器通常是32位或者64位的,而图像的一个像素点可能只有8bit,如果一次只能处理一个数据比较浪费空间;此时可以将64位寄存器拆成8个8位寄存器,就可以并行完成8个操作,提升效率。

例二:SSE指令采用128位寄存器,我们通常将4个32位浮点值打包到128位寄存器中,单个指令可完成4对浮点数的计算,这对于矩阵/向量操作非常友好(除此之外,还有Neon/FPU等寄存器)

高速缓存

一般来说CPU以超高速运行,而内存速度慢于CPU,硬盘速度慢于内存。

当我们把数据加载内存后,要对数据进行一定操作时,会将数据从内存载入CPU寄存器。考虑到CPU读/写主内存速度较慢,处理器使用了高速的缓存(Cache),作为内存到CPU中间的媒介。

引入L1和L2缓存后,CPU和内存之间的将无法进行直接的数据交互,而是需要经过两级缓存(目前也已出现L3缓存)。

① CPU请求数据:如果数据已经在缓存中,则直接从缓存载入寄存器;如果数据不在缓存中(缓存命中失败),则需要从内存读取,并将内存载入缓存中。

② CPU写入数据:有两种方案,(1) 写入到缓存时同步写入内存(write through cache) (2) 仅写入到缓存中,有必要时再写入内存(write-back)

为了提高程序性能,则需要尽可能避免缓存命中失败。一般而言,遵循尽可能地集中连续访问内存,减少”跳变“访问的原则(locality of reference)。这里其实隐含了两个意思,一个是内存空间上要尽可能连续,另外一个是访问时序上要尽可能连续。像节点式的数据结构的遍历就会差于内存连续性的容器。

虚拟内存

虚拟内存,也就是把不连续的物理内存块映射到虚拟地址空间(virtual address space)。使内存页对于应用程序来说看起来是连续的。一般而言,出于程序安全性和物理内存可能不足的考虑,我们的程序都会运行在虚拟内存上。

这意味着,每个程序都有自己的地址空间,我们使用的内存存在一个虚拟地址和一个物理地址,两者之间需要进行地址翻译。

缺页

在虚拟内存中,每个程序的地址空间被划分为多个块,每个内存块被称作页,每个页的包含了连续的地址,并且被映射到物理内存。并非所有页都在物理内存中,当我们访问了不在物理内存中的页时,这一现象称为缺页,操作系统会从磁盘将对应内容装载到物理内存;当内存不足,部分页也会写回磁盘。

在这里,我们将CPU,高速缓存和主存视为一个整体,统称为DRAM。由于DRAM与磁盘之间的读写也比较耗时,为了提高程序性能,我们依然需要确保自己的程序具有良好的“局部性”——在任意时刻都在一个较小的活动页面上工作。

分页

当使用虚拟内存时,会通过MMU将虚拟地址映射到物理内存,虚拟内存的内存块称为页,而物理内存中的内存块称为页框,两者大小一致,DRAM和磁盘之间以页为单位进行交换。

简单来说,如果想要从虚拟内存翻译到物理地址,首先会从一个TLB(Translation Lookaside Buffer)的设备中查找,如果找不到,在虚拟地址中也记录了虚拟页号和偏移量,可以先通过虚拟页号找到页框号,再通过偏移量在对应页框进行偏移,得到物理地址。为了加速这个翻译过程,有时候还会使用多级页表,倒排页表等结构。

置换算法

到目前为止,我们已经接触了不少和“置换”有关的内容:例如寄存器和高速缓存之间,DRAM和磁盘之间,以及TLB的缓存等。这个问题的本质是,我们在有限的空间内存储了一些快速查询的结构,但是我们无法存储所有的数据,所以当查询未命中时,就需要花更大的代价,而所谓置换,也就是我们的快速查询结构是在不断更新的,会随着我们的操作,使得一部分数据被装在到快速查询结构中,又有另一部分数据被卸载,相当于完成了数据的置换。

常见的置换有如下几种:

  • 最近未使用置换(NRU):出现未命中现象时,置换最近一个周期未使用的数据。
  • 先入先出置换(FIFO):出现未命中现象时,置换最早进入的数据。
  • 最近最少使用置换(LRU):出现未命中现象时,置换未使用时间最长的数据。

C++语法

位域(Bit Fields)

表示结构体位域的定义,指定变量所占位数。它通常位于成员变量后,用 声明符:常量表达式 表示。(参考资料)

声明符是可选的,匿名字段可用于填充。

以下是ue4中Float16的定义:

1
2
3
4
5
6
7
8
9
10
11
12
struct
{
#if PLATFORM_LITTLE_ENDIAN
uint16 Mantissa : 10;
uint16 Exponent : 5;
uint16 Sign : 1;
#else
uint16 Sign : 1;
uint16 Exponent : 5;
uint16 Mantissa : 10;
#endif
} Components;

new和placement new

new是C++中用于动态内存分配的运算符,它主要完成了以下两个操作:

  1. 调用operator new()函数,动态分配内存。
  2. 在分配的动态内存块上调用构造函数,以初始化相应类型的对象,并返回首地址。

当我们调用new时,会在堆中查找一个足够大的剩余空间,分配并返回;当我们调用delete时,则会将该内存标记为不再使用,而指针仍然执行原来的内存。

new的语法

1
::(optional) new (placement_params)(optional) ( type ) initializer(optional) 

● 一般表达式

1
p_var = new type(initializer); // p_var = new type{initializer};

● 对象数组表达式

1
2
p_var = new type[size]; // 分配
delete[] p_var; // 释放

● 二维数组表达式

1
2
auto p = new double[2][2];
auto p = new double[2][2]{ {1.0,2.0},{3.0,4.0} };

● 不抛出异常的表达式

1
new (nothrow) Type (optional-initializer-expression-list)

默认情况下,如果内存分配失败,new运算符会选择抛出std::bad_alloc异常,如果加入nothrow,则不抛出异常,而是返回nullptr。

● 占位符类型:我们可以使用placeholder type(如auto/decltype)指定类型:

1
auto p = new auto('c');

● 带位置的表达式(placement new):可以指定在哪块内存上构造类型。

它的意义在于我们可以利用placement new将内存分配和构造这两个模块分离(后续的allocator更好地践行了这一概念),这对于编写内存管理的代码非常重要,比如当我们想要编写内存池的代码时,可以预申请一块内存,然后通过placement new申请对象,一方面可以避免频繁调用系统new/delete带来的开销,另一方面可以自己控制内存的分配和释放。

预先分配的缓冲区可以是堆或者栈上的,一般按字节(char)类型来分配,这主要考虑了以下两个原因:

  • 方便控制分配的内存大小(通过sizeof计算即可)
  • 如果使用自定义类型,则会调用对应的构造函数。但是既然要做分配和构造的分离,我们实际上是不期望它做任何构造操作的,而且对于没有默认构造函数的自定义类型,我们是无法预分配缓冲区的。

以下是一个使用的例子:

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
class A
{
private:
int data;
public:
A(int indata)
: data(indata) { }
void print()
{
cout << data << endl;
}
};
int main()
{
const int size = 10;
char buf[size * sizeof(A)]; // 内存分配
for (size_t i = 0; i < size; i++)
{
new (buf + i * sizeof(A)) A(i); // 对象构造
}
A* arr = (A*)buf;
for (size_t i = 0; i < size; i++)
{
arr[i].print();
arr[i].~A(); // 对象析构
}
// 栈上预分配的内存自动释放
return 0;
}

和数组越界访问不一定崩溃类似,这里如果在未分配的内存上执行placement new,可能也不会崩溃。

● 自定义参数的表达式

当我们调用new时,实际上执行了operator new运算符表达式,和其它函数一样,operator new有多种重载,如上文中的placement new,就是operator new以下形式的一个重载:

● placement new的定义:新语法(C++17)还支持带对齐的operator new:

● aligned new的声明:调用示例:

1
auto p = new(std::align_val_t{ 32 }) A;

new的重载

在C++中,我们一般说new和delete动态分配和释放的对象位于自由存储区(free store),这是一个抽象概念。默认情况下,C++编译器会使用堆实现自由存储。

前文已经提及了new的几种重载,包括数组,placement,align等。

如果我们想要实现自己的内存分配自定义操作,我们可以有如下两个方式:

  • 编写重载的operator new,这意味着我们的参数需要和全局operator new有差异。
  • 重定义operator new,根据名字查找规则,会优先在申请内存的数据内部/数据定义处查找new运算符,未找到才会调用全局::operator new()。

需要注意的是,如果该全局operator new已经实现为inline函数,则我们不能重定义相关函数,否则无法通过编译,如下:

1
2
3
4
5
6
7
// Default placement versions of operator new.
inline void* operator new(std::size_t, void* __p) throw() { return __p; }
inline void* operator new[](std::size_t, void* __p) throw() { return __p; }

// Default placement versions of operator delete.
inline void operator delete (void*, void*) throw() { }
inline void operator delete[](void*, void*) throw() { }

但是,我们可以重写如下nothrow的operator new:
1
2
3
4
void* operator new(std::size_t, const std::nothrow_t&) throw();
void* operator new[](std::size_t, const std::nothrow_t&) throw();
void operator delete(void*, const std::nothrow_t&) throw();
void operator delete[](void*, const std::nothrow_t&) throw();

为什么说new是低效的

① 一般来说,操作越简单,意味着封装了更多的实现细节。new作为一个通用接口,需要处理任意时间、任意位置申请任意大小内存的请求,它在设计上就无法兼顾一些特殊场景的优化,在管理上也会带来一定开销。

② 系统调用带来的开销。多数操作系统上,申请内存会从用户模式切换到内核模式,当前线程会block住,上下文切换将会消耗一定时间。

③ 分配可能是带锁的。这意味着分配难以并行化。

alignas和alignof

不同的编译器一般都会有默认的对齐量,一般都为2的幂次。

在C中,我们可以通过预编译命令修改对齐量:

1
#pragma pack(n)

在内存对齐篇已经提及,我们最终的有效对齐量会取结构体最宽成员 和 编译器默认对齐量(或我们自己定义的对齐量)中较小的那个。

C++中也提供了类似的操作:

1
alignas

用于指定对齐量。

可以应用于类/结构体/union/枚举的声明/定义;非位域的成员变量的定义;变量的定义(除了函数参数或异常捕获的参数);

alignas会对对齐量做检查,对齐量不能小于默认对齐,如下面的代码,struct U的对齐设置是错误的:

1
2
3
4
5
6
7
8
9
struct alignas(8) S 
{
// ...
};

struct alignas(1) U
{
S s;
};

以下对齐设置也是错误的:

1
2
3
struct alignas(2) S {
int n;
};

此外,一些错误的格式也无法通过编译,如:

1
struct alignas(3) S { };

例子:

1
2
3
4
5
6
7
8
9
// every object of type sse_t will be aligned to 16-byte boundary
struct alignas(16) sse_t
{
float sse_data[4];
};

// the array "cacheline" will be aligned to 128-byte boundary
alignas(128)
char cacheline[128];

alignof operator

返回类型的std::size_t。如果是引用,则返回引用类型的对齐方式,如果是数组,则返回元素类型的对齐方式。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Foo {
int i;
float f;
char c;
};

struct Empty { };

struct alignas(64) Empty64 { };

int main()
{
std::cout << "Alignment of" "\n"
"- char :" << alignof(char) << "\n" // 1
"- pointer :" << alignof(int*) << "\n" // 8
"- class Foo :" << alignof(Foo) << "\n" // 4
"- empty class :" << alignof(Empty) << "\n" // 1
"- alignas(64) Empty:" << alignof(Empty64) << "\n"; // 64
}

std::max_align_t

一般为16bytes,malloc返回的内存地址,对齐大小不能小于max_align_t。

allocator

当我们使用C++的容器时,我们往往需要提供两个参数,一个是容器的类型,另一个是容器的分配器。其中第二个参数有默认参数,即C++自带的分配器(allocator):

1
template < class T, class Alloc = allocator<T> > class vector; // generic template

我们可以实现自己的allocator,只需实现分配、构造等相关的操作。在此之前,我们需要先对allocator的使用做一定的了解。

new操作将内存分配和对象构造组合在一起,而allocator的意义在于将内存分配和构造分离。这样就可以分配大块内存,而只在真正需要时才执行对象创建操作。

假设我们先申请n个对象,再根据情况逐一给对象赋值,如果内存分配和对象构造不分离可能带来的弊端如下:

  • 我们可能会创建一些用不到的对象;
  • 对象被赋值两次,一次是默认初始化时,一次是赋值时;
  • 没有默认构造函数的类甚至不能动态分配数组;

使用allocator之后,我们便可以解决上述问题。

分配

为n个string分配内存:

1
2
allocator<string> alloc; // 构造allocator对象
auto const p = alloc.allocate(n); // 分配n个未初始化的string

构造

在刚才分配的内存上构造两个string:

1
2
3
auto q = p;
alloc.construct(q++, "hello"); // 在分配的内存处创建对象
alloc.construct(q++, 10, 'c');

销毁

将已构造的string销毁:

1
2
while(q != p)
alloc.destroy(--q);

释放

将分配的n个string内存空间释放:

1
alloc.deallocate(p, n);

注意:传递给deallocate的指针不能为空,且必须指向由allocate分配的内存,并保证大小参数一致。

拷贝和填充

1
2
3
4
5
6
7
8
9
10
11
uninitialized_copy(b, e, b2)
// 从迭代器b, e 中的元素拷贝到b2指定的未构造的原始内存中;

uninitialized_copy(b, n, b2)
// 从迭代器b指向的元素开始,拷贝n个元素到b2开始的内存中;

uninitialized_fill(b, e, t)
// 从迭代器b和e指定的原始内存范围中创建对象,对象的值均为t的拷贝;

uninitialized_fill_n(b, n, t)
// 从迭代器b指向的内存地址开始创建n个对象;

为什么stl的allocator并不好用

如果仔细观察,我们会发现很多商业引擎都没有使用stl中的容器和分配器,而是自己实现了相应的功能。这意味着allocator无法满足某些引擎开发一些定制化的需求:

  • allocator内存对齐无法控制
  • allocator难以应用内存池之类的优化机制
  • 绑定模板签名

shared_ptr, unique_ptr和weak_ptr

智能指针是针对裸指针可能出现的问题封装的指针类,它能够更安全、更方便地使用动态内存。

shared_ptr

shared_ptr的主要应用场景是当我们需要在多个类中共享指针时。

多个类共享指针存在这么一个问题:每个类都存储了指针地址的一个拷贝,如果其中一个类删除了这个指针,其它类并不知道这个指针已经失效,此时就会出现野指针的现象。为了解决这一问题,我们可以使用引用指针来计数,仅当检测到引用计数为0时,才主动删除这个数据,以上就是shared_ptr的工作原理。

shared_ptr的基本语法如下:

初始化

1
shared_ptr<int> p = make_shared<int>(42);

拷贝和赋值

1
2
3
auto p = make_shared<int>(42);
auto r = make_shared<int>(42);
r = q; // 递增q指向的对象,递减r指向的对象

只支持直接初始化

由于接受指针参数的构造函数是explicit的,因此不能将指针隐式转换为shared_ptr:

1
2
shared_ptr<int> p1 = new int(1024); // err
shared_ptr<int> p2(new int(1024)); // ok

不与普通指针混用

  1. 通过get()函数,我们可以获取原始指针,但我们不应该delete这一指针,也不应该用它赋值/初始化另一个智能指针;
  2. 当我们将原生指针传给shared_ptr后,就应该让shared_ptr接管这一指针,而不再直接操作原生指针。

重新赋值

1
p.reset(new int(1024));

unique_ptr

有时候我们会在函数域内临时申请指针,或者在类中声明非共享的指针,但我们很有可能忘记删除这个指针,造成内存泄漏。此时我们可以考虑使用unique_ptr,由名字可见,某一时刻只有一个unique_ptr指向给定的对象,且它会在析构的时候自动释放对应指针的内存。

unique_ptr的基本语法如下:

初始化

1
unique_ptr<string> p = make_unique<string>("test");

不支持直接拷贝/赋值

为了确保某一时刻只有一个unique_ptr指向给定对象,unique_ptr不支持普通的拷贝或赋值。

1
2
3
4
unique_ptr<string> p1(new string("test"));
unique_ptr<string> p2(p1); // err
unique_ptr<string> p3;
p3 = p2; // err

所有权转移

可以通过调用release或reset将指针的所有权在unique_ptr之间转移:

1
2
3
unique_ptr<string> p2(p1.release());
unique_ptr<string> p3(new string("test"));
p2.reset(p3.release());

不能忽视release返回的结果

release返回的指针通常用来初始化/赋值另一个智能指针,如果我们只调用release,而没有删除其返回值,会造成内存泄漏:

1
2
p2.release(); // err
auto p = p2.release(); // ok, but remember to delete(p)

支持移动

1
2
3
unique_ptr<int> clone(int p) {
return unique_ptr<int>(new int(p));
}

weak_ptr

weak_ptr不控制所指向对象的生存期,即不会影响引用计数。它指向一个shared_ptr管理的对象。通常而言,它的存在有如下两个作用:

  1. 解决循环引用的问题
  2. 作为一个“观察者”:

详细来说,和之前提到的多个类共享内存的例子一样,使用普通指针可能会导致一个类删除了数据后其它类无法同步这一信息,导致野指针;之前我们提出了shared_ptr,也就是每个类记录一个引用,释放时引用数减一,直到减为0才释放。

但在有些情况下,我们并不希望当前类影响到引用计数,而是希望实现这样的逻辑:假设有两个类引用一个数据,其中有一个类将主动控制类的释放,而无需等待另外一个类也释放才真正销毁指针所指对象。对于另一个类而言,它只需要知道这个指针已经失效即可,此时我们就可以使用weak_ptr。

我们可以像如下这样检测weak_ptr所有对象是否有效,并在有效的情况下做相关操作:

1
2
3
4
5
6
7
auto p = make_shared<int>(42);
weak_ptr<int> wp(p);

if(shared_ptr<int> np = wp.lock())
{
// ...
}

分配与管理机制

到目前为止,我们对内存的概念有了初步的了解,也掌握了一些基本的语法。接下来我们要讨论如何进行有效的内存管理。设计高效的内存分配器通常会考虑到以下几点:

  • 尽可能减少内存碎片,提高内存利用率
  • 尽可能提高内存的访问局部性
  • 设计在不同场合上适用的内存分配器
  • 考虑到内存对齐

含freelist的分配器

我们首先来考虑一种能够处理任何请求的通用分配器。

一个非常朴素的想法是,对于释放的内存,通过链表将空闲内存链接起来,称为freelist。

分配内存时,先从freelist中查找是否存在满足要求的内存块,如果不存在,再从未分配内存中获取;当我们找到合适的内存块后,分配合适的内存,并将多余的部分放回freelist。

释放内存时,将内存插入到空闲链表,可能的话,合并前后内存块。

其中,有一些细节问题值得考虑:

① 空闲空间应该如何进行管理?

我们知道freelist是用于管理空闲内存的,但是freelist本身的存储也需要占用内存。我们可以按如下两种方式存储freelist:

  • 隐式空闲链表:将空闲链表信息与内存块存储在一起。主要记录大小,已分配位等信息。
  • 显式空闲链表:单独维护一块空间来记录所有空闲块信息。
  • 分离适配(segregated-freelist):将不同大小的内存块放在一起容易造成外部碎片,可以设置多个freelist,并让每个freelist存储不同大小的内存块,申请内存时选择满足条件的最小内存块。
  • 位图:除了freelist之外,还可以考虑用0,1表示对应内存区域是否已分配,称为位图。

② 分配内存优先分配哪块内存?

一般而言,从策略不同来分,有以下几种常见的分配方式:

  • 首次适应(first-fit):找到的第一个满足大小要求的空闲区
  • 最佳适应(best-fit) : 满足大小要求的最小空闲区
  • 循环首次适应(next-fit) :在先前停止搜索的地方开始搜索找到的第一个满足大小要求的空闲区

③ 释放内存后如何放置到空闲链表中?

  • 直接放回链表头部/尾部
  • 按照地址顺序放回

这几种策略本质上都是取舍问题:分配/放回时间复杂度如果低,内存碎片就有可能更多,反之亦然。

buddy分配器

按照一分为二,二分为四的原则,直到分裂出一个满足大小的内存块;合并的时候看buddy是否空闲,如果是就合并。

可以通过位运算直接算出buddy,buddy的buddy,速度较快。但内存碎片较多。

含对齐的分配器

一般而言,对于通用分配器来说,都应当传回对齐的内存块,即根据对齐量,分配比请求多的对齐的内存。

如下,是ue4中计算对齐的方式,它返回和对齐量向上对齐后的值,其中Alignment应为2的幂次。

1
2
3
4
5
6
7
template <typename T>
FORCEINLINE constexpr T Align(T Val, uint64 Alignment)
{
static_assert(TIsIntegral<T>::Value || TIsPointer<T>::Value, "Align expects an integer or pointer type");

return (T)(((uint64)Val + Alignment - 1) & ~(Alignment - 1));
}

其中~(Alignment - 1)代表的是高位掩码,类似于11110000的格式,它将剔除低位。在对Val进行掩码计算时,加上Alignment - 1的做法类似于(x + a) % a,避免Val值过小得到0的结果。

单帧分配器模型

用于分配一些临时的每帧生成的数据。分配的内存仅在当前帧适用,每帧开始时会将上一帧的缓冲数据清除,无需手动释放。

双帧分配器模型

它的基本特点和单帧分配器相近,区别在于第i+1帧适用第i帧分配的内存。它适用于处理非同步的一些数据,避免当前缓冲区被重写(同时读写)

堆栈分配器模型

堆栈分配器,它的优点是实现简单,并且完全避免了内存碎片,如前文所述,函数栈的设计也使用了堆栈分配器的模型。

双端堆栈分配器模型

可以从两端开始分配内存,分别用于处理不同的事务,能够更充分地利用内存。

池分配器模型

池分配器可以分配大量同尺寸的小块内存。它的空闲块也是由freelist管理的,但由于每个块的尺寸一致,它的操作复杂度更低,且也不存在内存碎片的问题。

tcmalloc的内存分配

tcmalloc是一个应用比较广泛的内存分配第三方库。对于大于页结构和小于页结构的内存块申请,tcmalloc分别做不同的处理。

小于页的内存块分配

使用多个内存块定长的freelist进行内存分配,如:8,16,32……,对实际申请的内存向上“取整”。

freelist采用隐式存储的方式。

大于页的内存块分配

可以一次申请多个page,多个page构成一个span。同样的,我们使用多个定长的span链表来管理不同大小的span。

对于不同大小的对象,都有一个对应的内存分配器,称为CentralCache。具体的数据都存储在span内,每个CentralCache维护了对应的spanlist。如果一个span可以存储多个对象,spanlist内部还会维护对应的freelist。

容器的访问局部性

由于操作系统内部存在缓存命中的问题,所以我们需要考虑程序的访问局部性,这个访问局部性实际上有两层意思:

  1. 时间局部性:如果当前数据被访问,那么它将在不久后很可能在此被访问;
  2. 空间局部性:如果当前数据被访问,那么它相邻位置的数据很可能也被访问;

我们来认识一下常用的几种容器的内存布局:

  • 数组/顺序容器:内存连续,访问局部性良好;
  • map:内部是树状结构,为节点存储,无法保证内存连续性,访问局部性较差(flat_map支持顺序存储);
  • 链表:初始状态下,如果我们连续顺序插入节点,此时我们认为内存连续,访问较快;但通过多次插入、删除、交换等操作,链表结构变得散乱,访问局部性较差;

碎片整理机制

内存碎片几乎是不可完全避免的,当一个程序运行一定时间后,将会出现越来越多的内存碎片。一个优化的思路就是在引擎底层支持定期地整理内存碎片。简单来说,碎片整理通过不断的移动操作,使所有的内存块“贴合”在一起。为了处理指针可能失效的问题,可以考虑使用智能指针。由于内存碎片整理会造成卡顿,我们可以考虑将整理操作分摊到多帧完成。

ue4内存管理

自定义内存管理

ue4的内存管理主要是通过FMalloc类型的GMalloc这一结构来完成特定的需求,这是一个虚基类,它定义了malloc,realloc,free等一系列常用的内存管理操作。其中,Malloc的两个参数分别是分配内存的大小和对应的对齐量,默认对齐量为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** The global memory allocator's interface. */
class CORE_API FMalloc :
public FUseSystemMallocForNew,
public FExec
{
public:
virtual void* Malloc( SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT ) = 0;
virtual void* TryMalloc( SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT );
virtual void* Realloc( void* Original, SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT ) = 0;
virtual void* TryRealloc(void* Original, SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT);
virtual void Free( void* Original ) = 0;

// ...
};

FMalloc有许多不同的实现,如FMallocBinned,FMallocBinned2等,可以在HAL文件夹下找到相关的头文件和定义,如下:

内部通过枚举量来确定对应使用的Allocator:

1
2
3
4
5
6
7
8
9
10
11
12
13
/** Which allocator is being used */
enum EMemoryAllocatorToUse
{
Ansi, // Default C allocator
Stomp, // Allocator to check for memory stomping
TBB, // Thread Building Blocks malloc
Jemalloc, // Linux/FreeBSD malloc
Binned, // Older binned malloc
Binned2, // Newer binned malloc
Binned3, // Newer VM-based binned malloc, 64 bit only
Platform, // Custom platform specific allocator
Mimalloc, // mimalloc
};

对于不同平台而言,都有自己对应的平台内存管理类,它们继承自FGenericPlatformMemory,封装了平台相关的内存操作。具体而言,包含FAndroidPlatformMemory,FApplePlatformMemory,FIOSPlatformMemory,FWindowsPlatformMemory等。

通过调用PlatformMemory的BaseAllocator函数,我们取得平台对应的FMalloc类型,基类默认返回默认的C allocator,而不同平台会有自己特殊的实现。

在PlatformMemory的基础上,为了方便调用,ue4又封装了FMemory类,定义通用内存操作,如在申请内存时,会调用FMemory::Malloc,FMemory内部又会继续调用GMalloc->Malloc。如下为节选代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct CORE_API FMemory
{
/** @name Memory functions (wrapper for FPlatformMemory) */

static FORCEINLINE void* Memmove( void* Dest, const void* Src, SIZE_T Count )
{
return FPlatformMemory::Memmove( Dest, Src, Count );
}

static FORCEINLINE int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count )
{
return FPlatformMemory::Memcmp( Buf1, Buf2, Count );
}

// ...
static void* Malloc(SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
static void* Realloc(void* Original, SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
static void Free(void* Original);
static SIZE_T GetAllocSize(void* Original);

// ...
};

为了在调用new/delete能够调用ue4的自定义函数,ue4内部替换了operator new。这一替换是通过IMPLEMENT_MODULE宏引入的:

IMPLEMENT_MODULE通过定义REPLACEMENT_OPERATOR_NEW_AND_DELETE宏实现替换,如下图所示,operator new/delete内实际调用被替换为FMemory的相关函数。

FMallocBinned

我们以FMallocBinned为例介绍ue4中通用内存的分配。

基本介绍

(1) 空闲内存如何管理?

FMallocBinned使用freelist机制管理空闲内存。每个空闲块的信息记录在FFreeMem结构中,显式存储。

(2)不同大小内存如何分配?

FMallocBinned使用内存池机制,内部包含POOL_COUNT(42)个内存池和2个扩展的页内存池;其中每个内存池的信息由FPoolInfo结构体维护,记录了当前FreeMem内存块指针等,而特定大小的所有内存池由FPoolTable维护;内存池内包含了内存块的双向链表。

(3)如何快速根据分配元素大小找到对应的内存池?

为了快速查询当前分配内存大小应该对应使用哪个内存池,有两种办法,一种是二分搜索O(logN),另一种是打表(O1),考虑到可分配内存数量并不大,MallocBinned选择了打表的方式,将信息记录在MemSizeToPoolTable。

(4)如何快速删除已分配内存?

为了能够在释放的时候以O(1)时间找到对应内存池,FMallocBinned维护了PoolHashBucket结构用于跟踪内存分配的记录。它组织为双向链表形式,存储了对应内存块和键值。

内存池

● 多个小对象内存池(内存池大小均为PageSize,但存储的数据量不一样)。数据块大小设定如下:

● 两个额外的页内存池,管理大于一个页的内存池,大小为3PageSize和6PageSize

● 操作系统的内存池

分配策略

分配内存的函数为void* FMallocBinned::Malloc(SIZE_T Size, uint32 Alignment)。其中第一个参数为需要分配的内存的大小,第二个参数为对齐的内存数。

如果用户未指定对齐的内存大小,MallocBinned内部会默认对齐于16字节,如果指定了大于16字节的对齐内存大小,则对齐于用户指定的对齐大小。根据对齐量,计算出最终实际分配的内存大小。

MallocBinned内部对于不同的内存大小有三种不同的处理:

(1) 分配小块内存(0,PAGE_SIZE_LIMIT/2)

根据分配大小从MemSizeToPoolTable中获取对应内存池,并从内存池的当前空闲位置读取一块内存,并移动当前内存指针。如果移动后的内存指针指向的内存块已经使用,则将指针移动到FreeMem链表的下一个元素;如果当前内存池已满,将该内存池移除,并链接到耗尽的内存池。

如果当前内存池已经用尽,下次内存分配时,检测到内存池用尽,会从系统重新申请一块对应大小的内存池。

(2) 分配大块内存 [PAGE_SIZE_LIMIT/2, PAGE_SIZE_LIMIT*3/4]∪(PageSize,PageSize + PAGE_SIZE_LIMIT/2)

需要从额外的页内存池分配,分配方式和(1)一样。

(3) 分配超大内存

从系统内存池中分配。

Allocator

对于ue4中的容器而言,它的模板有两个参数,第一个是元素类型,第二个就是对应的分配器(Allocator):

1
2
3
4
5
template<typename InElementType, typename InAllocator>
class TArray
{
// ...
};

如下图,容器一般都指定了自己默认的分配器:

默认的堆分配器

1
2
3
4
5
template <int IndexSize>
class TSizedHeapAllocator { ... };

// Default Allocator
using FHeapAllocator = TSizedHeapAllocator<32>;

默认情况下,如果我们不指定特定的Allocator,容器会使用大小类型为int32堆分配器,默认由FMemory控制分配(和new一致)

含对齐的分配器

1
2
3
4
5
template<uint32 Alignment = DEFAULT_ALIGNMENT>
class TAlignedHeapAllocator
{
// ...
};

由FMemory控制分配,含对齐。

可扩展大小的分配器

1
2
3
4
5
template <uint32 NumInlineElements, typename SecondaryAllocator = FDefaultAllocator>
class TInlineAllocator
{
//...
};

可扩展大小的分配器存储大小为NumInlineElements的定长数组,当实际存储的元素数量高于NumInlineElements时,会从SecondaryAllocator申请分配内存,默认情况下为堆分配器。

对齐量总为DEFAULT_ALIGNMENT

不可重定位的可扩展大小的分配器

1
2
3
4
5
template <uint32 NumInlineElements>
class TNonRelocatableInlineAllocator
{
// ...
};

在支持第二分配器的基础上,允许第二分配器存储指向内联元素的指针。这意味着Allocator不应做指针重定向的操作。但ue4的Allocator通常依赖于指针重定向,因此该分配器不应用于其它Allocator容器。

固定大小的分配器

1
2
3
4
5
template <uint32 NumInlineElements>
class TFixedAllocator
{
// ...
};

类似于InlineAllocator,会分配固定大小内存,区别在于当内联存储耗尽后,不会提供额外的分配器。

稀疏数组分配器

1
2
3
4
5
6
7
8
template<typename InElementAllocator = FDefaultAllocator,typename InBitArrayAllocator = FDefaultBitArrayAllocator>
class TSparseArrayAllocator
{
public:

typedef InElementAllocator ElementAllocator;
typedef InBitArrayAllocator BitArrayAllocator;
};

稀疏数组本身的定义比较简单,它主要用于稀疏数组(Sparse Array),相关的操作也在对应数组类中完成。稀疏数组支持不连续的下标索引,通过BitArrayAllocator来控制分配哪个位是可用的,能够以O(1)的时间删除元素。

默认使用堆分配。

哈希分配器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<
typename InSparseArrayAllocator = TSparseArrayAllocator<>,
typename InHashAllocator = TInlineAllocator<1,FDefaultAllocator>,
uint32 AverageNumberOfElementsPerHashBucket = DEFAULT_NUMBER_OF_ELEMENTS_PER_HASH_BUCKET,
uint32 BaseNumberOfHashBuckets = DEFAULT_BASE_NUMBER_OF_HASH_BUCKETS,
uint32 MinNumberOfHashedElements = DEFAULT_MIN_NUMBER_OF_HASHED_ELEMENTS
>
class TSetAllocator
{
public:
static FORCEINLINE uint32 GetNumberOfHashBuckets(uint32 NumHashedElements) { //... }

typedef InSparseArrayAllocator SparseArrayAllocator;
typedef InHashAllocator HashAllocator;
};

用于TSet/TMap等结构的哈希分配器,同样的实现比较简单,具体的分配策略在TSet等结构中实现。其中SparseArrayAllocator用于管理Value,HashAllocator用于管理Key。Hash空间不足时,按照2的幂次进行扩展。

默认使用堆分配。

除了使用默认的堆分配器,稀疏数组分配器和哈希分配器都有对应的可扩展大小(InlineAllocator)/固定大小(FixedAllocator)分配版本。

动态内存管理

TSharedPtr

1
2
3
4
5
6
7
8
template< class ObjectType, ESPMode Mode >
class TSharedPtr
{
// ...
private:
ObjectType* Object;
SharedPointerInternals::FSharedReferencer< Mode > SharedReferenceCount;
};

TSharedPtr是ue4提供的类似stl sharedptr的解决方案,但相比起stl,它可由第二个模板参数控制是否线程安全。

如上所示,它基于类内的引用计数实现(SharedReferenceCount),为了确保多个TSharedPtr能够同步当前引用计数的信息,引用计数被设计为指针类型。在拷贝/构造/赋值等操作时,会增加或减少引用计数的值,当引用计数为0时将销毁指针所指对象。

TSharedRef

1
2
3
4
5
6
7
8
template< class ObjectType, ESPMode Mode >
class TSharedRef
{
// ...
private:
ObjectType* Object;
SharedPointerInternals::FSharedReferencer< Mode > SharedReferenceCount;
};

和TSharedPtr类似,但存储的指针不可为空,创建时需同时初始化指针。类似于C++中的引用。

TRefCountPtr

1
2
3
4
5
6
7
template<typename ReferencedType>
class TRefCountPtr
{
// ...
private:
ReferencedType* Reference;
};

TRefCountPtr是基于引用计数的共享指针的另一种实现。和TSharedPtr的差异在于它的引用计数并非智能指针类内维护的,而是基于对象的,相当于TRefCountPtr内部只存储了对应的指针信息(ReferencedType* Reference)。
基于对象的引用计数,即引用计数存储在对象内部,这是通过从FRefCountBase继承引入的。这也就意味着TRefCountPtr引用的对象必须从FRefCountBase继承,它的使用是有局限性的。

但是在如统计资源引用而判断资源是否需要卸载的应用场景中,TRefCountPtr可手动添加/释放引用,使用上更友好。

1
2
3
4
5
6
7
8
9
10
11
12
13
class FRefCountBase
{
public:
// ...
private:
mutable int32 NumRefs = 0;
};
TWeakPtr

template< class ObjectType, ESPMode Mode >
class TWeakPtr
{
};

类似的,TWeakObjectPtr是ue4提供的类似stl weakptr的解决方案,它将不影响引用计数。

TWeakObjectPtr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T, class TWeakObjectPtrBase>
struct TWeakObjectPtr : private TWeakObjectPtrBase
{
// ...
};

struct FWeakObjectPtr
{
// ...

private:
int32 ObjectIndex;
int32 ObjectSerialNumber;
};

特别的,由于UObject有对应的gc机制,TWeakObjectPtr为指向UObject的弱指针,用于查询对象是否有效(是否被回收)

垃圾回收

C++语言本身并没有垃圾回收机制,ue4基于内部的UObject,单独实现了一套GC机制,此处仅做简单介绍。

首先,对于UObject相关对象,为了维持引用(防止被回收),通常使用UProperty()宏,使用容器(如TArray存储),或调用AddToRoot的方法。

ue4的垃圾回收代码实现位于GarbageCollection.cpp中的CollectGarbage函数中。这一函数会在游戏线程中被反复调用,要么在一些情况下手动调用,要么在游戏循环Tick()中满足条件时自动调用。

GC过程中,首先会收集所有不可到达的对象(无引用)。

之后,根据当前情况,会在单帧(无时间限制)或多帧(有时间限制)的时间内,清理相关对象(IncrementalPurgeGarbage)

SIMD

合理的内存布局/对齐有利于SIMD的广泛应用,在编写定义基础类型/底层数学算法库时,我们通常有必要考虑到这一点。

我们可以参考ue4中封装的sse初始化、加法、减法、乘法等操作,其中,__m128类型的变量需程序确保为16字节对齐,它适用于浮点数存储,大部分情况下存储于内存中,计算时会在SSE寄存器中运用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef __m128 VectorRegister;

FORCEINLINE VectorRegister VectorLoad( const void* Ptr )
{
return _mm_loadu_ps((float*)(Ptr));
}

FORCEINLINE VectorRegister VectorAdd( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
return _mm_add_ps(Vec1, Vec2);
}

FORCEINLINE VectorRegister VectorSubtract( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
return _mm_sub_ps(Vec1, Vec2);
}

FORCEINLINE VectorRegister VectorMultiply( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
return _mm_mul_ps(Vec1, Vec2);
}

除了SSE外,ue4还针对Neon/FPU等寄存器封装了统一的接口,这意味调用者可以无需考虑过多硬件的细节。

我们可以在多个数学运算库中看到相关的调用,如球谐向量的相加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** Addition operator. */
friend FORCEINLINE TSHVector operator+(const TSHVector& A,const TSHVector& B)
{
TSHVector Result;
for(int32 BasisIndex = 0;BasisIndex < NumSIMDVectors;BasisIndex++)
{
VectorRegister AddResult = VectorAdd(
VectorLoadAligned(&A.V[BasisIndex * NumComponentsPerSIMDVector]),
VectorLoadAligned(&B.V[BasisIndex * NumComponentsPerSIMDVector])
);

VectorStoreAligned(AddResult, &Result.V[BasisIndex * NumComponentsPerSIMDVector]);
}
return Result;
}

makefile很重要

什么是makefile?或许很多Winodws的程序员都不知道这个东西,因为那些Windows的IDE都为你做了这个工作,但我觉得要作一个好的和professional的程序员,makefile还是要懂。这就好像现在有这么多的HTML的编辑器,但如果你想成为一个专业人士,你还是要了解HTML的标识的含义。特别在Unix下的软件编译,你就不能不自己写makefile了,会不会写makefile,从一个侧面说明了一个人是否具备完成大型工程的能力。因为,makefile关系到了整个工程的编译规则。一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,makefile定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作,因为makefile就像一个Shell脚本一样,其中也可以执行操作系统的命令。makefile带来的好处就是——自动化编译,一旦写好,只需要一个make命令,整个工程完全自动编译,极大的提高了软件开发的效率。make是一个命令工具,是一个解释makefile中指令的命令工具,一般来说,大多数的IDE都有这个命令,比如:Delphi的make,VisualC++的nmake,Linux下GNU的make。可见,makefile都成为了一种在工程方面的编译方法。

现在讲述如何写makefile的文章比较少,这是我想写这篇文章的原因。当然,不同产商的make各不相同,也有不同的语法,但其本质都是在文件依赖性上做文章,这里,我仅对GNU的make进行讲述,我的环境是RedHatLinux8.0,make的版本是3.80。必竟,这个make是应用最为广泛的,也是用得最多的。而且其还是最遵循于IEEE1003.2-1992标准的(POSIX.2)。

在这篇文档中,将以C/C++的源码作为我们基础,所以必然涉及一些关于C/C++的编译的知识,相关于这方面的内容,还请各位查看相关的编译器的文档。这里所默认的编译器是UNIX下的GCC和CC。

关于程序的编译和链接

在此,我想多说关于程序编译的一些规范和方法,一般来说,无论是C、C++、还是pas,首先要把源文件编译成中间代码文件,在Windows下也就是.obj文件,UNIX下是.o文件,即ObjectFile,这个动作叫做编译(compile)。然后再把大量的ObjectFile合成执行文件,这个动作叫作链接(link)。

编译时,编译器需要的是语法的正确,函数与变量的声明的正确。对于后者,通常是你需要告诉编译器头文件的所在位置(头文件中应该只是声明,而定义应该放在C/C++文件中),只要所有的语法正确,编译器就可以编译出中间目标文件。一般来说,每个源文件都应该对应于一个中间目标文件(O文件或是OBJ文件)。

链接时,主要是链接函数和全局变量,所以,我们可以使用这些中间目标文件(O文件或是OBJ文件)来链接我们的应用程序。链接器并不管函数所在的源文件,只管函数的中间目标文件(ObjectFile),在大多数时候,由于源文件太多,编译生成的中间目标文件太多,而在链接时需要明显地指出中间目标文件名,这对于编译很不方便,所以,我们要给中间目标文件打个包,在Windows下这种包叫库文件(LibraryFile),也就是.lib文件,在UNIX下,是ArchiveFile,也就是.a文件。

总结一下,源文件首先会生成中间目标文件,再由中间目标文件生成执行文件。在编译时,编译器只检测程序语法,和函数、变量是否被声明。如果函数未被声明,编译器会给出一个警告,但可以生成ObjectFile。而在链接程序时,链接器会在所有的ObjectFile中找寻函数的实现,如果找不到,那到就会报链接错误码(LinkerError),在VC下,这种错误一般是:Link2001错误,意思说是说,链接器未能找到函数的实现。你需要指定函数的ObjectFile.

好,言归正传,GNU的make有许多的内容,闲言少叙,还是让我们开始吧。

Makefile介绍

make命令执行时,需要一个Makefile文件,以告诉make命令需要怎么样的去编译和链接程序。

首先,我们用一个示例来说明Makefile的书写规则。以便给大家一个感性认识。这个示例来源于GNU的make使用手册,在这个示例中,我们的工程有8个C文件,和3个头文件,我们要写一个Makefile来告诉make命令如何编译和链接这几个文件。我们的规则是:

1)如果这个工程没有编译过,那么我们的所有C文件都要编译并被链接。

2)如果这个工程的某几个C文件被修改,那么我们只编译被修改的C文件,并链接目标程序。

3)如果这个工程的头文件被改变了,那么我们需要编译引用了这几个头文件的C文件,并链接目标程序。

只要我们的Makefile写得够好,所有的这一切,我们只用一个make命令就可以完成,make命令会自动智能地根据当前的文件修改的情况来确定哪些文件需要重编译,从而自己编译所需要的文件和链接目标程序。

Makefile的规则

在讲述这个Makefile之前,还是让我们先来粗略地看一看Makefile的规则。

1
2
3
4
5
6
7
target...: prerequisites...

command

...

...

target也就是一个目标文件,可以是ObjectFile,也可以是执行文件。还可以是一个标签(Label),对于标签这种特性,在后续的伪目标章节中会有叙述。

prerequisites就是,要生成那个target所需要的文件或是目标。

command也就是make需要执行的命令。(任意的Shell命令)

这是一个文件的依赖关系,也就是说,target这一个或多个的目标文件依赖于prerequisites中的文件,其生成规则定义在command中。说白一点就是说,prerequisites中如果有一个以上的文件比target文件要新的话,command所定义的命令就会被执行。这就是Makefile的规则。也就是Makefile中最核心的内容。

说到底,Makefile的东西就是这样一点,好像我的这篇文档也该结束了。呵呵。还不尽然,这是Makefile的主线和核心,但要写好一个Makefile还不够,我会以后面一点一点地结合我的工作经验给你慢慢到来。内容还多着呢。:)

一个示例

正如前面所说的,如果一个工程有3个头文件,和8个C文件,我们为了完成前面所述的那三个规则,我们的Makefile应该是下面的这个样子的。

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
edit: main.o kbd.o command.o display.o insert.o search.o files.o utils.o

cc -o edit main.o kbd.o command.o display.o insert.o search.o files.o utils.o

main.o: main.c defs.h

cc –c main.c

kbd.o: kbd.c defs.h command.h

cc –c kbd.c

command.o: command.c defs.h command.h

cc –c command.c

display.o: display.c defs.h buffer.h

cc –c display.c

insert.o: insert.c defs.h buffer.h

cc -c insert.c

search.o: search.c defs.h buffer.h

cc -c search.c

files.o: files.c defs.h buffer.h command.h

cc -c files.c

utils.o: utils.c defs.h

cc –c utils.c

clean:

rm edit main.o kbd.o command.odisplay.o insert.o search.o files.outils.o

反斜杠(\)是换行符的意思。这样比较便于Makefile的易读。我们可以把这个内容保存在文件为Makefilemakefile的文件中,然后在该目录下直接输入命令make就可以生成执行文件edit。如果要删除执行文件和所有的中间目标文件,那么,只要简单地执行一下make clean就可以了。

在这个makefile中,目标文件(target)包含:执行文件edit和中间目标文件(*.o),依赖文件(prerequisites)就是冒号后面的那些.c文件和.h文件。每一个.o文件都有一组依赖文件,而这些.o文件又是执行文件edit的依赖文件。依赖关系的实质上就是说明了目标文件是由哪些文件生成的,换言之,目标文件是哪些文件更新的。

在定义好依赖关系后,后续的那一行定义了如何生成目标文件的操作系统命令,一定要以一个Tab键作为开头。记住,make并不管命令是怎么工作的,他只管执行所定义的命令。make会比较targets文件和prerequisites文件的修改日期,如果prerequisites文件的日期要比targets文件的日期要新,或者target不存在的话,那么,make就会执行后续定义的命令。

这里要说明一点的是,clean不是一个文件,它只不过是一个动作名字,有点像C语言中的lable一样,其冒号后什么也没有,那么,make就不会自动去找文件的依赖性,也就不会自动执行其后所定义的命令。要执行其后的命令,就要在make命令后明显得指出这个lable的名字。这样的方法非常有用,我们可以在一个makefile中定义不用的编译或是和编译无关的命令,比如程序的打包,程序的备份,等等。

make是如何工作的

在默认的方式下,也就是我们只输入make命令。那么,

make会在当前目录下找名字叫Makefilemakefile的文件。

如果找到,它会找文件中的第一个目标文件(target),在上面的例子中,他会找到edit这个文件,并把这个文件作为最终的目标文件。

如果edit文件不存在,或是edit所依赖的后面的.o文件的文件修改时间要比edit这个文件新,那么,他就会执行后面所定义的命令来生成edit这个文件。

如果edit所依赖的.o文件也存在,那么make会在当前文件中找目标为.o文件的依赖性,如果找到则再根据那一个规则生成.o文件。(这有点像一个堆栈的过程)

当然,你的C文件和H文件是存在的啦,于是make会生成.o文件,然后再用.o文件声明make的终极任务,也就是执行文件edit了。

这就是整个make的依赖性,make会一层又一层地去找文件的依赖关系,直到最终编译出第一个目标文件。在找寻的过程中,如果出现错误,比如最后被依赖的文件找不到,那么make就会直接退出,并报错,而对于所定义的命令的错误,或是编译不成功,make根本不理。make只管文件的依赖性,即,如果在我找了依赖关系之后,冒号后面的文件还是不在,那么对不起,我就不工作啦。

通过上述分析,我们知道,像clean这种,没有被第一个目标文件直接或间接关联,那么它后面所定义的命令将不会被自动执行,不过,我们可以显示要make执行。即命令——make clean,以此来清除所有的目标文件,以便重编译。

于是在我们编程中,如果这个工程已被编译过了,当我们修改了其中一个源文件,比如file.c,那么根据我们的依赖性,我们的目标file.o会被重编译(也就是在这个依性关系后面所定义的命令),于是file.o的文件也是最新的啦,于是file.o的文件修改时间要比edit要新,所以edit也会被重新链接了(详见edit目标文件后定义的命令)。

而如果我们改变了command.h,那么,kdb.o、command.o和files.o都会被重编译,并且,edit会被重链接。

makefile中使用变量

在上面的例子中,先让我们看看edit的规则:

1
2
3
edit:main.o kbd.o command.odisplay.o insert.o search.o files.outils.o

cc –o edit main.o kbd.ocommand.o display.o insert.o search.o files.outils.o

我们可以看到[.o]文件的字符串被重复了两次,如果我们的工程需要加入一个新的[.o]文件,那么我们需要在两个地方加(应该是三个地方,还有一个地方在clean中)。当然,我们的makefile并不复杂,所以在两个地方加也不累,但如果makefile变得复杂,那么我们就有可能会忘掉一个需要加入的地方,而导致编译失败。所以,为了makefile的易维护,在makefile中我们可以使用变量。makefile的变量也就是一个字符串,理解成C语言中的宏可能会更好。

比如,我们声明一个变量,叫objects,OBJECTS,objs,OBJS,obj,或是OBJ,反正不管什么啦,只要能够表示obj文件就行了。我们在makefile一开始就这样定义:

1
2
3
objects=main.o kbd.o command.odisplay.o\

insert.o search.o files.outils.o

于是,我们就可以很方便地在我们的makefile中以$(objects)的方式来使用这个变量了,于是我们的改良版makefile就变成下面这个样子:
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
objects = main.o kbd.o command.o display.o insert.osearch.o files.o utils.o

edit: $(objects)
cc–o edit$(objects)

main.o: main.cdefs.h
cc –c main.c

kbd.o: kbd.c defs.h command.h
cc –c kbd.c

command.o: command.c defs.h command.h
cc –c command.c

display.o: display.c defs.h buffer.h
cc -c display.c

insert.o: insert.c defs.h buffer.h
cc–c insert.c

search.o: search.c defs.h buffer.h
cc–c search.c

files.o: files.c defs.h buffer.h command.h
cc–c files.c

utils.o: utils.c defs.h
cc– c utils.c

clean:
rm edit$(objects)

于是如果有新的.o文件加入,我们只需简单地修改一下objects变量就可以了。

关于变量更多的话题,我会在后续给你一一道来。

让make自动推导

GNU的make很强大,它可以自动推导文件以及文件依赖关系后面的命令,于是我们就没必要去在每一个[.o]文件后都写上类似的命令,因为,我们的make会自动识别,并自己推导命令。

只要make看到一个[.o]文件,它就会自动的把[.c]文件加在依赖关系中,如果make找到一个whatever.o,那么whatever.c,就会是whatever.o的依赖文件。并且cc -c whatever.c也会被推导出来,于是,我们的makefile再也不用写得这么复杂。我们的是新的makefile又出炉了。

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
objects = main.o kbd.o command.o display.o insert.o search.o files.o utils.o

edit : $(objects)

cc-o edit $(objects)

main.o : defs.h

kbd.o : defs.hcommand.h

command.o : defs.hcommand.h

display.o : defs.hbuffer.h

insert.o : defs.hbuffer.h

search.o : defs.hbuffer.h

files.o : defs.hbuffer.h command.h

utils.o : defs.h

.PHONY : clean

clean :
rm edit $(objects)

这种方法,也就是make的隐晦规则。上面文件内容中,.PHONY表示,clean是个伪目标文件。

关于更为详细的隐晦规则伪目标文件,我会在后续给你一一道来。

另类风格的makefile

即然我们的make可以自动推导命令,那么我看到那堆[.o]和[.h]的依赖就有点不爽,那么多的重复的[.h],能不能把其收拢起来,好吧,没有问题,这个对于make来说很容易,谁叫它提供了自动推导命令和文件的功能呢?来看看最新风格的makefile吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
objects = main.o kbd.o command.o display.o insert.osearch.o files.o utils.o

edit : $(objects)

cc -o edit $(objects)

$(objects) : defs.h

kbd.o command.o files.o : command.h

display.o insert.osearch.o files.o : buffer.h

.PHONY : clean

clean :
rm edit $(objects)

这种风格,让我们的makefile变得很简单,但我们的文件依赖关系就显得有点凌乱了。鱼和熊掌不可兼得。还看你的喜好了。我是不喜欢这种风格的,一是文件的依赖关系看不清楚,二是如果文件一多,要加入几个新的.o文件,那就理不清楚了。

清空目标文件的规则

每个Makefile中都应该写一个清空目标文件(.o和执行文件)的规则,这不仅便于重编译,也很利于保持文件的清洁。这是一个修养(呵呵,还记得我的《编程修养》吗)。一般的风格都是:

1
2
3
clean:

rm edit $(objects)

更为稳健的做法是:
1
2
3
4
5
.PHONY: clean

clean:

-rm edit $(objects)

前面说过,.PHONY意思表示clean是一个伪目标,。而在rm命令前面加了一个小减号的意思就是,也许某些文件出现问题,但不要管,继续做后面的事。当然,clean的规则不要放在文件的开头,不然,这就会变成make的默认目标,相信谁也不愿意这样。不成文的规矩是——clean从来都是放在文件的最后

上面就是一个makefile的概貌,也是makefile的基础,下面还有很多makefile的相关细节,准备好了吗?准备好了就来。

Makefile总述

Makefile里有什么?

Makefile里主要包含了五个东西:显式规则、隐晦规则、变量定义、文件指示和注释。

  • 显式规则。显式规则说明了,如何生成一个或多的的目标文件。这是由Makefile的书写者明显指出,要生成的文件,文件的依赖文件,生成的命令。
  • 隐晦规则。由于我们的make有自动推导的功能,所以隐晦的规则可以让我们比较粗糙地简略地书写Makefile,这是由make所支持的。
  • 变量的定义。在Makefile中我们要定义一系列的变量,变量一般都是字符串,这个有点你C语言中的宏,当Makefile被执行时,其中的变量都会被扩展到相应的引用位置上。
  • 文件指示。其包括了三个部分,一个是在一个Makefile中引用另一个Makefile,就像C语言中的include一样;另一个是指根据某些情况指定Makefile中的有效部分,就像C语言中的预编译#if一样;还有就是定义一个多行的命令。有关这一部分的内容,我会在后续的部分中讲述。
  • 注释。Makefile中只有行注释,和UNIX的Shell脚本一样,其注释是用#字符,这个就像C/C++中的//一样。如果你要在你的Makefile中使用#字符,可以用反斜框进行转义,如:\#
  • 最后,还值得一提的是,在Makefile中的命令,必须要以[Tab]键开始。

Makefile的文件名

默认的情况下,make命令会在当前目录下按顺序找寻文件名为GNUmakefilemakefileMakefile的文件,找到了解释这个文件。在这三个文件名中,最好使用Makefile这个文件名,因为,这个文件名第一个字符为大写,这样有一种显目的感觉。最好不要用GNUmakefile,这个文件是GNU的make识别的。有另外一些make只对全小写的makefile文件名敏感,但是基本上来说,大多数的make都支持makefileMakefile这两种默认文件名。

当然,你可以使用别的文件名来书写Makefile,比如:Make.LinuxMake.SolarisMake.AIX等,如果要指定特定的Makefile,你可以使用make的-f--file参数,如:make –f Make.Linux或make –file Make.AIX。

引用其它的Makefile

在Makefile使用include关键字可以把别的Makefile包含进来,这很像C语言的#include,被包含的文件会原模原样的放在当前文件的包含位置。include的语法是:

include filename可以是当前操作系统Shell的文件模式(可以保含路径和通配符)

在include前面可以有一些空字符,但是绝不能是[Tab]键开始。include和可以用一个或多个空格隔开。举个例子,你有这样几个Makefile:a.mk、b.mk、c.mk,还有一个文件叫foo.make,以及一个变量$(bar),其包含了e.mk和f.mk,那么,下面的语句:

1
include foo.make *.mk$(bar)

等价于:
1
include foo.make a.mkb.mk c.mk e.mk f.mk

make命令开始时,会把找寻include所指出的其它Makefile,并把其内容安置在当前的位置。就好像C/C++的#include指令一样。如果文件都没有指定绝对路径或是相对路径的话,make会在当前目录下首先寻找,如果当前目录下没有找到,那么,make还会在下面的几个目录下找:

  1. 如果make执行时,有-I--include-dir参数,那么make就会在这个参数所指定的目录下去寻找。
  2. 如果目录/include(一般是:/usr/local/bin或/usr/include)存在的话,make也会去找。

如果有文件没有找到的话,make会生成一条警告信息,但不会马上出现致命错误。它会继续载入其它的文件,一旦完成makefile的读取,make会再重试这些没有找到,或是不能读取的文件,如果还是不行,make才会出现一条致命信息。如果你想让make不理那些无法读取的文件,而继续执行,你可以在include前加一个减号-。如:

1
-include<filename>

其表示,无论include过程中出现什么错误,都不要报错继续执行。和其它版本make兼容的相关命令是sinclude,其作用和这一个是一样的。

环境变量MAKEFILES

如果你的当前环境中定义了环境变量MAKEFILES,那么,make会把这个变量中的值做一个类似于include的动作。这个变量中的值是其它的Makefile,用空格分隔。只是,它和include不同的是,从这个环境变中引入的Makefile的目标不会起作用,如果环境变量中定义的文件发现错误,make也会不理。

但是在这里我还是建议不要使用这个环境变量,因为只要这个变量一被定义,那么当你使用make时,所有的Makefile都会受到它的影响,这绝不是你想看到的。在这里提这个事,只是为了告诉大家,也许有时候你的Makefile出现了怪事,那么你可以看看当前环境中有没有定义这个变量。

make的工作方式

GNU的make工作时的执行步骤入下:(想来其它的make也是类似)

  1. 读入所有的Makefile。
  2. 读入被include的其它Makefile。
  3. 初始化文件中的变量。
  4. 推导隐晦规则,并分析所有规则。
  5. 为所有的目标文件创建依赖关系链。
  6. 根据依赖关系,决定哪些目标要重新生成。
  7. 执行生成命令。

1-5步为第一个阶段,6-7为第二个阶段。第一个阶段中,如果定义的变量被使用了,那么,make会把其展开在使用的位置。但make并不会完全马上展开,make使用的是拖延战术,如果变量出现在依赖关系的规则中,那么仅当这条依赖被决定要使用了,变量才会在其内部展开。

当然,这个工作方式你不一定要清楚,但是知道这个方式你也会对make更为熟悉。有了这个基础,后续部分也就容易看懂了。

Makefile书写规则

规则包含两个部分,一个是依赖关系,一个是生成目标的方法。

在Makefile中,规则的顺序是很重要的,因为,Makefile中只应该有一个最终目标,其它的目标都是被这个目标所连带出来的,所以一定要让make知道你的最终目标是什么。一般来说,定义在Makefile中的目标可能会有很多,但是第一条规则中的目标将被确立为最终的目标。如果第一条规则中的目标有很多个,那么,第一个目标会成为最终的目标。make所完成的也就是这个目标。

好了,还是让我们来看一看如何书写规则。

规则举例

1
2
foo.o: foo.c defs.h #foo模块
cc –c –g foo.c

看到这个例子,各位应该不是很陌生了,前面也已说过,foo.o是我们的目标,foo.c和defs.h是目标所依赖的源文件,而只有一个命令cc –c –g foo.c(以Tab键开头)。这个规则告诉我们两件事:

  1. 文件的依赖关系,foo.o依赖于foo.c和defs.h的文件,如果foo.c和defs.h的文件日期要比foo.o文件日期要新,或是foo.o不存在,那么依赖关系发生。
  2. 如果生成(或更新)foo.o文件。也就是那个cc命令,其说明了,如何生成foo.o这个文件。(当然foo.c文件include了defs.h文件)

规则的语法

1
2
3
targets:prerequisites
command
...

或是这样:

1
2
3
targets:prerequisites;command
command
...

targets是文件名,以空格分开,可以使用通配符。一般来说,我们的目标基本上是一个文件,但也有可能是多个文件。

command是命令行,如果其不与target:prerequisites在一行,那么,必须以[Tab键]开头,如果和prerequisites在一行,那么可以用分号做为分隔。(见上)

prerequisites也就是目标所依赖的文件(或依赖目标)。如果其中的某个文件要比目标文件要新,那么,目标就被认为是过时的,被认为是需要重生成的。这个在前面已经讲过了。

如果命令太长,你可以使用反斜框(‘\’)作为换行符。make对一行上有多少个字符没有限制。规则告诉make两件事,文件的依赖关系和如何生成目标文件。

一般来说,make会以UNIX的标准Shell,也就是/bin/sh来执行命令。

在规则中使用通配符

如果我们想定义一系列比较类似的文件,我们很自然地就想起使用通配符。make支持三各通配符:*?[...]。这是和Unix的B-Shell是相同的。

~: 波浪号(~)字符在文件名中也有比较特殊的用途。如果是~/test,这就表示当前用户的$HOME目录下的test目录。而~hchen/test则表示用户hchen的宿主目录下的test目录。(这些都是Unix下的小知识了,make也支持)而在Windows或是MS-DOS下,用户没有宿主目录,那么波浪号所指的目录则根据环境变量HOME而定。

*通配符代替了你一系列的文件,如*.c表示所以后缀为c的文件。一个需要我们注意的是,如果我们的文件名中有通配符,如:*,那么可以用转义字符\,如\*来表示真实的*字符,而不是任意长度的字符串。

好吧,还是先来看几个例子吧:

1
2
clean:
rm –f *.o

上面这个例子我不不多说了,这是操作系统Shell所支持的通配符。这是在命令中的通配符。
1
2
3
4
5
print: *.c

lpr –p $?

touch print

上面这个例子说明了通配符也可以在我们的规则中,目标print依赖于所有的[.c]文件。其中的$?是一个自动化变量,我会在后面给你讲述。
1
objects=*.o

上面这个例子,表示了,通符同样可以用在变量中。并不是说[*.o]会展开,不!objects的值就是*.o。Makefile中的变量其实就是C/C++中的宏。如果你要让通配符在变量中展开,也就是让objects的值是所有[.o]的文件名的集合,那么,你可以这样:
1
objects:=$(wildcard*.o)

这种用法由关键字wildcard指出,关于Makefile的关键字,我们将在后面讨论。

文件搜寻

在一些大的工程中,有大量的源文件,我们通常的做法是把这许多的源文件分类,并存放在不同的目录中。所以,当make需要去找寻文件的依赖关系时,你可以在文件前加上路径,但最好的方法是把一个路径告诉make,让make在自动去找。

Makefile文件中的特殊变量VPATH就是完成这个功能的,如果没有指明这个变量,make只会在当前的目录中去找寻依赖文件和目标文件。如果定义了这个变量,那么,make就会在当当前目录找不到的情况下,到所指定的目录中去找寻文件了。

1
VPATH=src:../headers

上面的的定义指定两个目录,src../headers,make会按照这个顺序进行搜索。目录由冒号分隔。(当然,当前目录永远是最高优先搜索的地方)

另一个设置文件搜索路径的方法是使用make的vpath关键字(注意,它是全小写的),这不是变量,这是一个make的关键字,这和上面提到的那个VPATH变量很类似,但是它更为灵活。它可以指定不同的文件在不同的搜索目录中。这是一个很灵活的功能。它的使用方法有三种:

  1. vpath<pattern><directories>为符合模式<pattern>的文件指定搜索目录<directories>
  2. vpath<pattern>清除符合模式<pattern>的文件的搜索目录。
  3. vpath清除所有已被设置好了的文件搜索目录。

vapth使用方法中的<pattern>需要包含%字符。%的意思是匹配零或若干字符,例如,%.h表示所有以.h结尾的文件。<pattern>指定了要搜索的文件集,而<directories>则指定了的文件集的搜索的目录。例如:

1
vpath %.h ../headers

该语句表示,要求make在../headers目录下搜索所有以.h结尾的文件。(如果某文件在当前目录没有找到的话)

我们可以连续地使用vpath语句,以指定不同搜索策略。如果连续的vpath语句中出现了相同的<pattern>,或是被重复了的<pattern>,那么,make会按照vpath语句的先后顺序来执行搜索。如:

1
2
3
vpath %.c foo
vpath % blish
vpath %.c bar

其表示.c结尾的文件,先在foo目录,然后是blish,最后是bar目录。
1
2
vpath %.c foo:bar
vpath % blish

而上面的语句则表示.c结尾的文件,先在foo目录,然后是bar目录,最后才是blish目录。

伪目标

最早先的一个例子中,我们提到过一个clean的目标,这是一个伪目标

1
2
clean:
rm *.o temp

正像我们前面例子中的clean一样,即然我们生成了许多文件编译文件,我们也应该提供一个清除它们的目标以备完整地重编译而用。(以make clean来使用该目标)

因为,我们并不生成clean这个文件。伪目标并不是一个文件,只是一个标签,由于伪目标不是文件,所以make无法生成它的依赖关系和决定它是否要执行。我们只有通过显示地指明这个目标才能让其生效。当然,伪目标的取名不能和文件名重名,不然其就失去了伪目标的意义了。

当然,为了避免和文件重名的这种情况,我们可以使用一个特殊的标记.PHONY来显示地指明一个目标是伪目标,向make说明,不管是否有这个文件,这个目标就是伪目标

1
.PHONY:clean

只要有这个声明,不管是否有clean文件,要运行clean这个目标,只有make clean这样。于是整个过程可以这样写:
1
2
3
4
5
.PHONY:clean

clean:

rm *.o temp

伪目标一般没有依赖的文件。但是,我们也可以为伪目标指定所依赖的文件。伪目标同样可以作为默认目标,只要将其放在第一个。一个示例就是,如果你的Makefile需要一口气生成若干个可执行文件,但你只想简单地敲一个make完事,并且,所有的目标文件都写在一个Makefile中,那么你可以使用伪目标这个特性:
1
2
3
4
5
6
7
8
9
10
11
12
all:prog1 prog2 prog3

.PHONY:all

prog1:prog1.o utils.o
cc –o prog1 prog1.o utils.o

prog2:prog2.o
cc –o prog2 prog2.o

prog3:prog3.o sort.o utils.o
cc -o prog3 prog3.o sort.o utils.o

我们知道,Makefile中的第一个目标会被作为其默认目标。我们声明了一个all的伪目标,其依赖于其它三个目标。由于伪目标的特性是,总是被执行的,所以其依赖的那三个目标就总是不如all这个目标新。所以,其它三个目标的规则总是会被决议。也就达到了我们一口气生成多个目标的目的。.PHONY:all声明了all这个目标为伪目标

随便提一句,从上面的例子我们可以看出,目标也可以成为依赖。所以,伪目标同样也可成为依赖。看下面的例子:

1
2
3
4
5
6
7
8
9
10
.PHONY:cleanall cleanobj cleandiff

cleanall:cleanobj cleandiff
rm program

cleanobj:
rm *.o

cleandiff:
rm *.diff

make clean将清除所有要被清除的文件。cleanobjcleandiff这两个伪目标有点像子程序的意思。我们可以输入make cleanallmake cleanobjmake cleandiff命令来达到清除不同种类文件的目的

多目标

Makefile的规则中的目标可以不止一个,其支持多目标,有可能我们的多个目标同时依赖于一个文件,并且其生成的命令大体类似。于是我们就能把其合并起来。当然,多个目标的生成规则的执行命令是同一个,这可能会可我们带来麻烦,不过好在我们的可以使用一个自动化变量$@(关于自动化变量,将在后面讲述),这个变量表示着目前规则中所有的目标的集合,这样说可能很抽象,还是看一个例子吧。

1
2
3
bigoutput littleoutput:text.g

generate text.g -$(substoutput,,$@)>$@

上述规则等价于:
1
2
3
4
5
6
7
bigoutput:text.g

generate text.g -big >bigoutput

littleoutput:text.g

generate text.g -little> littleoutput

其中,-$(subst output,,$@)中的$表示执行一个Makefile的函数,函数名为subst,后面的为参数。关于函数,将在后面讲述。这里的这个函数是截取字符串的意思,$@表示目标的集合,就像一个数组,$@依次取出目标,并执于命令。

静态模式

静态模式可以更加容易地定义多目标的规则,可以让我们的规则变得更加的有弹性和灵活。我们还是先来看一下语法:

1
2
3
4
5
<targets...>:<target-pattern>:<prereq-patterns...>

<commands>

...

targets定义了一系列的目标文件,可以有通配符。是目标的一个集合。

target-parrtern是指明了targets的模式,也就是的目标集模式。

prereq-parrterns是目标的依赖模式,它对target-parrtern形成的模式再进行一次依赖目标的定义。

这样描述这三个东西,可能还是没有说清楚,还是举个例子来说明一下吧。如果我们的<target-parrtern>定义成%.o,意思是我们的集合中都是以.o结尾的,而如果我们的<prereq-parrterns>定义成%.c,意思是对<target-parrtern>所形成的目标集进行二次定义,其计算方法是,取<target-parrtern>模式中的%(也就是去掉了[.o]这个结尾),并为其加上[.c]这个结尾,形成的新集合。

所以,我们的目标模式或是依赖模式中都应该有%这个字符,如果你的文件名中有%那么你可以使用反斜杠\进行转义,来标明真实的%字符。

看一个例子:

1
2
3
4
5
6
7
objects=foo.o bar.o

all:$(objects)

$(objects):%.o:%.c

$(CC)–c $(CFLAGS) $< -o $@

上面的例子中,指明了我们的目标从$object中获取,%.o表明要所有以.o结尾的目标,也就是foo.o bar.o,也就是变量$object集合的模式,而依赖模式%.c则取模式%.o%,也就是foobar,并为其加下.c的后缀,于是,我们的依赖目标就是foo.c bar.c。而命令中的$<$@则是自动化变量,$<表示所有的依赖目标集(也就是foo.c bar.c),$@表示目标集(也就是foo.o bar.o)。于是,上面的规则展开后等价于下面的规则:
1
2
3
4
5
6
7
foo.o:foo.c

$(CC)–c $(CFLAGS) foo.c –o foo.o

bar.o:bar.c

$(CC)–c $(CFLAGS) bar.c –o bar.o

试想,如果我们的%.o有几百个,那种我们只要用这种很简单的“静态模式规则”就可以写完一堆规则,实在是太有效率了。“静态模式规则”的用法很灵活,如果用得好,那会一个很强大的功能。再看一个例子:
1
2
3
4
5
6
7
8
9
files=foo.elc bar.o lose.o

$(filter %.o,$(files)):%.o:%.c

$(CC)-c $(CFLAGS) $< -o $@

$(filter %.elc,$(files)):%.elc:%.el

emacs-f batch-byte-compile $<

$(filter %.o,$(files))表示调用Makefile的filter函数,过滤$filter集,只要其中模式为%.o的内容。其的它内容,我就不用多说了吧。这个例字展示了Makefile中更大的弹性。

自动生成依赖性

在Makefile中,我们的依赖关系可能会需要包含一系列的头文件,比如,如果我们的main.c中有一句#include"defs.h",那么我们的依赖关系应该是:

1
main.o:main.c defs.h

但是,如果是一个比较大型的工程,你必需清楚哪些C文件包含了哪些头文件,并且,你在加入或删除头文件时,也需要小心地修改Makefile,这是一个很没有维护性的工作。为了避免这种繁重而又容易出错的事情,我们可以使用C/C++编译的一个功能。大多数的C/C++编译器都支持一个-M的选项,即自动找寻源文件中包含的头文件,并生成一个依赖关系。例如,如果我们执行下面的命令:
1
cc -M main.c

其输出是:
1
main.o:main.c defs.h

于是由编译器自动生成的依赖关系,这样一来,你就不必再手动书写若干文件的依赖关系,而由编译器自动生成了。需要提醒一句的是,如果你使用GNU的C/C++编译器,你得用-MM参数,不然,-M参数会把一些标准库的头文件也包含进来。

gcc -M main.c的输出是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
main.o:main.cdefs.h/usr/include/stdio.h/usr/include/features.h\

/usr/include/sys/cdefs.h/usr/include/gnu/stubs.h\

/usr/lib/gcc-lib/i486-suse-linux/2.95.3/include/stddef.h\

/usr/include/bits/types.h/usr/include/bits/pthreadtypes.h\

/usr/include/bits/sched.h/usr/include/libio.h\

/usr/include/_G_config.h/usr/include/wchar.h\

/usr/include/bits/wchar.h/usr/include/gconv.h\

/usr/lib/gcc-lib/i486-suse-linux/2.95.3/include/stdarg.h\

/usr/include/bits/stdio_lim.h

gcc -MM main.c的输出则是:
1
main.o:main.c defs.h

那么,编译器的这个功能如何与我们的Makefile联系在一起呢。因为这样一来,我们的Makefile也要根据这些源文件重新生成,让Makefile自已依赖于源文件?这个功能并不现实,不过我们可以有其它手段来迂回地实现这一功能。GNU组织建议把编译器为每一个源文件的自动生成的依赖关系放到一个文件中,为每一个name.c的文件都生成一个name.d的Makefile文件,[.d]文件中就存放对应[.c]文件的依赖关系。

于是,我们可以写出[.c]文件和[.d]文件的依赖关系,并让make自动更新或自成[.d]文件,并把其包含在我们的主Makefile中,这样,我们就可以自动化地生成每个文件的依赖关系了。

这里,我们给出了一个模式规则来产生[.d]文件:

1
2
3
4
5
6
7
8
9
%.d: %.c

@set -e;rm -f $@;\

$(CC) -M $(CPPFLAGS) $<> $@.;\

sed 's,$*\.o[:]*,\1.o $@:,g'< $@. > $@;\

rm -f $@.

这个规则的意思是,所有的[.d]文件依赖于[.c]文件,rm -f $@的意思是删除所有的目标,也就是[.d]文件,第二行的意思是,为每个依赖文件$<,也就是[.c]文件生成依赖文件,$@表示模式%.d文件,如果有一个C文件是name.c,那么%就是name.意为一个随机编号,第二行生成的文件有可能是name.d.12345,第三行使用sed命令做了一个替换,关于sed命令的用法请参看相关的使用文档。第四行就是删除临时文件。

总而言之,这个模式要做的事就是在编译器生成的依赖关系中加入[.d]文件的依赖,即把依赖关系:

1
main.o:main.c defs.h

转成:
1
main.o main.d:main.c defs.h

于是,我们的[.d]文件也会自动更新了,并会自动生成了,当然,你还可以在这个[.d]文件中加入的不只是依赖关系,包括生成的命令也可一并加入,让每个[.d]文件都包含一个完赖的规则。一旦我们完成这个工作,接下来,我们就要把这些自动生成的规则放进我们的主Makefile中。我们可以使用Makefile的include命令,来引入别的Makefile文件(前面讲过),例如:
1
2
3
sources = foo.c bar.c

include $(sources:.c=.d)

上述语句中的$(sources:.c=.d)中的.c=.d的意思是做一个替换,把变量$(sources)所有[.c]的字串都替换成[.d],关于这个替换的内容,在后面我会有更为详细的讲述。当然,你得注意次序,因为include是按次来载入文件,最先载入的[.d]文件中的目标会成为默认目标

Makefile书写命令

每条规则中的命令和操作系统Shell的命令行是一致的。make会一按顺序一条一条的执行命令,每条命令的开头必须以[Tab]键开头,除非,命令是紧跟在依赖规则后面的分号后的。在命令行之间中的空格或是空行会被忽略,但是如果该空格或空行是以Tab键开头的,那么make会认为其是一个空命令。

我们在UNIX下可能会使用不同的Shell,但是make的命令默认是被/bin/sh——UNIX的标准Shell解释执行的。除非你特别指定一个其它的Shell。Makefile中,#是注释符,很像C/C++中的//,其后的本行字符都被注释。

显示命令

通常,make会把其要执行的命令行在命令执行前输出到屏幕上。当我们用@字符在命令行前,那么,这个命令将不被make显示出来,最具代表性的例子是,我们用这个功能来像屏幕显示一些信息。如:

@echo正在编译XXX模块……

当make执行时,会输出正在编译XXX模块......字串,但不会输出命令,如果没有@,那么,make将输出:

echo正在编译XXX模块……

正在编译XXX模块……

如果make执行时,带入make参数-n--just-print,那么其只是显示命令,但不会执行命令,这个功能很有利于我们调试我们的Makefile,看看我们书写的命令是执行起来是什么样子的或是什么顺序的。

而make参数-s--slient则是全面禁止命令的显示。

命令执行

当依赖目标新于目标时,也就是当规则的目标需要被更新时,make会一条一条的执行其后的命令。需要注意的是,如果你要让上一条命令的结果应用在下一条命令时,你应该使用分号分隔这两条命令。比如你的第一条命令是cd命令,你希望第二条命令得在cd之后的基础上运行,那么你就不能把这两条命令写在两行上,而应该把这两条命令写在一行上,用分号分隔。如:

示例一:

1
2
3
4
5
exec:

cd/home/hchen

pwd

示例二:
1
2
3
exec:

cd/home/hchen;pwd

当我们执行make exec时,第一个例子中的cd没有作用,pwd会打印出当前的Makefile目录,而第二个例子中,cd就起作用了,pwd会打印出/home/hchen

make一般是使用环境变量SHELL中所定义的系统Shell来执行命令,默认情况下使用UNIX的标准Shell——/bin/sh来执行命令。但在MS-DOS下有点特殊,因为MS-DOS下没有SHELL环境变量,当然你也可以指定。如果你指定了UNIX风格的目录形式,首先,make会在SHELL所指定的路径中找寻命令解释器,如果找不到,其会在当前盘符中的当前目录中寻找,如果再找不到,其会在PATH环境变量中所定义的所有路径中寻找。MS-DOS中,如果你定义的命令解释器没有找到,其会给你的命令解释器加上诸如.exe.com.bat.sh等后缀。

命令出错

每当命令运行完后,make会检测每个命令的返回码,如果命令返回成功,那么make会执行下一条命令,当规则中所有的命令成功返回后,这个规则就算是成功完成了。如果一个规则中的某个命令出错了(命令退出码非零),那么make就会终止执行当前规则,这将有可能终止所有规则的执行。

有些时候,命令的出错并不表示就是错误的。例如mkdir命令,我们一定需要建立一个目录,如果目录不存在,那么mkdir就成功执行,万事大吉,如果目录存在,那么就出错了。我们之所以使用mkdir的意思就是一定要有这样的一个目录,于是我们就不希望mkdir出错而终止规则的运行。

为了做到这一点,忽略命令的出错,我们可以在Makefile的命令行前加一个减号-(在Tab键之后),标记为不管命令出不出错都认为是成功的。如:

1
2
3
clean:

-rm -f *.o

还有一个全局的办法是,给make加上-i或是--ignore-errors参数,那么,Makefile中所有命令都会忽略错误。而如果一个规则是以.IGNORE作为目标的,那么这个规则中的所有命令将会忽略错误。这些是不同级别的防止命令出错的方法,你可以根据你的不同喜欢设置。

还有一个要提一下的make的参数的是-k或是--keep-going,这个参数的意思是,如果某规则中的命令出错了,那么就终目该规则的执行,但继续执行其它规则。

嵌套执行make

在一些大的工程中,我们会把我们不同模块或是不同功能的源文件放在不同的目录中,我们可以在每个目录中都书写一个该目录的Makefile,这有利于让我们的Makefile变得更加地简洁,而不至于把所有的东西全部写在一个Makefile中,这样会很难维护我们的Makefile,这个技术对于我们模块编译和分段编译有着非常大的好处。

例如,我们有一个子目录叫subdir,这个目录下有个Makefile文件,来指明了这个目录下文件的编译规则。那么我们总控的Makefile可以这样书写:

1
2
3
subsystem:

cd subdir &&$(MAKE)

其等价于:
1
2
3
subsystem:

$(MAKE) -C subdir

定义$(MAKE)宏变量的意思是,也许我们的make需要一些参数,所以定义成一个变量比较利于维护。这两个例子的意思都是先进入subdir目录,然后执行make命令。

我们把这个Makefile叫做总控Makefile,总控Makefile的变量可以传递到下级的Makefile中(如果你显示的声明),但是不会覆盖下层的Makefile中所定义的变量,除非指定了-e参数。

如果你要传递变量到下级Makefile中,那么你可以使用这样的声明:

1
export <variable...>

如果你不想让某些变量传递到下级Makefile中,那么你可以这样声明:
1
unexport <variable...>

如:

示例一:

1
export variable=value

其等价于:
1
2
3
variable=value

export variable

其等价于:
1
export variable :=value

其等价于:
1
2
3
variable :=value

export variable

示例二:
1
export variable +=value

其等价于:
1
2
3
variable +=value

export variable

如果你要传递所有的变量,那么,只要一个export就行了。后面什么也不用跟,表示传递所有的变量。

需要注意的是,有两个变量,一个是SHELL,一个是MAKEFLAGS,这两个变量不管你是否export,其总是要传递到下层Makefile中,特别是MAKEFILES变量,其中包含了make的参数信息,如果我们执行总控Makefile时有make参数或是在上层Makefile中定义了这个变量,那么MAKEFILES变量将会是这些参数,并会传递到下层Makefile中,这是一个系统级的环境变量。

但是make命令中的有几个参数并不往下传递,它们是-C,-f,-h``-o-W(有关Makefile参数的细节将在后面说明),如果你不想往下层传递参数,那么,你可以这样来:

1
2
3
subsystem:

cd subdir && $(MAKE)MAKEFLAGS=

如果你定义了环境变量MAKEFLAGS,那么你得确信其中的选项是大家都会用到的,如果其中有-t,-n,和-q参数,那么将会有让你意想不到的结果,或许会让你异常地恐慌。

还有一个在嵌套执行中比较有用的参数,-w或是--print-directory会在make的过程中输出一些信息,让你看到目前的工作目录。比如,如果我们的下级make目录是/home/hchen/gnu/make,如果我们使用make -w来执行,那么当进入该目录时,我们会看到:

1
make: Entering directory`/home/hchen/gnu/make'.

而在完成下层make后离开目录时,我们会看到:
1
make: Leaving directory`/home/hchen/gnu/make'

当你使用-C参数来指定make下层Makefile时,-w会被自动打开的。如果参数中有-s--slient)或是--no-print-directory,那么,-w总是失效的。

定义命令包

如果Makefile中出现一些相同命令序列,那么我们可以为这些相同的命令序列定义一个变量。定义这种命令序列的语法以define开始,以endef结束,如:

1
2
3
4
5
6
7
define run-yacc

yacc $(firstword$^)

mv y.tab.c $@

endef

这里,run-yacc是这个命令包的名字,其不要和Makefile中的变量重名。在defineendef中的两行就是命令序列。这个命令包中的第一个命令是运行Yacc程序,因为Yacc程序总是生成y.tab.c的文件,所以第二行的命令就是把这个文件改改名字。还是把这个命令包放到一个示例中来看看吧。
1
2
3
foo.c: foo.y

$(run-yacc)

我们可以看见,要使用这个命令包,我们就好像使用变量一样。在这个命令包的使用中,命令包run-yacc中的开头的特殊变量,我们会在后面介绍),make在执行命令包时,命令包中的每个命令会被依次独立执行。

使用变量

在Makefile中的定义的变量,就像是C/C++语言中的宏一样,他代表了一个文本字串,在Makefile中执行的时候其会自动原模原样地展开在所使用的地方。其与C/C++所不同的是,你可以在Makefile中改变其值。在Makefile中,变量可以使用在目标依赖目标命令或是Makefile的其它部分中。变量的命名字可以包含字符、数字,下划线(可以是数字开头),但不应该含有:#=或是空字符(空格、回车等)。变量是大小写敏感的,fooFooFOO是三个不同的变量名。传统的Makefile的变量名是全大写的命名方式,但我推荐使用大小写搭配的变量名,如:MakeFlags。这样可以避免和系统的变量冲突,而发生意外的事情。有一些变量是很奇怪字串,如$<$@等,这些是自动化变量,我会在后面介绍。

变量的基础

变量在声明时需要给予初值,而在使用时,需要给在变量名前加上字符,那么你需要用$$$$来表示。变量可以使用在许多地方,如规则中的目标依赖命令以及新的变量中。

先看一个例子:

1
2
3
4
5
6
7
objects=program.o foo.o utils.o

program:$(objects)

cc -o program $(objects)

$(objects):defs.h

变量会在使用它的地方精确地展开,就像C/C++中的宏一样,例如:
1
2
3
4
5
foo=c

prog.o:prog.$(foo)

$(foo)$(foo) -$(foo) prog.$(foo)

展开后得到:
1
2
3
prog.o:prog.c

cc -c prog.c

当然,千万不要在你的Makefile中这样干,这里只是举个例子来表明Makefile中的变量在使用处展开的真实样子。可见其就是一个替代的原理。另外,给变量加上括号完全是为了更加安全地使用这个变量,在上面的例子中,如果你不想给变量加上括号,那也可以,但我还是强烈建议你给变量加上括号。

变量中的变量

在定义变量的值时,我们可以使用其它变量来构造变量的值,在Makefile中有两种方式来在用变量定义变量的值。

先看第一种方式,也就是简单的使用=号,在=左侧是变量,右侧是变量的值,右侧变量的值可以定义在文件的任何一处,也就是说,右侧中的变量不一定非要是已定义好

的值,其也可以使用后面定义的值。如:

1
2
3
4
5
6
7
8
9
foo=$(bar)

bar=$(ugh)

ugh=Huh?

all:

echo $(foo)

我们执行make all将会打出变量$(foo)的值是Huh?($(foo)的值是$(bar),$(bar)的值是$(ugh),$(ugh)的值是Huh?)可见,变量是可以使用后面的变量来定义的。

这个功能有好的地方,也有不好的地方,好的地方是,我们可以把变量的真实值推到后面来定义,如:

1
2
3
CFLAGS=$(include_dirs) -O

include_dirs=-Ifoo -Ibar

CFLAGS在命令中被展开时,会是-Ifoo -Ibar -O。但这种形式也有不好的地方,那就是递归定义,如:
1
CFLAGS=$(CFLAGS) -O

或:
1
2
3
A=$(B)

B=$(A)

这会让make陷入无限的变量展开过程中去,当然,我们的make是有能力检测这样的定义,并会报错。还有就是如果在变量中使用函数,那么,这种方式会让我们的make运行时非常慢,更糟糕的是,他会使用得两个make的函数wildcardshell发生不可预知的错误。因为你不会知道这两个函数会被调用多少次。

为了避免上面的这种方法,我们可以使用make中的另一种用变量来定义变量的方法。这种方法使用的是:=操作符,如:

1
2
3
4
5
x:=foo

y:=$(x)bar

x:=later

其等价于:
1
2
3
y:=foobar

x:=later

值得一提的是,这种方法,前面的变量不能使用后面的变量,只能使用前面已定义好了的变量。如果是这样:
1
2
3
y:=$(x)bar

x:=foo

那么,y的值是bar,而不是foobar

上面都是一些比较简单的变量使用了,让我们来看一个复杂的例子,其中包括了make的函数、条件表达式和一个系统变量MAKELEVEL的使用:

1
2
3
4
5
6
7
8
9
10
11
ifeq(0,${MAKELEVEL})

cur-dir:=$(shell pwd)

whoami:=$(shell whoami)

host-type:=$(shell arch)

MAKE:= ${MAKE} host-type=${host-type}whoami=${whoami}

endif

关于条件表达式和函数,我们在后面再说,对于系统变量MAKELEVEL,其意思是,如果我们的make有一个嵌套执行的动作(参见前面的嵌套使用make),那么,这个变量会记录了我们的当前Makefile的调用层数。

下面再介绍两个定义变量时我们需要知道的,请先看一个例子,如果我们要定义一个变量,其值是一个空格,那么我们可以这样来:

1
2
3
nullstring:=

space:=$(nullstring) #endof the line

nullstring是一个Empty变量,其中什么也没有,而我们的space的值是一个空格。因为在操作符的右边是很难描述一个空格的,这里采用的技术很管用,先用一个Empty变量来标明变量的值开始了,而后面采用#注释符来表示变量定义的终止,这样,我们可以定义出其值是一个空格的变量。请注意这里关于#的使用,注释符#的这种特性值得我们注意,如果我们这样定义一个变量:
1
dir:=/foo/bar #directoryto put the frobs in

dir这个变量的值是/foo/bar,后面还跟了4个空格,如果我们这样使用这样变量来指定别的目录——$(dir)/file那么就完蛋了。

还有一个比较有用的操作符是?=,先看示例:

1
FOO ?=bar

其含义是,如果FOO没有被定义过,那么变量FOO的值就是bar,如果FOO先前被定义过,那么这条语将什么也不做,其等价于:
1
2
3
4
5
ifeq ($(originFOO),undefined)

FOO = bar

endif

变量高级用法

这里介绍两种变量的高级使用方法,第一种是变量值的替换。

我们可以替换变量中的共有的部分,其格式是$(var:a=b)或是${var:a=b},其意思是,把变量var中所有以a字串结尾a替换成b字串。这里的结尾意思是空格或是结束符

还是看一个示例吧:

1
2
3
foo:=a.o b.o c.o

bar:=$(foo:.o=.c)

这个示例中,我们先定义了一个$(foo)变量,而第二行的意思是把$(foo)中所有以.o字串结尾全部替换成.c,所以我们的$(bar)的值就是a.cb.cc.c

另外一种变量替换的技术是以静态模式(参见前面章节)定义的,如:

1
2
3
foo:=a.o b.o c.o

bar:=$(foo:%.o=%.c)

这依赖于被替换字串中的有相同的模式,模式中必须包含一个%字符,这个例子同样让$(bar)变量的值为a.c b.c c.c

第二种高级用法是——把变量的值再当成变量。先看一个例子:

1
2
3
x=y
y=z
a:=$($(x))

在这个例子中,$(x)的值是y,所以$($(x))就是$(y),于是$(a)的值就是z。(注意,是x=y,而不是x=$(y)

我们还可以使用更多的层次:

1
2
3
4
x=y
y=z
z=u
a:=$($($(x)))

这里的$(a)的值是u,相关的推导留给读者自己去做吧。

让我们再复杂一点,使用上在变量定义中使用变量的第一个方式,来看一个例子:

1
2
3
4
5
6
7
x=$(y)

y=z

z=Hello

a:=$($(x))

这里的$($(x))被替换成了$($(y)),因为$(y)值是z,所以,最终结果是:a:=$(z),也就是Hello

再复杂一点,我们再加上函数:

1
2
3
4
5
6
7
8
9
x=variable1

variable2:=Hello

y=$(subst 1,2,$(x))

z=y

a:= $($($(z)))

这个例子中,$($($(z)))扩展为$($(y)),而其再次被扩展为$($(subst 1,2,$(x)))。$(x)的值是variable1,subst函数把variable1中的所有1字串替换成2字串,于是,variable1变成variable2,再取其值,所以,最终,$(a)的值就是$(variable2)的值——Hello。(喔,好不容易)

在这种方式中,或要可以使用多个变量来组成一个变量的名字,然后再取其值:

1
2
3
4
5
6
7
first_second=Hello

a=first

b=second

all=$($a_$b)

这里的$a_$b组成了first_second,于是,$(all)的值就是Hello

再来看看结合第一种技术的例子:

1
2
3
4
5
a_objects:=a.o b.o c.o

1_objects:=1.o 2.o 3.o

sources:=$($(a1)_objects:.o=.c)

这个例子中,如果$(a1)的值是a的话,那么,$(sources)的值就是a.c b.c c.c;如果$(a1)的值是1,那么$(sources)的值是1.c 2.c 3.c

再来看一个这种技术和函数条件语句一同使用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
ifdef do_sort

func :=sort

else

func :=strip

endif

bar :=a d b g q c

foo :=$($(func)$(bar))

这个示例中,如果定义了do_sort,那么:foo:=$(sort a d b g q c),于是$(foo)的值就是a b c d g q,而如果没有定义do_sort,那么:foo:=$(sort a d b g q c),调用的就是strip函数。

当然,把变量的值再当成变量这种技术,同样可以用在操作符的左边:

1
2
3
4
5
6
7
8
9
dir =foo

$(dir)_sources:=$(wildcard$(dir)/*.c)

define $(dir)_print

lpr $($(dir)_sources)

endef

这个例子中定义了三个变量:dirfoo_sourcesfoo_print

追加变量值

我们可以使用+=操作符给变量追加值,如:

1
2
3
objects=main.o foo.o bar.outils.o

objects+=another.o

于是,我们的$(objects)值变成:main.o foo.o bar.o utils.o another.o(another.o被追加进去了)

使用+=操作符,可以模拟为下面的这种例子:

1
2
3
objects=main.o foo.o bar.outils.o

objects:=$(objects) another.o

所不同的是,用+=更为简洁。

如果变量之前没有定义过,那么,+=会自动变成=,如果前面有变量定义,那么+=会继承于前次操作的赋值符。如果前一次的是:=,那么+=会以:=作为其赋值符,如:

1
2
3
variable :=value

variable +=more

等价于:
1
2
3
variable:=value

variable:=$(variable)more

但如果是这种情况:
1
2
3
variable =value

variable +=more

由于前次的赋值符是=,所以+=也会以=来做为赋值,那么岂不会发生变量的递补归定义,这是很不好的,所以make会自动为我们解决这个问题,我们不必担心这个问题。

override指示符

如果有变量是通常make的命令行参数设置的,那么Makefile中对这个变量的赋值会被忽略。如果你想在Makefile中设置这类参数的值,那么,你可以使用override指示符。其语法是:

1
2
3
override <variable>=<value>

override <variable>:=<value>

当然,你还可以追加:
1
override <variable>+= <moretext>

对于多行的变量定义,我们用define指示符,在define指示符前,也同样可以使用ovveride指示符,如:
1
2
3
4
5
override define foo

bar

endef

多行变量

还有一种设置变量值的方法是使用define关键字。使用define关键字设置变量的值可以有换行,这有利于定义一系列的命令(前面我们讲过命令包的技术就是利用这个关键字)。

define指示符后面跟的是变量的名字,而重起一行定义变量的值,定义是以endef关键字结束。其工作方式和=操作符一样。变量的值可以包含函数、命令、文字,或是其它变量。因为命令需要以[Tab]键开头,所以如果你用define定义的命令变量中没有以[Tab]键开头,那么make就不会把其认为是命令。

下面的这个示例展示了define的用法:

1
2
3
4
5
6
7
define two-lines

echo foo

echo $(bar)

endef

环境变量

make运行时的系统环境变量可以在make开始运行时被载入到Makefile文件中,但是如果Makefile中已定义了这个变量,或是这个变量由make命令行带入,那么系统的环境变量的值将被覆盖。(如果make指定了-e参数,那么,系统环境变量将覆盖Makefile中定义的变量)

因此,如果我们在环境变量中设置了CFLAGS环境变量,那么我们就可以在所有的Makefile中使用这个变量了。这对于我们使用统一的编译参数有比较大的好处。如果Makefile中定义了CFLAGS,那么则会使用Makefile中的这个变量,如果没有定义则使用系统环境变量的值,一个共性和个性的统一,很像全局变量局部变量的特性。当make嵌套调用时(参见前面的嵌套调用章节),上层Makefile中定义的变量会以系统环境变量的方式传递到下层的Makefile中。当然,默认情况下,只有通过命令行设置的变量会被传递。而定义在文件中的变量,如果要向下层Makefile传递,则需要使用exprot关键字来声明。(参见前面章节)

当然,我并不推荐把许多的变量都定义在系统环境中,这样,在我们执行不用的Makefile时,拥有的是同一套系统变量,这可能会带来更多的麻烦。

目标变量

前面我们所讲的在Makefile中定义的变量都是全局变量,在整个文件,我们都可以访问这些变量。当然,自动化变量除外,如$<等这种类量的自动化变量就属于规则型变量,这种变量的值依赖于规则的目标和依赖目标的定义。

当然,我们同样可以为某个目标设置局部变量,这种变量被称为Target-specific Variable,它可以和全局变量同名,因为它的作用范围只在这条规则以及连带规则中,所以其值也只在作用范围内有效。而不会影响规则链以外的全局变量的值。

其语法是:

1
2
3
<target...> :<variable-assignment>

<target...> :override<variable-assignment>

可以是前面讲过的各种赋值表达式,如=:=+=或是?=。第二个语法是针对于make命令行带入的变量,或是系统环境变量。

这个特性非常的有用,当我们设置了这样一个变量,这个变量会作用到由这个目标所引发的所有的规则中去。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
prog:CFLAGS = -g

prog:prog.o foo.o bar.o

$(CC) $(CFLAGS) prog.ofoo.o bar.o

prog.o:prog.c

$(CC )$(CFLAGS) prog.c

foo.o:foo.c

$(CC) $(CFLAGS) foo.c

bar.o:bar.c

$(CC) $(CFLAGS) bar.c

在这个示例中,不管全局的$(CFLAGS)的值是什么,在prog目标,以及其所引发的所有规则中(prog.o foo.o bar.o的规则),$(CFLAGS)的值都是-g

模式变量

在GNU的make中,还支持模式变量(Pattern-specificVariable),通过上面的目标变量中,我们知道,变量可以定义在某个目标上。模式变量的好处就是,我们可以给定一种模式,可以把变量定义在符合这种模式的所有目标上。

我们知道,make的模式一般是至少含有一个%的,所以,我们可以以如下方式给所有以[.o]结尾的目标定义目标变量:

1
%.o:CFLAGS =-O

同样,模式变量的语法和目标变量一样:
1
2
3
<pattern...>:<variable-assignment>

<pattern...>:override<variable-assignment>

override同样是针对于系统环境传入的变量,或是make命令行指定的变量。

使用条件判断

使用条件判断,可以让make根据运行时的不同情况选择不同的执行分支。条件表达式可以是比较变量的值,或是比较变量和常量的值。

示例

下面的例子,判断$(CC)变量是否gcc,如果是的话,则使用GNU函数编译目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
libs_for_gcc=-lgnu

normal_libs=

foo:$(objects)

ifeq($(CC),gcc)

$(CC) -o foo $(objects)$(libs_for_gcc)

else

$(CC) -o foo $(objects)$(normal_libs)

endif

可见,在上面示例的这个规则中,目标foo可以根据变量$(CC)值来选取不同的函数库来编译程序。

我们可以从上面的示例中看到三个关键字:ifeq、else和endif。ifeq的意思表示条件语句的开始,并指定一个条件表达式,表达式包含两个参数,以逗号分隔,表达式以圆括号括起。else表示条件表达式为假的情况。endif表示一个条件语句的结束,任何一个条件表达式都应该以endif结束。

当我们的变量$(CC)值是gcc时,目标foo的规则是:

1
2
3
foo:$(objects)

$(CC) -o foo $(objects)$(libs_for_gcc)

而当我们的变量$(CC)值不是gcc时(比如cc),目标foo的规则是:
1
2
3
foo:$(objects)

$(CC) -o foo $(objects)$(normal_libs)

当然,我们还可以把上面的那个例子写得更简洁一些:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
libs_for_gcc=-lgnu

normal_libs=

ifeq($(CC),gcc)

libs=$(libs_for_gcc)

else

libs=$(normal_libs)

endif

foo:$(objects)

$(CC) -o foo $(objects)$(libs)

语法

条件表达式的语法为:

1
2
3
4
5
<conditional-directive>

<text-if-true>

endif

以及:
1
2
3
4
5
6
7
8
9
<conditional-directive>

<text-if-true>

else

<text-if-false>

endif

其中表示条件关键字,如ifeq。这个关键字有四个。

第一个是我们前面所见过的ifeq

1
2
3
4
5
6
7
8
9
ifeq(<arg1>,<arg2>)

ifeq'<arg1>''<arg2>'

ifeq"<arg1>""<arg2>"

ifeq"<arg1>"'<arg2>'

ifeq'<arg1>'"<arg2>"

比较参数arg1arg2的值是否相同。当然,参数中我们还可以使用make的函数。如:
1
2
3
4
5
ifeq($(strip $(foo)),)

<text-if-empty>

endif

这个示例中使用了strip函数,如果这个函数的返回值是空(Empty),那么就生效。

第二个条件关键字是ifneq。语法是:

1
2
3
4
5
6
7
8
9
ifneq(<arg1>,<arg2>)

ifneq'<arg1>''<arg2>'

ifneq"<arg1>""<arg2>"

ifneq"<arg1>"'<arg2>'

ifneq'<arg1>'"<arg2>"

其比较参数arg1arg2的值是否相同,如果不同,则为真。和ifeq类似。

第三个条件关键字是ifdef。语法是:

1
ifdef<variable-name>

如果变量的值非空,那到表达式为真。否则,表达式为假。当然,同样可以是一个函数的返回值。注意,ifdef只是测试一个变量是否有值,其并不会把变量扩展到当前位置。还是来看两个例子:

示例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
bar=

foo=$(bar)

ifdef foo

frobozz=yes

else

frobozz=no

endif

示例二:
1
2
3
4
5
6
7
8
9
10
11
foo=

ifdef foo

frobozz=yes

else

frobozz=no

endif

第一个例子中,$(frobozz)值是yes,第二个则是no

第四个条件关键字是ifndef。其语法是:

1
ifndef<variable-name>

这个我就不多说了,和ifdef是相反的意思。

<conditional-directive>这一行上,多余的空格是被允许的,但是不能以[Tab]键做为开始(不然就被认为是命令)。而注释符#同样也是安全的。elseendif也一样,只要不是以[Tab]键开始就行了。

特别注意的是,make是在读取Makefile时就计算条件表达式的值,并根据条件表达式的值来选择语句,所以,你最好不要把自动化变量(如$@等)放入条件表达式中,因为自动化变量是在运行时才有的。

而且,为了避免混乱,make不允许把整个条件语句分成两部分放在不同的文件中。

使用函数

在Makefile中可以使用函数来处理变量,从而让我们的命令或是规则更为的灵活和具有智能。make所支持的函数也不算很多,不过已经足够我们的操作了。函数调用后,函数的返回值可以当做变量来使用。

函数的调用语法

函数调用,很像变量的使用,也是以$来标识的,其语法如下:

1
$(<function><arguments>)

或是
1
${<function><arguments>}

这里,就是函数名,make支持的函数不多。是函数的参数,参数间以逗号,分隔,而函数名和参数之间以空格分隔。函数调用以$开头,以圆括号或花括号把函数名和参数括起。感觉很像一个变量,是不是?函数中的参数可以使用变量,为了风格的统一,函数和变量的括号最好一样,如使用$(substa,b,$(x))这样的形式,而不是$(substa,b,${x})的形式。因为统一会更清楚,也会减少一些不必要的麻烦。

还是来看一个示例:

1
2
3
4
5
6
7
8
9
comma:=,

empty:=

space:=$(empty)$(empty)

foo:=a b c

bar:=$(subst $(space),$(comma),$(foo))

在这个示例中,$(comma)的值是一个逗号。$(space)使用了$(empty)定义了一个空格,$(foo)的值是abc,$(bar)的定义用,调用了函数subst,这是一个替换函数,这个函数有三个参数,第一个参数是被替换字串,第二个参数是替换字串,第三个参数是替换操作作用的字串。这个函数也就是把$(foo)中的空格替换成逗号,所以$(bar)的值是a,b,c

字符串处理函数

1
$(subst <from>,<to>,<text>)

名称:字符串替换函数——subst。

功能:把字串中的字符串替换成

返回:函数返回被替换过后的字符串。

示例:

1
$(subst ee,EE,feetonthestreet),

feetonthestreet中的ee替换成EE,返回结果是fEEtonthestrEEt
1
$(patsubst <pattern>,<replacement>,<text>)

名称:模式字符串替换函数——patsubst。

功能:查找<text>中的单词(单词以空格Tab回车``换行分隔)是否符合模式,如果匹配的话,则以替换。这里,可以包括通配符%,表示任意长度的字串。如果中也包含%,那么,中的这个%将是中的那个%所代表的字串。(可以用\来转义,以\%来表示真实含义的%字符)返回:函数返回被替换过后的字符串。

示例:

1
$(patsubst %.c,%.o,x.c.cbar.c)

把字串x.c.c bar.c符合模式[%.c]的单词替换成[%.o],返回结果是x.c.o bar.o

备注:

这和我们前面变量章节说过的相关知识有点相似。如:
$(var:<pattern>=<replacement>)

相当于
$(patsubst <pattern>,<replacement>,$(var))


$(var:<suffix>=<replacement>)

则相当于
$(patsubst %<suffix>,%<replacement>,$(var))

例如有:objects=foo.o bar.o baz.o,

那么,$(objects:.o=.c)$(patsubst %.o,%.c,$(objects))是一样的。

1
$(strip <string>)

名称:去空格函数——strip。

功能:去掉字串中开头和结尾的空字符。

返回:返回被去掉空格的字符串值。

示例:

1
$(strip abc)

把字串abc去到开头和结尾的空格,结果是abc
1
$(findstring<find>,<in>)

名称:查找字符串函数——findstring。

功能:在字串中查找字串。

返回:如果找到,那么返回,否则返回空字符串。

示例:

1
2
3
$(findstring a,a b c)

$(findstring a,b c)

第一个函数返回a字符串,第二个返回 字符串(空字符串)

1
$(filter <pattern...>,<text>)

名称:过滤函数——filter。

功能:以模式过滤字符串中的单词,保留符合模式的单词。可以有多个模式。

返回:返回符合模式的字串。

示例:

1
2
3
4
5
6
7
sources:=foo.c bar.c baz.sugh.h

foo:$(sources)

cc $(filter %.c %.s,$(sources))-o foo

$(filter%.c%.s,$(sources))返回的值是`foo.c bar.c baz.s`。

1
$(filter-out <pattern...>,<text>)

名称:反过滤函数——filter-out。

功能:以模式过滤字符串中的单词,去除符合模式的单词。可以有多个模式。

返回:返回不符合模式的字串。

示例:

1
2
3
4
5
objects=main1.o foo.omain2.o bar.o

mains=main1.o main2.o

$(filter-out $(mains),$(objects))返回值是`foo.o bar.o`。

1
$(sort <list>)

名称:排序函数——sort。

功能:给字符串中的单词排序(升序)。

返回:返回排序后的字符串。

示例:$(sort foobarlose)返回barfoolose

备注:sort函数会去掉中相同的单词。

1
$(word <n>,<text>)

名称:取单词函数——word。

功能:取字符串中第个单词。(从一开始)

返回:返回字符串中第个单词。如果中的单词数要大,那么返回空字符串。

示例:

1
$(word 2,foo bar baz)返回值是`bar`。

1
$(wordlist <s>,<e>,<text>)

名称:取单词串函数——wordlist。

功能:从字符串中取从开始到的单词串。是一个数字。

返回:返回字符串中从的单词字串。如果中的单词数要大,那

么返回空字符串。如果大于的单词数,那么返回从开始,到结束的单

词串。

示例:$(wordlist 2,3,foo bar baz)返回值是bar baz

1
$(words <text>)

名称:单词个数统计函数——words。

功能:统计中字符串中的单词个数。

返回:返回中的单词数。

示例:$(words,foo bar baz)返回值是3

备注:如果我们要取中最后的一个单词,我们可以这样:$(word $(words

),)。

1
$(firstword <text>)

名称:首单词函数——firstword。

功能:取字符串中的第一个单词。

返回:返回字符串的第一个单词。

示例:$(firstword foo bar)返回值是foo

备注:这个函数可以用word函数来实现:$(word 1,)。

以上,是所有的字符串操作函数,如果搭配混合使用,可以完成比较复杂的功能。这里,举一个现实中应用的例子。我们知道,make使用VPATH变量来指定依赖文件的搜索路径。于是,我们可以利用这个搜索路径来指定编译器对头文件的搜索路径参数CFLAGS,如:

override CFLAGS +=$(patsubst%,-I%,$(subst:,,$(VPATH)))

如果我们的$(VPATH)值是src:../headers,那么$(patsubst %,-I%,$(subst:,,$(VPATH)))将返回-Isrc-I../headers,这正是cc或gcc搜索头文件路径的参数。

文件名操作函数

下面我们要介绍的函数主要是处理文件名的。每个函数的参数字符串都会被当做一个或是

一系列的文件名来对待。

1
$(dir <names...>)

名称:取目录函数——dir。

功能:从文件名序列中取出目录部分。目录部分是指最后一个反斜杠(/)之

前的部分。如果没有反斜杠,那么返回./

返回:返回文件名序列的目录部分。

示例:$(dir src/foo.chacks)返回值是src/./

1
$(notdir <names...>)

名称:取文件函数——notdir。

功能:从文件名序列中取出非目录部分。非目录部分是指最后一个反斜杠(/)之后的部分。

返回:返回文件名序列的非目录部分。

示例:$(notdir src/foo.chacks)返回值是foo.chacks

1
$(suffix<names...>)

名称:取后缀函数——suffix。

功能:从文件名序列中取出各个文件名的后缀。

返回:返回文件名序列的后缀序列,如果文件没有后缀,则返回空字串。

示例:$(suffix src/foo.c src-1.0/bar.chacks)返回值是.c.c

1
$(basename <names...>)

名称:取前缀函数——basename。

功能:从文件名序列中取出各个文件名的前缀部分。

返回:返回文件名序列的前缀序列,如果文件没有前缀,则返回空字串。

示例:$(basename src/foo.c src-1.0/bar.chacks)返回值是src/foo src-1.0/bar hacks

1
$(addsuffix <suffix>,<names...>)

名称:加后缀函数——addsuffix。

功能:把后缀加到中的每个单词后面。

返回:返回加过后缀的文件名序列。

示例:$(addsuffix.c,foobar)返回值是foo.c bar.c

1
$(addprefix <prefix>,<names...>)

名称:加前缀函数——addprefix。

功能:把前缀加到中的每个单词后面。

返回:返回加过前缀的文件名序列。

示例:$(addprefix src/,foo bar)返回值是src/foosrc/bar

1
$(join<list1>,<list2>)

名称:连接函数——join。

功能:把中的单词对应地加到的单词后面。如果的单词个数要比的多,那么,中的多出来的单词将保持原样。如果的单词个数要比多,那么,多出来的单词将被复制到中。

返回:返回连接过后的字符串。

示例:$(join aaa bbb,111 222 333)返回值是aaa111 bbb222 333

foreach函数

foreach函数和别的函数非常的不一样。因为这个函数是用来做循环用的,Makefile中的foreach函数几乎是仿照于Unix标准Shell(/bin/sh)中的for语句,或是C-Shell(/bin/csh)中的foreach语句而构建的。它的语法是:

1
$(foreach<var>,<list>,<text>)

这个函数的意思是,把参数中的单词逐一取出放到参数所指定的变量中,然后再执行所包含的表达式。每一次会返回一个字符串,循环过程中,的所返回的每个字符串会以空格分隔,最后当整个循环结束时,所返回的每个字符串所组成的整个字符串(以空格分隔)将会是foreach函数的返回值。

所以,最好是一个变量名,可以是一个表达式,而中一般会使用

这个参数来依次枚举中的单词。举个例子:

1
2
3
names:=a b c d

files:=$(foreach n,$(names),$(n).o)

上面的例子中,$(name)中的单词会被挨个取出,并存到变量n中,$(n).o每次根据$(n)计算出一个值,这些值以空格分隔,最后作为foreach函数的返回,所以,$(files)的值是a.o b.o c.o d.o

注意,foreach中的参数是一个临时的局部变量,foreach函数执行完后,参数的变量将不在作用,其作用域只在foreach函数当中。

if函数

if函数很像GNU的make所支持的条件语句——ifeq(参见前面所述的章节),if函数的语法是:

1
$(if<condition>,<then-part>)

或是
1
$(if<condition>,<then-part>,<else-part>)

可见,if函数可以包含else部分,或是不含。即if函数的参数可以是两个,也可以是三个。参数是if的表达式,如果其返回的为非空字符串,那么这个表达式就相当于返回真,于是,会被计算,否则会被计算。

而if函数的返回值是,如果为真(非空字符串),那个会是整个函数的返回值,如果为假(空字符串),那么会是整个函数的返回值,此时如果没有被定义,那么,整个函数返回空字串。

所以,只会有一个被计算。

call函数

call函数是唯一一个可以用来创建新的参数化的函数。你可以写一个非常复杂的表达式,这个表达式中,你可以定义许多参数,然后你可以用call函数来向这个表达式传递参数。其语法是:

1
$(call <expression>,<parm1>,<parm2>,<parm3>...)

当make执行这个函数时,参数中的变量,如$(1),$(2),$(3)等,会被参数依次取代。而的返回值就是call函数的返回值。例如:
1
2
3
reverse=$(1) $(2)

foo=$(call reverse,a,b)

那么,foo的值就是a b。当然,参数的次序是可以自定义的,不一定是顺序的,如:
1
2
3
reverse=$(2) $(1)

foo=$(call reverse,a,b)

此时的foo的值就是b a

origin函数

origin函数不像其它的函数,他并不操作变量的值,他只是告诉你你的这个变量是哪里来的?其语法是:

1
$(origin <variable>)

注意,是变量的名字,不应该是引用。所以你最好不要在中使用$字符。Origin函数会以其返回值来告诉你这个变量的出生情况,下面,是origin函

数的返回值:

undefined

如果从来没有定义过,origin函数返回这个值undefined

default

如果是一个默认的定义,比如CC这个变量,这种变量我们将在后面讲述。

environment

如果是一个环境变量,并且当Makefile被执行时,-e参数没有被打开。

file

如果这个变量被定义在Makefile中。

command line

如果这个变量是被命令行定义的。

override

如果是被override指示符重新定义的。

automatic

如果是一个命令运行中的自动化变量。关于自动化变量将在后面讲述。

这些信息对于我们编写Makefile是非常有用的,例如,假设我们有一个Makefile其包了一个定义文件Make.def,在Make.def中定义了一个变量bletch,而我们的环境中也有一个环境变量bletch,此时,我们想判断一下,如果变量来源于环境,那么我们就把之重定义了,如果来源于Make.def或是命令行等非环境的,那么我们就不重新定义它。于是,在我们的Makefile中,我们可以这样写:

1
2
3
4
5
6
7
8
9
ifdef bletch

ifeq"$(origin bletch)""environment"

bletch=barf,gag,etc.

endif

endif

当然,你也许会说,使用override关键字不就可以重新定义环境中的变量了吗?为什么需要使用这样的步骤?是的,我们用override是可以达到这样的效果,可是override过于粗暴,它同时会把从命令行定义的变量也覆盖了,而我们只想重新定义环境传来的,而不想重新定义命令行传来的。

shell函数

shell函数也不像其它的函数。顾名思义,它的参数应该就是操作系统Shell的命令。它和反引号是相同的功能。这就是说,shell函数把执行操作系统命令后的输出作为函数返回。于是,我们可以用操作系统命令以及字符串处理命令awk,sed等等命令来生成一个变量,如:

1
2
3
contents:=$(shell catfoo)

files:=$(shell echo *.c)

注意,这个函数会新生成一个Shell程序来执行命令,所以你要注意其运行性能,如果你的Makefile中有一些比较复杂的规则,并大量使用了这个函数,那么对于你的系统性能是有害的。特别是Makefile的隐晦的规则可能会让你的shell函数执行的次数比你想像的多得多。

控制make的函数

make提供了一些函数来控制make的运行。通常,你需要检测一些运行Makefile时的运行时信息,并且根据这些信息来决定,你是让make继续执行,还是停止。

1
$(error <text...>)

产生一个致命的错误,是错误信息。注意,error函数不会在一被使用就会产生错误信息,所以如果你把其定义在某个变量中,并在后续的脚本中使用这个变量,那么也是可以的。例如:

示例一:

1
2
3
4
5
ifdef ERROR_001

$(error error is $(ERROR_001))

endif

示例二:
1
2
3
4
5
ERR=$(error found an error!)

.PHONY:err

err:;$(ERR)

示例一会在变量ERROR_001定义了后执行时产生error调用,而示例二则在目录err被执行时才发生error调用。

1
$(warning <text...>)

这个函数很像error函数,只是它并不会让make退出,只是输出一段警告信息,而make继续执行。

make的运行

一般来说,最简单的就是直接在命令行下输入make命令,make命令会找当前目录的makefile来执行,一切都是自动的。但也有时你也许只想让make重编译某些文件,而不是整个工程,而又有的时候你有几套编译规则,你想在不同的时候使用不同的编译规则,等等。本章节就是讲述如何使用make命令的。

make的退出码

make命令执行后有三个退出码:

0——表示成功执行。

1——如果make运行时出现任何错误,其返回1。

2——如果你使用了make的-q选项,并且make使得一些目标不需要更新,那么返回2。

Make的相关参数我们会在后续章节中讲述。

指定Makefile

前面我们说过,GNU make找寻默认的Makefile的规则是在当前目录下依次找三个文件——GNUmakefilemakefileMakefile。其按顺序找这三个文件,一旦找到,就开始读取这个文件并执行。

当前,我们也可以给make命令指定一个特殊名字的Makefile。要达到这个功能,我们要使用make的-f或是--file参数(--makefile参数也行)。例如,我们有个makefile的名字是hchen.mk,那么,我们可以这样来让make来执行这个文件:

1
make –f hchen.mk

如果在make的命令行是,你不只一次地使用了-f参数,那么,所有指定的makefile将会被连在一起传递给make执行。

指定目标

一般来说,make的最终目标是makefile中的第一个目标,而其它目标一般是由这个目标连带出来的。这是make的默认行为。当然,一般来说,你的makefile中的第一个目标是由许多个目标组成,你可以指示make,让其完成你所指定的目标。要达到这一目的很简单,需在make命令后直接跟目标的名字就可以完成(如前面提到的make clean形式)任何在makefile中的目标都可以被指定成终极目标,但是除了以-打头,或是包含了=的目标,因为有这些字符的目标,会被解析成命令行参数或是变量。甚至没有被我们明确写出来的目标也可以成为make的终极目标,也就是说,只要make可以找到其隐含规则推导规则,那么这个隐含目标同样可以被指定成终极目标。

有一个make的环境变量叫MAKECMDGOALS,这个变量中会存放你所指定的终极目标的列表,如果在命令行上,你没有指定目标,那么,这个变量是空值。这个变量可以让你使用在一些比较特殊的情形下。比如下面的例子:

1
2
3
4
5
6
7
sources=foo.c bar.c

ifneq ($(MAKECMDGOALS),clean)

include $(sources:.c=.d)

endif

基于上面的这个例子,只要我们输入的命令不是makeclean,那么makefile会自动包含foo.dbar.d这两个makefile。

使用指定终极目标的方法可以很方便地让我们编译我们的程序,例如下面这个例子:

1
2
3
.PHONY:all

all:prog1 prog2 prog3prog4

从这个例子中,我们可以看到,这个makefile中有四个需要编译的程序——prog1prog2prog3prog4,我们可以使用make all命令来编译所有的目标

(如果把all置成第一个目标,那么只需执行make),我们也可以使用make prog2来单独编译目标prog2

即然make可以指定所有makefile中的目标,那么也包括伪目标,于是我们可以根据这种性质来让我们的makefile根据指定的不同的目标来完成不同的事。在Unix世界中,软件发布时,特别是GNU这种开源软件的发布时,其makefile都包含了编译、安装、打包等功能。我们可以参照这种规则来书写我们的makefile中的目标。

all这个伪目标是所有目标的目标,其功能一般是编译所有的目标。

clean这个伪目标功能是删除所有被make创建的文件。

install这个伪目标功能是安装已编译好的程序,其实就是把目标执行文件拷贝到指定的目标中去。

print这个伪目标的功能是例出改变过的源文件。

tar这个伪目标功能是把源程序打包备份。也就是一个tar文件。

dist这个伪目标功能是创建一个压缩文件,一般是把tar文件压成Z文件。或是gz文件。

TAGS这个伪目标功能是更新所有的目标,以备完整地重编译使用。

checktest这两个伪目标一般用来测试makefile的流程。

当然一个项目的makefile中也不一定要书写这样的目标,这些东西都是GNU的东西,但是我想,GNU搞出这些东西一定有其可取之处(等你的UNIX下的程序文件一多时你就会发现这些功能很有用了),这里只不过是说明了,如果你要书写这种功能,最好使用这种名字命名你的目标,这样规范一些,规范的好处就是——不用解释,大家都明白。而且如果你的makefile中有这些功能,一是很实用,二是可以显得你的makefile很专业(不是那种初学者的作品)。

检查规则

有时候,我们不想让我们的makefile中的规则执行起来,我们只想检查一下我们的命令,或是执行的序列。于是我们可以使用make命令的下述参数:

-n

--just-print

--dry-run

--recon

不执行参数,这些参数只是打印命令,不管目标是否更新,把规则和连带规则下的命令打印出来,但不执行,这些参数对于我们调试makefile很有用处。

-t

--touch

这个参数的意思就是把目标文件的时间更新,但不更改目标文件。也就是说,make假装编译目标,但不是真正的编译目标,只是把目标变成已编译过的状态。

-q

--question

这个参数的行为是找目标的意思,也就是说,如果目标存在,那么其什么也不会输出,当然也不会执行编译,如果目标不存在,其会打印出一条出错信息。

-W <file>

--what-if=<file>

--assume-new=<file>

--new-file=<file>

这个参数需要指定一个文件。一般是是源文件(或依赖文件),Make会根据规则推导来运行依赖于这个文件的命令,一般来说,可以和-n参数一同使用,来查看这个依赖文件所发生的规则命令。

另外一个很有意思的用法是结合-p-v来输出makefile被执行时的信息(这个将在后面讲述)。

make的参数

下面列举了所有GNU make 3.80版的参数定义。其它版本和产商的make大同小异,不过其它产商的make的具体参数还是请参考各自的产品文档。

-b

-m

这两个参数的作用是忽略和其它版本make的兼容性。

-B

--always-make

认为所有的目标都需要更新(重编译)。

-C <dir>

--directory=<dir>

指定读取makefile的目录。如果有多个-C参数,make的解释是后面的路径以前面的作为相对路径,并以最后的目录作为被指定目录。如:make –C ~hchen/test–C prog等价于make –C ~hchen/test/prog

—debug[=<options>]

输出make的调试信息。它有几种不同的级别可供选择,如果没有参数,那就是输出最简单的调试信息。下面是的取值:

a——也就是all,输出所有的调试信息。(会非常的多)

b——也就是basic,只输出简单的调试信息。即输出不需要重编译的目标。

v——也就是verbose,在b选项的级别之上。输出的信息包括哪个makefile被解析,不需要被重编译的依赖文件(或是依赖目标)等。

i——也就是implicit,输出所以的隐含规则。

j——也就是jobs,输出执行规则中命令的详细信息,如命令的PID、返回码等。

m——也就是makefile,输出make读取makefile,更新makefile,执行makefile的信息。

-d相当于--debug=a

-e --environment-overrides 指明环境变量的值覆盖makefile中定义的变量的值。

-f=<file>--file=<file>--makefile=<file>指定需要执行的makefile。

-h--help 显示帮助信息。

-i--ignore-errors 在执行时忽略所有的错误。

-I<dir>--include-dir=<dir>指定一个被包含makefile的搜索目标。可以使用多个-I参数来指定多个目录。

-j[<jobsnum>]--jobs[=<jobsnum>]指同时运行命令的个数。如果没有这个参数,make运行命令时能运行多少就运行多少。如果有一个以上的-j参数,那么仅最后一个-j才是有效的。(注意这个参数在MS-DOS中是无用的)

-k--keep-going出错也不停止运行。如果生成一个目标失败了,那么依赖于其上的目标就不会被执行了。

-l<load>--load-average[=<load]—max-load[=<load>] 指定make运行命令的负载。

-n--just-print--dry-run--recon 仅输出执行过程中的命令序列,但并不执行。

-o<file>--old-file=<file>--assume-old=<file>不重新生成的指定的,即使这个目标的依赖文件新于它。

-p--print-data-base、输出makefile中的所有数据,包括所有的规则和变量。这个参数会让一个简单的makefile都会输出一堆信息。如果你只是想输出信息而不想执行makefile,你可以使用make -q p命令。如果你想查看执行makefile前的预设变量和规则,你可以使用make–p–f/dev/null。这个参数输出的信息会包含着你的makefile文件的文件名和行号,所以,用这个参数来调试你的makefile会是很有用的,特别是当你的环境变量很复杂的时候。

-q--question不运行命令,也不输出。仅仅是检查所指定的目标是否需要更新。如果是0则说明要更新,如果是2则说明有错误发生。

-r--no-builtin-rules禁止make使用任何隐含规则。

-R--no-builtin-variabes禁止make使用任何作用于变量上的隐含规则。

-s--silent--quiet在命令运行时不输出命令的输出。

-S--no-keep-going--stop取消-k选项的作用。因为有些时候,make的选项是从环境变量MAKEFLAGS中继承下来的。所以你可以在命令行中使用这个参数来让环境变量中的-k选项失效。

-t--touch相当于UNIX的touch命令,只是把目标的修改日期变成最新的,也就是阻止生成目标的命令运行。

-v--version输出make程序的版本、版权等关于make的信息。

-w--print-directory输出运行makefile之前和之后的信息。这个参数对于跟踪嵌套式调用make时很有用。

--no-print-directory禁止-w选项。

-W <file>--what-if=<file>--new-file=<file>--assume-file=<file>假定目标需要更新,如果和-n选项使用,那么这个参数会输出该目标更新时的运行动作。如果没有-n那么就像运行UNIX的touch命令一样,使得的修改时间为当前时间。

--warn-undefined-variables只要make发现有未定义的变量,那么就输出警告信息。

隐含规则

在我们使用Makefile时,有一些我们会经常使用,而且使用频率非常高的东西,比如,我们编译C/C++的源程序为中间目标文件(Unix下是[.o]文件,Windows下是[.obj]文件)。本章讲述的就是一些在Makefile中的隐含的,早先约定了的,不需要我们再写出来的规则。

隐含规则也就是一种惯例,make会按照这种惯例心照不喧地来运行,那怕我们的Makefile中没有书写这样的规则。例如,把[.c]文件编译成[.o]文件这一规则,你根本就不用写出来,make会自动推导出这种规则,并生成我们需要的[.o]文件。

隐含规则会使用一些我们系统变量,我们可以改变这些系统变量的值来定制隐含规则的运行时的参数。如系统变量CFLAGS可以控制编译时的编译器参数。

我们还可以通过模式规则的方式写下自己的隐含规则。用后缀规则来定义隐含规则会有许多的限制。使用模式规则会更回得智能和清楚,但后缀规则可以用来保证我们Makefile的兼容性。

我们了解了隐含规则,可以让其为我们更好的服务,也会让我们知道一些约定俗成了的东西,而不至于使得我们在运行Makefile时出现一些我们觉得莫名其妙的东西。当然,任何事物都是矛盾的,水能载舟,亦可覆舟,所以,有时候隐含规则也会给我们造成不小的麻烦。只有了解了它,我们才能更好地使用它。

使用隐含规则

如果要使用隐含规则生成你需要的目标,你所需要做的就是不要写出这个目标的规则。那么,make会试图去自动推导产生这个目标的规则和命令,如果make可以自动推导生成这个目标的规则和命令,那么这个行为就是隐含规则的自动推导。当然,隐含规则是make事先约定好的一些东西。例如,我们有下面的一个Makefile:

1
2
3
foo:foo.o bar.o

cc –o foo foo.o bar.o $(CFLAGS) $(LDFLAGS)

我们可以注意到,这个Makefile中并没有写下如何生成foo.o和bar.o这两目标的规则和命令。因为make的隐含规则功能会自动为我们自动去推导这两个目标的依赖目标和生成命令。

make会在自己的隐含规则库中寻找可以用的规则,如果找到,那么就会使用。如果找不到,那么就会报错。在上面的那个例子中,make调用的隐含规则是,把[.o]的目标的依赖文件置成[.c],并使用C的编译命令cc –c $(CFLAGS)[.c]来生成[.o]的目标。也就是说,我们完全没有必要写下下面的两条规则:

1
2
3
4
5
6
7
foo.o:foo.c

cc –c foo.c$(CFLAGS)

bar.o:bar.c

cc –c bar.c $(CFLAGS)

因为,这已经是约定好了的事了,make和我们约定好了用C编译器cc生成[.o]文件的规则,这就是隐含规则。

当然,如果我们为[.o]文件书写了自己的规则,那么make就不会自动推导并调用隐含规则,它会按照我们写好的规则忠实地执行。

还有,在make的隐含规则库中,每一条隐含规则都在库中有其顺序,越靠前的则是越被经常使用的,所以,这会导致我们有些时候即使我们显示地指定了目标依赖,make也不会管。如下面这条规则(没有命令):

1
foo.o:foo.p

依赖文件foo.p(Pascal程序的源文件)有可能变得没有意义。如果目录下存在了foo.c文件,那么我们的隐含规则一样会生效,并会通过foo.c调用C的编译器生成foo.o文件。因为,在隐含规则中,Pascal的规则出现在C的规则之后,所以,make找到可以生成foo.o的C的规则就不再寻找下一条规则了。如果你确实不希望任何隐含规则推导,那么,你就不要只写出依赖规则,而不写命令。

隐含规则一览

这里我们将讲述所有预先设置(也就是make内建)的隐含规则,如果我们不明确地写下规则,那么,make就会在这些规则中寻找所需要规则和命令。当然,我们也可以使用make的参数-r--no-builtin-rules选项来取消所有的预设置的隐含规则。

当然,即使是我们指定了-r参数,某些隐含规则还是会生效,因为有许多的隐含规则都是使用了后缀规则来定义的,所以,只要隐含规则中有后缀列表(也就一系统定义在目标.SUFFIXES的依赖目标),那么隐含规则就会生效。默认的后缀列表是:.out,.a,.ln,.o,.c,.cc,.C,.p,.f,.F,.r,.y,.l,.s,.S,.mod,.sym,.def,.h,.info,.dvi,.tex,.texinfo,.texi,.txinfo,.w,.ch.web,.sh,.elc,.el。具体的细节,我们会在后面讲述。

还是先来看一看常用的隐含规则吧。

1、编译C程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.c,并且其生成命令是$(CC)–c $(CPPFLAGS) $(CFLAGS)

2、编译C++程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.cc或是<n>.C,并且其生成命令是$(CXX)–c $(CPPFLAGS) $(CFLAGS)。(建议使用.cc作为C++源文件的后缀,而不是.C

3、编译Pascal程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.p,并且其生成命令是$(PC)–c $(PFLAGS)

4、编译Fortran/Ratfor程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.r<n>.F<n>.f,并且其生成命令是:

.f``$(FC)–c$(FFLAGS)

.F``$(FC)–c$(FFLAGS)$(CPPFLAGS)

.f``$(FC)–c$(FFLAGS)$(RFLAGS)

5、预处理Fortran/Ratfor程序的隐含规则。

<n>.f的目标的依赖目标会自动推导为<n>.r<n>.F。这个规则只是转换Ratfor或有预处理的Fortran程序到一个标准的Fortran程序。其使用的命令是:

.F``$(FC)–F $(CPPFLAGS) $(FFLAGS)

.r``$(FC)–F $(FFLAGS) $(RFLAGS)

6、编译Modula-2程序的隐含规则。

<n>.sym的目标的依赖目标会自动推导为<n>.def,并且其生成命令是:$(M2C) $(M2FLAGS)$(DEFFLAGS)<n.o>的目标的依赖目标会自动推导为<n>.mod,并且其生成命令是:$(M2C) $(M2FLAGS) $(MODFLAGS)

7、汇编和汇编预处理的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.s,默认使用编译品as,并且其生成命令是:$(AS) $(ASFLAGS)<n>.s的目标的依赖目标会自动推导为<n>.S,默认使用C预编译器cpp,并且其生成命令是:$(AS)$(ASFLAGS)

8、链接Object文件的隐含规则。

<n>目标依赖于<n>.o,通过运行C的编译器来运行链接程序生成(一般是ld),其生成命令是:$(CC) $(LDFLAGS)<n>.o$(LOADLIBES) $(LDLIBS)。这个规则对于只有一个源文件的工程有效,同时也对多个Object文件(由不同的源文件生成)的也有效。例如如下规则:

x:y.o z.o

并且x.cy.cz.c都存在时,隐含规则将执行如下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
cc -c x.c -o x.o

cc -c y.c -o y.o

cc -c z.c -o z.o

cc x.o y.o z.o -o x

rm -f x.o

rm -f y.o

rm -f z.o

如果没有一个源文件(如上例中的x.c)和你的目标名字(如上例中的x)相关联,那么,你最好写出自己的生成规则,不然,隐含规则会报错的。

9、Yacc C程序时的隐含规则。

<n>.c的依赖文件被自动推导为n.y(Yacc生成的文件),其生成命令是:$(YACC) $(YFALGS)。(Yacc是一个语法分析器,关于其细节请查看相关资料)

10、LexC程序时的隐含规则。

<n>.c的依赖文件被自动推导为n.l(Lex生成的文件),其生成命令是:$(LEX)$(LFALGS)。(关于Lex的细节请查看相关资料)

11、LexRatfor程序时的隐含规则。

<n>.r的依赖文件被自动推导为n.l(Lex生成的文件),其生成命令是:$(LEX)$(LFALGS)

12、从C程序、Yacc文件或Lex文件创建Lint库的隐含规则。

<n>.ln(lint生成的文件)的依赖文件被自动推导为n.c,其生成命令是:$(LINT)$(LINTFALGS)$(CPPFLAGS)-i。对于<n>.y<n>.l也是同样的规则。

隐含规则使用的变量

在隐含规则中的命令中,基本上都是使用了一些预先设置的变量。你可以在你的makefile中改变这些变量的值,或是在make的命令行中传入这些值,或是在你的环境变量中设置这些值,无论怎么样,只要设置了这些特定的变量,那么其就会对隐含规则起作用。当然,你也可以利用make的-R--no–builtin-variables参数来取消你所定义的变量对隐含规则的作用。

例如,第一条隐含规则——编译C程序的隐含规则的命令是$(CC)–c $(CFLAGS) $(CPPFLAGS)。Make默认的编译命令是cc,如果你把变量$(CC)重定义成gcc,把变量$(CFLAGS)重定义成-g,那么,隐含规则中的命令全部会以gcc–c-g $(CPPFLAGS)的样子来执行了。

我们可以把隐含规则中使用的变量分成两种:一种是命令相关的,如CC;一种是参数相的关,如CFLAGS。下面是所有隐含规则中会用到的变量:

1、关于命令的变量。
AR:函数库打包程序。默认命令是ar

AS:汇编语言编译程序。默认命令是as

CC:C语言编译程序。默认命令是cc

CXX:C++语言编译程序。默认命令是g++

CO:从RCS文件中扩展文件程序。默认命令是co

CPP:C程序的预处理器(输出是标准输出设备)。默认命令是$(CC)–E

FC:Fortran和Ratfor的编译器和预处理程序。默认命令是f77

GET:从SCCS文件中扩展文件的程序。默认命令是get

LEX:Lex方法分析器程序(针对于C或Ratfor)。默认命令是lex

PC:Pascal语言编译程序。默认命令是pc

YACC:Yacc文法分析器(针对于C程序)。默认命令是yacc

YACCR:Yacc文法分析器(针对于Ratfor程序)。默认命令是yacc–r

MAKEINFO:转换Texinfo源文件(.texi)到Info文件程序。默认命令是makeinfo

TEX:从TeX源文件创建TeXDVI文件的程序。默认命令是tex

TEXI2DVI:从Texinfo源文件创建军TeXDVI文件的程序。默认命令是texi2dvi

WEAVE:转换Web到TeX的程序。默认命令是weave

CWEAVE:转换CWeb到TeX的程序。默认命令是cweave

TANGLE:转换Web到Pascal语言的程序。默认命令是tangle

CTANGLE:转换CWeb到C。默认命令是ctangle

RM:删除文件命令。默认命令是rm –f

2、关于命令参数的变量
下面的这些变量都是相关上面的命令的参数。如果没有指明其默认值,那么其默认值都是空。

ARFLAGS:函数库打包程序AR命令的参数。默认值是rv

ASFLAGS:汇编语言编译器参数。(当明显地调用.s.S文件时)。

CFLAGS:C语言编译器参数。

CXXFLAGS:C++语言编译器参数。

COFLAGS:RCS命令参数。

CPPFLAGS:C预处理器参数。(C和Fortran编译器也会用到)。

FFLAGS:Fortran语言编译器参数。

GFLAGS:SCCSget程序参数。

LDFLAGS:链接器参数。(如:ld

LFLAGS:Lex文法分析器参数。

PFLAGS:Pascal语言编译器参数。

RFLAGS:Ratfor程序的Fortran编译器参数。

YFLAGS:Yacc文法分析器参数。

隐含规则链

有些时候,一个目标可能被一系列的隐含规则所作用。例如,一个[.o]的文件生成,可能会是先被Yacc的[.y]文件先成[.c],然后再被C的编译器生成。我们把这一系列的隐含规则叫做隐含规则链

在上面的例子中,如果文件[.c]存在,那么就直接调用C的编译器的隐含规则,如果没有[.c]文件,但有一个[.y]文件,那么Yacc的隐含规则会被调用,生成[.c]文件,然后,再调用C编译的隐含规则最终由[.c]生成[.o]文件,达到目标。

我们把这种[.c]的文件(或是目标),叫做中间目标。不管怎么样,make会努力自动推导生成目标的一切方法,不管中间目标有多少,其都会执着地把所有的隐含规则和你书写的规则全部合起来分析,努力达到目标,所以,有些时候,可能会让你觉得奇怪,怎么我的目标会这样生成?怎么我的makefile发疯了?

在默认情况下,对于中间目标,它和一般的目标有两个地方所不同:第一个不同是除非中间的目标不存在,才会引发中间规则。第二个不同的是,只要目标成功产生,那么,产生最终目标过程中,所产生的中间目标文件会被以rm-f删除。

通常,一个被makefile指定成目标或是依赖目标的文件不能被当作中介。然而,你可以明显地说明一个文件或是目标是中介目标,你可以使用伪目标.INTERMEDIATE来强制声明。(如:.INTERMEDIATE:mid)

你也可以阻止make自动删除中间目标,要做到这一点,你可以使用伪目标.SECONDARY来强制声明(如:.SECONDARY:sec)。你还可以把你的目标,以模式的方式来指定(如:%.o)成伪目标.PRECIOUS的依赖目标,以保存被隐含规则所生成的中间文件。

隐含规则链中,禁止同一个目标出现两次或两次以上,这样一来,就可防止在make自动推导时出现无限递归的情况。

Make会优化一些特殊的隐含规则,而不生成中间文件。如,从文件foo.c生成目标程序foo,按道理,make会编译生成中间文件foo.o,然后链接成foo,但在实际情况下,这一动作可以被一条cc的命令完成(cc–o foo foo.c),于是优化过的规则就不会生成中间文件。

定义模式规则

你可以使用模式规则来定义一个隐含规则。一个模式规则就好像一个一般的规则,只是在规则中,目标的定义需要有”%”字符。”%”的意思是表示一个或多个任意字符。在依赖目标中同样可以使用”%”,只是依赖目标中的”%”的取值,取决于其目标。

有一点需要注意的是,”%”的展开发生在变量和函数的展开之后,变量和函数的展开发生在make载入Makefile时,而模式规则中的”%”则发生在运行时。

1、模式规则介绍
模式规则中,至少在规则的目标定义中要包含”%”,否则,就是一般的规则。目标中的”%”定义表示对文件名的匹配,”%”表示长度任意的非空字符串。例如:”%.c”表示以”.c”结尾的文件名(文件名的长度至少为3),而”s.%.c”则表示以”s.”开头,”.c”结尾的文件名(文件名的长度至少为5)。

如果”%”定义在目标中,那么,目标中的”%”的值决定了依赖目标中的”%”的值,也就是说,目标中的模式的”%”决定了依赖目标中”%”的样子。例如有一个模式规则如下:

1
%.o:%.c;<command......>

其含义是,指出了怎么从所有的[.c]文件生成相应的[.o]文件的规则。如果要生成的目标是”a.o b.o”,那么”%c”就是”a.c b.c”。

一旦依赖目标中的”%”模式被确定,那么,make会被要求去匹配当前目录下所有的文件名,一旦找到,make就会规则下的命令,所以,在模式规则中,目标可能会是多个的,如果有模式匹配出多个目标,make就会产生所有的模式目标,此时,make关心的是依赖的文件名和生成目标的命令这两件事。

2、模式规则示例
下面这个例子表示了,把所有的[.c]文件都编译成[.o]文件.

1
2
3
%.o:%.c

$(CC)-c $(CFLAGS) $(CPPFLAGS)$< -o $@

其中,”$@”表示所有的目标的挨个值,”$<”表示了所有依赖目标的挨个值。这些奇怪的变量我们叫”自动化变量”,后面会详细讲述。

下面的这个例子中有两个目标是模式的:

1
2
3
%.tab.c %.tab.h:%.y

bison -d $<

这条规则告诉make把所有的[.y]文件都以”bison -d .y”执行,然后生成”.tab.c”和”.tab.h”文件。(其中,”“表示一个任意字符串)。如果我们的执行程序”foo”依赖于文件”parse.tab.o”和”scan.o”,并且文件”scan.o”依赖于文件”parse.tab.h”,如果”parse.y”文件被更新了,那么根据上述的规则,”bison -d parse.y”就会被执行一次,于是,”parse.tab.o”和”scan.o”的依赖文件就齐了。(假设,”parse.tab.o”由”parse.tab.c”生成,和”scan.o”由”scan.c”生成,而”foo”由”parse.tab.o”和”scan.o”链接生成,而且foo和其[.o]文件的依赖关系也写好,那么,所有的目标都会得到满足)

3、自动化变量
在上述的模式规则中,目标和依赖文件都是一系例的文件,那么我们如何书写一个命令来完成从不同的依赖文件生成相应的目标?因为在每一次的对模式规则的解析时,都会是不同的目标和依赖文件。

自动化变量就是完成这个功能的。在前面,我们已经对自动化变量有所提涉,相信你看到这里已对它有一个感性认识了。所谓自动化变量,就是这种变量会把模式中所定义的一系列的文件自动地挨个取出,直至所有的符合模式的文件都取完了。这种自动化变量只应出现在规则的命令中。

下面是所有的自动化变量及其说明:

$@表示规则中的目标文件集。在模式规则中,如果有多个目标,那么,”$@”就是匹配于目标中模式定义的集合。

$%仅当目标是函数库文件中,表示规则中的目标成员名。例如,如果一个目标是”foo.a(bar.o)”,那么,”$%”就是”bar.o”,”$@”就是”foo.a”。如果目标不是函数库文件(Unix下是[.a],Windows下是[.lib]),那么,其值为空。

$<依赖目标中的第一个目标名字。如果依赖目标是以模式(即”%”)定义的,那么”$<”将是符合模式的一系列的文件集。注意,其是一个一个取出来的。

$?所有比目标新的依赖目标的集合。以空格分隔。

$^所有的依赖目标的集合。以空格分隔。如果在依赖目标中有多个重复的,那个这个变量会去除重复的依赖目标,只保留一份。

$+这个变量很像”$^”,也是所有依赖目标的集合。只是它不去除重复的依赖目标。

$*这个变量表示目标模式中”%”及其之前的部分。如果目标是”dir/a.foo.b”,并且目标的模式是”a.%.b”,那么,$*的值就是”dir/a.foo”。这个变量对于构造有关联的文件名是比较有较。如果目标中没有模式的定义,那么$*也就不能被推导出,但是,如果目标文件的后缀是make所识别的,那么$*就是除了后缀的那一部分。例如:如果目标是”foo.c”,因为”.c”是make所能识别的后缀名,所以,$*的值就是”foo”。这个特性是GNUmake的,很有可能不兼容于其它版本的make,所以,你应该尽量避免使用$*,除非是在隐含规则或是静态模式中。如果目标中的后缀是make所不能识别的,那么$*就是空值。

当你希望只对更新过的依赖文件进行操作时,$?在显式规则中很有用,例如,假设有一个函数库文件叫”lib”,其由其它几个object文件更新。那么把object文件打包的比较有效率的Makefile规则是:

1
2
3
lib:foo.o bar.o lose.owin.o

ar r lib $?

在上述所列出来的自动量变量中。四个变量($@$<$%$*)在扩展时只会有一个文件,而另三个的值是一个文件列表。这七个自动化变量还可以取得文件的目录名或是在当前目录下的符合模式的文件名,只需要搭配上”D”或”F”字样。这是GNUmake中老版本的特性,在新版本中,我们使用函数”dir”或”notdir”就可以做到了。”D”的含义就是Directory,就是目录,”F”的含义就是File,就是文件。

下面是对于上面的七个变量分别加上”D”或是”F”的含义:

$(@D)表示$@的目录部分(不以斜杠作为结尾),如果$@值是”dir/foo.o”,那么$(@D)就是”dir”,而如果$@中没有包含斜杠的话,其值就是”.”(当前目录)。

$(@F)表示$@的文件部分,如果$@值是”dir/foo.o”,那么$(@F)就是”foo.o”,$(@F)相当于函数$(notdir$@)

$(*D)$(*F)和上面所述的同理,也是取文件的目录部分和文件部分。对于上面的那个例子,$(*D)返回”dir”,而$(*F)返回”foo”

$(%D)$(%F)分别表示了函数包文件成员的目录部分和文件部分。这对于形同”archive(member)”形式的目标中的”member”中包含了不同的目录很有用。

$(<D)$(<F)分别表示依赖文件的目录部分和文件部分。

$(^D)$(^F)分别表示所有依赖文件的目录部分和文件部分。(无相同的)

$(+D)$(+F)分别表示所有依赖文件的目录部分和文件部分。(可以有相同的)

$(?D)$(?F)分别表示被更新的依赖文件的目录部分和文件部分。

最后想提醒一下的是,对于$<,为了避免产生不必要的麻烦,我们最好给$后面的那个特定字符都加上圆括号,比如,$(<)就要比$<要好一些。

还得要注意的是,这些变量只使用在规则的命令中,而且一般都是”显式规则”和”静态模式规则”(参见前面”书写规则”一章)。其在隐含规则中并没有意义。

4、模式的匹配
一般来说,一个目标的模式有一个有前缀或是后缀的”%”,或是没有前后缀,直接就是一个”%”。因为”%”代表一个或多个字符,所以在定义好了的模式中,我们把”%”所匹配的内容叫做”茎”,例如”%.c”所匹配的文件”test.c”中”test”就是”茎”。因为在目标和依赖目标中同时有”%”时,依赖目标的”茎”会传给目标,当做目标中的”茎”。

当一个模式匹配包含有斜杠(实际也不经常包含)的文件时,那么在进行模式匹配时,目录部分会首先被移开,然后进行匹配,成功后,再把目录加回去。在进行”茎”的传递时,我们需要知道这个步骤。例如有一个模式”e%t”,文件”src/eat”匹配于该模式,于是”src/a”就是其”茎”,如果这个模式定义在依赖目标中,而被依赖于这个模式的目标中又有个模式”c%r”,那么,目标就是”src/car”。(”茎”被传递)

5、重载内建隐含规则
你可以重载内建的隐含规则(或是定义一个全新的),例如你可以重新构造和内建隐含规则不同的命令,如:

1
2
3
%.o:%.c

$(CC) -c $(CPPFLAGS) $(CFLAGS)-D $(date)

你可以取消内建的隐含规则,只要不在后面写命令就行。如:
1
%.o:%.s

同样,你也可以重新定义一个全新的隐含规则,其在隐含规则中的位置取决于你在哪里写下这个规则。朝前的位置就靠前。

6、老式风格的”后缀规则”
后缀规则是一个比较老式的定义隐含规则的方法。后缀规则会被模式规则逐步地取代。因为模式规则更强更清晰。为了和老版本的Makefile兼容,GNUmake同样兼容于这些东西。后缀规则有两种方式:”双后缀”和”单后缀”。

双后缀规则定义了一对后缀:目标文件的后缀和依赖目标(源文件)的后缀。如”.c.o”相当于”%o:%c”。单后缀规则只定义一个后缀,也就是源文件的后缀。如”.c”相当于”%:%.c”。

后缀规则中所定义的后缀应该是make所认识的,如果一个后缀是make所认识的,那么这个规则就是单后缀规则,而如果两个连在一起的后缀都被make所认识,那就是双后缀规则。例如:”.c”和”.o”都是make所知道。因而,如果你定义了一个规则是”.c.o”那么其就是双后缀规则,意义就是”.c”是源文件的后缀,”.o”是目标文件的后缀。如下示例:

1
2
3
.c.o:

$(CC) -c $(CFLAGS) $(CPPFLAGS)-o $@ $<

后缀规则不允许任何的依赖文件,如果有依赖文件的话,那就不是后缀规则,那些后缀统统被认为是文件名,如:
1
2
3
.c.o:foo.h

$(CC) -c $(CFLAGS) $(CPPFLAGS)-o $@ $<

这个例子,就是说,文件”.c.o”依赖于文件”foo.h”,而不是我们想要的这样:
1
2
3
%.o:%.c foo.h

$(CC) -c $(CFLAGS) $(CPPFLAGS)-o $@ $<

后缀规则中,如果没有命令,那是毫无意义的。因为他也不会移去内建的隐含规则。

而要让make知道一些特定的后缀,我们可以使用伪目标”.SUFFIXES”来定义或是删除,如:

1
.SUFFIXES:.hack.win

把后缀.hack和.win加入后缀列表中的末尾。

.SUFFIXES:#删除默认的后缀

.SUFFIXES:.c .o .h#定义自己的后缀

先清楚默认后缀,后定义自己的后缀列表。

make的参数”-r”或”-no-builtin-rules”也会使用得默认的后缀列表为空。而变量”SUFFIXE”被用来定义默认的后缀列表,你可以用”.SUFFIXES”来改变后缀列表,但请不要改变变量”SUFFIXE”的值。

7、隐含规则搜索算法
比如我们有一个目标叫T。下面是搜索目标T的规则的算法。请注意,在下面,我们没有提到后缀规则,原因是,所有的后缀规则在Makefile被载入内存时,会被转换成模式规则。如果目标是”archive(member)”的函数库文件模式,那么这个算法会被运行两次,第一次是找目标T,如果没有找到的话,那么进入第二次,第二次会把”member”当作T来搜索。

1、把T的目录部分分离出来。叫D,而剩余部分叫N。(如:如果T是”src/foo.o”,那么,D就是”src/“,N就是”foo.o”)

2、创建所有匹配于T或是N的模式规则列表。

3、如果在模式规则列表中有匹配所有文件的模式,如”%”,那么从列表中移除其它的模式。

4、移除列表中没有命令的规则。

5、对于第一个在列表中的模式规则:

1)推导其”茎”S,S应该是T或是N匹配于模式中”%”非空的部分。

2)计算依赖文件。把依赖文件中的”%”都替换成”茎”S。如果目标模式中没有包含斜框字符,而把D加在第一个依赖文件的开头。

3)测试是否所有的依赖文件都存在或是理当存在。(如果有一个文件被定义成另外一个规则的目标文件,或者是一个显式规则的依赖文件,那么这个文件就叫”理当存在”)

4)如果所有的依赖文件存在或是理当存在,或是就没有依赖文件。那么这条规则将被采用,退出该算法。

6、如果经过第5步,没有模式规则被找到,那么就做更进一步的搜索。对于存在于列表中的第一个模式规则:

1)如果规则是终止规则,那就忽略它,继续下一条模式规则。

2)计算依赖文件。(同第5步)

3)测试所有的依赖文件是否存在或是理当存在。

4)对于不存在的依赖文件,递归调用这个算法查找他是否可以被隐含规则找到。

5)如果所有的依赖文件存在或是理当存在,或是就根本没有依赖文件。那么这条规则被采用,退出该算法。

7、如果没有隐含规则可以使用,查看”.DEFAULT”规则,如果有,采用,把”.DEFAULT”的命令给T使用。

一旦规则被找到,就会执行其相当的命令,而此时,我们的自动化变量的值才会生成。

使用make更新函数库文件

函数库文件也就是对Object文件(程序编译的中间文件)的打包文件。在Unix下,一般是由命令”ar”来完成打包工作。

函数库文件的成员

一个函数库文件由多个文件组成。你可以以如下格式指定函数库文件及其组成:

1
archive(member)

这个不是一个命令,而一个目标和依赖的定义。一般来说,这种用法基本上就是为了”ar”命令来服务的。如:
1
2
3
foolib(hack.o):hack.o

ar cr foolib hack.o

如果要指定多个member,那就以空格分开,如:
1
foolib(hack.o kludge.o)

其等价于:
1
foolib(hack.o) foolib(kludge.o)

你还可以使用Shell的文件通配符来定义,如:
1
foolib(*.o)

函数库成员的隐含规则

当make搜索一个目标的隐含规则时,一个特殊的特性是,如果这个目标是”a(m)”形式的,其会把目标变成”(m)”。于是,如果我们的成员是”%.o”的模式定义,并且如果我们使用”make foo.a(bar.o)”的形式调用Makefile时,隐含规则会去找”bar.o”的规则,如果没有定义bar.o的规则,那么内建隐含规则生效,make会去找bar.c文件来生成bar.o,如果找得到的话,make执行的命令大致如下:

1
2
3
4
5
cc-c bar.c -o bar.o

ar r foo.abar.o

rm -f bar.o

还有一个变量要注意的是”$%”,这是专属函数库文件的自动化变量,有关其说明请参见”自动化变量”一节。

函数库文件的后缀规则

你可以使用”后缀规则”和”隐含规则”来生成函数库打包文件,如:

1
2
3
4
5
6
7
.c.a:

$(CC) $(CFLAGS) $(CPPFLAGS)-c $< -o $*.o

$(AR) r $@ $*.o

$(RM) $*.o

其等效于:
1
2
3
4
5
6
7
(%.o):%.c

$(CC) $(CFLAGS) $(CPPFLAGS)-c $< -o $*.o

$(AR) r $@ $*.o

$(RM) $*.o

注意事项

在进行函数库打包文件生成时,请小心使用make的并行机制(”-j”参数)。如果多个ar命令在同一时间运行在同一个函数库打包文件上,就很有可以损坏这个函数库文件。所以,在make未来的版本中,应该提供一种机制来避免并行操作发生在函数打包文件上。但就目前而言,你还是应该不要尽量不要使用”-j”参数。

后序

终于到写结束语的时候了,以上基本上就是GNUmake的Makefile的所有细节了。其它的产商的make基本上也就是这样的,无论什么样的make,都是以文件的依赖性为基础的,其基本是都是遵循一个标准的。这篇文档中80%的技术细节都适用于任何的make,我猜测”函数”那一章的内容可能不是其它make所支持的,而隐含规则方面,我想不同的make会有不同的实现,我没有精力来查看GNU的make和VC的nmake、BCB的make,或是别的UNIX下的make有些什么样的差别,一是时间精力不够,二是因为我基本上都是在Unix下使用make,以前在SCOUnix和IBM的AIX,现在在Linux、Solaris、HP-UX、AIX和Alpha下使用,Linux和Solaris下更多一点。不过,我可以肯定的是,在Unix下的make,无论是哪种平台,几乎都使用了Richard Stallman开发的make和cc/gcc的编译器,而且,基本上都是GNU的make(公司里所有的UNIX机器上都被装上了GNU的东西,所以,使用GNU的程序也就多了一些)。GNU的东西还是很不错的,特别是使用得深了以后,越来越觉得GNU的软件的强大,也越来越觉得GNU的在操作系统中(主要是Unix,甚至Windows)”杀伤力”。

对于上述所有的make的细节,我们不但可以利用make这个工具来编译我们的程序,还可以利用make来完成其它的工作,因为规则中的命令可以是任何Shell之下的命令,所以,在Unix下,你不一定只是使用程序语言的编译器,你还可以在Makefile中书写其它的命令,如:tar、awk、mail、sed、cvs、compress、ls、rm、yacc、rpm、ftp……等等,等等,来完成诸如”程序打包”、”程序备份”、”制作程序安装包”、”提交代码”、”使用程序模板”、”合并文件”等等五花八门的功能,文件操作,文件管理,编程开发设计,或是其它一些异想天开的东西。比如,以前在书写银行交易程序时,由于银行的交易程序基本一样,就见到有人书写了一些交易的通用程序模板,在该模板中把一些网络通讯、数据库操作的、业务操作共性的东西写在一个文件中,在这些文件中用些诸如”@@@N、###N”奇怪字串标注一些位置,然后书写交易时,只需按照一种特定的规则书写特定的处理,最后在make时,使用awk和sed,把模板中的”@@@N、###N”等字串替代成特定的程序,形成C文件,然后再编译。这个动作很像数据库的”扩展C”语言(即在C语言中用”EXEC SQL”的样子执行SQL语句,在用cc/gcc编译之前,需要使用”扩展C”的翻译程序,如cpre,把其翻译成标准C)。如果

你在使用make时有一些更为绝妙的方法,请记得告诉我啊。

回头看看整篇文档,不觉记起几年前刚刚开始在Unix下做开发的时候,有人问我会不会写Makefile时,我两眼发直,根本不知道在说什么。一开始看到别人在vi中写完程序后输入”!make”时,还以为是vi的功能,后来才知道有一个Makefile在作怪,于是上网查啊查,那时又不愿意看英文,发现就根本没有中文的文档介绍Makefile,只得看别人写的Makefile,自己瞎碰瞎搞才积累了一点知识,但在很多地方完全是知其然不知所以然。后来开始从事UNIX下产品软件的开发,看到一个400人年,近200万行代码的大工程,发现要编译这样一个庞然大物,如果没有Makefile,那会是多么恐怖的一样事啊。于是横下心来,狠命地读了一堆英文文档,才觉得对其掌握了。但发现目前网上对Makefile介绍的文章还是少得那么的可怜,所以想写这样一篇文章,共享给大家,希望能对各位有所帮助。

现在我终于写完了,看了看文件的创建时间,这篇技术文档也写了两个多月了。发现,自己知道是一回事,要写下来,跟别人讲述又是另外一回事,而且,现在越来越没有时间专研技术细节,所以在写作时,发现在阐述一些细节问题时很难做到严谨和精练,而且对先讲什么后讲什么不是很清楚,所以,还是参考了一些国外站点上的资料和题纲,以及一些技术书籍的语言风格,才得以完成。整篇文档的提纲是基于GNU的Makefile技术手册的提纲来书写的,并结合了自己的工作经验,以及自己的学习历程。因为从来没有写过这么长,这么细的文档,所以一定会有很多地方存在表达问题,语言歧义或是错误。因些,我迫切地得等待各位给我指证和建议,以及任何的反馈。

我欢迎任何形式的交流,无论是讨论技术还是管理,或是其它海阔天空的东西。除了政治和娱乐新闻我不关心,其它只要积极向上的东西我都欢迎!

最最后,我还想介绍一下make程序的设计开发者。

首当其冲的是:Richard Stallman

开源软件的领袖和先驱,从来没有领过一天工资,从来没有使用过Windows操作系统。对于他的事迹和他的软件以及他的思想,我无需说过多的话,相信大家对这个人并不比我陌生,这是他的主页:http://www.stallman.org/。

第二位是:Roland McGrath

个人主页是:http://www.frob.com/~roland/
下面是他的一些事迹:

1)合作编写了并维护GNUmake。

2)和ThomasBushnell一同编写了GNUHurd。

3)编写并维护着GNUClibrary。

4)合作编写并维护着部分的GNUEmacs。

在此,向这两位开源项目的斗士致以最真切的敬意

Leetcode201. Bitwise AND of Numbers Range

Given two integers left and right that represent the range [left, right], return the bitwise AND of all numbers in this range, inclusive.

Example 1:

1
2
Input: left = 5, right = 7
Output: 4

Example 2:

1
2
Input: left = 0, right = 0
Output: 0

Example 3:

1
2
Input: left = 1, right = 2147483647
Output: 0

我们先从题目中给的例子来分析,[5, 7]里共有三个数字,分别写出它们的二进制为:

101  110  111

相与后的结果为100,仔细观察我们可以得出,最后的数是该数字范围内所有的数的左边共同的部分,如果上面那个例子不太明显,我们再来看一个范围[26, 30],它们的二进制如下:

11010  11011  11100  11101  11110

发现了规律后,我们只要写代码找到左边公共的部分即可,我们可以从建立一个32位都是1的mask,然后每次向左移一位,比较m和n是否相同,不同再继续左移一位,直至相同,然后把m和mask相与就是最终结果,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int rangeBitwiseAnd(int left, int right) {
int i = 0;
while(left > 0 && right > 0) {
if (left == right)
break;
left >>= 1;
right >>= 1;
i ++;
}
return left << i;
}
};

Leetcode202. Happy Number

Write an algorithm to determine if a number n is “happy”.

A happy number is a number defined by the following process: Starting with any positive integer, replace the number by the sum of the squares of its digits, and repeat the process until the number equals 1 (where it will stay), or it loops endlessly in a cycle which does not include 1. Those numbers for which this process ends in 1 are happy numbers.

Return True if n is a happy number, and False if not.

Example:

1
2
3
4
5
6
7
Input: 19
Output: true
Explanation:
12 + 92 = 82
82 + 22 = 68
62 + 82 = 100
12 + 02 + 02 = 1

简单直白的做法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
bool isHappy(int n) {
int cycle = 100, sum;
vector<int> nums;
while(cycle--) {
int temp = n;
nums.clear();
while(temp) {
nums.push_back(temp%10);
temp /= 10;
}
sum = 0;
for(int i : nums)
sum += i*i;
if(sum == 1)
return true;
n = sum;
}
return false;
}
};

对于一个数n,如果n不是Happy Number,那么在求n各数位平方和以及求在n之后的每个数的各数位平方和的过程中,一定会产生循环,利用这个性质:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool isHappy(int n) {
map<int,int> thash;
while(n && !thash[n]){
thash[n] = n;
int temp = 0, low;
while(n) {
low = n % 10;
temp += low * low;
n /= 10;
}
n = temp;
}
if(n == 1){
return true;
}
return false;
}
};

Leetcode203. Remove Linked List Elements

Remove all elements from a linked list of integers that have value val.

Example:

1
2
Input:  1->2->6->3->4->5->6, val = 6
Output: 1->2->3->4->5

删除列表中对应val的节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode *ptr = new ListNode(-1);
ListNode *prev, *cur;
ptr->next = head;
cur = head;
prev = ptr;
while(cur != NULL) {
if(cur->val == val)
prev->next = cur->next;
else
prev = cur;
cur = cur->next;
}
return ptr->next;
}
};

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
class Solution {
public:
ListNode* removeElements(ListNode* head, int val)
{
vector<int> v;
ListNode* a = head;
while(a != NULL) {
v.push_back(a->val);
a = a->next;
}
vector<int> y;
int s = v.size();
for(int i = 0; i < s; i ++) {
if(v[i] != val) {
y.push_back(v[i]);
}
}

reverse(y.begin(),y.end());
int n = y.size();
ListNode* z = new ListNode(0);
ListNode* p = z;
for(int i = 0; i < n; i ++) {
p->next = new ListNode(y.back());
y.pop_back();
p = p->next;
}
return z->next;
}
};

Leetcode204. Count Primes

Count the number of prime numbers less than a non-negative number, n.

Example:

1
2
3
Input: 10
Output: 4
Explanation: There are 4 prime numbers less than 10, they are 2, 3, 5, 7.

判断一定范围内有几个合数,下边这个简单的做法会超时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:

bool isprimes(int n) {
for(int i = 2; i <= n/2; i ++){
if(n % i == 0)
return false;
}
return true;
}

int countPrimes(int n) {
int sum = 0;
for(int i = 2; i < n; i ++)
if(isprimes(i))
sum ++;
return sum;
}
};

所以要用其他的方法,比如素数筛
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:

int countPrimes(int n) {
int sum = 0;
if(n < 2)
return 0;
int nn = sqrt(n);
bool prime[n];
for(int i = 0; i < n; i ++)
prime[i] = true;
prime[0] = prime[1] = false;
for(int i = 2; i <= nn; i ++) {
for(int j = i*2; j < n; j += i) {
prime[j] = false;
}
}
for(int i = 2; i < n; i ++) {
if(prime[i])
sum ++;
}
return sum;
}
};

Leetcode205. Isomorphic Strings

Given two strings s and t, determine if they are isomorphic.

Two strings are isomorphic if the characters in s can be replaced to get t.

All occurrences of a character must be replaced with another character while preserving the order of characters. No two characters may map to the same character but a character may map to itself.

Example 1:

1
2
Input: s = "egg", t = "add"
Output: true

Example 2:
1
2
Input: s = "foo", t = "bar"
Output: false

Example 3:
1
2
Input: s = "paper", t = "title"
Output: true

这道题让我们求同构字符串,就是说原字符串中的每个字符可由另外一个字符替代,可以被其本身替代,相同的字符一定要被同一个字符替代,且一个字符不能被多个字符替代,即不能出现一对多的映射。根据一对一映射的特点,需要用两个 HashMap 分别来记录原字符串和目标字符串中字符出现情况,由于 ASCII 码只有 256 个字符,所以可以用一个 256 大小的数组来代替 HashMap,并初始化为0,遍历原字符串,分别从源字符串和目标字符串取出一个字符,然后分别在两个数组中查找其值,若不相等,则返回 false,若相等,将其值更新为 i + 1,因为默认的值是0,所以更新值为 i + 1,这样当 i=0 时,则映射为1,如果不加1的话,那么就无法区分是否更新了。
1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
bool isIsomorphic(string s, string t) {
int m1[256] = {0}, m2[256] = {0}, n = s.size();
for (int i = 0; i < n; ++i) {
if (m1[s[i]] != m2[t[i]]) return false;
m1[s[i]] = i + 1;
m2[t[i]] = i + 1;
}
return true;
}
};

另一种使用两个unorder_map
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool isIsomorphic(string s, string t) {
int ssize = s.size(), tsize = t.size();
if(ssize != tsize)
return false;
map<char, char> mp, mp2;
for(int i = 0; i < ssize; i ++) {
if(mp.find(s[i]) == mp.end())
mp[s[i]] = t[i];
else if(mp[s[i]] != t[i])
return false;
if(mp2.find(t[i]) == mp2.end())
mp2[t[i]] = s[i];
else if(mp2[t[i]] != s[i])
return false;
}
return true;
}
};

Leetcode206. Reverse Linked List

Reverse a singly linked list.

Example:

Input: 1->2->3->4->5->NULL
Output: 5->4->3->2->1->NULL

假设存在链表 1 -> 2 -> 3 -> NULL,我们想要把它改成 NULL <- 1 <- 2 <- 3。

在遍历列表时,将当前节点的 next 指针改为指向前一个元素。由于节点没有引用其上一个节点,因此必须事先存储其前一个元素。在更改引用之前,还需要另一个指针来存储下一个节点。不要忘记在最后返回新的头引用!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev,*next,*curr;
curr=head;
prev = NULL;
while(curr!=NULL){
next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
return prev;
}
};

Leetcode207. Course Schedule

There are a total of numCourses courses you have to take, labeled from 0 to numCourses-1.

Some courses may have prerequisites, for example to take course 0 you have to first take course 1, which is expressed as a pair: [0,1]

Given the total number of courses and a list of prerequisite pairs, is it possible for you to finish all courses?

Example 1:

1
2
3
4
Input: numCourses = 2, prerequisites = [[1,0]]
Output: true
Explanation: There are a total of 2 courses to take.
To take course 1 you should have finished course 0. So it is possible.

Example 2:
1
2
3
4
5
Input: numCourses = 2, prerequisites = [[1,0],[0,1]]
Output: false
Explanation: There are a total of 2 courses to take.
To take course 1 you should have finished course 0, and to take course 0 you should
also have finished course 1. So it is impossible.

Constraints:

  • The input prerequisites is a graph represented by a list of edges, not adjacency matrices. Read more about how a graph is represented.
  • You may assume that there are no duplicate edges in the input prerequisites.
  • 1 <= numCourses <= 10^5

定义二维数组 graph 来表示这个有向图,一维数组 in 来表示每个顶点的入度。开始先根据输入来建立这个有向图,并将入度数组也初始化好。然后定义一个 queue 变量,将所有入度为0的点放入队列中,然后开始遍历队列,从 graph 里遍历其连接的点,每到达一个新节点,将其入度减一,如果此时该点入度为0,则放入队列末尾。直到遍历完队列中所有的值,若此时还有节点的入度不为0,则说明环存在,返回 false,反之则返回 true。

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
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> graph(numCourses, vector<int>(numCourses, 0));
int in[numCourses];
for(int i = 0; i < numCourses; i ++)
in[i] = 0;
for(auto i : prerequisites) {
graph[i[1]][i[0]] = 1;
in[i[0]] ++;
}
queue<int> q;
for(int i = 0; i < numCourses; i ++)
if(in[i] == 0)
q.push(i);
while(!q.empty()) {
int temp = q.front();
q.pop();
for(int i = 0; i < numCourses; i ++) {
if(graph[temp][i] == 1) {
in[i]--;
if(in[i] == 0)
q.push(i);
}
}
}
for(int i = 0; i < numCourses; i ++)
if(in[i] != 0)
return false;
return true;
}
};

Leetcode208. Implement Trie (Prefix Tree)

Implement a trie with insert, search, and startsWith methods.

Example:

1
2
3
4
5
6
7
8
Trie trie = new Trie();

trie.insert("apple");
trie.search("apple"); // returns true
trie.search("app"); // returns false
trie.startsWith("app"); // returns true
trie.insert("app");
trie.search("app"); // returns true

Note:

You may assume that all inputs are consist of lowercase letters a-z.
All inputs are guaranteed to be non-empty strings.

实现一个字典树即可

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
class Trie {
private:
struct Node{
Node *next[26];
bool isleaf;
Node(){
for(int i=0;i<26;i++)
next[i]=NULL;
isleaf=false;
}
};
Node* head;

public:
/** Initialize your data structure here. */
Trie() {
head = new Node();
}

/** Inserts a word into the trie. */
void insert(string word) {
Node* current = head;
for(int i=0;i<word.size();i++){
int index = word[i]-'a';
if(current->next[index]==NULL){
current->next[index] = new Node();
}
current = current->next[index];
}
current->isleaf = true;
}

/** Returns if the word is in the trie. */
bool search(string word) {
Node* current = head;
for(int i=0;i<word.size();i++){
int index = word[i]-'a';
if(current->next[index]==NULL)
return false;
else
current = current->next[index];
}
return current->isleaf;
}

/** Returns if there is any word in the trie that starts with the given prefix. */
bool startsWith(string prefix) {
Node* current = head;
for(int i=0;i<prefix.size();i++){
int index = prefix[i]-'a';
if(current->next[index]==NULL)
return false;
else
current = current->next[index];
}
return true;
}
};

这个实现的内存占用有些高了,抄一下其他人的
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
class TrieNode {

// R links to node children
private TrieNode[] links;

private final int R = 26;

private boolean isEnd;

public TrieNode() {
links = new TrieNode[R];
}

public boolean containsKey(char ch) {
return links[ch -'a'] != null;
}
public TrieNode get(char ch) {
return links[ch -'a'];
}
public void put(char ch, TrieNode node) {
links[ch -'a'] = node;
}
public void setEnd() {
isEnd = true;
}
public boolean isEnd() {
return isEnd;
}
}

Insertion of a key to a trie
We insert a key by searching into the trie. We start from the root and search a link, which corresponds to the first key character. There are two cases :

A link exists. Then we move down the tree following the link to the next child level. The algorithm continues with searching for the next key character.
A link does not exist. Then we create a new node and link it with the parent’s link matching the current key character. We repeat this step until we encounter the last character of the key, then we mark the current node as an end node and the algorithm finishes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Trie {
private TrieNode root;

public Trie() {
root = new TrieNode();
}

// Inserts a word into the trie.
public void insert(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char currentChar = word.charAt(i);
if (!node.containsKey(currentChar)) {
node.put(currentChar, new TrieNode());
}
node = node.get(currentChar);
}
node.setEnd();
}
}

Search for a key in a trie
Each key is represented in the trie as a path from the root to the internal node or leaf. We start from the root with the first key character. We examine the current node for a link corresponding to the key character. There are two cases :

A link exist. We move to the next node in the path following this link, and proceed searching for the next key character.

A link does not exist. If there are no available key characters and current node is marked as isEnd we return true. Otherwise there are possible two cases in each of them we return false :

There are key characters left, but it is impossible to follow the key path in the trie, and the key is missing.
No key characters left, but current node is not marked as isEnd. Therefore the search key is only a prefix of another key in the trie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Trie {
...

// search a prefix or whole key in trie and
// returns the node where search ends
private TrieNode searchPrefix(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char curLetter = word.charAt(i);
if (node.containsKey(curLetter)) {
node = node.get(curLetter);
} else {
return null;
}
}
return node;
}

// Returns if the word is in the trie.
public boolean search(String word) {
TrieNode node = searchPrefix(word);
return node != null && node.isEnd();
}
}

Search for a key prefix in a trie
The approach is very similar to the one we used for searching a key in a trie. We traverse the trie from the root, till there are no characters left in key prefix or it is impossible to continue the path in the trie with the current key character. The only difference with the mentioned above search for a key algorithm is that when we come to an end of the key prefix, we always return true. We don’t need to consider the isEnd mark of the current trie node, because we are searching for a prefix of a key, not for a whole key.

1
2
3
4
5
6
7
8
9
10
class Trie {
...

// Returns if there is any word in the trie
// that starts with the given prefix.
public boolean startsWith(String prefix) {
TrieNode node = searchPrefix(prefix);
return node != null;
}
}

Leetcode209. Minimum Size Subarray Sum

Given an array of positive integers nums and a positive integer target, return the minimal length of a contiguous subarray [numsl, numsl+1, …, numsr-1, numsr] of which the sum is greater than or equal to target. If there is no such subarray, return 0 instead.

Example 1:

1
2
3
Input: target = 7, nums = [2,3,1,2,4,3]
Output: 2
Explanation: The subarray [4,3] has the minimal length under the problem constraint.

Example 2:

1
2
Input: target = 4, nums = [1,4,4]
Output: 1

Example 3:

1
2
Input: target = 11, nums = [1,1,1,1,1,1,1,1]
Output: 0

需要定义两个指针 left 和 right,分别记录子数组的左右的边界位置,然后让 right 向右移,直到子数组和大于等于给定值或者 right 达到数组末尾,此时更新最短距离,并且将 left 像右移一位,然后再 sum 中减去移去的值,然后重复上面的步骤,直到 right 到达末尾,且 left 到达临界位置,即要么到达边界,要么再往右移动,和就会小于给定值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int len = nums.size();
if (len == 0)
return 0;
int left = 0, right = 0;
int sum = 0, res = INT_MAX;

while(right < len) {
while(right < len && sum < target) {
sum += nums[right++];
}
while(sum >= target) {
res = min(res, right-left);
sum -= nums[left++];
}
}

return res == INT_MAX ? 0 : res;
}
};

Leetcode210. Course Schedule II

There are a total of numCourses courses you have to take, labeled from 0 to numCourses - 1. You are given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course bi first if you want to take course ai.

For example, the pair [0, 1], indicates that to take course 0 you have to first take course 1.
Return the ordering of courses you should take to finish all courses. If there are many valid answers, return any of them. If it is impossible to finish all courses, return an empty array.

Example 1:

1
2
3
Input: numCourses = 2, prerequisites = [[1,0]]
Output: [0,1]
Explanation: There are a total of 2 courses to take. To take course 1 you should have finished course 0. So the correct course order is [0,1].

Example 2:

1
2
3
4
Input: numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]
Output: [0,2,1,3]
Explanation: There are a total of 4 courses to take. To take course 3 you should have finished both courses 1 and 2. Both courses 1 and 2 should be taken after you finished course 0.
So one correct course order is [0,1,2,3]. Another correct ordering is [0,2,1,3].

Example 3:

1
2
Input: numCourses = 1, prerequisites = []
Output: [0]

这道题我们得找出要上的课程的顺序,即有向图的拓扑排序 Topological Sort,从 queue 中每取出一个数组就将其存在结果中,最终若有向图中有环,则结果中元素的个数不等于总课程数,那我们将结果清空即可。

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
class Solution {
public:
vector<int> findOrder(int numCourses, vector<pair<int, int>>& prerequisites) {
vector<int> res;
vector<vector<int> > graph(numCourses, vector<int>(0));
vector<int> in(numCourses, 0);
for (auto &a : prerequisites) {
graph[a.second].push_back(a.first);
++in[a.first];
}
queue<int> q;
for (int i = 0; i < numCourses; ++i) {
if (in[i] == 0) q.push(i);
}
while (!q.empty()) {
int t = q.front();
res.push_back(t);
q.pop();
for (auto &a : graph[t]) {
--in[a];
if (in[a] == 0) q.push(a);
}
}
if (res.size() != numCourses) res.clear();
return res;
}
};

Leetcode212.Word Search II

Given an m x n board of characters and a list of strings words, return all words on the board.

Each word must be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once in a word.

Example 1:

1
2
Input: board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]], words = ["oath","pea","eat","rain"]
Output: ["eat","oath"]

Example 2:

1
2
Input: board = [["a","b"],["c","d"]], words = ["abcb"]
Output: []

Constraints:

  • m == board.length
  • n == board[i].length
  • 1 <= m, n <= 12
  • board[i][j] is a lowercase English letter.
  • 1 <= words.length <= 3 * 104
  • 1 <= words[i].length <= 10
  • words[i] consists of lowercase English letters.
  • All the strings of words are unique.

这道题是在之前那道 Word Search 的基础上做了些拓展,之前是给一个单词让判断是否存在,现在是给了一堆单词,让返回所有存在的单词,在这道题最开始更新的几个小时内,用 brute force 是可以通过 OJ 的,就是在之前那题的基础上多加一个 for 循环而已,但是后来出题者其实是想考察字典树的应用,所以加了一个超大的 test case,以至于 brute force 无法通过,强制我们必须要用字典树来求解。LeetCode 中有关字典树的题还有 Implement Trie (Prefix Tree) 和 Add and Search Word - Data structure design,那么我们在这题中只要实现字典树中的 insert 功能就行了,查找单词和前缀就没有必要了,然后 DFS 的思路跟之前那道 Word Search 基本相同,请参见代码如下:

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
class Solution {
public:
struct TrieNode {
TrieNode *child[26];
string str;
};
struct Trie {
TrieNode *root;
Trie() : root(new TrieNode()) {}
void insert(string s) {
TrieNode *p = root;
for (auto &a : s) {
int i = a - 'a';
if (!p->child[i]) p->child[i] = new TrieNode();
p = p->child[i];
}
p->str = s;
}
};
vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
vector<string> res;
if (words.empty() || board.empty() || board[0].empty()) return res;
vector<vector<bool>> visit(board.size(), vector<bool>(board[0].size(), false));
Trie T;
for (auto &a : words) T.insert(a);
for (int i = 0; i < board.size(); ++i) {
for (int j = 0; j < board[i].size(); ++j) {
if (T.root->child[board[i][j] - 'a']) {
search(board, T.root->child[board[i][j] - 'a'], i, j, visit, res);
}
}
}
return res;
}
void search(vector<vector<char>>& board, TrieNode* p, int i, int j, vector<vector<bool>>& visit, vector<string>& res) {
if (!p->str.empty()) {
res.push_back(p->str);
p->str.clear();
}
int d[][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
visit[i][j] = true;
for (auto &a : d) {
int nx = a[0] + i, ny = a[1] + j;
if (nx >= 0 && nx < board.size() && ny >= 0 && ny < board[0].size() && !visit[nx][ny] && p->child[board[nx][ny] - 'a']) {
search(board, p->child[board[nx][ny] - 'a'], nx, ny, visit, res);
}
}
visit[i][j] = false;
}
};

Leetcode213. House Robber II

You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed. All houses at this place are arranged in a circle. That means the first house is the neighbor of the last one. Meanwhile, adjacent houses have a security system connected, and it will automatically contact the police if two adjacent houses were broken into on the same night.

Given an integer array nums representing the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.

Example 1:

1
2
3
Input: nums = [2,3,2]
Output: 3
Explanation: You cannot rob house 1 (money = 2) and then rob house 3 (money = 2), because they are adjacent houses.

Example 2:

1
2
3
4
Input: nums = [1,2,3,1]
Output: 4
Explanation: Rob house 1 (money = 1) and then rob house 3 (money = 3).
Total amount you can rob = 1 + 3 = 4.

Example 3:

1
2
Input: nums = [0]
Output: 0

现在房子排成了一个圆圈,则如果抢了第一家,就不能抢最后一家,因为首尾相连了,所以第一家和最后一家只能抢其中的一家,或者都不抢,那这里变通一下,如果把第一家和最后一家分别去掉,各算一遍能抢的最大值,然后比较两个值取其中较大的一个即为所求。那只需参考之前的 House Robber 中的解题方法,然后调用两边取较大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() == 0)
return 0;
if (nums.size() == 1)
return nums[0];
return max(robb(nums, 0, nums.size()-1), robb(nums, 1, nums.size()));
}

int robb(vector<int>& nums, int l, int r) {
if (r-l <= 1)
return nums[l];
vector<int> dp(r, 0);

dp[l] = nums[l];
dp[l+1] = max(nums[l], nums[l+1]);
for (int i = l+2; i < r; i ++) {
dp[i] = max(dp[i-1], dp[i-2]+nums[i]);
}
return dp[r-1];
}
};

当然,我们也可以使用两个变量来代替整个 DP 数组,讲解与之前的帖子 House Robber 相同,分别维护两个变量 robEven 和 robOdd,顾名思义,robEven 就是要抢偶数位置的房子,robOdd 就是要抢奇数位置的房子。所以在遍历房子数组时,如果是偶数位置,那么 robEven 就要加上当前数字,然后和 robOdd 比较,取较大的来更新 robEven。这里就看出来了,robEven 组成的值并不是只由偶数位置的数字,只是当前要抢偶数位置而已。同理,当奇数位置时,robOdd 加上当前数字和 robEven 比较,取较大值来更新 robOdd,这种按奇偶分别来更新的方法,可以保证组成最大和的数字不相邻,最后别忘了在 robEven 和 robOdd 种取较大值返回,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() <= 1) return nums.empty() ? 0 : nums[0];
return max(rob(nums, 0, nums.size() - 1), rob(nums, 1, nums.size()));
}
int rob(vector<int> &nums, int left, int right) {
int robEven = 0, robOdd = 0;
for (int i = left; i < right; ++i) {
if (i % 2 == 0) {
robEven = max(robEven + nums[i], robOdd);
} else {
robOdd = max(robEven, robOdd + nums[i]);
}
}
return max(robEven, robOdd);
}
};

Leetcode215. Kth Largest Element in an Array

Find the kth largest element in an unsorted array. Note that it is the kth largest element in the sorted order, not the kth distinct element.

Example 1:

1
2
Input: [3,2,1,5,6,4] and k = 2
Output: 5

Example 2:

1
2
Input: [3,2,3,1,2,4,5,5,6] and k = 4
Output: 4

Note: You may assume k is always valid, 1 ≤ k ≤ array’s length.

这道题让我们求数组中第k大的数字,怎么求呢,当然首先想到的是给数组排序,然后求可以得到第k大的数字。先看一种利用 C++ 的 STL 中的集成的排序方法,不用我们自己实现,这样的话这道题只要两行就完事了,代码如下:

1
2
3
4
5
6
7
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
sort(nums.begin(), nums.end());
return nums[nums.size() - k];
}
};

下面这种解法是利用了 priority_queue 的自动排序的特性,跟上面的解法思路上没有什么区别,当然我们也可以换成 multiset 来做,一个道理,参见代码如下:

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int> q(nums.begin(), nums.end());
for (int i = 0; i < k - 1; ++i) {
q.pop();
}
return q.top();
}
};

这道题最好的解法应该是下面这种做法,用到了快速排序 Quick Sort 的思想,这里排序的方向是从大往小排。对快排不熟悉的童鞋们随意上网搜些帖子看下吧,多如牛毛啊,总有一款适合你。核心思想是每次都要先找一个中枢点 Pivot,然后遍历其他所有的数字,像这道题从大往小排的话,就把大于中枢点的数字放到左半边,把小于中枢点的放在右半边,这样中枢点是整个数组中第几大的数字就确定了,虽然左右两部分各自不一定是完全有序的,但是并不影响本题要求的结果,因为左半部分的所有值都大于右半部分的任意值,所以我们求出中枢点的位置,如果正好是 k-1,那么直接返回该位置上的数字;如果大于 k-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
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
int left = 0, right = nums.size() - 1;
while (true) {
int pos = partition(nums, left, right);
if (pos == k - 1) return nums[pos];
if (pos > k - 1) right = pos - 1;
else left = pos + 1;
}
}
int partition(vector<int>& nums, int left, int right) {
int pivot = nums[left], l = left + 1, r = right;
while (l <= r) {
if (nums[l] < pivot && nums[r] > pivot) {
swap(nums[l++], nums[r--]);
}
if (nums[l] >= pivot) ++l;
if (nums[r] <= pivot) --r;
}
swap(nums[left], nums[r]);
return r;
}
};

Leetcode216. Combination Sum III

Find all valid combinations of k numbers that sum up to n such that the following conditions are true:

  • Only numbers 1 through 9 are used.
  • Each number is used at most once.

Return a list of all possible valid combinations. The list must not contain the same combination twice, and the combinations may be returned in any order.

Example 1:

1
2
3
4
5
Input: k = 3, n = 7
Output: [[1,2,4]]
Explanation:
1 + 2 + 4 = 7
There are no other valid combinations.

Example 2:

1
2
3
4
5
6
7
Input: k = 3, n = 9
Output: [[1,2,6],[1,3,5],[2,3,4]]
Explanation:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
There are no other valid combinations.

n是k个数字之和,如果n小于0,则直接返回,如果n正好等于0,而且此时out中数字的个数正好为k,说明此时是一个正确解,将其存入结果res中,具体实现参见代码入下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<vector<int> > combinationSum3(int k, int n) {
vector<vector<int> > res;
vector<int> out;
combinationSum3DFS(k, n, 1, out, res);
return res;
}
void combinationSum3DFS(int k, int n, int level, vector<int> &out, vector<vector<int> > &res) {
if (n < 0) return;
if (n == 0 && out.size() == k) res.push_back(out);
for (int i = level; i <= 9; ++i) {
out.push_back(i);
combinationSum3DFS(k, n - i, i + 1, out, res);
out.pop_back();
}
}
};

Leetcode217. Contains Duplicate

Given an array of integers, find if the array contains any duplicates.

Your function should return true if any value appears at least twice in the array, and it should return false if every element is distinct.

Example 1:

1
2
Input: [1,2,3,1]
Output: true

Example 2:
1
2
Input: [1,2,3,4]
Output: false

Example 3:
1
2
Input: [1,1,1,3,3,4,3,2,4,2]
Output: true

构造一个set,不重复就加进去,重复返回true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool containsDuplicate(vector<int>& nums) {
set<int> s;
if(nums.size() == 0)
return false;
s.insert(nums[0]);
for(int i = 1; i < nums.size(); i ++) {
if(s.count(nums[i]))
return true;
else
s.insert(nums[i]);
}
return false;
}
};

还有一个是排序
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
bool containsDuplicate(vector<int>& nums)
{
sort(nums.begin(),nums.end());
for(int i=1;i<nums.size();i++)
{
if(nums[i]==nums[i-1])
return true;
}
return false;
}
};

Leetcode218. The Skyline Problem

A city’s skyline is the outer contour of the silhouette formed by all the buildings in that city when viewed from a distance. Now suppose you are given the locations and height of all the buildings as shown on a cityscape photo (Figure A), write a program to output the skyline formed by these buildings collectively (Figure B).

The geometric information of each building is represented by a triplet of integers [Li, Ri, Hi], where Li and Ri are the x coordinates of the left and right edge of the ith building, respectively, and Hi is its height. It is guaranteed that 0 ≤ Li, Ri ≤ INT_MAX, 0 < Hi ≤ INT_MAX, and Ri - Li > 0. You may assume all buildings are perfect rectangles grounded on an absolutely flat surface at height 0.

For instance, the dimensions of all buildings in Figure A are recorded as: [ [2 9 10], [3 7 15], [5 12 12], [15 20 10], [19 24 8] ].

The output is a list of “key points” (red dots in Figure B) in the format of [ [x1,y1], [x2, y2], [x3, y3], … ] that uniquely defines a skyline. A key point is the left endpoint of a horizontal line segment. Note that the last key point, where the rightmost building ends, is merely used to mark the termination of the skyline, and always has zero height. Also, the ground in between any two adjacent buildings should be considered part of the skyline contour.

For instance, the skyline in Figure B should be represented as:[ [2 10], [3 15], [7 12], [12 0], [15 10], [20 8], [24, 0] ].

Notes:

  • The number of buildings in any input list is guaranteed to be in the range [0, 10000].
  • The input list is already sorted in ascending order by the left x position Li.
  • The output list must be sorted by the x position.
  • There must be no consecutive horizontal lines of equal height in the output skyline. For instance, […[2 3], [4 5], [7 5], [11 5], [12 7]…] is not acceptable; the three lines of height 5 should be merged into one in the final output as such: […[2 3], [4 5], [12 7], …]

这里用到了 multiset 数据结构,其好处在于其中的元素是按堆排好序的,插入新元素进去还是有序的,而且执行删除元素也可方便的将元素删掉。这里为了区分左右边界,将左边界的高度存为负数,建立左边界和负高度的 pair,再建立右边界和高度的 pair,存入数组中,都存进去了以后,给数组按照左边界排序,这样就可以按顺序来处理那些关键的节点了。在 multiset 中放入一个0,这样在某个没有和其他建筑重叠的右边界上,就可以将封闭点存入结果 res 中。下面按顺序遍历这些关键节点,如果遇到高度为负值的 pair,说明是左边界,那么将正高度加入 multiset 中,然后取出此时集合中最高的高度,即最后一个数字,然后看是否跟 pre 相同,这里的 pre 是上一个状态的高度,初始化为0,所以第一个左边界的高度绝对不为0,所以肯定会存入结果 res 中。接下来如果碰到了一个更高的楼的左边界的话,新高度存入 multiset 的话会排在最后面,那么此时 cur 取来也跟 pre 不同,可以将新的左边界点加入结果 res。第三个点遇到绿色建筑的左边界点时,由于其高度低于红色的楼,所以 cur 取出来还是红色楼的高度,跟 pre 相同,直接跳过。下面遇到红色楼的右边界,此时首先将红色楼的高度从 multiset 中删除,那么此时 cur 取出的绿色楼的高度就是最高啦,跟 pre 不同,则可以将红楼的右边界横坐标和绿楼的高度组成 pair 加到结果 res 中,这样就成功的找到我们需要的拐点啦,后面都是这样类似的情况。当某个右边界点没有跟任何楼重叠的话,删掉当前的高度,那么 multiset 中就只剩0了,所以跟当前的右边界横坐标组成pair就是封闭点啦,具体实现参看代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
vector<pair<int, int>> getSkyline(vector<vector<int>>& buildings) {
vector<pair<int, int>> h, res;
multiset<int> m;
int pre = 0, cur = 0;
for (auto &a : buildings) {
h.push_back({a[0], -a[2]});
h.push_back({a[1], a[2]});
}
sort(h.begin(), h.end());
m.insert(0);
for (auto &a : h) {
if (a.second < 0) m.insert(-a.second);
else m.erase(m.find(a.second));
cur = *m.rbegin();
if (cur != pre) {
res.push_back({a.first, cur});
pre = cur;
}
}
return res;
}
};

Leetcode219. Contains Duplicate II

Given an array of integers and an integer k, find out whether there are two distinct indices i and j in the array such that nums[i] = nums[j] and the absolute difference between i and j is at most k.

Example 1:

1
2
Input: nums = [1,2,3,1], k = 3
Output: true

Example 2:
1
2
Input: nums = [1,0,1,1], k = 1
Output: true

Example 3:
1
2
Input: nums = [1,2,3,1,2,3], k = 2
Output: false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool containsNearbyDuplicate(vector<int>& nums, int k) {
map<int, int> mp;
if(nums.size() == 0)
return false;
for(int i = 0; i < nums.size(); i ++) {
if(mp.find(nums[i]) == mp.end())
mp[nums[i]] = i;
else if(i - mp[nums[i]] <= k)
return true;
else
mp[nums[i]] = i;
}
return false;
}
};

Leetcode221. Maximal Square

Given an m x n binary matrix filled with 0’s and 1’s, find the largest square containing only 1’s and return its area.

Example 1:

1
2
Input: matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
Output: 4

Example 2:

1
2
Input: matrix = [["0","1"],["1","0"]]
Output: 1

Example 3:

1
2
Input: matrix = [["0"]]
Output: 0

类似85题,注意是正方形。

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
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
int width = matrix.size();
if (width == 0)
return 0;
int height = matrix[0].size();
int res = 0;
vector<vector<int>> dp(width, vector<int>(height, 0));
for (int i = 0; i < width; i ++) {
for (int j = 0; j < height; j ++) {
if (matrix[i][j] == '1') {
dp[i][j] = j == 0 ? 1 : dp[i][j-1]+1;
int length = dp[i][j];
for (int k = i; k >= 0; k --) {
length = min(length, dp[k][j]);
if ((i-k+1) == length)
res = max(res, (i-k+1)*length);
}
}
}
}
return res;
}
};

来个效率高的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
int width = matrix.size();
if (width == 0)
return 0;
int height = matrix[0].size();
int res = 0;
vector<vector<int>> dp(width, vector<int>(height, 0));
for (int i = 0; i < width; i ++) {
for (int j = 0; j < height; j ++) {
if (i == 0 || j == 0)
dp[i][j] = matrix[i][j] - '0';
else if (matrix[i][j] == '1') {
dp[i][j] = min(dp[i-1][j-1], min(dp[i][j-1], dp[i-1][j])) + 1;
}
res = max(res, dp[i][j]);
}
}
return res*res;
}
};

Leetcode222. Count Complete Tree Nodes

Given the root of a complete binary tree, return the number of the nodes in the tree.

According to Wikipedia, every level, except possibly the last, is completely filled in a complete binary tree, and all nodes in the last level are as far left as possible. It can have between 1 and 2h nodes inclusive at the last level h.

Design an algorithm that runs in less than O(n) time complexity.

这道题给定了一棵完全二叉树,让我们求其节点的个数。最暴力的解法就是直接用递归来统计结点的个数,根本不需要考虑什么完全二叉树还是完美二叉树,

1
2
3
4
5
6
class Solution {
public:
int countNodes(TreeNode* root) {
return root ? (1 + countNodes(root->left) + countNodes(root->right)) : 0;
}
};

完美二叉树一定是完全二叉树,而完全二叉树不一定是完美二叉树。那么这道题给的完全二叉树就有可能是完美二叉树,若是完美二叉树,节点个数很好求,为2的h次方减1,h为该完美二叉树的高度。若不是的话,只能老老实实的一个一个数结点了。思路是由 root 根结点往下,分别找最靠左边和最靠右边的路径长度,如果长度相等,则证明二叉树最后一层节点是满的,是满二叉树,直接返回节点个数,如果不相等,则节点个数为左子树的节点个数加上右子树的节点个数再加1(根节点),其中左右子树节点个数的计算可以使用递归来计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int countNodes(TreeNode* root) {
int hLeft = 0, hRight = 0;
TreeNode *pLeft = root, *pRight = root;
while (pLeft) {
++hLeft;
pLeft = pLeft->left;
}
while (pRight) {
++hRight;
pRight = pRight->right;
}
if (hLeft == hRight) return pow(2, hLeft) - 1;
return countNodes(root->left) + countNodes(root->right) + 1;
}
};

Leetcode223. Rectangle Area

Find the total area covered by two rectilinearrectangles in a 2D plane.

Each rectangle is defined by its bottom left corner and top right corner as shown in the figure.

1
2
Input: A = -3, B = 0, C = 3, D = 4, E = 0, F = -1, G = 9, H = 2
Output: 45

Note:

Assume that the total area is never beyond the maximum possible value of int.

尝试先找出所有的不相交的情况,只有四种,一个矩形在另一个的上下左右四个位置不重叠,这四种情况下返回两个矩形面积之和。其他所有情况下两个矩形是有交集的,这时候只要算出长和宽,即可求出交集区域的大小,然后从两个矩型面积之和中减去交集面积就是最终答案。求交集区域的长和宽也不难,由于交集都是在中间,所以横边的左端点是两个矩形左顶点横坐标的较大值,右端点是两个矩形右顶点的较小值,同理,竖边的下端点是两个矩形下顶点纵坐标的较大值,上端点是两个矩形上顶点纵坐标的较小值。

1
2
3
4
5
6
7
8
class Solution {
public:
int computeArea(int A, int B, int C, int D, int E, int F, int G, int H) {
int sum1 = (C - A) * (D - B), sum2 = (H - F) * (G - E);
if (E >= C || F >= D || B >= H || A >= G) return sum1 + sum2;
return sum1 - ((min(G, C) - max(A, E)) * (min(D, H) - max(B, F))) + sum2;
}
};

我自己的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int computeArea(int ax1, int ay1, int ax2, int ay2, int bx1, int by1, int bx2, int by2) {
int inter_x1 = max(ax1, bx1);
int inter_x2 = min(ax2, bx2);
int inter_y1 = max(ay1, by1);
int inter_y2 = min(ay2, by2);

int total_area = (ax2-ax1)*(ay2-ay1) + (bx2-bx1)*(by2-by1);
if (bx1 > ax2 || by2 < ay1 || bx2 < ax1 || by1 > ay2)
return total_area;
else
return total_area - (inter_x2-inter_x1)*(inter_y2-inter_y1);
}
};

Leetcode224. Basic Calculator

Given a string s representing a valid expression, implement a basic calculator to evaluate it, and return the result of the evaluation.

Note: You are not allowed to use any built-in function which evaluates strings as mathematical expressions, such as eval().

Example 1:

1
2
Input: s = "1 + 1"
Output: 2

Example 2:

1
2
Input: s = " 2-1 + 2 "
Output: 3

Example 3:

1
2
Input: s = "(1+(4+5+2)-3)+(6+8)"
Output: 23

Constraints:

  • 1 <= s.length <= 3 * 105
  • s consists of digits, ‘+’, ‘-‘, ‘(‘, ‘)’, and ‘ ‘.
  • s represents a valid expression.
  • ‘+’ is not used as a unary operation (i.e., “+1” and “+(2 + 3)” is invalid).
  • ‘-‘ could be used as a unary operation (i.e., “-1” and “-(2 + 3)” is valid).
  • There will be no two consecutive operators in the input.
  • Every number and running calculation will fit in a signed 32-bit integer.

这道题让我们实现一个基本的计算器来计算简单的算数表达式,而且题目限制了表达式中只有加减号,数字,括号和空格,没有乘除,那么就没啥计算的优先级之分了。于是这道题就变的没有那么复杂了。我们需要一个栈来辅助计算,用个变量sign来表示当前的符号,我们遍历给定的字符串s,如果遇到了数字,由于可能是个多位数,所以我们要用while循环把之后的数字都读进来,然后用sign*num来更新结果res;如果遇到了加号,则sign赋为1,如果遇到了符号,则赋为-1;如果遇到了左括号,则把当前结果res和符号sign压入栈,res重置为0,sign重置为1;如果遇到了右括号,结果res乘以栈顶的符号,栈顶元素出栈,结果res加上栈顶的数字,栈顶元素出栈。代码如下:

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
class Solution {
public:
int calculate(string s) {
int res = 0, len = s.length();
stack<int> st;
int op = 1;
st.push(1);

for (int i = 0; i < len;) {
if (s[i] == ' ') {
i ++;
continue;
}

if (s[i] == '(') {
st.push(op);
i ++;
} else if (s[i] == ')') {
st.pop();
i ++;
} else if (s[i] == '+') {
op = 1 * st.top();
i ++;
} else if (s[i] == '-') {
op = -1 * st.top();
i ++;
} else {
long long val = 0;
while(i < len && '0' <= s[i] && s[i] <= '9')
val = val * 10 + s[i++] - '0';
res += (op * val);
}

}

return res;
}
};

Leetcode225. Implement Stack using Queues

Implement the following operations of a stack using queues.

  • push(x) — Push element x onto stack.
  • pop() — Removes the element on top of the stack.
  • top() — Get the top element.
  • empty() — Return whether the stack is empty.

Example:

1
2
3
4
5
6
7
MyStack stack = new MyStack();

stack.push(1);
stack.push(2);
stack.top(); // returns 2
stack.pop(); // returns 2
stack.empty(); // returns false

Notes:

  • You must use only standard operations of a queue — which means only push to back, peek/pop from front, size, and is empty operations are valid.
  • Depending on your language, queue may not be supported natively. You may simulate a queue by using a list or deque (double-ended queue), as long as you use only standard operations of a queue.
  • You may assume that all operations are valid (for example, no pop or top operations will be called on an empty stack).
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
class MyStack {
public:

queue<int> que;

/** Initialize your data structure here. */
MyStack() {

}

/** Push element x onto stack. */
void push(int x) {
que.push(x);
int temp = que.size();
while(temp > 1) {
int xx = que.front();
que.pop();
que.push(xx);
temp --;
}
}

/** Removes the element on top of the stack and returns that element. */
int pop() {
int tt = que.front();
que.pop();
return tt;
}

/** Get the top element. */
int top() {
return que.front();
}

/** Returns whether the stack is empty. */
bool empty() {
return que.empty();
}
};

Leetcode226. Invert Binary Tree

Invert a binary tree.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Input:

4
/ \
2 7
/ \ / \
1 3 6 9
Output:

4
/ \
7 2
/ \ / \
9 6 3 1

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if(root == NULL)
return root;
TreeNode* temp = root->left;
root->left = root->right;
root->right = temp;
if(root->left) invertTree(root->left);
if(root->right) invertTree(root->right);
return root;
}
};

Leetcode227. Basic Calculator II

Implement a basic calculator to evaluate a simple expression string.

The expression string contains only non-negativeintegers, +, -, *, / operators and empty spaces ``. The integer division should truncate toward zero.

Example 1:

1
2
Input: "3+2*2"
Output: 7

Example 2:

1
2
Input: " 3/2 "
Output: 1

Example 3:

1
2
Input: " 3+5 / 2 "
Output: 5

Note:

  • You may assume that the given expression is always valid.
  • Do not use the eval built-in library function.

这道题是之前那道 Basic Calculator 的拓展,不同之处在于那道题的计算符号只有加和减,而这题加上了乘除,那么就牵扯到了运算优先级的问题,好在这道题去掉了括号,还适当的降低了难度,估计再出一道的话就该加上括号了。不管那么多,这道题先按木有有括号来处理,由于存在运算优先级,我们采取的措施是使用一个栈保存数字,如果该数字之前的符号是加或减,那么把当前数字压入栈中,注意如果是减号,则加入当前数字的相反数,因为减法相当于加上一个相反数。如果之前的符号是乘或除,那么从栈顶取出一个数字和当前数字进行乘或除的运算,再把结果压入栈中,那么完成一遍遍历后,所有的乘或除都运算完了,再把栈中所有的数字都加起来就是最终结果了,参见代码如下:

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
class Solution {
public:
int calculate(string s) {
long res = 0, num = 0, n = s.size();
char op = '+';
stack<int> st;
for (int i = 0; i < n; ++i) {
if (s[i] >= '0') {
num = num * 10 + s[i] - '0';
}
if ((s[i] < '0' && s[i] != ' ') || i == n - 1) {
if (op == '+') st.push(num);
if (op == '-') st.push(-num);
if (op == '*' || op == '/') {
int tmp = (op == '*') ? st.top() * num : st.top() / num;
st.pop();
st.push(tmp);
}
op = s[i];
num = 0;
}
}
while (!st.empty()) {
res += st.top();
st.pop();
}
return res;
}
};
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
class Solution {
public:
int calculate(string s) {
long res = 0, curRes = 0, num = 0, n = s.size();
char op = '+';
for (int i = 0; i < n; ++i) {
char c = s[i];
if (c >= '0' && c <= '9') {
num = num * 10 + c - '0';
}
if (c == '+' || c == '-' || c == '*' || c == '/' || i == n - 1) {
switch (op) {
case '+': curRes += num; break;
case '-': curRes -= num; break;
case '*': curRes *= num; break;
case '/': curRes /= num; break;
}
if (c == '+' || c == '-' || i == n - 1) {
res += curRes;
curRes = 0;
}
op = c;
num = 0;
}
}
return res;
}
};
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
class Solution {
public:
int calculate(string s) {
int len = s.length();
long long res = 0, last_val;
char op = '+';

for (int i = 0; i < len; ) {
if (s[i] == ' ') {
i ++;
continue;
}

if ('0' <= s[i] && s[i] <= '9') {
long long val = 0;
while(i < len && '0' <= s[i] && s[i] <= '9')
val = val* 10 + s[i++] - '0';

if (op == '+') {
res += val;
last_val = val;
} else if (op == '-') {
res -= val;
last_val = -val;
} else if (op == '*') {
res = res - last_val + last_val * val;
last_val = last_val * val;
} else if (op == '/') {
res = res - last_val + last_val / val;
last_val = last_val / val;
}
}
else {
op = s[i];
i ++;
}
}
return res;
}
};

Leetcode228. Summary Ranges

Given a sorted integer array without duplicates, return the summary of its ranges.

Example 1:

1
2
3
Input:  [0,1,2,4,5,7]
Output: ["0->2","4->5","7"]
Explanation: 0,1,2 form a continuous range; 4,5 form a continuous range.

Example 2:

1
2
3
Input:  [0,2,3,4,6,8,9]
Output: ["0","2->4","6","8->9"]
Explanation: 2,3,4 form a continuous range; 8,9 form a continuous range.

这道题给定我们一个有序数组,让我们总结区间,具体来说就是让我们找出连续的序列,然后首尾两个数字之间用个“->”来连接,那么我只需遍历一遍数组即可,每次检查下一个数是不是递增的,如果是,则继续往下遍历,如果不是了,我们还要判断此时是一个数还是一个序列,一个数直接存入结果,序列的话要存入首尾数字和箭头“->”。我们需要两个变量i和j,其中i是连续序列起始数字的位置,j是连续数列的长度,当j为1时,说明只有一个数字,若大于1,则是一个连续序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
vector<string> summaryRanges(vector<int>& nums) {
vector<string> res;
int i = 0, n = nums.size();
while (i < n) {
int j = 1;
while (i + j < n && (long)nums[i + j] - nums[i] == j) ++j;
res.push_back(j <= 1 ? to_string(nums[i]) : to_string(nums[i]) + "->" + to_string(nums[i + j - 1]));
i += j;
}
return res;
}
};

Leetcode229. Majority Element II

Given an integer array of size n , find all elements that appear more than ⌊ n/3 ⌋ times.

Note: The algorithm should run in linear time and in O(1) space.

Example 1:

1
2
Input: [3,2,3]
Output: [3]

Example 2:

1
2
Input: [1,1,1,3,3,2,2,2]
Output: [1,2]

这道题让我们求出现次数大于 n/3 的数字,而且限定了时间和空间复杂度,那么就不能排序,也不能使用 HashMap,这么苛刻的限制条件只有一种方法能解了,那就是摩尔投票法 Moore Voting,这种方法在之前那道题 Majority Element 中也使用了。题目中给了一条很重要的提示,让先考虑可能会有多少个这样的数字,经过举了很多例子分析得出,任意一个数组出现次数大于 n/3 的数最多有两个,具体的证明博主就不会了,博主也不是数学专业的(热心网友用手走路提供了证明:如果有超过两个,也就是至少三个数字满足“出现的次数大于 n/3”,那么就意味着数组里总共有超过 3*(n/3) = n 个数字,这与已知的数组大小矛盾,所以,只可能有两个或者更少)。那么有了这个信息,使用投票法的核心是找出两个候选数进行投票,需要两遍遍历,第一遍历找出两个候选数,第二遍遍历重新投票验证这两个候选数是否为符合题意的数即可,选候选数方法和前面那篇 Majority Element 一样,由于之前那题题目中限定了一定会有大多数存在,故而省略了验证候选众数的步骤,这道题却没有这种限定,即满足要求的大多数可能不存在,所以要有验证,参加代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
vector<int> majorityElement(vector<int>& nums) {
vector<int> res;
int a = 0, b = 0, cnt1 = 0, cnt2 = 0, n = nums.size();
for (int num : nums) {
if (num == a) ++cnt1;
else if (num == b) ++cnt2;
else if (cnt1 == 0) { a = num; cnt1 = 1; }
else if (cnt2 == 0) { b = num; cnt2 = 1; }
else { --cnt1; --cnt2; }
}
cnt1 = cnt2 = 0;
for (int num : nums) {
if (num == a) ++cnt1;
else if (num == b) ++cnt2;
}
if (cnt1 > n / 3) res.push_back(a);
if (cnt2 > n / 3) res.push_back(b);
return res;
}
};

Leetcode230. Kth Smallest Element in a BST

Given a binary search tree, write a function kthSmallest to find the kth smallest element in it.

Note:
You may assume k is always valid, 1 ≤ k ≤ BST’s total elements.

Example 1:

1
2
3
4
5
6
7
Input: root = [3,1,4,null,2], k = 1
3
/ \
1 4
\
2
Output: 1

Example 2:

1
2
3
4
5
6
7
8
9
Input: root = [5,3,6,2,4,null,null,1], k = 3
5
/ \
3 6
/ \
2 4
/
1
Output: 3

Follow up:
What if the BST is modified (insert/delete operations) often and you need to find the kth smallest frequently? How would you optimize the kthSmallest routine?

如果用中序遍历所有的节点就会得到一个有序数组。先来看一种非递归的方法,中序遍历最先遍历到的是最小的结点,只要用一个计数器,每遍历一个结点,计数器自增1,当计数器到达k时,返回当前结点值即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
int cnt = 0;
stack<TreeNode*> s;
TreeNode *p = root;
while (p || !s.empty()) {
while (p) {
s.push(p);
p = p->left;
}
p = s.top(); s.pop();
++cnt;
if (cnt == k) return p->val;
p = p->right;
}
return 0;
}
};

当然,此题我们也可以用递归来解,还是利用中序遍历来解,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
return kthSmallestDFS(root, k);
}
int kthSmallestDFS(TreeNode* root, int &k) {
if (!root) return -1;
int val = kthSmallestDFS(root->left, k);
if (k == 0) return val;
if (--k == 0) return root->val;
return kthSmallestDFS(root->right, k);
}
};

再来看一种分治法的思路,由于 BST 的性质,可以快速定位出第k小的元素是在左子树还是右子树,首先计算出左子树的结点个数总和 cnt,如果k小于等于左子树结点总和 cnt,说明第k小的元素在左子树中,直接对左子结点调用递归即可。如果k大于 cnt+1,说明目标值在右子树中,对右子结点调用递归函数,注意此时的k应为 k-cnt-1,应为已经减少了 cnt+1 个结点。如果k正好等于 cnt+1,说明当前结点即为所求,返回当前结点值即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
int cnt = count(root->left);
if (k <= cnt) {
return kthSmallest(root->left, k);
} else if (k > cnt + 1) {
return kthSmallest(root->right, k - cnt - 1);
}
return root->val;
}
int count(TreeNode* node) {
if (!node) return 0;
return 1 + count(node->left) + count(node->right);
}
};

这道题的 Follow up 中说假设该 BST 被修改的很频繁,而且查找第k小元素的操作也很频繁,问我们如何优化。其实最好的方法还是像上面的解法那样利用分治法来快速定位目标所在的位置,但是每个递归都遍历左子树所有结点来计算个数的操作并不高效,所以应该修改原树结点的结构,使其保存包括当前结点和其左右子树所有结点的个数,这样就可以快速得到任何左子树结点总数来快速定位目标值了。定义了新结点结构体,然后就要生成新树,还是用递归的方法生成新树,注意生成的结点的 count 值要累加其左右子结点的 count 值。然后在求第k小元素的函数中,先生成新的树,然后调用递归函数。在递归函数中,不能直接访问左子结点的 count 值,因为左子节结点不一定存在,所以要先判断,如果左子结点存在的话,那么跟上面解法的操作相同。如果不存在的话,当此时k为1的时候,直接返回当前结点值,否则就对右子结点调用递归函数,k自减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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Follow up
class Solution {
public:
struct MyTreeNode {
int val;
int count;
MyTreeNode *left;
MyTreeNode *right;
MyTreeNode(int x) : val(x), count(1), left(NULL), right(NULL) {}
};

MyTreeNode* build(TreeNode* root) {
if (!root) return NULL;
MyTreeNode *node = new MyTreeNode(root->val);
node->left = build(root->left);
node->right = build(root->right);
if (node->left) node->count += node->left->count;
if (node->right) node->count += node->right->count;
return node;
}

int kthSmallest(TreeNode* root, int k) {
MyTreeNode *node = build(root);
return helper(node, k);
}

int helper(MyTreeNode* node, int k) {
if (node->left) {
int cnt = node->left->count;
if (k <= cnt) {
return helper(node->left, k);
} else if (k > cnt + 1) {
return helper(node->right, k - 1 - cnt);
}
return node->val;
} else {
if (k == 1) return node->val;
return helper(node->right, k - 1);
}
}
};

Leetcode231. Power of Two

Given an integer, write a function to determine if it is a power of two.

Example 1:

1
2
Input: 1 Output: true 
Explanation: 2^0 = 1

Example 2:
1
2
Input: 16 Output: true
Explanation: 2^4 = 16

判断一个数是不是2的幂
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
bool isPowerOfTwo(int n) {
if(n == 0)
return false;
while(n != 1) {
int temp = n & 1;
if(temp != 0)
return false;
n = n >> 1;
}
return true;
}
};

Leetcode232. Implement Queue using Stacks

Implement the following operations of a queue using stacks.

  • push(x) — Push element x to the back of queue.
  • pop() — Removes the element from in front of queue.
  • peek() — Get the front element.
  • empty() — Return whether the queue is empty.

Example:

1
2
3
4
5
6
MyQueue queue = new MyQueue();
queue.push(1);
queue.push(2);
queue.peek(); // returns 1
queue.pop(); // returns 1
queue.empty(); // returns false

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
class MyQueue {
public:

stack<int> s1, s2;
int front;
/** Initialize your data structure here. */
MyQueue() {

}

/** Push element x to the back of queue. */
void push(int x) {
if(s1.empty()) {
front = x;
}
s1.push(x);
}

/** Removes the element from in front of queue and returns that element. */
int pop() {
while(!s1.empty()){
s2.push(s1.top());
s1.pop();
}
int res = s2.top();
s2.pop();
while(!s2.empty()) {
s1.push(s2.top());
s2.pop();
}
return res;
}

/** Get the front element. */
int peek() {
while(!s1.empty()){
s2.push(s1.top());
s1.pop();
}
int res = s2.top();
while(!s2.empty()){
s1.push(s2.top());
s2.pop();
}
return res;
}

/** Returns whether the queue is empty. */
bool empty() {
return s1.empty();
}
};

Leetcode233. Number of Digit One

Given an integer n, count the total number of digit1 appearing in all non-negative integers less than or equal to n.

Example 1:

1
2
Input: n = 13
Output: 6

Example 2:

1
2
Input: n = 0
Output: 0

Constraints:

  • 0 <= n <= 2 * 109

这道题让我们比给定数小的所有数中1出现的个数,之前有道类似的题 Number of 1 Bits,那道题是求转为二进数后1的个数,博主开始以为这道题也是要用那题的方法,其实不是的,这题实际上相当于一道找规律的题。那么为了找出规律,就先来列举下所有含1的数字,并每 10 个统计下个数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1的个数  含1的数字                                          数字范围
1 1 [1, 9]
11 10 11 12 13 14 15 16 17 18 19 [10, 19]
1 21 [20, 29]
1 31 [30, 39]
1 41 [40, 49]
1 51 [50, 59]
1 61 [60, 69]
1 71 [70, 79]
1 81 [80, 89]
1 91 [90, 99]
11 100 101 102 103 104 105 106 107 108 109 [100, 109]
21 110 111 112 113 114 115 116 117 118 119 [110, 119]
11 120 121 122 123 124 125 126 127 128 129 [120, 129]

通过上面的列举可以发现,100 以内的数字,除了10-19之间有 11 个 ‘1’ 之外,其余都只有1个。如果不考虑 [10, 19] 区间上那多出来的 10 个 ‘1’ 的话,那么在对任意一个两位数,十位数上的数字(加1)就代表1出现的个数,这时候再把多出的 10 个加上即可。比如 56 就有 (5+1)+10=16 个。如何知道是否要加上多出的 10 个呢,就要看十位上的数字是否大于等于2,是的话就要加上多余的 10 个 ‘1’。那么就可以用 (x+8)/10 来判断一个数是否大于等于2。对于三位数区间 [100, 199] 内的数也是一样,除了 [110, 119] 之间多出的10个数之外,共 21 个 ‘1’,其余的每 10 个数的区间都只有 11 个 ‘1’,所以 [100, 199] 内共有 21 + 11 9 = 120 个 ‘1’。那么现在想想 [0, 999] 区间内 ‘1’ 的个数怎么求?根据前面的结果,[0, 99] 内共有 20 个,[100, 199] 内共有 120 个,而其他每 100 个数内 ‘1’ 的个数也应该符合之前的规律,即也是 20 个,那么总共就有 120 + 20 9 = 300 个 ‘1’。那么还是可以用相同的方法来判断并累加1的个数,参见代码如下:

解法一:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int countDigitOne(int n) {
int res = 0, a = 1, b = 1;
while (n > 0) {
res += (n + 8) / 10 * a + (n % 10 == 1) * b;
b += n % 10 * a;
a *= 10;
n /= 10;
}
return res;
}
};

Leetcode234. Palindrome Linked List

Given a singly linked list, determine if it is a palindrome.

Example 1:

1
2
Input: 1->2
Output: false

Example 2:
1
2
Input: 1->2->2->1
Output: true

使用反转链表。不同的是不反转整个链表,只反转回文的后半段链表。然后就是判断出哪里是回文的中间位置。

首先说反转链表函数。设置一个节点pre为空,是反转链表后的起始节点。设置节点next为空,是要反转链表当前节点的next节点。遍历,只要head不为空,则先将head->next保存在next节点中。然后head->next指向pre,然后head节点保存在pre中。最后head保存next节点。遍历结束,返回pre节点,即完成反转。

然后说判断回文中间位置。设置一个慢指针slow,一个快指针fast。遍历,只要fast->next和fast->next->next不为空,则slow往前走一步,fast往前走两步,slow = slow->next,fast = fast->next->next,这样当不满足遍历条件、结束遍历时,slow刚好指在中间位置,如果长度是计数,则刚好中间,长度是偶数,则中间前一个。

然后说反转后半部分链表。将slow->next开始反转,slow->next = reverselist(slow->next),然后将slow = slow->next。

最后是判断是否是回文。这时,可同时遍历head和slow,判断二者值是否相等即可,不相等直接返回false。遍历结束后,返回true。

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
class Solution {
public:

ListNode* reverseList(ListNode *head) {
ListNode *pre = NULL, *next = NULL;
while(head) {
next = head->next;
head->next = pre;
pre = head;
head = next;
}
return pre;
}

bool isPalindrome(ListNode* head) {
if(head == NULL || head->next == NULL)
return true;
ListNode *slow = head, *fast = head;
while(fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}
slow->next = reverseList(slow->next);
slow = slow->next;

while(slow) {
if (slow->val != head->val)
return false;
slow = slow->next;
head = head->next;
}
return true;
}
};

Leetcode235. Lowest Common Ancestor of a Binary Search Tree

Given a binary search tree (BST), find the lowest common ancestor (LCA) of two given nodes in the BST.

According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two nodes p and q as the lowest node in T that has both p and q as descendants (where we allow a node to be a descendant of itself).”

Given binary search tree: root = [6,2,8,0,4,7,9,null,null,3,5]

Example 1:

1
2
3
Input: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8
Output: 6
Explanation: The LCA of nodes 2 and 8 is 6.

Example 2:
1
2
3
Input: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4
Output: 2
Explanation: The LCA of nodes 2 and 4 is 2, since a node can be a descendant of itself according to the LCA definition.

这道题我们可以用递归来求解,我们首先来看题目中给的例子,由于二叉搜索树的特点是左<根<右,所以根节点的值一直都是中间值,大于左子树的所有节点值,小于右子树的所有节点值,那么我们可以做如下的判断,如果根节点的值大于p和q之间的较大值,说明p和q都在左子树中,那么此时我们就进入根节点的左子节点继续递归,如果根节点小于p和q之间的较小值,说明p和q都在右子树中,那么此时我们就进入根节点的右子节点继续递归,如果都不是,则说明当前根节点就是最小共同父节点,直接返回即可。
1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root) return NULL;
if (root->val > max(p->val, q->val))
return lowestCommonAncestor(root->left, p, q);
else if (root->val < min(p->val, q->val))
return lowestCommonAncestor(root->right, p, q);
else return root;
}
};

当然,此题也有非递归的写法,用个 while 循环来代替递归调用即可,然后不停的更新当前的根节点,也能实现同样的效果,代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
while (true) {
if (root->val > max(p->val, q->val)) root = root->left;
else if (root->val < min(p->val, q->val)) root = root->right;
else break;
}
return root;
}
};

Leetcode236. Lowest Common Ancestor of a Binary Tree

Given a binary tree, find the lowest common ancestor (LCA) of two given nodes in the tree.

According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two nodes p and q as the lowest node in T that has both p and q as descendants (where we allow a node to be a descendant of itself).”

Given the following binary tree: root = [3,5,1,6,2,0,8,null,null,7,4]

Example 1:

1
2
3
Input: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
Output: 3
Explanation: The LCA of nodes 5 and 1 is 3.

Example 2:

1
2
3
Input: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
Output: 5
Explanation: The LCA of nodes 5 and 4 is 5, since a node can be a descendant of itself according to the LCA definition.

Note:

  • All of the nodes’ values will be unique.
  • p and q are different and both values will exist in the binary tree.

在二叉树中来搜索p和q,然后从路径中找到最后一个相同的节点即为父节点,可以用递归来实现,在递归函数中,首先看当前结点是否为空,若为空则直接返回空,若为p或q中的任意一个,也直接返回当前结点。否则的话就对其左右子结点分别调用递归函数,由于这道题限制了p和q一定都在二叉树中存在,那么如果当前结点不等于p或q,p和q要么分别位于左右子树中,要么同时位于左子树,或者同时位于右子树,那么我们分别来讨论:

  • 若p和q分别位于左右子树中,那么对左右子结点调用递归函数,会分别返回p和q结点的位置,而当前结点正好就是p和q的最小共同父结点,直接返回当前结点即可,这就是题目中的例子1的情况。
  • 若p和q同时位于左子树,这里有两种情况,一种情况是 left 会返回p和q中较高的那个位置,而 right 会返回空,所以最终返回非空的 left 即可,这就是题目中的例子2的情况。还有一种情况是会返回p和q的最小父结点,就是说当前结点的左子树中的某个结点才是p和q的最小父结点,会被返回。
  • 若p和q同时位于右子树,同样这里有两种情况,一种情况是 right 会返回p和q中较高的那个位置,而 left 会返回空,所以最终返回非空的 right 即可,还有一种情况是会返回p和q的最小父结点,就是说当前结点的右子树中的某个结点才是p和q的最小父结点,会被返回,写法很简洁,代码如下:
1
2
3
4
5
6
7
8
9
10
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || p == root || q == root) return root;
TreeNode *left = lowestCommonAncestor(root->left, p, q);
TreeNode *right = lowestCommonAncestor(root->right, p , q);
if (left && right) return root;
return left ? left : right;
}
};

上述代码可以进行优化一下,如果当前结点不为空,且既不是p也不是q,那么根据上面的分析,p和q的位置就有三种情况,p和q要么分别位于左右子树中,要么同时位于左子树,或者同时位于右子树。我们需要优化的情况就是当p和q同时为于左子树或右子树中,而且返回的结点并不是p或q,那么就是p和q的最小父结点了,已经求出来了,就不用再对右结点调用递归函数了,这是为啥呢?因为根本不会存在 left 既不是p也不是q,同时还有p或者q在 right 中。首先递归的第一句就限定了只要遇到了p或者q,就直接返回,之后又限定了只有当 left 和 right 同时存在的时候,才会返回当前结点,当前结点若不是p或q,则一定是最小父节点,否则 left 一定是p或者q。这里的逻辑比较绕,不太好想,多想想应该可以理清头绪吧,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || p == root || q == root) return root;
TreeNode *left = lowestCommonAncestor(root->left, p, q);
if (left && left != p && left != q) return left;
TreeNode *right = lowestCommonAncestor(root->right, p , q);
    if (left && right) return root;
return left ? left : right;
}
};

Leetcode237. Delete Node in a Linked List

Write a function to delete a node (except the tail) in a singly linked list, given only access to that node.

Given linked list — head = [4,5,1,9], which looks like following:

Example 1:

1
2
3
Input: head = [4,5,1,9], node = 5
Output: [4,1,9]
Explanation: You are given the second node with value 5, the linked list should become 4 -> 1 -> 9 after calling your function.

Example 2:
1
2
3
Input: head = [4,5,1,9], node = 1
Output: [4,5,9]
Explanation: You are given the third node with value 1, the linked list should become 4 -> 5 -> 9 after calling your function.

这道题让我们删除链表的一个节点,更通常不同的是,没有给我们链表的起点,只给我们了一个要删的节点,跟我们以前遇到的情况不太一样,我们之前要删除一个节点的方法是要有其前一个节点的位置,然后将其前一个节点的next连向要删节点的下一个,然后delete掉要删的节点即可。这道题的处理方法是先把当前节点的值用下一个节点的值覆盖了,然后我们删除下一个节点即可。
1
2
3
4
5
6
7
class Solution {
public:
void deleteNode(ListNode* node) {
node->val = node->next->val;
node->next = node->next->next;
}
};

Leetcode238. Product of Array Except Self

Given an array nums of n integers where n > 1, return an array output such that output[i] is equal to the product of all the elements of numsexcept nums[i].

Example:

1
2
Input:  [1,2,3,4]
Output: [24,12,8,6]

Note: Please solve it without division and in O(n).

这道题给定我们一个数组,让我们返回一个新数组,对于每一个位置上的数是其他位置上数的乘积,并且限定了时间复杂度 O(n),并且不让我们用除法。如果让用除法的话,那这道题就应该属于 Easy,因为可以先遍历一遍数组求出所有数字之积,然后除以对应位置的上的数字。但是这道题禁止我们使用除法,那么我们只能另辟蹊径。我们想,对于某一个数字,如果我们知道其前面所有数字的乘积,同时也知道后面所有的数乘积,那么二者相乘就是我们要的结果,所以我们只要分别创建出这两个数组即可,分别从数组的两个方向遍历就可以分别创建出乘积累积数组。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> fwd(n, 1), bwd(n, 1), res(n);
for (int i = 0; i < n - 1; ++i) {
fwd[i + 1] = fwd[i] * nums[i];
}
for (int i = n - 1; i > 0; --i) {
bwd[i - 1] = bwd[i] * nums[i];
}
for (int i = 0; i < n; ++i) {
res[i] = fwd[i] * bwd[i];
}
return res;
}
};

我们可以对上面的方法进行空间上的优化,由于最终的结果都是要乘到结果 res 中,所以可以不用单独的数组来保存乘积,而是直接累积到结果 res 中,我们先从前面遍历一遍,将乘积的累积存入结果 res 中,然后从后面开始遍历,用到一个临时变量 right,初始化为1,然后每次不断累积,最终得到正确结果,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
vector<int> res(nums.size(), 1);
for (int i = 1; i < nums.size(); ++i) {
res[i] = res[i - 1] * nums[i - 1];
}
int right = 1;
for (int i = nums.size() - 1; i >= 0; --i) {
res[i] *= right;
right *= nums[i];
}
return res;
}
};

Leetcode239. Sliding Window Maximum

You are given an array of integers nums, there is a sliding window of size k which is moving from the very left of the array to the very right. You can only see the k numbers in the window. Each time the sliding window moves right by one position.

Return the max sliding window.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
Input: nums = [1,3,-1,-3,5,3,6,7], k = 3
Output: [3,3,5,5,6,7]
Explanation:
Window position Max
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7

Example 2:

1
2
Input: nums = [1], k = 1
Output: [1]

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

双端单调队列

本题要找长度为 k 的区间的最大值。模拟这个区间的移动过程,可以发现,右边增加一个数,左边必然会去掉一个数。

那么最大的数有什么性质呢?可以发现,如果扫描区间末尾,在已经遍历过的数之中,一个数 a 在 b 前面,并且 a 还比 b 小,那么 a 在之后的区间里永远无法成为最大值。

所以我们遍历到一个数时,它之前的所有比它小的数都可以去掉了,只保留比它大的数就行了。这就让人想到了之前介绍过的单调栈,但是本题中是先进先出,所以要改用单调队列。此外队列末尾不仅要增加元素,还得维护单调递减,适当去除一些元素,所以队列两端都得有插入和删除的功能。所以本题要使用双端队列,而队列中的元素又是单调递减的,所以又是双端单调队列。

这样思路就很明确了:

  • 遍历元素 nums[i] ,然后跟队列尾部元素比较,如果比尾部元素大,就出队,然后继续比较,直到 nums[i] 小于尾部元素,然后将它入队。
  • 然后用一下队列首部元素的下标,计算出队列中区间的长度,如果大于 k 了,那么队首元素就要出队。
  • 最后队首元素就是当前区间的最大值。

分块法

试想如果我们将数组划分为相同大小的若干块,每一块中最大值都是知道的话,那么要求区间最大值,只需要看它在哪几块里就行了。

那么块的大小应该设成多少呢?

如果块大小为 k ,就可以发现长度为 k 的区间 [i, j] 要么正好就是一个完整的块,要么跨越了两个相邻块。那么我们只需要知道 i 到它那块末尾元素中最大值,以及 j 到它那块开头最大值就行了,两个部分合并求最大值就是区间的最大值了。而每个元素到它自己那块的开头和末尾的最大值都可以预处理出来,方法和求前缀和类似。

那为什么块大小不能是其他值呢?如果块大小大于 k ,那么会出现区间完全包含于一块之中的情况,那就和不分块一样了。如果块大小小于 k ,那么就会出现区间横跨了好几块,那么还得遍历中间块的最大值。极端情况下如果块大小为 1 ,那么就等于暴力求解。

代码

双端单调队列(c++)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
deque<int> Q;
vector<int> res;
for (int i = 0; i < n; ++i) {
while (!Q.empty() && nums[i] >= nums[Q.back()]) Q.pop_back();
Q.push_back(i);
if (i - Q.front() + 1 > k) Q.pop_front();
if (i >= k-1) res.push_back(nums[Q.front()]);
}
return res;
}
};

双端单调队列+数组实现(c++)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
vector<int> Q(n, 0);
vector<int> res;
int l = 0, r = 0;
for (int i = 0; i < n; ++i) {
while (r-l > 0 && nums[i] >= nums[Q[r-1]]) r--;
Q[r++] = i;
if (i - Q[l] + 1 > k) l++;
if (i >= k-1) res.push_back(nums[Q[l]]);
}
return res;
}
};

分块法(c++)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
vector<int> lmax(n, 0), rmax(n, 0);
vector<int> res;
if (!n) return res;
for (int i = 0; i < n; ++i) {
if (i%k == 0) lmax[i] = nums[i];
else lmax[i] = max(lmax[i-1], nums[i]);
}
for (int i = n-1; i >= 0; --i) {
if ((i+1)%k == 0 || i == n-1) rmax[i] = nums[i];
else rmax[i] = max(rmax[i+1], nums[i]);
}
for (int i = k-1; i < n; ++i) {
res.push_back(max(lmax[i], rmax[i-k+1]));
}
return res;
}
};

双端单调队列(python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import collections

class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
Q = collections.deque()
res = []
for i in range(n):
while len(Q) > 0 and nums[i] >= nums[Q[-1]]:
Q.pop()
Q.append(i)
if i - Q[0] + 1 > k:
Q.popleft()
if i >= k-1:
res.append(nums[Q[0]])
return res

双端单调队列+数组实现(python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import collections

class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
Q = [0] * n
res = []
l, r = 0, 0
for i in range(n):
while r-l > 0 and nums[i] >= nums[Q[r-1]]:
r -= 1
Q[r] = i
r += 1
if i - Q[l] + 1 > k:
l += 1
if i >= k-1:
res.append(nums[Q[l]])
return res

分块法(python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import collections
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
lmax, rmax = [0] * n, [0] * n
res = []
if n == 0:
return res
for i in range(n):
if i%k == 0:
lmax[i] = nums[i]
else:
lmax[i] = max(lmax[i-1], nums[i])
for i in range(n-1, -1, -1):
if (i+1)%k == 0 or i == n-1:
rmax[i] = nums[i]
else:
rmax[i] = max(rmax[i+1], nums[i])
for i in range(k-1, n):
res.append(max(lmax[i], rmax[i-k+1]))
return res

Leetcode240. Search a 2D Matrix II

Write an efficient algorithm that searches for a value in an m x n matrix. This matrix has the following properties:

Integers in each row are sorted in ascending from left to right.
Integers in each column are sorted in ascending from top to bottom.
Example:

Consider the following matrix:

[
[1, 4, 7, 11, 15],
[2, 5, 8, 12, 19],
[3, 6, 9, 16, 22],
[10, 13, 14, 17, 24],
[18, 21, 23, 26, 30]
]
Given target = 5, return true.

Given target = 20, return false.

从右上角开始, 比较target 和 matrix[i][j]的值. 如果小于target, 则该行不可能有此数, 所以i++; 如果大于target, 则该列不可能有此数, 所以j—. 遇到边界则表明该矩阵不含target.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
if(matrix.size()==0 || matrix[0].size()==0)
return false;
int i=0,j=matrix[0].size()-1;
while(i<matrix.size() && j>=0) {
if(matrix[i][j]==target)
return true;
if(matrix[i][j] < target)
i++;
else
j--;
}
return false;
}
};

然后,做一些简单的优化,就能从打败50%升到打败60%。比如尽量少的使用matrix[i][j],而是用变量把它存下来。

Leetcode241. Different Ways to Add Parentheses 添加括号的不同方式

Given a string of numbers and operators, return all possible results from computing all the different possible ways to group numbers and operators. The valid operators are +, - and *.

Example 1:

1
2
3
4
5
Input: "2-1-1"
Output: [0, 2]
Explanation:
((2-1)-1) = 0
(2-(1-1)) = 2

Example 2:

1
2
3
4
5
6
7
8
Input: "2*3-4*5"
Output: [-34, -14, -10, -10, 10]
Explanation:
(2*(3-(4*5))) = -34
((2*3)-(4*5)) = -14
((2*(3-4))*5) = -10
(2*((3-4)*5)) = -10
(((2*3)-4)*5) = 10

这道题让给了一个可能含有加减乘的表达式,让我们在任意位置添加括号,求出所有可能表达式的不同值。

先从最简单的输入开始,若 input 是空串,那就返回一个空数组。若 input 是一个数字的话,那么括号加与不加其实都没啥区别,因为不存在计算,但是需要将字符串转为整型数,因为返回的是一个整型数组。当然,input 是一个单独的运算符这种情况是不存在的,因为前面说了这道题默认输入的合法的。下面来看若 input 是数字和运算符的时候,比如 “1+1” 这种情况,那么加不加括号也没有任何影响,因为只有一个计算,结果一定是2。再复杂一点的话,比如题目中的例子1,input 是 “2-1-1” 时,就有两种情况了,(2-1)-1 和 2-(1-1),由于括号的不同,得到的结果也不同,但如果我们把括号里的东西当作一个黑箱的话,那么其就变为 ()-1 和 2-(),其最终的结果跟括号内可能得到的值是息息相关的,那么再 general 一点,实际上就可以变成 () ? () 这种形式,两个括号内分别是各自的表达式,最终会分别计算得到两个整型数组,中间的问号表示运算符,可以是加,减,或乘。那么问题就变成了从两个数组中任意选两个数字进行运算,瞬间变成我们会做的题目了有木有?而这种左右两个括号代表的黑盒子就交给递归去计算,像这种分成左右两坨的 pattern 就是大名鼎鼎的分治法 Divide and Conquer 了,是必须要掌握的一个神器。类似的题目还有之前的那道 Unique Binary Search Trees II 用的方法一样,用递归来解,划分左右子树,递归构造。

好,继续来说这道题,我们不用新建递归函数,就用其本身来递归就行,先建立一个结果 res 数组,然后遍历 input 中的字符,根据上面的分析,我们希望在每个运算符的地方,将 input 分成左右两部分,从而扔到递归中去计算,从而可以得到两个整型数组 left 和 right,分别表示作用两部分各自添加不同的括号所能得到的所有不同的值,此时我们只要分别从两个数组中取数字进行当前的运算符计算,然后把结果存到 res 中即可。当然,若最终结果 res 中还是空的,那么只有一种情况,input 本身就是一个数字,直接转为整型存入结果 res 中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
vector<int> diffWaysToCompute(string input) {
vector<int> res;
for (int i = 0; i < input.size(); ++i) {
if (input[i] == '+' || input[i] == '-' || input[i] == '*') {
vector<int> left = diffWaysToCompute(input.substr(0, i));
vector<int> right = diffWaysToCompute(input.substr(i + 1));
for (int j = 0; j < left.size(); ++j) {
for (int k = 0; k < right.size(); ++k) {
if (input[i] == '+') res.push_back(left[j] + right[k]);
else if (input[i] == '-') res.push_back(left[j] - right[k]);
else res.push_back(left[j] * right[k]);
}
}
}
}
if (res.empty()) res.push_back(stoi(input));
return res;
}
};

Leetcode242. Valid Anagram

Given two strings s and t , write a function to determine if t is an anagram of s.

Example 1:

1
2
Input: s = "anagram", t = "nagaram"
Output: true

Example 2:
1
2
Input: s = "rat", t = "car"
Output: false

判断异位词,即包含相同的字符的字符串。使用map记录每个字符串中的字符,判断map是否相同。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool isAnagram(string s, string t) {
map<char, int> ss, tt;
for(int i = 0; i < s.size(); i ++) {
if(ss.find(s[i]) == ss.end())
ss[s[i]] = 1;
else ss[s[i]] ++;
}
for(int i = 0; i < t.size(); i ++) {
if(tt.find(t[i]) == tt.end())
tt[t[i]] = 1;
else tt[t[i]] ++;
}
return ss == tt;
}
};

Leetcode243. Shortest Word Distance

Given a list of words and two words word1 and word2, return the shortest distance between these two words in the list.

Example:

1
2
3
4
5
6
Assume that words = ["practice", "makes", "perfect", "coding", "makes"].

Input: word1 = “coding”, word2 = “practice”
Output: 3
Input: word1 = "makes", word2 = "coding"
Output: 1

Note:
You may assume that word1 does not equal to word2, and word1 and word2 are both in the list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int shortestDistance(vector<string>& words, string word1, string word2) {
int idx = -1, res = INT_MAX;
for (int i = 0; i < words.size(); ++i) {
if (words[i] == word1 || words[i] == word2) {
if (idx != -1 && words[idx] != words[i]) {
res = min(res, i - idx);
}
idx = i;
}
}
return res;
}
};

Leetcode246. Strobogrammatic Number

A strobogrammatic number is a number that looks the same when rotated 180 degrees (looked at upside down). Write a function to determine if a number is strobogrammatic. The number is represented as a string.

Example 1:

1
2
Input:  "69"
Output: true

Example 2:
1
2
Input:  "88"
Output: true

Example 3:
1
2
Input:  "962"
Output: false

这道题定义了一种对称数,就是说一个数字旋转 180 度和原来一样,也就是倒过来看一样,比如 609,倒过来还是 609 等等,满足这种条件的数字其实没有几个,只有 0,1,8,6,9。这道题其实可以看做求回文数的一种特殊情况,还是用双指针来检测,首尾两个数字如果相等的话,只有它们是 0,1,8 中间的一个才行,如果它们不相等的话,必须一个是6一个是9,或者一个是9一个是6,其他所有情况均返回 false。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool isStrobogrammatic(string num) {
int l = 0, r = num.size() - 1;
while (l <= r) {
if (num[l] == num[r]) {
if (num[l] != '1' && num[l] != '0' && num[l] != '8'){
return false;
}
} else {
if ((num[l] != '6' || num[r] != '9') && (num[l] != '9' || num[r] != '6')) {
return false;
}
}
++l; --r;
}
return true;
}
};

Leetcode247. Strobogrammatic Number II

A strobogrammatic number is a number that looks the same when rotated 180 degrees (looked at upside down). Find all strobogrammatic numbers that are of length = n.

For example, Given n = 2, return [“11”,”69”,”88”,”96”].

可以像是一层层的给字符串从里向外穿衣服一样DFS生成所有的解.

其中翻转之后和自身相等有0, 1, 8, 在n为奇数的情况下最里面的一个数可以为这三个数的任意一个. 再外边就一次给两端添加一个对称的字符. 如果是最外层的话需要注意不能是为0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
void DFS(int n, string str)
{
if(n==0) return result.push_back(str);
if(n%2==1) for(auto val: same) DFS(n-1, val);
if(n%2==1) return;
for(int i = (n==2)?1:0; i < two.size(); i++)
DFS(n-2, two[i].first + str + two[i].second);
}

vector<string> findStrobogrammatic(int n) {
if(n <= 0) return {};
DFS(n, "");
return result;
}
private:
vector<string> result;
vector<string> same{"0", "1", "8"};
vector<pair<char,char>> two{{'0','0'},{'1','1'},{'6','9'},{'8','8'},{'9','6'}};
};

Leetcode252. Meeting Rooms

Given an array of meeting time intervals consisting of start and end times [[s1,e1],[s2,e2],…] (si < ei), determine if a person could attend all meetings.

Example 1:

1
2
Input: [[0,30],[5,10],[15,20]]
Output: false

Example 2:
1
2
Input: [[7,10],[2,4]]
Output: true

NOTE: input types have been changed on April 15, 2019. Please reset to default code definition to get new method signature.

这道题给了我们一堆会议的时间,问能不能同时参见所有的会议,这实际上就是求区间是否有交集的问题,那么最简单暴力的方法就是每两个区间比较一下,看是否有 overlap,有的话直接返回 false 就行了。比较两个区间a和b是否有 overlap,可以检测两种情况,如果a的起始位置大于等于b的起始位置,且此时a的起始位置小于b的结束位置,则一定有 overlap,另一种情况是a和b互换个位置,如果b的起始位置大于等于a的起始位置,且此时b的起始位置小于a的结束位置,那么一定有 overlap,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
bool canAttendMeetings(vector<vector<int>>& intervals) {
for (int i = 0; i < intervals.size(); ++i) {
for (int j = i + 1; j < intervals.size(); ++j) {
if ((intervals[i][0] >= intervals[j][0] && intervals[i][0] < intervals[j][1]) || (intervals[j][0] >= intervals[i][0] && intervals[j][0] < intervals[i][1])) return false;
}
}
return true;
}
};

我们可以先给所有区间排个序,用起始时间的先后来排,然后从第二个区间开始,如果开始时间早于前一个区间的结束时间,则说明会议时间有冲突,返回 false,遍历完成后没有冲突,则返回 true,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
bool canAttendMeetings(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b){return a[0] < b[0];});
for (int i = 1; i < intervals.size(); ++i) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
};

Leetcode253. Meeting Rooms II

Given an array of meeting time intervals consisting of start and end times [[s1,e1],[s2,e2],…] (si < ei), find the minimum number of conference rooms required.

Example 1:

1
2
Input: [[0, 30],[5, 10],[15, 20]]
Output: 2

Example 2:

1
2
Input: [[7,10],[2,4]]
Output: 1

NOTE: input types have been changed on April 15, 2019. Please reset to default code definition to get new method signature.

这道题是之前那道 Meeting Rooms 的拓展,那道题只问我们是否能参加所有的会,也就是看会议之间有没有时间冲突,而这道题让求最少需要安排几个会议室,有时间冲突的肯定需要安排在不同的会议室。这道题有好几种解法,先来看使用 TreeMap 来做的,遍历时间区间,对于起始时间,映射值自增1,对于结束时间,映射值自减1,然后定义结果变量 res,和房间数 rooms,遍历 TreeMap,时间从小到大,房间数每次加上映射值,然后更新结果 res,遇到起始时间,映射是正数,则房间数会增加,如果一个时间是一个会议的结束时间,也是另一个会议的开始时间,则映射值先减后加仍为0,并不用分配新的房间,而结束时间的映射值为负数更不会增加房间数,利用这种思路可以写出代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int minMeetingRooms(vector<vector<int>>& intervals) {
map<int, int> m;
for (auto a : intervals) {
++m[a[0]];
--m[a[1]];
}
int rooms = 0, res = 0;
for (auto it : m) {
res = max(res, rooms += it.second);
}
return res;
}
};

第二种方法是用两个一维数组来做,分别保存起始时间和结束时间,然后各自排个序,定义结果变量 res 和结束时间指针 endpos,然后开始遍历,如果当前起始时间小于结束时间指针的时间,则结果自增1,反之结束时间指针自增1,这样可以找出重叠的时间段,从而安排新的会议室,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int minMeetingRooms(vector<vector<int>>& intervals) {
vector<int> starts, ends;
int res = 0, endpos = 0;
for (auto a : intervals) {
starts.push_back(a[0]);
ends.push_back(a[1]);
}
sort(starts.begin(), starts.end());
sort(ends.begin(), ends.end());
for (int i = 0; i < intervals.size(); ++i) {
if (starts[i] < ends[endpos]) ++res;
else ++endpos;
}
return res;
}
};

Leetcode256. Paint House

There are a row of n houses, each house can be painted with one of the three colors: red, blue or green. The cost of painting each house with a certain color is different. You have to paint all the houses such that no two adjacent houses have the same color.
The cost of painting each house with a certain color is represented by a n x 3 cost matrix. For example, costs[0][0] is the cost of painting house 0 with color red; costs[1][2] is the cost of painting house 1 with color green, and so on… Find the minimum cost to paint all houses.

Note:
All costs are positive integers.

Example:

1
2
3
4
Input: [[17,2,17],[16,16,5],[14,3,19]]
Output: 10
Explanation: Paint house 0 into blue, paint house 1 into green, paint house 2 into blue.
Minimum cost: 2 + 5 + 3 = 10.

解题思路:一道很明显的动态规划的题目. 每个房子有三种染色方案, 那么如果当前房子染红色的话, 最小代价将是上一个房子的绿色和蓝色的最小代价+当前房子染红色的代价. 对另外两种颜色也是如此. 因此动态转移方程为:

  • Sub-problem: find the minimum cost to paint the houses up to current house in red, blue or green.
  • Function:
    • Red: min(f[i - 11][1], f[i - 1][2]) + costs[i][0].
    • Blue: min(f[i - 1][0], f[i - 1][2]) + costs[i][1].
    • Green: min(f[i - 1][0], f[i - 1][1]) + costs[i][2].
    • Initialization: f[0][i] = 0.
    • Answer: min(f[costs.length][i]).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int minCost(vector<vector<int>>& costs) {
int n = costs.size();
// 直接initialize成0更好
vector<vector<int>> dp(n + 1, vector<int>(3, INT_MAX));

for (int i = 0; i < 3; i ++)
dp[0][i] = 0;

for(int i = 1; i < n + 1; i++){
dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + costs[i - 1][0];
dp[i][1] = min(dp[i - 1][0], dp[i - 1][2]) + costs[i - 1][1];
dp[i][2] = min(dp[i - 1][0], dp[i - 1][1]) + costs[i - 1][2];
}

return min(dp[n][0], min(dp[n][1], dp[n][2]));
}
};

Leetcode257. Binary Tree Paths

Given a binary tree, return all root-to-leaf paths.

Note: A leaf is a node with no children.

Example:

1
2
3
4
5
6
7
8
Input:
1
/ \
2 3
\
5
Output: ["1->2->5", "1->3"]
Explanation: All root-to-leaf paths are: 1->2->5, 1->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
class Solution {
public:

vector<string> res;

void dfs(TreeNode* root, string cur) {
if(root->left == NULL && root->right == NULL) {
res.push_back(cur);
return;
}

if(root->left)
dfs(root->left, cur+"->"+to_string(root->left->val));
if(root->right)
dfs(root->right, cur+"->"+to_string(root->right->val));
}

vector<string> binaryTreePaths(TreeNode* root) {
if(root == NULL)
return res;
dfs(root, to_string(root->val));
return res;
}
};

Leetcode258. Add Digits

Given a non-negative integer num, repeatedly add all its digits until the result has only one digit.

Example:

1
2
3
4
Input: 38
Output: 2
Explanation: The process is like: 3 + 8 = 11, 1 + 1 = 2.
Since 2 has only one digit, return it.

逐位相加直到小于10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int addDigits(int num) {
int res = num;
while(res / 10) {
int temp = res, sum = 0;;
while(temp) {
sum += temp%10;
temp /= 10;
}
res = sum;
}
return res;
}
};

Leetcode259. 3Sum Smaller

Given an array of n integers nums and a target, find the number of index triplets i, j, k with 0 <= i < j < k < n that satisfy the condition nums[i] + nums[j] + nums[k] < target.

Example:

1
2
3
4
5
Input: nums = [-2,0,1,3], and target = 2
Output: 2
Explanation: Because there are two triplets which sums are less than 2:
[-2,0,1]
[-2,0,3]

这道题是 3Sum 问题的一个变形,让我们求三数之和小于一个目标值,那么最简单的方法就是穷举法,将所有的可能的三个数字的组合都遍历一遍,比较三数之和跟目标值之间的大小,小于的话则结果自增1,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int threeSumSmaller(vector<int>& nums, int target) {
int res = 0;
sort(nums.begin(), nums.end());
for (int i = 0; i < int(nums.size() - 2); ++i) {
int left = i + 1, right = nums.size() - 1, sum = target - nums[i];
for (int j = left; j <= right; ++j) {
for (int k = j + 1; k <= right; ++k) {
if (nums[j] + nums[k] < sum) ++res;
}
}
}
return res;
}
};

题目中的 Follow up 让我们在 O(n^2) 的时间复杂度内实现,那么借鉴之前那两道题 3Sum Closest 和 3Sum 中的方法,采用双指针来做,这里面有个 trick 就是当判断三个数之和小于目标值时,此时结果应该加上 right-left,因为数组排序了以后,如果加上 num[right] 小于目标值的话,那么加上一个更小的数必定也会小于目标值,然后将左指针右移一位,否则将右指针左移一位,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int threeSumSmaller(vector<int>& nums, int target) {
if (nums.size() < 3) return 0;
int res = 0, n = nums.size();
sort(nums.begin(), nums.end());
for (int i = 0; i < n - 2; ++i) {
int left = i + 1, right = n - 1;
while (left < right) {
if (nums[i] + nums[left] + nums[right] < target) {
res += right - left;
++left;
} else {
--right;
}
}
}
return res;
}
};

Leetcode260. Single Number III

Given an array of numbers nums, in which exactly two elements appear only once and all the other elements appear exactly twice. Find the two elements that appear only once.

Example:

1
2
Input:  [1,2,1,3,2,5]
Output: [3,5]

Note:

  • The order of the result is not important. So in the above example, [5, 3] is also correct.
  • Your algorithm should run in linear runtime complexity. Could you implement it using only constant space complexity?

这道题其实是很巧妙的利用了 Single Number 的解法,因为那道解法是可以准确的找出只出现了一次的数字,但前提是其他数字必须出现两次才行。而这题有两个数字都只出现了一次,那么我们如果能想办法把原数组分为两个小数组,不相同的两个数字分别在两个小数组中,这样分别调用 Single Number 的解法就可以得到答案。那么如何实现呢,首先我们先把原数组全部异或起来,那么我们会得到一个数字,这个数字是两个不相同的数字异或的结果,我们取出其中任意一位为 ‘1’ 的位,为了方便起见,我们用 a &= -a 来取出最右端为 ‘1’ 的位,具体来说下这个是如何操作的吧。就拿题目中的例子来说,如果我们将其全部 ‘异或’ 起来,我们知道相同的两个数 ‘异或’ 的话为0,那么两个1,两个2,都抵消了,就剩3和5 ‘异或’ 起来,那么就是二进制的 11 和 101 ‘异或’ ,得到110。然后我们进行 a &= -a 操作。首先变负数吧,在二进制中负数采用补码的形式,而补码就是反码 +1,那么 110 的反码是 11…1001,那么加1后是 11…1010,然后和 110 相与,得到了 10,就是代码中的 diff 变量。得到了这个 diff,就可以将原数组分为两个数组了。为啥呢,我们想阿,如果两个相同的数字 ‘异或’ ,每位都会是0,而不同的数字 ‘异或’ ,一定会有对应位不同,一个0一个1,这样 ‘异或’ 是1。比如3和5的二进制 11 和 101,如果从低往高看,最开始产生不同的就是第二位,那么我们用第二位来和数组中每个数字相与,根据结果的不同,一定可以把3和5区分开来,而其他的数字由于是成对出现,所以区分开来也是成对的,最终都会 ‘异或’ 成0,不会3和5产生影响。分别将两个小组中的数字都异或起来,就可以得到最终结果了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
vector<int> singleNumber(vector<int>& nums) {
int diff = accumulate(nums.begin(), nums.end(), 0, bit_xor<int>());
diff &= -diff;
vector<int> res(2, 0);
for (auto &a : nums) {
if (a & diff) res[0] ^= a;
else res[1] ^= a;
}
return res;
}
};

Leetcode261. Graph Valid Tree

Given n nodes labeled from 0 to n-1 and a list of undirected edges (each edge is a pair of nodes), write a function to check whether these edges make up a valid tree.

Example 1:

1
2
3
Input: 
n = 5, and edges = [[0,1], [0,2], [0,3], [1,4]]
Output: true

Example 2:

1
2
3
Input: 
n = 5, and edges = [[0,1], [1,2], [2,3], [1,3], [1,4]]
Output: false

Note: you can assume that no duplicate edges will appear in edges. Since all edges are undirected, [0,1] is the same as [1,0] and thus will not appear together in edges.

这道题给了一个无向图,让我们来判断其是否为一棵树,如果是树的话,所有的节点必须是连接的,也就是说必须是连通图,而且不能有环,所以焦点就变成了验证是否是连通图和是否含有环。首先用 DFS 来做,根据 pair 来建立一个图的结构,用邻接链表来表示,还需要一个一位数组v来记录某个结点是否被访问过,然后用 DFS 来搜索结点0,遍历的思想是,当 DFS 到某个结点,先看当前结点是否被访问过,如果已经被访问过,说明环存在,直接返回 false,如果未被访问过,现在将其状态标记为已访问过,然后到邻接链表里去找跟其相邻的结点继续递归遍历,注意还需要一个变量 pre 来记录上一个结点,以免回到上一个结点,这样遍历结束后,就把和结点0相邻的节点都标记为 true,然后再看v里面是否还有没被访问过的结点,如果有,则说明图不是完全连通的,返回 false,反之返回 true,参见代码如下:

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 Solution {
public:
bool validTree(int n, vector<pair<int, int>>& edges) {
vector<vector<int>> g(n, vector<int>());
vector<bool> v(n, false);
for (auto a : edges) {
g[a.first].push_back(a.second);
g[a.second].push_back(a.first);
}
if (!dfs(g, v, 0, -1)) return false;
for (auto a : v) {
if (!a) return false;
}
return true;
}
bool dfs(vector<vector<int>> &g, vector<bool> &v, int cur, int pre) {
if (v[cur]) return false;
v[cur] = true;
for (auto a : g[cur]) {
if (a != pre) {
if (!dfs(g, v, a, cur)) return false;
}
}
return true;
}
};

下面来看 BFS 的解法,思路很相近,需要用 queue 来辅助遍历,这里没有用一维向量来标记节点是否访问过,而是用了一个 HashSet,如果遍历到一个节点,在 HashSet 中没有,则加入 HashSet,如果已经存在,则返回false,还有就是在遍历邻接链表的时候,遍历完成后需要将结点删掉,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
bool validTree(int n, vector<pair<int, int>>& edges) {
vector<unordered_set<int>> g(n, unordered_set<int>());
unordered_set<int> s{{0}};
queue<int> q{{0}};
for (auto a : edges) {
g[a.first].insert(a.second);
g[a.second].insert(a.first);
}
while (!q.empty()) {
int t = q.front(); q.pop();
for (auto a : g[t]) {
if (s.count(a)) return false;
s.insert(a);
q.push(a);
g[a].erase(t);
}
}
return s.size() == n;
}
};

我们再来看 Union Find 的方法,这种方法对于解决连通图的问题很有效,思想是遍历节点,如果两个节点相连,将其 roots 值连上,这样可以找到环,初始化 roots 数组为 -1,然后对于一个 pair 的两个节点分别调用 find 函数,得到的值如果相同的话,则说明环存在,返回 false,不同的话,将其 roots 值 union 上,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool validTree(int n, vector<pair<int, int>>& edges) {
vector<int> roots(n, -1);
for (auto a : edges) {
int x = find(roots, a.first), y = find(roots, a.second);
if (x == y) return false;
roots[x] = y;
}
return edges.size() == n - 1;
}
int find(vector<int> &roots, int i) {
while (roots[i] != -1) i = roots[i];
return i;
}
};

Leetcode263. Ugly Number

Write a program to check whether a given number is an ugly number. Ugly numbers are positive numbers whose prime factors only include 2, 3, 5.

Example 1:

1
2
3
Input: 6
Output: true
Explanation: 6 = 2 × 3

Example 2:
1
2
3
Input: 8
Output: true
Explanation: 8 = 2 × 2 × 2

Example 3:
1
2
3
Input: 14
Output: false
Explanation: 14 is not ugly since it includes another prime factor 7.

检测一个数是否为丑陋数,所谓丑陋数就是其质数因子只能是 2,3,5。那么最直接的办法就是不停的除以这些质数,如果剩余的数字是1的话就是丑陋数了。
1
2
3
4
5
6
7
8
9
10
class Solution {
public:
bool isUgly(int num) {
if (num <= 0) return false;
while (num % 2 == 0) num /= 2;
while (num % 3 == 0) num /= 3;
while (num % 5 == 0) num /= 5;
return num == 1;
}
};

Leetcode264. Ugly Number II

An ugly number is a positive integer whose prime factors are limited to 2, 3, and 5.

Given an integer n, return the nth ugly number.

Example 1:

1
2
3
Input: n = 10
Output: 12
Explanation: [1, 2, 3, 4, 5, 6, 8, 9, 10, 12] is the sequence of the first 10 ugly numbers.

Example 2:

1
2
3
Input: n = 1
Output: 1
Explanation: 1 has no prime factors, therefore all of its prime factors are limited to 2, 3, and 5.

这道题是之前那道 Ugly Number 的拓展,这里让找到第n个丑陋数,还好题目中给了很多提示,基本上相当于告诉我们解法了,根据提示中的信息,丑陋数序列可以拆分为下面3个子列表:

(1) 1x2 , 2x2, 2x2 , 3x2, 3x2 , 4x2 , 5x2…

(2) 1x3, 1x3 , 2x3, 2x3, 2x3 , 3x3, 3x3…

(3) 1x5, 1x5, 1x5, 1x5 , 2x5, 2x5, 2x5…

仔细观察上述三个列表,可以发现每个子列表都是一个丑陋数分别乘以 2,3,5,而要求的丑陋数就是从已经生成的序列中取出来的,每次都从三个列表中取出当前最小的那个加入序列,请参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int nthUglyNumber(int n) {
vector<int> res(1, 1);
int i2 = 0, i3 = 0, i5 = 0;
while (res.size() < n) {
int m2 = res[i2] * 2, m3 = res[i3] * 3, m5 = res[i5] * 5;
int mn = min(m2, min(m3, m5));
if (mn == m2) ++i2;
if (mn == m3) ++i3;
if (mn == m5) ++i5;
res.push_back(mn);
}
return res.back();
}
};

Leetcode266. Palindrome Permutation

Given a string, determine if a permutation of the string could form a palindrome.

Example 1:

1
2
Input: "code"
Output: false

Example 2:
1
2
Input: "aab"
Output: true

Example 3:
1
2
Input: "carerac"
Output: true

Hint:

  • Consider the palindromes of odd vs even length. What difference do you notice?
  • Count the frequency of each character.

这道题让我们判断一个字符串的全排列有没有是回文字符串的,那么根据题目中的提示,我们分字符串的个数是奇偶的情况来讨论,如果是偶数的话,由于回文字符串的特性,每个字母出现的次数一定是偶数次,当字符串是奇数长度时,只有一个字母出现的次数是奇数,其余均为偶数,那么利用这个特性我们就可以解题,我们建立每个字母和其出现次数的映射,然后我们遍历 HashMap,统计出现次数为奇数的字母的个数,那么只有两种情况是回文数,第一种是没有出现次数为奇数的字母,再一个就是字符串长度为奇数,且只有一个出现次数为奇数的字母,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
bool canPermutePalindrome(string s) {
unordered_map<char, int> m;
int cnt = 0;
for (auto a : s) ++m[a];
for (auto a : m) {
if (a.second % 2 == 1) ++cnt;
}
return cnt == 0 || (s.size() % 2 == 1 && cnt == 1);
}
};

Leetcode268. Missing Number

Given an array containing n distinct numbers taken from 0, 1, 2, …, n, find the one that is missing from the array.

Example 1:

1
2
Input: [3,0,1]
Output: 2

Example 2:
1
2
Input: [9,6,4,2,3,5,7,0,1]
Output: 8

随机从0到size()选取了n个数,其中只有一个丢失了(显然的)。
别人的算法:数学推出,0到size()的总和减去当前数组和sum.
1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int missingNumber(vector<int>& nums) {
int sum = 0;
int n = nums.size();
for(int i = 0; i < n; i ++) {
sum += nums[i];
}
return n*(n+1)/2 - sum;
}
};

这道问题被标注为位运算问题:参考讨论区的位运算解法:

异或运算xor,
0 ^ a = a ^ 0 =a
a ^ b = b ^ a
a ^ a = 0
0到size()间的所有数一起与数组中的数进行异或运算,
因为同则0,0异或某个未出现的数将存活下来

1
2
3
4
5
6
7
8
9
class Solution {
public:
int missingNumber(vector<int>& nums) {
int res = 0;
for (int i = 1; i <= nums.size(); i++)
res =res ^ i ^ nums[i-1];
return res;
}
};

Leetcode270. Closest Binary Search Tree Value

Given a non-empty binary search tree and a target value, find the value in the BST that is closest to the target.

Note: Given target value is a floating point. You are guaranteed to have only one unique value in the BST that is closest to the target.

Example:

1
2
3
4
5
6
7
8
9
Input: root = [4,2,5,1,3], target = 3.714286

4
/ \
2 5
/ \
1 3

Output: 4

这道题让我们找一个二分搜索数的跟给定值最接近的一个节点值,由于是二分搜索树,所以博主最先想到用中序遍历来做,一个一个的比较,维护一个最小值,不停的更新,实际上这种方法并没有提高效率,用其他的遍历方法也可以,参见代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int closestValue(TreeNode* root, double target) {
double d = numeric_limits<double>::max();
int res = 0;
stack<TreeNode*> s;
TreeNode *p = root;
while (p || !s.empty()) {
while (p) {
s.push(p);
p = p->left;
}
p = s.top(); s.pop();
if (d >= abs(target - p->val)) {
d = abs(target - p->val);
res = p->val;
}
p = p->right;
}
return res;
}
};

实际我们可以利用二分搜索树的特点 (左<根<右) 来快速定位,由于根节点是中间值,在往下遍历时,根据目标值和根节点的值大小关系来比较,如果目标值小于节点值,则应该找更小的值,于是到左子树去找,反之去右子树找,参见代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int closestValue(TreeNode* root, double target) {
int res = root->val;
while (root) {
if (abs(res - target) >= abs(root->val - target)) {
res = root->val;
}
root = target < root->val ? root->left : root->right;
}
return res;
}
};

Leetcode273. Integer to English Words

Convert a non-negative integer to its english words representation. Given input is guaranteed to be less than 231 - 1.

For example,

1
2
3
123 -> "One Hundred Twenty Three"
12345 -> "Twelve Thousand Three Hundred Forty Five"
1234567 -> "One Million Two Hundred Thirty Four Thousand Five Hundred Sixty Seven"

这道题让我们把一个整型数转为用英文单词描述,就像在check上写钱数的方法,我最开始的方法特别复杂,因为我用了几个switch语句来列出所有的单词,但是我看网上大神们的解法都是用数组来枚举的,特别的巧妙而且省地方,膜拜学习中。题目中给足了提示,首先告诉我们要3个一组的进行处理,而且题目中限定了输入数字范围为0到231 - 1之间,最高只能到billion位,3个一组也只需处理四组即可,那么我们需要些一个处理三个一组数字的函数,我们需要把1到19的英文单词都列出来,放到一个数组里,还要把20,30,… 到90的英文单词列出来放到另一个数组里,然后我们需要用写技巧,比如一个三位数n,百位数表示为n/100,后两位数一起表示为n%100,十位数表示为n%100/10,个位数表示为n%10,然后我们看后两位数是否小于20,小于的话直接从数组中取出单词,如果大于等于20的话,则分别将十位和个位数字的单词从两个数组中取出来。然后再来处理百位上的数字,还要记得加上Hundred。主函数中调用四次这个帮助函数,然后中间要插入”Thousand”, “Million”, “Billion”到对应的位置,最后check一下末尾是否有空格,把空格都删掉,返回的时候检查下输入是否为0,是的话要返回’Zero’。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
string numberToWords(int num) {
string res = convertHundred(num % 1000);
vector<string> v = {"Thousand", "Million", "Billion"};
for (int i = 0; i < 3; ++i) {
num /= 1000;
res = num % 1000 ? convertHundred(num % 1000) + " " + v[i] + " " + res : res;
}
while (res.back() == ' ') res.pop_back();
return res.empty() ? "Zero" : res;
}
string convertHundred(int num) {
vector<string> v1 = {"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"};
vector<string> v2 = {"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"};
string res;
int a = num / 100, b = num % 100, c = num % 10;
res = b < 20 ? v1[b] : v2[b / 10] + (c ? " " + v1[c] : "");
if (a > 0) res = v1[a] + " Hundred" + (b ? " " + res : "");
return res;
}
};

Leetcode274. H-Index

Given an array of citations (each citation is a non-negative integer) of a researcher, write a function to compute the researcher’s h-index.

According to the definition of h-index on Wikipedia: “A scientist has index h if h of his/her N papers have at least h citations each, and the other N − h papers have no more than h citations each.”

Example:

1
2
3
4
5
6
Input: citations = [3,0,6,1,5]
Output: 3
Explanation: [3,0,6,1,5] means the researcher has 5 papers in total and each of them had
received 3, 0, 6, 1, 5 citations respectively.
Since the researcher has 3 papers with at least 3 citations each and the remaining
two with no more than 3 citations each, her h-index is 3.

Note: If there are several possible values for h , the maximum one is taken as the h-index.

这道题让我们求H指数,这个质数是用来衡量研究人员的学术水平的质数,定义为一个人的学术文章有n篇分别被引用了n次,那么H指数就是n。而且wiki上直接给出了算法,可以按照如下方法确定某人的H指数:1、将其发表的所有SCI论文按被引次数从高到低排序;2、从前往后查找排序后的列表,直到某篇论文的序号大于该论文被引次数。所得序号减一即为H指数。我也就没多想,直接按照上面的方法写出了代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int hIndex(vector<int>& citations) {
sort(citations.begin(), citations.end());
int res = 0, size = citations.size();
for (int i = 0; i < size; i ++) {
if (citations[i] >= size-i) {
res = max(res, size-i);
}
}
return res;
}
};

Leetcode275. H-Index II

Given an array of citations sorted in ascending order (each citation is a non-negative integer) of a researcher, write a function to compute the researcher’s h-index.

According to the definition of h-index on Wikipedia: “A scientist has index h if h of his/her N papers have at least h citations each, and the other N − h papers have no more than h citations each.”

Example:

1
2
3
4
5
6
Input: citations = [0,1,3,5,6]
Output: 3
Explanation: [0,1,3,5,6] means the researcher has 5 papers in total and each of them had
received 0, 1, 3, 5, 6 citations respectively.
Since the researcher has 3 papers with at least 3 citations each and the remaining
two with no more than 3 citations each, her h-index is 3.

Note: If there are several possible values for h , the maximum one is taken as the h-index.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int hIndex(vector<int>& citations) {
int size = citations.size();
int left = 0, right = size-1;
while(left <= right) {
int mid = left + (right-left)/2;
if (citations[mid] == size-mid)
return size-mid;
else if (citations[mid] > size-mid)
right = mid - 1;
else
left = mid + 1;
}
return size - left;
}
};

Leetcode278. First Bad Version

You are a product manager and currently leading a team to develop a new product. Unfortunately, the latest version of your product fails the quality check. Since each version is developed based on the previous version, all the versions after a bad version are also bad.

Suppose you have n versions [1, 2, …, n] and you want to find out the first bad one, which causes all the following ones to be bad.

You are given an API bool isBadVersion(version) which will return whether version is bad. Implement a function to find the first bad version. You should minimize the number of calls to the API.

Example:

Given n = 5, and version = 4 is the first bad version.

call isBadVersion(3) -> false
call isBadVersion(5) -> true
call isBadVersion(4) -> true

Then 4 is the first bad version.

  1. 找出一个序列中第一个出错的位置,可以理解位这个序列是有序的,利用二分查找找到第一个位置
  2. 二分查找在处理的时候,如果不是,start = mid + 1; 是的话应该直接赋值start, 因为这时候这个值有可能就是第一个值
  3. 为什么要用 start + (end - start)/2 这种写法,而不是直接用(start + end)/2?这是为了防止大数溢出,假设这时候start已经是一个很大的数了,就会产生溢出
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Solution extends VersionControl {
    public int firstBadVersion(int n) {
    int start = 1, end = n;
    while (start < end){
    int mid = start + (end - start)/2;
    if(!isBadVersion(mid)) start = mid + 1;
    else end = mid;
    }
    return start;
    }
    }

Leetcode279. Perfect Squares

Given a positive integer n , find the least number of perfect square numbers (for example, 1, 4, 9, 16, …) which sum to n.

Example 1:

1
2
3
Input: n = 12
Output: 3
Explanation: 12 = 4 + 4 + 4.

Example 2:

1
2
3
Input: n = 13
Output: 2
Explanation: 13 = 4 + 9.

这道题说是给我们一个正整数,求它最少能由几个完全平方数组成。这道题是考察四平方和定理。先来看第一种很高效的方法,根据四平方和定理,任意一个正整数均可表示为4个整数的平方和,其实是可以表示为4个以内的平方数之和,那么就是说返回结果只有 1,2,3 或4其中的一个,首先我们将数字化简一下,由于一个数如果含有因子4,那么我们可以把4都去掉,并不影响结果,比如2和8,3和12等等,返回的结果都相同,读者可自行举更多的栗子。还有一个可以化简的地方就是,如果一个数除以8余7的话,那么肯定是由4个完全平方数组成,这里就不证明了,因为我也不会证明,读者可自行举例验证。那么做完两步后,一个很大的数有可能就会变得很小了,大大减少了运算时间,下面我们就来尝试的将其拆为两个平方数之和,如果拆成功了那么就会返回1或2,因为其中一个平方数可能为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int numSquares(int n) {
while (n % 4 == 0) n /= 4;
if (n % 8 == 7) return 4;
for (int a = 0; a * a <= n; ++a) {
int b = sqrt(n - a * a);
if (a * a + b * b == n) {
return !!a + !!b;
}
}
return 3;
}
};

这道题远不止这一种解法,我们还可以用动态规划 Dynamic Programming 来做,我们建立一个长度为 n+1 的一维dp数组,将第一个值初始化为0,其余值都初始化为INT_MAX,i从0循环到n,j从1循环到i+j<=n的位置,然后每次更新dp[i+j]的值,动态更新 dp 数组,其中dp[i]表示正整数i至少由多个完全平方数组成,那么我们求n,就是返回dp[n]即可,也就是 dp 数组的最后一个数字。需要注意的是这里的写法,i必须从0开始,j必须从1开始,因为我们的初衷是想用dp[i]来更新dp[i + j * j],如果i=0j=1了,那么dp[i]dp[i + j * j]就相等了。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i <= n; ++i) {
for (int j = 1; i + j * j <= n; ++j) {
dp[i + j * j] = min(dp[i + j * j], dp[i] + 1);
}
}
return dp.back();
}
};

Leetcode282. Expression Add Operators

Given a string that contains only digits 0-9 and a target value, return all possibilities to add binaryoperators (not unary) +, -, or * between the digits so they evaluate to the target value.

Example 1:

1
2
Input: _num_ = "123", _target_ = 6
Output: ["1+2+3", "1*2*3"]

Example 2:

1
2
Input: _num_ = "232", _target_ = 8
Output: ["2*3+2", "2+3*2"]

Example 3:

1
2
Input: _num_ = "105", _target_ = 5
Output: ["1*0+5","10-5"]

Example 4:

1
2
Input: _num_ = "00", _target_ = 0
Output: ["0+0", "0-0", "0*0"]

Example 5:

1
2
Input: _num_ = "3456237490", _target_ = 9191
Output: []

这道题给了我们一个只由数字组成的字符串,让我们再其中添加+,-或号来形成一个表达式,该表达式的计算和为给定了target值,让我们找出所有符合要求的表达式来。看了题目中的例子1和2,很容易让人误以为是必须拆成个位数字,其实不是的,比如例子3中的 “105”, 5能返回”10-5”,说明连着的数字也可以。如果非要在过往的题中找一道相似的题,我觉得跟 Combination Sum II 很类似。不过这道题要更复杂麻烦一些。还是用递归来解题,我们需要两个变量diff和curNum,一个用来记录将要变化的值,另一个是当前运算后的值,而且它们都需要用 long 型的,因为字符串转为int型很容易溢出,所以我们用长整型。对于加和减,diff就是即将要加上的数和即将要减去的数的负值,而对于乘来说稍有些复杂,此时的diff应该是上一次的变化的diff乘以即将要乘上的数,有点不好理解,那我们来举个例子,比如 2+32,即将要运算到乘以2的时候,上次循环的 curNum = 5, diff = 3, 而如果我们要算这个乘2的时候,新的变化值diff应为 32=6,而我们要把之前+3操作的结果去掉,再加上新的diff,即 (5-3)+6=8,即为新表达式 2+32 的值,有点难理解,大家自己一步一步推算吧。

还有一点需要注意的是,如果输入为”000”,0的话,容易出现以下的错误:

Wrong:[“0+0+0”,”0+0-0”,”0+00”,”0-0+0”,”0-0-0”,”0-00”,”00+0”,”00-0”,”000”,”0+00”,”0-00”,”000”,”00+0”,”00-0”,”000”,”000”]

Correct:[“000”,”00+0”,”00-0”,”0+00”,”0+0+0”,”0+0-0”,”0-00”,”0-0+0”,”0-0-0”]

我们可以看到错误的结果中有0开头的字符串出现,明显这不是数字,所以我们要去掉这些情况,过滤方法也很简单,我们只要判断长度大于1且首字符是‘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
class Solution {
public:
vector<string> addOperators(string num, int target) {
vector<string> res;
helper(num, target, 0, 0, "", res);
return res;
}
void helper(string num, int target, long diff, long curNum, string out, vector<string>& res) {
if (num.size() == 0 && curNum == target) {
res.push_back(out); return;
}
for (int i = 1; i <= num.size(); ++i) {
string cur = num.substr(0, i);
if (cur.size() > 1 && cur[0] == '0') return;
string next = num.substr(i);
if (out.size() > 0) {
helper(next, target, stoll(cur), curNum + stoll(cur), out + "+" + cur, res);
helper(next, target, -stoll(cur), curNum - stoll(cur), out + "-" + cur, res);
helper(next, target, diff * stoll(cur), (curNum - diff) + diff * stoll(cur), out + "*" + cur, res);
} else {
helper(next, target, stoll(cur), stoll(cur), cur, res);
}
}
}
};
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
class Solution {
public:
string s;
int target;
vector<string> res;
void dfs(const string& exp, int pos, long long sum, long long lastval) {
if (pos == s.length()) {
if (sum == target)
res.push_back(exp);
return;
}

const string ops = pos == 0 ? "+" : "+-*";
long long val = 0;
for (int i = pos; i < s.length(); i ++) {
if (i > pos && s[pos] == '0')
break;
val = val * 10 + s[i] - '0';

// 整个s的第一个符号只能是+
for (char op : ops) {
string newexpr;
if (!pos)
newexpr = s.substr(pos, i-pos+1);
else
newexpr = exp + string(1, op) + s.substr(pos, i-pos+1);
if (op == '+') {
dfs(newexpr, i+1, sum+val, val);
}
else if (op == '-') {
dfs(newexpr, i+1, sum - val, -val);
} else {
dfs(newexpr, i+1, sum - lastval + lastval * val, lastval * val);
}
}
}

}
vector<string> addOperators(string num, int target) {
this->s = num;
this->target = target;

dfs("", 0, 0, 0);
return res;
}
};

Leetcode283. Move Zeroes

Given an array nums, write a function to move all 0’s to the end of it while maintaining the relative order of the non-zero elements.

Example:

1
2
Input: [0,1,0,3,12]
Output: [1,3,12,0,0]

Note:

  • You must do this in-place without making a copy of the array.
  • Minimize the total number of operations.

把0移动到数组末尾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int begin = 0, end = 0;
if(nums.size() == 1)
return;
while(begin < nums.size() && end < nums.size()) {
end = begin;
if(nums[begin] == 0) {
while(end < nums.size() && nums[end] == 0)
end ++;
if(end == nums.size())
break;
int temp = nums[begin];
nums[begin] = nums[end];
nums[end] = temp;
}
begin ++;
}
}
};

优化方法:
1
2
3
4
5
6
7
void moveZeroes(vector<int>& nums) {
for (int lastNonZeroFoundAt = 0, cur = 0; cur < nums.size(); cur++) {
if (nums[cur] != 0) {
swap(nums[lastNonZeroFoundAt++], nums[cur]);
}
}
}

Leetcode287. Find the Duplicate Number

Given an array nums containing n + 1 integers where each integer is between 1 and n (inclusive), prove that at least one duplicate number must exist. Assume that there is only one duplicate number, find the duplicate one.

Example 1:

1
2
Input: [1,3,4,2,2]
Output: 2

Example 2:

1
2
Input: [3,1,3,4,2]
Output: 3

Note:

  • You must not modify the array (assume the array is read only).
  • You must use only constant, O (1) extra space.
  • Your runtime complexity should be less than O ( n 2).
  • There is only one duplicate number in the array, but it could be repeated more than once.

这道题给了我们 n+1 个数,所有的数都在 [1, n] 区域内,首先让证明必定会有一个重复数,这不禁让博主想起了小学华罗庚奥数中的抽屉原理(又叫鸽巢原理),即如果有十个苹果放到九个抽屉里,如果苹果全在抽屉里,则至少有一个抽屉里有两个苹果,这里就不证明了,直接来做题吧。题目要求不能改变原数组,即不能给原数组排序,又不能用多余空间,那么哈希表神马的也就不用考虑了,又说时间小于 O(n2),也就不能用 brute force 的方法,那也就只能考虑用二分搜索法了,在区间 [1, n] 中搜索,首先求出中点 mid,然后遍历整个数组,统计所有小于等于 mid 的数的个数,如果个数小于等于 mid,则说明重复值在 [mid+1, n] 之间,反之,重复值应在 [1, mid-1] 之间,然后依次类推,直到搜索完成,此时的 low 就是我们要求的重复值,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int left = 1, right = nums.size();
while (left < right){
int mid = left + (right - left) / 2, cnt = 0;
for (int num : nums) {
if (num <= mid) ++cnt;
}
if (cnt <= mid) left = mid + 1;
else right = mid;
}
return right;
}
};

另一种方法的基本思想是将数组抽象为一条线和一个圆环,因为1~n之间有n+1个数,所以一定有重复数字出现,所以重复的数字即是圆环与线的交汇点。然后设置两个指针,一个快指针一次走两步,一个慢指针一次走一步。当两个指针第一次相遇时,令快指针回到原点(0)且也变成一次走一步,慢指针则继续前进,再次回合时即是线与圆环的交汇点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int fast = nums[nums[0]], slow = nums[0];
while(fast != slow) {
fast = nums[nums[fast]];
slow = nums[slow];
}
fast = 0;
while(fast != slow) {
fast = nums[fast];
slow = nums[slow];
}
return slow;
}
};

Leetcode289. Game of Life

According to the Wikipedia’s article: “The Game of Life, also known simply as Life, is a cellular automaton devised by the British mathematician John Horton Conway in 1970.”

Given a board with m by n cells, each cell has an initial state live (1) or dead (0). Each cell interacts with its eight neighbors (horizontal, vertical, diagonal) using the following four rules (taken from the above Wikipedia article):

  • Any live cell with fewer than two live neighbors dies, as if caused by under-population.
  • Any live cell with two or three live neighbors lives on to the next generation.
  • Any live cell with more than three live neighbors dies, as if by over-population..
  • Any dead cell with exactly three live neighbors becomes a live cell, as if by reproduction.

Write a function to compute the next state (after one update) of the board given its current state. The next state is created by applying the above rules simultaneously to every cell in the current state, where births and deaths occur simultaneously.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Input: 
[
[0,1,0],
[0,0,1],
[1,1,1],
[0,0,0]
]
Output:
[
[0,0,0],
[1,0,1],
[0,1,1],
[0,1,0]
]

Follow up:

  • Could you solve it in-place? Remember that the board needs to be updated at the same time: You cannot update some cells first and then use their updated values to update other cells.
  • In this question, we represent the board using a 2D array. In principle, the board is infinite, which would cause problems when the active area encroaches the border of the array. How would you address these problems?

这道题是有名的 康威生命游戏,这是一种细胞自动机,每一个位置有两种状态,1为活细胞,0为死细胞,对于每个位置都满足如下的条件:

  1. 如果活细胞周围八个位置的活细胞数少于两个,则该位置活细胞死亡
  2. 如果活细胞周围八个位置有两个或三个活细胞,则该位置活细胞仍然存活
  3. 如果活细胞周围八个位置有超过三个活细胞,则该位置活细胞死亡
  4. 如果死细胞周围正好有三个活细胞,则该位置死细胞复活

由于题目中要求用置换方法 in-place 来解题,所以就不能新建一个相同大小的数组,那么只能更新原有数组,题目中要求所有的位置必须被同时更新,但在循环程序中还是一个位置一个位置更新的,当一个位置更新了,这个位置成为其他位置的 neighbor 时,怎么知道其未更新的状态呢?可以使用状态机转换:

  • 状态0: 死细胞转为死细胞
  • 状态1: 活细胞转为活细胞
  • 状态2: 活细胞转为死细胞
  • 状态3: 死细胞转为活细胞

最后对所有状态对2取余,则状态0和2就变成死细胞,状态1和3就是活细胞,达成目的。先对原数组进行逐个扫描,对于每一个位置,扫描其周围八个位置,如果遇到状态1或2,就计数器累加1,扫完8个邻居,如果少于两个活细胞或者大于三个活细胞,而且当前位置是活细胞的话,标记状态2,如果正好有三个活细胞且当前是死细胞的话,标记状态3。完成一遍扫描后再对数据扫描一遍,对2取余变成我们想要的结果。参见代码如下:

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 Solution {
public:
void gameOfLife(vector<vector<int> >& board) {
int m = board.size(), n = m ? board[0].size() : 0;
vector<int> dx{-1, -1, -1, 0, 1, 1, 1, 0};
vector<int> dy{-1, 0, 1, 1, 1, 0, -1, -1};
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
int cnt = 0;
for (int k = 0; k < 8; ++k) {
int x = i + dx[k], y = j + dy[k];
if (x >= 0 && x < m && y >= 0 && y < n && (board[x][y] == 1 || board[x][y] == 2)) {
++cnt;
}
}
if (board[i][j] && (cnt < 2 || cnt > 3)) board[i][j] = 2;
else if (!board[i][j] && cnt == 3) board[i][j] = 3;
}
}
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
board[i][j] %= 2;
}
}
}
};

Leetcode290. Word Pattern

Given a pattern and a string str, find if str follows the same pattern.

Here follow means a full match, such that there is a bijection between a letter in pattern and a non-empty word in str.

Example 1:

1
2
Input: pattern = "abba", str = "dog cat cat dog"
Output: true

Example 2:
1
2
Input:pattern = "abba", str = "dog cat cat fish"
Output: false

Example 3:
1
2
Input: pattern = "aaaa", str = "dog cat cat dog"
Output: false

Example 4:
1
2
Input: pattern = "abba", str = "dog dog dog dog"
Output: false

Notes:
You may assume pattern contains only lowercase letters, and str contains lowercase letters that may be separated by a single space.

给定一种规律 pattern 和一个字符串 str ,判断 str 中的单词和pattern的字母是否遵循相同的映射, pattern 里的每个字母和字符串 str 中的每个非空单词之间存在着双向连接的对应规律。

自己的代码,在测评的帮助下加了很多的boundary case,超过了双百:

  • Runtime: 0 ms, faster than 100.00% of C++ online submissions for Word Pattern.
  • Memory Usage: 6.7 MB, less than 100.00% of C++ online submissions for Word Pattern.
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
class Solution {
public:
bool wordPattern(string pattern, string str) {
map<string, char> mapp;
string word;
str.push_back(' ');
int i=0, j=0, count=0;;
while(i<str.size()) {
word="";
while(str[i]!=' ') {
word += str[i];
i++;
}
i++;
count ++; // str一共有多少个单词
if (mapp.find(word)==mapp.end()) {
int temp=0;
while(temp<j) {
if (pattern[temp]==pattern[j])
break;
temp ++;
}
if(temp == j)
mapp[word] = pattern[j];
else
return false;
}
else
if (mapp[word] != pattern[j])
return false;
j++;
}
if(count != pattern.size())
return false;
return true;
}
};

看看人家的思路:这道题目主要考察哈希表和字符串的内容。可以将题目拆解为下面三步:

  • 设置pattern字符到单词(字符串 str)的映射(哈希),使用HashMap()存储;使用HashSet() 记录被使用过的单词 。
  • 若单词个数和pattern字符个数不匹配,返回false;
  • 遍历pattern,同时对应的向前移动 str 中单词的指针,每次拆分出pattern中的一个字符, 判断:
    • 如果该字符从未出现在哈希表中:
    • 如果该字符对应的单词已被使用过 ,即HashSet()中包含该字符对应的单词,则返回false;
    • 将该字符与其对应的单词做映射,加入哈希表中;标记该字符指向的单词为已使用,并加入HashSet()
    • 如果该字符在哈希表的映射单词与当前指向的单词不同,则返回false;
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 Solution {
public boolean wordPattern(String pattern, String str) {
HashMap<Character, String> map = new HashMap<>();
HashSet<String> set = new HashSet<>();
String[] array = str.split(" ");

if (pattern.length() != array.length) {
return false;
}
for (int i = 0; i < pattern.length(); i++) {
char key = pattern.charAt(i);
if (!map.containsKey(key)) {
if (set.contains(array[i])) {
return false;
}
map.put(key, array[i]);
set.add(array[i]);
} else {
if (!map.get(key).equals(array[i])) {
return false;
}
}
}
return true;
}
}

Leetcode292. Nim Game

You are playing the following Nim Game with your friend: There is a heap of stones on the table, each time one of you take turns to remove 1 to 3 stones. The one who removes the last stone will be the winner. You will take the first turn to remove the stones.

Both of you are very clever and have optimal strategies for the game. Write a function to determine whether you can win the game given the number of stones in the heap.

Example:

1
2
3
4
5
Input: 4
Output: false
Explanation: If there are 4 stones in the heap, then you will never win the game;
No matter 1, 2, or 3 stones you remove, the last stone will always be
removed by your friend.

规律就是当有4,8,12,16….4n…时,我一定输;其他情况我一定赢。

因为当为4n时,我拿后剩下4n-1,4n-2,4n-3块,对方可以拿到4n-4=4(n-1)块。然后我再拿,对方再拿到4(n-2)块。。无论我怎么拿,对方总能拿到最后剩下4块。。。这样我就输了。同理,不为4n时,我总能拿到4n,这样对方就输了。

1
2
3
4
5
6
class Solution {
public:
bool canWinNim(int n) {
return n % 4 != 0;
}
};

Leetcode293. Flip Game

You are playing the following Flip Game with your friend: Given a string that contains only these two characters: + and -, you and your friend take turns to flip twoconsecutive “++” into “—“. The game ends when a person can no longer make a move and therefore the other person will be the winner.

Write a function to compute all possible states of the string after one valid move.

For example, given s = “++++”, after one move, it may become one of the following states:

1
2
3
4
5
[
"--++",
"+--+",
"++--"
]

If there is no valid move, return an empty list [].

这道题让我们把相邻的两个 ++ 变成 —,真不是一道难题,就从第二个字母开始遍历,每次判断当前字母是否为+,和之前那个字母是否为+,如果都为加,则将翻转后的字符串存入结果中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
vector<string> generatePossibleNextMoves(string s) {
vector<string> res;
for (int i = 1; i < s.size(); i ++) {
if (s[i] == '+' && s[i - 1] == '+') {
res.push_back(s.substr(0, i - 1) + "--" + s.substr(i + 1));
}
}
return res;
}
};

Leetcode295. Find Median from Data Stream

The median is the middle value in an ordered integer list. If the size of the list is even, there is no middle value and the median is the mean of the two middle values.

For example, for arr = [2,3,4], the median is 3.
For example, for arr = [2,3], the median is (2 + 3) / 2 = 2.5.
Implement the MedianFinder class:

MedianFinder() initializes the MedianFinder object.
void addNum(int num) adds the integer num from the data stream to the data structure.
double findMedian() returns the median of all elements so far. Answers within 10-5 of the actual answer will be accepted.

Example 1:

1
2
3
4
5
Input
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]
Output
[null, null, null, 1.5, null, 2.0]

Explanation

1
2
3
4
5
6
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1); // arr = [1]
medianFinder.addNum(2); // arr = [1, 2]
medianFinder.findMedian(); // return 1.5 (i.e., (1 + 2) / 2)
medianFinder.addNum(3); // arr[1, 2, 3]
medianFinder.findMedian(); // return 2.0

这道题给我们一个数据流,让我们找出中位数,由于数据流中的数据并不是有序的,所以我们首先应该想个方法让其有序。如果我们用vector来保存数据流的话,每进来一个新数据都要给数组排序,很不高效。所以之后想到用multiset这个数据结构,是有序保存数据的,但是它不能用下标直接访问元素,找中位数也不高效。这里用到的解法十分巧妙,我们使用大小堆来解决问题,其中大堆保存右半段较大的数字,小堆保存左半段较小的数组。这样整个数组就被中间分为两段了,由于堆的保存方式是由大到小,我们希望大堆里面的数据是从小到大,这样取第一个来计算中位数方便。我们用到一个小技巧,就是存到大堆里的数先取反再存,这样由大到小存下来的顺序就是实际上我们想要的从小到大的顺序。当大堆和小堆中的数字一样多时,我们取出大堆小堆的首元素求平均值,当小堆元素多时,取小堆首元素为中位数,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MedianFinder {
public:

// Adds a number into the data structure.
void addNum(int num) {
small.push(num);
large.push(-small.top());
small.pop();
if (small.size() < large.size()) {
small.push(-large.top());
large.pop();
}
}

// Returns the median of current data stream
double findMedian() {
return small.size() > large.size() ? small.top() : 0.5 *(small.top() - large.top());
}

private:
priority_queue<long> small, large;
};

LeetCode296. Best Meeting Point

A group of two or more people wants to meet and minimize the total travel distance. You are given a 2D grid of values 0 or 1, where each 1 marks the home of someone in the group. The distance is calculated using Manhattan Distance, where distance(p1, p2) = |p2.x - p1.x| + |p2.y - p1.y|.

Example:

1
2
3
4
5
6
7
8
Input: 
1 - 0 - 0 - 0 - 1
| | | | |
0 - 0 - 0 - 0 - 0
| | | | |
0 - 0 - 1 - 0 - 0

Output: 6

Explanation: Given three people living at (0,0), (0,4), and (2,2), The point (0,2) is an ideal meeting point, as the total travel distance of 2+2+2=6 is minimal. So return 6.

这道题让我们求最佳的开会地点,该地点需要到每个为1的点的曼哈顿距离之和最小,题目中给了提示,让从一维的情况来分析,先看一维时有两个点A和B的情况,

1
______A_____P_______B_______

可以发现,只要开会为位置P在 [A, B] 区间内,不管在哪,距离之和都是A和B之间的距离,如果P不在 [A, B] 之间,那么距离之和就会大于A和B之间的距离,现在再加两个点C和D:

1
______C_____A_____P_______B______D______

通过分析可以得出,P点的最佳位置就是在 [A, B] 区间内,这样和四个点的距离之和为AB距离加上 CD 距离,在其他任意一点的距离都会大于这个距离,那么分析出来了上述规律,这题就变得很容易了,只要给位置排好序,然后用最后一个坐标减去第一个坐标,即 CD 距离,倒数第二个坐标减去第二个坐标,即 AB 距离,以此类推,直到最中间停止,那么一维的情况分析出来了,二维的情况就是两个一维相加即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int minTotalDistance(vector<vector<int>>& grid) {
vector<int> rows, cols;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[i].size(); ++j) {
if (grid[i][j] == 1) {
rows.push_back(i);
cols.push_back(j);
}
}
}
return minTotalDistance(rows) + minTotalDistance(cols);
}
int minTotalDistance(vector<int> v) {
int res = 0;
sort(v.begin(), v.end());
int i = 0, j = v.size() - 1;
while (i < j) res += v[j--] - v[i++];
return res;
}
};

我们也可以不用多写一个函数,直接对 rows 和 cols 同时处理,稍稍能简化些代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int minTotalDistance(vector<vector<int>>& grid) {
vector<int> rows, cols;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[i].size(); ++j) {
if (grid[i][j] == 1) {
rows.push_back(i);
cols.push_back(j);
}
}
}
sort(cols.begin(), cols.end());
int res = 0, i = 0, j = rows.size() - 1;
while (i < j) res += rows[j] - rows[i] + cols[j--] - cols[i++];
return res;
}
};

Leetcode297. Serialize and Deserialize Binary Tree

Serialization is the process of converting a data structure or object into a sequence of bits so that it can be stored in a file or memory buffer, or transmitted across a network connection link to be reconstructed later in the same or another computer environment.

Design an algorithm to serialize and deserialize a binary tree. There is no restriction on how your serialization/deserialization algorithm should work. You just need to ensure that a binary tree can be serialized to a string and this string can be deserialized to the original tree structure.

Clarification: The input/output format is the same as how LeetCode serializes a binary tree. You do not necessarily need to follow this format, so please be creative and come up with different approaches yourself.

Example 1:

1
2
Input: root = [1,2,3,null,null,4,5]
Output: [1,2,3,null,null,4,5]

Example 2:

1
2
Input: root = []
Output: []

二叉树的序列化与反序列化

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
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
const int N = 200000;
char buf[N];

class Codec {
public:
int length;

void dfs(TreeNode* root) {
if (length)
buf[length++] = ',';
if (!root) {
buf[length++] = '#';
return;
}

string val = to_string(root->val);
for (char c : val)
buf[length++] = c;

dfs(root->left);
dfs(root->right);
}

// Encodes a tree to a single string.
string serialize(TreeNode* root) {
length = 0;
dfs(root);
buf[length] = 0;
return string(buf);
}

TreeNode* gen(string data, int& cur) {
if (cur >= data.length())
return NULL;
if (data[cur] == '#') {
cur += 2;
return NULL;
}

int flag = 1, val = 0;
if (data[cur] == '-') {
cur ++;
flag = -1;
}

while(cur < data.length() && data[cur] != ',') {
val = val * 10 + data[cur] - '0';
cur ++;
}
TreeNode* root = new TreeNode(flag * val);
cur ++;
root->left = gen(data, cur);
root->right = gen(data, cur);
return root;
}

// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
int cur = 0;
return gen(data, cur);
}
};

// Your Codec object will be instantiated and called as such:
// Codec ser, deser;
// TreeNode* ans = deser.deserialize(ser.serialize(root));

Leetcode299. Bulls and Cows

You are playing the following Bulls and Cows game with your friend: You write a 4-digit secret number and ask your friend to guess it, each time your friend guesses a number, you give a hint, the hint tells your friend how many digits are in the correct positions (called “bulls”) and how many digits are in the wrong positions (called “cows”), your friend will use those hints to find out the secret number.

For example:

1
2
Secret number:  1807
Friend's guess: 7810

According to Wikipedia: “Bulls and Cows (also known as Cows and Bulls or Pigs and Bulls or Bulls and Cleots) is an old code-breaking mind or paper and pencil game for two or more players, predating the similar commercially marketed board game Mastermind. The numerical version of the game is usually played with 4 digits, but can also be played with 3 or any other number of digits.”

Write a function to return a hint according to the secret number and friend’s guess, use A to indicate the bulls and B to indicate the cows, in the above example, your function should return 1A3B.

You may assume that the secret number and your friend’s guess only contain digits, and their lengths are always equal.

这道题提出了一个叫公牛母牛的游戏,有一个四位数字,你猜一个结果,然后根据你猜的结果和真实结果做对比,提示有多少个数字和位置都正确的叫做bulls,还提示有多少数字正确但位置不对的叫做cows,根据这些信息来引导我们继续猜测正确的数字。这道题并没有让我们实现整个游戏,而只用实现一次比较即可。给出两个字符串,让我们找出分别几个bulls和cows。这题需要用哈希表,来建立数字和其出现次数的映射。我最开始想的方法是用两次遍历,第一次遍历找出所有位置相同且值相同的数字,即bulls,并且记录secret中不是bulls的数字出现的次数。然后第二次遍历我们针对guess中不是bulls的位置,如果在哈希表中存在,cows自增1,然后映射值减1,参见如下代码:

解法一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
string getHint(string secret, string guess) {
int m[256] = {0}, bulls = 0, cows = 0;
for (int i = 0; i < secret.size(); ++i) {
if (secret[i] == guess[i]) ++bulls;
else ++m[secret[i]];
}
for (int i = 0; i < secret.size(); ++i) {
if (secret[i] != guess[i] && m[guess[i]]) {
++cows;
--m[guess[i]];
}
}
return to_string(bulls) + "A" + to_string(cows) + "B";
}
};

我们其实可以用一次循环就搞定的,在处理不是bulls的位置时,我们看如果secret当前位置数字的映射值小于0,则表示其在guess中出现过,cows自增1,然后映射值加1,如果guess当前位置的数字的映射值大于0,则表示其在secret中出现过,cows自增1,然后映射值减1,参见代码如下:

解法二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
string getHint(string secret, string guess) {
int m[256] = {0}, bulls = 0, cows = 0;
for (int i = 0; i < secret.size(); ++i) {
if (secret[i] == guess[i]) ++bulls;
else {
if (m[secret[i]]++ < 0) ++cows;
if (m[guess[i]]-- > 0) ++ cows;
}
}
return to_string(bulls) + "A" + to_string(cows) + "B";
}
};

Leetcode300. Longest Increasing Subsequence

Given an unsorted array of integers, find the length of longest increasing subsequence.

Example:

1
2
3
Input: [10,9,2,5,3,7,101,18]
Output: 4
Explanation: The longest increasing subsequence is [2,3,7,101], therefore the length is 4.

Note:

  • There may be more than one LIS combination, it is only necessary for you to return the length.
  • Your algorithm should run in O( n2 ) complexity.

这道题让我们求最长递增子串 Longest Increasing Subsequence 的长度,简称 LIS 的长度。首先来看一种动态规划 Dynamic Programming 的解法,这种解法的时间复杂度为 O(n2),类似 brute force 的解法,维护一个一维 dp 数组,其中 dp[i] 表示以 nums[i] 为结尾的最长递增子串的长度,对于每一个 nums[i],从第一个数再搜索到i,如果发现某个数小于 nums[i],更新 dp[i],更新方法为 dp[i] = max(dp[i], dp[j] + 1),即比较当前 dp[i] 的值和那个小于 num[i] 的数的 dp 值加1的大小,就这样不断的更新 dp 数组,到最后 dp 数组中最大的值就是要返回的 LIS 的长度,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> dp(nums.size(), 1);
int res = 0;
for (int i = 0; i < nums.size(); ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
res = max(res, dp[i]);
}
return res;
}
};

下面来看一种优化时间复杂度到 O(nlgn) 的解法,这里用到了二分查找法,所以才能加快运行时间哇。思路是,先建立一个数组 ends,把首元素放进去,然后比较之后的元素,如果遍历到的新元素比 ends 数组中的首元素小的话,替换首元素为此新元素,如果遍历到的新元素比 ends 数组中的末尾元素还大的话,将此新元素添加到 ends 数组末尾(注意不覆盖原末尾元素)。如果遍历到的新元素比 ends 数组首元素大,比尾元素小时,此时用二分查找法找到第一个不小于此新元素的位置,覆盖掉位置的原来的数字,以此类推直至遍历完整个 nums 数组,此时 ends 数组的长度就是要求的LIS的长度,特别注意的是 ends 数组的值可能不是一个真实的 LIS,比如若输入数组 nums 为 {4, 2, 4, 5, 3, 7},那么算完后的 ends 数组为 {2, 3, 5, 7},可以发现它不是一个原数组的 LIS,只是长度相等而已,千万要注意这点。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.empty()) return 0;
vector<int> ends{nums[0]};
for (auto a : nums) {
if (a < ends[0]) ends[0] = a;
else if (a > ends.back()) ends.push_back(a);
else {
int left = 0, right = ends.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (ends[mid] < a) left = mid + 1;
else right = mid;
}
ends[right] = a;
}
}
return ends.size();
}
};

decltype简介

我们之前使用的typeid运算符来查询一个变量的类型,这种类型查询在运行时进行。RTTI机制为每一个类型产生一个type_info类型的数据,而typeid查询返回的变量相应type_info数据,通过name成员函数返回类型的名称。同时在C++11中typeid还提供了hash_code这个成员函数,用于返回类型的唯一哈希值。RTTI会导致运行时效率降低,且在泛型编程中,我们更需要的是编译时就要确定类型,RTTI并无法满足这样的要求。编译时类型推导的出现正是为了泛型编程,在非泛型编程中,我们的类型都是确定的,根本不需要再进行推导。

而编译时类型推导,除了我们说过的auto关键字,还有本文的decltype。

decltype与auto关键字一样,用于进行编译时类型推导,不过它与auto还是有一些区别的。decltype的类型推导并不是像auto一样是从变量声明的初始化表达式获得变量的类型,而是总是以一个普通表达式作为参数,返回该表达式的类型,而且decltype并不会对表达式进行求值。

decltype用法

推导出表达式类型

1
2
int i = 4;
decltype(i) a; //推导结果为int。a的类型为int。

与using/typedef合用,用于定义类型。

1
2
3
4
5
6
7
8
9
10
using size_t = decltype(sizeof(0));//sizeof(a)的返回值为size_t类型
using ptrdiff_t = decltype((int*)0 - (int*)0);
using nullptr_t = decltype(nullptr);
vector<int >vec;

typedef decltype(vec.begin()) vectype;
for (vectype i = vec.begin; i != vec.end(); i++)
{
//...
}

这样和auto一样,也提高了代码的可读性。

重用匿名类型

在C++中,我们有时候会遇上一些匿名类型,如:

1
2
3
4
5
struct 
{
int d ;
doubel b;
}anon_s;

而借助decltype,我们可以重新使用这个匿名的结构体:

1
decltype(anon_s) as ;//定义了一个上面匿名的结构体

泛型编程中结合auto,用于追踪函数的返回值类型

这也是decltype最大的用途了。

1
2
3
4
5
template <typename _Tx, typename _Ty>
auto multiply(_Tx x, _Ty y)->decltype(_Tx*_Ty)
{
return x*y;
}

decltype推导四规则

  1. 如果e是一个没有带括号的标记符表达式或者类成员访问表达式,那么的decltype(e)就是e所命名的实体的类型。此外,如果e是一个被重载的函数,则会导致编译错误。
  2. 否则 ,假设e的类型是T,如果e是一个将亡值,那么decltype(e)为T&&
  3. 否则,假设e的类型是T,如果e是一个左值,那么decltype(e)为T&。
  4. 否则,假设e的类型是T,则decltype(e)为T。

标记符指的是除去关键字、字面量等编译器需要使用的标记之外的程序员自己定义的标记,而单个标记符对应的表达式即为标记符表达式。例如:

1
int arr[4]

则arr为一个标记符表达式,而arr[3]+0不是。

我们来看下面这段代码:

1
2
3
int i=10;
decltype(i) a; //a推导为int
decltype((i))b=i;//b推导为int&,必须为其初始化,否则编译错误

仅仅为i加上了(),就导致类型推导结果的差异。这是因为,i是一个标记符表达式,根据推导规则1,类型被推导为int。而(i)为一个左值表达式,所以类型被推导为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
36
37
38
39
40
41
42
43
44
int i = 4;
int arr[5] = { 0 };
int *ptr = arr;
struct S{ double d; }s ;
void Overloaded(int);
void Overloaded(char);//重载的函数
int && RvalRef();
const bool Func(int);

//规则一:推导为其类型
decltype (arr) var1; //int 标记符表达式

decltype (ptr) var2;//int * 标记符表达式

decltype(s.d) var3;//doubel 成员访问表达式

//decltype(Overloaded) var4;//重载函数。编译错误。

//规则二:将亡值。推导为类型的右值引用。

decltype (RvalRef()) var5 = 1;

//规则三:左值,推导为类型的引用。

decltype ((i))var6 = i; //int&

decltype (true ? i : i) var7 = i; //int& 条件表达式返回左值。

decltype (++i) var8 = i; //int& ++i返回i的左值。

decltype(arr[5]) var9 = i;//int&. []操作返回左值

decltype(*ptr)var10 = i;//int& *操作返回左值

decltype("hello")var11 = "hello"; //const char(&)[9] 字符串字面常量为左值,且为const左值。


//规则四:以上都不是,则推导为本类型

decltype(1) var12;//const int

decltype(Func(1)) var13=true;//const bool

decltype(i++) var14 = i;//int i++返回右值

这里需要提示的是,字符串字面值常量是个左值,且是const左值,而非字符串字面值常量则是个右值。
这么多规则,对于我们写代码的来说难免太难记了,特别是规则三。我们可以利用C++11标准库中添加的模板类is_lvalue_reference来判断表达式是否为左值:
1
cout << is_lvalue_reference<decltype(++i)>::value << endl;

结果1表示为左值,结果为0为非右值。
同样的,也有is_rvalue_reference这样的模板类来判断decltype推断结果是否为右值。

几种继承及其特点

public的变量和函数在类的内部外部都可以访问。

protected的变量和函数只能在类的内部和其派生类中访问。

private修饰的元素只能在类内访问。

成员默认属性

  • struct的成员默认是公有的
  • class的成员默认是私有的
  • class继承默认是私有继承
  • struct的继承默认是公有的

公有继承方式(public)

注意事项:

  • 基类的私有成员,子类不可以访问
  • 基类的保护成员,子类可以继承为自己的保护成员,在派生类可以访问,在外部不可以访问。
  • 基类的公有成员,子类可以继承为自己的公有成员。在派生类可以访问,在外部也可以访问。

保护继承(protected)

  • 基类公有成员,子类中继承为自己的保护成员,在派生类可以访问,在外部不可以访问
  • 基类保护成员,子类中继承为自己的保护成员,在派生类可以访问,在外部不可以访问
  • 基类私有成员,子类一样不可以访问基类的私有成员。

私有继承(private)

私有继承方式的,就是在继承时,把protected变成private,它需要注意的事项为:

  1. 基类公有成员,子类中继承为自己的私有成员,在派生类可以访问,在外部不可以访问。
  2. 基类保护成员,子类中继承为自己的私有成员,在派生类可以访问,在外部不可以访问。
  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
class Base
{
public:
void fun()
{
cout << "Base::fun()" << endl;
}
};
// 子类 父类
class D :public Base // 继承
{
public:
void fun()
{
cout << "D::fun()" << endl;
}
void show()
{
cout << "D::shoe()" << endl;
}
};
void main()
{
D d;
Base *pb = &d;
pb->fun();// 只能访问子类中父类所有的fun函数
d.fun(); // 只能访问子类自己的fun函数
}

派生类的默认成员函数

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函 数,则必须在派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类 对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调派生类构造。
  6. 派生类对象析构清理先调用派生类析构再调基类的析构

继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。

同样我们看代码 如下:

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
//继承与静态成员
//基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。如下
class Test
{
public:
Test()
{
count++;
}
public:
int GetCount()const
{
return count;
}
//int GetCount()const
//{
// return GetOBJCount();
//}
private:
static int count;// 类的静态成员必须在类外初始化
//因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的
};
int Test::count = 0;
class D1 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
class D2 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
class D3 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
class D4 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
void main()
{
D1 d1;
cout << d1.GetCount() << endl;
D2 d2;
cout << d2.GetCount() << endl;
D3 d3;
cout << d3.GetCount() << endl;
D4 d4;
cout << d4.GetCount() << endl;
}

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一 个static成员实例 。

static修饰的成员,只能在类中进行声明,类外定义,原因是因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的。

多态性

(1)解释多态性:函数的多种不同的实现方式即为多态

(2)必要性:在继承中,有时候基类的一些函数在派生类中也是有用的,但是功能不够全或者两者的功能实现方式就是不一样的,这个时候就希望重载那个基类的函数,但是为了不再调用这个函数时,出现不知道调用基类的还是子类的情况出现,于是就提出了多态。如果语言不知多态,则不能称为面向对象的。

(3)多态性是如何实现的:多态是实现是依赖于虚函数来实现的,之所以虚函数可以分清楚当前调用的函数是基类的还是派生类的,主要在于基类和派生类分别有着自己的虚函数表,再调用虚函数时,它们是通过去虚函数表去对应的函数的。

其实虚函数表的本质就是一种迟后联编的过程,正常编译都是先期联编的,但是当代码遇到了virtual时,就会把它当做迟后联编,但是为了迟后编译,就生成了局部变量–虚函数表,这就增大了一些空间上的消耗。(前提是两个函数的返回类型,参数类型,参数个数都得相同,不然就起不到多态的作用)

使用虚函数的一些限制

  1. 只有类成员函数才能声明为虚函数,这是因为虚函数只适用于有继承关系的类对象中。
  2. 静态成员函数不能说明为虚函数,因为静态成员函数不受限与某个对象,整个内存中只有一个,所以不会出现混淆的情况
  3. 内联函数不可以被继承,因为内联函数是不能子啊运行中动态的确认其位置的。
  4. 构造函数不可以被继承。
  5. 析构函数可以被继承,而且通常声明为虚函数。

纯虚函数

(1)解释:虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态重载。纯虚函数的声明有着特殊的语法格式:virtual 返回值类型成员函数名(参数表)=0;

(2)必要性:在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重载以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。

(3)抽象类的解释:包含纯虚函数的类称为抽象类。由于抽象类包含了没有定义的纯虚函数,所以不能定义抽象类的对象。在C++中,抽象类只能用于被继承而不能直接创建对象的类(Abstract Class)。

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

class A
{
public :
virtual void fun() = 0;
};
class B :public A
{
public :
virtual void fun()
{
cout << "B: " << endl;

}
};

int main()
{
B b;
b.fun();
return 0;
}

继承权限

  • public继承
    • 公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问
  • protected继承
    • 保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的.
  • private继承
    • 私有继承的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承

模板类的继承

模板类的继承包括四种:

1.(普通类继承模板类)

1
2
3
4
5
6
7
8
template<class T>
class TBase{
T data;
……
};
class Derived:public TBase<int>{
……
};

2.(模板类继承了普通类(非常常见))

1
2
3
4
5
6
7
8
class TBase{
……
};
template<class T>
class TDerived:public TBase{
T data;
……
};

3.(类模板继承类模板)

1
2
3
4
5
6
7
8
9
10
template<class T>
class TBase{
T data1;
……
};
template<class T1,class T2>
class TDerived:public TBase<T1>{
T2 data2;
……
};

4.(模板类继承类模板,即继承模板参数给出的基类)

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

class BaseA{
public:
BaseA(){cout<<"BaseA founed"<<endl;}
};
class BaseB{
public:
BaseB(){cout<<"BaseB founed"<<endl;}
};
template<typename T, int rows>
class BaseC{
private:
T data;
public:
BaseC():data(rows){
cout<<"BaseC founed "<< data << endl;}
};
template<class T>
class Derived:public T{
public:
Derived():T(){cout<<"Derived founed"<<endl;}
};

void main()
{
Derived<BaseA> x;// BaseA作为基类
Derived<BaseB> y;// BaseB作为基类
Derived<BaseC<int, 3> > z; // BaseC<int,3>作为基类
}

reverse函数

C++ < algorithm > 中定义的reverse函数用于反转在[first,last)范围内的顺序

1
2
template <class BidirectionalIterator>
void reverse (BidirectionalIterator first,BidirectionalIterator last);

例如,交换vector容器中元素的顺序
1
2
vector<int> v={1,2,3,4,5};
reverse(v.begin(),v.end());//v的值为5,4,3,2,1

当然,你也可以通过它方便的反转string类的字符串
1
2
string str="C++REVERSE";
reverse(str.begin(),str.end());//str结果为ESREVER++C

该函数等价于通过调用iter_swap来交换元素位置

1
2
3
4
5
6
7
8
9
template <class BidirectionalIterator>
void reverse (BidirectionalIterator first, BidirectionalIterator last)
{
while ((first!=last)&&(first!=--last))
{
std::iter_swap (first,last);
++first;
}
}

C++中constexpr作用

constexpr 是 C++ 11 标准新引入的关键字,不过在讲解其具体用法和功能之前,读者需要先搞清楚 C++ 常量表达式的含义。

所谓常量表达式,指的就是由多个(≥1)常量组成的表达式。换句话说,如果表达式中的成员都是常量,那么该表达式就是一个常量表达式。这也意味着,常量表达式一旦确定,其值将无法修改。

实际开发中,我们经常会用到常量表达式。以定义数组为例,数组的长度就必须是一个常量表达式:

1
2
3
4
5
6
7
// 1)
int url[10];//正确
// 2)
int url[6 + 4];//正确
// 3)
int length = 6;
int url[length];//错误,length是变量

上述代码演示了 3 种定义 url 数组的方式,其中第 1、2 种定义 url 数组时,长度分别为 10 和 6+4,显然它们都是常量表达式,可以用于表示数组的长度;第 3 种 url 数组的长度为 length,它是变量而非常量,因此不是一个常量表达式,无法用于表示数组的长度。
常量表达式的应用场景还有很多,比如匿名枚举、switch-case 结构中的 case 表达式等,感兴趣的读者可自行编码测试,这里不再过多举例。

我们知道,C++ 程序的执行过程大致要经历编译、链接、运行这 3 个阶段。值得一提的是,常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果;而常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间。

对于用 C++ 编写的程序,性能往往是永恒的追求。那么在实际开发中,如何才能判定一个表达式是否为常量表达式,进而获得在编译阶段即可执行的“特权”呢?除了人为判定外,C++11 标准还提供有 constexpr 关键字。

constexpr 关键字的功能是使指定的常量表达式获得在程序编译阶段计算出结果的能力,而不必等到程序运行阶段。C++ 11 标准中,constexpr 可用于修饰普通变量、函数(包括模板函数)以及类的构造函数。
注意,获得在编译阶段计算出结果的能力,并不代表 constexpr 修饰的表达式一定会在程序编译阶段被执行,具体的计算时机还是编译器说了算。

constexpr修饰普通变量

C++11 标准中,定义变量时可以用 constexpr 修饰,从而使该变量获得在编译阶段即可计算出结果的能力。

值得一提的是,使用 constexpr 修改普通变量时,变量必须经过初始化且初始值必须是一个常量表达式。举个例子:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;
int main()
{
constexpr int num = 1 + 2 + 3;
int url[num] = {1,2,3,4,5,6};
couts<< url[1] << endl;
return 0;
}

程序执行结果为:
1
2

读者可尝试将 constexpr 删除,此时编译器会提示“url[num] 定义中 num 不可用作常量”。

可以看到,程序第 6 行使用 constexpr 修饰 num 变量,同时将 “1+2+3” 这个常量表达式赋值给 num。由此,编译器就可以在编译时期对 num 这个表达式进行计算,因为 num 可以作为定义数组时的长度。

有读者可能发现,将此示例程序中的 constexpr 用 const 关键字替换也可以正常执行,这是因为 num 的定义同时满足“num 是 const 常量且使用常量表达式为其初始化”这 2 个条件,由此编译器会认定 num 是一个常量表达式。
注意,const 和 constexpr 并不相同,关于它们的区别,我们会在下一节做详细讲解。

另外需要重点提出的是,当常量表达式中包含浮点数时,考虑到程序编译和运行所在的系统环境可能不同,常量表达式在编译阶段和运行阶段计算出的结果精度很可能会受到影响,因此 C++11 标准规定,浮点常量表达式在编译阶段计算的精度要至少等于(或者高于)运行阶段计算出的精度。

constexpr修饰函数

constexpr 还可以用于修饰函数的返回值,这样的函数又称为“常量表达式函数”。

注意,constexpr 并非可以修改任意函数的返回值。换句话说,一个函数要想成为常量表达式函数,必须满足如下 4 个条件。

1) 整个函数的函数体中,除了可以包含 using 指令、typedef 语句以及 static_assert 断言外,只能包含一条 return 返回语句。

举个例子:

1
2
3
4
constexpr int display(int x) {
int ret = 1 + 2 + x;
return ret;
}

注意,这个函数是无法通过编译的,因为该函数的返回值用 constexpr 修饰,但函数内部包含多条语句。

如下是正确的定义 display() 常量表达式函数的写法:

1
2
3
4
constexpr int display(int x) {
//可以添加 using 执行、typedef 语句以及 static_assert 断言
return 1 + 2 + x;
}

可以看到,display() 函数的返回值是用 constexpr 修饰的 int 类型值,且该函数的函数体中只包含一个 return 语句。

2) 该函数必须有返回值,即函数的返回值类型不能是 void。

举个例子:

1
2
3
constexpr void display() {
//函数体
}

像上面这样定义的返回值类型为 void 的函数,不属于常量表达式函数。原因很简单,因为通过类似的函数根本无法获得一个常量。

3) 函数在使用之前,必须有对应的定义语句。我们知道,函数的使用分为“声明”和“定义”两部分,普通的函数调用只需要提前写好该函数的声明部分即可(函数的定义部分可以放在调用位置之后甚至其它文件中),但常量表达式函数在使用前,必须要有该函数的定义。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;
//普通函数的声明
int noconst_dis(int x);
//常量表达式函数的声明
constexpr int display(int x);
//常量表达式函数的定义
constexpr int display(int x){
return 1 + 2 + x;
}
int main()
{
//调用常量表达式函数
int a[display(3)] = { 1,2,3,4 };
cout << a[2] << endl;
//调用普通函数
cout << noconst_dis(3) << endl;
return 0;
}
//普通函数的定义
int noconst_dis(int x) {
return 1 + 2 + x;
}

程序执行结果为:
1
2
3
6

读者可自行将 display() 常量表达式函数的定义调整到 main() 函数之后,查看编译器的报错信息。

可以看到,普通函数在调用时,只需要保证调用位置之前有相应的声明即可;而常量表达式函数则不同,调用位置之前必须要有该函数的定义,否则会导致程序编译失败。

4) return 返回的表达式必须是常量表达式,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
using namespace std;
int num = 3;
constexpr int display(int x){
return num + x;
}
int main()
{
//调用常量表达式函数
int a[display(3)] = { 1,2,3,4 };
return 0;
}

该程序无法通过编译,编译器报“display(3) 的结果不是常量”的异常。

常量表达式函数的返回值必须是常量表达式的原因很简单,如果想在程序编译阶段获得某个函数返回的常量,则该函数的 return 语句中就不能包含程序运行阶段才能确定值的变量。

注意,在常量表达式函数的 return 语句中,不能包含赋值的操作(例如 return x=1 在常量表达式函数中不允许的)。另外,用 constexpr 修改函数时,函数本身也是支持递归的,感兴趣的读者可自行尝试编码测试。

constexpr修饰类的构造函数

对于 C++ 内置类型的数据,可以直接用 constexpr 修饰,但如果是自定义的数据类型(用 struct 或者 class 实现),直接用 constexpr 修饰是不行的。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;
//自定义类型的定义
constexpr struct myType {
const char* name;
int age;
//其它结构体成员
};
int main()
{
constexpr struct myType mt { "zhangsan", 10 };
cout << mt.name << " " << mt.age << endl;
return 0;
}

此程序是无法通过编译的,编译器会抛出“constexpr不能修饰自定义类型”的异常。

当我们想自定义一个可产生常量的类型时,正确的做法是在该类型的内部添加一个常量构造函数。例如,修改上面的错误示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;
//自定义类型的定义
struct myType {
constexpr myType(char *name,int age):name(name),age(age){};
const char* name;
int age;
//其它结构体成员
};
int main()
{
constexpr struct myType mt { "zhangsan", 10 };
cout << mt.name << " " << mt.age << endl;
return 0;
}

程序执行结果为:
1
zhangsan 10

可以看到,在 myType 结构体中自定义有一个构造函数,借助此函数,用 constexpr 修饰的 myType 类型的 my 常量即可通过编译。

注意,constexpr 修饰类的构造函数时,要求该构造函数的函数体必须为空,且采用初始化列表的方式为各个成员赋值时,必须使用常量表达式。

前面提到,constexpr 可用于修饰函数,而类中的成员方法完全可以看做是“位于类这个命名空间中的函数”,所以 constexpr 也可以修饰类中的成员函数,只不过此函数必须满足前面提到的 4 个条件。

举个例子:

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>
using namespace std;
//自定义类型的定义
class myType {
public:
constexpr myType(const char *name,int age):name(name),age(age){};
constexpr const char * getname(){
return name;
}
constexpr int getage(){
return age;
}
private:
const char* name;
int age;
//其它结构体成员
};
int main()
{
constexpr struct myType mt { "zhangsan", 10 };
constexpr const char * name = mt.getname();
constexpr int age = mt.getage();
cout << name << " " << age << endl;
return 0;
}

程序执行结果为:
1
zhangsan 10

注意,C++11 标准中,不支持用 constexpr 修饰带有 virtual 的成员方法。

constexpr修饰模板函数

C++11 语法中,constexpr 可以修饰模板函数,但由于模板中类型的不确定性,因此模板函数实例化后的函数是否符合常量表达式函数的要求也是不确定的。

针对这种情况下,C++11 标准规定,如果 constexpr 修饰的模板函数实例化结果不满足常量表达式函数的要求,则 constexpr 会被自动忽略,即该函数就等同于一个普通函数。

举个例子:

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;
//自定义类型的定义
struct myType {
const char* name;
int age;
//其它结构体成员
};
//模板函数
template<typename T>
constexpr T dispaly(T t){
return t;
}
int main()
{
struct myType stu{"zhangsan",10};
//普通函数
struct myType ret = dispaly(stu);
cout << ret.name << " " << ret.age << endl;
//常量表达式函数
constexpr int ret1 = dispaly(10);
cout << ret1 << endl;
return 0;
}

程序执行结果为:
1
2
zhangsan 10
10

可以看到,示例程序中定义了一个模板函数 display(),但由于其返回值类型未定,因此在实例化之前无法判断其是否符合常量表达式函数的要求:

第 20 行代码处,当模板函数中以自定义结构体 myType 类型进行实例化时,由于该结构体中没有定义常量表达式构造函数,所以实例化后的函数不是常量表达式函数,此时 constexpr 是无效的;

第 23 行代码处,模板函数的类型 T 为 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
#include <iostream>
using namespace std;
// C++98/03
template<int N> struct Factorial
{
const static int value = N * Factorial<N - 1>::value;
};
template<> struct Factorial<0>
{
const static int value = 1;
};
// C++11
constexpr int factorial(int n)
{
return n == 0 ? 1 : n * factorial(n - 1);
}
// C++14
constexpr int factorial2(int n)
{
int result = 1;
for (int i = 1; i <= n; ++i)
result *= i;
return result;
}

int main()
{
static_assert(Factorial<3>::value == 6, "error");
static_assert(factorial(3) == 6, "error");
static_assert(factorial2(3) == 6, "error");
int n = 3;
cout << factorial(n) << factorial2(n) << endl; //66
}

代码说明:

  • 以上代码演示了如何在编译期计算3的阶乘。
  • 在C++11之前,在编译期进行数值计算必须使用模板元编程技巧。具体来说我们通常需要定义一个内含编译期常量value的类模板(也称作元函数)。这个类模板的定义至少需要分成两部分,分别用于处理一般情况和特殊情况。
  • 代码示例中Factorial元函数的定义分为两部分:
    • 当模板参数大于0时,利用公式 N!=N*(N-1)! 递归调用自身来计算value的值。
    • 当模板参数为0时,将value设为1这个特殊情况下的值。
  • 在C++11之后,编译期的数值计算可以通过使用constexpr声明并定义编译期函数来进行。相对于模板元编程,使用constexpr函数更贴近普通的C++程序,计算过程显得更为直接,意图也更明显。
  • 但在C++11中constexpr函数所受到的限制较多,比如函数体通常只有一句return语句,函数体内既不能声明变量,也不能使用for语句之类的常规控制流语句。
  • 如factorial函数所示,使用C++11在编译期计算阶乘仍然需要利用递归技巧。
  • C++14解除了对constexpr函数的大部分限制。在C++14的constexpr函数体内我们既可以声明变量,也可以使用goto和try之外大部分的控制流语句。
  • 如factorial2函数所示,使用C++14在编译期计算阶乘只需利用for语句进行常规计算即可。
  • 虽说constexpr函数所定义的是编译期的函数,但实际上在运行期constexpr函数也能被调用。事实上,如果使用编译期常量参数调用constexpr函数,我们就能够在编译期得到运算结果;而如果使用运行期变量参数调用constexpr函数,那么在运行期我们同样也能得到运算结果。
  • 代码第32行所演示的是在运行期使用变量n调用constexpr函数的结果。
  • 准确的说,constexpr函数是一种在编译期和运行期都能被调用并执行的函数。出于constexpr函数的这个特点,在C++11之后进行数值计算时,无论在编译期还是运行期我们都可以统一用一套代码来实现。编译期和运行期在数值计算这点上得到了部分统一。

const的用法

const是不改变的。在C和C++中,我们使用关键字const来使程序元素保持不变。const关键字可以在C++程序的许多上下文中使用。它可以用于:变量、指针、函数参数和返回类型、类数据成员、类成员函数、对象。

  1. 修饰变量,说明该变量不可以被改变;
  2. 修饰指针,分为指向常量的指针(pointer to const)和自身是常量的指针(常量指针,const pointer);
  3. 修饰引用,指向常量的引用(reference to const),用于形参类型,即避免了拷贝,又避免了函数对值的修改;
  4. 修饰成员函数,说明该成员函数内不能修改成员变量。

下面的声明都是什么意思?

1
2
3
4
5
const int a; a是一个常整型数
int const a; a是一个常整型数
const int *a; a是一个指向常整型数的指针,整型数是不可修改的,但指针可以
int * const a; a为指向整型数的常指针,指针指向的整型数可以修改,但指针是不可修改的
int const * a const; a是一个指向常整型数的常指针,指针指向的整型数是不可修改的,同时指针也是不可修改的

const变量

如果你用const关键字做任何变量,你就不能改变它的值。同样,必须在声明的时候初始化常数变量。
Example:

1
2
3
4
5
6
int main
{
const int i = 10;
const int j = i + 10; // works fine
i++; // this leads to Compile time error
}

上面的代码中,我们使 i 成为常量,因此如果我们试图改变它的值,我们将得到编译时错误。尽管我们可以用它来代替其他变量。

指针与const关键字

指针也可以使用const关键字来声明。当我们使用const和指针时,我们可以用两种方式来做:可以把const应用到指针指向的地方,或者我们可以使指针本身成为一个常数。

指向const变量的指针:

意味着指针指向一个const变量。

1
const int* u;

这里,表示u是一个指针,可以指向const int类型变量。指针指向的内容不可改变。简称左定值,因为const位于*号的左边。

我们也可以这样写,

1
char const* v;

表示v是指向const类型的char的指针。
指向const变量的指针非常有用,因为它可以用来使任何字符串或数组不可变

const指针

为了使指针保持不变,我们必须把const关键字放到右边。对于const指针p其指向的内存地址不能够被改变,但其内容可以改变。简称,右定向。因为const位于*号的右边。

1
2
int x = 1;
int* const w = &x;

里,w是一个指针,它是const,指向一个int,现在我们不能改变指针,这意味着它总是指向变量x但是可以改变它指向的值,通过改变x的值。

当你想要一个可以在值中改变但不会在内存中移动的存储器时,常量指针指向一个变量是很有用的。因为指针总是指向相同的内存位置,因为它是用const关键字定义的,但是那个内存位置的值可以被更改。
左定值,右定向,const修饰不变量

const函数参数和返回类型

1
2
3
4
5
6
7
8
9
void f(const int i)
{
i++; // error
}

const int g()
{
return 1;
}

注意几个要点:

①对于内置数据类型,返回const或非const值,不会有任何影响。

1
2
3
4
5
6
7
8
9
10
const int h()
{
return 1;
}

int main()
{
const int j = h();
int k = h();
}

j和k都将被赋值为1。不会出现错误。

②对于用户定义的数据类型,返回const,将阻止它的修改。此时返回的值不能作为左值使用,既不能被赋值,也不能被修改。const 修饰返回的指针或者引用,是否返回一个指向 const 的指针,取决于我们想让用户干什么。

③在程序执行时创建的临时对象总是const类型。值传递的 const 修饰传递,一般这种情况不需要 const 修饰,因为函数会自动产生临时变量复制实参值。
当 const 参数为指针时,可以防止指针被意外篡改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<iostream>

using namespace std;

void Cpf(int *const a)
{
cout<<*a<<" ";
*a = 9;
}

int main(void)
{
int a = 8;
Cpf(&a);
cout<<a; // a 为 9
system("pause");
return 0;
}

自定义类型的参数传递,需要临时对象复制参数,对于临时对象的构造,需要调用构造函数,比较浪费时间,因此我们采取 const 外加引用传递的方法。并且对于一般的 int、double 等内置类型,我们不采用引用的传递方式。

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 namespace std;

class Test
{
public:
Test(){}
Test(int _m):_cm(_m){}
int get_cm()const
{
return _cm;
}

private:
int _cm;
};

void Cmf(const Test& _tt)
{
cout<<_tt.get_cm();
}

int main(void)
{
Test t(8);
Cmf(t);
system("pause");
return 0;
}

④如果一个函数有一个非const参数,它在发出调用时不能传递const参数。

1
2
3
4
void t(int*) 
{
// function logic
}

如果我们把一个const int参数传递给函数t,会出现错误。

⑤但是,一个具有const类型参数的函数,可以传递一个const类型参数以及一个非const参数。

1
2
3
4
void g(const int*) 
{
// function logic
}

这个函数可以有一个int,也可以有const int类型参数。

const修饰函数返回值

(1)指针传递

如果返回const data,non-const pointer,返回值也必须赋给const data,non-const pointer。因为指针指向的数据是常量不能修改。

1
2
3
4
5
6
7
8
9
10
11
const int * mallocA(){  ///const data,non-const pointer
int *a=new int(2);
return a;
}

int main()
{
const int *a = mallocA();
///int *b = mallocA(); ///编译错误
return 0;
}

(2)值传递

如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加const 修饰没有任何价值。所以,对于值传递来说,加const没有太多意义。

所以:

  • 不要把函数int GetInt(void)写成const int GetInt(void)
  • 不要把函数A GetA(void)写成const A GetA(void),其中A 为用户自定义的数据类型。

将类数据成员定义为const

这些是类中的数据变量,使用const关键字定义。它们在声明期间未初始化。它们的初始化在构造函数中完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Test
{
const int i;
public:
Test (int x) : i(x)
{
}
};

int main()
{
Test t(10);
Test s(20);
}

在这个程序中,i 是一个常量数据成员,在每个对象中它的独立副本将会出现,因此它使用构造函数对每个对象进行初始化。一旦初始化,它的值就不能改变

把类对象定义为const

当一个对象被声明或使用const关键字创建时,它的数据成员在对象的生命周期中永远不会被改变。

语法:

1
const class_name object;

例如,如果在上面定义的类测试中,我们想要定义一个常数对象,我们可以这样做:
1
const Test r(30);

将类的成员函数定义为const

const成员函数决不会修改对象中的数据成员。注意:const关键字不能与static关键字同时使用,因为static关键字修饰静态成员函数,静态成员函数不含有this指针,即不能实例化,const成员函数必须具体到某一实例。

如果有个成员函数想修改对象中的某一个成员怎么办?这时我们可以使用mutable关键字修饰这个成员,mutable的意思也是易变的,容易改变的意思,被mutable关键字修饰的成员可以处于不断变化中。

const成员函数不能调用非const成员函数,因为非const成员函数可以会修改成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
class Point{
public :
Point(int _x):x(_x){}
void testConstFunction(int _x) const{

///错误,在const成员函数中,不能修改任何类成员变量
x=_x;

///错误,const成员函数不能调用非onst成员函数,因为非const成员函数可以会修改成员变量
modify_x(_x);
}
void modify_x(int _x){
x=_x;
}
int x;
};

语法:

1
return_type function_name() const;

const对象和const成员函数的例子:

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
class StarWars
{
public:
int i;
StarWars(int x) // constructor
{
i = x;
}

int falcon() const // constant function
{
/*
can do anything but will not
modify any data members
*/
cout << "Falcon has left the Base";
}

int gamma()
{
i++;
}
};

int main()
{
StarWars objOne(10); // non const object
const StarWars objTwo(20); // const object

objOne.falcon(); // No error
objTwo.falcon(); // No error

cout << objOne.i << objTwo.i;

objOne.gamma(); // No error
objTwo.gamma(); // Compile time error
}

输出结果:
1
2
3
Falcon has left the Base
Falcon has left the Base
10 20

在这里,我们可以看到,const成员函数永远不会改变类的数据成员,并且它可以与const和非const对象一起使用。但是const对象不能与试图改变其数据成员的成员函数一起使用。

关于const的疑问:

const常量的判别标准:

  1. 只有字面量初始化的const常量才会进入符号表
  2. 使用其他变量初始化的const常量仍然是只读变量
  3. 被volatile修饰的const常量不会进入符号表

注意:

  1. const引用的类型与初始化变量的类型相同时:初始化变量成为只读变量
  2. const引用的类型与初始化变量的类型不相同时:初生成一个新的只读变量

Example:

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
#include <stdio.h>

int main()
{
const int x = 1; //字面量初始化,此时x为常量,进入符号表
const int& rx = x; //rx代表只读变量

int& nrx = const_cast<int&>(rx); //去掉rx的只读属性

nrx = 5; //改变了nrx内存空间的值

printf("x = %d\n", x); // 1
printf("rx = %d\n", rx); // 5
printf("nrx = %d\n", nrx); // 5
printf("&x = %p\n", &x); // &x = 002CFD80
printf("&rx = %p\n", &rx); // &x = 002CFD80
printf("&nrx = %p\n", &nrx); // &x = 002CFD80
//输出的地址相同,说明了x、rx、nrx代表同样的内存空间

volatile const int y = 2;//volatile代表易变的
int* p = const_cast<int*>(&y);

*p = 6;

printf("y = %d\n", y); //y = 6
printf("p = %p\n", p); //p = 001BF928

//判别是否是常量是编译器在编译时能不能确认它的值
const int z = y;

p = const_cast<int*>(&z);

*p = 7;

printf("z = %d\n", z); // z = 7
printf("p = %p\n", p); //p = 001BF910


char c = 'c';
char& rc = c;
const int& trc = c;

rc = 'a';

printf("c = %c\n", c); // c = a
printf("rc = %c\n", rc);// rc = a
printf("trc = %c\n", trc);//trc = c
//变量c是char类型,而trc是int类型,所以生成了一个新的只读变量

return 0;
}

输出结果:

const与#define的区别

  • const定义的常量是变量带类型,而#define定义的只是个常数不带类型;
  • define只在预处理阶段起作用,简单的文本替换,而const在编译、链接过程中起作用;
  • define只是简单的字符串替换没有类型检查。而const是有数据类型的,是要进行判断的,可以避免一些低级错误;
  • define预处理后,占用代码段空间,const占用数据段空间;
  • const不能重定义,而define可以通过#undef取消某个符号的定义,进行重定义;
  • define独特功能,比如可以用来防止文件重复引用。

const重载

可以看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A {
int count() {
std::cout << "non const" << std::endl;
return 1;
}

int count() const {
std::cout << "const" << std::endl;
return 1;
}
};

int main() {
A a;
a.count();
const A b;
b.count();
}

这段代码输出的是这样:

1
2
non const
const

const修饰的对象调用的是使用const修饰的方法,非const对象调用的是非const的方法。

看下面的这段代码:

1
2
A a;
a.func();

其实到底层,函数可能会变成这样:

1
func(A* a);

函数是在代码段,对象是在数据段,调用不同对象的函数,其实只不过是同一个函数,传递了不同的数据参数而已。

上面的是把对象的this指针传进去。

再回到上面的代码:

1
2
3
4
5
6
7
8
9
10
struct A {
int count() {
std::cout << "non const" << std::endl;
return 1;
}
int count() const {
std::cout << "const" << std::endl;
return 1;
}
};

可以理解为:

1
2
int count(A *);
int count(const A*);

咦,这不就是重载吗,难道还有const重载?

还真有,看下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A {
int count(const int& s) {
std::cout << "const" << std::endl;
return 1;
}

int count(int& s) {
std::cout << "non const" << std::endl;
return 1;
}
};

int main() {
A a;
a.count(4);
int c = 5;
a.count(c);
}

输出如下:

1
2
const
non const

所以得出结论:

不只是参数类型和个数不同会产生重载,const修饰的参数也会有重载。

但是只有当const修饰的是指针或者引用类型时才可以,普通的int和const int会编译失败的,具体大家可以自己写代码试试。

宏定义#define的理解与资料整理

利用define来定义 数值宏常量

#define宏定义是个演技非常高超的替身演员,但也会经常耍大牌的,所以我们用它要慎之又慎。它可以出现在代码的任何地方,从本行宏定义开始,以后的代码就就都认识这个宏了;也可以把任何东西定义成宏。因为编译器会在预编译的时候用真身替换替身,而在我们的代码里面却又用常常用替身来帮忙。

看例子:

1
#define PI 3.141592654

在此后的代码中你尽可以使用PI 来代替3.141592654,而且你最好就这么做。不然的话,如果我要把PI 的精度再提高一些,你是否愿意一个一个的去修改这串数呢?你能保证不漏不出错?而使用PI 的话,我们却只需要修改一次(这是十分高效的)。

这种情况还不是最要命的,我们再看一个例子:

1
#define ERROR_POWEROFF  -1

如果你在代码里不用ERROR_POWEROFF 这个宏而用-1,尤其在函数返回错误代码的时候(往往一个开发一个系统需要定义很多错误代码)。肯怕上帝都无法知道-1 表示的是什么意思吧。这个-1,我们一般称为“魔鬼数”,上帝遇到它也会发狂的。所以,我奉劝你代码里一定不要出现“魔鬼数”。(这里是从代码可读性的角度进行考虑!)

但是我们利用define来定义数值类型的数据,一般只是用来定义 常量 ,如果 要定义一些变量,则可以使用c语言中const这个关键字。

我们已经讨论了const 这个关键字,我们知道const 修饰的数据是有类型的,而define 宏定义的数据没有类型。为了安全,我建议你以后在定义一些宏常数的时候用const代替,编译器会给const 修饰的只读变量做类型校验,减少错误的可能。

但一定要注意const修饰的不是常量而是readonly 的变量,const 修饰的只读变量不能用来作为定义数组的维数,也不能放在case 关键字后面。

利用define来定义 字符串宏常量

除了定义宏常数之外,经常还用来定义字符串,尤其是路径:

1
2
#define ENG_PATH_1 E:\English\listen_to_this\listen_to_this_3
#define ENG_PATH_2 “E:\English\listen_to_this\listen_to_this_3”

噢,到底哪一个正确呢?如果路径太长,一行写下来比较别扭怎么办?用反斜杠接续符 ‘\’ 啊:

1
#define ENG_PATH_3 E:\English\listen_to_this\listen\_to_this_3

还没发现问题?这里用了4 个反斜杠,到底哪个是接续符?回去看看接续符反斜杠。

反斜杠作为接续符时,在本行其后面不能再有任何字符,空格都不行。所以,只有最后一个反斜杠才是接续符。至于A)和B),那要看你怎么用了,既然define 宏只是简单的替换,那给ENG_PATH_1 加上双引号不就成了:“ENG_PATH_1”。

但是请注意:有的系统里规定路径的要用双反斜杠“\\”,比如(这是正确的版本):

1
#define ENG_PATH_4 E:\\English\\listen_to_this\\listen_to_this_3

用define 宏定义注释符号

上面对define 的使用都很简单,再看看下面的例子:

1
2
3
4
5
#define BSC //
#define BMC /*
#define EMC */
BSC my single-line comment
BMC my multi-line comment EMC

D)和E)都错误,为什么呢?因为注释先于预处理指令被处理,当这两行被展开成//…或//时,注释已处理完毕,此时再出现//…或//自然错误。

因此,试图用宏开始或结束一段注释是不行的。

用define 宏定义表达式

这些都好理解,下面来点有“技术含量”的,定义一年有多少秒:

1
#define SEC_A_YEAR 60*60*24*365

这个定义没错吧?很遗憾,很有可能错了,至少不可靠。你有没有考虑在16 位系统下把这样一个数赋给整型变量的时候可能会发生溢出?一年有多少秒也不可能是负数吧。

改一下:

1
#define SEC_A_YEAR (60*60*24*365)UL

又出现一个问题,这里的括号到底需不需要呢?继续看一个例子,定义一个宏函数,求x 的平方:
1
#define SQR (x) x * x

对不对?试试:假设x 的值为10,SQR (x)被替换后变成10*10。没有问题。

再试试:假设x 的值是个表达式10+1,SQR (x)被替换后变成10+1*10+1。问题来了,这并不是我想要得到的。怎么办?括号括起来不就完了?

1
#define SQR (x) ((x)*(x))

最外层的括号最好也别省了,看例子,求两个数的和:
1
#define SUM (x) (x)+(x)

如果x 的值是个表达式5*3,而代码又写成这样:SUM (x)* SUM (x)。替换后变成:(5*3)+(5*3)*(5*3)+(5*3)。又错了!所以最外层的括号最好也别省了。我说过define是个演技高超的替身演员,但也经常耍大牌。要搞定它其实很简单,别吝啬括号就行了。

注意这一点:宏函数被调用时是以实参代换形参。而不是“值传送”。

宏定义中的空格

另外还有一个问题需要引起注意,看下面例子:

1
#define SUM (x) (x)+(x)

编译器认为这是定义了一个宏:SUM,其代表的是(x) (x)+(x)。

为什么会这样呢?其关键问题还是在于SUM 后面的这个空格。所以在定义宏的时候一定要注意什么时候该用空格,什么时候不该用空格。这个空格仅仅在定义的时候有效,在使用这个宏函数的时候,空格会被编译器忽略掉。也就是说,上一节定义好的宏函数SUM(x)在使用的时候在SUM 和(x)之间留有空格是没问题的。比如:SUM(3)和SUM (3)的意思是一样的。

undef

#undef是用来撤销宏定义的,用法如下:

1
2
3
4
5
#define PI 3.141592654

// code
#undef PI
//下面的代码就不能用PI 了,它已经被撤销了宏定义。

写好C语言,漂亮的宏定义很重要,使用宏定义可以防止出错,提高可移植性,可读性,方便性 等等。下面列举一些成熟软件中常用得宏定义:

防止一个头文件被重复包含

1
2
3
4
5
6
7
#ifndef COMDEF_H

#define COMDEF_H

//头文件内容

#endif

重新定义一些类型
防止由于各种平台和编译器的不同,而产生的类型字节数差异,方便移植。这里已经不是#define的范畴了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef unsigned char boolean; /* Boolean value type. */
typedef unsigned long int uint32; /* Unsigned 32 bit value */
typedef unsigned short uint16; /* Unsigned 16 bit value */
typedef unsigned char uint8; /* Unsigned 8 bit value */
typedef signed long int int32; /* Signed 32 bit value */
typedef signed short int16; /* Signed 16 bit value */
typedef signed char int8; /* Signed 8 bit value */
//下面的不建议使用
typedef unsigned char byte; /* Unsigned 8 bit value type. */
typedef unsigned short word; /* Unsinged 16 bit value type. */
typedef unsigned long dword; /* Unsigned 32 bit value type. */
typedef unsigned char uint1; /* Unsigned 8 bit value type. */
typedef unsigned short uint2; /* Unsigned 16 bit value type. */
typedef unsigned long uint4; /* Unsigned 32 bit value type. */
typedef signed char int1; /* Signed 8 bit value type. */
typedef signed short int2; /* Signed 16 bit value type. */
typedef long int int4; /* Signed 32 bit value type. */
typedef signed long sint31; /* Signed 32 bit value */
typedef signed short sint15; /* Signed 16 bit value */
typedef signed char sint7; /* Signed 8 bit value */  

得到指定地址上的一个字节或字

1
2
#define MEM_B( x ) ( *( (byte *) (x) ) )
#define MEM_W( x ) ( *( (word *) (x) ) )

求最大值和最小值

1
2
#define MAX( x, y ) ( ((x) > (y)) ? (x) : (y) )
#define MIN( x, y ) ( ((x) < (y)) ? (x) : (y) )

得到一个field在结构体(struct)中的偏移量

1
2
#define FPOS( type, field ) \
/*lint -e545 */ ( (dword) &(( type *) 0)-> field ) /*lint +e545 */

得到一个结构体中field所占用的字节数

1
#define FSIZ( type, field ) sizeof( ((type *) 0)->field )

按照LSB格式把两个字节转化为一个Word

1
#define FLIPW( ray ) ( (((word) (ray)[0]) * 256) + (ray)[1] )

按照LSB格式把一个Word转化为两个字节

1
2
3
#define FLOPW( ray, val ) \
(ray)[0] = ((val) / 256); \
(ray)[1] = ((val) & 0xFF)

得到一个变量的地址(word宽度)

1
2
#define B_PTR( var ) ( (byte *) (void *) &(var) )
#define W_PTR( var ) ( (word *) (void *) &(var) )

得到一个字的高位和低位字节

1
2
#define WORD_LO(xxx) ((byte) ((word)(xxx) & 255))
#define WORD_HI(xxx) ((byte) ((word)(xxx) >> 8))

返回一个比X大的最接近的8的倍数

1
#define RND8( x ) ((((x) + 7) / 8 ) * 8 )

将一个字母转换为大写

1
#define UPCASE( c ) ( ((c) >= 'a' && (c) <= 'z') ? ((c) - 0x20) : (c) )

判断字符是不是10进值的数字

1
#define DECCHK( c ) ((c) >= '0' && (c) <= '9')

判断字符是不是16进值的数字

1
2
3
#define HEXCHK( c ) ( ((c) >= '0' && (c) <= '9') ||\
((c) >= 'A' && (c) <= 'F') ||\
((c) >= 'a' && (c) <= 'f') )

防止溢出的一个方法

1
#define INC_SAT( val ) (val = ((val)+1 > (val)) ? (val)+1 : (val))

返回数组元素的个数

1
#define ARR_SIZE( a ) ( sizeof( (a) ) / sizeof( (a[0]) ) )

返回一个无符号数n尾的值MOD_BY_POWER_OF_TWO(X,n)=X%(2^n)

1
2
#define MOD_BY_POWER_OF_TWO( val, mod_by ) \
( (dword)(val) & (dword)((mod_by)-1) )

对于IO空间映射在存储空间的结构,输入输出处理

1
2
3
4
5
6
#define inp(port) (*((volatile byte *) (port)))
#define inpw(port) (*((volatile word *) (port)))
#define inpdw(port) (*((volatile dword *)(port)))
#define outp(port, val) (*((volatile byte *) (port)) = ((byte) (val)))
#define outpw(port, val) (*((volatile word *) (port)) = ((word) (val)))
#define outpdw(port, val) (*((volatile dword *) (port)) = ((dword) (val)))

使用一些宏跟踪调试
ANSI标准说明了五个预定义的宏名。它们是:

1
2
3
4
5
__LINE__
__FILE__
__DATE__
__TIME__
__STDC__

可以定义宏,例如:

当定义了_DEBUG,输出数据信息和所在文件所在行

1
2
3
4
5
#ifdef _DEBUG
#define DEBUGMSG(msg,date) printf(msg);printf(“%d%d%d”,date,_LINE_,_FILE_)
#else
#define DEBUGMSG(msg,date)
#endif

宏定义防止使用错误,用小括号包含。例如:

1
#define ADD(a,b) (a+b)

用do{}while(0)语句包含多语句防止错误,例如:

1
2
#define DO(a,b) a+b;\
a++;

应用时:

1
2
3
if(….)
DO(a,b); //产生错误
else

解决方法: 代码就只会执行一次。和直接加花括号有什么区别呢。哦对,不能随便在程序中,任意加{},组成代码块的。

1
2
#define DO(a,b) do{a+b;\
a++;}while(0)

new 操作符

当你写这种代码:

1
string *ps = new string("Memory Management");

你使用的new是new操作符。这个操作符就象sizeof一样是语言内置的。你不能改变它的含义,它的功能总是一样的。它要完毕的功能分成两部分。第一部分是分配足够的内存以便容纳所需类型的对象。第二部分是它调用构造函数初始化内存中的对象。new操作符总是做这两件事情,你不能以不论什么方式改变它的行为。

operator new

你所能改变的是怎样为对象分配内存。

new操作符调用一个函数来完毕必需的内存分配,你可以重写或重载这个函数来改变它的行为。new操作符为分配内存所调用函数的名字是operator new。

函数operator new 通常这样声明:

1
void * operator new(size_t size);

返回值类型是void*,由于这个函数返回一个未经处理(raw)的指针。未初始化的内存。參数size_t确定分配多少内存。

你能添加额外的參数重载函数operator new,可是第一个參数类型必须是size_t。

你一般不会直接调用operator new,可是一旦这么做。你能够象调用其他函数一样调用它:

1
void *rawMemory = operator new(sizeof(string));

操作符operator new将返回一个指针,指向一块足够容纳一个string类型对象的内存。就象malloc一样,operator new的职责仅仅是分配内存。

它对构造函数一无所知。operator new所了解的是内存分配。把operator new 返回的未经处理的指针传递给一个对象是new操作符的工作。当你的编译器遇见这种语句:

1
string *ps = new string("Memory Management");

它生成的代码或多或少与以下的代码相似:

1
2
3
4
void *memory = operator new(sizeof(string)); // 得到未经处理的内存,为String对象
call string::string("Memory Management")
on *memory; // 内存中的对象
string *ps = static_cast<string*>(memory); // 使ps指针指向新的对象

注意第二步包括了构造函数的调用,你做为一个程序猿被禁止这样去做。你的编译器则没有这个约束,它能够做它想做的一切。

因此假设你想建立一个堆对象就必须用new操作符。不能直接调用构造函数来初始化对象。(总结:operator new是用来分配内存的函数,为new操作符调用。能够被重载(有限制))

placement new

有时你确实想直接调用构造函数。在一个已存在的对象上调用构造函数是没有意义的,由于构造函数用来初始化对象。而一个对象只能在给它初值时被初始化一次。

可是有时你有一些已经被分配可是尚未处理的的(raw)内存,你须要在这些内存中构造一个对象。你能够使用一个特殊的operator new ,它被称为placement new。

以下的样例是placement new怎样使用,考虑一下:

1
2
3
4
5
6
7
8
9
10
class Widget {
 public:
  Widget(int widgetSize);
  ...
};

Widget * constructWidgetInBuffer(void *buffer,int widgetSize)
{
 return new (buffer) Widget(widgetSize);
}

这个函数返回一个指针。指向一个Widget对象,对象在转递给函数的buffer里分配。
当程序使用共享内存或memory-mapped I/O时这个函数可能实用,由于在这样程序里对象必须被放置在一个确定地址上或一块被例程分配的内存里。

在constructWidgetInBuffer里面。返回的表达式是:new (buffer) Widget(widgetSize)

这初看上去有些陌生,可是它是new操作符的一个使用方法,须要使用一个额外的变量(buffer)。当new操作符隐含调用operator new函数时。把这个变量传递给它。被调用的operator new函数除了带有强制的參数size_t外,还必须接受void*指针參数。指向构造对象占用的内存空间。这个operator new就是placement new,它看上去象这样:

1
2
3
4
void * operator new(size_t, void *location)
{
 return location;
}

这可能比你期望的要简单,可是这就是placement new须要做的事情。毕竟operator new的目的是为对象分配内存然后返回指向该内存的指针。在使用placement new的情况下,调用者已经获得了指向内存的指针。由于调用者知道对象应该放在哪里。placement new必须做的就是返回转递给它的指针。。

(总结:placement new是一种特殊的operator new,作用于一块已分配但未处理或未初始化的raw内存)

小结

让我们从placement new回来片刻,看看new操作符(new operator)与operator new的关系,(new操作符调用operator new)你想在堆上建立一个对象,应该用new操作符。它既分配内存又为对象调用构造函数。假设你只想分配内存,就应该调用operator new函数;它不会调用构造函数。假设你想定制自己的在堆对象被建立时的内存分配过程,你应该写你自己的operator new函数。然后使用new操作符,new操作符会调用你定制的operator new。假设你想在一块已经获得指针的内存里建立一个对象。应该用placement new。

Deletion and Memory Deallocation

为了避免内存泄漏,每一个动态内存分配必须与一个等同相反的deallocation相应。

函数operator delete与delete操作符的关系与operator new与new操作符的关系一样。当你看到这些代码:

1
2
3
string *ps;
...
delete ps; // 使用delete 操作符

你的编译器会生成代码来析构对象并释放对象占有的内存。

Operator delete用来释放内存。它被这样声明:

1
2
3
4
5
6
7
void operator delete(void *memoryToBeDeallocated);
···

因此, delete ps; 导致编译器生成类似于这种代码:
```C++
ps->~string(); // call the object's dtor
operator delete(ps); // deallocate the memory the object occupied

这有一个隐含的意思是假设你仅仅想处理未被初始化的内存,你应该绕过new和delete操作符,而调用operator new 获得内存和operator delete释放内存给系统:

1
2
3
4
5
void *buffer = operator new(50*sizeof(char)); // 分配足够的内存以容纳50个char
//没有调用构造函数
...
operator delete(buffer); // 释放内存
// 没有调用析构函数

这与在C中调用malloc和free等同。

假设你用placement new在内存中建立对象,你应该避免在该内存中用delete操作符。

由于delete操作符调用operator delete来释放内存,可是包括对象的内存最初不是被operator new分配的。placement new仅仅是返回转递给它的指针。谁知道这个指针来自何方?而你应该显式调用对象的析构函数来解除构造函数的影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在共享内存中分配和释放内存的函数 void * mallocShared(size_t size);

void freeShared(void *memory);
void *sharedMemory = mallocShared(sizeof(Widget));
Widget *pw = // 如上所看到的,
constructWidgetInBuffer(sharedMemory, 10); // 使用

// placement new
...
delete pw; // 结果不确定! 共享内存来自
// mallocShared, 而不是operator new
pw->~Widget(); // 正确。 析构 pw指向的Widget,
// 可是没有释放
//包括Widget的内存
freeShared(pw); // 正确。 释放pw指向的共享内存
// 可是没有调用析构函数

如上例所看到的,假设传递给placement new的raw内存是自己动态分配的(通过一些不经常使用的方法),假设你希望避免内存泄漏,你必须释放它。

数组

如何分配数组?会发生什么?

1
string *ps = new string[10]; // allocate an array of objects

被使用的new仍然是new操作符,可是建立数组时new操作符的行为与单个对象建立有少许不同。
第一是内存不再用operator new分配,取代以等同的数组分配函数,叫做operator new[](常常被称为array new)。

它与operator new一样能被重载。

在这种编译器下定制数组内存分配是困难的。由于它须要重写全局operator new。这可不是一个能轻易接受的任务。

缺省情况下,全局operator new处理程序中全部的动态内存分配,所以它行为的不论什么改变都将有深入和普遍的影响。并且全局operator new有一个正常的签名(normal signature)。

第二个不同是new操作符调用构造函数的数量。对于数组,在数组里的每个对象的构造函数都必须被调用:

1
2
3
string *ps = new string[10]; // 调用operator new[]为10个string对象分配内存,

// 然后对每一个数组元素调用string对象的缺省构造函数。

相同当delete操作符用于数组时,它为每一个数组元素调用析构函数,然后调用operator delete来释放内存。

就象你能替换或重载operator delete一样,你也替换或重载operator delete[]。

static关键字

首先说一下内存的五个区:

  • 栈(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值(除static),其操作方式类似于数据结构中的栈。
  • 堆(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆(优先队列)是两回事,分配方式倒是类似于链表。
  • 全局区(静态区):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域(BSS),程序结束后由系统释放。
  • 文字常量区:常量字符串就是放在这里的,如char str[]=”hello”,程序结束后由系统释放,区别const修饰的变量。
  • 程序代码区:存放函数体的二进制代码。

作用

  1. 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,在 main 函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它。
  2. 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为 static。
  3. 修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员。
  4. 修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在 static 函数内不能访问非静态成员。
  • 在函数体,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
  • 在模块内(但在函数体外),一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量。
  • 在模块内,一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用
  • 类内的static成员变量属于整个类所拥有,不能在类内进行定义,只能在类的作用域内进行定义
  • 类内的static成员函数属于整个类所拥有,不能包含this指针,只能调用static成员函数

全局变量和static变量的区别

  • 全局变量(外部变量)的说明之前再冠以static就构成了静态的全局变量。
    • 全局变量本身就是静态存储方式,静态全局变量当然也是静态存储方式。
    • 这两者在存储方式上并无不同。这两者的区别在于非静态全局变量的作用域是整个源程序,当一个源程序由多个原文件组成时,非静态的全局变量在各个源文件中都是有效的。
    • 而静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
  • static全局变量与普通的全局变量的区别是static全局变量只初始化一次,防止在其他文件单元被引用。

static函数与普通的函数作用域不同。尽在本文件中。只在当前源文件中使用的函数应该说明为内部函数(static),内部函数应该在当前源文件中说明和定义。

对于可在当前源文件以外使用的函数应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。static函数与普通函数最主要区别是static函数在内存中只有一份,普通静态函数在每个被调用中维持一份拷贝程序的局部变量存在于(堆栈)中,全局变量存在于(静态区)中,动态申请数据存在于(堆)

static 变量

静态局部变量保存在全局数据区(静态区),而不是保存在栈中,每次的值保持到下一次调用,直到下次赋新值。

  • static全局变量与普通的全局变量有什么区别:static全局变量只初使化一次,防止在其他文件单元中被引用;
  • static局部变量和普通局部变量有什么区别:static局部变量只被初始化一次,下一次依据上一次结果值;
  • static函数与普通函数有什么区别:static函数在内存中只有一份,普通函数在每个被调用中维持一份拷贝

static 成员变量

定义必须在类定义体的外部,在类的内部只是声明,声明必须加static,定义不需要。static类对象必须要在类外进行初始化,static修饰的变量先于对象存在,所以static修饰的变量要在类外初始化;

1
2
3
4
5
6
7
8
9
10
11
12
class A
{
public:
// 声明static变量,任何声明都不可初始化,如extern外部变量
static int a;
private:
static int b;
};
// 定义static成员变量,可初始化
int A::a = 5;
// 私有静态成员变量,不能直接用类名调用或者对象调用,只能在类内调用
int A::b = 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
26
27
28
29
30
31
#include <iostream>
using namespace std;
class A
{
public:
// 声明static变量,任何声明都不可初始化,如extern外部变量
static int a;
private:
static int b;
public:
static int getAValue()
{
return this.a;
}
};
// 定义static成员变量,可初始化
int A::a = 5;
// 私有静态成员变量,不能直接用类名调用或者对象调用,只能在类内调用
int A::b = 1;
int main(int argc, char *argv[])
{
// new 两个个实例(对象)
A * instanceA = new A();
A * instanceB = new A();
// 改变值,均输出1
instanceA->a = 1;
cout << A::a << endl;
cout << instanceA->getAValue() << endl;
cout << instanceB->getAValue() << endl;
return 0;
}

static 函数

1
2
3
4
5
6
#include <stdio.h>
int a = 5;
void printHello()
{
printf("hello world");
}
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
// 声明
void printHello();
int main(int argc,char *argv[])
{
// 声明
extern int a;
printf("a = %d\n",a);
printHello();
return 0;
}

【编译】

1
g++ a.cpp b.cpp -o ab.exe

【输出】
1
2
a = 5
hello world

如果在a.cpp中的int a = 5;定义前面加上static修饰,那么再次去编译,就会b.cpp报未定义错误。如果在a.cpp中的void printHello()函数前加static修饰,再次去编译,一样会报未定义错误。很明显,所有未加static修饰的函数和全局变量具有全局可见性,其他的源文件也能够访问。static修饰函数和变量这一特性可以在不同的文件中定义同名函数和同名变量,而不必担心命名冲突。static可以用作函数和变量的前缀,对于函数来讲,static的作用仅限于隐藏。这有点类似于C++中的名字空间。

static 成员函数

同样的和成员变量一样,跟类相关的,跟具体的类的对象无关,可以通过类名来调用。static成员函数里面不能访问非静态成员变量,也不能调用非静态成员函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;
class A
{
public:
void printStr()
{
printf("hello world");
}
static void print()
{
// 错误,静态成员函数不能调用非静态成员函数
printStr();
}
};
int main(int argc, char *argv[])
{
return 0;
}

静态成员函数没有this隐含指针修饰,存在一种情况,用const修饰类的成员函数(写在函数的最后,不是前面,前面是返回值为常量),表示该函数不能修改该类的状态,如不能在改函数里修改成员变量(除去mutable修饰的外),因为该函数存在一个隐式的this,const修饰后为const this,但是当static修饰成员函数的时候是没有this指针的,所以不能同时用static和const修饰同一个成员函数,不过可以修饰同一个成员变量。

static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;

虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual

vector和set使用sort方法进行排序

C++中vector和set都是非常方便的容器,

sort方法是algorithm头文件里的一个标准函数,能进行高效的排序,默认是按元素从小到大排序

将sort方法用到vector和set中能实现多种符合自己需求的排序

首先sort方法可以对静态的数组进行排序

1
2
3
4
5
6
7
8
9
#include<iostream>
using namespace std;
int main(){
int a[10] = { 9, 0, 1, 2, 3, 7, 4, 5, 100, 10 };
sort(a, a +10);
for (int i = 0; i < 10; i++)
cout << a[i] << endl;
return 0;
}

运行结果如下:
结果

这里可以看到是sort(a,a+10),但是数组a一共只有9个元素,为什么是a+10而不是a+9呢?

因为sort方法实际上最后一位地址对应的数是不取的,

而且vector,set,map这些容器的end()取出来的值实际上并不是最后一个值,而end的前一个才是最后一个值!

需要用prev(xxx.end()),才能取出容器中最后一个元素。

对vector使用sort函数

第一种情形:基本类型,如vector<int>vector<double>vector<string>也是可以的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
int main(){
vector<int> a;
int n = 5;
while (n--){
int score;
cin >> score;
a.push_back(score);
}
//cout <<" a.end()"<< *a.end() << endl; 执行这句话会报错!
cout << " prev(a.end)" << *prev(a.end()) << endl;
sort(a.begin(), a.end());
for (vector<int>::iterator it = a.begin(); it != a.end(); it++){
cout << *it << endl;
}
return 0;
}

看到了吗,实际上end的前一个指针指向的元素才是插入时的最后一个值!

排序后从小大大。

第二种情形:用自定义的结构体进行sort算法,

这时候需要自己定义个比较函数,因为sort算法是基于容器中的元素是可以两两比较的,然后从小到大排序,所以要自定义怎么样才是小于(’<’)

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<vector>
#include<set>
#include<string>
#include<algorithm>
using namespace std;
struct student{
char name[10];
int score;
};
//自定义“小于”
bool comp(const student &a, const student &b){
return a.score < b.score;
}
int main(){
vector<student> vectorStudents;
int n = 5;
while (n--){
student oneStudent;
string name;
int score;
cin >> name >> score;
strcpy(oneStudent.name, name.c_str());
oneStudent.score = score;
vectorStudents.push_back(oneStudent);
}
cout << "===========排序前================" << endl;
for (vector<student>::iterator it = vectorStudents.begin(); it != vectorStudents.end(); it++){
cout << "name: " << it->name << " score: " << it->score << endl;
}
sort(vectorStudents.begin(),vectorStudents.end(),comp);
cout << "===========排序后================" << endl;
for (vector<student>::iterator it = vectorStudents.begin(); it != vectorStudents.end(); it++){
cout << "name: " << it->name << " score: " << it->score << endl;
}
return 0;
}

对于set做类似的操作。

set是一个集合,内部的元素不会重复,同时它会自动进行排序,也是从小到大

而且set的insert方法没有insert(a,cmp)这种重载,所以如果要把结构体插入set中,我们就要重载’<’运算符。

set方法在插入的时候也是从小到大的,那么我们重载一下<运算符让它从大到小排序

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
#include<iostream>
#include<vector>
#include<set>
#include<string>
#include<algorithm>
using namespace std;
struct student{
char name[10];
int score;
};
//自定义“小于”
bool comp(const student &a, const student &b){
return a.score < b.score;
}
bool operator < (const student & stu1,const student &stu2){
return stu1.score > stu2.score;
}
int main(){
//vector<student> vectorStudents;
set<student> setStudents;
//int n = 5;
int n = 6;
while (n--){
student oneStudent;
string name;
int score;
cin >> name >> score;
strcpy(oneStudent.name, name.c_str());
oneStudent.score = score;
setStudents.insert(oneStudent);
}
cout << "===========排序前================" << endl;
for (set<student>::iterator it = setStudents.begin(); it != setStudents.end(); it++){
cout << "name: " << it->name << " score: " << it->score << endl;
}
//sort(setStudents.begin(), setStudents.end(), comp);
//cout << "===========排序后================" << endl;
//for (set<student>::iterator it = setStudents.begin(); it != setStudents.end(); it++){
// cout << "name: " << it->name << " score: " << it->score << endl;
//}
return 0;
}

restrict与GCC的编译优化

restrict是C99标准中新添加的关键字,对于从C89标准开始起步学习C语言的同学来说,第一次看到restrict还是相当陌生的。简单说来,restrict关键字是编程者对编译器所做的一个“承诺”:使用restrict修饰过的指针,它所指向的内容只能经由该指针(或从该指针继承而来的指针,如通过该指针赋值或做指针运算而得到的其他指针)修改,而不会被其他不相干的指针所修改。

有了编程者的承诺,编译器便可以对一些通过指针的运算进行大胆的优化了。

观察编译器优化的最好办法当然是查看编译后的汇编代码。Wikipedia上有一个很好的例子,测试环境:Ubuntu 11.04 (x86-64) + Linux 2.6.38 + gcc 4.5.2。测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

#ifdef RES
void multi_add(int* restrict p1, int* restrict p2, int* restrict pi)
#else
void multi_add(int* p1, int* p2, int* pi)
#endif
{
*p1 += *pi;
*p2 += *pi;
}

int main()
{
int a = 1, b = 2;
int inc = 1;

// increase both a and b by 1
multi_add(&a, &b, &inc);

// print the result
printf("a = %d, b = %d\n", a, b);
}

multi_add函数的功能很简单,将p1和p2指针所指向的内容都加上pi指针的内容。为了测试方便,使用了条件编译指令:如果定义RES宏,则使用带restrict的函数声明。

分别编译出两个版本的程序:

1
2
gcc restrict.c -o without_restrict
gcc restrict.c -o with_restrict -DRES --std=c99

使用objdump查看目标文件的汇编代码(-d选项表示disassemble):

1
objdump -d without_restrict

PS:gcc默认使用的是AT&T汇编,与很多同学在初次学习汇编时接触的Intel x86汇编有些不同

除了表示上的细微符号差别,最大的区别是src/dest的顺序,两者恰好相反:

1
2
3
Intel : mov  eax  2      (先dest后src)

AT&T : mov %2 %eax (先src后dest)

然而这次的结果让人失望:两个版本的程序拥有一模一样的multi_add函数,汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
push   %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp)
mov %rsi,-0x10(%rbp)
mov %rdx,-0x18(%rbp)
mov -0x8(%rbp),%rax
mov (%rax),%edx
mov -0x18(%rbp),%rax
mov (%rax),%eax
add %eax,%edx
mov -0x8(%rbp),%rax
mov %edx,(%rax)
mov -0x10(%rbp),%rax
mov (%rax),%edx
mov -0x18(%rbp),%rax
mov (%rax),%eax
add %eax,%edx
mov -0x10(%rbp),%rax
mov %edx,(%rax)
leaveq
retq

其中寄存器rdi存放p1的地址,rsi存放p2的地址,rdx存放的是pi的地址。大段的汇编代码,无非是将寄存器中的内容mov到栈上的临时变量上,再把临时变量的值mov进寄存器进行加法运算。

难道restrict关键字没有任何作用?我怀疑很可能是编辑器优化程度不够。这次,使用-O1重新编译源代码并反汇编,终于观察到差别:

未使用restrict的版本:

1
2
3
4
mov (%rdx), %eax
add %eax, (%rdi)
mov (%rdx), %eax
add %eax, (%rsi)

使用了restrict的版本:

1
2
3
mov (%rdx), %eax
add %eax, (%rdi)
add %eax, (%rsi)

可以看出,-O1的编译优化还是很给力的,所有运算直接在寄存器中进行,不再蛋疼地先mov进栈变量,再mov进寄存器进行add运算(在这个简单的例子中,确实没有必要)。

最大的区别在于将rdx寄存器间接引用的值mov进eax的语句只在一开始执行了1次。可以理解,当程序员“承诺”这些指针都是相互独立不再干扰时,pi指针的内容在函数范围内可以视之为常量,只需要load进寄存器一次。

而没有restrict关键字时,即使程序中没有对pi的内容进行操作,编译器仍然不能保证pi的内容在函数范围内是常量:因为有pointer aliasing的可能,即p1和p2指向的内容和pi相关(简单情况:p1和pi实际是同一个指针)。

需要注意的是,restrict是程序员给出的“承诺“,编译器没有指针的合法使用进行检查的职责,也没有这样的能力。

事实上,打开restrict关键字,如果这样调用:

1
multi_add(&a, &b, &a);

编译器不会报错。(事实上编译期完全有能力检查出简单alias的pointer)

而使用不同的编译优化级别(不优化,-O1, -O2),则产生了相当不同的结果。

  • 不优化 : a = 2, b = 4
  • -O1 : a = 2, b = 3
  • -O2以上: a = 2, b = 4

前面已经提到,没有开启-O选项时,gcc没有对restrict关键字进行优化(至少在这个例子中),所以应当是正确的行为(尽管此行为可能与编写multi_add函数的初衷不符合)

在O1下,restrict被优化,pi的值一开始即被缓存,所以产生了a和b都增加了1的结果

那么为什么O2以上,行为又开始变得正确了呢?

继续反汇编代码,发现-O2以上时,multi_add函数本身代码保持不变(确实在O1已经优化的相当简洁了),但main函数已经面目全非了:调用multi_add的代码已经改变,准确地说:

multi_add函数已经不再被main调用了

这里不再列出相关的汇编代码,因为这里的优化策略是相当复杂的。在这个例子中,由于a和b都是常量,a和b的值直接在编译期被算了出来,并放入寄存器中进行后续printf的调用。

可以看出,restrict确实是优化的利器。但是如果不仔细使用,它还是相当危险的,甚至能够导致在不同的优化级别下,出现完全不同的程序行为。

volatile

why volatile

volatile 关键词,最早出现于20世纪70年代,被用于处理 MMIO(Memory-mapped I/O) 带来的问题。在引入 MMIO 之后,一块内存地址既有可能是真正的内存,也有可能是映射的一个I/O端口。因此,读/写一个内存地址,既有可能是真正地操作内存,也有可能是读/写一个I/O设备。

那么 MMIO 为什么需要引入 volatile 关键词呢?我们结合下面这段示例代码进行解释:

1
2
3
4
5
6
7
8
9
unsigned int *p = FunB();
unsigned int a;
unsigned int b;

a = *p; // 语句1
b = *p; // 语句2

*p = a; // 语句3
*p = b; // 语句4

在上述代码片段中,指针p既有可能指向一个内存地址,也有可能指向一个I/O设备。如果指针p指向的是I/O设备,那么语句1和语句2中的变量a和变量b,就会接收到I/O设备的连续两个字节。但是,指针p也有可能指向内存地址,这种情况下,编译器就会进行语句优化,编译器的优化策略会判断变量a和变量b同时从同一个内存地址读取数据,因此在执行完语句1之后,直接将变量a赋值给变量b。对于指针p指向I/O设备的这种情况,就需要防止编译器进行此优化,即不能假设指针b指向的内容不变(对应 volatile 的易变性特性)。

同样,语句3和语句4也有类似的问题,编译器发现将变量a和b同时赋值给指针p是无意义的,因此可能会优化语句3中的赋值操作,而仅仅保留语句4。对于指针p指向I/O设备的情况,也需要防止编译器将类似的写操作给优化消失了(对应 volatile 的不可优化特性)。

对于I/O设备,编译器不能随意交互指令的顺序,因为指令顺序一变,写入I/O设备的内容也就发生变化了(对应 volatile 的顺序性)。

为了满足 MMIO 的这三点需求,就有了 volatile 关键字。

IN C/C++

在C/C++语言中,使用 volatile 关键字声明的变量(或对象)通常具有与优化、多线程相关的特殊属性。通常,volatile 关键字用来阻止(伪)编译器对其认为的、无法“被代码本身”改变的代码(变量或对象)进行优化。如在C/C++中,volatile 关键字可以用来提醒编译器使用 volatile 声明的变量随时有可能改变,因此编译器在代码编译时就不会对该变量进行某些激进的优化,故而编译生成的程序在每次存储或读取该变量时,都会直接从内存地址中读取数据。相反,如果该变量没有使用 volatile 关键字进行声明,则编译器可能会优化读取和存储操作,可能暂时使用寄存器中该变量的值,而如果这个变量由别的程序(线程)更新了的话,就会出现(内存中与寄存器中的)变量值不一致的现象。

定义为volatile的变量是说这变量可能会被意想不到地改变,即在你程序运行过程中一直会变,你希望这个值被正确的处理,每次从内存中去读这个值,而不是因编译器优化从缓存的地方读取,比如读取缓存在寄存器中的数值,从而保证volatile变量被正确的读取。

在单任务的环境中,一个函数体内部,如果在两次读取变量的值之间的语句没有对变量的值进行修改,那么编译器就会设法对可执行代码进行优化。由于访问寄存器的速度要快过RAM(从RAM中读取变量的值到寄存器),以后只要变量的值没有改变,就一直从寄存器中读取变量的值,而不对RAM进行访问。

而在多任务环境中,虽然在一个函数体内部,在两次读取变量之间没有对变量的值进行修改,但是该变量仍然有可能被其他的程序(如中断程序、另外的线程等)所修改。如果这时还是从寄存器而不是从RAM中读取,就会出现被修改了的变量值不能得到及时反应的问题。

因为访问寄存器要比访问内存单元快的多,所以编译器一般都会作减少存取内存的优化,但有可能会读脏数据。当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。精确地说就是,遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问;如果不使用valatile,则编译器将对所声明的语句进行优化。(简洁的说就是:volatile关键词影响编译器编译的结果,用volatile声明的变量表示该变量随时可能发生变化,与该变量有关的运算,不要进行编译优化,以免出错。加了volatile修饰的变量,编译器将不对其相关代码执行优化,而是生成对应代码直接存取原始内存地址)。

一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份。一般说来,volatile用在如下的几个地方:

  1. 并行设备的硬件寄存器(如:状态寄存器)
  2. 中断服务程序中修改的供其它程序检测的变量需要加volatile;
  3. 多任务环境下各任务间共享的标志应该加volatile;
  4. 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能有不同意义;

在C/C++语言中,使用 volatile 关键字声明的变量具有三种特性:易变的、不可优化的、顺序执行的。下面分别对这三种特性进行介绍。

易变的

volatile 在词典中的主要释义就是“易变的”。

在 C/C++ 语言中,volatile 的易变性体现在:假设有读、写两条语句,依次对同一个 volatile 变量进行操作,那么后一条的读操作不会直接使用前一条的写操作对应的 volatile 变量的寄存器内容,而是重新从内存中读取该 volatile 变量的值。

上述描述的(部分)示例代码如下:

1
2
3
4
volatile int nNum = 0;  // 将nNum声明为volatile
int nSum = 0;
nNum = FunA(); // nNum被写入的新内容,其值会缓存在寄存器中
nSum = nNum + 1; // 此处会从内存(而非寄存器)中读取nNum的值

不可优化的

在 C/C++ 语言中,volatile 的第二个特性是“不可优化性”。volatile 会告诉编译器,不要对 volatile 声明的变量进行各种激进的优化(甚至将变量直接消除),从而保证程序员写在代码中的指令一定会被执行。

上述描述的(部分)示例代码如下:

1
2
3
volatile int nNum;  // 将nNum声明为volatile
nNum = 1;
printf("nNum is: %d", nNum);

在上述代码中,如果变量 nNum 没有声明为 volatile 类型,则编译器在编译过程中就会对其进行优化,直接使用常量“1”进行替换(这样优化之后,生成的汇编代码很简介,执行时效率很高)。而当我们使用 volatile 进行声明后,编译器则不会对其进行优化,nNum 变量仍旧存在,编译器会将该变量从内存中取出,放入寄存器之中,然后再调用 printf() 函数进行打印。

顺序执行的

在 C/C++ 语言中,volatile 的第三个特性是“顺序执行特性”,即能够保证 volatile 变量间的顺序性,不会被编译器进行乱序优化。

说明:C/C++ 编译器最基本优化原理:保证一段程序的输出,在优化前后无变化。

为了对本特性进行深入了解,下面以两个变量(nNum1 和 nNum2)为例(既然存在“顺序执行”,那描述对象必然大于一个),结合如下示例代码,介绍 volatile 的顺序执行特性。

1
2
3
4
int nNum1;
int nNum2;
nNum2 = nNum1 + 1; // 语句1
nNum1 = 10; // 语句2

在上述代码中:

  • 当 nNum1 和 nNum2 都没有使用 volatile 关键字进行修饰时,编译器会对“语句1”和“语句2”的执行顺序进行优化:即先执行“语句2”、再执行“语句1”;
  • 当 nNum2 使用 volatile 关键字进行修饰时,编译器也可能会对“语句1”和“语句2”的执行顺序进行优化:即先执行“语句2”、再执行“语句1”;
  • 当 nNum1 和 nNum2 都使用 volatile 关键字进行修饰时,编译器不会对“语句1”和“语句2”的执行顺序进行优化:即先执行“语句1”、再执行“语句2”;

说明:上述论述可通过观察代码的生成的汇编代码进行验证。

volatile与多线程语义

对于多线程编程而言,在临界区内部,可以通过互斥锁(mutex)保证只有一个线程可以访问该临界区的内容,因此临界区内的变量不需要是 volatile 的;而在临界区外部,被多个线程访问的变量应声明为 volatile 的,这也符合了 volatile 的原意:防止编译器缓存(cache)了被多个线程并发用到的变量。

不过,需要注意的是,由于 volatile 关键字的“顺序执行特性”并非会完全保证语句的顺序执行(如 volatile 变量与非volatile 变量之间的操作;又如一些 CPU 也会对语句的执行顺序进行优化),因此导致了对 volatile 变量的操作并不是原子的,也不能用来为线程建立严格的 happens-before 关系。

对于上述描述,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int nNum1 = 0;
volatile bool flag = false;

thread1()
{
// some code

nNum1 = 666; // 语句1
flag = true; // 语句2
}

thread2()
{
// some code

if (true == flag)
{
// 语句3:按照程序设计的预想,此处的nNum1的值应为666,并据此进行逻辑设计
}
}

在上述代码中,我们的设计思路是先执行 thread1() 中的“语句1”、“语句2”、再执行 thread2() 中的“语句3”,不过实际上程序的执行结果未必如此。根据 volatile 的“顺序性”,非 volatile 变量 nNum1 和 volatile 变量 flag 的执行顺序,可能会被编译器(或 CPU)进行乱序优化,最终导致thread1中的“语句2”先于“语句1”执行,当“语句2”执行完成但“语句1”尚未执行时,此时 thread2 中的判断语句“if (true == flag)”是成立的,但实际上 nNum1 尚未进行赋值为666(语句1尚未执行),所以在判断语句中针对 nNum1 为666的前提下进行的相关操作,就会有问题了。

这是一个在多线程编程中,使用 volatile 不容易发现的问题。

实际上,上述多线程代码想实现的就是一个 happens-before 语义,即保证 thread1 代码块中的所有代码,一定要在 thread2 代码块的第一条代码之前完成。使用互斥锁(mutex)可以保证 happens-before 语义。但是,在 C/C++ 中的 volatile 关键词不能保证这个语义,也就意味着在多线程环境下使用 C/C++ 的 volatile 关键词,如果不够细心,就可能会出现上述问题。

说明:由于 Java 语言的 volatile 关键字支持 Acquire、Release 语义,因此 Java 语言的 volatile 能够用来构建 happens-before 语义。也就是说,前面提到的 C/C++ 中 volatile 在多线程下使用出现的问题,在 Java 语言中是不存在的。

不保证原子性

volatile只保证其“可见性”,不保证其“原子性”。

执行count++;这条语句由3条指令组成:

  1. 将 count 的值从内存加载到 cpu 的某个 寄存器r;
  2. 将 寄存器r 的值 +1,结果存放在 寄存器s;
  3. 将 寄存器s 中的值写回内存。

所以,如果有多个线程同时在执行 count++,在某个线程执行完第(3)步之前,其它线程是看不到它的执行结果的。(这里有疑惑:线程同时执行count++,为了保证其原子性,为何不加mutex lock?而是寻求volatile?)

在没有volatile的时候,执行完count++,执行结果其实是写到CPU缓存中,没有马上写回到内存中,后续在某些情况下(比如CPU缓存不够用)再将CPU缓存中的值flush到内存。因为没有存到内存里,其他线程是不能及时看到执行结果的。

在有volatile的时候,执行完count++,执行结果写入缓存中,并同时写入内存中,所以可以保证其它线程马上看到执行的结果。

但是,volatile 并没有保证原子性,在某个线程执行(1)(2)(3)的时候,volatile 并没有锁定 count 的值,也就是并不能阻塞其他线程也执行(1)(2)(3)。可能有两个线程同时执行(1),所以(2)计算出来一样的结果,然后(3)存回的也是同一个值。
考虑下面一段代码:

1
2
3
4
5
int some_int = 100;
while(some_int == 100)
{
//your code
}

因为编译器认为some_int没被改变过,一直是100。但是在多线程时,如果执行完第一行,但是还没执行到第三行时,另一个线程修改了some_int,while就不能进入循环了。加了volatile后,阻止了编译器优化,每次读到some_int会从内存中读取,而不是本线程的寄存去(当然这会损失效率)。这就是volatile的作用。

一句话总结:volatile保证线程能读到最新的数据,因为是从内存中读取,且存入内存中。而不是线程各自的寄存器中读写。

inline 内联函数

内联函数和普通函数相比可以加快程序运行的速度,因为不需要中断调用,在编译的时候内联函数可以直接嵌入到目标代码中。

  • 相当于把内联函数里面的内容写在调用内联函数处;
  • 相当于不用执行进入函数的步骤,直接执行函数体;
  • 相当于宏,却比宏多了类型检查,真正具有函数特性;
  • 编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数;
  • 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数。
  • 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 声明1(加 inline,建议使用)
inline int functionName(int first, int second,...);

// 声明2(不加 inline)
int functionName(int first, int second,...);

// 定义
inline int functionName(int first, int second,...) {/****/};

// 类内定义,隐式内联
class A {
int doA() { return 0; } // 隐式内联
}

// 类外定义,需要显式内联
class A {
int doA();
}
inline int A::doA() { return 0; } // 需要显式内联

编译器对 inline 函数的处理步骤

  1. 将 inline 函数体复制到 inline 函数调用点处;
  2. 为所用 inline 函数中的局部变量分配内存空间;
  3. 将 inline 函数的的输入参数和返回值映射到调用方法的局部变量空间中;
  4. 如果 inline 函数有多个返回点,将其转变为 inline 函数代码块末尾的分支(使用 GOTO)。

优点

  1. 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
  2. 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
  3. 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
  4. 内联函数在运行时可调试,而宏定义不可以。

缺点

  1. 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
  2. inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
  3. 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。

内联函数和宏定义的区别

内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:

  • 函数体内的代码比较长,将导致内存消耗代价
  • 函数体内有循环,函数执行时间要比函数调用开销大

主要区别

  • 内联函数在编译时展开,宏在预编译时展开
  • 内联函数直接嵌入到目标代码中,宏是简单的做文本替换
  • 内联函数有类型、语法判断等功能,而宏没有
  • 内联函数是函数,宏不是
  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
  • 内联函数代码是被放到符号表中,使用时像宏一样展开,没有调用的开销,效率很高;
  • 在使用时,宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。
  • 内联函数可以作为某个类的成员函数,这样可以使用类的保护成员和私有成员,进而提升效率。而当一个表达式涉及到类保护成员或私有成员时,宏就不能实现了。

union

联合(union)是一种节省空间的特殊的类,一个 union 可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当某个成员被赋值后其他成员变为未定义状态。联合有如下特点:

  • 默认访问控制符为 public
  • 可以含有构造函数、析构函数
  • 不能含有引用类型的成员
  • 不能继承自其他类,不能作为基类
  • 不能含有虚函数
  • 匿名 union 在定义所在作用域可直接访问 union 成员
  • 匿名 union 不能包含 protected 成员或 private 成员
  • 全局匿名联合必须是静态(static)的

C++11 标准规定,任何非引用类型都可以成为联合体的数据成员,这种联合体也被称为非受限联合体。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Student{
public:
Student(bool g, int a): gender(g), age(a) {}
private:
bool gender;
int age;
};
union T{
Student s; // 含有非POD类型的成员,gcc-5.1.0 版本报错
char name[10];
};
int main(){
return 0;
}

上面的代码中,因为 Student 类带有自定义的构造函数,所以是一个非 POD 类型的,这导致编译器报错。这种规定只是 C++ 为了兼容C语言而制定,然而在长期的编程实践中发现,这种规定是没有必要的。

C++11 允许非 POD 类型

C++98 不允许联合体的成员是非 POD 类型,但是 C++11 取消了这种限制。POD 是英文 Plain Old Data 的缩写,用来描述一个类型的属性。POD 类型一般具有以下几种特征(包括 class、union 和 struct等):

  • 没有用户自定义的构造函数、析构函数、拷贝构造函数和移动构造函数。
  • 不能包含虚函数和虚基类。
  • 非静态成员必须声明为 public。
  • 类中的第一个非静态成员的类型与其基类不同
  • 在类或者结构体继承时,满足以下两种情况之一:
    • 派生类中有非静态成员,且只有一个仅包含静态成员的基类;
    • 基类有非静态成员,而派生类没有非静态成员。
  • 所有非静态数据成员均和其基类也符合上述规则(递归定义),也就是说 POD 类型不能包含非 POD 类型的数据。
  • 此外,所有兼容C语言的数据类型都是 POD 类型(struct、union 等不能违背上述规则)。
1
2
class B1{};
class B2 : B1 { B1 b; };

class B2 的第一个非静态成员 b 是基类类型,所以它不是 POD 类型。

1
2
3
class B1 { static int n; };
class B2 : B1 { int n1; };
class B3 : B2 { static int n2; };

对于 B2,派生类 B2 中有非静态成员,且只有一个仅包含静态成员的基类 B1,所以它是 POD 类型。对于 B3,基类 B2 有非静态成员,而派生类 B3 没有非静态成员,所以它也是 POD 类型。

C++11 允许联合体有静态成员

C++11 删除了联合体不允许拥有静态成员的限制。例如:

1
2
3
4
5
6
union U {
static int func() {
int n = 3;
return n;
}
};

需要注意的是,静态成员变量只能在联合体内定义,却不能在联合体外使用,这使得该规则很没用。

非受限联合体的赋值注意事项

C++11 规定,如果非受限联合体内有一个非 POD 的成员,而该成员拥有自定义的构造函数,那么这个非受限联合体的默认构造函数将被编译器删除;其他的特殊成员函数,例如默认拷贝构造函数、拷贝赋值操作符以及析构函数等,也将被删除。

这条规则可能导致对象构造失败,请看下面的例子:

1
2
3
4
5
6
7
8
9
10
#include <string>
using namespace std;
union U {
string s;
int n;
};
int main() {
U u; // 构造失败,因为 U 的构造函数被删除
return 0;
}

在上面的例子中,因为 string 类拥有自定义的构造函数,所以 U 的构造函数被删除;定义 U 的类型变量 u 需要调用默认构造函数,所以 u 也就无法定义成功。

解决上面问题的一般需要用到 placement new,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <string>
using namespace std;
union U {
string s;
int n;
public:
U() { new(&s) string; }
~U() { s.~string(); }
};
int main() {
U u;
return 0;
}

构造时,采用placement new将 s 构造在其地址 &s 上,这里placement new的唯一作用只是调用了一下 string 类的构造函数。注意,在析构时还需要调用 string 类的析构函数。

非受限联合体的匿名声明和“枚举式类”

匿名联合体是指不具名的联合体(也即没有名字的联合体),一般定义如下:

1
2
3
union U{
union { int x; }; //此联合体为匿名联合体
};

可以看到,联合体 U 内定义了一个不具名的联合体,该联合体包含一个 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
#include <cstring>
using namespace std;
class Student{
public:
Student(bool g, int a): gender(g), age(a){}
bool gender;
int age;
};
class Singer {
public:
enum Type { STUDENT, NATIVE, FOREIGENR };
Singer(bool g, int a) : s(g, a) { t = STUDENT; }
Singer(int i) : id(i) { t = NATIVE; }
Singer(const char* n, int s) {
int size = (s > 9) ? 9 : s;
memcpy(name , n, size);
name[s] = '\0';
t = FOREIGENR;
}
~Singer(){}
private:
Type t;
union {
Student s;
int id;
char name[10];
};
};
int main() {
Singer(true, 13);
Singer(310217);
Singer("J Michael", 9);
return 0;
}

上面的代码中使用了一个匿名非受限联合体,它作为类 Singer 的“变长成员”来使用,这样的变长成员给类的编写带来了更大的灵活性,这是 C++98 标准中无法达到的。

assert()

断言,是宏,而非函数。assert宏的原型定义在 <assert.h>(C)、<cassert>(C++)中,其作用是如果它的条件返回错误,则终止程序执行。可以通过定义 NDEBUG 来关闭 assert,但是需要在源代码的开头,include <assert.h> 之前。

assert()会对表达式expression进行检测:

  • 如果expression的结果为 0(条件不成立),那么断言失败,表明程序出错,assert()会向标准输出设备(一般是显示器)打印一条错误信息,并调用 abort() 函数终止程序的执行。
  • 如果expression的结果为非 0(条件成立),那么断言成功,表明程序正确,assert()不进行任何操作。

参数:

  • expression:要检测的表达式。如果表达式的值为 0,那么断言失败,程序终止执行;如果表达式的值为非 0,那么断言成功,assert() 不进行任何操作。

assert() 的用法和机制

assert()的用法很简单,我们只要传入一个表达式,它会计算这个表达式的结果:如果表达式的结果为“假”,assert()会打印出断言失败的信息,并调用abort()函数终止程序的执行;如果表达式的结果为“真”,assert()就什么也不做,程序继续往后执行。

下面是一个具体的例子:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <assert.h>
int main(){
int m, n, result;
scanf("%d %d", &m, &n);
assert(n != 0); //写作 assert(n) 更加简洁
result = m / n;
printf("result = %d\n", result);
return 0;
}

NDEBUG 宏

如果查看<assert.h>头文件的源码,会发现assert()被定义为下面的样子:

1
2
3
4
5
6
#ifdef NDEBUG
#define assert(e) ((void)0)
#else
#define assert(e) \
((void) ((e) ? ((void)0) : __assert (#e, __FILE__, __LINE__)))
#endif

这意味着,一旦定义了NDEBUG宏,assert()就无效了。

NDEBUG是”No Debug“的意思,也即“非调试”。有的编译器(例如 Visual Studio)在发布(Release)模式下会定义 NDEBUG 宏,在调试(Debug)模式下不会定义定义这个宏;有的编译器(例如 Xcode)在发布模式和调试模式下都不会定义 NDEBUG 宏,这样当我们以发布模式编译程序时,就必须自己在编译参数中增加NDEBUG宏,或者在包含<assert.h>头文件之前定义NDEBUG宏。

调试模式是程序员在测试代码期间使用的编译模式,发布模式是将程序提供给用户时使用的编译模式。在发布模式下,我们不应该再依赖assert()宏,因为程序一旦出错,assert()会抛出一段用户看不懂的提示信息,并毫无预警地终止程序执行,这样会严重影响软件的用户体验,所以在发布模式下应该让assert()失效。

修改上面的代码,在包含<assert.h>之前定义NDEBUG宏:

1
2
3
4
5
6
7
8
9
10
11
#define NDEBUG
#include <stdio.h>
#include <assert.h>
int main(){
int m, n, result;
scanf("%d %d", &m, &n);
assert(n);
result = m / n;
printf("result = %d\n", result);
return 0;
}

当以发布模式编译这段代码时,assert()就会失效。如果希望继续以调试模式编译这段代码,去掉NDEBUG宏即可。

注意事项

使用assert()时,被检测的表达式最好不要太复杂,以下面的代码为例:assert( expression1 && expression2 && expression3);

当发生错误时,assert()只会告诉我们expression1 && expression2 && expression3整个表达式为不成立,但是这个大的表达式还包含了三个小的表达式,并且它们之间是&&运算,任何一个小表达式为不成立都会导致整个表达式为不成立,这样我们就无法推断到底是expression1有问题,还是expression2或者expression3有问题,从而给排错带来麻烦。

这里我们应该遵循使用assert()的一个原则:每次断言只能检验一个表达式。根据这个原则,上面的代码应改为:

1
2
3
assert(expression1);
assert(expression2);
assert(expression3);

如此,一旦程序出错,我们就知道是哪个小的表达式断言失败了,从而快速定位到有问题的代码。

使用assert()的另外一个注意事项是:不要用会改变环境的语句作为断言的表达式。请看下面的代码:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <assert.h>
int main(){
int i = 0;
while(i <= 110){
assert(++i <= 100);
printf("我是第%d行\n",i);
}
return 0;
}

在 Debug 模式下运行,程序循环到第 101 次时,i 的值为 100,++i <= 100不再成立,断言失败,程序终止运行。

在 Release 模式下运行,编译参数中设置了NDEBUG宏(如果编译器没有默认设置,那么需要你自己来设置),assert()会失效,++i <= 100这个表达式也不起作用了,while()无法终止,成为一个死循环。

定义了NDEBUG宏后,assert(++i <= 100)会被替换为((void)0)

pair类型

pair类型的定义和初始化

pair类型是在有文件utility中定义的,pair类型包含了两个数据值,通常有以下的一些定义和初始化的一些方法:

1
2
3
pair<T1, T2> p;
pair<T1, T2> p(v1, v2);
make_pair(v1, v2)

上述第一种方法是定义了一个空的pair对象p,第二种方法是定义了包含初始值为v1和v2的pair对象p。第三种方法是以v1和v2值创建的一个新的pair对象。

pair对象的一些操作

除此之外,pair对象还有一些方法,如取出pair对象中的每一个成员的值:

1
2
p.first
p.second

一个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>
#include <string>
#include <utility>
using namespace std;

int main(){
pair<int, string> p1(0, "Hello");
printf("%d, %s\n", p1.first, p1.second.c_str());
pair<int, string> p2 = make_pair(1, "World");
printf("%d, %s\n", p2.first, p2.second.c_str());
return 0;
}

map

标准库map类型是一种以键-值(key-value)存储的数据类型。

  • 第一个可以称为关键字(key),每个关键字只能在map中出现一次;
  • 第二个可能称为该关键字的值(value);

map以模板(泛型)方式实现,可以存储任意类型的数据,包括使用者自定义的数据类型。Map主要用于资料一对一映射(one-to-one)的情況,map內部的实现自建一颗红黑树,这颗树具有对数据自动排序的功能。在map内部所有的数据都是有序的。

以下分别从以下的几个方面总结:

  • map对象的定义和初始化
  • map对象的基本操作,主要包括添加元素,遍历等

map对象的定义和初始化

map是键-值对的组合,有以下的一些定义的方法:

1
2
3
map<k, v> m;
map<k, v> m(m2);
map<k, v> m(b, e);

上述第一种方法定义了一个名为m的空的map对象;第二种方法创建了m2的副本m;第三种方法创建了map对象m,并且存储迭代器b和e范围内的所有元素的副本。

map的value_type是存储元素的键以及值的pair类型,键为const。

使用map得包含map类所在的头文件

1
#include <map>  //注意,STL头文件没有扩展名.h

map对象是模板类,需要关键字和存储对象两个模板参数:
1
std:map<int, string> personnel;

这样就定义了一个用int作为索引,并拥有相关联的指向string的指针.

为了使用方便,可以对模板类进行一下类型定义,

1
typedef aap<int,CString> UDT_MAP_INT_CSTRING;

map共提供了6个构造函数,这块涉及到内存分配器这些东西,略过不表,在下面我们将接触到一些map的构造方法,这里要说下的就是,我们通常用如下方法构造一个map:

1
map<int, string> mapStudent;

map中元素的插入

在map中元素有两种插入方法:

  • 使用下标
  • 使用insert函数

在map中使用下标访问不存在的元素将导致在map容器中添加一个新的元素。

insert函数的插入方法主要有如下:

  • m.insert(e)
  • m.insert(beg, end)
  • m.insert(iter, e)

上述的e一个value_type类型的值。beg和end标记的是迭代器的开始和结束。

两种插入方法如下面的例子所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <map>
using namespace std;

int main(){
map<int, int> mp;
for (int i = 0; i < 10; i ++){
mp[i] = i;
}
for (int i = 10; i < 20; i++){
mp.insert(make_pair(i, i));
}
map<int, int>::iterator it;
for (it = mp.begin(); it != mp.end(); it++){
printf("%d-->%d\n", it->first, it->second);
}
return 0;
}

另外的方法:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义一个map对象
map<int, string> mapStudent;

// 第一种 用insert函數插入pair
mapStudent.insert(pair<int, string>(000, "student_zero"));

// 第二种 用insert函数插入value_type数据
mapStudent.insert(map<int, string>::value_type(001, "student_one"));

// 第三种 用"array"方式插入
mapStudent[123] = "student_first";
mapStudent[456] = "student_second";

以上三种用法,虽然都可以实现数据的插入,但是它们是有区别的,当然了第一种和第二种在效果上是完成一样的,用insert函数插入数据,在数据的 插入上涉及到集合的唯一性这个概念,即当map中有这个关键字时,insert操作是不能在插入数据的,但是用数组方式就不同了,它可以覆盖以前该关键字对 应的值,用程序说明如下:

1
2
mapStudent.insert(map<int, string>::value_type (001, "student_one"));
mapStudent.insert(map<int, string>::value_type (001, "student_two"));

map中元素的查找和读取

注意:上述采用下标的方法读取map中元素时,若map中不存在该元素,则会在map中插入。

因此,若只是查找该元素是否存在,可以使用函数count(k),该函数返回的是k出现的次数;若是想取得key对应的值,可以使用函数find(k),该函数返回的是指向该元素的迭代器。

上述的两个函数的使用如下所示:

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

int main(){
map<int, int> mp;
for (int i = 0; i < 20; i++){
mp.insert(make_pair(i, i));
}

if (mp.count(0)){
printf("yes!\n");
}else{
printf("no!\n");
}

map<int, int>::iterator it_find;
it_find = mp.find(0);
if (it_find != mp.end()){
it_find->second = 20;
}else{
printf("no!\n");
}

map<int, int>::iterator it;
for (it = mp.begin(); it != mp.end(); it++){
printf("%d->%d\n", it->first, it->second);
}
return 0;
}

从map中删除元素

从map中删除元素的函数是erase(),该函数有如下的三种形式:

1
2
3
m.erase(k)
m.erase(p)
m.erase(b, e)

第一种方法删除的是m中键为k的元素,返回的是删除的元素的个数;第二种方法删除的是迭代器p指向的元素,返回的是void;第三种方法删除的是迭代器b和迭代器e范围内的元素,返回void。

如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <map>
using namespace std;

int main(){
map<int, int> mp;
for (int i = 0; i < 20; i++){
mp.insert(make_pair(i, i));
}

mp.erase(0);
mp.erase(mp.begin());
map<int, int>::iterator it;
for (it = mp.begin(); it != mp.end(); it++){
printf("%d->%d\n", it->first, it->second);
}

return 0;
}

map的基本操作函数:

C++ maps是一种关联式容器,包含“关键字/值”对

  • begin() 返回指向map头部的迭代器
  • clear() 删除所有元素
  • count() 返回指定元素出现的次数
  • empty() 如果map为空则返回true
  • end() 返回指向map末尾的迭代器
  • equal_range() 返回特殊条目的迭代器对
  • erase() 删除一个元素
  • find() 查找一个元素
  • get_allocator() 返回map的配置器
  • insert() 插入元素
  • key_comp() 返回比较元素key的函数
  • lower_bound() 返回键值>=给定元素的第一个位置
  • max_size() 返回可以容纳的最大元素个数
  • rbegin() 返回一个指向map尾部的逆向迭代器
  • rend() 返回一个指向map头部的逆向迭代器
  • size() 返回map中元素的个数
  • swap() 交换两个map
  • upper_bound() 返回键值>给定元素的第一个位置
  • value_comp() 返回比较元素value的函数

stack

函数名 功能 复杂度
size() 返回栈的元素数 O(1)
top() 返回栈顶的元素 O(1)
pop() 从栈中取出并删除元素 O(1)
push(x) 向栈中添加元素x O(1)
empty() 在栈为空时返回true O(1)

贴一些代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "stdafx.h"
#include<iostream>
#include<stack>
using namespace std;
int main()
{
stack<int> S;
S.push(3);
S.push(7);
S.push(1);
cout << S.size() << " ";
cout << S.top() << " ";
S.pop();
cout << S.top() << " ";
S.pop();
cout << S.top() << " ";
S.push(5);
cout << S.top() << " ";
S.pop();
cout << S.top() << endl;
return 0;
}

queues

C++队列是一种容器适配器,它给予程序员一种先进先出(FIFO)的数据结构。

  1. back() 返回一个引用,指向最后一个元素
  2. empty() 如果队列空则返回真
  3. front() 返回第一个元素
  4. pop() 删除第一个元素
  5. push() 在末尾加入一个元素
  6. size() 返回队列中元素的个数

队列可以用线性表(list)或双向队列(deque)来实现(注意vector container 不能用来实现queue,因为vector 没有成员函数pop_front!):

1
2
queue<list<int>> q1
queue<deque<int>> q2

其成员函数有“判空(empty)” 、“尺寸(Size)” 、“首元(front)” 、“尾元(backt)” 、“加入队列(push)” 、“弹出队列(pop)”等操作。

例:

1
2
3
4
5
6
7
8
int main()
{
queue<int> q;
q.push(4);
q.push(5);
printf("%d\n",q.front());
q.pop();
}

Priority Queues

C++优先队列类似队列,但是在这个数据结构中的元素按照一定的断言排列有序。

  1. empty()如果优先队列为空,则返回真
  2. pop()删除第一个元素
  3. push()加入一个元素
  4. size()返回优先队列中拥有的元素的个数
  5. top()返回优先队列中有最高优先级的元素

优先级队列可以用向量(vector)或双向队列(deque)来实现(注意list container 不能用来实现queue,因为list 的迭代器不是任意存取iterator,而pop 中用到堆排序时是要求randomaccess iterator 的!):

  • priority_queue<vector<int>, less<int>> pq1; 使用递增less函数对象排序
  • priority_queue<deque<int>, greater<int>> pq2; 使用递减greater函数对象排序
  • 其成员函数有“判空(empty)” 、“尺寸(Size)” 、“栈顶元素(top)” 、“压栈(push)” 、“弹栈(pop)”等。

priority_queue模版类有三个模版参数,元素类型,容器类型,比较算子。其中后两个都可以省略,默认容器为vector,默认算子为less,即小的往前排,大的往后排(出队时序列尾的元素出队)。

初学者在使用priority_queue时,最困难的可能就是如何定义比较算子了。如果是基本数据类型,或已定义了比较运算符的类,可以直接用STL的less算子和greater算子——默认为使用less算子,即小的往前排,大的先出队。如果要定义自己的比较算子,方法有多种,这里介绍其中的一种:重载比较运算符。优先队列试图将两个元素x和y代入比较运算符(对less算子,调用xy),若结果为真,则x排在y前面,y将先于x出队,反之,则将y排在x前面,x将先出队。

例:

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

class T {
public:
int x, y, z;
T(int a, int b, int c):x(a), y(b), z(c)
{
}
};
bool operator < (const T &t1, const T &t2)
{
return t1.z < t2.z; // 按照z的顺序来决定t1和t2的顺序
}
int main()
{
priority_queue<T> q;
q.push(T(4,4,3));
q.push(T(2,2,5));
q.push(T(1,5,4));
q.push(T(3,3,6));
while (!q.empty())
{
T t = q.top();
q.pop();
cout << t.x << " " << t.y << " " << t.z << endl;
}
return 1;
}

输出结果为(注意是按照z的顺序从大到小出队的):

1
2
3
4
3 3 6 
2 2 5
1 5 4
4 4 3

再看一个按照z的顺序从小到大出队的例子:

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> 
#include <queue>
using namespace std;
class T
{
public:
int x, y, z;
T(int a, int b, int c):x(a), y(b), z(c)
{
}
};
bool operator > (const T &t1, const T &t2)
{
return t1.z > t2.z;
}
main()
{
priority_queue<T, vector<T>, greater<T> > q;
q.push(T(4,4,3));
q.push(T(2,2,5));
q.push(T(1,5,4));
q.push(T(3,3,6));
while (!q.empty())
{
T t = q.top();
q.pop();
cout << t.x << " " << t.y << " " << t.z << endl;
}
return 1;
}

输出结果为:
1
2
3
4
4 4 3 
1 5 4
2 2 5
3 3 6

vector的内部实现原理及基本用法

本文基于STL vector源代码,但是不考虑分配器allocator,迭代器iterator,异常处理try/catch等内容,同时对_Ucopy()、 _Umove()、 _Ufill()函数也不会过度分析。

vector的定义

1
2
3
4
5
6
7
8
9
10
11
12
template<class _Ty,
class _Ax>
class vector
: public _Vector_val<_Ty, _Ax>
{ // varying size array of values
public:
/********/
protected:
pointer _Myfirst; // pointer to beginning of array
pointer _Mylast; // pointer to current end of sequence
pointer _Myend; // pointer to end of array
};

简单理解,就是vector是利用上述三个指针来表示的,基本示意图如下:

两个关键大小:

  • 大小:size=_Mylast - _Myfirst;
  • 容量:capacity=_Myend - _Myfirst;

分别对应于resize()、reserve()两个函数。size表示vector中已有元素的个数,容量表示vector最多可存储的元素的个数;为了降低二次分配时的成本,vector实际配置的大小可能比客户需求的更大一些,以备将来扩充,这就是容量的概念。即capacity>=size,当等于时,容器此时已满,若再要加入新的元素时,就要重新进行内存分配,整个vector的数据都要移动到新内存。二次分配成本较高,在实际操作时,应尽量预留一定空间,避免二次分配。

构造与析构

构造

vector的构造函数主要有以下几种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector() : _Mybase()
{ // construct empty vector
_Buy(0);
}
explicit vector(size_type _Count) : _Mybase()
{ // construct from _Count * _Ty()
_Construct_n(_Count, _Ty());
}
vector(size_type _Count, const _Ty& _Val) : _Mybase()
{ // construct from _Count * _Val
_Construct_n(_Count, _Val);
}
vector(const _Myt& _Right) : _Mybase(_Right._Alval)
{ // construct by copying _Right
if (_Buy(_Right.size()))
_Mylast = _Ucopy(_Right.begin(), _Right.end(), _Myfirst);
}

vector优异性能的秘诀之一,就是配置比其所容纳的元素所需更多的内存,一般在使用vector之前,就先预留足够空间,以避免二次分配,这样可以使vector的性能达到最佳。因此元素个数_Count是个远比元素值 _Val重要的参数,因此当构造一个vector时,首要参数一定是元素个数。
由上各构造函数可知,基本上所有构造函数都是基于_Construct _n() 的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool _Buy(size_type _Capacity)
{ // allocate array with _Capacity elements
_Myfirst = 0, _Mylast = 0, _Myend = 0;
if (_Capacity == 0) //_Count为0时,直接返回
return (false);
else
{ // nonempty array, allocate storage
_Myfirst = this->_Alval.allocate(_Capacity); //分配内存,并更新成员变量
_Mylast = _Myfirst;
_Myend = _Myfirst + _Capacity;
}
return (true);
}

void _Construct_n(size_type _Count, const _Ty& _Val)
{ // 构造含有_Count个值为_Val的元素的容器
if (_Buy(_Count))
_Mylast = _Ufill(_Myfirst, _Count, _Val);
}

这样就完成了vector容器的构造了。

析构

vector的析构函数很简单,就是先销毁所有已存在的元素,然后释放所有内存

1
2
3
4
5
6
7
8
9
void _Tidy()
{ // free all storage
if (_Myfirst != 0)
{ // something to free, destroy and deallocate it
_Destroy(_Myfirst, _Mylast);
this->_Alval.deallocate(_Myfirst, _Myend - _Myfirst);
}
_Myfirst = 0, _Mylast = 0, _Myend = 0;
}

插入和删除元素

vector的插入和删除元素是通过push_back ()pop_back()两个接口来实现的,他们的内部实现也非常简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void push_back(const _Ty& _Val)
{ // insert element at end
if (size() < capacity())
_Mylast = _Ufill(_Mylast, 1, _Val);
else
insert(end(), _Val); //空间不足时,就会触发内存的二次分配
}
void pop_back()
{ // erase element at end
if (!empty())
{ // erase last element
_Destroy(_Mylast - 1, _Mylast);
--_Mylast;
}
}

其他接口

reserve()操作。之前提到过reserve(Count) 函数主要是预留Count大小的空间,对应的是容器的容量,目的是保证(_Myend - _Myfirst)>=Count。只有当空间不足时,才会操作,即重新分配一块内存,将原有元素拷贝到新内存,并销毁原有内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void reserve(size_type _Count)
{ // determine new minimum length of allocated storage
if (capacity() < _Count)
{ // not enough room, reallocate
pointer _Ptr = this->_Alval.allocate(_Count);
_Umove(begin(), end(), _Ptr);
size_type _Size = size();
if (_Myfirst != 0)
{ // destroy and deallocate old array
_Destroy(_Myfirst, _Mylast);
this->_Alval.deallocate(_Myfirst, _Myend - _Myfirst);
}
_Myend = _Ptr + _Count;
_Mylast = _Ptr + _Size;
_Myfirst = _Ptr;
}
}

resize()操作。resize(Count) 函数主要是用于改变size的,也就是改变vector的大小,最终改变的是(_Mylast - _Myfirst)的值,当size < Count时,就插入元素,当size >Count时,就擦除元素。

1
2
3
4
5
6
7
void resize(size_type _Newsize, _Ty _Val)
{ // determine new length, padding with _Val elements as needed
if (size() < _Newsize)
_Insert_n(end(), _Newsize - size(), _Val);
else if (_Newsize < size())
erase(begin() + _Newsize, end());
}

_Insert_n()操作。resize()操作和insert()操作都会利用到_Insert_n()这个函数,这个函数非常重要,也比其他函数稍微复杂一点。虽然_Insert_n(_where, _Count, _Val )函数比较长,但是操作都非常简单,主要可以分为以下几种情况:

  1. _Count == 0,不需要插入,直接返回
  2. max_size() - size() < _Count,超过系统设置的最大容量,会溢出,造成Xlen()异常
  3. _Capacity < size() + _Count,vector的容量不足以插入Count个元素,需要进行二次分配,扩大vector的容量。 在VS下,vector容量会扩大50%,即 _Capacity = _Capacity + _Capacity / 2;
    若仍不足,则 _Capacity = size() + _Count;
1
2
3
4
5
6
7
8
9
10
11
12
13
else if (_Capacity < size() + _Count)
{ // not enough room, reallocate
_Capacity = max_size() - _Capacity / 2 < _Capacity
? 0 : _Capacity + _Capacity / 2; // try to grow by 50%
if (_Capacity < size() + _Count)
_Capacity = size() + _Count;
pointer _Newvec = this->_Alval.allocate(_Capacity);
pointer _Ptr = _Newvec;
_Ptr = _Umove(_Myfirst, _VEC_ITER_BASE(_Where),_Newvec); // copy prefix
_Ptr = _Ufill(_Ptr, _Count, _Val); // add new stuff
_Umove(_VEC_ITER_BASE(_Where), _Mylast, _Ptr); // copy suffix
//内存释放与变量更新
}

这种情况下,数据从原始容器移动到新分配内存时是从前到后移动的

  1. 空间足够,且被插入元素的位置比较靠近_Mylast,即已有元素的尾部

这种情况下不需要再次进行内存分配,且数据是从后往前操作的。首先是将where~last向后移动,为待插入数据预留Count大小的空间,然后从_Mylast处开始填充,然后将从where处开始填充剩余元素

1
2
3
4
5
6
7
else if ((size_type)(_Mylast - _VEC_ITER_BASE(_Where)) < _Count)
{ // new stuff spills off end
_Umove(_VEC_ITER_BASE(_Where), _Mylast, _VEC_ITER_BASE(_Where) + _Count); // copy suffix
_Ufill(_Mylast, _Count - (_Mylast - _VEC_ITER_BASE(_Where)), _Val); // insert new stuff off end
_Mylast += _Count;
std::fill(_VEC_ITER_BASE(_Where), _Mylast - _Count, _Val); // insert up to old end
}

  1. 空间足够,但插入的位置比较靠前
    1
    2
    3
    4
    5
    6
    7
    8
    {   // new stuff can all be assigned
    _Ty _Tmp = _Val; // in case _Val is in sequence

    pointer _Oldend = _Mylast;
    _Mylast = _Umove(_Oldend - _Count, _Oldend, _Mylast); // copy suffix
    _STDEXT _Unchecked_move_backward(_VEC_ITER_BASE(_Where), _Oldend - _Count, _Oldend); // copy hole
    std::fill(_VEC_ITER_BASE(_Where), _VEC_ITER_BASE(_Where) + _Count, _Tmp); // insert into hole
    }

erase()操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
iterator erase(const_iterator _First_arg,
const_iterator _Last_arg)
{ // erase [_First, _Last)
iterator _First = _Make_iter(_First_arg);
iterator _Last = _Make_iter(_Last_arg);

if (_First != _Last)
{ // worth doing, copy down over hole
pointer _Ptr = _STDEXT unchecked_copy(_VEC_ITER_BASE(_Last), _Mylast,
_VEC_ITER_BASE(_First));

_Destroy(_Ptr, _Mylast);
_Mylast = _Ptr;
}
return (_First);
}

主要操作就是将后半部分的有效元素向前拷贝,并将后面空间的无效元素析构,并更新_Mylast变量

assign()操作最终都会调用到下面的函数,主要操作是首先擦除容器中已有的全部元素,在从头开始插入Count个Val元素

1
2
3
4
5
6
void _Assign_n(size_type _Count, const _Ty& _Val)
{ // assign _Count * _Val
_Ty _Tmp = _Val; // in case _Val is in sequence
erase(begin(), end());
insert(begin(), _Count, _Tmp);
}

基本使用

在经过上述对vector内部实现的分析后,再来理解相应接口就变得简单得多。vector对外接口主要可以分为:

构造、析构:

1
2
3
4
5
6
vector<Elem> c
vector <Elem> c1(c2)
vector <Elem> c(n)
vector <Elem> c(n, elem)
vector <Elem> c(beg,end)
c.~ vector <Elem>()

插入、删除、赋值

1
2
3
4
5
6
7
8
9
10
c.push_back(elem)
c.pop_back()
c.insert(pos,elem)
c.insert(pos,n,elem)
c.insert(pos,beg,end)
c.erase(pos)
c.erase(beg,end)
c.clear()
c.assign(beg,end)
c.assign(n,elem)

大小相关

1
2
3
4
5
c.capacity()
c.max_size()
c.resize(num)
c.reserve()
c.size()

获取迭代器

1
2
3
4
c.begin()
c.end()
c.rbegin()
c.rend()

获取数据

1
2
3
4
operator[]
c.at(idx)
c.front()
c.back()

size_t

在标准C库中的许多函数使用的参数或者返回值都是表示的用字节表示的对象大小,比如说malloc(n)函数的参数n指明了需要申请的空间大小,还有memcpy(s1, s2, n)的最后一个参数,表明需要复制的内存大小,strlen(s)函数的返回值表明了以’\0’结尾的字符串的长度(不包括’\0’),其返回值并不是该字符串的实际长度,因为要去掉’\0’。

或许你会认为这些参数或者返回值应该被申明为int类型(或者long或者unsigned),但是事实上并不是。C标准中将他们定义为size_t。标准中记载malloc的申明应该出现在,定义为:

1
void *malloc(size_t n);

memcpy和strlen的申明应该出现在中:

1
2
void *memcpy(void *s1, void const *s2, size_t n);
size_t strlen(char const *s);

size_t还经常出现在C++标准库中,此外,C++库中经常会使用一个相似的类型size_type,用的可能比size_t还要多。

可移植性问题

回忆memcpy(s1, s2, n)函数,它将s2指向地址开始的n个字节拷贝到s2指向的地址,返回s1,这个函数可以拷贝任何数据类型,所以参数和返回值的类型应该为可以指向任何类型的void,同时,>源地址不应该被改变,所以第二个参数s2类型应该为`const void`,这些都不是问题。真正的问题在于我们如何申明第三个参数,它代表了源对象的大小,我相信大部分程序员都会选择int:

1
void *memcpy(void *s1, void const *s2, int n);

使用int类型在大部分情况下都是可以的,但是我们可以使用unsigned int代替它让第三个参数表示的范围更大。在大部分机器上,unsigned int的最大值要比int的最大值大两倍。使用unsigned int修饰第三个参数的代价与int是相同的:
1
void *memcpy(void *s1, void const *s2, unsigned int n);

这样似乎没有问题了,unsigned int可以表示最大类型的对象大小了,这种情况只有在整形和指针类型具有相同大小的情况下,比如说在IP16中,整形和指针都占2个字节(16位),而在IP32上面,整形和指针都占4个字节(32位)。

使用size_t

size_t是一种数据相关的无符号类型,它被设计得足够大以便能够内存中任意对象的大小。在C++中,设计 size_t 就是为了适应多个平台的。ize_t的引入增强了程序在不同平台上的可移植性。

size_t的定义在<stddef.h>,<stdio.h>,<stdlib.h>,<string.h>, <time.h><wchar.h>这些标准C头文件中,也出现在相应的C++头文件, 等等中,你应该在你的头文件中至少包含一个这样的头文件在使用size_t之前。包含以上任何C头文件(由C或C++编译的程序)表明将size_t作为全局关键字。根据定义,size_t是sizeof关键字(注:sizeof是关键字,并非运算符)运算结果的类型。所以,应当通过适当的方式声明n来完成赋值:

1
n = sizeof(thing);

考虑到可移植性和程序效率,n应该被申明为size_t类型。类似的,下面的foo函数的参数也应当被申明为sizeof:
1
foo(sizeof(thing));

参数中带有size_t的函数通常会含有局部变量用来对数组的大小或者索引进行计算,在这种情况下,size_t是个不错的选择。

size_t的大小并非像很多网上描述的那样,其大小是由系统的位数决定的。size_t的大小是由你生成的程序类型决定的,只是生成的程序类型与系统的类型有一定关系。32bits的程序既可以在64bits的系统上运行,也可以在32bits的系统上运行。但是64bits的程序只能在64bits的系统上运行。然而我们编译的程序一般是32bits的,因此size_t的大小也就变成了4个字节。

内存对齐

struct/class/union内存对齐原则有四个:

  1. 数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员>有子成员,比如说是数组,结构体等)的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储),基本类型不包括struct/class/uinon。
  2. 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部”最宽基本类型成员”的整数倍地址开始存储.(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储.)。
  3. 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的”最宽基本类型成员”的整数倍.不足的要补齐.(基本类型不包括struct/class/uinon)。
  4. sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。

实例解释:下面以class为代表

1
2
3
4
5
6
7
No. 1
class Data
{
char c;
int a;
};
cout << sizeof(Data) << endl;

1
2
3
4
5
6
7
8
No. 2
class Data
{
char c;
double a;
};

cout << sizeof(Data) << endl;

显然程序No.1 输出的结果为 8, No.2 输出的结果为 16。No.1最大的数据成员是4bytes,1+4=5,补齐为4的倍数,也就是8。而No.2为8bytes,1+8=9,补齐为8的倍数,也就是16。

内存对齐的主要作用是:

  1. 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
  2. 性能原因:经过内存对齐后,CPU的内存访问速度大大提升。具体原因稍后解释。

strlen和sizeof区别?

sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。

sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串。

因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。

1
2
3
4
5
6
int main(int argc, char const *argv[]){   
const char* str = "name";
sizeof(str); // 取的是指针str的长度,是8
strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
return 0;
}

数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:sizeof(数组名);数组大小:sizeof(数组名)/sizeof(数组元素数据类型);用运算符sizeof 可以计算出数组的容量(字节数)。sizeof(p),p 为指针得到的是一个指针变量的字节数,而不是p 所指的内存容量。

OFFSETOF

OFFSETOF(s, m)的宏定义,s是结构类型,m是s的成员,求m在s中的偏移量。

1
#define OFFSETOF(s, m) size_t(&((s*)0)->m)

sizeof

sizeof一个类求大小(注意成员变量,函数,虚函数,继承等等对大小的影响)以下运行环境都是一般的,在32位编译环境中。

基本数据类型的sizeof

1
2
3
4
5
6
7
cout<<sizeof(char)<<endl;                     结果是1
cout<<sizeof(int)<<endl; 结果是4
cout<<sizeof(unsigned int)<<endl; 结果是4
cout<<sizeof(long int)<<endl; 结果是4
cout<<sizeof(short int)<<endl; 结果是2
cout<<sizeof(float)<<endl; 结果是4
cout<<sizeof(double)<<endl; 结果是8

指针变量的sizeof

1
2
3
4
5
6
7
8
9
10
11
12
char *pc ="abc";
sizeof( pc ); // 结果为4
sizeof(*pc); // 结果为1
int *pi;
sizeof( pi ); //结果为4
sizeof(*pi); //结果为4
char **ppc = &pc;
sizeof( ppc ); // 结果为4
sizeof( *ppc ); // 结果为4
sizeof( **ppc ); // 结果为1
void (*pf)();// 函数指针
sizeof( pf );// 结果为4

数组的sizeof数组的sizeof值等于数组所占用的内存字节数,如:

1
2
3
4
char a1[] = "abc";
int a2[3];
sizeof( a1 ); // 结果为4,字符 末尾还存在一个NULL终止符
sizeof( a2 ); // 结果为3*4=12(依赖于int)

写到这里,提一问,下面的c3,c4值应该是多少呢
1
2
3
4
5
6
7
8
void foo3(char a3[3])
{
int c3 = sizeof( a3 ); // c3 == 4
}
void foo4(char a4[])
{
int c4 = sizeof( a4 ); // c4 == 4
}

也许当你试图回答c4的值时已经意识到c3答错了,是的,c3!=3。这里函数参数a3已不再是数组类型,而是蜕变成指针,相当于char* a3,为什么仔细想想就不难明白,我们调用函数foo1时,程序会在栈上分配一个大小为3的数组吗不会!数组是“传址”的,调用者只需将实参的地址传递过去,所以a3自然为指针类型char*,c3的值也就为4。

结构体的sizeof

1
2
3
4
5
6
struct MyStruct
{
double dda1;
char dda;
int type
};

结果为16,为上面的结构分配空间的时候,VC根据成员变量出现的顺序和对齐方式,先为第一个成员dda1分配空间,其起始地址跟结构的起始地址相同(刚好偏移量0刚好为sizeof(double)的倍数),该成员变量占用sizeof(double)=8个字节;接下来为第二个成员dda分配空间,这时下一个可以分配的地址对于结构的起始地址的偏移量为8,是sizeof(char)的倍数,所以把dda存放在偏移量为8的地方满足对齐方式,该成员变量占用sizeof(char)=1个字节;接下来为第三个成员type分配空间,这时下一个可以分配的地址对于结构的起始地址的偏移量为9,不是sizeof(int)=4的倍数,为了满足对齐方式对偏移量的约束问题,VC自动填充3个字节(这三个字节没有放什么东西),这时下一个可以分配的地址对于结构的起始地址的偏移量为12,刚好是sizeof(int)=4的倍数,所以把type存放在偏移量为12的地方,该成员变量占用sizeof(int)=4个字节;这时整个结构的成员变量已经都分配了空间,总的占用的空间大小为:8+1+3+4=16,刚好为结构的字节边界数(即结构中占用最大空间的类型所占用的字节数sizeof(double)=8)的倍数,所以没有空缺的字节需要填充。

含位域结构体的sizeof

1
2
3
4
5
6
struct BF1
{
char f1 : 3;
char f2 : 4;
char f3 : 5;
};

位域类型为char,第1个字节仅能容纳下f1和f2,所以f2被压缩到第1个字节中,而f3只能从下一个字节开始。因此sizeof(BF1)的结果为2。

含有联合体的结构体的sizeof

1
2
3
4
5
6
7
8
9
10
struct s1
{
char *ptr,ch;
union A
{
short a,b;
unsigned int c:2, d:1;
};
struct s1* next;
};

这样是8+4=12个字节
1
2
3
4
5
6
7
8
9
10
struct s1
{
char *ptr,ch;
union //联合体是结构体的成员,占内存,并且最大类型是unsigned int,占4
{
short a,b;
unsigned int c:2, d:1;
};
struct s1* next;
};

这样是8+4+4=16个字节

结构体含有结构体的sizeof

1
2
3
4
5
6
7
8
9
10
11
12
struct S1
{
char c;
int i;
};
struct S3
{
char c1;
S1 s;
char c2;
};
cout<<sizeof(S3); //S3=16

S1的最宽简单成员的类型为int,S3在考虑最宽简单类型成员时是将S1“打散”看的,所以S3的最宽简单类型为int,这样,通过S3定义的变量,其存储空间首地址需要被4整除,整个sizeof(S3)的值也应该被4整除。

c1的偏移量为0,s的偏移量呢这时s是一个整体,它作为结构体变量也满足前面三个准则,所以其大小为8,偏移量为4,c1与s之间便需要3个填充字节,而c2与s之间就不需要了,所以c2的偏移量为12,算上c2的大小为13,13是不能被4整除的,这样末尾还得补上3个填充字节。最后得到sizeof(S3)的值为16。

带有#pragma pack的sizeof:它是用来调整结构体对齐方式的,不同编译器名称和用法略有不同,VC6中通过#pragma pack实现,也可以直接修改/Zp编译开关。#pragma pack的基本用法为:#pragma pack(n),n为字节对齐数,其取值为1、2、4、8、16,默认是8,如果这个值比结构体成员的sizeof值小,那么该成员的偏移量应该以此值为准,即是说,结构体成员的偏移量应该取二者的最小值,

再看示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma pack(push) // 将当前pack设置压栈保存
#pragma pack(2)// 必须在结构体定义之前使用
struct S1
{
char c;
int i;
};
struct S3
{
char c1;
S1 s;
char c2
};
#pragma pack(pop) // 恢复先前的pack设置

计算sizeof(S1)时,min(2, sizeof(i))的值为2,所以i的偏移量为2,加上sizeof(i)等于6,能够被2整除,所以整个S1的大小为6。

同样,对于sizeof(S3),s的偏移量为2,c2的偏移量为8,加上sizeof(c2)等于9,不能被2整除,添加一个填充字节,所以sizeof(S3)等于10。

空结构体的sizeof

1
2
struct S5 { };
sizeof( S5 ); // 结果为1

类的sizeof
类的sizeof值等于类中成员变量所占用的内存字节数。如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
public:
int b;
float c;
char d;
};

int main(void)
{
A object;
cout << "sizeof(object) is " << sizeof(object) << endl;
return 0 ;
}

输出结果为12(我的机器上sizeof(float)值为4,字节对其前面已经讲过)。

不过需要注意的是,如果类中存在静态成员变量,结果又会是什么样子呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A
{
public:
static int a;
int b;
float c;
char d;
};

int main()
{
A object;
cout << "sizeof(object) is " << sizeof(object) << endl;
return 0 ;
}

16?不对。结果仍然是12.

因为在程序编译期间,就已经为static变量在静态存储区域分配了内存空间,并且这块内存在程序的整个运行期间都存在。而每次声明了类A的一个对象的时候,为该对象在堆上,根据对象的大小分配内存。

如果类A中包含成员函数,那么又会是怎样的情况呢?看下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class A
{
public:
static int a;
int b;
float c;
char d;
int add(int x,int y)
{
return x+y;
}
};

int main()
{
A object;
cout << "sizeof(object) is " << sizeof(object) << endl;
b = object.add(3,4);
cout << "sizeof(object) is " << sizeof(object) << endl;
return 0 ;
}

结果仍为12。

因为只有非静态类成员变量在新生成一个object的时候才需要自己的副本。所以每个非静态成员变量在生成新object需要内存,而function是不需要的。

标准C++中的string类

相信使用过MFC编程的朋友对CString这个类的印象应该非常深刻吧?的确,MFC中的CString类使用起来真的非常的方便好用。但是如果离开了MFC框架,还有没有这样使用起来非常方便的类呢?答案是肯定的。也许有人会说,即使不用MFC框架,也可以想办法使用MFC中的API,具体的操作方法在本文最后给出操作方法。其实,可能很多人很可能会忽略掉标准C++中string类的使用。标准C++中提供的string类得功能也是非常强大的,一般都能满足我们开发项目时使用。现将具体用法的一部分罗列如下,只起一个抛砖引玉的作用吧,好了,废话少说,直接进入正题吧!

要想使用标准C++中string类,必须要包含

1
2
3
#include <string>// 注意是<string>,不是<string.h>,带.h的是C语言中的头文件
using std::string;
using std::wstring;


1
using namespace std;

下面你就可以使用string/wstring了,它们两分别对应着char和wchar_t。

string和wstring的用法是一样的,以下只用string作介绍:

string类的构造函数

1
2
string(const char *s);    //用c字符串s初始化
string(int n,char c); //用n个字符c初始化

此外,string类还支持默认构造函数和复制构造函数,如string s1;string s2=”hello”;都是正确的写法。当构造的string太长而无法表达时会抛出length_error异常 ;

string类的字符操作

1
2
3
4
const char &operator[](int n)const;
const char &at(int n)const;
char &operator[](int n);
char &at(int n);

operator[]at()均返回当前字符串中第n个字符的位置,但at函数提供范围检查,当越界时会抛出out_of_range异常,下标运算符[]不提供检查访问。

1
2
3
const char *data()const;//返回一个非null终止的c字符数组
const char *c_str()const;//返回一个以null终止的c字符串
int copy(char *s, int n, int pos = 0) const;//把当前串中以pos开始的n个字符拷贝到以s为起始位置的字符数组中,返回实际拷贝的数目

string的特性描述

1
2
3
4
5
6
int capacity()const;    //返回当前容量(即string中不必增加内存即可存放的元素个数)
int max_size()const; //返回string对象中可存放的最大字符串的长度
int size()const; //返回当前字符串的大小
int length()const; //返回当前字符串的长度
bool empty()const; //当前字符串是否为空
void resize(int len,char c);//把字符串当前大小置为len,并用字符c填充不足的部分

string类的输入输出操作

string类重载运算符operator>>用于输入,同样重载运算符operator<<用于输出操作。

函数getline(istream &in,string &s);用于从输入流in中读取字符串到s中,以换行符’\n’分开。

string的赋值

1
2
3
4
5
6
7
string &operator=(const string &s);//把字符串s赋给当前字符串
string &assign(const char *s);//用c类型字符串s赋值
string &assign(const char *s,int n);//用c字符串s开始的n个字符赋值
string &assign(const string &s);//把字符串s赋给当前字符串
string &assign(int n,char c);//用n个字符c赋值给当前字符串
string &assign(const string &s,int start,int n);//把字符串s中从start开始的n个字符赋给当前字符串
string &assign(const_iterator first,const_itertor last);//把first和last迭代器之间的部分赋给字符串

string的连接

1
2
3
4
5
6
7
string &operator+=(const string &s);//把字符串s连接到当前字符串的结尾 
string &append(const char *s); //把c类型字符串s连接到当前字符串结尾
string &append(const char *s,int n);//把c类型字符串s的前n个字符连接到当前字符串结尾
string &append(const string &s); //同operator+=()
string &append(const string &s,int pos,int n);//把字符串s中从pos开始的n个字符连接到当前字符串的结尾
string &append(int n,char c); //在当前字符串结尾添加n个字符c
string &append(const_iterator first,const_iterator last);//把迭代器first和last之间的部分连接到当前字符串的结尾

string的比较

1
bool operator==(const string &s1,const string &s2)const;//比较两个字符串是否相等

运算符”>”,”<”,”>=”,”<=”,”!=”均被重载用于字符串的比较;

1
2
3
4
5
6
int compare(const string &s) const;//比较当前字符串和s的大小
int compare(int pos, int n,const string &s)const;//比较当前字符串从pos开始的n个字符组成的字符串与s的大小
int compare(int pos, int n,const string &s,int pos2,int n2)const;//比较当前字符串从pos开始的n个字符组成的字符串与s中pos2开始的n2个字符组成的字符串的大小
int compare(const char *s) const;
int compare(int pos, int n,const char *s) const;
int compare(int pos, int n,const char *s, int pos2) const;

compare函数在>时返回1,<时返回-1,==时返回0

string的子串

1
string substr(int pos = 0,int n = npos) const;//返回pos开始的n个字符组成的字符串

string的交换

1
void swap(string &s2);    //交换当前字符串与s2的值

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
27
28
29
int find(char c, int pos = 0) const;//从pos开始查找字符c在当前字符串的位置
int find(const char *s, int pos = 0) const;//从pos开始查找字符串s在当前串中的位置
int find(const char *s, int pos, int n) const;//从pos开始查找字符串s中前n个字符在当前串中的位置
int find(const string &s, int pos = 0) const;//从pos开始查找字符串s在当前串中的位置
//查找成功时返回所在位置,失败返回string::npos的值
int rfind(char c, int pos = npos) const;//从pos开始从后向前查找字符c在当前串中的位置
int rfind(const char *s, int pos = npos) const;
int rfind(const char *s, int pos, int n = npos) const;
int rfind(const string &s,int pos = npos) const;
//从pos开始从后向前查找字符串s中前n个字符组成的字符串在当前串中的位置,成功返回所在位置,失败时返回string::npos的值
int find_first_of(char c, int pos = 0) const;//从pos开始查找字符c第一次出现的位置
int find_first_of(const char *s, int pos = 0) const;
int find_first_of(const char *s, int pos, int n) const;
int find_first_of(const string &s,int pos = 0) const;
//从pos开始查找当前串中第一个在s的前n个字符组成的数组里的字符的位置。查找失败返回string::npos
int find_first_not_of(char c, int pos = 0) const;
int find_first_not_of(const char *s, int pos = 0) const;
int find_first_not_of(const char *s, int pos,int n) const;
int find_first_not_of(const string &s,int pos = 0) const;
//从当前串中查找第一个不在串s中的字符出现的位置,失败返回string::npos
int find_last_of(char c, int pos = npos) const;
int find_last_of(const char *s, int pos = npos) const;
int find_last_of(const char *s, int pos, int n = npos) const;
int find_last_of(const string &s,int pos = npos) const;
int find_last_not_of(char c, int pos = npos) const;
int find_last_not_of(const char *s, int pos = npos) const;
int find_last_not_of(const char *s, int pos, int n) const;
int find_last_not_of(const string &s,int pos = npos) const;
//find_last_of和find_last_not_of与find_first_of和find_first_not_of相似,只不过是从后向前查找

string类的替换函数

1
2
3
4
5
6
7
8
9
10
string &replace(int p0, int n0,const char *s);//删除从p0开始的n0个字符,然后在p0处插入串s
string &replace(int p0, int n0,const char *s, int n);//删除p0开始的n0个字符,然后在p0处插入字符串s的前n个字符
string &replace(int p0, int n0,const string &s);//删除从p0开始的n0个字符,然后在p0处插入串s
string &replace(int p0, int n0,const string &s, int pos, int n);//删除p0开始的n0个字符,然后在p0处插入串s中从pos开始的n个字符
string &replace(int p0, int n0,int n, char c);//删除p0开始的n0个字符,然后在p0处插入n个字符c
string &replace(iterator first0, iterator last0,const char *s);//把[first0,last0)之间的部分替换为字符串s
string &replace(iterator first0, iterator last0,const char *s, int n);//把[first0,last0)之间的部分替换为s的前n个字符
string &replace(iterator first0, iterator last0,const string &s);//把[first0,last0)之间的部分替换为串s
string &replace(iterator first0, iterator last0,int n, char c);//把[first0,last0)之间的部分替换为n个字符c
string &replace(iterator first0, iterator last0,const_iterator first, const_iterator last);//把[first0,last0)之间的部分替换成[first,last)之间的字符串

string类的插入函数

1
2
3
4
5
6
7
8
9
string &insert(int p0, const char *s);
string &insert(int p0, const char *s, int n);
string &insert(int p0,const string &s);
string &insert(int p0,const string &s, int pos, int n);
//前4个函数在p0位置插入字符串s中pos开始的前n个字符
string &insert(int p0, int n, char c);//此函数在p0处插入n个字符c
iterator insert(iterator it, char c);//在it处插入字符c,返回插入后迭代器的位置
void insert(iterator it, const_iterator first, const_iterator last);//在it处插入[first,last)之间的字符
void insert(iterator it, int n, char c);//在it处插入n个字符c

string类的删除函数

1
2
3
iterator erase(iterator first, iterator last);//删除[first,last)之间的所有字符,返回删除后迭代器的位置
iterator erase(iterator it);//删除it指向的字符,返回删除后迭代器的位置
string &erase(int pos = 0, int n = npos);//删除pos开始的n个字符,返回修改后的字符串

string类的迭代器处理

string类提供了向前和向后遍历的迭代器iterator,迭代器提供了访问各个字符的语法,类似于指针操作,迭代器不检查范围。用string::iteratorstring::const_iterator声明迭代器变量,const_iterator不允许改变迭代的内容。常用迭代器函数有:

1
2
3
4
5
6
7
8
const_iterator begin()const;
iterator begin(); //返回string的起始位置
const_iterator end()const;
iterator end(); //返回string的最后一个字符后面的位置
const_iterator rbegin()const;
iterator rbegin(); //返回string的最后一个字符的位置
const_iterator rend()const;
iterator rend(); //返回string第一个字符位置的前面

rbegin和rend用于从后向前的迭代访问,通过设置迭代器string::reverse_iterator,string::const_reverse_iterator实现

字符串流处理

通过定义ostringstream和istringstream变量实现,#include <sstream>头文件中。例如:

1
2
3
4
5
6
7
string input("hello,this is a test");
istringstream is(input);
string s1,s2,s3,s4;
is>>s1>>s2>>s3>>s4;//s1="hello,this",s2="is",s3="a",s4="test"
ostringstream os;
os<<s1<<s2<<s3<<s4;
cout<<os.str();

以上就是对C++ string类的一个简要介绍。

string特性描述

可用下列函数来获得string的一些特性:

1
2
3
4
5
6
int capacity()const;    //返回当前容量(即string中不必增加内存即可存放的元素个数)
int max_size()const; //返回string对象中可存放的最大字符串的长度
int size()const; //返回当前字符串的大小
int length()const; //返回当前字符串的长度
bool empty()const; //当前字符串是否为空
void resize(int len,char c); //把字符串当前大小置为len,多去少补,多出的字符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>
#include <string>
using namespace std;
int main()
{
string str;
if (str.empty())
cout<<"str is NULL."<<endl;
else
cout<<"str is not NULL."<<endl;
str = str + "abcdefg";
cout<<"str is "<<str<<endl;
cout<<"str's size is "<<str.size()<<endl;
cout<<"str's capacity is "<<str.capacity()<<endl;
cout<<"str's max size is "<<str.max_size()<<endl;
cout<<"str's length is "<<str.length()<<endl;
str.resize(20,'c');
cout<<"str is "<<str<<endl;
str.resize(5);
cout<<"str is "<<str<<endl;
return 0;
}

string的查找

由于查找是使用最为频繁的功能之一,string提供了非常丰富的查找函数:(注:string::npos)

size_type find( const basic_string &str, size_type index ); //返回str在字符串中第一次出现的位置(从index开始查找),如果没找到则返回string::npos

size_type find( const char *str, size_type index ); // 同上

size_type find( const char *str, size_type index, size_type length ); //返回str在字符串中第一次出现的位置(从index开始查找,长度为length),如果没找到就返回string::npos

size_type find( char ch, size_type index ); // 返回字符ch在字符串中第一次出现的位置(从index开始查找),如果没找到就返回string::npos

注意:查找字符串a是否包含子串b,不是用 strA.find(strB) > 0 而是 strA.find(strB) != string:npos 这是为什么呢?(初学者比较容易犯的一个错误)本部分参考自web100与luhao1993

先看下面的代码

1
2
int idx = str.find("abc");
if (idx == string::npos);

上述代码中,idx的类型被定义为int,这是错误的,即使定义为unsigned int 也是错的,它必须定义为 string::size_type。npos 是这样定义的: static const size_type npos = -1; 因为 string::size_type (由字符串配置器 allocator 定义) 描述的是 size,故需为无符号整数型别。因为缺省配置器以型别 size_t 作为 size_type,于是 -1 被转换为无符号整数型别,npos 也就成了该型别的最大无符号值。不过实际数值还是取决于型别 size_type 的实际定义。不幸的是这些最大值都不相同。事实上,(unsigned long)-1 和 (unsigned short)-1 不同(前提是两者型别大小不同)。因此,比较式 idx == string::npos 中,如果 idx 的值为-1,由于 idx 和字符串string::npos 型别不同,比较结果可能得到 false。因此要想判断 find()等查找函数的结果是否为npos,最好的办法是直接比较。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>
#include<string>
using namespace std;
int main(){
int loc;
string s="study hard and make progress everyday! every day!!";
loc=s.rfind("make",10);
cout<<"the word make is at index"<<loc<<endl;//-1表示没找到
loc=s.rfind("make");//缺省状态下,从最后一个往前找
cout<<"the word make is at index"<<loc<<endl;
loc=s.find_first_of("day");
cout<<"the word day(first) is at index "<<loc<<endl;
loc=s.find_first_not_of("study");
cout<<"the first word not of study is at index"<<loc<<endl;
loc=s.find_last_of("day");
cout<<"the last word of day is at index"<<loc<<endl;
loc=s.find("day");//缺陷状态下从第一个往后找
cout<<loc;
return 0;
}

运行结果:

其他常用函数

  • string &insert(int p,const string &s); //在p位置插入字符串s
  • string &replace(int p, int n,const char *s); //删除从p开始的n个字符,然后在p处插入串s
  • string &erase(int p, int n); //删除p开始的n个字符,返回修改后的字符串
  • string substr(int pos = 0,int n = npos) const; //返回pos开始的n个字符组成的字符串
  • void swap(string &s2); //交换当前字符串与s2的值
  • string &append(const char *s); //把字符串s连接到当前字符串结尾
  • void push_back(char c) //当前字符串尾部加一个字符c
  • const char *data()const; //返回一个非null终止的c字符数组,data():与c_str()类似,用于string转const char*其中它返回的数组是不以空字符终止,
  • const char *c_str()const; //返回一个以null终止的c字符串,即c_str()函数返回一个指向正规C字符串的指针, 内容与本string串相同,用于string转const char*

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>
using namespace std;
int main()
{
string str1 = "abc123defg";
string str2 = "swap!";
cout<<str1<<endl;
cout<<str1.erase(3,3)<<endl; //从索引3开始的3个字符,即删除掉了"123"
cout<<str1.insert(0,"123")<<endl; //在头部插入
cout<<str1.append("123")<<endl; //append()方法可以添加字符串
str1.push_back('A'); //push_back()方法只能添加一个字符
cout<<str1<<endl;
cout<<str1.replace(0,3,"hello")<<endl; //即将索引0开始的3个字符替换成"hello"
cout<<str1.substr(5,7)<<endl; //从索引5开始7个字节
str1.swap(str2);
cout<<str1<<endl;
const char* p = str.c_str();
printf("%s\n",p);
return 0;
}

程序执行结果为:

1
2
3
4
5
6
7
8
9
abc123defg
abcdefg
123abcdefg
123abcdefg123
123abcdefg123A
helloabcdefg123A
abcdefg
swap!
swap!

this指针

this指针的用处

一个对象的this指针并不是对象本身的一部分,不会影响sizeof(对象)的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候,编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。 例如,调用date.SetMonth(9) <==> SetMonth(&date, 9),this帮助完成了这一转换。

this指针的使用

一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用return *this;另外一种情况是当参数与成员变量名相同时,如this->n = n (不能写成n = n)。

this指针程序示例

this指针存在于类的成员函数中,指向被调用函数所在的类实例的地址。根据以下程序来说明this指针

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point   
{   
int x, y;   
public:   
Point(int a, int b) { x=a; y=b;}   
void MovePoint( int a, int b){ x+=a; y+=b;}   
void print(){ cout<<"x="<<x<<"y="<<y<<endl;} <="" font="">  
};   
void main( )   {   
Point point1( 10,10);   
point1.MovePoint(2,2);   
point1.print();   
}

当对象point1调用MovePoint(2,2)函数时,即将point1对象的地址传递给了this指针。

MovePoint函数的原型应该是

1
void MovePoint( Point *this, int a, int b);

第一个参数是指向该类对象的一个指针,我们在定义成员函数时没看见是因为这个参数在类中是隐含的。这样point1的地址传递给了this,所以在MovePoint函数中便显式的写成:
1
void MovePoint(int a, int b) { this->x +=a; this-> y+= b;}

即可以知道,point1调用该函数后,也就是point1的数据成员被调用并更新了值。

即该函数过程可写成

1
point1.x+= a; point1. y + = b;

关于this指针的一个经典回答

当你进入一个房子后,你可以看见桌子、椅子、地板等,但是房子你是看不到全貌了。

对于一个类的实例来说,你可以看到它的成员函数、成员变量,但是实例本身呢?this是一个指针,它时时刻刻指向你这个实例本身

类的this指针有以下特点:

(1)this只能在成员函数中使用。全局函数、静态函数都不能使用this。实际上,成员函数默认第一个参数为T * const this。如:

1
2
3
4
5
6
7
class A
{
public:
int func(int p)
{
}
};

其中,func的原型在编译器看来应该是:
1
int func(A * const this,int p);

(2)由此可见,this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:

1
2
A a;
a.func(10);

此处,编译器将会编译成:
1
A::func(&a,10);

看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高—如VC通常是通过ecx寄存器传递this参数的。

(3)几个this指针的易混问题。

A. this指针是什么时候创建的?

this在成员函数的开始执行前构造,在成员的执行结束后清除。

但是如果class或者struct里面没有方法的话,它们是没有构造函数的,只能当做C的struct使用。采用 TYPE xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。采用new的方式 创建对象的话,在堆里分配内存,new操作符通过eax返回分配 的地址,然后设置给指针变量。之后去调 用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx。

B. this指针存放在何处?堆、栈、全局变量,还是其他?

this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。在汇编级 别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。不是存放在寄存器就是存放在内 存中,它们并不是和高级语言变量对应的。

C. this指针是如何传递类中的函数的?绑定?还是在函数参数的首参数就是this指针?那么,this指针 又是如何找到“类实例后函数的”?

大多数编译器通过ecx寄存器传递this指针。事实上,这也是一个潜规则。一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。

在call之前,编译器会把对应的对象地址放到eax中。this是通过函数参数的首参来传递的。this指针在调用之前生成,至于“类实例后函数”,没有这个说法。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的。

D. this指针是如何访问类中的变量的?

如果不是类,而是结构体的话,那么,如何通过结构指针来访问结构中的变量呢?如果你明白这一点的话,就很容易理解这个问题了。

在C++中 ,类和结构是只有一个区别的:类的成员默认是private,而结构是public。

this是类的指针,如果换成结构,那this就是结构的指针了。

E. 我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗?

this指针只有在成员函数中才有定义。因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。

F. 每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?

普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译器能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。

注意事项

  • this 指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该成员函数的那个对象。
  • 类内定义的静态方法不能指向实例本身,也就是没有this指针
  • 当对一个对象调用成员函数时,编译程序先将对象的地址赋给 this 指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用 this 指针。
  • 当一个成员函数被调用时,自动向它传递一个隐含的参数,该参数是一个指向这个成员函数所在的对象的指针。
  • this 指针被隐含地声明为: ClassName *const this,这意味着不能给 this 指针赋值;在 ClassName 类的 const 成员函数中,this 指针的类型为:const ClassName* const,这说明不能对 this 指针所指向的这种对象是不可修改的(即不能对这种对象的数据成员进行赋值操作);
  • this 并不是一个常规变量,而是个右值,所以不能取得 this 的地址(不能 &this)。
  • 在以下场景中,经常需要显式引用 this 指针:
    • 为实现对象的链式引用;
    • 为避免对同一对象进行赋值操作;
    • 在实现一些数据结构时,如 list

变长参数函数

首先回顾一下较多使用的变长参数函数,最经典的便是printf。

1
extern int printf(const char *format, ...);

以上是一个变长参数的函数声明。我们自己定义一个测试函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdarg.h>
#include <stdio.h>

int testparams(int count, ...)
{
va_list args;
va_start(args, count);
for (int i = 0; i < count; ++i)
{
int arg = va_arg(args, int);
printf("arg %d = %d", i, arg);
}
va_end(args);
return 0;
}

int main()
{
testparams(3, 10, 11, 12);
return 0;
}

变长参数函数的解析,使用到三个宏va_start,va_arg 和va_end,再看va_list的定义typedef char* va_list; 只是一个char指针。

这几个宏如何解析传入的参数呢?

函数的调用,是一个压栈,保存,跳转的过程。简单的流程描述如下:

  1. 把参数从右到左依次压入栈;
  2. 调用call指令,把下一条要执行的指令的地址作为返回地址入栈;(被调用函数执行完后会回到该地址继续执行)
  3. 当前的ebp(基址指针)入栈保存,然后把当前esp(栈顶指针)赋给ebp作为新函数栈帧的基址;
  4. 执行被调用函数,局部变量等入栈;
  5. 返回值放入eax,leave,ebp赋给esp,esp所存的地址赋给ebp;(这里可能需要拷贝临时返回对象)
  6. 从返回地址开始继续执行;(把返回地址所存的地址给eip)

由于开始的时候从右至左把参数压栈,va_start 传入最左侧的参数,往右的参数依次更早被压入栈,因此地址依次递增(栈顶地址最小)。va_arg传入当前需要获得的参数的类型,便可以利用 sizeof 计算偏移量,依次获取后面的参数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
#define _INTSIZEOF(n)          ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))

#define _ADDRESSOF(v) (&const_cast<char&>(reinterpret_cast<const volatile char&>(v)))

#define __crt_va_start_a(ap, v) ((void)(ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v)))
#define __crt_va_arg(ap, t) (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
#define __crt_va_end(ap) ((void)(ap = (va_list)0))

#define __crt_va_start(ap, x) ((void)(__vcrt_va_start_verify_argument_type<decltype(x)>(), __crt_va_start_a(ap, x)))

#define va_start __crt_va_start
#define va_arg __crt_va_arg
#define va_end __crt_va_end

上述宏定义中,_INTSIZEOF(n)将地址的低2位指令,做内存的4字节对齐。每次取参数时,调用__crt_va_arg(ap,t),返回t类型参数地址的值,同时将ap偏移到t之后。最后,调用_crt_va_end(ap)将ap置0.

变长参数的函数的使用及其原理看了宏定义是很好理解的。从上文可知,要使用变长参数函数的参数,我们必须知道传入的每个参数的类型。printf中,有format字符串中的特殊字符组合来解析后面的参数类型。但是当传入类的构造函数的参数时,我们并不知道每个参数都是什么类型,虽然参数能够依次传入函数,但无法解析并获取每个参数的数值。因此传统的变长参数函数并不足以解决传入任意构造函数参数的问题。

变长参数模板

我们需要用到C++11的新特性,变长参数模板。

这里举一个使用自定义内存池的例子。定义一个内存池类MemPool.h,以count个类型T为单元分配内存,默认分配一个对象。每当内存内空闲内存不够,则一次申请MEMPOOL_NEW_SIZE个内存对象。内存池本身只负责内存分配,不做初始化工作,因此不需要传入任何参数,只需实例化模板分配相应类型的内存即可。

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
#ifndef UTIL_MEMPOOL_H
#define UTIL_MEMPOOL_H

#include <stdlib.h>

#define MEMPOOL_NEW_SIZE 8

template<typename T, size_t count = 1>
class MemPool
{
private:
union MemObj {
char _obj[1];
MemObj* _freelink;
};

public:
static void* Allocate()
{
if (!_freelist) {
refill();
}
MemObj* alloc_mem = _freelist;
_freelist = _freelist->_freelink;
++_size;
return (void*)alloc_mem;
}

static void DeAllocate(void* p)
{
MemObj* q = (MemObj*)p;
q->_freelink = _freelist;
_freelist = q;
--_size;
}

static size_t TotalSize() {
return _totalsize;
}

static size_t Size() {
return _size;
}
private:
static void refill()
{
size_t size = sizeof(T) * count;
char* new_mem = (char*)malloc(size * MEMPOOL_NEW_SIZE);
for (int i = 0; i < MEMPOOL_NEW_SIZE; ++i) {
MemObj* free_mem = (MemObj*)(new_mem + i * size);
free_mem->_freelink = _freelist;
_freelist = free_mem;
}
_totalsize += MEMPOOL_NEW_SIZE;
}

static MemObj* _freelist;
static size_t _totalsize;
static size_t _size;
};

template<typename T, size_t count>
typename MemPool<T, count>::MemObj* MemPool<T, count>::_freelist = NULL;

template<typename T, size_t count>
size_t MemPool<T, count>::_totalsize = 0;

template<typename T, size_t count>
size_t MemPool<T, count>::_size = 0;
#endif

接下来在没有变长参数的情况下,实现通用MemNew和MemDelete函数模板。这里不对函数模板作详细解释,用函数模板我们可以对不同的类型实现同样的内存池分配操作。如下:

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
template<class T>
T *MemNew(size_t count)
{
T *p = (T*)MemPool<T, count>::Allocate();
if (p != NULL)
{
if (!std::is_pod<T>::value)
{
for (size_t i = 0; i < count; ++i)
{
new (&p[i]) T();
}
}
}
return p;
}

template<class T>
T *MemDelete(T *p, size_t count)
{
if (p != NULL)
{
if (!std::is_pod<T>::value)
{
for (size_t i = 0; i < count; ++i)
{
p[i].~T();
}
}
MemPool<T, count>::DeAllocate(p);
}
}

上述实现中,使用placement new对申请的内存进行构造,使用了默认构造函数,当申请内存的类型不具备默认构造函数时,placement new将报错。对于pod类型,可以省去调用构造函数的过程。

引入C++11变长模板参数后MemNew修改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<class T, class... Args>
T *MemNew(size_t count, Args&&... args)
{
T *p = (T*)MemPool<T, count>::Allocate();
if (p != NULL)
{
if (!std::is_pod<T>::value)
{
for (size_t i = 0; i < count; ++i)
{
new (&p[i]) T(std::forward<Args>(args)...);
}
}
}
return p;
}

以上函数定义包含了多个特性,后面我将一一解释,其中class… Args 表示变长参数模板,函数参数中Args&& 为右值引用。std::forward 实现参数的完美转发。这样,无论传入的类型具有什么样的构造函数,都能够完美执行placement new。

C++11中引入了变长参数模板的概念,来解决参数个数不确定的模板。

1
2
3
4
5
6
7
8
9
10
template<class... T> class Test {};
Test<> test0;
Test<int> test1;
Test<int,int> test2;
Test<int,int,long> test3;

template<class... T> void test(T... args);
test();
test<int>(0);
test<int,int,long>(0,0,0L);

变长参数函数模板

T… args 为形参包,其中args是模式,形参包中可以有0到任意多个参数。调用函数时,可以传任意多个实参。对于函数定义来说,该如何使用参数包呢?在上文的MemNew中,我们使用std::forward依次将参数包传入构造函数,并不关注每个参数具体是什么。如果需要,我们可以用sizeof…(args)操作获取参数个数,也可以把参数包展开,对每个参数做更多的事。展开的方法有两种,递归函数,逗号表达式。

递归函数方式展开,模板推导的时候,一层层递归展开,最后到没有参数时用定义的一般函数终止。

1
2
3
4
5
6
7
8
9
10
11
12
void test()
{
}

template<class T, class... Args>
void test(T first, Args... args)
{
std::cout << typeid(T).name() << " " << first << std::endl;
test(args...);
}

test<int, int, long>(0, 0, 0L);

output:

1
2
3
int 0
int 0
long 0

逗号表达式方式展开,利用数组的参数初始化列表和逗号表达式,逐一执行print每个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
void print(T arg)
{
std::cout << typeid(T).name() << " " << arg << std::endl;
}

template<class... Args>
void test(Args... args)
{
int arr[] = { (print(args), 0)... };
}

test(0, 0, 0L);

output:
1
2
3
int 0
int 0
long 0

变长参数类模板

变长参数类模板,一般情况下可以方便我们做一些编译期计算。可以通过偏特化和递归推导的方式依次展开模板参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class T, class... Types>
class Test
{
public:
enum {
value = Test<T>::value + Test<Types...>::value,
};
};

template<class T>
class Test<T>
{
public:
enum {
value = sizeof(T),
};
};

Test<int, int, long> test;
std::cout << test.value;

output: 12

右值引用和完美转发

对于变长参数函数模板,需要将形参包展开逐个处理的需求不多,更多的还是像本文的MemNew这样的需求,最终整个传入某个现有的函数。我们把重点放在参数的传递上。

要理解右值引用,需要先说清楚左值和右值。左值是内存中有确定存储地址的对象的表达式的值;右值则是非左值的表达式的值。const左值不可被赋值,临时对象的右值可以被赋值。左值与右值的根本区别在于是否能用&运算符获得内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
int i =0;//i 左值
int *p = &i;// i 左值
int& foo();
foo() = 42;// foo() 左值
int* p1 = &foo();// foo() 左值

int foo1();
int j = 0;
j = foo1();// foo 右值
int k = j + 1;// j + 1 右值
int *p2 = &foo1(); // 错误,无法取右值的地址
j = 1;// 1 右值

理解左值和右值之后,再来看引用,对左值的引用就是左值引用,对右值(纯右值和临终值)的引用就是右值引用。

如下函数foo,传入int类型,返回int类型,这里传入函数的参数0和返回值0都是右值(不能用&取得地址)。于是,未做优化的情况下,传入参数0的时候,我们需要把右值0拷贝给param,函数返回的时候需要将0拷贝给临时对象,临时对象再拷贝给res。当然现在的编译器都做了返回值优化,返回对象是直接创建在返回后的左值上的,这里只用来举个例子

1
2
3
4
5
6
7
int foo(int param)
{
printf("%d", param);
return 0;
}

int res = foo(0);

显然,这里的拷贝都是多余的。可能我们会想要优化,首先将参数int改为int&,传入左值引用,于是0无法传入了,当然我们可以改成const int&,这样终于省去了传参的拷贝。

1
2
3
4
5
int foo(const int& param)
{
printf("%d", param);
return 0;
}

由于const int& 既可以是左值也可以是右值,传入0或者int变量都能够满足。(但是似乎既然有左值引用的int&类型,就应该有对应的传入右值引用的类型int&&)。另外,这里返回的右值0,似乎不通过拷贝就无法赋值给左值res。

于是有了移动语义,把临时对象的内容直接移动给被赋值的左值对象(std::move)。和右值引用,X&&是到数据类型X的右值引用。

1
2
3
4
5
6
7
8
9
int result = 0;
int&& foo(int&& param)
{
printf("%d", param);
return std::move(result);
}

int&& res = foo(0);
int *pres = &res;

将foo改为右值引用参数和返回值,返回右值引用,免去拷贝。这里res是具名引用,运算符右侧的右值引用作为左值,可以取地址。右值引用既有左值性质,也有右值性质。

上述例子还只存在于拷贝的性能问题。回到MemNew这样的函数模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class T>
T* Test(T arg)
{
return new T(arg);
}

template<class T>
T* Test(T& arg)
{
return new T(arg);
}

template<class T>
T* Test(const T& arg)
{
return new T(arg);
}

template<class T>
T* Test(T&& arg)
{
return new T(std::forward<T>(arg));
}

上述的前三种方式传参,第一种首先有拷贝消耗,其次有的参数就是需要修改的左值。第二种方式则无法传常数等右值。第三种方式虽然左值右值都能传,却无法对传入的参数进行修改。第四种方式使用右值引用,可以解决参数完美转发的问题。

std::forward能够根据实参的数据类型,返回相应类型的左值和右值引用,将参数完整不动的传递下去。
解释这个原理涉及到引用塌缩规则

1
2
3
4
T& & ->T&
T& &&->T&
T&& &->T&
T&& &&->T&&

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template< class T > struct remove_reference      {typedef T type;};
template< class T > struct remove_reference<T&> {typedef T type;};
template< class T > struct remove_reference<T&&> {typedef T type;};

template< class T > T&& forward( typename std::remove_reference<T>::type& t )
{
return static_cast<T&&>(t);
}

template<class T>
typename std::remove_reference<T>::type&& move(T&& a) noexcept
{
return static_cast<typename std::remove_reference<T>::type&&>(a);
}

对于函数模板

1
2
3
4
5
template<class T>
T* Test(T&& arg)
{
return new T(std::forward<T>(arg));
}

当传入实参为X类型左值时,T为X&,最后的类型为X&。当实参为X类型右值时,T为X,最后的类型为X&&。

x为左值时:

1
2
X x;
Test(x);

T为X&,实例化后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
X& && std::forward(remove_reference<X&>::type& a) noexcept
{
return static_cast<X& &&>(a);
}

X* Test(X& && arg)
{
return new X(std::forward<X&>(arg));
}

// 塌陷后

X& std::forward(X& a)
{
return static_cast<X&>(a);
}

X* Test(X& arg)
{
return new X(std::forward<X&>(arg));
}

x为右值时:

1
2
X foo();
Test(foo());

T为X,实例化后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
X&& std::forward(remove_reference<X>::type& a) noexcept
{
return static_cast<X&&>(a);
}

X* Test(X&& arg)
{
return new X(std::forward<X>(arg));
}

// 塌陷后

X&& std::forward(X& a)
{
return static_cast<X&&>(a);
}

X* Test(X&& arg)
{
return new X(std::forward<X>(arg));
}

可以看到最终实参总是被推导为和传入时相同的类型引用。

至此,我们讨论了变长参数模板,讨论了右值引用和函数模板的完美转发,完整的解释了MemNew对任意多个参数的构造函数的参数传递过程。利用变长参数函数模板,右值引用和std::forward,可以完成参数的完美转发。

str相关函数

C语言str系列库函数在不同的库中有不同的实现方法,但原理都是一样的。因为库函数都是没有进行入口参数检查的,并且str系列库函数在面试中经常容易被面试官喊在纸上写某一个函数的实现,因此本文参考了OpenBSD和vc++ 8.0库中的代码,结合自己的编程习惯,部分整理如下:

1、strcpy

1
2
3
4
5
6
7
8
9
10
11
12
13
char * strcpy(char *dst, const char *src)  
{
char *d;

if (dst == NULL || src == NULL)
return dst;

d = dst;
while (*d++ = *src++) // while ((*d++ = *src++) != '\0')
;

return dst;
}

2、strncpy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//copy at most n characters of src to dst  
//Pad with '\0' if src fewer than n characters
char *strncpy(char *dst, const char*src, size_t n)
{
char *d;

if (dst == NULL || src == NULL)
return dst;

d = dst;
while (n != 0 && (*d++ = *src++)) /* copy string */
n--;
if (n != 0)
while (--n != 0)
*d++ == '\0'; /* pad out with zeroes */

return dst;
}

注意n是unsigned int,在进行n—操作时特别要小心。如果不小心写成下面这样就会出错:
1
2
3
4
while (n-- != 0 && (*d++ = *src++))  
;
while (n-- != 0)
*d++ = '\0';

第一个while循环中,当n变为0时,仍然会执行n—一,此时n等于经由-1变成的大正数,导致后面对n的使用出错。

3、strcat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char *strcat(char *dst, const char *src)  
{
char *d;
if (dst == NULL || src == NULL)
return dst;

d = dst;
while (*d)
d++;
//while (*d++ != 0);
//d--;

while (*d++ = *src++)
;

return dst;
}

4、strncat
写法1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//concatenate at most n characters of src to the end of dst  
//terminates dst with '\0'
char *strncat(char *dst, const char *src, size_t n)
{
if (NULL == dst || NULL == src)
return dst;

if (n != 0)
{
char *d = dst;
do
{
if ((*d = *src++) == '\0' )
return dst; //break
d++;
} while (--n != 0);
*d = '\0';
}

return dst;
}

写法2:
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
char *strncat(char *dst, const char *src, size_t n)  
{
char *d;

if (dst == NULL || src == NULL)
return dst;

d = dst;
while (*d)
d++;
//(1)
while (n != 0)
{
if ((*d++ = *src++) == '\0')
return dst;
n--;
}

//(2)
//while (n--) //这种方式写最后n的值不为0,不过这个n后面不会再被使用
// if ((*d++ == *src++) == '\0')
// return dst;

*d = '\0';

return dst;
}

5、strcmp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int strcmp(const char *s1, const char *s2)  
{
if (s1 == NULL || s2 == NULL)
return 0;
//(1)
//while (*s1 == *s2++)
// if (*s1++ == '\0')
// return 0;

//(2)
for (; *s1 == *s2; s1++, s2++)
if (*s1 == '\0')
return 0;
return *(unsigned char*)s1 - *(unsigned char*)s2;
}

6、strncmp
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
int strncmp(const char *s1, const char *s2, size_t n)  
{
if (s1 == NULL || s2 == NULL)
return 0;

if (n == 0)
return 0;
do
{
if (*s1 != *s2++)
return *(unsigned char*)s1 - *(unsigned char*)--s2;
if (*s1++ == '\0')
break;
} while (--n != 0);

//do
//{
// if (*s1 != *s2)
// return *(unsigned char*)s1 - *(unsigned char*)s2;
// if (*s1 == '\0')
// break;
// s1++;
// s2++;
//} while (--n != 0);

return 0;
}

7、strstr
写法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
//return pointer to first  occurrence of find in s  
//or NULL if not present
char *strstr(const char *s, const char *find)
{
char *cp = (char*)s;
char *s1, *s2;

if (s == NULL || find == NULL)
return NULL;

while (*cp != '\0')
{
s1 = cp;
s2 = (char*)find;

while (*s1 && *s2 && *s1 == *s2)
s1++, s2++;

if(*s2 == '\0')
return cp;

cp++;
}
return NULL;
}

写法2:参照简单模式匹配算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
char *strstr(const char *s, const char *find)  
{
int i = 0, j = 0;
while (*(s + i) != '\0' && *(find + j) != '\0')
{
if (*(s + i + j) == *(find + j))
j++; //继续比较后一字符
else
{
i++; //开始新一轮比较
j = 0;
}
}

return *(find + j) == '\0' ? (char*)(s + i) : NULL;
}

8、strchr

1
2
3
4
5
6
7
8
9
10
11
//return pointer to first occurrence of ch in str  
//NULL if not present
char *strchr(const char*str, int ch)
{
while (*str != '\0' && *str != (char)ch)
str++;

if(*str == (char)ch)
return (char*)str;
return NULL;
}

9、strrchr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//return pointer to last occurrence of ch in str  
//NULL if not present
char *strrchr(const char *str, int ch)
{
if (str == NULL)
return NULL;

char *s = (char*)str;

while (*s++)
; /* find end of string */

while (--s != str && *s != (char)ch)
; /* search towards front */

if(*s == (char)ch)
return (char*)s;
return NULL;
}

10、strlen

1
2
3
4
5
6
7
8
9
10
size_t strlen(const char *str)  
{
if (str == NULL)
return 0;

const char *eos = str;
while (*eos++)
;
return (eos - 1 - str);
}

RVO

函数如何返回值

函数返回值的传递分为两种情况。

当返回的对象的大小不超过8字节时,通过寄存器(eax edx)返回。

当返回的对象的大小大于8字节时,通过栈返回。但是,如果返回struct/class对象,尽管其大小不大于8字节,也是通过栈返回的。

在通过栈返回的时候,栈上会有一块空间来保存函数的返回值。当函数结束的时候,会把要返回的对象拷贝到这块区域,对于内置类型是直接拷贝,类类型的话是调用copy ctor。这块区域又称为函数返回的临时对象(temporary object)。

下面用代码看一下是不是这样。

首先,编写Base类和func()函数。

1
2
3
4
5
6
7
8
9
10
11
12
struct Base{
Base() { cout << "default ctor" << endl; };
Base(const Base& b) { cout << "copy ctor " << endl; }
Base& operator=(const Base& b){ cout << "operator=" << endl; a = b.a; return *this;}
~Base(){cout << "dtor " << endl;};
int a = 0;
};

Base func(){
Base a;
return a;
}

调用函数:(为了确保临时对象的存在,我绑定一个const引用到它上面;其实不绑定的话,直接func();也会有临时对象的存在)
1
const Base &r = func();

输出
1
2
default ctor
dtor

按理说,存在临时对象,输出应该是
1
2
3
4
default ctor
copy ctor
dtor
dtor

因为这里C++做了返回值优化(RVO)。RVO是一种编译器优化的技术,它把要返回的局部变量直接构造在临时对象所在的区域,达到少调用一次copy ctor的目的。

为了避免RVO,把func()重新编写。这样编译器不清楚哪个局部变量会被返回,所以就避免了返回值优化。

1
2
3
4
5
6
7
8
9
10
Base func(int i){
if(i > 0) {
Base a;
return a;
}
else{
Base b;
return b;
}
}

调用func:
1
func(0);

输出
1
2
3
4
default ctor // 函数内的局部对象
copy ctor //局部对象->临时对象
dtor // 局部对象析构
dtor // 临时对象析构

结果符合预期。

如果这样调用:

1
Base a = func(0);

输出:
1
2
3
4
default ctor // 函数内的局部对象
copy ctor // ?
dtor // 局部对象析构
dtor // ?

为何是这样?不应该是还有一次临时对象到a的copy ctor和a的dtor吗?
这里我猜测进行了另外的优化,将两者合并到了一起,也就是把a的存储区域作为临时对象的区域。

下面这样调用:

1
2
3
Base a = func(0);
cout << endl;
a = func(0);

输出是:
1
2
3
4
5
6
7
8
9
10
default ctor // func的局部对象
copy ctor // func的局部对象->临时对象
dtor // func的局部对象析构

default ctor // func的局部对象
copy ctor // func的局部对象->临时对象(也就是a)
dtor // func的局部对象析构
operator= // 临时对象->a
dtor // 临时对象析构
dtor // a析构

输出十分合理!

RVO,是Return Value Optimization。这是在函数返回返回值的时候编译器所做出的优化,是C++11标准的一部分,C++11称之为copy elision。

在第一次编写的func里面,编译器明确知道函数会返回哪一个局部对象,那么编译器会把存储这个局部对象的地址和存储返回值临时对象的地址进行复用,也就是说避免了从局部对象到临时对象的拷贝操作。这就是RVO。

现在把func重新改为:

1
2
3
4
Base func(){
Base b;
return b;
}

以下面三种方式调用func。
1
2
3
4
5
func();
cout << endl;
Base a = func();
cout << endl;
a = func();

输出
1
2
3
4
5
6
7
8
9
default ctor // 局部对象b(也是临时对象)的构造
dtor

default ctor // 局部对象b(也是临时对象,也是要初始化的对象a)的构造

default ctor // 局部对象b(也是临时对象)的构造
operator= // 局部对象b(也是临时对象)-> 对象a
dtor // 局部对象b
dtor // 对象a

输出十分合理!

std::move()

在查阅RVO的资料的时候,看到了这篇博客RVO V.S. std::move,讲的特别好。除了RVO里面还提到了std:move(),为了加深对std::move的理解,我又做了下面几个实验。

重新编写func:

1
2
3
4
Base func(){
Base b;
return std::move(b);
}

然后向Base添加下面的成员:
1
2
Base& operator=(Base&& b){  cout << "move operator=" << endl; a = b.a; return *this;}
Base(Base&& b) { cout << "move ctor" << endl;}

调用:
1
2
3
4
5
func();
cout << endl;
Base a = func();
cout << endl;
a = func();

输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
default ctor // 局部对象b
move ctor // 局部对象b向临时对象的移动
dtor
dtor

default ctor // 局部对象b
move ctor // 局部对象b向临时对象(也是要初始化的对象a)的移动
dtor

default ctor // 局部对象b
move ctor // 局部对象b向临时对象的移动
dtor // 局部对象b析构
move operator= // 临时对象到a的移动,临时对象是右值,所以用move
dtor // 临时对象析构
dtor

func的函数返回类型仍然是Base,而不是Base&&。这意味着函数还是会创建一个Base类的临时对象,只是临时对象是通过右值引用得到的,也就是说通过移动构造函数移动得到的。

把func的返回类型改为Base&&:

1
2
3
4
Base&& func(){
Base b;
return std::move(b);
}

还是调用,
1
2
3
4
5
func();
cout << endl;
Base a = func();
cout << endl;
a = func();

输出:
1
2
3
4
5
6
7
8
9
10
11
default ctor // 局部对象
dtor

default ctor // 局部对象
dtor // 局部对象
move ctor // 局部对象到a的移动(注意:因为这里局部对象已经析构,所以这里的行为是undefined,十分危险)

default ctor // 局部对象
dtor // 局部对象
move operator= // 局部对象到a的移动
dtor

总结:

  • 函数的返回类型是类类型,return局部对象,可能会有RVO;
  • 函数的返回类型是类类型,return右值引用,肯定不会有RVO;
  • 函数的返回类型是右值引用,return右值引用,没有临时对象的消耗,但是仍不可取,因为右值引用的对对象在使用前已经析构了。

cpp的tr1_function使用

介绍

function是一种通用、多态的函数封装。std::function的实例可以对任何可以调用的目标 进行存储、复制、和调用操作,这些目标包括函数、lambda表达式、绑定表达式、以及其它函数对象等。(c++11起的版本可用)

function(和bind一样)可以实现类似函数指针的功能,却比函数指针更加灵活(体现在占位符上面),尤其是在很多成员调用同一个函数(仅仅是参数类型不同)的时候比较方便。

  1. 可以作为函数和成员函数。
  2. 可做回调函数,取代函数指针。
  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
33
34
#include <iostream>
#include <tr1/functional>

typedef std::tr1::function<void()> HandleEvent;

class Animal{
public:
Animal(){}
~Animal(){}

static void Move(){
std::cout<<"I am moving...\n";
}
};

class Fish: public Animal{
public:
Fish(){}
~Fish(){}

static void Move(){
std::cout<<"I am swimming...\n";
}
};

int main(){

std::tr1::function<void()> move = &Animal::Move;
move();

move = &Fish::Move;
move();
return 0;
}

Animal类是父类,Fish继承于Animal。测试程序中分别将子类和父类的Move()函数地址赋值给function的指针。调用的结果如下:
  

1
2
I am moving… 
I am swimming…

为了体现function可以作为函数的参数传入,我们再写一个函数加到原来的代码中进行测试:
  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Moving(int option, std::tr1::function<void()> move){
if(option & 1 == 0){ //如果option为偶数,则执行Animal类中的Move方法
move = &Animal::Move;
}
else{
move = &Fish::Move;
}

move();
}
int main(){

std::tr1::function<void()> move = &Animal::Move;
move();

move = &Fish::Move;
move();

std::cout<<"-------------divid line------------\n";
Moving(4,move);

return 0;
}

测试结果如下:
  

1
2
3
4
I am moving… 
I am swimming…
————-divid line————
I am moving…

C++函数调用分析

这里以一个简单的C语言代码为例,来分析函数调用过程

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int func(int param1 ,int param2,int param3)
{
int var1 = param1;
int var2 = param2;
int var3 = param3;

printf("var1=%d,var2=%d,var3=%d",var1,var2,var3);
return var1;
}

int main(int argc, char* argv[])
{
int result = func(1,2,3);

return 0;
}

首先说明,在堆栈中变量分布是从高地址到低地址分布,EBP是指向栈底的指针,在过程调用中不变,又称为帧指针。ESP指向栈顶,程序执行时移动,ESP减小分配空间,ESP增大释放空间,ESP又称为栈指针。

下面来逐步分析函数的调用过程

函数main执行,main各个参数从右向左逐步压入栈中,最后压入返回地址

执行第15行,3个参数以从左向右的顺序压入堆栈,及从param3到param1,栈内分布如下图:

然后是返回地址入栈:此时的栈内分布如下:

第3行函数调用时,通过跳转指令进入函数后,函数地址入栈后,EBP入栈,然后把当前ESP的值给EBP,对应的汇编指令:

1
2
push ebp
mov ebp esp

此时栈顶和栈底指向同一位置,栈内分布如下:

第5行开始执行, int var1 = param1; int var2 = param2; int var3 = param3;按申明顺序依次存储。对应的汇编:

1
2
mov 0x8(%ebp),%eax
mov %eax,-0x4(%ebp)

其中将[EBP+0x8]地址里的内容赋给EAX,即把param的值赋给EAX,然后把EAX的中的值放到[EBP-4]这个地址里,即把EAX值赋给var1,完成C代码 int var1 = param1,其他变量雷同。

第9行,输出结果,第10行执行 对应的汇编代码:

1
mov  -0x4(%ebp),%eax

最后通过eax寄存器保存函数的返回值;

调用执行函数完毕,局部变量var3,var2,var1一次出栈,EBP恢复原值,返回地址出栈,找到原执行地址,param1,param2,param3依次出栈,函数调用执行完毕。图略

深入理解C++的动态绑定和静态绑定

为了支持c++的多态性,才用了动态绑定和静态绑定。理解他们的区别有助于更好的理解多态性,以及在编程的过程中避免犯错误。需要理解四个名词:

  • 对象的静态类型:对象在声明时采用的类型。是在编译期确定的。
  • 对象的动态类型:目前所指对象的类型。是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。

关于对象的静态类型和动态类型,看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class B
{
}
class C : public B
{
}
class D : public B
{
}
D* pD = new D();//pD的静态类型是它声明的类型D*,动态类型也是D*
B* pB = pD;//pB的静态类型是它声明的类型B*,动态类型是pB所指向的对象pD的类型D*
C* pC = new C();
pB = pC;//pB的动态类型是可以更改的,现在它的动态类型是C*

  • 静态绑定:绑定的是对象的静态类型,某特性(比如函数)依赖于对象的静态类型,发生在编译期。
  • 动态绑定:绑定的是对象的动态类型,某特性(比如函数)依赖于对象的动态类型,发生在运行期。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class B
{
void DoSomething();
virtual void vfun();
}
class C : public B
{
void DoSomething();//首先说明一下,这个子类重新定义了父类的no-virtual函数,这是一个不好的设计,会导致名称遮掩;这里只是为了说明动态绑定和静态绑定才这样使用。
virtual void vfun();
}
class D : public B
{
void DoSomething();
virtual void vfun();
}
D* pD = new D();
B* pB = pD;

让我们看一下,pD->DoSomething()pB->DoSomething()调用的是同一个函数吗?

不是的,虽然pD和pB都指向同一个对象。因为函数DoSomething是一个no-virtual函数,它是静态绑定的,也就是编译器会在编译期根据对象的静态类型来选择函数。pD的静态类型是D,那么编译器在处理pD->DoSomething()的时候会将它指向D::DoSomething()。同理,pB的静态类型是B,那pB->DoSomething()调用的就是B::DoSomething()

让我们再来看一下,pD->vfun()pB->vfun()调用的是同一个函数吗?

是的。因为vfun是一个虚函数,它动态绑定的,也就是说它绑定的是对象的动态类型,pB和pD虽然静态类型不同,但是他们同时指向一个对象,他们的动态类型是相同的,都是D*,所以,他们的调用的是同一个函数:D::vfun()

上面都是针对对象指针的情况,对于引用(reference)的情况同样适用。

指针和引用的动态类型和静态类型可能会不一致,但是对象的动态类型和静态类型是一致的。

1
2
D D;
D.DoSomething()和D.vfun()永远调用的都是D::DoSomething()和D::vfun()。

我总结了一句话:只有虚函数才使用的是动态绑定,其他的全部是静态绑定。目前我还没有发现不适用这句话的,如果有错误,希望你可以指出来。

特别需要注意的地方

当缺省参数和虚函数一起出现的时候情况有点复杂,极易出错。我们知道,虚函数是动态绑定的,但是为了执行效率,缺省参数是静态绑定的。

1
2
3
4
5
6
7
8
9
10
11
12
13
class B
{
virtual void vfun(int i = 10);
}
class D : public B
{
virtual void vfun(int i = 20);
}
D* pD = new D();
B* pB = pD;
pD->vfun();
pB->vfun();

有上面的分析可知pD->vfun()pB->vfun()调用都是函数D::vfun(),但是他们的缺省参数是多少?

分析一下,缺省参数是静态绑定的,pD->vfun()时,pD的静态类型是D*,所以它的缺省参数应该是20;同理,pB->vfun()的缺省参数应该是10。编写代码验证了一下,正确。

对于这个特性,估计没有人会喜欢。所以,永远记住:

绝不重新定义继承而来的缺省参数(Never redefine function’s inherited default parameters value.)

mem函数的类型及用法

memccpy函数原型:

1
void   *memccpy(void   *dest,   const   void   *src,   int   c,   size_t   n) 

函数功能:字符串拷贝,到指定长度或遇到指定字符时停止拷贝

参数说明: src-源字符串指针,c-中止拷贝检查字符,n-长度,dest-拷贝底目的字符串指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include   <string.h>; 
#include <stdio.h>;
int main()
{
char *src= "This is the source string ";
char dest[50];
char *ptr;
ptr=memccpy(dest,src, 'c ',strlen(src));
if (ptr)
{
*ptr= '\0 ';
printf( "The character was found:%s ",dest);
}
else
printf( "The character wasn 't found ");
return 0;
}

memchr函数原型:

1
void   *memchr(const   void   *s,   int   c,   size_t   n) 

在字符串中第开始n个字符中寻找某个字符c的位置

函数返回: 返回c的位置指针,返回NULL时表示未找到

参数说明: s-要搜索的字符串,c-要寻找的字符,n-指定长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include   <string.h>; 
#include <stdio.h>;
int main()
{
char str[17];
char *ptr;
strcpy(str, "This is a string ");
ptr=memchr(str, 'r ',strlen(str));
if(ptr)
printf( "The character 'r ' is at position:%d ",ptr-str);
else
printf( "The character was not found ");
return 0;
}

memcmp函数原型:

1
int   memcmp(const   void   *s1,   const   void   *s2,   size_t   n) 

函数功能: 按字典顺序对字符串s1,s2比较,并只比较前n个字符

函数返回: 返回数值表示比较结果

参数说明: s1,s2-要比较的字符串,n-比较的长度

1
2
3
4
5
6
7
8
9
10
11
#include   <stdio.h>
#include <string.h>
int main()
{
auto char buffer[80];
strcpy(buffer, "world ");
if( memcmp(buffer, "would ",6)<0){
printf( "Less than\n ");
}
return 0;
}

memicmp函数原型:

1
int   memicmp(const   void   *s1,   const   void   *s2,   size_t   n) 

函数功能: 按字典顺序、不考虑字母大小写对字符串s1,s2比较,并只比较前n个字符

函数返回: 返回数值表示比较结果

参数说明: s1,s2-要比较的字符串,n-比较的长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include   <stdio.h>
#include <string.h>
int main()
{
char *buf1 = "ABCDE123 ";
char *buf2 = "abcde456 ";
int stat;
stat = memicmp(buf1, buf2, 5);
printf( "The strings to position 5 are ");
if(stat)
printf( "not ");
printf( "the same ");
return 0;
}

memcpy函数原型:

1
void   *memcpy(void   *dest,   const   void   *src,   size_t   n) 

函数功能: 字符串拷贝

函数返回: 指向dest的指针

参数说明: src-源字符串,n-拷贝的最大长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include   <stdio.h>
#include <string.h>
int main()
{
char src[] = "****************************** ";
char dest[] = "abcdefghijlkmnopqrstuvwxyz0123456709 ";
char *ptr;
printf( "destination before memcpy: %s ",dest);
ptr=memcpy(dest,src,strlen(src));
if(ptr)
printf( "destination after memcpy:%s ",dest);
else
printf( "memcpy failed ");
return 0;
}

memmove函数原型:

1
void   *memmove(void   *dest,   const   void   *src,   size_t   n)

函数功能: 字符串拷贝

函数返回: 指向dest的指针

参数说明: src-源字符串,n-拷贝的最大长度

1
2
3
4
5
6
7
8
9
10
#include   <string.h>
#include <stdio.h>
int main()
{
char dest[40]= "abcdefghijklmnopqrstuvwxyz0123456789 ";
printf( "destination prior to memmove:%s\n ",dest);
memmove(dest+1,dest,35);
printf( "destination after memmove:%s ",dest);
return 0;
}

memset函数原型:

1
void   *memset(void   *s,   int   c,   size_t   n) 

函数功能: 字符串中的n个字节内容设置为c

参数说明: s-要设置的字符串,c-设置的内容,n-长度

1
2
3
4
5
6
7
8
9
10
11
#include   <string.h>
#include <stdio.h>
#include <mem.h>
int main()
{
char buffer[] = "Hello world ";
printf( "Buffer before memset:%s ",buffer);
memset(buffer, '* ',strlen(buffer)-1);
printf( "Buffer after memset:%s ",buffer);
return 0;
}

函数的重载、隐藏和覆盖

函数重载只会发生在同一个类中,函数名相同,只能通过参数类型,参数个数或者有无const来区分。不能通过返回值类型区分,而且virtual也是可有可无的,即虚函数和普通函数在同一类中也可以构成函数重载。

基类和派生类中只能是隐藏或者覆盖。

  1. 隐藏是指派生类中有函数与基类中函数同名,但是没有构成虚函数覆盖,就是隐藏。隐藏的表现:若基类中函数func()被派生类中函数func()隐藏,那么无法通过派生类对象访问基类中的func() 函数,派生类对象只能访问到派生类中的func()函数。不过基类中的func()确实继承到了派生类中。
  2. 虚函数也只是在基类和派生类中发挥多态的作用,而在同一类中虚函数也可以重载。

虚函数实现多态的条件:

  • 基类中将这些成员声明为virtual。
  • 基类和派生类中的这些函数必须同名且参数类型,参数个数,返回值类型必须相同。
  • 将派生类的对象赋给基类指针或者引用,实现多态。

缺少任何一条,只会是基类和派生类之间的隐藏,而不是覆盖

如何判断基类和派生类中函数是否是隐藏?当基类和派生类存在同名函数,不论参数类型,参数个数是否相同,派生类中的同名函数都会将基类中的同名函数隐藏掉。

  • 基类和派生类都是虚函数,并且同名,但是形参类型或者形参个数不同,多态不满足,但是构成了隐藏,只是没有虚特性。
  • 基类中不是虚函数,派生类中定义为虚函数,不构成多态,只是隐藏关系。
  • 基类和派生类的两个函数同名,都是虚函数,形参的个数和类型也都相同,但是返回值类型不同,这时编译会报错,因为两个虚函数在隐藏时,返回值类型发生了冲突,因此隐藏发生错误。注意,如果这两个函数不是虚函数,这不会报错,隐藏会成功;同时,如果派生类中是虚函数,基类中不是虚函数,也不过报错,隐藏也是成功的。但是如果基类中为虚函数,派生类中不是,也会报错。这些说明,虚化并隐藏时,返回值类型一定要保持相同。

虚函数要求返回值类型也一样,但是有一种情况允许虚函数返回值时本类对象的引用或者指针,也可以构成覆盖。这个是“协变”规则,具体协变看例子:

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
class A:
{
public:
virtual A* func()
{
cout<<"A"<<endl;
return this;
}
};
class B:public A
{
public:
virtual B* func()
{
cout<<"B"<<endl;
return this;
}
};
int main()
{
A *pa=new B;
B* pb=pa->func();//编译无法通过,因为pa是A*类型指针,编译时,对于pa->func()翻译成调用的是A类的函数,返回值为 A*类型。而A*类型无法赋值给派生类指针
B* pb=(B*)pa->func();//正确
B* pb=(B*)(pa->func());//正确
}

A *pa=new B;对于虚函数将基类指针指向派生类对象,调用派生类的虚函数。该基类指针能解引用的内存空间是继承到派生类中的基类的内存空间。基类指针调用派生类的虚函数,在虚函数中,this指针指向的是派生类本身,也就是在虚函数中将基类指针强制转换成了派生类指针。其实基类指针pa和派生类中的this指针值相同,都是派生类对象的地址。

协变的存在是为了解决返回值的强制类型转换,真正用途是,通过派生类对象调用虚函数,直接返回派生类指针。若无协变,则会返回基类指针,需要再将基类指针强制转换成派生类指针。具体的意思看例子:

若没有协变,那么上述的代码中派生类中虚函数需要改成以下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class B :public A
{
public:
virtual A* func()
{
cout<<"B"<<endl;
return this;//返回值this为B*类型指针,但是因为没有协变,返回的时候将B*类型赋值给了A*类型,然后以A*类型返回到main函数中
}
};
int main()
{
B b;
A *pa=b.func();
B *pb=dynamic<B*> (pa);//将返回的A*类型强制转换成B*类型
}

编译器总是根据类型来调用类成员函数。但是一个派生类的指针可以安全地转化为一个基类的指针。这样删除一个基类的指针的时候,C++不管这个指针指向一个基类对象还是一个派生类的对象,调用的都是基类的析构函数而不是派生类的。如果你依赖于派生类的析构函数的代码来释放资源,而没有重载析构函数,那么会有资源泄漏。所以建议的方式是将析构函数声明为虚函数

也就是delete a的时候,也会执行派生类的析构函数。

一个函数一旦声明为虚函数,那么不管你是否加上virtual修饰符,它在所有派生类中都成为虚函数。但是由于理解明确起见,建议的方式还是加上virtual修饰符。

构造方法用来初始化类的对象,与父类的其它成员不同,它不能被子类继承(子类可以继承父类所有的成员变量和成员方法,但不继承父类的构造方法)。因此,在创建子类对象时,为了初始化从父类继承来的数据成员,系统需要调用其父类的构造方法。

如果没有显式的构造函数,编译器会给一个默认的构造函数,并且该默认的构造函数仅仅在没有显式地声明构造函数情况下创建。

构造原则如下:

  1. 如果子类没有定义构造方法,则调用父类的无参数的构造方法。
  2. 如果子类定义了构造方法,不论是无参数还是带参数,在创建子类的对象的时候,首先执行父类无参数的构造方法,然后执行自己的构造方法。
  3. 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数,则会调用父类的默认无参构造函数。
  4. 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数且父类自己提供了无参构造函数,则会调用父类自己的无参构造函数。
  5. 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数且父类只定义了自己的有参构造函数,则会出错(如果父类只有有参数的构造方法,则子类必须显示调用此带参构造方法)。
  6. 如果子类调用父类带参数的构造方法,需要用初始化父类成员对象的方式,比如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream.h>
class animal
{
public:
animal(int height, int weight)
{
cout<<"animal construct"<<endl;
}
};
class fish:public animal
{
public:
int a;
fish() : animal(400,300), a(1)
{
cout<<"fish construct"<<endl;
}
};
void main()
{
fish fh;
}

强制类型转换运算符

将类型名作为强制类型转换运算符的做法是C语言的老式做法,C++ 为保持兼容而予以保留。

C++ 引入了四种功能不同的强制类型转换运算符以进行强制类型转换:static_castreinterpret_castconst_castdynamic_cast

强制类型转换是有一定风险的,有的转换并不一定安全,如把整型数值转换成指针,把基类指针转换成派生类指针,把一种函数指针转换成另一种函数指针,把常量指针转换成非常量指针等。C++ 引入新的强制类型转换机制,主要是为了克服C语言强制类型转换的以下三个缺点。

  • 没有从形式上体现转换功能和风险的不同。例如,将 int 强制转换成 double 是没有风险的,而将常量指针转换成非常量指针,将基类指针转换成派生类指针都是高风险的,而且后两者带来的风险不同(即可能引发不同种类的错误),C语言的强制类型转换形式对这些不同并不加以区分。
  • 将多态基类指针转换成派生类指针时不检查安全性,即无法判断转换后的指针是否确实指向一个派生类对象。
  • 难以在程序中寻找到底什么地方进行了强制类型转换。

强制类型转换是引发程序运行时错误的一个原因,因此在程序出错时,可能就会想到是不是有哪些强制类型转换出了问题。

如果采用C语言的老式做法,要在程序中找出所有进行了强制类型转换的地方,显然是很麻烦的,因为这些转换没有统一的格式。

而用 C++ 的方式,则只需要查找_cast字符串就可以了。甚至可以根据错误的类型,有针对性地专门查找某一种强制类型转换。例如,怀疑一个错误可能是由于使用了 reinterpret_cast 导致的,就可以只查找reinterpret_cast字符串。

C++ 强制类型转换运算符的用法如下:

1
强制类型转换运算符 <要转换到的类型> (待转换的表达式)

例如:
1
double d = static_cast <double> (3*5);  //将 3*5 的值转换成实数

下面分别介绍四种强制类型转换运算符。

static_cast

static_cast用于进行比较“自然”和低风险的转换,如整型和浮点型、字符型之间的互相转换。另外,如果对象所属的类重载了强制类型转换运算符 T(如 T 是 int、int* 或其他类型名),则 static_cast 也能用来进行对象到 T 类型的转换。

static_cast 不能用于在不同类型的指针之间互相转换,也不能用于整型和指针之间的互相转换,当然也不能用于不同类型的引用之间的转换。因为这些属于风险比较高的转换。

static_cast 用法示例如下:

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;
class A
{
public:
operator int() { return 1; }
operator char*() { return NULL; }
};
int main()
{
A a;
int n;
char* p = "New Dragon Inn";
n = static_cast <int> (3.14); // n 的值变为 3
n = static_cast <int> (a); //调用 a.operator int,n 的值变为 1
p = static_cast <char*> (a); //调用 a.operator char*,p 的值变为 NULL
n = static_cast <int> (p); //编译错误,static_cast不能将指针转换成整型
p = static_cast <char*> (n); //编译错误,static_cast 不能将整型转换成指针
return 0;
}

reinterpret_cast

reinterpret_cast 用于进行各种不同类型的指针之间、不同类型的引用之间以及指针和能容纳指针的整数类型之间的转换。转换时,执行的是逐个比特复制的操作。

这种转换提供了很强的灵活性,但转换的安全性只能由程序员的细心来保证了。例如,程序员执意要把一个 int 指针、函数指针或其他类型的指针转换成 string 类型的指针也是可以的,至于以后用转换后的指针调用 string 类的成员函数引发错误,程序员也只能自行承担查找错误的烦琐工作:(C++ 标准不允许将函数指针转换成对象指针,但有些编译器,如 Visual Studio 2010,则支持这种转换)。

reinterpret_cast 用法示例如下:

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
#include <iostream>
using namespace std;
class A
{
public:
int i;
int j;
A(int n):i(n),j(n) { }
};
int main()
{
A a(100);
int &r = reinterpret_cast<int&>(a); //强行让 r 引用 a
r = 200; //把 a.i 变成了 200
cout << a.i << "," << a.j << endl; // 输出 200,100
int n = 300;
A *pa = reinterpret_cast<A*> ( & n); //强行让 pa 指向 n
pa->i = 400; // n 变成 400
pa->j = 500; //此条语句不安全,很可能导致程序崩溃
cout << n << endl; // 输出 400
long long la = 0x12345678abcdLL;
pa = reinterpret_cast<A*>(la); //la太长,只取低32位0x5678abcd拷贝给pa
unsigned int u = reinterpret_cast<unsigned int>(pa);//pa逐个比特拷贝到u
cout << hex << u << endl; //输出 5678abcd
typedef void (* PF1) (int);
typedef int (* PF2) (int,char *);
PF1 pf1; PF2 pf2;
pf2 = reinterpret_cast<PF2>(pf1); //两个不同类型的函数指针之间可以互相转换
}

程序的输出结果是:

1
2
3
200, 100
400
5678abed

第 19 行的代码不安全,因为在编译器看来,pa->j 的存放位置就是 n 后面的 4 个字节。 本条语句会向这 4 个字节中写入 500。但这 4 个字节不知道是用来存放什么的,贸然向其中写入可能会导致程序错误甚至崩溃。

上面程序中的各种转换都没有实际意义,只是为了演示 reinteipret_cast 的用法而已。在编写黑客程序、病毒或反病毒程序时,也许会用到这样怪异的转换。

reinterpret_cast体现了 C++ 语言的设计思想:用户可以做任何操作,但要为自己的行为负责。

const_cast

const_cast 运算符仅用于进行去除 const 属性的转换,它也是四个强制类型转换运算符中唯一能够去除 const 属性的运算符。

将 const 引用转换为同类型的非 const 引用,将 const 指针转换为同类型的非 const 指针时可以使用 const_cast 运算符。例如:

1
2
3
const string s = "Inception";
string& p = const_cast <string&> (s);
string* ps = const_cast <string*> (&s); // &s 的类型是 const string*

dynamic_cast

用 reinterpret_cast 可以将多态基类(包含虚函数的基类)的指针强制转换为派生类的指针,但是这种转换不检查安全性,即不检查转换后的指针是否确实指向一个派生类对象。dynamic_cast专门用于将多态基类的指针或引用强制转换为派生类的指针或引用,而且能够检查转换的安全性。对于不安全的指针转换,转换结果返回 NULL 指针。

dynamic_cast 是通过“运行时类型检查”来保证安全性的。dynamic_cast 不能用于将非多态基类的指针或引用强制转换为派生类的指针或引用——这种转换没法保证安全性,只好用 reinterpret_cast 来完成。

dynamic_cast 示例程序如下:

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
#include <iostream>
#include <string>
using namespace std;
class Base
{ //有虚函数,因此是多态基类
public:
virtual ~Base() {}
};
class Derived : public Base { };
int main()
{
Base b;
Derived d;
Derived* pd;
pd = reinterpret_cast <Derived*> (&b);
if (pd == NULL)
//此处pd不会为 NULL。reinterpret_cast不检查安全性,总是进行转换
cout << "unsafe reinterpret_cast" << endl; //不会执行
pd = dynamic_cast <Derived*> (&b);
if (pd == NULL) //结果会是NULL,因为 &b 不指向派生类对象,此转换不安全
cout << "unsafe dynamic_cast1" << endl; //会执行
pd = dynamic_cast <Derived*> (&d); //安全的转换
if (pd == NULL) //此处 pd 不会为 NULL
cout << "unsafe dynamic_cast2" << endl; //不会执行
return 0;
}

程序的输出结果是:
1
unsafe dynamic_cast1

第 20 行,通过判断 pd 的值是否为 NULL,就能知道第 19 行进行的转换是否是安全的。第 23 行同理。

如果上面的程序中出现了下面的语句:

1
Derived & r = dynamic_cast <Derived &> (b);

那该如何判断该转换是否安全呢?不存在空引用,因此不能通过返回值来判断转换是否安全。C++ 的解决办法是:dynamic_cast 在进行引用的强制转换时,如果发现转换不安全,就会拋出一个异常,通过处理异常,就能发现不安全的转换。

attribute二三事

1
2
3
static inline skew_heap_entry_t *skew_heap_insert(
skew_heap_entry_t *a, skew_heap_entry_t *b,
compare_f comp) __attribute__((always_inline));

这个函数是在做uCore的时候发现的,有一个特别的地方attribute((always_inline)),之前从来没见过,于是去查了一下,不查不知道,一查下一跳啊,这竟然是GUN C的一个从来没听过的属性。

当我们用__inline__ __attribute__((always_inline))修饰一个函数的时候,编译器会将我们的代码编译.在调用的地方将我们的函数,插入到调用的地方.

attribute是GNU C特色之一,在iOS用的比较广泛.系统中有许多地方使用到. attribute可以设置函数属性(Function Attribute )、变量属性(Variable Attribute )和类型属性(Type Attribute)等.

函数属性(Function Attribute)

  • noreturn
  • noinline
  • always_inline
  • pure
  • const
  • nothrow
  • sentinel
  • format
  • format_arg
  • no_instrument_function
  • section
  • constructor
  • destructor
  • used
  • unused
  • deprecated
  • weak
  • malloc
  • alias
  • warn_unused_result
  • nonnull

类型属性(Type Attributes)

  • aligned
  • packed
  • transparent_union,
  • unused,
  • deprecated
  • may_alias

变量属性(Variable Attribute)

  • aligned
  • packed

Clang特有的

  • availability
  • overloadable

书写格式

书写格式:attribute后面会紧跟一对原括弧,括弧里面是相应的attribute参数

1
__attribute__(xxx)

常见的系统用法

format

1
#define NS_FORMAT_FUNCTION(F,A) __attribute__((format(__NSString__, F, A)))

format属性可以给被声明的函数加上类似printf或者scanf的特征,它可以使编译器检查函数声明和函数实际调用参数之间的格式化字符串是否匹配。该功能十分有用,尤其是处理一些很难发现的bug。对于format参数的使用如下

1
format (archetype, string-index, first-to-check)

第一参数需要传递“archetype”指定是哪种风格,这里是 NSString;“string-index”指定传入函数的第几个参数是格式化字符串;“first-to-check”指定第一个可变参数所在的索引.

noreturn

官方例子: abort() 和 exit()

该属性通知编译器函数从不返回值。当遇到类似函数还未运行到return语句就需要退出来的情况,该属性可以避免出现错误信息。

availability

官方例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (CGSize)sizeWithFont:(UIFont *)font NS_DEPRECATED_IOS(2_0, 7_0, "Use -sizeWithAttributes:") __TVOS_PROHIBITED;

//来看一下 后边的宏
#define NS_DEPRECATED_IOS(_iosIntro, _iosDep, ...) CF_DEPRECATED_IOS(_iosIntro, _iosDep, __VA_ARGS__)

#define CF_DEPRECATED_IOS(_iosIntro, _iosDep, ...) __attribute__((availability(ios,introduced=_iosIntro,deprecated=_iosDep,message="" __VA_ARGS__)))

//宏展开以后如下
__attribute__((availability(ios,introduced=2_0,deprecated=7_0,message=""__VA_ARGS__)));
//ios即是iOS平台
//introduced 从哪个版本开始使用
//deprecated 从哪个版本开始弃用
//message 警告的消息

availability属性是一个以逗号为分隔的参数列表,以平台的名称开始,包含一些放在附加信息里的一些里程碑式的声明。

  • introduced:第一次出现的版本。
  • deprecated:声明要废弃的版本,意味着用户要迁移为其他API
  • obsoleted: 声明移除的版本,意味着完全移除,再也不能使用它
  • unavailable:在这些平台不可用
  • message:一些关于废弃和移除的额外信息,clang发出警告的时候会提供这些信息,对用户使用替代的API非常有用。
  • 这个属性支持的平台:ios,macosx。
1
2
3
4
5
6
7
8
//如果经常用,建议定义成类似系统的宏
- (void)oldMethod:(NSString *)string __attribute__((availability(ios,introduced=2_0,deprecated=7_0,message="用 -newMethod: 这个方法替代 "))){
NSLog(@"我是旧方法,不要调我");
}

- (void)newMethod:(NSString *)string{
NSLog(@"我是新方法");
}

visibility

语法:

1
__attribute__((visibility("visibility_type")))

其中,visibility_type 是下列值之一:

  • default:假定的符号可见性可通过其他选项进行更改。缺省可见性将覆盖此类更改。缺省可见性与外部链接对应。
  • hidden:该符号不存放在动态符号表中,因此,其他可执行文件或共享库都无法直接引用它。使用函数指针可进行间接引用。
  • internal:除非由特定于处理器的应用二进制接口 (psABI) 指定,否则,内部可见性意味着不允许从另一模块调用该函数。
  • protected:该符号存放在动态符号表中,但定义模块内的引用将与局部符号绑定。也就是说,另一模块无法覆盖该符号。

除指定 default 可见性外,此属性都可与在这些情况下具有外部链接的声明结合使用。
您可在 C 和 C++ 中使用此属性。在 C++ 中,还可将它应用于类型、成员函数和命名空间声明。

系统用法:

1
2
3
4
5
6
//  UIKIT_EXTERN     extern
#ifdef __cplusplus
#define UIKIT_EXTERN extern "C" __attribute__((visibility ("default")))
#else
#define UIKIT_EXTERN extern __attribute__((visibility ("default")))
#endif

nonnull

编译器对函数参数进行NULL的检查,参数类型必须是指针类型(包括对象)

1
2
3
4
5
6
7
- (int)addNum1:(int *)num1 num2:(int *)num2  __attribute__((nonnull (1,2))){//1,2表示第一个和第二个参数不能为空
return *num1 + *num2;
}

- (NSString *)getHost:(NSURL *)url __attribute__((nonnull (1))){//第一个参数不能为空
return url.host;
}

常见用法

aligned

__attribute((aligned (n))),让所作用的结构成员对齐在n字节自然边界上。如果结构中有成员的长度大于n,则按照最大成员的长度来对齐.例如:

不加修饰的情况

1
2
3
4
5
6
typedef struct
{
char member1;
int member2;
short member3;
}Family;

输出字节:
1
NSLog(@"Family size is %zd",sizeof(Family));

输出结果为:
1
2016-07-25 10:28:45.380 Study[917:436064] Family size is 12

修改字节对齐为1

1
2
3
4
5
6
typedef struct
{
char member1;
int member2;
short member3;
}__attribute__ ((aligned (1))) Family;

输出字节:
1
NSLog(@"Family size is %zd",sizeof(Family));

输出结果为:
1
2016-07-25 10:28:05.315 Study[914:435764] Family size is 12

和上面的结果一致,因为设定的字节对齐为1.而结构体中成员的最大字节数是int 4个字节,1 < 4,按照4字节对齐,和系统默认一致.

修改字节对齐为8

1
2
3
4
5
6
typedef struct
{
char member1;
int member2;
short member3;
}__attribute__ ((aligned (8))) Family;

输出字节:
1
NSLog(@"Family size is %zd",sizeof(Family));

输出结果为:
1
2016-07-25 10:28:05.315 Study[914:435764] Family size is 16

这里 8 > 4,按照8字节对齐,结果为16。

可是想了半天,也不知道这玩意有什么用,设定值小于系统默认的,和没设定一样,设定大了,又浪费空间,效率也没提高,感觉学习学习就好.

packed

让指定的结构结构体按照一字节对齐,测试:

1
2
3
4
5
6
7
//不加packed修饰
typedef struct {
char version;
int16_t sid;
int32_t len;
int64_t time;
} Header;

计算长度:
1
NSLog(@"size is %zd",sizeof(Header));

输出结果为:
1
2016-07-22 11:53:47.728 Study[14378:5523450] size is 16

可以看出,默认系统是按照4字节对齐
1
2
3
4
5
6
7
//加packed修饰
typedef struct {
char version;
int16_t sid;
int32_t len;
int64_t time;
}__attribute__ ((packed)) Header;

计算长度
1
NSLog(@"size is %zd",sizeof(Header));

输出结果为:
1
2016-07-22 11:57:46.970 Study[14382:5524502] size is 15

用packed修饰后,变为1字节对齐,这个常用于与协议有关的网络传输中.

noinline & always_inline

内联函数:内联函数从源代码层看,有函数的结构,而在编译后,却不具备函数的性质。内联函数不是在调用时发生控制转移,而是在编译时将函数体嵌入在每一个调用处。编译时,类似宏替换,使用函数体替换调用处的函数名。一般在代码中用inline修饰,但是能否形成内联函数,需要看编译器对该函数定义的具体处理。这两个都是用在函数上

  • noinline 不内联
  • always_inline 总是内联

内联的本质是用代码块直接替换掉函数调用处,好处是:快减少系统开销.

使用例子:

1
2
//函数声明
void test(int a) __attribute__((always_inline));

warn_unused_result

当函数或者方法的返回值很重要时,要求调用者必须检查或者使用返回值,否则编译器会发出警告提示

1
2
3
4
 - (BOOL)availiable __attribute__((warn_unused_result))
{
return 10;
}

constructor / destructor

意思是: 构造器和析构器;constructor修饰的函数会在main函数之前执行,destructor修饰的函数会在程序exit前调用.
示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(int argc, char * argv[]) {
@autoreleasepool {
NSLog(@"main");
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

__attribute__((constructor))
void before(){
NSLog(@"before main");
}

__attribute__((destructor))
void after(){
NSLog(@"after main");
}

//在viewController中调用exit
- (void)viewDidLoad {
[super viewDidLoad];

exit(0);
}

输出如下:
1
2
3
2016-07-21 21:49:17.446 Study[14162:5415982] before main
2016-07-21 21:49:17.447 Study[14162:5415982] main
2016-07-21 21:49:17.534 Study[14162:5415982] after main

注意点:

  • 程序退出的时候才会调用after函数,经测试,手动退出程序会执行
  • 上面两个函数不管写在哪个类里,哪个文件中效果都一样
  • 如果存在多个修饰的函数,那么都会执行,顺序不定
  • 实际上如果存在多个修饰过的函数,可以它们的调整优先级

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(int argc, char * argv[]) {
@autoreleasepool {
NSLog(@"main");
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

__attribute__((constructor(101)))
void before1(){
NSLog(@"before main - 1");
}
__attribute__((constructor(102)))
void before2(){
NSLog(@"before main - 2");
}

__attribute__((destructor(201)))
void after1(){
NSLog(@"after main - 1");
}
__attribute__((destructor(202)))
void after2(){
NSLog(@"after main - 2");
}

输出结果如下:
1
2
3
4
5
2016-07-21 21:59:35.622 Study[14171:5418393] before main - 1
2016-07-21 21:59:35.624 Study[14171:5418393] before main - 2
2016-07-21 21:59:35.624 Study[14171:5418393] main
2016-07-21 21:59:35.704 Study[14171:5418393] after main - 2
2016-07-21 21:59:35.704 Study[14171:5418393] after main - 1

注意点:

  • 括号内的值表示优先级,[0,100]这个返回时系统保留的,自己千万别调用.
  • 根据输出结果可以看出,main函数之前的,数值越小,越先调用;main函数之后的数值越大,越先调用.
  • 当函数声明和函数实现分开写时,格式如下:
1
2
3
4
5
static void before() __attribute__((constructor));

static void before() {
printf("before\n");
}

讨论:+load,constructor,main的执行顺序,代码如下:

1
2
3
4
5
6
7
+ (void)load{
NSLog(@"load");
}
__attribute__((constructor))
void before(){
NSLog(@"before main");
}

输出结果如下:
1
2
3
2016-07-21 22:13:58.591 Study[14185:5421811] load
2016-07-21 22:13:58.592 Study[14185:5421811] before main
2016-07-21 22:13:58.592 Study[14185:5421811] main

可以看出执行顺序为:load->constructor->main。为什么呢?

因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法,然后才调用main函数.

enable_if

用来检查参数是否合法,只能用来修饰函数:

1
2
3
4
5
void printAge(int age)
__attribute__((enable_if(age > 0 && age < 120, "你丫太监?")))
{
NSLog(@"%d",age);
}

表示只能输入的参数只能是 0 ~ 120左右,否则编译报错.

内存管理

伟大的Bill Gates 曾经失言:

640K ought to be enough for everybody —— Bill Gates 1981

程序员们经常编写内存管理程序,往往提心吊胆。如果不想触雷,唯一的解决办法就是发现所有潜伏的地雷并且排除它们,躲是躲不了的。本文的内容比一般教科书的要深入得多,读者需细心阅读,做到真正地通晓内存管理。

C++内存管理详解

内存分配方式

分配方式简介

在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。

自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。

全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

明确区分堆与栈

在bbs上,堆与栈的区分问题,似乎是一个永恒的话题,由此可见,初学者对此往往是混淆不清的,所以我决定拿他第一个开刀。

首先,我们举一个例子:

1
void f() { int* p=new int[5]; }

这条短短的一句话就包含了堆与栈,看到new,我们首先就应该想到,我们分配了一块堆内存,那么指针p呢?他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中,他在VC6下的汇编代码如下:

1
2
3
4
5
6
00401028 push 14h
0040102A call operator new (00401060)
0040102F add esp,4
00401032 mov dword ptr [ebp-8],eax
00401035 mov eax,dword ptr [ebp-8]
00401038 mov dword ptr [ebp-4],eax

这里,我们为了简单并没有释放内存,那么该怎么去释放呢?是delete p么?澳,错了,应该是delete []p,这是为了告诉编译器:我删除的是一个数组,VC6就会根据相应的Cookie信息去进行释放内存的工作。

堆和栈究竟有什么区别?

好了,我们回到我们的主题:堆和栈究竟有什么区别?

主要的区别由以下几点:
1.管理方式不同;
2.空间大小不同;
3.能否产生碎片不同;
4.生长方向不同;
5.分配方式不同;
6.分配效率不同;
管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。

空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M(好像是,记不清楚了)。当然,我们可以修改:

打开工程,依次操作菜单如下:Project->Setting->Link,在Category 中选中Output,然后在Reserve中设定堆栈的最大值和commit。

注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。

碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了。

生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。

分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。

分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。

虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)

控制C++的内存分配

在嵌入式系统中使用C++的一个常见问题是内存分配,即对new和delete操作符的失控。

具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。

这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new和delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。

但当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的new和delete来代替系统的内存分配符,并且一个类一个类的重载new和delete。

一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象。对每个类重载new 和delete就提供了这样的控制。

重载全局的new和delete操作符

可以很容易地重载new 和 delete 操作符,如下所示:

1
2
3
4
5
6
7
8
9
10
void * operator new(size_t size)
{
void *p = malloc(size);
return (p);
}

void operator delete(void *p);
{
free(p);
}

这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,我们也可以直接调用malloc() 和free()。

也可以对单个类的new 和 delete 操作符重载。这是你能灵活的控制对象的内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TestClass {
public:
void * operator new(size_t size);
void operator delete(void *p);
// .. other members here ...
};
void *TestClass::operator new(size_t size)
{
void *p = malloc(size); // Replace this with alternative allocator
return (p);
}
void TestClass::operator delete(void *p)
{
free(p); // Replace this with alternative de-allocator
}

所有TestClass 对象的内存分配都采用这段代码。更进一步,任何从TestClass 继承的类也都采用这一方式,除非它自己也重载了new 和 delete 操作符。通过重载new 和 delete 操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。

为单个的类重载 new[ ]和delete[ ]

必须小心对象数组的分配。你可能希望调用到被你重载过的new 和 delete 操作符,但并不如此。内存的请求被定向到全局的new[ ]和delete[ ] 操作符,而这些内存来自于系统堆。

C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载new[ ] 和 delete[ ]操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TestClass {
public:
void * operator new[ ](size_t size);
void operator delete[ ](void *p);
// .. other members here ..
};
void *TestClass::operator new[ ](size_t size)
{
void *p = malloc(size);
return (p);
}
void TestClass::operator delete[ ](void *p)
{
free(p);
}
int main(void)
{
TestClass *p = new TestClass[10];
// ... etc ...
delete[ ] p;
}

但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。

常见的内存错误及其对策

发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。 常见的内存错误及其对策如下:

  • 内存分配未成功,却使用了它。

编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行

检查。如果是用malloc或new来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。

  • 内存分配虽然成功,但是尚未初始化就引用它。

犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

  • 内存分配成功并且已经初始化,但操作越过了内存的边界。

例如在使用数组时经常发生下标”多1”或者”少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

  • 忘记了释放内存,造成内存泄露。

含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。

动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。

  • 释放了内存却继续使用它。

有三种情况:

(1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。

(2)函数的return语句写错了,注意不要返回指向”栈内存”的”指针”或者”引用”,因为该内存在函数体结束时被自动销毁。

(3)使用free或delete释放了内存后,没有将指针设置为NULL。导致产生”野指针”。

【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

【规则3】避免数组或指针的下标越界,特别要当心发生”多1”或者”少1”操作。

【规则4】动态内存的申请与释放必须配对,防止内存泄漏。

【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生”野指针”。

指针与数组的对比

C++/C程序中,指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为两者是等价的。

数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。

指针可以随时指向任意类型的内存块,它的特征是”可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。

下面以字符串为例比较指针与数组的特性。

修改内容

下面示例中,字符数组a的容量是6个字符,其内容为hello。a的内容可以改变,如a[0]= ‘X’。指针p指向常量字符串”world”(位于静态存储区,内容为world),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]=’X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。

1
2
3
4
5
6
char a[] ="hello";
a[0] = 'X';
cout<<a<<endl;
char *p = "world"; // 注意p指向常量字符串
p[0] = 'X'; // 编译器不能发现该错误
cout<<p<<endl;

内容复制与比较

不能对数组名进行直接复制与比较。若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数strcpy进行复制。同理,比较b和a的内容是否相同,不能用if(b==a) 来判断,应该用标准库函数strcmp进行比较。

语句p = a 并不能把a的内容复制指针p,而是把a的地址赋给了p。要想复制a的内容,可以先用库函数malloc为p申请一块容量为strlen(a)+1个字符的内存,再用strcpy进行字符串复制。同理,语句if(p==a) 比较的不是内容而是地址,应该用库函数strcmp来比较。

1
2
3
4
5
6
7
8
9
10
11
// 数组…
char a[] = "hello";
char b[10];
strcpy(b, a); // 不能用 b = a;
if(strcmp(b, a) == 0) // 不能用 if (b == a)

// 指针…
int len = strlen(a);
char *p = (char *)malloc(sizeof(char)*(len+1));
strcpy(p,a); // 不要用 p = a;
if(strcmp(p, a) == 0) // 不要用 if (p == a)

计算内存容量

用运算符sizeof可以计算出数组的容量(字节数)。如下示例中,sizeof(a)的值是12(注意别忘了’’)。指针p指向a,但是sizeof(p)的值却是4。这是因为sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量。C++/C语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。

1
2
3
4
char a[] = "hello world";
char *p = a;
cout<< sizeof(a) << endl; // 12字节
cout<< sizeof(p) << endl; // 4字节

注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。如下示例中,不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)。

1
2
3
4
void Func(char a[100])
{
cout<< sizeof(a) << endl; // 4字节而不是100字节
}

指针参数是如何传递内存的?

如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如下示例中,Test函数的语句GetMemory(str, 200)并没有使str获得期望的内存,str依旧是NULL,为什么?

1
2
3
4
5
6
7
8
9
10
void GetMemory(char *p, int num)
{
 p = (char *)malloc(sizeof(char) * num);
}
void Test(void)
{
 char *str = NULL;
 GetMemory(str, 100); // str 仍然为 NULL
 strcpy(str, "hello"); // 运行错误
}

毛病出在函数GetMemory中。编译器总是要为函数的每个参数制作临时副本,指针参数p的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p所指的内存地址改变了,但是p丝毫未变。所以函数GetMemory并不能输出任何东西。事实上,每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存。

如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”,见示例:

1
2
3
4
5
6
7
8
9
10
11
12
void GetMemory2(char **p, int num)
{
*p = (char *)malloc(sizeof(char) * num);
}
void Test2(void)
{
char *str = NULL;
GetMemory2(&str, 100); // 注意参数是 &str,而不是str
strcpy(str, "hello");
cout<< str << endl;
free(str);
}

由于“指向指针的指针”这个概念不容易理解,我们可以用函数返回值来传递动态内存。这种方法更加简单,见示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
char *GetMemory3(int num)
{
char *p = (char *)malloc(sizeof(char) * num);
return p;
}
void Test3(void)
{
char *str = NULL;
str = GetMemory3(100);
strcpy(str, "hello");
cout<< str << endl;
free(str);
}

用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return语句用错了。这里强调不要用return语句返回指向”栈内存”的指针,因为该内存在函数结束时自动消亡,见示例:

1
2
3
4
5
6
7
8
9
10
11
char *GetString(void)
{
char p[] = "hello world";
return p; // 编译器将提出警告
}
void Test4(void)
{
char *str = NULL;
str = GetString(); // str 的内容是垃圾
cout<< str << endl;
}

用调试器逐步跟踪Test4,发现执行str = GetString语句后str不再是NULL指针,但是str的内容不是“hello world”而是垃圾。

如果把上述示例改写成如下示例,会怎么样?

1
2
3
4
5
6
7
8
9
10
11
char *GetString2(void)
{
char *p = "hello world";
return p;
}
void Test5(void)
{
char *str = NULL;
str = GetString2();
cout<< str << endl;
}

函数Test5运行虽然不会出错,但是函数GetString2的设计概念却是错误的。因为GetString2内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。

杜绝“野指针”

“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。 “野指针”的成因主要有两种:

  1. 指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如
1
2
char *p = NULL;
char *str = (char *) malloc(100);

指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。

  1. 指针操作超越了变量的作用域范围。这种情况让人防不胜防,示例程序如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
public:
void Func(void){ cout << “Func of class A” << endl; }
};
void Test(void)
{
A *p;
{
A a;
p = &a; // 注意 a 的生命期
}
p->Func(); // p是"野指针"
}

函数Test在执行语句p->Func()时,对象a已经消失,而p是指向a的,所以p就成了”野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。

有了malloc/free为什么还要new/delete?

malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。

对于非内部数据类型的对象而言,光用malloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。

因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。我们先看一看malloc/free和new/delete如何实现对象的动态内存管理,见示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Obj
{
public :
Obj(void){ cout << “Initialization” << endl; }
~Obj(void){ cout << “Destroy” << endl; }
void Initialize(void){ cout << “Initialization” << endl; }
void Destroy(void){ cout << “Destroy” << endl; }
};
void UseMallocFree(void)
{
Obj *a = (obj *)malloc(sizeof(obj)); // 申请动态内存
a->Initialize(); // 初始化
//…
a->Destroy(); // 清除工作
free(a); // 释放内存
}
void UseNewDelete(void)
{
Obj *a = new Obj; // 申请动态内存并且初始化
//…
delete a; // 清除并且释放内存
}

类Obj的函数Initialize模拟了构造函数的功能,函数Destroy模拟了析构函数的功能。函数UseMallocFree中,由于malloc/free不能执行构造函数与析构函数,必须调用成员函数Initialize和Destroy来完成初始化与清除工作。函数UseNewDelete则简单得多。

所以我们不要企图用malloc/free来完成动态对象的内存管理,应该用new/delete。由于内部数据类型的”对象”没有构造与析构的过程,对它们而言malloc/free和new/delete是等价的。

既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。

如果用free释放”new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放”malloc申请的动态内存”,结果也会导致程序出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

内存耗尽怎么办?

如果在申请动态内存时找不到足够大的内存块,malloc和new将返回NULL指针,宣告内存申请失败。通常有三种方式处理”内存耗尽”问题。

  1. 判断指针是否为NULL,如果是则马上用return语句终止本函数。例如:
1
2
3
4
5
6
7
8
void Func(void)
{
A *a = new A;
if(a == NULL)
{
return;
}
}
  1. 判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行。例如:
1
2
3
4
5
6
7
8
9
void Func(void)
{
A *a = new A;
if(a == NULL)
{
cout << “Memory Exhausted” << endl;
exit(1);
}
}
  1. 为new和malloc设置异常处理函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。详细内容请参考C++使用手册。

上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。

很多人不忍心用exit(1),问:”不编写出错处理程序,让操作系统自己解决行不行?”

不行。如果发生”内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用exit(1)把坏程序杀死,它可能会害死操作系统。道理如同:如果不把歹徒击毙,歹徒在老死之前会犯下更多的罪。

有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用malloc与new,几乎不可能导致”内存耗尽”。我在Windows 98下用Visual C++编写了测试程序,见示例7。这个程序会无休止地运行下去,根本不会终止。因为32位操作系统支持”虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98已经累得对键盘、鼠标毫无反应。

我可以得出这么一个结论:对于32位以上的应用程序,”内存耗尽”错误处理程序毫无用处。这下可把Unix和Windows程序员们乐坏了:反正错误处理程序不起作用,我就不写了,省了很多麻烦。

我不想误导读者,必须强调:不加错误处理将导致程序的质量很差,千万不可因小失大。

1
2
3
4
5
6
7
8
9
10
11
void main(void)
{
 float *p = NULL;
 while(TRUE)
 {
  p = new float[1000000];
  cout << “eat memory” << endl;
  if(p==NULL)
   exit(1);
 }
}

malloc/free的使用要点

函数malloc的原型如下:

1
void * malloc(size_t size);

用malloc申请一块长度为length的整数类型的内存,程序如下:

1
int *p = (int *) malloc(sizeof(int) * length);

我们应当把注意力集中在两个要素上:”类型转换”和”sizeof”。

  • malloc返回值的类型是void ,所以在调用malloc时要显式地进行类型转换,将void 转换成所需要的指针类型。

  • malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float等数据类型的变量的确切字节数。例如int变量在16位系统下是2个字节,在32位下是4个字节;而float变量在16位系统下是4个字节,在32位下也是4个字节。最好用以下程序作一次测试:

1
2
3
4
5
6
7
8
cout << sizeof(char) << endl;
cout << sizeof(int) << endl;
cout << sizeof(unsigned int) << endl;
cout << sizeof(long) << endl;
cout << sizeof(unsigned long) << endl;
cout << sizeof(float) << endl;
cout << sizeof(double) << endl;
cout << sizeof(void *) << endl;

在malloc的”()”中使用sizeof运算符是良好的风格,但要当心有时我们会昏了头,写出 p = malloc(sizeof(p))这样的程序来。

函数free的原型如下:

1
void free( void * memblock );

为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。

new/delete的使用要点

运算符new使用起来要比函数malloc简单得多,例如:

1
2
3
int *p1 = (int *)malloc(sizeof(int) * length);

int *p2 = new int[length];

这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Obj
{
public :
Obj(void); // 无参数的构造函数
Obj(int x); // 带一个参数的构造函数
}

void Test(void)
{
Obj *a = new Obj;
Obj *b = new Obj(1); // 初值为1
delete a;
delete b;
}

如果用new创建对象数组,那么只能使用对象的无参数构造函数。例如:

1
Obj *objects = new Obj[100]; // 创建100个动态对象

不能写成:

1
Obj *objects = new Obj[100](1);// 创建100个动态对象的同时赋初值1

在用delete释放对象数组时,留意不要丢了符号’[]’。例如:

1
2
delete []objects; // 正确的用法
delete objects; // 错误的用法

后者有可能引起程序崩溃和内存泄漏。

C++中的健壮指针和资源管理

我最喜欢的对资源的定义是:”任何在你的程序中获得并在此后释放的东西?quot;内存是一个相当明显的资源的例子。它需要用new来获得,用delete来释放。同时也有许多其它类型的资源文件句柄、重要的片断、Windows中的GDI资源,等等。将资源的概念推广到程序中创建、释放的所有对象也是十分方便的,无论对象是在堆中分配的还是在栈中或者是在全局作用于内生命的。

对于给定的资源的拥有着,是负责释放资源的一个对象或者是一段代码。所有权分立为两种级别——自动的和显式的(automatic and explicit),如果一个对象的释放是由语言本身的机制来保证的,这个对象的就是被自动地所有。例如,一个嵌入在其他对象中的对象,他的清除需要其他对象来在清除的时候保证。外面的对象被看作嵌入类的所有者。 类似地,每个在栈上创建的对象(作为自动变量)的释放(破坏)是在控制流离开了对象被定义的作用域的时候保证的。这种情况下,作用于被看作是对象的所有者。注意所有的自动所有权都是和语言的其他机制相容的,包括异常。无论是如何退出作用域的————正常流程控制退出、一个break语句、一个return、一个goto、或者是一个throw————自动资源都可以被清除。

到目前为止,一切都很好!问题是在引入指针、句柄和抽象的时候产生的。如果通过一个指针访问一个对象的话,比如对象在堆中分配,C++不自动地关注它的释放。程序员必须明确的用适当的程序方法来释放这些资源。比如说,如果一个对象是通过调用new来创建的,它需要用delete来回收。一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭。用EnterCritialSection进入的临界区(Critical Section)需要LeaveCriticalSection退出,等等。一个”裸”指针,文件句柄,或者临界区状态没有所有者来确保它们的最终释放。基本的资源管理的前提就是确保每个资源都有他们的所有者。

第一条规则(RAII)

一个指针,一个句柄,一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。

当你按照规则将所有资源封装的时候,你可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显。但是对那些动态申请的对象呢?不要急!任何动态申请的东西都被看作一种资源,并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止。它最终终止在最高级的所有者,自动的或者是静态的。这些分别是对离开作用域或者程序时释放资源的保证。

下面是资源封装的一个经典例子。在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的。每一个需要访问共享资源的客户需要获得临界区。例如,这可能是Win32下临界区的实现方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class CritSect
{
friend class Lock;
public:
CritSect () { InitializeCriticalSection (&_critSection); }
~CritSect () { DeleteCriticalSection (&_critSection); }
private:
void Acquire ()
{
EnterCriticalSection (&_critSection);
}
void Release ()
{
LeaveCriticalSection (&_critSection);
}
private:
CRITICAL_SECTION _critSection;
};

这里聪明的部分是我们确保每一个进入临界区的客户最后都可以离开。”进入”临界区的状态是一种资源,并应当被封装。封装器通常被称作一个锁(lock)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Lock
{
public:
Lock (CritSect& critSect) : _critSect (critSect)
{
_critSect.Acquire ();
}
~Lock ()
{
_critSect.Release ();
}
private
CritSect & _critSect;
};

锁一般的用法如下:

1
2
3
4
5
6
void Shared::Act () throw (char *)
{
Lock lock (_critSect);
// perform action —— may throw
// automatic destructor of lock
}

注意无论发生什么,临界区都会借助于语言的机制保证释放。

还有一件需要记住的事情————每一种资源都需要被分别封装。这是因为资源分配是一个非常容易出错的操作,是要资源是有限提供的。我们会假设一个失败的资源分配会导致一个异常————事实上,这会经常的发生。所以如果你想试图用一个石头打两只鸟的话,或者在一个构造函数中申请两种形式的资源,你可能就会陷入麻烦。只要想想在一种资源分配成功但另一种失败抛出异常时会发生什么。因为构造函数还没有全部完成,析构函数不可能被调用,第一种资源就会发生泄露。

这种情况可以非常简单的避免。无论何时你有一个需要两种以上资源的类时,写两个小的封装器将它们嵌入你的类中。每一个嵌入的构造都可以保证删除,即使包装类没有构造完成。

Smart Pointers

我们至今还没有讨论最常见类型的资源————用操作符new分配,此后用指针访问的一个对象。我们需要为每个对象分别定义一个封装类吗?(事实上,C++标准模板库已经有了一个模板类,叫做auto_ptr,其作用就是提供这种封装。我们一会儿在回到auto_ptr。)让我们从一个极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类,它十分坚固,甚至无法实现。

1
2
3
4
5
6
7
8
9
10
11
12
template <class T>
class SmartPointer
{
public:
~SmartPointer () { delete _p; }
T * operator->() { return _p; }
T const * operator->() const { return _p; }
protected:
SmartPointer (): _p (0) {}
explicit SmartPointer (T* p): _p (p) {}
T * _p;
};

为什么要把SmartPointer的构造函数设计为protected呢?如果我需要遵守第一条规则,那么我就必须这样做。资源————在这里是class T的一个对象————必须在封装器的构造函数中分配。但是我不能只简单的调用new T,因为我不知道T的构造函数的参数。因为,在原则上,每一个T都有一个不同的构造函数;我需要为他定义个另外一个封装器。模板的用处会很大,为每一个新的类,我可以通过继承SmartPointer定义一个新的封装器,并且提供一个特定的构造函数。

1
2
3
4
5
6
class SmartItem: public SmartPointer<Item>
{
public:
explicit SmartItem (int i)
  : SmartPointer<Item> (new Item (i)) {}
};

为每一个类提供一个Smart Pointer真的值得吗?说实话————不!他很有教学的价值,但是一旦你学会如何遵循第一规则的话,你就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构造函数成为public,但是只是是用它来做资源转换(Resource Transfer)我的意思是用new操作符的结果直接作为SmartPointer的构造函数的参数,像这样:

1
SmartPointer<Item> item (new Item (i));

这个方法明显更需要自控性,不只是你,而且包括你的程序小组的每个成员。他们都必须发誓出了作资源转换外不把构造函数用在人以其他用途。幸运的是,这条规矩很容易得以加强。只需要在源文件中查找所有的new即可。

Resource Transfer

到目前为止,我们所讨论的一直是生命周期在一个单独的作用域内的资源。现在我们要解决一个困难的问题————如何在不同的作用域间安全的传递资源。这一问题在当你处理容器的时候会变得十分明显。你可以动态的创建一串对象,将它们存放至一个容器中,然后将它们取出,并且在最终安排它们。为了能够让这安全的工作————没有泄露————对象需要改变其所有者。

这个问题的一个非常显而易见的解决方法是使用Smart Pointer,无论是在加入容器前还是还找到它们以后。这是他如何运作的,你加入Release方法到Smart Pointer中:

1
2
3
4
5
6
7
template <class T>
T * SmartPointer<T>::Release ()
{
T * pTmp = _p;
_p = 0;
return pTmp;
}

注意在Release调用以后,Smart Pointer就不再是对象的所有者了————它内部的指针指向空。现在,调用了Release都必须是一个负责的人并且迅速隐藏返回的指针到新的所有者对象中。在我们的例子中,容器调用了Release,比如这个Stack的例子:

1
2
3
4
5
6
void Stack::Push (SmartPointer <Item> & item) throw (char *)
{
if (_top == maxStack)
throw "Stack overflow";
_arr [_top++] = item.Release ();
};

同样的,你也可以再你的代码中用加强Release的可靠性。

相应的Pop方法要做些什么呢?他应该释放了资源并祈祷调用它的是一个负责的人而且立即作一个资源传递它到一个Smart Pointer?这听起来并不好。

Strong Pointers

资源管理在内容索引(Windows NT Server上的一部分,现在是Windows 2000)上工作,并且,我对这十分满意。然后我开始想……这一方法是在这样一个完整的系统中形成的,如果可以把它内建入语言的本身岂不是一件非常好?我提出了强指针(Strong Pointer)和弱指针(Weak Pointer)。一个Strong Pointer会在许多地方和我们这个SmartPointer相似—它在超出它的作用域后会清除他所指向的对象。资源传递会以强指针赋值的形式进行。也可以有Weak Pointer存在,它们用来访问对象而不需要所有对象—比如可赋值的引用。

任何指针都必须声明为Strong或者Weak,并且语言应该来关注类型转换的规定。例如,你不可以将Weak Pointer传递到一个需要Strong Pointer的地方,但是相反却可以。Push方法可以接受一个Strong Pointer并且将它转移到Stack中的Strong Pointer的序列中。Pop方法将会返回一个Strong Pointer。把Strong Pointer的引入语言将会使垃圾回收成为历史。

这里还有一个小问题—修改C++标准几乎和竞选美国总统一样容易。当我将我的注意告诉给Bjarne Stroutrup的时候,他看我的眼神好像是我刚刚要向他借一千美元一样。

然后我突然想到一个念头。我可以自己实现Strong Pointers。毕竟,它们都很想Smart Pointers。给它们一个拷贝构造函数并重载赋值操作符并不是一个大问题。事实上,这正是标准库中的auto_ptr有的。重要的是对这些操作给出一个资源转移的语法,但是这也不是很难。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class T>
SmartPointer<T>::SmartPointer (SmartPointer<T> & ptr)
{
_p = ptr.Release ();
}
template <class T>
void SmartPointer<T>::operator = (SmartPointer<T> & ptr)
{
if (_p != ptr._p)
{
delete _p;
_p = ptr.Release ();
}
}

使这整个想法迅速成功的原因之一是我可以以值方式传递这种封装指针!我有了我的蛋糕,并且也可以吃了。看这个Stack的新的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Stack
{
enum { maxStack = 3 };
public:
Stack ()
: _top (0)
{}
void Push (SmartPointer<Item> & item) throw (char *)
{
if (_top >= maxStack)
throw "Stack overflow";
_arr [_top++] = item;
}
SmartPointer<Item> Pop ()
{
if (_top == 0)
return SmartPointer<Item> ();
return _arr [--_top];
}
private
int _top;
SmartPointer<Item> _arr [maxStack];
};

Pop方法强制客户将其返回值赋给一个Strong Pointer,SmartPointer。任何试图将他对一个普通指针的赋值都会产生一个编译期错误,因为类型不匹配。此外,因为Pop以值方式返回一个Strong Pointer(在Pop的声明时SmartPointer后面没有&符号),编译器在return时自动进行了一个资源转换。他调用了operator =来从数组中提取一个Item,拷贝构造函数将他传递给调用者。调用者最后拥有了指向Pop赋值的Strong Pointer指向的一个Item。

我马上意识到我已经在某些东西之上了。我开始用了新的方法重写原来的代码。

Parser

我过去有一个老的算术操作分析器,是用老的资源管理的技术写的。分析器的作用是在分析树中生成节点,节点是动态分配的。例如分析器的Expression方法生成一个表达式节点。我没有时间用Strong Pointer去重写这个分析器。我令Expression、Term和Factor方法以传值的方式将Strong Pointer返回到Node中。看下面的Expression方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SmartPointer<Node> Parser::Expression()
{
// Parse a term
SmartPointer<Node> pNode = Term ();
EToken token = _scanner.Token();
if ( token == tPlus || token == tMinus )
{
// Expr := Term { ('+' | '-') Term }
SmartPointer<MultiNode> pMultiNode = new SumNode (pNode);
do
{
_scanner.Accept();
SmartPointer<Node> pRight = Term ();
pMultiNode->AddChild (pRight, (token == tPlus));
token = _scanner.Token();
} while (token == tPlus || token == tMinus);
pNode = up_cast<Node, MultiNode> (pMultiNode);
}
// otherwise Expr := Term
return pNode; // by value!
}

最开始,Term方法被调用。他传值返回一个指向Node的Strong Pointer并且立刻把它保存到我们自己的Strong Pointer,pNode中。如果下一个符号不是加号或者减号,我们就简单的把这个SmartPointer以值返回,这样就释放了Node的所有权。另外一方面,如果下一个符号是加号或者减号,我们创建一个新的SumMode并且立刻(直接传递)将它储存到MultiNode的一个Strong Pointer中。这里,SumNode是从MultiMode中继承而来的,而MulitNode是从Node继承而来的。原来的Node的所有权转给了SumNode。

只要是他们在被加号和减号分开的时候,我们就不断的创建terms,我们将这些term转移到我们的MultiNode中,同时MultiNode得到了所有权。最后,我们将指向MultiNode的Strong Pointer向上映射为指向Mode的Strong Pointer,并且将他返回调用着。

我们需要对Strong Pointers进行显式的向上映射,即使指针是被隐式的封装。例如,一个MultiNode是一个Node,但是相同的is-a关系在SmartPointer和SmartPointer之间并不存在,因为它们是分离的类(模板实例)并不存在继承关系。up-cast模板是像下面这样定义的:

1
2
3
4
5
template<class To, class From>
inline SmartPointer<To> up_cast (SmartPointer<From> & from)
{
return SmartPointer<To> (from.Release ());
}

如果你的编译器支持新加入标准的成员模板(member template)的话,你可以为SmartPointer定义一个新的构造函数用来从接受一个class U。

1
2
3
4
template <class T>
template <class U> SmartPointer<T>::SmartPointer (SPrt<U> & uptr)
: _p (uptr.Release ())
{}

这里的这个花招是模板在U不是T的子类的时候就不会编译成功(换句话说,只在U is-a T的时候才会编译)。这是因为uptr的缘故。Release()方法返回一个指向U的指针,并被赋值为_p,一个指向T的指针。所以如果U不是一个T的话,赋值会导致一个编译时刻错误。

1
std::auto_ptr

后来我意识到在STL中的auto_ptr模板,就是我的Strong Pointer。在那时候还有许多的实现差异(auto_ptr的Release方法并不将内部的指针清零—你的编译器的库很可能用的就是这种陈旧的实现),但是最后在标准被广泛接受之前都被解决了。

Transfer Semantics

目前为止,我们一直在讨论在C++程序中资源管理的方法。宗旨是将资源封装到一些轻量级的类中,并由类负责它们的释放。特别的是,所有用new操作符分配的资源都会被储存并传递进Strong Pointer(标准库中的auto_ptr)的内部。

这里的关键词是传递(passing)。一个容器可以通过传值返回一个StrongPointer来安全的释放资源。容器的客户只能够通过提供一个相应的Strong Pointer来保存这个资源。任何一个将结果赋给一个”裸”指针的做法都立即会被编译器发现。

1
2
auto_ptr<Item> item = stack.Pop (); // ok
Item * p = stack.Pop (); // Error! Type mismatch.

以传值方式被传递的对象有value semantics 或者称为 copy semantics。Strong Pointers是以值方式传递的—但是我们能说它们有copy semantics吗?不是这样的!它们所指向的对象肯定没有被拷贝过。事实上,传递过后,源auto_ptr不在访问原有的对象,并且目标auto_ptr成为了对象的唯一拥有者(但是往往auto_ptr的旧的实现即使在释放后仍然保持着对对象的所有权)。自然而然的我们可以将这种新的行为称作Transfer Semantics。

拷贝构造函数(copy construcor)和赋值操作符定义了auto_ptr的Transfer Semantics,它们用了非const的auto_ptr引用作为它们的参数。

1
2
auto_ptr (auto_ptr<T> & ptr);
auto_ptr & operator = (auto_ptr<T> & ptr);

这是因为它们确实改变了他们的源—剥夺了对资源的所有权。

通过定义相应的拷贝构造函数和重载赋值操作符,你可以将Transfer Semantics加入到许多对象中。例如,许多Windows中的资源,比如动态建立的菜单或者位图,可以用有Transfer Semantics的类来封装。

Strong Vectors

标准库只在auto_ptr中支持资源管理。甚至连最简单的容器也不支持ownership semantics。你可能想将auto_ptr和标准容器组合到一起可能会管用,但是并不是这样的。例如,你可能会这样做,但是会发现你不能够用标准的方法来进行索引。

1
vector< auto_ptr<Item> > autoVector;

这种建造不会编译成功;

1
Item * item = autoVector [0];

另一方面,这会导致一个从autoVect到auto_ptr的所有权转换:

1
auto_ptr<Item> item = autoVector [0];

我们没有选择,只能够构造我们自己的Strong Vector。最小的接口应该如下:

1
2
3
4
5
6
7
8
9
10
11
12
template <class T>
class auto_vector
{
public:
explicit auto_vector (size_t capacity = 0);
T const * operator [] (size_t i) const;
T * operator [] (size_t i);
void assign (size_t i, auto_ptr<T> & p);
void assign_direct (size_t i, T * p);
void push_back (auto_ptr<T> & p);
auto_ptr<T> pop_back ();
};

你也许会发现一个非常防御性的设计态度。我决定不提供一个对vector的左值索引的访问,取而代之,如果你想设定(set)一个值的话,你必须用assign或者assign_direct方法。我的观点是,资源管理不应该被忽视,同时,也不应该在所有的地方滥用。在我的经验里,一个strong vector经常被许多push_back方法充斥着。

Strong vector最好用一个动态的Strong Pointers的数组来实现:

1
2
3
4
5
6
7
8
9
template <class T>
class auto_vector
{
private
void grow (size_t reqCapacity);
auto_ptr<T> *_arr;
size_t _capacity;
size_t _end;
};

grow方法申请了一个很大的auto_ptr的数组,将所有的东西从老的书组类转移出来,在其中交换,并且删除原来的数组。

auto_vector的其他实现都是十分直接的,因为所有资源管理的复杂度都在auto_ptr中。例如,assign方法简单的利用了重载的赋值操作符来删除原有的对象并转移资源到新的对象:

1
2
3
4
void assign (size_t i, auto_ptr<T> & p)
{
_arr [i] = p;
}

我已经讨论了push_back和pop_back方法。push_back方法传值返回一个auto_ptr,因为它将所有权从auto_vector转换到auto_ptr中。

对auto_vector的索引访问是借助auto_ptr的get方法来实现的,get简单的返回一个内部指针。

1
2
3
4
T * operator [] (size_t i)
{
return _arr [i].get ();
}

没有容器可以没有iterator。我们需要一个iterator让auto_vector看起来更像一个普通的指针向量。特别是,当我们废弃iterator的时候,我们需要的是一个指针而不是auto_ptr。我们不希望一个auto_vector的iterator在无意中进行资源转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<class T>
class auto_iterator: public
iterator<random_access_iterator_tag, T *>
{
public:
auto_iterator () : _pp (0) {}
auto_iterator (auto_ptr<T> * pp) : _pp (pp) {}
bool operator != (auto_iterator<T> const & it) const
{ return it._pp != _pp; }
auto_iterator const & operator++ (int) { return _pp++; }
auto_iterator operator++ () { return ++_pp; }
T * operator * () { return _pp->get (); }
private
auto_ptr<T> * _pp;
};

我们给auto_vect提供了标准的begin和end方法来找回iterator:

1
2
3
4
5
6
7
class auto_vector
{
public:
typedef auto_iterator<T> iterator;
iterator begin () { return _arr; }
iterator end () { return _arr + _end; }
};

你也许会问我们是否要利用资源管理重新实现每一个标准的容器?幸运的是,不;事实是strongvector解决了大部分所有权的需求。当你把你的对象都安全的放置到一个strong vector中,你可以用所有其它的容器来重新安排(weak)pointer。

设想,例如,你需要对一些动态分配的对象排序的时候。你将它们的指针保存到一个strongvector中。然后你用一个标准的vector来保存从strong vector中获得的weak指针。你可以用标准的算法对这个vector进行排序。这种中介vector叫做permutation vector。相似的,你也可以用标准的maps, priority queues, heaps, hash tables等等。

Code Inspection

如果你严格遵照资源管理的条款,你就不会再资源泄露或者两次删除的地方遇到麻烦。你也降低了访问野指针的几率。同样的,遵循原有的规则,用delete删除用new申请的德指针,不要两次删除一个指针。你也不会遇到麻烦。但是,那个是更好的注意呢?

这两个方法有一个很大的不同点。就是和寻找传统方法的bug相比,找到违反资源管理的规定要容易的多。后者仅需要一个代码检测或者一个运行测试,而前者则在代码中隐藏得很深,并需要很深的检查。

设想你要做一段传统的代码的内存泄露检查。第一件事,你要做的就是grep所有在代码中出现的new,你需要找出被分配空间地指针都作了什么。你需要确定导致删除这个指针的所有的执行路径。你需要检查break语句,过程返回,异常。原有的指针可能赋给另一个指针,你对这个指针也要做相同的事。

相比之下,对于一段用资源管理技术实现的代码。你也用grep检查所有的new,但是这次你只需要检查邻近的调用:

● 这是一个直接的Strong Pointer转换,还是我们在一个构造函数的函数体中?

● 调用的返回知是否立即保存到对象中,构造函数中是否有可以产生异常的代码。?

● 如果这样的话析构函数中时候有delete?

下一步,你需要用grep查找所有的release方法,并实施相同的检查。

不同点是需要检查、理解单个执行路径和只需要做一些本地的检验。这难道不是提醒你非结构化的和结构化的程序设计的不同吗?原理上,你可以认为你可以应付goto,并且跟踪所有的可能分支。另一方面,你可以将你的怀疑本地化为一段代码。本地化在两种情况下都是关键所在。

在资源管理中的错误模式也比较容易调试。最常见的bug是试图访问一个释放过的strong pointer。这将导致一个错误,并且很容易跟踪。

共享的所有权

为每一个程序中的资源都找出或者指定一个所有者是一件很容易的事情吗?答案是出乎意料的,是!如果你发现了一些问题,这可能说明你的设计上存在问题。还有另一种情况就是共享所有权是最好的甚至是唯一的选择。

共享的责任分配给被共享的对象和它的客户(client)。一个共享资源必须为它的所有者保持一个引用计数。另一方面,所有者再释放资源的时候必须通报共享对象。最后一个释放资源的需要在最后负责free的工作。

最简单的共享的实现是共享对象继承引用计数的类RefCounted:

1
2
3
4
5
6
7
8
9
10
class RefCounted
{
public:
RefCounted () : _count (1) {}
int GetRefCount () const { return _count; }
void IncRefCount () { _count++; }
int DecRefCount () { return --_count; }
private
int _count;
};

按照资源管理,一个引用计数是一种资源。如果你遵守它,你需要释放它。当你意识到这一事实的时候,剩下的就变得简单了。简单的遵循规则—再构造函数中获得引用计数,在析构函数中释放。甚至有一个RefCounted的smart pointer等价物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class T>
class RefPtr
{
public:
RefPtr (T * p) : _p (p) {}
RefPtr (RefPtr<T> & p)
{
_p = p._p;
_p->IncRefCount ();
}
~RefPtr ()
{
if (_p->DecRefCount () == 0)
delete _p;
}
private
T * _p;
};

注意模板中的T不比成为RefCounted的后代,但是它必须有IncRefCount和DecRefCount的方法。当然,一个便于使用的RefPtr需要有一个重载的指针访问操作符。在RefPtr中加入转换语义学(transfer semantics)是读者的工作。

所有权网络

链表是资源管理分析中的一个很有意思的例子。如果你选择表成为链(link)的所有者的话,你会陷入实现递归的所有权。每一个link都是它的继承者的所有者,并且,相应的,余下的链表的所有者。下面是用smart pointer实现的一个表单元:

1
2
3
4
5
6
class Link
{
// ...
private
auto_ptr<Link> _next;
};

最好的方法是,将连接控制封装到一个弄构进行资源转换的类中。

对于双链表呢?安全的做法是指明一个方向,如forward:

1
2
3
4
5
6
7
class DoubleLink
{
// ...
private
DoubleLink *_prev;
auto_ptr<DoubleLink> _next;
};

注意不要创建环形链表。

这给我们带来了另外一个有趣的问题—资源管理可以处理环形的所有权吗?它可以,用一个mark-and-sweep的算法。这里是实现这种方法的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class T>
class CyclPtr
{
public:
CyclPtr (T * p)
:_p (p), _isBeingDeleted (false)
{}
~CyclPtr ()
{
_isBeingDeleted = true;
if (!_p->IsBeingDeleted ())
delete _p;
}
void Set (T * p)
{
_p = p;
}
bool IsBeingDeleted () const { return _isBeingDeleted; }
private
T * _p;
bool _isBeingDeleted;
};

注意我们需要用class T来实现方法IsBeingDeleted,就像从CyclPtr继承。对特殊的所有权网络普通化是十分直接的。

将原有代码转换为资源管理代码

如果你是一个经验丰富的程序员,你一定会知道找资源的bug是一件浪费时间的痛苦的经历。我不必说服你和你的团队花费一点时间来熟悉资源管理是十分值得的。你可以立即开始用这个方法,无论你是在开始一个新项目或者是在一个项目的中期。转换不必立即全部完成。下面是步骤。

  1. 首先,在你的工程中建立基本的Strong Pointer。然后通过查找代码中的new来开始封装裸指针。

  2. 最先封装的是在过程中定义的临时指针。简单的将它们替换为auto_ptr并且删除相应的delete。如果一个指针在过程中没有被删除而是被返回,用auto_ptr替换并在返回前调用release方法。在你做第二次传递的时候,你需要处理对release的调用。注意,即使是在这点,你的代码也可能更加”精力充沛”—你会移出代码中潜在的资源泄漏问题。

  3. 下面是指向资源的裸指针。确保它们被独立的封装到auto_ptr中,或者在构造函数中分配在析构函数中释放。如果你有传递所有权的行为的话,需要调用release方法。如果你有容器所有对象,用Strong Pointers重新实现它们。

  4. 接下来,找到所有对release的方法调用并且尽力清除所有,如果一个release调用返回一个指针,将它修改传值返回一个auto_ptr。

  5. 重复着一过程,直到最后所有new和release的调用都在构造函数或者资源转换的时候发生。这样,你在你的代码中处理了资源泄漏的问题。对其他资源进行相似的操作。

  6. 你会发现资源管理清除了许多错误和异常处理带来的复杂性。不仅仅你的代码会变得精力充沛,它也会变得简单并容易维护。

内存泄漏

C++中动态内存分配引发问题的解决方案

假设我们要开发一个String类,它可以方便地处理字符串数据。我们可以在类中声明一个数组,考虑到有时候字符串极长,我们可以把数组大小设为200,但一般的情况下又不需要这么多的空间,这样是浪费了内存。对了,我们可以使用new操作符,这样是十分灵活的,但在类中就会出现许多意想不到的问题,本文就是针对这一现象而写的。现在,我们先来开发一个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
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
/* String.h */
#ifndef STRING_H_
#define STRING_H_
class String
{
private:
char * str; //存储数据
int len; //字符串长度
public:
String(const char * s); //构造函数
String(); // 默认构造函数
~String(); // 析构函数
friend ostream & operator<<(ostream & os,const String& st);
};
#endif

/*String.cpp*/
#include <iostream>
#include <cstring>
#include "String.h"
using namespace std;
String::String(const char * s)
{
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
}//拷贝数据
String::String()
{
len =0;
str = new char[len+1];
str[0]='"0';
}
String::~String()
{
cout<<"这个字符串将被删除:"<<str<<'"n';//为了方便观察结果,特留此行代码。
delete [] str;
}
ostream & operator<<(ostream & os, const String & st)
{
os << st.str;
return os;
}

/*test_right.cpp*/
#include <iostream>
#include <stdlib.h>
#include "String.h"
using namespace std;
int main()
{
String temp("天极网");
cout<<temp<<'"n';
system("PAUSE");
return 0;
}

运行结果:

1
2
3
天极网

按任意键继续. . .

大家可以看到,以上程序十分正确,而且也是十分有用的。可是,我们不能被表面现象所迷惑!下面,请大家用test_String.cpp文件替换test_right.cpp文件进行编译,看看结果。有的编译器可能就是根本不能进行编译!

test_String.cpp:

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
#include <iostream>
#include <stdlib.h>
#include "String.h"
using namespace std;
void show_right(const String&);
void show_String(const String);//注意,参数非引用,而是按值传递。
int main()
{
String test1("第一个范例。");
String test2("第二个范例。");
String test3("第三个范例。");
String test4("第四个范例。");
cout<<"下面分别输入三个范例";
cout<<test1<<endl;
cout<<test2<<endl;
cout<<test3<<endl;
String* String1=new String(test1);
cout<<*String1<<endl;
delete String1;
cout<<test1<<endl; //在Dev-cpp上没有任何反应。
cout<<"使用正确的函数:"<<endl;
show_right(test2);
cout<<test2<<endl;
cout<<"使用错误的函数:"<<endl;
show_String(test2);
cout<<test2<<endl; //这一段代码出现严重的错误!
String String2(test3);
cout<<"String2: "<<String2<<endl;
String String3;
String3=test4;
cout<<"String3: "<<String3<<endl;
cout<<"下面,程序结束,析构函数将被调用。"<<endl;
return 0;
}

void show_right(const String& a)
{
cout<<a<<endl;
}
void show_String(const String a)
{
cout<<a<<endl;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
下面分别输入三个范例:
第一个范例。
第二个范例。
第三个范例。
第一个范例。
这个字符串将被删除:第一个范例。
使用正确的函数:
第二个范例。
第二个范例。
使用错误的函数:
第二个范例。
这个字符串将被删除:第二个范例。
这个字符串将被删除:?=
?=
String2: 第三个范例。
String3: 第四个范例。
下面,程序结束,析构函数将被调用。
这个字符串将被删除:第四个范例。
这个字符串将被删除:第三个范例。
这个字符串将被删除:?=
这个字符串将被删除:x =
这个字符串将被删除:?=
这个字符串将被删除:

现在,请大家自己试试运行结果,或许会更加惨不忍睹呢!下面,我为大家一一分析原因。

首先,大家要知道,C++类有以下这些极为重要的函数:

一:复制构造函数。

二:赋值函数。

我们先来讲复制构造函数。什么是复制构造函数呢?比如,我们可以写下这样的代码:String test1(test2);这是进行初始化。我们知道,初始化对象要用构造函数。可这儿呢?按理说,应该有声明为这样的构造函数:String(const String &);可是,我们并没有定义这个构造函数呀?答案是,C++提供了默认的复制构造函数,问题也就出在这儿。

(1):什么时候会调用复制构造函数呢?(以String类为例。)

在我们提供这样的代码:String test1(test2)时,它会被调用;当函数的参数列表为按值传递,也就是没有用引用和指针作为类型时,如:void show_String(const String),它会被调用。其实,还有一些情况,但在这儿就不列举了。

(2):它是什么样的函数。

它的作用就是把两个类进行复制。拿String类为例,C++提供的默认复制构造函数是这样的:

1
2
3
4
5
String(const String& a)
{
str=a.str;
len=a.len;
}

在平时,这样并不会有任何的问题出现,但我们用了new操作符,涉及到了动态内存分配,我们就不得不谈谈浅复制和深复制了。以上的函数就是实行的浅复制,它只是复制了指针,而并没有复制指针指向的数据,可谓一点儿用也没有。打个比方吧!就像一个朋友让你把一个程序通过网络发给他,而你大大咧咧地把快捷方式发给了他,有什么用处呢?我们来具体谈谈:

假如,A对象中存储了这样的字符串:”C++”。它的地址为2000。现在,我们把A对象赋给B对象:String B=A。现在,A和B对象的str指针均指向2000地址。看似可以使用,但如果B对象的析构函数被调用时,则地址2000处的字符串”C++”已经被从内存中抹去,而A对象仍然指向地址2000。这时,如果我们写下这样的代码:cout<<A<<endl;或是等待程序结束,A对象的析构函数被调用时,A对象的数据能否显示出来呢?只会是乱码。而且,程序还会这样做:连续对地址2000处使用两次delete操作符,这样的后果是十分严重的!

本例中,有这样的代码:

1
2
3
4
5
String* String1=new String(test1);

cout<<*String1<<endl;

delete String1;

假设test1中str指向的地址为2000,而String中str指针同样指向地址2000,我们删除了2000处的数据,而test1对象呢?已经被破坏了。大家从运行结果上可以看到,我们使用cout<<test1时,一点反应也没有。而在test1的析构函数被调用时,显示是这样:”这个字符串将被删除:”。

再看看这段代码:

1
2
3
cout<<"使用错误的函数:"<<endl;
show_String(test2);
cout<<test2<<endl;//这一段代码出现严重的错误!

show_String函数的参数列表void show_String(const String a)是按值传递的,所以,我们相当于执行了这样的代码:String a=test2;函数执行完毕,由于生存周期的缘故,对象a被析构函数删除,我们马上就可以看到错误的显示结果了:这个字符串将被删除:?=。当然,test2也被破坏了。解决的办法很简单,当然是手工定义一个复制构造函数喽!人力可以胜天!

1
2
3
4
5
6
String::String(const String& a)
{
len=a.len;
str=new char(len+1);
strcpy(str,a.str);
}

我们执行的是深复制。这个函数的功能是这样的:假设对象A中的str指针指向地址2000,内容为”I am a C++ Boy!”。我们执行代码String B=A时,我们先开辟出一块内存,假设为3000。我们用strcpy函数将地址2000的内容拷贝到地址3000中,再将对象B的str指针指向地址3000。这样,就互不干扰了。

大家把这个函数加入程序中,问题就解决了大半,但还没有完全解决,问题在赋值函数上。我们的程序中有这样的段代码:

1
2
String String3;
String3=test4;

经过我前面的讲解,大家应该也会对这段代码进行寻根摸底:凭什么可以这样做:String3=test4???原因是,C++为了用户的方便,提供的这样的一个操作符重载函数:operator=。所以,我们可以这样做。大家应该猜得到,它同样是执行了浅复制,出了同样的毛病。比如,执行了这段代码后,析构函数开始大展神威。由于这些变量是后进先出的,所以最后的String3变量先被删除:这个字符串将被删除:第四个范例。很正常。最后,删除到test4的时候,问题来了:这个字符串将被删除:?=。原因我不用赘述了,只是这个赋值函数怎么写,还有一点儿学问呢!大家请看:

平时,我们可以写这样的代码:x=y=z。(均为整型变量。)而在类对象中,我们同样要这样,因为这很方便。而对象A=B=C就是A.operator=(B.operator=(c))。而这个operator=函数的参数列表应该是:const String& a,所以,大家不难推出,要实现这样的功能,返回值也要是String&,这样才能实现A=B=C。我们先来写写看:

1
2
3
4
5
6
7
8
String& String::operator=(const String& a)
{
delete [] str;//先删除自身的数据
len=a.len;
str=new char[len+1];
strcpy(str,a.str);//此三行为进行拷贝
return *this;//返回自身的引用
}

是不是这样就行了呢?我们假如写出了这种代码:A=A,那么大家看看,岂不是把A对象的数据给删除了吗?这样可谓引发一系列的错误。所以,我们还要检查是否为自身赋值。只比较两对象的数据是不行了,因为两个对象的数据很有可能相同。我们应该比较地址。以下是完好的赋值函数:

1
2
3
4
5
6
7
8
9
10
String& String::operator=(const String& a)
{
if(this==&a)
return *this;
delete [] str;
len=a.len;
str=new char[len+1];
strcpy(str,a.str);
return *this;
}

把这些代码加入程序,问题就完全解决,下面是运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
下面分别输入三个范例:
第一个范例
第二个范例
第三个范例
第一个范例
这个字符串将被删除:第一个范例。
第一个范例
使用正确的函数:
第二个范例。
第二个范例。
使用错误的函数:
第二个范例。
这个字符串将被删除:第二个范例。
第二个范例。
String2: 第三个范例。
String3: 第四个范例。
下面,程序结束,析构函数将被调用。
这个字符串将被删除:第四个范例。
这个字符串将被删除:第三个范例。
这个字符串将被删除:第四个范例。
这个字符串将被删除:第三个范例。
这个字符串将被删除:第二个范例。
这个字符串将被删除:第一个范例。

如何对付内存泄漏?

写出那些不会导致任何内存泄漏的代码。很明显,当你的代码中到处充满了new 操作、delete操作和指针运算的话,你将会在某个地方搞晕了头,导致内存泄漏,指针引用错误,以及诸如此类的问题。这和你如何小心地对待内存分配工作其实完全没有关系:代码的复杂性最终总是会超过你能够付出的时间和努力。于是随后产生了一些成功的技巧,它们依赖于将内存分配(allocations)与重新分配(deallocation)工作隐藏在易于管理的类型之后。标准容器(standard containers)是一个优秀的例子。它们不是通过你而是自己为元素管理内存,从而避免了产生糟糕的结果。想象一下,没有string和vector的帮助,写出这个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<vector>
#include<string>
#include<iostream>
#include<algorithm>
using namespace std;
int main() // small program messing around with strings
{
cout << "enter some whitespace-separated words:"n";
vector<string> v;
string s;
while (cin>>s)
v.push_back(s);
sort(v.begin(),v.end());
string cat;
typedef vector<string>::const_iterator Iter;
for (Iter p = v.begin(); p!=v.end(); ++p)
cat += *p+"+";
cout << cat << '"n';
}

你有多少机会在第一次就得到正确的结果?你又怎么知道你没有导致内存泄漏呢?

注意,没有出现显式的内存管理,宏,造型,溢出检查,显式的长度限制,以及指针。通过使用函数对象和标准算法(standard algorithm),我可以避免使用指针————例如使用迭代子(iterator),不过对于一个这么小的程序来说有点小题大作了。

这些技巧并不完美,要系统化地使用它们也并不总是那么容易。但是,应用它们产生了惊人的差异,而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪。早在1981年,我就指出,通过将我必须显式地跟踪的对象的数量从几万个减少到几打,为了使程序正确运行而付出的努力从可怕的苦工,变成了应付一些可管理的对象,甚至更加简单了。

如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话,最快的途径也许就是先建立一个这样的库。

模板和标准库实现了容器、资源句柄以及诸如此类的东西,更早的使用甚至在多年以前。异常的使用使之更加完善。

如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。这里有个例子:我需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的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
#include<memory>
#include<iostream>
using namespace std;
struct S {
S() { cout << "make an S"n"; }
~S() { cout << "destroy an S"n"; }
S(const S&) { cout << "copy initialize an S"n"; }
S& operator=(const S&) { cout << "copy assign an S"n"; }
};
S* f()
{
return new S; // 谁该负责释放这个S?
};
auto_ptr<S> g()
{
return auto_ptr<S>(new S); // 显式传递负责释放这个S
}
int main()
{
cout << "start main"n";
S* p = f();
cout << "after f() before g()"n";
// S* q = g(); // 将被编译器捕捉
auto_ptr<S> q = g();
cout << "exit main"n";
// *p产生了内存泄漏
// *q被自动释放
}

在更一般的意义上考虑资源,而不仅仅是内存。

如果在你的环境中不能系统地应用这些技巧(例如,你必须使用别的地方的代码,或者你的程序的另一部分简直是原始人类(译注:原文是Neanderthals,尼安德特人,旧石器时代广泛分布在欧洲的猿人)写的,如此等等),那么注意使用一个内存泄漏检测器作为开发过程的一部分,或者插入一个垃圾收集器(garbage collector)。

浅谈C/C++内存泄漏及其检测工具

对于一个c/c++程序员来说,内存泄漏是一个常见的也是令人头疼的问题。已经有许多技术被研究出来以应对这个问题,比如Smart Pointer,Garbage Collection等。Smart Pointer技术比较成熟,STL中已经包含支持Smart Pointer的class,但是它的使用似乎并不广泛,而且它也不能解决所有的问题;Garbage Collection技术在Java中已经比较成熟,但是在c/c++领域的发展并不顺畅,虽然很早就有人思考在C++中也加入GC的支持。现实世界就是这样的,作为一个c/c++程序员,内存泄漏是你心中永远的痛。不过好在现在有许多工具能够帮助我们验证内存泄漏的存在,找出发生问题的代码。

内存泄漏的定义

一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显示释放的内存。应用程序一般使用malloc,realloc,new等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。以下这段小程序演示了堆内存发生泄漏的情形:

1
2
3
4
5
6
7
8
9
10
void MyFunction(int nSize)
{
char* p= new char[nSize];
if( !GetStringFrom( p, nSize ) ){
MessageBox(“Error”);
return;
}
…//using the string pointed by p;
delete p;
}

当函数GetStringFrom()返回零的时候,指针p指向的内存就不会被释放。这是一种常见的发生内存泄漏的情形。程序在入口处分配内存,在出口处释放内存,但是c函数可以在任何地方退出,所以一旦有某个出口处没有释放应该释放的内存,就会发生内存泄漏。

广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLE,GDI Object,SOCKET, Interface等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重。

GDI Object的泄漏是一种常见的资源泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
void CMyView::OnPaint( CDC* pDC )
{
CBitmap bmp;
CBitmap* pOldBmp;
bmp.LoadBitmap(IDB_MYBMP);
pOldBmp = pDC->SelectObject( &bmp );
...
if( Something() ){
return;
}
pDC->SelectObject( pOldBmp );
return;
}

当函数Something()返回非零的时候,程序在退出前没有把pOldBmp选回pDC中,这会导致pOldBmp指向的HBITMAP对象发生泄漏。这个程序如果长时间的运行,可能会导致整个系统花屏。这种问题在Win9x下比较容易暴露出来,因为Win9x的GDI堆比Win2k或NT的要小很多。

内存泄漏的发生方式

以发生的方式来分类,内存泄漏可以分为4类:

  1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。比如例二,如果Something()函数一直返回True,那么pOldBmp指向的HBITMAP对象总是发生泄漏。

  2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。比如例二,如果Something()函数只有在特定环境下才返回True,那么pOldBmp指向的HBITMAP对象并不总是发生泄漏。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

  3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。另一个例子:

1
2
3
4
5
6
7
8
char* g_lpszFileName = NULL;
void SetFileName( const char* lpcszFileName )
{
if( g_lpszFileName ){
free( g_lpszFileName );
}
g_lpszFileName = strdup( lpcszFileName );
}

如果程序在结束的时候没有释放g_lpszFileName指向的字符串,那么,即使多次调用SetFileName(),总会有一块内存,而且仅有一块内存发生泄漏。

  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
26
27
28
29
30
class Connection
{
public:
Connection( SOCKET s);
~Connection();
private:
SOCKET _socket;
};
class ConnectionManager
{
public:
ConnectionManager(){}
~ConnectionManager(){
list::iterator it;
for( it = _connlist.begin(); it != _connlist.end(); ++it ){
delete (*it);
}
_connlist.clear();
}
void OnClientConnected( SOCKET s ){
Connection* p = new Connection(s);
_connlist.push_back(p);
}
void OnClientDisconnected( Connection* pconn ){
_connlist.remove( pconn );
delete pconn;
}
private:
list _connlist;
};

假设在Client从Server端断开后,Server并没有呼叫OnClientDisconnected()函数,那么代表那次连接的Connection对象就不会被及时的删除(在Server程序退出的时候,所有Connection对象会在ConnectionManager的析构函数里被删除)。当不断的有连接建立、断开时隐式内存泄漏就发生了。

从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积,而隐式内存泄漏危害性则非常大,因为较之于常发性和偶发性内存泄漏它更难被检测到。

检测内存泄漏

检测内存泄漏的关键是要能截获住对分配内存和释放内存的函数的调用。截获住这两个函数,我们就能跟踪每一块内存的生命周期,比如,每当成功的分配一块内存后,就把它的指针加入一个全局的list中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。这里只是简单的描述了检测内存泄漏的基本原理,详细的算法可以参见Steve Maguire的<>。

如果要检测堆内存的泄漏,那么需要截获住malloc/realloc/free和new/delete就可以了(其实new/delete最终也是用malloc/free的,所以只要截获前面一组即可)。对于其他的泄漏,可以采用类似的方法,截获住相应的分配和释放函数。比如,要检测BSTR的泄漏,就需要截获SysAllocString/SysFreeString;要检测HMENU的泄漏,就需要截获CreateMenu/ DestroyMenu。(有的资源的分配函数有多个,释放函数只有一个,比如,SysAllocStringLen也可以用来分配BSTR,这时就需要截获多个分配函数)

在Windows平台下,检测内存泄漏的工具常用的一般有三种,MS C-Runtime Library内建的检测功能;外挂式的检测工具,诸如,Purify,BoundsChecker等;利用Windows NT自带的Performance Monitor。这三种工具各有优缺点,MS C-Runtime Library虽然功能上较之外挂式的工具要弱,但是它是免费的;Performance Monitor虽然无法标示出发生问题的代码,但是它能检测出隐式的内存泄漏的存在,这是其他两类工具无能为力的地方。

以下我们详细讨论这三种检测工具:

VC下内存泄漏的检测方法

用MFC开发的应用程序,在DEBUG版模式下编译后,都会自动加入内存泄漏的检测代码。在程序结束后,如果发生了内存泄漏,在Debug窗口中会显示出所有发生泄漏的内存块的信息,以下两行显示了一块被泄漏的内存块的信息:

1
2
E:"TestMemLeak"TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.
Data: <abcdefghijklmnop> 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70

第一行显示该内存块由TestDlg.cpp文件,第70行代码分配,地址在0x00881710,大小为200字节,{59}是指调用内存分配函数的Request Order,关于它的详细信息可以参见MSDN中_CrtSetBreakAlloc()的帮助。第二行显示该内存块前16个字节的内容,尖括号内是以ASCII方式显示,接着的是以16进制方式显示。

一般大家都误以为这些内存泄漏的检测功能是由MFC提供的,其实不然。MFC只是封装和利用了MS C-Runtime Library的Debug Function。非MFC程序也可以利用MS C-Runtime Library的Debug Function加入内存泄漏的检测功能。MS C-Runtime Library在实现malloc/free,strdup等函数时已经内建了内存泄漏的检测功能。

注意观察一下由MFC Application Wizard生成的项目,在每一个cpp文件的头部都有这样一段宏定义:

1
2
3
4
5
6
7
8
9
#ifdef _DEBUG

#define new DEBUG_NEW

#undef THIS_FILE

static char THIS_FILE[] = __FILE__;

#endif

有了这样的定义,在编译DEBUG版时,出现在这个cpp文件中的所有new都被替换成DEBUG_NEW了。那么DEBUG_NEW是什么呢?DEBUG_NEW也是一个宏,以下摘自afx.h,1632行

1
#define DEBUG_NEW new(THIS_FILE, __LINE__)

所以如果有这样一行代码:

1
char* p = new char[200];

经过宏替换就变成了:

1
char* p = new( THIS_FILE, __LINE__)char[200];

根据C++的标准,对于以上的new的使用方法,编译器会去找这样定义的operator new:

1
void* operator new(size_t, LPCSTR, int)

我们在afxmem.cpp 63行找到了一个这样的operator new 的实现

1
2
3
4
5
6
7
8
9
10
11
void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine)
{
return ::operator new(nSize, _NORMAL_BLOCK, lpszFileName, nLine);
}
void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine)
{
pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine);
if (pResult != NULL)
return pResult;
...
}

第二个operator new函数比较长,为了简单期间,我只摘录了部分。很显然最后的内存分配还是通过_malloc_dbg函数实现的,这个函数属于MS C-Runtime Library 的Debug Function。这个函数不但要求传入内存的大小,另外还有文件名和行号两个参数。文件名和行号就是用来记录此次分配是由哪一段代码造成的。如果这块内存在程序结束之前没有被释放,那么这些信息就会输出到Debug窗口里。

这里顺便提一下THIS_FILE,FILE和LINEFILELINE都是编译器定义的宏。当碰到FILE时,编译器会把FILE替换成一个字符串,这个字符串就是当前在编译的文件的路径名。当碰到LINE时,编译器会把LINE替换成一个数字,这个数字就是当前这行代码的行号。在DEBUG_NEW的定义中没有直接使用FILE,而是用了THIS_FILE,其目的是为了减小目标文件的大小。假设在某个cpp文件中有100处使用了new,如果直接使用FILE__,那编译器会产生100个常量字符串,这100个字符串都是cpp文件的路径名,显然十分冗余。如果使用THIS_FILE,编译器只会产生一个常量字符串,那100处new的调用使用的都是指向常量字符串的指针。

再次观察一下由MFC Application Wizard生成的项目,我们会发现在cpp文件中只对new做了映射,如果你在程序中直接使用malloc函数分配内存,调用malloc的文件名和行号是不会被记录下来的。如果这块内存发生了泄漏,MS C-Runtime Library仍然能检测到,但是当输出这块内存块的信息,不会包含分配它的的文件名和行号。

要在非MFC程序中打开内存泄漏的检测功能非常容易,你只要在程序的入口处加入以下几行代码:

1
2
3
int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG );
tmpFlag |= _CRTDBG_LEAK_CHECK_DF;
_CrtSetDbgFlag( tmpFlag );

这样,在程序结束的时候,也就是winmain,main或dllmain函数返回之后,如果还有内存块没有释放,它们的信息会被打印到Debug窗口里。

如果你试着创建了一个非MFC应用程序,而且在程序的入口处加入了以上代码,并且故意在程序中不释放某些内存块,你会在Debug窗口里看到以下的信息:

1
2
3
{47} normal block at 0x00C91C90, 200 bytes long.

Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

内存泄漏的确检测到了,但是和上面MFC程序的例子相比,缺少了文件名和行号。对于一个比较大的程序,没有这些信息,解决问题将变得十分困难。

为了能够知道泄漏的内存块是在哪里分配的,你需要实现类似MFC的映射功能,把new,maolloc等函数映射到_malloc_dbg函数上。这里我不再赘述,你可以参考MFC的源代码。

由于Debug Function实现在MS C-RuntimeLibrary中,所以它只能检测到堆内存的泄漏,而且只限于malloc,realloc或strdup等分配的内存,而那些系统资源,比如HANDLE,GDI Object,或是不通过C-Runtime Library分配的内存,比如VARIANT,BSTR的泄漏,它是无法检测到的,这是这种检测法的一个重大的局限性。另外,为了能记录内存块是在哪里分配的,源代码必须相应的配合,这在调试一些老的程序非常麻烦,毕竟修改源代码不是一件省心的事,这是这种检测法的另一个局限性。

对于开发一个大型的程序,MS C-Runtime Library提供的检测功能是远远不够的。接下来我们就看看外挂式的检测工具。我用的比较多的是BoundsChecker,一则因为它的功能比较全面,更重要的是它的稳定性。这类工具如果不稳定,反而会忙里添乱。到底是出自鼎鼎大名的NuMega,我用下来基本上没有什么大问题。

使用BoundsChecker检测内存泄漏

BoundsChecker采用一种被称为 Code Injection的技术,来截获对分配内存和释放内存的函数的调用。简单地说,当你的程序开始运行时,BoundsChecker的DLL被自动载入进程的地址空间(这可以通过system-level的Hook实现),然后它会修改进程中对内存分配和释放的函数调用,让这些调用首先转入它的代码,然后再执行原来的代码。BoundsChecker在做这些动作的时,无须修改被调试程序的源代码或工程配置文件,这使得使用它非常的简便、直接。

这里我们以malloc函数为例,截获其他的函数方法与此类似。

需要被截获的函数可能在DLL中,也可能在程序的代码里。比如,如果静态连结C-Runtime Library,那么malloc函数的代码会被连结到程序里。为了截获住对这类函数的调用,BoundsChecker会动态修改这些函数的指令。

以下两段汇编代码,一段没有BoundsChecker介入,另一段则有BoundsChecker的介入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
126: _CRTIMP void * __cdecl malloc (
127: size_t nSize
128: )
129: {
00403C10 push ebp
00403C11 mov ebp,esp
130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0);
00403C13 push 0
00403C15 push 0
00403C17 push 1
00403C19 mov eax,[__newmode (0042376c)]
00403C1E push eax
00403C1F mov ecx,dword ptr [nSize]
00403C22 push ecx
00403C23 call _nh_malloc_dbg (00403c80)
00403C28 add esp,14h
131: }

以下这一段代码有BoundsChecker介入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
126: _CRTIMP void * __cdecl malloc (
127: size_t nSize
128: )
129: {
00403C10 jmp 01F41EC8
00403C15 push 0
00403C17 push 1
00403C19 mov eax,[__newmode (0042376c)]
00403C1E push eax
00403C1F mov ecx,dword ptr [nSize]
00403C22 push ecx
00403C23 call _nh_malloc_dbg (00403c80)
00403C28 add esp,14h
131: }

当BoundsChecker介入后,函数malloc的前三条汇编指令被替换成一条jmp指令,原来的三条指令被搬到地址01F41EC8处了。当程序进入malloc后先jmp到01F41EC8,执行原来的三条指令,然后就是BoundsChecker的天下了。大致上它会先记录函数的返回地址(函数的返回地址在stack上,所以很容易修改),然后把返回地址指向属于BoundsChecker的代码,接着跳到malloc函数原来的指令,也就是在00403c15的地方。当malloc函数结束的时候,由于返回地址被修改,它会返回到BoundsChecker的代码中,此时BoundsChecker会记录由malloc分配的内存的指针,然后再跳转到到原来的返回地址去。

如果内存分配/释放函数在DLL中,BoundsChecker则采用另一种方法来截获对这些函数的调用。BoundsChecker通过修改程序的DLL Import Table让table中的函数地址指向自己的地址,以达到截获的目的。

截获住这些分配和释放函数,BoundsChecker就能记录被分配的内存或资源的生命周期。接下来的问题是如何与源代码相关,也就是说当BoundsChecker检测到内存泄漏,它如何报告这块内存块是哪段代码分配的。答案是调试信息(Debug Information)。当我们编译一个Debug版的程序时,编译器会把源代码和二进制代码之间的对应关系记录下来,放到一个单独的文件里(.pdb)或者直接连结进目标程序,通过直接读取调试信息就能得到分配某块内存的源代码在哪个文件,哪一行上。使用Code Injection和Debug Information,使BoundsChecker不但能记录呼叫分配函数的源代码的位置,而且还能记录分配时的Call Stack,以及Call Stack上的函数的源代码位置。这在使用像MFC这样的类库时非常有用,以下我用一个例子来说明:

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
void ShowXItemMenu()
{
...
CMenu menu;
menu.CreatePopupMenu();
//add menu items.
menu.TrackPropupMenu();
...
}
void ShowYItemMenu( )
{
...
CMenu menu;
menu.CreatePopupMenu();
//add menu items.
menu.TrackPropupMenu();
menu.Detach();//this will cause HMENU leak
...
}
BOOL CMenu::CreatePopupMenu()
{
...
hMenu = CreatePopupMenu();
...
}

当调用ShowYItemMenu()时,我们故意造成HMENU的泄漏。但是,对于BoundsChecker来说被泄漏的HMENU是在class CMenu::CreatePopupMenu()中分配的。假设的你的程序有许多地方使用了CMenu的CreatePopupMenu()函数,如CMenu::CreatePopupMenu()造成的,你依然无法确认问题的根结到底在哪里,在ShowXItemMenu()中还是在ShowYItemMenu()中,或者还有其它的地方也使用了CreatePopupMenu()?有了Call Stack的信息,问题就容易了。BoundsChecker会如下报告泄漏的HMENU的信息:

1
2
3
4
5
6
7
8
9
Function
File
Line
CMenu::CreatePopupMenu
E:"8168"vc98"mfc"mfc"include"afxwin1.inl
1009
ShowYItemMenu
E:"testmemleak"mytest.cpp
100

这里省略了其他的函数调用

如此,我们很容易找到发生问题的函数是ShowYItemMenu()。当使用MFC之类的类库编程时,大部分的API调用都被封装在类库的class里,有了Call Stack信息,我们就可以非常容易的追踪到真正发生泄漏的代码。

记录Call Stack信息会使程序的运行变得非常慢,因此默认情况下BoundsChecker不会记录Call Stack信息。可以按照以下的步骤打开记录Call Stack信息的选项开关:

  1. 打开菜单:BoundsChecker|Setting…
  2. 在Error Detection页中,在Error Detection Scheme的List中选择Custom
  3. 在Category的Combox中选择 Pointer and leak error check
  4. 钩上Report Call Stack复选框
  5. 点击Ok

基于Code Injection,BoundsChecker还提供了API Parameter的校验功能,memory over run等功能。这些功能对于程序的开发都非常有益。由于这些内容不属于本文的主题,所以不在此详述了。

尽管BoundsChecker的功能如此强大,但是面对隐式内存泄漏仍然显得苍白无力。所以接下来我们看看如何用Performance Monitor检测内存泄漏。

使用Performance Monitor检测内存泄漏

NT的内核在设计过程中已经加入了系统监视功能,比如CPU的使用率,内存的使用情况,I/O操作的频繁度等都作为一个个Counter,应用程序可以通过读取这些Counter了解整个系统的或者某个进程的运行状况。Performance Monitor就是这样一个应用程序。

为了检测内存泄漏,我们一般可以监视Process对象的Handle Count,Virutal Bytes 和Working Set三个Counter。Handle Count记录了进程当前打开的HANDLE的个数,监视这个Counter有助于我们发现程序是否有Handle泄漏;Virtual Bytes记录了该进程当前在虚地址空间上使用的虚拟内存的大小,NT的内存分配采用了两步走的方法,首先,在虚地址空间上保留一段空间,这时操作系统并没有分配物理内存,只是保留了一段地址。然后,再提交这段空间,这时操作系统才会分配物理内存。所以,Virtual Bytes一般总大于程序的Working Set。监视Virutal Bytes可以帮助我们发现一些系统底层的问题; Working Set记录了操作系统为进程已提交的内存的总量,这个值和程序申请的内存总量存在密切的关系,如果程序存在内存的泄漏这个值会持续增加,但是Virtual Bytes却是跳跃式增加的。

监视这些Counter可以让我们了解进程使用内存的情况,如果发生了泄漏,即使是隐式内存泄漏,这些Counter的值也会持续增加。但是,我们知道有问题却不知道哪里有问题,所以一般使用Performance Monitor来验证是否有内存泄漏,而使用BoundsChecker来找到和解决。

当Performance Monitor显示有内存泄漏,而BoundsChecker却无法检测到,这时有两种可能:第一种,发生了偶发性内存泄漏。这时你要确保使用Performance Monitor和使用BoundsChecker时,程序的运行环境和操作方法是一致的。第二种,发生了隐式的内存泄漏。这时你要重新审查程序的设计,然后仔细研究Performance Monitor记录的Counter的值的变化图,分析其中的变化和程序运行逻辑的关系,找到一些可能的原因。这是一个痛苦的过程,充满了假设、猜想、验证、失败,但这也是一个积累经验的绝好机会。

探讨C++内存回收

C++内存对象大会战

如果一个人自称为程序高手,却对内存一无所知,那么我可以告诉你,他一定在吹牛。用C或C++写程序,需要更多地关注内存,这不仅仅是因为内存的分配是否合理直接影响着程序的效率和性能,更为主要的是,当我们操作内存的时候一不小心就会出现问题,而且很多时候,这些问题都是不易发觉的,比如内存泄漏,比如悬挂指针。笔者今天在这里并不是要讨论如何避免这些问题,而是想从另外一个角度来认识C++内存对象。

我们知道,C++将内存划分为三个逻辑区域:堆、栈和静态存储区。既然如此,我称位于它们之中的对象分别为堆对象,栈对象以及静态对象。那么这些不同的内存对象有什么区别了?堆对象和栈对象各有什么优劣了?如何禁止创建堆对象或栈对象了?这些便是今天的主题。

基本概念

先来看看栈。栈,一般用于存放局部变量或对象,如我们在函数定义中用类似下面语句声明的对象:

1
Type stack_object ; 

stack_object便是一个栈对象,它的生命期是从定义点开始,当所在函数返回时,生命结束。

另外,几乎所有的临时对象都是栈对象。比如,下面的函数定义:

1
Type fun(Type object);

这个函数至少产生两个临时对象,首先,参数是按值传递的,所以会调用拷贝构造函数生成一个临时对象object_copy1 ,在函数内部使用的不是使用的不是object,而是object_copy1,自然,object_copy1是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化(NRV),那么也会产生一个临时对象object_copy2,这个临时对象会在函数返回后一段时间内被释放。比如某个函数中有如下代码:

1
2
3
Type tt ,result ; //生成两个栈对象

tt = fun(tt); //函数返回时,生成的是一个临时对象object_copy2

上面的第二个语句的执行情况是这样的,首先函数fun返回时生成一个临时对象object_copy2 ,然后再调用赋值运算符执行

1
tt = object_copy2 ; //调用赋值运算符

看到了吗?编译器在我们毫无知觉的情况下,为我们生成了这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的,所以,你也许明白了,为什么对于”大”对象最好用const引用传递代替按值进行函数参数传递了。

接下来,看看堆。堆,又叫自由存储区,它是在程序执行的过程中动态分配的,所以它最大的特性就是动态性。在C++中,所有堆对象的创建和销毁都要由程序员负责,所以,如果处理不好,就会发生内存问题。如果分配了堆对象,却忘记了释放,就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为NULL,该指针就是所谓的”悬挂指针”,再度使用此指针时,就会出现非法访问,严重时就导致程序崩溃。

那么,C++中是怎样分配堆对象的?唯一的方法就是用new(当然,用类malloc指令也可获得C式堆内存),只要使用new,就会在堆中分配一块内存,并且返回指向该堆对象的指针。

再来看看静态存储区。所有的静态对象、全局对象都于静态存储区分配。关于全局对象,是在main()函数执行前就分配好了的。其实,在main()函数中的显示代码执行之前,会调用一个由编译器生成的_main()函数,而_main()函数会进行所有全局对象的的构造及初始化工作。而在main()函数结束之前,会调用由编译器生成的exit函数,来释放所有的全局对象。比如下面的代码:

1
2
3
4
void main(void)
{
 ... ...// 显式代码
}

实际上,被转化成这样:

1
2
3
4
5
6
7
void main(void)
{
_main(); //隐式代码,由编译器产生,用以构造所有全局对象
... ... // 显式代码
... ...
exit() ; // 隐式代码,由编译器产生,用以释放所有全局对象
}

所以,知道了这个之后,便可以由此引出一些技巧,如,假设我们要在main()函数执行之前做某些准备工作,那么我们可以将这些准备工作写到一个自定义的全局对象的构造函数中,这样,在main()函数的显式代码执行之前,这个全局对象的构造函数会被调用,执行预期的动作,这样就达到了我们的目的。 刚才讲的是静态存储区中的全局对象,那么,局部静态对象了?局部静态对象通常也是在函数中定义的,就像栈对象一样,只不过,其前面多了个static关键字。局部静态对象的生命期是从其所在函数第一次被调用,更确切地说,是当第一次执行到该静态对象的声明代码时,产生该静态局部对象,直到整个程序结束时,才销毁该对象。

还有一种静态对象,那就是它作为class的静态成员。考虑这种情况时,就牵涉了一些较复杂的问题。

第一个问题是class的静态成员对象的生命期,class的静态成员对象随着第一个class object的产生而产生,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了一个class,该类中有一个静态对象作为成员,但是在程序执行过程中,如果我们没有创建任何一个该class object,那么也就不会产生该class所包含的那个静态对象。还有,如果创建了多个class object,那么所有这些object都共享那个静态对象成员。

第二个问题是,当出现下列情况时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base
{
public:
static Type s_object ;
}
class Derived1 : public Base / / 公共继承
{
 ... ...// other data
}
class Derived2 : public Base / / 公共继承
{
 ... ...// other data
}
Base example ;
Derivde1 example1 ;
Derivde2 example2 ;
**example.s_object = ...... ;**
**example1.s_object = ...... ;**
**example2.s_object = ...... ; **

请注意上面标为黑体的三条语句,它们所访问的s_object是同一个对象吗?答案是肯定的,它们的确是指向同一个对象,这听起来不像是真的,是吗?但这是事实,你可以自己写段简单的代码验证一下。我要做的是来解释为什么会这样? 我们知道,当一个类比如Derived1,从另一个类比如Base继承时,那么,可以看作一个Derived1对象中含有一个Base型的对象,这就是一个subobject。一个Derived1对象的大致内存布局如下:

让我们想想,当我们将一个Derived1型的对象传给一个接受非引用Base型参数的函数时会发生切割,那么是怎么切割的呢?相信现在你已经知道了,那就是仅仅取出了Derived1型的对象中的subobject,而忽略了所有Derived1自定义的其它数据成员,然后将这个subobject传递给函数(实际上,函数中使用的是这个subobject的拷贝)。

所有继承Base类的派生类的对象都含有一个Base型的subobject(这是能用Base型指针指向一个Derived1对象的关键所在,自然也是多态的关键了),而所有的subobject和所有Base型的对象都共用同一个s_object对象,自然,从Base类派生的整个继承体系中的类的实例都会共用同一个s_object对象了。上面提到的example、example1、example2的对象布局如下图所示:

三种内存对象的比较

栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,会调用operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是1MB~2MB,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。

堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。

接下来看看static对象。

首先是全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如C#,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。C++也完全可以剔除全局对象,但是最终没有,我想原因之一是为了兼容C。

其次是类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些class之间或这些class objects之间进行数据共享或通信时,这样的静态成员无疑是很好的选择。

接着是静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个nonstatic局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为nonstatic局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。

在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。

使用栈对象的意外收获

前面已经介绍到,栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。你也许说,这些都很正常啊,没什么大不了的。是的,没什么大不了的。但是只要我们再深入一点点,也许就有意外的收获了。

栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为栈对象可以自动的释放资源,即使在所在函数发生异常的时候。实际的过程是这样的:函数抛出异常时,会发生所谓的stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针(auto_ptr)中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。

禁止产生堆对象

上面已经提到,你决定禁止产生某种类型的堆对象,这时你可以自己创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。

那么怎样禁止产生堆对象了?我们已经知道,产生堆对象的唯一方法是使用new操作,如果我们禁止使用new不就行了么。再进一步,new操作执行时会调用operator new,而operator new是可以重载的。方法有了,就是使new operator 为private,为了对称,最好将operator delete也重载为private。现在,你也许又有疑问了,难道创建栈对象不需要调用new吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。好,让我们看看下面的示例代码:

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
#include <stdlib.h> //需要用到C式内存分配函数
class Resource ; //代表需要被封装的资源类
class NoHashObject
{
private:
Resource* ptr ;//指向被封装的资源
... ... //其它数据成员
void* operator new(size_t size) //非严格实现,仅作示意之用
{
return malloc(size) ;
}
void operator delete(void* pp) //非严格实现,仅作示意之用
{
free(pp) ;
}
public:
NoHashObject()
{
//此处可以获得需要封装的资源,并让ptr指针指向该资源
ptr = new Resource() ;
}
~NoHashObject()
{
delete ptr ; //释放封装的资源
}
};

NoHashObject现在就是一个禁止堆对象的类了,如果你写下如下代码:

1
2
NoHashObject* fp = new NoHashObject() ; //编译期错误!
delete fp ;

上面代码会产生编译期错误。好了,现在你已经知道了如何设计一个禁止堆对象的类了,你也许和我一样有这样的疑问,难道在类NoHashObject的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,我称之为”暴力破解法”。C++是如此地强大,强大到你可以用它做你想做的任何事情。这里主要用到的是技巧是指针类型的强制转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void main(void)
{
char* temp = new char[sizeof(NoHashObject)] ;
//强制类型转换,现在ptr是一个指向NoHashObject对象的指针
NoHashObject* obj_ptr = (NoHashObject*)temp ;
temp = NULL ; //防止通过temp指针修改NoHashObject对象
//再一次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员
Resource* rp = (Resource*)obj_ptr ;
//初始化obj_ptr指向的NoHashObject对象的ptr成员
rp = new Resource() ;
//现在可以通过使用obj_ptr指针使用堆中的NoHashObject对象成员了
.. ...
delete rp ;//释放资源
temp = (char*)obj_ptr ;
obj_ptr = NULL ;//防止悬挂指针产生
delete [] temp ;//释放NoHashObject对象所占的堆空间。
}

上面的实现是麻烦的,而且这种实现方式几乎不会在实践中使用,但是我还是写出来路,因为理解它,对于我们理解C++内存对象是有好处的。对于上面的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:

某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。

所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。

另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将NoHashObject的ptr指针成员安排在对象空间的头4个字节,这样才会保证下面这条语句的转换动作像我们预期的那样执行:

1
Resource* rp = (Resource*)obj_ptr ; 

但是,并不一定所有的编译器都是如此。

既然我们可以禁止产生某种类型的堆对象,那么可以设计一个类,使之不能产生栈对象吗?当然可以。

禁止产生栈对象

前面已经提到了,创建栈对象时会移动栈顶指针以”挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator new/delete操作的,所以将operator new/delete设置为private不能达到目的。当然从上面的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了。

这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚,那就是,如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为private。再进一步,将析构函数设为private除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。

如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private。

为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:

1
2
3
4
5
6
7
8
9
10
class NoStackObject
{
protected:
~NoStackObject() { }
public:
void destroy()
{
delete this ;//调用保护析构函数
}
};

接着,可以像这样使用NoStackObject类:

1
2
3
NoStackObject* hash_ptr = new NoStackObject() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;

呵呵,是不是觉得有点怪怪的,我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。很显然,用户是不习惯这种怪异的使用方式的。所以,我决定将构造函数也设为private或protected。这又回到了上面曾试图避免的问题,即不用new,那么该用什么方式来生成一个对象了?我们可以用间接的办法完成,即让这个类提供一个static成员函数专门用于产生该类型的堆对象。(设计模式中的singleton模式就可以用这种方式实现。)让我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class NoStackObject
{
protected:
NoStackObject() { }
~NoStackObject() { }
public:
static NoStackObject* creatInstance()
{
return new NoStackObject() ;//调用保护的构造函数
}
void destroy()
{
delete this ;//调用保护的析构函数
}
};

现在可以这样使用NoStackObject类了:

1
2
3
4
NoStackObject* hash_ptr = NoStackObject::creatInstance() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;
hash_ptr = NULL ; //防止使用悬挂指针

现在感觉是不是好多了,生成对象和释放对象的操作一致了。

浅议C++ 中的垃圾回收方法

许多 C 或者 C++ 程序员对垃圾回收嗤之以鼻,认为垃圾回收肯定比自己来管理动态内存要低效,而且在回收的时候一定会让程序停顿在那里,而如果自己控制内存管理的话,分配和释放时间都是稳定的,不会导致程序停顿。最后,很多 C/C++ 程序员坚信在C/C++ 中无法实现垃圾回收机制。这些错误的观点都是由于不了解垃圾回收的算法而臆想出来的。

其实垃圾回收机制并不慢,甚至比动态内存分配更高效。因为我们可以只分配不释放,那么分配内存的时候只需要从堆上一直的获得新的内存,移动堆顶的指针就够了;而释放的过程被省略了,自然也加快了速度。现代的垃圾回收算法已经发展了很多,增量收集算法已经可以让垃圾回收过程分段进行,避免打断程序的运行了。而传统的动态内存管理的算法同样有在适当的时间收集内存碎片的工作要做,并不比垃圾回收更有优势。

而垃圾回收的算法的基础通常基于扫描并标记当前可能被使用的所有内存块,从已经被分配的所有内存中把未标记的内存回收来做的。C/C++ 中无法实现垃圾回收的观点通常基于无法正确扫描出所有可能还会被使用的内存块,但是,看似不可能的事情实际上实现起来却并不复杂。首先,通过扫描内存的数据,指向堆上动态分配出来内存的指针是很容易被识别出来的,如果有识别错误,也只能是把一些不是指针的数据当成指针,而不会把指针当成非指针数据。这样,回收垃圾的过程只会漏回收掉而不会错误的把不应该回收的内存清理。其次,如果回溯所有内存块被引用的根,只可能存在于全局变量和当前的栈内,而全局变量(包括函数内的静态变量)都是集中存在于 bss 段或 data段中。

垃圾回收的时候,只需要扫描 bss 段, data 段以及当前被使用着的栈空间,找到可能是动态内存指针的量,把引用到的内存递归扫描就可以得到当前正在使用的所有动态内存了。

如果肯为你的工程实现一个不错的垃圾回收器,提高内存管理的速度,甚至减少总的内存消耗都是可能的。如果有兴趣的话,可以搜索一下网上已有的关于垃圾回收的论文和实现了的库,开拓视野对一个程序员尤为重要。

C++虚继承

概念

为了解决从不同途径继承来的同名的数据成员在内存中有不同的拷贝造成数据不一致问题,将共同基类设置为虚基类。这时从不同的路径继承过来的同名数据成员在内存中就只有一个拷贝,同一个函数名也只有一个映射。这样不仅就解决了二义性问题,也节省了内存,避免了数据不一致的问题。

class 派生类名:virtual 继承方式 基类名

virtual是关键字,声明该基类为派生类的虚基类。

在多继承情况下,虚基类关键字的作用范围和继承方式关键字相同,只对紧跟其后的基类起作用。

声明了虚基类之后,虚基类在进一步派生过程中始终和派生类一起,维护同一个基类子对象的拷贝。

底层实现原理与编译器相关,一般通过虚基类指针虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。

实际上,vbptr 指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

执行顺序

首先执行虚基类的构造函数,多个虚基类的构造函数按照被继承的顺序构造;

执行基类的构造函数,多个基类的构造函数按照被继承的顺序构造;

执行成员对象的构造函数,多个成员对象的构造函数按照申明的顺序构造;

执行派生类自己的构造函数;

析构以与构造相反的顺序执行;

mark

从虚基类直接或间接派生的派生类中的构造函数的成员初始化列表中都要列出对虚基类构造函数的调用。但只有用于建立对象的最派生类的构造函数调用虚基类的构造函数,而该派生类的所有基类中列出的对虚基类的构造函数的调用在执行中被忽略,从而保证对虚基类子对象只初始化一次。

在一个成员初始化列表中同时出现对虚基类和非虚基类构造函数的调用时,虚基类的构造函数先于非虚基类的构造函数执行。

虚继承与继承的差异

首先,虚拟继承与普通继承的区别有:

假设derived 继承自base类,那么derived与base是一种“is a”的关系,即derived类是base类,而反之错误;

假设derived 虚继承自base类,那么derivd与base是一种“has a”的关系,即derived类有一个指向base类的vptr。(貌似有些牵强!某些编译器确实如此,关于虚继承与普通继承的差异见: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
class stream
{
public:
stream(){cout<<"stream::stream()!"<<endl;}
};

class iistream:virtual stream
{
public:
iistream(){cout<<"istream::istream()!"<<endl;}
};

class oostream:virtual stream
{
public:
oostream(){cout<<"ostream::ostream()!"<<endl;}
};

class iiostream:public iistream,public oostream
{
public:
iiostream(){cout<<"iiostream::iiostream()!"<<endl;}
};

int main(int argc, const char * argv[])
{
iiostream oo;
}

程序运行的输出结果为:

1
2
3
4
stream::stream()!
istream::istream()!
ostream::ostream()!
iiostream::iiostream()!   

输出这样的结果是毫无悬念的!本来虚拟继承的目的就是当多重继承出现重复的基类时,其只保存一份基类。减少内存开销。其继承结构为:

1
2
3
4
5
6
7
8
9
            stream 

           /      \   

     istream   ostream   

           \      /

           iiostream  

这样子的菱形结构,使公共基类只产生一个拷贝。

从基类 stream 派生新类时,使用 virtual 将类stream说明为虚基类,这时派生类istream、ostream包含一个指向虚基类的vptr,而不会产生实际的stream空间。所以最终iiostream也含有一个指向虚基类的vptr,调用stream中的成员方法时,通过vptr去调用,不会产生二义性。
而现在我们换种方式使用虚继承:

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
class stream
{
public:
stream(){cout<<"stream::stream()!"<<endl;}
};

class iistream:public stream
{
public:
iistream(){cout<<"istream::istream()!"<<endl;}
};

class oostream:public stream
{
public:
oostream(){cout<<"ostream::ostream()!"<<endl;}
};

class iiostream:virtual iistream,virtual oostream
{
public:
iiostream(){cout<<"iiostream::iiostream()!"<<endl;}
};

int main(int argc, const char * argv[])
{
iiostream oo;
}

其输出结果为:

1
2
3
4
5
stream::stream()!
istream::istream()!
stream::stream()!
ostream::ostream()!
iiostream::iiostream()!

从结果可以看到,其构造过程中重复出现基类stream的构造过程。这样就完全没有达到虚拟继承的目的。其继承结构为:

1
2
3
4
5
6
7
8
9
stream      stream                                                                    

\            /                               

istream    ostream                                      

 \          /                                                             

   iiostream  

从继承结构可以看出,如果iiostream对象调用基类stream中的成员方法,会导致方法的二义性。因为iiostream含有指向其虚继承基类 istream,ostream的vptr。而 istream,ostream包含了stream的空间,所以导致iiostream不知道导致是调用那个stream的方法。要解决改问题,可以指定vptr,即在调用成员方法是需要加上作用域,例如

1
2
3
4
5
6
7
8
9
class stream
{
void f(){cout<<"here!"<<endl;}
}
main()
{
iiostream ii;
ii.f();
}

编译器提示调用f方法错误。而采用

1
ii.istream::f();

编译通过,并且会调用istream类vptr指向的f()方法。 前面说了这么多,在实际的应用中虚拟继承的胡乱使用,更是会导致继承顺序以及基类构造顺序的混乱。如下面的代码:

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
class B1
{
public:
B1(){cout<<"B1::B1()!<"<<endl;}
void f() {cout<<"i'm here!"<<endl;}
};

class V1: public B1
{
public:
V1(){cout<<"V1::V1()!<"<<endl;}
};

class D1: virtual public V1
{
public:
D1(){cout<<"D1::D1()!<"<<endl;}
};

class B2
{
public:
B2(){cout<<"B2::B2()!<"<<endl;}
};

class B3
{
public:
B3(){cout<<"B3::B3()!<"<<endl;}
};

class V2:public B1, public B2
{
public:
V2(){cout<<"V2::V2()!<"<<endl;}
};

class D2:virtual public V2, public B3
{
public:
D2(){cout<<"D2::D2()!<"<<endl;}
};

class M1
{
public:
M1(){cout<<"M1::M1()!<"<<endl;}
};

class M2
{
public:
M2(){cout<<"M2::M2()!<"<<endl;}
};

class X:public D1, public D2
{
M1 m1;
M2 m2;
};
int main(int argc, const char * argv[])
{
X x;
}

上面的代码是来自《Exceptional C++ Style》中关于继承顺序的一段代码。可以看到,上面的代码继承关系非常复杂,而且层次不是特别的清楚。而虚继承的加入更是让继承结构更加无序。不管怎么样,我们还是可以根据c++的标准来分析上面代码的构造顺序。c++对于创建一个类类型的初始化顺序是这样子的:

  1. 最上层派生类的构造函数负责调用虚基类子对象的构造函数。所有虚基类子对象会按照深度优先、从左到右的顺序进行初始化;
  2. 直接基类子对象按照它们在类定义中声明的顺序被一一构造起来;
  3. 非静态成员子对象按照它们在类定义体中的声明的顺序被一一构造起来;
  4. 最上层派生类的构造函数体被执行。

根据上面的规则,可以看出,最先构造的是虚继承基类的构造函数,并且是按照深度优先,从左往右构造。因此,我们需要将继承结构划分层次。显然上面的代码可以认为是4层继承结构。其中最顶层的是B1,B2类。第二层是V1,V2,V3。第三层是D1,D2.最底层是X。而D1虚继承V1,D2虚继承V2,且D1和D2在同一层。所以V1最先构造,其次是V2.在V2构造顺序中,B1先于B2.虚基类构造完成后,接着是直接基类子对象构造,其顺序为D1,D2.最后为成员子对象的构造,顺序为声明的顺序。构造完毕后,开始按照构造顺序执行构造函数体了。所以其最终的输出结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
B1::B1()!<

V1::V1()!<

B1::B1()!<

B2::B2()!<

V2::V2()!<

D1::D1()!<

B3::B3()!<

D2::D2()!<

M1::M1()!<

M2::M2()!<

从结果也可以看出其构造顺序完全符合上面的标准。而在结果中,可以看到B1重复构造。还是因为没有按照要求使用virtual继承导致的结果。要想只构造B1一次,可以将virtual全部改在B1上,如下面的代码:

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
class B1
{
public:
B1(){cout<<"B1::B1()!<"<<endl;}
void f() {cout<<"i'm here!"<<endl;}
};

class V1: virtual public B1 //public修改为virtual
{
public:
V1(){cout<<"V1::V1()!<"<<endl;}
};

class D1: public V1
{
public:
D1(){cout<<"D1::D1()!<"<<endl;}
};

class B2
{
public:
B2(){cout<<"B2::B2()!<"<<endl;}
};

class B3
{
public:
B3(){cout<<"B3::B3()!<"<<endl;}
};

class V2:virtual public B1, public B2 //public B1修改为virtual public B1
{
public:
V2(){cout<<"V2::V2()!<"<<endl;}
};

class D2: public V2, public B3
{
public:
D2(){cout<<"D2::D2()!<"<<endl;}
};

class M1
{
public:
M1(){cout<<"M1::M1()!<"<<endl;}
};

class M2
{
public:
M2(){cout<<"M2::M2()!<"<<endl;}
};

class X:public D1, public D2
{
M1 m1;
M2 m2;
}

根据上面的代码,其输出结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
B1::B1()!<

V1::V1()!<

D1::D1()!<

B2::B2()!<

V2::V2()!<

B3::B3()!<

D2::D2()!<

M1::M1()!<

M2::M2()!<

由于虚继承导致其构造顺序发生比较大的变化。不管怎么,分析的规则还是一样。

上面分析了这么多,我们知道了虚继承有一定的好处,但是虚继承会增大占用的空间。这是因为每一次虚继承会产生一个vptr指针。空间因素在编程过程中,我们很少考虑,而构造顺序却需要小心,因此使用未构造对象的危害是相当大的。因此,我们需要小心的使用继承,更要确保在使用继承的时候保证构造顺序不会出错。下面我再着重强调一下基类的构造顺序规则:

  1. 最上层派生类的构造函数负责调用虚基类子对象的构造函数。所有虚基类子对象会按照深度优先、从左到右的顺序进行初始化;
  2. 直接基类子对象按照它们在类定义中声明的顺序被一一构造起来;
  3. 非静态成员子对象按照它们在类定义体中的声明的顺序被一一构造起来;
  4. 最上层派生类的构造函数体被执行。

C++中虚函数

概念

在某基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数,用法格式为:virtual 函数返回类型 函数名(参数表) {函数体};实现多态性,通过指向派生类的基类指针或引用,访问派生类中同名覆盖成员函数。

定义

简单地说,那些被virtual关键字修饰的成员函数,就是虚函数。虚函数的作用,用专业术语来解释就是实现多态性(Polymorphism),多态性是将接口与实现进行分离;用形象的语言来解释就是实现以共同的方法,但因个体差异,而采用不同的策略。多态性底层的原理是什么?这里需要引出虚表和虚基表指针的概念。

  • 虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表
    • 虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
    • 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段
    • 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中
  • 虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针

C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

由于虚表指针vptr跟虚函数密不可分,对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面。

生成

编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址。

编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数

在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表

当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面。这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。

构造函数/析构函数能否声明为虚函数或者纯虚函数

析构函数:析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。

只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。

析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。

构造函数不能定义为虚函数。

  • 创建一个对象时需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型
  • 虚函数的调用需要虚函数表指针vptr,而该指针存放在对象的内存空间中,若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表vtable地址用来调用虚构造函数了
  • 虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数

将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。

有的人认为虚函数被声明为inline,但是编译器并没有对其内联,他们给出的理由是inline是编译期决定的,而虚函数是运行期决定的,即在不知道将要调用哪个函数的情况下,如何将函数内联呢?

上述观点看似正确,其实不然,如果虚函数在编译器就能够决定将要调用哪个函数时,就能够内联。当是指向派生类的指针(多态性)调用声明为inline的虚函数时,不会内联展开;当是对象本身调用虚函数时,会内联展开,当然前提依然是函数并不复杂的情况下

目的

直接的讲,C++中基类采用virtual虚析构函数是为了防止内存泄漏。

具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。

所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。

构造函数和析构函数可以调用虚函数吗,为什么

  • 在C++中,提倡不在构造函数和析构函数中调用虚函数;
  • 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;
  • 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;
  • 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子
    类的虚函数没有任何意义。

虚析构函数的作用,父类的析构函数是否要设置为虚函数?

1) C++中基类采用virtual虚析构函数是为了防止内存泄漏。

具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。

假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。

那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。

2) 纯虚析构函数一定得定义,因为每一个派生类析构函数会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上一层基类的析构函数。因此,缺乏任何一个基类析构函数的定义,就会导致链接失败,最好不要把虚析构函数定义为纯虚析构函数。

纯虚函数

定义

纯虚函数是一种特殊的虚函数,它的一般格式如下:

1
2
3
4
5
  class <类名>
 {
 virtual <类型><函数名>(<参数表>)=0;
 …
 };

在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。

纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。

凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。

抽象类

抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。

(1)抽象类的定义:称带有纯虚函数的类为抽象类。

(2)抽象类的作用:抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。

(3)使用抽象类时注意:抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。

抽象类是不能定义对象的。一个纯虚函数不需要(但是可以)被定义。

虚函数的代价?

  1. 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类;
  2. 带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小;
  3. 不能再是内敛的函数,因为内敛函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定到低是采用哪种函数,虚函数不能是内敛函数。

哪些函数不能是虚函数?

  • 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
  • 内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
  • 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
  • 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
  • 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。

C++中手动获取调用堆栈

原文链接;https://blog.csdn.net/kevinlynx/article/details/39269507

要了解调用栈,首先需要了解函数的调用过程,下面用一段代码作为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int add(int a, int b) {
int result = 0;
result = a + b;
return result;
}

int main(int argc, char *argv[]) {
int result = 0;
result = add(1, 2);
printf("result = %d \r\n", result);
return 0;
}

使用gcc编译,然后gdb反汇编main函数,看看它是如何调用add函数的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) disassemble main 
Dump of assembler code for function main:
0x08048439 <+0>: push %ebp
0x0804843a <+1>: mov %esp,%ebp
0x0804843c <+3>: and $0xfffffff0,%esp
0x0804843f <+6>: sub $0x20,%esp
0x08048442 <+9>: movl $0x0,0x1c(%esp) # 给result变量赋0值
0x0804844a <+17>: movl $0x2,0x4(%esp) # 将第2个参数压栈(该参数偏移为esp+0x04)
0x08048452 <+25>: movl $0x1,(%esp) # 将第1个参数压栈(该参数偏移为esp+0x00)
0x08048459 <+32>: call 0x804841c <add> # 调用add函数
0x0804845e <+37>: mov %eax,0x1c(%esp) # 将add函数的返回值赋给result变量
0x08048462 <+41>: mov 0x1c(%esp),%eax
0x08048466 <+45>: mov %eax,0x4(%esp)
0x0804846a <+49>: movl $0x8048510,(%esp)
0x08048471 <+56>: call 0x80482f0 <printf@plt>
0x08048476 <+61>: mov $0x0,%eax
0x0804847b <+66>: leave
0x0804847c <+67>: ret
End of assembler dump.

可以看到,参数是在add函数调用前压栈,换句话说,参数压栈由调用者进行,参数存储在调用者的栈空间中,下面再看一下进入add函数后都做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(gdb) disassemble add
Dump of assembler code for function add:
0x0804841c <+0>: push %ebp # 将ebp压栈(保存函数调用者的栈基址)
0x0804841d <+1>: mov %esp,%ebp # 将ebp指向栈顶esp(设置当前函数的栈基址)
0x0804841f <+3>: sub $0x10,%esp # 分配栈空间(栈向低地址方向生长)
0x08048422 <+6>: movl $0x0,-0x4(%ebp) # 给result变量赋0值(该变量偏移为ebp-0x04)
0x08048429 <+13>: mov 0xc(%ebp),%eax # 将第2个参数的值赋给eax(准备运算)
0x0804842c <+16>: mov 0x8(%ebp),%edx # 将第1个参数的值赋给edx(准备运算)
0x0804842f <+19>: add %edx,%eax # 加法运算(edx+eax),结果保存在eax中
0x08048431 <+21>: mov %eax,-0x4(%ebp) # 将运算结果eax赋给result变量
0x08048434 <+24>: mov -0x4(%ebp),%eax # 将result变量的值赋给eax(eax将作为函数返回值)
0x08048437 <+27>: leave # 恢复函数调用者的栈基址(pop %ebp)
0x08048438 <+28>: ret # 返回(准备执行下条指令)
End of assembler dump.

进入add函数后,首先进行的操作是将当前的栈基址ebp压栈(此栈基址是调用者main函数的),然后将ebp指向栈顶esp,接下来再进行函数内的处理流程。函数结束前,会将函数调用者的栈基址恢复,然后返回准备执行下一指令。这个过程中,栈上的空间会是下面的样子:

可以发现,每调用一次函数,都会对调用者的栈基址(ebp)进行压栈操作,并且由于栈基址是由当时栈顶指针(esp)而来,会发现,各层函数的栈基址很巧妙的构成了一个链,即当前的栈基址指向下一层函数栈基址所在的位置,如下图所示:

了解了函数的调用过程,想要回溯调用栈也就很简单了,首先获取当前函数的栈基址(寄存器ebp)的值,然后获取该地址所指向的栈的值,该值也就是下层函数的栈基址,找到下层函数的栈基址后,重复刚才的动作,即可以将每一层函数的栈基址都找出来,这也就是我们所需要的调用栈了。

下面是根据原理实现的一段获取函数调用栈的代码,供参考。

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
#include <stdio.h>

/* 打印调用栈的最大深度 */
#define DUMP_STACK_DEPTH_MAX 16

/* 获取寄存器ebp的值 */
void get_ebp(unsigned long *ebp) {
__asm__ __volatile__ (
"mov %%ebp, %0"
:"=m"(*ebp)
::"memory");
}

/* 获取调用栈 */
int dump_stack(void **stack, int size) {
unsigned long ebp = 0;
int depth = 0;

/* 1.得到首层函数的栈基址 */
get_ebp(&ebp);

/* 2.逐层回溯栈基址 */
for (depth = 0; (depth < size) && (0 != ebp) && (0 != *(unsigned long *)ebp) && (ebp != *(unsigned long *)ebp); ++depth) {
stack[depth] = (void *)(*(unsigned long *)(ebp + sizeof(unsigned long)));
ebp = *(unsigned long *)ebp;
}

return depth;
}

/* 测试函数 2 */
void test_meloner() {
void *stack[DUMP_STACK_DEPTH_MAX] = {0};
int stack_depth = 0;
int i = 0;

/* 获取调用栈 */
stack_depth = dump_stack(stack, DUMP_STACK_DEPTH_MAX);

/* 打印调用栈 */
printf(" Stack Track: \r\n");
for (i = 0; i < stack_depth; ++i) {
printf(" [%d] %p \r\n", i, stack[i]);
}

return;
}

/* 测试函数 1 */
void test_hutaow() {
test_meloner();
return;
}

/* 主函数 */
int main(int argc, char *argv[]) {
test_hutaow();
return 0;
}

需要知道的信息:

  • 函数调用对应的call指令本质上是先压入下一条指令的地址到堆栈,然后跳转到目标函数地址
  • 函数返回指令ret则是从堆栈取出一个地址,然后跳转到该地址
  • EBP寄存器始终指向当前执行函数相关信息(局部变量)所在栈中的位置,ESP则始终指向栈顶
  • 每一个函数入口都会保存调用者的EBP值,在出口处都会重设EBP值,从而实现函数调用的现场保存及现场恢复
  • 64位机器增加了不少寄存器,从而使得函数调用的参数大部分时候可以通过寄存器传递;同时寄存器名字发生改变,例如EBP变为RBP

在函数调用中堆栈的情况可用下图说明:

将代码对应起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void g() {
int *p = 0;
long a = 0x1234;
printf("%p %x\n", &a, a);
printf("%p %x\n", &p, p);
f();
*p = 1;
}

void b(int argc, char **argv) {
printf("%p %p\n", &argc, &argv);
g();
}

int main(int argc, char **argv) {
b(argc, argv);
return 0;
}

在函数g()中断点,看看堆栈中的内容(64位机器):

1
2
3
4
5
6
7
8
9
10
11
(gdb) p $rbp
$2 = (void *) 0x7fffffffe370
(gdb) p &p
$3 = (int **) 0x7fffffffe368
(gdb) p $rsp
$4 = (void *) 0x7fffffffe360
(gdb) x/8ag $rbp-16
0x7fffffffe360: 0x1234 0x0
0x7fffffffe370: 0x7fffffffe390 0x400631 <b(int, char**)+43>
0x7fffffffe380: 0x7fffffffe498 0x1a561cbc0
0x7fffffffe390: 0x7fffffffe3b0 0x40064f <main(int, char**)+27>

对应的堆栈图:

可以看看例子中0x400631 <b(int, char**)+43>0x40064f <main(int, char**)+27>中的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) disassemble 0x400631
...
0x0000000000400627 <b(int, char**)+33>: callq 0x400468 <printf@plt>
0x000000000040062c <b(int, char**)+38>: callq 0x4005ae <g()>
0x0000000000400631 <b(int, char**)+43>: leaveq # call的下一条指令
...

(gdb) disassemble 0x40064f
...
0x000000000040063f <main(int, char**)+11>: mov %rsi,-0x10(%rbp)
0x0000000000400643 <main(int, char**)+15>: mov -0x10(%rbp),%rsi
0x0000000000400647 <main(int, char**)+19>: mov -0x4(%rbp),%edi
0x000000000040064a <main(int, char**)+22>: callq 0x400606 <b(int, char**)>
0x000000000040064f <main(int, char**)+27>: mov $0x0,%eax # call的下一条指令
...

顺带一提,每个函数入口和出口,对应的设置RBP代码为:
1
2
3
4
5
6
7
8
(gdb) disassemble g
...
0x00000000004005ae <g()+0>: push %rbp # 保存调用者的RBP到堆栈
0x00000000004005af <g()+1>: mov %rsp,%rbp # 设置自己的RBP
...
0x0000000000400603 <g()+85>: leaveq # 等同于:movq %rbp, %rsp
# popq %rbp
0x0000000000400604 <g()+86>: retq

由以上可见,通过当前的RSP或RBP就可以找到调用堆栈中所有函数的RBP;找到了RBP就可以找到函数地址。因为,任何时候的RBP指向的堆栈位置就是上一个函数的RBP;而任何时候RBP所在堆栈中的前一个位置就是函数返回地址。

由此我们可以自己构建一个导致gdb无法取得调用堆栈的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void f() {
long *p = 0;
p = (long*) (&p + 1); // 取得g()的RBP
*p = 0; // 破坏g()的RBP
}

void g() {
int *p = 0;
long a = 0x1234;
printf("%p %x\n", &a, a);
printf("%p %x\n", &p, p);
f();
*p = 1; // 写0地址导致一次core
}

void b(int argc, char **argv) {
printf("%p %p\n", &argc, &argv);
g();
}

int main(int argc, char **argv) {
b(argc, argv);
return 0;
}

使用gdb运行该程序:

1
2
3
4
5
6
7
Program received signal SIGSEGV, Segmentation fault.
g () at ebp.c:37
37 *p = 1;
(gdb) bt
Cannot access memory at address 0x8
(gdb) p $rbp
$1 = (void *) 0x0

bt无法获取堆栈,在函数g()中RBP被改写为0,gdb从0偏移一个地址长度即0x8,尝试从0x8内存位置获取函数地址,然后提示Cannot access memory at address 0x8。

RBP出现了问题,我们就可以通过RSP来手动获取调用堆栈。因为RSP是不会被破坏的,要通过RSP获取调用堆栈则需要偏移一些局部变量所占的空间:

1
2
3
4
5
6
7
(gdb) p $rsp
$2 = (void *) 0x7fffffffe360
(gdb) x/8ag $rsp+16 # g()中局部变量占16字节
0x7fffffffe370: 0x7fffffffe390 0x400631 <b(int, char**)+43>
0x7fffffffe380: 0x7fffffffe498 0x1a561cbc0
0x7fffffffe390: 0x7fffffffe3b0 0x40064f <main(int, char**)+27>
0x7fffffffe3a0: 0x7fffffffe498 0x100000000

基于以上就可以手工找到调用堆栈:
1
2
3
g()
0x400631 <b(int, char**)+43>
0x40064f <main(int, char**)+27>

上面的例子本质上也是破坏堆栈,并且仅仅破坏了保存了的RBP。在实际情况中,堆栈可能会被破坏得更多,则可能导致手动定位也较困难。

堆栈被破坏还可能导致更多的问题,例如覆盖了函数返回地址,则会导致RIP错误;例如堆栈的不平衡。导致堆栈被破坏的原因也有很多,例如局部数组越界;delete/free栈上对象等。

omit-frame-pointer
使用RBP获取调用堆栈相对比较容易。但现在编译器都可以设置不使用RBP(gcc使用-fomit-frame-pointer,msvc使用/Oy),对于函数而言不设置其RBP意味着可以节省若干条指令。在函数内部则完全使用RSP的偏移来定位局部变量,包括嵌套作用域里的局部变量,即使程序实际运行时不会进入这个作用域。

例如:

1
2
3
4
5
6
7
void f2() {
int a = 0x1234;
if (a > 0) {
int b = 0xff;
b = a;
}
}

gcc中使用-fomit-frame-pointer生成的代码为:
1
2
3
4
5
6
7
8
9
(gdb) disassemble f2
Dump of assembler code for function f2:
0x00000000004004a5 <f2+0>: movl $0x1234,-0x8(%rsp) # int a = 0x1234
0x00000000004004ad <f2+8>: cmpl $0x0,-0x8(%rsp)
0x00000000004004b2 <f2+13>: jle 0x4004c4 <f2+31>
0x00000000004004b4 <f2+15>: movl $0xff,-0x4(%rsp) # int b = 0xff
0x00000000004004bc <f2+23>: mov -0x8(%rsp),%eax
0x00000000004004c0 <f2+27>: mov %eax,-0x4(%rsp)
0x00000000004004c4 <f2+31>: retq

C++智能指针

智能指针的作用

1) C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

2) 智能指针在C++11版本之后提供,包含在头文件中,shared_ptr、unique_ptr、weak_pptr。shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。

3) 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptrp4 = new int(1);的写法是错误的

4) unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。

5) 智能指针类将一个计数器与类指向的对象相关联,引用计数跟踪该类有多少个对象共享同一指针。每次创建类的新对象时,初始化指针并将引用计数置为1;当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数;对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数;调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)。

6) weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared_ptr。 weak_ptr只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少.

说说你了解的auto_ptr作用

  • auto_ptr的出现,主要是为了解决“有异常抛出时发生内存泄漏”的问题;抛出异常,将导致指针p所指向的空间得不到释放而导致内存泄漏;
  • auto_ptr构造时取得某个对象的控制权,在析构时释放该对象。我们实际上是创建一个auto_ptr类型的局部对象,该局部对象析构时,会将自身所拥有的指针空间释放,所以不会有内存泄漏;
  • auto_ptr的构造函数是explicit,阻止了一般指针隐式转换为 auto_ptr的构造,所以不能直接将一般类型的指针赋值给auto_ptr类型的对象,必须用auto_ptr的构造函数创建对象;
  • 由于auto_ptr对象析构时会删除它所拥有的指针,所以使用时避免多个auto_ptr对象管理同一个指针;
  • auto_ptr内部实现,析构函数中删除对象用的是delete而不是delete[],所以auto_ptr不能管理数组;
  • auto_ptr支持所拥有的指针类型之间的隐式类型转换。
  • 可以通过*和->运算符对auto_ptr所有用的指针进行提领操作;
  • T get(),获得auto_ptr所拥有的指针;T release(),释放auto_ptr的所有权,并将所
    有用的指针返回。

智能指针的循环引用

循环引用是指使用多个智能指针share_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
#include <iostream>
using namespace std;

template <typename T>
class Node
{
public:
Node(const T& value)
:_pPre(NULL)
, _pNext(NULL)
, _value(value)
{
cout << "Node()" << endl;
}
~Node()
~Node()
{
cout << "~Node()" << endl;
cout << "this:" << this << endl;
}

shared_ptr<Node<T>> _pPre;
shared_ptr<Node<T>> _pNext;
T _value;
};

void Funtest()
{
shared_ptr<Node<int>> sp1(new Node<int>(1));
shared_ptr<Node<int>> sp2(new Node<int>(2));

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;

sp1->_pNext = sp2; //sp1的引用+1
sp2->_pPre = sp1; //sp2的引用+1

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;
}
int main()
{
Funtest();
system("pause");
return 0;
}
//输出结果
//Node()
//Node()
//sp1.use_count:1
//sp2.use_count:1
//sp1.use_count:2
//sp2.use_count:2

从上面shared_ptr的实现中我们知道了只有当引用计数减减之后等于0,析构时才会释放对象,而上述情况造成了一个僵局,那就是析构对象时先析构sp2,可是由于sp2的空间sp1还在使用中,所以sp2.use_count减减之后为1,不释放,sp1也是相同的道理,由于sp1的空间sp2还在使用中,所以sp1.use_count减减之后为1,也不释放。sp1等着sp2先释放,sp2等着sp1先释放,二者互不相让,导致最终都没能释放,内存泄漏。

在实际编程过程中,应该尽量避免出现智能指针之间相互指向的情况,如果不可避免,可以使用弱指针—weak_ptr,它不增加引用计数,只要出了作用域就会自动析构。

使用智能指针管理内存资源,RAII是怎么回事?

RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。

因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。

智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。

毫不夸张的来讲,有了智能指针,代码中几乎不需要再出现delete了。

智能指针背后的设计思想

无智能指针造成内存泄漏的例子

1
2
3
4
5
6
7
8
9
10
void remodel(std::string & str)
{
std::string * ps = new std::string(str);//堆内存
...
if (weird_thing())
throw exception();
str = *ps;
delete ps;
return;
}

当出现异常时(weird_thing()返回true),delete将不被执行,因此将导致内存泄露 。

常规解决方案:

  • 在throw exception()之前添加delete ps;
  • 不要忘了最后一个delete ps;

智能指针的设计思想

仿照本地变量能够自动从栈内存中删除的思想,对指针设计一个析构函数,该析构函数将在指针过期时自动释放它指向的内存,总结来说就是:将基本类型指针封装为类对象指针(这个类肯定是个模板,以适应不同基本类型的需求),并在析构函数中编写delete语句以用来删除指针指向的内存空间。

转换remodel()函数的步骤:

  • 包含头文件memory(智能指针所在的头文件);
  • 将指向string的指针替换为指向string的智能指针对象;
  • 删除delete语句。

使用auto_ptr修改该函数的结果:

1
2
3
4
5
6
7
8
9
10
11
#include <memory>
void remodel (std::string & str)
{
std::auto_ptr<std::string> ps (new std::string(str))
...
if (weird_thing ())
throw exception()
str = *ps;
// delete ps; NO LONGER NEEDED
return;
}

C++智能指针简单介绍

STL一共给我们提供了四种智能指针:auto_ptr、unique_ptr、shared_ptr和weak_ptr。

其中:auto_ptr在C++11中已将其摒弃。

使用注意点:

所有的智能指针类都有一个explicit构造函数,以指针作为参数。比如auto_ptr的类模板原型为:

1
2
3
4
5
templet<class T>
class auto_ptr {
explicit auto_ptr(X* p = 0) ;
...
};

因此不能自动将指针转换为智能指针对象,必须显示调用:

1
2
3
4
5
6
shared_ptr<double> pd;
double *p_reg = new double;
pd = p_reg;//NOT ALLOWED(implicit conversion)
pd = shared_ptr<double>(p_reg);// ALLOWED (explicit conversion)
shared_ptr<double> pshared = p_reg;//NOT ALLOWED (implicit conversion)
shared_ptr<double> pshared(p_reg);//ALLOWED (explicit conversion)

对全部三种智能指针都应避免的一点:

1
2
string vacation("I wandered lonely as a child."); //heap param
shared_ptr<string> pvac(&vacation);//NO!!

pvac过期时,程序将把delete运算符用于非堆(栈)内存,这是错误的!

使用实例

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>
#include <string>
#include <memory>

class report
{
private:
std::string str;
public:
report(const std::string s) : str(s){
std::cout<<"Object created.\n";
}
~report(){
std::cout<<"Object deleted.\n";
}
void comment() const {
std::cout<<str<<"\n";
}
};

int main(){
{
std::auto_ptr<report> ps(new report("using auto ptr"));
ps->comment();
}//auto_ptr 作用域结束
{
std::shared_ptr<report> ps(new report("using shared_ptr"));
ps->comment();
}//shared_ptr 作用域结束
{
std::unique_ptr<report> ps(new report("using unique ptr"));
ps->comment();
}//unique_ptr 作用域结束
return 0;
}

为什么摒弃auto_ptr?

问题来源:

1
2
3
auto_ptr<string> ps (new string("I reigned lonely as a cloud."));
auto_ptr<string> vocation;
vocation = ps;

如果ps和vocation是常规指针,则两个指针指向同一个string对象,当指针过期时,则程序会试图删除同一个对象,要避免这种问题,解决办法:

定义赋值运算符,使之执行深复制。这样两个指针将指向不同的对象,其中的一个对象是另一个对象的副本,缺点是浪费空间,所以智能指针都未采取此方案。

建立所有权(ownership)概念。对于特定的对象,智能有一个智能对象可拥有,这样只能拥有对象的智能指针的析构函数会删除该对象。然后让赋值操作转让所有权。这就是用于auto_ptr和unique_ptr的策略,但unique_ptr的策略更严格。

创建智能更高的指针,跟踪引用特定对象的智能指针数。这称为引用计数。例如,赋值时,计数将加1,而指针过期时,计数将减1,当减为0时才调用delete。这是shared_ptr采用的策略。同样的策略也适用于复制构造函数。

摒弃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
#include <iostream>
#include <string>
#include <memory>
using namespace std;

int main(){
auto_ptr<string> films[5] = {
auto_ptr<string> (new string("Fowl Balls")),
auto_ptr<string> (new string("Duck Walks")),
auto_ptr<string> (new string("Chicken Runs")),
auto_ptr<string> (new string("Turkey Errors")),
auto_ptr<string> (new string("Goose Eggs"))
};
auto_ptr<string> pwin;
pwin = films[2];//films[2] loses owership,将所有权从films[2]转让给pwin,此时films[2]不再引用该字符串从而变成空指针
cout<<"The nominees for best avian baseball film are\n";
for(int i = 0;i < 5;++i)
{
cout<< *films[i]<<endl;
}
cout<<"The winner is "<<*pwin<<endl;
cin.get();

return 0;
}

运行下发现程序崩溃了,原因是films[2]已经是空指针了,输出空指针就会崩溃。如果把auto_ptr换成shared_ptr或unique_ptr后,程序就不会崩溃,原因如下:

适用shared_ptr时运行正常,因为shared_ptr采用引用计数,pwin和films[2]都指向同一块内存,在释放空间时因为事先要判断引用计数值的大小,因此不会出现多次删除一个对象的错误。

适用unique_ptr时编译出错,与auto_ptr一样,unique_ptr也采用所有权模型,但在适用unique_ptr时,程序不会等到运行阶段崩溃,在编译阶段下属代码就会出现错误:

1
2
unique_ptr<string> pwin;
pwin = films[2];//films[2] loses ownership

这就是为何摒弃auto_ptr的原因:避免潜在的内存泄漏问题。

unique_ptr为何优于auto_ptr?

使用规则更严格

1
2
3
auto_ptr<string> p1(new string("auto"));  //#1
auto_ptr<string> p2; //#2
p2 = p1; //#3

在语句#3中,p2接管string对象的所有权后,p1的所有权将被剥夺。–>可防止p1和p2的析构函数试图删除同一个对象。但如果随后试图使用p1,则会出现错误。

1
2
3
unique_ptr<string> p3(new string("auto"));//#4
unique_ptr<string> p4;//#5
p4=p3;//#6

编译器会认为#6语句为非法,可以避免上述问题。

对悬挂指针的操作更智能

总体来说:允许临时悬挂指针的赋值,禁止其他情况的出现。

示例:函数定义如下:

1
2
3
4
unique_ptr<string> demo(const char *s){
unique_ptr<string> temp (new string(a));
return temp;
}

在程序中调用函数:

1
2
unique_ptr<string> ps;
ps = demo("unique special");

编译器允许此种赋值方式。总之:当程序试图将一个unique_ptr赋值给另一个时,如果源unique_ptr是个临时右值,编译器允许这么做;如果源unique_ptr将存在一段时间,编译器将禁止这么做。

1
2
3
4
5
unique_ptr<string> pu1(new string("hello world"));
unique_ptr<string> pu2;
pu2 = pu1;//#1 not allowed
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string("you"));//#2 allowed

如果确实想执行类似#1的操作,仅当以非智能的方式使用摒弃的智能指针时(如解除引用时),这种赋值才不安全。要安全的重用这种指针,可给它赋新值。C++有一个标准库函数std::move(),可以将原来的指针转让所有权变成空指针,可以对其重新赋值。

1
2
3
4
5
unque_ptr<string> ps1,ps2;
ps1 = demo("hello");
ps2 = move(ps1);
ps1 = demo("alexia");
cout<<*ps2<<*ps1<<endl;

如何选择智能指针

使用指南:

如果程序要使用多个指向同一个对象的指针,应选用shared_ptr。这样的情况包括:

  • 有一个指针数组,并使用一些辅助指针来标示特定的元素,如最大的元素和最小的元素;
  • 连个对象包含指向第三个对象的指针;
  • STL容器包含指针。很多STL算法都支持复制和赋值操作,这些操作可用于shared_ptr,但不能用于unique_ptr(编译器发出warning)和auto_ptr(行为不确定)。如果你的编译器没有提供shared_ptr,可使用Boost库提供的shared_ptr。

如果程序不需要多个指向同一个对象的指针,则可使用unique_ptr。如果函数使用new分配内存,并返还指向该内存的指针,将其返回类型声明为unique_ptr是不错的选择。这样,所有权转让给接受返回值的unique_ptr,而该智能指针将负责调用delete。可将unique_ptr储存到STL容器中,只要不调用将unique_ptr复制或赋值给另一个算法(如sort())。例如,可在程序中使用类似于下面的代码段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unique_ptr<int> make_int(int n){
return unique_ptr<int>(new int(n));
}
void show(unique_ptr<int> &p1){
cout<<*a<<' ';
}
int main(){
...
vector<unique_ptr<int>> vp(size);
for(int i=0; i<vp.size();i++){
vp[i] = make_int(rand() %1000);//copy temporary unique_ptr
}
vp.push_back(make_int(rand()%1000));// ok because arg is temporary
for_each(vp.begin(),vp.end(),show); //use for_each();
}

其中push_back调用没有问题,因为它返回一个临时unique_ptr,该unique_ptr被赋值给vp中的一个unique_ptr。另外,如果按值而不是按引用给show()传递对象,for_each()将非法,因为这将导致使用一个来自vp的非临时unique_ptr初始化p1,而这是不允许的。前面说过,编译器将发现错误使用unique_ptr的企图。

在unique_ptr为右值时,可将其赋给shared_ptr,这与将一个unique_ptr赋给一个需要满足的条件相同。与前面一样,在下面的代码中,make_int()的返回类型为unique_ptr<int>

1
2
3
unique_ptr<int> pup(make_int(rand() % 1000));   // ok
shared_ptr<int> spp(pup); // not allowed, pup as lvalue
shared_ptr<int> spr(make_int(rand() % 1000)); // ok

模板shared_ptr包含一个显式构造函数,可用于将右值unique_ptr转换为shared_ptr。shared_ptr将接管原来归unique_ptr所有的对象。

在满足unique_ptr要求的条件时,也可使用auto_ptr,但unique_ptr是更好的选择。如果你的编译器没有unique_ptr,可考虑使用Boost库提供的scoped_ptr,它与unique_ptr类似。

弱引用智能指针 weak_ptr

设计weak_ptr的原因:解决使用shared_ptr因循环引用而不能释放资源的问题。

空悬指针问题

有两个指针p1和p2,指向堆上的同一个对象Object,p1和p2位于不同的线程中。假设线程A通过p1指针将对象销毁了(尽管把p1置为NULL),那p2就成了空悬指针。这是一种典型的C/C++内存错误。

使用weak_ptr能够帮助我们轻松解决上述的空悬指针问题(直接使用shared_ptr也是可以的)。

weak_ptr不控制对象的生命期,但是它知道对象是否还活着,如果对象还活着,那么它可以提升为有效的shared_ptr(提升操作通过lock()函数获取所管理对象的强引用指针);如果对象已经死了,提升会失败,返回一个空的shared_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
#include <iostream>
#include <memory>

int main()
{
// OLD, problem with dangling pointer
// PROBLEM: ref will point to undefined data!

int* ptr = new int(10);
int* ref = ptr;
delete ptr;

// NEW
// SOLUTION: check expired() or lock() to determine if pointer is valid
// empty definition
std::shared_ptr<int> sptr;
// takes ownership of pointer
sptr.reset(new int);
*sptr = 10;
// get pointer to data without taking ownership
std::weak_ptr<int> weak1 = sptr;
// deletes managed object, acquires new pointer
sptr.reset(new int);
*sptr = 5;
// get pointer to new data without taking ownership
std::weak_ptr<int> weak2 = sptr;
// weak1 is expired!
if(auto tmp = weak1.lock())
std::cout << *tmp << '\n';
else
std::cout << "weak1 is expired\n";
// weak2 points to new data (5)
if(auto tmp = weak2.lock())
std::cout << *tmp << '\n';
else
std::cout << "weak2 is expired\n";
}

循环引用问题
栗子 大法:

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
#include <iostream>
#include <boost/smart_ptr.hpp>
using namespace std;
using namespace boost;

class BB;
class AA
{
public:
AA() { cout << "AA::AA() called" << endl; }
~AA() { cout << "AA::~AA() called" << endl; }
shared_ptr<BB> m_bb_ptr; //!
};

class BB
{
public:
BB() { cout << "BB::BB() called" << endl; }
~BB() { cout << "BB::~BB() called" << endl; }
shared_ptr<AA> m_aa_ptr; //!
};

int main()
{
shared_ptr<AA> ptr_a (new AA);
shared_ptr<BB> ptr_b ( new BB);
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
//下面两句导致了AA与BB的循环引用,结果就是AA和BB对象都不会析构
ptr_a->m_bb_ptr = ptr_b;
ptr_b->m_aa_ptr = ptr_a;
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
}

运行结果:

可以看到由于AA和BB内部的shared_ptr各自保存了对方的一次引用,所以导致了ptr_a和ptr_b销毁的时候都认为内部保存的指针计数没有变成0,所以AA和BB的析构函数不会被调用。解决方法就是把一个shared_ptr替换成weak_ptr。

可以看到由于AA和BB内部的shared_ptr各自保存了对方的一次引用,所以导致了ptr_a和ptr_b销毁的时候都认为内部保存的指针计数没有变成0,所以AA和BB的析构函数不会被调用。解决方法就是把一个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
#include <iostream>
#include <boost/smart_ptr.hpp>
using namespace std;
using namespace boost;

class BB;
class AA
{
public:
AA() { cout << "AA::AA() called" << endl; }
~AA() { cout << "AA::~AA() called" << endl; }
weak_ptr<BB> m_bb_ptr; //!
};

class BB
{
public:
BB() { cout << "BB::BB() called" << endl; }
~BB() { cout << "BB::~BB() called" << endl; }
shared_ptr<AA> m_aa_ptr; //!
};

int main()
{
shared_ptr<AA> ptr_a (new AA);
shared_ptr<BB> ptr_b ( new BB);
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
//下面两句导致了AA与BB的循环引用,结果就是AA和BB对象都不会析构
ptr_a->m_bb_ptr = ptr_b;
ptr_b->m_aa_ptr = ptr_a;
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
}

运行结果:

最后值得一提的是,虽然通过弱引用指针可以有效的解除循环引用,但这种方式必须在能预见会出现循环引用的情况下才能使用,即这个仅仅是一种编译期的解决方案,如果程序在运行过程中出现了循环引用,还是会造成内存泄漏的。因此,不要认为只要使用了智能指针便能杜绝内存泄漏。

智能指针源码解析

在介绍智能指针源码前,需要明确的是,智能指针本身是一个栈上分配的对象。根据栈上分配的特性,在离开作用域后,会自动调用其析构方法。智能指针根据这个特性实现了对象内存的管理和自动释放。

本文所分析的智能指针源码基于 Android ndk-16b 中 llvm-libc++的 memory 文件。

unique_ptr

先看下 unique_ptr的声明。unique_ptr有两个模板参数,分别为_Tp和_Dp。

  • _Tp表示原生指针的类型。
  • _Dp则表示析构器,开发者可以自定义指针销毁的代码。其拥有一个默认值default_delete<_Tp>,其实就是标准的delete函数。

函数声明中typename __pointer_type<_Tp, deleter_type>::type可以简单理解为_Tp*,即原生指针类型。

1
2
3
4
5
6
7
8
template <class _Tp, class _Dp = default_delete<_Tp> >
class _LIBCPP_TEMPLATE_VIS unique_ptr {
public:
typedef _Tp element_type;
typedef _Dp deleter_type;
typedef typename __pointer_type<_Tp, deleter_type>::type pointer;
//...
}

unique_ptr中唯一的数据成员就是原生指针和析构器的 pair。

1
2
private:
__compressed_pair<pointer, deleter_type> __ptr_;

下面看下unique_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
template <class _Tp, class _Dp = default_delete<_Tp> >
class _LIBCPP_TEMPLATE_VIS unique_ptr {

public:
// 默认构造函数,用pointer的默认构造函数初始化__ptr_
constexpr unique_ptr() noexcept : __ptr_(pointer()) {}

// 空指针的构造函数,同上
constexpr unique_ptr(nullptr_t) noexcept : __ptr_(pointer()) {}

// 原生指针的构造函数,用原生指针初始化__ptr_
explicit unique_ptr(pointer __p) noexcept : __ptr_(__p) {}

// 原生指针和析构器的构造函数,用这两个参数初始化__ptr_,当前析构器为左值引用
unique_ptr(pointer __p, _LValRefType<_Dummy> __d) noexcept
: __ptr_(__p, __d) {}

// 原生指针和析构器的构造函数,析构器使用转移语义进行转移
unique_ptr(pointer __p, _GoodRValRefType<_Dummy> __d) noexcept
: __ptr_(__p, _VSTD::move(__d)) {
static_assert(!is_reference<deleter_type>::value,
"rvalue deleter bound to reference");
}

// 移动构造函数,取出原有unique_ptr的指针和析构器进行构造
unique_ptr(unique_ptr&& __u) noexcept
: __ptr_(__u.release(), _VSTD::forward<deleter_type>(__u.get_deleter())) {
}

// 移动赋值函数,取出原有unique_ptr的指针和析构器进行构造
unique_ptr& operator=(unique_ptr&& __u) _NOEXCEPT {
reset(__u.release());
__ptr_.second() = _VSTD::forward<deleter_type>(__u.get_deleter());
return *this;
}

}

再看下unique_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
template <class _Tp, class _Dp = default_delete<_Tp> >
class _LIBCPP_TEMPLATE_VIS unique_ptr {

// 返回原生指针
pointer get() const _NOEXCEPT {
return __ptr_.first();
}

// 判断原生指针是否为空
_LIBCPP_EXPLICIT operator bool() const _NOEXCEPT {
return __ptr_.first() != nullptr;
}

// 将__ptr置空,并返回原有的指针
pointer release() _NOEXCEPT {
pointer __t = __ptr_.first();
__ptr_.first() = pointer();
return __t;
}

// 重置原有的指针为新的指针,如果原有指针不为空,对原有指针所指对象进行销毁
void reset(pointer __p = pointer()) _NOEXCEPT {
pointer __tmp = __ptr_.first();
__ptr_.first() = __p;
if (__tmp)
__ptr_.second()(__tmp);
}
}

再看下unique_ptr指针特性的两个方法。

1
2
3
4
5
6
7
8
9
// 返回原生指针的引用
typename add_lvalue_reference<_Tp>::type
operator*() const {
return *__ptr_.first();
}
// 返回原生指针
pointer operator->() const _NOEXCEPT {
return __ptr_.first();
}

最后再看下unique_ptr的析构函数。

1
2
// 通过reset()方法进行对象的销毁
~unique_ptr() { reset(); }

shared_ptr

shared_ptr 与unique_ptr最核心的区别就是比unique_ptr多了一个引用计数,并由于引用计数的加入,可以支持拷贝。

先看下shared_ptr的声明。shared_ptr主要有两个成员变量,一个是原生指针,一个是控制块的指针,用来存储这个原生指针的shared_ptr和weak_ptr的数量。

1
2
3
4
5
6
7
8
9
10
11
template<class _Tp>
class shared_ptr
{
public:
typedef _Tp element_type;

private:
element_type* __ptr_;
__shared_weak_count* __cntrl_;
//...
}

我们重点看下__shared_weak_count的定义。

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
// 共享计数类
class __shared_count
{
__shared_count(const __shared_count&);
__shared_count& operator=(const __shared_count&);

protected:
// 共享计数
long __shared_owners_;
virtual ~__shared_count();
private:
// 引用计数变为0的回调,一般是进行内存释放
virtual void __on_zero_shared() _NOEXCEPT = 0;

public:
// 构造函数,需要注意内部存储的引用计数是从0开始,外部看到的引用计数其实为1
explicit __shared_count(long __refs = 0) _NOEXCEPT
: __shared_owners_(__refs) {}

// 增加共享计数
void __add_shared() _NOEXCEPT {
__libcpp_atomic_refcount_increment(__shared_owners_);
}

// 释放共享计数,如果共享计数为0(内部为-1),则调用__on_zero_shared进行内存释放
bool __release_shared() _NOEXCEPT {
if (__libcpp_atomic_refcount_decrement(__shared_owners_) == -1) {
__on_zero_shared();
return true;
}
return false;
}

// 返回引用计数,需要对内部存储的引用计数+1处理
long use_count() const _NOEXCEPT {
return __libcpp_relaxed_load(&amp;__shared_owners_) + 1;
}
};
class __shared_weak_count
: private __shared_count
{
// weak ptr计数
long __shared_weak_owners_;

public:
// 内部共享计数和weak计数都为0
explicit __shared_weak_count(long __refs = 0) _NOEXCEPT
: __shared_count(__refs),
__shared_weak_owners_(__refs) {}
protected:
virtual ~__shared_weak_count();

public:
// 调用通过父类的__add_shared,增加共享引用计数
void __add_shared() _NOEXCEPT {
__shared_count::__add_shared();
}
// 增加weak引用计数
void __add_weak() _NOEXCEPT {
__libcpp_atomic_refcount_increment(__shared_weak_owners_);
}
// 调用父类的__release_shared,如果释放了原生指针的内存,还需要调用__release_weak,因为内部weak计数默认为0
void __release_shared() _NOEXCEPT {
if (__shared_count::__release_shared())
__release_weak();
}
// weak引用计数减1
void __release_weak() _NOEXCEPT;
// 获取共享计数
long use_count() const _NOEXCEPT {return __shared_count::use_count();}
__shared_weak_count* lock() _NOEXCEPT;

private:
// weak计数为0的处理
virtual void __on_zero_shared_weak() _NOEXCEPT = 0;
};

其实__shared_weak_count也是虚类,具体使用的是__shared_ptr_pointer__shared_ptr_pointer中有一个成员变量__data_,用于存储原生指针、析构器、分配器。__shared_ptr_pointer继承了__shared_weak_count,因此它就主要负责内存的分配、销毁,引用计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class __shared_ptr_pointer
: public __shared_weak_count
{
__compressed_pair<__compressed_pair<_Tp, _Dp>, _Alloc> __data_;
public:
_LIBCPP_INLINE_VISIBILITY
__shared_ptr_pointer(_Tp __p, _Dp __d, _Alloc __a)
: __data_(__compressed_pair<_Tp, _Dp>(__p, _VSTD::move(__d)), _VSTD::move(__a)) {}

#ifndef _LIBCPP_NO_RTTI
virtual const void* __get_deleter(const type_info&) const _NOEXCEPT;
#endif

private:
virtual void __on_zero_shared() _NOEXCEPT;
virtual void __on_zero_shared_weak() _NOEXCEPT;
};

了解了引用计数的基本原理后,再看下shared_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
// 使用原生指针构造shared_ptr时,会构建__shared_ptr_pointer的控制块
shared_ptr<_Tp>::shared_ptr(_Yp* __p,
typename enable_if<is_convertible<_Yp*, element_type*>::value, __nat>::type)
: __ptr_(__p)
{
unique_ptr<_Yp> __hold(__p);
typedef typename __shared_ptr_default_allocator<_Yp>::type _AllocT;
typedef __shared_ptr_pointer<_Yp*, default_delete<_Yp>, _AllocT > _CntrlBlk;
__cntrl_ = new _CntrlBlk(__p, default_delete<_Yp>(), _AllocT());
__hold.release();
__enable_weak_this(__p, __p);
}

// 如果进行shared_ptr的拷贝,会增加引用计数
template<class _Tp>
inline
shared_ptr<_Tp>::shared_ptr(const shared_ptr& __r) _NOEXCEPT
: __ptr_(__r.__ptr_),
__cntrl_(__r.__cntrl_)
{
if (__cntrl_)
__cntrl_->__add_shared();
}

// 销毁shared_ptr时,会使共享引用计数减1,如果减到0会销毁内存
template<class _Tp>
shared_ptr<_Tp>::~shared_ptr()
{
if (__cntrl_)
__cntrl_->__release_shared();
}

weak_ptr

了解完shared_ptr,weak_ptr也就比较简单了。weak_ptr也包括两个对象,一个是原生指针,一个是控制块。虽然weak_ptr内存储了原生指针,不过由于未实现operator->因此不能直接使用。

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
class _LIBCPP_TEMPLATE_VIS weak_ptr
{
public:
typedef _Tp element_type;
private:
element_type* __ptr_;
__shared_weak_count* __cntrl_;

}
// 通过shared_ptr构造weak_ptr。会将shared_ptr的成员变量地址进行复制。增加weak引用计数
weak_ptr<_Tp>::weak_ptr(shared_ptr<_Yp> const&amp; __r,
typename enable_if<is_convertible<_Yp*, _Tp*>::value, __nat*>::type)
_NOEXCEPT
: __ptr_(__r.__ptr_),
__cntrl_(__r.__cntrl_)
{
if (__cntrl_)
__cntrl_->__add_weak();
}

// weak_ptr析构器
template<class _Tp>
weak_ptr<_Tp>::~weak_ptr()
{
if (__cntrl_)
__cntrl_->__release_weak();
}

MSVC C++ STL 源码解析系列介绍

std::unique_ptr是 c++ 11 添加的智能指针之一,是裸指针的封装,我们可以直接使用裸指针来构造std::unique_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct TestStruct {
int a;
int b;
};

class TestClass {
public:
TestClass() = default;
TestClass(int a, int b) : a(a), b(b) {}

private:
int a;
int b;
};

std::unique_ptr<int> p0 = std::unique_ptr<int>(new int { 1 });
std::unique_ptr<TestStruct> p1 = std::unique_ptr<TestStruct>(new TestStruct { 1, 2 });
std::unique_ptr<TestClass> p2 = std::unique_ptr<TestClass>(new TestClass(1, 2));

在 c++ 14 及以上,可以使用std::make_unique来更方便地构造std::unique_ptr,参数列表需匹配创建对象的构造函数:

1
2
3
std::unique_ptr<int> p0 = std::make_unique<int>(1);
std::unique_ptr<TestStruct> p1 = std::make_unique<TestStruct>(TestStruct { 1, 2 });
std::unique_ptr<TestClass> p2 = std::make_unique<TestClass>(1, 2);

除了保存普通对象,std::unique_ptr还能保存数组,这时std::make_unique的参数表示数组的长度:

1
2
3
std::unique_ptr<int[]> p0 = std::make_unique<int[]>(1);
std::unique_ptr<TestStruct[]> p1 = std::make_unique<TestStruct[]>(2);
std::unique_ptr<TestClass[]> p2 = std::make_unique<TestClass[]>(3);

std::unique_ptr重载了operator->,你可以像使用普通指针一样使用它:

1
2
3
4
5
std::unique_ptr<TestStruct> p = std::make_unique<TestStruct>(TestStruct { 1, 2 });
std::cout << "a: " << p->a << ", b: " << p->b << std::endl;

// 输出:
// a: 1, b: 2

当然,直接使用nullptr对其赋值,或者拿std::unique_ptrnullptr进行比较,都是可以的:

1
2
3
4
5
6
7
8
std::unique_ptr<TestClass> p = nullptr;
std::cout << (p == nullptr) << std::endl;
p = std::make_unique<TestClass>();
std::cout << (p == nullptr) << std::endl;

// 输出:
// 1
// 0

std::unique_ptr在离开其作用域时,所保存的对象会自动销毁:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::cout << "block begin" << std::endl;
{
auto p = std::make_unique<LifeCycleTestClass>();
p->PrintHello();
}
std::cout << "block end" << std::endl;

// 输出
// block begin
// constructor
// hello
// destructor
// block end

比较重要的一点是std::unique_ptr删除了拷贝构造,所有它对对象的所有权是独享的,你没有办法直接将std::unique_ptr相互拷贝,而只能通过std::move来转移所有权:

1
2
3
auto p1 = std::make_unique<TestClass>();
// 编译错误:Call to deleted constructor of 'std::unique_ptr<TestClass>'
auto p2 = p1;

正确的做法是:

1
2
auto p1 = std::make_unique<TestClass>();
auto p2 = std::move(p1);

因为触发了移动语义,转移所有权期间,对象不会重新构造。

除了上面这些特性,std::unique_ptr还提供了一些与裸指针相关的成员函数,你可以使用get()来直接获取裸指针:

1
2
auto p = std::make_unique<TestClass>();
TestClass* rawP = p.get();

也可以使用release()来释放裸指针,在释放后,原来的std::unique_ptr会变成nullptr

1
2
auto p = std::make_unique<TestClass>();
TestClass* rawP = p.release();

要注意的是,get()release()都不会销毁原有对象,只是单纯对裸指针进行操作而已。

在实际编程实践中,std::unique_ptr要比std::shared_ptr更实用,因为std::unique_ptr对对象的所有权是明确的,销毁时机也是明确的,可以很好地避免使用 new。

源码解析

下面的源码解析基于 MSVC 16 2019 (64-Bit),其他编译器可能有所不同。

_Compressed_pair

_Compressed_pairstd::unique_ptr内部用于存储 deleter 和裸指针的工具,从字面意思来看,它实现的功能和std::pair是类似的,但是有所差异的一点是在某些场景下,_Compressed_pair相比std::pair做了额外的压缩,我们先来看看源码:

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
struct _Zero_then_variadic_args_t {
explicit _Zero_then_variadic_args_t() = default;
}; // tag type for value-initializing first, constructing second from remaining args

struct _One_then_variadic_args_t {
explicit _One_then_variadic_args_t() = default;
}; // tag type for constructing first from one arg, constructing second from remaining args

template <class _Ty1, class _Ty2, bool = is_empty_v<_Ty1> && !is_final_v<_Ty1>>
class _Compressed_pair final : private _Ty1 { // store a pair of values, deriving from empty first
public:
_Ty2 _Myval2;

using _Mybase = _Ty1; // for visualization

template <class... _Other2>
constexpr explicit _Compressed_pair(_Zero_then_variadic_args_t, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_default_constructible<_Ty1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Ty1(), _Myval2(_STD forward<_Other2>(_Val2)...) {}

template <class _Other1, class... _Other2>
constexpr _Compressed_pair(_One_then_variadic_args_t, _Other1&& _Val1, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_constructible<_Ty1, _Other1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Ty1(_STD forward<_Other1>(_Val1)), _Myval2(_STD forward<_Other2>(_Val2)...) {}

constexpr _Ty1& _Get_first() noexcept {
return *this;
}

constexpr const _Ty1& _Get_first() const noexcept {
return *this;
}
};

template <class _Ty1, class _Ty2>
class _Compressed_pair<_Ty1, _Ty2, false> final { // store a pair of values, not deriving from first
public:
_Ty1 _Myval1;
_Ty2 _Myval2;

template <class... _Other2>
constexpr explicit _Compressed_pair(_Zero_then_variadic_args_t, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_default_constructible<_Ty1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Myval1(), _Myval2(_STD forward<_Other2>(_Val2)...) {}

template <class _Other1, class... _Other2>
constexpr _Compressed_pair(_One_then_variadic_args_t, _Other1&& _Val1, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_constructible<_Ty1, _Other1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Myval1(_STD forward<_Other1>(_Val1)), _Myval2(_STD forward<_Other2>(_Val2)...) {}

constexpr _Ty1& _Get_first() noexcept {
return _Myval1;
}

constexpr const _Ty1& _Get_first() const noexcept {
return _Myval1;
}
};

可以看到,_Compressed_pair在满足条件is_empty_v<_Ty1> && !is_final_v<_Ty1>时,会走上面的定义,使用 Empty base optimization 即空基类优化,不满足时,则走下面的特化,退化成普通的pair,我们来通过一段示例代码看一下压缩效果:

1
2
3
4
5
6
std::cout << sizeof(std::pair<A, int>) << std::endl;
std::cout << sizeof(std::_Compressed_pair<A, int>) << std::endl;

// 输出
// 8
// 4

当 A 为空类时,由于 c++ 的机制,会为其保留 1 字节的空间,A 和 int 联合存放在std::pair里时,因为需要进行对齐,就变成了 4 + 4 字节,而_Compressed_pair则通过空基类优化避免了这个问题。

unique_ptr

先来看看保存普通对象的std::unique_ptr的定义:

1
2
template <class _Ty, class _Dx = default_delete<_Ty>>
class unique_ptr;

这里的模板参数_Ty是保存的对象类型,_Dx是删除器类型,默认为default_delete<_Ty>,下面是具体的定义:

1
2
3
4
5
6
7
8
9
10
11
12
template <class _Ty>
struct default_delete { // default deleter for unique_ptr
constexpr default_delete() noexcept = default;

template <class _Ty2, enable_if_t<is_convertible_v<_Ty2*, _Ty*>, int> = 0>
default_delete(const default_delete<_Ty2>&) noexcept {}

void operator()(_Ty* _Ptr) const noexcept /* strengthened */ { // delete a pointer
static_assert(0 < sizeof(_Ty), "can't delete an incomplete type");
delete _Ptr;
}
};

很简单,只是一个重载了operator()的结构体而已,operator()中则直接调用delete

std::unique_ptr中定义了几个 using:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <class _Ty, class _Dx_noref, class = void>
struct _Get_deleter_pointer_type { // provide fallback
using type = _Ty*;
};

template <class _Ty, class _Dx_noref>
struct _Get_deleter_pointer_type<_Ty, _Dx_noref, void_t<typename _Dx_noref::pointer>> { // get _Dx_noref::pointer
using type = typename _Dx_noref::pointer;
};

using pointer = typename _Get_deleter_pointer_type<_Ty, remove_reference_t<_Dx>>::type;
using element_type = _Ty;
using deleter_type = _Dx;

这里element_type为元素类型,deleter_type为删除器类型,我们主要关注pointerpointer的类型由_Get_deleter_pointer_type决定,我们可以发现它有两个定义,前者是默认定义,当删除器中没有定义pointer时会fallback到这个定义,如果删除器定义了pointer,则会使用删除器中的pointer类型。下面是一段实验代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class Ty>
struct deleter {
using pointer = void*;

constexpr deleter() noexcept = default;

template <class Ty2, std::enable_if_t<std::is_convertible_v<Ty2*, Ty*>, int> = 0>
explicit deleter(const deleter<Ty2>&) noexcept {}

void operator()(Ty* Ptr) const noexcept /* strengthened */ { // delete a pointer
delete Ptr;
}
};

struct A {};

int main(int argc, char* argv[])
{
std::cout << typeid(std::_Get_deleter_pointer_type<A, std::remove_reference_t<std::default_delete<A>>>::type).name() << std::endl;
std::cout << typeid(std::_Get_deleter_pointer_type<A, std::remove_reference_t<deleter<A>>>::type).name() << std::endl;
}

输出结果:

1
2
struct A * __ptr64
void * __ptr64

然后我们来看一下std::unique_ptr的 private block:

1
2
3
4
5
private:
template <class, class>
friend class unique_ptr;

_Compressed_pair<_Dx, pointer> _Mypair;

只是定义了一个_Compressed_pair来同时保存删除器和裸指针,这里要注意的是,pair中保存的顺序,first是删除器,secondpointer

接下来看一下std::unique_ptr的各种构造和operator=,首先是默认构造:

1
2
template <class _Dx2 = _Dx, _Unique_ptr_enable_default_t<_Dx2> = 0>
constexpr unique_ptr() noexcept : _Mypair(_Zero_then_variadic_args_t{}) {}

这里的_Zero_then_variadic_args_t在上面也出现过,是一个空结构体,作用于用于标记参数数量,然后决定具体使用_Compressed_pair的哪一个构造。

接下来是nullptr_t的构造和operator=

1
2
3
4
5
6
7
template <class _Dx2 = _Dx, _Unique_ptr_enable_default_t<_Dx2> = 0>
constexpr unique_ptr(nullptr_t) noexcept : _Mypair(_Zero_then_variadic_args_t{}) {}

unique_ptr& operator=(nullptr_t) noexcept {
reset();
return *this;
}

主要是针对空指针的处理,当使用空指针进行构造和赋值的时候,相当于把std::unique_ptr重置。

接下来是更常用的构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class _Dx2>
using _Unique_ptr_enable_default_t =
enable_if_t<conjunction_v<negation<is_pointer<_Dx2>>, is_default_constructible<_Dx2>>, int>;

template <class _Dx2 = _Dx, _Unique_ptr_enable_default_t<_Dx2> = 0>
explicit unique_ptr(pointer _Ptr) noexcept : _Mypair(_Zero_then_variadic_args_t{}, _Ptr) {}

template <class _Dx2 = _Dx, enable_if_t<is_constructible_v<_Dx2, const _Dx2&>, int> = 0>
unique_ptr(pointer _Ptr, const _Dx& _Dt) noexcept : _Mypair(_One_then_variadic_args_t{}, _Dt, _Ptr) {}

template <class _Dx2 = _Dx,
enable_if_t<conjunction_v<negation<is_reference<_Dx2>>, is_constructible<_Dx2, _Dx2>>, int> = 0>
unique_ptr(pointer _Ptr, _Dx&& _Dt) noexcept : _Mypair(_One_then_variadic_args_t{}, _STD move(_Dt), _Ptr) {}

template <class _Dx2 = _Dx,
enable_if_t<conjunction_v<is_reference<_Dx2>, is_constructible<_Dx2, remove_reference_t<_Dx2>>>, int> = 0>
unique_ptr(pointer, remove_reference_t<_Dx>&&) = delete;

单参数的构造只传入指针,当满足删除器类型不是指针而且可默认构造的情况下启用,直接把传入的裸指针存入pair,这时候由于删除器是可默认构造的,pair中保存的删除器会被直接默认构造。另外的三个也需要满足一定条件,这时可以从外部传入删除器,并将其保存至pair中。

然后是移动构造:

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
template <class _Dx2 = _Dx, enable_if_t<is_move_constructible_v<_Dx2>, int> = 0>
unique_ptr(unique_ptr&& _Right) noexcept
: _Mypair(_One_then_variadic_args_t{}, _STD forward<_Dx>(_Right.get_deleter()), _Right.release()) {}

template <class _Ty2, class _Dx2,
enable_if_t<
conjunction_v<negation<is_array<_Ty2>>, is_convertible<typename unique_ptr<_Ty2, _Dx2>::pointer, pointer>,
conditional_t<is_reference_v<_Dx>, is_same<_Dx2, _Dx>, is_convertible<_Dx2, _Dx>>>,
int> = 0>
unique_ptr(unique_ptr<_Ty2, _Dx2>&& _Right) noexcept
: _Mypair(_One_then_variadic_args_t{}, _STD forward<_Dx2>(_Right.get_deleter()), _Right.release()) {}

#if _HAS_AUTO_PTR_ETC
template <class _Ty2,
enable_if_t<conjunction_v<is_convertible<_Ty2*, _Ty*>, is_same<_Dx, default_delete<_Ty>>>, int> = 0>
unique_ptr(auto_ptr<_Ty2>&& _Right) noexcept : _Mypair(_Zero_then_variadic_args_t{}, _Right.release()) {}
#endif // _HAS_AUTO_PTR_ETC

template <class _Ty2, class _Dx2,
enable_if_t<conjunction_v<negation<is_array<_Ty2>>, is_assignable<_Dx&, _Dx2>,
is_convertible<typename unique_ptr<_Ty2, _Dx2>::pointer, pointer>>,
int> = 0>
unique_ptr& operator=(unique_ptr<_Ty2, _Dx2>&& _Right) noexcept {
reset(_Right.release());
_Mypair._Get_first() = _STD forward<_Dx2>(_Right._Mypair._Get_first());
return *this;
}

template <class _Dx2 = _Dx, enable_if_t<is_move_assignable_v<_Dx2>, int> = 0>
unique_ptr& operator=(unique_ptr&& _Right) noexcept {
if (this != _STD addressof(_Right)) {
reset(_Right.release());
_Mypair._Get_first() = _STD forward<_Dx>(_Right._Mypair._Get_first());
}
return *this;
}

条件判断比较多,不过归根到底都是直接移动删除器,然后调用原std::unique_ptrrelease()释放裸指针,再将裸指针填入新的pair中。

最后,有关构造和赋值比较重要的是被删除的两个方法:

1
2
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;

这直接决定了std::unique_ptr没办法复制与相互赋值,这是语义上独享内存所有权的基石。

我们再看析构:

1
2
3
4
5
~unique_ptr() noexcept {
if (_Mypair._Myval2) {
_Mypair._Get_first()(_Mypair._Myval2);
}
}

比较简单,先判断pair中保存的裸指针是否为空,不为空的话则调用pair中保存的deleter来释放内存。

std::unique_ptr和大部分 stl 类一样提供了swap()方法:

1
2
3
4
void swap(unique_ptr& _Right) noexcept {
_Swap_adl(_Mypair._Myval2, _Right._Mypair._Myval2);
_Swap_adl(_Mypair._Get_first(), _Right._Mypair._Get_first());
}

有关删除器,std::unique_ptr还提供了getter方法来获取删除器:

1
2
3
4
5
6
7
_NODISCARD _Dx& get_deleter() noexcept {
return _Mypair._Get_first();
}

_NODISCARD const _Dx& get_deleter() const noexcept {
return _Mypair._Get_first();
}

接下来看与指针息息相关的几个操作符重载:

1
2
3
4
5
6
7
8
9
10
11
_NODISCARD add_lvalue_reference_t<_Ty> operator*() const noexcept /* strengthened */ {
return *_Mypair._Myval2;
}

_NODISCARD pointer operator->() const noexcept {
return _Mypair._Myval2;
}

explicit operator bool() const noexcept {
return static_cast<bool>(_Mypair._Myval2);
}

这使得我们可以像使用普通指针一样使用std::unique_ptr

最后是三个对裸指针的直接操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_NODISCARD pointer get() const noexcept {
return _Mypair._Myval2;
}

pointer release() noexcept {
return _STD exchange(_Mypair._Myval2, nullptr);
}

void reset(pointer _Ptr = nullptr) noexcept {
pointer _Old = _STD exchange(_Mypair._Myval2, _Ptr);
if (_Old) {
_Mypair._Get_first()(_Old);
}
}

从代码上可以看出来,get()release()并不会触发内存销毁,而reset()的内存销毁也是有条件的,只有reset()为空指针时才会触发销毁。

整体上来看std::unique_ptr的代码并不算复杂,只是裸指针的一层封装而已。

1
unique_ptr<_Ty[], _Dx>

std::unique_ptr还有另外一个定义,即:

1
2
template <class _Ty, class _Dx>
class unique_ptr<_Ty[], _Dx>;

这个定义是针对数组的。大部分代码其实都跟前面相同,我们主要关注不一样的地方,首先是default_delete的特化:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <class _Ty>
struct default_delete<_Ty[]> { // default deleter for unique_ptr to array of unknown size
constexpr default_delete() noexcept = default;

template <class _Uty, enable_if_t<is_convertible_v<_Uty (*)[], _Ty (*)[]>, int> = 0>
default_delete(const default_delete<_Uty[]>&) noexcept {}

template <class _Uty, enable_if_t<is_convertible_v<_Uty (*)[], _Ty (*)[]>, int> = 0>
void operator()(_Uty* _Ptr) const noexcept /* strengthened */ { // delete a pointer
static_assert(0 < sizeof(_Uty), "can't delete an incomplete type");
delete[] _Ptr;
}
};

针对数组,这里的operator()的实现由delete改成了delete[]

然后是一些操作符重载上的不同:

1
2
3
4
5
6
7
_NODISCARD _Ty& operator[](size_t _Idx) const noexcept /* strengthened */ {
return _Mypair._Myval2[_Idx];
}

explicit operator bool() const noexcept {
return static_cast<bool>(_Mypair._Myval2);
}

与普通的std::unique_ptr不同的是,它不再提供operator*operator->,取而代之的是operator[],这也与普通数组的操作一致。

其他的一些代码,主要是构造、析构、operator=,基本都与普通的定义一致,就不再赘述了。

make_unique

std::make_unique的用法在前面也说过了,主要是用于更优雅地构造std::unique_ptr的,代码其实也很简单,只是一层简单的透传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// FUNCTION TEMPLATE make_unique
template <class _Ty, class... _Types, enable_if_t<!is_array_v<_Ty>, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique(_Types&&... _Args) { // make a unique_ptr
return unique_ptr<_Ty>(new _Ty(_STD forward<_Types>(_Args)...));
}

template <class _Ty, enable_if_t<is_array_v<_Ty> && extent_v<_Ty> == 0, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique(const size_t _Size) { // make a unique_ptr
using _Elem = remove_extent_t<_Ty>;
return unique_ptr<_Ty>(new _Elem[_Size]());
}

template <class _Ty, class... _Types, enable_if_t<extent_v<_Ty> != 0, int> = 0>
void make_unique(_Types&&...) = delete;

在 C++ 20 之后,标准库还提供了std::make_unique_for_overwrite来构造std::unique_ptr,与std::make_unique的区别在于,它不需要传递额外参数,直接使用目标类型的默认构造,下面是源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#if _HAS_CXX20
// FUNCTION TEMPLATE make_unique_for_overwrite
template <class _Ty, enable_if_t<!is_array_v<_Ty>, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique_for_overwrite() { // make a unique_ptr with default initialization
return unique_ptr<_Ty>(new _Ty);
}

template <class _Ty, enable_if_t<is_unbounded_array_v<_Ty>, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique_for_overwrite(
const size_t _Size) { // make a unique_ptr with default initialization
using _Elem = remove_extent_t<_Ty>;
return unique_ptr<_Ty>(new _Elem[_Size]);
}

template <class _Ty, class... _Types, enable_if_t<is_bounded_array_v<_Ty>, int> = 0>
void make_unique_for_overwrite(_Types&&...) = delete;
#endif // _HAS_CXX20

也很简单,透传而已。

总结

  • std::unique_ptr有两个定义,分别针对普通类型和数组类型
  • std::unique_ptr第二个模板参数是删除器,不传递的情况下使用的是default_delete
  • std::unique_ptr重载了指针、数组相关的操作符,实现与裸指针类似的操作
  • std::unique_ptr不允许拷贝,语义上表示一段内存的所有权,转移所有权需要使用std::move产生移动语义
  • std::unique_ptr提供了get()release()来直接对裸指针进行操作
  • std::unqiue_ptr可以直接与nullptr比较,也可以使用nullptr赋值
  • 可以使用std::make_uniquestd::make_unique_for_overwrite来更方便地构造std::unique_ptr