告别复制的烦恼:深入理解 C++ 的左右值引用、std::move 和 std::forward

/ 0评 / 0

  C++11 引入的右值引用是语言的一项重大改进,它们开启了 移动语义和 完美转发这两大核心功能,极大地提升了程序的性能和泛型编程的能力。然而,左右值引用以及 std::move 和 std::forward 这几个概念常常让初学者甚至有经验的开发者感到困惑。
  别担心,本文将层层剖析这些概念,并通过例子让你彻底理解它们!

1.左值和右值是什么?

在理解右值引用之前,我们得先弄明白 C++ 中的表达式是左值 还是右值。

简单来说:

左值:表示一个拥有内存地址、具有持久性的对象。你可以用(&)取出它的地址,它通常出现在赋值号的左边

右值:表示一个临时的、不具有持久性的值。它没有可供用户直接访问的内存地址(或者说,它的生命周期很短)。它通常出现在赋值号的右边,或者作为函数调用的参数。

例子:

int main(){
    int a = 10;         // 'a' 是一个左值 (变量名)
    int b = 20;         // 'b' 是一个左值 (变量名)

    a = b;              // 左边是左值 'a', 右边是左值 'b'
    a = 10 + 20;        // 左边是左值 'a', 右边是右值 (表达式结果 30 是临时的)
    a = std::max(b, 30); // 左边是左值 'a', 右边是右值 (函数返回的临时值)

    int& ref = a;       // 左值引用可以绑定到左值 'a' 左值应用的写法就是这样 数据类型 &变量=左值;
    // int& ref2 = 10;  // 错误:非 const 左值引用不能绑定到右值 10

    const int& ref3 = 10; // 正确:const 左值引用可以绑定到右值,且延长临时值的生命周期
                          

    return 0;

}

  传统的 C++ (C++98) 只有左值引用 (&)。一个非 const 的左值引用只能绑定到左值。这带来一个问题:如果你有一个非常大的对象(比如 std::vector<int>),在函数间传递它时,如果是按值传递会发生昂贵的复制。即便使用左值引用,你也只能绑定到一个已有的左值对象。那对于那些马上就要消亡的临时对象,能不能有一种更高效的方式处理它们的资源呢?

2.右值引用(&&)登场

C++11引入了右值引用来解决上述问题

—— 一个右值引用主要用来绑定右值(临时对象、字面量等)。

—— 它的核心目的实现移动语义

例子:

int main(){
     int&& rv_ref = 10;      // 正确:右值引用绑定到右值 10
    // int&& rv_ref2 = a;   // 错误:右值引用不能绑定到左值 'a' (除非经过特定转换,后面会讲)

    std::string s1 = "hello";
    std::string&& rv_str_ref = std::string("world"); // 右值引用绑定临时 string 对象

    return 0;
    
}

3.移动语义

右值引用最大的价值就在于移动语义。在考虑到一个拥有大量资源的类,比如 std::vector 或 std::string 。它们内部通常管理着一块动态分配的内存。如果没有移动语义,对象复制之间开销很大。当发生复制时(比如通过拷贝构造函数或拷贝复制运算符),需要:

        1.重新分配一块内存

        2.将源对象内存中的数据完整复制到新的内存中。

        3.更新指针

这对于大型对象来说非常耗时和消耗内存。有了移动语义,就可以节省内存。当源对象是一个右值(意味着它是一个临时对象,即将消亡,不会在被使用)时,我们就不需要深拷贝。我们可以直接:

        1.将源对象内部资源(比如内存块的指针)“转移”给对象。

        2.将源对象的资源指针置空(nullptr)或标记为无效状态,防止其析构时释放已被转移的资源。

这个过程就像是资源的“搬家”而不是“复制”,效率就大大提高了。

实现移动语义需要为类提供:

—— 移动构造函数:ClassName(ClassName&& other);

—— 移动赋值运算符:ClassName &operator=(ClassName&& other);

概念性的例子(简化版MyString):

#include <iostream>
#include <cstring>
#include <utility> // For std::swap

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

