现代C++ | 右值引用 + std::move + noexcept

前言

C++98/03 时代,临时对象(右值)在传递时只能拷贝,这导致了巨大的性能浪费。 比如返回一个 std::vector 或 std::string 时,编译器要先构造临时对象,再拷贝给调用者,最后销毁临时对象------三次构造/析构。

C++11 委员会(Bjarne Stroustrup 和 Howard Hinnant 主推)决定引入移动语义:让临时对象可以"偷"资源(把指针、内存直接转移),不需要拷贝。 官方目标:"让返回大对象、插入容器、swap 等操作达到零拷贝",同时保持向后兼容。 C++11 引入右值引用(&&),C++14/17 又完善了 std::move_if_noexcept、容器对移动的支持等。


左值 和 右值

先搞清楚 最基础的lvalue 和 rvalue的概念:

左值 lvalue

  • 有名字
  • 能取地址
  • 能放在 = 左边
  • 有持久生命周期
cpp 复制代码
int a = 10;       // a 是左值
string s = "abc"; // s 是左值

右值 rvalue

  • 临时对象
  • 没名字
  • 不能取地址
  • 马上就要销毁
cpp 复制代码
10;               // 右值
string("hello");  // 临时对象 → 右值
a + 10;           // 表达式结果 → 右值

一句话区分:能取地址 & → 左值; 不能取地址 & → 右值

那么,右值无法取地址,那右值是无法修改的吗?

右值的核心定义是无持久身份、表达式结束后就销毁的临时对象,因此右值是可以修改的,只是它马上就要销毁,修改它通常没有意义,甚至会引发逻辑 bug。

cpp 复制代码
// 右值std::string("hello")被修改了,编译100%通过
std::string("hello").append(" world"); 

其实右值还可以再拆为两类:

  • 纯右值 prvalue

  • 将亡值 xvalue

纯右值 prvalue特征:

  • 无名临时、纯粹数值 / 临时构造结果

  • 不能取地址

  • 没有资源、不是 "快要消亡的已有对象"

cpp 复制代码
42;
3.14;
1 + 2;
std::string("hi");       // 临时对象 prvalue
func();                  // 值返回结果 prvalue

纯右值在C++17中有RVO优化,在后面讲。

将亡值 xvalue

定义:原本是左值,但被标记「即将消亡、可以偷资源」的值

典型来源:

  • std::move(左值)

  • 临时对象引用、数组 / 成员收尾即将销毁的值

cpp 复制代码
std::string s = "hello";

// std::move 把左值 转为 将亡值 xvalue
std::string&& temp = std::move(s); 

xvalue 特点:

  1. 属于 右值大类

  2. 有对象实体、有内存地址

  3. 语义:资源可以安全被「移动窃取」

  4. C++17 对 xvalue 不保证强制 RVO!


右值引用

C++98 只能对 lvalue 做拷贝。

而C++11 引入右值引用 T&&,专门绑定 rvalue,让我们能"偷"它的资源。

cpp 复制代码
void func(int& l)  { std::cout << "lvalue\n"; }
void func(int&& r) { std::cout << "rvalue\n"; }

int main() {
    int x = 42;
    func(x);           // 调用 lvalue 版本
    func(42);          // 调用 rvalue 版本!!
    func(std::move(x)); // 强制变成 rvalue,调用 rvalue 版本
}

底层实现:

  • T&& 是引用绑定,作为函数参数绑定到 rvalue 时不会延长生命周期,临时对象仍会在表达式结束时销毁。

  • 编译器会生成两个重载:拷贝构造函数const T& 和 移动构造函数T&&。

  • 当传入 rvalue 时,优先调用移动版本,偷资源。

关于延长临时对象的生命周期

直接用 T&& 栈变量绑定临时 → 生命周期延长,和 const T& 效果一致。

cpp 复制代码
std::string&& s = std::string("hello"); 
// 临时被延长,安全可用

函数参数 T&& 接收临时 → 不延长!临时按常规销毁

cpp 复制代码
void func(std::string&& r)
{
    // r 的外表类型:std::string&& 右值引用
    // r 的值类别:r左值!
    // 外层临时绑定函数形参后,临时生命周期不兜底延长
    &r;  // 可以取地址,左值
}

