C++|值类别(左值、右值)、移动语义、省略复制(返回值优化)

 2024-03-13 00:10:03  阅读 0

每个C++表达式都有两个属性:类型(type)和值类别(value)。

type 和 type 都可以翻译为“类型”或“类别”,但为了区分两者,下文中将 type 翻译为“类型”和“类别”。

1 我们先从CPL语言的定义开始

左值和右值的概念最早出现在C语言的祖先语言:CPL中。

在CPL的定义中,表示左侧值,即可以出现在赋值运算符(等号)左侧的值,右值的定义也是如此。

2 C 和 C++11 前

C语言遵循类似的分类方法,但判断左右值的标准与赋值运算符无关。

const int a = 0; // a这个id-expr是左值表达式,但a不能被放在赋值运算符左侧
"Luogu" // 字符串常量是左值表达式,但是常量表达式显然不能放在运算符左侧
int arr[3]; // arr作为id-expr时是左值表达式,但是不能被放在赋值运算符左侧

在新的定义中,它的意思是值,即可以用于地址运算(&)的值。

可以这样理解:左值是一个有内存地址的对象,而右值只是一个中间计算结果(虽然编译器经常需要在内存中分配一个地址来存储这个值,但这个内存地址是程序员无法感知的) ,所以可以假设它不存在)。 中间计算结果意味着该值立即无用,并且将来不会再次访问。

例如,在代码 int a = 0; 中,a 是左值,0 是右值。

++i和i++是典型的左值和右值。 ++i的实现是直接给i变量加一,然后返回i本身。 因为 i 是内存中的变量,所以它可以是左值。 事实上,前一个增量的函数签名是 T& T::++();。 但 i++ 不同。 它的实现是使用一个临时变量来存储i,然后对i加一。 返回的是一个临时变量,因此它是一个右值。 后面增量的函数签名是 TT::++(int);。

int n1 = 1;
int n2 = ++n1;
int n3 = ++ ++n1;  // 因为是左值,所以可以继续操作
int n4 = n1++;
// int n5 = n1++ ++;   // 错误,无法操作右值
// int n6 = n1 + ++n1; // 未定义行为
int&& n7 = n1++;  // 利用右值引用延长生命期
int n8 = n7++;    // n8 = 1

关于左值和右值的常见误解

以下类型是经常被误认为右值的左值:

3 C++11 入门

从C++11开始,为了配合移动语义,值类别不再像或那么简单。

考虑一个简单的场景:

std::vector src{...};
std::vector dst;
dst = src;

我们知道,第三行的赋值操作复杂度与src的长度成正比,复制的成本非常高。 但在某些情况下,比如src在以后的代码中不会被使用,那么我们可以将src所持有的内存“转移”到dst。 这就是移动语义的作用。

不管移动是如何实现的,我们首先考虑如何将 src 标记为可移动。 显然,这个表达式的类型无论是否可以移动都保持不变,所以我们只能从值类别入手。 不可移动的 src 是一个左值。 如果我们想在原系统下标记可移动的src,只能将其标记为右值。 但将其标记为右值是不合理的,因为这个src实际上有自己的内存地址,这与其他右值有本质的不同。 因此,C++11引入了dead()的值类别来标记这种表达式。

所以我们现在有三类:()、pure ()(pure 是原始右值)、dead value()。

然后我们发现死值同时具有左值和纯右值的一些属性。 例如,它可以像左值一样获取地址,并且像右值一样它不会被再次访问。

所以又多了两个组合类别:通用()(左值和死值)、()(纯右值和死值)。

有了初步的感性认识后,我们来看看标准委员会对它们的定义:

两个关键概念是:

这五种无非是根据上述两个属性的是或否来区分,所以下表可以帮助理解:

有身份()

没有身份

它可以移动()

不可移动

不存在

请注意,不拥有该身份意味着将来无法访问该对象。 这样的对象显然是可以移动的,因此不拥有身份就没有不能移动的价值。

4 移动语义和 std::move(C++11)

在C++11之后,C++增加了对使用右值引用的移动语义的支持,以避免复制堆空间中的对象(但无法避免复制堆栈空间)。 STL容器完全支持这个特性。 具体功能包括移动构造函数、移动赋值和具有移动功能的函数(包含右值引用的参数)。 另外,std::move函数可以用来生成右值引用,需要包含头文件。

注意:对象移动后,无论是修改还是访问,都不应对其执行任何操作。 被移动的对象处于有效但未指定的状态,具体内容取决于STL实现。 如果需要访问(即指定一个状态),可以使用对象的swap成员函数或者部分特化的std::swap来交换两个对象(这样也可以避免堆空间复制)。

// 移动构造函数
std::vector v{1, 2, 3, 4, 5};
std::vector v2(std::move(v));  // 移动v到v2, 不发生拷贝
// 移动赋值函数
std::vector v3;
v3 = std::move(v2);
// 有移动能力的函数
std::string s = "def";
std::vector numbers;
numbers.push_back(std::move(s));

当右值引用指向的空间在进入函数之前已经分配时,右值引用可以避免复制返回值。

struct Beta {
    Beta_ab ab;
    Beta_ab const& getAB() const& { return ab; }
    Beta_ab&& getAB() && { return std::move(ab); }
};
Beta_ab ab = Beta().getAB();  // 这里是移动语义,而非拷贝

C++17带来的5个新变化

从复制到移动的速度提升了很多,那么我们是否可以更彻底地优化它,节省移动的开销呢?

考虑这段代码:

std::vector make_vector(...) {
    std::vector result;
    // ...
    return result;
}
std::vector a = make_vector(...);

函数根据输入生成函数。 它最初是在堆栈上构建的,然后移动到调用者的堆栈中,需要进行移动操作。 这显然是浪费。 这个动作可以省略吗?

答案是肯定的,这就是RVO(Value)优化,省略了复制。 通常的方法是编译器直接在调用者的堆栈上构造返回的对象,然后在顶部对其进行修改。 这相当于这样的代码:

void make_vector(std::vector& result, ...) {
    // ... (对 result 进行操作)
}
std::vecctor a;
make_vector(a, ...);

在C++17之前,虽然标准没有指定,但所有主要编译器都实现了这种优化。 C++17之后,这种优化成为标准的强制要求。

回到移动语义最初提出时的问题,如何判断一个移动赋值是否可以省略? 引入新的价值类别?

不,C++11 值类已经足够复杂了。 我们意识到在C++11标准下,死值和纯右值都是可以移动的,所以我们可以在这两类上做文章。

C++17之后,纯右值不再可以移动,但可以隐式转换为死值。 在使用纯右值进行初始化的情况下,可以省略副本,但在其他不能省略的情况下,它会隐式转换为死值进行移动。

因此,C++17之后的值类别更加整齐地分为泛左值和纯右值,右值存在的意义被削弱。 这一变化在一定程度上简化了整个价值类别体系。

从C++17开始,纯右值无法移动,并且引入了强制复制消除(copy)的要求,达到了前所未有的值类别的最复杂阶段。 另外,void表达式开始引用一个无结果的对象,也变成了一个无法移动、没有标识的纯右值。

参考

标签: 移动 类别 拷贝

如本站内容信息有侵犯到您的权益请联系我们删除,谢谢!!


Copyright © 2020 All Rights Reserved 京ICP5741267-1号 统计代码