13

实用功能

你乐于挥霍的时间,都不能算作浪费。

—— 伯特兰·罗素1

13.1 导言

并非所有标准库组件都置身于“容器”或“I/O”这样显而易见的分类中。 本章节将为那些短小但用途广泛的组件给出使用范例。 因为这些组件(类或模板)在描述设计和编程的时候被用作基本词汇, 它们也常被称为词汇表类型(vocabulary types)。 对于那些更强大的库设施——包括标准库的其它组件而言,这些库组件通常充当零件。 一个实用的函数或者类型,无需很复杂,也没必要跟其它函数和类搞得如胶似漆的。

13.2 资源管理

只要不是小打小闹的程序,其关键任务之一就是管理资源。 资源就是需要先申请且使用后进行(显示或隐式)释放的东西。 比方说内存、锁、套接字、线程执柄以及文件执柄等等。 对于长时间运行的程序,不及时释放资源(“泄漏”)会导致严重的性能下降, 更有甚者还可能崩溃得让人心力交瘁的。 即便是较短的程序,资源泄露也会弄得很尴尬, 比方说因为资源不足而导致运行时间增加几个数量级。

标准库组件在设计层面力求避免资源泄露。 为此,它们依赖成对的 构造函数/析构函数 这种基础语言支持, 以确保资源随着持有它的对象一起释放。 Vector中管理器元素生命期的 构造函数/析构函数对 就是个范例(§4.2.2), 所有标准库容器都是以类似方式实现的。 重要的是,这个方法跟利用异常的错误处理相辅相成。 例如,以下技法用于标准库的锁类:

mutex m;    // used to protect access to shared data
// ...
void f()
{
    scoped_lock<mutex> lck {m}; // acquire the mutex m
    // ... manipulate shared data ...
}

lck的构造函数获取mutex(§15.5)之前,该thread不会继续执行。 对应的析构函数会释放此资源。 因此在本例中,在控制线程离开f()时(return、“经由函数末尾”或抛出异常), scoped_lock的析构函数会释放mutex

这是 RAII(“资源请求即初始化”技术;§4.2.2)的一个应用。 RAII是C++中约定俗成管理资源方式的基础。 容器(例如vectormapstringiostream) 就以类似的方式管理其资源(比如文件执柄和缓存)。

13.2.1 unique_ptrshared_ptr

到目前为止的示例都仅涉及作用域内的对象,申请的资源在离开作用域时被释放, 那么分配在自由存储区域内的对象又改怎么处理呢? 在<memory>中,标准库提供了两个“智能指针”用于管理自由存储区中的对象:

  • [1] unique_ptr用于独占所有权
  • [2] shared_ptr用于共享所有权

这些“智能指针”最基本的用途是避免粗心大意导致的内存泄漏。例如:

void f(int i, int j) // X* vs. unique_ptr<X>
{
    X* p = new X;               // new一个X
    unique_ptr<X> sp {new X};   // new一个X,并将其指针交给unique_ptr
    // ...

    if (i<99) throw Z{};        // 可能会抛出异常
    if (j<77) return;           // 可能会“提前”return
    // ... 使用 p 和 sp ..
    delete p;   // 销毁 *p
}

此处,如果i<99j<77,我们就“忘记了”删除p。 另一方面,无论以怎样的方式离开f()(抛出异常、执行return或“经由函数末尾”), unique_ptr都会确保其对象妥善销毁。 搞笑的是,这个问题本可以轻易就解决掉,只要使用指针且使用new就行:

void f(int i, int j) // 使用局部变量
{
    X x;
    // ...
}

不幸的是,new(以及指针和引用)的滥用是个日益严重的问题。

无论如何,在你切实需要使用指针语意的时候,与努力把裸指针用对相比, unique_ptr是个轻量级机制,没有额外空间和时间开销。 它的一个进阶用法是,把分配在自由存储区的对象传入或传出函数:

unique_ptr<X> make_X(int i)
    // 分配一个X并直接交给一个unique_ptr
{
    // ... 检查i,以及其它操作. ...
    return unique_ptr<X>{new X{i}};
}

unique_ptr是个单个对象(或数组)的执柄, 这跟vector作为对象序列的执柄如出一辙。 都(借由RAII)控制其它对象的生命期,也都依赖于转移语意实现简洁高效的return

shared_ptrunique_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。 为避免这些问题,标准库(在<memory>中)为 构造对象并返回相应智能指针 提供了函数make_shared()make_unique()。例如:

struct S {
    int i;
    string s;
    double d;
    // ...
};

auto p1 = make_shared<S>(1,"Ankh Morpork",4.65);    // p1是个shared_ptr<S>
auto p2 = make_unique<S>(2,"Oz",7.62);              // p2是个unique_ptr<S>

此处p2是个unique_ptr<S>,指向分配在自由存储区上的S类型对象, 该对象的值为{2,"Oz"s,7.62}

跟先使用new去构造对象,然后传给shared_ptr的分步操作相比, make_shared()不光是便利,其效率也明显更好, 因为它不需要为引用计数单独执行一次内存分配, 对于shared_ptr的实现来说,引用计数是必须的。

有了unique_ptrshared_ptr的帮助,就可以给很多程序实现一个彻底 “无裸new(no naked new)”的策略(§4.2.2)。 不过这些“智能指针”在概念上仍然是指针,因此在资源管理方面只能是次选 ——首选是 容器 和 其它在更高概念层级上进行资源管理的类型。 尤其是,shared_ptr本身并未为它的所有者提供针对共享对象的 读 和/或 写 规则。 仅仅消除了资源管理问题,并不能解决数据竞争(§15.7)和其它形式的混乱。

