Skip to content

Latest commit

 

History

History
1045 lines (761 loc) · 46.6 KB

appendix_A-chinese.md

File metadata and controls

1045 lines (761 loc) · 46.6 KB

附录A 对C++11语言特性的简要介绍

新的C++标准,不仅带来了对并发的支持,也将其他语言的一些特性带入标准库中。在本附录中,会给出对这些新特性进行简要介绍(这些特性用在线程库中)。除了thread_local(详见A.8部分)以外,就没有与并发直接相关的内容了,但对于多线程代码来说,它们都是很重要。我已只列出有必要的部分(例如,右值引用),这样能够使代码更容易理解。由于对新特性不熟,对用到某些特性的代码理解起来会有一些困难;没关系,当对这些特性渐渐熟知后,就能很容易的理解代码。由于C++11的应用越来越广泛,这些特性在代码中的使用也将会变越来越普遍。

话不多说,让我们从线程库中的右值引用开始,来熟悉对象之间所有权(线程,锁等等)的转移。

A.1 右值引用

如果你从事过C++编程,你会对引用比较熟悉,C++的引用允许你为已经存在的对象创建一个新的名字。对新引用所做的访问和修改操作,都会影响它的原型。

例如:

int var=42;
int& ref=var;  // 创建一个var的引用
ref=99;
assert(var==99);  // 原型的值被改变了,因为引用被赋值了

目前为止,我们用过的所有引用都是左值引用——对左值的引用。lvalue这个词来自于C语言,指的是可以放在赋值表达式左边的事物——在栈上或堆上分配的命名对象,或者其他对象成员——有明确的内存地址。rvalue这个词也来源于C语言,指的是可以出现在赋值表达式右侧的对象——例如,文字常量和临时变量。因此,左值引用只能被绑定在左值上,而不是右值。

不能这样写:

int& i=42;  // 编译失败

例如,因为42是一个右值。好吧,这有些假;你可能通常使用下面的方式讲一个右值绑定到一个const左值引用上:

int const& i = 42;

这算是钻了标准的一个空子吧。不过,这种情况我们之前也介绍过,我们通过对左值的const引用创建临时性对象,作为参数传递给函数。

其允许隐式转换,所以你可这样写:

void print(std::string const& s);
print("hello");  //创建了临时std::string对象

C++11标准介绍了右值引用(rvalue reference),这种方式只能绑定右值,不能绑定左值,其通过两个&&来进行声明:

int&& i=42;
int j=42;
int&& k=j;  // 编译失败

因此,可以使用函数重载的方式来确定:函数有左值或右值为参数的时候,看是否能被同名且对应参数为左值或有值引用的函数所重载。

其基础就是C++11新添语义——移动语义(move semantics)。

A.1.1 移动语义

右值通常都是临时的,所以可以随意修改;如果知道函数的某个参数是一个右值,就可以将其看作为一个临时存储或“窃取”内容,也不影响程序的正确性。这就意味着,比起拷贝右值参数的内容,不如移动其内容。动态数组比较大的时候,这样能节省很多内存分配,提供更多的优化空间。试想,一个函数以std::vector<int>作为一个参数,就需要将其拷贝进来,而不对原始的数据做任何操作。C++03/98的办法是,将这个参数作为一个左值的const引用传入,然后做内部拷贝:

void process_copy(std::vector<int> const& vec_)
{
  std::vector<int> vec(vec_);
  vec.push_back(42);
}

这就允许函数能以左值或右值的形式进行传递,不过任何情况下都是通过拷贝来完成的。如果使用右值引用版本的函数来重载这个函数,就能避免在传入右值的时候,函数会进行内部拷贝的过程,因为可以任意的对原始值进行修改:

void process_copy(std::vector<int> && vec)
{
  vec.push_back(42);
}

如果这个问题存在于类的构造函数中,窃取内部右值在新的实例中使用。可以参考一下清单中的例子(默认构造函数会分配很大一块内存,在析构函数中释放)。

清单A.1 使用移动构造函数的类

class X
{
private:
  int* data;

public:
  X():
    data(new int[1000000])
  {}

  ~X()
  {
    delete [] data;
  }

  X(const X& other):  // 1
   data(new int[1000000])
  {
    std::copy(other.data,other.data+1000000,data);
  }
  
  X(X&& other):  // 2
    data(other.data)
  {
    other.data=nullptr;
  }
};

一般情况下,拷贝构造函数①都是这么定义:分配一块新内存,然后将数据拷贝进去。不过,现在有了一个新的构造函数,可以接受右值引用来获取老数据②,就是移动构造函数。在这个例子中,只是将指针拷贝到数据中,将other以空指针的形式留在了新实例中;使用右值里创建变量,就能避免了空间和时间上的多余消耗。

X类(清单A.1)中的移动构造函数,仅作为一次优化;在其他例子中,有些类型的构造函数只支持移动构造函数,而不支持拷贝构造函数。例如,智能指针std::unique_ptr<>的非空实例中,只允许这个指针指向其对象,所以拷贝函数在这里就不能用了(如果使用拷贝函数,就会有两个std::unique_ptr<>指向该对象,不满足std::unique_ptr<>定义)。不过,移动构造函数允许对指针的所有权,在实例之间进行传递,并且允许std::unique_ptr<>像一个带有返回值的函数一样使用——指针的转移是通过移动,而非拷贝。

如果你已经知道,某个变量在之后就不会在用到了,这时候可以选择显式的移动,你可以使用static_cast<X&&>将对应变量转换为右值,或者通过调用std::move()函数来做这件事:

X x1;
X x2=std::move(x1);
X x3=static_cast<X&&>(x2);

