(说明:本章内容讲的主要是 c++11 标准相对于之前的标准新增加的内容。本书作者是 c++ 之父 Bjarne Stroustrup,这位作者的行文风格就是站在c++的设计者角度进行讲解,内容极其丰富,但并没有像传统编程书籍那样事无具细地罗列知识点,而是抓要点进行讲解,让你能够明白很多本质的东西。读者应当注意的是,作者的风格像是在和读者聊天,在聊天过程中透露他的要点。读者应注意作者的每一段描述,其中都蕴含了知识要点和设计思想,一定要认真研读,不可认为是闲言碎语略过。)
C++概观:并发及实用工具
(A Tour of C++: Concurrency and Utilities)
目录
5.5 正则表达式(Regular Expressions)
5.1 引言
从最终用户的角度来看,理想的标准库是能够直接提供支持几乎所有需求的组件。对于既有的应用领域,大型商业库可以接近这一理想状态。然而,这并不是 C++ 标准库试图做的事情。一个可管理的、普遍可用的库不可能满足所有人的需求。相反,C++ 标准库旨在提供对大多数应用领域中的大多数人有用的组件。也就是说,它旨在满足所有需求的交集,而非它们的并集。此外,对一些广泛且重要的应用领域的支持(如数学计算和文本处理)也已悄然出现。
5.2 资源管理
任何非平凡程序的关键任务之一就是管理资源。资源是必须获取并在之后(显式或隐式)释放的东西。例如内存、锁、套接字、线程句柄和文件句柄、等等。对于长期运行的程序,未能及时释放资源(“泄漏(a leak)”)可能会导致严重的性能下降,甚至可能导致严重的崩溃。即使对于短时运行程序,泄漏也会成为一种尴尬,例如资源短缺会使运行时间增加几个数量级。
标准库组件的设计目标是不泄漏资源。为此,它们依赖于基本语言对资源管理的支持,使用成对的构造函数/析构函数来确保资源不会比负责它的对象存活得更久。在 Vector 中使用成对构造函数/析构函数来管理其元素的生命周期就是一个例子(§3.2.1.2),所有标准库容器都以类似的方式实现。重要的是,这种方法可以与使用异常的错误处理正确交互。例如,该技术用于标准库锁类:
#include <mutex>
mutex m; // 用于保持对共享资源的访问
// ...
void f()
{
unique_lock<mutex> lck {m}; // 独占方式获得互斥锁m
// ... 操作共享数据(读写等) ...
} //隐式释放互斥锁
在 lck 的构造函数获得其互斥锁 m(§5.3.4)之前,线程不会继续运行。相应的析构函数会释放资源。因此,在此示例中,unique_lock 的析构函数会在控制线程离开 f() 时(通过返回(译注:return语句)、通过“脱离函数末尾(译注:遇到函数尾的’}’符号)”或通过抛出异常)释放互斥锁。(译注:作者的意思是说,在多线程环境下,当系统执行到 unique_lock<mutex> lck {m}; 这一步时,线程会被阻止,直到该线程获得锁返回。一般比较好的做法是,在进入多线程运行之前就创建青互斥锁,在使用的时候用完即释放,而销毁时放在多线程运行完成之后统一销毁。)
这是“资源获取即初始化”技术 (RAII;§3.2.1.2、§13.3) 的一个应用。该技术是 C++ 中惯用资源处理的基础。容器(如 vector 和 map)、字符串和 iostream 以类似的方式管理其资源(如文件句柄和缓冲区)。
5.2.1 unique_ptr和shared_ptr
到目前为止的示例都处理了在作用域中定义的对象,并在离开作用域时释放它们获取的资源,但是分配在自由存储中的对象(译注:主要是指存储在堆中的对象,而非存储在栈中的对象)又该如何处理呢?在<memory>中,标准库提供了两个“智能指针”来帮助管理自由存储中的对象:
[1] unique_ptr:表示独占所有权(§34.3.1)。也就是说,unique_ptr对象在析构时有义务销毁其所存储的自由存储的指针。该对象不能复制(没有复制构造函数和复制赋值函数),但可以移动,因此它只能被一个对象占有。
[2] shared_ptr:表示共享所有权(§34.3.2)。它可以复制,可以被多个对象共享。注意,shared_ptr共享所有权,但并不等于在多线程环境下是安全的,区别于独占指针unique_ptr只是因为应用场景不同。
这些“智能指针”最基本的用途是防止因编程粗心而导致的内存泄漏。
例如:
void f(int i, int j) // X* vs. unique_ptr<X>
{
X* p = new X; // allocate a new X
unique_ptr<X> sp {new X}; // allocate a new X and give its pointer to unique_ptr
// ...
if (i<99) throw Z{}; // may throw an exception
if (j<77) return; // may retur n "ear ly"
p−>do_something(); // may throw an exception
sp−>do_something(); // may throw an exception
// ...
delete p; // destroy *p
}
这里,如果 i<99 或 j<77,我们“忘记”删除 p。另一方面,unique_ptr 确保无论我们以何种方式退出 f()(通过抛出异常、通过执行 return 或“从末尾掉回”),其对象都会被正确销毁。具有讽刺意味的是,我们可以通过不使用指针和不使用 new 来解决这个问题:
void f(int i, int j) // 使用局部变量(译注:局部变量使用栈存储,因此在退出函数之前系统负责调用类的析构函数)
{
X x;
// ...
}
不幸的是,过度使用 new(以及指针和引用)似乎是一个日益严重的问题。
但是,当您真正需要指针的语义时,unique_ptr 是一种非常轻量级的机制,与正确使用内置指针相比,它没有空间或时间开销。它的进一步用途包括将自由存储分配的对象传入和传出函数:
unique_ptr<X> make_X(int i)
// 创建一个 X 并立即将其交给一个 unique_ptr 指针
{
// ... check i, etc. ...
return unique_ptr<X>{new X{i}};
}
unique_ptr 是指向单个对象(或数组)的句柄,其方式与 vector 是指向对象序列的句柄非常相似。两者都控制其他对象的生命周期(使用 RAII),并且都依赖移动语义来使返回变得简单而高效。
shared_ptr 与 unique_ptr 类似,不同之处在于 shared_ptr 是复制的而不是移动的(译注:移动对象比复制对象代价小很多)。一个对象的 shared_ptr 共享一个对象的所有权,并且当最后一个 shared_ptr 被销毁时,该对象也会被销毁。例如:
void f(shared_ptr<fstream>);
void g(shared_ptr<fstream>);
void user(const string& name, ios_base::openmode mode)
{
shared_ptr<fstream> fp {new fstream(name ,mode)};
if (!*fp) throw No_file{}; //确保恰当地打开文件
f(fp);
g(fp);
// ...
}
现在,fp 的构造函数打开的文件将被最后一个函数关闭,以(显式或隐式)销毁 fp 的副本。请注意,f() 或 g() 可能会生成一个持有 fp 副本的任务,或者以其他方式存储比 user() 存活时间更长的副本。因此,shared_ptr 提供了一种垃圾收集形式,它尊重基于析构函数的内存管理对象的资源管理。这既不免费,也不昂贵,但确实使共享对象的生命周期难以预测。请确保你仅在您确实需要共享所有权时才使用 shared_ptr。
有了 unique_ptr 和 shared_ptr,我们可以为许多程序实现完整的“无裸new”策略(§3.2.1.2)。但是,这些“智能指针”在概念上仍然是指针,因此只是我的资源管理的第二选择——位居在容器和其他在更高概念级别管理其资源的类型之后。特别是,shared_ptr 本身并不提供任何规则来规定哪些所有者可以读取和/或写入共享对象。数据竞争(§41.2.4)和其他形式的混乱不能简单地通过消除资源管理问题来解决。
我们在哪里使用“智能指针”(例如unique_ptr)而不是使用具有专门为资源设计的操作的资源句柄(例如向量或线程)?毫不奇怪,答案是“当我们需要指针语义时”。
- 当我们共享一个对象时,我们需要指针(或引用)来引用该共享对象,因此shared_ptr 成为显而易见的选择(除非有明显的单一所有者)。
- 当我们引用一个多态对象时(译注:即派生类对象,作为基类对象参数传递或其它调用方式隐藏了其具体派生类型),我们需要一个指针(或引用),因为我们不知道所引用对象的确切类型,甚至不知道其大小),因此 unique_ptr 成为显而易见的选择。
- 共享多态对象通常需要 shared_ptr。
我们不需要使用指针从函数返回对象集合;如果需要从函数返回对象集合,使用带资源句柄的容器是最佳选择,它简单而有效地完成此操作(§3.3.2)。
5.3 并发
并发性(同时执行多个任务)被广泛用于提高吞吐量(通过使用多个处理器进行单次计算)或提高响应能力(允许程序的一部分继续运行,而另一部分则等待响应)。所有现代编程语言都支持这一点。C++ 标准库提供的支持是 C++ 中使用了 20 多年的可移植且类型安全的变体,几乎得到现代硬件的普遍支持。标准库支持主要旨在支持系统层并发,而不是直接提供复杂的更上层并发性模型;这些模型可以作为使用标准库工具构建的库额外提供。
标准库直接支持在单个地址空间中并发执行多个线程。为此,C++ 提供了合适的内存模型(§41.2)和一组原子操作(§41.3)。但是,大多数用户只会从标准库和在此基础上构建的库的角度来看待并发。本节简要介绍了主要标准库并发支持工具的示例:线程、互斥锁、lock() 操作、packaged_tasks 和 Future。这些功能直接基于操作系统提供的功能构建,与操作系统相比不会产生性能损失。
5.3.1 任务和线程封闭类thread
我们将可能与其他计算并发执行的计算称为任务。线程是程序中任务的系统层表示。通过构造 std::thread(位于 <thread> 中)并以任务为参数来启动要与其他任务并发执行的任务。任务是一个函数或函数对象:
void f(); // function
struct F { // function object
void operator()(); // F’s call operator (§3.4.3)
};
void user()
{
thread t1 {f}; // f() 在单位线程中执行
thread t2 {F()}; // F()()在单位线程中执行
t1.join(); // 启动线程,等待 t1 完成
t2.join(); //启动线程,等待 t2 完成
}
join() 确保我们在线程完成之前不会退出 user()。“join” 意味着“等待线程终止”。(译注:调用join ()即启动线程运行,并阻止当前线程继续执行,直到调用的任务完成。)
程序的线程共享单个地址空间。在这方面,线程与进程不同,进程通常不直接共享数据。由于线程共享地址空间,因此它们可以通过共享对象进行通信(§5.3.4)。此类通信通常由锁或其他机制控制,以防止数据争用(对共享变量的不受控制的并发访问)。
编写并发任务可能非常棘手。考虑任务 f (函数)和 F(函数对象)的可能实现:
void f() { cout << "Hello "; }
struct F {
void operator()() { cout << "Parallel World!\n"; }
};
这是一个严重错误的例子:这里,f 和 F () 各自使用对象 cout,没有任何形式的同步(译注:cout对象是独占资源,如果在并发环境使用,需采用并发保持机制;用户可能在不同的线程中调用上述两个函数)。由于两个任务中各个操作的执行顺序未定义,因此产生的输出将不可预测,并且可能在程序的不同执行之间发生变化。程序可能会产生“奇怪”的输出,例如
PaHerallllel o World!
在定义并发程序的任务时,我们的目标是使任务完全独立,除非它们以简单而明显的方式进行通信。最简单的思考并发任务的方式是将其视为与其调用者同时运行的函数。为此,我们只需传递参数,返回结果,并确保其间不使用共享数据(无数据争用)。
5.3.2 应用thread时传递参数
通常,任务需要数据才能完成。我们可以轻松地将数据(或指向数据的指针或引用)作为参数传递。考虑:
void f(vector<double>& v); // 用 v 的数据完成某事
struct F { // function object: do something with v
vector<double>& v;
F(vector<double>& vv) :v{vv} { }
void operator()(); //应用运算符 ; §3.4.3
};
int main()
{
vector<double> some_vec {1,2,3,4,5,6,7,8,9};
vector<double> vec2 {10,11,12,13,14};
thread t1 {f,some_vec}; // f(some_vec) 在单独线程运行
thread t2 {F{vec2}}; // F(vec2)()在单独线程运行
t1.join(); // 注意,在同一线程中,t1 运行完成了才运行 t2 。
t2.join();
}
显然,F{vec2} 保存了对 F 中参数向量的引用。F 现在可以使用该数组,并且希望在 F 执行时没有其他任务访问 vec2。通过值传递 vec2 将消除这种风险。
使用 {f,some_vec} 进行初始化使用了线程可变参数模板构造函数,该构造函数可以接受任意参数序列(§28.6)。编译器检查是否可以在已知的下列参数的情况下调用第一个参数,并构建必要的函数对象以传递给线程。因此,如果 F::operator()() 和 f() 执行相同的算法,则这两个任务的处理大致相同:在这两种情况下,都会为线程构建一个要执行的函数对象。
5.3.3 通过非常量引用返回结果
在 §5.3.2 中的示例中,我通过非常量引用传递参数。只有当我预计任务会修改所引用数据的值时,我才会这样做(§7.7)。这是一种返回结果的方式,有点狡猾,但并不少见(译注:即传引用可以看作一种返回值方式)。一种不那么晦涩的技术是通过常量引用传递输入数据,并将存放结果的位置作为单独的参数传递:
void f(const vector<double>& v, double* res);// 从 v中取值;在 *res 中放置结果
class F {
public:
F(const vector<double>& vv, double∗ p) :v{vv}, res{p} { }
void operator()(); // 在 *res 中放置结果
private:
const vector<double>& v; // 输入源
double* res; //输出目标
};
int main()
{
vector<double> some_vec;
vector<double> vec2;
// ...
double res1;
double res2;
thread t1 {f,some_vec,&res1}; // f(some_vec,&res1) 在一个单独线程中执行
thread t2 {F{vec2,&res2}}; // F{vec2,&res2}() 在一个单独线程中执行
t1.join();
t2.join();
cout << res1 << ' ' << res2 << '\n';
}
我认为通过参数返回结果并不是特别优雅,因此我在 §5.3.5.1 节中再涉此主题。
5.3.4 访问共享数据
有时任务需要访问共享数据。在这种情况下,必须同步访问,以便一次最多只有一个任务可以访问。经验丰富的程序员会认识到这是一种简化(例如,许多任务同时读取不可变数据没有问题),但请考虑(在并发环境下或多线程环境下),如何确保一次最多只有一个任务可以访问已知的一组对象集。
该解决方案的基本要素是使用mutex,即“互斥对象”。线程使用 lock()(译注:或相关函数,例如,trylock())获取互斥锁:
mutex m; // 临界区互斥锁
int sh; // 共享数据
void f()
{
unique_lock<mutex> lck {m}; // 取得 mutex
sh += 7; // 操作共享数据
} //隐式释放互斥锁
unique_lock 的构造函数获取互斥锁(通过调用 m.lock())。如果另一个线程已经获取了互斥锁,则该线程将等待(“阻塞”),直到另一个线程完成其访问。一旦线程完成对共享数据的访问,unique_lock 就会释放互斥锁(通过调用 m.unlock())。互斥和锁定功能可在 <mutex> 中找到。
将共享数据和互斥锁对应是常规做法:程序员只需知道哪个互斥锁应该对应哪个共享数据。显然,这很易错的(error-prone),同样明显的是,我们试图通过各种语言手段使对应关系清晰。例如:
class Record {
public:
mutex rm;
// ...
};
不需要天才就能猜到,对于一个Record对象 rec,rec.rm 是一个互斥锁,您应该在访问 rec 的其他数据之前获得它(尽管注释或更好的名称可能会对读者有所帮助,在此我任意取个名)。
需要同时访问多个资源来执行某些操作的情况并不少见,这可能会导致死锁。例如,如果thread1 已取得互mutex1,然后尝试获取mutex2,面thread2 已经获得mutex2,然后尝试获取mutex1,则两个任务都无法继续进行。标准库以同时获取多个锁的操作形式提供帮助:
void f()
{
// ...
unique_lock<mutex> lck1 {m1,defer_lock}; // 延迟锁定(defer_lock): 不试图获取锁
unique_lock<mutex> lck2 {m2,defer_lock};
unique_lock<mutex> lck3 {m3,defer_lock};
// ...
lock(lck1,lck2,lck3); //同时获得三个锁
// ... 操作共享数据 ...
} // 隐式翻译所有互斥锁
此 lock() 仅在获取其所有互斥量参数后才会继续,并且在持有互斥量时绝不会阻塞(“进入休眠状态”)。各个 unique_locks 的析构函数确保当线程离开范围时释放互斥量。
通过共享数据进行通信是相当低级的。特别是,程序员必须想办法了解各种任务已经完成和尚未完成的工作。在这方面,共享数据的使用不如调用和返回的概念。在另一方面,有些人确信共享一定比复制参数和返回更有效率。当涉及大量数据时,确实如此,但锁定和解锁是相对昂贵的操作。在另一方面,现代机器非常擅长复制数据,尤其是紧致数据,例如向量元素。因此,不要因为“效率”而不假思索地选择共享数据进行通信,最好不要不加衡量。
5.3.4.1 等待事件
有时,线程需要等待某种外部事件,例如另一个thread正在完成任务或已经经过一定时间还未完成。最简单的“事件”就是经历的时间。考虑一下:
using namespace std::chrono; // see §35.2
auto t0 = high_resolution_clock::now();
this_thread::sleep_for(milliseconds{20});
auto t1 = high_resolution_clock::now();
cout << duration_cast<nanoseconds>(t1−t0).count() << " nanoseconds passed\n";
请注意,我甚至不必启动一个thread;默认情况下,this_thread 指的是唯一的线程(§42.2.6)。
我使用 duration_cast 将时钟的单位调整为我想要的纳秒。在尝试任何比此更复杂的时间操作之前,请参阅 §5.4.1 节和 §35.2节。时间工具位于 <chrono> 中。
<condition_variable> (§42.3.4) 中的 condition_variables 提供了使用外部事件进行通信的基本支持。condition_variable 是一种允许一个线程等待另一个线程的机制。具体来说,它允许一个线程等待某些条件(通常称为事件——其它线程工作完全的结果)发生。
考虑两个thread通过queue传递消息进行通信的经典案例。为简单起见,我将queue和为避免该队列上的竞争条件的机制声明为对生产者和消费者全局的变量:
class Message { // 通信对象
// ...
};
queue<Message> mqueue; // 消息队列
condition_variable mcond; // 通信事件条件变量
mutex mmutex; // 加锁机制
类型 queue,condition_variable ,和mutex 由标准库提供。
consumer() 读取和处理 Message 消息:
void consumer()
{
while(true) {
unique_lock<mutex> lck{mmutex}; // 获得mmutex
while (mcond.wait(lck)) /*什么都不做 */; // 释放 lck 和 wait;
//依赖唤醒的重新获取
auto m = mqueue.front(); // 取消息 (译注:事实上应该判断队列是否非空,以免生产者有通知无消息。)
mqueue.pop();
lck.unlock(); //释放lck
// ... process m ...
}
}
在此,我用了一个mutex和一个 unique_lock 显式地保护了在queue和 condition_variable上的操作。condition_variable 上的等待会在等待结束(便队非空时)后释放其锁参数,然后下一轮循环时重新获取它。
相应的 producer 看起来像这样:
void producer()
{
while(true) {
Message m;
// ... 填充消息 ...
unique_lock<mutex> lck {mmutex}; //保护操作
mqueue.push(m);
mcond.notify_one(); // 通知
} // 释放锁 (在域尾)
}
使用 condition_variable 支持多种形式的优雅且高效的共享,但可能相当棘手(§42.3.4)。
5.3.5 任务之通信
标准库提供了一些工具,允许程序员在任务的概念层次(潜在并发性的工作)上进行操作,而不是直接在线程和锁的较低层次上进行操作:
[1] future和 promise用于从单独线程上生成的任务返回值
[2] packaged_task 用于帮助启动任务并连接返回结果的机制
[3] async() 用于以与调用函数非常相似的方式启动任务。
这些工具位于<future>中。
5.3.5.1 future 和promise
future和 promise 的重点在于,它们可以在两个任务之间传输值,而无需明确使用锁;“系统”可以高效地实现传输。基本思路很简单:当一个任务想要将值传递给另一个任务时,它会将值放入 promise 中。无需究其原因,实现会让该值出现在相应的 future 中,然后可以从中读取该值(通常由任务的启动器读取)。我们可以用图形表示这一点:
若我们有一个 future<X> 被 fx调用,则我们可以从中 get() 一个 X 类型的值:
X v = fx.get(); // 若有必要, 等待值计算完成
如果值尚未到达,则线程将被阻塞,直到值到达。如果无法计算值,get() 可能会抛出异常(来自系统或从我们尝试 get() 值的任务传输)。promise 的主要目的是提供简单的“(置入)put”操作(称为 set_value() 和 set_exception())来匹配 future 的 get()。“future”和“promise”这两个名字都是历史遗留的;请不要责怪我。它们是另一个丰富的双关语来源。
如果你有一个 promise,并且需要将类型 X 的结果发送给 future,你可以做以下两件事之一:传递值或传递异常。例如:
void f(promise<X>& px) // a task: place the result in px
{
// ...
try {
X res;
// ... compute a value for res ...
px.set_value(res);
}
catch (...) { // oops: couldn’t compute res
// pass the exception to the future’s thread:
px.set_exception(current_exception());
}
}
current_exception() 指的是捕获的异常(§30.4.1.2)。
为了处理通过 future 传输的异常,get() 的调用者必须准备好在某处捕获它。例如:
void g(future<X>& fx) // a task: get the result from fx
{
// ...
try {
X v = fx.g et(); // if necessary, wait for the value to get computed
// ... use v ...
}
catch (...) { // oops: someone couldn’t compute v
// ... handle error ...
}
}
5.3.5.2 packaged_task
我们如何将future放入需要结果的任务中,并将相应的promise放入应该产生该结果的线程中? packaged_task 类型用于简化与future和promise相关的任务的设置,以便在线程上运行。 packaged_task 提供包装器代码,将任务的返回值或异常放入promise中(如 §5.3.5.1 中所示的代码)。如果您通过调用 get_future 来询问它,packaged_task 将为您提供与其promise相对应的future。例如,我们可以使用标准库的 accumulate()(§3.4.2、§40.6)设置两个任务,每个任务将 vector<double> 的一半元素相加:
double accum(double* beg, double* end, double init)
// compute the sum of [beg:end) starting with the initial value init
{
return accumulate(beg,end,init);
}
double comp2(vector<double>& v)
{
using Task_type = double(double*,double*,double); // 任务类型
packaged_task<Task_type> pt0 {accum}; // package the task (i.e., accum)
packaged_task<Task_type> pt1 {accum};
future<double> f0 {pt0.get_future()}; // get hold of pt0’s future
future<double> f1 {pt1.get_future()}; // get hold of pt1’s future
double* first = &v[0];
thread t1 {move(pt0),first,first+v.siz e()/2,0}; // star t a thread for pt0
thread t2 {move(pt1),first+v.siz e()/2,first+v.siz e(),0}; // star t a thread for pt1
// ...
return f0.get()+f1.g et(); // get the results
}
packaged_task 模板将任务的类型作为其模板参数(此处为Task_type,double(double∗,double∗,double) 的别名),并将任务作为其构造函数参数(此处为 accum)。由于 packaged_task 无法复制,因此需要 move() 操作。
请注意,此代码中没有明确提及锁:我们可以专注于要完成的任务,而不是用于管理其通信的机制。这两个任务将在不同的线程上运行,因此可能并行运行。
5.3.5.3 async()
本章中我所遵循的思路是我认为最简单但仍然最强大的思路:将任务视为可能与其他任务并发运行的函数。这远非 C++ 标准库支持的唯一模型,但它可以很好地满足各种需求。可以根据需要使用更微妙和更棘手的模型,例如依赖共享内存的编程风格。
为了启动可能异步运行的任务,我们可以使用 async():
double comp4(vector<double>& v)
//若v足够大,则产生很多任务
{
if (v.siz e()<10000) return accum(v.begin(),v.end(),0.0);
auto v0 = &v[0];
auto sz = v.siz e();
auto f0 = async(accum,v0,v0+sz/4,0.0); // 第一个四分之一
auto f1 = async(accum,v0+sz/4,v0+sz/2,0.0); // second quarter
auto f2 = async(accum,v0+sz/2,v0+sz*3/4,0.0); // third quarter
auto f3 = async(accum,v0+sz*3/4,v0+sz,0.0); // four th quar ter
return f0.get()+f1.get()+f2.get()+f3.get(); // 收集并组合结果,注意:如果获取时//尚未计算完成,则线程会阻塞,直到计算完成。
}
基本上,async() 将函数调用的“调用部分”与“获取结果部分”分开,并将两者与任务的实际执行分开。使用 async(),您不必考虑线程和锁。相反,您只需考虑可能异步计算其结果的任务。有一个明显的限制:不要考虑将 async() 用于共享需要锁定的资源的任务——使用 async() 您甚至不知道将使用多少个线程,因为这取决于 async() 根据它在调用时对可用系统资源的了解来决定。例如,async()可能会在决定使用多少个线程之前检查是否有任何空闲核心(处理器)。
请注意,async() 不仅仅是一种专门用于并行计算以提高性能的机制。例如,它还可用于生成一个从用户处获取信息的任务,让“主程序”保持活动状态并执行其他操作(§42.4.6)。
5.4 小工具组件
并非所有标准库组件以明显标记设施的部件出现,例如“容器”或“I/O”。本节给出了一些小型的、广泛使用的组件示例:
- 用于度量时间的clock和duration。
- 用于获得类信息类型函数(例如iterator_traits和 is_arithmetic )。
- 用于表示小的可能异构的(heterogeneous)值集的pair和tuple 。
这里的重点是,一个函数或类型不需要很复杂,也不需要与大量其他函数和类型紧密相关才能有用。此类库组件主要充当更强大的库工具(包括标准库的其他组件)的构建块。
5.4.1 Time
标准库提供了处理时间的设施。例如,以下是计时的基本方法:
using namespace std::chrono; // see §35.2
auto t0 = high_resolution_clock::now();
do_work();
auto t1 = high_resolution_clock::now();
cout << duration_cast<milliseconds>(t1−t0).count() << "msec\n";
时钟返回一个 time_point(时间点)。两个 time_point 相减可得出一个 duration(一段时间)。不同的时钟以不同的时间单位给出结果(我使用的时钟以纳秒为单位),因此将 duration 转换为已知单位为佳,通过调用 duration_cast 实现转换。
处理时间的标准库工具位于 <chrono> (§35.2) 中的子命名空间 std::chrono 中。
在没有先进行时间测量的情况下,不要对代码的“效率”做出表述。对性能的猜测是最不可靠的。
5.4.2 类型函数
类型函数是在编译时以类型作为参数或返回类型的函数。标准库提供了各种类型函数,以帮助库实现者和一般程序员编写利用语言各方面的代码、标准库代码和一般性代码的代码。
对于数值类型,<limits> 中的 numeric_limits 提供了各种有用的信息(§5.6.5)。例如:
constexpr float min = numeric_limits<float>::min(); // 最小正 float (§40.2)
类似地,对象大小可以通过内置的 sizeof 运算符(§2.2.2)求得。例如:
constexpr int szi = sizeof(int); // 类型int的字节数
此类类型函数是 C++ 编译时计算机制的一部分,可实现更严格的类型检查和更佳的性能。这种特征的用法通常称为元编程(metaprogramming)或(当涉及模板时)模板元编程(template metaprogramming)(第 28 章)。在这里,我仅介绍标准库提供的两个功能:iterator_traits(§5.4.2.1)和类型谓词(predicates)(§5.4.2.2)。
5.4.2.1 iterator_traits
标准库 sort() 采用一对迭代器来定义一个序列(§4.5)。此外,这些迭代器必须提供对该序列的随机访问,也就是说,它们必须是随机访问迭代器。某些容器(例如 forward_list)不提供此功能。特别是,forward_list 是一个单链表,因此下标会很昂贵,并且没有合理的方法可以引用回上一个元素。但是,与大多数容器一样,forward_list 提供了前向迭代器,可用于通过算法和 for 语句遍历序列(§33.1.1)。
标准库提供了一种机制,即 iterator_traits,它允许我们检查支持哪种迭代器。鉴于此,我们可以改进 §4.5.6 中的范围 sort(),以接受向量或 forward_list。例如:
void test(vector<string>& v, forward_list<int>& lst)
{
sort(v); // 向量排序
sort(lst); // 单向链表排序
}
实现这一目标所需的技术通常很实用。
首先,我编写了两个辅助函数,它们接受一个额外的参数,指示它们是用于随机访问迭代器还是前向迭代器。接受随机访问迭代器参数的版本很简单:
template<typename Ran> // 对随机访问迭代器
void sort_helper(Ran beg, Ran end,
random_access_iterator_tag) // 取下标的范围 [beg:end)
{
sort(beg,end); //排序
}
前向迭代器的版本几乎同样简单;只需将列表复制到向量中,排序,然后再次复制回来:
template<typename For> // 前向迭代器
void sort_helper(For beg, For end,
forward_iterator_tag) // 我们可以贯穿 [beg:end)
{
vector<decltype(∗beg)> v {beg,end}; // 从 [beg:end)初始化向量
sort(v.begin(),v.end());
copy(v.begin(),v.end(),beg); // 将元素拷回
}
decltype() 是一个内置类型函数,它返回其参数的声明类型(§6.3.6.3)。因此,v 是一个向量<X>,其中 X 是输入序列的元素类型。
真正的“类型魔法”在于辅助函数的选择:
template<typname C>
void sort(C& c)
{
using Iter = Iterator_type<C>;
sort_helper(c.begin(),c.end(),Iterator_category<Iter>{});
}
这里我使用了两个类型函数:Iterator_type<C> 返回 C 的迭代器类型(C::iterator)然后 Iterator_category<Iter>{} 构造一个指明所提供的迭代器类型的“标签”值:
- std::random_access_iterator_tag:若 C 的迭代器支持随机访问,使用此标签。
- std::forward_iterator_tag:若 C 的迭代器支持前向访问,使用此标签。
鉴于此,我们可以在编译时在两种排序算法之间进行选择。这种称为标签分派(tag dispatch)的技术是标准库和其他地方使用的几种技术之一,用于提高灵活性和性能。
标准库支持使用迭代器的技术,例如标签分派,以来自 <iterator> (§33.1.3) 的简单类模板 iterator_traits 的形式提供。这允许对 sort() 中使用的类型函数进行简单的定义:
template<typename C>
using Iterator_type = typename C::iterator; // C’s iterator type
template<typename Iter>
using Iterator_category = typename std::iterator_traits<Iter>::iterator_category; // //Iter’s categor y
如果您不想知道使用了什么样的“编译时类型魔法(magic)”来提供标准库功能,您可以自由地忽略 iterator_traits 等设施。但您无法使用它们支持的技术来改进您自己的代码。
5.4.2.2 类型谓词(Type Predicates)
标准库类型谓词是一个简单的类型函数,可以回答有关类型的基本问题(译注:是什么,真,假,等等,即下判断,“谓词”就是“下判断”的意思)。例如:
bool b1 = Is_arithmetic<int>(); // yes, int is an arithmetic type
bool b2 = Is_arithmetic<string>(); // no, std::str ing is not an arithmetic type
这些谓词可以在 <type_traits> 中找到,并在 §35.4.1 中描述。其他示例包括 is_class、is_pod、is_literal_type、has_virtual_destructor 和 is_base_of。它们在我们编写模板时最有用。例如:
template<typename Scalar>
class complex {
Scalar re, im;
public:
static_assert(Is_arithmetic<Scalar>(), "Sorr y, I only suppor t complex of arithmetic types");
// ...
};
为了提高与直接使用标准库相比的可读性,我定义了一个类型函数:
template<typename T>
constexpr bool Is_arithmetic()
{
return std::is_arithmetic<T>::value ;
}
较旧的程序直接使用 ::value 而不是 (),但我认为这很丑陋,并且会暴露实现细节。
5.4.3 pair和tuple
通常,我们需要一些仅仅是数据的数据,即值的集合,而不是具有明确定义的语义和其值具有不变量的类的对象(§2.4.3.2、§13.4)。在这种情况下,我们可以定义一个简单的 struct,其中包含一组适当命名的成员。或者,我们可以让标准库为我们编写定义。例如,标准库算法 equal_range(§32.6.1)返回 pair 迭代器,指定满足谓词的子序列:
template<typename Forward_iterator, typename T, typename Compare>
pair<Forward_iterator,Forward_iterator>
equal_range(Forward_iterator first, Forward_iterator last, const T& val, Compare cmp);
给定一个排序序列 [first:last),equal_range() 将返回表示与谓词 cmp 匹配的子序列的 pair。我们可以使用它在排序的 Records 序列中进行搜索:
auto rec_eq = [](const Record& r1, const Record& r2) { return r1.name<r2.name;};// 对比 names
void f(const vector<Record>& v) // 假设 v 基于其 "name" 域排序
{
auto er = equal_range(v.begin(),v.end(),Record{"Reg"},rec_eq);
for (auto p = er.first; p!=er.second; ++p) //打印所有输出记录
cout << ∗p; //假设 << 为 Record 重载
}
标准库pair (来自 <utility>)在标准库和其他地方使用得相当频繁。如果pair的元素有运算符,则 pair 会提供运算符,例如 =、== 和 <。make_pair() 函数可以轻松创建pair,而无需明确提及其类型(§34.2.4.1)。例如:
void f(vector<string>& v)
{
auto pp = make_pair(v.begin(),2); // pp is a pair<vector<str ing>::iterator,int>
// ...
}
如果需要两个以上(或更少)的元素,可以使用tuple(来自 <utility>;§34.2.4.2)。tuple 是元素的异构(heterogeneous)序列(译注:即不同类型);例如:
tuple<string,int,double> t2("Sild",123, 3.14); // 显式指定类型
auto t = make_tuple(string("Herring"),10, 1.23); //推导数开
// t 是一个 tuple<string,int,double>
string s = get<0>(t); // 获取元组第一个元素
int x = get<1>(t);
double d = get<2>(t);
tuple的元素是按编号排序的(从零开始),而不是像 pair 元素那样命名(第一个和第二个)。为了在编译时选择元素,我不得不使用丑陋的 get<1>(t),而不是 get(t,1) 或 t[1](§28.5.2)。
和pair一样,如果tuple的元素允许分配和比较,那么tuple也可以被分配和比较。
接口中经常使用pair,因为我们经常想要返回多个值,例如结果和该结果质量的指标。结果需要三个或更多部分的情况并不常见,因此tuple更常出现在泛型算法的实现中。
5.5 正则表达式(Regular Expressions)
正则表达式是文本处理的强大工具。它们提供了一种简单而简洁地描述文本模式(例如,美国邮政编码,如 TX 77845,或 ISO 样式日期,如 2009-06-07)并有效地在文本中查找此类模式的方法。在 <regex> 中,标准库以 std::regex 类及其支持函数的形式提供对正则表达式的支持。为了体验一下 regex 库的风格,让我们定义并打印一个模式:
regex pat (R"(\w{2}\s∗\d{5}(−\d{4})?)"); // ZIP code pattern: XXddddd-dddd and //var iants
cout << "pattern: " << pat << '\n';
几乎在任何语言中使用过正则表达式的人都会发现 \w{2}\s∗\d{5}(−\d{4})? 很熟悉。它指定一个模式,以两个字母 \w{2} 开头,后面可以跟一些空格 \s∗ ,后面可以跟五个数字 \d{5},后面可以跟一个破折号和四个数字 −\d{4}。如果您不熟悉正则表达式,这可能是学习它们的好时机([Stroustrup,2009]、[Maddock,2009]、[Friedl,1997])。正则表达式总结在 §37.1.1 中。
为了表达该模式,我使用以 R"( 开头并以 )" 结尾的原始字符串文字 (§7.3.2.1)。这样就可以在字符串中直接使用反斜杠和引号。
使用模式的最简单方法是在流中搜索它:
int lineno = 0;
for (string line; getline(cin,line);) { // 读入行缓存区
++lineno;
smatch matches; //匹配字符串到这儿
if (regex_search(line ,matches,pat)) // 在行中搜索 pat
cout << lineno << ": " << matches[0] << '\n';
}
regex_search(line ,matches,pat) 搜索行中与存储在 pat 中的正则表达式匹配的任何内容,如果找到任何匹配项,则将其存储在 matches 中。如果没有找到匹配项,regex_search(line ,matches,pat) 将返回 false。matches 变量属于 smatch 类型。“s”代表“sub”,smatch 是子匹配的向量。第一个元素,这里是 matches[0],是完整匹配。
更完整的描述请参见第 37 章。
5.6 数学工具(Math)
C++ 的设计初衷并不是数值计算。然而,C++ 大量用于数值计算,标准库也反映了这一点。
5.6.1 数学函数和算法
在 <cmath> 中,我们找到了“常用数学函数”,例如 sqrt()、log() 和 sin(),其参数类型为 float、double 和 long double(§40.3)。这些函数的复数版本可在 <complex>(§40.4)中找到。
在 <numeric> 中,我们发现了一小组通用的数值算法,例如 accumulate()。例如:
void f()
{
list<double> lst {1, 2, 3, 4, 5, 9999.99999};
auto s = accumulate(lst.begin(),lst.end(),0.0); // 求和
cout << s << '\n'; // print 10014.9999
}
这些算法适用于每个标准库序列,并且可以将操作作为参数提供(§40.6)。
5.6.2 复数
标准库支持一系列复数类型,与 §2.3 中描述的 complex 类类似。为了支持标量(译注:指实部和虚部除虚数单位的部分)为单精度浮点数 (float)、双精度浮点数 (double) 等的复数,标准库 complex 是一个模板:
template<typename Scalar>
class complex {
public:
complex(const Scalar& re ={}, const Scalar& im ={});
// ...
};
标准库支持复数的常见算术运算和最常见的数学函数。例如:
void f(complex<float> fl, complex<double> db)
{
complex<long double> ld {fl+sqrt(db)};
db += fl∗3;
fl = pow(1/fl,2);
// ...
}
sqrt() 和 pow()(指数)函数是 <complex> 中定义的常用数学函数。有关更多详细信息,请参阅 §40.4。
5.6.3 随机数
随机数在许多情况下都很有用,例如测试、游戏、模拟和安全。应用领域的多样性反映在 <random> 标准库提供的随机数生成器的广泛选择上。随机数生成器由两部分组成:
[1] 产生一系列随机或伪随机值的引擎(engine)。
[2] 将这些值映射到一定范围内的数学分布的分布(distribution)。
分布的例子有 uniform_int_distribution(其中产生的所有整数都具有相同的可能性)、normal_distribution(“钟形曲线”)和 exponential_distribution(指数增长);每个分布都针对某个指定范围。例如:
using my_engine = default_random_engine; // engine类型
using my_distribution = uniform_int_distribution<>; // distribution类型
my_engine re {}; // 默认engine
my_distribution one_to_six {1,6}; //映射到 ints 1..6 的distribution
auto die = bind(one_to_six,re); // 创建一个生存器
int x = die(); //滚动 die: x 成为 [1:6] 之间的具体值
标准库函数 bind() 创建一个函数对象,该函数对象将调用其第一个参数(此处为 one_to_six),并将其第二个参数(此处为 re)作为其参数(§33.5.1)。因此,调用 die() 等同于调用 one_to_six(re)。
由于其对通用性和性能的不懈关注,一位专家认为标准库随机数组件是“每个随机数库在成长过程中都希望成为的样子”。然而,它很难被视为“新手友好型”。使用语句使正在做的事情更加明显。相反,我本可以这样写:
auto die = bind(uniform_int_distribution<>{1,6}, default_random_engine{});
哪个版本更具可读性完全取决于上下文和读者。
对于新手(无论背景如何)来说,随机数库的完全通用接口可能是一个严重的障碍。一个简单的均匀随机数生成器通常对起步已足矣。例如:
Rand_int rnd {1,10}; // 创建一个范围 [1:10]之间的随机数
int x = rnd(); // x 是一个 [1:10]之间的数
那么,我们怎样才能得到它呢?我们必须在 Random_int 类中获取类似 die() 的东西:
class Rand_int {
public:
Rand_int(int low, int high) :dist{low,high} { }
int operator()() { return dist(re); } // draw an int
private:
default_random_engine re;
uniform_int_distribution<> dist;
};
该定义仍是“专家级”,但对于初学者来说,在 C++ 课程的第一周内,Rand_int() 的使用是可以掌握的。例如:
int main()
{
Rand_int rnd {0,4}; // 创建一均匀随机数生成器
vector<int> histogram(5); // 创建一个5个元素的vector
for (int i=0; i!=200; ++i)
++histogram[rnd()]; //用数字频率填充histogram[0:4]
for (int i = 0; i!=mn.size(); ++i) { // 输出条状图
cout << i << '\t';
for (int j=0; j!=mn[i]; ++j) cout << '∗';
cout << endl;
}
}
输出是(令人放心的无聊的)均匀分布(具有合理的统计变化):
0 ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
1 ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
2 ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
3 ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
4 ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
C++ 没有标准图形库,因此我使用“ASCII 图形”。显然,C++ 有很多开源和商业图形和 GUI 库,但在本书中,我限定自己办使用 ISO 标准设施。
有关随机数的更多信息,请参阅§40.7。
5.6.4 向量运算(Vector Arithmetic)
§4.4.1 中描述的 vector 被设计为一种用于保存值的通用机制,具有灵活性,并适合容器、迭代器和算法的体系结构。但是,它不支持数学向量运算。向向量添加此类运算很容易,但其通用性和灵活性阻碍了通常被认为对严肃的数值工作至关重要的优化。因此,标准库(在 <valarray> 中)提供了一个类似于向量的模板,称为 valarray,它不太通用,更适合数值计算的优化:
template<typename T>
class valarray {
// ...
};
valarray支持常见的算术运算和最常见的数学函数。例如:
void f(valarray<double>& a1, valarray<double>& a2)
{
valarray<double> a = a1∗3.14+a2/a1; // 数值数组运算符 *, +, /, 和 =
a2 += a1∗3.14;
a = abs(a);
double d = a2[7];
// ...
}
更多细节,参见 §40.5。具体来说,valarray提供了跨步访问(stride access),以帮助实现多维计算。
5.6.5 数的极限(Numeric Limits)
在 <limits> 中,标准库提供了描述内置类型属性的类——例如,浮点数的最大指数或整数的字节数;参见 §40.2。例如,我们可以断言 char 是有符号的:
static_assert(numeric_limits<char>::is_signed,"unsigned characters!");
static_assert(100000<numeric_limits<int>::max(),"small ints!");
请注意,仅第二个断言有效,因为 numeric_limits<int>::max() 是一个 constexpr 函数(§2.2.3、§10.4)。
5.7 建议
[1] 使用资源句柄来管理资源(RAII);§5.2。
[2] 使用unique_ptr引用多态类型的对象;§5.2.1。
[3] 使用 shared_ptr 引用共享对象;§5.2.1。
[4] 使用类型安全的并发机制;§5.3。
[5] 尽量减少共享数据的使用;§5.3.4。
[6] 不要因为“效率”而不假思索地选择共享数据进行通信,最好不要不加衡量;§5.3.4。
[7] 从并发任务的角度来思考,而不是线程;§5.3.5。
[8] 库不必很大或很复杂才有用;§5.4。
[9] 在声明效率之前先对程序进行计时;§5.4.1。
[10] 您可以编写代码来明确依赖类型的属性; §5.4.2。
[11] 使用正则表达式进行简单的模式匹配;§5.5。
[12] 不要试图仅使用语言进行严肃的数值计算;使用库;§5.6。
[13] 可通过 numeric_limits 访问数类型的属性;§5.6.5。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup