C++基础之左值、右值与移动语义

左值与右值

全文翻译自:https://eli.thegreenplace.net/2011/12/15/understanding-lvalues-and-rvalues-in-c-and-c

典型错误

//gcc
int foo() {return 2;}

int main() {
    foo() = 2;
    return 0;
}

//test.c: In function 'main':
//test.c:8:5: error: lvalue required as left operand of assignment
//g++
int& foo() {
    return 2;
}
//testcpp.cpp: In function 'int& foo()':
//testcpp.cpp:5:12: error: invalid initialization of non-const reference
of type 'int&' from an rvalue of type 'int'

左值的定义

An lvalue(locator value) represents an object that occupies some identifiable location in memory(i.e. has an address).

关键字

一个对象,在内存中可识别(有地址

右值的定义

Rvalues are defined by exclusion, by saying that every expression is either an lvalue or an rvalue. Therefore, from the above definition of lvalue,  an rvalue is an expression that does not represent an object occupying some identifiable location in memory.

关键字

一个表达式,并非一个在内存中占有位置(即无地址)的对象,左值的补集(表达式)

基本示例

int var;
var = 4; //CORRECT!赋值运算符=期望在它左边的是左值,因为它需要一个可识别内存位置的对象

4 = var; //ERROR!
(var +1) = 4; //ERROR!
//常量4和表达式var +1都不是左值,而是右值。
//因为它们都是表达式的临时结果,没有可识别的内存位置(它们仅存在于一些连续计算的临时寄存器中)。
//因此给它们赋值没有意义,没有地方(内存)赋值(存储)

所以,典型错误示例1中foo返回的是一个临时值,右值,它是不能出现在赋值运算符的左边的,编译器期望在此位置出现的是一个左值,所以会报错。

但也并非所有对函数的返回值赋值的操作都是非法的。例如,C++中的引用可以实现:

int globalvar = 20;

int& foo() {
    return globalvar;
}

int main() {
    foo() = 10;
    return 0;
}

这里foo返回的是一个引用,一个左值,所以它可以被赋值。事实上,C++从函数返回左值的能力对于重载一些操作符来说是非常重要的。一个典型的重载例子是类中的中括号运算符,用来实现一些查找访问。

std::map<int, float> mymap;
mymap[10] = 5.6;
//mymap[10]之所以可行就是因为非常量重载std::map::operator[]返回的值一个引用,可以被赋值

左值的可修改性

最初,C语言中定义的左值,即字面意思的“匹配赋值运算符左边的变量”。然而后来,ISO增加了const关键字,这个定义不得不进行了完善。

const int a = 10; // 'a' is an lvalue
a = 10; // but it can't be assigned!

定义完善后,并非所有左值都可被赋值。可以被赋值的称为modifiable lvalues。C99标准定义的modifiable lvalues

[...] an lvalue that does not have array type, does not have an incomplete type, does not have a const-qualified type, and if it is a structure or union, does not have any member (including, recursively, any member or element of all contained aggregates or unions) with a const-qualified type.

左值和右值间的转化

一般来说,语言构造操作一个对象的值需要参数为右值。例如,二元加法操作符“+”要求两个右值作为参数,并返回一个右值: 

int a = 1;     // a is an lvalue
int b = 2;     // b is an lvalue
int c = a + b; // + needs rvalues, so a and b are converted to rvalues and an rvalue is returned

我们可以看到,a和b都是左值。因此,第三行,它们执行了一个左值到右值的隐式转换所有的非数组,函数或者不完整类型的左值都可以转换为右值

反过来,右值不能隐式转换为左值,当然不能!这样违反了左值的本质:

rvalues can be assigned to lvalues explicitly. The lack of implicit conversion means that rvalues cannot be used in places where lvalues are expected.

右值可以通过显式转换的方式变成左值。例如,一元操作符“*”(解引用)需要的是一个右值,但它的结果却能产生一个左值:

int arr[] = {1, 2};
int* p = &arr[0];
*(p + 1) = 10;   // OK: p + 1 is an rvalue, but *(p + 1) is an lvalue

相反,一元操作符“&”(取地址)需要的是一个左值,产生的是一个右值:

int var = 10;
int* bad_addr = &(var + 1); // ERROR: lvalue required as unary '&' operand
int* addr = &var;           // OK: var is an lvalue
&var = 40;                  // ERROR: lvalue required as left operand of assignment

“&”在C++中还扮演另一个重要角色——定义引用类型,即左值引用非常量左值引用不能被赋予右值,因为会造成非法的右值到左值的转换:

std::string& sref = std::string();  // ERROR: invalid initialization of non-const reference of type
                                    // 'std::string&' from an rvalue of type 'std::string'

常量左值引用能够被赋予右值。因为它们是常量,不允许通过引用被修改,所以不会存在修改右值的问题。这使得在函数中通过常量引用来接收值成了C++的惯常手法,从而避免了不必要的拷贝与构造临时对象的操作。

CV修饰的右值

C++标准中他轮到左值到优质的转换问题,写道:

An lvalue (3.10) of a non-function, non-array type T can be converted to an rvalue. [...] If T is a non-class type, the type of the rvalue is the cv-unqualified version of T. Otherwise, the type of the rvalue is T.

文中的“cv-unqualified”是什么?CV-qualifier是一个被用来描述const和volatile类型修饰符的术语。

Each type which is a cv-unqualified complete or incomplete object type or is void (3.9) has three corresponding cv-qualified versions of its type: a const-qualified version, a volatile-qualified version, and a const-volatile-qualified version. [...] The cv-qualified or cv-unqualified versions of a type are distinct types; however, they shall have the same representation and alignment requirements (3.9)

翻译过来大致是:“每个非CV修饰的完全或不完全对象类型或者是空类型(3.9)都有三个相关的CV修饰的类型版本:const修饰版,volatile修饰版,和const-volatile修饰版。[…]一个类型的CV修饰版和非CV修饰版是不同的类型。然而,它们有相同的表示和对齐要求(3.9)”

但这和右值有什么关系呢?C语言中,右值没有CV修饰版的类型。只有左值有。另一方面,C++中,类型的右值能够有CV修饰版的类型,但内置类型(例如int)就不能有

#include <iostream>

class A {
public:
    void foo() const { std::cout << "A::foo() const\n"; }
    void foo() { std::cout << "A::foo()\n"; }
};

A bar() { return A(); }
const A cbar() { return A(); }

int main() {
    bar().foo();  // calls foo()
    cbar().foo(); // calls foo() const
}

main中第二个调用的是类A的foo() const方法,因为cbar返回的是const A类型,区别于A类型,且是右值。所以这就是一个典型的CV修饰的右值的应用示例。

右值引用(C++11)

右值引用和相关的“移动语义”是C++11标准引入的最强大的新特性之一。更多探讨详见文末的推荐链接,这里提供一个简单示例用以更好理解左值和右值的概念以及它们在语言中的重要性。

如上论述,左值和右值最主要的区别就是左值可以被修改,右值不能。而C++11标准对这个区别增加了一个关键转折——在一些特殊情形下,允许我们使用右值引用来修改它们。

考虑这样的一个简单的动态“整型数组”的实现:

class Intvec {
public:
    explicit Intvec(size_t num = 0)
        : m_size(num), m_data(new int[m_size]) {
        log("constructor");
    }

    ~Intvec() {
        log("destructor");
        if (m_data) {
            delete[] m_data;
            m_data = 0;
        }
    }

    Intvec(const Intvec& other)
        : m_size(other.m_size), m_data(new int[m_size]) {
        log("copy constructor");
        for (size_t i = 0; i < m_size; ++i)
            m_data[i] = other.m_data[i];
    }

    Intvec& operator=(const Intvec& other) {
        log("copy assignment operator");
        Intvec tmp(other);
        std::swap(m_size, tmp.m_size);
        std::swap(m_data, tmp.m_data);
        return *this;
    }
private:
    void log(const char* msg) {
        cout << "[" << this << "] " << msg << "\n";
    }

    size_t m_size;
    int* m_data;
};

如此,我们有了通常的构造函数、析构函数、拷贝构造函数和拷贝赋值操作符【关于这个赋值操作符的具体实现,原作者是这样解析的:

This a canonical implementation of a copy assignment operator, from the point of view of exception safety. By using the copy constructor and then the non-throwing std::swap , it makes sure that no intermediate state with uninitialized memory can arise if exceptions are thrown.】

简单运行:

Intvec v1(20);
Intvec v2;

cout << "assigning lvalue...\n";
v2 = v1;
cout << "ended assigning lvalue...\n";

输出结果:

assigning lvalue...
[0x28fef8] copy assignment operator
[0x28fec8] copy constructor
[0x28fec8] destructor
ended assigning lvalue...

拷贝赋值操作符的内部执行过程没问题。尝试用右值给v2赋值:

cout << "assigning rvalue...\n";
v2 = Intvec(33);
cout << "ended assigning rvalue...\n";

可以看到,这里多了一个临时对象的构造/析构过程。真可惜,这个操作很“多余”。

所以,C++11的右值引用给予了实现“移动语义”的可能,尤其是“移动赋值操作符”(区别于前面的“拷贝赋值操作符”)。

为类Intvec增加一个移动赋值操作符:

Intvec& operator=(Intvec&& other) { //参数是右值引用
    log("move assignment operator");
    std::swap(m_size, other.m_size);
    std::swap(m_data, other.m_data);
    return *this;
}

&&”语法就是最新的右值引用。名副其实,返回的就是一个右值的引用,并将在调用之后被析构。我们可以使用这个概念去“偷”内部的右值——它不再被需要啦!输出结果为:

assigning rvalue...
[0x28ff08] constructor
[0x28fef8] move assignment operator
[0x28ff08] destructor
ended assigning rvalue...

新的“移动赋值操作符”被调用了,因为一个右值被赋予了v2。创建临时对象Intvec(33)的构造和析构函数仍然需要,但是另一个赋值操作符内部的临时对象不再被需要。这个操作符只是简单地将右值内部的缓冲区与它自己(this指向的对象)的进行了交换,由此右值的析构函数将释放的是this对象本身不再使用的缓冲区,紧凑。

这个简单的例子只是右值引用和移动语义的冰山一角。这里只是证明C++中左值与右值之间区别的一个有趣的应用。编译器显然知道一些实例什么时候是右值,并能在编译阶段调配正确的构造函数。

小总结

C++11引入的新特性使得左值、右值,以及左值引用、右值引用的概念变得更加重要,对它们的牢固理解将对更深层次的理解C++的代码结构大有裨益,也很有必要。

理解能力测试:

class ValueCl {
public:
    ValueCl(const int &value) :mValue(value) {};

public:
    void SetValue(const int& value) { mValue = value; }
    int GetValue() const { return mValue; }

    ValueCl& operator+(const ValueCl& v) {
        mValue += v.mValue;
        return *this;
    }

protected:
    int mValue;
};

int main() {
    /*
     * 此处体现了可以在自定义类型的右值对象上调用类方法修改右值属性,
     * 并且const左值引用可以延长右值的生命周期
     */
    ValueCl{ 3 }.SetValue(4);

    /*
     * 对于这种情况下的右值可以出现在赋值表达式的左侧是因为,
     * ValueCl& operator=(const ValueCl& v);的默认赋值运算符的存在。
     * 同时可以在右值对象上调用类非const方法
     */
    ValueCl{ 1 } + ValueCl{ 2 } = ValueCl{ 3 };
}

右值引用与移动语义

在上文介绍右值引用时提到了C++11标准引入的另一个非常重要的新特性——移动语义,这一章将重点解密这个新特性。

全文摘译自:http://thbecker.net/articles/rvalue_references/section_01.html

右值引用至少解决了两个问题

1. 实现移动语义

2. 完美转发

先来回顾一下左值与右值:

//基础区分:赋值操作符的左边或右边
int a = 42;
int b = 43;
// a and b are both l-values:
a = b; // ok
b = a; // ok
a = a * b; // ok
// a * b is an rvalue:
int c = a * b; // ok, rvalue on right hand side of assignment
a * b = 42; // error, rvalue on left hand side of assignment

//技巧区分:在内存中有地址,可访问
// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
int& foo(); //function
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues:
int foobar(); //function
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue
j = 42; // ok, 42 is an rvalue

移动语义

 X 是一个类,有一个指针变量: m_pResource ,还有构造、复制、析构等操作(一个很好的例子是将X代入 std::vector ,则需要分配一个数组的内存)。那么它的拷贝构造函数会像这样:

X& X::operator=(X const &rhs) {
  // [...]
  // Make a clone of what rhs.m_pResource refers to.
  // Destruct the resource that m_pResource refers to. 
  // Attach the clone to m_pResource.
  // [...]
}

如果这样使用它:

X foo();
X x;
// perhaps use x in various ways
x = foo();

则最后一行代码的执行过程大致如下:

  • clones the resource from the temporary returned by foo,
  • destructs the resource held by x and replaces it with the clone,
  • destructs the temporary and thereby releases its resource.

关于这点,在上面的右值引用实际上已经探讨过了,就是会生成一个“没必要”的临时对象,会在中间环节经历构造并析构,对于内存与时间产生了额外的消耗。

如果拷贝赋值操作符的右边是右值,即 rhs 是右值,则其实可以通过 swap 指向不同内存的两个指针来实现最终析构的是X原本就要析构的内存,而将 rhs 对应得内存白留下来,并完整“赋值”给了目标 m_pResource :

// [...]
// swap m_pResource and rhs.m_pResource
// [...]  

这就是移动语义,在C++中,这种条件式行为可以通过重载操作符来实现:

X& X::operator=(<mystery type> rhs) {
  // [...]
  // swap this->m_pResource and rhs.m_pResource
  // [...]  
}

区别于传统的拷贝赋值操作符的参数(左值),这里的参数 <mystery type> rhs 实际上是个右值。目标是遇到左值时,调用传统的,遇到右值时,则调用上面这个移动语义的,由此,正式引入了右值引用的概念与一大功能:

void foo(X& x); // lvalue reference overload
void foo(X&& x); // rvalue reference overload

X x;
X foobar();

foo(x); // argument is lvalue: calls foo(X&)
foo(foobar()); // argument is rvalue: calls foo(X&&)

所以,右值引用的天赋

Rvalue references allow a function to branch at compile time (via overload resolution) on the condition "Am I being called on an lvalue or an rvalue?"

编译时期实现左值与右值调用的分开化。绝大部分时候应用于拷贝与赋值构造函数的调用:

X& X::operator=(X const & rhs); // classical implementation
X& X::operator=(X&& rhs) {
  // Move semantics: exchange content between this and rhs
  return *this;
}

强制移动语义

Forcing Move Semantics。由一个swap例子开始:

template<class T>
void swap(T& a, T& b)  { 
  T tmp(a);
  a = b; 
  b = tmp; 
} 

X a, b;
swap(a, b);

这里没有右值,swap函数内的三条语句也没有使用移动语义,但实际上是可以使用这个技巧的。

只要一个变量作为拷贝或赋值构造函数的参数(左边),符合如下任意一个条件:

1. 再也不会被使用。

2. 只会作为被赋值的一方来使用。

就可以使用移动语义。

C++中,通过标准库函数 std::move 来完成这样的功能。

move

move函数的功能,仅仅是将变量转换为右值(没有任何其它操作)。则上面的swap例子可以转换为:

template<class T> 
void swap(T& a, T& b) { 
  T tmp(std::move(a));
  a = std::move(b); 
  b = std::move(tmp);
} 

X a, b;
swap(a, b);

这样,三条语句都使用了移动语义(注意,是使用了,并不是实现了。实现移动语义要通过重载右值引用版本的拷贝或赋值构造函数来操作)。

move的优势

1. 对于那些实现了移动语义的类型,许多标准算法或操作都可以通过使用移动语义来获得显著的性能提升(避免了不必要的资源消耗)。一个很好的例子是就地排序(主要通过swap操作来实现),便可因此获益。

2. STL经常要求特定类型的可拷贝性,比如那些可以作为容器元素的类型。但很多情况下,可移动性就足够了。所以,有了移动语义后,很多原先不能拷贝,只能移动的类型(例如智能指针中的 unique_ptr )如今也可以作为容器元素来使用了

移动语义的副作用

一个变量被赋值,而它之前持有的资源仍然存在于某处,则该资源析构存在不确定性。由此,可能导致一些副作用(例如析构函数内存在释放锁的操作,锁的释放存在不确定性了)。所以,右值引用类型的拷贝赋值操作符的重载都需要显性地展现出所有可能导致的副作用:

X& X::operator=(X&& rhs){
  // Perform a cleanup that takes care of at least those parts of the
  // destructor that have side effects. Be sure to leave the object
  // in a destructible and assignable state.

  // Move semantics: exchange content between this and rhs
  
  return *this;
}

右值引用是右值吗?

Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.

 也就是说,被声明为右值引用的可能是右值,也可能是左值。区分关键在于:它是否有名字

void foo(X&& x) {
  X anotherX = x; // x is lvalue, so calls X(X const & rhs)
}

另一个声明为右值引用,没有名字的右值:

X&& goo();
X x = goo(); // calls X(X&& rhs) because the thing on
             // the right hand side has no name

如此设计的根本原因在于:允许移动语义默认可以被有名字的使用。

X anotherX = x; // x is still in scope!

再回到 std::move 上来看。一个没有名字的右值引用就是右值吗?比如上面的goo()的例子。尽管它所指向的资源已经被移动,但它仍是可访问的。 std::move 的工作原理与此类似,它将对象通过引用的方式传递,而不做任何其它操作,结果是一个右值引用。所以表达式 std::move(x) 被声明为一个右值引用且没有名字,因此它是一个右值。

 std::move “将对象转换为右值,尽管它不是。”实现的思想就是“隐藏它的名字”。

是否有名字

下面用一个例子来说明,是否有名字的重要性:

//Base类,通过重载拷贝构造函数来实现移动语义
Base(Base const & rhs); // non-move semantics
Base(Base&& rhs); // move semantics

//Derived类,继承自Base类,因此要重写移动语义实现
Derived(Derived const & rhs) 
  : Base(rhs) {
  // Derived-specific stuff
}

右值版本的实现,如果没有意识到名字的重要性,可能会出错:

Derived(Derived&& rhs) 
  : Base(rhs) // wrong: rhs is an lvalue
{
  // Derived-specific stuff
}

为什么rhs是左值呢?因为它有名字,所以这里它在初始化列表中调用的就是非移动版本的拷贝构造函数了。正确的写法应该是:

Derived(Derived&& rhs) 
  : Base(std::move(rhs)) // good, calls Base(Base&& rhs)
{
  // Derived-specific stuff
}

移动语义与编译器优化

X foo() {
  X x;
  // perhaps do something to x
  return x;
}

对于上述情况,现代编译器都会进行“返回值优化”。即并非在函数栈内构造一个X对象,然后再把它拷贝出去,而是直接在foo()返回值对应的位置直接构造X对象。因此,下面这种移动语义的使用,反而画蛇添足,不可取:

X foo() {
  X x;
  // perhaps do something to x
  return std::move(x); // making it worse!
}

因此,在使用移动语义时,一定要充分了解并考虑到编译器的实际工作原理。

完美转发

问题

除了移动语义以外,右值引用解决的另一个问题就是完美转发

考虑这样一个工厂函数:

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg arg) { 
  return shared_ptr<T>(new T(arg));
} 