想要将参数值不通过拷贝,转化为本地变量或成员变量时,就可以使用这个办法;虽然右值引用参数绑定了右值,不过在函数内部,会当做左值来进行处理:

void do_stuff(X&& x_)
{
  X a(x_);  // 拷贝
  X b(std::move(x_));  // 移动
}
do_stuff(X());  // ok,右值绑定到右值引用上
X x;
do_stuff(x);  // 错误,左值不能绑定到右值引用上

移动语义在线程库中用的比较广泛,无拷贝操作对数据进行转移可以作为一种优化方式,避免对将要被销毁的变量进行额外的拷贝。在2.2节中看到,在线程中使用std::move()转移std::unique_ptr<>得到一个新实例;在2.3节中,了解了在std:thread的实例间使用移动语义,用来转移线程的所有权。

std::threadstd::unique_lock<>std::future<>std::promise<>std::packaged_task<>都不能拷贝,不过这些类都有移动构造函数,能让相关资源在实例中进行传递,并且支持用一个函数将值进行返回。std::stringstd::vector<>也可以拷贝,不过它们也有移动构造函数和移动赋值操作符,就是为了避免拷贝拷贝大量数据。

C++标准库不会将一个对象显式的转移到另一个对象中,除非将其销毁的时候或对其赋值的时候(拷贝和移动的操作很相似)。不过,实践中移动能保证类中的所有状态保持不变,表现良好。一个std::thread实例可以作为移动源,转移到新(以默认构造方式)的std::thread实例中。还有,std::string可以通过移动原始数据进行构造,并且保留原始数据的状态,不过不能保证的是原始数据中该状态是否正确(根据字符串长度或字符数量决定)。

A.1.2 右值引用和函数模板

在使用右值引用作为函数模板的参数时,与之前的用法有些不同:如果函数模板参数以右值引用作为一个模板参数,当对应位置提供左值的时候,模板会自动将其类型认定为左值引用;当提供右值的时候,会当做普通数据使用。可能有些口语化,来看几个例子吧。

考虑一下下面的函数模板:

template<typename T>
void foo(T&& t)
{}

随后传入一个右值,T的类型将被推导为:

foo(42);  // foo<int>(42)
foo(3.14159);  // foo<double><3.14159>
foo(std::string());  // foo<std::string>(std::string())

不过,向foo传入左值的时候,T会被推导为一个左值引用:

int i = 42;
foo(i);  // foo<int&>(i)

因为函数参数声明为T&&,所以就是引用的引用,可以视为是原始的引用类型。那么foo<int&>()就相当于:

foo<int&>(); // void foo<int&>(int& t);

这就允许一个函数模板可以即接受左值,又可以接受右值参数;这种方式已经被std::thread的构造函数所使用(2.1节和2.2节),所以能够将可调用对象移动到内部存储,而非当参数是右值的时候进行拷贝。

A.2 删除函数

有时让类去做拷贝是没有意义的。std::mutex就是一个例子——拷贝一个互斥量,意义何在?std::unique_lock<>是另一个例子——一个实例只能拥有一个锁;如果要复制,拷贝的那个实例也能获取相同的锁,这样std::unique_lock<>就没有存在的意义了。实例中转移所有权(A.1.2节)是有意义的,其并不是使用的拷贝。当然其他例子就不一一列举了。

通常为了避免进行拷贝操作,会将拷贝构造函数和拷贝赋值操作符声明为私有成员,并且不进行实现。如果对实例进行拷贝,将会引起编译错误;如果有其他成员函数或友元函数想要拷贝一个实例,那将会引起链接错误(因为缺少实现):

class no_copies
{
public:
  no_copies(){}
private:
  no_copies(no_copies const&);  // 无实现
  no_copies& operator=(no_copies const&);  // 无实现
};

no_copies a;
no_copies b(a);  // 编译错误

在C++11中,委员会意识到这种情况,但是没有意识到其会带来攻击性。因此,委员会提供了更多的通用机制:可以通过添加= delete将一个函数声明为删除函数。

no_copise类就可以写为:

class no_copies
{
public:
  no_copies(){}
  no_copies(no_copies const&) = delete;
  no_copies& operator=(no_copies const&) = delete;
};

这样的描述要比之前的代码更加清晰。也允许编译器提供更多的错误信息描述,当成员函数想要执行拷贝操作的时候,可将连接错误转移到编译时。

拷贝构造和拷贝赋值操作删除后,需要显式写一个移动构造函数和移动赋值操作符,与std::threadstd::unique_lock<>一样,你的类是只移动的。

下面清单中的例子,就展示了一个只移动的类。

清单A.2 只移动类

class move_only
{
  std::unique_ptr<my_class> data;
public:
  move_only(const move_only&) = delete;
  move_only(move_only&& other):
    data(std::move(other.data))
  {}
  move_only& operator=(const move_only&) = delete;
  move_only& operator=(move_only&& other)
  {
    data=std::move(other.data);
    return *this;
  }
};

move_only m1;
move_only m2(m1);  // 错误,拷贝构造声明为“已删除”
move_only m3(std::move(m1));  // OK,找到移动构造函数

只移动对象可以作为函数的参数进行传递,并且从函数中返回,不过当想要移动左值,通常需要显式的使用std::move()static_cast<T&&>

可以为任意函数添加= delete说明符,添加后就说明这些函数是不能使用的。当然,还可以用于很多的地方;删除函数可以以正常的方式参与重载解析,并且如果被使用只会引起编译错误。这种方式可以用来删除特定的重载。比如,当函数以short作为参数,为了避免扩展为int类型,可以写出重载函数(以int为参数)的声明,然后添加删除说明符:

void foo(short);
void foo(int) = delete;

现在,任何向foo函数传递int类型参数都会产生一个编译错误,不过调用者可以显式的将其他类型转化为short:

foo(42);  // 错误,int重载声明已经删除
foo((short)42);  // OK

A.3 默认函数

删除函数的函数可以不进行实现,默认函数就则不同:编译器会创建函数实现,通常都是“默认”实现。当然,这些函数可以直接使用(它们都会自动生成):默认构造函数,析构函数,拷贝构造函数,移动构造函数,拷贝赋值操作符和移动赋值操作符。

为什么要这样做呢?这里列出一些原因:

  • 改变函数的可访问性——编译器生成的默认函数通常都是声明为public(如果想让其为protected或private成员,必须自己实现)。将其声明为默认,可以让编译器来帮助你实现函数和改变访问级别。

  • 作为文档——编译器生成版本已经足够使用,那么显式声明就利于其他人阅读这段代码,会让代码结构看起来很清晰。

  • 没有单独实现的时候,编译器自动生成函数——通常默认构造函数来做这件事,如果用户没有定义构造函数,编译器将会生成一个。当需要自定一个拷贝构造函数时(假设),如果将其声明为默认,也可以获得编译器为你实现的拷贝构造函数。

  • 编译器生成虚析构函数。

  • 声明一个特殊版本的拷贝构造函数,比如:参数类型是非const引用,而不是const引用。

  • 利用编译生成函数的特殊性质(如果提供了对应的函数,将不会自动生成对应函数——会在后面具体讲解)。

就像删除函数是在函数后面添加= delete一样,默认函数需要在函数后面添加= default,例如:

class Y
{
private:
  Y() = default;  // 改变访问级别
public:
  Y(Y&) = default;  // 以非const引用作为参数
  T& operator=(const Y&) = default; // 作为文档的形式,声明为默认函数
protected:
  virtual ~Y() = default;  // 改变访问级别,以及添加虚函数标签
};

编译器生成函数都有独特的特性,这是用户定义版本所不具备的。最大的区别就是编译器生成的函数都很简单。

列出了几点重要的特性:

  • 对象具有简单的拷贝构造函数,拷贝赋值操作符和析构函数,都能通过memcpy或memmove进行拷贝。

  • 字面类型用于constexpr函数(可见A.4节),必须有简单的构造,拷贝构造和析构函数。

  • 类的默认构造,拷贝,拷贝赋值操作符合析构函数,也可以用在一个已有构造和析构函数(用户定义)的联合体内。

  • 类的简单拷贝赋值操作符可以使用std::atomic<>类型模板(见5.2.6节),为某种类型的值提供原子操作。

仅添加= default不会让函数变得简单——如果类还支持其他相关标准的函数,那这个函数就是简单的——不过,用户显式的实现就不会让这些函数变简单。

第二个区别,编译器生成函数和用户提供的函数等价,也就是类中无用户提供的构造函数可以看作为一个aggregate,并且可以通过聚合初始化函数进行初始化:

struct aggregate
{
  aggregate() = default;
  aggregate(aggregate const&) = default;
  int a;
  double b;
};
aggregate x={42,3.141};

例子中,x.a被42初始化,x.b被3.141初始化。

第三个区别,编译器生成的函数只适用于构造函数;换句话说,只适用于符合某些标准的默认构造函数。

struct X
{
  int a;
};

如果创建了一个X的实例(未初始化),其中int(a)将会被默认初始化。

如果对象有静态存储过程,那么a将会被初始化为0;另外,当a没赋值的时候,其不定值可能会触发未定义行为:

X x1;  // x1.a的值不明确

另外,当使用显示调用构造函数的方式对X进行初始化,a就会被初始化为0:

X x2 = X();  // x2.a == 0

这种奇怪的属性会扩展到基础类和成员函数中。当类的默认构造函数是由编译器提供,并且一些数据成员和基类都是有编译器提供默认构造函数时,还有基类的数据成员和该类中的数据成员都是内置类型的时候,其值要不就是不确定的,要不就是被初始化为0(与默认构造函数是否能被显式调用有关)。

虽然这条规则令人困惑,并且容易造成错误,不过也很有用;当你编写构造函数的时候,就不会用到这个特性;数据成员,通常都可以被初始化(指定了一个值或调用了显式构造函数),或不会被初始化(因为不需要):

X::X():a(){}  // a == 0
X::X():a(42){}  // a == 2
X::X(){}  // 1

第三个例子中①,省略了对a的初始化,X中a就是一个未被初始化的非静态实例,初始化的X实例都会有静态存储过程。

通常的情况下,如果写了其他构造函数,编译器就不会生成默认构造函数。所以,想要自己写一个的时候,就意味着你放弃了这种奇怪的初始化特性。不过,将构造函数显示声明成默认,就能强制编译器为你生成一个默认构造函数,并且刚才说的那种特性会保留:

X::X() = default;  // 应用默认初始化规则

这种特性用于原子变量(见5.2节),默认构造函数显式为默认。初始值通常都没有定义,除非具有(a)一个静态存储的过程(静态初始化为0),(b)显式调用默认构造函数,将成员初始化为0,(c)指定一个特殊的值。注意,这种情况下的原子变量,为允许静态初始化过程,构造函数会通过一个声明为constexpr(见A.4节)的值为原子变量进行初始化。

A.4 常量表达式函数

整型字面值,例如42,就是常量表达式。所以,简单的数学表达式,例如,23x2-4。可以使用其来初始化const整型变量,然后将const整型变量作为新表达的一部分:

const int i=23;
const int two_i=i*2;
const int four=4;
const int forty_two=two_i-four;

使用常量表达式创建变量也可用在其他常量表达式中,有些事只能用常量表达式去做:

  • 指定数组长度:
int bounds=99;
int array[bounds];  // 错误,bounds不是一个常量表达式
const int bounds2=99;
int array2[bounds2];  // 正确,bounds2是一个常量表达式
  • 指定非类型模板参数的值:
template<unsigned size>
struct test
{};
test<bounds> ia;  // 错误,bounds不是一个常量表达式
test<bounds2> ia2;  // 正确,bounds2是一个常量表达式
  • 对类中static const整型成员变量进行初始化:
class X
{
  static const int the_answer=forty_two;
};
  • 对内置类型进行初始化或可用于静态初始化集合:
struct my_aggregate
{
  int a;
  int b;
};
static my_aggregate ma1={forty_two,123};  // 静态初始化
int dummy=257;
static my_aggregate ma2={dummy,dummy};  // 动态初始化
  • 静态初始化可以避免初始化顺序和条件变量的问题。

这些都不是新添加的——你可以在1998版本的C++标准中找到对应上面实例的条款。不过,新标准中常量表达式进行了扩展,并添加了新的关键字——constexpr

constexpr会对功能进行修改,当参数和函数返回类型符合要求,并且实现很简单,那么这样的函数就能够被声明为constexpr,这样函数可以当做常数表达式来使用:

constexpr int square(int x)
{
  return x*x;
}
int array[square(5)];

在这个例子中,array有25个元素,因为square函数的声明为constexpr。当然,这种方式可以当做常数表达式来使用,不意味着什么情况下都是能够自动转换为常数表达式:

int dummy=4;
int array[square(dummy)];  // 错误,dummy不是常数表达式

dummy不是常数表达式,所以square(dummy)也不是——就是一个普通函数调用——所以其不能用来指定array的长度。

A.4.1 常量表达式和自定义类型

目前为止的例子都是以内置int型展开的。不过,在新C++标准库中,对于满足字面类型要求的任何类型,都可以用常量表达式来表示。

要想划分到字面类型中,需要满足一下几点:

  • 一般的拷贝构造函数。

  • 一般的析构函数。

  • 所有成员变量都是非静态的,且基类需要是一般类型。

  • 必须具有一个一般的默认构造函数,或一个constexpr构造函数。

后面会了解一下constexpr构造函数。

现在,先将注意力集中在默认构造函数上,就像下面清单中的CX类一样。

清单A.3(一般)默认构造函数的类

class CX
{
private:
  int a;
  int b;
public:
  CX() = default;  // 1
  CX(int a_, int b_):  // 2
    a(a_),b(b_)
  {}
  int get_a() const
  {
    return a;
  }
  int get_b() const
  {
    return b;
  }
  int foo() const
  {
    return a+b;
  }
};

注意,这里显式的声明了默认构造函数①(见A.3节),为了保存用户定义的构造函数②。因此,这种类型符合字面类型的要求,可以将其用在常量表达式中。

可以提供一个constexpr函数来创建一个实例,例如:

constexpr CX create_cx()
{
  return CX();
}

也可以创建一个简单的constexpr函数来拷贝参数:

constexpr CX clone(CX val)
{
  return val;
}

不过,constexpr函数只有其他constexpr函数可以进行调用。CX类中声明成员函数和构造函数为constexpr:

class CX
{
private:
  int a;
  int b;
public:
  CX() = default;
  constexpr CX(int a_, int b_):
    a(a_),b(b_)
  {}
  constexpr int get_a() const  // 1
  {
    return a;
  }
  constexpr int get_b()  // 2
  {
    return b;
  }
  constexpr int foo()
  {
    return a+b;
  }
};

注意,const对于get_a()①来说就是多余的,因为在使用constexpr时就为const了,所以const描述符在这里会被忽略。

这就允许更多复杂的constexpr函数存在:

constexpr CX make_cx(int a)
{
  return CX(a,1);
}
constexpr CX half_double(CX old)
{
  return CX(old.get_a()/2,old.get_b()*2);
}
constexpr int foo_squared(CX val)
{
  return square(val.foo());
}
int array[foo_squared(half_double(make_cx(10)))];  // 49个元素

函数都很有趣,如果想要计算数组的长度或一个整型常量,就需要使用这种方式。最大的好处是常量表达式和constexpr函数会设计到用户定义类型的对象,可以使用这些函数对这些对象进行初始化。因为常量表达式的初始化过程是静态初始化,所以就能避免条件竞争和初始化顺序的问题:

CX si=half_double(CX(42,19));  // 静态初始化

当构造函数被声明为constexpr,且构造函数参数是常量表达式时,那么初始化过程就是常数初始化(可能作为静态初始化的一部分)。随着并发的发展,C++11标准中有一个重要的改变:允许用户定义构造函数进行静态初始化,就可以在初始化的时候避免条件竞争,因为静态过程能保证初始化过程在代码运行前进行。

特别是关于std::mutex(见3.2.1节)或std::atomic<>(见5.2.6节),当想要使用一个全局实例来同步其他变量的访问时,同步访问就能避免条件竞争的发生。构造函数中,互斥量不可能产生条件竞争,因此对于std::mutex的默认构造函数应该被声明为constexpr,为了保证互斥量初始化过程是一个静态初始化过程的一部分。

A.4.2 常量表达式对象

目前,已经了解了constexpr在函数上的应用。constexpr也可以用在对象上,主要是用来做判断的;验证对象是否是使用常量表达式,constexpr构造函数或组合常量表达式进行初始化。

