C++11精要:部分语言特性

新的C++标准给我们带来的不仅是对并发的支持,还有许多新程序库和C++新特性。对于线程库和本书其他章节涉及的某些C++新特性,本附录给出了简要概览。

虽然这些特性都与并发功能没有直接关系(thread_local除外,见A.8节),但对多线程代码而言,它们既重要又有用。我们限定了附录的篇幅,只介绍必不可少的特性(如右值引用),它们可以简化代码,使之更易于理解。假使读者尚未熟识本附录的内容,就径直阅读采用了这些特性的代码,那么代码理解起来可能会比较吃力。一旦熟识本附录的内容后,所涉及的代码普遍会变得容易理解。随着C++11的推广,采用这些特性的代码也会越来越常见。

闲言少叙,我们从右值引用开始介绍。C++线程库中包含不少组件,如线程和锁等,其归属权只能为单一对象独占,为了便于在对象间转移归属权,线程库充分利用了右值引用的功能。

A.1 右值引用

如果读者曾经接触过C++编程,对引用就不会陌生。C++的引用准许我们为已存在的对象创建别名。若我们访问和修改新创建的引用,全都会直接作用到它指涉的对象本体上,例如:

int var=42;
int& ref=var;    ⇽---  ①创建名为ref的引用,指向的目标是变量var
ref=99;
assert(var==99);    ⇽---  ②向引用赋予新值,则本体变量的值亦随之更新

在C++11标准发布以前,只存在一种引用——左值引用(lvalue reference)。术语左值来自C语言,指可在赋值表达式等号左边出现的元素,包括具名对象、在栈数据段和堆数据段[1]上分配的对象[2]、其他对象的数据成员,或一切具有确定存储范围的数据项。术语右值同样来自C语言,指只能在赋值表达式等号右边出现的元素,如字面值[3]和临时变量。左值引用只可以绑定左值,而无法与右值绑定。譬如,因为42是右值,所以我们不能编写语句:

int& i=42;     ⇽---  ①无法编译

但这其实不尽然,我们一般都能将右值绑定到const左值引用上:

int const& i=42;

C++在初期阶段尚不具备右值引用的特性,而在现实中,代码却要向接受引用的函数传入临时变量,因而早期的C++标准破例特许了这种绑定方式。

这能让参数发生隐式转换,我们也得以写出如下代码:

void print(std::string const& s);
print("hello");    ⇽---  ①创建std::string类型的临时变量

C++11标准采纳了右值引用这一新特性,它只与右值绑定,而不绑定左值。另外,其声明不再仅仅带有一个“&”,而改为两个“&”。

int&& i=42;
int j=42;
int&& k=j;    ⇽---  ①编译失败

我们可以针对同名函数编写出两个重载版本,分别接收左、右值引用参数,由重载机制自行决断应该调用哪个,从而判定参数采用左值还是右值。这种处理[4]是实现移动语义的基础。

A.1.1 移动语义

右值往往是临时变量,故可以自由改变。假设我们预先知晓函数参数是右值,就能让其充当临时数据,或“窃用”它的内容而依然保持程序正确运行,那么我们只需移动右值参数而不必复制本体。按这种方式,如果数据结构体积巨大,而且需要动态分配内存,则能省去更多的内存操作,创造出许多优化的机会。考虑一个函数,它通过参数接收std::vector<int>容器并进行改动。为了不影响原始数据[5],我们需在函数中复制出副本以另行操作。

根据传统做法,函数应该按const左值引用的方式接收参数,并在内部复制出副本;

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

这个函数接收左值和右值[6]皆可,但都会强制进行复制。

若我们预知原始数据能随意改动,即可重载该函数,编写一个接收右值引用参数的版本,以此避免复制[7]

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

现在,我们再来考虑利用自定义类型的构造函数,窃用右值参数的内容直接充当新实例。考虑代码清单A.1中的类,它的默认构造函数申请一大块内存,而析构函数则释放之。

代码清单A.1 具备移动构造函数的类

class X
{
private:
    int* data;
public:
    X():
        data(new int[1000000])
    {}
    ~X()
    {
        delete [] data;
    }
    X(const X& other):    ⇽---  ①
        data(new int[1000000])
    {
        std::copy(other.data,other.data+1000000,data);
    }
    X(X&& other):    ⇽---  ②
        data(other.data)
    {
        other.data=nullptr;
    }
};

拷贝构造函数①的定义与我们的传统经验相符:新分配一块内存,并从源实例复制数据填充到其中。然而,本例还展示了新的构造函数,它按右值引用的方式接收源实例②,即移动构造函数。它复制data指针,将源实例的data指针改为空指针,从而节约了一大块内存,还省去了复制数据本体的时间。

就类X而言,实现移动构造函数仅仅是一项优化措施。但是,某些类却很有必要实现移动构造函数,强令它们实现拷贝构造函数反而不合理。以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());    ⇽---  ③正确,X()生成一个匿名临时对象,作为右值与右值引用绑定
X x;
do_stuff(x);    ⇽---  ④错误,具名对象x是左值,不能与右值引用绑定

移动语义在线程库中大量使用,既可以取代不合理的复制语义,又可以实现资源转移。另外,按代码逻辑流程,某些对象注定要销毁,但我们却想延展其所含的数据。若复制操作的开销大,就可以改用转移来进行优化。2.2 节曾举例,借助std::move()向新构建的线程转移std::unique_ptr<>实例;2.3节则再次向读者举例,在std::thread的实例之间转移线程归属权。

std::thread、std::unique_lock<>、std::future<>、std::promise<>和std::packaged_task<>等类无法复制,但它们都含有移动构造函数,可以在其实例之间转移关联的资源,也能按转移的方式充当函数返回值。std::string和std::vector<>仍然可以复制,并且这两个类也具备移动构造函数和移动赋值操作符,能凭借移动右值避免大量复制数据。

在C++标准库中,若某源对象显式移动到另一对象,那么源对象只会被销毁,或被重新赋值(复制赋值或移动赋值皆可,倾向于后者),除此之外不会发生其他任何操作。按照良好的编程实践,类需确保其不变量(见3.1节)的成立范围覆盖其“移出状态”(moved-from state)。如果std::thread的实例作为移动操作的数据源,一旦发生了移动,它就等效于按默认方式构造的线程实例[8]。再借std::string举例,假设它的实例作为数据源参与移动操作,在完成操作后,这一实例仍需保持某种合法、有效的状态,即便C++标准并未明确规定该状态的具体细节[9][10](例如其长度值,以及所含的字符内容)。

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

