future

std::future

std::promise类

promise 对象可以保存某一类型 T 的值,该值可被 future 对象读取(可能在另外一个线程中),因此 promise 也提供了一种线程同步的手段。在 promise 对象构造时可以和一个共享状态(通常是std::future)相关联,并可以在相关联的共享状态(std::future)上保存一个类型为 T 的值。

可以通过 get_future 来获取与该 promise 对象相关联的 future 对象,调用该函数之后,两个对象共享相同的共享状态(shared state)

promise 对象是异步 Provider,它可以在某一时刻设置共享状态的值。
future 对象可以异步返回共享状态的值,或者在必要的情况下阻塞调用者并等待共享状态标志变为 ready,然后才能获取共享状态的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>       // std::cout
#include <functional> // std::ref
#include <thread> // std::thread
#include <future> // std::promise, std::future

void print_int(std::future<int>& fut) {
int x = fut.get(); // 获取共享状态的值.
std::cout << "value: " << x << '\n'; // 打印 value: 10.
}

int main ()
{
std::promise<int> prom; // 生成一个 std::promise<int> 对象.
std::future<int> fut = prom.get_future(); // 和 future 关联.
std::thread t(print_int, std::ref(fut)); // 将 future 交给另外一个线程t.
prom.set_value(10); // 设置共享状态的值, 此处和线程t保持同步.
t.join();
return 0;
}

std::promise构造函数

默认构造函数,初始化一个空的共享状态。
带自定义内存分配器的构造函数,与默认构造函数类似,但是使用自定义分配器来分配共享状态。
拷贝构造函数,被禁用。
移动构造函数。

另外,std::promise 的 operator= 没有拷贝语义,即 std::promise 普通的赋值操作被禁用,operator= 只有 move 语义,所以 std::promise 对象是禁止拷贝的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>       // std::cout
#include <thread> // std::thread
#include <future> // std::promise, std::future

std::promise<int> prom;

void print_global_promise () {
std::future<int> fut = prom.get_future();
int x = fut.get();
std::cout << "value: " << x << '\n';
}

int main ()
{
std::thread th1(print_global_promise);
prom.set_value(10);
th1.join();

prom = std::promise<int>(); // prom 被move赋值为一个新的 promise 对象.

std::thread th2 (print_global_promise);
prom.set_value (20);
th2.join();

return 0;
}

std::packaged_task类

std::packaged_task 包装一个可调用的对象,并且允许异步获取该可调用对象产生的结果,从包装可调用对象意义上来讲,std::packaged_task 与 std::function 类似,只不过 std::packaged_task 将其包装的可调用对象的执行结果传递给一个 std::future 对象(该对象通常在另外一个线程中获取 std::packaged_task 任务的执行结果)。

std::packaged_task 对象内部包含了两个最基本元素,一、被包装的任务(stored task),任务(task)是一个可调用的对象,如函数指针、成员函数指针或者函数对象,二、共享状态(shared state),用于保存任务的返回值,可以通过 std::future 对象来达到异步访问共享状态的效果。

可以通过 std::packged_task::get_future 来获取与共享状态相关联的 std::future 对象。在调用该函数之后,两个对象共享相同的共享状态,具体解释如下:

std::packaged_task 对象是异步 Provider,它在某一时刻通过调用被包装的任务来设置共享状态的值。
std::future 对象是一个异步返回对象,通过它可以获得共享状态的值,当然在必要的时候需要等待共享状态标志变为 ready.
std::packaged_task 的共享状态的生命周期一直持续到最后一个与之相关联的对象被释放或者销毁为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>     // std::cout
#include <future> // std::packaged_task, std::future
#include <chrono> // std::chrono::seconds
#include <thread> // std::thread, std::this_thread::sleep_for

