进程调度程序是确保进程能有效工作的一个内核子系统。

多任务

多任务可以分为两类:

  • 非抢占式多任务(cooperative multitasking)。除非进程自己主动停止运行,否则它会一直执行。进程主动让出自己的操作称为让步(yielding)。
  • 抢占式多任务(preemtive multitasking)。Linux提供了抢占式的多任务模式。由进程调度程序来决定什么时候停止一个进程的运行。这个强制的挂起动作就叫做抢占(preemption)。进程在被抢占之前能够运行的时间叫做时间片(timeslice)。

Linux的进程调度

从1991年的Linux第一版到后来的2.4内核系列,Linux的调度程序都相当简陋,设计近乎原始。在Linux2.5开发系列的内核中,采用了一种叫做O(1)调度程序的新调度程序。就如它的名字,时间复杂度是O(1)

O(1)调度器在拥有数以十计的多处理器环境下尚能表现出近乎完美的性能和可拓展性,但是对于响应时间敏感的交互进程来说却有一些先天不足。从2.6内核系统开发初期,开发人员引入了新的调度算法。其中最有名的是反转楼梯最后期限调度算法(Rotating Staircase Deadline scheduler)(RSDL)。该算法吸取了队列理论,将公平调度的概念引入了Linux调度程序,并最终在2.6.23内核版本中替代了O(1)调度算法。它被称为完全公平调度算法(CFS)。

策略

策略决定调度程序在何时让什么进程运行。

I/O消耗型和处理器消耗型的进程

进程可被分为I/O消耗型和处理器消耗型。前者指进程的大部分时间都用来提交或等待I/O请求。相反,处理器消耗型则大部分时间都用在执行代码上。当然两者的划分并非泾渭分明。

调度策略通常就要在两个矛盾的目标中寻找平衡:进程相应迅速和最大系统利用率。

进程优先级

Linux采用了两种不同的优先级范围。

第一种是使用nice值,范围是-20~+19,默认为0。越大的nice值意味着优先级越低。nice值代表时间片的比例。可以通过ps -el查看,NI列表示的就是nice值。

第二种范围是实时优先级,其范围是可配置的。默认情况下它的变化范围是[0, 99]。与nice值相反,越高的实时优先级代表进程优先级更高。任何实时进程的优先级都高于普通进程,也就是说nice优先级和实时优先级处于互不相交的两个范畴。

时间片

时间片是一个数值,表明进程在被抢占前能持续运行的时间。调度策略需要规定默认的一个时间片。但时间片过长会导致系统对交互的效应表现欠佳,过段会明显增加进程切换带来的处理器耗时。而且I/O消耗型和处理器消耗型的矛盾也显示出来:I/O消耗型不需要过长时间片,而处理器消耗型则希望越长越好。

Linux的CFS调度并没有直接分配时间片到进程,而是讲处理器的使用比例划分给了进程。这样一来,进程所获得的处理器时间其实是和系统负载有关。抢占时机也取决于新的可运行程序消耗了多少处理器使用比。

Linux调度算法

调度器类

Linux调度器是以模块方式提供的,称为调度器类(scheduler classes),不同类型的进程可以有针对性地选择调度算法。基础的调度器类代码定义在kernel/sched.c文件中,它会按照优先级顺序遍历调度类,拥有一个可执行进程的最高优先级的调度器类胜出。

CFS是一个针对简单进程的调度类,在Linux中称为SCHED_NORMAL

Unix系统中的进程调度

在Unix系统上,优先级以nice值形式输出给用户空间。在现实中会产生许多问题。

  • 若要讲nice值映射到时间片,就必然需要将nice单位值对应到处理器的绝对时间。但这样做会导致进程切换无法最优化执行。举例说明,默认nice值为0分配100ms的时间片,最高nice值为20分配5ms。如果同时运行时间片5ms的进程,则要在10ms间进行一次上下文切换;而运行时间片是100ms的进程,则要在100ms间进行一次上下文切换。

  • 相对nice值。假设两个进程nice值分别是0和1,时间片分别是100ms和95ms,区别微乎其微。而两个进程nice值分别为18和19,时间片分别为10ms和5ms,前者是后者的两倍!

  • 如果执行nice值到时间片的映射,需要能分配一个绝对时间片,而这个绝对时间片必须能在内核的测试范围内。

  • 最后一个问题是关于基于优先级的调度器会为了优化交互任务而唤醒相关进程的问题。

公平调度

CFS的做法是允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法了。CFS在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice值来计算时间片。nice值在CFS中被作为进程获得处理器运行比的权重。

每个进程都按照其权重在其全部可运行进程中所占比例的时间片来运行。CFS为完美多任务中的无限小调度周期的近似值设立了一个目标。这个目标称为目标延迟。假定目标延迟值是20ms,两个同样优先级的可运行任务会分别运行10ms。

CFS还为每个进程设置了时间片底线,这个底线称为最小粒度。默认情况下是1ms。

任何进程所获得的处理器时间是由它自己和其他所有可运行进程nice值对应的绝对时差值决定的。nice值对时间片的作用是几何加权。

Linux调度的实现

CFS位于kernel/sched_fair.c中。特别关注四个组成部分:

  • 时间记账
  • 进程选择
  • 调度器入口
  • 睡眠和唤醒

时间记账

所有调度器都必须对进程运行时间做记账。

调度器实体结构

CFS不再有时间片的概念,但是它也必须维护每个进程运行的时间记账。CFS使用sched_entity来跟踪进程运行记账:

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
struct sched_entity
{
struct load_weight load; /* for load-balancing */
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;

u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;

u64 nr_migrations;

#ifdef CONFIG_SCHEDSTATS
struct sched_statistics statistics;
#endif

#ifdef CONFIG_FAIR_GROUP_SCHED
struct sched_entity *parent;
/* rq on which this entity is (to be) queued: */
struct cfs_rq *cfs_rq;
/* rq "owned" by this entity/group: */
struct cfs_rq *my_q;
#endif
};

sched_entity作为PCB中一个名为se的成员变量。

虚拟实时

vruntime变量存放进程的虚拟运行时间,该运行时间的计算是经过了所有可运行进程总数的标准化。虚拟时间是以ns为单位的。CFS用vruntime变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。

进程选择

CFS调度算法的核心:当CFS需要选择下一个运行进程时,它会挑一个具有最小vruntime的进程。接下来就讨论如何选择具有最小vruntime值的进程。

CFS使用红黑树来组织可运行进程队列,并利用其迅速找到最小vruntime值的进程。

挑选下一个任务

先假设,红黑树存储了系统中所有的可运行进程,其中节点的键值是可运行进程的虚拟运行时间。那么树中最左侧的叶子节点,就是所有vruntime最小的那个。

向树中加入进程

CFS在进程变为可运行状态(被唤醒)或者通过fork()调用第一次创建进程时,将进程加入rbtree中,并且缓存最右子节点。

从树中删除进程

CFS从红黑树中删除进程,删除动作发生在进程堵塞(变为不可运行态)或者终止时(结束运行)。

调度器入口

进程调度的主要入口点是函数schedule()。它选择哪个进程可以运行,何时将其投入运行。schedule()会找到一个最高优先级的调度类——后者需要有自己的可运行队列,然后问后者谁才是下一个该运行的进程。

睡眠和唤醒

休眠(被阻塞)的进程处于一个特殊的不可执行状态。当进程休眠时,内核的操作如下:进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。唤醒的过程正好相反:进程被设置成可执行状态,然后从可执行队列中移到可执行红黑树中。

等待队列

休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。内核通过wake_queue_head_t来代表等待队列。

在内核中进行休眠的推荐操作相对复杂:

1
2
3
4
5
6
7
8
9
10
11
12
// q是我们希望休眠的等待队列
DEFINE_WAIT(wait);

add_wait_queue(q, &wait);
while (!condition)
{
prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);
if (signal_pending(current))
/* 处理信号 */
schedule();
}
finish_wait(&q, &wait);

唤醒

唤醒操作通过函数wake_up()进行。它会唤醒执行的等待队列上的所有进程。

抢占和上下文切换

这里的上下文切换是指进程间的切换,由定义在kernel/sched.c中的context_switch()函数负责处理。每当一个新的进程被选出来投入运行的时候,schedule()会调用该函数。它完成两个基本的工作:

-调用声明在asm/mmu_context.h中的switch_mm(),负责把虚拟内存从上一个进程映射切换到新进程中。

  • 调用声明asm/system.h中的switch_to(),负责从上一个进程的处理器状态切换到新进程的处理器状态。

内核提供了一个need_resched标志来表明是否需要重新执行一次调度。当某个进程应该被抢占时,scheduler_tick()就会设置这个标志;当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()也会设置这个标志,内核检查该标志,确认其被设置,调用schedule()来切换到一个新的进程。

在2.2以前的内核版本中,该标志位曾是一个全局变量,2.2~2.4版的内核中它在task_struct中。而在2.6版中,它被移到thread_info结构体里,用一个特别的标志变量中的一位来表示。

用户抢占

用户抢占发生在一下情况:

  • 从系统调用返回用户空间时
  • 从中断处理程序返回用户空间时

总而言之,内核即将返回用户空间的时候,如果need_resched标志被置位,会导致schedule()被调用。

内核抢占

大部分的Unix变体不支持内核抢占,调度程序没有办法在内核级的任务正在执行的时候重新调度——内核中的任务是以协作的方式调度的,不具备抢占性。

Linux在2.6版本的内核中,引入了内核抢占。只要重新调度是安全的,内核就可以在任何时候抢占正在执行的任务。它发生在:

  • 中断处理程序正在运行,并且返回内核空间之前。
  • 内核代码再一次具有可抢占性的时候。
  • 如果内核中的任务显示地调用schedule()
  • 如果内核中的任务阻塞(这也会导致调用schedule())。

实时调度策略

Linux提供了两种实时调度策略:SCHED_FIFOSCHED_RR。而普通的非实时的调度策略是SCHED_NORMAL。这些实时调度器并不被CFS管理,而是被一个特殊的实时调度器管理。

与调度相关的系统调用

主要通过C库提供的nice()sched_xxxx()系列函数。基本都是和系统调用的简单对应。

