docs/lang/value-category.md
值类别是 C++ 中一个非常重要的概念,虽然在算法竞赛中可能用处不大,但了解它可以帮助我们发现并避免不必要的复制,从而提高代码的效率和性能.
值类别的概念在 C 语言、C++98、C++11 和 C++17 中经历了多次发展,逐渐成为一个较为复杂的概念.
我们考虑将字符串塞入 vector 这一过程:
int main() {
std::vector<std::string> vec;
vec.reserve(3);
for (int i = 0; i < 3; ++i) {
std::string str;
std::cin >> str;
vec.push_back(str);
}
return 0;
}
可以发现字符串在转移的过程中,在 str 和 vec 中各保存了一份,内存占用加倍.
如果非要省下这一部分的内存,我们可以实现一个简陋的移动操作:自定义 MyString 结构体,内有一指针指向我们的字符串,即我们只需要把指针复制过去,并小心地清理原对象的指针,防止被错误析构.
struct MyString {
char *beg, *end;
// ...
};
void move_to(MyString &src, MyString &dst) {
dst.beg = src.beg;
dst.end = src.end;
src.beg = src.end = nullptr;
}
由于这种高效转移对象的需求较为常见,且与 C++ 的构造、析构等操作交互困难,C++11 将移动语义引入了语言核心.
在 C 语言标准中,对象是一个比变量更为一般化的概念,它指代一块内存区域,具有内存地址.对象的主要属性包括:大小、有效类型、值和标识符.标识符即变量名,值是该内存以其类型解释时的含义.例如,int 和 float 类型虽然都占用 4 字节,但对于同一块内存,我们会解释出不同的含义.
C 语言中每个表达式都具有类型和值类别.值类别主要分为三类:
因此,只有可修改的左值(没有 const 修饰且非数组的左值)可以位于赋值表达式左侧.
对于某个要求右值作为它的操作数的运算符,每当左值被用作操作数,都会对该表达式应用左值到右值,数组到指针,或者函数到指针标准转换以将它转换成右值.
常见误区:
int *a,表达式 a + 1 是右值,但 *(a + 1) 是左值.int *a,不能说变量 a 是左值,可以说其在表达式 a 中做左值.C++98 在值类别方面与 C 语言几乎一致,但增加了一些新的规则:
const T& 可绑定到右值.C++ 允许编译器执行复制消除(Copy Elision),可以减少临时对象的创建和销毁.
例如下面的代码,就触发了复制消除中的返回值优化(Return Value Optimization,RVO),你只会看到一次构造和一次复制构造,即便构造与析构有副作用.
struct X {
X() { std::puts("X::X()"); }
X(const X &) { std::puts("X::X(const X &)"); }
~X() { std::puts("X::~X()"); }
};
X get() {
X x;
return x;
}
int main() {
X x = get();
X y = X(X(X(X(x))));
return 0;
}
C++11 引入了移动语义和右值引用(T&&),包括移动构造、移动赋值函数.这给了我们利用临时对象的方法.
我们上面的 move_to 可以改写如下:
struct MyString {
// ...
MyString(MyString&& other) {
beg = other.beg;
end = other.end;
other.beg = other.end = nullptr;
}
};
我们现在关注的表达式特性增加了一点:
因此我们有三种值类别:
另外 C++11 还引入了两个复合类别:
为了配合移动语义,C++11 还引入了一个工具函数 std::move,其作用是将左值强制转换为右值,以便触发移动语义.
int main() {
std::vector<int> a = {1, 2, 3};
std::cout << "a: " << a.data() << std::endl;
std::vector<int> b = a;
std::cout << "b: " << b.data() << std::endl;
std::vector<int> c = std::move(b);
std::cout << "c: " << c.data() << std::endl;
}
因此我们只需将 push_back(str) 改为 push_back(std::move(str)) 即可避免复制.
int main() {
std::vector<std::string> vec;
vec.reserve(3);
for (int i = 0; i < 3; ++i) {
std::string str;
std::cin >> str;
vec.push_back(std::move(str));
// 另一种巧妙的写法,需要 C++17
// std::cin >> vec.emplace_back();
}
return 0;
}
由于
std::string有小对象优化(Small String Optimization,SSO),短字符串直接存储于结构体内,你可能得输入较长的字符串才能观察到data指针的不变性.
C++17 进一步简化了值类别:
C++11 将复制消除扩展到了移动上,下面的代码中 urvo 在编译器启用 RVO 的情况下是没有移动的.
C++17 要求纯右值非必须不实质化,直接构造到其最终目标的存储中,在构造之前对象尚不存在.因此在 C++17 中我们就没有返回这一步,也就不必依赖 RVO.也可以理解为强制了 URVO(Unnamed RVO),但对于 NRVO(Named RVO)还是非强制的.
std::string urvo() { return std::string("123"); }
std::string nrvo() {
std::string s;
s = "123";
std::cout << s;
return s;
}
int main() {
std::string str = urvo(); // 直接构造
std::string str = nrvo(); // 不一定直接构造,依赖于优化
}
同时 C++17 引入了临时量实质化的机制,当我们需要访问成员变量、调用成员函数等需要泛左值的情形时,可以隐式转换为亡值.
下面的例子中:
f1 中返回 std::move(x) 是多余的,并不会带来性能上的提升,反而会干扰编译器进行 NRVO 优化.f2 中返回 std::move(x) 是危险的,函数返回右值引用指向了已被销毁的局部变量 s,出现了悬空引用问题.std::string f1() {
std::string s = "123";
// 等价于 return std::string(std::move(s))
return std::move(s);
}
std::string&& f2() {
std::string s = "123";
return std::move(s);
}