我们该在何处使用(诸如unique_ptr的)“智能指针”, 而非带有专门为资源设计过操作的资源执柄(比如vectorthread)呢? 答案在意料之中,是“在我们需要指针语意的时候”。

  • 在共享一个对象时,需要指针(或引用)来指向被共享的对象, 于是shared_ptr就成为顺理成章的选择(除非有个显而易见的唯一所有者)。
  • 在经典的面向对象代码(§4.5)中指向一个多态对象时,需要使用指针(或引用), 因为被引用对象的具体类型(甚至其容量)未知,因此unique_ptr就是个显而易见的选择。
  • 被共享的多态对象通常需要shared_ptr

从函数中返回对象的集合时,需要使用指针; 容器在这个情形下作为资源执柄,即简单又高效(§5.2.2)。

13.2.2 move() 和 forward()

转移和复制之间的选择通常是隐式的(§3.6)。 在对象将被销毁时(如return),编译器倾向于采用转移, 因为这是更简洁、更高效的操作。 不过,有时候不得不显式指定。例如:某个unique_ptr是一个对象的唯一所有者。 因此,它无法被复制:

void f1()
{
    auto p = make_unique<int>(2);
    auto q = p;         // 报错:无法复制 unique_ptr
    // ...
}

如果某个unique_ptr需要去在别处,就必须转移它。例如:

void f1()
{
    auto p = make_unique<int>(2);
    auto q = move(p);       // p 现在持有 nullptr
    // ...
}

令人费解的是,std::move()并不会移动任何东西。 相反,它只是把它的参数转换成一个右值引用, 以此说明其参数不会再被使用,因此可以被转移(§5.2.2)。 它本应该取一个其它的函数名,比如rvalue_cast()。 跟其它类型转换相似,它也容易出错,最好避免使用。 它为某些特定情况而存在。参考以下这个简单的互换:

template <typename T>
void swap(T& a, T& b)
{
    T tmp {move(a)};    // T 的构造函数见到右值并转移
    a = move(b);        // T 的赋值见到右值并转移
    b = move(tmp);      // T 的赋值见到右值并转移
}

我们不想反复复制可能容量很大的对象,因此用std::move()进行转移。

跟其它类型转换相似,std::move()很诱人但也很危险。参考:

string s1 = "Hello";
string s2 = "World";
vector<string> v;
v.push_back(s1);        // 使用"const string&"参数;push_back()将进行复制
v.push_back(move(s2));  // 使用转移构造函数

此处s1被(通过push_back())复制,而s2则是被转移。 这(仅仅是)有时候会让s2push_back()更节约些。 问题是这里遗留的移出对象(moved-from object)。如果再次使用s2就会出问题:

cout << s1[2]; // 输出 ’l’
cout << s2[2]; // 崩溃?

我认为,如果std::move()被广泛应用,太容易出错。

除非能证明有显著和必要的性能提升,否则别用它。 后续维护很可能不经意就意想不到地用到了移出对象。

移出对象的状态通常来说是未指定的,但是所有标准库类型都会使之可销毁并且可被赋值。 如果不遵循这一惯例,可就不太明智了。 对于容器(如vectorstring),移出状态将会是“置空”。 对于许多类型,默认值就是个很好的空状态:有意义且构建的成本低廉。

参数转发是个转移操作的重要用途(§7.4.2)。 有时候,我们需要把一套参数原封不动地传递给另一个函数 (以达成“完美转发(perfect forwarding)”):

template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args)
{
    return unique_ptr<T>{new T{std::forward<Args>(args)...}};   // 转发每个参数
}

标准库std::forward()与简易的std::move()区别在于, 它能够正确处理左值和右值间细微的差异(§5.2.2)。 请仅在转发的时候使用std::forward(),并且别把任何东西forward()两次; 一旦你转发过某个对象,它就不再属于你了。

13.3 区间检查:gsl::span

按以往经验,越界错误是C和C++程序中严重错误的一个主要来源。 容器(第11章)、算法(第12章)和区间-for的使用显著缓解了这个问题, 但仍有改进的余地。 越界错误的一个关键原因在于人们传递指针(裸指针或智能指针), 而后依赖惯例去获取所指向元素的数量。 对于资源执柄以外的代码,有个颠扑不破的建议是: 假设所指向的对象至多有一个[CG: F.22], 但如果没有足够的支持,这个建议也不太好操作。 标准库的string_view(§9.3)能帮点忙,但它是只读的,并且只支持字符。 大多数程序员需要更多的支持。

C++ Core Guidelines [Stroustrup,2015] 提供了指导以及一个小型的 指导支持库(Guidelines Support Library)[GSL], 其中有个span类型用于指向包含元素的区间。 这个span已经向标准提交,但如果目前需要它, 只能下载使用 。 大体上,span是个表示元素序列的(指针,长度)对:

span

span为 包含元素的连续序列 提供访问。 其中的元素可以按多种形式存储,包括vector和内建数组。 与指针相仿,span对其指向的内容并无所有权。 在这方面,它与string_view(§9.3)和STL的迭代器对(§12.3)如出一辙。

考虑这个常见的接口风格:

void fpn(int* p, int n)
{
    for (int i = 0; i<n; ++i)
        p[i] = 0;
}

此处假设p指向n个整数。 遗憾的是,这种假设仅仅是个惯例,因此无法用于区间-for循环, 编译器也没办法实现一个低消耗高性能的越界检查。 此外,这个假设还会出错:

void use(int x)
{
    int a[100];
    fpn(a,100);     // OK
    fpn(a,1000);    // 我艹,手滑了!(fpn里越界错误)
    fpn(a+10,100);  // fpn里越界错误
    fpn(a,x);       // 可疑,但看上去无害
}

可利用span对它进行改进:

void fs(span<int> p)
{
    for (int& x : p)
        x = 0;
}

fs用法如下:

void use(int x)
{
    int a[100];
    fs(a);          // 隐式创建一个 span<int>{a,100} 
    fs(a,1000);     // 报错:需要一个 span
    fs({a+10,100}); // fs里报越界访问错误
    fs({a,x});      // 明显很可疑
}

此处,最常见的情形是直接由数组创建一个span, 于是获得了安全性(编译器计算元素数量)以及编码的简洁性。 至于其它情形,出错的概率也降低了,因为程序员不得不显式构造一个span。

对于在函数间进行传递这种常见情形,span要比(指针,数量)接口简洁, 而且很明显还无需额外的越界检查:

void f1(span<int> p);

void f2(span<int> p)
{
    // ...
    f1(p);
}

在用于取下标操作(即r[i])时,会执行越界检查, 在触发越界错误情况下会抛出gsl::fail_fast。 对于极度追求性能的代码,越界检查是可以禁掉的。 在span被纳入标准时,我期待std::span能够按照 [Garcia,2016] [Garcia,2018]中的约定去管理针对越界访问的响应。

请留意,对于循环来说,越界检查只需要进行一次。 因此,对于函数体仅仅是 针对span的循环 这种常见情形而言,越界访问几乎没有开销。

针对字符的span直接获得支持,并且名称是gsl::string_span

13.4 专用容器

标准库提供了几个未能全然符合STL框架的容器(第11章、第12章)。 比方说内建数组、arraystring。 我有时候称之为“次容器(almost containers)”,但这么说有点略失公允: 它们承载了元素,因此也算是容器,但由于各自的局限性或附带的便利性, 使它们在STL的语境中略失和谐。将其单独介绍有助于简化STL的叙述。

“次容器”
T[N] 内建数组:定长且连续分配的NT类型元素的序列;隐式转化为T*
array<T,N> 定长且连续分配的NT类型元素的序列;与内建数组类似,但消除了大部分问题
bitset<N> 定长的N个二进制位的序列
vector<bool> 一个以特化vector进行存储的紧凑二进制位的序列
pair<T,U> 包含TU类型的两个元素
tuple<T...> 任意元素数量且元素间类型可各不相同的序列
basic_string<C> 字符类型为C的序列;提供字符串操作
valarray<T> 数值类型为T的数组;支持数值运算

标准库为何要提供这么多容器呢?它们针对的是普遍却略有差异(常有交叠)的需求。 如果标准库未能提供,许多人就不得不造轮子。例如:

  • pairtuple是异质的;其它所有容器都是同质的(所有元素类型都相同)。
  • arrayvectortuple是连续分配的; forward_listmap都是链式结构。
  • bitsetvector<bool>承载二进制位且通过代理对象提供访问; 所有其它标准库容器都承载各种类型的元素且提供直接访问。
  • basic_string规定其元素类型是某种形式的字符且提供字符串操作, 比如字符串连接以及语境相关的操作。
  • valarray要求其元素是数字且提供数值运算。

这些容器都提供特定的服务,这些服务对诸多程序员社群是必不可少的。 这些需求无法用单一容器满足,因为其中某些需求相互矛盾,例如: “长度可增”和“确保分配在特定位置”就相互矛盾, “添加元素时元素位置不可变”和“分配必须连续”也是。

13.4.1 array

定义在<array>中的array是个给定类型的定长序列, 其中的元素数量要在编译期指定。 因此,array的元素可以分配在栈上,在对象里或者在静态存储区。 元素被分配的作用域包含了该array的定义。 array的最佳理解方式是看做内置数组,其容量不能改变, 但不会被隐式、令人略感诧异地转换成指针类型,它还提供了几个便利的函数。 与内建数组相比,使用array不会带来(时间或空间上的)开销。 array遵循STL容器模型的“元素执柄”模型。 相反,array直接包含了它的元素。

array可以用初始化列表进行初始化:

array<int,3> a1 = {1,2,3};

初始化列表中的元素数量必须等于或小于为array指定的数量。

元素数量是必选项:

array<int> ax = {1,2,3}; // 报错,未指定容量

元素数量必须是常量表达式:

void f(int n)
{
    array<string,n> aa = {"John's", "Queens' "};    // 报错:容量不是常量表达式
    //
}

如果需要用变量作为元素数量,请使用vector

在必要的时候,array可以被显式地传给要求指针的C-风格函数。例如:

void f(int* p, int sz);     // C-风格接口

void g()
{
    array<int,10> a;

    f(a,a.size());          // 报错:无隐式转换
    f(&a[0],a.size());      // C-风格用法
    f(a.data(),a.size());   // C-风格用法 

    auto p = find(a.begin(),a.end(),777);   // C++/STL-风格用法
    // ...
}

有了更加灵活的vector,为什么还要用array呢?array不那么灵活,所以更简单。 偶尔,与 借助vector(执柄)访问自由存储区内的元素并将其回收 相比, 直接访问栈上分配的元素具有大幅度的性能优势。 反之,栈是个稀缺资源(尤其在嵌入式系统上),栈溢出也太恶心。

那么既然可以用内建数组,又何必用array呢? array知悉其容量,因此易于搭配标准库算法使用,它还可以用=进行复制。 不过,我用array的主要原因是避免意外且恶心地隐式转换为指针。考虑:


void h()
{
    Circle a1[10];
    array<Circle,10> a2;
    // ...
    Shape* p1 = a1; // OK:灾难即将发生
    Shape* p2 = a2; // 报错:没有从array<Circle,10>到Shape*的隐式转换
    p1[3].draw();   // 灾难
}

带有“灾难”注释的那行代码假设sizeof(Shape)<sizeof(Circle), 因此借助Shape*去对Circle[]取下标就得到了错误的偏移。 所有标准库容器都都提供这个优于内建数组的益处。

13.4.2 bitset

系统的某个特性,例如输入流的状态,经常是一组标志位,以表示二元状态, 例如 好/坏、真/假、开/关。 C++支持针对少量标志位的高效表示,其方法要借助整数的位运算(§1.4)。 bitset<N>类泛化了这种表示法,提供了针对N个二进制位[0:N)的操作, 其中N要在编译期指定。 对于无法装进long long int的二进制位集合, 用bitset比直接使用整数方便很多。 对于较小的集合,bitset通常也优化得比较好。 如果需要给这些位命名而不使用编号,可以使用set(§11.4)或者枚举(§2.5)。

bitset可以用整数或字符串初始化:

bitset<9> bs1 {"110001111"};
bitset<9> bs2 {0b1'1000'1111}; // 使用数字分隔符的二进制数字文本(§1.4)

常规的位运算符(§1.4)以及左移与右移运算符(<<>>)也可以用:

bitset<9> bs3 = ~bs1        // 取补: bs3=="001110000"
bitset<9> bs4 = bs1&bs3;    // 全零
bitset<9> bs5 = bs1<<2;     // 向左移: bs5 = "000111100"

移位运算符(此处的<<)会“移入(shift in)”零。

to_ullong()to_string()运算提供构造函数的逆运算。 例如,我们可以输出一个int的二进制表示:

void binary(int i)
{
    bitset<8*sizeof(int)> b = i;    // 假设字节为8-bit(详见 §14.7)
    cout << b.to_string() << '\n';  // 输出i的二进制位
}

这段代码从左到右打印出10的二进制位,最高有效位在最左面, 因此对于参数123将有如下输出:

00000000000000000000000001111011

对此例而言,直接用bitset的输出运算符会更简洁:

void binary2(int i)
{
    bitset<8*sizeof(int)> b = i;    // 假设字节为8-bit(详见 §14.7)
    cout << b << '\n';              // 输出i的二进制位
}

13.4.3 pairtuple

我们时常会用到一些单纯的数据,也就是值的集合, 而非一个附带明确定义的语意以及 其值的不变式(§3.5.2)的类对象。 这种情况下,含有一组适当命名成员的简单struct通常就很理想了。 或者说,还可以让标准库替我们写这个定义。 例如,标准库算法equal_range返回一个表示符合某谓词子序列的迭代器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将返回一个pair, 以表示符合谓词cmp的子序列。 可以用它对一个承载Record的有序序列进行查找:

auto less = [](const Record& r1, const Record& r2) { return r1.name<r2.name;}; // compare names
void f(const vector<Record>& v) // 假设v按照其“name”字段排序
{
    auto er = equal_range(v.begin(),v.end(),Record{"Reg"},less);

    for (auto p = er.first; p!=er.second; ++p)  // 打印所有相等的记录
        cout << *p;                             // 假设定义过Record的<<
}

pair的第一个成员被称为first,第二个成员被称为second。 该命名在创造力方面乏善可陈,乍看之下可能还有点怪异, 但在编写通用代码的时候,却能够从这种一致的命名中受益匪浅。 在firstsecond过于泛化的情况下,可借助结构化绑定(§3.6.3):

void f2(const vector<Record>& v) // 假设v按照其“name”字段排序
{
    auto [first,last] = equal_range(v.begin(),v.end(),Record{"Reg"},less);

    for (auto p = first; p!=last; ++p)  // 打印所有相等的记录
        cout << *p;                     // 假设定义过Record的<<
}

标准库中(来自<utility>)的pair在标准库内外应用颇广。 如果其元素允许,pair就提供诸如===<这些运算符。 类型推导简化了pair的创建,使得无需再显式提及它的类型。例如:

void f(vector<string>& v)
{
    pair p1 {v.begin(),2};              // 一种方式
    auto p2 = make_pair(v.begin(),2);   // 另一种方式
    // ...
}

p1p2的类型都是pair<vector<string>::iterator,int>

如果所需的元素多于(或少于)两个,可以使用(来自<utility>的)tupletuple是个 元素的异质序列,例如:

tuple<string,int,double> t1 {"Shark",123,3.14};     // 显式指定了类型
auto t2 = make_tuple(string{"Herring"},10,1.23);    // 类型推导为tuple<string,int,double>
tuple t3 {"Cod"s,20,9.99};                          // 类型推导为tuple<string,int,double>

较旧的代码往往用make_tuple(),因为从构造函数推导模板参数类型是C++17的内容。

访问tuple成员需要用到get()函数模板:

string s = get<0>(t1);  // 获取第一个元素: "Shark"
int x = get<1>(t1);     // 获取第二个元素: 123
double d = get<2>(t1);  // 获取第三个元素: 3.14

tuple的成员是数字编号的(从零开始),并且下标必须是常数。

通过下标访问tuple很普遍、丑陋,还颇易出错。 好在,tuple中具有唯一类型的元素可在该tuple中以其类型“命名(named)”:

auto s = get<string>(t1);   // 获取string: "Shark"
auto x = get<int>(t1);      // 获取int: 123
auto d = get<double>(t1);   // 获取double: 3.14