func(std::string("temp"));
// 语句结束,原本临时直接销毁

关于r本身是左值:

  • 看有没有名字 / 能不能取地址,不看表面是不是 &&

  • 只要变量起了名字、能写 &x 取地址 → 它就是 左值 lvalue

  • 可以理解为右值传参会降级,只要一个右值引用有了名字、能被取地址,它就是左值,和它绑定的原始对象是不是右值无关。

cpp 复制代码
class Str{
public:
    Str()=default;
    Str(const Str&){cout<<"拷贝\n";}
    Str(Str&&)     {cout<<"移动\n";}
};

void tes1t(Str&& x){
    Str s2 = x;   // x 是左值 → 调用【拷贝构造】
}

void test2(Str&& x){
    Str s2 = std::move(x);  // 强转成右值 → 触发【移动构造】
}

test1(Str{});  
// Str{} 是纯右值,绑定到形参 int&& x

很好理解,如果不这样设计会发生什么?

cpp 复制代码
// 假设规则反过来:有名字的T&&还是右值
void test(Str&& x) {
    Str s1 = x; // x是右值,直接移动,x被掏空置空
    Str s2 = x; // 第二次用x,已经是空的了!未定义行为,直接崩溃
}

正因为 T&& 有名就变左值 ,才需要:std::forward 完美转发 + 通用引用保证原值类别原样传递,不丢右值属性,这个特性在后文会讲。

因此,看一个表达式是左值还是右值,永远只看它有没有名字、能不能取地址,和它的类型是不是&&没有任何关系。

另外,T&只能引用左值,T&&只能引用右值,而const T&既能引用左值又能引用右值,并且能延长临时对象的生命周期。const T&为什么能引用右值???

非 const 的T&意味着「你可以修改这个引用指向的对象」,如果允许它绑定右值,会出现这种无意义甚至危险的代码,所以 C++ 标准直接禁止非 const 左值引用绑定右值。

const T&是只读引用,你只能读取对象的值,不能修改它,哪怕绑定的是马上要销毁的临时对象,也只是安全读取,不会出现无意义的修改操作,更不会引发逻辑 bug。


移动语义 + std::move

拷贝:复制全部数据,慢 O (n)

移动:偷指针、偷内存,快 O (1)

假如有一段代码:

cpp 复制代码
std::vector<int> createBigVector() {
    std::vector<int> v(1000000);  // 分配 100 万个 int 的空间(约 4MB)
    // ... 填充数据
    return v;                      // 关键:返回局部对象 v
}
  • C++98 的问题:由于 v 是局部对象,函数返回时会触发拷贝构造函数:先将 v 的数据拷贝到一个 "临时返回对象",再将临时对象拷贝给 main() 中的 big。两次拷贝 100 万 int,耗时且浪费内存(编译器可能会自行优化成只拷贝一次)。

  • C++11 的优化:编译器自动识别 v 是 "即将销毁的局部对象",优先调用移动构造函数,而非拷贝,直接转移 v 内部的堆内存指针、大小、容量等资源给返回值,实现零拷贝,仅修改几个指针 / 整数。

移动构造函数长什么样?

cpp 复制代码
class MyString {
    char* data;
    size_t len;
public:
    // 移动构造函数(偷资源)
    MyString(MyString&& other) noexcept
        : data(other.data), len(other.len) {
        other.data = nullptr;   // 偷完后把对方置空
        other.len  = 0;
    }

    // 拷贝构造函数
    MyString(const MyString& other) { ... }
};

移动做了什么?

  • 把别人的指针拿过来

  • 把别人置空

  • 没有任何内存拷贝

std::move到底是什么?

std::move 不移动任何东西,它只是一个强转,把左值 → 转成右值。

本质就是static_cast<T&&>(x) (C++11引入的强制类型转换),告诉编译器这个变量以后不用了,转移其资源。

cpp 复制代码
string a = "hello";
string b = std::move(a);
  • a 本来是左值

  • move(a) → 变成右值

  • 于是 b 调用移动构造偷走 a 的内存

  • a 变成空

再次提醒,move后的对象被置空,只能析构/赋值/再次置空。


noexcept