发现读书不记笔记还是不行,过两天就忘得差不多了…最近在看《Linux内核设计与实现》,决定记些笔记。我可能有讲错的地方,一切以书上的讲解以及Linux 2.6.X源码为准 (逃


进程

进程包括执行的代码,数据,打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间以及一个或多个执行线程。实际上,进程就是正在执行的程序代码的实时结果。

执行线程,简称线程,是进程中活动的独享。每个线程有一个独立的程序计数器、进程栈和一组进程寄存器。Linux对进程和线程并不特别区分,线程只不过是一种特殊的进程。调度的对象是线程。

熟悉Unix/Linux系统编程的童鞋都知道,fork()函数通过复制一个已有的进程来创建一个全新的进程。然后可能会用exec()函数创建新的地址空间,然后把新的程序载入其中。最终通过exit()系统调用退出执行,并把占有的资源释放。父进程可以通过wait4()系统调用查询子进程是否终结。进程退出后被设置为僵死状态,直到父进程调用wait()或者waitid()为止。

需要注意的是,Linux上实现fork()是用的clone()系统调用。

进程描述符及任务结构

内核把进程的列表放在叫做 task list 的双向循环链表中。链表中的每个项目类型为 task_struct,称为进程描述符(process descriptor)的结构。进程描述符中包含一个具体进程的所有信息,包括:打开的文件、进程的地址空间、挂起的信号、进程的状态,还有其他的信息。

分配进程描述符

Linux 通过 slab 分配器分配 task_struct 结构,能达到对象复用和缓存着色(cache coloring)的目的。在内核栈的栈底(当然,这是对于向下增长的栈来说)创建爱你一个新的结构 struct thread_info

在X86上,thread_info定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct thread_info
{
struct task_struct *task; /* main task structure */
struct exec_domain *exec_domain; /* execution domain */
__u32 flags; /* low level flags */
__u32 status; /* thread synchronous flags */
__u32 cpu; /* current CPU */
int preempt_count; /* 0 => preemptable,
<0 => BUG */
mm_segment_t addr_limit;
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp; /* ESP of the previous stack in
case of nested (IRQ) stacks
*/
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};

struct task_struct *task 是指向该任务实际 task_struct 的指针。

进程描述符的存放

内核通过一个唯一的 PID 来标识每个进程。PID是一个数字,为 pid_t 类型。

在内核中,访问任务通常需要通过 current 宏获得指向其 task_struct 的指针。current 针对专门的硬件体系结构有不同的定义。在X86上,current 把栈指针的后13个有效位屏蔽掉,用来计算出 thread_info 的偏移。该操作是通过 current_thread_info() 函数来完成的。然后从得到的 thread_infotask 域中提取并返回 task_struct 的地址: current_thread_info()->task

进程状态

进程描述符中的 state 域描述了进程的当前状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* Task state bitmask. NOTE! These bits are also
* encoded in fs/proc/array.c: get_task_state().
*
* We have two separate sets of flags: task->state
* is about runnability, while task->exit_state are
* about the task exiting. Confusing, but this way
* modifying one set can't modify the other one by
* mistake.
*/
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* in tsk->exit_state */
#define EXIT_ZOMBIE 16
#define EXIT_DEAD 32
/* in tsk->state again */
#define TASK_DEAD 64
#define TASK_WAKEKILL 128
#define TASK_WAKING 256
#define TASK_STATE_MAX 512

可以看到,state的状态有五个:

  • TASK_RUNNING(运行): 进程是可执行的。它正在执行,或者在运行队列中等待执行。
  • TASK_INTERRUPTIBLE(可中断): 进程正在睡眠,也就是被阻塞,等待某些条件达成。一旦这些条件达成,内核就会把进程的状态设为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并素食准备投入运行。
  • TASK_UNINTERRUPTIBLE(不可中断):除了就算是接收信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。
  • __TASK_STOPPED(停止):进程停止执行。通常发生在接收到SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU等信号的时候。
  • __TASK_TRACED:被其他进程跟踪的进程。

设置当前进程状态

一般通过 set_task_state(task, state) 这个函数。set_current_state(state)set_task_state(current, state) 含义是等同的。

进程上下文

可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当执行了系统调用或者出发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。此时 current 宏是有效的。

进程家族树

Linux中进程有明显的继承关系,所有的进程都是 PID 为 1 的 init 进程的后代。内核在启动的最后阶段启动 init 进程。该进程读取系统的初始化脚本(initscript)并执行其他的相关程序。

每个进程都有一个父进程,零个或多个子进程。每个 task_struct 都包含一个指向其父进程叫做 parent 的指针,还包含一个称为 children 的子进程链表。

对于当前进程,可以通过下面的代码获得其父进程的进程描述符:

1
struct task_struct *my_parent = current->parent;

同样,也可以按下面的方式依次访问子进程:

1
2
3
4
5
6
7
struct task_struct *task;
struct list_head *list;

list_for_each(list, &current->children)
{
task = list_entry(list, struct task_struct, sibling);
}

init 进程的描述符是作为 init_task 静态分配的。向上遍历的方法如下:

1
2
3
struct task_struct *task;
for (task = current; task != &init_task; task = task->parent)
;

可以通过这个树型结构,从任何一个进程出发,查找任意指定的其他进程。但很多时候,只需要通过简单的遍历,因为任务队列本来就是一个双向的循环链表。

对于给定的进程,获取链表的下一个进程:

1
list_entry(task->tasks.next, struct task_struct, tasks)

获取前一个进程:

1
list_entry(task->tasks.prev, struct task_struct, tasks)

for_each_process(task) 宏提供了依次访问整个任务队列的能力。

1
2
3
4
5
6
struct task_struct *task;

for_each_process(task)
{
printk("%s[%d]\n", task->comm, task->pid)
}

进程创建

Unix 通过fork()拷贝当前进程创建一个子进程,然后通过exec()函数负责读取可执行文件并将其载入地址空间开始运行。

写时拷贝

Linux的fork()采用写时拷贝(copy-on-write)页实现。写时拷贝是一种推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父进程和子进程共享一个拷贝。只有在需要写入的时候,数据才会被复制。这种技术使得地址空间上页的拷贝被推迟到实际发生写入的时候才进行。fork()的实际开销就是赋值父进程的页表以及给子进程创建唯一的进程描述符。

fork()

Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父子进程需要共享的资源。fork()vfork()__clone()库函数都根据各自需要的参数去调用clone(),然后由clone()去调用do_fork()

do_fork() 完成了创建进程中的大部分工作。该函数调用copy_process()函数,然后让进程开始运行。

copy_process()完成的工作:

  • 调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct。这些值与当前进程值相同。

  • 检查并确保新创建子进程后,当前用户所拥有的进程数没有超出给它分配的资源的限制。

  • 子进程着手使自己与父进程区别开来。task_struct内的许多成员都清0或者设置为初始值。大多数据仍然未修改。

  • 子进程的状态被设置为TASK_UNINTERRUPTIBLE,以保证不会投入运行。

  • 调用copy_flags()以更新task_structflags成员。

  • 调用alloc_pid为新进程分配一个有效的PID。

  • 根据传给clone的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。

  • 最后返回一个指向子进程的指针。

回到do_fork()函数,如果copy_process()函数返回成功,新创建的子进程被唤醒并让其投入运行。

vfork()

除了不拷贝父进程的页表项外,vfork()fork()的功能相同。子进程作为父进程的一个单独的线程再它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。由于fork()是写时复制并明确子进程先执行,所以vfork()的好处就只限于不拷贝父进程的页表项了。

线程在Linux中的实现

Linux从内核的角度讲,没有线程的概念。线程被视作一个与其他进程共享某些资源的的进程。每个线程都有一个属于自己的task_struct,所以在内核中,它看起来就像一个普通的进程,只是和其他task会共享某些资源,比如地址空间。

创建线程

线程的创建和普通进程的创建类似,只不过在调用clone()的时候参数需要传递一些参数标志来指明需要共享的资源:

1
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

新创建的task和他的父亲共享地址空间、文件系统资源、文件描述符和信号处理程序。换言之,新创建的task是父进程的一个线程。

对应的,fork()的实现是:

1
clone(SIGCHLD, 0);

vfork()的实现是:

1
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);

内核线程

内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成————独立运行在内核空间的标准进程。和普通进程的区别在于没有独立的地址空间。

进程终结

当一个进程结束时,内核会释放它占有的资源并给父进程发送信号。

进程的终止有可能是自身引起的,比如调用exit()系统调用,既可能显示调用,也可能隐式地从某个程序的主函数返回;也可能是被动地终结,比如进程接收到既不能处理又不能忽略的信号或异常。

大部分任务需要靠do_exit()来完成。至此进程不可运行,并处于EIXT_ZOMBIE退出状态,它所占有的所有内存就是内核栈、thread_info结构和task_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息或者通知内核那是无关的信息后,进程所持有的剩余内核被释放,归还给系统使用。

删除进程描述符

wait()这一族的函数都是通过唯一的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中一个子进程退出,此函数会返回该子进程的PID。

最终释放进程描述符时,release_task()会被调用:

  • 它调用__exit_signal(),该函数调用__unhash_process(),后者又调用detach_pid()pidhash上删除该进程,同时从任务列表中删除该进程。
  • __exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
  • 如果这个进程是进程组最后一个进程,并且领头进程已经死亡,那么通知僵死的领头进程的父进程。
  • 调用put_task_struct()释放掉进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存

至此,进程描述符和所有的进程独享的资源就被全部释放掉了。

孤儿进程

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些子进程就变成了孤儿进程,在退出时永远处于僵死状态。解决方法就是给子进程在当前进程组内找一个线程作为父亲;如果不行,就让init做它们的父进程。在do_exit()会调用exit_notify(),该函数会调用forget_original_parent(),而后者会调用find_new_reaper()来执行寻找父进程。

这个问题是在CSAPP上看到的,详情可以看这个

简单来说,问题就是:在用补码表示有符号整型的机器上,C语言中的整型的最大值和最小值的表示方法不同。接下来就以32位int为例子讲解这个问题。

int的最大值INT_MAX和int的最小值INT_MIN,是这么定义的:

1
2
3
#define INT_MAX 2147483647

#define INT_MIN (-INT_MAX – 1)

熟悉补码的童鞋应该都知道,32位的数据,能表示的最大的整数的位级表示是0x7FFFFFFF,即2147483647,最小的整数的位级表示是0x80000000,即-2147483648。那么是为什么不能直接#define INT_MIN -2147483648或者#define INT_MIN 0x80000000?

CSAPP上是这么来说的,我觉得总结的很好:

不幸的是,补码表示的不对称性和C语言转换规则之间这种奇怪的交互,迫使我们使用奇怪的方式来写Tmin。虽然理解这个问题需要我们钻研C语言标准中一些隐晦的角落,但是它也能帮助我们理解整数数据类型和表示的一些细微之处。

接下来以我自己的理解来阐述一下原因。

首先,C语言里有整数常量(integer constant),让整型数值直接在表达式中使用。比如说int a = 10这里出现的10,就是个整型常量。整型常量除了默认的十进制,可以在开头加0表示八进制,比如010就是表示的八进制数字10;还可以在开头加0x或者0X表示十六进制,比如说0xFF就表示十六进制数字FF

而且integer constant可以添加后缀。可选的后缀有u或者Ul或者Lll或者LL。带u和带l的后缀可以进行组合(而且不用分先后顺序)。于是这样就有了6种后缀方式:

  • 无后缀
  • u 或者 U
  • l 或者 L
  • ll 或者 LL
  • 既有 l/L又有 u/U
  • 既有 ll/LL 又有 u/U

integer constant 有自己的类型。而决定每个 integer constant 类型的规则有点复杂。上面提到的3种进制方式和6种后缀方式一共带来了18种组合,每个组合都会有一个由若干个整数类型组成的候选列表,会依次匹配,第一个能容纳这个数值的类型就是这个 integer constant 的类型。完整的表格可以上cppreference上看

现在再来看INT_MIN。如果#define INT_MIN -2147483648,由于没有负整数常量,那么首先来判断2147483648的类型。它属于无后缀的十进制,从C99开始,候选列表为:

  • int
  • long int
  • long long int

而在int是32位补码表示的机器上,int最大为 21474836472147483648会被当成long int类型或者long long int类型,具体情况由 data models 来定。总而言之,就无法用 -2147483648 来表示最小的int值了。

#define INT_MIN 0x80000000也是类似的情况。首先来判断 0x80000000的类型。它属于无后缀的十六进制,候选列表为:

  • int
  • unsigned int
  • long int
  • unsigned long int
  • unsigned long long int

在 int 是32位补码表示的机器上,int最大为 2147483647,即 0x7FFFFFFF,小于 0x80000000。所以也不能用 int 来表示 0x80000000,自然就无法用 0x80000000 来表示最小的int值了。

在网上看到的一句话总结,我觉得说的挺有道理:虽然 -2147483648 这个数值能够用int类型来表示,但在C语言中却没法写出对应这个数值的int类型常量。

之前看CSAPP的时候,看到虚拟地址和物理地址的概念,对CPU的内存映射、地址空间这些有了粗略的印象。后来看了些关于X86的体系结构的内容,感觉相关的概念还是复杂了很多,所以特地整理一下。 (再次证明CSAPP还是当成入门书来看比较合适 ((逃


在具体介绍这些概念之前,还是简单的总结一下X86的实模式和保护模式在内存管理上的区别。

X86的实模式(real mode)始于当年的8086芯片。现在的X86芯片刚启动时也默认先进入实模式。保护模式内存管理比较简单,寻址就是 段地址 + 段内偏移。8086提供了四个16位段寄存器用于保存段地址,分别是CS,DS,ES,SS,段内偏移量也是16位。但是地址线却有20位,也就是说物理地址是20位。所以英特尔公司就设计了一种很巧妙的方法,计算物理地址的时候段基址左移4位,再和偏移地址相加,这就得到了20位的物理地址。

80286处理器首次提出了保护模式的概念,但本文说的保护模式是指IA-32的保护模式。保护模式提供的内存管理机制大概分成了两个部分:分段和分页。分段能够隔离单独的代码模块、数据模块和栈模块,让彼此互不干扰(地址空间分离)。分页提供了虚拟内存的机制,让程序的各个部分能够根据执行环境的需要被映射到物理内存。值得一提的是,分段不能关闭,但是分页是可关闭的。

但是对于几乎所有操作系统来说,其实并没有用到保护模式提供的分段机制。一般都是将所有内存当成一个段来处理,这就是所谓的平坦模式(flat mode)。

物理地址 physical address

物理地址的概念很好理解,就是物理内存真正的地址。CPU最终对于内存的访问就得通过给总线物理地址的信号。

在X86的保护模式下,得到物理地址的方法就是16位的段基址左移4位加上16位的 段内偏移地址,最终得到的就是20位物理地址。

在IA-32的保护模式下,物理地址也是类似的概念,但是扩充到了32位,寻址4GBytes。从Pentium Pro处理器开始,IA-32架构地址扩充到了 2^36 Gbytes。在开启分页的情况下,虚拟地址通过MMU和页表的映射后引发缺页中断或者直接得到物理地址。

有效地址 effective address

有效地址就是段内偏移地址,不论是实模式还是保护模式都是如此。

逻辑地址 logical address

在实模式下,逻辑地址包括16位段地址和16位偏移地址。

在保护模式下,逻辑地址包括16位的段选择子和32位的偏移地址。

至于段选择子是什么,我在英特尔手册上摘抄了一段:

A segment selector is a 16-bit identifier for a segment (see Figure 3-6). It does not point directly to the segment,but instead points to the segment descriptor that defines the segment.

简单来说,段选择子就是一个16位的索引,它不直接指向段,而是指向段描述符。每个段都需要一个段描述符,为了放这些段描述符,在内存中开辟了一些空间,构成一个段描述符表。

也不细讲了,再从手册上摘抄些资料看看凑合吧:

A logical address consists of a segment selector and an offset. The segment selector is a unique identifier for a segment. Among other things it provides an offset into a descriptor table (such as the global descriptor table, GDT) to a data structure called a segment descriptor. Each segment has a segment descriptor, which specifies the size of the segment, the access rights and privilege level for the segment, the segment type, and the location of the first byte of the segment in the linear address space (called the base address of the segment). The offset part of the logical address is added to the base address for the segment to locate a byte within the segment. The base address plus the offset thus forms a linear address in the processor’s linear address space.

线性地址 linear address

在保护模式下,处理器能把每个逻辑地址转换成一个线性地址。线性地址是32位。具体转换方式如下:

  • 通过段选择子中的偏移定位到 GDT 或者 LDT 中的段描述符,读进处理器。
  • 检查段描述符中的各种标志位是否访问本段,然后确认32位的偏移是在段的限制内。
  • 从段描述符中得到段的基址,然后和逻辑地址的32位的偏移相加,就得到了线性地址。

如果没有开启分页,那这个线性地址就已经是物理地址。如果开启了分页,那得到的线性地址就是虚拟地址。

虚拟地址 physical address

从上面对线性地址的描述可以看到,如果开启了分页,那线性地址就是虚拟地址,CPU通过页表的映射可以得到物理地址。

引入

前段时间看 std::unique_ptr 的实现,发现里面用到了好多用到std::enable_if 的地方,查了下才知道这涉及到C++ 里 SFINAE 的规则,还有定义成员类型的时候也在注释里提到了利用 SFINAE ,所以特地查阅资料记录下。

SFINAE 定义

SFINAE 表示 Substitution Failure Is Not An Error (替换失败不是错误)。这里的 Substitution (替换)是个什么概念呢?

先来了解一下模板实参替换的概念:

已指定、推导出或从默认模板实参获得所有模板实参时,函数参数列表中每次模板形参的使用都会被替换成对应的模板实参。

替换发生于:
-所有用于函数类型中的类型(包含返回类型和所有参数的类型)
-所有用于模板形参声明中的所有类型
-所有用于函数类型中的表达式
-所有用于模板形参声明中的表达式

(ps:后两种替换都是从C++11起)

听起来很拗口,总之就是模板形参会被替换成实参。

举个简单的例子就能懂了:

1
2
3
4
5
6
7
template<typename T,
typename U = typename T::type> // 第二种替换
T& // 第一种替换
fun(T) // 第一种替换
{
// 不替换
}

粗略的了解了替换之后,就能明白 SFINAE 大概什么意思了:模板形参替换推导类型失败时,从重载集抛弃特化,而非导致编译失败。其实还是挺好理解的,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct test{
using type = int;
};
template<typename T> // 重载版本1
void fun(typename T::type) { }

template<typename T> // 重载版本2
void fun(T) { }

int main()
{
fun<test>(0); // 调用1
fun<int>(0); // 调用2
}

对于调用1,它模板参数为test,替换就已经开始了。

  • 重载版本1,替换后它变成了类似 void fun(int)
  • 重载版本2,替换后它变成了类似 void fun(test)

两者都是在重载集中的。根据函数重载匹配规则,匹配到前者。可以看到,这里其实并没有用到 SFINAE

对于调用2,它模板参数为int

  • 重载版本1, 替换后它变成了类似 void fun(int::type),很显然,这个替换是failure(失败)。所以它从重载集被中删除。

  • 重载版本2,替换后它变成了类似 void fun(int)

所以调用版本2。

说道底,Substitution Failure 的含义就是:替换的实参写入时,带来了无效(invalid)的类型或表达式(参考上面提到的替换的四种情况)为 ill-formed 。(就像上面例子里出现的类似 int::type)。我查了下C++标准草案,发现里面并没有提到 SFINAE 的概念,只提到了替换后的类型或表达式为无效的话,模板类型推导就失败了。而且标准还特地强调了,当且仅当替换后的无效(invalid)类型和表达式是在函数类型和它的模板类型参数的立即上下文(immediate context)时,才导致类型推断失败。如果替换后的类型或者表达式会引发副作用(实例化某模板特化、生成某隐式定义的成员函数等)的话,就认为它并不是在 immediate context 中,会引发程序为 ill-formed。所以说,我猜正是因为只是模板类型推断失败,就不会引发错误(error)。

上面这段说的很拗口,我也没办法。cppreference和C++标准草案n上上说的也很拗口。而且cppreference上的描述还有很多奇怪的地方,一会而 Substitution Failure, 一会又冒出个 SFINAE error ,后来看了草案才知道 SFINAE error 就是让类型推断失败的情况。我把这段摘录下来,可以结合cppreference上的描述一起看下:

If a substitution results in an invalid type or expression, type deduction fails. An invalid type or expression is one that would be ill-formed, with a diagnostic required, if written using the substituted arguments. [ Note: If no diagnostic is required, the program is still ill-formed. Access checking is done as part of the substitution process. — end note ] Only invalid types and expressions in the immediate context of the function type and its template parameter types can result in a deduction failure. [ Note: The substitution into types and expressions can result in effects such as the instantiation of class template specializations and/or function template specializations, the generation of implicitly-defined functions, etc. Such effects are not in the “immediate context” and can result in the program being ill-formed. — end note ]

草案上还明确规定了什么情况下会让模板参数推断失败。cppreference上也介绍了,只不过正如上面所提到的,说法改了下,变成了 SFINAE error 出现的情况。相当于是对于标准的提炼和总结。

最后再提一下,如果 SFINAE 完美工作,但最终还是匹配不到某个函数,那显然也会造成编译失败。

SFINAE 应用例子

std::enable_if

这差不多算是很经典的利用 SFINAE 的例子了。直接上源代码:

1
2
3
4
5
6
7
8
9
10
// Primary template.
/// Define a member typedef @c type only if a boolean constant is true.
template<bool, typename _Tp = void>
struct enable_if
{ };

// Partial specialization for true.
template<typename _Tp>
struct enable_if<true, _Tp>
{ typedef _Tp type; };

根据 SFINAE 规则,std::enable_if<true, someType>的时候,它提供了成员类型 typestd::enable_if<false, someType>的时候,它无成员类型 type。而且由于它有了个默认模板参数 _TP = void,所以 std::enable_if<true> 提供了成员类型 type 为 void,当然,std::enable_if<false> 仍然无类型成员 type

std::enable_if对不同类型特性提供分离的函数重载与特化的便利方法。它可用作额外的函数参数、返回类型、或类模板或函数模板形参。来看个使用它的例子。std::unique_ptr所使用的默认的删除器 std::default_delete,它有一个重载版本的构造函数标准规定是这样的:从另一 std::default_delete 构造 std::default_delete 对象。此构造函数仅若 U* 可隐式转换为 T* 才参与重载决议。大概函数原型就是:

1
2
3
4
5
6
template<typename T>
default_delete{
template<class U>
default_delete( const default_delete<U>& d ) noexcept; //仅若 U* 可隐式转换为 T* 才参与重载决议。T是 default_delete 能删除的类型
// ...
}

有了 std::enable_if,再配合 type_traits 头文件提供的用于判断两种类型是否能转化的函数 std::is_convertible,完成这样的需求就很容易了:

1
2
3
4
5
6
7
template<typename T>
default_delete {
template<typename U, typename =
typename std::enable_if<std::is_convertible<U*, T*>::value>::type>
default_delete(const default_delete<Up>&) noexcept { }
// ...
}

U* 能够转化成 T* 的时候,std::is_convertible<U*, T*>::valuetruestd::enable_if<std::is_convertible<U*, T*>::value>::type 为void,模板参数能正常推断;反之,当 U* 不能够转化成 T* 的时候,std::is_convertible<U*, T*>::valuefalsestd::enable_if<std::is_convertible<U*, T*>::value>::type 为 ill-formed 。模板参数推断失败,显然无法实例化这个函数,也无法加入重载集。达到了 仅若 U 可隐式转换为 T* 才参与重载决议* 的要求。

确定 std::unique_ptr 成员类型 pointer

这个也是阅读std::unique_ptr源码学习到的。感觉这个用法也很巧妙。首先需求是这样的,std::unique_ptr 有一个成员类型 pointer,若该类型存在则为 std::remove_reference<Deleter>::type::pointer (其中 Deleter 是unique_ptr 的第二个模板参数,用于表示删除器的类型),否则为 T* 。必须满足可空指针 (NullablePointer) 。

是这么实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<typename T, typename Deleter = default_delete<T>>
class unique_ptr
{
private:
// Use SINFAE to determine whether std::remove_reference<Deleter>::type::pointer exits
class _Pointer
{
private:
using _Del = typename std::remove_reference<Deleter>::type;

template<typename U>
static typename U::pointer __test(typename U::pointer*); // 重载版本1

template<typename U>
static T* __test(...); // 重载版本2

public:
using type = decltype(__test<_Del>(0));
};
// ...
public:
typedef _pointer::type pointer;
// ...
};

如果std::remove_reference<Deleter>::type有类型成员 pointer__test<_Del>(0)优先调用重载版本1,从而 decltype(__test<_Del>(0)) 为重载版本1的返回值,即std::remove_reference<Deleter>::type::pointer;否则,只能调用重载版本2,decltype(__test<_Del>(0)) 为重载版本2的返回值,即T*

今天在看linux内核中链表操作的接口的时候,碰到这样一个宏定义:

1
2
3
4
// INIT_LIST_HEAD宏用于运行时初始化链表
#define INIT_LIST_HEAD(ptr) do { \
(ptr)->next = (ptr); (ptr)->prev = (ptr); \
} while (0)

不太明白这里的do{...}while(0)是个什么操作,看起来似乎没有什么用——因为这里while(0)显然并没有起到循环的效果。后来查了下资料(网址在这),才知道在Linux内核和其它一些著名的C库中有许多使用do{…}while(0)的宏定义,也大概明白了这么写的作用。

  • 第一个理由:空的 statement 会让编译器发出警告,所以会看到有些宏定义是这样的: #define FOO do{ }while(0) 。(暂时没看到这样的宏 = =。 先记录一下

  • 第二个理由:它提供了一个 block 用于声明局部变量。可能你会想到不使用 do{...}while(0) 而简单地使用一对大括号 {...} 。这样有缺陷,具体看下一条。

  • 第三个理由:让你能够声明复杂的宏定义。想象一下一个宏定义如下:

    1
    2
    3
    #define FOO(x) \
    printf("arg is %d\n", x); \
    do_something_useful(x);

    现在这么使用它:

    1
    2
    if (blah == 2)
    FOO(blah);

    宏本质上就是文本替换,所以它实际上:

    1
    2
    3
    if (blah == 2)
    printf("arg is %d\n", blah);
    do_something_useful(blah); // 不论 blah为何止,都会执行这条

    所以显然,这可能带来用于预期之外的效果。而如果用了 do{...}while(0) ,就会是这样的:

    1
    2
    3
    4
    5
    6
    if (blah == 2)
    do {
    printf("arg is %d\n", blah);
    do_something_useful(blah);
    } while (0);
    // OK

    可能有人会想,既然需要一个 block ,那么加个大括号不就好了?好的,假如说有这样一个宏定义:

    1
    2
    3
    4
    5
    6
    7
    // 交换两个值
    #define exch(x, y) { int tmp; tmp = x; x = y; y = tmp; }

    if (x > y)
    exch(x, y); // 看起来似乎没问题
    else
    do_something();

    很显然,这个相当于这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    if (x > y)
    {
    int tmp;
    tmp = x;
    x = y;
    y = tmp;
    }
    ; // 注意这里!!
    else // 语法错误
    do_something();

    问题就出现在那个 ; 。当然,你可以选择当初写下 each(x, y) 的时候在这行末尾不加分号,但是(我觉得)这实在显得太奇怪了。

    用了 do{...} while (0) 就没有这个问题:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    if (x > y)
    do {
    int tmp;
    tmp = x;
    x = y;
    y = tmp;
    } while (0); // Ok
    else
    do_something();

总结:Linux和其它代码库里的宏都用 do{...}while (0) 来包围执行逻辑,因为它能确保宏的行为总是相同的,而不管在调用代码中使用了多少分号和大括号。

(一点吐槽:感觉就是给C语言的宏擦屁股的?还有就是 if/else 即使只有一条语句也加大括号真是个好习惯 = =。

lambda 表达式

首先来明确一下三个概念:

  • lambda 表达式只是一个表达式。它是源码的一部分。

  • closure 是由lambda创造的运行期对象。根据捕获的模式,closure 持有被捕获对象的副本或者引用。

  • closure class 是给 closure 实例化的。每个 lambda 会让编译器生成一个独一无二的 closure classlambda 内的声明将变成它 closure class 内的成员函数的可执行指令。


Item 31

  • 在C++11里有两种默认的捕获模式: by-referenceby-value 。默认的 by-reference 捕获可以导致空悬引用。比如说这个 closure (或者它的副本)比捕获的引用的对象的声明周期长。 (这也应该是处理引用本就应该小心的地方)。应该不使用默认捕获,捕获的时候添上引用的对象。这样可以让程序员注意到引用的对象,从而对它的生命周期有了考量。或者直接就通过传值来捕获。但如果传值的对象是 raw pointer ,那又得考虑是否会出现空悬指针的问题。

  • 有种情况 by-value 捕获会造成意想不到的悬垂指针。看个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    std::vector<std::function<bool(int)>> filters;

    class Widget
    {
    ...
    int divisor;
    }
    void Widget::addFilter() const
    {
    //
    filters.emplace_back(
    [=](int value){ return value % divisor} == 0; }
    );
    }

    这里看似 by-value 捕获了divisor,但是却并非如此。首先,divisor是类内 non-static 数据成员 ,是不能被捕获的。但是这段代码却可以编译过。原因就在与 this 指针。类的 non-static 函数体中隐含了一个 this 指针,它是个纯右值(prvalue)表达式,表示调用这个函数的对象的地址。所以其实捕获的是 this 指针,而lambda表达式中的 divisor 其实也是 this->divisor

    所以既然是 by-value 捕获的 this 指针,那么就有可能出现空悬指针。为了避免这种情况,还是老实地在 addFilters 函数体内用一个 local 变量作为 divisor 的副本,然后 by-value 捕获这个副本。

  • 还有就是要注意,静态存储周期的对象都是可以直接在lambda 表达式中使用的,而且不能捕获 。所以在lambda表达式中用默认 by-value 捕获,就会让人产生错觉。但实际上什么都没捕获。


Item 32

  • C++14 提供了 init capture 这种新的捕获方式。具体来说就是在捕获列表中可以通过 = 的方式,等号左边是从 lambda 表达式生成的闭包类的数据成员,右边是表达式,用于初始化左边。可以明显看出,=左边和右边的作用域也不同。所以,通过这种语法,让“移动”捕获成为了可能,而非 C++11 那样只能传值和传引用捕获。

  • 不过在C++11中也是可以模拟出“移动”捕获的。

    • lambda 表达式可以看成定义一个函数类并构造它的对象。所以没有什么是直接定义一个类不能解决的。类中自然可以通过移动的方式初始化数据成员。

    • 通过 std::bind 来模拟。可以看个例子:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      std::vector<double> data;
      // In C++14
      auto func = [data = std::move(data)](){ /*uses of data*/ };
      // 以下是等价的版本
      // In C++11
      auto func = std::bind(
      [](const std::vector<double>& data)
      { /*uses of data*/ },
      std::move(data)
      );

      std::bind构造的对象持有所有参数的副本。当参数是左值,就拷贝构造;当参数是右值,就移动构造。所以参数data通过移动构造传进了 bind 对象内(姑且称之为d),然后每次调用 funcoperator() 的时候,都会调用那个 lambda 表达式的 operator() 。而参数就是 bind 对象内的data对象的副本。由于 lambda 没有加 multable,闭包类内的 operator()const 限定的的。而 bind 对象的data的副本却不是const的。为了防止它被修改,所以参数加了const。但其实这样也可以:

      1
      2
      3
      4
      5
      6
      auto func =
      std::bind(
      [](std::vector<double>& data) mutable
      { /* uses of data */ },
      std::move(data)
      );

Item 33

C++14 引入了 generic lambdas 的特性。具体来说就是 lambda 表达式的参数列表的类型是 auto 。这个特性的实现也很直观:生成的闭包类的 operator() 函数是个模板。所以,随着来的 universal reference 和完美转发都可以实现了。完美转发的关键还是在对引用折叠和 std::forward 函数实现的理解。

1
2
3
4
5
6
7
8
9
auto f = [](auto&& param)
{
func(std::forward<decltype(param)>(param));
};

auto f = [](auto&&... params)
{
func(std::forward<decltype(param)>(params)...);
};

Item 34

lambda 表达式与 std::bind 相比有些优势,如下:

  • lambda 表达式比较清晰。(这里书中给的代码有误,最后再详细讲)

  • lambda 表达式内可以调用重载的函数,但是std::bind第一个参数是重载函数的话得明确调用的版本(通过static_cast)。而且由于使用的指针,还会减少它被内联优化的可能性。

  • std::bind 默认是将参数拷贝的。想要保存参数的引用,必须要用std::ref。比如:

    1
    auto compressRateB = std::bind(compress, std::ref(w), _1);       // compressRateB acts as if it holds a reference to w, rather than a copy

    这点就很不如 lambda 表达式直观。

在 C++14 中,lambda 表达式已经十分优秀,再也没有使用 std::bind 的理由了。但是在 C++11 中,std::bind 在以下情况下还是很有用的:

  • Move capture 。C++11 lambda 表达式没有提供通过移动捕获的方式,但是能通过 std::bind 模拟出来。但 C++14 开始, lambda 表达式通过 init capture 已经能够实现这个。
  • Polymorphic function objectsstd::bind绑定一个模板函数的时候,它能完美转发任何参数。但是 C++14 开始,lambda 表达式可以通过 auto 实现泛型。

ps:这章里的代码竟然还有错误的地方,琢磨半天= =。 最后在 StackOverflow 上查到了正确的写法

先还原一下问题的引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders; // needed for use of "_1"
auto setSoundL = [](Sound s) {
setAlarm(steady_clock::now()) + 1h,
s,
30s);
};

auto setSoundB = std::bind(setAlarm,
steady_clock::now() + 1h,
_1,
30s);

可以看到 setSoundLsetSoundB 看似完成相同的功能,但是实际上却并非如此。因为 在调用 setSoundL 时会调用setAlarm函数。显然,此时才会now 函数。而对于 setSoundB 来说,它在生成的时候就已经被调用了 now() 函数。然后每次调用它的时候,并不会再对 now() 调用。所以可以看到,在语义上双方已经不同了。解决方法也很简单,将后者对 now 函数的调用推迟到它自身被调用的时候。

书上给出的修改是这样的:

1
2
3
4
// 省略了一系列的 using
auto setSoundB = std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h), // 在 C++14 中,标准操作模板的模板参数声明通常可以被省略。注意, C++ 11 并不支持这么写。
30s);