get<>()还可以进行写操作:

get<string>(t1) = "Tuna";   // 写入到string
get<int>(t1) = 7;           // 写入到int
get<double>(t1) = 312;      // 写入到double

pair类似,只要元素允许,tuple也可被赋值和进行算数比对。 与tuple类似,pair也可以通过get<>()访问。

pair的情况相同,结构化绑定(§3.6.3)也可用于tuple。 不过,在不必使用泛型代码时,带有命名成员的简单结构体通常可以让代码更易于维护。

13.5 待选项

标准库提供了三种类型来表示待选项:

  • variant表示一组指定的待选项中的一个(在<variant>中)
  • optional表示某个指定类型的值或者该值不存在(在<optional>中)
  • any表示一组未指定待选类型中的一个(在<any>中)

这三种类型为用户提供相似的功能。只可惜它们的接口并不一致。

13.5.1 variant

variant<A,B,C>通常是union(§2.4)显式应用的更安全、更便捷的替代品。 可能最简单的示例是在值和错误码之间返回其一:

variant<string,int> compose_message(istream& s)
{
    string mess;
    // ... 从s读取并构造一个消息 ...
    if (no_problems)
        return mess;            // 返回一个string
    else
        return error_number;    // 返回一个int
}

当你用一个值给variant赋值或初始化,它会记住此值的类型。 后续可以查询这个variant持有的类型并提取此值。例如:

    auto m = compose_message(cin);

    if (holds_alternative<string>(m)) {
        cout << m.get<string>();
    }
    else {
        int err = m.get<int>();
        // ... 处理错误 ...
    }

这种风格在不喜欢异常(参见 §3.5.3)的群体中颇受欢迎,但还有更有意思的用途。 例如,某个简单的编译器可能需要以不同的表示形式区分不同的节点:

using Node = variant<Expression,Statement,Declaration,Type>;

void check(Node* p)
{
    if (holds_alternative<Expression>(*p)) {
        Expression& e = get<Expression>(*p);
        // ...
    }
    else if (holds_alternative<Statement>(*p)) {
        Statement& s = get<Statement>(*p);
        // ...
    }
    // ... Declaration 和 Type ...
}

此模式通过检查待选项以决定适当的行为,它非常普遍且相对低效, 故此有必要提供直接的支持:

void check(Node* p)
{
    visit(overloaded {
        [](Expression& e) { /* ... */ },
        [](Statement& s) { /* ... */ },
        // ... Declaration 和 Type ...
    }, *p);
}

这大体上相当于虚函数调用,但有可能更快。 像所有关于性能的声明一样,在性能至关重要时,应该用测算去验证这个“有可能更快”。 对于多数应用,性能方面的差异并不显著。

很可惜,此处的overloaded是必须且不合标准的。 它是个“小魔法(piece of magic)”, 可以用一组(通常是lambda表达式)的参数构造出一个重载:

template<class... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};

template<class... Ts>
    overloaded(Ts...) -> overloaded<Ts...>; // 推导指南(deduction guide)

“访问者(visitor)”visit继而对overloaded应用(), 后者依据重载规则选择最适合的lambda表达式进行调用。

推导指南(deduction guide)是个用于解决细微二义性的机制, 主要用在基础类库中的模板类构造函数。

如果试图以某个类型访问variant,但与其中保存类型不符时, 将会抛出bad_variant_access

13.5.2 optional

optional<A>可视作一个特殊的variant(类似一个variant<A,nothing>), 或者是个关于A*概念的推广,它要么指向一个对象,要么是nullptr

如果一个函数可能返回一个对象也可能不返回时,optional就派上用场了:

optional<string> compose_message(istream& s)
{
    string mess;
    // ... 从s读取并构造一个消息 ...
    if (no_problems)
        return mess;
    return {};  // 空 optional
}

现在,可以这么用:

if (auto m = compose_message(cin))
    cout << *m;     // 请留意这个解引用 (*)
else {
    // ... 处理错误 ...
}

这在不喜欢异常(参见 §3.5.3)的群体中颇受欢迎。请留意这个*古怪的用法。 optional被视为指向对象的指针,而非对象本身。

等效于nullptroptional是空对象{}。例如:

int cat(optional<int> a, optional<int> b)
{
    int res = 0;
    if (a) res+=*a;
    if (b) res+=*b;
    return res;
}

int x = cat(17,19);
int y = cat(17,{});
int z = cat({},{});

如果试图访问无值的optional,其结果未定义;而且会抛出异常。 因此optional无法确保类型安全。

13.5.3 any

any可以持有任意的类型,并在切实持有某个类型的情况下,知道它是什么。 它大体上就是个不受限版本的variant

any compose_message(istream& s)
{
    string mess;
    // ... 从s读取并构造一个消息 ...
    if (no_problems)
        return mess;            // 返回一个 string
    else
        return error_number;    // 返回一个 int
}

在你用某个值为一个any赋值或初始化的时候,它会记住该值的类型。 随后可以查询此any持有何种类型并提取其值。例如:

auto m = compose_message(cin);
string& s = any_cast<string>(m);
cout << s;

如果试图以某个类型访问any,但与其中保存的类型不符时, 将会抛出bad_any_access。 还有些别的方法可以访问any,它们不会抛出异常。

13.6 分配器

