Skip to content

移动语义与转发

C++11 之后,很多代码会出现 &&std::movestd::forwardemplace_back。它们看起来零散,实际上都围绕同一个问题——当一个对象不再需要保留原有资源时,能不能把资源直接交给新对象,而不是重新复制一份?

例如,一个 std::string 内部可能在堆上分配了字符数组。复制字符串时,需要重新申请内存并复制每一个字符;但如果原字符串马上就要销毁,那么新对象完全可以直接接管那块内存。这就是移动语义的核心。

左值和右值

一种判断方式是,左值为有名字、可重复的对象,右值为即将消亡的临时值。比如 int calt = n + 1,calt 有变量名,还可以继续访问,而 n+1 只是临时的计算结果,表达式结束后通常就没有意义了。

传统的 C++ 的引用为左值引用:

cpp
int value = 2;
int& ref = value;

在这个例子中, int& 只可以绑定左值,不能绑定临时值,比如 int& ref = 42;。而在 C++11 中,新增加了右值引用:

cpp
int&& ref_r = 42;

右值引用的目的是告知编译器这个对象可以当作临时资源被处理,允许他的资源被转移。函数 std::move() 操作后的对象就是一个右值引用。比如下面的例子:

cpp
std::string a = "a long text";
std::string b = std::move(a);

在这段代码中,std::move(a) 将 a 转化为 std::string&& 类型的对象,所以编译器就可以优先匹配 std:string(std::string&& other),即移动构造而不是拷贝构造,提高性能。

右值引用最常见的用途是对于大资源对象,可以用其来选择移动构造/赋值的重载函数。同时,其标记了允许后续代码把 a 当作可被消耗的对象,比如可以让 b 拥有原先 a 的内存,而 a 清空。

昂贵的拷贝

先给出一个没有移动语义简化的字符串类:

cpp
class String {
public:
    String(const char* text) {
        size_ = std::strlen(text);
        data_ = new char[size_ + 1];
        std::strcpy(data_, text);
    }

    ~String() {
        delete[] data_;
    }

    String(const String& other) {
        size_ = other.size_;
        data_ = new char[size_ + 1];
        std::strcpy(data_, other.data_);
    }

private:
    char* data_ = nullptr;
    std::size_t size_ = 0;
};

调用:

cpp
String first("Modern C++");
String second = first;

当复制字符串时,程序会为 second 申请一块新的内存,将 first 的字节逐个复制到新内存,然后两个对象分别管理自己的数据。这当然是安全的,但是成本取决于字符串的大小。这被称为深拷贝。对于大得多的字符串,这样做其实非常昂贵。

移动构造

现在,我们为这个 String 类添加移动构造函数:

cpp
class String {
public:
    String(const char* text) {
        size_ = std::strlen(text);
        data_ = new char[size_ + 1];
        std::strcpy(data_, text);
    }

    ~String() {
        delete[] data_;
    }

    String(const String& other) {
        size_ = other.size_;
        data_ = new char[size_ + 1];
        std::strcpy(data_, other.data_);
    }

    String(String&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }

private:
    char* data_ = nullptr;
    std::size_t size_ = 0;
};

调用:

cpp
String first("Modern C++");
String second = std::move(first);

其中,移动构造函数的类型为 String&&。他在函数中干了两件事:让新的对象接管 other 的数据和大小;将原先的对象设置为空。此时,first 的资源被转移到 second 对象下,但是 first 依旧是一个合法的 String 对象。

移动赋值

构造对象和给已有对象赋值是两件事,因此还需要移动赋值运算符:

cpp
String& operator=(String&& other) noexcept {
    if (this != &other) {
        delete[] data_;

        data_ = other.data_;
        size_ = other.size_;

        other.data_ = nullptr;
        other.size_ = 0;
    }

    return *this;
}

调用:

cpp
String first("first");
String second("second");

second = std::move(first);

在这个重载函数中,second 先释放自己原先的 "second",然后 second 接管 first 的字符数组,first 被置为空状态, second 最终保存 "first"。这里的自赋值检查也很重要。虽然实际中 object = std::move(object) 不常见,但没有这个判断时,先释放自己的资源再接管资源,可能导致错误。

Rule of Five