// count down taking a second for each value:
int countdown (int from, int to) {
for (int i=from; i!=to; --i) {
std::cout << i << '\n';
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "Finished!\n";
return from - to;
}

int main ()
{
std::packaged_task<int(int,int)> task(countdown); // 设置 packaged_task
std::future<int> ret = task.get_future(); // 获得与 packaged_task 共享状态相关联的 future 对象.

std::thread th(std::move(task), 10, 0); //创建一个新线程完成计数任务.

int value = ret.get(); // 等待任务完成并获取结果.

std::cout << "The countdown lasted for " << value << " seconds.\n";

th.join();
return 0;
}

std::packaged_task构造函数

std::packaged_task 构造函数共有 5 中形式,不过拷贝构造已经被禁用了。下面简单地介绍一下几种构造函数的语义:

默认构造函数,初始化一个空的共享状态,并且该 packaged_task 对象无包装任务。
初始化一个共享状态,并且被包装任务由参数 fn 指定。
带自定义内存分配器的构造函数,与默认构造函数类似,但是使用自定义分配器来分配共享状态。
拷贝构造函数,被禁用。
移动构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>     // std::cout
#include <utility> // std::move
#include <future> // std::packaged_task, std::future
#include <thread> // std::thread

int main ()
{
std::packaged_task<int(int)> foo; // 默认构造函数.

// 使用 lambda 表达式初始化一个 packaged_task 对象.
std::packaged_task<int(int)> bar([](int x){return x*2;});

foo = std::move(bar); // move-赋值操作,也是 C++11 中的新特性.

// 获取与 packaged_task 共享状态相关联的 future 对象.
std::future<int> ret = foo.get_future();

std::thread(std::move(foo), 10).detach(); // 产生线程,调用被包装的任务.

int value = ret.get(); // 等待任务完成并获取结果.
std::cout << "The double of 10 is " << value << ".\n";

return 0;
}

std::packaged_task::valid

检查当前 packaged_task 是否和一个有效的共享状态相关联,对于由默认构造函数生成的 packaged_task 对象,该函数返回 false,除非中间进行了 move 赋值操作或者 swap 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>     // std::cout
#include <utility> // std::move
#include <future> // std::packaged_task, std::future
#include <thread> // std::thread

// 在新线程中启动一个 int(int) packaged_task.
std::future<int> launcher(std::packaged_task<int(int)>& tsk, int arg)
{
if (tsk.valid()) {
std::future<int> ret = tsk.get_future();
std::thread (std::move(tsk),arg).detach();
return ret;
}
else return std::future<int>();
}

int main ()
{
std::packaged_task<int(int)> tsk([](int x){return x*2;});

std::future<int> fut = launcher(tsk,25);

std::cout << "The double of 25 is " << fut.get() << ".\n";

return 0;
}

std::packaged_task::reset

重置 packaged_task 的共享状态,但是保留之前的被包装的任务

std::packaged_task::swap

交换 packaged_task 的共享状态

std::future 类

std::future 究竟是什么呢?简单地说,std::future 可以用来获取异步任务的结果,因此可以把它当成一种简单的线程间同步的手段。std::future 通常由某个 Provider 创建,你可以把 Provider 想象成一个异步任务的提供者,Provider 在某个线程中设置共享状态的值,与该共享状态相关联的 std::future 对象调用 get(通常在另外一个线程中) 获取该值,如果共享状态的标志不为 ready,则调用 std::future::get 会阻塞当前的调用者,直到 Provider 设置了共享状态的值(此时共享状态的标志变为 ready),std::future::get 返回异步任务的值或异常(如果发生了异常)。

一个有效(valid)的 std::future 对象通常由以下三种 Provider 创建,并和某个共享状态相关联。Provider 可以是函数或者类,其实我们前面都已经提到了,他们分别是:

std::async 函数,本文后面会介绍 std::async() 函数。
std::promise::get_future,get_future 为 promise 类的成员函数
std::packaged_task::get_future, get_future为 packaged_task 的成员函数
一个 std::future 对象只有在有效(valid)的情况下才有用(useful),由 std::future 默认构造函数创建的 future 对象不是有效的(除非当前非有效的 future 对象被 move 赋值另一个有效的 future 对象)。

在一个有效的 future 对象上调用 get 会阻塞当前的调用者,直到 Provider 设置了共享状态的值或异常(此时共享状态的标志变为 ready),std::future::get 将返回异步任务的值或异常(如果发生了异常)。

std::async

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// future example
#include <iostream> // std::cout
#include <future> // std::async, std::future
#include <chrono> // std::chrono::milliseconds

// a non-optimized way of checking for prime numbers:
bool
is_prime(int x)
{
for (int i = 2; i < x; ++i)
if (x % i == 0)
return false;
return true;
}

int
main()
{
// call function asynchronously:
std::future < bool > fut = std::async(is_prime, 444444443);

// do something while waiting for function to set future:
std::cout << "checking, please wait";
std::chrono::milliseconds span(100);
while (fut.wait_for(span) == std::future_status::timeout)
std::cout << '.';

bool x = fut.get(); // retrieve return value

std::cout << "\n444444443 " << (x ? "is" : "is not") << " prime.\n";

return 0;
}

std::future构造函数

std::future 的拷贝构造函数是被禁用的,只提供了默认的构造函数和 move 构造函数。另外,std::future 的普通赋值操作也被禁用,只提供了 move 赋值操作

std::future::share

返回一个 std::shared_future 对象,调用该函数之后,该 std::future 对象本身已经不和任何共享状态相关联,因此该 std::future 的状态不再是 valid 的了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>       // std::cout
#include <future> // std::async, std::future, std::shared_future

int do_get_value() { return 10; }

int main ()
{
std::future<int> fut = std::async(do_get_value);
std::shared_future<int> shared_fut = fut.share();

// 共享的 future 对象可以被多次访问.
std::cout << "value: " << shared_fut.get() << '\n';
std::cout << "its double: " << shared_fut.get()*2 << '\n';

return 0;
}

std::future::get

当与该 std::future 对象相关联的共享状态标志变为 ready 后,调用该函数将返回保存在共享状态中的值,如果共享状态的标志不为 ready,则调用该函数会阻塞当前的调用者,而此后一旦共享状态的标志变为 ready,get 返回 Provider 所设置的共享状态的值或者异常(如果抛出了异常)

std::future::valid

检查当前的 std::future 对象是否有效,即释放与某个共享状态相关联。一个有效的 std::future 对象只能通过 std::async(), std::future::get_future 或者 std::packaged_task::get_future 来初始化。

std::future::wait

等待与当前std::future 对象相关联的共享状态的标志变为 ready.

如果共享状态的标志不是 ready(此时 Provider 没有在共享状态上设置值(或者异常)),调用该函数会被阻塞当前线程,直到共享状态的标志变为 ready。
一旦共享状态的标志变为 ready,wait() 函数返回,当前线程被解除阻塞,但是 wait() 并不读取共享状态的值或者异常。

std::future::wait_for

与 std::future::wait() 的功能类似,即等待与该 std::future 对象相关联的共享状态的标志变为 ready,而与 std::future::wait() 不同的是,wait_for() 可以设置一个时间段 rel_time,如果共享状态的标志在该时间段结束之前没有被 Provider 设置为 ready,则调用 wait_for 的线程被阻塞,在等待了 rel_time 的时间长度后 wait_for() 返回(future_status::ready、future_status::timeout、future_status::deferred)

std::future::wait_until

与std::future::wait_for类似

std::shared_future类

std::shared_future 与 std::future 类似,但是 std::shared_future 可以拷贝、多个 std::shared_future 可以共享某个共享状态的最终结果(即共享状态的某个值或者异常)。shared_future 可以通过某个 std::future 对象隐式转换(参见 std::shared_future 的构造函数),或者通过 std::future::share() 显示转换,无论哪种转换,被转换的那个 std::future 对象都会变为 not-valid.

std::future_error类

std::future_error 继承子 C++ 标准异常体系中的 logic_error

std::future枚举类

enum class future_errc;
enum class future_status;
enum class launch;
下面分别介绍以上三种枚举类型:

std::future_errc 类型描述如下(参考):
类型 取值 描述
broken_promise 0 与该 std::future 共享状态相关联的 std::promise 对象在设置值或者异常之前一被销毁。
future_already_retrieved 1 与该 std::future 对象相关联的共享状态的值已经被当前 Provider 获取了,即调用了 std::future::get 函数。
promise_already_satisfied 2 std::promise 对象已经对共享状态设置了某一值或者异常。
no_state 3 无共享状态。

std::future_status 类型主要用在 std::future(或std::shared_future)中的 wait_for 和 wait_until 两个函数中的。
类型 取值 描述
future_status::ready 0 wait_for(或wait_until) 因为共享状态的标志变为 ready 而返回。
future_status::timeout 1 超时,即 wait_for(或wait_until) 因为在指定的时间段(或时刻)内共享状态的标志依然没有变为 ready 而返回。
future_status::deferred 2 共享状态包含了 deferred 函数。

std::launch 类型,该枚举类型主要是在调用 std::async 设置异步任务的启动策略的。

类型 描述
launch::async Asynchronous: 异步任务会在另外一个线程中调用,并通过共享状态返回异步任务的结果(一般是调用 std::future::get() 获取异步任务的结果)。
launch::deferred Deferred: 异步任务将会在共享状态被访问时调用,相当与按需调用(即延迟(deferred)调用)。

第一部分

注意:模板学习内容均参考 https://github.com/actuatorpro/CppTemplateTutorial.git, 可自主去github上学习

1. 前言

1.1. C++另类简介:比你用的复杂,但比你想的简单

C++似乎从它为世人所知的那天开始便成为天然的话题性编程语言。在它在周围有着形形色色的赞美与贬低之词。当我在微博上透露欲写此文的意愿时,也收到了很多褒贬不一的评论。作为一门语言,能拥有这么多使用并恨着它、使用并畏惧它的用户,也算是语言丛林里的奇观了。

C++之所以变成一门层次丰富、结构多变、语法繁冗的语言,是有着多层次的原因的。Bjarne在《The Design and Evolution of C++》一书中,详细的解释了C++为什么会变成如今(C++98/03)的模样。这本书也是我和陈梓瀚一直对各位已经入门的新手强烈推荐的一本书。通过它你多少可以明白,C++的诸多语法要素之所以变成如今的模样,实属迫不得已。

模板作为C++中最有特色的语言特性,它堪称玄学的语法和语义,理所应当的成为初学者的梦魇。甚至很多工作多年的人也对C++的模板部分保有充分的敬畏。在多数的编码标准中,Template俨然和多重继承一样,成为了一般程序员(非程序库撰写者)的禁区。甚至运用模板较多的Boost,也成为了“众矢之的”。

但是实际上C++模板远没有想象的那么复杂。我们只需要换一个视角:在C++03的时候,模板本身就可以独立成为一门“语言”。它有“值”,有“函数”,有“表达式”和“语句”。除了语法比较蹩脚外,它既没有指针也没有数组,更没有C++里面复杂的继承和多态。可以说,它要比C语言要简单的多。如果我们把模板当做是一门语言来学习,那只需要花费学习OO零头的时间即可掌握。按照这样的思路,可以说在各种模板书籍中出现的多数技巧,都可以被轻松理解。

简单回顾一下模板的历史。87年的时候,泛型(Generic Programming)便被纳入了C++的考虑范畴,并直接导致了后来模板语法的产生。可以说模板语法一开始就是为了在C++中提供泛型机制。92年的时候,Alexander Stepanov开始研究利用模板语法制作程序库,后来这一程序库发展成STL,并在93年被接纳入标准中。

此时不少人以为STL已经是C++模板的集大成之作,C++模板技止于此。但是在95年的《C++ Report》上,John Barton和Lee Nackman提出了一个矩阵乘法的模板示例。可以说元编程在那个时候开始被很多人所关注。自此篇文章发表之后,很多大牛都开始对模板产生了浓厚的兴趣。其中对元编程技法贡献最大的当属Alexandrescu的《Modern C++ Design》及模板程序库Loki。这一2001年发表的图书间接地导致了模板元编程库的出现。书中所使用的Typelist等泛型组件,和Policy等设计方法令人耳目一新。但是因为全书用的是近乎Geek的手法来构造一切设施,因此使得此书阅读起来略有难度。

2002年出版的另一本书《C++ Templates》,可以说是在Template方面的集大成之作。它详细阐述了模板的语法、提供了和模板有关的语言细节信息,举了很多有代表性例子。但是对于模板新手来说,这本书细节如此丰富,让他们随随便便就打了退堂鼓缴械投降。

本文的写作初衷,就是通过“编程语言”的视角,介绍一个简单、清晰的“模板语言”。我会尽可能地将模板的诸多要素连串起来,用一些简单的例子帮助读者学习这门“语言”,让读者在编写、阅读模板代码的时候,能像 if(exp) { dosomething(); }一样的信手拈来,让“模板元编程”技术成为读者牢固掌握、可举一反三的有用技能。

1.2. 适宜读者群

因为本文并不是用于C++入门,例子中也多少会牵涉一些其它知识,因此如果读者能够具备以下条件,会读起来更加轻松:

  • 熟悉C++的基本语法;
  • 使用过STL;
  • 熟悉一些常用的算法,以及递归等程序设计方法。

此外,尽管第一章会介绍一些Template的基本语法,但是还是会略显单薄。因此也希望读者能对C++ Template最基本语法形式有所了解和掌握;如果会编写基本的函数模板和类模板那就更好了。

诚如上节所述,本文并不是《C++ Templates》的简单重复,与《Modern C++ Design》交叠更少。从知识结构上,我建议大家可以先读本文,再阅读《C++ Templates》获取更丰富的语法与实现细节,以更进一步;《Modern C++ Design》除了元编程之外,还有很多的泛型编程示例,原则上泛型编程的部分与我所述的内容交叉不大,读者在读完1-3章了解模板的基本规则之后便可阅读《MCD》的相应章节;元编程部分(如Typelist)建议在阅读完本文之后再行阅读,或许会更易理解。

1.3. 版权

本文是随写随即同步到Github上,因此在行文中难免会遗漏引用。本文绝大部分内容应是直接承出我笔,但是也不定会有他山之石。所有指涉内容我会尽量以引号框记,或在上下文和边角注记中标示,如有遗漏烦请不吝指出。

全文所有为我所撰写的部分,作者均保留所有版权。如果有需要转帖或引用,还请注明出处并告知于我。

1.4. 推荐编译环境

C++编译器众多,且对模板的支持可能存在细微差别。如果没有特别强调,本书行文过程中,使用了下列编译器来测试文中提供的代码和示例:

  • Clang 14.0.3; 15.0 (amd64)
  • Visual Studio 2022 19.2+ (amd64)

此外,部分复杂实例我们还在文中提供了在线的编译器预览以方便大家阅读和测试。在线编译器参见: gcc.godbolt.org

一些示例中用到的特性所对应的C++标准:

特性 标准
std::decay_t C++ 14

1.5. 体例

1.5.1. 示例代码

1
2
3
void SampleCode() {
// 这是一段示例代码
}

1.5.2. 引用

引用自C++标准:

1.1.2/1 这是一段引用或翻译自标准的文字

引用自其他图书:

《书名》
这是一段引用或翻译自其他图书的文字

1.6. 意见、建议、喷、补遗、写作计划

  • 需增加:
    • 模板的使用动机。
    • 增加“如何使用本文”一节。本节将说明全书的体例(强调字体、提示语、例子的组织),所有的描述、举例、引用在重审时将按照体例要求重新组织。
    • 除了用于描述语法的例子外,其他例子将尽量赋予实际意义,以方便阐述意图。
    • 在合适的章节完整叙述模板的类型推导规则。Parameter-Argument, auto variable, decltype, decltype(auto)
    • 在函数模板重载和实例化的部分讲述ADL。
    • 变参模板处应当按照标准(Argument Packing/Unpacking)来讲解。
  • 建议:
    • 比较模板和函数的差异性
    • 蓝色:C++14 Return type deduction for normal functions 的分析

memory

内存模型

原子类型的大多数 API 都需要程序员提供一个 std::memory_order(可译为内存序,访存顺序) 的枚举类型值作为参数,比如:atomic_store,atomic_load,atomic_exchange,atomic_compare_exchange 等 API 的最后一个形参为 std::memory_order order,默认值是 std::memory_order_seq_cst(顺序一致性)。那么究竟什么是 std::memory_order 呢,为了解答这个问题,我们先来讨论 C++11 的内存模型。

一般来讲,内存模型可分为静态内存模型和动态内存模型,静态内存模型主要涉及类的对象在内存中是如何存放的,即从结构(structural)方面来看一个对象在内存中的布局。动态内存模型可理解为存储一致性模型,主要是从行为(behavioral)方面来看多个线程对同一个对象同时(读写)操作时(concurrency)所做的约束,动态内存模型理解起来稍微复杂一些,涉及了内存,Cache,CPU 各个层次的交互,尤其是在共享存储系统中,为了保证程序执行的正确性,就需要对访存事件施加严格的限制。

std::memory_order 规定了普通访存操作和相邻的原子访存操作之间的次序是如何安排的,在多核系统中,当多个线程同时读写多个变量时,其中的某个线程所看到的变量值的改变顺序可能和其他线程写入变量值的次序不相同。同时,不同的线程所观察到的某变量被修改次序也可能不相同。然而,如果保证所有对原子变量的操作都是顺序的话,可能对程序的性能影响很大,因此,我们可以通过 std::memory_order 来指定编译器对访存次序所做的限制。因此,在原子类型的 API 中,我们可以通过额外的参数指定该原子操作的访存次序(内存序),默认的内存序是 std::memory_order_seq_cst。

我们可以把上述 6 中访存次序(内存序)分为 3 类,顺序一致性模型(std::memory_order_seq_cst),Acquire-Release 模型(std::memory_order_consume, std::memory_order_acquire, std::memory_order_release, std::memory_order_acq_rel,) 和 Relax 模型(std::memory_order_relaxed)。三种不同的内存模型在不同类型的 CPU上(如 X86,ARM,PowerPC等)所带来的代价也不一样。例如,在 X86 或者 X86-64平台下,Acquire-Release 类型的访存序不需要额外的指令来保证原子性,即使顺序一致性类型操作也只需要在写操作(Store)时施加少量的限制,而在读操作(Load)则不需要花费额外的代价来保证原子性。

处理器一致性(Processor Consistency)模型:处理器一致性(Processor Consistency)模型比顺序一致性模型弱,因此对于某些在顺序一致性模型下能够正确执行的程序在处理器一致性条件下执行时可能会导致错误的结果,处理器一致性模型对访存事件发生次序施加的限制是:(1). 在任意读操作(Load)被允许执行之前,所有在同一处理器中先于这一 Load 的读操作都已完成;(2). 在任意写操作(Store)被允许执行之前,所有在同一处理器中先于这一 Store 的访存操作(包括 Load 和 Store操作)都已完成。上述条件允许 Store 之后的 Load 越过 Store 操作而有限执行。

弱一致性(Weak Consistency)模型:弱一致性(Weak Consistency)模型的主要思想是将同步操作和普通的访存操作区分开来,程序员必须用硬件可识别的同步操作把对可写共享单元的访存保护起来,以保证多个处理器对可写单元的访问是互斥的。弱一致性对访存事件发生次序的限制如下:(1). 同步操作的执行满足顺序一致性条件; (2). 在任一普通访存操作被允许执行之前,所有在同一处理器中先于这一访存操作的同步操作都已完成; (3). 在任一同步操作被允许执行之前,所有在同一处理器中先于这一同步操作的普通操作都已完成。上述条件允许在同步操作之间的普通访存操作执行时不用考虑进程之间的相关,虽然弱一致性增加了程序员的负担,但是它能有效地提高系统的性能。

释放一致性(Release Consistency)模型:释放一致性(Release Consistency)模型是对弱一致性(Weak Consistency)模型的改进,它把同步操作进一步分成了获取操作(Acquire)和释放操作(Release)。Acquire 用于获取对某些共享变量的独占访问权,而 Release 则用于释放这种访问权,释放一致性(Release Consistency)模型访存事件发生次序的限制如下:(1). 同步操作的执行满足顺序一致性条件; (2). 在任一普通访存操作被允许执行之前,所有在同一处理器中先于这一访存操作的 Acquire 操作都已完成; (3). 在任一 Release 操作被允许执行之前,所有在同一处理器中先于这一 Release 操作的普通操作都已完成。

在硬件实现的释放一致性模型中,对共享单元的访存是及时进行的,并在执行获取操作(Acquire)和释放操作(Release)时对齐。在共享虚拟存储系统或者在由软件维护的数据一致性的共享存储系统中,由于通信和数据交换的开销很大,有必要减少通信和数据交换的次数。为此,人们在释放一致性(Release Consistency)模型的基础上提出了急切更新释放一致性模型(Eager Release Consistency)和懒惰更新释放一致性模型(Lazy Release Consistency)。在急切更新释放一致性模型中,在临界区内的多个存数操作对共享内存的更新不是及时进行的,而是在执行 Release 操作之前(即退出临界区之前)集中进行,把多个存数操作合并在一起统一执行,从而减少了通信次数。而在懒惰更新释放一致性模型中,由一个处理器对某单元的存数操作并不是由此处理器主动传播到所有共享该单元的其他处理器,而是在其他处理器要用到此处理器所写的数据时(即其他处理器执行 Acquire 操作时)再向此处理器索取该单元的最新备份,这样可以进一步减少通信量。

mutex

mutex

Mutex 系列类

std::mutex : 最基本的Mutex类
std::recursive_mutex : 递归Mutex类
std::time_mutex : 定时Mutex类
std::recursive_timed_mutex : 定时递归Mutex类

Lock类

std::lock_guard : 与Mutex RAII相关,方便线程对互斥量上锁。
std::unique_lock : 与Mutex RAII相关,但提供了更好的上锁和解锁控制

其他类型

std::once_flag
std::adopt_lock_t
std::defer_lock_t
std::try_to_lock_t

函数

std::try_lock : 尝试同时对多个互斥量上锁
std::lock : 可以同时对多个互斥量上锁
std::call_once : 如果多个线程需要同时调用某个函数,call_once 可以保证多个线程对该函数只调用一次。

std::mutex介绍

std::mutex 是C++11 中最基本的互斥量,std::mutex 对象提供了独占所有权的特性——即不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock 则可以递归地对互斥量对象上锁。

构造函数,std::mutex不允许拷贝构造,也不允许 move 拷贝,最初产生的 mutex 对象是处于 unlocked 状态的。
lock(),调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:(1). 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。(2). 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
unlock(), 解锁,释放对互斥量的所有权。
try_lock(),尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况,(1). 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。(2). 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>       // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex

volatile int counter(0); // non-atomic counter
std::mutex mtx; // locks access to counter

void attempt_10k_increases() {
for (int i=0; i<10000; ++i) {
if (mtx.try_lock()) { // only increase if currently not locked:
++counter;
mtx.unlock();
}
}
}

int main (int argc, const char* argv[]) {
std::thread threads[10];
for (int i=0; i<10; ++i)
threads[i] = std::thread(attempt_10k_increases);

for (auto& th : threads) th.join();
std::cout << counter << " successful increases of the counter.\n";

return 0;
}

std::recursive_mutex

std::recursive_mutex 与 std::mutex 一样,也是一种可以被上锁的对象,但是和 std::mutex 不同的是,std::recursive_mutex 允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,std::recursive_mutex 释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),可理解为 lock() 次数和 unlock() 次数相同,除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。

