C/C++编程:左值、右值、亡值

1059 篇文章 285 订阅

值类别

基本类别

  • 左值 (lvalue):是非亡值的泛左值;
  • 纯右值:
  • 将亡值

混合类别

  • 泛左值(泛左值表达式包括左值、亡值。)
  • 右值(右值表达式包括纯右值、亡值)

左值&右值

  • 左值和右值的一个最根本的区别是:左值可以使用&符号, 右值是不可以的
    • lvalue(locator value)代表一个在内存中占有确定位置的对象(换句话说就是有一个地址)。
    • rvalue通过排他性来定义,每个表达式不是lvalue就是rvalue。
  • 通常来说,右值是暂时和短命的,而左值则活的很久,因为他们以变量的形式(variable)存在。
    • 左值是表达式(不一定是赋值表达式)后依旧存在的对象
    • 右值是表达式结束后不再存在的临时对象
  • 我们可以将左值看作为容器(container)而将右值看做容器中的事物。如果容器消失了,容器中的事物也就自然就无法存在了。
  • 非左值分为两种情况:
    • 根本没有存储空间,比如各种常数。可能在编译时直接优化成指令中的常数而没有存储空间,显然其内容也无法修改
  • 某个无名存储的表达式

值得一提的是,左值的英文简写为“lvalue”,右值的英文简写为“rvalue”。很多人认为它们分别是"left value"、“right value” 的缩写,其实不然。lvalue 是“loactor value”的缩写,可意为存储在内存中、有明确存储地址(可寻址)的数据,而 rvalue 译为 “read value”,指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

一些例子:

int x = 666; //ok
  • 在这里,666是一个右值。一个数字(从技术角度来说他是一个字面常量(literal constant))没有指定的内存地址,当然在程序运行时一些临时的寄存器除外
  • 在该例中,666被赋值(assign)给x,x是一个变量。一个变量有着具体(specific)的内存位置,所以他是一个左值。

C++中声明一个赋值(assignment)需要一个左值作为它的左操作数(left operand);这完全合法。

对于左值x,你可以做像这样的操作:

int* y = &x; //ok

在这里我通过取地址操作符&获取了x的内存地址并且把它放进了y。 &操作符需要一个左值并且产生了一个右值,这也是另一个完全合法的操作:

  • 在赋值操作符的左边我们有一个左值(一个变量),
  • 在右边我们使用取地址操作符产生的右值。

然而,我们不能这样写:

nt y;
666 = y; //error!

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

error: lvalue required as left operand of assignment

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

int* y = &666;// error~

GCC给出了以下错误提示:

error: lvalue required as unary '&' operand`

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

#include <iostream>

//右值引用
void main()
{
	int num = 1;
	int data = 0;
	std::cout << (void *)&data << std::endl;

	data = num + 1; //num + 1是在寄存器里面的操作,没有在内存里面操作,因此它是右值
	data = num + 2;
	data = num + 3;

	std::cin.get();
}
  • 右值没有内存实体,是在寄存器里面操作

返回左值和右值的函数

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

int setValue()
{
return 6;
}

// … somewhere in main() …

setValue() = 3; // error!

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

int global = 100;

int& setGlobal()
{
return global;
}

// … somewhere in main() …

setGlobal() = 400; // OK

该程序可以运行,因为在这里setGlobal()返回一个引用(reference),跟之前的setValue()不同。一个引用是指向一个已经存在的内存位置(global变量)的东西,因此它是一个左值,所以它能被赋值。注意这里的&:它不是取地址操作符,他定义了返回的类型(一个引用)。

左值到右值的转换

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

int x = 1;
int y = 3;
int z = x + y; // ok

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

左值到右值的转换

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

int x = 1;
int y = 3;
int z = x + y; // ok

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

左值引用

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

int y = 10;
int& yref = y;
yref++; // y is now 11

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

int& yref = 10; // will it work?

在右边我们有一个临时值,一个需要被存储在一个左值中的右值。在左边我们有一个引用(一个左值),他应该指向一个已经存在的对象。但是10 是一个数字常量(numeric constant),也就是一个左值,将它赋给引用与引用所表述的精神冲突。

如果你仔细想想,那就是被禁止的从右值到左值的转换。一个volitile的数字常量(右值)如果想要被引用,需要先变成一个左值。如果那被允许,你就可以通过它的引用来改变数字常量的值。相当没有意义,不是吗?更重要的是,一旦这些值不再存在这些引用该指向哪里呢?
下面的代码片段同样会发生错误,原因跟刚才的一样:

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的左值绑定到一个右值上,所以下面的代码可以成功运行:

const int& ref = 10; // OK!

当然,下面的也是:

void fnc(const int& x)
{
}

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

背后的道理是相当直接的,字面常量10是volatile的并且会很快失效(expire),所以给他一个引用是没什么意义的。如果我们让引用本身变成常量引用,那样的话该引用指向的值就不能被改变了。现在右值被修改的问题被很好地解决了。同样,这不是一个技术限制,而是C ++人员为避免愚蠢麻烦所作的选择。

应用:

  • C++中经常通过常量引用来将值传入函数中,这避免了不必要的临时对象的创建和拷贝。
  • 编译器会为你创建一个隐藏的变量(即一个左值)来存储初始的字面常量,然后将隐藏的变量绑定到你的引用上去。那跟我之前的一组代码片段中手动完成的是一码事,例如:
// the following…
const int& ref = 10;

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

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

const int& ref = 10;
std::cout << ref << “\n”; // OK!
std::cout << ++ref << “\n”; // error: increment of read-only reference ‘ref’

将亡值

其实上一节对于左值右值的描述,在我们编写绝大多数代码的场景下并没有什么影响。而C++11为了引入强大的右值引用,将右值的概念进行了进一步的划分:分为:纯右值(pure rvalue)、将亡值(eXpiring Value);而将亡值是C++11新引入的概念,它依托于右值。

  • 纯右值用于辨别临时变量和一些不跟对象管理的值。
    • 非引用返回的函数返回的临时变量值
    • 运算表达式产生的临时对象,比如1+2产生的临时变量值
    • 不跟对象关联的原始字面量,比如2,true,‘c’
    • 类型转换函数的返回值
    • lambda表达式等等

需要注意的是,字符串字面量只有在类中才是右值,当其位于普通函数中是左值。比如:

class Foo{
	const char* && right = "this is a rvalue"; // 右值
public:
	void bar(){
		right = "still rvalue"; // 右值
	}
};

int main(){
	const char *const &left = "this is an lvalue“; // 左值
}
  • 将亡值,是C++11为了引入右值引用而提出的概念(因此传统C++中,纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值, 比如:
    • 返回右值引用t&&的函数返回值
    • std::move的返回值
    • 转换为T&&的类型转换函数的返回值

我们来看一个例子:

std::vector<int>foo(){
	std::vector<int> temp = {1, 2, 3, 4};
	return temp;
}

std::vector<int> v = foo();

在这样的代码中,就传统的理解而言,函数foo的返回值temp在内部创建然后被赋值给v,然而v获得这个对象时,会将整个temp拷贝一份,然后把temp销毁。如果这个temp非常大,这将造成大量额外的开销(这也是传统C++一直被诟病的问题)。在最后一行中,v是左值,foor()返回的值就是右值(也就是纯右值),但是,v可以被别的变量俘获到,而foo()尝试的那个返回值作为一个临时值,一旦被v复制后,将立即被销毁,无法获取,也不能修改。而将亡值就定义了这一行为:临时的值能够被识别、同时也能够被移动

从C++11起,编译器为我们做了一些工作,此处的temp会被进行此隐式右值转换,等价于static_cast<std::vector<int>&&>(temp),进而此处的v会将foo局部返回的值进行移动

另:在C++之中,使用左值去初始化对象或者为对象赋值时,会调用拷贝构造函数或者赋值构造函数。而使用一个右值来初始化或者赋值时,会调用移动构造函数或者移动赋值运算符来移动资源,从而避免拷贝,提高效率在确保其他变量不再被使用、或即将被销毁时,来延长变量值的生命期。而实际上该右值会马上被销毁,所以称之为:将亡值。

我们来看下面这段代码:

这是我们简单定义的Time类,在类中我们定义了拷贝构造函数和移动构造函数:

class Time {


public:
    int* hour;
    int* minute;
    int* second;

    Time(int h, int m, int s) {
        hour = new int(h);
        minute = new int(m);
        second = new int(s);
    }

    Time(const Time& t) {
        cout << "copy" << endl;
        hour = new int(*t.hour);
        minute = new int(*t.minute);
        second = new int(*t.second);
    }

    Time(Time&& t) noexcept:hour(t.hour),minute(t.minute),second(t.second) {
        t.hour = nullptr;
        t.minute = nullptr;
        t.second = nullptr;
        cout << "move" << endl;
    }

    ~Time() {
        cout << "call ~Time()" << endl;
        delete hour;
        delete minute;
        delete second;
    }

};

接下来我们执行下面的代码:

int main()
{
    Time test(10,25,12);
    Time test2(test);
    return 0;
}

执行结果:

 copy
  call ~Time()
  call ~Time()

由上述代码我们看到test2对象调用了拷贝构造函数来构造了新的对象,这个过程显然是更占用内存的。而接下来,我们尝试利用move函数将test强行转化为将亡值,来避免内存重新分配的过程。但是之后我们也无法再访问test对象的内容了,因为都在移动构造函数之中置为了空指针。

int main()
{
    Time test(10,25,12);
    Time test2(move(test));
    return 0;
}

执行结果:

   move
     call ~Time()
     call ~Time()

通过这样的方式来减少不必要的内存操作。但是之后我们也无法再访问test对象的内容了,因为都在移动构造函数之中置为了空指针。将亡值通过移动构造函数”借尸还魂“,通过test2变量延续了自己的生命周期。

等等等等

太他喵难记了,所以在C++11之中,可以标准库中添加的模板类is_lvalue_reference来判断表达式是否为左值,is_rvalue_reference来判断是否为右值。

  • 7
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值