mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
386
极客时间专栏/现代C++实战30讲/基础篇/01 | 堆、栈、RAII:C++里该如何管理资源?.md
Normal file
386
极客时间专栏/现代C++实战30讲/基础篇/01 | 堆、栈、RAII:C++里该如何管理资源?.md
Normal file
@@ -0,0 +1,386 @@
|
||||
<audio id="audio" title="01 | 堆、栈、RAII:C++里该如何管理资源?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/83/9160af89dda171e0a29644f9a05e6a83.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
今天我们就正式开启了C++的学习之旅,作为第一讲,我想先带你把地基打牢。我们来学习一下内存管理的基本概念,大致的学习路径是:先讲堆和栈,然后讨论 C++ 的特色功能 RAII。掌握这些概念,是能够熟练运用 C++ 的基础。
|
||||
|
||||
## 基本概念
|
||||
|
||||
**堆**,英文是 heap,在内存管理的语境下,指的是动态分配内存的区域。这个堆跟数据结构里的堆不是一回事。这里的内存,被分配之后需要手工释放,否则,就会造成内存泄漏。
|
||||
|
||||
C++ 标准里一个相关概念是自由存储区,英文是 free store,特指使用 `new` 和 `delete` 来分配和释放内存的区域。一般而言,这是堆的一个子集:
|
||||
|
||||
- `new` 和 `delete` 操作的区域是 free store
|
||||
- `malloc` 和 `free` 操作的区域是 heap
|
||||
|
||||
但 `new` 和 `delete` 通常底层使用 `malloc` 和 `free` 来实现,所以 free store 也是 heap。鉴于对其区分的实际意义并不大,在本专栏里,除非另有特殊说明,我会只使用堆这一术语。
|
||||
|
||||
**栈**,英文是 stack,在内存管理的语境下,指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈和数据结构里的栈高度相似,都满足“后进先出”(last-in-first-out 或 LIFO)。
|
||||
|
||||
**RAII**,完整的英文是 Resource Acquisition Is Initialization,是 C++ 所特有的资源管理方式。有少量其他语言,如 D、Ada 和 Rust 也采纳了 RAII,但主流的编程语言中, C++ 是唯一一个依赖 RAII 来做资源管理的。
|
||||
|
||||
RAII 依托栈和析构函数,来对所有的资源——包括堆内存在内——进行管理。对 RAII 的使用,使得 C++ 不需要类似于 Java 那样的垃圾收集方法,也能有效地对内存进行管理。RAII 的存在,也是垃圾收集虽然理论上可以在 C++ 使用,但从来没有真正流行过的主要原因。
|
||||
|
||||
接下来,我将会对堆、栈和 RAII 进行深入的探讨。
|
||||
|
||||
## 堆
|
||||
|
||||
从现代编程的角度来看,使用堆,或者说使用动态内存分配,是一件再自然不过的事情了。下面这样的代码,都会导致在堆上分配内存(并构造对象)。
|
||||
|
||||
```
|
||||
// C++
|
||||
auto ptr = new std::vector<int>();
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
// Java
|
||||
ArrayList<int> list = new ArrayList<int>();
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
# Python
|
||||
lst = list()
|
||||
|
||||
```
|
||||
|
||||
从历史的角度,动态内存分配实际上是较晚出现的。由于动态内存带来的不确定性——内存分配耗时需要多久?失败了怎么办?等等——至今仍有很多场合会禁用动态内存,尤其在实时性要求比较高的场合,如飞行控制器和电信设备。不过,由于大家多半对这种用法比较熟悉,特别是从 C 和 C++ 以外的其他语言开始学习编程的程序员,所以提到内存管理,我们还是先讨论一下使用堆的编程方式。
|
||||
|
||||
在堆上分配内存,有些语言可能使用 `new` 这样的关键字,有些语言则是在对象的构造时隐式分配,不需要特殊关键字。不管哪种情况,程序通常需要牵涉到三个可能的内存管理器的操作:
|
||||
|
||||
1. 让内存管理器分配一个某个大小的内存块
|
||||
1. 让内存管理器释放一个之前分配的内存块
|
||||
1. 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放
|
||||
|
||||
C++ 通常会做上面的操作 1 和 2。Java 会做上面的操作 1 和 3。而 Python 会做上面的操作 1、2、3。这是语言的特性和实现方式决定的。
|
||||
|
||||
**需要略加说明的是,上面的三个操作都不简单,并且彼此之间是相关的。**
|
||||
|
||||
第一,分配内存要考虑程序当前已经有多少未分配的内存。内存不足时要从操作系统申请新的内存。内存充足时,要从可用的内存里取出一块合适大小的内存,做簿记工作将其标记为已用,然后将其返回给要求内存的代码。
|
||||
|
||||
需要注意到,绝大部分情况下,可用内存都会比要求分配的内存大,所以代码只被允许使用其被分配的内存区域,而剩余的内存区域仍属于未分配状态,可以在后面的分配过程中使用。另外,如果内存管理器支持垃圾收集的话,分配内存的操作还可能会触发垃圾收集。
|
||||
|
||||
第二,释放内存不只是简单地把内存标记为未使用。对于连续未使用的内存块,通常内存管理器需要将其合并成一块,以便可以满足后续的较大内存分配要求。毕竟,目前的编程模式都要求申请的内存块是连续的。
|
||||
|
||||
第三,垃圾收集操作有很多不同的策略和实现方式,以实现性能、实时性、额外开销等各方面的平衡。由于 C++ 里通常都不使用垃圾收集,所以就不是我们专栏的重点,不再展开讲解。
|
||||
|
||||
下面这张图展示了一个简单的分配过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/5a/1814fb6093744c64ac9d3861fb4d3a5a.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/3b/a7b72d6062c5cd798a2de991bffd713b.png" alt="">
|
||||
|
||||
注意在图 1e 的状态下,内存管理器是满足不了长度大于 4 的内存分配要求的;而在图 1f 的状态,则长度小于等于 7 的单个内存要求都可以得到满足。
|
||||
|
||||
当然,这只是一个简单的示意,只是为了让你能够对这个过程有一个大概的感性认识。在不考虑垃圾收集的情况下,内存需要手工释放;在此过程中,内存可能有碎片化的情况。比如,在图 1d 的情况下,虽然总共剩余内存为 6,但却满足不了长度大于 4 的内存分配要求。
|
||||
|
||||
幸运的是,大部分软件开发人员都不需要担心这个问题。内存分配和释放的管理,是内存管理器的任务,一般情况下我们不需要介入。我们只需要正确地使用 `new` 和 `delete`。每个 `new` 出来的对象都应该用 `delete` 来释放,就是这么简单。
|
||||
|
||||
但真的很简单、可以高枕无忧了吗?
|
||||
|
||||
事实说明,漏掉 `delete` 是一种常见的情况,这叫“内存泄漏”——相信你一定听到过这个说法。为什么呢?
|
||||
|
||||
我们还是看一些代码例子。
|
||||
|
||||
```
|
||||
void foo()
|
||||
{
|
||||
bar* ptr = new bar();
|
||||
…
|
||||
delete ptr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个很简单吧,但是却存在两个问题:
|
||||
|
||||
1. 中间省略的代码部分也许会抛出异常,导致最后的 `delete ptr` 得不到执行。
|
||||
1. 更重要的,这个代码不符合 C++ 的惯用法。在 C++ 里,这种情况下有 99% 的可能性不应该使用堆内存分配,而应使用栈内存分配。这样写代码的,估计可能是从 Java 转过来的🤭——但我真见过这样的代码。
|
||||
|
||||
而更常见、也更合理的情况,是分配和释放不在一个函数里。比如下面这段示例代码:
|
||||
|
||||
```
|
||||
bar* make_bar(…)
|
||||
{
|
||||
bar* ptr = nullptr;
|
||||
try {
|
||||
ptr = new bar();
|
||||
…
|
||||
}
|
||||
catch (...) {
|
||||
delete ptr;
|
||||
throw;
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
|
||||
void foo()
|
||||
{
|
||||
…
|
||||
bar* ptr = make_bar(…)
|
||||
…
|
||||
delete ptr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样的话,会漏 `delete` 的可能性是不是大多了?有关这个问题的解决方法,我们在下一讲还会提到。
|
||||
|
||||
好,堆我们暂时就讨论到这儿。下面,我们看看更符合 C++ 特性的栈内存分配。
|
||||
|
||||
## 栈
|
||||
|
||||
我们先来看一段示例代码,来说明 C++ 里函数调用、本地变量是如何使用栈的。当然,这一过程取决于计算机的实际架构,具体细节可能有所不同,但原理上都是相通的,都会使用一个后进先出的结构。
|
||||
|
||||
```
|
||||
void foo(int n)
|
||||
{
|
||||
…
|
||||
}
|
||||
|
||||
void bar(int n)
|
||||
{
|
||||
int a = n + 1;
|
||||
foo(a);
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
…
|
||||
bar(42);
|
||||
…
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码执行过程中的栈变化,我画了下面这张图来表示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/3b/6ef3d653af7fa6c9728ea4bea348093b.png" alt="">
|
||||
|
||||
在我们的示例中,栈是向上增长的。在包括 x86 在内的大部分计算机体系架构中,栈的增长方向是低地址,因而上方意味着低地址。任何一个函数,根据架构的约定,只能使用进入函数时栈指针向上部分的栈空间。当函数调用另外一个函数时,会把参数也压入栈里(我们此处忽略使用寄存器传递参数的情况),然后把下一行汇编指令的地址压入栈,并跳转到新的函数。新的函数进入后,首先做一些必须的保存工作,然后会调整栈指针,**分配出本地变量所需的空间**,随后执行函数中的代码,并在执行完毕之后,根据调用者压入栈的地址,返回到调用者未执行的代码中继续执行。
|
||||
|
||||
注意到了没有,本地变量所需的内存就在栈上,跟函数执行所需的其他数据在一起。当函数执行完成之后,这些内存也就自然而然释放掉了。我们可以看到:
|
||||
|
||||
- 栈上的分配极为简单,移动一下栈指针而已。
|
||||
- 栈上的释放也极为简单,函数执行结束时移动一下栈指针即可。
|
||||
- 由于后进先出的执行过程,不可能出现内存碎片。
|
||||
|
||||
顺便说一句,图 2 中每种颜色都表示某个函数占用的栈空间。这部分空间有个特定的术语,叫做栈帧(stack frame)。GCC 和 Clang 的命令行参数中提到 frame 的,如 `-fomit-frame-pointer`,一般就是指栈帧。
|
||||
|
||||
前面例子的本地变量是简单类型,C++ 里称之为 POD 类型(Plain Old Data)。对于有构造和析构函数的非 POD 类型,栈上的内存分配也同样有效,只不过 C++ 编译器会在生成代码的合适位置,插入对构造和析构函数的调用。
|
||||
|
||||
这里尤其重要的是:编译器会自动调用析构函数,包括在函数执行发生异常的情况。在发生异常时对析构函数的调用,还有一个专门的术语,叫栈展开(stack unwinding)。事实上,如果你用 MSVC 编译含异常的 C++ 代码,但没有使用上一讲说过的 `/EHsc` 参数,编译器就会报告:
|
||||
|
||||
>
|
||||
warning C4530: C++ exception handler used, but unwind semantics are not enabled. Specify /EHsc
|
||||
|
||||
|
||||
下面是一段简短的代码,可以演示栈展开:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
|
||||
class Obj {
|
||||
public:
|
||||
Obj() { puts("Obj()"); }
|
||||
~Obj() { puts("~Obj()"); }
|
||||
};
|
||||
|
||||
void foo(int n)
|
||||
{
|
||||
Obj obj;
|
||||
if (n == 42)
|
||||
throw "life, the universe and everything";
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
try {
|
||||
foo(41);
|
||||
foo(42);
|
||||
}
|
||||
catch (const char* s) {
|
||||
puts(s);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行代码的结果是:
|
||||
|
||||
>
|
||||
<p>`Obj()`<br>
|
||||
`~Obj()`<br>
|
||||
`Obj()`<br>
|
||||
`~Obj()`<br>
|
||||
`life, the universe and everything`</p>
|
||||
|
||||
|
||||
也就是说,不管是否发生了异常,`obj` 的析构函数都会得到执行。
|
||||
|
||||
在 C++ 里,所有的变量缺省都是值语义——如果不使用 `*` 和 `&` 的话,变量不会像 Java 或 Python 一样引用一个堆上的对象。对于像智能指针这样的类型,你写 `ptr->call()` 和 `ptr.get()`,语法上都是对的,并且 `->` 和 `.` 有着不同的语法作用。而在大部分其他语言里,访问成员只用 `.`,但在作用上实际等价于 C++ 的 `->`。这种值语义和引用语义的区别,是 C++ 的特点,也是它的复杂性的一个来源。要用好 C++,就需要理解它的值语义的特点。
|
||||
|
||||
对堆和栈有了基本了解之后,我们继续往下,聊一聊 C++ 的重要特性 RAII。
|
||||
|
||||
## RAII
|
||||
|
||||
C++ 支持将对象存储在栈上面。但是,在很多情况下,对象不能,或不应该,存储在栈上。比如:
|
||||
|
||||
- 对象很大;
|
||||
- 对象的大小在编译时不能确定;
|
||||
- 对象是函数的返回值,但由于特殊的原因,不应使用对象的值返回。
|
||||
|
||||
常见情况之一是,在工厂方法或其他面向对象编程的情况下,返回值类型是基类(的指针或引用)。下面的例子,是对工厂方法的简单演示:
|
||||
|
||||
```
|
||||
enum class shape_type {
|
||||
circle,
|
||||
triangle,
|
||||
rectangle,
|
||||
…
|
||||
};
|
||||
|
||||
class shape { … };
|
||||
class circle : public shape { … };
|
||||
class triangle : public shape { … };
|
||||
class rectangle : public shape { … };
|
||||
|
||||
shape* create_shape(shape_type type)
|
||||
{
|
||||
…
|
||||
switch (type) {
|
||||
case shape_type::circle:
|
||||
return new circle(…);
|
||||
case shape_type::triangle:
|
||||
return new triangle(…);
|
||||
case shape_type::rectangle:
|
||||
return new rectangle(…);
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个 `create_shape` 方法会返回一个 `shape` 对象,对象的实际类型是某个 `shape` 的子类,圆啊,三角形啊,矩形啊,等等。这种情况下,函数的返回值只能是指针或其变体形式。如果返回类型是 `shape`,实际却返回一个 `circle`,编译器不会报错,但结果多半是错的。这种现象叫对象切片(object slicing),是 C++ 特有的一种编码错误。这种错误不是语法错误,而是一个对象复制相关的语义错误,也算是 C++ 的一个陷阱了,大家需要小心这个问题。
|
||||
|
||||
那么,我们怎样才能确保,在使用 `create_shape` 的返回值时不会发生内存泄漏呢?
|
||||
|
||||
答案就在析构函数和它的栈展开行为上。我们只需要把这个返回值放到一个本地变量里,并确保其析构函数会删除该对象即可。一个简单的实现如下所示:
|
||||
|
||||
```
|
||||
class shape_wrapper {
|
||||
public:
|
||||
explicit shape_wrapper(
|
||||
shape* ptr = nullptr)
|
||||
: ptr_(ptr) {}
|
||||
~shape_wrapper()
|
||||
{
|
||||
delete ptr_;
|
||||
}
|
||||
shape* get() const { return ptr_; }
|
||||
private:
|
||||
shape* ptr_;
|
||||
};
|
||||
|
||||
void foo()
|
||||
{
|
||||
…
|
||||
shape_wrapper ptr_wrapper(
|
||||
create_shape(…));
|
||||
…
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你好奇 `delete` 空指针会发生什么的话,那答案是,这是一个合法的空操作。在 `new` 一个对象和 `delete` 一个指针时编译器需要干不少活的,它们大致可以如下翻译:
|
||||
|
||||
```
|
||||
// new circle(…)
|
||||
{
|
||||
void* temp = operator new(sizeof(circle));
|
||||
try {
|
||||
circle* ptr =
|
||||
static_cast<circle*>(temp);
|
||||
ptr->circle(…);
|
||||
return ptr;
|
||||
}
|
||||
catch (...) {
|
||||
operator delete(ptr);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
if (ptr != nullptr) {
|
||||
ptr->~shape();
|
||||
operator delete(ptr);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
也就是说,`new` 的时候先分配内存(失败时整个操作失败并向外抛出异常,通常是 `bad_alloc`),然后在这个结果指针上构造对象(注意上面示意中的调用构造函数并不是合法的 C++ 代码);构造成功则 `new` 操作整体完成,否则释放刚分配的内存并继续向外抛构造函数产生的异常。`delete` 时则判断指针是否为空,在指针不为空时调用析构函数并释放之前分配的内存。
|
||||
|
||||
回到 `shape_wrapper` 和它的析构行为。在析构函数里做必要的清理工作,这就是 RAII 的基本用法。这种清理并不限于释放内存,也可以是:
|
||||
|
||||
- 关闭文件(`fstream` 的析构就会这么做)
|
||||
- 释放同步锁
|
||||
- 释放其他重要的系统资源
|
||||
|
||||
例如,我们应该使用:
|
||||
|
||||
```
|
||||
std::mutex mtx;
|
||||
|
||||
void some_func()
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mtx);
|
||||
// 做需要同步的工作
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而不是:
|
||||
|
||||
```
|
||||
std::mutex mtx;
|
||||
|
||||
void some_func()
|
||||
{
|
||||
mtx.lock();
|
||||
// 做需要同步的工作……
|
||||
// 如果发生异常或提前返回,
|
||||
// 下面这句不会自动执行。
|
||||
mtx.unlock();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
顺便说一句,上面的 `shape_wrapper` 差不多就是个最简单的智能指针了。至于完整的智能指针,我们留到下一讲继续学习。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本讲我们讨论了 C++ 里内存管理的一些基本概念,强调栈是 C++ 里最“自然”的内存使用方式,并且,使用基于栈和析构函数的 RAII,可以有效地对包括堆内存在内的系统资源进行统一管理。
|
||||
|
||||
## 课后思考
|
||||
|
||||
最后留给你一道思考题。`shape_wrapper` 和智能指针比起来,还缺了哪些功能?欢迎留言和我分享你的观点。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Wikipedia, “Memory management”. [https://en.wikipedia.org/wiki/Memory_management](https://en.wikipedia.org/wiki/Memory_management)
|
||||
|
||||
[2] Wikipedia, “Stack-based memory allocation”. [https://en.wikipedia.org/wiki/Stack-based_memory_allocation](https://en.wikipedia.org/wiki/Stack-based_memory_allocation)
|
||||
|
||||
[3] Wikipedia, “Resource acquisition is initialization”. [https://en.wikipedia.org/wiki/RAII](https://en.wikipedia.org/wiki/RAII)
|
||||
|
||||
[3a] 维基百科, “RAII”. [https://zh.wikipedia.org/zh-cn/RAII](https://zh.wikipedia.org/zh-cn/RAII)
|
||||
|
||||
[4] Wikipedia, “Call stack”. [https://en.wikipedia.org/wiki/Call_stack](https://en.wikipedia.org/wiki/Call_stack)
|
||||
|
||||
[5] Wikipedia, “Object slicing”. [https://en.wikipedia.org/wiki/Object_slicing](https://en.wikipedia.org/wiki/Object_slicing)
|
||||
|
||||
[6] Stack Overflow, “Why does the stack address grow towards decreasing memory addresses?” [https://stackoverflow.com/questions/4560720/why-does-the-stack-address-grow-towards-decreasing-memory-addresses](https://stackoverflow.com/questions/4560720/why-does-the-stack-address-grow-towards-decreasing-memory-addresses)
|
||||
|
||||
注意:有些条目虽然有中文版,但内容太少;此处单独标出中文版条目的,则是内容比较全面、能够补充本专栏内容的情况。
|
||||
685
极客时间专栏/现代C++实战30讲/基础篇/02 | 自己动手,实现C++的智能指针.md
Normal file
685
极客时间专栏/现代C++实战30讲/基础篇/02 | 自己动手,实现C++的智能指针.md
Normal file
@@ -0,0 +1,685 @@
|
||||
<audio id="audio" title="02 | 自己动手,实现C++的智能指针" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/9a/b0ad5b326e15129171b567a6f5763d9a.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
上一讲,我们描述了一个某种程度上可以当成智能指针用的类 `shape_wrapper`。使用那个智能指针,可以简化资源的管理,从根本上消除资源(包括内存)泄漏的可能性。这一讲我们就来进一步讲解,如何将 `shape_wrapper` 改造成一个完整的智能指针。你会看到,智能指针本质上并不神秘,其实就是 RAII 资源管理功能的自然展现而已。
|
||||
|
||||
在学完这一讲之后,你应该会对 C++ 的 `unique_ptr` 和 `shared_ptr` 的功能非常熟悉了。同时,如果你今后要创建类似的资源管理类,也不会是一件难事。
|
||||
|
||||
## 回顾
|
||||
|
||||
我们上一讲给出了下面这个类:
|
||||
|
||||
```
|
||||
class shape_wrapper {
|
||||
public:
|
||||
explicit shape_wrapper(
|
||||
shape* ptr = nullptr)
|
||||
: ptr_(ptr) {}
|
||||
~shape_wrapper()
|
||||
{
|
||||
delete ptr_;
|
||||
}
|
||||
shape* get() const { return ptr_; }
|
||||
|
||||
private:
|
||||
shape* ptr_;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这个类可以完成智能指针的最基本的功能:对超出作用域的对象进行释放。**但它缺了点东西:**
|
||||
|
||||
1. 这个类只适用于 `shape` 类
|
||||
1. 该类对象的行为不够像指针
|
||||
1. 拷贝该类对象会引发程序行为异常
|
||||
|
||||
下面我们来逐一看一下怎么弥补这些问题。
|
||||
|
||||
## 模板化和易用性
|
||||
|
||||
要让这个类能够包装任意类型的指针,我们需要把它变成一个类模板。这实际上相当容易:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
class smart_ptr {
|
||||
public:
|
||||
explicit smart_ptr(T* ptr = nullptr)
|
||||
: ptr_(ptr) {}
|
||||
~smart_ptr()
|
||||
{
|
||||
delete ptr_;
|
||||
}
|
||||
T* get() const { return ptr_; }
|
||||
private:
|
||||
T* ptr_;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
和 `shape_wrapper` 比较一下,我们就是在开头增加模板声明 `template <typename T>`,然后把代码中的 `shape` 替换成模板参数 `T` 而已。这些修改非常简单自然吧?模板本质上并不是一个很复杂的概念。这个模板使用也很简单,把原来的 `shape_wrapper` 改成 `smart_ptr<shape>` 就行。
|
||||
|
||||
目前这个 `smart_ptr` 的行为还是和指针有点差异的:
|
||||
|
||||
- 它不能用 `*` 运算符解引用
|
||||
- 它不能用 `->` 运算符指向对象成员
|
||||
- 它不能像指针一样用在布尔表达式里
|
||||
|
||||
不过,这些问题也相当容易解决,加几个成员函数就可以:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
class smart_ptr {
|
||||
public:
|
||||
…
|
||||
T& operator*() const { return *ptr_; }
|
||||
T* operator->() const { return ptr_; }
|
||||
operator bool() const { return ptr_; }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 拷贝构造和赋值
|
||||
|
||||
拷贝构造和赋值,我们暂且简称为拷贝,这是个比较复杂的问题了。关键还不是实现问题,而是我们该如何定义其行为。假设有下面的代码:
|
||||
|
||||
```
|
||||
smart_ptr<shape> ptr1{create_shape(shape_type::circle)};
|
||||
smart_ptr<shape> ptr2{ptr1};
|
||||
|
||||
```
|
||||
|
||||
对于第二行,究竟应当让编译时发生错误,还是可以有一个更合理的行为?我们来逐一检查一下各种可能性。
|
||||
|
||||
最简单的情况显然是禁止拷贝。我们可以使用下面的代码:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
class smart_ptr {
|
||||
…
|
||||
smart_ptr(const smart_ptr&)
|
||||
= delete;
|
||||
smart_ptr& operator=(const smart_ptr&)
|
||||
= delete;
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
禁用这两个函数非常简单,但却解决了一种可能出错的情况。否则,`smart_ptr<shape> ptr2{ptr1};` 在编译时不会出错,但在运行时却会有未定义行为——由于会对同一内存释放两次,通常情况下会导致程序崩溃。
|
||||
|
||||
我们是不是可以考虑在拷贝智能指针时把对象拷贝一份?不行,通常人们不会这么用,因为使用智能指针的目的就是要减少对象的拷贝啊。何况,虽然我们的指针类型是 `shape`,但实际指向的却应该是 `circle` 或 `triangle` 之类的对象。在 C++ 里没有像 Java 的 `clone` 方法这样的约定;一般而言,并没有通用的方法可以通过基类的指针来构造出一个子类的对象来。
|
||||
|
||||
我们要么试试在拷贝时转移指针的所有权?大致实现如下:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
class smart_ptr {
|
||||
…
|
||||
smart_ptr(smart_ptr& other)
|
||||
{
|
||||
ptr_ = other.release();
|
||||
}
|
||||
smart_ptr& operator=(smart_ptr& rhs)
|
||||
{
|
||||
smart_ptr(rhs).swap(*this);
|
||||
return *this;
|
||||
}
|
||||
…
|
||||
T* release()
|
||||
{
|
||||
T* ptr = ptr_;
|
||||
ptr_ = nullptr;
|
||||
return ptr;
|
||||
}
|
||||
void swap(smart_ptr& rhs)
|
||||
{
|
||||
using std::swap;
|
||||
swap(ptr_, rhs.ptr_);
|
||||
}
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在拷贝构造函数中,通过调用 `other` 的 `release` 方法来释放它对指针的所有权。在赋值函数中,则通过拷贝构造产生一个临时对象并调用 `swap` 来交换对指针的所有权。实现上是不复杂的。
|
||||
|
||||
如果你学到的赋值函数还有一个类似于 `if (this != &rhs)` 的判断的话,那种用法更啰嗦,而且异常安全性不够好——如果在赋值过程中发生异常的话,this 对象的内容可能已经被部分破坏了,对象不再处于一个完整的状态。
|
||||
|
||||
**上面代码里的这种惯用法(见参考资料 [1])则保证了强异常安全性:**赋值分为拷贝构造和交换两步,异常只可能在第一步发生;而第一步如果发生异常的话,this 对象完全不受任何影响。无论拷贝构造成功与否,结果只有赋值成功和赋值没有效果两种状态,而不会发生因为赋值破坏了当前对象这种场景。
|
||||
|
||||
如果你觉得这个实现还不错的话,那恭喜你,你达到了 C++ 委员会在 1998 年时的水平:上面给出的语义本质上就是 C++98 的 `auto_ptr` 的定义。如果你觉得这个实现很别扭的话,也恭喜你,因为 C++ 委员会也是这么觉得的:`auto_ptr` 在 C++17 时已经被正式从 C++ 标准里删除了。
|
||||
|
||||
上面实现的最大问题是,它的行为会让程序员非常容易犯错。一不小心把它传递给另外一个 `smart_ptr`,你就不再拥有这个对象了……
|
||||
|
||||
## “移动”指针?
|
||||
|
||||
在下一讲我们将完整介绍一下移动语义。这一讲,我们先简单看一下 `smart_ptr` 可以如何使用“移动”来改善其行为。
|
||||
|
||||
我们需要对代码做两处小修改:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
class smart_ptr {
|
||||
…
|
||||
smart_ptr(smart_ptr&& other)
|
||||
{
|
||||
ptr_ = other.release();
|
||||
}
|
||||
smart_ptr& operator=(smart_ptr rhs)
|
||||
{
|
||||
rhs.swap(*this);
|
||||
return *this;
|
||||
}
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
看到修改的地方了吗?我改了两个地方:
|
||||
|
||||
- 把拷贝构造函数中的参数类型 `smart_ptr&` 改成了 `smart_ptr&&`;现在它成了移动构造函数。
|
||||
- 把赋值函数中的参数类型 `smart_ptr&` 改成了 `smart_ptr`,在构造参数时直接生成新的智能指针,从而不再需要在函数体中构造临时对象。现在赋值函数的行为是移动还是拷贝,完全依赖于构造参数时走的是移动构造还是拷贝构造。
|
||||
|
||||
根据 C++ 的规则,如果我提供了移动构造函数而没有手动提供拷贝构造函数,那后者自动被禁用(记住,C++ 里那些复杂的规则也是为方便编程而设立的)。于是,我们自然地得到了以下结果:
|
||||
|
||||
```
|
||||
smart_ptr<shape> ptr1{create_shape(shape_type::circle)};
|
||||
smart_ptr<shape> ptr2{ptr1}; // 编译出错
|
||||
smart_ptr<shape> ptr3;
|
||||
ptr3 = ptr1; // 编译出错
|
||||
ptr3 = std::move(ptr1); // OK,可以
|
||||
smart_ptr<shape> ptr4{std::move(ptr3)}; // OK,可以
|
||||
|
||||
```
|
||||
|
||||
这个就自然多了。
|
||||
|
||||
这也是 C++11 的 `unique_ptr` 的基本行为。
|
||||
|
||||
## 子类指针向基类指针的转换
|
||||
|
||||
哦,我撒了一个小谎。不知道你注意到没有,一个 `circle*` 是可以隐式转换成 `shape*` 的,但上面的 `smart_ptr<circle>` 却无法自动转换成 `smart_ptr<shape>`。这个行为显然还是不够“自然”。
|
||||
|
||||
不过,只需要额外加一点模板代码,就能实现这一行为。在我们目前给出的实现里,只需要增加一个构造函数即可——这也算是我们让赋值函数利用构造函数的好处了。
|
||||
|
||||
```
|
||||
template <typename U>
|
||||
smart_ptr(smart_ptr<U>&& other)
|
||||
{
|
||||
ptr_ = other.release();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,我们自然而然利用了指针的转换特性:现在 `smart_ptr<circle>` 可以移动给 `smart_ptr<shape>`,但不能移动给 `smart_ptr<triangle>`。不正确的转换会在代码编译时直接报错。
|
||||
|
||||
需要注意,上面这个构造函数不被编译器看作移动构造函数,因而不能自动触发删除拷贝构造函数的行为。如果我们想消除代码重复、删除移动构造函数的话,就需要把拷贝构造函数标记成 `= delete` 了(见“拷贝构造和赋值”一节)。不过,更通用的方式仍然是同时定义标准的拷贝/移动构造函数和所需的模板构造函数。下面的引用计数智能指针里我们就需要这么做。
|
||||
|
||||
至于非隐式的转换,因为本来就是要写特殊的转换函数的,我们留到这一讲的最后再讨论。
|
||||
|
||||
## 引用计数
|
||||
|
||||
`unique_ptr` 算是一种较为安全的智能指针了。但是,一个对象只能被单个 `unique_ptr` 所拥有,这显然不能满足所有使用场合的需求。一种常见的情况是,多个智能指针同时拥有一个对象;当它们全部都失效时,这个对象也同时会被删除。这也就是 `shared_ptr` 了。
|
||||
|
||||
`unique_ptr` 和 `shared_ptr` 的主要区别如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/c8/072fc41e503d22c3ab2bf6a3801903c8.png" alt="">
|
||||
|
||||
多个不同的 `shared_ptr` 不仅可以共享一个对象,在共享同一对象时也需要同时共享同一个计数。当最后一个指向对象(和共享计数)的 `shared_ptr` 析构时,它需要删除对象和共享计数。我们下面就来实现一下。
|
||||
|
||||
我们先来写出共享计数的接口:
|
||||
|
||||
```
|
||||
class shared_count {
|
||||
public:
|
||||
shared_count();
|
||||
void add_count();
|
||||
long reduce_count();
|
||||
long get_count() const;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这个 `shared_count` 类除构造函数之外有三个方法:一个增加计数,一个减少计数,一个获取计数。注意上面的接口增加计数不需要返回计数值;但减少计数时需要返回计数值,以供调用者判断是否它已经是最后一个指向共享计数的 `shared_ptr` 了。由于真正多线程安全的版本需要用到我们目前还没学到的知识,我们目前先实现一个简单化的版本:
|
||||
|
||||
```
|
||||
class shared_count {
|
||||
public:
|
||||
shared_count() : count_(1) {}
|
||||
void add_count()
|
||||
{
|
||||
++count_;
|
||||
}
|
||||
long reduce_count()
|
||||
{
|
||||
return --count_;
|
||||
}
|
||||
long get_count() const
|
||||
{
|
||||
return count_;
|
||||
}
|
||||
|
||||
private:
|
||||
long count_;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
现在我们可以实现我们的引用计数智能指针了。首先是构造函数、析构函数和私有成员变量:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
class smart_ptr {
|
||||
public:
|
||||
explicit smart_ptr(T* ptr = nullptr)
|
||||
: ptr_(ptr)
|
||||
{
|
||||
if (ptr) {
|
||||
shared_count_ =
|
||||
new shared_count();
|
||||
}
|
||||
}
|
||||
~smart_ptr()
|
||||
{
|
||||
if (ptr_ &&
|
||||
!shared_count_
|
||||
->reduce_count()) {
|
||||
delete ptr_;
|
||||
delete shared_count_;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
T* ptr_;
|
||||
shared_count* shared_count_;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
构造函数跟之前的主要不同点是会构造一个 `shared_count` 出来。析构函数在看到 `ptr_` 非空时(此时根据代码逻辑,`shared_count` 也必然非空),需要对引用数减一,并在引用数降到零时彻底删除对象和共享计数。原理就是这样,不复杂。
|
||||
|
||||
当然,我们还有些细节要处理。为了方便实现赋值(及其他一些惯用法),我们需要一个新的 `swap` 成员函数:
|
||||
|
||||
```
|
||||
void swap(smart_ptr& rhs)
|
||||
{
|
||||
using std::swap;
|
||||
swap(ptr_, rhs.ptr_);
|
||||
swap(shared_count_,
|
||||
rhs.shared_count_);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
赋值函数可以跟前面一样,保持不变,但拷贝构造和移动构造函数是需要更新一下的:
|
||||
|
||||
```
|
||||
smart_ptr(const smart_ptr& other)
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
other.shared_count_
|
||||
->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(const smart_ptr<U>& other)
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
other.shared_count_
|
||||
->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(smart_ptr<U>&& other)
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
other.ptr_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除复制指针之外,对于拷贝构造的情况,我们需要在指针非空时把引用数加一,并复制共享计数的指针。对于移动构造的情况,我们不需要调整引用数,直接把 `other.ptr_` 置为空,认为 `other` 不再指向该共享对象即可。
|
||||
|
||||
不过,上面的代码有个问题:它不能正确编译。编译器会报错,像:
|
||||
|
||||
>
|
||||
fatal error: ‘ptr_’ is a private member of ‘smart_ptr<circle>’
|
||||
|
||||
|
||||
错误原因是模板的各个实例间并不天然就有 friend 关系,因而不能互访私有成员 `ptr_` 和 `shared_count_`。我们需要在 `smart_ptr` 的定义中显式声明:
|
||||
|
||||
```
|
||||
template <typename U>
|
||||
friend class smart_ptr;
|
||||
|
||||
```
|
||||
|
||||
此外,我们之前的实现(类似于单一所有权的 `unique_ptr` )中用 `release` 来手工释放所有权。在目前的引用计数实现中,它就不太合适了,应当删除。但我们要加一个对调试非常有用的函数,返回引用计数值。定义如下:
|
||||
|
||||
```
|
||||
long use_count() const
|
||||
{
|
||||
if (ptr_) {
|
||||
return shared_count_
|
||||
->get_count();
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这就差不多是一个比较完整的引用计数智能指针的实现了。我们可以用下面的代码来验证一下它的功能正常:
|
||||
|
||||
```
|
||||
class shape {
|
||||
public:
|
||||
virtual ~shape() {}
|
||||
};
|
||||
|
||||
class circle : public shape {
|
||||
public:
|
||||
~circle() { puts("~circle()"); }
|
||||
};
|
||||
|
||||
int main()
|
||||
{
|
||||
smart_ptr<circle> ptr1(new circle());
|
||||
printf("use count of ptr1 is %ld\n",
|
||||
ptr1.use_count());
|
||||
smart_ptr<shape> ptr2;
|
||||
printf("use count of ptr2 was %ld\n",
|
||||
ptr2.use_count());
|
||||
ptr2 = ptr1;
|
||||
printf("use count of ptr2 is now %ld\n",
|
||||
ptr2.use_count());
|
||||
if (ptr1) {
|
||||
puts("ptr1 is not empty");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的运行结果是:
|
||||
|
||||
>
|
||||
<p>`use count of ptr1 is 1`<br>
|
||||
`use count of ptr2 was 0`<br>
|
||||
`use count of ptr2 is now 2`<br>
|
||||
`ptr1 is not empty`<br>
|
||||
`~circle()`</p>
|
||||
|
||||
|
||||
上面我们可以看到引用计数的变化,以及最后对象被成功删除。
|
||||
|
||||
## 指针类型转换
|
||||
|
||||
对应于 C++ 里的不同的类型强制转换:
|
||||
|
||||
- static_cast
|
||||
- reinterpret_cast
|
||||
- const_cast
|
||||
- dynamic_cast
|
||||
|
||||
智能指针需要实现类似的函数模板。实现本身并不复杂,但为了实现这些转换,我们需要添加构造函数,允许在对智能指针内部的指针对象赋值时,使用一个现有的智能指针的共享计数。如下所示:
|
||||
|
||||
```
|
||||
template <typename U>
|
||||
smart_ptr(const smart_ptr<U>& other,
|
||||
T* ptr)
|
||||
{
|
||||
ptr_ = ptr;
|
||||
if (ptr_) {
|
||||
other.shared_count_
|
||||
->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样我们就可以实现转换所需的函数模板了。下面实现一个 `dynamic_pointer_cast` 来示例一下:
|
||||
|
||||
```
|
||||
template <typename T, typename U>
|
||||
smart_ptr<T> dynamic_pointer_cast(
|
||||
const smart_ptr<U>& other)
|
||||
{
|
||||
T* ptr =
|
||||
dynamic_cast<T*>(other.get());
|
||||
return smart_ptr<T>(other, ptr);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在前面的验证代码后面我们可以加上:
|
||||
|
||||
```
|
||||
smart_ptr<circle> ptr3 =
|
||||
dynamic_pointer_cast<circle>(ptr2);
|
||||
printf("use count of ptr3 is %ld\n",
|
||||
ptr3.use_count());
|
||||
|
||||
```
|
||||
|
||||
编译会正常通过,同时能在输出里看到下面的结果:
|
||||
|
||||
>
|
||||
use count of ptr3 is 3
|
||||
|
||||
|
||||
最后,对象仍然能够被正确删除。这说明我们的实现是正确的。
|
||||
|
||||
## 代码列表
|
||||
|
||||
为了方便你参考,下面我给出了一个完整的 `smart_ptr` 代码列表:
|
||||
|
||||
```
|
||||
#include <utility> // std::swap
|
||||
|
||||
class shared_count {
|
||||
public:
|
||||
shared_count() noexcept
|
||||
: count_(1) {}
|
||||
void add_count() noexcept
|
||||
{
|
||||
++count_;
|
||||
}
|
||||
long reduce_count() noexcept
|
||||
{
|
||||
return --count_;
|
||||
}
|
||||
long get_count() const noexcept
|
||||
{
|
||||
return count_;
|
||||
}
|
||||
|
||||
private:
|
||||
long count_;
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
class smart_ptr {
|
||||
public:
|
||||
template <typename U>
|
||||
friend class smart_ptr;
|
||||
|
||||
explicit smart_ptr(T* ptr = nullptr)
|
||||
: ptr_(ptr)
|
||||
{
|
||||
if (ptr) {
|
||||
shared_count_ =
|
||||
new shared_count();
|
||||
}
|
||||
}
|
||||
~smart_ptr()
|
||||
{
|
||||
if (ptr_ &&
|
||||
!shared_count_
|
||||
->reduce_count()) {
|
||||
delete ptr_;
|
||||
delete shared_count_;
|
||||
}
|
||||
}
|
||||
|
||||
smart_ptr(const smart_ptr& other)
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
other.shared_count_
|
||||
->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(const smart_ptr<U>& other) noexcept
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
other.shared_count_->add_count();
|
||||
shared_count_ = other.shared_count_;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(smart_ptr<U>&& other) noexcept
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
other.ptr_ = nullptr;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(const smart_ptr<U>& other,
|
||||
T* ptr) noexcept
|
||||
{
|
||||
ptr_ = ptr;
|
||||
if (ptr_) {
|
||||
other.shared_count_
|
||||
->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
smart_ptr&
|
||||
operator=(smart_ptr rhs) noexcept
|
||||
{
|
||||
rhs.swap(*this);
|
||||
return *this;
|
||||
}
|
||||
|
||||
T* get() const noexcept
|
||||
{
|
||||
return ptr_;
|
||||
}
|
||||
long use_count() const noexcept
|
||||
{
|
||||
if (ptr_) {
|
||||
return shared_count_
|
||||
->get_count();
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
void swap(smart_ptr& rhs) noexcept
|
||||
{
|
||||
using std::swap;
|
||||
swap(ptr_, rhs.ptr_);
|
||||
swap(shared_count_,
|
||||
rhs.shared_count_);
|
||||
}
|
||||
|
||||
T& operator*() const noexcept
|
||||
{
|
||||
return *ptr_;
|
||||
}
|
||||
T* operator->() const noexcept
|
||||
{
|
||||
return ptr_;
|
||||
}
|
||||
operator bool() const noexcept
|
||||
{
|
||||
return ptr_;
|
||||
}
|
||||
|
||||
private:
|
||||
T* ptr_;
|
||||
shared_count* shared_count_;
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
void swap(smart_ptr<T>& lhs,
|
||||
smart_ptr<T>& rhs) noexcept
|
||||
{
|
||||
lhs.swap(rhs);
|
||||
}
|
||||
|
||||
template <typename T, typename U>
|
||||
smart_ptr<T> static_pointer_cast(
|
||||
const smart_ptr<U>& other) noexcept
|
||||
{
|
||||
T* ptr = static_cast<T*>(other.get());
|
||||
return smart_ptr<T>(other, ptr);
|
||||
}
|
||||
|
||||
template <typename T, typename U>
|
||||
smart_ptr<T> reinterpret_pointer_cast(
|
||||
const smart_ptr<U>& other) noexcept
|
||||
{
|
||||
T* ptr = reinterpret_cast<T*>(other.get());
|
||||
return smart_ptr<T>(other, ptr);
|
||||
}
|
||||
|
||||
template <typename T, typename U>
|
||||
smart_ptr<T> const_pointer_cast(
|
||||
const smart_ptr<U>& other) noexcept
|
||||
{
|
||||
T* ptr = const_cast<T*>(other.get());
|
||||
return smart_ptr<T>(other, ptr);
|
||||
}
|
||||
|
||||
template <typename T, typename U>
|
||||
smart_ptr<T> dynamic_pointer_cast(
|
||||
const smart_ptr<U>& other) noexcept
|
||||
{
|
||||
T* ptr = dynamic_cast<T*>(other.get());
|
||||
return smart_ptr<T>(other, ptr);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你足够细心的话,你会发现我在代码里加了不少 `noexcept`。这对这个智能指针在它的目标场景能正确使用是十分必要的。我们会在下面的几讲里回到这个话题。
|
||||
|
||||
## 内容小结
|
||||
|
||||
这一讲我们从 `shape_wrapper` 出发,实现了一个基本完整的带引用计数的智能指针。这个智能指针跟标准的 `shared_ptr` 比,还缺了一些东西(见参考资料 [2]),但日常用到的智能指针功能已经包含在内。现在,你应当已经对智能指针有一个较为深入的理解了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
这里留几个问题,你可以思考一下:
|
||||
|
||||
1. 不查阅 `shared_ptr` 的文档,你觉得目前 `smart_ptr` 应当添加什么功能吗?
|
||||
1. 你想到的功能在标准的 `shared_ptr` 里吗?
|
||||
1. 你觉得智能指针应该满足什么样的线程安全性?
|
||||
|
||||
欢迎留言和我交流你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Stack Overflow, GManNickG’s answer to “What is the copy-and-swap idiom?”. [https://stackoverflow.com/a/3279550/816999](https://stackoverflow.com/a/3279550/816999)
|
||||
|
||||
[2] cppreference.com, “std::shared_ptr”. [https://en.cppreference.com/w/cpp/memory/shared_ptr](https://en.cppreference.com/w/cpp/memory/shared_ptr)
|
||||
564
极客时间专栏/现代C++实战30讲/基础篇/03 | 右值和移动究竟解决了什么问题?.md
Normal file
564
极客时间专栏/现代C++实战30讲/基础篇/03 | 右值和移动究竟解决了什么问题?.md
Normal file
@@ -0,0 +1,564 @@
|
||||
<audio id="audio" title="03 | 右值和移动究竟解决了什么问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/78/82/7833d1a19392ab5cdb1cdfd3bd7b3f82.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
从上一讲智能指针开始,我们已经或多或少接触了移动语义。本讲我们就完整地讨论一下移动语义和相关的概念。移动语义是 C++11 里引入的一个重要概念;理解这个概念,是理解很多现代 C++ 里的优化的基础。
|
||||
|
||||
## 值分左右
|
||||
|
||||
我们常常会说,C++ 里有左值和右值。这话不完全对。标准里的定义实际更复杂,规定了下面这些值类别(value categories):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/3c/18b692072537d4ce179d3857a8a0133c.png" alt="">
|
||||
|
||||
我们先理解一下这些名词的字面含义:
|
||||
|
||||
- 一个 lvalue 是通常可以放在等号左边的表达式,左值
|
||||
- 一个 rvalue 是通常只能放在等号右边的表达式,右值
|
||||
- 一个 glvalue 是 generalized lvalue,广义左值
|
||||
- 一个 xvalue 是 expiring lvalue,将亡值
|
||||
- 一个 prvalue 是 pure rvalue,纯右值
|
||||
|
||||
还是有点晕,是吧?我们暂且抛开这些概念,只看其中两个:lvalue 和 prvalue。
|
||||
|
||||
左值 lvalue 是有标识符、可以取地址的表达式,最常见的情况有:
|
||||
|
||||
- 变量、函数或数据成员的名字
|
||||
- 返回左值引用的表达式,如 `++x`、`x = 1`、`cout << ' '`
|
||||
- 字符串字面量如 `"hello world"`
|
||||
|
||||
在函数调用时,左值可以绑定到左值引用的参数,如 `T&`。一个常量只能绑定到常左值引用,如 `const T&`。
|
||||
|
||||
反之,纯右值 prvalue 是没有标识符、不可以取地址的表达式,一般也称之为“临时对象”。最常见的情况有:
|
||||
|
||||
- 返回非引用类型的表达式,如 `x++`、`x + 1`、`make_shared<int>(42)`
|
||||
- 除字符串字面量之外的字面量,如 `42`、`true`
|
||||
|
||||
在 C++11 之前,右值可以绑定到常左值引用(const lvalue reference)的参数,如 `const T&`,但不可以绑定到非常左值引用(non-const lvalue reference),如 `T&`。从 C++11 开始,C++ 语言里多了一种引用类型——右值引用。右值引用的形式是 `T&&`,比左值引用多一个 `&` 符号。跟左值引用一样,我们可以使用 `const` 和 `volatile` 来进行修饰,但最常见的情况是,我们不会用 `const` 和 `volatile` 来修饰右值。本专栏就属于这种情况。
|
||||
|
||||
引入一种额外的引用类型当然增加了语言的复杂性,但也带来了很多优化的可能性。由于 C++ 有重载,我们就可以根据不同的引用类型,来选择不同的重载函数,来完成不同的行为。回想一下,在上一讲中,我们就利用了重载,让 `smart_ptr` 的构造函数可以有不同的行为:
|
||||
|
||||
```
|
||||
template <typename U>
|
||||
smart_ptr(const smart_ptr<U>& other) noexcept
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
other.shared_count_->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(smart_ptr<U>&& other) noexcept
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
other.ptr_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可能会好奇,使用右值引用的第二个重载函数中的变量 `other` 算是左值还是右值呢?根据定义,`other` 是个变量的名字,变量有标识符、有地址,所以它还是一个左值——虽然它的类型是右值引用。
|
||||
|
||||
尤其重要的是,拿这个 `other` 去调用函数时,它匹配的也会是左值引用。也就是说,**类型是右值引用的变量是一个左值!**这点可能有点反直觉,但跟 C++ 的其他方面是一致的。毕竟对于一个右值引用的变量,你是可以取地址的,这点上它和左值完全一致。稍后我们再回到这个话题上来。
|
||||
|
||||
再看一下下面的代码:
|
||||
|
||||
```
|
||||
smart_ptr<shape> ptr1{new circle()};
|
||||
smart_ptr<shape> ptr2 = std::move(ptr1);
|
||||
|
||||
```
|
||||
|
||||
第一个表达式里的 `new circle()` 就是一个纯右值;但对于指针,我们通常使用值传递,并不关心它是左值还是右值。
|
||||
|
||||
第二个表达式里的 `std::move(ptr)` 就有趣点了。它的作用是把一个左值引用强制转换成一个右值引用,而并不改变其内容。从实用的角度,在我们这儿 `std::move(ptr1)` 等价于 `static_cast<smart_ptr<shape>&&>(ptr1)`。因此,`std::move(ptr1)` 的结果是指向 `ptr1` 的一个右值引用,这样构造 `ptr2` 时就会选择上面第二个重载。
|
||||
|
||||
我们可以把 `std::move(ptr1)` 看作是一个有名字的右值。为了跟无名的纯右值 prvalue 相区别,C++ 里目前就把这种表达式叫做 xvalue。跟左值 lvalue 不同,xvalue 仍然是不能取地址的——这点上,xvalue 和 prvalue 相同。所以,xvalue 和 prvalue 都被归为右值 rvalue。我们用下面的图来表示会更清楚一点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/5a/036cc6865a9623a48918b504e408945a.png" alt="">
|
||||
|
||||
另外请注意,“值类别”(value category)和“值类型”(value type)是两个看似相似、却毫不相干的术语。前者指的是上面这些左值、右值相关的概念,后者则是与引用类型(reference type)相对而言,表明一个变量是代表实际数值,还是引用另外一个数值。在 C++ 里,所有的原生类型、枚举、结构、联合、类都代表值类型,只有引用(`&`)和指针(`*`)才是引用类型。在 Java 里,数字等原生类型是值类型,类则属于引用类型。在 Python 里,一切类型都是引用类型。
|
||||
|
||||
## 生命周期和表达式类型
|
||||
|
||||
一个变量的生命周期在超出作用域时结束。如果一个变量代表一个对象,当然这个对象的生命周期也在那时结束。那临时对象(prvalue)呢?在这儿,C++ 的规则是:一个临时对象会在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁,除非有生命周期延长发生。我们先看一个没有生命周期延长的基本情况:
|
||||
|
||||
```
|
||||
process_shape(circle(), triangle());
|
||||
|
||||
```
|
||||
|
||||
在这儿,我们生成了临时对象,一个圆和一个三角形,它们会在 `process_shape` 执行完成并生成结果对象后被销毁。
|
||||
|
||||
我们插入一些实际的代码,就可以演示这一行为:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
|
||||
class shape {
|
||||
public:
|
||||
virtual ~shape() {}
|
||||
};
|
||||
|
||||
class circle : public shape {
|
||||
public:
|
||||
circle() { puts("circle()"); }
|
||||
~circle() { puts("~circle()"); }
|
||||
};
|
||||
|
||||
class triangle : public shape {
|
||||
public:
|
||||
triangle() { puts("triangle()"); }
|
||||
~triangle() { puts("~triangle()"); }
|
||||
};
|
||||
|
||||
class result {
|
||||
public:
|
||||
result() { puts("result()"); }
|
||||
~result() { puts("~result()"); }
|
||||
};
|
||||
|
||||
result
|
||||
process_shape(const shape& shape1,
|
||||
const shape& shape2)
|
||||
{
|
||||
puts("process_shape()");
|
||||
return result();
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
puts("main()");
|
||||
process_shape(circle(), triangle());
|
||||
puts("something else");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出结果可能会是(`circle` 和 `triangle` 的顺序在标准中没有规定):
|
||||
|
||||
>
|
||||
<p>`main()`<br>
|
||||
`circle()`<br>
|
||||
`triangle()`<br>
|
||||
`process_shape()`<br>
|
||||
`result()`<br>
|
||||
`~result()`<br>
|
||||
`~triangle()`<br>
|
||||
`~circle()`<br>
|
||||
`something else`</p>
|
||||
|
||||
|
||||
目前我让 `process_shape` 也返回了一个结果,这是为了下一步演示的需要。你可以看到结果的临时对象最后生成、最先析构。
|
||||
|
||||
为了方便对临时对象的使用,C++ 对临时对象有特殊的生命周期延长规则。这条规则是:
|
||||
|
||||
>
|
||||
如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。
|
||||
|
||||
|
||||
我们对上面的代码只要改一行就能演示这个效果。把 `process_shape` 那行改成:
|
||||
|
||||
```
|
||||
result&& r = process_shape(
|
||||
circle(), triangle());
|
||||
|
||||
```
|
||||
|
||||
我们就能看到不同的结果了:
|
||||
|
||||
>
|
||||
<p>`main()`<br>
|
||||
`circle()`<br>
|
||||
`triangle()`<br>
|
||||
`process_shape()`<br>
|
||||
`result()`<br>
|
||||
`~triangle()`<br>
|
||||
`~circle()`<br>
|
||||
`something else`<br>
|
||||
`~result()`</p>
|
||||
|
||||
|
||||
现在 `result` 的生成还在原来的位置,但析构被延到了 `main` 的最后。
|
||||
|
||||
需要万分注意的是,这条生命期延长规则只对 prvalue 有效,而对 xvalue 无效。如果由于某种原因,prvalue 在绑定到引用以前已经变成了 xvalue,那生命期就不会延长。不注意这点的话,代码就可能会产生隐秘的 bug。比如,我们如果这样改一下代码,结果就不对了:
|
||||
|
||||
```
|
||||
#include <utility> // std::move
|
||||
…
|
||||
result&& r = std::move(process_shape(
|
||||
circle(), triangle()));
|
||||
|
||||
```
|
||||
|
||||
这时的代码输出就回到了前一种情况。虽然执行到 something else 那儿我们仍然有一个有效的变量 `r`,但它指向的对象已经不存在了,对 `r` 的解引用是一个未定义行为。由于 `r` 指向的是栈空间,通常不会立即导致程序崩溃,而会在某些复杂的组合条件下才会引致问题……
|
||||
|
||||
对 C++ 的这条生命期延长规则,在后面讲到视图(view)的时候会十分有用。那时我们会看到,有些 C++ 的用法实际上会隐式地利用这条规则。
|
||||
|
||||
此外,参考资料 [5] 中提到了一个有趣的事实:你可以把一个没有虚析构函数的子类对象绑定到基类的引用变量上,这个子类对象的析构仍然是完全正常的——这是因为这条规则只是延后了临时对象的析构而已,不是利用引用计数等复杂的方法,因而只要引用绑定成功,其类型并没有什么影响。
|
||||
|
||||
## 移动的意义
|
||||
|
||||
上面我们谈了一些语法知识。就跟学外语的语法一样,这些内容是比较枯燥的。虽然这些知识有时有用,但往往要回过头来看的时候才觉得。初学之时,更重要的是理解为什么,和熟练掌握基本的用法。
|
||||
|
||||
对于 `smart_ptr`,我们使用右值引用的目的是实现移动,而实现移动的意义是减少运行的开销——在引用计数指针的场景下,这个开销并不大。移动构造和拷贝构造的差异仅在于:
|
||||
|
||||
- 少了一次 `other.shared_count_->add_count()` 的调用
|
||||
- 被移动的指针被清空,因而析构时也少了一次 `shared_count_->reduce_count()` 的调用
|
||||
|
||||
在使用容器类的情况下,移动更有意义。我们可以尝试分析一下下面这个假想的语句(假设 `name` 是 `string` 类型):
|
||||
|
||||
```
|
||||
string result =
|
||||
string("Hello, ") + name + ".";
|
||||
|
||||
```
|
||||
|
||||
在 C++11 之前的年代里,这种写法是绝对不推荐的。因为它会引入很多额外开销,执行流程大致如下:
|
||||
|
||||
1. 调用构造函数 `string(const char*)`,生成临时对象 1;`"Hello, "` 复制 1 次。
|
||||
1. 调用 `operator+(const string&, const string&)`,生成临时对象 2;`"Hello, "` 复制 2 次,`name` 复制 1 次。
|
||||
1. 调用 `operator+(const string&, const char*)`,生成对象 3;`"Hello, "` 复制 3 次,`name` 复制 2 次,`"."` 复制 1 次。
|
||||
1. 假设返回值优化能够生效(最佳情况),对象 3 可以直接在 `result` 里构造完成。
|
||||
1. 临时对象 2 析构,释放指向 `string("Hello, ") + name` 的内存。
|
||||
1. 临时对象 1 析构,释放指向 `string("Hello, ")` 的内存。
|
||||
|
||||
既然 C++ 是一门追求性能的语言,一个合格的 C++ 程序员会写:
|
||||
|
||||
```
|
||||
string result = "Hello, ";
|
||||
result += name;
|
||||
result += ".";
|
||||
|
||||
```
|
||||
|
||||
这样的话,只会调用构造函数一次和 `string::operator+=` 两次,没有任何临时对象需要生成和析构,所有的字符串都只复制了一次。但显然代码就啰嗦多了——尤其如果拼接的步骤比较多的话。从 C++11 开始,这不再是必须的。同样上面那个单行的语句,执行流程大致如下:
|
||||
|
||||
1. 调用构造函数 `string(const char*)`,生成临时对象 1;`"Hello, "` 复制 1 次。
|
||||
1. 调用 `operator+(string&&, const string&)`,直接在临时对象 1 上面执行追加操作,并把结果移动到临时对象 2;`name` 复制 1 次。
|
||||
1. 调用 `operator+(string&&, const char*)`,直接在临时对象 2 上面执行追加操作,并把结果移动到 `result`;`"."` 复制 1 次。
|
||||
1. 临时对象 2 析构,内容已经为空,不需要释放任何内存。
|
||||
1. 临时对象 1 析构,内容已经为空,不需要释放任何内存。
|
||||
|
||||
性能上,所有的字符串只复制了一次;虽然比啰嗦的写法仍然要增加临时对象的构造和析构,但由于这些操作不牵涉到额外的内存分配和释放,是相当廉价的。程序员只需要牺牲一点点性能,就可以大大增加代码的可读性。而且,所谓的性能牺牲,也只是相对于优化得很好的 C 或 C++ 代码而言——这样的 C++ 代码的性能仍然完全可以超越 Python 类的语言的相应代码。
|
||||
|
||||
此外很关键的一点是,C++ 里的对象缺省都是值语义。在下面这样的代码里:
|
||||
|
||||
```
|
||||
class A {
|
||||
B b_;
|
||||
C c_;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
从实际内存布局的角度,很多语言——如 Java 和 Python——会在 `A` 对象里放 `B` 和 `C` 的指针(虽然这些语言里本身没有指针的概念)。而 C++ 则会直接把 `B` 和 `C` 对象放在 `A` 的内存空间里。这种行为既是优点也是缺点。说它是优点,是因为它保证了内存访问的局域性,而局域性在现代处理器架构上是绝对具有性能优势的。说它是缺点,是因为复制对象的开销大大增加:在 Java 类语言里复制的是指针,在 C++ 里是完整的对象。这就是为什么 C++ 需要移动语义这一优化,而 Java 类语言里则根本不需要这个概念。
|
||||
|
||||
一句话总结,移动语义使得在 C++ 里返回大对象(如容器)的函数和运算符成为现实,因而可以提高代码的简洁性和可读性,提高程序员的生产率。
|
||||
|
||||
所有的现代 C++ 的标准容器都针对移动进行了优化。
|
||||
|
||||
## 如何实现移动?
|
||||
|
||||
要让你设计的对象支持移动的话,通常需要下面几步:
|
||||
|
||||
- 你的对象应该有分开的拷贝构造和移动构造函数(除非你只打算支持移动,不支持拷贝——如 `unique_ptr`)。
|
||||
- 你的对象应该有 `swap` 成员函数,支持和另外一个对象快速交换成员。
|
||||
- 在你的对象的名空间下,应当有一个全局的 `swap` 函数,调用成员函数 `swap` 来实现交换。支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象,并快速实现它们的 `swap` 函数。
|
||||
- 实现通用的 `operator=`。
|
||||
- 上面各个函数如果不抛异常的话,应当标为 `noexcept`。这对移动构造函数尤为重要。
|
||||
|
||||
具体写法可以参考我们当前已经实现的 `smart_ptr`:
|
||||
|
||||
- `smart_ptr` 有拷贝构造和移动构造函数(虽然此处我们的模板构造函数严格来说不算拷贝或移动构造函数)。移动构造函数应当从另一个对象获取资源,清空其资源,并将其置为一个可析构的状态。
|
||||
|
||||
```
|
||||
smart_ptr(const smart_ptr& other) noexcept
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
other.shared_count_
|
||||
->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(const smart_ptr<U>& other) noexcept
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
other.shared_count_
|
||||
->add_count();
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
}
|
||||
}
|
||||
template <typename U>
|
||||
smart_ptr(smart_ptr<U>&& other) noexcept
|
||||
{
|
||||
ptr_ = other.ptr_;
|
||||
if (ptr_) {
|
||||
shared_count_ =
|
||||
other.shared_count_;
|
||||
other.ptr_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- `smart_ptr` 有 `swap` 成员函数。
|
||||
|
||||
```
|
||||
void swap(smart_ptr& rhs) noexcept
|
||||
{
|
||||
using std::swap;
|
||||
swap(ptr_, rhs.ptr_);
|
||||
swap(shared_count_,
|
||||
rhs.shared_count_);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 有支持 `smart_ptr` 的全局 `swap` 函数。
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
void swap(smart_ptr<T>& lhs,
|
||||
smart_ptr<T>& rhs) noexcept
|
||||
{
|
||||
lhs.swap(rhs);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- `smart_ptr` 有通用的 `operator=` 成员函数。注意为了避免让人吃惊,通常我们需要将其实现成对 `a = a;` 这样的写法安全。下面的写法算是个小技巧,对传递左值和右值都有效,而且规避了 `if (&rhs != this)` 这样的判断。
|
||||
|
||||
```
|
||||
smart_ptr&
|
||||
operator=(smart_ptr rhs) noexcept
|
||||
{
|
||||
rhs.swap(*this);
|
||||
return *this;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 不要返回本地变量的引用
|
||||
|
||||
有一种常见的 C++ 编程错误,是在函数里返回一个本地对象的引用。由于在函数结束时本地对象即被销毁,返回一个指向本地对象的引用属于未定义行为。理论上来说,程序出任何奇怪的行为都是正常的。
|
||||
|
||||
在 C++11 之前,返回一个本地对象意味着这个对象会被拷贝,除非编译器发现可以做返回值优化(named return value optimization,或 NRVO),能把对象直接构造到调用者的栈上。从 C++11 开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 `std::move` 进行干预——使用 `std::move` 对于移动行为没有帮助,反而会影响返回值优化。
|
||||
|
||||
下面是个例子:
|
||||
|
||||
```
|
||||
#include <iostream> // std::cout/endl
|
||||
#include <utility> // std::move
|
||||
|
||||
using namespace std;
|
||||
|
||||
class Obj {
|
||||
public:
|
||||
Obj()
|
||||
{
|
||||
cout << "Obj()" << endl;
|
||||
}
|
||||
Obj(const Obj&)
|
||||
{
|
||||
cout << "Obj(const Obj&)"
|
||||
<< endl;
|
||||
}
|
||||
Obj(Obj&&)
|
||||
{
|
||||
cout << "Obj(Obj&&)" << endl;
|
||||
}
|
||||
};
|
||||
|
||||
Obj simple()
|
||||
{
|
||||
Obj obj;
|
||||
// 简单返回对象;一般有 NRVO
|
||||
return obj;
|
||||
}
|
||||
|
||||
Obj simple_with_move()
|
||||
{
|
||||
Obj obj;
|
||||
// move 会禁止 NRVO
|
||||
return std::move(obj);
|
||||
}
|
||||
|
||||
Obj complicated(int n)
|
||||
{
|
||||
Obj obj1;
|
||||
Obj obj2;
|
||||
// 有分支,一般无 NRVO
|
||||
if (n % 2 == 0) {
|
||||
return obj1;
|
||||
} else {
|
||||
return obj2;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
cout << "*** 1 ***" << endl;
|
||||
auto obj1 = simple();
|
||||
cout << "*** 2 ***" << endl;
|
||||
auto obj2 = simple_with_move();
|
||||
cout << "*** 3 ***" << endl;
|
||||
auto obj3 = complicated(42);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出通常为:
|
||||
|
||||
>
|
||||
<p>`*** 1 ***`<br>
|
||||
`Obj()`<br>
|
||||
`*** 2 ***`<br>
|
||||
`Obj()`<br>
|
||||
`Obj(Obj&&)`<br>
|
||||
`*** 3 ***`<br>
|
||||
`Obj()`<br>
|
||||
`Obj()`<br>
|
||||
`Obj(Obj&&)`</p>
|
||||
|
||||
|
||||
也就是,用了 `std::move` 反而妨碍了返回值优化。
|
||||
|
||||
## 引用坍缩和完美转发
|
||||
|
||||
最后讲一个略复杂、但又不得不讲的话题,引用坍缩(又称“引用折叠”)。这个概念在泛型编程中是一定会碰到的。我们今天既然讲了左值和右值引用,也需要一起讲一下。
|
||||
|
||||
我们已经讲了对于一个实际的类型 `T`,它的左值引用是 `T&`,右值引用是 `T&&`。那么:
|
||||
|
||||
1. 是不是看到 `T&`,就一定是个左值引用?
|
||||
1. 是不是看到 `T&&`,就一定是个右值引用?
|
||||
|
||||
对于前者的回答是“是”,对于后者的回答为“否”。
|
||||
|
||||
关键在于,在有模板的代码里,对于类型参数的推导结果可能是引用。我们可以略过一些繁复的语法规则,要点是:
|
||||
|
||||
- 对于 `template <typename T> foo(T&&)` 这样的代码,如果传递过去的参数是左值,`T` 的推导结果是左值引用;如果传递过去的参数是右值,`T` 的推导结果是参数的类型本身。
|
||||
- 如果 `T` 是左值引用,那 `T&&` 的结果仍然是左值引用——即 `type& &&` 坍缩成了 `type&`。
|
||||
- 如果 `T` 是一个实际类型,那 `T&&` 的结果自然就是一个右值引用。
|
||||
|
||||
我们之前提到过,右值引用变量仍然会匹配到左值引用上去。下面的代码会验证这一行为:
|
||||
|
||||
```
|
||||
void foo(const shape&)
|
||||
{
|
||||
puts("foo(const shape&)");
|
||||
}
|
||||
|
||||
void foo(shape&&)
|
||||
{
|
||||
puts("foo(shape&&)");
|
||||
}
|
||||
|
||||
void bar(const shape& s)
|
||||
{
|
||||
puts("bar(const shape&)");
|
||||
foo(s);
|
||||
}
|
||||
|
||||
void bar(shape&& s)
|
||||
{
|
||||
puts("bar(shape&&)");
|
||||
foo(s);
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
bar(circle());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出为:
|
||||
|
||||
>
|
||||
<p>`bar(shape&&)`<br>
|
||||
`foo(const shape&)`</p>
|
||||
|
||||
|
||||
如果我们要让 `bar` 调用右值引用的那个 foo 的重载,我们必须写成:
|
||||
|
||||
```
|
||||
foo(std::move(s));
|
||||
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```
|
||||
foo(static_cast<shape&&>(s));
|
||||
|
||||
```
|
||||
|
||||
可如果两个 `bar` 的重载除了调用 `foo` 的方式不一样,其他都差不多的话,我们为什么要提供两个不同的 `bar` 呢?
|
||||
|
||||
事实上,很多标准库里的函数,连目标的参数类型都不知道,但我们仍然需要能够保持参数的值类别:左值的仍然是左值,右值的仍然是右值。这个功能在 C++ 标准库中已经提供了,叫 `std::forward`。它和 `std::move` 一样都是利用引用坍缩机制来实现。此处,我们不介绍其实现细节,而是重点展示其用法。我们可以把我们的两个 `bar` 函数简化成:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
void bar(T&& s)
|
||||
{
|
||||
foo(std::forward<T>(s));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于下面这样的代码:
|
||||
|
||||
```
|
||||
circle temp;
|
||||
bar(temp);
|
||||
bar(circle());
|
||||
|
||||
```
|
||||
|
||||
现在的输出是:
|
||||
|
||||
>
|
||||
<p>`foo(const shape&)`<br>
|
||||
`foo(shape&&)`</p>
|
||||
|
||||
|
||||
一切如预期一样。
|
||||
|
||||
因为在 `T` 是模板参数时,`T&&` 的作用主要是保持值类别进行转发,它有个名字就叫“转发引用”(forwarding reference)。因为既可以是左值引用,也可以是右值引用,它也曾经被叫做“万能引用”(universal reference)。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本讲介绍了 C++ 里的值类别,重点介绍了临时变量、右值引用、移动语义和实际的编程用法。由于这是 C++11 里的重点功能,你对于其基本用法需要牢牢掌握。
|
||||
|
||||
## 课后思考
|
||||
|
||||
留给你两道思考题:
|
||||
|
||||
1. 请查看一下标准函数模板 `make_shared` 的声明,然后想一想,这个函数应该是怎样实现的。
|
||||
1. 为什么 `smart_ptr::operator=` 对左值和右值都有效,而且不需要对等号两边是否引用同一对象进行判断?
|
||||
|
||||
欢迎留言和我交流你的看法,尤其是对第二个问题。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] cppreference.com, “Value categories”. [https://en.cppreference.com/w/cpp/language/value_category](https://en.cppreference.com/w/cpp/language/value_category)
|
||||
|
||||
[1a] cppreference.com, “值类别”. [https://zh.cppreference.com/w/cpp/language/value_category](https://zh.cppreference.com/w/cpp/language/value_category)
|
||||
|
||||
[2] Anders Schau Knatten, “lvalues, rvalues, glvalues, prvalues, xvalues, help!”. [https://blog.knatten.org/2018/03/09/lvalues-rvalues-glvalues-prvalues-xvalues-help/](https://blog.knatten.org/2018/03/09/lvalues-rvalues-glvalues-prvalues-xvalues-help/)
|
||||
|
||||
[3] Jeaye, “Value category cheat-sheet”. [https://blog.jeaye.com/2017/03/19/xvalues/](https://blog.jeaye.com/2017/03/19/xvalues/)
|
||||
|
||||
[4] Thomas Becker, “C++ rvalue references explained”. [http://thbecker.net/articles/rvalue_references/section_01.html](http://thbecker.net/articles/rvalue_references/section_01.html)
|
||||
|
||||
[5] Herb Sutter, “GotW #88: A candidate for the ‘most important const’”. [https://herbsutter.com/2008/01/01/gotw-88-a-candidate-for-the-most-important-const/](https://herbsutter.com/2008/01/01/gotw-88-a-candidate-for-the-most-important-const/)
|
||||
446
极客时间专栏/现代C++实战30讲/基础篇/04 | 容器汇编 I:比较简单的若干容器.md
Normal file
446
极客时间专栏/现代C++实战30讲/基础篇/04 | 容器汇编 I:比较简单的若干容器.md
Normal file
@@ -0,0 +1,446 @@
|
||||
<audio id="audio" title="04 | 容器汇编 I:比较简单的若干容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/ed/d8e06a7a49fccf72ff930414d7c23ced.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
上几讲我们学习了 C++ 的资源管理和值类别。今天我们换一个话题,来看一下 C++ 里的容器。
|
||||
|
||||
关于容器,已经存在不少的学习资料了。在 cppreference 上有很完备的参考资料([1])。今天我们采取一种非正规的讲解方式,尽量不重复已有的参考资料,而是让你加深对于重要容器的理解。
|
||||
|
||||
对于容器,学习上的一个麻烦点是你无法直接输出容器的内容——如果你定义了一个 `vector<int> v`,你是没法简单输出 `v` 的内容的。有人也许会说用 `copy(v.begin(), v.end(), ostream_iterator(…))`,可那既啰嗦,又对像 `map` 或 `vector<vector<…>>` 这样的复杂类型无效。因此,我们需要一个更好用的工具。在此,我向你大力推荐 xeus-cling [2]。它的便利性无与伦比——你可以直接在浏览器里以交互的方式运行代码,不需要本机安装任何编译器(点击“Trying it online”下面的 binder 链接)。下面是在线运行的一个截图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/ca/7199bb5b1394fe1aa9f91b850c309eca.png" alt="">
|
||||
|
||||
xeus-cling 也可以在本地安装。对于使用 Linux 的同学,安装应当是相当便捷的。有兴趣的话,使用其他平台的同学也可以尝试一下。
|
||||
|
||||
如果你既没有本地运行的条件,也不方便远程使用互联网来运行代码,我个人还为本专栏写了一个小小的工具 [3]。在你的代码中包含这个头文件,也可以方便地得到类似于上面的输出。示例代码如下所示:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include "output_container.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main()
|
||||
{
|
||||
map<int, int> mp{
|
||||
{1, 1}, {2, 4}, {3, 9}};
|
||||
cout << mp << endl;
|
||||
vector<vector<int>> vv{
|
||||
{1, 1}, {2, 4}, {3, 9}};
|
||||
cout << vv << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们会得到下面的输出:
|
||||
|
||||
>
|
||||
<p>`{ 1 => 1, 2 => 4, 3 => 9 }`<br>
|
||||
`{ { 1, 1 }, { 2, 4 }, { 3, 9 } }`</p>
|
||||
|
||||
|
||||
这个代码中用到了很多我们目前专栏还没有讲的知识,所以你暂且不用关心它的实现原理。如果你能看得懂这个代码,那就太棒了。如果你看不懂,唔,不急,慢慢来,你会明白的。
|
||||
|
||||
工具在手,天下我有。下面我们正式开讲容器篇。
|
||||
|
||||
## string
|
||||
|
||||
`string` 一般并不被认为是一个 C++ 的容器。但鉴于其和容器有很多共同点,我们先拿 `string` 类来开说。
|
||||
|
||||
`string` 是模板 `basic_string` 对于 `char` 类型的特化,可以认为是一个只存放字符 `char` 类型数据的容器。“真正”的容器类与 `string` 的最大不同点是里面可以存放任意类型的对象。
|
||||
|
||||
跟其他大部分容器一样, `string` 具有下列成员函数:
|
||||
|
||||
- `begin` 可以得到对象起始点
|
||||
- `end` 可以得到对象的结束点
|
||||
- `empty` 可以得到容器是否为空
|
||||
- `size` 可以得到容器的大小
|
||||
- `swap` 可以和另外一个容器交换其内容
|
||||
|
||||
(对于不那么熟悉容器的人,需要知道 C++ 的 `begin` 和 `end` 是半开半闭区间:在容器非空时,`begin` 指向第一个元素,而 `end` 指向最后一个元素后面的位置;在容器为空时,`begin` 等于 `end`。在 `string` 的情况下,由于考虑到和 C 字符串的兼容,`end` 指向代表字符串结尾的 `\0` 字符。)
|
||||
|
||||
上面就几乎是所有容器的共同点了。也就是说:
|
||||
|
||||
- 容器都有开始和结束点
|
||||
- 容器会记录其状态是否非空
|
||||
- 容器有大小
|
||||
- 容器支持交换
|
||||
|
||||
当然,这只是容器的“共同点”而已。每个容器都有其特殊的用途。
|
||||
|
||||
`string` 的内存布局大致如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/62/eec393f933220a9998b7235c8acc1862.png" alt="">
|
||||
|
||||
下面你会看到,不管是内存布局,还是成员函数,`string` 和 `vector` 是非常相似的。
|
||||
|
||||
`string` 当然是为了存放字符串。和简单的 C 字符串不同:
|
||||
|
||||
- `string` 负责自动维护字符串的生命周期
|
||||
- `string` 支持字符串的拼接操作(如之前说过的 `+` 和 `+=`)
|
||||
- `string` 支持字符串的查找操作(如 `find` 和 `rfind`)
|
||||
- `string` 支持从 `istream` 安全地读入字符串(使用 `getline`)
|
||||
- `string` 支持给期待 `const char*` 的接口传递字符串内容(使用 `c_str`)
|
||||
- `string` 支持到数字的互转(`stoi` 系列函数和 `to_string`)
|
||||
- 等等
|
||||
|
||||
推荐你在代码中尽量使用 `string` 来管理字符串。不过,对于对外暴露的接口,情况有一点复杂。我一般不建议在接口中使用 `const string&`,除非确知调用者已经持有 `string`:如果函数里不对字符串做复杂处理的话,使用 `const char*` 可以避免在调用者只有 C 字符串时编译器自动构造 `string`,这种额外的构造和析构代价并不低。反过来,如果实现较为复杂、希望使用 `string` 的成员函数的话,那就应该考虑下面的策略:
|
||||
|
||||
- 如果不修改字符串的内容,使用 `const string&` 或 C++17 的 `string_view` 作为参数类型。后者是最理想的情况,因为即使在只有 C 字符串的情况,也不会引发不必要的内存复制。
|
||||
- 如果需要在函数内修改字符串内容、但不影响调用者的该字符串,使用 `string` 作为参数类型(自动拷贝)。
|
||||
- 如果需要改变调用者的字符串内容,使用 `string&` 作为参数类型(通常不推荐)。
|
||||
|
||||
估计大部分同学对 `string` 已经很熟悉了。我们在此只给出一个非常简单的小例子:
|
||||
|
||||
```
|
||||
string name;
|
||||
cout << "What's your name? ";
|
||||
getline(cin, name);
|
||||
cout << "Nice to meet you, " << name
|
||||
<< "!\n";
|
||||
|
||||
```
|
||||
|
||||
## vector
|
||||
|
||||
`vector` 应该是最常用的容器了。它的名字“向量”来源于数学术语,但在实际应用中,我们把它当成动态数组更为合适。它基本相当于 Java 的 `ArrayList` 和 Python 的 `list`。
|
||||
|
||||
和 `string` 相似,`vector` 的成员在内存里连续存放,同时 `begin`、`end`、`front`、`back` 成员函数指向的位置也和 `string` 一样,大致如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/10/247951f886561c30ced2eb7700f9d510.png" alt="">
|
||||
|
||||
除了容器类的共同点,`vector` 允许下面的操作(不完全列表):
|
||||
|
||||
- 可以使用中括号的下标来访问其成员(同 `string`)
|
||||
- 可以使用 `data` 来获得指向其内容的裸指针(同 `string`)
|
||||
- 可以使用 `capacity` 来获得当前分配的存储空间的大小,以元素数量计(同 `string`)
|
||||
- 可以使用 `reserve` 来改变所需的存储空间的大小,成功后 `capacity()` 会改变(同 `string`)
|
||||
- 可以使用 `resize` 来改变其大小,成功后 `size()` 会改变(同 `string`)
|
||||
- 可以使用 `pop_back` 来删除最后一个元素(同 `string`)
|
||||
- 可以使用 `push_back` 在尾部插入一个元素(同 `string`)
|
||||
- 可以使用 `insert` 在指定位置前插入一个元素(同 `string`)
|
||||
- 可以使用 `erase` 在指定位置删除一个元素(同 `string`)
|
||||
- 可以使用 `emplace` 在指定位置构造一个元素
|
||||
- 可以使用 `emplace_back` 在尾部新构造一个元素
|
||||
|
||||
大家可以留意一下 `push_…` 和 `pop_…` 成员函数。它们存在时,说明容器对指定位置的删除和插入性能较高。`vector` 适合在尾部操作,这是它的内存布局决定的。只有在尾部插入和删除时,其他元素才会不需要移动,除非内存空间不足导致需要重新分配内存空间。
|
||||
|
||||
当 `push_back`、`insert`、`reserve`、`resize` 等函数导致内存重分配时,或当 `insert`、`erase` 导致元素位置移动时,`vector` 会试图把元素“移动”到新的内存区域。`vector` 通常保证强异常安全性,如果元素类型没有提供一个**保证不抛异常的移动构造函数**,`vector` 通常会使用拷贝构造函数。因此,对于拷贝代价较高的自定义元素类型,我们应当定义移动构造函数,并标其为 `noexcept`,或只在容器中放置对象的智能指针。这就是为什么我之前需要在 `smart_ptr` 的实现中标上 `noexcept` 的原因。
|
||||
|
||||
下面的代码可以演示这一行为:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
using namespace std;
|
||||
|
||||
class Obj1 {
|
||||
public:
|
||||
Obj1()
|
||||
{
|
||||
cout << "Obj1()\n";
|
||||
}
|
||||
Obj1(const Obj1&)
|
||||
{
|
||||
cout << "Obj1(const Obj1&)\n";
|
||||
}
|
||||
Obj1(Obj1&&)
|
||||
{
|
||||
cout << "Obj1(Obj1&&)\n";
|
||||
}
|
||||
};
|
||||
|
||||
class Obj2 {
|
||||
public:
|
||||
Obj2()
|
||||
{
|
||||
cout << "Obj2()\n";
|
||||
}
|
||||
Obj2(const Obj2&)
|
||||
{
|
||||
cout << "Obj2(const Obj2&)\n";
|
||||
}
|
||||
Obj2(Obj2&&) noexcept
|
||||
{
|
||||
cout << "Obj2(Obj2&&)\n";
|
||||
}
|
||||
};
|
||||
|
||||
int main()
|
||||
{
|
||||
vector<Obj1> v1;
|
||||
v1.reserve(2);
|
||||
v1.emplace_back();
|
||||
v1.emplace_back();
|
||||
v1.emplace_back();
|
||||
|
||||
vector<Obj2> v2;
|
||||
v2.reserve(2);
|
||||
v2.emplace_back();
|
||||
v2.emplace_back();
|
||||
v2.emplace_back();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以立即得到下面的输出:
|
||||
|
||||
>
|
||||
<p>`Obj1()`<br>
|
||||
`Obj1()`<br>
|
||||
`Obj1()`<br>
|
||||
`Obj1(const Obj1&)`<br>
|
||||
`Obj1(const Obj1&)`<br>
|
||||
`Obj2()`<br>
|
||||
`Obj2()`<br>
|
||||
`Obj2()`<br>
|
||||
`Obj2(Obj2&&)`<br>
|
||||
`Obj2(Obj2&&)`</p>
|
||||
|
||||
|
||||
`Obj1` 和 `Obj2` 的定义只差了一个 `noexcept`,但这个小小的差异就导致了 `vector` 是否会移动对象。这点非常重要。
|
||||
|
||||
C++11 开始提供的 `emplace…` 系列函数是为了提升容器的性能而设计的。你可以试试把 `v1.emplace_back()` 改成 `v1.push_back(Obj1())`。对于 `vector` 里的内容,结果是一样的;但使用 `push_back` 会额外生成临时对象,多一次(移动或拷贝)构造和析构。如果是移动的情况,那会有小幅性能损失;如果对象没有实现移动的话,那性能差异就可能比较大了。
|
||||
|
||||
现代处理器的体系架构使得对连续内存访问的速度比不连续的内存要快得多。因而,`vector` 的连续内存使用是它的一大优势所在。当你不知道该用什么容器时,缺省就使用 `vector` 吧。
|
||||
|
||||
`vector` 的一个主要缺陷是大小增长时导致的元素移动。如果可能,尽早使用 `reserve` 函数为 `vector` 保留所需的内存,这在 `vector` 预期会增长很大时能带来很大的性能提升。
|
||||
|
||||
## deque
|
||||
|
||||
`deque` 的意思是 double-ended queue,双端队列。它主要是用来满足下面这个需求:
|
||||
|
||||
- 容器不仅可以从尾部自由地添加和删除元素,也可以从头部自由地添加和删除。
|
||||
|
||||
`deque` 的接口和 `vector` 相比,有如下的区别:
|
||||
|
||||
- `deque` 提供 `push_front`、`emplace_front` 和 `pop_front` 成员函数。
|
||||
- `deque` 不提供 `data`、`capacity` 和 `reserve` 成员函数。
|
||||
|
||||
`deque` 的内存布局一般是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/a1/aea92e7e005d65fa56d6332bb7706fa1.png" alt="">
|
||||
|
||||
可以看到:
|
||||
|
||||
- 如果只从头、尾两个位置对 `deque` 进行增删操作的话,容器里的对象永远不需要移动。
|
||||
- 容器里的元素只是部分连续的(因而没法提供 `data` 成员函数)。
|
||||
- 由于元素的存储大部分仍然连续,它的遍历性能是比较高的。
|
||||
- 由于每一段存储大小相等,`deque` 支持使用下标访问容器元素,大致相当于 `index[i / chunk_size][i % chunk_size]`,也保持高效。
|
||||
|
||||
如果你需要一个经常在头尾增删元素的容器,那 `deque` 会是个合适的选择。
|
||||
|
||||
## list
|
||||
|
||||
`list` 在 C++ 里代表双向链表。和 `vector` 相比,它优化了在容器中间的插入和删除:
|
||||
|
||||
- `list` 提供高效的、O(1) 复杂度的任意位置的插入和删除操作。
|
||||
- `list` 不提供使用下标访问其元素。
|
||||
- `list` 提供 `push_front`、`emplace_front` 和 `pop_front` 成员函数(和 `deque` 相同)。
|
||||
- `list` 不提供 `data`、`capacity` 和 `reserve` 成员函数(和 `deque` 相同)。
|
||||
|
||||
它的内存布局一般是下图这个样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ad/14/addb521e20de1a302206f4286eac3914.png" alt="">
|
||||
|
||||
需要指出的是,虽然 `list` 提供了任意位置插入新元素的灵活性,但由于每个元素的内存空间都是单独分配、不连续,它的遍历性能比 `vector` 和 `deque` 都要低。这在很大程度上抵消了它在插入和删除操作时不需要移动元素的理论性能优势。如果你不太需要遍历容器、又需要在中间频繁插入或删除元素,可以考虑使用 `list`。
|
||||
|
||||
另外一个需要注意的地方是,因为某些标准算法在 `list` 上会导致问题,list 提供了成员函数作为替代,包括下面几个:
|
||||
|
||||
- `merge`
|
||||
- `remove`
|
||||
- `remove_if`
|
||||
- `reverse`
|
||||
- `sort`
|
||||
- `unique`
|
||||
|
||||
下面是一个示例(以 xeus-cling 的交互为例):
|
||||
|
||||
```
|
||||
#include <algorithm>
|
||||
#include <list>
|
||||
#include <vector>
|
||||
using namespace std;
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
list<int> lst{1, 7, 2, 8, 3};
|
||||
vector<int> vec{1, 7, 2, 8, 3};
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
sort(vec.begin(), vec.end()); // 正常
|
||||
// sort(lst.begin(), lst.end()); // 会出错
|
||||
lst.sort(); // 正常
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
lst // 输出 { 1, 2, 3, 7, 8 }
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
vec // 输出 { 1, 2, 3, 7, 8 }
|
||||
|
||||
```
|
||||
|
||||
如果不用 xeus-cling 的话,我们需要做点转换:
|
||||
|
||||
- 把 `using namespace std;` 后面的部分放到 `main` 函数里。
|
||||
- 文件开头加上 `#include "output_container.h"` 和 `#include <iostream>`。
|
||||
- 把输出语句改写成 `cout << … << endl;`。
|
||||
|
||||
这次我会给一下改造的示例(下次就请你自行改写了😉):
|
||||
|
||||
```
|
||||
#include "output_container.h"
|
||||
#include <iostream>
|
||||
#include <algorithm>
|
||||
#include <list>
|
||||
#include <vector>
|
||||
using namespace std;
|
||||
|
||||
int main()
|
||||
{
|
||||
list<int> lst{1, 7, 2, 8, 3};
|
||||
vector<int> vec{1, 7, 2, 8, 3};
|
||||
|
||||
sort(vec.begin(), vec.end()); // 正常
|
||||
// sort(lst.begin(), lst.end()); // 会出错
|
||||
lst.sort(); // 正常
|
||||
|
||||
cout << lst << endl;
|
||||
// 输出 { 1, 2, 3, 7, 8 }
|
||||
|
||||
cout << vec << endl;
|
||||
// 输出 { 1, 2, 3, 7, 8 }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## forward_list
|
||||
|
||||
既然 `list` 是双向链表,那么 C++ 里有没有单向链表呢?答案是肯定的。从 C++11 开始,前向列表 `forward_list` 成了标准的一部分。
|
||||
|
||||
我们先看一下它的内存布局:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/4e/ef23c4d60940c170629cf65771df084e.png" alt="">
|
||||
|
||||
大部分 C++ 容器都支持 `insert` 成员函数,语义是从指定的位置之前插入一个元素。对于 `forward_list`,这不是一件容易做到的事情(想一想,为什么?)。标准库提供了一个 `insert_after` 作为替代。此外,它跟 `list` 相比还缺了下面这些成员函数:
|
||||
|
||||
- `back`
|
||||
- `size`
|
||||
- `push_back`
|
||||
- `emplace_back`
|
||||
- `pop_back`
|
||||
|
||||
为什么会需要这么一个阉割版的 list 呢?原因是,在元素大小较小的情况下,`forward_list` 能节约的内存是非常可观的;在列表不长的情况下,不能反向查找也不是个大问题。提高内存利用率,往往就能提高程序性能,更不用说在内存可能不足时的情况了。
|
||||
|
||||
目前你只需要知道这个东西的存在就可以了。如果你觉得不需要用到它的话,也许你真的不需要它。
|
||||
|
||||
## queue
|
||||
|
||||
在结束本讲之前,我们再快速讲两个类容器。它们的特别点在于它们都不是完整的实现,而是依赖于某个现有的容器,因而被称为容器适配器(container adaptor)。
|
||||
|
||||
我们先看一下队列 `queue`,先进先出(FIFO)的数据结构。
|
||||
|
||||
`queue` 缺省用 `deque` 来实现。它的接口跟 `deque` 比,有如下改变:
|
||||
|
||||
- 不能按下标访问元素
|
||||
- 没有 `begin`、`end` 成员函数
|
||||
- 用 `emplace` 替代了 `emplace_back`,用 `push` 替代了 `push_back`,用 `pop` 替代了 `pop_front`;没有其他的 `push_…`、`pop_…`、`emplace…`、`insert`、`erase` 函数
|
||||
|
||||
它的实际内存布局当然是随底层的容器而定的。从概念上讲,它的结构可如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/ff/090f23e3b4cdd8d297e4b970cbbf6cff.png" alt="">
|
||||
|
||||
鉴于 `queue` 不提供 `begin` 和 `end` 方法,无法无损遍历,我们只能用下面的代码约略展示一下其接口:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <queue>
|
||||
|
||||
int main()
|
||||
{
|
||||
std::queue<int> q;
|
||||
q.push(1);
|
||||
q.push(2);
|
||||
q.push(3);
|
||||
while (!q.empty()) {
|
||||
std::cout << q.front()
|
||||
<< std::endl;
|
||||
q.pop();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个代码的输出就不用解释了吧。哈哈。
|
||||
|
||||
## stack
|
||||
|
||||
类似地,栈 `stack` 是后进先出(LIFO)的数据结构。
|
||||
|
||||
`stack` 缺省也是用 `deque` 来实现,但它的概念和 `vector` 更相似。它的接口跟 `vector` 比,有如下改变:
|
||||
|
||||
- 不能按下标访问元素
|
||||
- 没有 `begin`、`end` 成员函数
|
||||
- `back` 成了 `top`,没有 `front`
|
||||
- 用 `emplace` 替代了 `emplace_back`,用 `push` 替代了 `push_back`,用 `pop` 替代了 `pop_back`;没有其他的 `push_…`、`pop_…`、`emplace…`、`insert`、`erase` 函数
|
||||
|
||||
一般图形表示法会把 `stack` 表示成一个竖起的 `vector`:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/47/68026091ff5eacac00c49d75151fff47.png" alt="">
|
||||
|
||||
这里有一个小细节需要注意。`stack` 跟我们前面讨论内存管理时的栈有一个区别:在这里下面是低地址,向上则地址增大;而我们讨论内存管理时,高地址在下面,向上则地址减小,方向正好相反。提这一点,是希望你在有需要检查栈结构时不会因此而发生混淆;在使用 `stack` 时,这个区别通常无关紧要。
|
||||
|
||||
示例代码和上面的 `queue` 相似,但输出正好相反:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <stack>
|
||||
|
||||
int main()
|
||||
{
|
||||
std::stack<int> s;
|
||||
s.push(1);
|
||||
s.push(2);
|
||||
s.push(3);
|
||||
while (!s.empty()) {
|
||||
std::cout << s.top()
|
||||
<< std::endl;
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 内容小结
|
||||
|
||||
本讲我们介绍了 C++ 里面的序列容器和两个容器适配器。通过本讲的介绍,你应该已经对容器有了一定的理解和认识。下一讲我们会讲完剩余的标准容器。
|
||||
|
||||
## 课后思考
|
||||
|
||||
留几个问题请你思考一下:
|
||||
|
||||
1. 今天讲的容器有哪些共同的特点?
|
||||
1. 为什么 C++ 有这么多不同的序列容器类型?
|
||||
1. 为什么 `stack`(或 `queue`)的 `pop` 函数返回类型为 `void`,而不是直接返回容器的 `top`(或 `front`)成员?
|
||||
|
||||
欢迎留言和我交流你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] cppreference.com, “Containers library”. [https://en.cppreference.com/w/cpp/container](https://en.cppreference.com/w/cpp/container)
|
||||
|
||||
[1a] cppreference.com, “容器库”. [https://zh.cppreference.com/w/cpp/container](https://zh.cppreference.com/w/cpp/container)
|
||||
|
||||
[2] QuantStack, xeus-cling. [https://github.com/QuantStack/xeus-cling](https://github.com/QuantStack/xeus-cling)
|
||||
|
||||
[3] 吴咏炜, output_container. [https://github.com/adah1972/output_container/blob/master/output_container.h](https://github.com/adah1972/output_container/blob/master/output_container.h)
|
||||
606
极客时间专栏/现代C++实战30讲/基础篇/05 | 容器汇编 II:需要函数对象的容器.md
Normal file
606
极客时间专栏/现代C++实战30讲/基础篇/05 | 容器汇编 II:需要函数对象的容器.md
Normal file
@@ -0,0 +1,606 @@
|
||||
<audio id="audio" title="05 | 容器汇编 II:需要函数对象的容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/a8/c0be78d32a3c20c24679e93c0f151ba8.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
上一讲我们学习了 C++ 的序列容器和两个容器适配器,今天我们继续讲完剩下的标准容器([1])。
|
||||
|
||||
## 函数对象及其特化
|
||||
|
||||
在讲容器之前,我们需要首先来讨论一下两个重要的函数对象,`less` 和 `hash`。
|
||||
|
||||
我们先看一下 `less`,小于关系。在标准库里,通用的 `less` 大致是这样定义的:
|
||||
|
||||
```
|
||||
template <class T>
|
||||
struct less
|
||||
: binary_function<T, T, bool> {
|
||||
bool operator()(const T& x,
|
||||
const T& y) const
|
||||
{
|
||||
return x < y;
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
也就是说,`less` 是一个函数对象,并且是个二元函数,执行对任意类型的值的比较,返回布尔类型。作为函数对象,它定义了函数调用运算符(`operator()`),并且缺省行为是对指定类型的对象进行 `<` 的比较操作。
|
||||
|
||||
有点平淡无奇,是吧?原因是因为这个缺省实现在大部分情况下已经够用,我们不太需要去碰它。在需要大小比较的场合,C++ 通常默认会使用 `less`,包括我们今天会讲到的若干容器和排序算法 `sort`。如果我们需要产生相反的顺序的话,则可以使用 `greater`,大于关系。
|
||||
|
||||
计算哈希值的函数对象 `hash` 就不一样了。它的目的是把一个某种类型的值转换成一个无符号整数哈希值,类型为 `size_t`。它没有一个可用的默认实现。对于常用的类型,系统提供了需要的特化 [2],类似于:
|
||||
|
||||
```
|
||||
template <class T> struct hash;
|
||||
|
||||
template <>
|
||||
struct hash<int>
|
||||
: public unary_function<int, size_t> {
|
||||
size_t operator()(int v) const
|
||||
noexcept
|
||||
{
|
||||
return static_cast<size_t>(v);
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这当然是一个极其简单的例子。更复杂的类型,如指针或者 `string` 的特化,都会更复杂。要点是,对于每个类,类的作者都可以提供 `hash` 的特化,使得对于不同的对象值,函数调用运算符都能得到尽可能均匀分布的不同数值。
|
||||
|
||||
我们用下面这个例子来加深一下理解:
|
||||
|
||||
```
|
||||
#include <algorithm> // std::sort
|
||||
#include <functional> // std::less/greater/hash
|
||||
#include <iostream> // std::cout/endl
|
||||
#include <string> // std::string
|
||||
#include <vector> // std::vector
|
||||
#include "output_container.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main()
|
||||
{
|
||||
// 初始数组
|
||||
vector<int> v{13, 6, 4, 11, 29};
|
||||
cout << v << endl;
|
||||
|
||||
// 从小到大排序
|
||||
sort(v.begin(), v.end());
|
||||
cout << v << endl;
|
||||
|
||||
// 从大到小排序
|
||||
sort(v.begin(), v.end(),
|
||||
greater<int>());
|
||||
cout << v << endl;
|
||||
|
||||
cout << hex;
|
||||
|
||||
auto hp = hash<int*>();
|
||||
cout << "hash(nullptr) = "
|
||||
<< hp(nullptr) << endl;
|
||||
cout << "hash(v.data()) = "
|
||||
<< hp(v.data()) << endl;
|
||||
cout << "v.data() = "
|
||||
<< static_cast<void*>(v.data())
|
||||
<< endl;
|
||||
|
||||
auto hs = hash<string>();
|
||||
cout << "hash(\"hello\") = "
|
||||
<< hs(string("hello")) << endl;
|
||||
cout << "hash(\"hellp\") = "
|
||||
<< hs(string("hellp")) << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在 MSVC 下的某次运行结果如下所示:
|
||||
|
||||
>
|
||||
<p>`{ 13, 6, 4, 11, 29 }`<br>
|
||||
`{ 4, 6, 11, 13, 29 }`<br>
|
||||
`{ 29, 13, 11, 6, 4 }`<br>
|
||||
`hash(nullptr) = a8c7f832281a39c5`<br>
|
||||
`hash(v.data()) = 7a0bdfd7df0923d2`<br>
|
||||
`v.data() = 000001EFFB10EAE0`<br>
|
||||
`hash("hello") = a430d84680aabd0b`<br>
|
||||
`hash("hellp") = a430e54680aad322`</p>
|
||||
|
||||
|
||||
可以看到,在这个实现里,空指针的哈希值是一个非零的数值,指针的哈希值也和指针的数值不一样。要注意不同的实现处理的方式会不一样。事实上,我的测试结果是 GCC、Clang 和 MSVC 对常见类型的哈希方式都各有不同。
|
||||
|
||||
在上面的例子里,我们同时可以看到,这两个函数对象的值不重要。我们甚至可以认为,每个 `less`(或 `greater` 或 `hash`)对象都是等价的。关键在于其类型。以 `sort` 为例,第三个参数的类型确定了其排序行为。
|
||||
|
||||
对于容器也是如此,函数对象的类型确定了容器的行为。
|
||||
|
||||
## priority_queue
|
||||
|
||||
`priority_queue` 也是一个容器适配器。上一讲没有和其他容器适配器一起讲的原因就在于它用到了比较函数对象(默认是 `less`)。它和 `stack` 相似,支持 `push`、`pop`、`top` 等有限的操作,但容器内的顺序既不是后进先出,也不是先进先出,而是(部分)排序的结果。在使用缺省的 `less` 作为其 `Compare` 模板参数时,最大的数值会出现在容器的“顶部”。如果需要最小的数值出现在容器顶部,则可以传递 `greater` 作为其 `Compare` 模板参数。
|
||||
|
||||
下面的代码可以演示其功能:
|
||||
|
||||
```
|
||||
#include <functional> // std::greater
|
||||
#include <iostream> // std::cout/endl
|
||||
#include <memory> // std::pair
|
||||
#include <queue> // std::priority_queue
|
||||
#include <vector> // std::vector
|
||||
#include "output_container.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main()
|
||||
{
|
||||
priority_queue<
|
||||
pair<int, int>,
|
||||
vector<pair<int, int>>,
|
||||
greater<pair<int, int>>>
|
||||
q;
|
||||
q.push({1, 1});
|
||||
q.push({2, 2});
|
||||
q.push({0, 3});
|
||||
q.push({9, 4});
|
||||
while (!q.empty()) {
|
||||
cout << q.top() << endl;
|
||||
q.pop();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出为:
|
||||
|
||||
>
|
||||
<p>`(0, 3)`<br>
|
||||
`(1, 1)`<br>
|
||||
`(2, 2)`<br>
|
||||
`(9, 4)`</p>
|
||||
|
||||
|
||||
## 关联容器
|
||||
|
||||
关联容器有 `set`(集合)、`map`(映射)、`multiset`(多重集)和 `multimap`(多重映射)。跳出 C++ 的语境,`map`(映射)的更常见的名字是关联数组和字典 [3],而在 JSON 里直接被称为对象(object)。在 C++ 外这些容器常常是无序的;在 C++ 里关联容器则被认为是有序的。
|
||||
|
||||
我们可以通过以下的 xeus-cling 交互来体会一下。
|
||||
|
||||
```
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
using namespace std;
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
set<int> s{1, 1, 1, 2, 3, 4};
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
s
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ 1, 2, 3, 4 }`
|
||||
|
||||
|
||||
```
|
||||
multiset<int, greater<int>> ms{1, 1, 1, 2, 3, 4};
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
ms
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ 4, 3, 2, 1, 1, 1 }`
|
||||
|
||||
|
||||
```
|
||||
map<string, int> mp{
|
||||
{"one", 1},
|
||||
{"two", 2},
|
||||
{"three", 3},
|
||||
{"four", 4}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
mp
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ "four" => 4, "one" => 1, "three" => 3, "two" => 2 }`
|
||||
|
||||
|
||||
```
|
||||
mp.insert({"four", 4});
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
mp
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ "four" => 4, "one" => 1, "three" => 3, "two" => 2 }`
|
||||
|
||||
|
||||
```
|
||||
mp.find("four") == mp.end()
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`false`
|
||||
|
||||
|
||||
```
|
||||
mp.find("five") == mp.end()
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`(bool) true`
|
||||
|
||||
|
||||
```
|
||||
mp["five"] = 5;
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
mp
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ "five" => 5, "four" => 4, "one" => 1, "three" => 3, "two" => 2 }`
|
||||
|
||||
|
||||
```
|
||||
multimap<string, int> mmp{
|
||||
{"one", 1},
|
||||
{"two", 2},
|
||||
{"three", 3},
|
||||
{"four", 4}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
mmp
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ "four" => 4, "one" => 1, "three" => 3, "two" => 2 }`
|
||||
|
||||
|
||||
```
|
||||
mmp.insert({"four", -4});
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
mmp
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ "four" => 4, "four" => -4, "one" => 1, "three" => 3, "two" => 2 }`
|
||||
|
||||
|
||||
可以看到,关联容器是一种有序的容器。名字带“multi”的允许键重复,不带的不允许键重复。`set` 和 `multiset` 只能用来存放键,而 `map` 和 `multimap` 则存放一个个键值对。
|
||||
|
||||
与序列容器相比,关联容器没有前、后的概念及相关的成员函数,但同样提供 `insert`、`emplace` 等成员函数。此外,关联容器都有 `find`、`lower_bound`、`upper_bound` 等查找函数,结果是一个迭代器:
|
||||
|
||||
- `find(k)` 可以找到任何一个等价于查找键 k 的元素(`!(x < k || k < x)`)
|
||||
- `lower_bound(k)` 找到第一个不小于查找键 k 的元素(`!(x < k)`)
|
||||
- `upper_bound(k)` 找到第一个大于查找键 k 的元素(`k < x`)
|
||||
|
||||
```
|
||||
mp.find("four")->second
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`4`
|
||||
|
||||
|
||||
```
|
||||
mp.lower_bound("four")->second
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`4`
|
||||
|
||||
|
||||
```
|
||||
(--mp.upper_bound("four"))->second
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`4`
|
||||
|
||||
|
||||
```
|
||||
mmp.lower_bound("four")->second
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`4`
|
||||
|
||||
|
||||
```
|
||||
(--mmp.upper_bound("four"))->second
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`-4`
|
||||
|
||||
|
||||
如果你需要在 `multimap` 里精确查找满足某个键的区间的话,建议使用 `equal_range`,可以一次性取得上下界(半开半闭)。如下所示:
|
||||
|
||||
```
|
||||
#include <tuple>
|
||||
multimap<string, int>::iterator
|
||||
lower, upper;
|
||||
std::tie(lower, upper) =
|
||||
mmp.equal_range("four");
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
(lower != upper) // 检测区间非空
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`true`
|
||||
|
||||
|
||||
```
|
||||
lower->second
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`4`
|
||||
|
||||
|
||||
```
|
||||
(--upper)->second
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`-4`
|
||||
|
||||
|
||||
如果在声明关联容器时没有提供比较类型的参数,缺省使用 `less` 来进行排序。如果键的类型提供了比较算符 `<` 的重载,我们不需要做任何额外的工作。否则,我们就需要对键类型进行 `less` 的特化,或者提供一个其他的函数对象类型。
|
||||
|
||||
对于自定义类型,我推荐尽量使用标准的 `less` 实现,通过重载 `<`(及其他标准比较运算符)对该类型的对象进行排序。存储在关联容器中的键一般应满足严格弱序关系(strict weak ordering;[4]),即:
|
||||
|
||||
- 对于任何该类型的对象 x:`!(x < x)`(非自反)
|
||||
- 对于任何该类型的对象 x 和 y:如果 `x < y`,则 `!(y < x)`(非对称)
|
||||
- 对于任何该类型的对象 x、y 和 z:如果 `x < y` 并且 `y < z`,则 `x < z`(传递性)
|
||||
- 对于任何该类型的对象 x、y 和 z:如果 x 和 y 不可比(`!(x < y)` 并且 `!(y < x)`)并且 y 和 z 不可比,则 x 和 z 不可比(不可比的传递性)
|
||||
|
||||
大部分情况下,类型是可以满足这些条件的,不过:
|
||||
|
||||
- 如果类型没有一般意义上的大小关系(如复数),我们一定要别扭地定义一个大小关系吗?
|
||||
- 通过比较来进行查找、插入和删除,复杂度为对数 O(log(n)),有没有达到更好的性能的方法?
|
||||
|
||||
## 无序关联容器
|
||||
|
||||
从 C++11 开始,每一个关联容器都有一个对应的无序关联容器,它们是:
|
||||
|
||||
- `unordered_set`
|
||||
- `unordered_map`
|
||||
- `unordered_multiset`
|
||||
- `unordered_multimap`
|
||||
|
||||
这些容器和关联容器非常相似,主要的区别就在于它们是“无序”的。这些容器不要求提供一个排序的函数对象,而要求一个可以计算哈希值的函数对象。你当然可以在声明容器对象时手动提供这样一个函数对象类型,但更常见的情况是,我们使用标准的 `hash` 函数对象及其特化。
|
||||
|
||||
下面是一个示例(这次我们暂不使用 xeus-cling,因为它在输出复数时有限制,不能显示其数值):
|
||||
|
||||
```
|
||||
#include <complex> // std::complex
|
||||
#include <iostream> // std::cout/endl
|
||||
#include <unordered_map> // std::unordered_map
|
||||
#include <unordered_set> // std::unordered_set
|
||||
#include "output_container.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
namespace std {
|
||||
|
||||
template <typename T>
|
||||
struct hash<complex<T>> {
|
||||
size_t
|
||||
operator()(const complex<T>& v) const
|
||||
noexcept
|
||||
{
|
||||
hash<T> h;
|
||||
return h(v.real()) + h(v.imag());
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace std
|
||||
|
||||
int main()
|
||||
{
|
||||
unordered_set<int> s{
|
||||
1, 1, 2, 3, 5, 8, 13, 21
|
||||
};
|
||||
cout << s << endl;
|
||||
|
||||
unordered_map<complex<double>,
|
||||
double>
|
||||
umc{{{1.0, 1.0}, 1.4142},
|
||||
{{3.0, 4.0}, 5.0}};
|
||||
cout << umc << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出可能是(顺序不能保证):
|
||||
|
||||
>
|
||||
<p>`{ 21, 5, 8, 3, 13, 2, 1 }`<br>
|
||||
`{ (3,4) => 5, (1,1) => 1.4142 }`</p>
|
||||
|
||||
|
||||
请注意我们在 `std` 名空间中添加了特化,这是少数用户可以向 `std` 名空间添加内容的情况之一。正常情况下,向 `std` 名空间添加声明或定义是禁止的,属于未定义行为。
|
||||
|
||||
从实际的工程角度,无序关联容器的主要优点在于其性能。关联容器和 `priority_queue` 的插入和删除操作,以及关联容器的查找操作,其复杂度都是 O(log(n)),而无序关联容器的实现使用哈希表 [5],可以达到平均 O(1)!但这取决于我们是否使用了一个好的哈希函数:在哈希函数选择不当的情况下,无序关联容器的插入、删除、查找性能可能成为最差情况的 O(n),那就比关联容器糟糕得多了。
|
||||
|
||||
## array
|
||||
|
||||
我们讲的最后一个容器是 C 数组的替代品。C 数组在 C++ 里继续存在,主要是为了保留和 C 的向后兼容性。C 数组本身和 C++ 的容器相差是非常大的:
|
||||
|
||||
- C 数组没有 `begin` 和 `end` 成员函数(虽然可以使用全局的 `begin` 和 `end` 函数)
|
||||
- C 数组没有 `size` 成员函数(得用一些模板技巧来获取其长度)
|
||||
- C 数组作为参数有退化行为,传递给另外一个函数后那个函数不再能获得 C 数组的长度和结束位置
|
||||
|
||||
在 C 的年代,大家有时候会定义这样一个宏来获得数组的长度:
|
||||
|
||||
```
|
||||
#define ARRAY_LEN(a) \
|
||||
(sizeof(a) / sizeof((a)[0]))
|
||||
|
||||
```
|
||||
|
||||
如果在一个函数内部对数组参数使用这个宏,结果肯定是错的。现在 GCC 会友好地发出警告:
|
||||
|
||||
```
|
||||
void test(int a[8])
|
||||
{
|
||||
cout << ARRAY_LEN(a) << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
<p>warning: sizeof on array function parameter will return size of ‘int *’ instead of ‘int [8]’ [-Wsizeof-array-argument]<br>
|
||||
cout << ARRAY_LEN(a) << endl;</p>
|
||||
|
||||
|
||||
C++17 直接提供了一个 `size` 方法,可以用于提供数组长度,并且在数组退化成指针的情况下会直接失败:
|
||||
|
||||
```
|
||||
#include <iostream> // std::cout/endl
|
||||
#include <iterator> // std::size
|
||||
|
||||
void test(int arr[])
|
||||
{
|
||||
// 不能编译
|
||||
// std::cout << std::size(arr)
|
||||
// << std::endl;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
int arr[] = {1, 2, 3, 4, 5};
|
||||
std::cout << "The array length is "
|
||||
<< std::size(arr)
|
||||
<< std::endl;
|
||||
test(arr);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
此外,C 数组也没有良好的复制行为。你无法用 C 数组作为 `map` 或 `unordered_map` 的键类型。下面的代码演示了失败行为:
|
||||
|
||||
```
|
||||
#include <map> // std::map
|
||||
|
||||
typedef char mykey_t[8];
|
||||
|
||||
int main()
|
||||
{
|
||||
std::map<mykey_t, int> mp;
|
||||
mykey_t mykey{"hello"};
|
||||
mp[mykey] = 5;
|
||||
// 轰,大段的编译错误
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果不用 C 数组的话,我们该用什么来替代呢?
|
||||
|
||||
我们有三个可以考虑的选项:
|
||||
|
||||
- 如果数组较大的话,应该考虑 `vector`。`vector` 有最大的灵活性和不错的性能。
|
||||
- 对于字符串数组,当然应该考虑 `string`。
|
||||
- 如果数组大小固定(C 的数组在 C++ 里本来就是大小固定的)并且较小的话,应该考虑 `array`。`array` 保留了 C 数组在栈上分配的特点,同时,提供了 `begin`、`end`、`size` 等通用成员函数。
|
||||
|
||||
`array` 可以避免 C 数组的种种怪异行径。上面的失败代码,如果使用 `array` 的话,稍作改动就可以通过编译:
|
||||
|
||||
```
|
||||
#include <array> // std::array
|
||||
#include <iostream> // std::cout/endl
|
||||
#include <map> // std::map
|
||||
#include "output_container.h"
|
||||
|
||||
typedef std::array<char, 8> mykey_t;
|
||||
|
||||
int main()
|
||||
{
|
||||
std::map<mykey_t, int> mp;
|
||||
mykey_t mykey{"hello"};
|
||||
mp[mykey] = 5; // OK
|
||||
std::cout << mp << std::endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出则是意料之中的:
|
||||
|
||||
>
|
||||
`{ hello => 5 }`
|
||||
|
||||
|
||||
## 内容小结
|
||||
|
||||
本讲介绍了 C++ 的两个常用的函数对象,`less` 和 `hash`;然后介绍了用到这两个函数对象的容器适配器、关联容器和无序关联容器;最后,通过例子展示了为什么我们应当避免 C 数组而考虑使用 `array`。通过这两讲,我们已经完整地了解了 C++ 提供的标准容器。
|
||||
|
||||
## 课后思考
|
||||
|
||||
请思考一下:
|
||||
|
||||
1. 为什么大部分容器都提供了 `begin`、`end` 等方法?
|
||||
1. 为什么容器没有继承一个公用的基类?
|
||||
|
||||
欢迎留言和我交流你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] cppreference.com, “Containers library”. [https://en.cppreference.com/w/cpp/container](https://en.cppreference.com/w/cpp/container)
|
||||
|
||||
[1a] cppreference.com, “容器库”. [https://zh.cppreference.com/w/cpp/container](https://zh.cppreference.com/w/cpp/container)
|
||||
|
||||
[2] cppreference.com, “Explicit (full) template specialization”. [https://en.cppreference.com/w/cpp/language/template_specialization](https://en.cppreference.com/w/cpp/language/template_specialization)
|
||||
|
||||
[2a] cppreference.com, “显式(全)模板特化”. [https://zh.cppreference.com/w/cpp/language/template_specialization](https://zh.cppreference.com/w/cpp/language/template_specialization)
|
||||
|
||||
[3] Wikipedia, “Associative array”. [https://en.wikipedia.org/wiki/Associative_array](https://en.wikipedia.org/wiki/Associative_array)
|
||||
|
||||
[3a] 维基百科, “关联数组”. [https://zh.wikipedia.org/zh-cn/关联数组](https://zh.wikipedia.org/zh-cn/%E5%85%B3%E8%81%94%E6%95%B0%E7%BB%84)
|
||||
|
||||
[4] Wikipedia, “Weak ordering”. [https://en.wikipedia.org/wiki/Weak_ordering](https://en.wikipedia.org/wiki/Weak_ordering)
|
||||
|
||||
[5] Wikipedia, “Hash table”. [https://en.wikipedia.org/wiki/Hash_table](https://en.wikipedia.org/wiki/Hash_table)
|
||||
|
||||
[5a] 维基百科, “哈希表”. [https://zh.wikipedia.org/zh-cn/哈希表](https://zh.wikipedia.org/zh-cn/%E5%93%88%E5%B8%8C%E8%A1%A8)
|
||||
357
极客时间专栏/现代C++实战30讲/基础篇/06 | 异常:用还是不用,这是个问题.md
Normal file
357
极客时间专栏/现代C++实战30讲/基础篇/06 | 异常:用还是不用,这是个问题.md
Normal file
@@ -0,0 +1,357 @@
|
||||
<audio id="audio" title="06 | 异常:用还是不用,这是个问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/4f/abea4ae1ab39cce76ac8e7aad4a1864f.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
到现在为止,我们已经有好多次都提到异常了。今天,我们就来彻底地聊一聊异常。
|
||||
|
||||
首先,开宗明义,如果你不知道到底该不该用异常的话,那答案就是该用。如果你需要避免使用异常,原因必须是你有明确的需要避免使用异常的理由。
|
||||
|
||||
下面我们就开始说说异常。
|
||||
|
||||
## 没有异常的世界
|
||||
|
||||
我们先来看看没有异常的世界是什么样子的。最典型的情况就是 C 了。
|
||||
|
||||
假设我们要做一些矩阵的操作,定义了下面这个矩阵的数据结构:
|
||||
|
||||
```
|
||||
typedef struct {
|
||||
float* data;
|
||||
size_t nrows;
|
||||
size_t ncols;
|
||||
} matrix;
|
||||
|
||||
```
|
||||
|
||||
我们至少需要有初始化和清理的代码:
|
||||
|
||||
```
|
||||
enum matrix_err_code {
|
||||
MATRIX_SUCCESS,
|
||||
MATRIX_ERR_MEMORY_INSUFFICIENT,
|
||||
…
|
||||
};
|
||||
|
||||
int matrix_alloc(matrix* ptr,
|
||||
size_t nrows,
|
||||
size_t ncols)
|
||||
{
|
||||
size_t size =
|
||||
nrows * ncols * sizeof(float);
|
||||
float* data = malloc(size);
|
||||
if (data == NULL) {
|
||||
return MATRIX_ERR_MEMORY_INSUFFICIENT;
|
||||
}
|
||||
ptr->data = data;
|
||||
ptr->nrows = nrows;
|
||||
ptr->ncols = ncols;
|
||||
}
|
||||
|
||||
void matrix_dealloc(matrix* ptr)
|
||||
{
|
||||
if (ptr->data == NULL) {
|
||||
return;
|
||||
}
|
||||
free(ptr->data);
|
||||
ptr->data = NULL;
|
||||
ptr->nrows = 0;
|
||||
ptr->ncols = 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们做一下矩阵乘法吧。函数定义大概会是这个样子:
|
||||
|
||||
```
|
||||
int matrix_multiply(matrix* result,
|
||||
const matrix* lhs,
|
||||
const matrix* rhs)
|
||||
{
|
||||
int errcode;
|
||||
if (lhs->ncols != rhs->nrows) {
|
||||
return MATRIX_ERR_MISMATCHED_MATRIX_SIZE;
|
||||
// 呃,得把这个错误码添到 enum matrix_err_code 里
|
||||
}
|
||||
errcode = matrix_alloc(
|
||||
result, lhs->nrows, rhs->ncols);
|
||||
if (errcode != MATRIX_SUCCESS) {
|
||||
return errcode;
|
||||
}
|
||||
// 进行矩阵乘法运算
|
||||
return MATRIX_SUCCESS;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调用代码则大概是这个样子:
|
||||
|
||||
```
|
||||
matrix c;
|
||||
|
||||
// 不清零的话,错误处理和资源清理会更复杂
|
||||
memset(&c, 0, sizeof(matrix));
|
||||
|
||||
errcode = matrix_multiply(c, a, b);
|
||||
if (errcode != MATRIX_SUCCESS) {
|
||||
goto error_exit;
|
||||
}
|
||||
// 使用乘法的结果做其他处理
|
||||
|
||||
error_exit:
|
||||
matrix_dealloc(&c);
|
||||
return errcode;
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们有大量需要判断错误的代码,零散分布在代码各处。
|
||||
|
||||
可这是 C 啊。我们用 C++、不用异常可以吗?
|
||||
|
||||
当然可以,但你会发现结果好不了多少。毕竟,C++ 的构造函数是不能返回错误码的,所以你根本不能用构造函数来做可能出错的事情。你不得不定义一个只能清零的构造函数,再使用一个 `init` 函数来做真正的构造操作。C++ 虽然支持运算符重载,可你也不能使用,因为你没法返回一个新矩阵……
|
||||
|
||||
我上面还只展示了单层的函数调用。事实上,如果出错位置离处理错误的位置相差很远的话,每一层的函数调用里都得有判断错误码的代码,这就既对写代码的人提出了严格要求,也对读代码的人造成了视觉上的干扰……
|
||||
|
||||
## 使用异常
|
||||
|
||||
如果使用异常的话,我们就可以在构造函数里做真正的初始化工作了。假设我们的矩阵类有下列的数据成员:
|
||||
|
||||
```
|
||||
class matrix {
|
||||
…
|
||||
private:
|
||||
float* data_;
|
||||
size_t nrows_;
|
||||
size_t ncols_;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
构造函数我们可以这样写:
|
||||
|
||||
```
|
||||
matrix::matrix(size_t nrows,
|
||||
size_t ncols)
|
||||
{
|
||||
data_ = new float[nrows * ncols];
|
||||
nrows_ = nrows;
|
||||
ncols_ = ncols;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
析构非常简单:
|
||||
|
||||
```
|
||||
matrix::~matrix()
|
||||
{
|
||||
delete[] data_;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
乘法函数可以这样写:
|
||||
|
||||
```
|
||||
class matrix {
|
||||
…
|
||||
friend matrix
|
||||
operator*(const matrix&,
|
||||
const matrix&);
|
||||
};
|
||||
|
||||
matrix operator*(const matrix& lhs,
|
||||
const matrix& rhs)
|
||||
{
|
||||
if (lhs.ncols != rhs.nrows) {
|
||||
throw std::runtime_error(
|
||||
"matrix sizes mismatch");
|
||||
}
|
||||
matrix result(lhs.nrows, rhs.ncols);
|
||||
// 进行矩阵乘法运算
|
||||
return result;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用乘法的代码则更是简单:
|
||||
|
||||
```
|
||||
matrix c = a * b;
|
||||
|
||||
```
|
||||
|
||||
你可能已经非常疑惑了:错误处理在哪儿呢?只有一个 `throw`,跟前面的 C 代码能等价吗?
|
||||
|
||||
异常处理并不意味着需要写显式的 `try` 和 `catch`。**异常安全的代码,可以没有任何 `try` 和 `catch`。**
|
||||
|
||||
如果你不确定什么是“异常安全”,我们先来温习一下概念:异常安全是指当异常发生时,既不会发生资源泄漏,系统也不会处于一个不一致的状态。
|
||||
|
||||
我们看看可能会出现错误/异常的地方:
|
||||
|
||||
<li>
|
||||
首先是内存分配。如果 `new` 出错,按照 C++ 的规则,一般会得到异常 `bad_alloc`,对象的构造也就失败了。这种情况下,在 `catch` 捕捉到这个异常之前,所有的栈上对象会全部被析构,资源全部被自动清理。
|
||||
</li>
|
||||
<li>
|
||||
如果是矩阵的长宽不合适不能做乘法呢?我们同样会得到一个异常,这样,在使用乘法的地方,对象 `c` 根本不会被构造出来。
|
||||
</li>
|
||||
<li>
|
||||
如果在乘法函数里内存分配失败呢?一样,`result` 对象根本没有构造出来,也就没有 `c` 对象了。还是一切正常。
|
||||
</li>
|
||||
<li>
|
||||
如果 `a`、`b` 是本地变量,然后乘法失败了呢?析构函数会自动释放其空间,我们同样不会有任何资源泄漏。
|
||||
</li>
|
||||
|
||||
总而言之,只要我们适当地组织好代码、利用好 RAII,实现矩阵的代码和使用矩阵的代码都可以更短、更清晰。我们可以统一在外层某个地方处理异常——通常会记日志、或在界面上向用户报告错误了。
|
||||
|
||||
## 避免异常的风格指南?
|
||||
|
||||
但大名鼎鼎的 Google 的 C++ 风格指南不是说要避免异常吗 [1]?这又是怎么回事呢?
|
||||
|
||||
答案实际已经在 Google 的文档里了:
|
||||
|
||||
>
|
||||
Given that Google’s existing code is not exception-tolerant, the costs of using exceptions are somewhat greater than the costs in a new project. The conversion process would be slow and error-prone. We don’t believe that the available alternatives to exceptions, such as error codes and assertions, introduce a significant burden.
|
||||
Our advice against using exceptions is not predicated on philosophical or moral grounds, but practical ones. Because we’d like to use our open-source projects at Google and it’s difficult to do so if those projects use exceptions, we need to advise against exceptions in Google open-source projects as well. Things would probably be different if we had to do it all over again from scratch.
|
||||
|
||||
|
||||
我来翻译一下(我的加重):
|
||||
|
||||
>
|
||||
鉴于 Google 的现有代码不能承受异常,**使用异常的代价要比在全新的项目中使用异常大一些**。转换[代码来使用异常的]过程会缓慢而容易出错。我们不认为可代替异常的方法,如错误码或断言,会带来明显的负担。
|
||||
我们反对异常的建议并非出于哲学或道德的立场,而是出于实际考虑。因为我们希望在 Google 使用我们的开源项目,而如果这些项目使用异常的话就会对我们的使用带来困难,我们也需要反对在 Google 的开源项目中使用异常。**如果我们从头再来一次的话,事情可能就会不一样了。**
|
||||
|
||||
|
||||
这个如果还比较官方、委婉的话,Reddit 上还能找到一个更个人化的表述 [2]:
|
||||
|
||||
>
|
||||
I use [**sic**] to work at Google, and Craig Silverstein, who wrote the first draft of the style guideline, said that he regretted the ban on exceptions, but he had no choice; when he wrote it, it wasn’t only that the compiler they had at the time did a very bad job on exceptions, but that they already had a huge volume of non-exception-safe code.
|
||||
|
||||
|
||||
我的翻译(同样,我的加重):
|
||||
|
||||
>
|
||||
我过去在 Google 工作,写了风格指南初稿的 Craig Silverstein 说过**他对禁用异常感到遗憾**,但他当时别无选择。在他写风格指南的时候,不仅**他们使用的编译器在异常上工作得很糟糕**,而且**他们已经有了一大堆异常不安全的代码了**。
|
||||
|
||||
|
||||
当然,除了历史原因以外,也有出于性能等其他原因禁用异常的。美国国防部的联合攻击战斗机(JSF)项目的 C++ 编码规范就禁用异常,因为工具链不能保证抛出异常时的实时性能。不过在那种项目里,被禁用的 C++ 特性就多了,比如动态内存分配都不能使用。
|
||||
|
||||
一些游戏项目为了追求高性能,也禁用异常。这个实际上也有一定的历史原因,因为今天的主流 C++ 编译器,在异常关闭和开启时应该已经能够产生性能差不多的代码(在异常未抛出时)。代价是产生的二进制文件大小的增加,因为异常产生的位置决定了需要如何做栈展开,这些数据需要存储在表里。典型情况,使用异常和不使用异常比,二进制文件大小会有约百分之十到二十的上升。LLVM 项目的编码规范里就明确指出这是不使用 RTTI 和异常的原因 [3]:
|
||||
|
||||
>
|
||||
In an effort to reduce code and executable size, LLVM does not use RTTI (e.g. `dynamic_cast<>;`) or exceptions.
|
||||
|
||||
|
||||
我默默地瞅了眼我机器上 88MB 大小的单个 clang-9 可执行文件,对 Chris Lattner 的决定至少表示理解。但如果想跟这种项目比,你得想想是否值得这么去做。你的项目对二进制文件的大小和性能有这么渴求吗?需要这么去拼吗?
|
||||
|
||||
## 异常的问题
|
||||
|
||||
异常当然不是一个完美的特性,否则也不会招来这些批评和禁用了。对它的批评主要有两条:
|
||||
|
||||
- 异常违反了“你不用就不需要付出代价”的 C++ 原则。只要开启了异常,即使不使用异常你编译出的二进制代码通常也会膨胀。
|
||||
- 异常比较隐蔽,不容易看出来哪些地方会发生异常和发生什么异常。
|
||||
|
||||
对于第一条,开发者没有什么可做的。事实上,这也算是 C++ 实现的一个折中了。目前的主流异常实现中,都倾向于牺牲可执行文件大小、提高主流程(happy path)的性能。只要程序不抛异常,C++ 代码的性能比起完全不做错误检查的代码,都只有几个百分点的性能损失 [4]。除了非常有限的一些场景,可执行文件大小通常不会是个问题。
|
||||
|
||||
第二条可以算作是一个真正有效的批评。和 Java 不同,C++ 里不会对异常规约进行编译时的检查。从 C++17 开始,C++ 甚至完全禁止了以往的动态异常规约,你不再能在函数声明里写你可能会抛出某某异常。你唯一能声明的,就是某函数不会抛出异常——`noexcept`、`noexcept(true)` 或 `throw()`。这也是 C++ 的运行时唯一会检查的东西了。如果一个函数声明了不会抛出异常、结果却抛出了异常,C++ 运行时会调用 `std::terminate` 来终止应用程序。不管是程序员的声明,还是编译器的检查,都不会告诉你哪些函数会抛出哪些异常。
|
||||
|
||||
当然,不声明异常是有理由的。特别是在泛型编程的代码里,几乎不可能预知会发生些什么异常。我个人对避免异常带来的问题有几点建议:
|
||||
|
||||
1. 写异常安全的代码,尤其在模板里。可能的话,提供强异常安全保证 [5],在任何第三方代码发生异常的情况下,不改变对象的内容,也不产生任何资源泄漏。
|
||||
1. 如果你的代码可能抛出异常的话,在文档里明确声明可能发生的异常类型和发生条件。确保使用你的代码的人,能在不检查你的实现的情况,了解需要准备处理哪些异常。
|
||||
1. 对于肯定不会抛出异常的代码,将其标为 `noexcept`。注意类的特殊成员(构造函数、析构函数、赋值函数等)会自动成为 `noexcept`,如果它们调用的代码都是 `noexcept` 的话。所以,像 `swap` 这样的成员函数应当尽可能标成 `noexcept`。
|
||||
|
||||
## 使用异常的理由
|
||||
|
||||
虽然后面我们会描述到一些不使用异常、也不使用错误返回码的错误处理方式,但异常是渗透在 C++ 中的标准错误处理方式。标准库的错误处理方式就是异常。其中不仅包括运行时错误,甚至包括一些逻辑错误。比如,在说容器的时候,有一个我没提的地方是,在能使用 `[]` 运算符的地方,C++ 的标准容器也提供了 `at` 成员函数,能够在下标不存在的时候抛出异常,作为一种额外的帮助调试的手段。
|
||||
|
||||
```
|
||||
#include <iostream> // std::cout/endl
|
||||
#include <stdexcept> // std::out_of_range
|
||||
#include <vector> // std::vector
|
||||
using namespace std;
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
vector<int> v{1, 2, 3};
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
v[0]
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`1`
|
||||
|
||||
|
||||
```
|
||||
v.at(0)
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`1`
|
||||
|
||||
|
||||
```
|
||||
v[3]
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`-1342175236`
|
||||
|
||||
|
||||
```
|
||||
try {
|
||||
v.at(3);
|
||||
}
|
||||
catch (const out_of_range& e) {
|
||||
cerr << e.what() << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`_M_range_check: __n (which is 3) >= this->size() (which is 3)`
|
||||
|
||||
|
||||
C++ 的标准容器在大部分情况下提供了强异常保证,即,一旦异常发生,现场会恢复到调用函数之前的状态,容器的内容不会发生改变,也没有任何资源泄漏。前面提到过,`vector` 会在元素类型没有提供保证不抛异常的移动构造函数的情况下,在移动元素时会使用拷贝构造函数。这是因为一旦某个操作发生了异常,被移动的元素已经被破坏,处于只能析构的状态,异常安全性就不能得到保证了。
|
||||
|
||||
只要你使用了标准容器,不管你自己用不用异常,你都得处理标准容器可能引发的异常——至少有 `bad_alloc`,除非你明确知道你的目标运行环境不会产生这个异常。这对普通配置的 Linux 环境而言,倒确实是对的……这也算是 Google 这么规定的一个底气吧。
|
||||
|
||||
虽然对于运行时错误,开发者并没有什么选择余地;但对于代码中的逻辑错误,开发者则是可以选择不同的处理方式的:你可以使用异常,也可以使用 `assert`,在调试环境中报告错误并中断程序运行。由于测试通常不能覆盖所有的代码和分支,`assert` 在发布模式下一般被禁用,两者并不是完全的替代关系。在允许异常的情况下,使用异常可以获得在调试和发布模式下都良好、一致的效果。
|
||||
|
||||
标准 C++ 可能会产生哪些异常,可以查看参考资料 [6]。
|
||||
|
||||
## 内容小结
|
||||
|
||||
今天我们讨论了使用异常的理由和不使用异常的理由。希望通过本讲,你能够充分理解为什么异常是 C++ 委员会和很多大拿推荐的错误处理方式,并在可以使用异常的地方正确地使用异常这一方便的错误处理机制。
|
||||
|
||||
如果你还想进一步深入了解异常的话,可以仔细阅读一下参考资料 [4]。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你的 C++ 项目里使用异常吗?为什么?
|
||||
|
||||
欢迎留言和我交流你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Google, “Google C++ style guide”. [https://google.github.io/styleguide/cppguide.html#Exceptions](https://google.github.io/styleguide/cppguide.html#Exceptions)
|
||||
|
||||
[2] Reddit, Discussion on “Examples of C++ projects which embrace exceptions?”. [https://www.reddit.com/r/cpp/comments/4wkkge/examples_of_c_projects_which_embrace_exceptions/](https://www.reddit.com/r/cpp/comments/4wkkge/examples_of_c_projects_which_embrace_exceptions/)
|
||||
|
||||
[3] LLVM Project, “LLVM coding standards”. [https://llvm.org/docs/CodingStandards.html#do-not-use-rtti-or-exceptions](https://llvm.org/docs/CodingStandards.html#do-not-use-rtti-or-exceptions)
|
||||
|
||||
[4] Standard C++ Foundation, “FAQ—exceptions and error handling”. [https://isocpp.org/wiki/faq/exceptions](https://isocpp.org/wiki/faq/exceptions)
|
||||
|
||||
[5] cppreference.com, “Exceptions”. [https://en.cppreference.com/w/cpp/language/exceptions](https://en.cppreference.com/w/cpp/language/exceptions)
|
||||
|
||||
[5a] cppreference.com, “异常”. [https://zh.cppreference.com/w/cpp/language/exceptions](https://zh.cppreference.com/w/cpp/language/exceptions)
|
||||
|
||||
[6] cppreference.com, “std::exception”. [https://en.cppreference.com/w/cpp/error/exception](https://en.cppreference.com/w/cpp/error/exception)
|
||||
|
||||
[6a] cppreference.com, “std::exception”. [https://zh.cppreference.com/w/cpp/error/exception](https://zh.cppreference.com/w/cpp/error/exception)
|
||||
299
极客时间专栏/现代C++实战30讲/基础篇/07 | 迭代器和好用的新for循环.md
Normal file
299
极客时间专栏/现代C++实战30讲/基础篇/07 | 迭代器和好用的新for循环.md
Normal file
@@ -0,0 +1,299 @@
|
||||
<audio id="audio" title="07 | 迭代器和好用的新for循环" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0a/32/0add1a86ea70e4b24c519ca734100c32.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
我们已经讲过了容器。在使用容器的过程中,你也应该对迭代器(iterator)或多或少有了些了解。今天,我们就来系统地讲一下迭代器。
|
||||
|
||||
## 什么是迭代器?
|
||||
|
||||
迭代器是一个很通用的概念,并不是一个特定的类型。它实际上是一组对类型的要求([1])。它的最基本要求就是从一个端点出发,下一步、下一步地到达另一个端点。按照一般的中文习惯,也许“遍历”是比“迭代”更好的用词。我们可以遍历一个字符串的字符,遍历一个文件的内容,遍历目录里的所有文件,等等。这些都可以用迭代器来表达。
|
||||
|
||||
我在用 output_container.h 输出容器内容的时候,实际上就对容器的 `begin` 和 `end` 成员函数返回的对象类型提出了要求。假设前者返回的类型是 I,后者返回的类型是 S,这些要求是:
|
||||
|
||||
- I 对象支持 `*` 操作,解引用取得容器内的某个对象。
|
||||
- I 对象支持 `++`,指向下一个对象。
|
||||
- I 对象可以和 I 或 S 对象进行相等比较,判断是否遍历到了特定位置(在 S 的情况下是是否结束了遍历)。
|
||||
|
||||
注意在 C++17 之前,`begin` 和 `end` 返回的类型 I 和 S 必须是相同的。从 C++17 开始,I 和 S 可以是不同的类型。这带来了更大的灵活性和更多的优化可能性。
|
||||
|
||||
上面的类型 I,多多少少就是一个满足输入迭代器(input iterator)的类型了。不过,output_container.h 只使用了前置 `++`,但输入迭代器要求前置和后置 `++` 都得到支持。
|
||||
|
||||
输入迭代器不要求对同一迭代器可以多次使用 `*` 运算符,也不要求可以保存迭代器来重新遍历对象,换句话说,只要求可以单次访问。如果取消这些限制、允许多次访问的话,那迭代器同时满足了前向迭代器(forward iterator)。
|
||||
|
||||
一个前向迭代器的类型,如果同时支持 `--`(前置及后置),回到前一个对象,那它就是个双向迭代器(bidirectional iterator)。也就是说,可以正向遍历,也可以反向遍历。
|
||||
|
||||
一个双向迭代器,如果额外支持在整数类型上的 `+`、`-`、`+=`、`-=`,跳跃式地移动迭代器;支持 `[]`,数组式的下标访问;支持迭代器的大小比较(之前只要求相等比较);那它就是个随机访问迭代器(random-access iterator)。
|
||||
|
||||
一个随机访问迭代器 `i` 和一个整数 `n`,在 `*i` 可解引用且 `i + n` 是合法迭代器的前提下,如果额外还满足 `*(addressdof(*i) + n)` 等价于 `*(i + n)`,即保证迭代器指向的对象在内存里是连续存放的,那它(在 C++20 里)就是个连续迭代器(contiguous iterator)。
|
||||
|
||||
以上这些迭代器只考虑了读取。如果一个类型像输入迭代器,但 `*i` 只能作为左值来写而不能读,那它就是个输出迭代器(output iterator)。
|
||||
|
||||
而比输入迭代器和输出迭代器更底层的概念,就是迭代器了。基本要求是:
|
||||
|
||||
- 对象可以被拷贝构造、拷贝赋值和析构。
|
||||
- 对象支持 `*` 运算符。
|
||||
- 对象支持前置 `++` 运算符。
|
||||
|
||||
迭代器类型的关系可从下图中全部看到:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/5a/dd25c3f074fe0b792dddecfd15f74e5a.png" alt="">
|
||||
|
||||
迭代器通常是对象。但需要注意的是,指针可以满足上面所有的迭代器要求,因而也是迭代器。这应该并不让人惊讶,因为本来迭代器就是根据指针的特性,对其进行抽象的结果。事实上,`vector` 的迭代器,在很多实现里就直接是使用指针的。
|
||||
|
||||
## 常用迭代器
|
||||
|
||||
最常用的迭代器就是容器的 `iterator` 类型了。以我们学过的顺序容器为例,它们都定义了嵌套的 `iterator` 类型和 `const_iterator` 类型。一般而言,`iterator` 可写入,`const_iterator` 类型不可写入,但这些迭代器都被定义为输入迭代器或其派生类型:
|
||||
|
||||
- `vector::iterator` 和 `array::iterator` 可以满足到连续迭代器。
|
||||
- `deque::iterator` 可以满足到随机访问迭代器(记得它的内存只有部分连续)。
|
||||
- `list::iterator` 可以满足到双向迭代器(链表不能快速跳转)。
|
||||
- `forward_list::iterator` 可以满足到前向迭代器(单向链表不能反向遍历)。
|
||||
|
||||
很常见的一个输出迭代器是 `back_inserter` 返回的类型 `back_inserter_iterator` 了;用它我们可以很方便地在容器的尾部进行插入操作。另外一个常见的输出迭代器是 `ostream_iterator`,方便我们把容器内容“拷贝”到一个输出流。示例如下:
|
||||
|
||||
```
|
||||
#include <algorithm> // std::copy
|
||||
#include <iterator> // std::back_inserter
|
||||
#include <vector> // std::vector
|
||||
using namespace std;
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
vector<int> v1{1, 2, 3, 4, 5};
|
||||
vector<int> v2;
|
||||
copy(v1.begin(), v1.end(),
|
||||
back_inserter(v2));
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
v2
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`{ 1, 2, 3, 4, 5 }`
|
||||
|
||||
|
||||
```
|
||||
#include <iostream> // std::cout
|
||||
copy(v2.begin(), v2.end(),
|
||||
ostream_iterator<int>(cout, " "));
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`1 2 3 4 5`
|
||||
|
||||
|
||||
## 使用输入行迭代器
|
||||
|
||||
下面我们来看一下一个我写的输入迭代器。它的功能本身很简单,就是把一个输入流(`istream`)的内容一行行读进来。配上 C++11 引入的基于范围的 for 循环的语法,我们可以把遍历输入流的代码以一种自然、非过程式的方式写出来,如下所示:
|
||||
|
||||
```
|
||||
for (const string& line :
|
||||
istream_line_reader(is)) {
|
||||
// 示例循环体中仅进行简单输出
|
||||
cout << line << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以对比一下以传统的方式写的 C++ 代码,其中需要照顾不少细节:
|
||||
|
||||
```
|
||||
string line;
|
||||
for (;;) {
|
||||
getline(is, line);
|
||||
if (!is) {
|
||||
break;
|
||||
}
|
||||
cout << line << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从 `is` 读入输入行的逻辑,在前面的代码里一个语句就全部搞定了,在这儿用了 5 个语句……
|
||||
|
||||
我们后面会分析一下这个输入迭代器。在此之前,我先解说一下基于范围的 for 循环这个语法。虽然这可以说是个语法糖,但它对提高代码的可读性真的非常重要。如果不用这个语法糖的话,简洁性上的优势就小多了。我们直接把这个循环改写成等价的普通 for 循环的样子。
|
||||
|
||||
```
|
||||
{
|
||||
auto&& r = istream_line_reader(is);
|
||||
auto it = r.begin();
|
||||
auto end = r.end();
|
||||
for (; it != end; ++it) {
|
||||
const string& line = *it;
|
||||
cout << line << endl;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,它做的事情也不复杂,就是:
|
||||
|
||||
- 获取冒号后边的范围表达式的结果,并隐式产生一个引用,在整个循环期间都有效。注意根据生命期延长规则,表达式结果如果是临时对象的话,这个对象要在循环结束后才被销毁。
|
||||
- 自动生成遍历这个范围的迭代器。
|
||||
- 循环内自动生成根据冒号左边的声明和 `*it` 来进行初始化的语句。
|
||||
- 下面就是完全正常的循环体。
|
||||
|
||||
生成迭代器这一步有可能是——但不一定是——调用 `r` 的 `begin` 和 `end` 成员函数。具体规则是:
|
||||
|
||||
- 对于 C 数组(必须是没有退化为指针的情况),编译器会自动生成指向数组头尾的指针(相当于自动应用可用于数组的 `std::begin` 和 `std::end` 函数)。
|
||||
- 对于有 `begin` 和 `end` 成员的对象,编译器会调用其 `begin` 和 `end` 成员函数(我们目前的情况)。
|
||||
- 否则,编译器会尝试在 `r` 对象所在的名空间寻找可以用于 `r` 的 `begin` 和 `end` 函数,并调用 `begin(r)` 和 `end(r)`;找不到的话则失败报错。
|
||||
|
||||
## 定义输入行迭代器
|
||||
|
||||
下面我们看一下,要实现这个输入行迭代器,需要做些什么工作。
|
||||
|
||||
C++ 里有些固定的类型要求规范。对于一个迭代器,我们需要定义下面的类型:
|
||||
|
||||
```
|
||||
class istream_line_reader {
|
||||
public:
|
||||
class iterator { // 实现 InputIterator
|
||||
public:
|
||||
typedef ptrdiff_t difference_type;
|
||||
typedef string value_type;
|
||||
typedef const value_type* pointer;
|
||||
typedef const value_type& reference;
|
||||
typedef input_iterator_tag
|
||||
iterator_category;
|
||||
…
|
||||
};
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
仿照一般的容器,我们把迭代器定义为 `istream_line_reader` 的嵌套类。它里面的这五个类型是必须定义的(其他泛型 C++ 代码可能会用到这五个类型;之前标准库定义了一个可以继承的类模板 `std::iterator` 来产生这些类型定义,但这个类目前已经被废弃 [2])。其中:
|
||||
|
||||
- `difference_type` 是代表迭代器之间距离的类型,定义为 `ptrdiff_t` 只是种标准做法(指针间差值的类型),对这个类型没什么特别作用。
|
||||
- `value_type` 是迭代器指向的对象的值类型,我们使用 `string`,表示迭代器指向的是字符串。
|
||||
- `pointer` 是迭代器指向的对象的指针类型,这儿就平淡无奇地定义为 `value_type` 的常指针了(我们可不希望别人来更改指针指向的内容)。
|
||||
- 类似的,`reference` 是 `value_type` 的常引用。
|
||||
- `iterator_category` 被定义为 `input_iterator_tag`,标识这个迭代器的类型是 input iterator(输入迭代器)。
|
||||
|
||||
作为一个真的只能读一次的输入迭代器,有个特殊的麻烦(前向迭代器或其衍生类型没有):到底应该让 `*` 负责读取还是 `++` 负责读取。我们这儿采用常见、也较为简单的做法,让 `++` 负责读取,`*` 负责返回读取的内容(这个做法会有些副作用,但按我们目前的用法则没有问题)。这样的话,这个 `iterator` 类需要有一个数据成员指向输入流,一个数据成员来存放读取的结果。根据这个思路,我们定义这个类的基本成员函数和数据成员:
|
||||
|
||||
```
|
||||
class istream_line_reader {
|
||||
public:
|
||||
class iterator {
|
||||
…
|
||||
iterator() noexcept
|
||||
: stream_(nullptr) {}
|
||||
explicit iterator(istream& is)
|
||||
: stream_(&is)
|
||||
{
|
||||
++*this;
|
||||
}
|
||||
|
||||
reference operator*() const noexcept
|
||||
{
|
||||
return line_;
|
||||
}
|
||||
pointer operator->() const noexcept
|
||||
{
|
||||
return &line_;
|
||||
}
|
||||
iterator& operator++()
|
||||
{
|
||||
getline(*stream_, line_);
|
||||
if (!*stream_) {
|
||||
stream_ = nullptr;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
iterator operator++(int)
|
||||
{
|
||||
iterator temp(*this);
|
||||
++*this;
|
||||
return temp;
|
||||
}
|
||||
|
||||
private:
|
||||
istream* stream_;
|
||||
string line_;
|
||||
};
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
我们定义了默认构造函数,将 `stream_` 清空;相应的,在带参数的构造函数里,我们根据传入的输入流来设置 `stream_`。我们也定义了 `*` 和 `->` 运算符来取得迭代器指向的文本行的引用和指针,并用 `++` 来读取输入流的内容(后置 `++` 则以惯常方式使用前置 `++` 和拷贝构造来实现)。唯一“特别”点的地方,是我们在构造函数里调用了 `++`,确保在构造后调用 `*` 运算符时可以读取内容,符合日常先使用 `*`、再使用 `++` 的习惯。一旦文件读取到尾部(或出错),则 `stream_` 被清空,回到默认构造的情况。
|
||||
|
||||
对于迭代器之间的比较,我们则主要考虑文件有没有读到尾部的情况,简单定义为:
|
||||
|
||||
```
|
||||
bool operator==(const iterator& rhs)
|
||||
const noexcept
|
||||
{
|
||||
return stream_ == rhs.stream_;
|
||||
}
|
||||
bool operator!=(const iterator& rhs)
|
||||
const noexcept
|
||||
{
|
||||
return !operator==(rhs);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了这个 `iterator` 的定义后,`istream_line_reader` 的定义就简单得很了:
|
||||
|
||||
```
|
||||
class istream_line_reader {
|
||||
public:
|
||||
class iterator {…};
|
||||
istream_line_reader() noexcept
|
||||
: stream_(nullptr) {}
|
||||
explicit istream_line_reader(
|
||||
istream& is) noexcept
|
||||
: stream_(&is) {}
|
||||
iterator begin()
|
||||
{
|
||||
return iterator(*stream_);
|
||||
}
|
||||
iterator end() const noexcept
|
||||
{
|
||||
return iterator();
|
||||
}
|
||||
|
||||
private:
|
||||
istream* stream_;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
也就是说,构造函数只是简单地把输入流的指针赋给 `stream_` 成员变量。`begin` 成员函数则负责构造一个真正有意义的迭代器;`end` 成员函数则只是返回一个默认构造的迭代器而已。
|
||||
|
||||
以上就是一个完整的基于输入流的行迭代器了。这个行输入模板的设计动机和性能测试结果可参见参考资料 [3] 和 [4];完整的工程可用代码,请参见参考资料 [5]。该项目中还提供了利用 C 文件接口的 file_line_reader 和基于内存映射文件的 mmap_line_reader。
|
||||
|
||||
## 内容小结
|
||||
|
||||
今天我们介绍了所有的迭代器类型,并介绍了基于范围的 for 循环。随后,我们介绍了一个实际的输入迭代器工具,并用它来简化从输入流中读入文本行这一常见操作。最后,我们展示了这个输入迭代器的定义。
|
||||
|
||||
## 课后思考
|
||||
|
||||
请思考一下:
|
||||
|
||||
1. 目前这个输入行迭代器的行为,在什么情况下可能导致意料之外的后果?
|
||||
1. 请尝试一下改进这个输入行迭代器,看看能不能消除这种意外。如果可以,该怎么做?如果不可以,为什么?
|
||||
|
||||
欢迎留言和我交流你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] cppreference.com, “Iterator library”. [https://en.cppreference.com/w/cpp/iterator](https://en.cppreference.com/w/cpp/iterator)
|
||||
|
||||
[1a] cppreference.com, “迭代器库”. [https://zh.cppreference.com/w/cpp/iterator](https://zh.cppreference.com/w/cpp/iterator)
|
||||
|
||||
[2] Jonathan Boccara, “std::iterator is deprecated: why, what it was, and what to use instead”. [https://www.fluentcpp.com/2018/05/08/std-iterator-deprecated/](https://www.fluentcpp.com/2018/05/08/std-iterator-deprecated/)
|
||||
|
||||
[3] 吴咏炜, “Python `yield` and C++ coroutines”. [https://yongweiwu.wordpress.com/2016/08/16/python-yield-and-cplusplus-coroutines/](https://yongweiwu.wordpress.com/2016/08/16/python-yield-and-cplusplus-coroutines/)
|
||||
|
||||
[4] 吴咏炜, “Performance of my line readers”. [https://yongweiwu.wordpress.com/2016/11/12/performance-of-my-line-readers/](https://yongweiwu.wordpress.com/2016/11/12/performance-of-my-line-readers/)
|
||||
|
||||
[5] 吴咏炜, nvwa. [https://github.com/adah1972/nvwa/](https://github.com/adah1972/nvwa/)
|
||||
427
极客时间专栏/现代C++实战30讲/基础篇/08 | 易用性改进 I:自动类型推断和初始化.md
Normal file
427
极客时间专栏/现代C++实战30讲/基础篇/08 | 易用性改进 I:自动类型推断和初始化.md
Normal file
@@ -0,0 +1,427 @@
|
||||
<audio id="audio" title="08 | 易用性改进 I:自动类型推断和初始化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/f8/9b96c63e2ea13807ae9e7e56b40a0bf8.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
在之前的几讲里,我们已经多多少少接触到了一些 C++11 以来增加的新特性。下面的两讲,我会重点讲一下现代 C++(C++11/14/17)带来的易用性改进。
|
||||
|
||||
就像我们 [[开篇词]](https://time.geekbang.org/column/article/169177) 中说的,我们主要是介绍 C++ 里好用的特性,而非让你死记规则。因此,这里讲到的内容,有时是一种简化的说法。对于日常使用,本讲介绍的应该能满足大部分的需求。对于复杂用法和边角情况,你可能还是需要查阅参考资料里的明细规则。
|
||||
|
||||
## 自动类型推断
|
||||
|
||||
如果要挑选 C++11 带来的最重大改变的话,自动类型推断肯定排名前三。如果只看易用性或表达能力的改进的话,那它就是“舍我其谁”的第一了。
|
||||
|
||||
### auto
|
||||
|
||||
自动类型推断,顾名思义,就是编译器能够根据表达式的类型,自动决定变量的类型(从 C++14 开始,还有函数的返回类型),不再需要程序员手工声明([1])。但需要说明的是,`auto` 并没有改变 C++ 是静态类型语言这一事实——使用 `auto` 的变量(或函数返回值)的类型仍然是编译时就确定了,只不过编译器能自动帮你填充而已。
|
||||
|
||||
自动类型推断使得像下面这样累赘的表达式成为历史:
|
||||
|
||||
```
|
||||
// vector<int> v;
|
||||
for (vector<int>::iterator
|
||||
it = v.begin(),
|
||||
end = v.end();
|
||||
it != end; ++it) {
|
||||
// 循环体
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在我们可以直接写(当然,是不使用基于范围的 for 循环的情况):
|
||||
|
||||
```
|
||||
for (auto it = v.begin(), end = v.end();
|
||||
it != end; ++it) {
|
||||
// 循环体
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不使用自动类型推断时,如果容器类型未知的话,我们还需要加上 `typename`(注意此处 const 引用还要求我们写 `const_iterator` 作为迭代器的类型):
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
void foo(const T& container)
|
||||
{
|
||||
for (typename T::const_iterator
|
||||
it = v.begin(),
|
||||
…
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果 `begin` 返回的类型不是该类型的 `const_iterator` 嵌套类型的话,那实际上不用自动类型推断就没法表达了。这还真不是假设。比如,如果我们的遍历函数要求支持 C 数组的话,不用自动类型推断的话,就只能使用两个不同的重载:
|
||||
|
||||
```
|
||||
template <typename T, std::size_t N>
|
||||
void foo(const T (&a)[N])
|
||||
{
|
||||
typedef const T* ptr_t;
|
||||
for (ptr_t it = a, end = a + N;
|
||||
it != end; ++it) {
|
||||
// 循环体
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void foo(const T& c)
|
||||
{
|
||||
for (typename T::const_iterator
|
||||
it = c.begin(),
|
||||
end = c.end();
|
||||
it != end; ++it) {
|
||||
// 循环体
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果使用自动类型推断的话,再加上 C++11 提供的全局 `begin` 和 `end` 函数,上面的代码可以统一成:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
void foo(const T& c)
|
||||
{
|
||||
using std::begin;
|
||||
using std::end;
|
||||
// 使用依赖参数查找(ADL);见 [2]
|
||||
for (auto it = begin(c),
|
||||
ite = end(c);
|
||||
it != ite; ++it) {
|
||||
// 循环体
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这个例子可见,自动类型推断不仅降低了代码的啰嗦程度,也提高了代码的抽象性,使我们可以用更少的代码写出通用的功能。
|
||||
|
||||
`auto` 实际使用的规则类似于函数模板参数的推导规则([3])。当你写了一个含 `auto` 的表达式时,相当于把 `auto` 替换为模板参数的结果。举具体的例子:
|
||||
|
||||
- `auto a = expr;` 意味着用 `expr` 去匹配一个假想的 `template <typename T> f(T)` 函数模板,结果为值类型。
|
||||
- `const auto& a = expr;` 意味着用 `expr` 去匹配一个假想的 `template <typename T> f(const T&)` 函数模板,结果为常左值引用类型。
|
||||
- `auto&& a = expr;` 意味着用 `expr` 去匹配一个假想的 `template <typename T> f(T&&)` 函数模板,根据[[第 3 讲]](https://time.geekbang.org/column/article/169268) 中我们讨论过的转发引用和引用坍缩规则,结果是一个跟 `expr` 值类别相同的引用类型。
|
||||
|
||||
### decltype
|
||||
|
||||
`decltype` 的用途是获得一个表达式的类型,结果可以跟类型一样使用。它有两个基本用法:
|
||||
|
||||
- `decltype(变量名)` 可以获得变量的精确类型。
|
||||
- `decltype(表达式)` (表达式不是变量名,但包括 `decltype((变量名))` 的情况)可以获得表达式的引用类型;除非表达式的结果是个纯右值(prvalue),此时结果仍然是值类型。
|
||||
|
||||
如果我们有 `int a;`,那么:
|
||||
|
||||
- `decltype(a)` 会获得 `int`(因为 `a` 是 `int`)。
|
||||
- `decltype((a))` 会获得 `int&`(因为 `a` 是 lvalue)。
|
||||
- `decltype(a + a)` 会获得 `int`(因为 `a + a` 是 prvalue)。
|
||||
|
||||
### decltype(auto)
|
||||
|
||||
通常情况下,能写 `auto` 来声明变量肯定是件比较轻松的事。但这儿有个限制,你需要在写下 `auto` 时就决定你写下的是个引用类型还是值类型。根据类型推导规则,`auto` 是值类型,`auto&` 是左值引用类型,`auto&&` 是转发引用(可以是左值引用,也可以是右值引用)。使用 `auto` 不能通用地根据表达式类型来决定返回值的类型。不过,`decltype(expr)` 既可以是值类型,也可以是引用类型。因此,我们可以这么写:
|
||||
|
||||
```
|
||||
decltype(expr) a = expr;
|
||||
|
||||
```
|
||||
|
||||
这种写法明显不能让人满意,特别是表达式很长的情况(而且,任何代码重复都是潜在的问题)。为此,C++14 引入了 `decltype(auto)` 语法。对于上面的情况,我们只需要像下面这样写就行了。
|
||||
|
||||
```
|
||||
decltype(auto) a = expr;
|
||||
|
||||
```
|
||||
|
||||
这种代码主要用在通用的转发函数模板中:你可能根本不知道你调用的函数是不是会返回一个引用。这时使用这种语法就会方便很多。
|
||||
|
||||
### 函数返回值类型推断
|
||||
|
||||
从 C++14 开始,函数的返回值也可以用 `auto` 或 `decltype(auto)` 来声明了。同样的,用 `auto` 可以得到值类型,用 `auto&` 或 `auto&&` 可以得到引用类型;而用 `decltype(auto)` 可以根据返回表达式通用地决定返回的是值类型还是引用类型。
|
||||
|
||||
和这个形式相关的有另外一个语法,后置返回值类型声明。严格来说,这不算“类型推断”,不过我们也放在一起讲吧。它的形式是这个样子:
|
||||
|
||||
```
|
||||
auto foo(参数) -> 返回值类型声明
|
||||
{
|
||||
// 函数体
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通常,在返回类型比较复杂、特别是返回类型跟参数类型有某种推导关系时会使用这种语法。以后我们会讲到一些实例。今天暂时不多讲了。
|
||||
|
||||
### 类模板的模板参数推导
|
||||
|
||||
如果你用过 `pair` 的话,一般都不会使用下面这种形式:
|
||||
|
||||
```
|
||||
pair<int, int> pr{1, 42};
|
||||
|
||||
```
|
||||
|
||||
使用 `make_pair` 显然更容易一些:
|
||||
|
||||
```
|
||||
auto pr = make_pair(1, 42);
|
||||
|
||||
```
|
||||
|
||||
这是因为函数模板有模板参数推导,使得调用者不必手工指定参数类型;但 C++17 之前的类模板却没有这个功能,也因而催生了像 `make_pair` 这样的工具函数。
|
||||
|
||||
在进入了 C++17 的世界后,这类函数变得不必要了。现在我们可以直接写:
|
||||
|
||||
```
|
||||
pair pr{1, 42};
|
||||
|
||||
```
|
||||
|
||||
生活一下子变得简单多了!
|
||||
|
||||
在初次见到 `array` 时,我觉得它的主要缺点就是不能像 C 数组一样自动从初始化列表来推断数组的大小了:
|
||||
|
||||
```
|
||||
int a1[] = {1, 2, 3};
|
||||
array<int, 3> a2{1, 2, 3}; // 啰嗦
|
||||
// array<int> a3{1, 2, 3}; 不行
|
||||
|
||||
```
|
||||
|
||||
这个问题在 C++17 里也是基本不存在的。虽然不能只提供一个模板参数,但你可以两个参数全都不写 🤣:
|
||||
|
||||
```
|
||||
array a{1, 2, 3};
|
||||
// 得到 array<int, 3>
|
||||
|
||||
```
|
||||
|
||||
这种自动推导机制,可以是编译器根据构造函数来自动生成:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
struct MyObj {
|
||||
MyObj(T value);
|
||||
…
|
||||
};
|
||||
|
||||
MyObj obj1{string("hello")};
|
||||
// 得到 MyObj<string>
|
||||
MyObj obj2{"hello"};
|
||||
// 得到 MyObj<const char*>
|
||||
|
||||
```
|
||||
|
||||
也可以是手工提供一个推导向导,达到自己需要的效果:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
struct MyObj {
|
||||
MyObj(T value);
|
||||
…
|
||||
};
|
||||
|
||||
MyObj(const char*) -> MyObj<string>;
|
||||
|
||||
MyObj obj{"hello"};
|
||||
// 得到 MyObj<string>
|
||||
|
||||
```
|
||||
|
||||
更多的技术细节请参见参考资料 [4]。
|
||||
|
||||
### 结构化绑定
|
||||
|
||||
在讲关联容器的时候我们有过这样一个例子:
|
||||
|
||||
```
|
||||
multimap<string, int>::iterator
|
||||
lower, upper;
|
||||
std::tie(lower, upper) =
|
||||
mmp.equal_range("four");
|
||||
|
||||
```
|
||||
|
||||
这个例子里,返回值是个 `pair`,我们希望用两个变量来接收数值,就不得不声明了两个变量,然后使用 `tie` 来接收结果。在 C++11/14 里,这里是没法使用 `auto` 的。好在 C++17 引入了一个新语法,解决了这个问题。目前,我们可以把上面的代码简化为:
|
||||
|
||||
```
|
||||
auto [lower, upper] =
|
||||
mmp.equal_range("four");
|
||||
|
||||
```
|
||||
|
||||
这个语法使得我们可以用 `auto` 声明变量来分别获取 `pair` 或 `tuple` 返回值里各个子项,可以让代码的可读性更好。
|
||||
|
||||
关于这个语法的更多技术说明,请参见参考资料 [5]。
|
||||
|
||||
## 列表初始化
|
||||
|
||||
在 C++98 里,标准容器比起 C 风格数组至少有一个明显的劣势:不能在代码里方便地初始化容器的内容。比如,对于数组你可以写:
|
||||
|
||||
```
|
||||
int a[] = {1, 2, 3, 4, 5};
|
||||
|
||||
```
|
||||
|
||||
而对于 `vector` 你却得写:
|
||||
|
||||
```
|
||||
vector<int> v;
|
||||
v.push(1);
|
||||
v.push(2);
|
||||
v.push(3);
|
||||
v.push(4);
|
||||
v.push(5);
|
||||
|
||||
```
|
||||
|
||||
这样真是又啰嗦,性能又差,显然无法让人满意。于是,C++ 标准委员会引入了列表初始化,允许以更简单的方式来初始化对象。现在我们初始化容器也可以和初始化数组一样简单了:
|
||||
|
||||
```
|
||||
vector<int> v{1, 2, 3, 4, 5};
|
||||
|
||||
```
|
||||
|
||||
同样重要的是,这不是对标准库容器的特殊魔法,而是一个通用的、可以用于各种类的方法。从技术角度,编译器的魔法只是对 `{1, 2, 3}` 这样的表达式自动生成一个初始化列表,在这个例子里其类型是 `initializer_list<int>`。程序员只需要声明一个接受 `initializer_list` 的构造函数即可使用。从效率的角度,至少在动态对象的情况下,容器和数组也并无二致,都是通过拷贝(构造)进行初始化。
|
||||
|
||||
对于初始化列表在构造函数外的用法和更多的技术细节,请参见参考资料 [6]。
|
||||
|
||||
## 统一初始化
|
||||
|
||||
你可能已经注意到了,我在代码里使用了大括号 `{}` 来进行对象的初始化。这当然也是 C++11 引入的新语法,能够代替很多小括号 `()` 在变量初始化时使用。这被称为统一初始化(uniform initialization)。
|
||||
|
||||
大括号对于构造一个对象而言,最大的好处是避免了 C++ 里“最令人恼火的语法分析”(the most vexing parse)。我也遇到过。假设你有一个类,原型如下:
|
||||
|
||||
```
|
||||
class utf8_to_wstring {
|
||||
public:
|
||||
utf8_to_wstring(const char*);
|
||||
operator wchar_t*();
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
然后你在 Windows 下想使用这个类来帮助转换文件名,打开文件:
|
||||
|
||||
```
|
||||
ifstream ifs(
|
||||
utf8_to_wstring(filename));
|
||||
|
||||
```
|
||||
|
||||
你随后就会发现,`ifs` 的行为无论如何都不正常。最后,要么你自己查到,要么有人告诉你,上面这个写法会被编译器认为是和下面的写法等价的:
|
||||
|
||||
```
|
||||
ifstream ifs(
|
||||
utf8_to_wstring filename);
|
||||
|
||||
```
|
||||
|
||||
换句话说,编译器认为你是声明了一个叫 `ifs` 的函数,而不是对象!
|
||||
|
||||
如果你把任何一对小括号替换成大括号(或者都替换,如下),则可以避免此类问题:
|
||||
|
||||
```
|
||||
ifstream ifs{
|
||||
utf8_to_wstring{filename}};
|
||||
|
||||
```
|
||||
|
||||
推而广之,你几乎可以在所有初始化对象的地方使用大括号而不是小括号。它还有一个附带的特点:当一个构造函数没有标成 `explicit` 时,你可以使用大括号不写类名来进行构造,如果调用上下文要求那类对象的话。如:
|
||||
|
||||
```
|
||||
Obj getObj()
|
||||
{
|
||||
return {1.0};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果 Obj 类可以使用浮点数进行构造的话,上面的写法就是合法的。如果有无参数、多参数的构造函数,也可以使用这个形式。除了形式上的区别,它跟 `Obj(1.0)` 的主要区别是,后者可以用来调用 `Obj(int)`,而使用大括号时编译器会拒绝“窄”转换,不接受以 `{1.0}` 或 `Obj{1.0}` 的形式调用构造函数 `Obj(int)`。
|
||||
|
||||
这个语法主要的限制是,如果一个类既有使用初始化列表的构造函数,又有不使用初始化列表的构造函数,那编译器会**千方百计**地试图调用使用初始化列表的构造函数,导致各种意外。所以,如果给一个推荐的话,那就是:
|
||||
|
||||
- 如果一个类没有使用初始化列表的构造函数时,初始化该类对象可全部使用统一初始化语法。
|
||||
- 如果一个类有使用初始化列表的构造函数时,则只应用在初始化列表构造的情况。
|
||||
|
||||
关于这个语法的更多详细用法讨论,请参见参考资料 [7]。
|
||||
|
||||
## 类数据成员的默认初始化
|
||||
|
||||
按照 C++98 的语法,数据成员可以在构造函数里进行初始化。这本身不是问题,但实践中,如果数据成员比较多、构造函数又有多个的话,逐个去初始化是个累赘,并且很容易在增加数据成员时漏掉在某个构造函数中进行初始化。为此,C++11 增加了一个语法,允许在声明数据成员时直接给予一个初始化表达式。这样,当且仅当构造函数的初始化列表中不包含该数据成员时,这个数据成员就会自动使用初始化表达式进行初始化。
|
||||
|
||||
这个句子有点长。我们看个例子:
|
||||
|
||||
```
|
||||
class Complex {
|
||||
public:
|
||||
Complex()
|
||||
: re_(0) , im_(0) {}
|
||||
Complex(float re)
|
||||
: re_(re), im_(0) {}
|
||||
Complex(float re, float im)
|
||||
: re_(re) , im_(im) {}
|
||||
…
|
||||
|
||||
private:
|
||||
float re_;
|
||||
float im_;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
假设由于某种原因,我们不能使用缺省参数来简化构造函数,我们可以用什么方式来优化上面这个代码呢?
|
||||
|
||||
使用数据成员的默认初始化的话,我们就可以这么写:
|
||||
|
||||
```
|
||||
class Complex {
|
||||
public:
|
||||
Complex() {}
|
||||
Complex(float re) : re_(re) {}
|
||||
Complex(float re, float im)
|
||||
: re_(re) , im_(im) {}
|
||||
|
||||
private:
|
||||
float re_{0};
|
||||
float im_{0};
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
第一个构造函数没有任何初始化列表,所以类数据成员的初始化全部由默认初始化完成,`re_` 和 `im_` 都是 0。第二个构造函数提供了 `re_` 的初始化,`im_` 仍由默认初始化完成。第三个构造函数则完全不使用默认初始化。
|
||||
|
||||
## 内容小结
|
||||
|
||||
在本讲中,我们介绍了现代 C++ 引入的几个易用性改进:自动类型推断,初始化列表,及类数据成员的默认初始化。使用这些特性非常简单,可以立即简化你的 C++ 代码,而不会引入额外的开销。唯一的要求只是你不要再使用那些上古时代的老掉牙编译器了……
|
||||
|
||||
## 课后思考
|
||||
|
||||
你使用过现代 C++ 的这些特性了吗?如果还没有的话,哪些特性你打算在下一个项目里开始使用?
|
||||
|
||||
欢迎留言来分享你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] cppreference.com, “Placeholder type specifiers”. [https://en.cppreference.com/w/cpp/language/auto](https://en.cppreference.com/w/cpp/language/auto)
|
||||
|
||||
[1a] cppreference.com, “占位符类型说明符”. [https://zh.cppreference.com/w/cpp/language/auto](https://zh.cppreference.com/w/cpp/language/auto)
|
||||
|
||||
[2] Wikipedia, “Argument-dependent name lookup”. [https://en.wikipedia.org/wiki/Argument-dependent_name_lookup](https://en.wikipedia.org/wiki/Argument-dependent_name_lookup)
|
||||
|
||||
[2a] 维基百科, “依赖于实参的名字查找”. [https://zh.wikipedia.org/zh-cn/依赖于实参的名字查找](https://zh.wikipedia.org/zh-cn/%E4%BE%9D%E8%B5%96%E4%BA%8E%E5%AE%9E%E5%8F%82%E7%9A%84%E5%90%8D%E5%AD%97%E6%9F%A5%E6%89%BE)
|
||||
|
||||
[3] cppreference.com, “Template argument deduction”. [https://en.cppreference.com/w/cpp/language/template_argument_deduction](https://en.cppreference.com/w/cpp/language/template_argument_deduction)
|
||||
|
||||
[3a] cppreference.com, “模板实参推导”. [https://zh.cppreference.com/w/cpp/language/template_argument_deduction](https://zh.cppreference.com/w/cpp/language/template_argument_deduction)
|
||||
|
||||
[4] cppreference.com, “Class template argument deduction”. [https://en.cppreference.com/w/cpp/language/class_template_argument_deduction](https://en.cppreference.com/w/cpp/language/class_template_argument_deduction)
|
||||
|
||||
[4a] cppreference.com, “类模板实参推导”. [https://zh.cppreference.com/w/cpp/language/class_template_argument_deduction](https://zh.cppreference.com/w/cpp/language/class_template_argument_deduction)
|
||||
|
||||
[5] cppreference.com, “Structured binding declaration”. [https://en.cppreference.com/w/cpp/language/structured_binding](https://en.cppreference.com/w/cpp/language/structured_binding)
|
||||
|
||||
[5a] cppreference.com, “结构化绑定声明”. [https://zh.cppreference.com/w/cpp/language/structured_binding](https://zh.cppreference.com/w/cpp/language/structured_binding)
|
||||
|
||||
[6] cppreference.com, “std::initializer_list”. [https://en.cppreference.com/w/cpp/utility/initializer_list](https://en.cppreference.com/w/cpp/utility/initializer_list)
|
||||
|
||||
[6a] cppreference.com, “std::initializer_list”. [https://en.cppreference.com/w/cpp/utility/initializer_list](https://en.cppreference.com/w/cpp/utility/initializer_list)
|
||||
|
||||
[7] Scott Meyers, **Effective Modern C++**, item 7. O’Reilly Media, 2014. 有中文版(高博译,中国电力出版社,2018 年)
|
||||
345
极客时间专栏/现代C++实战30讲/基础篇/09 | 易用性改进 II:字面量、静态断言和成员函数说明符.md
Normal file
345
极客时间专栏/现代C++实战30讲/基础篇/09 | 易用性改进 II:字面量、静态断言和成员函数说明符.md
Normal file
@@ -0,0 +1,345 @@
|
||||
<audio id="audio" title="09 | 易用性改进 II:字面量、静态断言和成员函数说明符" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/cb/63290510c554cb7ef0c7b3c9333b9fcb.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
本讲我们继续易用性的话题,看看现代 C++ 带来的其他易用性改进。
|
||||
|
||||
## 自定义字面量
|
||||
|
||||
字面量(literal)是指在源代码中写出的固定常量,它们在 C++98 里只能是原生类型,如:
|
||||
|
||||
- `"hello"`,字符串字面量,类型是 `const char[6]`
|
||||
- `1`,整数字面量,类型是 `int`
|
||||
- `0.0`,浮点数字面量,类型是 `double`
|
||||
- `3.14f`,浮点数字面量,类型是 `float`
|
||||
- `123456789ul`,无符号长整数字面量,类型是 `unsigned long`
|
||||
|
||||
C++11 引入了自定义字面量,可以使用 `operator"" 后缀` 来将用户提供的字面量转换成实际的类型。C++14 则在标准库中加入了不少标准字面量。下面这个程序展示了它们的用法:
|
||||
|
||||
```
|
||||
#include <chrono>
|
||||
#include <complex>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main()
|
||||
{
|
||||
cout << "i * i = " << 1i * 1i
|
||||
<< endl;
|
||||
cout << "Waiting for 500ms"
|
||||
<< endl;
|
||||
this_thread::sleep_for(500ms);
|
||||
cout << "Hello world"s.substr(0, 5)
|
||||
<< endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出是:
|
||||
|
||||
>
|
||||
<p>`i * i = (-1,0)`<br>
|
||||
`Waiting for 500ms`<br>
|
||||
`Hello`</p>
|
||||
|
||||
|
||||
上面这个例子展示了 C++ 标准里提供的帮助生成虚数、时间和 `basic_string` 字面量的后缀。一个需要注意的地方是,我在上面使用了 `using namespace std`,这会同时引入 `std` 名空间和里面的内联名空间(inline namespace),包括了上面的字面量运算符所在的三个名空间:
|
||||
|
||||
- `std::literals::complex_literals`
|
||||
- `std::literals::chrono_literals`
|
||||
- `std::literals::string_literals`
|
||||
|
||||
在产品项目中,一般不会(也不应该)全局使用 `using namespace std`(不过,为节约篇幅起见,专栏里的很多例子,特别是不完整的例子,还是默认使用了 `using namespace std`)。这种情况下,应当在使用到这些字面量的作用域里导入需要的名空间,以免发生冲突。在类似上面的例子里,就是在函数体的开头写:
|
||||
|
||||
```
|
||||
using namespace std::literals::
|
||||
chrono_literals;
|
||||
|
||||
```
|
||||
|
||||
等等。
|
||||
|
||||
要在自己的类里支持字面量也相当容易,唯一的限制是非标准的字面量后缀必须以下划线 `_` 打头。比如,假如我们有下面的长度类:
|
||||
|
||||
```
|
||||
struct length {
|
||||
double value;
|
||||
enum unit {
|
||||
metre,
|
||||
kilometre,
|
||||
millimetre,
|
||||
centimetre,
|
||||
inch,
|
||||
foot,
|
||||
yard,
|
||||
mile,
|
||||
};
|
||||
static constexpr double factors[] =
|
||||
{1.0, 1000.0, 1e-3,
|
||||
1e-2, 0.0254, 0.3048,
|
||||
0.9144, 1609.344};
|
||||
explicit length(double v,
|
||||
unit u = metre)
|
||||
{
|
||||
value = v * factors[u];
|
||||
}
|
||||
};
|
||||
|
||||
length operator+(length lhs,
|
||||
length rhs)
|
||||
{
|
||||
return length(lhs.value +
|
||||
rhs.value);
|
||||
}
|
||||
|
||||
// 可能有其他运算符
|
||||
|
||||
```
|
||||
|
||||
我们可以手写 `length(1.0, length::metre)` 这样的表达式,但估计大部分开发人员都不愿意这么做吧。反过来,如果我们让开发人员这么写,大家应该还是基本乐意的:
|
||||
|
||||
```
|
||||
1.0_m + 10.0_cm
|
||||
|
||||
```
|
||||
|
||||
要允许上面这个表达式,我们只需要提供下面的运算符即可:
|
||||
|
||||
```
|
||||
length operator"" _m(long double v)
|
||||
{
|
||||
return length(v, length::metre);
|
||||
}
|
||||
|
||||
length operator"" _cm(long double v)
|
||||
{
|
||||
return length(v, length::centimetre);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果美国国家航空航天局采用了类似的系统的话,火星气候探测者号的事故也许就不会发生了 [1]。当然,历史无法重来,而且 C++ 引入这样的语法已经是在事故发生之后十多年了……
|
||||
|
||||
关于自定义字面量的进一步技术细节,请参阅参考资料 [2]。
|
||||
|
||||
## 二进制字面量
|
||||
|
||||
你一定知道 C++ 里有 `0x` 前缀,可以让开发人员直接写出像 `0xFF` 这样的十六进制字面量。另外一个目前使用得稍少的前缀就是 `0` 后面直接跟 0–7 的数字,表示八进制的字面量,在跟文件系统打交道的时候还会经常用到:有经验的 Unix 程序员可能会觉得 `chmod(path, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)` 并不比 `chmod(path, 0644)` 更为直观。从 C++14 开始,我们对于二进制也有了直接的字面量:
|
||||
|
||||
```
|
||||
unsigned mask = 0b111000000;
|
||||
|
||||
```
|
||||
|
||||
这在需要比特级操作等场合还是非常有用的。
|
||||
|
||||
不过,遗憾的是, I/O streams 里只有 `dec`、`hex`、`oct` 三个操纵器(manipulator),而没有 `bin`,因而输出一个二进制数不能像十进制、十六进制、八进制那么直接。一个间接方式是使用 `bitset`,但调用者需要手工指定二进制位数:
|
||||
|
||||
```
|
||||
#include <bitset>
|
||||
cout << bitset<9>(mask) << endl;
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`111000000`
|
||||
|
||||
|
||||
## 数字分隔符
|
||||
|
||||
数字长了之后,看清位数就变得麻烦了。有了二进制字面量,这个问题变得分外明显。C++14 开始,允许在数字型字面量中任意添加 `'` 来使其更可读。具体怎么添加,完全由程序员根据实际情况进行约定。某些常见的情况可能会是:
|
||||
|
||||
- 十进制数字使用三位的分隔,对应英文习惯的 thousand、million 等单位。
|
||||
- 十进制数字使用四位的分隔,对应中文习惯的万、亿等单位。
|
||||
- 十六进制数字使用两位或四位的分隔,对应字节或双字节。
|
||||
- 二进制数字使用三位的分隔,对应文件系统的权限分组。
|
||||
- 等等。
|
||||
|
||||
一些实际例子如下:
|
||||
|
||||
```
|
||||
unsigned mask = 0b111'000'000;
|
||||
long r_earth_equatorial = 6'378'137;
|
||||
double pi = 3.14159'26535'89793;
|
||||
const unsigned magic = 0x44'42'47'4E;
|
||||
|
||||
```
|
||||
|
||||
## 静态断言
|
||||
|
||||
C++98 的 `assert` 允许在运行时检查一个函数的前置条件是否成立。没有一种方法允许开发人员在编译的时候检查假设是否成立。比如,如果模板有个参数 `alignment`,表示对齐,那我们最好在编译时就检查 `alignment` 是不是二的整数次幂。之前人们用了一些模板技巧来达到这个目的,但输出的信息并不那么友善。比如,我之前使用的方法,会产生类似下面这样的输出:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/a5/59fa97e766d6c363ae23546e211774a5.png" alt="">
|
||||
|
||||
能起作用,但不够直观。C++11 直接从语言层面提供了静态断言机制,不仅能输出更好的信息,而且适用性也更好,可以直接放在类的定义中,而不像之前用的特殊技巧只能放在函数体里。对于类似上面的情况,现在的输出是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/48/37c7f1703b6c2d09137ccf3a5065be48.png" alt="">
|
||||
|
||||
静态断言语法上非常简单,就是:
|
||||
|
||||
```
|
||||
static_assert(编译期条件表达式,
|
||||
可选输出信息);
|
||||
|
||||
```
|
||||
|
||||
产生上面的示例错误信息的代码是:
|
||||
|
||||
```
|
||||
static_assert((alignment & (alignment - 1)) == 0,
|
||||
"Alignment must be power of two");
|
||||
|
||||
```
|
||||
|
||||
## default 和 delete 成员函数
|
||||
|
||||
在类的定义时,C++ 有一些规则决定是否生成默认的特殊成员函数。这些特殊成员函数可能包括:
|
||||
|
||||
- 默认构造函数
|
||||
- 析构函数
|
||||
- 拷贝构造函数
|
||||
- 拷贝赋值函数
|
||||
- 移动构造函数
|
||||
- 移动赋值函数
|
||||
|
||||
生成这些特殊成员函数(或不生成)的规则比较复杂,感兴趣的话你可以查看参考资料 [3]。每个特殊成员函数有几种不同的状态:
|
||||
|
||||
- 隐式声明还是用户声明
|
||||
- 默认提供还是用户提供
|
||||
- 正常状态还是删除状态
|
||||
|
||||
这三个状态是可组合的,虽然不是所有的组合都有效。隐式声明的必然是默认提供的;默认提供的才可能被删除;用户提供的也必然是用户声明的。
|
||||
|
||||
如果成员和父类没有特殊原因导致对象不可拷贝或移动,在用户不声明这些成员函数的情况下,编译器会自动产生这些成员函数,即隐式声明、默认提供、正常状态。有特殊成员、用户声明的话,情况就非常复杂了:
|
||||
|
||||
- 没有初始化的非静态 const 数据成员和引用类型数据成员会导致默认提供的默认构造函数被删除。
|
||||
- 非静态的 const 数据成员和引用类型数据成员会导致默认提供的拷贝构造函数、拷贝赋值函数、移动构造函数和移动赋值函数被删除。
|
||||
- 用户如果没有自己提供一个拷贝构造函数(必须形如 `Obj(Obj&)` 或 `Obj(const Obj&)`;不是模板),编译器会隐式声明一个。
|
||||
- 用户如果没有自己提供一个拷贝赋值函数(必须形如 `Obj& operator=(Obj&)` 或 `Obj& operator=(const Obj&)`;不是模板),编译器会隐式声明一个。
|
||||
- 用户如果自己声明了一个移动构造函数或移动赋值函数,则默认提供的拷贝构造函数和拷贝赋值函数被删除。
|
||||
- 用户如果没有自己声明拷贝构造函数、拷贝赋值函数、移动赋值函数和析构函数,编译器会隐式声明一个移动构造函数。
|
||||
- 用户如果没有自己声明拷贝构造函数、拷贝赋值函数、移动构造函数和析构函数,编译器会隐式声明一个移动赋值函数。
|
||||
- ……
|
||||
|
||||
我不鼓励你去死记硬背这些规则,而是希望你在项目和测试中体会其缘由。我认为这些规则还相当合理,虽然有略偏保守之嫌。尤其是关于移动构造和赋值:只要用户声明了另外的特殊成员函数中的任何一个,编译器就不默认提供了。不过嘛,缺省慢点总比缺省不安全要好……
|
||||
|
||||
我们这儿主要要说的是,我们可以改变缺省行为,在编译器能默认提供特殊成员函数时将其删除,或在编译器不默认提供特殊成员函数时明确声明其需要默认提供(不过,要注意,即使用户要求默认提供,编译器也可能根据其他规则将特殊成员函数标为删除)。
|
||||
|
||||
还是举例子来说明一下。对于下面这样的类,编译器看到有用户提供的构造函数,就会不默认提供默认构造函数:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
class my_array {
|
||||
public:
|
||||
my_array(size_t size);
|
||||
…
|
||||
private:
|
||||
T* data_{nullptr};
|
||||
size_t size_{0};
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在没有默认初始化时,我们如果需要默认构造函数,就需要手工写一个,如:
|
||||
|
||||
```
|
||||
my_array()
|
||||
: data_(nullptr)
|
||||
, size_(0) {}
|
||||
|
||||
```
|
||||
|
||||
可有了默认初始化之后,这个构造函数显然就不必要了,所以我们现在可以写:
|
||||
|
||||
```
|
||||
my_array() = default;
|
||||
|
||||
```
|
||||
|
||||
再来一个反向的例子。我们[[第 1 讲]](https://time.geekbang.org/column/article/169225) 里的 `shape_wrapper`,它的复制行为是不安全的。我们可以像[[第 2 讲]](https://time.geekbang.org/column/article/169263) 里一样去改进它,但如果正常情况不需要复制行为、只是想防止其他开发人员误操作时,我们可以简单地在类的定义中加入:
|
||||
|
||||
```
|
||||
class shape_wrapper {
|
||||
…
|
||||
shape_wrapper(
|
||||
const shape_wrapper&) = delete;
|
||||
shape_wrapper& operator=(
|
||||
const shape_wrapper&) = delete;
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在 C++11 之前,我们可能会用在 `private` 段里声明这些成员函数的方法,来达到相似的目的。但目前这个语法效果更好,可以产生更明确的错误信息。另外,你可以注意一下,用户声明成删除也是一种声明,因此编译器不会提供默认版本的移动构造和移动赋值函数。
|
||||
|
||||
## override 和 final 说明符
|
||||
|
||||
`override` 和 `final` 是两个 C++11 引入的新说明符。它们不是关键词,仅在出现在函数声明尾部时起作用,不影响我们使用这两个词作变量名等其他用途。这两个说明符可以单个或组合使用,都是加在类成员函数声明的尾部。
|
||||
|
||||
`override` 显式声明了成员函数是一个虚函数且覆盖了基类中的该函数。如果有 `override` 声明的函数不是虚函数,或基类中不存在这个虚函数,编译器会报告错误。这个说明符的主要作用有两个:
|
||||
|
||||
- 给开发人员更明确的提示,这个函数覆写了基类的成员函数;
|
||||
- 让编译器进行额外的检查,防止程序员由于拼写错误或代码改动没有让基类和派生类中的成员函数名称完全一致。
|
||||
|
||||
`final` 则声明了成员函数是一个虚函数,且该虚函数不可在派生类中被覆盖。如果有一点没有得到满足的话,编译器就会报错。
|
||||
|
||||
`final` 还有一个作用是标志某个类或结构不可被派生。同样,这时应将其放在被定义的类或结构名后面。
|
||||
|
||||
用法示意如下:
|
||||
|
||||
```
|
||||
class A {
|
||||
public:
|
||||
virtual void foo();
|
||||
virtual void bar();
|
||||
void foobar();
|
||||
};
|
||||
|
||||
class B : public A {
|
||||
public:
|
||||
void foo() override; // OK
|
||||
void bar() override final; // OK
|
||||
//void foobar() override;
|
||||
// 非虚函数不能 override
|
||||
};
|
||||
|
||||
class C final : public B {
|
||||
public:
|
||||
void foo() override; // OK
|
||||
//void bar() override;
|
||||
// final 函数不可 override
|
||||
};
|
||||
|
||||
class D : public C {
|
||||
// 错误:final 类不可派生
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 内容小结
|
||||
|
||||
今天我们介绍了现代 C++ 引入的另外几个易用性改进:自定义字面量,二进制字面量,数字分隔符,静态断言,default 和 delete 成员函数,及 override 和 final。同上一讲介绍的易用性改进一样,这些新功能可以改进代码的可读性,同时也不会带来额外的开销。在任何有条件使用满足新 C++ 标准的编译器的项目中,都应该考虑使用这些新特性。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你最喜欢的 C++ 易用性改进是什么?为什么?
|
||||
|
||||
欢迎留言和我分享你的看法!
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Wikipedia, “**Mars Climate Orbiter**”. [https://en.wikipedia.org/wiki/Mars_Climate_Orbiter](https://en.wikipedia.org/wiki/Mars_Climate_Orbiter)
|
||||
|
||||
[1a] 维基百科, “火星气候探测者号”. [https://zh.wikipedia.org/zh-cn/火星氣候探測者號](https://zh.wikipedia.org/zh-cn/%E7%81%AB%E6%98%9F%E6%B0%A3%E5%80%99%E6%8E%A2%E6%B8%AC%E8%80%85%E8%99%9F)
|
||||
|
||||
[2] cppreference.com, “User-defined literals”. [https://en.cppreference.com/w/cpp/language/user_literal](https://en.cppreference.com/w/cpp/language/user_literal)
|
||||
|
||||
[2a] cppreference.com, “用户定义字面量”. [https://zh.cppreference.com/w/cpp/language/user_literal](https://zh.cppreference.com/w/cpp/language/user_literal)
|
||||
|
||||
[3] cppreference.com, “Non-static member functions”, section “Special member functions”. [https://en.cppreference.com/w/cpp/language/member_functions](https://en.cppreference.com/w/cpp/language/member_functions)
|
||||
|
||||
[3a] cppreference.com, “非静态成员函数”, “特殊成员函数”部分. [https://zh.cppreference.com/w/cpp/language/member_functions](https://zh.cppreference.com/w/cpp/language/member_functions)
|
||||
Reference in New Issue
Block a user