Skip to content

移动语义(Move Semantics)

C++11 引入的移动语义解决了一个核心问题:如何在传递大对象时避免不必要的深拷贝


1. 概念:拷贝 vs 移动

1.1 拷贝——"复印一份"

std::vector<int> a = {1, 2, 3, 4, 5};  // a 拥有这 5 个 int
std::vector<int> b = a;                 // 深拷贝:b 分配新内存,把 5 个 int 逐一遍过去
// 现在 a 和 b 各有一份数据,互不影响
拷贝前:   a ──→ [1][2][3][4][5]   (在地址 0x1000)
拷贝后:   a ──→ [1][2][3][4][5]   (仍在 0x1000)
          b ──→ [1][2][3][4][5]   (新分配 0x2000,数据完全独立)

1.2 移动——"把资源偷过来"

std::vector<int> a = {1, 2, 3, 4, 5};
std::vector<int> b = std::move(a);  // 移动:b 直接接管 a 的指针,a 被"掏空"
// a 现在是空的,数据的所有权转移给了 b
移动前:   a ──→ [1][2][3][4][5]   (在地址 0x1000)
移动后:   b ──→ [1][2][3][4][5]   (仍指向 0x1000,没有分配新内存)
          a ──→ (空)

关键区别:移动没有分配新内存,只是把指针从 a 换到 b。

1.3 什么时候移动比拷贝划算

对象类型 拷贝代价 移动代价 移动值不值得
intfloat 等基本类型 1 条 mov 指令 1 条 mov 指令 ❌ 一样,没区别
std::vector<int>(1000) 分配内存 + 复制 1000 个元素 交换 3 个指针 ✅ 巨大差别
Eigen 矩阵 MatrixXf(100,100) 分配 10000 个 float + 逐元素复制 交换内部指针 ✅ 巨大差别
std::unique_ptr<T> ❌ 不允许拷贝 ✅ 移动 ✅ 必须移动(unique_ptr 不可拷贝)

2. 工程代码分析(GSRL 中的场景)

2.1 哪些对象适合移动

GSRL 中存在大量"重量级"对象,拷贝它们代价很高:

// KalmanFilter 内部持有 Eigen 矩阵——拷贝 = 全矩阵深拷贝
KalmanFilter<fp32, 3, 1, 0> kf;
kf.predict();  // 这个操作涉及矩阵乘法

// RLS 滤波器同样持有协方差矩阵
RLSFilter4D rls;
rls.update(input, output);
KalmanFilter<fp32, 3, 1, 0> 包含的成员:
  ├── StateVector m_state        (Eigen::Vector<fp32, 3>)
  ├── StateMatrix m_covariance   (Eigen::Matrix<fp32, 3, 3>)
  ├── StateMatrix m_transition   (Eigen::Matrix<fp32, 3, 3>)
  ├── StateMatrix m_processNoise (Eigen::Matrix<fp32, 3, 3>)
  ├── ObsMatrix m_observation    (Eigen::Matrix<fp32, 1, 3>)
  ├── MeasMatrix m_measNoise     (Eigen::Matrix<fp32, 1, 1>)
  ├── ControlMatrix m_control    (Eigen::Matrix<fp32, 3, 0>)
  └── GainMatrix m_gain          (Eigen::Matrix<fp32, 3, 1>)

默认拷贝 → 全部矩阵深拷贝 → 分配内存 + 逐元素复制
移动语义 → 只交换内部指针 → 几乎零开销

2.2 GSRL 中的实际使用

GSRL 依赖的 Eigen 数学库完整支持移动语义。当你在项目中编写类似下面的代码时,移动语义就自动生效了:

// 场景:函数返回一个配置好的滤波器
KalmanFilter2D createPosVelKF(fp32 dt)
{
    KalmanFilter2D kf;  // 临时对象
    kf.setStateTransition(/* F 矩阵 */);
    kf.setObservationMatrix(/* H 矩阵 */);
    return kf;  // ✅ 编译器自动使用移动语义(RVO/移动构造)
                // 不会拷贝整个滤波器!
}