但这显然也不能达成目标,now 函数还是会在 setSoundB 生成的时候就调用。不难想到,std::bind第一个参数,即可调用对象,是不会提前被调用的。所以应该这么改:

1
2
3
4
// 省略了一系列的 using
auto setSoundB = std::bind(setAlarm,
std::bind(std::plus<>(), std::bind(steady_clock::now()), 1h),
30s);

右值引用,移动语义和完美转发

  • *移动语义(Move semantics)*往往可以用来替代copy操作,减小开销。而且还要某些对象(比如std::unique_ptr, std::futrue, std::thread)只允许move,禁止copy。可是在某些情况下,它的开销并不一定比copy小。

  • *完美转发(Perfect forwarding)*可以让模板接受参数,然后转发给其它函数,保持参数的类型不变。在某些时候,它也并不是完美的。

而*右值引用(Rvalue reference)*就是使上述两个看似不相干的特性连接起来的胶水。它是支撑着移动语义和完美转的潜在语言机制。


Item 23

std::move不移动任何东西。std::forward不转发任何东西。在运行期间,不产生可执行的代码,不产升任何字节。它们只是是执行类型转换的函数模板(function templates)。

std::move

  • std::mvoe无条件地将它的参数转化成右值。可以看下一个接近标准库的简单实现

    1
    2
    3
    4
    5
    6
    7
    template<typename T>
    typename remove_reference<T>::type&&
    move(T&& param)
    {
    using ReturnType = typename remove_reference<T>::type&&;
    return static_cast<ReturnType>(param);
    }

    并且在c–14中,,std::move还能实现得更精炼:

    1
    2
    3
    4
    5
    6
    template<typename T>
    decltype(auto) move(T&& param)
    {
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
    }

    由于std::move只做了将参数转化成右值的工作,甚至有人建议将它的名字改成ralue_cast。当然,右值是可移动的,所以将std::move用于一个对象,可以告诉编译器这个对象是可被移动的。可能这就是命名为std::move的原因。

  • std::mvoe并不一定真正能引发移动操作。举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    class Annotation {
    public:
    explicit Annotation(const std::string text)
    : value(std::move(text)) { }

    private:
    std::string value;
    }

    你可能预想value(std::move(text))讲引发std::string的移动构造函数。但是,这里却发生了copy。原因在于text是个const对象。std::move作用于const对象时,返回值是个const限定的右值。而std::string构造函数的签名却大概是如下这样的:

    1
    2
    3
    4
    5
    6
    7
    class string {                          // typedef for std::basic_string<char>
    public:
    std::string(const string& rhs); // copy ctor
    std::string(string&& rhs); // move ctor

    // ...Other code
    };

    const std::string的右值不能传给rvalue reference to a non-const std::string(即不会调用移动构造函数),却可以传给lvalue-reference-to-const(即会调用拷贝构造函数)。因为移动一个对象会改变它的某些值,所以c–不允许const对象传给可能会改变它们值的函数。

    所以,有两个教训:

    • 如果你想要移动某个对象,不要将它声明为const。否则实际将会引发copy操作。
    • std::move不移动任何东西。它只是保证它转化的对象将会有资格被移动,即被转化成右值。

std::forward

  • std::forward的转化只在某些特定情况下工作。它最常用的场景是在universal reference作为参数的函数模板(function template)中,用于将参数传给另一个函数。比如说:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    void process(const Widget& lvalArg);
    void process(Widget&& rvalArg);

    template<typename T>
    void logAndProcess(T&& param)
    {
    auto now = std::chrono::system_clock::now();
    makeLogEntry("Calling 'process'", now);
    process(std::forward<T>(param));
    }

    Widget W;
    logAndProcess(w); // (1) call with lvalue
    logAndProcess(std::move(w)); // (2) call with rvalue

    试想一下,如果没有std::forward会如何。param会被推断成左值或者右值的引用(详情见第一章)。但是,无论如何,param本身都是一个左值,所以调用process的参数匹配的时候,都会调用参数是lvalue reference的重载版本。我们需要一个机制,只有当传给param的对象是右值的时候,将param从rvalue reference(是左值)转换右值。事实上,这就是上述提到的希望std::forward工作的特定情况。

    BTW,你可能会奇怪std::forward如何区分传给它的对象被初始化之前的类型。秘密就藏在模板参数T里面。后面Item 28会详细讲。

    事实上,某些时候,我们可以用std::forward来代替std::move

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Widget {
    public:
    Widget(Widget&& rhs)
    : s(std::move(rhs.s)) { }
    // 等价于下面这么写
    Widget(Widget&& rhs)
    : s(std::forward<std::string>(rhs)) { }
    private:
    std::string s;
    };

    注意到std::forward的模板参数是std::string,这可以让它的返回值是右值。但是这么写很不方便。所以还是用std::move来得好。


Item 24

  • univsersialcv reference可以绑定到任意类型,const或volatile限定的rvalue或lvalue。通常有两个运用的地方,并且都伴随着类型推断:

    • 模板中:

      1
      2
      template <typename T>
      void f(T&& param); // param is a universal reference
    • auto 表达式

      1
      auto&& var2 = val1;         // var2 is a universal reference
  • 如果加了const,就会绑定到rvalue reference

    1
    2
    template<typename T>
    void f(const T&& param); // param is a rvalue reference
  • 不要在模板里看到T&&就以为是universal reference。比如在vector中:

    1
    2
    3
    4
    5
    6
    7
    8
    template<typename T, class Allocator = allocator<T>>
    class vector {
    public:
    void push_back(T&& x); // x is a reference to rvalue

    template<class... Args>
    void emplace_back(Args&&... args); // args are universial references
    }
  • C++14的lambda表达式可以声明 auto&& 参数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    auto timeFuncInvocation = 
    [](auto&& func, auto&&... params)
    {
    //start timer;
    std::forward<decltype(func)>(func)(
    std::forward<decltype(params)>(params)...
    )
    //stop timer and record elapased time
    }
  • universal reference只是一层抽象,背后的原理是 reference collapsing(引用折叠)。