public:
    // 构造函数
    MyString(const char* str = "") {
        size = strlen(str);
        data = new char[size + 1];
        strcpy(data, str);
        std::cout << "Constructor: " << data << "\n";
    }

    // 析构函数
    ~MyString() {
        std::cout << "Destructor: " << (data ? data : "nullptr") << "\n";
        delete[] data;
    }

    // 拷贝构造函数 (深拷贝)
    MyString(const MyString& other) : size(other.size) {
        data = new char[size + 1];
        strcpy(data, other.data);
        std::cout << "Copy Constructor: " << data << " from " << other.data << "\n";
    }

    // 拷贝赋值运算符 (深拷贝)
    MyString& operator=(const MyString& other) {
        std::cout << "Copy Assignment: " << (data ? data : "nullptr") << " from " << other.data << "\n";
        if (this != &other) {
            delete[] data; // 释放旧资源
            size = other.size;
            data = new char[size + 1];
            strcpy(data, other.data); // 复制新资源
        }
        return *this;
    }

    // === 移动构造函数 (C++11) ===
    MyString(MyString&& other) noexcept // noexcept 是一种优化提示
        : data(other.data), size(other.size) // 浅拷贝指针和大小
    {
        other.data = nullptr; // 将源对象的指针置空,防止二次释放
        other.size = 0;
        std::cout << "Move Constructor: Stealing resources from " << (other.data ? other.data : "nullptr") << ". New data: " << (data ? data : "nullptr") << "\n";
    }

    // === 移动赋值运算符 (C++11) ===
    MyString& operator=(MyString&& other) noexcept // noexcept 是一种优化提示
    {
        std::cout << "Move Assignment: " << (data ? data : "nullptr") << " from " << (other.data ? other.data : "nullptr") << "\n";
        if (this != &other) {
            delete[] data; // 释放旧资源

            data = other.data; // 浅拷贝指针和大小
            size = other.size;

            other.data = nullptr; // 将源对象的指针置空
            other.size = 0;
        }
        return *this;
    }

    // 为了示例方便打印内部状态
    const char* GetData() const { return data; }
    size_t GetSize() const { return size; }
};

主函数:

int main() {
    std::cout << "Creating s1:\n";
    MyString s1 = "hello world, this is a test string"; // Constructor

    std::cout << "\nCreating s2 by copying s1:\n";
    MyString s2 = s1; // Copy Constructor (s1 是左值)
    std::cout << "s1: " << s1.GetData() << ", s2: " << s2.GetData() << "\n";

    std::cout << "\nCreating s3 from a temporary:\n";
    // MyString('...') 返回的是一个临时 MyString 对象,它是一个右值
    MyString s3 = MyString("another temporary string"); // Move Constructor (临时对象是右值)
    std::cout << "s3: " << s3.GetData() << "\n"; // 注意,MyString("...") 这个临时对象已经把资源转移给 s3 了

    std::cout << "\nAssigning temporary to s1:\n";
    s1 = MyString("yet another temp"); // Move Assignment (临时对象是右值)
    std::cout << "s1: " << s1.GetData() << "\n";

    std::cout << "\nEnd of main:\n";
    return 0;
} // s1, s2, s3 依次析构

输出:

Creating s1:
Constructor: hello world, this is a test string

Creating s2 by copying s1:
Copy Constructor: hello world, this is a test string from hello world, this is a test string
s1: hello world, this is a test string, s2: hello world, this is a test string

Creating s3 from a temporary:
Constructor: another temporary string
Move Constructor: Stealing resources from nullptr. New data: another temporary string // 发生了移动!
s3: another temporary string
Destructor: nullptr // 临时 MyString("another temporary string") 析构了,但因为资源已被转移,析构安全无副作用

Assigning temporary to s1:
Constructor: yet another temp
Move Assignment: hello world, this is a test string from yet another temp // 发生了移动!
Destructor: hello world, this is a test string // s1 原来的资源被释放
Destructor: nullptr // 临时 MyString("yet another temp") 析构了,资源已转移

End of main:
Destructor: yet another temp // s1 析构
Destructor: hello world, this is a test string // s2 析构
Destructor: another temporary string // s3 析构

  从输出日志可以看出,当从一个临时对象(右值)构造或赋值时,调用的是移动构造函数/移动赋值运算符,并且源对象的内部指针被设置为 nullptr,避免了昂贵的深拷贝。

4.std::move()是什么?

看上面的例子,移动构造和移动赋值是自动触发的,因为源对象 MyString("...") 就是一个显而易见的右值(临时对象)。

但是,有时候我们有一个左值对象,但我们知道我们使用完它后,它就不会再被使用了。比如在一个函数内部,我们创建了一个大对象并打算将其作为函数返回值,或者我们在集合 A 中处理完一个元素后,打算将其内容转移到集合 B 并从 A 中移除它。

在这种情况下,我们想让编译器对这个左值对象也应用移动语义,而不是拷贝语义。std::move 就是用来做这个的。

4.1 std::move()的作用