最后,但凡涉及函数模板,我们还要注意另一细节:假定函数的参数是右值引用,目标是模板参数,那么根据模板参数的自动类型推导机制,若我们给出左值作为函数参数,模板参数则会被推导为左值引用;若函数参数是右值,模板参数则会被推导为无修饰型别(plain unadorned type)的普通引用[11]。这听起来有点儿拗口,下面举例详细解说,考虑函数:

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&&,在本例的情形中会被解释成“引用的引用”,所以发生引用折叠(reference collapsing),编译器将它视为原有型别的普通引用[12]。这里,foo<int&>()的函数签名是“void foo<int&>(int& t);”。

利用该特性,同一个函数模板既能接收左值参数,又能接收右值参数。std::thread的构造函数正是如此(见2.1节和2.2节)。若我们以左值形式提供可调用对象作为参数,它即被复制到相应线程的内部存储空间;若我们以右值形式提供参数,则它会按移动方式传递。

A.2 删除函数

有时候,我们没理由准许某个类进行复制,类std::mutex就是最好的例证。若真能复制互斥,则副本的意义何在?类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_copies可以改写成:

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

新写法更清楚地表达了设计意图,其说明效力比原有代码更强。另外,假设我们试图在成员函数内复制类的实例,只要遵从新写法,就能让编译器给出更具说明意义的错误提示,还会令本来在链接时发生的错误提前至编译期。

若我们在实现某个类的时候,既删除拷贝构造函数和复制赋值操作符,又显式写出移动构造函数和移动赋值操作符,它便成了“只移型别”(move-only type),该特性与std::thread和std::unique_lock<>的相似。代码清单A.2展示了这种只移型别。

代码清单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));    ⇽---  ②正确,匹配移动构造函数

只移对象可以作为参数传入函数,也能充当函数返回值。然而,若要从某个左值移出数据,我们就必须使用std::move()或static_cast<T&&>显式表达该意图。

说明符“=delete”可修饰任何函数,而不局限于拷贝构造函数和赋值操作符,其可清楚注明目标函数无效。它还具备别的作用:如果某函数已声明为删除,却按普通方式参与重载解释(overload resolution)并且被选定,就会导致编译错误。利用这一特性,我们即能移除特定的重载版本。例如,假设某函数接收short型参数,那它也允许传入int值,进而将int值强制向下转换成short值。若要严格杜绝这种情况,我们可以编写一个传入int类型参数的重载,并将它声明为删除:

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

照此处理,如果以int值作为参数调用foo(),就会产生编译错误。因此,调用者只能先把给出的值全部显式转换成short型。

foo(42);    ⇽---  ①错误,接收int型参数的重载版本声明成删除
foo((short)42);    ⇽---  ②正确

A.3 默认函数

一旦将某函数标注为删除函数,我们就进行了显式声明:它不存在实现。但默认函数则完全相反:它们让我们得以明确指示编译器,按“默认”的实现方式生成目标函数。如果一个函数可以由编译器自动产生,那它才有资格被设为默认:默认构造函数[13]、析构函数、拷贝构造函数、移动构造函数、复制赋值操作符和移动赋值操作符等。

这么做所为何故?原因不外乎以下几点。

  • 借以改变函数的访问限制。按默认方式,编译器只会产生公有(public)函数。若想让它们变为受保护的(protected)函数或私有(private)函数,我们就必须手动实现。把它们声明为默认函数,即可指定编译器生成它们,还能改变其访问级别。
  • 充当说明注解。假设编译器产生的函数可满足所需,那么把它显式声明为“默认”将颇有得益:无论是我们自己还是别人,今后一看便知,该函数的自动生成正确贯彻了代码的设计意图。
  • 若编译器没有生成某目标函数,则可借“默认”说明符强制其生成。一般来说,仅当用户自定义构造函数不存在时,编译器才会生成默认构造函数,针对这种情形,添加“=default”修饰即可保证其生成出来。例如,尽管我们定义了自己的拷贝构造函数,但通过“声明为默认”的方式,依然会令编译器另外生成默认构造函数。
  • 令析构函数成为虚拟函数,并托付给编译器生成。
  • 强制拷贝构造函数遵从特定形式的声明,譬如,使之不接受const引用作为参数,而改为接受源对象的非const引用。
  • 编译器产生的函数具备某些特殊性质,一旦我们给出了自己的实现,这些性质将不复存在,但利用“默认”新特性即能保留它们并加以利用,细节留待稍后解说。

在函数声明后方添加“=delete”,它就成了删除函数。类似地,在目标函数声明后方添加“=default”,它则变为默认函数,例如:

class Y
{
private:
    Y() = default;    ⇽---  ①改变访问级别
public:
    Y(Y&) = default;    ⇽---  ②接受非const引用
    T& operator=(const Y&) = default;    ⇽---  ③声明成“默认”作为注解
protected:
    virtual ~Y() = default;     ⇽---  ④改变访问级别并加入“虚函数”性质
};

前文提过,在同一个类中,若将某些成员函数交由编译器实现,它们便会具备一定的特殊性质,但是让我们自定义实现,这些性质就会丧失。两种实现方式的最大差异是,编译器有可能生成平实函数[14]。据此我们得出一些结论,其中几项如下。

  • 如果某对象的拷贝构造函数、拷贝赋值操作符和析构函数都是平实函数,那它就可以通过memcpy()或memmove()复制。
  • constexpr函数(见附录A.4节)所用的字面值型别(literal type)必须具备平实构造函数、平实拷贝构造函数和平实析构函数。
  • 若要允许一个类能够被联合体(union)所包含,而后者已具备自定义的构造函数和析构函数,则这个类必须满足:其默认构造函数、拷贝构造函数、复制操作符和析构函数均为平实函数。
  • 假定某个类充当了类模板std::atomic<>的模板参数(见5.2.6节),那它应当带有平实拷贝赋值操作符,才可能提供该类型值的原子操作。

只在函数声明处加上“=default”,还不足以构成平实函数。仅当类作为一个整体满足全部其他要求,相关成员函数方可构成平实函数[15]。不过,一旦函数由用户自己动手显式编写而成,就肯定不是平实函数。

