0%

C++11(二)右值引用与POD

原图

继承构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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(){};
};

继承于A的派生类B实际上只是添加了一个接口 Extralnterface,那么如果我们在构造B的时候想要拥有A这样多的构造方法的话,就必须一“透传”各个接口。这无疑是相当不方便的。事实上,在C++中已经有了一个好用的规则,子类可以通过使用 using声明来声明继承基类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
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(){};
};

而有的时候,我们还会遇到继承构造函数“冲突”的情况。这通常发生在派生类拥有多个基类的时候。多个基类中的部分构造函数可能导致派生类中的继承构造函数的函数名、参数(有的时候,我们也称其为函数签名)都相同,那么继承类中的冲突的继承构造函数将导致不合法的派生类代码。

1
2
3
4
5
6
7
struct A {A(int){}};
struct B {B(int){}};

struct C: A, B {
using A::A;
using B::B;
}

A和B的构造函数会导致C中重复定义相同类型的继承构造函数。这种情况下,可以通过显式定义继承类的冲突的构造函数,阻止隐式生成相应的继承构造函数来解决冲突。比如:

1
2
3
4
5
struct C: A, B {
using A::A;
using B::B;
C(int){};
};

其中的构造函数C(int)就很好地解决了继承构造函数的冲突问题。

委派构造函数

与继承构造函数类似的,委派构造函数也是C++11中对C++的构造函数的一项改进其目的也是为了减少程序员书写构造函数的时间。通过委派其他构造函数,多构造函数的类编写将更加容易。

1
2
3
4
5
6
7
8
9
10
11
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;
//...
};

在上述代码中,我们声明了一个Info的自定义类型。该类型拥有2个成员变量以及3个构造函数。这里的3个构造函数都声明了初始化列表来初始化成员type和name,并且都调用了相同的函数 Initrest。可以看到,除了初始化列表有的不同,而其他的部分,3个构造函数基本上是相似的,因此其代码存在着很多重复。

在C++11中,我们可以使用委派构造函数来达到期望的效果。更具体的,C++11中的委派构造函数是在构造函数的初始化列表位置进行构造的、委派的。我们可以看看

1
2
3
4
5
6
7
8
9
10
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'};
//...

可以看到,在上述代码中,我们在Info(int)和Info(char)的初始化列表的位置,调用了“基准版本”的构造函数 Info。这里我们为了区分被调用者和调用者,称在初始化列表中调用“基准版本”的构造函数为委派构造函数( delegating constructor),而被调用的基准版本”则为目标构造函数( target constructor)。在C++11中,所谓委派构造,就是指委派函数将构造的任务委派给了目标构造函数来完成这样一种类构造的方式。

右值引用:移动语义和完美转发

C++11的左值和右值的概念

在c++中,一个值要么是右值,要么是左值,左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束时就不再存在的临时对象。所有的具名变量或者对象都是左值,而右值不具名。

1
int lvalue = 1; // lvalue 为左值, 1 为右值

左值引用和右值引用

  • 左值引用:引用一个对象
  • 右值引用:就是必须绑定到右值的引用,C++11中右值引用可以实现“移动语义”,通过 && 获得右值引用。右值引用,可以延长右值的生命期,比如:
1
2
3
int&& i = 123;
int&& j = std::move(i);
int&& k = i; //编译不过,这里i是一个左值,右值引用只能引用右值

可以通过下面的代码,更深入的体会左值引用和右值引用的区别:

1
2
3
4
5
6
7
8
9
10
11
12
int i;
int&& j = i++; // 右值 i先赋值,然后做+1运算,运算结果存在栈中
int&& k = ++i; // 左值 先做+1运算,+1的结果赋值到i变量中,i是一个左值
int& m = i++;
int& l = ++i;

move.cpp: In function ‘int main()’:
move.cpp:72:14: error: cannot bind ‘int’ lvalue to ‘int&&’
int&& k = ++i;
^
move.cpp:73:15: error: invalid initialization of non-const reference of type ‘int&’ from an rvalue of type ‘int
int& m = i++;

为什么需要右值引用

C++引入右值引用之后,可以通过右值引用,充分使用临时变量,或者即将不使用的变量即右值的资源,减少不必要的拷贝,提高效率。如下代码,均会产生临时变量:

1
2
3
4
5
6
7
8
class RValue {
};

