第四部分

4. 深入理解特化与偏特化

4.1. 正确的理解偏特化

4.1.1. 偏特化与函数重载的比较

在前面的章节中,我们介绍了偏特化的形式、也介绍了简单的用例。因为偏特化和函数重载存在着形式上的相似性,因此初学者便会借用重载的概念,来理解偏特化的行为。只是,重载和偏特化尽管相似但仍有差异。

我们来先看一个函数重载的例子:

1
2
3
4
5
6
7
8
9
void doWork(int);
void doWork(float);
void doWork(int, int);

void f() {
doWork(0);
doWork(0.5f);
doWork(0, 0);
}

在这个例子中,我们展现了函数重载可以在两种条件下工作:参数数量相同、类型不同;参数数量不同。

仿照重载的形式,我们通过特化机制,试图实现一个模板的“重载”:

1
2
3
4
5
6
7
8
9
10
11
template <typename T> struct DoWork;	 // (0) 这是原型

template <> struct DoWork<int> {}; // (1) 这是 int 类型的"重载"
template <> struct DoWork<float> {}; // (2) 这是 float 类型的"重载"
template <> struct DoWork<int, int> {}; // (3) 这是 int, int 类型的“重载”

void f(){
DoWork<int> i;
DoWork<float> f;
DoWork<int, int> ii;
}

这个例子在字面上“看起来”并没有什么问题,可惜编译器在编译的时候仍然提示出错了goo.gl/zI42Zv

1
2
3
4
5
6
5 : error: too many template arguments for class template 'DoWork'
template <> struct DoWork<int, int> {}; // 这是 int, int 类型的“重载”
^ ~~~~
1 : note: template is declared here
template <typename T> struct DoWork {}; // 这是原型
~~~~~~~~~~~~~~~~~~~~~ ^

从编译出错的失望中冷静一下,在仔细看看函数特化/偏特化和一般模板的不同之处:

1
2
3
template <typename T> class X      {};
template <typename T> class X <T*> {};
// ^^^^ 注意这里

对,就是这个<T*>,跟在X后面的“小尾巴”,我们称作实参列表,决定了第二条语句是第一条语句的跟班。所以,第二条语句,即“偏特化”,必须要符合原型X的基本形式:那就是只有一个模板参数。这也是为什么DoWork尝试以template <> struct DoWork<int, int>的形式偏特化的时候,编译器会提示模板实参数量过多。

另外一方面,在类模板的实例化阶段,它并不会直接去寻找 template <> struct DoWork<int, int>这个小跟班,而是会先找到基本形式,template <typename T> struct DoWork;,然后再去寻找相应的特化。

我们以DoWork<int> i;为例,尝试复原一下编译器完成整个模板匹配过程的场景,帮助大家理解。看以下示例代码:

1
2
3
4
5
6
7
8
template <typename T> struct DoWork;	      // (0) 这是原型

template <> struct DoWork<int> {}; // (1) 这是 int 类型的特化
template <> struct DoWork<float> {}; // (2) 这是 float 类型的特化
template <typename U> struct DoWork<U*> {}; // (3) 这是指针类型的偏特化

DoWork<int> i; // (4)
DoWork<float*> pf; // (5)

首先,编译器分析(0), (1), (2)三句,得知(0)是模板的原型,(1),(2),(3)是模板(0)的特化或偏特化。我们假设有两个字典,第一个字典存储了模板原型,我们称之为TemplateDict。第二个字典TemplateSpecDict,存储了模板原型所对应的特化/偏特化形式。所以编译器在处理这几句时,可以视作

1
2
3
4
5
6
// 以下为伪代码
TemplateDict[DoWork<T>] = {
DoWork<int>,
DoWork<float>,
DoWork<U*>
};

然后 (4) 试图以int实例化类模板DoWork。它会在TemplateDict中,找到DoWork,它有一个形式参数T接受类型,正好和我们实例化的要求相符合。并且此时T被推导为int。(5) 中的float*也是同理。

1
2
3
4
5
6
7
8
9
{   // 以下为 DoWork<int> 查找对应匹配的伪代码
templateProtoInt = TemplateDict.find(DoWork, int); // 查找模板原型,查找到(0)
template = templatePrototype.match(int); // 以 int 对应 int 匹配到 (1)
}

{ // 以下为DoWork<float*> 查找对应匹配的伪代码
templateProtoIntPtr = TemplateDict.find(DoWork, float*) // 查找模板原型,查找到(0)
template = templateProtoIntPtr.match(float*) // 以 float* 对应 U* 匹配到 (3),此时U为float
}

那么根据上面的步骤所展现的基本原理,我们随便来几个练习:

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 <typename T, typename U> struct X            ;    // 0 
// 原型有两个类型参数
// 所以下面的这些偏特化的实参列表
// 也需要两个类型参数对应
template <typename T> struct X<T, T > {}; // 1
template <typename T> struct X<T*, T > {}; // 2
template <typename T> struct X<T, T* > {}; // 3
template <typename U> struct X<U, int> {}; // 4
template <typename U> struct X<U*, int> {}; // 5
template <typename U, typename T> struct X<U*, T* > {}; // 6
template <typename U, typename T> struct X<U, T* > {}; // 7

template <typename T> struct X<unique_ptr<T>, shared_ptr<T>>; // 8

// 以下特化,分别对应哪个偏特化的实例?
// 此时偏特化中的T或U分别是什么类型?

X<float*, int> v0;
X<double*, int> v1;
X<double, double> v2;
X<float*, double*> v3;
X<float*, float*> v4;
X<double, float*> v5;
X<int, double*> v6;
X<int*, int> v7;
X<double*, double> v8;

在上面这段例子中,有几个值得注意之处。首先,偏特化时的模板形参,和原型的模板形参没有任何关系。和原型不同,它的顺序完全不影响模式匹配的顺序,它只是偏特化模式,如<U, int>U的声明,真正的模式,是由<U, int>体现出来的。

这也是为什么在特化的时候,当所有类型都已经确定,我们就可以抛弃全部的模板参数,写出template <> struct X<int, float>这样的形式:因为所有列表中所有参数都确定了,就不需要额外的形式参数了。

其次,作为一个模式匹配,偏特化的实参列表中展现出来的“样子”,就是它能被匹配的原因。比如,struct X<T, T>中,要求模板的两个参数必须是相同的类型。而struct X<T, T*>,则代表第二个模板类型参数必须是第一个模板类型参数的指针,比如X<float***, float****>就能匹配上。当然,除了简单的指针、constvolatile修饰符,其他的类模板也可以作为偏特化时的“模式”出现,例如示例8,它要求传入同一个类型的unique_ptrshared_ptr。C++标准中指出下列模式都是可以被匹配的:

N3337, 14.8.2.5/8