在同一个类中,某些特定的成员函数既能让编译器生成,又准许用户自行编写,我们继续分析两种实现方式的第二项差异:如果用户没有为某个类提供构造函数,那么它便得以充当聚合体[16],其初始化过程可依照聚合体初值(aggregate initializer)表达式完成。

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)就会发生默认初始化。假设对象具有静态生存期[17],它便会初始化为零值;否则该对象无从确定初值,除非另外赋予新值,但如果在此之前读取其值,就有可能引发未定义行为。

X x1;     ⇽---  ①x1.a的值尚未确定

有别于上例,如果类X的实例在初始化时显式调用了默认构造函数,成员a即初始化为0[18]

X x2=X();    ⇽---  ①x2.a==0必然成立

这个特殊性质还能扩展至基类及内部成员。假定某个类的默认构造函数由编译器产生,而它的每个数据成员与全部基类也同样如此,并且后面两者所含的成员都属于内建型别[19]。那么,这个最外层的类是否显式调用该默认构造函数,将决定其成员是否初始化为尚不确定的值,抑或发生零值初始化。

尽管上述规则既费解又容易出错,但它确有妙用。一旦我们手动实现默认构造函数,它就会丧失这个性质:要是指定了初值或显式地按默认方式构造,数据成员便肯定会进行初始化,否则初始化始终不会发生。

X::X():a(){}    ⇽---  ①a==0 必然成立
X::X():a(42){}    ⇽---  ②a==42 必然成立
X::X(){}    ⇽---  ③

假设类X的默认构造函数采纳本例③处的方式,略过成员a的初始化操作[20],那么对于类X的非静态实例,成员a不会被初始化。而如果类X的实例具有静态生存期,成员a即初始化成零值。它们完全相互独立,不存在重载,现实代码中只允许其中一条语句存在(任意一条语句),这里并列只是为了方便排版和印刷。

一般情况下,若我们自行编写出任何别的构造函数,编译器就不会再生成默认构造函数。如果我们依然要保留它,就得自己手动编写,但其初始化行为会失去上述特性。然而,将目标构造函数显式声明成“默认”,我们便可强制编译器生成默认构造函数,并且维持该性质。

X::X() = default;     ⇽---  ①默认初始化规则对成员a起作用

原子类型正是利用了这个性质(见5.2节)将自身的默认构造函数显式声明为“默认”。除去下列几种情况,原子类型的初值只能是未定义:它们具有静态生存期(因此静态初始化成零值);显式调用默认构造函数,以进行零值初始化;我们明确设定了初值。请注意,各种原子类型均具备一个构造函数,它们单独接受一个参数作为初值,而且它们都声明成constexpr函数,以准许静态初始化发生(见附录A.4节)。

A.4 常量表达式函数

整数字面值即为常量表达式(constant expression),如42。而简单的算术表达式也是常量表达式,如23*2−24。整型常量自身可依照常量表达式进行初始化,我们还能利用前者组成新的常量表达式:

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是常量表达式
  • 设定非类型模板参数(nontype template parameter)的值:
template<unsigned size>
struct test
{};
test<bounds> ia;    ⇽---  ①错误,界限bounds不是常量表达式
test<bounds2> ia2;    ⇽---  ②正确,界限bounds2是常量表达式
  • 在定义某个类时,充当静态常量整型数据成员的初始化表达式[21]
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};    ⇽---  ②动态初始化
  • 只要采用本例示范的静态初始化方式,即可避免初始化的先后次序问题,从而防止条件竞争(见3.3.1节)。

这些都不是新功能,我们遵从C++98标准也可以全部实现。不过 C++11 引入了constexpr关键字,扩充了常量表达式的构成形式。C++14 和C++17 进一步扩展了 constexpr关键字的功能,但其完整介绍并非本附录力所能及。

constexpr关键字的主要功能是充当函数限定符。假设某函数的参数和返回值都满足一定要求,且函数体足够简单,那它就可以声明为constexpr函数,进而在常量表达式中使用,例如:

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

在本例中,square()声明成了constexpr函数,而常量表达式可以设定数组界限,使之容纳25项数据。虽然constexpr函数能在常量表达式中使用,但是全部使用方式不会因此自动形成常量表达式。

int dummy=4;
int array[square(dummy)];    ⇽---  ①错误,dummy不是常量表达式

在本例中,变量dummy不是常量表达式①,故square(dummy)属于普通函数调用,无法充当常量表达式,因此不能用来设定数组界限。

A.4.1 constexpr关键字和用户定义型别

目前,所有范例都只涉及内建型别,如int。然而,在新的C++标准中,无论是哪种型别,只要满足要求并可以充当字面值类型[22],就允许它成为常量表达式。若某个类要被划分为字面值型别,则下列条件必须全部成立。

  • 它必须具有平实拷贝构造函数。
  • 它必须具有平实析构函数。
  • 它的非静态数据成员和基类都属于平实型别[23]
  • 它必须具备平实默认构造函数或常量表达式构造函数(若具备后者,则不得进行拷贝/移动构造)。

我们马上会介绍常量表达式构造函数。现在,我们先着重分析平实默认构造函数,以代码清单A.3中的类CX为例。

代码清单A.3 含有平实默认构造函数的类

class CX
{
private:
    int a;
    int b;
public:
    CX() = default;     ⇽---  ①
    CX(int a_, int b_):    ⇽---  ②
        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;
}

然而在C++11环境中,constexpr函数的用途仅限于此,即constexpr函数只能调用其他constexpr函数。C++14则放宽了限制,只要不在constexpr函数内部改动非局部变量,我们就几乎可以进行任意操作。有一种做法可以改进代码清单A.3的代码,即便在C++11中该做法同样有效,即为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    
    {
        return a;
    }
    constexpr int get_b()          
    {
        return b;
    }
    constexpr int foo()
    {
        return a+b;
    }
};

根据C++11标准,get_a()上的const现在成了多余的修饰①,因其限定作用已经为constexpr关键字所蕴含。同理,尽管get_b()略去了const修饰,可是它依然是const函数②。在C++14中,constexpr函数的功能有所扩充,它不再隐式蕴含const特性,故get_b()也不再是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函数完成任务将省去大量运算。一旦涉及用户自定义型别,常量表达式和constexpr函数带来的主要好处是:若依照常量表达式初始化字面值型别的对象,就会发生静态初始化,从而避免初始化的条件竞争和次序问题:

CX si=half_double(CX(42,19));     ⇽---   ①静态初始化

构造函数同样遵守这条规则。假定构造函数声明成了constexpr函数,且它的参数都是常量表达式,那么所属的类就会进行常量初始化[24],该初始化行为会在程序的静态初始化[25]阶段发生。随着并发特性的引入,C++11为此规定了以上行为模式,这是标准的最重要一项修订:让用户自定义的构造函数担负起静态初始化工作,而在运行任何其他代码之前,静态初始化肯定已经完成,我们遂能避免任何牵涉初始化的条件竞争。

std::mutex类和std::atomic<>类(见3.2.1节和5.2.6节)的作用是同步某些变量的访问,从而避免条件竞争,它们的功能可能要靠全局实例来实现,并且不少类的使用方式也与之相似,故上述行为特性对这些类的意义尤为重要。若std::mutex类的构造函数受条件竞争所累,其全局实例就无法发挥功效,因此我们将它的默认构造函数声明成constexpr函数,以确保其初始化总是在静态初始化阶段内完成。

A.4.2 constexpr对象

目前,我们已学习了关键字constexpr对函数的作用,它还能作用在对象上,主要目的是分析和诊断:constexpr限定符会查验对象的初始化行为,核实其所依照的初值是常量表达式、constexpr构造函数,或由常量表达式构成的聚合体初始化表达式。它还将对象声明为const常量。

constexpr int i=45;     ⇽---  ①正确
constexpr std::string s("hello");    ⇽---  ②错误,std::string不是字面值型别
int foo();
constexpr int j=foo();    ⇽---  ③错误,foo()并未声明为constexpr函数

A.4.3 constexpr函数要符合的条件

若要把一个函数声明为constexpr函数,那么它必须满足一些条件,否则就会产生编译错误。C++11标准对constexpr函数的要求如下。

  • 所有参数都必须是字面值型别。
  • 返回值必须是字面值型别。
  • 整个函数体只有一条return语句。
  • return语句返回的表达式必须是常量表达式。
  • 若return返回的表达式需要转换为某目标型别的值,涉及的构造函数或转换操作符必须是constexpr函数。

这些要求不难理解。constexpr函数必须能够嵌入常量表达式中,而嵌入的结果依然是常量表达式。另外,我们不得改动任何值。constexpr函数是纯函数(见4.4.1节),没有副作用。

C++14标准大幅度放宽了要求,虽然总体思想保持不变,即constexpr函数仍是纯函数,不产生副作用,但其函数体能够包含的内容显著增加。

  • 准许存在多条return语句。
  • 函数中创建的对象可被修改。
  • 可以使用循环、条件分支和switch语句。

类所具有的constexpr成员函数则需符合更多要求。

  • constexpr成员函数不能是虚函数。
  • constexpr成员函数所属的类必须是字面值型别。

constexpr构造函数需遵守不同的规则。

  • 在C++11环境下,构造函数的函数体必须为空。而根据C++14和后来的标准,它必须满足其他要求才可以成为constexpr函数。
  • 必须初始化每一个基类。
  • 必须初始化全体非静态数据成员。
  • 在成员初始化列表中,每个表达式都必须是常量表达式。
  • 若数据成员和基类分别调用自身的构造函数进行初始化,则它们所选取[26]执行的必须是constexpr构造函数。
  • 假设在构造数据成员和基类时,所依照的初始化表达式为进行类型转换而调用了相关的构造函数或转换操作符,那么执行的必须是constexpr函数。

这些规则与普通constexpr函数的规则一致,区别是构造函数没有返回值,不存在return语句。然而,构造函数还附带成员初始化列表,通过该列表初始化其中的全部基类和数据成员。平实拷贝构造函数是隐式的constexpr函数。

A.4.4 constexpr与模板

如果函数模板或类模板的成员函数加上constexpr修饰,而在模板的某个特定的具现化中,其参数和返回值却不属于字面值型别,则constexpr关键字会被忽略。该特性让我们可以写出一种函数模板,若选取了恰当的模板参数型别,它就具现化为constexpr函数,否则就具现化为普通的inline函数,例如:

template<typename T>
constexpr T sum(T a,T b)
{
    return a+b;
}
constexpr int i=sum(3,42);    ⇽---  ①正确,sum<int>具有constexpr特性
std::string s=sum(std::string("hello"),
    std::string(" world"));    ⇽---  ②正确,但sum<std::string>不具备constexpr特性

具现化的函数模板必须满足前文的全部要求,才可以成为constexpr函数。即便是函数模板,一旦它含有多条语句,我们就不能用关键字constexpr修饰其声明;这仍将导致编译错误[27]

A.5 lambda函数

lambda函数是C++11标准中一个激动人心的特性,因为它有可能大幅度简化代码,并消除因编写可调用对象而产生的公式化代码。lambda函数由C++11的新语法引入。据此,若某表达式需要一个函数,则可以等到所需之处才进行定义。std::condition_variable类具有几个等待函数,它们要求调用者提供断言(见4.1.1节范例),因此上述机制在这类场景中派上了大用场,因为lambda函数能访问外部调用语境中的本地变量,从而便捷地表达出自身语义,而无须另行设计带有函数调用操作符的类,再借成员变量捕获必需的状态。 

最简单的lambda表达式定义出一个自含的函数(self-contained function),该函数不接收参数,只依赖全局变量和全局函数,甚至没有返回值。该lambda表达式包含一系列语句,由一对花括号标识,并以方括号作为前缀(空的lambda引导符):

[]{    ⇽---  
    do_stuff();    ⇽---  ①lambda表达式从[]开始
    do_more_stuff();    ⇽---  
}();    ⇽---  ②lambda表达式结束,并被调用

在本例中,lambda表达式后附有一对圆括号,由它直接调用了这个lambda函数,但这种做法不太常见。如果真要直接调用某lambda函数,我们往往会舍弃其函数形式,而在调用之处原样写出它所含的语句。在传统泛型编程中,某些函数模板通过过参数接收可调用对象,而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函数的函数体仅有一条返回语句,那么lambda函数的返回值型别就是表达式的型别。以代码清单A.4为例,我们运用简易的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;});    ⇽---  ①
}