设计原因: C++98 的动态异常规范 throw() 运行时开销巨大、难以静态分析,已被废弃。C++11 引入 noexcept,目的是在编译期声明一个函数不会抛出异常,帮助编译器进行更激进的优化,尤其在容器 reallocate、移动语义等场景中至关重要。

底层原理:

  • noexcept 是编译期标记,告诉编译器该函数不会抛异常,或在特定条件下不抛。

  • 如果函数标记了 noexcept 但实际抛出了异常,程序会直接调用 std::terminate()(崩溃)。

  • 标准库容器如 std::vector在扩容时,只有移动构造函数是 noexcept 时,才会使用移动操作;否则为了强异常安全会退化为拷贝(O(N)),性能大幅下降

noexcept 最经典的应用场景。看一个自定义 MyVector 的例子:

cpp 复制代码
class MyVector {
    int* data;
    size_t size;
public:
    // 移动构造函数:必须加 noexcept!
    MyVector(MyVector&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }

    // 移动赋值运算符:同理加 noexcept
    MyVector& operator=(MyVector&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
            other.data = nullptr;
            other.size = 0;
        }
        return *this;
    }
};

为什么必须加 noexcept?假设 std::vector<MyVector> 扩容:

  • 若移动构造是 noexcept:直接移动旧元素到新内存(O (1) 指针交换);

  • 若没有 noexcept:编译器担心移动过程中抛异常,导致旧数据破坏,于是退化为拷贝构造(O (N) 逐元素复制),性能断崖式下跌。

C++11 之后,几乎所有标准库的移动操作都标记了 noexcept(如 std::stringstd::vectorstd::unique_ptr 的移动构造)。实际项目中,自定义资源类时遵循 "移动构造 + noexcept" 是标准写法。

std::move_if_noexcept

为了让 "移动或拷贝" 的选择更自动化,C++14 引入了 std::move_if_noexcept

cpp 复制代码
#include <vector>
#include <utility> // std::move_if_noexcept

struct MyType {
    MyType() = default;
    MyType(MyType&&) noexcept {} // 移动构造是 noexcept
    MyType(const MyType&) {}     // 拷贝构造
};

int main() {
    std::vector<MyType> v;
    MyType t;
    // 若 MyType 的移动构造是 noexcept,就移动;否则拷贝
    v.push_back(std::move_if_noexcept(t));
}

它的本质是:"优先移动,但不安全时就拷贝",是标准库容器内部的常用套路。

反面教材:只用 std::move

cpp 复制代码
#include <iostream>
#include <utility>

// 类 B:移动构造不是 noexcept(不安全)
class B {
public:
    B() = default;
    B(B&&) { std::cout << "B 移动构造(危险!可能抛异常)\n"; throw 42; }
    B(const B&) { std::cout << "B 拷贝构造(安全)\n"; }
};

// 自己写的通用转移函数:只用 std::move
template <typename T>
void transfer_object(T& src, T* dst) {
    // 不管三七二十一,直接 move!
    new (dst) T(std::move(src)); 
}

int main() {
    B src;
    alignas(B) char buffer[sizeof(B)];
    B* dst = reinterpret_cast<B*>(buffer);

    try {
        transfer_object(src, dst); // 这里会调用移动构造,然后抛异常!
    } catch (...) {
        std::cout << "捕获异常,但 src 可能已经被破坏了!\n";
    }
}
  • transfer_object 没有做 noexcept 检查;

  • 直接 std::move 会强制调用移动构造,哪怕它会抛异常;

  • 一旦抛异常,src 可能已经被 "偷" 走了一部分(比如指针被置空),数据就坏了

常见坑:

不要对 const 对象调用 std::move(const T&& 绑定不到移动构造函数)。

移动操作必须加 noexcept

自定义资源管理类(如字符串、智能指针、容器)时,移动构造 / 移动赋值必须加 noexcept, 否则标准库容器不会用移动逻辑,性能白给。

绝不要给 "可能抛异常" 的函数加 noexcept

如果函数里调用了可能抛异常的操作(如 new、动态类型转换、用户自定义回调),千万别标记 noexcept,否则一旦抛异常,程序直接崩溃,连栈展开都不会做。

灵活使用 noexcept(noexcept(expr))