T是模板类型实参或者类型列表(如 int, float, double 这样的,TT是template-template实参(参见6.2节),i是模板的非类型参数(整数、指针等),则以下形式的形参都会参与匹配:

T, cv-list T, T*, template-name <T>, T&, T&&

T [ integer-constant ]

type (T), T(), T(T)

T type ::*, type T::*, T T::*

T (type ::*)(), type (T::*)(), type (type ::*)(T), type (T::*)(T), T (type ::*)(T), T (T::*)(), T (T::*)(T)

type [i], template-name <i>, TT<T>, TT<i>, TT<>

对于某些实例化,偏特化的选择并不是唯一的。比如v4的参数是<float*, float*>,能够匹配的就有三条规则,1,6和7。很显然,6还是比7好一些,因为能多匹配一个指针。但是1和6,就很难说清楚谁更好了。一个说明了两者类型相同;另外一个则说明了两者都是指针。所以在这里,编译器也没办法决定使用那个,只好报出了编译器错误。

其他的示例可以先自己推测一下, 再去编译器上尝试一番:goo.gl/9UVzje

4.1.2. 不定长的模板参数

不过这个时候也许你还不死心。有没有一种办法能够让例子DoWork像重载一样,支持对长度不一的参数列表分别偏特化/特化呢?

答案当然是肯定的。

首先,首先我们要让模板实例化时的模板参数统一到相同形式上。逆向思维一下,虽然两个类型参数我们很难缩成一个参数,但是我们可以通过添加额外的参数,把一个扩展成两个呀。比如这样:

1
2
3
DoWork<int,   void> i;
DoWork<float, void> f;
DoWork<int, int > ii;

这时,我们就能写出统一的模板原型:

1
template <typename T0, typename T1> struct DoWork;

继而偏特化/特化问题也解决了:

1
2
3
template <> struct DoWork<int,   void> {};  // (1) 这是 int 类型的特化
template <> struct DoWork<float, void> {}; // (2) 这是 float 类型的特化
template <> struct DoWork<int, int> {}; // (3) 这是 int, int 类型的特化

显而易见这个解决方案并不那么完美。首先,不管是偏特化还是用户实例化模板的时候,都需要多撰写好几个void,而且最长的那个参数越长,需要写的就越多;其次,如果我们的DoWork在程序维护的过程中新加入了一个参数列表更长的实例,那么最悲惨的事情就会发生 —— 原型、每一个偏特化、每一个实例化都要追加上void以凑齐新出现的实例所需要的参数数量。

所幸模板参数也有一个和函数参数相同的特性:默认实参(Default Arguments)。只需要一个例子,你们就能看明白了goo.gl/TtmcY9

1
2
3
4
5
6
7
8
9
10
11
template <typename T0, typename T1 = void> struct DoWork;

template <typename T> struct DoWork<T> {};
template <> struct DoWork<int> {};
template <> struct DoWork<float> {};
template <> struct DoWork<int, int> {};

DoWork<int> i;
DoWork<float> f;
DoWork<double> d;
DoWork<int, int> ii;

所有参数不足,即原型中参数T1没有指定的地方,都由T1自己的默认参数void补齐了。

但是这个方案仍然有些美中不足之处。

比如,尽管我们默认了所有无效的类型都以void结尾,所以正确的类型列表应该是类似于<int, float, char, void, void>这样的形态。但你阻止不了你的用户写出类似于<void, int, void, float, char, void, void>这样不符合约定的类型参数列表。

其次,假设这段代码中有一个函数,它的参数使用了和类模板相同的参数列表类型,如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T0, typename T1 = void> struct X {
static void call(T0 const& p0, T1 const& p1); // 0
};

template <typename T0> struct X<T0> {
static void call(T0 const& p0); // 1
};

void foo(){
X<int>::call(5); // 调用函数 1
X<int, float>::call(5, 0.5f); // 调用函数 0
}

那么,每加一个参数就要多写一个偏特化的形式,甚至还要重复编写一些可以共享的实现。

不过不管怎么说,以长参数加默认参数的方式支持变长参数是可行的做法,这也是C++98/03时代的唯一选择。

例如,Boost.Tuple就使用了这个方法,支持了变长的Tuple:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Tuple 的声明,来自 boost
struct null_type;

template <
class T0 = null_type, class T1 = null_type, class T2 = null_type,
class T3 = null_type, class T4 = null_type, class T5 = null_type,
class T6 = null_type, class T7 = null_type, class T8 = null_type,
class T9 = null_type>
class tuple;

// Tuple的一些用例
tuple<int> a;
tuple<double&, const double&, const double, double*, const double*> b;
tuple<A, int(*)(char, int), B(A::*)(C&), C> c;
tuple<std::string, std::pair<A, B> > d;
tuple<A*, tuple<const A*, const B&, C>, bool, void*> e;

此外,Boost.MPL也使用了这个手法将boost::mpl::vector映射到boost::mpl::vector _n_上。但是我们也看到了,这个方案的缺陷很明显:代码臃肿和潜在的正确性问题。此外,过度使用模板偏特化、大量冗余的类型参数也给编译器带来了沉重的负担。

为了缓解这些问题,在C++11中,引入了变参模板(Variadic Template)。我们来看看支持了变参模板的C++11是如何实现tuple的:

1
template <typename... Ts> class tuple;

是不是一下子简洁了很多!这里的typename... Ts相当于一个声明,是说Ts不是一个类型,而是一个不定常的类型列表。同C语言的不定长参数一样,它通常只能放在参数列表的最后。看下面的例子:

1
2
3
4
template <typename... Ts, typename U> class X {};              // (1) error!
template <typename... Ts> class Y {}; // (2)
template <typename... Ts, typename U> class Y<U, Ts...> {}; // (3)
template <typename... Ts, typename U> class Y<Ts..., U> {}; // (4) error!

为什么第(1)条语句会出错呢?(1)是模板原型,模板实例化时,要以它为基础和实例化时的类型实参相匹配。因为C++的模板是自左向右匹配的,所以不定长参数只能结尾。其他形式,无论写作Ts, U,或者是Ts, V, Us,,或者是V, Ts, Us都是不可取的。(4) 也存在同样的问题。

但是,为什么(3)中, 模板参数和(1)相同,都是typename... Ts, typename U,但是编译器却并没有报错呢?

答案在这一节的早些时候。(3)和(1)不同,它并不是模板的原型,它只是Y的一个偏特化。回顾我们在之前所提到的,偏特化时,模板参数列表并不代表匹配顺序,它们只是为偏特化的模式提供的声明,也就是说,它们的匹配顺序,只是按照<U, Ts...>来,而之前的参数只是告诉你Ts是一个类型列表,而U是一个类型,排名不分先后。

在这里,我们只提到了变长模板参数的声明,如何使用我们将在第四章讲述。

4.1.3. 模板的默认实参

在上一节中,我们介绍了模板对默认实参的支持。当时我们的例子很简单,默认模板实参是一个确定的类型void或者自定义的null_type

1
2
3
template <
typename T0, typename T1 = void, typename T2 = void
> class Tuple;

实际上,模板的默认参数不仅仅可以是一个确定的类型,它还能是以其他类型为参数的一个类型表达式。
考虑下面的例子:我们要执行两个同类型变量的除法,它对浮点、整数和其他类型分别采取不同的措施。
对于浮点,执行内置除法;对于整数,要处理除零保护,防止引发异常;对于其他类型,执行一个叫做CustomeDiv的函数。

第一步,我们先把浮点正确的写出来:

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
include <type_traits>

template <typename T> T CustomDiv(T lhs, T rhs) {
// Custom Div的实现
}

template <typename T, bool IsFloat = std::is_floating_point<T>::value> struct SafeDivide {
static T Do(T lhs, T rhs) {
return CustomDiv(lhs, rhs);
}
};

template <typename T> struct SafeDivide<T, true>{ // 偏特化A
static T Do(T lhs, T rhs){
return lhs/rhs;
}
};

template <typename T> struct SafeDivide<T, false>{ // 偏特化B
static T Do(T lhs, T rhs){
return lhs;
}
};

void foo(){
SafeDivide<float>::Do(1.0f, 2.0f); // 调用偏特化A
SafeDivide<int>::Do(1, 2); // 调用偏特化B
}

在实例化的时候,尽管我们只为SafeDivide指定了参数T,但是它的另一个参数IsFloat在缺省的情况下,可以根据T,求出表达式std::is_floating_point<T>::value的值作为实参的值,带入到SafeDivide的匹配中。

嗯,这个时候我们要再把整型和其他类型纳入进来,无外乎就是加这么一个参数goo.gl/0Lqywt

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
include <complex>
include <type_traits>

template <typename T> T CustomDiv(T lhs, T rhs) {
T v;
// Custom Div的实现
return v;
}

template <
typename T,
bool IsFloat = std::is_floating_point<T>::value,
bool IsIntegral = std::is_integral<T>::value
> struct SafeDivide {
static T Do(T lhs, T rhs) {
return CustomDiv(lhs, rhs);
}
};

template <typename T> struct SafeDivide<T, true, false>{ // 偏特化A
static T Do(T lhs, T rhs){
return lhs/rhs;
}
};

template <typename T> struct SafeDivide<T, false, true>{ // 偏特化B
static T Do(T lhs, T rhs){
return rhs == 0 ? 0 : lhs/rhs;
}
};

void foo(){
SafeDivide<float>::Do(1.0f, 2.0f); // 调用偏特化A
SafeDivide<int>::Do(1, 2); // 调用偏特化B
SafeDivide<std::complex<float>>::Do({1.f, 2.f}, {1.f, -2.f}); // 调用一般形式
}

当然,这时也许你会注意到,is_integralis_floating_point和其他类类型三者是互斥的,那能不能只使用一个条件量来进行分派呢?答案当然是可以的:goo.gl/jYp5J2

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 <complex>
include <type_traits>

template <typename T> T CustomDiv(T lhs, T rhs) {
T v;
// Custom Div的实现
return v;
}

template <typename T, typename Enabled = std::true_type> struct SafeDivide {
static T Do(T lhs, T rhs) {
return CustomDiv(lhs, rhs);
}
};

template <typename T> struct SafeDivide<
T, typename std::is_floating_point<T>::type>{ // 偏特化A
static T Do(T lhs, T rhs){
return lhs/rhs;
}
};

template <typename T> struct SafeDivide<
T, typename std::is_integral<T>::type>{ // 偏特化B
static T Do(T lhs, T rhs){
return rhs == 0 ? 0 : lhs/rhs;
}
};

void foo(){
SafeDivide<float>::Do(1.0f, 2.0f); // 调用偏特化A
SafeDivide<int>::Do(1, 2); // 调用偏特化B
SafeDivide<std::complex<float>>::Do({1.f, 2.f}, {1.f, -2.f});
}

我们借助这个例子,帮助大家理解一下这个结构是怎么工作的:

  1. SafeDivide<int>
  • 通过匹配类模板的泛化形式,计算默认实参,可以知道我们要匹配的模板实参是SafeDivide<int, true_type>

  • 计算两个偏特化的形式的匹配:A得到<int, false_type>,和B得到 <int, true_type>

  • 最后偏特化B的匹配结果和模板实参一致,使用它。

  1. 针对SafeDivide<complex<float>>
  • 通过匹配类模板的泛化形式,可以知道我们要匹配的模板实参是SafeDivide<complex<float>, true_type>

  • 计算两个偏特化形式的匹配:A和B均得到SafeDivide<complex<float>, false_type>

  • A和B都与模板实参无法匹配,所以使用原型,调用CustomDiv

4.2. 后悔药:SFINAE

考虑下面这个函数模板:

1
2
3
4
template <typename T, typename U>
void foo(T t, typename U::type u) {
// ...
}

到本节为止,我们所有的例子都保证了一旦咱们敲定了模板参数中 TU,函数参变量 tu 的类型都是成立的,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
struct X {
typedef float type;
};

template <typename T, typename U>
void foo(T t, typename U::type u) {
// ...
}

void callFoo() {
foo<int, X>(5, 5.0); // T == int, typename U::type == X::type == float
}

那么这里有一个可能都不算是问题的问题 —— 对于下面的代码,你认为它会提示怎么样的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct X {
typedef float type;
};

struct Y {
typedef float type2;
};

template <typename T, typename U>
void foo(T t, typename U::type u) {
// ...
}

void callFoo() {
foo<int, X>(5, 5.0); // T == int, typename U::type == X::type == float
foo<int, Y>(5, 5.0); // ???
}

这个时候你也许会说:啊,这个简单,Y 没有 type 这个成员自然会出错啦!嗯,这个时候咱们来看看Clang给出的结果:

1
2
3
4
5
error: no matching function for call to 'foo'
foo<int, Y>(5, 5.0); // ???
^~~~~~~~~~~
note: candidate template ignored: substitution failure [with T = int, U = Y]: no type named 'type' in 'Y'
void foo(T t, typename U::type u) {

完整翻译过来就是,直接的出错原因是没有匹配的 foo 函数,间接原因是尝试用 [T = int, U = y] 做类型替换的时候失败了,所以这个函数模板就被忽略了。等等,不是出错,而是被忽略了?那么也就是说,只要有别的能匹配的类型兜着,编译器就无视这里的失败了?

银河火箭队的阿喵说,就是这样。不信邪的朋友可以试试下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct X {
typedef float type;
};

struct Y {
typedef float type2;
};

template <typename T, typename U>
void foo(T t, typename U::type u) {
// ...
}

template <typename T, typename U>
void foo(T t, typename U::type2 u) {
// ...
}
void callFoo() {
foo<int, X>(5, 5.0); // T == int, typename U::type == X::type == float
foo<int, Y>( 1, 1.0 ); // ???
}

这下相信编译器真的是不关心替换失败了吧。我们管这种只要有正确的候选,就无视替换失败的做法为SFINAE。

我们不用纠结这个词的发音,它来自于 Substitution failure is not an error 的首字母缩写。这一句之乎者也般难懂的话,由之乎者 —— 啊,不,Substitution,Failure和Error三个词构成。

我们从最简单的词“Error”开始理解。Error就是一般意义上的编译错误。一旦出现编译错误,大家都知道,编译器就会中止编译,并且停止接下来的代码生成和链接等后续活动。

其次,我们再说“Failure”。很多时候光看字面意思,很多人会把 Failure 和 Error 等同起来。但是实际上Failure很多场合下只是一个中性词。比如我们看下面这个虚构的例子就知道这两者的区别了。

假设我们有一个语法分析器,其中某一个规则需要匹配一个token,它可以是标识符,字面量或者是字符串,那么我们会有下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
switch(token)
{
case IDENTIFIER:
// do something
break;
case LITERAL_NUMBER:
// do something
break;
case LITERAL_STRING:
// do something
break;
default:
throw WrongToken(token);
}

假如我们当前的token是 LITERAL_STRING 的时候,那么第一步它在匹配 IDENTIFIER 时,我们可以认为它失败(failure)了,但是它在第三步就会匹配上,所以它并不是一个错误。

但是如果这个token既不是标识符、也不是数字字面量、也不是字符串字面量,而且我们的语法规定除了这三类值以外其他统统都是非法的时,我们才认为它是一个error。

大家所熟知的函数重载也是如此。比如说下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
struct A {};
struct B: public A {};
struct C {};

void foo(A const&) {}
void foo(B const&) {}

void callFoo() {
foo( A() );
foo( B() );
foo( C() );
}

那么 foo( A() ) 虽然匹配 foo(B const&) 会失败,但是它起码能匹配 foo(A const&),所以它是正确的;foo( B() ) 能同时匹配两个函数原型,但是 foo(B const&) 要更好一些,因此它选择了这个原型。而 foo( C() ); 因为两个函数都匹配失败(Failure)了,所以它找不到相应的原型,这时才会报出一个编译器错误(Error)。

所以到这里我们就明白了,在很多情况下,Failure is not an error。编译器在遇到Failure的时候,往往还需要尝试其他的可能性。

好,现在我们把最后一个词,Substitution,加入到我们的字典中。现在这句话的意思就是说,我们要把 Failure is not an error 的概念,推广到Substitution阶段。

所谓substitution,就是将函数模板中的形参,替换成实参的过程。概念很简洁但是实现却颇多细节,所以C++标准中对这一概念的解释比较拗口。它分别指出了以下几点:

  • 什么时候函数模板会发生实参 替代(Substitute) 形参的行为;

  • 什么样的行为被称作 Substitution;

  • 什么样的行为不可以被称作 Substitution Failure —— 他们叫SFINAE error。

我们在此不再详述,有兴趣的同学可以参照这里,这是标准的一个精炼版本。这里我们简单的解释一下。

考虑我们有这么个函数签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <
typename T0,
// 一大坨其他模板参数
typename U = /* 和前面T有关的一大坨 */
>
RType /* 和模板参数有关的一大坨 */
functionName (
PType0 /* PType0 是和模板参数有关的一大坨 */,
PType1 /* PType1 是和模板参数有关的一大坨 */,
// ... 其他参数
) {
// 实现,和模板参数有关的一大坨
}

那么,在这个函数模板被实例化的时候,所有函数签名上的“和模板参数有关的一大坨”被推导出具体类型的过程,就是替换。一个更具体的例子来解释上面的“一大坨”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <
typename T,
typename U = typename vector<T>::iterator // 1
>
typename vector<T>::value_type // 1
foo(
T*, // 1
T&, // 1
typename T::internal_type, // 1
typename add_reference<T>::type, // 1
int // 这里都不需要 substitution
)
{
// 根据定义,substitution只发生在函数签名上。
// 故而整个函数实现部分都不会存在 substitution。
// 这是一个重点需要记住。
}

所有标记为 1 的部分,都是需要替换的部分,而它们在替换过程中的失败(failure),就称之为替换失败(substitution failure)。

下面的代码是提供了一些替换成功和替换失败的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct X {
typedef int type;
};

struct Y {
typedef int type2;
};

template <typename T> void foo(typename T::type); // Foo0
template <typename T> void foo(typename T::type2); // Foo1
template <typename T> void foo(T); // Foo2

void callFoo() {
foo<X>(5); // Foo0: Succeed, Foo1: Failed, Foo2: Failed
foo<Y>(10); // Foo0: Failed, Foo1: Succeed, Foo2: Failed
foo<int>(15); // Foo0: Failed, Foo1: Failed, Foo2: Succeed
}

在这个例子中,当我们指定 foo<Y> 的时候,substitution就开始工作了,而且会同时工作在三个不同的 foo 签名上。如果我们仅仅因为 Y 没有 type,匹配 Foo0 失败了,就宣布代码有错,中止编译,那显然是武断的。因为 Foo1 是可以被正确替换的,我们也希望 Foo1 成为 foo<Y> 的原型。

std/boost库中的 enable_if 是 SFINAE 最直接也是最主要的应用。所以我们通过下面 enable_if 的例子,来深入理解一下 SFINAE 在模板编程中的作用。

假设我们有两个不同类型的计数器(counter),一种是普通的整数类型,另外一种是一个复杂对象,它从接口 ICounter 继承,这个接口有一个成员叫做increase实现计数功能。现在,我们想把这两种类型的counter封装一个统一的调用:inc_counter。那么,我们直觉会简单粗暴的写出下面的代码:

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
struct ICounter {
virtual void increase() = 0;
virtual ~ICounter() {}
};

struct Counter: public ICounter {
void increase() override {
// Implements
}
};

template <typename T>
void inc_counter(T& counterObj) {
counterObj.increase();
}

template <typename T>
void inc_counter(T& intTypeCounter){
++intTypeCounter;
}

void doSomething() {
Counter cntObj;
uint32_t cntUI32;

// blah blah blah
inc_counter(cntObj);
inc_counter(cntUI32);
}

我们非常希望它展现出预期的行为。因为其实我们是知道对于任何一个调用,两个 inc_counter 只有一个是能够编译正确的。“有且唯一”,我们理应当期望编译器能够挑出那个唯一来。

可惜编译器做不到这一点。首先,它就告诉我们,这两个签名

1
2
template <typename T> void inc_counter(T& counterObj);
template <typename T> void inc_counter(T& intTypeCounter);

其实是一模一样的。我们遇到了 redefinition

我们看看 enable_if 是怎么解决这个问题的。我们通过 enable_if 这个 T 对于不同的实例做个限定:

1
2
3
4
5
6
7
8
9
10
11
template <typename T> void inc_counter(
T& counterObj,
typename std::enable_if<
std::is_base_of<ICounter, T>::value
>::type* = nullptr );

template <typename T> void inc_counter(
T& counterInt,
typename std::enable_if<
std::is_integral<T>::value
>::type* = nullptr );

然后我们解释一下,这个 enable_if 是怎么工作的,语法为什么这么丑:

首先,替换(substitution)只有在推断函数类型的时候,才会起作用。推断函数类型需要参数的类型,所以, typename std::enable_if<std::is_integral<T>::value>::type 这么一长串代码,就是为了让 enable_if 参与到函数类型中;

其次, is_integral<T>::value 返回一个布尔类型的编译器常数,告诉我们它是或者不是一个 integral typeenable_if<C> 的作用就是,如果这个 C 值为 True,那么 enable_if<C>::type 就会被推断成一个 void 或者是别的什么类型,让整个函数匹配后的类型变成 void inc_counter<int>(int & counterInt, void* dummy = nullptr); 如果这个值为 False ,那么 enable_if<false> 这个特化形式中,压根就没有这个 ::type,于是替换就失败了。和我们之前的例子中一样,这个函数原型就不会被产生出来。

所以我们能保证,无论对于 int 还是 counter 类型的实例,我们都只有一个函数原型通过了substitution —— 这样就保证了它的“有且唯一”,编译器也不会因为你某个替换失败而无视成功的那个实例。

这个例子说到了这里,熟悉C++的你,一定会站出来说我们只要把第一个签名改成:

1
void inc_counter(ICounter& counterObj);

就能完美解决这个问题了,根本不需要这么复杂的编译器机制。

嗯,你说的没错,在这里这个特性一点都没用。

这也提醒我们,当你觉得需要写 enable_if 的时候,首先要考虑到以下可能的替代方案:

  • 重载(适用于函数模板)

  • 偏特化(适用于类模板)

  • 虚函数

但是问题到了这里并没有结束。因为 increase 毕竟是个虚函数。假如 Counter 需要调用的地方实在是太多了,这个时候我们会非常期望 increase 不再是个虚函数以提高性能。此时我们会调整继承层级:

1
2
3
4
5
6
struct ICounter {};
struct Counter: public ICounter {
void increase() {
// impl
}
};

那么原有的 void inc_counter(ICounter& counterObj) 就无法再执行下去了。这个时候你可能会考虑一些变通的办法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
void inc_counter(ICounter& c) {};

template <typename T>
void inc_counter(T& c) { ++c; };

void doSomething() {
Counter cntObj;
uint32_t cntUI32;

// blah blah blah
inc_counter(cntObj); // 1
inc_counter(static_cast<ICounter&>(cntObj)); // 2
inc_counter(cntUI32); // 3
}

对于调用 1,因为 cntObjICounter 是需要类型转换的,所以比 void inc_counter(T&) [T = Counter] 要更差一些。然后它会直接实例化后者,结果实现变成了 ++cntObj,BOOM!

那么我们做 2 试试看?嗯,工作的很好。但是等等,我们的初衷是什么来着?不就是让 inc_counter 对不同的计数器类型透明吗?这不是又一夜回到解放前了?

所以这个时候,就能看到 enable_if 是如何通过 SFINAE 发挥威力的了:

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 <type_traits>
include <utility>
include <cstdint>

struct ICounter {};
struct Counter: public ICounter {
void increase() {
// impl
}
};

template <typename T> void inc_counter(
T& counterObj,
typename std::enable_if<
std::is_base_of<ICounter, T>::value
>::type* = nullptr ){
counterObj.increase();
}

template <typename T> void inc_counter(
T& counterInt,
typename std::enable_if<
std::is_integral<T>::value
>::type* = nullptr ){
++counterInt;
}

void doSomething() {
Counter cntObj;
uint32_t cntUI32;

// blah blah blah
inc_counter(cntObj); // OK!
inc_counter(cntUI32); // OK!
}

这个代码是不是看起来有点脏脏的。眼尖的你定睛一瞧,咦, ICounter 不是已经空了吗,为什么我们还要用它作为基类呢?

这是个好问题。在本例中,我们用它来区分一个counter是不是继承自ICounter。最终目的,是希望知道 counter 有没有 increase 这个函数。

所以 ICounter 只是相当于一个标签。而于情于理这个标签都是个累赘。但是在C++11之前,我们并没有办法去写类似于:

1
template <typename T> void foo(T& c, decltype(c.increase())* = nullptr);

这样的函数签名,因为假如 Tint,那么 c.increase() 这个函数调用就不存在。但它又不属于Type Failure,而是一个Expression Failure,在C++11之前它会直接导致编译器出错,这并不是我们所期望的。所以我们才退而求其次,用一个类似于标签的形式来提供我们所需要的类型信息。以后的章节,后面我们会说到,这种和类型有关的信息我们可以称之为 type traits

到了C++11,它正式提供了 Expression SFINAE,这时我们就能抛开 ICounter 这个无用的Tag,直接写出我们要写的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Counter {
void increase() {
// Implements
}
};

template <typename T>
void inc_counter(T& intTypeCounter, std::decay_t<decltype(++intTypeCounter)>* = nullptr) {
++intTypeCounter;
}

template <typename T>
void inc_counter(T& counterObj, std::decay_t<decltype(counterObj.increase())>* = nullptr) {
counterObj.increase();
}

void doSomething() {
Counter cntObj;
uint32_t cntUI32;

// blah blah blah
inc_counter(cntObj);
inc_counter(cntUI32);
}

此外,还有一种情况只能使用 SFINAE,而无法使用包括继承、重载在内的任何方法,这就是Universal Reference。比如,

1
2
// 这里的a是个通用引用,可以准确的处理左右值引用的问题。
template <typename ArgT> void foo(ArgT&& a);

假如我们要限定ArgT只能是 float 的衍生类型,那么写成下面这个样子是不对的,它实际上只能接受 float 的右值引用。

1
void foo(float&& a);

此时的唯一选择,就是使用Universal Reference,并增加 enable_if 限定类型,如下面这样:

1
2
3
4
5
6
7
template <typename ArgT>
void foo(
ArgT&& a,
typename std::enabled_if<
std::is_same<std::decay_t<ArgT>, float>::value
>::type* = nullptr
);

从上面这些例子可以看到,SFINAE最主要的作用,是保证编译器在泛型函数、偏特化、及一般重载函数中遴选函数原型的候选列表时不被打断。除此之外,它还有一个很重要的元编程作用就是实现部分的编译期自省和反射。

虽然它写起来并不直观,但是对于既没有编译器自省、也没有Concept的C++11来说,已经是最好的选择了。

4.3. Concept “概念”:对模板参数约束的直接描述

4.3.1. “概念” 解决了什么问题

从上一节可以看出,我们兜兜转转了那么久,就是为了解决两个问题:

  1. 在模板进行特化的时候,盘算一下并告诉编译器这里能不能特化;

  2. 在函数决议面临多个候选的时候,如果有且仅有其中一个原型能够被函数决议接纳,那就决定是你了!

如果语言能允许用户直接描述需求并传达给编译器,不就不用这么麻烦了么。其实在很多现代语言中,都有类似的语言要素存在,比如C的约束(constraint on type parameters):

1
2
3
4
5
6
7
public class Employee {
// ...
}

public class GenericList<T> where T : Employee {
// ...
}

上例就非常清晰的呈现了我们对GenericListT的要求是:它得是一个EmployeeEmployee的子类。

这种“清晰的”类型约束,在C++中称作概念(Concept)。最早有迹可循的概念相关工作应当从2003年后就开始了。2006年Bjarne在POPL 06上的一篇报告“Specifying C++ concepts”算是“近代”Concept工作的首次公开亮相。委员会为Concept筹划数年,在2008年提出了第一版Concepts提案,试图进入C++0x的标准中。这也是Concept第一次在C++社群当中被广泛“炒作”。不过2009年的会议,让“近代”Concept在N2617草案戛然而止。

2013年之后,Concept改头换面为Concept Lite提案(N3701)卷土重来,历经多方博弈和多轮演化,最终形成了我们在C++20里看到的Concept。有关于Concept的方法论和比较,B.S. 在白皮书中有过比较详细的交代。

总之,在concept进入标准之后,模板特化的类型约束写起来就方便与直接多了。而且这些约束之间还可以像表达式一样复用和组合。虽然因为C++类型系统自身的琐碎导致基础库中的concept仍然相当的冗长,但是比起之前起码具备了可用性。

比如我们拿上一节中最后一个例子作为对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
// SFINAE
template <typename ArgT>
void foo(
ArgT&& a,
typename std::enabled_if<
std::is_same<std::decay_t<ArgT>, float>::value
>::type* = nullptr
);
// Concept
template <typename ArgT>
requires std::same_as<std::remove_cvref<T>, float>
void foo(ArgT&& a) {
}

可以看到,concept之后的表达式消除了语法噪音,显得更为简洁一些。而对于之前++的例子,concept下则更为扼要:

1
2
3
4
5
template <typename T> concept Incrementable = requires (T t) { ++t; }
template <Incrementable T>
void inc_counter(T& intTypeCounter) {
++intTypeCounter;
}

直接告诉编译器,我们对T的要求是你得有++

当然有人会问,那能不能直接写成以下形式,不是更简单吗?

1
2
template <typename T> requires (T t) { ++t; }
void inc_counter(T& cnt);

答案是:不能。
因为requires作为关键字/保留字是存在二义性的。当它用于函数模板或者类模板的声明时,它是一个constraint,后面需要跟着concept表达式;而用于concept中,则是一个required expression,用于concept的求解。既然constraint后面跟着一个concept表达式,而requires也可以用来定义一个concept expression,那么一个风骚的想法形成了:我能不能用 requires (requires (T t) {++t;}) 来约束模板参数呢?

当然是可以的!C++就是这么的简()单()!

1
2
template <typename T> requires (requires (T t) { ++t; })
void inc_counter(T& cnt);

总而言之,除了这些烦人的问题,“概念”的出现,使得模板的出错提示也清爽了些许 —— 虽然大佬们都在鼓吹concept让模板出错多么好调试,但是实际上模板出错,有一半是来源自类型系统本质上的复杂性,概念并不能解决这一问题。

比如这里使用SFINAE的提示:

1
2
3
4
5
6
<source>:23:5: error: no matching function for call to 'Inc'
Inc(y);
^~~
<source>:5:6: note: candidate template ignored: substitution failure [with T = X]: cannot increment value of type 'X'
void Inc(T& v, std::decay_t<decltype(++v)>* = nullptr)
^ ~~

而这里是使用了concept的提示。

1
2
3
4
5
6
7
8
9
10
11
<source>:25:5: error: no matching function for call to 'Inc_Concept'
Inc_Concept(y);
^~~~~~~~~~~
<source>:13:6: note: candidate template ignored: constraints not satisfied [with T = X]
void Inc_Concept(T& v)
^
<source>:12:11: note: because 'X' does not satisfy 'Incrementable'
template <Incrementable T>
^
<source>:10:41: note: because '++t' would be invalid: cannot increment value of type 'X'
concept Incrementable = requires(T t) { ++t; };

虽然在这个例子中,通过 Concept 获得出错提示看起来要比使用 SFINAE 所获得的错误描述要更长一点,但是对于更加复杂类型来说,则会友善许多。以后会找个例子给大家陈述。

4.3.2. “概念”入门

第二部分-语言可用性的强化

语言可用性的强化

常量

nullptr

nullptr 出现的目的是为了替代 NULL。在某种意义上来说,传统 C++ 会把 NULL、0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0。

constexpr

C++ 本身已经具备了常量表达式的概念,比如 1+2, 3*4 这种表达式总是会产生相同的结果并且没有任何副作用。如果编译器能够在编译时就把这些表达式直接优化并植入到程序运行时,将能增加程序的性能。

C++11 提供了 constexpr 让用户显式的声明函数或对象构造函数在编译期会成为常量表达式,这个关键字明确的告诉编译器应该去验证该函数在编译期就应该是一个常量表达式。

此外,constexpr 修饰的函数可以使用递归。
从 C++14 开始,constexpr 函数可以在内部使用局部变量、循环和分支等简单语句,

变量及其初始化

###if/switch变量声明强化
在传统 C++ 中,变量的声明虽然能够位于任何位置,甚至于 for 语句内能够声明一个临时变量 int,但始终没有办法在 if 和 switch 语句中声明一个临时的变量。
C++17 消除了这一限制,使得我们可以在 if(或 switch)中完成这一操作:

1
2
3
4
5
// 将临时变量放到 if 语句内
if (const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 3);
itr != vec.end()) {
*itr = 4;
}

初始化列表

在传统 C++ 中,不同的对象有着不同的初始化方法,例如普通数组、 POD (Plain Old Data,即没有构造、析构和虚函数的类或结构体) 类型都可以使用 {} 进行初始化,也就是我们所说的初始化列表。 而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 () 进行。 这些不同方法都针对各自对象,不能通用。

为解决这个问题,C++11 首先把初始化列表的概念绑定到类型上,称其为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁,例如:

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

class MagicFoo {
public:
std::vector<int> vec;
MagicFoo(std::initializer_list<int> list) {
for (std::initializer_list<int>::iterator it = list.begin();
it != list.end(); ++it)
vec.push_back(*it);
}
};
int main() {
// after C++11
MagicFoo magicFoo = {1, 2, 3, 4, 5};
MagicFoo magicFoo {1, 2, 3, 4, 5};

std::cout << "magicFoo: ";
for (std::vector<int>::iterator it = magicFoo.vec.begin();
it != magicFoo.vec.end(); ++it)
std::cout << *it << std::endl;
}

这种构造函数被叫做初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊关照。

初始化列表除了用在对象构造上,还能将其作为普通函数的形参,例如:

1
2
3
4
5
6
7
public:
void foo(std::initializer_list<int> list) {
for (std::initializer_list<int>::iterator it = list.begin();
it != list.end(); ++it) vec.push_back(*it);
}

magicFoo.foo({6,7,8,9});

结构化绑定

结构化绑定提供了类似其他语言中提供的多返回值的功能。在容器一章中,我们会学到 C++11 新增了 std::tuple 容器用于构造一个元组,进而囊括多个返回值。但缺陷是,C++11/14 并没有提供一种简单的方法直接从元组中拿到并定义元组中的元素,尽管我们可以使用 std::tie 对元组进行拆包,但我们依然必须非常清楚这个元组包含多少个对象,各个对象是什么类型,非常麻烦。

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
#include <iostream>
#include <tuple>

std::tuple<bool, int,int> GetUserAge(const std::string& user_name)
{
std::tuple<bool, int,int> result;

if (user_name.compare("xiaoli") == 0)
{
result = std::make_tuple(true, 18,0);
}
else
{
result = std::make_tuple(false, -1,-1);
}

return result;
}

int main()
{
std::tuple<bool, int,int> result = GetUserAge("xiaolili");

bool work_status;
int age;
int user_id;

std::tie(work_status, age, user_id) = result;
std::cout << "查询结果:" << work_status << " " << "年龄:" << age <<" "<<"用户id:"<<user_id <<std::endl;
getchar();
return 0;
}

C++17 完善了这一设定,给出的结构化绑定可以让我们写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <tuple>

std::tuple<int, double, std::string> f() {
return std::make_tuple(1, 2.3, "456");
}

int main() {
auto [x, y, z] = f();
std::cout << x << ", " << y << ", " << z << std::endl;
return 0;
}

类型推导

C++11 引入了 auto 和 decltype 这两个关键字实现了类型推导,让编译器来操心变量的类型。这使得 C++ 也具有了和其他现代编程语言一样,某种意义上提供了无需操心变量类型的使用习惯。

auto

使用 auto 进行类型推导的一个最为常见而且显著的例子就是迭代器。你应该在前面的小节里看到了传统 C++ 中冗长的迭代写法:

1
2
3
4
5
6
7
// 在 C++11 之前
// 由于 cbegin() 将返回 vector<int>::const_iterator
// 所以 it 也应该是 vector<int>::const_iterator 类型
for(vector<int>::const_iterator it = vec.cbegin(); it != vec.cend(); ++it)

//C++11之后
for (auto it = list.begin(); it != list.end(); ++it)

注意:auto 还不能用于推导数组类型:

1
2
3
4
auto auto_arr2[10] = {arr}; // 错误, 无法推导数组元素类型

2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto'
auto auto_arr2[10] = {arr};

decltype

decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 typeof 很相似:decltype(表达式)

1
2
3
4
5
6
7
8
9
auto x = 1;
auto y = 2;
decltype(x+y) z;
if (std::is_same<decltype(x), int>::value)
std::cout << "type x == int" << std::endl;
if (std::is_same<decltype(x), float>::value)
std::cout << "type x == float" << std::endl;
if (std::is_same<decltype(x), decltype(z)>::value)
std::cout << "type z == type x" << std::endl;

其中,std::is_same<T, U> 用于判断 T 和 U 这两个类型是否相等。输出结果为:

1
2
type x == int
type z == type x

尾返回类型推导

在传统 C++ 中我们必须这么写:

1
2
3
4
template<typename R, typename T, typename U>
R add(T x, U y) {
return x+y;
}

这样的代码其实变得很丑陋,因为程序员在使用这个模板函数的时候,必须明确指出返回类型。但事实上我们并不知道 add() 这个函数会做什么样的操作,以及获得一个什么样的返回类型。

1
decltype(x+y) add(T x, U y)

但事实上这样的写法并不能通过编译。这是因为在编译器读到 decltype(x+y) 时,x 和 y 尚未被定义。为了解决这个问题,C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto 关键字将返回类型后置:

1
2
3
4
template<typename T, typename U>
auto add2(T x, U y) -> decltype(x+y){
return x + y;
}

从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:

1
2
3
4
template<typename T, typename U>
auto add3(T x, U y){
return x + y;
}

decltype(auto)

decltype(auto) 是 C++14 开始提供的一个略微复杂的用法。
简单来说,decltype(auto) 主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype 的参数表达式。考虑看下面的例子,当我们需要对下面两个函数进行封装时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::string  lookup1();
std::string& lookup2();
//封装实现
std::string look_up_a_string_1() {
return lookup1();
}
std::string& look_up_a_string_2() {
return lookup2();
}
//C++14以后
decltype(auto) look_up_a_string_1() {
return lookup1();
}
decltype(auto) look_up_a_string_2() {
return lookup2();
}

控制流

if constexpr

我们知道了 C++11 引入了 constexpr 关键字,它将表达式或函数编译为常量结果。一个很自然的想法是,如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr 这个关键字引入到 if 语句中,允许在代码中声明常量表达式的判断条件,考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

template<typename T>
auto print_type_info(const T& t) {
if constexpr (std::is_integral<T>::value) {
return t + 1;
} else {
return t + 0.001;
}
}
int main() {
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}

在编译时,实际代码就会表现为如下:

1
2
3
4
5
6
7
8
9
10
int print_type_info(const int& t) {
return t + 1;
}
double print_type_info(const double& t) {
return t + 0.001;
}
int main() {
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}

区间for迭代

终于,C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句,我们可以进一步简化前面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
std::vector<int> vec = {1, 2, 3, 4};
if (auto itr = std::find(vec.begin(), vec.end(), 3); itr != vec.end()) *itr = 4;
for (auto element : vec)
std::cout << element << std::endl; // read only
for (auto &element : vec) {
element += 1; // writeable
}
for (auto element : vec)
std::cout << element << std::endl; // read only
}

