【C++11】左值右值引用与移动语义详细笔记(左值右值、右值引用、移动构造、移动赋值、引用折叠、万能转发)

文章目录

  • 前言
  • 一、分清左值和右值
  • [二、左值引用 vs 右值引用](#二、左值引用 vs 右值引用)
    • [1. 引用延长临时对象生命周期](#1. 引用延长临时对象生命周期)
    • [2. 重载区分左值、右值实参](#2. 重载区分左值、右值实参)
  • [三、移动语义:移动构造 和 移动赋值](#三、移动语义:移动构造 和 移动赋值)
    • [3. 编译器默认生成移动成员的规则](#3. 编译器默认生成移动成员的规则)
  • [四、std::move 是什么?](#四、std::move 是什么?)
  • [五、引用折叠 和 万能引用(转发引用)](#五、引用折叠 和 万能引用(转发引用))
    • [1. 引用折叠规则](#1. 引用折叠规则)
    • [2. 万能引用 `T&&`](#2. 万能引用 T&&)
  • [六、std::forward 完美转发](#六、std::forward 完美转发)
  • [七、在 STL 容器中的实际应用](#七、在 STL 容器中的实际应用)
  • 八、高频问题总结
  • 总结

前言

在 C++98 时代,拷贝对象是一件很头疼的事情。比如函数返回一个局部的string或者vector,会生成临时对象,再调用拷贝构造把资源完整复制一份。如果对象很大,堆内存很多,拷贝开销就非常高。

但我们仔细想想:这个临时对象函数结束马上就要销毁,它的堆内存反正就要释放,为什么不能直接把这块内存 "偷过来" 给新对象用,非要完整拷贝一遍?

C++11 带来的右值引用、移动语义 就是来解决这个痛点。不再总是做深拷贝,允许把即将销毁对象的资源转移走,大幅减少内存拷贝。同时还衍生出std::move、std::forward万能转发,也是 STL 容器push_back/emplace_back底层的核心。

前置知识基础:类和对象、拷贝构造、拷贝赋值。


一、分清左值和右值

左值和右值的核心判断标准:能不能取地址。

  • 左值 (lvalue) :有持久存储、可以取地址。可以放在等号左边,也可以放右边。普通变量、解引用*p、数组元素、引用变量、const修饰的变量,全是左值。
  • 右值 (rvalue) :临时数据,字面量、表达式结果、匿名临时对象,不能取地址,只能出现在等号右侧。
cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

int main()
{
    // -------- 左值,可以取地址 --------
    int a = 10;
    int* p = new int(1);
    const int c = 100;
    string s("hello");
    cout << &a << endl;
    cout << &(*p) << endl;
    cout << &c << endl;
    cout << (void*)&s[0] << endl;

    // -------- 右值,不能取地址 --------
    10;
    a + 5;
    string("test");
    // cout << &10;     // 编译报错,右值不能取地址
    // cout << &(a+5);  // 编译报错

    delete p;
    return 0;
}

非常重要的知识点:

右值被右值引用变量接住之后,这个引用变量本身是左值!

哪怕它绑定的是临时对象,只要它是一个变量,就可以取地址,属于左值。

cpp 复制代码
int main()
{
    int&& r = 10; // r绑定右值10
    cout << &r << endl; // 可以取地址!r本身是左值
    return 0;
}

C++11 把右值进一步细分:

  1. 纯右值 prvalue:传统意义的右值,字面量、表达式返回的临时对象。
  2. 将亡值 xvalue :即将消亡的值,std::move(左值)转换出来的就是将亡值。

泛左值 glvalue = 左值 + 将亡值。日常写代码不用死记分类,理解概念即可。

二、左值引用 vs 右值引用

  • 左值引用 T&:给左值起别名
  • 右值引用 T&&:给右值起别名
cpp 复制代码
#include <iostream>
#include <string>

int main()
{
    int a = 10;
    // 左值引用绑定左值
    int& ra = a;

    // int& rb = 20; // 报错,普通左值引用不能绑定右值
    const int& rc = 20; // const左值引用可以绑定右值,延长临时对象生命周期

    // 右值引用绑定右值
    int&& rd = 20;
    // int&& re = a; // 报错,右值引用不能直接绑定左值

    // std::move:把左值强制转换为将亡值(右值)
    int&& rf = std::move(a);

    return 0;
}

1. 引用延长临时对象生命周期

const左值引用、非 const 右值引用 ,都可以延长临时对象的生命周期。

区别:const引用不能修改,右值引用可以修改临时对象。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

int main()
{
    string s1 = "hello";
    const string& r2 = s1 + s1; // s1+s1是临时对象,生命周期延长
    // r2 += "test"; // const不能修改
    cout << r2 << endl;


    string&& r3 = s1 + s1;
    r3 += "test"; // 可以修改临时对象
    cout << r3 << endl;
    return 0;
}

注意:不要用右值引用接收函数内部局部变量,局部变量函数结束直接销毁,引用会悬空。

2. 重载区分左值、右值实参

同一个函数,可以同时重载T&、const T&、T&&,编译器会根据传入实参类型自动匹配:

cpp 复制代码
#include <iostream>
using namespace std;

void func(int& x)
{
    cout << "左值引用版本:" << x << endl;
}

void func(const int& x)
{
    cout << "const左值引用版本:" << x << endl;
}

void func(int&& x)
{
    cout << "右值引用版本:" << x << endl;
}

int main()
{
    int a = 10;
    const int b = 20;
    func(a);        // 左值,调用 func(int&)
    func(b);        // const左值,调用 func(const int&)
    func(30);       // 右值,调用 func(int&&)
    func(move(a));  // move之后变成右值,调用 func(int&&)

    int&& r = 100;
    func(r);        // r是变量,属于左值!匹配 func(int&)
    func(move(r));  // 再次move才匹配右值引用版本
    return 0;
}

这个例子再次印证上面那句话:右值引用变量本身是左值。

三、移动语义:移动构造 和 移动赋值

C++98 只有拷贝构造、拷贝赋值,遇到临时对象,只能完整拷贝堆上资源。

C++11 新增两个默认成员函数:移动构造 、移动赋值重载。

拷贝:复制一份资源,新旧对象互不干扰。

移动:窃取对方的资源,不用拷贝堆内存 ,源对象变成有效但未定义的状态。

移动语义只对管理堆资源的类才有意义,比如string、vector;内置类型移动和拷贝没有区别。

我们手写简化版 string,对比拷贝和移动:

cpp 复制代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

class MyString
{
public:
    MyString(const char* str = "")
    {
        cout << "普通构造" << endl;
        _size = strlen(str);
        _capacity = _size;
        _str = new char[_capacity + 1];
        strcpy(_str, str);
    }

    void Swap(MyString& s)
    {
        std::swap(_str, s._str);
        std::swap(_size, s._size);
        std::swap(_capacity, s._capacity);
    }

    // 拷贝构造:深拷贝,完整复制堆内存
    MyString(const MyString& s)
    {
        cout << "拷贝构造" << endl;
        _str = new char[s._capacity + 1];
        strcpy(_str, s._str);
        _size = s._size;
        _capacity = s._capacity;
    }

    // 移动构造:右值引用,窃取资源,swap交换指针
    MyString(MyString&& s)
    {
        cout << "移动构造" << endl;
        Swap(s);
    }

    // 拷贝赋值
    MyString& operator=(const MyString& s)
    {
        cout << "拷贝赋值" << endl;
        if (this != &s)
        {
            char* tmp = new char[s._capacity + 1];
            strcpy(tmp, s._str);
            delete[] _str;
            _str = tmp;
            _size = s._size;
            _capacity = s._capacity;
        }
        return *this;
    }

    // 移动赋值:窃取资源
    MyString& operator=(MyString&& s)
    {
        cout << "移动赋值" << endl;
        Swap(s);
        return *this;
    }

    ~MyString()
    {
        delete[] _str;
        _str = nullptr;
    }

    const char* c_str() const
    {
        return _str;
    }
private:
    char* _str = nullptr;
    size_t _size = 0;
    size_t _capacity = 0;
};

测试代码:

cpp 复制代码
MyString Func()
{
    MyString tmp("hello world");
    return tmp; // 返回局部对象,tmp是局部变量,返回时转为右值
}

int main()
{
    MyString s1("aaaaa");
    cout << endl;

    MyString s2 = s1;         // 左值,调用拷贝构造
    cout << endl;

    MyString s3 = Func();     // s3对象第一次创建,Func返回临时右值,调用移动构造,编译器还会做返回值优化RVO
    cout << endl;

    MyString s4("bbbb");
    cout << endl;

    MyString s5 = move(s4);   // move把s4转为右值,调用移动构造,s4资源被偷走
    cout << endl;

    MyString s6("xxx");
    cout << endl;

    s6 = Func();             // s6对象已存在,Func需要先构造一个临时右值对象(移动构造),然后把这个临时对象移动赋值给s6
    return 0;
}

VS编译器返回值优化 RVO:release 模式,编译器会把局部对象直接构造在接收对象栈帧上,连移动都省略。可以在g++ 可以加-fno-elide-constructors关闭优化,观察真实移动 / 拷贝调用流程。

3. 编译器默认生成移动成员的规则

  1. 如果你没有写拷贝构造、拷贝赋值、析构,编译器会自动生成默认移动构造、移动赋值。
  2. 只要你手动实现了拷贝构造 / 拷贝赋值 / 析构其中任意一个,编译器就不会自动生成移动构造和移动赋值。
  3. 如果你写了移动构造,编译器不会再给你生成拷贝构造。

如果你想强制让编译器生成默认移动函数,可以用= default;不想让编译器生成就写= delete。

cpp 复制代码
class Test
{
public:
    Test() = default;
    Test(const Test& t)
    {
        // 自己写拷贝构造,编译器不会生成移动构造
    }
    Test(Test&& t) = default; // 强制让编译器生成默认移动构造
    Test& operator=(Test&& t) = default;
    ~Test() = default;
};

四、std::move 是什么?

很多人误以为move会移动对象,std::move 不做任何资源移动!它仅仅做强制类型转换,把左值转为将亡值(右值)。

源码简化理解:

cpp 复制代码
template<class T>
typename remove_reference<T>::type&& move(T&& arg)
{
    return static_cast<typename remove_reference<T>::type&&>(arg);
}

它只是返回一个右值引用,真正触发移动构造 / 移动赋值,是后续函数调用匹配到T&&版本才会发生。

使用 move 之后,源对象处于 "有效但是未指定状态",你不能再随意使用源对象,只能给它赋值或者销毁。

五、引用折叠 和 万能引用(转发引用)

1. 引用折叠规则

C++ 不允许直接写int&&&这种引用的引用。但是模板推导、typedef 的时候可能间接产生,C++ 给出折叠规则:

  1. T& & → T&
  2. T& && → T&
  3. T&& & → T&
  4. T&& && → T&&

口诀:只要里面出现左值引用&,折叠结果就是左值引用;只有全是右值引用&&,结果才是右值引用。

2. 万能引用 T&&

模板里面写的T&&不是单纯右值引用,叫万能引用(转发引用)。

  • 如果传入实参是左值,T 会推导成左值引用,经过引用折叠,形参变成左值引用。
  • 如果传入实参是右值,T 推导成普通类型,形参变成右值引用。
cpp 复制代码
template<class T>
void Func(T&& t)
{
    // t 会根据实参是左/右值,实例化为左值引用或者右值引用
}

int main()
{
    int a = 10;
    Func(a);       // a左值 → T推导int& → 形参 int&
    Func(20);      // 20右值 → T推导int → 形参 int&&
    Func(move(a)); // move后右值 → T推导int → 形参 int&&
    return 0;
}

注意:只有模板参数T&&才是万能引用!如果类型已经确定,比如int&&就只是普通右值引用,不是万能引用。

六、std::forward 完美转发

万能引用拿到参数之后,形参变量本身一定是左值。如果你想保持原来实参的左 / 右值属性,就需要std::forward完美转发。

  • 实参原本是左值,forward 之后保持左值;
  • 实参原本是右值,forward 之后变成右值。
cpp 复制代码
template<class T>
void Fun(T&& t)
{
    // 直接传t,t是变量,永远是左值
    // 用forward<T>(t),保留原来的值类别属性
    OtherFunc(std::forward<T>(t));
}

对比记忆:

  • std::move:无条件转为右值。
  • std::forward<T>:保留原来实参的左 / 右属性,只用于模板万能引用。

七、在 STL 容器中的实际应用

C++11 之后,容器很多接口都提供两套重载:const 左值引用版本,右值引用版本。

类似下面的伪代码:

cpp 复制代码
// vector伪代码
void push_back(const T& val);  // 接收左值,走拷贝构造
void push_back(T&& val);       // 接收右值,走移动构造

实际使用:

cpp 复制代码
#include <vector>
#include <string>
using namespace std;

int main()
{
    vector<string> v;
    string s1("aaaa");

    v.push_back(s1);          // 传入左值,拷贝构造,s1还能用
    v.push_back(move(s1));    // move转为右值,移动构造,s1资源被夺走

    v.push_back(string("bbbb")); // 匿名临时右值,直接移动构造
    return 0;
}

顺带提一嘴emplace_back:

push_back:先构造对象,再拷贝 / 移动进容器。

emplace_back:利用可变参数模板 + 完美转发,直接在容器的内存空间原地构造对象,省去移动拷贝,效率更高。

cpp 复制代码
vector<pair<int, int>> vec;
vec.emplace_back(1,2); // 直接在容器内构造pair,不用生成临时对象

八、高频问题总结

  1. 什么是左值、右值?

能取地址的是左值;字面量、临时对象不能取地址是右值。右值引用绑定之后,引用变量本身是左值。

  1. move 是干什么的?会移动对象吗?

move 只是强制转换成右值,不移动任何资源,函数调用匹配到移动构造 / 移动赋值才会窃取资源。move 之后源对象只能赋值或者析构。

  1. 移动构造什么时候会被编译器自动生成?

没有用户自定义的拷贝构造、拷贝赋值、析构,编译器才会生成默认移动成员。写了其中任意一个,就不会生成。

  1. 万能引用和普通右值引用区别?

模板中T&&是万能引用,实参左值就推导左值引用,实参右值推导右值引用;确定类型如int&&只是普通右值引用,只能接收右值。

  1. forward 完美转发的作用?

万能引用的形参变量本身是左值,forward 可以保留原始实参的左 / 右值属性,做到完美转发。

  1. 为什么函数返回局部对象,不要返回局部对象的右值引用?

局部对象函数结束就销毁,返回引用会悬空,不管左值还是右值引用都不行。可以直接传值返回,编译器会触发移动或者 RVO 优化。


总结

右值引用、移动语义是 C++11 非常核心的更新,目的就是减少不必要的堆内存拷贝。

记住一条主线:

  • move:无条件变成右值;
  • forward:保留原始属性,配合万能引用;
  • 移动构造 / 移动赋值:窃取即将销毁对象的资源,避免深拷贝。

很多初学者会混淆 move 和 forward,记住:move 随便用;forward 几乎只在模板万能引用场景下使用。

如果这篇对你有帮助,欢迎点赞收藏,后续继续更新 C++11 系列。

相关推荐
MandalaO_O1 小时前
SpringMVC 异常处理 & 拦截器
笔记
布莱克6051 小时前
C++ 中 unordered_set 与 unordered_map 有什么不同?一篇讲透
开发语言·c++·哈希
外收内放1 小时前
FastAPI猜字谜实战(6):日志与异常
开发语言·python·学习·fastapi
鶴哥只手遮天2 小时前
AFSIM 2.9.0 深度解析:架构、原理与工程实践-第 1 章 项目全景与代码地图
c++
知识分享小能手2 小时前
C学习教程,从入门到精通,C语言单片机基础 — 完整知识点手册(19)
c语言·单片机·学习
充值内卷2 小时前
C++单文件迷宫游戏:TauriCPP 开发全记录
开发语言·c++·游戏
Antruly2 小时前
C++ Web 开发选型漫谈:从 Drogon 到 libuvcpp + uvp,八个项目的定位、兼容性与务实排名
开发语言·前端·c++
小黄人软件2 小时前
Cluade 桌面端配置(ccswitch配置或文件直接配置)
学习
李永奉2 小时前
杰理可视化SDK开发-音频录制功能使用教程
笔记·单片机·嵌入式硬件·mcu·物联网·音视频