// 场景:将滤波器存入容器
class MultiSensorFusion
{
    std::vector<KalmanFilter2D> filters;

    void addFilter(KalmanFilter2D &&kf)
    {
        filters.push_back(std::move(kf));  // 移动进容器,不拷贝
    }
};

2.3 行为树中的潜在场景

GSRL 的行为树用 memset 初始化子节点数组:

// alg_behavior_tree.hpp:85-88
template <uint8_t MaxChildren>
class BTComposite : public BTNode
{
protected:
    BTNode *m_children[MaxChildren];  // 存储的是指针,不是对象

public:
    BTComposite()
    {
        memset(static_cast<void *>(m_children), 0, sizeof(m_children));
    }
};

这里存的是裸指针(BTNode *),不涉及移动语义。但如果将来想升级成 std::vector<std::unique_ptr<BTNode>>,就必须std::move

// 升级版:用智能指针管理节点生命周期
class BTCompositeV2 : public BTNode
{
protected:
    std::vector<std::unique_ptr<BTNode>> m_children;

public:
    // unique_ptr 不可拷贝,必须用移动
    bool addChild(std::unique_ptr<BTNode> child)
    {
        if (child == nullptr) return false;
        m_children.push_back(std::move(child));  // 移动所有权
        return true;
    }
};

3. 动手写:在你的代码中使用 std::move

3.1 示例1:将滤波器存入容器

结合 GSRL 的 KalmanFilter 使用场景:

#include <vector>
#include <utility>  // std::move 在这里

// 场景:一个类管理多个滤波器
class FusionManager
{
public:
    std::vector<KalmanFilter2D> filters;

    // 添加滤波器:接收右值引用
    void addFilter(KalmanFilter2D &&filter)
    {
        // std::move 将 filter 转成右值,触发移动构造而不是拷贝构造
        filters.push_back(std::move(filter));
    }
};

// 使用
int main()
{
    FusionManager manager;

    // 创建一个配置好的滤波器
    KalmanFilter2D kf = createPosVelKF(0.001f);

    // 移入管理器——不会深拷贝整个滤波器!
    manager.addFilter(std::move(kf));

    // ⚠️ 此时 kf 已经被"掏空",不要再使用它
    // kf.predict(xxx);  // 未定义行为!
}

3.2 示例2:PID 控制器的移动

// 定义一个支持移动语义的 PID 控制器
class SimplePID
{
public:
    struct PIDParam {
        fp32 Kp, Ki, Kd;
        fp32 outputLimit;
        fp32 integralLimit;
    };

private:
    PIDParam m_param;
    fp32 *m_errorHistory;   // 动态分配的误差历史数组
    int m_historySize;

public:
    // 构造函数:分配动态内存
    SimplePID(const PIDParam &param, int historySize = 100)
        : m_param(param), m_historySize(historySize)
    {
        m_errorHistory = new fp32[historySize];
        memset(m_errorHistory, 0, historySize * sizeof(fp32));
    }

    // 移动构造函数:偷走资源,不重新分配
    SimplePID(SimplePID &&other) noexcept
        : m_param(other.m_param)
        , m_errorHistory(other.m_errorHistory)  // 直接接管指针
        , m_historySize(other.m_historySize)
    {
        // 把原对象的指针置空,防止析构时 double-free
        other.m_errorHistory = nullptr;
        other.m_historySize = 0;
    }

    // 移动赋值运算符
    SimplePID &operator=(SimplePID &&other) noexcept
    {
        if (this != &other) {
            delete[] m_errorHistory;              // 释放自己的旧资源
            m_errorHistory = other.m_errorHistory; // 接管对方的资源
            m_historySize = other.m_historySize;
            m_param = other.m_param;
            other.m_errorHistory = nullptr;
            other.m_historySize = 0;
        }
        return *this;
    }