第四部分-语言运行期的强化

语言运行期的强化

1.Lambda表达式

基本语法

捕获列表 mutable(可选) 异常属性 -> 返回类型 {
// 函数体
}

捕获

捕获提供了 Lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:
[] 空捕获列表
[name1, name2, …] 捕获一系列变量
[&] 引用捕获, 让编译器自行推导引用列表
[=] 值捕获, 让编译器自行推导值捕获列表

表达式捕获
上面提到的值捕获、引用捕获都是已经在外层作用域声明的变量,因此这些捕获方式捕获的均为左值,而不能捕获右值。

C++14 给与了我们方便,允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获, 被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto 本质上是相同的:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <memory> // std::make_unique
#include <utility> // std::move

void lambda_expression_capture() {
auto important = std::make_unique<int>(1);
auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
return x+y+v1+(*v2);
};
std::cout << add(3,4) << std::endl;
}

在上面的代码中,important 是一个独占指针,是不能够被 “=” 值捕获到,这时候我们可以将其转移为右值,在表达式中初始化。

泛型Lambda

1
2
3
4
5
6
auto add = [](auto x, auto y) {
return x+y;
};

add(1, 2);
add(1.1, 2.2);

2.函数对象包装器

std::function

Lambda 表达式的本质是一个和函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象), 当 Lambda 表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

