欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

理解C和C++中的左值和右值

程序员文章站 2022-03-22 21:37:28
...

翻译至https://eli.thegreenplace.net/2011/12/15/understanding-lvalues-and-rvalues-in-c-and-c/
C/C++编程中不是经常出现术语(左值)和rvalue(右值),但是一旦出现,它们的语意就不是特别清晰。最经常看到它们的地方是在编译错误和警告信息中。比如,用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

是的,这段代码不是合法的并且不是你想写的,但是那个错误信息提到了lvalue,一个通常在C/C++教程中不能找到的术语。另一个例子就是用g++编译下面的代码:

int& foo()
{
    return 2;
}

现在那个错误为:

testcpp.cpp: Infunction 'int& foo()':
testcpp.cpp:5:12: error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'

再次,那个错误信息提到了难以理解的rvalue。那么在C/C++中lvalue和rvalue意味着什么?这是我打算 在这篇文字中探讨的。

一个简单定义

这个部分先给出lvalue和rvalue的一个简单定义。文章下面将会详细描述。
lvalue(locator value)代表一个在内存中占有确定位置的对象(换句话说就是有一个地址)。
rvalue通过排他性来定义,每个表达式不是lvalue就是rvalue。因此从上面的lvalue的定义,rvalue是在不在内存中占有确定位置的表达式。

基本例子

上面的术语定义可能不是特别清楚,所以马上看一些简单例子是重要的。
假设我们定义了一个整形变量并且给它赋值:

int var;
var = 4;

赋值运算符要求一个lvalue作为它的左操作数,当然var是一个左值,因为它是一个占确定内存空间的对象。另一方面,下面的代码是无效的:

4 = var;        //ERROR!
(var + 10) = 4; //ERROR!

常量4和表达式var+1都不是lvalue(它们是rvalue)。它们不是lvalue,因为都是表达式的临时结果,没有确定的内存空间(换句话说,它们只是计算的周期驻留在临时的寄存器中)。因此给它们赋值没有语意-这里没有地方给它们赋值。
因此现在应该清楚了第一个代码片段的错误信息。foo返回一个临时的rvalue。尝试给它赋值,foo()=2,是一个错误;编译器期待在赋值运算符的左部分看到一个lvalue。
不是所有的对函数调用结果赋值都是无效的。比如,C++的引用(reference)让这成为可能:

int globalvar = 20;

int& foo()
{
    return globalvar;
}

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

这里foo返回一个引用,这是一个左值,所以它可以被赋值。实际上,C++从函数中返回左值的能力对于实现一些重载运算符时很重要的。一个普遍的例子是在类中为实现某种查找访问而重载中括号运算符 []。std::map可以这样做。

std::map<int, float> mymap;
mymap[10]=5.6;

给 mymap[10] 赋值是合法的因为非const的重载运算符 std::map::operator[] 返回一个可以被赋值的引用。

可修改的左值

开始在C语言中左值定义,它字面上意味着“合适作为赋值的左边部分”。然而,之后C标准中添加了const关键字后,这个定义不得不重新定义。毕竟:

const int a = 10; //‘a’是一个左值
a = 10;           //但是它不能被赋值

因此需要更深层次的重定义。不是所有的左值都能被赋值。这些可以称为可修改的左值。正式的,C99标准定义可修改左值为:

[…] 一个左值没有数组类型,没有不完全类型,没有const修饰的类型,并且如果它是结构体或联合体,则没有任何const修饰的成员(包含,递归包含,任何成员元素的集合)。

左值和右值的转换

通常来说,语言构造一个对象的值要求右值作为它的参数。例如,二元加运算符 ‘+’ 要求两个右值作为它的参数并且返回一个右值:

int a = 1;     //a是一个左值
int b = 2;     //b是一个左值
int c = a + b; //+需要右值,所以a和b都转换成右值,并且返回一个右值

从先前分析可以看到,ab都是左值。因此,在代码第三行,它们经历了一次从左值到右值的转换。所以的左值不能是数组,函数或不完全类型都可以转换成右值。
另一个方向的转换呢?右值可以转换成左值吗?当然不能!根据它的定义这将违反左值的语义[1]。
当然,这并不意味着左值不能通过更加显式的方法产生至右值。例如,一元运算符‘*’(解引用)拿一个右值作为参数而产生一个左值作为结果。考虑下面有效的代码:

int arr[] = {1, 2};
int* p = &arr[0];
*(p + 1) = 10;    //对的:p+1是一个右值,但是*(p+1)是一个左值

相反的,一元取地址符 ‘&’ 拿一个左值作为参数并且生成一个右值:

int var = 10;
int* bad_addr = &(var + 1); //错误:‘&’运算符要求一个左值
int* addr = &var;           //正确:var是左值
&var = 40;                  //错误:赋值运算符的左操作数要求一个左值