可以根据另一个表达式的 noexcept 状态,动态决定当前函数的 noexcept

cpp 复制代码
template <typename T>
void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) {
    a.swap(b);
}

std::move 在 return 语句中其实不需要,C++17返回临时对象强制零开销(GCE),返回局部变量自动最优(NRVO / 隐式移动),return 里永远不要写 std::move。

QA

移动语义和拷贝语义性能差多少?

移动通常只转移指针(O(1)),拷贝是 O(N)。返回大 vector 时,移动比拷贝快几百倍。

什么时候必须手动写 std::move?

局部变量传给别人(函数参数、赋值、插入容器)时。return 时通常不需要。

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

std::vector 等容器在扩容时,为了保证 "强异常安全",即扩容失败时旧数据不破坏,只有移动构造函数是 noexcept 才会使用移动操作;否则会退化为拷贝构造,导致性能从 O (1) 暴跌到 O (N)。

std::string 小字符串优化(SSO)对移动有影响吗?

短字符串(<16字节)可能还是拷贝,超过了才是真正移动。

  • noexcept 和旧的 throw() 有什么本质区别?

noexcept 是编译期标记,零运行时开销,编译器可基于它做优化;

throw() 是运行时检查,有巨大开销,且无法静态验证,已被废弃。

  • 什么时候必须加 noexcept

所有 "绝不抛异常" 的操作,尤其是:

  • 移动构造函数、移动赋值运算符;

  • swap 函数;

  • 析构函数(C++11 起析构函数默认是 noexcept)。

  • 移动构造函数要加 noexcept,具体是怎么保护的?

std::vector 扩容的标准步骤(简化版伪代码)是这样的:

cpp 复制代码
template <typename T>
void vector<T>::reallocate(size_t new_capacity) {
    // 1. 先分配新内存(不碰旧数据!)
    T* new_data = static_cast<T*>(operator new(new_capacity * sizeof(T)));
    size_t i = 0;

    try {
        // 2. 在新内存里逐个构造元素(关键步骤!)
        for (; i < size; ++i) {
            // 这里决定是用移动构造还是拷贝构造!
            construct_in_place(new_data + i, /* 源对象 */ old_data[i]);
        }
    } catch (...) {
        // 3. 异常处理:如果构造失败,"回滚"------销毁已造的,释放新内存
        for (size_t j = 0; j < i; ++j) {
            (new_data + j)->~T();
        }
        operator delete(new_data);
        throw; // 重新抛出异常,告诉用户扩容失败了
        // 注意:旧数据 old_data 完全没碰过!依然完好!
    }

    // 4. 确认所有元素都构造成功了!才敢碰旧数据
    //    销毁旧元素,释放旧内存
    for (size_t j = 0; j < size; ++j) {
        (old_data + j)->~T();
    }
    operator delete(old_data);

    // 5. 最后:更新指针和大小(Commit!)
    old_data = new_data;
    capacity = new_capacity;
}

关键点:

  • 旧数据 old_data 只有在 "新内存里所有元素都 100% 构造成功" 之后,才会被销毁和释放;

  • 如果中间任何一步抛异常,直接进入 catch 块:销毁新内存里已造的元素,释放新内存,然后跑路 ------旧数据 old_data 连碰都没碰过,绝对安全!

用一个具体的 MyString 类来举例,它有一个 char* data 指针:

cpp 复制代码
class MyString {
    char* data;
public:
    // 移动构造:偷指针,把源对象置空
    MyString(MyString&& other)  /* 假设这里先不加noexcept,我们看看后果 */ {
        data = other.data;   // 偷!
        other.data = nullptr;// 源对象空了!
    }

    // 拷贝构造:深拷贝,源对象不动
    MyString(const MyString& other) {
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data); // 源对象 other.data 完全没碰!
    }

    ~MyString() { delete[] data; }
};

情况 1:移动构造不是 noexcept

假设 MyString 的移动构造没加 noexcept,而且在移动第 3 个元素时(i=2),突然抛异常了:

cpp 复制代码
// 扩容步骤 2:在新内存构造元素
for (i = 0; i < size; ++i) {
    // 编译器一看:移动构造不是 noexcept,不敢用!
    // 但如果我们"强行"用移动构造(比如手动 std::move),会发生什么?
    new (new_data + i) MyString(std::move(old_data[i])); // 假设这里 i=2 时抛异常!
}