目的是实现完美转发(也就是仿佛工厂函数不存在,而是直接调用的构造函数)。但实际上它的参数传递并不符合要求,尤其是构造函数通过引用传参。于是修改成这样:

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg& arg) { 
  return shared_ptr<T>(new T(arg));
} 

依然不完美,因为工厂函数无法被右值调用

factory<X>(hoo()); // error if hoo returns by value
factory<X>(41); // error

这个问题可以通过提供一个重载来实现,参数以常量左值引用来传递:

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg const & arg) { 
  return shared_ptr<T>(new T(arg));
} 

这样仍然会存在两个问题。

第一个问题,参数不止一个。则该方法需要重载所有的非常量和常量引用参数。这显然不是个好注意。

第二个问题,这个转发封锁了移动语义。因为构造T类对象的参数在factory函数内是一个左值,所以移动语义不可能发生,除非move。

右值引用可以解决上述两个问题,实现完美转发。

解决

引用折叠规则

  • A& & becomes A&
  • A& && becomes A&
  • A&& & becomes A&
  • A&& && becomes A&&

模板参数演绎规则

template<typename T>
void foo(T&&);

Here, the following apply:

  1. When foo is called on an lvalue of type A, then T resolves to A& and hence, by the reference collapsing rules above, the argument type effectively becomes A&.
  2. When foo is called on an rvalue of type A, then T resolves to A, and hence the argument type becomes A&&.