RValue get() {
return RValue();
}

void put(RValue){}

为了充分利用右值的资源,减少不必要的拷贝,C++11引入了右值引用(&&),移动构造函数,移动复制运算符以及std::move。

std::move

右值引用(&&),移动构造函数,移动复制运算符以及std::move

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <memory>
#include <vector>
#include <string>
using namespace std;

struct RValue {
RValue():sources("hello!!!"){}
RValue(RValue&& a) {
sources = std::move(a.sources);
cout<<"&& RValue"<<endl;
}

RValue(const RValue& a) {
sources = a.sources;
cout<<"& RValue"<<endl;
}

void operator=(const RValue&& a) {
sources = std::move(a.sources);
cout<<"&& =="<<endl;
}

void operator=(const RValue& a) {
sources = a.sources;
cout<<"& =="<<endl;
}

string sources;;
};

RValue get() {
RValue a;
return a;
}

void put(RValue){}

int main() {
RValue a = get();
cout<<"---------------"<<endl;
put(RValue());
return 0;
}

不过,当运行的时候却发现没有任何输出

1
2
3
g++ move.cpp -std=c++11 -o move
./move
---------------

这是因为,编译器做了优化,编译的时候加上-fno-elide-constructors,去掉优化

1
2
3
4
5
6
g++ move.cpp -std=c++11 -fno-elide-constructors -o move
./move
&& RValue
&& RValue
---------------
&& RValue

通过上面的代码,可以看出,在没有加-fno-elide-constructors选项时,编译器做了优化,没有临时变量的生成。在加了-fno-elide-constructors选项时,get产生了两次临时变量,put生成了一次临时变量。

将get函数稍微修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RValue get() {
RValue a;
return std::move(RValue());
}

g++ move.cpp -std=c++11 -o move
./move
&& RValue
---------------

//加编译选项
g++ move.cpp -std=c++11 -fno-elide-constructors -o move
./move
&& RValue
&& RValue
---------------
&& RValue

只是简单的修改了一下,std::move(a),在编译器做了优化的情况下,用了std::move,反而多做了一次拷贝。

其实,RValue如果在没有定义移动构造函数,重复上面的操作,生成临时变量的次数还是一样的,只不过,调用的时拷贝构造函数了而已。

通过get函数可以知道,乱用std::move在编译器开启构造函数优化的场景下反而增加了不必要的拷贝。那么,std::move应该在什么场景下使用?

std::move使用场景

移动构造函数的原理

通过移动构造,b指向a的资源,a不再拥有资源,这里的资源,可以是动态申请的内存,网络链接,打开的文件,也可以是本例中的string。这时候访问a的行为时未定义的,比如,如果资源是动态内存,a被移动之后,再次访问a的资源,根据移动构造函数的定义,可能是空指针,如果是资源上文的string,移动之后,a的资源为空字符串(string被移动之后,为空字符串)。 可以通过下面代码验证,修改main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main() {
RValue a, b;
RValue a1 = std::move(a);
cout<<"a.sources:"<<a.sources<<endl;
cout<<"a1.sources:"<<a1.sources<<endl;
RValue b1(b);
cout<<"b.sources:"<<b.sources<<endl;
cout<<"b1.sources:"<<a1.sources<<endl;
return 0;
}

g++ move.cpp -std=c++11 -o move
./move
&& RValue
a.sources:
a1.sources:hello!!!
& RValue
b.sources:hello!!!
b1.sources:hello!!!

通过移动构造函数之后,a的资源为空,b指向了a的资源。通过拷贝构造函数,b复制了a的资源。

std::move的原理 std::move的定义:

1
2
3
4
5
6

template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

这里,T&&是通用引用,需要注意和右值引用(比如int&&)区分。通过move定义可以看出,move并没有”移动“什么内容,只是将传入的值转换为右值,此外没有其他动作。std::move+移动构造函数或者移动赋值运算符,才能充分起到减少不必要拷贝的意义。

std::move的使用场景

在之前的项目中看到有的同事到处使用std::move,好像觉得使用了std::move就能移动资源,提升性能一样,在我看来,std::move主要使用在以下场景:

  • 使用前提:
      1. 定义的类使用了资源并定义了移动构造函数和移动赋值运算符
      1. 该变量即将不再使用
  • 使用场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
RValue a, b;

