⚡C++ 语言基础

左值、右值与移动语义

交互动画 概念演示
1 / ?

难度:⭐⭐ | 高频指数:🔥🔥🔥

一句话搞懂

左值 = 有名字、能取地址、还活着的对象;右值 = 临时的、马上要没的值;移动 = 把资源”偷”过来而不是复制一份。


核心概念

左值 vs 右值

int x = 42;          // x 是左值(有名字,能 &x)
int y = x + 1;       // x + 1 是右值(临时结果,没法 &(x+1))
std::string("abc");  // 临时对象,右值

判断方法很简单:能取地址的就是左值,不能的就是右值。

将亡值(xvalue)

就是”我标记了这个对象可以被偷资源”:

std::string s = "hello";
std::string t = std::move(s);  // std::move(s) 是将亡值
// s 的资源被 t 偷走了

三者关系

        表达式
       /      \
   glvalue    rvalue
   /    \     /    \
lvalue  xvalue  prvalue
(左值)  (将亡值)  (纯右值)

面试记住:左值有身份能持续用,纯右值是临时结果,将亡值是”我允许你偷我资源”。


右值引用(T&&)到底怎么用

很多人看到 T&& 就懵,因为不知道它到底用在哪。其实右值引用在工程里只有三个用途,记住这三个就够了:

用途一:写移动构造 / 移动赋值(最核心)

右值引用存在的根本原因就是为了实现移动语义。没有 T&&,编译器无法区分”调用方还要用这个对象”和”调用方不要了,你可以偷资源”。

// 拷贝构造:参数是 const T&,表示"我只是看看,不动你的"
Buffer(const Buffer& other);

// 移动构造:参数是 T&&,表示"你不要了,我把资源偷走"
Buffer(Buffer&& other) noexcept;

结合 CrossShellNext:

class SshSession {
public:
    // 移动构造:把 other 的 SSH 句柄偷过来
    SshSession(SshSession&& other) noexcept
        : handle_(other.handle_), host_(std::move(other.host_)) {
        other.handle_ = nullptr;  // 源对象置空
    }

    // 移动赋值:先释放自己的,再偷对方的
    SshSession& operator=(SshSession&& other) noexcept {
        if (this != &other) {
            cleanup();  // 释放自己原来的资源
            handle_ = other.handle_;
            host_ = std::move(other.host_);
            other.handle_ = nullptr;
        }
        return *this;
    }
};

用途二:函数参数,表达”我要接管你的资源”

当函数需要消费调用方的对象时,参数写 T&&

// SessionManager 要接管 session 的所有权
void SessionManager::addSession(int id, std::unique_ptr<Session>&& session) {
    sessions_[id] = std::move(session);  // 偷走,调用方不再拥有
}

// 调用方:
auto sess = std::make_unique<SshSession>("host", 22);
manager.addSession(1, std::move(sess));  // 明确放弃所有权
// sess 现在是 nullptr

对比 const T& 参数:

// 只读,不接管
void printSession(const Session& session);  // 看看就好,不动你的

// 接管所有权
void addSession(std::unique_ptr<Session>&& session);  // 我要拿走

用途三:模板中的转发引用(万能引用)

这是高级用法。当 T&& 出现在模板参数推导中时,它不是”右值引用”,而是”转发引用”,能同时接受左值和右值:

// 这里的 T&& 是转发引用,不是右值引用!
template<typename T>
void wrapper(T&& arg) {
    // std::forward 保持 arg 原来的左值/右值身份
    realFunction(std::forward<T>(arg));
}

// 线程池提交任务时的完美转发
template<typename F>
void ThreadPool::submit(F&& task) {
    tasks_.emplace(std::forward<F>(task));
}

三个用途总结

用途写法含义你项目里的例子
移动构造/赋值T(T&& other)偷走 other 的资源SshSession(SshSession&&)
函数参数接管void f(T&& x)调用方放弃所有权addSession(unique_ptr&&)
模板转发引用template<T> void f(T&& x)保持原始值类别转发ThreadPool::submit(F&&)

