【C++】移动语义和完美转发

前言

学习C++移动语义和完美转发笔记,记录左值、右值、std::move()、万能引用、引用折叠等相关内容。

概念

  • 左值 (lvalue) 它是在内存中有明确存储地址、可以被寻址的值。如果你可以对一个表达式取地址(使用 & 运算符),那么它就是一个左值。左值通常是持久的,在它所在的定义域结束之前一直存在

  • 左值引用(Lvalue Reference)本质上就是给一个现有的左值起了一个“别名”,左值引用定义即初始化。

    • 普通左值引用 (T&):只能绑定到非 const 左值
    • 常量左值引用 (const T&):可以绑定到一切(左值、const 左值、右值)。
const int& temp = 10; 
//编译器会在内存中产生一个临时变量存储 10。
//temp 绑定到这个临时变量上。
//这个临时变量的寿命会变得和引用 temp 一样长。
  • 右值 (rvalue) 右值就是那些临时出现、没有持久名字、无法取地址的值。如果你无法对一个表达式使用 & 取地址运算符,或者它是一个即将销毁的临时对象,它就是右值。右值通常是“瞬时”的,在包含它的表达式执行完之后,它就会被立即销毁.

  • 右值引用(Rvalue Reference)一种绑定到右值(临时对象)的引用类型

    int&& rref = 10;

  • std::move 并不移动任何东西。它的唯一作用是:强制将一个左值转为右值引用

    template <typename T> 
    typename std::remove_reference<T>::type&& move(T&& t) {    
        return static_cast<typename std::remove_reference<T>::type&&>(t); 
    }
    
    • std::remove_reference<T>::type:这是一个类型萃取工具。无论 Tintint& 还是 int&&,它都能把修饰符去掉,只留下纯粹的底层类型 int

    • static_cast 将输入变量 t 强制转换为该类型的右值引用(即 type&&

  • std::forward 如果原始参数是左值,转发后仍然是左值。如果原始参数是右值,转发后仍然是右值

template <typename T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
    return static_cast<T&&>(t);
}
  • 移动语义(Move Semantics) 本质是资源所有权的转移,即将一个临时对象(右值)持有的资源转移,来避免昂贵的深拷贝操作。是由类实现的功能(通过移动构造函数)

  • 完美转发(Perfect Forwarding) 是:在函数模板中,将参数原封不动地转发给另一个函数,同时完全保留参数的所有属性(包括它是左值还是右值、是否带有 constvolatile 修饰符)。

    template <typename T>
    T&& forward(typename std::remove_reference<T>::type& t) noexcept {
        return static_cast<T&&>(t);
    }
    
    • 如果原始参数是左值,转发后仍然是左值

    • 如果原始参数是右值,转发后仍然是右值

  • 万能引用

    • 使用 && 符号
    • 必须发生模板类型推导:通常出现在 template <typename T> 之后的 T&&(不能加 const)。
    • 形式必须完全匹配:必须是具体的 T&&,不能有 conststd::vector<T>&& 等修饰。
    • 万能引用是完美转发的“门”。 它把参数原封不动地领进来(无论是左值还是右值),然后配合 std::forward 把它原封不动地送出去。
  • 引用折叠 是 C++ 编译器在处理“引用的引用”时遵循的一套自动简化规则。只要有左值引用(&)参与,结果就是左值引用;只有全是右值引用(&&)时,结果才是右值引用。

内容

左值

内存中有明确存储地址、可以被寻址的值

int a = 10;        // a 是左值(有名字,可取地址)
a = 20;            // a 在左边,OK

int* p = &a;       // p 是左值
*p = 30;           // *p(解引用结果)是左值

int** pp = &(*p);  // 我们可以对 (*p) 再次取地址 ,可以看到*p是可以取地址的

const int b = 5;   // b 是左值(虽然不可修改,但它有内存地址,是具名变量)

void func(int&& x) {
    // 这里的 x 类型是右值引用,但 x 本身是一个有名字的变量
    // 所以在函数内部,x 是一个左值!
    int* p = &x; // 这是合法的
}

左值引用

对左值的引用,即给左值起别名,必须初始化。

int a = 10;
int& ref = a;  // ref 是 a 的左值引用

