断断续续看了Bjarne Stroustrup的’C++之旅(a tour of C++
)’, 作者把很多原本看起来复杂的概念模型都讲的比较清晰, 也有不少好的代码示例, 有种拨云见雾的感觉. 于是想着写一篇文章来总结重新学习C++的一些经验, 主要阐述下现代C++(>=C++11
)中那些容易让人混淆而觉得陌生的技术.
了解C++历史的人都知道, Bjarne Stroustrup
是在Bell实验室(就是Unix操作系统与C语言诞生的地方)发明了C++, 初衷是在C中加入类(class)的概念, 增强C语言在系统编程上的效率与灵活性, 也正式因为这个原因, C++在1979年最初的名字是带类的C
(c with class), 直到1984年才改名为C++
. 到今天, C++
的发展历经了快40年历史, 但真正一次大的标准修改是在2011年, 这个版本也称为C++11
(C++11
之后的版本也统称为Modern C++
).接下来我们就来一起来回顾下现代C++中那些曾经让人头疼
的技术吧.
constants
除了const
之外, C++还引入了constexpr
这个关键字. const
的意思是这个变量不会被修改, 编译器在编译的时候会确保这一点: 如果尝试修改const
的赋值, 则会提示错误. 而constexpr
是说这个表达式会在编译的时候进行判定(不用等到运行时):
1 |
|
如果一个函数要在constexpr
表达式中使用, 则必须要以constexpr
开头, 并且函数的实现需要足够简单: 只由一个返回值的表达式组成,且不能调用任何其他非constexpr
的函数.
1 |
|
由于在编译时就完成了计算, 使用constexpr
通常可以改善性能, 但据c++ standar library: a tutorial and refernce
里边提到的, 引入constexpr
是为了解决C++98numeric_limits
标准库中的一个bug:std::numeric_limits<short>::max()
无法在常量表达式中使用.
引用与右值
C++针对C中的指针引入了引用(reference)
的概念, 引用类似于指针, 其通过&
进行声明, 但实际使用的时候无需在通过*
来引用对象, 更重要的是引用一旦被初始化就不能再被修改, 引用其他的对象.
引用类似指针,但非指针;引用只是对象的别名。引用与指针存在三个区别: 1) 没有空引用(
null reference
),就是说引用始终指向一个有效的对象 2) 所有引用需要初始化 3) 一个引用永远指向它初始化的那个对象
引用对于不管理资源的对象来说, 一般不会有什么问题, 但涉及到大量的数据/内存时(比如容器), 单纯的拷贝会变得低效. 有时, 我们只希望在不同对象之间移动(move)
数据而不是拷贝, 以避免产生不必要的临时对象. 比如我们有一个String
类, 包含了一个字符数组:
1 |
|
比如我们要往一个容器中插入对象, 那么每次实际容器中实际得到的都是一个拷贝的对象, 这就导致了不必要的内存拷贝, 如果对象本身很大(占用较大内存), 则拷贝会消耗更多的时间.
1 |
|
针对这种临时对象的拷贝, C++11
中引入了移动(move)
语义, 这样就可以支持对象之间数据的转移而不是拷贝了, 从而改善了程序的性能. 要让上述String
对象支持move
语义, 其实也很简单:
1 |
|
上述代码中&&
表示一个右值引用(rval refernece
), 就是说该引用跟一个右值绑定, 这通常有别于左值(赋值语句的左边), 由于右值引用无法进行赋值, 因此我们可以偷取
它的值与资源. C++标准库中也有一个move
函数可以用于操作右值引用(头文件utility
):
1 |
|
需要留意的时, 对于函数返回值不应该返回右值或使用move()
要返回的值, 对于如下函数:
1 |
|
C++会确保返回值按照如下规则进行:
- 如果
String
有move构造函数或者赋值函数, 编译器可能会忽略拷贝对象, 这种技术被称为返回值优化(returned value optimization
), 在C++11之前就有了. - 否则就使用move构造函数返回对象
- 如果没有move构造函数, 则使用拷贝构造函数返回拷贝对象
- 否则就会抛出编译时异常
有关更多右值引用、移动语义可以参考如下两篇文章:
- A Brief Introduction to Rvalue References
- A Proposal to Add Move Semantics Support to the C++ Language
lamda表达式与函数对象
C++11引入了lambda
表达式; lambda
表达式可以用于创建简单的函数对象(function object
), 构建匿名函数, 其返回的对象被称为闭包对象(closure object
). 一般来说, lambda
表达式由如下三个部分组成:
捕获列表(
caputure list
):[]
, 一个对象被捕获以后就可以在lambda
函数内部使用(值复制或者引用的形式),=
用于值的形式捕获所有本地变量;&
则表示以引用的形式捕获所有本地变量. 如果需要指定捕获的变量, 则需要在符号后面加上对应变量的名字, 如=a
,&a
等. 不指定捕获符号则默认的捕获是值形式.参数列表(
parameter list
):()
, 参数列表与普通函数完全一致, 如果没有参数, 可以选择直接忽略表达式中的()
(只有C++14以上的版本支持)函数体(
function body
):{}
表示函数体, 这与普通函数是一样的.
1 |
|
lambda
表达式源自于lambda calculus
, 是Alonzo Church
在1930s用于研究逻辑与计算时使用的数学形式语言, 这也是函数式编程语言LISP
的基础.
比如实现判断一个数值是否大于10
的lamda
表达式, 可以这么做:
1 |
|
默认情况下, 未被显式指定捕获本地变量都无法在表达式函内使用, 但可以隐式的在函数体内捕获这些变量:
1 |
|
如果同时使用了两种形式的捕获, 显式捕获会覆盖默认的捕获:
1 |
|
需要注意的是, lambda
表达式可以使用泛型参数, 这个在本质上是等同于函数式对象, 不妨来看个示例:
1 |
|
上述表达式可以写成如下形式的函数对象(标准库中很多算法广泛使用了函数对象来扩展实现, 如for_each
, bind
):
1 |
|
智能指针(smart pointers)
长期以来, C被大家所诟病的一个缺陷就是指针引发的一系列内存管理问题, 比如空指针, 内存泄漏等, C++虽然通过引入了引用(reference
)在一定程度上避免了指针带来内存管理的困扰, 但并没有从根源上解决指针所引发的资源管理问题. 现代C++普遍采用RAII(Resource Acquisition Is Initialization)
原则;从C++11开始, 标准库引入了智能指针(smart pointers)避免内存泄漏: unique_ptr
/shared_ptr
, 用以替换之前的auto_ptr
.
unique_ptr
: 表示一个唯一的所属关系, 始终只有一个指针指向该对象shared_ptr
: 表示一个共有的所属关系, 可以有多个指针共享对象资源
标准库中提供了接口用于方便的创建unique_ptr
/shared_ptr
:
1 |
|
类型推断(auto/decltype)
C++11引入了auto
关键字用于告诉编译器对变量, 函数以及模板类进行推断, 从而避免让用户自己显式的声明类型(auto
变量必须初始化,这样可以避免手动声明变量未被初始化的问题). 比如, 相比写一堆嵌套的模板实例类型, 使用auto
可以简化声明:
1 |
|
需要注意的是,C++11开始支持括号列表初始化方式,如下的声明实际得到的两个变量x1
/x2
是不一样的,编译器会把x1
的类型推断为int
值,而x2
推断为std::initializer_list
, 这类似与变量x3
的类型(具体可以参考Effective Modern C++书中的第二条有关auto
类型推断的解释):
1 |
|
另外, C++11还增加了一个关键字decltype
, 用于在编译的时候获取某个变量或者表达式的类型, 例如:
1 |
|
不过需要注意的是, decltype
推断出来的类型与auto
的实际类型可能有差异(示例来自wikipedia):
1 |
|
标准库
C++标准库中已经实现了大量通用的算法, 包括查找, 排序, 过滤以及随机算法等, 如果你正瞅着自己实现某个算法时, 可以直接看看标准模板库(STL
, Stardard Template Libarary)的算法. 举几个例子:
for_each
: 可以利用迭代器iterator
很方便的对容器进行遍历操作transform
: 对容器元素根据某个特定规则进行变换find_if
: 根据某个函数对象对进行搜索查找sort
/lower_bound
: 排序/搜索算法
标准模板库STL
包含了三大组件:容器(Container
)、算法(Algorithm
)以及迭代器(Interator
)。
- 容器用于容纳/组织对象,是一种数据结构的抽象,以类模板的方式实现
- 算法执行对象的操作,是一种函数的抽象,通过函数模板来实现
- 迭代器用于访问容器中的元素,为容器与算法提供一种协同工作的机制。
更多有关标注库的算法可以参考The C++ Standard Library, 2nd Edition.
转型操作符
C++中引入了很多类型转换(cast)操作,比如const_cast
, dynamic_cast
等,为转换操作提供了明确的语法表达。
const_cast
: 去除类型中的const信息,只是去掉转型类型修饰符。
1 |
|
static_cast
: 用于相对而言可跨平台的移植的转型,最常的用于将一个基类指针或者引用,向下转型为一个派生类指针与引用
1 |
|
dynamic_cast
: 基类指针向下(downcast)转型为派生类指针, 与static_cast不同的是, dynamic_cast仅用于多态类型的向下转型(就是说被转型的类型中,必须是一个指向带有虚函数的类类型的指针),并且会执行运行期的检查,确保了转型的正确。这也为dynamic_cast
带来了额外的开销。
1 |
|
如果转型失败,会抛出一个std::bad_cast
的异常。
reinterpret_cast
: 允许从bit
位的角度重新看待一个对象,将其看做是完全不同的东西
1 |
|
通常来说,reinterpret_cast
并不具备可移植性,因此使用时需要谨慎。用于类类型转换时,注意其与static_cast
的区别,reinterpret_cast
只是将基类指针假装成一个派生类指针而不改变其值,而static_cast
则会执行地址操作。
更多关于类型转换可以参考The genesis of casting..