默认情况下,标准库容器使用new分配空间。 操作符newdelete提供通用的自由存储(也叫动态内存或者堆), 该存储区域可存储任意容量的对象,且对象的生命期由用户控制。 这意味着时间和空间的开销在许多特殊情况下可以节省下来。 因此,在必要的时候,标准库容器允许使用特定语意的分配器。 这可以消除一系列烦恼,能够解决的问题有性能(如 内存池分配器)、 安全(回收时擦除内存内容的分配器)、线程内分配器, 以及非一致性内存架构(在特定内存区域上分配,这种内存搭配特定种类的指针)。 尽管它们很重要、非常专业且通常是高级技术,但此处不是讨论这些技术的地方。 不过,我还是要举一个例子,它基于实际的问题,解决方案使用了内存池分配器。

某个重要、长期运行的系统使用事件队列(参见 §15.6),该队列以vector表示事件, 这些事件以shared_ptr的形式传递。因此,一个事件最后的用户隐式地删除了它:

struct Event {
    vector<int> data = vector<int>(512);
};

list<shared_ptr<Event>> q;

void producer() {
    for (int n = 0; n!=LOTS; ++n) {
        lock_guardlk{m};    // m 是个 mutex (§15.5)
        q.push_back(make_shared<Event>());
        cv.notify_one();
    }
}

理论上讲这应该工作良好。它在逻辑上很简单,因此代码健壮而易维护。 可惜这导致了内存大规模的碎片化。 在16个生产者和4个消费者间传递了100,000个事件后,消耗了超过6GB的内存。

碎片化问题的传统解决方案是重写代码,使用内存池分配器。 内存池分配器管理固定容量的单个对象且一次性为大量对象分配空间, 而不是一个个进行分配。 好在C++17直接为此提供支持。 内存池分配器定义在std的子命名空间 pmr(“polymorphic memory resource”)中:

pmr::synchronized_pool_resource pool;           // 创建内存池
struct Event {
    vector<int> data = vector<int>{512,&pool};  // 让 Events 使用内存池
};
list<shared_ptr<Event>> q {&pool};              // 让 q 使用内存池

void producer() {
    for (int n = 0; n!=LOTS; ++n) {
        scoped_locklk{m};       // m 是个 mutex (§15.5)
        q.push_back(allocate_shared<Event,pmr::polymorphic_allocator<Event>>{&pool});
        cv.notify_one();
    }
}

现在,16个生产者和4个消费者间传递了100,000个事件后,消耗的内存不到3MB。 这是2000倍左右的提升!当然,实际利用(而非被碎片化浪费的)内存数量没变。 消灭了碎片之后,内存用量随时间推移保持稳定,因此系统可运行长达数月之久。

类似的技术在C++早期就已经取得了良好的效果,但通常需要重写代码才能用于专门的容器。 如今,标准容器通过可选的方式接受分配器参数。 容器的默认内存分配方式是使用newdelete

13.7 时间

<chrono>里面,标准库提供了处理时间的组件。 例如,这是给某个事物计时的基本方法:

using namespace std::chrono;    // 在子命名空间 std::chrono中;参见 §3.4

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(一个时间段)。 各种时钟按各自的时间单位给出结果(以上时钟按nanoseconds计时), 因此,把duration转换到一个已知的时间单位通常是个好点子。 这是duration_cast的业务。

未测算过时间的情况下,就别提“效率”这茬。臆测的性能总是不靠谱。

为了简化写法且避免出错,<chrono>提供了时间单位后缀(§5.4.4)。例如:

this_thread::sleep(10ms+33us); // 等等 10 毫秒和 33 微秒

时间后缀定义在命名空间std::chrono_literals里。

有个优雅且高效的针对<chrono>的扩展, 支持更长时间间隔(如:年和月)、日历和时区,将被添加到C++20的标准中。 它当前已经存在并被广泛应用了[Hinnant,2018] [Hinnant,2018b]。可以这么写:

auto spring_day = apr/7/2018;
cout << weekday(spring_day) << '\n'; // Saturday

它甚至能处理闰秒。

13.8 函数适配

以函数作为参数传递的时候,参数类型必须跟被调用函数的参数声明中的要求丝毫不差。 如果预期的参数“差不离儿(almost matches expectations)”, 那有三个不错的替代方案:

  • 使用lambda表达式(§13.8.1)。
  • std::mem_fn()把成员函数弄成函数对象(§13.8.2)。
  • 定义函数接受一个std::function(§13.8.3)。

还有些其它方法,但最佳方案通常是这三者之一。

13.8.1 lambda表达式适配

琢磨一下这个经典的“绘制所有图形”的例子:

void draw_all(vector<Shape*>& v)
{
    for_each(v.begin(),v.end(),[](Shape* p) { p->draw(); });
}

跟所有标准库算法类似,for_each()使用传统的函数调用语法f(x)调用其参数, 但Shapedraw()却按 OO 惯例写作x->f()。 lambda表达式轻而易举化解了二者写法的差异。

13.8.2 mem_fn()

给定一个成员函数,函数适配器mem_fn(mf)输出一个可按非成员函数调用的函数对象。 例如:

void draw_all(vector<Shape*>& v)
{
    for_each(v.begin(),v.end(),mem_fn(&Shape::draw));
}

在lambda表达式被C++11引入之前,men_fn() 及其等价物是把面向对象风格的调用转化到函数式风格的主要方式。

13.8.3 function

标准库的function是个可持有任何对象并通过调用操作符()调用的类型。 就是说,一个function类型的对象是个函数对象(§6.3.2)。例如:

int f1(double);
function<int(double)> fct1 {f1};                // 初始化为 f1

int f2(string);
function fct2 {f2};                             // fct2 的类型是 function<int(string)>

function fct3 = [](Shape* p) { p->draw(); };    // fct3 的类型是 function<void(Shape*)>

