ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

c/c++中的左值右值详解

c/c++中的左值右值详解

前言


「左值」「右值」是 C++ 里被提到最多、被讲清最少的两个词。很多教程用「等号左边是左值,右边是右值」来解释,这个说法在 C 里勉强够用,在 C++11 之后完全不够:等号左边也可以是右值引用,函数实参、返回值、成员访问都各有自己的值类别。真正决定一个表达式能做什么的,是它的值类别(value category):能不能取地址、能不能被移动、绑定到哪种引用、decltype推导出什么类型。


C++17 把值类别整理成三个基础类别和两个组合类别:左值(lvalue)、将亡值(xvalue)、纯右值(prvalue);左值与将亡值合称泛左值(glvalue),将亡值与纯右值合称右值(rvalue)。理解这套分类,才能解释为什么std::move不移动任何东西、为什么具名的右值引用在下一行就变成左值、为什么decltype(x)与decltype((x))结果不同。


最常见的误解有三个。第一,认为std::move(x)会执行移动——它只做一次类型转换,把表达式变成将亡值,真正的移动发生在被调用的移动构造或移动赋值里。第二,认为声明为T&&的变量就是右值——变量名本身永远是左值,哪怕它的类型是右值引用。第三,认为右值引用一定能延长生命周期——绑定到临时对象的右值引用确实会延长,但从函数返回右值引用就是悬垂。


本文按「值类别、引用绑定、move 与 forward、完整示例」展开,最后列出常见坑点。示例以 C++17 为基准。


一、三个基础类别


左值(lvalue)有身份(identity,能取地址、能比较地址),且不能被隐式移动。变量名、解引用表达式*p、数组下标a[i](内置类型)、前置自增的结果,都是左值。


纯右值(prvalue)没有身份,用于初始化对象或参与运算。字面量42、a + b的结果、按值返回的函数调用结果,都是纯右值。纯右值不能取地址,&42不合法。


将亡值(xvalue)有身份,但可以被移动。它通常由std::move(x)、static_cast<T&&>(x)产生,或者是对将亡值的成员访问。将亡值的存在,是为了让「我确定不再需要这个对象了」这件事能在类型系统里表达出来。


组合关系是:glvalue = lvalue + xvalue;rvalue = xvalue + prvalue。判断一个表达式,先问「有没有身份」,再问「能不能被移动」,就能定位它的类别。


decltype对值类别很敏感。对未加括号的标识符,decltype(x)给出声明时的类型;对加了括号的表达式,decltype((x))按值类别给引用:左值给T&,将亡值给T&&,纯右值给T。这也是模板库中判断「表达式类别」的常用手段。


二、引用绑定规则


左值引用T&只能绑定左值;const T&可以绑定左值,也可以绑定右值,绑定右值时临时对象的生命周期延长到该引用的生命周期结束。右值引用T&&只能绑定右值(将亡值或纯右值);它可以绑定临时对象并延长其生命周期。


函数重载时,编译器按实参的值类别选择:f(T&)接受左值,f(const T&)接受左值与右值,f(T&&)接受右值。同时提供f(const T&)与f(T&&)时,右值优先选后者,这就是「移动语义」在接口层的体现。


模板里的T&&是另一回事。当T需要推导时(函数模板参数、auto&&),T&&是转发引用(forwarding reference),它能绑定左值也能绑定右值,具体类型由实参决定:传左值时T推导为U&,经过引用折叠得到U&;传右值时T推导为U,得到U&&。引用折叠规则是:& + & = &,& + && = &,&& + & = &,&& + && = &&。只有「唯一一个&」的场合才得到左值引用。


注意,类模板成员函数里的T&&不一定是转发引用,因为T在类实例化时已经确定,不再参与推导。判断标准是「是否发生类型推导」,不是「长得像不像T&&」。


三、std::move与std::forward


std::move(x)的定义等价于static_cast<std::remove_reference_t<decltype(x)>&&>(x):它把任意表达式转成将亡值,本身不读取、不复制、不释放任何资源。真正「搬走」资源的是接收这个将亡值的移动构造函数或移动赋值运算符。若类型没有移动操作,重载解析会退回拷贝,此时std::move只是让拷贝发生,没有任何收益。


对const对象调用std::move,得到的是const T&&。const T&&不能绑定到T&&形参,只能绑定到const T&,于是调用的是拷贝构造。这是「对 const 对象 move 会退化成拷贝」的原因。


std::forward<T>(x)用于转发引用:当T是左值引用类型时,它返回左值;当T是普通类型时,它返回右值。它的作用是「保持调用方传入的值类别」,而不是「无条件转成右值」。在泛型包装函数里,参数要一路std::forward下去,否则具名的T&&参数在函数体内是左值,右值语义在这里断掉。


被移动后的对象处于「有效但未指定」(valid but unspecified)状态:可以析构、可以重新赋值,但不要读取它的值,也不要假设它为空。