且这个对象需要声明为const:

constexpr int i=45;  // ok
constexpr std::string s(“hello”);  // 错误,std::string不是字面类型

int foo();
constexpr int j=foo();  // 错误,foo()没有声明为constexpr

A.4.3 常量表达式函数的要求

将一个函数声明为constexpr,也是有几点要求的;当不满足这些要求,constexpr声明将会报编译错误。

  • 所有参数都必须是字面类型。

  • 返回类型必须是字面类型。

  • 函数体内必须有一个return。

  • return的表达式需要满足常量表达式的要求。

  • 构造返回值/表达式的任何构造函数或转换操作,都需要是constexpr。

看起来很简单,要在内联函数中使用到常量表达式,返回的还是个常量表达式,还不能对任何东西进行改动。constexpr函数就是无害的纯洁的函数。

constexpr类成员函数,需要追加几点要求:

  • constexpr成员函数不能是虚函数。

  • 对应类必须有字面类的成员。

constexpr构造函数的规则也有些不同:

  • 构造函数体必须为空。

  • 每一个基类必须可初始化。

  • 每个非静态数据成员都需要初始化。

  • 初始化列表的任何表达式,必须是常量表达式。

  • 构造函数可选择要进行初始化的数据成员,并且基类必须有constexpr构造函数。

  • 任何用于构建数据成员的构造函数和转换操作,以及和初始化表达式相关的基类必须为constexpr。

这些条件同样适用于成员函数,除非函数没有返回值,也就没有return语句。

另外,构造函数对初始化列表中的所有基类和数据成员进行初始化。一般的拷贝构造函数会隐式的声明为constexpr。

A.4.4 常量表达式和模板

将constexpr应用于函数模板,或一个类模板的成员函数;根据参数,如果模板的返回类型不是字面类,编译器会忽略其常量表达式的声明。当模板参数类型合适,且为一般inline函数,就可以将类型写成constexpr类型的函数模板。

template<typename T>
constexpr T sum(T a,T b)
{
  return a+b;
}
constexpr int i=sum(3,42);  // ok,sum<int>是constexpr
std::string s=
  sum(std::string("hello"),
      std::string(" world"));  // 也行,不过sum<std::string>就不是constexpr了

函数需要满足所有constexpr函数所需的条件。不能用多个constexpr来声明一个函数,因为其是一个模板;这样也会带来一些编译错误。

A.5 Lambda函数

lambda函数在C++11中的加入很是令人兴奋,因为lambda函数能够大大简化代码复杂度(语法糖:利于理解具体的功能),避免实现调用对象。C++11的lambda函数语法允许在需要使用的时候进行定义。能为等待函数,例如std::condition_variable(如同4.1.1节中的例子)提供很好谓词函数,其语义可以用来快速的表示可访问的变量,而非使用类中函数来对成员变量进行捕获。

最简单的情况下,lambda表达式就一个自给自足的函数,不需要传入函数仅依赖管局变量和函数,甚至都可以不用返回一个值。这样的lambda表达式的一系列语义都需要封闭在括号中,还要以方括号作为前缀:

[]{  // lambda表达式以[]开始
  do_stuff();
  do_more_stuff();
}();  // 表达式结束,可以直接调用

例子中,lambda表达式通过后面的括号调用,不过这种方式不常用。一方面,如果想要直接调用,可以在写完对应的语句后,就对函数进行调用。对于函数模板,传递一个参数进去时很常见的事情,甚至可以将可调用对象作为其参数传入;可调用对象通常也需要一些参数,或返回一个值,亦或两者都有。如果想给lambda函数传递参数,可以参考下面的lambda函数,其使用起来就像是一个普通函数。例如,下面代码是将vector中的元素使用std::cout进行打印:

std::vector<int> data=make_data();
std::for_each(data.begin(),data.end(),[](int i){std::cout<<i<<"\n";});

返回值也是很简单的,当lambda函数体包括一个return语句,返回值的类型就作为lambda表达式的返回类型。例如,使用一个简单的lambda函数来等待std::condition_variable(见4.1.1节)中的标志被设置。

清单A.4 lambda函数推导返回类型

std::condition_variable cond;
bool data_ready;
std::mutex m;
void wait_for_data()
{
  std::unique_lock<std::mutex> lk(m);
  cond.wait(lk,[]{return data_ready;});  // 1
}

lambda的返回值传递给cond.wait()①,函数就能推断出data_ready的类型是bool。当条件变量从等待中苏醒后,上锁阶段会调用lambda函数,并且当data_ready为true时,仅返回到wait()中。

当lambda函数体中有多个return语句,就需要显式的指定返回类型。只有一个返回语句的时候,也可以这样做,不过这样可能会让你的lambda函数体看起来更复杂。返回类型可以使用跟在参数列表后面的箭头(->)进行设置。如果lambda函数没有任何参数,还需要包含(空)的参数列表,这样做是为了能显式的对返回类型进行指定。对条件变量的预测可以写成下面这种方式:

cond.wait(lk,[]()->bool{return data_ready;});

还可以对lambda函数进行扩展,比如:加上log信息的打印,或做更加复杂的操作:

cond.wait(lk,[]()->bool{
  if(data_ready)
  {
    std::cout<<”Data ready”<<std::endl;
    return true;
  }
  else
  {
    std::cout<<”Data not ready, resuming wait”<<std::endl;
    return false;
  }
});

虽然简单的lambda函数很强大,能简化代码,不过其真正的强大的地方在于对本地变量的捕获。

A.5.1 引用本地变量的Lambda函数