示例

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg&& arg) { 
  return shared_ptr<T>(new T(std::forward<Arg>(arg)));
} 

//where std::forward is defined as follows:
template<class S>
S&& forward(typename remove_reference<S>::type& a) noexcept {
  return static_cast<S&&>(a);
} 

应用一(左值)

X x;
factory<A>(x);

根据演绎规则,工厂模板的参数 Arg 为 X& 。实际创造的factory和forward如下:

shared_ptr<A> factory(X& && arg) { 
  return shared_ptr<A>(new A(std::forward<X&>(arg)));
} 

X& && forward(remove_reference<X&>::type& a) noexcept {
  return static_cast<X& &&>(a);
} 

评估 remove_reference 并使用引用折叠规则后,实际为:

shared_ptr<A> factory(X& arg) { 
  return shared_ptr<A>(new A(std::forward<X&>(arg)));
} 

X& std::forward(X& a)  {
  return static_cast<X&>(a);
} 

这就是左值的完美转发。参数 Arg 通过两层迂回(都是传统左值引用)被传递给了A的构造函数。

应用二(右值)

X foo();
factory<A>(foo());

根据演绎规则,工厂模板的参数 Arg 为 X 。实际创造的factory和forward如下:

shared_ptr<A> factory(X&& arg) { 
  return shared_ptr<A>(new A(std::forward<X>(arg)));
} 