①处有一个lambda函数传入cond.wait(),其返回值型别根据变量data_ready的型别推导得出,即布尔值。一旦该条件变量从等待中被唤醒,它就继续保持互斥m的锁定状态,并且调用lambda函数,只有充当返回值的变量data_ready为true时,wait()调用才会结束并返回。

假若lambda函数的函数体无法仅用一条return语句写成,那该怎么办呢?这时就需要明确设定返回值型别。假若lambda函数的函数体只有一条return语句,我们就可自行选择是否显式设定返回值型别。假若函数体比较复杂,那该怎么办呢?就要明确设定返回值型别。设定返回值型别的方法是在lambda函数的参数列表后附上箭头(→)和目标型别。如果lambda函数不接收任何参数,而返回值型别却需显式设定,我们依然必须使之包含空参数列表,代码清单A.4条件变量所涉及的断言可写成:

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

只要指明了返回值型别,我们便可扩展lambda函数的功能,以记录信息或进行更复杂的处理:

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函数,尽管它具备强大的功能,可以在很大程度上简化代码,但lambda函数的真正厉害之处在于捕获本地变量。

涉及本地变量的lambda函数

如果lambda函数的引导符为空的方括号,那么它就无法指涉自身所在的作用域中的本地变量,但能使用全局变量和通过参数传入的任何变量。若想访问本地变量,则需先捕获之。要捕获本地作用域内的全体变量,最简单的方式是改用lambda引导符“[=]”。改用该引导符的lambda函数从创建开始,即可访问本地变量的副本。

我们来考察下面的简单函数,以分析实际效果:

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

每当make_offseter()被调用,它都会产生一个新的lambda函数对象,包装成std::function<>形式的函数而返回。该函数在自身生成之际预设好一个偏移量,在执行时再接收一个参数,进而计算并返回两者之和。例如:

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函数脱离生成函数或所属代码块的作用域,引用的变量即被销毁,若仍然调用lambda函数,就会导致未定义行为。其实在任何情况下,引用已销毁的变量都属于未定义行为,lambda函数也不例外。

下面的代码展示了一个lambda函数,它以“[&]”作为引导符,按引用的形式捕获每个本地变量:

int main()
{
    int offset=42;    ⇽---  ①
    std::function<int(int)> offset_a=[&](int j){return offset+j;};    ⇽---  ②
    offset=123;     ⇽---  ③
    std::function<int(int)> offset_b=[&](int j){return offset+j;};     ⇽---  ④
    std::cout<<offset_a(12)<<","<<offset_b(12)<<std::endl;     ⇽---  ⑤
    offset=99;     ⇽---  ⑥
    std::cout<<offset_a(12)<<","<<offset_b(12)<<std::endl;    ⇽---  ⑦
}

在前面的范例中,make_offseter()函数生成的lambda函数采用“[=]”作为引导符,它捕获的是偏移量offset的副本。然而,本例中的offset_a()函数使用的lambda引导符是“[&]”,通过引用捕获偏移量offset②。偏移量offset的初值为42①,但这无足轻重。offset_a(12)的调用结果总是依赖于偏移量offset的当前值。偏移量offset的值随后变成了123③。接着,代码生成了第二个lambda函数offset_b()④,它也按引用方式捕获本地变量,故其运行结果亦依赖于偏移量offset的值。

在输出第一行内容的时候⑤,偏移量offset仍是123,故输出是“135,135”。不过,在输出第二行内容之前⑦,偏移量offset已变成了99⑥,所以这次的输出是“111,111”。offset_a()函数和offset_b()函数的功效相同,都是计算偏移量offset的当前值(99)与调用时提供的参数(12)的和。

上面两种做法对所有变量一视同仁,但lambda函数的功能不限于此,因为灵活、通达毕竟是C++与生俱来的特质:我们可以分而治之,自行选择按复制和引用两种方式捕获不同的变量。另外,通过调整lambda引导符,我们还能显式选定仅仅捕获某些变量。若想按复制方式捕获全部本地变量,却针对其中一两个变量采取引用方式捕获,则应该使用形如“[=]”的lambda引导符,而在等号后面逐一列出引用的变量,并为它们添加前缀“&”。下面的lambda函数将变量i复制到其内,但通过引用捕获变量j和k,因此该范例会输出1239:

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;
}

还有另一种做法:我们可将按引用捕获设定成默认行为,但以复制方式捕获某些特定变量。这种处理方法使用形如“[&]”的lambda引导符,并在“&”后面逐一列出需要复制的变量。下面的lambda函数以引用形式捕获变量i,而将变量j和k复制到其内,故该范例会输出5688:

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;
}

若我们仅仅想要某几个具体变量,并按引用方式捕获,而非复制,就应该略去上述最开始的等号或“&”,且逐一列出目标变量,再为它们加上“&”前缀。下列代码中,变量i和k通过引用方式捕获,而变量j则通过复制方式捕获,故输出结果将是5682。

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函数却位于一个类的某成员函数内部,那么我们在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节)、在线程池中包装小任务(见9.1节)等。它还能作为参数传入std::thread类的构造函数,以充当线程函数(见2.1.1节),或在使用并行算法时(如8.5.1节示范的parallel_for_each())充当任务函数。

从C++14开始,lambda函数也能有泛型形式,其中的参数型别被声明成auto,而非具体型别。这么一来,lambda函数的调用操作符就是隐式模板,参数型别根据运行时外部提供的参数推导得出,例如:

auto f=[](auto x){ std::cout<<"x="<<x<<std::endl;};
f(42); // 属于整型变量,输出“x=42”
f("hello"); //  x的型别属于const char*,输出“x=hello”

C++14还加入了广义捕获(generalized capture)的概念,我们因此能够捕获表达式的运算结果,而不再限于直接复制或引用本地变量。该特性最常用于以移动方式捕获只移型别,从而避免以引用方式捕获,例如:

std::future<int> spawn_async_task(){
    std::promise<int> p;
    auto f=p.get_future();
    std::thread t([p=std::move(p)](){ p.set_value(find_the_answer());});
    t.detach();
    return f;
}

这里的p=std::move(p)就是广义捕获行为,它将promise实例移入lambda函数,因此线程可以安全地分离,我们不必担心本地变量被销毁而形成悬空引用。Lambda函数完成构建后,原来的实例p即进入“移出状态”(见A.1节),因此,我们事先从它取得了关联的future实例。