using foo = void(int); // 定义函数类型, using 的使用见上一节中的别名语法
void functional(foo f) { // 参数列表中定义的函数类型 foo 被视为退化后的函数指针类型 foo*
f(1); // 通过函数指针调用函数
}

int main() {
auto f = [](int value) {
std::cout << value << std::endl;
};
functional(f); // 传递闭包对象,隐式转换为 foo* 类型的函数指针值
f(1); // lambda 表达式调用
return 0;
}

上面的代码给出了两种不同的调用形式,一种是将 Lambda 作为函数类型传递进行调用, 而另一种则是直接调用 Lambda 表达式,在 C++11 中,统一了这些概念,将能够被调用的对象的类型, 统一称之为可调用类型。而这种类型,便是通过 std::function 引入的。

C++11 std::function 是一种通用、多态的函数封装, 它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作, 它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的), 换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。 例如:

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

int foo(int para) {
return para;
}

int main() {
// std::function 包装了一个返回值为 int, 参数为 int 的函数
std::function<int(int)> func = foo;

int important = 10;
std::function<int(int)> func2 = [&](int value) -> int {
return 1+value+important;
};
std::cout << func(10) << std::endl;
std::cout << func2(10) << std::endl;
}

std::bind 和 std::placeholder

std::bind 则是用来绑定函数调用的参数的, 它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数, 我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。 例如:

1
2
3
4
5
6
7
8
9
10
int foo(int a, int b, int c) {
;
}
int main() {
// 将参数1,2绑定到函数 foo 上,
// 但使用 std::placeholders::_1 来对第一个参数进行占位
auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
// 这时调用 bindFoo 时,只需要提供第一个参数即可
bindFoo(1);
}

3.右值引用

左值、右值、将亡值、纯右值

左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说, 左值是表达式(不一定是赋值表达式)后依然存在的持久对象。
右值 (rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。

而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true; 要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、 原始字面量、Lambda 表达式都属于纯右值。
需要注意的是,字面量除了字符串字面量以外,均为纯右值。而字符串字面量是一个左值,类型为 const char 数组

将亡值 (xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++ 中, 纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。

右值引用和左值引用

要拿到一个将亡值,就需要用到右值引用:T &&,其中 T 是类型。 右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。

C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值, 有了它我们就能够方便的获得一个右值临时对象,例如:

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>
#include <string>

void reference(std::string& str) {
std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {
std::cout << "右值" << std::endl;
}

int main()
{
std::string lv1 = "string,"; // lv1 是一个左值
// std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
std::string&& rv1 = std::move(lv1); // 合法, std::move可以将左值转移为右值
std::cout << rv1 << std::endl; // string,

const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
// lv2 += "Test"; // 非法, 常量引用无法被修改
std::cout << lv2 << std::endl; // string,string,

std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
std::cout << rv2 << std::endl; // string,string,string,Test

reference(rv2); // 输出左值

return 0;
}

rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。

移动语义

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作, 调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。 试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、 再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

传统的 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
26
27
28
29
30
31
32
#include <iostream>
class A {
public:
int *pointer;
A():pointer(new int(1)) {
std::cout << "构造" << pointer << std::endl;
}
A(A& a):pointer(new int(*a.pointer)) {
std::cout << "拷贝" << pointer << std::endl;
} // 无意义的对象拷贝
A(A&& a):pointer(a.pointer) {
a.pointer = nullptr;
std::cout << "移动" << pointer << std::endl;
}
~A(){
std::cout << "析构" << pointer << std::endl;
delete pointer;
}
};
// 防止编译器优化
A return_rvalue(bool test) {
A a,b;
if(test) return a; // 等价于 static_cast<A&&>(a);
else return b; // 等价于 static_cast<A&&>(b);
}
int main() {
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;
return 0;
}

在上面的代码中:

首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。
从而避免了无意义的拷贝构造,加强了性能。再来看看涉及标准库的例子:

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 <vector> // std::vector
#include <string> // std::string

int main() {

std::string str = "Hello world.";
std::vector<std::string> v;

// 将使用 push_back(const T&), 即产生拷贝行为
v.push_back(str);
// 将输出 "str: Hello world."
std::cout << "str: " << str << std::endl;

// 将使用 push_back(const T&&), 不会出现拷贝行为
// 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
// 这步操作后, str 中的值会变为空
v.push_back(std::move(str));
// 将输出 "str: "
std::cout << "str: " << str << std::endl;

return 0;
}

完美转发

一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v); // 始终调用 reference(int&)
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1是右值, 但输出是左值

std::cout << "传递左值:" << std::endl;
int l = 1;
pass(l); // l 是左值, 输出左值

return 0;
}

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。 因此 reference(v) 会调用 reference(int&),输出『左值』。

引用坍缩规则:在传统 C++ 中,我们不能够对一个引用类型继续进行引用, 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用, 既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型 实参参数类型 推导后函数形参类型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&
只有函数形参为右引用,实参也为右引用时才能被推导成右引用类型

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候, 保持原来的参数类型(左引用保持左引用,右引用保持右引用)。 为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递)

std::forward 和 std::move 一样,没有做任何事情,std::move 单纯的将左值转化为右值, std::forward 也只是单纯的将参数做了一个类型的转换,从现象上来看, std::forward(v) 和 static_cast<T&&>(v) 是完全一样的。

因此:在使用循环语句的过程中,auto&& 是最安全的方式

第三部分-继续学习现代C++

继续学习现代C++

模板

C++ 的模板一直是这门语言的一种特殊的艺术,模板甚至可以独立作为一门新的语言来进行使用。模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。因此模板也被很多人视作 C++ 的黑魔法之一。

外部模板

传统 C++ 中,模板只有在使用时才会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。
为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:

1
2
template class std::vector<bool>;          // 强行实例化
extern template class std::vector<double>; // 不在该当前编译文件中实例化模板

“>”

在传统 C++ 的编译器中,>>一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:

1
std::vector<std::vector<int>> matrix;

这在传统 C++ 编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译

类型别名模板

在传统 C++ 中,typedef 可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。
C++11 使用 using 引入了下面这种形式的写法,并且同时支持对传统 typedef 相同的功效:

1
2
3
4
5
6
7
8
typedef int (*process)(void *);
using NewProcess = int(*)(void *);
template<typename T>
using TrueDarkMagic = MagicType<std::vector<T>, std::string>;

int main() {
TrueDarkMagic<bool> you;
}

变长参数模板

模板一直是 C++ 所独有的黑魔法(一起念:Dark Magic)之一。 在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子, 接受一组固定数量的模板参数;而 C++11 加入了新的表示方法, 允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。

1
2
3
4
5
6
7
template<typename... Ts> class Magic;
class Magic<int,
std::vector<int>,
std::map<std::string,
std::vector<int>>> darkMagic;
class Magic<> nothing;
template<typename Require, typename... Args> class Magic;

除了在模板参数中能使用 … 表示不定长模板参数外, 函数参数也使用同样的表示法代表不定长参数, 这也就为我们简单编写变长参数函数提供了便捷的手段,例如:

1
2
3
4
5
6
template<typename... Args> void printf(const std::string &str, Args... args);

template<typename... Ts>
void magic(Ts... args) {
std::cout << sizeof...(args) << std::endl;
}

其次,对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法:

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
//递归
#include <iostream>
template<typename T0>
void printf1(T0 value) {
std::cout << value << std::endl;
}
template<typename T, typename... Ts>
void printf1(T value, Ts... args) {
std::cout << value << std::endl;
printf1(args...);
}
int main() {
printf1(1, 2, "123", 1.1);
return 0;
}

//变参模板展开
template<typename T0, typename... T>
void printf2(T0 t0, T... t) {
std::cout << t0 << std::endl;
if constexpr (sizeof...(t) > 0) printf2(t...);
}