lambda函数使用空的[](lambda introducer)就不能引用当前范围内的本地变量;其只能使用全局变量,或将其他值以参数的形式进行传递。当想要访问一个本地变量,需要对其进行捕获。最简单的方式就是将范围内的所有本地变量都进行捕获,使用[=]就可以完成这样的功能。函数被创建的时候,就能对本地变量的副本进行访问了。

实践一下,看一下下面的例子:

std::function<int(int)> make_offseter(int offset)
{
  return [=](int j){return offset+j;};
}

当调用make_offseter时,就会通过std::function<>函数包装返回一个新的lambda函数体。

这个带有返回的函数添加了对参数的偏移功能。例如:

int main()
{
  std::function<int(int)> offset_42=make_offseter(42);
  std::function<int(int)> offset_123=make_offseter(123);
  std::cout<<offset_42(12)<<”,“<<offset_123(12)<<std::endl;
  std::cout<<offset_42(12)<<”,“<<offset_123(12)<<std::endl;
}

屏幕上将打印出54,135两次,因为第一次从make_offseter中返回,都是对参数加42的;第二次调用后,make_offseter会对参数加上123。所以,会打印两次相同的值。

这种本地变量捕获的方式相当安全,所有的东西都进行了拷贝,所以可以通过lambda函数对表达式的值进行返回,并且可在原始函数之外的地方对其进行调用。这也不是唯一的选择,也可以通过选择通过引用的方式捕获本地变量。在本地变量被销毁的时候,lambda函数会出现未定义的行为。

下面的例子,就介绍一下怎么使用[&]对所有本地变量进行引用:

int main()
{
  int offset=42;  // 1
  std::function<int(int)> offset_a=[&](int j){return offset+j;};  // 2
  offset=123;  // 3
  std::function<int(int)> offset_b=[&](int j){return offset+j;};  // 4
  std::cout<<offset_a(12)<<”,”<<offset_b(12)<<std::endl;  // 5
  offset=99;  // 6
  std::cout<<offset_a(12)<<”,”<<offset_b(12)<<std::endl;  // 7
}

之前的例子中,使用[=]来对要偏移的变量进行拷贝,offset_a函数就是个使用[&]捕获offset的引用的例子②。所以,offset初始化成42也没什么关系①;offset_a(12)的例子通常会依赖与当前offset的值。在③上,offset的值会变为123,offset_b④函数将会使用到这个值,同样第二个函数也是使用引用的方式。

现在,第一行打印信息⑤,offset为123,所以输出为135,135。不过,第二行打印信息⑦就有所不同,offset变成99⑥,所以输出为111,111。offset_a和offset_b都对当前值进行了加12的操作。

尘归尘,土归土,C++还是C++;这些选项不会让你感觉到特别困惑,你可以选择以引用或拷贝的方式对变量进行捕获,并且你还可以通过调整中括号中的表达式,来对特定的变量进行显式捕获。如果想要拷贝所有变量,而非一两个,可以使用[=],通过参考中括号中的符号,对变量进行捕获。下面的例子将会打印出1239,因为i是拷贝进lambda函数中的,而j和k是通过引用的方式进行捕获的:

int main()
{
  int i=1234,j=5678,k=9;
  std::function<int()> f=[=,&j,&k]{return i+j+k;};
  i=1;
  j=2;
  k=3;
  std::cout<<f()<<std::endl;
}

或者,也可以通过默认引用方式对一些变量做引用,而对一些特别的变量进行拷贝。这种情况下,就要使用[&]与拷贝符号相结合的方式对列表中的变量进行拷贝捕获。下面的例子将打印出5688,因为i通过引用捕获,但j和k 通过拷贝捕获:

int main()
{
  int i=1234,j=5678,k=9;
  std::function<int()> f=[&,j,k]{return i+j+k;};
  i=1;
  j=2;
  k=3;
  std::cout<<f()<<std::endl;
}

如果你只想捕获某些变量,那么你可以忽略=或&,仅使用变量名进行捕获就行;加上&前缀,是将对应变量以引用的方式进行捕获,而非拷贝的方式。下面的例子将打印出5682,因为i和k是通过引用的范式获取的,而j是通过拷贝的方式:

int main()
{
  int i=1234,j=5678,k=9;
  std::function<int()> f=[&i,j,&k]{return i+j+k;};
  i=1;
  j=2;
  k=3;
  std::cout<<f()<<std::endl;
}

最后一种方式,是为了确保预期的变量能被捕获,在捕获列表中引用任何不存在的变量都会引起编译错误。当选择这种方式,就要小心类成员的访问方式,确定类中是否包含一个lambda函数的成员变量。类成员变量不能直接捕获,如果想通过lambda方式访问类中的成员,需要在捕获列表中添加this指针,以便捕获。下面的例子中,lambda捕获this后,就能访问到some_data类中的成员:

struct X
{
  int some_data;
  void foo(std::vector<int>& vec)
  {
    std::for_each(vec.begin(),vec.end(),
         [this](int& i){i+=some_data;});
  }
};

并发的上下文中,lambda是很有用的,其可以作为谓词放在std::condition_variable::wait()(见4.1.1节)和std::packaged_task<>(见4.2.1节)中;或是用在线程池中,对小任务进行打包。也可以线程函数的方式std::thread的构造函数(见2.1.1),以及作为一个并行算法实现,在parallel_for_each()(见8.5.1节)中使用。

A.6 变参模板

变参模板:就是可以使用不定数量的参数进行特化的模板。就像你接触到的变参函数一样,printf就接受可变参数。现在,就可以给你的模板指定不定数量的参数了。变参模板在整个C++线程库中都有使用,例如:std::thread的构造函数就是一个变参类模板。从使用者的角度看,仅知道模板可以接受无限个参数就够了,不过当要写这么一个模板或对其工作原理很感兴趣时,就需要了解一些细节。

