深入浅出 C++ 11 右值引用

296次阅读  |  发布于3年以前

1 写在前面

如果你还不知道 C++ 11 引入的右值引用是什么,可以读读这篇文章,看看有什么 启发;如果你已经对右值引用了如指掌,也可以读读这篇文章,看看有什么 补充。欢迎交流~

尽管 C++ 17 标准 在去年底已经正式发布了,但由于 C++ 语言变得 越来越复杂,让许多人对很多新特性 望而却步。

对于 2011 年发布的 C++ 11 标准,很多人虽然对 右值引用/移动语义/拷贝省略/通用引用/完美转发 之类的概念都 有所耳闻,却没有详细了解其 设计初衷/实现原理,甚至对一些细节 有所误解(包括我 )。

我刚开始学习 C++ 的时候,也常常 混淆 这几个 概念。但随着深入了解、与人探讨,才逐步理清楚这几个概念的来龙去脉。先分享几个我曾经犯过的错误。

1.1 误解:返回前,移动局部变量

ES.56: Write std::move() only when you need to explicitly move an object to another scope

std::string base_url = tag->GetBaseUrl();
if (!base_url.empty()) {
  UpdateQueryUrl(std::move(base_url) + "&q=" + word_);
}
LOG(INFO) << base_url;  // |base_url| may be moved-from

上述代码的问题在于:使用 std::move() 移动局部变量 base_url,会导致后续代码不能使用该变量;如果使用,会出现 未定义行为 (undefined behavior)(参考:std::basic_string(basic_string&&))。

如何检查 移动后使用 (use after move)

1.2 误解:被移动的值不能再使用

C.64: A move operation should move and leave its source in a valid state

auto p = std::make_unique<int>(1);
auto q = std::move(p);
assert(p == nullptr);  // OK: use after move

p.reset(new int{2});   // or p = std::make_unique<int>(2);
assert(*p == 1);       // OK: realive now

很多人认为:被移动的值会进入一个 非法状态 (invalid state),对应的内存不能再访问。

其实,C++ 标准要求对象 遵守 § 3 移动语义:被移动的对象进入一个 合法但未指定状态 (valid but unspecified state),调用该对象的方法不会出现异常。要求处于这个状态的对象:

另外,基本类型的值(例如 int/double)的移动和拷贝相同。例如,int i = 42; 被移动后,保持为原有值 i == 42

1.3 误解:移动返回值

F.48: Don’t return std::move(local)

std::unique_ptr<int> foo() {
  auto ret = std::make_unique<int>(1);
  //...
  return std::move(ret);  // -> return ret;
}

上述代码的问题在于:没必要使用 std::move() 移动返回值。

C++ 会把 即将离开作用域的 返回值 当成 右值(参考 § 2.1),对返回的对象进行 § 3 移动构造(语言标准);如果编译器允许 § 4 拷贝省略,还可以省略这一步的构造,直接把 ret 存放到返回值的内存里(编译器优化)。

Never apply std::move() or std::forward() to local objects if they would otherwise be eligible for the return value optimization. —— Meyer Scott, Effective Modern C++

另外,误用 std::move() 会 阻止 编译器的拷贝省略 优化。不过聪明的 Clang 会提示 -Wpessimizing-move 警告。

1.4 误解:不移动右值引用参数

F.18: For “will-move-from” parameters, pass by X&& and std::move() the parameter

std::unique_ptr<int> bar(std::unique_ptr<int>&& val) {
  //...
  return val;    // not compile
                 // -> return std::move/forward(val);
}

上述代码的问题在于:没有对返回值使用 std::move()(编译器提示 std::unique_ptr(const std::unique_ptr&) = delete 错误)。

If-it-has-a-name Rule:

  • Named rvalue references are lvalues.
  • Unnamed rvalue references are rvalues.

因为不论 左值引用 还是 右值引用 的变量(或参数)在初始化后,都是左值(参考 § 2.1):

