C++ 中的左值和右值

2022-07-23 11:29:39 浏览数 (1)

大家好,又见面了,我是你们的朋友全栈君。

一、前言

一直以来,我都对C 中左值(lvalue)和右值(lvalue)的概念模糊不清。我认为是时候好好理解他们了,因为这些概念随着C 语言的进化变得越来越重要。

二、左值和右值——一个友好的定义

首先,让我们避开那些正式的定义。在C 中,一个左值是指向一个指定内存的东西。另一方面,右值就是不指向任何地方的东西。通常来说,右值是暂时和短命的,而左值则活的很久,因为他们以变量的形式(variable)存在。我们可以将左值看作为容器(container)而将右值看做容器中的事物。如果容器消失了,容器中的事物也就自然就无法存在了。 让我们现在来看一些例子:

代码语言:javascript复制
int x = 666; //ok

在这里,666是一个右值。一个数字(从技术角度来说他是一个字面常量(literal constant))没有指定的内存地址,当然在程序运行时一些临时的寄存器除外。在该例中,666被赋值(assign)给xx是一个变量。一个变量有着具体(specific)的内存位置,所以他是一个左值。C 中声明一个赋值(assignment)需要一个左值作为它的左操作数(left operand):这完全合法。 对于左值x,你可以做像这样的操作:

代码语言:javascript复制
int* y = &x;  //ok

在这里我通过取地址操作符&获取了x的内存地址并且把它放进了y&操作符需要一个左值并且产生了一个右值,这也是另一个完全合法的操作:在赋值操作符的左边我们有一个左值(一个变量),在右边我们使用取地址操作符产生的右值。 然而,我们不能这样写:

代码语言:javascript复制
int y;
666 = y; //error!

可能上面的结论是显而易见的,但是从技术上来说是因为666是一个字面常量也就是一个右值,它没有一个具体的内存位置(memory location),所以我们会把y分配到一个不存在的地方。 下面是GCC给出的变异错误提示:

error: lvalue required as left operand of assignment

赋值的左操作数需要一个左值,这里我们使用了一个右值666。 我们也不能这样做:

代码语言:javascript复制
int* y = &666;//   error~

GCC给出了以下错误提示:

error: lvalue required as unary ‘&’ operand`

&操作符需要一个左值作为操作数,因为只有一个左值才拥有地址。

三、返回左值和右值的函数

我们知道一个赋值的左操作数必须是一个左值,因此下面的这个函数肯定会抛出错误:lvalue required as left operand of assignment

代码语言:javascript复制
int setValue()
{
    return 6;
}

// ... somewhere in main() ...

setValue() = 3; // error!

错误原因很清楚:setValue()返回了一个右值(一个临时值6),他不能作为一个赋值的左操作数。现在,我们看看如果函数返回一个左值,这样的赋值会发生什么变化。看下面的代码片段(snippet):

代码语言:javascript复制
int global = 100;

int& setGlobal()
{
    return global;    
}

// ... somewhere in main() ...

setGlobal() = 400; // OK

该程序可以运行,因为在这里setGlobal()返回一个引用(reference),跟之前的setValue()不同。一个引用是指向一个已经存在的内存位置(global变量)的东西,因此它是一个左值,所以它能被赋值。注意这里的&:它不是取地址操作符,他定义了返回的类型(一个引用)。 可以从函数返回左值看上去有些隐晦,它在你做一些进阶的编程例如实现一些操作符的重载(implementing overload operators)时会很有作用,这些知识会在未来的章节中讲述。

四、左值到右值的转换

一个左值可以被转换(convert)为右值,这完全合法且经常发生。让我们先用 操作符作为一个例子,根据C 的规范(specification),它使用两个右值作为参数并返回一个右值(译者按:可以将操作符理解为一个函数)。 让我们看下面的代码片段:

代码语言:javascript复制
int x = 1;
int y = 3;
int z = x   y;   // ok

等一下,xy是左值,但是加法操作符需要右值作为参数:发生了什么?答案很简单:xy经历了一个隐式(implicit)的左值到右值(lvalue-to-rvalue)的转换。许多其他的操作符也有同样的转换——减法、加法、除法等等。

五、左值引用

相反呢?一个右值可以被转化为左值吗?不可以,它不是技术所限,而是C 编程语言就是那样设计的。 在C 中,当你做这样的事:

代码语言:javascript复制
int y = 10;
int& yref = y;
yref  ;        // y is now 11

这里将yref声明为类型int&:一个对y的引用,它被称作左值引用(lvalue reference)。现在你可以开心地通过该引用改变y的值了。 我们知道,一个引用必须只想一个具体的内存位置中的一个已经存在的对象,即一个左值。这里y确实存在,所以代码运行完美。 现在,如果我缩短整个过程,尝试将10直接赋值给我的引用,并且没有任何对象持有该引用,将会发生什么?

代码语言:javascript复制
int& yref = 10;  // will it work?

在右边我们有一个临时值,一个需要被存储在一个左值中的右值。在左边我们有一个引用(一个左值),他应该指向一个已经存在的对象。但是10 是一个数字常量(numeric constant),也就是一个左值,将它赋给引用与引用所表述的精神冲突。 如果你仔细想想,那就是被禁止的从右值到左值的转换。一个volitile的数字常量(右值)如果想要被引用,需要先变成一个左值。如果那被允许,你就可以通过它的引用来改变数字常量的值。相当没有意义,不是吗?更重要的是,一旦这些值不再存在这些引用该指向哪里呢? 下面的代码片段同样会发生错误,原因跟刚才的一样:

代码语言:javascript复制
void fnc(int& x)
{
}

int main()
{
    fnc(10);  // Nope!
    // This works instead:
    // int x = 10;
    // fnc(x);
}

我将一个临时值10传入了一个需要引用作为参数的函数中,产生了将右值转换为左值的错误。这里有一个解决方法(workaround),创造一个临时的变量来存储右值,然后将变量传入函数中(就像注释中写的那样)。将一个数字传入一个函数确实不太方便。

六、常量左值引用

先看看GCC对于之前两个代码片段给出的错误提示:

error: invalid initialization of non-const reference of type ‘int&’ from an rvalue of type ‘int’

GCC认为引用不是const的,即一个常量。根据C 规范,你可以将一个const的左值绑定到一个右值上,所以下面的代码可以成功运行:

代码语言:javascript复制
const int& ref = 10;  // OK!

当然,下面的也是:

代码语言:javascript复制
void fnc(const int& x)
{
}

int main()
{
    fnc(10);  // OK!
}

背后的道理是相当直接的,字面常量10volatile的并且会很快失效(expire),所以给他一个引用是没什么意义的。如果我们让引用本身变成常量引用,那样的话该引用指向的值就不能被改变了。现在右值被修改的问题被很好地解决了。同样,这不是一个技术限制,而是C 人员为避免愚蠢麻烦所作的选择。 应用:C 中经常通过常量引用来将值传入函数中,这避免了不必要的临时对象的创建和拷贝。 编译器会为你创建一个隐藏的变量(即一个左值)来存储初始的字面常量,然后将隐藏的变量绑定到你的引用上去。那跟我之前的一组代码片段中手动完成的是一码事,例如:

代码语言:javascript复制
// the following...
const int& ref = 10;

// ... would translate to:
int __internal_unique_name = 10;
const int& ref = __internal_unique_name;

现在你的引用指向了真实存在的事物(知道它走出作用域外)并且你可以正常使用它,除了不能改变他指向的值。

代码语言:javascript复制
const int& ref = 10;
std::cout << ref << "n";   // OK!
std::cout <<   ref << "n"; // error: increment of read-only reference ‘ref’

七、C 11中的右值引用

右值引用及其相关的move语义是C 11新引入的最强大的特性之一。前文说到,左值(非const)可以被修改(赋值),但右值不能。但C 11引入的右值引用特性,打破了这个限制,允许我们获取右值的引用,并修改之。让我们先看点代码: 定义一个类Intvec及其赋值操作符重载函数如下:

代码语言:javascript复制
class Intvec
{
public:
    ...
    Intvec& operator=(const Intvec& other)
    {
        log("copy assignment operator");
        Intvec tmp(other);  //构造一个临时对象,因为other为const,不能被修改
        std::swap(m_size, tmp.m_size);
        std::swap(m_data, tmp.m_data);  
        //跟临时对象交换值,临时对象晰构时会delete [] m_data
        return *this;
    }
private:
    size_t m_size;  
    int* m_data;     //存放int数组,构造时动态分配
};  

代码要点:

  1. 代码使用了copy-swap策略,即先分配资源再更改自身状态,这样可以保证当资源分配失败的时候,自身能够维持原先状态,《高效C 》有条规则描述这个主题。所以先根据other拷贝构造一个临时对象tmp, 然后与tmp进行swap,m_data交换给了tmp之后,也会随着tmp的晰构而被释放。
  2. 之所以把other声明为const,有两个理由,其一是赋值操作不应该更改other,其二是可以传入一个右值。其实这样的声明随处可见。

假设现有类型为Intvec的对象v,用一个新对象给它赋值:

代码语言:javascript复制
v = Intvec(33);

这句代码合法,它构造一个临时对象,为右值,传入到Intvec的赋值运算符重载函数中。这个代码是可以工作,而且通常情况下都比较高效。但是如果Intvec里包含某些m_handle成员,创建和释放m_handle比较昂贵,那么拷贝构造越少越好。这种情况,我们设想一下,如果v能跟Intvec(33)临时对象直接进行内部数据交换,而不需要在重载函数里使用Intvec tmp(other);构造一个新对象出来swap,那该有多好! 如你所料,C 11引入的“右值引用”和“move语义”就可以实现这个目标,新的语法很简单,我们重载一个新的赋值操作运算符函数:

代码语言:javascript复制
Intvec& operator=(Intvec&& other)
{
    log("move assignment operator");
    std::swap(m_size, other.m_size);
    std::swap(m_data, other.m_data);
    return *this;
}

对于v = Intvec(33);这种写法就会调用此版本的重载函数(即传入一个右值)。 &&语法声明右值引用,表示一个指向右值的引用,通过这个引用,可以修改右值。

参考链接:https://www.jianshu.com/p/94b0221f64a5

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/126251.html原文链接:https://javaforall.cn

0 人点赞