和变参函数一样,变参部分可以在参数列表章使用省略号...代表,变参模板需要在参数列表中使用省略号:

template<typename ... ParameterPack>
class my_template
{};

即使主模板不是变参模板,模板进行部分特化的类中,也可以使用可变参数模板。例如,std::packaged_task<>(见4.2.1节)的主模板就是一个简单的模板,这个简单的模板只有一个参数:

template<typename FunctionType>
class packaged_task;

不过,并不是所有地方都这样定义;对于部分特化模板来说,其就像是一个“占位符”:

template<typename ReturnType,typename ... Args>
class packaged_task<ReturnType(Args...)>;

部分特化的类就包含实际定义的类;在第4章,可以写一个std::packaged_task<int(std::string,double)>来声明一个以std::string和double作为参数的任务,当执行这个任务后结果会由std::future<int>进行保存。

声明展示了两个变参模板的附加特性。第一个比较简单:普通模板参数(例如ReturnType)和可变模板参数(Args)可以同时声明。第二个特性,展示了Args...特化类的模板参数列表中如何使用,为了展示实例化模板中的Args的组成类型。实际上,因为这是部分特化,所以其作为一种模式进行匹配;在列表中出现的类型(被Args捕获)都会进行实例化。参数包(parameter pack)调用可变参数Args,并且使用Args...作为包的扩展。

和可变参函数一样,变参部分可能什么都没有,也可能有很多类型项。例如,std::packaged_task<my_class()>中ReturnType参数就是my_class,并且Args参数包是空的,不过std::packaged_task<void(int,double,my_class&,std::string*)>中,ReturnType为void,并且Args列表中的类型就有:int, double, my_class&和std::string*。

A.6.1 扩展参数包

变参模板主要依靠包括扩展功能,因为不能限制有更多的类型添加到模板参数中。首先,列表中的参数类型使用到的时候,可以使用包扩展,比如:需要给其他模板提供类型参数。

template<typename ... Params>
struct dummy
{
  std::tuple<Params...> data;
};

成员变量data是一个std::tuple<>实例,包含所有指定类型,所以dummy<int, double, char>的成员变量就为std::tuple<int, double, char>

可以将包扩展和普通类型相结合:

template<typename ... Params>
struct dummy2
{
  std::tuple<std::string,Params...> data;
};

这次,元组中添加了额外的(第一个)成员类型std::string。其优雅指出在于,可以通过包扩展的方式创建一种模式,这种模式会在之后将每个元素拷贝到扩展之中,可以使用...来表示扩展模式的结束。

例如,创建使用参数包来创建元组中所有的元素,不如在元组中创建指针,或使用std::unique_ptr<>指针,指向对应元素:

template<typename ... Params>
struct dummy3
{
  std::tuple<Params* ...> pointers;
  std::tuple<std::unique_ptr<Params> ...> unique_pointers;
};

类型表达式会比较复杂,提供的参数包是在类型表达式中产生,并且表达式中使用...作为扩展。当参数包已经扩展 ,包中的每一项都会代替对应的类型表达式,在结果列表中产生相应的数据项。因此,当参数包Params包含int,int,char类型,那么std::tuple<std::pair<std::unique_ptr<Params>,double> ... >将扩展为std::tuple<std::pair<std::unique_ptr<int>,double>,std::pair<std::unique_ptr<int>,double>,std::pair<std::unique_ptr<char>, double> >。如果包扩展被当做模板参数列表使用,那么模板就不需要变长的参数了;如果不需要了,参数包就要对模板参数的要求进行准确的匹配:

template<typename ... Types>
struct dummy4
{
  std::pair<Types...> data;
};
dummy4<int,char> a;  // 1 ok,为std::pair<int, char>
dummy4<int> b;  // 2 错误,无第二个类型
dummy4<int,int,int> c;  // 3 错误,类型太多

可以使用包扩展的方式,对函数的参数进行声明:

template<typename ... Args>
void foo(Args ... args);

这将会创建一个新参数包args,其是一组函数参数,而非一组类型,并且这里...也能像之前一样进行扩展。例如,可以在std::thread的构造函数中使用,使用右值引用的方式获取函数所有的参数(见A.1节):

template<typename CallableType,typename ... Args>
thread::thread(CallableType&& func,Args&& ... args);

函数参数包也可以用来调用其他函数,将制定包扩展成参数列表,匹配调用的函数。如同类型扩展一样,也可以使用某种模式对参数列表进行扩展。

例如,使用std::forward()以右值引用的方式来保存提供给函数的参数:

template<typename ... ArgTypes>
void bar(ArgTypes&& ... args)
{
  foo(std::forward<ArgTypes>(args)...);
}

注意一下这个例子,包扩展包括对类型包ArgTypes和函数参数包args的扩展,并且省略了其余的表达式。

当这样调用bar函数:

int i;
bar(i,3.141,std::string("hello "));

将会扩展为

template<>
void bar<int&,double,std::string>(
         int& args_1,
         double&& args_2,
         std::string&& args_3)
{
  foo(std::forward<int&>(args_1),
      std::forward<double>(args_2),
      std::forward<std::string>(args_3));
}

这样就将第一个参数以左值引用的形式,正确的传递给了foo函数,其他两个函数都是以右值引用的方式传入的。

最后一件事,参数包中使用sizeof...操作可以获取类型参数类型的大小,sizeof...(p)就是p参数包中所包含元素的个数。不管是类型参数包或函数参数包,结果都是一样的。这可能是唯一一次在使用参数包的时候,没有加省略号;这里的省略号是作为sizeof...操作的一部分,所以不算是用到省略号。