A.6 变参模板

变参模板即参数数目可变的模板。变参函数接收的参数数目可变,如printf(),我们对此耳熟能详。而现在C++11引入了变参模板,它接收的模板参数数目可变。C++线程库中变参模板无处不在。例如,std::thread类的构造函数能够启动线程(见2.1.1节),它就是变参函数模板,而std::packaged_task<>则是变参类模板(见4.2.1节)。从使用者的角度来说,只要了解变参模板可接收无限量的参数[28],就已经足够。但若我们想编写这种模板,或关心它到底如何运作,还需知晓细节。

我们声明变参函数时,需令函数参数列表包含一个省略号(...)。变参模板与之相同,在其声明中,模板参数列表也需带有省略号:

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

对于某个模板,即便其泛化版本的参数固定不变,我们也能用变参模板进行偏特化。譬如,std::packaged_task<>的泛化版本只是一个简单模板,具有唯一一个模板参数:

template<typename FunctionType>      //此处的FunctionType没有实际作用
class packaged_task;        //泛化的packaged_task声明,并无实际作用

但任何代码都没给出该泛化版本的定义,它的存在是为偏特化模板[29]充当“占位符”。

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)能在同一声明内共存。所示的第二个特性是,在packaged_task的特化版本中,其模板参数列表使用了组合标记“Args...”,当模板具现化时,Args所含的各种型别均据此列出。这是个偏特化版本,因而它会进行模式匹配:在模板实例化的上下文中,出现的型别被全体捕获并打包成Args。该可变参数Args叫作参数包(parameter pack),应用“Args...”还原参数列表则称为包展开(pack expansion)[30]

变参模板中的变参列表可能为空,也可能含有多个参数,这与变参函数相同。例如,模板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*共同构成。

展开参数包

变参模板之所以强大,是因为展开式能够物尽其用,不局限于原本的模板参数列表中的型别展开。首先,在任何需要模板型别列表的场合,我们均可以直接运用展开式,例如,在另一个模板的参数列表中展开:

template<typename ...Params>
struct dummy
{
    std::tuple<Params...> data;//tuple元组由C++11引入,与std::pair相似,但可含有多个元素
};

本例中,成员变量data是std::tuple<>的具现化,其内含型别全部根据上下文设定,因此dummy<int,double,char>拥有一个数据成员data,它的型别是std::tuple<int,double, char>。展开式能够与普通型别结合:

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

这次,tuple元组新增了一个成员(位列第一),型别是std::string。展开式大有妙用:我们可以创建某种展开模式,在随后展开参数包时,针对参数包内各元素逐一复制该模式。具体做法是,在该模式末尾加上省略号标记,表明依据参数包展开。上面的两个范例中,dummy类模板的参数包直接展开,其中的成员tuple元组据此实例化,所含的元素只能是参数包内的各种型别。然而,我们可以依照某种模式创建元组,使得其中的成员型别都是普通指针,甚至都是std::unique_ptr<>指针,其目标型别对应参数包中的元素。

template<typename ...Params>// ①[31]
struct dummy3
{
    std::tuple<Params* ...> pointers;// ②
    std::tuple<std::unique_ptr<Params> ...>unique_pointers;// ③
};

展开模式可随意设定成复杂的型别表达式,前提是参数包在型别表达式中出现,并且该表达式以省略号结尾,表明可依据参数包展开。

一旦参数包展开成多项具体型别,便会逐一代入型别表达式,分别生成多个对应项,最后组成结果列表。

若参数包含有3个型别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;    ⇽---  ①正确,data的型别为std::pair<int,char>
dummy4<int> b;    ⇽---  ②错误,缺少第二项型别
dummy4<int,int,int> c;    ⇽---  ③错误,型别数目过量

展开式的另一种用途是声明函数参数:

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,编译器便会自动推导,判定该变量所属型别与初始化表达式是否相同。上面的迭代器示例可以写成:

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

这只是关键字auto的最普通的一种用法,我们不应止步于此,我们还能让它修饰常量、指针、引用的声明。下面的代码用auto声明了几个变量,并注释出对应型别:

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

在C++环境下,只有另一个地方也发生型别推导:函数模板的参数。变量的型别推导沿袭了其中的规则:

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

上面是一条声明语句,定义了变量var并赋予了初值。其中,等号左边是个涉及关键字auto的型别表达式[32]。再对比下面的函数模板,它也用相同的型别表达式声明参数,只不过将auto改换成了模板的型别参数的名字。那么,上例的变量var与下例的参数var同属一种型别。

template<typename T>          //这一条语句与下一条语句是同一条语句的拆分写法
void f(type-expression var);    //这条语句声明了一个函数模板
f(some-expression);             //这条语句是一个函数调用

这使数组型别退化为指针,而且引用被略去,除非型别表达式将变量显式声明为引用,例如:

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

变量的声明因此简化。如果完整的型别标识符过分冗长,甚至无从得知目标型别(如模板内部的函数调用的结果型别),效果就特别明显。

A.8 线程局部变量

在程序中,若将变量声明为线程局部变量,则每个线程上都会存在其独立实例。在声明变量时,只要加入关键字thread_local标记,它即成为线程局部变量。有3种数据能声明为线程局部变量:以名字空间为作用域的变量、类的静态数据成员和普通的局部变量。换言之,它们具有线程存储生存期(thread storage duration):

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;    ⇽---  普通的局部变量,也是线程局部变量
}

对于同一个翻译单元[33]内的线程局部变量,假如它是类的静态数据成员,或以名字空间为作用域,那么在其初次使用之前应完成构造,但C++标准没有明确规定构造行为的具体提前量。线程局部变量的构造时机因不同编译器而异,某部分实现选择的是线程启动之际,某部分实现却就每个线程分别处理,选择的是该变量初次使用的前一刻,其他实现则设定别的时间点,或根据使用场景灵活调整。实际上,在给定的翻译单元中,若所有线程局部变量从未被使用,就无法保证会把它们构造出来。这使得含有线程局部变量的模块得以动态加载,当给定线程初次指涉模块中的线程局部变量时,才进行动态加载,进而构造变量。

对于函数内部声明的线程局部变量,在某个给定的线程上,当控制流程第一次经过其声明语句时,该变量就会初始化。假设某函数在给定的线程上从来都没有被调用,函数中却声明了线程局部变量,那么在该线程上它们均不会发生构造。这一行为规则与静态局部变量相同,但它对每个线程都单独起作用。