所以,返回右值引用变量时,需要使用 std::move()/std::forward() 显式的 § 5.4 移动转发 或 § 5.3 完美转发,将变量的类型 “还原” 为右值引用。

1.5 误解:手写错误的移动构造函数

C.20: If you can avoid defining default operations, do

C.21: If you define or =delete any default operation, define or =delete them all

C.80: Use =default if you have to be explicit about using the default semantics

C.66: Make move operations noexcept

实际上,多数情况下:

例如,标准库容器 std::vector 在扩容时,会通过 std::vector::reserve() 重新分配空间,并转移已有元素。如果扩容失败,std::vector 满足 强异常保证 (strong exception guarantee),可以回滚到失败前的状态。

为此,std::vector 使用 std::move_if_noexcept() 进行元素的转移操作:

如果 没有定义移动构造函数 或 自定义的移动构造函数没有 noexcept,会导致 std::vector 扩容时执行无用的拷贝,不易发现。

2 基础知识

之所以会出现上边的误解,往往是因为 C++ 语言的复杂性 和 使用者对基础知识的掌握程度 不匹配。

2.1 值类别 vs 变量类型

划重点 —— 值 (value) 和 变量 (variable) 是两个独立的概念:

值类别 (value category) 可以分为两种:

C++ 17 细化了 prvalue/xvalue/lvaluervalue/glvalue 类别,本文不详细讨论。

引用类型 (reference type) 属于一种 变量类型 (variable type),将在 § 2.2 详细讨论。

在变量 初始化 (initialization) 时,需要将 初始值 (initial value) 绑定到变量上;但 引用类型变量 的初始化 和其他变量不同:

2.2 左值引用 vs 右值引用 vs 常引用

引用类型 可以分为两种:

void f(Data&  data);  // 1, data is l-ref
void f(Data&& data);  // 2, data is r-ref
Data   data;

Data&  data1 = data;             // OK
Data&  data1 = Data{};           // not compile: invalid binding
Data&& data2 = Data{};           // OK
Data&& data2 = data;             // not compile: invalid binding
Data&& data2 = std::move(data);  // OK

f(data);    // 1, data is lvalue
f(Data{});  // 2, data is rvalue
f(data1);   // 1, data1 is l-ref type and lvalue
f(data2);   // 1, data2 is r-ref type but lvalue

另外,C++ 还支持了 常引用 (c-ref, const reference),同时接受 左值/右值 进行初始化:

void g(const Data& data);  // data is c-ref

g(data);    // ok, data is lvalue
g(Data{});  // ok, data is rvalue

常引用和右值引用 都能接受右值的绑定,有什么区别呢?

const Data& data1 = Data{};   // OK: extend lifetime
data1.modify();               // not compile: const

Data&& data2 = Data{};        // OK: extend lifetime
data2.modify();               // OK: non-const

void f(const Data& data);  // 1, data is c-ref
void f(Data&& data);       // 2, data is r-ref

f(Data{});  // 2, prefer 2 over 1 for rvalue

2.3 引用折叠

引用折叠 (reference collapsing)std::move()/std::forward() 的实现基础:

using Lref = Data&;
using Rref = Data&&;
Data data;

Lref&  r1 = data;    // r1 is Data&
Lref&& r2 = data;    // r2 is Data&
Rref&  r3 = data;    // r3 is Data&
Rref&& r4 = Data{};  // r4 is Data&&

3 移动语义

在 C++ 11 强化左右值概念后,我们可以针对右值进行优化。于是,C++ 11 中就提出了 移动语义 (move semantic)

由于基本类型不包含资源,其移动和拷贝相同:被移动后,保持为原有值。

3.1 避免先复制再释放资源

针对包含了资源的对象,我们可以通过移动对象的资源进行优化。例如,常用的 STL 类模板都有:

template<typename T>
class vector {
 public:
  vector(const vector& rhs);      // copy data
  vector(vector&& rhs) noexcept;  // move data
  ~vector();                      // dtor
 private:
  T* data_ = nullptr;
  size_t size_ = 0;
};

vector::vector(const vector& rhs) : data_(new T[rhs.size_]) {
  auto &lhs = *this;
  lhs.size_ = rhs.size_;
  std::copy_n(rhs.data_, rhs.size_, lhs.data_);  // copy data
}

vector::vector(vector&& rhs) noexcept {
  auto &lhs = *this;
  lhs.size_ = rhs.size_;
  lhs.data_ = rhs.data_;  // move data
  rhs.size_ = 0;
  rhs.data_ = nullptr;    // set data of rhs to null
}

vector::~vector() {
  if (data_)              // release only if owned
    delete[] data_;
}

上述代码中,构造函数 vector::vector() 根据参数的左右值类型判断:

析构函数 vector::~vector() 检查 data_ 是否有效,决定是否需要释放资源。

除了能判断参数是否为左右值,成员函数还可以判断当前对象的左右值类型:给成员函数加上 引用限定符 (reference qualifier),针对对象本身的左右值类型进行优化。

class Foo {
 public:
  Data& data() & { return data_; }        // lvalue, l-ref
  Data data() && { return move(data_); }  // rvalue, move-out
};

auto ret1 = foo.data();    // foo is lvalue, copy
auto ret2 = Foo{}.data();  // foo is rvalue, move

3.2 转移不可复制的资源

在之前写的 资源管理小记 提到:如果资源是不可复制的,那么装载资源的对象也应该是不可复制的。

如果资源对象不可复制,那么只能通过移动,创建新对象。例如,智能指针 std::unique_ptr 只允许移动构造,不允许拷贝构造。

template<typename T>
class unique_ptr {
 public:
  unique_ptr(const unique_ptr& rhs) = delete;
  unique_ptr(unique_ptr&& rhs) noexcept;  // move only
 private:
  T* data_ = nullptr;
};

unique_ptr::unique_ptr(unique_ptr&& rhs) noexcept {
  auto &lhs = *this;
  lhs.data_ = rhs.data_;
  rhs.data_ = nullptr;
}

上述代码中,unique_ptr 的移动构造过程和 vector 类似:

3.3 反例:不遵守移动语义

移动语义只是语言上的一个 概念,具体是否移动对象的资源、如何移动对象的资源,都需要通过编写代码 实现。而移动语义常常被 误认为,编译器 自动生成 移动对象本身的代码(§ 4 拷贝省略)。

为了证明这一点,我们可以实现不遵守移动语义的 bad_vec::bad_vec(bad_vec&& rhs),执行拷贝语义:

bad_vec::bad_vec(bad_vec&& rhs) : data_(new T[rhs.size_]) {
  auto &lhs = *this;
  lhs.size_ = rhs.size_;
  std::copy_n(rhs.data_, rhs.size_, lhs.data_);  // copy data
}

那么,一个 bad_vec 对象在被 move 移动后仍然可用:

bad_vec<int> v_old { 0, 1, 2, 3 };
auto v_new = std::move(v_old);

v_old[0] = v_new[3];           // ok, but odd :-)
assert(v_old[0] != v_new[0]);
assert(v_old[0] == v_new[3]);

虽然代码可以那么写,但是在语义上有问题:进行了拷贝操作,违背了移动语义的初衷。

4 拷贝省略

尽管 C++ 引入了移动语义,移动的过程 仍有优化的空间 —— 与其调用一次 没有意义的移动构造函数,不如让编译器 直接跳过这个过程 —— 于是就有了 拷贝省略 (copy elision)

然而,很多人会把移动语义和拷贝省略 混淆:

C++ 17 要求编译器对 纯右值 (prvalue, pure rvalue) 进行拷贝省略优化。(参考)

