C++11 新特性解析与应用
这是我这阵子学习 C++11 做的一些记录,材料大多来自《深入理解 C++11 新特性解析与应用》这本书。
第一节 右值引用
指针成员和拷贝构造
对 C++ 程序员来说,编写 C++ 程序有一条必须注意的规则,就是类中包含了一个指针成员的话,那么就要特别小心拷贝构造函数的编写。因为一不小心,就会出现内存泄漏。我们来看看下面代码清单中的例子。
|
在上述代码中,我们定义了一个 HasPtrMem 的类,它包含一个指针成员,该成员在构造时会接受一个 new 操作分配堆内存返回的指针,而在析构的时候则会被 delete 操作用于释放之前分配的堆内存。在 main 函数中,我们声明了 HasPtrMem 类型的变量 a,又使用 a 初始化了变量 b。按照 C++ 的语法,这会调用 HasPtrMem 的默认拷贝构造函数,它由编译器隐式生成,其作用是类似于 memcpy 的按位拷贝,它其实是一种浅拷贝。
但是这样的构造方式有一个问题,就是 a.ptr 和 b.ptr 都指向了同一块堆内存。因此在 main 作用域结束的时候,a 和 b 的析构函数纷纷被调用,当其中之一完成析构(比如 b), 那么 a.ptr 就成了一个悬挂指针,因为其指向的内存不再有效了,这时候就会报错(如下所示):
╭─yang@yangdeMacBook-Pro.local ~ |
这个问题在 C++ 编程中非常经典。这样的拷贝构造方式,在 C++ 中也常称为“浅拷贝”。而在未声明构造函数的情况下,C++ 也会为类生成一个浅拷贝的构造函数。通常最佳的解决方案是用户自定义拷贝构造函数来实现“深拷贝”,我们来看看下面代码清单中的修正方法。
|
在上述代码清单中,我们为 HasPtrMem 添加了一个拷贝构造函数 HasPtrMem(const HasPtrMem &h)
。拷贝构造函数从堆中分配新内存,将该分配来的内存指针还给 ptr
,又使用 *(h.ptr)
对 *ptr
进行了初始化。通过这样的方法,避免了悬挂指针的困扰。
移动语义
拷贝构造函数中为指针成员分配新的内存再进行内容拷贝的做法在 C++ 编程中几乎被视为不可违背的。不过在一些时候,我们确实不需要这样的拷贝构造语义。我们可以看看下面代码清单中的例子。
|
在代码清单中,我们声明了一个返回一个 HasPtrMem 变量的函数。为了记录构造函数、拷贝构造函数,以及析构函数调用的次数,我们使用了一些静态变量。在 main 函数中,我们简单地声明了一个 HasPtrMem 的变量 a,要求它使用 getTemp 函数的返回值进行初始化。编译运行该程序,我们可以看到下面输出:
╭─yang@yangdeMacBook-Pro.local ~ |
注意:指定这个参数(-fno-elide-constructors)是为了关闭编译器的优化,强制 g++ 在所有情况下都会调用拷贝构造函数。 |
这里构造函数被调用了 1 次,这是在 getTemp 函数中 HasPtrMem() 表达式显式地调用了构造函数而打印出来的。而拷贝构造函数则被调用了 2 次:一次是从 getTemp 函数中 HasPtrMem() 生成的变量上拷贝构造出一个临时值,以用作 getTemp 的返回值,而另外一次则是由临时值构造出 main 中变量 a 调用的。对应的,析构函数也就被调用了 3 次。
拷贝构造函数的调用场景为:(1)当用类的一个对象初始化该类的另一个对象时,例如 B(A) 或者 B=A;(2)函数的形参为类的对象时,当调用函数时,拷贝构造函数被调用;(3)如果函数的返回值是类的对象,函数执行完成返回调用者时。 |
在我们的例子里,类 HasPtrMem 只有一个 int 类型的指针。而如果 HasPtrMem 的指针指向非常大的堆内存数据的话,那么拷贝构造的过程就会非常昂贵。可以想象,这种情况一旦发生,a 的初始化表达式的执行速度将相当堪忧。( 事实上,编译器常常对函数的返回值有专门的优化,我们在本节结束时将会提到。)
在上面的例子中可以看出从临时变量中拷贝构造变量 a 的做法:即在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝内容至 a.ptr。而构造结束后,临时对象将析构,因此其拥有的堆内存资源会被析构函数所释放. C++11 提出了一种新方法,该方法在构造时使得 a.ptr 指向临时对象的堆内存资源, 同时我们保证临时对象不释放所指向的堆内存。那么构造完成后,临时对象虽然被析构,但是那块内存还在,只不过是被对象 a 偷走了。
在 C++11 中,这样 “偷走” 临时变量资源的构造函数,被称为移动构造函数。说白了就是将临时变量的资源管理权移交给另一个变量,我们可以看看下面代码清单是如何实现这种移动语义的:
|
可以看到:而不像拷贝构造函数一样需要重新分配内存,然后将内容依次拷贝到新分配的内存中。移动构造函数是将对象 h 的指针地址 h.ptr 赋值给了本对象的指针 ptr,随后将 h.ptr 置为空指针 nullptr,这就相当于完成了内存资源管理权的交接过程。下面看看程序运行的输出:
╭─yang@yangdeMacBook-Pro.local ~ |
可以看到,这里没有调用拷贝构造函数,而是调用了两次移动构造函数。移动构造的结果是,getTemp 中的 h.ptr 和 main 函数中的 a.ptr 的值是相同的 (即 h.ptr 和 a.ptr 都指向了相同的堆地址内存). 该堆内存在函数返回的过程中, 成功地逃避了被析构的厄运,取而代之地成为了赋值表达式中的变量 a 的资源。如果这块内存不是一个 int 长度的数据,而是以 MByte 为单位的空间,那么它带来的性能提升将非常惊人。
左值、右值和引用
在 C 语言中,我们常常会提起左值 (lvalue)、右值 (rvalue) 这样的称呼。而在编译程序时,编译器有时也会在报出的错误信息中会包含左值、右值的说法。关于左值和右值,一个最为典型的判别方法就是: 在赋值表达式中, 出现在等号左边的就是 “左值” ,而在等号右边的,则称为“右值”。比如:
a = b + c; |
在这个赋值表达式中,a 就是一个左值,而 b+c 则是一个右值。这种识别左值、右值的方法在 C++ 中依然有效。不过 C++ 中还有一个被广泛认同的说法,那就是可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。那么这个加法赋值表达式中,&a 是允许的操作,但 &(b+c) 这样的操作则不会通过编译。因此 a 是一个左值,(b+c) 是一个右值。
这些判别方法通常都非常有效。更为细致地讲, 右值是由两个概念构成:一个是将亡值,另一个是纯右值。其中纯右值就是 C++98 标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。 比如 1+3 所产生的临时变量值,是纯右值。 而不跟对象相关联的一些字面值如:2、’c’、true,也是纯右值。而将亡值则是 C++11 新增的跟右值引用相关的表达式,这样表达式通常是将要移动的对象(移为他用),比如返回右值引用 T&& 函数的返回值、std::move 的返回值等等。
在 C++11 中,右值引用就是对一个右值进行引用的类型。事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。通常情况下,我们只能是从右值表达式获得其引用。比如:
T &&a = returnRvalue(); |
上面第一个表达式中,假设 returnRvalue 返回了一个右值,我们就声明了一个名为 a 的右值引用,其值等于 returnRvalue 函数返回的临时变量的值,该函数在执行结束后会将右值的内存所有权移交给变量 a。而在第二个表达式中,b 只是临时变量构造而成的,因此在表达式结束后就会多了一次析构和拷贝的开销。
std::move 强制转化为右值
std::move 函数的功能是将一个左值强制转化成右值引用,继而我们可以通过右值引用使用该值。从实现上来讲,std::move 基本等同于一个类型转换。
static_cast<T &&> (lvalue); |
让我们来看看下面代码清单中的例子:
|
我们为类型 Moveable 定义来移动构造函数。这个函数本身其实没有什么问题,但调用的时候使用了 Moveable c(std::move(a)); 这样的语句。这里 a 本来是一个左值变量,通过 std::move 将其转换成右值。这样一来,a.ptr 就被 c 的移动构造函数设置为指针空值,那么随后对表达式 *a.ptr 执行时就会发生严重的运行时错误。下面是执行结果:
╭─yang@yangdeMacBook-Pro.local ~ |
有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数,看看下面这段 swap 模版函数代码:
template <class T> |
代码中,a 先将自己的资源交给 c,随后 b 再将资源交给 a,c 随后又将 a 中得到的资源交给 b,从而完成了一个置换动作。整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放与申请。
第二节 智能指针和垃圾回收
C++11 的智能指针
在 C++98 中,智能指针通过一个模版类型 auto_ptr 来实现。auto_ptr 以对象的方式管理堆分配的内存,并在适当的时间(比如析构)释放所获得的堆内存。这种堆内存管理的方式只需要程序员将 new 操作返回的指针作为 auto_ptr 的初始值即可,程序员不用再显式地调用 delete。比如:
auto_ptr<int> pt(new int(123)); // 包含一个int*的指针,并初始化为 123 的地址 |
这在一定程度上避免了堆内存忘记释放造成的问题。不过 auto_ptr 有一些缺点,先来看看下面一个例子:
|
为什么在把 p1 复制给 p2 之后 p1 再使用就异常了呢?这也正是它被抛弃的主要原因。因为 auto_ptr 拷贝构造函数中会把指向的地址进行转移,也就是从 p1 转移给了 p2。此时 p2 拥有了 p1 所指向的内存地址,但 p1 却为空,再使用它就会异常了。
如果我们使用 unique_ptr 指针,那么:
|
可以看到,如果我们直接使用 auto p2 = p1
,编译器将会告诉我们这是错误行为。这是因为每个 unique_ptr 都是唯一地拥有所指向的对象内存,并且这种所有权只能通过 std::move 函数移交出去。
对此,C++11 推出了另一智能指针 shared_ptr,它能实现对象内存所有权的共享。示例代码如下所示:
|
从上面来看:shared_ptr 允许 p1 将地址复制给 p2(内容并没有复制),实现 p1 和 p2 共享地拥有同一堆分配对象内存的所有权。但与 unique_ptr 不同的是,shared_ptr 在实现上采用了引用计数,一旦其中一个 shared_ptr 指针放弃了“所有权”,其他的 shared_ptr 对象内存的引用并不会受到影响: 虽然 p1 调用了 reset() 函数进行销毁,但只会导致引用计数降低,而不会引起堆内存的释放,所以 p2 仍能正常访问。 |
除了 unique_ptr 和 shared_ptr, 智能指针还包括了 weak_ptr 这个类模版。weak_ptr 的使用更复杂一点,它可以指向 shared_ptr 指针指向的对象内存,却不拥有该内存。而使用 weak_ptr 成员 lock,则可返回其指向内存的一个 shared_ptr 对象,且在所指对象内存已经无效时,返回空指针(nullptr)。
|
在上面代码清单中, 我们定义了一个共享对象内存的两个 shared_ptr 指针,sp1 和 sp2。而 weak_ptr wp 同样指向该对象内存。可以看到 sp1 及 sp2 都有效的时候,我们调用 wp 的 lock 函数,将返回一个有效的 shared_ptr 对象供使用,于是 check 函数会输出以下内容:
still 22 |
此后我们分别调用了 sp1 及 sp2 的 reset 函数,这会导致唯一的堆内存对象的引用计数降至 0 。而一旦引用计数归 0,shared_ptr 就会释放堆内存空间,使之失效。此时我们再调用 weak_ptr 的 lock 函数时,则返回一个指针空值 nullptr。这时 check 函数则会打印出:
pointer is invalid |
在整个过程中,只有 shared_ptr 参与了引用计数,而 weak_ptr 指针没有影响其指向内存的引用计数,因此可以用于验证 shared_ptr 指针的有效性。
垃圾回收机制
在程序中,不再使用或者没有任何指针指向的内存空间就称为垃圾,而将这些垃圾收集起来以便再次利用的机智,就称为垃圾回收。垃圾回收的方式虽然有很多,但主要可以分两大类:
- 基于引用计数机制:
简单地来说, 引用计数主要是使用系统记录对象被引用的次数, 当对象被引用计数的次数变为 0 时, 该对象即可被视作垃圾而回收。但这种方法有一个著名的缺点就是:难以处理 “ 环形引用 ” 问题,即两个垃圾对象彼此之间互相引用,它们各自的计数器不为 0,这种情况对引用计数算法来说是无能为力的。
- 基于跟踪处理机制:
相比于引用计数,跟踪处理的垃圾回收机制被更为广泛地应用。其基本方法是产生跟踪对象的关系图,然后进行垃圾回收。使用跟踪方式的垃圾回收算法主要有以下几种:
(1) 标记-清除:顾名思义,这个算法可以分为两个过程。首先将该算法将程序中正在使用的对象视为“根对象”,从根对象开始查找它所引用的堆空间,并在这些堆空间上做标记。当标记结束后,所有被标记的对象就是可达对象,而没有标记的对象就被认为是垃圾,在第二步清扫阶段就会被回收掉。
(2) 标记-整理:这个算法标记的方法和之前标记-清除的方法一样,但是在标记完之后, 不再遍历所有对象清扫垃圾了,而是将可达对象向左靠齐,这就解决了内存碎片的问题。该方法有个特点是,需要移动所有活对象,因此相对应的,程序中所有堆内存的引用都必须更新。
(3) 标记-拷贝:这种算法将堆空间分为两个部分:From 和 To 。刚开始系统只从 From 的堆空间里分配内存,当 From 分配满的时候系统就开始垃圾回收: 从 From 堆空间找出所有活对象,拷贝至 To 的堆空间里。这样一来,From 的堆空间里面就全剩下垃圾了。 而对象被拷贝到 To 里之后, 在 To 里是紧凑排列的。
第三节 auto 类型推导
静态类型、动态类型和类型推导
在编程语言的分类中,C/C++ 常被冠以静态类型的称号,而有的编程语言则号称是动态类型的,比如 Python。通常情况下,静和动的区别非常直观,我们可以看看下面这段 Python 代码:
name = 'world\n' |
我们发现,变量 name 在使用前从未进行过任何的类型声明,而当程序员想使用时,就可以拿来用。这种变量的使用方式显得非常随性,而在 C++ 程序员的眼中,每个变量使用前必须定义类型几乎是天经地义的事情,这样通常被视为编程语言中的静态类型的体现。而对于 Python 这种拿来就用的变量使用方式,则被视为动态类型的体现。
不过从技术上严格来讲,静态类型和动态类型的主要区别在于对变量进行类型检查的时间点。对于所谓的静态类型,类型检查主要发生在编译阶段;对于动态类型,类型检查主要发生在运行阶段。形如 Python 等语言中变量拿来就用的特性,则需要归功于一个技术,即类型推导。
我们可以使用 C++11 中 auto 的方式书写一下刚才的 Python 代码:
|
指的注意的是,auto 声明的变量必须立即被初始化,以使编译器能够从初始化表达式中推导出其类型。从这个意义上来讲,auto 并非一种类型的声明,而是一个类型声明时的占位符,编译器在编译时期会将 auto 替代为变量实际的类型。
auto 的优势
直观地,auto 推导的一个最大优势就是拥有初始化表达式的复杂类型声明时简化代码。由于 C++ 的发展,声明变量类型也变得越来越复杂,很多时候,名字空间、模版成为了类型的一部分,导致程序员在使用库的时候如履薄冰。
|
如我们所见,使用了 auto 后,写出的代码变得更加清晰可读了。