C++ 移动语义

0 阅读8分钟

C++移动语义

1. 左值 vs 右值

1. 定义

  • 左值:有名字、可反复使用、通常可取地址。
  • 右值:临时值、很快就销毁、通常不能直接取地址。

2. 快速判断法

先问自己两个问题:

  1. 这个表达式结束后,它还会留在原地让我继续用吗?
  2. 我能稳定地拿到它的地址吗?

大概率能:左值。大概率不能:右值。

3. 代码示例

int makeInt()
{
    return 42; // 按值返回,结果是临时值(右值)
}

void demo()
{
    int x = 10;            // x 是左值
    int y = x;             // x 仍然存在
    int z = makeInt();     // makeInt() 是右值

    int& lref = x;         // 左值引用绑定左值
    const int& cref = 10;  // const 左值引用可绑定右值,并延长临时对象生命周期
    int&& rref = 10;       // 右值引用绑定右值

    // int& bad1 = 10;     // 错:左值引用不能绑定右值
    // int&& bad2 = x;     // 错:右值引用不能直接绑定左值
}

4. 一个关键事实

只要“有名字”,表达式就是左值,哪怕它的类型是 T&&,它本身也还是左值表达式。

int&& rr = 10;
// rr 是有名字的变量,所以 rr 这个表达式是左值

2. 右值引用(&&)与移动语义的关系

1. 为什么 const T& 不够

const T& 也能绑定右值,但它“只读”。而移动语义要做的是:把源对象内部资源“转移”走,然后把源对象置为可析构的空状态。这个过程必须修改源对象。所以需要 T&&:明确告诉编译器“这个对象可以被转移资源”。

2. 右值引用到底在干什么

右值引用本身不搬资源,它只是给“可搬运”的语义通道。真正搬资源的是:

  1. 移动构造函数 T(T&&)
  2. 移动赋值运算符 T& operator=(T&&)

3. 移动构造函数

1. 作用

当“新对象正在创建”且源对象是右值时,优先调用移动构造。核心目标:不深拷贝,直接接管资源指针/句柄。

2. 代码示例

#include <algorithm>
#include <cstddef>
#include <iostream>

class Buffer
{
public:
    explicit Buffer(std::size_t n)
        : size_(n), data_(new int[n]{})
    {
    }

    ~Buffer()
    {
        delete[] data_;
    }

    Buffer(const Buffer& other)
        : size_(other.size_), data_(new int[other.size_])
    {
        std::copy(other.data_, other.data_ + size_, data_);
        std::cout << "copy ctor\n";
    }

    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_)
    {
        // 把源对象清空,避免双重释放
        other.size_ = 0;
        other.data_ = nullptr;
        std::cout << "move ctor\n";
    }

    Buffer& operator=(const Buffer& other)
    {
        if (this == &other)
        {
            return *this;
        }

        int* newData = new int[other.size_];
        std::copy(other.data_, other.data_ + other.size_, newData);

        delete[] data_;
        data_ = newData;
        size_ = other.size_;
        std::cout << "copy assign\n";
        return *this;
    }

    Buffer& operator=(Buffer&& other) noexcept
    {
        if (this == &other)
        {
            return *this;
        }

        delete[] data_;
        data_ = other.data_;
        size_ = other.size_;

        other.data_ = nullptr;
        other.size_ = 0;
        std::cout << "move assign\n";
        return *this;
    }

private:
    std::size_t size_ = 0;
    int* data_ = nullptr;
};

3. 使用示例

Buffer a(1024);
Buffer b = a;             // 拷贝构造
Buffer c = std::move(a);  // 移动构造

c 直接接管 a 的资源,避免深拷贝。

4. 移动赋值运算符

1. 和移动构造的区别

  • 移动构造:目标对象还没创建。
  • 移动赋值:目标对象已经存在,要先清理旧资源,再接管新资源。

2. 代码示例

Buffer a(256);
Buffer b(128);

b = std::move(a); // 调用移动赋值