Item 25

  • 合理运用universal reference可以有许多好处。考虑下以下两端代码:

    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
    // With universial reference
    class Widget{
    public:
    template<typename T>
    void setName(T&& newName)
    {
    name = std::forward<T>(newName);
    }
    // ... Other codes

    private:
    std::string name;
    }

    // With two override functions
    class Widget{
    public:
    void setName(const std::string& newName)
    {
    name = newName;
    }

    void setName(std::string&& newName)
    {
    new = newName;
    }
    // ... Other codes

    private:
    std::string name;
    }

    前者就比后者好一些。首先,后者增加了源代码的编写以及要维护的代码量。其次,它可能更加低效。比如说:

    1
    w.setname("cyyzero");

    使用universal reference版本,那么它被转发给std::string,并且只调用 std::string 的一次赋值函数。而对于两个重载版本的setName,将会创建一个临时的std::string对象,然后临时对象移动,最后析构。Last but not least,对于后者不仅源代码体积膨胀和执行期的效率低下,而且它是一种可拓展性很差的设计。可能需要重载的数量会很多。

  • 在某些情况下,你想要用绑定到 rvalue reference 或者 universal reference 的值多次,那么在最后一次才用std::move或者std::forward.

  • RVO, 即return value optimization,是指编译器对于返回值需要copy 的情况进行了优化,让copy避免。通常当local对象和返回值类型相同,并且这个local对象被返回,则会进行RVO优化。如果不符合消除拷贝的条件,返回值会被视为右值。所以对于return std::move(ret);这样试图对返回局部变量进行优化的情况,属于画蛇添足。它不会帮到编译器,还可能阻碍了优化(因为所返回对象的类型变成了rvalue reference)。

  • 对于返回值是传值,但返回的对象是左值或者右值引用的情况,那么把 std::move 用在右值引用上, std::forward 用在 universal reference 上。


Item 26

  • 对参数是 universal reference 的模板函数的重载,将会导致几乎都只调用参数是 universal reference 的版本。

  • 完美转发的构造函数一般会导致很多问题,因为对于参数是non-const lvalue的拷贝构造,和它的继承类调用它的拷贝或移动构造函数的时候,都会调用它。

  • 总而言之,尽量别对参数是universal reference的函数重载。


Item 27

对于上述的问题,有一些办法可以解决。

  • 放弃重载。定义多个不同的函数。

  • 通过const T&传参数。缺陷就在于效率可能会降低(因为不能move)。但是有时候选择放弃效率带来代码的简单,不失为一种方案。

  • 传值。这可能是反直觉的,因为传值意味着拷贝对象,很多时候都会带来很大的开销。但是知道需要拷贝的时候,就可以考虑传值,然后再将副本move。

  • Tag dispath(标签分派)。函数A参数设置为universal reference,然后通过对参数类型的分类,分派给其他函数。举个例子:

    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
    std::vector<std::string> names;

    std::string nameFromIdx(int idx);

    void logAndAddImpl(int idx, std::true_type)
    {
    logAndAdd(nameFromIdx(idx));
    }

    template<typename T>
    void logAndAddImpl(T&& name, std::false_type)
    {
    auto now = std::chrno::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
    }

    template<typename T>
    void logAndAdd(T&& name)
    {
    logAndAddImpl(
    std::forward<T>(name),
    std::is_integral<typename std::remove_referene<T>::type>()
    );
    }
  • 约束接收universal reference作为参数的模板。这是后需要用到std::enable_if。它的工作原理基于SFINAE。配合type traits,可以出色地完成工作:

    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 Person
    {
    public:
    template
    <
    typename T,
    typename = typename std::enable_if
    <
    !std::is_base_of
    <
    Person,
    typename std::decay<T>::type
    >::type
    &&
    !std::is_integeral<typename std::remove_reference<T>::type>::value
    >::type
    >
    Person(T&& t)
    :name(std::forward<T>(t)) { }

    Person(int Idx)
    :name(nameFromIdx(Idx)) { }

    private:
    std::string name;
    }

    不太好理解的地方:std::enable_if,std::is_base_of,std::decay,std::is_integeral.

  • 权衡。使用universal reference通常能减小开销,但是随之而来的也有许多劣势。一来有些参数不能玩么转发。二来有时候完美转发后的出错信息可读性差。比如说上面的例子,传给Person的参数不能构造std::string,此时的报错将很感人。所以最好用static_assert确定它时候符合要求。

    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 Person
    {
    public:
    template
    <
    typename T,
    typename = typename std::enable_if
    <
    !std::is_base_of
    <
    Person,
    typename std::decay<T>::type
    >::type
    &&
    !std::is_integeral<typename std::remove_reference<T>::type>::value
    >::type
    >
    Person(T&& t)
    :name(std::forward<T>(t))
    {
    static_assert
    (
    std::is_constructible<std::string, T>::value,
    "Parameter n can;t be usedd to construct a std::string"
    )
    }


    private:
    std::string name;
    }

Item 28

C++禁止声明引用的引用。但是编译器可能在特定上下文,以及模板实例化的时候遇到它们。这时候,引用折叠(reference collapsing)就派上用场了。引用折叠大概的规则就是:如果有一个是左值引用,结果就是左值引用。否则(即都是右值引用),结果为右值引用

  • T&& && => T&&
  • T&& & => T&
  • T& && => T&
  • T& & => T&

引用折叠主要在四种情况中出现:

  • 模板实例化。其实主要就是参数是universal reference的函数模板实例化的时候。universal reference配上std::forward,主要就是引用折叠的规则才使得完美转发成为可能。比如说有个完美转发的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    //std::forward的简单实现
    template<typename T>
    T&& forward(typename remove_refrence<T>::type& param)
    {
    return static_cast<T&&>(param);
    }

    template<typename T>
    void f(T&& fParam)
    {
    someFunc(forward<T>(fParam));
    }

    //返回Widget对象的工厂函数
    Widget getWidget();
    int main()
    {
    Widget w;
    f(w); // 情况(1)
    f(getWidget()); // 情况(2)
    }

    对于情况(1),f函数和forward函数会被推断成这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void f(Widget& && fParam)
    {
    someFunc(forward<Widget&>(fParam));
    }

    Widget& && forward(Widget &param)
    {
    return static_cast<Widget& &&>(param);
    }

    正是由于引用折叠的存在,它事实上是这样的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void f(Widget& fParam)
    {
    someFunc(forward<Widget&>(fParam));
    }

    Widget& forward(Widget &param)
    {
    return static_cast<Widget&>(param);
    }

    函数返回左值引用,返回值是个左值。所以 forward 返回值是 Widget 左值。于是完美转发成功。

    对于情况(2), f 函数和 forward 函数会被推断成这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void f(Widget&& fParam)
    {
    someFunc(forward<Widget>(fParam));
    }

    Widget&& forward(Widget& param)
    {
    static_cast<Widget&&>(param);
    }

    *函数返回右值引用,返回值是 xvalue (一种右值)*。所以forward的返回值是 Widget 右值。完美转发也成功了。

    ps:一开始我对于在f函数体内somefunc函数匹配规则不太理解,不太理解引用作为参数模板怎么推断。后来查了下,从函数调用推导模板参数 P 的时候,若 P 是引用类型,则用 P 所引用的类型推导。那么一切问题都迎刃而解了。

  • auto类型推导的时候。这个也不难想到,因为 auto 推导规则和模板类型推导差不多(见 Item 3)。还是来看一些例子:

    1
    2
    3
    4
    5
    auto&& wi = w;
    // 等价于 Widget& && w1 = w; => Widget &w1 = w;

    auto&& w2 = getWidget();
    // 等价于 Widget&& w2 = getWidget();
  • typedefalias declarations。在模板中声明等价类型的时候,也经常运用引用折叠。

    1
    2
    3
    4
    5
    template<typename T>
    class Widget
    {
    typedef T&& RvalueRefToT; // RvalueRefToT类型可能和预期的右值引用不太一样,由于T类型未知,如果发生引种折叠,它还可能是左值引用
    }
  • decltype。当使用decltype配合引用的时候,也可可能发生引用折叠。

最后总结:universal reference能工作就是依靠两点,一是类型推导能区分左值和右值,二是引用折叠的作用。


Item 29

  • 有时候,移动不一定比拷贝快。举个例子:

    • std::arraystd::array 的数据不会存放在堆上,基本就是内置数组类型的简单封装。所以移动也是O(n)复杂度。
    • 很多 std::string 的实现都采用了 *small string optimization(SSO)*。比较短的字符串可能不会在存在堆上。这样移动的话效率也不会比拷贝高。
  • 有些时候,为了保证老代码在C++98下的强异常安全,如果有移动操作但移动操作没有声明 noexcept,编译器也会选择采用拷贝操作。


Item 30

1
2
3
4
5
template<typename... T>
void fwd(T&&... params)
{
f(std::forward<T>(params)...);
}

这是一个很简答的完美转发的例子。但是实际上,它有时候并非如“完美”。在以下情况中完美转发会失败:

  • 编译器无法推断类型。
  • 推断的类型并非预期。这可能会造成编译失败,或者匹配重载函数的其他版本。