std::time_mutex

std::time_mutex 比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until()。

try_lock_for 函数接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。

try_lock_until 函数则接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>       // std::cout
#include <chrono> // std::chrono::milliseconds
#include <thread> // std::thread
#include <mutex> // std::timed_mutex

std::timed_mutex mtx;

void fireworks() {
// waiting to get a lock: each thread prints "-" every 200ms:
while (!mtx.try_lock_for(std::chrono::milliseconds(200))) {
std::cout << "-";
}
// got a lock! - wait for 1s, then this thread prints "*"
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "*\n";
mtx.unlock();
}

int main ()
{
std::thread threads[10];
// spawn 10 threads:
for (int i=0; i<10; ++i)
threads[i] = std::thread(fireworks);

for (auto& th : threads) th.join();

return 0;
}

std::recursive_timed_mutex

std::recursive_timed_mutex 和 std::recursive_mutex 与 std::mutex 的关系一样,std::recursive_timed_mutex 的特性也可以从 std::timed_mutex 推导出来。

std::lock_guard

与Mutex RAII相关,方便线程对互斥量上锁,(初始化时自动上锁,作用域结束后自动解锁)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>       // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::lock_guard
#include <stdexcept> // std::logic_error

std::mutex mtx;

void print_even (int x) {
if (x%2==0) std::cout << x << " is even\n";
else throw (std::logic_error("not even"));
}

void print_thread_id (int id) {
try {
// using a local lock_guard to lock mtx guarantees unlocking on destruction / exception:
std::lock_guard<std::mutex> lck (mtx);
print_even(id);
}
catch (std::logic_error&) {
std::cout << "[exception caught]\n";
}
}

int main ()
{
std::thread threads[10];
// spawn 10 threads:
for (int i=0; i<10; ++i)
threads[i] = std::thread(print_thread_id,i+1);

for (auto& th : threads) th.join();

return 0;
}

std::unique_lock

与 Mutex RAII 相关,方便线程对互斥量上锁,但提供了更好的上锁和解锁控制
unique_lock比lock_guard能提供更多的功能特性(但需要付出性能的一些代价)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>       // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock

std::mutex mtx; // mutex for critical section

void print_block (int n, char c) {
// critical section (exclusive access to std::cout signaled by lifetime of lck):
std::unique_lock<std::mutex> lck (mtx);
for (int i=0; i<n; ++i) {
std::cout << c;
}
std::cout << '\n';
}

int main ()
{
std::thread th1 (print_block,50,'*');
std::thread th2 (print_block,50,'$');

th1.join();
th2.join();

return 0;
}

std::call_once

std::call_once 是 C++ 标准库提供的一个函数,用于保证一个函数只会被调用一次,即使在多线程环境下也能确保线程安全。std::call_once 主要用于执行只需要执行一次的初始化操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <iostream>
#include <mutex>
#include <thread>

std::once_flag flag1, flag2;

void simple_do_once()
{
std::call_once(flag1, [](){ std::cout << "简单样例:调用一次\n"; });
}

void may_throw_function(bool do_throw)
{
if (do_throw)
{
std::cout << "抛出:call_once 会重试\n"; // 这会出现不止一次
throw std::exception();
}
std::cout << "没有抛出,call_once 不会再重试\n"; // 保证一次
}

void do_once(bool do_throw)
{
try
{
std::call_once(flag2, may_throw_function, do_throw);
}
catch (...) {}
}

int main()
{
std::thread st1(simple_do_once);
std::thread st2(simple_do_once);
std::thread st3(simple_do_once);
std::thread st4(simple_do_once);
st1.join();
st2.join();
st3.join();
st4.join();

std::thread t1(do_once, true);
std::thread t2(do_once, true);
std::thread t3(do_once, false);
std::thread t4(do_once, true);
t1.join();
t2.join();
t3.join();
t4.join();
}

thread

thread

std::thread构造

(1). 默认构造函数,创建一个空的 thread 执行对象。
(2). 初始化构造函数,创建一个 thread对象,该 thread对象可被 joinable,新产生的线程会调用 fn 函数,该函数的参数由 args 给出。
(3). 拷贝构造函数(被禁用),意味着 thread 不可被拷贝构造。
(4). move 构造函数,move 构造函数,调用成功之后 x 不代表任何 thread 执行对象。
注意:可被 joinable 的 thread 对象必须在他们销毁之前被主线程 join 或者将其设置为 detached.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <utility>
#include <thread>
#include <chrono>
#include <functional>
#include <atomic>