过程:

  1. i=0:移动成功,old_data[0].data 被置空,new_data[0] 接管了资源;

  2. i=1:移动成功,old_data[1].data 被置空;

  3. i=2:移动构造抛异常!

  4. 进入 catch 块:

    • 销毁 new_data[0]new_data[1](它们的 data 被释放);

    • 释放 new_data

    • 重新抛出异常。

  5. 回头看旧数据:

    • old_data[0].data 已经是 nullptr 了!

    • old_data[1].data 也已经是 nullptr 了!

    • 旧数据已经被破坏了!

结果:扩容失败,旧数据也没了 ------ 两头空,违反了 "强异常安全"。

情况 2:移动构造是 noexcept

现在给 MyString 的移动构造加上 noexcept

cpp 复制代码
MyString(MyString&& other) noexcept { 
    data = other.data;
    other.data = nullptr;
}

编译器一看:移动构造是 noexcept,承诺绝对不抛异常。

安全过程:

  1. 扩容步骤 2:放心大胆地用移动构造;

  2. 因为 noexcept,编译器知道这里绝对不会抛异常;

  3. 所有元素都移动成功;

  4. 进入步骤 4:销毁旧元素,释放旧内存;

  5. 进入步骤 5:更新指针,扩容成功。

结果:O (1) 移动,速度极快,而且因为 noexcept 保证不抛异常,所以绝对安全。

情况 3:移动构造不是 noexcept,但我们退回到拷贝构造

编译器看既然移动构造不是 noexcept,那我就不用它了,用拷贝构造

cpp 复制代码
// 扩容步骤 2:在新内存构造元素
for (i = 0; i < size; ++i) {
    // 编译器自动选择:用拷贝构造!
    new (new_data + i) MyString(old_data[i]); // 源对象 old_data[i] 完全不动!
}

假设拷贝第 3 个元素时(i=2)抛异常:

安全回滚过程:

  1. i=0:拷贝成功,new_data[0] 有了副本,old_data[0] 完好无损;

  2. i=1:拷贝成功,old_data[1] 完好无损;

  3. i=2:拷贝构造抛异常!

  4. 进入 catch 块:

    • 销毁 new_data[0]new_data[1](释放副本);

    • 释放 new_data

    • 重新抛出异常。

  5. 回头看旧数据:

    • old_data[0]old_data[1]old_data[2]...... 所有旧元素连碰都没碰过!

    • 旧数据完好无损!

结果:扩容失败,但旧数据还在 ------ 完美符合 "强异常安全",虽然性能是 O (N),但保命最重要。


Rule of Five/Rule of Zero原则

如果你的类里有需要自己释放的资源比如堆内存 new int、文件句柄、网络连接,就得小心处理:

  • 拷贝对象时,不能只复制指针(浅拷贝),否则两个对象会指向同一块资源,析构时重复释放导致崩溃。

  • C++11 有了移动语义后,还要处理 "转移资源所有权" 的情况。

Rule of Five

如果你手动写了以下 5 个函数中的任意一个,说明你的类需要复杂的资源管理,那你必须把5 个都写全:

  1. 析构函数

  2. 拷贝构造函数

  3. 拷贝赋值运算符

  4. 移动构造函数(C++11:转移资源给新对象,原对象变空)

  5. 移动赋值运算符(C++11:转移资源给已存在的对象,原对象变空)

为什么要 "写一个就得写全"?

举个反例:假设你只写了析构函数(释放堆内存),但用编译器默认的拷贝构造函数:

cpp 复制代码
class BadExample {
public:
    int* data;
    BadExample() : data(new int(42)) {}  // 分配堆内存
    ~BadExample() { delete data; }        // 只写了析构函数
    // 编译器默认生成的拷贝构造:浅拷贝!
};

int main() {
    BadExample a;
    BadExample b = a;  // 浅拷贝:b.data 和 a.data 指向同一块内存!
    // 程序结束时,a 和 b 都析构,delete 同一块内存两次 → 崩溃!
}

Rule of Zero