//对a,b做一系列操作之后,不再使用a,b,但需要保存到智能指针或者容器之中
unique_ptr<RValue> up(new RValue(std::move(a)));
vector<RValue*> vr;
vr.push_back(new RValue(std::move(b)));

//临时容器中保存的大量的元素需要复制到目标容器之中
vector<RValue> vrs_temp;
vrs_temp.push_back(RValue());
vrs_temp.push_back(RValue());
vrs_temp.push_back(RValue());
vector<RValue> vrs(std::move(vrs_temp));

RValue c;
put(std::move(c));
  • 在没有右值引用之前,为了使用临时变量,通常定义const的左值引用,比如const string&,在有了右值引用之后,为了使用右值语义,不要把参数定义为常量左值引用,否则,传递右值时调用的是拷贝构造函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void put(const RValue& c){
cout<<"----------"<<endl;
unique_ptr<RValue> up(new RValue(std::move(c)));
cout<<"----------"<<endl;
}

RValue c;
put(std::move(c));

g++ move.cpp -std=c++11 -o move
./move
----------
& RValue
----------

不使用左值常量引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void put(RValue c){
cout<<"----------"<<endl;
unique_ptr<RValue> up(new RValue(std::move(c)));
cout<<"----------"<<endl;
}

RValue c;
put(std::move(c));

g++ move.cpp -std=c++11 -o move
./move
&& RValue
----------
&& RValue
----------

这是因为,根据通用引用的定义,std::move(c)过程中,模板参数被推倒为const RValue&,因此,调用拷贝构造函数。

总结 通过简绍右值和右值引用以及std::move和移动构造函数,总结右值引用,移动构造函数和移动赋值运算符和std::move的用法和注意事项。

std::forward

forward的作用

std::forward被称为完美转发,它的作用是保持原来的值属性不变。啥意思呢?通俗的讲就是,如果原来的值是左值,经std::forward处理后该值还是左值;如果原来的值是右值,经std::forward处理后它还是右值。

看看下面的例子,你应该就清楚上面这句话的含义了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

template<typename T>
void print(T & t){
std::cout << "左值" << std::endl;
}

template<typename T>
void print(T && t){
std::cout << "右值" << std::endl;
}

template<typename T>
void testForward(T && v){
print(v);
print(std::forward<T>(v));
print(std::move(v));
}

int main(int argc, char * argv[])
{
testForward(1);

std::cout << "======================" << std::endl;

int x = 1;
testFoward(x);
}

//clang++ -std=c++11 -g -o forward test_forward.cpp

上面代码执行结果如下:

1
2
3
4
5
6
7
左值
右值
右值
=========================
左值
左值
右值

从上面第一组的结果我们可以看到,传入的1虽然是右值,但经过函数传参之后它变成了左值(在内存中分配了空间);而第二行由于使用了std::forward函数,所以不会改变它的右值属性,因此会调用参数为右值引用的print模板函数;第三行,因为std::move会将传入的参数强制转成右值,所以结果一定是右值。

再来看看第二组结果。因为x变量是左值,所以第一行一定是左值;第二行使用forward处理,它依然会让其保持左值,所以第二也是左值;最后一行使用move函数,因此一定是右值。

通过上面的例子我想你应该已经清楚forward的作用是什么了吧?

forward实现原理

要分析forward实现原理,我们首先来看一下forward代码实现。由于我们之前已经有了分析std::move的基础,所以再来看forward代码应该不会太困难。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
T&& forward(typename std::remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}

template <typename T>
T&& forward(typename std::remove_reference<T>::type&& param)
{
return static_cast<T&&>(param);
}

forward实现了两个模板函数,一个接收左值,另一个接收右值。

POD

POD是英文中Plain Old Data的缩写。POD在C++中是非常重要的一个概念,通常用于说明一个类型的属性,尤其是用户自定义类型的属性。具体地,C++11将POD划分为两个基本概念的合集,即:平凡的(trivial)和标准布局的(standard layout)。

trivial

