右值引用和移动语义

0 阅读31分钟

第一课:函数重载中的 & 与 && 语法解析与匹配规则

理论

在工程代码中,移动语义最直接的落地方式就是在函数参数中使用 && 进行重载。这决定了你的滤波模块是“借用”数据还是“霸占”数据。

语法规则与匹配优先级

当你在函数参数中写下 T& 和 T&& 时,编译器的重载决议遵循极其严格的优先级顺序:

  • T&(左值引用):只能绑定到左值(有名字、有明确内存地址的对象,如变量)。不能绑定右值。
  • T&&(右值引用):只能绑定到右值(临时对象、字面量、std::move() 的返回值)。不能绑定左值。
  • const T&(常量左值引用):万能但只读。既能绑定左值,也能绑定右值(此时右值会被隐式转换为常量)。

匹配优先级口诀:精准匹配胜过隐式转换。

传左值 -> 优先匹配 T&。如果没有 T&,退而求其次匹配 const T&。
传右值 -> 优先匹配 T&&。如果没有 T&&,退而求其次匹配 const T&。

核心语法

  1. 接收 左值引用 参数的函数声明:&

例如:(const 不是一定需要的,这里只是为了防止修改)

// 重载 1:接收左值,只读不窃取
void preprocessImuData(const std::vector<double> &data)
  1. 接收 右值引用 参数的函数声明:&&

例如:

// 重载 2:接收右值,准备窃取资源
void preprocessImuData(std::vector<double> &&data)
// 如果要在本函数内把它转移给其他对象,必须用 std::move(data)
// 但是如果是return又不需要写move,因为编译器会自己优化

Code

// 假设你有一个 IMU 预处理模块,
// 对于从传感器实时读出来的缓存(左值),你只想读取;
// 对于通过数学变换临时生成的一大段仿真数据(右值),
// 你想要直接窃取它的内存。
#include <iostream>
#include <vector>

// 重载 1:接收左值,只读不窃取
void preprocessImuData(const std::vector<double> &data)
{
    std::cout << "[左值引用版本] 接收到持久数据,大小: " << data.size()
              << ",仅做读取滤波处理。" << std::endl;
}

// 重载 2:接收右值,准备窃取资源
void preprocessImuData(std::vector<double> &&data)
{
    std::cout << "[右值引用版本] 接收到临时/将亡数据,大小: " << data.size()
              << ",准备转移其底层内存!" << std::endl;
    // 注意:data 在这里有名字,按语法它是左值。
    // 如果要在本函数内把它转移给其他对象,必须用 std::move(data)。
    // 补充,外界调用这个函数的时候传入的是右值引用类型,
    // 但是到了函数体内部,它有了名字data就是左值!
    // 想把左值传出给外部对象,就需要move,不然离开作用域就销毁了!
}

int main()
{
    // 1. 左值:真实的传感器数据缓冲区 (直接给对象名,就是左值)
    std::vector<double> real_sensor_data = {1.0, 2.0, 3.0, 4.0, 5.0};
    preprocessImuData(real_sensor_data); // 精准命中 重载 1 

    // 2. 右值:纯右值,临时构造的巨大仿真数据块 (copy方式产生右值)
    preprocessImuData(std::vector<double>(10000, 0.5)); // 精准命中 重载 2
    // 还可以这样,但是工程上不推荐,因为深拷贝性能开销大
    // preprocessImuData(std::vector<double>(real_sensor_data)); // 构造函数拷贝
    // preprocessImuData(std::vector<double>{real_sensor_data}); // 初始化列表
    
    // 3. 右值:通过 std::move 将左值强制转换为右值 (move方式产生右值)
    preprocessImuData(std::move(real_sensor_data)); // 精准命中 重载 2

    return 0;
}

第二课:类定义中的移动构造函数与 noexcept 的生死线

理论

在第一课中,我们实现了函数参数层面的“劫持”。但在真正的工程中,你的 IMU 数据不是散落的 vector,而是被封装在类里的。当你在多线程间传递对象,或者把对象塞进 std::vector 进行扩容时,如果触发了 默认的拷贝构造 ****,你的内存带宽会瞬间被打满。

这时候,你需要手写移动构造函数,自己实现“剪切”。

核心语法与签名

移动构造函数的标准签名如下:

ClassName(ClassName&& other) noexcept { ... }

这里有两个极其重要的语法点:

  • ClassName&& other:接收一个即将销毁的同类型对象。注意,other 在函数体内是个左值。
  • noexcept极度重要 ):向编译器保证此函数绝对不抛出异常。如果缺了它,std::vector 在扩容时会认为你的移动构造是不安全的,从而退化使用拷贝构造!你辛辛苦苦写的移动语义直接废掉。

Code

// 代码示例:简易 IMU 缓冲区
// 假设我们有一个管理底层原生指针的类
// (为了看清本质,我们不用 vector,用原始指针):

#include <iostream>
class SimpleImuBuffer
{
private:
    double *data_ptr_;
    size_t size_;

public:
    // 普通构造:分配内存
    SimpleImuBuffer(size_t size) : size_(size), data_ptr_(new double[size]) {}

    // 析构函数:释放内存
    ~SimpleImuBuffer()
    {
        delete[] data_ptr_;
        // 工程常识:delete nullptr 是绝对安全的,这也是为什么移动后必须置空的原因!
    }

    // 【重点】移动构造函数 也就是move
    SimpleImuBuffer(SimpleImuBuffer &&other) noexcept
        : data_ptr_(other.data_ptr_), size_(other.size_)
    {
        // 第一步:浅拷贝(窃取指针)
        // 第二步:将源对象“掏空”(置空处理)
        // 如果不置空,当 other 析构时,会 delete[] 我们刚偷过来的内存,导致 double free!
        other.data_ptr_ = nullptr;
        other.size_ = 0;
    }

    // 这两行一起禁用拷贝构造(实际 IMU 大内存对象通常不允许拷贝)
    // 这行禁用拷贝构造(创建时) SimpleImuBuffer a = b; 或 SimpleImuBuffer a(b);
    SimpleImuBuffer(const SimpleImuBuffer &) = delete;
    // 这行禁用拷贝赋值(创建之后) a = b;
    SimpleImuBuffer &operator=(const SimpleImuBuffer &) = delete;
};