3. 注意事项

  1. 先处理自赋值 if (this == &other)。
  2. 先释放旧资源,再接管新资源。
  3. 源对象要重置为“可析构状态”。
  4. 尽量加 noexcept,容器扩容时才更愿意走移动路径。

5. std::move 的原理与使用场景

1. 原理

std::move 本质是类型转换,不搬资源。

template <class T>
constexpr std::remove_reference_t<T>&& my_move(T&& t) noexcept
{
    return static_cast<std::remove_reference_t<T>&&>(t);
}

它做的事:把表达式转成右值引用,触发后续移动构造/移动赋值。

2. 常见使用场景

#include <string>
#include <vector>

void vectorDemo()
{
    std::vector<std::string> v;
    std::string s = "hello";

    v.push_back(std::move(s)); // 明确把 s 交给容器
    // s 仍然有效,但内容不保证
}
class User
{
public:
    void setName(std::string name)
    {
        // name 是按值传入的本地副本,这里可安全转移
        name_ = std::move(name);
    }

private:
    std::string name_;
};
#include <memory>

void uniquePtrDemo()
{
    std::unique_ptr<int> p1(new int(7));
    std::unique_ptr<int> p2 = std::move(p1); // unique_ptr 只能移动,不能拷贝
}

3. 常见错误

#include <string>

std::string makeName()
{
    std::string s = "cpp";
    return s; // 让编译器做 RVO/NRVO,通常不要写 return std:: move(s);
}
const std::string cs = "hello";
std::string t = std::move(cs); // 这里通常还是拷贝,不是移动

说明:移动通常要求修改源对象,const 对象不能被修改,所以很难真正移动。

6. 拷贝 vs 移动

1. 决策

场景建议
后面还要继续用原对象内容拷贝
明确不再需要原对象内容std::move
模板转发参数std::forward
返回局部变量直接 return obj;

2. 代码示例

// 只读,不保存副本:用 const T&
void printName(const std::string& name);

// 需要修改调用方对象:用 T&
void normalize(std::string& name);

// 只接临时对象(较少用)
void consume(std::string&& name);

// 需要“接收并保存”参数:常用值传递 + move(简单、稳)
class Profile
{
public:
    void setCity(std::string city)
    {
        city_ = std::move(city);
    }

private:
    std::string city_;
};

3. 为什么很多项目偏爱“值传递 + move”

  1. 调用方传左值:拷贝进来一次,再 move 到成员。
  2. 调用方传右值:可直接移动进来,整体成本低。
  3. 代码比“const& + && 双重重载”更简洁。

4. noexcept 很重要

标准容器扩容时,为了异常安全,常常只在“移动构造不抛异常”时优先移动。所以自定义类型的移动构造/移动赋值建议写成 noexcept。

class BigObject
{
public:
    BigObject(BigObject&&) noexcept = default;
    BigObject& operator=(BigObject&&) noexcept = default;
};

7. 完美转发(std::forward)

1. 先看问题

#include <iostream>
#include <string>

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

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

template <typename T>
void badWrapper(T&& arg)
{
    // arg 有名字,所以这里总是左值语义
    sink(arg);
}

badWrapper(std::string("hi")) 也可能走左值版本,浪费移动机会。

2. 正确做法

template <typename T>
void goodWrapper(T&& arg)
{
    // 保留调用方原始值类别:左值就左值,右值就右值
    sink(std::forward<T>(arg));
}

3. 和 std::move 的区别

  1. std::move:无脑转右值。
  2. std::forward<T>:按原始类别转发。

一句话记住:

  • 普通业务代码:想交出对象所有权,用 move。
  • 模板转发代码:想保留原语义,用 forward。

8. emplace vs insert/push_back

1. 在线性容器中(vector 为例)

#include <string>
#include <vector>

void vectorCase()
{
    std::vector<std::string> v;
    std::string s = "abc";

    v.push_back(s);               // 拷贝
    v.push_back(std::move(s));    // 移动

    v.emplace_back("hello");      // 在容器内直接构造 string
    v.emplace_back(5, 'x');       // 构造 "xxxxx",这个场景 emplace 常更直接
}

