C++值类别

话从何说起呢… 说起来十分熟悉, 表达式! 在C++中, 表达式是最基本的概念, 平常所写的每一段代码都是由一个个表达式组成. 表达式将运算符作用于一个或多个运算对象, 每个表达式都有对应的求值结果, 这是对表达式的标准定义.

那么我们随手写一个简单的表达式看看:

1
int a = 0; // 够简单吧

但其实, 其中包含了三个表达式! a是一个表达式, 0是一个表达式, a = 0组合而成是一个表达式, 而这三个不同的表达式又大有讲究.

在早期的C++中, 将表达式简单的分为左值表达式右值表达式(简称左值和右值), 左值可以出现在赋值语句的左侧, 而右值只能在赋值语句的右侧. 如此一来, 上面的表达式a是一个左值表达式, a = 0是一个右值表达式, 而a = 0…实际上是一个左值表达式, 因为(a = 0) = 1也是一条合法的语句, 所以a = 0出现在了左侧, 是一个左值.

以上只是对左值和右值一个浅显的定义, 实际上区分左值和右值的依据是, 对一个表达式的求值结果, 看能否取它的地址, 能取地址的是为左值, 否则是为右值. 在运用上, 当这个求值结果视为左值时, 需要的是其身份, 如作为变量的身份, 当这个求值结果是为右值时, 更关心的是它的值内容, 如是何数据.

回过头来, 在来看上面这个表达式, a作为一个左值, 以一个变量的身份渴望被赋予一个具体的值, 此时0作为一个右值, 取其数据给a, 这合情合理, 所以编译器给了绿灯通过, 此后a的求值结果便为0了.

上述的左值和右值便是C++中的值类别, 它俩的概念在很早之前就有了, 而到了C++11随着移动语义的引入, 值类别被重新进行了定义, 以区别表达式的两种独立的性质:

  1. 表达式拥有身份: 可以确定表达式是否与另一个表达式指代同一实体.
  2. 可被移动: 移动构造函数\移动赋值运算符或实现了移动语义的其他函数重载能够绑定于这个表达式.

