左值?右值?&&是什么鬼?—— 写给所有被C++移动语义折磨的人

开发
你是不是经常看到C++代码中那些奇怪的&、&&符号,还有到处乱飞的std::move,然后一脸懵逼?别担心,今天我用大白话带你彻底搞懂这些东西!

开场小段子:搬家引发的思考

想象一下,你要搬家。如果你是土豪,可能会直接买新家具,旧家具直接扔掉。但如果你像我一样是个普通人,肯定是把家具从旧房子搬到新房子。

这就是C++移动语义的核心思想——与其复制一份资源,不如直接把资源的所有权转移过去!

一、左值和右值: C++中最被误解的概念

很多教材会告诉你:"等号左边是左值,右边是右值"。这种解释就像告诉你"太阳从东边升起"一样,虽然看起来没错,但一旦情况复杂起来就不够用了。

1. 左值和右值的本质区别

最简单实用的判断标准是:

  • 能取地址的就是左值 —— 它在内存中有确定位置
  • 不能取地址的就是右值 —— 它是临时的,转瞬即逝

举些栗子感受一下:

// 左值例子:
int a = 42;        // a是左值,&a是合法的
int arr[5];        // arr是左值,&arr是合法的
int *p = &a;       // p是左值,&p是合法的
a = 100;           // a可以出现在等号左边,是个"可修改的左值"
const int c = 10;  // c是左值但不可修改,是个"不可修改的左值"
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
// 右值例子:
int b = a + 1;     // a+1是右值,你不能写&(a+1)
int d = 42;        // 字面量42是右值,你不能写&42
func();            // 函数返回值是右值(除非返回引用)
  • 1.
  • 2.
  • 3.
  • 4.

2. 脑洞助记:左值像房子,右值像旅馆

想象一下:

(1) 左值就像你拥有的房子:

  • 有固定地址(可以&取址)
  • 可以长期存在(生命周期确定)
  • 可以反复访问(可以多次使用)
  • 可以改装(可修改,除非const)

(2) 右值就像旅馆房间:

  • 临时的(生命周期短)
  • 住完就退房(用完就销毁)
  • 地址无法长期持有(不能直接取址)
  • 东西可以被带走(资源可以被转移走,可以被右值引用捕获)

二、引用:普通引用vs右值引用

1. 左值引用 (普通引用)

在传统C++中,"引用"通常指的是左值引用:

int a = 42;
int& ref = a;  // 左值引用,绑定到左值
ref = 100;     // 修改ref实际上就是修改a
std::cout << a;  // 输出100,原变量被修改了

// 下面这些是错误的用法
// int& invalid_ref;        // 错误:引用必须初始化  
// int& ref_to_literal = 42; // 错误:不能绑定到字面量(右值)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

左值引用的几个关键特点:

  • 必须初始化,而且一旦绑定就不能重新绑定到其他对象
  • 对引用的操作就是对原变量的操作
  • 常规左值引用只能绑定到左值(名字都带"左值",当然只能绑左值啦)

2. const左值引用的特殊性

const int&是个特殊的存在,它既能绑左值,又能绑右值!

int x = 42;
const int& ref1 = x;    // 绑定到左值,没问题
const int& ref2 = 42;   // 绑定到右值,也没问题!
const int& ref3 = x+1;  // 绑定到表达式结果,也可以!
  • 1.
  • 2.
  • 3.
  • 4.

为什么const int&能绑定右值?因为编译器会创建一个临时变量来存储右值,然后引用绑定到这个临时变量上。而且因为是const的,所以保证你不会修改这个临时对象,安全!

这就是为什么你经常看到函数参数用const T&——它能同时接受左值和右值参数!

void printValue(const std::string& s) {  // 既能接受左值又能接受右值
    std::cout << s << std::endl;
}

std::string str = "hello";
printValue(str);             // 左值:没问题
printValue(str + " world");  // 右值:也没问题!
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

3. 右值引用 (C++11新特性)