对于fct2,我让function的类型从初始化器推导:int(string)

显然,function用途广泛:用于回调、将运算作为参数传递、传递函数对象等等。 但是与直接调用相比,它可能会引入一些运行时的性能损耗, 而且function作为函数对象无法参与函数重载。 如果需要重载函数对象(也包括lambda表达式), 请考虑使用overloaded(§13.5.1)。

13.9 类型函数

类型函数(type function)是这样的函数:它在编译期进行估值, 以某个类型作为参数,或者返回一个类型。 标准库提供丰富的类型函数,用于帮助程序库作者(以及普通程序员)在编码时 能从语言本身、标准库以及普通代码中博采众长。

对于数值类型,<limits>中的numeric_limits提供了诸多有用的信息(§14.7)。 例如:

constexpr float min = numeric_limits<float>::min(); // 最小的正浮点数

同样的,对象容量可以通过内建的sizeof运算符(§1.4)获取。例如:

constexpr int szi = sizeof(int);    // int的字节数

这些类型函数是C++编译期计算机制的一部分,跟其它方式相比, 它们提供了更严格的类型检查和更好的性能。 这些特性的应用也被称为元编程(metaprogramming) 或者(在涉及模板的时候)叫模板元编程(template metaprogramming)。 此处,仅对标准库构件列举两个应用: iterator_traits(§13.9.1)和类型谓词(§13.9.2)。

概束(§7.2)让此技术的一部分变得多余,在余下的那些中还简化了一部分, 但概束尚未进入标准且未得到广泛支持,因此这些技术仍有广泛的应用。

13.9.1 iterator_traits

标准库的sort()接收一对迭代器用来定义一个序列(第12章)。 此外,这些迭代器必须支持对序列的随机访问,就是说, 它们必须是随机访问迭代器(random-access iterators)。 某些容器,比方说forward_list,不满足这一点。 更有甚者,forward_list是个单链表,因此取下标操作代价高昂, 且缺乏合理的方式去找到前一个元素。 但是,与大多数容器类似,forward_list 提供前向迭代器(forward iterators), 可供标准算法和 for-语句 对序列遍历(§6.2)。

标准库提供了一个名为iterator_traits的机制,可以对迭代器进行检查。 有了它,就可以改进 §12.8 中的 区间sort(), 使之同时接受vectorforward_list。例如:

void test(vector<string>& v, forward_list<int>& lst)
{
    sort(v);    // 排序vector
    sort(lst);  // 排序单链表
}

能让这段代码生效的技术具有普遍的用途。

首先要写两个辅助函数,它接受一个额外的参数, 以标示当前用于随机访问迭代器还是前向迭代器。 接收随机访问迭代器的版本无关紧要:

template<typename Ran>                                          // 用于随机访问迭代器
void sort_helper(Ran beg, Ran end, random_access_iterator_tag)  // 可在[beg:end)范围内取下标
{
    sort(beg,end);  // 直接排序
}

前向迭代器的版本仅仅将列表复制进vector、排序,然后复制回去:

template<typename For>                                      // 用于前向迭代器
void sort_helper(For beg, For end, forward_iterator_tag)    // 可在[beg:end)范围内遍历
{
    vector<Value_type<For>> v {beg,end};    // 用[beg:end)初始化一个vector
    sort(v.begin(),v.end());                // 利用随机访问进行排序
    copy(v.begin(),v.end(),beg);            // 把元素复制回去
}

Value_type<For>For的元素类型,被称为它的值类型(value type)。 每个标准库迭代器都有个成员value_type。 我通过定义一个类型别名(§6.4.2)来实现Value_type<For>这种写法:

template<typename C>
    using Value_type = typename C::value_type;  // C的值类型

这样,对于vector<X>而言,Value_type<X>就是X

真正的“类型魔法”在辅助函数的选择上:

template<typename 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>{}构造一个“标签(tag)”值标示迭代器类型:

  • std::random_access_iterator_tag: 如果C的迭代器支持随机访问
  • std::forward_iterator_tag:如果C的迭代器支持前向迭代

这样,就可以在编译期从这两个排序算法之间做出选择了。 这个技术被称为标签分发(tag dispatch), 是标准库内外用于提升灵活性和性能的技术之一。

Iterator_type可定义如下:

template<typename C>
    using Iterator_type = typename C::iterator; // C的迭代器类型

但是,要把这个思路扩展到缺乏成员类型的类型,比方说指针, 标准库对标签分发的支持就要以<iterator>中的iterator_traits形式出现。 为指针所做的特化会是这样:

template<class T>
struct iterator_traits<T*> {
    using difference_type = ptrdiff_t;
    using value_type = T;
    using pointer = T*;
    using reference = T&;
    using iterator_category = random_access_iterator_tag;
};

现在可以这样写:

template<typename Iter>
    using Iterator_category = typename std::iterator_traits<Iter>::iterator_category;   // Iter的类别

现在,尽管int*没有成员类型,仍可以用作随机访问迭代器; iterator_category<int*>random_access_iterator_tag

由于概束(§7.2)的出现,许多 trait 和基于 trait 的技术都变得多余了。 琢磨一下sort()这个概束版本的例子:

template<RandomAccessIterator Iter>
void sort(Iter p, Iter q); // 用于 std::vector 和其它支持随机访问的类型

template<ForwardIterator Iter>
void sort(Iter p, Iter q)
    // 用于 std::list 和其它仅支持前向遍历的类型
{
    vector<Value_type<Iter>> v {p,q};
    sort(v);                    // 使用随机访问排序sort
    copy(v.begin(),v.end(),p);
}

