MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ std::move 对性能的影响

2021-08-096.3k 阅读

C++ std::move 对性能的影响

在 C++ 编程领域中,理解 std::move 对性能的影响至关重要。std::move 是 C++11 引入的一个重要特性,它在处理对象的移动语义时发挥着关键作用,能够显著提升程序性能,尤其是在涉及大型对象或者复杂数据结构的场景中。

1. 左值与右值的基本概念

在深入探讨 std::move 之前,我们需要明确左值(lvalue)和右值(rvalue)的概念。

左值可以简单理解为具有持久化身份的表达式,它通常是一个变量或者对象,有一个可获取的地址。例如:

int a = 5;
int* ptr = &a;

这里的 a 就是一个左值,我们可以通过 &a 获取其地址。

右值则表示临时对象或者没有持久化身份的表达式。例如字面常量、函数的临时返回值等。像下面这样:

int b = 3 + 4;

3 + 4 就是一个右值,它是一个临时计算结果,并没有固定的存储地址。

2. 移动语义的引入背景

在 C++98/03 时代,对象的复制操作是通过拷贝构造函数和赋值运算符重载来完成的。对于简单类型,这种方式效率尚可,但当涉及到大型对象,例如包含大量数据的数组或者复杂的动态分配内存的对象时,拷贝操作就会变得非常昂贵。

假设我们有一个如下的 MyString 类来模拟字符串操作:

class MyString {
private:
    char* data;
    size_t length;
public:
    MyString(const char* str = nullptr) {
        if (str == nullptr) {
            length = 0;
            data = new char[1];
            data[0] = '\0';
        } else {
            length = strlen(str);
            data = new char[length + 1];
            strcpy(data, str);
        }
    }

    MyString(const MyString& other) {
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
    }

    MyString& operator=(const MyString& other) {
        if (this == &other) {
            return *this;
        }
        delete[] data;
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
        return *this;
    }

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

如果我们进行如下操作:

MyString str1("Hello");
MyString str2 = str1;

这里 str2 = str1 会调用拷贝构造函数,将 str1 的数据完整地复制到 str2 中,这涉及到内存的重新分配和数据的复制,对于大数据量的情况,性能开销很大。

3. std::move 的原理

std::move 本质上是将一个左值强制转换为右值引用。它的定义在 <utility> 头文件中:

template <typename T>
typename std::remove_reference<T>::type&& move(T&& t) {
    return static_cast<typename std::remove_reference<T>::type&&>(t);
}

简单来说,std::move 告诉编译器,我们可以将一个对象当作右值来处理,从而启用移动语义。

4. 移动构造函数和移动赋值运算符

为了利用 std::move 带来的性能提升,我们需要为类定义移动构造函数和移动赋值运算符。

以刚才的 MyString 类为例,添加移动构造函数和移动赋值运算符:

class MyString {
private:
    char* data;
    size_t length;
public:
    // 移动构造函数
    MyString(MyString&& other) noexcept : length(other.length), data(other.data) {
        other.length = 0;
        other.data = nullptr;
    }

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

    // 其他构造函数、析构函数、拷贝构造函数和拷贝赋值运算符不变
    MyString(const char* str = nullptr) {
        if (str == nullptr) {
            length = 0;
            data = new char[1];
            data[0] = '\0';
        } else {
            length = strlen(str);
            data = new char[length + 1];
            strcpy(data, str);
        }
    }

    MyString(const MyString& other) {
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
    }

    MyString& operator=(const MyString& other) {
        if (this == &other) {
            return *this;
        }
        delete[] data;
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
        return *this;
    }

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

移动构造函数和移动赋值运算符的关键在于,它们不会进行数据的复制,而是直接“窃取”源对象的资源,将源对象置于一个可析构的状态(例如这里将 length 设为 0,data 设为 nullptr)。

5. std::move 对性能的具体影响

5.1 减少不必要的拷贝

考虑以下场景,我们有一个函数返回一个 MyString 对象:

MyString createString() {
    MyString temp("Some long string that requires a lot of memory allocation");
    return temp;
}

在 C++11 之前,temp 对象会被拷贝到函数的返回值中,这会涉及到大量的数据复制。而在 C++11 中,编译器会应用返回值优化(RVO),如果无法应用 RVO,std::move 会被隐式调用,将 temp 移动到返回值中,避免了不必要的拷贝。

我们可以通过以下代码验证:

#include <iostream>
#include <string>

class MyString {
private:
    char* data;
    size_t length;
public:
    MyString(const char* str = nullptr) {
        if (str == nullptr) {
            length = 0;
            data = new char[1];
            data[0] = '\0';
        } else {
            length = strlen(str);
            data = new char[length + 1];
            strcpy(data, str);
        }
        std::cout << "Constructor: " << data << std::endl;
    }

    MyString(const MyString& other) {
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
        std::cout << "Copy Constructor: " << data << std::endl;
    }

    MyString(MyString&& other) noexcept : length(other.length), data(other.data) {
        other.length = 0;
        other.data = nullptr;
        std::cout << "Move Constructor: " << data << std::endl;
    }