第三课:类定义中的移动赋值运算符与自赋值防御

理论

移动构造函数是在一块“白纸”上建房子,而移动赋值运算符则是要拆掉自己的旧房子,再接管别人的房子。这就带来了更复杂的工程细节。

核心语法与铁律

移动赋值运算符的标准签名:

ClassName& operator=(ClassName&& other) noexcept;

与移动构造相比,它多出了两条必须遵守的工程铁律:

  • 铁律一:自赋值检查。 必须在开头写 if (this != &other) return *this;。在手写移动赋值运算符时,如果缺少了自赋值检查 if (this != &other),后果极其灾难,会导致野指针(Dangling Pointer)和数据彻底丢失!。防御性编程是高级工程师的基本素养。
  • 铁律二:先释放自身旧资源。 在窃取 other 的指针之前,必须先把 this 当前持有的内存 delete[] 掉,否则这块内存就永远丢失了。

Code

ImuDataFrame& ImuDataFrame::operator=(ImuDataFrame&& other) noexcept {
    // 铁律一:自赋值检查
    if (this != &other) {
        // 铁律二:先拆自己的旧房子
        delete[] accel_;
        delete[] gyro_;
        delete timestamp_;

        // 窃取对方的资源
        accel_ = other.accel_;
        gyro_ = other.gyro_;
        timestamp_ = other.timestamp_;

        // 将对方掏空
        other.accel_ = nullptr;
        other.gyro_ = nullptr;
        other.timestamp_ = nullptr;
    }
    // 返回自身的引用,以支持 a = b = c 的链式调用
    return *this;
}

第二课和第三课综合

核心语法

class SimpleImuBuffer {
public:
    // 同时禁用,彻底禁止拷贝语义
    SimpleImuBuffer(const SimpleImuBuffer&) = delete;
    SimpleImuBuffer& operator=(const SimpleImuBuffer&) = delete;
    
    // 通常还要启用移动(大对象常用移动语义)
    SimpleImuBuffer(SimpleImuBuffer&&) = default;
    SimpleImuBuffer& operator=(SimpleImuBuffer&&) = default;
};

解释:

  1. SimpleImuBuffer(const SimpleImuBuffer&) = delete; // 禁用 拷贝构造(Copy Constructor)

这一行禁用了 SimpleImuBuffer b = a;

但是你可以这样:

SimpleImuBuffer a;

SimpleImuBuffer b;

b = a; // 移动赋值!

因为左右都是 & 左值引用类型,所以

会匹配函数 SimpleImuBuffer& operator=(const SimpleImuBuffer&)

铁律:编译器不会自作主张把左值当右值用。除非你显式写出 SimpleImuBuffer b = std::move(a);,否则左值永远匹配拷贝。

  1. SimpleImuBuffer& operator=(const SimpleImuBuffer&) = delete; // 禁用左值移动赋值

这一行禁用了 b = a

但是没有禁用:

SimpleImuBuffer a;

SimpleImuBuffer b = a; // 移动构造

因为这一行的 b 是刚刚新建的也就是构造的,a 是 & 左值引用类型,

这会匹配函数 SimpleImuBuffer(const SimpleImuBuffer&)

  1. 性能优化就使用 move

SimpleImuBuffer(SimpleImuBuffer&&) 就匹配 SimpleImuBuffer b = std::move(a);

SimpleImuBuffer& operator=(SimpleImuBuffer&&) 就匹配 b = std::move(a)

= default 是启用,= delete 是禁用,不写则可能被禁用

你可以选用默认实现方式,那么就写 = default

但是你也可以手动实现,也就是自己重载这两个函数,

注意:一定要注意 声明时就要 写上 noexcept 防止不被调用!