template<Range R> void sort(R& r)
{
    sort(r.begin(),r.end());    // 以适宜的方式排序
}

精进矣。

13.9.2 类型谓词

标准库在<type_trait>里面提供了简单的类型函数, 被称为类型谓词(type predicates), 为有关类型的一些基本问题提供应答。例如:

bool b1 = std::is_arithmetic<int>();    // 是,int是个算数类型
bool b2 = std::is_arithmetic<string>(); // 否,std::string 不是算数类型

其它范例包括is_classis_podis_literal_typehas_virtual_destructor,以及is_base_of。 它们主要在编写模板的时候发挥作用。例如:

template<typename Scalar>
class complex {
    Scalar re, im;
public:
    static_assert(is_arithmetic<Scalar>(), "Sorry, I only support complex of arithmetic types");
    // ...
};

为提高可读性,标准库定义了模板别名。例如:

template<typename T>
    constexpr bool is_arithmetic_v = std::is_arithmetic<T>();

我对_v后缀的写法不以为然,但这个定义别名的技术却大有作为。 例如,标准库这样定义概束Regular(§12.7):

template<class T>
    concept Regular = Semiregular<T> && EqualityComparable<T>;

13.9.3 enable_if

常见的类型谓词应用包括static_assert条件、编译期if,以及enable_if。 标准库的enable_if在 按需选择定义 方面应用广泛。 考虑定义一个“智能指针”:

template<typename T>
class Smart_pointer {
    // ...
    T& operator*();
    T& operator->();    // -> 能且仅能在T是类的情况下正常工作
};

该且仅该T是个类类型时定义->。 例如,Smart_pointer<vector<T>>就应该有->, 而Smart_pointer<int>就不该有。

这里没办法用编译期if,因为不在函数里。但可以这么写:

template<typename T> class Smart_pointer {
    // ...
    T& operator*();
    std::enable_if<is_class<T>(),T&> operator->();  // -> 在且仅在T是类的情况下被定义
};

如果is_class<T>()trueoperator->()的返回类型为T&; 否则operator->()的定义就被忽略了。

enable_if的语法有点怪,用起来也有点麻烦, 且在许多情况下会因为概束(§7.2)而显得多余。 不过,enable_if是大量当前模板元编程和众多标准库组件的基础。 它依赖一个名为SFINAE (“替换失败并非错误(Substitution Failure Is Not An Error)”)的语言特性。

13.10 忠告

  • [1] 有用的的程序库不一定要庞大或者复杂;§13.1。
  • [2] 任何需要申请并(显式或隐式)释放的东西都是资源;§13.2。
  • [3] 利用资源执柄去管理资源(RAII);§13.2;[CG: R.1]。
  • [4] 把unique_ptr用于多态类的对象;§13.2.1;[CG: R.20]。
  • [5] (仅)将shared_ptr用于被共享的对象;§13.2.1;[CG: R.20]。
  • [6] 优先采用附带专用语意的资源执柄,而非智能指针;§13.2.1。
  • [7] 优先采用unique_ptr,而非shared_ptr;§5.3,§13.2.1。
  • [8] 用make_unique()去构造unique_ptr;§13.2.1;[CG: R.22]。
  • [9] 用make_shared()去构造shared_ptr;§13.2.1;[CG: R.23]。
  • [10] 优先采用智能指针,而非垃圾回收;§5.3,§13.2.1。
  • [11] 别使用std::move();§13.2.2;[CG: ES.56]。
  • [12] 仅在参数转发时使用std::forward();§13.2.2。
  • [13] 将某个对象std::move()std::forward()之后, 绝不能再读取它;§13.2.2。
  • [14] 相较于 指针加数量 的方式,优先使用span§13.3;[CG: F.24]。
  • [15] 在需要容量为 constexpr 的序列时,采用array;§13.4.1。
  • [16] 优先采用array,而非内建数组;§13.4.1;[CG: SL.con.2]。
  • [17] 如果要用到N个二进制位,而N又不一定是内建整数类型位数的时候, 用bitset;§13.4.2。
  • [18] 别滥用pairtuple;具名struct通常能让代码更具可读性;§13.4.3。
  • [19] 使用pair时,利用模板参数推导或make_pair(), 以避免冗余的类型指定§13.4.3。
  • [20] 使用tuple时,利用模板参数推导或make_tuple(), 以避免冗余的类型指定§13.4.3;[CG: T.44]。
  • [21] 相较于显式的union,优先采用variant;§13.5.1; [CG: C.181]。
  • [22] 利用分配器以避免产生内存碎片;§13.6。
  • [23] 在做效率方面的决定之前,请先测定程序的执行时间;§13.7。
  • [24] 使用duration_cast为测量结果的输出选择合适的时间单位;§13.7。
  • [25] 指定duration时,采用适当的时间单位;§13.7。
  • [26] 在需要传统的函数调用写法的地方, 请使用men_fn()或lambda表达式将成员函数转换成函数对象;§13.8.2。
  • [27] 需要把可调用的东西保存起来时,请采用function;§13.8.3。
  • [28] 编写代码时,可显式依赖于类型的属性;§13.9。
  • [29] 只要条件允许,就优先使用概束去取代 trait 和enable_if;§13.9。
  • [30] 使用别名和类型谓词以简化书写;§13.9.1,§13.9.2。
1. 此引言的出处略复杂,详情请见 https://quoteinvestigator.com/2010/06/11/time-you-enjoy/ ,译法取自网络。—— 译者注

results matching ""

    No results matching ""