//初始化列表展开
template<typename T, typename... Ts>
auto printf3(T value, Ts... args) {
std::cout << value << std::endl;
(void) std::initializer_list<T>{([&args] {
std::cout << args << std::endl;
}(), value)...};
}

折叠表达式

1
2
3
4
5
6
7
8
#include <iostream>
template<typename ... T>
auto sum(T ... t) {
return (t + ...);
}
int main() {
std::cout << sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) << std::endl;
}

非类型模板参数推导

1
2
3
4
5
6
7
8
9
10
template <typename T, int BufSize>
class buffer_t {
public:
T& alloc();
void free(T& item);
private:
T data[BufSize];
}

buffer_t<int, 100> buf; // 100 作为模板参数

C++17 引入了这一特性,我们的确可以 auto 关键字,让编译器辅助完成具体类型的推导, 例如:

1
2
3
4
5
6
7
8
template <auto value> void foo() {
std::cout << value << std::endl;
return;
}

int main() {
foo<10>(); // value 被推导为 int 类型
}

面向对象

委托构造

C++11 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
value2 = value;
}
};

int main() {
Base b(2);
std::cout << b.value1 << std::endl;
std::cout << b.value2 << std::endl;
}

继承构造

在传统 C++ 中,构造函数如果需要继承是需要将参数一一传递的,这将导致效率低下。C++11 利用关键字 using 引入了继承构造函数的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
value2 = value;
}
};
class Subclass : public Base {
public:
using Base::Base; // 继承构造
};
int main() {
Subclass s(3);
std::cout << s.value1 << std::endl;
std::cout << s.value2 << std::endl;
}

显式虚函数重载

1
2
3
4
5
6
struct Base {
virtual void foo();
};
struct SubClass: Base {
void foo();
};

SubClass::foo 可能并不是程序员尝试重载虚函数,只是恰好加入了一个具有相同名字的函数。另一个可能的情形是,当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚拟函数并摇身一变成为了一个普通的类方法,这将造成灾难性的后果。
C++11 引入了 override 和 final 这两个关键字来防止上述情形的发生。

当重载虚函数时,引入 override 关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的其函数签名一致的虚函数,否则将无法通过编译:

1
2
3
4
5
6
7
struct Base {
virtual void foo(int);
};
struct SubClass: Base {
virtual void foo(int) override; // 合法
virtual void foo(float) override; // 非法, 父类没有此虚函数
};

final 则是为了防止类被继续继承以及终止虚函数继续重载引入的。

1
2
3
4
5
6
7
8
9
10
11
12
struct Base {
virtual void foo() final;
};
struct SubClass1 final: Base {
}; // 合法

struct SubClass2 : SubClass1 {
}; // 非法, SubClass1 已 final

struct SubClass3: Base {
void foo(); // 非法, foo 已 final
};

显式禁用默认函数

在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、 复制构造、赋值算符以及析构函数。 另外,C++ 也为所有类定义了诸如 new delete 这样的运算符。 当程序员有需要时,可以重载这部分函数。
这就引发了一些需求:无法精确控制默认函数的生成行为。 例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private。 尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式。
并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。 若用户定义了任何构造函数,编译器将不再生成默认构造函数, 但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬。
C++11允许显式的声明采用或拒绝编译器自带的函数。 例如:

1
2
3
4
5
6
class Magic {
public:
Magic() = default; // 显式声明使用编译器生成的构造
Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
Magic(int magic_number);
}

强类型枚举

在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同,这通常不是我们希望看到的结果。
C++11 引入了枚举类(enumeration class),并使用 enum class 的语法进行声明:

1
2
3
4
5
6
enum class new_enum : unsigned int {
value1,
value2,
value3 = 100,
value4 = 100
};

而我们希望获得枚举值的值时,将必须显式的进行类型转换,不过我们可以通过重载 << 这个算符来进行输出.

1
2
3
4
5
6
7
8
9
10
#include <iostream>
template<typename T>
std::ostream& operator<<(
typename std::enable_if<std::is_enum<T>::value,
std::ostream>::type& stream, const T& e)
{
return stream << static_cast<typename std::underlying_type<T>::type>(e);
}

std::cout << new_enum::value3 << std::endl

第五部分-容器

容器

1.线性容器

std::array

1.为什么要引入 std::array 而不是直接使用 std::vector?
与 std::vector 不同,std::array 对象的大小是固定的,如果容器大小是固定的,那么可以优先考虑使用 std::array 容器。 另外由于 std::vector 是自动扩容的,当存入大量的数据后,并且对容器进行了删除操作, 容器并不会自动归还被删除元素相应的内存,这时候就需要手动运行 shrink_to_fit() 释放这部分内存。

2.已经有了传统数组,为什么要用 std::array?
使用 std::array 能够让代码变得更加“现代化”,而且封装了一些操作函数,比如获取数组大小以及检查是否非空,同时还能够友好的使用标准库中的容器算法,比如 std::sort。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 非法,不同于 C 风格数组,std::array 不会自动退化成 T*
// int *arr_p = arr;

void foo(int *p, int len) {
return;
}

std::array<int, 4> arr = {1,2,3,4};

// C 风格接口传参
// foo(arr, arr.size()); // 非法, 无法隐式转换
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());

std::forward_list

std::forward_list 是一个列表容器,使用方法和 std::list 基本类似,因此我们就不花费篇幅进行介绍了。

需要知道的是,和 std::list 的双向链表的实现不同,std::forward_list 使用单向链表进行实现, 提供了 O(1) 复杂度的元素插入,不支持快速随机访问(这也是链表的特点), 也是标准库容器中唯一一个不提供 size() 方法的容器。当不需要双向迭代时,具有比 std::list 更高的空间利用率。

无序容器

我们已经熟知了传统 C++ 中的有序容器 std::map/std::set,这些元素内部通过红黑树进行实现, 插入和搜索的平均复杂度均为 O(log(size))。在插入元素时候,会根据 < 操作符比较元素大小并判断元素是否相同, 并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 < 操作符的顺序来逐个遍历。

无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant), 在不关心容器内部元素顺序时,能够获得显著的性能提升。

C++11 引入了的两组无序容器分别是:std::unordered_map/std::unordered_multimap 和 std::unordered_set/std::unordered_multiset。

它们的用法和原有的 std::map/std::multimap/std::set/set::multiset 基本类似

元组

纵观传统 C++ 中的容器,除了 std::pair 外, 似乎没有现成的结构能够用来存放不同类型的数据(通常我们会自己定义结构)。 但 std::pair 的缺陷是显而易见的,只能保存两个元素。

元组基本操作

关于元组的使用有三个核心的函数:
std::make_tuple: 构造元组
std::get: 获得元组某个位置的值
std::tie: 元组拆包

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
#include <tuple>
#include <iostream>

auto get_student(int id)
{
// 返回类型被推断为 std::tuple<double, char, std::string>

if (id == 0)
return std::make_tuple(3.8, 'A', "张三");
if (id == 1)
return std::make_tuple(2.9, 'C', "李四");
if (id == 2)
return std::make_tuple(1.7, 'D', "王五");
return std::make_tuple(0.0, 'D', "null");
// 如果只写 0 会出现推断错误, 编译失败
}

int main()
{
auto student = get_student(0);
std::cout << "ID: 0, "
<< "GPA: " << std::get<0>(student) << ", "
<< "成绩: " << std::get<1>(student) << ", "
<< "姓名: " << std::get<2>(student) << '\n';

double gpa;
char grade;
std::string name;

// 元组进行拆包
std::tie(gpa, grade, name) = get_student(1);
std::cout << "ID: 1, "
<< "GPA: " << gpa << ", "
<< "成绩: " << grade << ", "
<< "姓名: " << name << '\n';
}

std::get 除了使用常量获取元组对象外,C++14 增加了使用类型来获取元组中的对象:

1
2
3
4
std::tuple<std::string, double, double, int> t("123", 4.5, 6.7, 8);
std::cout << std::get<std::string>(t) << std::endl;
std::cout << std::get<double>(t) << std::endl; // 非法, 引发编译期错误
std::cout << std::get<3>(t) << std::endl;

运行期索引

std::get<> 依赖一个编译期的常量,所以下面的方式是不合法的:

1
2
int index = 1;
std::get<index>(t);