当参数比较特殊的收就很容易出错。举一些例子:

  • Branced initializers 。假设 f 声明为如下:

    1
    2
    3
    void f(const std::vector<int>& v);
    f({1,2,3}); // Ok, 列表隐式转化为向量
    fwd({1,2,3}); // 编译失败

    这是因为如果直接调用 f ,编译器将会对实参和形参类型匹配,但必要的时候会进行隐式转化。而通过 fwd 调用的时候,标准规定不能推断表达式 {1,2,3} 的类型(std::initializer_list 是 “non-deduced context”)。只有fwd的参数被声明为 std::initializer_list的时候,才可以这么传参数。而 auto 就不受这个限制了,可以这么写:

    1
    2
    auto il = {1,2,3};
    fwd(il); // It works.
  • 0 或者 NULL。很可能会被腿短成int类型,而非空指针。所以用 nullptr 代替它们。

  • 只有声明的 static const 成员变量。建议写上定义(不要重复初始化)。否则可能会在参数是引用的函数传参时链接失败。

  • 重载函数的名字或者模板的名字。这通常对于 f 参数是函数指针的时候,给 f 传重载函数的函数名,编译器会选择签名相同函数传指针过去。而对于 fwd ,来说,如果给它的参数是重载函数或者模板的名字,就无法推断,编译出错。解决的办法就是先定义一个函数指针,然后再作为参数传递给 fwd

  • 位域(Bitfields)。因为位域的成员是无法引用的,所以将它作为参数传递给 fwd 就会出问题。(原因在于位域的成员可能是任意的bit,比如说一个32位int类型的第3到第5个bit,这就无法寻址)。

智能指针

首先,raw pointer有着诸多缺陷

  • 指向对象的不明确(单个对象or数组?)
  • 析构方式的不明确(deletedelete[]还是一个特殊的析构机制,比如调用某个函数)
  • 析构的时机、次数容易出错
  • 容易出现dangling pointer

所以,c++11有了智能指针。


Item 18

  • 资源紧缺的机器上也可以用std::unique_ptr,因为当它使用默认或者stateless lambda作为删除器的时候,和raw pointer大小一样。而且对于很多操作(比如解引用),它们执行相同的指令。

  • std::unique_ptr体现了独占所有权(exclusive ownership)的语义。只能move,不能copy。

  • 对于层级继承关系来说,可以用工厂函数返回std::unique_ptr。并且调用方可以用std::shared_ptr来接收工厂函数的返回值。

  • std::unique_ptr有两种形式,一个是单独的对象(std::unique_ptr<T>),另一个是数组(std::unique_ptr<T[]>)。不过对于后者来说,c++中已经有了足够多足够好的替代品(比如说std::arraystd::vectorstd::string)。最好在使用返回一个裸指针的C-like API的时候才使用std::unique_ptr<T[]>。


Item 19

  • std::shared_ptr性能影响:

    • 大小是raw pointer的两倍。因为还有一个指向control block的raw pointer。
    • 引用计数的内存是动态分配的。
    • 引用计数增加和减少是原子操作。可能会降低速度。
    • control block用到了虚函数。
  • std::shared_ptr的移动操作比拷贝操作快。因为移动操作会直接让源std::shared_ptr置null,这意味着旧的std::shared_ptr引用计数不用操作。

  • 对于std::unique_ptr来说,删除器是它类型的一部分;而对于std::shared_ptr来说却不是。而且改变std::shared_ptr的删除器不会改变它的大小(因为分配在堆上)。

  • std::shared_ptr包含两个部分:Ptr to T和Ptr to Control Block。

    • Ptr to T指向T Object;

    • Ptr to Control Block指向:

      • Reference Count
      • Weak Count
      • Other Data(e.g.,custon deleter,allocto,etc)
  • 一些关于control block的规则:

    • std::make_shared总是会创建一个control block。
    • 当一个std::shared_ptr用unique_ptr或者auto_ptr构造的时候,也会创建一个control block。
    • 用一个raw pointer构造std::shared_ptr的时候,也会创建一个control block。
  • 基于以上的规则,用同一个raw pointer构造多个std::shared_ptr是未定义行为。会有多个control block,导致对象被多次析构。

  • std::shared_ptr不同于std::unique_ptr,不能很好地工作于数组。没有std::shared_ptr<T[]>的版本。所以默认删除器用的delete,而且没有重载oparetor[]。而且对于单个对象,std::shared_ptr允许从派生类向基类的转化;当作用在数组上时,可能会有奇怪的行为。所以尽量用std::array或者std::vector代替build-in array。


Item 20

  • std::weak_ptr不能解引用。所以一般还得用std::shared_ptr

    • 一种形式

      1
      2
      3
      4
      //从前有一个wpw是std::weak_ptr
      std::shared_ptr<Widget> spw1 = wpw.lock(); //如果wpw悬垂,spw1是空指针。
      auto spw2 = wpw.lock(); //同上。
      //其他操作
    • 另一种形式

      1
      std::shared_ptr<Widget> spw3(wpw);  //如果wpw悬垂,抛出std::bad_weak_ptr异常
  • 使用std::weak_ptr的例子:

    • 由于某个类构造开销比较大,所以设计了cache factory function,将不同的对象构造并缓存,返回std::shared_ptr

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      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是指向缓存对象的std::shared_Ptr(当然,对象无缓存就是null了)
      if (!objPtr)
      { //如果没缓存,
      objPtr = loadWidget(id); //构造它
      cache[id] = objPtr; //缓存它
      }
      return objPtr;
      }
    • 观察者设计模式(Observer design pattern)中,subject(状态会变化的对象)通常有一个成员是指向observer(观察者,观察subject的变化)的指针。这个指针对于控制observer的生命周期不感兴趣(即不需要std::shared_ptr),但是需要确定指向的observer是否已经被析构,所以用std::weak_ptr

    • 三个对象A,B,C。A和C通过std::shared_ptr指向B,即共享B的所有权。B需要有一个指针也指向A。有三个选择:

      • Raw pointer。如果A被析构,就悬垂了。
      • std::shared_ptr。A和B都用std::shared_ptr指向对方,这个嵌套会导致A和B都不会被析构。
      • std::weak_ptr。这是个最佳选择,不会有上述的问题。

      ps:用std::weak_ptr阻止std::shared_ptr循环的方法不是很常见。对于有严格分层的数据机构,比如说树来说,子节点属于父节点,父节点析构,子节点也应该被析构。父节点指向子节点用std::unique_ptr,子节点指向父节点用raw pointer。因为子节点总是会随着父节点被析构,不会出现子节点指向的父节点悬垂的情况。

  • std::weak_ptrstd::shared_ptr大小相同。std::weak_ptr不参与对象的所有权,因此不会影响指向对象的reference count。


