注:本博客内容大多引用自原书,这里只做部分归纳和总结。其中,每个标题对应每章的一个小节。本篇博客仅用作学习用途 ,推荐阅读原书 《深入理解C++11:C++11新持性解析与应用》进行深入理解。
C++中的自定义类型——类,是C++面向对象的基石。
类具有可派生性,派生类可以自动获得基类的成员变量和接口(虚函数和纯虚函数,这里我们指的都是public派生)。不过基类的非虚函数则无法再被派生类使用了。这条规则对于类中最为特别的构造函数也不例外,如果派生类要使用基类的构造函数,通常需要在构造函数中显式声明。比如下面的例子:
struct A{A(int i){}};
struct B:A{B(int i):A(i){}};
B派生于A,B又在构造函数中调用A的构造函数,从而完成构造函数的“传递”。
这在C++代码中非常常见。当然,这样的设计有一定的好处,尤其是B中有成员的时候。如代码清单3-1所示的例子。
struct A
{
A(int i){}
};
struct B:A
{
B(int i):A(i),d(i){}
int d;
};
//编译选项:g++ -c 3-1-1.cpp
在代码清单3-1中我们看到,派生于结构体A的结构体B拥有一个成员变量d,那么在B的构造函数B(int i)中,我们可以在初始化其基类A的同时初始化成员d。从这个意义上讲,这样的构造函数设计也算是非常合理的。\
不过合情合理并不等于合用,有的时候,我们的基类可能拥有数量众多的不同版本的构造函数——这样的情况并不少见,我们在2.7节中就曾经看到过这样的例子。那么倘若基类中有大量的构造函数,而派生类却只有一些成员函数时,那么对于派生类而言,其构造就等同于构造基类。这时候问题就来了,在派生类中我们写的构造函数完完全全就是为了构造基类。那么为了遵从于语法规则,我们还需要写很多的“透传”的构造函数。我们可以看看下面这个例子,如代码清单3-2所示。
struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//...
};
struct B:A{
B(int i):A(i){}
B(double d,int i):A(d,i){}
B(float f,int i,const char*c):A(f,i,c){}
//...
virtual void ExtraInterface(){}
};
//编译选项:g++ -c 3-1-2.cpp
在代码清单3-2中,我们的基类A有很多的构造函数的版本,而继承于A的派生类B实际上只是添加了一个接口ExtraInterface。那么如果我们在构造B的时候想要拥有A这样多的构造方法的话,就必须一一“透传”各个接口。这无疑是相当不方便的。
事实上,在C++中已经有了一个好用的规则,就是如果派生类要使用基类的成员函数的话,可以通过using声明(using-declaration)来完成。我们可以看看下面这个例子,如代码清单3-3所示。
#include
using namespace std;
struct Base {
void f(double i) { cout<<"Base:"<<i<<endl; }
};
struct Derived :Base {
using Base::f;
void f(int i) { cout<<"Derived:"<<i<<endl; }
};
int main() {
Base b;
b.f(4.5);//Base:4.5
Derived d;
d.f(4.5);//Base:4.5
}
//编译选项:g++3-1-3.cpp
在代码清单3-3中,我们的基类Base和派生类Derived声明了同名的函数f,不过在派生类中的版本跟基类有所不同。派生类中的f函数接受int类型为参数,而基类中接受double类型的参数。这里我们使用了using声明,声明派生类Derived也使用基类版本的函数f。这样一来,派生类中实际就拥有了两个f函数的版本。可以看到,我们在main函数中分别定义了Base变量b和Derived变量d,并传入浮点字面常量4.5,结果都会调用到基类的接受double为参数的版本。
在C++11中,这个想法被扩展到了构造函数上。子类可以通过使用using声明来声明继承基类的构造函数。那我们要改造代码清单3-2所示的例子就非常容易了,如代码清单3-4所示。
struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//...
};
struct B:A{
using A::A;//继承构造函数
//...
virtual void ExtraInterface(){}
};
这里我们通过using A::A的声明,把基类中的构造函数悉数继承到派生类B中。这样我们在代码清单3-2中的“透传”构造函数就不再需要了。
而且更为精巧的是,C++11标准继承构造函数被设计为跟派生类中的各种类默认函数(默认构造、析构、拷贝构造等)一样,是隐式声明的。这意味着如果一个继承构造函数不被相关代码使用,编译器不会为其产生真正的函数代码。这无疑比“透传”方案总是生成派生类的各种构造函数更加节省目标代码空间。
不过继承构造函数只会初始化基类中成员变量,对于派生类中的成员变量,则无能为力。不过配合我们2.7节中的类成员的初始化表达式,为派生类成员变量设定一个默认值还是没有问题的。
在代码清单3-5中我们就同时使用了继承构造函数和成员变量初始化两个C++11的特性。这样就可以解决一些继承构造函数无法初始化的派生类成员问题。如果这样仍然无法满足需求的话,程序员只能自己来实现一个构造函数,以达到基类和成员变量都能够初始化的目的。
struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//...
};
struct B:A{
using A::A;
int d{0};
};
int main(){
B b(356);// 365传递给A(int i){}函数,b.d被通过初始化列表初始化为0
}
有的时候,基类构造函数的参数会有默认值。对于继承构造函数来讲,参数的默认值是不会被继承的。事实上,默认值会导致基类产生多个构造函数的版本,这些函数版本都会被派生类继承。比如代码清单3-6所示的这个例子。
struct A{
A(int a=3,double=2.4){}
};
struct B:A{
using A::A;
};
可以看到,在代码清单3-6中,我们的基类的构造函数A(int a=3,double=2.4)有一个接受两个参数的构造函数,且两个参数均有默认值。那么A到底有多少个可能的构造函数的版本呢?
事实上,B可能从A中继承来的候选继承构造函数有如下一些:
❑A(int=3,double=2.4);这是使用两个参数的情况。
❑A(int=3);这是减掉一个参数的情况。
❑A(const A&);这是默认的复制构造函数。
❑A();这是不使用参数的情况。
相应地,B中的构造函数将会包括以下一些:
❑B(int,double);这是一个继承构造函数。
❑B(int);这是减少掉一个参数的继承构造函数。
❑B(const B&);这是复制构造函数,这不是继承来的。
❑B();这是不包含参数的默认构造函数。
可以看见,参数默认值会导致多个构造函数版本的产生,因此程序员在使用有参数默认值的构造函数的基类的时候,必须小心。
而有的时候,我们还会遇到继承构造函数“冲突”的情况。这通常发生在派生类拥有多个基类的时候。多个基类中的部分构造函数可能导致派生类中的继承构造函数的函数名、参数(有的时候,我们也称其为函数签名)都相同,那么继承类中的冲突的继承构造函数将导致不合法的派生类代码,如代码清单3-7所示。
struct A{A(int){}};
struct B{B(int){}};
struct C:A,B{
using A::A;
using B::B;
};
在代码清单3-7中,A和B的构造函数会导致C中重复定义相同类型的继承构造函数。这种情况下,可以通过显式定义继承类的冲突的构造函数,阻止隐式生成相应的继承构造函数来解决冲突。比如:
struct C:A,B{
using A::A;
using B::B;
C(int){}
};
其中的构造函数C(int)就很好地解决了代码清单3-7中继承构造函数的冲突问题。
另外我们还需要了解的一些规则是,如果基类的构造函数被声明为私有成员函数,或者派生类是从基类中虚继承的,那么就不能够在派生类中声明继承构造函数 。 此外,如果一旦使用了继承构造函数,编译器就不会再为派生类生成默认构造函数了,那么形如代码清单3-8中这样的情况,程序员就必须注意继承构造函数没有包含一个无参数的版本。在本例中,变量b的定义应该是不能够通过编译的。
struct A{A(int){}};
struct B:A{using A::A;};
//...
B b;//B没有默认构造函数
因为使用里using A::A;
相当于为 B 实现了一个 int参数的构造函数,则编译器不再为其产生其他版本的默认构造函数,因此我们在 B b;
使用默认的无参构造函数时编译不通过。
与继承构造函数类似的,委派构造函数也是C++11中对C++的构造函数的一项改进,其目的也是为了减少程序员书写构造函数的时间。通过委派其他构造函数,多构造函数的类编写将更加容易。
首先我们可以看看代码清单3-9中构造函数代码冗余的例子。
class Info{
public:
Info():type(1),name('a'){InitRest();}
Info(int i):type(i),name('a'){InitRest();}
Info(char e):type(1),name(e){InitRest();}
private:
void InitRest(){/*其他初始化*/}
int type;
char name;
//...
};
//编译选项:g++ -c 3-2-1.cpp
在代码清单3-9中,我们声明了一个Info的自定义类型。该类型拥有2个成员变量以及3个构造函数。这里的3个构造函数都声明了初始化列表来初始化成员type和name,并且都调用了相同的函数InitRest。可以看到,除了初始化列表有的不同,而其他的部分,3个构造函数基本上是相似的,因此其代码存在着很多重复。
读者可能会想到2.7节中我们对成员初始化的方法,那么我们用该方法来改写一下这个例子,如代码清单3-10所示。
class Info{
public:
Info(){InitRest();}
Info(int i):type(i){InitRest();}
Info(char e):name(e){InitRest();}
private:
void InitRest(){/*其他初始化*/}
int type{1};
char name{'a'};
//...
};
//编译选项:g++ -c-std=c++11 3-2-2.cpp
在代码清单3-10中,我们在Info成员变量type和name声明的时候就地进行了初始化。可以看到,构造函数确实简单了不少,不过每个构造函数还是需要调用InitRest函数进行初始化。而现实编程中,构造函数中的代码还会更长,比如可能还需要调用一些基类的构造函数等。那能不能在一些构造函数中连InitRest都不用调用呢?
答案是肯定的,但前提是我们能够将一个构造函数设定为“基准版本”,比如本例中Info()版本的构造函数,而其他构造函数可以通过委派“基准版本”来进行初始化。按照这个想法,我们可能会如下编写构造函数:
Info(){InitRest();}
Info(int i){this->Info();type=i;}
Info(char e){this->Info();name=e;}
这里我们通过this指针调用我们的“基准版本”的构造函数。不过可惜的是,一般的编译器都会阻止this->Info()的编译。原则上,编译器不允许在构造函数中调用构造函数,即使参数看起来并不相同。
当然,我们还可以开发出一个更具有“黑客精神”的版本:
Info(){InitRest();}
Info(int i){new(this)Info();type=i;}
Info(char e){new(this)Info();name=e;}
这里我们使用了placement new来强制在本对象地址(this指针所指地址)上调用类的构造函数。这样一来,我们可以绕过编译器的检查,从而在2个构造函数中调用我们的“基准版本”。这种方法看起来不错,却是在已经初始化一部分的对象上再次调用构造函数,因此虽然针对这个简单的例子在我们的实验机上该做法是有效的,却是种危险的做法。
在C++11中,我们可以使用委派构造函数来达到期望的效果。更具体的,C++11中的委派构造函数是在构造函数的初始化列表位置进行构造的、委派的。我们可以看看代码清单3-11所示的这个例子。
class Info{
public:
Info(){InitRest();} // 目标构造函数
Info(int i):Info(){type=i;} // 委派构造函数
Info(char e):Info(){name=e;} // 委派构造函数
private:
void InitRest(){/*其他初始化*/}
int type{1};
char name{'a'};
//...
};
//编译选项:g++ -c-std=c++11 3-2-3.cpp
可以看到,在代码清单3-11中,我们在Info(int)和Info(char)的初始化列表的位置,调用了“基准版本”的构造函数Info()。这里我们为了区分被调用者和调用者,称在初始化列表中调用“基准版本”的构造函数为委派构造函数(delegating constructor),而被调用的“基准版本”则为目标构造函数(target constructor)。在C++11中,所谓委派构造,就是指委派函数将构造的任务委派给了目标构造函数来完成这样一种类构造的方式。
(在函数首部与函数体之间添加了一个冒号:,后面紧跟val(_val), … 的形式。注:1.使用构造函数初始化列表并没有效率上的优势,仅仅是书写方便,尤其是成员变量较多时,这种写法非常简单明了。2.成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。 3.初始化 const 成员变量的唯一方法就是使用初始化列表。)
当然,在代码清单3-11中,委派构造函数只能在函数体中为type、name等成员赋初值。这是由于委派构造函数不能有初始化列表造成的。在C++中,构造函数不能同时“委派”和使用初始化列表,所以如果委派构造函数要给变量赋初值,初始化代码必须放在函数体中。比如:
struct Rule1{
int i;
Rule1(int a):i(a){}
Rule1():Rule1(40),i(1){}//无法通过编译
};
Rule1的委派构造函数Rule1()的写法就是非法的。我们不能在初始化列表中既初始化成员,又委托其他构造函数完成构造。
这样一来,代码清单3-11中的代码的初始化就不那么令人满意了,因为初始化列表的初始化方式总是先于构造函数完成的(实际在编译完成时就已经决定了)。这会可能致使程序员犯错(稍后解释)。不过我们可以稍微改造一下目标构造函数,使得委派构造函数依然可以在初始化列表中初始化所有成员,如代码清单3-12所示。
class Info{
public:
Info():Info(1,'a'){}
Info(int i):Info(i,'a'){}
Info(char e):Info(1,e){}
private:
Info(int i,char e):type(i),name(e){/*其他初始化*/}
int type;
char name;
//...
};
//编译选项:g++ -c-std=c++11 3-2-4.cpp
在代码清单3-12中,我们定义了一个私有的目标构造函数Info(int,char),这个构造函数接受两个参数,并将参数在初始化列表中初始化。而且由于这个目标构造函数的存在,我们可以不再需要InitRest函数了,而是将其代码都放入Info(int,char)中。这样一来,其他委派构造函数就可以委托该目标构造函数来完成构造。
事实上,在使用委派构造函数的时候,我们也建议程序员抽象出最为“通用”的行为做目标构造函数。这样做一来代码清晰,二来行为也更加正确。读者可以比较一下代码清单3-11和代码清单3-12中Info的定义,这里我们假设代码清单3-11、代码清单3-12中注释行的“其他初始化”位置的代码如下:
type+=1;
那么调用Info(int)版本的构造函数会得到不同的结果。比如如果做如下一个类型的声明:
Info f(3);
这个声明对代码清单3-11中的Info定义而言,会导致成员f.type的值为3,(因为Info(int)委托Info()初始化,后者调用InitRest将使得type的值为4。不过Info(int)函数体内又将type重写为3)。而依照代码清单3-12中的Info定义,f.type的值将最终为4。从代码编写者角度看,代码清单3-12中Info的行为会更加正确。这是由于在C++11中,目标构造函数的执行总是先于委派构造函数而造成的。因此避免目标构造函数和委托构造函数体中初始化同样的成员通常是必要的,否则则可能发生代码清单3-11错误。
而在构造函数比较多的时候,我们可能会拥有不止一个委派构造函数,而一些目标构造函数很可能也是委派构造函数,这样一来,我们就可能在委派构造函数中形成链状的委派构造关系,如代码清单3-13所示。
class Info{
public:
Info():Info(1){}//委派构造函数
Info(int i):Info(i,'a'){}//既是目标构造函数,也是委派构造函数
Info(char e):Info(1,e){}
private:
Info(int i,char e):type(i),name(e){/*其他初始化*/}//目标构造函数
int type;
char name;
//...
};
//编译选项:g++ -c-std=c++11 3-2-5.cpp
代码清单3-13所示就是这样一种链状委托构造,这里我们使Info()委托Info(int)进行构造,而Info(int)又委托Info(int,char)进行构造。在委托构造的链状关系中,有一点程序员必须注意,就是不能形成委托环(delegation cycle)。比如:
struct Rule2{
int i,c;
Rule2():Rule2(2){}
Rule2(int i):Rule2('c'){}
Rule2(char c):Rule2(2){}
};
Rule2定义中,Rule2()、Rule2(int)和Rule2(char)都依赖于别的构造函数,形成环委托构造关系。这样的代码通常会导致编译错误。
委派构造的一个很实际的应用就是使用构造模板函数产生目标构造函数,如代码清单3-14所示。
#include <list>
#include <vector>
#include <deque>
using namespace std;
class TDConstructed{
template<class T>TDConstructed(T first,T last):
l(first,last){}
list<int> l;
public:
TDConstructed(vector<short> &v):
TDConstructed(v.begin(),v.end()){}
TDConstructed(deque<int> &d):
TDConstructed(d.begin(),d.end()){}
};
//编译选项:g++ -c-std=c++11 3-2-6.cpp
在代码清单3-14中,我们定义了一个构造函数模板。而通过两个委派构造函数的委托,构造函数模板会被实例化。
T会分别被推导为vector<short>::iterator和deque<int>::iterator两种类型。这样一来,我们的TDConstructed类就可以很容易地接受多种容器对其进行初始化。这无疑比罗列不同类型的构造函数方便了很多。可以说,委托构造使得构造函数的泛型编程也成为了一种可能。
此外,在异常处理方面,如果在委派构造函数中使用try的话,那么从目标构造函数中产生的异常,都可以在委派构造函数中被捕捉到。我们可以看看代码清单3-15所示的例子。
#include
using namespace std;
class DCExcept {
public:
DCExcept(double d)
try :DCExcept(1, d) {
cout<<"Run the body."<<endl;
//其他初始化
}
catch (...) {
cout<<"caught exception."<<endl;
}
private:
DCExcept(int i, double d) {
cout<<"going to throw!"<<endl;
throw 0;
}
int type;
double data;
};
int main() {
DCExcept a(1.2);
}
//编译选项:g++ -std=c++11 3-2-7.cpp
在代码清单3-15中,我们在目标构造函数DCExcept(int,double)抛出了一个异常,并在委派构造函数DCExcept(int)中进行捕捉。编译运行该程序,我们在实验机上获得以下输出:
going to throw!
caught exception.
terminate called after throwing an instance of’int’
Aborted
可以看到,由于在目标构造函数中抛出了异常,委派构造函数的函数体部分的代码并没有被执行。这样的设计是合理的,因为如果函数体依赖于目标构造函数构造的结果,那么当目标构造函数构造发生异常的情况下,还是不要执行委派构造函数函数体中的代码为好。
其实,在Java等一些面向对象的编程语言中,早已经支持了委派构造函数这样的功能。因此,相比于继承构造函数,委派构造函数的设计和实现都比较早。而通过成员的初始化、委派构造函数,以及继承构造函数,C++中的构造函数的书写将进一步简化,这对程序员尤其是库的编写者来说,无疑是有积极意义的。
对C++程序员来说,编写C++程序有一条必须注意的规则,就是在类中包含了一个指针成员的话,那么就要特别小心拷贝构造函数的编写,因为一不小心,就会出现内存泄露(注意:浅拷贝与深拷贝)。我们来看看代码清单3-16中的例子。
#include
using namespace std;
class HasPtrMem {
public:
HasPtrMem() :d(new int(0)) {}
HasPtrMem(const HasPtrMem&h) :
d(new int(*h.d)) {}//拷贝构造函数,从堆中分配内存,并用*h.d初始化
~HasPtrMem() { delete d; }
int* d;
};
int main() {
HasPtrMem a;
HasPtrMem b(a);
cout<<* a.d<<endl;//0
cout<<* b.d<<endl;//0
}//正常析构
//编译选项:g++3-3-1.cpp
在代码清单3-16中,我们定义了一个HasPtrMem的类。这个类包含一个指针成员,该成员在构造时接受一个new操作分配堆内存返回的指针,而在析构的时候则会被delete操作用于释放之前分配的堆内存。
在main函数中,我们声明了HasPtrMem类型的变量a,又使用a初始化了变量b。按照C++的语法,这会调用HasPtrMem的拷贝构造函数。这里的拷贝构造函数由编译器隐式生成,其作用是执行类似于memcpy的按位拷贝。这样的构造方式有一个问题,就是a.d和b.d都指向了同一块堆内存。因此在main作用域结束的时候,a和b的析构函数纷纷被调用,当其中之一完成析构之后(比如b),那么a.d就成了一个“悬挂指针”(dangling pointer),因为其不再指向有效的内存了。那么在该悬挂指针上释放内存就会造成严重的错误。
这个问题在C++编程中非常经典。这样的拷贝构造方式,在C++中也常被称为“浅拷贝”(shollow copy)。而在未声明构造函数的情况下,C++也会为类生成一个浅拷贝的构造函数。通常最佳的解决方案是用户自定义拷贝构造函数来实现“深拷贝”(deep copy),我们来看看代码清单3-17中的修正方法。
#include
using namespace std;
class HasPtrMem {
public:
HasPtrMem() :d(new int(0)) {}
HasPtrMem(HasPtrMem&h) :
d(new int(*h.d)) {}//拷贝构造函数,从堆中分配内存,并用*h.d初始化
~HasPtrMem() { delete d; }
int* d;
};
int main() {
HasPtrMem a;
HasPtrMem b(a);
cout << *a.d << endl;//0
cout << *b.d << endl;//0
}
}//正常析构
//编译选项:g++3-3-2.cpp
在代码清单3-17中,我们为HasPtrMem添加了一个拷贝构造函数。拷贝构造函数从堆中分配新内存,将该分配来的内存的指针交还给d,又使用*(h.d)对*d进行了初始化。通过这样的方法,就避免了悬挂指针的困扰。
拷贝构造函数中为指针成员分配新的内存再进行内容拷贝的做法在C++编程中几乎被视为是不可违背的。不过在一些时候,我们确实不需要这样的拷贝构造语义。我们可以看看代码清单3-18所示的例子。
#include
using namespace std;
class HasPtrMem {
public:
HasPtrMem() :d(new int(0)) {
cout<<"Construct:"<<++n_cstr<<endl;
}
HasPtrMem(const HasPtrMem&h) :d(new int(*h.d)) {
cout<<"Copy construct:"<<++n_cptr<<endl;
}
~HasPtrMem() {
cout<<"Destruct:"<<++n_dstr<<endl;
}
int* d;
static int n_cstr;
static int n_dstr;
static int n_cptr;
};
int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
HasPtrMem GetTemp() { return HasPtrMem(); }
int main() {
HasPtrMem a = GetTemp();
}
//编译选项:g++3-3-3.cpp-fno-elide-constructors
注:因为编译器进行了返回值优化(ROV)因此运行结果显示只有一个对象的生成和释放。我们可以在编译时添加 -fno-elide-constructors
参数禁止编译器优化,此时运行结果如下图所示。
相关参考链接:C++编译器优化技术:RVO、NRVO和复制省略
另外在查资料的时候发现几篇很有参考价值的博文:
C++中临时对象的产生与优化、C++ 命名返回值优化(NRVO)
在代码清单3-18中,我们声明了一个返回一个HasPtrMem变量的函数。为了记录构造函数、拷贝构造函数,以及析构函数调用的次数,我们使用了一些静态变量。在main函数中,我们简单地声明了一个HasPtrMem的变量a,要求它使用GetTemp的返回值进行初始化。编译运行该程序,我们可以看到下面的输出:
Construct:1
Copy construct:1
Destruct:1
Copy construct:2
Destruct:2
Destruct:3
这里构造函数被调用了一次,这是在GetTemp函数中HasPtrMem()表达式显式地调用了构造函数而打印出来的。而拷贝构造函数则被调用了两次。这两次一次是从GetTemp函数中HasPtrMem()生成的变量上拷贝构造出一个临时值,以用作GetTemp的返回值,而另外一次则是由临时值构造出main中变量a调用的。对应地,析构函数也就调用了3次。这个过程如图3-1所示。
最让人感到不安就是拷贝构造函数的调用。在我们的例子里,类HasPtrMem只有一个int类型的指针。而如果HasPtrMem的指针指向非常大的堆内存数据的话,那么拷贝构造的过程就会非常昂贵。可以想象,这种情况一旦发生,a的初始化表达式的执行速度将相当堪忧。而更为令人堪忧的是,临时变量的产生和销毁以及拷贝的发生对于程序员来说基本上是透明的,不会影响程序的正确性,因而即使该问题导致程序的性能不如预期,也不易被程序员察觉(事实上,编译器常常对函数返回值有专门的优化,我们在本节结束时会提到)。
事实上VS编译器也是支持这种优化的,下面是有关右值引用的移动语义等文章的参考链接。
右值引用声明符: &&
移动构造函数和移动赋值运算符 (C++)
让我们把目光再次聚集在临时对象上,即图3-1中的main函数的部分。按照C++的语义,临时对象将在语句结束后被析构,会释放它所包含的堆内存资源。而a在拷贝构造的时候,又会被分配堆内存。这样的一去一来似乎并没有太大的意义,那么我们是否可以在临时对象构造a的时候不分配内存,即不使用所谓的拷贝构造语义呢?
在C++11中,答案是肯定的。我们可以看看如图3-2所示的示意图。
图3-2中的上半部分可以看到从临时变量中拷贝构造变量a的做法,即在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝内容至a.d。而构造完成后,临时对象将析构,因此其拥有的堆内存资源会被析构函数释放。而图3-2的下半部分则是一种“新”方法(实际跟我们在代码清单3-1中做得差不多),该方法在构造时使得a.d指向临时对象的堆内存资源。同时我们保证临时对象不释放所指向的堆内存(下面解释怎么做),那么在构造完成后,临时对象被析构,a就从中“偷”到了临时对象所拥有的堆内存资源。
在C++11中,这样的“偷走”临时变量中资源的构造函数,就被称为“移动构造函数”。而这样的“偷”的行为,则称之为“移动语义”(move semantics)。当然,换成白话的中文,可以理解为“移为己用”。我们可以看看代码清单3-19中是如何来实现这种移动语义的。
#include <iostream>
using namespace std;
class HasPtrMem{
public:
HasPtrMem():d(new int(3)){
cout<<"Construct:"<<++n_cstr<<endl;
}
HasPtrMem(const HasPtrMem&h):d(new int(*h.d)){
cout<<"Copy construct:"<<++n_cptr<<endl;
}
HasPtrMem(HasPtrMem&&h):d(h.d){//移动构造函数
h.d=nullptr;//将临时值的指针成员置空
cout<<"Move construct:"<<++n_mvtr<<endl;
}
~HasPtrMem(){
delete d;
cout<<"Destruct:"<<++n_dstr<<endl;
}
int*d;
static int n_cstr;
static int n_dstr;
static int n_cptr;
static int n_mvtr;
};
int HasPtrMem::n_cstr=0;
int HasPtrMem::n_dstr=0;
int HasPtrMem::n_cptr=0;
int HasPtrMem::n_mvtr=0;
HasPtrMem GetTemp(){
HasPtrMem h;
cout<<"Resource from"<<__func__<<":"<<hex<<h.d<<endl;
return h;
}
int main(){
HasPtrMem a=GetTemp();
cout<<"Resource from"<<__func__<<":"<<hex<<a.d<<endl;
}
//编译选项:g++ -std=c++11 3-3-4.cpp-fno-elide-constructors
相比于代码清单3-18,代码清单3-19中的HasPtrMem类多了一个构造函数HasPtrMem(HasPtrMem&&),这个就是我们所谓的移动构造函数。与拷贝构造函数不同的是,移动构造函数接受一个所谓的“右值引用”的参数,关于右值我们接下来会解释,读者可以暂时理解为临时变量的引用。可以看到,移动构造函数使用了参数h的成员d初始化了本对象的成员d(而不是像拷贝构造函数一样需要分配内存,然后将内容依次拷贝到新分配的内存中),而h的成员d随后被置为指针空值nullptr(请参见7.1节,这里等同于NULL)。这就完成了移动构造的全过程。
这里所谓的“偷”堆内存,就是指将本对象d指向h.d所指的内存这一条语句,相应地,我们还将h的成员d置为指针空值。这其实也是我们“偷”内存时必须做的。这是因为在移动构造完成之后,临时对象会立即被析构。如果不改变h.d(临时对象的指针成员)的话,则临时对象会析构掉本是我们“偷”来的堆内存。这样一来,本对象中的d指针也就成了一个悬挂指针,如果我们对指针进行解引用,就会发生严重的运行时错误。
为了看看移动构造的效果,我们让GetTemp和main函数分别打印变量h和变量a中的指针h.d和a.d,在我们的实验机上运行的结果如下:
Construct:1
Resource from GetTemp:0x603010
Move construct:1
Destruct:1
Move construct:2
Destruct:2
Resource from main:0x603010
Destruct:3
可以看到,这里没有调用拷贝构造函数,而是调用了两次移动构造函数,移动构造的结果是,GetTemp中的h的指针成员h.d和main函数中的a的指针成员a.d的值是相同的,即h.d和a.d都指向了相同的堆地址内存。该堆内存在函数返回的过程中,成功地逃避了被析构的“厄运”,取而代之地,成为了赋值表达式中的变量a的资源。如果堆内存不是一个int长度的数据,而是以MByte为单位的堆空间,那么这样的移动带来的性能提升将非常惊人。
或许读者会质疑说:为什么要这么费力地添加移动构造函数呢?完全可以选择改变GetTemp的接口,比如直接传一个引用或者指针到GetTemp的参数中去,效果应该也不差。其实从性能上来讲,这样的做法确实毫无问题,甚至只好不差。不过从使用的方便性上来讲效果不好。如果函数返回临时值的话,可以在单条语句里完成很多计算,比如我们可以很自然地写出如下语句:
Caculate(GetTemp(),SomeOther(Maybe(),Useful(Values,2)));
但如果通过传引用或者指针的方法而不返回值的话,通常就需要很多语句来完成上面的工作。可能是像下面这样的代码:
string*a;vector b;//事先声明一些变量用于传递返回值
...
Useful(Values,2,a);//最后一个参数是指针,用于返回结果
SomeOther(Maybe(),a,b);//最后一个参数是引用,用于返回结果
Caculate(GetTemp(),b);
两者在代码编写效率和可读性上都存在着明显的差别。而即使声明这些传递返回值的变量为全局的,函数再将这些引用和指针都作为返回值返回给调用者,我们也需要在Caculate调用之前声明好所有的引用和指针。这无疑是繁琐的工作。函数返回临时变量的好处就是不需要声明变量,也不需要知道生命期。程序员只需要按照最自然的方式,使用最简单的语句就可以完成大量的工作。
那么再回到移动语义上来,还有一个最为关键的问题没有解决,那就是移动构造函数何时会被触发。之前我们只是提到了临时对象,一旦我们用到的是个临时变量,那么移动构造语义就可以得到执行。那么,在C++中如何判断产生了临时对象?如何将其用于移动构造函数?是否只有临时变量可以用于移动构造?……读者可能还有很多问题。要回答这些问题,需要先了解一下C++中的“值”是如何分类的。
注意 事实上,移动语义并不是什么新的概念,在C++98/03的语言和库中,它已经存在了,比如:
❑在某些情况下拷贝构造函数的省略(copy constructor elision in some contexts)
❑智能指针的拷贝(auto_ptr“copy”)
❑链表拼接(list::splice)
❑容器内的置换(swap on containers)
以上这些操作都包含了从一个对象向另外一个对象的资源转移(至少概念上)的过程,唯一欠缺的是统一的语法和语义的支持,来使我们可以使用通用的代码移动任意的对象(就像我们今天可以使用通用的代码来拷贝任意对象一样)。如果能够任意地使用对象的移动而不是拷贝,那么标准库中的很多地方的性能都会大大提高。
在C语言中,我们常常会提起左值(lvalue)、右值(rvalue)这样的称呼。而在编译程序时,编译器有时也会在报出的错误信息中会包含左值、右值的说法。不过左值、右值通常不是通过一个严谨的定义而为人所知的,大多数时候左右值的定义与其判别方法是一体的。一个最为典型的判别方法就是,在赋值表达式中,出现在等号左边的就是“左值”,而在等号右边的,则称为“右值”。比如:
a=b+c;
在这个赋值表达式中,a就是一个左值,而b+c则是一个右值。这种识别左值、右值的方法在C++中依然有效。不过C++中还有一个被广泛认同的说法,那就是可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。那么这个加法赋值表达式中,&a是允许的操作,但&(b+c)这样的操作则不会通过编译。因此a是一个左值,(b+c)是一个右值。
这些判别方法通常都非常有效。更为细致地,在C++11中,右值是由两个概念构成的,一个是将亡值(xvalue,eXpiring Value),另一个则是纯右值(prvalue,Pure Rvalue)。
其中纯右值就是C++98标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。比如非引用返回的函数返回的临时变量值(我们在前面多次提到了)就是一个纯右值。一些运算表达式,比如1+3产生的临时变量值,也是纯右值。而不跟对象关联的字面量值,比如:2、‘c’、true,也是纯右值。此外,类型转换函数的返回值、lambda表达式(见7.3节)等,也都是右值。
而将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&&的函数返回值、std::move的返回值(稍后解释),或者转换为T&&的类型转换函数的返回值(稍后解释)。而剩余的,可以标识函数、对象的值都属于左值。在C++11的程序中,所有的值必属于左值、将亡值、纯右值三者之一。
注意: 事实上,之所以我们只知道一些关于左值、右值的判断而很少听到其真正的定义的一个原因就是——很难归纳。而且即使归纳了,也需要大量的解释。
在C++11中,右值引用就是对一个右值进行引用的类型。事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。通常情况下,我们只能是从右值表达式获得其引用。比如:
T&& a=ReturnRvalue();
这个表达式中,假设ReturnRvalue返回一个右值,我们就声明了一个名为a的右值引用,其值等于ReturnRvalue函数返回的临时变量的值。
为了区别于C++98中的引用类型,我们称C++98中的引用为“左值引用”(lvalue reference)。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化 。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。
在上面的例子中,ReturnRvalue函数返回的右值在表达式语句结束后,其生命也就终结了(通常我们也称其具有表达式生命期),而通过右值引用的声明,该右值又“重获新生”,其生命期将与右值引用类型变量a的生命期一样。只要a还“活着”,该右值临时量将会一直“存活”下去。
所以相比于以下语句的声明方式:
T b=ReturnRvalue();
我们刚才的右值引用变量声明,就会少一次对象的析构及一次对象的构造。因为a是右值引用,直接绑定了ReturnRvalue()返回的临时量,而b只是由临时值构造而成的,而临时量在表达式结束后会析构因应就会多一次析构和构造的开销。
不过值得指出的是,能够声明右值引用a的前提是ReturnRvalue返回的是一个右值。通常情况下,右值引用是不能够绑定到任何的左值的。比如下面的表达式就是无法通过编译的。
int c;
int&& d=c;
相对地,在C++98标准中就已经出现的左值引用是否可以绑定到右值(由右值进行初始化)呢?比如:
T& e=ReturnRvalue();
const T&f=ReturnRvalue();
这样的语句是否能够通过编译呢?这里的答案是:e的初始化会导致编译时错误,而f则不会。
出现这样的状况的原因是,在常量左值引用在C++98标准中开始就是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。而且在使用右值对其初始化的时候,常量左值引用还可以像右值引用一样将右值的生命期延长。不过相比于右值引用所引用的右值,常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。
既然常量左值引用在C++98中就已经出现,读者可能会努力地搜索记忆,想找出在C++中使用常量左值绑定右值的情况。不过可能一切并不如愿。这是因为,在C++11之前,左值、右值对于程序员来说,一直呈透明状态。不知道什么是左值、右值,并不影响写出正确的C++代码。引用的是左值和右值通常也并不重要。不过事实上,在C++98通过左值引用来绑定一个右值的情况并不少见,比如:
const bool& judgement=true;
就是一个使用常量左值引用来绑定右值的例子。不过与如下声明相比较看起来似乎差别不大。
const bool judgement=true;
可能很多程序员都没有注意到其中的差别(从语法上讲,前者直接使用了右值并为其“续命”,而后者的右值在表达式结束后就销毁了)。
事实上,即使在C++98中,我们也常可以使用常量左值引用来减少临时对象的开销,如代码清单3-20所示。
#include
using namespace std;
struct Copyable {
Copyable() {}
Copyable(const Copyable&o) {
cout<<"Copied"<<endl;
}
};
Copyable ReturnRvalue() { return Copyable(); }
void AcceptVal(Copyable) {}
void AcceptRef(const Copyable&) {}
int main() {
cout<<"Pass by value:"<<endl;
AcceptVal(ReturnRvalue());//临时值被拷贝传入
cout<<"Pass by reference:"<<endl;
AcceptRef(ReturnRvalue());//临时值被作为引用传递
}
//编译选项:g++3-3-5.cpp-fno-elide-constructors
在代码清单3-20中,我们声明了结构体Copyable,该结构体的唯一的作用就是在被拷贝构造的时候打印一句话:Copied。而两个函数,AcceptVal使用了值传递参数,而AcceptRef使用了引用传递。在以ReturnRvalue返回的右值为参数的时候,AcceptRef就可以直接使用产生的临时值(并延长其生命期),而AcceptVal则不能直接使用临时对象。
编译运行代码清单3-20,可以得到以下结果:
Pass by value:
Copied
Copied
Pass by reference:
Copied
可以看到,由于使用了左值引用,临时对象被直接作为函数的参数,而不需要从中拷贝一次。读者可以自行分析一下输出结果,这里就不赘述了。而在C++11中,同样地,如果在代码清单3-20中以右值引用为参数声明如下函数:
void AcceptRvalueRef(Copyable&&){}
也同样可以减少临时变量拷贝的开销。进一步地,还可以在AcceptRvalueRef中修改该临时值(这个时候临时值由于被右值引用参数所引用,已经获得了函数时间的生命期)。不过修改一个临时值的意义通常不大,除非像3.3.2节一样使用移动语义。
就本例而言,如果我们这样实现函数:
void AcceptRvalueRef(Copyable&&s){
Copyable news=std::move(s);
}
这里std::move的作用是强制一个左值成为右值(看起来很奇怪?这个我们会在下面一节中解释)。该函数就是使用右值来初始化Copyable变量news。当然,如同我们在上小节提到的,使用移动语义的前提是Copyable还需要添加一个以右值引用为参数的移动构造函数,比如:
Copyable(Copyable&&o){/*实现移动语义*/}
这样一来,如果Copyable类的临时对象(即ReturnRvalue返回的临时值)中包含一些大块内存的指针,news就可以如同代码清单3-19一样将临时值中的内存“窃”为己用,从而从这个以右值引用参数的AcceptRvalueRef函数中获得最大的收益。事实上,右值引用的来由从来就跟移动语义紧紧相关。这是右值存在的一个最大的价值(另外一个价值是用于转发,我们会在后面的小节中看到)。
对于本例而言,很有趣的是,读者也可以思考一下:如果我们不声明移动构造函数,而只声明一个常量左值的构造函数会发生什么?如同我们刚才提到的,常量左值引用是个“万能”的引用类型,无论左值还是右值,常量还是非常量,一概能够绑定。那么如果Copyable没有移动构造函数,下列语句:
Copyable news=std::move(s);
将调用以常量左值引用为参数的拷贝构造函数。这是一种非常安全的设计——移动不成,至少还可以执行拷贝。因此,通常情况下,程序员会为声明了移动构造函数的类声明一个常量左值为参数的拷贝构造函数,以保证在移动构造不成时,可以使用拷贝构造(不过,我们也会在之后看到一些特殊用途的反例)。
为了语义的完整,C++11中还存在着常量右值引用,比如我们通过以下代码声明一个常量右值引用。
const T&&crvalueref=ReturnRvalue();
但是,一来右值引用主要就是为了移动语义,而移动语义需要右值是可以被修改的,那么常量右值引用在移动语义中就没有用武之处;二来如果要引用右值且让右值不可以更改,常量左值引用往往就足够了。因此在现在的情况下,我们还没有看到常量右值引用有何用处。
表3-1中,我们列出了在C++11中各种引用类型可以引用的值的类型。值得注意的是,只要能够绑定右值的引用类型,都能够延长右值的生命期。
有的时候,我们可能不知道一个类型是否是引用类型,以及是左值引用还是右值引用(这在模板中比较常见)。标准库在<type_traits>头文件中提供了3个模板类:is_rvalue_reference
、is_lvalue_reference
、is_reference
,可供我们进行判断。比如:
cout<<is_rvalue_reference<string&&>::value;
我们通过模板类的成员value就可以打印出stirng&&是否是一个右值引用了。配合第4章中的类型推导操作符decltype,我们甚至还可以对变量的类型进行判断。当读者搞不清楚引用类型的时候,不妨使用这样的小工具实验一下。
在C++11中,标准库在<utility>中提供了一个有用的函数std::move,这个函数的名字具有迷惑性,因为实际上std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而我们可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:
static_cast<T&&>(lvalue);
值得一提的是,被转化的左值,其生命期并没有随着左右值的转化而改变。如果读者期望std::move转化的左值变量lvalue能立即被析构,那么肯定会失望了。我们来看代码清单3-21所示的例子。
#include
using namespace std;
class Moveable {
public:
Moveable() :i(new int(3)) {}
~Moveable() { delete i; }
Moveable(const Moveable&m) :i(new int(*m.i)) {}
Moveable(Moveable&&m) :i(m.i) {
m.i = nullptr;
}
int* i;
};
int main() {
Moveable a;
Moveable c(move(a));//会调用移动构造函数
cout<<* a.i<<endl;//运行时错误
}
//编译选项:g++ -std=c++11 3-3-6.cpp-fno-elide-constructors
在代码清单3-21中,我们为类型Moveable定义了移动构造函数。这个函数定义本身没有什么问题,但调用的时候,使用了Moveable c(move(a));这样的语句。这里的a本来是一个左值变量,通过std::move将其转换为右值。这样一来,a.i就被c的移动构造函数设置为指针空值。由于a的生命期实际要到main函数结束才结束,那么随后对表达式*a.i进行计算的时候,就会发生严重的运行时错误。
这是个典型误用std::move的例子。当然,标准库提供该函数的目的不是为了让程序员搬起石头砸自己的脚。事实上,要使用该函数,必须是程序员清楚需要转换的时候。比如上例中,程序员应该知道被转化为右值的a不可以再使用。不过更多地,我们需要转换成为右值引用的还是一个确实生命期即将结束的对象。我们来看看代码清单3-22所示的正确例子。
#include
using namespace std;
class HugeMem {
public:
HugeMem(int size) :sz(size>0 ? size : 1) {
c = new int[sz];
}
~HugeMem() { delete[] c; }
HugeMem(HugeMem&&hm) :sz(hm.sz), c(hm.c) {
hm.c = nullptr;
}
int* c;
int sz;
};
class Moveable {
public:
Moveable() :i(new int(3)), h(1024) {}
~Moveable() { delete i; }
Moveable(Moveable&&m) :
i(m.i), h(move(m.h)) {//强制转为右值,以调用移动构造函数
m.i = nullptr;
}
int* i;
HugeMem h;
};
Moveable GetTemp() {
Moveable tmp = Moveable();
cout<<hex<<"Huge Mem from"<<__func__
<<"@"<<tmp.h.c<<endl;//Huge Mem from GetTemp@0x603030
return tmp;
}
int main() {
Moveable a(GetTemp());
cout<<hex<<"Huge Mem from"<<__func__
<<"@"<<a.h.c<<endl;//Huge Mem from main@0x603030
}
//编译选项:g++ -std=c++11 3-3-7.cpp-fno-elide-constructors
在代码清单3-22中,我们定义了两个类型:HugeMem和Moveable,其中Moveable包含了一个HugeMem的对象。在Moveable的移动构造函数中,我们就看到了std::move函数的使用。该函数将m.h强制转化为右值,以迫使Moveable中的h能够实现移动构造。这里可以使用std::move,是因为m.h是m的成员,既然m将在表达式结束后被析构,其成员也自然会被析构,因此不存在代码清单3-21中的生存期不对的问题。另外一个问题可能是std::move使用的必要性。这里如果不使用std::move(m.h)这样的表达式,而是直接使用m.h这个表达式将会怎样?
其实这是C++11中有趣的地方:可以接受右值的右值引用本身却是个左值。这里的m.h引用了一个确定的对象,而且m.h也有名字,可以使用&m.h取到地址,因此是个不折不扣的左值。不过这个左值确确实实会很快“灰飞烟灭”,因为拷贝构造函数在Moveable对象a的构造完成后也就结束了。那么这里使用std::move强制其为右值就不会有问题了。而且,如果我们不这么做,由于m.h是个左值,就会导致调用HugeMem的拷贝构造函数来构造Moveable的成员h(虽然这里没有声明,读者可以自行添加实验一下)。如果是这样,移动语义就没有能够成功地向类的成员传递。换言之,还是会由于拷贝而导致一定的性能上的损失。
事实上,为了保证移动语义的传递,程序员在编写移动构造函数的时候,应该总是记得使用std::move转换拥有形如堆内存、文件句柄等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义。而即使成员没有移动构造函数,那么接受常量左值的构造函数版本也会轻松地实现拷贝构造,因此也不会引起大的问题。
我们在前面多次提到,移动语义一定是要修改临时变量的值。那么,如果这样声明移动构造函数:
Moveable(const Moveable&&)
或者这样声明函数:
const Moveable ReturnVal();
都会使得的临时变量常量化,成为一个常量右值,那么临时变量的引用也就无法修改,从而导致无法实现移动语义。因此程序员在实现移动语义一定要注意排除不必要的const关键字。
在C++11中,拷贝/移动构造函数实际上有以下3个版本:
T Object(T&)
T Object(const T&)
T Object(T&&)
其中常量左值引用的版本是一个拷贝构造版本,而右值引用版本是一个移动构造版本。默认情况下,编译器会为程序员隐式地生成一个(隐式表示如果不被使用则不生成)移动构造函数。不过如果程序员声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或者多个,编译器都不会再为程序员生成默认版本。默认的移动构造函数实际上跟默认的拷贝构造函数一样,只能做一些按位拷贝的工作。这对实现移动语义来说是不够的。通常情况下,如果需要移动语义,程序员必须自定义移动构造函数。当然,对一些简单的、不包含任何资源的类型来说,实现移动语义与否都无关紧要,因为对这样的类型而言,移动就是拷贝,拷贝就是移动。
同样地,声明了移动构造函数、移动赋值函数、拷贝赋值函数和析构函数中的一个或者多个,编译器也不会再为程序员生成默认的拷贝构造函数。所以在C++11中,拷贝构造/赋值和移动构造/赋值函数必须同时提供,或者同时不提供,程序员才能保证类同时具有拷贝和移动语义。只声明其中一种的话,类都仅能实现一种语义。
其实,只实现一种语义在类的编写中也是非常常见的。比如说只有拷贝语义的类型——事实上在C++11之前我们见过大多数的类型的构造都是只使用拷贝语义的。而只有移动语义的类型则非常有趣,因为只有移动语义表明该类型的变量所拥有的资源只能被移动,而不能被拷贝。那么这样的资源必须是唯一的。因此,只有移动语义构造的类型往往都是“资源型”的类型,比如说智能指针,文件流等,都可以视为“资源型”的类型。在本书的第5章中,就可以看到标准库中的仅可移动的模板类:
unique_ptr。一些编译器,如vs2011,现在也把ifstream这样的类型实现为仅可移动的。
在标准库的头文件<type_traits>里,我们还可以通过一些辅助的模板类来判断一个类型是否是可以移动的。比如is_move_constructible
、is_trivially_move_constructible
、is_nothrow_move_constructible
,使用方法仍然是使用其成员value。比如:
cout<<is_move_constructible<UnknownType>::value;
就可以打印出UnknowType是否可以移动,这在一些情况下还是非常有用的。
而有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数。看看下面这段swap模板函数代码:
template<class T>
void swap(T&a,T&b)
{
T tmp(move(a));
a=move(b);
b=move(tmp);
}
如果T是可以移动的,那么移动构造和移动赋值将会被用于这个置换。代码中,a先将自己的资源交给tmp,随后b再将资源交给a,tmp随后又将从a中得到的资源交给b,从而完成了一个置换动作。整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放与申请。而如果T不可移动却是可拷贝的,那么拷贝语义会被用来进行置换。这就跟普通的置换语句是相同的了。因此在移动语义的支持下,我们仅仅通过一个通用的模板,就可能更高效地完成置换,这对于泛型编程来说,无疑是具有积极意义的。
另外一个关于移动构造的话题是异常。对于移动构造函数来说,抛出异常有时是件危险的事情。因为可能移动语义还没完成,一个异常却抛出来了,这就会导致一些指针就成为悬挂指针。因此程序员应该尽量编写不抛出异常的移动构造函数,通过为其添加一个noexcept关键字,可以保证移动构造函数中抛出来的异常会直接调用terminate程序终止运行,而不是造成指针悬挂的状态。而标准库中,我们还可以用一个std::move_if_noexcept的模板函数替代move函数。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义。我们来看一下代码清单3-23所示的例子。
#include
#include
using namespace std;
struct Maythrow {
Maythrow() {}
Maythrow(const Maythrow&) {
std::cout<<"Maythorow copy constructor."<<endl;
}
Maythrow(Maythrow&&) {
std::cout<<"Maythorow move constructor."<<endl;
}
};
struct Nothrow {
Nothrow() {}
Nothrow(Nothrow&&)noexcept {
std::cout<<"Nothorow move constructor."<<endl;
}
Nothrow(const Nothrow&) {
std::cout<<"Nothorow move constructor."<<endl;
}
};
int main() {
Maythrow m;
Nothrow n;
Maythrow mt = move_if_noexcept(m);//Maythorow copy constructor.
Nothrow nt = move_if_noexcept(n);//Nothorow move constructor.
return 0;
}
//编译选项:g++ -std=c++11 3-3-8.cpp
在代码清单3-23中,可以清楚地看到move_if_noexcept的效果。事实上,move_if_noexcept是以牺牲性能保证安全的一种做法,而且要求类的开发者对移动构造函数使用noexcept进行描述,否则就会损失更多的性能。这是库的开发者和使用者必须协同平衡考虑的。
还有一个与移动语义看似无关,但偏偏有些关联的话题是,编译器中被称为RVO/NRVO的优化(RVO,Return Value Optimization,返回值优化,或者NRVO,Named Return Value optimization)。事实上,在本节中大量的代码都使用了-fno-elide-constructors
选项在g++/clang++中关闭这个优化,这样可以使读者在代码中较为容易地利用函数返回的临时量右值。
但若在编译的时候不使用该选项的话,读者会发现很多构造和移动都被省略了。对于下面这样的代码,一旦打开g++/clang++的RVO/NRVO,从ReturnValue函数中a变量拷贝/移动构造临时变量,以及从临时变量拷贝/移动构造b的二重奏就通通没有了。
A ReturnRvalue(){A a();return a;}
A b=ReturnRvalue();
b变量实际就使用了ReturnRvalue函数中a的地址,任何的拷贝和移动都没有了。通俗地说,就是b变量直接“霸占”了a变量。这是编译器中一个效果非常好的一个优化。不过RVO/NRVO并不是对任何情况都有效。比如有些情况下,一些构造是无法省略的。还有一些情况,即使RVO/NRVO完成了,也不能达到最好的效果。但结论是明显的,移动语义可以解决编译器无法解决的优化问题,因而总是有用的。
所谓完美转发(perfect forwarding),是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。比如:
template<typename T>
void IamForwording(T t){IrunCodeActually(t);}
这个简单的例子中,IamForwording是一个转发函数模板。而函数IrunCodeActually则是真正执行代码的目标函数。对于目标函数IrunCodeActually而言,它总是希望转发函数将参数按照传入Iamforwarding时的类型传递(即传入IamForwording的是左值对象,IrunCodeActually就能获得左值对象,传入IamForwording的是右值对象,IrunCodeActually就能获得右值对象),而不产生额外的开销,就好像转发者不存在一样。
这似乎是一件非常容易的事情,但实际却并不简单。在上面例子中,我在IamForwording的参数中使用了最基本类型进行转发,该方法会导致参数在传给IrunCodeActually之前就产生了一次额外的临时对象拷贝。因此这样的转发只能说是正确的转发,但谈不上完美。
所以通常程序员需要的是一个引用类型,引用类型不会有拷贝的开销。其次,则需要考虑转发函数对类型的接受能力。因为目标函数可能需要能够既接受左值引用,又接受右值引用。那么如果转发函数只能接受其中的一部分,我们也无法做到完美转发。结合表3-1,我们会想到“万能”的常量左值类型。不过以常量左值为参数的转发函数却会遇到一些尴尬,比如:
void IrunCodeActually(int t){}
template<typename T>
void IamForwording(const T&t){IrunCodeActually(t);}
这里,由于目标函数的参数类型是非常量左值引用类型,因此无法接受常量左值引用作为参数,这样一来,虽然转发函数的接受能力很高,但在目标函数的接受上却出了问题。那么我们可能就需要通过一些常量和非常量的重载来解决目标函数的接受问题。这在函数参数比较多的情况下,就会造成代码的冗余。而且依据表3-1,如果我们的目标函数的参数是个右值引用的话,同样无法接受任何左值类型作为参数,间接地,也就导致无法使用移动语义。
那C++11是如何解决完美转发的问题的呢?实际上,C++11是通过引入一条所谓“引用折叠”(reference collapsing)的新语言规则,并结合新的模板推导规则来完成完美转发。
在C++11以前,形如下列语句:
typedef const int T;
typedef T&TR;
TR&v=1;//该声明在C++98中会导致编译错误
其中TR&v=1这样的表达式会被编译器认为是不合法的表达式,而在C++11中,一旦出现了这样的表达式,就会发生引用折叠,即将复杂的未知表达式折叠为已知的简单表达式,具体如表3-2所示。
这个规则并不难记忆,因为一旦定义中出现了左值引用,引用折叠总是优先将其折叠为左值引用。而模板对类型的推导规则就比较简单,当转发函数的实参是类型X的一个左值引用,那么模板参数被推导为X&类型,而转发函数的实参是类型X的一个右值引用的话,那么模板的参数被推导为X&&类型。结合以上的引用折叠规则,就能确定出参数的实际类型。进一步,我们可以把转发函数写成如下形式:
template<typename T>
void IamForwording(T&& t){
IrunCodeActually(static_cast<T&&>(t));
}
注意 对于完美转发而言,右值引用并非“天生神力”,只是C++11新引入了右值,因此为其新定下了引用折叠的规则,以满足完美转发的需求。
注意一下,我们不仅在参数部分使用了T&&这样的标识,在目标函数传参的强制类型转换中也使用了这样的形式。比如我们调用转发函数时传入了一个X类型的左值引用,可以想象,转发函数将被实例化为如下形式:
void IamForwording(X&&& t){
IrunCodeActually(static_cast<X&&&>(t));
}
应用上引用折叠规则,就是:
void IamForwording(X&t){
IrunCodeActually(static_cast<X&>(t));
}
这样一来,我们的左值传递就毫无问题了。实际使用的时候,IrunCodeActually如果接受左值引用的话,就可以直接调用转发函数。不过读者可能发现,这里调用前的static_cast没有什么作用。事实上,这里的static_cast是留给传递右值用的。
而如果我们调用转发函数时传入了一个X类型的右值引用的话,我们的转发函数将被实例化为:
void IamForwording(X&&&&t) {
IrunCodeActually(static_cast<X&&&&>(t));
}
应用上引用折叠规则,就是:
void IamForwording(X&&t) {
IrunCodeActually(static_cast<X&&>(t));
}
这里我们就看到了static_cast的重要性。如我们在上面几个小节中讲到的,对于一个右值而言,当它使用右值引用表达式引用的时候,该右值引用却是个不折不扣的左值,那么我们想在函数调用中继续传递右值,就需要使用std::move来进行左右值的转换。而std::move通常就是一个static_cast。不过在C++11中,用于完美转发的函数却不再叫作move,而是另外一个名字:forward。所以我们可以把转发函数写成这样:
template<typename T>
void IamForwording(T&&t){
IrunCodeActually(forward(t));
}
move和forward在实际实现上差别并不大。不过标准库这么设计,也许是为了让每个名字对应于不同的用途,以应对未来可能的扩展(虽然现在我们使用move可能也能通过完美转发函数的编译,但这并不是推荐的做法)。
我们来看一个完美转发的例子,如代码清单3-24所示。
#include
using namespace std;
void RunCode(int&&m) { cout<<"rvalue ref"<<endl; }
void RunCode(int&m) { cout<<"lvalue ref"<<endl; }
void RunCode(const int&&m) { cout<<"const rvalue ref"<<endl; }
void RunCode(const int&m) { cout<<"const lvalue ref"<<endl; }
template<typename T>
void PerfectForward(T&&t) { RunCode(forward<T>(t)); }
int main() {
int a;
int b;
const int c = 1;
const int d = 0;
PerfectForward(a);//lvalue ref
PerfectForward(move(b));//rvalue ref
PerfectForward(c);//const lvalue ref
PerfectForward(move(d));//const rvalue ref
}
//编译选项:g++ -std=c++11 3-3-9.cpp
在代码清单3-24中,我们使用了表3-1中的所有4种类型的值对完美转发进行测试,可以看到,所有的转发都被正确地送到了目的地。
完美转发的一个作用就是做包装函数,这是一个很方便的功能。我们对代码清单3-24中的转发函数稍作修改,就可以用很少的代码记录单参数函数的参数传递状况,如代码清单3-25所示。
#include
using namespace std;
template<typename T, typename U>
void PerfectForward(T&&t, U&Func) {
cout<<t<<"\tforwarded..."<<endl;
Func(forward<T>(t));
}
void RunCode(double&&m) {}
void RunHome(double&&h) {}
void RunComp(double&&c) {}
int main() {
PerfectForward(1.5, RunComp);//1.5 forwarded...
PerfectForward(8, RunCode);//8 forwarded...
PerfectForward(1.5, RunHome);//1.5 forwarded...
}
//编译选项:g++ -std=c++11 3-3-10.cpp
当然,读者可以尝试将该例子变得更复杂一点,以更加符合实际的需求。事实上,在C++11标准库中我们可以看到大量完美转发的实际应用,一些很小巧好用的函数,比如make_pair、make_unique等在C++11都通过完美转发实现了。这样一来,就减少了一些函数版本的重复(const和非const版本的重复),并能够充分利用移动语义。无论从运行性能的提高还是从代码编写的简化上,完美转发都堪称完美。
在C++中,有个非常好也非常坏的特性,就是隐式类型转换。隐式类型转换的“自动性”可以让程序员免于层层构造类型。但也是由于它的自动性,会在一些程序员意想不到的地方出现严重的但不易被发现的错误。我们可以先看看代码清单3-26所示的这个例子。
#include
using namespace std;
struct Rational1 {
Rational1(int n = 0, int d = 1) :num(n), den(d) {
cout<<__func__<<"("<<num<<"/"<<den<<")"<<endl;
}
int num;//Numerator(被除数)
int den;//Denominator(除数)
};
struct Rational2 {
explicit Rational2(int n = 0, int d = 1) :num(n), den(d) {
cout<<__func__<<"("<<num<<"/"<<den<<")"<<endl;
}
int num;
int den;
};
void Display1(Rational1 ra) {
cout<<"Numerator:"<<ra.num<<"Denominator:"<<ra.den<<endl;
}
void Display2(Rational2 ra) {
cout<<"Numerator:"<<ra.num<<"Denominator:"<<ra.den<<endl;
}
int main() {
Rational1 r1_1 = 11;//Rational1(11/1)
Rational1 r1_2(12);//Rational1(12/1)
Rational2 r2_1 = 21;//无法通过编译
Rational2 r2_2(22);//Rational2(22/1)
Display1(1);//Rational1(1/1)
//Numerator:1 Denominator:1
Display2(2);//无法通过编译
Display2(Rational2(2));//Rational2(2/1)
//Numerator:2 Denominator:1
return 0;
}
//编译选项:g++ -std=c++11 3-4-1.cpp
在代码清单3-26中,声明了两个类型Rational1和Rational2。两者在代码上的区别不大,只不过Rational1的构造函数Rational1(int,int)没有explicit关键字修饰,这意味着该构造函数可以被隐式调用。因此,在定义变量r1_1的时候,字面量11就会成功地构造出Rational1(11,1)这样的变量,Rational2却不能从字面量21中构造,这是因为其构造函数由于使用了关键字explicit修饰,禁止被隐式构造,因此会导致编译失败。相同的情况也出现在函数Display2上,由于字面量2不能隐式地构造出Rational2对象,因此表达式Display2(2)的编译同样无法通过。
这里虽然Display1(1)编译成功,不过如果不是结合了上面Rational1的定义,我们很容易在阅读代码的时候产生误解。按照习惯,程序员会误认为Display1是个打印整型数的函数。因此,使用了explicit这个关键字保证对象的显式构造在一些情况下都是必须的。
不过同样的机制并没有出现在自定义的类型转换符上。这就允许了一个逆向的过程,从自定义类型转向一个已知类型。这样虽然出现问题的几率远小于从已知类型构造自定义类型,不过有的时候,我们确实应该阻止会产生歧义的隐式转换。让我们来看看代码清单3-27所示的例子,该例子来源于C++11提案。
#include
using namespace std;
template<typename T>
class Ptr {
public:
Ptr(T* p) :_p(p) {}
operator bool()const {
if (_p != 0)
return true;
else
return false;
}
private:
T* _p;
};
int main() {
int a;
Ptr<int> p(&a);
if (p)//自动转换为bool型,没有问题
cout<<"valid pointer."<<endl;//valid pointer.
else
cout<<"invalid pointer."<<endl;
Ptr<double> pd(0);
cout<<p + pd<<endl;//1,相加,语义上没有意义
}
//编译选项:g++3-4-2.cpp
在代码清单3-27中,我们定义了一个指针模板类型Ptr。为了方便判断指针是否有效,我们为指针编写了自定义类型转换到bool类型的函数,这样一来,我们就可以通过if§这样的表达式来轻松地判断指针是否有效。不过这样的转换使得Ptr<int>和Ptr<double>两个指针的加法运算获得了语法上的允许。不过明显地,我们无法看出其语义上的意义。
在C++11中,标准将explicit的使用范围扩展到了自定义的类型转换操作符上,以支持所谓的“显式类型转换”。explicit关键字作用于类型转换操作符上,意味着只有在直接构造目标类型或显式类型转换的时候可以使用该类型。我们可以看看代码清单3-28所示的例子。
class ConvertTo{};
class Convertable{
public:
explicit operator ConvertTo()const{return ConvertTo();}
};
void Func(ConvertTo ct){}
void test(){
Convertable c;
ConvertTo ct(c);//直接初始化,通过
ConvertTo ct2=c;//拷贝构造初始化,编译失败
ConvertTo ct3=static_cast<ConvertTo>(c);//强制转化,通过
Func(c);//拷贝构造初始化,编译失败
}
//编译选项:g++ -std=c++11 3-4-3.cpp
在代码清单3-28中,我们定义了两个类型ConvertTo和Convertable,Convertable定义了一个显式转换到ConvertTo类型的类型转换符。那么对于main中ConvertTo类型的ct变量而言,由于其直接初始化构造于Convertable变量c,所以可以编译通过。而做强制类型转换的ct3同样通过了编译。而ct2由于需要从c中拷贝构造,因而不能通过编译。此外,我们使用函数Func的时候,传入Convertable的变量c的也会导致参数的拷贝构造,因此也不能通过编译。
如果我们把该方法用于代码清单3-27中,可以发现我们预期的事情就发生了,if§可以通过编译,因为可以通过p直接构造出bool类型的变量。而p+pd这样的语句就无法通过编译了,这是由于全局的operator+并不接受bool类型变量为参数,而Convertable也不能直接构造出适用于operator+的int类型的变量造成的(不过读者可以尝试一下使用p&&pd这样的表达式,是能够通过编译的)。这样一来,程序的行为将更加良好。
可以看到,所谓显式类型转换并没完全禁止从源类型到目标类型的转换,不过由于此时拷贝构造和非显式类型转换不被允许,那么我们通常就不能通过赋值表达式或者函数参数的方式来产生这样一个目标类型。通常通过赋值表达式和函数参数进行的转换有可能是程序员的一时疏忽,而并非本意。那么使用了显式类型转换,这样的问题就会暴露出来,这也是我们需要显式转换符的一个重要原因。
在C++98中,标准允许使用花括号"{}"对数组元素进行统一的集合(列表)初始值设定,比如:
int arr[5]={0};
int arr[]={1,2,3,4};
这些都是合法的表达式。不过一些自定义类型,却无法享受这样便利的初始化。通常,如标准程序库中的vector这样的容器,总是需要声明对象-循环初始化这样的重复动作,这对于使用模板的泛型编程无疑是非常不利的。
在2.7节中,我们看到了C++11对类成员的快速就地初始化。有一种初始化形式就是使用花括号的集合(列表)初始化。而事实上,在C++11中,集合(列表)的初始化已经成为C++语言的一个基本功能,在C++11中,这种初始化的方法被称为“初始化列表”(initializer list)。让我们来看看代码清单3-29所示的这个例子。
#include
#include
using namespace std;
int a[] = { 1,3,5 };//C++98通过,C++11通过
int b[]{ 2,4,6 };//C++98失败,C++11通过
vector<int> c{ 1,3,5 };//C++98失败,C++11通过
map<int, float>d =
{ {1,1.0f},{2,2.0f},{5,3.2f} };//C++98失败,C++11通过
//编译选项:g++ -c-std=c++11 3-5-1.cpp
在代码清单3-29中,我们看到了变量b、c、d,在C++98的情况下均无法通过编译,在C++11中,却由于列表初始化的存在而可以通过编译。这里,列表初始化可以在“{}”花括号之前使用等号,其效果与不带使用等号的初始化相同。
这样一来,自动变量和全局变量的初始化在C++11中被丰富了。程序员可以使用以下几种形式完成初始化的工作:
❑等号“=”加上赋值表达式(assignment-expression),比如inta=3+4。
❑等号“=”加上花括号式的初始化列表,比如int a={3+4}。
❑圆括号式的表达式列表(expression-list),比如int a(3+4)。
❑花括号式的初始化列表,比如int a{3+4}。
而后两种形式也可以用于获取堆内存new操作符中,比如:
int*i=new int(1);
double*d=new double{1.2f};
这在C++11中也是合法的表达式。
代码清单3-29中可能令读者比较惊讶的是,使用初始化列表对vector、map等非内置的复杂的数据类型进行初始化竟然也是可以的。进一步地,读者可能会猜测是否初始化列表是专属于内置类型、数组,以及标准模板库中容器的功能呢?
事实并非如此,如同我们所提到的,在C++11中,标准总是倾向于使用更为通用的方式来支持新的特性。标准模板库中容器对初始化列表的支持源自<initializer_list>这个头文件中initialize_list类模板的支持。程序员只要#include了<initializer_list>头文件,并且声明一个以initialize_list<T>模板类为参数的构造函数,同样可以使得自定义的类使用列表初始化。让我们来看一看代码清单3-30的例子。
#include <vector>
#include <string>
using namespace std;
enum Gender{boy,girl};
class People{
public:
People(initializer_list<pair<string,Gender>>l){//initializer_list的构造函数
auto i=l.begin();
for(;i!=l.end();++i)
data.push_back(*i);
}
private:
vector<pair<string,Gender>>data;
};
People ship2012={{"Garfield",boy},{"HelloKitty",girl}};
//编译选项:g++ -c-std=c++11 3-5-2.cpp
在代码清单3-30中,我们为类People定义了一个使用initializer_list<pair<string,Gender>>模板类作为参数的构造函数。这里我们使用了C++11的auto关键字来自动类型推导以简化代码的编写(其意义比较明显,这里就不展开解释了,详情请查看4.2节)。由于该构造函数的存在,ship2012声明就可以使用列表初始化了。事实上,编写一个列表初始化的构造函数并不困难。对于旧有的代码,列表初始化构造函数还常常可以调用已有的代码来实现。
同样的,函数的参数列表也可以使用初始化列表,如代码清单3-31所示。
#include
using namespace std;
void Fun(initializer_list<int> iv) {}
int main() {
Fun({ 1,2 });
Fun({});//空列表
}
//编译选项:g++ -std=c++11 3-5-3.cpp
在代码清单3-31中,定义了一个可以接受初始化列表的函数Fun。同理,类和结构体的成员函数也可以使用初始化列表,包括一些操作符的重载函数。而在代码清单3-32所示的这个例子中,我们利用了初始化列表重载了operator[],并且重载了operator=以及使用辅助的数组。虽然这个例子比较复杂,但重载的效果还是能够让人感觉眼前一亮的。
#include
#include
using namespace std;
class Mydata {
public:
Mydata&operator[](initializer_list<int> l)
{
for (auto i = l.begin(); i != l.end(); ++i)
idx.push_back(*i);
return*this;
}
Mydata&operator = (int v)
{
if (idx.empty() != true) {
for (auto i = idx.begin(); i != idx.end(); ++i) {
d.resize((*i>d.size()) ? *i : d.size());
d[*i - 1] = v;
}
idx.clear();
}
return*this;
}
void Print() {
for (auto i = d.begin(); i != d.end(); ++i)
cout<<* i<<"";
cout<<endl;
}
private:
vector<int> idx;//辅助数组,用于记录index
vector<int> d;
};
int main() {
Mydata d;
d[{2, 3, 5}] = 7;
d[{1, 4, 5, 8}] = 4;
d.Print();//4 7 7 4 4 0 0 4
}
//编译选项:g++ -std=c++11 3-5-4.cpp
在代码清单3-32中,我们看到自定义类型Mydata拥有一个以前所有C++代码都不具备的功能,即可以在[]符号中使用列表,将设置数组中的部分为一个指定的值。在这里我们先把数组的第2、3、5位设为数值7,而后又将其1、4、5、8位设为数值4,最终我们得到数组的内容为“4 7 7 4 4 0 0 4”。读者可以自行分析一下代码的实现方式(这段代码比较粗糙,读者应该重点体会初始化列表带来的编程上的灵活性)。当然,由于内置的数组不能重载operator[],我们也就无法为其实现相应的功能。
此外,初始化列表还可以用于函数返回的情况。返回一个初始化列表,通常会导致构造一个临时变量,比如:
vector<int> Func(){return{1,3};}
当然,跟声明时采用列表初始化一样,列表初始化构造成什么类型是依据返回类型的,比如:
deque<int> Func2(){return{3,5};}
上面的返回值就是以deque<int>列表初始化构造函数而构造的。而跟普通的字面量相同,如果返回值是一个引用类型的话,则会返回一个临时变量的引用。比如:
const vector<int> &Func1(){return{3,5};}
这里注意,必须要加const限制符。该规则与返回一个字面常量是一样的。
使用列表初始化还有一个最大优势是可以防止类型收窄(narrowing)。类型收窄一般是指一些可以使得数据变化或者精度丢失的隐式类型转换。可能导致类型收窄的典型情况如下:
❑从浮点数隐式地转化为整型数。比如:int a=1.2,这里a实际保存的值为整数1,可以视为类型收窄。
❑从高精度的浮点数转为低精度的浮点数,比如从long double隐式地转化为double,或从double转为float。如果这些转换导致精度降低,都可以视为类型收窄。
❑从整型(或者非强类型的枚举)转化为浮点型,如果整型数大到浮点数无法精确地表示,则也可以视为类型收窄。
❑从整型(或者非强类型的枚举)转化为较低长度的整型,比如:unsigned char=1024,1024明显不能被一般长度为8位的unsigned char所容纳,所以也可以视为类型收窄。
值得注意的是,如果变量a从类型A转化为类型B,其值在B中也是可以被表示的,且再转化回类型A能获得原有的值的话,那么这种类型转换也不能叫作类型收窄。所以类型收窄也可以简单地理解为新类型无法表示原有类型数据的值的情况。事实上,发生类型收窄通常也是危险的,应引起程序员的注意。因此,在C++11中,使用初始化列表进行初始化的数据编译器是会检查其是否发生类型收窄的。我们来看看代码清单3-33所示的这个例子。
const int x=1024;
const int y=10;
char a=x;//收窄,但可以通过编译
char*b=new char(1024);//收窄,但可以通过编译
char c={x};//收窄,无法通过编译
char d={y};//可以通过编译
unsigned char e{-1};//收窄,无法通过编译
float f{7};//可以通过编译
int g{2.0f};//收窄,无法通过编译
float*h=new float{1e48};//收窄,无法通过编译
float i=1.2l;//可以通过编译
//编译选项:clang++-std=c++11 3-5-5.cpp
在例子代码清单3-33中,我们定义了a到i一共9个需要初始化的变量。可以看到,对于变量a和*b而言,由于其采用的是赋值表达符及圆括号式的表达式初始化,所以虽然它们的数据类型明显收窄(char通常取值范围为-128到127),却不会引发编译失败(事实上,在我们的实验机上会得到编译器的警告)。而使用初始化列表的情况则不一样。对于变量c,由于其类型收窄,则会导致编译器报错。而对于变量d来说,其初始化使用了常量值10,而10是可以由char类型表示的,因此这里不会发生收窄,编译可以通过。同样的情况还发生在变量f、i的初始化上。虽然初始化语句中的变量类型往往“大于”变量声明的类型,但是由于值在f、i中可以表示,还可以被转回原有类型不发生数据改变或者精度错误等,因此也不能算收窄。
比较容易引起疑问的是无符号类型的变量e。虽然按理说e如果再被转换为有符号数,其值依然是-1,但对于无符号数而言,并不能表示-1,因此这里我们也认为e的初始化有收窄的情况。另外,f和g的差别在于2.0f是一个有精度的浮点数值,通常可以认为,将2.0f转换成整型会丢失精度,所以g的声明也是收窄的。
在C++11中,列表初始化是唯一一种可以防止类型收窄的初始化方式。这也是列表初始化区别于其他初始化方式的地方。事实上,现有编译器大多数会在发生类型收窄的时候提示用户,因为类型收窄通常是代码可能出现问题的征兆。C++11将列表初始化设定为可以防范类型收窄,也就是为了加强类型使用的安全性。
总的来说,列表初始化改变了C++中对类型初始化的一些基本模式,将标准程序库跟语言拉得更近了。这样的做法有效地统一了内置类型和自定义类型的行为。这也是C++11设计所遵循的一个思想,即通用为本,专用为末。
POD是英文中Plain Old Data的缩写。POD在C++中是非常重要的一个概念,通常用于说明一个类型的属性,尤其是用户自定义类型的属性。POD属性在C++11中往往又是构建其他C++概念的基础,事实上,在C++11标准中,POD出现的概率相当高。因此学习C++,尤其是在C++11中,了解POD的概念是非常必要的。
POD意如其名。Plain,表示了POD是个普通的类型,在C++中常见的类型都有这样的属性,而不像一些存在着虚函数虚继承的类型那么特别。而Old则体现了其与C的兼容性,比如可以用最老的memcpy()函数进行复制,使用memset()进行初始化等。当然,这样的描述都太过于笼统,具体地,C++11将POD划分为两个基本概念的合集,即:平凡的(trivial)和标准布局的(standard layout)。
我们先来看一下平凡的定义。通常情况下,一个平凡的类或结构体应该符合以下定义:
1、 拥有平凡的默认构造函数(trivial constructor)和析构函数(trivial destructor)。
平凡的默认构造函数就是说构造函数“什么都不干”。通常情况下,不定义类的构造函数,编译器就会为我们生成一个平凡的默认构造函数。而一旦定义了构造函数,即使构造函数不包含参数,函数体里也没有任何的代码,那么该构造函数也不再是“平凡”的。比如:
struct NoTrivial{NoTrivial();};
在NoTrivial的定义中,构造函数就不是平凡的,这对于析构函数来讲也类似。但这样的类型声明并非“无可救药”地“非平凡化”(non-trivial)了,在第7章中,可以看到如何使用=default关键字来显式地声明缺省版本的构造函数,从而使得类型恢复“平凡化”。
2、 拥有平凡的拷贝构造函数(trivial copy constructor)和移动构造函数(trivial move constructor)。
平凡的拷贝构造函数基本上等同于使用memcpy进行类型的构造。同平凡的默认构造函数一样,不声明拷贝构造函数的话,编译器会帮程序员自动地生成。同样地,可以显式地使用=default声明默认拷贝构造函数。
而平凡移动构造函数跟平凡的拷贝构造函数类似,只不过是用于移动语义。
3、 拥有平凡的拷贝赋值运算符(trivial assignment operator)和移动赋值运算符(trivialmove operator)。
这基本上与平凡的拷贝构造函数和平凡的移动构造运算符类似。
4、 不能包含虚函数以及虚基类。
以上4点虽然看似复杂,不过在C++11中,我们可以通过一些辅助的类模板来帮我们进行以上属性的判断。
template<typename T>struct std::is_trivial;
类模板is_trivial的成员value可以用于判断T的类型是否是一个平凡的类型。除了类和结构体外,is_trivial还可以对内置的标量类型数据(比如int、float都属于平凡类型)及数组类型(元素是平凡类型的数组总是平凡的)进行判断。
我们可以看看代码清单3-34所示的例子。
#include
#include
using namespace std;
struct Trivial1 {};
struct Trivial2 {
public:
int a;
private:
int b;
};
struct Trivial3 {
Trivial1 a;
Trivial2 b;
};
struct Trivial4 {
Trivial2 a[23];
};
struct Trivial5 {
int x;
static int y;
};
struct NonTrivial1 {
NonTrivial1() :z(42) {}
int z;
};
struct NonTrivial2 {
NonTrivial2();
int w;
};
NonTrivial2::NonTrivial2() = default;
struct NonTrivial3 {
Trivial5 c;
virtual void f();
};
int main() {
cout<<is_trivial<Trivial1>::value<<endl;//1
cout<<is_trivial<Trivial2>::value<<endl;//1
cout<<is_trivial<Trivial3>::value<<endl;//1
cout<<is_trivial<Trivial4>::value<<endl;//1
cout<<is_trivial<Trivial5>::value<<endl;//1
cout<<is_trivial<NonTrivial1>::value<<endl;//0
cout<<is_trivial<NonTrivial2>::value<<endl;//0
cout<<is_trivial<NonTrivial3>::value<<endl;//0
return 0;
}
//编译选项:g++ -std=c++11 3-6-1.cpp
读者可以依照代码清单3-34的输出结果核对上面提到的4种规则。
POD包含的另外一个概念是标准布局。标准布局的类或结构体应该符合以下定义:
1、 所有非静态成员有相同的访问权限(public,private,protected)。
这一点非常好理解,比如:
struct{
public:
int a;
private:
int b;
};
成员a和b就拥有不同的访问权限,因此该匿名结构体不是标准布局的。如果去掉private关键字的话,那么,该匿名结构体就符合标准布局的定义了。
struct{
public:
int a;
int b;
};
2、 在类或者结构体继承时,满足以下两种情况之一:
❑派生类中有非静态成员,且只有一个仅包含静态成员的基类。
❑基类有非静态成员,而派生类没有非静态成员。
这样的类或者结构体,也是标准布局的。比如下面的例子:
struct B1{static int a;};
struct D1:B1{int d;};
struct B2{int a;};
struct D2:B2{static int d;};
struct D3:B2,B1{static int d;};
struct D4:B2{int d;};
struct D5:B2,D1{};
D1、D2和D3都是标准布局的,而D4和D5则不属于标准布局的。这实际上使得非静态成员只要同时出现在派生类和基类间,其即不属于标准布局的。而多重继承也会导致类型布局的一些变化,所以一旦非静态成员出现在多个基类中,派生类也不属于标准布局的。
3、 类中第一个非静态成员的类型与其基类不同。
这条规则非常特别,用于形如:
struct A:B{B b;};
这样的情况。这里A类型不是一个标准布局的类型,因为第一个非静态成员变量b的类型跟A所继承的类型B相同。而形如:
struct C:B{int a;B b;};
则是一个标准布局的类型。
读者可能对这个规则感到不解,不过该规则实际上是基于C++中允许优化不包含成员的基类而产生的。我们可以看看代码清单3-35这个例子。
#include
using namespace std;
struct B1 {};
struct B2 {};
struct D1 :B1 {
B1 b;//第一个非静态变量跟基类相同
int i;
};
struct D2 :B1 {
B2 b;
int i;
};
int main() {
D1 d1;
D2 d2;
cout<<hex;
cout<<reinterpret_cast<long long>(&d1)<<endl;
//7ffffd945c60
cout<<reinterpret_cast<long long>(&(d1.b))<<endl;
//7ffffd945c61
cout<<reinterpret_cast<long long>(&(d1.i))<<endl;
//7ffffd945c64
cout<<reinterpret_cast<long long>(&d2)<<endl;
//7ffffd945c50
cout<<reinterpret_cast<long long>(&(d2.b))<<endl;
//7ffffd945c50
cout<<reinterpret_cast<long long>(&(d2.i))<<endl;
//7ffffd945c54
}
//编译选项:g++ -std=c++11 3-6-2.cpp
在代码清单3-35中,我们声明了4个类。其中两个没有成员的基类B1和B2,以及两个派生于B1的派生类D1和D2。D1和D2唯一的区别是第一个非静态成员的类型。在D1中,第一个非静态成员的类型是B1,这跟它的基类相同;而D2中,第一个非静态成员的类型则是B2。直观地看,D1和D2应该是“布局相同”的,程序员应该可以使用memcpy这样的函数在这两种类型间进行拷贝,但实际上却并不是这样。
我们可以看看main函数中的状况。在main中,将D1类型的变量d1以及D2类型的变量d2的地址分别打印出来。同时我们也把它们的成员的地址打印出来。可以看到,对于d2,它和它的成员共享了一个地址(本例中,实验机上的结果为7ffffd945c50),而对于d1却没有出现类似的情况。
事实上,在C++标准中,如果基类没有成员,标准允许派生类的第一个成员与基类共享地址。因为派生类的地址总是“堆叠”在基类之上的,所以这样的地址共享,表明了基类并没有占据任何的实际空间(可以节省一点数据)。但是如果基类的第一个成员仍然是基类,在我们的例子中可以看到,编译器仍然会为基类分配1字节的空间。分配为1字节空间是由于C++标准要求类型相同的对象必须地址不同(基类地址及派生类中成员d的地址必须不同),而导致的结果是,对于D1和D2两种类型而言,其“布局”也就是不同的了。我们可以看看如图3-3所示的示意图。
所以在标准布局的解释中,C++11标准强制要求派生类的第一个非静态成员的类型必须不同于基类。
4、 没有虚函数和虚基类。
5、 所有非静态数据成员均符合标准布局类型,其基类也符合标准布局。这是一个递归的定义,没有什么好特别解释的。
以上5点构成了标准布局的含义,最为重要的应该是前两条。
同样,在C++11中,我们可以使用模板类来帮助判断类型是否是一个标准布局的类型。
template <typename T> struct std::is_standard_layout;
通过is_standard_layout模板类的成员value(is_standard_layout<T>::value),我们可以在代码中打印出类型的标准布局属性。那么,通过代码清单3-36所示的这个例子,我们可以加深一下对标准类型的理解。
#include
#include
using namespace std;
struct SLayout1 {};
struct SLayout2 {
private:
int x;
int y;
};
struct SLayout3 :SLayout1 {
int x;
int y;
void f();
};
struct SLayout4 :SLayout1 {
int x;
SLayout1 y;
};
struct SLayout5 :SLayout1, SLayout3 {};
struct SLayout6 { static int y; };
struct SLayout7 :SLayout6 { int x; };
struct NonSLayout1 :SLayout1 {
SLayout1 x;
int i;
};
struct NonSLayout2 :SLayout2 { int z; };
struct NonSLayout3 :NonSLayout2 {};
struct NonSLayout4 {
public:
int x;
private:
int y;
};
int main() {
cout<<is_standard_layout<SLayout1>::value<<endl;//1
cout<<is_standard_layout<SLayout2>::value<<endl;//1
cout<<is_standard_layout<SLayout3>::value<<endl;//1
cout<<is_standard_layout<SLayout4>::value<<endl;//0
cout<<is_standard_layout<SLayout5>::value<<endl;//0
cout<<is_standard_layout<SLayout6>::value<<endl;//1
cout<<is_standard_layout<SLayout7>::value<<endl;//1
cout<<is_standard_layout<NonSLayout1>::value<<endl;//0
cout<<is_standard_layout<NonSLayout2>::value<<endl;//0
cout<<is_standard_layout<NonSLayout3>::value<<endl;//0
cout<<is_standard_layout<NonSLayout4>::value<<endl;//0
return 0;
}
//编译选项:g++ -std=c++11 3-6-3.cpp
同样地,读者可以对照我们上述的5条规则,自行分析一下代码清单3-36中的情况。
那么,我们现在回到POD来,对于POD而言,在C++11中的定义就是平凡的和标准布局的两个方面。同样地,要判定某一类型是否是POD,标准库中的<type_traits>头文件也为程序员提供了如下模板类:
template<typename T>struct std::is_pod;
我们可以使用std::is_pod<T>::value来判定一个类型是否是POD,如代码清单3-37所示。
#include
#include
using namespace std;
union U {};
union U1 { U1() {} };
enum E {};
typedef double* DA;
typedef void(*PF)(int, double);
int main() {
cout<<is_pod<U>::value<<endl;//1
cout<<is_pod<U1>::value<<endl;//0
cout<<is_pod<E>::value<<endl;//1
cout<<is_pod<int>::value<<endl;//1
cout<<is_pod<DA>::value<<endl;//1
cout<<is_pod<PF>::value<<endl;//1
}
//编译选项:g++ -std=c++11 3-6-4.cpp
事实上,如我们在代码清单3-37中看到的一样,很多内置类型默认都是POD的。POD最为复杂的地方还是在类或者结构体的判断。不过通过上面平凡和标准布局的判断,相信读者对POD已经有所理解。那么,使用POD有什么好处呢?
我们看得到的大概有如下3点:
1、字节赋值,代码中我们可以安全地使用memset和memcpy对POD类型进行初始化和拷贝等操作。
2、提供对C内存布局兼容。C++程序可以与C函数进行相互操作,因为POD类型的数据在C与C++间的操作总是安全的。
3、保证了静态初始化的安全有效。静态初始化在很多时候能够提高程序的性能,而POD类型的对象初始化往往更加简单(比如放入目标文件的.bss段,在初始化中直接被赋0)。
如我们所提到的,理解POD对理解C++11中其他概念非常重要,之后我们还会在本书中看到很多引用POD的地方。
在C/C++中,联合体(Union)是一种构造类型的数据结构。在一个联合体内,我们可以定义多种不同的数据类型,这些数据将会共享相同内存空间,这在一些需要复用内存的情况下,可以达到节省空间的目的。不过,根据C++98标准,并不是所有的数据类型都能够成为联合体的数据成员。我们先来看一段代码,如代码清单3-38所示。
struct Student{
Student(bool g,int a):gender(g),age(a){}
bool gender;
int age;
};
union T{
Student s;//编译失败,不是一个POD类型
int id;
char name[10];
};
//编译选项:g++ -std=c++98 3-7-1.cpp
在代码清单3-38中,我们声明了一个Student的类型。根据在3.6节中学习到的知识,由于Student自定义了一个构造函数,所以该类型是非POD的。在C++98标准中,union T是无法通过编译的。事实上,除了非POD类型之外,C++98标准也不允许联合体拥有静态或引用类型的成员。这样虽然可能在一定程度上保证了和C的兼容性,不过也为联合体的使用带来了很大的限制。
而且通过长期的实践应用证明,C++98标准对于联合体的限制是完全没有必要的。随着C++的发展,C与C++在一些方面存在着事实的不同。比如典型的“复数”类型complex,由于C语言中的复数遵从代码运行平台的二进制接口的规定,通常是一个平台上的内置类型。而在C++中,复数则常常是一个模板来实现的。这样一来,形如下面这样的C++声明,通常C++98编译器认为是不合法的。
union{
double d;
complex<double> cd;//错误,complex不是一个POD类型
};
形如下面这样的C声明则被认为是合法的代码。
union{
double d;
complex cd;
};
这样一来,联合体保持与C一定程度上的兼容的特性也渐渐形同虚设。因此,在新的C++11标准中,取消了联合体对于数据成员类型的限制。标准规定,任何非引用类型都可以成为联合体的数据成员,这样的联合体即所谓的非受限联合体(Unrestricted Union)。所以如果使用g++或者clang++中的-std=c++11选项编译代码清单3-38的例子,是能够通过的。此外,联合体拥有静态成员(在非匿名联合体中)的限制,也在C++11新标准中被删除了。不过从实践中,我们发现C++11的规则不允许静态成员变量的存在(否则所有该类型的联合体将共享一个值)。而静态成员函数存在的唯一作用,大概就是为了返回一个常数,我们可以看看代码清单3-39所示的例子。
#include
using namespace std;
union T{static long Get(){return 32;}};
int main(){
cout<<T::Get()<<endl;
}
//编译选项:g++ -std=c++11 3-7-2.cpp
在代码清单3-39中,我们就定义了一个有静态成员函数的联合体。不过看起来这里的union T更像是一个作用域限制符,并没有太大的实用意义。
在C++98中,标准规定了联合体会自动对未在初始化成员列表中出现的成员赋默认初值。然而对于联合体而言,这种初始化常常会带来疑问,因为在任何时刻只有一个成员可以是有效的,如代码清单3-40所示。
union T{
int x;
double d;
char b[sizeof(double)];
};
T t={0};//到底是初始化第一个成员还是所有成员呢?
//编译选项:g++ -std=c++98-c 3-7-3.cpp
代码清单3-40中使用了花括号组成的初始化列表,试图将成员变量x初始化为零,即整个联合体的数据t中低位的4字节被初始化为0,然而实际上,t所占的8个字节将全部被置0。
而在C++11中,为了减少这样的疑问,标准会默认删除一些非受限联合体的默认函数。比如,非受限联合体有一个非POD的成员,而该非POD成员类型拥有有非平凡的构造函数,那么非受限联合体成员的默认构造函数将被编译器删除。其他的特殊成员函数,例如默认拷贝构造函数、拷贝赋值操作符以及析构函数等,也将遵从此规则。我们可以看看代码清单3-41所示的这个例子。
#include
using namespace std;
union T{
string s;//string有非平凡的构造函数
int n;
};
int main(){
T t;//构造失败,因为T的构造函数被删除了
}
//编译选项:g++ -std=c++11 3-7-4.cpp
在代码清单3-41中,联合体T拥有一个非POD的成员s。而string有非平凡的构造函数,因此T的构造函数被删除,其类型的变量t也就无法声明成功。解决这个问题的办法是,由程序员自己为非受限联合体定义构造函数。通常情况下,placement new会发挥很好的作用,如代码清单3-42所示。
#include
using namespace std;
union T{
string s;
int n;
public:
//自定义构造函数和析构函数
T(){new(&s)string;}
~T(){s.~string();}
};
int main(){
T t;//构造析构成功
}
//编译选项:g++ -std=c++11 3-7-5.cpp
在代码清单3-42中,我们定义了union T的构造和析构函数。构造时,采用placement new将s构造在其地址&s上。这里placement new的唯一作用只是调用了一下string的构造函数。而在析构时,又调用了string的析构函数。读者必须注意的是,析构的时候union T也必须是一个string对象,否则可能导致析构的错误(或者让析构函数为空,至少不会造成运行时错误)。这样一来,变量t的声明就可以通过编译了。
匿名非受限联合体可以运用于类的声明中,这样的类也被称为“枚举式的类”(union-like class)。我们可以看看代码清单3-43所示的例子。
#include
using namespace std;
struct Student {
Student(bool g, int a) :gender(g), age(a) {}
bool gender;
int age;
};
class Singer {
public:
enum Type { STUDENT, NATIVE, FOREIGNER };
Singer(bool g, int a) :s(g, a) { t = STUDENT; }
Singer(int i) :id(i) { t = NATIVE; }
Singer(const char* n, int s) {
int size = (s>9) ? 9 : s;
memcpy(name, n, size);
name[s] = '\0';
t = FOREIGNER;
}
~Singer() {}
private:
Type t;
union {//匿名的非受限联合体
Student s;
int id;
char name[10];
};
};
int main() {
Singer(true, 13);
Singer(310217);
Singer("J Michael", 9);
}
//编译选项:g++ -std=c++11 3-7-6.cpp
在代码清单3-43中,我们也把匿名非受限联合体成为类Singer的“变长成员”(variant member)。可以看到,这样的变长成员给类的编写带来了更大的灵活性,这是C++98标准中无法达到的。
在C/C++程序中,程序员常常会使用结构体或者类来创造新的类型,以满足实际的需求。比如在进行科学计算时,用户可能需要用到复数(通常会包含实部和虚部两部分)。而对于颜色,用户通常会需要一个四元组(三原色及Alpha)。而对于奥运会组委会,他们则常常会需要七元组(标示来自七大洲的状况)。不过,有的时候自定义类型也会有些书写的麻烦,尤其是用户想声明一个自定义类型的“字面量”(literal)的时候。我们可以看看代码清单3-44所示的例子。
#include
using namespace std;
typedef unsigned char uint8;
struct RGBA {
uint8 r;
uint8 g;
uint8 b;
uint8 a;
RGBA(uint8 R, uint8 G, uint8 B, uint8 A = 0) :
r(R), g(G), b(B), a(A) {}
};
std::ostream&operator<<(std::ostream&out, RGBA&col) {
return out<<"r:"<<(int)col.r
<<",g:"<<(int)col.g
<<",b:"<<(int)col.b
<<",a:"<<(int)col.a<<endl;
}
void blend(RGBA&col1, RGBA&col2) {
cout<<"blend"<<endl<<col1<<col2<<endl;
}
int main() {
RGBA col1(255, 240, 155);
RGBA col2({ 15,255,10,7 });//C++11风格的列表初始化
blend(col1, col2);
}
//编译选项:g++ -std=c++11 3-8-1.cpp
在代码清单3-44所示的例子中,我们在main函数中想对两个确定的RGBA变量进行运算。这里我们采用了传统的方式,即先声明两个RGBA的变量,并且赋予相应初值,再将其传给函数blend。程序员在编写测试用例的时候,常会遇到需要声明较多值确定的RGBA变量。那么这样的声明变量-传值运算的方式是件非常麻烦的。如果自定义类型可以像内置类型一样向函数传递字面常量,比如向函数func传递字面常量func(2,5.0f),无疑这样的测试代码会简化很多。
C++11标准允许了这种想象,即我们可以通过定一个后缀标识的操作符,将声明了该后缀标识的字面量转化为需要的类型。我们可以看一看代码清单3-45所示的代码。
#include
#include
using namespace std;
typedef unsigned char uint8;
struct RGBA {
uint8 r;
uint8 g;
uint8 b;
uint8 a;
RGBA(uint8 R, uint8 G, uint8 B, uint8 A = 0) :
r(R), g(G), b(B), a(A) {}
};
RGBA operator""_C(const char* col, size_t n) {//一个长度为n的字符串col
const char* p = col;
const char* end = col + n;
const char* r, * g, * b, * a;
r = g = b = a = nullptr;
for (; p != end; ++p) {
if (*p == 'r')r = p;
else if (*p == 'g')g = p;
else if (*p == 'b')b = p;
else if (*p == 'a')a = p;
}
if ((r == nullptr) || (g == nullptr) || (b == nullptr))
throw;
else if (a == nullptr)
return RGBA(atoi(r + 1), atoi(g + 1), atoi(b + 1));
else
return RGBA(atoi(r + 1), atoi(g + 1), atoi(b + 1), atoi(b + 1));
}
std::ostream&operator<<(std::ostream&out, RGBA&col) {
return out<<"r:"<<(int)col.r
<<",g:"<<(int)col.g
<<",b:"<<(int)col.b
<<",a:"<<(int)col.a<<endl;
}
void blend(RGBA&&col1, RGBA&&col2) {
//Some color blending action
cout<<"blend"<<endl<<col1<<col2<<endl;
}
int main() {
blend("r255 g240 b155"_C, "r15 g255 b10 a7"_C);
}
//编译选项:g++ -std=c++11 3-8-2.cpp
这里,我们声明了一个字面量操作符(literal operator)函数:RGBA operator""_C(const char*col,size_t n)
函数。这个函数会解析以_C为后缀的字符串,并返回一个RGBA的临时变量。有了这样一个用户字面常量的定义,main函数中我们不再需要通过声明RGBA类型的声明变量-传值运算的方式来传递实际意义上的常量。通过声明一个字符串以及一个_C后缀,operator""_C函数会产生临时变量。blend函数就可以通过右值引用获得这些临时值并进行计算了。这样一来,用户就完成了定义自定义类型的字面常量,main函数中的代码书写显得更加清晰。
除去字符串外,后缀声明也可以作用于数值,比如,用户可能使用60W、120W的表示方式来标识功率,用50kg来表示质量,用1200N来表示力等。请看代码清单3-46所示的例子。
struct Watt{unsigned int v;};
Watt operator""_w(unsigned long long v){
return{(unsigned int)v};
}
int main(){
Watt capacity=1024_w;
}
//编译选项:g++ -std=c++11 3-8-3.cpp
这里我们用_w后缀来标识瓦特。除了整型数,用户自定义字面量还可以用于浮点型数等的字面量。不过必须注意的是,在C++11中,标准要求声明字面量操作符有一定的规则,该规则跟字面量的“类型”密切相关。
C++11中具体规则如下:
❑如果字面量为整型数,那么字面量操作符函数只可接受unsigned long long或者const char* 为其参数。当unsigned long long无法容纳该字面量的时候,编译器会自动将该字面量转化为以\0为结束符的字符串,并调用以const char* 为参数的版本进行处理。
❑如果字面量为浮点型数,则字面量操作符函数只可接受long double或者const char* 为参数。const char* 版本的调用规则同整型的一样(过长则使用const char* 版本)。
❑如果字面量为字符串,则字面量操作符函数函数只可接受const char* ,size_t为参数(已知长度的字符串)。
❑如果字面量为字符,则字面量操作符函数只可接受一个char为参数。
总体上来说,用户自定义字面量为代码书写带来了极大的便利。此外,在使用这个特性的时候,应该注意以下几点:
❑在字面量操作符函数的声明中,operator""与用户自定义后缀之间必须有空格。
❑后缀建议以下划线开始。不宜使用非下划线后缀的用户自定义字符串常量,否则会被编译器警告。当然,这也很好理解,因为如果重用形如201203L这样的字面量,后缀“L”无疑会引起一些混乱的状况。为了避免混乱,程序员最好只使用下划线开始的后缀名。
在老式的C语言编程的实际项目中,我们常会需要一个“字典”来记录程序中所有的名字。这是由于C中所有的非静态全局变量、非静态的函数名都是都是全局共享的。那么对于多个程序员合作编程而言,总是需要知道自己给变量函数取的名字是否冲突,以避免发生编译错误,因此字典是一种使用C语言合作编程的一种重要交流手段。
在C++中,引入了名字空间(namespace)这样一个概念。名字空间的目的是分割全局共享的名字空间。程序员在编写程序时可以建立自己的名字空间,而使用者则可以通过双冒号“空间名::函数/变量名”的形式来引用自己需要的版本。这就解决了C中名字冲突的问题。不过有很多时候,我们会遇到一个名字空间下包含多个子名字空间的状况。子名字空间通常会带来一些使用上的不便。我们来看看代码清单3-47所示的这个例子。
#include
using namespace std;
//这是Jim编写的库,用了Jim这个名字空间
namespace Jim {
namespace Basic {
struct Knife { Knife() { cout << "Knife in Basic." << endl; } };
class CorkScrew {};
}
namespace Toolkit {
template<typename T>class SwissArmyKnife {};
}
//...
namespace Other {
Knife b;//无法通过编译
struct Knife { Knife() { cout << "Knife in Other" << endl; } };
Knife c;//Knife in Other
Basic::Knife k;//Knife in Basic
}
}
//这是LiLei在使用Jim的库
using namespace Jim;
int main(){
Toolkit::SwissArmyKnife<Basic::Knife>sknife;
}
//编译选项:g++3-9-1.cpp
在代码清单3-47中,库的编写者Jim用名字空间将自己的代码封装起来。同时,该程序员把名字空间继续细分为Basic、Toolkit及Other等几个。可以看到,通过名字空间的细分,Other名字空间中不能直接引用Basic名字空间中的名字Knife。而Other名字空间中定义了Knife类型,那么变量c的声明就会导致其使用的Knife类型是属于名字空间Other中的版本的。这样的使用名字空间的方式是非常清楚的。
不过Jim这样会带来一个问题,即库的使用者在使用Jim名字空间的时候,需要知道太多的子名字空间的名字。使用者显然不希望声明一个sknife变量时,需要Toolkit::SwissArm yKnife<Basic::Knife>这么长的类型声明。而从库的提供者Jim的角度看,通常也没必要让使用者LiLei看到子名字空间,因此他可能考虑这样修改代码,如代码清单3-48所示。
#include
using namespace std;
namespace Jim {
namespace Basic {
struct Knife { Knife() { cout<<"Knife in Basic."<<endl; } };
class CorkScrew {};
}
namespace Toolkit {
template<typename T>class SwissArmyKnife{};
}
//...
namespace Other {
//...
}
//打开一些内部名字空间
using namespace Basic;
using namespace Toolkit;
}
//LiLei决定对该class进行特化
namespace Jim {
template<>class SwissArmyKnife<Knife>{};//编译失败
}
using namespace Jim;
int main() {
SwissArmyKnife<Knife>sknife;
}
//编译选项:g++3-9-2.cpp
在代码清单3-48所示的例子中,Jim在名字空间Jim的最后部分,打开了(using)Basic和Toolkit两个名字空间(我们省略了关于Other名字空间中的部分)。这样一来在代码清单3-48中遇到的名字过长的问题就不复存在了。不过这里又有了新的问题:库的使用者LiLei由于觉得Toolkit中的模板SwissArmyKnife有的时候不太合用,所以决定特化一个SwissArmyKnife<Knife>的版本。这个时候,我们编译该例子则会失败。这是由于C++98标准不允许在不同的名字空间中对模板进行特化造成的。
在C++11中,标准引入了一个新特性,叫做“内联的名字空间”。通过关键字“inline namespace”就可以声明一个内联的名字空间。内联的名字空间允许程序员在父名字空间定义或特化子名字空间的模板。我们可以看看代码清单3-49所示的例子。
#include
using namespace std;
namespace Jim {
inline namespace Basic {
struct Knife { Knife() { cout<<"Knife in Basic."<<endl; } };
class CorkScrew {};
}
inline namespace Toolkit {
template<typename T>class SwissArmyKnife{};
}
//...
namespace Other {
Knife b;//Knife in Basic
struct Knife { Knife() { cout<<"Knife in Other"<<endl; } };
Knife c;//Knife in Other
Basic::Knife k;//Knife in Basic
}
}
//这是LiLei在使用Jim的库
namespace Jim {
template<>class SwissArmyKnife<Knife>{};//编译通过
}
using namespace Jim;
int main() {
SwissArmyKnife<Knife>sknife;
}
//编译选项:g++ -std=c++11 3-9-3.cpp
代码清单3-49中,我们将名字空间Basic和Toolkit都声明为inline的。此时,LiLei对库中模板的偏特化(SwissArmyKnife<Knife>)则可以通过编译。不过这里我们需要再次注意一下Other这个名字空间中的状况。可以看到,变量b的声明语句是可以通过编译的,而且其被声明为一个Basic::Knife的类型。如果换个角度理解的话,在子名字空间Basic中的名字现在看起来就跟在父名字空间Jim中一样。了解了这一点,读者或者会皱起眉头,Jim名字空间中的良好分隔明显被破坏了,要做到这样的效果,只需要把Knife和CorkScrew放到全局名字空间中就可以了,根本不用inline namespace这么复杂。事实上,这跟inline namespace的使用方式有关。我们可以看看代码清单3-50所示的例子。
#include <iostream>
using namespace std;
namespace Jim{
#if__cplusplus==201103L
inline
#endif
namespace cpp11{
struct Knife{Knife(){cout<<"Knife in c++11."<<endl;}};
//...
}
#if__cplusplus<201103L
inline
#endif
namespace oldcpp{
struct Knife{Knife(){cout<<"Knife in old c++."<<endl;}};
//...
}
}
using namespace Jim;
int main(){
Knife a;//Knife in c++11.(默认版本)
cpp11::Knife b;//Knife in c++11.(强制使用cpp11版本)
oldcpp::Knife c;//Knife in old c++.(强制使用oldcpp11版本)
}
//编译选项:g++ -std=c++11 3-9-4.cpp
VS 2019中启用了 /Zc:__cplusplus 选项后 __cplusplus中的数值才是编译器版本,如果未启用该项则默认为 199711L 。 参考链接:/Zc:__cplusplus(启用更新的 __cplusplus 宏),这里用g++编译,运行结果如下。
在代码清单3-50中,Jim为它的名字空间设定了两个子名字空间:cpp11和oldcpp。这里我们看到了在2.1节中提到的关于C++的宏__cplusplus。代码的意思是,如果现在的宏__cplusplus等于201103这个常数,那么就将名字空间cpp11内联到Jim中,而如果小于201103,则将名字空间oldcpp内联到Jim中。这样一来,编译器就可以根据当前编译器对C++支持的状况,选择合适的实现版本。而如果需要的话,我们依然可以通过名字空间的方式(如cpp11::Knife)来访问相应名字空间中的类型、数据、函数等。这对程序库的发布很有好处,因为需要长期维护的程序库,可能版本间的接口和实现等都随着程序库的发展而发生了变化。那么根据需要将合适的名字空间导入到父名字空间中,无疑会方便库的使用。
事实上,在C++标准程序库中,开发者已经开始这么做了。如果程序员需要长期维护、发布不同库的版本,不妨试用一下内联名字空间这个特性。
还有一点需要指出的是,匿名的名字空间同样可以把其包含的名字导入父名字空间。所以读者可能认为代码清单3-50中的代码同样可以通过匿名名字空间与宏组合来实现。不过跟代码清单3-48中使用using打开名字空间的情况一样,匿名名字空间无法允许在父名字空间的模板特化。这也是C++11中为什么要引入新的内联名字空间的一个根本原因。不过与我们在代码清单3-49中看到的一样,名字空间的内联会破坏该名字空间本身具有的封装性,所以程序员不应该在需要隔离名字的时候使用inline namespace关键字。
此外,在代码实践时,读者可能还会被一些C++的语言特性迷惑,比较典型的是所谓“参数关联名称查找”,即ADL(Argument-Dependent name Lookup)。这个特性允许编译器在名字空间内找不到函数名称的时候,在参数的名字空间内查找函数名字。比如说下面这个例子:
namespace ns_adl{
struct A{};
void ADLFunc(A a){}//ADLFunc定义在namespace ns_adl中
}
int main(){
ns_adl::A a;
ADLFunc(a);//ADLFunc无需声明名字空间
}
函数ADLFunc就无需在使用时声明其所在的名字空间,因为编译器可以在其参数a的名字空间ns_adl中找到ADLFunc,编译也就不会报错了。
ADL带来了一些使用上的便利性,不过也在一定程度上破坏了namespace的封装性。很多人认为使用ADL会带来极大的负面影响[1]。因此,比较好的使用方式,还是在使用任何名字前打开名字空间,或者使用“::”列出变量、函数完整的名字空间。
[1]:读者可以参考以下网页:http://stackoverflow.com/questions/2958648/what-are-the-pitfalls-of-adl
在C++中,使用typedef为类型定义别名。比如:
typedef int myint;
就定义了一个int的别名myint。当遇到一些比较长的名字,尤其是在使用模板和域的时候,使用别名的优势会更加明显。比如:
typedef std::vector<std::string>strvec;
这里使用strvec作为std::vector<std::string>的别名。在C++11中,定义别名已经不再是typedef的专属能力,使用using同样也可以定义类型的别名,而且从语言能力上看,using丝毫不比typedef逊色。我们可以看看代码清单3-51所示的这个例子。
#include
#include
using namespace std;
using uint = unsigned int;
typedef unsigned int UINT;
using sint = int;
int main() {
cout<<is_same<uint, UINT>::value<<endl;//1
}
//编译选项:g++ -std=c++11 3-10-1.cpp
在本例中,使用了C++11标准库中的is_same模板类来帮助我们判断两个类型是否一致。is_same模板类接受两个类型作为模板实例化时的参数,而其成员类型value则表示两个参数类型是否一样。在代码清单3-51所示的例子中我们看到,使用using uint=unsigned int;定义的类型别名uint和使用typedef unsigned int UINT;定义的类型别名UINT都是一样的类型。两者效果相同,或者说,在C++11中,using关键字的能力已经包括了typedef的部分。
在使用模板编程的时候,using的语法甚至比typedef更加灵活。比如下面这个例子:
template<typename T>using MapString = std::map<T, char* >;
MapString<int> numberedString;
在这里,我们“模板式”地使用了using关键字,将std::map<T,char*>定义为了一个MapString类型,之后我们还可以使用类型参数对MapString进行类型的实例化,而使用typedef将无法达到这样的效果。
在C++模板中,有一条著名的规则,即SFINEA-Substitution failure is not an error,中文直译即是“匹配失败不是错误”。更为确切地说,这条规则表示的是对重载的模板的参数进行展开的时候,如果展开导致了一些类型不匹配,编译器并不会报错。我们可以具体地看看代码清单3-52所示的来自wikipedia的例子[1]。
[1]:http://en.wikipedia.org/wiki/Substitution_failure_is_not_an_error
struct Test {
typedef int foo;
};
template<typename T>
void f(typename T::foo) {}//第一个模板定义-#1
template<typename T>
void f(T) {}//第二个模板定义-#2
int main() {
f<Test>(10);//调用#1.
f<int>(10);//调用#2.由于SFINEA,虽然不存在类型int::foo,也不会发生编译错误
}
//编译选项:g++2-14-1.cpp
在代码清单3-52中,我们重载了函数模板f的定义。第一个模板f接受的参数类型为T::foo,这里我们通过typename来使编译器知道T::foo是一个类型。而第二个模板定义则接受一个T类型的参数。在main函数中,分别使用f<Test>和f<int>对模板进行实例化的时候会发现,对于f<int>来讲,虽然不存在int::foo这样的类型,编译器依然不会报错,相反地,编译器会找到第二个模板定义并对其进行实例化。这样一来,就保证了编译的正确性。
事实上,通过上面的例子我们可以发现,SFINAE规则的作用比起其拗口的定义而言更为直观。基本上,这是一个使得C++模板推导规则符合程序员想象的规则。通过SFINAE,我们能够使得模板匹配更为“精确”,即使得一些模板函数、模板类在实例化时使用特殊的模板版本,而另外一些则使用通用的版本,这样就大大增加了模板设计使用上的灵活性。而在现实生活中,这样的使用方式在标准库中使用非常普遍(当你在标准库中发现一大堆的__enable_if,或者应该想起这是SFINAE)。因此也可以说,SFINAE也是C++模板推导中非常基础的一个特性。
不过在C++98中,标准对于SFINAE并没有完全清晰的描述,一些在模板参数中使用表达式的情况,并没有被主流编译器支持。我们可以看看代码清单3-53所示的这个来自于C++11标准提案中的例子。
template<int I>struct A{};
char xxx(int);
char xxx(float);
template<class T>A<sizeof(xxx((T)0))>f(T) {}
int main() {
f(1);
}
//编译选项:g++-std=c++11
在代码清单3-53中,我们在定义函数模板f的时候,其返回值则定义为一个以sizeof(xxx((T)0))为参数的类模板A。这里值得注意的是,我们使用了sizeof表达式,以及强制的类型转换。事实上,这样的表达式是可以在模板实例化时被计算出的。不过由于实现上的复杂性,以及标准中并未明确地提及,大多数C++98编译器都会报出一个SFINEA失败信息。而事实上,这样灵活的用法却非常有用,比如本例中,程序员可以根据参数的长度而定义出不同返回值的模板函数f(一种是sizeof((int)0),一种则是sizeof((float)0))。如果编译器拒绝了这样的使用方式,无疑会为泛型编程的应用带来一些限制。
在C++11中,标准对这样的状况,尤其是模板参数替换时使用了表达式的情况进行了明确规定,即表达式中没有出现“外部于表达式本身”的元素,比如说发生一些模板的实例化,或者隐式地产生一些拷贝构造函数的话,这样的模板推导都不会产生SFINAE失败(即编译器报错)。这样一来,C++11中的一些新特性(比如我们将在第4章中讲到的decltype等)将能够成功地进行广泛的应用,进一步地,新的STL也将因此受益。
在本章中,我们介绍了C++11中共11个崭新特性。这些新特性都在着重于通用性的考量下,经过标准委员会反复揣摩而最终成型。
最为引人注目的就是右值引用。右值引用堪称是C++11中的一项重大的变革。这次的变革,是以暴露原本一直被C/C++掩盖得较好的左值右值关系为代价的。不过客观地讲,也是在程序员对C++性能的一再紧逼下,左值右值的概念才终于“露出真身”。相比于C++的其他概念,右值引用的理解会稍微复杂一些,但其目的却比较明确,就是实现所谓的移动语义。移动语义与在C++98中常见的拷贝语义在类的构造上采用了完全不同的方式。移动语义主要是将行将被释放的资源“偷”出来,作为行将构造的类型的资源。那么这势必就会跟变量生命期产生关系,跟右值、临时量打上交道。而最终,C++11中又采用了右值引用的方式使得移动构造函数能够有效地获得这些右值临时量,以使程序员能够完成行为良好的移动语义。通过这样的移动语义,库的实现者可以巧妙地将各种形如堆内存的资源放入对象中,而不必担心在诸如函数传递的过程中带来过大的资源释放、申请开销。此外,标准制定者还趁机利用了右值引用来实现了所谓的完美转发,从技术上讲,完美转发就是通过引用折叠规则和模板推导规则,使得转发函数在不损失任何数据属性的情况下,将数据完美地传递给其他函数。完美转发在标准库中被广泛地使用,也是泛型编程中一种很好的包装方式。
在C++11中,标准则又重新回答了最基本的问题——什么是简单的类型及什么是复杂的类型,即怎么才算得上是POD。C++11的POD的概念分为平凡的和标准布局两个概念。标准布局强调了类型的数据在排布上是简单的,比如可以通过memcpy拷贝的简单类型。而平凡的则强调了类型没有复杂的构造、析构或者多态等看起来“不平凡”的行为。了解POD实际为了解C++11中其他很多的概念奠定了基础。
另外一个新引入的改动则是列表初始化。相比于C++98中的赋值表达式和值初始化,列表初始化主要被实现为标准库中的initializer_list,使得用户不仅可以列表式地初始化内置类型、数组、STL容器,还可以对自定义类型进行列表初始化。这应该是C++标准中第一次出现与库实现结合得如此紧密的语言特性。而列表初始化相对于老式的初始化,对总是容易出错的类型收窄做了限制。总的说来,无论从使用的方便性还是安全性上讲,列表初始化都表现出了优良的特性,也是C++核心语言进步的一种体现。
两种新的构造函数的声明方式——继承构造函数和委派构造函数,则都使得程序员能够在编写构造函数中少写一些代码。前者我们将关注点放在了继承结构下使用using关键字将构造函数继承上,而后者,我们则把目光放在了单类型中多个构造函数间通过初始化列表的相互委托关系上。而用户自定义字面量将C++中的各种重载再一次强化。通过后缀,用户可以将程序中非内置的几乎所有的字面量据为己用,产生自己的类型。同样地,这会减少C++11代码的书写量。另外一个简化,则是using的“泛化”,using关键字已经能够像typedef一样定义别名,且其在模板中使用起来更佳。
此外,我们还介绍了能够避免意外的显式类型转换,以及能为类产生变长成员的非受限联合体。内联的名字空间则是用于库发布的一个特性,通过将子名字空间的名字导入父名字空间,用户可以方便地使用名字空间的名字。不过名字空间的内联也导致名字空间对名字封装的失效。因此通常情况下,这个特性都会结合宏一起使用。
最后我们还介绍了SFINAE规则的改进,通过这样的改进,C++11进一步提高了范型编程的能力。
读完这一章,相信读者已经能够体会到C++11的一些风格。通用手段为先,如果能够使用更为通用的方法解决的话,就不再劳烦语言核心进行繁复的更改(这在列表初始化上体现得非常明显)。而在C++11中,我们也发现范型编程的力量越来越强大,语言层面的更改,很多都是在支持解决泛型编程中出现的一些问题(比如完美转发、模板别名、SFINAE等)。如果熟悉了这些特性,那么必然程序代码会越写越少,性能也越来越好。