std::move()并不执行任何移动操作!它只是一个类型转换,它将输入的表达式强制转换成一个对应的右值引用。通过把一个左值变成右值引用,来欺骗(或者说指示)编译器,让它有机会选择移动构造函数或移动赋值运算符的重载版本(如果类提供了的话),而不是拷贝版本。

记住: std::move()只是让你能够移动,实际是否发生移动取决于类型是否有移动构造/赋值,以及编译器是否选择了它们。

例子:

#include <iostream>
#include <string>
#include <vector>
#include <utility> // For std::move

int main() {
    std::string s1 = "very long string that demonstrates allocation";
    std::cout << "s1 before move: '" << s1 << "'\n";

    // 使用 std::move 将 s1 转换为右值引用
    // 这使得 std::string 的移动构造函数有机会被调用
    std::string s2 = std::move(s1); // std::string(string&&) 被调用

    std::cout << "s1 after move: '" << s1 << "'\n"; // s1 的状态变得不确定,通常是空的或有效但无指定值
    std::cout << "s2 after move: '" << s2 << "'\n"; // s2 获得了 s1 的资源

    std::vector<int> v1(1000, 1);
    std::cout << "v1 size before move: " << v1.size() << "\n";

    std::vector<int> v2 = std::move(v1); // std::vector(vector&&) 被调用

    std::cout << "v1 size after move: " << v1.size() << "\n"; // v1 状态不确定,通常为空
    std::cout << "v2 size after move: " << v2.size() << "\n"; // v2 获得了 v1 的资源

    // 注意:不要在 std::move 之后依赖原对象(s1, v1)的内容,除了对它们进行重新赋值或销毁。
    // 例如:s1 = "new content"; // 这是合法的

    return 0;
}

输出:

s1 before move: 'very long string that demonstrates allocation'
s1 after move: '' // 或其他空状态
s2 after move: 'very long string that demonstrates allocation'
v1 size before move: 1000
v1 size after move: 0 // 通常为空
v2 size after move: 1000

这个例子的核心是:通过 std::move(s1),我们将左值 s1 表达为了一个右值引用。然后,std::string s2 = ... 这个语句的初始化过程发现右边是一个右值引用,因此选择了 std::string 的移动构造函数,而不是拷贝构造函数。

5. 引用折叠和万能引用

要理解 std::forward,我们需要先了解两个更高级的概念:引用折叠和万能引用。

折叠引用:

C++ 中有一些模板推导规则和规则允许你在某些情况下创建引用的引用。但最终结果会遵循以下折叠规则:

简而言之:只要有任何一个是左值引用 (&),结果就是左值引用 (&);只有两个都是右值引用 (&&),结果才是右值引用 (&&)。

万能引用:

这是一个在 模板参数推导 过程中,类型为 T&& 的参数的特别称谓。它看起来像右值引用,但它的实际行为取决于传递给它的参数类型

考虑一个函数模板:

template <typename T>
void process(T&& arg) {
    // ...
}

如果传递一个左值给 process (例如 int a; process(a);):

编译器推导出 T 的类型是 int& (注意,不是 int)。根据引用折叠规则,T&& (也就是 (int&) &&) 会折叠成 int&。所以 arg 最终是一个左值引用 (int&)。

如果传递一个右值给 process (例如 process(10); 或 process(std::string("temp"));):

编译器推导出 T 的类型是 int 或 std::string (非引用类型)。根据引用折叠规则,T&& (也就是 int&& 或 std::string&&) 保持为 int&& 或 std::string&&。所以 arg 最终是一个右值引用 (int&& 或 std::string&&)。

结论: 在 template<typename T> void func(T&& arg) 这种形式中:

如果传入左值,arg 会被推导并折叠成左值引用。如果传入右值,arg 会被推导并保留为右值引用。

这种参数 (T&& 在模板推导时) 可以接受任何类型(左值或右值),并且保留了原始参数的引用类型信息,因此被称为万能引用或更准确地称为转发引用 。

6. std::forward()是什么?

std::forward 就是为了配合万能引用实现完美转发 (Perfect Forwarding) 而设计的。

场景: 我们写一个泛型函数模板,它接收任意类型的参数,然后将这些参数原封不动地转发给另一个函数或构造函数。这里的"原封不动地"是指:

只使用万能引用本身并不能实现完美转发。在上面的 process(T&& arg) 例子中,尽管 arg 保留了原始参数的左值/右值属性,但 arg 本身在 process 函数体内却始终被视为一个左值(因为它是一个具名的变量,你可以取它的地址)。

问题: 如果在 process 内部直接调用另一个函数 other_func(arg),即使原始参数是右值,arg 作为函数体内的变量,也会以左值引用传递给 other_func 的相应重载(如果存在 other_func(const Something&) 或 other_func(Something&))。这会丢失原始参数的右值属性,从而可能导致不必要的复制。