C++11引入了右值引用,语法是双&&:

int&& rref = 42;       // 右值引用,绑定到右值
int x = 10;
// int&& bad_ref = x;  // 错误:右值引用不能直接绑定到左值
  • 1.
  • 2.
  • 3.

右值引用专门用来绑定右值的,这些右值通常是临时的、即将消亡的值。

4. 右值引用的"双面性"

这个很重要但容易让人混乱:右值引用类型的变量本身是左值!

int&& rref = 42;  // rref的类型是右值引用(int&&),但rref本身是个左值
int& ref = rref;  // 正确!因为rref虽然类型是右值引用,但它是个有名字的变量,所以是左值

void foo(int&& x) {
    x = 100;  // x在函数内部是左值!尽管它的类型是右值引用
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

记住这个规则:如果它有名字,它就是左值,不管它的类型是什么!

5. 左值引用和右值引用在函数中的表现

void foo(int& x) {
    // 参数必须是左值
}

void bar(int&& x) {
    // 参数必须是右值
    // 但x本身在函数内部是左值(因为它有名字)
}

int main() {
    int a = 5;
    foo(a);     // 正确,a是左值
    // foo(10);    // 错误,10是右值
    
    bar(10);    // 正确,10是右值
    // bar(a);     // 错误,a是左值
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

6. 究竟什么时候用左值引用,什么时候用右值引用?

(1) 用左值引用的场景:

  • 想避免复制大对象时:void process(BigObject& obj);
  • 需要修改传入的参数时:void increment(int& value);
  • 实现"输出参数"时:void getValues(int& out1, std::string& out2);

(2) 用const左值引用的场景:

  • 想避免复制,但不需要修改原对象:void print(const BigObject& obj);
  • 函数既要接受左值又要接受右值:bool compare(const std::string& s1, const std::string& s2);

(3) 用右值引用的场景:

  • 实现移动语义(下面会讲):void moveFrom(BigObject&& obj);
  • 完美转发(高级话题,下次讲):template<typename T> void wrapper(T&& param);

左值引用就像借用别人的东西,而右值引用则像是接管了一个无主之物!

三、移动语义:不是真的"移动",而是"偷"

现在我们来到C++11最激动人心的部分!移动语义就像是程序员的"循环利用"艺术,让我们能够合法地"偷"资源,而不是复制它们。

1. 传统复制的问题

假设你有个自定义字符串类:

class MyString {
private:
    char* data;
    size_t length;
public:
    // 构造函数
    MyString(constchar* str) {
        length = strlen(str);
        data = newchar[length + 1];
        strcpy(data, str);
    }
    
    // 析构函数
    ~MyString() {
        delete[] data;
    }
    
    // ... 其他成员
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

传统的复制是这样的:

// 复制构造函数
MyString(const MyString& other) {
    length = other.length;
    data = newchar[length + 1];
    strcpy(data, other.data);  // 复制内容,开辟新内存
}

// 复制赋值运算符
MyString& operator=(const MyString& other) {
    if (this != &other) {
        delete[] data;  // 释放原有资源
        length = other.length;
        data = newchar[length + 1];
        strcpy(data, other.data);  // 复制内容,开辟新内存
    }
    return *this;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

问题在哪? 当你在传递大对象时,特别是临时对象,复制操作会带来不必要的性能开销。

2. 思考一个场景

MyString createGreeting() {
    MyString greeting("Hello, world!");
    return greeting;  // 返回时会创建临时对象
}

void useString() {
    MyString s = createGreeting();  // 从临时对象复制构造
    // 临时对象随后被销毁
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

在这个过程中,我们做了什么?

  • 创建greeting,分配内存并填充"Hello, world!"
  • 返回时创建临时对象,又分配内存并复制"Hello, world!"
  • 构造s时,再次分配内存并复制"Hello, world!"
  • 临时对象销毁,释放其内存

三次内存分配,两次不必要的复制! 有没有更好的方法?

3. 移动语义:合法的资源"窃取"

C++11引入的移动语义允许我们直接"偷取"即将被销毁的对象的资源:

// 移动构造函数
MyString(MyString&& other) noexcept {
    length = other.length;
    data = other.data;         // 直接偷走指针
    other.data = nullptr;      // 把被偷的对象标记为"已被偷"
    other.length = 0;
}

// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept {
    if (this != &other) {
        delete[] data;         // 释放自身原有资源
        length = other.length;
        data = other.data;     // 偷走other的资源
        other.data = nullptr;  // 标记other为"已被偷"状态
        other.length = 0;
    }
    return *this;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

现在我们的代码变成:

MyString createGreeting() {
    MyString greeting("Hello, world!");
    return greeting;  // 返回时会创建临时对象
}
void useString() {
    MyString s = createGreeting();  // 现在可以移动构造,而不是复制
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

这里的巨大优势在于:

  • 只有一次内存分配(在createGreeting里面)
  • 没有不必要的复制
  • 通过简单地转移指针所有权,我们获得了巨大的性能提升

4. 移动语义背后的魔法细节

来聊聊那些你必须知道的移动语义细节,我尽量用最简单的语言和例子说明:

(1) 被移动对象必须保持有效但状态不确定

MyString source("Hello");
MyString dest = std::move(source);  // 移动构造

// 此时source仍然是有效的对象,但它的内容是什么?
// 我们只知道它不再拥有原来的字符串资源,但具体状态不确定
// 你可以对source赋新值,但不应该使用它的当前值

// 此时直接使用source是危险的
std::cout << source.data;    // 危险!source可能已经是空指针

// 但给source赋新值是安全的
source = MyString("World");  
// 赋值后,使用source又变得安全了
std::cout << source.data;    // 现在安全了,因为source有了新值
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

为了安全,最好的做法是把被移动的对象当作"已经被掏空"的东西,不要再使用它的值,直到你给它赋予新值。

(2) 移动操作应该标记为noexcept(这很重要)

// 最佳实践:标记移动操作为noexcept
MyString(MyString&& other) noexcept {
    data = other.data;
    other.data = nullptr;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

为什么要加noexcept?这涉及到STL容器的性能优化:

// 这样STL容器在扩容时会优先使用移动而不是复制
std::vector<MyString> vec;
vec.push_back(MyString("test"));  // 当vector需要扩容时会使用移动操作
  • 1.
  • 2.
  • 3.

关键点:虽然在简单移动场景下,不加noexcept也会调用移动构造函数,但在STL容器的特定操作中(特别是扩容时),noexcept会产生重要影响:

  • 如果移动构造标记了noexcept,STL容器知道移动操作不会抛异常,就可以放心使用更高效的移动操作
  • 如果没有标记noexcept,某些STL实现会采取保守策略,在需要保证异常安全性的场景下退回到复制操作

简而言之,加上noexcept是一种优化提示,告诉STL容器:"放心,我的移动操作绝对不会抛异常,你可以放心使用它来提高性能!"

(3) 简单类型的移动等同于复制

// 对于int、double这样的简单类型,移动和复制没有区别
int a = 5;
int b = std::move(a);  // 和 int b = a; 效果完全一样
std::cout << a;  // 输出仍然是5,因为int的移动就是复制
  • 1.
  • 2.
  • 3.
  • 4.

移动语义只对管理资源(指针、句柄等)的类有明显优势。

(4) 自定义类的规则变了

在C++11之前,如果你不定义任何特殊函数,编译器会自动生成:

  • 默认构造函数
  • 复制构造函数
  • 复制赋值运算符
  • 析构函数

C++11之后,又多了两个:

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

但有个重要规则:如果你自定义了复制操作,编译器不会生成移动操作;反之亦然。

class OnlyCopy {
public:
    OnlyCopy(const OnlyCopy& other) { /*...*/ }  // 只定义了复制构造
    // 编译器不会生成移动构造和移动赋值
};

class OnlyMove {
public:
    OnlyMove(OnlyMove&& other) noexcept { /*...*/ }  // 只定义了移动构造
    // 编译器不会生成复制构造和复制赋值
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

如果你想要两者都有,就必须两者都自己定义!

5. 现实中移动语义的使用场景

来看几个真实场景,理解移动语义为什么这么强大:

(1) 容器扩容的性能飞跃

当std::vector需要扩容时,它需要把所有元素从旧内存转移到新内存。看看有无移动语义的区别:

// 创建一个字符串向量并添加数据
std::vector<MyString> names;
for(int i = 0; i < 1000; ++i) {
    names.push_back(MyString("很长的字符串..."));  // 每次可能导致扩容
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

没有移动语义时:

  • 分配新内存(原来大小的1.5或2倍)
  • 复制构造所有元素到新内存(每个元素都要新分配内存并复制字符串内容)
  • 析构旧内存中的所有元素
  • 释放旧内存

有移动语义时:

  • 分配新内存
  • 移动构造所有元素到新内存(只是转移指针,不复制内容)
  • 析构旧内存中的所有元素(这些都是被移动过的空壳)
  • 释放旧内存

性能对比:对于管理大量内存的类(如字符串、容器),移动比复制可能快几倍甚至数10倍!

(2) 返回大对象的函数

C++里返回大对象一直是个性能担忧,看看移动语义怎么解决这个问题:

// 返回一个包含百万个元素的向量
std::vector<int> createLargeVector() {
    std::vector<int> result;
    for(int i = 0; i < 1000000; ++i) {
        result.push_back(i);
    }
    return result;  // 返回一个巨大的对象!
}

// 使用这个函数
void useVector() {
    std::vector<int> myVec = createLargeVector();  // 不用担心性能了!
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

C++98时代:这会导致一次额外的复制,程序员经常被迫使用输出参数或动态分配来避免这个开销。

C++11移动语义后:编译器会自动优化,使用移动语义避免多余复制。甚至在更多情况下,编译器可能应用返回值优化(RVO),完全消除复制/移动。

(3) 交换(swap)操作的巨大改进

移动语义让交换操作变得超级高效:

// 交换两个很大的字符串
MyString a("超长字符串...");
MyString b("另一个超长字符串...");

// C++98的交换
void old_swap(MyString& a, MyString& b) {
    MyString temp(a);  // 复制构造
    a = b;            // 复制赋值
    b = temp;         // 复制赋值
}

// C++11的交换
void new_swap(MyString& a, MyString& b) {
    MyString temp(std::move(a));  // 移动构造
    a = std::move(b);             // 移动赋值
    b = std::move(temp);          // 移动赋值
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

性能提升:在管理大量资源的类中,移动交换可能比传统交换快几十倍!这也是为什么C++11标准库全面升级了std::swap的实现。

(4) 智能指针与移动语义的完美配合

移动语义让std::unique_ptr真正变得易用。理解这一点很简单:

// unique_ptr的核心特点:独占所有权,不允许复制
std::unique_ptr<BigObject> p1(new BigObject());
// std::unique_ptr<BigObject> p2 = p1;  // 错误!不能复制

// 但有了移动语义,我们可以转移所有权:
std::unique_ptr<BigObject> p2 = std::move(p1);  // 成功!p1变为空,p2获得所有权
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

这让我们能轻松地在函数间传递unique_ptr:

std::unique_ptr<BigObject> createObject() {
    auto ptr = std::make_unique<BigObject>();
    // 配置对象...
    return ptr;  // 自动使用移动语义,资源所有权被转移
}

void processObject() {
    auto obj = createObject();  // obj获得所有权
    // 使用obj...
}  // obj自动释放资源
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

简单来说:没有移动语义,unique_ptr 就像一个不能传递的"门票";有了移动语义,它变成可以转让但同一时刻只有一人持有的"门票"。这让我们能同时拥有安全性和灵活性!

6. 小贴士:移动语义的失效情况

有些情况下即使你用了std::move,移动语义也会失效:

对象没有移动操作 如果类没有定义移动构造/赋值,std::move会退化为复制操作。

移动操作被禁用 某些类可能显式删除了移动操作。

移动不如复制快 对于某些简单类型,编译器可能选择复制而不是移动,因为复制可能更高效。

现在,你对"偷"资源的艺术是不是有更清晰的理解了?移动语义是C++11最重要的特性之一,掌握它会让你的代码性能有质的飞跃!

四、std::move:不是移动,是变身大法

这个名字起得有点坑人,很多C++新手看到std::move就以为它会移动什么东西。事实上:

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

1. std::move的真相

它的真正作用非常简单:把一个左值强制转换为右值引用类型。

// std::move简化版实现(揭开它的神秘面纱)
template<typename T>
typename std::remove_reference<T>::type&& move(T&& param) {
    return static_cast<typename std::remove_reference<T>::type&&>(param);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

不用被上面的代码吓到,它本质上就是一个类型转换函数,相当于:

// 伪代码,更容易理解
template<typename T>
右值引用类型 move(参数) {
    return 把参数转成右值引用类型;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

2. 为什么叫"变身大法"?

想象一下,std::move就像是一个魔法标签:

MyString a("Hello");  // a是个普通的左值

// std::move在这里施了个魔法!
MyString b = std::move(a);  // 把a贴上"可以偷我"的标签
  • 1.
  • 2.
  • 3.
  • 4.

这个魔法做了什么?

  • 它把a从"普通左值"变身为"右值引用"
  • 这个变身让编译器调用移动构造函数而不是复制构造函数
  • 移动构造函数看到右值引用,心想:"这家伙被标记为可偷了,我可以偷它的资源!"

3. 看个实际例子

#include <iostream>
#include <string>
usingnamespacestd;

int main() {
    string name = "Hello World";  // name是个左值
    
    cout << "原始name: " << name << endl;
    
    // 错误理解:下面这行会移动name
    string new_name = std::move(name);
    
    // 真相:std::move只是转换类型,真正的移动发生在string的移动构造函数中
    cout << "移动后name: " << name << endl;  // name可能为空或未定义状态
    cout << "new_name: " << new_name << endl;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

输出可能是:

原始name: Hello World
移动后name: 
new_name: Hello World
  • 1.
  • 2.
  • 3.

为什么说"可能为空"?因为被移动的对象处于"有效但未指定"的状态,标准只保证它可以安全析构,不保证它的具体内容。实际上对于std::string,大多数实现中移动后的字符串会变为空。

4. std::move使用注意事项

  • 移动后不要再使用原对象的值:
vector<int> v1 = {1, 3, 5};
auto v2 = std::move(v1);  // v1被转换成右值引用
// cout << v1[0] << endl;  // 危险!v1的状态未定义
v1 = {1, 2, 3};  // 重新赋值后才能安全使用
  • 1.
  • 2.
  • 3.
  • 4.
  • 返回值不需要std::move:
// 不要这样做
vector<int> createVector() {
    vector<int> result = {1, 2, 3};
    return std::move(result);  // 多余的!编译器已经会自动应用返回值优化
}

// 正确做法
vector<int> createVector() {
    vector<int> result = {1, 2, 3};
    return result;  // 编译器会自动处理
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 何时该用std::move
// 场景1:当你确定不再需要某个变量时
string str = "hello";
doSomething(std::move(str));  // str的值被移走了

// 场景2:实现移动语义
void swap(T& a, T& b) {
    T temp = std::move(a);  // 移动a到temp
    a = std::move(b);       // 移动b到a
    b = std::move(temp);    // 移动temp到b
}

// 场景3:将对象插入容器
vector<MyObject> v;
MyObject obj;
v.push_back(std::move(obj));  // 避免不必要的复制
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

5. 小贴士:std::forward vs std::move

std::move和std::forward容易混淆:

  • std::move总是无条件地将参数转为右值引用
  • std::forward根据模板参数类型有条件地转换(完美转发,这个我们下篇聊)

6. 总结:理解std::move

  • std::move不移动任何东西,它只是类型转换
  • 真正的移动发生在移动构造函数或移动赋值运算符中
  • 移动后,原对象仍然存在,但状态不确定
  • 只有当你不再需要原对象的值时,才使用std::move

记住这个比喻:std::move就像是给对象贴了个"可偷"的标签,告诉编译器:"这个对象的资源可以被偷走!"

五、实战例子:移动语义的威力

来看个实际例子,感受一下移动语义带来的性能提升:

#include <iostream>
#include <vector>
#include <string>
#include <chrono>

int main() {
    constint NUM_STRINGS = 100000;  // 测试字符串数量
    constint STRING_SIZE = 1000;      // 每个字符串大小

    // 创建源数据 - 两个相同的字符串向量
    std::vector<std::string> sourceDataCopy;
    std::vector<std::string> sourceDataMove;

    // 填充源数据
    for (int i = 0; i < NUM_STRINGS; i++) {
        sourceDataCopy.push_back(std::string(STRING_SIZE, 'x'));
        sourceDataMove.push_back(std::string(STRING_SIZE, 'x'));
    }

    // 准备目标容器
    std::vector<std::string> destCopy;
    std::vector<std::string> destMove;
    destCopy.reserve(NUM_STRINGS);
    destMove.reserve(NUM_STRINGS);

    // 测试复制性能
    auto startCopy = std::chrono::high_resolution_clock::now();

    for (constauto& str : sourceDataCopy) {
        destCopy.push_back(str);  // 复制插入
    }

    auto endCopy = std::chrono::high_resolution_clock::now();

    // 测试移动性能
    auto startMove = std::chrono::high_resolution_clock::now();

    for (auto& str : sourceDataMove) {
        destMove.push_back(std::move(str));  // 移动插入
    }

    auto endMove = std::chrono::high_resolution_clock::now();

    // 计算时间
    auto copyTime = std::chrono::duration_cast<std::chrono::milliseconds>(endCopy - startCopy).count();
    auto moveTime = std::chrono::duration_cast<std::chrono::milliseconds>(endMove - startMove).count();

    // 输出结果
    std::cout << "插入" << NUM_STRINGS << "个字符串(每个"
        << STRING_SIZE << "字符)的时间对比:\n";
    std::cout << "复制方式: " << copyTime << " ms\n";
    std::cout << "移动方式: " << moveTime << " ms\n";
    std::cout << "性能比率: 复制/移动 = "
        << (moveTime > 0 ? static_cast<double>(copyTime) / moveTime : 0)
        << " 倍\n";

    // 验证移动确实发生了
    size_t emptyCount = 0;
    for (constauto& str : sourceDataMove) {
        if (str.empty()) emptyCount++;
    }
    std::cout << "被移动的源字符串数量: " << emptyCount << " (应该接近于 " << NUM_STRINGS << ")\n";

    return0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.

在我的电脑上运行结果(你的可能不同):

插入100000个字符串(每个1000字符)的时间对比:
复制方式: 170 ms
移动方式: 68 ms
性能比率: 复制/移动 = 2.5 倍
被移动的源字符串数量: 100000 (应该接近于 100000)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

看到差距了吗?移动比复制快了近3倍!插入的字符串数量越多,效果越明显!

六、何时使用移动语义?

理解了移动语义的原理后,关键问题来了:什么时候该用它?

下面我从实战角度详细讲解各种常见场景。

1. 不再需要某对象的值时

当你确定不再需要某个变量的值时,可以安全地"偷走"它的资源:

std::string name = "一个很长的字符串...";
std::string name2;

// 当你确定之后不再使用name的值时
name2 = std::move(name);  // 直接偷走name的资源

// 此后不应该再访问name的值,除非重新赋值
// cout << name << endl;  // 危险!name可能已被掏空
name = "新值";  // 这样是安全的
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

这是最常见也最实用的移动语义场景,尤其适用于大型对象(如字符串、容器等)的传递。

2. 函数返回值(通常不需要std::move)

对于函数返回值,编译器通常会自动应用返回值优化(RVO)或移动语义:

std::vector<int> createVector() {
    std::vector<int> result;
    // 填充result...
    
    return result;  // 不需要std::move!
    // 编译器会自动优化,可能直接在调用者空间构造,
    // 或者应用移动语义
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

错误示范:

std::vector<int> createVector() {
    std::vector<int> result;
    // ...
    return std::move(result);  // 错误用法!可能阻碍RVO
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

为什么不需要std::move?因为C++标准允许编译器在返回局部变量时省略复制/移动(RVO 返回值优化),这比移动更高效。而使用std::move反而会阻止这种优化!

3. 实现容器类或资源管理类

如果你在设计自己的容器或管理资源的类,移动语义是必不可少的:

class Buffer {
private:
    char* data;
    size_t size;

public:
    // 移动构造函数
    Buffer(Buffer&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }
    
    // 移动赋值运算符
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
            other.data = nullptr;
            other.size = 0;
        }
        return *this;
    }
    
    // ... 其他成员 ...
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.

4. 向容器中插入大型对象

当向容器中添加元素时,移动可以避免不必要的复制:

std::vector<MyLargeObject> collection;

MyLargeObject obj = createLargeObject();  // 创建一个大对象

// 不好的方式:复制obj到容器
collection.push_back(obj);  // obj会被复制

// 更好的方式:移动obj到容器
collection.push_back(std::move(obj));  // obj被移动,避免复制
// 此后obj处于有效但未指定状态
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

5. swap函数实现

移动语义让交换操作变得更高效:

template <typename T>
void my_swap(T& a, T& b) {
    T temp = std::move(a);  // 移动a到temp
    a = std::move(b);       // 移动b到a
    b = std::move(temp);    // 移动temp到b
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

6. 函数参数使用右值引用

当你想在函数内部"窃取"参数资源时:

void processAndStore(std::string&& str) {
    // 因为参数是右值引用,我们知道调用者不再需要它
    storage.push_back(std::move(str));  // 可以安全地移动
}

// 调用方式
processAndStore(std::string("临时字符串"));  // 直接传递临时对象
std::string s = "hello";
processAndStore(std::move(s));  // 明确表示不再需要s的值
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

7. 什么时候不要使用移动语义?

了解不应该使用移动的场景同样重要:

(1) 当你还需要使用源对象的值时

std::string name = "Alice";
std::string greeting = "Hello, " + std::move(name);  // 错误用法!
std::cout << "Name: " << name << std::endl;  // name的值现在不确定
  • 1.
  • 2.
  • 3.

(2) 不必要的std::move

// 不需要这样做
return std::move(result);  // 多余的!编译器会自动处理返回值优化(RVO)
  • 1.
  • 2.

(3) 简单类型(如int、double等)

int a = 5;
int b = std::move(a);  // 没有效果,和 int b = a; 完全一样
  • 1.
  • 2.

8. 移动语义自动触发的地方

在某些情况下,移动语义会自动触发,无需显式使用std::move:

(1) 返回局部变量

std::vector<int> createVector() {
    std::vector<int> result;
    // 填充result...
    return result;  // 编译器通常会自动应用移动语义或返回值优化
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

(2) 临时对象初始化

std::string s = std::string("hello") + " world";  // 右侧临时对象会被移动,而非复制
  • 1.

9. 移动语义测试

如何验证移动语义是否真的生效?可以添加打印语句:

class MyClass {
public:
    MyClass() { std::cout << "构造\n"; }
    MyClass(const MyClass&) { std::cout << "复制构造\n"; }
    MyClass(MyClass&&) noexcept { std::cout << "移动构造\n"; }
    // ...
};

int main() {
    MyClass a;
    MyClass b = a;           // 输出"复制构造"
    MyClass c = std::move(a); // 输出"移动构造"
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

10. 总结:移动语义的最佳实践

  • 当确定不再需要原对象的值时,使用std::move
  • 为你的类实现移动操作,并标记为noexcept
  • 函数参数中使用右值引用可以"窃取"临时对象的资源
  • 移动后不要使用原对象的值,除非重新赋值
  • 对于大型对象,优先考虑移动而非复制

记住:移动语义是C++性能优化的重要武器!

七、小结:左值、右值与移动语义的关系

  • 左值:有名字、有地址的东西
  • 右值:临时的、即将消亡的东西
  • 左值引用&:绑定到左值的引用
  • 右值引用&&:绑定到右值的引用
  • 移动语义:利用右值引用从即将消亡的对象"偷"资源
  • std::move:把左值变身为右值引用,允许我们对左值应用移动语义

八、写在最后:移动语义小贴士

搞懂了移动语义的原理,我再给你几个简单实用的小贴士,帮你在实际编码中用好这个特性:

1. 日常使用要点

记住移动后原对象就像"被偷了家":

string original = "Hello World";
string new_str = std::move(original);

// original现在可能是空的!除非你给它新值,否则别再用它
  • 1.
  • 2.
  • 3.
  • 4.

合适的场景才用std::move:

  • 当你确定不再需要某个变量值时
  • 当你要把资源从一个对象转移到另一个对象时
  • 当你往容器里插入大对象时

不要对简单类型用std::move:

int a = 5;
int b = std::move(a);  // 没意义,和 int b = a; 完全一样
  • 1.
  • 2.

2. 自己写类时的注意事项

实现移动操作时记得"掏空"原对象:

MyClass(MyClass&& other) noexcept {
    data = other.data;       // 偷资源
    other.data = nullptr;    // 记得标记原对象"已被偷" 
}
  • 1.
  • 2.
  • 3.
  • 4.

移动操作最好标记为noexcept:

  • 这样能提高容器操作的性能
  • 使用noexcept关键字即可

如果实现了移动,通常也需要实现复制:

  • 大多数情况下两者都需要
  • 在实现移动操作时,记得正确处理资源所有权

3. 简单判断口诀

  • 临时对象或右值 → 自动触发移动
  • 命名变量 → 需要std::move才能触发移动
  • 移动后的变量 → 不要再使用它的值(除非重新赋值)

怎么样,现在对左值、右值和移动语义是不是有了更清晰的理解?C++的这部分特性虽然开始有点绕,但掌握后真的能写出更高效的代码。

下次当你看到std::move或者&&时,你就能自信地说:"我知道这是在做什么!"

责任编辑:赵宁宁 来源: 跟着小康学编程
相关推荐

2025-02-07 09:58:43

C++11Lvalue对象

2022-02-16 12:52:22

C++项目编译器

2022-07-26 00:36:06

C#C++函数

2010-02-03 17:32:54

C++左值与右值

2020-08-11 11:00:16

左值引用右值引用移动语义

2012-02-13 10:18:42

C++ 11

2024-12-17 17:24:24

2015-11-12 10:03:34

前端H5web

2024-03-05 09:55:00

C++右值引用开发

2017-01-13 23:06:45

swiftios

2024-01-31 23:51:22

C++移动语义代码

2021-11-10 12:13:02

HostonlyCookie浏览器

2009-11-12 09:37:14

Visual Stud

2017-04-03 15:35:13

知识体系架构

2020-09-27 06:53:57

MavenCDNwrapper

2021-12-03 17:22:09

CC++编程语言

2010-02-06 15:49:31

删除C++容器值

2019-10-30 10:13:15

区块链技术支付宝

2021-07-06 10:17:07

Python LaunLinuxWindows

2015-03-17 10:13:52

HTML5什么鬼
点赞
收藏

51CTO技术栈公众号