线程局部变量的其他性质与静态变量一致,它们先进行零值初始化,再进行其他变量初始化(如动态初始化[34])。如果线程局部变量的构造函数抛出异常,程序就会调用std::terminate()而完全终止。

给定一个线程,在其线程函数返回之际,该线程上构造的线程局部变量全都会发生析构,它们调用析构函数的次序与调用构造函数的次序相反。由于这些变量的初始化次序并不明确,因此必须保证它们的析构函数间没有相互依赖。若线程局部变量的析构函数因抛出异常而退出,程序则会调用std::terminate(),与构造函数的情形一样。

如果线程通过调用std::exit()退出,或从main()自然退出(这等价于先取得main()的返回值,再以该值调用std::exit()),那么线程局部变量也会被销毁。当应用程序退出时,如果有其他线程还在运行,则那些线程上的线程局部变量不会发生析构。

线程局部变量的地址因不同线程而异,但我们依然可以令一个普通指针指向该变量。假定该指针的值源于某线程所执行的取址操作,那么它指涉的目标对象就位于该线程上,其他线程也能通过这一指针访问那个对象。若线程在对象销毁后还试图访问它,将导致未定义行为(向来如此)。所以,若我们向另一个线程传递指针,其目标是线程局部变量,那就需要确保在目标变量所属的线程结束后,该指针不会再被提取。

A.9 类模板的参数推导

C++17拓展了模板参数的自动推导型别的思想:如果我们通过模板声明一个对象,那么在大多情况下,根据该对象的初始化表达式,能推导出模板参数的型别。

具体来说,若仅凭某个类模板的名字声明对象,却未设定模板参数列表,编译器就会根据对象的初始化表达式,指定调用类模板的某个构造函数,还借以推导模板参数,而函数模板也将发生普通的型别推导,这两个推导机制遵守相同的规则。

例如,类模板std::lock_guard<>单独接收一个模板参数,其型别是某种互斥类,该类模板的构造函数也接收唯一一个参数,它是个引用,所引用的目标对象的型别与模板参数对应。如果我们以类模板std::lock_guard<>声明一个对象,并提供一个互斥用于初始化,那么模板的型别参数就能根据互斥的型别推导出。

std::mutex m;
std::lock_guard guard(m); // 将推导出 std::lock_guard<std::mutex>

相同的推导机制也适用于std::scoped_lock<>,只不过它具有多个模板参数,可以根据多个互斥参数推导出。

std::mutex m1;
std::shared_mutex m2;
std::scoped_lock guard(m1,m2);  //将推导出std::scoped_lock<std::mutex,std:: shared_mutex>

某些模板的构造函数尚未完美契合推导机制,有可能推导出错误的型别。这些模板的作者可以明确编写推导指南,以保证推导出正确的型别。但这些议题超出了本书的范畴。

A.10 小结

C++11标准为语言增加了不少新特性,本附录仅触及皮毛,因为我们只着眼于有效推动线程库演进的功能。C++11增加的新特性包括静态断言(static assertion/static_assert)、强类型枚举(strongly typed enumeration/enum class)、委托构造(delegating constructor)函数、Unicode编码支持、模板别名(template alias)和新式的统一初始化列表(uniform initialization sequence),以及许多相对细小的改变。本书的主旨并非详细讲解全部新特性,因为那很可能需要单独编写一本专著。C++14和C++17增加的新特性也不少,但这些新特性也超出了本书的范畴。截至本书编写的时候,有两份资料完整涵盖了标准所做的改动,分别是网站cppreference整理的文档和Bjarne Stroustrup编撰的C++11 FAQ,它们几乎可以说是C++新特性的最佳概览。还有不少C++参考书籍广受欢迎,相信它们也会与时俱进,不断修订和更新。

本附录涵盖了部分C++新特性,希望它的深度足以充分展现这些新特性,演示出它们在线程库中如何大展拳脚,两者是如何密切关联,也希望通过以上简介,读者能够理解并运用新特性的多线程代码,进而举一反三,借助这些特性编写多线程程序。本附录按一定深度讲解了C++新特性,应该足够让读者简单地学以致用。虽说如此,毕竟这只是一份简介,而非针对新特性的完整参考材料或自学教材。如果读者有意大量使用C++新特性,我们建议购买专门的参考材料或自学教材,从而获取事半功倍的学习效果。


[1] 译者注:这里的栈和堆都指可执行程序的内存空间的某些特定部分。抽象数据结构中也有同名的概念,但它们的含义与这里所提的不同。STL库还提供了栈容器,它也有别于于此处的栈。

[2] 译者注:这里的对象特指语言层面的数据实例(由C++标准文件给出定义),不同于“面向对象编程”中的抽象概念的对象,详见5.1.1节。

[3] 译者注:字面值即literal,是代码中明文写出的具体数值,如“double a=1.6;”中的1.6,或下例中的42。

[4] 译者注:实际上,单凭右值传参即可独立实现移动语义。函数重载是为了兼容旧代码,某些类尚不支持移动行为,依旧按传统的左值形式传递参数。

[5] 译者注:本例要求维持数据不变,但后文作为对照的相关范例却假定准许修改数据,显然有失严谨。原书着眼于移动语义的实现方法和性能优势,而忽略了其具体前提假设和实际功能需求。尽管如此,这只是在需求层面出现的前后不一,并不妨碍移动语义本身的实现和使用。

[6] 译者注:此处的右值特指前文的绑定常量的引用,而非C++11新特性的右值引用。

[7] 译者注:这里为了讲解移动语义,刻意采用右值引用传参,但实际上,按传统的非const左值引用传参也能避免复制(直接引用原始数据,并不采用移动语义)。

[8] 译者注:按默认方式构造的std::thread对象不含实际数据,也不管控或关联任何线程,请参考2.3节。

[9] 译者注:指移动操作在源对象上实际产生的效果。对于std::string类,C++标准仅要求移动操作在常数复杂度的时间内完成,却没有规定源数据上的实际效用如何。另外请注意,移动语义可能通过不同方式实现,不一定真正窃取数据,也不一定搬空源对象,请参考Effective Modern C++中的条款29。