const int& r3 = 10; // 正确!

std::cout << "a" << "b" << "c"; // 每次调用 << 都返回 cout 的左值引用

右值

没有固定地址、没有名字的值,通常是临时结果、字面量或即将销毁的对象。

10;                 // 纯右值:字面量

a + b;              // 纯右值:运算的中间结果,没有名字

func(5);            // 如果 func 返回一个值(非引用),func(5) 就是右值

std::string("Hi");  // 纯右值:临时构造的匿名对象

右值引用

绑定到右值上。 它的符号是 &&

int&& r1 = 10;          // 正确:10 是右值

int a = 10;
int&& r3 = std::move(a); // 正确:std::move 把左值转成了右值(将亡值)

int n = 5;
// int&& r = ++n; // 错误:++n 返回的是修改后的 n 本身(有地址)
int&& r = n++;    // 正确:n++ 返回的是一个临时副本(旧值 5),n 本身已变

万能引用&引用折叠

template<typename T>
void func(T&& param); // 这是一个万能引用
//场景 A:传入左值变量 
int a = 10; 
func(a);

//因为 a 是左值,根据万能引用的特殊推导规则,编译器将 T 推导为 int&。
//为什么不能是int;因为如果为 int,函数变为 void func(int && param) 这个函数只能接收右值,所以编译器只能推导为int &
//代入模板:函数签名变成 void func(int& && param);。
//引用折叠:根据规则,int& && 含有左值引用,折叠为 int&。
//最终形态:void func(int& param); —— 成功以左值引用的方式接收了变量。


//场景 B:传入右值字面量 20
func(20);
//推导:因为 20 是右值,编译器将 T 推导为 int。
//模板:函数签名变成 void func(int&& param);。
//折叠:没有冲突,或者看作 int&&,保持 int&&。
//最终形态:void func(int&& param); —— 成功以右值引用的方式接收了临时变量

移动语义

我们将一个临时对象赋值给另一个对象时,会触发深拷贝。比如一个包含 很多数据的 std::vector,拷贝它需要重新分配内存再复制数据,这非常耗时。

移动语义 允许我们直接获取临时对象的资源,只需修改指针指向,而不必重新分配内存。

#include <iostream>
#include <vector>
#include <utility>

class MyBuffer {
private:
    int* data; // 唯一的私有属性

public:
    // 构造函数 explicit  防止隐式转换   
    //MyBuffer b2 = 100;   ❌ 编译错误,不能隐式转换
    explicit MyBuffer(int value) : data(new int(value)) {
        std::cout << "分配内存并存入: " << *data << std::endl;
    }

    // 析构函数
    ~MyBuffer() {
        if (data) {
            delete data;
            data = nullptr;
            std::cout << "释放内存" << std::endl;
        }
    }

    // ---------------------------------------------------------
    // 拷贝构造函数 (Copy Constructor) - 深拷贝
    // ---------------------------------------------------------
    MyBuffer(const MyBuffer& other) : data(other.data ? new int(*other.data) : nullptr) {
        std::cout << "深拷贝数据: " << (data ? *data : 0) << std::endl;
    }

    // ---------------------------------------------------------
    // 移动构造函数 (Move Constructor)
    // ---------------------------------------------------------
    MyBuffer(MyBuffer&& other) noexcept : data(other.data) {
        other.data = nullptr;
        std::cout << "资源所有权已转移" << std::endl;
    }

    // ---------------------------------------------------------
    // 移动赋值运算符 (Move Assignment Operator)
    // ---------------------------------------------------------
    MyBuffer& operator=(MyBuffer&& other) noexcept {
        std::cout << "执行移动赋值" << std::endl;
        
        if (this != &other) {
            delete data;        // 1. 释放当前对象持有的旧内存
        	data = other.data;  // 2. 接管新资源
        	other.data = nullptr;
        }
        return *this;
    }

    // 访问器方法
    int getValue() const {
        return data ? *data : 0;
    }

    void setValue(int value) {
        if (data) {
            *data = value;
        } else {
            data = new int(value);
        }
    }

    // 检查是否拥有资源
    bool isEmpty() const {
        return data == nullptr;
    }
};