void f1(int n)
{
for (int i = 0; i < 5; ++i) {
std::cout << "Thread " << n << " executing\n";
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}

void f2(int& n)
{
for (int i = 0; i < 5; ++i) {
std::cout << "Thread 2 executing\n";
++n;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}

int main()
{
int n = 0;
std::thread t1; // t1 is not a thread
std::thread t2(f1, n + 1); // pass by value
std::thread t3(f2, std::ref(n)); // pass by reference
std::thread t4(std::move(t3)); // t4 is now running f2(). t3 is no longer a thread
t2.join();
t4.join();
std::cout << "Final value of n is " << n << '\n';
}

std::thread赋值操作:

(1). move 赋值操作,如果当前对象不可 joinable,需要传递一个右值引用(rhs)给 move 赋值操作;如果当前对象可被 joinable,则 terminate() 报错。
(2). 拷贝赋值操作被禁用,thread 对象不可被拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
#include <stdlib.h>

#include <chrono> // std::chrono::seconds
#include <iostream> // std::cout
#include <thread> // std::thread, std::this_thread::sleep_for

void thread_task(int n) {
std::this_thread::sleep_for(std::chrono::seconds(n));
std::cout << "hello thread "
<< std::this_thread::get_id()
<< " paused " << n << " seconds" << std::endl;
}

/*
* === FUNCTION =========================================================
* Name: main
* Description: program entry routine.
* ========================================================================
*/
int main(int argc, const char *argv[])
{
std::thread threads[5];
std::cout << "Spawning 5 threads...\n";
for (int i = 0; i < 5; i++) {
threads[i] = std::thread(thread_task, i + 1);
}
std::cout << "Done spawning threads! Now wait for them to join\n";
for (auto& t: threads) {
t.join();
}
std::cout << "All threads joined.\n";

return EXIT_SUCCESS;
}

其他成员函数

  1. get_id()

    1
    atd::thread::id main_thread_id = std::this_thread::get_id();
  2. joinable()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    std::thread foo;
    std::thread bar(mythread);

    std::cout << "Joinable after construction:\n" << std::boolalpha;
    std::cout << "foo: " << foo.joinable() << '\n';//false
    std::cout << "bar: " << bar.joinable() << '\n';//true

    if (foo.joinable()) foo.join();
    if (bar.joinable()) bar.join();

    std::cout << "Joinable after joining:\n" << std::boolalpha;
    std::cout << "foo: " << foo.joinable() << '\n';//false
    std::cout << "bar: " << bar.joinable() << '\n';//false
  3. join() //等待线程完成其执行

  4. detach() //允许线程独立执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <iostream>       // std::cout
    #include <thread> // std::thread, std::this_thread::sleep_for
    #include <chrono> // std::chrono::seconds

    void pause_thread(int n)
    {
    std::this_thread::sleep_for (std::chrono::seconds(n));
    std::cout << "pause of " << n << " seconds ended\n";
    }

    int main()
    {
    std::cout << "Spawning and detaching 3 threads...\n";
    std::thread (pause_thread,1).detach();
    std::thread (pause_thread,2).detach();
    std::thread (pause_thread,3).detach();
    std::cout << "Done spawning threads.\n";

    std::cout << "(the main thread will now pause for 5 seconds)\n";
    // give the detached threads time to finish (but not guaranteed!):
    pause_thread(5);
    return 0;
    }
  5. swap() //Swaps the state of the object with that of x.

  6. native_handle()
    std::thread::native_handle_type nht = std::thread::native_handle();

第二部分

2. Template的基本语法

2.1. 什么是模板(Template)

2.2. 类模板 (Class Template) 的基本语法

2.2.1. “模板类”还是“类模板”

2.2.2. Class Template的与成员变量定义

我们来回顾一下最基本的Class Template声明和定义形式:

Class Template声明:

1
template <typename T> class ClassA;

Class Template定义:

1
2
3
4
template <typename T> class ClassA
{
T member;
};

template 是C++关键字,意味着我们接下来将定义一个模板。和函数一样,模板也有一系列参数。这些参数都被囊括在template之后的< >中。在上文的例子中, typename T便是模板参数。回顾一下与之相似的函数参数的声明形式:

1
void foo(int a);

T则可以类比为函数形参a,这里的“模板形参”T,也同函数形参一样取成任何你想要的名字;typename则类似于例子中函数参数类型int,它表示模板参数中的T将匹配一个类型。除了 typename 之外,我们在后面还要讲到,整型也可以作为模板的参数。

在定义完模板参数之后,便可以定义你所需要的类。不过在定义类的时候,除了一般类可以使用的类型外,你还可以使用在模板参数中使用的类型 T。可以说,这个 T是模板的精髓,因为你可以通过指定模板实参,将T替换成你所需要的类型。

例如我们用ClassA<int>来实例化类模板ClassA,那么ClassA<int>可以等同于以下的定义:

1
2
3
4
// 注意:这并不是有效的C++语法,只是为了说明模板的作用
typedef class {
int member;
} ClassA<int>;

可以看出,通过模板参数替换类型,可以获得很多形式相同的新类型,有效减少了代码量。这种用法,我们称之为“泛型”(Generic Programming),它最常见的应用,即是STL中的容器类模板。

2.2.3. 模板的使用

对于C++来说,类型最重要的作用之一就是用它去产生一个变量。例如我们定义了一个动态数组(列表)的类模板vector,它对于任意的元素类型都具有push_back和clear的操作,我们便可以如下定义这个类:

1
2
3
4
5
6
7
8
9
10
template <typename T>
class vector
{
public:
void push_back(T const&);
void clear();

private:
T* elements;
};

此时我们的程序需要一个整型和一个浮点型的列表,那么便可以通过以下代码获得两个变量:

1
2
vector<int> intArray;
vector<float> floatArray;

此时我们就可以执行以下的操作,获得我们想要的结果:

1
2
intArray.push_back(5);
floatArray.push_back(3.0f);

变量定义的过程可以分成两步来看:第一步,vector<int>int绑定到类模板vector上,获得了一个“普通的类vector<int>”;第二步通过“vector”定义了一个变量。
与“普通的类”不同,类模板是不能直接用来定义变量的 —— 毕竟它的名字是“模板”而不是“类”。例如:

1
vector unknownVector; // 错误示例

这样就是错误的。我们把通过类型绑定将类模板变成“普通的类”的过程,称之为模板实例化(Template Instantiate)。实例化的语法是:

1
模板名 < [模板实参1,模板实参2,...] >

看几个例子:

1
2
3
4
5
6
7
8
9
vector<int>
ClassA<double>

template <typename T0, typename T1> class ClassB
{
// Class body ...
};

ClassB<int, float>

当然,在实例化过程中,被绑定到模板参数上的类型(即模板实参)需要与模板形参正确匹配。
就如同函数一样,如果没有提供足够并匹配的参数,模板便不能正确的实例化。

2.2.4. 类模板的成员函数定义

由于C++11正式废弃“模板导出”这一特性,因此在类模板的变量在调用成员函数的时候,需要看到完整的成员函数定义。因此现在的类模板中的成员函数,通常都是以内联的方式实现。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T>
class vector
{
public:
void clear()
{
// Function body
}

private:
T* elements;
};

当然,我们也可以将vector<T>::clear的定义部分放在类型之外,只不过这个时候的语法就显得蹩脚许多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
class vector
{
public:
void clear(); // 注意这里只有声明
private:
T* elements;
};

template <typename T>
void vector<T>::clear() // 函数的实现放在这里
{
// Function body
}

函数的实现部分看起来略微拗口。我第一次学到的时候,觉得

1
2
3
4
void vector::clear()
{
// Function body
}

这样不就行了吗?但是简单想就会知道,clear里面是找不到泛型类型T的符号的。

因此,在成员函数实现的时候,必须要提供模板参数。此外,为什么类型名不是vector而是vector<T>呢?
如果你了解过模板的偏特化与特化的语法,应该能看出,这里的vector在语法上类似于特化/偏特化。实际上,这里的函数定义也确实是成员函数的偏特化。特化和偏特化的概念,本文会在第二部分详细介绍。

综上,正确的成员函数实现如下所示:

1
2
3
4
5
template <typename T> // 模板参数
void vector<T> /*看起来像偏特化*/ ::clear() // 函数的实现放在这里
{
// Function body
}

2.3. 函数模板 (Function Tempalte) 入门

2.3.1. 函数模板的声明和定义

函数模板的语法与类模板基本相同,也是以关键字template和模板参数列表作为声明与定义的开始。模板参数列表中的类型,可以出现在参数、返回值以及函数体中。比方说下面几个例子

1
2
3
4
5
6
7
8
9
10
11
template <typename T> void foo(T const& v);

template <typename T> T foo();

template <typename T, typename U> U foo(T const&);

template <typename T> void foo()
{
T var;
// ...
}

无论是函数模板还是类模板,在实际代码中看起来都是“千变万化”的。这些“变化”,主要是因为类型被当做了参数,导致代码中可以变化的部分更多了。

归根结底,模板无外乎两点:

  1. 函数或者类里面,有一些类型我们希望它能变化一下,我们用标识符来代替它,这就是“模板参数”;

  2. 在需要这些类型的地方,写上相对应的标识符(“模板参数”)。

当然,这里的“可变”实际上在代码编译好后就固定下来了,可以称之为编译期的可变性。

这里多啰嗦一点,主要也是想告诉大家,模板其实是个很简单的东西。

下面这个例子,或许可以帮助大家解决以下两个问题:

  1. 什么样的需求会使用模板来解决?

  2. 怎样把脑海中的“泛型”变成真正“泛型”的代码?

1
举个例子:generic typed function ‘add’

在我遇到的朋友中,即便如此对他解释了模板,即便他了解了模板,也仍然会对模板产生畏难情绪。毕竟从形式上来说,模板化的类和模板化的函数都要较非模板的版本更加复杂,阅读代码所需要理解的内容也有所增多。

如何才能克服这一问题,最终视模板如平坦代码呢?

答案只有一个:无他,唯手熟尔

在学习模板的时候,要反复做以下的思考和练习:

  1. 提出问题:我的需求能不能用模板来解决?

  2. 怎么解决?

  3. 把解决方案用代码写出来。

  4. 如果失败了,找到原因。是知识有盲点(例如不知道怎么将 T& 转化成 T),还是不可行(比如试图利用浮点常量特化类模板,但实际上这样做是不可行的)?

通过重复以上的练习,应该可以对模板的语法和含义都有所掌握。如果提出问题本身有困难,或许下面这个经典案例可以作为你思考的开始:

  1. 写一个泛型的数据结构:例如,线性表,数组,链表,二叉树;

  2. 写一个可以在不同数据结构、不同的元素类型上工作的泛型函数,例如求和;

当然和“设计模式”一样,模板在实际应用中,也会有一些固定的需求和解决方案。比较常见的场景包括:泛型(最基本的用法)、通过类型获得相应的信息(型别萃取)、编译期间的计算、类型间的推导和变换(从一个类型变换成另外一个类型,比如boost::function)。这些本文在以后的章节中会陆续介绍。

2.3.2. 函数模板的使用

我们先来看一个简单的函数模板,两个数相加:

1
2
3
4
template <typename T> T Add(T a, T b)
{
return a + b;
}

函数模板的调用格式是:

1
函数模板名 < 模板参数列表 > ( 参数 )

例如,我们想对两个 int 求和,那么套用类的模板实例化方法,我们可以这么写:

1
2
3
int a = 5;
int b = 3;
int result = Add<int>(a, b);

这时我们等于拥有了一个新函数:

1
int Add<int>(int a, int b) { return a + b; }

这时在另外一个偏远的程序角落,你也需要求和。而此时你的参数类型是 float ,于是你写下:

1
Add<float>(a, b);

一切看起来都很完美。但如果你具备程序员的最佳美德——懒惰——的话,你肯定会这样想,我在调用 Add<int>(a, b) 的时候, ab 匹配的都是那个 T。编译器就应该知道那个 T 实际上是 int 呀?为什么还要我多此一举写 Add<int> 呢?
唔,我想说的是,编译器的作者也是这么想的。所以实际上你在编译器里面写下以下片段:

1
2
3
int a = 5;
int b = 3;
int result = Add(a, b);

编译器会心领神会地将 Add 变成 Add<int>。但是编译器不能面对模棱两可的答案。比如你这么写的话呢?

1
2
3
int  a = 5;
char b = 3;
int result = Add(a, b);

第一个参数 a 告诉编译器,这个 Tint。编译器点点头说,好。但是第二个参数 b 不高兴了,告诉编译器说,你这个 T,其实是 char
两个参数各自指导 T 的类型,编译器就不知道怎么做了。在Visual Studio 2012下,会有这样的提示:

1
error C2782: 'T _1_2_2::Add(T,T)' : template parameter 'T' is ambiguous

好吧,”ambiguous”,这个提示再明确不过了。

不过,只要你别逼得编译器精神分裂的话,编译器其实是非常聪明的,它可以从很多的蛛丝马迹中,猜测到你真正的意图,有如下面的例子:

1
2
3
4
5
6
template <typename T> class A {};

template <typename T> T foo( A<T> v );

A<int> v;
foo(v); // 它能准确地猜到 T 是 int.

咦,编译器居然绕过了A这个外套,猜到了 T 匹配的是 int。编译器是怎么完成这一“魔法”的,我们暂且不表,2.2节时再和盘托出。

下面轮到你的练习时间了。你试着写了很多的例子,但是其中一个你还是犯了疑惑:

1
2
3
4
5
6
7
8
9
float data[1024];

template <typename T> T GetValue(int i)
{
return static_cast<T>(data[i]);
}

float a = GetValue(0); // 出错了!
int b = GetValue(1); // 也出错了!

为什么会出错呢?你仔细想了想,原来编译器是没办法去根据返回值推断类型的。函数调用的时候,返回值被谁接受还不知道呢。如下修改后,就一切正常了:

1
2
float a = GetValue<float>(0);
int b = GetValue<int>(1);

嗯,是不是so easy啊?嗯,你又信心满满的做了一个练习:

你要写一个函数模板叫 c_style_cast,顾名思义,执行的是C风格的转换。然后出于方便起见,你希望它能和 static_cast 这样的内置转换有同样的写法。于是你写了一个use case。

1
DstT dest = c_style_cast<DstT>(src);

根据调用形式你知道了,有 DstTSrcT 两个模板参数。参数只有一个, src,所以函数的形参当然是这么写了: (SrcT src)。实现也很简单, (DstT)v

我们把手上得到的信息来拼一拼,就可以编写自己的函数模板了:

1
2
3
4
5
6
7
template <typename SrcT, typename DstT> DstT c_style_cast(SrcT v)
{
return (DstT)(v);
}

int v = 0;
float i = c_style_cast<float>(v);

嗯,很Easy嘛!我们F6一下…咦!这是什么意思!

1
error C2783: 'DstT _1_2_2::c_style_cast(SrcT)' : could not deduce template argument for 'DstT'

然后你仔细的比较了一下,然后发现 … 模板参数有两个,而参数里面能得到的只有 SrcT 一个。结合出错信息看来关键在那个 DstT 上。这个时候,你死马当活马医,把模板参数写完整了:

1
float i = c_style_cast<int, float>(v);

嗯,很顺利的通过了。难道C++不能支持让参数推导一部分模板参数吗?

当然是可以的。只不过在部分推导、部分指定的情况下,编译器对模板参数的顺序是有限制的:先写需要指定的模板参数,再把能推导出来的模板参数放在后面

在这个例子中,能推导出来的是 SrcT,需要指定的是 DstT。把函数模板写成下面这样就可以了:

1
2
3
4
5
6
7
template <typename DstT, typename SrcT> DstT c_style_cast(SrcT v)	// 模板参数 DstT 需要人肉指定,放前面。
{
return (DstT)(v);
}

int v = 0;
float i = c_style_cast<float>(v); // 形象地说,DstT会先把你指定的参数吃掉,剩下的就交给编译器从函数参数列表中推导啦。

2.4. 整型也可是Template参数

模板参数除了类型外(包括基本类型、结构、类类型等),也可以是一个整型数(Integral Number)。这里的整型数比较宽泛,包括布尔型,不同位数、有无符号的整型,甚至包括指针。我们将整型的模板参数和类型作为模板参数来做一个对比:

1
2
template <typename T> class TemplateWithType;
template <int V> class TemplateWithValue;

我想这个时候你也更能理解 typename 的意思了:它相当于是模板参数的“类型”,告诉你 T 是一个 typename

按照C++ Template最初的想法,模板不就是为了提供一个类型安全、易于调试的宏吗?有类型就够了,为什么要引入整型参数呢?考虑宏,它除了代码替换,还有一个作用是作为常数出现。所以整型模板参数最基本的用途,也是定义一个常数。例如这段代码的作用:

1
2
3
4
5
6
template <typename T, int Size> struct Array
{
T data[Size];
};

Array<int, 16> arr;

便相当于下面这段代码:

1
2
3
4
5
6
class IntArrayWithSize16
{
int data[16]; // int 替换了 T, 16 替换了 Size
};

IntArrayWithSize16 arr;

其中有一点需要注意,因为模板的匹配是在编译的时候完成的,所以实例化模板的时候所使用的参数,也必须要在编译期就能确定。例如以下的例子编译器就会报错:

1
2
3
4
5
6
7
8
template <int i> class A {};

void foo()
{
int x = 3;
A<5> a; // 正确!
A<x> b; // error C2971: '_1_3::A' : template parameter 'i' : 'x' : a local variable cannot be used as a non-type argument
}

因为x不是一个编译期常量,所以 A<x> 就会告诉你,x是一个局部变量,不能作为一个模板参数出现。

嗯,这里我们再来写几个相对复杂的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template <int i> class A 
{
public:
void foo(int)
{
}
};
template <uint8_t a, typename b, void* c> class B {};
template <bool, void (*a)()> class C {};
template <void (A<3>::*a)(int)> class D {};

template <int i> int Add(int a) // 当然也能用于函数模板
{
return a + i;
}

void foo()
{
A<5> a;
B<7, A<5>, nullptr> b; // 模板参数可以是一个无符号八位整数,可以是模板生成的类;可以是一个指针。
C<false, &foo> c; // 模板参数可以是一个bool类型的常量,甚至可以是一个函数指针。
D<&A<3>::foo> d; // 丧心病狂啊!它还能是一个成员函数指针!
int x = Add<3>(5); // x == 8。因为整型模板参数无法从函数参数获得,所以只能是手工指定啦。
}

template <float a> class E {}; // ERROR: 别闹!早说过只能是整数类型的啦!

当然,除了单纯的用作常数之外,整型参数还有一些其它的用途。这些“其它”用途最重要的一点是让类型也可以像整数一样运算。《Modern C++ Design》给我们展示了很多这方面的例子。不过你不用急着去阅读那本天书,我们会在做好足够的知识铺垫后,让你轻松学会这些招数。

2.5. 模板形式与功能是统一的

第一章走马观花的带着大家复习了一下C++ Template的基本语法形式,也解释了包括 typename 在内,类/函数模板写法中各个语法元素的含义。形式是功能的外在体现,介绍它们也是为了让大家能理解到,模板之所以写成这种形式是有必要的,而不是语言的垃圾成分。

从下一章开始,我们便进入了更加复杂和丰富的世界:讨论模板的匹配规则。其中有令人望而生畏的特化与偏特化。但是,请相信我们在序言中所提到的:将模板作为一门语言来看待,它会变得有趣而简单。

第一部分-迈向C++

迈向现代C++

被弃用的特性

注意:弃用并非彻底不能用,只是用于暗示程序员这些特性将从未来的标准中消失,应该尽量避免使用。但是,已弃用的特性依然是标准库的一部分,并且出于兼容性的考虑,大部分特性其实会『永久』保留

1.不再允许字符串字面值常量赋值给一个 char *。如果需要用字符串字面值常量赋值和初始化一个 char *,应该使用 const char * 或者 auto。
2.C++98 异常说明、 unexpected_handler、set_unexpected() 等相关特性被弃用,应该使用 noexcept。
3.auto_ptr 被弃用,应使用 unique_ptr。
4.bool 类型的 ++ 操作被弃用。
5.C 语言风格的类型转换被弃用(即在变量前使用 (convert_type)),应该使用 static_cast、reinterpret_cast、const_cast 来进行类型转换。
6.特别地,在最新的 C++17 标准中弃用了一些可以使用的 C 标准库,例如
7.std::bind,std::function,export等特效

与C的兼容性

在编写 C++ 时,也应该尽可能的避免使用诸如 void* 之类的程序风格。而在不得不使用 C 时,应该注意使用 extern “C” 这种特性,将 C 语言的代码与 C++代码进行分离编译,再统一链接这种做法。

第五部分

5. 未完成章节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 6. 元编程下的数据结构与算法
## 6.1. 表达式与数值计算
## 6.2. 获得类型的属性——类型萃取(Type Traits)
## 6.3. 列表与数组
## 6.4. 字典结构
## 6.5. “快速”排序
## 6.6. 其它常用的“轮子”

# 7. 非模板的编译期计算

# 8. 模板的进阶技巧
## 8.1. 嵌入类
## 8.2. Template-Template Class
## 8.3. 高阶函数
## 8.4. 闭包:模板的“基于对象”
stl allocator?
mpl::apply
## 8.5. 占位符(placeholder):在C++中实现方言的基石
## 8.6. 编译期“多态”

# 9. 模板的威力:从foreach, transform到Linq
## 9.1. Foreach与Transform
## 9.2. Boost中的模板
Any Spirit Hana TypeErasure
## 9.3. Reactor、Linq与C++中的实践
## 9.4. 更高更快更强:从Linq到FP

# 10. 结语:讨论有益,争端无用
## 10.1. 更好的编译器,更友善的出错信息
## 10.2. 模板的症结:易于实现,难于完美
## 10.3. 一些期望
alexandrescu 关于 min max 的讨论:《再谈Min和Max》
std::experimental::any / boost.any 对于 reference 的处理

第三部分

3. 模板元编程基础

3.1. 编程,元编程,模板元编程

技术的学习是一个登山的过程。第一章是最为平坦的山脚道路。而从这一章开始,则是正式的爬坡。无论是我写作还是你阅读,都需要付出比第一章更多的代价。那么问题就是,付出更多的精力学习模板是否值得?

这个问题很功利,但是一针见血。因为技术的根本目的在于解决需求。那C++的模板能做什么?

一个高(树)大(新)上(风)的回答是,C++里面的模板,犹如C中的宏、C和Java中的自省(restropection)和反射(reflection),是一个改变语言内涵,拓展语言外延的存在。

程序最根本的目的是什么?复现真实世界或人所构想的规律,减少重复工作的成本,或通过提升规模完成人所不能及之事。但是世间之事万千,有限的程序如何重现复杂的世界呢?

答案是“抽象”。论及具体手段,无外乎“求同”与“存异”:概括一般规律,处理特殊情况。这也是软件工程所追求的目标。一般规律概括的越好,我们所付出的劳动也就越少。

同样的,作为脑力劳动的产品,程序本身也是有规律性的。《Modern C++ Design》中的前言就抛出了一连串有代表性的问题:

1
2
3
如何撰写更高级的C++程式?
如何应付即使在很干净的设计中仍然像雪崩一样的不相干细节?
如何构建可复用组件,使得每次在不同程式中应用组件时无需大动干戈?

我们以数据结构举例。在程序里,你需要一些堆栈。这个堆栈的元素可能是整数、浮点或者别的什么类型。一份整型堆栈的代码可能是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StackInt
{
public:
void push(int v);
int pop();
int Find(int x)
{
for(int i = 0; i < size; ++i)
{
if(data[i] == x) { return i; }
}
}
// ... 其他代码 ...
};

如果你要支持浮点了,那么你只能将代码再次拷贝出来,并作如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StackFloat
{
public:
void push(float v);
float pop();
int Find(float x)
{
for(int i = 0; i < size; ++i)
{
if(data[i] == x) { return i; }
}
}
// ... 其他代码 ...
};

当然也许你觉得这样做能充分体会代码行数增长的成就感。但是有一天,你突然发现:呀,Find 函数实现有问题了。怎么办?这个时候也许你只有两份这样的代码,那好说,一一去修正就好了。如果你有十个呢?二十个?五十个?

时间一长,你就厌倦了这样的生活。你觉得每个堆栈都差不多,但是又有点不一样。为了这一点点不一样,你付出了太多的时间。吃饭的时间,泡妞的时间,睡觉的时间,看岛国小电影顺便练习小臂力量的时间。

于是便诞生了新的技术,来消解我们的烦恼。

这个技术的名字,并不叫“模板”,而是叫“元编程”。

元(meta)无论在中文还是英文里,都是个很“抽象(abstract)”的词。因为它的本意就是“抽象”。元编程,也可以说就是“编程的抽象”。用更好理解的说法,元编程意味着你撰写一段程序A,程序A会运行后生成另外一个程序B,程序B才是真正实现功能的程序。那么这个时候程序A可以称作程序B的元程序,撰写程序A的过程,就称之为“元编程”。

回到我们的堆栈的例子。真正执行功能的,其实仍然是浮点的堆栈、整数的堆栈、各种你所需要的类型的堆栈。但是因为这些堆栈之间太相似了,仅仅有着些微的不同,我们为什么不能有一个将相似之处囊括起来,同时又能分别体现出不同之处的程序呢?很多语言都提供了这样的机会。C中的宏,C++中的模板,Python中的Duck Typing,广义上将都能够实现我们的思路。

我们的目的,是找出程序之间的相似性,进行“元编程”。而在C++中,元编程的手段,可以是宏,也可以是模板。

宏的例子姑且不论,我们来看一看模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T>
class Stack
{
public:
void push(T v);
T pop();
int Find(T x)
{
for(int i = 0; i < size; ++i)
{
if(data[i] == x) { return i; }
}
}
// ... 其他代码 ...
};

typedef Stack<int> StackInt;
typedef Stack<float> StackFloat;

通过模板,我们可以将形形色色的堆栈代码分为两个部分,一个部分是不变的接口,以及近乎相同的实现;另外一部分是元素的类型,它们是需要变化的。因此同函数类似,需要变化的部分,由模板参数来反映;不变的部分,则是模板内的代码。可以看到,使用模板的代码,要比不使用模板的代码简洁许多。

如果元编程中所有变化的量(或者说元编程的参数),都是类型,那么这样的编程,我们有个特定的称呼,叫“泛型”。

但是你会问,模板的发明,仅仅是为了做和宏几乎一样的替换工作吗?可以说是,也可以说不是。一方面,很多时候模板就是为了替换类型,这个时候作用上其实和宏没什么区别。只是宏是基于文本的替换,被替换的文本本身没有任何语义。只有替换完成,编译器才能进行接下来的处理。而模板会在分析模板时以及实例化模板时时候都会进行检查,而且源代码中也能与调试符号一一对应,所以无论是编译时还是运行时,排错都相对简单。

但是模板和宏也有很大的不同,否则此文也就不能成立了。模板最大的不同在于它是“可以运算”的。我们来举一个例子,不过可能有点牵强。考虑我们要写一个向量逐分量乘法。只不过这个向量,它非常的大。所以为了保证速度,我们需要使用SIMD指令进行加速。假设我们有以下指令可以使用:

1
2
3
4
Int8,16: N/A
Int32 : VInt32Mul(int32x4, int32x4)
Int64 : VInt64Mul(int64x4, int64x4)
Float : VInt64Mul(floatx2, floatx2)

所以对于Int8和Int16,我们需要提升到Int32,而Int32和Int64,各自使用自己的指令。所以我们需要实现下的逻辑:

1
2
3
4
5
6
7
8
9
for(v4a, v4b : vectorsA, vectorsB)
{
if type is Int8, Int16
VInt32Mul( ConvertToInt32(v4a), ConvertToInt32(v4b) )
elif type is Int32
VInt32Mul( v4a, v4b )
elif type is Float
...
}

这里的问题就在于,如何根据 type 分别提供我们需要的实现?这里有两个难点。首先, if(type == xxx) {} 是不存在于C++中的。第二,即便存在根据 type 的分配方法,我们也不希望它在运行时branch,这样会变得很慢。我们希望它能按照类型直接就把代码编译好,就跟直接写的一样。

嗯,聪明你果然想到了,重载也可以解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
GenericMul(int8x4,  int8x4);
GenericMul(int16x4, int16x4);
GenericMul(int32x4, int32x4);
GenericMul(int64x4, int64x4);
// 其它 Generic Mul ...

for(v4a, v4b : vectorsA, vectorsB)
{
GenericMul(v4a, v4b);
}

这样不就可以了吗?

唔,你赢了,是这样没错。但是问题是,我这个平台是你可没见过,它叫 Deep Thought, 特别缺心眼儿,不光有 int8,还有更奇怪的 int9, int11,以及可以代表世间万物的 int42。你总不能为之提供所有的重载吧?这简直就像你枚举了所有程序的输入,并为之提供了对应的输出一样。

好吧,我承认这个例子还是太牵强了。不过相信我,在你阅读完第二章和第三章之后,你会将这些特性自如地运用到你的程序之中。你的程序将会变成体现模板“可运算”威力的最好例子。

3.2. 模板世界的If-Then-Else:类模板的特化与偏特化

3.2.1. 根据类型执行代码

前一节的示例提出了一个要求:需要做出根据类型执行不同代码。要达成这一目的,模板并不是唯一的途径。比如之前我们所说的重载。如果把眼界放宽一些,虚函数也是根据类型执行代码的例子。此外,在C语言时代,也会有一些技法来达到这个目的,比如下面这个例子,我们需要对两个浮点做加法, 或者对两个整数做乘法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Variant
{
union
{
int x;
float y;
} data;
uint32 typeId;
};

Variant addFloatOrMulInt(Variant const* a, Variant const* b)
{
Variant ret;
assert(a->typeId == b->typeId);
if (a->typeId == TYPE_INT)
{
ret.x = a->x * b->x;
}
else
{
ret.y = a->y + b->y;
}
return ret;
}

更常见的是 void*:

1
2
3
4
5
6
7
8
9
10
11
12
define BIN_OP(type, a, op, b, result) (*(type *)(result)) = (*(type const *)(a)) op (*(type const*)(b))
void doDiv(void* out, void const* data0, void const* data1, DATA_TYPE type)
{
if(type == TYPE_INT)
{
BIN_OP(int, data0, *, data1, out);
}
else
{
BIN_OP(float, data0, +, data1, out);
}
}

在C++中比如在 Boost.Any 的实现中,运用了 typeid 来查询类型信息。和 typeid 同属于RTTI机制的 dynamic_cast,也经常会用来做类型判别的工作。我想你应该写过类似于下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
IAnimal* animal = GetAnimalFromSystem();

IDog* maybeDog = dynamic_cast<IDog*>(animal);
if(maybeDog)
{
maybeDog->Wangwang();
}
ICat* maybeCat = dynamic_cast<ICat*>(animal);
if(maybeCat)
{
maybeCat->Moemoe();
}

当然,在实际的工作中,我们建议把需要 dynamic_cast 后执行的代码,尽量变成虚函数。不过这个已经是另外一个问题了。我们看到,不管是哪种方法都很难避免 if 的存在。而且因为输入数据的类型是模糊的,经常需要强制地、没有任何检查的转换成某个类型,因此很容易出错。

但是模板与这些方法最大的区别并不在这里。模板无论其参数或者是类型,它都是一个编译期分派的办法。编译期就能确定的东西既可以做类型检查,编译器也能进行优化,砍掉任何不必要的代码执行路径。例如在上例中,

1
2
3
4
5
template <typename T> T addFloatOrMulInt(T a, T b);

// 迷之代码1:用于T是float的情况

// 迷之代码2:用于T是int时的情况

如果你运用了模板来实现,那么当传入两个不同类型的变量,或者不是 intfloat 变量,编译器就会提示错误。但是如果使用了我们前述的 Variant 来实现,编译器可就管不了那么多了。但是,成也编译期,败也编译期。最严重的“缺点”,就是你没办法根据用户输入或者别的什么在运行期间可能发生变化的量来决定它产生、或执行什么代码。比如下面的代码段,它是不成立的。

1
2
3
4
5
6
7
template <int i, int j>
int foo() { return i + j; }
int main()
{
cin >> x >> y;
return foo<x, y>();
}

这点限制也粉碎了妄图用模板来包办工厂(Factory)甚至是反射的梦想。尽管在《Modern C++ Design》中(别问我为什么老举这本书,因为《C++ Templates》和《Generic Programming》我只是囫囵吞枣读过,基本不记得了)大量运用模板来简化工厂方法;同时C++11/14中的一些机制如Variadic Template更是让这一问题的解决更加彻底。但无论如何,直到C++11/14,光靠模板你就是写不出依靠类名或者ID变量产生类型实例的代码。

所以说,从能力上来看,模板能做的事情都是编译期完成的。编译期完成的意思就是,当你编译一个程序的时候,所有的量就都已经确定了。比如下面的这个例子:

1
2
3
4
5
int a = 3, b = 5;
Variant aVar, bVar;
aVar.setInt(a); // 我们新加上的方法,怎么实现的无所谓,大家明白意思就行了。
bVar.setInt(b);
Variant result = addFloatOrMulInt(aVar, bVar);

除非世界末日,否则这个例子里不管你怎么蹦跶,单看代码我们就能知道, aVarbVar 都一定会是整数。所以如果有合适的机制,编译器就能知道此处的 addFloatOrMulInt 中只需要执行 Int 路径上的代码,而且编译器在此处也能单独为 Int 路径生成代码,从而去掉那个不必要的 if

在模板代码中,这个“合适的机制”就是指“特化”和“部分特化(Partial Specialization)”,后者也叫“偏特化”。

3.2.2. 特化

我的高中物理老师对我说过一句令我受用至今的话:把自己能做的事情做好。编写模板程序也是一样。当你试图用模板解决问题之前,先撇开那些复杂的语法要素,用最直观的方式表达你的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 这里是伪代码,意思一下

int|float addFloatOrMulInt(a, b)
{
if(type is Int)
{
return a * b;
}
else if (type is Float)
{
return a + b;
}
}

void foo()
{
float a, b, c;
c = addFloatOrMulInt(a, b); // c = a + b;

int x, y, z;
z = addFloatOrMulInt(x, y); // z = x * y;
}

因为这一节是讲类模板有关的特化和偏特化机制,所以我们不用普通的函数,而是用类的静态成员函数来做这个事情(这就是典型的没事找抽型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 这里仍然是伪代码,意思一下,too。
class AddFloatOrMulInt
{
static int|float Do(a, b)
{
if(type is Int)
{
return a * b;
}
else if (type is Float)
{
return a + b;
}
}
};

void foo()
{
float a, b, c;
c = AddFloatOrMulInt::Do(a, b); // c = a + b;

int x, y, z;
z = AddFloatOrMulInt::Do(x, y); // z = x * y;
}

好,意思表达清楚了。我们先从调用方的角度,把这个形式改写一下:

1
2
3
4
5
6
7
8
void foo()
{
float a, b, c;
c = AddFloatOrMulInt<float>::Do(a, b); // c = a + b;

int x, y, z;
z = AddFloatOrMulInt<int>::Do(x, y); // z = x * y;
}

也许你不明白为什么要改写成现在这个样子。看不懂不怪你,怪我讲得不好。但是你别急,先看看这样改写以后能不能跟我们的目标接近一点。如果我们把 AddFloatOrMulInt<float>::Do 看作一个普通的函数,那么我们可以写两个实现出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float AddFloatOrMulInt<float>::Do(float a, float b)
{
return a + b;
}

int AddFloatOrMulInt<int>::Do(int a, int b)
{
return a * b;
}

void foo()
{
float a, b, c;
c = AddFloatOrMulInt<float>::Do(a, b); // c = a + b;

int x, y, z;
z = AddFloatOrMulInt<int>::Do(x, y); // z = x * y;
}

这样是不是就很开心了?我们更进一步,把 AddFloatOrMulInt<int>::Do 换成合法的类模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 这个是给float用的。
template <typename T> class AddFloatOrMulInt
{
T Do(T a, T b)
{
return a + b;
}
};

// 这个是给int用的。
template <typename T> class AddFloatOrMulInt
{
T Do(T a, T b)
{
return a * b;
}
};

void foo()
{
float a, b, c;

// 嗯,我们需要 c = a + b;
c = AddFloatOrMulInt<float>::Do(a, b);
// ... 觉得哪里不对劲 ...
// ...
// ...
// ...
// 啊!有两个AddFloatOrMulInt,class看起来一模一样,要怎么区分呢!
}

好吧,问题来了!如何要让两个内容不同,但是模板参数形式相同的类进行区分呢?特化!特化(specialization)是根据一个或多个特殊的整数或类型,给出模板实例化时的一个指定内容。我们先来看特化是怎么应用到这个问题上的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 首先,要写出模板的一般形式(原型)
template <typename T> class AddFloatOrMulInt
{
static T Do(T a, T b)
{
// 在这个例子里面一般形式里面是什么内容不重要,因为用不上
// 这里就随便给个0吧。
return T(0);
}
};

// 其次,我们要指定T是int时候的代码,这就是特化:
template <> class AddFloatOrMulInt<int>
{
public:
static int Do(int a, int b) //
{
return a * b;
}
};

// 再次,我们要指定T是float时候的代码:
template <> class AddFloatOrMulInt<float>
{
public:
static float Do(float a, float b)
{
return a + b;
}
};

void foo()
{
// 这里面就不写了
}

我们再把特化的形式拿出来一瞧:这货有点怪啊: template <> class AddFloatOrMulInt<int>。别急,我给你解释一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 我们这个模板的基本形式是什么?
template <typename T> class AddFloatOrMulInt;

// 但是这个类,是给T是Int的时候用的,于是我们写作
class AddFloatOrMulInt<int>;
// 当然,这里编译是通不过的。

// 但是它又不是个普通类,而是类模板的一个特化(特例)。
// 所以前面要加模板关键字template,
// 以及模板参数列表
template </* 这里要填什么? */> class AddFloatOrMulInt<int>;

// 最后,模板参数列表里面填什么?因为原型的T已经被int取代了。所以这里就不能也不需要放任何额外的参数了。
// 所以这里放空。
template <> class AddFloatOrMulInt<int>
{
// ... 针对Int的实现 ...
};

// Bingo!

哈,这样就好了。我们来做一个练习。我们有一些类型,然后你要用模板做一个对照表,让类型对应上一个数字。我先来做一个示范:

1
2
3
4
5
6
7
8
9
10
11
12

template <typename T> class TypeToID
{
public:
static int const ID = -1;
};

template <> class TypeToID<uint8_t>
{
public:
static int const ID = 0;
};

然后呢,你的任务就是,要所有无符号的整数类型的特化(其实就是uint8_tuint64_t啦),把所有的基本类型都赋予一个ID(当然是不一样的啦)。当你做完后呢,可以把类型所对应的ID打印出来,我仍然以 uint8_t 为例:

1
2
3
4
void PrintID()
{
cout << "ID of uint8_t: " << TypeToID<uint8_t>::ID << endl;
}

嗯,看起来挺简单的,是吧。但是这里透露出了一个非常重要的信号,我希望你已经能察觉出来了: TypeToID 如同是一个函数。这个函数只能在编译期间执行。它输入一个类型,输出一个ID。

如果你体味到了这一点,那么恭喜你,你的模板元编程已经开悟了。

3.2.3. 特化:一些其它问题

在上一节结束之后,你一定做了许多的练习。我们再来做三个练习。第一,给float一个ID;第二,给void*一个ID;第三,给任意类型的指针一个ID。先来做第一个:

1
2
3
4
5
6
7
8
9
// ...
// TypeToID 的模板“原型”
// ...

template <> class TypeToID<float>
{
public:
static int const ID = 0xF10A7;
};

嗯, 这个你已经了然于心了。那么void*呢?你想了想,这已经是一个复合类型了。不错你还是战战兢兢地写了下来:

1
2
3
4
5
6
7
8
9
10
template <> class TypeToID<void*>
{
public:
static int const ID = 0x401d;
};

void PrintID()
{
cout << "ID of uint8_t: " << TypeToID<void*>::ID << endl;
}

遍译运行一下,对了。模板不过如此嘛。然后你觉得自己已经完全掌握了,并试图将所有C++类型都放到模板里面,开始了自我折磨的过程:

1
2
3
4
5
6
class ClassB {};

template <> class TypeToID<void ()>; // 函数的TypeID
template <> class TypeToID<int[3]>; // 数组的TypeID
template <> class TypeToID<int (int[3])>; // 这是以数组为参数的函数的TypeID
template <> class TypeToID<int (ClassB::*[3])(void*, float[2])>; // 我也不知道这是什么了,自己看着办吧。

甚至连 constvolatile 都能装进去:

1
template <> class TypeToID<int const * volatile * const volatile>;

此时就很明白了,只要 <> 内填进去的是一个C++能解析的合法类型,模板都能让你特化。不过这个时候如果你一点都没有写错的话, PrintID 中只打印了我们提供了特化的类型的ID。那如果我们没有为之提供特化的类型呢?比如说double?OK,实践出真知,我们来尝试着运行一下:

1
2
3
4
void PrintID()
{
cout << "ID of double: " << TypeToID<double>::ID << endl;
}

嗯,它输出的是-1。我们顺藤摸瓜会看到, TypeToID的类模板“原型”的ID是值就是-1。通过这个例子可以知道,当模板实例化时提供的模板参数不能匹配到任何的特化形式的时候,它就会去匹配类模板的“原型”形式。

不过这里有一个问题要理清一下。和继承不同,类模板的“原型”和它的特化类在实现上是没有关系的,并不是在类模板中写了 ID 这个Member,那所有的特化就必须要加入 ID 这个Member,或者特化就自动有了这个成员。完全没这回事。我们把类模板改成以下形式,或许能看的更清楚一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T> class TypeToID
{
public:
static int const NotID = -2;
};

template <> class TypeToID<float>
{
public:
static int const ID = 1;
};

void PrintID()
{
cout << "ID of float: " << TypeToID<float>::ID << endl; // Print "1"
cout << "NotID of float: " << TypeToID<float>::NotID << endl; // Error! TypeToID<float>使用的特化的类,这个类的实现没有NotID这个成员。
cout << "ID of double: " << TypeToID<double>::ID << endl; // Error! TypeToID<double>是由类模板实例化出来的,它只有NotID,没有ID这个成员。
}

这样就明白了。类模板和类模板的特化的作用,仅仅是指导编译器选择哪个编译,但是特化之间、特化和它原型的类模板之间,是分别独立实现的。所以如果多个特化、或者特化和对应的类模板有着类似的内容,很不好意思,你得写上若干遍了。

第三个问题,是写一个模板匹配任意类型的指针。对于C语言来说,因为没有泛型的概念,因此它提供了无类型的指针void*。它的优点是,所有指针都能转换成它。它的缺点是,一旦转换称它后,你就再也不知道这个指针到底是指向float或者是int或者是struct了。

比如说copy

1
2
3
4
5
6
7
8
9
10
11
void copy(void* dst, void const* src, size_t elemSize, size_t elemCount, void (*copyElem)(void* dstElem, void const* srcElem))
{
void const* reader = src;
void const* writer = dst;
for(size_t i = 0; i < elemCount; ++i)
{
copyElem(writer, reader);
advancePointer(reader, elemSize); // 把Reader指针往后移动一些字节
advancePointer(writer, elemSize);
}
}

为什么要提供copyElem,是因为可能有些struct需要深拷贝,所以得用特殊的copy函数。这个在C++98/03里面就体现为拷贝构造和赋值函数。

但是不管怎么搞,因为这个函数的参数只是void*而已,当你使用了错误的elemSize,或者传入了错误的copyElem,就必须要到运行的时候才有可能看出来。注意,这还只是有可能而已。

那么C++有了模板后,能否既能匹配任意类型的指针,同时又保留了类型信息呢?答案是显然的。至于怎么写,那就得充分发挥你的直觉了:

首先,我们需要一个typename T来指代“任意类型”这四个字:

1
template <typename T>

接下来,我们要写函数原型:

1
void copy(?? dest, ?? src, size_t elemCount);

这里的 ?? 要怎么写呢?既然我们有了模板类型参数T,那我们不如就按照经验,写 T* 看看。

1
2
template <typename T>
void copy(T* dst, T const* src, size_t elemCount);

编译一下,咦,居然通过了。看来这里的语法与我们以前学到的知识并没有什么不同。这也是语言设计最重要的一点原则:一致性。它可以让你辛辛苦苦体验到的规律不至于白费。

最后就是实现:

1
2
3
4
5
6
7
8
template <typename T>
void copy(T* dst, T const* src, size_t elemCount)
{
for(size_t i = 0; i < elemCount; ++i)
{
dst[i] = src[i];
}
}

是不是简洁了许多?你不需要再传入size;只要你有正确的赋值函数,也不需要提供定制的copy;也不用担心dst和src的类型不匹配了。

最后,我们把函数模板学到的东西,也应用到类模板里面:

1
2
3
4
5
6
template <typename T> // 嗯,需要一个T
class TypeToID<T*> // 我要对所有的指针类型特化,所以这里就写T*
{
public:
static int const ID = 0x80000000; // 用最高位表示它是一个指针
};

最后写个例子来测试一下,看看我们的 T* 能不能搞定 float*

1
2
3
4
void PrintID()
{
cout << "ID of float*: " << TypeToID<float*>::ID << endl;
}

哈哈,大功告成。嗯,别急着高兴。待我问一个问题:你知道 TypeToID<float*> 后,这里的T是什么吗?换句话说,你知道下面这段代码打印的是什么吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...
// TypeToID 的其他代码,略过不表
// ...

template <typename T> // 嗯,需要一个T
class TypeToID<T*> // 我要对所有的指针类型特化,所以这里就写T*
{
public:
typedef T SameAsT;
static int const ID = 0x80000000; // 用最高位表示它是一个指针
};

void PrintID()
{
cout << "ID of float*: " << TypeToID< TypeToID<float*>::SameAsT >::ID << endl;
}

别急着运行,你先猜。

————————- 这里是给勤于思考的码猴的分割线 ——————————-

OK,猜出来了吗,T是float。为什么呢?因为你用 float * 匹配了 T *,所以 T 就对应 float 了。没想清楚的自己再多体会一下。

嗯,所以实际上,我们可以利用这个特性做一件事情:把指针类型的那个指针给“干掉”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
class RemovePointer
{
public:
typedef T Result; // 如果放进来的不是一个指针,那么它就是我们要的结果。
};

template <typename T>
class RemovePointer<T*> // 祖传牛皮藓,专治各类指针
{
public:
typedef T Result; // 正如我们刚刚讲的,去掉一层指针,把 T* 这里的 T 取出来。
};

void Foo()
{
RemovePointer<float*>::Result x = 5.0f; // 喏,用RemovePointer后,那个Result就是把float*的指针处理掉以后的结果:float啦。
std::cout << x << std::endl;
}

当然啦,这里我们实现的不算是真正的 RemovePointer,因为我们只去掉了一层指针。而如果传进来的是类似 RemovePointer<int**> 这样的东西呢?是的没错,去掉一层之后还是一个指针。RemovePointer<int**>::Result 应该是一个 int*,要怎么才能实现我们想要的呢?聪明的你一定能想到:只要像剥洋葱一样,一层一层一层地剥开,不就好了吗!相应地我们应该怎么实现呢?可以把 RemovePointer 的特化版本改成这样(当然如果有一些不明白的地方你可以暂时跳过,接着往下看,很快就会明白的):

1
2
3
4
5
6
7
8
9
template <typename T>
class RemovePointer<T*>
{
public:
// 如果是传进来的是一个指针,我们就剥夺一层,直到指针形式不存在为止。
// 例如 RemovePointer<int**>,Result 是 RemovePointer<int*>::Result,
// 而 RemovePointer<int*>::Result 又是 int,最终就变成了我们想要的 int,其它也是类似。
typedef typename RemovePointer<T>::Result Result;
};

是的没错,这便是我们想要的 RemovePointer 的样子。类似的你还可以试着实现 RemoveConst, AddPointer 之类的东西。

OK,回到我们之前的话题,如果这个时候,我需要给 int* 提供一个更加特殊的特化,那么我还得多提供一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
// TypeToID 的其他代码,略过不表
// ...

template <typename T> // 嗯,需要一个T
class TypeToID<T*> // 我要对所有的指针类型特化,所以这里就写T*
{
public:
typedef T SameAsT;
static int const ID = 0x80000000; // 用最高位表示它是一个指针
};

template <> // 嗯,int* 已经是个具体的不能再具体的类型了,所以模板不需要额外的类型参数了
class TypeToID<int*> // 嗯,对int*的特化。在这里呢,要把int*整体看作一个类型
{
public:
static int const ID = 0x12345678; // 给一个缺心眼的ID
};

void PrintID()
{
cout << "ID of int*: " << TypeToID<int*>::ID << endl;
}

嗯,这个时候它会输出0x12345678的十进制(大概?)。
可能会有较真的人说,int* 去匹配 T 或者 T*,也是合法的。就和你说22岁以上能结婚,那24岁当然也能结婚一样。
那为什么 int* 就会找 int*float *因为没有合适的特化就去找 T*,更一般的就去找 T 呢?废话,有专门为你准备的东西你不用,非要自己找事?这就是直觉。
但是呢,直觉对付更加复杂的问题还是没用的(也不是没用,主要是你没这个直觉了)。我们要把这个直觉,转换成合理的规则——即模板的匹配规则。
当然,这个匹配规则是对复杂问题用的,所以我们会到实在一眼看不出来的时候才会动用它。一开始我们只要把握:模板是从最特殊到最一般形式进行匹配的 就可以了。

3.3. 即用即推导

3.3.1. 视若无睹的语法错误

这一节我们将讲述模板一个非常重要的行为特点:那就是什么时候编译器会对模板进行推导,推导到什么程度。

这一知识,对于理解模板的编译期行为、以及修正模板编译错误都非常重要。

我们先来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T> struct X {};

template <typename T> struct Y
{
typedef X<T> ReboundType; // 类型定义1
typedef typename X<T>::MemberType MemberType; // 类型定义2
typedef UnknownType MemberType3; // 类型定义3

void foo()
{
X<T> instance0;
typename X<T>::MemberType instance1;
WTF instance2
大王叫我来巡山 - + &
}
};

把这段代码编译一下,类型定义3出错,其它的都没问题。不过到这里你应该会有几个问题:

  1. 不是struct X<T>的定义是空的吗?为什么在struct Y内的类型定义2使用了 X<T>::MemberType 编译器没有报错?
  2. 类型定义2中的typename是什么鬼?为什么类型定义1就不需要?
  3. 为什么类型定义3会导致编译错误?
  4. 为什么void foo()在MSVC下什么错误都没报?

这时我们就需要请出C++11标准 —— 中的某些概念了。这是我们到目前为止第一次参阅标准。我希望能尽量减少直接参阅标准的次数,因此即便是极为复杂的模板匹配决议我都暂时没有引入标准中的描述。
然而,Template引入的“双阶段名称查找(Two phase name lookup)”堪称是C++中最黑暗的角落 —— 这是LLVM的团队自己在博客上说的 —— 因此在这里,我们还是有必要去了解标准中是如何规定的。

3.3.2. 名称查找:I am who I am

在C++标准中对于“名称查找(name lookup)”这个高大上的名词的诠释,主要集中出现在三处。第一处是3.4节,标题名就叫“Name Lookup”;第二处在10.2节,继承关系中的名称查找;第三处在14.6节,名称解析(name resolution)。

名称查找/名称解析,是编译器的基石。对编译原理稍有了解的人,都知道“符号表”的存在及重要意义。考虑一段最基本的C代码:

1
2
3
4
int a = 0;
int b;
b = (a + 1) * 2;
printf("Result: %d", b);

在这段代码中,所有出现的符号可以分为以下几类:

  • int:类型标识符,代表整型;
  • a, b, printf:变量名或函数名;
  • =, +, *:运算符;
  • ,, ;, (, ):分隔符;

那么,编译器怎么知道int就是整数类型,b=(a+1)*2中的ab就是整型变量呢?这就是名称查找/名称解析的作用:它告诉编译器,这个标识符(identifer)是在哪里被声明或定义的,它究竟是什么意思。

也正因为这个机制非常基础,所以它才会面临各种可能的情况,编译器也要想尽办法让它在大部分场合都表现的合理。比如我们常见的作用域规则,就是为了对付名称在不同代码块中传播、并且遇到重名要如何处理的问题。下面是一个最简单的、大家在语言入门过程中都会碰到的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int a = 0;
void f() {
int a = 0;
a += 2;
printf("Inside <a>: %d\n", a);
}
void g() {
printf("Outside <a>: %d\n", a);
}
int main() {
f();
g();
}

/* ------------ Console Output -----------------
Inside <a>: 2
Outside <a>: 0
--------------- Console Output -------------- */

我想大家尽管不能处理所有名称查找中所遇到的问题,但是对一些常见的名称查找规则也有了充分的经验,可以解决一些常见的问题。
但是模板的引入,使得名称查找这一本来就不简单的基本问题变得更加复杂了。
考虑下面这个例子:

1
2
3
4
5
6
7
8
9
struct A  { int a; };
struct AB { int a, b; };
struct C { int c; };

template <typename T> foo(T& v0, C& v1){
v0.a = 1;
v1.a = 2;
v1.c = 3;
}

简单分析上述代码很容易得到以下结论:

  1. 函数foo中的变量v1已经确定是struct C的实例,所以,v1.a = 2;会导致编译错误,v1.c = 3;是正确的代码;
  2. 对于变量v0来说,这个问题就变得很微妙。如果v0struct A或者struct AB的实例,那么foo中的语句v0.a = 1;就是正确的。如果是struct C,那么这段代码就是错误的。

因此在模板定义的地方进行语义分析,并不能完全得出代码是正确或者错误的结论,只有到了实例化阶段,确定了模板参数的类型后,才知道这段代码正确与否。令人高兴的是,在这一问题上,我们和C++标准委员会的见地一致,说明我们的C++水平已经和Herb Sutter不分伯仲了。既然我们和Herb Sutter水平差不多,那凭什么人家就吃香喝辣?下面我们来选几条标准看看服不服:

14.6 名称解析(Name resolution)

1) 模板定义中能够出现以下三类名称:

  • 模板名称、或模板实现中所定义的名称;
  • 和模板参数有关的名称;
  • 模板定义所在的定义域内能看到的名称。

9) … 如果名字查找和模板参数有关,那么查找会延期到模板参数全都确定的时候。 …

10) 如果(模板定义内出现的)名字和模板参数无关,那么在模板定义处,就应该找得到这个名字的声明。…

