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

Posted 人邮异步社区

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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,[]()->boolreturn 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;   //①

上面是一条声明语句,定义了变量v

以上是关于C++11精要:部分语言特性的主要内容,如果未能解决你的问题,请参考以下文章

Swift语言精要 - Operator(运算符重载)

RxJava开发精要4 – Observables过滤

Java 12 特性冻结,但原始字符串字面量特性被移除

C语言精要总结-内存地址对齐与struct大小判断篇

C语言精要总结-内存地址对齐与struct大小判断篇

C++:C++11新特性超详细版