不要自己手动管理资源!直接用标准库已经封装好的类比如 std::vectorstd::stringstd::unique_ptrstd::shared_ptr,这些类已经帮你写好了完美的析构、拷贝、移动函数,你什么都不用写!

为什么这是最佳实践?看对比:

手动管理资源(需要写 Rule of Five)

cpp 复制代码
class ManualVector {
public:
    int* data;
    size_t size;

    // 1. 构造函数
    ManualVector(size_t s) : data(new int[s]), size(s) {}

    // 2. 析构函数
    ~ManualVector() { delete[] data; }

    // 3. 拷贝构造函数(深拷贝)
    ManualVector(const ManualVector& other) {
        size = other.size;
        data = new int[size];
        std::copy(other.data, other.data + size, data);
    }

    // 4. 拷贝赋值运算符
    ManualVector& operator=(const ManualVector& other) {
        if (this == &other) return *this;
        delete[] data;  // 先释放自己的资源
        size = other.size;
        data = new int[size];
        std::copy(other.data, other.data + size, data);
        return *this;
    }

    // 5. 移动构造函数
    ManualVector(ManualVector&& other) noexcept {
        data = other.data;  // 直接转移指针
        size = other.size;
        other.data = nullptr;  // 原对象变空,避免析构时重复释放
        other.size = 0;
    }

    // 6. 移动赋值运算符
    ManualVector& operator=(ManualVector&& other) noexcept {
        if (this == &other) return *this;
        delete[] data;
        data = other.data;
        size = other.size;
        other.data = nullptr;
        other.size = 0;
        return *this;
    }
};

用 Rule of Zero,一行代码搞定

cpp 复制代码
class SmartVector {
public:
    std::vector<int> data;  // 直接用标准库的 vector!

    SmartVector(size_t s) : data(s) {}
    // 什么析构、拷贝、移动都不用写!
    // 编译器会自动调用 vector 的对应函数,完美!
};

什么时候必须手动写 std::move?

必须 move 的场景:

  • 把局部变量转给别人

    cpp 复制代码
    void f(vector<int> v);
    
    vector<int> v;
    f(std::move(v)); 
  • 赋值转移

    cpp 复制代码
    a = std::move(b);

不需要move的场景:

  • return局部变量

    cpp 复制代码
    vector<int> f() {
        vector<int> v;
        return v;  // 自动优化 → 移动
    }

C++17:强制 RVO(返回值优化)

C++17 之后,返回临时对象 → 100% 不拷贝、不移动,直接构造在调用方,这种规则被称为GCE。

cpp 复制代码
return string("hello");

在 C++17 之前,RVO(返回值优化)和 NRVO(具名返回值优化)只是编译器的 "可选优化"。

虽然几乎所有编译器在 Release 模式下都会做优化,但在语言规则上存在两个致命问题:

  1. 语法检查严格:即使编译器实际上会优化掉拷贝 / 移动,你的类必须有可访问的拷贝或移动构造函数(不能是 deleteprivate),否则编译报错。

  2. Debug 模式的噩梦:在 Debug 模式下(为了调试),优化通常会关闭,导致原本应该高效的代码变得极慢。

以C++11/14为例子:

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

class Widget {
public:
    Widget() { cout << "构造函数" << endl; }
    Widget(const Widget&) { cout << "拷贝构造" << endl; }
    Widget(Widget&&) noexcept { cout << "移动构造" << endl; }
};

Widget createWidget() {
    return Widget(); // 返回一个临时对象
}

int main() {
    Widget w = createWidget(); 
    // C++17前:理论上需要先构造临时对象,再移动构造给w
    // 虽然编译器可能优化掉,但移动构造函数必须存在!
}

C++17 彻底改写了这一机制。核心在于重新定义了 "值类别"(Value Categories)。简单来说,C++17 引入了一个核心概念:纯右值(prvalue)不实质化(materialize)。核心法则:

当你返回一个纯右值(例如 return T(...))时:

  1. 没有临时对象。

  2. 不调用拷贝构造。

  3. 不调用移动构造。

  4. 直接在调用方的内存地址上构造对象。

这不仅是优化,这是语言层面的保证。

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

class Widget {
public:
    Widget() { cout << "构造函数" << endl; }
    
