右值和移动
CPP的Expression

- 一个 lvalue 是通常可以放在等号左边的表达式,左值
- 一个 rvalue 是通常只能放在等号右边的表达式,右值
- 一个 glvalue 是 generalized lvalue,广义左值
- 一个 xvalue 是 expiring lvalue,将亡值
- 一个 prvalue 是 pure rvalue,纯右值
左值 lvalue 是有标识符、可以取地址的表达式:
- 变量、函数或数据成员的名字
- 返回左值引用的表达式,如 ++x、x = 1、cout << ""
- 字符串字面量如 "morisa"
纯右值 prvalue 是没有标识符、不可以取地址的表达式,一般也称之为“临时对象”:
- 返回非引用类型的表达式,如 x++、x + 1、make_shared(1)
- 除字符串字面量之外的字面量,如 42、true
右值引用
在 C++11 之前,右值可以绑定到常左值引用(const lvalue reference)的参数,如 const T&,但不可以绑定到非常左值引用(non-const lvalue reference),如 T&。从 C++11 开始,C++ 语言里多了一种引用类型——右值引用。右值引用的形式是 T&&,比左值引用多一个 & 符号。跟左值引用一样,我们可以使用 const 和 volatile 来进行修饰,但最常见的情况是,我们不会用 const 和 volatile 来修饰右值。
template <typename V>
m_s_ptr(m_s_ptr<V>&& other)
{
_ptr = other._ptr;
if (_ptr) {
_sc = other._sc;
other._ptr = nullptr;
}
}
根据定义,other 是个变量的名字,变量有标识符、有地址,所以是一个左值——虽然它的类型是右值引用。
拿这个 other 去调用函数时,它匹配的也会是左值引用。类型是右值引用的变量是一个左值!
m_s_ptr<Base> p1{new A()};
m_s_ptr<Base> p2 = std::move(p1);
new A() 就是一个纯右值;但对于指针,我们通常使用值传递,并不关心它是左值还是右值。
std::move(p1)作用是把一个左值引用强制转换成一个右值引用,而并不改变其内容。
std::move(p1)等价于static_cast<m_s_ptr<Base>&&>(p1)。
这里的结果是一个指向p1的一个右值引用,因此会调用上面的重载构造m_s_ptr(m_s_ptr<V>&& other) 。
可以把 std::move(ptr1) 看作是一个有名字的右值。为了跟无名的纯右值 prvalue 相区别,C++ 里目前就把这种表达式叫做 xvalue。跟左值 lvalue 不同,xvalue 仍然是不能取地址的,这点上,xvalue 和 prvalue 相同。所以,xvalue 和 prvalue 都被归为右值 rvalue。