14.6.2 依赖性名称(Dependent names)

1) …(模板定义中的)表达式和类型可能会依赖于模板参数,并且模板参数会影响到名称查找的作用域 … 如果表达式中有操作数依赖于模板参数,那么整个表达式都依赖于模板参数,名称查找延期到模板实例化时进行。并且定义时和实例化时的上下文都会参与名称查找。(依赖性)表达式可以分为类型依赖(类型指模板参数的类型)或值依赖。

14.6.2.2 类型依赖的表达式

2) 如果成员函数所属的类型是和模板参数有关的,那么这个成员函数中的this就认为是类型依赖的。

14.6.3 非依赖性名称(Non-dependent names)

1) 非依赖性名称在模板定义时使用通常的名称查找规则进行名称查找。

[Working Draft: Standard of Programming Language C++, N3337][1]

知道差距在哪了吗:人家会说黑话。什么时候咱们也会说黑话了,就是标准委员会成员了,反正懂得也不比他们少。不过黑话确实不太好懂 —— 怪我翻译不好的人,自己看原文,再说好懂了人家还靠什么吃饭 —— 我们来举一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int a;
struct B { int v; }
template <typename T> struct X {
B b; // B 是第三类名字,b 是第一类
T t; // T 是第二类
X* anthor; // X 这里代指 X<T>,第一类
typedef int Y; // int 是第三类
Y y; // Y 是第一类
C c; // C 什么都不是,编译错误。
void foo() {
b.v += y; // b 是第一类,非依赖性名称
b.v *= T::s_mem; // T::s_mem 是第二类
// s_mem的作用域由T决定
// 依赖性名称,类型依赖
}
};