6.1 std::forward 的作用

std::forward<T>(arg) 是一个条件转换:

这正是我们想要的完美转发行为:如果原始参数是左值,就转发为左值;如果原始参数是右值,就转发为右值(以便接收方可以移动)。

std::forward 的使用方式通常是 std::forward< decltype(arg) >(arg) 或在模板函数中直接使用 template parameter T as std::forward<T>(arg) 。

例子:

假设我们有一个需要区分不同引用类型的函数 recv

#include <iostream>
#include <string>
#include <utility> // For std::forward

void recv(std::string& s) { // 左值引用重载
    std::cout << "recv(string&): " << s << std::endl;
}

void recv(const std::string& s) { // const 左值引用重载
    std::cout << "recv(const string&): " << s << std::endl;
}

void recv(std::string&& s) { // 右值引用重载 (可以触发移动)
    std::cout << "recv(string&&): " << s << std::endl;
}

// 不进行完美转发的包装函数
template <typename T>
void naive_wrapper(T&& arg) { // 万能引用
    std::cout << "Inside naive_wrapper:\n";
    recv(arg); // arg 在此函数体内是左值,总是调用左值引用或 const 左值引用重载
    std::cout << "Leaving naive_wrapper\n";
}

// 进行完美转发的包装函数
template <typename T>
void perfect_wrapper(T&& arg) { // 万能引用
    std::cout << "Inside perfect_wrapper:\n";
    // 使用 std::forward<T> 保持原始参数的左值/右值属性
    recv(std::forward<T>(arg));
    std::cout << "Leaving perfect_wrapper\n";
}

int main() {
    std::string s1 = "hello lvalue";
    const std::string s_const = "hello const lvalue";

    std::cout << "--- Calling naive_wrapper with lvalue ---\n";
    naive_wrapper(s1); // T deduced as string&, arg is string&. recv(arg) calls recv(string&)

    std::cout << "\n--- Calling naive_wrapper with const lvalue ---\n";
    naive_wrapper(s_const); // T deduced as const string&, arg is const string&. recv(arg) calls recv(const string&)

    std::cout << "\n--- Calling naive_wrapper with rvalue ---\n";
    naive_wrapper(std::string("hello rvalue")); // T deduced as string, arg is string&&. BUT within naive_wrapper, arg is lvalue. recv(arg) calls recv(const string&) because can't bind non-const lvalue to temporary.

    std::cout << "\n--- Calling perfect_wrapper with lvalue ---\n";
    perfect_wrapper(s1); // T deduced as string&. std::forward<string&>(arg) becomes string&. recv calls recv(string&)

    std::cout << "\n--- Calling perfect_wrapper with const lvalue ---\n";
    perfect_wrapper(s_const); // T deduced as const string&. std::forward<const string&>(arg) becomes const string&. recv calls recv(const string&)

    std::cout << "\n--- Calling perfect_wrapper with rvalue ---\n";
    perfect_wrapper(std::string("hello rvalue")); // T deduced as string. std::forward<string>(arg) becomes string&&. recv calls recv(string&&)

    return 0;
}

输出:

--- Calling naive_wrapper with lvalue ---
Inside naive_wrapper:
recv(string&): hello lvalue
Leaving naive_wrapper

--- Calling naive_wrapper with const lvalue ---
Inside naive_wrapper:
recv(const string&): hello const lvalue
Leaving naive_wrapper

--- Calling naive_wrapper with rvalue ---
Inside naive_wrapper:
recv(const string&): hello rvalue // 注意:原本传入右值,但这里调用了 const 左值引用版本!丢失了右值属性。
Leaving naive_wrapper

--- Calling perfect_wrapper with lvalue ---
Inside perfect_wrapper:
recv(string&): hello lvalue // 成功转发左值
Leaving perfect_wrapper

--- Calling perfect_wrapper with const lvalue ---
Inside perfect_wrapper:
recv(const string&): hello const lvalue // 成功转发 const 左值
Leaving perfect_wrapper

--- Calling perfect_wrapper with rvalue ---
Inside perfect_wrapper:
recv(string&&): hello rvalue // 成功转发右值,可以触发移动语义
Leaving perfect_wrapper

通过对比 naive_wrapper 和 perfect_wrapper 的输出,我们可以清楚地看到 std::forward 如何在转发右值时成功保持了其右值属性,从而调用了右值引用重载。这对于在泛型代码中正确地利用移动语义至关重要。 ​

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注