生命周期
一个变量的生命周期在超出作用域时结束。如果一个变量代表一个对象,当然这个对象的生命周期也在那时结束。
一个临时对象(prvalue)会在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁,除非有生命周期延长发生。
class Base
{
public:
virtual ~Base(){}
};
class A : public Base
{
public:
A() { std::cout << "A" << std::endl; }
~A() { std::cout << "~A" << std::endl; }
};
class TMP
{
public:
TMP() { std::cout << "TMP" << std::endl; }
~TMP() { std::cout << "~TMP" << std::endl; }
};
TMP test(const Base& base)
{
std::cout << "test" << std::endl;
return TMP();
}
int main(int argc, char** argv)
{
std::cout << "start" << std::endl;
test(A());
std::cout << "end" << std::endl;
}
/*
output:
start
A
test
TMP
~TMP
~A
end
*/
为了方便对临时对象的使用,C++ 对临时对象有特殊的生命周期延长规则。
如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。
test(A());改为TMP&& tmp = test(A());
/*
output:
start
A
test
TMP
~A
end
~TMP
*/
生命期延长规则只对
prvalue有效,而对xvalue无效。如果由于某种原因,prvalue在绑定到引用以前已经变成了xvalue,那生命期就不会延长。
TMP&& tmp = std::move(test(A()));
/*
output:
start
A
test
TMP
~TMP
~A
end
*/
执行到 end 时仍有一个有效的变量 tmp,但它指向的对象已经不存在了,对 tmp的解引用是一个未定义行为。由于 tmp指向的是栈空间,通常不会立即导致程序崩溃,而会在某些复杂的组合条件下才会引致问题。
移动
移动语义使得在 C++ 里返回大对象(如容器)的函数和运算符成为现实,因而可以提高代码的简洁性和可读性,提高程序员的生产率。所有的现代 C++ 的标准容器都针对移动进行了优化。
C++ 里的对象缺省都是值语义。
class A { B b; C c; };从实际内存布局的角度,很多语言——如 Java 和 Python——会在 A 对象里放 B 和 C 的指针(虽然这些语言里本身没有指针的概念)。而 C++ 则会直接把 B 和 C 对象放在 A 的内存空间里。这种行为既是优点也是缺点。说它是优点,是因为它保证了内存访问的局域性,而局域性在现代处理器架构上是绝对具有性能优势的。说它是缺点,是因为复制对象的开销大大增加:在 Java 类语言里复制的是指针,在 C++ 里是完整的对象。这就是为什么 C++ 需要移动语义这一优化,而 Java 类语言里则根本不需要这个概念。
对象支持移动的话,通常需要下面几步:
- 应该有分开的拷贝构造和移动构造函数(除非你只打算支持移动,不支持拷贝——如
unique_ptr)。 - 对象应该有
swap成员函数,支持和另外一个对象快速交换成员。 - 对象的名空间下,应当有一个
全局 swap函数,调用成员函数 swap 来实现交换。支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象,并快速实现它们的 swap 函数。 - 实现通用的
operator=。 - 上面各个函数不抛异常的话,应当标为
noexcept。
移动构造函数应当从另一个对象获取资源,清空其资源,并将其置为一个可析构的状态。
m_shared_ptr(const m_shared_ptr& other) noexcept
{
ptr = other.ptr;
if (ptr) {
other.counter->increase();
counter = other.counter;
}
}
template <typename V>
m_shared_ptr(const m_shared_ptr<V>& other) noexcept
{
ptr = other.ptr;
if (ptr) {
other.counter->increase();
counter = other.counter;
}
}
template <typename V>
m_shared_ptr(m_shared_ptr<V>&& other) noexcept
{
ptr = other.ptr;
if (ptr) {
counter = other.counter;
other.ptr = nullptr;
}
}
swap 成员函数
void swap(m_shared_ptr& other) noexcept
{
std::swap(ptr, other.ptr);
std::swap(counter, other.counter);
}
全局 swap 函数
template <typename T>
void swap(m_shared_ptr<T>& A, m_shared_ptr<T>& B) noexcept
{
A.swap(B);
}
通用的 operator= 成员函数
m_shared_ptr& operator=(m_shared_ptr other)
{
m_shared_ptr(other).swap(*this);
return *this;
}
通常我们需要将其实现成对 a = a; 这样的写法安全。下面的写法算是个小技巧,对传递左值和右值都有效,而且规避了 if (&other != this) 这样的判断。
这里左值和右值都有效是因为构造参数时,如果是左值,就用拷贝构造构造函数,右值就用移动构造函数 无论是左值还是右值,构造参数时直接生成新的智能指针,因此不需要判断。
不要返回本地变量的引用
有一种常见的 C++ 编程错误,是在函数里返回一个本地对象的引用。由于在函数结束时本地对象即被销毁,返回一个指向本地对象的引用属于未定义行为。
在 C++11 之前,返回一个本地对象意味着这个对象会被拷贝,除非编译器发现可以做返回值优化(named return value optimization,或 NRVO),能把对象直接构造到调用者的栈上。
从 C++11 开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 std::move 进行干预——使用 std::move 对于移动行为没有帮助,反而会影响返回值优化。
用了 std::move 妨碍了返回值优化。
class C
{
public:
C()noexcept { std::cout << "C()" << std::endl; }
C(const C&)noexcept { std::cout << "C(const C&)" << std::endl; }
C(C&&)noexcept { std::cout << "C(C&&)" << std::endl; }
};
C fun1()
{
C c;
// 简单返回对象一般有 NRVO
return c;
}
C fun2()
{
C c;
//std::move禁止 NRVO
return std::move(c);
}
C fun3(bool f)
{
C c1, c2;
//有分支一般无 NRVO
return f ? c1 : c2;
}
int main(int argc, char** argv)
{
std::cout << "fun1" << std::endl;
C c1 = fun1();
std::cout << "fun2" << std::endl;
C c2 = fun2();
std::cout << "fun3" << std::endl;
C c3 = fun3(true);
}
/*
output:
fun1
C()
C(C&&)
fun2
C()
C(C&&)
fun3
C()
C()
C(const C&)
*/
引用坍缩和完美转发
T&一定是个左值引用,T&&不一定是个右值引用。
对于 template <typename T>foo(T&&) ,如果传递过去的参数是左值,T 的推导结果是左值引用;如果传递过去的参数是右值,T 的推导结果是参数的类型本身。
如果 T 是左值引用,那 T&& 的结果仍然是左值引用——即 type& && 坍缩成了 type&。
如果 T 是一个实际类型,那 T&& 的结果自然就是一个右值引用。
右值引用变量仍然会匹配到左值引用上去。
class Base
{
public:
virtual ~Base(){}
};
class A : public Base
{
public:
A() { }
~A() { }
};
void f1(const Base&)
{
std::cout << "const Base&" << std::endl;
}
void f1(Base&&)
{
std::cout << "Base&&" << std::endl;
}
void f2(const Base& b)
{
std::cout << "const Base& b" << std::endl;
f1(b);
}
void f2(Base&& b)
{
std::cout << "Base&& b" << std::endl;
f1(b);
}
int main(int argc, char** argv)
{
f2(A());
}
/*
output:
Base&& b
const Base&
*/
要f1调用右值引用,写成f1(std::move(b));或f2(static_cast<Base&&>(b));
我们需要能够保持参数的值类别:左值的仍然是左值,右值的仍然是右值,用std::forward函数实现。
template <typename T>
void f2(T&& t)
{
f1(std::forward<T>(t));
}
// in main
A a;
f2(a);
f2(A());
/*
output:
const Base&
Base&&
*/
在 T 是模板参数时,T&& 的作用主要是保持值类别进行转发,叫“转发引用”(forwarding reference)。既可以是左值引用,也可以是右值引用,它也曾经被叫做“万能引用”(universal reference)。
常见问题
值类别(value category)指的是上面这些左值、右值相关的概念,后者则是与引用类型(reference type)相对而言,表明一个变量是代表实际数值,还是引用另外一个数值。在 C++ 里,所有的原生类型、枚举、结构、联合、类都代表值类型,只有引用(&)和指针(*)才是引用类型。在 Java 里,数字等原生类型是值类型,类则属于引用类型。在 Python 里,一切类型都是引用类型。
标准函数模板 make_shared 的声明
template <class T, class... Args> std::shared_ptr<T> make_shared (Args&&... args) { T* ptr = new T(std::forward<Args...>(args...)); return std::shared_ptr<T>(ptr); }make_shared声明里的(Args&&...) 是universal reference, 所以在函数体里用完美转发(std::forward)把参数出入T的构造函数, 以调用每个参数各自对用的构造函数(copy or move)。
- 本文链接:https://morisa66.github.io/2021/03/03/RValue/
- 版权声明:本博客所有文章除特别声明外,均默认采用 许可协议。