‘&’ 符号在C++中扮演了另一个重要角色-它允许定义应用类型。这被称为“左值引用”。非const左值引用不能被赋右值,因为这将要求一个无效的右值到左值的转换:

std::string& sref = std::string(); //错误:无效的初始化,用一个右值类型‘std::string’初始化非const引用类型‘std::string&

常量左值引用可以被赋右值。因为它们是常量,不能通过引用被修改,因此修改一个右值没问题。这使得C++中接受常量引用作为函数形参成为可能,这避免了一些不必要的临时对象的拷贝和构造。

CV修饰的右值

如果我们仔细地读了C++标准中讨论左值到右值的转换问题[2],我们注意到它这样说的:

一个非函数,非数组类型的左值(3.10)T可以被转换成一个右值。[…]如果T是一个非类类型,那么转换成的右值类型是T的非CV修饰版本。否则,那个右值类型是T。

什么是“cv-unqualified”的东西?CV-qualifier是一个被用来描述const和volatile类型修饰符的术语。
C++标准的3.9.3部分:

每个非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();  //调用foo
    cbar().foo(); //调用foo const
}

main 函数的第二个调用实际上调用的是 Afoo () const 方法,因为 cbar 返回的是 const A 类型,这是与 A 不同的。这正是上个引用中最后一句话的意思。也可以注意到 cbar 返回的是一个右值。因此这也是一个 cv修饰的右值的例子。

右值引用(C++11)

右值引用和相关的移动语义是C++11标准中引入的最强大的特性之一。对这个特性更广泛的讨论超过了这篇小文章的范畴[3],但是我任然想提供一些简单的例子,因为我认为这是一个合适的地方去证明理解左值和右值对理解重要的语言概念有帮助。
我已经在文章中花了大部分去解释左值和右值主要的区别之一是左值可以被修改,而右值不能。好的,C++11中对于这个区别添加了一个关键的转换,通在一些特殊的情况允许我们去定义右值得引用然后修改它。
作为一个例子,考虑下面一个简单的动态 “整数vector” 实现。这里我只展示相关的方法:

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;
};

因此,我们定义了通常的构造器,析构器,拷贝构造器和拷贝赋值运算符[4],所有这些都用一个打印输出函数让我们知道它们实际上是在什么时候被调用。
我们运行一些简单代码,将 v1 的内容 拷贝到 v2

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...

的确是这样-这显示了 operator= 内部执行过程。但是假设我们赋一些右值给 v2

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

虽然这里我只是赋一个刚刚构造的vector,但是这只是真是证明一个非常普遍的例子,一些临时的右值被构造然后被赋值给 v2(比如,这可能发生在函数中返回一个vector)。现在的输出是:

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

哇,这看起来有好多工作。尤其是,它有一对额外的构造/析构调用。不幸的是,这是个额外工作,没有任何用,因为在拷贝赋值运算符的内部,另一个临时拷贝的对象在被创建和析构。
还好,没问题。C++11给我们右值引用可以实现“移动语义”,特别是一个“移动赋值运算符”[5]。我们来添加另一个 operator=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)创建的构造和析构调用任然需要,但是另一个在赋值运算符内部的临时对象不再需要。移动运算符只是简单的切换右值的内部缓冲区为自己的,分配它所以右值析构器将会释放我们对象自己不再使用的缓冲区。很紧凑。
我要再次提醒的是这个例子只是移动语义和右值引用的冰山一角。你可以料到,这是个复杂的主题,有许多的案例需要考虑。我这里只是证明C++中左值和右值不同的一个有趣应用。编译器显然知道一些对象什么时候是右值,并且能够在编译期分配调用正确的构造器。

总结

一个人可以写很多C++代码而不考虑右值和左值的问题,在某些错误信息中将它们视为怪异的编译器术语而不考虑。然而,这篇文章的目的是展示和更好的理解这个主题以此来更深层次的理解C++某些代码结构,并且使得语言专家们之间的C++标准和讨论更加明了。
并且,在新的C++标准中这些主题成为更加重要,因为C++11引入了右值引用和移动语义。为了真正的理解这些语言新特效,对右值和左值的牢固理解是关键。


[1] 右值可以显式地赋给左值。隐式转换的缺乏意味着右值不能再左值期待的地方使用。
[2] 这在C++11标准草案的4.1部分。
[3] 你可以简单的通过谷歌“rvalue references”找到很多有用的资料。一些我个人认为有用的资源:这个这个尤其是这个
[4] 从异常安全的角度,这是一个标准的拷贝赋值运算符实现。通过使用拷贝构造器和不抛出异常的 std::swap,它确保如果异常抛出,未初始化的内存没有中间状态发生。
[5] 因此现在你应该知道为什么我一直指出我的 operator= 为拷贝赋值运算符。在C++11中这个区别很重要。

相关标签: 右值和左值