使用 std::variant<>(C++ 17 引入),提供给 variant<> 的类型模板参数 可以让一个 variant<> 从而容纳提供的几种类型的变量(在其他语言,例如 Python/JavaScript 等,表现为动态类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <variant>
template <size_t n, typename... T>
constexpr std::variant<T...> _tuple_index(const std::tuple<T...>& tpl, size_t i) {
if constexpr (n >= sizeof...(T))
throw std::out_of_range("越界.");
if (i == n)
return std::variant<T...>{ std::in_place_index<n>, std::get<n>(tpl) };
return _tuple_index<(n < sizeof...(T)-1 ? n+1 : 0)>(tpl, i);
}
template <typename... T>
constexpr std::variant<T...> tuple_index(const std::tuple<T...>& tpl, size_t i) {
return _tuple_index<0>(tpl, i);
}
template <typename T0, typename ... Ts>
std::ostream & operator<< (std::ostream & s, std::variant<T0, Ts...> const & v) {
std::visit([&](auto && x){ s << x;}, v);
return s;
}
1
2
3
int i = 1;
std::cout << tuple_index(t, i) << std::endl;
//tuple_index(t,i) -> tuple_index(t,1) -> tuple_index<0>(t,1) -> tuple_index<1>(t,1) -> {std::in_place_index<1>,std::get<1>(t)}

元组合并和遍历

合并两个元组,这可以通过 std::tuple_cat 来实现:

1
auto new_tuple = std::tuple_cat(get_student(1), std::move(t));

遍历一个元组

1
2
3
4
5
6
7
8
template <typename T>
auto tuple_len(T &tpl) {
return std::tuple_size<T>::value;
}
// 迭代
for(int i = 0; i != tuple_len(new_tuple); ++i)
// 运行期索引
std::cout << tuple_index(new_tuple, i) << std::endl;

第六部分-智能指针与内存管理

智能指针与内存管理

1.RAII与引用计数

引用计数这种计数是为了防止内存泄露而产生的。 基本想法是对于动态分配的对象,进行引用计数,每当增加一次对同一个对象的引用,那么引用对象的引用计数就会增加一次, 每删除一次引用,引用计数就会减一,当一个对象的引用计数减为零时,就自动删除指向的堆内存。

所以通常的做法是对于一个对象而言,我们在构造函数的时候申请空间,而在析构函数(在离开作用域时调用)的时候释放空间, 也就是我们常说的 RAII 资源获取即初始化技术。

在传统 C++ 里我们只好使用 new 和 delete 去 『记得』对资源进行释放。而 C++11 引入了智能指针的概念,使用了引用计数的想法,让程序员不再需要关心手动释放内存。 这些智能指针包括 std::shared_ptr/std::unique_ptr/std::weak_ptr,使用它们需要包含头文件

std::shared_ptr

std::shared_ptr 是一种智能指针,它能够记录多少个 shared_ptr 共同指向一个对象,从而消除显式的调用 delete,当引用计数变为零的时候就会将对象自动删除。

但还不够,因为使用 std::shared_ptr 仍然需要使用 new 来调用,这使得代码出现了某种程度上的不对称。

std::make_shared 就能够用来消除显式的使用 new,所以std::make_shared 会分配创建传入参数中的对象, 并返回这个对象类型的std::shared_ptr指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <memory>
void foo(std::shared_ptr<int> i) {
(*i)++;
}
int main() {
// auto pointer = new int(10); // illegal, no direct assignment
// Constructed a std::shared_ptr
auto pointer = std::make_shared<int>(10);
foo(pointer);
std::cout << *pointer << std::endl; // 11
// The shared_ptr will be destructed before leaving the scope
return 0;
}

std::shared_ptr 可以通过 get() 方法来获取原始指针,通过 reset() 来减少一个引用计数, 并通过use_count()来查看一个对象的引用计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
auto pointer = std::make_shared<int>(10);
auto pointer2 = pointer; // 引用计数+1
auto pointer3 = pointer; // 引用计数+1
int *p = pointer.get(); // 这样不会增加引用计数
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 3
std::cout << "pointer2.use_count() = " << pointer2.use_count() << std::endl; // 3
std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 3

pointer2.reset();
std::cout << "reset pointer2:" << std::endl;
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 2
std::cout << "pointer2.use_count() = "
<< pointer2.use_count() << std::endl; // pointer2 已 reset; 0
std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 2
pointer3.reset();
std::cout << "reset pointer3:" << std::endl;
std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 1
std::cout << "pointer2.use_count() = " << pointer2.use_count() << std::endl; // 0
std::cout << "pointer3.use_count() = "
<< pointer3.use_count() << std::endl; // pointer3 已 reset; 0

std::unique_ptr

std::unique_ptr 是一种独占的智能指针,它禁止其他智能指针与其共享同一个对象,从而保证代码的安全:

1
2
std::unique_ptr<int> pointer = std::make_unique<int>(10); // make_unique 从 C++14 引入
std::unique_ptr<int> pointer2 = pointer; // 非法

既然是独占,换句话说就是不可复制。但是,我们可以利用 std::move 将其转移给其他的 unique_ptr

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>
#include <memory>

struct Foo {
Foo() { std::cout << "Foo::Foo" << std::endl; }
~Foo() { std::cout << "Foo::~Foo" << std::endl; }
void foo() { std::cout << "Foo::foo" << std::endl; }
};

void f(const Foo &) {
std::cout << "f(const Foo&)" << std::endl;
}

int main() {
std::unique_ptr<Foo> p1(std::make_unique<Foo>());
// p1 不空, 输出
if (p1) p1->foo();
{
std::unique_ptr<Foo> p2(std::move(p1));
// p2 不空, 输出
f(*p2);
// p2 不空, 输出
if(p2) p2->foo();
// p1 为空, 无输出
if(p1) p1->foo();
p1 = std::move(p2);
// p2 为空, 无输出
if(p2) p2->foo();
std::cout << "p2 被销毁" << std::endl;
}
// p1 不空, 输出
if (p1) p1->foo();
// Foo 的实例会在离开作用域时被销毁
}

std::weak_ptr

std::weak_ptr 是一种智能指针,通常不单独使用,只能和 shared_ptr 类型指针搭配使用,可以视为 shared_ptr 指针的一种辅助工具。借助 weak_ptr 类型指针可以获取 shared_ptr 指针的一些状态信息,比如有多少指向相同的 shared_ptr 指针、通过expired()判断shared_ptr 指针指向的堆内存是否已经被释放等等,还可以解决shared_ptr 循环引用的问题。

weak_ptr可以从一个shared_ptr或者另一个weak_ptr对象构造,获得资源的观测权。但weak_ptr没有共享资源,它的构造不会引起指针引用计数的增加。使用weak_ptr的成员函数use_count()可以观测资源的引用计数,另一个成员函数expired()的功能等价于use_count()==0,但更快。表示被观测的资源(也就是shared_ptr的管理的资源)已经不复存在。

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>
#include <memory>

int main() {
{
std::shared_ptr<int> sh_ptr = std::make_shared<int>(10);
std::cout << sh_ptr.use_count() << std::endl; // 输出1

std::weak_ptr<int> wp(sh_ptr);
std::cout << wp.use_count() << std::endl; // 赋值给weak_ptr后还是输出1

if(!wp.expired()){ // 检查sh_ptr是否还有效
std::shared_ptr<int> sh_ptr2 = wp.lock(); //将sh_ptr赋值给sh_ptr2
*sh_ptr = 100;
std::cout << wp.use_count() << std::endl; // 输出2
}
} //delete memory

std::weak_ptr<int> wp;
{
std::shared_ptr<int> sh_ptr = std::make_shared<int>(10);
wp = sh_ptr;
std::cout << std::boolalpha << wp.expired() << std::endl; // 输出false,引用对象还没删除
} //delete memory

std::cout << std::boolalpha << wp.expired() << std::endl; // 输出true,引用对象已经删除

return 0;
}

weak_ptr可以使用一个非常重要的成员函数lock()从被观测的shared_ptr获得一个可用的shared_ptr对象, 从而操作资源。但当expired()==true的时候,lock()函数将返回一个存储空指针的shared_ptr。

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>
#include <memory>

int main(int argc, const char* argv[]) {
std::shared_ptr<int> sp(new int(10));
std::weak_ptr<int> wp(sp);
//sp.reset();

if (std::shared_ptr<int> pa = wp.lock()) {
std::cout << *pa << std::endl;
}
else {
std::cout << "wp指向对象为空" << std::endl;
}

sp.reset();
if (std::shared_ptr<int> pa = wp.lock()) {
std::cout << *pa << std::endl;
}
else {
std::cout << "wp指向对象为空" << std::endl;
}
return 0;
}

weak_ptr的一个作用是解决share_ptr的循环引用问题。如下面代码所示,class AA中含有指向class BB的shared指针, class BB 中含有指向class AA的shared指针,这样形成了循环引用。m_bb_ptr和m_aa_ptr的强引用计数永远大于等于1,所以直到程序退出前都不会被退出,这种情况有时候在正常的业务逻辑中是不可避免的,而解决循环引用的方法是改用weak_ptr:

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
class BB;

class AA
{
public:
AA() { cout << "AA::AA() called" << endl; }
~AA() { cout << "AA::~AA() called" << endl; }
shared_ptr<BB> m_bb_ptr;
};

class BB
{
public:
BB() { cout << "BB::BB() called" << endl; }
~BB() { cout << "BB::~BB() called" << endl; }
shared_ptr<AA> m_aa_ptr;
};

int main()
{
shared_ptr<AA> ptr_a(new AA);
shared_ptr<BB> ptr_b(new BB);
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
//下面两句导致了AA与BB的循环引用,结果就是AA和BB对象都不会析构
ptr_a->m_bb_ptr = ptr_b;
ptr_b->m_aa_ptr = ptr_a;
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
return 0;
}

可以看到由于AA和BB内部的shared_ptr各自保存了对方的一次引用,所以导致了ptr_a和ptr_b销毁的时候都认为内部保存的指针计数没有变成0,所以AA和BB的析构函数不会被调用。解决方法就是把一个shared_ptr替换成weak_ptr。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BB;
class AA
{
public:
AA() { cout << "AA::AA() called" << endl; }
~AA() { cout << "AA::~AA() called" << endl; }
weak_ptr<BB> m_bb_ptr; //!
};

class BB
{
public:
BB() { cout << "BB::BB() called" << endl; }
~BB() { cout << "BB::~BB() called" << endl; }
shared_ptr<AA> m_aa_ptr; //!
};

总结:
1.weak_ptr虽然是一个模板类,但是不能用来直接定义指向原始指针的对象。
2.weak_ptr接受shared_ptr类型的变量赋值,但是反过来是行不通的,需要使用lock函数。
3.weak_ptr设计之初就是为了服务于shared_ptr的,所以不增加引用计数就是它的核心功能。
4.由于不知道什么之后weak_ptr所指向的对象就会被析构掉,所以使用之前请先使用expired函数检测一下。

第八部分-并行和并发

并行和并发

该部分内容在并发编程里有详细讲解,这里就简单概括一下

1.并行基础

std::thread 用于创建一个执行的线程实例,所以它是一切并发编程的基础,使用时需要包含 头文件, 它提供了很多基本的线程操作,例如 get_id() 来获取所创建线程的线程 ID,使用 join() 来等待一个线程结束(与该线程汇合)等等

2.互斥区和临界区

std::mutex 是 C++11 中最基本的 mutex 类,通过实例化 std::mutex 可以创建互斥量, 而通过其成员函数 lock() 可以进行上锁,unlock() 可以进行解锁。 但是在实际编写代码的过程中,最好不去直接调用成员函数, 因为调用成员函数就需要在每个临界区的出口处调用 unlock(),当然,还包括异常。 这时候 C++11 还为互斥量提供了一个 RAII 语法的模板类 std::lock_guard。 RAII 在不失代码简洁性的同时,很好的保证了代码的异常安全性。

3.期物

期物(Future)表现为 std::future,它提供了一个访问异步操作结果的途径,这句话很不好理解。 为了理解这个特性,我们需要先理解一下在 C++11 之前的多线程行为。

试想,如果我们的主线程 A 希望新开辟一个线程 B 去执行某个我们预期的任务,并返回我一个结果。 而这时候,线程 A 可能正在忙其他的事情,无暇顾及 B 的结果, 所以我们会很自然的希望能够在某个特定的时间获得线程 B 的结果。

在 C++11 的 std::future 被引入之前,通常的做法是: 创建一个线程 A,在线程 A 里启动任务 B,当准备完毕后发送一个事件,并将结果保存在全局变量中。 而主函数线程 A 里正在做其他的事情,当需要结果的时候,调用一个线程等待函数来获得执行的结果。

而 C++11 提供的 std::future 简化了这个流程,可以用来获取异步任务的结果。 自然地,我们很容易能够想象到把它作为一种简单的线程同步手段,即屏障(barrier)。

4.条件变量

条件变量 std::condition_variable 是为了解决死锁而生,当互斥操作不够用而引入的。 比如,线程可能需要等待某个条件为真才能继续执行, 而一个忙等待循环中可能会导致所有其他线程都无法进入临界区使得条件为真时,就会发生死锁。 所以,condition_variable 实例被创建出现主要就是用于唤醒等待线程从而避免死锁。 std::condition_variable的 notify_one() 用于唤醒一个线程; notify_all() 则是通知所有线程

5.原子操作和内存模型

std::mutex 可以解决并发读写的问题,但互斥锁是操作系统级的功能, 这是因为一个互斥锁的实现通常包含两条基本原理:
1.提供线程间自动的状态转换,即『锁住』这个状态
2.保障在互斥锁操作期间,所操作变量的内存与临界区外进行隔离

这是一组非常强的同步条件,换句话说当最终编译为 CPU 指令时会表现为非常多的指令(我们之后再来看如何实现一个简单的互斥锁)。 这对于一个仅需原子级操作(没有中间态)的变量,似乎太苛刻了。

原子操作

现代 CPU 体系结构提供了 CPU 指令级的原子操作, 因此在 C++11 中多线程下共享变量的读写这一问题上,还引入了 std::atomic 模板,使得我们实例化一个原子类型,将一个 原子类型读写操作从一组指令,最小化到单个 CPU 指令。

当然,并非所有的类型都能提供原子操作,这是因为原子操作的可行性取决于具体的 CPU 架构,以及所实例化的类型结构是否能够满足该 CPU 架构对内存对齐 条件的要求,因而我们总是可以通过 std::atomic::is_lock_free 来检查该原子类型是否需支持原子操作

一致性模型

并行执行的多个线程,从某种宏观层面上讨论,可以粗略的视为一种分布式系统。 在分布式系统中,任何通信乃至本地操作都需要消耗一定时间,甚至出现不可靠的通信。

如果我们强行将一个变量 v 在多个线程之间的操作设为原子操作,即任何一个线程在操作完 v 后, 其他线程均能同步感知到 v 的变化,则对于变量 v 而言,表现为顺序执行的程序,它并没有由于引入多线程 而得到任何效率上的收益。对此有什么办法能够适当的加速呢?答案便是削弱原子操作的在进程间的同步条件。

从原理上看,每个线程可以对应为一个集群节点,而线程间的通信也几乎等价于集群节点间的通信。 削弱进程间的同步条件,通常我们会考虑四种不同的一致性模型:
1.线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。
2.顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致。
3.因果一致性:它的要求进一步降低,只需要有因果关系的操作顺序得到保障,而非因果关系的操作顺序则不做要求。
4.最终一致性:是最弱的一致性要求,它只保障某个操作在未来的某个时间节点上会被观察到,但并未要求被观察到的时间。因此我们甚至可以对此条件稍作加强,例如规定某个操作被观察到的时间总是有界的。当然这已经不在我们的讨论范围之内了。

内存顺序

为了追求极致的性能,实现各种强度要求的一致性,C++11 为原子操作定义了六种不同的内存顺序 std::memory_order 的选项,表达了四种多线程间的同步模型:
1.宽松模型:在此模型下,单个线程内的原子操作都是顺序执行的,不允许指令重排,但不同线程间原子操作的顺序是任意的。类型通过std::memory_order_relaxed 指定

1
2
3
4
5
6
7
8
9
10
11
12
std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_relaxed);
});
}