(就是你声明的时候,后缀要么= default 要么 noexcept

  1. 实现细节之:SimpleImuBuffer(SimpleImuBuffer&& other) noexcept;

三步走:1.写 noexcept;2.写 : 初始化列表;3.写函数体旧指针置空。

例如:

    SimpleImuBuffer(SimpleImuBuffer &&other) noexcept 		// 1. noexcept
        : data_ptr_(other.data_ptr_), size_(other.size_) 	// 2. 初始化列表窃取
    {
        other.data_ptr_ = nullptr; 	// 3. 置空防止原来的析构函数二次释放
        other.size_ = 0;			// 配套置空
    }
  1. 实现细节之:SimpleImuBuffer& operator=(SimpleImuBuffer&& other) noexcept;

实现步骤:

1- 需要先判断 &other != this (防御性编程)

2- 然后先释放 this 的资源,避免内存泄漏

3- 接着接手 other 的资源,实现转义语义

4- 最后还要把 other 置空,防止二次释放

5- 最最后,返回 *this!以支持连续调用(a = b = c)

第二/三课补充:用 std::exchange 消灭手写置空代码 (C++14)

在手写移动构造时,你的做法是标准的三步走:浅拷贝 -> 掏空旧对象。

accel_ = other.accel_;
other.accel_ = nullptr;

在现代 C++ 中,这种分两行的写法不够优雅(而且多线程下非原子操作可能被打断)。标准库提供了一个专门为移动语义打造的神器:std::exchange(在 <utility> 头文件中)。它能把“窃取值”和“赋新值(如 nullptr)”合并成一行,这是顶级 C++ 开源库里的标准写法:

// 移动构造的初始化列表中直接一步到位:
ImuDataFrame(ImuDataFrame&& other) noexcept
: accel_(std::exchange(other.accel_, nullptr)), // 把 other.accel_ 设为 nullptr,并将其旧值赋给当前的 accel_
timestamp_(std::exchange(other.timestamp_, nullptr)) {}

Code(建议认真阅读,但请不要在工程中这么做)

#include <iostream>

class ImuDataFrame
{
private:
    double *accel_ = nullptr;      // 三轴加速度
    double *gyro_ = nullptr;       // 三轴角速度
    int64_t *timestamp_ = nullptr; // 时间戳

public:
    ImuDataFrame(double ax = 1.0, double ay = 2.0, double az = 3.0,
                 double gx = -1.0, double gy = -2.0, double gz = -3.0,
                 int64_t timestamp = -1); // 重构默认构造函数,为了实现RAII
    ~ImuDataFrame();                      // 重构默认析构函数,为了实现RAII

    // 禁用 copy语义的 "=" (小细节,这俩有const)
    ImuDataFrame(const ImuDataFrame &other) = delete;            // 1. 禁用拷贝构造
    ImuDataFrame &operator=(const ImuDataFrame &other) = delete; // 2. 禁用拷贝赋值

    // 重载 move语义的 "=" (小细节,这俩没有const,但是有noexcept)
    ImuDataFrame(ImuDataFrame &&other) noexcept;            // 3. 重载右值引用的 移动构造函数
    ImuDataFrame &operator=(ImuDataFrame &&other) noexcept; // 4. 重载右值引用的 移动赋值运算符

    void display();
};

// 构造函数,申请内存 RAII
ImuDataFrame::ImuDataFrame(double ax, double ay, double az,
                           double gx, double gy, double gz,
                           int64_t timestamp)
{
    accel_ = new double[3]{ax, ay, az};
    gyro_ = new double[3]{gx, gy, gz};
    timestamp_ = new int64_t(timestamp);
    std::cout << "[Class info] 对象构造! 内存申请完毕!" << std::endl;
}

// 析构函数,释放内存 RAII
ImuDataFrame::~ImuDataFrame()
{
    delete[] accel_;
    delete[] gyro_;
    delete timestamp_;
    std::cout << "[Class info] 对象自动析构! 内存释放完毕!" << std::endl;
}

// 右值引用的移动构造函数,三步走: 1.noexcept 2.":"初始化列表浅拷贝 3.清除旧对象(避免双重释放)
ImuDataFrame::ImuDataFrame(ImuDataFrame &&other) noexcept
    : accel_(other.accel_), gyro_(other.gyro_), timestamp_(other.timestamp_)
{
    other.accel_ = nullptr;
    other.gyro_ = nullptr;
    other.timestamp_ = nullptr;
    std::cout << "[Class info] 右值引用的移动构造函数被调用!" << std::endl;
}

// 右值引用的移动赋值运算符,这里请注意 命名空间写在哪里!(ImuDataFrame::作用于函数名)
ImuDataFrame &ImuDataFrame::operator=(ImuDataFrame &&other) noexcept
{
    // 注意这个不是构造函数,不能使用初始化列表!

    // 1. 防御性编程,先确保other不是自己!
    if (this == &other)
    {
        std::cout << "[warning] 移动赋值运算符检查到尝试进行自我交换!" << std::endl;
        return *this;
    }

    std::cout << "[Class info] 右值引用的移动赋值运算符被调用!" << std::endl;

    // 2. 旧的资源先释放(不释放会内存泄漏!)
    delete[] accel_;
    delete[] gyro_;
    delete timestamp_;

    // 3. 浅拷贝(实现语义)
    accel_ = other.accel_;
    gyro_ = other.gyro_;
    timestamp_ = other.timestamp_;

    // 4. 再置空处理(不置空会二次释放!)
    other.accel_ = nullptr;
    other.gyro_ = nullptr;
    other.timestamp_ = nullptr;

    // 5. 返回
    return *this;
}

void ImuDataFrame::display()
{
    if (timestamp_)
    {
        std::cout << "[data] timestamp_ = " << *timestamp_ << std::endl;
    }
    else
    {
        std::cout << "[warning] timestamp_ is nullptr!" << std::endl;
    }

    if (accel_)
    {
        for (int i = 0; i < 3; i++)
        {
            std::cout << "[data] accel_[" << i << "] = " << accel_[i] << std::endl;
        }
    }
    else
    {
        std::cout << "[warning] accel_ is nullptr!" << std::endl;
    }

    if (gyro_)
    {
        for (int i = 0; i < 3; i++)
        {
            std::cout << "[info] gyro_[" << i << "] = " << gyro_[i] << std::endl;
        }
    }
    else
    {
        std::cout << "[warning] gyro_ is nullptr!" << std::endl;
    }
}

auto main() -> int
{
    // // 1. 创建一个原始对象 frame1
    // ImuDataFrame frame1(0.1, -0.1, 0.2, 0.3, 0.4, -9.8, 12345678);

    // // 2. 使用 std::move(frame1) 移动构造一个新对象 frame2
    // ImuDataFrame frame2(std::move(frame1));

    // // 3. 打印 frame2 中的数据以验证窃取成功
    // std::cout << "[info] frame2: " << std::endl;
    // frame2.display();

    // // 4. 打印 frame1 中的指针地址(应当是 0,即 nullptr),证明源对象被安全掏空
    // std::cout << "[info] frame1: " << std::endl;
    // frame1.display();

    // // 5. 确保程序结束时正常退出,没有 double free
    // // 这个通过查看运行时的终端输出,看析构函数是否被正确调用

    // 1. 创建 frame1
    ImuDataFrame frame1(0.1, -0.1, 0.2, 0.3, 0.4, -9.8, 12345678);

    // 2. 用 frame1 移动构造 frame2(此时 frame1 应该被掏空)
    ImuDataFrame frame2(std::move(frame1));

    // 3. 创建一个临时右值 ImuDataFrame(9.9, 8.8, 7.7, -1.0, -2.0, -3.0, 999)
    // 4. 使用移动赋值,把这个临时右值赋给已经被掏空的 frame1
    // 5. 打印 frame1 的数据,验证它成功获得了新数据(即“拆旧建新”成功)
    std::cout << "检查move移出之后的frame1" << std::endl;
    frame1.display();
    frame1 = ImuDataFrame(9.9, 8.8, 7.7, -1.0, -2.0, -3.0, 999);
    std::cout << "检查move移入之后的frame1" << std::endl;
    frame1.display();

    // 6. 创建 frame3,执行一次自赋值操作:frame3 = std::move(frame3);
    ImuDataFrame frame3;
    frame3 = std::move(frame3);

    // 7. 打印 frame3 的数据,验证自赋值检查保护了它没有被掏空
    frame3.display();

    return 0;
}

第四课 现代 C++ 的零成本抽象——消灭手写的 Rule of Five

在真实的自动驾驶或机器人 IMU 算法工程中,没人会在类里写 newdelete[] 和手动的 noexcept 移动构造。

这叫“手动管理内存”,是 C++98 时代的古老做派。(学习时为了理解底层才这么做)

C++11 引入了移动语义,配合标准库容器,诞生了一个终极法则:零成本抽象

理论

1. 核心原理:编译器的“全自动挡”

只要你把类的成员从原生指针换成 std::vectorstd::unique_ptr,你可以把之前写的所有 Rule of Five 代码全部删掉!

为什么?因为:

  • std::vectorstd::unique_ptr 在标准库内部已经完美实现了移动构造和移动赋值(包括加了 noexcept)。
  • 当你不在类里写这些函数时,C++ 编译器会自动生成默认的构造/析构/赋值/移动函数。
  • 编译器自动生成的移动函数,会自动去调用每个成员变量(如 vector)自己的移动函数。
  • 结果:你的类自动获得了最高效的指针窃取能力,代码量是 0,性能与手写裸指针一模一样。

2. 代码对比

以前(苦逼的手动挡):

double* accel_;
~ImuDataFrame() { delete[] accel_; } // 忘了就泄漏
ImuDataFrame(ImuDataFrame&& o) noexcept : accel_(o.accel_) { o.accel_ = nullptr; } // 繁琐

现在(现代 C++ 的自动挡):

std::vector<double> accel_;
// 不需要写析构!不需要写移动!编译器全自动搞定!

补充:Rule of Zero 的致命暗坑(极其重要!)

“如果用了 std::vector,就不需要手写移动函数了”。但是,C++11 埋了一个非常隐蔽的坑: 只要你显式声明了析构函数(哪怕是空的、或者是 = default ),编译器就会立刻罢工,不再自动为你生成移动构造和移动赋值函数!

反面教材

class ImuDataFrame {
std::vector<double> accel_;
public:
~ImuDataFrame() {} // 只要你写了这行!
};
// 此时你执行 ImuDataFrame b = std::move(a); 
// 编译器发现没有移动构造,会退化成“深度拷贝”!性能直接爆炸!
  • 正确做法:要么什么都不写(完全遵守 Rule of Zero),要么如果你非得写析构函数,就必须把移动操作重新声明回来: ImuDataFrame(ImuDataFrame&&) = default;

Code(工程实现推荐实例!认真阅读!)

#include <iostream>
#include <vector>
#include <memory>

class ImuDataFrame
{
private:
    std::vector<double> accel_;          // 三轴加速度
    std::vector<double> gyro_;           // 三轴角速度
    std::unique_ptr<int64_t> timestamp_; // 时间戳

public:
    ImuDataFrame(double ax = 1, double ay = 2, double az = 3,
                 double gx = -1, double gy = -2, double gz = -3,
                 int64_t timestamp = -1); //  默认构造函数

    // 显式声明移动操作(禁用拷贝,因为 unique_ptr 不可拷贝)
    ImuDataFrame(const ImuDataFrame &) = delete;
    ImuDataFrame &operator=(const ImuDataFrame &) = delete;
    
    ImuDataFrame(ImuDataFrame &&) = default;            // 移动构造
    ImuDataFrame &operator=(ImuDataFrame &&) = default; // 移动赋值

    void display() const; // 不修改成员就加 const
};

// 构造函数,只是负责初始化值
ImuDataFrame::ImuDataFrame(double ax, double ay, double az,
                           double gx, double gy, double gz,
                           int64_t timestamp)
    : accel_{ax, ay, az}, // 初始化列表
      gyro_{gx, gy, gz},  // 智能指针使用make_是最佳实践
      timestamp_(std::make_unique<int64_t>(timestamp))
{
    // 使用构造函数的初始化列表,比在函数体里写,不会创建临时对象,性能更优!
    std::cout << "[Class info] 对象构造! 初始化完毕!" << std::endl;
}

void ImuDataFrame::display() const
{
    if (timestamp_)
    {
        std::cout << "[data] timestamp_ = " << *timestamp_ << std::endl;
    }
    else
    {
        std::cout << "[warning] timestamp_ is nullptr!" << std::endl;
    }

    if (!accel_.empty())
    {
        // 用迭代器比用for(int i)更好!
        // 而且尤其注意是 const &
        std::cout << "[data] accel: ";
        for (const auto &val : accel_)
        {
            std::cout << val << " ";
        }
        std::cout << std::endl;
    }
    else
    {
        std::cout << "[warning] accel_ is empty!" << std::endl;
    }

    if (!gyro_.empty())
    {
        std::cout << "[data] gyro: ";
        for (const auto &val : gyro_)
        {
            std::cout << val << " ";
        }
        std::cout << std::endl;
    }
    else
    {
        std::cout << "[warning] gyro_ is empty!" << std::endl;
    }
}

auto main() -> int
{
    // 测试移动构造
    ImuDataFrame frame1(0.1, -0.1, 0.2, 0.3, 0.4, -9.8, 12345678);

    std::cout << "\n=== 移动构造 frame2 ===" << std::endl;
    ImuDataFrame frame2(std::move(frame1));

    std::cout << "\nframe2 (移入后):" << std::endl;
    frame2.display();

    std::cout << "\nframe1 (移出后):" << std::endl;
    frame1.display(); // ✅ 现在 timestamp_ 为 nullptr,accel/gyro 被移走

    // 测试移动赋值
    std::cout << "\n=== 移动赋值给 frame1 ===" << std::endl;
    frame1 = ImuDataFrame(9.9, 8.8, 7.7, -1.0, -2.0, -3.0, 999);

    std::cout << "\nframe1 (重新赋值后):" << std::endl;
    frame1.display();

    // 测试自赋值(编译器生成的 default 移动赋值有保护)
    std::cout << "\n=== 自赋值测试 ===" << std::endl;
    ImuDataFrame frame3(1, 2, 3, 4, 5, 6, 100);
    frame3.display();

    frame3 = std::move(frame3); // 有保护,不会崩溃

    std::cout << "\nframe3 自赋值后:" << std::endl;
    frame3.display(); // 未指明状态,可能空也可能有值

    return 0;
}

第五课:隐式移动与返回值优化(RVO/NRVO 的底层真相)

理论

原则:相信编译器,不要 std::move 返回值!

RVO:return Type{}; — 无名临时对象优化
NRVO:return var; — 具名变量优化(C++17 前可选,后更稳定)
原则:相信编译器,不要 std::move 返回值!

在前四课中,我们一直在用 std::move() 来“显式”地触发移动。但移动语义最强大的地方,在于它的隐式触发

在 C++98 时代,有一个著名的毒瘤设计:坚决不要返回局部对象。老一代 C++ 程序员会被教导写成这样:

// C++98 的恐惧
void generateImuData(ImuDataFrame& out_frame) { 
    // 填充 out_frame
}

因为他们害怕 return 局部对象会触发一次昂贵的深拷贝。

到了现代 C++,这个恐惧被彻底终结。编译器会像幽灵一样,在背后帮你做极致的性能优化。

缩写全称中文
RVOReturn Value Optimization返回值优化
NRVONamed Return Value Optimization具名返回值优化

RVO/NRVO核心思想:直接在调用者的内存位置构造返回值,省略拷贝/移动操作

RVO:返回临时对象(无名)

std::vector<double> createVector() {
    return std::vector<double>{1.0, 2.0, 3.0};  // 返回临时对象(无名)
}

int main() {
    auto vec = createVector();  // ✅ RVO:直接在 vec 的内存位置构造
    return 0;
}

优化效果:

  • 理论上:构造临时对象 → 移动/拷贝给 vec → 析构临时对象
  • RVO 后:直接在 vec 的位置构造,零开销

NRVO:返回具名局部变量

std::vector<double> createVector() {
    std::vector<double> result{1.0, 2.0, 3.0};  // 具名局部变量
    return result;  // ✅ NRVO:直接在调用者位置构造 result
}

int main() {
    auto vec = createVector();  // 无拷贝/移动
    return 0;
}

NRVO 比 RVO 更难,因为编译器要推断 result 就是最终返回值。


1. 编译器的两道免死金牌

当你在函数里写 return local_obj; 时,编译器会按顺序尝试两招:

  • 第一招:RVO / NRVO (返回值优化)。
    编译器直接把局部变量 local_obj 的内存地址,安排在调用者预留好的内存上。连移动构造函数都不调用!直接 zero cost(零成本)!
  • 第二招:隐式移动。
    如果你的代码逻辑太复杂(比如多个分支返回不同的局部变量),导致编译器无法执行 RVO。C++11 标准规定:编译器必须自动把 return local_obj; 当作 return std::move(local_obj); 来处理。 它会自动调用移动构造,绝不可能退化成拷贝构造。

2. 核心痛点:如何观察“隐式移动”?

把移动构造写成了 = default。这导致:当编译器执行移动构造时,你完全看不到任何日志,你会怀疑它到底有没有移动。

为了验证“隐式移动”,我们必须把移动构造的手写实现拿回来,仅仅是为了打印一行日志。

RVO 优化实例

auto parseCanFrame(const std::string &raw_data) -> std::tuple<bool, int, std::array<double, 3>>
{
    if (raw_data.length() > 5)
    {
        return {true, 0x123, {1.5, 2.5, 3.5}};
    }
    return {false, -1, {0, 0, 0}};
}

但是如果你像下面这么写,虽然代码逻辑一样,但是将无法触发返回值优化!(极其经典的性能陷阱!)

auto parseCanFrame(const std::string &raw_data) -> std::tuple<bool, int, std::array<double, 3>>
{
    std::tuple<bool, int, std::array<double, 3>> res;
    if (raw_data.length() > 5)
        res = {true, 0x123, {1.5, 2.5, 3.5}};
    else
        res = {false, -1, {0, 0, 0}};
    return res;
}

RVO 的前提是:直接 return 一个临时对象。你提前声明了 res,就亲手扼杀了 RVO,强制编译器产生了无意义的默认构造和赋值操作。

正确的心法是:让变量在 return 语句中“就地诞生”。

Code

// 结论:如果调用工厂函数的时候看到移动构造日志 = RVO 失败,退化为隐式移动。

#define TEST_NRVO // 请仅在观察NRVO的时候取消注释

#include <iostream>
#include <vector>
#include <memory>

class ImuDataFrame
{
private:
    std::vector<double> accel_;          // 三轴加速度
    std::vector<double> gyro_;           // 三轴角速度
    std::unique_ptr<int64_t> timestamp_; // 时间戳

public:
    ImuDataFrame(double ax = 1, double ay = 2, double az = 3,
                 double gx = -1, double gy = -2, double gz = -3,
                 int64_t timestamp = -1); //  默认构造函数

    // 显式声明移动操作(禁用拷贝,因为 unique_ptr 不可拷贝)
    ImuDataFrame(const ImuDataFrame &) = delete;
    ImuDataFrame &operator=(const ImuDataFrame &) = delete;

    ImuDataFrame(ImuDataFrame &&other) noexcept;            // 移动构造
    ImuDataFrame &operator=(ImuDataFrame &&other) noexcept; // 移动赋值

    // ImuDataFrame(ImuDataFrame &&) = default; // 移动构造
    // ImuDataFrame &operator=(ImuDataFrame &&) = default; // 移动赋值

    void display() const; // 不修改成员就加 const
};

//  工厂函数声明
ImuDataFrame createImuFrame(bool use_high_precision);

// 构造函数,只是负责初始化值
ImuDataFrame::ImuDataFrame(double ax, double ay, double az,
                           double gx, double gy, double gz,
                           int64_t timestamp)
    : accel_{ax, ay, az}, // 初始化列表
      gyro_{gx, gy, gz},  // 智能指针使用make_是最佳实践
      timestamp_(std::make_unique<int64_t>(timestamp))
{
    // 使用构造函数的初始化列表,比在函数体里写,不会创建临时对象,性能更优!
    std::cout << "[Class info] 对象构造! 初始化完毕!" << std::endl;
}

// 移动构造
ImuDataFrame::ImuDataFrame(ImuDataFrame &&other) noexcept
    : accel_(std::move(other.accel_)),
      gyro_(std::move(other.gyro_)),
      timestamp_(std::move(other.timestamp_))
{
    std::cout << "[Class info] 移动构造被调用!" << std::endl;
}

// 移动赋值
ImuDataFrame &ImuDataFrame::operator=(ImuDataFrame &&other) noexcept
{
    if (this == &other)
    {
        std::cout << "[warning] 移动赋值被调用,但是你在尝试自交换!不予执行!" << std::endl;
        return *this;
    }
    std::cout << "[Class info] 移动赋值被调用!" << std::endl;

    accel_ = std::move(other.accel_);
    gyro_ = std::move(other.gyro_);
    timestamp_ = std::move(other.timestamp_);
    return *this;
}

void ImuDataFrame::display() const
{
    if (timestamp_)
    {
        std::cout << "[data] timestamp_ = " << *timestamp_ << std::endl;
    }
    else
    {
        std::cout << "[warning] timestamp_ is nullptr!" << std::endl;
    }

    if (!accel_.empty())
    {
        // 用迭代器比用for(int i)更好!
        // 而且尤其注意是 const &
        std::cout << "[data] accel: ";
        for (const auto &val : accel_)
        {
            std::cout << val << " ";
        }
        std::cout << std::endl;
    }
    else
    {
        std::cout << "[warning] accel_ is empty!" << std::endl;
    }

    if (!gyro_.empty())
    {
        std::cout << "[data] gyro: ";
        for (const auto &val : gyro_)
        {
            std::cout << val << " ";
        }
        std::cout << std::endl;
    }
    else
    {
        std::cout << "[warning] gyro_ is empty!" << std::endl;
    }
}

// 工厂函数定义
ImuDataFrame createImuFrame(bool use_high_precision)
{
#ifdef TEST_NRVO
    // 此时就算是调用了工厂函数,你也无法看到调用了移动构造
    ImuDataFrame high_res_frame(0.01, 0.02, 0.03, -0.01, -0.02, -0.03, 123456);
    return high_res_frame;
#else
    if (use_high_precision)
    {
        ImuDataFrame high_res_frame(0.01, 0.02, 0.03, -0.01, -0.02, -0.03, 123456);
        return high_res_frame;
    }
    else
    {
        ImuDataFrame low_res_frame(0.1, 0.2, 0.3, -0.1, -0.2, -0.3, 123456);
        return low_res_frame;
    }
#endif
}

auto main() -> int
{
    std::cout << "=== 测试 RVO/隐式移动 ===" << std::endl;

    std::cout << "=== 测试工厂函数 ===" << std::endl;

    auto frame = createImuFrame(true); // 结果显示: 构造器+移动构造

    frame.display(); // 测试工厂函数

    std::cout << "=== 测试移动 ===" << std::endl;

    auto f1 = createImuFrame(false); // 结果显示: 构造器+移动构造
    auto f2 = std::move(f1);         // 结果显示: 只用了移动构造

    f1.display();
    f2.display();

    std::cout << "=== 测试自移动 ===" << std::endl;

    auto f3 = ImuDataFrame(0, 0, 0); // 结果显示: 只用了构造器
    f3 = std::move(f3);              // 显示了移动赋值自交换!
    f3.display();                    // 数据不变!

    return 0;
}

第六课:noexcept 移动的生死局——为什么 STL 会背叛你

理论

在移动构造和移动赋值后面加上 noexcept。你可能以为这只是个“表示我不会抛异常”的礼貌性注释。

错。在 C++ 中,noexcept 是用来榨干性能的武器,不加它,你的移动语义就是一具空壳。

1. 编译器的“背叛”逻辑

考虑 std::vector。当 vector 容量满了,你再 push_back 时,它会去申请一块更大的内存,然后把旧内存里的元素 搬(移动) 到新内存里,最后销毁旧内存。

如果在搬运过程中,某个元素的移动构造函数抛出了异常怎么办?
旧内存里的元素已经被搬走了一半(变成了半残废状态),新内存也坏了。STL 无法保证数据的一致性!

因此,C++ 标准委员会给 std::vector 植入了极其冷酷的底层代码逻辑:

  • 如果元素的移动构造是 noexcept 的 -> 使用移动(快)。
  • 如果元素的移动构造不是 noexcept 的,但元素的拷贝构造可用 -> 退化使用拷贝(慢,但安全)。
  • 如果元素的移动构造不是 noexcept,且拷贝构造被你 = delete 了 -> 直接拒绝编译!报错!

你的 ImuDataFrame 刚好禁用了拷贝。这就意味着,如果你敢删掉移动构造的 noexceptstd::vector 连装都装不下它。

第七课:消灭堆分配与魔法数字 —— std::optional 的状态强类型(C++17)

理论

在前几课中,为了表达“时间戳可能无效”这个状态,我们使用了 std::unique_ptr<int64_t> timestamp_
这是一个严重的过度设计。为了存一个 8 字节的整数,我们竟然在堆上 new 了一块内存,还要维护指针的判空!在自动驾驶高频的 IMU 中断里这么干,内存分配器的开销会拖垮实时性。

在 C++17 之前,程序员喜欢用“魔法数字”(比如默认传 -1)来代表无效状态。这极不优雅,因为 -1 本身可能也是一个合法的时间戳。

C++17 引入了 std::optional。它是一个“可能在里面装了个对象,也可能什么都没装”的包装器。最关键的是:它完全没有堆内存分配,大小等于 T 本身加上一个 bool 标志位。

1. 核心痛点与解法

  • 痛点:表达“函数可能返回有效数据,也可能返回失败”时,以前用输出参数、或者抛异常、或者返回裸指针 nullptr
  • 解法:让函数返回 std::optional<T>。没有值时返回 std::nullopt。调用方必须用 if (opt.has_value()) 显式检查,否则编译器不让你拿到里面的值。

第七课补充:std::optional 的优雅取值法 .value_or()

你提到用 if (opt.has_value()) 检查,这很对。但在实际工程中,我们经常遇到这种逻辑:“如果传感器有传时间戳就用传感器的,如果没有,就用系统当前时间”。 用 has_value() 要写四五行 if-else,而 std::optional 提供了一个极其优雅的接口 .value_or(),一句话搞定:

// 假设 getSensorTimestamp() 返回 std::optional<int64_t>
int64_t current_ts = getSensorTimestamp().value_or(getSystemTime());
// 语义极其清晰,彻底消灭分支判断!

第八课:共享所有权与幽灵引用 —— shared_ptr 与 weak_ptr 的生死较量

在前面七节课里,我们都是 unique_ptr 的狂热信徒:“谁创建,谁销毁,绝对不共享”。

但在真实的工程中,有时候你就是无法确定对象该在什么时候死。

比如自动驾驶架构:一个“传感器数据中枢”和一个“视觉处理节点”。中枢需要向节点推送数据(中枢持有节点的指针),节点处理完异常时需要往中枢写日志(节点持有中枢的指针)。生命周期互相纠缠,谁也不能独占对方。

这时候 unique_ptr 就失效了,我们需要 std::shared_ptr(共享所有权的智能指针)。它通过内部的引用计数来管理内存:有多少个 shared_ptr 指向这块内存,计数就是几。计数降为 0,内存才释放。

致命陷阱:循环引用

shared_ptr 有一个极其恐怖的副作用:循环引用导致的内存泄漏。

如果 A 持有 B 的 shared_ptr,B 又持有 A 的 shared_ptr。A 的计数因为有 B 指着,永远不为 0;B 的计数因为有 A 指着,也永远不为 0。两者死锁,内存永远泄漏,析构函数永远不会执行。

为了打破这个死局,C++ 引入了 std::weak_ptr(弱指针)。它像一个旁观者,指向对象,但绝对不增加引用计数。需要用的时候,通过 .lock() 方法临时“提升”为 shared_ptr 用一下,用完马上释放。

实例:

#include <iostream>
#include <memory>
class VisionNode;
class SensorHub
{
private:
    std::shared_ptr<VisionNode> node_;
public:
    SensorHub() { std::cout << "SensorHub 被构造!" << std::endl; }
    ~SensorHub() { std::cout << "SensorHub 被析构!" << std::endl; }
    void bindNode(std::shared_ptr<VisionNode> node) { node_ = node; }
};
class VisionNode
{
private:
    std::weak_ptr<SensorHub> hub_;
public:
    VisionNode() { std::cout << "VisionNode 被构造!" << std::endl; }
    ~VisionNode() { std::cout << "VisionNode 被析构!" << std::endl; }
    void bindHub(std::shared_ptr<SensorHub> hub) { hub_ = hub; }
    void reportStatus()
    {
        if (auto h = hub_.lock()) // 尝试将weak指针升格为shared
            std::cout << "[Node] Hub 连接正常, 准备上报..." << std::endl;
        else
            std::cout << "[Node] Hub 已经挂了!" << std::endl;
    }
};
auto main() -> int
{
    // 1. 创建两节点
    auto hub = std::make_shared<SensorHub>();
    auto node = std::make_shared<VisionNode>();
    // 2. 互相绑定
    hub->bindNode(node);
    node->bindHub(hub);
    // 3. 验证绑定
    node->reportStatus();
    // 4. 运行检查析构函数是否正常执行
    std::cout << "[main] 准备离开作用域..." << std::endl;
    {
        std::cout << "进入局部作用域..." << std::endl;
        // 1. 创建两节点
        auto hub = std::make_shared<SensorHub>();
        auto node = std::make_shared<VisionNode>();
        // 2. 互相绑定
        hub->bindNode(node);
        node->bindHub(hub);
        // 3. 验证绑定
        node->reportStatus();
        std::cout << "离开局部作用域..." << std::endl;
    }
    return 0;
}

这个例子里需要注意到!

注意点 1:析构顺序

先创建的 hub 后创建的 node,按理说按照栈结构,应该先析构 node 后析构 hub。

结果真正运行时先析构的是 hub,这是因为 node 身上有两个强引用,node 自己和 hub->node

然后 hub 只有 hub 自己是强引用,node->hub 是弱引用,所以离开作用域时,

先是 hub 的计数归 0(因为 hub 被销毁),node 的计数为 1(node 被销毁,但是还有 hub->node)

当 hub 被回收之后,hub->node 也不复存在,所以 node 接着被回收了。

注意点 2:shared 和 weak

请注意!node->bind 函数的签名是 shared 指针!但是 node->hub 的定义却是 weak!

此外 node->reportStatus() 中访问这个 weak 必须使用 .lock()

第九课:消灭丑陋的输出参数 —— std::tuple 与结构化绑定

在 C++11/14 时代,如果一个函数需要返回多个值,我们只能这么写:

bool parseData(const std::string& raw, int& out_id, double& out_x, double& out_y);

这种“输出参数”不仅让函数签名变得极长,而且在调用端看着非常反人类:

int id; double x, y;
if (parseData("abc", id, x, y)) { ... } // 变量必须提前声明,代码零散

C++17 给出了终极解决方案:std::tuple(元组) 配合 结构化绑定。
你可以把 tuple 想象成一个可以装任意类型、任意数量元素的盲盒。而结构化绑定允许你用一句话把盲盒里的东西整齐地摆放在桌面上。

核心语法

  • 返回:return {状态码, ID, 数据};(编译器自动推导为 tuple)。
  • 接收:auto [status, id, data] = function(); (这就是结构化绑定,极其性感)。

在自动驾驶中,解析一帧底层 CAN 总线报文,通常需要返回:是否解析成功、帧ID、三轴加速度数据。这是 tuple 最完美的战场。

例子:

#include <iostream>
#include <tuple>
#include <array>
#include <string>

auto parseCanFrame(const std::string &raw_data) -> std::tuple<bool, int, std::array<double, 3>>;

auto main() -> int
{	// 直接用 auto [...] 语法
    auto [is_valid, frame_id, accel] = parseCanFrame("ABCDEF");
    if (is_valid)
        std::cout << "[Parse OK] ID: " << frame_id << ", Accel: " 
            << accel[0] << ' ' << accel[1] << ' ' << accel[2] << std::endl;
    else
        std::cout << "[Parse Failed] Invalid frame." << std::endl;
    // 非初始化的时候不能使用 [...] = 来获取值!
    std::tie(is_valid, frame_id, accel) = parseCanFrame("AB");
    if (is_valid)
        std::cout << "[Parse OK] ID: " << frame_id << ", Accel: " 
            << accel[0] << ' ' << accel[1] << ' ' << accel[2] << std::endl;
    else
        std::cout << "[Parse Failed] Invalid frame." << std::endl;
}

auto parseCanFrame(const std::string &raw_data) -> std::tuple<bool, int, std::array<double, 3>>
{
    if (raw_data.length() > 5) return {true, 0x123, {1.5, 2.5, 3.5}};
    return {false, -1, {0, 0, 0}};
}

第十课:终结虚函数 —— std::variant 与静态多态分发

核心思想:std::variant 就是 C++ 版的 Rust enum

在传统的面向对象编程中,处理不同类型的传感器数据(IMU、GPS、轮速计),我们会写一个基类 SensorData,然后让它们继承,再写一堆 virtual void process()

在 C++ 中,虚函数是性能毒药。它强制数据必须在堆上分配(new),引发内存碎片;它引入了虚表指针查找,破坏 CPU 流水线和分支预测;最要命的是,不同类型的数据散落在堆的不同角落,彻底破坏了缓存局部性。

C++17 引入了 std::variant。它是一个“类型安全的联合体”,直接把数据生吞进栈内存里(大小等于其最大类型加上一小块标志位)。配合 std::visit,你可以实现零堆分配、绝对缓存友好的静态多态。

  1. 核心机制:std::visitif constexpr

std::visit 接受一个“访问者”和一个 variant。在 C++17 中,我们最喜欢用泛型 Lambda 结合 if constexpr 来做分发:编译器会为每一种可能的类型生成一份特化代码,运行时没有任何虚函数开销,直接跳转。

核心思想:std::variant 就是 C++ 版的 Rust enum

在 Rust 中,写这种代码:

enum SensorData {
    Imu(ImuFrame),
    Gps(GpsFrame),
    // 编译器知道它最大有多大,直接在栈上分配这块固定大小的空间
}
let data = SensorData::Imu(ImuFrame { x: 1.0, y: 2.0 });
// 通过 match 进行模式匹配,绝对安全
match data {
    SensorData::Imu(frame) => println!("IMU: {}", frame.x),
    SensorData::Gps(frame) => println!("GPS: {}", frame.lat),
}

在 C++ 中, std::variant<ImuFrame, GpsFrame> 干的就是完全一样的事!
它是一个带标签的联合体

  • 它的大小在编译期就固定了:等于 max(sizeof(ImuFrame), sizeof(GpsFrame)) + 几个字节(用来存当前是哪个类型的“标签”)。
  • 在任意时刻,它只能装其中一个类型。你赋值了 ImuFrame,它里面的 GpsFrame 就会被销毁。
  • 绝对安全。不像 C 语言的 union,你存了 int 却当 float 读,编译器不管你。variant 会记住当前装了什么,如果你用错了类型去取,它会直接抛出异常(或者触发未定义行为的处理机制),绝不让内存悄悄损坏。

C++ 的痛点:如何取出数据?

在 Rust 里,取出数据非常优雅,用 match
但在 C++ 中,怎么知道里面装了什么,并且安全地取出来?

错误的示范:std::get (相当于强行 unwrap 且指定类型)

std::variant<int, double> v = 42;
// 如果你不小心写了 std::get<double>(v),直接抛异常崩溃!
std::cout << std::get<int>(v) << std::endl; // 必须明确指定类型,很死板

丑陋的示范:std::holds_alternative (相当于写一堆 if-else)

if (std::holds_alternative<int>(v)) {
    std::cout << std::get<int>(v);
} else if (std::holds_alternative<double>(v)) {
    std::cout << std::get<double>(v);
}

类型一多,这种代码会让你疯掉。

终极的示范:std::visit + 泛型 Lambda (相当于 C++ 的 match)

这是 C++17 处理 variant 的最高级形态。std::visit 会去看那个“标签”,然后把你写好的函数(Lambda)“丢进去”执行。

std::variant<int, double> v = 3.14;
// 1. auto&& arg 意思是:不管你里面装了什么类型,我先用一个万能引用接住它
std::visit([](auto&& arg) {
    std::cout << arg << std::endl;
}, v);

但这还不够!因为有时候不同的类型,需要做不同的处理(就像 Rust 的不同分支)。这时候,就要请出 C++ 的黑魔法组合:if constexpr + std::is_same_v

std::variant<int, double> v = 42;
std::visit([](auto&& arg) {
    // 1. decay_t 剥离掉引用和 const,拿到最纯粹的真实类型 T
    using T = std::decay_t<decltype(arg)>;
    
    // 2. if constexpr 是编译期判断!
    // 如果 T 是 int,下面这个分支会被编译;如果不是 int,下面这个分支在编译时会被直接丢掉!
    if constexpr (std::is_same_v<T, int>) {
        std::cout << "这是个整数: " << arg << std::endl;
    } 
    // 3. 同理,匹配 double
    else if constexpr (std::is_same_v<T, double>) {
        std::cout << "这是个浮点数: " << arg << std::endl;
    }
}, v);

总结映射表

Rust 概念C++ 对应概念作用
enum Data { A, B }std::variant<A, B>栈上的安全类型集合
Data::A(val)直接赋值 variant = A{val}存入数据
match data { A(x) => ... }std::visit([](auto&& x) { if constexpr(...) ... }, variant)编译期安全分发处理

核心心法std::visit 里的 Lambda 会被编译器根据 variant 的所有可能类型,在编译期实例化出多份代码。运行时,它看一眼标签,直接跳到对应的代码执行。没有任何虚表查表,没有任何堆分配,性能与手写 if-else 完全一致,但安全性达到了 Rust 的级别。