Item 21

  • 有三个make函数,作用都是完美转发参数然后构造某个对象,并让智能指针指向它。分别是

    • std::make_unique.c++14才加入了标准库。不过自己也可以写个类似很简单的实现

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
              template<typename T, typename... Ts>
      std::unique_ptr<T> make_unique(Ts&&... params)
      {
      return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
      }
      ````
      * `std::make_shared`。和`std::make_unique`大同小异。
      * `std::allocate_shared`。和`std::make_shared`作用几乎相同。但是它的第一个参数是一个allocator对象。

      * 用make函数的好处

      * 一是不用重复类型

      ```cpp
      auto upw1(std::make_unique<Widget>()); //with make func
      std::unique_ptr<Widget> upw2(new Widget); //without make func
    • 而是异常安全。考虑以下这个代码:

      1
      2
      3
      4
      5
      //从前有两个函数
      void processWidget(std::shared_ptr<Widget> spw, int priority);
      int computePriority();

      processWidget(std::shared_ptr<Widget>(new Widget), computePriority());

      将会导致可能的内存泄露。因为上述最后一行代码的参数完成操作需要:

      • new Widget,即一个Widget对象被创建在堆上。
      • std::shared_ptr被构造。
      • computePriority必须执行一次。

      构造std::shared_ptr<Widget>肯定会发生在new Widget之后,毕竟后者作为前者的参数。但是computePriority函数执行的时间不确定。编译器可能会让computePriority正好在new Widget之后被调用。如果在运行期间,computePriority函数抛出异常,那么就内存泄漏了。所以应该用make函数

      1
      2
      3
      4
      5
      //两个函数同上
      void processWidget(std::shared_ptr<Widget> spw, int priority);
      int computePriority();

      processWidget(std::make_shared<Widget>(), computePriority);
  • 用make函数性能上也可能会更好。考虑一下代码

    1
    std::shared_ptr<Widget> spw(new Widget);

    它总共分配了两次空间。一次是new Widget,另一个是构造std::shared_ptr<Widget>时,分配cotrol block。

    而对于make函数

    1
    auto spw = std::make_shared<Widget>();

    只需要申请一次空间。因为会一次分配能容纳Widget对象和control block大小之和的空间,然后在分别初始化它们。不仅如此,根据cpu cache局部性,以后缓存命中率还可能更高。

  • 不过make函数也有一些局限性:

    • 不能指定删除器。而直接用智能指针的构造函数却可以。

    • 当你小要用花括号作为对象构造函数的时候,不能用make函数。或者这么用:

      1
      2
      3
      4
      //create std::initalizer_list
      auto initList = { 10, 20 };
      //createstd::vector using std::initializer_list ctor
      auto spv = std::make_shared<std::vector<int>>(initList);

      对于std::unique_ptr,它的make函数有以上缺点。但是对于std::shared_ptr和它的make函数来说,还有另外两个缺点(都是极端情况

    • 如果一个对象的类型有自定义版本的operator new和operator delete,那么使用make函数来创建他们通常是一个糟糕的想法。

    • 上面也提到过,当使用make函数的时候,会一次分配能容纳Widget对象和control block大小之和的空间。而control block有reference count和weak count。只要weak count大于0,引用快就必须存在。那么显然,会有对象所占的内存无法及时释放的情况出现:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class ReallyBigType{...};
      auto pBigObj = std::make_shared<ReallyBigType>(); //通过make_shared创建一个很大的对象

      ... //创建若干个指向此对象的std::shared_ptr和std::weak_ptr并使用它们

      ... //最后一个指向此对象的std::shared_ptr在这被销毁。但是std::weak_ptr仍然保持

      ... //在这期间,先前创建的大对象所占的内存仍然没有被回收

      ... //最后一个指向此对象的std::weak_ptr在这被销毁。对象和control所占的内存才被释放。

      这种情形直接用new反而更好。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class ReallyBigType{...};                                       //和之前一样
      std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType); //通过new创建一个很大的对象

      ... //和之前一样,创建若干个指向此对象的std::shared_ptr和std::weak_ptr并使用它们

      ... //最后一个指向此对象的std::shared_ptr在这被销毁。但是std::weak_ptr仍然保持。为对象分配的空间已经被释放

      ... //在这期间,只有control block的空间还没释放

      ... //最后一个指向此对象的std::weak_ptr在这被销毁。control block所占的内存才被释放。

Item 22

Pimpl(“pointer to implementation”) Idiom是医用用来缩短编译时间的技术。用一个指向某个class(or struct)的指针来代替具体的成员对象。举个例子

1
2
3
4
5
6
7
8
9
10
//in header "widget.h"
class Widget {
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; //Gadget是用户自定义的类型
};

每次Gadget.h改变,都得重新编译。所以运用Pimpl Idiom:

1
2
3
4
5
6
7
8
9
10
//still in header "widget.h"
class Widget {
public:
Widget();
~Widget();
...
private:
struct Impl; // declare implementation struct
Impl *pImpl; // and pointer to it
};

每次Gadget.h改变,Widget不受影响。
不过学了智能指针,你可能互相到把raw pointer换成std::unique_ptr。就想下面这样:

1
2
3
4
5
6
7
8
// in "widget.h"
class Widget {
public:
Widget();
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// in "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>()) {}

可能注意到了,由于用智能指针管理资源,似乎用编译器生成的析构函数就能正常工作了的样子。编译没问题。不过,当你写下WIdget w;,想要使用它的时候,却会编译出错。

问题出在试图析构w,生成代码的时候。此时,析构函数将会被调用。根据编译器生成特殊成员函数的规则(详情见Item 17),编译器试图生成一个析构函数。这个生成的析构函数会调用plmpl(一个使用默认删除器的std::unique_ptr)的析构函数。在这个指针的析构函数中,将会直接用delete作用于一个raw pointer。要注意到,在这时,Impl还是个不完全类型。但是,由于在实现在实现作用,默认deleter通常会用c++11的static_assert来确保指针指向完整类型。所以当编译器生成析构函数的时候,它就碰到了一个失败的static_assert。

要解决这个问题也很简单,做到在析构的时候Impl是完整类型就可以了。

1
2
3
4
5
6
7
8
9
10
11
// in file "widget.h"
class Widget {
public:
Widget();
~Widget(); // 只声明
...

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
// in file "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() // ~Widget的定义
{}

如果想要强调编译器生成的析构函数就可以工作(声明它的唯一理由就是为了在Widget的实现中生成),你也可以这么干:

1
Widget::~Widget() = default;  //same effect as above

处于同样的考量,还能加上move和copy操作。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// in file "widget.h"
class Widget {
public:
Widget();
~Widget(); // 只声明
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
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
20
21
22
23
24
25
26
27
28
// in file "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default;
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;

Widget::Widget(const Widget& rhs)
: pImpl(std::make_unique<Impl>(*rhs.pImpl));

Widget& Widget::operator=(const Widget& rhs)
{
*pImpl = *rhs,pImpl;
return *this;
}

可以看到,如果Pimpl采用std::unique_ptr,即使编译器生成的函数能工作,也需要明确的把比声明和实现分开来。

但是如果采用std::shared_ptr,上述的建议就不需要了。直接用编译器生成的函数就能够工作。

造成不同的根本原因是它们支持custom deleter的不同方式。

  • 对于std::unique_ptr,deleter是类型的一部分,使得编译器能够生成更小的运行期数据结构和更快的运行期代码。这个带来的后果就是,当编译器生成特殊函数的时候,指向的类型必须完整。
  • 对于std::shared_ptr,deleter不是类型的一部分,使得需要更大的运行期数据结构和更慢的代码。但是当编译器产生特殊函数的时候,指向的类型不需要是完整的。

ps:第一本看的英文原版…希望这个寒假能看完吧

Item 1-5

  • (建议直接看书,个人认为很重要

Item 6

  • 警惕auto被类型推导为某个意想不到的proxy class(ie. vector::operator[])

Item 7

  • 大括号可以用来完成uniform initialization,且不会发生隐式narrowing conversion.

  • 一般来说,Object o{xxx};会优先匹配参数是std::initializer_list的构造函数

  • 类内非静态成员不能用()初始化

  • auto t = {xxx};, t类型被推断为std::initializer_list

  • 一组对比

    • Object o(); => Error
    • Object o{}; => OK
  • 另一组对比

    • Object o{};调用默认构造函数
    • Object o{ {}};或者Object o( {});调用参数为空的std::initializer_list的构造函数

Item 8

  • 0,NULL和nullptr三者,用nullptr表示空指针(之前单独总结过,不多说了)(逃

Item 9

  • With a alias template:

    1
    2
    3
    4
    5
    6
    7
    template<typename T>                          // MyAllocList<T>
    using MyAllocList = std::list<T, MyAlloc<T>>; // is synonym for
    // std::list<T,
    // MyAlloc<T>>

    MyAllocList<Widget> lw; // client code

    1
    2
    3
    4
    5
    6
    7
    template<typename T>
    class Widget {
    private:
    MyAllocList<T> list;
    ...
    };

  • And with a typedef:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<typename T>
    // MyAllocList<T>::type
    struct MyAllocList {
    // is synonym for
    typedef std::list<T, MyAlloc<T>> type; // std::list<T,
    };
    //MyAlloc<T>>
    MyAllocList<Widget>::type lw;
    // client code

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename T>
    class Widget {
    // Widget<T> contains
    private:
    // a MyAllocList<T>
    typename MyAllocList<T>::type list;
    // as a data member
    ...
    };
  • c++14运用alias template的一点改进

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    std::remove_const<T>::type
    std::remove_const_t<T>
    // C++11: const T → T
    // C++14 equivalent
    std::remove_reference<T>::type
    std::remove_reference_t<T>
    // C++11: T&/T&& → T
    // C++14 equivalent
    std::add_lvalue_reference<T>::type
    // C++11: T → T&
    std::add_lvalue_reference_t<T>
    // C++14 equivalent

Item10

  • 一般来说,用scoped enums代替unscoped enums。

    • scoped emums带来了namespace pollution。
    • unscoped enums的成员会隐式转化成整数类型。scoped enums则不会(但可以显式转化)。(这个带来了一点好处。见下
  • tuple和unscoped enum搭配更佳, 免去记住所有参数序列。否则就得…

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename E>
    constexpr auto toUType(E enumerator) noexecpt
    {
    return static_cast<std::underlying_type_t<E>>(enumerator);
    }

    //...

    auto val = std::get<toUType(UserInFoFields::uiEmail)>(uInfo);

Item 11

  • c++11前常常将成员函数声明放在private区域而不实现,以防止编译器自动生成。现在有了=delete

  • =delete不光能用于成员函数。还能通过讲“重载函数”声明为=delete防止函数的参数被隐式转换。

  • =delete还能用于阻止template instantiation。

Item 12

  • override虚函数的时候加上 override关键词。

  • member function reference qualifiers能区分*this的类别(类似于以前的const

Item 13

  • 在c++98的时候,容器的非const对象无法直接获得const_iterator,一般通过强制转化,或者用绑定到一个reference-to-const。

  • 在c++98,const_iterator不能用于在插入或者删除的函数指示位置。

  • c++11提供了beginend函数(包括许多容器的成员函数和非成员函数的通用版本)。但是c++14才提供了(这里指的是non-member版本)cbegin, cend, rbegin, rendcrend。有个用c++11实现cbegin的例子。

    1
    2
    3
    4
    5
    template <class C>
    auto cbegin(const C& container)->decltype(std::begin(container))
    {
    retrurn std::begin(container)
    }

Item 14

  • exception-neutral函数(指这类函数:它本身不抛出异常,但是它调用的函数可能会抛出异常)不能加noexcept

  • 典型地,move operations,swap,memory deallocation函数, destructor都默认noexcept

Item 15

  • constexpr对象是编译期常量。(不过准确的说,应该translation期间的常量,包括编译compile和链接link)

  • 对于constexpr函数来说,有两种情况。这意味着不需要对运行期和编译期执行的函数进行重载

    • 如果所有参数都是编译期常量,函数将在编译期就得到结果。

    • 当一个或多个参数不是编译期常量的时候,就和普通函数一样,在运行期执行。

  • constexpr是函数接口的一部分,不能随意去除

Item 16

  • 对于某个需要同步的对象来说,用std::atomic就够了。但是对于多个对象作为一个整体需要同步的时候,往往需要用到mutex。

Item 17

  • c++11开始,特殊成员函数有

    • defualt constructor 默认构造函数
    • destructor 析构函数
    • copy constructor 拷贝构造函数
    • copy assignment operator 拷贝赋值函数
    • move constructor 移动构造函数
    • move assignment operator 移动赋值函数
  • 对于后两个移动操作有关的函数(构造和赋值)来说,编译器默认生成的版本的行为是对所有非static成员调用执行对应(构造或赋值)的移动操作。对于带有继承的类来说,还会对基类进行对应的移动操作。但是,遇到不支持移动操作的对象,所以会用copy来替代move操作。

  • 完成上述操作的核心是将std::move用于相应的对象(即非static成员或者基类部分),然后根据函数重载规则,会调用相应的copy和move函数。

  • 这两个移动操作不是相互独立的。如果声明了其中一个,编译器将不会自动生成另一个。

  • 声明了拷贝操作(拷贝构造函数和拷贝赋值函数),那么编译器将不会生成移动操作的版本。反之亦然。

  • c++98开始就有个Rule of Three,即析构函数,拷贝构造函数,拷贝赋值函数三者自定义了任意一个,那一般来说其他两个也得自定义,编译器生成的很可能是错误的行为。

  • 总结:下面三个条件都成立,则移动操作将会被编译器生成:

    • 没有声明拷贝操作
    • 没有声明移动操作
    • 没有声明析构函数
  • 想让编译器生成可以显示地用=default。尽量别依赖编译器隐式生成的规则,一是能规避复杂规则带来的bug,二是让代码意图明显。

  • 成员函数模板不会影响编译器生成这些特殊成员函数。

0%