什么时候不需要右值引用

  • intdouble、裸指针 → 拷贝就几个字节,移动没意义
  • 只读访问 → 用 const T& 就够了
  • 对象很小(< 16 字节的 POD)→ 直接按值传递更快

移动语义

为什么需要移动

std::vector<int> a(1000000, 1);  // 100万个元素
std::vector<int> b = a;          // 拷贝:重新分配内存 + 复制100万个元素 → 慢
std::vector<int> c = std::move(a); // 移动:偷走 a 的内部指针 → 快(接近零成本)

移动就是把内部指针/句柄交接过去,不复制实际数据

std::move 到底干了啥

它不移动任何东西! 它只是把左值强制转成右值,相当于贴了个标签说”这个对象的资源你可以拿走”。

// std::move 本质上就是:
static_cast<T&&>(obj)

真正的移动发生在移动构造函数/移动赋值运算符里。

移动构造长什么样

class Buffer {
    char* data_;
    size_t size_;
public:
    // 拷贝构造:深拷贝,慢
    Buffer(const Buffer& other)
        : data_(new char[other.size_]), size_(other.size_) {
        memcpy(data_, other.data_, size_);
    }

    // 移动构造:偷指针,快
    Buffer(Buffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;  // 源对象置空,防止 double free
        other.size_ = 0;
    }
};

核心区别:拷贝是 new + memcpy,移动是偷指针 + 置空源对象

移动后源对象什么状态

有效但未指定。 只保证能析构、能重新赋值,不能假设它还有原来的值。

std::string s = "hello";
std::string t = std::move(s);
// s 现在是合法对象,但内容不确定(通常是空串,但标准不保证)
// 安全操作:s = "new value"; 或者让 s 析构

关键规则

0. 先搞清楚:变量的”类型” vs 表达式的”值类别”

这是理解右值引用最关键的前置知识。很多人懵就是因为把这两件事混在一起了。

类型(Type):声明变量时写的东西,描述”这个变量能绑定什么”。

值类别(Value Category):表达式在使用时的属性,描述”这个表达式此刻是左值还是右值”。

它们是两个独立维度:

int x = 42;
//  ↑ 类型是 int
//  x 这个表达式的值类别是:左值(有名字、能取地址)

int&& r = 42;
//  ↑ 类型是 int&&(右值引用)
//  r 这个表达式的值类别是:左值!(因为 r 有名字、能 &r)

核心规则:类型和值类别是独立的。类型是 T&& 不代表表达式就是右值。

用生活类比:

  • 类型 = 你的身份证上写的”性别”,是固定的声明
  • 值类别 = 你此刻在做什么(站着/坐着),是当下的状态

再看一组对比:

void f(std::string&& s) {
    // s 的类型:std::string&&(右值引用)← 声明时决定的
    // s 的值类别:左值            ← 因为 s 有名字,在函数体里还活着

    // 类比:s 的"身份证"写着"右值引用",但它此刻"站着"(有名字、能用多次)
}

为什么 C++ 要这样设计?安全。

void f(std::string&& s) {
    g(s);   // 如果 s 自动是右值,g 可能偷走它的资源
    h(s);   // 那这里 s 就是空壳了 → bug!
}

所以 C++ 规定:有名字的变量永远是左值表达式,不管它的类型是什么。 你要放弃它的资源,必须显式 std::move(s) 表达意图。

快速判断表

表达式类型值类别为什么
int x = 1; 中的 xint左值有名字
int&& r = 1; 中的 rint&&左值有名字
std::move(x)int&&将亡值(xvalue)move 转换了值类别
42int纯右值(prvalue)字面量,没名字
std::string("hi")std::string纯右值临时对象
f(std::string&& s) 中的 sstd::string&&左值有名字

结合 CrossShellNext 理解

// SessionManager::addSession
void addSession(int id, std::unique_ptr<Session>&& session) {
    // session 的类型:unique_ptr<Session>&&
    // session 的值类别:左值(有名字)

    sessions_[id] = session;             // ❌ 编译错误!左值不能移动 unique_ptr
    sessions_[id] = std::move(session);  // ✅ 显式转成右值,触发移动赋值
}