下面的函数会返回参数的数量:

template<typename ... Args>
unsigned count_args(Args ... args)
{
  return sizeof... (Args);
}

就像普通的sizeof操作一样,sizeof...的结果为常量表达式,所以其可以用来指定定义数组长度,等等。

A.7 自动推导变量类型

c++是静态语言:所有变量的类型,都会在编译时被准确指定。所以,作为程序员你需要为每个变量指定对应的类型。

有些时候就需要使用一些繁琐类型定义,比如:

std::map<std::string,std::unique_ptr<some_data>> m;
std::map<std::string,std::unique_ptr<some_data>>::iterator
      iter=m.find("my key");

常规的解决办法是使用typedef来缩短类型名的长度。这种方式在C++11中仍然可行,不过这里要介绍一种新的解决办法:如果一个变量需要通过一个已初始化的变量类型来为其做声明,那么就可以直接使用auto关键字。这样,编译器就会通过已初始化的变量,去自动推断变量的类型。

auto iter=m.find("my key");

当然,auto还有很多种用法:可以使用它来声明const、指针或引用变量。这里使用auto对相关类型进行了声明:

auto i=42; // int
auto& j=i; // int&
auto const k=i; // int const
auto* const p=&i; // int * const

变量类型的推导规则是建立一些语言规则基础上:函数模板参数。其声明形式如下:

some-type-expression-involving-auto var=some-expression;

var变量的类型与声明函数模板的参数的类型相同。要想替换auto,需要使用完整的类型参数:

template<typename T>
void f(type-expression var);
f(some-expression);

在使用auto的时候,数组类型将衰变为指针,引用将会被删除(除非将类型进行显式为引用),比如:

int some_array[45];
auto p=some_array; // int*
int& r=*p;
auto x=r; // int
auto& y=r; // int&

这样能大大简化变量的声明过程,特别是在类型标识符特别长,或不清楚具体类型的时候(例如,调用函数模板,等到的目标值类型就是不确定的)。

A.8 线程本地变量

线程本地变量允许程序中的每个线程都有一个独立的实例拷贝。可以使用thread_local关键字来对这样的变量进行声明。命名空间内的变量,静态成员变量,以及本地变量都可以声明成线程本地变量,为了在线程运行前对这些数据进行存储操作:

thread_local int x;  // 命名空间内的线程本地变量

class X
{
  static thread_local std::string s;  // 线程本地的静态成员变量
};
static thread_local std::string X::s;  // 这里需要添加X::s

void foo()
{
  thread_local std::vector<int> v;  // 一般线程本地变量
}

由命名空间或静态数据成员构成的线程本地变量,需要在线程单元对其进行使用进行构建。有些实现中,会将对线程本地变量的初始化过程,放在线程中去做;还有一些可能会在其他时间点做初始化,在一些有依赖的组合中,根据具体情况来进行决定。将没有构造好的线程本地变量传递给线程单元使用,不能保证它们会在线程中进行构造。这样就可以动态加载带有线程本地变量的模块——变量首先需要在一个给定的线程中进行构造,之后其他线程就可以通过动态加载模块对线程本地变量进行引用。

函数中声明的线程本地变量,需要使用一个给定线程进行初始化(通过第一波控制流将这些声明传递给指定线程)。如果函数没有被指定线程调用,那么这个函数中声明的线程本地变量就不会构造。本地静态变量也是同样的情况,除非其单独的应用于每一个线程。

静态变量与线程本地变量会共享一些属性——它们可以做进一步的初始化(比如,动态初始化);如果在构造线程本地变量时抛出异常,srd::terminate()就会将程序终止。

析构函数会在构造线程本地变量的那个线程返回时调用,析构顺序是构造的逆顺序。当初始化顺序没有指定时,确定析构函数和这些变量是否有相互依存关系就尤为重要了。当线程本地变量的析构函数抛出异常时,std::terminate()会被调用,将程序终止。

当线程调用std::exit()或从main()函数返回(等价于调用std::exit()作为main()的“返回值”)时,线程本地变量也会为了这个线程进行销毁。应用退出时还有线程在运行,对于这些线程来说,线程本地变量的析构函数就没有被调用。

虽然,线程本地变量在不同线程上有不同的地址,不过还是可以获取指向这些变量的一般指针。指针会在线程中,通过获取地址的方式,引用相应的对象。当引用被销毁的对象时,会出现未定义行为,所以在向其他线程传递线程本地变量指针时,就需要保证指向对象所在的线程结束后,不能对相应的指针进行解引用。

A.9 小结

本附录仅是摘录了部分C++11标准的新特性,因为这些特性和线程库之间有着良好的互动。其他的新特性,包括:静态断言(static_assert),强类型枚举(enum class),委托构造函数,Unicode码支持,模板别名,以及统一的初始化序列。对于新功能的详细描述已经超出了本书的范围;需要另外一本书来进行详细介绍。对标准改动的最好的概述可能就是由Bjarne Stroustrup编写的《C++11FAQ》[1], 其他C++的参考书籍也会在未来对C++11标准进行覆盖。

希望这里的简短介绍,能让你了解这些新功能和线程库之间的关系,并且在写多线程代码的时候能用到这些新功能。虽然,本附录为了新特性提供了足够简单的例子,不过这里还是一个简单的介绍,并非新功能的一份完整的参考或教程。如果想在你的代码中大量使用这些新功能,我建议去找相关权威的参考书或教程,了解更加详细的情况。


【1】 http://www.research.att.com/~bs/C++0xFAQ.html