    // 禁止拷贝(或者实现深拷贝)
    SimplePID(const SimplePID &) = delete;
    SimplePID &operator=(const SimplePID &) = delete;
    //  ↑ = delete 是 C++11 语法,显式声明"这个函数不存在"
    //  如果有人尝试拷贝 SimplePID,会在编译期直接报错,而不是运行时崩

    // 析构函数——对象销毁时自动调用,负责清理动态分配的资源
    // 复习:如果 m_errorHistory 在移动时已被置为 nullptr,delete[] nullptr 是安全的(空操作)
    ~SimplePID()
    {
        delete[] m_errorHistory;
    }
};

// 使用
int main()
{
    SimplePID::PIDParam param = {10.0f, 0.1f, 0.0f, 100.0f, 50.0f};
    SimplePID pid1(param);                   // 创建 PID1
    SimplePID pid2(std::move(pid1));         // 移动构造——pid1 的数组被 pid2 接管
    // pid1 不能再用了,它的内部指针已经被置空
}

伪代码——移动过程中发生了什么:

移动前:
  pid1 ──→ m_errorHistory ──→ [0][0][0]...[0]  (100 个 fp32,在堆上)
  pid2 ──→ m_errorHistory ──→ nullptr

SimplePID pid2(std::move(pid1)); 执行后:
  pid1 ──→ m_errorHistory ──→ nullptr            ← 被掏空
  pid2 ──→ m_errorHistory ──→ [0][0][0]...[0]    ← 接管了原 pid1 的数据

没有分配新内存!只是复制了一个指针。

3.3 示例3:常见错误

void badExample()
{
    std::vector<int> data = {1, 2, 3, 4, 5};

    // 错误1:std::move 之后继续使用原对象
    std::vector<int> other = std::move(data);
    // data.push_back(6);  // ❌ data 处于"有效但未定义"状态
                            // 可能崩溃,可能不崩,但一定是错的
    // 原因:vector 内部的数据存在堆上。std::move 后 data 的内部指针已被置空,
    // 此时再访问就相当于对空指针解引用。详见《内存.md》的栈/堆部分。

    // 正确做法:永远假设被 move 的对象已经失效
    // 如果还想用 data,先重新赋值:
    data = {10, 20, 30};    // ✅ 重新赋值后可以正常使用

    // 错误2:试图移动 const 对象
    const std::vector<int> constVec = {1, 2, 3};
    // std::vector<int> v = std::move(constVec);
    // ❌ std::move(constVec) 返回的是 const 右值引用
    // 不会匹配移动构造函数(移动要修改源对象),
    // 而是回退到拷贝构造——搬起石头砸自己的脚
}

4. std::move 本质

std::move 本身不移动任何东西!

它的作用只有一件事:
  把左值强制转换成右值引用 → 让编译器选择移动重载(移动构造/移动赋值)

真正的"移动"操作发生在:
  目标类的移动构造函数 / 移动赋值运算符 中

std::move(x)  ≈  static_cast<T&&>(x)

一句话总结:std::move 是"我想移动这个对象"的意图声明,实际移动由类的移动构造函数完成。


5. 使用原则

原则 说明
大对象用 std::move Eigen 矩阵、std::vectorstd::string
unique_ptr 必须 std::move 它根本不能拷贝
基本类型不要 std::move intfloat 移动 = 拷贝,多写无益
std::move 后不要用原对象 除非你确定它的状态(如重新赋值后)
const 对象不要 std::move 实际会回退成拷贝
返回局部变量时不需要 std::move 编译器自动做 RVO/NRVO

6. 总结

概念 拷贝 移动
操作 把数据逐份复制 把资源所有权转移
内存 分配新空间 不分配
原对象 保持完整 被"掏空"(有效但内容不确定)
int/float 没差别 没差别
vector/Matrix 很慢 很快
unique_ptr(独占所有权的智能指针,不可拷贝) 不允许 必须

作者: Qing | 修改日期: 2026-07-18