当一个类手动管理资源,例如堆内存、文件句柄或互斥锁时,我们一般称其为资源类。在资源类的设计中,通常要一起考虑五个特殊的成员函数,这被称为 Rule of Five。具体如下:

  • ~String(); 析构函数
  • String(const String& other); 拷贝构造
  • String& operator=(const String&); 拷贝赋值
  • String(String&& other); 移动构造
  • String& operator=(const String&&); 移动赋值

不过,更为现代的建议是优先使用 std::stringstd::vector 等 RAII 类型,让编译器自动生成这些操作。

关于 std::move()

std::move() 很容易被认为是执行移动操作,但是其本身并不移动任何资源,仅仅是将一个左值转化为右值。真正发生资源转移的是 String 的移动构造函数。这一点在 std::move() 函数声明可以看出来:

cpp
template <class _Tp>

[[__nodiscard__]] inline _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR __libcpp_remove_reference_t<_Tp>&&

move(_LIBCPP_LIFETIMEBOUND _Tp&& __t) _NOEXCEPT {

using _Up _LIBCPP_NODEBUG = __libcpp_remove_reference_t<_Tp>;

return static_cast<_Up&&>(__t);

}

但是,针对 const 对象使用 move 函数有例外的情况。如果对于一个 const String 对象使用 move 函数,那么大概率其会调用拷贝构造,而不是移动构造。如下代码所示。

cpp
const String text("hello");
String copy = std::move(text);

这是因为,我们调用移动构造函数时,需要修改原来的对象,将其内容清空(参见上文子实现的 String 类)。但是 text 为 const 对象,无法被修改。所以他最终会被匹配到拷贝构造函数。

所以还可以注意到,移动构造函数往往喜欢添加 noexcept 属性。如果遇到上述的情况,其不应该抛出异常,而是转而尝试改用其他的函数进行匹配。

完美转发

加入我们写一个通用函数用于将对象加入容器:

cpp
template <typename T>
void add(std::vector<T>& values, T value) {
    values.push_back(value);
}

这样写看似没问题,但 value 是函数内部的具名变量,因此它永远是左值。即使调用者传入右值,也会通过按左值被传给 push_back,会发生额外的拷贝。所以可以写成以下的形式。

cpp
template <typename T>
void add(std::vector<T>& values, T value) {
    values.push_back(std::move(value));
}

这样虽然保证了加入到容器中的是右值,但是参数传入函数时仍然会创建中间对象,发生拷贝。所以更加通用的方法是转发引用。

cpp
template <typename T>
void process(T&& value);

上面的 T&& 在模板类型推导场景中称为转发引用。他可以同时接受左值和右值,这是通过 C++ 语言的引用折叠规则实现的:

T&  &  -> T&
T&  && -> T&
T&& &  -> T&
T&& && -> T&&

可以发现,当我们传入左值时,其最终参数是左值引用;传入右值时,最终参数是右值引用。

虽然转发引用能够接收左值和右值,但在函数内部,变量 value 有名字,表达式依然是左值。如果调用者传入的是右值,右值属性在这里就丢失了,如下所示。

cpp
template <typename T>
void wrapper(T&& value) {
    target(value);  // value 在这里是左值
}

新的函数 std::forward<T> 的作用是根据 T 的推导结果恢复原始值类别:

cpp
void target(const String&) {
    std::cout << "接收左值\n";
}

void target(String&&) {
    std::cout << "接收右值\n";
}

template <typename T>
void wrapper(T&& value) {
    target(std::forward<T>(value));
}

调用:

cpp
String text("hello");

wrapper(text);               // 接收左值
wrapper(String("temporary")); // 接收右值

这就是完美转发:让包装函数像“透明通道”一样,把参数的类型和值类别尽量原样传给下一层函数。

返回值优化(拷贝消除)

参考以下案例:

cpp
String create_message() {
    String message("Hello");
    return message;
}

// Call
String result = create_message();

按照上文的解释,程序会构造一个 String 对象,然后移动这个返回值到临时变量,然后将临时变量移动到 result。实际上,现代编译器往往会直接在 result 的位置上构造 message,这被称为返回值优化(RVO/NRVO),即拷贝消除。从 C++17 开始,许多这种场景的拷贝消除是语言规则保证的,而不是可有可无的编译器优化。所以,返回局部对象时,要直接写:

cpp
String create() {
    String result("Hello");
    return result;
}

而不要 return std::move(result)。这可能阻止 NRVO,反而让一次本可消除的构造变成实际移动。

Released under MIT License