int main() {
    std::cout << "=== 测试移动语义 ===" << std::endl;
    
    // 测试移动构造函数
    MyBuffer b1(100);
    MyBuffer b2(std::move(b1)); // 触发移动构造
    // b1 现在是"有效但未指定状态"
    std::cout << "b1是否为空: " << b1.isEmpty() << std::endl;
    std::cout << "b2的值: " << b2.getValue() << std::endl;
    
    // 测试移动赋值
    MyBuffer b3(300);
    MyBuffer b4(400);
    std::cout << "\n移动赋值前 - b3: " << b3.getValue() << ", b4: " << b4.getValue() << std::endl;
    b4 = std::move(b3);
    std::cout << "移动赋值后 - b3是否为空: " << b3.isEmpty() << std::endl;
    std::cout << "移动赋值后 - b4: " << b4.getValue() << std::endl;
    
    return 0;
}

out

root1@ubuntu:~/work/hello/build$ ./hello_cmake_g 
=== 测试移动语义 ===
分配内存并存入: 100
资源所有权已转移
b1是否为空: 1
b2的值: 100

分配内存并存入: 300
分配内存并存入: 400

移动赋值前 - b3: 300, b4: 400
执行移动赋值
移动赋值后 - b3是否为空: 1
移动赋值后 - b4: 300
释放内存
释放内存

完美转发

#include <iostream>
#include <utility>

void target(int& x) { 
	std::cout << "调用左值函数\n"; 
}
void target(int&& x) { 
	std::cout << "调用右值函数\n"; 
}

template <typename T>
void perfectForwarder(T&& arg) {
    // std::forward 会根据 T 的类型决定是 cast 成左值还是右值
    target(std::forward<T>(arg)); 
}

int main() {
    int a = 10;
    std::cout << "传递了右值\n"; 
    perfectForwarder(a);  
   
    std::cout << "传递了右值\n"; 
    perfectForwarder(20); 
}

out

root1@ubuntu:~/work/hello/build$ ./hello_cmake_g 
传递了右值
调用左值函数
传递了右值
调用右值函数

思考


++ii++的区别,为什么要习惯性写++i ?

  • 前置自增 ++i (左值)

    在 C++ 的底层实现中,前置自增的操作类似于:“先给这个内存地址里的值加 1,然后把这个地址传回去。”

    • 返回类型: 通常是引用类型(如 T&)。

    • 内存逻辑: 它直接在原变量上操作,不产生中间人。

    • 为什么是左值: 因为它返回的是变量本身,它在表达式结束后依然存在,拥有确定的内存地址。

  • 后置自增 i++ (右值)

    后置自增的操作逻辑则复杂一些:“先把当前的值存到一个临时地方,给原变量加 1,然后把刚才那个临时值传回去。”

    • 返回类型: 通常是按值返回(如 T)。

    • 内存逻辑: 产生了一个临时对象(Temporary Object)

      • 为什么是右值: 那个临时副本在表达式执行完的那一刻就被销毁了。它没有持久的“身份”,你无法通过地址再次找到它,因此它是右值。

对于内置类型(如 int): 现代编译器非常聪明,通常会把 i++ 优化掉,使两者性能一致。

对于自定义类型(如迭代器 std::map::iterator): 区别极大。

  • ++i 直接修改内部指针并返回引用。
  • i++ 必须先调用拷贝构造函数创建一个副本,修改原值,最后返回那个副本。这个副本的创建和随后的析构都是额外的开销。


std::move 与 std::forward 的本质区别?

  • std::move<T>(x)无条件转换。不管 x 是什么,通通强制转为右值引用。
  • std::forward<T>(x)有条件转换。只有当 T 被推导为右值引用时,才将其转换为右值引用;否则保持左值属性。


移动构造函数为什么要加 noexcept

为了 STL 容器的异常安全性。std::vector 扩容需要搬移元素时,如果你的移动构造函数不声明 noexceptvector 为了保证在搬移失败时能回滚,会放弃使用高效的“移动语义”,转而使用效率较低的“拷贝构造”。


posted @ 2026-01-05 01:02  hjk-airl  阅读(23)  评论(0)    收藏  举报