Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

用C/C++开发的程序执行效率很高,但却经常受到内存泄漏的困扰。本文提供一种通过wrap malloc查找memory leak的思路,依靠这个方法,笔者紧急解决了内存泄漏问题,避免项目流血上大促,该方法在日后工作中大放光彩,发现了项目中大量沉疴已久的内存泄漏问题。

什么是内存泄漏?

动态申请的内存丢失引用,造成没有办法回收它(我知道杠jing要说进程退出前系统会统一回收),这便是内存泄漏。

Java等编程语言会自动管理内存回收,而C/C++需要显式的释放,有很多手段可以避免内存泄漏,比如RAII,比如智能指针(大多基于引用计数计数),比如内存池。

理论上,只要我们足够小心,在每次申请的时候,都牢记释放,那这个世界就清净了,但现实往往没有那么美好,比如抛异常了,释放内存的语句执行不到,又或者某菜鸟程序员不小心埋了一个雷,所以,我们必须直面真实的世界,那就是我们会遭遇内存泄漏。

怎么查内存泄漏?

我们可以review代码,但从海量代码里找到隐藏的问题,这如同大海捞针,往往两手空空。

所以,我们需要借助工具,比如valgrind,但这些找内存泄漏的工具,往往对你使用动态内存的方式有某种期待,或者说约束,比如常驻内存的对象会被误报出来,然后真正有用的信息会掩盖在误报的汪洋大海里。很多时候,甚至valgrind根本解决不了日常项目中的问题。

所以很多著名的开源项目,为了能用valgrind跑,都费大力气,大幅修改源代码,从而使得项目符合valgrind的要求,满足这些要求,用valgrind跑完没有任何报警的项目叫valgrind干净。

既然这些玩意儿都中看不中用,所以,求人不如求己,还是得自力更生。

什么是动态内存分配器?

动态内存分配器是介于kernel跟应用程序之间的一个函数库,glibc提供的动态内存分配器叫ptmalloc,它也是应用最广泛的动态内存分配器实现。

从kernel角度看,动态内存分配器属于应用程序层;而从应用程序的角度看,动态内存分配器属于系统层。

应用程序可以通过mmap系统直接向kernel申请动态内存,也可以通过动态内存分配器的malloc接口分配内存,而动态内存分配器会通过sbrk、mmap向kernel分配内存,所以应用程序通过free释放的内存,并不一定会真正返还给系统,它也有可能被动态内存分配器缓存起来。

google有自己的动态内存分配器tcmalloc,另外jemalloc也是著名的动态内存分配器,他们有不同的性能表现,也有不同的缓存和分配策略。你可以用它们替换linux系统glibc自带的ptmalloc。

new/delete跟malloc/free的关系

new是c++的用法,比如Foo *f = new Foo,其实它分为3步。

  1. 通过operator new()分配sizeof(Foo)的内存,最终通过malloc分配。
  2. 在新分配的内存上构建Foo对象。
  3. 返回新构建的对象地址。

new=分配内存+构造+返回,而delete则是等于析构+free。所以搞定malloc、free就是从根本上搞定动态内存分配。

chunk

每次通过malloc返回的一块内存叫一个chunk,动态内存分配器是这样定义的,后面我们都这样称呼。

wrap malloc

gcc支持wrap,即通过传递-Wl,--wrap,malloc的方式,可以改变调用malloc的行为,把对malloc的调用链接到自定义的__wrap_malloc(size_t)函数,而我们可以在__wrap_malloc(size_t)函数的实现中通过__real_malloc(size_t)真正分配内存,而后我们可以做搞点小动作。

同样,我们可以wrap freemallocfree是配对的,当然也有其他相关API,比如callocreallocvalloc,但这根本上还是malloc+free,比如realloc就是malloc + free。

怎么去定位内存泄漏呢?

我们会malloc各种不同size的chunk,也就是每种不同size的chunk会有不同数量,如果我们能够跟踪每种size的chunk数量,那就可以知道哪种size的chunk在泄漏。很简单,如果该size的chunk数量一直在增长,那它很可能泄漏。

光知道某种size的chunk泄漏了还不够,我们得知道是哪个调用路径上导致该size的chunk被分配,从而去检查是不是正确释放了。

怎么跟踪到每种size的chunk数量?

我们可以维护一个全局 unsigned int malloc_map[1024 * 1024]数组,该数组的下标就是chunk的size,malloc_map[size]的值就对应到该size的chunk分配量。

这等于维护了一个chunk size到chunk count的映射表,它足够快,而且它可以覆盖到0 ~ 1M大小的chunk的范围,它已经足够大了,试想一次分配一兆的块已经很恐怖了,可以覆盖到大部分场景。

那大于1M的块怎么办呢?我们可以通过log记录下来。

  • __wrap_malloc里,++malloc_map[size]
  • __wrap_free里,--malloc_map[size]

很简单,我们通过malloc_map记录了各size的chunk的分配量。

如何知道释放的chunk的size?

不对,free(void *p)只有一个参数,我如何知道释放的chunk的size呢?怎么办?

我们通过在__wrap_malloc(size_t)的时候,分配8+size的chunk,也就是多分配8字节,开始的8字节存储该chunk的size,然后返回的是(char*)chunk + 8,也就是偏移8个字节返回给调用malloc的应用程序。

这样在free的时候,传入参数void* p,我们把p往前移动8个字节,解引用就能得到该chunk的大小,而该大小值就是前一步,在__wrap_malloc的时候设置的size。

好了,我们真正做到记录各size的chunk数量了,它就存在于malloc_map[1M]的数组中,假设64个字节的chunk一直在被分配,数量一直在增长,我们觉得该size的chunk很有可能泄漏,那怎么定位到是哪里调用过来的呢?

如何记录调用链?

我们可以维护一个toplist数组,该数组假设有10个元素,它保存的是chunk数最大的10种size,这个很容易做到,通过对malloc_map取top 10就行。

然后我们在__wrap_malloc(size_t)里,测试该size是不是toplist之一,如果是的话,那我们通过glibc的backtrace把调用堆栈dump到log文件里去。

注意:这里不能再分配内存,所以你只能使用backtrace,而不能使用backtrace_symbols,这样你只能得到调用堆栈的符号地址,而不是符号名。

如何把符号地址转换成符号名,也就是对应到代码行呢?

addr2line

addr2line工具可以做到,你可以追查到调用链,进而定位到内存泄漏的问题。

至此,你已经get到了整个核心思想。

当然,实际项目中,我们做的更多,我们不仅仅记录了toplist size,还记录了各size chunk的增量toplist,会记录大块的malloc/free,会wrap更多的API。

总结一下:通过wrap malloc/free + backtrace + addr2line,你就可以定位到内存泄漏了,恭喜大家。

使用valgrind

valgrind是什么?

Valgrind是一套Linux下,开放源代码(GPL V2)的仿真调试工具的集合。Valgrind由内核(core)以及基于内核的其他调试工具组成。内核类似于一个框架(framework),它模拟了一个CPU环境,并提供服务给其他工具;而其他工具则类似于插件 (plug-in),利用内核提供的服务完成各种特定的内存调试任务。Valgrind的体系结构如下图所示:

Valgrind包括如下一些工具:

Memcheck

最常用的工具,用来检测程序中出现的内存问题,所有对内存的读写都会被检测到,一切对malloc()/free()/new/delete的调用都会被捕获。所以,它能检测以下问题:

  • 对未初始化内存的使用;
  • 读/写释放后的内存块;
  • 读/写超出malloc分配的内存块;
  • 读/写不适当的栈中内存块;
  • 内存泄漏,指向一块内存的指针永远丢失;
  • 不正确的malloc/free或new/delete匹配;
  • memcpy()相关函数中的dst和src指针重叠。

Callgrind

和gprof类似的分析工具,但它对程序的运行观察更是入微,能给我们提供更多的信息。和gprof不同,它不需要在编译源代码时附加特殊选项,但加上调试选项是推荐的。Callgrind收集程序运行时的一些数据,建立函数调用关系图,还可以有选择地进行cache模拟。在运行结束时,它会把分析数据写入一个文件。callgrind_annotate可以把这个文件的内容转化成可读的形式。

Cachegrind

Cache分析器,它模拟CPU中的一级缓存I1,Dl和二级缓存,能够精确地指出程序中cache的丢失和命中。如果需要,它还能够为我们提供cache丢失次数,内存引用次数,以及每行代码,每个函数,每个模块,整个程序产生的指令数。这对优化程序有很大的帮助。

Helgrind

它主要用来检查多线程程序中出现的竞争问题。Helgrind寻找内存中被多个线程访问,而又没有一贯加锁的区域,这些区域往往是线程之间失去同步的地方,而且会导致难以发掘的错误。Helgrind实现了名为“Eraser”的竞争检测算法,并做了进一步改进,减少了报告错误的次数。不过,Helgrind仍然处于实验阶段。

Massif

堆栈分析器,它能测量程序在堆栈中使用了多少内存,告诉我们堆块,堆管理块和栈的大小。Massif能帮助我们减少内存的使用,在带有虚拟内存的现代系统中,它还能够加速我们程序的运行,减少程序停留在交换区中的几率。

此外,lackey和nulgrind也会提供。Lackey是小型工具,很少用到;Nulgrind只是为开发者展示如何创建一个工具。

原理

一个典型的Linux C程序内存空间由如下几部分组成:

  • 代码段(.text)。这里存放的是CPU要执行的指令。代码段是可共享的,相同的代码在内存中只会有一个拷贝,同时这个段是只读的,防止程序由于错误而修改自身的指令。
  • 初始化数据段(.data)。这里存放的是程序中需要明确赋初始值的变量,例如位于所有函数之外的全局变量:int val=”100”。需要强调的是,以上两段都是位于程序的可执行文件中,内核在调用exec函数启动该程序时从源程序文件中读入。
  • 未初始化数据段(.bss)。位于这一段中的数据,内核在执行该程序前,将其初始化为0或者null。例如出现在任何函数之外的全局变量:int sum;
  • 堆(Heap)。这个段用于在程序中进行动态内存申请,例如经常用到的malloc,new系列函数就是从这个段中申请内存。
  • 栈(Stack)。函数中的局部变量以及在函数调用过程中产生的临时变量都保存在此段中。

Memcheck 能够检测出内存问题,关键在于其建立了两个全局表。

  • Valid-Value 表:
    • 对于进程的整个地址空间中的每一个字节(byte),都有与之对应的 8 个 bits;对于 CPU 的每个寄存器,也有一个与之对应的 bit 向量。这些 bits 负责记录该字节或者寄存器值是否具有有效的、已初始化的值。
  • Valid-Address 表
  • 对于进程整个地址空间中的每一个字节(byte),还有与之对应的 1 个 bit,负责记录该地址是否能够被读写。

检测原理

当要读写内存中某个字节时,首先检查这个字节对应的 A bit。如果该A bit显示该位置是无效位置,memcheck 则报告读写错误。

内核(core)类似于一个虚拟的 CPU 环境,这样当内存中的某个字节被加载到真实的 CPU 中时,该字节对应的 V bit 也被加载到虚拟的 CPU 环境中。一旦寄存器中的值,被用来产生内存地址,或者该值能够影响程序输出,则 memcheck 会检查对应的V bits,如果该值尚未初始化,则会报告使用未初始化内存错误。

Valgrind 使用

用法: valgrind [options] prog-and-args

  • [options]:常用选项,适用于所有Valgrind工具
  • -tool=<name>:最常用的选项。运行 valgrind中名为toolname的工具。默认memcheck。
  • h –help:显示帮助信息。
  • -version:显示valgrind内核的版本,每个工具都有各自的版本。
  • q –quiet:安静地运行,只打印错误信息。
  • v –verbose:更详细的信息, 增加错误数统计。
  • -trace-children=no|yes:跟踪子线程? [no]
  • -track-fds=no|yes:跟踪打开的文件描述?[no]
  • -time-stamp=no|yes:增加时间戳到LOG信息? [no]
  • -log-fd=<number>:输出LOG到描述符文件 [2=stderr]
  • -log-file=<file>:将输出的信息写入到filename.PID的文件里,PID是运行程序的进行ID
  • -log-file-exactly=<file>:输出LOG信息到 file
  • -log-file-qualifier=<VAR>:取得环境变量的值来做为输出信息的文件名。 [none]
  • -log-socket=ipaddr:port:输出LOG到socket ,ipaddr:port

LOG信息输出

  • -xml=yes:将信息以xml格式输出,只有memcheck可用
  • -num-callers=<number> show <number>:callers in stack traces [12]
  • -error-limit=no|yes:如果太多错误,则停止显示新错误? [yes]
  • -error-exitcode=<number>:如果发现错误则返回错误代码 [0=disable]
  • -db-attach=no|:当出现错误,valgrind会自动启动调试器gdb。[no]
  • -db-command=<command>:启动调试器的命令行选项 [gdb -nw %f %p]

适用于Memcheck工具的相关选项:

  • -leak-check=no|summary|full:要求对leak给出详细信息? [summary]
  • -leak-resolution=low|med|high:how much bt merging in leak check [low]
  • -show-reachable=no|yes:show reachable blocks in leak check? [no]

Valgrind 使用举例(一)

1
mpirun -n 12 valgrind run/cpl/c_coupler/exe/c_coupler : -n 10 valgrind run/atm/gamil/exe/gamil : -n 4 valgrind run/ocn/licom/exe/licom : -n 4 valgrind run/sice/cice/exe/cice : -n 4 valgrind run/lnd/clm/exe/clm

下面是一段有问题的C程序代码test.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# include <stdlib.h>
void f(void)
{
int* x = malloc(10 * sizeof(int));
x[10] = 0; //问题1: 数组下标越界
} //问题2: 内存没有释放

int main(void)
{
f();
return 0;
}

valgrind --tool=memcheck --leak-check=full ./test

问题分析:

  • 对于位于程序中不同段的变量,其初始值是不同的,全局变量和静态变量初始值为0,而局部变量和动态申请的变量,其初始值为随机值。如果程序使用了为随机值的变量,那么程序的行为就变得不可预期。

数组越界/内存未释放

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdlib.h>
void k(void)
{
int *x = malloc(8 * sizeof(int));
x[9] = 0; //数组下标越界
} //内存未释放

int main(void)
{
k();
return 0;
}

1)编译程序test.c

1
gcc -Wall test.c -g -o test #Wall提示所有告警,-g 调试信息,-o输出

2)使用Valgrind检查程序BUG

1
2
valgrind --tool=memcheck --leak-check=full ./test
#--leak-check=full 所有泄露检查

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
==2989== Memcheck, a memory error detector
==2989== Copyright (C) 2002-2012, and GNU GPL'd, by Julian Seward
et al.
==2989== Using Valgrind-3.8.1 and LibVEX; rerun with -h for
copyright info
==2989== Command: ./test
==2989==
==2989== Invalid write of size 4
==2989== at 0x4004E2: k (test.c:5)
==2989== by 0x4004F2: main (test.c:10)
==2989== Address 0x4c27064 is 4 bytes after a block of size 32 alloc'd
==2989== at 0x4A06A2E: malloc (vg_replace_malloc.c:270)
==2989== by 0x4004D5: k (test.c:4)
==2989== by 0x4004F2: main (test.c:10)
==2989==
==2989==
==2989== HEAP SUMMARY:
==2989== in use at exit: 32 bytes in 1 blocks
==2989== total heap usage: 1 allocs, 0 frees, 32 bytes allocated
==2989==
==2989== 32 bytes in 1 blocks are definitely lost in loss record 1 of 1
==2989== at 0x4A06A2E: malloc (vg_replace_malloc.c:270)
==2989== by 0x4004D5: k (test.c:4)
==2989== by 0x4004F2: main (test.c:10)
==2989==
==2989== LEAK SUMMARY:
==2989== definitely lost: 32 bytes in 1 blocks
==2989== indirectly lost: 0 bytes in 0 blocks
==2989== possibly lost: 0 bytes in 0 blocks
==2989== still reachable: 0 bytes in 0 blocks
==2989==suppressed: 0 bytes in 0 blocks
==2989==
==2989== For counts of detected and suppressed errors, rerun with: -v
==2989== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 6 from 6)

内存释放后读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
char *p = malloc(1); //分配
*p = 'a';

char c = *p;

printf("\n [%c]\n",c);

free(p); //释放
c = *p; //取值
return 0;
}

1)编译程序t2.c

1
gcc -Wall t2.c -g -o t2

2)使用Valgrind检查程序BUG

1
valgrind --tool=memcheck --leak-check=full ./t2

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
==3058== Memcheck, a memory error detector
==3058== Copyright (C) 2002-2012, and GNU GPL'd, by Julian
Seward et al.
==3058== Using Valgrind-3.8.1 and LibVEX; rerun with -h
for copyright info
==3058== Command: ./t2
==3058==

[a]
==3058== Invalid read of size 1
==3058== at 0x4005A3: main (t2.c:14)
==3058== Address 0x4c27040 is 0 bytes inside a block of size
1 free'd
==3058== at 0x4A06430: free (vg_replace_malloc.c:446)
==3058== by 0x40059E: main (t2.c:13)
==3058==
==3058==
==3058== HEAP SUMMARY:
==3058== in use at exit: 0 bytes in 0 blocks
==3058== total heap usage: 1 allocs, 1 frees, 1 bytes allocated
==3058==
==3058== All heap blocks were freed -- no leaks are possible
==3058==
==3058== For counts of detected and suppressed errors, rerun with:
-v
==3058== ERROR SUMMARY: 1 errors from 1 contexts
(suppressed: 6 from 6)

从上输出内容可以看到,Valgrind检测到无效的读取操作然后输出“Invalid read of size 1”。

无效读写

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
char *p = malloc(1); //分配1字节
*p = 'a';
char c = *(p+1); //地址加1
printf("\n [%c]\n",c);
free(p);
return 0;
}

1)编译程序t3.c

1
gcc -Wall t3.c -g -o t3

2)使用Valgrind检查程序BUG

1
valgrind --tool=memcheck --leak-check=full ./t3

3) 运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
==3128== Memcheck, a memory error detector
==3128== Copyright (C) 2002-2012, and GNU GPL'd, by Julian Seward et al.
==3128== Using Valgrind-3.8.1 and LibVEX; rerun with -h for copyright info
==3128== Command: ./t3
==3128==
==3128== Invalid read of size 1 #无效读取
==3128==at 0x400579: main (t3.c:9)
==3128==Address 0x4c27041 is 0 bytes after a block of size 1 alloc'd
==3128==at 0x4A06A2E: malloc (vg_replace_malloc.c:270)
==3128==by 0x400565: main (t3.c:6)
==3128==[]
==3128==
==3128== HEAP SUMMARY:
==3128==in use at exit: 0 bytes in 0 blocks
==3128==total heap usage: 1 allocs, 1 frees, 1 bytes allocated
==3128==
==3128== All heap blocks were freed -- no leaks are possible
==3128==
==3128== For counts of detected and suppressed errors, rerun with: -v
==3128== ERROR SUMMARY: 1 errors from 1 contexts
(suppressed: 6 from 6)

内存泄露

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
int *p = malloc(1);
*p = 'x';
char c = *p;
printf("%c\n",c); //申请后未释放
return 0;
}

1)编译程序t4.c

1
gcc -Wall t4.c -g -o t4

2)使用Valgrind检查程序BUG

1
valgrind --tool=memcheck --leak-check=full ./t4

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
35
36
==3221== Memcheck, a memory error detector
==3221== Copyright (C) 2002-2012, and GNU GPL'd, by Julian Seward et al.
==3221== Using Valgrind-3.8.1 and LibVEX; rerun with -h for copyright info
==3221== Command: ./t4
==3221==
==3221== Invalid write of size 4
==3221==at 0x40051E: main (t4.c:7)
==3221==Address 0x4c27040 is 0 bytes inside a block of size 1 alloc'd
==3221==at 0x4A06A2E: malloc (vg_replace_malloc.c:270)
==3221==by 0x400515: main (t4.c:6)
==3221==
==3221== Invalid read of size 4
==3221==at 0x400528: main (t4.c:8)
==3221==Address 0x4c27040 is 0 bytes inside a block of size 1 alloc'd
==3221==at 0x4A06A2E: malloc (vg_replace_malloc.c:270)
==3221==by 0x400515: main (t4.c:6)
==3221==
==3221==
==3221== HEAP SUMMARY:
==3221==in use at exit: 1 bytes in 1 blocks
==3221==total heap usage: 1 allocs, 0 frees, 1 bytes allocated
==3221==
==3221== 1 bytes in 1 blocks are definitely lost in loss record 1 of 1
==3221==at 0x4A06A2E: malloc (vg_replace_malloc.c:270)
==3221==by 0x400515: main (t4.c:6)
==3221==
==3221== LEAK SUMMARY:
==3221==definitely lost: 1 bytes in 1 blocks
==3221==indirectly lost: 0 bytes in 0 blocks
==3221== possibly lost: 0 bytes in 0 blocks
==3221==still reachable: 0 bytes in 0 blocks
==3221== suppressed: 0 bytes in 0 blocks
==3221==
==3221== For counts of detected and suppressed errors, rerun with: -v
==3221== ERROR SUMMARY: 3 errors from 3 contexts
(suppressed: 6 from 6)

从检查结果看,可以发现内存泄露。

内存多次释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char *p;
p=(char *)malloc(100);
if(p)
printf("Memory Allocated at: %s/n",p);
else
printf("Not Enough Memory!/n");
free(p); //重复释放
free(p);
free(p);
return 0;
}

1)编译程序t5.c

1
gcc -Wall t5.c -g -o t5

2)使用Valgrind检查程序BUG

1
valgrind --tool=memcheck --leak-check=full ./t5

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
==3294== Memcheck, a memory error detector
==3294== Copyright (C) 2002-2012, and GNU GPL'd, by Julian Seward et al.
==3294== Using Valgrind-3.8.1 and LibVEX; rerun with -h for copyright info
==3294== Command: ./t5
==3294==
==3294== Conditional jump or move depends on uninitialised value(s)
==3294== at 0x3CD4C47E2C: vfprintf (in /lib64/libc-2.12.so)
==3294== by 0x3CD4C4F189: printf (in /lib64/libc-2.12.so)
==3294== by 0x400589: main (t5.c:9)
==3294==
==3294== Invalid free() / delete / delete[] / realloc()
==3294== at 0x4A06430: free (vg_replace_malloc.c:446)
==3294== by 0x4005B5: main (t5.c:13)
==3294== Address 0x4c27040 is 0 bytes inside a block of size
100 free'd
==3294== at 0x4A06430: free (vg_replace_malloc.c:446)
==3294== by 0x4005A9: main (t5.c:12)
==3294==
==3294== Invalid free() / delete / delete[] / realloc()
==3294== at 0x4A06430: free (vg_replace_malloc.c:446)
==3294== by 0x4005C1: main (t5.c:14)
==3294== Address 0x4c27040 is 0 bytes inside a block of size
100 free'd
==3294== at 0x4A06430: free (vg_replace_malloc.c:446)
==3294== by 0x4005A9: main (t5.c:12)
==3294==
Memory Allocated at: /n==3294==
==3294== HEAP SUMMARY:
==3294== in use at exit: 0 bytes in 0 blocks
==3294== total heap usage: 1 allocs, 3 frees, 100 bytes allocated

从上面的输出可以看到(标注), 该功能检测到我们对同一个指针调用了3次释放内存操作。

内存动态管理

常见的内存分配方式分三种:静态存储,栈上分配,堆上分配。全局变量属于静态存储,它们是在编译时就被分配了存储空间,函数内的局部变量属于栈上分配,而最灵活的内存使用方式当属堆上分配,也叫做内存动态分配了。常用的内存动态分配函数包括:malloc, alloc, realloc, new等,动态释放函数包括free, delete。

一旦成功申请了动态内存,我们就需要自己对其进行内存管理,而这又是最容易犯错误的。下面的一段程序,就包括了内存动态管理中常见的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>
int main(int argc,char *argv[])
{
int i;
char* p = (char*)malloc(10);
char* pt=p;
for(i = 0;i < 10;i++)
{
p[i] = 'z';
}
free(p);
pt[1] = 'x';
free(pt);
return 0;
}

1)编译程序t6.c

1
gcc -Wall t6.c -g -o t6

2)使用Valgrind检查程序BUG

1
valgrind --tool=memcheck --leak-check=full ./t6

3) 运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
==3380== Memcheck, a memory error detector
==3380== Copyright (C) 2002-2012, and GNU GPL'd, by Julian Seward et al.
==3380== Using Valgrind-3.8.1 and LibVEX; rerun with -h for copyright info
==3380== Command: ./t6
==3380==
==3380== Invalid write of size 1
==3380==at 0x40055C: main (t6.c:14)
==3380==Address 0x4c27041 is 1 bytes inside a block of size 10 free'd
==3380==at 0x4A06430: free (vg_replace_malloc.c:446)
==3380==by 0x400553: main (t6.c:13)
==3380==
==3380== Invalid free() / delete / delete[] / realloc()
==3380==at 0x4A06430: free (vg_replace_malloc.c:446)
==3380==by 0x40056A: main (t6.c:15)
==3380==Address 0x4c27040 is 0 bytes inside a block of size 10 free'd
==3380==at 0x4A06430: free (vg_replace_malloc.c:446)
==3380==by 0x400553: main (t6.c:13)
==3380==
==3380==
==3380== HEAP SUMMARY:
==3380==in use at exit: 0 bytes in 0 blocks
==3380==total heap usage: 1 allocs, 2 frees, 10 bytes allocated

申请内存在使用完成后就要释放。如果没有释放,或少释放了就是内存泄露;多释放也会产生问题。上述程序中,指针p和pt指向的是同一块内存,却被先后释放两次。系统会在堆上维护一个动态内存链表,如果被释放,就意味着该块内存可以继续被分配给其他部分,如果内存被释放后再访问,就可能覆盖其他部分的信息,这是一种严重的错误,上述程序第14行中就在释放后仍然写这块内存。

输出结果显示,第13行分配和释放函数不一致;第14行发生非法写操作,也就是往释放后的内存地址写值;第15行释放内存函数无效。

massif

Massif 命令行选项

关于 massif 命令行选项,可以直接查看 valgrind 的 help 信息:

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
MASSIF OPTIONS
--heap=<yes|no> [default: yes]
Specifies whether heap profiling should be done.

--heap-admin=<size> [default: 8]
If heap profiling is enabled, gives the number of administrative bytes per block to use. This should be an estimate of the average, since it may vary. For example, the
allocator used by glibc on Linux requires somewhere between 4 to 15 bytes per block, depending on various factors. That allocator also requires admin space for freed blocks,
but Massif cannot account for this.

--stacks=<yes|no> [default: no]
Specifies whether stack profiling should be done. This option slows Massif down greatly, and so is off by default. Note that Massif assumes that the main stack has size zero
at start-up. This is not true, but doing otherwise accurately is difficult. Furthermore, starting at zero better indicates the size of the part of the main stack that a user
program actually has control over.

--pages-as-heap=<yes|no> [default: no]
Tells Massif to profile memory at the page level rather than at the malloc'd block level. See above for details.

--depth=<number> [default: 30]
Maximum depth of the allocation trees recorded for detailed snapshots. Increasing it will make Massif run somewhat more slowly, use more memory, and produce bigger output
files.

--alloc-fn=<name>
Functions specified with this option will be treated as though they were a heap allocation function such as malloc. This is useful for functions that are wrappers to malloc or
new, which can fill up the allocation trees with uninteresting information. This option can be specified multiple times on the command line, to name multiple functions.

Note that the named function will only be treated this way if it is the top entry in a stack trace, or just below another function treated this way. For example, if you have a
function malloc1 that wraps malloc, and malloc2 that wraps malloc1, just specifying --alloc-fn=malloc2 will have no effect. You need to specify --alloc-fn=malloc1 as well.
This is a little inconvenient, but the reason is that checking for allocation functions is slow, and it saves a lot of time if Massif can stop looking through the stack trace
entries as soon as it finds one that doesn't match rather than having to continue through all the entries.

Note that C++ names are demangled. Note also that overloaded C++ names must be written in full. Single quotes may be necessary to prevent the shell from breaking them up. For
example:

--alloc-fn='operator new(unsigned, std::nothrow_t const&)'

--ignore-fn=<name>
Any direct heap allocation (i.e. a call to malloc, new, etc, or a call to a function named by an --alloc-fn option) that occurs in a function specified by this option will be
ignored. This is mostly useful for testing purposes. This option can be specified multiple times on the command line, to name multiple functions.

Any realloc of an ignored block will also be ignored, even if the realloc call does not occur in an ignored function. This avoids the possibility of negative heap sizes if
ignored blocks are shrunk with realloc.

The rules for writing C++ function names are the same as for --alloc-fn above.

--threshold=<m.n> [default: 1.0]
The significance threshold for heap allocations, as a percentage of total memory size. Allocation tree entries that account for less than this will be aggregated. Note that
this should be specified in tandem with ms_print's option of the same name.

--peak-inaccuracy=<m.n> [default: 1.0]
Massif does not necessarily record the actual global memory allocation peak; by default it records a peak only when the global memory allocation size exceeds the previous peak
by at least 1.0%. This is because there can be many local allocation peaks along the way, and doing a detailed snapshot for every one would be expensive and wasteful, as all
but one of them will be later discarded. This inaccuracy can be changed (even to 0.0%) via this option, but Massif will run drastically slower as the number approaches zero.

--time-unit=<i|ms|B> [default: i]
The time unit used for the profiling. There are three possibilities: instructions executed (i), which is good for most cases; real (wallclock) time (ms, i.e. milliseconds),
which is sometimes useful; and bytes allocated/deallocated on the heap and/or stack (B), which is useful for very short-run programs, and for testing purposes, because it is
the most reproducible across different machines.

--detailed-freq=<n> [default: 10]
Frequency of detailed snapshots. With --detailed-freq=1, every snapshot is detailed.

--max-snapshots=<n> [default: 100]
The maximum number of snapshots recorded. If set to N, for all programs except very short-running ones, the final number of snapshots will be between N/2 and N.

--massif-out-file=<file> [default: massif.out.%p]
Write the profile data to file rather than to the default output file, massif.out.<pid>. The %p and %q format specifiers can be used to embed the process ID and/or the
contents of an environment variable in the name, as is the case for the core option --log-file.

对其中几个常用的选项做一个说明:

  • –stacks: 栈内存的采样开关,默认关闭。打开后,会针对栈上的内存也进行采样,会使 massif 性能变慢;
  • –time-unit:指定用来分析的时间单位。这个选项三个有效值:执行的指令(i),即默认值,用于大多数情况;即时(ms,单位毫秒),可用于某些特定事务;以及在堆(/或者)栈中分配/取消分配的字节(B),用于很少运行的程序,且用于测试目的,因为它最容易在不同机器中重现。这个选项在使用 ms_print 输出结果画图是游泳
  • –detailed-freq: 针对详细内存快照的频率,默认是 10, 即每 10 个快照会有采集一个详细的内存快照
  • –massif-out-file: 采样结束后,生成的采样文件(后续可以使用 ms_print 或者 massif-visualizer 进行分析)

开始采集

经过上面的了解,接下来可以开始内存数据采集了,假设我们需要采集的二进制程序名为 xprogram:

1
valgrind -v --tool=massif --time-unit=B --detailed-freq=1 --massif-out-file=./massif.out  ./xprogram someargs

运行一段时间后,采集到足够多的内存数据之后,我们需要停止程序,让它生成采集的数据文件,使用 kill 命令让 valgrind 程序退出。

attention: 这里禁止使用 kill -9 模式去杀进程,不然不会产生采样文件

ms_print 分析采样文件

ms_print 是用来分析 massif 采样得到的内存数据文件的,使用命令为:

1
ms_print ./massif.out

或者把输出保存到文件:

1
ms_print ./massif.out > massif.result

打开 massif.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
--------------------------------------------------------------------------------
Command: ./xprogram someargs
Massif arguments: --time-unit=B --massif-out-file=./massif.out
ms_print arguments: massif.out
--------------------------------------------------------------------------------


GB
1.279^ #
| #
| @ @#
| @::@#
| @:@: @#
| @:: @:@: @#
| : ::::@: ::@:@: @#
| @ @@@@ :::::: :@: : @:@: @#
| : @:@ @ @: :::: :@: : @:@: @#
| @ :::::@:@ @ @: :::: :@: : @:@: @#
| @@:::@@::: :: @:@ @ @: :::: :@: : @:@: @#
| :::@ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| :: @@::::: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| :::: :@ :: :: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| @ :::::::: :@ :: :: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| ::@::: : :::: :@ :: :: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| ::::@: : : :::: :@ :: :: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| :: ::@: : : :::: :@ :: :: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| @@:: ::@: : : :::: :@ :: :: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
| ::@ :: ::@: : : :::: :@ :: :: @ : :@@: : :: @:@ @ @: :::: :@: : @:@: @#
0 +----------------------------------------------------------------------->GB
0 813.9

Number of snapshots: 68
Detailed snapshots: [2, 7, 16, 21, 24, 25, 30, 32, 33, 34, 41, 44, 46, 48, 51, 52, 58, 59, 61, 64, 65, 66, 67 (peak)]

这张图大概意思就表示堆内存的分配量随着采样时间的变化。从上图可以看到堆内存一直在增长,可能存在一些内存泄露等问题。

往下看还能看到内存的分配栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  0              0                0                0             0            0
1 20,021,463,688 133,278,776 124,687,612 8,591,164 0
2 45,201,848,936 204,228,232 191,089,596 13,138,636 0
93.57% (191,089,596B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
->41.07% (83,886,080B) 0xF088E6: rocksdb::Arena::AllocateNewBlock(unsigned long) (in /chain/xtopchain)
| ->41.07% (83,886,080B) 0xF08500: rocksdb::Arena::AllocateFallback(unsigned long, bool) (in /chain/xtopchain)
| ->41.07% (83,886,080B) 0xF0886C: rocksdb::Arena::AllocateAligned(unsigned long, unsigned long, rocksdb::Logger*) (in /chain/xtopchain)
| ->41.07% (83,886,080B) 0xDE62BC: rocksdb::ConcurrentArena::AllocateAligned(unsigned long, unsigned long, rocksdb::Logger*)::{lambda()
| | ->41.07% (83,886,080B) 0xDE7D9A: char* rocksdb::ConcurrentArena::AllocateImpl<rocksdb::ConcurrentArena::AllocateAligned(unsigned long, unsigned long, rocksdb::Logger*)::{lambda()
| | ->41.07% (83,886,080B) 0xDE6371: rocksdb::ConcurrentArena::AllocateAligned(unsigned long, unsigned long, rocksdb::Logger*) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xE6FAB0: rocksdb::InlineSkipList<rocksdb::MemTableRep::KeyComparator const&>::AllocateNode(unsigned long, int) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xE6F472: rocksdb::InlineSkipList<rocksdb::MemTableRep::KeyComparator const&>::AllocateKey(unsigned long) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xE6E40A: rocksdb::(anonymous namespace)::SkipListRep::Allocate(unsigned long, char**) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xDE32E3: rocksdb::MemTable::Add(unsigned long, rocksdb::ValueType, rocksdb::Slice const&, rocksdb::Slice const&, bool, rocksdb::MemTablePostProcessInfo*) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xE5C218: rocksdb::MemTableInserter::PutCFImpl(unsigned int, rocksdb::Slice const&, rocksdb::Slice const&, rocksdb::ValueType) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xE5C92C: rocksdb::MemTableInserter::PutCF(unsigned int, rocksdb::Slice const&, rocksdb::Slice const&) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xE570E4: rocksdb::WriteBatch::Iterate(rocksdb::WriteBatch::Handler*) const (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xE598D5: rocksdb::WriteBatchInternal::InsertInto(rocksdb::WriteThread::WriteGroup&, unsigned long, rocksdb::ColumnFamilyMemTables*, rocksdb::FlushScheduler*, bool, unsigned long, rocksdb::DB*, bool, bool, bool) (in /chain/xtopchain)
| | ->41.07% (83,886,080B) 0xD45AD7: rocksdb::DBImpl::WriteImpl(rocksdb::WriteOptions const&, rocksdb::WriteBatch*, rocksdb::WriteCallback*, unsigned long*, unsigned long, bool, unsigned long*, unsigned long, rocksdb::PreReleaseCallback*) (in /chain/xtopchain)
| | ->28.75% (58,720,256B) 0x1013B9C: rocksdb::WriteCommittedTxn::CommitWithoutPrepareInternal() (in /chain/xtopchain)
| | | ->28.75% (58,720,256B) 0x1013653: rocksdb::PessimisticTransaction::Commit() (in /chain/xtopchain)
| | | ->28.75% (58,720,256B) 0xF40E17: rocksdb::PessimisticTransactionDB::Put(rocksdb::WriteOptions const&, rocksdb::ColumnFamilyHandle*, rocksdb

能看到内存分配的调用堆栈情况,据此可以看到哪里分配的内存较多。

手册

一般像下面这样调用Valgrind:

1
valgrind program args

这样将在Valgrind使用Memcheck运行程序program(带有参数args)。内存检查执行一系列的内存检查功能,包括检测访问未初始化的内存,已经分配内存的错误使用(两次释放,释放后再访问,等等)并检查内存泄漏。

可用—tool指定使用其它工具:
valgrind --tool=toolname program args

可使用的工具如下:

  • cachegrind是一个缓冲模拟器。它可以用来标出你的程序每一行执行的指令数和导致的缓冲不命中数。
  • callgrind在cachegrind基础上添加调用追踪。它可以用来得到调用的次数以及每次函数调用的开销。作为对cachegrind的补充,callgrind可以分别标注各个线程,以及程序反汇编输出的每条指令的执行次数以及缓存未命中数。
  • helgrind能够发现程序中潜在的条件竞争。
  • lackey是一个示例程序,以其为模版可以创建你自己的工具。在程序结束后,它打印出一些基本的关于程序执行统计数据。
  • massif是一个堆剖析器,它测量你的程序使用了多少堆内存。
  • memcheck是一个细粒度的的内存检查器。
  • none没有任何功能。它它一般用于Valgrind的调试和基准测试。

基本选项:
这些选项对所有工具都有效。

-h —help
显示所有选项的帮助,包括内核和选定的工具两者。

—help-debug
和—help相同,并且还能显示通常只有Valgrind的开发人员使用的调试选项。

—version
显示Valgrind内核的版本号。工具可以有他们自已的版本号。这是一种保证工具只在它们可以运行的内核上工作的一种设置。这样可以减少在工具和内核之间版本兼容性导致奇怪问题的概率。

-q —quiet
安静的运行,只打印错误信息。在进行回归测试或者有其它的自动化测试机制时会非常有用。

-v —verbose
显示详细信息。在各个方面显示你的程序的额外信息,例如:共享对象加载,使用的重置,执行引擎和工具的进程,异常行为的警告信息。重复这个标记可以增加详细的级别。

-d 调试Valgrind自身发出的信息。通常只有Valgrind开发人员对此感兴趣。重复这个标记可以产生更详细的输出。如果你希望发送一个bug报告,通过-v -d生成的输出会使你的报告更加有效。

—tool= [default: memcheck]
运行toolname指定的Valgrind,例如,Memcheck, Addrcheck, Cachegrind,等等。

—trace-children= [default: no]
当这个选项打开时,Valgrind会跟踪到子进程中。这经常会导致困惑,而且通常不是你所期望的,所以默认这个选项是关闭的。

—track-fds= [default: no]
当这个选项打开时,Valgrind会在退出时打印一个打开文件描述符的列表。每个文件描述符都会打印出一个文件是在哪里打开的栈回溯,和任何与此文件描述符相关的详细信息比如文件名或socket信息。

—time-stamp= [default: no]
当这个选项打开时,每条信息之前都有一个从程序开始消逝的时间,用天,小时,分钟,秒和毫秒表示。

—log-fd= [default: 2, stderr]
指定Valgrind把它所有的消息都输出到一个指定的文件描述符中去。默认值2, 是标准错误输出(stderr)。注意这可能会干扰到客户端自身对stderr的使用, Valgrind的输出与客户程序的输出将穿插在一起输出到stderr。

—log-file=
指定Valgrind把它所有的信息输出到指定的文件中。实际上,被创建文件的文件名是由filename、’.’和进程号连接起来的(即.),从而每个进程创建不同的文件。

—log-file-exactly=
类似于—log-file,但是后缀”.pid”不会被添加。如果设置了这个选项,使用Valgrind跟踪多个进程,可能会得到一个乱七八糟的文件。

—log-file-qualifier=
当和—log-file一起使用时,日志文件名将通过环境变量$VAR来筛选。这对于MPI程序是有益的。更多的细节,查看手册2.3节 “注解”。

—log-socket=
指定Valgrind输出所有的消息到指定的IP,指定的端口。当使用1500端口时,端口有可能被忽略。如果不能建立一个到指定端口的连接,Valgrind将输出写到标准错误(stderr)。这个选项经常和一个Valgrind监听程序一起使用。

错误相关选项:
这些选项适用于所有产生错误的工具,比如Memcheck, 但是Cachegrind不行。

—xml= [default: no]
当这个选项打开时,输出将是XML格式。这是为了使用Valgrind的输出做为输入的工具,例如GUI前端更加容易些。目前这个选项只在Memcheck时生效。

—xml-user-comment=
在XML开头 附加用户注释,仅在指定了—xml=yes时生效,否则忽略。

—demangle= [default: yes]
打开/关闭C++的名字自动解码。默认打开。当打开时,Valgrind将尝试着把编码过的C++名字自动转回初始状态。这个解码器可以处理g++版本为2.X,3.X或4.X生成的符号。

一个关于名字编码解码重要的事实是,禁止文件中的解码函数名仍然使用他们未解码的形式。Valgrind在搜寻可用的禁止条目时不对函数名解码,因为这将使禁止文件内容依赖于Valgrind的名字解码机制状态, 会使速度变慢,且无意义。

—num-callers= [default: 12]
默认情况下,Valgrind显示12层函数调用的函数名有助于确定程序的位置。可以通过这个选项来改变这个数字。这样有助在嵌套调用的层次很深时确定程序的位置。注意错误信息通常只回溯到最顶上的4个函数。(当前函数,和它的3个调用者的位置)。所以这并不影响报告的错误总数。

这个值的最大值是50。注意高的设置会使Valgrind运行得慢,并且使用更多的内存,但是在嵌套调用层次比较高的程序中非常实用。

—error-limit= [default: yes]
当这个选项打开时,在总量达到10,000,000,或者1,000个不同的错误,Valgrind停止报告错误。这是为了避免错误跟踪机制在错误很多的程序下变成一个巨大的性能负担。

—error-exitcode= [default: 0]
指定如果Valgrind在运行过程中报告任何错误时的退出返回值,有两种情况;当设置为默认值(零)时,Valgrind返回的值将是它模拟运行的程序的返回值。当设置为非零值时,如果Valgrind发现任何错误时则返回这个值。在Valgrind做为一个测试工具套件的部分使用时这将非常有用,因为使测试工具套件只检查Valgrind返回值就可以知道哪些测试用例Valgrind报告了错误。

—show-below-main= [default: no]
默认地,错误时的栈回溯不显示main()之下的任何函数(或者类似的函数像glibc的__libc_start_main(),如果main()没有出现在栈回溯中);这些大部分都是令人厌倦的C库函数。如果打开这个选项,在main()之下的函数也将会显示。

—suppressions= [default: $PREFIX/lib/valgrind/default.supp]
指定一个额外的文件读取不需要理会的错误;你可以根据需要使用任意多的额外文件。

—gen-suppressions= [default: no]
当设置为yes时,Valgrind将会在每个错误显示之后自动暂停并且打印下面这一行:

1
---- Print suppression ? --- [Return/N/n/Y/y/C/c] ----

这个提示的行为和—db-attach选项(见下面)相同。

如果选择是,Valgrind会打印出一个错误的禁止条目,你可以把它剪切然后粘帖到一个文件,如果不希望在将来再看到这个错误信息。

当设置为all时,Valgrind会对每一个错误打印一条禁止条目,而不向用户询问。

这个选项对C++程序非常有用,它打印出编译器调整过的名字。

注意打印出来的禁止条目是尽可能的特定的。如果需要把类似的条目归纳起来,比如在函数名中添加通配符。并且,有些时候两个不同的错误也会产生同样的禁止条目,这时Valgrind就会输出禁止条目不止一次,但是在禁止条目的文件中只需要一份拷贝(但是如果多于一份也不会引起什么问题)。并且,禁止条目的名字像<在这儿输入一个禁止条目的名字>;名字并不是很重要,它只是和-v选项一起使用打印出所有使用的禁止条目记录。

—db-attach= [default: no]
当这个选项打开时,Valgrind将会在每次打印错误时暂停并打出如下一行:

1
---- Attach to debugger ? --- [Return/N/n/Y/y/C/c] ----

按下回车,或者N、回车,n、回车,Valgrind不会对这个错误启动调试器。

按下Y、回车,或者y、回车,Valgrind会启动调试器并设定在程序运行的这个点。当调试结束时,退出,程序会继续运行。在调试器内部尝试继续运行程序,将不会生效。

按下C、回车,或者c、回车,Valgrind不会启动一个调试器,并且不会再次询问。

注意:—db-attach=yes与—trace-children=yes有冲突。你不能同时使用它们。Valgrind在这种情况下不能启动。

—db-command= [default: gdb -nw %f %p]
通过—db-attach指定如何使用调试器。默认的调试器是gdb.默认的选项是一个运行时扩展Valgrind的模板。 %f会用可执行文件的文件名替换,%p会被可执行文件的进程ID替换。

这指定了Valgrind将怎样调用调试器。默认选项不会因为在构造时是否检测到了GDB而改变,通常是/usr/bin/gdb.使用这个命令,你可以指定一些调用其它的调试器来替换。

给出的这个命令字串可以包括一个或多个%p %f扩展。每一个%p实例都被解释成将调试的进程的PID,每一个%f实例都被解释成要调试的进程的可执行文件路径。

—input-fd= [default: 0, stdin]
使用—db-attach=yes和—gen-suppressions=yes选项,在发现错误时,Valgrind会停下来去读取键盘输入。默认地,从标准输入读取,所以关闭了标准输入的程序会有问题。这个选项允许你指定一个文件描述符来替代标准输入读取。

—max-stackframe= [default: 2000000]
栈的最大值。如果栈指针的偏移超过这个数量,Valgrind则会认为程序是切换到了另外一个栈执行。

如果在程序中有大量的栈分配的数组,你可能需要使用这个选项。valgrind保持对程序栈指针的追踪。如果栈指针的偏移超过了这个数量,Valgrind假定你的程序切换到了另外一个栈,并且Memcheck行为与栈指针的偏移没有超出这个数量将会不同。通常这种机制运转得很好。然而,如果你的程序在栈上申请了大的结构,这种机制将会表现得愚蠢,并且Memcheck将会报告大量的非法栈内存访问。这个选项允许把这个阀值设置为其它值。

应该只在Valgrind的调试输出中显示需要这么做时才使用这个选项。在这种情况下,它会告诉你应该指定的新的阀值。

普遍地,在栈中分配大块的内存是一个坏的主意。因为这很容易用光你的栈空间,尤其是在内存受限的系统或者支持大量小堆栈的线程的系统上,因为Memcheck执行的错误检查,对于堆上的数据比对栈上的数据要高效很多。如果你使用这个选项,你可能希望考虑重写代码在堆上分配内存而不是在栈上分配。

MALLOC()相关的选项:
对于使用自有版本的malloc() (例如Memcheck和massif),下面的选项可以使用。

—alignment= [default: 8]
默认Valgrind的malloc(),realloc(), 等等,是8字节对齐地址的。这是大部分处理器的标准。然而,一些程序可能假定malloc()等总是返回16字节或更多对齐的内存。提供的数值必须在8和4096区间之内,并且必须是2的幂数。

非通用选项:
这些选项可以用于所有的工具,它们影响Valgrind core的几个特性。大部分人不会用到这些选项。

—run-libc-freeres= [default: yes]
GNU C库(libc.so),所有程序共用的,可能会分配一部分内存自已用。通常在程序退出时释放内存并不麻烦 — 这里没什么问题,因为Linux内核一个进程退出时会回收进程全部的资源,所以这只是会造成速度慢。

glibc的作者认识到这样会导致内存检查器,像Valgrind,在退出时检查内存错误的报告glibc的内存泄漏问题,为了避免这个问题,他们提供了一个__libc_freeres()例程特别用来让glibc释放分配的所有内存。因此Memcheck在退出时尝试着去运行__libc_freeres()

不幸的是,在glibc的一些版本中,libc_freeres是有bug会导致段错误的。这在Red Hat 7.1上有特别声明。所以,提供这个选项来决定是否运行libc_freeres。如果你的程序看起来在Valgrind上运行得很好,但是在退出时发生段错误,你可能需要指定—run-libc-freeres=no来修正,这将可能错误的报告libc.so的内存泄漏。

—sim-hints=hint1,hint2,…
传递杂凑的提示给Valgrind,轻微的修改模拟行为的非标准或危险方式,可能有助于模拟奇怪的特性。默认没有提示打开。小心使用!目前已知的提示有:

  • lax-ioctls: 对ioctl的处理非常不严格,唯一的假定是大小是正确的。不需要在写时缓冲区完全的初始化。没有这个,用大量的奇怪的ioctl命令来使用一些设备驱动将会非常烦人。
  • enable-inner:打开某些特殊的效果,当运行的程序是Valgrind自身时。

—kernel-variant=variant1,variant2,…
处理系统调用和ioctls在这个平台的默认核心上产生不同的变量。这有助于运行在改进过的内核或者支持非标准的ioctls上。小心使用。如果你不理解这个选项做的是什么那你几乎不需要它。已经知道的变量有:

  • bproc: 支持X86平台上的sys_broc系统调用。这是为了运行在BProc,它是标准Linux的一个变种,有时用来构建集群。

—show-emwarns= [default: no]
当这个选项打开时,Valgrind在一些特定的情况下将对CPU仿真产生警告。通常这些都是不引人注意的。

—smc-check= [default: stack]
这个选项控制Valgrind对自我修改的代码的检测。Valgrind可以不做检测,可以检测栈中自我修改的代码,或者任意地方检测自我修改的代码。注意默认选项是捕捉绝大多数情况,到目前我们了解的情况为止。使用all选项时会极大的降低速度。(但是用none选项运行极少影响速度,因为对大多数程序,非常少的代码被添加到栈中)

调试VALGRIND选项:
还有一些选项是用来调试Valgrind自身的。在运行一般的东西时不应该需要的。如果你希望看到选项列表,使用—help-debug选项。

内存检查选项:
—leak-check= [default: summary]
当这个选项打开时,当客户程序结束时查找内存泄漏。内存泄漏意味着有用malloc分配内存块,但是没有用free释放,而且没有指针指向这块内存。这样的内存块永远不能被程序释放,因为没有指针指向它们。如果设置为summary,Valgrind会报告有多少内存泄漏发生了。如果设置为full或yes,Valgrind给出每一个独立的泄漏的详细信息。

—show-reachable= [default: no]
当这个选项关闭时,内存泄漏检测器只显示没有指针指向的内存块,或者只能找到指向块中间的指针。当这个选项打开时,内存泄漏检测器还报告有指针指向的内存块。这些块是最有可能出现内存泄漏的地方。你的程序可能,至少在原则上,应该在退出前释放这些内存块。这些有指针指向的内存块和没有指针指向的内存块,或者只有内部指针指向的块,都可能产生内存泄漏,因为实际上没有一个指向块起始的指针可以拿来释放,即使你想去释放它。

—leak-resolution= [default: low]
在做内存泄漏检查时,确定memcheck将怎么样考虑不同的栈是相同的情况。当设置为low时,只需要前两层栈匹配就认为是相同的情况;当设置为med,必须要四层栈匹配,当设置为high时,所有层次的栈都必须匹配。

对于hardcore内存泄漏检查,你很可能需要使用—leak-resolution=high和—num-callers=40或者更大的数字。注意这将产生巨量的信息,这就是为什么默认选项是四个调用者匹配和低分辨率的匹配。注意—leak-resolution= 设置并不影响memcheck查找内存泄漏的能力。它只是改变了结果如何输出。

—freelist-vol= [default: 5000000]
当客户程序使用free(C中)或者delete(C++)释放内存时,这些内存并不是马上就可以用来再分配的。这些内存将被标记为不可访问的,并被放到一个已释放内存的队列中。这样做的目的是,使释放的内存再次被利用的点尽可能的晚。这有利于memcheck在内存块释放后这段重要的时间检查对块不合法的访问。

这个选项指定了队列所能容纳的内存总容量,以字节为单位。默认的值是5000000字节。增大这个数目会增加memcheck使用的内存,但同时也增加了对已释放内存的非法使用的检测概率。

—workaround-gcc296-bugs= [default: no]
当这个选项打开时,假定读写栈指针以下的一小段距离是gcc 2.96的bug,并且不报告为错误。距离默认为256字节。注意gcc 2.96是一些比较老的Linux发行版(RedHat 7.X)的默认编译器,所以你可能需要使用这个选项。如果不是必要请不要使用这个选项,它可能会使一些真正的错误溜掉。一个更好的解决办法是使用较新的,修正了这个bug的gcc/g++版本。

—partial-loads-ok= [default: no]
控制memcheck如何处理从地址读取时字长度,字对齐,因此哪些字节是可以寻址的,哪些是不可以寻址的。当设置为yes是,这样的读取并不抛出一个寻址错误。而是从非法地址读取的V字节显示为未定义,访问合法地址仍然是像平常一样映射到内存。

设置为no时,从部分错误的地址读取与从完全错误的地址读取同样处理:抛出一个非法地址错误,结果的V字节显示为合法数据。

注意这种代码行为是违背ISO C/C++标准,应该被认为是有问题的。如果可能,这种代码应该修正。这个选项应该只是做为一个最后考虑的方法。

—undef-value-errors= [default: yes]
控制memcheck是否检查未定义值的危险使用。当设为yes时,Memcheck的行为像Addrcheck, 一个轻量级的内存检查工具,是Valgrind的一个部分,它并不检查未定义值的错误。使用这个选项,如果你不希望看到未定义值错误。

CACHEGRIND选项:
手动指定I1/D1/L2缓冲配置,大小是用字节表示的。这三个必须用逗号隔开,中间没有空格,例如:

1
valgrind --tool=cachegrind --I1=65535,2,64

你可以指定一个,两个或三个I1/D1/L2缓冲。如果没有手动指定,每个级别使用
普通方式(通过CPUID指令得到缓冲配置,如果失败,使用默认值)得到的配置。

—I1=,,
指定第一级指令缓冲的大小,关联度和行大小。

—D1=,,
指定第一级数据缓冲的大小,关联度和行大小。

—L2=,,
指定第二级缓冲的大小,关联度和行大小。

CALLGRIND选项:

—heap= [default: yes]
当这个选项打开时,详细的追踪堆的使用情况。关闭这个选项时,massif.pid.txt或massif.pid.html将会非常的简短。

—heap-admin= [default: 8]
每个块使用的管理字节数。这只能使用一个平均的估计值,因为它可能变化。glibc使用的分配器每块需要4~15字节,依赖于各方面的因素。管理已经释放的块也需要空间,尽管massif不计算这些。

—stacks= [default: yes]
当打开时,在剖析信息中包含栈信息。多线程的程序可能有多个栈。

—depth= [default: 3]
详细的堆信息中调用过程的深度。增加这个值可以给出更多的信息,但是massif会更使这个程序运行得慢,使用更多的内存,并且产生一个大的massif.pid.txt或者massif.pid.hp文件。

—alloc-fn=
指定一个分配内存的函数。这对于使用malloc()的包装函数是有用的,可以用它来填充原来无效的上下文信息。(这些函数会给出无用的上下文信息,并在图中给出无意义的区域)。指定的函数在上下文中被忽略,例如,像对malloc()一样处理。这个选项可以在命令行中重复多次,指定多个函数。

—format= [default: text]
产生text或者HTML格式的详细堆信息,文件的后缀名使用.txt或者.html。

HELGRIND选项:

—private-stacks= [default: no]
假定线程栈是私有的。

—show-last-access= [default: no]
显示最后一次字访问出错的位置。

LACKEY选项:
—fnname= [default: _dl_runtime_resolve()]
函数计数。

—detailed-counts= [default: no]
对读取,存储和alu操作计数。

利用GCC编译选项Sanitizers快速定位内存错误

先从一个小例子开头

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

int main(int argc, char **argv) {
int *array = new int[100];
delete [] array;
array[argc]==1; //can't detected
cout&lt;&lt; "passed 1st"&lt;&lt;endl;

array[argc] = array[argc];
cout&lt;&lt; "passed 2nd"&lt;&lt;endl;

array[argc]=100; // BOOM
cout&lt;&lt;"passed 3rd"&lt;&lt;endl;
}
1
2
$ g++ -g -O -fsanitize=address -o asan heap-use-after-free.cpp
$ ./asan

重点在这个-fsanitize=address选项上,不加它运行这段代码基本是不会报错的。

Sanitizers简介

Sanitizers是谷歌发起的开源工具集,包括了AddressSanitizer,MemorySanitizer,ThreadSanitizer,LeakSanitizer,Sanitizers项目本是LLVM项目的一部分,但GNU也将该系列工具加入到了自家的GCC编译器中。GCC从4.8版本开始支持Address和Thread Sanitizer,4.9版本开始支持Leak Sanitizer和UB Sanitizer,这些都是查找隐藏Bug的利器。

原文 不上道的翻译
Use after free (dangling pointer dereference) 为悬浮指针赋值
Heap buffer overflow 堆缓冲区溢出
Stack buffer overflow 栈缓冲区溢出
Global buffer overflow 全局缓冲区溢出
Use after return 通过返回值访问局部变量的内存
Use after scope 访问已经释放的局部变量的内存
Initialization order bugs 使用未初始化的内存
Memory leaks 内存泄漏

Enable AddressSanitizer, a fast memory error detector. Memory access instructions are instrumented to detect out-of-bounds and use-after-free bugs. The option enables -fsanitize-address-use-after-scope. See https://github.com/google/sanitizers/wiki/AddressSanitizer for more details. The run-time behavior can be influenced using the ASAN_OPTIONS environment variable. When set to help=1, the available options are shown at startup of the instrumented program. See https://github.com/google/sanitizers/wiki/AddressSanitizerFlags#run-time-flags for a list of supported options. The option cannot be combined with -fsanitize=thread and/or -fcheck-pointer-bounds.

-fsanitize=kernel-address
Enable AddressSanitizer for Linux kernel. See https://github.com/google/kasan/wiki for more details. The option cannot be combined with -fcheck-pointer-bounds.

-fsanitize=thread
Enable ThreadSanitizer, a fast data race detector. Memory access instructions are instrumented to detect data race bugs. See https://github.com/google/sanitizers/wiki#threadsanitizer for more details. The run-time behavior can be influenced using the TSAN_OPTIONS environment variable; see https://github.com/google/sanitizers/wiki/ThreadSanitizerFlags for a list of supported options. The option cannot be combined with -fsanitize=address, -fsanitize=leak and/or -fcheck-pointer-bounds.

Note that sanitized atomic builtins cannot throw exceptions when operating on invalid memory addresses with non-call exceptions (-fnon-call-exceptions).

-fsanitize=leak
Enable LeakSanitizer, a memory leak detector. This option only matters for linking of executables and the executable is linked against a library that overrides malloc and other allocator functions. See https://github.com/google/sanitizers/wiki/AddressSanitizerLeakSanitizer for more details. The run-time behavior can be influenced using the LSAN_OPTIONS environment variable. The option cannot be combined with -fsanitize=thread.

-fsanitize=undefined
Enable UndefinedBehaviorSanitizer, a fast undefined behavior detector. Various computations are instrumented to detect undefined behavior at runtime. Current suboptions are:

-fsanitize=shift
This option enables checking that the result of a shift operation is not undefined. Note that what exactly is considered undefined differs slightly between C and C++, as well as between ISO C90 and C99, etc. This option has two suboptions, -fsanitize=shift-base and -fsanitize=shift-exponent.

-fsanitize=shift-exponent
This option enables checking that the second argument of a shift operation is not negative and is smaller than the precision of the promoted first argument.

-fsanitize=shift-base
If the second argument of a shift operation is within range, check that the result of a shift operation is not undefined. Note that what exactly is considered undefined differs slightly between C and C++, as well as between ISO C90 and C99, etc.

-fsanitize=integer-divide-by-zero
Detect integer division by zero as well as INT_MIN / -1 division.

-fsanitize=unreachable
With this option, the compiler turns the builtin_unreachable call into a diagnostics message call instead. When reaching the builtin_unreachable call, the behavior is undefined.

-fsanitize=vla-bound
This option instructs the compiler to check that the size of a variable length array is positive.

-fsanitize=null
This option enables pointer checking. Particularly, the application built with this option turned on will issue an error message when it tries to dereference a NULL pointer, or if a reference (possibly an rvalue reference) is bound to a NULL pointer, or if a method is invoked on an object pointed by a NULL pointer.

-fsanitize=return
This option enables return statement checking. Programs built with this option turned on will issue an error message when the end of a non-void function is reached without actually returning a value. This option works in C++ only.

-fsanitize=signed-integer-overflow
This option enables signed integer overflow checking. We check that the result of +, *, and both unary and binary – does not overflow in the signed arithmetics. Note, integer promotion rules must be taken into account. That is, the following is not an overflow:

1
2
signed char a = SCHAR_MAX;
a++;

-fsanitize=bounds
This option enables instrumentation of array bounds. Various out of bounds accesses are detected. Flexible array members, flexible array member-like arrays, and initializers of variables with static storage are not instrumented. The option cannot be combined with -fcheck-pointer-bounds.

-fsanitize=bounds-strict
This option enables strict instrumentation of array bounds. Most out of bounds accesses are detected, including flexible array members and flexible array member-like arrays. Initializers of variables with static storage are not instrumented. The option cannot be combined with -fcheck-pointer-bounds.

-fsanitize=alignment
This option enables checking of alignment of pointers when they are dereferenced, or when a reference is bound to insufficiently aligned target, or when a method or constructor is invoked on insufficiently aligned object.

-fsanitize=object-size
This option enables instrumentation of memory references using the __builtin_object_size function. Various out of bounds pointer accesses are detected.

-fsanitize=float-divide-by-zero
Detect floating-point division by zero. Unlike other similar options, -fsanitize=float-divide-by-zero is not enabled by -fsanitize=undefined, since floating-point division by zero can be a legitimate way of obtaining infinities and NaNs.

-fsanitize=float-cast-overflow
This option enables floating-point type to integer conversion checking. We check that the result of the conversion does not overflow. Unlike other similar options, -fsanitize=float-cast-overflow is not enabled by -fsanitize=undefined. This option does not work well with FE_INVALID exceptions enabled.

-fsanitize=nonnull-attribute
This option enables instrumentation of calls, checking whether null values are not passed to arguments marked as requiring a non-null value by the nonnull function attribute.

-fsanitize=returns-nonnull-attribute
This option enables instrumentation of return statements in functions marked with returns_nonnull function attribute, to detect returning of null values from such functions.

-fsanitize=bool
This option enables instrumentation of loads from bool. If a value other than 0/1 is loaded, a run-time error is issued.

-fsanitize=enum
This option enables instrumentation of loads from an enum type. If a value outside the range of values for the enum type is loaded, a run-time error is issued.

-fsanitize=vptr
This option enables instrumentation of C++ member function calls, member accesses and some conversions between pointers to base and derived classes, to verify the referenced object has the correct dynamic type.

While -ftrapv causes traps for signed overflows to be emitted, -fsanitize=undefined gives a diagnostic message. This currently works only for the C family of languages.

-fno-sanitize=all
This option disables all previously enabled sanitizers. -fsanitize=all is not allowed, as some sanitizers cannot be used together.

-fasan-shadow-offset=number
This option forces GCC to use custom shadow offset in AddressSanitizer checks. It is useful for experimenting with different shadow memory layouts in Kernel AddressSanitizer.

-fsanitize-sections=s1,s2,…
Sanitize global variables in selected user-defined sections. si may contain wildcards.

-fsanitize-recover[=opts]
-fsanitize-recover= controls error recovery mode for sanitizers mentioned in comma-separated list of opts. Enabling this option for a sanitizer component causes it to attempt to continue running the program as if no error happened. This means multiple runtime errors can be reported in a single program run, and the exit code of the program may indicate success even when errors have been reported. The -fno-sanitize-recover= option can be used to alter this behavior: only the first detected error is reported and program then exits with a non-zero exit code.

Currently this feature only works for -fsanitize=undefined (and its suboptions except for -fsanitize=unreachable and -fsanitize=return), -fsanitize=float-cast-overflow, -fsanitize=float-divide-by-zero, -fsanitize=bounds-strict, -fsanitize=kernel-address and -fsanitize=address. For these sanitizers error recovery is turned on by default, except -fsanitize=address, for which this feature is experimental. -fsanitize-recover=all and -fno-sanitize-recover=all is also accepted, the former enables recovery for all sanitizers that support it, the latter disables recovery for all sanitizers that support it.

Even if a recovery mode is turned on the compiler side, it needs to be also enabled on the runtime library side, otherwise the failures are still fatal. The runtime library defaults to halt_on_error=0 for ThreadSanitizer and UndefinedBehaviorSanitizer, while default value for AddressSanitizer is halt_on_error=1. This can be overridden through setting the halt_on_error flag in the corresponding environment variable.

Syntax without an explicit opts parameter is deprecated. It is equivalent to specifying an opts list of:

undefined,float-cast-overflow,float-divide-by-zero,bounds-strict

-fsanitize-address-use-after-scope
Enable sanitization of local variables to detect use-after-scope bugs. The option sets -fstack-reuse to ‘none’.

-fsanitize-undefined-trap-on-error
The -fsanitize-undefined-trap-on-error option instructs the compiler to report undefined behavior using __builtin_trap rather than a libubsan library routine. The advantage of this is that the libubsan library is not needed and is not linked in, so this is usable even in freestanding environments.

-fsanitize-coverage=trace-pc
Enable coverage-guided fuzzing code instrumentation. Inserts a call to __sanitizer_cov_trace_pc into every basic block

内核同步

内核为不同的请求服务

内核抢占

如果进程正在执行内核函数时,即在内核态运行,允许发生内核切换,这个内核就是抢占的。

无论在抢占还是非抢占内核中,运行在内核态的进程都可以自动放弃CPU,这叫做计划性进程切换。抢占式内核在响应引起进程切换的异步事件的方式上与非抢占内核有差别,叫做强制性进程切换。所有的切换都是由宏switch_to实现的。

抢占式内核的特点是一个在内核态运行的进程,可能在执行内核函数期间被另一个进程取代。使内核可抢占的目的是减少用户态进程的分派延迟,即从进程变为可执行状态到他实际开始运行之间的时间间隔。当被current_thread_info()宏所引用的thread_info描述符的preempt_count字段大于0的时候就禁止内核抢占,该字段的编码对应三个不同的计数器,因此它在如下几种情况时都大于0:

  • 内核正在执行中断服务例程
  • 可延迟函数被禁止
  • 通过把抢占计数器设置为正数而显式的禁用内核调用

因此只有当内核正在执行异常处理程序,而且内核抢占没有被显式禁用,才可能抢占内核。表中列出了一些简单的宏,它们处理preempt_count字段的抢占计数器。

preempt_enable()宏递减抢占计数器,检查TIF_NEED_RESCHED是否被设置。此时,进程切换请求是挂起的,因此调用preempt_schedule()函数:

1
2
3
4
5
6
// 检查 preempt_count 是否为0,以及是否允许本地中断
if(!current_thread_info->preempt_count && !irqs_disabled()){
current_thread_info->preempt_count = PREMPT_ACTIVE;
schedule(); // 选择另外一个进程运行
current_thread_info->preempt_count = 0;
}

该函数检查是否允许本地中断,以及当前进程的preempt_count字段是否为0,如果两个条件都为真,就调用schedule()选择另一个进程来运行。内核抢占可能在结束内核控制路径时发生,也可能在异常处理程序调用preempt_enable()时发生。

什么时候同步是必需的

当计算结果依赖于两个或两个以上的交叉内核控制路径的嵌套方式时,可能出现竞争条件。临界区是一段代码,在其他的内核控制路径能进入临界区前,进入临界区的内核控制路径必须执行完这段代码。

什么时候同步是不必要的

  • 中断处理程序和 tasklet 不必编写成可重入的函数。
  • 仅被软中断和 tasklet 访问的 每 CPU 变量不需要同步。
  • 仅被一种 tasklet 访问发数据结构不需要同步。

同步原语

表中列出了Linux内核使用的同步技术。

每 CPU 变量

最简单的同步技术包括把内核变量声明为每CPU变量,主要是一个数组,系统中的每个 CPU 对应数组的一个元素。一个 CPU 不应访问其他 CPU 对应的数组元素,另外,它可以随意修改它自己的元素而不用担心出现竞争条件。这意味着它只能在确定系统的CPU上的数据在逻辑上是独立的时候才能使用。

虽然每 CPU 变量为来自不同 CPU 的并发访问提供包含,但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,这需要另外的同步原语。此外,在单处理器和多处理器系统中内核抢占都可能使每CPU变量产生竞争条件。总的原则是内核控制路径应该在禁用抢占的情况下访问每CPU变量。

原子操作

避免由于“读——修改——写”指令引起的竞争条件的最容易的办法,就是确保这样的操作在芯片级是原子的任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其他的CPU访问同一存储器单元。这些很小的原子操作(atomic operations)可以建立在其他更灵活机制的基础之上以创建临界区。

回顾一下80x86的指令;

  • 进行零次或一次对齐内存访问的汇编指令是原子的
  • 如果在读操作之后、写操作之前没有其他处理器占用内存总线,那么从内存中读取数据、更新数据并把更新后的数据写回内存中的这些“读——修改——写”汇编语言指令(例如inc或dec)是原子的。当然,在单处理器系统中,永远都不会发生内存总线窃用的情况。
  • 操作码前缀是lock字节(0xf0)的“读——修改——写”汇编语言指令即使在多处理器系统中也是原子的。当控制单元检测到这个前缀时,就“锁定”内存总线,直到这条指令执行完成为止。因此,当加锁的指令执行时,其他处理器不能访问这个内存单元。
  • 操作码前缀是一个rep字节(0xf2,0xf3)的汇编语言指令不是原子的,这条指令强行让控制单元多次重复执行相同的指令。控制单元在执行新的循环之前要检查挂起的中断。

Linux提供了atmoic_t类型和专门的函数和宏,作用于atmoic_t变量,并当作单独的原子的汇编指令来使用。

另一类原子函数操作作用于位掩码

优化和内存屏障

编译器可能重新安排汇编语言指令以使寄存器以最优方式使用,此外现代CPU通常并行地执行若干条指令,且可重新安排内存访问。优化屏障原语保证编译程序不会混淆放在原语操作之前的汇编指令和放在原语操作之后的汇编语言指令。Linux 中,优化屏障是barrier()宏,展开为asm volatile("":::“memory”)volatile禁止编译器把asm指令与程序中的其他指令重新组合,memory关键字强制编译器假定RAM中的所有内存单元已被汇编指令修改,因此编译器不能使用存放在CPU寄存器中的内存单元来优化asm指令前的代码。

内存屏障原语确保在原语执行之后的操作执行之前,原语前的操作已经完成。下列指令是串行的,因为它们起内存屏障的作用:

  • 对IO端口进行操作的指令
  • 有lock前缀的所有指令
  • 写控制寄存器、系统寄存器或调试寄存器的所有指令(例如cli和sti,用于修改eflags寄存器的IF标志的状态)
  • 引入的汇编语言指令lfence、sfence和mfence,它们分别有效地实现读内存屏障、写内存屏障和读写内存屏障。

Linux使用6个内存屏障原语:

内存屏障原语的实现依赖于体系结构,如果CPU支持lfence指令,就把rmb()展开为asm volatile("lfence"),否则展开为asm volatile("lock;addl $0,0(%%esp)":::"memory")。asm告诉编译器插入一些汇编指令并起到优化屏障的作用,lock;addl $0,0(%%esp)把0加到栈顶的内存单元,这条指令本身没有价值,但是lock前缀使得这条指令成为CPU的内存屏障。

Intel的wmb()宏更简单,因为它展开为barrier(),因为Intel从不对写内存访问重新排序。

自旋锁

当内核控制路径必须访问共享数据结构或进入临界区时,就需要为自己获取一把“锁”。自旋锁时用来在多处理器环境中工作的一种特殊的锁。如果内核控制路径发现锁被运行在另一个 CPU 上的内核控制路径“锁着”,就会在周围“旋转”,反复执行一条紧凑的循环指令(“忙等”),直到锁被释放。自旋锁的循环指令表示忙等,即使等待的CPU无事可做也会占用CPU。

一般来说,有自旋锁保护的每个临界区都是禁止内核抢占的。在单处理器上自旋锁本身不起作用,只是禁止或启用内核抢占。等待自旋锁释放的进程可能被更高优先级的进程替代

Linux中的自旋锁用spinlock_t表示:

  • slock,自旋锁的状态,1:未加锁;<=0:加锁。
  • break_slock,进程正在忙等自旋锁。

六个宏用于初始化、设置、测试自旋锁,所有的宏都是基于原子操作的,保证自旋锁在多个CPU都想修改自旋锁时也能正确更新。

具有内核抢占的 spin_lock 宏

用于请求自旋锁的spin_lock宏:

  • 调用preempt_disable()禁用内核抢占
  • _raw_spin_trylock()对自旋锁的 slock 字段执行原子性的测试和设置操作。

    • 函数首先执行指令如下,
      1
      2
      movb $0, %al
      xchgb %al, slp->slock // xchg 原子性地交换 8 位寄存器 %al 和 slp->slock
  • 如果自旋锁中的旧值是正数,宏结束:内核控制路径已经获得自旋锁

  • 否则,内核控制路径无法获得自旋锁,宏必须执行循环一直到在其他CPU上运行的内核控制路径释放自旋锁。调用preempt_enable()递减在第 1 步 递增了的抢占计数器。忙等期间可被其他进程抢占。
  • 如果break_lock字段等于 0,则设置为 1。通过检测该字段,拥有锁且在别的 CPU 上运行的进程就能知道是否有其他进程在等待该锁。如果进程持有某个自旋锁的时间过长,该进程可提前释放锁。
  • 执行等待循环:

    1
    2
    while(spin_is_locked(slp) && slp->break_lock)
    cpu_relax(); // 宏 cpu_relax() 简化为一条 pause 汇编指令,引入很短的延迟,加快了紧跟在锁后边的代码的执行并减少了能源消耗。
  • 回到第 1 步,再次试图获取自旋锁。

非抢占式内核中的 spin_lock 宏

如果在内核编译时没有旋转内核抢占选项,spin_lock 宏本质上为:

1
2
3
4
5
6
7
1: lock; decb slp->slock  // decb 递减自旋锁的值,该指令是原子的,因为带有 lock 前缀
jns 3f // 检测符号标志,如果被清 0,说明自旋锁被设置为 1(未锁),从 3 处继续执行,f:forward
2: pause // 否则,在 2 处执行紧凑的循环,直到自旋锁出现正值
cmpb $0, slp->slock
jle 2b
jmp 1b // 然后从 1 处重新执行,检查是否其他的处理器抢占了锁
3:

spin_unlock 宏

spin_unlock宏释放以前获得的自旋锁,本质上是:

1
movb $1, slp->slock

随后调用preempt_enable()(如果不支持内核抢占,什么也不做)。

读/写自旋锁

读/写自旋锁是为了增加内核的并发能力只要没有内核控制路径对数据结构修改,读/写自旋锁就允许多个内核控制路径同时读同一个数据结构。如果一个内核控制路径想对数据结构进行写操作,必须首先获得读/写锁的写锁,写锁授权独占访问这个资源。

读/写锁是一个rwlock_t结构,lock字段是一个 32 位的字段,分两部分:

  • 0~23 位,计数器,对受保护的数据结构并发进行读操作的内核控制路径的数目。
  • 第 24 位,“未锁”标志字段,当没有内核控制路径在读或写时设置该位,否则清 0。

lock 值:

  • 0x01000000:自旋锁为空(设置了“未锁”标志,且无读者)。
  • 0x00000000:写者获得了自旋锁(“未锁”标志清 0,且无读者)。
  • 0x00ffffff,0x00fffffe 等:一个或多个读者获得了自旋锁(“未锁”标志清 0,读者个数的二进制补码在 0~23 位上)。

rwlock_t结构也包括break_lock字段。rwlock_init宏把读/写自旋锁的lock字段初始化为 0x01000000(“未锁”),把break_lock初始化为 0。

读自旋锁

read_lock宏,作用于读/写自旋锁的地址rwlp,与spin_lock相似。如果编译内核时选择了内核抢占选项,read_lockspin_lock只有一点不同:执行_raw_read_trylock(),在第 2 步获得读/写自旋锁。

1
2
3
4
5
6
7
8
9
int _raw_read_trylock(rwlock_t  *lock)
{
atomic_t *count = (atomic_t *)lock->lock;
atomic_dec(count);
if(atomic_read(count) >= 0)
return 1;
atomic_inc(count);
return 0;
}

读写锁计数器lock是通过原子操作来访问的,但整个函数对计数器的操作并不是原子性的。

如果编译内核时没有选择内核抢占选项,read_lock宏产生如下汇编代码

1
2
3
4
5
	movl $rwlp->lock, %eax
lock; subl $1, (%eax) // 将自旋锁原子减 1,增加读者个数
jns 1f // 如果递减操作结果非负,就获得自旋锁
call __read_lock_failed // 否则,调用 __read_lock_failed()
1:

这里__read_lock_failed()是下列汇编语言函数:

1
2
3
4
5
6
7
8
9
// 试图获取自旋锁
__read_lock_failed:
lock; incl (%eax) // 原子增加 lock 字段,以取消 read_lock 宏执行的递减操作
1: pause
cmpl $1, (%eax) // 循环,直到 lock 字段 >= 0
js 1b
lock; decl (%eax)
js __read_lock_failed
ret

read_lock宏原子地把自旋锁的值减一,由此增加读者的个数,如果递减操作产生一个非负值,则获得自旋锁,否则调用__read_lock_failed(),该函数原子性地增加lock字段以取消由read_lock宏执行的递减操作,然后循环直到lock字段变为正数。

read_unlock释放读自旋锁:增加lock字段的计数器,以减少读者的计数,然后调用preempt_enable()重新启用内核抢占。

1
lock; incl rwlp->lock    // 减少读者计数

写自旋锁

write_lock宏与spin_lock()read_lock()相似。如果支持内核抢占,则禁用内核抢占并调用_raw_write_trylock()获得锁。如果函数返回0,说明锁已被占用,因此忙等待循环。

1
2
3
4
5
6
7
8
int _raw_write_trylock(rwlock_t *lock)
{
atomic_t *count = (atomic_t *)lock->lock;
if(atomic_sub_and_test(0x01000000, count)) // 从读/写自旋锁中减去 0x01000000,清除未上锁标志并返回 1
return 1;
atomic_add(0x01000000, count); // 原子地在自旋锁值上增加 0x0100000,以抵消减操作。
return 0; // 锁已经被占用,需重新启用内核抢占并开始忙等待
}

write_unlock宏释放写锁:

1
lock; addl $0x0100000, rwlp  // 把 lock 字段中的“未锁”标识置位

再调用preempt_enable()

顺序锁

顺序锁与读/写锁相似,只是为写者赋予了较高的优先级:读者读的时候允许写者运行。好处是写者永远不会等待(除非另一个写者在写),缺点是读者有时需多次读相同的数据直到获得有效的副本。

seqlock_t包括两个字段:

  • 类型为spinlock_tlock字段。
  • 整型的sequence字段,是一个顺序计数器。每个读者都必须在读数据前后两次读顺序计数器,如果两次读到的值不同,说明新的写者开始写并增加了顺序计数器,读取的数据无效。

SEQLOCK_UNLOCKED赋给变量seqlock_t,或执行seqlock_init宏,将seqlock_t初始化为未上锁。写者通过调用write_seqlock()write_sequnlock()获取和释放顺序锁。write_seqlock()获取seqlock_t中的自旋锁,然后使顺序计数器加 1。write_sequnlock()再次增加顺序计数器,然后释放自旋锁。可保证有写者写时,计数器值为奇数,没有写者时,计数器值是偶数。

读者执行下面临界区代码:

1
2
3
4
5
6
unsigned int seq;
do
{
seq = read_seqbegin(&seqlock); // 返回顺序锁的当前序号。如果是奇数,或 seq 的值与顺序锁的顺序计数器值不匹配,read_deqretry() 返回 1
/* ... 临界区 ... */
}while(read_deqretry(&seqlock, seq));

read_seqbegin()返回顺序锁的当前顺序号,如果局部变量seq的值是奇数,或seq的值与顺序锁的顺序计数器的当前值不匹配,read_seqretry()就返回1。

读者进入临界区,不必禁用内核抢占。由于写者获取自旋锁,它进入临界区时自动禁用内核抢占。

使用顺序锁的条件:

  • 被保护的数据结构不包括被写者修改和被读者间接引用的指针。
  • 读者的临界区代码没有副作用。
  • 另外:
    • 读者的临界区代码应该简短。
    • 写者不应常获取顺序锁。

典型例子:保护与系统时间处理相关的数据结构。

读-拷贝-更新(RCU)

是为了保护在多数情况下被多个 CPU 读的数据结构而设计的一种同步技术。RCU 允许多个读者和写者并发执行,且不使用锁,相比读/写自旋锁、顺序锁有更大的优势。通过限制 RCP 的范围,可不使用共享数据结构而实现多个 CPU 同步:

  • RCU 只包含被动态分配并通过引用指针引用的数据结构
  • 在被 RCU 保护的临界区中,任何内核控制路径不能睡眠

内核控制路径读取被 RCU 保护的数据结构流程:

  • 执行rcu_read_lock()等同于preempt_disable()
  • 读者间接引用该数据结构指针所对应的内存单元,并开始读该数据结构。读者在完成读操作前,不能睡眠。
  • rcu_read_unlock()等同于preempt_enable(),标记临界区的结束。

内核控制路径写被 RCU 保护的数据结构时,需要做一些事情防止竞争条件的出现。

  • 写者要更新数据结构时,间接引用指针并生成整个数据结构的副本
  • 写者修改该副本。
  • 修改完毕,写者改变指向数据结构的指针,使其指向被修改后的副本。
    • 修改指针的操作是一个原子操作。
    • 需要内存屏障保证:只有数结构被修改后,已更新的指针对其他 CPU 才是可见的。如果把自旋锁与 RCU 结合以禁止写者的并发执行,就隐含地引入了内存屏障。

使用 RCU 技术的困难:写者修改指针时不能立即释放数据结构的旧副本。写着开始修改时,正在访问数据结构的读者可能还在读旧副本。只有 CPU 上所有的读者都执行完宏rcu_read_unlock()后,才能释放旧副本。内核要求每个读者在执行以下操作前执行rcu_read_unlock()宏:

  • CPU 执行进程切换。
  • CPU 开始在用户态执行。
  • CPU 执行空循环。

对于以上任何情况中,认为 CPU 经过了静止状态

写者调用call_rcu()释放数据结构的旧副本。当所有的CPU都通过静止状态之后,call_rcu()接受rcu_head描述符的地址和将要调用的回调函数的地址作为参数。一旦回调函数被执行,它通常释放数据结构的旧副本。

函数call_rcu()把回调函数和其参数的地址存放在rcu_head描述符中,然后把描述符插入回调函数的每 CPU 链表中,内核每经过一个时钟滴答检查本地 CPU 是否经历了一个静止状态。如果所有 CPU 都经历了静止状态,本地 tasklet 执行链表中的所有回调函数。

信号量

实现了一个加锁原语,即让等待者睡眠,直到等待的资源变为空闲。

Linux 提供两种信号量:

  • 内核信号量,由内核控制路径使用。
  • System V IPC 信号量,由用户态进程使用。

内核信号量类似自旋锁,当锁关闭时,不允许内核控制路径继续进行。内核控制路径试图获取内核信号量保护的资源时,相应的资源会被挂起,直到资源被释放。因此,只有可睡眠的函数才能获取内核信号量,中断处理程序和可延迟函数不能使用内核信号量。

内核信号量数据结构struct semaphore,包含的字段:

  • count,存放atomic_t类型的值。> 0,资源空闲;= 0,信号量忙,但没有进程等待;< 0,资源不可用,至少一个进程等待资源。
  • wait,存放等待队列链表的地址,等待该资源的所有睡眠进程都在这个链表中。
  • sleepers,表示是否有一些进程在信号量上睡眠。

初始化信号量的几种情形:init_MUTEX()将 count 字段设置为 1(资源空闲),init_MUTEX_LOCKED()将 count 字段设置为 0。宏DECLARE_MUTEXDECLARE_MUTEX_LOCKED完成同样的功能,但它们也静态分配semaphore结构的变量。将 count 初始化为任意的正整数 n 时,最多有 n 个进程可以并发地访问该资源。

释放信号量

释放内核信号量时,调用up()函数,等价于:

1
2
3
4
5
6
7
8
9
10
	movl $sem->count, %ecx  
lock; incl (%ecx)
jg 1f
lea %ecx, %eax
pushl %edx
pushl %ecx
call __up // 从 eax 寄存器接收参数
popl %ecx
popl %edx
1:

up()增加*sem信号量 count 字段的值,然后检查它的值是否大于0。如果大于 0,说明没有进程在等待队列上睡眠,什么也不做,否则,调用 __up() 唤醒睡眠的进程。注意__up()从eax寄存器接收参数。__up()是:

1
2
3
4
__attribute_((regparm(3))) void __up(struct semaphore *sem)
{
wake_up(&sem->wait);
}

获取信号量时需要调用down()函数,等价于:

1
2
3
4
5
6
7
8
9
10
11
down:
movl $sem->count, %ecx
lock; decl (%ecx);
jns 1f
lea %ecx, %eax
pushl %edx
pushl %ecx
call __down
popl %ecx
popl %edx
1:

down()函数减少*sem信号量的 count 值,然后检查该值是否为负。该值的减少和检查过程必须是原子的。如果count大于等于0,当前进程获得资源并继续正常执行。否则,当前进程必须挂起,将一些寄存器内容压栈后,调用 __down()。这里__down()是下列C函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 挂起当前进程,直到信号量被释放
__attribute__((regparam(3))) void __down(struct semaphore *sem)
{
DECLARE_WAITQUEUE(wait, current);
unsigned long flags;
current->state = TASK_UNINTERRUPTIBLE;
spin_lock_irqsave(&sem->wait.lock, flags);
add_wait_queue_exclusive_locked(&sem->wait, &wait);
sem->sleepers++;
for(;;)
{
if(!atomic_add_negative(sem->sleepers-1, &sem->count)) // count -= 1
{
sem->sleepers = 0; // 如果 count 字段 >= 0,将 sleepers 置 0,表示没有进程在信号量等待队列上睡眠
break;
}

// 如果 count < 0
sem->seleepers = 1;
spin_unlock_irqrestore(&sem->wait.lock, flags);
schedule(); // 调用 schedule() 挂起当前进程
spin_lock_irqsave(&sem->wait.lock, flags);
current->state = TASK_UNINTERRUPTTIBLE;
}
remove_wait_queue_locked(&sem->wait, &wait);
wake_up_locked(&sem->wait); // 试图唤醒信号量等待队列中的另一个进程,并终止保持的信号量
spin_unlock_irqrestore(&sem->wait.lock, flags);
current->state = TASK_RUNNTING;
}

__down()将当前进程的状态从TASK_RUNNGING变为TASK_UNINTERRUPTIBLE,并把进程放在信号量的等待队列。该函数在访问信号量数据结构的字段前,获得保护信号量等待队列自旋锁sem->wait.lock,并禁止本地中断。当插入和删除元素时,等待队列函数根据需要获取和释放等待队列的自旋锁。__down()的主要任务是挂起当前进程直到信号量被释放。如果没有进程在信号量等待队列上睡眠,则信号量的sleepers字段通常被置为0,否则被置为1。考虑以下几种典型的情况:

  • MUTEX信号量打开(count=1,sleepers=0)
    • down宏仅仅把count字段置为0,并跳到主程序的下一条指令;因此,__down()函数根本不执行。
  • MUTEX信号量关闭,没有睡眠进程(count=0,sleepers=0)
    • down宏减count并将count字段置为-1 且sleepers字段置为0来调用__down()函数。在循环体的每次循环中,该函数检查count字段是否为负。
      • 如果count字段为负,__down()就调用schedule()挂起当前进程。count字段仍然设置为-1,而sleepers字段置为1,。随后,进程在这个循环内核恢复自己的运行并又进行测试。
      • 如果count字段不为负,则把sleepers置为0,并从循环退出。__down()试图唤醒信号量等待队列中的另一个进程,并终止保持的信号量。在退出时,count字段和sleepers字段都置为0,这表示信号量关闭且没有进程等待信号量。
  • MUTEX信号量关闭,有其他睡眠进程(count=-1,sleepers=1)
    • down宏减count并将count字段置为-2且sleepers字段置为1来调用__down()函数。该函数暂时把sleepers置为2,然后通过把sleepers - 1 加到count来取消down宏执行的减操作。同时,该函数检查count是否依然为负。
      • 如果count字段为负,__down()函数把sleepers重新设置为1,并调用schedule()函数挂起当前进程。count字段还是置为-1,而sleepers字段置为1.
      • 如果count字段不为负,__down()函数吧sleepers置为0,试图唤醒信号量等待队列上的另一个进程,并退出持有的信号量。在退出时,count字段置为0且sleepers字段置为0。

down_trylock()函数:适用于异步处理程序。该函数和down()函数除了对资源繁忙情况的处理有所不同之外,其他都是相同的。在资源繁忙时,该函数会立即返回,而不是让进程去睡眠。

down_interruptible()函数:该函数广泛使用在设备驱动程序中,因为如果进程接收了一个信号但在信号量上被阻塞,就允许进程放弃“down”操作。

另外,因为进程通常发现信号量处于打开状态,因此,就可以优化信号量函数。尤其是,如果信号量等待队列为空,up()函数就不执行跳转指令。同样,如果信号量是打开的,down()函数就不执行跳转指令。信号量实现的复杂性是由于极力在执行流的主分支上避免费时的指令而造成的。

读写信号量

类似于读/写自旋锁,不同之处:信号量再次变为打开之前,等待进程挂起而不是自旋转。只有在内核控制路径不持有读信号量和写信号量时,才能获取写信号量。

内核以严格的FIFO顺序处理等待读写信号量的所有进程。如果读者或写者进程发现信号量关闭,这些进程就被插入到信号量等待队列链表的末尾。当信号量被释放时,就检查处于等待队列链表第一个位置的进程。第一个进程常被唤醒。如果是一个写者进程,等待队列上其他的进程就继续睡眠。如果是一个读者进程,那么紧跟第一个进程的其他所有读者进程也被唤醒并获得锁。不过,在写者进程之后排队的读者进程继续睡眠。

数据结构为rw_semaphore

  • count,两个 16 位计数器。高 16 位以二进制补码形式存放非等待写者进程的总数(0 或 1)和等待的写内核控制路径数。低 16 位存放非等待的读者和写者总数。
  • wait_list,等待进程的链表。链表中的每个元素是rwsem_waiter结构,包含一个指针和一个标志,指针指向睡眠进程的描述符,标志表示进程为读信号量还是写信号量。
  • wait_lock,自旋锁,保护等待队列链表和rw_semaphore结构。

函数:

  • init_rwsem()初始化rw_semaphore结构,把count置为0,wait_lock置为未锁,wait_list 置为空链表。
  • dwon_read()down_write()获取读或写信号量。
  • up_read()up_write()释放读或写信号量。
  • down_read_trylock()down_write_trylock()类似于down_read()down_write(),但在信号量忙的情况下,不阻塞进程。
  • downgrade_write()自动将写锁转换为读锁。

补充原语

为了解决多处理器系统上发生的一种微妙的竞争关系,当进程 A 分配了一个临时信号变量,并将其初始化为关闭的 MUTEX,然后将其地址传递给进程 B。A 调用down(),打算一旦被唤醒旧撤销该信号量,而运行在不同 CPU 上的 B 在该信号量上调用up(),结果,up()可能访问一个不存在的数据结构。

上述现象的原因:up()down()可在同一信号量上并发执行。补充是专门设计来解决以上问题的同步原语。completion包含一个等待队列头和一个标志。

1
2
3
4
5
struct completion 
{
unsigned int done;
wait_queue_head_t wait;
};

up()对应的叫做complete()complete()接收completion的地址作为参数,在补充等待队列的自旋锁上调用spin_lock_irqsave()递增 done 字段,唤醒在 wait 等待队列上睡眠的互斥进程,调用spin_unlock_irqrestore()

down()对应的叫做wait_for_completion(),接收completion的地址作为参数,并检查done标志,如果 done > 0,说明complete()已在另一个CPU上运行,wait_for_completion()终止。否则wait_for_completion()current作为一个互斥进程加到等待队列的末尾,将current置为TASK_UNINTERRUPTIBLE状态并让其睡眠。一旦current被唤醒,将其从等待队列中删除。然后函数检查done标志的值,如果done = 0,结束;否则,再次挂起current

补充原语和信号量之间的真正差别:如何使用等待队列中包含的自旋锁。补充原语中,自旋锁确保complete()wait_for_completion()不会并发执行。信号量中,自旋锁用于避免并发执行的down()函数弄乱信号量的数据结构。

禁止本地中断

确保一组内核语句被当做一个临界区处理的主要机制之一就是中断禁止。即使当硬件设备产生了一个IRQ信号时,中断禁止也让内核控制路径继续执行。因此,这就提供了一种有效的方式,确保中断处理程序访问的数据结构也受到保护。然而,禁止本地中断并不保护运行在另一个CPU上的中断处理程序对数据结构的并发访问,因此,在多处理器系统上,禁止本地中断通常与自旋锁结合使用。

local_irq_disable()使用cli汇编语言指令关闭本地CPU上的中断,宏local_irq_enable()函数使用sti汇编语言指令打开被关闭的中断。汇编语言指令clisti分别清除和设置eflags控制寄存器的IF标志。如果eflags寄存器的IF标志被清零,宏irqs_disabled()产生等于1的值;如果IF标志被设置,该宏也产生为1的值。

中断可以以嵌套方式执行,因此内核在临界区末尾不能简单设置 IF 标志。控制路径必须保存先前赋给 IF 标志的置,并在执行结束时恢复它。

保存和恢复eflags的内容是分别通过宏local_irq_save()local_irq_restore()宏来实现的。local_irq_save宏把eflags寄存器的内容拷贝到一个局部变量中,随后用cli汇编语言指令把IF标志清零。在临界区的末尾,宏local_irq_restore恢复eflags原来的内容。因此,只有在这个控制路径发出cli汇编指令之前,中断被激活的情况下,中断才处于打开状态。

禁止和激活可延迟函数

禁止可延迟函数在一个 CPU 上执行的一种简单方式是禁止在那个 CPU 上的中断,使得软中断不能异步开始。另一种方式是禁止可延迟函数而不禁止中断,通过操作当前thread_info描述符preempt_count字段中存放的软中断计数器即可。

如果软中断计数器是正数,do_softirq() 函数就不会执行。tasklet 会在软中断之前被执行,并将该计数器设置为大于 0 的值。

local_bh_disable给本地 CPU 的软中断计数器加 1。local_bh_enable()函数从本地 CPU 的软中断计数器中减 1。内核因此能使用几个嵌套的local_bh_disable调用,只有宏local_bj_enable与第一个local_bh_disable调用相匹配,可延迟函数才再次被激活。

递减软中断计数器后,local_bh_enable执行两个重要操作以有助于保证适时执行长时间等待的进程:

  • 如果本地 CPU 的preempt_count字段中硬中断计数器和软中断计数器的值都等于 0,且有挂起的软终端,就调用do_softirq()激活这些软中断。
  • 如果本地 CPU 的TIF_NEED_RESCHED标志被设置,说明进程切换请求时挂起的,调用preempt_schedule()

对内核数据结构的同步访问

系统性能可能随所选择的同步原语种类的不同而有很大变化。通常情况下,内核开发者采用下述由经验得到的法则:把系统中的并发度保持在尽可能高的程度。系统中的并发度取决于两个主要因素:

  • 同时运转的I/O设备数;
  • 进行有效工作的CPU数。

为了使I/O吞吐量最大化,应该使中断禁止保持在很短的时间。当中断被禁止时,由I/O设备产生的IRQ被PIC暂时忽略,因此,就没有新的活动在这种设备上开始。

为了有效地利用CPU,应该尽可能避免使用基于自旋锁的同步原语。当一个CPU执行紧指令循环等待自旋锁打开时,是在浪费宝贵的机器周期。同时,由于自旋锁对硬件高速缓存的影响而使其对系统的整体性能产生不利影响。

在自旋锁、信号量及中断禁止之间选择

只要内核控制路径获得自旋锁(还有读/写锁、顺序锁或 RCU“读锁”),就禁用本地中断或本地软中断,自动禁用内核抢占。

保护异常所访问的数据结构

当一个数据结构仅由异常处理程序访问时,最常见的产生同步问题的异常就是系统调用服务例程,在这种情况下,CPU运行在内核态而为用户态程序提供服务。因此,仅由异常访问的数据结构通常表示一种资源,可以分配给一个或多个进程。竞争条件可以通过信号量避免,因为信号量原语允许进程陲眠到资源变为可用。

保护中断所访问的数据结构

中断处理程序本身不能同时多次运行。因此,访问数据结构就无需任何同步原语。但是,如果多个中断处理程序访问一个数据结构,情况就有所不同了。一个处理程序可以中断另一个处理程序,不同的中断处理程序可以在多处理器系统上同时运行。没有同步,共享的数据结构就很容易被破坏。

单处理器系统中,必须在中断处理程序的所有临界区上禁止中断来避免竞争条件,其他同步原语都不行。因为信号量能阻塞进程,自旋锁可能使系统冻结。多处理器系统中,避免竞争条件最简单的方法是禁止本地中断,并获取保护数据结构的自旋锁或读/写自旋锁

Linux内核使用了几个宏,把本地终端激活/禁止与自旋锁结合。

保护被可延迟函数所访问的数据结构

单处理器系统上不存在竞争条件,因为可延迟函数的执行总是在一个 CPU 上串行执行,不需要同步原语。多处理器系统上存在竞争条件,因为几个可延迟函数可以并发执行。

由软中断访问的数据结构必须受到保护,通常使用自旋锁,因为同一个软中断可在多个 CPU 上并发运行。仅由一种 tasklet 访问的数据结构不需要保护,因为同种 tasklet 不能并发执行。由几种 tasklet 访问,必须对数据结构进行保护。

保护由异常和中断访问的数据结构

单处理系统上,因为中断处理程序是不是可重入的且不能被异常中断,只要内核以本地中断禁止访问数据结构,内核在访问数据结构的过程中就不会被中断。如果数据结构被一种中断处理程序访问,中断处理程序不用禁止本地中断就可访问数据结构。多处理器系统上,必须关注异常和中断在其他CPU上的并发执行。本地中断禁止必须外加自旋锁,强制并发的内核控制路径等待,直到访问数据结构的处理程序完成自己的工作。

有时使用信号量代替自旋锁可能更好。因为中断处理程序不能被挂起,必须用紧循环down_trylock()函数获得信号量,在这里,信号量的作用与自旋锁一样。系统调用服务例程可在信号量忙时挂起调用进程,提高系统并发度。

保护由异常和可延迟函数访问的数据结构

保护由异常和可延迟函数访问的数据结构与异常和中断处理程序访问的数据结构处理方式类似。可延迟函数本质上是由中断的出现激活的,而可延迟函数执行时不可能产生异常。因此,把本地中断禁止与自旋锁结合起来即可

异常处理程序可通过使用local_bh_disable()宏禁止可延迟函数,而不禁止本地中断。在每 CPU 上可延迟函数的执行都被串行化,不存在竞争条件。多处理器系统上,使用自旋锁可确保任何时候只有一个内核控制路径访问数据结构。

保护由中断和可延迟函数访问的数据结构

类似于中断和异常访问的数据结构。可延迟函数执行期间禁用本地中断。没有其他的中断处理程序访问数据结构时,中断处理程序可随意访问被可延迟函数访问的数据结构而不用关中断。多处理器系统上,需要自旋锁禁止对多个 CPU 上数据结构的并发访问。

保护由异常、中断和可延迟函数访问的数据结构

禁止本地中断和获取自旋锁几乎总是避免竞争条件所必须的,但没有必要显式禁止可延迟函数。

避免竞争条件的实例

引用计数器

引用计数器广泛应用于内核中以避免由于资源的并发分配和释放而产生的竞争条件。它是一个atomic_t计数器,与特定的资源,如内存页、模块或文件相关。当内核控制路径开始使用资源,原子地减少计数器值;当内核控制路径用完资源,原子地增加计数器值。原子计数器变为 0,说明资源未被使用,如果必要,释放该资源。

大内核锁

大内核锁是相对粗粒度的自旋锁,确保每次只有一个进程运行在内核态。在2.2和2.4版本中具有极大的灵活性,不再依赖一个单独的自旋锁,而是由许多不同的自旋锁保护大量的内核数据结构。从2.6.11开始,用叫kernel_sem的信号量实现大内核锁,但比信号量复杂。每个进程描述符含有lock_depth字段,允许同一个进程几次获得大内核锁。对大内核锁两个连续的请求不挂起处理器。字段为-1表示,进程未获得过锁;字段为正数,表示请求了多少次锁。lock_depth对中断处理程序、异常处理程序以及可延迟函数获取大内核锁都是至关重要的,如果没有这个字段,那么在当前进程已经有大内核锁的情况下,任何试图获得这个锁的异步函数都可能产生死锁。

lock_kernel()获得大内核锁:

1
2
3
4
depth = current->lock_depth + 1;
if(depth == 0)
down(&kernel_sem);
current->lock_depth = depth;

unlock_kernel()释放大内核锁:

1
2
if(--current->lock_depth < 0)
up(&kernel_sem);

持有大内核锁的进程可调用schedule()放弃 CPU。不过schedule()检查被替换进程的lock_depth字段,如果它的值是正数或0,则自动释放kernel_sem信号量。因此不会有显式调用schedule()的进程在进程切换前后都保持大内核锁。

当一个持有大内核锁的进程被强占时,schedule()一定不能释放信号量,因为在临界区内执行代码的进程没有主动触发进程切换。

为避免被强占的进程事情大内核锁,preempt_schedule_irq()临时把进程的lock_depth字段设置为 -1,这样schedule()假定被替换的进程不拥有 kernel_sem信号量,也就不能释放它。一旦该进程再次被调度程序选中,preempt_schedule_irq()函数就恢复lock_depth原来的值,并让进程在被大内核锁保护的临界区中继续执行。

内存描述符读/写信号量

mm_struct类型的每个内存描述符在mmap_sem字段中都包含了信号量。因为几个轻量级进程之间可以共享一个内存描述符,因此信号量可保护该描述符,以避免可能产生的竞争条件。这种信号量为以读/写信号量方式实现,因为一些内核函数,如缺页异常处理程序只需要扫描内存描述符。

slab 高速缓存链表的信号量

slab 高速缓存描述符链表是通过cache_chain_sem信号量保护的,允许互斥地访问和修改该链表。当kmem_cache_create()在链表中增加一个新元素,而kmem_cache_shrink()kmem_cache_reap()顺序地扫描整个链表时,可能产生竞争条件。

索引节点的信号量

Linux 把磁盘文件的信息存放在一种叫做索引节点的内存对象中。相应的数据结构包括自己的信号量,存放在i_sem字段中。在文件系统的处理过程中会出现很多竞争条件。竞争条件都可以通过用索引节点信号量保护目录文件来避免。只要一个程序使用了两个或多个信号量,就存在死锁的可能,因为两个不同的控制路径可能互相死等着释放信号量。在有些情况下,内核必须获得两个或更多的信号量锁。索引节点信号量倾向于这种情况,例如,在rename()系统调用的服务例程中就会发生这种情况。在这种情况下,操作涉及两个不同的索引节点,因此,必须采用两个信号量。为了避免这样的死锁,信号量的请求按预先确定的地址顺序进行。

定时测量

很多计算机化的活动都是由定时测量(timing measurement)来驱动的,这常常对用户是不可见的。Linux内核必需完成两种主要的定时测量,我们可以对此加以区别:

  • 保存当前时间和日期,以便能通过time()ftime()gettimeofday()系统调用把它们返回给用户程序,也可以由内核本身把当前时间作为文件和网络包的时间戳。
  • 维持定时器,这个机制能够告诉内核或用户程序某一时间间隔已经过去了。

定时测量是由基于固定频率振荡器和计数器的几个硬件电路完成的。

时钟和定时器电路

在80x86体系结构上,内核必须显式地与集中时钟和定时器电路打交道。时钟电路同时用于跟踪当前时间和产生精确的事件量度。

实时时钟(RTC)

所有PC都包含一个叫实时时钟(real Time Clock RTC)的时钟,它独立于CPU和所有其他芯片的。即使当PC被切断电源,RTC还继续工作,因为它靠一个小电池或蓄电池供电。RTC能在IRQ8上周期性的发出中断,频率在2~8192Hz之间。也可以对RTC进行编程以使当RTC到达某个特定的值是激活IRQ8线,也就是作为一个闹钟来工作。Linux只用RTC来获取事件和日期,不过,通过对/dev/rtc设备文件进行操作,也允许进程对RTC编程。

时间戳计数器(TSC)

所有的80x86微处理器都包含一条CLK输入引线,它接受外部振荡器的时钟信号。计数器在每个时钟信号到来时加1,该计数器利用时间戳计数器TSC寄存器来实现,通过指令rdtsc访问。Linux利用这个寄存器能获得更精确的时间测量,为了做到这点Linux必须在初始化时确定时钟信号的频率。算出CPU实际频率的任务是在系统初始化期间完成的。calibrate_tsc()通过计算一个大约在5ms的时间间隔内所产生的时钟信号的个数来计算CPU的实际频率。

可编程间隔定时器(PIT)

PIT的作用类似于微波炉的闹钟,即让用户意识到烹调的时间间隔已经过去了。所不同的是,这个设备不是通过振铃,而是发出一个特殊的中断,叫做时钟中断来通知内核又一个时间间隔过去了。

时钟中断的频率取决于体系结构,较慢的机器其节拍大概为10ms,而较快的机器的节拍大概1ms。在Linux中有几个宏产生决定时钟中断频率的常量:

  • HZ产生每秒时钟中断的近似个数,也就是时钟中断的频率。
  • CLOCK_TICK_RATE产生的值为1193182,这个是是振荡器频率。
  • LATCH产生CLOCK_TICK_RATEHZ的比值再四舍五入后的整数值。这个值用来对PIT编程。

PIT由setup_pit_timer()进行初始化

outb()等价于outb汇编指令,把第一个操作数拷贝到第二个操作数指定的IO端口。outb_p()类似于outb(),不过,它会通过一个空操作而产生一个暂停,以避免使硬件难以分辨。udelay()宏函数引入一个更短的延迟。第一条outb_p()让PIT以新的频率产生中断。接下来的两条outb_p()outb()为设备提供新的中断频率。把16位LATCH常量作为两个连续的字节发送到设备的8位IO端口0x40,结果,PIT将以1000Hz的频率产生时钟中断。

CPU本地定时器

CPU本地定时器是一种能够产生单步中断或周期性中断的设备,它类似于可编程间隔定时器APIC,区别:

  • APIC计数器是32位,而PIC计数器是16位; 因此,可以对本地定时器编程来产生很低频率的中断
  • 本地APIC定时器把中断只发送给自己的处理器,而PIT产生一个全局性中断,系统中的任一CPU都可以对其处理。
  • APIC定时器是基于总线时钟信号的。每隔1,2,4,8,16,32,64或128总线时钟信号到来时对该定时器进行递减可以实现对其编程的目的。相反,PIT有其自己的内部时钟振荡器,可以更灵活地编程。

高精度事件定时器(HPET)

高精度事件定时器是由Intel和Microsoft联合开发的一种新型定时器芯片。尽管这种定时器在终端用户机器上还并不普遍,但Linux2.6已经能够支持它们。

ACPI电源管理定时器

ACPI电源管理定时器(或称ACPI PMT)是另一种时钟设备,包含在几乎所有基于ACPI的主板上。它的时钟信号拥有大约为3.58MHz的固定频率。该设备实际上是一个简单的计数器, 它在每个时钟节拍到来时增加一次。为了读取计数器的当前值,内核需要访问某个I/O端口,该I/O端口的地址由BIOS在初始化阶段确定。

如果操作系统或者BIOS可以通过动态降低CPU的工作频率或者工作电压来节省电池的电能,那么ACPI电源管理定时器就比TSC更优越。当发生ACPI PMT的频率不会改变。而另一方面,TSC计数器的高频率非常便于测量特别小的时间间隔。

Linux计时体系结构

Linux必定执行与定时相关的操作。例如,内核周期性地:

  • 更新自系统启动以来所经过的时间
  • 更新时间和日期
  • 确定当前进程在每个CPU上已运行了多长时间,如果已经超过了分配给它的时间,则抢占它。时间片(也叫时限)
  • 更新资源使用统计数
  • 检查每个软定时器的时间间隔是否已到。

Linux的计时体系结构(time keeping architecture)是一组与时间流相关的内核数据结构和函数,

  • 在单处理器系统上,所有的计时活动都是由全局定时器(可以是可编程间隔定时器也可以是高精度事件定时器)产生的中断触发的
  • 在多处理器系统上,所有普通的活动(像软定时器的处理)都是由全局定时器产生的中断触发的,而具体CPU的活动是由本地APIC定时器产生的中断触发的。

计时体系机构的数据结构

定时器对象

为了使用一种统一的方法来处理可能存在的定时器资源,内核使用了定时器对象,它是timer_opts类型的一个描述符,该类型由定时器名称和四个标准的方法组成,如表6-1所示:

定时器对象中最重要的方法是mark_offsetget_offsetmark_offset方法由时间中断处理程序调用,并以适当的数据结构记录每个节拍到来时的准确时间get_offset方法使用已记录的值来计算自上一次时钟中断(节拍)以来经过的时间(us为单位)。由于这两种方法,使得Linux计时体系结构能够达到子节拍的分辨度,也就是说,内核能够以比节拍周期更高的精度来测定当前的时间,这种操作被称作定时插补(timerinterpolation)

变量cur_timer存放了某个定时器对象的地址,该定时器是系统可利用的定时器资源中“最好的”,内核初始化期间,select_timer()设置cur_timer指向适当定时器对象的地址。表6-2以优先级顺序列出了80x86体系结构中最常用的定时器对象。定时插补一列列出了定时器对象的mark_offsetget_offset使用的定时器源,

jiffies变量

jiffies变量是一个计数器,用来记录自系统启动以来产生的节拍总数。每次时钟中断发生时(每个节拍)它便加1.在80x86体系结构中,jiffies是一个32位的变量,因此每隔大约50天它的值会回绕(wraparound)到0,这对Linux服务器来说是一个相对较短的时间间隔。不过,由于使用了time_aftertime_afer_eqtime_beforetime_before_eq四个宏,内核干净利索地处理了jiffies变量的益出。

jiffies被初始化为0xfffb6c20,它是一个32位有符号值,正好等于-300000。因此计数器将会在系统启动5分钟内处于溢出状态,使得那些不对jiffies做溢出检查的有缺陷代码在开发阶段被及时发现。

但是内核需要自系统启动以来产生的系统节拍的真实数目。在80x86系统中,jiffies变量通过连接器被换算成一个64位计数器的低32位,这个64位的计数器被称作jiffies_64。在1ms为一个节拍的情况下,jiffies_64变量将会在数十亿年后才发生回绕,所以我们可以放心地假定它不会溢出。

在32位系统中不能自动对64位变量进行访问,因此需要一些同步机制当两个32位计数器(由这两个32位计数器组织为64位计数器)的值在被读取时这个64位的计数器不会被更新,结果是每个64位的读操作明显比32位的读操作更慢。

get_jiffies_64()用来读取jiffies_64的值并返回该值。

xtime_lock()顺序锁用来保护64位的读操作:该函数一直读jiffies_64直到确认该变量并没有同时被其他内核控制路径更新才读取jiffies_64。当在临界区增加jiffies_64的值时必须用write_seqlock(&xtime_lock)write_sequnlock(&xtime_lock)进行保护。

xtime变量

xtime变量存放当前时间和日期;它是一个timespec类型的数据结构,该结构有两个字段:

  • tv_sec:存放自1970年1月1日(UTC)午夜以来经过的秒数
  • tv_nsec:存放自上一秒开始经过的纳秒数

xtime变量通常是每个节拍更新一次,也就是说,大约每秒更新1000次。用户程序从xtime变量获得当前时间和日期。内核也经常引用它。xtime顺序锁消除了对xtime的同时访问而可能发生的竞争条件。

单处理器系统上的计时体系结构

在单处理器系统上,所有与定时有关的活动都是由IRQ线0上的可编程间隔定时器产生的中断触发的。

初始化阶段

在内核初始化期间,time_init()函数被用来建立计时体系结构,它通常执行如下操作:

  1. 初始化xtime变量。利用get_cmos_time()函数从实时时钟上读取自1970年1月1日(UTC)午夜以来经过的秒数。设置xtimetv_nsec字段,使得即将发生的jiffies变量溢出与tv_sec字段保持一致,也就说,它将落到秒的范围内。
  2. 初始化wall_to_monotonic变量,它存放将被加到xtime上的秒数和纳秒数。
  3. 如果内核支持HPET,它将调用hpet_enable()函数来确认ACPI固件是否探测到了该芯片并将它的寄存器映射到了内存地址空间中。
  4. 调用select_timer()来挑选系统中可利用的最好的定时器资源,并设置cur_timer变量指向该定时器资源对应的定时器对象的地址。
  5. 调用setup_irq(0, &irq0)来创建与IRQ0相应的中断门,IRQ0引脚线连接着系统时钟中断源(PIT或HPET)。irq0变量被静态定义如下:struct irqaction irq0 = {timer_interrupt, SA_INTERRUPT, 0, "timer", NULL, NULL);。从现在起,timer_interrupt()函数将会在每个节拍到来时被调用,而中断被禁止,因为IRQ0主描述符的状态字段中的SA_INTERRUPT标志被置位。

时钟中断处理程序

timer_interrupt()函数是PIT或HPET的中断服务例程,它执行以下步骤:

  • xtime_lock顺序锁上产生一个write_seqlock()来保护与定时相关的内核变量。
  • 执行cur_timer定时器对象的mark_offset方法。正如前面的”计时体系结构的数据结构”一节解释的那样,有四种可能的情况:
    • cur_timer指向timer_hpet对象;这种情况下,HPET芯片作为时钟中断源。mark_offset方法检查自上一个节拍以来是否丢失时钟中断,在这种不太可能发生的情况下,它会相应地更新jiffies_64。接着,该方法记录下HPET周期计数器的当前值。
    • cur_time指向timer_pmtmr对象;在这种情况下PIT芯片作为时钟中断源,但是内核使用APIC电源管理定时器以更高的分辨度来测量时间。mark_offset方法检查自上一个节拍以来是否丢失时钟中断,如果丢失则更新jiffies_64。然后,它记录APIC电源管理定时器计数器的当前值。
    • cur_time指向timer_tsc对象;在这种情况下,PIT芯片作为时钟中断源,但是内核使用时间戳计数器以更高的分辨度来测量时间。mark_offset方法执行与上一种情况相同的操作。
    • cur_timer指向timer_pit对象;这种情况下,PIT芯片作为时钟中断源,除此之外没有别的定时器电路。mark_offset方法什么也不做。
  • 调用do_timer_interrupt()函数,do_timer_interupt()函数执行以下操作:
    • 使jiffies_64的值增加1。注意,这样做是安全的,因为内核控制路径仍然为写操作保持着xtime_lock顺序锁。
    • 调用update_times()函数来更新系统日期和时间,并计算当前系统负载。
    • 调用update_process_times()函数为本地CPU执行几个与定时相关的计数操作。
    • 调用profile_tick()函数。
    • 如果使用外部时钟来同步系统时钟,则每隔660秒调用一次set_rtc_mmss()函数来调整实时时钟。这个特性用来帮助网络中的系统同步它们的时钟。
  • 调用write_sequnlock()释放xtime_lock顺序锁。
  • 返回值1,报告中断已经被有效地处理了。

多处理器系统上的计时体系结构

多处理器系统可以依赖两种不同的时钟中断源:可编程间隔定时器或高精度事件定时器产生的中断,以及CPU本地定时器产生的中断。在linux2.6中,PIT或HPET产生的全局时钟中断触发不涉及具体CPU的活动,比如处理器软定时器和保持系统时间的更新。相反,一个CPU本地时间中断触发涉及本地CPU的计时活动,例如监视当前进程的运行时间和更新资源使用统计数。

初始化阶段

全局时钟中断处理程序由time_imit()函数初始化。Linux内核为本地时钟中断保留第239号中断向量。初始化阶段,函数apic_intr_init()根据239号向量和低级中断处理程序apic_timer_interrupt()的地址设置IDT的中断门。函数calibrate_APIC_clock()通过正在启动的CPU的本地APIC来计算在一个节拍内收到了多少个总线时钟信号。这个确切的值被用来对本地所有APIC编程,并由此在每个节拍产生一次本地时钟中断。这是由setup_APIC_timer()完成,该函数被系统中的每个CPU执行一次。

全局时钟中断处理程序

SMP版本的timer_interrupt()处理程序与UP版本的该处理程序在几个地方有差异:

  • timer_interrupt()调用函数do_timer_interrupt()向IO APIC芯片的一个端口写入,以应答定时器的中断。
  • update_process_times()函数不被调用,因为该函数执行与特定CPU相关的操作
  • profile_tick()不被调用,因为该函数同样执行与特定CPU相关的操作。

本地时钟中断处理程序

处理程序执行系统中与特定CPU相关的计时活动,即监管内核代码并检测当前进程在特定CPU上已经运行了多长时间。apic_timer_interrupt()等价于:

该低级处理函数与其他低级中断处理函数相似,被称作smp_apic_timer_interrupt()的高级中断处理函数执行如下步骤:

  • 获得CPU逻辑号
  • 使irq_stat数组中第n项的apic_timer_irqs字段加一
  • 应答本地APIC上的中断
  • 调用irq_enter()函数
  • 调用smp_local_timer_interrupt()函数
  • 调用irq_exit()函数

smp_local_timer_interrupt()函数执行每个CPU的计时活动,执行下边的主要步骤。

  • 调用profile_tick()函数
  • 调用update_process_times()函数检查当前进程运行的时间并更新一些本地CPU统计数

更新时间和日期

用户程序从xtime变量中获得当前时间和日期。内核必须周期性地更新该变量,才能使它的值保持相当的精确。全局时钟中断处理程序调用update_times()函数更新xtime变量的值。

全局时钟中断处理程序调用update_time()函数更新xtime()的值:

1
2
3
4
5
6
7
8
9
void update_times(void) {
unsigned long ticks;
ticks = jiffies - wall_jiffies;
if(ticks) {
wall_jiffies += ticks;
update_wall_time(ticks);
}
calc_load(ticks);
}

wall_jiffies变量存放xtime变量最后更新的时间。内核不必每个时钟节拍更新xtime变量。然而最后不会有时钟节拍丢失。因此,xtime最终存放正确的系统时间。update_wall_time()函数连续调用update_wall_time_one_tick()函数ticks次,每次调用都给xtime.tv_nsec字段加上1000000。如果xtime.tv_nsec大于999999999,那么update_wall_time()函数还会更新xtimetv_sec字段。如果系统发出adjtimex()系统调用,那么函数可能会稍微调整1000000这个值使时钟稍快或稍慢一点。

更新系统统计数

内核在与定时相关的其他任务中必须周期性地收集若干数据用于:

  • 检查运行进程的CPU资源限制
  • 更新与本地CPU工作负载有关的统计数
  • 计算平均系统负载
  • 监管内核代码

更新本地CPU统计数

单处理器系统上的全局时钟中断处理程序或多处理器系统上的本地时钟中断处理程序调用update_process_times()函数来更新一些内核统计数。该函数执行以下步骤:

  • 检查当前进程运行了多长时间。当时钟中断发生时,根据当前进程运行在用户态还是内核态,选择调用account_user_time()还是account_system_time()。每个函数基本上执行如下步骤。
    • 更新当前进程描述符的utime字段或stime字段。在进程描述符中提供两个被称作cutimecstime的附加字段,分别用来统计子进程在用户态和内核态下所经过的CPU节拍数。由于效率的原因,update_process_times()并不更新这些字段,而只是当父进程询问它的其中一个子进程的状态时才对其进行更新。
    • 检查是否已达到总的CPU时限,如果是,向current进程发送SIGXCPUSIGKILL信号
    • 调用account_it_virt()account_it_prof()来检查进程定时器。
    • 更新一些内核统计数,这些统计数存放在每CPU变量kstat中。
  • 调用raise_softirq()来激活本地CPU上的TIMER_SOFTIRQ任务队列。
  • 如果必须回收一些老版本的,受RCU保护的数据结构,那么检查本地CPU是否经历了静止状态并调用tasklet_schedule()来激活本地CPU的rcu_tasklet任务队列。
  • 调用scheduler_tick()函数,该函数使当前进程的时间片计数器减1,并检查计数器是否已减到0

记录系统负载

用户输入uptime命令后可以看到一些统计数据:如相对于最后1分钟,5分钟,15分钟的”平均负载”。在单处理器系统上,值0意味着没有活跃的进程在运行,而值1意味着一个单独的进程100%占有CPU,值大于1说明几个运行着的进程共享CPU。

update_times()在每个节拍都要调用calc_load()函数来计算处于TASK_RUNNINGTASK_UNINTERRUPTIBLE状态的进程数,并用这个数据更新平均系统负载。

监管内核代码

Linux包含一个被称作readprofiler的最低要求的代码监管器,检测在内核态的什么地方花费时间。监管器确定内核的热点——执行最频繁的内核代码片段。它基于非常简单的蒙特卡洛算法在每次时钟中断发生时,内核确定该中断是否发生在内核态;如果是,内核从堆栈取回中断发生前的eip寄存器的值。并用这个值揭示中断发生前内核正在做什么。最后,采样数据积聚在“热点”上。

profile_tick()函数为代码监管器采集数据。这个函数在单处理器系统上是由do_timer_interrup()调用的,在多处理器系统上是由smp_local_timer_interrup()函数调用的。

为了激活代码监管器,在Linux内核启动时必须传递字符串参数profile=N,这里2的N的次方表示要监管的代码段的大小,采集的数据可以从/proc/profile文件中读取。可以通过修改这个文件来重置计数器;在多处理器系统上,修改这个文件还可以改变抽样频率。不过,内核开发者并不直接访问/proc/profile文件,而是用readprofile系统命令。

Linux2.6内核还包含了另一个监管器,叫做oprofileoprofile除了更灵活,更可定制外,还能用于发现内核代码,用户态应用程序及系统库中的热点。当使用ofrofile时,profile_tick()调用timer_notify()函数来收集这个新监管器所使用的数据。

检查非屏蔽中断监视器

Linux提供了看门狗系统,这对于探测引起系统冻结的内核bug可能相当有用。为了激活这样的看门狗,必须在内核启动时传递nmi_watchdog参数。

看门狗基于本地和I/O APIC一个巧妙的硬件特性;它们能在每个CPU上产生周期性的NMI中断,因为NMI中断是不能用汇编语言指令cli屏蔽的,所以,即使禁止中断,看门狗也能检测到死锁

因而,一旦每个时钟节拍到来,所有的CPU,不管其正在做什么,都开始执行NMI中断处理程序;该中断处理程序又调用do_nmi()。这个函数获得CPU的逻辑号n,然后检查irq_stat数组第n项的apic_timer_irqs字段。如果该CPU字段工作正常,那么,第n项的值必定不同于在前一个NMI中断中读出的值。当CPU正常运行时,第n项的apic_timer_irq字段就会被本地时钟中断处理程序增加,如果计数器没有增加,说明本地时钟中断处理程序在整个时钟节拍期间根本就没有被执行。

当NMI中断处理程序检测到一个CPU冻结时,就会敲响所有的钟:它把引起恐慌的信息记录在系统日志文件中,转储该CPU寄存器的内容和内核栈的内容。最后杀死当前进程,这就为内核开发者提供了发现错误的机会。

软定时器和延迟函数

定时器允许在将来的某个时刻,函数在给定的时间间隔用完时被调用。超时(time-out)表示与定时器相关的时间间隔已经用完的那个时刻。

每个定时器都包含一个字段,表示定时器将需要多长时间才能到期。这个字段的初值就是jiffies的当前值加上合适的节拍数。这个字段的值不再改变,当jiffies大于或等于这个字段存放的值时,定时器到期。

Linux考虑两种类型的定时器, 即动态定时器 (dynamic timer)和间隔定时器 (internal timer).第一种类型由内核使用,而间隔定时器可以由进程的用户态创建。

因为对定时器函数的检查总是由可延迟函数进行,而可延迟函数被激活以后很长时间才能被执行,因此,内核不能确保定时器函数正好在定时期间开始执行,而只能保证在适当的时间执行它们,或者假定延迟到几百毫秒之后执行它们

动态定时器

动态定时器被动态的创建和撤销,对当前活动的动态定时器的个数没有限制。动态定时器存放在下列timer_list结构中:

1
2
3
4
5
6
7
8
9
struct timer_list{
struct list_head entry;
unsigned long expires;
spinlock_t lock;
unsigned long magic;
void (*function)(unsigned long);
unsigned long data;
tvec_base_t *base;
};

function字段包含定时器到期时执行函数的地址data字段指定传递给定时器函数的参数。正是由于data字段,就可以定义一个单独的通用函数来处理多个设备驱动程序的超时问题,在data字段可以存放设备ID,或其它有意义的数据,定时器函数可以用这些数据区分不同的设备。expires字段给出定时器到期时间,时间用节拍数表示,其值为系统启动以来所经历过的节拍数。当expries的值小于或等于jiffies的值时,就说明计时器到期或终止。entry字段用于将软定时器插入双向循环链表队列中,该链表根据定时器expires字段的值将它们分组存放。

为了创建并激活一个动态定时器,内核必须:

  • 如果需要,创建一个新的timer_list对象,比如说设为t。这可以通过以下几种方式来进行:
    • 在代码中定义一个静态全局变量
    • 在函数内定义一个局部变量;在这情况下,这个对象存放在内核堆栈中。
    • 在动态分配的描述符中包含这个对象。
  • 调用init_timer(&t)函数初始化这个对象。实际上是把t.base指针字段置为NULL并把t.lock自旋锁设为”打开”.
  • 把定时器到期时激活函数的地址存入funciton字段。如果需要,把传递给函数的参数值存入data字段。
  • 如果动态定时器还没有被插入到链表中,给expires字赋一个合适的值并调用add_timer(&t)函数把t元素插入到合适的链表中。
  • 否则,如果动态定时器已经插入到链表中,则调用mod_timer()函数来更新expires字段,这样也能将对象插入到合适的链表中。

一旦定时器到期,内核就自动把元素t从它的链表中删除。不过,有时进程应该用del_timer()del_timer_sync()del_singleshot_timer_syn()函数显式地从定时器链表中删除一个定时器。事实上,在定时器到时期之前,睡眠的进程可能被唤醒,在这种情况下,唤醒的进程就可以选定撤消某个定时器。虽然从链表中已删除的定时器上调用del_timer()没什么害处。不过,在定时器函数内删除定时器是一种的习惯做法。

在linux2.6中,动态定时器需要CPU来激活,也就是说,定时器函数总会在每一个执行add_timer()或稍后执行mod_timer()函数的那同一个CPU上运行。不过,del_timer()及与其类似的函数能使所有动态定时器无效,即使该定时器并不依赖于本地CPU激活。

动态定时器与竞争条件

被异步激活的的动态定时器有参与竞争条件的倾向。例如,考虑一个动态定时器,它的函数作用于可丢弃的资源。如果在定时器函数被激活时资源不存在,那么不停止定时器就释放资源势必导致数据结构的崩溃。因此,一种凭经验的做法就是在释放资源前停止定时器:

1
2
del_timer(&t);
X_release_Resources();

然后,在多处理器系统上,这段代码是不安全的,因为当调用del_timer()函数时,定时器函数可能已经在其它CPU上运行了。结果,当定时器函数还作用在资源上时,资源可能被释放。为了避免这种竞争条件,内核提供了del_timer_sync()函数。这个函数从链表中删除定时器,然后检查定时器函数是否还在其它CPU上运行;如果是,del_timer_sync()就等待,直到定时器函数结束。

del_timer_sync()函数相当复杂,而且执行速度慢,因为它必须小心考虑这种情况:定时函数重新激活它自己。如果内核开发者知道定时器从不重新激活定时器,她就能使用更简单更快速的del_singleshot_timer_sync()函数来使定时器无效,并等直到定时器函数结束。

当然,也存在其它种类的竞争条件。例如,修改已激活定时器expires字段的正确方法是调用mod_timer(),而不是删除定时器随后又创建它。在后一种方法中,要修改同一定时器expires字段的两个内核控制路径可能糟糕地交错在一起。定时器函数在SMP上的安全实现是通过每个timer_list对象包含的lock自旋锁达到的:每当内核必须访问动态定时器的链表时,就禁止中断并猎取这个自旋锁。

动态定时器的数据结构

基于一种巧妙的数据结构,即expires值划分成不同的大小,并允许动态定时器从大expires值的链表效率到小expires值的链表进行有效的过滤。此外,在多处理器系统中活动的动态定时器集合被分配到各个不同的CPU中。

动态定时器的主要数据结构是一个叫做tvec_bases的每CPU变量;它包括NR_CPUS个元素,系统中每个CPU各有一个。每个元素是一个tvec_base_t类型的数据结构,它包含相应CPU中处理动态定时器需要的所有数据。

1
2
3
4
5
6
7
8
9
10
typedef struct tvec_t_base_s{
spinlock_t lock;
unsigned long timer_jiffies;
struct timer_list *running_timer;
tvec_root_t tvl
tvec_t tv2;
tvec_t tv3;
tvec_t tv4;
tvec_t tv5;
} tver_base_t;

字段tv1的数据结构为tvec_root_t类型,它包含一个vec数组,这个数组由256个list_head元素组成。这个结构包含了在紧接着到来的255个节拍内将要到期的所有动态定时器。

字段tv2tv3tv4的数据结构都是tvec_t类型,该类型有一个数组vec。这些链表包含在紧接着到来的2的14次减1等几个节拍内将要到期的所有动态定时器。

字段tv5与前面的字段几乎相同,但唯一区别就是vec数组的最后一项是一个大expires字段值的动态定时器链表。tv5从不需要从其它的数组补充。图6-1用图例说明了5个链表组。

timer_jiffies字段的值表示需要检查的动态定时器的最早到期时间;如果这个值与jiffies的值一样,说明可延迟函数没有积压;如果这个值小于jiffies,说明前几个节拍相关的可延迟函数必须处理。该字段在系统启动时被设置成jiffies的值,且只能由run_timer_softirq()函数增加它的值。注意当处理动态定时器的可延迟函数在很长一段时间内都没有被执行时,timer_jiffies字段的值表示需要检查的动态定时器的最早到期时间;如果这个值与jiffies的值一样,说明可延迟函数没有积压;如果这个值小于jiffies,说明前几个节拍相关的可延迟函数必须处理。该字段在系统启动时被设置成jiffies的值,且只能由run_timer_softirq()函数增加它的值。注意当处理动态定时器的可延迟函数在很长一段时间内都没有被执行时,timer_jiffies字段可能会落后jiffies许多。

在多处理器系统中,字段running_timer指向由本地CPU当前正处理的动态定时器timer_list数据结构。

动态定时器处理

尽管软定时器具有巧妙的数据结构,但是对其处理是一种耗时的活动,所以不应该被时钟中断处理程序执行。在Linux2.6中该活动由延迟函数来执行,也就是由TIMER_SOFTIRQ软中断行。

run_timer_softirq()函数是与TIMER_SOFTIRQ软中断请求相关的可延迟函数。它实质上执行如下操作:

  • 把与本地CPU相关的tvec_base_t数据结构的地址存放到base本地变量中。
  • 获得base->lock自旋锁并禁止本地中断
  • 开始执行一个while循环,当base->timer_jiffies大于jiffies的值时终止,在每一次循环过程中,执行下列子步骤:
    • 计算base->tv1中链表的索引,该索引保存着下一次将要处理的定时器:index =base->timer_jiffies &255
    • 如果索引值为0,说明base->tv1中的所有链表已经被检查过了,所以为空;于是该函数通过调用cascade()来过滤动态定时器
      • if(!index && (!cascade(base, &base->tv2, (base->timer_jiffies>>8)&63)) && (!cascade(base, &base->tv3, (base->timer_jiffies>>14)&63)) && (!cascade(base, &base->tv4, (base->timer_jiffies>>20)&63)) && (!cascade(base, &base->tv4, (base->timer_jiffies>>26)&63)) )
      • 考虑每一次调用cascade函数的情况:它接收base的地址、base->tv2的地址、base->tv2中链表的索引作为参数。该索引值是通过观察base->timer_jiffies的特殊位上的值决定的。cascade()函数将base->tv2中链表上的所有动态定时器移动base->tv1的适当链表上。然后,如果所有base->tv2中链表不为空,它返回一个正值。如base->tv2中的链表为空,cascade()将再次被调用,把base->tv3中的某个链表上包含的定时器填充到base->tv2上,如此等等。
    • 使base->timer_jiffies的值加1。
    • 对于base->tv1.vec[index]链表上的每一个定时器,执行它所对应的定时器函数。特别说明的时,链表上的每个timer_list元素t实质上执行以下步骤。
      • t从base->tv1的链表中删除
      • 在多处理器系统上,将base->running_timer设置为&t
      • 设置t.base为NULL
      • 释放base->lock自旋锁,并允许本地中断
      • 传递t.data作为参数,执行定时器函数t.function
      • 获得base->lock自旋锁,禁止本地中断
      • 如果有其他定时器,则继续执行
    • 链表上的所有定时器已经被处理。继续执行最外层while循环的下一次循环。
  • 最外层的while循环结束,这就意味着所有到期的定时器已经被处理了。在多处器系统中,设置base->running_timer为NULL
  • 释放base->lock自旋锁并允许本地中断。

由于jiffiestimer_jiffies的值经常是一样的,所以最外层的while循环常常只执行一次。一般情况下,最外层循环会连续执行jiffies-base->timer_jiffies+1次。此外,如果在run_timer_softirq()正在执行时发生了时钟中断,那么也得考虑在这个节拍所出现的到期动态定时器,因为jiffies变量的值是由全局时钟中断处理程序异步增加的。

请注意,就在进入最外层循环前,run_timer_softirq()要禁止中断并获取base->lock自旋锁;调用每个动态定时器函数前,激活中断并释放自旋锁,直到函数执行结束,这就保证了动态定时器的数据结构不被交错执行的内核控制路径所破坏。

综上所述可知,这种相当复杂的算法确保了极好的性能。让我们来看看为什么,为了简单起见,假定TIMER_SOFTIRQ软中断正好在相应的时钟中断发生后执行。那么,在256次中出现的255次时钟中断,run_imter_softirq()仅仅运行到期定时器的函数,为了周期性地补充base->tv1.vec,在64次补充当中,63次足以把base->tv2指向的链表分成base->tv1指向的256个链表。依次地,base->tv2.vec数组必须在0.006%的情况下得到补充,即使16.4秒一次。类似地,每17分28秒补充一次base->tv3.vec,每18小时38分补充一次base->tv4.vec,而base->tv5.vec不需要补充。

动态定时器应用之一:nanosleep()系统调用

让我们考虑nanosleep()系统调用的服务例程,即sys_nanosleep(),它接收一个指向timespec结构的指针作为参数,并将调用进程挂起直到特定的时间间隔用完。服务例程首先调用copy_from_user()将包含在timespec结构中的值复制到局部变量t中,接着函数执行:

1
2
current->state = TASK_INTERRUPTIBLE;
remaining = schedule_timeout(timespec_to_jiffies(&t) + 1);

timespec_to_jiffies()函数将存放在timespec结构中的时间间隔转换成节拍数。内核使用动态定时器实现进程的延时。他们出现在schedule_timeout()中,执行:

1
2
3
4
5
6
7
8
9
10
11
struct timer_list timer;
unsigned long expire = timeout + jiffies;
init_timer(&timer);
timer.expires = expire;
timer.data = (unsigned long) current;
timer.function = process_timeout;
add_timer(&timer);
schedule(); //进程挂起直到定时器到时
del_singleshot_timer_sync(&timer);
timeout = expire - jiffies;
return (timeout < 0 ? 0 : timeout);

当schedule()被调用时,选择另一个进程执行;当前一个进程恢复执行时,该函数就删除这个动态定时器。最后的返回值有两种可能,0表示延迟到期,timeout表示如果进程因某些其他原因被唤醒,到延时到期还剩余的节拍数,延时到期时执行下列函数:

1
2
3
void process_timeout(unsigned long __data) {
wake_up_process((task_t *)__data);
}

process_timeout()接收进程描述符指针作为参数,挂起的进程被唤醒。进程被唤醒就继续执行sys_nanosleep(),如果schedule_timeout()返回值表明进程延时到期,系统调用结束。

延迟函数

动态定时器有很大的设置开销和一个相当大的最小等待时间(1ms),所以使用很不方便,在这种情况下内核使用udelay()ndelay(),前者接收一个微秒级时间间隔作为参数,并在指定的延迟结束后返回,后者与前者类似,但是指定延迟的参数时纳秒级的。

1
2
3
4
5
6
7
8
9
10
void udelay(unsigned long usecs) {
unsigned long loops;
loops = (usecs*HZ*current_cpu_data.loops_per_jiffy)/1000000;
cur_time->delay(loops);
}
void ndelay(unsigned long nsecs) {
unsigned long loops;
loops = (nsecs*HZ*current_cpu_data.loops_per_jiffy)/1000000000;
cur_time->delay(loops);
}

两个函数都依赖于cur_timer定时器对象的delay方法,它接收loops中的时间间隔作为参数。不过每一次loop精确的持续时间取决于cur_timer涉及的定时器对象。

  • 如果cur_timer指向timer_hpettimer_pmtmrtimer_tsc对象,那么一次loop对应于一个CPU循环,也就是两个连续CPU时钟信号间的时间间隔;
  • 如果cur_timer指向对象,那么一次loop对应于一条紧凑指令循环在一次单独的循环中所花费的时间;

在初始化阶段,select_timer()设置好cur_timer后,内核通过执行calibrate_delay()函数来决定一个节拍里有多少次loop。这个值被保存在current_cpu_data.loops_per_jiffy变量中,这样udelay()ndelay()就能根据它来把微秒和纳秒转换成loops

当然,如果可以利用HPET或TSC硬件电路,那么cur_timer->delay()方法使用它们来获得精确的时间测量。否则,该方法执行一个紧凑指令循环的loops次循环。

与定时测量相关的系统调用

time() 和 gettimeofday() 系统调用

用户态下的进程通过以下几个系统调用获得当前的时间和日期。

  • time()返回从1970年1月1日午夜(UTC)开始走过的秒数。
  • gettimeofday()返回从 UTC 开始所走过的秒数及在前 1 秒内走过的微妙数,存放于timeval中。

gettimeofday()sys_gettimeofday()实现,该函数调用do_gettimeofday(),它执行下列动作:

  • 为读操作获取xtime_lock顺序锁。
  • usec = cur_timer->getoffset();确定自上一次时钟中断以来走过的微妙数。
    • cur_timer可能指向对象timer_hpettimer_pmtmrtimer_tsctimer_pit,分别获取相应计数器的当前值与上一次时钟中断处理程序时的值比较。
  • 如果某定时器中断丢失,usec += (jiffies - wall_jiffies) * 1000;usec加上相应的延迟。
  • usec += (xtime.tv_nsec / 1000);为 usec 加上前 1 秒内走过的微妙数。
  • xtime的内容复制到系统调用参数tv指定的用户空间缓冲区中,并给微秒字段的值加上usectv->tv_sec = xtime->tv_sec; tv->tv_usec = usec;
  • xtime_lock顺序锁上调用read_seqretry(),如果另一条内核控制路径同时为写操作获得了xtime_lock,跳回步骤 1。
  • 检查微秒字段是否溢出,如果有必要调整该字段和秒字段:
1
2
3
4
while(tv->tv_usec >= 1000000){
tv->tv_usec -= 1000000;
tv->tv_sec++;
}

adjtimex() 系统调用

通常把系统配置成能在常规基准上运行时间同步协议,如网络定时协议(NTP),在每个节拍逐渐调整时间。这依赖于adjtimex()adjtimex()接收指向timex结构的指针作为参数,用timex自动中的值更新内核参数,并返回具有当前内核值的同一结构。update_wall_time_one_tick()使用这以内核值对每个节拍中加到`xtime.tv_usec``的微秒进行微调。

setitimer() 和 alarm() 系统调用

Linux允许用户态的进程激活一种叫做间隔定时器的特殊定时器。它引起 Unix 信号被周期性地发送到进程,也可能在指定的延时后仅发送一个信号,它由以下两个方面来刻画:

  • 发送信号所需要的频率
  • 在下一个信号被产生以前所剩余的时间

setitimer()可激活间隔定时器,第一个参数指定应当采取下面哪一个策略:

  • ITIMER_REAL,真正过去的时间,进程接收SIGALRM信号。
  • ITIMER_VIRTUAL,进程在用户态下花费的时间,进程接收SIGVTALRM信号。
  • ITIMER_PROF,进程既在用户态下又在内核态下所花费的时间,进程接收SIGPROF信号。

间隔定时器既能一次执行,也能周期循环。setitimer()的第二个参数指向一个itimerval类型的结构,它指定了定时器初始的持续时间以及定时器被重新激活后使用的持续时间setitimer()的第三个参数是一个指针,可选,指向一个itimerval类型的结构,系统调用将先前定时器的参数填充到该结构中。

为分别实现前述每种策略的定时器,进程描述符包含 3 对字段:

  • it_real_incrit_real_value
  • it_virt_incrit_virt_value
  • it_prof_incrit_prof_value

每对中的第一个字段存放两个信号之间以节拍为单位的间隔,第二个字段存放定时器当前值。

ITIMER_REAL间隔定时器利用动态定时器实现,因为即使进程不运行,内核也能向其发送信号。每个进程描述符包含一个叫real_timer的动态定时器对象。

setitimer()过程:

  • 初始化real_timer字段
  • 调用add_timer()将动态定时器插入到合适的链表中
  • 定时器到期时,内核执行it_real_fn()函数,并由it_real_fn()函数向进程发送一个SIGALRM信号
  • 如果it_real_incr不为空,再次设置expires字段,并重新激活定时器

ITIMER_VIRTUALITIMER_PROF间隔定时器不需要动态定时器,因只有进程运行时才会被更新。account_it_virt()account_it_prof()update_process_times()调用,而update_process_times()在单处理器系统上被 PIT 的时钟中断处理程序调用,在多处理器上被本地时钟中断处理程序调用。因此,每个节拍中,这两个间隔定时器都会被更新一次,如果到期,就给当前进程发送一个合适的信号。

alarm()会在一个指定的时间间隔用完时向调用的进程发送一个SIGALRM信号,参数为ITIMER_REAL时类似于setitimer()

与 POSIX 定时器相关的系统调用

引入一种新型软定时器,尤其是针对多线程和实时应用程序。这些定时器被称为POSIX定时器。执行POSIX定时器必须向用户态程序提供一些POSIX时钟,也就是说虚拟时间源预定义了分辨度和属性。只要想使用POSIX定时器,就创建一个新的定时器资源并指定一个现存的POSIX时钟来作为定时基准。

Linux 2.6 内核提供两种类型的 POSIX 时钟:

  • CLOCK_REALTIME,该虚拟时钟表示系统的实时时钟,本质上是xtime变量的值。clock_getres()系统调用返回的分辨度为 999 848ns,1s 内更新 xtime 约 1000 次。
  • CLOCK_MONOTONIC,该虚拟时钟表示由于与外部时间源的同步,每次回到初值的系统实时时钟。实际上,该虚拟时钟由xtimewal_to_monotonic两个变量的和表示。分辨度由clock_getres()返回,为 999 848ns。

Linux 内核使用动态定时器实现 POSIX 定时器,与ITIMER_REAL间隔定时器相似,但更灵活、可靠,区别如下:

  • 一个 POSIX 定时器到期时,内核可以发送各种信号给整个多线程应用程序,也可发送给单个指定线程。
  • 对于 POSIX 定时器,进程可调用timer_getoverrun()系统调用来得到自第一个信号产生以来定时器到期的次数。

进程调度

调度策略

传统 Unix 操作系统的调度必须实现几个冲突的目标:进程响应时间尽可能快后台作业的吞吐量尽可能高尽可能避免进程的饥饿现象低优先级和高优先级的进程需要尽可能调和等等。调度策略是决定什么时候以怎样的方式为一个新进程选择运行的规则。

Linux 的调度基于分时技术,多个进程以时间多路复用方式运行,CPU的时间被分成片,给每个可运行进程分配一片。如果片到期,进程切换就可以执行。调度策略也根据进程的优先级对它们进行分类。Linux 中,进程的优先级是动态的

  • 进程分类方式一:
    • I/O 受限。频繁使用 I/O 设备,并花费很多时间等待 I/O 操作的完成。
    • CPU 受限。需要大量 CPU 时间的数值计算应用程序。
  • 进程分类方式二:
    • 交互式进程。如命令 shell,文本编辑程序及图形应用程序。
    • 批处理进程。如程序设计语言的编译程序。
    • 实时进程。如视频和音频应用程序、机器人控制程序及从物理传感器收集数据的程序。

一个批处理进程可能是 I/O 受限的(如数据库服务器),或 CPU 受限的(如图像绘制程序)。Linux 中,调度程序可确认实时程序,通过基于进程过去行为的启发式算法(平均睡眠时间)区分交互式程序和批处理程序。通过下表中的系统调用改变调度优先级:

进程的抢占

如果进程进入TASK_RUNNING状态,内核检查到它的动态优先级大于当前正在运行进程的优先级,current的执行被中断,调度程序选择另一个进程运行(通常为刚刚变为可运行的进程)。

进程当前的时间片到期也可以被抢占。此时,当前进程thread_info结构中的TIF_NEED_RESCHED标志被设置,以便时钟中断处理程序终止时调度程序被调用。被抢占的进程没有被挂起,因为还处于TASK_RUNNING状态,只不过不再使用 CPU。

一个时间片必须持续多长?

如果平均时间片太短,进程切换引起的系统额外开销就变得非常高。如果平均时间片太长,进程看起来就不再是并发执行,也会降低系统的响应能力。Linux 单凭经验的方法,即选择尽可能长、同时能保持良好响应时机的一个时间片。

调度算法

Linux 2.6 的调度算法较好的解决了与可运行进程数量的比例关系,因为它在固定的时间内(与可运行的进程数量无关)选中要运行的队列,也很好地处理了与处理器数量的比例关系,因为每个CPU都拥有自己的可运行进程队列,较好的解决了区分交互式进程和批处理进程的问题。

总是至少有个一个可运行进程,即swapper进程,它的PID为0,只有在没有其他进程执行时运行。

每个 Linux 进程总是按照如下调度类型被调度:

  • SHED_FIFO,先进先出的实时进程。当调度程序把CPU分配给进程的时候,它把该进程描述符保留在运行队列链表的当前位置。如果没有更高优先级的实时进程,则进程继续使用CPU。
  • SCHED_RR,时间片轮转的实时进程。把该进程的描述符放在运行队列链表的末尾。
  • SCHED_NORMAL,普通的分时进程。

普通进程的调度

每个普通进程都有静态优先级:100(最高)~ 139(最低)。值越大静态优先级越低。新进程继承父进程的静态优先级,但可通过将某些nice值传递给nice()setpriority()改变。

基本时间片

静态优先级本质上决定了进程的基本时间片,即进程用完了以前的时间片,系统分配给进程的时间片长度。静态优先级和基本时间片的关系如下:

静态优先级越小,基本时间片就越长,通常优先级越高的进程获得更长的CPU时间片。

动态优先级和平均睡眠时间

动态优先级的范围为:100(最高)~ 139(最低),它是调度程序在选择新进程来运行的时候使用的优先级:动态优先级 = max(100, min( 静态优先级 - bonus + 5, 139))bonus的值与进程的平均睡眠时间相关,范围0~10,小于5表示降低动态优先级以示惩罚,大于5表示增加动态优先级以示奖赏

粗略地将,平均睡眠时间是进程在睡眠状态所消耗的平均纳秒数,进程在运行的过程中平均睡眠时间递减,不会大于 1s。

平均睡眠时间也被调度程序用来判断一个给定进程是交互进程还是批处理进程。如果一个进程满足下式,则被看作交互式进程:动态优先级 ≤ 3 × 静态优先级 / 4 + 28,相当于下面的:bonus - 5 ≥ 静态优先级 / 4 - 28

表达式:静态优先级/4-28被称为交互式的δ。应该注意,高优先级进程比低优先级进程更容易成为交互式进程。例如,具有最高静态优先级(100)的进程,当它的bonus值超过2,即睡眠时间超过200ms时,就被看作是交互式进程。相反,具有最低静态优先级(139)的进程决不会被当作交互式进程,因
为bonus值总是小于11,相应地需要交互式δ等于6。一个具有缺省静态优先级(120)的进程,一但其平均睡眠时间超过700ms,就成为交互式进程。

活动和过期进程

当一个较高优先级的进程用完其时间片,应该被还没有用完时间片的低优先级进程取代,为此,调度程序维持两个不相交的可运行进程的集合。

  • 活动进程:还没有用完时间片的进程,允许运行。
  • 过期进程:用完了时间片,被禁止运行,直到所有活动进程过期。

总体方案要稍复杂一些:

  • 用完时间片的活动批处理进程总是变成过期进程。
  • 用完时间片的交互式进程仍是活动进程:调度重新重填其时间片并把它留在活动进程集合中
  • 当最老的过期进程等待了很久,或过期进程比交互式进程的静态优先级高,调度程序把用完时间片的交互式进程移到过期进程集合。
  • 活动进程集合最终会变为空,过期进程将有机会运行。

实时进程的调度

每个实时进程都有实时优先级,范围 1(最高)~ 99(最低)。调度程序总是让优先级高的进程运行,实时进程运行的过程中禁止低优先级进程的运行。实时进程总是被当成活动进程。用户可通过sched_setparam()sched_setscheduler()改变进程的实时优先级。

只有如下事情发生,实时进程才会被另一个进程取代:

  • 进程被另外一个具有更高优先级的实时进程抢占。
  • 进程执行了阻塞操作并进入睡眠(处于TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE状态)。
  • 进程停止(处于TASK_STOPPEDTASK_TRACED状态)或被杀死(处于EXIT_ZOMBIEEXIT_DEAD状态)。
  • 进程通过调用sched_yield()自愿放弃 CPU。
  • 进程是基于时间片轮转的实时进程(SCHED_RR),且用完了时间片。

当系统调用nice()setpriority()用于基于时间片轮转的实时进程时,不改变实时进程的优先级而会改变其基本时间片的长度

调度程序所使用的数据结构

进程链表链接所有的进程描述符,运行队列链表链接所有的可运行进程(处于TASK_RUNNING状态的进程)的进程描述符,swapper 进程(idle 进程)除外。

数据结构 runqueue

runqueue结构存放在runqueues每 CPU 变量中。宏this_rq()产生本地 CPU 运行队列的地址,宏cpu_rq(n)产生索引为n的 CPU 运行队列的地址。

最重要的字段是与可运行进程的链表相关的字段。系统中的每个可运行进程属于且只属于一个运行队列。只要可运行进程保持在同一个运行队列,它就只可能在拥有该运行队列的CPU上执行。

arrays是一个包含两个prio_array_t的数组,每个数据结构都表示一个可运行进程的集合。两个数据结构的作用会发生周期性的变化:活动进程突然变成过期进程,过期进程变为活动进程。调度程序简单地交换运行队列的 active 和 expired 字段的内容完成这种变化。

进程描述符

每个进程描述符都包含几个与调度相关的字段。

新进程被创建时,copy_process()调用sched_fork()用下述方法设置current父进程和p子进程的time_slice字段:

1
2
p->time_slice = (current->time_slice + 1) >> 1;
current->time_slice >> = 1;

父进程剩余的节拍数被划分成两等份:一份给自己,另一份给子进程。避免因为创建过多子进程而占用太多时间片,一个进程不能通过创建多个后代来霸占资源。

如果父进程的时间片只剩下一个时钟节拍,则current->time_slice置为0。copy_process()current-time_slice重新置为 1,然后调用scheduler_tick()递减该字段。

copy_process()也初始化子进程描述符中与进程调度相关的字段:

1
2
p->first_time_slice = 1;       // 因为子进程没有用完它的时间片,所以设置为 1
p->timestamp = sched_clock(); // 返回被转换成纳秒的 64 位仅存去 TSC 的内容

调度程序所使用的函数

调度程序依靠几个函数完成调度工作

  • scheduler_tick():维持当前最新的time_slice计数器
  • try_to_wake_up():唤醒睡眠进程
  • recalc_task_prio():更新进程的动态优先级
  • schedule():选择要被执行的新进程
  • load_balance():维持多处理器系统中运行队列的平衡

scheduler_tick()

主要步骤如下:

  • 将转换为纳秒的 TSC 值存入本地运行队列的timestamp_last_tick字段。TSC值从sched_clock()获得。
  • 如果进程是本地 CPU 的 swapper 进程,执行下列步骤:
    • 如果本地运行队列还包括另外一个可运行的进程,就设置当前进程的TIF_NEED_RESCHED字段,强制重新调度。
    • 跳到第 7 步(没必要更新 swapper 进程的时间片计数器)。
  • 如果current->array没有指向本地运行队列的活动链表,说明进程已经过期但还没有被替换,则设置TIF_NEED_RESCHED标志,强制重新调度,跳到第 7 步。
  • 获得this_rq()->lock自旋锁。
  • 递减当前进程的时间片计数器,如果已经用完时间片,则根据进程的调度类型进行相应操作,稍后讨论。
  • 释放this_rq()->lock自旋锁。
  • 调用reabalance_tick(),保证不同 CPU 的运行队列的可运行进程数量基本相同。

更新实时进程的时间片

对于先进先出的实时进程,scheduler_tick()什么也不做,因为current不可能被其他低优先级或等优先级的进程抢占,维持最新时间片计数器没有意义。

对于基于时间片轮转的实时进程,scheduler_tick()递减其时间片计数器,如果时间片被用完,执行一系列操作以达到抢占当前进程的目的:

1
2
3
4
5
6
7
if(current->policy == SCHED_RR && !--current->time_slice){
current->time_slice = task_timeslice(current);
current->first_time_slice = 0;
set_tsk_need_resched(current);
list_del(&current->run_list);
list_add_tail(&current->run_list, this_rq()->active->queue+current->prio); // 然后把进程重新插入到同一个活动链表的尾部
}

第一步操作包括调用task_timeslice()来重填进程的时间片计数器,根据进程的静态优先级返回相应的基本时间片。此外,currentfirst_time_slice字段被清零,该标志被fork()中的copy_process()设置,并在进程的第一个时间片刚一用完立即清0。

第二步,scheduler_tick()调用函数set_tsk_need_resched()设置进程的TIF_NEED_RESCHED标志,强制调用schedule()函数,使current被另一个具有相同或更高优先级的实时进程取代。

scheduler_tick()最后一步把进程描述符移到与当前进程优先级相对应的运行队列活动链表尾部。把current指向的进程放到链表尾部,保证在每个优先级与它相同的可运行实时进程获得CPU时间片以前,它不会再次运行。这是基于时间片的轮转,进程描述符的移动首先调用list_del()把进程从运行队列的活动链表中删除,之后调用list_add_tail()把进程重新插入到同一个活动链表尾部。

更新普通进程的时间片

如果当前进程是普通进程,scheduler_tick()执行如下操作:

  • 递减时间片计数器(current->time_slice)
  • 如果时间片用完,执行下列操作:
    • dequeue_task()从可运行进程的this_rq()->active集合中删除current
    • set_tsk_need_resched()设置TIF_NEED_RESCHED标志。
    • 更新current的动态优先级:current->prio = effective_prio(current);
      • effective_prio()currentstatic_priosleep_avg字段,计算进程的动态优先级。
    • 重填进程的时间片:current->time_slice = task_timeslice(current);current->first_time_slice = 0;
    • 如果本地运行队列的数据结构的expired_timestamp字段等于0(过期进程集合为空),把当前时钟节拍的值赋给expired_timestamp
      • if(!this_rq()->expired_timestamp) this_rq()->expired_timestamp = jiffies;
    • 把当前进程插入活动进程集合或过期进程集合:
1
2
3
4
5
6
7
8
9
10
// 如果进程是一个非交互式进程,TASK_INTERACTIVE 宏产生值 0
// EXPIRED_STARVING 宏检查到运行队列中的第一个过期进程的等待时间已经超过 1000 个时钟节拍乘以运行队列中的可运行进程数加1,产生值 1
// 如果当前进程的静态优先级大于一个过期进程的静态优先级,EXPIRED_STARVING 宏也产生值 1
if(!TASK_INTERACTIVE(current) || EXPIRED_STARVING(this_rq()){
enqueue_task(current, this_rq()->expired); // 插入过期进程集合
if(current->static_prio < this_rq()->best_expired_prio)
this_rq()->best_expired_prio = current->static_prio;
}
else
enqueue_task(current, this_rq()->active); // 插入活动进程集合
  • 否则,时间片没有用完(current->time_slice不等于 0),检查当前进程的剩余时间片是否太长:
1
2
3
4
5
6
7
8
// bonus = TIMESLICE_GRANULARIT 宏产生的 CPU 的数量 * 比例常量 的乘积
// 具有高静态优先级的交互式进程,其时间片被分成大小为 TIMESLICE_GRANULARITY 的几个片段,以使这些进程不会独占 CPU
if(TASK_INTERACTIVE(p) && !((task_timeslice(p) - p->time_slice) % TIMESLICE_GRANULARIT(p)) &&
(p->time_slice >= TIMESLICE_GRANULARITY(p)) && (p->array == rq->active)) {
list_del(&current->run_list);
list_add_tail(&current->run_list, this_rq()->active->queue+current->prio);
set_tsk_need_resched(p);
}

try_to_wake_up()

通过将进程状态设置为TASK_RUNNING,并把该进程插入本地 CPU 的运行队列来唤醒睡眠或停止的进程。参数:

  • 被唤醒进程的进程描述符p
  • 可被唤醒的进程状态掩码state
  • 标志sync,禁止被唤醒的进程抢占本地 CPU 上正在运行的进程

执行下列操作:

  1. task_rq_lock()禁用本地中断,获得最后执行进程的 CPU 所拥有的运行队列rq的锁。CPU的逻辑号存储在p->thread_info->cpu字段。
  2. 如果进程的状态p->state不属于参数state(状态掩码),跳到第 9 步,终止函数。
  3. 如果p->array != NULL,那么进程已经属于某个运行队列,跳到第 8 步。
  4. 多处理器系统中,检查要被唤醒的进程是否应该迁移到另一个 CPU 的运行队列,根据以下启发式规则选择一个目标运行队列:
    1. 空闲 CPU 的运行队列。
    2. 先前工作量较小的 CPU 运行队列。
    3. 如果进程最近被执行过,选择老的运行队列 。
    4. 工作量较小的本地 CPU 运行队列。
  5. 如果进程处于TASK_UNINTERRUPTIBLE状态,递减目标运行队列的nr_uninterruptible字段,p->activeted = -1
  6. 调用activate_task(),执行下列步骤:
    1. sched_clock()获得以纳秒为单位的当前时间戳。如果目标 CPU 不是本地 CPU,补偿本地时钟中断的偏差:now = (sched_clock() - this_rq()->timestamp_last_tick) + rq->timestamp_last_tick;
    2. recalc_task_prio(),参数为进程描述符指针、上一步计算出的时间戳now
    3. 根据情况设置p->activated字段。
    4. p->timestamp = now;
    5. 把进程描述符插入活动进程集合:enqueue_task(p, rq->active); rq->nr_running++;
  7. 如果目标 CPU 不是本地 CPU,或没有设置sync标志,就检查可运行的新进程的动态优先级是否比rq运行队列中当前进程的动态优先级高,如果p->prio < rq->curr->prio,调用resched_task()抢占rq->curr
    1. 单处理器系统中,resched_task()仅执行set_tsk_need_resched()设置rq->currTIF_NEED_RESCHED标志;
    2. 多处理器系统中,resched_task()如果发现TIF_NEED_RESCHED的旧值为0、目标CPU与本地CPU不同、rq->curr进程的TIF_POLLING_NRFLAG的标志清0,调用smp_send_reschedule()产生 IPI,强制目标 CPU 重新调度。
  8. 把进程的p->state置为TASK_RUNNING
  9. task_rq_unlock()打开rq运行队列的锁并打开本地中断。
  10. 成功唤醒进程返回 1,否则返回 0。

recalc_task_prio()

更新进程的平均睡眠时间p->sleep_avg和动态优先级p->prio。参数:

  • 进程描述符的指针p
  • sched_clock()计算出的当前时间戳now

执行下述操作:

  1. min(now - p->timestamp, 10^9);的结果赋值给sleep_timep->timestamp包含导致进程进入睡眠状态的进程切换的时间。因此sleep_time是进程从最后一次执行开始,消耗在睡眠状态的纳秒数
  2. 如果sleep_time不大于0,不需要更新进程的平均睡眠时间,跳到第 8 步。
  3. 如果进程不是内核线程、从TASK_UNINTERRUPTIBLE状态被唤醒(p->activated等于-1)、连续睡眠的时间超过睡眠极限,则p->sleep_avg设置为相当于900时钟节拍的值,然后跳到第 8 步。
  4. 执行CURRENT_BONUS宏计算进程原来的平均睡眠时间的bonus值,如果10 - bonus大于0,函数用这个值与sleep_time相乘。因为将要把sleep_time加到进程的平均睡眠时间上,所以当前平均睡眠时间越短,它增加的就越快。
  5. 如果进程处于TASK_UNINTERRUPTIBLE状态但不是内核线程,执行下述步骤:
    1. 如果平均睡眠时间p->sleep_avg大于睡眠时间极限,sleep_time置为0,不用调整平均睡眠时间,跳到第 6 步。
    2. 如果sleep_time + p->sleep_avg大于等于睡眠时间极限,p->sleep_avg置为睡眠时间极限并把sleep_time置为0。
    3. 通过对进程平均睡眠时间的轻微限制,函数不会对睡眠时间长的批处理进程给予过多奖赏。
  6. sleep_time加到平均睡眠时间p->sleep_avg上。
  7. 检查p->sleep_avg是否超过1000个以纳秒为单位的时钟节拍,如果是,函数就把p->sleep_avg减到1000个时钟节拍。
  8. 更新进程的动态优先级:p->prio = effective_prio(p)

schedule()

schedule()实现调度程序。从运行队列链表中找到一个进程,并随后将 CPU 分配给这个进程。schedule()可由几个内核控制路径调用,可采取直接调用或延迟调用的方式。

直接调用

如果current进程未获得必需的资源而阻塞,就直接调用调度程序。要阻塞的内核路径执行下述步骤:

  1. current进程插入适当的等待队列。
  2. current进程的状态改为TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE
  3. 调用schedule()
  4. 如果资源不可用,跳到第 2 步;否则,从等待队列中删除current进程。
  5. 一旦资源可用,就从等待队列中删除current进程

延迟调用

也可以把current进程的TIF_NEED_RESCHED标志置为1,而以延迟方式调用调度程序。每次在恢复用户进程的执行之前会检查该标志。延迟调用调度程序的例子:

  • current进程用完时间片,由scheduler_tick()完成schedule()的延迟调用。
  • 被唤醒进程的优先级高于当前进程,try_to_wake_up()完成schedule()的延迟调用。
  • 发出系统调用sched_setscheduler()时。

进程切换前schedule()所执行的操作

schedule()的任务是用另一个进程来替换当前正在执行的进程,关键是设置一个叫做next的变量,它指向被选中的进程,以取代current进程。
如果系统中没有优先级高于current进程的可运行进程,那么nextcurrent相等,不发生进程切换。

schedule()首先禁用内核抢占,初始化一些局部变量:

1
2
3
4
need_resched:
preempt_disable();
prev = current;
rq = this_rq();

current返回的指针赋给prev,并把与本地 CPU 对应的运行队列赋给rq。

下一步current()保证prev不占用大内核锁:

1
2
if(prev->lock_depth >= 0)
up(&kernel_sem);

调用sched_clock()获取TSC,将其值转换为纳秒,存放在now中。然后计算prev所用的CPU时间片长度。通常使用限制在1s内的时间。

1
2
3
4
now = sched_clock();
run_time = now - prev->timestamp;
if(run_time > 1000000000)
run_time = 1000000000; // 限制在 1s

优待有较长平均睡眠时间的进程:

1
run_time /= (CURRENT_BONUS(pre) ? : 1);

CURRENT_BONUS返回 0~10 之间的值,它与进程的平均睡眠时间成正比。

寻找可运行进程前,schedule()必须关掉本地中断,并获得所要保护的运行队列的自旋锁:

1
spin_lock_irq(&rq->lock);

prev可能是一个正在被终止的进程,schedule()通过检查PF_EDAD标志验证:

1
2
if(prev->flags & PF_DEAD)
prev->state = EXIT_DEAD;

接下来schedule()检查prev的状态。如果不是可运行状态,且没有在内核态被抢占,就从运行队列删除prev进程。但是,如果它有非阻塞挂起信号,且状态为TASK_INTERRUPTIBLE,将该进程的状态设置为TASK_RUNNING,并插入运行队列,给prev一次被选中执行的机会:

1
2
3
4
5
6
7
8
9
10
if(prev->state != TASK_RUNNING && !(preempt_count() & PREEMPT_ACTIVE))
{
if(prev->state == TASK_INTERRUPTIBLE && signal_pending(prev))
prev->state = TASK_RUNNING;
else {
if(prev->state == TASK_UNINTERRUPTIBLE)
rq->nr_uninterruptible++;
deactivate_task(prev, rq);
}
}

deactivate_task()从运行队列中删除该进程:

1
2
3
rq->nr_running--;
dequeue_task(p, p->array);
p->array = NULL;

现在,schedule()检查运行队列中剩余的可运行进程数。如果有可运行的进程,就调用dependent_sleeper(),绝大多数情况下,该函数立即返回 0。但是,如果内核支持超线程技术,如果被选中执行的进程优先级比已经在相同物理 CPU 的某个逻辑 CPU 上运行的兄弟进程低,则schedule()拒绝选择该进程,而执行swapper进程:

1
2
3
4
5
6
7
8
if(rq->nr_running)
{
if(dependent_sleeper(smp_processor_id(), rq))
{
next = rq->idle;
goto switch_tasks;
}
}

如果运行队列中没有可运行的进程,则调用idle_balance()从另外一个运行队列迁移一些可运行进程过来。

1
2
3
4
5
6
7
8
9
10
11
12
13

if(!rq->nr_running)
{
idle_balance(smp_processor_id(), rq);
if(!rq->nr_running)
{
next = rq->idle;
rq->expired_timestamp = 0;
wake_sleeping_dependent(smp_processor_id(), rq);
if(!rq->nr_running)
goto switch_tasks;
}
}

如果idle_balance()没有迁移成功,当内核支持超线程技术时,schedule()调用wake_sleeping_dependent()重新调度空闲CPU的可运行进程。然而,在单处理器系统中,迁移失败时,将swapper进程作为next进程。

假设运行队列中有可运行进程,现在检查这些可运行进程中是否至少有一个进程是活动的。如果没有,则交换运行队列结构的activeexpired字段。

1
2
3
4
5
6
7
8
9
array = rq->active;
if(!array->nr_active)
{
rq->active = rq->expired;
rq->expired = array;
array = rq->active;
rq->expired_timestamp = 0;
rq->best_expired_prio = 140;
}

现在可在活动prio_array_t中搜索一个可运行的进程了。首先schedule()搜索进程集合掩码的第一个非0位,其下标对应包含最佳运行进程的链表。随后,返回该链表的第一个进程描述符:

1
2
idx = sched_find_first_bit(array->bitmap);
next = list_entry(array->queue[idx].next, task_t, run_list);

sched_find_first_bit()基于bsfl汇编指令,返回 32 位数组中被设置为 1 的最低位的位下标。next存放将取代prev的进程描述符指针。

schedule()检查next->activated字段,表示进程被唤醒时的状态。

如果next是一个普通进程,正在从TASK_INTERRUPTIBLETASK_STOPPED状态被唤醒,调度程序就把自从进程插入运行队列开始所经过的纳秒数加到进程的平均睡眠时间中。

1
2
3
4
5
6
7
8
9
10
11
if(next->prio >= 100 && next->activated > 0)
{
unsigned long long delta = now - next->timestamp;
if(next->activated == 1)
delta = (delta * 38) / 128;
array = next->array;
dequeue_task(next, array);
recalc_task_prio(next, next->timestamp + delta);
enqueue_task(next, array);
}
next->activated = 0;

schedule()进行进程切换时所执行的操作

schedule()已经要让next进程运行,内核将立刻访问next进程的thread_info,其地址存放在next进程描述符接近顶部的位置。

1
2
switch_tasks:
prefetch(next);

prefetch()提示CPU把next进程的描述符第一部分字段装入硬件高速缓存。

在替代 prev 前,调度程序应该完成一些管理的工作,以防以延迟方式调用schedule()clear_tsk_need_resched()清除prevTIF_NEED_RESCHED标志。然后函数记录CPU正在经历静止状态。

1
2
clear_tsk_need_resched(prev);
rcu_qsctr_inc(prev->thread_info->cpu); // CPU 正在经历静止状态

schedule()必须减少prev的平均睡眠时间,并把它补充给进程所使用的CPU时间片,随后更新进程的时间戳:

1
2
3
4
prev->sleep_avg -= run_time;
if((long)prev->sleep_avg <= 0)
prev->sleep_avg = 0;
prev->timestamp = prev->last_ran = now;

prevnext可能是同一个进程,这时函数不作进程切换:

1
2
3
4
5
if(prev == next)
{
spin_unlock_irq(&rq->lock);
goto finish_schedule;
}

否则,进程切换:

1
2
3
4
next->timestamp = now;
rq->nr_switches++;
rq->curr = next;
prev = context_switch(rq, prev, next);

context_switch()建立next的地址空间。进程描述符的active_mm字段指向进程所使用的内存描述符,而mm字段指向进程所拥有的内存描述符。对于一般进程,这两个字段地址相同;而内核线程没有自己的地址空间,mm总是被置为 NULL。context_switch()确保,如果next是一个内核线程,使用prev所使用的地址空间:

1
2
3
4
5
6
if(!next->mm)  // 内核线程
{
next->active_mm = prev->active_mm;
atomic_inc(&prev->active_mm->mm_count);
enter_lazy_tlb(prev->active_mm, next);
}

如果内核线程都有自己的地址空间,当调度程序选择一个新进程运行时,需改变页表,因为内核线程仅使用线性地址空间的第 4 个 GB,该空间的映射对系统的所有进程都是相同的。甚至在最坏情况下,cr3寄存器会使所有TLB表项无效,导致极大的性能损失。现在的Linux中,如果next是内核线程,就不触及页表,进一步优化,schedule()将进程设置为懒惰TLB模式。而如果next是一个普通进程,context_switch()next的地址空间替换prev的地址空间:

1
2
if(next->mm)  // 普通进程
switch_mm(prev->active_mm, next->mm, next);

如果prev是内核线程或正在退出的进程,context_switch()prev内存描述符的指针保存到运行队列的prev_mm字段中:

1
2
3
4
5
if(!prev->mm)
{
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}

现在,context_switch()可调用switch_to()执行prevnext之间的进程切换了:

1
2
switch_to(prev, next, prev);
return prev;

总结:更新prev的时间片、时间戳,根据prev是内核线程还是普通线程,进行相应的内存描述符替换。

进程切换后 schedule() 所执行的动作

sechedule()函数中在switch_to宏后紧接着的指令不是让next进程立即执行,而是如果稍后调度程序又选择prev时由prev执行。到那时,prev不指向schedule()开始时所替换出的进程,而是指向被调度时被prev替换出的进程。

进程切换后的第一部分指令:

1
2
barrier();  // 代码优化屏障
finish_task_switch(prev);

schedule()中,紧接着context_switch()函数调用之后,宏barrier()产生一个代码优化屏障。然后执行finish_task_switch()
1
2
3
4
5
6
7
8
mm = this_rq()->prev_mm;
this_rq()->prev_mm = NULL;
prev_task_flags = prev->flags;
spin_unlock_irq(&this_rq()->lock);
if(mm)
mmdrop(mm);
if(prev_task_flags & PF_DEAD)
put_task_struct(prev);

如果prev是一个内核线程,运行队列的prev_mm存放prev的内存描述符地址。finish_task_switch()还要释放运行队列的自旋锁并打开本地中断。然后检查prev是否是一个正在从系统中被删除的僵死任务,如果是,则调用put_task_struct()释放进程描述符的引用计数,并撤销所有其余对该进程的引用。

schedule()的最后一部分指令:

1
2
3
4
5
6
7
8
9
finish_schedule:

prev = current;
if(prev->lock_depth >= 0)
__reacquire_kernel_lock();
preempt_enable_no_resched();
if(test_bit(TIF_NEED_RESCHED, &current_thread_info()->flags)
goto need_resched;
return;

schedule()在需要的时候重新获得大内核锁,重新启用内核抢占,并检查是否一些其他的进程设置了当前进程的TIF_NEED_RESECED,如果是,则schedule()重新开始执行,否则结束。

多处理器系统中运行队列的平衡

关注三种不同类型的多处理机器:

  • 标准的多处理器体系结构:所共有的RAM被所有CPU共享
  • 超线程:超线程芯片是一个立刻执行几个执行线程的微处理器,它包括几个内部寄存器的拷贝,并快速在它们之间切换。当前线程在访问内存的间隙,处理器可以执行另一个线程
  • NUMA:把CPU和RAM以本地节点分组。CPU访问与它在同一个节点的本地RAM时几乎没有竞争,但是访问远程RAM时,芯片就非常慢。

一个保持可运行状态的进程通常被限制在一个固定的CPU上,这样可以填满CPU的硬件高速缓存,但是这可能引起严重的性能损失。内核应周期性地检查运行队列的工作量是否平衡,需要时,把一些进程从一个运行队列迁移到另一个运行队列。为适应各种已有的多处理器体系结构,Linux 提出一种基于调度域概念的复杂的运行队列平衡算法。

调度域

调度域实际上是一个CPU集合它们的工作量由内核保持平衡。调度域采取分层组织的形式:最上层的调度域包括多个子调度域,每个子调度域包括一个CPU子集。每个调度域被划分为一个或多个组,每个组代表调度域的一个CPU子集,工作量的平衡在调度域的组之间完成。只有在一个组的工作量远远大于另一个组时才把进程从一个CPU调度到另一个CPU。

每个调度域由sched_domain描述符表示,调度域中的每个组由sched_group描述符表示。sched_domaingroups字段指向组描述符链表中的第一个元素sched_domainparent字段指向父调度域的描述符。

所有物理CPU的sched_domain描述符存放在每CPU变量phys_domains中。

  • 如果内核不支持超线程技术,这些域在域层次结构的最底层,运行队列描述符的sd字段指向它们。
  • 如果内核支持超线程技术,底层调度域存放在每CPU变量cpu_domains中。

rebalance_tick()

为了保持系统中运行队列的平衡,每经过一次时钟节拍,scheduler_tick()调用rebalance_tick()。参数有:

  • 本地CPU的下标this_cpu
  • 本地运行队列的地址this_rq
  • 标志idle

    • SCHED_IDLE,CPU当前空闲,即currentswapper进程
    • NOT_IDLECPU当前不空闲,即current不是swapper进程
  • 首先,访问运行队列描述符的nr_runningcpu_load字段,确定运行队列中的进程数,更新运行队列的平均工作量。

  • 然后,从基本域到最上层的调度域循环,每次循环确定是否已到调用load_balance()的时间,从而在调度域上指向重新平衡的操作。
    • sched_domainidle值决定调用load_balance()的频率。
    • 如果idle等于SCHED_IDLE,那么运行队列为空,load_balance()被调用频率高。
    • 反之,如果idle等于NOT_IDLEload_balance()被调用频率低。

load_balance()

检查调度域是否处于严重的不平衡状态,如果是,从最繁忙的的组中迁移一些进程到本地CPU的运行队列。参数有:

  • this_cpu,本地 CPU 的下标
  • this_rq,本地运行队列的描述符的地址
  • sd,指向被检查的调度域的描述符
  • idle,取值为SCHED_IDLENOT_IDLE

函数执行下面的操作:

  1. 获取this_rq->lock自旋锁。
  2. find_busiest_group()分析调度域中各组的工作量。返回最繁忙的sched_group描述符的地址,假设该组不包括本地CPU,在这种情况下,还返回为恢复平衡而被迁移到本地运行队列中的进程数。如果最繁忙的组包括本地CPU,或所有的组本来就是平衡的,返回NULL。需过滤统计工作量中的波动。
  3. 如果find_busiest_group()在调度域中没有没有找到既不包括本地CPU又非常繁忙的组,就释放this_rq->lock自旋锁,调整调度域描述符的参数,以延迟本地CPU下一次对load_balance()的调度,然后函数终止。
  4. find_busiest_queue()查找第2步中找到的组中最繁忙的CPU,返回相应运行队列的描述符地址busiest
  5. 获取另一个自旋锁busiest->lock。为避免死锁,首先释放this_rq->lock,然后通过增加CPU下标获得这两个锁。
  6. move_tasks()从最繁忙的运行队列把一些进程迁移到本地运行队列this_rq中。
  7. 如果move_tasks()没有迁移成功,则调度域不平衡,busiest->active_balance = 1,并唤醒migration线程,其描述符存放在busiest->migration_thread中。migration内核下次顺着调度域的链搜索从最繁忙运行队列的基本域到最上层域搜索空闲CPU,如果找到一个空闲CPU,就调用move_tasks()把一个进程迁移到空闲运行队列。
  8. 释放busiest->lockthis_rq->lock自旋锁。
  9. 函数结束。

move_tasks()

把进程从源运行队列迁移到本地运行队列。接收参数有:

  • this_rq,本地运行队列描述符
  • this_cpu,本地 CPU 下标
  • busiest,源运行队列描述符
  • max_nr_move,被迁移进程的最大数
  • sd,在其中执行平衡操作的调度域的描述符地址
  • idle标志,可被设置为SCHED_IDLENOT_IDLENEWLY_IDLE

函数首先分析busiest运行队列的过期进程,从优先级高的进程开始。扫描完所有的过期进程后,扫描busiest运行队列的活动进程,对所有后续进程调用can_migrate_task(),如果下列条件都满足,can_migrate_task()返回1:

  • 进程当前没有在远程CPU上执行
  • 本地CPU包含在进程描述符的cpus_allowed位掩码中
  • 至少满足下列条件之一:
    • 本地 CPU 空闲。如果内核支持超线程技术,所有本地物理芯片中的逻辑 CPU 必须空闲。
    • 内核在平衡调度域是因反复迁移进程失败而现如困境。
    • 被迁移的进程不是“高速缓存命中”。

如果can_migrate_task()返回1,调用pull_task()将后续进程迁移到本地运行队列。pull_task()先执行dequeue_task()从远程运行队列删除进程,然后执行enqueue_task()把进程插入本地运行队列,如果刚被迁移的进程比当前进程拥有更高的动态优先级,调用resched_task()抢占本地CPU的当前进程。

与调度相关的系统调用

nice()

nice()允许进程改变自己的基本优先级。包含在increment参数中的整数值用来修改进程描述符的nice字段nice()已被setpriority()取代。

getpriority() 和 setpriority()

nice()只影响调用它的进程,而getpriority()setpriority()作用于给定组中所有进程的基本优先级。getpriority()返回20减去给定组中所有进程中最低nice字段的值,即最高优先级。setpriority()把给定组中所有进程的基本优先级设置为一个给定的值。

内核对这两个系统调用的实现基于sys_getpriority()sys_setpriority()服务例程,参数有:

  • which:指定进程组的值,采用以下值:
    • PRIO_PROCESS:根据进程ID选择进程(pid
    • PRIO_PGRP:根据组ID先择进程(pgrp
    • PRIO_USER:根据用户ID选择进程(uid
  • who:用pidpgrpuid的值选择进程
  • niceval:新的基本优先级值

sched_getaffinity()和sched_setaffinity()

分别返回和设置CPU进程亲和力掩码,即允许执行进程的CPU的位掩码。该掩码存放在进程描述符的cpus_allowed字段中。

与实时进程相关的系统调用

sched_getscheduler()和sched_setscheduler()

sched_getscheduler()查询参数pid表示的进程当前使用的调度策略。如果pid等于0,检索调用进程的策略。如果成功,为进程返回策略:SCHED_FIFOSCHED_RRSCHED_NORMAL

sched_setscheduler()既设置调度策略,也设置由参数pid表示的进程的相关参数。如果pid等于0,调用进程的调度程序参数将被设置。

sched_getparam()和sched_setparam()

sched_getparam()检索参数pid表示的进程的调度参数。如果pid是0,current`进程的参数被检索。

sched_setparam()类似于sched_setscheduler(),不同之处在于不让调用者设置policy字段。

sched_yield()

允许进程在不被挂起的情况下自愿放弃CPU,进程仍然处于TASK_RUNNING状态,但调度程序把它放在运行队列的过期进程集合中,或运行队列链表的末尾。

sched_get_priority_min()和sched_get_priority_max()

sched_get_priority_min()sched_get_priority_max()分别返回最小和最大实时静态优先级的值,该值由policy参数标识的调度策略使用。

sched_rr_get_interval()

把参数pid标识的实时进程的轮转时间片写入用户地址空间的一个结构中。如果pid等于 0,系统调用就写当前进程的时间片。

绪论

Linux与其他类Unix内核的比较

在Linux系统下,很容易编译和运行目前现有的大多数Unix程序。Linux包括了现代Unix操作系统的全部特点,诸如虚拟存储、虚拟文件系统、轻量级进程、Unix信号量、SVR4进程间通信、支持对称多处理器系统等。

  • 单块结构的内核:它是一个庞大、复杂的自我完善程序,由几个逻辑上独立的成分构成。大多数商用Unix变体也是单块结构。
  • 编译并静态连接的传统Unix内核:大部分现代操作系统内核可以动态地装载和卸载部分内核代码,通常把这部分代码称作模块module。Linux对模块的支持是很好的。
  • 内核线程:Linux以一种十分有线的方式使用内核线程来周期性地执行几个内核函数,但是它们并不代表基本的执行上下文抽象。
  • 多线程应用程序支持:一个多线程用户程序由很多轻量级进程LWP组成,这些进程可能对共同的地址空间、共同的物理内存页、共同的打开文件等等进行操作。Linux把轻量级进程当做基本的执行上下文,通过非标准的clone()系统调用来处理它们。
  • 抢占式preemptive内核:Linux可以随意交错执行处于特权模式的执行流。
  • 多处理器支持:Linux支持不同存储模式的对称多处理,包括NUMA:系统不仅可以使用多处理器,而且每个处理器可以毫无区别地执行任何一个任务。
  • 文件系统:Linux标准文件系统呈现出多种风格。

操作系统基本概念

操作系统必须完成两个主要目标:

  • 与硬件部分交互,为包含在硬件平台上的所有低层可编程部件提供服务。
  • 为运行在计算机系统上的应用程序(即所谓用户程序)提供执行环境。

现代操作系统依靠特殊的硬件特性来禁止用户程序直接与低层硬件部分进行交互,或者禁止直接访问任意的物理地址。硬件为CPU引入了至少两种不同的执行模式:用户程序的非特权模式内核的特权模式。Unix把它们分别称为用户态User Mode和内核态Kernel Mode。

下面是一些基本概念:

  • 多用户系统:一台能并发和独立地执行分别属于两个或多个用户的若干应用程序的计算机。
  • 用户和组:每个用户用一个数字来表示,及用户标识符User ID,UID。每个用户是一个或多个用户组的一名成员,组由唯一的用户组标识符user group ID标识。root用户几乎无所不能。
  • 进程:所有的操作系统都使用一种基本的抽象:进程process。一个进程可以定义为:程序执行时的一个实例,或者一个运行程序的执行上下文。Linux是具有抢占式进程的多处理操作系统
  • 内核体系结构:大部分Unix内核是单块结构:每一个内核层都被集成到整个内核程序中,并代表当前进程在内核态下运行。相反,微内核microkernel操作系统只需要内核有一个很小的函数集。运行在微内核之上的几个系统进程实现从前操作系统级实现的功能,如内存分配程序、设备驱动程序、系统调用处理程序等等。宏内核的优势是效率高,因为微内核不同层次之间的消息传递等需要花费一定的代价。Linux内核提供了模块,其代码可以在运行时链接到内核或从内核解除链接。

Unix文件系统概述

Unix文件是以字节序列组成的信息载体,内核不解释文件的内容。从用户的观点来看,文件被组织在一个数结构的命名空间中。树的根对应的目录被称为根目录。Unix的每个进程都由一个当前工作目录,它属于进程执行上下文,标识出进程所用的当前目录。

  • 绝对路径: 路径名的第一个字符是‘/’
  • 相对路径: 路径名的第一个字符不是‘/’

硬连接指通过索引节点来进行连接。硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。硬链接有两方面的限制:

  • 不允许用户给目录创建硬链接。避免出现环形目录结构体
  • 只有在统一文件系统中的文件之间才能创建硬链接。

软链接也称符号连接symbolic link。软链接文件有类似于Windows的快捷方式。它实际上是一个特殊的文件。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息,可以是位于任意一个文件系统的任意文件或目录。

文件可以是下列类型之一:

  • 普通文件 regular file
  • 目录
  • 符号链接
  • 面向块的设备文件 block-oriented device file
  • 面向字符的设备文件 character-oriented device file
  • 管道pipe和命名管道named pipe,也教FIFO
  • 套接字 socket

文件系统处理文件需要的所有信息包含在一个名为索引节点inode的数据结构体中。每个文件都有自己的索引节点,文件系统用索引节点来标识文件。索引节点至少提供如下信息:

  • 文件类型
  • 与文件相关的硬链接个数
  • 以字节为单位的文件长度
  • 设备标识符,即包含文件的设备的标识符
  • 在文件系统中标识文件的索引节点号
  • 文件拥有者的UID
  • 文件的用户组ID
  • 几个时间戳,表示索引节点状态改变的时间、最后访问时间及最后修改时间
  • 访问权限和文件模式

访问权限和文件模式
文件的潜在用户分为三种类型:

  • 作为文件所有者的用户
  • 同组用户,不包括所有者
  • 所有剩下的用户

文件的访问权限也有三种:

  • 执行

因此,文件访问权限的组合就用9种不同的二进制来标记。
还有三种附加的标记,即suid Set User ID、sgid Set Group ID及sticky用来定义文件的模式。

  • suid。 进程执行一个文件时通常保持进程拥有者的UID。然而,如果设置了可执行文件suid的标志位,进程就获得了该文件拥有者的UID。
  • sgid。 进程执行一个文件时通常保持进程组的用户组ID。然而,如果设置了可执行文件sgid的标志位,进程就获得了该文件用户组的ID。
  • sticky。设置了sticky标志位的可执行文件相当于向内核发出一个请求,当程序执行结束以后,依然将它保留在内存。该标志已经过时。

当文件由一个进程创建时,文件拥有者的ID就是该进程的UID。而其用户组ID可以是进程创建者的ID,也可以是父目录的ID,这取决于父目录sgid标志位的值。

文件操作的系统调用

  • 打开文件。进程只能访问打开的文件。
  • 访问打开的文件。可以顺序/随机地访问。对设备文件和命名管道文件,通常只能顺序访问。
  • 关闭文件。释放与文件描述符fd相对应的打开文件对象。当一个进程终止时,内核会关闭其所有仍然打开着的文件。
  • 更名及删除文件。不需要打开就可以更名和删除文件。实际上,该操作并没有对这个文件的内容起作用,而是对一个或多个目录的内容起作用。

Unix内核概述

内核本身并不是一个进程,而是进程的管理者。进程/内核模式假定:请求内核服务的进程使用所谓的系统调用system call的特殊编程机制。每个系统调用都设置了一组识别进程请求的参数,然后执行与硬件相关的CPU指令完成从用户态到内核态的转换。

Unix系统还包括所谓内核线程kernel thread的特权进程,具有如下特点:

  • 以内核态运行在内核地址空间。
  • 不与用户直接交互,因此不需要终端设备。
  • 通常在系统启动时创建,然后一直处于活跃状态直到系统关闭。

有几种方式激活内核例程:

  • 进程调用系统调用
  • 正在执行进程的CPU发出一个异常信号
  • 外围设备向CPU发出一个中断信号以通知一个事件的发生
  • 内核线程被执行

为了让内核管理进程,每个进程由一个进程描述符process descriptor表示,这个描述符包含有关进程当前状态的信息。

当内核暂停一个进程的执行时,就把几个相关处理器寄存器的内容保存在进程描述符中。这些寄存器包括:

  • 程序计数器PC和栈指针SP寄存器
  • 通用寄存器
  • 浮点寄存器
  • 包含CPU状态信息的处理器控制寄存器,处理器状态字 processor status word
  • 用来跟踪进程对RAM访问的内存管理寄存器

所有的Linux内核都是可重入的,这意味着若干个进程可以同时在内核态执行,可以包含非可重入函数,利用锁机制保证一次只有一个进程执行非重入函数。

每个进程运作在自身私有地址空间。用户态下运行的进程涉及到私有栈、数据区和代码区。在内核态运行时,进程访问内核的数据区和代码区,但使用另外的私有栈。Linux支持mmap系统调用,该系统调用允许存放在块设备上的文件或信息的一部分映射到进程的部分地址空间。

一般来说,对于全局变量的安全访问通过原子操作来保证。临界区是这样的一段代码,进入这段代码的进程必须完成,之后另一个进程才能进入

  • 非抢占式内核:当进程在内核态执行时,不能被任意挂起,也不能被另一个进程代替。
  • 禁止中断。在进入一个临界区之前禁止所有的硬件中断,离开时再重新启用中断。
  • 信号量。可以把信号量看成一个对象,其组成如下: 一个整数变量;一个等待进程的链表;两个原子方法down和up。
    • 当内核希望访问这个数据结构时,在相应的信号量上执行down方法。如果信号量的当前值不是负数,则允许访问这个数据结构。
    • 否则,把执行内核控制路径的进程加入到这个信号量的链表并阻塞该进程。
    • 当另一个进程在那个信号量上执行up方法时,允许信号量链表上的一个进程继续执行。
  • 自旋锁。当一个进程发现锁被另一进程锁着时,不停地旋转,直到锁打开。在单处理器下自旋锁是无效的。
  • 避免死锁。Linux通过按规定的顺序请求信号量来避免死锁deadlock。

Unix信号signal提供了把系统事件报告给进程的一种机制。每种事件都由自己的信号编号,通常用一个符号常量来表示,例如SIGTERM。有两种系统事件:

  • 异步通告。
  • 同步错误或异常。

用户态下进程间通信机制很多,通常有:信号量、消息队列及共享内存。共享内存为进程之间交换和共享数据提供了最快的方式。

进程管理:fork系统调用用来创建一个新进程;exit系统调用用来终止一个进程;exec系统调用用来装入一个新程序。

僵死进程:wait4系统调用允许进程等待,直到其中的一个子进程结束,它返回已终止子进程的进程标识符。僵死进程表示进程已经终止,父进程还没有执行完wait4。

进程组和登陆会话:现代Unix操作系统引入了进程组process group的概念,以表示一种作业job的抽象。现代Unix内核也引入了登陆会话login session。

虚拟内存:Virtual memory作为一个逻辑层,处于应用程序的内核请求与硬件内存管理单元MMU memory management unit之间。现代CPU包含了能自动把虚拟地址转换成物理地址的硬件电路。它有很多用途和优点:

  • 若干个进程可以并发地执行
  • 应用程序所需内存大于可用物理内存时也可以运行
  • 程序只有部分代码装入内存时进程可以执行它
  • 运行每个进程访问可用物理内存的子集
  • 进程可以共享库函数或程序的一个单独内存映像
  • 程序是可重定位的,也就是说,可以把程序放在物理内存的任何地方
  • 程序员可编写与机器无关的代码
  • 进程虚拟地址空间处理

进程的虚拟地址空间包括了进程可以引用的所有虚拟内存地址。通常包括如下几个内存区:

  • 程序的可执行代码
  • 程序的初始化数据
  • 程序的未初始化数据
  • 初始程序栈
  • 所需共享库的可执行代码和数据

所有现代Linux都采用了请求调页的分配策略,进程可以在它的页还没有在内存的时候就开始执行,当进程访问一个不存在的页时,MMU产生一个异常,异常处理程序分配一个空闲的页。

高速缓存:物理内存的一大优势就是用作磁盘和其他块设备的高速缓存。sync()把所有“脏”的缓冲区写入磁盘来强制同步。

设备驱动程序:内核通过设备驱动程序device driver与I/O设备交互。

内存寻址

内存地址

当使用80x86微处理器时,我们必须区分以下三种不同的地址:

  • 逻辑地址:包含在机器语言指令中用来指定一个操作数或一条指令的地址。每一个逻辑地址都由一个和一个偏移量组成。
  • 线性地址/虚拟地址:是一个32位无符号整数,可用来表达4GB地址。
  • 物理地址:用于内存芯片级内存单元寻址。

内存控制单元(MMU)通过一种称为分段单元的硬件电路把逻辑地址转换成线性地址,第二个称为分页单元的硬件电路把线性地址转换为物理地址。

硬件中的分段

段选择符和段寄存器

一个逻辑地址由两部分组成:段标识符(16位长)和指定段内相对地址的偏移量(32位长)。段寄存器的唯一目的是存放段选择符,这些段寄存器称为cs,ss,ds,es,fs和gs:

  • cs:代码段寄存器,指向包含程序指令的段,有一个两位的字段用于指明当前CPU特权级。
  • ss:栈段寄存器:指向包含当前程序栈的段
  • ds:数据段寄存器,指向包含静态数据或全局数据段

段描述符

每个段由一个8字节的段描述符表示,它描述了段的特征。段描述符放在全局描述符表GDT或者局部描述符表LDT,GDT在主存中的地址和大小存放在gdtr控制寄存器中,而LDT的地址和大小放在ldtr中。


有几种不同的段:

  • 代码段描述符表示这个段代表一个代码段
  • 数据段描述符,表示这个段代表一个数据段
  • 任务状态段描述符TSSD,表示这个段代表一个任务状态段TSS,只能出现在GDT中。

快速访问段描述符

一种附加的非编程寄存器含有8个字节的段描述符,每当一个段选择符被装入段寄存器,相应的段描述符就由内存装入对应的非编程寄存器。这样处理器只需要引用存放段描述符的CPU寄存器即可。

段选择符包含:

  • index:指定放在GDT或LDT中的相应描述符的入口
  • TI:指明段描述符是在GDT还是LDT
  • RPL:请求者特权

段描述符在GDT或LDT中的相对地址是由段选择符的最高13位乘以8得到的。

分段单元

逻辑地址转换为线性地址:

  • 检查段选择符的TI字段,以决定段描述符保存在哪一个描述符表中,分段单元从gdtr或者ldtr中得到线性地址。
  • 从段选择符的index中得到段描述符的地址,index字段的值乘以8,结果与gdtr或ldtr寄存器中的内容相加。
  • 把逻辑地址的偏移量与段描述符Base字段的值相加得到线性地址。

Linux中的分段

运行在用户态的所有Linux进程都使用一对相同的段来对指令和数据寻址。这两个段就是用户代码段和用户数据段,对内核亦然。

相应的段选择符由宏__USER_CS__USER_DS__KERNEL_CS__KERNEL_DS定义。所有段都是从0x00000000开始,在Linux下逻辑地址与线性地址是一致的,即逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。

Linux GDT

所有的GDT都放在cpu_gdt_table中,而所有的GDT的地址和它们的大小被放在cpu_gdt_descr数组中。每个GDT包含18个段描述符和14个空的,使经常一起访问的段描述符能够处于同一个32字节的硬件高速缓存行中,其中:

  • 用户和内核各有一个代码段和数据段
  • 一个TSS任务段来保存寄存器的状态
  • 包含缺省局部描述符表的段
  • 3个局部线程存储
  • 与高级电源管理相关的三个段
  • 与支持即插即用的BIOS相关的5个段
  • 被内核用来处理双重错误异常的TSS段

Linux LDT

Linux系统中,大多数用户态的程序都不使用LDT。内核定义了一个缺省的LDT共大多数进程使用。

硬件中的分页

分页单元把线性地址转换成物理地址,其中一个关键任务是把所请求的访问类型与线性地址的访问权限比较,如果访问无效则产生缺页异常。线性地址被分为固定长度为单位的组,称为。分页单元把所有的RAM分成固定长度的页框,每一个页框包含一个页。页只是一个数据库,可以放在任何页框或者磁盘中。线性地址映射到物理地址的数据结构称为页表,通过设置cr0寄存器的PG标志启动分页,PG=0时,线性地址被解释成物理地址。

常规分页

32位线性地址被分为3个域:目录10位页表10位偏移量12位。转换分为两步,每一步都基于一种转换表,第一种为页目录表,第二为页表,这样可以减少每个进程页表所需的RAM数。每个活动进程必须有一个分配给它的页目录,在进程实际需要一个页表的时候才给进程分配RAM会更有效率。

页目录物理地址放在控制寄存器cr3中,每一页有4096字节的数据。线性地址内地Directory字段决定页目录中的目录项,目录项指向适当的页表,table字段决定页表中的表项,表项中含有页所在页框的物理地址,offset字段决定页框内的相对位置。

页目录项和页表项有相同的结构:

  • Present标志:
    • 置1,所指页或页表在主存中;为0,不在主存中。如果当访问一个地址时,页目录项或页表项的Present标志为0,则分页单元将该线性地址存放在寄存器cr2中,产生14号异常:缺页异常。
  • 包含页框物理地址最高20位的字段
  • accessed
    • 每当分页单元对相应页框进行寻址时,设置这个标志。分页单元从来不重置这个标志,而是必须由操作系统去做。
  • Dirty
    • 只应用于页表项中,每当对一个页框写操作时就设置。分页单元从来不重置这个标志,而是必须由操作系统去做。
  • Read/Write
    • 页或页表的存取权限。与段的3种存取权限(读、写、执行)不同的是,页的存取权限只有两种(读、写)。
  • User/Supervisor
    • 访问页或页表所需的特权级。若此标志为0,只有当CPL小于3(Linux:CPU处于内核态)时才能对页寻址,否则总能对页寻址。
  • PCD/PWT
    • 控制硬件高速缓存处理页或页表的方式。
  • Page Size
    • 只应用于页目录项。设置为1,则页目录项指向2M或4M的内存。
  • Global
    • 只应用于页表项,用于防止常用页(全局页)从TLB中刷新出去。(当cr4寄存器的PGE(页全局启用)标志置位时,这个标志才有效)。

扩展分页

设置页框大小为4MB而不是4KB,允许把大段连续的线性地址转换成相应的物理地址,不需要中间页表进行转换,32位线性地址分为两个字段:10位directory和22位offset。通过设置cr4处理器寄存器的PSE标志能使扩展分页与常规分页共存

硬件保护方案

与页和页表相关的特权级只有两个,因为特权由前面常规分页一节中所提到的User/Ssupervisor标志所控制。若这个标志为0,只有当CPL小于3时才能对页寻址;若该标志为1,则总能对页寻址。页的存取只有两种(读/写),Read/Write为0只读,否则可读写。

物理地址扩展分页机制

从PentiumPro开始。Intel所有处理器现在寻址能力达64GB。不过,只有引入一种的分页机制把32位线性地址转换到36位物理地址才能使用所增加的物理地址。Intel引入一种叫做物理地址扩展PAE的机制。通过设置cr4控制寄存器中的物理地址扩展标志激活PAE。页目录中的页大小标志PS启用大尺寸页。

Intel为了支持PAE已经改变了分页机制:

  • 64GB的RAM被分为了2的24次方个页框,页表项的物理地址字段从20位扩展到24位。因为PAE页表项必须包含12个标志位和24个物理地址。总数之和为36。页表项大小从32位变成64增加了一倍。结果,一个4KB的页表包含512个表页不是1024个表项。
  • 引入一个叫做页目录指针表PDPT的页表新级别,它由4个64位表项组成。
  • cr3控制寄存器包含一个27位的页目录指针表基地址字段。因为PDPT存放在RAM的前4GB中,并在32字节的倍数上对齐,因此27位足以表示这种表的基地址。
  • 当把线性地址映射到4KB的页时,32位线性地址按下列方式解释:
    • cr3:指向一个PDPT
    • 位31-30:指向PDPT中4个项中的一个
    • 位29-21:指向嶡目录中512个项中的一个
    • 位20-12:指向页表中512项中一个
    • 位11-0:4KB页中的偏移量
  • 当把线性地址映射到2MB的页时,32位线性地址按下列方式解释:
    • cr3:指向一个PDPT
    • 位31-30:指向PDPT中4个项中的一个
    • 位29-21:指向页目录中512个项中的一个
    • 位20-0:4KB页中的偏移量

硬件高速缓存

为了缩小CPU和RAM之间的速度不匹配,基于著名的局部性原理引入了硬件高速缓存内存。高速缓存再被细分为行的子集。

  • 在一种极端的情况下,高速缓存可以是直接映射的,这时主存中的一个行总是存放在高速缓存中完全相同的位置。
  • 在另一种极端情况下,高速缓存是充分关联的,这意味着主存中的任意一个行可以存放在高速缓存中的任意位置。
  • 大多数高速缓存在某种程序上是N路相关联的,意味着主存中的任意一个行可以存放在高速缓存N行中的任意一行中。

高速缓存单元插在分页单元和主内存之间,包含一个硬件高速缓存内存用来存放内存中真正的行和一个高速缓存控制器用来存放一个表项数组,对应内存中的行。每个表项有一个标签tag和几个标志flag。

当命中一个高速缓存时:

  • 对于读操作,控制器从高速缓存行中选择数据并送到CPU寄存器,不需要访问RAM因而节约了CPU时间。
  • 对于写操作,控制器可能采用以下两个基本筻略之一,分别称之为通写回写
    • 在通写中,控制器总是既写RAM也写高速缓存行,为了提高写操作的效率关闭高速缓存。
    • 回写方式只更新高速缓存行,不改变RAM的内容,提供了更快的功效,当然,回写结束以后,RAM最终须被更新。
    • 只有当CPU执行一条要求刷新高速缓存表项的指令时,或者当一个FLUSH硬件信号产生时,高速缓存控制器才把高速缓存行写回到RAM中。

当高速缓存没有命中时,高速缓存行被写回到内存中,如果有必要的话,把正确的行从RAM中取出放到高速缓存的表项中。

每个CPU都有自己的本地硬件高速缓存,只要一个CPU修改了它的硬件高速缓存,它就必须检查同样的数据是否包含在其他的硬件高速缓存中,这种叫做高速缓存侦听。处理器的cr0寄存器的CD位用来启用或禁用高速缓存电路。

转换后援缓冲器TLB

80x86处理器包含了一个称为转换后援缓冲器的高速缓存用于加快线性地址的转换。当一个线性地址第一次使用时,通过慢速访问RAM中的页表计算出相应的物理地址。同时,物理地址被存放在一个TLB表项中,以便以后对同一个线性地址的引用可以快速转换。当CPU中的cr3寄存器被修改时,硬件自动使本地的TLB所有项都无效。

Linux中的分页

两级页表对32位系统来说已经足够了,但64位系统需要更多数量的分页级别。Linux采用了一种同时适用于32位和64位系统的普通分页模型。4种页表分别被为:

  • 页全局目录
  • 页上级目录
  • 页中间目录
  • 页表

对于没有启用物理地址扩展的32位系统。两级页表已经足够了。Linux通过使“页上级目录”位和“页中间目录”位全为0,从根本上取消了页上级目录和页中间目录字段。内核为页上级目录和同中间目录保留一个位置,这是通过把它们的页目录数设置为1,并把这两个目录项映射到页全局目录的一个适当的目录项而实现的。

启用了物理地址扩展的32位系统使用了三级页表。Linux的页全局目录对应80x86的页目录指针,取消了页上级目录,页中间目录对应80x86的页目录,Linux的页对应80x86的页表。最后,64位系统使用三级还是四级分页取决于硬件对线性地址的位的划分。

Linux的进程处理很大程序上依赖于分页。事实上,线性地址到物理地址的自动转换使下面的设计目录就得可行:

  • 给每一个进程分配一块不同的物理地址空间,这确保了可以有效地防止寻址错误。
  • 区别页和页框之不同。这就允许放在某个页框中的一个页,然后保存到磁盘上,以后重新装入这同一页时又可以被装在不同的页框中。这就是虚拟内存机制的基本要素。

线性地址字段

下列宏简化了页表处理:

  • PAGE_SHIFT:指定Offset字段的位数;当用于80x86处理时,它产生的值为12。由于页内所有地址都必须能放到Offset字段中。因此80x86系统的页的大小是4096个字节。PAGE_SHIFT的值为12,可以看作以2为底的页大小的对数。这个宏由PAGE_SIZE使用以返回页的大小。最后,PAGE_MASK宏产生的值为0xfffff000,用以屏蔽offset字段的所有位。
  • PMD_SHIFT:指定线性地址的Offset字段和Table字段的总位数。PMD_SIZE宏用于计算页中间目录的一个单独表项所映射的区域大小,也就是一个页表的大小。PMD_MASK宏用于屏蔽OFFSET字段与TABLE字段的所有位。
    • 大型页不使用最后一级页表,所以产生大型尺寸的LARGE_PAGE_SIZE宏等于PMD_SIZE,而在大型页地址中用于屏蔽Offset字段和Table字段的所有位的LARGE_PAGE_MASK宏,就等于PMD_MASK
  • PUD_SHIFT:指定页上级目录项能映射的区域大小的对数。PUD_SIZE宏用于计算页全局目录中的一个单独项所能映射的区域大小。PUD_MASK宏用于屏蔽Offset字段、Table字段。MiddleAir字段等。
  • PGDIR_SHIFT:确定页全局目录能映射的区域大小的对象。PGDIR_SIZE宏用于计算页全局目录中一个单独表项能映射区域的大小。PGDIR_MASK宏用于屏蔽Offset,Table,等一些字段。
  • PTRS_PER_PTEPTRS_PER_PMDPTRS_PER_PUDPTRS_PER_PGD:用于计算页表,页中间目录,页上级目录和页全局目录表中表项的个数。当PAE被禁止时,它们产生的值为别为1024,1,1和1024。当PAE被激活时,产生的值分别为512,512,1和4。

页表处理

pte_tpmd_tpud_tpgd_t分别描述页表项页中间目录项页上级目录页全局目录项的格式。当PAE被激活时它们都是64位的数据类型。否则都是32位数据类型,它表示与一个单独表项相关的保护标志。五个类型转换宏(__pte,__pmd,__pud,__pgd__pgprot)把一个无符号整数转换成所需要类型。另外的五个类型转换宏(pte_val,pmd_val,pud_val,pgd_valpgprot_val)执行相反的转换,即把上面提到的四种特殊的类型转换成一个无符号整数。

内核还提供了许多宏和函数用于读或修改页表表项:

  • 如果相应的表项值为0,那么,宏pte_none,pmd_none,pud_nonepgd_none产生的值为1,否则产生的值为0。
  • pte_clear,pmd_clear,pud_clearpgd_clear清除相应页表的一个表项,由此禁止进程使用由该页表项映射的线性地址。
  • ptep_get_and_clear()函数清除一个页表项并返回前一个值。
  • set_pte,set_pmd,set_pudset_pgd向一个页表项中写入指定的值。set_pte_atomicset_pte的作用相同,但是当PAE被激活时它同样能保证64位的值被原子地写入。
  • 如果A和B两个页表项指向同一个页并且指定相同的访问优先级,那么pte_same(A,B)返回1,否则返回0。
  • 如果页中间目录项e指向一个大型页,那么pmd_large返回1,否则返回0。
  • pmd_bad由函数使用并通过输入参数传递来检查页中间目录项。如果目录项指向一个不能使用的页表,则这宏产生的值为1;
    • 页不在主存中
    • 页只允许读访问
    • Acessed或者Dirty被清除。

如果一个页表项的Present标志或者pagesize标志等于1,则pte_present宏产生的值为1,否则为0。对于当前在主存中却又没有读、写或执行权限的页,内核将其Present和PageSize分别标记为0和1。这样,任何试图对此类页的访问都会引起一个缺页异常。因为页的present标志清0,而内核可能通过检查Pagesize的值来检测到产生异常并不是因为缺页。

如果相应表项的present标志等于1,也就是说,如果对应的页或页表被载入主存,pmd_present宏产生的值为1。pud_presetn宏和pgd_present宏产生的值总为1。

表2-5中列出的函数用来查询页表项中任意一个标志的当前值;除了pte_file()外,其它函数只有在pte_present返回1的时候。才能正常返回页表项中任意一个标志。

表2-6列出的另一组函数用于设置页表项中和标志的值。

表2-7对页表项进行操作,它们把一个页地址和一组保护标志组合成页表项,或者执行相反的操作,从一个页表项中提出页地址。

当使用两级页表时,创建或删除一个页中间目录项是不重要的。页中间目录仅含有一个指向下属页表的目录项。所以,页中间目录项只是页全局目录中的一项而已。然而当处理页表时,创建一个页表可能很复杂,因为包含页表项的那个页表可能就不存在。在这样的情况下,有必要分配一个新页框,把它填写为0,并把这个表项加入。

如果PAE被激活,内核使用三级页表。当内核创建一个新的页全局目录时,同时也分配四个相应的页中间目录;只有当父页全局目录被释放时,这四个页中间目录才以释放。

物理内存布局

在初始化阶段,内核必须建立一个物理地址映射来指定哪些物理地址范围对内核可用而哪些不可用保留页框的页绝不能被动态分配或交换到磁盘上,内核将下列页框记为保留:

  • 在不可用的物理地址范围内的页框
  • 含有内核代码和已初始化的数据结构的页框

一般来说,Linux内核安装在RAM中物理地址0x00100000开始地方,也就是说,从第二个MB开始。所需页框总数依赖于内核的配置方案:典型的配置所得到的内核可以被安装在小于3M的RAM中。

  • 页框0由BIOS使用,存放加电自检期间检查到的系统硬件配置。
  • 物理地址从0x000a0000到0x000fffff的范围通常留给BIOS例程,并且映射ISA图形卡上的内部内存。这个区域就是所有IMB兼容PC上从640KB到1MB之间著名的洞
  • 第一个MB内的其它页框可能由特定计算机模型保留。

启动时的一些步骤:

  • 在启动过程的早期阶段,内核询问BIOS并了解物理内存的大小。
  • 随后,内核执行machine_specific_memory_setup()函数,该函数建立物理地址映射(见表2-9)。从0x07ff00000x07ff2fff的物理地址范围中存有加电自检阶段由BIOS写入的系统硬件设备信息;
  • 在初始化阶段,内核把这些信息拷贝到一个合适的内核数据结构中,然后认为这些页框是可用的。相反,从0x07ff3000到0x07ffffff的物理地址范围被映射到硬件设备的ROM芯片。从0xffff0000开始的物理地址范围标记为保留,因为它由硬件映射到BIOS的ROM芯片。注意BIOS也许不提供一些物理地址范围的信息。

  • setup_memory()函数在machine_specific_memory_setup()执行后被调用:它分析物理内存区域表并初始化一些变量业描述内核的物理内存布局,这些变量如表2-10所示。

为了避免把内核装入一组不连续的页框里面,Linux更愿意跳过RAM的第一个MB。明确地说,Linux用PC体系结构未保留的页框来动态存放所分配的页。图2-13显示Linux怎样填充前3MB的RAM。

符号_text对应于物理地址0x00100000,表示内核代码第一个字节的地址。内核代码的结束位置由另外一个类似的符号_etext表示。内核数据分为两组:初始化过的数据没有初始化的数据。初始化过的数据在_etext后开始,在_edata处结束。紧接着是未初始化的数据并以_end结束。

进程的页表

进程的线性地址空间分成两部分:

  • 从0x00000000到0xbfffffff的线性地址,无论进程运行在用户态还是在内核态都可以寻址。
  • 从0xc0000000到0xffffffff的线性地址,只有内核态的进程才能寻址。

当进程运行在用户态时,它产生的线性地址小于0xc0000000;当进程运行在内核态时,它执行内核代码,所产生的地址大于等于0xc0000000。但是,在一些情况下,内核为了检索或存放数据必须访问用户态线性地址空间。

宏PAGE_OFFSET产生的值为0xc0000000,这就是进程在线性地址空间中的偏移量,也是内核生存空间的开始之处。页全局目录的第一部分表项映射的线性地址小于0xc0000000,具体大小依赖于特定进程。

内核页表

内核维持着一组自己使用的页表,驻留在所谓的主内核页全局目录中。系统初始化后,这组页表还从未被任何进程或任何内核线程直接使用。内核初始化自己的页表分为两个阶段。

  • 内核创建一个有限的地址空间,包括内核的代码段和数据段、初始页表和用于存放动态数据结构的共128KB大小的空间。这个最小限度的地址空间仅够内核装入RAM和对初始化的核心数据结构。
  • 内核充分利用剩余的RAM并适当地建立分页表。

临时内核页表

临时页全局目录是在内核编译过程中静态地初始化的,而临时页表是由startup_32()汇编语言函数初始化的。临时页全局目录放在swapper_pg_dir变量中。临时页表在pg0变量处开始存放,紧接在内核未初始化的数据段后面。为了映射RAM前8MB的空间,需要用到两个页表。

分页第一个阶段的目录是允许在实模式下和保护模式下都能很容易地对这8MB寻址。因此,内核必须创建一个映射,把从0x00000000到0x007fffff的线性地址从0xc0000000和0xc07fffff的线性地址映射到从0x00000000和0x007fffff的物理地址

内核通过把swapper_pg_dir所有项都填充为0来创建期望的映射,不过,0、1、0x300和0x301这四项除外;后两项包含了从0xc0000000到0xc07fffff间的所有线性地址。0、1、0x300和0x301按以下方式初始化:

  • 0项和0x300项的地址字段置为pg0的物理地址,而1项和0x301项的地址字段置为紧随pg0后的页框的物理地址。
  • 把这四个项中的Present,Reand/Write和User/Supervisor标志置位。
  • 把这四个项中的Accessed、Diryt、PCD、PWD和PageSize标志清0。

汇编语言函数startup_32()也启用分页单元,通过向cr3控制寄存器装入swpper_pg_dir的地址及设置cr0控制寄存器的PG标志来达到这一目的。下面是等价的代码片段:

1
2
3
4
5
movl $swapper_pg_dir-0xc0000000, %eax
movl %eax, %cr3
movl %cr0, %eax
orl $0x80000000, %eax
movl %eax, %cr0

当RAM小于896MB时的最终内核页表

由内核页表所提供的最终映射必须把从0xc0000000开始的线性地址转化为从0开始的物理地址。宏__pa用于把从PAGE_OFFSET开始的线性地址转换成相应的物理地址,而宏__va做相反的转化。主内核页全局目录仍然保存在swapper_pg_dir变量中。它由paging_init()函数初始化。该函数进行如下操作:

  • 调用pagetable_init()适当地建立页表项。
  • swapper_pg_dir的物理地址写cr3控制寄存器中。
  • 如果CPU支持PAE并且如果内核编译时支持PAE,则将cr4控制寄存器的PAE标志置位。
  • 调用__flush_tlb_all()使TLB的所有项无效。

pagetable_init()执行的操作既依赖于现有RAM的容量,也依赖于CPU模型。计算机有小于896MB的RAM,32位物理地址足以对所有可用RAM进行寻址,因而没有必要激活PAE机制。

我们假定CPU是支持4MB页和“全局”TLB表项的最新80x86微处理器。注意如果页全局目录项对应的是0xc0000000之上的线性地址,则把所有这些项的User/Supervisor标志清0。由此拒绝用户态进程访问内核地址空间。还要注意PageSize被置位使得内核可能通过使用大型而对RAM进行寻址。

startup_32()函数创建的物理内存前8MB的恒等映射用来完成内核的初始化阶段。当这种映射不再必要,内核调用zap_low_mappings()函数清除对应的页表项。

当RAM大小在896MB和4096MB之间时的最终内核页表

在这种情况下,并不把RAM全部映射到内核地址空间。Linux在初始化阶段可以做的最好的事是把一个具有896MB的RAM窗口映射到内核线性空间。如果一个程序需要对现在RAM的其余部分寻址,就必须把某些其它的线性地址间隔映射到所需的RAM。这意味着修改一些页表的值。

当RAM大于4096MB时的最终内核页表

如果RAM大于4GB计算机的内核页表初始化;更确切地说,要处理以下发生的情况:

  • CPU模型支持物理地址扩展
  • RAM容量大于4GB
  • 内核以PAE支持来编译

尽管PAE处理36位物理地址,但是线性地址依然是32位地址,如前所述,Linux映射一个896MB的RAM窗口到内核线性地址空间,剩余RAM留着不映射,并由动态重映射来处理。主要差异是使用三级分页模型,因此页全局目录按以下循环代码初始化:

而全局目录中的前三项与用户线性地址空间相对应,内核用一个空页的地址对这三项进行初始化。第四项用页中间目录中的前448项用RAM前896MB的物理地址填充。

注意,支持PAE的所有CPU模型也支持大型2MB页和全局页。正如前一种情况一样,只要可能,Linux使用大型页来减少页表数。

然后页全局目录的第四项被拷贝到第一项中,这样好为线性地址空间的前896MB中的低物理内存映射做镜像。为了完成对SMP系统的初始化,这个映射是必需的:当这个映射不再必要时,内核通过调用zap_low_mapping()函数来清除对应的页表项。

固定映射的线性地址

我们看到内核线性地址第四个GB的初始部分映射系统的物理内存。但是,至少128M的线性地址总是留作他用,因为内核使用这些线性地址实现非连续内存分配和固定映射的线性地址。非连续内存分配仅仅是动态分配和释放内存页的一种特殊方式。

固定映射的线性地址基本上是一种类似于0xffffc000这样的常量线性地址,其对应的物理地址不必等于线性地址减去0xc0000000,而是可以以任意方式建立。因此,每个固定映射的线性地址都映射一个物理内存的页框。其实主是使用固定映射的线性地址来代替指针变量,因为这些变量的值从不改变。

就指针变量而言,固定映射的线性地址更有效。事实上间接引用一个立即常量地址要多一次内存访问。此外,在间接引用一个指针变量之前对其值进行检查是一个良好的编程习惯。

每个固定映射的线性地址都定义于enum fixed_address数据结构中的整型索引来表示。每个固定映射的线性地址都存放在线性地址第四个GB的低端。fix_to_virt()函数计算从给定索引开始的常量线性地址:

1
2
3
4
5
inline unsigned long fix_to_virt(const unsigned int idx) {
if (idx >= __end_of_fixed_addresses)
__this_fixmap_does_not_exist();
return (0xfffff000UL - (idx << PAGE_SHIFT));
}

假定某个内核函数调用fix_to_virt(FIX_IO_APIC_BASE_0)。FIX_IO_APIC_BASE_0是个等于3的常量,因此编译程序可以去掉if语句,因为它的条件在编译时为假。相反,如果条件为真,或者fix_to_virt()参数不是一个常量,则编译程序在连接阶段产生一个错误,因为__this_fixmap_does_not_exist没有定义。

为了把一个物理地址与固定映射的线性地址关联起来,内核使用set_fixma(idx,phys)set_fixmap_nocache(idx,phys)宏。这两个函数都把fix_to_virt(idx)线性地址对应一个页表项初始化为物理地址phys;不过,第二个函数也把页表项的PCD标志置位,因此,当访问这个页框中的数据时禁用硬件高速缓存。反过来,clear_fixmap(idx)用来撤销固定映射线性地址idx和物理地址之间的连接。

处理硬件高速缓存和TLB

采用一些技术来减少高速缓存和TLB的未命中次数。

处理硬件高速缓存

L1_CACHE_BYTES宏产生以字节为单位的高速缓存行的大小。为了使高速缓存的命中率达到最优化,内核在下列决策中考虑体系结构:

  • 一个数据结构中最常使用的字段放在该数据结构内的低偏移部分,以便它们能够处于高速缓存的同一行中。
  • 当为一大组数据结构分配空间时,内核试图把它们都存放在内存中,以便所有高速缓存行按同一方式使用。

处理TLB

处理器不能自动同步它们自己的TLB高速缓存,因为决定线性地址和物理地址之间映射何时不再有效的是内核,而不是硬件。Linux2.6提供了几种在合适时机应当运用的TLB刷新方法,这取决于页表更换的类型。

Intel微处理器只提供了两种使TLB无效的技术:

  • 向cr3寄存器写入值时所有Pentium处理器自动刷新相对于非全局页的TLB表项。
  • 在pentiumPro及以后的处理器中,invlpg汇编语言指令使映射指定线性地址的单个TLB表项无效。

表2-12列出了采用这种硬件技术的Linux宏;这些宏是实现独立于系统的方法的基本要点。

一般来说,任何进程切换都会暗示着更换活动页表集。相对于过期页表,本地TLB表项必须被刷新:这个过程在内核把新页全局目录的地址写入cr3控制寄存器时会自动完成。不过内核在下列情况下将避免TLB被刷新:

  • 当两个使用相同页表集的普通进程之间执行进程切换时。
  • 当在一个普通进程一个内核线程执行进程切换时。事实上,内核线程并不拥有自己的页表集;更确切地说,它们使用刚在CPU上执行过的普通进程的页表集。

为了避免多处理器系统上无用的TLB刷新,内核使用一种叫做懒惰TLB模式的技术。其基本思想是,如果几个CPU正在使用相同的页表,而且必须对这些CPU上的一个TLB表项刷新,那么,在一些情况下,正在运行内核线程的那些CPU上的刷新就可以廷迟。处于懒惰TLB模式的每个CPU都不刷新相应的表项;但是,CPU记住它的当前进程正运行在一组页表上,而这组页表的TLB表项对用户态地址是无效的。只要处于懒惰TLB模式的CPU用一个不同的页表集切换到一个普通进程,硬件就自动刷新TLB表项,同时内核把CPU设置为非懒惰TLB模式。

为了实现懒惰TLB模式,需要一些额外的数据结构。cpu_tlbstate变量是一个具有NR_CPUS个结构的静态数组,这个结构有两个字段,一个是指向当前进程内存描述符的active_mm字段,一个是具有两个状态值的state字段:TLBSTATE_OKTLBSTATE_LYZY。此外,每个内存描述符中包含一个cpu_vm_mask字段,该字段存放的是CPU下标;只有当内存描述符属于当前运行的一个进程时这个字段才有意义。

当一个CPU开始执行内核线程时,内核把该CPU的cpu_tlbstate元素的state字段置为TLBSTATE_LAZY;此外,活动内存描述符的cpu_vm_mask字段存放系统中所有CPU的下标。对于与给定页表集相关的所有CPU的TLB表项,当另外一个CPU想使这些表项有效时,该CPU就把一个处理器间中断发送给下标处于对应内存描述符的cpu_vm_mask字段中的那些CPU。

当CPU接受到一个与TLB刷新相关的处理器中断,并验证它影响了当前进程的页表集时,它就检查它的cpu_tlbstate元素的state字段是否等于TLBSTATE_LAZY。如果等于,内核就拒绝使TLB表项无效,并从内存描述符的cpu_vm_mask字段删除该CPU下标。这有两种结果:

  • 只要CPU还处于懒惰TLB模式,它将不接受其它与TLB刷新相关的处理器间中断。
  • 如果CPU切换到另一个进程,而这个进程与刚被替换的内核线程使用相同的页表集。那么内核调用__flush_tlb()使该CPU的所有非全局TLB表项有效。

进程

进程、轻量级进程和线程

进程是程序执行时的一个实例。从内核观点看,进程的目的就是担当分配系统资源的实体。当一个进程创建时,它接受父进程地址空间的一个拷贝,并开始执行与父进程相同的代码。它们各有独立的数据拷贝(栈和堆),因此子进程对一个内存单元的修改对父进程是不可见的。

现代Unix系统支持多线程应用程序——一个进程由几个用户线程组成。每个线程都代表进程的一个执行流。现在,大部分多线程应用程序都是用pthread库的标准库函数集编写的。多线程应用程序多个执行流的创建、处理、调度调整都是在用户态进行的。

Linux使用轻量级进程对多线程应用程序提供更好的支持。两个轻量级进程基本上可以共享一些资源,诸如地址空间、打开文件等等。只要其中一个修改共享资源,另一个就立即查看这种修改。当然,当两个线程访问共享资源时就必须同步它们自己。实现多线程应用程序的一个简单方式就是把轻量级进程与每个线程关联起来。这样,线程之间就可以访问相同的应用程序数据结构集;同时,每个线程都可以由内核独立调度,以便一个睡眠的同时另一个仍然是可以运行的。

POSIX兼容的多线程应用程序由支持线程组的内核来处理最好不过。在Linux中,一个线程组基本上就是实现了多线程应用的一组轻量级进程,对于像getpid()kill()_exit()这样的一些系统调用,它像一个组织,起整体的作用。

进程描述符

进程描述符是task_struct数据结构,不仅包含了很多进程属性的字段,而且一些字段还包括了指向其它数据结构的指针。

进程的状态

进程描述符中的state字段描述了进程当前所处的状态。它由一组标志组成,其中每个标志描述一种可能的进程状态。只能设置一种状态;其余的标志被清除。

  • 可运行状态TASK_RUNNING:进程要么在CPU上执行,要么准备执行。
  • 可中断的等待状态TASK_INTERRUPTIBLE:进程被挂起,直到某个条件变为真。
  • 不可中断的等待状态TASK_UNINTERRUPTIBLE:与可中断的等待状态类似,但有一个例外,把信号传递到睡眠进程不能改变它的状态。
  • 暂停状态TASK_STOPPED:进程的执行被暂停。当进程接收到SIGSTOP、SIGTSTP、SIGTIIN或SIGTTOU信号后,进入暂停状态。
  • 跟踪状态TASK_TRACED:进程的执行已由debugger程序暂停。当一个进程被另一个进程监控时,任何信号都可以把这个进程置于TASK_TRACED状态。

还有两个进程状态是既可以存放在进程描述符的state字段中,也可以存放在exit_state字段中。从这两个字段的名称可以看出,只有当进程的执行被终止时,进程的状态才会变为这两种状态中的一种:

  • 僵死状态EXIT_ZOMBIE:进程的执行被终止,但是,父进程还没有发布wait4()waitpid()系统调用来返回有关死亡进程的信息。发布wait()类系统调用前,内核不能丢弃包含在死进程描述符中的数据,因为父进程可能还需要它。
  • 僵死撤消状态EXIT_DEAD:最终状态:由于父进程刚发出wait4()waitpid()系统调用,因而进程由系统删除。为了防止其它执行线程在同一个进程上也执行wait()类系统调用,而把进程的状态由僵死改为僵死撤消状态。

state字段的值通常用一个简单的赋值语句设置。例如:

1
p->state= TASK_RUNNING;

内核也使用set_task_stateset_current_state宏;它们分别设置指定进程的状态和当前执行进程的状态

标识一个进程

能被独立调度的每个执行上下文都必须拥有它自己的进程描述符;因此,即使共享内核大部分数据结构的轻量级进程,也有它们自己的task_struct结构。

类Unix操作系统允许用户使用一个叫做进程标识符processId的数来标识进程,PID存放在进程描述符的pid字段中。在缺省情况下,最大的PID号是32767;系统管理员可以通过往/proc/sys/kernel/pid_max这个文件中写入一个更小的值来减少PID的上限值,使PID的上限小于32767。

由于循环使用PID编号,内核必须通过管理一个pidmap-array位图来表示当前已分配的PID号和闲置的PID号。因为一个页框包含32768个位。所以在32位体系结构中pidmap-array位图存放在一个单独的页中。

Linux引入线程组的表示。一个线程组中的所有线程使用和该线程组的领头线程有相同的PID,也就是该组中第一个轻量级进程的PID,它被存入进程描述符的tgid字段中。getpid()系统调用返回当前进程的tgid值而不是pid的值,因此,一个多线程应用的所有线程共享相同的PID。绝大多数进程都属于一个线程组,包含单一的成员;线程组的领头线程其tgid的值与pid的值相同,因而getpid()系统调用对这类进程所起的作用和一般进程是一样的。

进程描述符处理

对每个进程来说,Linux都把两个不同的数据结构紧凑地存放在一个单独为进程分配的存储区域内:一个是内核态的进程堆栈,另一个是紧挨进程描述符的小数据结构thread_info,叫做线程描述符,这块存储区域的大小通常为8192个字节。考虑到效率的因素,内核让这8K空间占据连续的两个页框并让第一个页框的起始地址是2的13次方的倍数。

图3-2显示了在2页内存区中存放两种数据结构的方式。线程描述符驻留于这个内存区的开始,而栈从末端向下增长。该图还显示了分别通过taskthread_info字段使thread_info结构与task_struct结构互相关联。

esp寄存器是CPU栈指针,用来存放栈顶单元的地址。在80x86系统中,栈起始于末端,并朝这个内存区开始的方向增长。从用户态刚切换到内核态以后,进程的内核栈总是空的,因此,esp寄存器指向这个栈的顶端。一旦数据写入堆栈,esp的值就递减。因为thread_info结构是52个字节长,因此,内核栈能扩展到8140个字节。

C语言使用下列的联合结构方便地表示一个进程的线程描述符和内核栈:

1
2
3
4
union thread_union{
struct thread_info thread_info;
unsigned long stack[2048];
};

如图3-2所示,thread_info结构从0x015fa000地址开始存放,而栈从0x015fc000地址开始存放。esp寄存器的值指向地址为0x015fa878的当前栈顶。内核使用alloc_thread_infofree_thread_info宏分配和释放存储thread_info结构和内核栈的内存区。

标识当前进程

内核很容易从esp寄存器的值获得当前在CPU上正在运行的进程的thread_info结构的地址。事实上,如果thread_union结构长度是8K,则内核屏蔽掉esp的低13位有效位就可以获得thread_info结构的基地址;而如果thread_union结构长度是4K,内核需要屏蔽掉esp的低12位有效位。这项工作由current_thread_info()函数来完成,它产生如下一些汇编指令:

1
2
3
movl $0xffffe000, %ecx
andl %esp, %ecx
movl %ecx, p

这三条指令执行后,p就包含在执行指令的CPU上运行的进程的thread_info结构的指针。

进程最常用的是进程描述符的地址不是thread_info结构的地址。为了获得当前在CPU上运行进程的描述符指针,内核要调用current宏。该宏本质上等价于current_thread_info()->task,它产生如下汇编语言指令:

1
2
3
movl $0xffffe000, %ecx
andl %esp, %ecx
movl (%ecx), p

因为task字段在thread_info结构中的偏移量为0,所以执行完这三条指令之后,p就包含在CPU上运行进程的描述符指针。

current宏经常作为进程描述符字段的前缀出现在内核代码中,例如,current->pid返回在CPU上正在执行的进程的PID。

用栈存放进程描述符的另一个优点体现在多处理器系统上:对于每个硬件处理器,仅通过检查栈就可以获得当前正确的进程

双向链表

Linux内核定义了list_head数据结构,字段nextprev分别表示通过双向链表向前和向后的指针元素,不过,值得特别关注是,list_head字段的指针中存放的是另一个list_head字段的地址,而不是含有list_head结构的整个数据结构地址。

新链表是用LIST_HEAD(list_name)宏创建的,它申明类型为list_head的新变量list_name,该变量作为新链表头的占位符,是一个哑元素。LIST_HEAD(list_name)宏还初始list_head数据结构的prev和next字段,让它们指向list_name变量本身。

Linux2.6内核支持另一种双向链表,主要用于散列表,表头存放在hlist_head数据结构中,该结构只不过是指向表的第一个元素的指针。每个元素都是hlist_node类型的数据结构,它的next指针指向下一个元素,pprev指针指向前一个元素的next字段。因为不是循环链表,所以第一个元素的pprev字段和最后一个元素的next字段都置为NULL。对这种表可以用类似表3-1中的函数和宏来操纵。

进程链表

进程链表把所有进程的描述符链接起来。每个task_struct结构都包含一个list_head类型的tasks字段,这个类型的prev和next字段分别指向前面和后面的task_struct元素。

进程链表的头是init_task描述符,它是所谓的0进程或swapper进程的进程描述符。init_tasktasks.prev字段指向链表中最后插入的进程描述符的tasks字段。

SET_LINKSREMOVE_LINKS宏分别用于从进程链表中插入和删除一个进程描述符。这些宏考虑了进程间的父子关系。还有一个很有用的宏就是for_each_process,它的功能是扫描整个进程链表,其定义如下:

1
2
3
4
#define for_each_process(p) \
for(p = &init_task; (p=list_entry(p)->tasks.next, \
struct task_struct, tasks) \
) != &init_task;)

这个宏是循环控制语句,内核开发都利用它提供循环。注意init_task进程描述符是如何起到链表头作用的。这个宏从指向init_task的指针开始,把指针移动下一个任务,然后继续,直到又到init_task为止。在每一次循环时,传递给这个宏的参变量中存放的是当前被打描进程描述符的地址,这与list_entry宏的返回值一样。

TASK_RUNNING状态的进程链表

早先的Linux版本把所有的可运行进程都放在同一个叫做运行队列的链表中,由于维持链表中的进程按优先级排序开销过大,因此,早期的调度程序不得不为选择“最佳”可运行进程而扫描整个队列。

Linux2.6实现的运行队列有所不同。其目的是让调度程序能在固定的时间内选出”最佳”可运行进程,与队列中可运行的进程数无关。

提高调度程序运行速度的诀窍是建立多个可运行进程链表,每种进程优先权对应一个不同的链表,每个task_struct描述符包含一个list_head类型的字段run_list,如果进程的优先权等于k,run_list字段把该进程链入优先权为k的可运行进程的链表中。此外,在多处理器系统中,每个CPU都有它自己的运行队列,即它自己的进程链表集。这是一个通过使数据结构更复杂来改善性能的典型例子:调度程序的操作效率的确提高了,但运行队列的链表却为此而被拆分成140个不同的队列!

内核必须为系统中每个运行队列保存大量的数据,不过运行队列的主要数据结构还是组成运行队列的进程描述符链表,所有这些链表都由一个单独的prio_array_t数据结构来实现,其字段说明如表3-2所示。

enqueue_task(p,array)函数把进程描述符插入某个运行队列的链表,其代码本质上等同于:

1
2
3
4
list_add_tail(&p->run_list, &array->queue[p->prio])
__set_bit(p->prio, array->bitmap)
array->nr_active ++;
p->array = array;

进程描述符prio字段存放进程的动态优先权,而array字段是一个指针,指向当前运行队列的prio_array_t数据结构。类似地,dequeue_task(p,array)函数从运行队列的链表中删除一个进程描述符。

进程间的关系

程序创建的进程具有父/子关系。如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。在进程描述符中引入几个字段来表示这些关系,表示给定进程P的这些字段列在表3-3中。进程0和进程1由内核创建的:稍后我们将看到,进程1是所有进程的祖先。

特别要说明的是,进程之间还存在其它关系:一个进程可能是一个进程组或登录会话的领头进程,也可能是一个线程组的领头进程,它还可能跟踪其它进程的执行。表3-4列出进程描述符中的一些字段,这些字段建立起了进程P和其它进程之间的关系。

Pidhash表及链表

在几种情况下,内核必须能从进程的PID导出对应的进程描述符指针。例如,为kill()系统调用时就会发生这种情况:当进程P1希望向另一进程P2发送一个信号时,P1调用kill()系统调用,其参数为P2的PID,内核从这个PID导出其对应的进程描述符,然后从P2的进程描述符中取出记录挂起信号的数据结构指针。

为了加速查找。引入了4个散列表。需要4个散列是因为进程描述符包含了表示不同类型PID的字段,而且每种类型的PID需要它自己的散列表。内核初始化期间动态地为4个散列表分配空间,并把它们的地址存入pid_hash数组。一个散列表就被存在4个页框中,可以拥有2048个表项。

用pid_hashfn宏把PID转化为表索引,pid_hashfn宏展开为:

1
#define pid_hashfn(x) hash_long((unsigned long)x, pidhash_shift)

变量pidhash_shitf用来存放表索引的长度。很多散列函数都使用hash_long(),在32位体系结构中它基本等价于
1
2
3
4
unsigned long hash_long(unsigned long val, unsigned int bits) {
unsigned long hash = val * 0x9e370001UL;
return hash >> (32 - bits);
}

因为在我们的例子中pidhash_shift等于11,所以pid_hashfn的值范围是0到2的11次减1。

Linux利用链表来处理冲突的PID,每一个表项是由冲突的进程描述符组成的双向链表。图3-5显示了具有两个链表的PID散列表。进程号为2890和29384的两个进程散列到这个表的第200个元素,而进程号为29385的进程散列到这个表的第1466个元素。

具有链表的散列法比从PID到表索引的线性转换更优越,这是因为在任何给定的实例中,系统中的进程数总是远远小于32768。如果在任何给定的实例中大部分表项都不使用的话,那么把表定义为32768项会是一种浪费。

PID散列表可以为包含在一个散列表中的任何PID号定义进程链表。最主要的数据结构是四个pid结构的数组。它在进程描述符的pid字段中,表3-6显示了pid结构的字段。

图3-6给出了PIDTYPE_TGID类型散列表的例子。pid_hash数组的第二个元素存放散列表的地址,也就是用hlist_head结构的数组表示链表的头。在散列表第71项为起点形成的链表中,有两个PID号为246和4351的进程描述符。PID的值存放在pid结构的nr字段中,而pid结构在进程描述符中。我们考虑线程组4351的PID链表:散列表中的进程描述符的pid_list字段中存放链表的头,同时每个PID链表中指向前一个元素和后一个元素的指针也存放在每个链表元素的pid_list字段中。

下面是处理PID散列表的函数和宏:

1
2
do_eash_task_pid(nr,type,task)
while_each_task_pid(nr,type,task)

标记do-while循环的开始和结束,循环作用在PID值等于nr的PID链表上,链表的类型由参数type给出,task参数指向当前被扫描的元素的进程描述符。

1
find_task_by_pid_type(type,nr)

在type类型的散列表中查找PID等于nr的进程,该函数返回所匹配的进程描述指针,若没有匹配的进程,函数返回NULL。

1
find_task_by_pid(nr)

find_task_by_pid_type(type,nr)相同。

1
attach_pid(task,type,nr)

把task指向的PID等于nr的进程描述符插入type类型的散列表中。如果一个PID等于nr的进程描述符已经在散列表中,这个函数就只把task插入已有的PID进程链表中。

1
detach_pid(task,type)

从type类型的PID进程链表中删除task所指向的进程描述符。如果删除后PID进程链表没有变成空,则函数终止,否则,该函数还要从type类型的散列表中删除进程描述符。最后,如果PID的值没有出现任何其它的散列表中,为了这个值能够被反复使用,该函数还必须清除PID位图中的相应位。

1
next_thread(task)

返回PIDTYPE_TGID类型的散列链表中task指示的下一个轻量级进程的进程描述符。由于散列链表是循环的,若应用于传统的进程,那么该宏返回进程本身的描述符地址。

如何组织进程

运行队列链表把处于TASK_RUNNING状态的所有进程组织在一起。Linux把其它��态的进程分组:

  • 没有为处于TASK_STOPPEDEXIT_ZOMBIEEXIT_DEAD状态的进程建立专门的链表。由于对处于暂停、僵死、死亡状态进程的访问比较简单(通过特定父进程的子进程链表),所以不必对这三种状态进程分组。

等待队列

等待队列实现了在事件上的条件等待:希望等待特定事件的进程把自己放进合适的等待队列,并放弃控制权。因此,等待队列表示一组睡眠的进程,当某一条件变为真时,由内核唤醒它们。

等待队列由双向链表实现,其元素包括指向进程描述符的指针。每个等待队列都有一个等待队列头,等待队列头是一个类型为wait_queue_head_t的数据结构:

1
2
3
4
5
struct __wait_queue_head{
spinlock_t lock;
struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;;

因为等待队列是由中断处理程序和主要内核函数修改的,因此必须对其双向链表进行保护以免对其进行同时访问,因为同时访问会导致不可预测的后果。同步是通过等待队列头中的lock自旋锁达到的。task_list字段是等待进程链表的头。

等待队列链表中的元素类型为wait_queue_t:

1
2
3
4
5
6
7
struct __wait_queue {
unsigned int flags;
struct task_struct *task;
wait_queue_func_t func;
struct list_head task_list
};
typedef struct __wait_queue wait_queue_t;

等待队列链表中的每个元素代表一个睡眠进程,该进程等待某一事件的发生:它的描述符地址存放在task字段中。task_list字段中包含的是指针,由这个指针把一个元素链接到等待相同事件的进程链表中

有两种睡眠进程:互斥进程由内核有选择地唤醒,而非互斥进程总是由内核在事件发生时唤醒。等待访问临界资源的进程就是互斥进程的典型例子。

等待队列的操作

可以用DECLARE_WAIT_QUEUE_HEAD(name)宏定义一个新等待队列的头,它静态地声明一个叫name的等待队列的头变量并对该变量的lock和task_list字段进行初始化。函数init_waitqueue_head()可以用来初始化动态分配的等待队列的头变量。

函数init_waitqueue_entry(q,p)如下所示初始化wait_queue_t结构的变量q:

1
2
3
q->flags = 0;
q->task = p;
q->func = default_wake_function;

非互斥进程p将由default_wake_function()唤醒,default_wake_function()是在第七章中要讨论的try_to_wake_up()函数的一个简单的封装。

也可以选择DEFINE_WAIT宏声明一个wait_queue_t类型的新变量,并用CPU上运行的当前进程的描述符和唤醒函数autoremove_wake_function()的地址初始化这个新变量。这个函数调用default_wake_function()来唤醒睡眠进程,然后从等待队列的链表中删除对应的元素。最后,内核开发都可以通过:

  • init_waitqueue_func_entry()函数来自定义唤醒函数,该函数负责初始化等待队列的元素
  • add_wait_queue()函数把一个非互斥进程插入等待队列链表的第一个位置
  • add_wait_queue_exclusive()函数把一个互斥进程插入等待队列链表的最后一个位置
  • remove_wait_queue()函数从等待队列链表中删除一个进程
  • waitqueue_active()函数检查一个给定的等待队列是否为空

要等待特定条件的进程可以调用如下列表中的任何一个函数。

  • sleep_on()对当前进程进行操作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void sleep_on(wait_queue_head_t *wq)
    {
    wait_queue_t wait
    init_waitqueue_entry(&wait,current);
    current->state = TASK_UNINTERRUPTIBLE;
    add_wait_queue(wq,&wait);
    schedule();
    remove_wait_queue(wq,&wait);
    }

    该函数把当前进程的状态设置为TASK_UNINTERRUPTIBLE,并把它插入到特定的等待队列。然后,它调用调度程序,而调度程序重新开始另一个程序的执行。当睡眠进程被唤醒时,调度程序重新开始执行sleep_on()函数,把该进程从等待队列中删除。

  • interruptible_sleep_on()sleep_on()函数是一样的,但稍有不同,前者把当前进程的状态设置为TASK_INERRUPTIBLE而不是TASK_UNINTERRUPTIBLE,因此,接受一个信号就可以唤醒当前进程。

  • sleep_on_timeout()interruptible_sleep_on_timeout()与前面函数类似,但它们允许调用都定义一个时间间隔,过了这个间隔以后,进程交由内核唤醒。为了做到这点,它们调用shedule_timeout()函数而不是schedule()函数。
  • 在Linux 2.6中引入的prepare_to_wait()prepare_to_wait_exclusive()finish_wait()函数提供了另外一种途径来使当前进程在一个等待队列中睡眠。它们的典型应用如下:
1
2
3
4
5
6
DEFINE_WAIT(wait);
prepare_to_wait_exclusive(&wq,&wait,TASK_INTERRUPTIBLE);

if(!condition)
schedule();
finish_wait(&wq,&wait);

函数prepare_to_wait()prepare_to_wait_exclusive()用传递的第三个参数设置进程的状态,然后把等待队列元素的互斥标志flag分别设置为0或1,最后,把等待元素wait插入到以wq为头的等待队列的链表中。进程一但被唤醒就执行finish_wait()函数,它把进程的状态再次设置为TASK_RUNNING,并从等待队列中删除等待元素。

  • wait_eventwait_event_interruptible宏使它们的调用进程在等待队列上睡眠,一直到修改了给定条件为止。例如,宏wait_event(wq,condition)本质上实现下面的功能
1
2
3
4
5
6
7
8
DEFINE_WAIT(__wait);
for(;;){
prepare_to_wait(&wq,&__wait,TASK_UNINTERRUPTIBLE);
if(condition)
break;
schedule();
}
finish_wait(&wq,&__wait);

对上面列出的函数做一些说明:sleep_on()类函数在以下条件下不能使用,那就是必须测试条件并且当条件还没有得到验证时又紧接着让进程去睡眠;由于那些条件是众所周知的竞争条件产生的根源,所以不鼓励这样使用。此外,为了把一个互斥进程插入等待队列,内核必须使用prepare_to_wait_exclusive()

所有其它的相关函数把进程当作非互斥进程来插入。最后,除非使用DEFINE_WAITfinish_wait(),否则内核必须在唤醒等待进程后从等待队列中删除对应的等待队列元素。

内核通过下面的任何一个宏唤醒等待队列中的进程并把它们的状态置为TASK_RUNNINGwake_up,wake_up_nr,wake_up_all,wake_up_interruptible,wake_up_interruptible_nr,wake_up_interruptible_all,wake_up_interruptible_syncwake_up_locked。从每个宏的名字我们可以明白其功能。

  • 所有宏都考虑到处于TASK_INTERRUPTIBLE状态的睡眠进程;如果宏的名字中不含有字符串interruptible,那么处于TASK_UNINTERRUPTIBLE状态的睡眠进程也被考虑到。
  • 有宏都唤醒具有请求状态的所有非互斥进程。
  • 名字中含有nr字符串的宏唤醒给定数的具有请求状态的互斥进程;这个数字是宏的一个参数。名字中含有all字符串的宏只唤醒具有请求状态的所有互斥进程。最后,名字中不含nr或all字符吕的宏只唤醒具有请求状态的一个互斥进程。
  • 名字中不含有sync字符串的宏检查被唤醒进程的优先级是否高于系统中正在运行进程的优先级,并在必要时调用schedule()。这些检查并不是名字中含有sync字符串的宏进行的,造成的结果是高优先级进程的执行稍有延迟。
  • wake_up_locked宏和wake_up宏相类似,仅有的不同是当wait_queue_head_t中的自旋锁已经被持有时要调用wake_up_locked

进程资源限制

每个进程都有一组相关的资源限制,限制指定了进程能使用的系统资源数量。这些限制避免用户过分使用系统资源。Linux承认以下表3-7中的资源限制。

对当前进程的资源限制存放在current->signal->rlim字段,即进程的信号描述符的一个字段。该字段是类型为rlimit结构的数组,每个资源限制对应一个元素。

1
2
3
4
struct rlimit{
unsigned long rlim_cur;
unsigned long rlim_max;
};

rlim_cur字段是资源的当前资源限制。例如,current->signal->rlim[RLIMIT_CPU].rlim_cur表示正运行进程所占用CPU时间的当前限制。

rlim_max字段是资源限制所允许的最大值。利用getrlimit()setrlimit()系统调用,用户总能把一些资源的rlim_cur限制增加到rlim_max。然而,只有超级用户才能改变rlim_max字段,或把rlim_cur字段设置成大于相应rlim_max字段的一个值。

大多数资源限制包含值RLIMIT_INFINITY,它意味着没有对相应的资源施加用户限制。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换任务切换上下文切换

硬件上下文

要恢复一个进程的执行之前,内核必须确保每个寄存器装入了挂起进程时的值。进程恢复执行前必须装入寄存器的一组数据称为硬件上下文。硬件上下文是进程可执行上下文的一个子集,因为可执行上下文包含进程执行时需要的所有信息。在Linux中,进程硬件上下文的一部分存在TSS段,而剩余部分存放在内核态的堆栈中

我们把进程切换定义为这样的行为:保存prev硬件上下文,用next硬件上下文代替prev。因为进程切换经常发生,因此减少和装入硬件上下文所花费的时间是非常重要的。基于以下原因,Linux2.6使用软件执行进程切换:

  • 通过一组mov指令逐步执行切换,这样能较好地控制所装入数据的合法性,尤其是,这使检查ds和es段寄存器的值成为可能,这些值有可能被恶意用户伪造。当用单独的farjmp指令时,不可能进行这类检查。

进程切换只发生在内核态。在执行进程切换之前,用户态进程所使用的所有寄存器内容已保存在内核态堆栈上,这也包括ss和esp这对寄存器的内容。

任务状态段

80x86体系结构包括一个特殊的段类型,叫任务状态段,来存放硬件上下文。尽管Linux并不使用硬件上下文切换,但是强制它为系统中每个不同的CPU创建一个TSS。这样做的两个主要理由为:

  • 当80x86的一个CPU从用户态切换到内核态时,它就从TSS中获取内核态堆栈的地址。
  • 当用户态进程试图通过in或out指令访问一个I/O端口时,CPU需要访问存放在TSS中的I/O许可图以检查该进程是否有访问端口的权力。

更确切地说,当进程在用户态下执行in或out指令时,控制单元执行下列操作:

  1. 它检查eflags寄存器中的2位IOPL字段。如果该字段值为3,控制单元就执行I/O指令。否则,执行下一个检查。
  2. 访问tr寄存器以确定当前的TSS和相应的I/O许可权位图。
  3. 检查I/O指令中指定的I/O端口在I/O许可权位图中对应的位。如果该位清0,这条I/O指令就执行,否则控制单元产生一个”Generalprotetion”异常。

tss_struct结构描述TSS的格式。正如第二章所提到的,init_tss数组为系统上每个不同的CPU存放一个TSS。在每次进程切换时,内核都更新TSS的某些字段以便相应的CPU控制单元可以安全地检索到它需要的信息。因此,TSS反映了CPU上的当前进程的特权级,但不必为没有在运行的进程保留TSS。

每个TSS有它自己8字节的任务状态段描述符TSSD。这个描述符包括指向TSS起始地址的32位Base字段,20位Limit字段。TSSD的S标志被清0,以表示相应的TSS是系统段的事实。

Type字段置为11或9以表示这个段实际上是TSS。在Intel的原始设计中,系统中的每个进程都应当指向自己的TSS;Type字段的第二个有效位叫做Busy位;如果进程正由CPU执行,则该位置为1,否则置为0。在Linux的设计中,每个CPU只有一个TSS,因此,Busy位总置为1。

由linux创建的TSSD存放在全局描述符表中。GDT的基地址存放在每个CPU的gdtr寄存器中。每个CPU的tr寄存器包含相应TSS的TSSD选择符,也包括了两个隐藏了非编程字段;TSSD的Base字段和Limit字段。这样,处理器就能直接对TSS寻址而不用从GDT中检索TSS的地址。

Thread字段

在每次进程切换时,被替换进程的硬件上下文必须保存在别处。不能像Intel原始设计那样把它保存在TSS中,因为Linux为每个处理器而不是为每个进程使用TSS。

因此,每个进程描述符包含一个类型为thread_structthread字段,只要进程被切换出去,内核就把其硬件上下文保存在这个结构中

执行进程切换

进程切换可能只发生在精心定义的点:schedule()函数。这里,我们仅关注内核如何执行一个进程切换。从本质上说,每个进程切换由两步组成:

  • 切换页全局目录以安装一个新的地址空间;
  • 切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要的所有信息,包含CPU寄存器。

switch_to宏

进程切换的第二步由switch_to宏执行。

  • 首先,该宏有三个参数,它们是prevnextlast。prev和next仅是局部变量prev和next的占位符,即它们是输入参数,分别表示被替换进程和新进程描述符的地址在内存中的位置
  • 当内核想再次激活A,就必须暂停另一个进程C,于是就要用prev指向C而next指向A来执行另一个swithch_to宏。当A恢复它的执行流时,就会找到它原来的内核栈,于是prev局部变量还是指向A的描述符而next指向B的描述符。此时,代表进程A执行的内核就失去了对C的任何引用。
  • switch_to宏的最后一个参数是输出参数,它表示宏把进程C的描述符地址写在内存的什么位置了。在进程切换之前,宏把第一个输入参数prev表示的变量的内容存入CPU的eax寄存器。在完成进程切换,A已经恢复执行时,宏把CPU的eax寄存器的内容写入由第三个输出参数———-last所指示的A在内存中的位置。因为CPU寄存器不会在切换点发生变化,所以C的描述符地址也存在内存的这个位置。在schedule()执行过程中,参数last指向A的局部变量prev,所以prev被C的地址覆盖。

图3-7显示了进程A,B,C内核堆栈的内容以及eax寄存器的内容。图中显示的是在被eax寄存器的内容覆盖以前的prev局部变量的值。

我们将采用标准汇编语言而不是麻烦的内联汇编语言来描述switch_to宏在80x86微处理器上所完成的典型工作。

  1. 在eax和edx寄存器中分别保存prev和next的值。

    1
    2
    movl prev, %eax
    movl next, %edx
  2. 把eflags和ebp寄存器的内容保存在prev内核栈中。必須保存它们的原因是编译器认为在switch_to结束之前它们的值应当保持不变。

    1
    2
    pushf1
    push %ebp
  3. 把esp的内容保存到prev->thread.esp中以使该字段指向prev内核栈的栈顶:

    1
    movl %esp, 484(%eax)
  4. next->thread.esp装入esp。此时,内核开始在next的内核栈上操作,因此这条指令实际上完成了从prev到next的切换。由于进程描述符的地址和内核栈的地址紧挨着,所以改变内核栈意味着改变进程。

    1
    movl 484(%edx), %esp
  5. 把标记为1的地址存入prev->thread.eip。当被替换的进程重新恢复执行时,进程执行被标记为1的那条指令:

    1
    movl $lf, 480(%eax)
  6. 宏把next->thread.eip的值压入next的内核栈。

    1
    pushl 480(%edx)
  7. 跳到__switch_to()C函数

    1
    jmp__switch_to
  8. 这里被进程B替换的进程A再次获得CPU;它执行一些保存eflags和ebp寄存器内容的指令,这两条指令的第一条指令被标记为1。

    1
    2
    3
    1:
    popl %ebp
    popfl
  9. 拷贝eax寄存器的内容到switch_to宏的第三个参数last标识的内存区域中:

    1
    movl %eax, last

正如以前讨论的,eax寄存器指向刚被替换的进程描述符。

__switch_to()函数

__switch_to()函数执行大多数开始于switch_to()宏的进程切换。这个函数作用于prev_p和next_p参数,这两个参数表示前一个进程和新进程。这个函数的调用不同于一般函数的调用,因为__switch_to()从exa和edx取参数prev_p和next_p。为了强迫函数从寄存器取它的参数,内核利用__attribute__regparm关键字。__switch_to()函数的声明如下:

1
__switch_to(struct task_struct *prev_p, struct tast_struct *next_p)__attribute_(regparm(3));

函数执行的步骤如下:

  • 执行由__unlazy_fpu()宏产生的代码,以有选择地保存prev_p进程的FPU、MMX及XMM寄存器的内容。__unlazy_fpu(prev_p);
  • 执行smp_processor_id()宏获得本地(local)CPU的下标,即执行代码的CPU。该宏从当前进程的thread_info结构的cpu字段获得下标将它保存到cpu局部变量。
  • next_p->thread.esp0装入对应于本地CPU的TSS的esp0字段;将在通过sysenter指令发生系统调用一节看到,以后任何由sysenter汇编指令产生的从用户态到内核态的特权级转换将把这个地址拷贝到esp寄存器中init_tss[cpu].esp0 = next_p->thread.esp0;
  • next_p进程使用的线程局部存储段装入本地CPU的全局描述符表;三个段选择符保存在进程描述符内的tls_array数组中

    1
    2
    3
    cpu_gdt_table[cpu][6]= next_p->thread.tls_array[0];
    cpu_gdt_table[cpu][7]= next_p->thread.tls_array[1];
    cpu_gdt_table[cpu][8]= next_p->thread.tls_array[2];
  • 把fs和gs段寄存器的内容分别存放在prev_p->thread.fsprev_p->thread.gs中,对应的汇编语言指令是:

    1
    2
    movl%fs,40(%esi)
    movl%gs,44(%esi)
  • 如果fs或gs段寄存器已经被prev_p或next_p进程中的任意一个使用,则将next_p进程的thread_struct描述符中保存的值装入这些寄存器中。这一步在逻辑上补充了前一步中执行的操作。主要的汇编语言指令如下:

    1
    2
    movl 40(%ebx),%fs
    movl 44(%edb),%gs
  • ebx寄存器指向next_p->thread结构。代码实际上更复杂,因为当它检测到一个无效的段寄存器值时,CPU可能产生一个异常。

  • next_p->thread.debugreg数组的内容装载dr0,…,dr7中的6个调试寄存器。只有在next_p被挂起时正在使用调试寄存器,这种操作才能进行。这些寄存器不需要被保存,因为只有当一个调试器想要监控prev时prev_p->thread.debugreg才会修改。
1
2
3
4
5
6
7
8
9
if(next_p->thread.debugreg[7]){
loaddebug(&next_p->thread,0);
loaddebug(&next_p->thread,1);
loaddebug(&next_p->thread,2);
loaddebug(&next_p->thread,3);
/* 没有4和5 */
loaddebug(&next_p->thread,6);
loaddebug(&next_p->thread,7);
}
  • 如果必要,更新TSS中的I/O位图。当next_p或prev_p有其自己的定制I/O权限位图时必须这么做:

    1
    2
    if(prev_p->thread.io_bitmap_ptr|| next_p->thread.io_bitmap_ptr )
    handle_io_bitmap(&next_p->thread,&init_tss[cpu]);
  • 因为进程很少修改I/O权限位图,所以该位图在“懒”模式中被处理;当且仅当一个进程在当前时间片内实际访问I/O端口时,真实位图才被拷贝到本地CPU的TSS中。进程的定制I/O权限位图被保存在thread_info结构的io_bitmap_ptr字段指向的缓冲区中。handle_io_bitmap()函数为next_p进程设置本地CPU使用的TSS的in_bitmap字段如下:

    • 如果next_p进程不拥有自己的I/O权限位图,则TSS的io_bitmap字段被设为0x8000.
    • 如果next_p进程拥有自己的I/O权限位图,则TSS的io_bitmap字段被设为0x9000。
  • TSS的io_bitmap字段应当包含一个在TSS中的偏移量,其中存放实际位图。无论何时用户态进程试图访问一个I/O端口,0x8000和0x9000指向TSS界限之外并将因此引起”Generalprotection”异常。do_general_protection()异常处理程序将检查保存在io_bitmap字段的值:

    • 如果是0x8000,函数发送一个SIGSEGV信号给用户态进程;
    • 如果是0x9000,函数把进程位图拷贝拷贝到本地CPU的TSS中,把io_bitmap字段为实际位图的偏移(104),并强制再一次执行有缺陷的汇编指令。
  • 终止。__switch_to()C函数通过使用下列声明结束:

    1
    return prev_p;

由编译器产生的相应汇编语言指令是:

1
2
movl %edi, %eax
ret

prev_p参数被拷贝到eax,因为缺省情况下任何C函数的返回值被传递给eax寄存器。注意eax的值因此在调用__switch_to()的过程中被保护起来;这非常重要,因为调用switch_to宏时会假定eax总是用来存放被替换的进程描述符的地址。

汇编语言指令ret把栈顶保存的返回地址装入eip程序计数器。不过,通过简单地跳转到__switch_to()函数来调用该函数。因此,ret汇编指令在栈中找到标号为1的指令的地址,其中标号为1的地址是由switch_to()宏推入栈中的。如果因为next_p第一次执行而以前从未被挂起,__switch_to()就找到ret_from_fork()函数的起始地址。

保存和加载FPU,MMX和XMM寄存器

FPU是算术浮点单元,浮点算术函数是用ESCAPE指令来执行的,若一个进程在使用ESCAPE指令,那么浮点寄存器的内容也属于这个进程的硬件上下文,需要被保存;在Pentium系列中,提出MMX指令,用于加速多媒体应用程序的执行,MMX指令作用于FPU的浮点寄存器;

MMX指令之所以可以加速多媒体应用程序的执行,是因为它引入了SIMD单指令多数据流水线;Pentium 3 为SIMD提出了扩展,Streaming SIMD Extension,即SSE,它为处理包含在8个128位寄存器(XMM寄存器)的浮点值增加功能;Pentium 4又提出了SSE2,支持高精度浮点值,SSE和SSE2都是使用同一XMM寄存器集;

80x86并不会自动保存浮点寄存器(FPU,MMX,XMM),是通过CR0的TS标志位设置切换机制:

  • 每当执行硬件上下文切换时,设置TS标志
  • 每当TS标志被设置时执行ESCAPE,MMX,SSE或SSE2指令,控制单元就产生一个“device not available”异常。

TS标志使内核只有在真正需要时才保存和恢复FPU、MMX和XMM寄存器。

这些浮点寄存器的内容是保存在进程的进程描述符中的thread.i387字段中,格式由i387_union联合体描述:

1
2
3
4
union i387_union {
struct i387_fsave_struct fsave; //供具有数学协处理器和MMX单元的CPU保存浮点寄存器
struct i387_fxsave_struct fxsave;//供具有SSE和SSE2扩展功能的CPU保存浮点寄存器
struct i387_soft_struct soft; //供无数学协处理器的CPU使用(无实际的,Linux通过软件模拟

进程描述符包含两个附加的标志:

  • 包含在thread_info描述符中的status字段中的TS_USEDFPU,表示进程是否用到了FPU、MMX、XMM寄存器。
  • 包含在task_struct描述符的flags字段中的PF_USED_MATH标志,表示thread.i387是否有意义。

保存FPU处理器

__unlazp_fpu宏检查prev的TS_USEDFPU,如果被设置,内核必须保存相关的硬件上下文:

1
2
if (prev->thread_info->status & TS_USEDFPU)
save_init_fpu(prev);

save_init_fpu执行以下操作:

  • 把FPU寄存器的内容转储到prev进程描述符中,重新初始化FPU。
  • 重置prev的TS_USEDFPU标志:prev->thread_info->status &= ~TS_USEDFPU
  • stts()宏设置cr0的TS标志

装载FPU寄存器

next进程第一次试图执行ESCAPE、MMX或者SSE/SSE2指令时,控制单元产生一个“device not available”异常,内核运行math_state_restore()函数。

1
2
3
4
5
6
7
void math_state_restore() {
asm volatile ("clts); /* clear the TS flag of cr0 */
if (!(current->flags & PF_USED_MATH))
init_fpu(current);
restore_fpu(current);
current->thread.status != TS_USEDFPU;
}

这个函数清除cr0的TS标识,以便以后执行FPU、MMX或者SSE/SSE2指令时不再触发异常。如果thread.i387子字段中的内容无效,也就是说PF_USED_MATH为0,则调用init_fpu()重新设置thread.i387子字段,并把PF_USED_MATH置为1。

在内核态使用FPU、MMX和SSE/SSE2单元

在使用协处理器之前如果用户态进程使用了FPU,内核必须调用kernel_fpu_begin(),其本质是save_init_fpu()保存寄存器内容,重新设置cr0寄存器的TS标志。在使用完协处理器之后,内核必须调用kernel_fpu_end()设置cr0寄存器的TS标志。

创建进程

传统的Unix操作系统以统一的方式对待所有的进程;子进程复制父进程所拥有的资源。这种方法使进程的创建非常慢且效率低。因为子进程需要拷贝父进程的整个地址空间。实际上,子进程几乎不必读或修改父进程拥有的所有资源,在很多情况下,子进程立即调用execve(),并清除父进程仔细拷贝过来的地址空间。

现代Unix内核通过引入三种不同的机制解决了这个问题:

  • 写时复制技术允许父子进程读相同的物理页。只要两者中有一个试图写一个物理页。内核就把这个页的内容拷贝到一个新的物理页,并把这个新物理页分配给正在写的进程。
  • 轻量级进程允许父子进程共享每进程在内核的很多数据结构,如页表,打开文件表及信号处理。
  • vfork()系统调用创建的进程共享其父进程的内存地址空间。为了防止父进程重写子进程需要的数据,阻塞父进程的执行,一直到子进程退出或执行一个新程序为止。

clone()、fork()及vfork()系统调用

在linux中,轻量级进程是由名为clone()的函数创建的,这个函数使用下列参数:

  • fn:指定一个由新进程执行的函数。当这个函数返回时,子进程终止。函数返回一个整数,表示子进程的退出代码。
  • arg:指向传递给fn()函数的数据。
  • flags:各种各样的信息。低字节指定子进程结束时发送到父进程的信号代码,通常选择SIGCHLD信号。剩余的3个字节给一clone标志组用于编码,如表3-8所示。
  • child_stack:表示把用户态堆栈指针赋给子进程的esp寄存器。调用进程应该总是为子进程分配新的堆栈。
  • tls:表示线程局部存储段数据结构的地址,该结构是为新轻量级进程定义的。只有在CLONE_SETTLS标志被设置时才有意义。
  • ptid:表示父进程的用户态变量地址,该父进程具有与新轻量级进程相同的PID。只有在CLONE_PARENT_SETTID标志被设置时才有意义。
  • ctid:表示新轻量级进程的用户态变量地址,该进程具有这一类进程的PID。只有在CLONE_CHILD_SETTID标志被设置时才有意义。

clone()是在C语言库中定义的一个封装函数,它负责建立新轻量级进程的堆栈并且调用对编程者隐藏的clone()系统调用。实现clone系统调用的sys_clone()服务例程没有fn和arg参数。实际上,封装函数把fn指针存放在子进程堆栈的某个位置处,该位置就是该封装函数本身返回地址存放的位置。arg指针正好存放在子进程堆栈中fn的下面。当封装函数结束时,CPU从堆栈中取出返回地址,然后执行fn(arg)函数。

传统的fork()系统调用在Linux中是用clone()实现的,其中clone()flags参数指定为SIGCHLD信号及所有清0的clone()标志。而它的child_stack参数是父进程当前的堆栈指针。因此,父进程和子进程暂时共享同一个用户态堆栈。但是,要感谢写时复制机制,通常只要父子进程中有一个试图去改变栈,则立即各自得到用户态堆栈的一份拷贝。

do_fork()函数

do_fork()函数负责处理clone()fork()vfork()系统调用,执行时使用下列参数:

  • clone_flags:与clone()的参数flags相同
  • stack_start:与clone()的参数stack_start相同
  • regs:指向通用寄存器值的指针,通用寄存器的值是从用户态切换到内核态时被保存到内核态堆栈中的。
  • stack_size:未使用
  • parent_tidptr,child_tidptr:与clone()中的对应参数ptid和ctid相同。

do_fork()利用辅助函数copy_process()创建进程描述符以及子进程执行所需要的所有其它内核数据结构。下面是do_fork()执行的主要步骤:

  • 通过查找pidmap_array位图,为子进程分配新的PID。
  • 检查父进程的ptrace字段;如果它的值不等于0,说明有另外一个进程正跟踪父进程,因而,do_fork()检查debugger程序是否自己想跟踪子进程。在这种情况下,如果子进程不是内核线程,那么do_fork()函数设置CLONE_PTRACE标志。
  • 调用copy_process()复制进程描述符。如果所有必须的资源都是可用的,该函数返回刚创建的task_struct描述符的地址
  • 如果设置了CLONE_STOPPED标志,或者必须跟踪子进程,即在p->ptrace中设置了PT_PTRACED标志,那么子进程的状态被设置成TASK_STOPPED,并为子进程增加挂起的SIGSTOP信号。在另外一个进程把子进程的状态恢复为TASK_RUNNING之前,子进程将一直保持TASK_STOPPED状态。
  • 如果没有设置CLONE_STOPPED标志,则调用wake_up_new_task()函数以执行下述操作:
    • 调整父进程和子进程的调度参数
    • 如果子进程将和父进程运行在同一个CPU上,而且父进程和子进程不能共享同一组页表,那么,就把子进程插入父进程运行队列,插入进让子进程恰好在父进程前面,因此而迫使子进程先于父进程运行。如果子进程刷新其它地址空间,并在创建之后执行新程序,那么这种简单的处理会产生较好的性能。而如果我们让父进程先运行,那么写时复制机制将会执行一系列不必要的页复制。
    • 否则,如果子进程与父进程运行在不同的CPU上,或者父进程和子进程共享同一组页表,就把子进程插入父进程运行队列的队尾。
  • 如果CLONE_STOPPED标志被设置,则把子进程置为TASK_STOPPED状态。
  • 如果父进程被跟踪,则把子进程的PID存入current的ptrace_message字段并调用ptrace_notify()ptrace_notify()使当前进程停止运行,并向当前进程的父进程发送SIGCHLD信号。子进程的祖父进程是跟踪父进程的debugger进程。SIGCHLD信号通知debugger进程;current已经创建了一个子进程,可能通过查找current->ptrace_message字段获得子进程的PID。
  • 如果设置了CLONE_VFORK标志,则把父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间。
  • 结束并返回子进程的PID。

copy_process()函数

copy_process()创建进程描述符以及子进程执行所需要的所有其它数据结构。它的参数与do_fork()的参数相同,外加子进程的PID。下面描述copy_process()的最重要的步骤:

  • 检查参数clone_flags所传递标志的一致性。尤其是,在下列情况下,它返回错误代码:
    • CLONE_NEWNSCLONE_FS标志都被设置
    • CLONE_THREAD标志被设置,但CLONE_SIGHAND标志被清0
    • CLONE_SIGHAND标志被设置,但CLONE_VM被清0
  • 通过调用security_task_create()以及稍后调用的security_task_alloc()执行所有附加的安全检查。
  • 调用dup_task_struct()为子进程获取进程描述符。该函数执行如下操作:
    • 如果需要,则在当前进程中调用__unlazy_fpu(),把FPU、MMX和SSE/SSE2寄存器保存到父进程的thread_info结构中。稍后,dup_task_struct()把这些值复制到子进程的thread_info结构中。
    • 执行alloc_task_struct宏,为新进程获取进程描述符,并将描述符地址保存在tsk局部变量中。
    • 执行alloc_thread_info宏以获取一块空闲内存区,用来存放新进程的thread_info结构和内核栈,并将这块内存区字段的地址存在局部变量ti中。正如在本章前面”标识一个进程”一节中所述:这块内存区字段的大小是8KB或4KB。
    • 将current进程描述符的内容复制tsk所指向的task_struct结构中,然后把tsk->thread_info置为ti。
    • 把current进程的thread_info描述符的内容复制到ti所指向的结构中,然后把ti->task置为tsk。
    • 把新进程描述符的使用计数器置为2,用来表示进程描述符正被使用而且其相应的进程处于活动状态
    • 返回新进程的进程描述符指针。
  • 检查存放在current->signal->rlim[RLIMIT_NPROC].rlim_cur变量中的值是否小于或等于用户所拥有的进程数,如果是,则返回错误码,除非进程没有root权限。该函数从用户数据结构user_struct中获取用户所拥有的进程数。通过进程描述符user字段的指针可以找到这个数据结构。
  • 递增user_struct结构的使用计数器和用户所拥有的进程的计数器。
  • 检查系统中的进程数量是否超过max_threads变量的值。这个变量的缺省值取决于系统内存容量的大小。总的原则是:所有thread_info描述符和内核线程所占用的空间不能超过物理内存大小的1/8
  • 如果实现新进程的执行域和可执行格式的内核函数都包含在内核模块中,则递增它们的使用计数器。
  • 设置与进程状态相关的几个关键字段:
    • 把大内核锁计数器tsk->lock_depth初始化为-1
    • tsk->did_exec字段被始化为0;它记录了进程发出的execve()系统调用的次数
    • 更新从父进程复制到tsk->flags字段中的一些标志;首先清除PF_SUPERPRIV标志,该标志表示进程是否使用了某种超级用户权限。然后设置PF_FORKNOEXEC标志,它表示子进程还没有发出execve()系统调用。
  • 把新进程的PID存入tsk->pid字段
  • 如果clone_flags参数中的CLONE_PARENT_SHTTID标志被设置,就把子进程的PID复制到参数parent_tidptr指向的用户态变量中。
  • 初始化子进程描述符中的list_head数据结构和自旋锁,并为与挂起信号、定时器及时间计表相关的几个字段赋初值。
  • 调用copy_semundo(),copy_files(),copy_fs(),copy_sighand(),copy_signal(),copy_mmcopy_namespace()来创建新的数据结构,并把父进程相应数据结构的值复制到新数据结构中,除非clone_flasgs参数指出它们有不同的值。
  • 调用copy_thread()用发出clone()系统调用时CPU寄存器的值来初始化子进程的内核栈。不过,copy_thread()把exa寄存器对应字段的值字段强行置为0。子进程描述符的thread.esp字段初始化为子进程内核栈的基地址,汇编语言函数的地址存放在thread.eip字段中。如果父进程使用I/O权限位图,则子进程获取该位图的一个拷贝。最后,如果CLONE_SETTLE标志被设置,则子进程获取由clone()系统调用的参数tls指向的用户态数据结构所表示的TLS段。
  • 如果clone_flags参数的值被置为CLONE_CHILD_SETTIDCLONE_CHILD_CLEARTID,就把child_tidptr参数的值分别复制到tsk->set_child_tidtsk->clear_child_tid字段。这些标志说明;必须改变子进程用户态地址空间的child_tidptr所指向的变量的值,不过实际的写操作要稍后再执行。
  • 清除子进程thread_info结构的TIF_SYSCALL_TRACE标志,以使ret_form_fork()函数不会把系统调用结束的消息通知给调试进程。
  • clone_flags参数低位信号数字编码初始化tsk->exit_signal字段,如果CLONE_THREAD标志被置位,就把tsk->exit_signal字段初始化为-1。正如我们将在本章稍后“进程终止”一节所所见的,只有当线程组的最后一个成员“死亡”,才会产生一个信号,以通知线程组的领头进程的父进程。
  • 调用sched_fork()完成对新进程调度程序数据结构的初始化。该函数把新进程的状态设置为TASK_RUNNING,并把thread_info结构的preempt_count字段设置为1,从而禁止内核抢占。
  • 把新进程的thread_info结构的cpu字段设置为由smp_processor_id()所返回的本地CPU号。
  • 初始化表示亲子关系的字段。尤其是,如果CLONE_PARENTCLONE_THREAD被设置,就用current->real_parent的值初始化tsk->real_parenttsk->parent;因此,子进程的父进程是当前进程的父进程。否则,把tsk->real_parenttsk->parent置为当前进程。
  • 如果不需要跟踪子进程,就把tsk->ptrace字段设置为0。tsk->ptrace字段会存放一些标志,而这些标志是在一个进程被另一个进程跟踪时才会用到的。采用这种方式,即使当前进程被跟踪,子进程也不会被跟踪。
  • 执行SET_LINES宏,把新进程描述符插入进程链表。
  • 如果子进程必须被跟踪。就把current->parent赋给tsk->parent,并将子进程插入调试程序的跟踪链表中。
  • 调用attach_pid()把新进程描述符的PID插入pidhash[PIDTYPE_PID]散列表。
  • 如果子进程是线程组的领头进程
    • tsk->tgid的初值置为tsk->pid
    • tsk->group_leader的初值为tsk
    • 调用三次attach_pid(),把子进程分别插入PIDTYPE_TGIDPIDTYPE_PGIDPIDTYPE_SID类型的PID散列表。
  • 否则,如果子进程属于它的父进程的线程组
    • tsk->tgid的初值设置为tsk->current->tgid
    • tsk->group_leader的初值设置为current->group_leader的值。
    • 调用attach_pid(),把子进程插入PIDTYPE_TGID类型的散列表中。
  • 现在,新进程已经被加入进程集合。递增nr_threads变量的值。
  • 递增total_forks变量以记录被创建的进程的数量。
  • 终止并返回子进程描述符指针(tsk)。

内核线程

内核线程不受不必要的用户态上下文的拖累。在Linux中,内核线程在以下几方面不同于普通进程:

  • 内核线程只运行在内核态,而普通进程既可以在内核态,也可以运行在用户态。
  • 因为内核线程运行在内核态,它们只使用大于PAGE_OFFSET的线性地址空间。另一方面,不管在用户态还是在内核态,普通进程可以用4GB的线性地址空间。

创建一个内核线程

kernel_thread()函数创建一个新的内核线程,它接受的参数有:

  • 所要执行的内核函数的地址(fn)
  • 要传递给函数的参数(arg)
  • 一组clone标志(flags)

该函数本质上以下面的方式调用do_fork();

1
do_fork(flags|CLONE_VM|CLONE_UNTRACED, 0, pregs, 0, NULL, NULL);

  • CLONE_VM标志避免复制调用进程的页表;由于新内核线程无论如何都不会访问用户态地址空间,所以这种复制无疑会造成时间和空间的浪费。- - -
  • CLONE_UNTRACED标志保证不会有任何进程跟踪新内核线程,即使调用进程被跟踪。

传递给do_fork()的参数pregs表示内核栈的地址,copy_thread()函数将从这里找到为新线程初始化CPU寄存器的值。kernel_thread()函数在这个栈中保留寄存器值的目的是:

  • 通过copy_thread()把ebx和edx分别设置为参数fn和arg的值。
  • 把eip寄存器的值设置为下面汇编语言代码段的地址:
    1
    2
    3
    4
    5
    movl %edx, %eax
    pushl %edx
    call *%ebx
    pushl %eax
    call do_exit
    因此,新的内核线程开始执行fn(arg)函数,如果该函数结束,内核线程执行系统调用_exit(),并把fn()的返回值传递给它。

进程0

所有进程的祖先叫做进程0,idle进程或因为历史的原因叫做swapper进程,它是在Linux的初始化阶段从无到有创建的一个内核线程。这个祖先进程使用下列静态分配的数据结构:

  • 存放在init_task变量中的进程描述符,由INIT_TASK宏完成对它的初始化。
  • 存放在init_thread_union变量中的thread_info描述符和内核栈,由INIT_THREAD_INFO宏完成对它们的初始化。
  • 由进程描述符指向的下列表:
    • init_mm
    • init_fs
    • init_files
    • init_signhand
    • 这些表分别由下列宏初始化
      • INIT_MM
      • INIT_FS
      • INIT_FILES
      • INIT_SIGNALE
      • INIT_SIGHAND
  • 主内核页全局目录存放在swpper_pg_dir中

start_kernel()函数初始化内核需要的所有数据结构,激活中断,创建另一个叫进程1的内核线程;

1
kernel_thread(init, NULL, CLONE_FS|CLONE_SIGHAND);

  • 新创建内核线程的PID为1,并与进程0共享每进程所有的内核数据结构。此外,当调度程序选择到它时,init进程开始执行init()函数。
  • 创建init进程后,进程0执行cpu_idle()函数,该函数本质上是在开中断的情况下重复执行hlt汇编语言指令。只有当没有其它进程处于TASK_RUNNING状态时,调度程序才选择进程0。
  • 只要打开机器电源,计算机的BIOS就启动某一个CPU,同时禁止其它CPU。运行在CPU0上的swapper进程初始化内核数据结构,然后激活其它的CPU,并通过copy_process()函数创建另外的swapper进程,把0传递给新创建的swapper进程做为它们的新PID。此外,内核把适当的CPU索引赋给内核所创建的每个进程的thread_info描述符的cpu字段。

进程1

由进程0创建的内核线程执行init()函数,init()依次完成内核初始化。init()调用execve()系统调用装入可执行程序init.结果,init内核线程变成一个普通进程,且拥有自己的每进程内核数据结构。在系统关闭之前,init进程一直存活,因为它创建和监控在操作系统外层执行的所有进程的活动。

其它内核线程

Linux使用很多其它内核线程。一些内核线程的例子是:

  • keventd:执行keventd_wq工作队列中的函数
  • pmd:处理与高级电源管理相关的事件
  • kswapd:执行内存回收
  • pdflush:刷新“脏”缓冲区中的内容到磁盘以回收内存
  • kblockd:执行kblockd_workqueue工作队列中的函数。实质上,它周期性激活块设备驱动程序。
  • ksoftirqd:运行tasklet,系统中每个CPU都有这样一个内核线程。

撤销进程

进程终止的一般方式是调用exit(),该函数释放C函数库所分配的资源,执行编程者所注册的每个函数,并结束从系统回收进程的那个调用。C编译程序总是把exit()函数插入到main()函数的最后一条语句之后。

内核可以有选择地强迫整个线程组死掉。这发生在以下两种典型情况下:

  • 当进程接收到一个不能处理或忽略的信号时
  • 当内核正在代表进程运行时在内核态产生一个不可恢复的CPU异常时。

进程终止

在Linux2.6中有两个终止用户态应用的系统调用:

  • exit_group()系统调用,它终止整个线程组,即整个基于多线程的应用。do_group_exit()是实现这个系统调用的主要内核函数。这是C库函数exit()应该调用的系统调用。
  • exit()系统调用,它终止某一个线程,而不管线程所属线程组中的所有其它进程,do_exit()是实现这个系统调用的主要内核函数。这是被诸如pthread_exit()的Linux线程库的函数所调用的系统调用。

do_group_exit()函数

do_group_exit()函数杀死属于current线程组的所有进程。它接受进程终止代码作为参数,进程终止代号可能是系统调用exit_group()指定的一个值,也可能是内核提供的一个错误代号。该函数执行下述操作:

  • 检查退出进程的SIGNAL_GROUP_EXIT标志是否不为0,如果不为0,说明内核已经开始为线性组执行退出的过程。在这种情况下,就把存放在current->signal->group_exit_code的值当作退出码,然后跳转到第4步。
  • 否则,设置进程的SIGNAL_GROUP_EXIT标志并把终止代号放到current->signal->group_exit_code字段。
  • 调用zap_other_threads()函数杀死current线程组中的其它进程。为了完成这个步骤,函数扫描与current->tgid对应的PIDTYPE_TGID类型的散列表中的每PID链表,向表中所有不同于current的进程发送SIGKILL信号,结果,所有这样的进程都将执行do_exit()函数,从而被杀死。
  • 调用do_exit()函数,把进程的终止代码传递给它。正如我们将在下面看到的,do_exit()杀死进程而且不再返回。

do_exit()函数

所有进程的终止都是由do_exit()函数来处理的,这个函数从内核数据结构中删除对终止进程的大部分引用。do_exit()函数接受进程的终止代号作为参数并执行下列操作:

  • 把进程描述符的flag字段设置为PF_EXITING标志,以表示进程正在被删除。
  • 如果需要,通过函数del_timer_sync()从动态定时器队列中删除进程描述符。
  • 分别调用exit_mm()exit_sem()__exit_files()__exit_fs()exit_namespace()exit_thread()函数从进程描述符中分离出与分页、信号量、文件系统、打开文件描述符、命名空间以及I/O权限位图相关的数据结构。如果没有其它进程共享这些数据结构,那么这些函数还删除所有这些数据结构。
  • 如果实现了被杀死进程的执行域和可执行格式的内核函数包含在内核模块中,则函数递减它们的使用计数器。
  • 把进程描述符的exit_code字段设置成进程的终止代号,这个值要么是_exit()exit_group()系统调用参数,要么是由内核提供的一个错误代码。
  • 调用exit_notify()函数执行下面的操作:
    • 更新父进程和子进程的亲属关系。如果同一线程组中有正在运行的进程,就让终止进程所创建的所有子进程都变成同一线程组中另外一个进程的子进程,否则让它们成为init的子进程
    • 检查被终止进程其进程描述符的exit_signal字段是否不等于-1,并检查进程是否是其所属进程组的最后一个成员。在这种情况下,函数通过给正被终止进程的父进程发送一个信号,以通知父进程子进程死亡。
    • 否则,也就是exit_signal字段等于-1,或者线程组中还有其它进程,那么只要进程正在被跟踪,就向父进程发送一个SIGCHLD信号。
    • 如果进程描述符的exit_signal字段等于-1,而且进程没有被跟踪,就把进程描述符的exit_state字段置为EXIT_DEAD,然后调用release_task()回收进程的其它数据结构占用的内存,并递减进程描述符的使用计数器,以使进程描述符本身正好不会被释放。
    • 否则,如果进程描述符的exit_signal字段不等于-1,或进程正在被跟踪,就把exit_state字段置为EXIT_ZOMBIE
    • 把进程描述符的flags字段设置为PF_DEAD标志。
  • 调用schedule()函数选择一个新进程运行。调度程序忽略处于EXIT_ZOMBIE状态的进程,所以这种进程正好在schedule()中的宏switch_to被调用之后停止执行。

进程删除

Unix允许进程查询内核以获得其父进程的PID,或者其任何于进程的执行状态。例如,进程可以创建一个子进程来执行特定的任务,然后调用诸如wait()这样的一些库函数检查子进程是否终止。如果子进程已经终止,那么,它的终止代号将告诉父进程这个任务是否已成功地完成。

因此不允许Unix内核在进程一终止后就丢弃包含在进程描述符字段中的数据。只有父进程发出了与被终止的进程相关的wait()类系统调用之后,才允许这样做。这就是引入僵死状态的原因:尽管从技术上来说进程已死,但必须保存它的描述符,直到父进程得到通知。

release_task()函数从僵死进程的描述符中分离出最后的数据结构;对僵死进程的处理有两种可能方式;如果父进程不需要接收来自子进程的信号,就调用do_exit();如果已经给父进程发送了一个信号,就调用wait4()waitpid()系统调用。在后一种情况下,函数还将回收进程描述符所占用的内存空间,而在前一种情况下,内存的回收将由进程调度程序来完成。该函数执行下述步骤:

  • 递减终止进程拥有者的进程个数。这个值存放在本章前面提到的user_struct结构中。
  • 如果进程正在被跟踪,函数将它从调试程序的ptrace_children链表中删除,并让该进程重新属于初始的父进程。
  • 调用__exit_signal()删除所有的挂起信号并释放进程的signal_struct描述符。如果该描述符不再被其它的轻量级进程使用,函数进一步删除这个数据结构。此外,函数调用exit_itimers()从进程中剥离掉所有的POSIX时间间隔定时器。
  • 调用__exit_sighand()删除信号处理函数。
  • 调用__unhash_process(),该函数依次执行下面的操作:
    • 变量nr_threads减。
    • 两次调用detach_pid(),分别从PIDTYPE_PIDPIDTYPE_TGID类型的PID散列表中删除进程描述符。
    • 如果进程是线程组的领头进程,那么再调用两次detach_pid(),从PIDTYPE_PGIDPIDTYPE_SID类型的散列表中删除进程描述符。
    • 用宏REMOVE_LINKS从进程链表中解除进程描述符的链接。
  • 如果进程不是线程的领头进程,领头进程处于僵死状态,而且进程是线程组的最后一个成员,则该函数向领头进程的父进程发送一个信号,通知它进程已终止。
  • 调用sched_exit()函数来调整父进程的时间片。
  • 调用put_task_struct()递减进程描述符的使用计数器,如果计数器变为0,则函数终止所有残留的对进程的引用。
    • 递减进程所有者的user_struct数据结构的使用计数器,如果使用计数器变成0,就释放该数据结构。
    • 释放进程描述符以及thread_info描述符和内核堆栈所占用的内存区域。

中断和异常

中断通常被定义为一个事件,该事件改变处理器执行的指令顺序。这样的事件与CPU芯片内外部硬件电路产生的电信号相对应,通常分为同步中断和异步中断:

  • 同步中断是当指令执行时由CPU控制单元产生的,之所以称为同步,是因为只有在一条指令终止执行后CPU才会发出中断
  • 异步中断是由其它硬件设备依照CPU时钟信号随机产生的。

在Intel微处理器手册中,把同步和异步中断分别称为异常中断中断是由间隔定时器和I/O设备产生的,例如,用户的一次按键会引起一个中断。另一方面,异常是由程序的错误产生的,或者是由内核必须处理的异常条件产生的。第一种情况下,内核通过发送一个每个Unix程序员都熟悉的信号来处理异常。第二种情况下,内核执行恢复异常需要的所有步骤,例如缺页,或对内核服务的一个请求。

中断信号的作用

中断信号使处理器转而去运行正常控制流之外的代码,要在内核态堆栈保存程序计数器的当前值,并把与中断类型相关的一个地址放进程序计数器。

中断处理是由内核执行的最敏感的任务之一,因为它必须满足下列约束:

  1. 当内核正打算去完成一些别的事情时,中断随时会到来。因此,内核的目标就是让中断尽可能快地处理完,尽其所能把更多的处理向后推迟。
  2. 因为中断随时会到来,所以内核可能正在处理其中的一个中断时,另一个中断又发生了。应该尽可能多地允许这种情况发生。因此,中断处理程序必须编写成使相应的内核控制路径能以嵌套的方式执行。
  3. 尽管内核在处理前一个中断时可以接受一个新的中断,但在内核代码中还是存在一些临界区,在临界区中,中断必须被禁止。必须尽可能地限制这样的临界区,因为根据以前的要求,内核,尤其是中断处理程序,应该在大部分时间内开中断的方式运行。

中断和异常

Intel文档把中断和异常分为以下几类:

  • 中断
    • 可屏蔽中断:I/O设备发出的所有中断请求都产生可屏蔽中断。可屏蔽中断可以处于两种状态;屏蔽的非屏蔽的;一个屏蔽的中断只要还是屏蔽的,控制单元就忽略它。
    • 非屏蔽中断:只有几个危急事件才引起非屏蔽中断。非屏蔽中断总是由CPU辨认。
  • 异常
    • 处理器探测异常:当CPU执行指令时探测到的一个反常条件所产生的异常。可以进一步分为三组,这取决于CPU控制单元产生异常时保存在内核态堆栈eip寄存器中的值。
      • 故障:通常可以纠正;一但纠正,程序就可以在不失连贯性的情况下重新开始。保存在eip中的值是引起故障的指令地址。因此,当异常处理程序终止时,那条指令会被重新执行。
      • 陷阱:在陷阱指令执行后立即报告:内核把控制权返回给程序后就可以继续它的执行而不失连续性。保存在eip中的值是一个随后要执行的指令地址。只有当没有必要重新执行已终止的指令时,才触发陷阱。陷阱的主要用途是为了调试程序。在这种情况下,中断信号的作用是通知调试程序一条特殊指令已被执行。一旦用户检查到调试程序所提供的数据,它就可能要求被调试程序从下一条指令重新开始执行。
      • 异常中止:发生一个严重的错误:控制单元出了问题,不能在eip寄存器中引起异常的指令所在的确切位置。异常中止用于报告严重的错误,如硬件故障或系统表中无效的值或不一致的值。由控制单元发送的这个中断信号是紧急信号,用来把控制权切换到相应的异常中止处理程序,这个异常中止处理程度除了强制爱影响的进程终止外,没有别的选择。
      • 编程异常:在编程者发出请求时发生。是由int或int3指令触发的;当into和bound指令检查的条件不为真是,也引起编程异常。控制单元把编程异常作为陷阱来处理。编程异常也叫做软中断。这样的异常有两种常用的用途:执行系统调用及给调试程序通报一个特定的事件。

每个中断和异常是由0~255之间的一个数来标识。因为一些未知的原因,Intel把这个8位的无符号整数叫做一个向量。非屏蔽中断的向量和异常的向量是固定的,而可屏蔽中断的向量叫做一个向量。非屏蔽中断的向量和异常的向量是固定的,而可屏蔽中断的向量可以通过对中断控制器的编程来改变。

IRQ和中断

每个能够发出中断请求的硬件设备控制器都有一条名为IRQ的输出线。所有现有的IRQ线都与一个名为可编程中断控制器的硬件电路的输入引脚相连。可编程中断控制器执行下列动作:

  • 监视IRQ线,检查产生的信号。如果有条或两条以上的IRQ线上产生信号,就选择引脚编号较小的IRQ线。
  • 如果一个引发信号出现在IRQ线上:
    • 把接收到的引发信号转换成对应的向量。
    • 把这个向量存放在中断控制器的一个I/O端口,从而允许CPU通过数据总线读此向量。
    • 把引发信号发送到处理器的INTR引脚,即产生一个中断。
    • 等待,直到CPU通过把这个中断信号写进可编程中断控制器的一个I/O端口来确认它;当这种情况发生时,清INTR线。
  • 返回到第一步。

IRQ线是从0开始顺序编号的,因此,第一条IRQ线通常表示成IRQ0。与IRQn关联的Intel的缺省量是n+32。如前所述,通过向中断控制器端口发布合适的指令,就可以修改IRQ和向量之间的映射。

可以有选择地禁止每条IRQ线。因此,可以对PIC编程从而禁止IRQ。禁止的中断是丢失不了的,它们一旦被激活,PIC就又把他们发送到CPU。这个特点被大多数中断处理程序使用,因为这允许中断处理程序逐次地处理同一类型的IRQ.

有选择地激活/禁止IRQ线不同于可屏蔽中断的全局屏蔽/非屏蔽。当eflags寄存器的IF标志被清0时,由PIC发布的每个可屏蔽中断都由CPU暂时忽略。cli和sti汇编指令分别清除和设置该标志。

传统的PIC是由两片8259A风格的外部芯片以“级联”的方式连接在一起的。每个芯片可以处理多达8个不同的IRQ输入线。因为从PIC的INT输出线连接到主PIC的IRQ2引脚,因此,可用IRQ线的个数限制为15.

高级可编程中断控制器

为了充分发挥SMP体系结构的并行性,能够把中断传递给系统中的每个CPU至关重要。基于此理由,Intel从Pentium III开始引入了一种名为I/O高级可编程控制器(IO APIC)的新组件,用以代替老式的8259A可编程中断控制器。

来自外部硬件设备的中断请求以两种方式在可用CPU之间分发:

  • 静态分发
    • IRQ信号传递给重定表相应项中所列出的本地APIC。中断立即传递给一个特定的CPU,或一组CPU,或所有CPU。
  • 动态分发
    • 如果处理器正在执行最低优先级的进程,IRQ信号就传递给这种处理器的本地APIC每个本地APIC都有一个可编程任务优先级寄存器TRP,TPR用来计算当前运行进程的优先级。Intel希望在操作系统内核中通过每次进程切换对这个寄存器进行修改。
    • 如果两个或多个CPU共享最低优先级,就利用仲裁技术在这些CPU之间分配负荷。在本地APIC的仲裁优先级寄存器中,给每个CPU都分配一个0~15范围内的值。
    • 每当中断传递给一个CPU时,其相应的仲裁优先级就自动置为0,而其它每个CPU的仲裁优先级都增加1.当仲裁优先级寄存器大于15时,就把它置为获胜CPU的前一个仲裁优先级加1。因此,中断以轮转方式在CPU之间分发,且具有相同的相同的任务优先级。

除了在处理器之间分发中断外,多APIC系统还允许CPU产生处理器间中断。当一个CPU希望把中断发给另一个CPU时,它就在自己本地APIC的中断指令寄存器中存放这个中断向量和目标本地APIC的标识符。然后,通过APIC总线向目标本地APIC发送一条消息,从而向自己的CPU发出一个相应的中断。

目前大部分单处理器系统都包含一个I/O APIC芯片,可以用以下两种方式对这种芯片进行配置:

  • 作为一种标准8259A方式的外部PIC连接到CPU。本地APIC被禁止,两条LINT0和LINT1本地IRQ线分别配置为INTR和NMI引脚。
  • 作为一种标准外部I/O APIC.本地APIC被激活,且所有的外部中断都通过I/O APIC接收。

异常

80x86微处理器发布了大约20种不同的异常。内核必须为每种异常提供一个专门的异常处理程序。对于某此异常,CPU控制单元在开始执行异常处理程序前会产生一个硬件出错码,并且压入内核态堆栈。下面的列表给出了在80x86处理器中可以找到的异常的向量、名字、类型及其简单描述。

  • 0 – “Divide error” 故障
    • 当一个程序试图执行整数被0除操作时产生。
  • 1 – “Debug” (陷阱或故障)
    • 产生于:(1)设置eflags的TF标志时,(2)一条指令或操作数的地址落在一个活动debug寄存器的范围之内。
  • 2 – 未用
    • 为非屏蔽中断保留
  • 3 – “Breakpoint” 陷阱
    • 由int3断点指令(通常由debugger插入)引起。
  • 4 – “overflow” 陷阱
    • 当eflags的OF标志被设置时,into指令被执行。
  • 5 – “Bounds check” 故障
    • 对于有效地址范围之外的操作数,bound指令被执行。
  • 6 – “Invalid opcode” (故障)
    • CPU执行单元检测到一个无效的操作友。
  • 7 – “Device not availabe”(故障)
    • 随着cr0的TS标志被设置,ESCAPE、MMX或XMM指令被执行。
  • 8 – “Double fault”(异常中止)
    • 正常情况下,当CPU下试图为前一个异常调用处理程序时,同时又检测到一个异常,两个异常能被串行地处理。然而,在少数情况下,处理器不能串行地处理它们,因而产生这种异常。
  • 9 – “Coprocessor segment overrun” (异常中止)
    • 因外部的数学协处理器引起的问题。
  • 10 – “Invalid Tss” 故障
    • CPU试图让一个上下文切换到有无效的TSS的进程。
  • 11 – “Segment not present”故障
    • 引用一个不存在的内存段。
  • 12 – “Stack segment fault” 故障
    • 试图超过栈段界限的指令,或者由ss标识的段不在内存。
  • 13 – “General protection” 故障
    • 违反了80x86保护模式下的保护规则之一。
  • 14 – “Page fault” 故障
    • 寻址的页不在内存,相应的页表项为空,或者违反了一种分页保护机制。
  • 15 – 由Intel保留
  • 16 – “Floating point error” 故障
    • 集成到CPU芯片中的浮点单元用信号通知一个错误情形,如数学溢出,或被0除。
  • 17 – “Alignment check” 故障
    • 操作数的地址没有被正确地对齐。
  • 18 – “Machine check” 异常中止
    • 机器检查机制检测出一个CPU错误或总线错误。
  • 19 – “SIMD floating point exception” 故障
    • 集成到CPU芯片中的SSE工SSE2单元对浮点操作用信号通知一个错误情形。
  • 20-31这些值由Intel留作将来开发。如表4-1所示,每个异常都由专门的异常处理程序来处理,它们通常把一个Unix信号发送到引起异常的进程。

中断描述符表

中断描述表(IDT)是一个系统表,它与每一个中断或异常向量相联系,每一个向量在表中有相应的中断或异常处理程序的入口地址。内核在允许中断发生前,必须适当地初始化IDT。IDT表中的每一项对应一个中断或异常向量,每个向量由8个字节组成。因此,最多需要256*8=2048字节来存放IDT。

idtrCPU寄存器使IDT可以位于内存的任何地方,它指定IDT的线性基地址及其限制。在允许中断之间,必须有lidt汇编指令初始化idtr。IDT包含三种类型的描述符,图4-2显示了每种描述符中的64位的含义。尤其值得注意的是,在40-43位的Type字段的值表示描述符的类型。

这些描述符是:

  • 任务门
    • 当中断信号发生时,必须取代当前进程的哪个进程的TSS选择符存放在任务门中。
  • 中断门
    • 包含段选择符和中断或异常处理程序的段内偏移量。当控制权转移到一个适当的段时,处理器清IF标志,从而关闭将来会发生的可屏蔽中断。
  • 陷阱门
    • 与中断门相似,只要控制权传递到一个适当的段时处理器不修改IF标志。

中断和异常的硬件处理

在处理指令之前,控制单元会检查在运行前一条指令时是否已经发生了一个中断或异常。如果发生了一个中断或异常,那么控制单元执行下列操作:

  • 确定与中断或异常关联的向量i
  • 读由idtr寄存器指向的IDT表中的第i项。
  • gdtr寄存器获得GDT的基地址,并在GDT中查找,以读取IDT表项中的选择符所标识的段描述符。这个描述符指定中断或异常处理程序所在段的基地址。
  • 确信中断是由授权的发生源发出的。首先将在当前特权级CPL与段描述符的描述符特权级DPL比较,如果CPL小于DPL,就产生一个”General proection”异常,因为中断处理程序的特权不能低于引起中断的程序的特权。对于编程异常,则做进一步的安全检查:比较CPL与处于IDT中的门描述符的DPL,如果DPL小于CPL,就产生一个”General protection”异常。这最后一个检查可以避免用户应用程序访问特殊的陷阱门或中断门
  • 检查是否发生了特权级的变化,也就是说,CPL是否不同于所选择的段描述符的DPL。如果是,控制单元必须开始使用与新的特权级相关的栈。通过执行以下步骤来做到这点:
    • 读tr寄存器,以访问运行进程的TSS段。
    • 用与新特权级相关的栈段和栈指针的正确值装载ss和esp寄存器。这些值可以在TSS中找到。
    • 在新栈中保存ss和esp以前的值,这些值定义了与旧特权级相关的栈的逻辑地址。
  • 如果故障已发生,用引用异常的指令地址装载cs和 eip寄存器,从而使得这条指令能再次被执行。
  • 在栈中保存eflags、cs及eip的内容。
  • 如果异常产生了一个硬件出错码,则将它保存在栈中。
  • 装载cs和eip寄存器,其值分别为IDT表中第i项门描述符的段选择符和偏移量字段。这些值给出了中断或者异常处理程序的第一条指令的逻辑地址。

控制单元所执行的最后一步就是跳转到中断或者异常处理程序。换句话说,处理完中断信号后,控制单元所执行的指令就是被选中处理程序的第一条指令。中断或异常被处理完后,相应的处理程序必须产生一条iret指令,把控制权交给被中断的进程,这将迫使控制单元:

  • 用保存在栈中的值装载cs、eip或eflags寄存器。如果一个硬件出错码曾被压入栈中,并且在eip内容的上面,那么,执行iret指令前必须先弹出这个硬件出错码。
  • 检查处理程序的CPU是否等于cs中最低两位的值。如果是,iret终止执行;否则,转入下一步。
  • 从栈中装载ss和esp寄存器,因此,返回到与旧特权级相关的栈。
  • 检查ds、es、fs及gs段寄存器的内容,如果其中一个寄存器包含的选择符是一个段选择符,并且其DPL值小于CPL,那么,清相应的段寄存器。控制单元这么做是为了禁止用户态的程序利用内核以前所用的段寄存器。如果不清这些寄存器,怀有恶意的用户态程序就可能利用它们来访问内核地址空间。

中断和异常处理程序的嵌套执行

每个中断或异常都会引起一个内核控制路径,或者说代表当前进程在内核态执行单独的指令序列。例如:当I/O设备发出一个中断时,相应的内核控制路径的第一部分指令就是那些把寄存器的内容保存到内核堆栈的指令,而最后一部分指令就是恢复寄存器内容并让CPU返回到用户态的那些指令。

内核控制路径可以任意嵌套;一个中断处理程序可以被另一个中断处理程序”中断”,因此引起内核控制路径的嵌套执行,如图4-3所示。其结果是,对中断进行处理的内核控制路径,其最后一部分指令并不总能使当前进程返回到用户态;如果嵌套深度大于1,这些指令将执行上次被打断的内核控制路径,此时的CPU依然运行在内核态

允许内核控制路径嵌套执行必须付出代价,那就是中断处理程序必须永不阻塞,换句话说,中断处理程序运行期间不能发生进程切换。事实上,嵌套的内核控制路径恢复执行时需要的所有数据都存放在内核态堆栈中,这个栈毫无疑义的属于当前进程。

与异常形成对照的是,尽管处理中断的内核控制路径代表当前进程运行,但由I/O设备产生的中断并不引用当前进程的专有数据结构一个中断处理程序既可以抢占其它的中断处理程序,也可以抢占异常处理程序。相反,异常处理程序从不抢占中断处理程序。在内核态能触发的唯一异常就是刚刚描述的缺页异常。但是,中断处理程序从不执行可以导致缺页的操作。

基于以下两个主要原因,Linux交错执行内核控制路径:

  • 为了提高可编程中断控制器和设备控制器的吞吐量,假定设备控制器在一条IRQ线上产生了一个信号,PIC把这个信号转换成一个外部中断,然后PIC和设备控制器保持阻塞,一直到PIC从CPU处接收到一条应答信息。由于内核控制路径的交替执行,内核即使正在处理前一个中断,也能发送应答。
  • 为了实现一种没有优先级的中断模型。因为每个中断处理程序都可以被另一个中断处理程序延缓,因此,在硬件设备之间没必要建立预定义优先级。这就简化了内核代码,提高了内核的可移植性。

在多处理器系统上,几个内核控制路径可以并发执行。此外,与异常相关的内核控制路径可以开始在一个CPU上执行,并且由于进程切换而移往另一个CPU上执行。

初始化中断描述表

内核启用中断以前,必须把IDT表的初始地址装到idtr寄存器,并初始化表中的每项。这项工作是在初始化系统时完成的。

int指令允许用户态进程发出一个中断信号,其值可以是0~255的任意一个向量。因此,为了防止用户通过int指令模拟非法的中断和异常,IDT的初始化必须非常小心。这可以通过把中断或陷阱门描述符的DPL字段设置成0来实现。如果进程试图发出其中的一个中断信号,控制单元将检查出CPL的值与DPL字段有冲突,并且产生一个”General protection”异常

然而,在少数情况下,用户态进程必须能发出一个编程异常。为此,只要把中断或陷阱门描述符的DPL字段设置成3,即特权级尽可能一样高就足够了。现在,让我们来看一下Linux是如何实现这种策略的。

中断门、陷阱门及系统门

与在前面”中断描述符表”中所提到的一样,Intel提供了三种类型的中断描述符:任务门中断门陷阱门描述符。Linux使用与Intel稍有不同的细目分类和术语,把它们如下进行分类:

  • 中断门
    • 用户态的进程不能访问的一个Intel中断门。所有的Linux中断处理程序都通过中断门激活,并全部限制在内核态。
  • 系统门
    • 用户态的进程可以访问的一个Intel陷阱门。通过系统门来激活三个Linux异常处理程序,它们的向量是4,5及128。因此,在用户态下,可以发布into、bound及int$0x80三条汇编语言指令。
  • 系统中断门
    • 能够被用户态进程访问的Intel中断门。与向量3相关的异常处理程序是由系统中断门激活的,因此,在用户态可以使用汇编语言指令int3。
  • 陷阱门
    • 用户态的进程不能访问的一个Intel陷阱门。大部分Linux异常处理程序都通过陷阱门来激活。
  • 任务门
    • 不能被用户态进程访问的Intel任务门。Linux对”Double fault”异常的处理程序是由任务门激活的。

下列体系结构相关的函数用来IDT中插入门:

  • set_intr_gate(n,addr)
    • 在IDT的第n个表项插入一个中断门。门中的段选择符设置成内核代码的段选择符,偏移量设置成中断处理程序的地址addr,DPL字段设置成0。
  • set_system_gate(n,addr)
    • 在IDT的每n个表项插入一个陷阱门。门中的段选择符设置成内核代码的段选择符,偏移量设置成中断处理程序的地址addr,DPL字段设置成0。
  • set_system_intr_gate(n,addr)
    • 在IDT的第n个表项插入一个中断门。门中的段选择符设置成内核代码的段选择符,偏移量设置成中断处理程序的地址addr,DPL字段设置成0。
  • set_trap_gate(n,addr)
    • 与前一个函数类似,只不过DPL的字段设置成0。
  • set_task_gate(n,gdt)
    • 在IDT的第n个表项插入一个中断门。门中的段选择符存放一个TSS的全局描述符指针,该TSS中包含要被激活的函数,偏移量设置成0,DPL字段设置成3。

IDT的初步初始化

IDT存放在idt_table表中,有256个表项。6字节的idt_descr变量指定了IDT的大小和它的地址,只有当内核用lidt汇编指令初始化idtr寄存器时才用到这个变量。在内核初始化过程中,setup_idt()汇编函数用同一个中断门来填充所有这256个idt_table表项。

用汇编语言写成的ignore_int()中断处理程序,可以看作一个空的处理程序,它执行下列动作:

  1. 在栈中保存一些寄存器的内容。
  2. 调用printk()函数打印”Unknown interrupt”系统消息。
  3. 从栈恢复寄存器的内容。
  4. 执行iret指令以恢复被中断的程序。

ignore_int()处理程序应该从不被执行,在控制台或日志文件中出现的“Unknown interrupt”消息标志着要么是出现了一个硬件问题,要么就是出现了一个内核的问题。

紧接着这个预初始化,内核将在IDT中进行第二遍初始化,用有意义的陷阱和断处理程序替换这个空处理程序。一旦这个过程完成,对控制单元产生的每个不同的异常,IDT都有一个专门的陷阱或系统门,而对于可编程中断控制器确认的每一个IRQ,IDT都将包含一个专门的中断门。

异常处理

在两种情况下,Linux利用CPU异常更有效地管理硬件资源。

  • 第一种情况:“Device not availeble”异常与cr0寄存器的TS标志一起用来把新值装入浮点寄存器。
  • 第二种情况指的是“PageFault”异常,该异常推迟给进程分配新的页框,直到不能再推迟为止。相应的处理程序比较复杂,因为异常可能表示一个错误条件,也可能不表示一个错误条件。

异常处理程序有一个标准的结构,由以下三部分组成:

  • 在内核堆栈中保存大多数寄存器的内容。
  • 用高级C函数处理异常。
  • 通过ret_from_exception()函数从异常处理程序退出。

为了利用异常,必须对IDT进行适当的初始化,使得每个被确认的异常都有一个异常处理程序。trap_init()函数的工作是将一些最终值插入到IDT的非屏蔽中断及异常表项中。这是由函数set_trap_gate()set_intr_gate()set_system_gate()set_system_intr_gate()set_task_gate()来完成的。

由于”Double fault”异常表示内核有严重的非法操作,其处理是通过任务门而不是陷阱门或系统门来完成的,因而,试图显示寄存器值的异常处理程序并不确定esp寄存器的值是否正确。产生这种异常的时候,CPU取出存放在IDT第8项中的任务门描述符,该描述符指向存放在GDT表第32项中TSS段描述符。然后,CPU用TSS段中的相关值装载eip和esp寄存器,结果是:处理器在自己的私有栈上执行doublefault_fn()异常处理函数。

为异常处理程序保存寄存器的值

让我们用handler_name来表示一个通用的异常处理程序的名字。每一个异常处理程序都以下列的汇编指令开始:

1
2
3
4
handle_name:
pushl $0
pushl $do_handler_name
jmp error_code

当异常发生时,如果控制单元没有自动地把一个硬件出错代码插入到栈中,相应的汇编语言片段会包含一条pushl $0指令,在栈中垫上一个空值。然后,把高级C函数的地址压栈中,它的名字由异常处理程序名与do_前缀组成。

标号为error_code的汇编语言片段对所有的异常处理程序都是相同的。除了“Devicenot available”这一个异常。这段代码执行以下步骤:

  • 把高级C函数可能用到的寄存器保存在栈中。
  • 产生一条cld指令来清eflags的方向标志DF,以确保调用字符串指令时会自动增加edi和esi寄存器的值。
  • 把栈中位于esp+36处的硬件出错码拷贝到edx中,给栈中这一位置存上值-1,这个值用来把0x80异常与其它异常隔离开。
  • 把保存在栈中esp+32位置的do_handler_name()高级C函数的地址装入edi寄存器中,然后,在栈的这个位置写入es的值。
  • 把内核栈的当前栈顶拷贝到eax寄存器。这个地址表示内存单元的地址,在这个单元中存放的是第1步所保存的最后一个寄存器的值。
  • 把用户数据段的选择符拷贝到ds和es寄存器中。
  • 调用地址在edi中的高级C函数。
  • 被调用的函数从eax和edx寄存器而不是从栈中接收参数。

进入和离开异常处理程序

大部分函数把硬件出错码和异常向量保存在当前进程的描述符中,然后,向当前进程发送一个适当的信号。用代码描述如下:

1
2
3
current->thread.error_code = error_code;
current->thread.trap_no = vector;
force_sig(sig_number,current);

异常处理程序刚一终止,当前进程就关注这个信号。该信号要么在用户态由进程自己的信号处理程序来处理,要么由内核来处理。在后面这种情况下,内核一般会杀死这个进程。

异常处理程序总是检查异常是发生在用户态还是在内核态,在后一种情况下,还要检查是否由系统调用的无效参数引起出现在内核态的任何其它异常都是由于内核的bug引起的。在这种情况下,异常处理程序认为是内核行为失常了。为了避免硬盘上的数据崩溃,处理程序调用die()函数,该函数在控制台上打印出所有CPU寄存器的内容,并调用do_exit()来终止当前进程。

当执行异常处理的C函数终止时,程序执行一条jmp指令以跳转到ret_from_exception()函数。

中断处理

中断处理依赖于中断类型。就我们的目的而言,我们将讨论三种主要的中断类型:

  • I/O中断
    • 某些I/O设备需要关注;相应的中断处理程序必须查询设备以确定适当的操作过程。我们在后面”I/O中断处理”一节将描述这种中断。
  • 时钟中断
    • 某种时钟产生一个中断;这种中断告诉内核一个固定的时间间隔已经过去。这些中断大部分是作为I/O中断来处理的。
  • 处理器间中断
    • 多处理器系统中一个CPU对另一个CPU发出一个中断。

I/O中断处理

中断处理程序的灵活性是以两种不同的方式实现的,讨论如下:

  • IRQ共享
    • 中断处理程序执行多个中断服务例程。每个ISR是一个与单独设备相关的函数。因为不可能预先知道哪个特定的设备产生IRQ,因此,每个ISR都被执行,以验证它的设备是否需要关注;如果是,当设备产生中断时,就执行需要执行的所有操作。
  • IRQ动态分配
    • 一条IRQ线在可能的最后时刻与一个设备驱动程序相关联;例如,软盘设备的IRQ线只有在用户访问软盘设备时才被分配。这样,即使几个硬件设备并不共享IRQ线,同一个IRQ向量也可以由这几个设备在不同时刻使用。

需要时间长的、非重要的操作应该推后,因为当一个中断处理程序正在运行时,相应的IRQ线上发出的信号就被暂时忽略。更重要的是,中断处理程序是代表进程执行的,它所代表的进程必须总处于TASK_RUNNING状态,否则,就可能出现系统僵死情形。因此,中断处理程序不能执行任何阻塞过程。因此,Linux把紧随中断要执行的操作分为三类:

  • 紧急的
    • 它们必须尽快地执行。紧急操作要在一个中断处理程序内立即执行,而且是在禁止可屏蔽中断的情况下。
  • 非紧急的
    • 这样的操作诸如:修改那些只有处理器才会访问的数据结构。这些操作也要很快地完成,因此,它们由中断处理程序立即执行,但必须是在开中断的情况下。
  • 非紧急可廷迟的
    • 这样的操作诸如:把缓冲区的内容拷贝到某个进程的地址空间。这些操作可能被廷迟较长的时间间隔而不影响内核操作。

不管引起中断的电路种类如何,所有的I/O中断处理程序都执行四个相同的基本操作:

  • 在内核态堆栈中保存IRQ的值和寄存器的内容
  • 为正在给IRQ线服务的PIC发送一个应答,这将允许PIC进一步发出中断。
  • 执行共享这个IRQ的所有设备的中断服务例程
  • 跳到ret_from_intr()的地址终止。

当中断发生时,需要用几个描述符表示IRQ线的状态和需要执行的函数。图4-4以示意图的方式展示了处理一个中断的硬件电路和软件函数。

中断向量

Linux使用向量128实现系统调用。IBM PC兼容的体系结构要求,一些设备必须被静态地连接到指定的IRQ线。尤其是:

  • 间隔定时设备必须连到IRQ0线。
  • 从8259 APIC必须与IRQ2线相连。
  • 必须把外部数学协处理器连接到IRQ13线。
  • 一般而言,一个I/O设备可以连接到有限个IRQ线。

为IRQ可配置设备选择一条线有三种方式:

  • 设置一些硬件跳接器。
  • 安装设备时执行一个实用程序。这样的程序可以让用户选择一个可用的IRQ号,或者探测系统自身以确定一个可用的IRQ号。
  • 在系统启动时执行一个硬件协议。外设宣布它们准备使用哪些中断线,然后协商一个最终的值以尽可能减少冲突。该过程一旦完成,每个中断处理程序都能过访问设备某个I/O端口的函数。

表4-3显示了设备和IRQ之间一种相当随意的安排,你或许能在某个PC中找到同样的排列。

内核必须在启用中断前发现IRQ号与I/O设备之间的对应,IRQ号与I/O设备之间的对应是在初始化每个设备驱动程序时建立的。

IRQ数据结构

每个中断向量都有它自己的irq_desc_t描述符,其字段在表4-4中列出。所有的这些描述符组织在一起形成irq_desc数组。

如果一个中断内核没有处理,那么这个中断就是个意外中断。通常,内核检查从IRQ线接收的意外中断的数量,当这条IRQ线连接的有故障设备没完没了地发中断时,就禁用这条IRQ线。由于几个设备可能共享IRQ线,内核不会在每检测到一个意外中断时就立刻禁用IRQ线,更合适的办法是:内核把中断和意外中断的总次数分别存放在irq_desc_t描述符的irq_countirqs_unhandled字段中,当第100000次中断时,如果意外中断的次数超过99900,内核才禁用这条IRQ线

描述IRQ线状态的标志列在表4-5中。

irq_desc_t描述符的depth字段和IRQ_DISABLED标志表示IRQ线是否被禁用。每次调用disable_irq()disable_irq_nosync()函数,depth字段的值增加,如果depth等于0,函数禁用IRQ线并设置它的IRQ_DISABLED标志,相反,每当调用enable_irq()函数,depth字段的值减少,如果depth变为0,函数激活IRQ线并清除IRQ_DISABLED标志。

在系统初始化期间,init_IRQ()函数把每个IRQ主描述符的status字段设置成IRQ_DISABLED。此外,init_IRQ()调用替换由setup_idt()所建立的中断门来更新IDT。这是能过下列语句实现的:

1
2
3
for(i = 0;i < NR_IRQS ; i++)
if(i+32!=128)
set_intr_gate(i+32,interrupt[i]);

这段代码在interrupt数组中找到用于建立中断门的中断处理程序地址。interrupt数组中的第n项中存放IRQn的中断处理程序的地址。

定义PIC对象的数据结构叫做hw_interrupt_type。假定我们的计算机是有两片8259APIC的单处理机,它提供16个标准的IRQ。在这种情况下,有16个irq_desc_t描述符,其中每个描述符的handler字段指向描述8259APIC的i8259A_irq_type变量。这个变量被初始化:

这个结构中的第一个字段”XT-PIC”是PIC的名字。接下来就是用于对PIC编程的六个不同的函数指针。前两个函数分别启动和关闭芯片的IRQ线。但是,在使用8259A芯片的情况下,这两个函数的作用与第三、四个函数都是一样的,每三,四函数是启用和禁用IRQ线mask_and_ack_8259A()函数通过把适当的字节发往8259AI/O端口来应答所接收的IRQ。end_8259A_irq()函数在IRQ的中断处理程序终止时被调用。最后一个set_affinity()方法置为空:它用在多处理器系统中以声明特定IRQ所在CPU的”亲和力”,也就是说,那些CPU被启用来处理特定的IRQ。

如前所述,多个设备能共享一个单独的IRQ。因此,内核要维护多个irqaction描述符,其中的每个描述符涉及一个特定的硬件设备和一个特定的中断。包含在这个描述符中的字段如表4-6所示,标志如表4-7所示。

最后,irq_start数组包含NR_CPUS个元素,系统中的每个CPU对应一个元素。每个元素的类型为irq_cpustat_t,该类型包含几个计数器和内核记录CPU正在做什么的标志。(见表4-8)

IRQ在多处理器系统上的分发

Linux遵循对称多处理模型;这就意味着,内核从本质上对任何一个CPU都不应该有偏爱。因而,内核试图以轮转的方式把来自硬件设备的IRQ信号在所有CPU之间分发。因此,所有CPU服务于I/O中断的执行时间片几乎相同。

在系统启动的过程中,引导CPU执行setup_IO_APIC_irqs()函数来初始化I/OAPIC芯片。芯片的中断重定向表的24项被填充,以便根据”最低优先级”模式把来自I/O硬件设备的所有信号都传递给系统中的每个CPU。此外,在系统启动期间,所有的CPU都执行setup_local_apic()函数,该函数处理本地APIC的初始化。特别是,每个芯片的任务优先级寄存器都初始化为一个固定的值,这就意味着CPU愿意处理任何类型的IRQ信号,而不是其优先级。Linux内核启动以后再也不修改这个值。

内核线程为多APIC系统开发了一种优良特性,叫做CPU的IRQ亲和力通过修改I/OAPIC的中断重定向表表项,可以把中断信号发送到某个特定的CPU上set_ioapic_affinity_irq()函数用来实现这一功能,该函数有两个参数;被重定向的IRQ向量和一个32位掩码。系统管理员通过文件/proc/irq/n/smp_affinity中写入新的CPU位图掩码也可以改变指定中断IRQ的亲和力。

多种类型的内核栈

就像在第三章”标识一个进程”一节所提到的,每个进程的thread_info描述符与thread_union结构中的内核栈紧邻,而根据内核编译的选项不同,thread_union结构可能占一个页框或两个页框。如果thread_union结构的大小为8KB,那么当前进程的内核栈被用于所有类型的内核控制路径:异常、中断和可廷迟的函数。相反,如果thread_union结构的大小为4KB,内核就使用三种类型的内核栈:

  • 异常栈,用于处理异常。这个栈包含在每个进程的thread_union数据结构中,因此对系统中的每个进程,内核使用不同的异常栈。
  • 硬中断请求栈,用于处理中断。系统中的每个CPU都有一个硬中断请求栈,而且每个栈占用一个单独的页框。
  • 软中断请求栈,用于处理可廷迟的函数。系统中的每个CPU都有一个软中断请求栈,而且每个栈占用一个单独的页框。

所有的硬中断请求存放在hardirq_stack数组中,而所有的软中断请求存在softirq_stack数组中,每个数组元素都是跨越一个单独页框的irq_ctx类型的联合体。thread_info结构存放在这个页的低部,栈使用其余的内存空间,注意每个栈向低地址方向增长。

handirq_ctxsoftirq_ctx数组使内核能快速确定指定CPU的硬中断请求栈和软中断请求栈,它们包含的指针分别指向相应的irq_ctx元素。

为中断处理程序保存寄存器的值

保存寄存器是中断处理程序做的第一件事情。如前所述,IRQn中断处理程序的地址开始存在interrupt[n]中,然后复制到IDT相应表项的中断门中。

通过文件arch/i386/kernel/entry.S中的几条汇编语言指令建立interrupt数组,数组包括NR_IRQS个元素,这里NR_IRQS宏产生的数为224或16,当内核支持新近的I/OAPIC芯片时,NR_IRQS宏产生的数为224,而当内核支持旧的8259A可编程控制器芯片是,NR_IRQS宏产生数是16。数组中索引为n的元素中存放下面两条汇编语言指令的地址

1
2
pushl $n-256
jmp common_interrup

结果是把中断号减256的结果保存在栈中。内核用负数表示所有的中断,因为正数用来表示系统调用。当引用这个数时,可以对所有的中断处理程序都执行相同的代码。这段代码开始于标签common_interrupt处,包括下面的汇编语言宏和指令。
1
2
3
4
5
common_interrupt:
SAVE_ALL
movl %esp, %eax
call do_IRQ
jmp ret_from_intr

SAVE_ALL宏依次展开成下列片段:
1
2
3
4
5
6
7
8
9
10
11
12
13
cld
push %es
push %ds
push %eax
push %ebp
push %edi
push %esi
push %edx
push %ecx
push %ebx
movl$__USER_DS,%edx
movl%edx,%ds
movl%edx,%es

SAVE_ALL可以在栈中保存中断处理程序可能会使用的所有CPU寄存器,但eflags、cs、eip、ss及esp除外。因为这几个寄存器已经由控制单元自动保存了,然后,这个宏把用户数据段的选择符装到ds和es寄存器。然后,这个宏把用户数据段的选择符装到ds和esp寄存器。

保存寄存器的值以后,栈顶的地址被存放到eax寄存器中,然后中断处理程序调用do_IRQ()函数。执行do_IRQ()的ret指令时,控制转到ret_from_intr()

do_IRQ()函数

调用do_IRQ()函数执行与一个中断相关的所有中断服务例程。该函数声明为:

1
__attribut__((regparm(3)))unsigned int do_IRQ(struct pt_regs *regs)

关键字regparm表示函数到eax寄存器中去找到参数regs的值。如上所见,eax指向被SAVE_ALL最后压入栈的哪个寄存器在栈中的位置。

do_IRQ()函数执行下面的操作:

  • 执行irq_enter()宏,它使表示中断处理程序嵌套数量的计数器递增。计数器保存在当前进程thread_info结构的preempt_count字段中。
  • 如果thread_union结构的大小为4KB,函数切换到硬中断请求栈,并执行下面这些特殊步骤:
    • 执行current_thread_info()函数以获取与内核栈相连的thread_info描述符的地址。
    • 把上一步获取的thread_info描述符的地址与存放在harding_ctx[smp_processor_id()]中的地址相比较,如果两个地址相等,说明内核已经在使用硬中断请求栈,因此跳转到第3步,这种情况发生在内核处理另外一个中断时又产生了中断请求的时候
    • 这一步必须切换内核栈。保存当前进程描述符指针,该指针在本地CPU的irq_ctx联合体中的thread_info描述符的task字段中。完成这一步操作就能在内核使用硬件中断请求栈时使当前宏预先的期望工作。
    • 把esp栈指针寄存器的当前值存入本地CPU的irq_ctx联合体的thread_info描述符的previosu_esp字段中。
    • 把本地CPU硬中断请求栈的栈顶装入esp寄存器;以前esp的值存入ebx 寄存器。
  • 调用__do_IRQ()函数,把指针regs和regs->orig_eax字段中的中断号传递给该函数
  • 如果在上面的第2e步已经成功地切换到硬中断请求栈,函数把ebx寄存器中的原始栈指针拷贝到esp寄存器,从而回到以前在用的异常栈或软中断请求栈。
  • 执行宏irq_exit(),该宏递减中断计数器并检查是否有可廷迟函数正等待执行。
  • 结束;控制转向ret_from_intr()函数。

__do_IRQ()函数

__do_IRQ()函数接受IRQ号和指向pt_regs结构的指针作为它的参数。函数相当于下面的代码段;

在访问主IRQ描述符之前,内核获得相应的自旋锁。在多处理器系统上,这个锁是必要的,因为同类型的其它中断可能产生,其它CPU可能关注新中断的出现。没有自旋锁,主IRQ描述符会被几个CPU同时访问。

获得自旋锁后,函数就调用主IRQ描述符的ack方法。如果使用旧的8259APIC,相应的mask_and_ack_8259A()函数应答PIC上的中断,并禁用这条IRQ线。屏蔽IRQ线是为了确保在这个中断处理程序结束前,CPU不进一步接受这种中断的出现。请记住,__do_IRQ()函数是以禁止本地中断运行的;事实上,CPU控制单元自动清eflags寄存器IF标志,因为中断处理程序是通过IDT中断门调用的。

然而,在使用I/O高级可编程中断控制器时,事情更为复杂。应答中断依赖于中断类型,可能是由ack方法做,也可能廷迟到中断处理程序结束。在任何一种情况下,我们都认为中断处理程序结束前,本地APIC不进一步接收这种中断,尽管这种中断的进一步出现可能被其它的CPU接受。

然后,__do_IRQ()初始化主IRQ描述符的几个标志,设置IRQ_PENDING,是因为中断已经被应答,但是还没有被真正处理;也清除IRQ_WAITINGIRQ_REPLAY标志。现在,__do_IRQ()函数检查是否必须真正地处理中断。在三种情况下什么也不做,这在下面给予讨论:

  • IRQ_DISABLED被设置
    • 即使相应的IRQ线被禁止,CPU也可能执行__do_IRQ()函数;
  • IRQ_INPROGRESS被设置
    • 在多处理器系统中,另一个CPU可能处理同一个中断的前一次出现。因为设备驱动程序的中断服务例程不必是可重入的。此外,释放的CPU很快又返回到它正在做的事上而没有弄脏它的硬件高速缓存;这对系统性能是益的。
  • irq_desc[irq].actionNULL
    • 当中断没有相关的中断服务例程时出现这种情况下,通常情况下,只有在内核正在探测一个硬件设备时这才会发生。

__do_IRQ()设置IRQ_INPROGRESS标志并开始一个循环。在每次循环中,函数清IRQ_PENDING标志,释放中断自旋锁,并调用handle_IRQ_event()执行中断服务例程。当handle_IRQ_event()终止时,__do_IRQ()再次获得自旋锁,并检查IRQ_PENDING标志的值。如果该标志清0,那么,中断的进一步出现不传递给另一个CPU,因此,循环结束。相反,如果IRQ_PENDING被设置,当这个CPU正在执行handle_IRQ_event()时,另一个CPU已经在为这种中断执行do_IRQ()函数。因此,do_IRQ()执行循环的另一次反复,为新出现中断提供服务。

我们的__do_IRQ()函数现在准备终止,或者是因为已经执行了中断服务例程,或者是因为无事可做。函数调用主IRQ描述符的end方法。当使用旧的8259APIC时,相应的end_8259A_irq()函数重新激活IRQ线。当使用I/OAPIC时,end方法应答中断。

最后,__do_IRQ()释放自旋锁;艰难的工作已经完成。

挽救丢失的中断

__do_IRQ()函数小而简单,但在大多数情况下它都能正常工作。的确,IRQ_PENDINGIRQ_INPROGRESSIRQ_DISABLED标志确保中断能被正确地处理,即使硬件失常也不例外。然而,在多处理器系统上事情可能不会这么顺利。内核用来激活IRQ线的enable_irq()函数先检查是否发生了中断丢失,如果是,该函数就强迫硬件让丢失的中断再产生一次

函数通过检查IRQ_PENDING标志的值检测一个中断被丢失了。当离开中断处理程序时,这个标志总置为0;因此,如果IRQ线被禁止且该标志被设置,那么,中断的一个出现已经被应答但还没有处理。在这种情况下,hw_resend_irq()函数产生一个新中断。这可以通过强制本地APIC产生一个自我中断来达到。IRQ_REPLAY标志的作用是确保只产生一个自我中断。

中断服务例程

如前所述,一个中断服务例程实现一种特定设备的操作。当中断处理程序必须执行ISR时,它就调用handle_IRQ_event()函数。这个函数本质上执行如下步骤:

  • 如果SA_INTERRUPT标志清0,就用sti汇编语言指令激活本地中断。
  • 通过下列代码执行每个中断的中断服务例程:
1
2
3
4
5
retval =0;
do{
retval != action->handler(irq,action->dev_id,regs);
action = action->next;
}while(action);

在循环的开始,action指向irqaction数据结构链表的开始,而irqaction表示接受中断后要采取的操作

  • 用cli汇编语言指令禁止本地中断。
  • 通过返回局部变量retval的值而终止,也就是说,如果没有与中断对应的中断服务例程,返回0;否则返回1

所有的中断服务例程都作用于相同的参数:

  • irq
    • IRQ号
  • dev_id
    • 设备标识符
  • regs
    • 指向内核栈的pt_regs结构的指针,栈中含有中断发生后随即保存的寄存器。pt_regs结构包括15个字段。
      • 开始的9个字段是被SAVE_ALL压入栈中的寄存器的值。
      • 第10个字段为IRQ号编码,通过orig_eax字段被引用。
      • 其余的字段对应由控制单元自动压入栈中寄存器的值。

第一个参数允许一个单独的ISR处理几条IRQ线,第二个参数允许一个单独的ISR照顾几个同类型的设备,第三个参数允许ISR访问被中断的内核控制路径的执行上下文,实际上,大多数ISR不使用这些参数。

每个中断服务例程在成功处理完中断后都返回1,也就是说,当中断服务例程所处理的硬件设备发出信号时;否则返回0。这个返回码使内核可以更新在本章前面“IRQ数据结构”一节描述过的伪中断计数器。

do_IRQ()函数调用一个ISR时,主IRQ描述符的SA_INTERRUPT标志决定是开中断还是关中断,通过中断调用的ISR可以由一种状态转换成相反的状态。在单处理器系统上,这是通过cli和sti。

IRQ线的动态分配

同一条IRQ线可以让几个硬件设备使用,即使这些设备不允许IRQ共享。技巧就在于使这些硬件设备的活动串行化,以便一次只能有一上设备拥有这个IRQ线。

在激活一个准备利用IRQ线的设备之前,其相应的驱动程序调用request_irq()。这个函数建立一个新的irqaction描述符,并用参数值初始化它。然后调用setup_irq()函数把这个描述符插入到合适的IRQ链表。如果setup_irq()返回一个出错码,设备驱动程序中止操作,这意味着IRQ线已由另一个设备所使用,而这个设备不允许中断共享。当设备操作结束时,驱动程序调用free_irq()函数从IRQ链表删除这个描述符,并释放相应的内存区。

通常将IRQ6分配给软盘控制器,给定这个号,软盘驱动程序发出下列请求:

1
request_irq(6, floppy_interrupt, SA_INTERRUPT|SA_SAMPLE_RANDOM, "floppy", NULL);

我们可以观赛到,floppy_interrup()中断服务例程必须以关中断的方式来执行,并且不共享这个IRQ。设备SA_SAMPLE_RANDOM标志意味对软盘的访问是内核用于产生随机数的一个较好的随机事件源。当软盘的操作被终止时,驱动程序就释放IRQ6:

1
free_irq(6,NULL);

为了把一个irqaction描述符插入到适当的链表中,内核调用setup_irq()函数,传递给这个函数的参数为irq_nr(即IRQ号)和new(分配的irqaction地址)。这个函数将:

  • 检查一个设备是否已经在用irq_nr这个IRQ,如果是,检查两个设备的irqaction描述符中的SA_SHIRQ标志是否都指定了IRQ线能被共享。如果不能使用这个IRQ线,则返回一个出错码。
  • *new加到由irq_desc[irq_nr]->action指向的链表的末尾。
  • 如果没有其它设备共享同一个IRA,清*new的flags字段的IRQ_DISABLEDIRQ_AUTODETECTIRQ_WAITINGIRQ_INPROGRESS标志,并调用irq_desc[irq_nr]->handler PIC对象的startup方法以确保IRQ信号被激活。

处理器间中断处理

处理器间中断允许一个CPU向系统其他的CPU发送中断信号,处理器间中断(IPI)不是通过IRQ线传输的,而是作为信号直接放在连接所有CPU本地APIC的总线上。在多处理器系统上,Linux定义了下列三种处理器间中断:

  • CALL_FUNCTION_VECTOR (向量0xfb)
    • 发往所有的CPU,但不包括发送者,强制这些CPU运行发送者传递过来的函数,相应的中断处理程序叫做call_function_interrupt(),例如,地址存放在群居变量call_data中来传递的函数,可能强制其他所有的CPU都停止,也可能强制它们设置内存类型范围寄存器的内容。通常,这种中断发往所有的CPU,但通过smp_call_function()执行调用函数的CPU除外。
  • RESCHEDULE_VECTOR (向量0xfc)
    • 当一个CPU接收这种类型的中断时,相应的处理程序限定自己来应答中断,当从中断返回时,所有的重新调度都自动运行。
  • INVALIDATE_TLB_VECTOR (向量0xfd)
    • 发往所有的CPU,但不包括发送者,强制它们的转换后援缓冲器TLB变为无效。相应的处理程序刷新处理器的某些TLB表项。

处理器间中断处理程序的汇编语言代码是由BUILD_INTERRUPT宏产生的,它保存寄存器,从栈顶押入向量号减256的值,然后调用高级C函数,其名字就是第几处理程序的名字加前缀smp_,例如CALL_FUNCTION_VECTOR类型的处理器间中断的低级处理程序时call_function_interrupt(),它调用名为smp_call_function_interrupt()的高级处理程序,每个高级处理程序应答本地APIC上的处理器间中断,然后执行由中断触发的特定操作。

Linux有一组函数使得发生处理器间中断变为一件容易的事:

函数 说明
send_IPI_all() 发送一个IPI到所有CPU,包括发送者
send_IPI_allbutself() 发送一个IPI到所有CPU,不包括发送者
send_IPI_self() 发送一个IPI到发送者的CPU
send_IPI_mask() 发送一个IPI到位掩码指定的一组CPU

软中断及tasklet

把可廷迟中断从中断处理程序中抽出来有助于使内核保持较短的响应时间。这对于那些期望它们的中断能在几毫秒内得到处理的”急迫”应用来说是非常重要的。

Linux2.6迎接这种挑战是通过两种非紧迫、可中断内核函数:所谓的可延迟函数通过工作队列来执行的函数

软中断的分配是静态的,而tasklet的分配和初始化可以在运行是进行。软中断可以并发地运行在多个CPU上。因此,软中断是可重入函数而且必须明确地使用自旋锁保护其数据结构。tasklet不必担心这些问题,因为内核对tasklet的执行了更加严格的控制。相同类型的tasklet总是被串行地执行,换句话说就是:不能在两个CPU上同时运行相同类型的tasklet

一般而言,在可廷迟函数上可以执行四种操作:

  • 初始化
    • 定义一个新的可廷迟函数;这个操作通常在内核自身初始化或加载模块时进行。
  • 激活
    • 标记一个可延迟函数为”挂起”。激活可以在任何时候进行。
  • 屏蔽
    • 有选择地屏蔽一个可延迟函数,这样,即使它被激活,内核也不执行它。我们会在第五章”禁止和激活可延迟函数”一节看到,禁止可延迟函数有时是必要的。
  • 执行
    • 执行一个挂起的可延迟函数和同类型的其它所有挂起的可延迟函数;执行是在特定的时间进行的,这将在后面”软中断”一节解释。

激活和执行不知何故总是捆绑在一起;由给定CPU激活的一个可延迟函数必须在同一个CPU上执行。没有什么明显的理由说明这条规则对系统性能是有益的。把可延迟函数绑定在激活CPU上从理论上说可以利用CPU的硬件高速缓存。毕竟,可以想象,激活的内核线程访问的一些数据结构,可延迟函数也可能会使用。然后,当可延迟函数运行时,因为它的执行可以延迟一段时间,因此相关高速缓存行很可能就不再在高速缓存中了。此外,把一个函数绑定在一个CPU上总是有潜在”危险的”操作,因为一个CPU可能忙死而其它CPU又无所事事。

软中断

Linux2.6使用有限个软中断。在很多场合,tasklet是足够用的,且更容易编写,因为tasklet不必是可重入的。事实上,如表4-9所示,目前只定义了六种软中断。

一个软中断的下标决定了它的优先级:低下标意味着高优先级,因为软中断函数将从下标0开始执行。

软中断所使用的数据结构

表示软中断的主要数据结构是softirq_vec数组,该数组包含类型为softirq_action的32个元素,一个软中断的优先级是相应的softirq_action元素在数组内的下标。如表4-9所示,只有数组的前六个元素被有效地使用。softirq_action数据结构包括两个字段;指向软中断函数的一个action指针指向软中断函数需要的通过数据结构的data指针

另外一个关键的字段是32位的preempt_count字段,用它来跟踪内核抢占和内核控制路径的嵌套,该字段存放在每个进程描述符的thread_info字段中。如表4-10所示,preempt_count字段的编码表示三个不同的计数器和一个标志。

  • 第一个计数器记录显式禁用本地CPU内核抢占的次数,值等于0表示允许内核抢占。
  • 第二个计数器表示可延迟函数被禁用的程度。
  • 第三个计数器表示在本地CPU上中断处理程序的嵌套数。

preempt_count字段起这个名字的理由是很充分的:当内核代码明确不允许发生抢占或当内核下在中断上下文中运行是,必须禁用内核的抢占功能。因此,为了确定是否能够抢占当前进程,内核快速检查preempt_count字段中的相应值是否等于0。

in_interrupt()检查current_thread_info()->preempt_count字段的硬中断计数器和软中断计数器,只要这两个计数器中的一个值为正数,该宏就产生一个非零值否则产生一个零值。如果内核不使用多内核栈,则该宏只检查当前进程的thread_info描述符的preempt_count字段。但是,如果内核使用多内核栈,则该宏可能还要检查本地CPU的irq_ctx联合体中thread_info描述符的preempt_count字段。在这种情况下,由于该字段总是正数值,所以宏返回非零值。

实现软中断的最后一个关键的数据结构是每个CPU都有的32位掩码,它存放在irq_cpustat_t数据结构(见表4-8)的__softirq_pending字段中。为了获取或设置位掩码的值,内核使用宏local_softirq_pending(),它选择本地CPU的软中断位掩码。

处理软中断

open_softirq()函数处理软中断的初始化。它使用三个参数;软中断下标指向要执行的软中断函数的指针指向可能由软中断函数使用的数据结构的指针open_softirq()限制自己初始化softirq_vec数组中适当的元素。

raise_softirq()函数用来激活软中断,它接受软中断下标nr做为参数,执行下面的操作:

  • 执行local_irq_save宏以保存eflags寄存器IF标志的状态值并禁用本地CPU上的中断。
  • 把软中断标记为挂起状态,这是通过设置本地CPU的软中断掩码中与下标nr相关位来实现的。
  • 如果in_interrupt()产生为1的值,则跳转到第5步。这种情况说明:要么已经在中断上下文中调用了raise_softirq(),要么当前禁用了软中断。
  • 否则,就在需要的时候去调用wakeup_softirqd()以唤醒本地CPU的ksoftirqd内核线程。
  • 执行local_irq_restore宏,恢复在第1步保存的IF标志的状态值。

应该周期性地检查活动的软中断,检查是在内核代码的几个点上进行的。这在下列几种情况下进行。

  • 当内核调用local_bh_enable()函数激活本地CPU的软中断时。
  • do_IRQ()完成了I/O中断的处理是或调用irq_exit()宏时。
  • 如果系统使用I/OAPIC,则当smp_apic_timer_interrupt()函数处理完本地定时器中断时。
  • 在多处理器系统中,当CPU处理完被CALL_FUNCTION_VECTOR处理器间中断所触发的函数时。
  • 当一个特殊的ksoftirqd/n内核线程被唤醒时。

do_softirq()函数

如果在这样的一个检查点检测到挂起的软中断,内核就调用do_softirq()来处理它们。这个函数执行下面的操作。

  • 如果in_interrup()产生的值是1,则函数返回。这种情况说明要么在中断上下文中调用了do_softirq()函数,要么当前禁用软中断。
  • 执行local_irq_save以保存IF标志的状态值,并禁止本地CPU上的中断。
  • 如果thread_union的结构大小为4KB,那么在需要情况下,它切换到软中断请求栈。
  • 调用__do_softirq()函数。
  • 如果在上面第3步成功切换到软中断请求栈,则把最初的栈指针恢复到esp寄存器中,这样就切换回到以前使用的异常栈。
  • 执行local_irq_restore以恢复在第2步保存的IF标志的状态值并返回。

__do_softirq()函数

__do_softirq()函数读取本地CPU的软中断掩码并执行行与每个设置位相关的可延迟函数。由于正在执行一个软中断函数时可能出现新挂起的软中断,所以为了保证可延迟函数的低延迟性__do_softirq()一直运行到执行完所有挂起的软中断。但是,这种机制可能迫使__do_softirq()运行很长一段时间,因而大大延迟用户态进程的执行。因此,__do_softirq()只做固定次数的循环,然后就返回。如果还有其余挂起的软中断,那么下一节要描述的内核线程ksoftirqd将会在预期的时间内处理它们。下面简单描述__do_softirq()函数执行的操作:

  • 把循环计数器的值初始为10。
  • 把本地CPU软中断的位掩码复制到局部变量pending中。
  • 调用local_bh_disable()增加软中断计数器的值。在可延迟函数开始执行之前应该禁用它们。因为在绝大多数情况下可能会产生新的中断。当do_IRQ()执行irq_exit()宏时,可能有另外一个__do_softirq()函数的实例开始执行。这种情况是应该避免的,因为可延迟函数必须以串行的方式在CPU上运行。因此,__do_softirq()函数的第一实例禁用可延迟函数,以使每个新的函数实例将会在__do_softirq()函数的第1步就退出。
  • 清除本地CPU的软中断位图,以便可以激活新的软中断。
  • 执行local_irq_enable()来激活本地中断。
  • 根据局部变量pending每一位的设置,执行对应的软中断处理函数。回
  • 执行local_irq_disable()以禁用本地中断。
  • 把本地CPU的软中断位掩码复制到局部变量pending中,并且再次递减循环计数器。
  • 如果pending不为0,那么从最后一次循环开始,至少有一个软中断被激活,而且循环计数器仍然是正数,跳转回到第4步。
  • 如果还有更多的挂起软中断,则调用wakeup_softirqd()唤醒内核线程来处理本地CPU的软中断。
  • 软中断计数器减1,因而重新激活可延迟函数。

ksoftirqd内核线程

每个CPU都有自己的ksoftirqd内核线程。每个ksoftirqd/n内核线程都运行ksoftirqd()函数,该函数实际上执行下列的循环:

当内核线程被唤醒时,就检查local_softirq_pending()中的软中断位掩码并在必要时调用do_softirq()。如果没有挂起的软中断,函数把当前进程状态置为TASK_INTERRUPTIBLE,最后,如果当前进程需要就调用cond_resched()函数来实现进程切换。

软中断函数可以重新激活自己,实际上,网络软中断和tasklet软中断都可以这么做。此外,像网卡上数据包泛滥这样的外部事件可能以高频激活软中断。软中断的连续高流量可能会产生问题,该问题就是由引入的内核线程来解决的。没有内核线程,开发者实际上就面临两种选择策略。

do_softirq()函数确定哪些软中断是挂起的,并执行它们的函数。如果已经执行的软中断又被激活,do_softirq()函数则唤醒内核线程并终止。内核线程有较低的优先级,因此用户程序就有机会运行;但是,如果机器空闲,挂起的软中断就很快被执行。

tasklet

tasklet是I/O驱动程序中实现可延迟函数的首选方法。如前所述,tasklet建立在两个叫做HI_SOFTIRQTASKLET_SOFTIRQ的软中断之上。几个tasklet可以与同一个软中断相关联,每个tasklet执行自己的函数。两个软中断之间没有真正的区别,只不过do_softirq()先执行HI_SOFTIRQ的tasklet,后执行TASKLET_SOFTIRQ的tasklet。

tasklet和高优先级的tasklet分别存放在tasklet_vectasklet_hi_vec数组中,都包含类型为tasklet_headNR_CUPS个元素,每个元素都由一个指向tasklet描述符链表的指针组成。tasklet描述符是一个tasklet_struct类型的数据结构,其字段如表4-11所示。

Tasklet描述符的state字段含有两个标志:

  • TASKLET_STATE_SCHED
    • 该标志被设置时,表示tasklet是挂起的;也意味着tasklet描述符被插入到tasklet_vectasklet_hi_vec数组的其中一个链表中。
  • TASKLET_STATE_RUN
    • 该标志被设置是,表示tasklet正在被执行;在单处理器系统上不使用这个标志,因为没有必要检查特定的tasklet是否在运行。

首先分配一个新的tasklet_struct数据结构,并调用tasklet_init()初始化它;该函数接收的参数为tasklet描述符的地址,tasklet函数的地址和它的可选整形参数。

调用tasklet_disable_nosync()tasklet_disable()可以选择性地禁止tasklet。这两个函数都增加tasklet描述符的count字段,但是最后一个函数只有在tasklet函数已经运行的实例结束后才返回。为了重新激活你的tasklet。调用tasklet_enable()

为了激活tasklet,你应该根据自己tasklet需要的优先级,调用tasklet_schedule()函数或tasklet_hi_schedule()函数。这两个函数非常类似,其中每个都执行下列操作:

  • 检查TASKLET_STATE_SCHED标志;如果设置则返回
  • 调用local_irq_save保存IF标志的状态并禁用本地中断。
  • tasklet_vec[n]tasklet_hi_vec[n]指向的链表的起始处增加tasklet描述符。
  • 调用raise_softirq_irqoff()激活TASKLET_SOFTIRQHI_SOFTIRQ类型的软中断。
  • 调用local_irq_restore恢复IF标志的状态。

软中断函数一旦被激活,就由do_softirq()函数执行。与HI_SOFTIRQ软中断相关的软中断函数叫做tasklet_hi_action()。而与TASKLET_SOFTIRQ相关的函数叫做tasklet_action()。这两个函数非常相似。它们都执行下列操作:

  • 禁用本地中断。
  • 获得本地CPU的逻辑号。
  • tasklet_vec[n]tasklet_hi_vec[n]指向的链表的地址存入局部变量list.
  • tasklet_vec[n]tasklet_hi_vec[n]的值赋为NULL,因此,已调度的tasklet描述符的链表被清空。
  • 打开本地中断。
  • 对于list指向的链表中的每个tasklet描述符
    • 在多处理器上,检查tasklet的TASKLET_STATE_RUN标志
      • 如果标志被设置,同类型的一个tasklet正在另一个CPU上执行。因此就把任务描述符加入tasklet_vec[n]tasklet_hi_vec[n]指向的链表,再次激活TASKLET_SOFTIRQHI_SOFTIRQ
      • 如果标志未被设置,需要设置这个标志,以便tasklet函数不能在其他CPU上运行。
    • 通过查看tasklet的count字段,检查count是否被禁止。如果是,就清除TASKLET_STATE_RUN标志,并把任务描述符重新插入到由tasklet_vec[n]tasklet_hi_vec[n]指向的链表,再次激活TASKLET_SOFTIRQHI_SOFTIRQ
    • 如果tasklet被激活,清除TASKLET_STATE_SECHED标志,并执行tasklet函数

注意,除非tasklet函数重新激活自己,否则,tasklet的每次激活至多触发tasklet函数的一次执行。

工作队列

在linux2.6中引入了工作队列,它用来代替任务队列。它们允许内核函数被激活,而且稍后由叫做工作者线程的特殊内核线程来执行。

可延迟函数和工作队列主要区别在于:可延迟函数运行在中断上下文中,而工作队列中的函数运行在进程上下文中执行可阻塞函数的唯一方式是在进程上下文中运行。因为。在中断上下文中不可能发生进程切换。可延迟函数和工作队列中的函数都不能访问进程的用户态地址空间。事实上,可延迟函数被执行时不可能有任何正在运行的进程。另一方面,工作队列中的函数是由内核线程来执行的。因此,根本不存在它要访问的用户态地址空间。

工作队列的数据结构

与工作队列相关的主要数据结构是名为workqueue_struct的描述符,它包括一个有NR_CPUS个元素的数组,NR_CPUS是系统中CPU的最大数量。每个元素都是cpu_workqueue_struct类型的描述符,有关数据结构的字段如表4-12所示。

cpu_workqueue_struct结构的worklist字段是双向链表的头,链表集中了工作队列中的所有挂起函数。work_struct数据用来表示每一个挂起函数,它的字段如表4-13所示。

工作队列函数

create_workqueue("foo")函数接收一个字符串作为参数,返回新创建工作队列的workqueue_struct描述符的地址。该函数还创建n个工作者线程,并根据传递给函数的字符串为工作者线程命名,如foo/0foo/1等等。create_singlethread_workqueue()函数与之相似,但不管系统中有多少个CPU,create_singlethread_workqueue()函数都只创建一个工作者线程。内核调用destroy_workqueue()函数撤消工作队列,它接收指向workqueue_struct数组的指针作为参数。

queue_work()把函数插入工作队列,它接收wq和work两个指针。wq指向workqueue_struct描述符,work指向work_struct描述符。queue_work()主要执行下面的步骤:

  • 检查要插入的函数是否已经在工作队列中,如果是就结束。
  • work_struct描述符加到工作队列链表中,然后把work->pending置为1。
  • 如果工作者线程在本地CPU的cpu_workqueue_struct描述符的more_work等待队列上睡眠,该函数唤醒这个线程。

queue_delayed_work()函数多接收一个以系统滴答数来表示时间延迟的参数,它用于确保挂起函数在执行前的等待时间尽可能短。事实上,queue_delay_work()依靠软定时器把work_struct描述符插入工作队列链表的实际操作作向后推迟了。如果相应的work_struct描述符还没有插入工作队列链表。cancel_delayed_work()就删除曾被调度过的工作队列函数。

每个工作队列线程在worker_thread()函数内部不断地执行循环操作,因而,线程在大多数时间里处于睡眠状态并等待某些工作被插入队列。工作线程一旦被唤醒就调用run_workqueue()函数,该函数从工作都线程的工作队列链表中删除所有work_struct描述符并执行相应的挂起函数。由于工作队列函数可以阻塞,因此,可以让工作都线程睡眠,甚至可以让它迁移到另一个CPU上恢复执行。

有些时候,内核必须等待工作队列中的所有挂起函数执行完毕。flush_workqueue()函数接收workqueue_struct描述符的地址,并且在工作队列中的所有挂起函数结束之前使调用进程一直处于阻塞状态。但是该函数不会等待在调用flush_work_queue()之后新加入工作队列的挂起函数,每个cpu_workqueue_struct描述符的remove_sequence字段和insert_sequence字段用于识别新增加的挂起函数。

预定义工作队列

内核引入叫做events的预定义工作队列,所有的内核开发者都可以随意使用它。预定义工作队列中是一个包括不同内核层函数和I/O驱动程序的标准工作队列,它的workqueue_struct描述述存放在keventd_wq数组中。为了使用预定义工作队列。内核提供表4-14中列出的函数。

当函数很少调用时,预定义工作队列节省了重要的系统资源。另一方面,不应该使在预定义工作队列中执行的函数长时间处于阻塞状态。因为工作队列链表中的挂起函数是在每个CPU上以串行方式执行的,而太长的延迟对预定义工作队列的其它用户会产生不良影响。

从中断和异常返回

终止阶段的主要目的很清楚,即恢复某个程序的执行;但是,在这样做之间,还需要考虑几个问题:

  • 内核控制路径并发执行的数量
    • 如果仅仅只有一个,那么CPU必须切换到用户态。
  • 挂起进程的切换请求
    • 如果有任何请求,内核就必须执行进程调度,否则,把控制权还给当前进程。
  • 挂起的信号
    • 如果一个信号发送到当前进程,就必须处理它。
  • 单步执行模式
    • 如果调试程序正在跟踪当前进程的执行,就必须在进程切换回到用户态之前恢复单步执行。

需要使用一些标志来记录挂起进程切换的请求,挂起信号和单步执行,这些标志被存放在thread_info描述符的flags字段中,这个字段也存放其它与从中断和异常返回无关的标志。表4-15完整地列出了中断和异常返回相关的标志。

从技术上说,完成所有这些事情的内核汇编语言代码并不是一个函数,因为控制权从不返回到调用它的函数。它只是一个代码片段,有两个不同的入口点,分别叫做ret_form_intr()ret_from_exception()。正如其名所暗示的,中断处理程序结束时,内核进入ret_from_intr(),而当异常处理程序结束时,它进入ret_form_exception()。为了描述起来更容易一些,我们将把这两个入口点当做函数来讨论。

图4-6是关于两个入口点的完整的流程图。灰色的框图涉及实现内核抢占的汇编指令,如果你只想了解不支持抢占的内核都做了些什么,就可以忽略这些灰色的框图。在流程图中,入口点ret_from_exception()ret_from_intr()看起来非常相似,它们唯一区别是:如果内核在编译时选择了支持内核抢占,那么从异常返回时要立刻禁用本地中断

流程图大致给出了恢复执行被中断的程序所必需的步骤。现在,我们要通过讨论汇编语言代码来详细描述这个过程。

入口点

ret_from_intr()ret_from_exception()入口点本质上相当于下面这段汇编代码:

1
2
3
4
5
6
7
8
9
10
ret_form_exception:
cli
ret_from_intr:
movl $-8192, %ebp
andl %esp, %ebp
movl 0x30(%esp), %eax
movb 0x2c(%esp), %al
testl %0x00020003, %eax
jnz resum_userspace
jpm resume_kernel

回忆前面对handle_IRQ_event()描述的第3步,在中断返回时,本地中断是被禁用的。因此,只有在从异常返回时才使用cli这条汇编指令。

内核把当前thread_info描述符的地址装载到ebp寄存器。

接下来,要根据发生中断或异常时压入栈中的cs和eflags寄存器的值,来确定被中断的程序在中断发生时是否运行在用户态,或确定是否设置了eflasg的VM标志。在任何一种情况下,代码的执行就跳转到resume_userspace处。否则,代码的执行就跳转到resume_kernel处。

恢复内核控制路径

如果被恢复的程序运行在内核态,就执行resume_kernel处的汇编语言代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
resume_kernel:
cli
cmpl $0, 0x14(%esp)
jz need_resched
restore_all:

pop1 $ebx
pop1 $ecx
pop1 $edx
pop1 $esi
pop1 $edi
pop1 $ebp
pop1 $eax
pop1 $ds
pop1 $es
addl $4, %esp
iret

如果thread_info描述符的preempt_count字段为0,则内核跳转到need_resched处,否则,被中断的程序重新开始执行。函数用中断和异常开始时保存的值装载寄存器,然后通过执行iret指令结束其控制。

检查内核抢占

执行检查内核抢占的代码是,所有没执行完的内核控制路径都不是中断处理程序,否则preempt_count字段的值就会是大于0的。但是正如在本章”中断和异常处理程序的嵌套执行”一节所强调的,最多可能有两个异常相关的内核控制路径。

1
2
3
4
5
6
7
8
need_resched:
movl 0x8(%ebp), %ecx
testb $(1<<TIF_NEED_RESCHED), %cl
jz restore_all
testl $0x00000200, 0x30(%esp)
jz restore_all
call preempt_schedule_irq
jmp need_resched

如果current->thread_info的flags字段中的TIF_NEED_RESCHED标志为0,说明没有需要切换的进程,因此程序跳转到restore_all处。如果正在被恢复的内核控制路径是在禁用本地CPU的情况下运行,那么也跳转到restore_all处。在这种情况下,进程切换可能破坏内核数据结构。

如果需要进行进程切换,就调用preempt_schedule_irq()函数:它设置preempt_count字段的PREEMPT_ACTIVE标志,把大内核锁计数器暂时置为-1。打开本地中断并调用schedule()以选择另一个进程来运行。当前面的进程要恢复时,preempt_schedule_irq()使大内核计数器的值恢复为以前的值,清除PREENPT_ACTIVE标志并禁用本地中断。但当前进程的TIF_NEED_RESCHED标志被设置,将继续调用schedule()函数。

恢复用户态程序

如果恢复的程序原来运行在用户态,就跳转到resum_user_space处:

1
2
3
4
5
6
resume_userspace:
cli
movl 0x0(%ebp), %ecx
andl $0x0000ff6e, %ecx
je restore_all
jmp work_pending

禁止本地中断之后检测current->thread_info的flags字段的值。如果只设置了TIF_SYSCALL_TRACE,TIF_SYSCALL_AUDITTIF_SINGLESTEP标志,就不做任何其它的事情,只是跳转到restore_all,从而恢复用户态程序。

检测调度标志

thread_info descriptor描述符的flags表示在恢复被中断的程序前,需要完成额外的工作。

1
2
3
4
5
6
7
work_pending:
testb $(1<<TIF_NEED_RESCHED), %cl
jz work_notifysing
work_resched:
call schedule
cli
jmp resume_usersapce

如果进程切换请求被挂起,就调用schedule()选择另一个进程投入运行。当前面的进程要恢复时,就跳回到resume_userspace处。

处理挂起信号、虚拟8086模式和单步执行

除了处理进程切换请求,还有其它的工作需要处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
work_notifysig:
movl %esp,%eax
testl $0x00020000, 0x30(%esp)
je 1f
work_notifysig_v86:
pushl %ecx
call save_v86_state
popl %ecx
movl %eax, %esp
l:
xorl %edx, %edx
call do_notify_resume
jmp restore_all

如果用户态程序eflags寄存器的VM控制标志被设置,就调用save_v86_state()函数在用户态地址空间建立虚拟8086模式的数据结构。然后,就调用do_notify_resume()函数处理挂起信号和单步执行。最后,跳转到restore_all标记处,恢复被中断的程序。

条款1:指针与引用的区别

指针与引用看上去完全不同(指针用操作符’*’和’->’,引用使用操作符’.’),但是它们似乎有相同的功能。指针与引用都是让你间接引用其他对象。你如何决定在什么时候使用指针,在什么时候使用引用呢?

首先,要认识到在任何情况下都不能用指向空值的引用。一个引用必须总是指向某些对象。因此如果你使用一个变量并让它指向一个对象,但是该变量在某些时候也可能不指向任何对象,这时你应该把变量声明为指针,因为这样你可以赋空值给该变量。相反,如果变量肯定指向一个对象,例如你的设计不允许变量为空,这时你就可以把变量声明为引用。

1
2
char *pc = 0;          // 设置指针为空值
char& rc = *pc; // 让引用指向空值

这是非常有害的,毫无疑问。结果将是不确定的(编译器能产生一些输出,导致任何事情都有可能发生)。因为引用肯定会指向一个对象,在C里,引用应被初始化。
1
2
3
4
string& rs;             // 错误,引用必须被初始化
string s("xyzzy");

string& rs = s; // 正确,rs指向s

指针没有这样的限制。

1
2
string *ps;             // 未初始化的指针
// 合法但危险

不存在指向空值的引用这个事实意味着使用引用的代码效率比使用指针的要高。因为在使用引用之前不需要测试它的合法性。
1
2
3
void printDouble(const double& rd) {
cout << rd; // 不需要测试rd,它
} // 肯定指向一个double值

相反,指针则应该总是被测试,防止其为空:

1
2
3
4
5
void printDouble(const double *pd) {
if (pd) { // 检查是否为NULL
cout << *pd;
}
}

指针与引用的另一个重要的不同是指针可以被重新赋值以指向另一个不同的对象。但是引用则总是指向在初始化时被指定的对象,以后不能改变。

1
2
3
4
5
6
7
8
9
10
11
string s1("Nancy");
string s2("Clancy");

string& rs = s1; // rs 引用 s1
string *ps = &s1; // ps 指向 s1

rs = s2; // rs 仍旧引用s1,
// 但是 s1的值现在是
// "Clancy"
ps = &s2; // ps 现在指向 s2;
// s1 没有改变

总的来说,在以下情况下你应该使用指针:

  • 一是你考虑到存在不指向任何对象的可能(在这种情况下,你能够设置指针为空);
  • 二是你需要能够在不同的时刻指向不同的对象(在这种情况下,你能改变指针的指向)。
  • 如果总是指向一个对象并且一旦指向一个对象后就不会改变指向,那么你应该使用引用。

还有一种情况,就是当你重载某个操作符时,你应该使用引用。最普通的例子是操作符[]。这个操作符典型的用法是返回一个目标对象,其能被赋值。

1
2
3
vector<int> v(10);       // 建立整形向量(vector),大小为10;
// 向量是一个在标准C库中的一个模板(见条款35)
v[5] = 10; // 这个被赋值的目标对象就是操作符[]返回的值

如果操作符[]返回一个指针,那么后一个语句就得这样写:

1
*v[5] = 10;

但是这样会使得v看上去象是一个向量指针。因此你会选择让操作符返回一个引用。(这有一个有趣的例外,参见条款30)

当你知道你必须指向一个对象并且不想改变其指向时,或者在重载操作符并为防止不必要的语义误解时,你不应该使用指针。而在除此之外的其他情况下,则应使用指针。

条款2:尽量使用C++风格的类型转换

C++通过引进四个新的类型转换操作符克服了C风格类型转换的缺点,这四个操作符是,static_cast,const_cast,dynamic_cast, 和reinterpret_cast。在大多数情况下,对于这些操作符你只需要知道原来你习惯于这样写,

1
(type) expression

而现在你总应该这样写:
1
static_cast<type>(expression)

例如,假设你想把一个int转换成double,以便让包含int类型变量的表达式产生出浮点数值的结果。如果用C风格的类型转换,你能这样写:

1
2
int firstNumber, secondNumber;
double result = ((double)firstNumber)/secondNumber;

如果用上述新的类型转换方法,你应该这样写:
1
double result = static_cast<double>(firstNumber)/secondNumber;

static_cast也有功能上限制。例如,你不能用static_cast象用C风格的类型转换一样把struct转换成int类型或者把double类型转换成指针类型,另外,static_cast不能从表达式中去除const属性,因为另一个新的类型转换操作符const_cast有这样的功能。

const_cast用于类型转换掉表达式的constvolatileness属性。通过使用const_cast,你向人们和编译器强调你通过类型转换想做的只是改变一些东西的constness 或者 volatileness属性。这个含义被编译器所约束。如果你试图使用const_cast来完成修改constness 或者 volatileness属性之外的事情,你的类型转换将被拒绝。下面是一些例子:

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 Widget { ... };
class SpecialWidget: public Widget { ... };

void update(SpecialWidget *psw);

SpecialWidget sw; // sw 是一个非const 对象。
const SpecialWidget& csw = sw; // csw 是sw的一个引用
// 它是一个const 对象

update(&csw); // 错误!不能传递一个const SpecialWidget* 变量
// 给一个处理SpecialWidget*类型变量的函数

update(const_cast<SpecialWidget*>(&csw));
// 正确,csw的const被显示地转换掉(
// csw和sw两个变量值在update函数中能被更新)

update((SpecialWidget*)&csw);
// 同上,但用了一个更难识别的C风格的类型转换

Widget *pw = new SpecialWidget;
update(pw); // 错误!pw的类型是Widget*,但是
// update函数处理的是SpecialWidget*类型
update(const_cast<SpecialWidget*>(pw));
// 错误!const_cast仅能被用在影响
// constness or volatileness的地方上。,
// 不能用在向继承子类进行类型转换。

到目前为止,const_cast最普通的用途就是转换掉对象的const属性。

第二种特殊的类型转换符是dynamic_cast,它被用于安全地沿着类的继承关系向下进行类型转换。这就是说,你能用dynamic_cast把指向基类的指针或引用转换成指向其派生类或其兄弟类的指针或引用,而且你能知道转换是否成功。失败的转换将返回空指针(当对指针进行类型转换时)或者抛出异常(当对引用进行类型转换时):

1
2
3
4
5
6
7
8
9
10
11
12
Widget *pw;
...
update(dynamic_cast<SpecialWidget*>(pw));
// 正确,传递给update函数一个指针
// 是指向变量类型为SpecialWidget的pw的指针
// 如果pw确实指向一个对象,否则传递过去的将使空指针。
void updateViaRef(SpecialWidget& rsw);
updateViaRef(dynamic_cast<SpecialWidget&>(*pw));
//正确。 传递给updateViaRef函数
// SpecialWidget pw 指针,如果pw
// 确实指向了某个对象
// 否则将抛出异常

dynamic_casts在帮助你浏览继承层次上是有限制的。它不能被用于缺乏虚函数的类型上(参见条款24),也不能用它来转换掉constness:
1
2
3
4
5
6
7
8
9
int firstNumber, secondNumber;
...
double result = dynamic_cast<double>(firstNumber)/secondNumber;
// 错误!没有继承关系
const SpecialWidget sw;
...
update(dynamic_cast<SpecialWidget*>(&sw));
// 错误! dynamic_cast不能转换
// 掉const。

如你想在没有继承关系的类型中进行转换,你可能想到static_cast。如果是为了去除const,你总得用const_cast

reinterpret_cast被用于的类型转换的转换结果几乎都是实现时定义(implementation-defined)。因此,使用reinterpret_casts的代码很难移植。reinterpret_casts的最普通的用途就是在函数指针类型之间进行转换。例如,假设你有一个函数指针数组:

1
2
3
4
5
typedef void (*FuncPtr)();      // FuncPtr is 一个指向函数
// 的指针,该函数没有参数
// 也返回值类型为void
FuncPtr funcPtrArray[10]; // funcPtrArray 是一个能容纳
// 10个FuncPtrs指针的数组

让我们假设你希望(因为某些莫名其妙的原因)把一个指向下面函数的指针存入funcPtrArray数组:
1
int doSomething();

你不能不经过类型转换而直接去做,因为doSomething函数对于funcPtrArray数组来说有一个错误的类型。在FuncPtrArray数组里的函数返回值是void类型,而doSomething函数返回值是int类型。reinterpret_cast可以让你迫使编译器以你的方法去看待它们:

1
2
funcPtrArray[0] =                   // this compiles
reinterpret_cast<FuncPtr>(&doSomething);

转换函数指针的代码是不可移植的(C++不保证所有的函数指针都被用一样的方法表示),在一些情况下这样的转换会产生不正确的结果(参见条款31),所以你应该避免转换函数指针类型。

条款3:不要使用多态性数组

类继承的最重要的特性是你可以通过基类指针或引用来操作派生类。这样的指针或引用具有行为的多态性,就好像它们同时具有多种形态。C++允许你通过基类指针和引用来操作派生类数组。不过这根本就不是一个特性,因为这样的代码根本无法如你所愿地那样运行。

假设你有一个类BST(比如是搜索树对象)和继承自BST类的派生类BalancedBST:

1
2
class BST { ... };
class BalancedBST: public BST { ... };

有这样一个函数,它能打印出BST类数组中每一个BST对象的内容:

1
2
3
4
5
6
7
void printBSTArray(ostream& s,
const BST array[],
int numElements) {
for (int i = 0; i < numElements; ) {
s << array[i]; //假设BST类
} //重载了操作符<<
}

当你传递给该函数一个含有BST对象的数组变量时,它能够正常运行:

1
2
3
BST BSTArray[10];
...
printBSTArray(cout, BSTArray, 10); // 运行正常

然而,请考虑一下,当你把含有BalancedBST对象的数组变量传递给printBSTArray函数时,会产生什么样的后果:

1
2
BalancedBST bBSTArray[10];
printBSTArray(cout, bBSTArray, 10); // 还会运行正常么?

你的编译器将会毫无警告地编译这个函数,但是再看一下这个函数的循环代码:

1
2
3
for (int i = 0; i < numElements; ) {
s << array[i];
}

array数组中每一个元素都是BST类型,因此每个元素与数组起始地址的间隔是i*sizeof(BST)。BalancedBST对象长度的比BST长,printBSTArray函数生成的指针算法将是错误的。多态和指针算法不能混合在一起来用,所以数组与多态也不能用在一起

条款4:避免无用的缺省构造函数

缺省构造函数(指没有参数的构造函数)在C++语言中是一种让你无中生有的方法。缺省构造函数则可以不利用任何在建立对象时的外部数据就能初始化对象。如果一个类没有缺省构造函数,就会存在一些使用上的限制。

请考虑一下有这样一个类,它表示公司的设备,这个类包含一个公司的ID代码,这个ID代码被强制做为构造函数的参数:

1
2
3
4
5
class EquipmentPiece {
public:
EquipmentPiece(int IDNumber);
...
};

因为EquipmentPiece类没有一个缺省构造函数,所以在三种情况下使用它,就会遇到问题。第一种情况是建立数组时。一般来说,没有一种办法能在建立对象数组时给构造函数传递参数。所以在通常情况下,不可能建立EquipmentPiece对象数组:

1
2
3
4
EquipmentPiece bestPieces[10];           // 错误!没有正确调用
// EquipmentPiece 构造函数
EquipmentPiece *bestPieces = new EquipmentPiece[10];
// 错误!与上面的问题一样

不过还是有三种方法能回避开这个限制。对于使用非堆数组(non-heap arrays)(即不在堆中给数组分配内存。译者注)的一种解决方法是在数组定义时提供必要的参数:
1
2
3
4
5
6
7
8
int ID1, ID2, ID3, ..., ID10;            // 存储设备ID号的变量
EquipmentPiece bestPieces[] = { // 正确, 提供了构造
EquipmentPiece(ID1), // 函数的参数
EquipmentPiece(ID2),
EquipmentPiece(ID3),
...,
EquipmentPiece(ID10)
};

不过很遗憾,这种方法不能用在堆数组(heap arrays)的定义上。更通用的解决方法是利用指针数组来代替一个对象数组:
1
2
3
4
5
typedef EquipmentPiece* PEP;             //  PEP 指针指向
//一个EquipmentPiece对象

PEP bestPieces[10]; // 正确, 没有调用构造函数
PEP *bestPieces = new PEP[10]; // 也正确

在指针数组里的每一个指针被重新赋值,以指向一个不同的EquipmentPiece对象:
1
2
for (int i = 0; i < 10; ++i)
bestPieces[i] = new EquipmentPiece( ID Number );

不过这种方法有两个缺点,第一你必须删除数组里每个指针所指向的对象。如果你忘了,就会发生内存泄漏。第二增加了内存分配量,因为正如你需要空间来容纳EquipmentPiece对象一样,你也需要空间来容纳指针。如果你为数组分配raw memory,你就可以避免浪费内存。使用placement new方法(参见条款8)在内存中构造EquipmentPiece对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 为大小为10的数组 分配足够的内存
// EquipmentPiece 对象; 详细情况请参见条款8
// operator new[] 函数
void *rawMemory =
operator new[](10*sizeof(EquipmentPiece));
// make bestPieces point to it so it can be treated as an
// EquipmentPiece array
EquipmentPiece *bestPieces =
static_cast<EquipmentPiece*>(rawMemory);
// construct the EquipmentPiece objects in the memory
// 使用"placement new" (参见条款8)
for (int i = 0; i < 10; ++i)
new (&bestPieces[i]) EquipmentPiece( ID Number );

使用placement new的缺点除了是大多数程序员对它不熟悉外(能使用它就更难了),还有就是当你不想让它继续存在使用时,必须手动调用数组对象的析构函数,调用操作符delete[]来释放 raw memory(请再参见条款8):

1
2
3
4
5
6
// 以与构造bestPieces对象相反的顺序解构它。
for (int i = 9; i >= 0; --i)
bestPieces[i].~EquipmentPiece();

// deallocate the raw memory
operator delete[](rawMemory);

对于类里没有定义缺省构造函数所造成的第二个问题是它们无法在许多基于模板(template-based)容器类里使用。因为实例化一个模板时,模板的类型参数应该提供一个缺省构造函数,这是一个常见的要求。这个要求总是来自于模板内部,被建立的模板参数类型数组里。例如一个数组模板类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<class T>
class Array {
public:
Array(int size);
...

private:
T *data;
};

template<class T>
Array<T>::Array(int size)
{
data = new T[size]; // 为每个数组元素
... //依次调用 T::T()
}

在多数情况下,通过仔细设计模板可以杜绝对缺省构造函数的需求。例如标准的vector模板(生成一个类似于可扩展数组的类)对它的类型参数没有必须有缺省构造函数的要求。

一些人认为所有的类都应该有缺省构造函数,即使缺省构造函数没有足够的数据来初始化一个对象。比如这个原则的拥护者会这样修改EquipmentPiece类:

1
2
3
4
5
6
7
class EquipmentPiece {
public:
EquipmentPiece( int IDNumber = UNSPECIFIED);
...
private:
static const int UNSPECIFIED; // ID值不确定。
};

这允许这样建立EquipmentPiece对象
1
EquipmentPiece e;                         //这样合法

这样的修改使得其他成员函数变得复杂,因为不再能确保EquipmentPiece对象进行有意义的初始化。

条款5:谨慎定义类型转换函数

C++编译器能够在两种数据类型之间进行隐式转换(implicit conversions),例如允许把char隐式转换为int,C中许多这种可怕的转换可能会导致数据的丢失。有两种函数允许编译器进行这些的转换:单参数构造函数(single-argument constructors)和隐式类型转换运算符。单参数构造函数是指只用一个参数即可以调用的构造函数。该函数可以是只定义了一个参数,也可以是虽定义了多个参数但第一个参数以后的所有参数都有缺省值。以下有两个例子:

1
2
3
4
5
6
7
8
9
10
11
class Name {                                 // for names of things
public:
Name(const string& s); // 转换 string 到 Name
...
};

class Rational { // 有理数类
public:
Rational(int numerator = 0, // 转换int到
int denominator = 1); // 有理数类
};

隐式类型转换运算符只是一个样子奇怪的成员函数:operator 关键字,其后跟一个类型符号。你不用定义函数的返回类型,因为返回类型就是这个函数的名字。例如为了允许Rational(有理数)类隐式地转换为double类型(在用有理数进行混合类型运算时,可能有用),你可以如此声明Rational类:
1
2
3
4
5
class Rational {
public:
...
operator double() const; // 转换Rational类成
}; // double类型

在下面这种情况下,这个函数会被自动调用:

1
2
3
Rational r(1, 2);                            // r 的值是1/2
double d = 0.5 * r; // 转换 r 到double,
// 然后做乘法

当你在不需要使用转换函数时,这些的函数缺却能被调用运行。结果这些不正确的程序会做出一些令人恼火的事情,而你又很难判断出原因。让我们首先分析一下隐式类型转换运算符,它们是最容易处理的。假设你有一个如上所述的Rational类,你想让该类拥有打印有理数对象的功能,就好像它是一个内置类型。因此,你可能会这么写:

1
2
Rational r(1, 2);
cout << r; // 应该打印出"1/2"

再假设你忘了为Rational对象定义operator<<。你可能想打印操作将失败,因为没有合适的operator<<被调用。但是你错了。当编译器调用operator<<时,会发现没有这样的函数存在,但是它会试图找到一个合适的隐式类型转换顺序以使得函数调用正常运行。类型转换顺序的规则定义是复杂的,但是在这种情况下编译器会发现它们能调用Rational::operator double函数,来把r转换为double类型。所以上述代码打印的结果是一个浮点数,而不是一个有理数。这简直是一个灾难,但是它表明了隐式类型转换的缺点:它们的存在将导致错误的发生

解决方法是用等同的函数来替代转换运算符,而不用语法关键字。例如为了把Rational对象转换为double,用asDouble函数代替operator double函数:

1
2
3
4
5
class Rational {
public:
...
double asDouble() const; //转变 Rational
}; // 成double

这个成员函数能被显式调用:

1
2
3
4
Rational r(1, 2);
cout << r; // 错误! Rationa对象没有
// operator<<
cout << r.asDouble(); // 正确, 用double类型 //打印r

在多数情况下,这种显式转换函数的使用虽然不方便,但是函数被悄悄调用的情况不再会发生,这点损失是值得的。

通过单参数构造函数进行隐式类型转换更难消除。而且在很多情况下这些函数所导致的问题要甚于隐式类型转换运算符。举一个例子,一个array类模板,这些数组需要调用者确定边界的上限与下限:

1
2
3
4
5
6
7
8
template<class T>
class Array {
public:
Array(int lowBound, int highBound);
Array(int size);
T& operator[](int index);
...
};

第一个构造函数允许调用者确定数组索引的范围,例如从10到20。它是一个两参数构造函数,所以不能做为类型转换函数。第二个构造函数让调用者仅仅定义数组元素的个数(使用方法与内置数组的使用相似),不过不同的是它能做为类型转换函数使用,能导致无穷的痛苦。例如比较Array<int>对象,部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool operator==( const Array<int>& lhs,
const Array<int>& rhs);
Array<int> a(10);
Array<int> b(10);
...

for (int i = 0; i < 10; ++i)
if (a == b[i]) { // 哎呦! "a" 应该是 "a[i]"
do something for when
a[i] and b[i] are equal;
}
else {
do something for when not;
}

我们想用a的每个元素与b的每个元素相比较,但是当录入a时,我们偶然忘记了数组下标。当然我们希望编译器能报出各种各样的警告信息,但是它根本没有。因为它把这个调用看成用Array<int>参数(对于a)和int (对于b[i])参数调用operator==函数 ,然而没有operator==函数是这些的参数类型,我们的编译器注意到它能通过调用Array<int>构造函数能转换int类型到Array<int>类型,这个构造函数只有一个int 类型的参数。然后编译器如此去编译,生成的代码就象这样:

1
2
for (int i = 0; i < 10; ++i)
if (a == static_cast< Array<int> >(b[i])) ...

每一次循环都把a的内容与一个大小为b[i]的临时数组(内容是未定义的)比较 。这不仅不可能以正确的方法运行,而且还是效率低下的。因为每一次循环我们都必须建立和释放Array<int>对象。

通过不声明运算符(operator)的方法,可以克服隐式类型转换运算符的缺点,但是单参数构造函数没有那么简单。毕竟,你确实想给调用者提供一个单参数构造函数。同时你也希望防止编译器不加鉴别地调用这个构造函数。幸运的是,有一个方法可以让你鱼肉与熊掌兼得。事实上是两个方法:一是容易的方法,二是当你的编译器不支持容易的方法时所必须使用的方法。

容易的方法是利用一个最新编译器的特性,explicit关键字。为了解决隐式类型转换而特别引入的这个特性,它的使用方法很好理解。构造函数用explicit声明,如果这样做,编译器会拒绝为了隐式类型转换而调用构造函数。显式类型转换依然合法:

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
template<class T>
class Array {
public:
...
explicit Array(int size); // 注意使用"explicit"
...
};

Array<int> a(10); // 正确, explicit 构造函数
// 在建立对象时能正常使用

Array<int> b(10); // 也正确

if (a == b[i]) ... // 错误! 没有办法
// 隐式转换
// int 到 Array<int>

if (a == Array<int>(b[i])) ... // 正确,显式从int到
// Array<int>转换
// (但是代码的逻辑
// 不合理)

if (a == static_cast< Array<int> >(b[i])) ...
// 同样正确,同样
// 不合理

if (a == (Array<int>)b[i]) ... //C风格的转换也正确,
// 但是逻辑
// 依旧不合理

在例子里使用了static_cast(参见条款2),两个“>”字符间的空格不能漏掉,如果这样写语句:

1
if (a == static_cast<Array<int>>(b[i])) ...

这是一个不同的含义的语句。因为C++编译器把”>>”做为一个符号来解释。在两个”>”间没有空格,语句会产生语法错误。如果你的编译器不支持explicit,你不得不回到不使用成为隐式类型转换函数的单参数构造函数。

我前面说过复杂的规则决定哪一个隐式类型转换是合法的,哪一个是不合法的。这些规则中没有一个转换能够包含用户自定义类型(调用单参数构造函数或隐式类型转换运算符)。你能利用这个规则来正确构造你的类,使得对象能够正常构造,同时去掉你不想要的隐式类型转换。

再来想一下数组模板,你需要用整形变量做为构造函数参数来确定数组大小,但是同时又必须防止从整数类型到临时数组对象的隐式类型转换。你要达到这个目的,先要建立一个新类ArraySize。这个对象只有一个目的就是表示将要建立数组的大小。你必须修改Array的单参数构造函数,用一个ArraySize对象来代替int。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<class T>
class Array {
public:

class ArraySize { // 这个类是新的
public:
ArraySize(int numElements): theSize(numElements) {}
int size() const { return theSize; }

private:
int theSize;
};

Array(int lowBound, int highBound);
Array(ArraySize size); // 注意新的声明
...
};

这里把ArraySize嵌套入Array中,为了强调它总是与Array一起使用。你也必须声明ArraySize为公有,为了让任何人都能使用它。想一下,当通过单参数构造函数定义Array对象,会发生什么样的事情:

1
Array<int> a(10);

你的编译器要求用int参数调用Array<int>里的构造函数,但是没有这样的构造函数。编译器意识到它能从int参数转换成一个临时ArraySize对象,ArraySize对象只是Array<int>构造函数所需要的,这样编译器进行了转换。函数调用(及其后的对象建立)也就成功了。

事实上你仍旧能够安心地构造Array对象,不过这样做能够使你避免类型转换。考虑一下以下代码:

1
2
3
4
5
6
7
8
bool operator==( const Array<int>& lhs,
const Array<int>& rhs);
Array<int> a(10);
Array<int> b(10);
...
for (int i = 0; i < 10; ++i)
if (a == b[i]) ... // 哎呦! "a" 应该是 "a[i]";
// 现在是一个错误。

为了调用operator==函数,编译器要求Array<int>对象在”==”右侧,但是不存在一个参数为int的单参数构造函数。而且编译器无法把int转换成一个临时ArraySize对象然后通过这个临时对象建立必须的Array<int>对象,因为这将调用两个用户定义(user-defined)的类型转换,一个从int到ArraySize,一个从ArraySize到Array<int>。这种转换顺序被禁止的,所以当试图进行比较时编译器肯定会产生错误。

在你跳到条款33之前,再仔细考虑一下本条款的内容。让编译器进行隐式类型转换所造成的弊端要大于它所带来的好处,所以除非你确实需要,不要定义类型转换函数。

条款6:自增(increment)、自减(decrement)操作符前缀形式与后缀形式的区别

C++允许重载increment 和 decrement操作符的两种形式。C++规定后缀形式有一个int类型参数,当函数被调用时,编译器传递一个0做为int参数的值给该函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UPInt {                            // "unlimited precision int"
public:
UPInt& operator++(); // ++ 前缀
const UPInt operator++(int); // ++ 后缀

UPInt& operator--(); // -- 前缀
const UPInt operator--(int); // -- 后缀

UPInt& operator+=(int); // += 操作符,UPInts
// 与ints 相运算
...
};
UPInt i;
++i; // 调用 i.operator++();
i++; // 调用 i.operator++(0);
--i; // 调用 i.operator--();
i--; // 调用 i.operator--(0);

这些操作符前缀与后缀形式返回值类型是不同的。前缀形式返回一个引用,后缀形式返回一个const类型。下面我们将讨论++操作符的前缀与后缀形式,这些说明也同样使用与—操作符。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 前缀形式:增加然后取回值
UPInt& UPInt::operator++()
{
*this += 1; // 增加
return *this; // 取回值
}

// postfix form: fetch and increment
const UPInt UPInt::operator++(int)
{
UPInt oldValue = *this; // 取回值
++(*this); // 增加
return oldValue; // 返回被取回的值
}

后缀操作符函数没有使用它的参数。它的参数只是用来区分前缀与后缀函数调用。很明显一个后缀increment必须返回一个对象(它返回的是增加前的值),但是为什么是const对象呢?假设不是const对象,下面的代码就是正确的:

1
2
UPInt i;
i++++; // 两次increment后缀运算

这组代码与下面的代码相同:

1
i.operator++(0).operator++(0);

很明显,第一个调用的operator++函数返回的对象调用了第二个operator++函数。

条款7:不要重载overload &&, ||, or ,.

C++使用布尔表达式简化求值法(short-circuit evaluation)。这表示一旦确定了布尔表达式的真假值,即使还有部分表达式没有被测试,布尔表达式也停止运算。例如:

1
2
3
char *p;
...
if ((p != 0) && (strlen(p) > 10)) ...

这里不用担心当p为空时strlen无法正确运行,因为如果p不等于0的测试失败,strlen不会被调用。同样:
1
2
3
4
int rangeCheck(int index) {
if ((index < lowerBound) || (index > upperBound)) ...
...
}

如果index小于lowerBound,它不会与upperBound进行比较。

C++允许根据用户定义的类型,来定制&&和||操作符。方法是重载函数operator&& 和operator||。如果你重载了操作符&&,对于编译器来说,等同于下面代码之一:

1
2
3
4
5
6
if (expression1.operator&&(expression2)) ...
// when operator&& is a
// member function
if (operator&&(expression1, expression2)) ...
// when operator&& is a
// global function

这好像没有什么不同,但是函数调用法与简短求值法是绝对不同的。首先当函数被调用时,需要运算其所有参数,所以调用函数functions operator&& 和 operator||时,两个参数都需要计算,换言之,没有采用简短计算法。第二是C++语言规范没有定义函数参数的计算顺序,所以没有办法知道表达式1与表达式2哪一个先计算。完全与具有从左参数到右参数计算顺序的简短计算法相反。

因此如果你重载&&或||,就没有办法提供给程序员他们所期望和使用的行为特性,所以不要重载&&和||。存在一些限制,你不能重载下面的操作符:

1
2
3
4
5
.              .*              ::             ?:

new delete sizeof typeid

static_cast dynamic_cast const_cast reinterpret_cast

你能重载:
1
2
3
4
5
6
7
8
9
10
11
12
13
operator new        operator delete

operator new[] operator delete[]

+ - * / % ^ & | ~

! = < > += -= *= /= %=

^= &= |= << >> >>= <<= == !=

<= >= && || ++ -- , ->* ->

() []

条款8:理解各种不同含义的new和delete

当你写这样的代码:

1
string *ps = new string("Memory Management");

你使用的new是new操作符。它要完成的功能分成两部分。第一部分是分配足够的内存以便容纳所需类型的对象。第二部分是它调用构造函数初始化内存中的对象new操作符为分配内存所调用函数的名字是operator new

函数operator new 通常这样声明:

1
void * operator new(size_t size);

返回值类型是void*,因为这个函数返回一个未经处理(raw)的指针,未初始化的内存。你一般不会直接调用operator new,但是一旦这么做,你可以象调用其它函数一样调用它:
1
void *rawMemory = operator new(sizeof(string));

操作符operator new将返回一个指针,指向一块足够容纳一个string类型对象的内存。

就象malloc一样,operator new的职责只是分配内存

1
string *ps = new string("Memory Management");

它生成的代码或多或少与下面的代码相似:
1
2
3
4
5
6
7
void *memory = operator new(sizeof(string));   // 得到未经处理的内存为String对象

call string::string("Memory Management") //初始化
on *memory; // 内存中
// 的对象
string *ps = // 是ps指针指向
static_cast<string*>(memory); // 新的对象

注意第二步包含了构造函数的调用,

有时你确实想直接调用构造函数。有时你有一些已经被分配但是尚未处理的的(raw)内存,你需要在这些内存中构造一个对象。你可以使用一个特殊的operator new,它被称为placement new

下面的例子是placement new如何使用,考虑一下:

1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
Widget(int widgetSize);
...
};

Widget * constructWidgetInBuffer(void *buffer,
int widgetSize)
{
return new (buffer) Widget(widgetSize);
}

这个函数返回一个指针,指向一个Widget 对象,对象在转递给函数的buffer里分配。当程序使用共享内存或memory-mapped I/O时这个函数可能有用,因为在这样程序里对象必须被放置在一个确定地址上或一块被例程分配的内存里。

在constructWidgetInBuffer里面,返回的表达式是:

1
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 size, void *location)
{
return location;
}

operator new的目的是为对象分配内存然后返回指向该内存的指针。placement new必须做的就是返回转递给它的指针。为了使用placement new,你必须使用语句#include <new>

  • 你想在堆上建立一个对象,应该用new操作符。它既分配内存又为对象调用构造函数。
  • 如果你仅仅想分配内存,就应该调用operator new函数;它不会调用构造函数。
  • 如果你想定制自己的在堆对象被建立时的内存分配过程,你应该写你自己的operator new函数,然后使用new操作符,new操作符会调用你定制的operator new。
  • 如果你想在一块已经获得指针的内存里建立一个对象,应该用placement new。

为了避免内存泄漏,每个动态内存分配必须与一个等同相反的deallocation对应。函数operator delete与delete操作符的关系与operator new与new操作符的关系一样。当你看到这些代码:

1
2
3
string *ps;
...
delete ps; // 使用delete 操作符

你的编译器会生成代码来析构对象并释放对象占有的内存。operator delete用来释放内存,它被这样声明:
1
void operator delete(void *memoryToBeDeallocated);

因此,
1
delete ps;

导致编译器生成类似于这样的代码:
1
2
3
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
6
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
// 在共享内存中分配和释放内存的函数
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内存是自己动态分配的(通过一些不常用的方法),如果你希望避免内存泄漏,你必须释放它。(参见我的文章Counting objects里面关于placement delete的注释。)

到目前为止一切顺利,但是还得接着走。到目前为止我们所测试的都是一次建立一个对象。怎样分配数组?会发生什么?

1
string *ps = new string[10];          // allocate an array of objects

被使用的new仍然是new操作符,但是建立数组时new操作符的行为与单个对象建立有少许不同。第一是内存不再用operator new分配,代替以等同的数组分配函数,叫做operator new[](经常被称为array new)。它与operator new一样能被重载。这就允许你控制数组的内存分配,就象你能控制单个对象内存分配一样。

第二个不同是new操作符调用构造函数的数量。对于数组,在数组里的每一个对象的构造函数都必须被调用:

1
2
3
4
string *ps =               // 调用operator new[]为10个
new string[10]; // string对象分配内存,
// 然后对每个数组元素调用
// string对象的缺省构造函数。

同样当delete操作符用于数组时,它为每个数组元素调用析构函数,然后调用operator delete来释放内存。就象你能替换或重载operator delete一样,你也替换或重载operator delete[]。在它们重载的方法上有一些限制。

new和delete操作符是内置的,其行为不受你的控制,凡是它们调用的内存分配和释放函数则可以控制。当你想定制new和delete操作符的行为时,请记住你不能真的做到这一点。你只能改变它们为完成它们的功能所采取的方法,而它们所完成的功能则被语言固定下来,不能改变。

条款9:使用析构函数防止资源泄漏

我们可以把总被执行的清除代码放入局部对象的析构函数里,这样可以避免重复书写清除代码。因为当函数返回时局部对象总是被释放,无论函数是如何退出的。标准C++库函数包含一个类模板,叫做auto_ptr,每一个auto_ptr类的构造函数里,让一个指针指向一个堆对象(heap object),并且在它的析构函数里删除这个对象。下面所示的是auto_ptr类的一些重要的部分:

1
2
3
4
5
6
7
8
template<class T>
class auto_ptr {
public:
auto_ptr(T *p = 0): ptr(p) {} // 保存ptr,指向对象
~auto_ptr() { delete ptr; } // 删除ptr指向的对象
private:
T *ptr; // raw ptr to object
};

auto_ptr类的完整代码是非常有趣的,上述简化的代码实现不能在实际中应用。用auto_ptr对象代替raw指针,你将不再为堆对象不能被删除而担心,即使在抛出异常时,对象也能被及时删除。(因为auto_ptr的析构函数使用的是单对象形式的delete,所以auto_ptr不能用于指向对象数组的指针。如果想让auto_ptr类似于一个数组模板,你必须自己写一个。在这种情况下,用vector代替array可能更好)

隐藏在auto_ptr后的思想是:用一个对象存储需要被自动释放的资源,然后依靠对象的析构函数来释放资源,这种思想不只是可以运用在指针上,还能用在其它资源的分配和释放上。

条款10:在构造函数中防止资源泄漏

  • C++保证删除null指针是安全的.
  • 面对尚未完全构造好的对象, C++拒绝调用其对应的析构函数.
  • C++不自动清理那些构造期间抛出异常(exceptions)的对象, 需要在构造函数中捕获可能存在的异常.
  • 最好把共享代码抽出放进一个private的辅助函数内, 然后让析构或构造函数都调用它.
  • 智能指针shared_ptr可以帮助构造函数处理构造过程中出现的异常.

条款11:禁止异常信息(exceptions)传递到析构函数外

在有两种情况下会调用析构函数。第一种是在正常情况下删除一个对象,例如对象超出了作用域或被显式地delete。第二种是异常传递的堆栈辗转开解(stack-unwinding)过程中,由异常处理系统删除一个对象。

在上述两种情况下,调用析构函数时异常可能处于激活状态也可能没有处于激活状态。遗憾的是没有办法在析构函数内部区分出这两种情况。因此在写析构函数时你必须保守地假设有异常被激活,因为如果在一个异常被激活的同时,析构函数也抛出异常,并导致程序控制权转移到析构函数外,C++将调用terminate函数。这个函数的作用正如其名字所表示的:它终止你程序的运行,而且是立即终止,甚至连局部对象都没有被释放。

我们知道禁止异常传递到析构函数外有两个原因,第一能够在异常转递的堆栈辗转开解(stack-unwinding)的过程中,防止terminate被调用。第二它能帮助确保析构函数总能完成我们希望它做的所有事情。

条款12:理解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”间的差异

从语法上看,在函数里声明参数与在catch子句中声明参数几乎没有什么差别:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget { ... };                 //一个类,具体是什么类
// 在这里并不重要
void f1(Widget w); // 一些函数,其参数分别为
void f2(Widget& w); // Widget, Widget&,或
void f3(const Widget& w); // Widget* 类型
void f4(Widget *pw);
void f5(const Widget *pw);

catch (Widget w) ... //一些catch 子句,用来
catch (Widget& w) ... //捕获异常,异常的类型为
catch (const Widget& w) ... // Widget, Widget&, 或
catch (Widget *pw) ... // Widget*
catch (const Widget *pw) ...

传递函数参数与异常的途径可以是传值、传递引用或传递指针,这是相同的。但是当你传递参数和异常时,系统所要完成的操作过程则是完全不同的。产生这个差异的原因是:你调用函数时,程序的控制权最终还会返回到函数的调用处,但是当你抛出一个异常时,控制权永远不会回到抛出异常的地方。

有这样一个函数,参数类型是Widget,并抛出一个Widget类型的异常:

1
2
3
4
5
6
7
8
// 一个函数,从流中读值到Widget中
istream operator>>(istream& s, Widget& w);
void passAndThrowWidget()
{
Widget localWidget;
cin >> localWidget; //传递localWidget到 operator>>
throw localWidget; // 抛出localWidget异常
}

当传递localWidget到函数operator>>里,不用进行拷贝操作,而是把operator>>内的引用类型变量w指向localWidget,任何对w的操作实际上都施加到localWidget上。这与抛出localWidget异常有很大不同。不论通过传值捕获异常还是通过引用捕获(不能通过指针捕获这个异常,因为类型不匹配)都将进行lcalWidget的拷贝操作,也就说传递到catch子句中的是localWidget的拷贝。必须这么做,因为当localWidget离开了生存空间后,其析构函数将被调用。如果把localWidget本身(而不是它的拷贝)传递给catch子句,这个子句接收到的只是一个被析构了的Widget,一个Widget的“尸体”。这是无法使用的。因此C++规范要求被做为异常抛出的对象必须被复制。

即使被抛出的对象不会被释放,也会进行拷贝操作。例如如果passAndThrowWidget函数声明localWidget为静态变量(static),

1
2
3
4
5
6
7
void passAndThrowWidget()
{
static Widget localWidget; // 现在是静态变量(static);
//一直存在至程序结束
cin >> localWidget; // 象以前那样运行
throw localWidget; // 仍将对localWidget
} //进行拷贝操作

当抛出异常时仍将复制出localWidget的一个拷贝。这表示即使通过引用来捕获异常,也不能在catch块中修改localWidget;仅仅能修改localWidget的拷贝。对异常对象进行强制复制拷贝,这个限制有助于我们理解参数传递与抛出异常的第二个差异:抛出异常运行速度比参数传递要慢。

当异常对象被拷贝时,拷贝操作是由对象的拷贝构造函数完成的。该拷贝构造函数是对象的静态类型(static type)所对应类的拷贝构造函数,而不是对象的动态类型(dynamic type)对应类的拷贝构造函数。比如以下这经过少许修改的passAndThrowWidget:

1
2
3
4
5
6
7
8
9
10
class Widget { ... };
class SpecialWidget: public Widget { ... };
void passAndThrowWidget()
{
SpecialWidget localSpecialWidget;
...
Widget& rw = localSpecialWidget; // rw 引用SpecialWidget
throw rw; //它抛出一个类型为Widget
// 的异常
}

这里抛出的异常对象是Widget,即使rw引用的是一个SpecialWidget。因为rw的静态类型(static type)是Widget,而不是SpecialWidget。你的编译器根本没有主要到rw引用的是一个SpecialWidget。编译器所注意的是rw的静态类型(static type)。这种行为可能与你所期待的不一样,但是这与在其他情况下C++中拷贝构造函数的行为是一致的。

异常是其它对象的拷贝,这个事实影响到你如何在catch块中再抛出一个异常。比如下面这两个catch块,乍一看好像一样:

1
2
3
4
5
6
7
8
9
10
catch (Widget& w)                 // 捕获Widget异常
{
... // 处理异常
throw; // 重新抛出异常,让它
} // 继续传递
catch (Widget& w) // 捕获Widget异常
{
... // 处理异常
throw w; // 传递被捕获异常的
} // 拷贝

这两个catch块的差别在于第一个catch块中重新抛出的是当前捕获的异常,而第二个catch块中重新抛出的是当前捕获异常的一个新的拷贝。如果忽略生成额外拷贝的系统开销,这两种方法还有差异么?

当然有。第一个块中重新抛出的是当前异常(current exception),无论它是什么类型。特别是如果这个异常开始就是做为SpecialWidget类型抛出的,那么第一个块中传递出去的还是SpecialWidget异常,即使w的静态类型(static type)是Widget。这是因为重新抛出异常时没有进行拷贝操作。第二个catch块重新抛出的是新异常,类型总是Widget,因为w的静态类型(static type)是Widget。一般来说,你应该用

1
throw

来重新抛出当前的异常,因为这样不会改变被传递出去的异常类型,而且更有效率,因为不用生成一个新拷贝。

让我们测试一下下面这三种用来捕获Widget异常的catch子句,异常是做为passAndThrowWidgetp抛出的:

1
2
3
4
5
catch (Widget w) ...                // 通过传值捕获异常
catch (Widget& w) ... // 通过传递引用捕获
// 异常
catch (const Widget& w) ... //通过传递指向const的引用
//捕获异常

我们立刻注意到了传递参数与传递异常的另一个差异。一个被异常抛出的对象(刚才解释过,总是一个临时对象)可以通过普通的引用捕获;它不需要通过指向const对象的引用(reference-to-const)捕获。在函数调用中不允许转递一个临时对象到一个非const引用类型的参数里,但是在异常中却被允许。

让我们先不管这个差异,回到异常对象拷贝的测试上来。我们知道当用传值的方式传递函数的参数,我们制造了被传递对象的一个拷贝,并把这个拷贝存储到函数的参数里。同样我们通过传值的方式传递一个异常时,也是这么做的。当我们这样声明一个catch子句时:

1
catch (Widget w) ...                // 通过传值捕获

会建立两个被抛出对象的拷贝,一个是所有异常都必须建立的临时对象,第二个是把临时对象拷贝进w中。同样,当我们通过引用捕获异常时,
1
2
3
catch (Widget& w) ...               // 通过引用捕获

catch (const Widget& w) ... //也通过引用捕获

这仍旧会建立一个被抛出对象的拷贝:拷贝是一个临时对象。相反当我们通过引用传递函数参数时,没有进行对象拷贝。当抛出一个异常时,系统构造的(以后会析构掉)被抛出对象的拷贝数比以相同对象做为参数传递给函数时构造的拷贝数要多一个。

我们还没有讨论通过指针抛出异常的情况,不过通过指针抛出异常与通过指针传递参数是相同的。不论哪种方法都是一个指针的拷贝被传递。你不能认为抛出的指针是一个指向局部对象的指针,因为当异常离开局部变量的生存空间时,该局部变量已经被释放。Catch子句将获得一个指向已经不存在的对象的指针。这种行为在设计时应该予以避免。

对象从函数的调用处传递到函数参数里与从异常抛出点传递到catch子句里所采用的方法不同,这只是参数传递与异常传递的区别的一个方面,第二个差异是在函数调用者或抛出异常者与被调用者或异常捕获者之间的类型匹配的过程不同。比如在标准数学库(the standard math library)中sqrt函数:

1
double sqrt(double);                 // from <cmath> or <math.h>

我们能这样计算一个整数的平方根,如下所示:
1
2
3
int i;

double sqrtOfi = sqrt(i);

毫无疑问,C++允许进行从int到double的隐式类型转换,所以在sqrt的调用中,i 被悄悄地转变为double类型,并且其返回值也是double。一般来说,catch子句匹配异常类型时不会进行这样的转换。见下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
void f(int value)
{
try {
if (someFunction()) { // 如果 someFunction()返回
throw value; //真,抛出一个整形值
...
}
}
catch (double d) { // 只处理double类型的异常
...
}

}

在try块中抛出的int异常不会被处理double异常的catch子句捕获。该子句只能捕获真真正正为double类型的异常;不进行类型转换。因此如果要想捕获int异常,必须使用带有int或int&参数的catch子句。

不过在catch子句中进行异常匹配时可以进行两种类型转换。第一种是继承类与基类间的转换。一个用来捕获基类的catch子句也可以处理派生类类型的异常。

捕获runtime_errors异常的Catch子句可以捕获range_error类型和overflow_error类型的异常,可以接收根类exception异常的catch子句能捕获其任意派生类异常。这种派生类与基类(inheritance_based)间的异常类型转换可以作用于数值、引用以及指针上:

1
2
3
4
5
6
7
8
catch (runtime_error) ...               // can catch errors of type
catch (runtime_error&) ... // runtime_error,
catch (const runtime_error&) ... // range_error, or
// overflow_error
catch (runtime_error*) ... // can catch errors of type
catch (const runtime_error*) ... // runtime_error*,
// range_error*, or
// overflow_error*

第二种是允许从一个类型化指针(typed pointer)转变成无类型指针(untyped pointer),所以带有const void*指针的catch子句能捕获任何类型的指针类型异常:
1
catch (const void*) ...                 //捕获任何指针类型异常

传递参数和传递异常间最后一点差别是catch子句匹配顺序总是取决于它们在程序中出现的顺序。因此一个派生类异常可能被处理其基类异常的catch子句捕获,即使同时存在有能处理该派生类异常的catch子句,与相同的try块相对应。例如:

1
2
3
4
5
6
7
8
9
10
11
try {
...
}
catch (logic_error& ex) { // 这个catch块 将捕获
... // 所有的logic_error
} // 异常, 包括它的派生类
catch (invalid_argument& ex) { // 这个块永远不会被执行
... //因为所有的
} // invalid_argument
// 异常 都被上面的
// catch子句捕获。

与上面这种行为相反,当你调用一个虚拟函数时,被调用的函数位于与发出函数调用的对象的动态类型(dynamic type)最相近的类里。你可以这样说虚拟函数采用最优适合法,而异常处理采用的是最先适合法。如果一个处理派生类异常的catch子句位于处理基类异常的catch子句前面,编译器会发出警告。(因为这样的代码在C++里通常是不合法的。)不过你最好做好预先防范:不要把处理基类异常的catch子句放在处理派生类异常的catch子句的前面。象上面那个例子,应该这样去写:
1
2
3
4
5
6
7
8
9
try {
...
}
catch (invalid_argument& ex) { // 处理 invalid_argument
... //异常
}
catch (logic_error& ex) { // 处理所有其它的
... // logic_errors异常
}

综上所述,把一个对象传递给函数或一个对象调用虚拟函数与把一个对象做为异常抛出,这之间有三个主要区别。

  • 异常对象在传递时总被进行拷贝;当通过传值方式捕获时,异常对象被拷贝了两次。对象做为参数传递给函数时不需要被拷贝。
  • 对象做为异常被抛出与做为参数传递给函数相比,前者类型转换比后者要少(前者只有两种转换形式)。
  • catch子句进行异常类型匹配的顺序是它们在源代码中出现的顺序,第一个类型匹配成功的catch将被用来执行。当一个对象调用一个虚拟函数时,被选择的函数位于与对象类型匹配最佳的类里,即使该类不是在源代码的最前头。

条款13:通过引用(reference)捕获异常

当你写一个catch子句时,必须确定让异常通过何种方式传递到catch子句里。你可以有三个选择:与你给函数传递参数一样,通过指针(by pointer),通过传值(by value)或通过引用(by reference)。

从throw处传递一个异常到catch子句是一个缓慢的过程,在理论上通过指针方式捕获异常的实现对于这个过程来说是效率最高的。因为在传递异常信息时,只有采用通过指针抛出异常的方法才能够做到不拷贝对象,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class exception { ... };          // 来自标准C++库(STL)
// 中的异常类层次
// (参见条款12)

void someFunction()
{
static exception ex; // 异常对象
throw &ex; // 抛出一个指针,指向ex
}

void doSomething()
{
try {
someFunction(); // 抛出一个 exception*
}
catch (exception *ex) { // 捕获 exception*;
... // 没有对象被拷贝
}
}

这看上去很不错,但是实际情况却不是这样。为了能让程序正常运行,程序员定义异常对象时必须确保当程序控制权离开抛出指针的函数后,对象还能够继续生存。全局与静态对象都能够做到这一点,但是程序员很容易忘记这个约束。如果真是如此的话,他们会这样写代码:
1
2
3
4
5
6
7
8
9
void someFunction()
{
exception ex; // 局部异常对象;
// 当退出函数的生存空间时
// 这个对象将被释放。
...
throw &ex; // 抛出一个指针,指向
... // 已被释放的对象
}

这简直糟糕透了,因为处理这个异常的catch子句接受到的指针,其指向的对象已经不再存在。

另一种抛出指针的方法是在建立一个堆对象(new heap object):

1
2
3
4
5
6
7
8
void someFunction()
{
...
throw new exception; // 抛出一个指针,指向一个在堆中
... // 建立的对象(希望
} // 操作符new — 参见条款8—
// 自己不要再抛出一个
// 异常!)

通过指针捕获异常也不符合C+ +语言本身的规范。四个标准的异常――bad_alloc(当operator new不能分配足够的内存时,被抛出),bad_cast(当dynamic_cast针对一个引用(reference)操作失败时,被抛出),bad_typeid(当dynamic_cast对空指针进行操作时,被抛出)和bad_exception(用于unexpected异常;参见条款14)――都不是指向对象的指针,所以你必须通过值或引用来捕获它们。

通过值捕获异常(catch -by-value)可以解决上述的问题,例如异常对象删除的问题和使用标准异常类型的问题。但是当它们被抛出时系统将对异常对象拷贝两次(参见条款 12)。而且它会产生slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。这样的sliced对象实际上是一个基类对象:它们没有派生类的数据成员,而且当调用它们的虚拟函数时,系统解析后调用的是基类对象的函数。

最后剩下方法就是通过引用捕获异常(catch-by-reference)。通过引用捕获异常能使你避开上述所有问题。不象通过指针捕获异常,这种方法不会有对象删除的问题而且也能捕获标准异常类型。也不象通过值捕获异常,这种方法没有slicing problem,而且异常对象只被拷贝一次。

如果你通过引用捕获异常(catch by reference),你就能避开上述所有问题,不会为是否删除异常对象而烦恼;能够避开slicing异常对象;能够捕获标准异常类型;减少异常对象需要被拷贝的数目。

条款14:审慎使用异常规格(exception specifications)

异常规格使得代码更容易理解,因为它明确地描述了一个函数可以抛出什么样的异常。但是它不只是一个有趣的注释。编译器在编译时有时能够检测到异常规格的不一致。而且如果一个函数抛出一个不在异常规格范围里的异常,系统在运行时能够检测出这个错误,然后一个特殊函数unexpected将被自动地调用。函数unexpected 缺省的行为是调用函数terminate,而terminate缺省的行为是调用函数abort,所以一个违反异常规格的程序其缺省的行为就是halt。

一个函数调用了另一个函数,并且后者可能抛出一个违反前者异常规格的异常,例如函数f1没有声明异常规格,这样的函数就可以抛出任意种类的异常:

1
extern void f1();                  // 可以抛出任意的异常

假设有一个函数f2通过它的异常规格来声明其只能抛出int类型的异常:
1
void f2() throw(int);

f2调用f1是非常合法的,即使f1可能抛出一个违反f2异常规格的异常:
1
2
3
4
5
void f2() throw(int)
{
f1(); // 即使f1可能抛出不是int类型的
//异常,这也是合法的。
}

当带有异常规格的新代码与没有异常规格的老代码整合在一起工作时,这种灵活性就显得很重要。一种好方法是避免在带有类型参数的模板内使用异常规格。例如下面这种模板,它好像不能抛出任何异常:
1
2
3
4
5
6
// a poorly designed template wrt exception specifications
template<class T>
bool operator==(const T& lhs, const T& rhs) throw()
{
return &lhs == &rhs;
}

这个模板为所有类型定义了一个操作符函数operator==。对于任意一对类型相同的对象,如果对象有一样的地址,该函数返回true,否则返回false。

这个模板包含的异常规格表示模板生成的函数不能抛出异常。但是事实可能不会这样,因为opertor&能被一些类型对象重载。如果被重载的话,当调用从operator==函数内部调用opertor&时,opertor&可能会抛出一个异常,这样就违反了我们的异常规格,使得程序控制跳转到unexpected。

能够避免调用unexpected函数的第二个方法是如果在一个函数内调用其它没有异常规格的函数时应该去除这个函数的异常规格。这很容易理解,但是实际中容易被忽略。比如允许用户注册一个回调函数:

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
// 一个window系统回调函数指针
//当一个window系统事件发生时
typedef void (*CallBackPtr)(int eventXLocation,
int eventYLocation,
void *dataToPassBack);
//window系统类,含有回调函数指针,
//该回调函数能被window系统客户注册
class CallBack {
public:
CallBack(CallBackPtr fPtr, void *dataToPassBack)
: func(fPtr), data(dataToPassBack) {}
void makeCallBack(int eventXLocation,
int eventYLocation) const throw();
private:
CallBackPtr func; // function to call when
// callback is made
void *data; // data to pass to callback
}; // function
// 为了实现回调函数,我们调用注册函数,
//事件的作标与注册数据做为函数参数。
void CallBack::makeCallBack(int eventXLocation,
int eventYLocation) const throw()
{
func(eventXLocation, eventYLocation, data);
}

这里在makeCallBack内调用func,要冒违反异常规格的风险,因为无法知道func会抛出什么类型的异常。

通过在程序在CallBackPtr typedef中采用更严格的异常规格来解决问题:

1
2
3
typedef void (*CallBackPtr)(int eventXLocation,
int eventYLocation,
void *dataToPassBack) throw();

这样定义typedef后,如果注册一个可能会抛出异常的callback函数将是非法的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 一个没有异常给各的回调函数
void callBackFcn1(int eventXLocation, int eventYLocation,
void *dataToPassBack);
void *callBackData;
...
CallBack c1(callBackFcn1, callBackData);
//错误!callBackFcn1可能
// 抛出异常
//带有异常规格的回调函数
void callBackFcn2(int eventXLocation,
int eventYLocation,
void *dataToPassBack) throw();
CallBack c2(callBackFcn2, callBackData);
// 正确,callBackFcn2
// 没有异常规格

传递函数指针时进行这种异常规格的检查,是语言的较新的特性,所以有可能你的编译器不支持这个特性。如果它们不支持,那就依靠你自己来确保不能犯这种错误。

避免调用unexpected的第三个方法是处理系统本身抛出的异常。这些异常中最常见的是bad_alloc,当内存分配失败时它被operator new 和operator new[]抛出。

虽然防止抛出unexpected异常是不现实的,但是C++允许你用其它不同的异常类型替换unexpected异常,你能够利用这个特性。例如你希望所有的unexpected异常都被替换为UnexpectedException对象。你能这样编写代码:

1
2
3
4
5
6
7
class UnexpectedException {};          // 所有的unexpected异常对象被
//替换为这种类型对象

void convertUnexpected() // 如果一个unexpected异常被
{ // 抛出,这个函数被调用
throw UnexpectedException();
}

通过用convertUnexpected函数替换缺省的unexpected函数,来使上述代码开始运行:
1
set_unexpected(convertUnexpected);

当你这么做了以后,一个unexpected异常将触发调用convertUnexpected函数。Unexpected异常被一种UnexpectedException新异常类型替换。如果被违反的异常规格包含UnexpectedException异常,那么异常传递将继续下去,好像异常规格总是得到满足。

另一种把unexpected异常转变成知名类型的方法是替换unexpected函数,让其重新抛出当前异常,这样异常将被替换为bad_exception。你可以这样编写:

1
2
3
4
5
6
7
8
9
void convertUnexpected()          // 如果一个unexpected异常被
{ //抛出,这个函数被调用
throw; // 它只是重新抛出当前
} // 异常

set_unexpected(convertUnexpected);
// 安装 convertUnexpected
// 做为unexpected
// 的替代品

如果这么做,你应该在所有的异常规格里包含bad_exception(或它的基类,标准类exception)。你将不必再担心如果遇到unexpected异常会导致程序运行终止。任何不听话的异常都将被替换为bad_exception,这个异常代替原来的异常继续传递。

到现在你应该理解异常规格能导致大量的麻烦。编译器仅仅能部分地检测它们的使用是否一致,在模板中使用它们会有问题,一不注意它们就很容易被违反,并且在缺省的情况下它们被违反时会导致程序终止运行。异常规格还有一个缺点就是它们能导致unexpected被触发即使一个high-level调用者准备处理被抛出的异常,比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Session {                  // for modeling online
public: // sessions
~Session();
...

private:
static void logDestruction(Session *objAddr) throw();
};

Session::~Session()
{
try {
logDestruction(this);
}
catch (...) { }
}

session的析构函数调用logDestruction记录有关session对象被释放的信息,它明确地要捕获从logDestruction抛出的所有异常。但是logDestruction的异常规格表示其不抛出任何异常。现在假设被logDestruction调用的函数抛出了一个异常,而logDestruction没有捕获。我们不会期望发生这样的事情,凡是正如我们所见,很容易就会写出违反异常规格的代码。当这个异常通过logDestruction传递出来,unexpected将被调用,缺省情况下将导致程序终止执行。这是一个正确的行为,这是session析构函数的作者所希望的行为么?作者想处理所有可能的异常,所以好像不应该不给session析构函数里的catch块执行的机会就终止程序。如果logDestruction没有异常规格,这种事情就不会发生。

条款15:了解异常处理的系统开销

C++编译器必须支持异常,也就是说,当你不用异常处理时你不能让编译器生产商消除这方面的开销,因为程序一般由多个独立生成的目标文件(object files)组成,只有一个目标文件不进行异常处理并不能代表其他目标文件不进行异常处理。

使用异常处理的第二个开销来自于try 块,无论何时使用它,也就是无论何时你想能够捕获异常,那你都得为此付出代价。不同的编译器实现try块的方法不同,所以编译器与编译器间的开销也不一样。粗略地估计,如果你使用try块,代码的尺寸将增加5%-10%并且运行速度也同比例减慢。

现在我们来到了问题的核心部分,看看抛出异常的开销。事实上我们不用太关心这个问题,因为异常是很少见的,这种事件的发生往往被描述为exceptional (异常的,罕见的)。与一个正常的函数返回相比,通过抛出异常从函数里返回可能会慢三个数量级。

条款16:牢记80-20准则(80-20 rule)

软件整体的性能取决于代码组成中的一小部分。用profiler程序识别出令人讨厌的程序的20%部分。不是所有的工作都让profiler去做。你想让它去直接地测量你感兴趣的资源。

profiler 告诉你每条语句执行了多少次或各函数被调用了多少次,知道语句执行或函数调用的频繁程度,有时能帮助你洞察软件内部的行为。

条款17:考虑使用lazy evaluation(懒惰计算法)

当你使用了lazy evaluation(懒惰计算法)后,采用此种方法的类将推迟计算工作直到系统需要这些计算的结果。如果不需要结果,将不用进行计算。

引用计数

1
2
3
4
5
6
7
8
class String { ... };            // 一个string 类 (the standard
// string type may be implemented
// as described below, but it
// doesn't have to be)

String s1 = "Hello";

String s2 = s1; / 调用string拷贝构造函数

通常string拷贝构造函数让s2被s1初始化后,s1和s2都有自己的”Hello”拷贝。这种拷贝构造函数会引起较大的开销:只因为到string拷贝构造函数,就要制作s1值的拷贝并把它赋给s2。然而这时的s2并不需要这个值的拷贝,因为s2没有被使用。

懒惰能就是少工作。不应该赋给s2一个s1的拷贝,而是让s2与s1共享一个值。我们只须做一些记录以便知道谁在共享什么,就能够省掉调用new和拷贝字符的开销。事实上s1和s2共享一个数据结构,这对于client来说是透明的,对于下面的例子来说,这没有什么差别,因为它们只是读数据:

1
2
3
cout << s1;                              // 读s1的值

cout << s1 + s2; // 读s1和s2的值

仅仅当这个或那个string的值被修改时,共享同一个值的方法才会造成差异。仅仅修改一个string的值,而不是两个都被修改,这一点是极为重要的。例如这条语句:
1
s2.convertToUpperCase();

这是至关紧要的,仅仅修改s2的值,而不是连s1的值一块修改。

为了这样执行语句,string的convertToUpperCase函数应该制作s2值的一个拷贝,在修改前把这个私有的值赋给s2。在convertToUpperCase内部,我们不能再懒惰了:必须为s2(共享的)值制作拷贝以让s2自己使用。另一方面,如果不修改s2,我们就不用制作它自己值的拷贝。

除非确实需要,不去为任何东西制作拷贝。我们应该是懒惰的,只要可能就共享使用其它值。在一些应用领域,你经常可以这么做。

区别对待读取和写入

来看看使用lazy evaluation的第二种方法。考虑这样的代码:

1
2
3
4
5
6
String s = "Homer's Iliad";            // 假设是一个
// reference-counted string
...

cout << s[3]; // 调用 operator[] 读取s[3]
s[3] = 'x'; // 调用 operator[] 写入 s[3]

读取reference-counted string是很容易的,而写入这个string则需要在写入前对该string值制作一个新拷贝。我们可以推迟做出是读操作还是写操作的决定,直到我们能判断出正确的答案。

Lazy Fetching(懒惰提取)

第三个lazy evaluation的例子,假设你的程序使用了一些包含许多字段的大型对象。这些对象的生存期超越了程序运行期,所以它们必须被存储在数据库里。每一个对都有一个唯一的对象标识符,用来从数据库中重新获得对象:

1
2
3
4
5
6
7
8
9
10
11
12
class LargeObject {                        // 大型持久对象
public:
LargeObject(ObjectID id); // 从磁盘中恢复对象

const string& field1() const; // field 1的值
int field2() const; // field 2的值
double field3() const; // ...
const string& field4() const;
const string& field5() const;
...

};

现在考虑一下从磁盘中恢复LargeObject的开销:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void restoreAndProcessObject(ObjectID id)
{
LargeObject object(id); // 恢复对象
}

因为LargeObject对象实例很大,为这样的对象获取所有的数据,数据库的操作的开销将非常大,特别是如果从远程数据库中获取数据和通过网络发送数据时。而在这种情况下,不需要读去所有数据。例如,考虑这样一个程序:
```C++
void restoreAndProcessObject(ObjectID id)
{
LargeObject object(id);
if (object.field2() == 0) {
cout << "Object " << id << ": null field2./n";
}
}

这里仅仅需要filed2的值,所以为获取其它字段而付出的努力都是浪费。

当LargeObject对象被建立时,不从磁盘上读取所有的数据,这样懒惰法解决了这个问题。不过这时建立的仅是一个对象“壳“,当需要某个数据时,这个数据才被从数据库中取回。这种“demand-paged”对象初始化的实现方法是:

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 LargeObject {
public:
LargeObject(ObjectID id);

const string& field1() const;
int field2() const;
double field3() const;
const string& field4() const;
...

private:
ObjectID oid;

mutable string *field1Value; //参见下面有关
mutable int *field2Value; // "mutable"的讨论
mutable double *field3Value;
mutable string *field4Value;
...

};

LargeObject::LargeObject(ObjectID id)
: oid(id), field1Value(0), field2Value(0), field3Value(0), ...
{}

const string& LargeObject::field1() const
{
if (field1Value == 0) {
从数据库中为filed 1读取数据,使
field1Value 指向这个值;
}

return *field1Value;
}

对象中每个字段都用一个指向数据的指针来表示,LargeObject构造函数把每个指针初始化为空。这些空指针表示字段还没有从数据库中读取数值。每个LargeObject成员函数在访问字段指针所指向的数据之前必须字段指针检查的状态。如果指针为空,在对数据进行操作之前必须从数据库中读取对应的数据。

Lazy Expression Evaluation(懒惰表达式计算)

有关lazy evaluation的最后一个例子来自于数字程序。考虑这样的代码:

1
2
3
4
5
6
7
8
9
template<class T>
class Matrix { ... }; // for homogeneous matrices

Matrix<int> m1(1000, 1000); // 一个 1000 * 1000 的矩阵
Matrix<int> m2(1000, 1000); // 同上

...

Matrix<int> m3 = m1 + m2; // m1+m2

通常operator的实现使用eagar evaluation:在这种情况下,它会计算和返回m1与m2的和。这个计算量相当大(1000000次加法运算),当然系统也会分配内存来存储这些值。

lazy evaluation方法说这样做工作太多,所以还是不要去做。而是应该建立一个数据结构来表示m3的值是m1与m2的和,在用一个enum表示它们间是加法操作。很明显,建立这个数据结构比m1与m2相加要快许多,也能够节省大量的内存。

考虑程序后面这部分内容,在使用m3之前,代码执行如下:

1
2
3
4
5
Matrix<int> m4(1000, 1000);

... // 赋给m4一些值

m3 = m4 * m1;

现在我们可以忘掉m3是m1与m2的和(因此节省了计算的开销),在这里我们应该记住m3是m4与m1运算的结果。不必说,我们不用进行乘法运算。因为我们是懒惰的。

实际上lazy evaluation就存在于APL语言中。APL是在1960年代发展起来语言,能够进行基于矩阵的交互式的运算。APL使用lazy evaluation 来拖延它们的计算直到确切地知道需要矩阵哪一部分的结果,然后仅仅计算这一部分。

总结

以上这四个例子展示了lazy evaluation在各个领域都是有用的:能避免不需要的对象拷贝通过使用operator[]区分出读操作,避免不需要的数据库读取操作,避免不需要的数字操作

条款18:分期摊还期望的计算

这个条款的核心就是over-eager evaluation(过度热情计算法):在要求你做某些事情以前就完成它们。例如下面这个模板类,用来表示放有大量数字型数据的一个集合:

1
2
3
4
5
6
7
8
template<class NumericalType>
class DataCollection {
public:
NumericalType min() const;
NumericalType max() const;
NumericalType avg() const;
...
};

假设min, max和avg函数分别返回现在这个集合的最小值,最大值和平均值,有三种方法实现这三种函数。使用eager evaluation(热情计算法),当min,max和avg函数被调用时,我们检测集合内所有的数值,然后返回一个合适的值。使用lazy evaluation(懒惰计算法),只有确实需要函数的返回值时我们才要求函数返回能用来确定准确数值的数据结构。使用 over-eager evaluation(过度热情计算法),我们随时跟踪目前集合的最小值,最大值和平均值,这样当min,max或avg被调用时,我们可以不用计算就立刻返回正确的数值。如果频繁调用min,max和avg,我们把跟踪集合最小值、最大值和平均值的开销分摊到所有这些函数的调用上,每次函数调用所分摊的开销比eager evaluation或lazy evaluation要小。

隐藏在over-eager evaluation后面的思想是如果你认为一个计算需要频繁进行。你就可以设计一个数据结构高效地处理这些计算需求,这样可以降低每次计算需求的开销。

采用over-eager最简单的方法就是caching(缓存)那些已经被计算出来而以后还有可能需要的值

以下是实现findCubicleNumber的一种方法:它使用了标准模板库(STL)里的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
32
33
34
int findCubicleNumber(const string& employeeName)
{
// 定义静态map,存储 (employee name, cubicle number)
// pairs. 这个 map 是local cache。
typedef map<string, int> CubicleMap;
static CubicleMap cubes;

// try to find an entry for employeeName in the cache;
// the STL iterator "it" will then point to the found
// entry, if there is one (see Item 35 for details)
CubicleMap::iterator it = cubes.find(employeeName);

// "it"'s value will be cubes.end() if no entry was
// found (this is standard STL behavior). If this is
// the case, consult the database for the cubicle
// number, then add it to the cache
if (it == cubes.end()) {
int cubicle =
the result of looking up employeeName's cubicle
number in the database;

cubes[employeeName] = cubicle; // add the pair
// (employeeName, cubicle)
// to the cache
return cubicle;
}
else {
// "it" points to the correct cache entry, which is a
// (employee name, cubicle number) pair. We want only
// the second component of this pair, and the member
// "second" will give it to us
return (*it).second;
}
}

这个方法是使用local cache,用开销相对不大的内存中查询来替代开销较大的数据库查询。假如隔间号被不止一次地频繁需要,在findCubicleNumber内使用cache会减少返回隔间号的平均开销。

  • 贯穿本条款的是一个常见的主题,更快的速度经常会消耗更多的内存。跟踪运行时的最小值、最大值和平均值,这需要额外的空间,但是能节省时间。
  • Cache运算结果需要更多的内存,但是一旦需要被cache的结果时就能减少需要重新生成的时间。
  • Prefetch需要空间放置被prefetch的东西,但是它减少了访问它们所需的时间。

自从有了计算机就有这样的描述:你能以空间换时间。

在本条款中我提出的建议,即通过over-eager方法分摊预期计算的开销,例如caching和prefething,这并不与在条款17中提出的有关lazy evaluation的建议相矛盾。当你必须支持某些操作而不总需要其结果时,可以使用lazy evaluation用以提高程序运行效率。当你必须支持某些操作而其结果几乎总是被需要或被不止一次地需要时,可以使用over-eager用以提高程序运行效率。它们对性能的巨大提高证明在这方面花些精力是值得的。

条款19:理解临时对象的来源

在C++中真正的临时对象是看不见的,它们不出现在你的源代码中。建立一个没有命名的非堆(non-heap)对象会产生临时对象。这种未命名的对象通常在两种条件下产生:为了使函数成功调用而进行隐式类型转换函数返回对象时。

首先考虑为使函数成功调用而建立临时对象这种情况。当传送给函数的对象类型与参数类型不匹配时会产生这种情况。例如一个函数,它用来计算一个字符在字符串中出现的次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 返回ch在str中出现的次数
size_t countChar(const string& str, char ch);

char buffer[MAX_STRING_LEN];
char c;

// 读入到一个字符和字符串中,用setw
// 避免缓存溢出,当读取一个字符串时
cin >> c >> setw(MAX_STRING_LEN) >> buffer;

cout << "There are " << countChar(buffer, c)
<< " occurrences of the character " << c
<< " in " << buffer << endl;

看一下countChar的调用。第一个被传送的参数是字符数组,但是对应函数的正被绑定的参数的类型是const string&。仅当消除类型不匹配后,才能成功进行这个调用,你的编译器很乐意替你消除它,方法是建立一个string类型的临时对象。通过以buffer做为参数调用string的构造函数来初始化这个临时对象。countChar的参数str被绑定在这个临时的string对象上。当countChar返回时,临时对象自动释放。

这样的类型转换很方便,但是从效率的观点来看,临时string对象的构造和释放是不必要的开销。通常有两个方法可以消除它。一种是重新设计你的代码,不让发生这种类型转换。另一种方法是通过修改软件而不再需要类型转换。

仅当通过传值(by value)方式传递对象或传递常量引用(reference-to-const)参数时,才会发生这些类型转换。当传递一个非常量引用(reference-to-non-const)参数对象,就不会发生。考虑一下这个函数:

1
void uppercasify(string& str);               // 把str中所有的字符改变成大写

在字符计数的例子里,能够成功传递char数组到countChar中,但是在这里试图用char数组调用upeercasify函数,则不会成功:
1
2
3
char subtleBookPlug[] = "Effective C++";

uppercasify(subtleBookPlug); // 错误!

没有为使调用成功而建立临时对象,为什么呢?

假设建立一个临时对象,那么临时对象将被传递到upeercasify中,其会修改这个临时对象,把它的字符改成大写。但是对subtleBookPlug函数调用的真正参数没有任何影响;仅仅改变了临时从subtleBookPlug生成的string对象。无疑这不是程序员所希望的。程序员传递subtleBookPlug参数到uppercasify函数中,期望修改subtleBookPlug的值。当程序员期望修改非临时对象时,对非常量引用(references-to-non-const)进行的隐式类型转换却修改临时对象。这就是为什么C++语言禁止为非常量引用(reference-to-non-const)产生临时对象。这样非常量引用(reference-to-non-const)参数就不会遇到这种问题。

建立临时对象的第二种环境是函数返回对象时。例如operator+必须返回一个对象,以表示它的两个操作数的和。例如给定一个类型Number,这种类型的operator+被这样声明:

1
2
const Number operator+(const Number& lhs,
const Number& rhs);

这个函数的返回值是临时的,因为它没有被命名;它只是函数的返回值。你必须为每次调用operator+构造和释放这个对象而付出代价。

综上所述,临时对象是有开销的,所以你应该尽可能地去除它们,然而更重要的是训练自己寻找可能建立临时对象的地方。在任何时候只要见到常量引用(reference-to-const)参数,就存在建立临时对象而绑定在参数上的可能性。在任何时候只要见到函数返回对象,就会有一个临时对象被建立(以后被释放)。

条款20:协助完成返回值优化

一个返回对象的函数很难有较高的效率,因为传值返回会导致调用对象内的构造和析构函数,这种调用是不能避免的。考虑rational(有理数)类的成员函数operator

1
2
3
4
5
6
7
8
9
10
11
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
...
int numerator() const;
int denominator() const;
};

// 有关为什么返回值是const的解释,参见条款6,
const Rational operator*(const Rational& lhs,
const Rational& rhs);

甚至不用看`operator
的代码,我们就知道它肯定要返回一个对象,因为它返回的是两个任意数字的计算结果。这些结果是任意的数字。operator*`如何能避免建立新对象来容纳它们的计算结果呢?这是不可能的,所以它必须得建立新对象并返回它。

以某种方法返回对象,能让编译器消除临时对象的开销,这样编写函数通常是很普遍的。这种技巧是返回constructor argument而不是直接返回对象,你可以这样做:

1
2
3
4
5
6
7
8
// 一种高效和正确的方法,用来实现
// 返回对象的函数
const Rational operator*(const Rational& lhs,
const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}

仔细观察被返回的表达式。它看上去好象正在调用Rational的构造函数,实际上确是这样。你通过这个表达式建立一个临时的Rational对象,
1
2
Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());

并且这是一个临时对象,函数把它拷贝给函数的返回值,这种方法还会给你带来很多开销,因为你仍旧必须为在函数内临时对象的构造和释放而付出代价,你仍旧必须为函数返回对象的构造和释放而付出代价。

C++规则允许编译器优化不出现的临时对象(temporary objects out of existence)。因此如果你在如下的环境里调用operator*

1
2
3
4
Rational a = 10;
Rational b(1, 2);

Rational c = a * b; // 在这里调用operator*

编译器就会被允许消除在operator*内的临时变量和operator*返回的临时变量。它们能在为目标c分配的内存里构造return 表达式定义的对象。如果你的编译器这样去做,调用operator*的临时对象的开销就是零:没有建立临时对象。

通过使用函数的return location(或者用一个在函数调用位置的对象来替代),来消除局部临时对象――是众所周知的和被普遍实现的。它甚至还有一个名字:返回值优化(return value optimization)。

条款21:通过重载避免隐式类型转换

以下是一段代码,如果没有什么不寻常的原因,实在看不出什么东西:

1
2
3
4
5
6
7
8
9
10
11
12
class UPInt {                                 // unlimited precision
public: // integers 类
UPInt();
UPInt(int value);
...
};
//有关为什么返回值是const的解释,参见Effective C++ 条款21
const UPInt operator+(const UPInt& lhs, const UPInt& rhs);

UPInt upi1, upi2;

UPInt upi3 = upi1 + upi2;

这里还看不出什么令人惊讶的东西。upi1 和upi2都是UPInt对象,所以它们之间相加就会调用UPInts的operator函数。

现在考虑下面这些语句:

1
2
upi3 = upi1 + 10;
upi3 = 10 + upi2;

这些语句也能够成功运行。方法是通过建立临时对象把整形数10转换为UPInts。

如果我们想要把UPInt和int对象相加,通过声明如下几个函数达到这个目的,每一个函数有不同的参数类型集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const UPInt operator+(const UPInt& lhs,      // add UPInt
const UPInt& rhs); // and UPInt

const UPInt operator+(const UPInt& lhs, // add UPInt
int rhs); // and int

const UPInt operator+(int lhs, // add int and
const UPInt& rhs); // UPInt

UPInt upi1, upi2;
UPInt upi3 = upi1 + upi2; // 正确,没有由upi1 或 upi2
// 生成的临时对象
upi3 = upi1 + 10; // 正确, 没有由upi1 or 10
// 生成的临时对象
upi3 = 10 + upi2; //正确, 没有由10 or upi2
//生成的临时对象。

一旦你开始用函数重载来消除类型转换,你就有可能这样声明函数,把自己陷入危险之中:
1
const UPInt operator+(int lhs, int rhs);           // 错误!

在C+ +中有一条规则是每一个重载的operator必须带有一个用户定义类型(user-defined type)的参数。int不是用户定义类型,所以我们不能重载operator成为仅带有此类型参数的函数。

条款22:考虑用运算符的赋值形式(op=)取代其单独形式(op)

大多数程序员认为如果他们能这样写代码:

1
x = x + y;                    x = x - y;

那他们也能这样写:
1
x += y;                       x -= y;

如果x和y是用户定义的类型(user-defined type),就不能确保这样。就C++来说,operator+、operator=和operator+=之间没有任何关系。

确保operator的赋值形式(assignment version)(例如operator+=)与一个operator的单独形式(stand-alone)(例如 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
class Rational {
public:
...
Rational& operator+=(const Rational& rhs);
Rational& operator-=(const Rational& rhs);
};

// operator+ 根据operator+=实现;
//有关为什么返回值是const的解释,
//参见Effective C++条款21 和 109页 的有关实现的警告
const Rational operator+(const Rational& lhs,
const Rational& rhs)
{
return Rational(lhs) += rhs;
}

// operator- 根据 operator -= 来实现
const Rational operator-(const Rational& lhs,
const Rational& rhs)
{
return Rational(lhs) -= rhs;
}

在这个例子里,从零开始实现operator+=和-=,而operator+ 和operator- 则是通过调用前述的函数来提供自己的功能。使用这种设计方法,只用维护operator的赋值形式就行了。而且如果假设operator赋值形式在类的public接口里,这就不用让operator的单独形式成为类的友元。

如果你不介意把所有的operator的单独形式放在全局域里,那就可以使用模板来替代单独形式的函数的编写:

1
2
3
4
5
6
7
8
9
10
11
template<class T>
const T operator+(const T& lhs, const T& rhs)
{
return T(lhs) += rhs; // 参见下面的讨论
}

template<class T>
const T operator-(const T& lhs, const T& rhs)
{
return T(lhs) -= rhs; // 参见下面的讨论
}

使用这些模板,只要为operator赋值形式定义某种类型,一旦需要,其对应的operator单独形式就会被自动生成。

第一、总的来说operator 的赋值形式(例如operator+=)比其单独形式效率更高,因为单独形式要返回一个新对象,从而在临时对象的构造和释放上有一些开销、第二、提供operator的赋值形式(例如operator+=)的同时也要提供其标准形式,允许类的客户端在便利与效率上做出折衷选择。

最后一点,涉及到operator单独形式的实现。再看看operator+ 的实现:

1
2
3
template<class T>
const T operator+(const T& lhs, const T& rhs)
{ return T(lhs) += rhs; }

表达式T(lhs)调用了T的拷贝构造函数。它建立一个临时对象,其值与lhs一样。这个临时对象用来与rhs一起调用operator+= ,操作的结果被从operator+.返回。实现方法总可以使用返回值优化,所以编译器为其生成优化代码的可能就会更大。

条款23:考虑变更程序库

理想的程序库应该是短小的、快速的、强大的、灵活的、可扩展的、直观的、普遍适用的、具有良好的支持、没有使用约束、没有错误的。考虑iostream 和stdio程序库,iostream程序库与C中的stdio相比有几个优点,在效率方面,iostream程序库总是不如stdio,因为stdio产生的执行文件与iostream产生的执行文件相比尺寸小而且执行速度快。

让我们测试一个简单的benchmark 程序,只测试最基本的I/O功能。这个程序从标准输入读取30000个浮点数,然后把它们以固定的格式写到标准输出里。编译时预处理符号STDIO决定是使用stdio还是iostream。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#ifdef STDIO
#include <stdio.h>
#else
#include <iostream>
#include <iomanip>
using namespace std;
#endif

const int VALUES = 30000; // # of values to read/write

int main()
{
double d;

for (int n = 1; n <= VALUES; ++n) {
#ifdef STDIO
scanf("%lf", &d);
printf("%10.5f", d);
#else
cin >> d;
cout << setw(10) // 设定field宽度
<< setprecision(5) // 设置小数位置
<< setiosflags(ios::showpoint) // keep trailing 0s
<< setiosflags(ios::fixed) // 使用这些设置
<< d;
#endif

if (n % 5 == 0) {
#ifdef STDIO
printf("/n");
#else
cout << '/n';
#endif
}
}
return 0;
}

cout远不如printf输入方便,但是操作符<<既是类型安全(type-safe)又可以扩展,而printf则不具有这两种优点。

应该注意到stdio的高效性主要是由其代码实现决定的,所以我已经测试过的系统其将来的实现或者我没有测试过的系统的当前实现都可能表现出iostream和stdio并没有显著的差异。一旦你找到软件的瓶颈应该知道是否可能通过替换程序库来消除瓶颈。

条款24:理解虚拟函数、多继承、虚基类和RTTI所需的代价

当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。大多数编译器是使用virtual tablevirtual table pointers。virtual table和virtual table pointers通常被分别地称为vtbl和vptr。

一个vtbl通常是一个函数指针数组。在程序中的每个类只要声明了虚函数或继承了虚函数,它就有自己的vtbl,并且类中vtbl的项目是指向虚函数实现体的指针。例如,如下这个类定义:

1
2
3
4
5
6
7
8
9
10
11
class C1 {
public:
C1();

virtual ~C1();
virtual void f1();
virtual int f2(char c) const;
virtual void f3(const string& s);

void f4() const;
};

C1的virtual table数组看起来如下图所示:

注意非虚函数f4不在表中,而且C1的构造函数也不在。非虚函数就象普通的C函数那样被实现,所以有关它们的使用在性能上没有特殊的考虑。如果有一个C2类继承自C1,重新定义了它继承的一些虚函数,并加入了它自己的一些虚函数,
1
2
3
4
5
6
7
class C2: public C1 {
public:
C2(); // 非虚函数
virtual ~C2(); // 重定义函数
virtual void f1(); // 重定义函数
virtual void f5(char *str); // 新的虚函数
};

它的virtual table项目指向与对象相适合的函数。这些项目包括指向没有被C2重定义的C1虚函数的指针:

这个论述引出了虚函数所需的第一个代价:你必须为每个包含虚函数的类的virtual talbe留出空间。类的vtbl的大小与类中声明的虚函数的数量成正比(包括从基类继承的虚函数)。每个类应该只有一个virtual table,所以virtual table所需的空间可能很大。

virtual table放在哪里呢?

  • 为每一个可能需要vtbl的object文件生成一个vtbl拷贝。
    • 连接程序然后去除重复的拷贝,在最后的可执行文件或程序库里就为每个vtbl保留一个实例。
  • 采用启发式算法来决定哪一个object文件应该包含类的vtbl。
    • 要在一个object文件中生成一个类的vtbl,要求该object文件包含该类的第一个非内联、非纯虚拟函数(non-inline non-pure virual function)定义(也就是类的实现体)。因此上述C1类的vtbl将被放置到包含C1::~C1定义的object文件里(不是内联的函数),C2类的vtbl被放置到包含C1::~C2定义的object文件里(不是内联函数)。

Virtual table只实现了虚拟函数的一半机制,virtual table pointer来建立联系。每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。这个看不见的数据成员也称为vptr,被编译器加在对象里,位置只有才编译器知道。从理论上讲,我们可以认为包含有虚函数的对象的布局是这样的:

虚函数所需的第二个代价是:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价

假如我们有一个程序:

1
2
3
4
void makeACall(C1 *pC1)
{
pC1->f1();
}

通过指针pC1调用虚拟函数f1。仅仅看这段代码,你不会知道它调用的是那一个f1函数――C1::f1或C2::f1,因为pC1可以指向C1对象也可以指向C2对象。尽管如此编译器仍然得为在makeACall的f1函数的调用生成代码,它必须确保无论pC1指向什么对象,函数的调用必须正确。编译器生成的代码会做如下这些事情:

  1. 通过对象的vptr找到类的vtbl。
  2. 找到对应vtbl内的指向被调用函数的指针(在上例中是f1)。
  3. 调用第二步找到的的指针所指向的函数。

如果我们假设每个对象有一个隐藏的数据叫做vptr,而且f1在vtbl中的索引为i,此语句

1
pC1->f1();

生成的代码就是这样的
1
2
3
4
(*pC1->vptr[i])(pC1);            //调用被vtbl中第i个单元指
// 向的函数,而pC1->vptr
//指向的是vtbl;pC1被做为
// this指针传递给函数。

这几乎与调用非虚函数效率一样。在大多数计算机上它多执行了很少的一些指令。调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。

在实际运行中,虚函数所需的代价与内联函数有关。实际上虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令”,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数”。这是虚函数所需的第三个代价:你实际上放弃了使用内联函数

如果一个派生类有一个以上从基类的继承路径,基类的数据成员被复制到每一个继承类对象里,把基类定义为虚基类则可以消除这种复制。然而虚基类本身会引起它们自己的代价,因为虚基类的实现经常使用指向虚基类的指针做为避免复制的手段,一个或者更多的指针被存储在对象里。虚函数能使对象变得更大,而且不能使用内联

运行时类型识别(RTTI)能让我们在运行时找到对象和类的有关信息,你能通过使用typeid操作符访问一个类的type_info对象。我们保证可以获得一个对象动态类型信息,如果该类型有至少一个虚函数。RTTI被设计为在类的vtbl基础上实现。

使用这种实现方法,RTTI耗费的空间是在每个类的vtbl中的占用的额外单元再加上存储type_info对象的空间。下面这个表各是对虚函数、多继承、虚基类以及RTTI所需主要代价的总结:

Feature Increases Size of Objects Increases Per-Class Data Reduces Inlining
Virtual Functions Yes Yes Yes
Multiple Inheritance Yes Yes No
Virtual Base Classes Often Sometimes No
RTTI No Yes No

条款25:将构造函数和非成员函数虚拟化

当你有一个指针或引用,但是不知道其指向对象的真实类型是什么时,你可以调用虚拟函数来完成特定类型(type-specific)对象的行为。虚拟构造函数是指能够根据输入给它的数据的不同而建立不同类型的对象。还有一种特殊种类的虚拟构造函数――虚拟拷贝构造函数――也有着广泛的用途。虚拟拷贝构造函数能返回一个指针,指向调用该函数的对象的新拷贝。因为这种行为特性,虚拟拷贝构造函数的名字一般都是copySelf,cloneSelf或者是象下面这样就叫做clone。很少会有函数能以这么直接的方式实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class NLComponent {
public:
// declaration of virtual copy constructor
virtual NLComponent * clone() const = 0;
...
};

class TextBlock: public NLComponent {
public:
virtual TextBlock * clone() const // virtual copy
{ return new TextBlock(*this); } // constructor
...
};

class Graphic: public NLComponent {
public:
virtual Graphic * clone() const // virtual copy
{ return new Graphic(*this); } // constructor
...
};

正如我们看到的,类的虚拟拷贝构造函数只是调用它们真正的拷贝构造函数。因此“拷贝”的含义与真正的拷贝构造函数相同。如果真正的拷贝构造函数只做了简单的拷贝,那么虚拟拷贝构造函数也做简单的拷贝。如果真正的拷贝构造函数做了全面的拷贝,那么虚拟拷贝构造函数也做全面的拷贝。如果真正的拷贝构造函数做一些奇特的事情,象引用计数或copy-on-write,那么虚拟构造函数也这么做。

被派生类重定义的虚拟函数不用必须与基类的虚拟函数具有一样的返回类型。如果函数的返回类型是一个指向基类的指针(或一个引用),那么派生类的函数可以返回一个指向基类的派生类的指针(或引用)。这不是C++的类型检查上的漏洞,它使得又可能声明象虚拟构造函数这样的函数。

在NLComponent中的虚拟拷贝构造函数能让实现NewLetter的(正常的)拷贝构造函数变得很容易:

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 NewsLetter {
public:
NewsLetter(const NewsLetter& rhs);
...

private:
list<NLComponent*> components;
};

NewsLetter::NewsLetter(const NewsLetter& rhs)
{
// 遍历整个rhs链表,使用每个元素的虚拟拷贝构造函数
// 把元素拷贝进这个对象的component链表。
// 有关下面代码如何运行的详细情况,请参见条款35.
for (list<NLComponent*>::const_iterator it =
rhs.components.begin();
it != rhs.components.end();
++it) {

// "it" 指向rhs.components的当前元素,调用元素的clone函数,
// 得到该元素的一个拷贝,并把该拷贝放到
//这个对象的component链表的尾端。
components.push_back((*it)->clone());
}
}

遍历被拷贝的NewsLetter对象中的整个component链表,调用链表内每个元素对象的虚拟构造函数。我们在这里需要一个虚拟构造函数,因为链表中包含指向NLComponent对象的指针,但是我们知道其实每一个指针不是指向TextBlock对象就是指向Graphic对象。无论它指向谁,我们都想进行正确的拷贝操作,虚拟构造函数能够为我们做到这点。

虚拟化非成员函数

非成员函数也不能成为真正的虚拟函数。然而,既然一个函数能够构造出不同类型的新对象是可以理解的,那么同样也存在这样的非成员函数,可以根据参数的不同动态类型而其行为特性也不同。例如,假设你想为TextBlock和Graphic对象实现一个输出操作符。显而易见的方法是虚拟化这个输出操作符。但是输出操作符是operator<<,函数把ostream&做为它的左参数(left-hand argument)(即把它放在函数参数列表的左边 译者注),这就不可能使该函数成为TextBlock 或 Graphic成员函数。

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 NLComponent {
public:
// 对输出操作符的不寻常的声明
virtual ostream& operator<<(ostream& str) const = 0;
...
};

class TextBlock: public NLComponent {
public:
// 虚拟输出操作符(同样不寻常)
virtual ostream& operator<<(ostream& str) const;
};

class Graphic: public NLComponent {
public:
// 虚拟输出操作符 (让就不寻常)
virtual ostream& operator<<(ostream& str) const;
};

TextBlock t;
Graphic g;

t << cout; // 通过virtual operator<<
//把t打印到cout中。
// 不寻常的语法
g << cout; //通过virtual operator<<
//把g打印到cout中。

类的使用者得把stream对象放到<<符号的右边,这与输出操作符一般的用法相反。为了能够回到正常的语法上来,我们必须把operator<<移出TextBlock 和 Graphic类,但是如果我们这样做,就不能再把它声明为虚拟了。)

另一种方法是为打印操作声明一个虚拟函数(例如print)把它定义在TextBlock 和 Graphic类里。但是如果这样,打印TextBlock 和 Graphic对象的语法就与使用operator<<做为输出操作符的其它类型的对象不一致了,定义operator<< 和print函数,让前者调用后者!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class NLComponent {
public:
virtual ostream& print(ostream& s) const = 0;
...

};
class TextBlock: public NLComponent {
public:
virtual ostream& print(ostream& s) const;
...
};
class Graphic: public NLComponent {
public:
virtual ostream& print(ostream& s) const;
...
};
inline ostream& operator<<(ostream& s, const NLComponent& c)
{
return c.print(s);
}

条款26:限制某个类所能产生的对象数量(上)

每次实例化一个对象时,我们很确切地知道一件事情:“将调用一个构造函数。”事实确实这样,阻止建立某个类的对象,最容易的方法就是把该类的构造函数声明在类的private域:

1
2
3
4
5
class CantBeInstantiated {
private:
CantBeInstantiated();
CantBeInstantiated(const CantBeInstantiated&);
};

这样做以后,每个人都没有权力建立对象,我们能够有选择性地放松这个限制。把打印机对象封装在一个函数内,以便让每个人都能访问打印机,但是只有一个打印机对象被建立。:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class PrintJob;                           // forward 声明
// 参见Effective C++条款34
class Printer {
public:
void submitJob(const PrintJob& job);
void reset();
void performSelfTest();

friend Printer& thePrinter();

private:
Printer();
Printer(const Printer& rhs);
};

Printer& thePrinter()
{
static Printer p; // 单个打印机对象
return p;
}

这个设计由三个部分组成:

  • Printer类的构造函数是private。这样能阻止建立对象。
  • 全局函数thePrinter被声明为类的友元,让thePrinter避免私有构造含函数引起的限制。
  • thePrinter包含一个静态Printer对象,这意味着只有一个对象被建立。

客户端代码无论何时要与系统的打印机进行交互访问,它都要使用thePrinter函数:

1
2
3
4
5
6
7
8
9
class PrintJob {
public:
PrintJob(const string& whatToPrint);
...

};
string buffer;
thePrinter().reset();
thePrinter().submitJob(buffer);

使用静态函数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Printer {
public:
static Printer& thePrinter();

private:
Printer();
Printer(const Printer& rhs);
};

Printer& Printer::thePrinter()
{
static Printer p;
return p;
}

客户端使用printer时有些繁琐:
1
2
Printer::thePrinter().reset();
Printer::thePrinter().submitJob(buffer);

另一种方法是把thePrinter移出全局域,放入namespace(命名空间)。命名空间从句法上来看有些象类,但是它没有public、protected或private域。所有都是public。如下所示,我们把Printer、thePrinter放入叫做PrintingStuff的命名空间里:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace PrintingStuff {
class Printer { // 在命名空间
public: // PrintingStuff中的类
void submitJob(const PrintJob& job);
void reset();
void performSelfTest();

friend Printer& thePrinter();

private:
Printer();
Printer(const Printer& rhs);
};

Printer& thePrinter() // 这个函数也在命名空间里
{
static Printer p;
return p;
}
}

使用这个命名空间后,客户端可以通过使用fully-qualified name(完全限制符名)
1
2
PrintingStuff::thePrinter().reset();
PrintingStuff::thePrinter().submitJob(buffer);

但是也可以使用using声明,以简化键盘输入:
1
2
3
4
using PrintingStuff::thePrinter;    // 从命名空间"PrintingStuff"
//引入名字"thePrinter"
thePrinter().reset(); // 现在可以象使用局部命名
thePrinter().submitJob(buffer); // 一样,使用thePrinter

单独的Printer是位于函数里的静态成员而不是在类中的静态成员,只有第一次执行函数时,才会建立函数中的静态对象,所以如果没有调用函数,就不会建立对象。与一个函数的静态成员相比,把Printer声明为类中的静态成员还有一个缺点,它的初始化时间不确定。

第二个细微之处是内联与函数内静态对象的关系。再看一下thePrinter的非成员函数形式:

1
2
3
4
5
Printer& thePrinter()
{
static Printer p;
return p;
}

除了第一次执行这个函数时,其它时候这就是一个一行函数——return p;。记住一件事:带有内部链接的函数可能在程序内被复制(也就是说程序的目标(object)代码可能包含一个以上的内部链接函数的代码,这种复制也包括函数内的静态对象。如果建立一个包含局部静态对象的非成员函数,你可能会使程序的静态对象的拷贝超过一个!所以不要建立包含局部静态数据的非成员函数。

允许对象来去自由

使用thePrinter函数封装对单个对象的访问,以便把Printer对象的数量限制为一个,这样做的同时也会让我们在每一次运行程序时只能使用一个Printer对象。导致我们不能这样编写代码:

1
2
3
4
5
6
建立 Printer 对象 p1;
使用 p1;
释放 p1;
建立Printer对象p2;
使用 p2;
释放 p2;

这种设计在同一时间里没有实例化多个Printer对象,而是在程序的不同部分使用了不同的Printer对象。不允许这样编写有些不合理。我们必须把先前使用的对象计数的代码与刚才看到的伪构造函数代码合并在一起:
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
class Printer {
public:
class TooManyObjects{};

// 伪构造函数
static Printer * makePrinter();

~Printer();

void submitJob(const PrintJob& job);
void reset();
void performSelfTest();
...

private:
static size_t numObjects;
Printer();
Printer(const Printer& rhs); //我们不定义这个函数
}; //因为不允许
//进行拷贝

// Obligatory definition of class static
size_t Printer::numObjects = 0;

Printer::Printer()
{
if (numObjects >= 1) {
throw TooManyObjects();
}
继续运行正常的构造函数;
++numObjects;
}

Printer * Printer::makePrinter()
{ return new Printer; }

当需要的对象过多时,会抛出异常,如果你认为这种方式给你的感觉是unreasonably harsh,你可以让伪构造函数返回一个空指针。当然客户端在使用之前应该进行检测。除了客户端必须调用伪构造函数,而不是真正的构造函数之外,它们使用Printer类就象使用其他类一样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Printer p1;                               // 错误! 缺省构造函数是
// private
Printer *p2 =
Printer::makePrinter(); // 正确, 间接调用
// 缺省构造函数
Printer p3 = *p2; // 错误! 拷贝构造函数是
// private

p2->performSelfTest(); // 所有其它的函数都可以
p2->reset(); // 正常调用
...
delete p2; // 避免内存泄漏,如果
// p2 是一个 auto_ptr,
// 就不需要这步。

这种技术很容易推广到限制对象为任何数量上。我们只需把hard-wired常量值1改为根据某个类而确定的数量,然后消除拷贝对象的约束。例如,下面这个经过修改的Printer类的代码实现,最多允许10个Printer对象存在:
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
class Printer {
public:
class TooManyObjects{};
// 伪构造函数
static Printer * makePrinter();
static Printer * makePrinter(const Printer& rhs);

private:
static size_t numObjects;
static const size_t maxObjects = 10; // 见下面解释
Printer();
Printer(const Printer& rhs);
};
// Obligatory definitions of class statics
size_t Printer::numObjects = 0;
const size_t Printer::maxObjects;

Printer::Printer()
{
if (numObjects >= maxObjects) {
throw TooManyObjects();
}
}

Printer::Printer(const Printer& rhs)
{
if (numObjects >= maxObjects) {
throw TooManyObjects();
}
}

Printer * Printer::makePrinter()
{ return new Printer; }

Printer * Printer::makePrinter(const Printer& rhs)
{ return new Printer(rhs); }

或者把maxObjects作为枚举类型。
1
2
3
4
5
class Printer {
private:
enum { maxObjects = 10 }; // 在类中,
... // maxObjects为常量10
};

或者象non-const static成员一样初始化static常量:
1
2
3
4
5
6
class Printer {
private:
static const size_t maxObjects; // 没有赋给初值
};
// 放在一个代码实现的文件中
const size_t Printer::maxObjects = 10;

一个具有对象计数功能的基类

我们很容易地能够编写一个具有实例计数功能的基类,然后让像Printer这样的类从该基类继承。Printer类的计数器是静态变量numObjects,我们应该把变量放入实例计数类中。然而也需要确保每个进行实例计数的类都有一个相互隔离的计数器。使用计数类模板可以自动生成适当数量的计数器,因为我们能让计数器成为从模板中生成的类的静态成员:

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
template<class BeingCounted>
class Counted {
public:
class TooManyObjects{}; // 用来抛出异常
static int objectCount() { return numObjects; }

protected:
Counted();
Counted(const Counted& rhs);
~Counted() { --numObjects; }

private:
static int numObjects;
static const size_t maxObjects;
void init(); // 避免构造函数的
}; // 代码重复

template<class BeingCounted>
Counted<BeingCounted>::Counted()
{ init(); }

template<class BeingCounted>
Counted<BeingCounted>::Counted(const Counted<BeingCounted>&)
{ init(); }

template<class BeingCounted>
void Counted<BeingCounted>::init()
{
if (numObjects >= maxObjects) throw TooManyObjects();
++numObjects;
}

从这个模板生成的类仅仅能被做为基类使用,因此构造函数和析构函数被声明为protected。注意private成员函数init用来避免两个Counted构造函数的语句重复。

现在我们能修改Printer类,这样使用Counted模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Printer: private Counted<Printer> {
public:
// 伪构造函数
static Printer * makePrinter();
static Printer * makePrinter(const Printer& rhs);

~Printer();

void submitJob(const PrintJob& job);
void reset();
void performSelfTest();

using Counted<Printer>::objectCount; // 参见下面解释
using Counted<Printer>::TooManyObjects; // 参见下面解释

private:
Printer();
Printer(const Printer& rhs);
};

Printer使用了Counter模板来跟踪存在多少Printer对象。另一种方法是在Printer和counted<Printer>之间使用public继承,但是我们必须给Counted类一个虚拟析构函数。

当Printer继承Counted<Printer>时,它可以忘记有关对象计数的事情。编写Printer类时根本不用考虑对象计数,就好像有其他人会为它计数一样。Printer的构造函数可以是这样的:

1
2
3
4
Printer::Printer()
{
进行正常的构造函数运行
}

因为Counted<Printer>是Printer的基类,Counted<Printer>的构造函数总在Printer的前面被调用。如果建立过多的对象,Counted<Printer>的构造函数就会抛出异常,甚至都没有调用Printer的构造函数。

最后还有一点需要注意,必须定义Counted内的静态成员。对于numObjects来说,这很容易——我们只需要在Counted的实现文件里定义它即可:

1
2
template<class BeingCounted>                 // 定义numObjects
int Counted<BeingCounted>::numObjects; // 自动把它初始化为0

我们应该如何初始化Counted<Printer>::maxObjects?简单的方法就是什么也不做,让此类的客户端提供合适的初始化。Printer的作者必须把这条语句加入到一个实现文件里:

1
const size_t Counted<Printer>::maxObjects = 10;

同样FileDescriptor的作者也得加入这条语句:
1
const size_t Counted<FileDescriptor>::maxObjects = 16;

条款27:要求或禁止在堆中产生对象(上)

要求在堆中建立对象

为了执行这种限制,你必须找到一种方法禁止以调用“new”以外的其它手段建立对象。这很容易做到。非堆对象(non-heap object)在定义它的地方被自动构造,在生存时间结束时自动被释放,所以只要禁止使用隐式的构造函数和析构函数,就可以实现这种限制

把这些调用变得不合法的一种最直接的方法是把构造函数和析构函数声明为private。这样做副作用太大。没有理由让这两个函数都是private。最好让析构函数成为private,让构造函数成为public。处理过程与条款26相似,你可以引进一个专用的伪析构函数,用来访问真正的析构函数。客户端调用伪析构函数释放他们建立的对象。

例如,如果我们想仅仅在堆中建立代表unlimited precision numbers(无限精确度数字)的对象,可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class UPNumber {
public:
UPNumber();
UPNumber(int initValue);
UPNumber(double initValue);
UPNumber(const UPNumber& rhs);

// 伪析构函数 (一个const 成员函数, 因为
// 即使是const对象也能被释放。)
void destroy() const { delete this; }

private:
~UPNumber();
};

然后客户端这样进行程序设计:
1
2
3
4
5
UPNumber n;                          // 错误! (在这里合法,但是当它的析构函数被隐式地调用时,就不合法了)
UPNumber *p = new UPNumber; //正确
delete p; // 错误! 试图调用
// private 析构函数
p->destroy(); // 正确

另一种方法是把全部的构造函数都声明为private。这种方法的缺点是一个类经常有许多构造函数,类的作者必须记住把它们都声明为private。否则如果这些函数就会由编译器生成,构造函数包括拷贝构造函数,也包括缺省构造函数;编译器生成的函数总是public。因此仅仅声明析构函数为private是很简单的,因为每个类只有一个析构函数。

通过限制访问一个类的析构函数或它的构造函数来阻止建立非堆对象:

1
2
3
4
5
6
7
8
9
10
11
12
class UPNumber { ... };              // 声明析构函数或构造函数
// 为private
class NonNegativeUPNumber:
public UPNumber { ... }; // 错误! 析构函数或
//构造函数不能编译

class Asset {
private:
UPNumber value;
... // 错误! 析构函数或
//构造函数不能编译
};

这些困难不是不能克服的。通过把UPNumber的析构函数声明为protected(同时它的构造函数还保持public)就可以解决继承的问题,需要包含UPNumber对象的类可以修改为包含指向UPNumber的指针:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class UPNumber { ... };              // 声明析构函数为protected
class NonNegativeUPNumber:
public UPNumber { ... }; // 现在正确了; 派生类
// 能够访问
// protected 成员

class Asset {
public:
Asset(int initValue);
~Asset();
...

private:
UPNumber *value;
};

Asset::Asset(int initValue)
: value(new UPNumber(initValue)) // 正确
{ ... }

Asset::~Asset()
{ value->destroy(); } // 也正确

判断一个对象是否在堆中

最根本的问题是对象可以被分配在三个地方,而不是两个。是的,能够容纳对象,但是我们忘了静态对象。静态对象是那些在程序运行时仅能初始化一次的对象。静态对象不仅仅包括显示地声明为static的对象,也包括在全局和命名空间里的对象。这些对象肯定位于某些地方,而这些地方既不是栈也不是堆。

它们的位置是依据系统而定的,但是在很多栈和堆相向扩展的系统里,它们位于堆的底端。不仅没有一种可移植的方法来判断对象是否在堆上,而且连能在多数时间正常工作的“准可移植”的方法也没有。如果你实在非得必须判断一个地址是否在堆上,你必须使用完全不可移植的方法,其实现依赖于系统调用。

如果你发现自己实在为对象是否在堆中这个问题所困扰,一个可能的原因是你想知道对象是否能在其上安全调用delete。这种删除经常采用delete this这种声明狼籍的形式。不过知道“是否能安全删除一个指针”与“只简单地知道一个指针是否指向堆中的事物”不一样,因为不是所有在堆中的事物都能被安全地delete。再考虑包含UPNumber对象的Asset对象:

1
2
3
4
5
6
7
class Asset {
private:
UPNumber value;
...
};

Asset *pa = new Asset;

很明显*pa(包括它的成员value)在堆上。同样很明显在指向pa->value上调用delete是不安全的,因为该指针不是被new返回的。

幸运的是“判断是否能够删除一个指针”比“判断一个指针指向的事物是否在堆上”要容易。因为对于前者我们只需要一个operator new返回的地址集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void *operator new(size_t size)
{
void *p = getMemory(size); //调用一些函数来分配内存,
//处理内存不够的情况
把 p加入到一个被分配地址的集合;
return p;
}

void operator delete(void *ptr)
{
releaseMemory(ptr); // return memory to
// free store

从被分配地址的集合中移去ptr;
}

bool isSafeToDelete(const void *address)
{
返回address是否在被分配地址的集合中;
}

这很简单,operator new在地址分配集合里加入一个元素,operator delete从集合中移去项目,isSafeToDelete在集合中查找并确定某个地址是否在集合中。如果operator newoperator delete函数在全局作用域中,它就能适用于所有的类型,甚至是内建类型。

在实际当中,有三种因素制约着对这种设计方式的使用。

  • 第一是我们极不愿意在全局域定义任何东西,特别是那些已经具有某种含义的函数,象operator newoperator delete。正如我们所知,只有一个全局域,只有一种具有正常特征形式(也就是参数类型)的operator newoperator delete。这样做会使得我们的软件与其它也实现全局版本的operator newoperator delete的软件(例如许多面向对象数据库系统)不兼容。
  • 我们考虑的第二个因素是效率:如果我们不需要这些,为什么还要为跟踪返回的地址而负担额外的开销呢?
  • 最后一点可能有些平常,但是很重要。实现isSafeToDelete让它总能够正常工作是不可能的。难点是多继承下来的类或继承自虚基类的类有多个地址,所以无法保证传给isSafeToDelete的地址与operator new 返回的地址相同,即使对象在堆中建立。

C++使用一种抽象基类满足了我们的需要。抽象基类是不能被实例化的基类,也就是至少具有一个纯虚函数的基类。mixin(mix in)类提供某一特定的功能,并可以与其继承类提供的其它功能相兼容。这种类几乎都是抽象类。因此我们能够使用抽象混合(mixin)基类给派生类提供判断指针指向的内存是否由operator new分配的能力。该类如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class HeapTracked {                  // 混合类; 跟踪
public: // 从operator new返回的ptr

class MissingAddress{}; // 异常类,见下面代码
virtual ~HeapTracked() = 0;

static void *operator new(size_t size);
static void operator delete(void *ptr);

bool isOnHeap() const;

private:
typedef const void* RawAddress;
static list<RawAddress> addresses;
};

这个类使用了list(链表)数据结构跟踪从operator new返回的所有指针,list标准C++库的一部分。operator new函数分配内存并把地址加入到list中;operator delete用来释放内存并从list中移去地址元素。isOnHeap判断一个对象的地址是否在list中。

HeapTracked类的实作很简单,调用全局的operator new和operator delete函数来完成内存的分配与释放,list类里的函数进行插入操作和删除操作,并进行单语句的查找操作。以下是HeapTracked的全部实作:

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
// mandatory definition of static class member
list<RawAddress> HeapTracked::addresses;

// HeapTracked的析构函数是纯虚函数,使得该类变为抽象类。然而析构函数必须被定义,
//所以我们做了一个空定义。
HeapTracked::~HeapTracked() {}

void * HeapTracked::operator new(size_t size)
{
void *memPtr = ::operator new(size); // 获得内存
addresses.push_front(memPtr); // 把地址放到list的前端
return memPtr;
}

void HeapTracked::operator delete(void *ptr)
{
//得到一个 "iterator",用来识别list元素包含的ptr;
list<RawAddress>::iterator it =
find(addresses.begin(), addresses.end(), ptr);

if (it != addresses.end()) { // 如果发现一个元素
addresses.erase(it); //则删除该元素
::operator delete(ptr); // 释放内存
} else { // 否则
throw MissingAddress(); // ptr就不是用operator new
} // 分配的,所以抛出一个异常
}

bool HeapTracked::isOnHeap() const
{
// 得到一个指针,指向*this占据的内存空间的起始处,
// 有关细节参见下面的讨论
const void *rawAddress = dynamic_cast<const void*>(this);

// 在operator new返回的地址list中查到指针
list<RawAddress>::iterator it =
find(addresses.begin(), addresses.end(), rawAddress);

return it != addresses.end(); // 返回it是否被找到
}

因为isOnHeap仅仅用于HeapTracked对象中,我们能使用dynamic_cast操作符的一种特殊的特性来消除这个问题。只需简单地放入dynamic_cast,把一个指针dynamic_cast成void*类型(或const void*volatile void*),生成的指针指向“原指针指向对象内存”的开始处。但是dynamic_cast只能用于“指向至少具有一个虚拟函数的对象”的指针上。isOnHeap更具有选择性,所以能把this指针dynamic_cast成const void*,变成一个指向当前对象起始地址的指针。如果HeapTracked::operator new为当前对象分配内存,这个指针就是HeapTracked::operator new返回的指针。如果你的编译器支持dynamic_cast 操作符,这个技巧是完全可移植的。

使用这个类,即使是最初级的程序员也可以在类中加入跟踪堆中指针的功能。他们所需要做的就是让他们的类从HeapTracked继承下来。例如我们想判断Assert对象指针指向的是否是堆对象:

1
2
3
4
5
class Asset: public HeapTracked {
private:
UPNumber value;
...
};

我们能够这样查询Assert*指针,如下所示:
1
2
3
4
5
6
7
8
9
void inventoryAsset(const Asset *ap)
{
if (ap->isOnHeap()) {
ap is a heap-based asset — inventory it as such;
}
else {
ap is a non-heap-based asset — record it that way;
}
}

象HeapTracked这样的混合类有一个缺点,它不能用于内建类型,因为象int和char这样的类型不能继承自其它类型。不过使用象HeapTracked的原因一般都是要判断是否可以调用”delete this”,你不可能在内建类型上调用它,因为内建类型没有this指针。

禁止堆对象

通常对象的建立这样三种情况:对象被直接实例化对象做为派生类的基类被实例化对象被嵌入到其它对象内。我们将按顺序地讨论它们。

禁止客户端直接实例化对象很简单,利用new操作符总是调用operator new函数来达到目的。例如,如果你想不想让客户端在堆中建立UPNumber对象,你可以这样编写:

1
2
3
4
5
6
class UPNumber {
private:
static void *operator new(size_t size);
static void operator delete(void *ptr);
...
};

现在客户端仅仅可以做允许它们做的事情:
1
2
3
UPNumber n1;                         // okay
static UPNumber n2; // also okay
UPNumber *p = new UPNumber; // error! attempt to call private operator new

如果你也想禁止UPNumber堆对象数组,可以把operator new[]和operator delete[]也声明为private。有趣的是,把operator new声明为private经常会阻碍UPNumber对象做为一个位于堆中的派生类对象的基类被实例化。因为如果operator new和operator delete没有在派生类中被声明为public,它们就会被继承下来,继承了基类private函数的类,如下所示:
1
2
3
4
5
6
7
8
9
10
11
class UPNumber { ... };             // 同上
class NonNegativeUPNumber: //假设这个类
public UPNumber { //没有声明operator new
};

NonNegativeUPNumber n1; // 正确

static NonNegativeUPNumber n2; // 也正确

NonNegativeUPNumber *p = // 错误! 试图调用
new NonNegativeUPNumber; // private operator new

如果派生类声明它自己的operator new,当在堆中分配派生对象时,就会调用这个函数,必须得找到一种不同的方法防止UPNumber基类部分缠绕在这里。同样,UPNumber的operator new是private这一点,不会对分配包含做为成员的UPNumber对象的对象产生任何影响:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Asset {
public:
Asset(int initValue);
...

private:
UPNumber value;
};

Asset *pa = new Asset(100); // 正确, 调用
// Asset::operator new 或
// ::operator new, 不是
// UPNumber::operator new

条款28:灵巧(smart)指针(上)

灵巧指针是一种外观和行为都被设计成与内建指针相类似的对象,不过它能提供更多的功能。当你使用灵巧指针替代C++的内建指针(也就是dumb pointer),你就能控制下面这些方面的指针的行为:

  • 构造和析构。你可以决定建立灵巧指针时应该怎么做。通常赋给灵巧指针缺省值0,避免出现令人头疼的未初始化的指针。当指向某一对象的最后一个灵巧指针被释放时,一些灵巧指针负责删除它们指向的对象。
  • 拷贝和赋值。你能对拷贝灵巧指针或设计灵巧指针的赋值操作进行控制。对于一些类型的灵巧指针来说,期望的行为是自动拷贝它们所指向的对象或用对这些对象进行赋值操作,也就是进行deep copy(深层拷贝)。
  • Dereferencing(取出指针所指东西的内容)。当客户端引用被灵巧指针所指的对象,可以自行决定行为。

灵巧指针从模板中生成,因为要与内建指针类似,必须是strongly typed(强类型)的;模板参数确定指向对象的类型。大多数灵巧指针模板看起来都象这样:

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>                    //灵巧指针对象模板
class SmartPtr {
public:
SmartPtr(T* realPtr = 0); // 建立一个灵巧指针
// 指向dumb pointer所指的
// 对象。未初始化的指针
// 缺省值为0(null)

SmartPtr(const SmartPtr& rhs); // 拷贝一个灵巧指针

~SmartPtr(); // 释放灵巧指针

// make an assignment to a smart ptr
SmartPtr& operator=(const SmartPtr& rhs);

T* operator->() const; // dereference一个灵巧指针
// 以访问所指对象的成员

T& operator*() const; // dereference 灵巧指针

private:
T *pointee; // 灵巧指针所指的对象
};

拷贝构造函数和赋值操作符都被展现在这里。对于灵巧指针类来说,不能允许进行拷贝和赋值操作,它们应该被声明为private 。两个dereference操作符被声明为const,是因为dereference一个指针时不能对指针进行修改。最后,每个指向T对象的灵巧指针包含一个指向T的dumb pointer。这个dumb pointer指向的对象才是灵巧指针指向的真正对象。

采用不同的方法分别处理本地对象与远程对象是一件很烦人的事情。让所有的对象都位于一个地方会更方便。灵巧指针可以让程序库实现这样的梦想。

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
template<class T>                    // 指向位于分布式 DB(数据库)
class DBPtr { // 中对象的灵巧指针模板
public: //
DBPtr(T *realPtr = 0); // 建立灵巧指针,指向
// 由一个本地dumb pointer
// 给出的DB 对象
DBPtr(DataBaseID id); // 建立灵巧指针,
// 指向一个DB对象,
// 具有惟一的DB识别符
}; //同上

class Tuple { // 数据库元组类
public:
void displayEditDialog(); // 显示一个图形对话框,
// 允许用户编辑元组。
// user to edit the tuple
bool isValid() const; // 返回*this是否通过了
}; // 合法性验证

// 这个类模板用于在修改T对象时进行日志登记。
// 有关细节参见下面的叙述:
template<class T>
class LogEntry {
public:
LogEntry(const T& objectToBeModified);
~LogEntry();
};

void editTuple(DBPtr<Tuple>& pt)
{
LogEntry<Tuple> entry(*pt); // 为这个编辑操作登记日志
// 有关细节参见下面的叙述

// 重复显示编辑对话框,直到提供了合法的数值。
do {
pt->displayEditDialog();
} while (pt->isValid() == false);
}

程序员只需关心通过对象进行访问的元组,而不用关心如何声明它们,其行为就像一个内建指针。正如你所看到的,使用灵巧指针与使用dump pointer没有很大的差别。这表明了封装是非常有效的。

灵巧指针的构造、赋值和析构

灵巧指针的析构通常很简单:找到指向的对象(一般由灵巧指针构造函数的参数给出),让灵巧指针的内部成员dumb pointer指向它。如果没有找到对象,把内部指针设为0或发出一个错误信号(可以是抛出一个异常)。

看一下标准C++类库中auto_ptr模板。一个auto_ptr对象是一个指向堆对象的灵巧指针,直到auto_ptr被释放。auto_ptr模板的实作如下:

1
2
3
4
5
6
7
8
9
10
template<class T>
class auto_ptr {
public:
auto_ptr(T *ptr = 0): pointee(ptr) {}
~auto_ptr() { delete pointee; }
...

private:
T *pointee;
};

假如auto_ptr拥有对象时,它可以正常运行。但是当auto_ptr被拷贝或被赋值时,会发生什么情况呢?
1
2
3
4
5
6
auto_ptr<TreeNode> ptn1(new TreeNode);
auto_ptr<TreeNode> ptn2 = ptn1; // 调用拷贝构造函数
//会发生什么情况?
auto_ptr<TreeNode> ptn3;
ptn3 = ptn2; // 调用 operator=;
// 会发生什么情况?

如果我们只拷贝内部的dumb pointer,会导致两个auto_ptr指向一个相同的对象。这是一个灾难,因为当释放auto_ptr时每个auto_ptr都会删除它们所指的对象。这意味着一个对象会被我们删除两次。

另一种方法是通过调用new,建立一个所指对象的新拷贝。这确保了不会有许多指向同一个对象的auto_ptr,但是建立(以后还得释放)新对象会造成不可接受的性能损耗。并且我们不知道要建立什么类型的对象。如果auto_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
26
27
28
29
template<class T>
class auto_ptr {
public:
auto_ptr(auto_ptr<T>& rhs); // 拷贝构造函数

auto_ptr<T>& // 赋值
operator=(auto_ptr<T>& rhs); // 操作符
};

template<class T>
auto_ptr<T>::auto_ptr(auto_ptr<T>& rhs)
{
pointee = rhs.pointee; // 把*pointee的所有权
// 传递到 *this
rhs.pointee = 0; // rhs不再拥有
} // 任何东西

template<class T>
auto_ptr<T>& auto_ptr<T>::operator=(auto_ptr<T>& rhs)
{
if (this == &rhs) // 如果这个对象自我赋值
return *this; // 什么也不要做

delete pointee; // 删除现在拥有的对象
pointee = rhs.pointee; // 把*pointee的所有权
rhs.pointee = 0; // 从 rhs 传递到 *this

return *this;
}

注意赋值操作符在接受新对象的所有权以前必须删除原来拥有的对象。如果不这样做,原来拥有的对象将永远不会被删除。记住,除了auto_ptr对象,没有人拥有auto_ptr指向的对象。

因为当调用auto_ptr的拷贝构造函数时,对象的所有权被传递出去,所以通过传值方式传递auto_ptr对象是一个很糟糕的方法。因为:

1
2
3
4
5
6
7
8
9
// 这个函数通常会导致灾难发生
void printTreeNode(ostream& s, auto_ptr<TreeNode> p)
{ s << *p; }

int main()
{
auto_ptr<TreeNode> ptn(new TreeNode);
printTreeNode(cout, ptn); //通过传值方式传递auto_ptr
}

当printTreeNode的参数p被初始化时,ptn指向对象的所有权被传递到给了p。当printTreeNode结束执行后,p离开了作用域,它的析构函数删除它指向的对象。然而ptr不再指向任何对象,所以调用printTreeNode以后任何试图使用它的操作都将产生不可定义的行为。只有在你确实想把对象的所有权传递给一个临时的函数参数时,才能通过传值方式传递auto_ptr。通过const引用传递可以传递,方法是这样的:

1
2
3
4
// 这个函数的行为更直观一些
void printTreeNode(ostream& s,
const auto_ptr<TreeNode>& p)
{ s << *p; }

在函数里,p是一个引用,而不是一个对象,所以不会调用拷贝构造函数初始化p。当ptn被传递到上面这个printTreeNode时,它还保留着所指对象的所有权,调用printTreeNode以后还可以安全地使用ptn。

当拷贝一个对象或这个对象做为赋值的数据源,就会修改该对象

灵巧指针的析构函数通常是这样的:

1
2
3
4
5
6
7
template<class T>
SmartPtr<T>::~SmartPtr()
{
if (*this owns *pointee) {
delete pointee;
}
}

实作Dereference 操作符

让我们把注意力转向灵巧指针的核心部分,the operator*operator->函数。理论上,这很简单:

1
2
3
4
5
6
7
template<class T>
T& SmartPtr<T>::operator*() const
{
perform "smart pointer" processing;

return *pointee;
}

注意返回类型是一个引用。必须时刻牢记:pointee不用必须指向T类型对象;它也可以指向T的派生类对象。如果在这种情况下operator*函数返回的是T类型对象而不是派生类对象的引用,你的函数实际上返回的是一个错误类型的对象。

operator->的情况与operator是相同的,但是在分析operator->之前,让我们先回忆一下这个函数调用的与众不同的含义。再考虑editTuple函数,其使用一个指向Tuple对象的灵巧指针:

1
2
3
4
5
6
7
8
void editTuple(DBPtr<Tuple>& pt)
{
LogEntry<Tuple> entry(*pt);

do {
pt->displayEditDialog();
} while (pt->isValid() == false);
}

语句pt->displayEditDialog();被编译器解释为:(pt.operator->())->displayEditDialog();,这意味着不论operator->返回什么,它必须使用成员选择操作符(->)。因此operator->仅能返回两种东西:一个指向某对象的dumb pointer*另一个灵巧指针
。多数情况下,你想返回一个普通dumb pointer。在此情况下,你这样实作operator-> :
1
2
3
4
5
6
template<class T>
T* SmartPtr<T>::operator->() const
{
perform "smart pointer" processing;
return pointee;
}

这样做运行良好。因为该函数返回一个指针,通过operator->调用虚拟函数,其行为也是正确的。

测试灵巧指针是否为NULL

目前为止我们讨论的函数能让我们建立、释放、拷贝、赋值、dereference灵巧指针。但是有一件我们做不到的事情是“发现灵巧指针为NULL”:

1
2
3
4
SmartPtr<TreeNode> ptn;
if (ptn == 0) ... // error!
if (ptn) ... // error!
if (!ptn) ... // error!

这是一个严重的限制。

在灵巧指针类里加入一个isNull成员函数是一件很容易的事,但是仍然没有解决当测试NULL时灵巧指针的行为与dumb pointer不相似的问题。另一种方法是提供隐式类型转换操作符,允许编译上述的测试。一般应用于这种目的的类型转换是void* :

1
2
3
4
5
6
7
8
9
10
11
template<class T>
class SmartPtr {
public:
...
operator void*(); // 如果灵巧指针为null,返回0,否则返回非0。
}
SmartPtr<TreeNode> ptn;

if (ptn == 0) ... // 现在正确
if (ptn) ... // 也正确
if (!ptn) ... // 正确

这与iostream类中提供的类型转换相同,所以可以这样编写代码:

1
2
ifstream inputFile("datafile.dat");
if (inputFile) ... // 测试inputFile是否已经被成功地打开。

象所有的类型转换函数一样,它有一个缺点,在一些情况下虽然大多数程序员希望它调用失败,但是函数还能够成功地被调用。特别是它允许灵巧指针与完全不同的类型之间进行比较:
1
2
3
4
SmartPtr<Apple> pa;
SmartPtr<Orange> po;

if (pa == po) ... // 这能够被成功编译!

即使在SmartPtr<Apple>SmartPtr<Orange>之间没有operator= 函数,也能够编译,因为灵巧指针被隐式地转换为void*指针,对于内建指针类型有一个内建的比较函数。这种进行隐式类型转换的行为特性很危险。

有一种两全之策可以提供合理的测试空值的语法形式,这就是在灵巧指针类中重载operator!,当且仅当灵巧指针是一个空指针时,operator!返回true:

1
2
3
4
5
6
7
template<class T>
class SmartPtr {
public:
...
bool operator!() const; // 当且仅当灵巧指针是
... // 空值,返回true。
};

客户端程序如下所示:
1
2
3
4
5
6
7
SmartPtr<TreeNode> ptn;
if (!ptn) { // 正确
... // ptn 是空值
}
else {
... // ptn不是空值
}

但是这样就不正确了:
1
2
if (ptn == 0) ...                    // 仍然错误
if (ptn) ... // 也是错误的

仅在这种情况下会存在不同类型之间进行比较:
1
2
3
SmartPtr<Apple> pa;
SmartPtr<Orange> po;
if (!pa == !po) ... // 能够编译

通过成员模板来实现灵巧指针的类型转换有两个缺点。第一,支持成员模板的编译器较少,所以这种技术不具有可移植性。第二,这种方法的工作原理不很明了,要理解它必须先要深入理解函数调用的参数匹配,隐式类型转换函数,模板函数隐式实例化和成员函数模板。正如Daniel Edelson所说,灵巧指针固然灵巧,但不是指针。最好的方法是使用成员模板生成类型转换函数,在会产生二义性结果的地方使用casts

灵巧指针和const

对于dumb指针来说,const既可以针对指针所指向的东西,也可以针对于指针本身,或者兼有两者的含义:

1
2
3
4
5
6
7
8
9
CD goodCD("Flood");
const CD *p; // p 是一个non-const 指针
//指向 const CD 对象
CD * const p = &goodCD; // p 是一个const 指针
// 指向non-const CD 对象;
// 因为 p 是const, 它
// 必须被初始化
const CD * const p = &goodCD; // p 是一个const 指针
// 指向一个 const CD 对象

我们自然想要让灵巧指针具有同样的灵活性。不幸的是只能在一个地方放置const,并只能对指针本身起作用,而不能针对于所指对象:
1
2
const SmartPtr<CD> p =                // p 是一个const 灵巧指针
&goodCD; // 指向 non-const CD 对象

好像有一个简单的补救方法,就是建立一个指向cosnt CD的灵巧指针:
1
2
SmartPtr<const CD> p =            // p 是一个 non-const 灵巧指针
&goodCD; // 指向const CD 对象

现在我们可以建立const和non-const对象和指针的四种不同组合:
1
2
3
4
5
6
7
8
9
10
11
SmartPtr<CD> p;                          // non-const 对象
// non-const 指针

SmartPtr<const CD> p; // const 对象,
// non-const 指针

const SmartPtr<CD> p = &goodCD; // non-const 对象
// const指针

const SmartPtr<const CD> p = &goodCD; // const 对象
// const 指针

但是美中不足的是,使用dumb指针我们能够用non-const指针初始化const指针,我们也能用指向non-cosnt对象的指针初始化指向const对象的指针;就像进行赋值一样。例如:
1
2
CD *pCD = new CD("Famous Movie Themes");
const CD * pConstCD = pCD; // 正确

但是如果我们试图把这种方法用在灵巧指针上,情况会怎么样呢?
1
2
SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtr<const CD> pConstCD = pCD; // 正确么?

SmartPtr<CD>SmartPtr<const CD>是完全不同的类型。在编译器看来,它们是毫不相关的,所以没有理由相信它们是赋值兼容的。到目前为止这是一个老问题了,把它们变成赋值兼容的惟一方法是你必须提供函数,用来把SmartPtr类型的对象转换成SmartPtr类型。如果你使用的编译器支持成员模板,就可以利用前面所说的技巧自动生成你需要的隐式类型转换操作。

包括const的类型转换是单向的:从non-const到const的转换是安全的,但是从const到non-const则不是安全的。而且用const指针能的事情,用non-const指针也能做,但是用non-const指针还能做其它一些事情。同样,用指向const的指针能做的任何事情,用指向non-const的指针也能做到,但是用指向non-const的指针能够完成一些使用指向const的指针所不能完成的事情。

这些规则看起来与public继承的规则相类似。你能够把一个派生类对象转换成基类对象,但是反之则不是这样,你对基类所做的任何事情对派生类也能做,但是还能对派生类做另外一些事情。我们能够利用这一点来实作灵巧指针,就是说可以让每个指向T的灵巧指针类public派生自一个对应的指向const-T的灵巧指针类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<class T>                    // 指向const对象的
class SmartPtrToConst { // 灵巧指针
... // 灵巧指针通常的
// 成员函数

protected:
union {
const T* constPointee; // 让 SmartPtrToConst 访问
T* pointee; // 让 SmartPtr 访问
};
};

template<class T> // 指向non-const对象
class SmartPtr: // 的灵巧指针
public SmartPtrToConst<T> {
... // 没有数据成员
};

使用这种设计方法,指向non-const-T对象的灵巧指针包含一个指向const-T的dumb指针,指向const-T的灵巧指针需要包含一个指向cosnt-T的dumb指针。最方便的方法是把指向const-T的dumb指针放在基类里,把指向non-const-T的dumb指针放在派生类里,然而这样做有些浪费,因为SmartPtr对象包含两个dumb指针:一个是从SmartPtrToConst继承来的,一个是SmartPtr自己的。

一种在C世界里的老式武器可以解决这个问题,这就是union,它在C++中同样有用。Union在protected中,所以两个类都可以访问它,它包含两个必须的dumb指针类型,SmartPtrToConst<T>对象使用constPointee指针,SmartPtr<T>对象使用pointee指针。因此我们可以在不分配额外空间的情况下,使用两个不同的指针。

利用这种新设计,我们能够获得所要的行为特性:

1
2
SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtrToConst<CD> pConstCD = pCD; // 正确

条款29:引用计数

引用计数允许多个有相同值的对象共享这个值的实现。这个技巧:

  • 简化跟踪堆中的对象的过程。引用计数可以免除跟踪对象所有权的担子,对象自己拥有自己。当没人再使用它时,它自己自动销毁自己。因此,引用计数是个简单的垃圾回收体系。
  • 让所有的对象共享这个值的实现。这么做不但节省内存,而且可以使得程序运行更快,因为不需要构造和析构这个值的拷贝。

保存当前共享/引用同一个值的对象数目的需求意味着我们必须增加一个计数值(引用计数)。

所以我们将创建一个类来保存引用计数及其跟踪的值。我们叫这个类StringValue,又因为它唯一的用处就是帮助我们实现String类,所以我们将它嵌套在String类的私有区内。另外,为了便于String的所有成员函数读取其数据区,我们将StringValue申明为struct。

1
2
3
4
5
6
class String {
private:
struct StringValue { ... }; // holds a reference count
// and a string value
StringValue *value; // value of this String
};

这是StringValue的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class String {
private:
struct StringValue {
int refCount;
char *data;
StringValue(const char *initValue);
~StringValue();
};
...
};
String::StringValue::StringValue(const char *initValue)
: refCount(1)
{
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::~StringValue()
{
delete [] data;
}

StringValue的主要目的是提供一个空间将一个特别的值和共享此值的对象的数目联系起来。接下来是String的成员函数,首先是构造函数:
1
2
3
4
5
6
class String {
public:
String(const char *initValue = "");
String(const String& rhs);
...
};

第一个构造函数被实现得尽可能简单。我们用传入的char *字符串创建了一个新的StringValue对象,并将我们正在构造的string对象指向这个新生成的StringValue:
1
2
3
String::String(const char *initValue)
: value(new StringValue(initValue))
{}

String的拷贝构造函数很高效:新生成的String对象与被拷贝的对象共享相同的StringValue对象:

1
2
3
4
5
String::String(const String& rhs)
: value(rhs.value)
{
++value->refCount;
}

这肯定比通常的string类高效,因为不需要为新生成的string值分配内存、释放内存以及将内容拷贝入这块内存。

String类的析构函数同样容易实现,因为大部分情况下它不需要做任何事情。只要引用计数值不是0,也就是至少有一个String对象使用这个值,这个值就不可以被销毁。只有当唯一的使用者被析构了,String的析构函数才摧毁StringValue对象:

1
2
3
4
5
6
7
8
9
class String {
public:
~String();
...
};
String::~String()
{
if (--value->refCount == 0) delete value;
}

这就是String的构造和析构,我们现在转到赋值操作:
1
2
3
4
5
class String {
public:
String& operator=(const String& rhs);
...
};

当用户写下这样的代码:
1
s1 = s2;                              // s1 and s2 are both String objects

其结果应该是s1和s2指向相同的StringValue对象。对象的引用计数应该在赋值时被增加。并且,s1原来指向的StringValue对象的引用计数应该减少,因为s1不再具有这个值了。如果s1是拥有原来的值的唯一对象,这个值应该被销毁。在C++中,其实现看起来是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
String& String::operator=(const String& rhs)
{
if (value == rhs.value) { // do nothing if the values
return *this; // are already the same; this
} // subsumes the usual test of
// this against &rhs
if (--value->refCount == 0) { // destroy *this's value if
delete value; // no one else is using it
}
value = rhs.value; // have *this share rhs's
++value->refCount; // value
return *this;
}

写时拷贝

围绕我们的带引用计数的String类,考虑一下数组下标操作([]),它允许字符串中的单个字符被读或写:

1
2
3
4
5
6
class String {
public:
const char& operator[](int index) const; // for const Strings
char& operator[](int index); // for non-const Strings
...
};

这个函数的const版本的实现很容易,因为它是一个只读操作,String对象的值不受影响:
1
2
3
4
const char& String::operator[](int index) const
{
return value->data[index];
}

非const的operator[]版本就是一个完全不同的故事了。它可能是被调用了来读一个字符,也可能被调用了来写一个字符:

1
2
3
4
String s;
...
cout << s[3]; // this is a read
s[5] = 'x'; // this is a write

我们必须保守地假设“所有”调用非const operator[]的行为都是为了写操作。为了安全地实现非const的operator[],我们必须确保没有其它String对象在共享这个可能被修改的StringValue对象。简而言之,当我们返回StringValue对象中的一个字符的引用时,必须确保这个StringValue的引用计数是1。这儿是我们的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
char& String::operator[](int index)
{
// if we're sharing a value with other String objects,
// break off a separate copy of the value for ourselves
if (value->refCount > 1) {
--value->refCount; // decrement current value's
// refCount, because we won't
// be using that value any more
value = // make a copy of the
new StringValue(value->data); // value for ourselves
}
// return a reference to a character inside our
// unshared StringValue object
return value->data[index];
}

指针、引用与写时拷贝

大部分情况下,写时拷贝可以同时保证效率和正确性。只有一个挥之不去的问题。看一下这样的代码:

1
2
String s1 = "Hello";
char *p = &s1[1];

现在看增加一条语句:

1
String s2 = s1;

String的拷贝构造函数使得s2共享s1的StringValue对象,下面这样的语句将有不受欢迎的结果:
1
*p = 'x';                     // modifies both s1 and s2!

String的拷贝构造函数没有办法检测这样的问题,因为它不知道指向s1拥有的StringValue对象的指针的存在。并且,这个问题不局限于指针:它同样存在于有人保存了一个String的非const operator[]的返回值的引用的情况下。

解决的方法是这样的:在每个StringValue对象中增加一个标志以指出它是否为可共享的。在最初(对象可共享时)将标志打开,在非const的operator[]被调用时将它关闭。一旦标志被设为false,它将永远保持在这个状态。

这是增加了共享标志的修改版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class String {
private:
struct StringValue {
int refCount;
bool shareable; // add this
char *data;
StringValue(const char *initValue);
~StringValue();
};
...
};
String::StringValue::StringValue(const char *initValue)
: refCount(1),
shareable(true) // add this
{
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::~StringValue()
{
delete [] data;
}

如上所见,并不需要太多的改变;需要修改的两行都有注释。当然,String的成员函数也必须被修改以处理这个共享标志。这里是拷贝构造函数的实现:
1
2
3
4
5
6
7
8
9
10
String::String(const String& rhs)
{
if (rhs.value->shareable) {
value = rhs.value;
++value->refCount;
}
else {
value = new StringValue(rhs.value->data);
}
}

所有其它的成员函数也都必须以类似的方法检查这个共享标志。非const的operator[]版本是唯一将共享标志设为false的地方:
1
2
3
4
5
6
7
8
9
char& String::operator[](int index)
{
if (value->refCount > 1) {
--value->refCount;
value = new StringValue(value->data);
}
value->shareable = false; // add this
return value->data[index];
}

带引用计数的基类

将引用计数的代码写成与运行环境无关的,第一步是构建一个基类RCObject,任何需要引用计数的类都必须从它继承。RCObject封装了引用计数功能,如增加和减少引用计数的函数。它还包含了当这个值不再被需要时摧毁值对象的代码。最后,它包含了一个字段以跟踪这个值对象是否可共享,并提供查询这个值和将它设为false的函数。不需将可共享标志设为true的函数,因为所有的值对象默认都是可共享的。如上面说过的,一旦一个对象变成了不可共享,将没有办法使它再次成为可共享。RCObject的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class RCObject {
public:
RCObject();
RCObject(const RCObject& rhs);
RCObject& operator=(const RCObject& rhs);
virtual ~RCObject() = 0;
void addReference();
void removeReference();
void markUnshareable();
bool isShareable() const;
bool isShared() const;
private:
int refCount;
bool shareable;
};

RCObjcet可以被构造(作为派生类的基类部分)和析构;可以有新的引用加在上面以及移除当前引用;其可共享性可以被查询以及被禁止;它们可以报告当前是否被共享了。这就是它所提供的功能。对于想有引用计数的类,这确实就是我们所期望它们完成的东西。注意虚析构函数,它明确表明这个类是被设计了作基类使用的。同时要注意这个析构函数是纯虚的,它明确表明这个类只能作基类使用。

RCOject的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RCObject::RCObject()
: refCount(0), shareable(true) {}
RCObject::RCObject(const RCObject&)
: refCount(0), shareable(true) {}
RCObject& RCObject::operator=(const RCObject&)
{ return *this; }
RCObject::~RCObject() {} // virtual dtors must always
// be implemented, even if
// they are pure virtual
// and do nothing
void RCObject::addReference() { ++refCount; }
void RCObject::removeReference()
{ if (--refCount == 0) delete this; }
void RCObject::markUnshareable()
{ shareable = false; }
bool RCObject::isShareable() const
{ return shareable; }
bool RCObject::isShared() const
{ return refCount > 1; }

为了使用我们新写的引用计数基类,我们将StringValue修改为是从RCObject继承而得到引用计数功能的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class String {
private:
struct StringValue: public RCObject {
char *data;
StringValue(const char *initValue);
~StringValue();
};
...
};
String::StringValue::StringValue(const char *initValue)
{
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::~StringValue()
{
delete [] data;
}

这个版本的StringValue和前面的几乎一样,唯一改变的就是StringValue的成员函数不再处理refCount字段。RCObject现在接管了这个工作。

实现引用计数不是没有代价的。每个被引用的值带一个引用计数,其大部分操作都需要以某种形式检查或操作引用计数。对象的值需要更多的内存,而我们在处理它们时需要执行更多的代码。此外,就内部的源代码而言,带引用计数的类的复杂度比不带的版本高。没有引用计数的String类只依赖于自己,而我们最终的String类如果没有三个辅助类(StringValue、RCObject和RCPtr)就无法使用。

总之,引用计数在下列情况下对提高效率很有用:

  • 少量的值被大量的对象共享。这样的共享通常通过调用赋值操作和拷贝构造而发生。对象/值的比例越高,越是适宜使用引用计数。
  • 对象的值的创建和销毁代价很高昂,或它们占用大量的内存。即使这样,如果不是多个对象共享相同的值,引用计数仍然帮不了你任何东西。

条款30:代理类(Proxy classes)

所谓代理类(proxy class),指的是它的每一个对象都是为了其他对象而存在的,就像是其他对象的代理人一般。某些情况下用代理类取代某些内置类型可以实现独特的功能,因为可以为代理类定义成员函数而但却无法对内置类型定义操作。

C++没有提供分配动态二维数组的语法,因此常常需要定义一些类(模板实现这些功能),像这样:

1
2
3
4
5
6
template<class T>
class Array2D {
public:
Array2D(int dim1, int dim2);
...
};

既然是二维数组,那么有必要提供使用”[][]”访问元素的操作,然而[][]并不是一个操作符,C++也就不允许重载一个operator[][],解决办法就是采用代理类,像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T>
class Array2D {
public:
//代理类
class Array1D {
public:
T& operator[](int index);
const T& operator[](int index) const;
...
};
Array1D operator[](int index);
const Array1D operator[](int index) const;
...
};

那么以下操作:
1
2
3
Array2D<float> data(10, 20);
...
cout << data[3][6];

data[3][6]实际上进行了两次函数调用:第一次调用Array2D的operator[],返回Array1D对象,第二次调用Array1D的operator[],返回指定元素。

区分operator[]的读写动作

条款29用String类的例子讨论了引用计数,由于当时无法判断non-const版本oeprator[]返回的字符将被用于读操作还是写操作,因此保险起见,一旦调用non-const版本operator[],便开辟一块新内存并复制数据结构到新内存。在这种策略下,因此如果operator[]返回的字符被用于读操作,那么分配新内存并复制数据结构的行为其实是不必要的,由此会带来效率损失,使用proxy class便可以做到区分non-const operator[]用于读还是写操作,在 proxy 类上只能做三件事:

  • 创建它,也就是指定它扮演哪个字符。
  • 将它作为赋值操作的目标,在这种情况下可以将赋值真正作用在它扮演的字符上。这样被使用时,proxy 类扮演的是左值。
  • 用其它方式使用它。这时,代理类扮演的是右值。

像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class String {
public:
//代理类用于区分operator[]的读写操作
class CharProxy { // proxies for string chars
public:
CharProxy(String& str, int index); // creation
CharProxy& operator=(const CharProxy& rhs); // lvalue
CharProxy& operator=(char c); // uses
operator char() const;
private:
String& theString; //用于操作String,并在适当时机开辟新内存并复制
int charIndex;
};
const CharProxy operator[](int index) const; // for const Strings
CharProxy operator[](int index); // for non-const Strings
...
friend class CharProxy;
private:
RCPtr<StringValue> value;//见条款29
};

对String调用operator[]将返回CharProxy对象,CharProxy通过重载oeprator char模拟char类型的行为,但它比char类型更有优势——可以为CharProxy定义新的操作,这样当对CharProxy使用operator=时,便可以得知对CharProxy进行写操作,由于CHarProxy保存了父对象String的一个引用,便可以在现在执行开辟内存并复制数据结构的行为,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs)
{
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex];
return *this;
}
String::CharProxy& String::CharProxy::operator=(char c)
{
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
theString.value->data[charIndex] = c;
return *this;
}
//以上来那个函数的代码部分有重复,可考虑将重复部分提取成一个函数

由于内存开辟和数据结构赋值任务交由CharProxy完成,String的operator[]相当简单,像这样:

1
2
3
4
5
6
7
8
const String::CharProxy String::operator[](int index) const
{
return CharProxy(const_cast<String&>(*this), index);
}
String::CharProxy String::operator[](int index)
{
return CharProxy(*this, index);
}

CharProxy实现的其他部分如下:
1
2
3
4
5
String::CharProxy::CharProxy(String& str, int index): theString(str), charIndex(index) {}
String::CharProxy::operator char() const
{
return theString.value->data[charIndex];
}

局限性.

就像智能指针永远无法完全取代内置指针一样,proxy class也永远无法模仿内置类型的所有特点.proxy class可以实现内置类型无法做到功能,但有利有弊——为了模仿内置类型的其他特点,它还要打许多”补丁”.

  • 对proxy class取址.
    • 条款29通过为StringValue类添加可共享标志(flag)来表示对象是否可被共享以防止外部指针的篡改,其中涉及到对operator[]返回值进行取址操作,这就提示CharProxy也需要对operator&进行重载,像这样:
1
2
3
4
5
6
7
8
9
10
11
class String {
public:
class CharProxy {
public:
...
char * operator&();
const char * operator&() const;
...
};
...
};

const版本operator&实现比较容易:

1
2
3
4
5
const char * String::CharProxy::operator&() const
{
return &(theString.value->data[charIndex]);
}
non-const版本的operator&要做的事情多一些:

1
2
3
4
5
6
7
8
9
10
char * String::CharProxy::operator&()
{
//如果正在使用共享内存,就开辟新内存并复制数据结构
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
//由于有外部指针指向它,因此有被篡改风险,禁止使用共享内存
theString.value->markUnshareable();
return &(theString.value->data[charIndex]);
}
  • 将proxy class传递给接受”references to non-const objects”的函数.

假设有一个swap函数用于对象两个char的内容:

1
void swap(char& a, char& b);

那么将无法将CharProxy做参数传递给swap,因为swap的参数是char&,尽管CharProxy可以转换到char,但由于抓换后的char是临时对象,仍然无法绑定到char&,解决方法似乎只有对swap进行重载.

  • 通过proxy cobjects调用真实对象的member function.

    • 如果proxy class的作用是用来取代内置类型,那么它必须也应该对内置类型能够进行的操作进行重载,如++,+=等,如果它用来取代类类型,那么它也必须具有相同成员函数,使得对该类类型能够进行的操作同样也能够施行于proxy class.
  • 隐式类型转换.

    • proxy class要具有和被代理类型相同的行为,通常的做法是重载隐式转换操作符,正如条款5对proxy class的使用那样,proxy class可以利用”用户定制的隐式类型转换不能连续实行两次”的特点阻止不必要的隐式类型转换,proxy class同样可能因为这个特点而阻止用户需要的隐式类型转换.

proxy class的作用很强大,像上面所提到的实现多维数组,区分operator[]的读写操作,压抑隐式类型转换等,但是也有其缺点,如果函数返回proxy class对象,那么它生成一个临时对象,产生和销毁它就有可能带来额外的构造和析构成本,此外正如4所讲,proxy class无法完全代替真正对象的行为,尽管大多数情况下真正对象的操作都可由proxy class完成.

条款31:让函数根据一个以上的对象来决定怎么虚拟

问题来源:假设正在编写一个小游戏,游戏的背景是发生在太空,有宇宙飞船、太空船和小行星,它们可能会互相碰撞,而且其碰撞的规则不同,如何用C++代码处理物体间的碰撞。代码的框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GameObject{...};
class SpaceShip:public GameObject{...};
class SpaceStation:public GameObject{...};
class Asteroid:public GameObject{...};

void checkForCollision(GameObject& obj1,GameObject& obj2)
{
if(theyJustCollided(obj1,obj2))
{
processCollision(obj1,obj2);
}
else
{
...
}
}

正如上述代码所示,当调用processCollision()时,obj1和obj2的碰撞结果取决于obj1和obj2的真实类型,但我们只知道它们是GameObject对象。相当于我们需要一种作用在多个对象上的虚函数。这类型问题,在C++中被称为二重调度问题,下面介绍几种方法解决二重调度问题。

虚函数加RTTI

虚函数实现了一个单一调度,我们只需要实现另一调度。其具体实现方法:将processCollision()定义为虚函数,解决一重调度,然后只需要检测一个对象类型,利用RTTI来检测对象的类型,再利用if…else语句来调用不同的处理方法。具体实现如下:

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 GameObject{
public:
virtual void collide(GameObject& otherObject) = 0;
...
};
class SpaceShip:public GameObject{
public:
virtual void collide(GameObject& otherObject);
...
};

class CollisionWithUnknownObject{
public:
CollisionWithUnknownObject(GameObject& whatWehit);
...
};
void SpaceShip::collide(GameObject& otherObject)
{
const type_info& objectType = typeid(otherObject);
if(objectType == typeid(SpaceShip))
{
SpaceShip& ss = static_cast<SpaceShip&>(otherObject);
process a SpaceShip-SpaceShip collision;
}
else if(objectType == typeid(SpaceStation))
{
SpaceStation& ss = static_cast<SpaceStation&>(otherObject);
process a SpaceShip-SpaceStation collision;
}
else if(objectType == typeid(Asteroid))
{
Asteroid& a = static_cast<Asteriod&>(otherObject);
process a SpaceShip-Asteroid collision;
}
else
{
throw CollisionWithUnknownObject(otherObject);
}
}

该方法的实现简单,容易理解,其缺点是其扩展性不好。如果增加一个新的类时,我们必须更新每一个基于RTTI的if…else链以处理这个新的类型。

只使用虚函数

基本原理就是用两个单一调度实现二重调度,也就是有两个单独的虚函数调用:第一次决定第一个对象的动态类型,第二次决定第二个对象动态类型。其具体实现如下:

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
class SpaceShip;
class SpaceStation;
class Asteroid;
class GameObject{
public:
virtual void collide(GameObject& otherObject) = 0;
virtual void collide(SpaceShip& otherObject) = 0;
virtual void collide(SpaceStation& otherObject) = 0;
virtual void collide(Asteroid& otherObject) = 0;
...
};
class SpaceShip:public GameObject{
public:
virtual void collide(GameObject& otherObject);
virtual void collide(SpaceShip& otherObject);
virtual void collide(SpaceStation& otherObject);
virtual void collide(Asteroid& otherObject);
...
};

void SpaceShip::collide(GameObject& otherObject)
{
otherObject.collide(*this);
}
void SpaceShip::collide(SpaceShip& otherObject)
{
process a SpaceShip-SpaceShip collision;
}
void SpaceShip::collide(SpaceStation& otherObject)
{
process a SpaceShip-SpaceStation collision;
}
void SpaceShip::collide(Asteroid& otherObject)
{
process a SpaceShip-Asteroid collision;
}

与前面RTTI方法一样,该方法的缺点扩展性不好。每个类都必须知道它的同胞类,当增加新类时,所有的代码都必须更新。

模拟虚函数表

编译器通常创建一个函数指针数组(vtbl)来实现虚函数,并在虚函数被调用时在这个数组中进行下标索引。我们可以借鉴编译器虚拟函数表的方法,建立一个对象到碰撞函数指针的映射,然后在这个映射中利用对象进行查询,获取对应的碰撞函数指针,进行函数调用。具体代码实现如下:

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
namespace{
void shipAsteroid(GameObject& spaceShip,GameObject& asteroid);
void shipStation(GameObject& spaceShip,GameObject& spaceStation);
void asteroidStation(GameObject& asteroid,GameObject& spaceStation);
...
//implement symmetry
void asteroidShip(GameObject& asteroid,GameObject& spaceShip)
{ shipAsteroid(spaceShip,asteroid);}
void stationShip(GameObject& spaceStation,GameObject& spaceShip)
{ shipStation(spaceShip,spaceStation);}
void stationAsteroid(GameObject& spaceStation,GameObject& asteroid)
{ asteroidStation(asteroid,spaceStation);}

typedef void(*HitFunctionPtr)(GameObject&,GameObject&);
typedef map<pair<string,string>,HitFunctionPtr> HitMap;
pair<string,string> makeStringPair(const char *s1,const char *s2);

HitMap* initializeCollisionMap();
HitFunctionPtr lookup(const string& class1,const string& class2);
}

void processCollision(GameObject& obj1,GameObject& obj2)
{
HitFunctionPtr phf = lookup(typeid(obj1).name(),typeid(obj2).name());
if(phf)
phf(obj1,obj2);
else
throw UnknownCollision(obj1,obj2);
}

namespace{
pair<string,string> makeStringPair(const char *s1,const char *s2)
{
return pair<string,string>(s1,s2);
}

HitMap* initializeCollisionMap()
{
HitMap *phm = new HitMap;
(*phm)[makeStringPair("SpaceShip","Asteroid")] = &shipAsteroid;
(*phm)[makeStringPair("SpaceShip","SpaceStation")] = &shipStation;
...
return phm;
}

HitFunctionPtr lookup(const string& class1,const string& class2)
{
static auto_ptr<HitMap> collisionMap(initializeCollisionMap());
HitMap::iterator mapEntry = collisionMap->find(make_pair(class1,class2));
if(mapEntry == collisionMap->end())
return 0;
return (*mapEntry).second;
}
}

如上述代码所示,使用非成员函数来处理碰撞过程,根据obj1和obj2来查询初始化之后映射表,来确定对应的非成员函数指针。利用模拟虚函数表的方法,基本上完成了基于多个对象的虚拟化功能。但是为了更方便的使用代码,更方便的维护代码,我们还需要进一步完善其实现过程。

将映射表和注册映射表过程封装起来

由于具体应用的过程,映射表的映射关系存在着增加和删除的操作,因而需要把映射表封装类体,提供增加,删除等接口。具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
class CollisionMap{
public:
typedef void (*HitFunctionPtr)(GameObject&,GameObject&);
void addEntry(const string& type1,const string& type2,HitFunctionPtr collisionFunction,bool symmetric = true);
void removeEntry(const string& type1,const string& type2);
HitFunctionPtr lookup(const string& type1,const string& type2);

static CollisionMap& theCollisinMap();
private:
CollisionMap();
CollisinMap(const CollisionMap&);
};

在应用中,我们必须确保在发生碰撞前将映射关系加入了映射表。一个方法是让GameObject的子类在构造函数中进行确认,这将导致在运行期的性能开销,另外一个方法创建一个RegisterCollisionFunction类,用于完成映射关系的注册工作。RegisterCollisionFunction相应的代码如下:

1
2
3
4
5
6
7
8
9
class RegisterCollisionFunction{
public:
RegisterCollisionFunction(const string& type1,const string& type2,CollisionMap::HitFunctionPtr collisionFunction,bool symmetric = true)
{
CollisionMap::theCollisionMap().addEntry(type1,type2,collisionFunction,symmetric);
}
};
//利用此类型的全局对象来自动地注册映射关系
RegisterCollisionFunction cf1("SpaceShip","Asteroid",&shipAsteroid);

条款32:在未来时态下开发程序

要在未来时态下开发程序,就必须接受事物会发生变化,并为此作了准备。这是应该考虑的:

  • 新的函数将被加入到函数库中,新的重载将发生,于是要注意那些含糊的函数调用行为的结果;
  • 新的类将会加入继承层次,现在的派生类将会是以后的基类,并已为此作好准备;
  • 将会编制新的应用软件,函数将在新的运行环境下被调用,它们应该被写得在新平台上运行正确;
  • 程序的维护人员通常不是原来编写它们的人,因此应该被设计得易于被别人理解、维护和扩充。

因为万物都会变化,要写能承受软件发展过程中的混乱攻击的类。应该判断一个函数的含意,以及它被派生类重定义的话是否有意义。如果是有意义的,申明它为虚,即使没有人立即重定义它。如果不是的话,申明它为非虚,并且不要在以后为了便于某人而更改;确保更改是对整个类的运行环境和类所表示的抽象是有意义的。

处理每个类的赋值和拷贝构造函数,即使“从没人这样做过”。他们现在没有这么做并不意味着他们以后不这么做。如果这些函数是难以实现的,那么申明它们为私有。这样,不会有人误调编译器提供的默认版本而做错事。

基于最小惊讶法则:努力提供这样的类,它们的操作和函数有自然的语法和直观的语义。和内建数据类型的行为保持一致:拿不定主意时,仿照int来做。

努力于可移植的代码。写可移植的代码并不比不可移植的代码难太多,只有在性能极其重要时采用不可移植的结构才是可取的。

将你的代码设计得当需要变化时,影响是局部的。尽可能地封装;将实现细节申明为私有。只要可能,使用无名的命名空间和文件内的静态对象或函数。避免导致虚基类的设计,因为这种类需要每个派生类都直接初始化它--即使是那些间接派生类。避免需要RTTI的设计,它需要if…then…else型的瀑布结构。

这是著名的老生常谈般的告戒,但大部分程序员仍然违背它。看这条一个著名C++专家提出忠告(很不幸,许多作者也这么说):

你需要虚析构函数,只要有人delete一个实际值向D的B *。这里,B是基类,D是其派生类。换句话说,这位作者暗示,如果你的程序看起来是这样时,并不需要B有虚析构函数:

1
2
3
class B { ... };                   // no virtual dtor needed
class D: public B { ... };
B *pb = new D;

然而,当你加入这么一句时,情况就变了:
1
2
delete pb;                        // NOW you need the virtual
// destructor in B

这意味着,用户代码中的一个小变化--增加了一个delete语句--实际上能导致需要修改B的定义。如果这发生了的话,所有B的用户都必须重编译。采纳了这个作者的建议的话,一条语句的增加将导致大量代码的重编译和重链接。这绝不是一个高效的设计。

条款33:将非尾端类设计为抽象类

考虑下面的需求,软件处理动物,Cat与Dog需要特殊处理,因此,设计Cat和Dog继承Animal。Animal有copy赋值(不是虚方法),Cat和Dog也有copy赋值。考虑下面的情况:

1
2
3
4
5
Cat cat1;
Cat cat2;
Animal *a1 = &cat1;
Animal *a2 = &cat2;
*a1 = *a2;

思考a1 = a2会有什么问题? copy赋值不是虚方法,根据表面类型,调用Animal的copy赋值,这就导致所谓的部分赋值,cat2的Animal成分赋值给cat1的Animal成分,二者的Cat成分保持不变。

怎么解决上面的问题?将Animal的copy赋值声明为virtual方法,如下:

1
virtual Animal& operator=(const Animal &rhs);

Cat和Dog重写:
1
2
virtual Cat& operator=(const Animal &rhs);
virtual Dog& operator=(const Animal &rhs);

这里使用了C++语言后期的一个特性,即协变,返回的引用更加具体。但是,对于形参表,重写必须保证保持一致。将copy赋值声明为virtual,解决了部分赋值的问题。但是,引入了一个新的问题。如下:
1
2
3
4
5
Cat cat;
Dog dog;
Animal* a1 = &cat;
Animal* a2 = &dog;
*a1 = *a2;

这是异型赋值,左边是Cat,右边是Dog。C++是强类型语言,一般情况下,异型赋值不合法,不会造成问题。但是,这种情况下导致异型赋值合法。对于指针解引用的情况,我们期望同型赋值是合法的,异型赋值是非法的。容易想到的办法是,在重写的copy赋值中,使用dynamic_cast进行同型判断。比如Cat的copy赋值,首先判断rhs是不是Cat,如果是,就赋值,如果不是,抛出异常。

我们知道,使用dynamic_cast效率低,考虑下面的情况,cat1 = cat2; 即使cat1与cat2的表面类型就是Cat,也会调用Cat& operator=(const Animal &rhs)进行一次dynamic_cast的运算,这不是我们所期望的。解决办法是:增加一个过载方法,编译器编译时,根据表面类型确定方法的调用。如下:Cat& operator=(const Cat &rhs)

同时对于重写的方法,可以调用前面的方法,如下:

1
2
3
4
Cat& operator=(const Animal &rhs)
{
return operator=(dynamic_cast<Cat&>(rhs));
}

运行期的类型检查,dynamic_cast的使用应该尽量避免。因为,首先效率低,其次,有些编译器还不支持dynamic_cast,不具有移植性。有没有更好的办法?导致问题的原因是,对于指针解引用的赋值,父类的copy赋值不是虚方法,导致部分赋值

因此,解决办法是,提取一个抽象类AbstractAnimal,将copy赋值声明为protected,子类可以调用,表面类型是抽象类的指针解引用赋值,不能调用。增加一个Animal类,继承AbstractAnimal。

对于抽象类,内部至少要有一个纯虚方法,很自然地将析构方法声明为纯虚方法。对于纯虚方法,需要注意:

  • 纯虚方法意味着当前类为抽象类,不能实例化。
  • 纯虚方法要求子类必须重写。
  • 特别注意,纯虚方法一般不提供实现,但是允许提供实现,子类也可以调用。如果析构方法为纯虚方法,必须要提供实现。因为子类调用自身的析构方法后,必定会去调用父类的析构方法。

考虑,具体基类没有字段,是不是就不需要上述的抽象类了?这有两个问题,首先现在没有字段,以后可能会有字段,其次如果一个类没有字段,一开始就应该是一个抽象类。

结论,对于继承体系中的非尾端类,应该设计为抽象类,如果使用外界的程序库,需要做一下变通。

条款34:如何在同一程序中混合使用 C++和 C

确保你的C++编译器和C编译器兼容,之后,还有四个要考虑的问题:名变换静态初始化内存动态分配数据结构兼容

名变换

名变换,就是C++编译器给程序的每个函数换一个独一无二的名字。重载不兼容于绝大部分链接程序,因为链接程序通常无法分辨同名的函数。名变换是对链接程序的妥协;链接程序通常坚持函数名必须独一无二。

如果你有一个函数叫drawline而编译器将它变换为xyzzy,你总使用名字drawLine,不会注意到背后的obj文件引用的是xyzzy的。但如果drawLine是一个C函数,obj文件中包含的编译后的drawLine函数仍然叫drawLine;没有名变换动作。当你试图将obj文件链接为程序时,将得到一个错误,因为链接程序在寻找一个叫xyzzy的函数,而没有这样的函数存在。

要解决这个问题,你需要一种方法来告诉C++编译器不要在这个函数上进行名变换。要禁止名变换,使用C++的extern 'C'指示:

1
2
3
4
// declare a function called drawLine; don't mangle
// its name
extern "C"
void drawLine(int x1, int y1, int x2, int y2);

例如,如果不幸到必须要用汇编写一个函数,你也可以申明它为extern ‘C’:

1
2
// this function is in assembler - don't mangle its name
extern "C" void twiddleBits(unsigned char bits);

为每一个函数添加extern 'C'是痛苦的。extern ‘C’可以对一组函数生效,只要将它们放入一对大括号中:

1
2
3
4
5
6
7
extern "C" {                           // disable name mangling for
// all the following functions
void drawLine(int x1, int y1, int x2, int y2);
void twiddleBits(unsigned char bits);
void simulate(int iterations);
...
}

这样使用extern ‘C’简化了维护那些必须同时供C++和C使用的头文件的工作。当用C++编译时,你应该加extern ‘C’,但用C编译时,不应该这样。通过只在C++编译器下定义的宏__cplusplus,你可以将头文件组织得这样:
1
2
3
4
5
6
7
8
9
10
#ifdef __cplusplus
extern "C" {
#endif
void drawLine(int x1, int y1, int x2, int y2);
void twiddleBits(unsigned char bits);
void simulate(int iterations);
...
#ifdef __cplusplus
}
#endif

静态初始化

在main执行前和执行后都有大量代码被执行,静态的类对象和定义在全局的、命名空间中的或文件体中的类对象的构造函数通常在main被执行前就被调用。这个过程称为静态初始化。为了解决main()应该首先被调用,而对象又需要在main()执行前被构造的两难问题,许多编译器在main()的最开始处插入了一个特别的函数,由它来负责静态初始化。同样地,编译器在main()结束处插入了一个函数来析构静态对象。产生的代码通常看起来象这样:

1
2
3
4
5
6
7
8
int main(int argc, char *argv[])
{
performStaticInitialization(); // generated by the
// implementation
the statements you put in main go here;
performStaticDestruction(); // generated by the
// implementation
}

不要注重于这些名字。函数performStaticInitialization()和performStaticDestruction()通常是更含糊的名字,甚至是内联函数(这时在你的obj文件中将找不到这些函数)。要点是:如果一个C++编译器采用这种方法来初始化和析构静态对象,除非main()是用C++写的,这些对象将从没被初始化和析构。因为这种初始化和析构静态对象的方法是如此通用,只要程序的任意部分是C++写的,你就应该用C++写main()函数。

有时看起来用C写main()更有意义--比如程序的大部分是C的,C++部分只是一个支持库。然而,这个C++库很可能含有静态对象,所以用C++写main()仍然是个好主意。这并不意味着你需要重写你的C代码。只要将C写的main()改名为realMain(),然后用C++版本的main()调用realMain():

1
2
3
4
5
6
extern "C"                                // implement this
int realMain(int argc, char *argv[]); // function in C
int main(int argc, char *argv[]) // write this in C++
{
return realMain(argc, argv);
}

动态内存分配

通行规则很简单:C++部分使用new和deleteC部分使用malloc和free。唯一要记住的就是:将你的new和delete与mallco和free进行严格的隔离

说比做容易。看一下这个粗糙(但很方便)的strdup函数,它并不在C和C++标准(运行库)中,却很常见:

1
2
char * strdup(const char *ps);         // return a copy of the
// string pointed to by ps

要想没有内存泄漏,strdup的调用着必须释放在strdup()中分配的内存。但这内存这么释放?用delete?用free?如果你调用的strdup来自于C函数库中,那么是后者。如果它是用C++写的,那么恐怕是前者。在调用strdup后所需要做的操作,在不同的操作系统下不同,在不同的编译器下也不同。

数据结构的兼容性

没有可移植的方法来传递对象或传递指向成员函数的指针给C写的函数。想让你的C++和C编译器生产兼容的输出,两种语言间的函数可以安全地交换指向对象的指针和指向非成员的函数或静态成员函数的指针。自然地,结构和内建类型(如int、char等)的变量也可自由通过。

只有非虚函数的结构(或类)的对象兼容于它们在C中的孪生版本。增加虚函数将结束游戏,因为其对象将使用一个不同的内存结构。从其它结构(或类)进行继承的结构,通常也改变其内存结构,所以有基类的结构也不能与C函数交互。就数据结构而言,结论是:在C++和C之间这样相互传递数据结构是安全的--在C++和C下提供同样的定义来进行编译。在C++版本中增加非虚成员函数或许不影响兼容性,但几乎其它的改变都将影响兼容。

总结

如果想在同一程序下混合C++与C编程,记住下面的指导原则:

  • 确保C++和C编译器产生兼容的obj文件。
  • 将在两种语言下都使用的函数申明为extern ‘C’。
  • 只要可能,用C++写main()。
  • 总用delete释放new分配的内存;总用free释放malloc分配的内存。
  • 将在两种语言间传递的东西限制在用C编译的数据结构的范围内;这些结构的C++版本可以包含非虚成员函数。

条款35:让自己习惯使用标准 C++语言

C++标准运行库的功能分为下列类别(参见Effective C++ Item 49):

  • 支持标准C运行库。
  • 支持string类型。
  • 支持本地化。
  • 支持I/O操作
  • 支持数学运算。
  • 支持通用容器和运算。

在介绍STL前,必须先知道标准C++运行库的两个特性。

  • 第一,在运行库中的几乎任何东西都是模板。在本书中,我谈到过运行库中的string类,实际上没有这样的类。其实,有一个模板类叫basic_string来描述字符序列,它接受一个字符类型的参数来构造此序列,这使得它能表示char串、wide char串、Unicode char串等等。
  • 我们通常认为的string类是从basic_string<char>实例化而成的。用于它被用得如此广泛,标准运行库作了一个类型定义:
1
typedef basic_string<char> string;

这其实仍然隐藏了很多细节,因为basic_string模板带三个参数;除了第一个外都有默认参数。要全面理解string类型,必须面对这个未经删节的basic_string:

1
2
3
4
template<class charT,
class traits = string_char_traits<charT>,
class Allocator = allocator>
class basic_string;

另外需要知道的是:标准运行库将几乎所有内容都包含在命名空间std中。要想使用标准运行库里面的东西而无需特别指明运行库的名称,你可以使用using指示或使用(更方便的)using申明。幸运的是,这种重复工作在你#include恰当的头文件时自动进行。

标准模板库

STL基于三个基本概念:包容器(container)、选择子(iterator)和算法(algorithms)。包容器是被包容对象的封装;选择子是类指针的对象让你能如同使用指针操作内建类型的数组一样操作STL的包容器;算法是对包容器进行处理的函数,并使用选择子来实现的。

一个指向数组的指针可以正确地指出数组的任意元素或刚刚超出数组范围的那个元素。如果指向了那个超范围的元素,它将只能与其它指向此数组的指针进行地址比较;对其进行反引用,其结果为未定义。我们可以利用这条规则来实现在数组中查找一个特定值的函数。对一个整型数组,函数可能是这样的:

1
2
3
4
5
int * find(int *begin, int *end, int value)
{
while (begin != end && *begin != value) ++begin;
return begin;
}

这个函数在begin与end之间查找value,返回第一个值为value的元素;如果没有找到,它返回end。

返回end来表示没找到,看起来有些可笑。find()函数必须返回特别的指针值来表明查找失败,就此目的而言,end指针与NULL指针效果相同。但,如我们将要看到的,end指针在推广到其它包容器类型时比NULL指针好。

你可以这么使用find()函数:

1
2
3
4
5
6
7
8
9
10
11
int values[50];
...
int *firstFive = find(values, // search the range
values+50, // values[0] - values[49]
5); // for the value 5
if (firstFive != values+50) { // did the search succeed?
... // yes
}
else {
... // no, the search failed
}

你也可以只搜索数组的一部分:
1
2
3
4
5
6
7
8
int *firstFive = find(values,        // search the range
values+10, // values[0] - values[9]
5); // for the value 5
int age = 36;
...
int *firstValue = find(values+10, // search the range
values+20, // values[10] - values[19]
age); // for the value in age

find()函数内部并没有限制它只能对int型数组操作,所以它可以实际上是一个模板:
1
2
3
4
5
6
template<class T>
T * find(T *begin, T *end, const T& value)
{
while (begin != end && *begin != value) ++begin;
return begin;
}

在每次调用过程中,每个传值的参数都要有构造函数和析构函数的开销。通过传引用避免了这个开销

选择子就是被设计为操作STL的包容器的类指针对象。有了作为类指针对象的选择子的概念,我们可以用选择子代替find()中的指针。改写后的find()类似于:

1
2
3
4
5
6
template<class Iterator, class T>
Iterator find(Iterator begin, Iterator end, const T& value)
{
while (begin != end && *begin != value) ++begin;
return begin;
}

STL中包含了很多使用包容器和选择子的算法,find()是其中之一。STL中的包容器有bitset、vector、list、deque、queue、priority-queue、stack、set和map,你可以在其中任一类型上使用find(),例如:
1
2
3
4
5
6
7
list<char> charList;                  // create STL list object
// for holding chars
...
// find the first occurrence of 'x' in charList
list<char>::iterator it = find(charList.begin(),
charList.end(),
'x');

要对list对象调用find(),你必须提供一个指向list中的第一个元素的选择子和一个越过list中最后一个元素的选择子。如果list类不提供帮助,这将有些难,因为你无法知道list是怎么实现的。

当find()执行完成时,它返回一个选择子对象指向找到的元素或charList.end()。它提供了一个类型重定义,iterator就是list内部使用的选择子的类型。既然charList是一个包容char的list,它内部的选择子类型就是list<char>::iterator。同样的方法也完全适用于其它STL包容器。此外,C++指针也是STL选择子,所以,最初的数组的例子也能适用STL的find()函数:

1
2
3
4
int values[50];
...
int *firstFive = find(values, values+50, 5); // fine, calls
// STL find

温故而知新

1.1 从Hello World说起

1
2
3
4
5
#include <studio.h>
int main() {
printf("Hello World\n");
return 0;
}

1.2 万变不离其宗

  1. 计算机中三个关键的部件:中央处理器CPU,内存和I/O控制芯片

  2. 早期CPU和内存频率较低,且频率一样,它们连在一个总线(Bus)上。但其他I/O设备比CPU和内存速度要慢,所以为了协调I/O设备和总线之间的速度,所以每个I/O设备都有一个相应的I/O控制器

  3. 随着CPU的提升,以及内存和硬件的发展。出现了北桥(Northbridge, PCI Bridge),其运算速度非常高。用于连接高速设备。南桥(Southbridge, ISA Bridge)用于连接低速设备(磁盘,USB等)。高速设备采用PCI总线,低速设备采用ISA总线,然后在通过南北桥连接。

  4. PCI最高级为133MHZ,还是不能满足人们需求,于是又发明了AGP,PCI Express等诸多总线结构和相应的控制芯片。虽然结构越来越复杂,但我们先可以简单的将其按最初的模型进行理解

SMP与多核

  1. SMP: 对称多处理器( Symmetrical Muti-Processing),也就是多个CPU。
    • 理论上速度的提高和cpu的数量成正比,但是程序的任务之间是有依赖关系的。就如一个女人花10个月生一个孩子,但是10个女人不能一个月生一个孩子
    • 多处理器用来处理不相干的任务的时候,效果比较显著
  2. 多核:多核处理器(Muti-core processing)。一个处理器上有多个核心,这样成本减少了。可以简单的将多核处理器理解为和SMP功能几乎一样。但是成本更低。

1.3 站得高,望的远

  1. 系统软件一般分为两个部分

    • 平台性的:操作系统内核,驱动程序,运行库和系统工具。
    • 程序开发的: 编译器,汇编器,链接器等开发工具和开发库。(重点)
  2. 计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

  3. 系统软件体系结构中,各种软件的位置。

  4. 各层次之间用API接口进行通信。API由下层定义和实现,由上层来使用。上次并不关心下层的实现细节。只要下层遵循定义好的API,那么下层可以随意被替换。

上层应用通过 Operating System API来访问运行时库。运行时库通过系统调用接口(system call interface)来访问系统内核,系统调用接口在实现上往往以软件终端的方式提供操作系统内核通过硬件规格(hardware specification)来操作硬件,硬件规格由硬件厂商负责提供。

1.4 操作系统做什么

  1. 提供抽象的接口
  2. 管理硬件资源

1.4.1 不要让CPU打盹

  1. 早期, 为了不让CPU空闲下来,编写了一个监控程序,当进行磁盘读写操作等IO操作的时候。此时CPU空闲,将CPU交给另外等待的程序,使CPU能够充分利用起来,这种方法调度策略虽然粗糙但也提高了CPU的利用率,叫做多道程序(mutiprogramming)
  2. 接着,改进成协作模式,即为每个程序运行一段时间后主动让出CPU给其他程序,使的一段时间内每个程序都有机会运行一小段时间。这种方式叫做分时系统(Time-Sharing System)。但任何一个程序死循环都会造成CPU无法被释放,系统死机。
  3. 现在,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间。使进程之间的地址空间相互隔离。CPU由操作系统统一进行分配。每个进程根据优先级的高低都有机会得到CPU,但是如果运行超出一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程。这种CPU分配方式即所谓的抢占式(preemptive).操作系统可以强行剥夺CPU资源并且分配给它认为目前最需要的进程。这就是现在的多任务(Muti-tasking)系统

1.4.2 设备驱动

  • 操作系统是硬件层的上层,它是对硬件的管理和抽象。
  • 在 Windows系统中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象成了 DirectX对象;磁盘被抽象成了普通文件系统
  • 繁琐的硬件细节全都交给了操作系统,具体地讲是操作系统中的硬件驱动( Device Driver)程序来完成。
  • 驱动程序可以看作是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较好的灵活性
  • 硬盘结构:
    • 有多个盘片,每个盘片分两面,每面按照同心圆划分为若干个磁道,每个磁道划分为若干个扇区。
    • 比如一个硬盘有2个盘片,每个盘面分65536磁道,每个磁道分1024个扇区,那么硬盘的容量就是2·2“65536“1024*512 = 137438953472字节(128GB但是我们可以想象,每个盘面上同心圆的周长不一样,如果按照每个磁道都拥有相同数量的扇区,那么靠近盘面外围的磁道密度肯定比内圈更加稀疏,这样是比较浪费空间的。但是如果不同的磁道扇区数又不同,计算起来就十分麻烦。
    • 为了屏蔽这些复杂的硬件细节,现代的硬盘普遍使用一种叫做LBA( Logical Block Address)的方式,即整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号
    • 逻辑扇区号抛弃了所有复杂的磁道、盘面之类的概念。当我们给出一个逻辑的扇区号时,硬盘的电子设备会将其转换成实际的盘面、磁道等这些位置

1.5 内存不够怎么办

  • 在早期的计算机中,程序是直接运行在物理内存上的,也就是说,程序在运行时所访问的地址都是物理地址。
  • 直接运行在物理内存中存在问题
    • 地址空间不隔离:地址容易被恶意修改。所有程序都直接访问物理地址,程序所使用的内存空间不是相互隔离的恶意的程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意的、但是有bug的程序可能不小心修改了其他程序的数据,就会使其他程序也崩溃
    • 内存使用效率低:程序间切换开销太大。由于没有有效的内存管理机制,通常需要一个程序执行时,监控程序就将整个程序装入内存中然后开始执行。如果我们忽然需要运行程序C,那么这时内存空间其实已经不够了,这时候我们可以用的一个办法是将其他程序的数据暂时写到磁盘里面,等到需要用到的时候再读回来。由于程序所需要的空间是连续的,那么这个例子里中,如果我们将程序A换出到磁盘所释放的内存空间是不够的,所以只能将B换出到磁盘,然后将C读入到内存开始运行。可以看到整个过程中有大量的数据在换入换出,导致效率十分低下
    • 程序运行的地址不确定。因为程序每次需要装入运行时,我们都需要给它从内存中分配一块足够大的空闲区域,这个空闲区域的位置是不确定的。这给程序的编写造成了一定的麻烦,因为程序在编写时,它访问数据和指令跳转时的目标地址很多都是固定的,这涉及程序的重定位问题

解决方案:增加中间层,即使用一种间接的地址访问方法。把程序给出的地址看作是一种虚拟地址( Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域跟另外个程序相互不重叠,以达到地址空间隔离的效果。

1.5.1 关于隔离

地址空间是个比较抽象的概念,你可以把它想象成一个很大的数组,每个数组的元素是一个字节,而这个数组大小由地址空间的地址长度决定。32位的地址空间的大小为2^32=4294967296字节,即4GB,地址空间有效的地址是0~4294967295,用十六进制表示就是0x00000000~0xFFFFFFFF

地址空间分两种:

  • 物理地址空间( Physical Address Space)
    • 物理地址空间是实实在在存在的,存在于计算机中,而且对于每一台计算机来说只有唯一的个,你可以把物理空间想象成物理内存,比如你的计算机用的是 Intel的 Pentium4的处理器,那么它是32位的机器,即计算机地址线有32条(实际上是36条地址线,不过我们暂时认为它只是32条),那么物理空间就有4GB。但是你的计算机上只装了512MB的内存,那么其实物理地址的真正有效部分只有0x0000000~0x1FFFFFFF,其他部分都是无效的(实际上还有一些外部IO设备映射到物理空间的,也是有效的,但是我们暂时无视其存在)
  • 虚拟地址空间(Ⅴirtual Address Space)
    • 人们想象出来的地址空间,其实它并不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效地做到了进程的隔离

1.5.2 分段( Segmentation)

最开始人们使用的是一种叫做分段( Segmentation)的方法,基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。各程序都有自己的虚拟地址空间,当程序加载到内存中,访问自己的地址时,操作系统通过CPU将虚拟地址转换为实际的物理地址。

分段机制基本解决了上面提到”地址空间不隔离”和”程序运行的地址不确”的问题。

  • 首先它做到了地址隔离,因为程序A和程序B被映射到了两块不同的物理空间区域,它们之间没有任何重叠如果程序A访问虚拟空间的地址超出了0x00A0这个范围,那么硬件就会判断这是一个非法的访问,拒绝这个地址请求,并将这个请求报告给操作系统或监控程序,由它来决定如何处理
  • 再者,对于每个程序来说,无论它们被分配到物理地址的哪一个区域,对于程序来说都是透明的,它们不需要关心物理地址的变化,它们只需要按照从地址0x000000到0x00A0000编写程序、放置变量,所以程序不再需要重定位。

但还没有解决内存使用率的问题

  1. 分段对内存区域的映射还是按照程序为单位,如果内存不足,被换入换出到磁盘的都是整个程序,这样势必会造成大量的磁盘访问操作,从而严重影响速度,这种方法还是显得粗糙,粒度比较大
  2. 根据程序的局部性原理,当一个程序在运行时,在某个时间段内,它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内都是不会被用到的。人们很自然地想到了更小粒度的内存分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高了内存的使用率。这种方法就是分页(Paging)

1.5.3 分页( Paging)

每一页的大小由硬件决定。如果硬件支持多种页,那么由操作系统选择决定页的大小, 同一时刻只能选择一种大小。目前几乎所有的PC上的操作系统都使用4KB大小的页。我们使用的PC机是32位的虚拟地址空间,也就是4GB,那么按4KB每页分的话,总共有1048576个页。在进程中,我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中把不常用的代码和数据保存在磁盘里,当需要用到的时候再把它从磁盘里取出来即可

页的映射关系:

  • 虚拟空间的页就叫虚拟页(VP, Virtual Page)
  • 物理内存中的页叫做物理页(PP, Physical Page)
  • 磁盘中的页叫做磁盘页(DP, Disk Page)
  • VP0,VP1,VP7映射到物理内存页面
  • 两个进程的VP7都映射到同一个PP3,则实现了内存共享
  • VP4、VP5和VP6可能尚未被用到或访问到,它们暂时处于未使用的状态
  • 有部分页面却在磁盘中比如VP2和VP3位于磁盘的DP0和DP1中
  • Process1的VP2和VP3不在内存中,但是当进程需要用到这两个页的时候,硬件会捕获到这个消息,就是所谓的页错误( Page ),然后操作系统接管进程,负责将VP2和VP3从磁盘中读出来并且装入内存,然后将内存中的这两个页与VP2和VP3之间建立映射关系。以页为单位来存取和交换这些数据非常方便,硬件本身就支持这种以页为单位的操作方式

保护也是页映射的目的之一。简单地说就是每个页可以设置权限属性,谁可以修改,谁可以访问等,而只有操作系统有权限修改这些属性,那么操作系统就可以做到保护自己和保护进程

虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的。但是几乎所有的硬件都用一个叫MMU( Memory Management Unit)的部件来进行页映射

在页映射模式下,CPU发出的是 Virtual Address,即我们的程序看到的是虚拟地址。经过MMU转换以后就变成了 Physical Address。一般MMU都集成在CPU内部了,不会以独立的部件存在

1.6 众人拾柴火焰高

1.6.1 线程基础

1.6.1.1 什么是线程

一个标准的线程由线程ID当前指令指针(PC)寄存器集合堆栈组成。一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)

使用多线程的场景

  • 某个操作可能会陷入长时间等待
    • 等待的线程会进入睡眠状态,无法继续执行。多线程执行可以有效利用等待的时间。典型的例子是等待网络响应,这可能要花费数秒甚至数十秒
  • 某个操作(常常是计算)会消耗大量的时间
    • 如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线程负责计算
  • 程序逻辑本身就要求并发操作
    • 一个多端下载软件(例如 Bittorrent)
  • 利用多CPU或多核计算机的优势
    • 本身具备同时执行多个线程的能力,因此单线程程序无法全面地发挥计算机的全部计算能力
  • 相对于多进程应用,多线程在数据共享方面效率要高很多
1.6.1.2 线程的访问权限
  1. 线程也拥有自己的私有存储空间

    • 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)
    • 线程局部存储( Thread Local Storage,TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。
    • 寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。

1.6.1.3 线程调度和优先级
  • 当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发
  • 但对于线程数量大于处理器数量的情况, 此时至少有一个处理器会运行多个线程
  • 处理器上切换不同的线程的行为称之为线程调度( Thread Schedule)
  • 在线程调度中,线程通常拥有至少三种状态

    • 运行( Running):此时线程正在执行。
    • 就绪( Ready):此时线程可以立刻运行,但CPU经被占用。
    • 等待( Waiting):此时线程正在等待某一事件(通常是IO或同步)发生,无法执行

处于运行中线程拥有一段可以执行的时间,这段时间称为时间片( Time Slice),当时间片用尽的时候,该进程将进入就绪状态。如果在时间片用尽之前进程就开始等待某事件那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态

线程调度方案: 优先级调度( Priority Schedule)和轮转法( Round robin)

  • 优先级调度( Priority Schedule)
    • 具有高优先级的线程会更早地执行,而低优先级的线程常常要等待到系统中已经没有高优先级的可执行的线程存在时才能够执行
  • 轮转法( Round robin)
    • 所谓轮转法,即是之前提到的让各个线程轮流执行一小段时间的方法。这决定了线程之间交错执行的特点

根据线程的表现自动调整优先级

  1. I/O密集型线程频繁进入等待,放弃时间片段,优先级更容易提升
  2. CPU密集型线程很少等待
  3. 一个线程被饿死,是说它的优先级较低,一直得不到执行。会逐渐提高其优先级以免其被饿死。
  4. 一个高优先级的IO密集型线程由于大部分时间都处于等待状态,因此相对不容易造成其他线程饿死

优先级调度环境下,线程的优先级改变有三种方式

  • 用户指定优先级
  • 根据进入等待状态的频繁程度提升或降低优先级
  • 长时间得不到执行而被提升优先级
1.6.1.4 可抢占线程和不可抢占线程
  • 抢占( Preemption): 线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态
  • 不可抢占: 早期的一些系统(例如 Windows3.1)里,线程是不可抢占的。线程必须手动发出一个放弃执行的命令,才能让其他的线程得到执行

线程主动放弃:

  1. 当线程试图等待某事件时(I/O等)
  2. 线程主动放弃时间片
1.6.1.5 Linux的多线程
  • Windows内核有明确的线程和进程的概念。windows中使用,CreateProcess和 Create Thread来创建进程和线程,并且有一系列的AP来操纵它们
  • Linux对多线程的支持颇为贫乏,事实上,在 Linux内核中并不存在真正意义上的线程概念
  • Linux将所有的执行实体(无论是线程还是进程)都称为任务(Task)
    • 每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。
    • 不过, Linux下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程

1.6.2 线程安全

多线程处于一个多变的环境中,可访问的全局变量和堆数据随时可能被线程改变。因此多线程程序在并发时数据的一致性变得非常重要

1.6.2.1 竞争和原子操作
  1. 多个线程同时访问一个共享数据,可能造成很恶劣的后果
  2. 可以对简单数据进行原子操作,保证数据的一致性。但是对复杂数据,原子操作指令就力不从心了
1.6.2.2 同步与锁
  • 对共享的数据进行同步访问
  • 最简单的同步就是,访问之前要获取锁,访问之后释放锁。锁已经被占用时,尝试获取锁的线程会等待,直到获取到锁
  • 二元信号量( Binary Semaphore)是最简单的一种锁
    • 只有两种状态:占用与非占用。
    • 它适合只能被唯一一个线程独占访问的资源。
    • 当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放

对于允许多个线程并发访问的资源,多元信号量简称信号量( Semaphore),它是一个很好的选择。如果信号量的值小于0,则进入等待状态,否则继续执行。

互斥量( Mutex)和二元信号量很类似

  • 相同点: 资源仅同时允许一个线程访问
  • 不同点: 信号量可以被一个线程获取,被另一个线程释放。互斥量只能被同一个线程获取和释放。

临界区( Critical Section)是比互斥量更加严格的同步手段:

  • 把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区
  • 临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何”进程”里都是可见的,也就是说,一个”进程”创建了一个互斥量或信号量,另一个”进程”试图去获取该锁是合法的
  • 临界区的作用范围仅限于本进程
  • 临界区具有和互斥量相同的性质

读写锁( Read-Write Lock)致力于一种更加特定的场合的同步

  • 对于读取频繁,而仅仅偶尔写入的情况,用上述的锁方案会显得非常低效
  • 读操作共享的方式获取锁,写操作独占的方式获取锁。是最高效的
    • 对于同一个锁,读写锁有两种获取方式,共享的( Shared)或独占的( Exclusive)
    • 当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取
    • 已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取

条件变量( Condition variable)作为一种同步手段,作用类似于一个栅栏。

  • 一个条件变量可以被多个线程等待
  • 一旦条件变量被唤醒,那么这多个等待的线程一起恢复执行。
1.6.2.3 可重入( Reentrant)与线程安全
  • 一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行
  • 一个函数要被重入,只有两种情况:
    • 多个线程同时执行这个函数。
    • 函数自身(可能是经过多层调用之后)调用自身。
  • 一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。可重入是并发安全的强力保障。
1.6.2.4 过度优化

案例1 编译器优化

1
2
3
4
5
x = 0;
Thread1 Thread2
lock() lock()
x++; x++;
unlock(); unlock();

x最终的值可能为1.

不同线程使用独立的寄存器。当某个线程计算完x++后,编译器为了提高速度,并没有将1写回到内存中。所以另一个线程执行完x++后。最终写回到变量x的值为1

案例2 CPU动态调度或编译器优化

1
2
3
4
x = y = 0;
Thread1 Thread2
x =1; y=1;
r1=y; r2=x;

很显然,r1和r2至少一个为1。逻辑上不可能同时为0.然而,实际上r1=r2=0的情况可能发生。

原因在于

  1. 早在几十年前,CPU就发展出了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序
  2. 编译器在进行优化的时候,也可能为了效率而交换毫不相干的两条相邻指令(如x=1和rl=y)的执行顺序。从而造成r1=r2=0

可以使用volatile关键字试图阻止过度优化

  1. 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回
  2. 阻止编译器调整操作 volatile变量的指令顺序

调用CPU提供的一条指令,这条指令常常被称为 barrier。barrier指令会阻止CPU将该指令之前的指令交换到 barrier之后

1.6.3 多线程内部情况

1.6.3.1 三种线程模型
  • 线程的并发执行是由多处理器或操作系统调度来实现的
  • 大多数操作系统,包括 Windows和 Linux,都在内核里提供线程的支持
  • 这里的内核线程和Linux里的kernel_thread不是同一回事
  • 实际上用户使用的并不是内核线程,而是存在于用户态的用户线程。
  • 用户线程并不一定在操作系统内核中对应同等数量的内核线程

一对一模型:

  1. 1个用户线程对应一个内核线程
  2. 内核线程的数量可能比用户线程的数量多
  • 优点:
    • 线程之间的并发是真正的并发,一个线程出问题,其他不受影响
  • 缺点:
    • 由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户线程数量受到限制
    • 内核线程调度时,上下文切换的开销较大,导致用户线程执行效率下降

多对一模型

多对1模型,将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码进行,切换速度要快的多

  • 优点:
    • 高效的上下文切换和几乎无限制的线程数量
  • 缺点:
    • 一个用户线程阻塞,则内核线程就会被阻塞,造成所有用户线程被阻塞

多对多模型是1对1和多对1线程模型的结合。

编译和链接

2.1 被隐藏了的过程

编译分为4个过程

  • 预处理( Prepressing)
  • 编译( Compilation)
  • 汇编( Assembly)
  • 链接( Linking)

2.2.1 预处理(预编译)

1
2
3
$gcc -E hello.c -o hello.i
或者
$cpp hello.c > hello.i

预处理(预编译)过程主要处理那些源代码文件中的以“#”开始的预编译指令

  • 将所有的”#define”删除,并且展开所有的宏定义
  • 处理所有条件预编译指令,比如”#if”、”#ifdef”、”#elif”、”#else”、 “#endif”
  • 处理”#include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件
  • 删除所有的注释”//“和”/**/“
  • 添加行号和文件名标识,比如#2 “helo.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号
  • 保留所有的#pragma编译器指令,因为编译器需要使用它们

2.1.2 编译

编译过程就是把预处理完的文件进行一系列词法分析语法分析语义分析优化后生产相应的汇编代码文件

1
$ gcc -s hello. i -o hello.s

gcc这个命令只是相应后台程序的包装(例如c语言的预编译和编译程序都是cc1,c++对应的是cc1plus),它会根据不同的参数要求去调用预编译,编译程序cc1、汇编器as、链接器ld

2.1.3 汇编

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器

指令, 只是根据汇编指令和机器指令的对照表一一翻译就可以了

1
2
$as hello.s -o hello.o
$gcc -c hello.s -o hello.o

2.1.4 链接

调用ld才可以产生一个能够正常运行的Hello world程序

1
$ ld -static /usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/i486-linux-gun/4.1.3/crtbeginT.o -L/usr/lib/gcc/i486-linux-gun/4.1.3 -L/usr/lib -L/lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/i486-linux-gun/4.1.3/crtend.o /usr/lib/crtn.o

可以看到,我们需要将一大堆文件链接起来才可以得到最终的可执行文件

2.2 编译器做了什么?

  1. 编译器将高级语言翻译成机器语言,大大提高了编程的效率。程序员不用考虑特定的机器,字长,内存大小等等限制
  2. 编译的过程可以分为6步: 扫描,语法分析,语义分析,源代码优化,代码生成和目标代码优化。

  3. 以下面代码为例做相关的分析

1
2
array[index] = (index + 4) * (2 + 6)
ComplierExpression.c

2.2.1 词法分析(扫描)

  1. 首先源代码程序被输入到扫描器( Scanner)
  2. 扫描器只是简单地进行词法分析,运用一种类似于有限状态机( Finite state Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记号( Token)
  3. 上面的那行程序,总共包含了28个非空字符,经过扫描以后,产生了16个记号


  4. 词法分析产生的记号一般可以分为如下几类

    1. 关键字
    2. 标识符,存放到符号表
    3. 字面量(包含数字、字符串等),数字、字符串常量存放到文字表
    4. 特殊符号(如加号、等号)
  5. 有一个叫做lex的程序可以实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。因为这样个程序的存在,编译器的开发者就无须为每个编译器开发一个独立的词法扫描器,而是根据需要改变词法规则就可以了

  6. 对于一些有预处理的语言,比如C语言,它的宏替换和文件包含等工作一般不归入编译器的范围而交给一个独立的预处理器

2.2.2 语法分析

  1. 接下来语法分析器( Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树( Syntax Tree)

  2. 整个分析过程釆用了上下文无关语法( Context-free Grammar)的分析手段,简单地讲,由语法分析器生成的语法树就是以表达式( Expression)为节点的树

  3. 上面例子中的语句就是一个由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句

    • 整个语句被看作是一个赋值表达式;赋值表达式的左边是个数组表达式,它的右边是一个乘法表达式;数组表达式又由两个符号表达式组成,等等
    • 符号数字是最小的表达式,作为整个语法树的叶节点
    • 如果出现了表达式不合法,比如各种括号不匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误
  4. 语法分析也有一个现成的工具叫做yacc( Yet Another Compiler Compiler), 可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建出一棵语法树。

  5. 如lex一样,对于不同的编程语言,编译器的开发者只须改变语法规则,而无须为每个编译器编写一个语法分析器,所以它又被称为“编译器编译器( Compiler Compiler)”

2.2.3 语义分析

  1. 语义分析,由语义分析器( Semantic Analyzer)来完成
  2. 语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的;比如同样一个指针和一个浮点数做乘法运算是否合法等。
  3. 编译器所能分析的语义是静态语义(Satc Semantic),所谓静态语义是指在编译期可以确定的语义。

    • 静态语义通常包括声明和类型的匹配,类型的转换
    • 当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型转换的过程,语义分析过程中需要完成这个步骤
      +将一个浮点型赋值给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会报错
  4. 对应的动态语义( Dynamic Semantic),就是只有运行期才能确定的语义

    1. 将0作为除数是个运行期语义错误
  5. 经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点

    • 每个表达式(包括符号和数字)都被标识了类型
    • 语义分析器还对符号表里的符号类型也做了更新

2.2.4 中间语言生成(源代码优化)

  • 源代码级优化器(Source Code Optimizer)会在源代码级别进行优化。(2+6)这个表达式可以被优化掉,因为它的值在编译期就可以被确定

    • 我们看到(2+6)这个表达式被优化成8

    • 直接在语法树上作优化比较困难,源代码优化器往往将整个语法树转换成中间代码( intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了

    • 它一般跟目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等

  • 中间代码有很多种类型,在不同的编译器中有着不同的形式

    • 比较常见的有:三地址码(Thee-address Code)和P代码( P-Code)
  • 中间代码使得编译器可以被分为前端和后端

    • 编译器前端负责产生机器无关的中间代码
    • 编译器后端将中间代码转换成目标机器代码。
    • 这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端

2.2.5 代码生成与优化

  1. 源代码级优化器产生中间代码标志着下面的过程都属于编译器后端

  2. 编译器后端主要包括:

    • 代码生成器( Code Generator)

      • 代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等
    • 代码优化器( Target Code Optimizer)。

      • 最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等
  3. 这个目标代码中有一个问题是: index和array的地址还没有确定。

    • 如果index和array定义在跟上面的源代码同一个编译单元里面,那么编译器可以为 index和array分配空间,确定它们的地址。
    • 如果是定义在其他的程序模块,那么定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定
    • 现代的编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件

代码生成

最后把中间代码转换为汇编语言,这个阶段称为代码生成(code generation)。负责代码生 成的程序模块称为代码生成器(code generator)。

代码生成的关键在于如何来填补编程语言和汇编语言之间的差异。一般而言,比起编程语 言,汇编语言在使用上面的限制要多一些。例如,C 和 Java 可以随心所欲地定义局部变量,而 汇编语言中能够分配给局部变量的寄存器只有不到 30 个而已。处理流程控制方面也只有和 goto语句功能类似的跳转指令。在这样的限制下,还必须以不改变程序的原有语义为前提进行转换

代码优化

现实的编译器还包括优化(optimization)阶段。

现在的计算机,即便是同样的代码,根据编译器优化性能的不同,运行速度也会有数倍的 差距。由于编译器要处理相当多的程序,因此在制作编译器时,最重要的一点就是要尽可能地 提高编译出来的程序的性能。

优化可以在编译器的各个环节进行。可以对抽象语法树进行优化,可以对中间代码的代码 进行优化,也可以对转换后的机器语言进行优化。进一步来说,不仅是编译器,对链接以及运 行时调用的程序库的代码也都可以进行优化。

2.3 链接年龄比编译器长

  1. 一开始人们直接使用机器指令进行编程,当程序修改的时候十分的麻烦。

  2. 人们把相关的指令和函数符号化后产生了汇编语言。每次指令跳转函数时自动计算函数对应的地址。

    1
    2
    3
    jmp divide 跳转到除法程序
    假如0001为跳转指令,变成汇编后为jmp
    divide表示除法程序的地址
  3. 人们将日益庞大的软件,按功能或性质划分为不同的模块。通过模块之间的通信将不同的模块组合成一个完整的软件

  4. 每个模块可以单独开发,编译,测试,改变部分代码不需要编译整个程序

  5. 模块之间的通信方式一般为:模块之间的函数调用,模块之间的变量访问。需要知道对应函数和变量的地址。统称为模块间符号的引用

  6. 模块间通过符号来通信类似于拼图,其拼接的过程就是链接(Linking)。

2.4 模块拼装——静态链接

  1. 人们把每个源代码模块独立的编译。然后按照需要把它们组装起来,这个组装模块的过程就是链接

  2. 链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接

  3. 链接过程主要包括了

    • 地址和空间分配( Address and Storage Allocation)
    • 符号决议( Symbol Resolution)
      • 符号决议有时候也被叫做符号绑定( Symbol Binding)、名称绑定( Name Binding),名称决议( Name Resolution),甚至还有叫做地址绑定( Address Binding), 指令绑定( Instruction Binding)的
      • 大体上它们的意思都一样,但从细节角度来区分,它们之间还是存在一定区别的,比如“决议”更倾向于静态链接,而“绑定”更倾向于动态链接,即它们所使用的范围不一样。
      • 在静态链接,我们将统一称为符号决议
    • 重定位( Relocation)等这些步骤
  4. 最基本的静态链接过程:

    • 每个模块的源代码文件(如.c)文件经过编译器编译成目标文件( Object File,一般扩展名为o或.obj)。
    • 目标文件和库( Library)一起链接形成最终可执行文件
      • 最常见的库就是运行时库( Runtime Library),
      • 库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放
  5. 链接过程的理解

    • 比如我们在程序模块 main.c中使用另外一个模块 func.c中的函数foo。我们在main.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。如果没有链接器,须要我们手工把每个调用foo的指令进行修正,则填入正确的foo函数地址。当func.c模块被重新编译,foo函数的地址有可能改变时,那么我们在main.c中所有使用到foo的地址的指令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。
    • 使用链接器,你可以直接引用其他模块的函数和全局变量而无须知道它们的地址,因为链接器在链接的时候,会根据你所引用的符号foo,自动去相应的func.c模块査找foo的地址,然后将main.c模块中所有引用到foo的指令重新修正,让它们的目标地址为真止的foo函数的地址。这就是静态链接的最基本的过程和作用
  6. 引用其他模块的变量,在链接时如何修正变量的地址》假设我们有个全局变量叫做var,它在目标文件A里面。我们在目标文件B里面要访问这个全局变量

    1. 我们在目标文件B里面有这么一条指令movl $0x2a, var
    2. 这条指令就是给这个var变量赋值0x2a,相当于C语言里面的语句var=42
    3. 我们编译目标文件B,得到这条指令机器码

    4. 由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所以编译器在没法确定地址的情况下,将这条mov指令的目标地址置为0, 等待链接器在将目标文件A和B链接起来的时候再将其修正

    5. 我们假设A和B链接后,变量var的地址确定下来为0x1000, 那么链接器将会把这个指令的目标地址部分修改成0x10000。
    6. 这个地址修正的过程也被叫做重定位( Relocation), 每个要被修正的地方叫一个重定位入口( Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址引用的位置“打补丁”,使它们指向正确的地址

目标文件里有什么

  1. 目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程其中可能有些符号或有些地址还没有被调整
  2. 目标文件本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同
  3. 可执行文件格式涵盖了程序的编译、链接、装载和执行的各个方面
  4. 从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件,在 Windows下,我们可以统称它们为 PE-COFF文件格式, 在 Linux下,我们可以将它们统称为ELF文件

3.1 目标文件的格式

  1. 可执行文件格式( Executable)主要是 Windows下的PE( Portable Executable)和 Linux的ELF( Executable Linkable format),它们都是COFF( Common file format)格式的变种

  2. COFF的主要贡献是在目标文件里面引入了”段”的机制,不同的目标文件可以拥有不同数量及不同类型的“段”。另外,它还定义了调试数据格式

  3. 目标文件就是源代码编译后但未进行链接的那些中间文件( Windows的.obj和 Linux下的.o)

  4. 按可执行文件格式存储:

    1. 可执行文件( Windows的.exe和 Linux下的ELF可执行文件)
    2. 动态链接库(DLL, Dynamic Linking Library)( Windows的dll和 Linux的.so)
    3. 静态链接库( Static Linking Library)( Windows的lib和 Linux的a)文件
  5. 静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引,可以简单理解为一个包含有很多目标文件的文件包

  6. 采用ELF格式的文件类型

3.2 目标文件是什么样的

  1. 目标文件内的内容:

    1. 编译后的机器指令代码、数据
    2. 链接时所须要的一些信息,比如符号表、调试信息、字符串等
  2. 一般目标文件将这些信息按不同的属性,以”节”( Section)的形式存储,有时候也叫“段”(Segment)。

  3. 简单的目标文件结构

    • ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性

      • 包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标文件、目标操作系统等信息
      • 文件头还包括个段表(Section Table),段表其实是一个描述文件中各个段的数组
      • 段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息
      • 文件头后面就是各个段的内容,比如代码段保存的就是程序的指令,数据段保存的就是程序的静态变量等
    • 程序源代码编译后的机器指令经常被放在代码段( Code Section)里,代码段常见的名字有“.code”或“.text”

    • 已经初始化的全局变量和局部静态变量数据经常放在数据段( Data Section),数据段的一般名字都叫“.data”

    • 未初始化的全局变量和局部静态变量数据经常放在.bss段, 只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间

      • 我们知道未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以被放在data段的但是因为它们都是0,所以为它们在data段分配空间并且存放数据0是没有必要的。
      • 程序运行的时候它们的确是要占内存空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和, 记为.bss段
      • Bss( Block Started by Symbol)最初用于定义符号并且为该符号预留给定数量的未初始化空间
    • 程序源代码被编译以后主要分成两种段:程序指令程序数据代码段属于程序指令,而数据段和.bss段属于程序数据

    • 将数据和指令分段存放的好处:将数据置成可读写,程序的指令设置为只读,防止指令被改写

      • 当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域
      • 对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区
      • 域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写
    • 程序的指令和数据被分开存放对CPU的缓存命中率提高有好处

      • 现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
    • 如果系统中有多个副本时,将指令部分共享内存。

      • 如果系统中运行了数百个进程,可以想象共享的方法来节省大量空间

3.3 挖掘 SimpleSection.o

如不加说明, 则以下所有分析的都是32位的Intel x86平台的ELF格式

  1. SimpleSection代码清单

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    int printf(const char *format, ...);
    int global_init_val = 84;
    int global_uninit_val;

    void func1(int i) {
    printf("%d\n",i);
    }

    int main(void) {
    static int static_var = 85;
    static int static_var2;
    int a = 1;
    int b ;
    func1(static_var + static_var2 + a+b);
    return a;
    }
    1. $ gcc -c Simplesection.c得到目标文件Simplesection.o,参数-c表示只编译不链接

    2. $ objdump -h Simplesection.o 查看目标文件的结构和内容

      • readelf是linux下专门针对ELF文件格式的解析器

      • 参数“h”就是把ELF文件的各个段的基本信息打印出来

      • 上面的结果来看, SimpleSection.o的段包括了:

        1. 最基本的代码段(.text)
        2. 数据段(.data)
        3. BSS段(.bss)
        4. 只读数据段(.rodata)
        5. 注释信息段(.comment)
        6. 堆栈提示段(.note. GNU-stack)
      • 各种信息的含义

        1. Size: 段的长度

        2. File offset:段所在的位置

        3. CONTENTS:表示该段在文件中存在

          1. BSS段没有“ CONTENTS”,表示它实际上在ELF文件中不存在
          2. “noe.GNU-Stack” 段虽然有”CONTENTS”,但它的长度为0,这是个很古怪的段, 我们暂且忽略它,认为它在ELF文件中也不存在

  1. $size SimpleSection.o ,size命令用来查看ELF文件的代码段,数据段和BSS段的长度

    1
    2
    3
    4
    5
    text data bss dec hex filename
    95 8 4 107 6d SimpleSection.o

    dec表示3个段长度的和的十进制
    hexdec表示3个段长度的和的十六进制

3.3.1 代码段

  1. $ objdump -s -d Simplesection.o, -s参数可以将所有段的内容以十六进制的方式打印出来,-d参数可以将所有包含指令的段反汇编

    1. func1和main的内容是.text的对应的汇编表示
    2. 最左面一列是偏移量, 中间4列是十六进制内容, 最右面一列是text段的ASCI码形式
    3. .text段的第一个字节“0x55”就是“ func1”函数的第一条“push %ebp”指令,而最后一个字节0xc3正是 main()函数的最后一条指令“ret”。

3.3.2 数据段和只读数据段

  1. .data段保存的是那些已经初始化了的全局静态变量局部静态变量

    • 前面的Simple Sectionc代码里面一共有两个这样的变量,分别是 global_init_varabal与 static_val这两个变量每个4个字节,共刚好8个字节,所以“.data”这个段的大小为8个字节:54000000即为84, 55000000即为85
  2. .rodata存放只读数据, 一般是程序里面的只读变量(如const修饰的变量)和字符串常量

    1. 我们在调用“printf”的时候,用到了一个字符串常量“%d\n”,它是一种只读数据,所以它被放到了“.rodata”段,我们可以从输出结果看到”.rodata”这个段的4个字节刚好是这个字符串常量的ASCI字节序,最后以\0结尾
    2. 单独设立“.rodata”段有很多好处:
      1. 不光是在语义上支持了C++的 const关键字,
        2.操作系统在加载的时候可以将“.rodata”段的属性映射成只读这样对于这个段的任何修改操作都会作为非法操作处理,保证了程序的安全性
      2. 另外在某些嵌入式平台下,有些存储区域是采用只读存储器的,如ROM,这样将”.rodata”段放在该存储区域中就可以保证程序访问存储器的正确性。
  3. 有时候编译器会把字符串常量放到.data段,而不会单独放在.rodata

3.3.3 BSS段

  1. bss段存放的是未初始化的全局变量未初始化的局部静态变量

    • 上述代码中global_uninit_var和static_var2就是应该被存放在bss段, bss段为它们预留了空间
    • 但是我们可以看到该段的大小只有4个字节,这与 global_uninit_var和 static_var2的大小的8个字节不符

    • 可以通过符号表( Symbol Table)(后面章节介绍符号表)看到,只有static_var2被存放在了.bss段,而 global_uninit_var却没有被存放在任何段,只是一个未定义的COMMON符号

    • 这其实是跟不同的语言与不同的编译器实现有关,有些编译器会将全局的未初始化变量存放在目标文件.bss段
    • 有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间
    • 编译单元内部可见的静态变量的确是放在.bss段的(例如: global_uninit_var加上static修饰)
  2. $ objdump -x -s -d SimpleSection.o, 查看bss段

  3. 示例

    1
    2
    static int x1 =0;
    static int x2 =1;

    x1和x2会被放在什么段中呢?

    1. x1会被放在bss中,x2会被放在data中。
    2. x1为0,可以认为是未初始化的,因为未初始化的都是0,所以被优化掉了可以放在.bss, 这样可以节省磁盘空间,因为.bss不占磁盘空间。
    3. 另外一个变量x2初始化值为1,是初始化

3.3.4 其他段

  1. 由“.”作为前缀,表示这些表的名字是系统保留的
  2. 一个ELF文件也可以拥有几个相同段名的段,比如一个ELF文件中可能有两个或两个以上叫做“text”的段
  3. 可以插入自定义的段,但是不能用.作为段名的前缀
  4. .sdata、.tdesc、 .sbss、. ita4、.lit8、 .reginfo、 .grab、.lbis、.conflict可以不用理会这些段,它们已经被遗弃了
3.3.4.1 自定义段

你可能希望变量或某些部分的代码放到指定的段中,以实现某些特定的功能, 我们在全局变量或函数之前加上__attribute__((section("name")))属性就可以把相应的变量或函数以name作为段名的段中

1
2
3
4
将global变量放到Foo段中
__attribute__((section("Foo"))) int global = 42;
将foo方法放到BAR段中
__attribute__((section("BAR"))) void foo(){}

3.4 ELF文件结构描述

  1. 最前部是ELF文件头( ELF Header),它包含了描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等
  2. ELF文件中与段有关的重要结构就是段表( Section Header Table),该表描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性
  3. ELF中辅助的结构,比如字符串表、符号表等

3.4.1 文件头

  1. 查看文件头, $ readelf -h SimpleSection.o


ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、AB版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等

  1. elf的变量体系

  2. 文件头结构体Elf32_Ehdr

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    typedef struct {
    unsigned char e_ident[16];
    Elf32_Half e_type; //ELF文件类型
    Elf32_Half e_machine; //ELF文件的CPU平台属性
    Elf32_Word e_version; //ELF版本号
    Elf32_Addr e_entry; //EFL文件的入口虚拟地址。重定向文件入口地址为0
    Elf32_Off e_phoff; //
    Elf32_Off e_shoff; //段表在文件中的偏移
    Elf32_Word e_flags; //文件头标志位
    Elf32_Half e_ehsize; //文件头本身的大小
    Elf32_Half e_phentsize;
    Elf32_Half e_phnum;
    Elf32_Half e_shentisize; //段表描述符大小 sizeof(ELF32_Shdr)
    Elf32_Half e_shnum; //段表描述符数量
    Elf32_Half e_shstrndx; //段表字符串表所在的段在段表中的下标
    } Elf32_Ehdr;

  • ELF的文件头结构及相关常数被定义在”/usr/include/elf.h”
  1. ELF魔数

    Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00

    • 最开始的4个字节是所有ELF文件都必须相同的标识码,分别为0x7F、0x45、0x4c , 0x46

      1. 第一个字节对应ASCI字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASCI码
      2. a.out格式最开始两个字节为0x01、0x07; PE/COFF文件最开始两个个字节为0x4d、0x5a,即ASCI字符MZ
      3. 这种魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。
    • 第5个字节是用来标识ELF的文件类的,0x01表示是32位的,0x02表示是64位

    • 第6个字是字节序,规定该ELF文件是大端的还是小端的 ,0x01表示是小端的,0x02表示是大端

    • 第7个字节规定ELF文件的主版本号,一般是1。因为ELF标准自12版以后就再也没有更新了

    • 后面的9个字节ELF标准没有定义,一般填0,有些平台会使用这9个字节作为扩展标志

  2. 文件类型

    • e_type成员表示ELF文件类型,即前面提到过的3种ELF文件类型,每个文件类型对应一个常量。

    • 系统通过这个常量来判断ELF的真正文件类型,而不是通过文件的扩展名

    • ELF文件类型常量

  3. 机器类型

    • ELF文件格式在不同平台下遵循同一套ELF标准,但同一个ELF文件在不同平台下并不能使用。

    • e_machine表示该ELF文件的平台属性

3.4.2 段表

  1. 段表( Section Header Table)就是保存ELF中段的基本属性的结构
  2. 段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。
  3. ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的
  4. 段表在ELF文件中的位置由ELF文件头的”e_shoff”成员决定

  5. objdump -h命令只是把ELF文件中关键的段显示了出来,而省略了其他的辅助性的段

    • 使用了“ objudmp -h”来查看ELF文件中包含的段,结果是 SimpleSection
    • 里面看到了总共有6个段,分别是“.code”“.data””、””.bss”、“ rodata”、“ .comment”和 “note.GNU-stack”
  6. 使用$ readelf -S SimpleSection.o查看ELF真正的段表结构

    • 段表是一个以”ELF32_Shdr”结构体为元素的数组。
    • 每个”ELF32_Shdr”结构体对应一个段。
    • “ELF32_Shdr”被称为段描述符( Section Descriptor)
    • 对于 SimpleSection.o来说,段表就是有11个元素的数组。ELF段表的这个数组的第一个元素是无效的段描述符,它的类型为“NULL”,除此之外每个段描述符都对应一个段。也就是说 SimpleSection.o共有10个有效的段
  7. 段的描述符结构Elf32_Shdr

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    typedef struct
    {
    Elf32_Word sh_name; //段名在字符串表 ".shstrtab"的偏移量
    Elf32_Word sh_type; //段的类型
    Elf32_Word sh_flags; //段的标志位
    Elf32_Addr sh_addr; //段虚拟地址:如果可以被加载,则为加载后的虚拟地址。否则为0
    Elf32_Off sh_offset;//段在文件中的偏移
    Elf32_Word sh_size; //段的大小
    Elf32_Word sh_link; //段链接信息
    Elf32_Word sh_info; //段链接信息
    Elf32_Word sh_addralign; //段地址对齐
    Elf32_Word sh_entsize; //项的长度
    } Elf32_Shdr;

  8. SimpleSection.o的SectionTable 及所有段的位置和长度

  • Section Table长度为0x1b8,也就是440个字节,包含了11个段的描述,每个段为40个字符sizeof(Elf32_Shdr)
  • 最后一个段.rel.text结束后为0x450,即1104个字节,刚好是SimpleSection.o的长度。

  • Section Table和.rel.text都是因为对齐的原因,与前面的段之间分别有一个字节和两个字节的间隔

  1. 段的类型:sh_type

    • 段的名字只是在链接和编译过程中有意义,但它不能真正地表示段的类型
    • 我们也可以将一个数据段命名为“.text”,对于编译器和链接器来说,主要决定段的属性的是段的类型( sh_type)和段的标志位( sh_flags)

  2. 段的标志位:sh_flag

    • 表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等

  3. 系统保留段的属性


  4. 段的链接信息:sh_link, sh_info

    • 如果段的类型是与链接相关的(不论是动态链接或静态链接),比如重定位表、符号表等,那么 sh_link和 sh_info这两个成员所包含的意义如表3-11所示

    • 对于其他类型的段,这两个成员没有意义

3.4.3 重定位表

  1. Simplesection.o中有一个叫做“.rel.text”的段,它的类型(sh_type)为SHT_REL,也就是说它是一个重定位表( Relocation Table)
  2. 对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表
  3. SimpleSection.o中的.rel.text就是针对.text段的重定位表,因为.text段中至少有一个绝对地址的引用,那就是对“ printf”函数的调用
  4. 一个重定位表同时也是ELF的一个段,那么这个段的类型就是SHT_REL, 它的“ sh_link”表示符号表的下标,它的“ sh_info”表示它作用于哪个段
  5. 比如.rel.text作用于.text段,而.text段的下标为1,那么“ rel. text”的sh_info为1

3.4.4 字符串表

  1. 因为字符串的长度往往是不定的,所以用周定的结构来表示它比较困难。

  2. 把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串,不用考虑字符串长度问题

  3. 字符串表(String Table).strtab用来保存普通的字符串,段表字符串表(Section Header String Table).shstrtab用来保存段表中用到的字符串,比如段名。

  4. ELF文件头中的c_shstrndx就表示.shstrtab在段表数组中的下标。

3.5 链接的接口——符号

  1. 链接过程的本质就是要把多个不同的目标文件之间相互“粘”到一起

  2. 在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用

    • 目标文件B要用到了目标文件A中的函数“foo”,那么我们就称目标文件A定义( Define)了函数“foo”,称目标文件B引用( Reference)了目标文件A中的函数“foo”
  3. 每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆

  4. 在链接中,我们将函数和变量统称为符号( Symbol),函数名或变量名就是符号名( Symbol Name)

  5. 我们可以将符号看作是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成

  6. 链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表( Symbol Table),这个表里面记录了目标文件中所用到的所有符号。

  7. 每个定义的符号有个对应的值,叫做符号值( Symbol Value),对于变量和函数来说,符号值就是它们的地址

  8. 符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种

    • 全局符号: 定义在本目标文件的全局符号,可以被其他目标文件引用。

      • 比如 SimpleSection.o里面的“func1”、“main”和“global_init_var”
    • 外部符号:在本目标文件中引用的全局符号,却没有定义在本目标文件,这般叫做外部符号( External Symbol),也就是我们前面所讲的符号引用,比如 SimpleSection.o里面的printf

    • 段名:这种符号往往由编译器产生,它的值就是该段的起始地址。比如 SimpleSection.o里面的“.text”、“.data”等

    • 局部符号:这类符号只在编译单元内部可见

      • 比如 SimpleSection.o里面的“static_var”和“static_var2”。调试器可以使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往也忽略它们
    • 行号信息:即目标文件指令与源代码中代码行的对应关系,它也是可选的

      1. 对链接过程来说,最值得关注的就是全局符号,也就是前两个分类,因为链接过程中只关心符号的相互粘合。
      2. 段名,局部符号,行号信息,对于其他目标文件是不可见的,所以在链接过程中无关紧要。
  9. 使用很多工具来查看ELF文件的符号表,比如 readelf、 objdump、nm等

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    使用“nm”来查看
    $ nm Simplesection.o

    00000000 T func1
    00000000 D g1oba1_init_var
    00000004 C global_uninit_var
    0000001b T main
    U printf
    00000004 d static_var.1286
    00000000 b static_var2.1287

3.5.1 ELF符号表结构

  1. 符号表往往是文件中的一个段,段名一般叫.symtab

  2. 符号表是一个Elf32_Sym结构(32位ELF文件)的数组, 每个Elf32_Sym结构对应一个符号

  3. Elf32_Sym结构定义

    1
    2
    3
    4
    5
    6
    7
    8
    typedef struct {
    Elf32_Word st_name; //符号名在字符串表中的下标
    Elf32_Addr st_value; //符号相对应的值。
    Elf32_Word st_size; //符号大小
    unsigned char st_info; //符号类型和绑定信息
    unsigned char st_other;//目前为0,暂时没用
    Elf32_Half st_shndx;//符号所在的段
    } Elf32_Sym;

  • 符号类型和绑定信息(st_info)

    1. 该成员低4位表示符号的类型( Symbol Type)
    2. 高28位表示符号绑定信息( Symbol Binding)

  • 符号所在的段(st_shndx)

    1. 如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标
    2. 但是如果符号不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx的值有些特殊,如下图

  • 符号值(st_value)

    1. 每个符号都有一个对应的值,如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址
    2. 应该按下面这几种情况区别对待:


  1. 使用readelf查看符号表

    1
    $ readelf -s SimpleSection.o

    • 第一列Num表示符号表数组的下标,从0开始,共15个符号

    • 第二列 Value就是符号值,即 st_value;

    • 第三列Size为符号大小,即 st_size

    • 第四列和第五列分别为符号类型和绑定信息,即对应 st_info的低4位和高28位

    • 第六列vis目前在C/C++语言中未使用,以暂时忽略

    • 第七列Ndx即 st_shndx,表示该符号所属的段

    • 当然最后一列即符号名称

    • 从上面的输出可以看出

      • 第一个符号,即下标为0的符号, 永远是一个未定义的符号

      • 对于另外几个符号的解释

        1. func1和main函数都是定义在 SimpleSection.c里面的,它们所在的位置都为代码段所以Ndx为1,即 Simple Sectiono里面.text段的下标为1。它们是函数,所以类型是 STT_FUNC;它们是全局可见的,所以是 STB_GLOBAL;Size表示函数指令所占的字节数; Value表示函数相对于代码段起始位置的偏移量
  2. 通过readelf -a 或 objdump-x可知.text的下标为1

  3. 再来看 printf这个符号,该符号在 SimpleSection.c里面被引用,但是没有被定义。所以它的Ndx是 SHN_UNDEF

  4. global_init_var是已初始化的全局变量,它被定义在.data段,即下标为3。3.4.2 段表结构

  5. global_uninit_var是未初始化的全局变量,它是一个 SHN_COMMON类型的符号,它本身并没有存在于BSS段;关于未初始化的全局变量具体请参见”COMMON块”

  6. static_var.1533static_var2.1534是两个静态变量,它们的绑定属性是STB_ LOCAL,即只是编译单元内部可见。至于为什么它们的变量名从“ static_var”和“ static_var2”变成了现在这两个“ static_var.1533”和“ statIc_var2.1534”,我们在下面一节“符号修饰”中将会详细介绍

  7. 对于那些 STT_SECTION类型的符号,它们表示下标为Ndx的段的段名。它们的符号名没有显示,其实它们的符号名即它们的段名。比如2号符号的Ndx为1,那么它即表示.text段的段名,该符号的符号名应该就是“.text”。如果我们使用“ objdump -t”就可以清楚地看到这些段名符号

  8. “SimpleSection.c”这个符号表示编译单元的源文件名

3.5.2 特殊符号

  1. 当我们使用ld作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号

  2. 这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号

  3. 其实这些符号是被定义在ld链接器的链接脚本的,你无须定义它们,但可以声明它们并且使用。

  4. 链接器会在将程序最终链接成可执行文件的时候将其解析成正确的值

  5. 只有使用ld链接生产最终可执行文件的时候这些符号才会存在

  6. 几个特殊符号:

    • __executable_start,该符号为程序起始地址,注意,不是入冂地址,是程序的最开始的地址

    • __etext或 _etext或 etext,该符号为代码段结束地址,即代码段最末尾的地址

    • _edata或 edata,该符号为数据段结束地址,即数据段最末尾的地址

    • _end或end,该符号为程序结束地址

    • 以上地址都为程序被装载时的虚拟地址

    • 我们可以在程序中直接使用这些符号

3.5.3 符号修饰与函数签名

  • 约在20世纪70年代以前, 编译器编译源代码产生目标文件时,符号名与相应的变量和函数的名字是一样的
  • 但随着库越来越多,项目越来越大名字重复的原来越多,所以就会造成目标文件中符号名冲突
  • 为了防止类似的符号名冲突,UNIX下的C语言就规定,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线_。而 Fortran语言的源代码经过编译以后,所有的符号名前加上_,后面也加上_
  • 这种简单而原始的方法的确能够暂时减少多种语言目标文件之间的符号冲突的概率,但还是没有从根本上解决符号冲突的问题
  • 像C++这样的后来设计的语言开始考虑到了这个问题,增加了名称空间( Namespace)的方法来解决多模块的符号冲突问题
3.5.3.1 c++符号修饰
  • 为了支持C++这些复杂的特性,人们发明了符号修饰( Name Decoration)或符号改编( Name Mangling)的机制

  • C++允许多个不同参数类型的函数拥有一样的名字,就是所谓的函数重载;另外C++还在语言级别支持名称空间,即允许在不同的名称空间有多个同样名字的符号

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int func(int);
    float func(float);
    class C {
    int func(int);
    class C2{
    int func(int);
    };
    };

    namespace N {
    int func(int);
    class C {
    int func(int);
    };
    }

    这段代码中有6个同名函数叫func,只不过它们的返回类型和参数及所在的名称空间不同。我们引入一个术语叫做函数签名( Function Signature),函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。

  • 在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称( Decorated name)

  • 签名和名称修饰机制不光被使用到函数上,C++中的全局变量和静态变量也有同样的修饰机制

  • 名称修饰机制也可以防止静态变量的冲突

3.5.4 弱符号和强符号

  • 对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号

  • 我们也可以通过GCC的“ attribute(weak)”来定义任何一个强符号为弱符号

  • 注意,强符号和弱符号都是针对定义来说的,不是针对符号的引用

  • 对于下面的程序

    1
    2
    3
    4
    5
    6
    7
    extern int ext;
    int weak;
    int strong = 1;
    __attribute__((weak)) weak2=2;
    int main() {
    return 0
    }
    1. “weak”和“weak2”是弱符号

    2. “ strong”和“main”是强符号

    3. “ext”既非强符号也非弱符号,因为它是一个外部变量的引用

    4. 针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号

      • 规则1: 不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误

      • 规则2: 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号

      • 规则3: 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。

        • 比如目标文件A定义全局变量 global为int型,占4个字节;目标文件B定义 global为 double型,占8个字节,那么目标文件A和B链接后,符号gobl占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。
3.5.4.1 弱引用和强引用
  • 强引用( Strong Reference): 对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误

  • 弱引用(Weak Reference): 在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号来被定义,则链接器对于该引用不报错

  • 链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误

  • 这种弱符号和弱引用对于库来说十分有用,

    • 程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。

      1
      2
      3
      4
      5
      6
      7
      8
      通过使用__attribute__(weakref)”这个扩展关键字来声明对一个外部函数的引用为弱引用
      __attribute__((weakref)) void foo ();
      int main() {
      if (foo) {
      foo();
      }
      }
      此时编译,不会报错。运行时,也不会crash,如果foo未定义,则不执行。如果定义,则执行。
    • 库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数

3.6 调试信息

  • 目标文件里面还有可能保存的是调试信息
  • 调试信息在目标文件和可执行文件中占用很大的空间,往往比程序的代码和数据本身大好几倍,所以当我们开发完程序并要将它发布的时候,须要把这些对于用户没有用的调试信息去掉,以节省大量的空间

理解

  1. 文件头中e_shoff字段表明了段表(Section Header Table)在整个文件中的位置
  2. 段表字符串表(Section Header String Table).shstrtab用来保存段表中用到的字符串,比如段名
  3. 文件头中e_shstrndx字段表明了.shstrtab在段表(Section Header Table)中的位置
  4. 段表描述符(Elf32_Shdr)中的字段sh_name, 表明段名在段表字符串表 “.shstrtab”的偏移量
  5. 字符串表(String Table).strtab用来保存普通的字符串
  6. 对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表,例如rel.text
  7. 通常来说符号即为变量名和函数名符号值即为变量和函数对应的地址 (重点)
  8. 符号表里的符号是该模块的全局符号,而不是在函数内部定义的局部符号,因为局部符号是放在函数栈内的,地址是无法确定的。 (重点)
  9. 进行链接的时候,只需要关注本文件定义的全局符号以及引用的外部的符号 (重点)
  10. 如果是本文件定义的全局符号,其st_shndx为该符号在本文件中的所在段的下标
  11. 如果是引用的外部的符号,其st_shndx为0,即为SHN_UNDET,表示未定义。

静态链接

如何将多个目标文件链接起来,形成一个可执行文件? 链接的核心内容:静态链接

定义两个文件a.c, b.c

1
2
3
4
5
6
7
8
9
10
11
12
/* a.c */                        
extern int shared;
int main() {
int a = 100;
swap(&a, &shared);
}

/* b.c */
int shared = 1;
void swap(int *a, int *b) {
*a ^= *b ^= *a ^= *b
}
  1. 将两个源文件编译后,形成目标文件a.o, b.o 。
  2. “b.c”总共定义了两个全局符号,一个是变量“ shared”,另外一个是函数“swap”
  3. “a,c”里面定义了一个全局符号就是“main”

4.1 空间与地址分配

  • 对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合并成一个输出文件
  • 链接器如何将它们的各个段合并到输出文件?或者说,输出文件中的空间如何分配给输入文件?

4.1.1 按序叠加

最简单的方案就是将输入的目标文件按照次序叠加起来

  • 在有很多输入文件的情况下,输出文件将会有很多零散的段
  • 这种做法非常浪费空间,因为每个段都须要有一定的地址和空间对齐要求
  • 对于x86的硬件来说,段的装载地址和空间的对齐单位是页,也就是4096字节,那么就是说如果一个段的长度只有1个字节,它也要在内存中占用4096字节。这样会造成内存空间大量的内部碎片,所以这并不是一个很好的方案

4.1.2 相似段合并

更实际的方法是将相同性质的段合并到一起

  • “.bss”段在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以链接器在合并各个段的同时,也将“.bss”合并,并且分配虚拟空间
  • “链接器为目标文件分配地址和空间”这句话中的“地址和空间”其实有两个含义:

    • 在输出的可执行文件中的空间
    • 在装载后的虚拟地址中的虚拟地址空间

      • 对于有实际数据的段,比如“.text”和“.data”来说,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在
      • 对于“.bss”这样的段来说,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容
      • 事实上,我们在这里谈到的空间分配只关注于虚拟地址空间的分配,因为这个关系到链接器后面的关于地址计算的步骤,而可执行文件本身的空间分配与链接过程关系并不是很大
  • 链接器空间分配的策略基本上都采用上述方法中的第二种,使用这种方法的链接器一般都采用一种叫两步链接( Two-pass Linking)的方法

    • 第一步: 空间与地址分配

      1. 扫描所有的输入目标文件,并且获得它们的各个段的长度属性和位置
      2. 将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统放到一个全局符号表
      3. 通过上面的过程, 链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系
    • 第二步: 符号解析与重定位

      1. 使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等
      2. 事实上第二步是链接过程的核心,特别是重定位过程
  • a.o和b.o链接成可执行文件的过程

    1
    2
    3
    4
    $ ld a.o b.o -e main -o ab

    -e main 表示将main函数作为程序的入口。ld默认的程序入口为_start。
    -o ab 表示链接输出文件名为ab, 默认为a.out。

    1. VMA表示 Virtual Memory Address,即虚拟地址,LMA表示 Load Memory Address即加载地址。正常情况下这两个值应该是一样的,但是在有些嵌入式系统中,特别是在那些程序放在ROM的系统中时,LMA和VMA是不相同的。这里我们只要关注VMA即可。
    2. 链接后程序使用的地址已经是在程序在进程中的虚拟地址了。
    3. 因此我们关心上面各个段中的VMA( Virtual Memory Address)和Size,而忽略文件偏移(File off)。
      1. 在链接之前,目标文件中的所有段的ⅤMA都是0,因为虚拟空间还没有被分配,所
        以它们默认都为0
      2. 链接之后,可执行文件“ab”中的各个段都被分配到了相应的虚拟地址
    4. 整个链接过程前后,目标文件各段的分配、程序虚拟地址如下图

      • “a.o”和“bo”的代码段被先后叠加起来,合并成“ab”的一个.text段,加起来的长度为0x72

4.1.3 符号地址的确定

  1. 在第一步的扫描和空间分配阶段时,链接器将目标文件按照相似段合并后,输入文件中的各个段在链接后的虚拟地址就已经确定了
  2. 然后链接器开始计算各个符号的虚拟地址,因为各个符号在段内容的地址是相对固定的。所以链接器须要给每个符号加上一个相对于对应段的偏移量,使它们能够调整到正确的虚拟地址。

    从前面“objdump”的输出看到,“main”位于“a.o”的“.text”段的最开始,也就是偏移为0,所以“main”这个符号在最终的输出文件中的地址应该是0x08048094+0,即0x08048094

4.2 符号解析和重定位

4.2.1 重定位

在a.o中是怎样使用shared,swap这两个外部指令的。

4.2.1.1 重定位前

通过objdump中的-d参数查看a.o的反汇编结果

1
$ objdump -d a.o

  1. 在未进行前面提到过的空间分配之前,目标文件代码段中的起始地址以0x0000000开始,等到空间分配完成以后,各个函数才会确定自己在虚拟地址空间中的位置
  2. main函数总共由于17条指令组成,偏移为0x18的mov指令,总共8个字节。前4个字节是指令,后面4个字节为shared的地址,此时为0x0000000
  3. 偏移为0x26的call指令,总共5个字节。第一个字节是指令,后面4个字节为swap的地址,此时为0xFFFFFFFC,这也是一个假的地址,因为编译的时候是不知道swap的地址的
4.2.1.2 重定位后
  • 编译器把把真正的地址计算工作留给了链接器
  • 通过前面的空间与地址分配可以得知,链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了
  • 那么链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正
  • 用 objdump来反汇编输出程序“ab”的代码段,可以看到main函数的两个重定位入口都已经被修正到正确的位置

    经过修正以后,“ shared”和“swap”的地址分别为0x08049108和0x00000009

4.2.2 重定位表

  • 链接器是通过重定位表知道哪些指令是要被调整,以及这些指令怎么调整。
  • 重定位表( Relocation Table)是可重定位的ELF文件中一个或多个段(也叫重定位段),专门用来保存与重定位相关的信息

    1. 比如代码段“text”如有要被重定位的地方,那么会有一个相对应叫“.rel.text”的段保存了代码段的重定位表;
    2. 如果代码段“data”有要被重定位的地方,就会有一个相对应叫“.rel.data”的段保存了数据段的重定位表
  • 使用objdump来查看a.o的重定位表

    1
    $ objdump -r a.o

    • 可以看到“a.o”里面有两个重定位入口
    • 重定位入口的偏移( Offset)表示该入口在要被重定位的段中的位置
    • “ RELOCATION RECORDS FOR [.tex]”表示这个重定位表是代码段的重定位表
    • 对照前面的反汇编结果可以知道,这里的0x1c和0x27,分别就是代码段中“shared”和“swap”的地址
    • 重定位表是一个Elf32_Rel结构的数组,每个数组元素对应一个重定位入口

      1
      2
      3
      4
      typedef struct {
      Elf32_Addr r_offset; //重定位入口在对应段中的偏移
      Elf32_Word r_info; //重定位入口的类型和符号
      } Elf32_Rel;

4.2.3 符号解析

  • 重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号
  • 重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就需要确定这个符号的目标地址。
  • 这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位

  • 查看a.o的符号表

    1
    $ readelf -s a.o

    1. “ GLOBAL”类型的符号,除了“main”函数是定义在代码段之外,其他两个“ shared和“swap”都是“UND”,即“ undefined”未定义类型
    2. 这种未定义的符号都是因为该目标文件中有关于它们的重定位项
    3. 所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误

4.2.4 指令修正方式

  • 不同的处理器指令对于地址的格式和方式都不一样
  • 寻址方式:
    1. 近址寻址或远址寻址
    2. 绝对寻址或相对寻址
    3. 寻址长度为8位、16位、32位或64位
    4. 绝对近址32位寻址
    5. 相对近址32位寻址

4.3 COMMON块

  • COMMON机制

    现在的编译器和链接器都支持一种叫 COMMON块(Common block)的机制,
    这种机制最早来源于 Fortran,早期的 Fortran没有动态分配空间的机制,程序员必须事先声明它所需要的临时使用空间的大小。 Fortran把这种空间叫 COMMON块,当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。

  • 现代的链接机制在处理弱符号的时候,采用的就是与 COMMON块一样的机制

  • 链接器对同一个符号在不同目标文件中的定义分为三种情况

    1. 两个或两个以上强符号类型不同
      多个强符号定义本身就是非法的,链接器会报符号多重定义错误

    2. 有一个强符号,其他都是弱符号,出现类型不一致;

      1. 输出文件中,以强符号的类型为准。
      2. 如果某个弱符号类型大小大于强符号,则ld链接器会报警告
        ld: warning: alignment 4 of symbol global in a.o smaller tha n 8 in b.o
    3. 两个或两个以上弱符号类型不一致
      以类型最大的符号为准

  • 编译器将未初始化的全局变量定义作为弱符号处理

    1
    2
    3
    4
    5
    6
    7
    8
    在 SimpleSection.c这个例子中, global_uninit_val并没有被放在.bbs段,而是被标记为common, 它在符号表中的值如下

    st_name = "global_uninit_var"
    st_value = 4
    st_size = 4
    st_info = 0x11 STB_GLOBAL STT_OBUECT
    st_other = 0
    st_shndx = 0xfff2 SHN_COMMON
  • 为什么需要COMMON机制?

    编译器和链接器允许不同类型的弱符号存在, 但是链接器无法判断各个符号的类型是否一致,从而导致链接的时候不知道怎么分配空间

  • 目标文件中,编译器为什么不直接把未初始化的全局变量也当作未初始化的局部静态变量一样处理,为它在BSS段分配空间,而是将其标记为一个 COMMON类型的变量?

    1. 当编译器将一个编译单元编译成目标文件的时候,如果该编译单元包含了弱符号(未初始化的全局变量就是典型的弱符号),那么该弱符号最终所占空间的大小在此时是未知的,因为有可能其他编译单元中该符号所占的空间比本编译单元该符号所占的空间要大
    2. 所以编译器此时无法为该弱符号在BSS段分配空间,因为所须要空间的大小未知
    3. 但是链接器在链接过程中可以确定弱符号的大小,因为当链接器读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定了,所以它可以在最终输出文件的BSS段为其分配空间。所以总体来看,未初始化全局变量最终还是被放在BSS段的。
  • 当我们在gcc中使用”-fno-common”时,那么所有未初始化的全局变量不以 COMMON块的形式处理。或者使用“attribute”扩展:int global __attribute__((nocommon))

  • 一个未初始化的全局变量不是以 COMMON块的形式存在,那么它就相当于一个强符号,如果其他目标文件中还有同一个变量的强符号定义,链接时就会发生符号重复定义错误

4.4 C++相关问题

  • C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作,主要有两个方面:
    • C++的重复代码消除
    • 全局构造与析构
  • C++复杂的结构往往在不同的编译器和链接器之间相互不能通用,使得它还需要进行二进制的兼容

4.4.1 重复代码消除

C++编译器在很多时候会产生重复的代码,比如模板( Templates)、外部内联函数(Exern inline function)和虚函数表(Ⅴirtual Function Table)都有可能在不同的编译单元里生成相同的代码。如果直接将重复代码保留下来,会造成下面几个问题

  • 空间浪费
    • 可以想象一个有几百个编译单元的工程同时实例化了许多个模板,最后链接的时候必须将这些重复的代码消除掉,否则最终程序的大小肯定会膨胀得很厉害
  • 地址较易出错
    • 有可能两个指向同一个函数的指针会不相等
  • 指令运行效率较低
    • 因为现代的CPU都会对指令和数据进行缓存,如果同样一份指令有多份副本,那么指令Cache的命中率就会降低

主流做法是不同编译单元内将模板,外部内联函数,虚函数表等各自单独的放在一个段中,等到在链接时丢弃重复的段,然后合并到代码段中。

模板函数是add<T>(),某个编译单元以int类型和foat类型实例化了该模板函数,那么该编译单元的日标文件中就包含了两个该模板实例的段。为了简单起见,我们假设这两个段的名字分别叫 .temp.add、和 .temp.add。这样,当别的编译单元也以int或float类型实例化该模板函数后,也会生成同样的名字,这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段

  • GCC把这种类似的须要在最终链接时合并的段叫“ Link once”,它的做法是将这种类型的段命名为“ .gnu.linkonce.name”
  • VISUAL C++编译器做法稍有不同,它把这种类型的段叫做“COMDAT”
  • 相同名称的段可能拥有不同的内容,此时链接器随意选择其中任何一个副本作为链接的输入,然后同时提供一个警告信息。

这可能由于不同的编译单元使用了不同的编译器版本或者编译优化选项,导致同一个函数编译出来的实际代码有所不同

4.1.1.1函数级别链接

一个目标文件可能包含成千上百个函数或变量。使用某个目标文件中的任意一个函数或变量时,就须要把它整个地链接进来,也就是说那些没有用到的函数也被一起链接了进来。这样的后果是链接输出文件会变得很大。

VISUAL C++编译器提供了一个编译选项叫函数级别链接( Functional-Level Linking,/Gy),这个选项的作用就是让所有的函数都像前面模板函数一样,单独保存到一个段里。当链接器须要用到某个函数时,它就将它合并到输出文件中,对于那些没有用的函数则将它们抛弃。

这种做法可以很大程度上减小输出文件的长度,减少空间浪费。但是这个优化选项会减慢编译和链接过程。

因为链接器须要计算各个函数之间的依赖关系,并且所有函数都保持到独立的段中,目标函数的段的数量大大增加,重定位过程也会因为段的数目的增加而变得复杂,目标文件随着段数目的增加也会变得相对较大。GCC编译器也提供了类似的机制,它有两个选择分别是-ffunction- sections-fdata-sections,这两个选项的作用就是将每个函数或变量分别保持到独立的段中

4.4.2 全局构造与析构

C++的全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后被执行。.init该段里面保存的是可执行指令,它构成了进程的初始化代码。当一个程序开始运行时,在main函数被调用之前,Gibc的初始化部分安排执行这个段的中的代码。.fini该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时, Glibc会安排执行这个段中的代码。C++的全局对象的构造函数放在.init段里。C++的全局对象的析构函数放在.fini段里

4.4.3 C++与ABI

  • 使两个编译器编译出来的目标文件能够相互链接,那么这两个目标文件必须满足下面这些条件:

    1. 采用同样的目标文件格式
    2. 拥有同样的符号修饰标准
    3. 变量的内存分布方式相同
    4. 函数的调用方式相同
  • 符号修饰标准变量内存布局函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)
    • ABI:指的是二进制层面的接口
    • API:指的是源码层面的接口
  • 影响ABI的因素非常多,硬件、编程语言、编译器、链接器、操作系统等都会影响ABI

  • 对于C语言的目标代码来说,以下几个方面会决定目标文件之间是否二进制兼容

    1. 内置类型(如int、 float、char等)的大小和在存储器中的放置方式(大端、小端、对齐方式等)
    2. 组合类型(如 struct、 union、数组等)的存储方式和内存分布。
    3. 外部符号( external-linkage)与用户定义的符号之间的命名方式和解析方式,如函数名func在C语言的目标文件中是否被解析成外部符号_func
    4. 函数调用方式,比如参数入栈顺序、返回值如何保持等。
    5. 堆栈的分布方式,比如参数和局部变量在堆栈里的位置,参数传递方法等。
    6. 寄存器使用约定,函数调用时哪些寄存器可以修改,哪些须要保存,等等。
  • C++增添了更多额外的内容,使C++要做到二进制兼容比C来得更为不易

    1. 继承类体系的内存分布,如基类,虚基类在继承类中的位置等。
    2. 指向成员函数的指针( pointer-to-member)的内存分布,如何通过指向成员函数的指针来调用成员函数,如何传递this指针
    3. 如何调用虚函数, vtable的内容和分布形式, vtable指针在 object中的位置等。
    4. template如何实例化。

    5. 外部符号的修饰

    6. 全局对象的构造和析构
    7. 异常的产生和捕获机制
    8. 标准库的细节问题,RTTI如何实现等
    9. 内嵌函数访问细节
  • C++一直为人诟病的一大原閃是它的二进制兼容性不好, 目前情况还是不容乐观,基本形成以微软的 VISUAL C++和GNU阵营的GCC(采用 Intel Itanium C++ABI标准)为首的两大派系,各持己见互不兼容

    不仅不同的编译器编译的二进制代码之间无法相互兼容,有时候连同一个编译器的不同版本之间兼容性也不好

    比如我有一个库A是公司 Company A用 Compiler A编译的,我有另外一个库B是公司 Company B用 Compiler B编译的,当我想写一个C++程序来同时使用厍A和B将会很是棘手

4.5 静态链接

  • 静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。(里面也包含文件的索引)

  • gibc本身是用C语言开发的,它由成百上千个C语言源代码文件组成,也就是说,编译完成以后有相同数量的目标文件。比如输入输出有printf.o, scanf.o:文件操作有 fread o, fwrite.o;时间日期有 date.o, time.o;内存管理有 malloc.o

  • 把这些零散的目标文件直接提供给库的使用者,很大程度上会造成文件传输、管理和组织方面的不便,于是通常人们使用“ar”压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索,就形成了libc.a这个静态库文件。

  • libc.a里面总共包含了1400个目标文件,如何查找’printf”函数所在的目标文件?

    1
    2
    使用“objdump”或“readelf”加上文本查找工具如“grep"”
    $ objdump -t libc.a

  • 那么我们写一个hello.c程序,编译成hello.o与printf.o链接是不是就可以了呢?

    1. $gcc -c -fno-builtin hello.c得到hello.o。GCC会自作聪明地将“ Hello world”程序中只使用了一个字符串参数的“pinf”替换成“puts”函数,以提高运行速度,我们要使用“-fno-builtin”关闭这个内置函数优化选项

    2. 通过ar工具解压出“ printf.o”, ar -x libc.a

    3. $ hello.o printf.o,链接失败了,缺少了两个外部符号定义


    4. 当我们找到这两个外部符号定义的文件时,发现它们还依赖其他目标文件,依赖似乎无穷尽,所以直接链接很难成功。

  • 通过gcc将整个编译链接过程的中间步骤打印出来


  • 第一步是调用ccl程序,这个程序实际上就是GCC的C语言编译器,它将“ hello. c”编译成一个临时的汇编文件“/tmp/ccUhtGSB.s”
  • 第二步调用as程序,as程序是GNU的汇编器,它将“/tmp/ccUhtGSB.s”汇编成临时目标文件“/tmp/ccQZRPL5.o。”,这个“/tmp/ccQZRPL5.o”实际上就是前面的“ hello.o”
  • 最关键的步骤是最后一步,GCC调用 collect2程序来完成最后的链接。
    • collect2可以看作是ld链接器的一个包装
    • 它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理, 主要是收集所有与程序初始化相关的信息并且构造初始化的结构
  • 最后一步中,至少有下列几个库和目标文件被链接入了最终可执行文件
    • ctr1.o
    • ctri.o
    • crtbeginT.o
    • libgcc.a
    • libgcc_eh.a
    • libc.a
    • crtend.o
    • crtn.o
  • 之所以静态库中一个目标文件只包含一个函数,是为了减少引入的目标文件的数量从而减少输出文件的体积。

4.6 链接过程控制

4.6.1 链接控制脚本

  • 链接器一般提供多种控制链接过程的方法,以用来产生用户所需要的文件。

    1. 使用命令行来给链接器指定参数,我们前面所使用的ld的-o、-e参数就属于这类。
    2. 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令。VISUAL C++编译器会把链接参数放在PE目标文件的 drectve段以用来传递参数
    3. 使用链接控制脚本—最为灵活、最为强大的链接控制方法
  • VISUAL C++也允许使用脚本来控制整个链接过程, VISUAL C+把这种控制脚本叫做模块定义文件( Module- Definition File),它们的扩展名一般为.def

  • 我们以ld的链接脚本来进行介绍

    1. $ ld verbose查看默认的链接脚本
    2. ld链接脚本默认存放在/usr/lib/ldscripts/下, 不同的机器平台、输出文件格式都有相应的链接脚本。Intel IA32下的普通可执行ELF文件链接脚本文件为elf_i386.x;IA32下共享库的链接脚本文件为elf_i386.xs
    3. 为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本。然后通过$ ld -T link.script,指定链接脚本。
  • 链接脚本相关的内容用到时可以去参考书,暂时先不管;

4.7 BFD库

  • BFD库( Binary File Descriptor library)目标是希望通过一种统一的接口来处理不同的目标文件格式。
  • 现在GCC(更具体地讲是GNU汇编器GAS, GNU Assembler)、链接器ld、调试器GDB及 binutils的其他工具都通过BFD库来处理目标文件,而不是直接操作目标文件。
  • 这样做最大的好处是将编译器和链接器本身同具体的目标文件格式隔离开来,一旦我们须要支持一种新的目标文件格式,只须要在BFD库里面添加一种格式就可以了,而不须要修改编译器和链接器。

总结

  • 在链接之前,指令内所使用的外部符号对应的地址不正确的(例如0x00000000等)
  • 链接器,通过相似段合并将所有的.o文件链接在一起
  • 链接的两大过程:静态链接过程
    • 空间与地址分配
      1. 将所有目标文件进行相似段合并时,就能确定原来目标文件的段,在最终的输出文件的位置
      2. 将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统放到一个全局符号表
      3. 此时已经可以确定,各个段在虚拟地址空间的地址,以及各个符号在虚拟地址空间的地址
        • 各个指令和数据段等内容的虚拟地址都会被重新确定
        • 此时唯一没有被确定的是外部符号的地址
    • 符号解析与重定位
      • 符号解析过程
        • 通过符号在全局符号表中查找对应符号的虚拟地址
      • 重定位过程
        • 通过重定位表,知道哪些位置的指令地址需要进行重新调整,以及怎么调整。
        • 重定位表中一系列的重定位入口,通过重定位入口可以得到需要重定位的符号,以及符号位于哪条指令
        • 得到符号后,通过符号解析得到对应的地址,然后修改对应指令中该符号的地址即可。

可执行文件的装载与进程

可执行文件只有装载到内存以后才能被CPU执行。

6.1 进程虚拟地址空间

  • 程序和进程的区别

    1. 程序(或者狭义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件
    2. 进程则是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库叫做运行时( Runtime)也有一定的含义
  • 程序被运行起来以后,它将拥有自己独立的虚拟地址空间( Virtual Address Space)

  • 虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。

    1. 硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小
    2. 32位的硬件平台决定了虚拟地址空间的地址为0到2^32-1,即为4GB
    3. 64位的硬件平台具有64位寻址能力,即为2^64-1,为17179869184GB
  • 从程序的角度,c语言指针所占的位数和虚拟空间的位数相同

  • 操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中

  • 进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程

  • Linux操作系统中,虚拟地址的空间分配

    1. 操作系统本身用了1GB:从地址0xC000000到0XFFFFFFFF,共1GB。剩下的从0x000000址开始到0xBFFFFFFF共3GB的空间都是留给进程使用的
    2. 进程并不能完全使用这3GB的虚拟空间,其中有一部分是预留给其他用途的
  • 对于 Windows操作系统来说,它的进程虚拟地址空间划分是操作系统占用2GB,那么

    进程只剩下2GB空间。但可以修改为操作系统占用1GB, 进程占3GB。

6.1.1 PAE
  • 32位虚拟空间地址是4GB,这个是无法改变的。

  • 但从硬件层面上来讲,将原先的32位地址先扩展至36位地址线之后, Intel修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存(64G)。 Intel把这个地址扩展方式叫做PAE( Physical Address Extension)。

  • 一个应用程序中0x10000000x2000000一段256MB的虚拟地址空间用来做窗口,程序可以从高于4GB的物理空间中申请多个大小为256MB的物理空间,编号成A、B、C等,然后根据需要将这个窗口映射到不同的物理空间块,用到A时将0x1000000-0x2000000映射到A,用到B、C时再映射过去,如此重复操作即可

在 Windows下,这种访问内存的操作方式叫做AWE( Address windowing Extensions)在Linux等类unix操作系统则采用mmap()系统调用来实现。

6.2 装载方式

  • 程序执行时所需要的指令和数据必须在内存中才能够正常运行,所以最简单的方法是直接把程序整个载入内存。但是很多时候物理内存是不够,扩展更大的内存代价又是昂贵的。

  • 人们发现程序运行时是有局部性原理的,所以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理

  • 覆盖载入(Overlay)页映射(Paging)是两种典型的载入方法

  • 动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。

6.2.1 覆盖载入

  • 覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了

  • 程序员在编写程序的时候需要手工将程序分割成若干块,然后编写一个小的辅助代码()来管理这些模块何时应该驻留内存而何时应该被替换掉。

  • 这个小的辅助代码就是所谓的覆盖管理器( Overlay Manager)

  • 例如main模块会调用A,B模块。但A,B模块不会互相调用。整个程序需要1792个字节

    1. 当main调用A时将A载入。当main调用B时将B载入覆盖A。所以整个程序只需要1536个字节
    2. 覆盖管理器比较小,一般是数十到数百个字节,常驻内存
  • 真实的项目比较复杂,序员需要手工将模块按照它们之间的调用依赖关系组织成树状结构

    • 这个树状结构中从仼何一个模块到树的根(也就是main)模块都叫调用路径

      • 当该模块被调用时,整个调用路径上的模块必须都在内存中。比如程序正在模块E中执行代
        码,那么模块B和模块mai必须都在内存中,以确保模块E执行完毕以后能够正确返回至模块B和模块main
    • 禁止跨树间调用

      • 任意一个模块不允许跨过树状结构进行调用。比如上面例子中,模块A不可以调用模块B、E、F;模块C不可以调用模块D、B、E、F等。因为覆盖管理器不能够保证跨树间的模块能够存在于内存中。不过很多时候可能两个子模块都需要依赖于某个模块,比如模块E和模块C都需要另外一个模块G,那么最方便的做法是将模块G并入到main模块中,这样G就在E和C的调用路径上了。

6.2.2 页映射

  • 页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。

  • 页映射将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页

    硬件规定的页的大小有4096字节、8192字节、2MB、4MB等,最常见的 Intel IA32处理器一般都使用4096字节的页,
    那么512MB的物理内存就拥有51210241024/4096=131072个页。

  • 页映射装载过程

    1. 假设我们的32位机器有16KB的内存,每个页大小为4096字节,则共有4个页

    2. 设程序所有的指令和数据总和为32KB,那么程序总共被分为8个页。我们将它们编号为P0~P7

    3. 16KB的内存无法同时将32KB的程序装入,那么我们将按照动态装入的原理来进行整个装入过程

    4. 此时P0, P3,P5,P6已经装载到内存了,然后接下来要装载P4。可以按照先进先出算法将F0分配给P4。或者如果发现F2很少被访问到,可以按照最少使用算法,选择F2分配给P4.

    5. 页映射机制由操作系统的存储管理器进行管理。

6.3 从操作系统角度看可执行文件的装载

6.3.1 进程的建立

  • 一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程

  • 创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:

    1. 创建一个独立的虚拟地址空间
    2. 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
    3. 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
  • 创建虚拟地址空间

    1. 一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间
    2. 创建一个虚拟空间实际上是创建映射函数所需要的相应的数据结构
    3. 在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录( Page Directory), 映射关系等到后面程序发生页错误的时再进行设置
    4. 页映射关系函数是虚拟空间到物理内存的映射关系。
  • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系

    1. 这一步是建立虚拟空间与可执行文件的映射关系。

    2. 当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理贞的映射关系。

    3. 当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。

    4. 由于可执行文件在装载时实际上是被映射到虚拟空间,所以可执行文件很多时候又被叫做映像文件( Image)

    5. 假设我们的ELF可执行文件只有一个代码段“.text“,它的虚拟地址为0x08048000,它在文件中的大小为0x000e1,对齐为0x1000

      由于虚拟存储的贞映射都是以页为单位的,在32位的 Intel IA32下一般为4096字节,所以32位ELF
      的对齐粒度为0x1000。由于该.text段大小不到一个页,考虑到对齐该段占用一个段

    6. Linux将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area)。在windows中叫虚拟段

      操作系统创建进程后,会在进程相应的数据结构中设置有一个.text段的VMA:它在虚拟空间中的地址为
      0x08048000~0x08049000,它对应ELF文件中偏移为0的.text,它的属性为只读(一般代码段都是只读的),还有一些其他的属性。

    7. 上面描述的数据结构,即为映射关系,该映射关系在操作系统内部保存。

  • 将CPU指令寄存器设置成可执行文件入口,启动运行

    1. 操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行
    2. 这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换
    3. 可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址
    4. 该入口地址即为ELF文件头中保存有入口地址

6.3.2 页错误

  • 进程建立后,其实可执行文件的真正指令和数据都没有被装入到内存中
  • 操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已
  • 当CPU开始打算执行入口地址的指令时,发现该页面0x08048000~0x0804900是个空页面,于是发生页错误。CPU将控制权交给操作系统
  • 操作系统通过查询前面建立的数据结构(虚拟空间与可执行文件之间的关系),找到该页面所在的VMA,并在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系

  • 然后再把控制权还给进程,进程共刚才页错误的位置重新开始执行。

  • 随着进程执行, 页错误也会不断地产生,当所需要的内存会超过可用的内存时,操作系统进行虚拟内存的管理。

6.4 进程虚拟空间分布

6.4.1 ELF文件链接视图和执行视图

  • 当段的数量增多时,如果按照每个段都映射为系统页的整数倍,会产生空间浪费的问题。
  • 段的权限组合:

    1. 以代码段为代表的权限为可读可执行的段
    2. 以数据段和BSS段为代表的权限为可读可写的段
    3. 以只读数据段为代表的权限为只读的段
  • 新方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射。

  • ELF可执行文件引入了一个概念叫做Segment,一个Segment包含一个或多个属性类似的Section
    • 比如有两个段分别叫“.text”和“.init”,它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设.text为4097字节,.init为512字节,这两个段分别映射的话就要占用三个页面,但是,如果将它们合并成一起映射的话只须占用两个页面
  • “ Segment’”的概念实际上是从装载的角度重新划分了ELF的各个段。系统正是按照segment而不是按照section来映射可执行文件的。
  • 在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。以便将它们当成一个段来进行映射

  • segment结构,有一段代码如下

    1
    2
    3
    4
    5
    6
    #include <stdlib.h>
    int main() {
    while(1) {
    sleep(1000);
    }
    }
    1. 使用静态链接编译成可执行文件 $ gcc -static SectionMapping.c -o SectionMapping.elf
    2. 通过$ readelf -S SectionMapping.elf 可知共有33个section

    3. 描述segment的结构叫程序头(Program Header), 它描述了ELF如何被操作系统映射到进程的虚拟空间

    4. 通过$ readelf -l SectionMapping.elf查看segment

    5. 这个可执行文件中共有5个 Segment.。从装载的角度看,我们目前只关心两个“LOAD”类型的 Segment,因为只有它是需要被映射的,其他的装载时起辅助作用的。

    6. 从Section的角度看ELF文件是链接视图,从Segment的角度看ELF文件是执行视图
    7. 所有相同属性的Section被归类到一个Segment中,并被映射到同一个VMA

    8. ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存Segmetn的信息。

    9. ELF目标文件不需要被装载,所以它没有程序头表,ELF可执行文件和共享库文件都有。
  • 程序头表是一个结构体(ELF32_Phdr)数组


    • 如果 p_memse大于 p_filesz,就表示该“ Segment”在内存中所分配的空间大小超过文件中实际的大小。
    • “多余”的部分则全部填充为“0”。这样做的好处是,我们在构造ELF可执行文件时不需要再额外设立BSS的“ Segment”了,可以把数据“ Segment”的 p_memse扩大,那些额外的部分就是BSS
    • 在前面的例子中只看到了两个“LOAD”类型的Segment,是因为BSS已经被合并到了数据类型的段里面了

6.4.2 堆和栈

  • 操作系统通过使用VMA来对进程的地址空间进行管理
  • 一个进程中的栈和堆分别都有一个对应的VMA
  • 在 Linux下,通过“/pro”来查看进程的虚拟空间分布:

    1
    2
    3
    $ ./SectionMapping elf&
    [1]21963
    $ cat /proc/21963/maps

    1. 第一列是VMA的地址范围
    2. 第二列是VMA的权限
      “r”表示可读
      “w”表示可写
      “x”表示可执行
      “p”表示私有(COW, Copy on Write)
      “s”表示共享
    3. 第三列是偏移,表示VMA对应的 Segment在映像文件中的偏移
    4. 第四列表示映像文件所在设备的主设备号和次设备号
    5. 第五列表示映像文件的节点号
    6. 最后列是映像文件的路径
    • 前两个是映射到可执行文件中的两个 Segment

    • 另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射

      到文件中,这种VMA叫做匿名虚拟内存区域( Anonymous Virtual Memory Area)

    • 有两个区域分别是堆(Heap)和栈( Stack), 这两个VMA几乎在所有的进程中存在

    • C语言程序里面最常用的 malloc()内存分配函数就是从里面分配的,堆由系统库管理

    • 一般般也叫做堆栈,我们知道每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个

      VMA堆栈就全都归它使用

    • 特殊VIMA叫做vdso, 它的地址己经位于内核空间了(即大于0xC00000的地址), 事实上它是一个内核的模块,进程可以通过访问这个MA来跟内核进行一些通信

  • 操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间

  • 一个进程基本上可以分为如下几种ⅤMA区域

    1. 代码VMA,权限只读、可执行;;有映像文件。
    2. 数据VMA,权限可读写、可执行;有映像文件。
    3. 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
    4. 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展
  • 进程虚拟空间的图示

    1. VMA2的结束地址,计算出应该是0x080bc00,但实际上是0x080bb000。

    2. Linux规定一个VMA可以映射到某个文件的一个区域,或者是没有映射到任何文件

    3. VMA2从“ tdata”段到“data”段部分要建立从虚拟空间到文件的映射, 而“.bss”和“_ libcfreeres_ptrs”部分没有映射到文件。

6.4.3 堆的最大申请数量

  • 通过下面程序测试malloc()能够申请多大内存

    • 在Linux(可分配的空间为3G)机器上运行结果大约在2.9G左右, windows(可分配的空间为2G)下运行结果大约是1.5个G
    • 具体的数值会受到操作系统版本、程序本身大小、用到的动态/共享库数量、大小、程序栈数量、大小等影响
    • 甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小

6.4.4 段地址对齐

  • 对于Inter 80x86处理器来说,默认页大小为4096。所以映射时内存空间长度必须是页的整数倍

  • 段对齐讨论

    1. 假设我们有一个ELF可执行文件,它有三个段( Segment)需要装载,我们将它们命名为SEG0、SEG1和SEG2。

    2. 每个段分开映射,对于长度不足个页的部分则占一个页。通常ELF可执行文件的起始虚拟地址为0x08048000

      这种对齐方式在文件段的内部会有很多内部碎片,浪费磁盘空间

    3. 为了解决这种问题,有些UNIX系统采用了一个很取巧的办法,就是让那些各个段接壤

      部分共享一个物理页面,然后将该物理页面分别映射两次

      SEG1的接壤部分的那个物理页,系统将它们映射两份到虚拟地址空间,一份为SEGO,另外一份为SEGI,其他的页都按照正常的页粒度进行映射.

    4. 在这种情况下,内存空间得到了充分的利用,本来要用到5个物理页面,也就是20480字节的内存,现在只有3个页面,即12288字节。

    5. 这种映射方式下,对于一个物理页面来说,它可能同时包含了两个段的数据,甚至可能是多于两个段

    6. 因为段地址对齐的关系,各个段的虚拟地址就往往不是系统页面长度的整数倍了

6.4.5 进程栈初始化

  • 操作系统在进程启动前将进程运行环境相关的信息提前保存到进程的虚拟空间的栈中(也就是VMA中的 Stack VMA)
  • Linux的进程初始化后栈的结构

    1. 假设系统中有两个环境变量

      1
      2
      HOME=/home/user
      PATH=/usr/bin
    2. 运行命令行 $ prog 123

    3. 假设堆栈段底部地址为0xBF802000,那么进程初始化后的堆栈就如图

      栈顶寄存器esp指向的位置是初始化以后堆栈的顶部,最前面的4个字节表示命令行参数的数量,我们的例子里面是两个,即“prog”和“123”,紧接的就是分布指向这两个参数字符串的指针:后面跟了一个0;接着是两个指向环境变量字符串的指针,它们分别指向字符串“HOME=/home/user”和“PATH=/usr/bin”;后面紧跟一个0表示结束。

    4. 进程在启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给 main()函数的argc(命令行参数数量) 和 argv(命令行参数字符串指针数组)两个参数。

6.5 Linux 内核装载ELF过程

在bash执行一个命令执行ELF程序时,ELF文件装载过程

  • 用户层面,bash进程会调用fork()系统调用创建一个新的进程

  • 然后新的进程调用 execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令

    • execveo系统调用被定义在 unistd.h,它的原型如下
    • int execve(const char *filename, char *const argv[], char *const envp[]);
    • 它的三个参数分别是被执行的程序文件名、执行参数和环境变量
    • Glibc对 execvpo系统调用进行了包装,提供了 execl、 execl、 execl、 execvo和 execvp0等5个不同形式的exec系列API,它们只是在调用的参数形式上有所区别,但最终都会调用到 execve()这个系统中
  • 在进入 execve()系统调用之后, Linux内核就开始进行真正的装载工作

  • 在内核中execve()系统调用相应的入口是 sys_execve(), sys_execve()进行一些参数的检查复制之后,调用do_execve()

  • do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节来判断文件的格式

    1. 每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数( Magic Number),通过对魔数的判断可以确定文件的格式和类型
    2. ELF的可执行文件格式的头4个字节为0x7F、’e’、‘l’、’f’;
    3. 而Java的可执行文件格式的头4个字节为’c’、’a’、’f’、’e’
  • 然后调用 search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程,search_binary_handle会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程

  • ELF可执行文件的装载处理过程叫做 load_elf_binary(),其主要步骤为:

    1. 检査ELF可执行文件格式的有效性,比如魔数、程序头表中段( Segment)的数量
    2. 寻找动态链接的“ .interp”段,设置动态链接器路径(与动态链接有关,具体请参考第9章)
    3. 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据
    4. 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址(参照动态链接)
    5. 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_eny所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器
  • 当 load_elf_binary执行完毕,返回至 do_execve()再返回至 sys_execve()时, 上面的第5步已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了

  • 所以当 sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。

总结

  • 虚拟地址空间
    • 程序被运行起来,系统为它创建一个进程,它将拥有自己独立的虚拟地址空间( Virtual Address Space)
    • 进程只能使用一部分虚拟地址空间 ,因为一部分是给操作系统和其他用途的
  • 程序的局部性原理
    • 利用程序的局部性原理,所以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就引入了动态载入。
  • 动态载入的原理
    • 程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中
  • 装载方式: 页映射
  • 虚拟地址空间
    • 实际上是⼀组⻚映射关系函数
    • ⻚映射关系函数是虚拟空间到物理内存的映射关系。
  • 进程的建立
    • 创建⼀个独立的虚拟地址空间
    • 读取可执⾏⽂件头,并且建⽴虚拟空间与可执⾏⽂件的映射关系
    • 将CPU的指令寄存器设置成可执⾏⽂件的⼊⼝地址,启动运⾏
  • 页错误:不断的通过页错误将可执行程序装入到内存中

    • 进程建立后,其实可执行文件的真正指令和数据都没有被装入到内存中
    • 操作系统只是通过可执⾏⽂件头部的信息建⽴起可执⾏⽂件和进程虚存之间的映射关系⽽已
    • 当CPU开始打算执⾏⼊⼝地址的指令时,发现该⻚⾯是个空⻚⾯,于是发
      ⽣⻚错误。CPU将控制权交给操作系统。
    • 操作系统通过查询虚拟空间与可执⾏⽂件之间的映射关系,在磁盘中找到该⻚⾯,将该页面载入到内存中,在物理内存中分配⼀个物理⻚⾯,将进程中该虚拟⻚与分配的物理⻚之间建⽴映射关系
    • 然后再把控制权还给进程,进程共刚才⻚错误的位置重新开始执⾏。
    • 随着进程执⾏, ⻚错误也会不断地产⽣,当所需要的内存会超过可⽤的内存时,操作系统进⾏虚
      拟内存的管理
  • 进程虚拟空间分布

    • 当段的数量增多时,如果按照每个段都映射为系统⻚的整数倍,会产⽣空间浪费的问题。因此,对于相同权限的段,把它们合并到⼀起当作⼀个段进⾏映射
    • 进程ⅤMA权限划分
      • 代码VMA,权限只读、可执⾏;;有映像⽂件
      • 数据VMA,权限可读写、可执⾏;有映像⽂件
      • 堆VMA,权限可读写、可执⾏;⽆映像⽂件,匿名,可向上扩展。
      • 栈VMA,权限可读写、不可执⾏;⽆映像⽂件,匿名,可向下扩展
    • 虚拟空间分布图示

动态链接

7.1 为什么要动态链接

  • 内存和磁盘空间

    • 如果只使用静态链接,静态连接的方式对于计算机内存和磁盘的空间浪费非常严重
    • 每个程序内部除了都保留着 printf()函数、 scanf()函数、 strlen()等这样的公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构。
    • 这些重复的库和结构造成了内存和磁盘空间的严重浪费
  • 程序开发和发布

    • 静态链接对程序的更新、部署和发布也会带来很多麻烦
    • 当一个程序中使用了某个库,当这个库更新时。如果使用的是静态链接,那么需要将最新的库,重新静态链接生成可执行文件,然后用户只能更新整个应用。
  • 动态链接

    • 要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起
    • 不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接
    • 假设Program1.o, Program12.o都依赖Lib.o,那么动态链接过程:

      1. 要运行 Program1这个程序时,系统首先加载 Program1.o
      2. 当系统发现Program1.o中用到了Lib.o,即 Program1.o依赖于Lib.o,那么系统接着加载Lib.o,然后重复该过程直到所有的依赖都加入进来
      3. 然后系统开始进行链接工作, 原理与静态链接非常相似,包括符号解析、地址重定位等
      4. 链接完成后,系统开始把控制权交给 Program1.o的程序入口处,程序开始运行
      5. 此后如果我们需要运行 Program2,则只需要加载 Program2.o,而不需要重新加载Lib.o
      6. 因为内存中已经存在了一份Lib.o的副本,系统要做的只是将 Program2.o和Lib.o链接起来
    • 动态链接解决了共享的目标文件多个副本浪费磁盘和内存空间的问题,可以看到,磁盘和内存中只存在一份Lib.o,而不是两份。

    • 在内存中共享一个目标文件模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上

    • 使程序的升级变得更加容易,当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序就完成了升级的目标。

    • 动态链接的方式使得开发过程中各个模块更加独立,耦合度更小

  • 程序可扩展性和兼容性

    1. 动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件( Plug-in)
    2. 动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库。
  • 动态链接的基本实现

    1. 在 Linux系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,是以“.so”为扩展名的文件
    2. 在 Windows系统中,动态链接文件被称为动态链接库( Dynamical Linking Library),是以“.dll”为扩展名的文件
    3. 在 Linux中,常用的C语言库的运行库glibc,它的动态链接形式的版本保存在“/lib”, 文件名叫做“libc.so”
    4. 整个系统只保留一份C语言库的动态链接文件“libc.so”,而所有的C语言编写的、动态链接的程序都可以在运行时使用它
    5. 当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作
    6. 程序与libc.so之间真正的链接工作是由动态链接器完成的
    7. 动态链接会导致程序在性能的一些损失,但是对动态链接的链接过程可以进行优化,延迟绑定( Lazy Binding)等方法
    8. 动态链接与静态链接相比,性能损失大约在5%以下, 这点性能损失用来换取程序在空间上的节省和程序构建和升级时的灵活性,是相当值得的

7.2 简单的动态链接例子

  1. 以Program1和Program2进行演示。 相关源代码如下

  2. $ gcc -fPIC -shared -o Lib.so Lib.c得到Lib.so

  3. 分别编译链接Program1.c和Program2.c

    1
    2
    $ gcc -o Program1 Program1.C  ./Lib.so
    $ gcc -o Program2 Program2.C ./Lib.so

    其编译和链接过程如下图

    1. 如果是静态链接, Program1.o被连接成可执行文件的过程中,Program1.o会和Lib.o连接在一起,生成可执行文件Program1
    2. 这里是动态链接,Lib.o没有链接进来, 链接的输入目标文件只有Program1.o
    3. 如果 foobar是一个定义与其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将 Program1.o中的 foobar地址引用重定位
    4. 如果 foobar是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行
    5. Lib.so中保存了完整的符号信息(因为运行时进行动态链接还须使用符号信息),把Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道: foobar是一个定义在Lib.so的动态符号。这样链接器就可以对 foobar的引用做特殊的处理,使它成为一个对动态符号的引用

7.2.1 动态链接程序运行时地址空间分布

  1. 对Lib.c的foobar()进行修改,使运行Program1时不立马结束

    1
    2
    3
    4
    5
    #include <stdio.h>
    void foobar(int i) {
    print("printing from Lib.so %d\n", i);
    sleep(-1);
    }
  2. 查看进程的虚拟地址空间分布

    1. Lib.so, Program1,libc-2.6.1.so,ld-2.6.so被映射至进程的虚拟地址空间
    2. libc-2.6.1.so是c语言运行库
    3. ld-2.6.so是Linux下的动态链接器
    4. 系统开始运行Program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给 Program1,然后开始执行。
  3. 通过$ readelf -l Lib.so查看Lib.so的装载属性

    • 动态链接模块的装载地址是从地址0x000000开始的,这个地址是无效地
    • 共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象

7.3 地址无关代码

7.3.1 固定装载地址的困扰

  • 共享对象在编译时不能假设自己在进程虚拟地址空间中的位置
  • 与此不同的是,可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,比如 Linux下般都是0x08040000, Windows下一般都是0x0040000

7.3.2 装载时重定位

  • 为了能够使共享对象在任意地址装载,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成
  • 模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位
  • 静态链接的重定位叫做链接时重定位(Link Time Relocation),此时对绝对地址的重定位处理叫装载时重定位(Load Time Relocation)

7.3.3 地址无关代码

  • 装载时重定位是解决动态模块中有绝对地址引用的办法之一
  • 但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势
  • 解决方案是: 把共享模块指令部分那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术

下面说的都是虚拟地址:

动态库数据部分为什么要在每个进程中有个副本(要分配真实的物理地址空间)?假如动态库的数据部分,有全局变量,当某个进行修改这个全局变量时,不应该对其他的进程造成影响。对于动态库中,假如引用外部的变量和函数,此时在其内部是绝对地址。为啥要把这部分指令也在使用该动态库的进程中放置一份(要分配真实的物理空间)?在多个进程中,某个动态库引用的外部的变量和函数的地址,不是固定的。例如该动态库访问的其他动态库中的外部变量的地址就不能固定。

  • 共享对象模块中的地址引用类型:

    • 按照是否为跨模块分成两类:模块内部引用和模块外部引用
    • 按照不同的引用方式又可以分为指令引用和数据访问,
    1. 第一种是模块内部的函数调用、跳转等(相对地址)
    2. 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量(相对地址)
    3. 第三种是模块外部的函数调用、跳转等(绝对地址)
    4. 第四种是模块外部的数据访问,比如其他模块中定义的全局变量(绝对地址)

模块内部和模块外部的定义

  1. 在上面的文件被编译时,不能确定b和ext()是模块外部还是模块内部
  2. 因为它们有可能被定义在同一个共享对象的其他目标文件中
  3. 由于没法确定,所以编译器只能把它们当做模块外部的函数和变量处理
  4. MSVC编译器提供了__declspec(dllimport)编译器扩展来表示一个符号是符号内部的还是模块内部的

总结:

  1. 模块内部整个共享模块的内部
  2. 模块外部整个共享模块的外部
  • 类型一 : 模块内部调用或跳转

    1. 即为被调用的函数或变量处于同一个模块,它们之间的相对位置固定
    2. 模块内部的跳转,函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以这种指令是不需要重定位的。
    3. 例如foo函数调用bar函数

    4. foo对bar的调用实际上是一条相对地址调用指令

  • 类型二 模块内部数据访问

    1. 指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址
    2. 一个模块前面一般是若干个页的代码后面紧跟着若干个页的数据, 这些页之间的相对位置是固定的
    3. 任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了
    4. 现代的体系结构中,数据的相对寻址往往没有相对与当前指令地址(PC)的寻址方式
    5. ELF先找到PC的值,然后在加上一个偏移量就可以访问到相应的变量了
    6. ELF获取PC值的常用方法

  • 类型三 模块间数据访问

    1. 模块间的数据访问目标地址要等到装载时才决定
    2. 这些其他模块的全局变量的地址是跟模块装载地址有关的
    3. ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global offset Table,GOT)
    4. 当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用
    5. 通过GOT机制引用其他模块的全局变量

      1. 每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确
      2. 当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址
      3. GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响
      4. 从第二中类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对与当前指令的偏移,那么同样可以确定GOT的位置
      5. 而GOT中的每个地址对应哪个变量是编译器决定的,所以我们找到GOT后就可以找到对应的变量的地址
  • 类型四: 模块间调用,跳转

    1. 与类型三的方法一样,也是采用GOT来找到函数。
    2. GOT中保存的是目标函数的地址。
    3. 通过GOT机制引用其他模块的函数

  • 地址无关代码小结

  • gcc一般使用-fPIC产生地址无关代码,-fpic也可以产生地址无关代码,而且代码更少,速度更快。但地址无关代码跟硬件平台相关, -fpic在某些硬件平台上会有一些限制。

  • 地址无关代码技术除了可以用在共享对象上面,也可以用于执行文件。这样的执行文件叫做地址无关可执行文件(PIE,Position-Independent Executable )

7.3.4 共享模块的全局变量问题

  • ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过GOT来实现变量的访问

7.3.5 数据段地址无关性

  • 对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了“R_386_RELATIVE”类型的重定位入口
  • 当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位

7.4 延迟绑定

  • 动态链接比静态链接慢的主要原因
    1. 动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址
    2. 对于模块间的调用也要先定位GOT,然后再进行间接跳转
  • 动态链接减慢程序的启动速度

    1. 程序开始执行时,动态链接器都要进行一次链接工作
    2. ,动态链接器会寻找并装载所需要的共享对象,然后进行符号査找地址重定位等工作
  • 延迟绑定实现

    1. 在程序运行过程中,可能很多函数在程序执行完时都不会被用到
    2. 如果一开始把所有函数都链接好实际上是一种浪费。所以ELF采用了一种叫延迟绑定(Lazy Binding)的做法
    3. 延迟绑定(Lazy Binding)基本思想:当函数第一次被用到时才进行绑定(符号查找,重定位等),如果没有用到则不进行绑定
    4. 程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定
    5. ELF使用PLT(Procedure Linkage Table)机制来实现延迟绑定, 这种机制使用了很精巧的指令序列。
  • PLT的基本原理:假设liba.so需要调用libc.so的bar()函数

    1. PLT并不直接通过GOT跳转来实现函数绑定,而是通过PLT项的结构进行跳转。而每个外部函数在PLT中都有一个相应的项。比如bar()函数对应的项

    2. bar@plt的第一条指令是一条通过GOT间接跳转的指令, bar@GOT表示GOT中保存bar()这个函数相应的项

    3. 为了实现延迟绑定,链接器在初始化阶段并没有将bar()的地址填入到该项
    4. 而是将上面代码中第二条指令“ push n”的地址填入到bar@GOT中
    5. 第二条指令将一个数字n压入堆栈中,这个数字是bar这个符号引用在重定位表“ rel.plt”中的下标
    6. 第三条指令将模块的ID压入到堆栈
    7. 第四条跳转到动态链接器的_dl_ runtime_resolve()函数,来完成符号解析和重定位工作
    8. _dl_ runtime_resolve()函数进行一系列工作之后将bar()真正的地址填入到bar@GOT中。
    9. 一旦解析完毕后,再次调用第一条指令直接就能跳转到真正的bar()函数中,不需要再往下执行了。
  • ELF中将GOT分成两个表分别叫做.got.got.plt

    1. .got用来保存全局变量引用的地址
    2. .got.plt用来保存外部函数引用的地址
    3. .got.plt的前三项是有特殊意义的
      • 第一项保存的是.dynamic段的地址,这个段描述了本模块动态链接相关的信息
      • 第二项保存的是本模块的ID
      • 第三项保存的是 _dl_runtime_resolve的地址
    4. .got.plt的第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化
    5. .got.plt的其余项对应每个外部函数的引用
  • GOT中的PLT数据结构

  • PLT在ELF文件中以独立的段存放,段名通常叫做“.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的“ Segment”被装载入内存

7.5 动态链接相关结构

  • 首先操作系统会读取可执行文件的头部检查文件的合法性,然后从头部中的“ ProgramHeader”中读取每个“ Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置
  • 静态链接情况下,操作系统接着就可以把控制权转交给可执行文件的入口地址,然后程序开始执行

  • 动态链接情况下,操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件,还需要链接共享对象

  • 在映射完可执行文件之后,操作系统会先启动个动态链接器( Dynamic Linker)
  • 在 Linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间
  • 操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)
  • 动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作
  • 当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行

7.5.1 “.interp”段

  • 动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由ELF可执行文件决定
  • 动态链接的ELF可执行文件中,有一个专门的段叫做.interp(interpreter解释器)段,里面存储了动态链接器的路径。
  • 查看.interp的内容

  • 在 Linux的系统中, lib/d-linux.so.2通常是一个软链接,比如在我的机器上,它指向/lib/ld-2.6.1.so,这个才是真正的动态链接器。

  • Linux中,操作系统在对可执行文件的进行加载的时候,会先查询.interp段保存的路径,然后去加载动态链接器。

7.5.2 “.dynamic”段

.dynamic是动态链接最重要的段。保存了动态链接所需要的基本信息

  1. 依赖于哪些共享对象
  2. 动态链接符号表的位置
  3. 动态链接重定位表的位置
  4. 共享对象初始化代码的地址等
  • .dynamic是Elf32_Dyn为元素的数组, Elf32_Dyn由一个类型值,和一个附加的数值或指针组成

    1
    2
    3
    4
    5
    6
    7
    typedef struct {
    Elf32_Sword d_tag;
    union {
    Elf32_Word d_val;
    Elf32_Addr d_ptr;
    } d_un;
    } Elf32_Dyn;
  • 对应字段的含义

  • 使用readelf工具查看.dynamic段的内容

  • 查看主模块或共享库依赖了哪些模块

7.5.3 动态符号表

Program1程序依赖于Lib.so的 foobar()函数。foobar()是Program1的导入函数(Import Function),foobar()是Lib.so的导出函数(Export Function)。ELF专门有一个叫做动态符号表( Dynamic Symbol Table)的段用来保存这些导入导出关系,段名叫.dynsym(Dynamic Symbol)。与.symtab不同的是,.dynsym只保存了与动态链接相关的符号, 对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有.dynsym. symtab两个表.symtab中往往保存了所有符号,包括.dynsym中的符号。与.symtab类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表.strtab( String Table),在这里就是动态符号字符串表.dynstr( Dynamic String Table)。动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(".hash")。用readelf工具查看ELF文件的动态符号表及它的哈希表

7.5.4 动态链接重定位表

对于使用了PIC技术的可执行文件或共享对象,代码段是不需要重定位。但是数据段需要重定位。静态链接中, .rel.text表示代码段的重定位。 .rel.data是数据段的重定位表。动态链接中, .rel.dyn是对数据引用的修正,它修正的位置位于.got以及数据段。rel.plt是对函数引用的修正,它修正的位置位于.got.plt

  • 查看动态链接文件的重定位表

    1. 上面可以看到R_386_RELATIVE, R_386_GLOB_DAT和R_386_JUMP_SLOT这三种重定位入口类型
    2. R_386_GLOB_DAT和R_386_JUMP_SLOT表示被修正的位置只需要直接填入符号的地址即可。以printf为例, printf的偏移为0x00005d8。 当动态链接器需要进行重定位时,它会查找printf的地址。假设链接器在全局符号表里面找到“ printf”的地址为0x08801234。那么链接器就会将这个地址填入到“got.plt”中的偏移为0x00005d8的位置中去,从而实现了地址的重定位。
    3. .got.plt的前三项被系统占据,从第四项开始存放导入函数的地址。
    4. R386_RELATIVE类型的重定位入口,这种类型的重定位实际上就是基址重置( Rebasing)
    5. another something xxxx

7.5.5 动态链接时进程堆栈初始化信息

  • 进程初始化的时候,堆栈里面保存了关于进程执行环境和命令行参数等信息
  • 堆栈里面还保存了动态链接器所需要的一些辅助信息数组( Auxiliary Vector)
  • 辅助信息的格式是一个结构数组,它的结构被定义在”elf.h”

    1
    2
    3
    4
    5
    6
    7
    8
    typedef struct
    {
    uint_32_t a_type;
    union
    {
    uint_32_t a_val;
    } a_un;
    } Elf32_auxv_t
    1. 事实上这个unon没什么用,只是历史遗留而已,可以当作不存在

    2. 字段的含义

7.6 动态链接的步骤和实现

  • 基本分为3步
    1. 启动动态连接器本身
    2. 装载所有需要的共享对象
    3. 重定位和初始化

7.6.1 动态链接器自举(bootstrap->引导程序)

  • 对于普通共享对象文件来说,它的重定位工作由动态链接器来完成;它也可以依赖于其他共亨对象,其中的被依赖的共享对象由动态链接器负责链接和装载
  • 动态链接器本身也是一个共享对象, 它不可以依赖于其他任何共享对象。 其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成
  • 动态链接器必须在启动时有段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举( Bootstrap)。
  • 动态链接器入口地址即是自举代码的入口,当操作系统将进程控制杈交给动态链接器时,动态链接器的自举代码即开始执行.
    1. 自举代码首先会找到它自己的GOT
    2. GOT的第一个入口保存的即是“ .dynamic”段的偏移地址,由此找到了动态连接器本身的”.dynamic”段。
    3. 通过“.dynamic”中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。
    4. 从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量
  • 在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。
    1. 使用PIC模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT的方式
    2. 在GOT/PLT没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数

7.6.2 装载共享对象

  • 完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表( Global Symbol Table)
  • 然后通过可执行文件中的.dynamic查看可执行文件依赖的共享库。
  • 将所有依赖的所有共享对象名字装载到一个集合中。
  • 然后开始遍历去装载共享对象,当装载共享对象的时候,查看ELF文件头和.dynamic段, 然后将它相应的代码段和数据段映射到地址空间。查看要装载共享对象的.dynamic看其是否还依赖其他共享对象。如果依赖,则把依赖的共享对象的名字继续放在集合中。如此循环直到所有的共享对象都装载到集合中。
  • 一般将这种依赖关系当做一个图,动态链接器一般采用广度优先遍历进行装载
  • 当一个新的共享对象被装载时,它的符号表会被合并到全局符号表中。所以装载完之后,全局符号表里面包含进程中所有的动态链接所需的符号。

  • 当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,那么后面加入的符号将被忽略。

    1
    2
    3
    4
    5
    6
    a1.so定义了一个a函数
    a2.so定义了一个a函数(两个a函数实现不同)
    b1.so的b1函数调用了a1.so的a函数
    b2.so的b2函数调用了a2.so的a函数

    当在main函数中,调用b1(), b2()时,全部执行的是a1.so中定义的a函数

7.6.3 重定位和初始化

重定位:上面步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将他们的GOT/PLT中的每个需要重定位的位置进行修正。

初始化:

  1. 如果某个共享对象有.init段,那么动态链接器会执行.init段中代码,实现共享对象的初始化
  2. 进程退出的时候对执行共享对象中的.finit段中的代码
  3. 动态链接器不执行可执行文件中的.init段中代码。
  4. 可执行文件中的.init.finit由程序初始化部分代码负责执行
  • 转移控制权:完成了共享对象的重定位和初始化之后,动态链接器将控制权转移给程序的入口。

7.6.4 Linux动态链接器实现

  • Linux动态链接器本身是一个共享对象,它的路径是/lib/ld-linux.so.2,这实际上是个软链接,它指向/lib/d-x.y.z.so,这个才是真正的动态连接器文件。

  • 动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序

  • 动态链接器本身是动态链接的还是静态链接的?

动态链接器本身应该是静态链接的,它不能依赖于其他共享对象,动态链接器本身是用来帮助其他ELF文件解决共享对象依赖问题的,如果它也依赖于其他共享对象,那么谁来帮它解决依赖问题?所以它本身必须不依赖于其他共享对象。

这一点可以使用ldd来判断

1
2
$ ldd /lib/ld-linux.so.2
statically linked

  • 动态链接器本身必须是PIC的吗?

是不是PIC对于动态链接器来说并不关键,动态链接器可以是PIC的也可以不是,但往往使用PIC会更加简单一些。一方面,如果不是PIC的话,会使得代码段无法共享,浪费内存;另一方面也会使ld.o本身初始化更加复杂,因为自举时还需要对代码段进行重定位。

实际上的 Id-linux.so.2是PIC的

  • 动态链接器可以被当作可执行文件运行,那么的装载地址应该是多少?

    ld.so的装载地址跟一般的共享对象没区别,即为0x00000这个装载地址是一个无效的装载地址,作为一个共亨库,内核在装载它时会为其选择一个合适的装载地址

7.7 显式运行时链接

  • 显式运行时链接( Explicit Run- time Linking),有时候也叫做运行时加载 ,让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。
  • 动态装载库( Dynamic Loading Library): 动态链接器可以在运行时将共享模块装载进内存并且可以进行重定位等操作。
  • 动态装载库实质上就是共享对象,只是开发者使用的角度不同
  • 两者的区别在于

    • 共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态连接器自动完成,对于程序本身是透明的
    • 动态库的装载则是通过一系列由动态链接器提供的API,共有4个函数
      1. 打开动态库( dlopen)
      2. 查找符号( dlsym)
      3. 错误处理( dlerror)
      4. 关闭动态库( dlclose)
  • 运行时加载可以用来实现一些诸如插件、驱动等功能,当程序需要用到某个插件或者驱动的时候,才将相应的模块装载进来,而不需要从程序一启动就加载

7.7.1 dlopen()

  • dlopen()函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程
  • 其c原型定义

    1
    void * dlopen(const char *filename, int flag )
  • 第一个参数是被加载动态库的路径
    • 如果这个路径是绝对路径(以“/”开始的路径),则该函数将会尝试直接打开该动态库
    • 如果是相对路径,那么 dlopen会尝试在以一定的顺序去查找该动态库文件
      • 查找有环境变量 LD_LIBRARY_PATH指定的一系列目录
      • 查找由/etc/ld.so.cache里面所指定的共享库路径
      • /lib、/usr/lib 注意:这个查找顺序与旧的aout装载器的顺序刚好相反,旧的aout的装载器在装载共享库的时候会先查找/usr/lib,然后是/lib
    • 如果我们将 filename这个参数设置为0,那么 dlopen返回的将是全局符号表的句柄,并且可以执行它们

全局符号表包括了

  1. 程序的可执行文件本身
  2. 被动态链接器加载到进程中的所有共享模块
  3. 在运行时通过 dlopen打开并且使用了 RTLD_GLOBAL方式的模块中的符号
  • 第二个参数fag表示函数符号的解析方式

    • RTLD_LAZY表示使用延迟绑定,当函数第一次被用到时才进行绑定,即PLT机制
    • RTLD_NOW表示当模块被加载时即完成所有的函数绑定工作,如果有任何未定义的符号引用的绑定工作没法完成,那么 dlopen返回错误
    • RTLD_GLOBAL跟上面的两者中任意一个一起使用(通过“或”操作),它表示将被加载的模块的全局符号合并到进程的全局符号表中,使得以后加载的模块可以使用这些符号
    • 调试程序的时候我们可以使用 RTLD_NOW作为加载参数,因为如果模块加载时有任何符号未被绑定的话,我们可以使用 dlerroro立即捕获到相应的错误信息
    • 如果使用 RTLD_LAZY的话,这种符号未绑定的错误会在加载后发生,则难以捕获
    • 当然使用 RTLD_NOW会导致加载动态库的速度变慢
  • dlopen的返回值是被加载的模块的句柄

    • 这个句柄在后面使用dsym或者 dlclose时需要用到。
    • 如果加载模块失败,则返回NUL。
    • 如果模块已经通过 dlopen被加载过了,那么返回的是同一个句柄
    • 如果被加载的模块之间有依赖关系,那么需要先手动加载被依赖的模块。比如模块A依赖与模块B,那么程序员需要手工加载被依赖的模块,比如先加载B,再加载A
  • dlopen的加载过程基本跟动态链接器一致,在完成装载、映射和重定位以后,就会执行“.init”段的

7.7.2 dlsym()

dlsym函数基本上是运行时装载的核心部分,我们可以通过这个函数找到所需要的符号。它的定义如下

1
void * dlsym(void *handle, char *symbol)

  • 第一个参数是由 dlopen返回的动态库的句柄
  • 第二个参数即所要查找的符号的名字,一个以“\0”结尾的C字符串
  • dlsym返回的值对于不同类型的符号,意义是不同的
    • 如果查找的符号是个函数,如果找到返回函数的地址,否则返回NULL
    • 如果查找的符号是个变量,如果找到返回变量的地址,否则返回NULL
    • 如果查找的符号是个常量,如果找到返回的是该常量的值, 否则返回NULL

如果常量的值刚好是NULL或者0呢,我们如何判断dlsym是否找到了该符号呢?

  • 如果符号找到了,dlerror返回为NULL
  • 如果符号没有找到,dlerror返回为错误信息

注意

  1. 符号不仅仅是函数和变量,有时还是常量,比如表示编译单元文件名的符号等,这一般由编译器和链接器产生,而且对外不可见, 但它们的确存在于模块的符号表中
  2. dlsym是可以查找到这些符号的
  3. 我们也可以通过”objdump -t”来查看符号表,常量在符号表里面的类型是*ABS*
  • 当多个共享模块的符号名冲突时,先装入的符号优先,我们把这种优先级方式称为装载序列(LoadOrdering)
  • 不论是动态链接器装载共享对象,还是dlopen装载动态库,它们进行符号的解析和重定位时都是采用的是装载序列
  • dlsym对符号的查找优先级分两种类

    1. 第一种情况: 如果我们是在全局符号表中进行符号查找,即 dlopen()时,参数 filename为NULL,那么由于全局符号表使用的装载序列,所以 dlsym使用的也是装载序列
    2. 第二种情况是如果我们是对某个通过 dlopen打开的共享对象进行符号查找的话,那么采用的是一种叫做依赖序列( Dependency Ordering)的优先级

      依赖序列( Dependency Ordering)是以被dlopen打开的那个共享对象为根节点,对它所有依赖的共享对象进行广度优先遍历,直到找到符号为止

7.7.3 dlerror()

每次我们调用 dlopen、 dlsym或 dlclose以后,我们都可以调用 dlerror函数来判断上一次调用是否成功。dlerror的返回值类型是char*,如果返回NUL,则表示上一次调用成功;如果不是,则返回相应的错误消息

7.7.4 dlclose()

dlclose的作用跟 dlopen刚好相反,它的作用是将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用dlopen加载某模块时,相应的计数器加一;每次使用dlcloseo卸载某模块时,相应计数器减一。 只有当计数器值减到0时,模块才被真正地卸载掉。

卸载的过程跟加载刚好相反,先执行. finit段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件

下面的例子将数学库模块用运行时的方法加载到内存中,然后获取sin()符号地址,使用sin()

-ldl 表示使用了DL库(Dynamical Loading),它位于/lib/libdl.so.2

总结

  • 为什么要动态链接?
    • 如果使用静态链接,那些公有的函数在每个程序中都存在一份,极大的浪费空间
    • 使用动态链接,那些公有的库在内存和磁盘上只有一份,极大节省了空间
  • 如果某个模块(可执行文件或动态库)使用了某个动态库中的函数或变量?

    • 链接器进行链接时,发现某个模块引用的外部符号(函数或变量),被定义在某个动态库的符号表中,则把这个外部符号标记为动态链接的符号,在此时不对该符号进行重定位。而将重定位的过程放到装载阶段去完成。
  • 装载时重定位

    • 当动态库被装载时,该动态库的地址确定,此时其定义的符号和函数的地址也确定了,因此可以对引用该动态库符号的模块进行重定位了
  • 地址无关代码(PIC)
    • 把共享模块指令部分那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本
  • 延迟绑定
    • 当函数第一次被用到时才进行绑定(符号查找,重定位等),如果没有用到则不进行绑定
  • 动态符号表

    • .dynsym(Dynamic Symbol)
    • 只保存了与动态链接相关的符号
  • 动态链接重定位表

    • 对于使⽤了PIC技术的可执⾏⽂件或共享对象,代码段是不需要重定位。但是数据段需要重定位
  • 动态链接加载的过程

    1. 动态连接器本身
      1. 操作系统先加载可执行文件
      2. 然后在映射完可执行文件之后,操作系统启动动态链接器( Dynamic Linker)
      3. 动态链接器( Dynamic Linker)通过自举的方式加载自身。
      4. 完成基本⾃举以后,动态链接器将可执⾏⽂件和链接器本身的符号表都合并到⼀个符号表当中,我们
        可以称它为全局符号表( Global Symbol Table)。 同时把控制权交给动态链接器
    2. 装载所有需要的共享对象
      1. 然后通过可执⾏⽂件中的 .dynamic 查看可执⾏⽂件依赖的共享库
      2. 遍历加载共享库,同时查看要装载共享对象的.dynamic看其是否还依赖其他
        共享对象, 直到所有的共享库都被加载(采⽤⼴度优先遍历进⾏装载)
      3. 当⼀个新的共享对象被装载时,它的符号表会被合并到全局符号表中。所以装载完之后,全局符号
        表⾥⾯包含进程中所有的动态链接所需的符号。
    3. 重定位
      1. 链接器开始重新遍历可执⾏⽂件和每个共享对象的重定位表,进行地址的修正。
      2. 然后将控制权转交给程序入口
  • 显式运⾏时链接

    • 程序⾃⼰在运⾏时控制加载指定的模块(动态库),并且可以在不需要该模块时将其卸载
    • 与动态链接器加载动态库的区别是:
      1. 动态链接器是在程序真正开始执行前,自动进行动态库的装载和链接
      2. 程序运行时加载是指,程序通过一些API来装载,链接,使用动态库
        • 打开动态库( dlopen)
        • 查找符号( dlsym)
        • 错误处理( dlerror)
        • 关闭动态库( dlclose)

第8章 Linux共享库的组织

从文件的结构上来讲,共享库和共享对象没什么区别

8.1 共享库兼容性

  • 共享库的更新分为:
    • 1 兼容更新;
    • 2 不兼容更新

导致C语言的共享库ABI(接口)改变的行为有:

  1. 导出函数的行为发生改变,也就是调用该函数产生的结果和以前不一致;
  2. 导出函数被删除;
  3. 导出数据的结构发生变化;
  4. 导出函数的接口发生变化;

解决共享库的兼容性问题的有效办法之一是使用共享库版本的方法。文件名规则:libname.so.x.y.z

  • x:主版本号。库的重大升级,不同主版本号之间不兼容
  • y:次版本号。库的增量升级,主版本号相同,高的次版本号的库向后兼容低的次版本号
  • z:发布版本号。错误修正,性能改进,相同主版本号、次版本号的共享库,不同的发布版本号之间相互完全兼容

采用SO-NAME的命名机制来记录共享库的依赖关系。每个共享库都有对应的“SO-NAME“,即共享库的文件名去掉次版本号和发布版本号,保留主版本号。在Linux系统中,系统会为每个共享库在它所在的目录创建一个跟”SO-NAME“相同的并且指向它的软链接,使得所有依赖某个共享库的模块,在编译时都使用共享库的SO-NAME,而不依赖具体版本号。SO-NAME表示一个库的接口,接口不向后兼容,SO-NAME就发生变化。如果需要一个libXXX.so.2.6.1的共享库,只需要加上-lXXX,这个XXX叫做链接名,不同类型的库可能会有同样的链接名。

8.2 符号版本

  • 次版本交会问题:低次版本号不兼容高次版本号,高次版本号兼容低次版本号 —- 解决方式:符号版本机制
  • 基于符号的版本机制方案的基本思路:让每个导入和导出的符号都有一个相关联的版本号,它的实际做法类似于名称修饰的方法
  • 链接器在链接时根据符号版本脚本中指定的关系来产生共享库,并且设置符号的集合与它们之间的关系

8.3 共享库系统路径

FHS(File Hierarchy Standard)标准规定一个系统的系统文件应该如何存放,包括各个目录的结构、组织和作用,这有利于促进各个开源操作系统之间的兼容性。FHS规定,系统中存放共享库的主要位置:

  1. /lib 存放系统最关键和基础的共享库
  2. /usr/lib 保存一些非系统运行时所需要的关键性的共享库
  3. /usr/local/lib 存放一些和操作系统本身并不十分相关的库

8.4 共享库查找过程

动态链接器对于模块的查找规则:如果“.dynamic”的DT_NEED里面保存的事绝对路口,那动态链接器就按照路径查找;如果DT_NEED里面保存的是相对路径,那么动态链接器会在/lib、/usr/lib和由/etc/Id.so.conf配置文件指定的目录中查找共享库。为了程序的可移植性和兼容性,共享库的路径往往是相对的

ld.so.conf是一个文本配置文件,指定的目录有:

  • /uer/local/lib;
  • /lib/i486-linux-gnu;
  • /usr/lib/i486-linux-gnu

Linux系统都有一个ldconfig程序,作用是为共享库目录下的各个共享库创建、删除和更新相应的SO-NAME,并且把这些SO-NAME收集起来,集中存放到/etc/Id.so.cache文件里面,建立一个SO-NAME缓存,加快共享库的查找过程

如果动态链接器在/etc/Id.so.cache里面没有找到所需要的共享库,那么还会遍历/lib和/usr/lib这两个目录,如果还没有宣告失败

8.5 环境变量

LD_LIBRARY_PATH:可以临时改变某个应用程序的共享库查找路径,而不会影响系统中的其他程序。动态链接器会按照下列顺序依次装载或者查找共享对象:

  • 由环境变量LD_LIBRARY_PATH指定的路径
  • 由路径缓存文件/etc/Id.so.cache指定的路径
  • 默认共享库目录,先/usr/lib,后/lib

LD_PRELOAD:指定预先装载的一些共享库或者目标文件(比LD_LIBRARY_PATH优先,无论是否依赖,都会装载),由于全局符号介入这个机制的存在,LD_PRELOAD里面指定的共享库或目标文件中的全局符号,这使得我们可以方便地改写标准库中的函数。

LD_DEBUG:可以打开动态链接器的调试功能,设置这个变量时,动态链接器会在调试信息中打印出有用的信息。

LD_DEBUG=help ls

Valid options for the LD_DEBUG environment variable are:

1
2
3
4
5
6
7
8
9
10
libs        display library search paths
reloc display relocation processing
files display progress for input file
symbols display symbol table processing
bindings display information about symbol binding
versions display version dependencies
all all previous options combined
statistics display relocation statistics
unused determined unused DSOs
help display this help message and exit

To direct the debugging output into a file instead of standard output
a filename can be specified using the LD_DEBUG_OUTPUT environment variable.

8.6 共享库的创建和安装

共享库的创建,和共享对象的创建过程基本一致

  • 最关键的使用GCC的两个参数:“-shared”和“-fPIC”
  • “-shared”表示输出结果是共享库类型
  • “-fPIC”表示使用地址无关代码技术来生产输出文件
  • “-Wl, -soname, my_soname”将指定参数传递给链接器

清除符号信息:strip:清除共享库或者可执行文件的所有符号和调试信息

共享库安装:

  • 将共享库复制到某个标准的共享库目录,如/lib、/usr/lib等,然后运行Idconfig即可,需要系统root权限
  • 建立相应的SO_NAME软链接,并告诉编译器和程序如何查找该共享库
    1. 建立软链接,ldconfig -n shared_library-directory
    2. 编译程序时,指定共享库的位置,“-L”:指定共享库搜索目录;“-l”:指定共享库路径

共享库构造和析构函数

  • 在函数声明时加上__attribute__ ((constructor))的属性,指定该函数为共享库构造函数
  • 在函数声明时加上__attribute__ ((destructor))的属性,指定该函数为共享库析构函数
  • 多个构造函数,默认情况下执行顺序没有规定,需要可以使用优先级参数定义__attribute__((constructor(degree)))

共享库脚本:共享库普遍时动态链接的ELF共享对象文件(.so),事实上,共享库还可以是符号一定格式的链接脚本文件

系统存在大量的共享库,随着更新和升级形成不同的相互兼容和不兼容的版本。如何管理和维护这些共享库,让它们不同的版本之间不会相互冲突时使用共享库的一个重要问题

第10章 内存

内存是装载程序运行的介质,也是程序进行各种运算和表达的场所。

10.1 程序的内存布局

在32位系统里,内存空间拥有4GB的寻址能力。相对于16位时代i386的短地址加段内偏移的寻址模式,如今的应用程序可以直接使用32位的地址进行寻址,这被称为平坦的内存模型。

尽管当前的内存空间号称是平坦的,但实际上内存仍然在不同的地址区间上有着不同的地位,例如,大多数操作系统会将4GB的内存空间上的一部分挪给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。Windows在默认情况下会将高地址的2GB空间分配给内核,而Linux默认情况下将高地址的1GB空间分配给内核。

用户使用的剩下2GB或3GB的内存空间称为用户空间。在用户空间里,有许多地址区间有特殊的地位:

  • 栈: 栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。栈通常在用户空间的最高地址处分配。
  • 堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆通常存在于栈的下方,在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多。
  • 可执行文件映像:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。
  • 保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称。例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。

动态链接库映射区用于映射装载的动态链接库。在Linux下,如果可执行文件依赖其他共享库,那么系统就会为它在从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。

段错误是怎么回事?

这是典型的非法指针解引用造成的错误。当指针指向一个不允许读或写的内存地址,而程序却试图利用指针来读或写该地址的时候,就会出现这个错误。
在Linux或Windows的内存布局中,有些地址是始终不能读写的,例如0地址。还有些地址是一开始不允许读写,应用程序必须事先请求获取这些地址的读写权,或者某些地址一开始并没有映射到实际的物理内存,应用程序必须事先请求将这些地址映射到实际的物理地址,之后才能够自由地读写这片内存。当一个指针指向这些区域的时候,对它指向的内存进行读写就会引发错误。造成这样的最普遍的原因有两种:

  1. 程序员将指针初始化为NULL,之后却没有给它一个合理的值就开始使用指针。

  2. 程序员没有初始化栈上的指针,指针的值一般会是随机数,之后就直接开始使用指针。(野指针)

10.2 栈与调用惯例

10.2.1 什么是栈

在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中,也可以将已经压入栈中的数据弹出,但栈这个容器必须遵守一条规则:先入栈的数据后出栈(FIFO)。

在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使栈减小。

在经典的操作系统里,栈总是向下增长的。在i386下,栈顶由称为esp的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。

栈在程序运行中具有举足轻重的地位。最重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)或活动记录(Active Record)。堆栈帧一般包括如下几方面内容:

  • 函数的返回地址和参数。
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器。

在i386中,一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针(Frame Pointer)。一个很常见的活动记录实例如下所示:

在参数之后的数据(包括参数)即是当前函数的活动记录,ebp固定在图中所示的位置,不随这个函数的执行而变化,相反地,esp始终指向栈顶,因此随着函数的执行,esp会不断变化。固定不变的ebp可以用来定位函数活动记录的各个数据。在ebp之前首先是这个函数的返回地址,它的地址是ebp-4,再往前是压入栈中的参数,它们的地址分别是ebp-8、ebp-12等,视参数数量和大小而定。ebp所直接指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp可以通过读取这个值恢复到调用前的值。之所以函数的活动记录会形成这样的结构,是因为函数调用本身是如此书写的:一个i386下的函数总是这样调用的:

  • 把所有或一部分参数压入栈中,如果有其它参数没有入栈,那么使用某些特定的寄存器传递。
  • 把当前指令的下一个指令的地址压入栈中。
  • 跳转到函数体执行。

其中第2步和第3步由指令call一起执行。跳转到函数体之后即开始执行函数,而i386函数体的“标准”开头是这样的(但也可以不一样):

  • push ebp:把ebp压入栈中(称为old ebp)。
  • mov ebp, esp:ebp = esp(这时ebp指向栈顶,而此时栈顶就是old ebp)。
  • 【可选】sub esp, xxx:在栈上分配xxx字节的临时空间。
  • 【可选】push xxx:如有必要,保存名为xxx寄存器(可重复多个)。

把ebp压入栈中,是为了在函数返回的时候便于恢复以前的ebp值。而之所以可能要保存一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后再取出。函数返回则正好相反:

  • 【可选】pop xxx:如有必要,恢复保存过的寄存器(可重复多个)。
  • mov esp, ebp:恢复esp同时回收局部变量空间。
  • pop ebp:从栈中恢复保存的ebp的值。
  • ret:从栈中取得返回地址,并跳转到该位置。

10.2.2 调用惯例

函数的调用方和被调用方对于函数如何调用须要有一个明确的规定,只要双方遵守同样的规定,函数才能被正确地调用,这样的规定被称为调用惯例。一个调用惯例一般会规定如下几个方面的内容:

  • 函数参数的传递顺序和方式 函数参数的传递有很多种方式,最常见的一种是通过栈传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数调用方将参数压栈的顺序:是从左到右,还是从右到左。有些调用惯例还允许使用寄存器传递参数,以提高性能。
  • 栈的维护方式 在函数将参数压栈之后,函数体会被调用,伺候需要被压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也可以由函数本身来完成。
  • 名字修饰的策略 为了链接的时候对调用惯例进行区分,调用惯例要对函数本身的名字进行修饰。不同的调用惯例有不同的修饰策略。
调用惯例 出栈方 参数传递 名字修饰
cdecl 函数调用方 从右至左的顺序压参数入栈 下划线+函数名
stdcall 函数本身 从右至左的顺序压参数入栈 下划线+函数名+@+参数的字节数
fastcall 函数本身 头两个DWORD类型或者占更少字节的参数被放入寄存器,其他剩下的参数按从右至左的顺序压入栈中 @+函数名+@+参数的字节数
pascal 函数本身 从左至右的顺序压参数入栈 较为复杂,参见pascal文档

10.2.3 函数返回值传递

函数将返回值存储在eax中,返回后函数的调用方再读取eax。但是eax本身只有4个字节,对于返回5~8字节对象的情况,几乎所有的调用惯例都是采用eax和edx联合返回的方式进行的。其中eax存储返回值要低4字节,而edx存储返回值要高1~4字节。而对于超过8字节的返回类型,一般会栈上额外开辟一片空间,并将这片空间的一部分作为传递返回值的临时对象,并将对象的地址作为隐藏参数传递给函数。

10.3 堆与内存管理

10.3.1 什么是堆

堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效。

10.3.2 Linux进程堆管理

Linux提供了两种堆空间分配方式:brk()系统调用和mmap()系统调用。

1
2
3
int brk(void *end_data_segment);

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

glibc的malloc函数是这样处理用户的空间请求的:对于小于128KB的请求来说,它会在现有的堆空间里,按照堆分配算法为它分配一块空间并返回;对于大于128KB的请求来说,它会使用mmap()函数为它分配一块匿名空间,然后在这个匿名空间中为用户分配空间。

10.3.3 Windows进程堆管理

Windows进程将地址空间分配给了各种EXE、DLL文件、堆、栈。其中EXE文件一般位于0x00400000起始地址;而一部分DLL位于0x10000000起始地址,如运行库DLL;还有一部分DLL位于接近0x80000000位置,如系统DLL、NTDLL.DLL、Kernel32.DLL。

Windows提供了一个API叫做VirtualAlloc(),用于向系统申请空间,它与Linux下的mmap非常相似。实际上VirtualAlloc()申请的空间不一定只用于堆,它仅仅是向系统预留了一块虚拟地址,应用程序可以按照需要随意使用。在使用VirtualAlloc()函数申请空间时,系统要求空间大小必须为页的整数倍,即对于x86系统来说,必须是4096字节的整数倍。为了避免造成大量的浪费,需要实现一个分配的算法。Windows堆管理器提供了与堆相关的API用来创建、分配、释放和销毁堆空间(HeapCreate、HeapAlloc、HeapFree和HeapDestroy)。

10.3.4 堆分配算法

1. 空闲链表

把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个链表,知道找到合适大小的块并且将它拆分;当用户释放空间时将它合并到空闲链表中。

2. 位图

将整个堆划分为大量的块,每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块我们称为已分配区域的头,其余的称为已分配区域的主体。而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此仅仅需要两位即可表示一个块。

3. 对象池

如果每一次分配的大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求时只需要找到一个小块就可以了。

第11章 运行库

11.1 入口函数和程序初始化

入口函数和入口点(Entry Point):首先运行的代码不是main函数,而是负责准备main函数执行所需要的环境、调用main函数的函数;在main函数返回后,它会记录main函数的返回值,并调用atexit函数注册的函数,然后结束进程。

程序运行典型步骤:

  1. 操作系统创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数;
  2. 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等;
  3. 入口函数在完成初始化后,调用 main 函数,正式开始执行程序主体部分;
  4. main 函数执行完毕后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭 I/O 等,然后进行系统调用结束进程。

11.1.2 入口函数

glibc的启动过程在不同的情况下差别较大,比如静态glibc和动态glibc,glibc用于可执行文件和用于共享库,这样的差别可组合出4种情况,在此以最简单的可执行文件+静态glibc为例进行说明。

glibc的入口函数为_start(由ld链接器默认的链接脚本指定,可通过参数人为设定自己的入口)。_start由汇编实现,平台相关。书中以i386为例

这省略了不重要代码,可以看到_start最终调用了libc_start_main。加粗部分为对该部分的完整调用过程:前7个push完成给libc_start_main(有7个形参)传递参数的任务。在看回最开始的三条指令前,必须清楚的知道:在调用_start前,装载器会把用户的参数和环境变量压入栈中。

  • xor指令(异或)的作用是将ebp寄存器置0,目的是表明当前是程序的最外层函数。
  • pop指令将argc值(即参数个数)传递给esi寄存器
  • mov指令将arg0的地址存放到ecx寄存器

通过以上分析,可将_start简化成如下可读性代码:

接着分析__libc_start_main,接口如下:

与上述push的七个参数正好一致,并且参数由右至左入栈(涉及调用惯例问题)。其中:

  • ubp_av为arg0的地址
  • init为main调用前的初始化函数
  • fini为main调用后的收尾工作
  • rtld_fini处理与动态加载有关的收尾工作(rtld = runtime loader)
  • stack_end为栈底地址,即栈空间的起始地址(栈空间由高地址往地址增长),__libc_start_main内部将会保存栈底地址以备他用。

exit的代码如下:

1
2
3
4
5
6
7
8
9
void exit (int status) {
while (__exit_funcs != NULL) {
// __exit_funcs是存储由__cxa_atexit和atexit注册的函数链表
// 执行注册函数
__exit_funcs = __exit_funcs->next;
}
...
_exit(status);
}

其中__exit_funcs是存储由__cxa_atexit和atexit注册的函数的链表。最后的_exit的作用是调用了exit这个系统调用。

_exit调用后,进程就结束了。由此可见,进程正常结束有两种情况:

  • main正常返回即未调用exit系统调用,在__libc_start_main执行完main后会主动调用exit
  • 若main里主动调用exit,则不执行进程直接结束,不返回到__libc_start_main
  • 亦即,无论如何,exit必然会被执行到,它是进程结束的必经之路。

11.2 C/C++ 运行库

任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。这样的一个代码集合称之为运行时库(Runtime Library)。而C语言的运行库,即被称为C运行库(CRT)。

一个C语言运行库大致包含了如下功能:

  1. 启动与退出:包括入口函数及入口函数所依赖的其他函数等。
  2. 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。
  3. I/O:I/O功能的封装和实现,参见上一节中I/O初始化部分。
  4. 堆:堆的封装和实现,参见上一节中堆初始化部分。
  5. 语言实现:语言中一些特殊功能的实现。
  6. 调试:实现调试功能的代码。

在这些运行库的组成成分中,C语言标准库占据了主要地位并且大有来头。C语言标准库是C语言标准化的基础函数库,我们平时使用的printf、exit等都是标准库中的一部分。标准库定义了C语言中普遍存在的函数集合。

11.2.2 C语言标准库

C语言标准库还有一些特殊的库,用于执行一些特殊的操作,例如:

  • 变长参数(stdarg.h)。
  • 非局部跳转(setjmp.h)。

变长参数是C语言的特殊参数形式,例如如下函数声明:int printf(const char* format, ...);。如此的声明表明,printf函数除了第一个参数类型为const char*之外,其后可以追加任意数量、任意类型的参数。在函数的实现部分,可以使用stdarg.h里的多个宏来访问各个额外的参数:假设lastarg是变长参数函数的最后一个具名参数(例如printf里的format),那么在函数内部定义类型为va_list的变量:va_list ap;,该变量以后将会依次指向各个可变参数。ap必须用宏va_start初始化一次,其中lastarg必须是函数的最后一个具名的参数。

va_start(ap, lastarg);此后,可以使用va_arg宏来获得下一个不定参数(假设已知其类型为type):type next = va_arg(ap, type);。在函数结束前,还必须用宏va_end来清理现场。在这里我们可以讨论这几个宏的实现细节。在研究这几个宏之前,我们要先了解变长参数的实现原理。变长参数的实现得益于C语言默认的cdecl调用惯例的自右向左压栈传递方式。

下面让我们来看va_list等宏应该如何实现。

  • va_list实际是一个指针,用来指向各个不定参数。由于类型不明,因此这个va_listvoid*char*为最佳选择。
  • va_startva_list定义的指针指向函数的最后一个参数后面的位置,这个位置就是第一个不定参数。
  • va_arg获取当前不定参数的值,并根据当前不定参数的大小将指针移向下一个参数。
  • va_end将指针清0。

按照以上思路,va系列宏的一个最简单的实现就可以得到了,如下所示:

1
2
3
4
#define va_list char*
#define va_start(ap,arg) (ap=(va_list)&arg+sizeof(arg))
#define va_arg(ap,t) (*(t*)((ap+=sizeof(t))-sizeof(t)))
#define va_end(ap) (ap=(va_list)0)

变长参数宏:在很多时候我们希望在定义宏的时候也能够像print一样可以使用变长参数,即宏的参数可以是任意个,这个功能可以由编译器的变长参数宏实现。在GCC编译器下,变长参数宏可以使用“##”宏字符串连接操作实现,比如:

1
#define printf(args…) fprintf(stdout, ##args)

那么printf(“%d %s”, 123, “hello”)就会被展开成:
1
fprintf(stdout, “%d %s”, 123, “hello”)

非局部跳转即使在C语言里也是一个备受争议的机制。使用非局部跳转,可以实现从一个函数体内向另一个事先登记过的函数体内跳转,而不用担心堆栈混乱。下面让我们来看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <setjmp.h>
#include <stdio.h>

void f() {
longjmp(b, 1);
}

int main() {
if (setjmp(b))
printf("World\n");
else {
printf("Hello ");
f();
}
}

这段代码按常理不论setjmp返回什么,也只会打印出“Hello ”和“World!”之一,然而事实上的输出是:Hello World!

实际上,当setjmp正常返回的时候,会返回0,因此会打印出“Hello ”的字样。而longjmp的作用,就是让程序的执行流回到当初setjmp返回的时刻,并且返回由longjmp指定的返回值(longjmp的参数2),也就是1,自然接着会打印出“World!”并退出。换句话说,longjmp可以让程序“时光倒流”回setjmp返回的时刻,并改变其行为,以至于改变了未来。

11.2.3 glibc与MSVC CRT

 运行库是平台相关的,因为它与操作系统结合得非常紧密。C语言的运行库从某种程度上来讲是C语言的程序和不同操作系统平台之间的抽象层,它将不同的操作系统API抽象成相同的库函数。比如我们可以在不同的操作系统平台下使用fread来读取文件,而事实上fread在不同的操作系统平台下的实现是不同的,但作为运行库的使用者我们不需要关心这一点。

glibc即GNU C Library,是GNU旗下的C标准库。发布版本主要由两部分组成,一部分是头文件,比如stdio.h、stdlib.h等,它们往往位于/usr/include;另外一部分则是库的二进制文件部分。二进制部分主要的就是C语言标准库,它有静态和动态两个版本。动态的标准库我们及在本书的前面章节中碰到过了,它位于/lib/libc.so.6;而静态标准库位于/usr/lib/libc.a。事实上glibc除了C标准库之外,还有几个辅助程序运行的运行库,这几个文件可以称得上是真正的“运行库”。它们就是/usr/lib/crt1.o、/usr/lib/crti.o和/usr/lib/crtn.o。是不是对这几个文件还有点印象呢?我们在第2章讲到静态库链接的时候已经碰到过它们了,虽然它们都很小,但这几个文件都是程序运行的最关键的文件。

glibc启动文件:crt1.o里面包含的就是程序的入口函数_start,由它负责调用__libc_start_main初始化libc并且调用main函数进入真正的程序主体。实际上最初开始的时候它并不叫做crt1.o,而是叫做crt.o,包含了基本的启动、退出代码。由于当时有些链接器对链接时目标文件和库的顺序有依赖性,crt.o这个文件必须被放在链接器命令行中的所有输入文件中的第一个,为了强调这一点,crt.o被更名为crt0.o,表示它是链接时输入的第一个文件。

后来由于C++的出现和ELF文件的改进,出现了必须在main()函数之前执行的全局/静态对象构造和必须在main()函数之后执行的全局/静态对象析构。为了满足类似的需求,运行库在每个目标文件中引入两个与初始化相关的段“.init”和“.finit”。运行库会保证所有位于这两个段中的代码会先于/后于main()函数执行,所以用它们来实现全局构造和析构就是很自然的事情了。链接器在进行链接时,会把所有输入目标文件中的“.init”和“.finit”按照顺序收集起来,然后将它们合并成输出文件中的“.init”和“.finit”。但是这两个输出的段中所包含的指令还需要一些辅助的代码来帮助它们启动(比如计算GOT之类的),于是引入了两个目标文件分别用来帮助实现初始化函数的crti.o和crtn.o。

与此同时,为了支持新的库和可执行文件格式,crt0.o也进行了升级,变成了crt1.o。crt0.o和crt1.o之间的区别是crt0.o为原始的,不支持“.init”和“.finit”的启动代码,而crt1.o是改进过后,支持“.init”和“.finit”的版本。这一点我们从反汇编crt1.o可以看到,它向libc启动函数libc_start_main()传递了两个函数指针`libc_csu_init__libc_csu_fini`,这两个函数负责调用_init()和_finit(),我们在后面“C++全局构造和析构”的章节中还会详细分析。

在默认情况下,ld链接器会将libc、crt1.o等这些CRT和启动文件与程序的模块链接起来,但是有些时候,我们可能不需要这些文件,或者希望使用自己的libc和crt1.o等启动文件,以替代系统默认的文件,这种情况在嵌入式系统或操作系统内核编译的时候很常见。GCC提高了两个参数“-nostartfile”和“-nostdlib”,分别用来取消默认的启动文件和C语言运行库。

其实C++全局对象的构造函数和析构函数并不是直接放在.init和.finit段里面的,而是把一个执行所有构造/析构的函数的调用放在里面,由这个函数进行真正的构造和析构。

除了全局对象构造和析构之外,.init和.finit还有其他的作用。由于它们的特殊性(在main之前/后执行),一些用户监控程序性能、调试等工具经常利用它们进行一些初始化和反初始化的工作。当然我们也可以使用__attribute__((section(“.init”)))将函数放到.init段里面,但是要注意的是普通函数放在“.init”是会破坏它们的结构的,因为函数的返回指令使得_init()函数会提前返回,必须使用汇编指令,不能让编译器产生“ret”指令。

在第2章中我们在链接时碰到过的诸多输入文件中,已经解决了crt1.o、crti.o和crtn.o,剩下的还有几个crtbeginT.o、libgcc.a、libgcc_eh.a、crtend.o。严格来讲,这几个文件实际上不属于glibc,它们是GCC的一部分,它们都位于GCC的安装目录下:

  • /usr/lib/gcc/i486-Linux-gnu/4.1.3/crtbeginT.o
  • /usr/lib/gcc/i486-Linux-gnu/4.1.3/libgcc.a
  • /usr/lib/gcc/i486-Linux-gnu/4.1.3/libgcc_eh.a
  • /usr/lib/gcc/i486-Linux-gnu/4.1.3/crtend.o

首先是crtbeginT.o及crtend.o,这两个文件是真正用于实现C++全局构造和析构的目标文件。是crti.o和crtn.o中的“.init”和“.finit”提供一个在main()之前和之后运行代码的机制,而真正全局构造和析构则由crtbeginT.o和crtend.o来实现。我们在后面的章节还会详细分析它们的实现机制。

libgcc.a里面包含的函数主要包括整数运算、浮点数运算(不同的CPU对浮点数的运算方法很不相同)等,而libgcc_eh.a则包含了支持C++的异常处理(Exception Handling)的平台相关函数。另外GCC的安装目录下往往还有一个动态链接版本的libgcc.a,为libgcc_s.so。

11.3 运行库与多线程

11.3.1 CRT的多线程困扰

实际运行的线程拥有自己的私有存储空间

  • 栈(尽管并非无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。
  • 线程局部存储(Thread Local Storage, TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但是通常只是具有很有限的尺寸。
  • 寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。

从C程序员角度:

线程私有 线程之间共享(进程所有)
局部变量 全局变量
函数的参数 推上的数据
TLS数据 函数里的静态变量
程序代码,任何线程都有权力读取并执行任何代码
打开文件,A线程打开的文件可以由B线程读写

C/C++运行库在多线程下的问题:

  • (1) errno: 在C标准库里,大多数错误代码是在函数返回之前赋值在名为errno的全局变量里的。多线程并发的时候,有可能A线程的errno的值在获取之前就被B线程给覆盖了。
  • (2) strtok()等函数都会使用函数内部的静态变量来存储字符串的位置,不同的线程调用这个函数将会把它内部的局部静态变量弄混。
  • (3) malloc/new 与free/delete:堆分配/释放函数或关键字在不加锁的情况下是线程不安全的。由于这些函数或关键字的调用十分频繁,因此在保证线程安全的时候显得十分繁琐。
  • (4) 异常处理:在早期的C++运行库里,不同的线程抛出异常会彼此冲突,从而造成信息丢失的情况。
  • (5) printf/fprintf及其他I/O函数:流输出函数同样是线程不安全的,因为它们共享了同一个控制台或文件输出。不同的输出并发时,信息会混杂在一起。
  • (6) 其他线程不安全因素:包括与信号相关的一些函数。

通常情况下,C标准库中在不进行线程安全保护的情况下自然地具有线程安全的属性的函数有(不考虑errno因素):

  • (1) 字符处理(ctype.h),包括isdigit,toupper等,这些函数同时还是可重入的。
  • (2) 字符串处理函数(string.h) 包括strlen,strcmp等,但其中涉及对参数中的数组进行写入的函数(如strcpy)仅在参数中的数组各不相同时,可以并发。
  • (3) 数学函数(math.h),包括sin, pow等,这些函数同时还是可重入的。
  • (4) 字符串转整数/浮点数(stdlib.h),包括atof, atoi, atol, strtod, strtol, strtoul。
  • (5) 获取环境变量(stdlib.h), 包括getenv, 可重入
  • (6) 变长数组辅助函数(stdarg.h)
  • (7) 非局部跳转函数(setjmp.h),包括setjmp和longjmp,前提是longjmp仅跳转到本线程设置的jumpbuf上。

11.3.2 CRT改进

  • 使用TLS
  • 加锁
  • 改进函数调用方式

11.3.3 线程局部存储实现

在一个线程中使用全局变量,且该全局变量只能在当前线程中可访问,这就需要线程局部存储(TLS, Thread Local Storage)。对于GCC,加关键字__thread

__thread int thread

对于MSVC,加关键字 __declspec(thread)

__declspec(thread) int number

一旦一个全局变量被定义称TLS,那么每个线程都会拥有这个变量的副本,任何线程对这个变量的修改,都不会影响其他线程中,该变量的副本。

第一章 容器

第一条: 慎重选择容器类型

C++所提供的容器类型有如下几种:

  • 标准STL序列容器 vector string deque list
  • 标准STL关联容器 set multiset map multimap
  • 非标准序列容器 slist rope
  • 非标准关联容器 hash_set hash_multiset hash_map hash_multimap
  • vector作为string的替代
  • vector作为标准关联容器的替代
  • 非标准的STL容器 array bitset valarray stack queue priority_queue

标准容器中的vector stringlist比较熟悉。dequedouble ended queue,提供了与vector一样的随机访问功能,但同时对头尾元素的增删操作提供了优化。setmultiset中的数据都是顺序排列的,数据值本身就是键值,set中的数据必须唯一而multiset没有这样的限制。mapmultimap中的数据对按照键值顺序排列,map中不允许出现重复的key,而multimap中可以用相同的key对应不同的value。slistsingle linked list,与STL中标准的list之间的区别就在于slist的iterator是单向的,而list的iterator是双向的。rope用于处理大规模的字符串。hash_sethash_multisethash_maphash_multimap利用hash算法对相对应的关联容器进行了优化。bitset是专门用来存储bit的容器。valarray主要用于对一系列的数字进行高速运算。priority_queue类似于heap,可以高效的获取最高priority的元素。

  • 连续内存容器,动态申请一块或多块内存,每块内存中存储多个容器中的元素,当发生插入或删除操作时,要对该内存中的其他元素进行新移动操作,这会降低效率。vector,string,rope都是连续内存容器。
  • 基于节点的容器,为容器中的每一元素申请单独的内存,元素中有指针指向其他的元素,插入和删除的操作只需要改变指针的指向。缺点在于占用内存相对连续内存容器较大。list, slist, 关联容器以及hash容器都是基于节点的容器。

第二条: 不要编写试图独立于容器的代码

  • 数组被泛化为以其所包含对象的类型为参数的容器
  • 函数被泛化为以其使用的迭代器的类型为参数的算法
  • 指针被泛化为以其所指向的对象的类型为参数的迭代器

考虑到以后可能会使用其他的容器替换现有的容器,为了使修改的部分最小化,最好采用如下的方式

1
2
3
4
5
6
7
8
9
10
11
class Widget{...};
template<typename T>
SpecialAllocator{...};

typedef vector<Widget, SpecialAllocator<Widget>> WidgetContainer;
typedef WidgetContainer::iterator WCIterator;

WidgetContainer wc;
Widget widget;
...
WCIterator i = find(wc.begin(), wc.end(), widget);

使用Class将自定义的容器封装起来,可以更好的实现修改部分最小化,同时达到了安全修改的目的

1
2
3
4
5
6
7
8
9
10
11
class CustomizedContainer
{
private:
typedef vector<Widget> InternalContainer;
typedef InternalContainer::Iterator ICIterator;

InternalContainer container;

public:
...
};

第三条: 确保容器内对象的拷贝正确而高效

STL的工作方式是Copy In, Copy Out,也就是说在STL容器中的插入对象和读取对象,使用的都是对象的拷贝。在存放基类对象的容器中存放子类的对象,当容器内的对象发生拷贝时,会发生截断(剥离 slicing)。

1
2
3
4
5
6
7
8
9
vector<Widget> vw;

class SpecialWidget : public Widget
{
...
};

SpecialWidget sw;
vw.push_back(sw);

正确的方法是使容器包含指针而非对象。

1
2
3
4
5
6
7
8
9
vector<Widget*> vw;

class SpecialWidget : public Widget
{
...
};

SpecialWidget sw;
vw.push_back(&sw);

容器与数组在数据拷贝方面的对比:

当创建一个包含某类型对象的一个数组的时候,总是调用了次数等于数组长度的该类型的构造函数。尽管这个初始值之后会被覆盖掉

1
Widget w[maxNumWidgets]; //maxNumWidgets 次的Widget构造函数

如果使用vecor,效率会有所提升。

1
2
3
4
5
6
7
8
vector<widget> w;     //既不调用构造函数也不调用拷贝构造函数

vector<widget> w(5); //1次构造 5次拷贝构造

vector<widget> w; //既不调用构造函数也不调用拷贝构造函数
w.reserve(5); //既不调用构造函数也不调用拷贝构造函数
vector<widget> w(5); //1次构造 5次拷贝构造
w.reserve(6); //需要移动位置,调用5次拷贝构造

第四条: 调用empty()而不是检查size()是否为0

empty()对于所有标准容器都是常数时间,而对list操作,size()耗费线性时间。list具有常数时间的Splice操作,如果在两个list之间做链接的时候需要记录被链接到当前list的节点的个数,那么Splice操作将会变成线性时间。对于list而言,用户对Splice效率的要求高于取得list长度的要求,所以list的size()需要耗费线性的时间去遍历整个list。所以,调用empty()是判断list是否为空的最高效方法。

第五条: 区间成员函数优先于与之对应的单元素成员函数

区间成员函数在效率方面的开销要小于循环调用单元素的成员函数,以insert为例

  • 避免不必要的函数调用
  • 避免频繁的元素移动
  • 避免多次进行内存分配

区间创建

1
container::container(InputIterator begin, InputIterator end);

区间插入

1
2
void container::insert(Iterator position, InputIterator begin, InputIterator end);
void associatedContainer::insert(InputIterator begin, InputIterator end);

区间删除

1
2
Iterator container::erase(Iterator begin, Interator end);
void associatedContainer:erase(Iterator begin, Iterator end);

区间赋值

1
void container::assign(InputIterator begin, InputIterator end);

第六条:当心C++编译器最烦人的分析机制

C++会尽可能的将一条语句解释为函数声明。下列语句都声明了一个函数返回值为int类型的函数f,其参数是double类型。

1
2
3
int f(double(d));
int f(double d);
int f(double);  

下列语句都声明了一个返回值为int类型的函数g,它的参数是返回值为double类型且无参的函数指针
1
2
3
4
5
6
7
8
9
int g(double(*pf)());
int g(double pf());
int g(double ()); //注意与int g(double (f))的区别
```  

对于如下语句,编译器会做出这样的解释:声明了一个返回值为`list<int>`的函数data,该函数有两个参数,一个是`istream_iterator<int>`类型的变量,另一个是返回值为`istream_iterator<int>`类型的无参函数指针。
```C++
ifstream dataFile("ints.dat");
list<int> data(istream_iterator<int>(dataFile),istream_iterator<int>());

如果希望构造一个list<int>类型的变量data,最好的方式是使用命名的迭代器。尽管这与通常的STL风格相违背,但是消除了编译器的二义性而且增强了程序的可读性。
1
2
3
4
ifstream dataFile("ints.dat");
istream_iterator dataBegin(dataFile);
istream_iterator dataEnd;
list<int> data(dataBegin,dataEnd); 

第七条:如果容器中包含了通过new操作创建的指针,切记在容器对象析构前将指针delete掉

STL容器在析构之前,会将其所包含的对象进行析构。

1
2
3
4
5
6
7
8
9
10
11
class widget
{
...
};

doSth()
{
widget w; //一次构造函数
vector<widget> v;
v.push_back(w); //一次拷贝构造函数
} // 两次析构函数

但如果容器中包含的是指针的话,一旦没有特别将指针delete掉将会发生内存泄漏

1
2
3
4
5
6
7
8
9
10
11
class widget
{
...
};

doSth()
{
widget* w = new widget();
vector<widget*> v;
v.push_back(w);
} // memory leak!!!

最为方便并且能够保证异常安全的做法是将容器所保存的对象定义为带有引用计数的智能指针

1
2
3
4
5
6
7
8
9
10
11
class widget
{
...
};

doSth()
{
shared_ptr<widget> w(new widget()); //构造函数一次
vector<shared_ptr<widget>> v;
v.push_back(w);
} //析构函数一次 没有内存泄漏

第八条:切勿创建包含auto_ptr对象的容器

由于auto_ptr对于其”裸指针”必须具有独占性,当将一个auto_ptr的指针赋给另一个auto_ptr时,其值将被置空。

1
2
3
4
auto_ptr<int> p1(new int(1)); // p1 = 1
auto_ptr<int> p2(new int(2)); // p2 = 2

p2 = p1; // p2 = 1 p1 = emtpy;

第三条提到STL容器中的插入对象和读取对象,使用的都是对象的拷贝,并且基于STL容器的算法也通常需要进行对象的copy,所以,创建包含auto_ptr的容器是不明智的。

第九条:慎重选择删除元素的方法

要删除容器中特定值的所有对象

1
2
3
4
5
6
7
8
//对于vector、string、deque 使用erase-remove方法
container.erase(remove(container.begin(),container.end(),value),container.end());

//对于list 使用remove方法
list.remove(value);

//对于标准关联容器 使用erase方法
associatedContainer.erase(value);

要删除容器中满足特定条件的所有对象

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
bool condition(int );

//对于vector、string、deque 使用erase-remove_if方法
container.erase(remove_if(container.begin(),container.end(),condition),container.end());

//对于list 使用remove_if方法
list.remove_if(condition);

//对于标准关联容器 第一种方法是结合remove_copy_if和swap方法
associatedContainer.remove_copy_if(associatedContainer.begin(),
associatedContainer.end(),
insert(tempAssocContainer,tempAssocContainer.end()),
condition);

//另外一种方法是遍历容器内容并在erase元素之前将迭代器进行后缀递增
for(assocIt = associatedContainer.begin(); assocIt != associatedContainer.end())
{
if(condition(*assoIt))
{
///当关联容器中的一个元素被删除掉时,所有指向该元素的迭代器都被设为无效,所以要提前将迭代器向后递增
associatedContainer.erase(assoIt++);
}
else
{
assocIt++;
}
}

如果除了在删除容器内对象的同时还需要进行额外的操作时

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
bool condition(int );
void dosth();

//对于标准序列容器,循环遍历容器内容,利用erase的返回值更新迭代器
for(containerIt = container.begin(); containerIt != container.end())
{
if(condition(*containerIt))
{
doSth();
//当标准容器中的一个元素被删除掉时,所有指向该元素以及该元素之后的迭代器都被设为无效,所以要利用erase的返回值
containerIt = container.erase(containerIt++);
}
else
{
containerIt++;
}
}

//对于标准关联容器,循环遍历容器内容,并在erase之前后缀递增迭代器
for(assocIt = associatedContainer.begin(); assocIt != associatedContainer.end())
{
if(condition(*assoIt))
{
dosth();
associatedContainer.erase(assoIt++);
}
else
{
assocIt++;
}
}

我觉得第三种情况下可以用第二种情况的实现代替,我们需要做的仅是将额外做的事情和判断条件包装在一个函数内,并用这个函数替代原有的判断条件。 

1
2
3
4
5
6
7
8
9
10
bool condition_doSth(int i)
{
bool ret = condition(i);
if(ret)
{
doSth();
}

return ret;
}

第十条:了解分配子的约定和限制

如果需要编写自定义的分配子,有以下几点需要注意

  • 当分配子是一个模板,模板参数T代表你为其分配内存的对象的类型
  • 提供类型定义pointer和reference,始终让pointer为T*而reference为T&
  • 不要让分配子拥有随对象而不同的状态,通常,分配子不应该有非静态数据成员
  • 传递给allocator的是要创建元素的个数而不是申请的字节数,该函数返回T*,尽管此时还没有T对象构造出来
  • 必须提供rebind模板,因为标准容器依赖于该模板

第十一条:理解自定义分配子的合理用法

如果需要在共享的内存空间中手动的管理内存分配,下列代码提供了一定的参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//用户自定义的管理共享内存的malloc和free
void* mallocShared(size_t bytesNeeded);
void* freeShared(void* ptr);

template<typename T>
class sharedMemoryAllocator
{
public:
...

point allocator(size_type numObjects, const void* localityHint=0)
{
return static_cast<pointer>(mallocShared(numObjects*sizeof(T)));
}

void deallocate(pointer ptrMemory, size_type numObjects)
{
freeShared(ptrMemory);
}
}

  
如果不仅仅是将容器的元素放在共享内存,而且要将容器对象本身也放在共享内存中,参考如下代码
1
2
3
4
5
6
7
void* ptrVecMemory = mallocShared(sizeof(SharedDoubleVec));
SharedDoubleVec* sharedVec = new(ptrVecMemory) SharedDoubleVec;

...

sharedVec->~SharedDoubleVec();
freeShared(sharedVec);

第十二条:切勿对STL容器的线程安全性有不切实际的依赖

对于STL容器的多线程读是安全的,对于多个不同的STL容器,采用面向对象的方式对STL容器进行加锁和解锁

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<typename Container>
class lock
{
public:

Lock(const Container& container):c(container)
{
getMutexFor(c);
}

~Lock()
{
releaseMutex(c);
}

private:
Container& c;
},
vector<int> v;
...

{
Lock<vector<int>> lock(v); //构造lock,加锁v
doSthSync(v); //对v进行多线程的操作
} //析构lock,解锁v

vector和string

第十三条: vector和string优先于动态分配的数组

如果使用new来动态分配内存,使用者必须承担以下的责任

  • 确保之后调用delete将内存释放
  • 确保使用的是正确的delete形式,对于单个对象要用delete,对于数组对象需要用delete[]
  • 确保对于一个对象只delete一次

vector、string自动管理其所包含元素的构造与析构,并有一系列的STL算法支持,同时vector也能够保证和老代码的兼容。使用了引用计数的string可以避免不必要的内存分配和字符串拷贝(COW- copy on write),但是在多线程环境里,对这个string进行线程同步的开销远大于COW的开销。此时,可以考虑使用vector<char>或动态数组。

第十四条: 使用reserve来避免不必要的内存分配

对于STL容器而言,当他们的容量不足以放下一个新元素的时候,会自动增长以便容纳新的数据。(只要不超过max_size)

  • 分配一块儿原内存大小数倍的新内存,对于vector和string而言,通常是两倍。
  • 将原来容器中的元素拷贝到新内存中
  • 析构旧内存中的对象
  • 释放旧内存

reserve以及与resever相关的几个函数

  • size() 容器中现有的元素的个数
  • capacity() 容器在不重新分配内存的情况下可容纳元素的总个数
  • resize(Container::size_type n) 将容器的size强制改变为n
    • n>size 将现有容器中的元素拷贝到新内存,并将空余部分用默认构造的新函数填满
    • n<size 将尾部的元素全部析构掉
  • reserve(Container::size_type n)将容器的size改变至少为n
    • n>size 将现有容器中的元素拷贝到新内存,多余部分的内存仍然空置
    • n<size 对容器没有影响

通常有两种方式使用reserve避免不必要的内存分配

  • 预测大致所需的内存,并在构造容器之后就调用reserve预留内存
  • 先用reserve分配足够大的内存,将所有元素都加入到容器之后再去除多余内存。

第十五条: string实现的多样性

实现A 在该实现中,包含默认Allocator的string是一个指针大小的4倍。对于有自定义的Allocator的string,他的大小将更大

实现B 在使用默认的Allocator的情况下,string对象的大小与指针的大小相等。当使用自定义的Allocator时,string对象将加上对应的自定义Allocator的对象。Other部分用来在多线程条件下进行同步控制,其大小通常为指针大小的6倍。

实现C string对象的大小总与指针大小相同,没有对单个对象的Allocator的支持。X包含一些与值的可共享性相关的数据

实现D 对于使用默认Allocator的string,其大小等于指针大小的7倍。不使用引用计数,string内部包含一块内存可容纳15个字符的字符串。

总结string的多种实现

  • string的值可能会被引用计数(实现A 实现B 实现C)也可能不会(实现D)
  • string对象的大小可能在char*指针的1倍到7倍之间
  • 创建一个新的字符串值可能需要0次(实现D capacity<=15)、1次(实现A、实现C、实现D capacity>15)或2次(实现B)动态的内存分配
  • string对象可能共享(实现B、实现C)也可能不共享(实现A 实现D)其大小和容量信息
  • string可能支持(实现A 实现B 实现D)也可能不支持(实现C)单个对象的分配子
  • 不同的实现对字符内存的最小分配单位有不同的策略

第十六条: 了解如何把vector和string数据传给旧的API

将vector传递给接受数组指针的函数,要注意vector为空的情况。迭代器并不等价于指针,所以不要将迭代器传递给参数为指针的函数。

1
2
3
4
5
6
void foo(const int* ptr, size_t size);


vector<int> v;
...
foo(v.empty() ? NULL : &v[0], v.size());

将string传递给接受字符串指针的函数。该方法还适用于s为空或包含”\0”的情况

1
2
3
4
5
6
void foo(const char* ptr);


string s;
...
foo(s.c_str());

使用初始化数组的方式初始化vector

1
2
3
4
5
6
//向数组中填入数据
size_t fillArray(int* ptr, size_t size);

int maxSize = 10;
vector<int> v(maxSize);
v.resize(fillArray(&v[0],v.size()));

借助vector与数组内存布局的一致性,我们可以使用vector作为中介,将数组中的内容拷贝到其他STL容器之中或将其他STL容器中的内容拷贝到数组中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//向数组中填入数据
size_t fillArray(int* ptr, size_t size);

vector<int> v(maxSize);
v.resize(fillArray(&v[0],v.size()));
set<int> s(v.begin(),v.end());

void foo(const int* ptr, size_t size);

list<int> l();
...

vector<int> v(l.begin(),l.end());
foo(v.empty()? NULL : &v[0],v.size());

第十七条: 使用swap去除多余容量

1
vector<int>(v).swap(v);

vector<int>(v)使用v创建一个临时变量,v中空余的内存将不会被拷贝到这个临时变量的空间中,再利用swap将这个临时变量与v进行交换,相当于去除掉了v中的多余内存。

由于STL实现的多样性,swap的方式并不能保证去掉所有的多余容量,但它将尽量将空间压缩到其实现的最小程度。利用swap的交换容器的值的好处在于可以保证容器中元素的迭代器、指针和引用在交换后依然有效。

1
2
3
4
5
6
7
vector<int> v1;
v1.push_back(1);
vector<int>::iterator i = v1.begin();

vector<int> v2(v1);
v2.swap(v1);
cout<<*i<<endl; //output 1 iterator指向v2的begin

但是在使用基于临时变量的swap要当心iterator失效的情况  

1
2
3
4
5
6
vector<int> v1;
v1.push_back(1);
vector<int>::iterator i = v1.begin();

vector<int>(v1).swap(v1);
cout<<*i<<endl; //crash here

原因在于第5行构造的临时变量在该行结束后就被析构了。

第十八条: 避免使用vector

vector<bool>并不是一个真正的容器,也并不存储真正的bool类型,为了节省空间,它存储的是bool的紧凑表示,通常是一个bit。
由于指向单个bit的指针或引用都是不被允许的,vector<bool>采用代理对象模拟指针指向单个bit。

1
2
3
4
5
vector<bool> v;
//...

bool *pb = &v[0]; // compile error
vector<bool>::reference *pr = &v[0]; // OK

可以考虑两种方式替代vector<bool>

  • deque<bool>但是要注意deque的内存布局与数组并不一致
  • bitset不是STL容器所以不支持迭代器,其大小在编译器就已经确定,bool也是紧凑的存储在内存中。

关联容器

第十九条: 理解相等(equality)和等价(equivalence)的区别  

相等的概念是基于operator==的,也就是取决于operator==的实现。等价关系是基于元素在容器中的排列顺序的,如果两个元素谁也不能排列在另一个的前面,那么这两个元素是等价的。标准关联容器需要保证内部元素的有序排列,所以标准容器的实现是基于等价的。标准关联容器的使用者要为所使用的容器指定一个比较函数(默认为less),用来决定元素的排列顺序。

非成员的函数(通常为STL算法)大部分是基于相等的。下列代码可能会返回不同的结果

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
struct CIStringCompare:
public binary_function<string, string, bool> {
bool operator()(const string& lhs,
const string& rhs) const
{
int i = stricmp(lhs.c_str(),rhs.c_str());
if(i < 0)
return true;
else
return false;
}
};

set<string, CIStringCompare> s; //set的第二个参数是类型而不是函数
s.insert("A");

if(s.find("a") != s.end()) //true
{
cout<<"a";
}

if(find(s.begin(),s.end(),"a") != s.end()) //false
{
cout<<"a";
}

第二十条: 为包含指针的关联容器指定比较类型  

下面的程序通常不会得到用户期望的结果。

1
2
3
4
5
6
7
8
9
set<string*> s;
s.insert(new string("A"));
s.insert(new string("C"));
s.insert(new string("B"));

for(set<string*>::iterator i = s.begin(); i != s.end(); i++)
{
cout<<**i; //输出一定会是ABC么?
}

因为set中存储的是指针类型,而它也仅仅会对指针所处的位置大小进行排序,与指针所指向的内容无关。当关联容器中存储指针或迭代器类型的时候,往往需要用户自定义一个比较函数来替换默认的比较函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct CustomedStringCompare:
public binary_function<string*, string*, bool> {
bool operator()(const string* lhs,
const string* rhs) const
{
return *lhs < *rhs;
}
};

set<string*,CustomedStringCompare> s;
s.insert(new string("A"));
s.insert(new string("C"));
s.insert(new string("B"));

for(set<string*, CustomedStringCompare>::iterator i = s.begin(); i != s.end(); i++)
{
cout<<**i; //ABC
}

  
可以更进一步的实现一个通用的解引用比较类型

1
2
3
4
5
6
7
8
9
struct DerefenceLess{
template<typename PtrType>
bool operator()(PtrType ptr1, PtrType ptr2) const
{
return *ptr1 < *ptr2;
}
};

set<string*,DerefenceLess> s;

如果用less_equal来实现关联容器中的比较函数,那么对于连续插入两个相等的元素则有

1
2
3
set<int,less_equal<int>> s;
s.insert(1);
s.insert(1);

因为关联容器是依据等价来实现的,所以判断两个1是否等价!
1
!(1<=1) && !(1<=1) // false 不等价 

所以这两个1都被存储在set中,从而破坏了set中不能有重复数据的约定. 

比较函数的返回值表明元素按照该函数定义的顺序排列,一个值是否在另一个之前。相等的值不会有前后顺序,所以,对于相等的值,比较函数应该返回false。

对于multiset又如何呢?multiset应该可以存储两个相等的元素吧? 答案也是否定的。对于下面的操作:

1
2
3
4
5
multiset<int,less_equal> s;
s.insert(1);
s.insert(1);

pair<multiset<int,less_equal>::iterator,multiset<int,less_equal>::iterator> ret = s.equal_range(1);

返回的结果并不是所期望的两个1。因为equal_range的实现(lower_bound:第一个不小于参数值的元素(基于比较函数的小于), upper_bound:第一个大于参数值的元素)是基于等价的,而这两个1基于less_equal是不等价的,所以返回值中比不存在1。

事实上,上面的代码在执行时会产生错误。VC9编译器Debug环境会在第3行出错,Release环境会在之后用到ret的地方发生难以预测的错误。

第二十二条: 切勿直接修改set或multiset的键  

set、multiset、map、multimap都会按照一定的顺序存储其中的元素,但如果修改了其中用于排序的键值,则将会破坏容器的有序性。

对于map和multimap而言,其存储元素的类型为pair<const key, value>,修改map中的key值将不能通过编译(除非使用const_cast)。对于set和multiset,其存储的键值并不是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
class Employee
{
public:
int id;
string title;
};

struct compare:
public binary_function<Employee&, Employee&, bool> {
bool operator()(const Employee& lhs,
const Employee& rhs) const
{
return lhs.id < rhs.id;
}
};

set<Employee,compare> s;

Employee e1,e2;

e1.id = 2;
e1.title = "QA";

e2.id = 1;
e2.title = "Developer";

s.insert(e1);
s.insert(e2);

set<Employee,compare>::iterator i = s.begin();
i->title = "Manager"; //OK to update non-key value
i->id = 3; // 破坏了有序性

  
有些STL的实现将set<T>::iteratoroperator*返回一个const T&,用来保护容器中的值不被修改,在这种情况下,如果希望修改非键值,必须通过const_case。
1
2
3
4
set<Employee,compare>::iterator i = s.begin();
const_cast<Employee&>(*i).title = "Manager"; //OK
const_cast<Employee*>(&*i).title = "Arch"; //OK
const_cast<Employee>(*i).title = "Director"; // Bad 仅仅就修改了临时变量的值 set中的值没有发生改变

对于map和multimap而言,尽量不要修改键值,即使是通过const_cast的方式,因为STL的实现可能将键值放在只读的内存区域当中。

相对安全(而低效)的方式来修改关联容器中的元素

  • 找到希望修改的元素。
  • 将要被修改的元素做一份拷贝。(注意拷贝的Map的key值不要声明为const)
  • 修改拷贝的值。
  • 从容器中删除元素。(erase 见第九条)
  • 插入拷贝的那个元素。如果位置不变或邻近,可以使用hint方式的insert从而将插入的效率从对数时间提高到常数时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
set<Employee,compare> s;

Employee e1,e2;

e1.id = 2;
e1.title = "QA";

e2.id = 1;
e2.title = "Developer";

s.insert(e1);
s.insert(e2);

set<Employee,compare>::iterator i = s.begin();
Employee e(*i);
e.title = "Manager";

s.erase(i++);
s.insert(i,e);

第二十三条: 考虑使用排序的vector替代关联容器  

哈希容器大部分情况下可以提供常数时间的查找效率,标准容器也可以达到对数时间的查找效率。

标准容器通常基于平衡二叉树实现, 这种实现对于插入、删除和查找的混合操作提供了优化。但是对于3步式的操作(首先进行插入操作,再进行查找操作,再修改元素或删除元素),排序的vector能够提供更好的性能。因为相对于vector,关联容器需要更大的存储空间。在排序的vector中存储数据比在关联容器中存储数据消耗更少的内存,考虑到页面错误的因素,通过二分搜索进行查找,排序的vector效率更高一些。

如果使用排序的vector替换map,需要实现一个自定义的排序类型,该排序类型依照键值进行排序。

第二十四条: 当效率至关重要时,请在map:operator[]和map:insert之间谨慎作出选择 

从效率方面的考虑,当向map中添加元素时,应该使用insert,当需要修改一个元素的值的时候,需要使用operator[]
如果使用operator[]添加元素

1
2
3
4
5
6
7
8
class Widget{
};

map<int,Widget> m;
Widget w;

m[0] = w;
//Widget构造函数被调用两次

对于第8行,如果m[0]没有对应的值,则会通过默认的构造函数生成一个widget对象,然后再用operator=将w的值赋给这个widget对象。 使用insert可以避免创建这个中间对象。

1
2
3
4
map<int,Widget> m;
Widget w;

m.insert(map<int,Widget>::value_type(0,w)); //没有调用构造函数

如果使用insert修改元素的值(当然,不会有人这样做)

1
2
3
4
5
6
7
8
9
10
11
12
map<int,Widget> m;
Widget w(1);
m.insert(map<int,Widget>::value_type(0,w));

Widget w2(2);

m.insert(map<int,Widget>::value_type(0,w2)).first->second = w2; //构造了一个pair对象

// 上面这段代码比较晦涩
// map::insert(const value_type& x)的返回值为pair<iterator,bool>
// 当insert的值已经存在时,iterator指向这个已经存在的值,bool值为false。
// 反之,指向新插入的值,bool值为true。

使用operator[]则轻便且高效的多

1
2
3
4
5
6
7
map<int,Widget> m;
Widget w(1);
m.insert(map<int,Widget>::value_type(0,w));

Widget w2(2);

m[0] = w2;

 
一个通用的添加和修改map中元素的方法

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 MapType,
typename KeyType,
typename ValueType>
typename MapType::iterator InsertOrUpdate(MapType& map,const KeyType& k, const ValueType& v) // 注意typename的用法 从属类型前一定要使用typename
{
typename MapType::iterator i = map.lower_bound(k); // 如果i!=map.end(),则i->first不小于k

if(i!=map.end() && !map.key_comp()(k,i->first)) // k不小于i->first 等价!
{
i->second = v;
return i;
}

else
{
return map.insert(i,pair<const KeyType, ValueType>(k,v));
}
};

map<int,Widget> m;
Widget w(1);

map<int,Widget>::iterator i = InsertOrUpdate<map<int,Widget>,int,Widget>(m,0,w);

第二十五条: 熟悉非标准的哈希容器

常见的hash容器的实现有SGI和Dinkumware,SGI的hashset的声明类似于

1
2
3
4
5
template<typename T,
typename HashFunction = hash<T>,
typename CompareFunction = equal_to<T>,
typename Allocator = allocator<T>>
class hashSet;

Dinkumware的hash_set声明

1
2
3
4
5
6
7
8
template<typename T,
typename CompareFunction>
class hash_compare;

template<typename T,
typename HashingInfo = hash_compare<T,less<T>>,
typename Allocator = allocator<T>>
class hash_set;

SGI使用传统的开放式哈希策略,由指向元素的单向链表的指针数组(桶)构成。Dinkumware同样使用开放式哈希策略,由指向元素的双向链表的迭代器数组(桶)组成。从内存的角度上讲,SGI的设计要节省一些。

迭代器

第二十六条: iterator优先于const_iterator, reverse_iterator以及const_reverse_iterator

对于容器类container<T>而言,

  • iterator的功效相当与T*
  • const_iterator的功效相当于 const T*
  • reverse_iteratorconst_reverse_iterator与前两者类似,只是按照反向遍历

它们之间相互转换的关系如图

从iterator到const_iterator和reverse_iterator存在隐式转换,从reverse_iterator到const_iterator也存在隐式转换。

通过base()可以将reverse_iterator转换为iterator,同样可以将const_reversse_iterator转换为const_iterator,但是转换后的结果并不指向同一元素(有一个偏移量)

第二十七条: 使用distance和advance将容器的const_iterator转换成iterator

对于大多数的容器,const_cast并不能将const_iterator转换为iterator。即使在某些编译器上可以将vector和string的const_iterator转换为iterator,但存在移植性的问题

通过distance和advance将const_iterator转换为iterator的方法

1
2
3
4
5
6
7
8
9
10
vector<Widget> v;

typedef vector<Widget>::const_iterator ConstIter;
typedef vector<Widget>::iterator Iter;

ConstIter ci;

... //使ci指向v中的元素
Iter i = v.begin();
advance(i,distance<ConstIter>(i,ci));
  

第二十八条: 正确理解由reverse_iterator的base()成员函数所产生的iterator的用法

使用reverse_iteratorbase()成员函数所产生的iterator和原来的reverse_iterator之间有一个元素的偏移量。

容器的插入、删除和修改操作都是基于iterator的,所以对于reverse_iterator,必须通过base()成员函数转换为iterator之后才能进行增删改的操作。

  • 对于插入操作而言,新插入的元素都在3和4之间,所以可以直接使用insert(ri.base(),xxx)
  • 对于修改和删除操作,由于ri和ri.base()并不指向同一元素,所以在修改和删除前,必须修正偏移量

修正ri和ri.base()偏移量的做法

1
2
3
4
5
6
7
8
9
10
set<Widget> s;

typedef set<Widget>::reverse_iterator RIter;

RIter ri;

... //使ri指向v中的元素

s.erase(--ri.base()); //直接修改函数返回的指针不能被直接修改。 如果iterator是基于指针实现的,代码将不具有可以执行。
s.erase((++ri).base()); //具备可移植性的代码

第二十九条: 对于逐个字符的输入请考虑使用istreambuf_iterator

常用的istream_iterator内部使用的operator>>实际上执行了格式化的输入,每一次的operator>>操作都有很多的附加操作

  • 一个内部sentry对象的构造和析构(设置和清理行为的对象)
  • 检查可能影响行为的流标志(比如skipws)
  • 检查可能发生的读取错误
  • 出现错误时检查流的异常屏蔽标志以决定是否抛出异常

对于istreambuf_iterator,它直接从流的缓冲区中读取下一个字符,不存在任何的格式化,所以效率相对istream_iterator要高得多。对于非格式化的输出,也可以考虑使用ostreambuf_iterator代替ostream_iterator。(损失了格式化输出的灵活性)

算法

第三十条: 确保目标区间足够大

下面例子中,希望将一个容器中的内容添加到另一个容器的尾部

1
2
3
4
5
6
7
8
9
int transformogrify(int x); //将x值做一些处理,返回一个新的值

vector<int> values;

vector<int> results;

... //初始化values

transform(values.begin(),values.end(),results.end(),transformogrify);

由于results.end()返回的迭代器指向一段未初始化的内存,上面的代码在运行时会导致无效对象的赋值操作。

可以通过back_inserter或者front_inserter来实现在头尾插入另一个容器中的元素。因为front_inserter的实现是基于push_front操作(vector和string不支持push_front),所以通过front_inserter插入的元素与他们在原来容器中的顺序正好相反,这个时候可以使用reverse_iterator。

1
2
3
4
5
6
7
8
9
10
11
12
13
int transformogrify(int x); //将x值做一些处理,返回一个新的值
vector<int> values;
vector<int> results;

... //初始化values
transform(values.begin(),values.end(),back_inserter(results),transformogrify);
int transformogrify(int x); //将x值做一些处理,返回一个新的值

deque<int> values;
deque<int> results;

... //初始化values
transform(values.rbegin(),values.rend(),front_inserter(results),transformogrify);

另外可以使用inserter在results的任意位置插入元素

1
2
3
4
5
6
7
int transformogrify(int x); //将x值做一些处理,返回一个新的值

vector<int> values;
vector<int> results;

... //初始化values
transform(values.begin(),values.end(),inserter(results,results.begin()+results.size()/2),transformogrify); //插入中间 

书中提到“但是,如果该算法执行的是插入操作,则第五条中建议的方案(使用区间成员函数)并不适用”,不知是翻译的问题还是理解不到位,为什么插入操作不能用区间成员函数替换? 在我看来是因为区间成员函数并不支持自定义的函数对象,而这又跟插入操作有什么关系呢?莫非删除可以???

如果插入操作的目标容器是vector或string,可以通过reserve操作来避免不必要的容器内存重新分配。

1
2
3
4
5
6
7
8
int transformogrify(int x); //将x值做一些处理,返回一个新的值

vector<int> values;
vector<int> results;

//... //初始化values
results.reserve(values.size()+results.size()); //预留results和values的空间
transform(values.begin(),values.end(),back_inserter(results),transformogrify);

如果操作的结果不是插入而是替换目标容器中的元素,可以采用下面的两种方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int transformogrify(int x); //将x值做一些处理,返回一个新的值

vector<int> values;

vector<int> results;

//... //初始化values

results.resize(values.size()); //想想对于results.size() > values.size() 和results.size() < values.size()两种情况
transform(values.begin(),values.end(),results.begin(),transformogrify);
int transformogrify(int x); //将x值做一些处理,返回一个新的值

vector<int> values;
vector<int> results;

//... //初始化values

results.clear(); //results.size()为,results.capacity()不变
results.reserve(values.size()); //相对于上一种方式,如果values.size()小于原来的results.size(),那么会空余出一些元素的内存。
transform(values.begin(),values.end(),results.begin(),transformogrify);

第三十一条: 了解各种与排序有关的选择

对vector、string、deque或数组中的元素执行一次完全排序,可以使用sort或stable_sort

1
2
3
4
5
6
7
vector<int> values;
values.push_back(4);
values.push_back(1);
values.push_back(2);
values.push_back(5);
values.push_back(3);
sort(values.begin(),values.end()); // 1,2,3,4,5

对vector、string、deque或数组中的元素选出前n个进行并对这n个元素进行排序,可以使用partial_sort

1
partial_sort(values.begin(),values.begin()+2,values.end()); // 1,2,4,5,3 注意第二个参数是一个开区间

对vector、string、deque或数组中的元素,要求找到按顺序排在第n个位置上的元素,或者找到排名前n的数据,但并不需要对这n个数据进行排序,这时可以使用nth_element

1
nth_element(values.begin(),values.begin()+1,values.end()); // 1,2,3,4,5 注意第二个参数是一个闭区间

这个返回的结果跟我期望的有些差距,期望的返回值应该是1,2,4,5,3。VC10编译器

对于标准序列容器(这回包含了list),如果要将其中元素按照是否满足某种特定的条件区分开来,可以使用partition或stable_partition

1
2
vector<int>::iterator firstIteratorNotLessThan3 = partition(values.begin(),values.end(),lessThan3); //返回值为 2,1,4,5,3
vector<int>::iterator firstIteratorNotLessThan3 = stable_partition(values.begin(),values.end(),lessThan3); //返回值为 1,2,4,5,3

对于list而言,它的成员函数sort保证了可以stable的对list中元素进行排序。对于nth_element和partition操作,有三种替代方案:

  • 将list中的元素拷贝到提供随机访问迭代器的容器中,然后执行相应的算法
  • 创建一个list::iterator的容器,在对容器执行相应的算法
  • 利用一个包含迭代器的有序容器的信息,反复调用splice成员函数,将list中的成员调整到相应的位置。

第三十二条: 如果确实要删除元素,请确保在remove这一类算法以后调用erase

remove算法接受两个迭代器作为参数,这两个迭代器指定了需要进行操作的区间。Remove并不知道它所操作的容器,所以并不能真正的将容器中的元素删除掉。

1
2
3
4
5
6
7
8
vector<int> values;

for(int i=0; i<10; i++) {
values.push_back(i);
}

values[3] = values[5] = values[9] = 99;
remove(values.begin(),values.end(),99); // 0,1,2,4,6,7,8,7,8,99

从上面的代码可见,remove并没有删除所有值为99的元素,只不过是用后面元素的值覆盖了需要被remove的元素的值,并一一填补空下来的元素的空间,对于最后三个元素,并没有其他的元素去覆盖他们的值,所以仍然保留原值。

上图可以看出,remove只不过是用后面的值填补了空缺的值,但并没有将容器中的元素删除,所以在remove之后,要调用erase将不需要的元素删除掉。

1
values.erase(remove(values.begin(),values.end(),99),values.end()); // 0,1,2,4,6,7,8

类似于remove的算法还有remove_if和unique, 这些算法都没有真正的删除元素,习惯用法是将它们作为容器erase成员函数的第一个参数。List是容器中的一个例外,它有remove和unique成员函数,而且可以从容器中直接删除不需要的元素。

第三十三条: 对于包含指针的容器使用remove这一类算法时要特别小心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget{
public:
...
bool isCertified() const;
...
};

vector<Widget*> v;

for(int i=0; i<10; i++)
{
v.push_back(new Widget());
}

v.erase(remove_if(v.begin(),v.end(),not1(mem_fun(&Widget::isCertified))),v.end());

上面的代码可能会造成内存泄漏

避免内存泄漏的方式有两种,第一种是先将需要被删除的元素的指针删除并设置为空,然后再删除容器中的空指针。第二种方式更为简单而且直观,就是使用智能指针。

方案1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void delAndNullifyUncertified(Widget*& pWidget)
{
if(!pWidget->isCertified())
{
delete pWidget;
pWidget = 0;
}
}

vector<Widget*> v;

for(int i=0; i<10; i++)
{
v.push_back(new Widget());
}

for_each(v.begin(),v.end(),delAndNullifyUncertified);

v.erase(remove(v.begin(),v.end(),static_cast<Widget*>(0)),v.end());

方案2

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
class RCSP{...}; // Reference counting smart pointer

typedef RSCP<Widget> RSCPW;

vector<RSCPW> v;

for(int i=0; i<10; i++)
{
v.push_back(RSCPW(new Widget()));
}

v.erase(remove_if(v.begin(),v.end(),not1(mem_fun(&Widget::isCertified))),v.end());

第三十四条: 了解哪些算法要求使用排序的区间作为参数

  • 用于查找的算法binary_search, lower_bound, upper_boundequal_range采用二分法查找数据,所以数据必须是事先排好序的。对于随机访问迭代器,这些算法可以保证对数时间的查找效率,对于双向迭代器,需要线性时间
  • set_union, set_intersection, set_differenceset_symmetric_difference提供了线性时间的集合操作。排序的元素是线性效率的前提。
  • merge和inplace_merge实现了合并和排序的联合操作。读入两个排序的区间,合并成一个新的排序区间。具有线性时间的性能。
  • includes,判断一个区间中的元素是否都在另一个区间之中。具有线性的时间性能。
  • unique和unique_copy不一定需要排序的区间,但一般来说只有针对排序的区间才能删除所有的重复数据,否则只是保留相邻的重复数据中的第一个。

针对一个区间的进行多次算法的操作,要保证这些算法的排序方式是一致的。(比如都是升序或都是降序)

第三十五条: 通过mismatch和lexicographical_compare实现简单的忽略大小写的字符串比较

Mismatch的作用在于找出两个区间中第一个对应值不同的位置。 要实现忽略大小写的字符串比较,可以先找到两个字符串中第一个不同的字符,然后通过比较这两个字符的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int ciStringCompareImpl(const string& s1, const string& s2)
{
typedef pair<string::const_iterator, string::const_iterator> PSCI; //pair of string::const_iterator

PSCI p = mismatch(s1.begin(),s1.end(),s2.begin(),not2(ptr_fun(ciCharCompare)));

if(p.first == s1.end())
{
if(p.second == s2.end()) return 0;
else return -1;
}

return ciCharCompare(*p.first,*p.second);
}

Lexicograghical_compare是strcmp的一个泛化的版本,strcmp只能与字符数组一起工作,而lexicograghical_compare可以与任何类型的值区间一起工作。

1
2
3
4
5
6
bool charLess(char c1, char c2);

bool ciStringCompair(const string& s1, const string& s2)
{
return lexicographical_compare(s1.begin(),s1.end(),s2.begin(),s2.end(),charLess);
}

第三十六条: 理解copy_if算法的正确实现

标准的STL中并不存在copy_if算法,正确的copy_if算法的实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename InputIterator,
typename OutputIterator,
typename Predicate>
OutputIterator copy_if(InputIterator begin,
InputIterator end,
OutputIterator destBegin,
Predicate p)
{
while(begin != end)
{
if(p(*begin))
{
*destBegin++ = *begin;
++begin;
}

return destBegin;
}
}

第三十七条: 使用accumulate或者for_each进行区间统计

accumulate有两种形式,第一种接受两个迭代器和一个初始值,返回结果是初始值与两个迭代器区间的元素的总和。

1
2
3
vector<int> v;
...
accumulate(v.begin(),v.end(),0);

第二种方式加了一个统计函数,使得accumulate函数变得更加通用。
1
2
3
vector<string> v;
...
accumulate(v.begin(),v.end(),static_cast<string::size_type>(0), StringLegthSum);

accumulate的一个限制是不能产生任何的副作用,这时,for_each就是一个很好的补充。For_each接受三个参数,两个迭代器确定的一个区间,以及统计函数。For_each的返回值是一个函数对象,必须通过调用函数对象中的方法才能够取得统计的值。

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
struct Point
{
Point(double _x, double _y):x(_x),y(_y)
{
}

double x,y;
}

class PointAverge : public unary_function<Point,void>
{
public:
PointAverage(): sum_x(0.0), sum_y(0.0),sum(0)
{
}

void operator()(const Point& p) //可以产生副作用
{
sum++;
sum_x += p.x;
sum_y += p.y;
}

Point GetResult() //用于返回统计结果
{
return Point(sum_x/sum, sum_y/sum);
}

private:

double sum_x, sum_y;
nt sum;
}

vector<Point> v;
...
Point result = for_each(v.begin(),v.end(),PointAverage()).GetResult();

函数子、函数子类、函数及其他

第三十八条 遵循按值传递的原则来设计函数子类

c和C++中 以函数指针为参数的例子,函数指针是按值传递的

1
2
3
void qsort(void* base, size_t nmemb, size_t size,

int(*cmpfcn)(const void *, const void *));

STL函数对象是对函数指针的抽象形式,在STL中函数对象在函数中的传递也是按值传递的。for_each算法的返回值就是一个函数对象,它的第三个参数也是函数对象。
1
2
3
4
template<class InputIterator,
class Function>
Function //按值返回
for_each(InputIterator first, InputIterator second, Function f); //按值传递

因为STL函数对象按值传递的特性,所以在设计函数对象时要:

  • 将函数对象要尽可能的小,以减少拷贝的开销。
  • 函数对象尽量是单态的(不要使用虚函数),以避免剥离问题。

对于复杂的设计而言,具有包含很多信息的和含有继承关系的函数对象也可能难以避免,这时可以采用Bridge 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
template<typename T>
class functorImp :
public unary_function<T,void> {
private :
Widget w;
int x;

public :
virtual ~functorImp();
virtual void operator() (const T& val) const;
friend class functor<T>;
};

template<typename T>
class functor :
public unary_function<T,void> {
private:
functorImp<T> *pImp; //唯一的一个数据成员

public:
void operator() (const T& val) const
{
pImp->operator()(val); //调用重载的operator
}
};

函数对象本身只包含一个指针,而且是不含虚函数的单态对象。真正的数据和操作都是由指针所指向的对象完成的。对于这个实现,要注意的是在函数对象拷贝的过程中,如何维护这个指针成员。既能避免内存泄漏而且可以保证指针有效性的智能指针是个不错的选择。

1
shared_ptr<functorImp<T> *> pImp;

第三十九条 确保判别式是纯函数

判别式的一些基本概念:

  • 判别式 - 返回值为bool类型或者可以隐式转换为bool类型的函数
  • 纯函数 - 返回值仅与函数的参数相关的函数
  • 判别式类 – operator()函数是判别式的函数子类。 STL中凡是能接受判别式的地方,就可以接受一个判别式类的对象。

对于判别式不是纯函数的一个反例

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 Remove3rdElement 
: public unary_function<int,bool> {
public:

Remove3rdElement():i(0){}

bool operator() (const int&)
{
return ++i == 3;
}

int i;
};
...
vector<int> myvector;
vector<int>::iterator it;

myvector.push_back(1);
myvector.push_back(2);
myvector.push_back(3);
myvector.push_back(4);
myvector.push_back(5);
myvector.push_back(6);
myvector.push_back(7);
myvector.erase(remove_if(myvector.begin(), myvector.end(), Remove3rdElement()),myvector.end()); // 1,2,4,5,7 remove_if之后的结果为 1,2,4,5,7,6,7。 返回值指向的是第六个元素。

第四十条 如果一个类是函数子,应该使它可配接

STL中四个标准的函数配接器(not1, not2, bind1st, bind2nd)要求其使用的函数对象包含一些特殊的类型定义,包含这些类型定义的函数对象称作是可配接的函数对象。下面的代码无法通过编译:

1
2
3
4
5
6
bool isWanted(const int i)

...

vector<int> myvector;
vector<int>::iterator it = find_if(myvector.begin(), myvector.end(), not1(isWanted)); // error C2955: 'std::unary_function' : use of class template requires template argument list

从上面的错误可以看出,这个isWanted函数指针不能被not1使用,因为缺少了一些模板参数列表。ptr_fun的作用就在于给予这个函数指针所需要的类型定义从而使之可配接。

1
vector<int>::iterator it = find_if(myvector.begin(), myvector.end(), not1(ptr_fun(isWanted)));

这些特殊的类型定义包括: argument_type``first_argument_type``second_argument_type``result_type,提供这些类型定义最简单的方式是是函数对象的类从特定的模板继承。

如果函数子类的operator方法只有一个实参,那么应该从unary_function继承;如果有两个实参,应该从binary_function继承。对于unary_function和binary_function,必须指定参数类型和返回值类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
class functor : public unary_function<int, bool>
{
public :
bool operator()(int);
};

template<typename T>
class functor2 : public binary_function<int, double, bool>
{
public :
bool operator()(int, double, bool);
};

对于operator方法的参数:

  • operator的参数如果是非指针类型的,传递给unary_function和binary_function的参数需要去掉const和引用&符号
  • operator的参数如果是指针类型的,传递给unary_function和binary_function的参数要与operator的参数完全一致。

第四十一条 理解ptr_fun、mem_fun和mem_fun_reference的来由

对于ptr_fun在第40条已经有了一些介绍,它可以用在任何的函数指针上来使其可配接。下面的例子,希望在myvector和myvector2的每一个元素上调用元素的成员函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget
{
public :
void test();
};

...

vector<Widget> myvector;
vector<Widget*> myvector2;

...

for_each(myvector.begin(),myvector.end(), &Widget::test); // 编译错误
for_each(myvector2.begin(),myvector2.end(), &Widget::test); //编译错误

而for_each的实现可能是这样的

1
2
3
4
5
6
template<typename InputIterator, typename Function>
Function for_each(InputIterator begin, InputIterator end, Function f)
{
while (begin != end)
f(*begin++);
} 

对于mem_fun和mem_fun_reference, 就是要使成员方法可以作为合法的函数指针传递

1
2
for_each(myvector.begin(),myvector.end(), mem_fun_ref(&Widget::test)); // 当容器中的元素为对象时使用mem_fun_ref
for_each(myvector2.begin(),myvector2.end(), mem_fun(&Widget::test)); // 当容器中的元素为指针时,使用mem_fun

那么mem_fun是如何实现的呢?
1
2
3
template<typename R, typename C>
mem_fun_t<R,C>
mem_fun(R(C::*pmf)());

mem_fun接受一个返回值为R且不带参数的C类型的成员函数,并返回一个mem_fun_t类型的对象。mem_fun_t是一个函数子类,拥有成员函数的指针,并提供了operator()接口。operator中调用了通过参数传递进来的对象上的成员函数。

第四十二条 确保less与operator<具有相同的语义

STL规定,less总是等价于operator<, operator<是less的默认实现。

应当尽量避免修改less的行为,而且要确保它与operator<具有相同的意义。如果希望以一种特殊的方式来排序对象,那么就去创建一个新的函数子类,它的名字不能是less.

在程序中使用STL

第四十三条:算法调用优先于手写的循环

算法往往作用于一对迭代器所指定的区间中的每一个元素上,所以算法的内部实现是基于循环的。虽然说类似于find和find_if的算法可能不会遍历所有的元素就返回了结果,但是在极端情况下,还是需要遍历全部的元素。

第四十四条:容器的成员函数优于同名的算法

  • 成员函数速度优于同名算法
  • 成员函数与容器的联系更加紧密

对于关联容器请看下面的例子:

1
2
3
set<int> s;
set<int>::iterator i1 = s.find(727);
set<int>::iterator i2 = find(s.begin(), s.end(), 727);

对于set而言,它的find成员函数的时间复杂度是log(n),而算法find的时间复杂度是线性的n。明显,成员函数的效率要远高于算法。另外,算法是基于相等性而关联容器基于等价性,在这种情况下,调用成员函数和调用算法可能会得到不同的结果。(参见第19条)对于map以及multimap,成员函数之针基于key进行操作,而算法基于key-value pair进行操作。对于list而言,成员函数相对于算法的优势更加明显。算法是基于元素的拷贝的,而list成员函数可能只需要修改指针的指向。还有之前所提到的list的remove成员函数,同时起到了remove和erase的作用。

有些算法,例如sort并不能应用在list上,因为sort是基于随机访问迭代器的。还有merge算法,它要求不能修改源区间,而merge成员函数总是在修改源链表的元素的指针指向。

第四十五条:正确区分count、find、binary_search、lower_bound、upper_bound和equal_range

  • count: 区间内是否存在某个特定的值,如果存在的话,这个值有多少个拷贝。
  • find: 区间内时候存在某个特定的值,如果存在的话,第一个符合条件的值在哪里。
  • binary_search:一个排序的区间内是否存在一个特定的值。
  • lower_bound:返回一个迭代器,或者指向第一个满足条件的元素,或者指向适合于该值插入的位置。切记lower_bound是基于等价性的,用相等性来比较lower_bound的返回值和目标元素是存在潜在风险的。
  • upper_bound:返回一个迭代器,指向最后一个满足条件元素的后面一个元素。
  • equal_range:返回一对迭代器,第一个指向lower_bound的返回值,第二个指向upper_bound的返回值。如果两个返回值指向同一位置,则说明没有符合条件的元素。Lower_bound与upper_bound的distance可以求得符合条件的元素的个数。

下表总结了在什么情况下使用什么样的算法或成员函数

对于multi容器来说,find并不能保证找出的元素是第一个具有此值的元素。如果希望找到第一个元素,必须通过lower_bound,然后在通过等价性的验证。Equal_range是另外一种方式,而且可以避免等价性测试,只是equal_range的开销要大于lower_bound。

第四十六条:考虑使用函数对象而不是函数作为STL算法的参数

函数对象优于函数的第一个原因在于函数对象的operator方法可以被优化为内联函数,从而使的函数调用的开销在编译器被消化。而编译器并没有将函数指针的间接调用在编译器进行优化,也就是说,函数作为STL算法的参数相对于函数对象而言,具有函数调用的开销。

第二个理由是某些编译器对于函数作为STL的参数支持的并不好。

第三个理由是有助于避免一些微妙的、语言本身的缺陷。比如说实例化一个函数模板,可能会与其他已经预定义的函数产生冲突。

第四十七条:避免产生“直写型”(write-only)的代码

根据以往的经验,代码被阅读的次数要远远多于被编写的次数,所以要有意识的写出具备可读性的代码。对于STL而言,则是尽量避免“直写型”的代码。

直写型的代码是这样的,对于程序的编写者而言,它显得非常的直接,并且每一步都符合当初设计的逻辑。但是对于程序的阅读者来说,在没有全面了解程序编写者动机的前提下,这样的代码往往让人一头雾水。

1
v.erase(remove_if(find_if(v.rbegin(),v.rend(),bind2nd(greater_equaql<int>(),y)).base()),v.end(),bind2nd(less<int>(),x));

比较易读的写法最好是这样的
1
2
3
4
5
6
7
// 初始化range_begin,使它指向v中大于等于y的最后一个元素之后的那个元素
// 如果不存在这样的元素,则rangeBegin被初始化为v.begin()
// 如果这个元素恰好是v的最后一个元素,则range_begin将被初始化为v.end()
VecIt rangeBegin = find_if(v.rbegin(),v.rend(),bind2nd(greater_equal<int>(),y)).base();

// 从rangeBegin到v.end()的区间中,删除所有小于x的值
v.erase(remove_if(rangeBegin,v.end(),bind2nd(less<int>(),x)),v.end());

第四十八条 总是include正确的头文件

与STL头文件相关的一些总结

  • 几乎所有的STL容器都被声明在与之同名的头文件之中
  • 除了accumulateinner_productadjacent_differencepartial_sum被声明在<numeric>中之外,其他都所有算法都声明在<algorithm>
  • 特殊类型的迭代器,例如isteam_iteratoristreambuf_iterator,都被声明在<iterator>
  • 标准的函数子,比如less<T>,和函数子配接器,比如not1、bind2nd都被声明在<functional>中。

第四十九条 学会分析与STL相关的编译器诊断信息

STL的编译错误信息往往冗长而且难以阅读,通过文本替换将复杂的容器名称替换为简单的代号,可以使得错误信息得到简化。

例如,将std::basic_string<char, std::char_traits<char>, std::allocator<char>>替换为可读性更强的string。

下面列举一些常见的STL错误,以及可能的出错原因

  • Vector和string的迭代器通常就是指针,当错误的使用iterator的时候,编译器的错误信息中可能会包含指针类型的错误。
  • 如果诊断信息提到了back_insert_iterator, front_insert_iterator和insert_iterator,则几乎意味着程序中直接或间接地调用了back_inserter, front_inserter或者是inserter。
  • 输出迭代器以及inserter函数返回的迭代器在赋值操作符内部完成输入或者插入操作,如果有赋值操作符有关的错误信息,可以关注这些迭代器。
  • 如果错误信息来自于算法的内部实现,往往意味着传递给算法的对象使用了错误的类型。
  • 如果在使用一个常见的STL组件,但编译器却不认知,可能是没有包含合适的头文件。

CHAPTER 1 Deducing Types

C++98有一套用于模板类型推导的规则,C++11修改了其中的一些规则并为autodecltype添加了新的规则。类型推导的广泛应用让我们不必再输入那些明显多余的类型,它让C++程序更具适应性,因为在源代码某处修改类型会通过类型推导自动传播到其它地方。但是类型推导也会让代码更复杂,因为由编译器进行的类型推导并不总是如我们期望的那样进行。

如果对于类型推导操作没有一个扎实的理解,要想写出有现代感的C++程序是不可能的。类型推导随处可见:在函数模板调用中,在auto出现的地方,在decltype表达式出现的地方,以及C++14的decltype(auto) 中。

这一章的内容是每个C++程序员都应该掌握的知识。它解释了模板类型推导是如何工作的,auto是如何依赖模板类型推导的,以及decltype是如何按照它自己那套独特的规则工作的。它甚至解释了你该如何强制编译器产生他进行类型推导的结果,这能让你确认编译器的类型推导是否按照你期望的那样进行。

Item 1 :Understand template type deduction

条款一:理解模板类型推导

对于一个复杂系统的用户来说很多时候他们最关心的是它做了什么而不是它怎么做的。在这一点上C++中的模板类型推导表现得非常出色。数百万的程序员只需要向模板函数传递实参就能通过编译器的类型推导获得令人满意的结果,尽管他们中的大多数对于传递给函数的那些实参是如何引导编译器进行类型推导的只能给出非常模糊的描述,而且还是在被逼无奈的情况。

如果那些人中包括你,我有一个好消息和一个坏消息。好消息是现在C++最重要最吸引人的特性auto是建立在模板类型推导的基础上的,如果你熟悉C++98的模板类型推导,那么你不必害怕C++11的auto。坏消息是虽然auto是建立在模板类型推导的基础上,但是在某些情况下auto不如模板类型推导那么直观容易理解。这个条款便包含了你需要知道的关于模板类型推导的全部内容:

如果你不介意浏览少许伪代码,考虑这样一个函数模板:

1
2
template<typename T>
void f(ParamType param);

它的调用看起来像这样
1
f(expr);    //使用表达式调用f

在编译期间,编译器使用expr进行两个类型推导:一个是针对T的,另一个是针对ParamType的。这两个类型通常是不同的,因为ParamType包括了const和引用的修饰。举个例子,如果模板这样声明:
1
2
template<typename T>
void f(const T& param);

然后这样进行调用
1
2
int x = 0;
f(x); //用一个int类型的变量调用f

T被推导为int,ParamType却被推导为const int&

我们可能很自然的期望T和传递进函数的参数是相同的类型,在上面的例子中,事实就是那样,xint,T是expr的类型即int。但有时情况并非总是如此,T的推导不仅取决于expr的类型,也取决于ParamType的类型。这里有三种情况:

  • ParamType是一个指针或引用,但不是通用引用(关于通用引用请参见Item24。在这里你只需要知道它存在,而且不同于左值引用和右值引用)
  • ParamType一个通用引用
  • ParamType既不是指针也不是引用

我们下面将分成三个情景来讨论这三种情况,每个情景的都基于我们之前给出的模板:

1
2
3
4
template<typename T>
void f(ParamType param);

f(expr); //从expr中推导T和ParamType

情景一:ParamType是一个指针或引用但不是通用引用

最简单的情况是ParamType是一个指针或者引用但非通用引用,也就是我们这个情景讨论的内容。在这种情况下,类型推导会这样进行:

  1. 如果expr的类型是一个引用,忽略引用部分
  2. 然后剩下的部分决定T,然后T与形参匹配得出最终ParamType

举个例子,如果这是我们的模板

1
2
template<typename T>
void f(T & param); //param是一个引用

我们声明这些变量:
1
2
3
int x=27;		//x是int
const int cx=x; //cx是const int
const int & rx=cx; //rx是指向const int的引用

当把这些变量传递给f时类型推导会这样
1
2
3
f(x);		//T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int &
f(rx); //T是const int,param的类型是const int &

在第二个和第三个调用中,注意因为cx和rx被指定为const值,所以T被推导为const int,从而产生了const int&类型的param。这对于调用者来说很重要,当他们传递一个const对象给一个引用类型的参数时,他们传递的对象保留了常量性。这也是为什么向T&类型的参数传递const对象是安全的:对象T的常量性会被保留为T的一部分。

在第三个例子中,注意即使rx的类型是一个引用,T也会被推导为一个非引用 ,这是因为如上面提到的如果expr的类型是一个引用,将忽略引用部分。

这些例子只展示了左值引用,但是类型推导会如左值引用一样对待右值引用。通常,右值只能传递给右值引用,但是在模板类型推导中这种限制将不复存在。

情景二:ParamType一个通用引用

如果ParamType是一个通用引用那事情就比情景一更复杂了。如果ParamType被声明为通用引用(在函数模板中假设有一个模板参数T,那么通用引用就是T&&),它们的行为和T&大不相同,完整的叙述请参见Item24,在这有些最必要的你还是需要知道:

  • 如果expr是左值,T和ParamType都会被推导为左值引用。这非常不寻常,第一,这是模板类型推导中唯一一种T和ParamType都被推导为引用的情况。第二,虽然ParamType被声明为右值引用类型,但是最后推导的结果它是左值引用。
  • 如果expr是右值,就使用情景一的推导规则

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T>
void f(T&& param); //param现在是一个通用引用类型

int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样

f(x); //x是左值,所以T是int&
//param类型也是int&

f(cx); //cx是左值,所以T是const int &
//param类型也是const int&

f(rx); //rx是左值,所以T是const int &
//param类型也是const int&

f(27); //27是右值,所以T是int
//param类型就是int&&

Item24详细解释了为什么这些例子要这样做。这里关键在于类型推导对于通用引用是不同于普通的左值或者右值引用。比如,当通用引用被使用时,类型推导会区分左值实参和右值实参,但是情况一就不会。

情景三:ParamType既不是指针也不是引用

ParamType既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:

1
2
template<typename T>
void f(T param); //以传值的方式处理param

这意味着无论传递什么param都会成为它的一份拷贝——一个完整的新对象。事实上param成为一个新对象这一行为会影响T如何从expr中推导出结果。

  1. 和之前一样,如果expr的类型是一个引用,忽略这个引用部分
  2. 如果忽略引用之后expr是一个const,那就再忽略const。如果它是volatile,也会被忽略(volatile不常见,它通常用于驱动程序的开发中。关于volatile的细节请参见Item40)

因此

1
2
3
4
5
6
7
int x=27;				//如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样

f(x); //T和param都是int
f(cx); //T和param都是int
f(rx); //T和param都是int

注意即使cxrx表示const值,param也不是const。这是有意义的。param是一个拷贝自cxrx且现在独立的完整对象。具有常量性的cxrx不可修改并不代表param也是一样。这就是为什么expr的常量性或易变性(volatileness)在类型推导时会被忽略:因为expr不可修改并不意味着他的拷贝也不能被修改。

认识到只有在传值给形参时才会忽略常量性和易变性这一点很重要,正如我们看到的,对于形参来说指向const的指针或者指向const的引用在类型推导时const都会被保留。但是考虑这样的情况,expr是一个const指针,指向const对象,expr通过传值传递给param

1
2
3
4
5
template<typename T>
void f(T param); //传值

const char* const ptr = //ptr是一个常量指针,指向常量对象
" Fun with pointers";

在这里,解引用符号(*)的右边的const表示ptr本身是一个const:ptr不能被修改为指向其它地址,也不能被设置为null(解引用符号左边的const表示ptr指向一个字符串,这个字符串是const,因此字符串不能被修改)。当ptr作为实参传给f,像这种情况,ptr自身会传值给形参,根据类型推导的第三条规则,ptr自身的常量性将会被省略,所以param是const char* 。也就是说一个常量指针指向const字符串,在类型推导中这个指针指向的数据的常量性将会被保留,但是指针自身的常量性将会被忽略。

数组实参

上面的内容几乎覆盖了模板类型推导的大部分内容,但这里还有一些小细节值得注意,比如在模板类型推导中指针不同于数组,虽然它们两个有时候是完全等价的。关于这个等价最常见的例子是在很多上下文中数组会退化为指向它的第一个元素的指针,比如下面就是允许的做法:

1
2
3
const char name[] = "J. P. Briggs";		//name的类型是const char[13]

const char * ptrToName = name; //数组退化为指针

在这里const char* 指针ptrToName会由name初始化,而name的类型为const char[13],这两种类型(const char * const char[13])是不一样的,但是由于数组退化为指针的规则,编译器允许这样的代码。

但要是一个数组传值给一个模板会怎样?会发生什么?

1
2
3
4
template<typename T>
void f(T param);

f(name); //对于T和param会产生什么样的类型

我们从一个简单的例子开始,这里有一个函数的形参是数组,是的,这样的语法是合法的:
1
void myFunc(int param[]);

但是数组声明会被视作指针声明,这意味着myFunc的声明和下面声明是等价的:
1
void myFunc(int *param);	//同上

这样的等价是C语言的产物,C++又是建立在C语言的基础上,它让人产生了一种数组和指针是等价的的错觉。

因为数组形参会视作指针形参,所以传递给模板的一个数组类型会被推导为一个指针类型。这意味着在模板函数f的调用中,它的模板类型参数T会被推导为const char*

1
f(name);	//name是一个数组,但是T被推导为const char *

但是现在难题来了,虽然函数不能接受真正的数组,但是可以接受指向数组的引用!所以我们修改f为传引用:
1
2
template<typename T>
void f(T& param);

我们这样进行调用
1
f(name);	//传数组

T被推导为了真正的数组!这个类型包括了数组的大小,在这个例子中T被推导为const char[13],param则被推导为const char(&)[13]。是的,这种语法看起来简直有毒,但是知道它将会让你在关心这些问题的人的提问中获得大神的称号。

有趣的是,对模板函数声明为一个指向数组的引用使得我们可以在模板函数中推导出数组的大小:

1
2
3
4
5
6
7
8
//在编译期间返回一个数组大小的常量值(
//数组形参没有名字,因为我们只关心数组
//的大小)
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
return N;
}

在Item15提到将一个函数声明为constexpr使得结果在编译期间可用。这使得我们可以用一个花括号声明一个数组,然后第二个数组可以使用第一个数组的大小作为它的大小,就像这样:
1
2
3
int keyVals[] = {1,3,5,7,9,11,22,25};	//keyVals有七个元素

int mappedVals[arraySize(keyVals)]; //mappedVals也有七个

当然作为一个现代C++程序员,你自然应该想到使用std::array而不是内置的数组:
1
std::array<int,arraySize(keyVals)> mappedVals;		//mappedVals的size为7

至于arraySize被声明为noexcept,会使得编译器生成更好的代码,具体的细节请参见Item14。

函数实参

在C++中不止是数组会退化为指针,函数类型也会退化为一个函数指针,我们对于数组的全部讨论都可以应用到函数来:

1
2
3
4
5
6
7
8
9
10
void someFunc(int, double);	//someFunc是一个函数,类型是void(int,double)

template<typename T>
void f1(T param); //传值

template<typename T>
void f2(T & param); //传引用

f1(someFunc); //param被推导为指向函数的指针,类型是void(*)(int, double)
f2(someFunc); //param被推导为指向函数的引用,类型为void(&)(int, bouel)

这个实际上没有什么不同,但是如果你知道数组退化为指针,你也会知道函数退化为指针。

这里你需要知道:auto依赖于模板类型推导,正如我在开始谈论的,在大多数情况下它们的行为很直接。在通用引用中对于左值的特殊处理使得本来很直接的行为变得有些污点,然而,数组和函数退化为指针把这团水搅得更浑浊。有时你只需要编译器告诉你推导出的类型是什么。这种情况下,翻到item4,它会告诉你如何让编译器这么做。

记住:

  • 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
  • 对于通用引用的推导,左值实参会被特殊对待
  • 对于传值类型推导,实参如果具有常量性和易变性会被忽略
  • 在模板类型推导时,数组或者函数实参会退化为指针,除非它们被用于初始化引用

Item 2:Understand auto type deduction

条款二:理解auto类型推导

如果你已经读过Item1的模板类型推导,那么你几乎已经知道了auto类型推导的大部分内容,至于为什么不是全部是因为这里有一个auto不同于模板类型推导的例外。但这怎么可能,模板类型推导包括模板,函数,形参,但是auto不处理这些东西啊。

你是对的,但没关系。auto类型推导和模板类型推导有一个直接的映射关系。它们之间可以通过一个非常规范非常系统化的转换流程来转换彼此。

在Item1中,模板类型推导使用下面这个函数模板来解释:

1
2
template<typename T>
void f(ParmaType param); //使用一些表达式调用f

在f的调用中,编译器使用expr推导T和ParamType。当一个变量使用auto进行声明时,auto扮演了模板的角色,变量的类型说明符扮演了ParamType的角色。废话少说,这里便是更直观的代码描述,考虑这个例子:
1
auto x = 27;

这里x的类型说明符是auto,另一方面,在这个声明中:
1
const auto cx = x;

类型说明符是const auto。另一个:
1
const auto & rx=cx;

类型说明符是const auto&。在这里例子中要推导x rx cx的类型,编译器的行为看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>	//理想化的模板用来推导x的类型
void func_for_x(T param);

func_for_x(27);

template<typename T> //理想化的模板用来推导cx 的类型
void func_for_cx(const T param);

func_for_cx(x);

template<typename T> //理想化的模板用来推导rx的类型
void func_for_rx(const T & param);

func_for_rx(x);

正如我说的,auto类型推导除了一个例外(我们很快就会讨论),其他情况都和模板类型推导一样。

Item1把模板类型推导分成三个部分来讨论ParamType在不同情况下的类型。在使用auto作为类型说明符的变量声明中,类型说明符代替了ParamType,因此Item1描述的三个情景稍作修改就能适用于auto:

  • 类型说明符是一个指针或引用但不是通用引用
  • 类型说明符一个通用引用
  • 类型说明符既不是指针也不是引用

我们早已看过情景一和情景三的例子:

1
2
3
auto x = 27;            //情景三
const auto cx = x; //情景三
const auto & rx=cx; //情景一

Item1讨论并总结了数组和函数如何退化为指针,那些内容也同样适用于auto类型推导
1
2
3
4
5
6
7
8
9
10
const char name[] =     //name的类型是const char[13]
"R. N. Briggs";

auto arr1 = name; //arr1的类型是const char*
auto& arr2 = name; //arr2的类型是const char(&)[13]

void someFunc(int,double);

auto func1=someFunc; //func1的类型是void(int,double)
auto& func2 = someFunc; //func2的类型是void(&)(int,double)

就像你看到的那样auto类型推断和模板类型推导一样几乎一样的工作,它们就像一个硬币的两面。

讨论完相同点接下来就是不同点,前面我们已经说到auto类型推导和模板类型推导有一个例外使得它们的工作方式不同,接下来我们要讨论的就是那个例外。
我们从一个简单的例子开始,如果你想用一个int值27来声明一个变量,C++98提供两种选择:

1
2
int x1=27;
int x2(27);

C++11由于也添加了用于支持统一初始化(uniform initialization)的语法:
1
2
int x3={27};
int x47{27};

总之,这四种不同的语法只会产生一个相同的结果:变量类型为int值为27

但是Item5解释了使用auto说明符代替指定类型说明符的好处,所以我们应该很乐意把上面声明中的int替换为auto,我们会得到这样的代码:

1
2
3
4
auto x1=27;
auto x2(27);
auto x3={27};
auto x4{27};

这些声明都能通过编译,但是他们不像替换之前那样有相同的意义。前面两个语句确实声明了一个类型为int值为27的变量,但是后面两个声明了一个存储一个元素27的 std::initializer_list 类型的变量。
1
2
3
4
auto x1=27;         //类型是int,值是27
auto x2(27); //同上
auto x3={27}; //类型是std::initializer_list<int>,值是{27}
auto x4{27}; //同上

这就造成了auto类型推导不同于模板类型推导的特殊情况。当用auto声明的变量使用花括号进行初始化,auto类型推导会推导出auto的类型为 std::initializer_list。如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码!
1
auto x5={1,2,3.0};      //错误!auto类型推导不能工作

就像注释说的那样,在这种情况下类型推导将会失败,但是对我们来说认识到这里确实发生了两种类型推导是很重要的。一种是由于auto的使用:x5的类型不得不被推导,因为x5使用花括号的方式进行初始化,x5必须被推导为 std::initializer_list,但是 std::initializer_list是一个模板。
std::initializer_list会被实例化,所以这里T也会被推导。 另一种推导也就是模板类型推导被归入第二种推导。在这个例子中推导之所以出错是因为在花括号中的值并不是同一种类型。

对于花括号的处理是auto类型推导和模板类型推导唯一不同的地方。当使用auto的变量使用花括号的语法进行初始化的时候,会推导出std::initializer_list的实例化,但是对于模板类型推导这样就行不通:

1
2
3
4
5
6
auto x={11,23,9};	//x的类型是std::initializer_list<int>

template<typename T>
void f(T param);

f({11,23,9}); //错误!不能推导出T

然而如果指定T是std::initializer而留下未知T,模板类型推导就能正常工作:
1
2
3
4
template<typename T>
void f(std::initializer_list<T> initList);

f({11,23,9}); //T被推导为int,initList的类型被推导为std::initializer_list<int>

因此auto类型推导和模板类型推导的真正区别在于auto类型推导假定花括号表示std::initializer_list而模板类型推导不会这样(确切的说是不知道怎么办)。

你可能想知道为什么auto类型推导对于花括号和模板类型推导有不同的处理方式。我也想知道。哎,我至今没找到一个令人信服的解释。但是规则就是规则,这意味着你必须记住如果你使用auto声明一个变量,并用花括号进行初始化,auto类型推导总会得出std::initializer_list的结果。如果你使用uniform initialization(花括号的方式进行初始化)用得很爽你就得记住这个例外以免犯错,在C++11编程中一个典型的错误就是偶然使用了std::initializer_list类型的变量,这个陷阱也导致了很多C++程序员抛弃花括号初始化,只有不得不使用的时候再做考虑。

对于C++11故事已经说完了。但是对于C++14故事还在继续,C++14允许auto用于函数返回值并会被推导(参见Item3),而且C++14的lambda函数也允许在形参中使用auto。但是在这些情况下虽然表面上使用的是auto但是实际上是模板类型推导的那一套规则在工作,所以说下面这样的代码不会通过编译:

1
2
3
4
auto createInitList()
{
return {1,2,3}; //错误!推导失败
}

同样在C++14的lambda函数中这样使用auto也不能通过编译:
1
2
3
4
5
std::vector<int> v;

auto resetV = [&v](const auto & newValue){v=newValue;}; //C++14
...
reset({1,2,3}); //错误!推导失败

记住:

  • auto类型推导通常和模板类型推导相同,但是auto类型推导假定花括号初始化代表std::initializer_list而模板类型推导不这样做
  • 在C++14中auto允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案。

Item 3: Understand decltype

条款三:理解decltype

decltype是一个奇怪的东西。给它一个名字或者表达式decltype就会告诉你名字或者表达式的类型。通常,它会精确的告诉你你想要的结果。但有时候它得出的结果也会让你挠头半天最后只能网上问答求助寻求解释。

我们将从一个简单的情况开始,没有任何令人惊讶的情况。相比模板类型推导和auto类型推导,decltype只是简单的返回名字或者表达式的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const int i=0;                         //decltype(i)是const int

bool f(const Widget& w); //decltype(w)是const Widget&
//decltype(f)是bool(const Widget&)

struct Point{
int x; //decltype(Point::x)是int
int y; //decltype(Point::y)是int
};

template<typename T>
class Vector{
...
T& operator[](std::size_t index);
...
}
vector<int> v; //decltype(v)是vector<int>
...
if(v[0]==0) //decltype(v[0])是int&

看见了吧?没有任何奇怪的东西。

在C++11中,decltype最主要的用途就是用于函数模板返回类型,而这个返回类型依赖形参。举个例子,假定我们写一个函数,一个参数为容器,一个参数为索引值,这个函数支持使用方括号的方式访问容器中指定索引值的数据,然后在返回索引操作的结果前执行认证用户操作。函数的返回类型应该和索引操作返回的类型相同。

对一个T类型的容器使用operator[] 通常会返回一个T&对象,比如std::deque就是这样,但是std::vector有一个例外,对于std::vectoroperator[]不会返回bool&,它会返回一个有名字的对象类型(译注:MSVC的STL实现中返回的是std::_Vb_reference>>)。关于这个问题的详细讨论请参见Item6,这里重要的是我们可以看到对一个容器进行operator[]操作返回的类型取决于容器本身。

使用decltype使得我们很容易去实现它,这是我们写的第一个版本,使用decltype计算返回类型,这个模板需要改良,我们把这个推迟到后面:

1
2
3
4
5
6
7
template<typename Container,typename Index>
auto authAndAccess(Container& c,Index i)
->decltype(c[i])
{
authenticateUser();
return c[i];
}

函数名称前面的auto不会做任何的类型推导工作。相反的,他只是暗示使用了C++11的尾置返回类型语法,即在函数形参列表后面使用一个-> 符号指出函数的返回类型,尾置返回类型的好处是我们可以在函数返回类型中使用函数参数相关的信息。在authAndAccess函数中,我们指定返回类型使用c和i。如果我们按照传统语法把函数返回类型放在函数名称之前, c和i就未被声明所以不能使用。

在这种声明中,authAndAccess函数返回operator[] 应用到容器中返回的对象的类型,这也正是我们期望的结果。

C++11允许自动推导单一语句的lambda表达式的返回类型, C++14扩展到允许自动推导所有的lambda表达式和函数,甚至它们内含多条语句。对于authAndAccess来说这意味着在C++14标准下我们可以忽略尾置返回类型,只留下一个auto。在这种形式下auto不再进行auto类型推导,取而代之的是它意味着编译器将会从函数实现中推导出函数的返回类型。

1
2
3
4
5
6
template<typename Container,typename Index> //C++ 14版本
auto authAndAccess(Container& c,Index i)
{
authenticateUser();
return c[i];
}

Item2解释了函数返回类型中使用auto编译器实际上是使用的模板类型推导的那套规则。如果那样的话就会这里就会有一些问题,正如我们之前讨论的,operator[] 对于大多数T类型的容器会返回一个T& ,但是Item1解释了在模板类型推导期间,如果表达式是一个引用那么引用会被忽略。基于这样的规则,考虑它会对下面用户的代码有哪些影响:
1
2
3
4
5
std::deque<int> d;
...
authAndAccess(d,5)=10; //认证用户,返回d[5],
//然后把10赋值给它
//无法通过编译器!

在这里d[5]本该返回一个int& ,但是模板类型推导会剥去引用的部分,因此产生了int返回类型。函数返回的值是一个右值,上面的代码尝试把10赋值给右值,C++11禁止这样做,所以代码无法编译。

要想让authAndAccess像我们期待的那样工作,我们需要使用decltype类型推导来推导它的返回值,比如指定authAndAccess应该返回一个和c[i] 表达式类型一样的类型。C++期望在某些情况下当类型被暗示时需要使用decltype类型推导的规则,C++14通过使用decltype(auto) 说明符使得这成为可能。我们第一次看见decltype(auto) 可能觉得非常的矛盾,(到底是decltype还是auto?),实际上我们可以这样解释它的意义:auto说明符表示这个类型将会被推导,decltype说明decltype的规则将会引用到这个推导过程中。因此我们可以这样写authAndAccess

1
2
3
4
5
6
7
template<typename Container,typename Index>
decltype(auto)
authAndAccess(Container& c,Index i)
{
authenticateUser();
return c[i];
}

现在authAndAccess将会真正的返回c[i]的类型。现在事情解决了,一般情况下c[i]返回T& ,authAndAccess也会返回
T&,特殊情况下c[i]返回一个对象,authAndAccess也会返回一个对象。

decltype(auto) 的使用不仅仅局限于函数返回类型,当你想对初始化表达式使用decltype推导的规则,你也可以使用:

1
2
3
4
5
6
7
8
Widget w;

const Widget& cw = w;

auto myWidget1 = cw; //auto类型推导
//myWidget1的类型为Widget
decltype(auto) myWidget2 = cw; //decltype类型推导
//myWidget2的类型是const Widget&

但是这里有两个问题困惑着你。一个是我之前提到的authAndAccess的改良至今都没有描述。让我们现在加上它。

再看看C++14版本的authAndAccess:

1
2
template<typename Container,typename Index>
decltype(auto) authAndAccess(Container& c,Index i);

容器通过传引用的方式传递非常量左值引用,因为返回一个引用允许用户可以修改容器。但是这意味着在这个函数里面不能传值调用,右值不能被绑定到左值引用上(除非这个左值引用是一个const,但是这里明显不是)。

公认的向authAndAccess传递一个右值是一个edge case。一个右值容器,是一个临时对象,通常会在authAndAccess调用结束被销毁,这意味着authAndAccess返回的引用将会成为一个悬置的(dangle)引用。但是使用向authAndAccess传递一个临时变量也并不是没有意义,有时候用户可能只是想简单的获得临时容器中的一个元素的拷贝,比如这样:

1
2
3
4
std::deque<std::string> makeStringDeque();      //工厂函数

//从makeStringDeque中或得第五个元素的拷贝并返回
auto s = authAndAccess(makeStringDeque(),5);

要想支持这样使用authAndAccess我们就得修改一下当前的声明使得它支持左值和右值。重载是一个不错的选择(一个函数重载声明为左值引用,另一个声明为右值引用),但是我们就不得不维护两个重载函数。另一个方法是使authAndAccess的引用可以绑定左值和右值,Item24解释了那正是通用引用能做的,所以我们这里可以使用通用引用进行声明:
1
2
template<typename Containter,typename Index>
decltype(auto) authAndAccess(Container&& c,Index i);

在这个模板中,我们不知道我们操纵的容器的类型是什么,那意味着我们相当于忽略了索引对象的可能,对一个未知类型的对象使用传值是通常对程序的性能有极大的影响在这个例子中还会造成不必要的拷贝,还会造成对象切片行为,以及给同事落下笑柄。但是就容器索引来说,我们遵照标准模板库对于对于索引的处理是有理由的,所以我们坚持传值调用。

然而,我们还需要更新一下模板的实现让它能听从Item25的告诫应用std::forward实现通用引用:

1
2
3
4
5
6
template<typename Container,typename Index>     //最终的C++14版本
decltype(auto)
authAndAccess(Container&& c,Index i){
authenticateUser();
return std::forward<Container>(c)[i];
}

这样就能对我们的期望交上一份满意的答卷,但是这要求编译器支持C++14。如果你没有这样的编译器,你还需要使用C++11版本的模板,它看起来和C++14版本的极为相似,除了你不得不指定函数返回类型之外:
1
2
3
4
5
6
7
8
template<typename Container,typename Index>     //最终的C++11版本
auto
authAndAccess(Container&& c,Index i)
->decltype(std::forward<Container>(c)[i])
{
authenticateUser();
return std::forward<Container>(c)[i];
}

另一个问题是就像我在条款的开始唠叨的那样,decltype通常会产生你期望的结果,但并不总是这样。在极少数情况下它产生的结果可能让你很惊讶。老实说如果你不是一个大型库的实现者你不太可能会遇到这些异常情况。

为了完全理解decltype的行为,你需要熟悉一些特殊情况。它们大多数都太过晦涩以至于几乎没有书进行有过权威的讨论,这本书也不例外,但是其中的一个会让我们更加理解decltype的使用。

对一个名字使用decltype将会产生这个名字被声明的类型。名字是左值表达式,但那不影响decltype的行为,decltype确保产生的类型总是左值引用。换句话说,如果一个左值表达式除了名字外还有类型,那么decltype将会产生T&LEIX .这几乎没有什么太大影响,因为大多数左值表达式的类型天生具备一个左值引用修饰符。举个例子,函数返回左值,几乎也返回了左值引用。

这个行为暗含的意义值得我们注意,在:

1
int x =0;

中,x是一个变量的名字,所以decltype(x)int。但是如果用一个小括号包覆这个名字,比如这样(x) ,就会产生一个比名字更复杂的表达式。对于名字来说,x是一个左值,C++11定义了表达式(x) 也是一个左值。因此decltype((x))int& 。用小括号覆盖一个名字可以改变decltype对于名字产生的结果。

在C++11中这稍微有点奇怪,但是由于C++14允许了decltype(auto) 的使用,这意味着你在函数返回语句中细微的改变就可以影响类型的推导:

1
2
3
4
5
6
7
8
9
10
11
12
decltype(auto) f1()
{
int x = 0;
...
return x; //decltype(x)是int,所以f1返回int
}

decltype(auto) f2()
{
int x =0l;
return (x); //decltype((x))是int&,所以f2返回int&
}

注意不仅f2的返回类型不同于f1,而且它还引用了一个局部变量!这样的代码将会把你送上未定义行为的特快列车,一辆你绝对不想上第二次的车。

当使用decltype(auto) 的时候一定要加倍的小心,在表达式中看起来无足轻重的细节将会影响到类型的推导。为了确认类型推导是否产出了你想要的结果,请参见Item4描述的那些技术。

同时你也不应该忽略decltype这块大蛋糕。没错,decltype可能会偶尔产生一些令人惊讶的结果,但那毕竟是少数情况。通常,decltype都会产生你想要的结果,尤其是当你对一个名字使用decltype时,因为在这种情况下,decltype只是做一件本分之事:它产出名字的声明类型。

记住

  • decltype总是不加修改的产生变量或者表达式的类型。
  • 对于T类型的左值表达式,decltype总是产出T的引用即T&
  • C++14支持decltype(auto) ,就像auto一样,推导出类型,但是它使用自己的独特规则进行推导。

Item 4:Know how to view deduced types

条款四:学会查看类型推导结果

选择使用工具查看类型推导取决于软件开发过程中你想在哪个阶段显示类型推导信息,我们探究三种方案:在你编辑代码的时候获得类型推导的结果,在编译期间获得结果,在运行时获得结果

IDE编辑器

在IDE中的代码编辑器通常可以显示程序代码中变量,函数,参数的类型,你只需要简单的把鼠标移到它们的上面,举个例子,有这样的代码中:

1
2
3
4
const int theAnswer = 42;

auto x = theAnswer;
auto y = &theAnswer;

一个IDE编辑器可以直接显示x推导的结果为int,y推导的结果为const int*

为此,你的代码必须或多或少的处于可编译状态,因为IDE之所以能提供这些信息是因为一个C++编译器(或者至少是前端中的一个部分)运行于IDE中。如果这个编译器对你的代码不能做出有意义的分析或者推导,它就不会显示推导的结果。

对于像int这样简单的推导,IDE产生的信息通常令人很满意。正如我们将看到的,如果更复杂的类型出现时,IDE提供的信息就几乎没有什么用了。

编译器诊断

另一个获得推导结果的方法是使用编译器出错时提供的错误消息。这些错误消息无形的提到了造成我们编译错误的类型是什么。
举个例子,假如我们想看到之前那段代码中x和y的类型,我们可以首先声明一个类模板但不定义。就像这样:

1
2
template<typename T>	//只对TD进行声明
class TD; //TD == "Type Displayer"

如果尝试实例化这个类模板就会引出一个错误消息,因为这里没有用来实例化的类模板定义。为了查看x和y的类型,只需要使用它们的类型去实例化TD:
1
2
TD<decltype(x)> xType;	//引出错误消息
TD<decltype(y)> yType; //x和y的类型

我使用variableNameType的结构来命名变量,因为这样它们产生的错误消息可以有助于我们查找。对于上面的代码,我的编译器产生了这样的错误信息,我取一部分贴到下面:
1
2
3
4
error: aggregate 'TD<int> xType' has incomplete type and 
cannot be defined
error: aggregate 'TD<const int *> yType' has incomplete type and
cannot be defined

另一个编译器也产生了一样的错误,只是格式稍微改变了一下:
1
2
error: 'xType' uses undefined class 'TD<int>'
error: 'yType' uses undefined class 'TD<const int *>'

除了格式不同外,几乎所有我测试过的编译器都产生了这样有用的错误消息。

运行时输出

使用printf的方法使类型信息只有在运行时才会显示出来(尽管我不是非常建议你使用printf),但是它提供了一种格式化输出的方法。现在唯一的问题是只需对于你关心的变量使用一种优雅的文本表示。“这有什么难的“,你这样想”这正是typeid和std::type_info::name的价值所在”。为了实现我们我们想要查看x和y的类型的需求,你可能会这样写:

1
2
std::cout<<typeid(x).name()<<"\n";	//显示x和y的类型
std::cout<<typeid(y).name()<<"\n";

这种方法对一个对象如x或y调用typeid产生一个std::type_info的对象,然后std::type_info里面的成员函数name()来产生一个C风格的字符串表示变量的名字。

调用std::type_info::name不保证返回任何有意义的东西,但是库的实现者尝试尽量使它们返回的结果有用。实现者们对于“有用”有不同的理解。举个例子,GNU和Clang环境下x会显示为i,y会显示为PKi,这样的输出你必须要问问编译器实现者们才能知道他们的意义:i表示int,PK表示const to konst(const)。Microsoft的编译器输出得更直白一些:对于x输出“int“对于y输出”int const*“

因为对于x和y来说这样的结果是正确的,你可能认为问题已经接近了,别急,考虑一个更复杂的例子:

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

std::vector<Widget> createVec();

const auto vw = createVec();

if(!vw.empty()){
f(&vw[0]);
...
}

在这段代码中包含了一个用户定义的类型Widget,一个STL容器和一个auto变量vw,这个更现实的情况是你可能在会遇到的并且想获得他们类型推导的结果,比如模板类型参数T,比如函数参数param。

从这里中我们不难看出typeid的问题所在。我们添加一些代码来显示类型:

1
2
3
4
5
6
7
template<typename T>
void f(const T& param){
using std::cout;
cout<<"T= "<<typeid(T).name()<<"\n";
cout<<"param = "<<typeid(param).name()<<"\n";
...
}

GNU和Clang执行这段代码将会输出这样的结果
1
2
T=		PK6Widget
param= PK6Widget

我们早就知道在这些编译器中PK表示“指向常量”,所以只有数字6对我们来说是神奇的。其实数字6是类名称的字符串长度,所以这些编译器高数我们T和param都是const Widget*

Microsoft的编译器也同意上述言论:

1
2
T=		class Widget const *
param= class Widget const *

这三个独立的编译器产生了相同的信息而且非常准确,当然看起来不是那么准确。在模板f中,param的类型是const T&。难道你们不觉得T和param相同类型很奇怪吗?比如T是int,param的类型应该是const int&而不是相同类型才对吧。

遗憾的是,事实就是这样,std::type_info::name的结果并不总是可信的,就像上面一样三个编译器都犯了相同的错误。因为std::type_info::name被批准犯这样的错。正如Item1提到的如果传递的是一个引用,那么引用部分将被忽略,如果忽略后还具有常量性或者易变性,那么常量性或者易变性也会被忽略。那就是为什么const Widget *const &类型会输出const Widget *,首先引用被忽略,然后这个指针自身的常量性被忽略,剩下的就是指针指向一个常量对象。

同样遗憾的是,IDE编辑器显示的类型信息也不总是可靠的,或者说不总是有用的。还是一样的例子,一个IDE编辑器可能会把T的类型显示为

1
2
std::_Simple_types<std::_Wrap_alloc<std::_Vec_base_types<Widget,
std::allocator<Widget>>::_Alloc>::value_type>::value_type *

同样把param的类型显示为
1
const std::_Simple_types<...>::value_type *const&

这个比起T来说要简单一些,但是如果你不知道<…>表示编译器忽略T的类型那么可能你还是会产生困惑。如果你运气好点你的IDE可能表现得比这个要好一些。

比起运气如果你更倾向于依赖库,那么你乐意被告知std::type_info::name不怎么好,Boost TypeIndex Library(通常写作Boost.TypeIndex)是更好的选择。这个库不是标准C++的一部分,也不时IDE或者TD这样的模板。Boost TypeIndex是跨平台,开源,有良好的开源协议的库,这意味着使用Boost和STL一样具有高度可移植性。

这里是如何使用Boost.TypeIndex得到f的类型的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <boost/type_index.hpp>

template<typename T>
void f(const T& param){
using std::cout;
using boost::type_index::type_id_with_cvr;

//显示T
cout<<"T= "
<<type_id_with_cvr<T>().pretty_name()
<<"\n";
//显示param类型
cout<<"param= "
<<type_id_with_cvr<decltype(param)>().pretty_name()
<<"\n";
}

boost::type_index::type_id_with_cvr获取一个类型实参,它不消除实参的常量性,易变性和引用修饰符,然后pretty_name成员函数输出一个我们能看懂的友好内容。
基于这个f的实现版本,再次考虑那个产生错误类型信息的调用:
1
2
3
4
5
6
std::vetor<Widget> createVec();
const auto vw = createVec();
if(!vw.empty()){
f(&vw[0]);
...
}

在GNU和Clang的编译器环境下,使用Boost.TypeIndex版本的f最后会产生下面的输出:
1
2
T=		Widget const *
param= Widget const * const&

在Microsoft的编译器环境下,结果也是极其相似:
1
2
T=		class Widget const *
param= class Widget const * const&

这样近乎一致的结果是很不错的,但是请记住IDE,编译器错误诊断或者Boost.TypeIndex只是用来帮助你理解编译器推导的类型是什么。它们是有用的,但是作为本章结束语我想说它们根本不能让你不用理解Item1-3提到的。

记住

  • 类型推断可以从IDE看出,从编译器报错看出,从一些库的使用看出
  • 这些工具可能既不准确也无帮助,所以理解C++类型推导规则才是最重要的

CHAPTER 2 auto

从概念上来说,auto要多简单有多简单,但是它比看起来要微妙一些。使用它可以存储类型,当然,它也会犯一些错误,而且比之手动声明一些复杂类型也会存在一些性能问题。此外,从程序员的角度来说,如果按照符合规定的流程走,那auto类型推导的一些结果是错误的。当这些情况发生时,对我们来说引导auto产生正确的结果是很重要的,因为严格按照说明书上面的类型写声明虽然可行但是最好避免。

本章简单的覆盖了auto的里里外外。

Item 5:Prefer auto to explicit type declarations

条款五:优先考虑auto而非显式类型声明

哈,开心一下:

1
int x;

等等,该死!我忘记了初始化x,所以x的值是不确定的。它可能会被初始化为0,这得取决于工作环境。哎。

别介意,让我们转换一个话题, 对一个局部变量使用解引用迭代器的方式初始化:

1
2
3
4
5
6
7
8
template<typename It>
void dwim(It b, It e)
{
while(b!=e){
typename std::iterator_traits<It>::value_type
currValue = *b;
}
}

嘿!typename std::iterator_traits<It>::value_type是想表达迭代器指向的元素的值的类型吗?我无论如何都说不出它是多么有趣这样的话,该死!等等,我早就说过了吗?

好吧,声明一个局部变量,变量的类型只有编译后知道,这里必须使用’typename’指定,该死!

该死该死该死,C++编程不应该是这样不愉快的体验。

别担心,它只在过去是这样,到了C++11所有的这些问题都消失了,这都多亏了auto。auto变量从初始化表达式中推导出类型,所以我们必须初始化。这意味着当你在现代化C++的高速公路上飞奔的同时你不得不对只声明不初始化变量的老旧方法说拜拜:

1
2
3
4
5
int x1;				//潜在的未初始化的变量

auto x2; //错误!必须要初始化

auto x3=0; //没问题,x已经定义了

而且即使初始化表达式使用解引用迭代器也不会对你的高速驾驶有任何影响
1
2
3
4
5
6
7
8
template<typename It>
void dwim(It b,It e)
{
while(b!=e){
auto currValue = *b;
...
}
}

因为auto使用Item2所述的auto类型推导技术,它甚至能表示一些只有编译器才知道的类型:
1
2
auto derefUPLess = [](const std::unique_ptr<Widget> &p1,	//专用于Widget类型的比较函数
const std::unique_ptr<Widget> &p2){return *p1<*p2;};

很酷对吧,如果使用C++14,将会变得更酷,因为lambda表达式中的形参也可以使用auto:
1
auto derefUPLess = [](const auto& p1,const auto& p2){return *p1<*p2;};

尽管这很酷,但是你可能会想我们完全不需要使用auto声明局部变量来保存一个闭包,因为我们可以使用std::function对象。
没错,我们的确可以那么做,但是事情可能不是完全如你想的那样。当然现在你可能会问:std::function对象到底是什么,让我来给你解释一下:

std::function是一个C++11标准模板库中的一个模板,它泛化了函数指针的概念。与函数指针只能指向函数不同,std::function可以指向任何可调用对象,也就是那些像函数一样能进行调用的东西。当你声明函数指针时你必须指定函数类型(即函数签名),同样当你创建std::function对象时你也需要提供函数签名,由于它是一个模板所以你需要在它的模板参数里面提供。举个例子,假设你想声明一个std::function对象func使他指向一个可调用对象,比如一个具有这样函数签名的函数:

1
2
bool(const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2);

你就得这么写:
1
2
std::function<bool(const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2)> func;

因为lambda表达式能产生一个可调用对象,所以我们现在可以把闭包存放到std::function对象中。这意味着我们可以不使用auto写出C++11版的dereUPLess
1
2
3
4
std::function<bool(const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2)>
dereUPLess = [](const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2){return *p1<*p2;};

语法冗长不说,还需要重复写很多形参类型,使用std::function还不如使用auto。用auto声明的变量保存一个闭包这个变量将会得到和闭包一样的类型。

实例化std::function并声明一个对象这个对象将会有固定的大小。当使用这个对象保存一个闭包时它可能大小不足不能存储,这个时候std::function的构造函数将会在堆上面分配内存来存储,这就造成了使用std::function比auto会消耗更多的内存。并且通过具体实现我们得知通过std::function调用一个闭包几乎无疑比auto声明的对象调用要慢。
换句话说,std::function方法比auto方法要更耗空间且更慢,并且比起写一大堆类型使用auto要方便得多。在这场存储闭包的比赛中,auto无疑取得了胜利(也可以使用std::bind来生成一个闭包,但在Item34我会尽我最大努力说服你使用lambda表达式代替std::bind)

使用auto除了使用未初始化的无效变量,省略冗长的声明类型,直接保存闭包外,它还有一个好处是可以避免一个问题,我称之为依赖类型快捷方式的问题。你将看到这样的代码——甚至你会这么写:

1
2
std::vector<int> v;
unsigned sz = v.size();

v.size()的标准返回类型是std::vector<int>::size_type,但是很多程序猿都知道std::vector<int>::size_type实际上被指定为无符号整型,所以很多人都认为用unsigned比写那一长串的标准返回类型方便。这会造成一些有趣的结果。
举个例子,在Windows 32-bitstd::vector<int>::size_typeunsigned int都是一样的类型,但是在Windows 64-bitstd::vector<int>::size_type是64位,unsigned int是32位。这意味着这段代码在Windows 32-bit上正常工作,但是当把应用程序移植到Windows 64-bit上时就可能会出现一些问题。
谁愿意花时间处理这些细枝末节的问题呢?

所以使用auto可以确保你的不需要浪费时间:

1
auto sz =v.size();

你还是不相信使用auto是多么明智的选择?考虑下面的代码:
1
2
3
4
5
6
std::unordered_map<std::string,int> m;
...
for(const std::pair<std::string,int>& p : m)
{
...
}

看起来好像很合情合理的表达,但是这里有一个问题,你看到了吗?

要想看到错误你就得知道std::unordered_map的key是一个常量,所以std::pair的类型不是std::pair<std::string,int>而是std::pair<const std::string,int>。编译器会努力的找到一种方法把前者转换为后者。它会成功的,因为它会创建一个临时对象,这个临时对象的类型是p想绑定到的对象的类型,即m中元素的类型,然后把p的引用绑定到这个临时对象上。在每个循环迭代结束时,临时对象将会销毁,如果你写了这样的一个循环,你可能会对它的一些行为感到非常惊讶,因为你确信你只是让成为p指向m中各个元素的引用而已。

使用auto可以避免这些很难被意识到的类型不匹配的错误:

1
2
3
4
for(const auto & p : m)
{
...
}

这样无疑更具效率,且更容易书写。而且,这个代码有一个非常吸引人的特性,如果你把p换成是指向m中各个元素的指针,在没有auto的版本中p会指向一个临时变量,这个临时变量在每次迭代完成时会被销毁!

后面这两个例子说明了显式的指定类型可能会导致你不像看到的类型转换。如果你使用auto声明目标变量你就不必担心这个问题。

基于这些原因我建议你优先考虑auto而非显式类型声明。然而auto也不是完美的。每个auto变量都从初始化表达式中推导类型,有一些表达式的类型和我们期望的大相径庭。关于在哪些情况下会发生这些问题,以及你可以怎么解决这些问题我们在Item2和6讨论,所以这里我不再赘述。我想把注意力放到你可能关心的另一点:使用auto代替传统类型声明对源码可读性的影响。

首先,深呼吸,放松,auto是可选项,不是命令,在某些情况下如果你的专业判断告诉你使用显式类型声明比auto要更清晰更易维护,那你就不必再坚持使用auto。牢记C++没有在其他众所周知的语言所拥有的类型接口上开辟新土地。

其他静态类型的过程式语言(如C#,D,Sacla,Visual Basic等)或多或少的都有那些非静态类型的函数式语言(如ML,Haskell,OCaml.F#等)的特性。在某种程度上,几乎没有显式类型使得动态类型语言Perl,Python,Ruby等取得了成功,软件开发社区对于类型接口有丰富的经验,他们展示了在维护大型工业强度的代码上使用这种技术没有任何争议。

一些开发者也担心使用auto就不能瞥一眼源代码便知道对象的类型,然而,IDE扛起了部分担子,在很多情况下,少量显示一个对象的类型对于知道对象的确切类型是有帮助的,这通常已经足够了。举个例子,要想知道一个对象是容器还是计数器还是智能指针,不需要知道它的确切类型,一个适当的变量名称就能告诉我们大量的抽象类型信息。

真正的问题是显式指定类型可以避免一些微妙的错误,以及更具效率和正确性,而且,如果初始化表达式改变变量的类型也会改变,这意味着使用auto可以帮助我们完成一些重构工作。举个例子,如果一个函数返回类型被声明为int,但是后来你认为将它声明为long会更好,调用它作为初始化表达式的变量会自动改变类型,但是如果你不使用auto你就不得不在源代码中挨个找到调用地点然后修改它们。

记住

  • auto变量必须初始化,通常它可以避免一些移植性和效率性的问题,也使得重构更方便,还能让你少打几个字。
  • 正如Item2和6讨论的,auto类型的变量可能会踩到一些陷阱。

Item 6:Use the explicitly typed initializer idiom when auto deduces undesired types.

条款六:auto推导若非己愿,使用显式类型初始化惯用法

在Item5中解释了比起显式指定类型使用auto声明变量有若干技术优势,但是有时当你想向左转auto却向右转。举个例子,假如我有一个函数,参数为Widget,返回一个std::vector<bool>,这里的bool表示Widget是否提供一个独有的特性。

1
std::vector<bool> features(const Widget& w);

更进一步假设5表示是否Widget具有高优先级,我们可以写这样的代码:
1
2
3
bool highPriority = features(w)[5];
...
processWidget(w,highPriority);

这个代码没有任何问题。它会正常工作,但是如果我们使用auto代替显式指定类型做一些看起来很无害的改变:
1
2
3
auto highPriority = features(w)[5];
...
processWidget(w,highPriority); //未定义行为!

就像注释说的,这个processWidget是一个未定义行为。为什么呢?答案有可能让你很惊讶,使用auto后highPriority不再是bool类型。虽然从概念上来说std::vector<bool>意味着存放bool,但是std::vector<bool>operator[]不会返回容器中元素的引用,取而代之它返回一个std::vector<bool>::reference的对象(一个嵌套于std::vector<bool>中的类)
std::vector<bool>::reference之所以存在是因为std::vector<bool>指定了它作为代理类。operator[]返回一个代理类来扮演bool&。要想成功扮演这个角色,bool&适用的上下文std::vector<bool>::reference也必须一样能适用。基于这个特性std::vector<bool>::reference可以隐式的转化为bool(不是bool&,是bool!要想完整的解释std::vector<bool>::reference能模拟bool&的行为所使用的一堆技术可能扯得太远了,所以这里简单地说隐式类型转换只是这个大型马赛克的一小块)

有了这些信息,我们再来看看原始代码的一部分:

1
bool highPriority = features(w)[5];     //显式的声明highPriority的类型

这里,feature返回一个std::vector<bool>对象后再调用operator[]operator[]将会返回一个std::vector<bool>::reference对象,然后再通过隐式转换赋值给bool变量highPriority。highPriority因此表示的是features返回的vector中的第五个bit,这也正如我们所期待的那样。
然后再对照一下当使用auto时发生了什么:
1
auto highPriority = features(w)[5];     //推导highPriority的类型

同样的,feature返回一个std::vector<bool>对象,再调用operator[]operator[]将会返回一个std::vector<bool>::reference对象,但是现在这里有一点变化了,auto推导highPriority的类型为std::vector<bool>::reference,但是highPriority对象没有第五bit的值。

这个值取决于std::vector<bool>::reference的具体实现。其中的一种实现是这样的(std::vector<bool>::reference)对象包含一个指向word的指针,然后加上方括号中的偏移实现被引用bit这样的行为。然后再来考虑highPriority初始化表达的意思,注意这里假设std::vector<bool>::reference就是刚提到的实现方式。

调用feature将返回一个std::vector,这个对象没有名字,为了方便我们的讨论,我这里叫他temp,operator[]被temp调用,然后然后的std::vector<bool>::reference包含一个指针,这个指针指向一个temp里面的word,加上相应的偏移,。highPriority是一个std::vector<bool>::reference的拷贝,所以highPriority也包含一个指针,指向temp中的一个word,加上合适的偏移,这里是5.在这个语句解释的时候temp将会被销毁,因为它是一个临时变量。因此highPriority包含一个悬置的指针,如果用于processWidget调用中将会造成未定义行为:

1
2
processWidget(w,highPriority);      //未定义行为!
//highPriority包含一个悬置指针

std::vector<bool>::reference是一个代理类的例子:所谓代理类就是以模仿和增强一些类型的行为为目的而存在的类。很多情况下都会使用代理类,std::vector<bool>::reference展示了对std::vector<bool>使用operator[]来实现引用bit这样的行为。另外,C++标准模板库中的智能指针也是用代理类实现了对原始指针的资源管理行为。代理类的功能已被大家广泛接受。事实上,“Proxy”设计模式是软件设计这座万神庙中一直都存在的高级会员。

一些代理类被设计于用以对客户可见。比如std::shared_ptrstd::unique_ptr。其他的代理类则与之相反,比如std::vector<bool>::referencestd::bitset::reference

在后者的阵营里一些C++库也是用了表达式模板的黑科技。这些库通常被用于提高数值运算的效率。给出一个矩阵类Matrix和矩阵对象m1,m2,m3,m4,举个例子,这个表达式

1
Matrix sum = m1 + m2 + m3 + m4;

可以使计算更加高效,只需要使让operator+返回一个代理类代理结果而不是返回结果本身。也就是说,对两个Matrix对象使用operator+将会返回如Sum<Matrix,Matrix>这样的代理类作为结果而不是直接返回一个Matrix对象。在std::vector<bool>::reference和bool中存在一个隐式转换,同样对于Matrix来说也可以存在一个隐式转换允许Matrix的代理类转换为Matrix,这让表达式等号右边能产生代理对象来初始化Sum。客户应该避免看到实际的类型。

作为一个通则,不可见的代理类通常不适用于auto。这样类型的对象的生命期通常不会设计为能活过一条语句,所以创建那样的对象你基本上就走向了违反程序库设计基本假设的道路.std::vector<bool>::reference就是这种情况,我们看到违反这个基本假设将导致未定义行为。

因此你想避开这种形式的代码:

1
auto someVar = expression of "invisible" proxy class type;

但是你怎么能意识到你正在使用代理类?它们被设计为不可见,至少概念上说是这样!每当你发现它们,你真的应该舍弃Item5演示的auto所具有的诸多好处吗?

让我们首先回到如何找到它们的问题上。虽然代理类都在程序员日常使用的雷达下方飞行,但是很多库都证明它们可以上方飞行。当你越熟悉你使用的库的基本设计理念,你的思维就会越活跃,不至于思维僵化认为代理类只能在这些库中使用。

当缺少文档的时候,可以去看看头文件。很少会出现源代码全都用代理对象,它们通常用于一些函数的返回类型,所以通常能从函数签名中看出它们的存在。这里有一份来自C++ STANDARD的说明书:

1
2
3
4
5
6
7
8
9
namespace std{
template<class Allocator>
class vector<bool,Allocator>{
public:
class reference{...};

reference operator[](size_type n);
};
}

假设你知道对std::vector使用operator[]通常会返回一个T&,在这里operator[]不寻常的返回类型提示你它使用了代理类。多关注你使用的接口可以暴露代理类的存在。

实际上, 很多开发者都是在跟踪一些令人困惑的复杂问题或在单元测试出错进行调试时才看到代理类的使用。不管你怎么发现它们的,当你不知道这个类型有没有被代理还想使用auto时你就不能单单只用一个auto。auto本身没什么问题,问题是auto不会推导出你想要的类型。解决方案是强制使用一个不同的类型推导形式,这种方法我通常称之为显式类型初始器惯用法(_the explicitly typed initialized idiom_)

显式类型初始器惯用法使用auto声明一个变量,然后对表达式强制类型转换得出你期望的推导结果。举个例子,我们该怎么将这个惯用法施加到highPriority上?

1
auto highPriority = static_cast<bool>(features(w)[5]);

这里,feature(w)[5]还是返回一个std::vector<bool>::reference对象,就像之前那样,但是这个转型使得表达式类型为bool,然后auto才被用于推导highPriority。在运行时,对std::vector使用operator[]将返回一个std::vector::reference,然后强制类型转换使得它执行向bool的转型,在这个过程中指向std::vector<bool>的指针已经被解引用。这就避开了我们之前的未定义行为。然后5将被用于指向bit的指针,bool值被用于初始化highPriority。

对于Matrix来说,显式类型初始器惯用法是这样的:

1
auto sum = static_cast<Matrix>(m1+m2+m3+m4);

应用这个惯用法不限制初始化表达式产生一个代理类。它也可以用于强调你声明了一个变量类型,它的类型不同于初始化表达式的类型。举个例子,假设你有这样一个表达式计算公差值:
1
double calEpsilon();

calEpsilon清楚的表明它返回一个double,但是假设你知道对于这个程序来说使用float的精度已经足够了,而且你很关心double和float的大小。你可以声明一个float变量储存calEpsilon的计算结果。
1
float ep = calEpsilon();

但是这几乎没有表明“我确实要减少函数返回值的精度”。使用显式类型初始器惯用法我们可以这样:
1
auto ep = static_cast<float>(calEpsilon());

处于同样的原因,如果你故意想用int类型存储一个表达式返回的float类型的结果,你也可以使用这个方法。假如你需要计算一个随机访问迭代器(比如std::vector,std::deque,std::array)中某元素的下标,你给它一个0.0到1.0的值表明这个元素离容器的头部有多远(0.5意味着位于容器中间)。进一步假设你很自信结果下标是int。如果容器是c,d是double类型变量,你可以用这样的方法计算容器下标:
1
int index = d * c.size();

但是这种写法并没有明确表明你想将右侧的double类型转换成int类型,显式类型初始器可以帮助你正确表意:
1
auto index = static_cast<int>(d * size());

记住

  • 不可见的代理类可能会使auto从表达式中推导出“错误的”类型
  • 显式类型初始器惯用法强制auto推导出你想要的结果

CHAPTER 3 Moving to Modern C++

说起知名的特性,C++11/14有一大堆可以吹的东西,auto,智能指针,移动语意,lambda,并发——每个都是如此的重要,这章将覆盖这些内容。
精通这些特性是必要的,但是成为高效率的现代C++程序员也要求一系列小步骤。
从C++98移步C++11/14遇到的每个细节问题都会在本章得到答复。
应该在创建对象时用{}而不是()吗?为什么alias声明比typedef好?constexpr和const有什么不同?常量成员函数和线程安全有什么关系?这个列表越列越多。
这章将会逐个回答这些问题。

Item 7:Distinguish between () and {} when creating objects

条款七:区别使用()和{}创建对象

从不同的角度看,C++11初始化对象的语法选择既丰富得让人尴尬又混乱得让人糊涂。一般来说,初始化值要用()或者{}括起来或者放到”=”的右边:

1
2
3
4
5
int x(0);				//使用小括号初始化

int y = 0; //使用"="初始化

int z{0}; //使用花括号初始化

在很多情况下,你可以使用”=”和花括号的组合:
1
int z = {0};			//使用"="和花括号

在这个条款的剩下部分,我通常会忽略”=”和花括号组合初始化的语法,因为C++通常把它视作和只有花括号一样。
“混乱得令人糊涂”指出在初始化中使用”=”可能会误导C++新手,使他们以为这里是赋值运算符。
对于像int这样的内置类型,研究两者区别是没有多大意义的,但是对于用户定义的类型而言,区别赋值运算符和初始化就非常重要了,因为这可能包含不同的函数调用:
1
2
3
4
5
Widget w1;              //调用默认构造函数

Widget w2 = w1; //不是赋值运算符,调用拷贝构造函数

w1 = w2; //是一个赋值运算符,调用operator=函数

甚至对于一些初始化语法,在一些情况下C++98没有办法去表达初始化。举个例子,要想直接表示一些存放一个特殊值的STL容器是不可能的(比如Item1,3,5)

C++11使用统一初始化(uniform initialization)来整合这些混乱且繁多的初始化语法,所谓统一初始化是指使用单一初始化语法在任何地方_[0]_表达任何东西。
它基于花括号,出于这个原因我更喜欢称之为括号初始化_[1]_。统一初始化是一个概念上的东西,而括号初始化是一个具体语法构型。
括号初始化让你可以表达以前表达不出的东西。使用花括号,指定一个容器的元素变得很容易:

1
std::vector<int> v{1,3,5};      //v包含1,3,5

括号初始化也能被用于为非静态数据成员指定默认初始值。C++11允许”=”初始化也拥有这种能力:
1
2
3
4
5
6
7
class Widget{
...
private:
int x{0}; //没问题,x初始值为0
int y = 0; //同上
int z(0); //错误!
}

另一方面,不可拷贝的对象可以使用花括号初始化或者小括号初始化,但是不能使用”=”初始化:
1
2
3
std::vector<int> ai1{0};    //没问题,x初始值为0
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!

因此我们很容易理解为什么括号初始化又叫统一初始化,在C++中这三种方式都被指派为初始化表达式,但是只有括号任何地方都能被使用。

括号表达式有一个异常的特性,它不允许内置类型隐式的变窄转换(narrowing conversion)。如果一个使用了括号初始化的表达式的值无法用于初始化某个类型的对象,代码就不会通过编译:

1
2
3
double x,y,z;               

int sum1{x+y+z}; //错误!三个double的和不能用来初始化int类型的变量

使用小括号和”=”的初始化不检查是否转换为变窄转换,因为由于历史遗留问题它们必须要兼容老旧代码
1
2
3
int sum2(x + y +z);         //可以(表达式的值被截为int)

int sum3 = x + y + z; //同上

另一个值得注意的特性是括号表达式对于C++最令人头疼的解析问题_[2]_有天生的免疫性。
C++规定任何能被决议为一个声明的东西必须被决议为声明。这个规则的副作用是让很多程序员备受折磨:当他们想创建一个使用默认构造函数构造的对象,却不小心变成了函数声明。
问题的根源是如果你想使用一个实参调用一个构造函数,你可以这样做:
1
Widget w1(10);              //使用实参10调用Widget的一个构造函数

但是如果你尝试使用一个没有参数的构造函数构造对象,它就会变成函数声明:
1
Widget w2();                //最令人头疼的解析!声明一个函数w2,返回Widget

由于函数声明中形参列表不能使用花括号,所以使用花括号初始化表明你想调用默认构造函数构造对象就没有问题:
1
Widget w3{};                  //调用没有参数的构造函数构造对象

关于括号初始化还有很多要说的。它的语法能用过各种不同的上下文,它防止了隐式的变窄转换,而且对于C++最令人头疼的解析也天生免疫。
既然好到这个程度那为什么这个条款不叫“Prefer braced initialization syntax”呢?

括号初始化的缺点是有时它有一些令人惊讶的行为。
这些行为使得括号初始化和std::initializer_list和构造函数重载决议本来就不清不楚的暧昧关系进一步混乱。
把它们放到一起会让看起来应该左转的代码右转。
举个例子,Item2解释了当auto声明的变量使用花括号初始化,变量就会被推导为std::initializer_list,尽管使用相同内容的其他初始化方式会产生正常的结果。
所以,你越喜欢用atuo,你就越不能用括号初始化。

在构造函数调用中,只要不包含std::initializer_list参数,那么花括号初始化和小括号初始化都会产生一样的结果:

1
2
3
4
5
6
7
8
9
10
class Widget { 
public:
Widget(int i, bool b); //未声明默认构造函数
Widget(int i, double d); // std::initializer_list参数

};
Widget w1(10, true); // 调用构造函数
Widget w2{10, true}; // 同上
Widget w3(10, 5.0); // 调用第二个构造函数
Widget w4{10, 5.0}; // 同上

然而,如果有一个或者多个构造函数的参数是std::initializer_list,
使用括号初始化语法绝对比传递一个std::initializer_list实参要好。
而且只要某个调用能使用括号表达式编译器就会使用它。
如果上面的Widget的构造函数有一个std::initializer_list实参,就像这样:
1
2
3
4
5
6
class Widget { 
public:
Widget(int i, bool b);
Widget(std::initializer_list<long double> il); //新添加的

};

w2w4将会使用新添加的构造函数构造,即使另一个非std::initializer_list构造函数对于实参是更好的选择:
1
2
3
4
5
6
7
8
9
10
11
12
13
Widget w1(10, true);     // 使用小括号初始化
//调用第一个构造函数

Widget w2{10, true}; // 使用花括号初始化
// 调用第二个构造函数
// (10 和 true 转化为long double)

Widget w3(10, 5.0); // 使用小括号初始化
// 调用第二个构造函数

Widget w4{10, 5.0}; // 使用花括号初始化
// 调用第二个构造函数
// (10 和 true 转化为long double)

甚至普通的构造函数和移动构造函数都会被std::initializer_list构造函数劫持:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget { 
public:
Widget(int i, bool b);
Widget(std::initializer_list<long double> il);
operator float() const;
};
Widget w5(w4); // 使用小括号,调用拷贝构造函数

Widget w6{w4}; // 使用花括号,调用std::initializer_list构造函数

Widget w7(std::move(w4)); // 使用小括号,调用移动构造函数

Widget w8{std::move(w4)}; // 使用花括号,调用std::initializer_list构造函数

编译器热衷于把括号初始化与使std::initializer_list构造函数匹配了,热衷程度甚至超过了最佳匹配。比如:
1
2
3
4
5
6
7
8
class Widget { 
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<bool> il);

};
Widget w{10, 5.0}; //错误!要求变窄转换

这里,编译器会直接忽略前面两个构造函数,然后尝试调用第三个构造函数,也即是std::initializer_list构造函数。
调用这个函数将会把int(10)和double(5.0)`转换为bool,由于括号初始化拒绝变窄转换,所以这个调用无效,代码无法通过编译。

只有当没办法把括号初始化中实参的类型转化为std::initializer_list时,编译器才会回到正常的函数决议流程中。
比如我们在构造函数中用std::initializer_list<std::string代替std::initializer_list<bool>,这时非std::initializer_list构造函数将再次成为函数决议的候选者,
因为没有办法把int和bool转换为std::string:

1
2
3
4
5
6
7
8
9
10
11
class Widget { 
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<std::string> il);

};
Widget w1(10, true); // 使用小括号初始化,调用第一个构造函数
Widget w2{10, true}; // 使用花括号初始化,调用第一个构造函数
Widget w3(10, 5.0); // 使用小括号初始化,调用第二个构造函数
Widget w4{10, 5.0}; // 使用花括号初始化,调用第二个构造函数

代码的行为和我们刚刚的论述如出一辙。这里还有一个有趣的边缘情况_[3]_。
假如你使用的花括号初始化是空集,并且你欲构建的对象有默认构造函数,也有std::initializer_list构造函数。
你的空的花括号意味着什么?如果它们意味着没有实参,就该使用默认构造函数,
但如果它意味着一个空的std::initializer_list,就该调用std::initializer_list构造函数。

最终会调用默认构造函数。空的花括号意味着没有实参,不是一个空的std::initializer_list:

1
2
3
4
5
6
7
8
9
class Widget { 
public:
Widget();
Widget(std::initializer_list<int> il);
...
};
Widget w1; // 调用默认构造函数
Widget w2{}; // 同上
Widget w3(); // 最令人头疼的解析!声明一个函数

如果你想调用std::initializer_list构造,你就得创建一个空花括号的实参来表明你想调用一个std::initializer_list构造函数,它的实参是一个空值。
1
2
Widget w4({});        // 调用std::initializer_list
Widget w5{{}}; // 同上

此时,括号初始化的晦涩规则,std::initializer_list和构造函数重载就会一下子涌进你的脑袋,你可能会想研究了半天这些东西在你的日常编程中到底占多大比例。
可能比你想象的要多。因为std::vector也会受到影响。
std::vector有一个非std::initializer_list构造函数允许你去指定容器的初始大小,以及使用一个值填满你的容器。
但它也有一个std::initializer_list构造函数允许你使用花括号里面的值初始化容器。如果你创建一个数值类型的vector,然后你传递两个实参。把这两个实参放到小括号和放到花括号中是不同:
1
2
3
4
5
6
std::vector<int> v1(10, 20);    //使用非std::initializer_list
//构造函数创建一个包含10个元素的std::vector
//所有的元素的值都是20
std::vector<int> v2{10, 20}; //使用std::initializer_list
//构造函数创建包含两个元素的std::vector
//元素的值为10和20

让我们退回之前的讨论。从这个讨论中我有两个重要结论。
第一,作为一个类库作者,你需要意识到如果你的一堆构造函数中重载过一个或者多个std::initializer_list,
用户代码如果使用了括号初始化,可能只会看到你重载的std::initializer_list这一个版本的构造函数。
因此,你最好把你的构造函数设计为不管用户是小括号还是使用花括号进行初始化都不会有什么影响。
换句话说,现在看到std::vector设计的缺点以后你设计的时候避免它。

这里的暗语是如果一个类没有std::initializer_list构造函数,然后你添加一个,
用户代码中如果使用括号初始化可能会发现过去被决议为非std::initializer_list构造函数现在被决议为新的函数。
当然,这种事情也可能发生在你添加一堆重载函数的时候,std::initializer_list重载不会和其他重载函数比较,
它直接盖过了其它重载函数,其它重载函数几乎不会被考虑。所以如果你要使用std::initializer_list构造函数,请三思而后行。

第二个,作为一个类库使用者,你必须认真的在花括号和小括号之间选择一个来创建对象。
大多数开发者都使用其中一种作为默认情况,只有当他们不能使用这种的时候才会考虑另一种。
如果使用默认使用花括号初始化,会得到大范围适用面的好处,它禁止变窄转换,免疫C++最令人头疼的解析。
他们知道在一些情况下(比如给一个容器大小和一个值创建std::vector)要使用小括号。
如果默认使用小括号初始化,它们能和C++98语法保持一致,它避开了auto自动推导std::initializer_list的问题,
也不会不经意间就调用了std::initializer_list构造函数。
他们承认有时候只能使用花括号(比如创建一个包含特殊值的容器)。
关于花括号和小括号的使用没有一个一致的观点,所以我的建议是用一个,并坚持使用。

如果你是一个模板的作者,花括号和小括号创建对象就更麻烦了。
通常不能知晓哪个会被使用。
举个例子,假如你想创建一个接受任意数量的参数,然后用它们创建一个对象。使用可变参数模板(variadic template )可以非常简单的解决:

1
2
3
4
5
template<typename T,
typename... Ts>
void doSomeWork(Ts&&... params) {
create local T object from params... …
}

在现实中我们有两种方式使用这个伪代码(关于std::forward请参见Item25):
1
2
T localObject(std::forward<Ts>(params)...);    // 使用小括号
T localObject{std::forward<Ts>(params)...}; // 使用花括号

考虑这样的调用代码:
1
2
3
std::vector<int> v; 

doSomeWork<std::vector<int>>(10, 20);

如果doSomeWork创建localObject时使用的是小括号,std::vector就会包含10个元素。
如果doSomeWork创建localObject时使用的是花括号,std::vector就会包含2个元素。
哪个是正确的?doSomeWork的作者不知道,只有调用者知道。

这正是标准库函数std::make_unique和std::make_shared(参见Item21)面对的问题。
它们的解决方案是使用小括号,并被记录在文档中作为接口的一部分。

记住

  • 括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
  • 在构造函数重载决议中,括号初始化尽最大可能与std::initializer_list参数匹配,即便其他构造函数看起来是更好的选择
  • 对于数值类型的std::vector来说使用花括号初始化和小括号初始化会造成巨大的不同
  • 在模板类选择使用小括号初始化或使用花括号初始化创建对象是一个挑战。

Item 8: Prefer nullptr to 0 and NULL.

条款八:优先考虑nullptr而非0和NULL

你看这样对不对:字面值0是一个int不是指针。
如果C++发现在当前上下文只能使用指针,它会很不情愿的把0解释为指针,但是那是最后的退路。
一般来说C++的解析策略是把0看做int而不是指针。

实际上,NULL也是这样的。但在NULL的实现细节有些不确定因素,
因为实现被允许给NULL一个除了int之外的整型类型(比如long)。
这不常见,但也算不上问题所在。这里的问题不是NULL没有一个确定的类型,而是0和NULL都不是指针类型。

在C++98中,对指针类型和整型进行重载意味着可能导致奇怪的事情。
如果给下面的重载函数传递0或NULL,它们绝不会调用指针版本的重载函数:

1
2
3
4
5
6
7
void f(int);        //三个f的重载函数
void f(bool);
void f(void*);

f(0); //调用f(int)而不是f(void*)

f(NULL); //可能不会被编译,一般来说调用f(int),绝对不会调用f(void*)

而f(NULL)的不确定行为是由NULL的实现不同造成的。
如果NULL被定义为0L(指的是0long类型),这个调用就具有二义性,因为从longint的转换或从longbool的转换或0Lvoid* 的转换都会被考虑。
有趣的是源代码表现出的意思(我指的是使用NULL调用f)和实际想表达的意思(我指的是用整型数据调用f)是相矛盾的。
这种违反直觉的行为导致C++98程序员都将避开同时重载指针和整型作为编程准则_[0]_。
在C++11中这个编程准则也有效,因为尽管我这个条款建议使用nullptr,可能很多程序员还是会继续使用0NULL,哪怕nullptr是更好的选择。

nullptr的优点是它不是整型。
老实说它也不是一个指针类型,但是你可以把它认为是通用类型的指针。
nullptr的真正类型是std::nullptr_t,在一个完美的循环定义以后,std::nullptr_t又被定义为nullptr
std::nullptr_t可以转换为指向任何内置类型的指针,这也是为什么我把它叫做通用类型的指针。

使用nullptr调用f将会调用void*版本的重载函数,因为nullptr不能被视作任何整型:

1
f(nullptr);         //调用重载函数f的f(void*)版本

使用nullptr*代替0NULL可以避开了那些令人奇怪的函数重载决议,这不是它的唯一优势。
它也可以使代码表意明确,尤其是当和auto一起使用时。
举个例子,假如你在一个代码库中遇到了这样的代码:
1
2
3
4
auto result = findRecord( /* arguments */ );
if (result == 0) {

}

如果你不知道findRecord返回了什么(或者不能轻易的找出),那么你就不太清楚到底result是一个指针类型还是一个整型。
毕竟,0也可以像我们之前讨论的那样被解析。
但是换一种假设如果你看到这样的代码:
1
2
3
4
auto result = findRecord( /* arguments */ );
if (result == nullptr) {

}

这就没有任何歧义:result的结果一定是指针类型。

当模板出现时nullptr就更有用了。
假如你有一些函数只能被合适的已锁互斥量调用。
每个函数都有一个不同类型的指针:

1
2
3
int    f1(std::shared_ptr<Widget> spw);  // 只能被合适的
double f2(std::unique_ptr<Widget> upw); // 已锁互斥量调
bool f3(Widget* pw); // 用

如果这样传递空指针:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::mutex f1m, f2m, f3m;         // 互斥量f1m,f2m,f3m,各种用于f1,f2,f3函数
using MuxGuard = // C++11的typedef,参见Item9
std::lock_guard<std::mutex>;

{
MuxGuard g(f1m); // 为f1m上锁
auto result = f1(0); // 向f1传递控制空指针
} // 解锁

{
MuxGuard g(f2m); // 为f2m上锁
auto result = f2(NULL); // 向f2传递控制空指针
} // 解锁

{
MuxGuard g(f3m); // 为f3m上锁
auto result = f3(nullptr); // 向f3传递控制空指针
} // 解锁

令人遗憾前两个调用没有使用nullptr,但是代码可以正常运行,这也许对一些东西有用。
但是重复的调用代码——为互斥量上锁,调用函数,解锁互斥量——更令人遗憾。它让人很烦。
模板就是被设计于减少重复代码,所以让我们模板化这个调用流程:
1
2
3
4
5
6
7
8
9
template<typename FuncType,         
typename MuxType,
typename PtrType>
auto lockAndCall(FuncType func,
MuxType& mutex,
PtrType ptr) -> decltype(func(ptr)) {
MuxGuard g(mutex);
return func(ptr);
}

如果你对函数返回类型 (auto … -> decltype(func(ptr)) 感到困惑不解,Item3可以帮助你。
在C++14中代码的返回类型还可以被简化为decltype(auto)
1
2
3
4
5
6
7
8
9
template<typename FuncType,         
typename MuxType,
typename PtrType>
decltype(auto) lockAndCall(FuncType func,
MuxType& mutex,
PtrType ptr) {
MuxGuard g(mutex);
return func(ptr);
}

可以写这样的代码调用lockAndCall模板(两个都算):
1
2
3
4
5
auto result1 = lockAndCall(f1, f1m, 0);          // 错误!

auto result2 = lockAndCall(f2, f2m, NULL); // 错误!

auto result3 = lockAndCall(f3, f3m, nullptr); // 没问题

代码虽然可以这样写,但是就像注释中说的,前两个情况不能通过编译。
在第一个调用中存在的问题是当0被传递给lockAndCall模板,模板类型推导会尝试去推导实参类型,
0的类型总是int,所以int版本的实例化中的func会被int类型的实参调用。
这与f1期待的参数std::shared_ptr不符。
传递0本来想表示空指针,结果f1得到的是和它相差十万八千里的int
int类型看做std::shared_ptr类型自然是一个类型错误。
在模板lockAndCall中使用0之所以失败是因为得到的是int但实际上模板期待的是一个
std::shared_ptr

第二个使用NULL调用的分析也是一样的。当NULL被传递给lockAndCall,形参ptr被推导为整型_[1]_,
然后当ptr——一个int或者类似int的类型——传递给f2的时候就会出现类型错误。当ptr被传递给f3的时候,
隐式转换使std::nullptr_t转换为Widget* ,因为std::nullptr_t可以隐式转换为任何指针类型。

模板类型推导将0NULL推导为一个错误的类型,这就导致它们的替代品nullptr很吸引人。
使用nullptr,模板不会有什么特殊的转换。
另外,使用nullptr不会让你受到同重载决议特殊对待0NULL一样的待遇。
当你想用一个空指针,使用nullptr,不用0或者NULL

记住

  • 优先考虑nullptr而非0和NULL
  • 避免重载指针和整型

Item 9:Prefer alias declarations to typedefs

条款九:优先考虑别名声明而非typedefs

我相信每个人都同意使用STL容器是个好主意,并且我希望Item18能说服你让你觉得使用std:unique_ptr也是个好主意,但我猜没有人喜欢写上几次 std::unique_ptr<std::unordered_map<std::string,std::string>>这样的类型,它可能会让你患上腕管综合征的风险大大增加。

避免上述医疗悲剧也很简单,引入typedef即可:

1
typedef  std::unique_ptr<std::unordered_map<std::string, std::string>>  UPtrMapSS; 

typedef是C++98的东西。虽然它可以在C++11中工作,但是C++11也提供了一个别名声明(alias declaration):
1
using UPtrMapSS =  std::unique_ptr<std::unordered_map<std::string, std::string>>;

由于这里给出的typedef和别名声明做的都是完全一样的事情,我们有理由想知道会不会出于一些技术上的原因两者有一个更好。

这里,在说它们之前我想提醒一下很多人都发现当声明一个函数指针时别名声明更容易理解:

1
2
3
4
5
// FP是一个指向函数的指针的同义词,它指向的函数带有int和const std::string&形参,不返回任何东西
typedef void (*FP)(int, const std::string&); // typedef

//同上
using FP = void (*)(int, const std::string&);     // 别名声明

当然,两个结构都不是非常让人满意,没有人喜欢花大量的时间处理函数指针类型的别名_[0]_,所以至少在这里,没有一个吸引人的理由让你觉得别名声明比typedef好。

不过有一个地方使用别名声明吸引人的理由是存在的:模板。特别的,别名声明可以被模板化但是typedef不能。
这使得C++11程序员可以很直接的表达一些C++98程序员只能把typedef嵌套进模板化的struct才能表达的东西,
考虑一个链表的别名,链表使用自定义的内存分配器,MyAlloc

使用别名模板,这真是太容易了:

1
2
3
4
template<typename T>
using MyAllocList = std::list<T,MyAlloc<T>>;

MyAllocList<Widget> lw;

使用typedef,你就只能从头开始:
1
2
3
4
5
template<typename T>                     
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<Widget>::type lw;

更糟糕的是,如果你想使用在一个模板内使用typedef声明一个持有链表的对象,而这个对象又使用了模板参数,你就不得不在在typedef前面加上typename
1
2
3
4
5
6
template<typename T> 
class Widget {
private:
typename MyAllocList<T>::type list;

};

这里MyAllocList::type使用了一个类型,这个类型依赖于模板参数T
因此MyAllocList::type是一个依赖类型,在C++很多讨人喜欢的规则中的一个提到必须要在依赖类型名前加上typename
如果使用别名声明定义一个MyAllocList,就不需要使用typename(同时省略麻烦的::type后缀),
1
2
3
4
5
6
7
8
template<typename T> 
using MyAllocList = std::list<T, MyAlloc<T>>; // as before
template<typename T>
class Widget {
private:
MyAllocList<T> list;

};

对你来说,MyAllocList(使用了模板别名声明的版本)可能看起来和MyAllocList::type(使用typedef的版本)一样都应该依赖模板参数T,但是你不是编译器。
当编译器处理Widget模板时遇到MyAllocList(使用模板别名声明的版本),它们知道MyAllocList是一个类型名,
因为MyAllocList是一个别名模板。它一定是一个类型名。因此MyAllocList就是一个非依赖类型,就不要求必须使用typename

当编译器在Widget的模板中看到MyAllocList::type(使用typedef的版本),它不能确定那是一个类型的名称。
因为可能存在MyAllocList的一个特化版本没有MyAllocList::type
那听起来很不可思议,但不要责备编译器穷尽考虑所有可能。
举个例子,一个误入歧途的人可能写出这样的代码:

1
2
3
4
5
6
7
8
9
class Wine { … };
template<> // 当T是Wine
class MyAllocList<Wine> { // 特化MyAllocList
private:
enum class WineType // 参见Item10了解
{ White, Red, Rose }; // "enum class"
WineType type; // 在这个类中,type是
// 一个数据成员!
};

就像你看到的,MyAllocList::type不是一个类型。
如果Widget使用Wine实例化,在Widget模板中的MyAllocList::type将会是一个数据成员,不是一个类型。
Widget模板内,如果MyAllocList::type表示的类型依赖于T,编译器就会坚持要求你在前面加上typename

如果你尝试过模板元编程(TMP), 你一定会碰到取模板类型参数然后基于它创建另一种类型的情况。
举个例子,给一个类型T,如果你想去掉T的常量修饰和引用修饰,比如你想把const std::string&变成const std::string
又或者你想给一个类型加上const或左值引用,比如把Widget变成const WidgetWidget&
(如果你没有用过玩过模板元编程,太遗憾了,因为如果你真的想成为一个高效C++程序员_[1]_,至少你需要熟悉C++的基础。你可以看看我在Item23,27提到的类型转换)。
C++11在type traits中给了你一系列工具去实现类型转换,如果要使用这些模板请包含头文件
里面不全是类型转换的工具,也包含一些predictable接口的工具。给一个类型T,你想将它应用于转换中,结果类型就是std::transformation\::type,比如:

1
2
3
std::remove_const<T>::type           // 从const T中产出T
std::remove_reference<T>::type // 从T&和T&&中产出T
std::add_lvalue_reference<T>::type // 从T中产出T&

注释仅仅简单的总结了类型转换做了什么,所以不要太随便的使用。
在你的项目使用它们之前,你最好看看它们的详细说明书。
尽管写了一些,但我这里不是想给你一个关于type traits使用的教程。注意类型转换尾部的::type
如果你在一个模板内部使用类型参数,你也需要在它们前面加上typename
至于为什么要这么做是因为这些type traits是通过在struct内嵌套typedef来实现的。
是的,它们使用类型别名_[2]_技术实现,而正如我之前所说这比别名声明要差。

关于为什么这么实现是有历史原因的,但是我们跳过它(我认为太无聊了),因为标准委员会没有及时认识到别名声明是更好的选择,所以直到C++14它们才提供了使用别名声明的版本。
这些别名声明有一个通用形式:对于C++11的类型转换std::transformation::type在C++14中变成了std::transformation_t.
举个例子或许更容易理解:

1
2
3
4
5
6
7
8
std::remove_const<T>::type           // C++11: const T → T 
std::remove_const_t<T> // C++14 等价形式

std::remove_reference<T>::type // C++11: T&/T&& → T
std::remove_reference_t<T> // C++14 等价形式

std::add_lvalue_reference<T>::type // C++11: T → T&
std::add_lvalue_reference_t<T> // C++14 等价形式

C++11的的形式在C++14中也有效,但是我不能理解为什么你要去用它们。
就算你没有使用C++14,使用别名模板也是小儿科
只需要C++11,甚至每个小孩都能仿写。
对吧?如果你有一份C++14标准,就更简单了,只需要复制粘贴:
1
2
3
4
5
6
7
8
template <class T> 
using remove_const_t = typename remove_const<T>::type;

template <class T>
using remove_reference_t = typename remove_reference<T>::type;

template <class T>
using add_lvalue_reference_t = typename add_lvalue_reference<T>::type;

看见了吧?不能再简单了。

记住

  • typedef不支持模板化,但是别名声明支持。
  • 别名模板避免了使用”::type“后缀,而且在模板中使用typedef还需要在前面加上typename
  • C++14提供了C++11所有类型转换的别名声明版本

Item 10:优先考虑限域枚举而非未限域枚举

条款10:优先考虑限域枚举而非未限域枚举

通常来说,在花括号中声明一个名字会限制它的作用域在花括号之内。但这对于C++98风格的enum中声明的枚举名是不成立的。这些在enum作用域中声明的枚举名所在的作用域也包括enum本身,也就是说这些枚举名和enum所在的作用域中声明的相同名字没有什么不同

1
2
3
4
enum Color { black, white, red };   // black, white, red 和
// Color一样都在相同作用域
auto white = false; // 错误! white早已在这个作用
// 域中存在

事实上这些枚举名泄漏进和它们所被定义的enum域一样的作用域。有一个官方的术语:未限域枚举(unscoped enum)在C++11中它们有一个相似物,限域枚举(scoped enum),它不会导致枚举名泄漏:
1
2
3
4
5
6
7
8
enum class Color { black, white, red }; // black, white, red
// 限制在Color域内
auto white = false; // 没问题,同样域内没有这个名字

Color c = white; //错误,这个域中没有white

Color c = Color::white; // 没问题
auto c = Color::white; // 也没问题(也符合条款5的建议)

因为限域枚举是通过enum class声明,所以它们有时候也被称为枚举类(enum classes)。

使用限域枚举减少命名空间污染是一个足够合理使用它而不是它的同胞未限域枚举的理由,其实限域枚举还有第二个吸引人的优点:在它的作用域中,枚举名是强类型。未限域枚举中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型)。因此下面这种歪曲语义的做法也是完全有效的:

1
2
3
4
5
6
7
8
9
10
enum Color { black, white, red };       // 未限域枚举
std::vector<std::size_t> // func返回x的质因子
primeFactors(std::size_t x);
Color c = red;

if (c < 14.5) { // Color与double比较 (!)
auto factors = // 计算一个Color的质因子(!)
primeFactors(c);

}

enum后面写一个class就可以将非限域枚举转换为限域枚举,接下来就是完全不同的故事展开了。
现在不存在任何隐式转换可以将限域枚举中的枚举名转化为任何其他类型。
1
2
3
4
5
6
7
8
9
enum class Color { black, white, red }; // Color现在是限域枚举
Color c = Color::red; // 和之前一样,只是
// 多了一个域修饰符
if (c < 14.5) { // 错误!不能比较
// Color和double
auto factors = // 错误! 不能向参数为std::size_t的函数
primeFactors(c); // 传递Color参数

}

如果你真的很想执行Color到其他类型的转换,和平常一样,使用正确的类型转换运算符扭曲类型系统:
1
2
3
4
5
6
if (static_cast<double>(c) < 14.5) { // 奇怪的代码,但是
// 有效
auto factors = // suspect, but
primeFactors(static_cast<std::size_t>(c)); // 能通过编译

}

似乎比起非限域枚举而言限域枚举有第三个好处,因为限域枚举可以前置声明。比如,它们可以不指定枚举名直接前向声明:
1
2
enum Color;         // 错误!
enum class Color; // 没问题

其实这是一个误导。在C++11中,非限域枚举也可以被前置声明,但是只有在做一些其他工作后才能实现。这些工作来源于一个事实:
在C++中所有的枚举都有一个由编译器决定的整型的基础类型。对于非限域枚举比如Color
1
enum Color { black, white, red };

编译器可能选择char作为基础类型,因为这里只需要表示三个值。然而,有些枚举中的枚举值范围可能会大些,比如:
1
2
3
4
5
6
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF
};

这里值的范围从00xFFFFFFFF。除了在不寻常的机器上(比如一个char至少有32bits的那种),编译器都会选择一个比char大的整型类型来表示Status

为了高效使用内存,编译器通常在确保能包含所有枚举值的前提下为枚举选择一个最小的基础类型。在一些情况下,编译器
将会优化速度,舍弃大小,这种情况下它可能不会选择最小的基础类型,而是选择对优化大小有帮助的类型。为此,C++98
只支持枚举定义(所有枚举名全部列出来);枚举声明是不被允许的。这使得编译器能为之前使用的每一个枚举选择一个基础类型。

但是不能前置声明枚举也是有缺点的。最大的缺点莫过于它可能增加编译依赖。再次考虑Status枚举:

1
2
3
4
5
6
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF
};

这种enum很有可能用于整个系统,因此系统中每个包含这个头文件的组件都会依赖它。如果引入一个新状态值,
1
2
3
4
5
6
7
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500,
indeterminate = 0xFFFFFFFF
};

那么可能整个系统都得重新编译,即使只有一个子系统——或者一个函数使用了新添加的枚举名。这是大家都不希望看到的。C++11中的前置声明可以解决这个问题。
比如这里有一个完全有效的限域枚举声明和一个以该限域枚举作为形参的函数声明:
1
2
enum class Status; // forward declaration
void continueProcessing(Status s); // use of fwd-declared enum

即使Status的定义发生改变,包含这些声明的头文件也不会重新编译。而且如果Status添加一个枚举名(比如添加一个_audited_),continueProcessing的行为不受影响(因为continueProcessing没有使用这个新添加的_audited_),continueProcessing也不需要重新编译。
但是如果编译器在使用它之前需要知晓该枚举的大小,该怎么声明才能让C++11做到C++98不能做到的事情呢?
答案很简单:限域枚举的基础类型总是已知的,而对于非限域枚举,你可以指定它。默认情况下,限域枚举的基础类型是int
1
enum class Status; // 基础类型是int

如果默认的int不适用,你可以重写它:
1
2
3
enum class Status: std::uint32_t;   // Status的基础类型
                                   // 是std::uint32_t
                                   // (需要包含 <cstdint>)

不管怎样,编译器都知道限域枚举中的枚举名占用多少字节。要为非限域枚举指定基础类型,你可以同上,然后前向声明一下:
1
2
3
enum Color: std::uint8_t;   // 为非限域枚举Color指定
                           // 基础为
                           // std::uint8_t

基础类型说明也可以放到枚举定义处:
1
2
3
4
5
6
7
enum class Status: std::uint32_t { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500,
indeterminate = 0xFFFFFFFF
};

限域枚举避免命名空间污染而且不接受隐式类型转换,但它并非万事皆宜,你可能会很惊讶听到至少有一种情况下非限域枚举是很有用的。
那就是获取C++11 tuples中的字段的时候。比如在社交网站中,假设我们有一个tuple保存了用户的名字,email地址,声望点:
1
2
3
4
using UserInfo = // 类型别名,参见Item 9
std::tuple<std::string, // 名字
std::string, // email地址
std::size_t> ; // 声望

虽然注释说明了tuple各个字段对应的意思,但当你在另文件遇到下面的代码那之前的注释就不是那么有用了:
1
2
3
UserInfo uInfo; // tuple对象

auto val = std::get<1>(uInfo); // 获取第一个字段

作为一个程序员,你有很多工作要持续跟进。你应该记住第一个字段代表用户的email地址吗?我认为不。
可以使用非限域枚举将名字和字段编号关联起来以避免上述需求:
1
2
3
4
enum UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo;

auto val = std::get<uiEmail>(uInfo); // ,获取用户email

之所以它能正常工作是因为UserInfoFields中的枚举名隐式转换成std::size_t了,其中std::size_tstd::get模板实参所需的。
对应的限域枚举版本就很啰嗦了:
1
2
3
4
5
6
enum class UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo; // as before

auto val =
std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>
(uInfo);

为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的std::size_t值,但这有一点技巧性。
std::get是一个模板(函数),需要你给出一个std::size_t值的模板实参(注意使用<>而不是()),因此将枚举名变换为std::size_t值会发生在编译期。
如Item 15提到的,那必须是一个constexpr模板函数。
事实上,它也的确该是一个constexpr函数,因为它应该能用于任何enum
如果我们想让它更一般化,我们还要泛化它的返回类型。较之于返回std::size_t,我们更应该泛化枚举的基础类型。
这可以通过std::underlying_type这个type trait获得。(参见Item 9关于type trait的内容)。
最终我们还要再加上noexcept修饰(参见Item 14),因为我们知道它肯定不会产生异常。
根据上述分析最终得到的toUType模板函数在编译期接受任意枚举名并返回它的值:
1
2
3
4
5
6
7
8
template<typename E>
constexpr typename std::underlying_type<E>::type
toUType(E enumerator) noexcept
{
return
static_cast<typename
std::underlying_type<E>::type>(enumerator);
}

在C++14中,toUType还可以进一步用std::underlying_type_t(参见Item 9)代替typename std::underly ing_type<E>::type打磨:
1
2
3
4
5
6
template<typename E> // C++14
constexpr std::underlying_type_t<E>
toUType(E enumerator) noexcept
{
return static_cast<std::underlying_type_t<E>>(enumerator);
}

还可以再用C++14 auto(参见Item 3)打磨一下代码:
1
2
3
4
5
6
template<typename E> // C++14
constexpr auto
toUType(E enumerator) noexcept
{
return static_cast<std::underlying_type_t<E>>(enumerator);
}

不管它怎么写,toUType现在允许这样访问tuple的字段了:
1
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

比起使用非限域枚举,限域有很多可圈可点的地方,它避免命名空间污染,防止不经意间使用隐式转换。
(下面这句我没看懂,保留原文。。(是什么典故吗。。。))
In many cases, you
may decide that typing a few extra characters is a reasonable price to pay for the ability
to avoid the pitfalls of an enum technology that dates to a time when the state of
the art in digital telecommunications was the 2400-baud modem.

记住

  • C++98的枚举即非限域枚举
  • 限域枚举的枚举名仅在enum内可见。要转换为其它类型只能使用cast。
  • 非限域/限域枚举都支持基础类型说明语法,限域枚举基础类型默认是int。非限域枚举没有默认基础类型。
  • 限域枚举总是可以前置声明。非限域枚举仅当指定它们的基础类型时才能前置。

Item 11:优先考虑使用deleted函数而非使用未定义的私有声明

如果你写的代码要被其他人使用,你不想让他们调用某个特殊的函数,你通常不会声明这个函数。无声明,不函数。简简单单!但有时C++会给你自动声明一些函数,如果你想防止客户调用这些函数,事情就不那么简单了。

上述场景见于特殊的成员函数,即当有必要时C++自动生成的那些函数。Item 17 详细讨论了这些函数,但是现在,我们只关心拷贝构造函数和拷贝赋值运算符重载。This chapter is largely devoted to common practices in
C++98 that have been superseded by better practices in C++11, and in C++98, if you
want to suppress use of a member function, it’s almost always the copy constructor,
the assignment operator, or both.

在C++98中防止调用这些函数的方法是将它们声明为私有成员函数。举个例子,在C++ 标准库iostream继承链的顶部是模板类basic_ios。所有istreamostream类都继承此类(直接或者间接)。拷贝istreamostream是不合适的,因为要进行哪些操作是模棱两可的。比如一个istream对象,代表一个输入值的流,流中有一些已经被读取,有一些可能马上要被读取。如果一个istream被拷贝,需要像拷贝将要被读取的值那样也拷贝已经被读取的值吗?解决这个问题最好的方法是不定义这个操作。直接禁止拷贝流。

要使istreamostream类不可拷贝,basic_ios在C++98中是这样声明的(包括注释):

1
2
3
4
5
6
7
8
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:

private:
basic_ios(const basic_ios& ); // not defined
basic_ios& operator=(const basic_ios&); // not defined
};

将它们声明为私有成员可以防止客户端调用这些函数。故意不定义它们意味着假如还是有代码用它们就会在链接时引发缺少函数定义(missing function definitions)错误。

在C++11中有一种更好的方式,只需要使用相同的结尾:用= delete将拷贝构造函数和拷贝赋值运算符标记为deleted函数。上面相同的代码在C++11中是这样声明的:

1
2
3
4
5
6
7
8
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:

basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;

};

删除这些函数(译注:添加”= delete”)和声明为私有成员可能看起来只是方式不同,别无其他区别。其实还有一些实质性意义。deleted函数不能以任何方式被调用,即使你在成员函数或者友元函数里面调用deleted函数也不能通过编译。这是较之C++98行为的一个改进,后者不正确的使用这些函数在链接时才被诊断出来。

通常,deleted函数被声明为public而不是private.这也是有原因的。当客户端代码试图调用成员函数,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private的错误(译注:而没有诸如该函数被deleted修饰的错误),即使函数的访问性不影响它的使用。所以值得牢记,如果要将老代码的”私有且未定义”函数替换为deleted函数时请一并修改它的访问性为public,这样可以让编译器产生更好的错误信息。

deleted函数还有一个重要的优势是任何函数都可以标记为deleted,而只有private只能修饰成员函数。假如我们有一个非成员函数,它接受一个整型参数,检查它是否为幸运数:

1
bool isLucky(int number);

C++有沉重的C包袱,使得含糊的、能被视作数值的任何类型都能隐式转换为int,但是有一些调用可能是没有意义的:
1
2
3
if (isLucky('a')) … // 字符'a'是幸运数?
if (isLucky(true)) … // "true"是?
if (isLucky(3.5)) … // 难道判断它的幸运之前还要先截尾成3?

如果幸运数必须真的是整数,我们该禁止这些调用通过编译。
其中一种方法就是创建deleted重载函数,其参数就是我们想要过滤的类型:
1
2
3
4
bool isLucky(int number); // 原始版本
bool isLucky(char) = delete; // 拒绝char
bool isLucky(bool) = delete; // 拒绝bool
bool isLucky(double) = delete; // 拒绝float和double

(上面double重载版本的注释说拒绝float和double可能会让你惊讶,但是请回想一下:将float转换为intdouble,C++更喜欢转换为double。使用float调用isLucky因此会调用double重载版本,而不是int版本。好吧,它也会那么去尝试。事实是调用被删除的double重载版本不能通过编译。不再惊讶了吧。)

虽然deleted寒暑假不能被使用,它它们还是存在于你的程序中。也即是说,重载决议会考虑它们。这也是为什么上面的函数声明导致编译器拒绝一些不合适的函数调用。

1
2
3
if (isLucky('a')) … //错误! 调用deleted函数
if (isLucky(true)) … // 错误!
if (isLucky(3.5f)) … // 错误!

另一个deleted函数用武之地(private成员函数做不到的地方)是禁止一些模板的实例化。
假如你要求一个模板仅支持原生指针(尽管第四章建议使用智能指针代替原生指针)
1
2
template<typename T>
void processPointer(T* ptr);

在指针的世界里有两种特殊情况。一是void*指针,因为没办法对它们进行解引用,或者加加减减等。
另一种指针是char*,因为它们通常代表C风格的字符串,而不是正常意义下指向单个字符的指针。
这两种情况要特殊处理,在processPointer模板里面,我们假设正确的函数应该拒绝这些类型。
也即是说,processPointer不能被void*char*调用。
要想确保这个很容易,使用delete标注模板实例:
1
2
3
4
template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;

现在如果使用void*char*调用processPointer就是无效的,按常理说const void*const void*也应该无效,所以这些实例也应该标注delete:
1
2
3
4
template<>
void processPointer<const void>(const void*) = delete;
template<>
void processPointer<const char>(const char*) = delete;

如果你想做得更彻底一些,你还要删除const volatile void*const volatile char*重载版本,另外还需要一并删除其他标准字符类型的重载版本:std::wchar_t,std::char16_tstd::char32_t

有趣的是,如果的类里面有一个函数模板,你可能想用private(经典的C++98惯例)来禁止这些函数模板实例化,但是不能这样做,因为不能给特化的模板函数指定一个不同(于函数模板)的访问级别。如果processPointer是类Widget里面的模板函数, 你想禁止它接受void*参数,那么通过下面这样C++98的方法就不能通过编译:
compile:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:

template<typename T>
void processPointer(T* ptr)
{ … }
private:
template<> // 错误!
void processPointer<void>(void*);
};

问题是模板特例化必须位于一个命名空间作用域,而不是类作用域。delete不会出现这个问题,因为它不需要一个不同的访问级别,且他们可以在类外被删除(因此位于命名空间作用域):
1
2
3
4
5
6
7
8
9
10
class Widget {
public:

template<typename T>
void processPointer(T* ptr)
{ … }

};
template<>
void Widget::processPointer<void>(void*) = delete; // 还是public,但是已经被删除了

事实上C++98的最佳实践即声明函数为private但不定义是在做C++11 delete函数要做的事情。作为模仿者,C++98的方法不是十全十美。它不能在类外正常工作,不能总是在类中正常工作,它的罢工可能直到链接时才会表现出来。所以请坚定不移的使用delete函数。

记住:

  • 比起声明函数为private但不定义,使用delete函数更好
  • 任何函数都能delete,包括非成员函数和模板实例

Item 12:使用override声明重载函数

条款12:使用override声明重载函数

在C++面向对象的世界里,涉及的概念有类,继承,虚函数。这个世界最基本的概念是派生类的虚函数重写基类同名函数。令人遗憾的是虚函数重写可能一不小心就错了。给人感觉语言的这一部分设计观点是墨菲定律不是用来遵守的,只是值得尊敬的。

鉴于”重写”听起来像”重载”,尽管两者完全不相关,下面就通过一个派生类和基类来说明什么是虚函数重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base {
public:
virtual void doWork(); // 基类虚函数

};
class Derived: public Base {
public:
virtual void doWork(); // 重写Base::doWork(这里"virtual"是可以省略的)

};
std::unique_ptr<Base> upb = // 创建基类指针
std::make_unique<Derived>(); // 指向派生类对象
// 关于std::make_unique请
// 参见Item1
upb->doWork(); // 通过基类指针调用doWork
// 实际上是派生类的doWork
// 函数被调用

要想重写一个函数,必须满足下列要求:

  • 基类函数必须是virtual
  • 基类和派生类函数名必须完全一样(除非是析构函数
  • 基类和派生类函数参数必须完全一样
  • 基类和派生类函数常量性(constness)必须完全一样
  • 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
    除了这些C++98就存在的约束外,C++11又添加了一个:
  • 函数的引用限定符(reference qualifiers)必须完全一样。成员函数的引用限定符是C++11很少抛头露脸的特性,所以如果你从没听过它无需惊讶。它可以限定成员函数只能用于左值或者右值。成员函数不需要virtual也能使用它们:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Widget {
    public:

    void doWork() &; //只有*this为左值的时候才能被调用
    void doWork() &&; //只有*this为右值的时候才能被调用
    };

    Widget makeWidget(); // 工厂函数(返回右值)
    Widget w; // 普通对象(左值)

    w.doWork(); // 调用被左值引用限定修饰的Widget::doWork版本
    // (即Widget::doWork &)
    makeWidget().doWork(); // 调用被右值引用限定修饰的Widget::doWork版本
    // (即Widget::doWork &&)
    后面我还会提到引用限定符修饰成员函数,但是现在,只需要记住如果基类的虚函数有引用限定符,派生类的重写就必须具有相同的引用限定符。如果没有,那么新声明的函数还是属于派生类,但是不会重写父类的任何函数。

这么多的重写需求意味着哪怕一个小小的错误也会造成巨大的不同。
代码中包含重写错误通常是有效的,但它的意图不是你想要的。因此你不能指望当你犯错时编译器能通知你。比如,下面的代码是完全合法的,咋一看,还很有道理,但是它包含了非虚函数重写。你能识别每个case的错误吗,换句话说,为什么派生类函数没有重写同名基类函数?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1();
virtual void mf2(unsigned int x);
virtual void mf3() &&;
void mf4() const;
};

需要一点帮助吗?

  • mf1在基类声明为const,但是派生类没有这个常量限定符
  • mf2在基类声明为接受一个int参数,但是在派生类声明为接受unsigned int参数
  • mf3在基类声明为左值引用限定,但是在派生类声明为右值引用限定
  • mf4在基类没有声明为虚函数
    你可能会想,“哎呀,实际操作的时候,这些warnings都能被编译器探测到,所以我不需要担心。”可能你说的对,也可能不对。就我目前检查的两款编译器来说,这些代码编译时没有任何warnings,即使我开启了输出所有warnings(其他编译器可能会为这些问题的部分输出warnings,但不是全部)

由于正确声明派生类的重写函数很重要,但很容易出错,C++11提供一个方法让你可以显式的将派生类函数指定为应该是基类重写版本:将它声明为override。还是上面那个例子,我们可以这样做:

1
2
3
4
5
6
7
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};

代码不能编译,当然了,因为这样写的时候,编译器会抱怨所有与重写有关的问题。这也是你想要的,以及为什么要在所有重写函数后面加上override。使用override的代码编译时看起来就像这样(假设我们的目的是重写基类的所有函数):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
virtual void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1() const override;
virtual void mf2(int x) override;
virtual void mf3() & override;
void mf4() const override; // 可以添加virtual,但不是必要
};

注意在这个例子中mf4有别于之前,它在Base中的声明有virtual修饰,所以能正常工作。
大多数和重写有关的错误都是在派生类引发的,但也可能是基类的不正确导致。

比起让编译器(译注:通过warnings)告诉你”将要”重写实际不会重写,不如给你的派生类成员函数全都加上override。如果你考虑修改修改基类虚函数的函数签名,override还可以帮你评估后果。
如果派生类全都用上override,你可以只改变基类函数签名,重编译系统,再看看你造成了多大的问题(即,多少派生类不能通过编译),然后决定是否值得如此麻烦更改函数签名。没有重写,你只能寄希望于完善的单元测试,因为,正如我们所见,派生类虚函数本想重写基类,但是没有,编译器也没有探测并发出诊断信息。

C++既有很多关键字,C++11引入了两个上下文关键字(contextual keywords),overridefinal(向虚函数添加final可以防止派生类重写。final也能用于类,这时这个类不能用作基类)。
这两个关键字的特点是它们是保留的,它们只是位于特定上下文才被视为关键字。对于override,它只在成员函数声明结尾处才被视为关键字。这意味着如果你以前写的代码里面已经用过override这个名字,那么换到C++11标准你也无需修改代码:

1
2
3
4
5
class Warning { // potential legacy class from C++98
public:

void override(); // C++98和C++11都合法
};

关于override想说的就这么多,但对于成员函数引用限定(reference qualifiers)还有一些内容。我之前承诺我会在后面提供更多的关于它们的资料,现在就是”后面”了。
如果我们想写一个函数只接受左值实参,我们的声明可以包含一个左值引用形参:
1
void doSomething(Widget& w); // 只接受左值Widget对象

如果我们想写一个函数只接受右值实参,我们的声明可以包含一个右值引用形参:
1
void doSomething(Widget&& w); // 只接受右值Widget对象

成员函数的引用限定可以很容易的区分哪个成员函数被对象调用(即*this)。它和在成员函数声明尾部添加一个const暗示该函数的调用者(即*this)是const 很相似。
对成员函数添加引用限定不常见,但是可以见。
举个例子,假设我们的Widget类有一个std::vector数据成员,我们提供一个范围函数让客户端可以直接访问它:
1
2
3
4
5
6
7
8
9
class Widget {
public:
using DataType = std::vector<double>; // 参见Item

DataType& data() { return values; }

private:
DataType values;
};

这是最具封装性的设计,只给外界保留一线光。但先把这个放一边,思考一下下面的客户端代码:
1
2
3
Widget w;

auto vals1 = w.data(); // 拷贝w.values到vals1

Widget::data函数的返回值是一个左值引用(准确的说是std::vector<double>&),
因为左值引用是左值,vals1从左值初始化,因此它由w.values拷贝构造而得,就像注释说的那样。
现在假设我们有一个创建Widgets的工厂函数,
1
Widget makeWidget();

我们想用makeWidget返回的std::vector初始化一个变量:
1
auto vals2 = makeWidget().data(); // 拷贝Widget里面的值到vals2

再说一次,Widgets::data返回的是左值引用,还有,左值引用是左值。所以,我们的对象(vals2)又得从Widget里的values拷贝构造。这一次,WidgetmakeWidget返回的临时对象(即右值),所以将其中的std::vector进行拷贝纯属浪费。最好是移动,但是因为data返回左值引用,C++的规则要求编译器不得不生成一个拷贝。
我们需要的是指明当data被右值Widget对象调用的时候结果也应该是一个右值。
现在就可以使用引用限定写一个重载函数来达成这一目的:
1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
using DataType = std::vector<double>;

DataType& data() & // 对于左值Widgets,
{ return values; } // 返回左值
DataType data() && // 对于右值Widgets,
{ return std::move(values); } // 返回右值

private:
DataType values;
};

注意data重载的返回类型是不同的,左值引用重载版本返回一个左值引用,右值引用重载返回一个临时对象。这意味着现在客户端的行为和我们的期望相符了:
1
2
auto vals1 = w.data(); 				//调用左值重载版本的Widget::data,拷贝构造vals1
auto vals2 = makeWidget().data(); //调用右值重载版本的Widget::data, 移动构造vals2

这真的很nice,但别被这结尾的暖光照耀分心以致忘记了该条款的中心。这个条款的中心是只要你在派生类声明想要重写基类虚函数的函数,就加上override

记住:

  • 为重载函数加上override
  • 成员函数限定让我们可以区别对待左值对象和右值对象(即*this)

Item 13:优先考虑const_iterator而非iterator

条款 13:优先考虑const_iterator而非iterator

STL const_iterator等价于指向常量的指针。它们都指向不能被修改的值。标准实践是能加上const就加上,这也指示我们对待const_iterator应该如出一辙。

上面的说法对C++11和C++98都是正确的,但是在C++98中,标准库对const_iterator的支持不是很完整。首先不容易创建它们,其次就算你有了它,它的使用也是受限的。
假如你想在std::vector<int>中查找第一次出现1983(C++代替C with classes的那一年)的位置,然后插入1998(第一个ISO C++标准被接纳的那一年)。如果vector中没有1983,那么就在vector尾部插入。在C++98中使用iterator可以很容易做到:

1
2
3
4
5
std::vector<int> values;

std::vector<int>::iterator it =
std::find(values.begin(),values.end(), 1983);
values.insert(it, 1998);

但是这里iterator真的不是一个好的选择,因为这段代码不修改iterator指向的内容。用const_iterator重写这段代码是很平常的,但是在C++98中就不是了。下面是一种概念上可行但是不正确的方法:

1
2
3
4
5
6
7
8
9
typedef std::vector<int>::iterator IterT; 	// typetypedef
std::vector<int>::const_iterator ConstIterT; // defs
std::vector<int> values;

ConstIterT ci =
std::find(static_cast<ConstIterT>(values.begin()), // cast
static_cast<ConstIterT>(values.end()), // cast
1983);
values.insert(static_cast<IterT>(ci), 1998); // 可能无法通过编译,原因见下

typedef不是强制的,但是可以让类型转换更好写。(你可能想知道为什么我使用typedef而不是Item 9提到的别名声明,因为这段代码在演示C++98做法,别名声明是C++11加入的特性)

之所以std::find的调用会出现类型转换是因为在C++98中values是非常量容器,没办法简简单单的从非常量容器中获取const_iterator。严格来说类型转换不是必须的,因为用其他方法获取const_iterator也是可以的(比如你可以把values绑定到常量引用上,然后再用这个变量代替values),但不管怎么说,从非常量容器中获取const_iterator的做法都有点别扭。

当你费劲地获得了const_iterator,事情可能会变得更糟,因为C++98中,插入操作的位置只能由iterator指定,const_iterator是不被接受的。这也是我在上面的代码中,将const_iterator转换为iterat的原因,因为向insert传入const_iterator不能通过编译。

老实说,上面的代码也可能无法编译,因为没有一个可移植的从const_iteratoriterator的方法,即使使用static_cast也不行。甚至传说中的牛刀reinterpret_cast也杀不了这条鸡。(它C++98的限制,也不是C++11的限制,只是const_iterator就是不能转换为iterator,不管看起来对它们施以转换是有多么合理。)不过有办法生成一个iterator,使其指向和const_iterator指向相同,但是看起来不明显,也没有广泛应用,在这本书也不值得讨论。除此之外,我希望目前我陈述的观点是清晰的:const_iterator在C++98中会有很多问题。这一天结束时,开发者们不再相信能加const就加它的教条,而是只在实用的地方加它,C++98的const_iterator不是那么实用。

所有的这些都在C++11中改变了,现在const_iterator即容易获取又容易使用。容器的成员函数cbegincend产出const_iterator,甚至对于非常量容器,那些之前只使用iterator指示位置的STL成员函数也可以使用const_iterator了。使用C++11 const_iterator重写C++98使用iterator的代码也稀松平常:

1
2
3
4
5
std::vector<int> values; // 和之前一样

auto it = // 使用cbegin
std::find(values.cbegin(),values.cend(), 1983); // 和cend
values.insert(it, 1998);

现在使用const_iterator的代码就很实用了!

唯一一个C++11对于const_iterator支持不足(译注:C++14支持但是C++11的时候还没)的情况是:当你想写最大程度通用的库,并且这些库代码为一些容器和类似容器的数据结构提供非成员函数beginend(以及cbegincendrbeginrend)而不是成员函数(其中一种情况就是原生数组)。最大程度通用的库会考虑使用非成员函数而不是假设成员函数版本存在。

举个例子,我们可以泛化下面的findAndInsert

1
2
3
4
5
6
7
8
9
10
11
12
template<typename C, typename V>
void findAndInsert(C& container, // 在容器中查找第一次
const V& targetVal, // 出现targetVal的位置,
const V& insertVal) // 然后插入insertVal
{
using std::cbegin; // there
using std::cend;
auto it = std::find(cbegin(container), // 非成员函数cbegin
cend(container), // 非成员函数cend
targetVal);
container.insert(it, insertVal);
}

它可以在C++14工作良好,但是很遗憾,C++11不在良好之列。由于标准化的疏漏,C++11只添加了非成员函数beginend,但是没有添加cbegincendrbeginrendcrbegincrend。C++14修订了这个疏漏,如果你使用C++11,并且想写一个最大程度通用的代码,而你使用的STL没有提供缺失的非成员函数cbegin和它的朋友们,你可以简单的抛出你自己的实现。比如,下面就是非成员函数cbegin的实现:

1
2
3
4
5
template <class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
return std::begin(container); // 解释见下
}

你可能很惊讶非成员函数cbegin没有调用成员函数cbegin吧?但是请跟逻辑走。这个cbegin模板接受任何容器或者类似容器的数据结构C,并且通过const引用访问第一个实参container。如果C是一个普通的容器类型(如std::vector<int>),container将会引用一个常量版本的容器(即const std::vector<int>&)。对const容器调用非成员函数begin(由C++11提供)将产出const_iterator,这个迭代器也是模板要返回的。用这种方法实现的好处是就算容器只提供begin不提供cbegin也没问题。那么现在你可以将这个非成员函数cbegin施于只支持begin的容器。

如果C是原生数组,这个模板也能工作。这时,container成为一个const数组。C++11为数组提供特化版本的非成员函数begin,它返回指向数组第一个元素的指针。一个const数组的元素也是const,所以对于const数组,非成员函数begin返回指向const的指针。在数组的上下文中,所谓指向const的指针,也就是const_iterator了。

回到最开始,本条款的中心是鼓励你只要能就使用const_iterator。最原始的动机是——只要它有意义就加上const——C++98就有的思想。但是在C++98,它(译注:const_iterator)只是一般有用,到了C++11,它就是极其有用了,C++14在其基础上做了些修补工作。

记住

  • 优先考虑const_iterator而非iterator
  • 在最大程度通用的代码中,优先考虑非成员函数版本的beginendrbegin等,而非同名成员函数

Item 14:如果函数不抛出异常请使用noexcept

条款 14:如果函数不抛出异常请使用noexcept

在C++98中,异常说明(exception specifications)是喜怒无常的野兽。你不得不写出函数可能抛出的异常类型,如果函数实现有所改变,异常说明也可能需要修改。改变异常说明会影响客户端代码,因为调用者可能依赖原版本的异常说明。编译器不会为函数实现,异常说明和客户端代码中提供一致性保障。大多数程序员最终都认为不值得为C++98的异常说明如此麻烦。

在C++11标准化过程中,大家一致认为异常说明真正有用的信息是一个函数是否会抛出异常。非黑即白,一个函数可能抛异常,或者不会。这种”可能-绝不”的二元论构成了C++11异常说的基础,从根本上改变了C++98的异常说明。(C++98风格的异常说明也有效,但是已经标记为deprecated(废弃))。在C++11中,无条件的noexcept保证函数不会抛出任何异常。

关于一个函数是否已经声明为noexcept是接口设计的事。函数的异常抛出行为是客户端代码最关心的。调用者可以查看函数是否声明为noexcept,这个可以影响到调用代码的异常安全性和效率。

就其本身而言,函数是否为noexcept和成员函数是否const一样重要。如果知道这个函数不会抛异常就加上noexcept是简单天真的接口说明。

不过这里还有给不抛异常的函数加上noexcept的动机:它允许编译器生成更好的目标代码。
要想知道为什么,了解C++98和C++11指明一个函数不抛异常的方式是很有用了。考虑一个函数f,它允许调用者永远不会受到一个异常。两种表达方式如下:

1
2
int f(int x) throw(); // C++98风格
int f(int x) noexcept; // C++11风格

如果在运行时,f出现一个异常,那么就和f的异常说明冲突了。在C++98的异常说明中,调用栈会展开至f的调用者,一些不合适的动作比如程序终止也会发生。C++11异常说明的运行时行为明显不同:调用栈只是_可能_在程序终止前展开。
展开调用栈和_可能_展开调用栈两者对于代码生成(code generation)有非常大的影响。在一个noexcept函数中,当异常传播到函数外,优化器不需要保证运行时栈的可展开状态,也不需要保证noexcept函数中的对象按照构造的反序析构。而”throw()“标注的异常声明缺少这样的优化灵活性,它和没加一样。可以总结一下:
1
2
3
RetType function(params) noexcept;   // 极尽所能优化
RetType function(params) throw(); // 较少优化
RetType function(params); // 较少优化

这是一个充分的理由使得你当知道它不抛异常时加上noexcept

还有一些函数让这个案例更充分。移动操作是绝佳的例子。假如你有一份C++98代码,里面用到了std::vector<Widget>Widget通过push_back一次又一次的添加进std::vector

1
2
3
4
5
6
std::vector<Widget> vw;

Widget w;
// work with w
vw.push_back(w); // add w to vw


假设这个代码能正常工作,你也无意修改为C++11风格。但是你确实想要C++11移动语义带来的性能优势,毕竟这里的类型是可以移动的(move-enabled types)。因此你需要确保Widget有移动操作,可以手写代码也可以让编译器自动生成,当然前提是自动生成的条件能满足(参见Item 17)。

当新元素添加到std::vectorstd::vector可能没地方放它,换句话说,std::vector的大小(size)等于它的容量(capacity)。这时候,std::vector会分配一片的新的大块内存用于存放,然后将元素从已经存在的内存移动到新内存。在C++98中,移动是通过复制老内存区的每一个元素到新内存区完成的,然后老内存区的每个元素发生析构。
这种方法使得push_back可以提供很强的异常安全保证:如果在复制元素期间抛出异常,std::vector状态保持不变,因为老内存元素析构必须建立在它们已经成功复制到新内存的前提下。

在C++11中,一个很自然的优化就是将上述复制操作替换为移动操作。但是很不幸运,这回破坏push_back的异常安全。如果n个元素已经从老内存移动到了新内存区,但异常在移动第n+1个元素时抛出,那么push_back操作就不能完成。但是原始的std::vector已经被修改:有n个元素已经移动走了。恢复std::vector至原始状态也不太可能,因为从新内存移动到老内存本身又可能引发异常。

这是个很严重的问题,因为老代码可能依赖于push_back提供的强烈的异常安全保证。因此,C++11版本的实现不能简单的将push_back里面的复制操作替换为移动操作,除非知晓移动操作绝不抛异常,这时复制替换为移动就是安全的,唯一的副作用就是性能得到提升。

std::vector::push_back受益于”如果可以就移动,如果必要则复制”策略,并且它不是标准库中唯一采取该策略的函数。C++98中还有一些函数如std::vector::reverse,std:;deque::insert等也受益于这种强异常保证。对于这个函数只有在知晓移动不抛异常的情况下用C++11的move替换C++98的copy才是安全的。但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查是否声明noexcept

swap函数是noexcept的绝佳用地。swap是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中。它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。有趣的是,标准库的swap是否noexcept有时依赖于用户定义的swap是否noexcept。比如,数组和std::pairswap声明如下:

1
2
3
4
5
6
7
8
9
10
11
template <class T, size_t N>
void swap(T (&a)[N], // see
T (&b)[N]) noexcept(noexcept(swap(*a, *b))); // below

template <class T1, class T2>
struct pair {

void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&
noexcept(swap(second, p.second)));

};

这些函数视情况noexcept:它们是否noexcept依赖于noexcept声明中的表达式是否noexcept。假设有两个Widget数组,不抛异常的交换数组前提是数组中的元素交换不抛异常。对于Widget的交换是否noexcept决定了对于Widget数组的交换是否noexcept,反之亦然。类似的,交换两个存放Widgetstd::pair是否noexcept依赖于Widget的交换是否noexcept。事实上交换高层次数据结构是否noexcept取决于它的构成部分的那些低层次数据结构是否异常,这激励你只要可以就提供noexcept swap函数(译注:因为如果你的函数不提供noexcept保证,其它依赖你的高层次swap就不能保证noexcept)。

现在,我希望你能为noexcept提供的优化机会感到高兴,同时我还得让你缓一缓别太高兴了。优化很重要,但是正确性更重要。我在这个条款的开头提到noexcept是函数接口的一部分,所以仅当你保证一个函数实现在长时间内不会抛出异常时才声明noexcept。如果你声明一个函数为noexcept,但随即又后悔了,你没有选择。你只能从函数声明中移除noexcept(即改变它的接口),这理所当然会影响客户端代码。你可以改变实现使得这个异常可以避免,再保留原版本(不正确的)异常说明。如果你这么做,程序将会在异常离开这个函数时终止。或者你可以重新设计既有实现,改变实现后再考虑你希望它是什么样子。这些选择都不尽人意。

这个问题的本质是实际上大多数函数都是异常中立(exception neutral)的。这些函数自己不抛异常,但是它们内部的调用可能抛出。此时,异常中立函数允许那些抛出异常的函数在调用链上更进一步直到遇到异常处理程序,而不是就地终止。异常中立函数决不应该声明为noexcept,因为它们可能抛出那种”让它们过吧”的异常(译注:也就是说在当前这个函数内不处理异常,但是又不立即终止程序,而是让调用这个函数的函数处理)异常。因此大多数函数都不应该被指定为noexcept

然而,一些函数很自然的不应该抛异常,更进一步值得注意的是移动操作和swap——使其不抛异常有重大意义,只要可能就应该将它们声明为noexcept。老实说,当你确保函数决不抛异常的时候,一定要将它们声明为noexcept

请注意我说的那些很自然不应该抛异常的函数实现。为了noexcept而扭曲函数实现达成目的是本末倒置。是把马放到马车前,是一叶障目不见泰山。是…选择你喜欢的比喻吧。如果一个简单的函数实现可能引发异常(即调用它可能抛出异常),而你为了讨好调用者隐藏了这个(即捕获所有异常,然后替换为状态码或者特殊返回值),这不仅会使你的函数实现变得复杂,还会让所有调用点的代码变得复杂。调用者可能不得不检查状态码或特殊返回值。而这些复杂的运行时开销(额外的分支,大的函数放入指令缓存)可以超出noexcept带来的性能提升,再加上你会悲哀的发现这些代码又难读又难维护。那是糟糕的软件工程化。

对于一些函数,使其成为noexcept是很重要的,它们应当默认如是。在C++98构造函数和析构函数抛出异常是糟糕的代码设计——不管是用户定义的还是编译器生成的构造析构都是noexcept。因此它们不需要声明noexcept。(这么做也不会有问题,只是不合常规)。析构函数非隐式noexcept的情况仅当类的数据成员明确声明它的析构函数可能抛出异常(即,声明noexcept(false))。这种析构函数不常见,标准库里面没有。如果一个对象的析构函数可能被标准库使用,析构函数又可能抛异常,那么程序的行为是未定义的。

值得注意的是一些库接口设计者会区分有宽泛契约(wild contracts)和严格契约(narrow contracts)的函数。有宽泛契约的函数没有前置条件。这种函数不管程序状态如何都能调用,它对调用者传来的实参不设约束。宽泛契约的函数决不表现出未定义行为。

反之,没有宽泛契约的函数就有严格契约。对于这些函数,如果违反前置条件,结果将会是未定义的。

如果你写了一个有宽泛契约的函数并且你知道它不会抛异常,那么遵循这个条款给它声明一个noexcept 是很容易的。
对于严格契约的函数,情况就有点微妙了。举个例子,假如你在写一个参数为std::string的函数f,并且这个函
数f很自然的决不引发异常。这就在建议我们f应该被声明为noexcept

现在假如f有一个前置条件:类型为std::string的参数的长度不能超过32个字符。如果现在调用f并传给它一个
大于32字符的参数,函数行为将是未定义的,因为违反了 _(口头/文档)定义的_ 前置条件,导致了未定义行为。f没有
义务去检查前置条件,它假设这些前置条件都是满足的。(调用者有责任确保参数字符不超过32字符等这些假设有效。)。
即使有前置条件,将f声明为noexcept似乎也是合适的:

1
2
void f(const std::string& s) noexcept; 	// 前置条件:
// s.length() <= 32

f的实现者决定在函数里面检查前置条件冲突。虽然检查是没有必要的,但是也没禁止这么做。另外在系统测试时,检查
前置条件可能就是有用的了。debug一个抛出的异常一般都比跟踪未定义行为起因更容易。那么怎么报告前置条件冲突使得
测试工具或客户端错误处理程序能检测到它呢?简单直接的做法是抛出"precondition was violated"异常,但是如果
f声明了noexcept,这就行不通了;抛出一个异常会导致程序终止。因为这个原因,区分严格/宽泛契约库设计者一般
会将noexcept留给宽泛契约函数。

作为结束语,让我详细说明一下之前的观察,即编译器不会为函数实现和异常规范提供一致性保障。考虑下面的代码,它是
完全正确的:

1
2
3
4
5
6
7
8
void setup(); // 函数定义另在一处
void cleanup();
void doWork() noexcept
{
setup(); // 前置设置
// 真实工作
cleanup(); // 执行后置清理
}

这里,doWork声明为noexcept,即使它调用了非noexcept函数setupcleanup。看起来有点矛盾,
其实可以猜想setupcleanup在文档上写明了它们决不抛出异常,即使它们没有写上noexcept。至于为什么
明明不抛异常却不写noexcept也是有合理原因的。比如,它们可能是用C写的库函数的一部分。(即使一些函数从
C标准库移动到了std命名空间,也可能缺少异常规范,std::strlen就是一个例子,它没有声明noexcept)。
或者它们可能是C++98库的一部分,它们不使用C++98异常规范的函数的一部分,到了C++11还没有修订。

因为有很多合理原因解释为什么noexcept依赖于缺少noexcept保证的函数,所以C++允许这些代码,编译器
一般也不会给出warnigns。

记住:

  • noexcept是函数接口的一部分,这意味着调用者会依赖它、
  • noexcept函数较之于非noexcept函数更容易优化
  • noexcept对于移动语义,swap,内存释放函数和析构函数非常有用
  • 大多数函数是异常中立的(译注:可能抛也可能不抛异常)而不是noexcept

Item 15:尽可能的使用constexpr

条款 15:尽可能的使用constexpr

如果要给C++11颁一个“最令人困惑新词”奖,constexpr十有八九会折桂。当用于对象上面,它本质上就是const的加强形式,但是当它用于函数上,意思就大不相同了。有必要消除困惑,因为你绝对会用它的,特别是当你发现constexpr “正合吾意”的时候。

从概念上来说,constexpr表明一个值不仅仅是常量,还是编译期可知的。这个表述并不全面,因为当constexpr被用于函数的时候,事情就有一些细微差别了。
为了避免我毁了结局带来的surprise,我现在只想说,你不能假设constexpr函数是const,也不能保证它们的(译注:返回)值是在编译期可知的。最有意思的是,这些是特性。关于constexpr函数返回的结果不需要是const,也不需要编译期可知这一点是良好的行为。

不过我们还是先从constexpr对象开始说起。这些对象,实际上,和const一样,它们是编译期可知的。(技术上来讲,它们的值在翻译期(translation)决议,所谓翻译不仅仅包含是编译(compilation)也包含链接(linking),除非你准备写C++的编译器和链接器,否则这些对你不会造成影响,所以你编程时无需担心,把这些constexpr对象值看做编译期决议也无妨的。)

编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。对于那些嵌入式系统的开发者,这个特性是相当重要的。更广泛的应用是“其值编译期可知”的常量整数会出现在需要“整型常量表达式( integral constant expression )的context中,这类context包括数组大小,整数模板参数(包括std::array对象的长度),枚举量,对齐修饰符(译注:alignas(val)),等等。如果你想在这些context中使用变量,你一定会希望将它们声明为constexpr,因为编译器会确保它们是编译期可知的:

1
2
3
4
5
6
7
8
int sz;                             // 非constexpr变量

constexpr auto arraySize1 = sz; // 错误! sz的值在
// 编译期不可知
std::array<int, sz> data1; // 错误!一样的问题
constexpr auto arraySize2 = 10; // 没问题,10是编译
// 期可知常量
std::array<int, arraySize2> data2; // 没问题, arraySize2是constexpr

注意const不提供constexpr所能保证之事,因为const对象不需要在编译期初始化它的值。

1
2
3
int sz;                            // 和之前一样
const auto arraySize = sz; // 没问题,arraySize是sz的常量复制
std::array<int, arraySize> data; // 错误,arraySize值在编译期不可知

简而言之,所有constexpr对象都是const,但不是所有const对象都是constexpr。如果你想编译器保证一个变量有一个可以放到那些需要编译期常量的上下文的值,你需要的工具是constexpr而不是const

如果使用场景涉及函数,那 constexpr就更有趣了。如果实参是编译期常量,它们将产出编译期值;如果是运行时值,它们就将产出运行时值。这听起来就像你不知道它们要做什么一样,那么想是错误的,请这么看:

  • constexpr函数可以用于需求编译期常量的上下文。如果你传给constexpr函数的实参在编译期可知,那么结果将在编译期计算。如果实参的值在编译期不知道,你的代码就会被拒绝。
  • 当一个constexpr函数被一个或者多个编译期不可知值调用时,它就像普通函数一样,运行时计算它的结果。这意味着你不需要两个函数,一个用于编译期计算,一个用于运行时计算。constexpr全做了。

假设我们需要一个数据结构来存储一个实验的结果,而这个实验可能以各种方式进行。实验期间风扇转速,温度等等都可能导致亮度值改变,亮度值可以是高,低,或者无。如果有n个实验相关的环境条件。它们每一个都有三个状态,最终可以得到的组合有3^n个。储存所有实验结果的所有组合需要这个数据结构足够大。假设每个结果都是int并且n是编译期已知的(或者可以被计算出的),一个std::array是一个合理的选择。我们需要一个方法在编译期计算3^n。C++标准库提供了std::pow,它的数学意义正是我们所需要的,但是,对我们来说,这里还有两个问题。第一,std::pow是为浮点类型设计的 我们需要整型结果。第二,std::pow不是constexpr(即,使用编译期可知值调用得到的可能不是编译期可知的结果),所以我们不能用它作为std::array的大小。

幸运的是,我们可以应需写个pow。我将展示怎么快速完成它,不过现在让我们先看看它应该怎么被声明和使用:

1
2
3
4
5
6
7
constexpr                               // pow是constexpr函数
int pow(int base, int exp) noexcept // 绝不抛异常
{
// 实现在这里
}
constexpr auto numConds = 5; //条件个数
std::array<int, pow(3, numConds)> results; // 结果有3^numConds个元素

回忆下pow前面的constexpr没有告诉我们pow返回一个const值,它只说了如果baseexp是编译期常量,pow返回值可能是编译期常量。如果base 和/或 exp不是编译期常量,pow结果将会在运行时计算。这意味着pow不知可以用于像std::array的大小这种需要编译期常量的地方,它也可以用于运行时环境:
1
2
3
auto base = readFromDB("base");     // 运行时获取三个值
auto exp = readFromDB("exponent");
auto baseToExp = pow(base, exp); // 运行时调用pow

因为constexpr函数必须能在编译期值调用的时候返回编译器结果,就必须对它的实现施加一些限制。这些限制在C++11和C++14标准间有所出入。

C++11中,constexpr函数的代码不超过一行语句:一个return。听起来很受限,但实际上有两个技巧可以扩展constexpr函数的表达能力。第一,使用三元运算符“?:”来代替if-else语句,第二,使用递归代替循环。因此pow可以像这样实现:

1
2
3
4
constexpr int pow(int base, int exp) noexcept
{
return (exp == 0 ? 1 : base * pow(base, exp - 1));
}

这样没问题,但是很难想象除了使用函数式语言的程序员外会觉得这样硬核的编程方式更好。在C++14中,constexpr函数的限制变得非常宽松了,所以下面的函数实现成为了可能;
1
2
3
4
5
6
constexpr int pow(int base, int exp) noexcept  // C++14
{
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;
return result;
}

constexpr函数限制为只能获取和返回字面值类型,这基本上意味着具有那些类型的值能在编译期决定。在C++11中,除了void外的所有内置类型外还包括一些用户定义的字面值,因为构造函数和其他成员函数可以是constexpr
1
2
3
4
5
6
7
8
9
10
11
12
class Point {
public:
constexpr Point(double xVal = 0, double yVal = 0) noexcept : x(xVal), y(yVal)
{}
constexpr double xValue() const noexcept { return x; }
constexpr double yValue() const noexcept { return y; }

void setX(double newX) noexcept { x = newX; }
void setY(double newY) noexcept { y = newY; }
private:
double x, y;
};

Point的构造函数被声明为constexpr,因为如果传入的参数在编译期可知,Point的数据成员也能在编译器可知。因此Point就能被初始化为constexpr
1
2
constexpr Point p1(9.4, 27.7); // 没问题,构造函数会在编译期“运行”
constexpr Point p2(28.8, 5.3); // 也没问题

类似的,xValue和yValue的getter函数也能是constexpr,因为如果对一个编译期已知的Point对象调用getter,数据成员x和y的值也能在编译期知道。这使得我们可以写一个constexpr函数里面调用Point的getter并初始化constexpr的对象:
1
2
3
4
5
6
7
constexpr
Point midpoint(const Point& p1, const Point& p2) noexcept
{
return { (p1.xValue() + p2.xValue()) / 2,
(p1.yValue() + p2.yValue()) / 2 };
}
constexpr auto mid = midpoint(p1, p2);

这太令人激动了。它意味着mid对象通过调用构造函数,getter和成员函数就能在只读内存中创建!它也意味着你可以在模板或者需要枚举量的表达式里面使用像mid.xValue()*10的表达式!它也意味着以前相对严格的某一行代码只能用于编译期,某一行代码只能用于运行时的界限变得模糊,一些运行时的普通计算能并入编译时。越多这样的代码并入,你的程序就越快。(当然,编译会花费更长时间)

在C++11中,有两个限制使得Point的成员函数setXsetY不能声明为constexpr。第一,它们修改它们操作的对象的状态, 并且在C++11中,constexpr成员函数是隐式的const。第二,它们只能有void返回类型,void类型不是C++11中的字面值类型。这两个限制在C++14中放开了,所以C++14中Point的setter也能声明为constexpr

1
2
3
4
5
6
7
class Point {
public:
...
constexpr void setX(double newX) noexcept { x = newX; }
constexpr void setY(double newY) noexcept { y = newY; }
...
};

现在也能写这样的函数:
1
2
3
4
5
6
7
constexpr Point reflection(const Point& p) noexcept
{
Point result;
result.setX(-p.xValue());
result.setY(-p.yValue());
return result;
}

客户端代码可以这样写:
1
2
3
4
5
6
constexpr Point p1(9.4, 27.7);
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);

constexpr auto reflectedMid = // reflectedMid的值
reflection(mid); // 在编译期可知

本章的建议是尽可能的使用constexpr,现在我希望大家已经明白缘由:constexopr对象和constexpr函数可以用于很多非constexpr不能使用的场景。使用constexpr关键字可以最大化你的对象和函数可以使用的场景。

还有个重要的需要注意的是constexpr是对象和函数接口的一部分。加上constexpr相当于宣称“我能在C++要求常量表达式的地方使用它”。如果你声明一个对象或者函数是constexpr,客户端程序员就会在那些场景中使用它。如果你后面认为使用constexpr是一个错误并想移除它,你可能造成大量客户端代码不能编译。尽可能的使用constexpr表示你需要长期坚持对某个对象或者函数施加这种限制。

记住

  • constexpr对象是cosnt,它的值在编译期可知
  • 当传递编译期可知的值时,cosntexpr函数可以产出编译期可知的结果

Item 16:让const成员函数线程安全

条款16: 让const成员函数线程安全

如果我们在数学领域中工作,我们就会发现用一个类表示多项式是很方便的。在这个类中,使用一个函数来计算多项式的根是很有用的。也就是多项式的值为零的时候。这样的一个函数它不会更改多项式。所以,它自然被声明为const函数。

1
2
3
4
5
6
7
8
class Polynomial {
public:
using RootsType = // 数据结构保存多项式为零的值
std::vector<double>; // (“using” 的信息查看条款9)

RootsType roots() const;

};

计算多项式的根是很复杂的,因此如果不需要的话,我们就不做。如果必须做,我们肯定不会只做一次。所以,如果必须计算它们,就缓存多项式的根,然后实现roots来返回缓存的值。下面是最基本的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
if (!rootsAreVaild) { // 如果缓存不可用
// 计算根
rootsAreVaild = true; // 用`rootVals`存储它们
}

return rootVals;
}

private:
mutable bool rootsAreVaild{ false }; // initializers 的更多信息
mutable RootsType rootVals{}; // 请查看条款7
};

从概念上讲,roots并不改变它所操作的多项式对象。但是作为缓存的一部分,它也许会改变rootValsrootsAreVaild的值。这就是mutable的经典使用样例,这也是为什么它是数据成员声明的一部分。

假设现在有两个线程同时调用Polynomial对象的roots方法:

1
2
3
4
5
Polynomial p;


/*------ Thread 1 ------*/ /*-------- Thread 2 --------*/
auto rootsOfp = p.roots(); auto valsGivingZero = p.roots();

这些用户代码是非常合理的。roots是const 成员函数,那就表示着它是一个读操作。在没有同步的情况下,让多个线程执行读操作是安全的。它最起码应该做到这点。在本例中却没有做到线程安全。因为在roots中,这些线程中的一个或两个可能尝试修改成员变量rootsAreVaildrootVals。这就意味着在没有同步的情况下,这些代码会有不同的线程读写相同的内存,这就是data race的定义。这段代码的行为是未定义的。

问题就是roots被声明为const,但不是线程安全的。const声明在c++11和c++98 中都是正确的(检索多项式的根并不会更改多项式的值),因此需要纠正的是线程安全的缺乏。

解决这个问题最普遍简单的方法就是———-使用互斥锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
std::lock_guard<std::mutex> g(m); // lock mutex

if (!rootsAreVaild) { // 如果缓存无效
// 计算/存储roots
rootsAreVaild = true;
}

return rootsVals;
} // unlock mutex

private:
mutable std::mutex m;
mutable bool rootsAreVaild { false };
mutable RootsType rootsVals {};
};

std::mutex m被声明为mutable,因为锁定和解锁它的都是non-const函数。在roots(const成员函数)中,m将被视为const对象。
值得注意的是,因为std::mutex是一种move-only的类型(一种可以移动但不能复制的类型),所以将m添加进多项式中的副作用是使它失去了被复制的能力。不过,它仍然可以移动。

在某些情况下,互斥量是过度的(?)。例如,你所做的只是计算成员函数被调用了多少次。使用std::atomic 修饰的counter(保证其他线程视这个操作为不可分割的发生,参见item40)。(然而它是否轻量取决于你使用的硬件和标准库中互斥量的实现。)以下是如何使用std::atomic来统计调用次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {									// 2D point
public:
// noexcept的使用参考Item 14
double distanceFromOrigin() const noexcept
{
++callCount; // 原子的递增

return std::sqrt((x * x) + (y * y));
}

private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};

std::mutex一样,std::atomicmove-only类型,所以在Point中调用Count的意思就是Point也是move-only的。

因为对std::atomic变量的操作通常比互斥量的获取和释放的消耗更小,所以你可能更倾向与依赖std::atomic。例如,在一个类中,缓存一个开销昂贵的int,你就会尝试使用一对std::atomic变量而不是互斥锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Widget {
public:

int magicValue() const
{
if (cacheVaild) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; // 第一步
cacheVaild = true; // 第二步
return cachedVaild;
}
}

private:
mutable std::atomic<bool> cacheVaild{ false };
mutable std::atomic<int> cachedValue;
};

这是可行的,但有时运行会比它做到更加困难。考虑:

  • 一个线程调用Widget::magicValue,将cacheValid视为false,执行这两个昂贵的计算,并将它们的和分配给cachedValue
  • 此时,第二个线程调用Widget::magicValue,也将cacheValid视为false,因此执行刚才完成的第一个线程相同的计算。(这里的“第二个线程”实际上可能是其他几个线程。)

这种行为与使用缓存的目的背道而驰。将cachedValueCacheValid的顺序交换可以解决这个问题,但结果会更糟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:

int magicValue() const
{
if (cacheVaild) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheVaild = true; // 第一步
return cachedValue = val1 + val2; // 第二步
}
}

}

假设cacheVaild是false,那么:

  • 一个线程调用Widget::magicValue,在cacheVaild 被设置成true时执行到它。
  • 在这时,第二个线程调用Widget::magicValue随后检查缓存值。看到它是true,就返回cacheValue,即使第一个线程还没有给它赋值。因此返回的值是不正确的。

这里有一个坑。对于需要同步的是单个的变量或者内存位置,使用std::atomic就足够了。
不过,一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥锁。对于Widget::magicValue是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Widget {
public:

int magicValue() const
{
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // unlock m

private:
mutable std::mutex m;
mutable int cachedValue; // no longer atomic
mutable bool cacheValid{ false }; // no longer atomic
};

现在,这个条款是基于,多个线程可以同时在一个对象上执行一个const成员函数这个假设的。如果你不是在这种情况下编写一个const成员函数。也就是你可以保证在对象上永远不会有多个线程执行该成员函数。再换句话说,该函数的线程安全是无关紧要的。比如,为单线程使用而设计类的成员函数的线程安全是不重要的。在这种情况下你可以避免,因使用 mutexstd::atomics所消耗的资源,以及包含它们的类只能使用移动语义带来的副作用。然而,这种单线程的场景越来越少见,而且很可能会越来越少。可以肯定的是,const成员函数应支持并发执行,这就是为什么你应该确保const成员函数是线程安全的。

应该注意的事情

  • 确保const成员函数线程安全,除非你确定它们永远不会在临界区(concurrent context)中使用。
  • std::atomic可能比互斥锁提供更好的性能,但是它只适合操作单个变量或内存位置。

Item 17:理解特殊成员函数的生成

条款 17:理解特殊成员函数函数的生成

在C++术语中,特殊成员函数是指C++自己生成的函数。C++98有四个:默认构造函数函数,析构函数,拷贝构造函数,拷贝赋值运算符。这些函数仅在需要的时候才生成,比如某个代码使用它们但是它们没有在类中声明。默认构造函数仅在类完全没有构造函数的时候才生成。(防止编译器为某个类生成构造函数,但是你希望那个构造函数有参数)生成的特殊成员函数是隐式public且inline,除非该类是继承自某个具有虚函数的类,否则生成的析构函数是非虚的。

但是你早就知道这些了。好吧好吧,都说古老的历史:美索不达米亚,商朝,FORTRAN,C++98。但是时代改变了,C++生成特殊成员的规则也改变了。要留意这些新规则,因为用C++高效编程方面很少有像它们一样重要的东西需要知道。

C++11特殊成员函数俱乐部迎来了两位新会员:移动构造函数和移动赋值运算符。它们的签名是:

1
2
3
4
5
6
7
class Widget {
public:
...
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
...
};

掌控它们生成和行为的规则类似于拷贝系列。移动操作仅在需要的时候生成,如果生成了,就会对非static数据执行逐成员的移动。那意味着移动构造函数根据rhs参数里面对应的成员移动构造出新部分,移动赋值运算符根据参数里面对应的非static成员移动赋值。移动构造函数也移动构造基类部分(如果有的话),移动赋值运算符也是移动赋值基类部分。

现在,当我对一个数据成员或者基类使用移动构造或者移动赋值,没有任何保证移动一定会真的发生。逐成员移动,实际上,更像是逐成员移动请求,因为对不可移动类型使用移动操作实际上执行的是拷贝操作。逐成员移动的核心是对对象使用std::move,然后函数决议时会选择执行移动还是拷贝操作。Item 23包括了这个操作的细节。本章中,简单记住如果支持移动就会逐成员移动类成员和基类成员,如果不支持移动就执行拷贝操作就好了。

两个拷贝操作是独立的:声明一个不会限制编译器声明另一个。所以如果你声明一个拷贝构造函数,但是没有声明拷贝赋值运算符,如果写的代码用到了拷贝赋值,编译器会帮助你生成拷贝赋值运算符重载。同样的,如果你声明拷贝赋值运算符但是没有拷贝构造,代码用到拷贝构造编译器就会生成它。上述规则在C++98和C++11中都成立。

如果你声明了某个移动函数,编译器就不再生成另一个移动函数。这与复制函数的生成规则不太一样:两个复制函数是独立的,声明一个不会影响另一个的默认生成。这条规则的背后原因是,如果你声明了某个移动函数,就表明这个类型的移动操作不再是“逐一移动成员变量”的语义,即你不需要编译器默认生成的移动函数的语义,因此编译器也不会为你生成另一个移动函数

再进一步,如果一个类显式声明了拷贝操作,编译器就不会生成移动操作。这种限制的解释是如果声明拷贝操作就暗示着默认逐成员拷贝操作不适用于该类,编译器会明白如果默认拷贝不适用于该类,移动操作也可能是不适用的。

这是另一个方向。声明移动操作使得编译器不会生成拷贝操作。(编译器通过给这些函数加上delete来保证,参见Item11)。比较,如果逐成员移动对该类来说不合适,也没有理由指望逐成员考吧操作是合适的。听起来会破坏C++98的某些代码,因为C++11中拷贝操作可用的条件比C++98更受限,但事实并非如此。C++98的代码没有移动操作,因为C++98中没有移动对象这种概念。只有一种方法能让老代码使用用户声明的移动操作,那就是使用C++11标准然后添加这些操作, 并在享受这些操作带来的好处同时接受C++11特殊成员函数生成规则的限制。

也许你早已听过_Rule of Three_规则。这个规则告诉我们如果你声明了拷贝构造函数,拷贝赋值运算符,或者析构函数三者之一,你应该也声明其余两个。它来源于长期的观察,即用户接管拷贝操作的需求几乎都是因为该类会做其他资源的管理,这也几乎意味着1)无论哪种资源管理如果能在一个拷贝操作内完成,也应该在另一个拷贝操作内完成2)类析构函数也需要参与资源的管理(通常是释放)。通常意义的资源管理指的是内存(如STL容器会动态管理内存),这也是为什么标准库里面那些管理内存的类都声明了“the big three”:拷贝构造,拷贝赋值和析构。

Rule of Three带来的后果就是只要出现用户定义的析构函数就意味着简单的逐成员拷贝操作不适用于该类。接着,如果一个类声明了析构也意味着拷贝操作可能不应该自定生成,因为它们做的事情可能是错误的。在C++98提出的时候,上述推理没有得倒足够的重视,所以C++98用户声明析构不会左右编译器生成拷贝操作的意愿。C++11中情况仍然如此,但仅仅是因为限制拷贝操作生成的条件会破坏老代码。

Rule of Three规则背后的解释依然有效,再加上对声明拷贝操作阻止移动操作隐式生成的观察,使得C++11不会为那些有用户定义的析构函数的类生成移动操作。所以仅当下面条件成立时才会生成移动操作:

  • 类中没有拷贝操作
  • 类中没有移动操作
  • 类中没有用户定义的析构

有时,类似的规则也会扩展至移动操作上面,因为现在类声明了拷贝操作,C++11不会为它们自动生成其他拷贝操作。这意味着如果你的某个声明了析构或者拷贝的类依赖自动生成的拷贝操作,你应该考虑升级这些类,消除依赖。假设编译器生成的函数行为是正确的(即逐成员拷贝类数据是你期望的行为),你的工作很简单,C++11的=default就可以表达你想做的:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
...
~Widget();
...
Widget(const Widget&) = default;
Widget&
operator=(const Widget&) = default; // behavior is OK
...
};

这种方法通常在多态基类中很有用,即根据继承自哪个类来定义接口。多态基类通常有一个虚析构函数,因为如果它们非虚,一些操作(比如对一个基类指针或者引用使用delete或者typeid)会产生未定义或错误结果。除非类继承自一个已经是virtual的析构函数,否则要想析构为虚函数的唯一方法就是加上virtual关键字。通常,默认实现是对的,=default是一个不错的方式表达默认实现。然而用户声明的析构函数会抑制编译器生成移动操作,所以如果该类需要具有移动性,就为移动操作加上=default。声明移动会抑制拷贝生成,所以如果拷贝性也需要支持,再为拷贝操作加上=default
1
2
3
4
5
6
7
8
9
class Base {
public:
virtual ~Base() = default;
Base(Base&&) = default;
Base& operator=(Base&&) = default;
Base(const Base&) = default;
Base& operator=(const Base&) = default;
...
};

实际上,就算编译器乐于为你的类生成拷贝和移动操作,生成的函数也如你所愿,你也应该手动声明它们然后加上=default。这看起来比较多余,但是它让你的意图更明确,也能帮助你避免一些微妙的bug。比如,你有一个字符串哈希表,即键为整数id,值为字符串,支持快速查找的数据结构:
1
2
3
4
5
6
7
 class StringTable {
public:
StringTable() {}
...
private:
std::map<int, std::string> values;
};

假设这个类没有声明拷贝操作,没有移动操作,也没有析构,如果它们被用到编译器会自动生成。没错,很方便。

后来需要在对象构造和析构中打日志,增加这种功能很简单:

1
2
3
4
5
6
7
8
9
10
11
12
class StringTable {
public:
StringTable()
{ makeLogEntry("Creating StringTable object"); }

~StringTable()
{ makeLogEntry("Destroying StringTable object"); }
...
Item 17 | 113
private:
std::map<int, std::string> values; // as before
};

看起来合情合理,但是声明析构有潜在的副作用:它阻止了移动操作的生成。然而,拷贝操作的生成是不受影响的。因此代码能通过编译,运行,也能通过功能(译注:即打日志的功能)测试。功能测试也包括移动功能,因为即使该类不支持移动操作,对该类的移动请求也能通过编译和运行。这个请求正如之前提到的,会转而由拷贝操作完成。它因为着对StringTable对象的移动实际上是对对象的拷贝,即拷贝里面的std::map<int, std::string>对象。拷贝std::map<int, std::string>对象很可能比移动慢几个数量级。简单的加个析构就引入了极大的性能问题!对拷贝和移动操作显式加个=default,问题将不再出现。

受够了我喋喋不休的讲述C++11拷贝移动规则了吧,你可能想知道什么时候我才会把注意力转入到剩下两个特殊成员函数,默认构造和析构。现在就是时候了,但是只有一句话,因为它们几乎没有改变:它们在C++98中是什么样,在C++11中就是什么样。

C++11对于特殊成员函数处理的规则如下:

  • 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成
  • 析构函数:基本上和C++98相同;稍微不同的是现在析构默认noexcept(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。
  • 拷贝构造函数:和C++98运行时行为一样:逐成员拷贝非static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete。当用户声明了拷贝赋值或者析构,该函数不再自动生成。
  • 拷贝赋值运算符:和C++98运行时行为一样:逐成员拷贝赋值非static数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete。当用户声明了拷贝构造或者析构,该函数不再自动生成。
  • 移动构造函数和移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成。

注意没有成员函数模版阻止编译器生成特殊成员函数的规则。这意味着如果Widget是这样:

1
2
3
4
5
6
7
8
class Widget {
...
template<typename T>
Widget(const T& rhs);

template<typename T>
Widget& operator=(const T& rhs); ...
};

编译器仍会生成移动和拷贝操作(假设正常生成它们的条件满足),即使可以模板实例化产出拷贝构造和拷贝赋值运算符的函数签名。(当T为Widget时)。很可能你会决定这是一个不值得承认的边缘情况,但是我提到它是有道理的,Item16将会详细讨论它可能带来的后果。

记住:

  • 特殊成员函数是编译器可能自动生成的函数:默认构造,析构,拷贝操作,移动操作
  • 移动操作仅当类没有显式声明移动操作,拷贝操作,析构时才自动生成
  • 拷贝构造仅当类没有显式声明拷贝构造时才自动生成,并且如果用户声明了移动操作,拷贝构造就是delete。拷贝赋值运算符仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是delete。当用户声明了析构函数,拷贝操作不再自动生成

CHAPTER 4 Smart Pointers

诗人和歌曲作家喜欢爱。有时候喜欢计数。很少情况下两者兼有。受伊丽莎白·巴雷特·勃朗宁(Elizabeth Barrett Browning)对爱和数的不同看法的启发(“我怎么爱你?”让我数一数。”)和保罗·西蒙(Paul Simon)(“离开你的爱人必须有50种方法。”),我们可以试着枚举一些为什么原始指针很难被爱的原因:

  1. 它的声明不能指示所指到底是单个对象还是数组
  2. 它的声明没有告诉你用完后是否应该销毁它,即指针是否拥有所指之物
  3. 如果你决定你应该销毁对象所指,没人告诉你该用delete还是其他析构机制(比如将指针传给专门的销毁函数)
  4. 如果你发现该用delete。 原因1说了不知道是delete单个对象还是delete数组。如果用错了结果是未定义的
  5. 假设你确定了指针所指,知道销毁机制,也很难确定你在所有执行路径上都执行了销毁操作(包括异常产生后的路径)。少一条路径就会产生资源泄漏,销毁多次还会导致未定义行为
  6. 一般来说没有办法告诉你指针是否变成了悬空指针(dangling pointers),即内存中不再存在指针所指之物。悬空指针会在对象销毁后仍然指向它们。

原始指针是强大的工具,当然,另一方面几十年的经验证明,只要注意力稍有疏忽,这个强大的工具就会攻击它的主人。

智能指针是解决这些问题的一种办法。智能指针包裹原始指针,它们的行为看起来像被包裹的原始指针,但避免了原始指针的很多陷阱。你应该更倾向于智能指针而不是原始指针。几乎原始指针能做的所有事情智能指针都能做,而且出错的机会更少。

在C++11中存在四种智能指针:std::auto_ptr,std::unique_ptr,std::shared_ptr,std::weak_ptr。都是被设计用来帮助管理动态对象的生命周期,在适当的时间通过适当的方式来销毁对象,以避免出现资源泄露或者异常行为。

std::auto_ptr是C++98的遗留物,它是一次标准化的尝试,后来变成了C++11的std::unique_ptr。要正确的模拟原生制作需要移动语义,但是C++98没有这个东西。取而代之,std::auto_ptr拉拢拷贝操作来达到自己的移动意图。这导致了令人奇怪的代码(拷贝一个std::auto_ptr会将它本身设置为null!)和令人沮丧的使用限制(比如不能将std::auto_ptr放入容器)。

std::unique_ptr能做std::auto_ptr可以做的所有事情以及更多。它能高效完成任务,而且不会扭曲拷贝语义。在所有方面它都比std::unique_ptr好。现在std::auto_ptr唯一合法的使用场景就是代码使用C++98编译器编译。除非你有上述限制,否则你就该把std::auto_ptr替换为std::unique_ptr而且绝不回头。

各种智能指针的API有极大的不同。唯一功能性相似的可能就是默认构造函数。因为有很多关于这些API的详细手册,所以我将只关注那些API概览没有提及的内容,比如值得注意的使用场景,运行时性能分析等,掌握这些信息可以更高效的使用智能指针。

Item 18:对于独占资源使用std::unique_ptr

当你需要一个智能指针时,std::unique_ptr通常是最合适的。可以合理假设,默认情况下,std::unique_ptr等同于原始指针,而且对于大多数操作(包括取消引用),他们执行的指令完全相同。这意味着你甚至可以在内存和时间都比较紧张的情况下使用它。如果原始指针够小够快,那么std::unique_ptr一样可以。

std::unique_ptr体现了专有所有权语义。一个non-null std::unique_ptr始终有其指向的内容。移动操作将所有权从源指针转移到目的指针,拷贝操作是不允许的,因为如果你能拷贝一个std::unique_ptr,你会得到指向相同内容的两个std::unique_ptr,每个都认为自己拥有资源,销毁时就会出现重复销毁。因此,std::unique_ptr只支持移动操作。当std::unique_ptr销毁时,其指向的资源也执行析构函数。而原始指针需要显示调用delete来销毁指针指向的资源。

std::unique_ptr的常见用法是作为继承层次结构中对象的工厂函数返回类型。假设我们有一个基类Investment(比如 stocks,bonds,real estate等)的继承结构。

1
2
3
4
class Investment { ... };
class Sock: public Investment {...};
class Bond: public Investment {...};
class RealEstate: public Investment {...};
1
2
3
4
classDiagram
Investment <|-- Sock
Investment <|-- Bond
Investment <|-- RealEstate

这种继承关系的工厂函数在堆上分配一个对象然后返回指针,调用方在不需要的时候,销毁对象。这使用场景完美匹配std::unique_ptr,因为调用者对工厂返回的资源负责(即对该资源的专有所有权),并且std::unique_ptr会自动销毁指向的内容。可以这样声明:

1
2
3
template<typename... Ts>
std::unique_ptr<Investment>
makeInvestment(Ts&&... params);

调用者应该在单独的作用域中使用返回的std::unique_ptr智能指针

1
2
3
4
5
{
...
auto pInvestment = makeInvestment(arguments);
...
} //destroy *pInvestment

但是也可以在所有权转移的场景中使用它,比如将工厂返回的std::unique_ptr移入容器中,然后将容器元素移入对象的数据成员中,然后对象随即被销毁。发生这种情况时,并且销毁该对象将导致销毁从工厂返回的资源,对象std::unique_ptr的数据成员也被销毁。如果所有权链由于异常或者其他非典型控制流出现中断(比如提前return函数或者循环中的break),则拥有托管资源的std::unique_ptr将保证指向内容的析构函数被调用,销毁对应资源。

默认情况下,销毁将通过delete进行,但是在构造过程中,可以自定义std::unique_ptr指向对象的析构函数:任意函数(或者函数对象,包括lambda)。如果通过makeInvestment创建的对象不能直接被删除,应该首先写一条日志,可以实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
auto delInvmt = [](Investemnt* pInvestment)
{
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvestment(Ts&& params)
{
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
if (/*a Stock object should be created*/)
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /* a Bond object should be created */ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /* a RealEstate object should be created */ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}

稍后,我将解释其工作原理,但首先请考虑如果你是调用者,情况如何。假设你存储makeInvestment调用结果在auto变量中,那么你将在愉快中忽略在删除过程中需要特殊处理的事实,当然,你确实幸福,因为使用了unique_ptr意味着你不需要考虑在资源释放时的路径,以及确保只释放一次,std::unique_ptr自动解决了这些问题。从使用者角度,makeInvestment接口很棒。

这个实现确实相当棒,如果你理解了:

  • delInvmt是自定义的从makeInvestmetn返回的析构函数。所有的自定义的析构行为接受要销毁对象的原始指针,然后执行销毁操作。如上例子。使用lambda创建delInvmt是方便的,而且,正如稍后看到的,比编写常规的函数更有效

  • 当使用自定义删除器时,必须将其作为第二个参数传给std::unique_ptr。对于decltype,更多信息查看Item3

  • makeInvestment的基本策略是创建一个空的std::unique_ptr,然后指向一个合适类型的对象,然后返回。为了与pInv关联自定义删除器,作为构造函数的第二个参数

  • 尝试将原始指针(比如new创建)赋值给std::unique_ptr通不过编译,因为不存在从原始指针到智能指针的隐式转换。这种隐式转换会出问题,所以禁止。这就是为什么通过reset来传递new指针的原因

  • 使用new时,要使用std::forward作为参数来完美转发给makeInvestment(查看Item 25)。这使调用者提供的所有信息可用于正在创建的对象的构造函数

  • 自定义删除器的参数类型是Investment*,尽管真实的对象类型是在makeInvestment内部创建的,它最终通过在lambda表达式中,作为Investment*对象被删除。这意味着我们通过基类指针删除派生类实例,为此,基类必须是虚函数析构

    1
    2
    3
    4
    5
    6
    class Investment {
    public:
    ...
    virtual ~Investment();
    ...
    };

在C++14中,函数的返回类型推导存在(参阅Item 3),意味着makeInvestment可以更简单,封装的方式实现:

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... Ts>
makeInvestment(Ts&& params)
{
auto delInvmt = [](Investemnt* pInvestment)
{
makeLogEntry(pInvestment);
delete pInvestment;
};
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
if (/*a Stock object should be created*/)
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /* a Bond object should be created */ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /* a RealEstate object should be created */ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}

我之前说过,当使用默认删除器时,你可以合理假设std::unique_ptr和原始指针大小相同。当自定义删除器时,情况可能不再如此。删除器是个函数指针,通常会使std::unique_ptr的字节从一个增加到两个。对于删除器的函数对象来说,大小取决于函数对象中存储的状态多少,无状态函数对象(比如没有捕获的lambda表达式)对小大没有影响,这意味当自定义删除器可以被lambda实现时,尽量使用lambda

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
auto delInvmt = [](Investemnt* pInvestment)
{
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvestment(Ts&& params); //返回Investment*的大小

void delInvmt2(Investment* pInvestment)
{
makeLogEntry(pInvestment);
delete pInvestment;
}
template<typename... Ts>
std::unique_ptr<Investment, void(*)(Investment*)>
makeInvestment(Ts&&... params); //返回Investment*的指针加至少一个函数指针的大小

具有很多状态的自定义删除器会产生大尺寸std::unique_ptr对象。如果你发现自定义删除器使得你的std::unique_ptr变得过大,你需要审视修改你的设计

工厂函数不是std::unique_ptr的唯一常见用法。作为实现Pimpl Idiom的一种机制,它更为流行。代码并不复杂,但是在某些情况下并不直观,所以这安排在Item22的专门主题中

std::unique_ptr有两种形式,一种用于单个对象(std::unique_ptr<T>),一种用于数组(std::unique_ptr<T[]>)。结果就是,指向哪种形式没有歧义。std::unique_ptr的API设计自动匹配你的用法,比如[]操作符就是数组对象,*和->就是单个对象专有

数组的std::unique_ptr的存在应该不被使用,因为std::array,std::vector,std::string这些更好用的数据容器应该取代原始数组。原始数组的使用唯一情况是使用C的API时

std::unique_ptr是C++11中表示专有所有权的方法,但是其最吸引人的功能之一是它可以轻松高效的转换为std::shared_ptr

1
std::shared_ptr<Investment> sp = makeInvestment(arguments);

这就是为什么std :: unique_ptr非常适合用作工厂函数返回类型的关键部分。 工厂函数无法知道调用者是否要对它们返回的对象使用专有所有权语义,或者共享所有权(即std :: shared_ptr)是否更合适。 通过返回std :: unique_ptr,工厂为调用者提供了最有效的智能指针,但它们并不妨碍调用者用其更灵活的兄弟替换它。 (有关std :: shared_ptr的信息,请转到Item 19

小结

  • std::unique_ptr是轻量级、快速的、只能move的管理专有所有权语义资源的智能指针
  • 默认情况,资源销毁通过delete,但是支持自定义delete函数。有状态的删除器和函数指针会增加std::unique_ptr的大小
  • std::unique_ptr转化为std::shared_ptr是简单的

Item 19:对于共享资源使用std::shared_ptr

条款十九:对于共享资源使用std::shared_ptr

程序员使用带垃圾回收的语言指着C++笑看他们如何防止资源泄露。“真是原始啊!”他们嘲笑着说。“你们没有从1960年的Lisp那里得到启发吗,机器应该自己管理资源的生命周期而不应该依赖人类。”C++程序眼滚动眼珠。“你得到的启发就是只有内存算资源,其他资源释放都是非确定性的你知道吗?我们更喜欢通用,可预料的销毁,谢谢你。”但我们的虚张声势可能底气不足。因为垃圾回收真的很方便,而且手动管理生命周期真的就像是使用石头小刀和兽皮制作RAM电路。为什么我们不能同时有两个完美的世界:一个自动工作的世界(垃圾回收),一个销毁可预测的世界(析构)?

C++11中的std::shared_ptr将两者组合了起来。一个通过std::shared_ptr访问的对象其生命周期由指向它的指针们共享所有权(shared ownership)。没有特定的std::shared_ptr拥有该对象。相反,所有指向它的std::shared_ptr都能相互合作确保在它不再使用的那个点进行析构。当最后一个std::shared_ptr到达那个点,std::shared_ptr会销毁它所指向的对象。就垃圾回收来说,客户端不需要关心指向对象的生命周期,而对象的析构是确定性的。

std::shared_ptr通过引用计数来确保它是否是最后一个指向某种资源的指针,引用计数即资源和一个值关联起来,这个值会跟踪有多少std::shared_ptr指向该资源。std::shared_ptr构造函数递增引用计数值(注意是通常——原因参见下面),析构函数递减值,拷贝赋值运算符可能递增也可能递减值。(如果sp1和sp2是std::shared_ptr并且指向不同对象,赋值运算符sp1=sp2会使sp1指向sp2指向的对象。直接效果就是sp1引用计数减一,sp2引用计数加一。)如果std::shared_ptr发现引用计数值为零,没有其他std::shared_ptr指向该资源,它就会销毁资源。

引用计数暗示着性能问题:

  • std::shared_ptr大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个资源的引用计数值
  • 引用计数必须动态分配。 理论上,引用计数与所指对象关联起来,但是被指向的对象不知道这件事情(译注:不知道有指向自己的指针)。因此它们没有办法存放一个引用计数值。Item21会解释使用std::make_shared创建std::shared_ptr可以避免引用计数的动态分配,但是还存在一些std::make_shared不能使用的场景,这时候引用计数就会动态分配。
  • 递增递减引用计数必须是原子性的,因为多个reader、writer可能在不同的线程。比如,指向某种资源的std::shared_ptr可能在一个线程执行析构,在另一个不同的线程,std::shared_ptr指向相同的对象,但是执行的确是拷贝操作。原子操作通常比非原子操作要慢,所以即使是引用计数,你也应该假定读写它们是存在开销的。

我写道std::shared_ptr构造函数只是“通常”递增指向对象的引用计数会不会让你有点好奇?创建一个指向对象的std::shared_ptr至少产生了一个指向对象的智能指针,为什么我没说总是增加引用计数值?

原因是移动构造函数的存在。从另一个std::shared_ptr移动构造新std::shared_ptr会将原来的std::shared_ptr设置为null,那意味着老的std::shared_ptr不再指向资源,同时新的std::shared_ptr指向资源。这样的结果就是不需要修改引用计数值。因此移动std::shared_ptr会比拷贝它要快:拷贝要求递增引用计数值,移动不需要。移动赋值运算符同理,所以移动赋值运算符也比拷贝赋值运算符快。

类似std::unique_ptr(参加Item18),std::shared_ptr使用delete作为资源的默认销毁器,但是它也支持自定义的销毁器。这种支持有别于std::unique_ptr。对于std::unique_ptr来说,销毁器类型是智能指针类型的一部分。对于std::shared_ptr则不是:

1
2
3
4
5
6
7
8
9
10
11
auto loggingDel = [](Widget *pw) 	//自定义销毁器
{ // (和Item 18一样)
makeLogEntry(pw);
delete pw;
};

std::unique_ptr< // 销毁器类型是
Widget, decltype(loggingDel) // ptr类型的一部分
> upw(new Widget, loggingDel);
std::shared_ptr<Widget> // 销毁器类型不是
spw(new Widget, loggingDel); // ptr类型的一部分

std::shared_ptr的设计更为灵活。考虑有两个std::shared_ptr,每个自带不同的销毁器(比如通过lambda表达式自定义销毁器):
1
2
3
4
auto customDeleter1 = [](Widget *pw) { … };
auto customDeleter2 = [](Widget *pw) { … };
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

因为pw1pw2有相同的类型,所以它们都可以放到存放那个类型的对象的容器中:
1
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

它们也能相互赋值,也可以传入形参为std::shared_ptr<Widget>的函数。但是std::unique_ptr就不行,因为std::unique_ptr把销毁器视作类型的一部分。

另一个不同于std::unique_ptr的地方是,指定自定义销毁器不会改变std::shared_ptr对象的大小。不管销毁器是什么,一个std::shared_ptr对象都是两个指针大小。这是个好消息,但是它应该让你隐隐约约不安。自定义销毁器可以是函数对象,函数对象可以包含任意多的数据。它意味着函数对象是任意大的。std::shared_ptr怎么能引用一个任意大的销毁器而不使用更多的内存?

它不能。它必须使用更多的内存。然而,那部分内存不是std::shared_ptr对象的一部分。那部分在堆上面,只要std::shared_ptr自定义了分配器,那部分内存随便在哪都行。我前面提到了std::shared_ptr对象包含了所指对象的引用计数。没错,但是有点误导人。因为引用计数是另一个更大的数据结构的一部分,那个数据结构通常叫做控制块(control block)。控制块包含除了引用计数值外的一个自定义销毁器的拷贝,当然前提是存在自定义销毁器。如果用户还指定了自定义分配器,控制器也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,正如Item21提到的,一个次级引用计数weak count,但是目前我们先忽略它。我们可以想象std::shared_ptr对象在内存中是这样:

std::shared_ptr对象一创建,对象控制块就建立了。至少我们期望是如此。通常,对于一个创建指向对象的std::shared_ptr的函数来说不可能知道是否有其他std::shared_ptr早已指向那个对象,所以控制块的创建会遵循下面几条规则:

  • std::make_shared总是创建一个控制块(参见Item21)。它创建一个指向新对象的指针,所以可以肯定std::make_shared调用时对象不存在其他控制块。
  • 当从独占指针上构造出std::shared_ptr时会创建控制块(即std::unique_ptr或者std::auto_ptr)。独占指针没有使用控制块,所以指针指向的对象没有关联其他控制块。(作为构造的一部分,std::shared_ptr侵占独占指针所指向的对象的独占权,所以std::unique_ptr被设置为null)
  • 当从原始指针上构造出std::shared_ptr时会创建控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr,你将假定传递一个std::shared_ptr或者std::weak_ptr作为构造函数实参,而不是原始指针。用std::shared_ptr或者std::weak_ptr作为构造函数实参创建std::shared_ptr不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。

这些规则造成的后果就是从原始指针上构造超过一个std::shared_ptr就会让你走上未定义行为的快车道,因为指向的对象有多个控制块关联。多个控制块意味着多个引用计数值,多个引用计数值意味着对象将会被销毁多次(每个引用计数一次)。那意味着下面的代码是有问题的,很有问题,问题很大:

1
2
3
4
5
auto pw = new Widget; // pw是原始指针

std::shared_ptr<Widget> spw1(pw, loggingDel); // 为*pw创建控制块

std::shared_ptr<Widget> spw2(pw, loggingDel); // 为*pw创建第二个控制块

创建原始指针指向动态分配的对象很糟糕,因为它完全背离了这章的建议:对于共享资源使用std::shared_ptr而不是原始指针。(如果你忘记了该建议的动机,请翻到115页)。撇开那个不说,创建pw那一行代码虽然让人厌恶,但是至少不会造成未定义程序行为。

现在,传给spw1的构造函数一个原始指针,它会为指向的对象创建一个控制块(引用计数值在里面)。这种情况下,指向的对象是*pw。就其本身而言没什么问题,但是将同样的原始指针传递给spw2的构造函数会再次为*pw创建一个控制块。因此*pw有两个引用计数值,每一个最后都会变成零,然后最终导致*pw销毁两次。第二个销毁会产生未定义行为。

std::shared_ptr给我们上了两堂课。第一,避免传给std::shared_ptr构造函数原始指针。通常替代方案是使用std::make_shared(参见Item21),不过上面例子中,我们使用了自定义销毁器,用std::make_shared就没办法做到。第二,如果你必须传给std::shared_ptr构造函数原始指针,直接传new出来的结果,不要传指针变量。如果上面代码第一部分这样重写:

1
2
std::shared_ptr<Widget> spw1(new Widget, // 直接使用new的结果
loggingDel);

会少了很多创建第二个从原始指针上构造std::shared_ptr的诱惑。相应的,创建spw2也会很自然的用spw1作为初始化参数(即用std::shared_ptr拷贝构造),那就没什么问题了:
1
std::shared_ptr<Widget> spw2(spw1);  // spw2使用spw1一样的控制块

一个尤其令人意外的地方是使用this原始指针作为std::shared_ptr构造函数实参的时候可能导致创建多个控制块。假设我们的程序使用std::shared_ptr管理Widget对象,我们有一个数据结构用于跟踪已经处理过的Widget对象:
1
std::vector<std::shared_ptr<Widget>> processedWidgets;

继续,假设Widget有一个用于处理的成员函数:
1
2
3
4
5
6
class Widget {
public:

void process();

};

对于Widget::process看起来合理的代码如下:
1
2
3
4
5
6
void Widget::process()
{
// 处理Widget
processedWidgets.emplace_back(this); // 然后将他加到已处理过的Widget的列表中
// 这是错的
}

评论已经说了这是错的——或者至少大部分是错的。(错误的部分是传递this,而不是使用了emplace_back。如果你不熟悉emplace_back,参见Item42)。上面的代码可以通过编译,但是向容器传递一个原始指针(this),std::shared_ptr会由此为指向的对象(*this)创建一个控制块。那看起来没什么问题,直到你意识到如果成员函数外面早已存在指向Widget对象的指针,它是未定义行为的Game, Set, and Match(译注:一部电影,但是译者没看过。。。)。

std::shared_ptrAPI已有处理这种情况的设施。它的名字可能是C++标准库中最奇怪的一个:std::enable_shared_from_this。它是一个用做基类的模板类,模板类型参数是某个想被std::shared_ptr管理且能从该类型的this对象上安全创建std::shared_ptr指针的存在。在我们的例子中,Widget将会继承自std::enable_shared_from_this

1
2
3
4
5
6
class Widget: public std::enable_shared_from_this<Widget> {
public:

void process();

};

正如我所说,std::enable_shared_from_this是一个用作基类的模板类。它的模板参数总是某个继承自它的类,所以Widget继承自std::enable_shared_from_this<Widget>。如果某类型继承自一个由该类型(译注:作为模板类型参数)进行模板化得到的基类这个东西让你心脏有点遭不住,别去想它就好了。代码完全合法,而且它背后的设计模式也是没问题的,并且这种设计模式还有个标准名字,尽管该名字和std::enable_shared_from_this一样怪异。这个标准名字就是奇异递归模板模式(The Curiously Recurring Template Pattern(CRTP))。如果你想学更多关于它的内容,请搜索引擎一展身手,现在我们要回到std::enable_shared_from_this上。

std::enable_shared_from_this定义了一个成员函数,成员函数会创建指向当前对象的std::shared_ptr却不创建多余控制块。这个成员函数就是shared_from_this,无论在哪当你想使用std::shared_ptr指向this所指对象时都请使用它。这里有个Widget::process的安全实现:

1
2
3
4
5
6
7
void Widget::process()
{
// 和之前一样,处理Widget

// 把指向当前对象的shared_ptr加入processedWidgets
processedWidgets.emplace_back(shared_from_this());
}

从内部来说,shared_from_this查找当前对象控制块,然后创建一个新的std::shared_ptr指向这个控制块。设计的依据是当前对象已经存在一个关联的控制块。要想符合设计依据的情况,必须已经存在一个指向当前对象的std::shared_ptr(即调用shared_from_this的成员函数外面已经存在一个std::shared_ptr)。如果没有std::shared_ptr指向当前对象(即当前对象没有关联控制块),行为是未定义的,shared_from_this通常抛出一个异常。

要想防止客户端在调用std::shared_ptr前先调用shared_from_this,继承自std::enable_shared_from_this的类通常将它们的构造函数声明为private,并且让客户端通过工厂方法创建std::shared_ptr。以Widget为例,代码可以是这样:

1
2
3
4
5
6
7
8
9
10
11
class Widget: public std::enable_shared_from_this<Widget> {
public:
// 完美转发参数的工厂方法
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);

void process(); // 和前面一样

private:

};

现在,你可能隐约记得我们讨论控制块的动机是想了解std::shared_ptr关联一个控制块的成本。既然我们已经知道了怎么避免创建过多控制块,就让我们回到原来的主题。

控制块通常只占几个word大小,自定义销毁器和分配器可能会让它变大一点。通常控制块的实现比你想的更复杂一些。它使用继承,甚至里面还有一个虚函数(用来确保指向的对象被正确销毁)。这意味着使用std::shared_ptr还会招致控制块使用虚函数带来的成本。

了解了动态分配控制块,任意大小的销毁器和分配器,虚函数机制,原子引用计数修改,你对于std::shared_ptr的热情可能有点消退。可以理解,对每个资源管理问题来说都没有最佳的解决方案。但就它提供的功能来说,std::shared_ptr的开销是非常合理的。在通常情况下,std::shared_ptr创建控制块会使用默认销毁器和默认分配器,控制块只需三个word大小。它的分配基本上是无开销的。(开销被并入了指向的对象的分配成本里。细节参见Item21)。对std::shared_ptr解引用的开销不会比原始指针高。执行原子引用计数修改操作需要承担一两个原子操作开销,这些操作通常都会一一映射到机器指令上,所以即使对比非原子指令来说,原子指令开销较大,但是它们仍然只是单个指令。对于每个被std::shared_ptr指向的对象来说,控制块中的虚函数机制产生的开销通常只需要承受一次,即对象销毁的时候。

作为这些轻微开销的交换,你得到了动态分配的资源的生命周期自动管理的好处。大多数时候,比起手动管理,使用std::shared_ptr管理共享性资源都是非常合适的。如果你还在犹豫是否能承受std::shared_ptr带来的开销,那就再想想你是否需要共享资源。如果独占资源可行或者可能可行,用std::unique_ptr是一个更好的选择。它的性能profile更接近于原始指针,并且从std::unique_ptr升级到std::shared_ptr也很容易,因为std::shared_ptr可以从std::unique_ptr上创建。

反之不行。当你的资源由std::shared_ptr管理,现在又想修改资源生命周期管理方式是没有办法的。即使引用计数为一,你也不能重新修改资源所有权,改用std::unique_ptr管理它。所有权和std::shared_ptr指向的资源之前签订的协议是“除非死亡否则永不分离”。不能离婚,不能废除,没有特许。

std::shared_ptr不能处理的另一个东西是数组。和std::unique_ptr不同的是,std::shared_ptr的API设计之初就是针对单个对象的,没有办法std::shared_ptr<T[]>。一次又一次,“聪明”的程序员踌躇于是否该使用std::shared_ptr<T>指向数组,然后传入自定义数组销毁器。(即delete [])。这可以通过编译,但是是一个糟糕的注意。一方面,std::shared_ptr没有提供operator[]重载,所以数组索引操作需要借助怪异的指针算术。另一方面,std::shared_ptr支持转换为指向基类的指针,这对于单个对象来说有效,但是当用于数组类型时相当于在类型系统上开洞。(出于这个原因,std::unique_ptr禁止这种转换。)。更重要的是,C++11已经提供了很多内置数组的候选方案(比如std::array,std::vector,std::string)。声明一个指向傻瓜数组的智能指针几乎总是标识着糟糕的设计。

记住:

  • std::shared_ptr为任意共享所有权的资源一种自动垃圾回收的便捷方式
  • 较之于std::unique_ptrstd::shared_ptr对象通常大两倍,控制块会产生开销,需要原子引用计数修改操作
  • 默认资源销毁是通过delete,但是也支持自定义销毁器。销毁器的类型是什么对于std::shared_ptr的类型没有影响
  • 避免从原始指针变量上创建std::shared_ptr

Item 20:像std::shared_ptr一样使用std::weak_ptr可能造成dangle

自相矛盾的是,如果有一个像std::shared_ptr的指针但是不参与资源所有权共享的指针是很方便的。换句话说,类似std::shared_ptr的指针但是不影响对象的引用计数。这种类型的智能指针必须要解决一个std::shared_ptr不存在的问题:可能指向已经销毁的对象。一个真正的智能指针应该跟踪所值对象,在dangle时知晓,比如当指向对象不再存在。那就是对std::weak_ptr最精确的描述。

你可能想知道什么时候该用std::weak_ptr。你可能想知道关于std::weak_ptrAPI的更多。它什么都好除了不太智能。std::weak_ptr不能解引用,也不能测试是否为空值。因为std::weak_ptr不是一个独立的智能指针。它是std::shared_ptr的增强。

这种关系在它创建之时就建立了。std::weak_ptr通常从std::shared_ptr上创建。当从std::shared_ptr上创建std::weak_ptr时两者指向相同的对象,但是std::weak_ptr不会影响所指对象的引用计数:

1
2
3
4
5
6
7
8
9
10
auto spw = 					// after spw is constructed
std::make_shared<Widget>(); // the pointed-to Widget's
// ref count(RC) is 1
// See Item 21 for in on std::make_shared

std::weak_ptr<Widget> wpw(spw); // wpw points to same Widget as spw. RC remains 1

spw = nullptr; // RC goes to 0, and the
// Widget is destroyed.
// wpw now dangles

std::weak_ptrexpired来表示已经dangle。你可以用它直接做测试:

1
if (wpw.expired()) … // if wpw doesn't point to an object

但是通常你期望的是检查std::weak_ptr是否已经失效,如果没有失效则访问其指向的对象。这做起来比较容易。因为缺少解引用操作,没有办法写这样的代码。即使有,将检查和解引用分开会引入竞态条件:在调用expired和解引用操作之间,另一个线程可能对指向的对象重新赋值或者析构,并由此造成对象已析构。这种情况下,你的解引用将会产生未定义行为。

你需要的是一个原子操作实现检查是否过期,如果没有过期就访问所指对象。这可以通过从std::weak_ptr创建std::shared_ptr来实现,具体有两种形式可以从std::weak_ptr上创建std::shared_ptr,具体用哪种取决于std::weak_ptr过期时你希望std::shared_ptr表现出什么行为。一种形式是std::weak_ptr::lock,它返回一个std::shared_ptr,如果std::weak_ptr过期这个std::shared_ptr为空:

1
2
3
std::shared_ptr<Widget> spw1 = wpw.lock();  // if wpw's expired, spw1 is null

auto spw2 = wpw.lock(); // same as above, but uses auto

另一种形式是以std::weak_ptr为实参构造std::shared_ptr。这种情况中,如果std::weak_ptr过期,会抛出一个异常:
1
std::shared_ptr<Widget> spw3(wpw);			// if wpw's expired, throw std::bad_weak_ptr

但是你可能还想知道为什么std::weak_ptr就有用了。考虑一个工厂函数,它基于一个UID从只读对象上产出智能指针。根据Item18的描述,工厂函数会返回一个该对象类型的std::unique_ptr
1
std::unique_ptr<const Widget> loadWidget(WidgetID id);

如果调用loadWidget是一个昂贵的操作(比如它操作文件或者数据库I/O)并且对于ID来重复使用很常见,一个合理的优化是再写一个函数除了完成loadWidget做的事情之外再缓存它的结果。当请求获取一个Widget时阻塞在缓存操作上这本身也会导致性能问题,所以另一个合理的优化可以是当Widget不再使用的时候销毁它的缓存。

对于可缓存的工厂函数,返回std::unique_ptr不是好的选择。调用者接受缓存后的对象的只能指针,调用者也应该确定这些对象的生命周期,但是缓存本身也需要一个指针指向它所缓的对象。缓存对象的指针需要知道它是否已经dangle,因为当工厂客户端使用完工厂产生的对象后,对象将被销毁,关联的缓存条目会dangle。所以缓存应该使用std::weak_ptr,这可以知道是否已经dangle。这意味着工厂函数返回值类型应该是std::shared_ptr,因为std::weak_ptr依赖std::shared_ptr

下面是一个粗制滥造的缓存版本的loadWidget实现:

1
2
3
4
5
6
7
8
9
10
11
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache; // 译者注:这里是高亮
auto objPtr = cache[id].lock(); // objPtr is std::shared_ptr to cached object (or null if object's not in cache)
if (!objPtr) { // if not in cache
objPtr = loadWidget(id); // load it
cache[id] = objPtr; // cache it
}
return objPtr;
}

这个实现使用了C++11的hash表容器std::unordered_map,尽管没有显式表明需要WidgetID哈希和相等性比较的能力。

fastLoadWidget的实现忽略了以下事实:cache可能会累积expired的与已经销毁的Widget相关联的std::weak_ptr。可以改进实现方式,但不要花时间在不会引起对std :: weak_ptr的深入了解的问题上,让我们考虑第二个用例:观察者设计模式。此模式的主要组件是subjects(状态可能会更改的对象)和observers(状态发生更改时要通知的对象)。在大多数实现中,每个subject都包含一个数据成员,该成员持有指向其observer的指针。这使subject很容易发布状态更改通知。subject对控制observers的生命周期(例如,当它们被销毁时)没有兴趣,但是subject对确保observers被销毁时,不会访问它具有极大的兴趣 。一个合理的设计是每个subject持有其observers的std::weak_ptr,因此可以在使用前检查是否已经dangle。

作为最后一个使用std::weak_ptr的例子,考虑一个持有三个对象A,B,C的数据结构,A和C共享B的所有权,因此持有std::shared_ptr

image-20201101170753295

假定从B指向A的指针也很有用。应该使用哪种指针?

image-20201101170921305

有三种选择:

  • 原始指针。使用这种方法,如果A被销毁,但是C继续指向B,B就会有一个指向A的悬垂指针。而且B不知道指针已经悬垂,所以B可能会继续访问,就会导致未定义行为
  • std::shared_ptr。这种设计,A和B都互相持有对方的std::shared_ptr,导致std::shared_ptr在销毁时出现循环。即使A和B无法从其他数据结构被访问(比如,C不再指向B),每个的引用计数都是1.如果发升了这种情况,A和B都被泄露:程序无法访问它们,但是资源并没有被回收。
  • std::weak_ptr。这避免了上述两个问题。如果A被销毁,B还是有dangle指针,但是B可以检查。尤其是尽管A和B互相指向,B的指针不会影响A的引用计数,因此不会导致无法销毁。

使用std::weak_ptr显然是这些选择中最好的。但是,需要注意使用std::weak_ptr打破std::shared_ptr循环并不常见。在严格分层的数据结构比如树,子节点只被父节点持有。当父节点被销毁时,子节点就被销毁。从父到子的链接关系可以使用std::unique_ptr很好的表征。从子到父的反向连接可以使用原始指针安全实现,因此子节点的生命周期肯定短于父节点。因此子节点解引用一个悬垂的父节点指针是没有问题的。

当然,不是所有的使用指针的数据结构都是严格分层的,所以当发生这种情况时,比如上面所述cache和观察者情况,知道std::weak_ptr随时待命也是不错的。

从效率角度来看,std::weak_ptrstd::shared_ptr基本相同。两者的大小是相同的,使用相同的控制块(参见Item 19),构造、析构、赋值操作涉及引用计数的原子操作。这可能让你感到惊讶,因为本Item开篇就提到std::weak_ptr不影响引用计数。我写的是std::weak_ptr不参与对象的共享所有权,因此不影响指向对象的引用计数。实际上在控制块中还是有第二个引用计数,std::weak_ptr操作的是第二个引用计数。想了解细节的话,继续看Item 21吧。

记住

  • std::shared_ptr使用std::weak_ptr可能会dangle
  • std::weak_ptr的潜在使用场景包括:caching、observer lists、打破std::shared_ptr指向循环

Item 21:优先考虑使用std::make_unique和std::make_shared而非new

让我们先对std::make_unique和std::make_shared做个铺垫。std::make_shared 是C++11标准的一部分,但很可惜的是,std::make_unique不是。它从C++14开始加入标准库。如果你在使用C++11,不用担心,一个基础版本的std::make_unique是很容易自己写出的,如下:

1
2
3
4
5
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

正如你看到的,make_unique只是将它的参数完美转发到所要创建的对象的构造函数,从新产生的原始指针里面构造出std::unique_ptr,并返回这个std::unique_ptr。这种形式的函数不支持数组和自定义析构,但它给出了一个示范:只需一点努力就能写出你想要的make_uniqe函数。需要记住的是,不要把它放到std命名空间中,因为你可能并不希望在升级厂家编译器到符合C++14标准的时候产生冲突。

std::make_uniquestd::make_shared有三个make functions中的两个:接收抽象参数,完美转发到构造函数去动态分配一个对象,然后返回这个指向这个对象的指针。第三个make function 是std::allocate_shared.它和std::make_shared一样,除了第一个参数是用来动态分配内存的对象。

即使是对使用和不使用make函数创建智能指针的最简单比较,也揭示了为什么最好使用这些函数的第一个原因。例如:

1
2
3
4
auto upw1(std::make_unique<Widget>());  // with make func
std::unique_ptr<Widget> upw2(new Widget); // without make func
auto spw1(std::make_shared<Widget>()); // with make func
std::shared_ptr<Widget> spw2(new Widget); // without make func

我高亮了区别:使用new的版本重复了类型,但是make function的版本没有。(译者注:这里高亮的是Widget,用new的声明语句需要写2遍Widget,make function只需要写一次) 重复写类型和软件工程里面一个关键原则相冲突:应该避免重复代码。源代码中的重复增加了编译的时间,会导致目标代码冗余,并且通常会让代码库使用更加困难。它经常演变成不一致的代码,而代码库中的不一致常常导致bug。此外,打两次字比一次更费力,而且谁不喜欢减少打字负担?

第二个使用make function的原因和异常安全有段。假设我们有个函数按照某种优先级处理Widget:

1
void processWidget(std::shared_ptr<Widget> spw, int priority);

根据值传递std::shared ptr可能看起来很可疑,但是Item 41解释了,如果processWidget总是复制std::shared ptr(例如,通过将其存储在已处理的Widget的数据结构中),那么这可能是一个可复用的设计选择。

现在假设我们有一个函数来计算相关的优先级

int computePriority();

并且我们在调用processWidget时使用了new而不是std:: make_shared

1
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak!

如注释所说,这段代码可能在new Widget时发生泄露。为何?调用的代码和被调用的函数都用std::shared_ptrs,且std::shared_ptrs就是设计出来防止泄露的。它们会在最后一个std::shared_ptr销毁时自动释放所指向的内存。如果每个人在每个地方都用std::shared_ptrs,这段代码怎么会泄露呢?

答案和编译器将源码转换为目标代码有关。在运行时,一个函数的参数必须先被计算,才能被调用,所以在调用processWidget之前,必须执行以下操作,processWidget才开始执行:

  • 表达式’new Widget’必须计算,例如,一个Widget对象必须在堆上被创建
  • 负责管理new出来指针的std::shared_ptr<Widget>构造函数必须被执行
  • computePriority()必须运行

编译器不需要按照执行顺序生成代码。“new Widget”必须在std::shared_ptr的构造函数被调用前执行,因为new出来的结果作为构造函数的参数,但compute Priority可能在这之前,之后,或者之间执行。也就是说,编译器可能按照这个执行顺序生成代码:

  1. 执行new Widget
  2. 执行computePriority
  3. 运行std::shared_ptr构造函数

如果按照这样生成代码,并且在运行是computePriority产生了异常,那么第一步动态分配的Widget就会泄露。因为它永远都不会被第三步的std::shared_ptr所管理了。

使用std::make_shared可以防止这种问题。调用代码看起来像是这样:

1
processWidget(std::make_shared<Widget>(), computePriority());

在运行时,std::make_shared和computePriority会先被调用。如果是std::make_shared,在computePriority调用前,动态分配Widget的原始指针会安全的保存在作为返回值的std::shared_ptr中。如果compu tePriority生成一个异常,那么std::shared_ptr析构函数将确保管理的Widget被销毁。如果首先调用computePriority并产生一个异常,那么std::make_shared将不会被调用,因此也就不需要担心new Widget(会泄露)。

如果我们将std::shared_ptr,std::make_shared替换成std::unique_ptr,std::make_unique,同样的道理也适用。因此,在编写异常安全代码时,使用std::make_unique而不是new与使用std::make_shared同样重要。

std::make_shared的一个特性(与直接使用new相比)得到了效率提升。使用std::make_shared允许编译器生成更小,更快的代码,并使用更简洁的数据结构。考虑以下对new的直接使用:

1
std::shared_ptr<Widget> spw(new Widget);

显然,这段代码需要进行内存分配,但它实际上执行了两次.Item 19解释了每个std::shared_ptr指向一个控制块,其中包含被指向对象的引用计数。这个控制块的内存在std::shared_ptr构造函数中分配。因此,直接使用new需要为Widget分配一次内存,为控制块分配再分配一次内存。

如果使用std::make_shared代替:auto spw = std::make_shared_ptr<Widget>();一次分配足矣。这是因为std::make_shared分配一块内存,同时容纳了Widget对象和控制块。这种优化减少了程序的静态大小,因为代码只包含一个内存分配调用,并且它提高了可执行代码的速度,因为内存只分配一次。此外,使用std::make_shared避免了对控制块中的某些簿记信息的需要,潜在地减少了程序的总内存占用。

对于std::make_shared的效率分析同样适用于std::allocate_shared,因此std::make_shared的性能优势也扩展到了该函数。

更倾向于使用函数而不是直接使用new的争论非常激烈。尽管它们在软件工程、异常安全和效率方面具有优势,但本item的意见是,更倾向于使用make函数,而不是完全依赖于它们。这是因为有些情况下它们不能或不应该被使用。

例如,没有make函数允许指定定制的析构(见item18和19),但是std::unique_ptr和std::shared_ptr有构造函数这么做。给Widget自定义一个析构:

1
auto widgetDeleter = [](Widget*){...};

使用new创建智能指针非常简单:
1
2
3
4
std::unique_ptr<Widget, decltype(widgetDeleter)>
upw(new Widget, widgetDeleter);

std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

对于make函数,没有办法做同样的事情。

make函数第二个限制来自于其单一概念的句法细节。Item7解释了,当构造函数重载,有std::initializer_list作为参数和不用其作为参数时,用大括号创建对象更倾向于使用std::initializer_list作为参数的构造函数,而用圆括号创建对象倾向于不用std::initializer_list作为参数的构造函数。make函数会将它们的参数完美转发给对象构造函数,但是它们是使用圆括号还是大括号?对某些类型,问题的答案会很不相同。例如,在这些调用中,

1
2
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);

生成的智能指针是否指向带有10个元素的std::vector,每个元素值为20,或指向带有两个元素的std::vector,其中一个元素值10,另一个为20 ?或者结果是不确定的?

好消息是这并非不确定:两种调用都创建了10个元素,每个值为20.这意味着在make函数中,完美转发使用圆括号,而不是大括号。坏消息是如果你想用大括号初始化指向的对象,你必须直接使用new。使用make函数需要能够完美转发大括号初始化,但是,正如item31所说,大括号初始化无法完美转发。但是,item30介绍了一个变通的方法:使用auto类型推导从大括号初始化创建std::initializer_list对象(见Item 2),然后将auto创建的对象传递给make函数。

1
2
3
4
// create std::initializer_list
auto initList = { 10, 20 };
// create std::vector using std::initializer_list ctor
auto spv = std::make_shared<std::vector<int>>(initList);

对于std::unique_ptr,只有这两种情景(定制删除和大括号初始化)使用make函数有点问题。对于std::shared_ptr和它的make函数,还有至少2个问题。都属于边界问题,但是一些开发者常碰到,你也可能是其中之一。

一些类重载了operator new和operator delete。这些函数的存在意味着对这些类型的对象的全局内存分配和释放是不合常规的。设计这种定制类往往只会精确的分配、释放对象的大小。例如,Widget类的operator new和operator delete只会处理sizeof(Widget)大小的内存块的分配和释放。这种常识不太适用于std::shared_ptr对定制化分配(通过std::allocate_shared)和释放(通过定制化deleters),因为std::allocate_shared需要的内存总大小不等于动态分配的对象大小,还需要再加上控制块大小。因此,适用make函数去创建重载了operator new 和 operator delete类的对象是个典型的糟糕想法。

与直接使用new相比,std::make_shared在大小和速度上的优势源于std::shared_ptr的控制块与指向的对象放在同一块内存中。当对象的引用计数降为0,对象被销毁(析构函数被调用).但是,因为控制块和对象被放在同一块分配的内存块中,直到控制块的内存也被销毁,它占用的内存是不会被释放的。

正如我说,控制块除了引用计数,还包含簿记信息。引用计数追踪有多少std::shared_ptrs指向控制块,但控制块还有第二个计数,记录多少个std::weak_ptrs指向控制块。第二个引用计数就是weak count。当一个std::weak_ptr检测对象是否过期时(见item 19),它会检测指向的控制块中的引用计数(而不是weak count)。如果引用计数是0(即对象没有std::shared_ptr再指向它,已经被销毁了),std::weak_ptr已经过期。否则就没过期。

只要std::weak_ptrs引用一个控制块(即weak count大于零),该控制块必须继续存在。只要控制块存在,包含它的内存就必须保持分配。通过std::shared_ptr make函数分配的内存,直到最后一个std::shared_ptr和最后一个指向它的std::weak_ptr已被销毁,才会释放。

如果对象类型非常大,而且销毁最后一个std::shared_ptr和销毁最后一个std::weak_ptr之间的时间很长,那么在销毁对象和释放它所占用的内存之间可能会出现延迟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReallyBigType { … };

// 通过std::make_shared创建一个大对象
auto pBigObj = std::make_shared<ReallyBigType>();

// 创建 std::shared_ptrs 和 std::weak_ptrs
// 指向这个对象,使用它们

// 最后一个 std::shared_ptr 在这销毁,
// 但 std::weak_ptrs 还在

// 在这个阶段,原来分配给大对象的内存还分配着

// 最后一个std::weak_ptr在这里销毁;
// 控制块和对象的内存被释放

直接只用new,一旦最后一个std::shared_ptr被销毁,ReallyBigType对象的内存就会被释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ReallyBigType { … };

//通过new创建特大对象
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);

// 像之前一样,创建 std::shared_ptrs 和 std::weak_ptrs
// 指向这个对象,使用它们

// 最后一个 std::shared_ptr 在这销毁,
// 但 std::weak_ptrs 还在

// memory for object is deallocated

// 在这阶段,只有控制块的内存仍然保持分配

// 最后一个std::weak_ptr在这里销毁;
// 控制块内存被释放

如果你发现自己处于不可能或不合适使用std::make_shared的情况下,你将想要保证自己不受我们之前看到的异常安全问题的影响。最好的方法是确保在直接使用new时,在一个不做其他事情的语句中,立即将结果传递到智能指针构造函数。这可以防止编译器生成的代码在使用new和调用管理新对象的智能指针的构造函数之间发生异常。

例如,考虑我们前面讨论过的processWidget函数,对其非异常安全调用的一个小修改。这一次,我们将指定一个自定义删除器:

1
2
void processWidget(std::shared_ptr<Widget> spw, int priority);
void cusDel(Widget *ptr); // 自定义删除器

这是非异常安全调用:

1
2
3
4
5
//和之前一样,潜在的内存泄露
processWidget(
std::shared_ptr<Widget>(new Widget, cusDel),
computePriority()
);

回想一下:如果computePriority在“new Widget”之后,而在std::shared_ptr构造函数之前调用,并且如果computePriority产生一个异常,那么动态分配的Widget将会泄漏。

这里使用自定义删除排除了对std::make_shared的使用,因此避免这个问题的方法是将Widget的分配和std::shared_ptr的构造放入它们自己的语句中,然后使用得到的std::shared_ptr调用processWidget。这是该技术的本质,不过,正如我们稍后将看到的,我们可以对其进行调整以提高其性能:

1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); // 正确,但是没优化,见下

这是可行的,因为std::shared_ptr假定了传递给它的构造函数的原始指针的所有权,即使构造函数产生了一个异常。此例中,如果spw的构造函数抛出异常(即无法为控制块动态分配内存),仍然能够保证cusDel会在new Widget产生的指针上调用。

一个小小的性能问题是,在异常不安全调用中,我们将一个右值传递给processWidget

1
2
3
4
processWidget(
std::shared_ptr<Widget>(new Widget, cusDel), // arg is rvalue
computePriority()
);

但是在异常安全调用中,我们传递了左值
1
processWidget(spw, computePriority());  //spw是左值

因为processWidget的std::shared_ptr参数是传值,传右值给构造函数只需要move,而传递左值需要拷贝。对std::shared_ptr而言,这种区别是有意义的,因为拷贝std::shared_ptr需要对引用计数原子加,move则不需要对引用计数有操作。为了使异常安全代码达到异常不安全代码的性能水平,我们需要用std::move将spw转换为右值.
1
processWidget(std::move(spw), computePriority());

这很有趣,也值得了解,但通常是无关紧要的,因为您很少有理由不使用make函数。除非你有令人信服的理由这样做,否则你应该使用make函数。

记住:

  • 和直接使用new相比,make函数消除了代码重复,提高了异常安全性。对于std::make_sharedstd::allocate_shared,生成的代码更小更快。
  • 不适合使用make函数的情况包括需要指定自定义删除器和希望用大括号初始化
  • 对于std::shared_ptrs, make函数可能不被建议的其他情况包括
    (1)有自定义内存管理的类和
    (2)特别关注内存的系统,非常大的对象,以及std::weak_ptrs比对应的std::shared_ptrs活得更久

Item 22:当使用Pimpl惯用法,请在实现文件中定义特殊成员函数

如果你曾经与过多的编译次数斗争过,你会对Pimpl(Pointer to implementation)惯用法很熟悉。 凭借这样一种技巧,你可以把一个类数据成员替换成一个指向包含具体实现的类(或者结构体), 将放在主类(primary class)的数据成员们移动到实现类去(implementation class), 而这些数据成员的访问将通过指针间接访问呢。 举个例子,假如有一个类Widget看起来如下:

1
2
3
4
5
6
7
8
9
10
class Widget()      //定义在头文件`widget.h`
{
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; //Gadget是用户自定义的类型
}

因为类Widget的数据成员包含有类型std::stringstd::vectorGadget, 定义有这些类型的头文件在类Widget编译的时候,必须被包含进来,这意味着类Widget的使用者必须要#include <string>,<vector>以及gadget.h。 这些头文件将会增加类Widget使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget使用者也必须要重新编译。 标准库文件<string><vector>不是很常变,但是gadget.h可能会经常修订。

在C++98中使用Pimpl惯用法,可以把Widget的数据成员替换成一个原始指针(raw pointer),指向一个已经被声明过却还未被定义的类,如下:

1
2
3
4
5
6
7
8
9
10
11
class Widget       //仍然在"Widget.h"中
{
public:
Widget();
~Widget(); //析构函数在后面会分析
...

private:
struct Impl; //声明一个 实现结构体
Impl *pImpl; //以及指向它的指针
}

因为类Widget不再提到类型std:::string,std::vector以及Gadget,Widget的使用者不再需要为了这些类型而引入头文件。 这可以加速编译,并且意味着,如果这些头文件中有所变动,Widget 的使用者不会受到影响。

一个已经被声明,却还未被实现的类型,被称为未完成类型(incomplete type)。 Widget::Impl就是这种类型。 你能对一个未完成类型做的事很少,但是声明一个指向它指针是可以的。 Pimpl手法利用了这一点。

Pimpl惯用法的第一步,是声明一个数据成员,它是个指针,指向一个未完成类型。 第二步是动态分配(dynamic allocation)和回收一个对象,该对象包含那些以前在原来的类中的数据成员。 内存分配和回收的代码都写在实现文件(implementation file)里,比如,对于类Widget而言,写在Widget.cpp里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl //之前在Widget中声明的Widget::Impl类型的定义
{
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}

Widget::Widget() //为此Widget对象分配数据成员
: pImpl(new Impl)
{}

Widget::~Widget()
{delete pImpl;} //销毁数据成员

在这里我把#include命令写出来是为了明确一点,对于头文件std::string,std::vectorGadget的整体依赖依然存在。 然而,这些依赖从头文件widget.h(它被所有Widget类的使用者包含,并且对他们可见)移动到了widget.cpp(该文件只被Widget类的实现者包含,并只对它可见)。 我高亮了其中动态分配和回收Impl对象的部分(markdown高亮不了,实际是newdelete两部分——译者注)。这就是为什么我们需要Widget的析构函数——我们需要回收该对象。

但是,我展示给你们看的是一段C++98的代码,散发着一股已经过去了几千年的腐朽气息。 它使用了原始指针,原始的new和原始的delete,一切都让它如此的…原始。这一章建立在“智能指针比原始指针更好”的主题上,并且,如果我们想要的只是在类Widget的构造函数动态分配Widget::impl对象,在Widget对象销毁时一并销毁它, std::unique_ptr(见Item 18)是最合适的工具。 在头文件中用std::unique_ptr替代原始指针,就有了如下代码:

1
2
3
4
5
6
7
8
9
10
class Widget       //在"Widget.h"中
{
public:
Widget();
...

private:
struct Impl; //声明一个 实现结构体
std::unique_ptr<Impl> pImpl; //使用智能指针而不是原始指针
}

实现文件也可以改成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl //跟之前一样
{
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}

Widget::Widget() //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

你会注意到,Widget的析构函数不存在了。这是因为我们没有代码加在里面了。 std::unique_ptr在自身析构时,会自动销毁它所指向的对象,所以我们自己无需手动销毁任何东西。这就是智能指针的众多优点之一:它使我们从手动资源释放中解放出来。

以上的代码能编译,但是,最普通的Widget用法却会导致编译出错:

1
2
3
#include "widget.h"

Wdiget w; //编译出错

你所看到的错误信息根据编译器不同会有所不同,但是其文本一般会提到一些有关于把sizeofdelete应用到未完成类型incomplete type的信息。对于未完成类型,使用以上操作是禁止的。

Pimpl惯用法中使用std::unique_ptr会抛出错误,有点惊悚,因为第一std::unique_ptr宣称它支持未完成类型,第二Pimpl惯用法是std::unique_ptr的最常见的用法。 幸运的是,让这段代码能正常运行很简单。 只需要对是什么导致以上代码编译出错有一个基础的认识就可以了。

在对象w被析构时,例如离开了作用域(scope),问题出现了。在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr,所以我们没有声明一个析构函数,因为我们并没有任何代码需要写在里面。根据编译器自动生成的特殊成员函数的规则(见 Item 17),编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类Widget的数据成员Pimpl的析构函数。 Pimpl是一个std::unique_ptr<Widget::Impl>,也就是说,一个带有默认销毁器(default deleter)的std::unique_ptr。 默认销毁器(default deleter)是一个函数,它使用delete来销毁内置于std::unique_ptr的原始指针。然而,在使用delete之前,通常会使默认销毁器使用C++11的特性static_assert来确保原始指针指向的类型不是一个未完成类型。 当编译器为Widget w的析构生成代码时,它会遇到static_assert检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象w销毁的地方出现,因为类Widget的析构函数,正如其他的编译器生成的特殊成员函数一样,是暗含inline属性的。 错误信息自身往往指向对象w被创建的那行,因为这行代码明确地构造了这个对象,导致了后面潜在的析构。

为了解决这个问题,你只需要确保在编译器生成销毁std::unique_ptr<Widget::Imple>的代码之前, Widget::Impl已经是一个完成类型(complete type)。 当编译器”看到”它的定义的时候,该类型就成为完成类型了。 但是 Widget::Impl的定义在wideget.cpp里。成功编译的关键,就是,在widget.cpp文件内,让编译器在”看到” Widget的析构函数实现之前(也即编译器自动插入销毁std::unique_ptr的数据成员的位置),先定义Wdiget::Impl

做出这样的调整很容易。只需要在先在widget.h里,只声明(declare)类Widget的析构函数,却不要在这里定义(define)它:

1
2
3
4
5
6
7
8
9
10
class Widget {      // as before, in "widget.h"
public:
Widget();
~Widget(); // declaration only
...

private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};

widget.cpp文件中,在结构体Widget::Impl被定义之后,再定义析构函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl //跟之前一样,定义Widget::Impl
{
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}

Widget::Widget() //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

Widget::~Widget() //析构函数的定义(译者注:这里高亮)
{}

这样就可以了,并且这样增加的代码也最少,但是,如果你想要强调编译器自动生成的析构函数会工作的很好——你声明Widget的析构函数的唯一原因,是确保它会在Widget的实现文件内(指widget.cpp,译者注)被自动生成,你可以把析构函数体直接定义为=default:

1
Widget::~Widget() = default;       //同上述代码效果一致

使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意: 对隐藏的std::unique_ptr进行移动。 正如Item 17所解释的那样,声明一个类Widget的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,你必须自己声明相关的函数。考虑到编译器自动生成的版本能够正常功能,你可能会被诱使着来这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget       //在"Widget.h"中
{
public:
Widget();
~Widget();
...

Widget(Widget&& rhs) = default; //思路正确,但代码错误
Widget& operator=(Widget&& rhs) = default;


private:
struct Impl; //如上
std::unique_ptr<Impl> pImpl;
}

这样的做法会导致同样的错误,和之前的声明一个不带析构函数的类的错误一样,并且是因为同样的原因。 编译器生成的移动赋值操作符(move assignment operator),在重新赋值之前,需要先销毁指针pImpl指向的对象。然而在Widget的头文件里,pImpl指针指向的是一个未完成类型。情况和移动构造函数(move constructor)有所不同。 移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁pImpl的代码。然而,销毁pImpl需要Impl是一个完成类型。

因为这个问题同上面一致,所以解决方案也一样——把移动操作的定义移动到实现文件里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget       //在"Widget.h"中
{
public:
Widget();
~Widget();
...

Widget(Widget&& rhs); //仅声明
Widget& operator=(Widget&& rhs);


private:
struct Impl; //如上
std::unique_ptr<Impl> pImpl;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl //跟之前一样,定义Widget::Impl
{
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}

Widget::Widget() //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

Widget::~Widget() = default;

Widget(Widget&& rhs) = default; //在这里定义
Widget& operator=(Widget&& rhs) = default;

pImpl惯用法是用来减少类实现者和类使用者之间的编译依赖的一种方法,但是,从概念而言,使用这种惯用法并不改变这个类的表现。 原来的类Widget包含有std::string,std::vectorGadget数据成员,并且,假设类型Gadget,如同std::stringstd::vector一样,允许复制操作,所以类Widget支持复制操作也很合理。 我们必须要自己来写这些函数,因为第一,对包含有只可移动(move-only)类型,如std::unique_ptr的类,编译器不会生成复制操作;第二,即使编译器帮我们生成了,生成的复制操作也只会复制std::unique_ptr(也即浅复制(shallow copy)),而实际上我们需要复制指针所指向的对象(也即深复制(deep copy))。

使用我们已经熟悉的方法,我们在头文件里声明函数,而在实现文件里去实现他们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget       //在"Widget.h"中
{
public:
Widget();
~Widget();
...

Widget(const Widget& rhs); //仅声明
Widget& operator=(const Widget& rhs);


private:
struct Impl; //如上
std::unique_ptr<Impl> pImpl;
}
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 "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl //跟之前一样,定义Widget::Impl
{
...
}

Widget::Widget() //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

Widget::~Widget() = default;
...
Widget::Widget(const Widget& rhs)
:pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}

Widget& Widget::operator=(const Widget& rhs)
{
*pImpl = *rhs.pImpl;
return *this;
}

两个函数的实现都比较中规中矩。 在每个情况中,我们都只从源对象(rhs)中,复制了结构体Impl的内容到目标对象中(*this)。我们利用了编译器会为我们自动生成结构体Impl的复制操作函数的机制,而不是逐一复制结构体Impl的成员,自动生成的复制操作能自动复制每一个成员。 因此我们通过调用Widget::Impl的编译器生成的复制操作函数来实现了类Widget的复制操作。 在复制构造函数中,注意,我们仍然遵从了Item 21的建议,使用std::make_unique而非直接使用new

为了实现Pimpl惯用法,std::unique_ptr是我们使用的智能指针,因为位于对象内部的pImpl指针(例如,在类Widget内部),对所指向的对应实现的对象的享有独占所有权(exclusive ownership)。然而,有趣的是,如果我们使用std::shared_ptr而不是std::unique_ptr来做pImpl指针, 我们会发现本节的建议不再适用。 我们不需要在类Widget里声明析构函数,也不用用户定义析构函数,编译器将会愉快地生成移动操作,并且将会如我们所期望般工作。代码如下:

1
2
3
4
5
6
7
8
9
//在Widget.h中
class Widget{
public:
Widget();
... //没有对移动操作和析构函数的声明
private:
struct Impl;
std::shared_ptr<Impl> pImpl; //使用std::shared_ptr而非std::unique_ptr
}

而类Widget的使用者,使用#include widget.h,可以使用如下代码

1
2
3
Widget w1;
auto w2(std::move(w1)); //移动构造w2
w1 = std::move(w2); //移动赋值w1

这些都能编译,并且工作地如我们所望: w1将会被默认构造,它的值会被移动进w2,随后值将会被移动回w1,然后两者都会被销毁(因此导致指向的Widget::Impl对象一并也被销毁)。

std::unique_ptrstd::shared_ptrpImpl指针上的表现上的区别的深层原因在于,他们支持自定义销毁器(custom deleter)的方式不同。 对std::unique_ptr而言,销毁器的类型是unique_ptr的一部分,这让编译器有可能生成更小的运行时数据结构和更快的运行代码。 这种更高效率的后果之一就是unique_ptr指向的类型,在编译器的生成特殊成员函数被调用时(如析构函数,移动操作)时,必须已经是一个完成类型。 而对std::shared_ptr而言,销毁器的类型不是该智能指针的一部分,这让它会生成更大的运行时数据结构和稍微慢点的代码,但是当编译器生成的特殊成员函数被使用的时候,指向的对象不必是一个完成类型。(译者注: 知道unique_ptrshared_ptr的实现,这一段才比较容易理解。)

对于pImpl惯用法而言,在std::unique_ptrstd::shared_ptr的特性之间,没有一个比较好的折中。 因为对于类Widget以及Widget::Impl而言,他们是独享占有权关系,这让std::unique_ptr使用起来很合适。 然而,有必要知道,在其他情况中,当共享所有权(shared ownership)存在时,std::shared_ptr是很适用的选择的时候,没有必要使用std::unique_ptr所必需的声明——定义(function-definition)这样的麻烦事了。

记住

  • pImpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
  • 对于std::unique_ptr类型的pImpl指针,需要在头文件的类里声明特殊的成员函数,但是在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。
  • 以上的建议只适用于std::unique_ptr,不适用于std::shared_ptr

CHAPTER 5 RValue References, Move Semantics and Perfect Forwarding

当你第一次了解到移动语义完美转发的时候,它们看起来非常直观:

  • 移动语义使编译器有可能用廉价的移动操作来代替昂贵的复制操作。正如复制构造函数和复制赋值操作符给了你赋值对象的权利一样,移动构造函数和移动赋值操作符也给了控制移动语义的权利。移动语义也允许创建只可移动(move-only)的类型,例如std::unique_ptr, std::futurestd::thread

  • 完美转发使接收任意数量参数的函数模板成为可能,它可以将参数转发到其他的函数,使目标函数接收到的参数与被传递给转发函数的参数保持一致。

右值引用是连接这两个截然不同的概念的胶合剂。它隐藏在语言机制之下,使移动语义和完美转发变得可能。

你对这些特点(features)越熟悉,你就越会发现,你的初印象只不过是冰山一角。移动语义、完美转发和右值引用的世界比它所呈现的更加微妙。
举个例子,std::move并不移动任何东西,完美转发也并不完美。移动操作并不永远比复制操作更廉价;即便如此,它也并不总是像你期望的那么廉价。而且,它也并不总是被调用,即使在当移动操作可用的时候。构造type&&也并非总是代表一个右值引用。

无论你挖掘这些特性有多深,它们看起来总是还有更多隐藏起来的部分。幸运的是,它们的深度总是有限的。本章将会带你到最基础的部分。一旦到达,C++11的这部分特性将会具有非常大的意义。比如,你会掌握std::movesd::forward的惯用法。你能够对type&&的歧义性质感到舒服。你会理解移动操作的令人惊奇的不同代价的背后真相。这些片段都会豁然开朗。在这一点上,你会重新回到一开始的状态,因为移动语义、完美转发和右值引用都会又一次显得直截了当。但是这一次,它们不再使人困惑。

在本章的这些小节中,非常重要的一点是要牢记参数(parameter)永远是左值(lValue),即使它的类型是一个右值引用。比如,假设

1
void f(Widget&& w);

参数w是一个左值,即使它的类型是一个Widget的右值引用(如果这里震惊到你了,请重新回顾从本书第二页开始的关于左值和右值的总览。)

Item 23: 理解std::move和std::forward

为了了解std::movestd::forward,一种有用的方式是从它们不做什么这个角度来了解它们。std::move不移动(move)任何东西,std::forward也不转发(forward)任何东西。在运行期间(run-time),它们不做任何事情。它们不产生任何可执行代码,一字节也没有。

std::movestd::forward仅仅是执行转换(cast)的函数(事实上是函数模板)。std::move无条件的将它的参数转换为右值,而std::forward只在特定情况满足时下进行转换。
它们就是如此。这样的解释带来了一些新的问题,但是从根本上而言,这就是全部内容。

为了使这个故事更加的具体,这里是一个C++11的std::move的示例实现。它并不完全满足标准细则,但是它已经非常接近了。

1
2
3
4
5
6
7
8
9
template <typename T>                       //in namespace std
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType = // alias declaration;
typename remove_reference<T>::type&&; // 见 Item 9

return static_cast<ReturnType>(param);
}

我为你们高亮了这段代码的两部分(译者注:markdown不支持代码段内高亮。高亮的部分为movestatic_cast)。一个是函数名字,因为函数的返回值非常具有干扰性。而且我不想你们被它搞得晕头转向。另外一个高亮的部分是包含这段函数的本质的转换。正如你所见,std::move接受一个对象的引用(准确的说,一个通用引用(universal reference),后见Item 24),返回一个指向同对象的引用。

该函数返回类型的&&部分表明std::move函数返回的是一个右值引用,但是,正如Item 28所解释的那样,如果类型T恰好是一个左值引用,那么T&&将会成为一个左值引用。为了避免如此,类型萃取器(type trait,见Item 9)std::remove_reference应用到了类型T上,因此确保了&&被正确的应用到了一个不是引用的类型上。这保证了std::move返回的真的是右值引用,这很重要,因为函数返回的右值引用是右值(rvalues)。因此,std::move将它的参数转换为一个右值,这就是它的全部作用。

此外,std::move在C++14中可以被更简单地实现。多亏了函数返回值类型推导(见Item 3)和标准库的模板别名std::remove_reference_t(见Item 9),std::move可以这样写:

1
2
3
4
5
6
template <typename T>
decltype(auto) move(T&& param) //C++14;still in namesapce std
{
using ReturnType = remove_referece_t<T>&&;
return static_cast<ReturnType>(param);
}

看起来更简单,不是吗?

因为std::move除了转换它的参数到右值以外什么也不做,有一些提议说它的名字叫rvalue_cast可能会更好。虽然可能确实是这样,但是它的名字已经是std::move,所以记住std::move做什么和不做什么很重要。它其实并不移动任何东西。

当然,右值本来就是移动操作的侯选者,所以对一个对象使用std::move就是告诉编译器,这个对象很适合被移动。所以这就是为什么std::move叫现在的名字: 更容易指定可以被移动的对象。

事实上,右值只不过经常是移动操作的候选者。假设你有一个类,它用来表示一段注解。这个类的构造函数接受一个包含有注解的std::string作为参数,然后它复制该参数到类的数据成员(data member)。假设你了解Item 41,你声明一个值传递(by value)的参数:

1
2
3
4
5
class Annotation {
public:
explicit Annotation(std::string text); //将会被复制的参数
... //如同 Item 41,
}; //值传递

但是Annotation类的构造函数仅仅是需要读取参数text的值,它并不需要修改它。为了和历史悠久的传统:能使用const就使用const保持一致,你修订了你的声明以使text变成const

1
2
3
4
5
class Annotation {
public:
explicit Annotation(const std::string text);
...
};

当复制参数text到一个数据成员的时候,为了避免一次复制操作的代价,你仍然记得来自Item 41的建议,把std::move应用到参数text上,因此产生一个右值,

1
2
3
4
5
6
7
8
9
10
11
class Annotation {
public:
explicit Annotation(const std::string text)
value(std::move(text)) //"move" text到value上;这段代码执行起来
//并不如看起来那样
{...}
...

private:
std::string value;
};

这段代码可以编译,可以链接,可以运行。这段代码将数据成员value设置为text的值。这段代码与你期望中的完美实现的唯一区别,是text并不是被移动到value,而是被复制。诚然,text通过std::move被转换到右值,但是text被声明为const std::string,所以在转换之前,text是一个左值的const std::string,而转换的结果是一个右值的const std::string,但是纵观全程,const属性一直保留。

当编译器决定哪一个std::string的构造函数被构造时,考虑它的作用,将会有两种可能性。

1
2
3
4
5
6
class string {                  //std::string事实上是
public: //std::basic_string<char>的类型别名
...
string(const string& rhs); //复制构造函数
string(string&& rhs); //移动构造函数
}

在类Annotation的构造函数的成员初始化列表(member initialization list)中,std::move(text)的结构是一个const std::string的右值。这个右值不能被传递给std::string的移动构造函数,因为移动构造函数只接受一个指向非常量(non-const)std::string的右值引用。然而,该右值却可以被传递给std::string的复制构造函数,因为指向常量的左值引用允许被绑定到一个常量右值上。因此,std::string在成员初始化的过程中调用了复制构造函数,即使text已经被转换成了右值。这样是为了确保维持常量属性的正确性。从一个对象中移动(Moving)出某个值通常代表着修改该对象,所以语言不允许常量对象被传递给可以修改他们的函数(例如移动构造函数)。

从这个例子中,可以总结出两点。第一,不要在你希望能移动对象的时候,声明他们为常量。对常量对象的移动请求会悄无声息的被转化为复制操作。第二点,std::move不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。关于std::move,你能确保的唯一一件事就是将它应用到一个对象上,你能够得到一个右值。

关于std::forward的故事与std::move是相似的,但是与std::move总是无条件的将它的参数转换为右值不同,std::forward只有在满足一定条件的情况下才执行转换。std::forward有条件的转换。要明白什么时候它执行转换,什么时候不,想想std::forward的典型用法。
最常见的情景是一个模板函数,接收一个通用引用参数(universal reference parameter),并将它传递给另外的函数:

1
2
3
4
5
6
7
8
9
10
11
void process(const Widget& lvalArg);  //左值处理
void process(Widget&& rvalArg); //右值处理

template <typename T> //用以转发参数到process的模板
void logAndProcess(T&& param)
{
auto now = //获取现在时间
std::chrono::system_clock::now();
makeLogEntry("calling 'process',now);
process(std::forward<T>(param));
}

考虑两次对logAndProcess的调用,一次左值为参数,一次右值为参数,

1
2
3
4
Widget w;

logAndProcess(w); //call with lvalue
logAndProcess(std::move(w)); //call with rvalue

logAndProcess函数的内部,参数param被传递给函数process。函数process分别对左值和右值参数做了重载。当我们使用左值来调用logAndProcess时,自然我们期望该左值被当作左值转发给process函数,而当我们使用右值来调用logAndProcess函数时,我们期望process函数的右值重载版本被调用。

但是参数param,正如所有的其他函数参数一样,是一个左值。每次在函数logAndProcess内部对函数process的调用,都会因此调用函数process的左值重载版本。为防如此,我们需要一种机制(mechanism) : 当且仅当传递给函数logAndProcess的用以初始化参数param的值是一个右值时,参数param会被转换为有一个右值。这就是为什么std::forward是一个有条件的转换:它只把由右值初始化的参数,转换为右值。

你也许会想知道std::forward是怎么知道它的参数是否是被一个右值初始化的。举个例子,在上述代码中,std::forward是怎么分辨参数param是被一个左值还是右值初始化的? 简短的说,该信息藏在函数logAndProcess的模板参数T中。该参数被传递给了函数std::forward,它解开了含在其中的信息。该机制工作的细节可以查询 Item 28.

考虑到std::movestd::forward都可以归结于转换,他们唯一的区别就是std::move总是执行转换,而std::forward偶尔为之。你可能会问是否我们可以免于使用std::move而在任何地方只使用std::forward。 从纯技术的角度,答案是yes: std::forward是可以完全胜任,std::move并非必须。当然,其实两者中没有哪一个函数是真的必须的,因为我们可以到处直接写转换代码,但是我希望我们能同意:这将相当的,嗯,让人恶心。

std::move的吸引力在于它的便利性: 减少了出错的可能性,增加了代码的清晰程度。考虑一个类,我们希望统计有多少次移动构造函数被调用了。我们只需要一个静态的计数器(static counter),它会在移动构造的时候自增。假设在这个类中,唯一一个非静态的数据成员是std::string,一种经典的移动构造函数(例如,使用std::move)可以被实现如下:

1
2
3
4
5
6
7
8
9
10
11
class Widget{
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s))
{
++moveCtorCalls;
}
private:
static std::size_t moveCtorCalls;
std::string s;
}

如果要用std::forward来达成同样的效果,代码可能会看起来像

1
2
3
4
5
6
7
8
9
class Widget{
public:
Widget(Widget&& rhs) //不自然,不合理的实现
: s(std::forward<std::string>(rhs.s))
{
++moveCtorCalls;
}
...
}

注意,第一,std::move只需要一个函数参数(rhs.s),而std::forward不但需要一个函数参数(rhs.s),还需要一个模板类型参数std::string。其次,我们转发给std::forward的参数类型应当是一个非引用(non-reference),因为传递的参数应该是一个右值(见 Item 28)。 同样,这意味着std::move比起std::forward来说需要打更少的字,并且免去了传递一个表示我们正在传递一个右值的类型参数。同样,它根绝了我们传递错误类型的可能性,(例如,std::string&可能导致数据成员s被复制而不是被移动构造)。

更重要的是,std::move的使用代表着无条件向右值的转换,而使用std::forward只对绑定了右值的引用进行到右值转换。这是两种完全不同的动作。前者是典型地为了移动操作,而后者只是传递(亦作转发)一个对象到另外一个函数,保留它原有的左值属性或右值属性。因为这些动作实在是差异太大,所以我们拥有两个不同的函数(以及函数名)来区分这些动作。

记住:

  • std::move执行到右值的无条件的转换,但就自身而言,它不移动任何东西。
  • std::forward只有当它的参数被绑定到一个右值时,才将参数转换为右值。
  • std::movestd::forward在运行期什么也不做。

Item 24:区分通用引用与右值引用

据说,真相使人自由,然而在特定的环境下,一个精心挑选的谎言也同样使人解放。这一节就是这样一个谎言。因为我们在和软件打交道,然而,让我们避开“谎言(lie)”这个词,不妨说,本节包含了一种“抽象(abstraction)”。

为了声明一个指向某个类型T的右值引用(Rvalue Reference), 你写下了T&&。由此,一个合理的假设是,当你看到一个T&&出现在源码中,你看到的是一个右值引用。唉,事情并不如此简单:

1
2
3
4
5
6
7
8
9
void f(Widget&& param);     //右值引用
Widget&& var1 = Widget(); //右值引用
auto&& var2 = var1; //不是右值引用

template <typename T>
void f(std::vector<T>&& param); //右值引用

template <typename T>
void f(T&& param); //不是右值引用

事实上,T&&有两种不同的意思。第一种,当然是右值引用。这种引用表现得正如你所期待的那样: 它们只绑定到右值上,并且它们主要的存在原因就是为了声明某个对象可以被移动。

T&&的第二层意思,是它既可以是一个右值引用,也可以是一个左值引用。这种引用在源码里看起来像右值引用(也即T&&),但是它们可以表现得它们像是左值引用(也即T&)。它们的二重性(dual nature)使它们既可以绑定到右值上(就像右值引用),也可以绑定到左值上(就像左值引用)。 此外,它们还可以绑定到常量(const)和非常量(non-const)的对象上,也可以绑定到volatilenon-volatile的对象上,甚至可以绑定到即constvolatile的对象上。它们可以绑定到几乎任何东西。这种空前灵活的引用值得拥有自己的名字。我把它叫做通用引用(universal references)。(注: Item 25解释了std::forward几乎总是可以应用到通用引用上,并且在这本书即将出版之际,一些C++社区的成员已经开始将这种通用引用称之为转发引用(forwarding references))。

在两种情况下会出现通用引用。最常见的一种是函数模板参数,正如在之前的示例代码中所出现的例子:

1
2
template <typename T>
void f(T&& param); //param是一个通用引用

第二种情况是auto声明符,包含从以上示例中取得的这个例子:

1
auto&& val2 = var1;     //var2是一个通用引用

这两种情况的共同之处就是都存在类型推导(type deduction)。在模板f的内部,参数param的类型需要被推导,而在变量var2的声明中,var2的类型也需要被推导。同以下的例子相比较(同样来自于上面的示例代码),下面的例子不带有类型推导。如果你看见T&&不带有类型推导,那么你看到的就是一个右值引用。

1
2
3
4
void f(Widget&& param);         //没有类型推导
//param是一个右值引用
Widget&& var1 = Widget(); //没有类型推导
//var1是一个右值引用

因为通用引用是引用,所以他们必须被初始化。一个通用引用的初始值决定了它是代表了右值引用还是左值引用。如果初始值是一个右值,那么通用引用就会是对应的右值引用,如果初始值是一个左值,那么通用引用就会是一个左值引用。对那些是函数参数的通用引用来说,初始值在调用函数的时候被提供:

1
2
3
4
5
6
7
8
9
template <typename T>
void f(T&& param); //param是一个通用引用

Widget w;
f(w); //传递给函数f一个左值;参数param的类型
//将会是Widget&,也即左值引用

f(std::move(w)); //传递给f一个右值;参数param的类型会是
//Widget&&,即右值引用

对一个通用引用而言,类型推导是必要的,但是它还不够。声明引用的格式必须正确,并且这种格式是被限制的。它必须是准确的T&&。再看看之前我们已经看过的代码示例:

1
2
template <typename T>
void f(std::vector<T>&& param); //param是一个右值引用

当函数f被调用的时候,类型T会被推导(除非调用者显式地指定它,这种边缘情况我们不考虑)。但是参数param的类型声明并不是T&&,而是一个std::vector<T>&&。这排除了参数param是一个通用引用的可能性。param因此是一个右值引用——当你向函数f传递一个左值时,你的编译器将会开心地帮你确认这一点:

1
2
std::vector<int> v;
f(v); //错误!不能将左值绑定到右值引用

即使是出现一个简单的const修饰符,也足以使一个引用失去成为通用引用的资格:

1
2
template <typename T>
void f(const T&& param); //param是一个右值引用

如果你在一个模板里面看见了一个函数参数类型为T&&,你也许觉得你可以假定它是一个通用引用。错!这是由于在模板内部并不保证一定会发生类型推导。考虑如下push_back成员函数,来自std::vector:

1
2
3
4
5
6
7
template <class T,class Allocator = allocator<T>> //来自C++标准
class vector
{
public:
void push_back(T&& x);
...
}

push_back函数的参数当然有资格成为一个通用引用,然而,在这里并没有发生类型推导。
因为push_back在一个特有(particular)的vector实例化(instantiation)之前不可能存在,而实例化vector时的类型已经决定了push_back的声明。也就是说,

1
std::vector<Widget> v;

将会导致std::vector模板被实例化为以下代码:

1
2
3
4
5
class vector<Widget , allocagor<Widget>>
{
public:
void push_back(Widget&& x); // 右值引用
}

现在你可以清楚地看到,函数push_back不包含任何类型推导。push_back对于vector<T>而言(有两个函数——它被重载了)总是声明了一个类型为指向T的右值引用的参数。

相反,std::vector内部的概念上相似的成员函数emplace_back,却确实包含类型推导:

1
2
3
4
5
6
7
8
template <class T,class Allocator = allocator<T>> //依旧来自C++标准
class vector
{
public:
template <class... Args>
void emplace_back(Args&&... args);
...
}

这儿,类型参数(type parameter)Args是独立于vector的类型参数之外的,所以Args会在每次emplace_back被调用的时候被推导(Okay,Args实际上是一个参数包(parameter pack),而不是一个类型参数,但是为了讨论之利,我们可以把它当作是一个类型参数)。

虽然函数emplace_back的类型参数被命名为Args,但是它仍然是一个通用引用,这补充了我之前所说的,通用引用的格式必须是T&&。 没有任何规定必须使用名字T。举个例子,如下模板接受一个通用引用,但是格式(type&&)是正确的,并且参数param的类型将会被推导(重复一次,不考虑边缘情况,也即当调用者明确给定参数类型的时候)。

1
2
template <typename MyTemplateType>          //param是通用引用
void someFunc(MyTemplateType&& param);

我之前提到,类型为auto的变量可以是通用引用。更准确地说,类型声明为auto&&的变量是通用引用,因为会发生类型推导,并且它们满足正确的格式要求(T&&)。auto类型的通用引用不如模板函数参数中的通用引用常见,但是它们在C++11中常常突然出现。而它们在C++14中出现地更多,因为C++14的匿名函数表达式(lambda expressions)可以声明auto&&类型的参数。举个例子,如果你想写一个C++14标准的匿名函数,来记录任意函数调用花费的时间,你可以这样:

1
2
3
4
5
6
7
8
9
auto timeFuncInvocation =
[](auto&& func, auto&&... params) //C++14标准
{
start timer;
std::forward<decltype(func)>(func)( //对参数params调用func
std::forward<delctype(params)>(params)...
);
stop timer and record elapsed time;
};

如果你对位于匿名函数里的std::forward<decltype(blah blah blah)>反应是”What the ….!”, 这只代表着你可能还没有读 Item 33。别担心。在本节,重要的事是匿名函数声明的auto&&类型的参数。func是一个通用引用,可以被绑定到任何可被调用的对象,无论左值还是右值。args0个或者多个通用引用(也就是说,它是个通用引用参数包(a universal reference parameter pack)),它可以绑定到任意数目、任意类型的对象上。
多亏了auto类型的通用引用,函数timeFuncInvocation可以对近乎任意(pretty-much any)函数进行计时。(如果你想知道任意(any)近乎任意(pretty-much any的区别,往后翻到 Item 30)。

牢记整个本小节——通用引用的基础——是一个谎言,uhh,一个“抽象”。隐藏在其底下的真相被称为”引用折叠(reference collapsing)“,小节Item 28致力于讨论它。但是这个真相并不降低该抽象的有用程度。区分右值引用和通用引用将会帮助你更准确地阅读代码(”究竟我眼前的这个T&&是只绑定到右值还是可以绑定任意对象呢?”),并且,当你在和你的合作者交流时,它会帮助你避免歧义(”在这里我在用一个通用引用,而非右值引用”)。它也可以帮助你弄懂Item 25和26,它们依赖于右值引用和通用引用的区别。所以,拥抱这份抽象,陶醉于它吧。就像牛顿的力学定律(本质上不正确),比起爱因斯坦的相对论(这是真相)而言,往往更简单,更易用。所以这份通用引用的概念,相较于穷究引用折叠的细节而言,是更合意之选。

记住:

  • 如果一个函数模板参数的类型为T&&,并且T需要被推导得知,或者如果一个对象被声明为auto&&,这个参数或者对象就是一个通用引用。
  • 如果类型声明的形式不是标准的type&&,或者如果类型推导没有发生,那么type&&代表一个右值引用。
  • 通用引用,如果它被右值初始化,就会对应地成为右值引用;如果它被左值初始化,就会成为左值引用。

Item 25: 对右值引用使用std::move,对通用引用使用std::forward

右值引用仅绑定可以移动的对象。如果你有一个右值引用参数,你就知道这个对象可能会被移动:

1
2
3
4
class Widget {
Widget(Widget&& rhs); //rhs definitely refers to an object eligible for moving
...
};

这是个例子,你将希望通过可以利用该对象右值性的方式传递给其他使用对象的函数。这样做的方法是将绑定次类对象的参数转换为右值。如Item23中所述,这不仅是std::move所做,而且是为它创建:

1
2
3
4
5
6
7
8
class Widget {
public:
Widget(Widget&& rhs) :name(std::move(rhs.name)), p(std::move(rhs.p)) {...}
...
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

另一方面(查看Item24),通用引用可能绑定到有资格移动的对象上。通用引用使用右值初始化时,才将其强制转换为右值。Item23阐释了这正是std::forward所做的:

1
2
3
4
5
6
7
8
class Widget {
public:
template<typename T>
void setName(T&& newName) { //newName is universal reference
name = std::forward<T>(newName);
}
...
}

总而言之,当传递给函数时右值引用应该无条件转换为右值(通过std::move),通用引用应该有条件转换为右值(通过std::forward)。

Item23 解释说,可以在右值引用上使用std::forward表现出适当的行为,但是代码较长,容易出错,所以应该避免在右值引用上使用std::forward。更糟的是在通用引用上使用std::move,这可能会意外改变左值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {
public:
template<typename T>
void setName(T&& newName) {
name = std::move(newName); //universal reference compiles, but is bad ! bad ! bad !
}
...

private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); // factory function

Widget w;
auto n = getWidgetName(); // n is local variiable
w.setName(n); // move n into w! n's value now unkown

上面的例子,局部变量n被传递给w.setName,可以调用方对n只有只读操作。但是因为setName内部使用std::move无条件将传递的参数转换为右值,n的值被移动给w,n最终变为未定义的值。这种行为使得调用者蒙圈了。

你可能争辩说setName不应该将其参数声明为通用引用。此类引用不能使用const(Item 24),但是setName肯定不应该修改其参数。你可能会指出,如果const左值和右值分别进行重载可以避免整个问题,比如这样:

1
2
3
4
5
6
7
8
9
class Widget {
public:
void setName(const std::string& newName) { // set from const lvalue
name = newName;
}
void setName(std::string&& newName) { // set from rvalue
name = std::move(newName);
}
};

这样的话,当然可以工作,但是有缺点。首先编写和维护的代码更多;其次,效率下降。比如,考虑如下场景:

1
w.setName("Adela Novak");

使用通用引用的版本,字面字符串”Adela Novak”可以被传递给setName,在w内部使用了std::string的赋值运算符。w的name的数据成员直接通过字面字符串直接赋值,没有中间对象被创建。但是,重载版本,会有一个中间对象被创建。一次setName的调用会包括std::string的构造器调用(中间对象),std::string的赋值运算调用,std::string的析构调用(中间对象)。这比直接通过const char*赋值给std::string开销昂贵许多。实际的开销可能因为库的实现而有所不同,但是事实上,将通用引用模板替换成多个函数重载在某些情况下会导致运行时的开销。如果例子中的Widget数据成员是任意类型(不一定是std::string),性能差距可能会变得更大,因为不是所有类型的移动操作都像std::string开销较小(参看Item29)。

但是,关于重载函数最重要的问题不是源代码的数量,也不是代码的运行时性能。而是设计的可扩展性差。Widget::setName接受一个参数,可以是左值或者右值,因此需要两种重载实现,n个参数的话,就要实现$2^n$种重载。这还不是最坏的。有的函数—-函数模板——接受无限制参数,每个参数都可以是左值或者右值。此类函数的例子比如std::make_unique或者std::make_shared。查看他们的的重载声明:

1
2
3
4
5
template<class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);

template<class T, class... Args>
unique_ptr<T> make_unique(Args&&... args);

对于这种函数,对于左值和右值分别重载就不能考虑了:通用引用是仅有的实现方案。对这种函数,我向你保证,肯定使用std::forward传递通用引用给其他函数。

好吧,通常,最终。但是不一定最开始就是如此。在某些情况,你可能需要在一个函数中多次使用绑定到右值引用或者通用引用的对象,并且确保在完成其他操作前,这个对象不会被移动。这时,你只想在最后一次使用时,使用std::move或者std::forward。比如:

1
2
3
4
5
6
7
8
9
template<typename T>
void setSignText(T&& text)
{
sign.setText(text);

auto now = std::chrono::system_clock::now();

signHistory.add(now, std::forward<T>(text));
}

这里,我们想要确保text的值不会被sign.setText改变,因为我们想要在signHistory.add中继续使用。因此std::forward只在最后使用。

对于std::move,同样的思路,但是需要注意,在有些稀少的情况下,你需要调用std::move_if_noexcept代替std::move。要了解何时以及为什么,参考Item 14。

如果你使用的按值返回的函数,并且返回值绑定到右值引用或者通用引用上,需要对返回的引用使用std::move或者std::forward。要了解原因,考虑+操作两个矩阵的函数,左侧的矩阵参数为右值(可以被用来保存求值之后的和)

1
2
3
4
Matrix operator+(Matrix&& lhs, const Matrix& rhs){
lhs += rhs;
return std::move(lhs); // move lhs into return value
}

通过在返回语句中将lhs转换为右值,lhs可以移动到返回值的内存位置。如果std::move省略了

1
2
3
4
Matrix operator+(Matrix&& lhs, const Matrix& rhs){
lhs += rhs;
return lhs; // copy lhs into return value
}

事实上,lhs作为左值,会被编译器拷贝到返回值的内存空间。假定Matrix支持移动操作,并且比拷贝操作效率更高,使用std::move的代码效率更高。

如果Matrix不支持移动操作,将其转换为左值不会变差,因为右值可以直接被Matrix的拷贝构造器使用。如果Matrix随后支持了移动操作,+操作符的定义将在下一次编译时受益。就是这种情况,通过将std::move应用到返回语句中,不会损失什么,还可能获得收益。

使用通用引用和std::forward的情况类似。考虑函数模板reduceAndCopy收到一个未规约对象Fraction,将其规约,并返回一个副本。如果原始对象是右值,可以将其移动到返回值中,避免拷贝开销,但是如果原始对象是左值,必须创建副本,因此如下代码:

1
2
3
4
5
template<typename T>
Fraction reduceAndCopy(T&& frac) {
frac.reduce();
return std::forward<T>(frac); // move rvalue into return value, copy lvalue
}

如果std::forward被忽略,frac就是无条件复制到返回值内存空间。

有些开发者获取到上面的知识后,并尝试将其扩展到不适用的情况。

1
2
3
4
5
Widget makeWidget() {
Widget w; //local variable
... // configure w
return w; // "copy" w into return value
}

想要优化copy的动作为如下代码:

1
2
3
4
5
Widget makeWidget() {
Widget w; //local variable
... // configure w
return std::move(w); // move w into return value(don't do this!)
}

这种用法是有问题的,但是问题在哪?

在进行优化时,标准化委员会远领先于开发者,第一个版本的makeWidget可以在分配给函数返回值的内存中构造局部变量w来避免复制局部变量w的需要。这就是所谓的返回值优化(RVO),这在C++标准中已经实现了。

所以”copy”版本的makeWidget在编译时都避免了拷贝局部变量w,进行了返回值优化。(返回值优化的条件:1. 局部变量与返回值的类型相同;2. 局部变量就是返回值)。

移动版本的makeWidget行为与其名称一样,将w的内容移动到makeWidget的返回值位置。但是为什么编译器不使用RVO消除这种移动,而是在分配给函数返回值的内存中再次构造w呢?条件2中规定,仅当返回值为局部对象时,才进行RVO,但是move版本不满足这条件,再次看一下返回语句:

1
return std::move(w);

返回的已经不是局部对象w,而是局部对象w的引用。返回局部对象的引用不满足RVO的第二个条件,所以编译器必须移动w到函数返回值的位置。开发者试图帮助编译器优化反而限制了编译器的优化选项。

(译者注:本段即绕又长,大意为即使开发者非常熟悉编译器,坚持要在局部变量上使用std::move返回)

这仍然是一个坏主意。C++标准关于RVO的部分表明,如果满足RVO的条件,但是编译器选择不执行复制忽略,则必须将返回的对象视为右值。实际上,标准要求RVO,忽略复制或者将sdt::move隐式应用于返回的本地对象。因此,在makeWidget的”copy”版本中,编译器要不执行复制忽略的优化,要不自动将std::move隐式执行。

按值传递参数的情形与此类似。他们没有资格进行RVO,但是如果作为返回值的话编译器会将其视作右值。结果就是,如果代码如下:

1
2
3
4
Widget makeWidget(Widget w) {
...
return w;
}

实际上,编译器的代码如下:

1
2
3
4
Widget makeWidget(Widget w){
...
return std::move(w);
}

这意味着,如果对从按值返回局部对象的函数使用std::move,你并不能帮助编译器,而是阻碍其执行优化选项。在某些情况下,将std::move应用于局部变量可能是一件合理的事,但是不要阻碍编译器RVO。

需要记住的点

  • 在右值引用上使用std::move,在通用引用上使用std::forward
  • 对按值返回的函数返回值,无论返回右值引用还是通用引用,执行相同的操作
  • 当局部变量就是返回值是,不要使用std::move或者std::forward

Item 26: 避免在通用引用上重载

假定你需要写一个函数,它使用name这样一个参数,打印当前日期和具体时间到日志中,然后将name加入到一个全局数据结构中。你可能写出来这样的代码:

1
2
3
4
5
6
7
std::multiset<std::string> names; // global data structure
void logAndAdd(const std::string& name)
{
auto now = std::chrono::system_lock::now(); // get current time
log(now, "logAndAdd"); // make log entry
names.emplace(name); // add name to global data structure; see Item 42 for info on emplace
}

这份代码没有问题,但是同样的也没有效率。考虑这三个调用:

1
2
3
4
std::string petName("Darla");
logAndAdd(petName); // pass lvalue std::string
logAndAdd(std::string("Persephone")); // pass rvalue std::string
logAndAdd("Patty Dog"); // pass string literal

在第一个调用中,logAndAdd使用变量作为参数。在logAndAddname最终也是通过emplace传递给names。因为name是左值,会拷贝到names中。没有方法避免拷贝,因为是左值传递的。

在第三个调用中,参数name绑定一个右值,但是这次是通过”Patty Dog”隐式创建的临时std::string变量。在第二个调用总,name被拷贝到names,但是这里,传递的是一个字符串字面量。直接将字符串字面量传递给emplace,不会创建std::string的临时变量,而是直接在std::multiset中通过字面量构建std::string。在第三个调用中,我们会消耗std::string的拷贝开销,但是连移动开销都不想有,更别说拷贝的。

我们可以通过使用通用引用(参见Item 24)重写第二个和第三个调用来使效率提升,按照Item 25的说法,std::forward转发引用到emplace。代码如下:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string petName("Darla"); // as before
logAndAdd(petName); // as before , copy
logAndAdd(std::string("Persephone")); // move rvalue instead of copying it
logAndAdd("Patty Dog"); // create std::string in multiset instead of copying a temporary std::string

非常好,效率优化了!

在故事的最后,我们可以骄傲的交付这个代码,但是我没有告诉你client不总是有访问logAndAdd要求的names的权限。有些clients只有names的下标。为了支持这种client,logAndAdd需要重载为:

1
2
3
4
5
6
7
std::string nameFromIdx(int idx); // return name corresponding to idx
void logAndAdd(int idx)
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}

之后的两个调用按照预期工作:

1
2
3
4
5
6
std::string petName("Darla");
logAndAdd(petName);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog"); // these calls all invoke the T&& overload

logAndAdd(22); // calls int overload

事实上,这只能基本按照预期工作,假定一个client将short类型当做下标传递给logAndAdd:

1
2
3
short nameIdx;
...
logAndAdd(nameIdx); // error!

之后一行的error说明并不清楚,下面让我来说明发生了什么。

有两个重载的logAndAdd。一个使用通用应用推导出T的类型是short,因此可以精确匹配。对于int参数类型的重载logAndAdd也可以short类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。

在通用引用中的实现中,将short类型emplacestd::string的容器中,发生了错误。所有这一切的原因就是对于short类型通用引用重载优先于int类型的重载。

使用通用引用类型的函数在C++中是贪婪函数。他们机会可以精确匹配任何类型的参数(极少不适用的类型在Item 30中介绍)。这也是组合重载和通用引用使用是糟糕主意的原因:通用引用的实现会匹配比开发者预期要多得多的参数类型。

一个更容易调入这种陷阱的例子是完美转发构造函数。简单对logAndAdd例子进行改造就可以说明这个问题。将使用std::string类型改为自定义Person类型即可:

1
2
3
4
5
6
7
8
9
10
class Person
{
public:
template<typename T>
explicit Person(T&& n) :name(std::forward<T>(n)) {} // perfect forwarding ctor; initializes data member
explicit Person(int idx): name(nameFromIdx(idx)) {}
...
private:
std::string name;
};

logAndAdd的例子中,传递一个不是int的整型变量(比如std::size_t, short, long等)会调用通用引用的构造函数而不是int的构造函数,这会导致编译错误。这里这个问题甚至更糟糕,因为Person中存在的重载比肉眼看到的更多。在Item 17中说明,在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板构造也在合适的条件范围内。如果拷贝和移动构造被生成,Person类看起来就像这样:

1
2
3
4
5
6
7
8
9
10
11
class Person
{
public:
template<typename T>
explicit Person(T&& n) :name(std::forward<T>(n)) {} // perfect forwarding ctor
explicit Person(int idx); // int ctor

Person(const Person& rhs); // copy ctor(complier-generated)
Person(Person&& rhs); // move ctor (compiler-generated)
...
};

只有你在花了很多时间在编译器领域时,下面的行为才变得直观(译者注:这里意思就是这种实现会导致不符合人类直觉的结果,下面就解释了这种现象的原因)

1
2
Person p("Nancy"); 
auto cloneOfP(p); // create new Person from p; this won't compile!

这里我们视图通过一个Person实例创建另一个Person,显然应该调用拷贝构造即可(p是左值,我们可以思考通过移动操作来消除拷贝的开销)。但是这份代码不是调用拷贝构造,而是调用完美转发构造。然后,该函数将尝试使用Person对象p初始化Personstd::string的数据成员,编译器就会报错。

“为什么?”你可能会疑问,“为什么拷贝构造会被完美转发构造替代?我们显然想拷贝Person到另一个Person”。确实我们是这样想的,但是编译器严格遵循C++的规则,这里的相关规则就是控制对重载函数调用的解析规则。

编译器的理由如下:cloneOfPnon-const左值p初始化,这意味着可以实例化模板构造函数为采用Personnon-const左值。实例化之后,Person类看起来是这样的:

1
2
3
4
5
6
7
8
class Person { 
public:
explicit Person(Person& n) // instantiated from
: name(std::forward<Person&>(n)) {} // perfect-forwarding // template
explicit Person(int idx); // as before
Person(const Person& rhs); // copy ctor (compiler-generated)
...
};

auto cloneOfP(p);语句中,p被传递给拷贝构造或者完美转发构造。调用拷贝构造要求在p前加上const的约束,而调用完美转发构造不需要任何条件,所以编译器按照规则:采用最佳匹配,这里调用了完美转发的实例化的构造函数。

如果我们将本例中的传递的参数改为const的,会得到完全不同的结果:

1
2
const Person cp("Nancy"); 
auto cloneOfP(cp); // call copy constructor!

因为被拷贝的对象是const,是拷贝构造函数的精确匹配。虽然模板参数可以实例化为完全一样的函数签名:

1
2
3
4
5
6
7
class Person {
public:
explicit Person(const Person& n); // instantiated from template

Person(const Person& rhs); // copy ctor(compiler-generated)
...
};

但是无所谓,因为重载规则规定当模板实例化函数和非模板函数(或者称为“正常”函数)匹配优先级相当时,优先使用“正常”函数。拷贝构造函数(正常函数)因此胜过具有相同签名的模板实例化函数。

(如果你想知道为什么编译器在生成一个拷贝构造函数时还会模板实例化一个相同签名的函数,参考Item17)

当继承纳入考虑范围时,完美转发的构造函数与编译器生成的拷贝、移动操作之间的交互会更加复杂。尤其是,派生类的拷贝和移动操作会表现的非常奇怪。来看一下:

1
2
3
4
5
6
7
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) :Person(rhs)
{...} // copy ctor; calls base class forwarding ctor!
SpecialPerson(SpecialPerson&& rhs): Person(std::move(rhs))
{...} // move ctor; calls base class forwarding ctor!
};

如同注释表示的,派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数!为了理解原因,要知道派生类使用SpecialPerson作为参数传递给其基类,然后通过模板实例化和重载解析规则作用于基类。最终,代码无法编译,因为std::string没有SpecialPerson的构造函数。

我希望到目前为止,已经说服了你,如果可能的话,避免对通用引用的函数进行重载。但是,如果在通用引用上重载是糟糕的主意,那么如果需要可转发大多数类型的参数,但是对于某些类型又要特殊处理应该怎么办?存在多种办法。实际上,下一个Item,Item27专门来讨论这个问题,敬请阅读。

需要记住的事

  • 对通用引用参数的函数进行重载,调用机会会比你期望的多得多
  • 完美转发构造函数是糟糕的实现,因为对于non-const左值不会调用拷贝构造而是完美转发构造,而且会劫持派生类对于基类的拷贝和移动构造

Item 27:熟悉通用引用重载的替代方法

Item 26中说明了对使用通用引用参数的函数,无论是独立函数还是成员函数(尤其是构造函数),进行重载都会导致一系列问题。但是也提供了一些示例,如果能够按照我们期望的方式运行,重载可能也是有用的。这个Item探讨了几种通过避免在通用引用上重载的设计或者通过限制通用引用可以匹配的参数类型的方式来实现所需行为的方案。

讨论基于Item 26中的示例,如果你还没有阅读Item 26,请先阅读在继续本Item的阅读。

Abandon overloading

在Item 26中的第一个例子中,logAndAdd代表了许多函数,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的logAndAdd函数,可以分别改名为logAndAddNamelogAndAddNameIdx。但是,这种方式不能用在第二个例子,Person构造函数中,因为构造函数的名字本类名固定了。此外谁愿意放弃重载呢?

Pass by const T&

一种替代方案是退回到C++98,然后将通用引用替换为const的左值引用。事实上,这是Item 26中首先考虑的方法。缺点是效率不高,会有拷贝的开销。现在我们知道了通用引用和重载的组合会导致问题,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。

Pass by value

通常在不增加复杂性的情况下提高性能的一种方法是,将按引用传递参数替换为按值传递,这是违反直觉的。该设计遵循Item 41中给出的建议,即在你知道要拷贝时就按值传递,因此会参考Item 41来详细讨论如何设计与工作,效率如何。这里,在Person的例子中展示:

1
2
3
4
5
6
7
8
9
10
11
class Person {
public:
explicit Person(std::string p) // replace T&& ctor; see
: name(std::move(n)) {} // Item 41 for use of std::move

explicit Person(int idx)
: name(nameFromIdx(idx)) {}
...
private:
std::string name;
};

因为没有std::string构造器可以接受整型参数,所有int或者其他整型变量(比如std::size_t、short、long等)都会使用int类型重载的构造函数。相似的,所有std::string类似的参数(字面量等)都会使用std::string类型的重载构造函数。没有意外情况。我想你可能会说有些人想要使用0或者NULL会调用int重载的构造函数,但是这些人应该参考Item 8反复阅读指导使用0或者NULL作为空指针让他们恶心。

Use Tag dispatch

传递const左值引用参数以及按值传递都不支持完美转发。如果使用通用引用的动机是完美转发,我们就只能使用通用引用了,没有其他选择。但是又不想放弃重载。所以如果不放弃重载又不放弃通用引用,如何避免咋通用引用上重载呢?

实际上并不难。通过查看重载的所有参数以及调用的传入参数,然后选择最优匹配的函数——计算所有参数和变量的组合。通用引用通常提供了最优匹配,但是如果通用引用是包含其他非通用引用参数列表的一部分,则不是通用引用的部分会影响整体。这基本就是tag dispatch 方法,下面的示例会使这段话更容易理解。

我们将tag dispatch应用于logAndAdd例子,下面是原来的代码,以免你找不到Item 26的代码位置:

1
2
3
4
5
6
7
8
std::multiset<std::string> names; // global data structure
template<typename T> // make log entry and add
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clokc::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

就其本身而言,功能执行没有问题,但是如果引入一个int类型的重载,就会重新陷入Item 26中描述的麻烦。这个Item的目标是避免它。不通过重载,我们重新实现logAndAdd函数分拆为两个函数,一个针对整型值,一个针对其他。logAndAdd本身接受所有的类型。

这两个真正执行逻辑的函数命名为logAndAddImpl使用重载。一个函数接受通用引用参数。所以我们同时使用了重载和通用引用。但是每个函数接受第二个参数,表征传入的参数是否为整型。这第二个参数可以帮助我们避免陷入到Item 26中提到的麻烦中,因为我们将其安排为第二个参数决定选择哪个重载函数。

是的,我知道,“不要在啰嗦了,赶紧亮出代码”。没有问题,代码如下,这是最接近正确版本的:

1
2
3
4
5
6
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_integral<T>()); // not quite correct
}

这个函数转发它的参数给logAndAddImpl函数,但是多传递了一个表示是否T为整型的变量。至少,这就是应该做的。对于右值的整型参数来说,这也是正确的。但是如同Item 28中说明,如果左值参数传递给通用引用name,类型推断会使左值引用。所以如果左值int被传入logAndAdd,T将被推断为int&。这不是一个整型类型,因为引用不是整型类型。这意味着std::is_integral<T>对于左值参数返回false,即使确实传入了整型值。

意识到这个问题基本相当于解决了它,因为C++标准库有一个类型trait(参见Item 9),std::remove_reference,函数名字就说明做了我们希望的:移除引用。所以正确实现的代码应该是这样:

1
2
3
4
5
6
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_instegral<typename std::remove_reference<T>::type>());
}

这个代码很巧妙。(在C++14中,你可以通过std::remove_reference_t<T>来简化写法,参看Item 9)

处理完之后,我们可以将注意力转移到名为logAndAddImpl的函数上了。有两个重载函数,第一个仅用于非整型类型(即std::is_instegral<typename std::remove_reference<T>::type>()false):

1
2
3
4
5
6
7
template<typename T>
void logAndAddImpl(T&& name, std::false_type) // 高亮为std::false_type
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

一旦你理解了高亮参数的含义代码就很直观。概念上,logAndAdd传递一个布尔值给logAndAddImpl表明是否传入了一个整型类型,但是truefalse是运行时值,我们需要使用编译时决策来选择正确的logAndAddImpl重载。这意味着我们需要一个类型对应truefalse同理。这个需要是经常出现的,所以标准库提供了这样两个命名std::true_type and std::false_typelogAndAdd传递给logAndAddImpl的参数类型取决于T是否整型,如果T是整型,它的类型就继承自std::true_type,反之继承自std::false_type。最终的结果就是,当T不是整型类型时,这个logAndAddImpl重载会被调用。

第二个重载覆盖了相反的场景:当T是整型类型。在这个场景中,logAndAddImpl简单找到下标处的name,然后传递给logAndAdd

1
2
3
4
5
std::string nameFromIdx(int idx); // as in item 26
void logAndAddImpl(int idx, std::true_type) // 高亮:std::true_type
{
logAndAdd(nameFromIdx(idx));
}

通过下标找到对应的name,然后让logAndAddImpl传递给logAndAdd,我们避免了将日志代码放入这个logAndAddImpl重载中。

在这个设计中,类型std::true_typestd::false_type是“标签”,其唯一目的就是强制重载解析按照我们的想法来执行。注意到我们甚至没有对这些参数进行命名。他们在运行时毫无用处,事实上我们希望编译器可以意识到这些tag参数是无用的然后在程序执行时优化掉它们(至少某些时候有些编译器会这样做)。这种在logAndAdd内部的通过tag来实现重载实现函数的“分发”,因此这个设计名称为:tag dispatch。这是模板元编程的标准构建模块,你对现代C++库中的代码了解越多,你就会越多遇到这种设计。

就我们的目的而言,tag dispatch的重要之处在于它可以允许我们组合重载和通用引用使用,而没有Item 26中提到的问题。分发函数—-logAndAdd——接受一个没有约束的通用引用参数,但是这个函数没有重载。实现函数—-logAndAddImpl——是重载的,一个接受通用引用参数,但是重载规则不仅依赖通用引用参数,还依赖新引入的tag参数。结果是tag来决定采用哪个重载函数。通用引用参数可以生成精确匹配的事实在这里并不重要。(译者注:这里确实比较啰嗦,如果理解了上面的内容,这段完全可以没有。)

Constraining templates that take universal references(约束使用通用引用的模板)

tag dispatch的关键是存在单独一个函数(没有重载)给客户端API。这个单独的函数分发给具体的实现函数。创建一个没有重载的分发函数通常是容易的,但是Item 26中所述第二个问题案例是Person类的完美转发构造函数,是个例外。编译器可能会自行生成拷贝和移动构造函数,所以即使你只写了一个构造函数并在其中使用tag dispatch,编译器生成的构造函数也打破了你的期望。

实际上,真正的问题不是编译器生成的函数会绕过tag diapatch设计,而是不总会绕过tag dispatch。你希望类的拷贝构造总是处理该类型的non-const左值构造请求,但是如同Item 26中所述,提供具有通用引用的构造函数会使通用引用构造函数被调用而不是拷贝构造函数。还说明了当一个基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会发生错误的调用(调用基类的完美转发构造函数而不是基类的拷贝或者移动构造)

这种情况,采用通用引用的重载函数通常比期望的更加贪心,但是有不满足使用tag dispatch的条件。你需要不同的技术,可以让你确定允许使用通用引用模板的条件。朋友你需要的就是std::enable_if

std::enable_if可以给你提供一种强制编译器执行行为的方法,即使特定模板不存在。这种模板也会被禁止。默认情况下,所有模板是启用的,但是使用std::enable_if可以使得仅在条件满足时模板才启用。在这个例子中,我们只在传递的参数类型不是Person使用Person的完美转发构造函数。如果传递的参数是Person,我们要禁止完美转发构造函数(即让编译器忽略它),因此就是拷贝或者移动构造函数处理,这就是我们想要使用Person初始化另一个Person的初衷。

这个主意听起来并不难,但是语法比较繁杂,尤其是之前没有接触过的话,让我慢慢引导你。有一些使用std::enbale_if的样板,让我们从这里开始。下面的代码是Person完美转发构造函数的声明,我仅展示声明,因为实现部分跟Item 26中没有区别。

1
2
3
4
5
6
7
class Person {
public:
template<typename T,
typename = typename std::enable_if<condition>::type> // 本行高亮
explicit Person(T&& n);
...
};

为了理解高亮部分发生了什么,我很遗憾的表示你要自行查询语法含义,因为详细解释需要花费一定空间和时间,而本书并没有足够的空间(在你自行学习过程中,请研究”SFINAE”以及std::enable_if,因为“SFINAE”就是使std::enable_if起作用的技术)。这里我想要集中讨论条件的表示,该条件表示此构造函数是否启用。

这里我们想表示的条件是确认T不是Person类型,即模板构造函数应该在T不是Person类型的时候启用。因为type trait可以确定两个对象类型是否相同(std::is_same),看起来我们需要的就是!std::is_same<Person, T>::value(注意语句开始的!,我们想要的是不相同)。这很接近我们想要的了,但是不完全正确,因为如同Item 28中所述,对于通用引用的类型推导,如果是左值的话会推导成左值引用,比如这个代码:

1
2
Person p("Nancy");
auto cloneOfP(p); // initialize from lvalue

T的类型在通用引用的构造函数中被推导为Person&PersonPerson&类型是不同的,std::is_same对比std::is_same<Person, Person&>::value会是false

如果我们更精细考虑仅当T不是Person类型才启用模板构造函数,我们会意识到当我们查看T时,应该忽略:

  • 是否引用。对于决定是否通用引用构造器启用的目的来说,Person, Person&, Person&&都是跟Person一样的。
  • 是不是const或者volatile。如上所述,const Person , volatile Person , const volatile Person也是跟Person一样的。

这意味着我们需要一种方法消除对于T引用,const, volatile修饰。再次,标准库提供了这样的功能type trait,就是std::decaystd::decay<T>::valueT是相同的,只不过会移除引用, const, volatile的修饰。(这里我没有说出另外的真相,std::decay如同其名一样,可以将array或者function退化成指针,参考Item 1,但是在这里讨论的问题中,它刚好合适)。我们想要控制构造器是否启用的条件可以写成:

1
!std::is_same<Person, typename std::decay<T>::type>::value

表示PersonT的类型不同。

将其带回整体代码中,Person的完美转发构造函数的声明如下:

1
2
3
4
5
6
7
8
9
class Person {
public:
template<typename T,
typename = typename std::enable_if<
!std::is_same<Person, typename std::decay<T>::type>::value
>::type> // 本行高亮
explicit Person(T&& n);
...
};

如果你之前从没有看到过这种类型的代码,那你可太幸福了。最后是这种设计是有原因的。当你使用其他机制来避免同时使用重载和通用引用时(你总会这样做),确实应该那样做。不过,一旦你习惯了使用函数语法和尖括号的使用,也不坏。此外,这可以提供你一直想要的行为表现。在上面的声明中,使用Person初始化一个Person——无论是左值还是右值,const还是volatile都不会调用到通用引用构造函数。

成功了,对吗?确实!

当然没有。等会再庆祝。Item 26还有一个情景需要解决,我们需要继续探讨下去。

假定从Person派生的类以常规方式实现拷贝和移动操作:

1
2
3
4
5
6
7
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs): Person(rhs)
{...} // copy ctor; calls base class forwarding ctor!
SpecialPerson(SpecialPerson&& rhs): Person(std::move(rhs))
{...} // move ctor; calls base class forwarding ctor!
};

这和Item 26中的代码是一样的,包括注释也是一样。当我们拷贝或者移动一个SpecialPerson对象时,我们希望调用基类对应的拷贝和移动构造函数,但是这里,我们将SpecialPerson传递给基类的构造器,因为SpecialPersonPerson类型不同,所以完美转发构造函数是启用的,会实例化为精确匹配的构造函数。生成的精确匹配的构造函数之于重载规则比基类的拷贝或者移动构造函数更优,所以这里的代码,拷贝或者移动SpecialPerson对象就会调用Person类的完美转发构造函数来执行基类的部分。跟Item 26的困境一样。

派生类仅仅是按照常规的规则生成了自己的移动和拷贝构造函数,所以这个问题的解决还要落实在在基类,尤其是控制是否使用Person通用引用构造函数启用的条件。现在我们意识到不只是禁止Person类型启用模板构造器,而是禁止Person以及任何派生自Person的类型启用模板构造器。讨厌的继承!

你应该不意外在这里看到标准库中也有type trait判断一个类型是否继承自另一个类型,就是std::is_base_of。如果std::is_base_of<T1, T2>true表示T2派生自T1。类型系统是自派生的,表示std::is_base_of<T, T>::value总是true。这就很方便了,我们想要修正关于我们控制Person完美转发构造器的启用条件,只有当T在消除引用,const, volatile修饰之后,并且既不是Person又不是Person的派生类,才满足条件。所以使用std::is_base_of代替std::is_same就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_base_if<Person,
typename std::decay<T>::type
>::value
>::type
>
explicit Person(T&& n);
...
};

现在我们终于完成了最终版本。这是C++11版本的代码,如果我们使用C++14,这份代码也可以工作,但是有更简洁一些的写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person  { // C++14
public:
template<
typename T,
typename = std::enable_if_t< // less code here
!std::is_base_of<Person,
std::decay_t<T> // and here
>::value
> // and here
>
explicit Person(T&& n);
...
};

好了,我承认,我又撒谎了。我们还没有完成,但是越发接近最终版本了。非常接近,我保证。

我们已经知道如何使用std::enable_if来选择性禁止Person通用引用构造器来使得一些参数确保使用到拷贝或者移动构造器,但是我们还是不知道将其应用于区分整型参数和非整型参数。毕竟,我们的原始目标是解决构造函数模糊性问题。

我们需要的工具都介绍过了,我保证都介绍了,
(1)加入一个Person构造函数重载来处理整型参数
(2)约束模板构造器使其对于某些参数禁止
使用这些我们讨论过的技术组合起来,就能解决这个问题了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person { // C++14
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n): name(std::forward<T>(n))
{...} // ctor for std::strings and args convertible to strings

explicit Person(int idx): name(nameFromIdx(idx))
{...} // ctor for integral args

... // copy and move ctors, etc
private:
std::string name;
};

看!多么优美!好吧,优美之处只是对于那些迷信模板元编程之人,但是事实却是提出了不仅能工作的方法,而且极具技巧。因为使用了完美转发,所以具有最大效率,因为控制了使用通用引用的范围,可以避免对于大多数参数能实例化精确匹配的滥用问题。

Trade-offs (权衡,折中)

本Item提到的前三个技术—-abandoning overloading, passing by const T&, passing by value—-在函数调用中指定每个参数的类型。后两个技术——tag dispatch和 constraing template eligibility——使用完美转发,因此不需要指定参数类型。这一基本决定(是否指定类型)有一定后果。

通常,完美转发更有效率,因为它避免了仅处于符合参数类型而创建临时对象。在Person构造函数的例子中,完美转发允许将Nancy这种字符串字面量转发到容器内部的std::string构造器,不使用完美转发的技术则会创建一个临时对象来满足传入的参数类型。

但是完美转发也有缺点。·即使某些类型的参数可以传递给特定类型的参数的函数,也无法完美转发。Item 30中探索了这方面的例子。

第二个问题是当client传递无效参数时错误消息的可理解性。例如假如创建一个Person对象的client传递了一个由char16_t(一种C++11引入的类型表示16位字符)而不是charstd::string包含的):

1
Person p(u"Konrad Zuse"); // "Konrad Zuse" consists of characters of type const char16_t

使用本Item中讨论的前三种方法,编译器将看到可用的采用int或者std::string的构造函数,并且它们或多或少会产生错误消息,表示没有可以从const char16_t转换为int或者std::string的方法。

但是,基于完美转发的方法,const char16_t不受约束地绑定到构造函数的参数。从那里将转发到Personstd::string的构造函数,在这里,调用者传入的内容(const char16_t数组)与所需内容(std::string构造器可接受的类型)发生的不匹配会被发现。由此产生的错误消息会让人更容易理解,在我使用的编译器上,会产生超过160行错误信息。

在这个例子中,通用引用仅被转发一次(从Person构造器到std::string构造器),但是更复杂的系统中,在最终通用引用到达最终判断是否可接受的函数之前会有多层函数调用。通用引用被转发的次数越多,产生的错误消息偏差就越大。许多开发者发现仅此问题就是在性能优先的接口使用通用引用的障碍。(译者注:最后一句话可能翻译有误,待确认)

Person这个例子中,我们知道转发函数的通用引用参数要支持std::string的初始化,所以我们可以用static_assert来确认是不是支持。std::is_constructible type trait执行编译时测试一个类型的对象是否可以构造另一个不同类型的对象,所以代码可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
public:
template<typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) :name(std::forward<T>(n))
{
//assert that a std::string can be created from a T object(这里到...高亮)
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
... // the usual ctor work goes here
}
... // remainder of Person class (as before)
};

如果client代码尝试使用无法构造std::string的类型创建Person,会导致指定的错误消息。不幸的是,在这个例子中,static_assert在构造函数体中,但是作为成员初始化列表的部分在检查之前。所以我使用的编译器,结果是由static_assert产生的清晰的错误消息在常规错误消息(最多160行以上那个)后出现。

需要记住的事

  • 通用引用和重载的组合替代方案包括使用不同的函数名,通过const左值引用传参,按值传递参数,使用tag dispatch
  • 通过std::enable_if约束模板,允许组合通用引用和重载使用,std::enable_if可以控制编译器哪种条件才使用通用引用的实例
  • 通用引用参数通常具有高效率的优势,但是可用性就值得斟酌

Item 28:理解引用折叠

Item23中指出,当参数传递给模板函数时,模板参数的类型是左值还是右值被推导出来。但是并没有提到只有当参数被声明为通用引用时,上述推导才会发生,但是有充分的理由忽略这一点:因为通用引用是Item24中才提到。回过头来看,通用引用和左值/右值编码意味着:

1
2
template<typename T>
void func(T&& param);

被推导的模板参数T将根据被传入参数类型被编码为左值或者右值。

编码机制是简单的。当左值被传入时,T被推导为左值。当右值被传入时,T被推导为非引用(请注意不对称性:左值被编码为左值引用,右值被编码为非引用),因此:

1
2
3
4
Widget widgetFactory(); // function returning rvalue
Widget w; // a variable(an lvalue)
func(w); // call func with lvalue; T deduced to be Widget&
func(widgetFactory()); // call func with rvalue; T deduced to be Widget

上面的两种调用中,Widget被传入,因为一个是左值,一个是右值,模板参数T被推导为不同的类型。正如我们很快看到的,这决定了通用引用成为左值还是右值,也是std::forward的工作基础。

在我们更加深入std::forward和通用引用之前,必须明确在C++中引用的引用是非法的。不知道你是否尝试过下面的写法,编译器会报错:

1
2
3
int x;
...
auto& & rx = x; //error! can't declare reference to reference

考虑下,如果一个左值传给模板函数的通用引用会发生什么:

1
2
3
4
template<typename T>
void func(T&& param);

func(w); // invoke func with lvalue; T deduced as Widget&

如果我们把推导出来的类型带入回代码中看起来就像是这样:

1
void func(Widget& && param);

引用的引用!但是编译器没有报错。我们从Item24中了解到因为通用引用param被传入一个左值,所以param的类型被推导为左值引用,但是编译器如何采用T的推导类型的结果,这是最终的函数签名?

1
void func(Widget& param);

答案是引用折叠。是的,禁止你声明引用的引用,但是编译器会在特定的上下文中使用,包括模板实例的例子。当编译器生成引用的引用时,引用折叠指导下一步发生什么。

存在两种类型的引用(左值和右值),所以有四种可能的引用组合(左值的左值,左值的右值,右值的右值,右值的左值)。如果一个上下文中允许引用的引用存在(比如,模板函数的实例化),引用根据规则折叠为单个引用:

如果任一引用为左值引用,则结果为左值引用。否则(即,如果引用都是右值引用),结果为右值引用

在我们上面的例子中,将推导类型Widget&替换模板func会产生对左值引用的右值引用,然后引用折叠规则告诉我们结果就是左值引用。

引用折叠是std::forward工作的一种关键机制。就像Item25中解释的一样,std::forward应用在通用引用参数上,所以经常能看到这样使用:

1
2
3
4
5
6
template<typename T>
void f(T&& fParam)
{
... // do some work
someFunc(std::forward<T>(fParam)); // forward fParam to someFunc
}

因为fParam是通用引用,我们知道参数T的类型将在传入具体参数时被编码。std::forward的作用是当传入参数为右值时,即T为非引用类型,才将fParam(左值)转化为一个右值。

std::forward可以这样实现:

1
2
3
4
5
template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}

这不是标准库版本的实现(忽略了一些接口描述),但是为了理解std::forward的行为,这些差异无关紧要。

假设传入到f的Widget的左值类型。T被推导为Widget&,然后调用std::forward将初始化为std::forward<Widget&>。带入到上面的std::forward的实现中:

1
2
3
4
Widget& && forward(typename remove_reference<Widget&>::type& param)
{
return static_cast<Widget& &&>(param);
}

std::remove_reference<Widget&>::type表示Widget(查看Item9),所以std::forward成为:

1
2
3
4
Widget& && forward(Widget& param)
{
return static_cast<Widget& &&>(param);
}

根据引用折叠规则,返回值和static_cast可以化简,最终版本的std::forward就是

1
2
3
4
Widget& forward(Widget& param)
{
return static_cast<Widget&>(param);
}

正如你所看到的,当左值被传入到函数模板f时,std::forward转发和返回的都是左值引用。内部的转换不做任何事,因为param的类型已经是Widget&,所以转换没有影响。左值传入会返回左值引用。通过定义,左值引用就是左值,因此将左值传递给std::forward会返回左值,就像说的那样,完美转发。

现在假设一下,传递给f的是一个Widget的右值。在这个例子中,T的类型推导就是Widget。内部的std::forward因此转发std::forward<Widget>,带入回std::forward实现中:

1
2
3
4
Widget&& forward(typename remove_reference<Widget>::type& param)
{
return static_cast<Widget&&>(param);
}

remove_reference引用到非引用的类型上还是相同的类型,所以化简如下

1
2
3
4
Widget&& forward(Widget& param)
{
return static_cast<Widget&&>(param);
}

这里没有引用的引用,所以不需要引用折叠,这就是最终版本。

从函数返回的右值引用被定义为右值,因此在这种情况下,std::forward会将f的参数fParam(左值)转换为右值。最终结果是,传递给f的右值参数将作为右值转发给someFunc,完美转发。

在C++14中,std::remove_reference_t的存在使得实现变得更简单:

1
2
3
4
5
template<typename T>  // C++ 14; still in namepsace std
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}

引用折叠发生在四种情况下。第一,也是最常见的就是模板实例化。第二,是auto变量的类型生成,具体细节类似模板实例化的分析,因为类型推导基本与模板实例化雷同(参见Item2)。考虑下面的例子:

1
2
3
4
5
6
template<typename T>
void func(T&& param);
Widget widgetFactory(); // function returning rvalue
Widget w; // a variable(an lvalue)
func(w); // call func with lvalue; T deduced to be Widget&
func(widgetFactory()); // call func with rvalue; T deduced to be Widget

在auto的写法中,规则是类似的:auto&& w1 = w;初始化w1为一个左值,因此为auto推导出类型Widget&。带回去就是Widget& && w1 = w,应用引用折叠规则,就是Widget& w1 = w,结果就是w1是一个左值引用。

另一方面,auto&& w2 = widgetFactory();使用右值初始化w2,非引用带回Widget&& w2 = widgetFactory()。没有引用的引用,这就是最终结果。

现在我们真正理解了Item24中引入的通用引用。通用引用不是一种新的引用,它实际上是满足两个条件下的右值引用:

  • 通过类型推导将左值和右值区分。T类型的左值被推导为&类型,T类型的右值被推导为T
  • 引用折叠的发生

通用引用的概念是有用的,因为它使你不必一定意识到引用折叠的存在,从直觉上判断左值和右值的推导即可。

我说了有四种情况会发生引用折叠,但是只讨论了两种:模板实例化和auto的类型生成。第三,是使用typedef和别名声明(参见Item9),如果,在创建或者定义typedef过程中出现了引用的引用,则引用折叠就会起作用。举例子来说,假设我们有一个Widget的类模板,该模板具有右值引用类型的嵌入式typedef:

1
2
3
4
5
6
template<typename T>
class Widget {
public:
typedef T&& RvalueRefToT;
...
};

假设我们使用左值引用实例化Widget:

1
Widget<int&> w;

就会出现

1
typedef int& && RvalueRefToT;

引用折叠就会发挥作用:

1
typedef int& RvalueRefToT;

这清楚表明我们为typedef选择的name可能不是我们希望的那样:RvalueRefToT是左值引用的typedef,当使用Widget被左值引用实例化时。

最后,也是第四种情况是,decltype使用的情况,如果在分析decltype期间,出现了引用的引用,引用折叠规则就会起作用(关于decltype,参见Item3)

需要记住的事

  • 引用折叠发生在四种情况:模板实例化;auto类型推导;typedef的创建和别名声明;decltype
  • 当编译器生成了引用的引用时,结果通过引用折叠就是单个引用。有左值引用就是左值引用,否则就是右值引用
  • 通用引用就是通过类型推导区分左值还是右值,并且引用折叠出现的右值引用

Item 29: Assume that move operations are not present, not cheap, and not used

移动语义可以说是C++11最主要的特性。你可能会见过这些类似的描述“移动容器和拷贝指针一样开销小”, “拷贝临时对象现在如此高效,编码避免这种情况简直就是过早优化”这种情绪很容易理解。移动语义确实是这样重要的特性。它不仅允许编译器使用开销小的移动操作代替大开销的复制操作,而且默认这么做。以C++98的代码为基础,使用C++11重新编译你的代码,然后,哇,你的软件运行的更快了。

移动语义确实令人振奋,但是有很多夸大的说法,这个Item的目的就是给你泼一瓢冷水,保持理智看待移动语义。

让我们从已知很多类型不支持移动操作开始这个过程。为了升级到C++11,C++98的很多标准库做了大修改,为很多类型提供了移动的能力,这些类型的移动实现比复制操作更快,并且对库的组件实现修改以利用移动操作。但是很有可能你工作中的代码没有完整地利用C++11。对于你的应用中(或者代码库中),没有适配C++11的部分,编译器即使支持移动语义也是无能为力的。的确,C++11倾向于为缺少移动操作定义的类默认生成,但是只有在没有声明复制操作,移动操作,或析构函数的类中才会生成移动操作(参考Item17)。禁止移动操作的类中(通过delete move operation 参考Item11),编译器不生成移动操作的支持。对于没有明确支持移动操作的类型,并且不符合编译器默认生成的条件的类,没有理由期望C++11会比C++98进行任何性能上的提升。

即使显式支持了移动操作,结果可能也没有你希望的那么好。比如,所有C++11的标准库都支持了移动操作,但是认为移动所有容器的开销都非常小是个错误。对于某些容器来说,压根就不存在开销小的方式来移动它所包含的内容。对另一些容器来说,开销真正小的移动操作却使得容器元素移动含义事与愿违。

考虑一下std::array,这是C++11中的新容器。std::array本质上是具有STL接口的内置数组。这与其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本身只保存了只想堆内存数据的指针(真正实现当然更复杂一些,但是基本逻辑就是这样)。这种实现使得在常数时间移动整个容器成为可能的,只需要拷贝容器中保存的指针到目标容器,然后将原容器的指针置为空指针就可以了。

1
2
3
std::vector<Widget> vm1;

auto vm2 = std::move(vm1); // move vm1 into vm2. Runs in constant time. Only ptrs in vm1 and vm2 are modified

std::array没有这种指针实现,数据就保存在std::array容器中

1
2
3
std::array<Widget, 10000> aw1;

auto aw2 = std::move(aw1); // move aw1 into aw2. Runs in linear time. All elements in aw1 are moved into aw2.

注意aw1中的元素被移动到了aw2中,这里假定Widget类的移动操作比复制操作快。但是使用std::array的移动操作还是复制操作都将花费线性时间的开销,因为每个容器中的元素终归需要拷贝一次,这与“移动一个容器就像操作几个指针一样方便”的含义想去甚远。

另一方面,std::strnig提供了常数时间的移动操作和线性时间的复制操作。这听起来移动比复制快多了,但是可能不一定。许多字符串的实现采用了small string optimization(SSO)。”small”字符串(比如长度小于15个字符的)存储在了std::string的缓冲区中,并没有存储在堆内存,移动这种存储的字符串并不必复制操作更快。

SSO的动机是大量证据表明,短字符串是大量应用使用的习惯。使用内存缓冲区存储而不分配堆内存空间,是为了更好的效率。然而这种内存管理的效率导致移动的效率并不必复制操作高。

即使对于支持快速移动操作的类型,某些看似可靠的移动操作最终也会导致复制。Item14解释了原因,标准库中的某些容器操作提供了强大的异常安全保证,确保C++98的代码直接升级C++11编译器不会不可运行,仅仅确保移动操作不会抛出异常,才会替换为移动操作。结果就是,即使类提供了更具效率的移动操作,编译器仍可能被迫使用复制操作来避免移动操作导致的异常。

因此,存在几种情况,C++11的移动语义并无优势:

  • No move operations:类没有提供移动操作,所以移动的写法也会变成复制操作
  • Move not faster:类提供的移动操作并不必复制效率更高
  • Move not usable:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为noexcept

值得一提的是,还有另一个场景,会使得移动并没有那么有效率:

  • Source object is lvalue:除了极少数的情况外(例如 Item25),只有右值可以作为移动操作的来源

但是该Item的标题是假定不存在移动操作,或者开销不小,不使用移动操作。存在典型的场景,就是编写模板代码,因为你不清楚你处理的具体类型是什么。在这种情况下,你必须像出现移动语义之前那样,保守地考虑复制操作。不稳定的代码也是如此,类的特性经常被修改导致可能移动操作会有问题。

但是,通常,你了解你代码里使用的类,并且知道是否支持快速移动操作。这种情况,你无需这个Item的假设,只需要查找所用类的移动操作详细信息,并且调用移动操作的上下文中,可以安全的使用快速移动操作替换复制操作。

需要记住的事

  • Assume that move operations are not present, not cheap, and not used.
  • 完全了解的代码可以忽略本Item

Item 30:熟悉完美转发的失败case

C++11最显眼的功能之一就是完美转发功能。完美转发,太棒了!哎,开始使用,你就发现“完美”,理想与现实还是有差距。C++11的完美转发是非常好用,但是只有当你愿意忽略一些失败情况,这个Item就是使你熟悉这些情形。

在我们开始epsilon探索之前,有必要回顾一下“完美转发”的含义。“转发”仅表示将一个函数的参数传递给另一个函数。对于被传递的第二个函数目标是收到与第一个函数完全相同的对象。这就排除了按值传递参数,因为它们是原始调用者传入内容的副本。我们希望被转发的函数能够可以与原始函数一起使用对象。指着参数也被排除在外,因为我们不想强迫调用者传入指针。关于通用转发,我们将处理引用参数。

完美转发意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const还是volatile。结合到我们会处理引用参数,这意味着我们将使用通用引用(参见Item24),因为通用引用参数被传入参数时才确定是左值还是右值。

假定我们有一些函数f,然后想编写一个转发给它的函数(就使用一个函数模板)。我们需要的核心看起来像是这样:

1
2
3
4
5
template<typename T>
void fwd(T&& param) // accept any argument
{
f(std::forward<T>(param)); // forward it to f
}

从本质上说,转发功能是通用的。例如fwd模板,接受任何类型的采纳数,并转发得到的任何参数。这种通用性的逻辑扩展是转发函数不仅是模板,而且是可变模板,因此可以接受任何数量的参数。fwd的可变个是如下:

1
2
3
4
5
template<typename... Ts>
void fwd(Ts&&... params) // accept any arguments
{
f(std::forward<Ts>(params)...); // forward them to f
}

这种形式你会在标准化容器emplace中(参见Item42)和只能容器的工厂函数std::make_unique和std::make_shared中(参见Item21)看到。

给定我们的目标函数f和被转发的函数fwd,如果f使用特定参数做一件事,但是fwd使用相同的参数做另一件事,完美转发就会失败:

1
2
f(expression); // if this does one thing
fwd(expression); // but this does something else, fwd fails to perfectly forward expression to f

导致这种失败的原因有很多。知道它们是什么以及如何解决它们很重要,因此让我们来看看那种参数无法做到完美转发。

Braced initializers(支撑初始化器)

假定f这样声明:

1
void f(const std::vector<int>& v);

在这个例子中,通过列表初始化器,

1
f({1,2,3});  // fine "{1,2,3}" implicitly converted to std::vector<int> 

但是传递相同的列表初始化器给fwd不能编译

1
fwd({1,2,3}); // error! doesn't compile

这是因为这是完美转发失效的一种情况。

所有这种错误有相同的原因。在对f的直接调用(例如f({1,2,3})),编译器看到传入的参数是声明中的类型。如果类型不匹配,就会执行隐式转换操作使得调用成功。在上面的例子中,从{1,2,3}生成了临时变量std::vector<int>对象,因此f的参数会绑定到std::vector<int>对象上。

当通过调用函数模板fwd调用f时,编译器不再比较传入给fwd的参数和f的声明中参数的类型。代替的是,推导传入给fwd的参数类型,然后比较推导后的参数类型和f的声明类型。当下面情况任何一个发生时,完美转发就会失败:

  • 编译器不能推导出一个或者多个fwd的参数类型,编译器就会报错
  • 编译器将一个或者多个fwd的参数类型推导错误。在这里,“错误”可能意味着fwd将无法使用推导出的类型进行编译,但是也可能意味着调用者f使用fwd的推导类型对比直接传入参数类型表现出不一致的行为。这种不同行为的原因可能是因为f的函数重载定义,并且由于是“不正确的”类型推导,在fwd内部调用f和直接调用f将重载不同的函数。

在上面的f({1,2,3})例子中,问题在于,如标准所言,将括号初始化器传递给未声明为std::initializer_list的函数模板参数,该标准规定为“非推导上下文”。简单来讲,这意味着编译器在对fwd的调用中推导表达式{1,2,3}的类型,因为fwd的参数没有声明为std::initializer_list。对于fwd参数的推导类型被阻止,编译器只能拒绝该调用。

有趣的是,Item2 说明了使用braced initializer的auto的变量初始化的类型推导是成功的。这种变量被视为std::initializer_list对象,在转发函数应推导为std::initializer_list类型的情况,这提供了一种简单的解决方法——使用auto声明一个局部变量,然后将局部变量转发:

1
2
auto il = {1,2,3}; // il's type deduced to be std::initializer_list<int> 
fwd(il); // fine, perfect-forwards il to f

0或者NULL作为空指针

Item8说明当你试图传递0或者NULL作为空指针给模板时,类型推导会出错,推导为一个整数类型而不是指针类型。结果就是不管是0还是NULL都不能被完美转发为空指针。解决方法非常简单,使用nullptr就可以了,具体的细节,参考Item 8.

仅声明的整数静态const数据成员

通常,无需在类中定义整数静态const数据成员;声明就可以了。这是因为编译器会对此类成员

1
2
3
4
5
6
7
8
class Widget {
public:
static const std::size_t MinVals = 28; // MinVal's declaration
...
};
... // no defn. for MinVals
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); // use of MinVals

这里,我们使用Widget::MinVals(或者简单点MinVals)来确定widgetData的初始容量,即使MinVals缺少定义。编译器通过将值28放入所有位置来补充缺少的定义。没有为MinVals的值留存储空间是没有问题的。如果要使用MinVals的地址(例如,有人创建了MinVals的指针),则MinVals需要存储(因为指针总要有一个地址),尽管上面的代码仍然可以编译,但是链接时就会报错,直到为MinVals提供定义。

按照这个思路,想象下f(转发参数给fwd的函数)这样声明:

1
void f(std::size_t val);

使用MinVals调用f是可以的,因为编译器直接将值28代替MinVals

1
f(Widget::MinVals); // fine, treated as "28"

同样的,如果尝试通过fwd来调用f

1
fwd(Widget::MinVals); // error! shouldn't link

代码可以编译,但是不能链接。就像使用MinVals地址表现一样,确实,底层的问题是一样的。

尽管代码中没有使用MinVals的地址,但是fwd的参数是通用引用,而引用,在编译器生成的代码中,通常被视作指针。在程序的二进制底层代码中指针和引用是一样的。在这个水平下,引用只是可以自动取消引用的指针。在这种情况下,通过引用传递MinVals实际上与通过指针传递MinVals是一样的,因此,必须有内存使得指针可以指向。通过引用传递整型static const数据成员,必须定义它们,这个要求可能会造成完美转发失败,即使等效不使用完美转发的代码成功。(译者注:这里意思应该是没有定义,完美转发就会失败)

可能你也注意到了在上述讨论中我使用了一些模棱两可的词。代码“不应该”链接,引用“通常”被看做指针。传递整型static const数据成员“通常”要求定义。看起来就像有些事情我没有告诉你……

确实,根据标准,通过引用传递MinVals要求有定义。但不是所有的实现都强制要求这一点。所以,取决于你的编译器和链接器,你可能发现你可以在未定义的情况使用完美转发,恭喜你,但是这不是那样做的理由。为了具有可移植性,只要给整型static const提供一个定义,比如这样:

1
const std::size_t Widget::MinVals; // in Widget's .cpp file

注意定义中不要重复初始化(这个例子中就是赋值28)。不要忽略这个细节,否则,编译器就会报错,提醒你只初始化一次。

重载的函数名称和模板名称

假定我们的函数f(通过fwd完美转发参数给f)可以通过向其传递执行某些功能的函数来定义其行为。假设这个函数参数和返回值都是整数,f声明就像这样:

1
void f(int (*pf)(int)); // pf = "process function"

值得注意的是,也可以使用更简单的非指针语法声明。这种声明就像这样,含义与上面是一样的:

1
void f(int pf(int)); // declares same f as above

无论哪种写法,我们都有了一个重载函数,processVal:

1
2
int processVal(int value);
int processVal(int value, int priority);

我们可以传递processVal给f

1
f(processVal); // fine

但是有一点要注意,f要求一个函数指针,但是processVal不是一个函数指针或者一个函数,它是两个同名的函数。但是,编译器可以知道它需要哪个:通过参数类型和数量来匹配。因此选择了一个int参数的processVal地址传递给f

工作的基本机制是让编译器帮选择f的声明选择一个需要的processVal。但是,fwd是一个函数模板,没有需要的类型信息,使得编译器不可能帮助自动匹配一个合适的函数:

1
fwd(processVal); // error! which processVal?

processVal没有类型信息,就不能类型推导,完美转发失败。

同样的问题会发生在如果我们试图使用函数模板代替重载的函数名。一个函数模板是未实例化的函数,表示一个函数族:

1
2
3
template<typename T>
T workOnVal(T param) { ... } // template for processing values
fwd(workOnVal); // error! which workOnVal instantiation ?

获得像fwd的完美转发接受一个重载函数名或者模板函数名的方式是指定转发的类型。比如,你可以创造与f相同参数类型的函数指针,通过processVal或者workOnVal实例化这个函数指针(可以引导生成代码时正确选择函数实例),然后传递指针给f:

1
2
3
4
using ProcessFuncType = int (*)(int); // make typedef; see Item 9
PorcessFuncType processValPtr = processVal; // specify needed signature for processVal
fwd(processValPtr); // fine
fwd(static_cast<ProcessFuncType>(workOnVal)); // alse fine

当然,这要求你知道fwd转发的函数指针的类型。对于完美转发来说这一点并不合理,毕竟,完美转发被设计为转发任何内容,如果没有文档告诉你转发的类型,你如何知道?(译者注:这里应该想表达,这是解决重载函数名或者函数模板的解决方案,但是这是完美转发本身的问题)

位域

完美转发最后一种失败的情况是函数参数使用位域这种类型。为了更直观的解释,IPv4的头部可以如下定义:

1
2
3
4
5
6
7
8
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
...
};

如果声明我们的函数f(转发函数fwd的目标)为接收一个std::size_t的参数,则使用IPv4Header对象的totalLength字段进行调用没有问题:

1
2
3
4
void f(std::size_t sz);
IPv4Header h;
...
f(h.totalLength);// fine

如果通过fwd转发h.totalLength给f呢,那就是一个不同的情况了:

1
fwd(h.totalLength); // error!

问题在于fwd的参数是引用,而h.totalLength是非常量位域。听起来并不是那么糟糕,但是C++标准非常清楚地谴责了这种组合:非常量引用不应该绑定到位域。禁止的理由很充分。位域可能包含了机器字节的任意部分(比如32位int的3-5位),但是无法直接定位。我之前提到了在硬件层面引用和指针时一样的,所以没有办法创建一个指向任意bit的指针(C++规定你可以指向的最小单位是char),所以就没有办法绑定引用到任意bit上。

一旦意识到接收位域作为参数的函数都将接收位域的副本,就可以轻松解决位域不能完美转发的问题。毕竟,没有函数可以绑定引用到位域,也没有函数可以接受指向位域的指针(不存在这种指针)。这种位域类型的参数只能按值传递,或者有趣的事,常量引用也可以。在按值传递时,被调用的函数接受了一个位域的副本,而且事实表明,位域的常量引用也是将其“复制”到普通对象再传递。

传递位域给完美转发的关键就是利用接收参数函数接受的是一个副本的事实。你可以自己创建副本然后利用副本调用完美转发。在IPv4Header的例子中,可以如下写法:

1
2
3
// copy bitfield value; see Item6 for info on init. form
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // forward the copy

总结

在大多数情况下,完美转发工作的很好。你基本不用考虑其他问题。但是当其不工作时,当看起来合理的代码无法编译,或者更糟的是,无法按照预期运行时,了解完美转发的缺陷就很重要了。同样重要的是如何解决它们。在大多数情况下,都很简单

需要记住的事

  • 完美转发会失败当模板类型推导失败或者推导类型错误
  • 导致完美转发失败的类型有braced initializers,作为空指针的0或者NULL,只声明的整型static const数据成员,模板和重载的函数名,位域

CHAPTER6 Lambda表达式

Lambda表达式是C++编程中的游戏规则改变者。这有点令人惊讶,因为它没有给语言带来新的表达能力。Lambda可以做的所有事情都可以通过其他方式完成。但是lambda是创建函数对象相当便捷的一种方法,对于日常的C++开发影响是巨大的。没有lambda时,标准库中的_if算法(比如,std::find_if, std::remove_if, std::count_if等)通常需要繁琐的谓词,但是当有lambda可用时,这些算法使用起来就变得相当方便。比较函数(比如,std::sort, std::nth_element, std::lower_bound等)与算法函数也是相同的。在标准库外,lambda可以快速创建std::unique_ptrstd::shared_ptr的自定义deleter,并且使线程API中条件变量的条件设置变得同样简单(参见Item 39)。除了标准库,lambda有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。Lambda确实使C++成为更令人愉快的编程语言。

与Lambda相关的词汇可能会令人疑惑,这里做一下简单的回顾:

  • lambda表达式就是一个表达式。在代码的高亮部分就是lambda

    1
    2
    std::find_if(container.begin(), container.end(),
    [](int val){ return 0 < val && val < 10; }); // 本行高亮
  • 闭包是lambda创建的运行时对象。依赖捕获模式,闭包持有捕获数据的副本或者引用。在上面的std::find_if调用中,闭包是运行时传递给`std::find_if第三个参数。

  • 闭包类(closure class)是从中实例化闭包的类。每个lambda都会使编译器生成唯一的闭包类。Lambda中的语句成为其闭包类的成员函数中的可执行指令。

Lambda通常被用来创建闭包,该闭包仅用作函数的参数。上面对std::find_if的调用就是这种情况。然而,闭包通常可以拷贝,所以可能有多个闭包对应于一个lambda。比如下面的代码:

1
2
3
4
5
6
7
8
9
{
int x; // x is local variable
...
auto c1 = [x](int y) { return x * y > 55; }; // c1 is copy of the closure produced by the lambda

auto c2 = c1; // c2 is copy of c1
auto c3 = c2; // c3 is copy of c2
...
}

c1, c2,c3都是lambda产生的闭包的副本。

非正式的讲,模糊lambda,闭包和闭包类之间的界限是可以接受的。但是,在随后的Item中,区分编译期(lambdas 和 closure classes)还是运行时(closures)以及它们之间的相互关系是重要的。

Item 31:避免使用默认捕获模式

C++11中有两种默认的捕获模式:按引用捕获和按值捕获。但按引用捕获可能会带来悬空引用的问题,而按值引用可能会诱骗你让你以为能解决悬空引用的问题(实际上并没有),还会让你以为你的闭包是独立的(事实上也不是独立的)。

这就是本条目的一个总结。如果你是一个工程师,渴望了解更多内容,就让我们从按引用捕获的危害谈起把。

按引用捕获会导致闭包中包含了对局部变量或者某个形参(位于定义lambda的作用域)的引用,如果该lambda创建的闭包生命周期超过了局部变量或者参数的生命周期,那么闭包中的引用将会变成悬空引用。举个例子,假如我们有一个元素是过滤函数的容器,该函数接受一个int作为参数,并返回一个布尔值,该布尔值的结果表示传入的值是否满足过滤条件。

1
2
3
4
using FilterContainer = // see Item 9 for
std::vector<std::function<bool(int)>>; // "using", Item 2
// for std::function
FilterContainer filters; // filtering funcs

我们可以添加一个过滤器,用来过滤掉5的倍数。

1
2
3
filters.emplace_back( // see Item 42 for
[](int value) { return value % 5 == 0; } // info on
);

然而我们可能需要的是能够在运行期获得被除数,而不是将5硬编码到lambda中。因此添加的过滤器逻辑将会是如下这样:

1
2
3
4
5
6
7
8
9
10
void addDivisorFilter()
{
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back( // danger!
[&](int value) { return value % divisor == 0; } // ref to
); // divisor
} // will
// dangle!

这个代码实现是一个定时炸弹。lambda对局部变量divisor进行了引用,但该变量的生命周期会在addDivisorFilter返回时结束,刚好就是在语句filters.emplace_back返回之后,因此该函数的本质就是容器添加完,该函数就死亡了。使用这个filter会导致未定义行为,这是由它被创建那一刻起就决定了的。

现在,同样的问题也会出现在divisor的显式按引用捕获。

1
2
3
4
filters.emplace_back(
[&divisor](int value) // danger! ref to
{ return value % divisor == 0; } // divisor will
);

但通过显式的捕获,能更容易看到lambda的可行性依赖于变量divisor的生命周期。另外,写成这种形式能够提醒我们要注意确保divisor的生命周期至少跟lambda闭包一样长。比起”[&]”传达的意思,显式捕获能让人更容易想起“确保没有悬空变量”。

如果你知道一个闭包将会被马上使用(例如被传入到一个stl算法中)并且不会被拷贝,那么在lambda环境中使用引用捕获将不会有风险。在这种情况下,你可能会争论说,没有悬空引用的危险,就不需要避免使用默认的引用捕获模式。例如,我们的过滤lambda只会用做C++11中std::all_of的一个参数,返回满足条件的所有元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename C>
void workWithContainer(const C& container)
{
auto calc1 = computeSomeValue1(); // as above
auto calc2 = computeSomeValue2(); // as above
auto divisor = computeDivisor(calc1, calc2); // as above
using ContElemT = typename C::value_type; // type of
// elements in
// container
using std::begin; // for
using std::end; // genericity;
// see Item 13
if (std::all_of( // if all values
begin(container), end(container), // in container
[&](const ContElemT& value) // are multiples
{ return value % divisor == 0; }) // of divisor...
) {
// they are...
} else {
// at least one
} // isn't...
}

的确如此,这是安全的做法,但这种安全是不确定的。如果发现lambda在其它上下文中很有用(例如作为一个函数被添加在filters容器中),然后拷贝粘贴到一个divisor变量已经死亡的,但闭包生命周期还没结束的上下文中,你又回到了悬空的使用上了。同时,在该捕获语句中,也没有特别提醒了你注意分析divisor的生命周期。

从长期来看,使用显式的局部变量和参数引用捕获方式,是更加符合软件工程规范的做法。

额外提一下,C++14支持了在lambda中使用auto来声明变量,上面的代码在C++14中可以进一步简化,ContElemT的别名可以去掉,if条件可以修改为:

1
2
3
if (std::all_of(begin(container), end(container),
[&](const auto& value) // C++14
{ return value % divisor == 0; }))

一个解决问题的方法是,divisor按值捕获进去,也就是说可以按照以下方式来添加lambda:

1
2
3
4
filters.emplace_back( 							  // now
[=](int value) { return value % divisor == 0; } // divisor
); // can't
// dangle

这足以满足本实例的要求,但在通常情况下,按值捕获并不能完全解决悬空引用的问题。这里的问题是如果你按值捕获的是一个指针,你将该指针拷贝到lambda对应的闭包里,但这样并不能避免lambda外删除指针的行为,从而导致你的指针变成悬空指针。

也许你要抗议说:“这不可能发生。看过了第四章,我对智能指针的使用非常热衷。只有那些失败的C++98的程序员才会用裸指针和delete语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使用裸指针,也的确存在被你删除的可能性。只不过在现代的C++编程风格中,不容易在源代码中显露出来而已。

假设在一个Widget类,可以实现向过滤容器添加条目:

1
2
3
4
5
6
7
class Widget {
public:
// ctors, etc.
void addFilter() const; // add an entry to filters
private:
int divisor; // used in Widget's filter
};

这是Widget::addFilter的定义:

1
2
3
4
5
6
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}

这个做法看起来是安全的代码,lambda依赖于变量divisor,但默认的按值捕获被拷贝进了lambda对应的所有比保重,这真的正确吗?

错误,完全错误。

闭包只会对lambda被创建时所在作用域里的非静态局部变量生效。在Widget::addFilter()的视线里,divisor并不是一个局部变量,而是Widget类的一个成员变量。它不能被捕获。如果默认捕获模式被删除,代码就不能编译了:

1
2
3
4
5
6
void Widget::addFilter() const
{
filters.emplace_back( // error!
[](int value) { return value % divisor == 0; } // divisor
); // not
} // available

另外,如果尝试去显式地按引用或者按值捕获divisor变量,也一样会编译失败,因为divisor不是这里的一个局部变量或者参数。

1
2
3
4
5
6
7
void Widget::addFilter() const
{
filters.emplace_back(
[divisor](int value) // error! no local
{ return value % divisor == 0; } // divisor to capture
);
}

因此这里的默认按值捕获并不是不会变量divisor,但它的确能够编译通过,这是怎么一回事呢?

解释就是这里隐式捕获了this指针。每一个非静态成员函数都有一个this指针,每次你使用一个类内的成员时都会使用到这个指针。例如,编译器会在内部将divisor替换成this->divisor。这里Widget::addFilter()的版本就是按值捕获了this。

1
2
3
4
5
6
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}

真正被捕获的是Widget的this指针。编译器会将上面的代码看成以下的写法:

1
2
3
4
5
6
7
8
9
void Widget::addFilter() const 
{
auto currentObjectPtr = this;

filters.emplace_back(
[currentObjectPtr](int value)
{ return value % currentObject->divisor == 0; }
);
}

明白了这个就相当于明白了lambda闭包的生命周期与Widget对象的关系,闭包内含有Widget的this指针的拷贝。特别是考虑以下的代码,再参考一下第四章的内容,只使用智能指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
using FilterContainer = 					// as before
std::vector<std::function<bool(int)>>;
FilterContainer filters; // as before
void doSomeWork()
{
auto pw = // create Widget; see
std::make_unique<Widget>(); // Item 21 for
// std::make_unique
pw->addFilter(); // add filter that uses
// Widget::divisor

} // destroy Widget; filters
// now holds dangling pointer!

当调用doSomeWork时,就会创建一个过滤器,其生命周期依赖于由std::make_unique管理的Widget对象。即一个含有Widget this指针的过滤器。这个过滤器被添加到filters中,但当doSomeWork结束时,Widget会由std::unique_ptr去结束其生命。从这时起,filter会含有一个悬空指针。

这个特定的问题可以通过做一个局部拷贝去解决:

1
2
3
4
5
6
7
8
void Widget::addFilter() const
{
auto divisorCopy = divisor; // copy data member
filters.emplace_back(
[divisorCopy](int value) // capture the copy
{ return value % divisorCopy == 0; } // use the copy
);
}

事实上如果采用这种方法,默认的按值捕获也是可行的。

1
2
3
4
5
6
7
8
void Widget::addFilter() const
{
auto divisorCopy = divisor; // copy data member
filters.emplace_back(
[=](int value) // capture the copy
{ return value % divisorCopy == 0; } // use the copy
);
}

但为什么要冒险呢?当你一开始捕获divisor的时候,默认的捕获模式就会自动将this指针捕获进来了。

在C++14中,一个更好的捕获成员变量的方式时使用通用的lambda捕获:

1
2
3
4
5
6
7
void Widget::addFilter() const
{
filters.emplace_back( // C++14:
[divisor = divisor](int value) // copy divisor to closure
{ return value % divisor == 0; } // use the copy
);
}

这种通用的lambda捕获并没有默认的捕获模式,因此在C++14中,避免使用默认捕获模式的建议仍然时成立的。

使用默认的按值捕获还有另外的一个缺点,它们预示了相关的闭包是独立的并且不受外部数据变化的影响。一般来说,这是不对的。lambda并不会独立于局部变量和参数,但也没有不受静态存储生命周期的影响。一个定义在全局空间或者指定命名空间的全局变量,或者是一个声明为static的类内或文件内的成员。这些对象也能在lambda里使用,但它们不能被捕获。但按值引用可能会因此误导你,让你以为捕获了这些变量。参考下面版本的addDivisorFilter()函数:

1
2
3
4
5
6
7
8
9
10
11
12
void addDivisorFilter()
{
static auto calc1 = computeSomeValue1(); // now static
static auto calc2 = computeSomeValue2(); // now static
static auto divisor = // now static
computeDivisor(calc1, calc2);
filters.emplace_back(
[=](int value) // captures nothing!
{ return value % divisor == 0; } // refers to above static
);
++divisor; // modify divisor
}

随意地看了这份代码的读者可能看到”[=]”,就会认为“好的,lambda拷贝了所有使用的对象,因此这是独立的”。但上面的例子就表现了不独立闭包的一种情况。它没有使用任何的非static局部变量和形参,所以它没有捕获任何东西。然而lambda的代码引用了静态变量divisor,任何lambda被添加到filters之后,divisor都会递增。通过这个函数,会把许多lambda都添加到filiters里,但每一个lambda的行为都是新的(分别对应新的divisor值)。这个lambda是通过引用捕获divisor,这和默认的按值捕获表示的含义有着直接的矛盾。如果你一开始就避免使用默认的按值捕获模式,你就能解除代码的风险。

建议

  • 默认的按引用捕获可能会导致悬空引用;
  • 默认的按值引用对于悬空指针很敏感(尤其是this指针),并且它会误导人产生lambda是独立的想法;

Item 32:使用初始化捕获来移动对象到闭包中

在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptr或std::future)要进入到闭包里,使用C++11是无法实现的。如果你要复制的对象复制开销非常高,但移动的成本却不高(例如标准库中的大多数容器),并且你希望的是宁愿移动该对象到闭包而不是复制它。然而C++11却无法实现这一目标。

但如果你的编译器支持C++14,那又是另一回事了,它能支持将对象移动道闭包中。如果你的兼容支持C++14,那么请愉快地阅读下去。如果你仍然在使用仅支持C++11的编译器,也请愉快阅读,因为在C++11中有很多方法可以实现近似的移动捕获。

缺少移动捕获被认为是C++11的一个缺点,直接的补救措施是将该特性添加到C++14中,但标准化委员会选择了另一种方法。他们引入了一种新的捕获机制,该机制非常灵活,移动捕获是它执行的技术之一。新功能被称作初始化捕获,它几乎可以完成C++11捕获形式的所有工作,甚至能完成更多功能。默认的捕获模式使得你无法使用初始化捕获表示,但第31项说明提醒了你无论如何都应该远离这些捕获模式。(在C++11捕获模式所能覆盖的场景里,初始化捕获的语法有点不大方便。因此在C++11的捕获模式能完成所需功能的情况下,使用它是完全合理的)。

使用初始化捕获可以让你指定:

  1. 从lambda生成的闭包类中的数据成员名称;
  2. 初始化该成员的表达式;

这是使用初始化捕获将std::unique_ptr移动到闭包中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget { // some useful type
public:
...
bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private: ...
};

auto pw = std::make_unique<Widget>(); // create Widget; see Item 21 for info on std::make_unique configure *pw

auto func = [pw = std::move(pw)] // init data mbr in closure w/ std::move(pw)
{ return pw->isValidated()
&& pw->isArchived(); };

上面的文本包含了初始化捕获的使用,”=”的左侧是指定的闭包类中数据成员的名称,右侧则是初始化表达式。有趣的是,”=”左侧的作用范围不同于右侧的作用范围。在上面的示例中,’=’左侧的名称pw表示闭包类中的数据成员,而右侧的名称pw表示在lambda上方声明的对象,即由调用初始化的变量到调用std::make_unique。因此,pw = std :: move(pw)的意思是“在闭包中创建一个数据成员pw,并通过将std::move应用于局部变量pw的方法来初始化该数据成员。

一般中,lambda主体中的代码在闭包类的作用范围内,因此pw的使用指的是闭包类的数据成员。

在此示例中,注释configure * pw表示在由std::make_unique创建窗口小部件之后,再由lambda捕获到该窗口小部件的std::unique_ptr之前,该窗口小部件即pw对象以某种方式进行了修改。如果不需要这样的配置,即如果std::make_unique创建的Widget处于适合被lambda捕获的状态,则不需要局部变量pw,因为闭包类的数据成员可以通过直接初始化std::make_unique来实现:

1
2
3
auto func = [pw = std::make_unique<Widget>()] // init data mbr 
{ return pw->isValidated() // in closure w/
&& pw->isArchived(); }; // result of call // to make_unique

这清楚地表明了,这个C ++ 14的捕获概念是从C ++11发展出来的的,在C ++11中,无法捕获表达式的结果。 因此,初始化捕获的另一个名称是广义lambda捕获。
但是,如果您使用的一个或多个编译器不支持C ++ 14的初始捕获怎么办? 如何使用不支持移动捕获的语言完成移动捕获?

请记住,lambda表达式只是生成类和创建该类型对象的一种方式而已。如果对于lambda,你觉得无能为力。 那么我们刚刚看到的C++ 14的示例代码可以用C ++11重新编写,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
class IsValAndArch {
public:
using DataType = std::unique_ptr<Widget>; // "is validated and archived"
explicit IsValAndArch(DataType&& ptr) // Item 25 explains
: pw(std::move(ptr)) {} // use of std::move
bool operator()() const
{ return pw->isValidated() && pw->isArchived(); }
private:
DataType pw;
};

auto func = IsValAndArch(std::make_unique<Widget>());

这个代码量比lambda表达式要多,但这并不难改变这样一个事实,即如果你希望使用一个C++11的类来支持其数据成员的移动初始化,那么你唯一要做的就是在键盘上多花点时间。

如果你坚持要使用lambda(并且考虑到它们的便利性,你可能会这样做),可以在C++11中这样使用:

  1. 将要捕获的对象移动到由std::bind
  2. 将被捕获的对象赋予一个引用给lambda;

如果你熟悉std::bind,那么代码其实非常简单。如果你不熟悉std::bind,那可能需要花费一些时间来习惯改代码,但这无疑是值得的。

假设你要创建一个本地的std::vector,在其中放入一组适当的值,然后将其移动到闭包中。在C ++14中,这很容易实现:

1
2
3
4
std::vector<double> data; // object to be moved
// into closure
// populate data
auto func = [data = std::move(data)] { /* uses of data */ }; // C++14 init capture

我已经对该代码的关键部分进行了高亮:要移动的对象的类型(std::vector\<double>),该对象的名称(数据)以及用于初始化捕获的初始化表达式(std::move(data))。C++11的等效代码如下,其中我强调了相同的关键事项:

1
2
3
4
5
6
std::vector<double> data; // as above
auto func =
std::bind( // C++11 emulation
[](const std::vector<double>& data) { /* uses of data */ }, // of init capture
std::move(data)
);

如lambda表达式一样,std::bind生产了函数对象。我将它称呼为由std::bind所绑定对象返回的函数对象。std::bind的第一个参数是可调用对象,后续参数表示要传递给该对象的值。

一个绑定的对象包含了传递给std::bind的所有参数副本。对于每个左值参数,绑定对象中的对应对象都是复制构造的。对于每个右值,它都是移动构造的。在此示例中,第二个参数是一个右值(std::move的结果,请参见第23项),因此将数据移动到绑定对象中。这种移动构造是模仿移动捕获的关键,因为将右值移动到绑定对象是我们解决无法将右值移动到C++11闭包中的方法。

当“调用”绑定对象(即调用其函数调用运算符)时,其存储的参数将传递到最初传递给std::bind的可调用对象。在此示例中,这意味着当调用func(绑定对象)时,func中所移动构造的数据副本将作为参数传递给传递给std::bind中的lambda。

该lambda与我们在C++14中使用的lambda相同,只是添加了一个参数data来对应我们的伪移动捕获对象。此参数是对绑定对象中数据副本的左值引用。(这不是右值引用,因尽管用于初始化数据副本的表达式(std::move(data))为右值,但数据副本本身为左值。)因此,lambda将对绑定在对象内部的移动构造数据副本进行操作。

默认情况下,从lambda生成的闭包类中的operator()成员函数为const的。这具有在lambda主体内呈现闭包中的所有数据成员为const的效果。但是,绑定对象内部的移动构造数据副本不一定是const的,因此,为了防止在lambda内修改该数据副本,lambda的参数应声明为const引用。 如果将lambda声明为可变的,则不会在其闭包类中将operator()声明为const,并且在lambda的参数声明中省略const也是合适的:

1
2
3
4
5
auto func =
std::bind( // C++11 emulation
[](std::vector<double>& data) mutable // of init capture
{ /* uses of data */ }, // for mutable lambda std::move(data)
);

因为该绑定对象存储着传递给std::bind的所有参数副本,所以在我们的示例中,绑定对象包含由lambda生成的闭包副本,这是它的第一个参数。 因此闭包的生命周期与绑定对象的生命周期相同。 这很重要,因为这意味着只要存在闭包,包含伪移动捕获对象的绑定对象也将存在。

如果这是您第一次接触std::bind,则可能需要先阅读您最喜欢的C ++11参考资料,然后再进行讨论所有详细信息。 即使是这样,这些基本要点也应该清楚:

  • 无法将移动构造一个对象到C ++11闭包,但是可以将对象移动构造为C++11的绑定对象。
  • 在C++11中模拟移动捕获包括将对象移动构造为绑定对象,然后通过引用将对象移动构造传递给lambda。
  • 由于绑定对象的生命周期与闭包对象的生命周期相同,因此可以将绑定对象中的对象视为闭包中的对象。

作为使用std::bind模仿移动捕获的第二个示例,这是我们之前看到的在闭包中创建std::unique_ptr的C++14代码:

1
2
3
auto func = [pw = std::make_unique<Widget>()]  	// as before,
{ return pw->isValidated() // create pw
&& pw->isArchived(); }; // in closure

这是C++11的模拟实现:

1
2
3
4
5
6
auto func = std::bind(
[](const std::unique_ptr<Widget>& pw)
{ return pw->isValidated()
&& pw->isArchived(); },
std::make_unique<Widget>()
);

具备讽刺意味的是,这里我展示了如何使用std::bind解决C++11 lambda中的限制,但在条款34中,我却主张在std::bind上使用lambda。

但是,该条目解释的是在C++11中有些情况下std::bind可能有用,这就是其中一种。 (在C++14中,初始化捕获和自动参数等功能使得这些情况不再存在。)

要谨记的是:

  • 使用C ++14的初始化捕获将对象移动到闭包中。
  • 在C ++11中,通过手写类或std::bind的方式来模拟初始化捕获。

Item 33:对于std::forward的auto&&形参使用decltype

泛型lambda(generic lambdas)是C++14中最值得期待的特性之一——因为在lambda的参数中可以使用auto关键字。这个特性的实现是非常直截了当的:即在闭包类中的operator()函数是一个函数模版。例如存在这么一个lambda:

1
auto f = [](auto x){ return func(normalize(x)); };

对应的闭包类中的函数调用操作符看来就变成这样:

1
2
3
4
5
6
class SomeCompilerGeneratedClassName { public:
template<typename T>
auto operator()(T x) const
{ return func(normalize(x)); }
...
};

在这个样例中,lambda对变量x做的唯一一件事就是把它转发给函数normalize。如果函数normalize对待左值右值的方式不一样,这个lambda的实现方式就不大合适了,因为即使传递到lambda的实参是一个右值,lambda传递进去的形参总是一个左值。

实现这个lambda的正确方式是把x完美转发给函数normalize。这样做需要对代码做两处修改。首先,x需要改成通用引用,其次,需要使用std::forwardx转发到函数normalize。实际上的修改如下:

1
2
auto f = [](auto&& x)
{ return func(normalize(std::forward<???>(x))); };

在理论和实际之间存在一个问题:你传递给std::forward的参数是什么类型,就决定了上面的???该怎么修改。

一般来说,当你在使用完美转发时,你是在一个接受类型参数为T的模版函数里,所以你可以写std::forward<T>。但在泛型lambda中,没有可用的类型参数T。在lambda生成的闭包里,模版化的operator()函数中的确有一个T,但在lambda里却无法直接使用它。

前面item28解释过在传递给通用引用的是一个左值,那么它会变成左值引用。传递的是右值就会变成右值引用。这意味着在这个lambda中,可以通过检查x的类型来检查传递进来的实参是一个左值还是右值,decltype就可以实现这样的效果。传递给lambda的是一个左值,decltype(x)就能产生一个左值引用;如果传递的是一个右值,decltype(x)就会产生右值引用。

Item28也解释过在调用std::forward,传递给它的类型类型参数是一个左值引用时会返回一个左值;传递的是一个非引用类型时,返回的是一个右值引用,而不是常规的非引用。在前面的lambda中,如果x绑定的是一个左值引用,decltype(x)就能产生一个左值引用;如果绑定的是一个右值,decltype(x)就会产生右值引用,而不是常规的非引用。

在看一下Item28中关于std::forward的C++14实现:

1
2
3
4
5
template<typename T> // in namespace 
T&& forward(remove_reference_t<T>& param) // std
{
return static_cast<T&&>(param);
}

如果用户想要完美转发一个Widget类型的右值时,它会使用Widget类型(非引用类型)来示例化std::forward,然后产生以下的函数:

1
2
3
4
Widget&& forward(Widget& param) 
{ // instantiation of
return static_cast<Widget&&>(param); // std::forward when
} // T is Widget

思考一下如果用户代码想要完美转发一个Widget类型的右值,但没有遵守规则将T指定为非引用类型,而是将T指定为右值引用,这回发生什么?思考将T换成Widget如何,在std::forward实例化、应用了remove_reference_t后,音乐折叠之前,这是产生的代码:

1
2
3
4
Widget&& && forward(Widget& param)        // instantiation of 
{ // std::forward when
return static_cast<Widget&& &&>(param); // T is Widget&&
} // (before reference-collapsing)

应用了引用折叠之后,代码会变成:

1
2
3
4
Widget&& forward(Widget& param)        // instantiation of 
{ // std::forward when
return static_cast<Widget&&>(param); // T is Widget&&
} // (before reference-collapsing)

对比发现,用一个右值引用去实例化std::forward和用非引用类型去实例化产生的结果是一样的。

那是一个很好的消息,引用当传递给lambda形参x的是一个右值实参时,decltype(x)可以产生一个右值引用。前面已经确认过,把一个左值传给lambda时,decltype(x)会产生一个可以传给std::forward的常规类型。而现在也验证了对于右值,把decltype(x)产生的类型传递给std::forward的类型参数是非传统的,不过它产生的实例化结果与传统类型相同。所以无论是左值还是右值,把decltype(x)传递给std::forward都能得到我们想要的结果,因此lambda的完美转发可以写成:

1
2
3
4
5
6
auto f = 
[](auto&& param)
{
return
func(normalize(std::forward<decltype(pram)>(param)));
};

再加上6个点,就可以让我们的lambda完美转发接受多个参数了,因为C++14中的lambda参数是可变的:

1
2
3
4
5
6
auto f = 
[](auto&&... params)
{
return
func(normalized(std::forward<decltype(params)>(params)...));
};

要谨记的是:

  • auto&&参数使用decltype来(std::forward)转发参数;

Item 34:考虑lambda表达式而非std::bind

C++11中的std::bind是C++98的std::bind1ststd::bind2nd的后续,但在2005年已经成为了标准库的一部分。那时标准化委员采用了TR1的文档,其中包含了bind的规范。(在TR1中,bind位于不同的命名空间,因此它是std::tr1::bind,而不是std::bind,接口细节也有所不同)。这段历史意味着一些程序员有十年或更长时间的使用std::bind经验。如果您是其中之一,可能会不愿意放弃一个对您有用的工具。这是可以理解的,但是在这种情况下,改变是更好的,因为在C ++11中,lambda几乎是比std :: bind更好的选择。 从C++14开始,lambda的作用不仅强大,而且是完全值得使用的。

这个条目假设您熟悉std::bind。 如果不是这样,您将需要获得基本的了解,然后再继续。 无论如何,这样的理解都是值得的,因为您永远不知道何时会在必须阅读或维护的代码库中遇到std::bind的使用。

与第32项中一样,我们将从std::bind返回的函数对象称为绑定对象。

优先lambda而不是std::bind的最重要原因是lambda更易读。 例如,假设我们有一个设置闹钟的函数:

1
2
3
4
5
6
7
8
9
// typedef for a point in time (see Item 9 for syntax) 
using Time = std::chrono::steady_clock::time_point;

// see Item 10 for "enum class"
enum class Sound { Beep, Siren, Whistle };

// typedef for a length of time
using Duration = std::chrono::steady_clock::duration;
// at time t, make sound s for duration d void setAlarm(Time t, Sound s, Duration d);

进一步假设,在程序的某个时刻,我们已经确定需要设置一个小时后响30秒的闹钟。 但是,具体声音仍未确定。我们可以编写一个lambda来修改setAlarm的界面,以便仅需要指定声音:

1
2
3
4
5
6
7
8
9
10
// setSoundL ("L" for "lambda") is a function object allowing a // sound to be specified for a 30-sec alarm to go off an hour // after it's set
auto setSoundL =
[](Sound s)
{
// make std::chrono components available w/o qualification
using namespace std::chrono;
setAlarm(steady_clock::now() + hours(1), // alarm to go off
s, // in an hour for
seconds(30)); // 30 seconds
};

我们在lambda中突出了对setAlarm的调用。这看来起是一个很正常的函数调用,即使是几乎没有lambda经验的读者也可以看到:传递给lambda的参数被传递给了setAlarm

通过使用基于C++11对用户自定义常量的支持而建立的标准后缀,如秒(s),毫秒(ms)和小时(h)等,我们可以简化C++14中的代码。这些后缀在std::literals命名空间中实现,因此上述代码可以按照以下方式重写:

1
2
3
4
5
6
7
8
9
auto setSoundL =
[](Sound s)
{
using namespace std::chrono;
using namespace std::literals; // for C++14 suffixes
setAlarm(steady_clock::now() + 1h, // C++14, but
s, // same meaning
30s); // as above
};

下面是我们第一次编写对应的std::bind调用。这里存在一个我们后续会修复的错误,但正确的代码会更加复杂,即使是此简化版本也会带来一些重要问题:

1
2
3
4
5
6
7
using namespace std::chrono; // as above
using namespace std::literals;
using namespace std::placeholders; // needed for use of "_1"
auto setSoundB = std::bind(setAlarm, // "B" for "bind"
steady_clock::now() + 1h, // incorrect! see below
_1,
30s);

我想像在lambda中一样突出显示对setAlarm的调用,但是没有这么做。这段代码的读者只需知道,调用setSoundB会使用在对std :: bind的调用中所指定的时间和持续时间来调用setAlarm。对于初学者来说,占位符“ _1”本质上是一个魔术,但即使是普通读者也必须从思维上将占位符中的数字映射到其在std::bind参数列表中的位置,以便明白调用setSoundB时的第一个参数会被传递进setAlarm,作为调用时的第二个参数。在对std::bind的调用中未标识此参数的类型,因此读者必须查阅setAlarm声明以确定将哪种参数传递给setSoundB

但正如我所说,代码并不完全正确。在lambda中,表达式steady_clock::now() + 1h显然是是setAlarm的参数。调用setAlarm时将对其进行计算。这是合理的:我们希望在调用setAlarm后一小时发出警报。但是,在std::bind调用中,将steady_clock::now() + 1h作为参数传递给了std::bind,而不是setAlarm。这意味着将在调用std::bind时对表达式进行求值,并且该表达式产生的时间将存储在结果绑定对象中。结果,闹钟将被设置为在调用std::bind后一小时发出声音,而不是在调用setAlarm`一小时后发出。

要解决此问题,需要告诉std::bind推迟对表达式的求值,直到调用setAlarm为止,而这样做的方法是将对std::bind的第二个调用嵌套在第一个调用中:

1
2
3
4
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h), _1,
30s);

如果您熟悉C++98的std::plus模板,您可能会惊讶地发现在此代码中,尖括号之间未指定任何类型,即该代码包含std::plus<>,而不是std::plus<type>。 在C ++14中,通常可以省略标准运算符模板的模板类型参数,因此无需在此处提供。 C++11没有提供此类功能,因此等效于lambda的C ++11 std::bind使用为:

1
2
3
4
5
6
using namespace std::chrono;                   // as above
using namespace std::placeholders;
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<steady_clock::time_point>(), steady_clock::now(), hours(1)),
seconds(30));

如果此时Lambda看起来不够吸引,那么应该检查一下视力了。

当setAlarm重载时,会出现一个新问题。 假设有一个重载函数,其中第四个参数指定了音量:

1
2
enum class Volume { Normal, Loud, LoudPlusPlus };
void setAlarm(Time t, Sound s, Duration d, Volume v);

lambda能继续像以前一样使用,因为根据重载规则选择了setAlarm的三参数版本:

1
2
3
4
5
6
7
auto setSoundL =
[](Sound s)
{
using namespace std::chrono;
setAlarm(steady_clock::now() + 1h, s,
30s);
};

然而,std::bind的调用将会编译失败:

1
2
3
4
5
6
7
auto setSoundB =                               // error! which
std::bind(setAlarm, // setAlarm?
std::bind(std::plus<>(),
steady_clock::now(),
1h),
_1,
30s);

这里的问题是,编译器无法确定应将两个setAlarm函数中的哪一个传递给std::bind。 它们仅有的是一个函数名称,而这个函数名称是不确定的。
要获得对std::bind的调用能进行编译,必须将setAlarm强制转换为适当的函数指针类型:

1
2
3
4
5
6
7
8
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
auto setSoundB = // now
std::bind(static_cast<SetAlarm3ParamType>(setAlarm), // okay
std::bind(std::plus<>(),
steady_clock::now(),
1h),
_1,
30s);

但这在lambdastd::bind的使用上带来了另一个区别。 在setSoundL的函数调用操作符(即lambda的闭包类对应的函数调用操作符)内部,对setAlarm的调用是正常的函数调用,编译器可以按常规方式进行内联:

1
2
setSoundL(Sound::Siren); 	// body of setAlarm may 
// well be inlined here

但是,对std::bind的调用是将函数指针传递给setAlarm,这意味着在setSoundB的函数调用操作符(即绑定对象的函数调用操作符)内部,对setAlarm的调用是通过一个函数指针。 编译器不太可能通过函数指针内联函数,这意味着与通过setSoundL进行调用相比,通过setSoundBsetAlarm的调用,其函数不大可能被内联:

1
2
setSoundB(Sound::Siren); 	// body of setAlarm is less 
// likely to be inlined here

因此,使用lambda可能会比使用std::bind能生成更快的代码。
setAlarm示例仅涉及一个简单的函数调用。如果您想做更复杂的事情,使用lambda会更有利。 例如,考虑以下C++14的lambda使用,它返回其参数是否在最小值(lowVal)和最大值(highVal)之间的结果,其中lowValhighVal 是局部变量:

1
2
3
4
auto betweenL =
[lowVal, highVal]
(const auto& val) // C++14
{ return lowVal <= val && val <= highVal; };

使用std::bind可以表达相同的内容,但是该构造是一个通过晦涩难懂的代码来保证工作安全性的示例:

1
2
3
4
5
using namespace std::placeholders;           // as above
auto betweenB =
std::bind(std::logical_and<>(), // C++14
std::bind(std::less_equal<>(), lowVal, _1),
std::bind(std::less_equal<>(), _1, highVal));

在C++11中,我们必须指定要比较的类型,然后std::bind调用将如下所示:

1
2
3
4
auto betweenB = // C++11 version 
std::bind(std::logical_and<bool>(),
std::bind(std::less_equal<int>(), lowVal, _1),
std::bind(std::less_equal<int>(), _1, highVal));

当然,在C++11中,lambda也不能采用auto参数,因此它也必须指定一个类型:

1
2
3
4
auto betweenL = // C++11 version 
[lowVal, highVal]
(int val)
{ return lowVal <= val && val <= highVal; };

无论哪种方式,我希望我们都能同意,lambda版本不仅更短,而且更易于理解和维护。
之前我就说过,对于那些没有std::bind使用经验的人,其占位符(例如_1,_2等)本质上都是magic。 但是,不仅仅占位符的行为是不透明的。 假设我们有一个函数可以创建Widget的压缩副本,

1
2
3
4
enum class CompLevel { Low, Normal, High };  // compression
// level
Widget compress(const Widget& w, // make compressed
CompLevel lev); // copy of w

并且我们想创建一个函数对象,该函数对象允许我们指定应将特定w的压缩级别。这种使用std::bind的话将创建一个这样的对象:

1
2
3
Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);

现在,当我们将w传递给std::bind时,必须将其存储起来,以便以后进行压缩。它存储在对象compressRateB中,但是这是如何存储的呢(是通过值还是引用)。之所以会有所不同,是因为如果在对std::bind的调用与对compressRateB的调用之间修改了w,则按引用捕获的w将反映其更改,而按值捕获则不会。

答案是它是按值捕获的,但唯一知道的方法是记住std::bind的工作方式;在对std::bind的调用中没有任何迹象。与lambda方法相反,其中w是通过值还是通过引用捕获是显式的:

1
2
3
auto compressRateL =          	// w is captured by
[w](CompLevel lev) // value; lev is
{ return compress(w, lev); }; // passed by value

同样明确的是如何将参数传递给lambda。 在这里,很明显参数lev是通过值传递的。 因此:

1
2
compressRateL(CompLevel::High); // arg is passed 
// by value

但是在对由std::bind生成的对象调用中,参数如何传递?

1
2
compressRateB(CompLevel::High); // how is arg
// passed?

同样,唯一的方法是记住std::bind的工作方式。(答案是传递给绑定对象的所有参数都是通过引用传递的,因为此类对象的函数调用运算符使用完美转发。)
与lambda相比,使用std::bind进行编码的代码可读性较低,表达能力较低,并且效率可能较低。 在C++14中,没有std::bind的合理用例。 但是,在C ++11中,可以在两个受约束的情况下证明使用std::bind是合理的:

  • 移动捕获。 C++11的lambda不提供移动捕获,但是可以通过结合lambda和std::bind来模拟。 有关详细信息,请参阅条款32,该条款还解释了在C ++ 14中,lambda对初始化捕获的支持将少了模拟的需求。
  • 多态函数对象。 因为绑定对象上的函数调用运算符使用完全转发,所以它可以接受任何类型的参数(以条款30中描述的完全转发的限制为例子)。当您要使用模板化函数调用运算符来绑定对象时,此功能很有用。 例如这个类,
1
2
3
4
5
class PolyWidget {
public:
template<typename T>
void operator()(const T& param); ...
};

std::bind可以如下绑定一个PolyWidget对象:

1
2
PolyWidget pw;
auto boundPW = std::bind(pw, _1);

boundPW可以接受任意类型的对象了:

1
2
3
4
5
6
boundPW(1930); 	// pass int to
// PolyWidget::operator()
boundPW(nullptr); // pass nullptr to
// PolyWidget::operator()
boundPW("Rosebud"); // pass string literal to
// PolyWidget::operator()

这一点无法使用C++11的lambda做到。 但是,在C++14中,可以通过带有auto参数的lambda轻松实现:

1
2
auto boundPW = [pw](const auto& param) // C++14 
{ pw(param); };

当然,这些是特殊情况,并且是暂时的特殊情况,因为支持C++14 lambda的编译器越来越普遍了。
bind在2005年被非正式地添加到C ++中时,与1998年的前身相比有了很大的改进。 在C ++11中增加了lambda支持,这使得std::bind几乎已经过时了,从C ++ 14开始,更是没有很好的用例了。

要谨记的是:

  • 与使用std::bind相比,Lambda更易读,更具表达力并且可能更高效。
  • 只有在C++11中,std::bind可能对实现移动捕获或使用模板化函数调用运算符来绑定对象时会很有用。

CHAPTER7: 并发API

C++11的伟大标志之一是将并发整合到语言和库中。熟悉其他线程API(比如pthreads或者Windows threads)的开发者有时可能会对C++提供的斯巴达式(译者注:应该是简陋和严谨的意思)功能集感到惊讶,这是因为C++对于并发的大量支持是在编译器的约束层面。由此产生的语言保证意味着在C++的历史中,开发者首次通过标准库可以写出跨平台的多线程程序。这位构建表达库奠定了坚实的基础,并发标准库(tasks, futures, threads, mutexes, condition variables, atomic objects等)仅仅是成为并发软件开发者丰富工具集的基础。

在接下来的Item中,记住标准库有两个futures的模板:std::future和std::shared_future。在许多情况下,区别不重要,所以我们经常简单的混于一谈为futures

Item 35:优先基于任务编程而不是基于线程

如果开发者想要异步执行 doAsyncWork 函数,通常有两种方式。其一是通过创建 std::thread 执行 doAsyncWork, 比如

1
2
int doAsyncWork();
std::thread t(doAsyncWork);

其二是将 doAsyncWork 传递给 std::async, 一种基于任务的策略:
1
auto fut = std::async(doAsyncWork); // "fut" for "future"

这种方式中,函数对象作为一个任务传递给 std::async

基于任务的方法通常比基于线程的方法更优,原因之一上面的代码已经表明,基于任务的方法代码量更少。我们假设唤醒doAsyncWork的代码对于其提供的返回值是有需求的。基于线程的方法对此无能为力,而基于任务的方法可以简单地获取std::async返回的future提供的get函数获取这个返回值。如果doAsycnWork发生了异常,get函数就显得更为重要,因为get函数可以提供抛出异常的访问,而基于线程的方法,如果doAsyncWork抛出了异常,线程会直接终止(通过调用std::terminate)。

基于线程与基于任务最根本的区别在于抽象层次的高低。基于任务的方式使得开发者从线程管理的细节中解放出来,对此在C++并发软件中总结了’thread’的三种含义:

  • 硬件线程(Hardware threads)是真实执行计算的线程。现代计算机体系结构为每个CPU核心提供一个或者多个硬件线程。
  • 软件线程(Software threads)(也被称为系统线程)是操作系统管理的在硬件线程上执行的线程。通常可以存在比硬件线程更多数量的软件线程,因为当软件线程被比如 I/O、同步锁或者条件变量阻塞的时候,操作系统可以调度其他未阻塞的软件线程执行提供吞吐量。
  • std::threads是C++执行过程的对象,并作为软件线程的handle(句柄)。std::threads存在多种状态,1. null表示空句柄,因为处于默认构造状态(即没有函数来执行),因此不对应任何软件线程。 2. moved from (moved-to的std::thread就对应软件进程开始执行) 3. joined(连接唤醒与被唤醒的两个线程) 4. detached(将两个连接的线程分离)

软件线程是有限的资源。如果开发者试图创建大于系统支持的硬件线程数量,会抛出std::system_error异常。即使你编写了不抛出异常的代码,这仍然会发生,比如下面的代码,即使 doAsyncWorknoexcept

1
int doAsyncWork() noexcept; // see Item 14 for noexcept

这段代码仍然会抛出异常。
1
2
std::thread t(doAsyncWork); // throw if no more
// threads are available

设计良好的软件必须有效地处理这种可能性(软件线程资源耗尽),一种有效的方法是在当前线程执行doAsyncWork,但是这可能会导致负载不均,而且如果当前线程是GUI线程,可能会导致响应时间过长的问题;另一种方法是等待当前运行的线程结束之后再创建新的线程,但是仍然有可能当前运行的线程在等待doAsyncWork的结果(例如操作得到的变量或者条件变量的通知)。

即使没有超出软件线程的限额,仍然可能会遇到资源超额的麻烦。如果当前准备运行的软件线程大于硬件线程的数量,系统的线程调度程序会将硬件核心的时间切片,当一个软件线程的时间片执行结束,会让给另一个软件线程,即发生上下文切换。软件线程的上下文切换会增加系统的软件线程管理开销,并且如果发生了硬件核心漂移,这个开销会更高,具体来说,如果发生了硬件核心漂移,(1)CPU cache中关于上次执行线程的数据很少,需要重新加载指令;(2)新线程的cache数据会覆盖老线程的数据,如果将来会再次覆盖老线程的数据,显然频繁覆盖增加很多切换开销。

避免资源超额是困难的,因为软件线程之于硬件线程的最佳比例取决于软件线程的执行频率,(比如一个程序从IO密集型变成计算密集型,执行频率是会改变的),而且比例还依赖上下文切换的开销以及软件线程对于CPU cache的使用效率。此外,硬件线程的数量和CPU cache的速度取决于机器的体系结构,即使经过调校,软件比例在某一种机器平台取得较好效果,换一个其他类型的机器这个调校并不能提供较好效果的保证。

而使用std::async可以将调校最优比例这件事隐藏于标准库中,在应用层面不需过多考虑

1
2
3
auto fut = std::async(doAsyncWork);  // onus of thread mgmt is
// on implement of
// the Standard Library

这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额的异常,为何这么说调用std::async并不保证开启一个新的线程,只是提供了执行函数的保证,具体是否创建新的线程来运行此函数,取决于具体实现,比如可以通过调度程序来将AsyncWork运行在等待此函数结果的线程上,调度程序的合理性决定了系统是否会抛出资源超额的异常,但是这是库开发者需要考虑的事情了。

如果考虑自己实现在等待结果的线程上运行输出结果的函数,之前提到了可能引出负载不均衡的问题,std::async运行时的调度程序显然比开发者更清楚调度策略的制定,因为运行时调度程序管理的是所有执行过程,而不仅仅个别开发者运行的代码。

如果在GUI程序中使用std::async会引起响应变慢的问题,还可以通过std::launch::asyncstd::async传递调度策略来保证运行函数在不同的线程上执行。

最前沿的线程调度算法使用线程池来避免资源超额的问题,并且通过窃取算法来提升了跨硬件核心的负载均衡。C++标准实际上并不要求使用线程池或者work-stealing算法,而且这些技术的实现难度可能比你想象中更有挑战。不过,库开发者在标准库实现中采用了这些前沿的技术,这使得采用基于任务的方式编程的开发者在这些技术发展中持续获得回报,相反如果开发者直接使用std::thread编程,处理资源耗竭,负责均衡问题的责任就压在了应用开发者身上,更不说如何使得开发方案跨平台使用。

对比基于线程的开发方式,基于任务的设计为开发者避免了线程管理的痛苦,并且自然提供了一种获取异步执行的结果的方式。当然,仍然存在一些场景直接使用std::thread会更有优势:

  • 需要访问非常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads 或者 windows threads)来实现的,系统级API通常会提供更加灵活的操作方式,举个例子,C++并发API没有线程优先级和affinities的概念。为了提供对底层系统级线程API的访问,std::thread对象提供了native_handle的成员函数,而在高层抽象的比如std::futures没有这种能力。
  • 需要优化应用的线程使用。举个例子,只在特定系统平台运行的软件,可以调教地比使用C++并行API更好的程序性能。
  • 需要实现C++并发API之外的线程技术。举例来说,自行实现线程池技术。

这些都是在应用开发中并不常见的例子,大多数情况,开发者应该优先采用基于任务的编程方式。

记住

  • std::threadAPI不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行
  • 基于线程的编程方式关于解决资源超限,负载均衡的方案移植性不佳
  • 基于任务的编程方式std::async会默认解决上面两条问题

Item 36: 确保在异步为必须时,才指定std::launch::async

当你调用std::async执行函数时(或者其他可调用对象),你通常希望异步执行函数。但是这并不一定是你想要std::async执行的操作。你确实通过std::asynclaunch policy(译者注:这里没有翻译)要求执行函数,有两种标准policy,都通过std::launch域的枚举类型表示(参见Item10关于枚举的更多细节)。假定一个函数f传给std::async来执行:

  • std::launch::async的launch policy意味着f必须异步执行,即在不同的线程
  • std::launch::deferred的launch policy意味着f仅仅在当调用get或者wait要求std::async的返回值时才执行。这表示f推迟到被求值才延迟执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当get或wait被调用,f会同步执行,即调用方停止直到f运行结束。如果get和wait都没有被调用,f将不会被执行

有趣的是,std::async的默认launch policy是以上两种都不是。相反,是求或在一起的。下面的两种调用含义相同

1
2
auto fut1 = std::async(f); // run f using default launch policy
auto fut2 = std::async(std::launch::async | std::launch::deferred, f); // run f either async or defered

因此默认策略允许f异步或者同步执行。如同Item 35中指出,这种灵活性允许std::async和标准库的线程管理组件(负责线程的创建或销毁)避免超载。这就是使用std::async并发编程如此方便的原因。

但是,使用默认启动策略的std::async也有一些有趣的影响。给定一个线程t执行此语句:

1
auto fut = std::async(f); // run f using default launch policy
  • 无法预测f是否会与t同时运行,因为f可能被安排延迟运行
  • 无法预测f是否会在调用get或wait的线程上执行。如果那个线程是t,含义就是无法预测f是否也在线程t上执行
  • 无法预测f是否执行,因为不能确保get或者wait会被调用

默认启动策略的调度灵活性导致使用线程本地变量比较麻烦,因为这意味着如果f读写了线程本地存储(thread-local storage, TLS),不可能预测到哪个线程的本地变量被访问:

1
auto fut = std::async(f); // TLS for f possibly for independent thread, but possibly for thread invoking get or wait on fut

还会影响到基于超时机制的wait循环,因为在task的wait_for或者wait_until调用中(参见Item 35)会产生延迟求值(std::launch::deferred)。意味着,以下循环看似应该终止,但是实际上永远运行:

1
2
3
4
5
6
7
8
9
10
11
using namespace std::literals; // for C++14 duration suffixes; see Item 34
void f()
{
std::this_thread::sleep_for(1s);
}

auto fut = std::async(f);
while (fut.wait_for(100ms) != std::future_status::ready)
{ // loop until f has finished running... which may never happen!
...
}

如果f与调用std::async的线程同时运行(即,如果为f选择的启动策略是std::launch::async),这里没有问题(假定f最终执行完毕),但是如果f是延迟执行,fut.wait_for将总是返回std::future_status::deferred。这表示循环会永远执行下去。

这种错误很容易在开发和单元测试中忽略,因为它可能在负载过高时才能显现出来。当机器负载过重时,任务推迟执行才最有可能发生。毕竟,如果硬件没有超载,没有理由不安排任务并发执行。

修复也是很简单的:只需要检查与std::async的future是否被延迟执行即可,那样就会避免进入无限循环。不幸的是,没有直接的方法来查看future是否被延迟执行。相反,你必须调用一个超时函数——比如wait_for这种函数。在这个逻辑中,你不想等待任何事,只想查看返回值是否std::future_status::deferred,如果是就使用0调用wait_for来终止循环。

1
2
3
4
5
6
7
8
9
auto fut = std::async(f);
if (fut.wait_for(0s) == std::future_status::deferred) { // if task is deferred
... // use wait or get on fut to call f synchronously
}
else { // task isn't deferred
while(fut.wait_for(100ms) != std::future_status::ready) { // infinite loop not possible(assuming f finished)
... // task is neither deferred nor ready, so do concurrent word until it's ready
}
}

这些各种考虑的结果就是,只要满足以下条件,std::async的默认启动策略就可以使用:

  • task不需要和执行get or wait的线程并行执行
  • 不会读写线程的线程本地变量
  • 可以保证在std::async返回的将来会调用get or wait,或者该任务可能永远不会执行是可以接受的
  • 使用wait_for or wait_until编码时考虑deferred状态

如果上述条件任何一个都满足不了,你可能想要保证std::async的任务真正的异步执行。进行此操作的方法是调用时,将std::launch::async作为第一个参数传递:

1
auto fut = std::async(std::launch::async, f); // launch f asynchronously

事实上,具有类似std::async行为的函数,但是会自动使用std::launch::async作为启动策略的工具也是很容易编写的,C++11版本如下:

1
2
3
4
5
6
7
template<typename F, typename... Ts>
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&... params)
{
return std::async(std::launch::async, std::forward<F>(f), std::forward<Ts>(params)...);
}

这个函数接受一个可调用对象和0或多个参数params然后完美转发(参见Item25)给std::async,使用std::launch::async作为启动参数。就像std::async一样,返回std::future类型。确定结果的类型很容易,因为类型特征std::result_of可以提供(参见Item 9 关于类型特征的详细表述)。

reallyAsync就像std::async一样使用:

1
auto fut = reallyAsync(f); 

在C++14中,返回类型的推导能力可以简化函数的定义:

1
2
3
4
5
6
7
template<typename f, typename... Ts>
inline
auto
reallyAsync(F&& f, Ts&&... params)
{
return std::async(std::launch::async, std::forward<T>(f), std::forward<Ts>(params)...);
}

这个版本清楚表明,reallyAsync除了使用std::launch::async启动策略之外什么也没有做。

需要记住的事

  • std::async的默认启动策略是异步或者同步的
  • 灵活性导致访问thread_locals的不确定性,隐含了task可能不会被执行的意思,会影响程序基于wait的超时逻辑
  • 只有确实异步时才指定std::launch::async

Item 37:Make std::threads unjoinable on all paths

每个std::thread对象处于两个状态之一:joinable or unjoinablejoinable状态的std::thread对应于正在运行或者可能正在运行的异步执行线程。比如,一个blocked或者等待调度的std::threadjoinable,已运行结束的std::thread也可以认为是joinable

unjoinablestd::thread对象比如:

  • Default-constructed std::threads。这种std::thread没有函数执行,因此无法绑定到具体的线程上
  • 已经被moved的std::thread对象。move的结果就是将std::thread对应的线程所有权转移给另一个std::thread
  • 已经joined的std::thread。在join之后,std::thread执行结束,不再对应于具体的线程
  • 已经detached的std::thread。detach断开了std::thread与线程之间的连接

(译者注:std::thread可以视作状态保存的对象,保存的状态可能也包括可调用对象,有没有具体的线程承载就是有没有连接)

std::thread的可连接性如此重要的原因之一就是当连接状态的析构函数被调用,执行逻辑被终止。比如,假定有一个函数doWork,执行过滤函数filter,接收一个参数maxValdoWork检查是否满足计算所需的条件,然后通过使用0到maxVal之间的所有值过滤计算。如果进行过滤非常耗时,并且确定doWork条件是否满足也很耗时,则将两件事并发计算是很合理的。

我们希望为此采用基于任务的设计(参与Item 35),但是假设我们希望设置做过滤线程的优先级。Item 35阐释了需要线程的基本句柄,只能通过std::thread的API来完成;基于任务的API(比如futures)做不到。所以最终采用基于std::thread而不是基于任务

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
constexpr auto tenMillion = 10000000; // see Item 15 for constexpr
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) // return whether computation was performed; see Item2 for std::function
{
std::vector<int> goodVals;
std::thread t([&filter, maxVal, &goodVals]
{
for (auto i = 0; i <= maxVal; ++i)
{
if (filter(i)) goodVals.push_back(i);
}
});
auto nh = t.native_handle(); // use t's native handle to set t's priority
...
if (conditionsAreStatisfied()) {
t.join(); // let t finish
performComputation(goodVals); // computation was performed
return true;
}

return false; // computation was not performed
}

在解释这份代码为什么有问题之前,看一下tenMillion的初始化可以在C++14中更加易读,通过单引号分隔数字:

1
constexpr auto tenMillion = 10'000'000; // C++14

还要指出,在开始运行之后设置t的优先级就像把马放出去之后再关上马厩门一样(译者注:太晚了)。更好的设计是在t为挂起状态时设置优先级(这样可以在执行任何计算前调整优先级),但是我不想你为这份代码考虑这个而分心。如果你感兴趣代码中忽略的部分,可以转到Item 39,那个Item告诉你如何以挂起状态开始线程。

返回doWork。如果conditionsAreSatisfied()返回真,没什么问题,但是如果返回假或者抛出异常,std::thread类型的tdoWork结束时会调用t的析构器。这造成程序执行中止。

你可能会想,为什么std::thread析构的行为是这样的,那是因为另外两种显而易见的方式更糟:

  • 隐式join。这种情况下,std::thread的析构函数将等待其底层的异步执行线程完成。这听起来是合理的,但是可能会导致性能异常,而且难以追踪。比如,如果conditonAreStatisfied()已经返回了假,doWork继续等待过滤器应用于所有值就很违反直觉。

  • 隐式detach。这种情况下,std::thread析构函数会分离其底层的线程。线程继续运行。听起来比join的方式好,但是可能导致更严重的调试问题。比如,在doWork中,goodVals是通过引用捕获的局部变量。可能会被lambda修改。假定,lambda的执行时异步的,conditionsAreStatisfied()返回假。这时,doWork返回,同时局部变量goodVals被销毁。堆栈被弹出,并在doWork的调用点继续执行线程

    某个调用点之后的语句有时会进行其他函数调用,并且至少一个这样的调用可能会占用曾经被doWork使用的堆栈位置。我们称为f,当f运行时,doWork启动的lambda仍在继续运行。该lambda可以在堆栈内存中调用push_back,该内存曾是goodVals,位于doWork曾经的堆栈位置。这意味着对f来说,内存被修改了,想象一下调试的时候痛苦

标准委员会认为,销毁连接中的线程如此可怕以至于实际上禁止了它(通过指定销毁连接中的线程导致程序终止)

这使你有责任确保使用std::thread对象时,在所有的路径上最终都是unjoinable的。但是覆盖每条路径可能很复杂,可能包括return, continue, break, goto or exception,有太多可能的路径。

每当你想每条路径的块之外执行某种操作,最通用的方式就是将该操作放入本地对象的析构函数中。这些对象称为RAII对象,通过RAII类来实例化。(RAII全称为 Resource Acquisition Is Initialization)。RAII类在标准库中很常见。比如STL容器,智能指针,std::fstream类等。但是标准库没有RAII的std::thread类,可能是因为标准委员会拒绝将join和detach作为默认选项,不知道应该怎么样完成RAII。

幸运的是,完成自行实现的类并不难。比如,下面的类实现允许调用者指定析构函数join或者detach

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ThreadRAII {
public:
enum class DtorAction{ join, detach }; // see Item 10 for enum class info
ThreadRAII(std::thread&& t, DtorAction a): action(a), t(std::move(t)) {} // in dtor, take action a on t
~ThreadRAII()
{
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
std::thread& get() { return t; } // see below
private:
DtorAction action;
std::thread t;
};

我希望这段代码是不言自明的,但是下面几点说明可能会有所帮助:

  • 构造器只接受std::thread右值,因为我们想要movestd::thread对象给ThreadRAII(再次强调,std::thread不可以复制)

  • 构造器的参数顺序设计的符合调用者直觉(首先传递std::thread,然后选择析构执行的动作),但是成员初始化列表设计的匹配成员声明的顺序。将std::thread成员放在声明最后。在这个类中,这个顺序没什么特别之处,调整为其他顺序也没有问题,但是通常,可能一个成员的初始化依赖于另一个,因为std::thread对象可能会在初始化结束后就立即执行了,所以在最后声明是一个好习惯。这样就能保证一旦构造结束,所有数据成员都初始化完毕可以安全的异步绑定线程执行

  • ThreadRAII提供了get函数访问内部的std::thread对象。这类似于标准智能指针提供的get函数,可以提供访问原始指针的入口。提供get函数避免了ThreadRAII复制完整std::thread接口的需要,因为着ThreadRAII可以在需要std::thread上下文的环境中使用

  • ThreadRAII析构函数调用std::thread对象t的成员函数之前,检查t是否joinable。这是必须的,因为在unjoinbale的std::thread上调用join or detach会导致未定义行为。客户端可能会构造一个std::threadt,然后通过t构造一个ThreadRAII,使用get获取t,然后移动t,或者调用join or detach,每一个操作都使得t变为unjoinable
    如果你担心下面这段代码

    1
    2
    3
    4
    5
    6
    7
    if (t.joinable()) {
    if (action == DtorAction::join) {
    t.join();
    } else {
    t.detach();
    }
    }

    存在竞争,因为在t.joinable()t.join or t.detach执行中间,可能有其他线程改变了t为unjoinable,你的态度很好,但是这个担心不必要。std::thread只有自己可以改变joinable or unjoinable的状态。在ThreadRAII的析构函数中被调用时,其他线程不可能做成员函数的调用。如果同时进行调用,那肯定是有竞争的,但是不在析构函数中,是在客户端代码中试图同时在一个对象上调用两个成员函数(析构函数和其他函数)。通常,仅当所有都为const成员函数时,在一个对象同时调用两个成员函数才是安全的。

doWork的例子上使用ThreadRAII的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
std::vector<int> goodVals;
ThreadRAII t(std::thread([&filter, maxVal, &goodVals] {
for (auto i = 0; i <= maxVal; ++i) {
if (filter(i)) goodVals.push_back(i);
}
}),
ThreadRAII::DtorAction::join
);
auto nh = t.get().native_handle();
...
if (conditonsAreStatisfied()) {
t.get().join();
performComputation(goodVals);
return true;
}
return false;
}

这份代码中,我们选择在ThreadRAII的析构函数中异步执行join的动作,因为我们先前分析中,detach可能导致非常难缠的bug。我们之前也分析了join可能会导致性能异常(坦率说,也可能调试困难),但是在未定义行为(detach导致),程序终止(std::thread默认导致),或者性能异常之间选择一个后果,可能性能异常是最好的那个。

哎,Item 39表明了使用ThreadRAII来保证在std::thread的析构时执行join有时可能不仅导致程序性能异常,还可能导致程序挂起。“适当”的解决方案是此类程序应该和异步执行的lambda通信,告诉它不需要执行了,可以直接返回,但是C++11中不支持可中断线程。可以自行实现,但是这不是本书讨论的主题。(译者注:关于这一点,C++ Concurrency in Action 的section 9.2 中有详细讨论,也有中文版出版)

Item 17说明因为ThreadRAII声明了一个析构函数,因此不会有编译器生成移动操作,但是没有理由ThreadRAII对象不能移动。所以需要我们显式声明来告诉编译器自动生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ThreadRAII {
public:
enum class DtorAction{ join, detach }; // see Item 10 for enum class info
ThreadRAII(std::thread&& t, DtorAction a): action(a), t(std::move(t)) {} // in dtor, take action a on t
~ThreadRAII()
{
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}

ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;
std::thread& get() { return t; } // see below
private:
DtorAction action;
std::thread t;
};

需要记住的事

  • 在所有路径上保证thread最终是unjoinable
  • 析构时join会导致难以调试的性能异常问题
  • 析构时detach会导致难以调试的未定义行为
  • 声明类数据成员时,最后声明std::thread类型成员

Item 38:关注不同线程句柄的析构行为

Item 37中说明了joinable的std::thread对应于可执行的系统线程。non-defered任务的future(参见Item 36)与系统线程有相似的关系。因此,可以将std::thread对象和future对象都视作系统线程的句柄。

从这个角度来说,有趣的是std::threadfutures在析构时有相当不同的行为。在Item 37中说明,joinable的std::thread析构会终止你的程序,因为两个其他的替代选择—隐式join或者隐式detach都是更加糟糕的。但是,futures的析构表现有时就像执行了隐式join,有时又是隐式执行了detach,有时又没有执行这两个选择。永远不会造成程序终止。这个线程句柄多种表现值得研究一下。

我们可以观察到实际上future是通信信道的一端(被调用者通过该信道将结果发送给调用者)。被调用者(通常是异步执行)将计算结果写入通信信道中(通过std::promise对象),调用者使用future读取结果。你可以想象成下面的图示,虚线表示信息的流动方向:

image-20201022194524454

但是被调用者的结果存储在哪里?被调用者会在调用者get相关的future之前执行完成,所以结果不能存储在被调用者的std::promise。这个对象是局部的,当被调用者执行结束后,会被销毁。

结果同样不能存储在调用者的future,因为std::future可能会被用来创建std::shared_future(这会将被调用者的结果所有权从std::future转移给std::shared_future),而std::shared_futurestd::future被销毁之后被复制很多次。鉴于不是所有的结果都可以被拷贝(有些只能移动)和结果的声明周期与最后一个引用它的future一样长,哪个才是被调用者用来存储结果的?这两个问题。

因为与被调用者关联的对象和调用者关联的对象都不适合存储这个结果,必须存储在两者之外的位置。此位置称为共享状态(shared state)。共享状态通常是基于堆的对象,但是标准并未指定其类型、接口和实现。标准库的作者可以通过任何他们喜欢的方式来实现共享状态。

我们可以想象调用者,被调用者,共享状态之间关系如下图,虚线还是表示信息的流控方向:

image-20201022201806802

共享状态的存在非常重要,因为future的析构行为—这个Item的话题—-取决于关联future的共享状态。

  • Non-defered任务(启动参数为std::launch::async)的最后一个关联共享状态的future析构函数会在任务完成之前block住。本质上,这种future的析构对执行异步任务的线程做了隐式的join
  • future其他对象的析构简单的销毁。对于异步执行的任务,就像对底层的线程执行detach。对于defered任务的最后一种future,意味着这个defered任务永远不会执行了。

这些规则听起来好复杂。我们真正要处理的是一个简单的“正常”行为以及一个单独的例外。正常行为是future析构函数销毁future。那意味着不join也不detach,只销毁future的数据成员(当然,还做了另一件事,就是对于多引用的共享状态引用计数减一。)

正常行为的例外情况仅在同时满足下列所有情况下才会执行:

  • 关联future的共享状态是被调用了std::async创建的
  • 任务的启动策略是std::launch::async(参见Item 36),原因是运行时系统选择了该策略,或者在对std::async的调用中指定了该策略。
  • future是关联共享状态的最后一个引用。对于std::future,情况总是如此,对于std::shared_future,如果还有其他的std::shared_future引用相同的共享状态没有销毁,就不是。

只有当上面的三个条件都满足时,future的析构函数才会表现“异常”行为,就是在异步任务执行完之前block住。实际上,这相当于运行std::async创建的任务的线程隐式join

通常会听到将这种异常的析构函数行为称为”Futures from std::async block in their destructors”。作为近似描述没有问题,但是忽略了原因和细节,现在你已经知道了其中三味。

你可能想要了解更加深入。比如“为什么会有这样的规则”(译者注:这里的问题是意译,原文重复了问题本身),这很合理。据我所知,标准委员会希望避免这个问题与隐式detach(参见Item 37)相关联,但是不想采取强制程序终止这种激进的方案(因此搞了join,同样参见Item 37),所以妥协使用隐式join。这个决定并非没有争议,并且认真讨论过在C++14中放弃这种行为。最后,决定先不改变,所以C++11和C++14中这里的行为是一致的。

没有API来提供future是否指向std::async调用产生的共享状态,因此给定一个std::future对象,无法判断是不是会在析构函数block等待异步任务的完成。这就产生了有意思的事情:

1
2
3
4
5
6
7
8
9
// this container might block in its dtor, because one or more contained futures could refer to a shared state for a non-deferred task launched via std::async
std::vector<std::future<void>> futs; // see Item 39 for info on std::future<void>
class Widget // Widget objects might block in their dtors
{
public:
...
private:
std::shared_future<double> fut;
};

当然,如果你有办法知道给定的future不满足上面条件的任意一条,你就可以确定析构函数不会执行“异常”行为。比如,只有通过std::async创建的共享状态才有资格执行“异常”行为,但是有其他创建共享状态的方式。一种是使用std::packaged_task,一个std::packaged_task对象准备一个函数(或者其他可调用对象)来异步执行,然后将其结果放入共享状态中。然后通过std::packaged_taskget_future函数获取有关该共享状态的信息:

1
2
3
int calcValue(); // func to run 
std::packaged_task<int()> pt(calcValue); // wrap calcValue so it can run asynchrously
auto fut = pt.get_future(); // get future for pt

此时,我们知道future没有关联std::async创建的共享状态,所以析构函数肯定正常方式执行。

一旦被创建,std::packaged_task类型的pt可能会在线程上执行。(译者注:后面有些啰嗦的话这里不完整翻译。。大意就是可以再次使用std::async来执行,但是那就不用std::packaged_task了)

std::packaged_task不能拷贝,所以当pt被传递给std::thread时是右值传递(通过move,参见Item 23):

1
std::thread t(std::move(pt)); // run pt on t

这个例子是你对于future的析构函数的正常行为有一些了解,但是将这些语句放在一个作用域的语句块里更容易:

1
2
3
4
5
6
{ // begin block
std::packaged_task<int()> pt(calcValue);
auto fut = pt.get_future();
std::thread t(std::move(pt));
...
} // end block

此处最有趣的代码是在创建std::thread对象t之后的”…”。”…”有三种可能性:

  • 对t不做什么。这种情况,t会在语句块结束joinable,这会使得程序终止(参见Item 37)
  • 对t调用join。这种情况,不需要fut的析构函数block,因为join被显式调用了
  • 对t调用detach。这种情况,不需要在fut的析构函数执行detach,因为显式调用了

换句话说,当你有一个关联了std::packaged_task创建的共享状态的future时,不需要采取特殊的销毁策略,通常你会代码中做这些。

需要记住的事

  • future的正常析构行为就是销毁future本身的成员数据
  • 最后一个引用std::async创建共享状态的future析构函数会在任务结束前block

Item 39:对于一次性事件通信考虑使用无返回futures

有时,一个任务通知另一个异步执行的任务发生了特定的事件很有用,因为第二个任务要等到特定事件发生之后才能继续执行。事件也许是数据已经初始化,也许是计算阶段已经完成,或者检测到重要的传感器值。这种情况,什么是线程间通信的最佳方案?

一个明显的方案就是使用条件变量(condvar)。如果我们将检测条件的任务称为检测任务,对条件作出反应的任务称为反应任务,策略很简单:反应任务等待一个条件变量,检测任务在事件发生时改变条件变量。代码如下:

1
2
std::condition_variable cv; // condvar for event
std::mutex m; // mutex for use with cv

检测任务中的代码不能再简单了:

1
2
... // detect event
cv.notify_one(); // tell reacting task

如果有多个反应任务需要被通知,使用notify_all()代替notify_one(),但是这里,我们假定只有一个反应任务需要通知。

反应任务对的代码稍微复杂一点,因为在调用wait条件变量之前,必须通过std::unique_lock对象使用互斥锁mutex来同步(lock a mutex是等待条件变量的经典实现。std::unique_lock是C++11的易用API),代码如下:

1
2
3
4
5
6
7
... // propare to react
{ // open critical section
std::unique_lock<std::mutex> lk(m); // lock mutex
cv.wait(lk); // wati for notify; this isn't correct!
... // react to event(m is blocked)
} // close crit. section; unlock m via lk's dtor
... // continue reacting (m now unblocked)

这份代码的第一个问题就是有时被称为code smell:即使代码正常工作,但是有些事情也不是很正确。这种问题源自于使用互斥锁。互斥锁被用于保护共享数据的访问,但是可能检测任务和反应任务不会同时访问共享数据,比如说,检测任务会初始化一个全局数据结构,然后给反应任务用,如果检测任务在初始化之后不会再访问这个数据,而反应任务在初始化之前不会访问这个数据,就不存在数据竞争,也就没有必要使用互斥锁。但是条件变量必须使用互斥锁,这就留下了令人不适的设计。

即使你忽略了这个问题,还有两个问题需要注意:

  • 如果检测任务在反应任务wait之前通知条件变量,反应任务会挂起。为了能使条件变量唤醒另一个任务,任务必须等待在条件变量上。如果检测任务在反应任务wait之前就通知了条件变量,反应任务就会丢失这次通知,永远不被唤醒

  • wait语句虚假唤醒。线程API的存在一个事实(不只是C++)即使条件变量没有被通知,也可能被虚假唤醒,这种唤醒被称为spurious wakeups。正确的代码通过确认条件变量进行处理,并将其作为唤醒后的第一个操作。C++条件变量的API使得这种问题很容易解决,因为允许lambda(或者其他函数对象)来测试等待条件。因此,可以将反应任务这样写:

    1
    2
    cv.wait(lk, 
    [] { return whether the evet has occurred; });

    要利用这个能力需要反应任务能够确定其等待的条件为真。但是我们考虑的情况下,它正在等待的条件是检测线程负责识别的事件。反应线程可能无法确定等待的事件是否已发生。这就是为什么需要一个条件变量的原因

在很多情况下,使用条件变量进行任务通信非常合适,但是也有不那么合适的情况。

对于很多开发者来说,他们的下一个诀窍是共享的boolean标志。flag被初始化为false。当检测线程识别到发生的事件,将flag设置为true;

1
2
3
std::atomic<bool> flag(false); // shared flag; see Item 40 for std::atomic
... // detect event
flag = true; // tell reacting task

就其本身而言,反应线程轮询该flag。当发现flag被设置为true,它就知道等待的事件已经发生了:

1
2
3
... // prepare
while(!flag); // wait for event
... // react to event

这种方法不存在基于条件变量的缺点。不需要互斥锁,在反应变量设置flag为true之前轮询不会出现问题,并且不会出现虚假唤醒。好,好,好。

不好的一点是反应任务中轮询的开销。在等待flag为设置为true的时候,任务基本被锁住了,但是一直占用cpu。这样,反应线程占用了可能给另一个任务使用的硬件线程,每次启动或者完成的时间片都会产生上下文切换的开销,并且保持CPU核心运行(否则可能会停下来省电)。一个真正blocked的任务不会这样,这也是基于条件变量的优点,因为等待调用中的任务真的blocked。

将条件变量和flag的设计组合起来很常用。一个flag表示是否发生了感兴趣的事件,但是通过互斥锁同步了对该flag的访问。因为互斥锁阻止并发该flag,所以如Item 40所述,不需要将flag设置为std::atomic。一个简单的bool类型就可以,检测任务代码如下:

1
2
3
4
5
6
7
8
9
std::conditon_variable cv;
std::mutex m;
bool flag(false); // not std::atomic
... // detect event
{
std::lock_guard<std::mutex> g(m); // lock m via g's ctor
flag = true; // tell reacting task(part 1)
} // unlock m via g's dtor
cv.notify_one(); // tell reacting task (part 2)

反应任务代码如下:

1
2
3
4
5
6
7
... // prepare to react 
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{ return flag; }); // use lambda to avoid spurious wakeups
... // react to event (m is blocked)
}
... // continue reacting (m now unblocked)

这份代码解决了我们一直讨论的问题。无论是否反应线程在调用wait之前还是之后检测线程对条件变量发出通知都可以工作,即使出现了虚假唤醒也可以工作,而且不需要轮询。但是仍然有些古怪,因为检测任务通过奇怪的方式与反应线程通信。(译者注:下面的话挺绕的,可以参考原文)检测任务通知条件变量告诉反应线程等待的事件可能发生了,反应线程必须通过检查flag来确保事件发生了。检测线程设置flag来告诉反应线程事件确实发生了,但是检测线程首先需要通知条件变量唤醒反应线程来检查flag。这种方案是可以工作的,但是不太优雅。

一个替代方案是让反应任务通过在检测任务设置的future上wait来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item 38中说明了future代表了从被调用方(通常是异步的)到调用方的通信的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item 38中也说说明了通信新到发送端是std::promise,接收端是future不只能用在调用-被调用场景。这样的通信信道可以被在任何你需要从程序一个地方传递到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件是否已发生。

方案很简单。检测任务有一个std::promise对象(通信信道的写入),反应任务有对应的std::future(通信信道的读取)。当反应任务看到事件已经发生,设置std::promise对象(写入到通信信道)。同时,反应任务在std::future上等待。wait会锁住反应任务直到std::promise被设置。

现在,std::promise和futures(std::future and std::shared_future)都是需要参数类型的模板。参数表明被传递的信息类型。在这里,没有数据被传递,只需要让反应任务知道future已经被设置了。我们需要的类型是表明在std::promisefutures之间没有数据被传递。所以选择void。检测任务使用std::promise<void>,反应任务使用std::future<void> or std::shared_future<void>。当感兴趣的事件发生时,检测任务设置std::promise<void>,反应任务在futures上等待。即使反应任务不接收任何数据,通信信道可以让反应任务知道检测任务是否设置了void数据(通过对std::promise<void>调用set_value)。

所以,代码如下:

1
std::promise<void> p; // promise for communications channel

检测任务代码如下:

1
2
... // detect event 
p.set_value(); // tell reacting task

反应任务代码如下:

1
2
3
... // prepare to react 
p.get_future().wait(); // wait on future corresponding to p
... //react to event

像使用flag的方法一样,此设计不需要互斥锁,无论检测任务是否在反应任务等待之前设置std::promise都可以工作,并且不受虚假唤醒的影响(只有条件变量才容易受到此影响)。与基于条件变量的方法一样,反应任务真是被blocked,不会一直占用系统资源。是不是很完美?

当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item 38中说明,std::promise 和 future之间有共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。

也许更重要的是,std::promise只能设置一次。std::promise 与 future之间的通信是一次性的:不能重复使用。这是与基于条件变量或者flag的明显差异,条件变量可以被重复通知,flag也可以重复清除和设置。

一次通信可能没有你想象中那么大的限制。假定你想创建一个挂起的线程以避免想要使用一个线程执行程序的时候的线程创建的开销。或者你想在线程运行前对其进行设置,包括优先级和core affinity。C++并发API没有提供这种设置能力,但是提供了native_handle()获取原始线程的接口(通常获取的是POXIC或者Windows的线程),这些低层次的API使你可以对线程设置优先级和 core affinity。

假设你仅仅想要挂起一次线程(在创建后,运行前),使用void future就是一个方案。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::promise<void> p;
void react(); // func for reacting task
void detect() // func for detecting task
{
st::thread t([] // create thread
{
p.get_future().wait(); // suspend t until future is set
react();
});
... // here, t is suspended prior to call to react
p.set_value(); // unsuspend t (and thus call react)
... // do additional work
t.join(); // make t unjoinable(see Item 37)
}

因为根据Item 37说明,对于检测任务所有路径threadt都要是unjoinable的,所以使用建议的ThreadRAII。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void detect()
{
ThreadRAII tr(
std::thread([]
{
p.get_future().wait();
react();
}),
ThreadRAII::DtorAction::join // risky ! (see below)
);
... // thread inside tr is suspended here
p.set_value(); // unsuspend thread inside tr
... // do additional work
}

这样看起来安全多了。问题在于第一个”…”区域(注释了thread inside tr is suspended here),如果异常发生,p.set_value()永远不会调用,这意味着lambda中的wait永远不会返回,即lambda不会结束,问题就是,因为RAII对象tr再析构函数中join。换句话说,如果在第一个”…”中发生了异常,函数挂起,因为tr的析构不会被调用。

有很多方案解决这个问题,但是我把这个经验留给读者(译者注:http://scottmeyers.blogspot.com/2013/12/threadraii-thread-suspension-trouble.html 中这个问题的讨论)。这里,我只想展示如何扩展原始代码(不使用RAII类)使其挂起然后取消挂起,这不仅是个例,是个通用场景。简单概括,关键就是在反应任务的代码中使用std::shared_future代替std::future。一旦你知道std::futureshare成员函数将共享状态所有权转移到std::shared_future中,代码自然就写出来了。唯一需要注意的是,每个反应线程需要处理自己的std::shared_future副本,该副本引用共享状态,因此通过share获得的shared_future要被lambda按值捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::promise<void> p; // as before
void detect() // now for multiple reacting tasks
{
auto sf = g.get_future().share(); // sf's type is std::shared_future<void>
std::vector<std::thread> vt; // container for reacting threads
for (int i = 0; i < threadsToRun; ++i)
{
vt.emplace_back([sf]{
sf.wait();
react();
}); // wait on local copy of sf; see Item 43 for info on emplace_back
}
... // detect hangs if this "..." code throws !
p.set_value(); // unsuspend all threads
...
for (auto& t : vt) {
t.join(); // make all threads unjoinable: see Item2 for info on "auto&"
}
}

这样future就可以达到预期效果了,这就是你应该将其应用于一次通信的原因。

需要记住的事

  • 对于简单的事件通信,条件变量需要一个多余的互斥锁,对检测和反应任务的相对进度有约束,并且需要反应任务来验证事件是否已发生
  • 基于flag的设计避免的上一条的问题,但是不是真正的挂起反应任务
  • 组合条件变量和flag使用,上面的问题都解决了,但是逻辑不让人愉快
  • 使用std::promise和future的方案,要考虑堆内存的分配和销毁开销,同时有只能使用一次通信的限制

Item 40: 当需要并发时使用std::atomic,特定内存才使用volatile

可伶的volatile。如此令人迷惑。本不应该出现在本章节,因为它没有关于并发的能力。但是在其他编程语言中(比如,Java和C#),volatile是有并发含义的,即使在C++中,有些编译器在实现时也将并发的某种含义加入到了volatile关键字中。因此在此值得讨论下关于volatile关键字的含义以消除异议。

开发者有时会混淆volatile的特性是std::atomic(这确实本节的内容)的模板。这种模板的实例化(比如,std::atomic<int> , std::atomic<bool>, std::atomic<Widget*>等)给其他线程提供了原子操作的保证。一旦std::atomic对象被构建,在其上的操作使用特定的机器指令实现,这比锁的实现更高效。

分析如下使用std::atmoic的代码:

1
2
3
4
5
std::atomic<int> ai(0); // initialize ai to 0
ai = 10; // atomically set ai to 10
std::cout << ai; // atomically read ai's value
++ai; //atomically increment ai to 11
--ai; // atomically decrement ai to 10

在这些语句执行过程中,其他线程读取ai,只能读取到0,10,11三个值其中一个。在没有其他线程修改ai情况下,没有其他可能。

这个例子中有两点值得注意。首先,在std::cout << ai;中,std::atomic只保证了对ai的读取时原子的。没有保证语句的整个执行是原子的,这意味着在读取ai与将其通过≤≤操作符写入到标准输出之间,另一个线程可能会修改ai的值。这对于这个语句没有影响,因为<<操作符是按值传递参数的(所以输出就是读取到的ai的值),但是重要的是要理解原子性的范围只保证了读取是原子的。

第二点值得注意的是最后两条语句—-关于ai的加减。他们都是 read-modify-write(RMW)操作,各自原子执行。这是std::atomic类型的最优的特性之一:一旦std::atomic对象被构建,所有成员函数,包括RMW操作,对于其他线程来说保证原子执行。

相反,使用volatile在多线程中不保证任何事情:

1
2
3
4
5
volatile int vi(0); // initalize vi to 0
vi = 10; // set vi to 10
std::cout << vi; // read vi's value
++vi; // increment vi to 11
--vi; // decrement vi to 10

代码的执行过程中,如果其他线程读取vi,可能读到任何值,比如-12,68,4090727。这份代码就是未定义的,因为这里的语句修改vi,同时其他线程读取,这就是有没有std::atomic或者互斥锁保护的对于内存的同时读写,这就是数据竞争的定义。

为了举一个关于在多线程程序中std::atomicvolatile表现不同的恰当例子,考虑这样一个加单的计数器,同时初始化为0:

1
2
std::atomic<int> ac(0); 
volatile int vc(0);

然后我们在两个同时运行的线程中对两个计数器计数:

1
2
3
/*--------- Thread1 ---------*/      /*---------- Thread2 -----------*/
++ac; ++ac;
++vc; ++vc;

当两个线程执行结束时,ac的值肯定是2,以为每个自增操作都是原子的。另一方面,vc的值,不一定是2,因为自增不是原子的。每个自增操作包括了读取vc的值,增加读取的值,然后将结果写回到vc。这三个操作对于volatile修饰的整形变量不能保证原子执行,所有可能是下面的执行顺序:

  1. Thread1 读取vc的值,是0
  2. Thread2读取vc的值,还是0
  3. Thread1 将0加1,然后写回到vc
  4. Thread2将0加1,然后写回到vc

vc的最后结果是1,即使看起来自增了两次。

不仅只有这一种执行顺序的可能,vc的最终结果是不可预测的,因为vc会发生数据竞争,标准规定数据竞争的造成的未定义行为表示编译器生成的代码可能是任何逻辑,当然,编译器不会利用这种行为来作恶。但是只有在没有数据竞争的程序中编译器的优化才有效,这些优化在存在数据竞争的程序中会造成异常和不可预测的行为。

RMW操作不是仅有的std::atomic在并发中有效而volatile无效的例子。假定一个任务计算第二个任务需要的重要值。当第一个任务完成计算,必须传递给第二个任务。Item 39表明一种使用std::atomic<bool>的方法来使第一个任务通知第二个任务计算完成。代码如下:

1
2
3
std::atomic<bool> valVailable(false); 
auto imptValue = coputeImportantValue(); // compute value
valAvailable = true; // tell other task it's vailable

人类读这份代码,能看到在valAvailable赋值true之前对imptValue赋值是重要的顺序,但是所有编译器看到的是一对没有依赖关系的赋值操作。通常来说,编译器会被允许重排这对没有关联的操作。这意味着,给定如下顺序的赋值操作:

1
2
a = b;
x = y;

编译器可能重排为如下顺序:

1
2
x = y;
a = b;

即使编译器没有重排顺序,底层硬件也可能重排,因为有时这样代码执行更快。

然而,std::atomic会限制这种重排序,并且这样的限制之一是,在源代码中,对std::atomic变量写之前不会有任何操作。这意味对我们的代码

1
2
auto impatValue = computeImportantValue(); 
valVailable = true;

编译器不仅要保证赋值顺序,还要保证生成的硬件代码不会改变这个顺序。结果就是,将valAvaliable声明为std::atomic确保了必要的顺序—— 其他线程看到imptValue值保证valVailable设为true之后。

声明为volatile不能保证上述顺序:

1
2
3
volatile bool valAvaliable(false);
auto imptValue = computeImportantValue();
valAvailable = true;

这份代码编译器可能将赋值顺序对调,也可能在生成机器代码时,其他核心看到valVailable更改在imptValue之前。


“正常”内存应该有这个特性,在写入值之后,这个值会一直保证直到被覆盖。假设有这样一个正常的int

1
int x;

编译器看到下列的操作序列:

1
2
auto y = x; // read x
y = x; // read x again

编译器可通过忽略对y的一次赋值来优化代码,因为初始化和赋值是冗余的。

正常内存还有一个特征,就是如果你写入内存没就不会读,再次吸入,第一次写就可以被忽略,因为肯定会被覆盖。给出下面的代码:

1
2
x = 10; // write x
x = 20; // write x again

编译器可以忽略第一次写入。这意味着如果写在一起:

1
2
3
4
auto y = x; 
y = x;
x = 10;
x = 20;

编译器生成的代码是这样的:

1
2
auto y = x;
x = 20;

可能你会想睡会写这种重复读写的代码(技术上称为redundant loads 和 dead stores),答案是开发者不会直接写,至少我们不希望开发者这样写。但是在编译器执行了模板实例化,内联和一系列重排序优化之后,结果会出现多余的操作和无效存储,所以编译器需要摆脱这样的情况并不少见。

这种有话讲仅仅在内存表现正常时有效。“特殊”的内存不行。最常见的“特殊”内存是用来memory-mapped I/O的内存。这种内存实际上是与外围设备(比如外部传感器或者显示器,打印机,网络端口)通信,而不是读写(比如RAM)。这种情况下,再次考虑多余的代码:

1
2
auto y = x; // read x 
y = x; // read x again

如果x的值是一个温度传感器上报的,第二次对于x的读取就不是多余的,因为温度可能在第一次和第二次读取之间变化。

类似的,写也是一样:

1
2
x = 10;
x = 20;

如果x与无线电发射器的控制端口关联,则代码时控制无线电,10和20意味着不同的指令。优化会更改第一条无线电指令。

volatile是告诉编译器我们正在处理“特殊”内存。意味着告诉编译器“不要对这块内存执行任何优化”。所以如果x对应于特殊内存,应该声明为volatile

1
volatile int x;

带回我们原始代码:

1
2
3
4
5
auto y = x;
y = x; // can't be optimized away

x = 10; // can't be optimized away
x = 20;

如果x是内存映射(或者已经映射到跨进程共享的内存位置等),这正是我们想要的。

那么,在最后一段代码中,y是什么类型:int还是volatile int?

在处理特殊内存时,必须保留看似多余的读取或者无效存储的事实,顺便说明了为什么std::atomic不适合这种场景。std::atomic类型允许编译器消除此类冗余操作。代码的编写方式与使用volatile的方式完全不同,但是如果我们暂时忽略它,只关注编译器执行的操作,则可以说,

1
2
3
4
5
6
std::atomic<int> x;
auto y = x; // conceptually read x (see below)
y = x; // conceptually read x again(see below)

x = 10; // write x
y = 20; // write x again

原则上,编译器可能会优化为:

1
2
auto y = x; // conceptually read x 
x = 20; // write x

对于特殊内存,显然这是不可接受的。

现在,就当他没有优化了,但是对于x是std::atomic<int>类型来说,下面的两条语句都编译不通过。

1
2
auto y = x; // error
y = x; // error

这是因为std::atomic类型的拷贝操作时被删除的(参见Item 11)。想象一下如果y使用x来初始化会发生什么。因为x是std::atomic类型,y的类型被推导为std::atomic(参见Item 2)。我之前说了std::atomic最好的特性之一就是所有成员函数都是原子的,但是为了执行从x到y的拷贝初始化是原子的,编译器不得不生成读取x和写入x为原子的代码。硬件通常无法做到这一点,因此std::atomic不支持拷贝构造。处于同样的原因,拷贝赋值也被delete了,这也是为什么从x赋值给y也编译失败。(移动操作在std::atomic没有显式声明,因此对于Item 17中描述的规则来看,std::atomic既不提移动构造器也不提供移动赋值能力)。

可以将x的值传递给y,但是需要使用std::atomicload和store成员函数。load函数原子读取,store原子写入。要使用x初始化y,然后将x的值放入y,代码应该这样写:

1
2
std::atomic<int> y(x.load());
y.store(x.load());

这可以编译,但是可以清楚看到不是整条语句原子,而是读取写入分别原子化执行。

给出的代码,编译器可以通过存储x的值到寄存器代替读取两次来“优化”:

1
2
3
register = x.load(); // read x into register
std::atomic<int> y(register); // init y with register value
y.store(register); // store register value into y

结果如你所见,仅读取x一次,这是对于特殊内存必须避免的优化(这种优化不允许对volatile类型值执行)。

事情越辩越明:

  • std::atomic用在并发程序中
  • volatile用于特殊内存场景

因为std::atomicvolatile用于不同的目的,所以可以结合起来使用:

1
volatile std::atomic<int> vai; // operations on vai are atomic and can't be optimized away

这可以用在比如vai变量关联了memory-mapped I/O内存并且用于并发程序的场景。

最后一点,一些开发者尤其喜欢使用std::atomicloadstore函数即使不必要时,因为这在代码中显式表明了这个变量不“正常”。强调这一事实并非没有道理。因为访问std::atomic确实会更慢一些,我们也看到了std::atomic会阻止编译器对代码执行顺序重排。调用loadstore可以帮助识别潜在的可扩展性瓶颈。从正确性的角度来看,没有看到在一个变量上调用store来与其他线程进行通信(比如flag表示数据的可用性)可能意味着该变量在声明时没有使用std::atomic。这更多是习惯问题,但是,一定要知道atomicvolatile的巨大不同。

必须记住的事

  • std::atomic是用在不使用锁,来使变量被多个线程访问。是用来编写并发程序的
  • volatile是用在特殊内存的场景中,避免被编译器优化内存。

CHAPTER8 Tweaks

对于C++中的通用技术,总是存在适用场景。除了本章覆盖的两个例外,描述什么场景使用哪种通用技术通常来说很容易。这两个例外是传值(pass by value)和 emplacement。决定何时使用这两种技术受到多种因素的影响,本书提供的最佳建议是在使用它们的同时仔细考虑清楚,尽管它们都是高效的现代C++编程的重要角色。接下来的Items提供了是否使用它们来编写软件的所需信息。

Item 41.Consider pass by value for copyable parameters that are cheap to move and always copied 如果参数可拷贝并且移动操作开销很低,总是考虑直接按值传递

有些函数的参数是可复制的。比如说,addName成员函数可以拷贝自己的参数到一个私有容器。为了提高效率,应该拷贝左值,移动右值。

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
void addName(const std::string& newName) {
names.push_back(newName);
}
void addName(std::string&& newName) {
names.push_back(std::move(newName));
}
...
private:
std::vector<std::string> names;
};

这是可行的,但是需要编写两个同名异参函数,这有点让人难受:两个函数声明,两个函数实现,两个函数文档,两个函数的维护。唉。

此外,你可能会担心程序的目标代码的空间占用,当函数都内联(inlined)的时候,会避免同时两个函数同时存在导致的代码膨胀问题,但是一旦存在没有被内联(inlined),目标代码就是出现两个函数。

另一种方法是使addName函数成为具有通用引用的函数模板:(参考Item24)

1
2
3
4
5
6
7
8
class Widget {
public:
template<typename T>
void addName(T&& newName) {
names.push_back(std::forward<T>(newName));
}
...
};

这减少了源代码的维护工作,但是通用引用会导致其他复杂性。作为模板,addName的实现必须放置在头文件中。在编译器展开的时候,可能会不止为左值和右值实例化为多个函数,也可能为std::string和可转换为std::string的类型分别实例化为多个函数(参考Item25)。同时有些参数类型不能通过通用引用传递(参考Item30),而且如果传递了不合法的参数类型,编译器错误会令人生畏。(参考Item27)

是否存在一种编写addName的方法(左值拷贝,右值移动),而且源代码和目标代码中都只有一个函数,避免使用通用模板这种特性?答案是是的。你要做的就是放弃你学习C++编程的第一条规则,就是用户定义的对象避免传值。像是addName函数中的newName参数,按值传递可能是一种完全合理的策略。

在我们讨论为什么对于addName中的newName参数按值传递非常合理之前,让我们来考虑如下实现:

1
2
3
4
5
6
7
class Widget {
public:
void addName(std::string newName) {
names.push_back(std::move(newName));
}
...
}

该代码唯一可能令人困惑的部分就是std::move这里。std::move典型的应用场景是用在右值引用,但是在这里,我们了解到的信息:(1)newName是完全复制的传递进来的对象,换句话说,改变不会影响原值;(2)newName的最终用途就在这个函数里,不会再做他用,所以移动它不会影响其他代码。

事实就是我们只编写了一个addName函数,避免了源代码和目标代码的重复。我们没有使用通用引用的特性,不会导致头文件膨胀,odd failure cases(这里不知道咋翻译),或者令人困惑的错误问题(编译)。但是这种设计的效率如何呢?按值传值会不会开销很大?

在C++98中,可以肯定的是,无论调用者如何调用,参数newName都是拷贝传递。但是在C++11中,addName就是左值拷贝,右值移动,来看如下例子:

1
2
3
4
5
6
Widget w;
...
std::string name("Bart");
w.addName(name); // call addName with lvalue
...
w.addName(name + "Jenne"); // call addName with rvalue

第一处调用,addName的参数是左值,因此是拷贝构造参数,就像在C++98中一样。第二处调用,参数是一个临时值,是一个右值,因此newName的参数是移动构造的。

就像我们想要的那样,左值拷贝,右值移动,优雅吧?

优雅,但是要牢记一些警示,回顾一下我们考虑过的三个版本的addName:

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 Widget {  // Approach 1
public:
void addName(const std::string& newName) {
names.push_back(newName);
}
void addName(std::string&& newName) {
names.push_back(std::move(newName));
}
...
private:
std::vector<std::string> names;
};

class Widget { // Approach 2
public:
template<typename T>
void addName(T&& newName) {
names.push_back(std::forward<T>(newName));
}
...
};

class Widget { // Approach 3
public:
void addName(std::string newName) {
names.push_back(std::move(newName));
}
...
};

本书将前两个版本称为“按引用方法”,因为都是通过引用传递参数,仍然考虑这两种调用方式:

1
2
3
4
5
6
Widget w;
...
std::string name("Bart");
w.addName(name); // call addName with lvalue
...
w.addName(name + "Jenne"); // call addName with rvalue

现在分别考虑三种实现中,两种调用方式,拷贝和移动操作的开销。会忽略编译器对于移动和拷贝操作的优化。

  • Overloading(重载):无论传递左值还是传递右值,调用都会绑定到一种newName的引用实现方式上。拷贝和复制零开销。左值重载中,newName拷贝到Widget::names中,右值重载中,移动进去。开销总结:左值一次拷贝,右值一次移动。
  • Using a universal reference(通用模板方式):同重载一样,调用也绑定到addName的引用实现上,没有开销。由于使用了std::forward,左值参数会复制到Widget::names,右值参数移动进去。开销总结同重载方式。
    Item25 解释了如果调用者传递的参数不是std::string类型,将会转发到std::string的构造函数(几乎是零开销的拷贝或者移动操作)。因此通用引用的方式同样有同样效率,所以者不影响本次分析,简单分析std::string参数类型即可。
  • Passing by value(按值传递):无论传递左值还是右值,都必须构造newName参数。如果传递的是左值,需要拷贝的开销,如果传递的是右值,需要移动的开销。在函数的实现中,newName总是采用移动的方式到Widget::names。开销总结:左值参数,一次拷贝一次移动,右值参数两次移动。对比按引动传递的方法,对于左值或者右值,均多出一次移动操作。

再次回顾本Item的内容:

1
总是考虑直接按值传递,如果参数可拷贝并且移动操作开销很低

这样措辞是有原因的:

  1. 应该仅consider using pass by value。是的,因为只需要编写一个函数,同时只会在目标代码中生成一个函数。避免了通用引用方式的种种问题。但是毕竟开销会更高,而且下面还会讨论,还会存在一些目前我们并未讨论到的开销。

  2. 仅考虑对于copable parameters按值传递。不符合此条件的的参数必须只有移动构造函数。回忆一下“重载”方案的问题,就是必须编写两个函数来分别处理左值和右值,如果参数没有拷贝构造函数,那么只需要编写右值参数的函数,重载方案就搞定了。
    考虑一下std::unique_ptr<std::string>的数据成员和其set函数。因为std::unique_ptr是仅可移动的类型,所以考虑使用“重载”方式编写即可:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Widget {
    public:
    ...
    void setPtr(std::unique_ptr<std::string>&& ptr) {
    p = std::move(ptr);
    }
    private:
    std::unique_ptr<std::string> p;
    };

    调用者可能会这样写:

    1
    2
    3
    Widget w;
    ...
    w.setPtr(std::make_unique<std::string>("Modern C++"));

    这样,传递给setPtr的参数就是右值,整体开销就是一次移动。如果使用传值方式编写:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Widget {
    public:
    ...
    void setPtr(std::unique_ptr<std::string> ptr) {
    p = std::move(ptr);
    }
    private:
    std::unique_ptr<std::string> p;
    };

    同样的调用就会先使用移动构造函数移动到参数ptr,然后再移动到p,整体开销就是两次移动。

  3. 按值传递应该仅应用于哪些cheap to move的参数。当移动的开销较低,额外的一次移动才能被开发者接受,但是当移动的开销很大,执行不必要的移动类似不必要的复制时,这个规则就不适用了。

  4. 你应该只对always copied(肯定复制)的参数考虑按值传递。为了看清楚为什么这很重要,假定在复制参数到names容器前,addName需要检查参数的长度是否过长或者过短,如果是,就忽略增加name的操作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Widget { // Approach 3
    public:
    void addName(std::string newName) {
    if ((newName.length() >= minLen) && (newName.length() <= maxLen)) {
    names.push_back(std::move(newName));
    }
    }
    ...
    private:
    std::vector<std::string> names;
    };

    即使这个函数没有在names添加任何内容,也增加了构造和销毁newName的开销,而按引用传递会避免这笔开销。

即使你编写的函数是移动开销小的参数而且无条件复制,有时也可能不适合按值传递。这是因为函数复制参数存在两种方式:一种是通过构造函数(拷贝构造或者移动构造),还有一种是赋值(拷贝赋值或者移动赋值)。addName使用构造函数,它的参数传递给vector::push_back,在这个函数内部,newName是通过构造函数在std::vector创建一个新元素。对于使用构造函数拷贝参数的函数,上述分析已经可以给出最终结论:按值传递对于左值和右值均增加了一次移动操作的开销。

当参数通过赋值操作进行拷贝时,分析起来更加复杂。比如,我们有一个表征密码的类,因为密码可能会被修改,我们提供了setter函数changeTo。用按值传递的策略,我们实现一个密码类如下:

1
2
3
4
5
6
7
8
9
10
class Password {
public:
explicit Password(std::string pwd) : text(std::move(pwd)) {}
void changeTo(std::string newPwd) {
text = std::move(newPwd);
}
...
private:
std::string text;
};

将密码存储为纯文本格式恐怕将使你的软件安全团队抓狂,但是先忽略这点考虑这段代码:

1
2
std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);

p.text被给定的密码构造,用按值传递的方式增加了一次移动操作的开销相对于重载或者通用引用,但是这无关紧要,一切看起来如此美好。

但是,该程序的用户可能对初始密码不太满意,因为这段密码"Supercalifragilisticexpialidocious"在许多字典中可以被发现。他或者她因此修改密码:

1
2
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);

不用关心新密码是不是比就密码更好,那是用户关心的问题。我们对于changeTo函数的按值传递实现方案会导致开销大大增加。

传递给changeTo的参数是一个左值(newPassword),所以newPwd参数需要被构造,std::string的拷贝构造函数会被调用,这个函数会分配新的存储空间给新密码。newPwd会移动赋值到text,这会导致释放旧密码的内存。所以changeTo存在两次动态内存管理的操作:一次是为新密码创建内存,一次是销毁旧密码的内存。

但是在这个例子中,旧密码比新密码长度更长,所以本来不需要分配新内存,销毁就内存的操作。如果使用重载的方式,两次动态内存管理操作可以避免:

1
2
3
4
5
6
7
8
9
10
class Password {
public:
...
void changeTo(std::string& newPwd) {
text = newPwd;
}
...
private:
std::string text;
};

这种情况下,按值传递的开销(包括了内存分配和内存销毁)可能会比std::stringmove操作高出几个数量级。

有趣的是,如果旧密码短于新密码,在赋值过程中就不可避免要重新分配内存,这种情况,按值传递跟按引用传递的效率是一样的。因此,参数的赋值操作开销取决于具体的参数的值,这种分析适用于动态分配内存的参数类型。

这种潜在的开销增加仅在传递左值参数时才适用,因为执行内存分配和释放通常发生在复制操作中。

结论是,使用按值传递的函数通过赋值复制一个参数的额外开销取决于传递的类型中左值和右值的比例,即这个值是否需要动态分配内存,以及赋值操作符的具体实现中对于内存的使用。对于std::string来说,取决于实现是否使用了小字符串优化(SSO 参考Item 29),如果是,值是否匹配SSO缓冲区。

所以,正如我所说,当参数通过赋值进行拷贝时,分析按值传递的开销是复杂的。通常,最有效的经验就是“在证明没问题之前假设有问题”,就是除非已证明按值传递会为你需要的参数产生可接受开销的执行效率,否则使用重载或者通用引用的实现方式。

到此为止,对于需要运行尽可能快的软件来说,按值传递可能不是一个好策略,因为毕竟多了一次移动操作。此外,有时并不能知道是不是还多了其他开销。在Widget::addName例子中,按值传递仅多了一次移动操作,但是如果加入值的一些校验,可能按值传递就多了创建和销毁类型的开销相对于重载和通用引用的实现方式。

可以看到导致的方向,在调用链中,每次调用多了一次移动的开销,那么当调用链较长,总体就会产生无法忍受的开销,通过引用传递,调用链不会增加任何开销。

跟性能无关,总是需要考虑的是,按值传递不像按引用传递那样,会收到切片问题的影响。这是C++98的问题,在此不在详述,但是如果要设计一个函数,来处理这样的参数:基类或者其派生类,如果不想声明为按值传递,因为你就是要分割派生类型

1
2
3
4
5
6
7
class Widget{...};
class SpecialWidget: public Widget{...};
void processWidget(Widget w);
...
SecialWidget sw;
...
processWidget(sw);

如果不熟悉slicing problem,可以先通过搜索引擎了解一下。这样你就知道切片问题是另一个C++98中默认按值传递名声不好的原因。有充分的理由来说明为什么你学习C++编程的第一件事就是避免用户自定义类型进行按值传递。

C++11没有从根本上改变C++98按值传递的基本盘,通常,按值传递仍然会带来你希望避免的性能下降,而且按值传递会导致切片问题。C++11中新的功能是区分了左值和右值,实现了可移动类型的移动语义,尽管重载和通用引用都有其缺陷。对于特殊的场景,复制参数,总是会被拷贝,而且移动开销小的函数,可以按值传递,这种场景通常也不会有切片问题,这时,按值传递就提供了一种简单的实现方式,同时实现了接近引用传递的开销的效率。

需要记住的事

  • 对于可复制,移动开销低,而且无条件复制的参数,按值传递效率基本与按引用传递效率一致,而且易于实现,生成更少的目标代码
  • 通过构造函数拷贝参数可能比通过赋值拷贝开销大的多
  • 按值传递会引起切片问题,所说不适合基类类型的参数

Item 42: 考虑使用emplacement代替insertion

如果你拥有一个容器,例如std::string,那么当你通过插入函数(例如insert, push_front, push_back,或者对于std::forward_listinsert_after)添加新元素时,你传入的元素类型应该是std::string。毕竟,这就是容器里的内容。

逻辑上看来如此,但是并非总是如此。考虑如下代码:

1
2
std::vector<std::string> vs; // container of std::string
vs.push_back("xyzzy"); // add string literal

这里,容量里内容是std::string,但是你试图通过push_back加入字符串字面量,即引号内的字符序列。字符转字面量并不是std::string,这意味着你传递给push_back的参数并不是容器里的内容类型。

std::vectorpush_back被按左值和右值分别重载:

1
2
3
4
5
6
7
8
template<class T, class Allocator = allocator<T>>
class vector {
public:
...
void push_back(const &T x); // insert lvalue
void push_back(T&& x); // insert rvalue
...
};

vs.push_back("xyzzy")这个调用中,编译器看到参数类型(const char[6])和push_back采用的参数类型(std::string的引用)之间不匹配。它们通过从字符串字面量创建一个std::string类型的临时变量来消除不匹配,然后传递临时变量给push_back。换句话说,编译器处理的这个调用应该像这样:

1
vs.push_back(std::string("xyzzy")); // create temp std::string and pass it to push_back

代码编译并运行,皆大欢喜。除了对于性能执着的人意识到了这份代码不如预期的执行效率高。

为了创建std::string类型的临时变量,调用了std::string的构造器,但是这份代码并不仅调用了一次构造器,调用了两次,而且还调用了析构器。这发生在push_back运行时:

  1. 一个std::string的临时对象从字面量”xyzzy”被创建。这个对象没有名字,我们可以称为temptemp通过std::string构造器生成,因为是临时变量,所以temp是右值。
  2. temp被传递给push_back的右值x重载函数。在std::vector的内存中一个x的副本被创建。这次构造器是第二次调用,在std::vector内部重新创建一个对象。(将x副本复制到std::vector内部的构造器是移动构造器,因为x传入的是右值,有关将右值引用强制转换为右值的信息,请参见Item25)。
  3. push_back返回之后,temp被销毁,调用了一次std::string的析构器。

性能执着者(译者注:直译性能怪人)不禁注意到是否存在一种方法可以获取字符串字面量并将其直接传入到步骤2中的std::string内部构造,可以避免临时对象temp的创建与销毁。这样的效率最好,性能执着者也不会有什么意见了。

因为你是一个C++开发者,所以你会有高于平均水平的要求。如果你不是C++开发者,你可能也会同意这个观点(如果你根本不考虑性能,为什么你没在用python?)。所以让我来告诉你如何使得push_back达到最高的效率。就是不使用push_back,你需要的是emplace_back

emplace_back就是像我们想要的那样做的:直接把传递的参数(无论是不是std::string)直接传递到std::vector内部的构造器。没有临时变量会生成:

1
vs.emplace_back("xyzzy"); // construct std::string inside vs directly from "xyzzy"

emplace_back使用完美转发,因此只要你没有遇到完美转发的限制(参见Item30),就可以传递任何参数以及组合到emplace_back。比如,如果你在vs传递一个字符和一个数量给std::string构造器创建std::string,代码如下:

1
vs.emplace_back(50, 'x'); // insert std::string consisting of 50 'x' characters

emplace_back可以用于每个支持push_back的容器。类似的,每个支持push_front的标准容器支持emplace_front。每个支持insert(除了std::forward_liststd::array)的标准容器支持emplace。关联容器提供emplace_hint来补充带有“hint”迭代器的插入函数,std::forward_listemplace_after来匹配insert_after

使得emplacement函数功能优于insertion函数的原因是它们灵活的接口。insertion函数接受对象来插入,而emplacement函数接受构造器接受的参数插入。这种差异允许emplacement函数避免临时对象的创建和销毁。

因为可以传递容器内类型给emplacement函数(该参数使函数执行复制或者移动构造器),所以即使insertion函数不会构造临时对象,也可以使用emplacement函数。在这种情况下,insertion和emplacement函数做的是同一件事,比如:

1
std::string queenOfDisco("Donna Summer");

下面的调用都是可行的,效率也一样:

1
2
vs.push_back(queenOfDisco); // copy-construct queenOfDisco
vs.emplace_back(queenOfDisco); // ditto

因此,emplacement函数可以完成insertion函数的所有功能。并且有时效率更高,至上在理论上,不会更低效。那为什么不在所有场合使用它们?

因为,就像说的那样,理论上,在理论和实际上没有什么区别,但是实际,区别还是有的。在当前标准库的实现下,有些场景,就像预期的那样,emplacement执行性能优于insertion,但是,有些场景反而insertion更快。这种场景不容易描述,因为依赖于传递的参数类型、容器类型、emplacement或insertion的容器位置、容器类型构造器的异常安全性和对于禁止重复值的容器(即std::set,std::map,std::unorder_set,set::unorder_map)要添加的值是否已经在容器中。因此,大致的调用建议是:通过benchmakr测试来确定emplacment和insertion哪种更快。

当然这个结论不是很令人满意,所以还有一种启发式的方法来帮助你确定是否应该使用emplacement。如果下列条件都能满足,emplacement会优于insertion:

  • 值是通过构造器添加到容器,而不是直接赋值。例子就像本Item刚开始的那样(添加”xyzzy”到std::string的std::vector中)。新值必须通过std::string的构造器添加到std::vector。如果我们回看这个例子,新值放到已经存在对象的位置,那情况就完全不一样了。考虑下:

    1
    2
    3
    std::vector<std::string> vs; // as before
    ... // add elements to vs
    vs.emplace(vs.begin(), "xyzzy"); // add "xyzzy" to beginning of vs

    对于这份代码,没有实现会在已经存在对象的位置vs[0]构造添加的std::string。而是,通过移动赋值的方式添加到需要的位置。但是移动赋值需要一个源对象,所以这意味着一个临时对象要被创建,而emplacement优于insertion的原因就是没有临时对象的创建和销毁,所以当通过赋值操作添加元素时,emplacement的优势消失殆尽。

    而且,向容器添加元素是通过构造还是赋值通常取决于实现者。但是,启发式仍然是有帮助的。基于节点的容器实际上总是使用构造器添加新元素,大多数标准库容器都是基于节点的。例外的容器只有std::vector, std::deque, std::stringstd::array也不是基于节点的,但是它不支持emplacement和insertion)。在不是基于节点的容器中,你可以依靠emplace_back来使用构造向容器添加元素,对于std::dequeemplace_front也是一样的。

  • 传递的参数类型与容器的初始化类型不同。再次强调,emplacement优于insertion通常基于以下事实:当传递的参数不是容器保存的类型时,接口不需要创建和销毁临时对象。当将类型为T的对象添加到container时,没有理由期望emplacement比insertion运行的更快,因为不需要创建临时对象来满足insertion接口。

  • 容器不拒绝重复项作为新值。这意味着容器要么允许添加重复值,要么你添加的元素都是不重复的。这样要求的原因是为了判断一个元素是否已经存在于容器中,emplacement实现通常会创建一个具有新值的节点,以便可以将该节点的值与现有容器中节点的值进行比较。如果要添加的值不在容器中,则链接该节点。然后,如果值已经存在,emplacement创建的节点就会被销毁,意味着构造和析构时浪费的开销。这样的创建就不会在insertion函数中出现。

本Item开始的例子中下面的调用满足上面的条件。所以调用比push_back运行更快。

1
2
vs.emplace_back("xyzzy"); // construct new value at end of container; don't pass the type in container; don't use container rejecting duplicates
vs.emplace_back(50, 'x'); // ditto

在决定是否使用emplacement函数时,需要注意另外两个问题。首先是资源管理。假定你有一个std::shared_ptr<Widget>s的容器,

1
std::list<std::shared_ptr<Widget>> ptrs;

然后你想添加一个通过自定义deleted释放的std::shared_ptr(参见Item 19)。Item 21说明你应该使用std::make_shared来创建std::shared_ptr,但是它也承认有时你无法做到这一点。比如当你要指定一个自定义deleter时。这时,你必须直接创建一个原始指针,然后通过std::shared_ptr来管理。

如果自定义deleter是这个函数,

1
void killWidget(Widget* pWidget);

使用insertion函数的代码如下:

1
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));

也可以像这样

1
ptrs.push_back({new Widget, killWidget});

不管哪种写法,在调用push_back中会生成一个临时std::shared_ptr对象。push_back的参数是std::shared_ptr的引用,因此必须有一个std::shared_ptr

std::shared_ptr的临时对象创建应该可以避免,但是在这个场景下,临时对象值得被创建。考虑如下可能的时间序列:

  1. 在上述的调用中,一个std::shared_ptr<Widget>的临时对象被创建来持有new Widget对象。称这个对象为temp
  2. push_back接受temp的引用。在节点的分配一个副本来复制temp的过程中,OOM异常被抛出
  3. 随着异常从push_back的传播,temp被销毁。作为唯一管理Widget的弱指针std::shared_ptr对象,会自动销毁Widget,在这里就是调用killWidget

这样的话,即使发生了异常,没有资源泄露:在调用push_back中通过new Widget创建的Widgetstd::shared_ptr管理下自动销毁。生命周期良好。

考虑使用emplace_back代替push_back

1
ptrs.emplace_back(new Widget, killWidget);
  1. 通过new Widget的原始指针完美转发给emplace_back的内部构造器。如果分配失败,还是抛出OOM异常
  2. 当异常从emplace_back传播,原始指针是仅有的访问途径,但是因为异常丢失了,这就发生了资源泄露

在这个场景中,生命周期不良好,这个失误不能赖std::shared_ptrstd::unique_ptr使用自定义deleter也会有同样的问题。根本上讲,像std::shared_ptr和std::unique_ptr这样的资源管理类的有效性取决于资源被立即传递给资源管理对象的构造函数。实际上,这就是std::make_shared和std::make_unique这样的函数如此重要的原因。

在对存储资源管理类的容器调用insertion函数时(比如std::list<std::shared_ptr<Widget>>),函数的参数类型通常确保在资源的获取和管理资源对象的创建之间没有其他操作。在emplacement函数中,完美转发推迟了资源管理对象的创建,直到可以在容器的内存中构造它们为止,这给异常导致资源泄露提供了可能。所有的标准库容器都容易受到这个问题的影响。在使用资源管理对象的容器时,比如注意确保使用emplacement函数不会为提高效率带来降低异常安全性的后果。

坦白说,无论如何,你不应该将new Widget传递给emplace_back或者push_back或者大多数这种函数,因为,就像Item 21中解释的那样,这可能导致我们刚刚讨论的异常安全性问题。使用独立语句将从new Widget获取指针然后传递给资源管理类,然后传递这个对象的右值引用给你想传递new Widget的函数(Item 21 有这个观点的详细讨论)。代码应该如下:

1
2
std::shared_ptr<Widget> spw(new Widget, killWidget); // create Widget and have spw manage it
ptrs.push_back(std::move(spw)); // add spw as rvalue

emplace_back的版本如下:

1
2
std::shared_ptr<Widget> spw(new Widget, killWidget); // create Widget and have spw manage it
ptrs.emplace_back(std::move(spw));

无论哪种方式,都会产生spw的创建和销毁成本。给出选择emplacement函数优于insertion函数的动机是避免临时对象的开销,但是对于swp的概念来讲,当根据正确的方式确保获取资源和连接到资源管理对象上之间无其他操作,添加资源管理类型对象到容器中,emplacement函数不太可能胜过insertion函数。

emplacement函数的第二个值得注意的方面是它们与显式构造函数的交互。对于C++11正则表达式的支持,假设你创建了一个正则表达式的容器:

1
std::vector<std::regex> regexes;

由于你同事的打扰,你写出了如下看似毫无意义的代码:

1
regexes.emplace_back(nullptr); // add nullptr to container of regexes?

你没有注意到错误,编译器也没有提示你,所以你浪费了大量时间来调试。突然,你发现你插入了空指针到正则表达式的容器中。但是这怎么可能?指针不是正则表达式,如果你试图下面这样写

1
std::regex r = nullptr; // error! won't compile

编译器就会报错。有趣的是,如果你调用push_back而不是emplace_back,编译器就会报错

1
regexes.push_back(nullptr); // error! won't compile

当前你遇到的奇怪行为由于可能用字符串构造std::regex的对象,这就意味着下面代码合法:

1
std::regex upperCaseWorld("[A-Z]+");

通过字符串创建std::regex要求相对较长的运行时开销,所以为了最小程度减少无意中产生此类开销的可能性,采用const char*指针的std::regex构造函数是显式的。这就是为什么下面代码无法编译的原因:

1
2
std::regex r = nullptr; // error! won't compile
regexes.push_back(nullptr); // error

在上面的代码中,我们要求从指针到std::regex的隐式转换,但是显式构造的要求拒绝了此类转换。

但是在emplace_back的调用中,我们没有声明传递一个std::regex对象。代替的是,我们传递了一个std::regex构造器参数。那不是隐式转换,而是显式的:

1
std::regex r(nullptr); // compiles

如果简洁的注释“compiles”表明缺乏直观理解,好的,因为这个代码可以编译,但是行为不确定。使用const char*指针的std::regex构造器要求字符串是一个有效的正则表达式,nullptr不是有效的。如果你写出并编译了这样的代码,最好的希望就是运行时crash掉。如果你不幸运,就会花费大量的时间调试。

先把push_back, emplace_back放在一边,注意到相似的初始化语句导致了多么不一样的结果:

1
2
std::regex r1 = nullptr; // error ! won't compile
std::regex r2(nullptr); // compiles

在标准的官方术语中,用于初始化r1的语法是所谓的复制初始化。相反,用于初始化r2的语法是(也被称为braces)被称为直接初始化。复制初始化不是显式调用构造器的,直接初始化是。这就是r2可以编译的原因。

然后回到push_back和 emplace_back,更一般来说,insertion函数对比emplacment函数。emplacement函数使用直接初始化,这意味着使用显式构造器。insertion函数使用复制初始化。因此:

1
2
regexes.emplace_back(nullptr); // compiles. Direct init permits use of explicit std::regex ctor taking a pointer
regexes.push_back(nullptr); // error! copy init forbids use of that ctor

要汲取的是,当你使用emplacement函数时,请特别小心确保传递了正确的参数,因为即使是显式构造函数,编译器可以尝试解释你的代码称为有效的(译者注:这里意思是即使你写的代码逻辑上不对,显式构造器时编译器可能能解释通过即编译成功)

需要记住的事

  • 原则上,emplacement函数有时会比insertion函数高效,并且不会更差
  • 实际上,当执行如下操作时,emplacement函数更快
    1. 值被构造到容器中,而不是直接赋值
    2. 传入的类型与容器类型不一致
    3. 容器不拒绝已经存在的重复值
  • emplacement函数可能执行insertion函数拒绝的显示构造

校验数字的表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
数字:^[0-9]*$
n位的数字:^\d{n}$
至少n位的数字:^\d{n,}$
m-n位的数字:^\d{m,n}$
零和非零开头的数字:^(0|[1-9][0-9]*)$
非零开头的最多带两位小数的数字:^([1-9][0-9]*)+(.[0-9]{1,2})?$
带1-2位小数的正数或负数:^(\-)?\d+(\.\d{1,2})?$
正数、负数、和小数:^(\-|\+)?\d+(\.\d+)?$
有两位小数的正实数:^[0-9]+(.[0-9]{2})?$
有1~3位小数的正实数:^[0-9]+(.[0-9]{1,3})?$
非零的正整数:^[1-9]\d*$ 或 ^([1-9][0-9]*){1,3}$ 或 ^\+?[1-9][0-9]*$
非零的负整数:^\-[1-9][]0-9"*$ 或 ^-[1-9]\d*$
非负整数:^\d+$ 或 ^[1-9]\d*|0$
非正整数:^-[1-9]\d*|0$ 或 ^((-\d+)|(0+))$
非负浮点数:^\d+(\.\d+)?$ 或 ^[1-9]\d*\.\d*|0\.\d*[1-9]\d*|0?\.0+|0$
非正浮点数:^((-\d+(\.\d+)?)|(0+(\.0+)?))$ 或 ^(-([1-9]\d*\.\d*|0\.\d*[1-9]\d*))|0?\.0+|0$
正浮点数:^[1-9]\d*\.\d*|0\.\d*[1-9]\d*$ 或 ^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$
负浮点数:^-([1-9]\d*\.\d*|0\.\d*[1-9]\d*)$ 或 ^(-(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*)))$
浮点数:^(-?\d+)(\.\d+)?$ 或 ^-?([1-9]\d*\.\d*|0\.\d*[1-9]\d*|0?\.0+|0)$

校验字符的表达式

1
2
3
4
5
6
7
8
9
10
11
12
汉字:^[\u4e00-\u9fa5]{0,}$
英文和数字:^[A-Za-z0-9]+$ 或 ^[A-Za-z0-9]{4,40}$
长度为3-20的所有字符:^.{3,20}$
由26个英文字母组成的字符串:^[A-Za-z]+$
由26个大写英文字母组成的字符串:^[A-Z]+$
由26个小写英文字母组成的字符串:^[a-z]+$
由数字和26个英文字母组成的字符串:^[A-Za-z0-9]+$
由数字、26个英文字母或者下划线组成的字符串:^\w+$ 或 ^\w{3,20}$
中文、英文、数字包括下划线:^[\u4E00-\u9FA5A-Za-z0-9_]+$
中文、英文、数字但不包括下划线等符号:^[\u4E00-\u9FA5A-Za-z0-9]+$ 或 ^[\u4E00-\u9FA5A-Za-z0-9]{2,20}$
可以输入含有^%&',;=?$\"等字符:[^%&',;=?$\x22]+
禁止输入含有~的字符:[^~\x22]+

特殊需求表达式

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
Email地址:^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$
域名:[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(/.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+/.?
InternetURL:[a-zA-z]+://[^\s]* 或 ^http://([\w-]+\.)+[\w-]+(/[\w-./?%&=]*)?$
手机号码:^(13[0-9]|14[0-9]|15[0-9]|16[0-9]|17[0-9]|18[0-9]|19[0-9])\d{8}$ (由于工信部放号段不定时,所以建议使用泛解析 ^([1][3,4,5,6,7,8,9])\d{9}$)
电话号码("XXX-XXXXXXX"、"XXXX-XXXXXXXX"、"XXX-XXXXXXX"、"XXX-XXXXXXXX"、"XXXXXXX"和"XXXXXXXX):^(\(\d{3,4}-)|\d{3.4}-)?\d{7,8}$
国内电话号码(0511-4405222、021-87888822):\d{3}-\d{8}|\d{4}-\d{7}
18位身份证号码(数字、字母x结尾):^((\d{18})|([0-9x]{18})|([0-9X]{18}))$
帐号是否合法(字母开头,允许5-16字节,允许字母数字下划线):^[a-zA-Z][a-zA-Z0-9_]{4,15}$
密码(以字母开头,长度在6~18之间,只能包含字母、数字和下划线):^[a-zA-Z]\w{5,17}$
强密码(必须包含大小写字母和数字的组合,不能使用特殊字符,长度在8-10之间):^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,10}$
日期格式:^\d{4}-\d{1,2}-\d{1,2}
一年的12个月(01~09和1~12):^(0?[1-9]|1[0-2])$
一个月的31天(01~09和1~31):^((0?[1-9])|((1|2)[0-9])|30|31)$
钱的输入格式:
1.有四种钱的表示形式我们可以接受:"10000.00" 和 "10,000.00", 和没有 "分" 的 "10000" 和 "10,000":^[1-9][0-9]*$
2.这表示任意一个不以0开头的数字,但是,这也意味着一个字符"0"不通过,所以我们采用下面的形式:^(0|[1-9][0-9]*)$
3.一个0或者一个不以0开头的数字.我们还可以允许开头有一个负号:^(0|-?[1-9][0-9]*)$
4.这表示一个0或者一个可能为负的开头不为0的数字.让用户以0开头好了.把负号的也去掉,因为钱总不能是负的吧.下面我们要加的是说明可能的小数部分:^[0-9]+(.[0-9]+)?$
5.必须说明的是,小数点后面至少应该有1位数,所以"10."是不通过的,但是 "10" 和 "10.2" 是通过的:^[0-9]+(.[0-9]{2})?$
6.这样我们规定小数点后面必须有两位,如果你认为太苛刻了,可以这样:^[0-9]+(.[0-9]{1,2})?$
7.这样就允许用户只写一位小数.下面我们该考虑数字中的逗号了,我们可以这样:^[0-9]{1,3}(,[0-9]{3})*(.[0-9]{1,2})?$
8.1到3个数字,后面跟着任意个 逗号+3个数字,逗号成为可选,而不是必须:^([0-9]+|[0-9]{1,3}(,[0-9]{3})*)(.[0-9]{1,2})?$
备注:这就是最终结果了,别忘了"+"可以用"*"替代如果你觉得空字符串也可以接受的话(奇怪,为什么?)最后,别忘了在用函数时去掉去掉那个反斜杠,一般的错误都在这里
xml文件:^([a-zA-Z]+-?)+[a-zA-Z0-9]+\\.[x|X][m|M][l|L]$
中文字符的正则表达式:[\u4e00-\u9fa5]
双字节字符:[^\x00-\xff] (包括汉字在内,可以用来计算字符串的长度(一个双字节字符长度计2,ASCII字符计1))
空白行的正则表达式:\n\s*\r (可以用来删除空白行)
HTML标记的正则表达式:<(\S*?)[^>]*>.*?</\1>|<.*? /> (网上流传的版本太糟糕,上面这个也仅仅能部分,对于复杂的嵌套标记依旧无能为力)
首尾空白字符的正则表达式:^\s*|\s*$或(^\s*)|(\s*$) (可以用来删除行首行尾的空白字符(包括空格、制表符、换页符等等),非常有用的表达式)
腾讯QQ号:[1-9][0-9]{4,} (腾讯QQ号从10000开始)
中国邮政编码:[1-9]\d{5}(?!\d) (中国邮政编码为6位数字)
IP地址:\d+\.\d+\.\d+\.\d+ (提取IP地址时有用)
IP地址:((?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d))

实现

正则表达式是一个非常强力的工具,本文就来具体看一看正则表达式的底层原理是什么。力扣第 10 题「正则表达式匹配」就要求我们实现一个简单的正则匹配算法,包括「.」通配符和「*」通配符。

这两个通配符是最常用的,其中点号「.」可以匹配任意一个字符,星号「*」可以让之前的那个字符重复任意次数(包括 0 次)。

比如说模式串.a*b就可以匹配文本zaaab,也可以匹配cb;模式串a..b可以匹配文本amnb;而模式串.*就比较牛逼了,它可以匹配任何文本。

题目会给我们输入两个字符串s和p,s代表文本,p代表模式串,请你判断模式串p是否可以匹配文本s。我们可以假设模式串只包含小写字母和上述两种通配符且一定合法,不会出现a或者b*这种不合法的模式串,

函数签名如下:

1
bool isMatch(string s, string p);

对于我们将要实现的这个正则表达式,难点在那里呢?

点号通配符其实很好实现,s中的任何字符,只要遇到.通配符,无脑匹配就完事了。主要是这个星号通配符不好实现,一旦遇到*通配符,前面的那个字符可以选择重复一次,可以重复多次,也可以一次都不出现,这该怎么办?

对于这个问题,答案很简单,对于所有可能出现的情况,全部穷举一遍,只要有一种情况可以完成匹配,就认为p可以匹配s。那么一旦涉及两个字符串的穷举,我们就应该条件反射地想到动态规划的技巧了。

思路分析

我们先脑补一下,s和p相互匹配的过程大致是,两个指针i, j分别在s和p上移动,如果最后两个指针都能移动到字符串的末尾,那么久匹配成功,反之则匹配失败。

正则表达算法问题只需要把住一个基本点:看两个字符是否匹配,一切逻辑围绕匹配/不匹配两种情况展开即可。

如果不考虑*通配符,面对两个待匹配字符s[i]和p[j],我们唯一能做的就是看他俩是否匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool isMatch(string s, string p) {
int i = 0, j = 0;
while (i < s.size() && j < p.size()) {
// 「.」通配符就是万金油
if (s[i] == p[j] || p[j] == '.') {
// 匹配,接着匹配 s[i+1..] 和 p[j+1..]
i++; j++;
} else {
// 不匹配
return false;
}
}
return i == j;
}

那么考虑一下,如果加入*通配符,局面就会稍微复杂一些,不过只要分情况来分析,也不难理解。

p[j + 1]为*通配符时,我们分情况讨论下:

  • 如果匹配,即s[i] == p[j],那么有两种情况:
    • p[j]有可能会匹配多个字符,比如s = aaa, p = a*,那么p[0]会通过*匹配 3 个字符a
    • p[i]也有可能匹配 0 个字符,比如s = aa, p = a*aa,由于后面的字符可以匹配s,所以p[0]只能匹配 0 次。
  • 如果不匹配,即s[i] != p[j],只有一种情况:
    • p[j]只能匹配 0 次,然后看下一个字符是否能和s[i]匹配。比如说s = aa, p = b*aa,此时p[0]只能匹配 0 次。

综上,可以把之前的代码针对通配符进行一下改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (s[i] == p[j] || p[j] == '.') {
// 匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 有 * 通配符,可以匹配 0 次或多次
} else {
// 无 * 通配符,老老实实匹配 1 次
i++; j++;
}
} else {
// 不匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 有 * 通配符,只能匹配 0 次
} else {
// 无 * 通配符,匹配无法进行下去了
return false;
}
}

整体的思路已经很清晰了,但现在的问题是,遇到`
`通配符时,到底应该匹配 0 次还是匹配多次?多次是几次?

你看,这就是一个做「选择」的问题,要把所有可能的选择都穷举一遍才能得出结果。动态规划算法的核心就是「状态」和「选择」,「状态」无非就是i和j两个指针的位置,「选择」就是p[j]选择匹配几个字符。

动态规划解法

根据「状态」,我们可以定义一个dp函数:

1
bool dp(string& s, int i, string& p, int j);

dp函数的定义如下:若dp(s,i,p,j) = true,则表示s[i..]可以匹配p[j..];若dp(s,i,p,j) = false,则表示s[i..]无法匹配p[j..]。根据这个定义,我们想要的答案就是i = 0,j = 0时dp函数的结果,所以可以这样使用这个dp函数:
1
2
3
bool isMatch(string s, string p) {
// 指针 i,j 从索引 0 开始移动
return dp(s, 0, p, 0);

可以根据之前的代码写出dp函数的主要逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool dp(string& s, int i, string& p, int j) {
if (s[i] == p[j] || p[j] == '.') {
// 匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 1.1 通配符匹配 0 次或多次
return dp(s, i, p, j + 2)
|| dp(s, i + 1, p, j);
} else {
// 1.2 常规匹配 1 次
return dp(s, i + 1, p, j + 1);
}
} else {
// 不匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 2.1 通配符匹配 0 次
return dp(s, i, p, j + 2);
} else {
// 2.2 无法继续匹配
return false;
}
}
}

根据dp函数的定义,这几种情况都很好解释:

  • 通配符匹配 0 次或多次
    • 将j加 2,i不变,含义就是直接跳过p[j]和之后的通配符,即通配符匹配 0 次:
    • 将i加 1,j不变,含义就是p[j]匹配了s[i],但p[j]还可以继续匹配,即通配符匹配多次的情况:
    • 两种情况只要有一种可以完成匹配即可,所以对上面两种情况求或运算。
  • 常规匹配 1 次
    • 由于这个条件分支是无*的常规匹配,那么如果s[i] == p[j],就是i和j分别加一:
  • 通配符匹配 0 次
    • 类似情况 1.1,将j加 2,i不变:
  • 如果没有*通配符,也无法匹配,那只能说明匹配失败了:

看图理解应该很容易了,现在可以思考一下dp函数的 base case:

一个 base case 是j == p.size()时,按照dp函数的定义,这意味着模式串p已经被匹配完了,那么应该看看文本串s匹配到哪里了,如果s也恰好被匹配完,则说明匹配成功:

1
2
3
if (j == p.size()) {
return i == s.size();
}

另一个 base case 是i == s.size()时,按照dp函数的定义,这种情况意味着文本串s已经全部被匹配了,那么是不是只要简单地检查一下p是否也匹配完就行了呢?
1
2
3
4
if (i == s.size()) {
// 这样行吗?
return j == p.size();
}

这是不正确的,此时并不能根据j是否等于p.size()来判断是否完成匹配,只要p[j..]能够匹配空串,就可以算完成匹配。比如说s = “a”, p = “abc“,当i走到s末尾的时候,j并没有走到p的末尾,但是p依然可以匹配s。所以我们可以写出如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int m = s.size(), n = p.size();

if (i == s.size()) {
// 如果能匹配空串,一定是字符和 * 成对儿出现
if ((n - j) % 2 == 1) {
return false;
}
// 检查是否为 x*y*z* 这种形式
for (; j + 1 < p.size(); j += 2) {
if (p[j + 1] != '*') {
return false;
}
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* 计算 p[j..] 是否匹配 s[i..] */
bool dp(string& s, int i, string& p, int j) {
int m = s.size(), n = p.size();
// base case
if (j == n) {
return i == m;
}
if (i == m) {
if ((n - j) % 2 == 1) {
return false;
}
for (; j + 1 < n; j += 2) {
if (p[j + 1] != '*') {
return false;
}
}
return true;
}

// 记录状态 (i, j),消除重叠子问题
string key = to_string(i) + "," + to_string(j);
if (memo.count(key)) return memo[key];

bool res = false;

if (s[i] == p[j] || p[j] == '.') {
if (j < n - 1 && p[j + 1] == '*') {
res = dp(s, i, p, j + 2)
|| dp(s, i + 1, p, j);
} else {
res = dp(s, i + 1, p, j + 1);
}
} else {
if (j < n - 1 && p[j + 1] == '*') {
res = dp(s, i, p, j + 2);
} else {
res = false;
}
}
// 将当前结果记入备忘录
memo[key] = res;

return res;
}

代码中用了一个哈希表memo消除重叠子问题,因为正则表达算法的递归框架如下:
1
2
3
4
5
bool dp(string& s, int i, string& p, int j) {
dp(s, i, p, j + 2); // 1
dp(s, i + 1, p, j); // 2
dp(s, i + 1, p, j + 1); // 3
}

那么,如果让你从dp(s, i, p, j)得到dp(s, i+2, p, j+2),至少有两条路径:1 -> 2 -> 2和3 -> 3,那么就说明(i+2, j+2)这个状态存在重复,这就说明存在重叠子问题。

动态规划的时间复杂度为「状态的总数」「每次递归花费的时间」,本题中状态的总数当然就是i和j的组合,也就是M N(M为s的长度,N为p的长度);递归函数dp中没有循环(base case 中的不考虑,因为 base case 的触发次数有限),所以一次递归花费的时间为常数。二者相乘,总的时间复杂度为O(MN)。空间复杂度很简单,就是备忘录memo的大小,即O(MN)。

TCP

服务器端首先执行 LISTEN 原语进入被动打开状态( LISTEN ),等待客户端连接;

  • 当客户端的一个应用程序发出 CONNECT 命令后,本地的 TCP 实体为其创建一个连接记录并标记为 SYN SENT 状态,然后给服务器发送一个 SYN 报文段;
  • 服务器收到一个 SYN 报文段,其 TCP 实体给客户端发送确认 ACK 报文段同时发送一个 SYN 信号,进入 SYN RCVD 状态;
  • 客户端收到 SYN + ACK 报文段,其 TCP 实体给服务器端发送出三次握手的最后一个 ACK 报文段,并转换为 ESTABLISHED 状态;
  • 服务器端收到确认的 ACK 报文段,完成了三次握手,于是也进入 ESTABLISHED 状态。

在此状态下,双方可以自由传输数据。当一个应用程序完成数据传输任务后,它需要关闭 TCP 连接。假设仍由客户端发起主动关闭连接。

  • 客户端执行 CLOSE 原语,本地的 TCP 实体发送一个 FIN 报文段并等待响应的确认(进入状态 FIN WAIT 1 );
  • 服务器收到一个 FIN 报文段,它确认客户端的请求发回一个 ACK 报文段,进入 CLOSE WAIT 状态;
  • 客户端收到确认 ACK 报文段,就转移到 FIN WAIT 2 状态,此时连接在一个方向上就断开了;
  • 服务器端应用得到通告后,也执行 CLOSE 原语关闭另一个方向的连接,其本地 TCP 实体向客户端发送一个 FIN 报文段,并进入 LAST ACK 状态,等待最后一个 ACK 确认报文段;
  • 客户端收到 FIN 报文段并确认,进入 TIMED WAIT 状态,此时双方连接均已经断开,但 TCP 要等待一个 2 倍报文段最大生存时间 MSL ( Maximum Segment Lifetime ),确保该连接的所有分组全部消失,以防止出现确认丢失的情况。当定时器超时后, TCP 删除该连接记录,返回到初始状态( CLOSED )。
  • 服务器收到最后一个确认 ACK 报文段,其 TCP 实体便释放该连接,并删除连接记录,返回到初始状态( CLOSED )。

SYN攻击

属于DDoS攻击的一种,它利用 TCP协议 缺陷,通过发送大量的半连接请求,耗费 CPU 和内存资源。SYN攻击除了能影响 主机 外,还可以危害 路由器 、 防火墙 等网络系统,事实上SYN攻击并不管目标是什么系统,只要这些系统打开TCP服务就可以实施。

服务器 接收到连接请求(syn= j),将此信息加入未连接队列,并发送请求包给客户(syn=k, ack =j+1),此时进入 SYN_RECV 状态。当服务器未收到客户端的确认包时,重发请求包,一直到超时,才将此条目从未连接队列删除。配合IP欺骗,SYN攻击能达到很好的效果,通常,客户端在短时间内伪造大量不存在的IP地址,向服务器不断地发送syn包,服务器回复确认包,并等待客户的确认,由于源地址是不存在的,服务器需要不断的重发直至超时,这些伪造的SYN包将长时间占用未连接队列,正常的SYN请求被丢弃,目标系统运行缓慢,严重者引起网络堵塞甚至系统瘫痪。

检测SYN攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。

TCP和UDP的区别

UDP的特点如下:

  1. 无链接
  2. UDP使用尽最大努力交付,不保证可靠性
  3. UDP是面向报文的,UDP对应用层交付下来的报文,既不合并,也不拆分,而是保留这些报文的边界。应用层交给UDP多长的报文,UDP就照样发送,即一次发送一个报文
  4. UDP没有拥塞控制
  5. UDP支持一对一、一对多、多对一和多对多的交互通信
  6. UDP的首部开销小,只有8字节

TCP的特点:

  1. TCP是面向连接的
  2. 每条TCP连接只能用于两个断点,一对一
  3. TCP提供可靠交付的服务:连接传输数据、无差错、不丢失、不重复、并且按序到达
  4. TCP提供全双工通信
  5. 面向字节流。TCP根据对方给出的窗口和当前网络拥塞的程度来决定一个报文应该包含多少个字节

TCP如何提供可靠性的连接:

  1. 数据被分割成TCP认为最合适发送的数据块
  2. 重传机制:TCP发出一个段后,启动定时器,如果不能及时收到报文段,则重发一个报文段
  3. 当TCP收到一个段后,会发送一个确认
  4. TCP将保持它首部和数据的校验和
  5. TCP对收到的数据进行重新排序,保证数据的有序
  6. TCP会丢弃重复的数据
  7. TCP提供流量控制

疑症(1)TCP 的三次握手、四次挥手

下面两图大家再熟悉不过了,TCP 的三次握手和四次挥手见下面左边的”TCP 建立连接”、”TCP 数据传送”、”TCP 断开连接”时序图和右边的”TCP 协议状态机” 。
TCP三次握手、四次挥手时序图
TCP协议状态机

要弄清 TCP 建立连接需要几次交互才行,我们需要弄清建立连接进行初始化的目标是什么。TCP 进行握手初始化一个连接的目标是:分配资源、初始化序列号(通知 peer 对端我的初始序列号是多少),知道初始化连接的目标,那么要达成这个目标的过程就简单了,握手过程可以简化为下面的四次交互:
1)client 端首先发送一个 SYN 包告诉 Server 端我的初始序列号是 X;2)Server 端收到 SYN 包后回复给 client 一个 ACK 确认包,告诉 client 说我收到了;3)接着 Server 端也需要告诉 client 端自己的初始序列号,于是 Server 也发送一个 SYN 包告诉 client 我的初始序列号是 Y;4)Client 收到后,回复 Server 一个 ACK 确认包说我知道了。

整个过程 4 次交互即可完成初始化,但是,细心的同学会发现两个问题:

  • Server 发送 SYN 包是作为发起连接的 SYN 包,还是作为响应发起者的 SYN 包呢?怎么区分?比较容易引起混淆
  • Server 的 ACK 确认包和接下来的 SYN 包可以合成一个 SYN ACK 包一起发送的,没必要分别单独发送,这样省了一次交互同时也解决了问题[1].这样 TCP 建立一个连接,三次握手在进行最少次交互的情况下完成了 Peer 两端的资源分配和初始化序列号的交换。

大部分情况下建立连接需要三次握手,也不一定都是三次,有可能出现四次握手来建立连接的。如下图,当 Peer 两端同时发起 SYN 来建立连接的时候,就出现了四次握手来建立连接(对于有些 TCP/IP 的实现,可能不支持这种同时打开的情况)。

在三次握手过程中,细心的同学可能会有以下疑问:

  • 初始化序列号 X、Y 是可以是写死固定的吗,为什么不能呢?
  • 假如 Client 发送一个 SYN 包给 Server 后就挂了或是不管了,这个时候这个连接处于什么状态呢?会超时吗?为什么呢?

TCP 进行断开连接的目标是:回收资源、终止数据传输。由于 TCP 是全双工的,需要 Peer 两端分别各自拆除自己通向 Peer 对端的方向的通信信道。这样需要四次挥手来分别拆除通信信道,就比较清晰明了了。

  • Client 发送一个 FIN 包来告诉 Server 我已经没数据需要发给 Server 了;
  • Server 收到后回复一个 ACK 确认包说我知道了;
  • 然后 server 在自己也没数据发送给 client 后,Server 也发送一个 FIN 包给 Client 告诉 Client 我也已经没数据发给 client 了;4)Client 收到后,就会回复一个 ACK 确认包说我知道了。

到此,四次挥手,这个 TCP 连接就可以完全拆除了。在四次挥手的过程中,细心的同学可能会有以下疑问:

  • Client 和 Server 同时发起断开连接的 FIN 包会怎么样呢,TCP 状态是怎么转移的?
  • 左侧图中的四次挥手过程中,Server 端的 ACK 确认包能不能和接下来的 FIN 包合并成一个包呢,这样四次挥手就变成三次挥手了。
  • 四次挥手过程中,首先断开连接的一端,在回复最后一个 ACK 后,为什么要进行 TIME_WAIT 呢(超时设置是 2*MSL,RFC793 定义了 MSL 为 2 分钟,Linux 设置成了 30s),在 TIME_WAIT 的时候又不能释放资源,白白让资源占用那么长时间,能不能省了 TIME_WAIT 呢,为什么?

疑症(2)TCP 连接的初始化序列号能否固定

如果初始化序列号(缩写为 ISN:Inital Sequence Number)可以固定,我们来看看会出现什么问题。假设 ISN 固定是 1,Client 和 Server 建立好一条 TCP 连接后,Client 连续给 Server 发了 10 个包,这 10 个包不知怎么被链路上的路由器缓存了(路由器会毫无先兆地缓存或者丢弃任何的数据包),这个时候碰巧 Client 挂掉了,然后 Client 用同样的端口号重新连上 Server,Client 又连续给 Server 发了几个包,假设这个时候 Client 的序列号变成了 5。

接着,之前被路由器缓存的 10 个数据包全部被路由到 Server 端了,Server 给 Client 回复确认号 10,这个时候,Client 整个都不好了,这是什么情况?我的序列号才到 5,你怎么给我的确认号是 10 了,整个都乱了。RFC793 中,建议 ISN 和一个假的时钟绑在一起,这个时钟会在每 4 微秒对 ISN 做加一操作,直到超过 2^32,又从 0 开始,这需要 4 小时才会产生 ISN 的回绕问题,这几乎可以保证每个新连接的 ISN 不会和旧的连接的 ISN 产生冲突。这种递增方式的 ISN,很容易让攻击者猜测到 TCP 连接的 ISN,现在的实现大多是在一个基准值的基础上进行随机的。

疑症(3)初始化连接的 SYN 超时问题

Client 发送 SYN 包给 Server 后挂了,Server 回给 Client 的 SYN-ACK 一直没收到 Client 的 ACK 确认,这个时候这个连接既没建立起来,也不能算失败。这就需要一个超时时间让 Server 将这个连接断开,否则这个连接就会一直占用 Server 的 SYN 连接队列中的一个位置,大量这样的连接就会将 Server 的 SYN 连接队列耗尽,让正常的连接无法得到处理。目前,Linux 下默认会进行 5 次重发 SYN-ACK 包,重试的间隔时间从 1s 开始,下次的重试间隔时间是前一次的双倍,5 次的重试时间间隔为 1s,2s, 4s, 8s,16s,总共 31s,第 5 次发出后还要等 32s 都知道第 5 次也超时了,所以,总共需要 1s + 2s +4s+ 8s+ 16s + 32s =63s,TCP 才会把断开这个连接。
由于,SYN 超时需要 63 秒,那么就给攻击者一个攻击服务器的机会,攻击者在短时间内发送大量的 SYN 包给 Server(俗称 SYN flood 攻击),用于耗尽 Server 的 SYN 队列。对于应对 SYN 过多的问题,linux 提供了几个 TCP 参数:tcp_syncookies、tcp_synack_retries、tcp_max_syn_backlog、tcp_abort_on_overflow 来调整应对。

疑症(4) TCP 的 Peer 两端同时断开连接

由上面的”TCP 协议状态机”图可以看出,TCP 的 Peer 端在收到对端的 FIN 包前发出了 FIN 包,那么该 Peer 的状态就变成了 FIN_WAIT1,Peer 在 FIN_WAIT1 状态下收到对端 Peer 对自己 FIN 包的 ACK 包的话,那么 Peer 状态就变成 FIN_WAIT2,Peer 在 FIN_WAIT2 下收到对端 Peer 的 FIN 包,在确认已经收到了对端 Peer 全部的 Data 数据包后,就响应一个 ACK 给对端 Peer,然后自己进入 TIME_WAIT 状态。
但是如果 Peer 在 FIN_WAIT1 状态下首先收到对端 Peer 的 FIN 包的话,那么该 Peer 在确认已经收到了对端 Peer 全部的 Data 数据包后,就响应一个 ACK 给对端 Peer,然后自己进入 CLOSEING 状态,Peer 在 CLOSEING 状态下收到自己的 FIN 包的 ACK 包的话,那么就进入 TIME WAIT 状态。于是,TCP 的 Peer 两端同时发起 FIN 包进行断开连接,那么两端 Peer 可能出现完全一样的状态转移 FIN_WAIT1——>CLOSEING——->TIME_WAIT,也就会 Client 和 Server 最后同时进入 TIME_WAIT 状态。同时关闭连接的状态转移如下图所示:

疑症(5)四次挥手能不能变成三次挥手呢??

答案是可能的。TCP 是全双工通信,Cliet 在自己已经不会在有新的数据要发送给 Server 后,可以发送 FIN 信号告知 Server,这边已经终止 Client 到对端 Server 那边的数据传输。但是,这个时候对端 Server 可以继续往 Client 这边发送数据包。于是,两端数据传输的终止在时序上是独立并且可能会相隔比较长的时间,这个时候就必须最少需要 2+2= 4 次挥手来完全终止这个连接。但是,如果 Server 在收到 Client 的 FIN 包后,在也没数据需要发送给 Client 了,那么对 Client 的 ACK 包和 Server 自己的 FIN 包就可以合并成为一个包发送过去,这样四次挥手就可以变成三次了(似乎 linux 协议栈就是这样实现的)

疑症(6) TCP 的头号疼症 TIME_WAIT 状态

要说明 TIME_WAIT 的问题,需要解答以下几个问题

Peer 两端,哪一端会进入 TIME_WAIT 呢?为什么?

相信大家都知道,TCP 主动关闭连接的那一方会最后进入 TIME_WAIT。那么怎么界定主动关闭方呢?是否主动关闭是由 FIN 包的先后决定的,就是在自己没收到对端 Peer 的 FIN 包之前自己发出了 FIN 包,那么自己就是主动关闭连接的那一方。对于疑症(4)中描述的情况,那么 Peer 两边都是主动关闭的一方,两边都会进入 TIME_WAIT。为什么是主动关闭的一方进行 TIME_WAIT 呢,被动关闭的进入 TIME_WAIT 可以不呢?我们来看看 TCP 四次挥手可以简单分为下面三个过程:

  • 过程一.主动关闭方发送 FIN;
  • 过程二.被动关闭方收到主动关闭方的 FIN 后发送该 FIN 的 ACK,被动关闭方发送 FIN;
  • 过程三.主动关闭方收到被动关闭方的 FIN 后发送该 FIN 的 ACK,被动关闭方等待自己 FIN 的 ACK。

问题就在过程三中,据 TCP 协议规范,不对 ACK 进行 ACK,如果主动关闭方不进入 TIME_WAIT,那么主动关闭方在发送完 ACK 就走了的话,如果最后发送的 ACK 在路由过程中丢掉了,最后没能到被动关闭方,这个时候被动关闭方没收到自己 FIN 的 ACK 就不能关闭连接,接着被动关闭方会超时重发 FIN 包,但是这个时候已经没有对端会给该 FIN 回 ACK,被动关闭方就无法正常关闭连接了,所以主动关闭方需要进入 TIME_WAIT 以便能够重发丢掉的被动关闭方 FIN 的 ACK。

TIME_WAIT 状态是用来解决或避免什么问题呢?

TIME_WAIT 主要是用来解决以下几个问题:

上面解释为什么主动关闭方需要进入 TIME_WAIT 状态中提到的:主动关闭方需要进入 TIME_WAIT 以便能够重发丢掉的被动关闭方 FIN 包的 ACK。如果主动关闭方不进入 TIME_WAIT,那么在主动关闭方对被动关闭方 FIN 包的 ACK 丢失了的时候,被动关闭方由于没收到自己 FIN 的 ACK,会进行重传 FIN 包,这个 FIN 包到主动关闭方后,由于这个连接已经不存在于主动关闭方了,这个时候主动关闭方无法识别这个 FIN 包,协议栈会认为对方疯了,都还没建立连接你给我来个 FIN 包?,于是回复一个 RST 包给被动关闭方,被动关闭方就会收到一个错误(我们见的比较多的:connect reset by peer,这里顺便说下 Broken pipe,在收到 RST 包的时候,还往这个连接写数据,就会收到 Broken pipe 错误了),原本应该正常关闭的连接,给我来个错误,很难让人接受。

防止已经断开的连接 1 中在链路中残留的 FIN 包终止掉新的连接 2(重用了连接 1 的所有的 5 元素(源 IP,目的 IP,TCP,源端口,目的端口)),这个概率比较低,因为涉及到一个匹配问题,迟到的 FIN 分段的序列号必须落在连接 2 的一方的期望序列号范围之内,虽然概率低,但是确实可能发生,因为初始序列号都是随机产生的,并且这个序列号是 32 位的,会回绕。

防止链路上已经关闭的连接的残余数据包(a lost duplicate packet or a wandering duplicate packet) 干扰正常的数据包,造成数据流的不正常。这个问题和 2)类似。

TIME_WAIT 会带来哪些问题呢?

TIME_WAIT 带来的问题注意是源于:一个连接进入 TIME_WAIT 状态后需要等待 2*MSL(一般是 1 到 4 分钟)那么长的时间才能断开连接释放连接占用的资源,会造成以下问题:

  • 作为服务器,短时间内关闭了大量的 Client 连接,就会造成服务器上出现大量的 TIME_WAIT 连接,占据大量的 tuple,严重消耗着服务器的资源。
  • 作为客户端,短时间内大量的短连接,会大量消耗的 Client 机器的端口,毕竟端口只有 65535 个,端口被耗尽了,后续就无法在发起新的连接了。

由于上面两个问题,作为客户端需要连本机的一个服务的时候,首选 UNIX 域套接字而不是 TCP)。TIME_WAIT 很令人头疼,很多问题是由 TIME_WAIT 造成的,但是 TIME_WAIT 又不是多余的不能简单将 TIME_WAIT 去掉,那么怎么来解决或缓解 TIME_WAIT 问题呢?可以进行 TIME_WAIT 的快速回收和重用来缓解 TIME_WAIT 的问题。有没一些清掉 TIME_WAIT 的技巧呢?

TIME_WAIT 的快速回收和重用

【1】TIME_WAIT 快速回收:linux 下开启 TIME_WAIT 快速回收需要同时打开 tcp_tw_recycle 和 tcp_timestamps(默认打开)两选项。Linux 下快速回收的时间为 3.5* RTO(Retransmission Timeout),而一个 RTO 时间为 200ms 至 120s。开启快速回收 TIME_WAIT,可能会带来(问题一、)中说的三点危险,为了避免这些危险,要求同时满足以下三种情况的新连接要被拒绝掉:

  • 来自同一个对端 Peer 的 TCP 包携带了时间戳;
  • 之前同一台 peer 机器(仅仅识别 IP 地址,因为连接被快速释放了,没了端口信息)的某个 TCP 数据在 MSL 秒之内到过本 Server;
  • Peer 机器新连接的时间戳小于 peer 机器上次 TCP 到来时的时间戳,且差值大于重放窗口戳(TCP_PAWS_WINDOW)。

初看起来正常的数据包同时满足下面 3 条几乎不可能,因为机器的时间戳不可能倒流的,出现上述的 3 点均满足时,一定是老的重复数据包又回来了,丢弃老的 SYN 包是正常的。到此,似乎启用快速回收就能很大程度缓解 TIME_WAIT 带来的问题。但是,这里忽略了一个东西就是 NAT。

在一个 NAT 后面的所有 Peer 机器在 Server 看来都是一个机器,NAT 后面的那么多 Peer 机器的系统时间戳很可能不一致,有些快,有些慢。这样,在 Server 关闭了与系统时间戳快的 Client 的连接后,在这个连接进入快速回收的时候,同一 NAT 后面的系统时间戳慢的 Client 向 Server 发起连接,这就很有可能同时满足上面的三种情况,造成该连接被 Server 拒绝掉。所以,在是否开启 tcp_tw_recycle 需要慎重考虑了

【2】TIME_WAIT 重用:linux 上比较完美的实现了 TIME_WAIT 重用问题。只要满足下面两点中的一点,一个 TW 状态的四元组(即一个 socket 连接)可以重新被新到来的 SYN 连接使用。

  • 新连接 SYN 告知的初始序列号比 TIME_WAIT 老连接的末序列号大;
  • 如果开启了 tcp_timestamps,并且新到来的连接的时间戳比老连接的时间戳大。

要同时开启 tcp_tw_reuse 选项和 tcp_timestamps 选项才可以开启 TIME_WAIT 重用,还有一个条件是:重用 TIME_WAIT 的条件是收到最后一个包后超过 1s。细心的同学可能发现 TIME_WAIT 重用对 Server 端来说并没解决大量 TIME_WAIT 造成的资源消耗的问题,因为不管 TIME_WAIT 连接是否被重用,它依旧占用着系统资源。即便如此,TIME_WAIT 重用还是有些用处的,它解决了整机范围拒绝接入的问题,虽然一般一个单独的 Client 是不可能在 MSL 内用同一个端口连接同一个服务的,但是如果 Client 做了 bind 端口那就是同个端口了。时间戳重用 TIME_WAIT 连接的机制的前提是 IP 地址唯一性,得出新请求发起自同一台机器,但是如果是 NAT 环境下就不能这样保证了,于是在 NAT 环境下,TIME_WAIT 重用还是有风险的。

有些同学可能会混淆 tcp_tw_reuse 和 SO_REUSEADDR 选项,认为是相关的一个东西,其实他们是两个完全不同的东西,可以说两个半毛钱关系都没。tcp_tw_reuse 是内核选项,而 SO_REUSEADDR 用户态的选项,使用 SO_REUSEADDR 是告诉内核,如果端口忙,但 TCP 状态位于 TIME_WAIT,可以重用端口。如果端口忙,而 TCP 状态位于其他状态,重用端口时依旧得到一个错误信息,指明 Address already in use”。如果你的服务程序停止后想立即重启,而新套接字依旧使用同一端口,此时 SO_REUSEADDR 选项非常有用。但是,使用这个选项就会有(问题二、)中说的三点危险,虽然发生的概率不大。

清掉 TIME_WAIT 的奇技怪巧

可以用下面两种方式控制服务器的 TIME_WAIT 数量:

【1】修改 tcp_max_tw_buckets
tcp_max_tw_buckets 控制并发的 TIME_WAIT 的数量,默认值是 180000。如果超过默认值,内核会把多的 TIME_WAIT 连接清掉,然后在日志里打一个警告。官网文档说这个选项只是为了阻止一些简单的 DoS 攻击,平常不要人为的降低它。

【2】利用 RST 包从外部清掉 TIME_WAIT 链接
根据 TCP 规范,收到任何的发送到未侦听端口、已经关闭的连接的数据包、连接处于任何非同步状态(LISTEN,SYS-SENT,SYN-RECEIVED)并且收到的包的 ACK 在窗口外,或者安全层不匹配,都要回执以 RST 响应(而收到滑动窗口外的序列号的数据包,都要丢弃这个数据包,并回复一个 ACK 包),内核收到 RST 将会产生一个错误并终止该连接。我们可以利用 RST 包来终止掉处于 TIME_WAIT 状态的连接,其实这就是所谓的 RST 攻击了。
为了描述方便:假设 Client 和 Server 有个连接 Connect1,Server 主动关闭连接并进入了 TIME_WAIT 状态,我们来描述一下怎么从外部使得 Server 的处于 TIME_WAIT 状态的连接 Connect1 提前终止掉。要实现这个 RST 攻击,首先我们要知道 Client 在 Connect1 中的端口 port1(一般这个端口是随机的,比较难猜到,这也是 RST 攻击较难的一个点),利用 IP_TRANSPARENT 这个 socket 选项,它可以 bind 不属于本地的地址,因此可以从任意机器绑定 Client 地址以及端口 port1,然后向 Server 发起一个连接,Server 收到了窗口外的包于是响应一个 ACK,这个 ACK 包会路由到 Client 处。

这个时候 99%的可能 Client 已经释放连接 connect1 了,这个时候 Client 收到这个 ACK 包,会发送一个 RST 包,server 收到 RST 包然后就释放连接 connect1 提前终止 TIME_WAIT 状态了。提前终止 TIME_WAIT 状态是可能会带来(问题二)中说的三点危害,具体的危害情况可以看下 RFC1337。RFC1337 中建议,不要用 RST 过早的结束 TIME_WAIT 状态。

至此,上面的疑症都解析完毕,然而细心的同学会有下面的疑问:TCP 的可靠传输是确认号来实现的,那么 TCP 的确认机制是怎样的呢?是收到一个包就马上确认,还是可以稍等一下在确认呢?假如发送一个包,一直都没收到确认呢?什么时候重传呢?超时机制的怎样的?

TCP 两端 Peer 的处理能力不对等的时候,比如发送方处理能力很强,接收方处理能力很弱,这样发送方是否能够不管接收方死活狂发数据呢?如果不能,流量控制机制的如何的?

TCP 是端到端的协议,也就是 TCP 对端 Peer 只看到对方,看不到网络上的其他点,那么 TCP 的两端怎么对网络情况做出反映呢?发生拥塞的时候,拥塞控制机制是如何的?

疑症(7)TCP 的延迟确认机制

按照 TCP 协议,确认机制是累积的,也就是确认号 X 的确认指示的是所有 X 之前但不包括 X 的数据已经收到了。确认号(ACK)本身就是不含数据的分段,因此大量的确认号消耗了大量的带宽,虽然大多数情况下,ACK 还是可以和数据一起捎带传输的,但是如果没有捎带传输,那么就只能单独回来一个 ACK,如果这样的分段太多,网络的利用率就会下降。为缓解这个问题,RFC 建议了一种延迟的 ACK,也就是说,ACK 在收到数据后并不马上回复,而是延迟一段可以接受的时间,延迟一段时间的目的是看能不能和接收方要发给发送方的数据一起回去,因为 TCP 协议头中总是包含确认号的,如果能的话,就将数据一起捎带回去,这样网络利用率就提高了。

延迟 ACK 就算没有数据捎带,那么如果收到了按序的两个包,那么只要对第二包做确认即可,这样也能省去一个 ACK 消耗。由于 TCP 协议不对 ACK 进行 ACK 的,RFC 建议最多等待 2 个包的积累确认,这样能够及时通知对端 Peer,我这边的接收情况。Linux 实现中,有延迟 ACK 和快速 ACK,并根据当前的包的收发情况来在这两种 ACK 中切换。一般情况下,ACK 并不会对网络性能有太大的影响,延迟 ACK 能减少发送的分段从而节省了带宽,而快速 ACK 能及时通知发送方丢包,避免滑动窗口停等,提升吞吐率。

关于 ACK 分段,有个细节需要说明一下,ACK 的确认号,是确认按序收到的最后一个字节序,对于乱序到来的 TCP 分段,接收端会回复相同的 ACK 分段,只确认按序到达的最后一个 TCP 分段。TCP 连接的延迟确认时间一般初始化为最小值 40ms,随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。

TCP 的重传机制以及重传的超时计算

TCP 的重传超时计算

TCP 交互过程中,如果发送的包一直没收到 ACK 确认,是要一直等下去吗?显然不能一直等(如果发送的包在路由过程中丢失了,对端都没收到又如何给你发送确认呢?),这样协议将不可用,既然不能一直等下去,那么该等多久呢?等太长时间的话,数据包都丢了很久了才重发,没有效率,性能差;等太短时间的话,可能 ACK 还在路上快到了,这时候却重传了,造成浪费,同时过多的重传会造成网络拥塞,进一步加剧数据的丢失。也是,我们不能去猜测一个重传超时时间,应该是通过一个算法去计算,并且这个超时时间应该是随着网络的状况在变化的。为了使我们的重传机制更高效,如果我们能够比较准确知道在当前网络状况下,一个数据包从发出去到回来的时间 RTT——Round Trip Time,那么根据这个 RTT 我们就可以方便设置 TimeOut——RTO(Retransmission TimeOut)了。

为了计算这个 RTO,RFC793 中定义了一个经典算法,算法如下:

  • [1] 首先采样计算RTT值
  • [2] 然后计算平滑的RTT,称为Smoothed Round Trip Time (SRTT),SRTT = ( ALPHA SRTT ) + ((1-ALPHA) RTT)
  • [3] RTO = min[UBOUND,max[LBOUND,(BETA*SRTT)]]

其中:UBOUND 是 RTO 值的上限;例如:可以定义为 1 分钟,LBOUND 是 RTO 值的下限,例如,可以定义为 1 秒;ALPHA is a smoothing factor (e.g., .8 to .9), and BETA is a delay variance factor(e.g., 1.3 to 2.0).

然而这个算法有个缺点就是:在算 RTT 样本的时候,是用第一次发数据的时间和 ack 回来的时间做 RTT 样本值,还是用重传的时间和 ACK 回来的时间做 RTT 样本值?不管是怎么选择,总会造成会要么把 RTT 算过长了,要么把 RTT 算过短了。如下图:(a)就计算过长了,而(b)就是计算过短了。

针对上面经典算法的缺陷,于是提出 Karn / Partridge Algorithm 对经典算法进行了改进(算法大特点是——忽略重传,不把重传的 RTT 做采样),但是这个算法有问题:如果在某一时间,网络闪动,突然变慢了,产生了比较大的延时,这个延时导致要重转所有的包(因为之前的 RTO 很小),于是,因为重转的不算,所以,RTO 就不会被更新,这是一个灾难。于是,为解决上面两个算法的问题,又有人推出来了一个新的算法,这个算法叫 Jacobson / Karels Algorithm(参看 FC6289),这个算法的核心是:除了考虑每两次测量值的偏差之外,其变化率也应该考虑在内,如果变化率过大,则通过以变化率为自变量的函数为主计算 RTT(如果陡然增大,则取值为比较大的正数,如果陡然减小,则取值为比较小的负数,然后和平均值加权求和),反之如果变化率很小,则取测量平均值。
公式如下:(其中的 DevRTT 是 Deviation RTT 的意思)

1
2
3
SRTT = SRTT + α (RTT – SRTT)  —— 计算平滑RTT
DevRTT = (1-β)*DevRTT + β*(|RTT-SRTT|) ——计算平滑RTT和真实的差距(加权移动平均)
RTO= µ * SRTT + ∂ *DevRTT —— 神一样的公式

(其中:在Linux下,α = 0.125,β = 0.25, μ = 1,∂ = 4 ——这就是算法中的“调得一手好参数”,nobody knows why,

最后的这个算法在被用在今天的 TCP 协议中并工作非常好。

知道超时怎么计算后,很自然就想到定时器的设计问题。一个简单直观的方案就是为 TCP 中的每一个数据包维护一个定时器,在这个定时器到期前没收到确认,则进行重传。这种设计理论上是很合理的,但是实现上,这种方案将会有非常多的定时器,会带来巨大内存开销和调度开销。既然不能每个包一个定时器,那么多少个包一个定时器才好呢,这个似乎比较难确定。可以换个思路,不要以包量来确定定时器,以连接来确定定时器会不会比较合理呢?目前,采取每一个 TCP 连接单一超时定时器的设计则成了一个默认的选择,并且 RFC2988 给出了每连接单一定时器的设计建议算法规则:
[1].每一次一个包含数据的包被发送(包括重发),如果还没开启重传定时器,则开启它,使得它在 RTO 秒之后超时(按照当前的 RTO 值)。[2]. 当接收到一个 ACK 确认一个新的数据;如果所有的发出数据都被确认了,关闭重传定时器;[3].当接收到一个 ACK 确认一个新的数据,还有数据在传输,也就是还有没被确认的数据,重新启动重传定时器,使得它在 RTO 秒之后超时(按照当前的 RTO 值)。

当重传定时器超时后,依次做下列 3 件事情:[4.1]. 重传最早的尚未被 TCP 接收方 ACK 的数据包;[4.2]. 重新设置 RTO 为 RTO *2(“还原定时器”),但是新 RTO 不应该超过 RTO 的上限(RTO 有个上限值,这个上限值最少为 60s);[4.3]. 重启重传定时器。

上面的建议算法体现了一个原则:没被确认的包必须可以超时,并且超时的时间不能太长,同时也不要过早重传。规则[1][3][4.3]共同说明了只要还有数据包没被确认,那么定时器一定会是开启着的(这样满足没被确认的包必须可以超时的原则)。规则[4.2]说明定时器的超时值是有上限的(满足超时的时间不能太长)。

规则[3]说明,在一个 ACK 到来后重置定时器可以保护后发的数据不被过早重传;因为一个 ACK 到来了,说明后续的 ACK 很可能会依次到来,也就是说丢失的可能性并不大。规则[4.2]也是在一定程度上避免过早重传,因为,在出现定时器超时后,有可能是网络出现拥塞了,这个时候应该延长定时器,避免出现大量的重传进一步加剧网络的拥塞。

TCP 的重传机制

通过上面我们可以知道,TCP 的重传是由超时触发的,这会引发一个重传选择问题,假设 TCP 发送端连续发了 1、2、3、4、5、6、7、8、9、10 共 10 包,其中 4、6、8 这 3 个包全丢失了,由于 TCP 的 ACK 是确认最后连续收到序号,这样发送端只能收到 3 号包的 ACK,这样在 TIME_OUT 的时候,发送端就面临下面两个重传选择:[1].仅重传 4 号包 [2].重传 3 号后面所有的包,也就是重传 4~10 号包

对于,上面两个选择的优缺点都比较明显。方案[1],优点:按需重传,能够最大程度节省带宽。缺点:重传会比较慢,因为重传 4 号包后,需要等下一个超时才会重传 6 号包。方案[2],优点:重传较快,数据能够较快交付给接收端。缺点:重传了很多不必要重传的包,浪费带宽,在出现丢包的时候,一般是网络拥塞,大量的重传又可能进一步加剧拥塞。

上面的问题是由于单纯以时间驱动来进行重传的,都必须等待一个超时时间,不能快速对当前网络状况做出响应,如果加入以数据驱动呢?TCP 引入了一种叫 Fast Retransmit(快速重传)的算法,就是在连续收到 3 次相同确认号的 ACK,那么就进行重传。这个算法基于这么一个假设,连续收到 3 个相同的 ACK,那么说明当前的网络状况变好了,可以重传丢失的包了。

快速重传解决了 timeout 的问题,但是没解决重传一个还是重传多个的问题。出现难以决定是否重传多个包问题的根源在于,发送端不知道那些非连续序号的包已经到达接收端了,但是接收端是知道的,如果接收端告诉一下发送端不就可以解决这个问题吗?于是,RFC2018 提出了 Selective Acknowledgment(SACK,选择确认)机制,SACK 是 TCP 的扩展选项,包括(1)SACK 允许选项(Kind=4,Length=2,选项只允许在有 SYN 标志的 TCP 包中),(2)SACK 信息选项 Kind=5,Length)。一个 SACK 的例子如下图,红框说明:接收端收到了 0-5500,8000-8500,7000-7500,6000-6500 的数据了,这样发送端就可以选择重传丢失的 5500-6000,6500-7000,7500-8000 的包。

SACK 依靠接收端的接收情况反馈,解决了重传风暴问题,这样够了吗?接收端能不能反馈更多的信息呢?显然是可以的,于是,RFC2883 对对 SACK 进行了扩展,提出了 D-SACK,也就是利用第一块 SACK 数据中描述重复接收的不连续数据块的序列号参数,其他 SACK 数据则描述其他正常接收到的不连续数据。这样发送方利用第一块 SACK,可以发现数据段被网络复制、错误重传、ACK 丢失引起的重传、重传超时等异常的网络状况,使得发送端能更好调整自己的重传策略。D-SACK,有几个优点:

  • 发送端可以判断出,是发包丢失了,还是接收端的 ACK 丢失了。(发送方,重传了一个包,发现并没有 D-SACK 那个包,那么就是发送的数据包丢了;否则就是接收端的 ACK 丢了,或者是发送的包延迟到达了);
  • 发送端可以判断自己的 RTO 是不是有点小了,导致过早重传(如果收到比较多的 D-SACK 就该怀疑是 RTO 小了);
  • 发送端可以判断自己的数据包是不是被复制了。(如果明明没有重传该数据包,但是收到该数据包的 D-SACK);
  • 发送端可以判断目前网络上是不是出现了有些包被 delay 了,也就是出现先发的包却后到了。

疑症(9)TCP 的流量控制

我们知道 TCP 的窗口(window)是一个 16bit 位字段,它代表的是窗口的字节容量,也就是 TCP 的标准窗口最大为 2^16-1=65535 个字节。另外在 TCP 的选项字段中还包含了一个 TCP 窗口扩大因子,option-kind 为 3,option-length 为 3 个字节,option-data 取值范围 0-14。窗口扩大因子用来扩大 TCP 窗口,可把原来 16bit 的窗口,扩大为 31bit。

这个窗口是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。也就是,发送端是根据接收端通知的窗口大小来调整自己的发送速率的,以达到端到端的流量控制。尽管流量控制看起来简单明了,就是发送端根据接收端的限制来控制自己的发送就好了,但是细心的同学还是会有些疑问的。

  1. 发送端是怎么做到比较方便知道自己哪些包可以发,哪些包不能发呢?
  2. 如果接收端通知一个零窗口给发送端,这个时候发送端还能不能发送数据呢?如果不发数据,那一直等接收端口通知一个非 0 窗口吗,如果接收端一直不通知呢?
  3. 如果接收端处理能力很慢,这样接收端的窗口很快被填满,然后接收处理完几个字节,腾出几个字节的窗口后,通知发送端,这个时候发送端马上就发送几个字节给接收端吗?发送的话会不会太浪费了,就像一艘万吨油轮只装上几斤的油就开去目的地一样。对于发送端产生数据的能力很弱也一样,如果发送端慢吞吞产生几个字节的数据要发送,这个时候该不该立即发送呢?还是累积多点在发送?

疑问 1)的解决:

发送方要知道那些可以发,哪些不可以发,一个简明的方案就是按照接收方的窗口通告,发送方维护一个一样大小的发送窗口就可以了,在窗口内的可以发,窗口外的不可以发,窗口在发送序列上不断后移,这就是 TCP 中的滑动窗口。如下图所示,对于 TCP 发送端其发送缓存内的数据都可以分为 4 类:

  1. 已经发送并得到接收端 ACK 的;
  2. 已经发送但还未收到接收端 ACK 的;
  3. 未发送但允许发送的(接收方还有空间);
  4. 未发送且不允许发送(接收方没空间了)。

其中,[2]和[3]两部分合起来称之为发送窗口。

下面两图演示的窗口的滑动情况,收到 36 的 ACK 后,窗口向后滑动 5 个 byte。

疑问 2)的解决

由问题 1)我们知道,发送端的发送窗口是由接收端控制的。下图,展示了一个发送端是怎么受接收端控制的。

由上图我们知道,当接收端通知一个 zero 窗口的时候,发送端的发送窗口也变成了 0,也就是发送端不能发数据了。如果发送端一直等待,直到接收端通知一个非零窗口在发数据的话,这似乎太受限于接收端,如果接收端一直不通知新的窗口呢?显然发送端不能干等,起码有一个主动探测的机制。为解决 0 窗口的问题,TCP 使用了 Zero Window Probe 技术,缩写为 ZWP。发送端在窗口变成 0 后,会发 ZWP 的包给接收方,来探测目前接收端的窗口大小,一般这个值会设置成 3 次,每次大约 30-60 秒(不同的实现可能会不一样)。

如果 3 次过后还是 0 的话,有的 TCP 实现就会发 RST 掉这个连接。正如有人的地方就会有商机,那么有等待的地方就很有可能出现 DDoS 攻击点。攻击者可以在和 Server 建立好连接后,就向 Server 通告一个 0 窗口,然后 Server 端就只能等待进行 ZWP,于是攻击者会并发大量的这样的请求,把 Server 端的资源耗尽。

疑问点 3)的解决

疑点 3)本质就是一个避免发送大量小包的问题。造成这个问题原因有二:

  • 接收端一直在通知一个小的窗口;
  • 发送端本身问题,一直在发送小包。这个问题,TCP 中有个术语叫 Silly Window Syndrome(糊涂窗口综合症)。

解决这个问题的思路有两种,

  • 接收端不通知小窗口,
  • 发送端积累一下数据在发送。

思路 1)是在接收端解决这个问题,David D Clark’s 方案,如果收到的数据导致 window size 小于某个值,就 ACK 一个 0 窗口,这就阻止发送端在发数据过来。等到接收端处理了一些数据后 windows size 大于等于了 MSS,或者 buffer 有一半为空,就可以通告一个非 0 窗口。

思路 2)是在发送端解决这个问题,有个著名的 Nagle’s algorithm。Nagle 算法的规则:[1]如果包长度达到 MSS ,则允许发送;[2]如果该包含有 FIN ,则允许发送;[3]设置了 TCP_NODELAY 选项,则允许发送;[4]设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于 MSS)均被确认,则允许发送;[5]上述条件都未满足,但发生了超时(一般为 200ms ),则立即发送。

规则[4]指出 TCP 连接上最多只能有一个未被确认的小数据包。从规则[4]可以看出 Nagle 算法并不禁止发送小的数据包(超时时间内),而是避免发送大量小的数据包。由于 Nagle 算法是依赖 ACK 的,如果 ACK 很快的话,也会出现一直发小包的情况,造成网络利用率低。TCP_CORK 选项则是禁止发送小的数据包(超时时间内),设置该选项后,TCP 会尽力把小数据包拼接成一个大的数据包(一个 MTU)再发送出去,当然也不会一直等,发生了超时(一般为 200ms),也立即发送。Nagle 算法和 CP_CORK 选项提高了网络的利用率,但是增加是延时。从规则[3]可以看出,设置 TCP_NODELAY 选项,就是完全禁用 Nagle 算法了。

这里要说一个小插曲,Nagle 算法和延迟确认(Delayed Acknoledgement)一起,当出现(write-write-read)的时候会引发一个 40ms 的延时问题,这个问题在 HTTP svr 中体现的比较明显。场景如下:

客户端在请求下载 HTTP svr 中的一个小文件,一般情况下,HTTP svr 都是先发送 HTTP 响应头部,然后在发送 HTTP 响应 BODY(特别是比较多的实现在发送文件的实施采用的是 sendfile 系统调用,这就出现 write-write-read 模式了)。当发送头部的时候,由于头部较小,于是形成一个小的 TCP 包发送到客户端,这个时候开始发送 body,由于 body 也较小,这样还是形成一个小的 TCP 数据包,根据 Nagle 算法,HTTP svr 已经发送一个小的数据包了,在收到第一个小包的 ACK 后或等待 200ms 超时后才能在发小包,HTTP svr 不能发送这个 body 小 TCP 包。

客户端收到 http 响应头后,由于这是一个小的 TCP 包,于是客户端开启延迟确认,客户端在等待 Svr 的第二个包来在一起确认或等待一个超时(一般是 40ms)在发送 ACK 包;这样就出现了你等我、然而我也在等你的死锁状态,于是出现最多的情况是客户端等待一个 40ms 的超时,然后发送 ACK 给 HTTP svr,HTTP svr 收到 ACK 包后在发送 body 部分。大家在测 HTTP svr 的时候就要留意这个问题了。

疑症(10)TCP 的拥塞控制

谈到拥塞控制,就要先谈谈拥塞的因素和本质。本质上,网络上拥塞的原因就是大家都想独享整个网络资源,对于 TCP,端到端的流量控制必然会导致网络拥堵。这是因为 TCP 只看到对端的接收空间的大小,而无法知道链路上的容量,只要双方的处理能力很强,那么就可以以很大的速率发包,于是链路很快出现拥堵,进而引起大量的丢包,丢包又引发发送端的重传风暴,进一步加剧链路的拥塞。

另外一个拥塞的因素是链路上的转发节点,例如路由器,再好的路由器只要接入网络,总是会拉低网络的总带宽,如果在路由器节点上出现处理瓶颈,那么就很容易出现拥塞。由于 TCP 看不到网络的状况,那么拥塞控制是必须的并且需要采用试探性的方式来控制拥塞,于是拥塞控制要完成两个任务:[1]公平性;[2]拥塞过后的恢复。

TCP 发展到现在,拥塞控制方面的算法很多,其中 Reno 是目前应用最广泛且较为成熟的算法,下面着重介绍一下 Reno 算法(RFC5681)。介绍该算法前,首先介绍一个概念 duplicate acknowledgment(冗余 ACK、重复 ACK)一般情况下一个 ACK 被称为冗余 ACK,要同时满足下面几个条件(对于 SACK,那么根据 SACK 的一些信息来进一步判断)。

  • [1] 接收 ACK 的那端已经发出了一些还没被 ACK 的数据包;
  • [2] 该 ACK 没有捎带 data;
  • [3] 该 ACK 的 SYN 和 FIN 位都是 off 的,也就是既不是 SYN 包的 ACK 也不是 FIN 包的 ACK;
  • [4] 该 ACK 的确认号等于接收 ACK 那端已经收到的 ACK 的最大确认号;
  • [5] 该 ACK 通知的窗口等接收该 ACK 的那端上一个收到的 ACK 的窗口。

Reno 算法包含 4 个部分:

  • [1]慢热启动算法 – Slow Start;
  • [2]拥塞避免算法 – Congestion Avoidance;
  • [3]快速重传 - Fast Retransimit;
  • [4]快速恢复算法 – Fast Recovery。

TCP 的拥塞控制主要原理依赖于一个拥塞窗口(cwnd)来控制,根据前面的讨论,我们知道有一个接收端通告的接收窗口(rwnd)用于流量控制;加上拥塞控制后,发送端真正的发送窗口=min(rwnd,cwnd)。关于 cwnd 的单位,在 TCP 中是以字节来做单位的,我们假设 TCP 每次传输都是按照 MSS 大小来发送数据,因此你可以认为 cwnd 按照数据包个数来做单位也可以理解,下面如果没有特别说明是字节,那么 cwnd 增加 1 也就是相当于字节数增加 1 个 MSS 大小。

慢热启动算法 – Slow Start

慢启动体现了一个试探的过程,刚接入网络的时候先发包慢点,探测一下网络情况,然后在慢慢提速。不要一上来就拼命发包,这样很容易造成链路的拥堵,出现拥堵了在想到要降速来缓解拥堵这就有点成本高了,毕竟无数的先例告诫我们先污染后治理的成本是很高的。慢启动的算法如下(cwnd 全称 Congestion Window):

  1. 连接建好的开始先初始化 cwnd = N,表明可以传 N 个 MSS 大小的数据;
  2. 每当收到一个 ACK,++cwnd; 呈线性上升;
  3. 每当过了一个 RTT,cwnd = cwnd*2; 呈指数让升;
  4. 还有一个慢启动门限 ssthresh(slow start threshold),是一个上限,当 cwnd >= ssthresh 时,就会进入”拥塞避免算法 - Congestion Avoidance”。

根据 RFC5681,如果 MSS > 2190 bytes,则 N = 2;如果 MSS < 1095 bytes,则 N =4;如果 2190 bytes >= MSS >= 1095 bytes,则 N = 3;一篇 Google 的论文《An Argument for Increasing TCP’s Initial Congestion Window》建议把 cwnd 初始化成了 10 个 MSS。Linux 3.0 后采用了这篇论文的建议。

拥塞避免算法 – Congestion Avoidance

慢启动的时候说过,cwnd 是指数快速增长的,但是增长是有个门限 ssthresh(一般来说大多数的实现 ssthresh 的值是 65535 字节)的,到达门限后进入拥塞避免阶段。在进入拥塞避免阶段后,cwnd 值变化算法如下:1)每收到一个 ACK,调整 cwnd 为 (cwnd + 1/cwnd) * MSS 个字节;2)每经过一个 RTT 的时长,cwnd 增加 1 个 MSS 大小。

TCP 是看不到网络的整体状况的,那么 TCP 认为网络拥塞的主要依据是它重传了报文段。前面我们说过 TCP 的重传分两种情况:

  1. 出现 RTO 超时,重传数据包。这种情况下,TCP 就认为出现拥塞的可能性就很大,于是它反应非常’强烈’ [1] 调整门限 ssthresh 的值为当前 cwnd 值的 1/2;[2] reset 自己的 cwnd 值为 1;[3] 然后重新进入慢启动过程。
  2. 在 RTO 超时前,收到 3 个 duplicate ACK 进行重传数据包。这种情况下,收到 3 个冗余 ACK 后说明确实有中间的分段丢失,然而后面的分段确实到达了接收端,因为这样才会发送冗余 ACK,这一般是路由器故障或者轻度拥塞或者其它不太严重的原因引起的,因此此时拥塞窗口缩小的幅度就不能太大,此时进入快速重传。

快速重传

Fast Retransimit 做的事情有:

  1. 调整门限 ssthresh 的值为当前 cwnd 值的 1/2;
  2. 将 cwnd 值设置为新的 ssthresh 的值;
  3. 重新进入拥塞避免阶段。

在快速重传的时候,一般网络只是轻微拥堵,在进入拥塞避免后,cwnd 恢复的比较慢。针对这个,“快速恢复”算法被添加进来,当收到 3 个冗余 ACK 时,TCP 最后的[3]步骤进入的不是拥塞避免阶段,而是快速恢复阶段。

快速恢复算法 – Fast Recovery :

快速恢复的思想是“数据包守恒”原则,即带宽不变的情况下,在网络同一时刻能容纳数据包数量是恒定的。当“老”数据包离开了网络后,就能向网络中发送一个“新”的数据包。既然已经收到了 3 个冗余 ACK,说明有三个数据分段已经到达了接收端,既然三个分段已经离开了网络,那么就是说可以在发送 3 个分段了。

于是只要发送方收到一个冗余的 ACK,于是 cwnd 加 1 个 MSS。快速恢复步骤如下(在进入快速恢复前,cwnd 和 sshthresh 已被更新为:sshthresh = cwnd /2,cwnd = sshthresh):1)把 cwnd 设置为 ssthresh 的值加 3,重传 Duplicated ACKs 指定的数据包;2)如果再收到 duplicated Acks,那么 cwnd = cwnd +1;3)如果收到新的 ACK,而非 duplicated Ack,那么将 cwnd 重新设置为【3】中 1)的 sshthresh 的值。然后进入拥塞避免状态。

细心的同学可能会发现快速恢复有个比较明显的缺陷就是:它依赖于 3 个冗余 ACK,并假定很多情况下,3 个冗余的 ACK 只代表丢失一个包。但是 3 个冗余 ACK 也很有可能是丢失了很多个包,快速恢复只是重传了一个包,然后其他丢失的包就只能等待到 RTO 超时了。超时会导致 ssthresh 减半,并且退出了 Fast Recovery 阶段,多个超时会导致 TCP 传输速率呈级数下降。出现这个问题的主要原因是过早退出了 Fast Recovery 阶段。

为解决这个问题,提出了 New Reno 算法,该算法是在没有 SACK 的支持下改进 Fast Recovery 算法(SACK 改变 TCP 的确认机制,把乱序等信息会全部告诉对方,SACK 本身携带的信息就可以使得发送方有足够的信息来知道需要重传哪些包,而不需要重传哪些包),具体改进如下:

  • 发送端收到 3 个冗余 ACK 后,重传冗余 ACK 指示可能丢失的那个包 segment1,如果 segment1 的 ACK 通告接收端已经收到发送端的全部已经发出的数据的话,那么就是只丢失一个包,如果没有,那么就是有多个包丢失了;
  • 发送端根据 segment1 的 ACK 判断出有多个包丢失,那么发送端继续重传窗口内未被 ACK 的第一个包,直到 sliding window 内发出去的包全被 ACK 了,才真正退出 Fast Recovery 阶段。

我们可以看到,拥塞控制在拥塞避免阶段,cwnd 是加性增加的,在判断出现拥塞的时候采取的是指数递减。为什么要这样做呢?这是出于公平性的原则,拥塞窗口的增加受惠的只是自己,而拥塞窗口减少受益的是大家。这种指数递减的方式实现了公平性,一旦出现丢包,那么立即减半退避,可以给其他新建的连接腾出足够的带宽空间,从而保证整个的公平性。

至此,TCP 的疑难杂症基本介绍完毕了,总的来说 TCP 是一个有连接的、可靠的、带流量控制和拥塞控制的端到端的协议。TCP 的发送端能发多少数据,由发送端的发送窗口决定(当然发送窗口又被接收端的接收窗口、发送端的拥塞窗口限制)的,那么一个 TCP 连接的传输稳定状态应该体现在发送端的发送窗口的稳定状态上,这样的话,TCP 的发送窗口有哪些稳定状态呢?TCP 的发送窗口稳定状态主要有上面三种稳定状态:

  • 接收端拥有大窗口的经典锯齿状:
    大多数情况下都是处于这样的稳定状态,这是因为,一般情况下机器的处理速度就是比较快,这样 TCP 的接收端都是拥有较大的窗口,这时发送端的发送窗口就完全由其拥塞窗口 cwnd 决定了;网络上拥有成千上万的 TCP 连接,它们在相互争用网络带宽,TCP 的流量控制使得它想要独享整个网络,而拥塞控制又限制其必要时做出牺牲来体现公平性。于是在传输稳定的时候 TCP 发送端呈现出下面过程的反复:
    • 用慢启动或者拥塞避免方式不断增加其拥塞窗口,直到丢包的发生;
    • 然后将发送窗口将下降到 1 或者下降一半,进入慢启动或者拥塞避免阶段(要看是由于超时丢包还是由于冗余 ACK 丢包);过程如下图:

  • 接收端拥有小窗口的直线状态:这种情况下是接收端非常慢速,接收窗口一直很小,这样发送窗口就完全有接收窗口决定了。由于发送窗口小,发送数据少,网络就不会出现拥塞了,于是发送窗口就一直稳定的等于那个较小的接收窗口,呈直线状态。
  • 两个直连网络端点间的满载状态下的直线状态:这种情况下,Peer 两端直连,并且只有位于一个 TCP 连接,那么这个连接将独享网络带宽,这里不存在拥塞问题,在他们处理能力足够的情况下,TCP 的流量控制使得他们能够跑慢整个网络带宽。

通过上面我们知道,在 TCP 传输稳定的时候,各个 TCP 连接会均分网络带宽的。在通信链路带宽固定(假设为 W),多人公用一个网络带宽的情况下,利用 TCP 协议的拥塞控制的公平性,多开几个 TCP 连接就能多分到一些带宽(当然要忽略有些用 UDP 协议带来的影响),

因为网络是网状的,一个节点是要和很多几点互联的,这就存在多个带宽为 W 的通信链路,如果我们能够将要下载的文件,一半从 A 通信链路下载,另外一半从 B 通信链路下载,这样整个下载时间就减半了为 FS/(2W),这就是 p2p 加速。

附加题 1:P2P 理论上的加速比

传统的 C/S 模式传输文件,在跑满 Client 带宽的情况下传输一个文件需要耗时 FS/BW,如果有 n 个客户端需要下载文件,那么总耗时是 n(FS/BW),当然啦,这并不一定是串行传输,可以并行来传输的,这样总耗时也就是 FS/BW 了,但是这需要服务器的带宽是 n 个 client 带宽的总和 nBW。C/S 模式一个明显的缺点是服务要传输一个文件 n 次,这样对服务器的性能和带宽带来比较大的压力,我可以换下思路,服务器将文件传给其中一个 Client 后,让这些互联的 Client 自己来交互那个文件,那服务器的压力就减少很多了。这就是 P2P 网络的好处,P2P 利用各个节点间的互联,提倡“人人为我,我为人人”。

知道 P2P 传输的好处后,我们来谈下理论上的最大加速比,为了简化讨论,一个简单的网络拓扑有 4 个相互互联的节点,并且每个节点间的网络带宽是 BW,传输一个大小为 FS 的文件最快的时间是多少呢?假设节点 N1 有个大小为 FS 的文件需要传输给 N2,N3,N4 节点,一种简单的方式就是:节点 N1 同时将文件传输给节点 N2,N3,N4 耗时 FS/BW,这样大家都拥有文件 FS 了。大家可以看出,整个过程只有节点 1 在发送文件,其他节点都是在接收,完全违反了 P2P 的“人人为我,我为人人”的宗旨。那怎么才能让大家都做出贡献了呢?解决方案是切割文件。

  1. 首先,节点 N1 文件分成 3 个片段 FS2,FS3,FS4,接着将 FS2 发送给 N2,FS3 发送给 N3,FS4 发送给 N4,耗时 FS/(3*BW)
  2. 然后,N2,N3,N4 执行“人人为我,我为人人”的精神,将自己拥有的 F2,F3,F4 分别发给没有的其他的节点,这样耗时 FS/(3*BW)完成交换。

于是总耗时为 2FS/(3BW)完成了文件 FS 的传输,可以看出耗时减少为原来的 2/3 了,如果有 n 个节点,那么时间就是原来的 2/(n-1),也就是加速比是 2/(n-1),这就是加速的理论上限了吗?还没发挥最多能量的,相信大家已经看到分割文件的好处了,上面的文件分割粒度还是有点大,以至于,在第二阶段[2]传输过程中,节点 N1 无所事事。为了最大化发挥大家的作用,我们需要将 FS2,FS3,FS4 在进行分割,假设将它们都均分为 K 等份,这样就有 FS21,FS22…FS2K、FS31,FS32…FS3K、FS41,FS42…FS4K,一共 3K 个分段。于是下面就开始进行加速分发:

  • 节点 N1 将分段 FS21,FS31,FS41 分别发送给 N2,N3,N4 节点。耗时,FS/(3K*BW)
  • 节点 N1 将分段 FS22,FS32,FS42 分别发送给 N2,N3,N4 节点,同时节点 N2,N3,N4 将阶段[1]收到的分段相互发给没有的节点。耗时,FS/(3K*BW)

[K]节点 N1 将分段 FS2K,FS3K,FS4K 分别发送给 N2,N3,N4 节点,同时节点 N2,N3,N4 将阶段[K-1]收到的分段相互发给没有的节点。耗时,FS/(3K*BW)。[K+1]节点 N2,N3,N4 将阶段[K]收到的分段相互发给没有的节点。耗时,FS/(3K*BW)。于是总的耗时为(K+1) (FS/(3KBW)) = FS/(3BW) +FS/(3KBW),当 K 趋于无穷大的时候,文件进行无限细分的时候,耗时变成了 FS/(3*BW),也就是当节点是 n+1 的时候,加速比是 n。这就是理论上的最大加速比了,最大加速比是 P2P 网络节点个数减 1。

附加题 2:系统调用 listen() 的 backlog 参数指的是什么

要说明 backlog 参数的含义,首先需要说一下 Linux 的协议栈维护的 TCP 连接的两个连接队列:[1]SYN 半连接队列;[2]accept 连接队列。

  • SYN 半连接队列:Server 端收到 Client 的 SYN 包并回复 SYN,ACK 包后,该连接的信息就会被移到一个队列,这个队列就是 SYN 半连接队列(此时 TCP 连接处于 非同步状态 )
  • accept 连接队列:Server 端收到 SYN,ACK 包的 ACK 包后,就会将连接信息从[1]中的队列移到另外一个队列,这个队列就是 accept 连接队列(这个时候 TCP 连接已经建立,三次握手完成了)。

用户进程调用 accept()系统调用后,该连接信息就会从[2]中的队列中移走。相信不少同学就 backlog 的具体含义进行争论过,有些认为 backlog 指的是[1]和[2]两个队列的和。而有些则认为是 backlog 指的是[2]的大小。其实,这两个说法都对,在 linux kernel 2.2 之前 backlog 指的是[1]和[2]两个队列的和。而 2.2 以后,就指的是[2]的大小,那么在 kernel 2.2 以后,[1]的大小怎么确定的呢?两个队列的作用分别是什么呢?

SYN 半连接队列的作用

对于 SYN 半连接队列的大小是由(/proc/sys/net/ipv4/tcp_max_syn_backlog)这个内核参数控制的,有些内核似乎也受 listen 的 backlog 参数影响,取得是两个值的最小值。当这个队列满了,Server 会丢弃新来的 SYN 包,而 Client 端在多次重发 SYN 包得不到响应而返回(connection time out)错误。但是,当 Server 端开启了 syncookies,那么 SYN 半连接队列就没有逻辑上的最大值了,并且/proc/sys/net/ipv4/tcp_max_syn_backlog 设置的值也会被忽略。

accept 连接队列

accept 连接队列的大小是由 backlog 参数和(/proc/sys/net/core/somaxconn)内核参数共同决定,取值为两个中的最小值。当 accept 连接队列满了,协议栈的行为根据(/proc/sys/net/ipv4/tcp_abort_on_overflow)内核参数而定。如果 tcp_abort_on_overflow=1,server 在收到 SYN_ACK 的 ACK 包后,协议栈会丢弃该连接并回复 RST 包给对端,这个是 Client 会出现(connection reset by peer)错误。如果 tcp_abort_on_overflow=0,server 在收到 SYN_ACK 的 ACK 包后,直接丢弃该 ACK 包。这个时候 Client 认为连接已经建立了,一直在等 Server 的数据,直到超时出现 read timeout 错误。

Leetcode401. Binary Watch

A binary watch has 4 LEDs on the top which represent the hours (0-11), and the 6 LEDs on the bottom represent the minutes (0-59). Each LED represents a zero or one, with the least significant bit on the right.

Given a non-negative integer n which represents the number of LEDs that are currently on, return all possible times the watch could represent.

Example:

1
2
Input: n = 1
Return: ["1:00", "2:00", "4:00", "8:00", "0:01", "0:02", "0:04", "0:08", "0:16", "0:32"]

Note:

  • The order of output does not matter.
  • The hour must not contain a leading zero, for example “01:00” is not valid, it should be “1:00”.
  • The minute must be consist of two digits and may contain a leading zero, for example “10:2” is not valid, it should be “10:02”.

首先先明白二进制手表的含义,把1,2,4,8转化为四位的二进制就是0001, 0010, 0100,1000, 9点时亮1和8,是1001。分钟数也是同理。
其次表示小时的数值只有0-11,表示分钟的数值只有0-59。先分别对小时跟分钟的数值进行预处理,按照包含而二进制中包含1的个数分开保存小时数值的字符串跟分钟数值的字符串。

用bitset可以方便地记下来每个数字有几个二进制1,这样可以简单地做出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<string> readBinaryWatch(int num) {
vector<string> res;
for(int i = 0; i < 12; i ++) {
bitset<4> h(i);
for(int j = 0; j < 60; j ++) {
bitset<6> m(j);
if(h.count() + m.count() == num)
res.push_back(to_string(i) + (j < 10? ":0": ":") + to_string(j));
}
}
return res;
}
};

基本还是一道DFS的题目,分别在小时和分钟上做DFS,给定几个灯亮,然后把这些亮的灯枚举分给小时和分钟.需要注意的是剪枝,即小时必须小于12,分钟小于60。然后将小时和分钟组合即可.还有一个需要注意的是如果分钟只有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
class Solution {
public:
void DFS(int len, int k, int curIndex, int val, vector<int>& vec)
{
if(k==0 && len==4 && val < 12) vec.push_back(val);
if(k==0 && len==6 && val < 60) vec.push_back(val);
if(curIndex == len || k == 0) return;
DFS(len, k, curIndex+1, val, vec);
val += pow(2, curIndex), k--, curIndex++;
DFS(len, k, curIndex, val, vec);
}

vector<string> readBinaryWatch(int num) {
vector<string> ans;
for(int i = max(0, num-6); i <= min(4, num); i++)
{
vector<int> vec1, vec2;
DFS(4, i, 0, 0, vec1), DFS(6, num-i, 0, 0, vec2);
for(auto val1: vec1)
for(auto val2: vec2)
{
string str = (to_string(val2).size()==1?"0":"") + to_string(val2);
ans.push_back(to_string(val1)+":"+ str);
}
}
return ans;
}
};

Leetcode402. Remove K Digits

Given string num representing a non-negative integer num, and an integer k, return the smallest possible integer after removing k digits from num.

Example 1:

1
2
3
Input: num = "1432219", k = 3
Output: "1219"
Explanation: Remove the three digits 4, 3, and 2 to form the new number 1219 which is the smallest.

Example 2:

1
2
3
Input: num = "10200", k = 1
Output: "200"
Explanation: Remove the leading 1 and the number is 200. Note that the output must not contain leading zeroes.

Example 3:

1
2
3
Input: num = "10", k = 2
Output: "0"
Explanation: Remove all the digits from the number and it is left with nothing which is 0.

这道题让我们将给定的数字去掉k位,要使得留下来的数字最小。

首先来考虑,若数字是递增的话,比如 1234,那么肯定是要从最后面移除最大的数字。若是乱序的时候,比如 1324,若只移除一个数字,移除谁呢?这个例子比较简单,我们一眼可以看出是移除3,变成 124 是最小。这里我们维护一个递增栈,只要发现当前的数字小于栈顶元素的话,就将栈顶元素移除,比如点那个遍历到2的时候,栈里面有1和3,此时2小于栈顶元素3,那么将3移除即可。为何一定要移除栈顶元素呢,后面说不定有更大的数字呢?这是因为此时栈顶元素在高位上,就算后面的数字再大,也是在低位上,我们只有将高位上的数字尽可能的变小,才能使整个剩下的数字尽可能的小。

我们开始遍历给定数字 num 的每一位,对于当前遍历到的数字c,进行如下 while 循环,如果 res 不为空,且k大于0,且 res 的最后一位大于c,那么应该将 res 的最后一位移去,且k自减1。当跳出 while 循环后,我们将c加入 res 中,最后将 res 的大小重设为 n-k。根据题目中的描述,可能会出现 “0200” 这样不符合要求的情况,所以我们用一个 while 循环来去掉前面的所有0,然后返回时判断是否为空,为空则返回 “0”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
string removeKdigits(string num, int k) {
string res = "";
int len = num.length();
for (int i = 0; i < len; i ++) {
while (k > 0 && res.length() > 0 && num[i] < res.back()) {
res.pop_back();
k --;
}
if (res.length() > 0 || num[i] != '0')
res += num[i];
}
while(res.size() > 0 && k--)
res.pop_back();
return res.empty() ? "0" : res;
}
};

Leetcode403. Frog Jump

A frog is crossing a river. The river is divided into x units and at each unit there may or may not exist a stone. The frog can jump on a stone, but it must not jump into the water.

Given a list of stones’ positions (in units) in sorted ascending order, determine if the frog is able to cross the river by landing on the last stone. Initially, the frog is on the first stone and assume the first jump must be 1 unit.

If the frog’s last jump was k units, then its next jump must be either k - 1, k , or k + 1 units. Note that the frog can only jump in the forward direction.

Note:

  • The number of stones is ≥ 2 and is < 1,100.
  • Each stone’s position will be a non-negative integer < 231.
  • The first stone’s position is always 0.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
[0,1,3,5,6,8,12,17]

There are a total of 8 stones.
The first stone at the 0th unit, second stone at the 1st unit,
third stone at the 3rd unit, and so on...
The last stone at the 17th unit.

Return true. The frog can jump to the last stone by jumping
1 unit to the 2nd stone, then 2 units to the 3rd stone, then
2 units to the 4th stone, then 3 units to the 6th stone,
4 units to the 7th stone, and 5 units to the 8th stone.

Example 2:

1
2
3
4
[0,1,2,3,4,8,9,11]

Return false. There is no way to jump to the last stone as
the gap between the 5th and 6th stone is too large.

题目中说青蛙如果上一次跳了k距离,那么下一次只能跳 k-1, k, 或 k+1 的距离,那么青蛙跳到某个石头上可能有多种跳法,由于这道题只是让判断青蛙是否能跳到最后一个石头上,并没有让返回所有的路径,这样就降低了一些难度。我们可以用递归来做,这里维护一个 HashMap,建立青蛙在 pos 位置和拥有 jump 跳跃能力时是否能跳到对岸。为了能用一个变量同时表示 pos 和 jump,可以将jump左移很多位并或上 pos,由于题目中对于位置大小有限制,所以不会产生冲突。首先判断 pos 是否已经到最后一个石头了,是的话直接返回 true;然后看当前这种情况是否已经出现在 HashMap 中,是的话直接从 HashMap 中取结果。如果没有,就遍历余下的所有石头,对于遍历到的石头,计算到当前石头的距离dist,如果距离小于 jump-1,接着遍历下一块石头;如果 dist 大于 jump+1,说明无法跳到下一块石头,m[key] 赋值为 false,并返回 false;如果在青蛙能跳到的范围中,调用递归函数,以新位置i为 pos,距离 dist 为 jump,如果返回 true 了,给 m[key] 赋值为 true,并返回 true。如果结束遍历给 m[key] 赋值为 false,并返回 false,参加代码如下:

解法一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool canCross(vector<int>& stones) {
unordered_map<int, bool> m;
return helper(stones, 0, 0, m);
}
bool helper(vector<int>& stones, int pos, int jump, unordered_map<int, bool>& m) {
int n = stones.size(), key = pos | jump << 11;
if (pos >= n - 1) return true;
if (m.count(key)) return m[key];
for (int i = pos + 1; i < n; ++i) {
int dist = stones[i] - stones[pos];
if (dist < jump - 1) continue;
if (dist > jump + 1) return m[key] = false;
if (helper(stones, i, dist, m)) return m[key] = true;
}
return m[key] = false;
}
};

我们也可以用迭代的方法来解,用一个 HashMap 来建立每个石头和在该位置上能跳的距离之间的映射,建立一个一维 dp 数组,其中 dp[i] 表示在位置为i的石头青蛙的弹跳力(只有青蛙能跳到该石头上,dp[i] 才大于0),由于题目中规定了第一个石头上青蛙跳的距离必须是1,为了跟后面的统一,对青蛙在第一块石头上的弹跳力初始化为0(虽然为0,但是由于题目上说青蛙最远能到其弹跳力+1的距离,所以仍然可以到达第二块石头)。这里用变量k表示当前石头,然后开始遍历剩余的石头,对于遍历到的石头i,来找到刚好能跳到i上的石头k,如果i和k的距离大于青蛙在k上的弹跳力+1,则说明青蛙在k上到不了i,则k自增1。从k遍历到i,如果青蛙能从中间某个石头上跳到i上,更新石头i上的弹跳力和最大弹跳力。这样当循环完成后,只要检查最后一个石头上青蛙的最大弹跳力是否大于0即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool canCross(vector<int>& stones) {
unordered_map<int, unordered_set<int>> m;
vector<int> dp(stones.size(), 0);
m[0].insert(0);
int k = 0;
for (int i = 1; i < stones.size(); ++i) {
while (dp[k] + 1 < stones[i] - stones[k]) ++k;
for (int j = k; j < i; ++j) {
int t = stones[i] - stones[j];
if (m[j].count(t - 1) || m[j].count(t) || m[j].count(t + 1)) {
m[i].insert(t);
dp[i] = max(dp[i], t);
}
}
}
return dp.back() > 0;
}
};

Leetcode404. Sum of Left Leaves

Find the sum of all left leaves in a given binary tree.

Example:

1
2
3
4
5
6
    3
/ \
9 20
/ \
15 7
There are two left leaves in the binary tree, with values 9 and 15 respectively. Return 24.

求二叉树的所有左叶子节点的和,判断是不是左叶子节点,加到对列中。
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 sumOfLeftLeaves(TreeNode* root) {
if(!root)
return 0;
if(!root->left && !root->right)
return 0;
queue<TreeNode*> q;
int res = 0;
q.push(root);
while(!q.empty()) {
TreeNode* temp = q.front();
q.pop();
if(temp->left)
q.push(temp->left);
if(temp->right && (temp->right->left || temp->right->right))
q.push(temp->right);
if(!temp->left && !temp->right)
res += temp->val;
}
return res;
}
};

Leetcode405. Convert a Number to Hexadecimal

Given an integer, write an algorithm to convert it to hexadecimal. For negative integer, two’s complement method is used.

Note:

  1. All letters in hexadecimal (a-f) must be in lowercase.
  2. The hexadecimal string must not contain extra leading 0s. If the number is zero, it is represented by a single zero character ‘0’; otherwise, the first character in the hexadecimal string will not be the zero character.
  3. The given number is guaranteed to fit within the range of a 32-bit signed integer.
  4. You must not use any method provided by the library which converts/formats the number to hex directly.

Example 1:

1
2
Input: 26
Output: "1a"

Example 2:
1
2
Input: -1
Output: "ffffffff"

十进制转十六进制,简单。
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:
string toHex(int num) {
string res = "";
if(num == 0)
return "0";
char digits[] = {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'};
if(num < 0) {
num = abs(num);
num = ~num + 1;
}
int count = 8;
while(count --) {
int temp = 15 & num;
num = num >> 4;
res = digits[temp] + res;
if(num == 0)
break;
cout << digits[temp] << " " << num << endl;
}
return res;
}
};

Leetcode406. Queue Reconstruction by Height

You are given an array of people, people, which are the attributes of some people in a queue (not necessarily in order). Each people[i] = [hi, ki] represents the ith person of height hi with exactly ki other people in front who have a height greater than or equal to hi.

Reconstruct and return the queue that is represented by the input array people. The returned queue should be formatted as an array queue, where queue[j] = [hj, kj] is the attributes of the jth person in the queue (queue[0] is the person at the front of the queue).

Example 1:

1
2
Input: people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
Output: [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]

Explanation:

  • Person 0 has height 5 with no other people taller or the same height in front.
  • Person 1 has height 7 with no other people taller or the same height in front.
  • Person 2 has height 5 with two persons taller or the same height in front, which is person 0 and 1.
  • Person 3 has height 6 with one person taller or the same height in front, which is person 1.
  • Person 4 has height 4 with four people taller or the same height in front, which are people 0, 1, 2, and 3.
  • Person 5 has height 7 with one person taller or the same height in front, which is person 1.
  • Hence [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] is the reconstructed queue.

这道题给了我们一个队列,队列中的每个元素是一个 pair,分别为身高和前面身高不低于当前身高的人的个数,让我们重新排列队列,使得每个 pair 的第二个参数都满足题意。首先来看一种超级简洁的方法,给队列先排个序,按照身高高的排前面,如果身高相同,则第二个数小的排前面。然后新建一个空的数组,遍历之前排好序的数组,然后根据每个元素的第二个数字,将其插入到 res 数组中对应的位置,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort(people.begin(), people.end(), [](vector<int>& a, vector<int>& b) {
return a[0] > b[0] || (a[0] == b[0] && a[1] < b[1]);
});
vector<vector<int>> res;
for (auto a : people) {
res.insert(res.begin() + a[1], a);
}
return res;
}
};

Leetcode407. Trapping Rain Water II

Given an m x n integer matrix heightMap representing the height of each unit cell in a 2D elevation map, return the volume of water it can trap after raining.

Example 1:

1
2
3
4
5
Input: heightMap = [[1,4,3,1,3,2],[3,2,1,3,2,4],[2,3,3,2,3,1]]
Output: 4
Explanation: After the rain, water is trapped between the blocks.
We have two small pounds 1 and 3 units trapped.
The total volume of water trapped is 4.

Example 2:

1
2
Input: heightMap = [[3,3,3,3,3],[3,2,2,2,3],[3,2,1,2,3],[3,2,2,2,3],[3,3,3,3,3]]
Output: 10

这道三维的,我们需要用 BFS 来做,解法思路很巧妙,下面我们就以题目中的例子来进行分析讲解,多图预警,手机流量党慎入:

首先我们应该能分析出,能装水的底面肯定不能在边界上,因为边界上的点无法封闭,那么所有边界上的点都可以加入 queue,当作 BFS 的启动点,同时我们需要一个二维数组来标记访问过的点,访问过的点我们用红色来表示,那么如下图所示:

我们再想想,怎么样可以成功的装进去水呢,是不是周围的高度都应该比当前的高度高,形成一个凹槽才能装水,而且装水量取决于周围最小的那个高度,有点像木桶原理的感觉,那么为了模拟这种方法,我们采用模拟海平面上升的方法来做,我们维护一个海平面高度 mx,初始化为最小值,从1开始往上升,那么我们 BFS 遍历的时候就需要从高度最小的格子开始遍历,那么我们的 queue 就不能使用普通队列了,而是使用优先级队列,将高度小的放在队首,最先取出,这样我们就可以遍历高度为1的三个格子,用绿色标记出来了,如下图所示:

如上图所示,向周围 BFS 搜索的条件是不能越界,且周围格子未被访问,那么可以看出上面的第一个和最后一个绿格子无法进一步搜索,只有第一行中间那个绿格子可以搜索,其周围有一个灰格子未被访问过,将其加入优先队列 queue 中,然后标记为红色,如下图所示:

那么优先队列 queue 中高度为1的格子遍历完了,此时海平面上升1,变为2,此时我们遍历优先队列 queue 中高度为2的格子,有3个,如下图绿色标记所示:

我们发现这三个绿格子周围的格子均已被访问过了,所以不做任何操作,海平面继续上升,变为3,遍历所有高度为3的格子,如下图绿色标记所示:

由于我们没有特别声明高度相同的格子在优先队列 queue 中的顺序,所以应该是随机的,其实谁先遍历到都一样,对结果没啥影响,我们就假设第一行的两个绿格子先遍历到,那么那么周围各有一个灰格子可以遍历,这两个灰格子比海平面低了,可以存水了,把存水量算出来加入结果 res 中,如下图所示:

上图中这两个遍历到的蓝格子会被加入优先队列 queue 中,由于它们的高度小,所以下一次从优先队列 queue 中取格子时,它们会被优先遍历到,那么左边的那个蓝格子进行BFS搜索,就会遍历到其左边的那个灰格子,由于其高度小于海平面,也可以存水,将存水量算出来加入结果 res 中,如下图所示:

等两个绿格子遍历结束了,它们会被标记为红色,蓝格子遍历会先被标记红色,然后加入优先队列 queue 中,由于其周围格子全变成红色了,所有不会有任何操作,如下图所示:

此时所有的格子都标记为红色了,海平面继续上升,继续遍历完优先队列 queue 中的格子,不过已经不会对结果有任何影响了,因为所有的格子都已经访问过了,此时等循环结束后返回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
class Solution {
public:
int trapRainWater(vector<vector<int>>& heights) {
if (heights.size() == 0)
return 0;
int m = heights.size(), n = heights[0].size(), res = 0;
vector<vector<bool> > visited(m, vector<bool>(n, false));
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> q;
vector<vector<int>> dir{{0,-1},{-1,0},{0,1},{1,0}};

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
if (i == 0 || i == m-1 || j == 0 || j == n-1) {
q.push({heights[i][j], i*n+j});
visited[i][j] = true;
}
int max_height = 0;
while(!q.empty()) {
pair<int, int> t = q.top();
q.pop();
int h = t.first, r = t.second / n, c = t.second % n;
max_height = max(max_height, h);
for (int i = 0; i < 4; i ++) {
int x = r + dir[i][0];
int y = c + dir[i][1];
if (x < 0 || x >= m || y < 0 || y >= n || visited[x][y])
continue;
visited[x][y] = true;
if (heights[x][y] < max_height)
res = res + (max_height - heights[x][y]);
q.push({heights[x][y], x*n+y});
}
}
return res;
}
};

Leetcode409. Longest Palindrome

Given a string which consists of lowercase or uppercase letters, find the length of the longest palindromes that can be built with those letters.

This is case sensitive, for example “Aa” is not considered a palindrome here.

Note:
Assume the length of given string will not exceed 1,010.

Example:

1
2
3
4
Input: "abccccdd"
Output: 7
Explanation:
One longest palindrome that can be built is "dccaccd", whose length is 7.

先统计每个字母的个数,然后如果这个字母是偶数个的话,可以放到回文里,如果是奇数的话,先放进去个数减一个,然后如果现在回文长度是偶数,那还可以加一个。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int longestPalindrome(string s) {
int ch[128] = {0};
for(char c : s)
ch[c]++;
int ans = 0;
for(int i : ch) {
ans += (i / 2 * 2);
if(ans % 2== 0 && i % 2 == 1)
ans ++;
}
return ans;
}
};

Leetcode410. Split Array Largest Sum

Given an array which consists of non-negative integers and an integer m , you can split the array into m non-empty continuous subarrays. Write an algorithm to minimize the largest sum among these m subarrays.

Note: Given m satisfies the following constraint: 1 ≤ m ≤ length(nums) ≤ 14,000.

Examples:

1
2
3
4
5
6
Input:
nums = [7,2,5,10,8]
m = 2

Output:
18

Explanation:

  • There are four ways to split nums into two subarrays.
  • The best way is to split it into [7,2,5] and [10,8],
  • where the largest sum among the two subarrays is only 18.

这道题给了我们一个非负数的数组 nums 和一个整数m,让把数组分割成m个非空的连续子数组,让最小化m个子数组中的最大值。

首先来分析,如果m和数组 nums 的个数相等,那么每个数组都是一个子数组,所以返回 nums 中最大的数字即可,如果m为1,那么整个 nums 数组就是一个子数组,返回 nums 所有数字之和,所以对于其他有效的m值,返回的值必定在上面两个值之间,所以可以用二分搜索法来做。用一个例子来分析,nums = [1, 2, 3, 4, 5], m = 3,将 left 设为数组中的最大值5,right 设为数字之和 15,然后算出中间数为 10,接下来要做的是找出和最大且小于等于 10 的子数组的个数,[1, 2, 3, 4], [5],可以看到无法分为3组,说明 mid 偏大,所以让 right=mid,然后再次进行二分查找,算出 mid=7,再次找出和最大且小于等于7的子数组的个数,[1,2,3], [4], [5],成功的找出了三组,说明 mid 还可以进一步降低,让 right=mid,再次进行二分查找,算出 mid=6,再次找出和最大且小于等于6的子数组的个数,[1,2,3], [4], [5],成功的找出了三组,尝试着继续降低 mid,让 right=mid,再次进行二分查找,算出 mid=5,再次找出和最大且小于等于5的子数组的个数,[1,2], [3], [4], [5],发现有4组,此时的 mid 太小了,应该增大 mid,让 left=mid+1,此时 left=6,right=6,循环退出了,返回 right 即可,参见代码如下:

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 splitArray(vector<int>& nums, int m) {
long left = 0, right = 0;
for (int i = 0; i < nums.size(); ++i) {
left = max(left, (long)nums[i]);
right += nums[i];
}
while (left < right) {
long long mid = left + (right - left) / 2;
if (can_split(nums, m, mid)) right = mid;
else left = mid + 1;
}
return right;
}
bool can_split(vector<int>& nums, long m, long sum) {
long cnt = 1, curSum = 0;
for (int i = 0; i < nums.size(); ++i) {
curSum += nums[i];
if (curSum > sum) {
curSum = nums[i];
++cnt;
if (cnt > m) return false;
}
}
return true;
}
};

上面的解法相对来说比较难想,在热心网友 perthblank 的提醒下,再来看一种 DP 的解法,相对来说,这种方法应该更容易理解一些。建立一个二维数组 dp,其中 dp[i][j] 表示将数组中前j个数字分成i组所能得到的最小的各个子数组中最大值,初始化为整型最大值,如果无法分为i组,那么还是保持为整型最大值。为了能快速的算出子数组之和,还是要建立累计和数组,难点就是在于推导状态转移方程了。

来分析一下,如果前j个数字要分成i组,那么i的范围是什么,由于只有j个数字,如果每个数字都是单独的一组,那么最多有j组;如果将整个数组看为一个整体,那么最少有1组,所以i的范围是[1, j],所以要遍历这中间所有的情况,假如中间任意一个位置k,dp[i-1][k] 表示数组中前k个数字分成 i-1 组所能得到的最小的各个子数组中最大值,而 sums[j]-sums[k] 就是后面的数字之和,取二者之间的较大值,然后和 dp[i][j] 原有值进行对比,更新 dp[i][j] 为二者之中的较小值,这样k在 [1, j] 的范围内扫过一遍,dp[i][j] 就能更新到最小值,最终返回 dp[m][n] 即可,博主认为这道题所用的思想应该是之前那道题 Reverse Pairs 中解法二中总结的分割重现关系 (Partition Recurrence Relation),由此看来很多问题的本质都是一样,但是披上华丽的外衣,难免会让人有些眼花缭乱了,参见代码如下:

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 splitArray(vector<int>& nums, int m) {
int n = nums.size();
vector<long> sums(n + 1);
vector<vector<long>> dp(m + 1, vector<long>(n + 1, LONG_MAX));
dp[0][0] = 0;
for (int i = 1; i <= n; ++i) {
sums[i] = sums[i - 1] + nums[i - 1];
}
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
for (int k = i - 1; k < j; ++k) {
long val = max(dp[i - 1][k], sums[j] - sums[k]);
dp[i][j] = min(dp[i][j], val);
}
}
}
return dp[m][n];
}
};

Leetcode412. Fizz Buzz

Write a program that outputs the string representation of numbers from 1 to n.

But for multiples of three it should output “Fizz” instead of the number and for the multiples of five output “Buzz”. For numbers which are multiples of both three and five output “FizzBuzz”.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
n = 15,
Return:
[
"1",
"2",
"Fizz",
"4",
"Buzz",
"Fizz",
"7",
"8",
"Fizz",
"Buzz",
"11",
"Fizz",
"13",
"14",
"FizzBuzz"
]

太简单了浪费时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<string> fizzBuzz(int n) {
vector<string> res;
if(n == 0)
return res;
for(int i = 0; i < n; i ++)
{
if((i + 1) % 3 == 0 && (i + 1) % 5 == 0)
res.push_back("FizzBuzz");
else if((i + 1) % 3 == 0)
res.push_back("Fizz");
else if((i + 1) % 5 == 0)
res.push_back("Buzz");
else
res.push_back(to_string(i + 1));
}
return res;
}
};

Leetcode413. Arithmetic Slices

An integer array is called arithmetic if it consists of at least three elements and if the difference between any two consecutive elements is the same.

For example, [1,3,5,7,9], [7,7,7,7], and [3,-1,-5,-9] are arithmetic sequences. Given an integer array nums, return the number of arithmetic subarrays of nums.

A subarray is a contiguous subsequence of the array.

Example 1:

1
2
3
Input: nums = [1,2,3,4]
Output: 3
Explanation: We have 3 arithmetic slices in nums: [1, 2, 3], [2, 3, 4] and [1,2,3,4] itself.

Example 2:

1
2
Input: nums = [1]
Output: 0

这道题让我们算一种算数切片,说白了就是找等差数列,限定了等差数列的长度至少为3,那么[1,2,3,4]含有3个长度至少为3的算数切片,我们再来看[1,2,3,4,5]有多少个呢:
len = 3: [1,2,3], [2,3,4], [3,4,5]

len = 4: [1,2,3,4], [2,3,4,5]

len = 5: [1,2,3,4,5]

那么我们可以归纳出规律,长度为n的等差数列有1个,长度为n-1的等差数列有2个,… ,长度为3的等差数列有 n-2 个,那么总共就是 1 + 2 + 3 + … + n-2 ,此时就要祭出高斯求和公式了,长度为n的等差数列中含有长度至少为3的算数切片的个数为(n-1)(n-2)/2,那么题目就变成了找原数组中等差数列的长度,然后带入公式去算个数即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int numberOfArithmeticSlices(vector<int>& A) {
int res = 0, len = 2, n = A.size();
for (int i = 2; i < n; ++i) {
if (A[i] - A[i - 1] == A[i - 1] - A[i - 2]) {
++len;
} else {
if (len > 2) res += (len - 1) * (len - 2) * 0.5;
len = 2;
}
}
if (len > 2) res += (len - 1) * (len - 2) * 0.5;
return res;
}
};

Leetcode414. Third Maximum Number

Given a non-empty array of integers, return the third maximum number in this array. If it does not exist, return the maximum number. The time complexity must be in O(n).

Example 1:

1
2
3
Input: [3, 2, 1]
Output: 1
Explanation: The third maximum is 1.

Example 2:
1
2
3
Input: [1, 2]
Output: 2
Explanation: The third maximum does not exist, so the maximum (2) is returned instead.

Example 3:
1
2
3
4
Input: [2, 2, 3, 1]
Output: 1
Explanation: Note that the third maximum here means the third maximum distinct number.
Both numbers with value 2 are both considered as second maximum.

遍历数组,通过跟三个变量(max, mid, min)的比较,来交换它们之间数字。
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:
int thirdMax(vector<int>& nums) {
long min = LONG_MIN, mid = LONG_MIN, max = LONG_MIN;
int count = 0;
for(int i = 0; i < nums.size(); i ++) {
if(nums[i] == max || nums[i] == mid)
continue;
if(nums[i] > max) {
min = mid;
mid = max;
max = nums[i];
count ++;
}
else if(nums[i] > mid) {
min = mid;
mid = nums[i];
count ++;
}
else if(nums[i] >= min) {
min = nums[i];
count ++;
}
}

if(count >= 3)
return min;
else return max;
}
};

Leetcode415. Add Strings

Given two non-negative integers num1 and num2 represented as string, return the sum of num1 and num2.

Note:

  • The length of both num1 and num2 is < 5100.
  • Both num1 and num2 contains only digits 0-9.
  • Both num1 and num2 does not contain any leading zero.
  • You must not use any built-in BigInteger library or convert the inputs to integer directly.

简单模拟,做的及其纠结。

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
class Solution {
public:
string addStrings(string nums1, string nums2) {
if(nums1.length() < nums2.length()) {
string temp = nums1;
nums1 = nums2;
nums2 = temp;
}
int i, j;
for(i = nums1.length()-1, j = nums2.length()-1; i >= 0 && j >= 0; i --, j --) {
nums1[i] = nums1[i] + nums2[j] - 48;
if(nums1[i] > 57) {
if(i == 0)
break;
nums1[i] -= 10;
nums1[i - 1] = nums1[i - 1] + 1;
}
}
if(i < 0) {
i ++;
if(nums1[i] > 57) {
nums1[i] -= 10;
if(i == 0)
nums1 = "1" + nums1;
else
nums1[i - 1] ++;
}
}
else {
while(i >= 0 && nums1[i] > 57) {
nums1[i] -= 10;
if(i == 0)
nums1 = "1" + nums1;
else
nums1[i - 1] ++;
i --;
}
}
return nums1;
}
};

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:
string addStrings(string num1, string num2) {
// computing lengths of both strings
int num1length = num1.length() - 1;
int num2length = num2.length()- 1;

// solution string
string sol = "";

// remainder for when summing two numbers
int remainder = 0;

// while both strings haven't been consumed
while (num1length >= 0 || num2length >= 0) {

// get current characters of iteration
int current_num1 = (num1length >= 0) ? num1.at(num1length) - '0' : 0;
int current_num2 = (num2length >= 0) ? num2.at(num2length) - '0' : 0;

// appending sum and remainder from previous to solution string
int sum = current_num1 + current_num2 + remainder;
sol = std::to_string(sum % 10) + sol;

// determining whether there's a remainder for next sum
remainder = (sum > 9) ? 1 : 0;

// decrementing for next addition
num1length--;
num2length--;

}

// append final remainder if there is one
sol = (remainder == 1) ? std::to_string(1) + sol : sol;

return sol;
}
};

Leetcode416. Partition Equal Subset Sum

Given a non-empty array nums containing only positive integers, find if the array can be partitioned into two subsets such that the sum of elements in both subsets is equal.

Example 1:

1
2
3
Input: nums = [1,5,11,5]
Output: true
Explanation: The array can be partitioned as [1, 5, 5] and [11].

Example 2:

1
2
3
Input: nums = [1,2,3,5]
Output: false
Explanation: The array cannot be partitioned into equal sum subsets.

这道题给了我们一个数组,问这个数组能不能分成两个非空子集合,使得两个子集合的元素之和相同。那么想,原数组所有数字和一定是偶数,不然根本无法拆成两个和相同的子集合,只需要算出原数组的数字之和,然后除以2,就是 target,那么问题就转换为能不能找到一个非空子集合,使得其数字之和为 target。开始博主想的是遍历所有子集合,算和,但是这种方法无法通过 OJ 的大数据集合。于是乎,动态规划 Dynamic Programming 就是不二之选。定义一个一维的 dp 数组,其中 dp[i] 表示原数组是否可以取出若干个数字,其和为i。那么最后只需要返回 dp[target] 就行了。

初始化 dp[0] 为 true,由于题目中限制了所有数字为正数,就不用担心会出现和为0或者负数的情况。关键问题就是要找出状态转移方程了,需要遍历原数组中的数字,对于遍历到的每个数字 nums[i],需要更新 dp 数组,既然最终目标是想知道 dp[target] 的 boolean 值,就要想办法用数组中的数字去凑出 target,因为都是正数,所以只会越加越大,加上 nums[i] 就有可能会组成区间 [nums[i], target] 中的某个值,那么对于这个区间中的任意一个数字j,如果 dp[j - nums[i]] 为 true 的话,说明现在已经可以组成 j-nums[i] 这个数字了,再加上 nums[i],就可以组成数字j了,那么 dp[j] 就一定为 true。如果之前 dp[j] 已经为 true 了,当然还要保持 true,所以还要 ‘或’ 上自身,于是状态转移方程如下:

1
dp[j] = dp[j] || dp[j - nums[i]]         (nums[i] <= j <= target)

有了状态转移方程,就可以写出代码了,这里需要特别注意的是,第二个 for 循环一定要从 target 遍历到 nums[i],而不能反过来,想想为什么呢?因为如果从 nums[i] 遍历到 target 的话,假如 nums[i]=1 的话,那么 [1, target] 中所有的 dp 值都是 true,因为 dp[0] 是 true,dp[1] 会或上 dp[0],为 true,dp[2] 会或上 dp[1],为 true,依此类推,完全使的 dp 数组失效了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0), target = sum >> 1;
if (sum & 1) return false;
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int num : nums) {
for (int i = target; i >= num; --i) {
dp[i] = dp[i] || dp[i - num];
}
}
return dp[target];
}
};

Leetcode417. Pacific Atlantic Water Flow

There is an m x n rectangular island that borders both the Pacific Ocean and Atlantic Ocean. The Pacific Ocean touches the island’s left and top edges, and the Atlantic Ocean touches the island’s right and bottom edges.

The island is partitioned into a grid of square cells. You are given an m x n integer matrix heights where heights[r][c] represents the height above sea level of the cell at coordinate (r, c).

The island receives a lot of rain, and the rain water can flow to neighboring cells directly north, south, east, and west if the neighboring cell’s height is less than or equal to the current cell’s height. Water can flow from any cell adjacent to an ocean into the ocean.

Return a 2D list of grid coordinates result where result[i] = [ri, ci] denotes that rain water can flow from cell (ri, ci) to both the Pacific and Atlantic oceans.

Example 1:

1
2
Input: heights = [[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]
Output: [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]

Example 2:

1
2
Input: heights = [[2,1],[1,2]]
Output: [[0,0],[0,1],[1,0],[1,1]]

上面一条边和左边一条边代表的是太平洋,右边一条边和下边一条边代表的是大西洋。现在告诉你水往低处流,问哪些位置的水能同时流进太平洋和大西洋?

直接DFS求解。一般来说DFS需要有固定的起点,但是对于这个题,四条边界的每个位置都算作起点。

使用两个二维数组,分别记录每个位置的点能不能到达太平洋和大西洋。然后对4条边界进行遍历,看这些以这些边为起点能不能所有的地方。注意了,因为是从边界向中间去寻找,所以,这个时候是新的点要比当前的点海拔高才行。

最坏情况下的时间复杂度是O((M+N)*MN),空间复杂度是O(MN)。

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
class Solution {
public:
vector<vector<int>> pacificAtlantic(vector<vector<int>>& heights) {
int m = heights.size();
if (m == 0)
return {};
int n = heights[0].size();
vector<vector<int>> res;
vector<vector<bool>> p_flags(m, vector<bool>(n ,false));
vector<vector<bool>> a_flags(m, vector<bool>(n ,false));

for (int i = 0; i < m; i ++) {
dfs(heights, p_flags, i, 0);
dfs(heights, a_flags, i, n-1);
}
for (int i = 0; i < n; i ++) {
dfs(heights, p_flags, 0, i);
dfs(heights, a_flags, m-1, i);
}

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
if (p_flags[i][j] && a_flags[i][j]) {
vector<int> t;
t.push_back(i);
t.push_back(j);
res.push_back(t);
}
return res;
}

void dfs(vector<vector<int>>& heights, vector<vector<bool>>& flags, int i, int j) {
int m = heights.size(), n = heights[0].size();
vector<pair<int, int>> dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
flags[i][j] = true;
for (int ii = 0; ii < 4; ii ++) {
int x = i + dirs[ii].first;
int y = j + dirs[ii].second;
if (x >= 0 && x < m && y >= 0 && y < n && !flags[x][y] && heights[x][y] >= heights[i][j]) {
flags[x][y] = true;
dfs(heights, flags, x, y);
}
}
}
};

Leetcode419. Battleships in a Board

Given an 2D board, count how many battleships are in it. The battleships are represented with ‘X’s, empty slots are represented with ‘.’s. You may assume the following rules:
You receive a valid board, made of only battleships or empty slots.
Battleships can only be placed horizontally or vertically. In other words, they can only be made of the shape 1xN (1 row, N columns) or Nx1 (N rows, 1 column), where N can be of any size.
At least one horizontal or vertical cell separates between two battleships - there are no adjacent battleships.
Example:

1
2
3
X..X
...X
...X

In the above board there are 2 battleships.
Invalid Example:
1
2
3
...X
XXXX
...X

This is an invalid board that you will not receive - as battleships will always have a cell separating between them.
Follow up:
Could you do it in one-pass, using only O(1) extra memory and without modifying the value of the board?

利用最简单的方法找有多少个X块,这里用的遍历很方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int countBattleships(vector<vector<char>>& board) {
int x = board.size();
int y = board[0].size();
int res=0;
for(int i=0;i<x;i++)
for(int j=0;j<y;j++)
if(board[i][j]=='X'){
if(j<y-1 && board[i][j+1]=='X') continue;
if(i<x-1 && board[i+1][j]=='X') continue;
res++;
}
return res;
}
};

Leetcode421. Maximum XOR of Two Numbers in an Array

Given an integer array nums, return the maximum result of nums[i] XOR nums[j], where 0 <= i <= j < n.

Example 1:

1
2
3
Input: nums = [3,10,5,25,2,8]
Output: 28
Explanation: The maximum result is 5 XOR 25 = 28.

Example 2:

1
2
Input: nums = [14,70,53,83,49,91,36,80,92,51,66,70]
Output: 127

Constraints:

  • 1 <= nums.length <= 2 * 105
  • 0 <= nums[i] <= 231 - 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

class Solution {
public:

struct Node {
int son[2];
Node() {
son[0] = son[1] = -1;
}
};

vector<Node> nodes;

int findMaximumXOR(vector<int>& a) {
// 让异或的结果最高位尽可能大
// 从高位到低位考虑,ai当前这个位为0,那希望aj当前位为1
// 用前缀树

if (a.size() == 0)
return 0;

nodes.push_back({});
insert(a.back());

int ret = 0;
for (int i = a.size()-2; i >= 0; i --) {
int ans = query(a[i]);
ret = max(ret, ans);
insert(a[i]);
}
return ret;
}

void insert(int a) {
int id = 0;
for (int i = 31; i >= 0; i --) {
int t = (a >> i) & 1;
if (nodes[id].son[t] == -1) {
nodes.push_back({});
nodes[id].son[t] = nodes.size()-1;
}
id = nodes[id].son[t];
}
}

int query(int x) {
int ret = 0;
int id = 0;
for (int i = 31; i >= 0; i --) {
int b = (x >> i) & 1;
int b2 = b ^ 1;
if (nodes[id].son[b2] != -1) {
id = nodes[id].son[b2];
ret |= (1 << i);
}
else {
id = nodes[id].son[b];
}
}
return ret;
}
};

Leetcode423. Reconstruct Original Digits from English

Given a non-empty string containing an out-of-order English representation of digits 0-9, output the digits in ascending order.

Note:

  • Input contains only lowercase English letters.
  • Input is guaranteed to be valid and can be transformed to its original digits. That means invalid inputs such as “abc” or “zerone” are not permitted.
  • Input length is less than 50,000.

Example 1:

1
2
Input: "owoztneoer"
Output: "012"

Example 2:

1
2
Input: "fviefuro"
Output: "45"

这道题给了我们一串英文字符串,是由表示数字的英文单词组成的,不过字符顺序是打乱的,让我们重建出数字。那么这道题的思路是先要统计出各个字符出现的次数,然后算出每个单词出现的次数,然后就可以重建了。由于题目中限定了输入的字符串一定是有效的,那么不会出现无法成功重建的情况,这里需要用个trick。

我们仔细观察这些表示数字的单词”zero”, “one”, “two”, “three”, “four”, “five”, “six”, “seven”, “eight”, “nine”,我们可以发现有些的单词的字符是独一无二的,比如z,只出现在zero中,还有w,u,x,g这四个单词,分别只出现在two,four,six,eight中,那么这五个数字的个数就可以被确定了,由于含有o的单词有zero,two,four,one,其中前三个都被确定了,那么one的个数也就知道了;由于含有h的单词有eight,three,其中eight个数已知,那么three的个数就知道了;由于含有f的单词有four,five,其中four个数已知,那么five的个数就知道了;由于含有s的单词有six,seven,其中six个数已知,那么seven的个数就知道了;由于含有i的单词有six,eight,five,nine,其中前三个都被确定了,那么nine的个数就知道了。

知道了这些问题就变的容易多了,我们按这个顺序”zero”, “two”, “four”, “six”, “eight”, “one”, “three”, “five”, “seven”, “nine”就能找出所有的个数了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
string originalDigits(string s) {
string res = "";
vector<string> words{"zero", "two", "four", "six", "eight", "one", "three", "five", "seven", "nine"};
vector<int> nums{0, 2, 4, 6, 8, 1, 3, 5, 7, 9}, counts(26, 0);
vector<char> chars{'z', 'w', 'u', 'x', 'g', 'o', 'h', 'f', 's', 'i'};
for (char c : s) ++counts[c - 'a'];
for (int i = 0; i < 10; ++i) {
int cnt = counts[chars[i] - 'a'];
for (int j = 0; j < words[i].size(); ++j) {
counts[words[i][j] - 'a'] -= cnt;
}
while (cnt--) res += (nums[i] + '0');
}
sort(res.begin(), res.end());
return res;
}
};

Leetcode424. Longest Repeating Character Replacement

Given a string that consists of only uppercase English letters, you can replace any letter in the string with another letter at most k times. Find the length of a longest substring containing all repeating letters you can get after performing the above operations.

Note:
Both the string’s length and k will not exceed 10 4.

Example 1:

1
2
3
4
5
6
7
8
Input:
s = "ABAB", k = 2

Output:
4

Explanation:
Replace the two 'A's with two 'B's or vice versa.

Example 2:

1
2
3
4
5
6
7
8
9
Input:
s = "AABABBA", k = 1

Output:
4

Explanation:
Replace the one 'A' in the middle with 'B' and form "AABBBBA".
The substring "BBBB" has the longest repeating letters, which is 4.

这道题给我们了一个字符串,说我们有k次随意置换任意字符的机会,让我们找出最长的重复字符的字符串。我们首先来想,如果没有k的限制,让我们求把字符串变成只有一个字符重复的字符串需要的最小置换次数,那么就是字符串的总长度减去出现次数最多的字符的个数。如果加上k的限制,我们其实就是求满足 (子字符串的长度减去出现次数最多的字符个数)<=k 的最大子字符串长度即可,搞清了这一点,我们也就应该知道怎么用滑动窗口来解了吧。我们用一个变量 start 记录滑动窗口左边界,初始化为0,然后遍历字符串,每次累加出现字符的个数,然后更新出现最多字符的个数,然后我们判断当前滑动窗口是否满足之前说的那个条件,如果不满足,我们就把滑动窗口左边界向右移动一个,并注意去掉的字符要在 counts 里减一,直到满足条件,我们更新结果 res 即可。需要注意的是,当滑动窗口的左边界向右移动了后,窗口内的相同字母的最大个数貌似可能会改变啊,为啥这里不用更新 maxCnt 呢?这是个好问题,原因是此题让求的是最长的重复子串,maxCnt 相当于卡了一个窗口大小,我们并不希望窗口变小,虽然窗口在滑动,但是之前是出现过跟窗口大小相同的符合题意的子串,缩小窗口没有意义,并不会使结果 res 变大,所以我们才不更新 maxCnt 的,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int characterReplacement(string s, int k) {
int res = 0, maxCnt = 0, start = 0;
vector<int> counts(26, 0);
for (int i = 0; i < s.size(); ++i) {
maxCnt = max(maxCnt, ++counts[s[i] - 'A']);
while (i - start + 1 - maxCnt > k) {
--counts[s[start] - 'A'];
++start;
}
res = max(res, i - start + 1);
}
return res;
}
};

Leetcode427. Construct Quad Tree

Given a n * n matrix grid of 0’s and 1’s only. We want to represent the grid with a Quad-Tree.

Return the root of the Quad-Tree representing the grid.

Notice that you can assign the value of a node to True or False when isLeaf is False, and both are accepted in the answer.

A Quad-Tree is a tree data structure in which each internal node has exactly four children. Besides, each node has two attributes:

  • val: True if the node represents a grid of 1’s or False if the node represents a grid of 0’s.
  • isLeaf: True if the node is leaf node on the tree or False if the node has the four children.
1
2
3
4
5
6
7
8
class Node {
public boolean val;
public boolean isLeaf;
public Node topLeft;
public Node topRight;
public Node bottomLeft;
public Node bottomRight;
}

We can construct a Quad-Tree from a two-dimensional area using the following steps:

If the current grid has the same value (i.e all 1’s or all 0’s) set isLeaf True and set val to the value of the grid and set the four children to Null and stop.

Example 1:

1
2
3
4
5
6
Input: grid = [[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0]]
Output: [[0,1],[1,1],[0,1],[1,1],[1,0],null,null,null,null,[1,0],[1,0],[1,1],[1,1]]
Explanation: All values in the grid are not the same. We divide the grid into four sub-grids.
The topLeft, bottomLeft and bottomRight each has the same value.
The topRight have different values so we divide it into 4 sub-grids where each has the same value.
Explanation is shown in the photo below:

这道题让我们根据一个二维数组来建立一棵四叉树,首先整个数组被分成了四等份,左上,左下,和右下部分内的值均相同,那么他们都是一个叶结点,而右上只有再四等分一下,才能使各自部分内的值相同,所以其就不是叶结点,而四等分后的每个区间才是叶结点。题目中限定了N的值一定是2的指数,就是说其如果可分的话,一定可以四等分,而之前说了,只有区间内的值不同时,才需要四等分,否则整体就当作一个叶结点。所以我们需要check四等分区间内的值是否相同,当然,我们可以将二维数组拆分为四个二维数组,但是那样可能不太高效,而且还占用额外空间,一个比较好的选择是用坐标变量来控制等分数组的范围,我们只需要一个起始点坐标,和区间的长度,就可以精确定位一个区间了。

比如说对于例子中的整个二维数组数组来说,知道起始点坐标 (0, 0),还有长度8,就知道表示的是哪个区间。我们可以遍历这个区间上的其他所有的点,跟起点对比,只要有任何点跟起点不相同,则说明该区间是可分的,因为我们前面说了,只有一个区间上所有的值均相同,才能当作一个叶结点。只要有不同,就表示可以四分,那么我们就新建一个结点,这里的左上,左下,右上,和右下四个子结点就需要用过调用递归函数来实现了,实现原理都一样。

对于非叶结点,结点值可以是true或者false都没问题。如果某个区间上所有值均相同,那么就生成一个叶结点,结点值就跟区间值相同,isLeaf是true,四个子结点均为NULL即可,参见代码如下:

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:
Node* construct(vector<vector<int>>& grid) {
return build(grid, 0, 0, grid.size()-1, grid.size()-1);
}

Node* build(vector<vector<int>>& grid, int r0, int c0, int r1, int c1) {
if (r0 > r1 || c0 > c1)
return NULL;
bool isleaf = true;
int val = grid[r0][c0];
for (int i = r0; i <= r1; i ++)
for (int j = c0; j <= c1; j ++)
if (grid[i][j] != val) {
isleaf = false;
break;
}
if (isleaf)
return new Node(val == 1, true, NULL, NULL, NULL, NULL);
int mid1 = (r0+r1) / 2, mid2 = (c0+c1) / 2;
return new Node(false, false,
build(grid, r0, c0, mid1, mid2),
build(grid, r0, mid2+1, mid1, c1),
build(grid, mid1+1, c0, r1, mid2),
build(grid, mid1+1, mid2+1, r1, c1));
}
};

Leetcode429. N-ary Tree Level Order Traversal

Given an n-ary tree, return the level order traversal of its nodes’ values. (ie, from left to right, level by level).

Note:

  • The depth of the tree is at most 1000.
  • The total number of nodes is at most 5000.

这道题给了我们一棵N叉树,让我们对其进行层序遍历。虽说现在每一个结点可能有很多个子结点,但其实处理的思路的都是一样的。子结点放到了一个children数组中,我们访问的时候只要遍历数组就行了。先来看迭代的写法,用到了队列queue来辅助,首先判断root是否为空,为空直接返回空数组,否则加入queue中。然后遍历queue,这里用的trick就是,要加个for循环,要将当前queue中的结点的个数统计下来,因为再加入下一层的结点时,queue的结点个数会增加,而在加入下一层结点之前,当前queue中的结点个数全都属于一层,所以我们要把层与层区分开来,将同一层的结点都放到一个数组out中,之后再放入结果res中,这种层序遍历的思想在迷宫遍历找最短路径的时候应用的也很多,是个必须要掌握的方法呢,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<vector<int>> levelOrder(Node* root) {
if (!root) return {};
vector<vector<int>> res;
queue<Node*> q{{root}};
while (!q.empty()) {
vector<int> out;
for (int i = q.size(); i > 0; --i) {
auto t = q.front(); q.pop();
out.push_back(t->val);
if (!t->children.empty()) {
for (auto a : t->children) q.push(a);
}
}
res.push_back(out);
}
return res;
}
};

Leetcode430. Flatten a Multilevel Doubly Linked List

You are given a doubly linked list which in addition to the next and previous pointers, it could have a child pointer, which may or may not point to a separate doubly linked list. These child lists may have one or more children of their own, and so on, to produce a multilevel data structure, as shown in the example below.

Flatten the list so that all the nodes appear in a single-level, doubly linked list. You are given the head of the first level of the list.

Example 1:

1
2
Input: head = [1,2,3,4,5,6,null,null,null,7,8,9,10,null,null,11,12]
Output: [1,2,3,7,8,11,12,9,10,4,5,6]

Explanation:

The multilevel linked list in the input is as follows:

Example 2:

1
2
Input: head = [1,2,null,3]
Output: [1,3,2]

Explanation:

1
2
3
4
5
The input multilevel linked list is as follows:

1---2---NULL
|
3---NULL

这道题给了一个多层的双向链表,让我们压平成为一层的双向链表,题目中给了形象的图例,不难理解题意。根据题目中给的例子,我们可以看出如果某个结点有下一层双向链表,那么下一层双向链表中的结点就要先加入进去,如果下一层链表中某个结点还有下一层,那么还是优先加入下一层的结点,整个加入的机制是DFS的,就是有岔路先走岔路,走到没路了后再返回,这就是深度优先遍历的机制。好,那么既然是DFS,肯定优先考虑递归啦。方法有了,再来看具体怎么递归。由于给定的多层链表本身就是双向的,所以我们只需要把下一层的结点移到第一层即可,那么没有子结点的结点就保持原状,不作处理。只有对于那些有子结点的,我们需要做一些处理,由于子结点链接的双向链表要加到后面,所以当前结点之后要断开,再断开之前,我们用变量 next 指向下一个链表,然后对子结点调用递归函数,我们 suppose 返回的结点已经压平了,那么就只有一层,就相当于要把这一层的结点加到断开的地方,所以需要知道这层的最后一个结点的位置,我们用一个变量 last,来遍历到压平的这一层的末结点。现在就可以开始链接了,首先把子结点链到 cur 的 next,然后把反向指针 prev 也链上。此时 cur 的子结点 child 可以清空,然后压平的这一层的末节点 last 链上之前保存的 next 结点,如果 next 非空,那么链上反向结点 prev。这些操作完成后,我们就已经将压平的这一层完整的加入了之前层断开的地方,继续在之前层往下遍历即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
Node* flatten(Node* head) {
Node *cur = head;
while (cur) {
if (cur->child) {
Node *next = cur->next;
Node *last = cur->child;
while (last->next) last = last->next;
cur->next = cur->child;
cur->next->prev = cur;
cur->child = NULL;
last->next = next;
if (next) next->prev = last;
}
cur = cur->next;
}
return head;
}
};

Leetcode433. Minimum Genetic Mutation

A gene string can be represented by an 8-character long string, with choices from ‘A’, ‘C’, ‘G’, and ‘T’.

Suppose we need to investigate a mutation from a gene string start to a gene string end where one mutation is defined as one single character changed in the gene string.

For example, “AACCGGTT” —> “AACCGGTA” is one mutation. There is also a gene bank bank that records all the valid gene mutations. A gene must be in bank to make it a valid gene string.

Given the two gene strings start and end and the gene bank bank, return the minimum number of mutations needed to mutate from start to end. If there is no such a mutation, return -1.

Note that the starting point is assumed to be valid, so it might not be included in the bank.

Example 1:

1
2
Input: start = "AACCGGTT", end = "AACCGGTA", bank = ["AACCGGTA"]
Output: 1

Example 2:

1
2
Input: start = "AACCGGTT", end = "AAACGGTA", bank = ["AACCGGTA","AACCGCTA","AAACGGTA"]
Output: 2

Example 3:

1
2
Input: start = "AAAAACCC", end = "AACCCCCC", bank = ["AAAACCCC","AAACCCCC","AACCCCCC"]
Output: 3

先建立bank数组的距离场,这里距离就是两个字符串之间不同字符的个数。然后以start字符串为起点,向周围距离为1的点扩散,采用BFS搜索,每扩散一层,level自加1,当扩散到end字符串时,返回当前level即可。注意我们要把start字符串也加入bank中,而且此时我们也知道start的坐标位置,bank的最后一个位置,然后在建立距离场的时候,调用一个count子函数,用来统计输入的两个字符串之间不同字符的个数,注意dist[i][j]和dist[j][i]是相同,所以我们只用算一次就行了。然后我们进行BFS搜索,用一个visited集合来保存遍历过的字符串,注意检测距离的时候,dist[i][j]和dist[j][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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public:
int minMutation(string start, string end, vector<string>& bank) {
if (bank.empty())
return -1;
bank.push_back(start);
int res = 0, len = bank.size();
queue<int> q;
vector<vector<int> > dist(len, vector<int>(len, 0));
vector<int> visited(len, 0);
for (int i = 0; i < len; i ++)
for (int j = i+1; j < len; j ++)
dist[i][j] = cal_dist(bank[i], bank[j]);
q.push(len-1);

while(!q.empty()) {
res ++;
int size = q.size();
for (int i = 0; i < size; i ++) {
int t = q.front();
q.pop();
visited[t] = true;
for (int j = 0; j < len; j ++) {
if ((dist[t][j] != 1 && dist[j][t] != 1) || visited[j])
continue;
q.push(j);
if (bank[j] == end)
return res;
}
}
}
return -1;
}

int cal_dist(string a, string b) {
int cnt = 0, len = a.length();
for (int i = 0; i < len; i ++)
if (a[i] != b[i])
cnt ++;
return cnt;
}
};

Leetcode434. Number of Segments in a String

Count the number of segments in a string, where a segment is defined to be a contiguous sequence of non-space characters.

Please note that the string does not contain any non-printable characters.

Example:

1
2
Input: "Hello, my name is John"
Output: 5

判断一个句子中有几个段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int countSegments(string s) {
int res = 0;
if(s == "" || s == " ")
return 0;
for(int i = 0; i < s.length(); i ++) {
if(s[i] != ' ')
res ++;
while(i < s.length() && s[i] != ' ')
i ++;
}
return res;
}
};

有一种简单做法:
1
2
3
4
5
6
7
8
int countSegments(string s) {
stringstream ss(s);
string word;
int count = 0;
while (ss >> word)
count++;
return count;
}

Leetcode435. Non-overlapping Intervals

Given an array of intervals intervals where intervals[i] = [starti, endi], return the minimum number of intervals you need to remove to make the rest of the intervals non-overlapping.

Example 1:

1
2
3
Input: intervals = [[1,2],[2,3],[3,4],[1,3]]
Output: 1
Explanation: [1,3] can be removed and the rest of the intervals are non-overlapping.

Example 2:

1
2
3
Input: intervals = [[1,2],[1,2],[1,2]]
Output: 2
Explanation: You need to remove two [1,2] to make the rest of the intervals non-overlapping.

这道题给了我们一堆区间,让求需要至少移除多少个区间才能使剩下的区间没有重叠,那么首先要给区间排序,根据每个区间的 start 来做升序排序,然后开始要查找重叠区间,判断方法是看如果前一个区间的 end 大于后一个区间的 start,那么一定是重复区间,此时结果 res 自增1,我们需要删除一个,那么此时究竟该删哪一个呢,为了保证总体去掉的区间数最小,我们去掉那个 end 值较大的区间,而在代码中,我们并没有真正的删掉某一个区间,而是用一个变量 last 指向上一个需要比较的区间,我们将 last 指向 end 值较小的那个区间;如果两个区间没有重叠,那么此时 last 指向当前区间,继续进行下一次遍历,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
int res = 0, begin = 0, end = 0;
sort(intervals.begin(), intervals.end());
begin = intervals[0][0], end = intervals[0][1];
for (int i = 1; i < intervals.size(); i ++) {
if (end > intervals[i][0]) {
res ++;
if (end > intervals[i][1])
end = intervals[i][1];
}
else
end = intervals[i][1];
}
return res;
}
};

Leetcode436. Find Right Interval

You are given an array of intervals, where intervals[i] = [starti, endi] and each starti is unique.

The right interval for an interval i is an interval j such that startj >= endi and startj is minimized.

Return an array of right interval indices for each interval i. If no right interval exists for interval i, then put -1 at index i.

Example 1:

1
2
3
Input: intervals = [[1,2]]
Output: [-1]
Explanation: There is only one interval in the collection, so it outputs -1.

Example 2:

1
2
3
4
5
Input: intervals = [[3,4],[2,3],[1,2]]
Output: [-1,0,1]
Explanation: There is no right interval for [3,4].
The right interval for [2,3] is [3,4] since start0 = 3 is the smallest start that is >= end1 = 3.
The right interval for [1,2] is [2,3] since start1 = 2 is the smallest start that is >= end2 = 2.

Example 3:

1
2
3
4
Input: intervals = [[1,4],[2,3],[3,4]]
Output: [-1,2,-1]
Explanation: There is no right interval for [1,4] and [3,4].
The right interval for [2,3] is [3,4] since start2 = 3 is the smallest start that is >= end1 = 3.

这道题给了我们一堆区间,让我们找每个区间的最近右区间,要保证右区间的 start 要大于等于当前区间的 end,由于区间的顺序不能变,所以我们不能给区间排序,我们需要建立区间的 start 和该区间位置之间的映射,由于题目中限定了每个区间的 start 都不同,所以不用担心一对多的情况出现。然后我们把所有的区间的 start 都放到一个数组中,并对这个数组进行降序排序,那么 start 值大的就在数组前面。然后我们遍历区间集合,对于每个区间,我们在数组中找第一个小于当前区间的 end 值的位置,如果数组中第一个数就小于当前区间的 end,那么说明该区间不存在右区间,结果 res 中加入-1;如果找到了第一个小于当前区间 end 的位置,那么往前推一个就是第一个大于等于当前区间 end 的 start,我们在 HashMap 中找到该区间的坐标加入结果 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
class Solution {
public:
vector<int> findRightInterval(vector<vector<int>>& intervals) {
unordered_map<int, int> map;
vector<int> start;
for (int i = 0; i < intervals.size(); i ++) {
map[intervals[i][0]] = i;
start.push_back(intervals[i][0]);
}
sort(start.begin(), start.end());
vector<int> ress;
int len = intervals.size();

for (int i = 0; i < len; i ++) {
int low = 0, high = len-1, mid;
int best = -1;
while(low <= high) {
mid = low + (high-low) / 2;
if (start[mid] < intervals[i][1])
low = mid+1;
else {
best = map[start[mid]];
high = mid-1;
}
}
ress.push_back(best);
}
return ress;
}
};

Leetcode437. Path Sum III

You are given a binary tree in which each node contains an integer value. Find the number of paths that sum to a given value. The path does not need to start or end at the root or a leaf, but it must go downwards (traveling only from parent nodes to child nodes).

The tree has no more than 1,000 nodes and the values are in the range -1,000,000 to 1,000,000.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
root = [10,5,-3,3,2,null,11,3,-2,null,1], sum = 8
10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
Return 3. The paths that sum to 8 are:
1. 5 -> 3
2. 5 -> 2 -> 1
3. -3 -> 11

这道题让我们求二叉树的路径的和等于一个给定值,说明了这条路径不必要从根节点开始,可以是中间的任意一段,而且二叉树的节点值也是有正有负。那么可以用递归来做,相当于先序遍历二叉树,对于每一个节点都有记录了一条从根节点到当前节点到路径,同时用一个变量 curSum 记录路径节点总和,然后看 curSum 和 sum 是否相等,相等的话结果 res 加1,不等的话继续查看子路径和有没有满足题意的,做法就是每次去掉一个节点,看路径和是否等于给定值,注意最后必须留一个节点,不能全去掉了,因为如果全去掉了,路径之和为0,而如果给定值刚好为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
class Solution {
public:
int ans = 0;

void dfs(TreeNode* root, vector<TreeNode*>& out, int sum, int cur) {
if(root == NULL)
return;
cur += root->val;
if (cur == sum) ++ans;
out.push_back(root);
int t = cur;
for(int i = 0; i < out.size() - 1; i ++) {
t = t - out[i]->val;
if (t == sum)
++ans;
}
dfs(root->left, out, sum, cur);
dfs(root->right, out, sum, cur);
out.pop_back();
}

int pathSum(TreeNode* root, int sum) {
vector<TreeNode*> out;
dfs(root, out, sum, 0);
return ans;
}
};

Leetcode438. Find All Anagrams in a String

Given two strings s and p, return an array of all the start indices of p’s anagrams in s. You may return the answer in any order.

Example 1:

1
2
3
4
5
Input: s = "cbaebabacd", p = "abc"
Output: [0,6]
Explanation:
The substring with start index = 0 is "cba", which is an anagram of "abc".
The substring with start index = 6 is "bac", which is an anagram of "abc".

Example 2:

1
2
3
4
5
6
Input: s = "abab", p = "ab"
Output: [0,1,2]
Explanation:
The substring with start index = 0 is "ab", which is an anagram of "ab".
The substring with start index = 1 is "ba", which is an anagram of "ab".
The substring with start index = 2 is "ab", which is an anagram of "ab".

这道题给了我们两个字符串s和p,让在s中找字符串p的所有变位次的位置,所谓变位次就是字符种类个数均相同但是顺序可以不同的两个词,那么肯定首先就要统计字符串p中字符出现的次数,然后从s的开头开始,每次找p字符串长度个字符,来验证字符个数是否相同,如果不相同出现了直接 break,如果一直都相同了,则将起始位置加入结果 res 中,参见代码如下:(不用unordered_map而是用vector会更快!)

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 Solution {
public:
vector<int> findAnagrams(string s, string p) {
unordered_map<char, int> map, maps;
vector<int> res;
int lens = s.length(), lenp = p.length();
for (int i = 0; i < 26; i ++) {
map['a' + i] = 0;
maps['a' + i] = 0;
}
for (int i = 0; i < lenp; i ++) {
map[p[i]] ++;
}
if (lenp > lens)
return {};
for (int i = 0; i < lenp-1; i ++)
maps[s[i]] ++;
for (int i = lenp-1; i < lens; i ++) {
maps[s[i]] ++;

int j = 0;
for(j = 0; j < 26; j ++)
if (maps['a' + j] != map['a' + j])
break;
if (j == 26)
res.push_back(i-lenp+1);
maps[s[i-lenp+1]] --;
}
return res;
}
};

Leetcode441. Arranging Coins

You have a total of n coins that you want to form in a staircase shape, where every k-th row must have exactly k coins.

Given n, find the total number of full staircase rows that can be formed. n is a non-negative integer and fits within the range of a 32-bit signed integer.

Example 1:

1
2
3
4
5
6
7
n = 5
The coins can form the following rows:
¤
¤ ¤
¤ ¤

Because the 3rd row is incomplete, we return 2.

Example 2:
1
2
3
4
5
6
7
8
9
n = 8

The coins can form the following rows:
¤
¤ ¤
¤ ¤ ¤
¤ ¤

Because the 4th row is incomplete, we return 3.

直接遍历即可,从1开始,如果剩下是数不能构成一行则返回。注意要先判断剩下的数是否满足,而不是累加以后再判断,这样可能会导致溢出。
1
2
3
4
5
6
7
8
9
10
int arrangeCoins(int n) {
int i = 1, ans = n;
if(n == 1)
return 1;
while(ans >= i) {
ans -= i;
i ++;
}
return i-1;
}

前 i 行完整的硬币数量为i * (i + 1) / 2 ,前 i+1 行则为(i + 2) * (i + 1) / 2。所以(i + 1)*i / 2 ≤ n < (i + 2) * (i + 1) / 2,所以sqrt(2n + 0.25) - 1.5 < n ≤ sqrt(2n + 0.25) - 0.5
1
2
3
4
int arrangeCoins(int n)
{
return (int)(sqrt(2 * (double)n + 0.25) - 0.5);
}

Leetcode442. Find All Duplicates in an Array

Given an integer array nums of length n where all the integers of nums are in the range [1, n] and each integer appears once or twice, return an array of all the integers that appears twice.

You must write an algorithm that runs in O(n) time and uses only constant extra space.

Example 1:

1
2
Input: nums = [4,3,2,7,8,2,3,1]
Output: [2,3]

Example 2:

1
2
Input: nums = [1,1,2]
Output: [1]

Example 3:

1
2
Input: nums = [1]
Output: []

这类问题的一个重要条件就是1 ≤ a[i] ≤ n (n = size of array),不然很难在O(1)空间和O(n)时间内完成。首先来看一种正负替换的方法,这类问题的核心是就是找nums[i]和nums[nums[i] - 1]的关系,我们的做法是,对于每个nums[i],我们将其对应的nums[nums[i] - 1]取相反数,如果其已经是负数了,说明之前存在过,我们将其加入结果res中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
vector<int> findDuplicates(vector<int>& nums) {
vector<int> res;
for (int i = 0; i < nums.size(); ++i) {
int idx = abs(nums[i]) - 1;
if (nums[idx] < 0) res.push_back(idx + 1);
nums[idx] = -nums[idx];
}
return res;
}
};

本题使用Set的数据结构对数组进行遍历,找到出现两次的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> findDuplicates(vector<int>& nums) {
vector<int> res;
set<int> s;
unordered_map<int, bool> flags;
for (int n : nums) {
if (!s.count(n))
s.insert(n);
else
res.push_back(n);
}
return res;
}
};

Leetcode443. String Compression

Given an array of characters, compress it in-place. The length after compression must always be smaller than or equal to the original array. Every element of the array should be a character (not int) of length 1. After you are done modifying the input array in-place, return the new length of the array.

Follow up:
Could you solve it using only O(1) extra space?

Example 1:

1
2
3
4
5
6
7
8
Input:
["a","a","b","b","c","c","c"]

Output:
Return 6, and the first 6 characters of the input array should be: ["a","2","b","2","c","3"]

Explanation:
"aa" is replaced by "a2". "bb" is replaced by "b2". "ccc" is replaced by "c3".

Example 2:
1
2
3
4
5
6
7
8
Input:
["a"]

Output:
Return 1, and the first 1 characters of the input array should be: ["a"]

Explanation:
Nothing is replaced.

Example 3:
1
2
3
4
5
6
7
8
9
Input:
["a","b","b","b","b","b","b","b","b","b","b","b","b"]

Output:
Return 4, and the first 4 characters of the input array should be: ["a","b","1","2"].

Explanation:
Since the character "a" does not repeat, it is not compressed. "bbbbbbbbbbbb" is replaced by "b12".
Notice each digit has it's own entry in the array.

字符串压缩,坑很多,如果是只有一个字符的话就不用压缩,否则的话把字符和字符的数量都加到vector中,还要原地修改。
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
class Solution {
public:
int compress(vector<char>& chars) {
int ans = 0;
char c = chars[0], cc = c;
int cur = 1, pointer = 0;
string nums;
for(int i = 1; i < chars.size(); i ++) {
if(chars[i] != c) {
cout << c << " " << cur << endl;
chars[pointer++] = c;
nums = to_string(cur);
if(cur == 1) {
c = chars[i];
continue;
}
for(int i = 0; i < nums.length(); i ++) {
chars[pointer++] = nums[i];
}
cur = 1;
c = chars[i];
}
else
cur ++;
}
chars[pointer++] = c;
if(cur == 1) {
return pointer;
}
nums = to_string(cur);
for(int i = 0; i < nums.length(); i ++) {
chars[pointer++] = nums[i];
}
return pointer;
}
};

Leetcode445. Add Two Numbers II

You are given two non-empty linked lists representing two non-negative integers. The most significant digit comes first and each of their nodes contains a single digit. Add the two numbers and return the sum as a linked list.

You may assume the two numbers do not contain any leading zero, except the number 0 itself.

Example 1:

1
2
Input: l1 = [7,2,4,3], l2 = [5,6,4]
Output: [7,8,0,7]

Example 2:

1
2
Input: l1 = [2,4,3], l2 = [5,6,4]
Output: [8,0,7]

Example 3:

1
2
Input: l1 = [0], l2 = [0]
Output: [0]

由于加法需要从最低位开始运算,而最低位在链表末尾,链表只能从前往后遍历,没法取到前面的元素,那怎么办呢?我们可以利用栈来保存所有的元素,然后利用栈的后进先出的特点就可以从后往前取数字了,我们首先遍历两个链表,将所有数字分别压入两个栈s1和s2中,我们建立一个值为0的res节点,然后开始循环,如果栈不为空,则将栈顶数字加入sum中,然后将res节点值赋为sum%10,然后新建一个进位节点head,赋值为sum/10,如果没有进位,那么就是0,然后我们head后面连上res,将res指向head,这样循环退出后,我们只要看res的值是否为0,为0返回res->next,不为0则返回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
class Solution {
public:
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
stack<int> s1, s2;
ListNode *head = l1;
while(head) {
s1.push(head->val);
head = head->next;
}
head = l2;
while(head) {
s2.push(head->val);
head = head->next;
}
head = NULL;
int sum = 0;
while(!s1.empty() || !s2.empty()) {
if (!s1.empty()) {
sum += s1.top();
s1.pop();
}
if (!s2.empty()) {
sum += s2.top();
s2.pop();
}
head = new ListNode(sum%10, head);
sum /= 10;
}
if (sum > 0)
head = new ListNode(sum, head);
return head;
}
};

Leetcode447. Number of Boomerangs

Given n points in the plane that are all pairwise distinct, a “boomerang” is a tuple of points (i, j, k) such that the distance between i and j equals the distance between i and k (the order of the tuple matters).

Find the number of boomerangs. You may assume that n will be at most 500 and coordinates of points are all in the range [-10000, 10000] (inclusive).

Example:

1
2
3
Input: [[0,0],[1,0],[2,0]]
Output: 2
Explanation: The two boomerangs are [[1,0],[0,0],[2,0]] and [[1,0],[2,0],[0,0]]

给定 n 个两两各不相同的平面上的点,一个 “回旋镖” 是一个元组(tuple)的点(i,j,k),并且 i 和 j 的距离等于 i 和 k之间的距离(考虑顺序)。找出回旋镖的个数。你可以假设 n 不大于500,点的坐标范围在[-10000, 10000](包括边界)。

抓住两组点 (x1,y1)、(x2,y2) 和 (x1,y1)、(x3,y3) 之间的距离相等这个信息:distance = sqrt{(x1-x2)^2+(y1-y2)^2} = sqrt{(x1-x3)^2+(y1-y3)^2}

按照这种相等的距离,我们可以给所有点进行分类,相同距离的这些点(假设n个)可以构成一个排列组合中的排列:n*(n-1)个回旋镖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int numberOfBoomerangs(vector<vector<int>>& points) {
int ans = 0;
unordered_map<int, int> hash;
for(int i=0;i<points.size();i++){
for(int j=0;j<points.size();j++){
hash[pow(points[i][0]-points[j][0],2)+pow(points[i][1]-points[j][1],2)] += 1;
}
for(auto d:hash){
ans += d.second*(d.second-1);
}
hash.clear();
}
return ans;
}
};

Leetcode448. Find All Numbers Disappeared in an Array

Given an array of integers where 1 ≤ a[i] ≤ n (n = size of array), some elements appear twice and others appear once. Find all the elements of [1, n] inclusive that do not appear in this array. Could you do it without extra space and in O(n) runtime? You may assume the returned list does not count as extra space.

Example:

1
2
3
4
5
Input:
[4,3,2,7,8,2,3,1]

Output:
[5,6]

这道题让我们找出数组中所有消失的数,将nums[i]置换到其对应的位置nums[nums[i]-1]上去,比如对于没有缺失项的正确的顺序应该是[1, 2, 3, 4, 5, 6, 7, 8],而我们现在却是[4,3,2,7,8,2,3,1],我们需要把数字移动到正确的位置上去,比如第一个4就应该和7先交换个位置,以此类推,最后得到的顺序应该是[1, 2, 3, 4, 3, 2, 7, 8],我们最后在对应位置检验,如果nums[i]和i+1不等,那么我们将i+1存入结果res中即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
vector<int> res;
for (int i = 0; i < nums.size(); ++i) {
if (nums[i] != nums[nums[i] - 1]) {
swap(nums[i], nums[nums[i] - 1]);
--i;
}
}
for (int i = 0; i < nums.size(); ++i) {
if (nums[i] != i + 1) {
res.push_back(i + 1);
}
}
return res;
}
};

Leetcode449. Serialize and Deserialize BST

Serialization is 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 search tree. There is no restriction on how your serialization/deserialization algorithm should work. You need to ensure that a binary search tree can be serialized to a string, and this string can be deserialized to the original tree structure.

The encoded string should be as compact as possible.

Example 1:

1
2
Input: root = [2,1,3]
Output: [2,1,3]

Example 2:

1
2
Input: root = []
Output: []

Constraints:

  • The number of nodes in the tree is in the range [0, 104].
  • 0 <= Node.val <= 104
  • The input tree is guaranteed to be a binary search tree.

用队列来做,比较慢,但是很原生且具有通用性:

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
class Codec {
public:

// Encodes a tree to a single string.
string serialize(TreeNode* root) {
string res = "";
if (root == NULL)
return "";
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* temp = q.front();
q.pop();
if (temp) {
res += to_string(temp->val) + " ";
q.push(temp->left);
q.push(temp->right);
}
else
res += "# ";
}
return res;
}

// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
if (data == "")
return NULL;
int pos = 0;
TreeNode *root = new TreeNode(get_num(data, pos));
pos ++;
queue<TreeNode*> q;
q.push(root);
while(!q.empty()) {
TreeNode* temp = q.front();
q.pop();
if (data[pos] == '#') {
temp->left = NULL;
pos ++;
}
else {
temp->left = new TreeNode(get_num(data, pos));
q.push(temp->left);
}
pos ++;
if (data[pos] == '#') {
temp->right = NULL;
pos ++;
}
else {
temp->right = new TreeNode(get_num(data, pos));
q.push(temp->right);
}
pos ++;
}
return root;
}

int get_num(string data, int& pos) {
int res = 0;
while(data[pos] != ' ')
res = res * 10 + data[pos++] - '0';
return res;
}
};

层序遍历的非递归解法略微复杂一些,我们需要借助queue来做,本质是BFS算法,也不是很难理解,就是BFS算法的常规套路稍作修改即可,参见代码如下:

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
class Codec {
public:

// Encodes a tree to a single string.
string serialize(TreeNode* root) {
if (!root) return "";
ostringstream os;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode *t = q.front(); q.pop();
if (t) {
os << t->val << " ";
q.push(t->left);
q.push(t->right);
} else {
os << "# ";
}
}
return os.str();
}

// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
if (data.empty()) return NULL;
istringstream is(data);
queue<TreeNode*> q;
string val = "";
is >> val;
TreeNode *res = new TreeNode(stoi(val)), *cur = res;
q.push(cur);
while (!q.empty()) {
TreeNode *t = q.front(); q.pop();
if (!(is >> val)) break;
if (val != "#") {
cur = new TreeNode(stoi(val));
q.push(cur);
t->left = cur;
}
if (!(is >> val)) break;
if (val != "#") {
cur = new TreeNode(stoi(val));
q.push(cur);
t->right = cur;
}
}
return res;
}
};

Leetcode450. Delete Node in a BST

Given a root node reference of a BST and a key, delete the node with the given key in the BST. Return the root node reference (possibly updated) of the BST.

Basically, the deletion can be divided into two stages:

Search for a node to remove.
If the node is found, delete the node.
Follow up: Can you solve it with time complexity O(height of tree)?

Example 1:

1
2
3
4
5
Input: root = [5,3,6,2,4,null,7], key = 3
Output: [5,4,6,2,null,null,7]
Explanation: Given key to delete is 3. So we find the node with value 3 and delete it.
One valid answer is [5,4,6,2,null,null,7], shown in the above BST.
Please notice that another valid answer is [5,2,6,null,4,null,7] and it's also accepted.

这道题让我们删除二叉搜索树中的一个节点,难点在于删除完结点并补上那个结点的位置后还应该是一棵二叉搜索树。被删除掉的结点位置,不一定是由其的左右子结点补上,比如下面这棵树:

1
2
3
4
5
6
7
     7
/ \
4 8
/ \
2 6
\ /
3 5

如果要删除结点4,那么应该将结点5补到4的位置,这样才能保证还是 BST,那么结果是如下这棵树:
1
2
3
4
5
6
7
     7
/ \
5 8
/ \
2 6
\
3

先来看一种递归的解法,首先判断根节点是否为空。由于 BST 的左<根<右的性质,使得可以快速定位到要删除的结点,对于当前结点值不等于 key 的情况,根据大小关系对其左右子结点分别调用递归函数。若当前结点就是要删除的结点,先判断若有一个子结点不存在,就将 root 指向另一个结点,如果左右子结点都不存在,那么 root 就赋值为空了,也正确。难点就在于处理左右子结点都存在的情况,需要在右子树找到最小值,即右子树中最左下方的结点,然后将该最小值赋值给 root,然后再在右子树中调用递归函数来删除这个值最小的结点,参见代码如下:
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:
TreeNode* deleteNode(TreeNode* root, int key) {
if (!root)
return NULL;
if (root->val > key)
root->left = deleteNode(root->left, key);
else if (root->val < key)
root->right = deleteNode(root->right, key);
else {
if (!root->left || !root->right)
root = root->left ? root->left : root->right;
else {
TreeNode* cur = root->right;
while(cur->left)
cur = cur->left;
root->val = cur->val;
root->right = deleteNode(root->right, cur->val);
}
}
return root;
}
};

Leetcode451. Sort Characters By Frequency

Given a string s, sort it in decreasing order based on the frequency of characters, and return the sorted string.

Example 1:

1
2
3
4
Input: s = "tree"
Output: "eert"
Explanation: 'e' appears twice while 'r' and 't' both appear once.
So 'e' must appear before both 'r' and 't'. Therefore "eetr" is also a valid answer.

Example 2:

1
2
3
4
Input: s = "cccaaa"
Output: "aaaccc"
Explanation: Both 'c' and 'a' appear three times, so "aaaccc" is also a valid answer.
Note that "cacaca" is incorrect, as the same characters must be together.

Example 3:

1
2
3
4
Input: s = "Aabb"
Output: "bbAa"
Explanation: "bbaA" is also a valid answer, but "Aabb" is incorrect.
Note that 'A' and 'a' are treated as two different characters.

竟然还要区分大小写,还要排序,那map等结构就不能用了,直接用数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
static bool comp(pair<char, int>& a, pair<char, int>& b) {
return a.second > b.second || a.second == b.second && a.first < b.first;
}
string frequencySort(string s) {
int count[256] = {0};
for (char c : s)
count[c] ++;
sort(s.begin(), s.end(), [&](char a, char b){
return count[a] > count[b] || count[a] == count[b] && a < b;
});
return s;
}
};

Leetcode452. Minimum Number of Arrows to Burst Balloons

There are a number of spherical balloons spread in two-dimensional space. For each balloon, provided input is the start and end coordinates of the horizontal diameter. Since it’s horizontal, y-coordinates don’t matter and hence the x-coordinates of start and end of the diameter suffice. Start is always smaller than end. There will be at most 104 balloons.

An arrow can be shot up exactly vertically from different points along the x-axis. A balloon with xstart and xend bursts by an arrow shot at x if xstart ≤ x ≤ xend. There is no limit to the number of arrows that can be shot. An arrow once shot keeps travelling up infinitely. The problem is to find the minimum number of arrows that must be shot to burst all balloons.

Example 1:

1
2
3
Input: points = [[10,16],[2,8],[1,6],[7,12]]
Output: 2
Explanation: One way is to shoot one arrow for example at x = 6 (bursting the balloons [2,8] and [1,6]) and another arrow at x = 11 (bursting the other two balloons).

Example 2:

1
2
Input: points = [[1,2],[3,4],[5,6],[7,8]]
Output: 4

Example 3:

1
2
Input: points = [[1,2],[2,3],[3,4],[4,5]]
Output: 2

这道题给了我们一堆大小不等的气球,用区间范围来表示气球的大小,可能会有重叠区间。然后我们用最少的箭数来将所有的气球打爆。那么这道题是典型的用贪婪算法来做的题,因为局部最优解就等于全局最优解,我们首先给区间排序,我们不用特意去写排序比较函数,因为默认的对于pair的排序,就是按第一个数字升序排列,如果第一个数字相同,那么按第二个数字升序排列,这个就是我们需要的顺序,所以直接用即可。然后我们将res初始化为1,因为气球数量不为0,所以怎么也得先来一发啊,然后这一箭能覆盖的最远位置就是第一个气球的结束点,用变量end来表示。然后我们开始遍历剩下的气球,如果当前气球的开始点小于等于end,说明跟之前的气球有重合,之前那一箭也可以照顾到当前的气球,此时我们要更新end的位置,end更新为两个气球结束点之间较小的那个,这也是当前气球和之前气球的重合点,然后继续看后面的气球;如果某个气球的起始点大于end了,说明前面的箭无法覆盖到当前的气球,那么就得再来一发,既然又来了一发,那么我们此时就要把end设为当前气球的结束点了,这样贪婪算法遍历结束后就能得到最少的箭数了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
static bool comp(vector<int>& a, vector<int>& b) {
return a[0] < b[0];
}

int findMinArrowShots(vector<vector<int>>& points) {
sort(points.begin(), points.end(), comp);
int res = 1, end = points[0][1];
for (int i = 1; i < points.size(); i ++) {
if (end >= points[i][0])
end = min(end, points[i][1]);
else {
res ++;
end = points[i][1];
}
}
return res;
}
};

Leetcode453. Minimum Moves to Equal Array Elements

Given a non-empty integer array of size n, find the minimum number of moves required to make all array elements equal, where a move is incrementing n - 1 elements by 1.

Example:

1
2
3
4
5
6
7
8
9
Input:
[1,2,3]

Output:
3

Explanation:
Only three moves are needed (remember each move increments two elements):
[1,2,3] => [2,3,3] => [3,4,3] => [4,4,4]

这道题给了我们一个长度为n的数组,说是每次可以对 n-1 个数字同时加1,问最少需要多少次这样的操作才能让数组中所有的数字相等。那么想,为了快速的缩小差距,该选择哪些数字加1呢,不难看出每次需要给除了数组最大值的所有数字加1,这样能快速的到达平衡状态。但是这道题如果老老实实的每次找出最大值,然后给其他数字加1,再判断是否平衡,思路是正确,但是 OJ 不答应。正确的解法相当的巧妙,需要换一个角度来看问题,其实给 n-1 个数字加1,效果等同于给那个未被选中的数字减1,比如数组 [1,2,3],给除去最大值的其他数字加1,变为 [2,3,3],全体减1,并不影响数字间相对差异,变为 [1,2,2],这个结果其实就是原始数组的最大值3自减1,那么问题也可能转化为,将所有数字都减小到最小值,这样难度就大大降低了,只要先找到最小值,然后累加每个数跟最小值之间的差值即可。

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int minMoves(vector<int>& nums) {
int minn = INT_MAX, res = 0;
for(int i : nums)
minn = min(minn, i);
for(int i : nums)
res += (i - minn);
return res;
}
};

Leetcode454. 4Sum II

Given four integer arrays nums1, nums2, nums3, and nums4 all of length n, return the number of tuples (i, j, k, l) such that:

  • 0 <= i, j, k, l < n
  • nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0

Example 1:

1
2
Input: nums1 = [1,2], nums2 = [-2,-1], nums3 = [-1,2], nums4 = [0,2]
Output: 2

Explanation: The two tuples are:

  1. (0, 0, 0, 1) -> nums1[0] + nums2[0] + nums3[0] + nums4[1] = 1 + (-2) + (-1) + 2 = 0
  2. (1, 1, 0, 0) -> nums1[1] + nums2[1] + nums3[0] + nums4[0] = 2 + (-1) + (-1) + 0 = 0

这道题是之前那道 4Sum 的延伸,让我们在四个数组中各取一个数字,使其和为0。如果把A和B的两两之和都求出来,在 HashMap 中建立两数之和跟其出现次数之间的映射,那么再遍历C和D中任意两个数之和,只要看哈希表存不存在这两数之和的相反数就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
int n = nums1.size();
unordered_map<int, int> mab, mcd;
for (int i = 0; i < n; i ++)
for (int j = 0; j < n; j ++) {
mab[nums1[i]+nums2[j]] ++;
}
int res = 0;
for (int i = 0; i < n; i ++)
for (int j = 0; j < n; j ++) {
res += mab[-nums3[i]-nums4[j]];
}
return res;
}
};

用两个 HashMap 分别记录 AB 和 CB 的两两之和出现次数,然后遍历其中一个 HashMap,并在另一个 HashMap 中找和的相反数出现的次数,更方便,但更慢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
int n = nums1.size();
unordered_map<int, int> mab, mcd;
for (int i = 0; i < n; i ++)
for (int j = 0; j < n; j ++) {
mab[nums1[i]+nums2[j]] ++;
mcd[nums3[i]+nums4[j]] ++;
}
int res = 0;
for (auto i : mab)
res += (i.second * mcd[-i.first]);
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
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4)
{
map<int, int> index1,index2,index3;
unordered_map<int,int> index4;
for (size_t i = 0; i < nums4.size(); ++i)
{
index1[nums1[i]]++;
index2[nums2[i]]++;
index3[nums3[i]]++;
index4[nums4[i]]++;
}
unordered_map<int, int> sums;
for (auto && it3 : index3)
for (auto && it4 : index4)
sums[it3.first+it4.first] += it3.second*it4.second;

int count = 0;
for (auto it1 = index1.begin(); it1 != index1.end(); ++it1)
{
for (auto it2 = index2.begin(); it2 != index2.end(); ++it2)
{
long t2 = (long) it1->first + (long) it2->first;
int ct2 = it1->second * it2->second;
auto pos = sums.find(-t2);
if (pos == sums.end()) continue;
count += pos->second*ct2;
}
}
return count;
}
};

Leetcode455. Assign Cookies

Assume you are an awesome parent and want to give your children some cookies. But, you should give each child at most one cookie. Each child i has a greed factor gi, which is the minimum size of a cookie that the child will be content with; and each cookie j has a size sj. If sj >= gi, we can assign the cookie j to the child i, and the child i will be content. Your goal is to maximize the number of your content children and output the maximum number.

Note:
You may assume the greed factor is always positive.
You cannot assign more than one cookie to one child.

Example 1:

1
2
3
4
5
Input: [1,2,3], [1,1]
Output: 1
Explanation: You have 3 children and 2 cookies. The greed factors of 3 children are 1, 2, 3.
And even though you have 2 cookies, since their size is both 1, you could only make the child whose greed factor is 1 content.
You need to output 1.

Example 2:
1
2
3
4
5
Input: [1,2], [1,2,3]
Output: 2
Explanation: You have 2 children and 3 cookies. The greed factors of 2 children are 1, 2.
You have 3 cookies and their sizes are big enough to gratify all of the children,
You need to output 2.

有一堆饼干和一堆孩子,每个饼干大小为s[j],每个孩子想要的大小为g[i],求这堆饼干能满足至多多少个孩子?
很容易想到,每个孩子尽量拿到和他想要的大小差距最小的饼干,就能保证不会“浪费”大块饼干。因此把g和s排序后,把最相邻的饼干分给刚刚好满足的孩子,就能得到最大的满足数量了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int count = 0;
for(int i = 0, j = 0; i < g.size() && j < s.size(); j ++){
if(s[j] >= g[i]) {
count ++;
i ++;
}
}
return count;
}
};

Leetcode456. 132 Pattern

Given an array of n integers nums, a 132 pattern is a subsequence of three integers nums[i], nums[j] and nums[k] such that i < j < k and nums[i] < nums[k] < nums[j].

Return true if there is a 132 pattern in nums, otherwise, return false.

Example 1:

1
2
3
Input: nums = [1,2,3,4]
Output: false
Explanation: There is no 132 pattern in the sequence.

Example 2:

1
2
3
Input: nums = [3,1,4,2]
Output: true
Explanation: There is a 132 pattern in the sequence: [1, 4, 2].

Example 3:

1
2
3
Input: nums = [-1,3,2,0]
Output: true
Explanation: There are three 132 patterns in the sequence: [-1, 3, 2], [-1, 3, 0] and [-1, 2, 0].

思路是维护一个栈和一个变量 third,其中 third 就是第三个数字,也是 pattern 132 中的2,初始化为整型最小值,栈里面按顺序放所有大于 third 的数字,也是 pattern 132 中的3,那么在遍历的时候,如果当前数字小于 third,即 pattern 132 中的1找到了,直接返回 true 即可,因为已经找到了,注意应该从后往前遍历数组。如果当前数字大于栈顶元素,那么将栈顶数字取出,赋值给 third,然后将该数字压入栈,这样保证了栈里的元素仍然都是大于 third 的,想要的顺序依旧存在,进一步来说,栈里存放的都是可以维持坐标 second > third 的 second 值,其中的任何一个值都是大于当前的 third 值,如果有更大的值进来,那就等于形成了一个更优的 second > third 的这样一个组合,并且这时弹出的 third 值比以前的 third 值更大,为什么要保证 third 值更大,因为这样才可以更容易的满足当前的值 first 比 third 值小这个条件,举个例子来说吧,比如 [2, 4, 2, 3, 5],由于是从后往前遍历,所以后三个数都不会进入 while 循环,那么栈中的数字为 5, 3, 2(其中2为栈顶元素),此时 third 还是整型最小,那么当遍历到4的时候,终于4大于栈顶元素2了,那么 third 赋值为2,且2出栈。此时继续 while 循环,因为4还是大于新栈顶元素3,此时 third 赋值为3,且3出栈。现在栈顶元素是5,那么 while 循环结束,将4压入栈。下一个数字2,小于 third,则找到符合要求的序列 [2, 4, 3],参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool find132pattern(vector<int>& nums) {
int n = nums.size(), third = INT_MIN;
stack<int> s;
for (int i = n-1; i >= 0; i --) {
if (nums[i] < third)
return true;
while(!s.empty() && nums[i] > s.top()) {
third = s.top(); s.pop();
}
s.push(nums[i]);
}
return false;
}
};

Leetcode459. Repeated Substring Pattern

Given a non-empty string check if it can be constructed by taking a substring of it and appending multiple copies of the substring together. You may assume the given string consists of lowercase English letters only and its length will not exceed 10000.

Example 1:

1
2
3
Input: "abab"
Output: True
Explanation: It's the substring "ab" twice.

Example 2:
1
2
Input: "aba"
Output: False

Example 3:
1
2
3
Input: "abcabcabcabc"
Output: True
Explanation: It's the substring "abc" four times. (And the substring "abcabc" twice.)

传统方法,挨个子字符串对比
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:
bool repeatedSubstringPattern(string s) {
string temp;
int length = s.length();
for(int i = 1; i <= length/2; i ++) {
if(length % i)
continue;
temp = s.substr(0, i);
temp = gen(temp, length/i);
if(temp == s)
return true;
}
return false;
}

string gen(string temp, int i) {
string ans = "";
while(i--)
ans += temp;
return ans;
}
};

另一种做法,用dp。维护的一位数组dp[i]表示,到位置i-1为止的重复字符串的字符个数,不包括被重复的那个字符串,什么意思呢,我们举个例子,比如”abcabc”的dp数组为[0 0 0 0 1 2 3],dp数组长度要比原字符串长度多一个。那么我们看最后一个位置数字为3,就表示重复的字符串的字符数有3个。如果是”abcabcabc”,那么dp数组为[0 0 0 0 1 2 3 4 5 6],我们发现最后一个数字为6,那么表示重复的字符串为“abcabc”,有6个字符。那么怎么通过最后一个数字来知道原字符串是否由重复的子字符串组成的呢,首先当然是最后一个数字不能为0,而且还要满足dp[n] % (n - dp[n]) == 0才行,因为n - dp[n]是一个子字符串的长度,那么重复字符串的长度和肯定是一个子字符串的整数倍。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool repeatedSubstringPattern(string s) {
int n = s.length();
vector<int> dp(n+1, 0);
int i = 1, j = 0;
while(i < n) {
if(s[i] == s[j])
dp[++i] = ++j;
else if(j == 0)
i ++;
else
j = dp[j];
}
return dp[n] && (dp[n] % (n - dp[n]) == 0);
}
};

Leetcode461. Hamming Distance

The Hamming distance between two integers is the number of positions at which the corresponding bits are different. Given two integers x and y, calculate the Hamming distance.

Note:
0 ≤ x, y < 231.

Example:

1
2
3
4
5
6
7
Input: x = 1, y = 4
Output: 2

Explanation:
1 (0 0 0 1)
4 (0 1 0 0)
↑ ↑

The above arrows point to positions where the corresponding bits are different.

求两个数的海明距离,就是判断其二进制有多少不一样的位

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int hammingDistance(int x, int y) {
int temp = x ^ y;
int res=0;
for(int i=temp;i>0;i=i>>1)
if(i&1) res++;
return res;
}
};

Leetcode462. Minimum Moves to Equal Array Elements II

Given an integer array nums of size n, return the minimum number of moves required to make all array elements equal.

In one move, you can increment or decrement an element of the array by 1.

Test cases are designed so that the answer will fit in a 32-bit integer.

Example 1:

1
2
3
4
5
Input: nums = [1,2,3]
Output: 2
Explanation:
Only two moves are needed (remember each move increments or decrements one element):
[1,2,3] => [2,2,3] => [2,2,2]

Example 2:

1
2
Input: nums = [1,10,2,9]
Output: 16

这道题每次对任意一个数字加1或者减1,让我们用最少的次数让数组所有值相等。首先给数组排序,最终需要变成的相等的数字就是中间的数,如果数组有奇数个,那么就是最中间的那个数字;如果是偶数个,那么就是中间两个数的区间中的任意一个数字。而两端的数字变成中间的一个数字需要的步数实际上就是两端数字的距离。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int minMoves2(vector<int>& nums) {
int res = 0, i = 0, j = (int)nums.size() - 1;
sort(nums.begin(), nums.end());
while (i < j) {
res += nums[j--] - nums[i++];
}
return res;
}
};

既然有了上面的分析,我们知道实际上最后相等的数字就是数组的最中间的那个数字,那么我们在给数组排序后,直接利用坐标定位到中间的数字,然后算数组中每个数组与其的差的绝对值累加即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int minMoves2(vector<int>& nums) {
sort(nums.begin(), nums.end());
int res = 0, mid = nums[nums.size() / 2];
for (int num : nums) {
res += abs(num - mid);
}
return res;
}
};

上面的两种方法都给整个数组排序了,时间复杂度是O(nlgn),其实我们并不需要给所有的数字排序,我们只关系最中间的数字,那么这个stl中自带的函数nth_element就可以完美的发挥其作用了,我们只要给出我们想要数字的位置,它就能在O(n)的时间内返回正确的数字,然后算数组中每个数组与其的差的绝对值累加即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int minMoves2(vector<int>& nums) {
int res = 0, n = nums.size(), mid = n / 2;
nth_element(nums.begin(), nums.begin() + mid, nums.end());
for (int i = 0; i < n; ++i) {
res += abs(nums[i] - nums[mid]);
}
return res;
}
};

Leetcode463. Island Perimeter

You are given a map in form of a two-dimensional integer grid where 1 represents land and 0 represents water.

Grid cells are connected horizontally/vertically (not diagonally). The grid is completely surrounded by water, and there is exactly one island (i.e., one or more connected land cells).

The island doesn’t have “lakes” (water inside that isn’t connected to the water around the island). One cell is a square with side length 1. The grid is rectangular, width and height don’t exceed 100. Determine the perimeter of the island.

Example:

1
2
3
4
5
6
7
Input:
[[0,1,0,0],
[1,1,1,0],
[0,1,0,0],
[1,1,0,0]]

Output: 16

Explanation: The perimeter is the 16 yellow stripes in the image below:

看一共有几条边,对每个格子进行遍历。

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 islandPerimeter(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
int ans = 0, temp;
for(int i = 0; i < m; i ++) {
for(int j = 0; j < n; j ++) {
if(!grid[i][j])
continue;
temp = 0;
if(j == 0 || (j > 0 && !grid[i][j-1])) temp ++;
if(j == n-1 || (j < n-1 && !grid[i][j+1])) temp ++;
if(i == 0 || (i > 0 && !grid[i-1][j])) temp ++;
if(i == m-1 || (i < m-1 && !grid[i+1][j])) temp ++;

ans += temp;
}
}
return ans;
}
};

Leetcode464. Can I Win

In the “100 game,” two players take turns adding, to a running total, any integer from 1..10. The player who first causes the running total to reach or exceed 100 wins.

What if we change the game so that players cannot re-use integers?

For example, two players might take turns drawing from a common pool of numbers of 1..15 without replacement until they reach a total >= 100.

Given an integer maxChoosableInteger and another integer desiredTotal, determine if the first player to move can force a win, assuming both players play optimally.

You can always assume that maxChoosableInteger will not be larger than 20 and desiredTotal will not be larger than 300.

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
Input:
maxChoosableInteger = 10
desiredTotal = 11

Output:
false

Explanation:
No matter which integer the first player choose, the first player will lose.
The first player can choose an integer from 1 up to 10.
If the first player choose 1, the second player can only choose integers from 2 up to 10.
The second player will win by choosing 10 and get a total = 11, which is >= desiredTotal.
Same with other integers chosen by the first player, the second player will always win.

这道题给了我们一堆数字,然后两个人,每人每次选一个数字,看数字总数谁先到给定值,有点像之前那道 Nim Game,但是比那题难度大。我刚开始想肯定说用递归啊,结果写完发现 TLE 了,后来发现我们必须要优化效率,使用 HashMap 来记录已经计算过的结果。我们首先来看如果给定的数字范围大于等于目标值的话,直接返回 true。如果给定的数字总和小于目标值的话,说明谁也没法赢,返回 false。然后我们进入递归函数,首先我们查找当前情况是否在 HashMap 中存在,有的话直接返回即可。我们使用一个整型数按位来记录数组中的某个数字是否使用过,我们遍历所有数字,将该数字对应的 mask 算出来,如果其和 used 相与为0的话,说明该数字没有使用过,我们看如果此时的目标值小于等于当前数字,说明已经赢了,或者调用递归函数,如果返回 false,说明也是第一个人赢了。为啥呢,因为当前已经选过数字了,此时就该对第二个人调用递归函数,只有返回的结果是 false,我们才能赢,所以此时我们 true,并返回 true。如果遍历完所有数字,标记 false,并返回 false,参见代码如下:

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:
bool canIWin(int maxChoosableInteger, int desiredTotal) {
if (maxChoosableInteger >= desiredTotal) return true;
if (maxChoosableInteger * (maxChoosableInteger + 1) / 2 < desiredTotal) return false;
unordered_map<int, bool> m;
return canWin(maxChoosableInteger, desiredTotal, 0, m);
}
bool canWin(int length, int total, int used, unordered_map<int, bool>& m) {
if (m.count(used)) return m[used];
for (int i = 0; i < length; ++i) {
int cur = (1 << i);
if ((cur & used) == 0) {
if (total <= i + 1 || !canWin(length, total - (i + 1), cur | used, m)) {
m[used] = true;
return true;
}
}
}
m[used] = false;
return false;
}
};

Leetcode467. Unique Substrings in Wraparound String

Consider the string s to be the infinite wraparound string of “abcdefghijklmnopqrstuvwxyz”, so s will look like this: “…zabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd….”.

Now we have another string p. Your job is to find out how many unique non-empty substrings of p are present in s. In particular, your input is the string p and you need to output the number of different non-empty substrings of p in the string s.

Note: p consists of only lowercase English letters and the size of p might be over 10000.

Example 1:

1
2
3
4
Input: "a"
Output: 1

Explanation: Only the substring "a" of string "a" is in the string s.

Example 2:

1
2
3
Input: "cac"
Output: 2
Explanation: There are two substrings "a", "c" of string "cac" in the string s.

Example 3:

1
2
3
Input: "zab"
Output: 6
Explanation: There are six substrings "z", "a", "b", "za", "ab", "zab" of string "zab" in the string s.

这道题说有一个无限长的封装字符串,然后又给了我们另一个字符串p,问我们p有多少非空子字符串在封装字符串中。我们通过观察题目中的例子可以发现,由于封装字符串是26个字符按顺序无限循环组成的,那么满足题意的p的子字符串要么是单一的字符,要么是按字母顺序的子字符串。这道题遍历p的所有子字符串会TLE,因为如果p很大的话,子字符串很多,会有大量的满足题意的重复子字符串,必须要用到trick,而所谓技巧就是一般来说你想不到的方法。我们看abcd这个字符串,以d结尾的子字符串有abcd, bcd, cd, d,那么我们可以发现bcd或者cd这些以d结尾的字符串的子字符串都包含在abcd中,那么我们知道以某个字符结束的最大字符串包含其他以该字符结束的字符串的所有子字符串,说起来很拗口,但是理解了我上面举的例子就行。那么题目就可以转换为分别求出以每个字符(a-z)为结束字符的最长连续字符串就行了,我们用一个数组cnt记录下来,最后在求出数组cnt的所有数字之和就是我们要的结果啦,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findSubstringInWraproundString(string p) {
vector<int> cnt(26, 0);
int len = 0;
for (int i = 0; i < p.size(); ++i) {
if (i > 0 && (p[i] == p[i - 1] + 1 || p[i - 1] - p[i] == 25)) {
++len;
} else {
len = 1;
}
cnt[p[i] - 'a'] = max(cnt[p[i] - 'a'], len);
}
return accumulate(cnt.begin(), cnt.end(), 0);
}
};

Leetcode468. Validate IP Address

Given a string IP, return “IPv4” if IP is a valid IPv4 address, “IPv6” if IP is a valid IPv6 address or “Neither” if IP is not a correct IP of any type.

A valid IPv4 address is an IP in the form “x1.x2.x3.x4” where 0 <= xi <= 255 and xi cannot contain leading zeros. For example, “192.168.1.1” and “192.168.1.0” are valid IPv4 addresses but “192.168.01.1”, while “192.168.1.00” and “192.168@1.1” are invalid IPv4 addresses.

A valid IPv6 address is an IP in the form “x1:x2:x3:x4:x5:x6:x7:x8” where:

  • 1 <= xi.length <= 4
  • xi is a hexadecimal string which may contain digits, lower-case English letter (‘a’ to ‘f’) and upper-case English letters (‘A’ to ‘F’).
  • Leading zeros are allowed in xi.

For example, “2001:0db8:85a3:0000:0000:8a2e:0370:7334” and “2001:db8:85a3:0:0:8A2E:0370:7334” are valid IPv6 addresses, while “2001:0db8:85a3::8A2E:037j:7334” and “02001:0db8:85a3:0000:0000:8a2e:0370:7334” are invalid IPv6 addresses.

Example 1:

1
2
3
Input: IP = "172.16.254.1"
Output: "IPv4"
Explanation: This is a valid IPv4 address, return "IPv4".

Example 2:

1
2
3
Input: IP = "2001:0db8:85a3:0:0:8A2E:0370:7334"
Output: "IPv6"
Explanation: This is a valid IPv6 address, return "IPv6".

Example 3:

1
2
3
Input: IP = "256.256.256.256"
Output: "Neither"
Explanation: This is neither a IPv4 address nor a IPv6 address.

Example 4:

1
2
Input: IP = "2001:0db8:85a3:0:0:8A2E:0370:7334:"
Output: "Neither"

巨难搞,就跟判断一个数是不是合法一样。

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
class Solution {
public:

bool is_number(char c) {
return ('0' <= c && c <= '9');
}
bool is_char(char c) {
return ('A' <= c && c <= 'F') || ('a' <= c && c <= 'f');
}

string validIPAddress(string IP) {
if (isv4(IP))
return "IPv4";
else if (isv6(IP))
return "IPv6";
else
return "Neither";
}

bool isv4(string IP) {
int len = IP.length();
int num_points = 0, number = 0;
bool is_begin = true;
for (int i = 0; i < len; i ++) {
if (number > 255)
return false;
else if (IP[i] == '.') {
num_points ++;
number = 0;
if (is_begin)
return false;
is_begin = true;
}
else if (!is_number(IP[i]))
return false;
else {
if (i < len-1 && is_begin && IP[i+1] != '.' && IP[i] == '0')
return false;
is_begin = false;
if (i > 0 && IP[i] == '0' && IP[i-1] == 0)
return false;
number = number * 10 + IP[i] - '0';
}
}
if (number > 255 || num_points != 3 || IP[len-1] == '.')
return false;
return true;
}

bool isv6(string IP) {
int len = IP.length();
int num_points = 0, number = 0, sum = 0;
bool is_begin = true;
for (int i = 0; i < len; i ++) {
if (IP[i] == ':') {
if (number > 4)
return false;
num_points ++;
number = 0;
if (is_begin)
return false;
is_begin = true;
}
else {
is_begin = false;
if (!(is_number(IP[i]) || is_char(IP[i])) )
return false;
number ++;
}
}
if (num_points != 7 || number > 4 || IP[len-1] == ':')
return false;
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
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
#pragma GCC optimize("Ofast")
static auto _ = [] () {ios_base::sync_with_stdio(false);cin.tie(nullptr);cout.tie(nullptr);return 0;}();

class Solution
{
public:
string validIPAddress(string const& IP)
{
bool isV6 = IP.find(':') != string::npos;

if (isV6) { // Try to parse ipv6
int count = 0, segments = 0;

auto isValidHex = [](char c) {
return isdigit(c) ||
('a' <= c && c <= 'f') ||
('A' <= c && c <= 'F');
};

int ptr = -1, size = IP.size();
while (ptr < size && segments < 8) {
++ptr; // skip leading ':'

while (ptr < size && IP[ptr] != ':')
if (isValidHex(IP[ptr]))
++ptr, ++count;
else
return "Neither";

if (count == 0 || count > 4)
return "Neither";
else
count = 0,
++segments;
}

if (ptr == IP.size() && segments == 8)
return "IPv6";
else
return "Neither";
}
else { // Try to parse ipv4
int segments = 0, number = 0, count = 0;
int ptr = -1, size = IP.size();
while (ptr < size && segments < 4) {
++ptr; // skip initial dot

if (ptr < size && IP[ptr] == '0') {
++segments;
++ptr;
if (ptr == size || IP[ptr] == '.')
continue;
else
return "Neither";
}

while (ptr < size && IP[ptr] != '.')
if (number < 250 && isdigit(IP[ptr])) {
++count;
number *= 10;
number += IP[ptr] - '0';
++ptr;
}
else {
return "Neither";
}

if (1 <= count && count <= 4 && 1 <= number && number <= 255) {
count = 0;
number = 0;
++segments;
}
else {
return "Neither";
}
}

if (ptr == IP.size() && segments == 4)
return "IPv4";
else
return "Neither";
}
return "Neither";
}
};

Leetcode470. Implement Rand10() Using Rand7()

Given the API rand7() that generates a uniform random integer in the range [1, 7], write a function rand10() that generates a uniform random integer in the range [1, 10]. You can only call the API rand7(), and you shouldn’t call any other API. Please do not use a language’s built-in random API.

Each test case will have one internal argument n, the number of times that your implemented function rand10() will be called while testing. Note that this is not an argument passed to rand10().

Follow up:

  • What is the expected value for the number of calls to rand7() function?
  • Could you minimize the number of calls to rand7()?

Example 1:

1
2
Input: n = 1
Output: [2]

Example 2:

1
2
Input: n = 2
Output: [2,8]

Example 3:

1
2
Input: n = 3
Output: [3,8,10]

这道题给了我们一个随机生成 [1, 7] 内数字的函数rand7(),需要利用其来生成一个能随机生成 [1, 10] 内数字的函数rand10(),注意这里的随机生成的意思是等概率生成范围内的数字。这是一道很有意思的题目,由于rand7()只能生成1到7之间的数字,所以 8,9,10 这三个没法生成,那么怎么办?

大多数人可能第一个想法就是,再用一个呗,然后把两次的结果加起来,范围不就扩大了么,扩大成了 [2, 14] 之间,然后如果再减去1,范围不就是 [1, 13] 了么。想法不错,但是有个问题,这个范围内的每个数字生成的概率不是都相等的,为啥这么说呢,我们来举个简单的例子看下,就比如说rand2(),我们知道其可以生成两个数字1和2,且每个的概率都是 1/2。那么对于(rand2() - 1) +rand2()``呢,看一下:

1
2
3
4
5
rand2() - 1 + rand()2  =   ?
1 1 1
1 2 2
2 1 2
2 2 3

我们发现,生成数字范围 [1, 3] 之间的数字并不是等概率大,其中2出现的概率为 1/2,1和3分别为 1/4。这就不随机了。问题出在哪里了呢,如果直接相加,不同组合可能会产生相同的数字,比如 1+2 和 2+1 都是3。所以需要给第一个rand2()升一个维度,让其乘上一个数字,再相加。比如对于(rand2() - 1) * 2 +rand2()``,如下:

1
2
3
4
5
(rand2() - 1) * 2 + rand()2  =   ?
1 1 1
1 2 2
2 1 3
2 2 4

这时右边生成的 1,2,3,4 就是等概率出现的了。这样就通过使用rand2(),来生成rand4()了。那么反过来想一下,可以通过rand4()来生成rand2(),其实更加简单,我们只需通过rand4() % 2 + 1即可,如下:

1
2
3
4
5
rand4() % 2 + 1 =  ?
1 2
2 1
3 2
4 1

同理,我们也可以通过rand6()来生成rand2(),我们只需通过rand6() % 2 + 1即可,如下:

1
2
3
4
5
6
7
rand6() % 2 + 1 =  ?
1 2
2 1
3 2
4 1
5 2
6 1

所以,回到这道题,我们可以先凑出rand10*N(),然后再通过rand10*N() % 10 + 1来获得rand10()。那么,只需要将rand7()转化为rand10*N()即可,根据前面的讲解,我们转化也必须要保持等概率,那么就可以变化为(rand7() - 1) * 7 + rand7(),就转为了rand49()。但是 49 不是 10 的倍数,不过 49 包括好几个 10 的倍数,比如 40,30,20,10 等。这里,我们需要把rand49()转为rand40(),需要用到拒绝采样Rejection Sampling。这种采样方法就是随机到需要的数字就接受,不是需要的就拒绝,并重新采样,这样还能保持等概率。

当用 rand49()生成一个 [1, 49] 范围内的随机数,如果其在 [1, 40] 范围内,我们就将其转为rand10()范围内的数字,直接对 10 去余并加1,返回即可。如果不是,则继续循环即可,参见代码如下:

1
2
3
4
5
6
7
8
9
class Solution {
public:
int rand10() {
while (true) {
int num = (rand7() - 1) * 7 + rand7();
if (num <= 40) return num % 10 + 1;
}
}
};

我们可以不用 while 循环,而采用调用递归函数。

1
2
3
4
5
6
7
class Solution {
public:
int rand10() {
int num = (rand7() - 1) * 7 + rand7();
return (num <= 40) ? (num % 10 + 1) : rand10();
}
};

Leetcode472. Concatenated Words

Given an array of strings words (without duplicates), return all the concatenated words in the given list of words.

A concatenated word is defined as a string that is comprised entirely of at least two shorter words in the given array.

Example 1:

1
2
3
4
5
Input: words = ["cat","cats","catsdogcats","dog","dogcatsdog","hippopotamuses","rat","ratcatdogcat"]
Output: ["catsdogcats","dogcatsdog","ratcatdogcat"]
Explanation: "catsdogcats" can be concatenated by "cats", "dog" and "cats";
"dogcatsdog" can be concatenated by "dog", "cats" and "dog";
"ratcatdogcat" can be concatenated by "rat", "cat", "dog" and "cat".

Example 2:

1
2
Input: words = ["cat","dog","catdog"]
Output: ["catdog"]

这道题给了一个由单词组成的数组,某些单词是可能由其他的单词组成的,让我们找出所有这样的单词。我们首先把所有单词都放到一个unordered_set中,这样可以快速找到某个单词是否在数组中存在。对于当前要判断的单词,我们先将其从set中删去,然后调用之前的Word Break的解法。如果是可以拆分,那么我们就存入结果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
class Solution {
public:
vector<string> findAllConcatenatedWordsInADict(vector<string>& words) {
if (words.size() == 0)
return {};
vector<string> res;
unordered_set<string> dict(words.begin(), words.end());
for (int i = 0; i < words.size(); i ++) {
dict.erase(words[i]);
int len = words[i].size();
vector<bool> flag(len+1, false);
flag[0] = true;
for (int j = 0; j <= len; j ++)
for (int k = 0; k < j; k ++)
if (flag[k] && dict.count(words[i].substr(k, j-k))) {
flag[j] = true;
break;
}
if (flag[len])
res.push_back(words[i]);
dict.insert(words[i]);
}
return res;
}
};

Leetcode473. Matchsticks to Square

Remember the story of Little Match Girl? By now, you know exactly what matchsticks the little match girl has, please find out a way you can make one square by using up all those matchsticks. You should not break any stick, but you can link them up, and each matchstick must be used exactly one time.

Your input will be several matchsticks the girl has, represented with their stick length. Your output will either be true or false, to represent whether you could make one square using all the matchsticks the little match girl has.

Example 1:

1
2
3
Input: [1,1,2,2,2]
Output: true
Explanation: You can form a square with length 2, one side of the square came two sticks with length 1.

Example 2:

1
2
3
Input: [3,3,3,3,4]
Output: false
Explanation: You cannot find a way to form a square with all the matchsticks.

Note:

  • The length sum of the given matchsticks is in the range of 0 to 10^9.
  • The length of the given matchstick array will not exceed 15.

这道题让我们用数组中的数字来摆出一个正方形。这道题实际上是让我们将一个数组分成四个和相等的子数组。可以用优化过的递归来解,递归的方法基本上等于brute force。先给数组从大到小的顺序排序,这样大的数字先加,如果超过target了,就直接跳过了后面的再次调用递归的操作,效率会提高不少。我们建立一个长度为4的数组sums来保存每个边的长度和,我们希望每条边都等于target,数组总和的四分之一。然后我们遍历sums中的每条边,我们判断如果加上数组中的当前数字大于target,那么我们跳过,如果没有,我们就加上这个数字,然后对数组中下一个位置调用递归,如果返回为真,我们返回true,否则我们再从sums中对应位置将这个数字减去继续循环,参见代码如下:

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:
bool makesquare(vector<int>& nums) {
if (nums.empty() || nums.size() < 4) return false;
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % 4 != 0) return false;
vector<int> sums(4, 0);
sort(nums.rbegin(), nums.rend());
return helper(nums, sums, 0, sum / 4);
}
bool helper(vector<int>& nums, vector<int>& sums, int pos, int target) {
if (pos >= nums.size()) {
return sums[0] == target && sums[1] == target && sums[2] == target;
}
for (int i = 0; i < 4; ++i) {
if (sums[i] + nums[pos] > target) continue;
sums[i] += nums[pos];
if (helper(nums, sums, pos + 1, target)) return true;
sums[i] -= nums[pos];
}
return false;
}
};

Leetcode474. Ones and Zeroes

In the computer world, use restricted resource you have to generate maximum benefit is what we always want to pursue.

For now, suppose you are a dominator of m 0s and n 1s respectively. On the other hand, there is an array with strings consisting of only 0s and 1s.

Now your task is to find the maximum number of strings that you can form with given m 0s and n 1s. Each 0 and 1 can be used at most once.

Note:

  • The given numbers of 0s and 1s will both not exceed 100
  • The size of given string array won’t exceed 600.

Example 1:

1
2
3
Input: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
Output: 4
Explanation: This are totally 4 strings can be formed by the using of 5 0s and 3 1s, which are “10,”0001”,”1”,”0”

Example 2:

1
2
3
Input: Array = {"10", "0", "1"}, m = 1, n = 1
Output: 2
Explanation: You could form "10", but then you'd have nothing left. Better form "0" and "1".

这道题是一道典型的应用DP来解的题,我们需要建立一个二维的DP数组,其中dp[i][j]表示有i个0和j个1时能组成的最多字符串的个数,而对于当前遍历到的字符串,我们统计出其中0和1的个数为zeros和ones,然后dp[i - zeros][j - ones]表示当前的i和j减去zeros和ones之前能拼成字符串的个数,那么加上当前的zeros和ones就是当前dp[i][j]可以达到的个数,我们跟其原有数值对比取较大值即可,所以递推式如下:

1
dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1);

有了递推式,我们就可以很容易的写出代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (string str : strs) {
int zeros = 0, ones = 0;
for (char c : str) (c == '0') ? ++zeros : ++ones;
for (int i = m; i >= zeros; --i) {
for (int j = n; j >= ones; --j) {
dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1);
}
}
}
return dp[m][n];
}
};

Leetcode475. Heaters

Winter is coming! Your first job during the contest is to design a standard heater with fixed warm radius to warm all the houses.

Now, you are given positions of houses and heaters on a horizontal line, find out minimum radius of heaters so that all houses could be covered by those heaters.

So, your input will be the positions of houses and heaters seperately, and your expected output will be the minimum radius standard of heaters.

Note:

  • Numbers of houses and heaters you are given are non-negative and will not exceed 25000.
  • Positions of houses and heaters you are given are non-negative and will not exceed 10^9.
  • As long as a house is in the heaters’ warm radius range, it can be warmed.
  • All the heaters follow your radius standard and the warm radius will the same.

Example 1:

1
2
3
Input: [1,2,3],[2]
Output: 1
Explanation: The only heater was placed in the position 2, and if we use the radius 1 standard, then all the houses can be warmed.

Example 2:
1
2
3
Input: [1,2,3,4],[1,4]
Output: 1
Explanation: The two heater was placed in the position 1 and 4. We need to use radius 1 standard, then all the houses can be warmed.

思路:

  1. 先对houses和heaters排序,result记录全局最小温暖半径,temp记录当前house的最小温暖半径。
  2. 依次为每个house查找最小的温暖半径(显然,每个house的最小半径只需考虑其左边最近的heaters和右边最近的heaters)。
  3. 对每一个house先查找位置不小于其位置的第一个heater,其位置为j。
  4. 若未找到,则当前house的最小温暖半径由左边最近的heaters决定。
  5. 若第一个heater的位置就不小于当前house的位置,则当前house的最小温暖半径由右边最近的heaters决定。
  6. 若找到的位置不小于当前house位置的第一个heater的位置大于当前house位置(若等于,则当前house的最小温暖半径等于0),则当前house的最小温暖半径是其与左边最近的heaters的距离和其与右边最近的heaters的距离的较小值。
  7. 若当前house的最小温暖半径大于全局result,则更新result。
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 findRadius(vector<int>& houses, vector<int>& heaters) {
int res = 0;
sort(houses.begin(), houses.end());
sort(heaters.begin(), heaters.end());
for(int house = 0, heater = 0; house < houses.size(); house ++) {
int temp = 0;
while(heater < heaters.size() && heaters[heater] < houses[house])
heater ++;
if(heater == heaters.size())
temp = houses[house] - heaters[heaters.size() - 1];
else if(heater == 0)
temp = heaters[0] - houses[house];
else if(heaters[heater] > houses[house])
temp = min(heaters[heater] - houses[house], houses[house] - heaters[heater-1]);
if(temp > res)
res = temp;
}
return res;
}
};

Leetcode476. Number Complement

Given a positive integer num, output its complement number. The complement strategy is to flip the bits of its binary representation.

Example 1:

1
2
3
Input: num = 5
Output: 2
Explanation: The binary representation of 5 is 101 (no leading zero bits), and its complement is 010. So you need to output 2.

Example 2:
1
2
3
Input: num = 1
Output: 0
Explanation: The binary representation of 1 is 1 (no leading zero bits), and its complement is 0. So you need to output 0.

给定一个正整数,对该数的二进制表示形式,从最高位的1开始向后按位取反。如果我们能知道该数最高位的1所在的位置,就可以构造一个长度和该数据所占位置一样长的一个掩码mask,然后概述和mask进行异或即可。例如:5的二进制是101,我们的构造的掩码为mask=111,两者异或则为010,即是所要的结果。
1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int findComplement(int num) {
long mask = 1, temp = num;
while(temp > 0) {
mask = mask << 1;
temp = temp >> 1;
}
return num^(mask-1);
}
};

Leetcode477. Total Hamming Distance

The Hamming distance between two integers is the number of positions at which the corresponding bits are different.

Given an integer array nums, return the sum of Hamming distances between all the pairs of the integers in nums.

Example 1:

1
2
3
4
5
6
Input: nums = [4,14,2]
Output: 6
Explanation: In binary representation, the 4 is 0100, 14 is 1110, and 2 is 0010 (just
showing the four bits relevant in this case).
The answer will be:
HammingDistance(4, 14) + HammingDistance(4, 2) + HammingDistance(14, 2) = 2 + 2 + 2 = 6.

Example 2:

1
2
Input: nums = [4,14,4]
Output: 4

这道题是之前那道 Hamming Distance 的拓展,由于有之前那道题的经验,我们知道需要用异或来求每个位上的情况,那么需要来找出某种规律来,比如看下面这个例子,4,14,2 和1:

4: 0 1 0 0

14: 1 1 1 0

2: 0 0 1 0

1: 0 0 0 1

先看最后一列,有三个0和一个1,那么它们之间相互的汉明距离就是3,即1和其他三个0分别的距离累加,然后在看第三列,累加汉明距离为4,因为每个1都会跟两个0产生两个汉明距离,同理第二列也是4,第一列是3。仔细观察累计汉明距离和0跟1的个数,可以发现其实就是0的个数乘以1的个数,发现了这个重要的规律,那么整道题就迎刃而解了,只要统计出每一位的1的个数即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int totalHammingDistance(vector<int>& nums) {
int res = 0, n = nums.size();
for (int i = 0; i < 32; ++i) {
int cnt = 0;
for (int num : nums) {
if (num & (1 << i)) ++cnt;
}
res += cnt * (n - cnt);
}
return res;
}
};

Leetcode478. Generate Random Point in a Circle

Given the radius and x-y positions of the center of a circle, write a function randPoint which generates a uniform random point in the circle.

Note:

  • input and output values are in floating-point.
  • radius and x-y position of the center of the circle is passed into the class constructor.
  • a point on the circumference of the circle is considered to be in the circle.
  • randPoint returns a size 2 array containing x-position and y-position of the random point, in that order.

Example 1:

1
2
3
4
Input: 
["Solution","randPoint","randPoint","randPoint"]
[[1,0,0],[],[],[]]
Output: [null,[-0.72939,-0.65505],[-0.78502,-0.28626],[-0.83119,-0.19803]]

Example 2:

1
2
3
4
Input: 
["Solution","randPoint","randPoint","randPoint"]
[[10,5,-7.5],[],[],[]]
Output: [null,[11.52438,-8.33273],[2.46992,-16.21705],[11.13430,-12.42337]]

Explanation of Input Syntax:

  • The input is two lists: the subroutines called and their arguments. Solution’s constructor has three arguments, the radius, x-position of the center, and y-position of the center of the circle. randPoint has no arguments. Arguments are always wrapped with a list, even if there aren’t any.

这道题给了我们一个圆,包括中点位置和半径,让随机生成圆中的任意一个点。这里说明了圆上也当作是圆中,而且这里的随机意味着要等概率。

圆的方程表示为(x - a) ^ 2 + (y - b) ^ 2 = r ^ 2,这里的(a, b)是圆心位置,r为半径。那么如何生成圆中的任意位置呢,如果用这种方式来生成,先随机出一个x,随机出y的时候还要考虑其是否在圆中间,比较麻烦。继续回到高中时代,模糊的记忆中飘来了三个字,极坐标。是的,圆还可以用极坐标的形式来表示,只需随机出一个角度 theta,再随机出一个小于半径的长度,这样就可以得到圆中的坐标位置了。

先来生成 theta吧,由于一圈是 360 度,即 2pi,所以随机出一个 [0, 1] 中的小数,再乘以 2pi,就可以了。然后就是随机小于半径的长度,这里有个问题需要注意一下,这里并不是直接随机出一个 [0, 1] 中的小数再乘以半径r,而是要对随机出的 [0, 1] 中的小数取个平方根再乘以半径r。这是为啥呢,简单来说,是为了保证等概率。如果不用平方根的话,那么表示圆的时候(len * cos(theta)) ^ 2 + (len * sin(theta) ^ 2,这里就相当于对随机出的 [0, 1] 中的小数平方了,那么其就不是等概率的了,因为两个小于1的小数相乘了,其会更加靠近0,这就是为啥要平方一下的原因。最后在求点位置的时候要加上圆心的偏移即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
Solution(double radius, double x_center, double y_center) {
r = radius; centerX = x_center; centerY = y_center;
}

vector<double> randPoint() {
double theta = 2 * M_PI * ((double)rand() / RAND_MAX);
double len = sqrt((double)rand() / RAND_MAX) * r;
return {centerX + len * cos(theta), centerY + len * sin(theta)};
}

private:
double r, centerX, centerY;
};

这其实就是拒绝采样的经典应用,在一个正方形中有均匀分布的点,随机出其内切圆中的一个点,那么就是随机出x和y之后,然后算其平方和,如果小于等于r平方,说明其在圆内,可以返回其坐标,记得加上圆心偏移,否则重新进行采样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
Solution(double radius, double x_center, double y_center) {
r = radius; centerX = x_center; centerY = y_center;
}

vector<double> randPoint() {
while (true) {
double x = (2 * (double)rand() / RAND_MAX - 1.0) * r;
double y = (2 * (double)rand() / RAND_MAX - 1.0) * r;
if (x * x + y * y <= r * r) return {centerX + x, centerY + y};
}
}

private:
double r, centerX, centerY;
};

480. Sliding Window Median

Median is the middle value in an ordered integer list. If the size of the list is even, there is no middle value. So the median is the mean of the two middle value.

Examples:

[2,3,4] , the median is 3

[2,3], the median is (2 + 3) / 2 = 2.5

Given an array 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. Your job is to output the median array for each window in the original array.

For example,

Given nums = [1,3,-1,-3,5,3,6,7], and k = 3.

1
2
3
4
5
6
7
8
Window position                Median
--------------- -----
[1 3 -1] -3 5 3 6 7 1
1 [3 -1 -3] 5 3 6 7 -1
1 3 [-1 -3 5] 3 6 7 -1
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] 6

Therefore, return the median sliding window as [1,-1,-1,3,5,6].

Note:

  • You may assume k is always valid, ie: 1 ≤ k ≤ input array’s size for non-empty array.

这道题给了我们一个数组,还是滑动窗口的大小,让我们求滑动窗口的中位数。我想起来之前也有一道滑动窗口的题Sliding Window Maximum,于是想套用那道题的方法,可以用deque怎么也做不出,因为求中位数并不是像求最大值那样只操作deque的首尾元素。后来看到了史蒂芬大神的方法,原来是要用一个multiset集合,和一个指向最中间元素的iterator。我们首先将数组的前k个数组加入集合中,由于multiset自带排序功能,所以我们通过k/2能快速的找到指向最中间的数字的迭代器mid,如果k为奇数,那么mid指向的数字就是中位数;如果k为偶数,那么mid指向的数跟前面那个数求平均值就是中位数。当我们添加新的数字到集合中,multiset会根据新数字的大小加到正确的位置,然后我们看如果这个新加入的数字比之前的mid指向的数小,那么中位数肯定被拉低了,所以mid往前移动一个,再看如果要删掉的数小于等于mid指向的数(注意这里加等号是因为要删的数可能就是mid指向的数),则mid向后移动一个。然后我们将滑动窗口最左边的数删掉,我们不能直接根据值来用erase来删数字,因为这样有可能删掉多个相同的数字,而是应该用lower_bound来找到第一个不小于目标值的数,通过iterator来删掉确定的一个数字,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
vector<double> medianSlidingWindow(vector<int>& nums, int k) {
vector<double> res;
multiset<double> ms(nums.begin(), nums.begin() + k);
auto mid = next(ms.begin(), k / 2);
for (int i = k; ; ++i) {
res.push_back((*mid + *prev(mid, 1 - k % 2)) / 2);
if (i == nums.size()) return res;
ms.insert(nums[i]);
if (nums[i] < *mid) --mid;
if (nums[i - k] <= *mid) ++mid;
ms.erase(ms.lower_bound(nums[i - k]));
}
}
};

假定窗口里已经排序了,前半部分是x,后半部分是y,用multiset能自动排序。两个集合的size顶多相差1。

a[i-k]在x中,从x中删除;若不在x中则从y中删除。x中的最大值挪到y中成为最小值。维护x.size()-y.size()=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
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
class Solution {
public:

double calc(const multiset<int>& x, const multiset<int>& y, int k) {
if (k & 1) return *x.rbegin();
return (1.0 * *x.rbegin() + *y.begin()) / 2.0;
}

void remove(multiset<int>& x, multiset<int>& y, int val) {
if (x.count(val)) {
x.erase(x.find(val));
if (x.size() < y.size() && y.size()) {
int v = *y.begin();
x.insert(v);
y.erase(y.find(v));
}
return;
}

y.erase(y.find(val));
if (x.size() - y.size() > 1) {
int v = *x.rbegin();
x.erase(x.find(v));
y.insert(v);
}
}

void add(multiset<int>& x, multiset<int>& y, int val) {
if (x.empty() || val < *x.rbegin()) {
x.insert(val);
if (x.size() - y.size() > 1) {
int v = *x.rbegin();
x.erase(x.find(v));
y.insert(v);
}
return;
}

y.insert(val);
if (y.size() > x.size()) {
int v = *y.begin();
y.erase(y.find(v));
x.insert(v);
}
}

vector<double> medianSlidingWindow(vector<int>& a, int k) {
int n = a.size();
multiset<int> x, y;

// k 个元素
for (int i = 0 ; i < k; i ++)
x.insert(a[i]);

for (int i = 0; i < k/2; i ++) {
int val = *x.rbegin();
x.erase(x.find(val));
y.insert(val);
}

vector<double> res;
res.push_back(calc(x, y, k));

for (int l = 1, r = k; r < n; l ++, r ++) {
// 先删除a[l-1],这是上一个窗口的最小元素
remove(x, y, a[l-1]);
add(x, y, a[r]);
res.push_back(calc(x, y, k));
}
return res;
}
};

上面的方法用到了很多STL内置的函数,比如next,lower_bound啥的,下面我们来看一种不使用这些函数的解法。这种解法跟Find Median from Data Stream那题的解法很类似,都是维护了small和large两个堆,分别保存有序数组的左半段和右半段的数字,保持small的长度大于等于large的长度。我们开始遍历数组nums,如果i>=k,说明此时滑动窗口已经满k个了,再滑动就要删掉最左值了,我们分别在small和large中查找最左值,有的话就删掉。然后处理增加数字的情况(分两种情况:1.如果small的长度小于large的长度,再看如果large是空或者新加的数小于等于large的首元素,我们把此数加入small中。否则就把large的首元素移出并加入small中,然后把新数字加入large。2.如果small的长度大于large,再看如果新数字大于small的尾元素,那么新数字加入large中,否则就把small的尾元素移出并加入large中,把新数字加入small中)。最后我们再计算中位数并加入结果res中,根据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
class Solution {
public:
vector<double> medianSlidingWindow(vector<int>& nums, int k) {
vector<double> res;
multiset<int> small, large;
for (int i = 0; i < nums.size(); ++i) {
if (i >= k) {
if (small.count(nums[i - k])) small.erase(small.find(nums[i - k]));
else if (large.count(nums[i - k])) large.erase(large.find(nums[i - k]));
}
if (small.size() <= large.size()) {
if (large.empty() || nums[i] <= *large.begin()) small.insert(nums[i]);
else {
small.insert(*large.begin());
large.erase(large.begin());
large.insert(nums[i]);
}
} else {
if (nums[i] >= *small.rbegin()) large.insert(nums[i]);
else {
large.insert(*small.rbegin());
small.erase(--small.end());
small.insert(nums[i]);
}
}
if (i >= (k - 1)) {
if (k % 2) res.push_back(*small.rbegin());
else res.push_back(((double)*small.rbegin() + *large.begin()) / 2);
}
}
return res;
}
};

Leetcode481. Magical String

A magical string S consists of only ‘1’ and ‘2’ and obeys the following rules:

The string S is magical because concatenating the number of contiguous occurrences of characters ‘1’ and ‘2’ generates the string S itself.

The first few elements of string S is the following: S = “1221121221221121122……”

If we group the consecutive ‘1’s and ‘2’s in S, it will be:

1
1 22 11 2 1 22 1 22 11 2 11 22 ……

and the occurrences of ‘1’s or ‘2’s in each group are:

1
1 2 2 1 1 2 1 2 2 1 2 2 ……

You can see that the occurrence sequence above is the S itself.

Given an integer N as input, return the number of ‘1’s in the first N number in the magical string S.

Note: N will not exceed 100,000.

Example 1:

1
2
3
Input: 6
Output: 3
Explanation: The first 6 elements of magical string S is "12211" and it contains three 1's, so return 3.

这道题介绍了一种神奇字符串,只由1和2组成,通过计数1组和2组的个数,又能生成相同的字符串。而让我们求前n个数字中1的个数。让我们按规律生成这个神奇字符串,只有生成了字符串的前n个字符,才能统计出1的个数。其实这道题的难点就是在于找到规律来生成字符串。

根据第三个数字2开始往后生成数字,此时生成两个1,然后根据第四个数字1,生成一个2,再根据第五个数字1,生成一个1,以此类推,生成的数字1或2可能通过异或3来交替生成,在生成的过程中同时统计1的个数即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int magicalString(int n) {
if (n <= 0) return 0;
if (n <= 3) return 1;
int res = 1, head = 2, tail = 3, num = 1;
vector<int> v{1, 2, 2};
while (tail < n) {
for (int i = 0; i < v[head]; ++i) {
v.push_back(num);
if (num == 1 && tail < n) ++res;
++tail;
}
num ^= 3;
++head;
}
return res;
}
}

Leetcode482. License Key Formatting

You are given a license key represented as a string S which consists only alphanumeric character and dashes. The string is separated into N+1 groups by N dashes.

Given a number K, we would want to reformat the strings such that each group contains exactly K characters, except for the first group which could be shorter than K, but still must contain at least one character. Furthermore, there must be a dash inserted between two groups and all lowercase letters should be converted to uppercase.

Given a non-empty string S and a number K, format the string according to the rules described above.

Example 1:

1
2
3
4
Input: S = "5F3Z-2e-9-w", K = 4
Output: "5F3Z-2E9W"
Explanation: The string S has been split into two parts, each part has 4 characters.
Note that the two extra dashes are not needed and can be removed.

Example 2:
1
2
3
Input: S = "2-5g-3-J", K = 2
Output: "2-5G-3J"
Explanation: The string S has been split into three parts, each part has 2 characters except the first part as it could be shorter as mentioned above.

Note:

  • The length of string S will not exceed 12,000, and K is a positive integer.
  • String S consists only of alphanumerical characters (a-z and/or A-Z and/or 0-9) and dashes(-).
  • String S is non-empty.

繁琐的字符串拼接题。。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
string licenseKeyFormatting(string S, int K) {
string tmp, res;
for (int i = S.size() - 1; i >= 0; i--) {
if(S[i] == '-')
continue;
tmp = (char) toupper(S[i]) + tmp;
if(tmp.length() == K) {
res.insert(0, "-" + tmp);
tmp.clear();
}
}
if (tmp.empty() && !res.empty()) return res.substr(1);
res = tmp + res;
return res;
}
};

Leetcode485. Max Consecutive Ones

Given a binary array, find the maximum number of consecutive 1s in this array.

Example 1:

1
2
3
4
Input: [1,1,0,1,1,1]
Output: 3
Explanation: The first two digits or the last three digits are consecutive 1s.
The maximum number of consecutive 1s is 3.

计算最长的连续1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findMaxConsecutiveOnes(vector<int>& nums) {
int res = 0, count = 0;;
for(int i = 0; i < nums.size(); i ++) {
if(nums[i] == 0) {
res = count > res ? count : res;
count = 0;
}
else
count ++;
}
res = count > res ? count : res;
return res;
}
};

Leetcode486. Predict the Winner

Given an array of scores that are non-negative integers. Player 1 picks one of the numbers from either end of the array followed by the player 2 and then player 1 and so on. Each time a player picks a number, that number will not be available for the next player. This continues until all the scores have been chosen. The player with the maximum score wins.

Given an array of scores, predict whether player 1 is the winner. You can assume each player plays to maximize his score.

Example 1:

1
2
3
4
5
6
Input: [1, 5, 2]
Output: False
Explanation: Initially, player 1 can choose between 1 and 2.
If he chooses 2 (or 1), then player 2 can choose from 1 (or 2) and 5. If player 2 chooses 5, then player 1 will be left with 1 (or 2).
So, final score of player 1 is 1 + 2 = 3, and player 2 is 5.
Hence, player 1 will never be the winner and you need to return False.

Example 2:

1
2
3
4
Input: [1, 5, 233, 7]
Output: True
Explanation: Player 1 first chooses 1. Then player 2 have to choose between 5 and 7. No matter which number player 2 choose, player 1 can choose 233.
Finally, player 1 has more score (234) than player 2 (12), so you need to return True representing player1 can win.

Note:

  • 1 <= length of the array <= 20.
  • Any scores in the given array are non-negative integers and will not exceed 10,000,000.
  • If the scores of both players are equal, then player 1 is still the winner.

这道题给了一个小游戏,有一个数组,两个玩家轮流取数,说明了只能从开头或结尾取,问我们第一个玩家能赢吗。而且当前玩家赢返回 true 的条件就是递归调用下一个玩家输返回 false。这里需要一个变量来标记当前是第几个玩家,还需要两个变量来分别记录两个玩家的当前数字和,在递归函数里面,如果当前数组为空了,直接比较两个玩家的当前得分即可,如果数组中只有一个数字了,根据玩家标识来将这个数字加给某个玩家并进行比较总得分。如果数组有多个数字,分别生成两个新数组,一个是去掉首元素,一个是去掉尾元素,然后根据玩家标识分别调用不同的递归,只要下一个玩家两种情况中任意一种返回 false 了,那么当前玩家就可以赢了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool PredictTheWinner(vector<int>& nums) {
return canWin(nums, 0, 0, 1);
}
bool canWin(vector<int> nums, int sum1, int sum2, int player) {
if (nums.empty()) return sum1 >= sum2;
if (nums.size() == 1) {
if (player == 1) return sum1 + nums[0] >= sum2;
else if (player == 2) return sum2 + nums[0] > sum1;
}
vector<int> va = vector<int>(nums.begin() + 1, nums.end());
vector<int> vb = vector<int>(nums.begin(), nums.end() - 1);
if (player == 1) {
return !canWin(va, sum1 + nums[0], sum2, 2) || !canWin(vb, sum1 + nums.back(), sum2, 2);
} else if (player == 2) {
return !canWin(va, sum1, sum2 + nums[0], 1) || !canWin(vb, sum1, sum2 + nums.back(), 1);
}
}
};

两人依次拿,如果Player1赢,则Player1拿的>Player2拿的。我们把Player1拿的视为”+”,把Player2拿的视为”-“,如果最后结果大于等于0则Player1赢。

因此对于递归来说,beg ~ end的结果为max(nums[beg] - partition(beg + 1, end), nums[end] - partition(beg, end + 1));对于非递归来说DP[beg][end]表示即为beg ~ end所取的值的大小(最终与零比较)。

总结:

  1. 该问题没有直接比较一个选手所拿元素的和值,而是把问题转换为两个选手所拿元素的差值。这一点很巧妙,是关键的一步。
  2. 找出递推表达式:max(nums[beg] - partition(beg + 1, end), nums[end] - partition(beg, end + 1))
  3. 通过递推表达式构造递归算法是比较简单的。但是要构造一个非递归的算法难度较大。对于非递归算法,首先在dp中赋初始值,这是我们解题的第一步。在这个问题中,我们使用一个二位的数组dp来表示nums数组中任意开始和结束位置两人结果的差值。

初始的时候,我们仅仅知道对角线上的值。dp[i][i] = nums[i]

接下来既然是求任意的开始和结束,对于二维数组,那肯定是一个双层的循环。通过dp中已知的元素和动态规划的递推表达式,我们就可以构造出我们的需要的结果。非递归的方式是从小问题到大问题的过程。

1
2
3
4
5
6
7
8
9
10
public class Solution {
public boolean PredictTheWinner(int[] nums) {
return helper(nums, 0, nums.length-1) >= 0;
}

public int helper(int[] nums, int start, int end) {
if(start == end) return nums[start];
else return Math.max(nums[start]-helper(nums, start+1, end), nums[end]-helper(nums, start, end-1));
}
}

【java代码——递归2(保存中间状态)】

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Solution {
public boolean PredictTheWinner(int[] nums) {
return helper(nums, 0, nums.length-1, new Integer[nums.length][nums.length]) >= 0;
}

public int helper(int[] nums, int start, int end, Integer[][] dp) {
if(dp[start][end] == null) {
if(start == end) return nums[start];
else return Math.max(nums[start]-helper(nums, start+1,end, dp), nums[end]-helper(nums, start,end-1, dp));
}
return dp[start][end];
}
}

Leetcode488. 祖玛游戏

你正在参与祖玛游戏的一个变种。

在这个祖玛游戏变体中,桌面上有 一排 彩球,每个球的颜色可能是:红色 ‘R’、黄色 ‘Y’、蓝色 ‘B’、绿色 ‘G’ 或白色 ‘W’ 。你的手中也有一些彩球。

你的目标是 清空 桌面上所有的球。每一回合:

从你手上的彩球中选出 任意一颗 ,然后将其插入桌面上那一排球中:两球之间或这一排球的任一端。
接着,如果有出现 三个或者三个以上 且 颜色相同 的球相连的话,就把它们移除掉。
如果这种移除操作同样导致出现三个或者三个以上且颜色相同的球相连,则可以继续移除这些球,直到不再满足移除条件。
如果桌面上所有球都被移除,则认为你赢得本场游戏。
重复这个过程,直到你赢了游戏或者手中没有更多的球。
给你一个字符串 board ,表示桌面上最开始的那排球。另给你一个字符串 hand ,表示手里的彩球。请你按上述操作步骤移除掉桌上所有球,计算并返回所需的 最少 球数。如果不能移除桌上所有的球,返回 -1 。

示例 1:

1
2
3
4
5
6
输入:board = "WRRBBW", hand = "RB"
输出:-1
解释:无法移除桌面上的所有球。可以得到的最好局面是:
- 插入一个 'R' ,使桌面变为 WRRRBBW 。WRRRBBW -> WBBW
- 插入一个 'B' ,使桌面变为 WBBBW 。WBBBW -> WW
桌面上还剩着球,没有其他球可以插入。

示例 2:

1
2
3
4
5
6
输入:board = "WWRRBBWW", hand = "WRBRW"
输出:2
解释:要想清空桌面上的球,可以按下述步骤:
- 插入一个 'R' ,使桌面变为 WWRRRBBWW 。WWRRRBBWW -> WWBBWW
- 插入一个 'B' ,使桌面变为 WWBBBWW 。WWBBBWW -> WWWW -> empty
只需从手中出 2 个球就可以清空桌面。

示例 3:

1
2
3
4
5
6
输入:board = "G", hand = "GGGGG"
输出:2
解释:要想清空桌面上的球,可以按下述步骤:
- 插入一个 'G' ,使桌面变为 GG 。
- 插入一个 'G' ,使桌面变为 GGG 。GGG -> empty
只需从手中出 2 个球就可以清空桌面。

示例 4:

1
2
3
4
5
6
7
输入:board = "RBYYBBRRB", hand = "YRBGB"
输出:3
解释:要想清空桌面上的球,可以按下述步骤:
- 插入一个 'Y' ,使桌面变为 RBYYYBBRRB 。RBYYYBBRRB -> RBBBRRB -> RRRB -> B
- 插入一个 'B' ,使桌面变为 BB 。
- 插入一个 'B' ,使桌面变为 BBB 。BBB -> empty
只需从手中出 3 个球就可以清空桌面。

根据题目要求,桌面上最多有 1616 个球,手中最多有 55 个球;我们可以以任意顺序在 55 个回合中使用手中的球;在每个回合中,我们可以选择将手中的球插入到桌面上任意两球之间或这一排球的任意一端。

因为插入球的颜色和位置的选择是多样的,选择的影响也可能在多次消除操作之后才能体现出来,所以通过贪心方法根据当前情况很难做出全局最优的决策。实际每次插入一个新的小球时,并不保证插入后一定可以消除,因此我们需要搜索和遍历所有可能的插入方法,找到最小的插入次数。比如以下测试用例:

桌面上的球为 RRWWRRBBRR,手中的球为 WB,如果我们按照贪心法每次插入进行消除就会出现无法完全消除。因此,我们使用广度优先搜索来解决这道题。即对状态空间进行枚举,通过穷尽所有的可能来找到最优解,并使用剪枝的方法来优化搜索过程。

为什么使用广度优先搜索?
我们不妨规定,每一种不同的桌面上球的情况和手中球的情况的组合都是一种不同的状态。对于相同的状态,其清空桌面上球所需的回合数总是相同的;而不同的插入球的顺序,也可能得到相同的状态。因此,如果使用深度优先搜索,则需要使用记忆化搜索,以避免重复计算相同的状态。

因为只需要找出需要回合数最少的方案,因此使用广度优先搜索可以得到可以消除桌面上所有球的方案时就直接返回结果,而不需要继续遍历更多需要回合数更多的方案。而广度优先搜索虽然需要在队列中存储较多的状态,但是因为使用深度优先搜索也需要存储这些状态及这些状态对应的结果,因此使用广度优先搜索并不会需要更多的空间。

在算法的实现中,我们可以通过以下方法来实现广度优先:

使用队列来维护需要处理的状态队列,使用哈希集合存储已经访问过的状态。每一次取出队列中的队头状态,考虑其中所有可以插入球的方案,如果新方案还没有被访问过,则将新方案添加到队列的队尾。

下面,我们考虑剪枝条件:

第 1 个剪枝条件:手中颜色相同的球每次选择时只需要考虑其中一个即可
如果手中有颜色相同的球,那么插入这些球中的哪一个都没有区别。因此,手中颜色相同的球,我们只需要考虑其中一个即可。在具体的实现中,我们可以先将手中的球排序,如果当前遍历的球的颜色和上一个遍历的球的颜色相同,则跳过当前遍历的球。

第 2 个剪枝条件:只在连续相同颜色的球的开头位置或者结尾位置插入新的颜色相同的球
如果桌面上有一个红球,那么在其左侧和右侧插入一个新的红球没有区别;同理,如果桌面上有 2 个连续的红球,那么在其左侧、中间和右侧插入一个新的红球没有区别。因此,如果新插入的球和桌面上某组连续颜色相同的球(也可以是 1 个)的颜色相同,我们只需要考虑在其左侧插入新球的情况即可。在具体的实现中,如果新插入的球和插入位置左侧的球的颜色相同,则跳过这个位置。

第 3 个剪枝条件:只考虑放置新球后有可能得到更优解的位置
考虑插入新球的颜色与插入位置周围球的颜色的情况,在已经根据第 2 个剪枝条件剪枝后,还可能出现如下三种情况:插入新球与插入位置右侧的球颜色相同;插入新球与插入位置两侧的球颜色均不相同,且插入位置两侧的球的颜色不同;插入新球与插入位置两侧的球颜色均不相同,且插入位置两侧的球的颜色相同。

对于「插入新球与插入位置右侧的球颜色相同」的情况,这种操作可能可以构成连续三个相同颜色的球实现消除,是有可能得到更优解的。读者可以结合以下例子理解。

例如:桌面上的球为 WWRRBBWW,手中的球为 WWRB,答案为 2。

操作方法如下:WWRRBBWW -> WW(R)RRBBWW -> WWBBWW -> WW(B)BBWW -> WWWW “”。

对于「插入新球与插入位置两侧的球颜色均不相同,且插入位置两侧的球的颜色不同」的情况,这种操作可以将连续相同颜色的球拆分到不同的组合中消除,也是有可能得到更优解的。读者可以结合以下例子理解。

例如:桌面上的球为 RRWWRRBBRR,手中的球为 WB,答案为 2。

操作方法如下:RRWWRRBBRR→RRWWRRBBR(W)R→RRWWRR(B)BBRWR→RRWWRRRWR→RRWWWR→RRR→””。

对于「插入新球与插入位置两侧的球颜色均不相同,且插入位置两侧的球的颜色相同」的情况,这种操作并不能对消除顺序产生任何影响。如插入位置旁边的球可以消除的话,那么这种插入方法与直接将新球插入到与之颜色相同的球的旁边没有区别。因此,这种操作不能得到比「插入新球与插入位置右侧的球颜色相同」更好的情况,得到更优解。读者可以结合以下例子理解。

例如:桌面上的球为 WWRRBBWW,手中的球为 WWRB,答案为 2。

操作方法如下:WWRRBBWW→WWRRBB(R)WW→WWRRB(B)BRWW→WWRRRWW→WWWW→””。

细节

题目规定了如果在消除操作后,如果导致出现了新的连续三个或者三个以上颜色相同的球,则继续消除这些球,直到不再满足消除条件,实际消除时我们可以利用栈的特性,每次遇到连续可以消除的球时,我们就将其从栈中弹出。在实现中,我们可以在遍历桌面上的球时,使用列表维护遍历过的每种球的颜色和连续数量,从而通过一次遍历消除连续三个或者三个以上颜色相同的球。具体地:

使用 visited_ball 维护遍历过的每种球的颜色和连续数量,设其中最后一个颜色 last_color,其连续数量为last_num;遍历桌面上的球,设当前遍历到的球为cur_ball,其颜色为cur_color。

  • 首先,判断:如果visited_ball 不为空,且cur_color 与last_color 不同,则判断:如果last_num 大于等于 3,则从visited_ball 中移除last_color 和last_num。
  • 接着,判断:如果visited_ball 为空,或cur_color 与last_color 不同,则向visited_ball 添加cur_color 及连续数量 1;
  • 否则,累加last_num。
  • 最后,根据列表中维护的每种球的颜色和连续数量,重新构造桌面上的球的组合即可。
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
struct State {
string board;
string hand;
int step;
State(const string & board, const string & hand, int step) {
this->board = board;
this->hand = hand;
this->step = step;
}
};

class Solution {
public:
string clean(const string & s) {
string res;
vector<pair<char, int>> st;

for (auto c : s) {
while (!st.empty() && c != st.back().first && st.back().second >= 3) {
st.pop_back();
}
if (st.empty() || c != st.back().first) {
st.push_back({c,1});
} else {
st.back().second++;
}
}
if (!st.empty() && st.back().second >= 3) {
st.pop_back();
}
for (int i = 0; i < st.size(); ++i) {
for (int j = 0; j < st[i].second; ++j) {
res.push_back(st[i].first);
}
}
return res;
}

int findMinStep(string board, string hand) {
unordered_set<string> visited;
sort(hand.begin(), hand.end());

visited.insert(board + " " + hand);
queue<State> qu;
qu.push(State(board, hand, 0));
while (!qu.empty()) {
State curr = qu.front();
qu.pop();

for (int j = 0; j < curr.hand.size(); ++j) {
// 第 1 个剪枝条件: 当前选择的球的颜色和前一个球的颜色相同
if (j > 0 && curr.hand[j] == curr.hand[j - 1]) {
continue;
}
for (int i = 0; i <= curr.board.size(); ++i) {
// 第 2 个剪枝条件: 只在连续相同颜色的球的开头位置插入新球
if (i > 0 && curr.board[i - 1] == curr.hand[j]) {
continue;
}

// 第 3 个剪枝条件: 只在以下两种情况放置新球
bool choose = false;
// 第 1 种情况 : 当前球颜色与后面的球的颜色相同
if (i < curr.board.size() && curr.board[i] == curr.hand[j]) {
choose = true;
}
// 第 2 种情况 : 当前后颜色相同且与当前颜色不同时候放置球
if (i > 0 && i < curr.board.size() && curr.board[i - 1] == curr.board[i] && curr.board[i] != curr.hand[j]){
choose = true;
}
if (choose) {
string new_board = clean(curr.board.substr(0, i) + curr.hand[j] + curr.board.substr(i));
string new_hand = curr.hand.substr(0, j) + curr.hand.substr(j + 1);
if (new_board.size() == 0) {
return curr.step + 1;
}
if (!visited.count(new_board + " " + new_hand)) {
qu.push(State(new_board, new_hand, curr.step + 1));
visited.insert(new_board + " " + new_hand);
}
}
}
}
}

return -1;
}
};

Leetcode491. Increasing Subsequences

Given an integer array nums, return all the different possible increasing subsequences of the given array with at least two elements. You may return the answer in any order.

The given array may contain duplicates, and two equal integers should also be considered a special case of increasing sequence.

Example 1:

1
2
Input: nums = [4,6,7,7]
Output: [[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]

Example 2:

1
2
Input: nums = [4,4,3,2,1]
Output: [[4,4]]

这道题让我们找出所有的递增子序列,应该不难想到,这题肯定是要先找出所有的子序列,从中找出递增的。首先来看一种迭代的解法,对于重复项的处理,最偷懒的方法是使用 TreeSet,利用其自动去处重复项的机制,然后最后返回时再转回 vector 即可。由于是找递增序列,所以需要对递归函数做一些修改,首先题目中说明了递增序列数字至少两个,所以只有子序列个数大于等于2时,才加入结果。然后就是要递增,如果之前的数字大于当前的数字,那么跳过这种情况,继续循环,参见代码如下:

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<vector<int>> findSubsequences(vector<int>& nums) {
set<vector<int>> res;
vector<int> s;
helper(nums, 0, s, res);
return vector(res.begin(), res.end());
}

void helper(vector<int>& nums, int i, vector<int> s, set<vector<int>>& res) {
if (s.size() > 1)
res.insert(s);
for (; i < nums.size(); i ++) {
if (!s.empty() && s.back() > nums[i])
continue;
s.push_back(nums[i]);
helper(nums, i+1, s, res);
s.pop_back();
}
}
};

Leetcode492. Construct the Rectangle

For a web developer, it is very important to know how to design a web page’s size. So, given a specific rectangular web page’s area, your job by now is to design a rectangular web page, whose length L and width W satisfy the following requirements:

  1. The area of the rectangular web page you designed must equal to the given target area.
  2. The width W should not be larger than the length L, which means L >= W.
  3. The difference between length L and width W should be as small as possible.

You need to output the length L and the width W of the web page you designed in sequence.
Example:

1
2
3
4
Input: 4
Output: [2, 2]
Explanation: The target area is 4, and all the possible ways to construct it are [1,4], [2,2], [4,1].
But according to requirement 2, [1,4] is illegal; according to requirement 3, [4,1] is not optimal compared to [2,2]. So the length L is 2, and the width W is 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
class Solution {
public:
vector<int> constructRectangle(int area) {
int ii = sqrt(area);
vector<int> res;
res.push_back(1);
res.push_back(area);
for(int i = 1; i <= ii; i ++) {
int temp = area / i;
if(temp * i == area) {
if(abs(res[0]-res[1]) > abs(temp - i)) {
res[0] = i;
res[1] = temp;
}
}
}
if(res[0] < res[1]) {
int temp = res[0];
res[0] = res[1];
res[1] = temp;
}
return res;
}
};

Leetcode493. Reverse Pairs

Given an array nums, we call (i, j) an important reverse pair if i < j and nums[i] > 2*nums[j].

You need to return the number of important reverse pairs in the given array.

Example1:

1
2
Input: [1,3,2,3,1]
Output: 2

Example2:

1
2
Input: [2,4,3,5,1]
Output: 3

Note:

  • The length of the given array will not exceed 50,000.
  • All the numbers in the input array are in the range of 32-bit integer.

一种方法叫分割重现关系 (Partition Recurrence Relation),用式子表示是 T(i, j) = T(i, m) + T(m+1, j) + C。这里的C就是处理合并两个部分的子问题,那么用文字来描述就是“已知翻转对的两个数字分别在子数组 nums[i, m] 和 nums[m+1, j] 之中,求满足要求的翻转对的个数”,这里翻转对的两个条件中的顺序条件已经满足,就只需要找到满足大小关系的的数对即可。如果两个子数组是有序的,那么我们可以用双指针的方法在线性时间内就可以统计出符合题意的翻转对的个数。要想办法产生有序的子数组,那么这就和 MergeSort 的核心思想完美匹配了。我们知道混合排序就是不断的将数组对半拆分成子数组,拆到最小的数组后开始排序,然后一层一层的返回,最后原数组也是有序的了。这里我们在混合排序的递归函数中,对有序的两个子数组进行统计翻转对的个数,区间 [left, mid] 和 [mid+1, right] 内的翻转对儿个数就被分别统计出来了,此时还要统计翻转对儿的两个数字分别在两个区间中的情况,那么i遍历 [left, mid] 区间所有的数字,j则从 mid+1 开始检测,假如 nums[i] 大于 nums[j] 的二倍,则这两个数字就是翻转对,此时j再自增1,直到不满足这个条件停止,则j增加的个数就是符合题意的翻转对的个数,所以用当前的j减去其初始值 mid+1 即为所求,然后再逐层返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int reversePairs(vector<int>& nums) {
return mergeSort(nums, 0, nums.size() - 1);
}
int mergeSort(vector<int>& nums, int left, int right) {
if (left >= right) return 0;
int mid = left + (right - left) / 2;
int res = mergeSort(nums, left, mid) + mergeSort(nums, mid + 1, right);
for (int i = left, j = mid + 1; i <= mid; ++i) {
while (j <= right && nums[i] / 2.0 > nums[j]) ++j;
res += j - (mid + 1);
}
sort(nums.begin() + left, nums.begin() + right + 1);
return res;
}
};

Leetcode494. 目标和

给你一个整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 ‘+’ 或 ‘-‘ ,然后串联起所有整数,可以构造一个 表达式 :

例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-‘ ,然后串联起来得到表达式 “+2-1” 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

1
2
3
4
5
6
7
8
输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

1
2
输入:nums = [1], target = 1
输出:1

这道题给了我们一个数组,和一个目标值,让给数组中每个数字加上正号或负号,然后求和要和目标值相等,求有多少中不同的情况。那么对于这种求多种情况的问题,博主最想到的方法使用递归来做。从第一个数字,调用递归函数,在递归函数中,分别对目标值进行加上当前数字调用递归,和减去当前数字调用递归,这样会涵盖所有情况,并且当所有数字遍历完成后,若目标值为0了,则结果 res 自增1,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
int res = 0;
helper(nums, S, 0, res);
return res;
}
void helper(vector<int>& nums, long S, int start, int& res) {
if (start >= nums.size()) {
if (S == 0) ++res;
return;
}
helper(nums, S - nums[start], start + 1, res);
helper(nums, S + nums[start], start + 1, res);
}
};

我们对上面的递归方法进行优化,使用 memo 数组来记录中间值,这样可以避免重复运算,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
vector<unordered_map<int, int>> memo(nums.size());
return helper(nums, S, 0, memo);
}
int helper(vector<int>& nums, long sum, int start, vector<unordered_map<int, int>>& memo) {
if (start == nums.size()) return sum == 0;
if (memo[start].count(sum)) return memo[start][sum];
int cnt1 = helper(nums, sum - nums[start], start + 1, memo);
int cnt2 = helper(nums, sum + nums[start], start + 1, memo);
return memo[start][sum] = cnt1 + cnt2;
}
};

Leetcode495. Teemo Attacking

Our hero Teemo is attacking an enemy Ashe with poison attacks! When Teemo attacks Ashe, Ashe gets poisoned for a exactly duration seconds. More formally, an attack at second t will mean Ashe is poisoned during the inclusive time interval [t, t + duration - 1]. If Teemo attacks again before the poison effect ends, the timer for it is reset, and the poison effect will end duration seconds after the new attack.

You are given a non-decreasing integer array timeSeries, where timeSeries[i] denotes that Teemo attacks Ashe at second timeSeries[i], and an integer duration.

Return the total number of seconds that Ashe is poisoned.

Example 1:

1
2
3
4
5
6
Input: timeSeries = [1,4], duration = 2
Output: 4
Explanation: Teemo's attacks on Ashe go as follows:
- At second 1, Teemo attacks, and Ashe is poisoned for seconds 1 and 2.
- At second 4, Teemo attacks, and Ashe is poisoned for seconds 4 and 5.
Ashe is poisoned for seconds 1, 2, 4, and 5, which is 4 seconds in total.

Example 2:

1
2
3
4
5
6
Input: timeSeries = [1,2], duration = 2
Output: 3
Explanation: Teemo's attacks on Ashe go as follows:
- At second 1, Teemo attacks, and Ashe is poisoned for seconds 1 and 2.
- At second 2 however, Teemo attacks again and resets the poison timer. Ashe is poisoned for seconds 2 and 3.
Ashe is poisoned for seconds 1, 2, and 3, which is 3 seconds in total.

直接使用贪心算法,比较相邻两个时间点的时间差,如果小于duration,就加上这个差,如果大于或等于,就加上duration即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int findPoisonedDuration(vector<int>& timeSeries, int duration) {
int last = -1, res = 0;
for (int i = 0; i < timeSeries.size(); i ++) {
if (timeSeries[i] <= last) {
if (timeSeries[i] + duration > last) {
res = res + (timeSeries[i] + duration - last -1);
last = timeSeries[i] + duration - 1;
}
}
else {
res += duration;
last = timeSeries[i] + duration - 1;
}
}
return res;
}
};

Leetcode496. Next Greater Element I

You are given two arrays (without duplicates) nums1 and nums2 where nums1’s elements are subset of nums2. Find all the next greater numbers for nums1’s elements in the corresponding places of nums2.

The Next Greater Number of a number x in nums1 is the first greater number to its right in nums2. If it does not exist, output -1 for this number.

Example 1:

1
2
3
4
5
6
Input: nums1 = [4,1,2], nums2 = [1,3,4,2].
Output: [-1,3,-1]
Explanation:
For number 4 in the first array, you cannot find the next greater number for it in the second array, so output -1.
For number 1 in the first array, the next greater number for it in the second array is 3.
For number 2 in the first array, there is no next greater number for it in the second array, so output -1.

Example 2:
1
2
3
4
5
Input: nums1 = [2,4], nums2 = [1,2,3,4].
Output: [3,-1]
Explanation:
For number 2 in the first array, the next greater number for it in the second array is 3.
For number 4 in the first array, there is no next greater number for it in the second array, so output -1.

在num2中找到num1的每个元素,然后从这个元素往后找一个比它大的数,用标志位控制即可。

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:
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
vector<int> res;
int first = nums1.size(), second = nums2.size();
int i, j;
bool find;
for(i = 0; i < first; i ++){
find = false;
for(j = 0; j < second; j ++) {
if(nums1[i] == nums2[j])
find = true;
if(find && nums1[i] < nums2[j])
break;
}
if(j == second)
res.push_back(-1);
else
res.push_back(nums2[j]);
}
return res;
}
};

Leetcode498. Diagonal Traverse

Given an m x n matrix mat, return an array of all the elements of the array in a diagonal order.

Example 1:

1
2
Input: mat = [[1,2,3],[4,5,6],[7,8,9]]
Output: [1,2,4,7,5,3,6,8,9]

Example 2:

1
2
Input: mat = [[1,2],[3,4]]
Output: [1,2,3,4]

这道题给了我们一个mxn大小的数组,让我们进行对角线遍历,先向右上,然后左下,再右上,以此类推直至遍历完整个数组,题目中的例子和图示也能很好的帮我们理解。由于移动的方向不再是水平或竖直方向,而是对角线方向,那么每移动一次,横纵坐标都要变化,向右上移动的话要坐标加上[-1, 1],向左下移动的话要坐标加上[1, -1],那么难点在于我们如何处理越界情况,越界后遍历的方向怎么变换。向右上和左下两个对角线方向遍历的时候都会有越界的可能,但是除了左下角和右上角的位置越界需要改变两个坐标之外,其余的越界只需要改变一个。那么我们就先判断要同时改变两个坐标的越界情况,即在右上角和左下角的位置。如果在右上角位置还要往右上走时,那么要移动到它下面的位置的,那么如果col超过了n-1的范围,那么col重置为n-1,并且row自增2,然后改变遍历的方向。同理如果row超过了m-1的范围,那么row重置为m-1,并且col自增2,然后改变遍历的方向。然后我们再来判断一般的越界情况,如果row小于0,那么row重置0,然后改变遍历的方向。同理如果col小于0,那么col重置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:
vector<int> findDiagonalOrder(vector<vector<int>>& mat) {
if (mat.empty() || mat[0].empty())
return {};
int x = 0, y = 0, dir = 0;
vector<int> res;
vector<vector<int>> dirs{{-1,1}, {1,-1}};
int m = mat.size(), n = mat[0].size(), size = m*n;
for (int i = 0; i < size; i ++) {
res.push_back(mat[x][y]);
x += dirs[dir][0];
y += dirs[dir][1];
if (x >= m) { x = m - 1; y += 2; dir = 1 - dir; }
if (y >= n) { y = n - 1; x += 2; dir = 1 - dir; }
if (x < 0) { x = 0; dir = 1 - dir; }
if (y < 0) { y = 0; dir = 1 - dir; }
}
return res;
}
};

Leetcode500. Keyboard Row

Given a List of words, return the words that can be typed using letters of alphabet on only one row’s of American keyboard like the image below.

Example:

1
2
Input: ["Hello", "Alaska", "Dad", "Peace"]
Output: ["Alaska", "Dad"]

给出n个字符串,从而判断每个字符串中的字符石头来自美式键盘上的同一行,若来自同一行,返回该string。过程将键盘上的每行字符存储到相应的vector或者数组中,然后循环Input中的每个string,并且循环string中的每个char,从而进行比较。

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
class Solution {
public:
vector<string> findWords(vector<string>& words) {
std::unordered_set <char> row1={'q','w','e','r','t','y','u','i','o','p'};
std::unordered_set <char> row2={'a','s','d','f','g','h','j','k','l'};
std::unordered_set <char> row3={'z','x','c','v','b','n','m'};
vector<string> res;

for(string word : words) {
bool d1=true, d2=true, d3=true;
for(char c : word) {
if(d1) {
auto re = row1.find(tolower(c));
if(re == row1.end())
d1 = false;
}
if(d2) {
auto re = row2.find(tolower(c));
if(re == row2.end())
d2 = false;
}
if(d3) {
auto re = row3.find(tolower(c));
if(re == row3.end())
d3 = false;
}
}
if(d1||d2||d3)
res.push_back(word);
}
return res;
}
};