Data f() {
  Data val;
  // ...
  throw val;
  // ...
  return val;

  // NRVO from lvalue to ret (not guaranteed)
  // if NRVO is disabled, move ctor is called
}

void g(Date arg);

Data v = f();     // copy elision from prvalue (C++ 17)
g(f());           // copy elision from prvalue (C++ 17)

初始化 局部变量、函数参数时,传入的纯右值可以确保被优化 —— Return Value Optimization (RVO);而返回的 将亡值 (xvalue, eXpiring value) 不保证被优化 —— Named Return Value Optimization (NRVO)

5 通用引用和完美转发

揭示 std::move()/std::forward() 的原理,需要读者有一定的 模板编程基础。

5.1 为什么需要通用引用

C++ 11 引入了变长模板的概念,允许向模板参数里传入不同类型的不定长引用参数。由于每个类型可能是左值引用或右值引用,针对所有可能的左右值引用组合,特化所有模板 是 不现实的。

假设没有 通用引用的概念,模板 std::make_unique<> 至少需要两个重载:

template<typename T, typename... Args>
unique_ptr<T> make_unique(const Args&... args) {
  return unique_ptr<T> {
    new T { args... }
  };
}

template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args) {
  return unique_ptr<T> {
    new T { std::move<Args>(args)... }
  };
}

上述代码的问题在于:如果传入的 args 既有 左值引用 又有 右值引用,那么这两个模板都 无法匹配。

5.2 通用引用

Item 24: Distinguish universal references from rvalue references. —— Meyer Scott, Effective Modern C++

Meyer Scott 指出:有时候符号 && 并不一定代表右值引用,它也可能是左值引用 —— 如果一个引用符号需要通过 左右值类型推导(模板参数类型 或 auto 推导),那么这个符号可能是左值引用或右值引用 —— 这叫做 通用引用 (universal reference)

// rvalue ref: no type deduction
void f1(Widget&& param1);
Widget&& var1 = Widget();
template<typename T> void f2(vector<T>&& param2);

// universal ref: type deduction
auto&& var2 = var1;
template<typename T> void f3(T&& param);

上述代码中,前三个 && 符号不涉及引用符号的左右值类型推导,都是右值引用;而后两个 && 符号会 根据初始值推导左右值类型:

基于通用引用,§ 5.1 的模板 std::make_unique<> 只需要一个重载:

template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args) {
  return unique_ptr<T> {
    new T { std::forward<Args>(args)... }
  };
}

其中,std::forward() 实现了 针对不同左右值类型的转发 —— 完美转发。

5.3 完美转发

什么是 完美转发 (perfect forwarding)

因此,std::forward() 定义两个 不涉及 左右值类型 推导 的模板(不能使用 通用引用参数):

template <typename T>
T&& forward(std::remove_reference_t<T>& val) noexcept {
  // forward lvalue as either lvalue or rvalue
  return static_cast<T&&>(val);
}

template <typename T>
T&& forward(std::remove_reference_t<T>&& val) noexcept {
  // forward rvalue as rvalue (not lvalue)
  static_assert(!std::is_lvalue_reference_v<T>,
                "Cannot forward rvalue as lvalue.");
  return static_cast<T&&>(val);
}
实参/返回值 类型 左值引用返回值 右值引用返回值
(重载 1)左值引用实参 完美转发 移动转发
(重载 2)右值引用实参 语义错误 完美转发

5.4 移动转发

类似的,std::move() 只转发为右值引用类型:

template <typename T>
std::remove_reference_t<T>&& move(T&& val) noexcept {
  // forward either lvalue or rvalue as rvalue
  return static_cast<std::remove_reference_t<T>&&>(val);
}
实参/返回值 类型 右值引用返回值
左值引用实参 移动转发
右值引用实参 移动转发/完美转发

写在最后

虽然这些东西你不知道,也不会伤害你;但如果你知道了,就可以合理利用,从而提升开发效率,避免不必要的问题。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8