    // 注意:我们把拷贝和移动构造都删除了!
    Widget(const Widget&) = delete;
    Widget(Widget&&) = delete;
};

Widget createWidget() {
    return Widget(); // 返回纯右值
}

int main() {
    Widget w = createWidget(); 
    // C++17后:编译通过!且只打印一次 "构造函数"
    // 即使没有拷贝/移动构造函数也没关系,因为根本用不到它们
}

即使你的类不可拷贝、不可移动,你依然可以通过值返回它,而且效率为 0。

为了实现这一点,编译器改变了调用约定。步骤解析:

  1. 调用方(Caller)负责分配内存:main 函数在自己的栈帧上提前为 w 分配好内存。

  2. 传递 "隐藏指针":main 调用 createWidget 时,偷偷把 w 的内存地址作为一个隐藏参数传了进去。

  3. 被调用方(Callee)直接构造:createWidget 内部的 return Widget(); 不再是在自己的栈帧里构造对象,而是直接拿着那个隐藏指针,在 main 函数的地址上构造对象。

cpp 复制代码
// 编译器眼中的 main
void main() {
    Widget w; // 先不构造,只分配内存
    createWidget(&w); // 把地址传进去
}

// 编译器眼中的 createWidget
void createWidget(Widget* __ret) {
    new (__ret) Widget(); // 直接在传入的地址上构造(Placement new)
}

什么时候才 "强制"?

C++17 的强制复制消除不是万能的,它有严格的适用条件。

强制生效的情况(返回纯右值):

cpp 复制代码
std::string func() {
    return std::string("hello"); // 1. 返回临时对象
}

std::vector<int> func2() {
    return {1, 2, 3}; // 2. 返回初始化列表(也是纯右值)
}

不强制的情况(NRVO):

如果你返回的是一个局部变量的名字(Named RVO),C++17 仍然不强制,这还是属于编译器的可选优化。

cpp 复制代码
std::string func() {
    std::string s = "hello";
    return s; // 这是 NRVO,C++17 不强制保证
              // 编译器可能优化,也可能调用移动构造
              // 注意:此时类必须有可访问的移动/拷贝构造函数
}

NRVO(return 局部变量名)编译器行为:

  1. 能优化 → 直接 NRVO,0 拷贝 0 移动(最快)

  2. 不能优化 → 自动调用移动构造(很快)

  3. 没移动构造 → 才调用拷贝构造(慢)

千万不要在返回局部变量时加 std::move

cpp 复制代码
std::string func() {
    std::string s = "hello";
    return std::move(s); // 大错特错!
}

这会阻止 NRVO,强制编译器调用移动构造函数。在 C++17 中,返回纯右值是最好的,返回具名对象次之,返回 std::move 是最差的。std::move(s) → 左值转 xvalue 将亡值,直接彻底禁用 NRVO 优化机会,强制走移动构造函数 搬运资源,C++17 强制 GCE 只认 prvalue,xvalue 不享受特权。

移动构造是「廉价拷贝」,有指令开销;RVO 是「直接原地构造」,零搬运、零函数调用。

相关推荐
我爱学习好爱好爱3 分钟前
Ansible 环境搭建
linux·运维·ansible
weixin_649555679 分钟前
C语言程序设计第四版(何钦铭、颜晖)第十一章指针进阶之奇数值结点链表
c语言·开发语言·链表
书到用时方恨少!25 分钟前
Python os 模块使用指南:系统交互的瑞士军刀
开发语言·python
我是大猴子26 分钟前
事务失效的几种情况以及是为什么(详解)
java·开发语言
-许平安-32 分钟前
MCP项目笔记六(PluginsLoader)
c++·笔记·raii·plugin system
呜喵王阿尔萨斯35 分钟前
argc & argv
c语言·c++
人工智能训练38 分钟前
从 1.1.3 到 1.13.2!Ubuntu 24.04 上 Dify 升级保姆级教程(零数据丢失 + 一键迁移)
linux·运维·人工智能·windows·ubuntu·dify
Vect__42 分钟前
std::bind和lambda的使用
c++
爱编码的小八嘎1 小时前
C语言完美演绎6-1
c语言
她叫我大水龙1 小时前
MSYS2的C/C++,python2,python3编译环境安装脚本
c语言·c++