所以,按照标准的意思,名称查找会在模板定义和实例化时各做一次,分别处理非依赖性名称和依赖性名称的查找。这就是“两阶段名称查找”这一名词的由来。只不过这个术语我也不知道是谁发明的,它并没有出现的标准上,但是频繁出现在StackOverflow和Blog上。

接下来,我们就来解决2.3.1节中留下的几个问题。

先看第四个问题。为什么MSVC中,函数模板的定义内不管填什么编译器都不报错?因为MSVC在分析模板中成员函数定义时没有做任何事情。至于为啥连“大王叫我来巡山”都能过得去,这是C++语法/语义分析的特殊性导致的。
C++是个非常复杂的语言,以至于它的编译器,不可能通过词法-语法-语义多趟分析清晰分割,因为它的语义将会直接干扰到语法:

1
2
3
void foo(){
A<T> b;
}

在这段简短的代码中,就包含了两个歧义的可能,一是A是模板,于是A<T>是一个实例化的类型,b是变量,另外一种是比较表达式(Comparison Expression)的组合,((A < T) > b)

甚至词法分析也会受到语义的干扰,C++11中才明确被修正的vector<vector<int>>,就因为>>被误解为右移或流操作符,而导致某些编译器上的错误。因此,在语义没有确定之前,连语法都没有分析的价值。