    MyString& operator=(const MyString& other) {
        if (this == &other) {
            return *this;
        }
        delete[] data;
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
        std::cout << "Copy Assignment: " << data << std::endl;
        return *this;
    }

    MyString& operator=(MyString&& other) noexcept {
        if (this == &other) {
            return *this;
        }
        delete[] data;
        length = other.length;
        data = other.data;
        other.length = 0;
        other.data = nullptr;
        std::cout << "Move Assignment: " << data << std::endl;
        return *this;
    }

    ~MyString() {
        std::cout << "Destructor: " << data << std::endl;
        delete[] data;
    }
};

MyString createString() {
    MyString temp("Some long string that requires a lot of memory allocation");
    return temp;
}

int main() {
    MyString str = createString();
    return 0;
}

在支持 RVO 的编译器上运行这段代码,你可能只会看到构造函数和析构函数的输出,而不会看到拷贝构造函数的输出,说明 RVO 起作用了。如果禁用 RVO(不同编译器有不同方法,例如在 GCC 中可以使用 -fno-elide-constructors 选项),你会看到移动构造函数被调用,而不是拷贝构造函数,这表明 std::move 隐式地将 temp 移动到了返回值中。

5.2 在容器操作中的性能提升

在标准库容器(如 std::vectorstd::list 等)中,std::move 也能显著提升性能。

例如,当我们向 std::vector 中插入元素时:

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

class MyString {
private:
    char* data;
    size_t length;
public:
    MyString(const char* str = nullptr) {
        if (str == nullptr) {
            length = 0;
            data = new char[1];
            data[0] = '\0';
        } else {
            length = strlen(str);
            data = new char[length + 1];
            strcpy(data, str);
        }
        std::cout << "Constructor: " << data << std::endl;
    }

    MyString(const MyString& other) {
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
        std::cout << "Copy Constructor: " << data << std::endl;
    }

    MyString(MyString&& other) noexcept : length(other.length), data(other.data) {
        other.length = 0;
        other.data = nullptr;
        std::cout << "Move Constructor: " << data << std::endl;
    }

    MyString& operator=(const MyString& other) {
        if (this == &other) {
            return *this;
        }
        delete[] data;
        length = other.length;
        data = new char[length + 1];
        strcpy(data, other.data);
        std::cout << "Copy Assignment: " << data << std::endl;
        return *this;
    }

    MyString& operator=(MyString&& other) noexcept {
        if (this == &other) {
            return *this;
        }
        delete[] data;
        length = other.length;
        data = other.data;
        other.length = 0;
        other.data = nullptr;
        std::cout << "Move Assignment: " << data << std::endl;
        return *this;
    }

    ~MyString() {
        std::cout << "Destructor: " << data << std::endl;
        delete[] data;
    }
};

int main() {
    std::vector<MyString> vec;
    MyString str("Hello");
    vec.push_back(str);
    return 0;
}

这里 vec.push_back(str) 会调用 MyString 的拷贝构造函数将 str 复制到 vector 中。如果我们使用 std::move

int main() {
    std::vector<MyString> vec;
    MyString str("Hello");
    vec.push_back(std::move(str));
    return 0;
}

此时会调用 MyString 的移动构造函数,避免了数据的复制,大大提升了性能。特别是当 MyString 对象较大时,这种性能提升更为明显。

5.3 性能提升的局限性

虽然 std::move 通常能带来显著的性能提升,但也有一些情况下其效果并不明显甚至可能带来负面影响。

例如,对于一些小型对象,其拷贝操作本身就很快,使用 std::move 引入的额外代码开销(如移动构造函数和移动赋值运算符的调用)可能会抵消掉潜在的性能提升。

再比如,如果移动操作本身很复杂,例如涉及到大量的资源清理和重新初始化,那么移动操作可能并不比拷贝操作快多少。

6. 正确使用 std::move 的注意事项

首先,在使用 std::move 时要确保对象是可移动的。如果一个类没有定义移动构造函数和移动赋值运算符,使用 std::move 并不会启用移动语义,可能会导致未定义行为。

其次,一旦使用 std::move 将一个对象转换为右值引用并进行移动操作后,原对象的状态是未定义的,除了对其进行赋值或者销毁,不应该再对其进行其他操作。

例如:

MyString str1("Hello");
MyString str2 = std::move(str1);
// 此时 str1 处于未定义状态,不应该再访问 str1.data 等成员

7. 总结 std::move 在现代 C++ 编程中的地位

std::move 是现代 C++ 编程中提升性能的重要工具之一。它通过引入移动语义,避免了大量不必要的对象拷贝,在处理大型对象和复杂数据结构时能够显著提升程序的执行效率。然而,正确使用 std::move 需要深入理解左值、右值、移动构造函数和移动赋值运算符等概念,并且要注意其适用场景,避免在不恰当的地方使用而带来不必要的复杂性或者性能问题。在编写高效的 C++ 代码时,合理运用 std::move 可以让程序在性能上达到一个新的高度。