for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;

2.释放/消费模型:在此模型中,我们开始限制进程间的操作顺序,如果某个线程需要修改某个值,但另一个线程会对该值的某次操作产生依赖,即后者依赖前者。具体而言,线程 A 完成了三次对 x 的写操作,线程 B 仅依赖其中第三次 x 的写操作,与 x 的前两次写行为无关,则当 A 主动 x.release() 时候(即使用 std::memory_order_release),选项 std::memory_order_consume 能够确保 B 在调用 x.load() 时候观察到 A 中第三次对 x 的写操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 初始化为 nullptr 防止 consumer 线程从野指针进行读取
std::atomic<int*> ptr(nullptr);
int v;
std::thread producer([&]() {
int* p = new int(42);
v = 1024;
ptr.store(p, std::memory_order_release);
});
std::thread consumer([&]() {
int* p;
while(!(p = ptr.load(std::memory_order_consume)));

std::cout << "p: " << *p << std::endl;
std::cout << "v: " << v << std::endl;
});
producer.join();
consumer.join();

3.释放/获取模型:在此模型下,我们可以进一步加紧对不同线程间原子操作的顺序的限制,在释放 std::memory_order_release 和获取 std::memory_order_acquire 之间规定时序,即发生在释放(release)操作之前的所有写操作,对其他线程的任何获取(acquire)操作都是可见的,亦即发生顺序(happens-before)。

可以看到,std::memory_order_release 确保了它之前的写操作不会发生在释放操作之后,是一个向后的屏障(backward),而 std::memory_order_acquire 确保了它之前的写行为不会发生在该获取操作之后,是一个向前的屏障(forward)。对于选项 std::memory_order_acq_rel 而言,则结合了这两者的特点,唯一确定了一个内存屏障,使得当前线程对内存的读写不会被重排并越过此操作的前后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
std::vector<int> v;
std::atomic<int> flag = {0};
std::thread release([&]() {
v.push_back(42);
flag.store(1, std::memory_order_release);
});
std::thread acqrel([&]() {
int expected = 1; // must before compare_exchange_strong
while(!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel))
expected = 1; // must after compare_exchange_strong
// flag has changed to 2
});
std::thread acquire([&]() {
while(flag.load(std::memory_order_acquire) < 2);

std::cout << v.at(0) << std::endl; // must be 42
});
release.join();
acqrel.join();
acquire.join();

在此例中我们使用了 compare_exchange_strong 比较交换原语(Compare-and-swap primitive),它有一个更弱的版本,即 compare_exchange_weak,它允许即便交换成功,也仍然返回 false 失败。其原因是因为在某些平台上虚假故障导致的,具体而言,当 CPU 进行上下文切换时,另一线程加载同一地址产生的不一致。除此之外,compare_exchange_strong 的性能可能稍差于 compare_exchange_weak,但大部分情况下,鉴于其使用的复杂度而言,compare_exchange_weak 应该被有限考虑

4.顺序一致模型:在此模型下,原子操作满足顺序一致性,进而可能对性能产生损耗。可显式的通过 std::memory_order_seq_cst 进行指定

1
2
3
4
5
6
7
8
9
10
11
12
std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_seq_cst);
});
}

for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;

第七部分-正则表达式

正则表达式

正则表达式不是 C++ 语言的一部分,这里仅做简单的介绍。

正则表达式描述了一种字符串匹配的模式。一般使用正则表达式主要是实现下面三个需求:

检查一个串是否包含某种形式的子串;
将匹配的子串替换;
从某个串中取出符合条件的子串。
正则表达式是由普通字符(例如 a 到 z)以及特殊字符组成的文字模式。模式描述在搜索文本时要匹配的一个或多个字符串。 正则表达式作为一个模板,将某个字符模式与所搜索的字符串进行匹配。

普通字符

普通字符包括没有显式指定为元字符的所有可打印和不可打印字符。这包括所有大写和小写字母、所有数字、所有标点符号和一些其他符号。

特殊字符

特别字符 描述
$ 匹配输入字符串的结尾位置。
(,) 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。
* 匹配前面的子表达式零次或多次。
+ 匹配前面的子表达式一次或多次。
. 匹配除换行符 \n 之外的任何单字符。
[ 标记一个中括号表达式的开始。
? 匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。
\ 将下一个字符标记为或特殊字符、或原义字符、或向后引用、或八进制转义符。例如, n 匹配字符 n。\n 匹配换行符。序列 \ 匹配 ‘' 字符,而 ( 则匹配 ‘(‘ 字符。
^ 匹配输入字符串的开始位置,除非在方括号表达式中使用,此时它表示不接受该字符集合。
{ 标记限定符表达式的开始。
l(或符号) 指明两项之间的一个选择。

限定符

字符 描述
* 匹配前面的子表达式零次或多次。例如,foo* 能匹配 fo 以及 foooo。* 等价于{0,}。
+ 匹配前面的子表达式一次或多次。例如,foo+ 能匹配 foo 以及 foooo,但不能匹配 fo。+ 等价于 {1,}。
? 匹配前面的子表达式零次或一次。例如,Your(s)? 可以匹配 Your 或 Yours 中的Your 。? 等价于 {0,1}。
{n} n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 for 中的 o,但是能匹配 foo 中的两个 o。
{n,} n 是一个非负整数。至少匹配 n 次。例如,o{2,} 不能匹配 for 中的 o,但能匹配 foooooo 中的所有 o。o{1,} 等价于 o+。o{0,} 则等价于 o*。
{n,m} m 和 n 均为非负整数,其中 n 小于等于 m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 foooooo 中的前三个 o。o{0,1} 等价于 o?。注意,在逗号和两个数之间不能有空格。

std::regex及其相关

C++11 提供的正则表达式库操作 std::string 对象, 模式 std::regex (本质是 std::basic_regex)进行初始化, 通过 std::regex_match 进行匹配, 从而产生 std::smatch (本质是 std::match_results 对象)。

我们通过一个简单的例子来简单介绍这个库的使用。考虑下面的正则表达式:

[a-z]+.txt: 在这个正则表达式中, [a-z] 表示匹配一个小写字母, + 可以使前面的表达式匹配多次, 因此 [a-z]+ 能够匹配一个小写字母组成的字符串。 在正则表达式中一个 . 表示匹配任意字符,而 . 则表示匹配字符 ., 最后的 txt 表示严格匹配 txt 则三个字母。因此这个正则表达式的所要匹配的内容就是由纯小写字母组成的文本文件。
std::regex_match 用于匹配字符串和正则表达式,有很多不同的重载形式。 最简单的一个形式就是传入 std::string 以及一个 std::regex 进行匹配, 当匹配成功时,会返回 true,否则返回 false。例如:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <string>
#include <regex>

int main() {
std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
// 在 C++ 中 \ 会被作为字符串内的转义符,
// 为使 \. 作为正则表达式传递进去生效,需要对 \ 进行二次转义,从而有 \\.
std::regex txt_regex("[a-z]+\\.txt");
for (const auto &fname: fnames)
std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
}

另一种常用的形式就是依次传入 std::string/std::smatch/std::regex 三个参数, 其中 std::smatch 的本质其实是 std::match_results。 故而在标准库的实现中, std::smatch 被定义为了 std::match_resultsstd::string::const_iterator, 也就是一个子串迭代器类型的 match_results。 使用 std::smatch 可以方便的对匹配的结果进行获取

1
2
3
4
5
6
7
8
9
10
11
12
13
std::regex base_regex("([a-z]+)\\.txt");
std::smatch base_match;
for(const auto &fname: fnames) {
if (std::regex_match(fname, base_match, base_regex)) {
// std::smatch 的第一个元素匹配整个字符串
// std::smatch 的第二个元素匹配了第一个括号表达式
if (base_match.size() == 2) {
std::string base = base_match[1].str();
std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
std::cout << fname << " sub-match[1]: " << base << std::endl;
}
}
}
1
2
3
4
5
6
7
8
9
10
//输出结果
foo.txt: 1
bar.txt: 1
test: 0
a0.txt: 0
AAA.txt: 0
sub-match[0]: foo.txt
foo.txt sub-match[1]: foo
sub-match[0]: bar.txt
bar.txt sub-match[1]: bar

hazel具体实践遇到的一些小bug

1.事件系统–未定义的标识符

Hazel和sandBox预处理器设置错误。

2.日志系统

错误 C2338 static_assert failed: ‘Cannot format an argument. To make type T formattable provide a formatter specialization: https://fmt.dev/latest/api.html#udt‘ Hazel C:\Cpp_Dev\Hazel\Hazel\vendor\spdlog\include\spdlog\fmt\bundled\core.h 1757

1
2

HZ_CORE_TRACE("{0}", e.); => HZ_CORE_TRACE("{0}", e.ToString());

3.GLFW

这里的011的GLFW和现有库不同 会导致编译出错
需要把GLFW的分支切到 git checkout -f b4b4319 这个是当时录制教程的一个中间commit
然后把lua脚本中的systemversion改为latest
然后premake 然后再编译 就可以避免LINK错误了