大约是基于如此考量,为了偷懒,MSVC将包括所有模板成员函数的语法/语义分析工作都挪到了第二个Phase,于是乎连带着语法分析都送进了第二个阶段。符合标准么?显然不符合。

但是这里值得一提的是,MSVC的做法和标准相比,虽然投机取巧,但并非有弊无利。我们来先说一说坏处。考虑以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ----------- X.h ------------

template <typename T> struct X {
// 实现代码
};

// ---------- X.cpp -----------

// ... 一些代码 ...
X<int> xi;
// ... 一些代码 ...
X<float> xf;
// ... 一些代码 ...

此时如果X中有一些与模板参数无关的错误,如果名称查找/语义分析在两个阶段完成,那么这些错误会很早、且唯一的被提示出来;但是如果一切都在实例化时处理,那么可能会导致不同的实例化过程提示同样的错误。而模板在运用过程中,往往会产生很多实例,此时便会大量报告同样的错误。

当然,MSVC并不会真的这么做。根据推测,最终他们是合并了相同的错误。因为即便对于模板参数相关的编译错误,也只能看到最后一次实例化的错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T> struct X {};

template <typename T> struct Y
{
typedef X<T> ReboundType; // 类型定义1
void foo()
{
X<T> instance0;
X<T>::MemberType instance1;
WTF instance2
}
};

