首先,本章很长,也较难理解,建议读者有大段连续的时间看这个。。。
3.3.1 指针成员与拷贝构造
关于拷贝构造函数的调用时间,可以看这篇文章。
如果类中包含了指针,需要小心处理,下面是一段有问题的代码
class C {
public:
C():i(new int(0)){
cout << "none argument constructor called" << endl;
}
~C(){
cout << "destructor called" << endl;
delete i;
}
int* i;
};
int main(){
C c1;
C c2 = c1;
cout << *c1.i << endl;
cout << *c2.i << endl;
return 0;
}
XCode代码执行输出
none argument constructor called
0
0
destructor called
destructor called
CppTest(50956,0x1000ad5c0) malloc: *** error for object 0x10070c510: pointer being freed was not allocated
CppTest(50956,0x1000ad5c0) malloc: *** set a breakpoint in malloc_error_break to debug
原因是编译期会默认为类创建拷贝构造函数,而默认的拷贝构造函数只是简单的赋值,对类C,系统默认生成的拷贝构造函数如
C(const C& c):i(c.i){
}
导致c1和c2的i值一样,即指向同一片地址,当c1析构之后,c2.i就成为了一个“悬挂指针”(dangling pointer),不再指向有效的内存了,如果对悬挂指针再次进行delete就会出现严重的错误。
以上系统生成的默认拷贝构造函数做的是浅拷贝(shallow copy),为了解决这个问题,通常是用户自定义拷贝构造函数实现深拷贝(deep copy),修正如下
class C {
public:
C():i(new int(0)){
cout << "none argument constructor called" << endl;
}
//增加此拷贝构造函数,根据传入的c,new一个新的int给i变量
C(const C& c) :i(new int(*c.i)){
}
~C(){
cout << "destructor called" << endl;
delete i;
}
int* i;
};
执行代码后如下
none argument constructor called
0
0
destructor called
destructor called
Program ended with exit code: 0
3.3.2 移动语义
拷贝函数中为指针成员分配新的内存再进行内容拷贝的方法在C++中几乎被视为不可违背的,不过有些时候却是不必要的。如下代码:
//这是一个成员包含指针的类
class HasPtrMem {
public:
HasPtrMem() : d(new int(0)) {
cout << "Construct:" << ++n_cstr << endl;
}
HasPtrMem(const HasPtrMem& h) {
cout << "Copy construct:" << ++n_cptr << endl;
}
~HasPtrMem() {
cout << "Destruct:" << ++n_dstr << endl;
}
private:
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 m = GetTemp();//②
return 0;
}
这里我没用Xcode编译运行,因为在Build Setting里增加-fno-elide-constructors编译器依然还是优化了,所以根据教材用命令行执行
g++ -std=c++11 main.cpp -fno-elide-constructors
会在cpp文件下生成一个a.out文件,在命令行执行./a.out
输出
Construct:1
Copy construct:1
Destruct:1
Copy construct:2
Destruct:2
Destruct:3
构造函数被调用1次,是在①处,第一次调用拷贝构造函数是在GetTemp return的时候,将①生成的变量拷贝构造出一个临时值,来当做GetTemp的返回,第二次拷贝构造函数是在②处。同时就有了于此对应的三次析构函数的调用。例子里用的是一个int类型的指针,而如果该指针指向的是非常大的堆内存数据的话,那没拷贝过程就会非常耗时,而且由于整个行为是透明且正确的,分析问题时也不易察觉。
在C++中,我们可以通过移动构造函数解决此问题,修改代码如下:
//这是一个成员包含指针的类
class HasPtrMem {
public:
HasPtrMem() : d(new int(0)) {
cout << "Construct:" << ++n_cstr << endl;
}
HasPtrMem(const HasPtrMem& h) {
cout << "Copy construct:" << ++n_cptr << endl;
}
HasPtrMem(HasPtrMem&& h):d(h.d) {
h.d = nullptr; //③注意对之前的h赋空指针
cout << "Move construct:" << ++n_mvtr << endl;
}
~HasPtrMem() {
cout << "Destruct:" << ++n_dstr << endl;
}
private:
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() {
return HasPtrMem();
}
int main(){
HasPtrMem m = GetTemp();
return 0;
}
输出
Construct:1
Move construct:1
Destruct:1
Move construct:2
Destruct:2
Destruct:3
这里通过指针赋值的方式,将d的内存直接偷了过来,避免了拷贝构造函数的调用。注意③,这里需要对原来的d进行赋空值,因为在移动构造函数完成之后,临时对象会立即被析构,如果不改变d,那临时对象被析构时,因为偷来的d和原本的d指向同一块内存,会被释放,成为悬挂指针,会造成错误。
为什么不用函数参数里带个指针或者引用当返回结果呢?不是性能的问题,而是代码编写效率及可读性不好,如:
string *a;
int c = 1
int &b = c;
Calculate(GetTemp(),b);//最后一个参数用于返回结果
最后说明一下移动构造函数被调用的时机:一旦用到的是临时变量,那么移动语义就可以得到执行。下一节讲下C++的值是如何分类的。未完待续,后面还有4节。。。
3.3.3 左值、右值与右值引用
关于左值(lvalue)和右值(rvalue)的判别方法:
- 在赋值表达式中,出现在等号左边的是“左值”,等号右边的是“右值”,如
a = b + c;
中,a是左值,而b+c是右值; - 可以取地址的、有名字的是左值,反之是右值,对于
a = b + c;
,&a是允许的操作,&(b+c)是不允许的操作,所以a是左值,b+c是右值。
而在C++11中右值是由两个概念构成的,一个是将亡值(xvalue, eXpriring Value),另个一个则是纯右值(prvalue, Pure Rvalue)。
其中纯右值包括:
- 非引用返回的函数返回的临时变量值
- 运算表达式,如1+3产生的临时变量值
- 不跟对象关联的字面量,如2、’c‘、true
- 类型转换函数的返回值
- lamda表达式
将亡值贼是C++11新增的跟右值引用相关的表达式,包括:
- 返回右值引用T&&的函数返回值
- std::move的返回值
- 转换为T&&的类型转换函数的返回值
而剩余的,可以标识函数、对象的值都属于左值。在C++11的程序中,所有的值必属于左值、将亡值、纯右值三者之一。
在C++11中,右值引用就是对一个右值进行引用的类型。由于右值不具有名字,我们也只能通过引用的方式找到它的存在。通常我们只能是从右值表达式获得其引用。比如:
T&& a = ReturnRvalue();①
右值引用和左值引用都是引用类型,都必须立即进行初始化。引用类型本身并不拥有绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,右值引用则是匿名变量的别名。
在上面①的例子中,ReturnRvalue函数返回的右值在表达式语句结束后,其生命也就终结了,而通过右值引用的声明,该右值又“重获新生”,其生命期将于右值引用类型a的生命期一样。只要a还“活着”,该右值临时量将会一直“存活”下去。
所以相比于一下语句:
T b = ReturnRvalue();
①的声明方式会少一次对象的析构和一次对象构造。因为a是右值引用,直接绑定了ReturnRvalue()返回的临时量,而b是由临时值构造的,而临时量在表达式结束后会析构因而会多一次析构和构造的开销。
注意,能够声明右值引用a的前提是ReturnRvalue返回的是一个右值。通常右值引用是不能够绑定到任何左值的,如下代码会导致编译无法通过:
int c;
int &&d = c;
有的时候,我们可能不知道一个类型是否是引用类型,以及是左值引用还是右值引用。标准库<type_traits>头文件中提供了3个类模板:is_rvalue_reference、is_lvalue_reference和is_reference,比如:
cout << is_rvalue_reference<string &&>::value;
3.3.4 std::move 强制转化为右值
C++11中,<utility>中提供了函数std::move,功能是将一个左值强制转化为右值引用,继而我们可以通过右值引用使用该值,用于移动语义。std::move基本等同于一个类型转换:
static_cast<T&&>(lvalue);
被转化的左值,其生命期并没有随着左右值的转化而改变。下面是一个正确使用std::move的例子
class HugeMem {
public:
HugeMem(int size): sz(size>0 ? size: 1) {
c = new int[size];
}
~HugeMem() {
delete [] c;
}
HugeMem(HugeMem&& h) : sz(h.sz), c(h.c) {
h.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)) { //使用move将m.h转为右值引用,继而调用HugeMem的移动构造函数
m.i = nullptr;
}
int *i;
HugeMem h;
};
Moveable getTemp() {
Moveable tmp = Moveable();
cout << hex << "Huge Mem from " << __func__ << "@" << tmp.h.c << endl;
return tmp;
}
int main(){
Moveable a(getTemp());//因为getTemp()返回的是右值,所以会调用Moveable的移动构造函数
cout << hex << "Huge Mem from " << __func__ << "@" << a.h.c << endl;
return 0;
}
输出
Huge Mem from getTemp@0x104002000
Huge Mem from main@0x104002000
需要注意的是,在编写移动构造函数的时候,应该总是使用std::move转换拥有形如堆内存、文件句柄的等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义,即使成员没有移动构造函数,也会调用拷贝构造,因为不会引起大的问题。
3.3.5 移动语义的一些其他问题
移动语义一定是要改变临时变量的值(这里有以为,需要解决,目前没看出哪里一定要改变,先这么硬背吧)。如声明:
Moveable(const Moveale &&);//这个对应3.3.4的例子,如果这样声明移动构造函数会报错
而如果是将3.3.4的例子中的Moveable getTemp()
改为const Moveable getTemp()
,再执行命令
g++ -std=c++11 main.cpp -fno-elide-constructors
注意上面的改动在Xcode中是可以运行的,可以正确调用到移动构造函数,但是通过命令行会提示
copy constructor is implicitly deleted because 'Moveable' has a user-declared move constructor
可见Moveable a(getTemp());
实际是要调用Moveable的拷贝构造函数。报错原因显示声明了移动构造函数,编译器就不会为类生成默认的拷贝构造函数了,所以提示没有显示声明拷贝构造函数。
在C++11中,拷贝/移动改造函数有以下3个版本:
- T Object(T&)
- T Object(const T&)
- T Object(T&&)
其中常量左值引用的版本是一个拷贝构造函数版本,右值引用参数的是一个移动构造函数版本。默认情况下,编译器会为程序员隐式地生成一个移动构造函数,但是如果声明了一自定义的拷贝构造函数、拷贝赋值函数、移动构造函数、析构函数中的一个或者多个,编译器都不会再生成默认版本。所以在C++11中,拷贝构造函数、拷贝赋值函数、移动构造函数和移动赋值函数必须同时提供,或者同时不提供,只声明其中一种的话,类都仅能实现一种语义。
只实现一种语义在类的编写中也是非常常见的,比如如果只实现移动语义,则表明该类型的变量拥有的资源只能被移动,不能被拷贝,那么这样的资源必须是唯一的,如智能指针、文件流。
在<type_traits>里,可以通过一些辅助的模板类来判断一个类型是否是可以移动的,如:
- is_move_constructible
- is_trivially_move_constructible
- is_nothrow_move_constructible
使用方法都是使用value成员,如
cout << is_move_constructible<UnknowTYpe>::value;
有了移动语义,可以实现高性能的置换函数,如:
template <class T>
void swap(T& a, T& b) {
T tmp(move(a));
a = move(b);
b = move(tmp);
}
如果T是可以移动的,则不会有资源的释放和申请,如果T不可移动但是可以拷贝,则和普通声明一样了。
要注意的是,尽量不要编写会抛出异常的移动构造函数,因为有可能移动没完成,会导致一些指针成为悬挂指针,通过添加noexcept关键字,可以保证移动构造函数抛出异常直接终止程序。
3.3.6 完美转发
完美转发(perfect forwarding),是指在模板函数中,完全依照模板的参数类型讲参数传递给模板中调用的另外一个函数,如:
template <typename T>
void IamForwarding(T t) {
IrunCodeActually(t);
}
这是一个参数透传的实现,但是因为使用最基本类型转发,会在传参的时候产生一次额外的临时对象拷贝,因为只能说是转发,但不完美。所以通常需要的是一个引用类型餐护士,不会有拷贝的开销。其次需要考虑函数对类型的接受能力,因为目标函数可能需要既接受左值引用,又接受右值引用,如果转发函数只能接受其中的一部分,也不完美。
对应代码
typedef const A T;
typedef T& TR;
TR& v = 1;
在C++11中引入了一条所谓“引用折叠”的新语言规则,规则如下
TR的类型定义 | 声明v的类型 | v的实际类型 |
---|---|---|
T& | TR | A& |
T& | TR& | A& |
T& | TR&& | A& |
T&& | TR | A&& |
T&& | TR& | A& |
T&& | TR&& | A&& |
规则就是一单定义中出现了左值引用,引用折叠总是优先将其折叠为左值引用。前三行TR定义为T&,则v世界类型为A&,第五行的v的类型为TR&,则v的实际类型也为A&,其他则为右值引用。于是我们把转发函数改为:
template <typename T>
void IamForwarding(T&& t) {
IrunCodeActually(static_cast<T&&>(t));
}
对于传入的左值引用
void IamForwarding(X& && t) {
IrunCodeActually(static_cast<X& &&>(t));
}
折叠后是
void IamForwarding(X& t) {
IrunCodeActually(static_cast<X&>(t));
}
对于右值引用
void IamForwarding(X&& && t) {
IrunCodeActually(static_cast<X&& &&>(t));
}
折叠后是
void IamForwarding(X&& t) {
IrunCodeActually(static_cast<X&&>(t));
}
此处的static_cast类似std::move的作用,将左值转换为右值引用。不过在C++11中,用于完美转发的函数不叫move,叫forward,所以也可以这么写
void IamForwarding(X&& t) {
IrunCodeActually(forward(t));
}
move和forward实现差别不大,但是为了不同用途,有了不同命名。
下面是完美转发的例子:
void run(int && m) { cout << "rvalue ref" << endl; }
void run(int & m) { cout << "lvalue ref" << endl; }
void run(const int && m) { cout << "const rvalue ref" << endl; }
void run(const int & m) { cout << "const lvalue ref" << endl; }
template <typename T>
void perfectForward(T&& t) {
run(forward<T>(t));
}
int main(){
int a;
int b;
const int c = 1;
const int d = 0;
perfectForward(a);
perfectForward(move(b));
perfectForward(c);
perfectForward(move(d));
return 0;
}
输出
lvalue ref
rvalue ref
const lvalue ref
const rvalue ref