一句话总结:类型是”声明时写的”,值类别是”用的时候是什么”。T&& 类型的变量,用的时候仍然是左值,因为它有名字。


1. 有名字的右值引用是左值

这是上面规则的直接推论,也是最经典的坑:

void f(std::string&& s) {
    // s 的类型是 string&&,但 s 这个表达式是左值!
    // 因为它有名字
    g(s);            // 传给 g 的是左值
    g(std::move(s)); // 要再 move 一次才是右值
}

2. const 会抑制移动

const std::string s = "abc";
std::string t = std::move(s);  // 实际走的是拷贝!
// 因为移动需要修改源对象,const 不让改

3. 返回局部对象别写 std::move

std::string f() {
    std::string s = "abc";
    return s;             // ✅ 编译器会 RVO 或自动移动
    // return std::move(s); // ❌ 反而可能妨碍 RVO 优化
}

4. 移动构造要标 noexcept

Buffer(Buffer&& other) noexcept;  // ✅

不标的话,vector 扩容时为了异常安全可能选择拷贝而不是移动,白白浪费性能。


什么时候移动有意义

场景有没有收益原因
stringvector 等容器✅ 收益大内部有堆内存,移动只转指针
unique_ptr、文件句柄✅ 必须移动不可拷贝,只能移动
intdouble、小 POD❌ 没意义拷贝本身就很快,移动没区别

std::move vs std::forward

std::movestd::forward
作用无条件转成右值保留原来的左值/右值身份
用在哪明确不再需要这个对象时模板转发引用里
搭配普通代码template<class T> void f(T&& x)
// std::move:我不要了,你拿走
std::string s = "hi";
vec.push_back(std::move(s));

// std::forward:我不决定,调用者传什么我就转什么
template<class T>
void wrapper(T&& arg) {
    target(std::forward<T>(arg));
}

传参怎么选

场景写法原因
只读大对象const T&不拷贝,能接左值和右值
要接管资源T&& 或按值 T明确表达消费语义
内部要保存副本T(按值)+ std::move接口简洁,左值右值都能处理
// 经典模式:按值接收 + move 到成员
void set_name(std::string name) {
    name_ = std::move(name);
}

易错点

  • std::move 不移动,只是类型转换
  • 有名字的 T&& 变量本身是左值
  • const 对象 move 后还是拷贝
  • 返回局部对象不要写 std::move(妨碍 RVO)
  • 移动后源对象不是”废了”,是”值不确定”
  • 移动构造不标 noexcept 会影响容器性能
  • 小对象(int、指针)移动没意义

面试速答版

左值是有身份、能取地址的对象,右值是临时值。移动语义利用右值引用,把”深拷贝资源”改成”转移资源所有权”,对 string、vector、智能指针这类管理堆资源的对象收益很大。std::move 本身不移动,只是把左值转成右值让编译器匹配移动构造。移动后源对象仍有效但值未指定。工程上注意:const 抑制移动,返回局部对象别写 std::move,移动构造要标 noexcept


面试加分版

左值右值我从两层来讲。第一层是值类别:左值有身份能持续使用,纯右值是临时结果,将亡值是有身份但资源可被复用的对象。注意值类别是表达式属性,T&& 是类型属性,命名后的右值引用变量本身仍是左值表达式。

第二层是移动语义。C++11 引入右值引用后,类型可以为即将销毁的对象提供移动构造,直接接管资源句柄而不是深拷贝。比如 vector 的移动只是转移内部指针和元数据,不复制整块内存。std::move 只是类型转换,把左值转成右值让编译器匹配移动重载,真正移动靠的是移动构造函数。

工程上三个要点:一,返回局部对象不要写 std::move,编译器能做 RVO 甚至 C++17 起强制省略构造;二,移动构造标 noexcept,否则容器扩容可能退回拷贝;三,参数设计上如果函数内部要保存副本,“按值传参再 move 到成员”往往比写两套重载更简洁。

一句话:左值右值解决”对象此刻是什么身份”,移动语义解决”资源能不能高效交接”。

Related · 语言基础