void poo(){
Y<int>::foo();
Y<float>::foo();
}

MSVC下和模板相关的错误只有一个:

1
2
3
4
5
error C2039: 'MemberType': is not a member of 'X<T>'
with
[
T=float
]

然后是一些语法错误,比如MemberType不是一个合法的标识符之类的。这样甚至你会误以为int情况下模板的实例化是正确的。虽然在有了经验之后会发现这个问题挺荒唐的,但是仍然会让新手有困惑。

相比之下,更加遵守标准的Clang在错误提示上就要清晰许多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
error: unknown type name 'WTF'
WTF instance2
^
error: expected ';' at end of declaration
WTF instance2
^
;
error: no type named 'MemberType' in 'X<int>'
typename X<T>::MemberType instance1;
~~~~~~~~~~~~~~~^~~~~~~~~~
note: in instantiation of member function 'Y<int>::foo' requested here
Y<int>::foo();
^
error: no type named 'MemberType' in 'X<float>'
typename X<T>::MemberType instance1;
~~~~~~~~~~~~~~~^~~~~~~~~~
note: in instantiation of member function 'Y<float>::foo' requested here
Y<float>::foo();
^
4 errors generated.

可以看到,Clang的提示和标准更加契合。它很好地区分了模板在定义和实例化时分别产生的错误。

另一个缺点也与之类似。因为没有足够的检查,如果你写的模板没有被实例化,那么很可能缺陷会一直存在于代码之中。特别是模板代码多在头文件。虽然不如接口那么重要,但也是属于被公开的部分,别人很可能会踩到坑上。缺陷一旦传播开修复起来就没那么容易了。

但是正如我前面所述,这个违背了标准的特性,并不是一无是处。首先,它可以完美的兼容标准。符合标准的、能够被正确编译的代码,一定能够被MSVC的方案所兼容。其次,它带来了一个非常有趣的特性,看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct A;
template <typename T> struct X {
int v;
void convertTo(A& a) {
a.v = v; // 这里需要A的实现
}
};

struct A { int v; };

void main() {
X<int> x;
x.foo(5);
}

这个例子在Clang中是错误的,因为:

1
2
3
4
5
6
7
error: variable has incomplete type 'A'
A a;
^
note: forward declaration of 'A'
struct A;
^
1 error generated.

符合标准的写法需要将类模板的定义,和函数模板的定义分离开:

TODO 此处例子不够恰当,并且描述有歧义。需要在未来版本中修订。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct A;
template <typename T> struct X {
int v;
void convertTo(A& a);
};

struct A { int v; };

template <typename T> void X<T>::convertTo(A& a) {
a.v = v;
}

void main() {
X<int> x;
x.foo(5);
}

但是其实我们知道,foo要到实例化之后,才需要真正的做语义分析。在MSVC上,因为函数实现就是到模板实例化时才处理的,所以这个例子是完全正常工作的。因此在上面这个例子中,MSVC的实现要比标准更加易于写和维护,是不是有点写Java/C那种声明实现都在同一处的清爽感觉了呢!

扩展阅读: [The Dreaded Two-Phase Name Lookup][2]

3.3.3. “多余的” typename 关键字

到了这里,2.3.1 中提到的四个问题,还有三个没有解决:

1
2
3
4
5
6
7
8
template <typename T> struct X {};

template <typename T> struct Y
{
typedef X<T> ReboundType; // 这里为什么是正确的?
typedef typename X<T>::MemberType MemberType2; // 这里的typename是做什么的?
typedef UnknownType MemberType3; // 这里为什么会出错?
};

我们运用我们2.3.2节中学习到的标准,来对Y内部做一下分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T> struct Y
{
// X可以查找到原型;
// X<T>是一个依赖性名称,模板定义阶段并不管X<T>是不是正确的。
typedef X<T> ReboundType;

// X可以查找到原型;
// X<T>是一个依赖性名称,X<T>::MemberType也是一个依赖性名称;
// 所以模板声明时也不会管X模板里面有没有MemberType这回事。
typedef typename X<T>::MemberType MemberType2;

// UnknownType 不是一个依赖性名称
// 而且这个名字在当前作用域中不存在,所以直接报错。
typedef UnknownType MemberType3;
};

下面,唯一的问题就是第二个:typename是做什么的?

对于用户来说,这其实是一个语法噪音。也就是说,其实就算没有它,语法上也说得过去。事实上,某些情况下MSVC的确会在标准需要的时候,不用写typename。但是标准中还是规定了形如 T::MemberType 这样的qualified id 在默认情况下不是一个类型,而是解释为T的一个成员变量MemberType,只有当typename修饰之后才能作为类型出现。

事实上,标准对typename的使用规定极为复杂,也算是整个模板中的难点之一。如果想了解所有的标准,需要阅读标准14.6节下2-7条,以及14.6.2.1第一条中对于current instantiation的解释。

简单来说,如果编译器能在出现的时候知道它是一个类型,那么就不需要typename,如果必须要到实例化的时候才能知道它是不是合法,那么定义的时候就把这个名称作为变量而不是类型。

我们用一行代码来说明这个问题:

1
a * b;

在没有模板的情况下,这个语句有两种可能的意思:如果a是一个类型,这就是定义了一个指针b,它拥有类型a*;如果a是一个对象或引用,这就是计算一个表达式a*b,虽然结果并没有保存下来。可是如果上面的a是模板参数的成员,会发生什么呢?

1
2
3
4
template <typename T> void meow()
{
   T::a * b; // 这是指针定义还是表达式语句?
}

编译器对模板进行语法检查的时候,必须要知道上面那一行到底是个什么——这当然可以推迟到实例化的时候进行(比如VC,这也是上面说过VC可以不加typename的原因),不过那是另一个故事了——显然在模板定义的时候,编译器并不能妄断。因此,C++标准规定,在没有typename约束的情况下认为这里T::a不是类型,因此T::a * b; 会被当作表达式语句(例如乘法);而为了告诉编译器这是一个指针的定义,我们必须在T::a之前加上typename关键字,告诉编译器T::a是一个类型,这样整个语句才能符合指针定义的语法。

在这里,我举几个例子帮助大家理解typename的用法,这几个例子已经足以涵盖日常使用[(预览)][3]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct A;
template <typename T> struct B;
template <typename T> struct X {
typedef X<T> TA; // 编译器当然知道 X<T> 是一个类型。
typedef X TB; // X 等价于 X<T> 的缩写
typedef T TC; // T 不是一个类型还玩毛

// !!!注意我要变形了!!!
class Y {
typedef X<T> TD; // X 的内部,既然外部高枕无忧,内部更不用说了
typedef X<T>::Y TE; // 嗯,这里也没问题,编译器知道Y就是当前的类型,
// 这里在VS2015上会有错,需要添加 typename,
// Clang 上顺利通过。
typedef typename X<T*>::Y TF; // 这个居然要加 typename!
// 因为,X<T*>和X<T>不一样哦,
// 它可能会在实例化的时候被别的偏特化给抢过去实现了。
};

typedef A TG; // 嗯,没问题,A在外面声明啦
typedef B<T> TH; // B<T>也是一个类型
typedef typename B<T>::type TI; // 嗯,因为不知道B<T>::type的信息,
// 所以需要typename
typedef B<int>::type TJ; // B<int> 不依赖模板参数,
// 所以编译器直接就实例化(instantiate)了
// 但是这个时候,B并没有被实现,所以就出错了
};

3.4. 本章小结

这一章是写作中最艰难的一章,中间停滞了将近一年。因为要说清楚C++模板中一些语法噪音和设计决议并不是一件轻松的事情。不过通过这一章的学习,我们知道了下面这几件事情:

  1. 部分特化/偏特化特化 相当于是模板实例化过程中的if-then-else。这使得我们根据不同类型,选择不同实现的需求得以实现;

  2. 在 2.3.3 一节我们插入了C++模板中最难理解的内容之一:名称查找。名称查找是语义分析的一个环节,模板内书写的 变量声明typedef类型名称 甚至 类模板中成员函数的实现 都要符合名称查找的规矩才不会出错;

  3. C++编译器对语义的分析的原则是“大胆假设,小心求证”:在能求证的地方尽量求证 —— 比如两段式名称查找的第一阶段;无法检查的地方假设你是正确的 —— 比如typedef typename A<T>::MemberType X;在模板定义时因为T不明确不会轻易判定这个语句的死刑。

从下一章开始,我们将进入元编程环节。我们将使用大量的示例,一方面帮助巩固大家学到的模板知识,一方面也会引导大家使用函数式思维去解决常见的问题。

现代C++学习-其他杂项

其他杂项

1.新类型 long long int

long long int 并不是 C++11 最先引入的,其实早在 C99, long long int 就已经被纳入 C 标准中,所以大部分的编译器早已支持。 C++11 的工作则是正式把它纳入标准库, 规定了一个 long long int 类型至少具备 64 位的比特数

2.noexcept的修饰和操作

C++11 将异常的声明简化为以下两种情况:
1.函数可能抛出任何异常
2.函数不能抛出任何异常
并使用 noexcept 对这两种行为进行限制

1
2
void may_throw(); // 可能抛出异常
void no_throw() noexcept; // 不可能抛出异常

使用 noexcept 修饰过的函数如果抛出异常,编译器会使用 std::terminate() 来立即终止程序运行。

noexcept 还能够做操作符,用于操作一个表达式,当表达式无异常时,返回 true,否则返回 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
void may_throw() {
throw true;
}
auto non_block_throw = []{
may_throw();
};
void no_throw() noexcept {
return;
}

auto block_throw = []() noexcept {
no_throw();
};
int main()
{
std::cout << std::boolalpha
<< "may_throw() noexcept? " << noexcept(may_throw()) << std::endl
<< "no_throw() noexcept? " << noexcept(no_throw()) << std::endl
<< "lmay_throw() noexcept? " << noexcept(non_block_throw()) << std::endl
<< "lno_throw() noexcept? " << noexcept(block_throw()) << std::endl;
return 0;
}

noexcept 修饰完一个函数之后能够起到封锁异常扩散的功效,如果内部产生异常,外部也不会触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
may_throw();
} catch (...) {
std::cout << "捕获异常, 来自 may_throw()" << std::endl;
}
try {
non_block_throw();
} catch (...) {
std::cout << "捕获异常, 来自 non_block_throw()" << std::endl;
}
try {
block_throw();
} catch (...) {
std::cout << "捕获异常, 来自 block_throw()" << std::endl;
}
1
2
捕获异常, 来自 may_throw()
捕获异常, 来自 non_block_throw()

3.字面量

原始字符串字面量

C++11 提供了原始字符串字面量的写法,可以在一个字符串前方使用 R 来修饰这个字符串, 同时,将原始字符串使用括号包裹

1
2
3
4
5
6
7
8
#include <iostream>
#include <string>

int main() {
std::string str = R"(C:\File\To\Path)";
std::cout << str << std::endl;
return 0;
}

自定义字面量

C++11 引进了自定义字面量的能力,通过重载双引号后缀运算符实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 字符串字面量自定义必须设置如下的参数列表
std::string operator"" _wow1(const char *wow1, size_t len) {
return std::string(wow1)+"woooooooooow, amazing";
}

std::string operator"" _wow2 (unsigned long long i) {
return std::to_string(i)+"woooooooooow, amazing";
}

int main() {
auto str = "abc"_wow1;
auto num = 1_wow2;
std::cout << str << std::endl;
std::cout << num << std::endl;
return 0;
}

自定义字面量支持四种字面量:
1.整型字面量:重载时必须使用 unsigned long long、const char *、模板字面量算符参数,在上面的代码中使用的是前者;
2.浮点型字面量:重载时必须使用 long double、const char *、模板字面量算符;
3.字符串字面量:必须使用 (const char *, size_t) 形式的参数表;
4.字符字面量:参数只能是 char, wchar_t, char16_t, char32_t 这几种类型

4.内存对齐

C++ 11 引入了两个新的关键字 alignof 和 alignas 来支持对内存对齐进行控制。 alignof 关键字能够获得一个与平台相关的 std::size_t 类型的值,用于查询该平台的对齐方式。 当然我们有时候并不满足于此,甚至希望自定定义结构的对齐方式,同样,C++ 11 还引入了 alignas 来重新修饰某个结构的对齐方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

struct Storage {
char a;
int b;
double c;
long long d;
};

struct alignas(std::max_align_t) AlignasStorage {
char a;
int b;
double c;
long long d;
};

int main() {
std::cout << alignof(Storage) << std::endl;
std::cout << alignof(AlignasStorage) << std::endl;
return 0;
}

其中 std::max_align_t 要求每个标量类型的对齐方式严格一样,因此它几乎是最大标量没有差异, 进而大部分平台上得到的结果为 long double,因此我们这里得到的 AlignasStorage 的对齐要求是 8 或 16