经验规则:

  1. 已经有对象了:push_back(obj) / push_back(std::move(obj)) 清晰。
  2. 只有构造参数:emplace_back(args...) 更顺手。
  3. 不是所有场景 emplace_back 都更快,别迷信。

2. 在关联容器中(map 为例)

#include <map>
#include <string>

void mapCase()
{
    std::map<int, std::string> m;

    m.insert({1, "one"});              // 插入一个 pair 对象
    m.emplace(2, "two");               // 原地构造 pair
    m.try_emplace(2, "TWO");           // key 已存在时,不会构造新 value
    m.insert_or_assign(2, "TWO");      // 有则赋值,无则插入
}

建议:

  1. 只插入,不覆盖:优先 try_emplace(尤其 value 构造昂贵时)。
  2. 要“存在则更新”:用 insert_or_assign。
  3. insert / emplace 都能用时,按可读性选,不要为“理论微优化”牺牲清晰度。

3. 一个常见坑

std::vector<std::vector<int>> vv;
vv.push_back({1, 2, 3}); // 常常没问题
// vv.emplace_back({1, 2, 3}); // 某些场景会因模板推导出问题
vv.emplace_back(std::initializer_list<int>{1, 2, 3}); // 更稳

9. 高频场景模板

1. 场景一:类成员接收字符串

class Person
{
public:
    void setName(std::string name)
    {
        name_ = std::move(name);
    }

private:
    std::string name_;
};

适用:大部分业务类 setter。

2. 场景二:构造函数转移资源

class Session
{
public:
    explicit Session(std::vector<int> data)
        : data_(std::move(data))
    {
    }

private:
    std::vector<int> data_;
};

适用:把传入参数保存为成员。

3. 场景三:容器里放 move-only 对象

#include <memory>
#include <vector>

void moveOnlyCase()
{
    std::vector<std::unique_ptr<int>> v;
    auto p = std::make_unique<int>(42);

    v.push_back(std::move(p)); // 必须 move
}

适用:文件句柄、锁、socket 封装、unique_ptr 等。

4. 场景四:写通用模板包装器

template <typename F, typename... Args>
decltype(auto) call(F&& f, Args&&... args)
{
    // 保留 f 和 args 的值类别
    return std::forward<F>(f)(std::forward<Args>(args)...);
}

适用:日志包装器、重试框架、任务调度器。

10. 最容易踩的 10 个坑

  1. 把 std::move 当成“真的移动动作”。
  2. move 之后继续依赖原对象内容。
  3. 在模板转发函数里用 move,误伤左值参数。
  4. 忘记给移动构造/赋值加 noexcept,导致容器扩容走拷贝。
  5. 以为“T&& 类型变量本身就是右值”。
  6. 对 const 对象 move,结果还是拷贝。
  7. 没有资源所有权却乱写自定义移动函数。
  8. 写了移动操作,但忘了把源对象置为空壳状态。
  9. 在 return 局部变量时强行 std::move,干扰编译器优化。
  10. 盲目把所有 push_back 改成 emplace_back,可读性反而变差。

11. 小结

1. 基本规则

  1. 变量默认是左值,想移动就 std::move。
  2. 模板转发参数就 std::forward<T>(arg)。
  3. move 后对象仍有效,但内容不保证。
  4. 能用编译器默认移动操作就优先 = default,并加 noexcept。
  5. 有对象就 push_back,有构造参数就 emplace_back。
  6. map 插入优先考虑 try_emplace / insert_or_assign。

2. 实用的写法组合

class Config
{
public:
    void setPath(std::string path)
    {
        path_ = std::move(path);
    }

private:
    std::string path_;
};
template <typename T>
void forwardToSink(T&& v)
{
    sink(std::forward<T>(v));
}
std::vector<std::string> v;
v.emplace_back("log.txt");

到这里,基本上已经覆盖绝大多数的移动语义场景了。剩下就是多看标准库接口签名,遇到 && 就先判断“这是要消费对象,还是要完美转发”。