X&& forward(X& a) noexcept {
  return static_cast<X&&>(a);
} 

这就是右值的完美转发。两层迂回都是右值引用,A的构造函数接收到的参数是没有名字的右值引用,亦即右值,所以会按照右值进行调用。forward保留了移动语义的部分。

move的实现

template<class T> 
typename remove_reference<T>::type&&
std::move(T&& a) noexcept {
  typedef typename remove_reference<T>::type&& RvalRef;
  return static_cast<RvalRef>(a);
} 

左值示例

X x;
std::move(x);

根据模板演绎规则,编译器最终实例化:

typename remove_reference<X&>::type&&
std::move(X& && a) noexcept {
  typedef typename remove_reference<X&>::type&& RvalRef;
  return static_cast<RvalRef>(a);
} 

则评估 remove_reference 和引用折叠规则后:

X&& std::move(X& a) noexcept {
  return static_cast<X&&>(a);
} 

整个过程就是:左值 x 被绑定到参数类型的左值引用,再通过这个函数被转换为一个没有名字的右值引用。

右值示例

略过。相比起 std::move(x); , static_cast<X&&>(x); 更简单,但move表达性更好。

右值引用与异常

当为移动语义重载拷贝构造函数和拷贝赋值构造函数时,作者强烈建议完成以下两点:

1. 力求重载不抛出异常。

2. 成功实现第一点后,则确保使用了新的 noexcept 关键字。

具体原因不再赘述(后面还有一点关于隐式移动的案例),欢迎阅读原文。

推荐阅读

https://www.artima.com/articles/a-brief-introduction-to-rvalue-references

https://stackoverflow.com/questions/5481539/what-does-t-double-ampersand-mean-in-c11

posted @ 2021-04-15 13:38  箐茗  阅读(220)  评论(0编辑  收藏  举报