四、完整示例


下面的示例演示值类别的判定、重载选择、转发引用与decltype的差异。


// 适用于 C++17
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>

void take(const std::string& s) {
std::cout << "const lvalue ref: " << s << '\n';
}

void take(std::string&& s) {
std::cout << "rvalue ref: " << s << '\n';
}

// 转发引用:保持调用方的值类别
template <typename T>
void relay(T&& value) {
take(std::forward<T>(value));
}

int main() {
std::string a = "hello";
take(a); // 左值,选 const&
take(std::string("tmp")); // 纯右值,选 &&
take(std::move(a)); // 将亡值,选 &&

const std::string c = "const";
take(std::move(c)); // const T&& 只能绑 const&,退化

std::string b = "world";
relay(b); // T = std::string&,转发为左值
relay(std::string("rvalue")); // T = std::string,转发为右值
relay(std::move(b)); // T = std::string,转发为右值;b 之后不再使用

int n = 0;
int& r = n;
static_assert(std::is_same_v<decltype(n), int>);
static_assert(std::is_same_v<decltype((n)), int&>);
static_assert(std::is_same_v<decltype(r), int&>);
static_assert(std::is_same_v<decltype(std::move(n)), int&&>);
static_assert(std::is_same_v<decltype(n + 1), int>);

std::cout << n << ' ' << r << '\n';
return 0;
}

按标准语义推演:take(a)选const&;take(std::string("tmp"))与take(std::move(a))选&&;take(std::move(c))中实参类型是const std::string&&,不能绑定到std::string&&,只能绑定const std::string&,所以仍走左值重载;relay(b)中T为std::string&,转发后是左值;两个右值实参转发后走&&重载;decltype的四条断言按值类别规则都成立。以上只按语言规则推导,未经编译运行核对。


五、值类别对照




类别有身份可被移动能否取地址典型表达式



左值 lvalue是否(需显式 move)能变量名、*p、++i

将亡值 xvalue是是能std::move(x)、static_cast<T&&>(x)

纯右值 prvalue否是不能42、a + b、按值返回的调用

泛左值 glvalue是视具体类别能左值与将亡值

右值 rvalue否或视情况是视具体类别将亡值与纯右值



常见坑点


一、以为std::move会移动对象


❌std::move(v);单独一行,以为v的资源已经被搬走。


✅std::move只做类型转换;必须把它用在移动构造或移动赋值的实参位置,例如T t = std::move(v);。


二、对const对象使用std::move期望移动


❌const std::string s = "x"; std::string t = std::move(s);实际调用拷贝构造。


✅ 需要移动就不要声明为const;const T&&无法绑定T&&形参。


三、具名右值引用在函数体内当右值用


❌void f(T&& x) { g(x); }——x是左值,调用的是g(T&)或g(const T&)。


✅ 用g(std::forward<T>(x))转发,或者明确g(std::move(x))表示不再使用。


四、移动后继续读取对象的值


❌std::string t = std::move(s); std::cout << s;读取处于「有效但未指定」状态的对象。


✅ 移动后只允许析构或重新赋值;需要保留内容时先拷贝。


五、返回局部变量时画蛇添足地std::move


❌return std::move(local);阻止了返回值优化,强制一次移动构造。


✅ 直接return local;,编译器可以省略拷贝/移动(C++17 对纯右值返回有强制省略的情形)。


六、把类模板成员里的T&&当转发引用


❌ 在template <typename T> class Box { void set(T&& v); };中认为T&&能自动转发。


✅T在类实例化时已确定,不参与推导;需要转发时把成员函数本身写成模板,或按值/按const&接收。


七、返回右值引用导致悬垂


❌T&& f() { T local; return std::move(local); }返回局部对象的右值引用,调用方拿到即悬垂。


✅ 按值返回;右值引用形参/返回值只用于「引用已有对象」的场景。


八、对字符串字面量使用std::move


❌std::move("abc")得到const char(&&)[4],既不能移动也没有意义,还容易让重载解析出意外结果。


✅ 字面量直接传递;需要std::string时显式构造。


总结




问题答案



左值和右值的区别是什么左值有身份且不可隐式移动;右值可被移动,其中将亡值有身份、纯右值没有

std::move做了什么把表达式转成将亡值,不移动任何资源

为什么T&&参数在函数内是左值具名变量永远是左值,类型不影响这一点

什么时候用std::forward转发引用参数需要保持调用方的值类别时

为什么对const对象 move 会拷贝const T&&只能绑定const T&

decltype(x)与decltype((x))为何不同前者取声明类型,后者按表达式的值类别给引用



左值与右值的核心只有一句话:值类别描述的是「表达式」,不是「变量」;类型是T&&的变量名,作为表达式仍然是左值。把这句话记住,再理解std::move是转换、std::forward是保持,移动语义的绝大部分困惑就会消失。




返回列表