值类别有了更多的划分: (概念及具体实例参见cppreference: https://zh.cppreference.com/w/cpp/language/value_category, 每当拿不准一个表达式是什么值类别时, 就看看他)

  • 拥有身份且不可被移动的表达式被称作左值(lvalue)表达式.
  • 拥有身份且可被移动的表达式被称作亡值(xvalue)表达式.
  • 不拥有身份且可被移动的表达式被称作纯右值(prvalue)表达式.
  • 不拥有身份且不可被移动的表达式无法使用.
  • 拥有身份的表达式被称作泛左值(glvalue)表达式. 左值和亡值都是泛左值表达式.
  • 可被移动的表达式被称作右值 (rvalue)表达式. 纯右值和亡值都是右值表达式.

(上面’左值’与下面’泛左值’的概念相似, 而’右值’则成了’纯右值’).
(为何区分出这么多概念? 肯定是有其复杂原因的吧, 下面就来愉悦的一探究竟吧!).

左值 右值

首先C++为何要区分左值右值? 在乎于左值与右值的不同性质, 往往需要理解其性质, 才对C++这门语言中一些奇怪的表达方式了然于心, 而不是觉得一些代码看上去没有章法, 一头雾水.
举个例子:

1
2
int value = 0;
// 假设存在函数 GetValue*(), 这里故意没写其返回类型, *表示一些命名后缀

GetValue*()是一个表达式, 那么它具有什么样的值类别? 左值还是右值? 实际上答案正是与其返回值类型有关!

1
2
3
4
5
6
7
8
9
int GetValue(); // 这里GetValue()返回一个非引用类型, 是一个右值表达式
int& GetValueLeftRef(); // GetValueLeftRef()返回一个左值引用, 是一个左值表达式

// 再来一遍, 对于左值, 需要的是其身份, 对于右值, 更关心的是它的值内容. 那么

int j = GetValue(); // 右值赋值给左值
j = GetValueLeftRef(); // 左值赋值给左值, 也合情理, 实际上这里可以得到左值的性质①
// GetValue() = 10; // 给右值赋值将不会被通过
GetValueLeftRef() = 10; // 但给左值赋值没问题!

有了左值和右值的概念后, GetValueLeftRef() = 10这样的写法就能够被理解了.

移动语义

那么亡值/泛左值/纯右值又是些什么鬼? 正如上面所说, 自C++11起加入了移动语义, 之所以有了更多的值类别, 全和这个移动语义有关.
移动语义是相对拷贝语义而言的, 对一个对象实施复制要经历开辟内存, 拷贝数据构造对象, 若被拷贝对象不再使用, 还要对其进行析构, 在回收被拷贝对象的内存, 实际上, 拷贝前后的东西完全一样, 那么为什么要按看似正规的流程复制一遍, 而不是直接使用这一对象, 实际上这个过程应该像两个指针一样, 其中当前指针为空, 将其指向被拷贝指针所在位置, 在将被拷贝指针置空即可, 避免开辟内存拷贝析构释放内存的步骤… 啊哈, 这刚好就是移动所要讲的东西了.

为了证明移动比拷贝快, 这里先做一个测试:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
static int MoveCount = 0;
static int CopyCount = 0;
static const int RUNCOUNT = 100000;
static const int DATASIZE = 1024;
class Data {
std::vector<char> _data;

public:
Data(){ }
Data(char i) : _data(DATASIZE, i) { }
Data& operator=(Data&& raw) noexcept {
if (this == &raw) return *this;

++MoveCount;
_data = std::move(raw._data); // 实施vector的移动赋值运算, 先别管std::move和MoveBigData&&, 只需知道这里发生的是移动

return *this;
}
Data& operator=(const Data& raw) {
if (this == &raw) return *this;

++CopyCount;
for (size_t i = 0; i < raw._data.size(); ++i) {
_data[i] = raw._data[i]; // 实施常规的拷贝运算
}

return *this;
}
public:
void MakeRoom() {
_data.resize(1024);
}
};

class RawData {
char* _pData;
public:
RawData():_pData(nullptr) {

}
RawData(char i) : RawData() {
_pData = new char[DATASIZE];
memset(_pData, i, DATASIZE);
}
RawData& operator=(RawData&& raw) noexcept{
if (this == &raw) return *this;

++MoveCount;
char* pOriData = _pData;
_pData = raw._pData; // 移动指针
raw._pData = nullptr;

if(!pOriData) delete[] pOriData;

return *this;
}
RawData& operator=(const RawData& raw) {
if (this == &raw) return *this;

++CopyCount;
for (size_t i = 0; i < DATASIZE; ++i) {
_pData[i] = raw._pData[i]; // 依次复制数据
}

return *this;
}
~RawData() {
if (_pData != nullptr) delete[] _pData;
}

public:
void MakeRoom() {
if(!_pData) _pData = new char[DATASIZE];
}
};

template <typename T>
void TimeTest() {
CopyCount = 0;
MoveCount = 0;

std::chrono::high_resolution_clock clock;

T cSrc('1');
T cDst;
cDst.MakeRoom();

auto start = clock.now();
for (int i = 0; i < RUNCOUNT; ++i) {
cDst = cSrc; // 拷贝
}
auto end = clock.now();
std::chrono::duration<double> diff = end - start;
std::cout << typeid(T).name() << "\tTime to copy " << DATASIZE << " bytes data " << CopyCount << " times cost " << diff.count() << " s" << std::endl;

std::array<MoveBigData*, COUNT> mbdSrc;
for (size_t i = 0; i < mbdSrc.size(); ++i) {
mbdSrc[i] = new MoveBigData('1'); // 这里很浪费资源
}
MoveBigData mbdDst;
start = clock.now();
for (int i = 0; i < RUNCOUNT / 2; ++i) {
mDst = std::move(mSrc);
mSrc = std::move(mDst); // 移动, 最后数据会回到mbdSrc
}
end = clock.now();
diff = end - start;
std::cout << "Time cost of move " << COUNT << "'bytes: " << diff.count() << "s" << std::endl;

// 释放mbdSrc... 略

return 0;
}

输出:

1
2
3
4
5
class Data      Time to copy 1024 bytes data 100000 times cost 5.02812 s
class Data Time to move 1024 bytes data 100000 times cost 0.0296033 s // 哇, 移动效率相当的客观哟

class RawData Time to copy 1024 bytes data 100000 times cost 0.213069 s // 使用char*比std::vector<char>快这么多?
class RawData Time to move 1024 bytes data 100000 times cost 0.0060621 s // 还是说代码存在问题?

上面的例子足以说明新加的’移动’同学相当强大, 也足以说明它有被cpper所熟知并应用的必要.

右值引用 万能引用

如何告知编译器需要进行的是一个移动操作, 而不是其他? 这一点可以从引用(这里指T&这样的别名引用)得到一丝启发, T&类型就是告知编译器, 这是个引用类型, 新的变量名只是引用对象的别名罢了, 拜托不要给拷贝操作. 类似的, 新概念右值引用使用T&&告知编译器, 希望在这里进行一个移动操作, 所以对想要移动的对象, 在它的类型后面加上&&就可以了. 然而… 事情真的这么简单吗.
举个例子:

1
2
3
4
int i = 0;
int& iRef = i; // 没毛病
// int&& iRightRef = i; // 报错, 无法将一个右值引用绑定到左值
// int& iLeftRef = 0; // 报错, 非常量引用的初始值必须是一个左值, 相应的, 可不可以理解为, 不能将一个左值引用绑定到一个右值呢?

这个问题很简单, 右值引用顾名思义, 是需要对一个右值进行引用, ok, 很快修复, 继续

1
2
int&& rightRef = i * 1; // i * 1是一个右值了, ok, 但, rightRef始终是一个左值
auto&& apRef = rightRef; // rightRef是一个左值, 这一句却能够通过编译, 因为... auto, 并且apRef的类型变成了int&, why!

其实没有什么Why, 只不过是语言特性, 由于使用了auto, 那么就是想要编译器帮忙推导一下这个变量的类型, 但rightRef是一个左值, 不能将其使用与一个右值引用, 没关系, 不用右值引用可以用左值引用啊, 用右值引用无非是想获得对该对象的专属权然后来使用, 没法获得专属权, 说明是用户没写明白, 那就帮推导成一个左值引用好了, 获得它的使用权程序也能继续干活, 那就这么办吧. ps: 左值引用就是别名引用(隐…)
这个灵活的特性被称之为万能引用(此时正在看Scott Meyers的书, 他说叫万能引用那便是万能引用吧, 实际后来叫转发引用), 所以对于一个T&&来说, 它有两层意思:

  1. 如果绑定的对象是一个右值, 那么就是右值引用, 右值引用用于识别出可移动对象.
  2. 如果T是一个需要推导的类型(模板, auto等), 那么它是个万能引用, 此时若绑定的对象是左值时, 它变为一个左值引用.
    此处要留意, 需要推导是万能引用的必要而非充分条件, 即存在推导不一定是万能引用, 主要问题在于T&&中T的形式, 万能引用要求T就只能是T, 而不能是其他东西, 如const T&&, vector<T>&&, 这些将被认为是右值引用!

std::move std::forward

在祭出std::move std::forward之前, 还是了解一下其缘由的好… 方便知道右值引用为何要使用他俩.

移动操作限定了需要一个右值引用, 那么似乎其效用很有限啊, 因为实际上大多数时候, 我们都是在操作变量(也就是左值), 左值是不能被移动的, 左值有其生命期, 假设能够移动, 那将导致提前结束它的生命, 就如同一个指针, 将其设置为nullptr, 在程序后文中使用该值将引发一个错误. 那么有没有一种情况, 希望我们将一个左值当成一个右值使用, 并且程序确实不需要保留它的资源, 甚至想撇清关系? 似乎工厂模式就适合这样的场景, 工厂申请资源, 构建好产品再完全交割给用户, 应景! 确实有这样的需求, 那么如何移动一个左值, 额, 左值是不能被移动的, 这一点需要时刻明确, 那如何是好? 或许是我过分强调这个左右的概念了, 导致似乎左值就只能是在左边, 右值只能在右边, 完全忘记了左值也是可以在右边的, 左值兼有身份和值两个概念, 事实上, 当左值在赋值操作的右边时, 可以看做是读出对象的值, 而希望一个左值发生右值引用时, 是希望剥夺它身份的概念, 单独保留其值, 这时就可以将其看做是一个右值了, 我们知道, 当希望某个变量赋有或失去其类型之外的东西的时候, 实际上是发生了类型转换, 当需要从左值的身份和值择一而为时, 编译器便可以帮忙施为, 但要剥夺它身份的存在, 则需要手动施为, 编译器表示出了叉子别赖我, 拒绝背锅, 所以由左值到右值, 在移动右值这个过程将由所写的代码倾情演出, 说了这么多, 上菜上栗子:

1
2
3
4
// 使用上面定义好的类
Data mSrc('1');
Data mDst;
mDst = static_cast<Data&&>(mSrc); // !!强制类型转换, 将mSrc转换为右值引用类型, 调用Data的移动赋值构造函数, 再通过vector的移动赋值构造函数移动数据

事实上, static_cast<Data&&>这步正是std::move做的事情, std::move会将实参(如mSrc)强制转换为右值, 通知大家, 这个对象是一个具备移动条件.
std::move这个名字多少存在误导, 以为移动发生在这个函数里面, 但切记, 它真的只是做了强制类型转换, 不会移动任何东西, 实际上上面的代码大致可以等价于:

1
mDst = std::move(mSrc); // 当然std::move会做更多细节处理

当我发现static_cast<T&&>强制转换就能发生移动时, 实际上, 我先写了下面这个例子, 发现问题后才做了上面的测试, 事实上, 上面这个例子运行良好, 下面这个却:

1
2
3
4
5
6
7
8
9
10
// 使用上面定义好的类
int i = 1;
int&& iRightRef = static_cast<int&&>(i); // 也可以使用std::move(i)

// 移动并没有发生

i = 10; // i 仍然可以使用, 并没有被掏空
iRightRef = 20; // iRightRef 的使用似乎也没问题

cout << "output: " << i << " " << iRightRef << endl; // 但 output: 20 20, !!iRightRef变成了i的左值引用, why?

其实不难理解, 因为Data定义了移动赋值构造函数, 所以有明确的地点来实施这个移动计划, 而int就没有那么幸运了, 作为内建类型, 它太渺小或许小到压根不需要用移动这个牛刀, 但等式要完成, 那么隐式类型转换发生了, 具体过程可以参见上一个why(用why打了个标记, 用上了…), int&&或许被当做一个int&处理, 所以得到了一个左值引用, 而不是右值引用, 这种类似情况时有发生, 如带有const限定的值也能被转换为右值, 但’移动它’会发生赋值操作, const当然不可能让资源被转移.

所以这里我们可以得出结论: std::move而对这个对象之前是什么并不关心, 对之后移动是否发生也不关心, 唯一做的就是保证它的结果是一个右值, 是一个可移动的对象

而对于std::forward, 它做的事和std::move很像, 只不过它相对理智, 它只会对满足条件的参数进行强制类型转换, 条件是: 实参是使用右值完成初始化时, 它才会执行将参数强制转换为右值.
举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void process(const Data& data) {
cout << "forward to process accept const Data& data" << endl;
}

void process(Data&& data) {
cout << "forward to process accept Data&& data" << endl;
}

template <typename T>
void forwardParamToProcess(T&& param) { // T&&是一个万能引用, param 是一个左值
process(std::forward<T>(param)); // forward不关心param是左值还是右值, 而是关心它的初始化物是左值还是右值
}

int main() {
Data data('1');
forwardParamToProcess(data); // param的实参data是一个左值, forward不会对param进行右值转换, 所以将会调用第一个process
forwardParamToProcess(std::move(data)); // param的实参data是一个右值, forward会对param进行右值转换, 所以将会调用第二个process

return 0;
}

输出:

1
2
forward to process accept const Data& data
forward to process accept Data&& data

看上去std::move的功能完全可以被更智能的std::forward所替代, 实际不然, 它们使用场景各不相同, 使用std::move时更想传达一个希望对参数进行一个移动操作, 而std::forward更多的是转发参数到令一个对象或函数.
从这个角度出发, std::move与std::forward的命名也更加贴切, 这样也能使代码逻辑更加清晰, 使不至于错用误用.