我们先来看一下平凡的定义。通常情况下,一个平凡的类或结构体应该符合以下定义:

  • 拥有平凡的默认构造函数(trivial constructor)和析构函数(trivial destructor)。平凡的默认构造函数就是说构造函数“什么都不干”。使用=default关键字来显式地声明缺省版本的构造函数,使得类型恢复“平凡化”
  • 拥有平凡的拷贝构造函数(trivial copy constructor)和移动构造函数(trivialmove constructor)。平凡的拷贝构造函数基本上等同于使用memcpy进行类型的构造。同平凡的默认构造函数一样,不声明拷贝构造函数的话,编译器会帮程序员自动地生成。同样地,可以显式地使用=default声明默认拷贝构造函数
  • 拥有平凡的拷贝赋值运算符(trivial assignment operator)和移动赋值运算符(trivial move operator)。这基本上与平凡的拷贝构造函数和平凡的移动构造运算符类似
  • 不能包含虚函数以及虚基类

standard layout

标准布局的类或结构体应该符合以下定义:

  • 所有非静态成员有相同的访问权限(public, private, protected)
  • 在类或者结构体继承时,满足以下两种情况之一:
    • 派生类中有非静态成员,且只有一个仅包含静态成员的基类
    • 基类有非静态成员,而派生类没有非静态成员
  • 类中第一个非静态成员的类型与其基类不同
  • 没有虚函数和虚基类
  • 所有非静态数据成员均符合标准布局类型,其基类也符合标准布局。这是一个递归的定义,没有什么好特别解释的

POD

对于POD而言,在C++11中的定义就是平凡的和标准布局的两个方面。同样地,要判定某一类型是否是POD,标准库中的头文件也为程序员提供了如下模板类:

1
2
3
4
5
6
/// is_pod
// Could use is_standard_layout && is_trivial instead of the builtin.
template<typename _Tp>
struct is_pod
: public integral_constant<bool, __is_pod(_Tp)>
{ };

使用POD我们看得到的大概有如下3点:

  • 字节赋值,代码中我们可以安全地使用memset和memcpy对POD类型进行初始化和拷贝等操作
  • 提供对C内存布局兼容。C++程序可以与C函数进行相互操作,因为POD类型的数据在C与C++间的操作总是安全的
  • 保证了静态初始化的安全有效。静态初始化在很多时候能够提高程序的性能,而POD类型的对象初始化往往更加简单(比如放入目标文件的.bss段,在初始化中直接被赋0)

用户定义字面量

C++中的字面量

C++ 自带4种字面量:

  • 整形 123
  • 浮点型 12.3
  • 字符 '1'
  • 字符串 "123"

字面量又可添加后缀来表明具体类型:

  • 无符号整形(unsigned int): 123u
  • 长整形(long): 123l

在 C++03 中,我们可以定义一个浮点数height

1
double height = 3.4;

那么,痛点来了,此处的 height 的单位是什么呢?米?厘米?又或是英尺?在面对此类问题时,如果我们能编写如下代码,事情就会简单许多:

1
2
3
4
height = 3cm;

// ratio = (3 * 10) / 2
ratio = 3cm / 2mm;

用户自定义字面量

C++11允许用户自定义字面量后缀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
long double
operator"" _cm(long double x) {
return x * 10;
}

long double
operator"" _m(long double x) {
return x * 1000;
}

long double
operator"" _mm(long double x) {
return x;
}

// height = 30.0
auto height = 3.0_cm;

// length = 1230.0
auto length = 1.23_m;

如果使用这种写法,_cm, _m, _mm 等函数将在运行时被调用,如果希望在编译时就调用字面量后缀函数,则需要把函数定义为 constexpr,例如

1
2
3
4
constexpr long double
operator"" _cm(long double x) {
return x * 10;
}

更进一步,我们甚至可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 注意,如果这里要定义函数为 constexpr
// 编译时需要使用 c++14 标准(-std=c++14)
int operator"" _bin(
const char* s,
size_t l
) {
int ret = 0;
for (int i = 0; i < l; i++){
ret = (ret << 1) | (s[i] - '0');
}
return ret;
}

int num = "110"_bin; // num = 6

自定义字面量的限制

C++11 只允许字面量后缀函数的参数为以下类型,即整数,浮点以及字符串:

  • unsigned long long
  • long double
  • char const*
  • char const*, std::size_t
  • wchar_t const*, std::size_t
  • char16_t const*, std::size_t
  • char32_t const*, std::size_t

返回值则无类型限制

参考文献

https://zhuanlan.zhihu.com/p/94588204
https://www.jianshu.com/p/97fdd852974f
https://zhuanlan.zhihu.com/p/111369693
《深入理解C++11:C++11新特性解析与应用》