[10] 译者注:2020年3月,原书作者在自己的网站上发表了一篇技术博客,详尽解释了类的不变量,还阐述了它与移动语义的关联,更深入分析了不变量在并发环境中种种情况下的破与立,是本书的重要补充,感兴趣的读者可自行查阅。

[11] 译者注:这一特性又称“万能引用”(universal reference),深入分析见Effective Modern C++中的条款24。

[12] 译者注:左值的多重引用会引发折叠,请参阅Effective Modern C++中的条款28。

[13] 译者注:“默认构造函数”特指不接收任何参数的构造函数(或参数全都具备默认值)。该术语在C++11之前已长久存在,原意强调“根据规则自然成为默认”。本节的“=default”意指“按设计意图人为指定成默认”,请注意区分。

[14] 译者注:平实函数即trivial function,其现实意义是默认构造函数和析构函数不执行任何操作;复制、赋值和移动操作仅仅涉及最简单、直接的按位进行内存复制/内存转移,而没有任何其他行为;若对象所含的默认函数全是平实函数,就可依照Plain Old Data(POD)方式进行处理。

[15] 译者注:若要成为平实函数,函数自身及所属的类都应符合一定条件,具体条件因各个函数而异,详见C++官方标准文件ISO/IEC 14882:2011,12.1节第5款、12.4节第5款、12.8节第12款和第25款。

[16] 译者注:聚合体即aggregate,是C++11引入的概念,它通常可以是数组、联合体、结构体或类(不得含有虚函数或自定义的构造函数,亦不得继承自父类的构造函数,还要服从其他限制),其涵盖范围随C++标准的演化而正在扩大。

[17] 译者注:静态生存期(static storage duration)指某些对象随整个程序开始而获得存储空间,到程序结束空间才被回收,这些对象包括静态局部变量、静态数据成员、全局变量等。

[18] 译者注:在本例中,等号右侧先由默认构造函数生成一个临时变量,左侧再根据该变量创建变量x2并初始化(根据前面类型X的定义,变量x2按复制方式构造而成)。无论编译选项是否采用任何优化设置,编译器都会照此处理,不会发生赋值行为。详见《C++程序设计语言第四版》16.2.6节。

[19] 译者注:更精确地说,这个性质按递归方式扩展,即基类的基类、成员的基类、基类的成员、基类的基类的成员、成员的成员的成员等均必须满足要求。换言之,继承关系与包含关系中的元素要全部符合条件:它们或属于内建型别,或由编译器生成默认构造函数。

[20] 译者注:本例的代码是3个自定义默认构造函数,3个花括号是它们的函数体(内空,未进行任何操作),而非初始化列表。

[21] 译者注:本例主旨在于示范常量表达式,但它还牵涉另一特殊之处:静态数据成员the_answer由表达式forty_two初始化,所在的语句既是声明又是定义。作为静态数据成员,其只许枚举值和整型常量在类定义内部直接定义,而任意其他类型仅能声明,且必须在类定义外部给出定义(参考下一个范例)。详见C++官方标准文件ISO/IEC 14882:2011,9.4.2节。

[22] 译者注:字面值类型是C++11引入的新概念,是某些型别的集合,请注意与字面值区分,其是在代码中明确写出的值。

[23] 译者注:平实型别即trivial type,指默认构造函数、拷贝/移动构造函数、拷贝/移动赋值操作符、析构函数全都属于平实函数的类型,参考A.3节。

[24] 译者注:常量初始化即constant initialization。在实践中,常量往往在编译期就完成计算,在运行期直接套用算好的值。

[25] 译者注:静态初始化是指全局变量、静态变量等在程序整体运行前完成初始化。

[26] 译者注:指存在多个构造函数重载的情形。

[27] 译者注:此处特指C++11的情形。在C++14中,constexpr()函数模板可以合法含有多条语句,前提是符合前文所列要求。

[28] 译者注:虽然C++标准的正文部分确实如此规定,但出于现实考虑(计算机资源毕竟有限),C++标准的附录建议模板参数数目的最低上限为1024。各编译器可能自行按其他限制给出实现,譬如微软Visual C++ 2015的模板参数最多为2046个。详见官方标准文件ISO/IEC 14882-2011附录B。

[29] 译者注:按C++语法,任何模板都必须具备泛化形式的声明,不能只以偏特化形式进行声明。尽管泛化的packaged_task没有实际作用,但作为声明它不得省略,用途是告诉编译器程序中存在名为packaged_task的模板。然而该泛化版本所含信息不足,无从确定模板的具体性质,而它的偏特化版本却可以胜任,故详细定义由后者负责。

[30] 译者注:后文中,包展开多指形如“Args...”的组合标记,译为“展开式”以便理解。本节还有几处出现“展开式结合模式”(pattern with the pack expansion),简称为“展开模式”。请读者注意区别。

[31] 译者注:请注意对比①与②③处省略号的不同位置。①处省略号是变参模板声明的语法成分,表示型别参数的数目可变,②③两处的省略号标示出展开模式。②处的模式是型别表达式Params*,而③处的模式则是型别表达式std::unique_ptr<Params>。

[32] 译者注:some-type-expression-involving-auto意为“涉及关键字auto的某种型别表达式”,指auto、auto&、auto const或auto* const等,见前一个关于变量i、j、k、p的范例,而some-expression意为“某种表达式”。

[33] 译者注:翻译单元即translation unit,是有关C++代码编译的术语,指当前代码所在的源文件,以及经过预处理后,全部有效包含的头文件和其他源文件。详见C++官方标准文件ISO/IEC 14882:2011,2.1节。

[34] 译者注:动态初始化即dynamic initialization,指除非静态初始化(指零值初始化和常量初始化)以外的一切初始化行为。

本文摘自:《C++并发编程实战(第2版)》

这是一本介绍C++并发和多线程编程的深度指南。本书从C++标准程序库的各种工具讲起,介绍线程管控、在线程间共享数据、并发操作的同步、C++内存模型和原子操作等内容。同时,本书还介绍基于锁的并发数据结构、无锁数据结构、并发代码,以及高级线程管理、并行算法函数、多线程应用的测试和除错。本书还通过附录及线上资源提供丰富的补充资料,以帮助读者更完整、细致地掌握C++并发编程的知识脉络。

本书适合需要深入了解C++多线程开发的读者,以及使用C++进行各类软件开发的开发人员、测试人员,还可以作为C++线程库的参考工具书。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值