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


    彻底搞清楚:右值引用/移动语义/拷贝省略/通用引用/完美转发 —— 以最短的篇幅,介绍常见误解(什么时候要用 move?什么时候不能 move?为什么 move 失败?)和基础知识(为什么右值引用变量是左值?为什么会调用移动构造函数?),一步步解释“为什么/是什么/怎么做”。

    写在前面

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

    尽管 C++ 17 标准已经发布了,很多人还不熟悉 C++ 11 的 右值引用/移动语义/拷贝省略/通用引用/完美转发 等概念,甚至对一些细节 有所误解(包括我 [emoji])。

    本文将以最短的篇幅,一步步解释 关于右值引用的 为什么/是什么/怎么做。先分享几个我曾经犯过的错误。

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

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

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

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

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

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

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

    其实,C++ 标准要求对象 遵守 [sec|移动语义] 移动语义 —— 被移动的对象进入一个 合法但未指定状态 (valid but unspecified state),调用该对象的方法(包括析构函数)不会出现异常,甚至在重新赋值后可以继续使用:

    1. auto p = std::make_unique<int>(1);
    2. auto q = std::move(p);
    3. assert(p == nullptr); // OK: reset to default
    4. p.reset(new int{2}); // or p = std::make_unique<int>(2);
    5. assert(*p == 2); // OK: reset to int*(2

    另外,基本类型(例如 int/double)的移动语义 和拷贝相同:

    1. int i = 1;
    2. int j = std::move(i);
    3. assert(i == j)

    误解:移动非引用返回值

    F.48: Don’t return std::move(local)
    1. std::unique_ptr<int> foo() {
    2. auto ret = std::make_unique<int>(1);
    3. //...
    4. return std::move(ret); // -> return ret;
    5. }

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

    C++ 会把 即将离开作用域的 非引用类型的 返回值当成 右值(参考 [sec|值类别 vs 变量类型]),对返回的对象进行 [sec|移动语义] 移动构造(语言标准);如果编译器允许 [sec|拷贝省略] 拷贝省略,还可以省略这一步的构造,直接把 ret 存放到返回值的内存里(编译器优化)。

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

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

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

    F.18: For “will-move-from” parameters, pass by X&& and std::move() the parameter
    1. std::unique_ptr<int> bar(std::unique_ptr<int>&& val) {
    2. //...
    3. return val; // not compile
    4. // -> return std::move/forward(val);
    5. }

    上述代码的问题在于:没有对返回值使用 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.

    因为不论 左值引用 还是 右值引用 的变量(或参数)在初始化后,都是左值(参考 [sec|值类别 vs 变量类型]):

    • 命名的右值引用(named rvalue reference)变量 是 左值,但变量类型 却是 右值引用
    • 在作用域内,左值变量 可以通过 变量名(variable name)被取地址、被赋值

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

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

    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

    实际上,多数情况下:

    • 如果 没有定义 拷贝构造/拷贝赋值/移动构造/移动赋值/析构 函数的任何一个,编译器会 自动生成 移动构造/移动赋值 函数(rule of zero
    • 如果 需要定义 拷贝构造/拷贝赋值/移动构造/移动赋值/析构 函数的任何一个,不要忘了 移动构造/移动赋值 函数,否则对象会 不可移动rule of five
    • 尽量使用=default 让编译器生成 移动构造/移动赋值 函数,否则 容易写错
    • 如果 需要自定义 移动构造/移动赋值 函数,尽量定义为 noexcept 不抛出异常(编译器生成的版本会自动添加),否则 不能高效 使用标准库和语言工具

    例如,标准库容器 std::vector 在扩容时,会通过 std::vector::reserve() 重新分配空间,并转移已有元素。如果扩容失败,std::vector 满足 ef="https://en.cppreference.com/w/cpp/language/exceptions#Exception_safety">强异常保证 (strong exception guarantee),可以回滚到失败前的状态。

    资料领取直通车:大厂面试题锦集+视频教程icon-default.png?t=M85Bhttps://docs.qq.com/doc/DTlhVekRrZUdDUEpy

    Linux服务器学习网站:C/C++Linux服务器开发/后台架构师

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

    • 优先 使用 noexcept 移动构造函数(高效;不抛出异常)
    • 其次 使用 拷贝构造函数(低效;如果异常,可以回滚)
    • 再次 使用 非 noexcept 移动构造函数(高效;如果异常,无法回滚
    • 最后 如果 不可拷贝、不可移动,编译失败

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

    基础知识

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

    值类别 vs 变量类型

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

    • 值 只有 类别(category) 的划分,变量 只有 类型(type) 的划分
    • 值 不一定拥有 身份(identity),也不一定拥有 变量名(例如 表达式中间结果 i + j + k

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

    • 左值(lvalue, left value) 是 能被取地址、不能被移动 的值
    • 右值(rvalue, right value) 是 表达式中间结果/函数返回值(可能拥有变量名,也可能没有)
    C++ 17 细化了  prvalue/xvalue/lvalue 和  rvalue/glvalue 类别,本文不详细讨论。

    引用类型属于一种变量类型 (variable type),将在 [sec|左值引用 vs 右值引用 vs 常引用] 详细讨论。

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

    • 创建时,必须显式初始化(和指针不同,不允许 空引用(null reference);但可能存在 悬垂引用(dangling reference)
    • 相当于是 其引用的值 的一个 别名(alias)(例如,对引用变量的 赋值运算(assignment operation) 会赋值到 其引用的值 上)
    • 一旦绑定了初始值,就 不能重新绑定 到其他值上了(和指针不同,赋值运算不能修改引用的指向;而对于 Java/JavaScript 等语言,对引用变量赋值 可以重新绑定)

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

    引用类型 可以分为两种:

    • 左值引用(l-ref, lvalue reference) 用 & 符号引用 左值(但不能引用右值)
    • 右值引用(r-ref, rvalue reference) 用 && 符号引用 右值(也可以移动左值)
    1. void f(Data& data); // 1, data is l-ref
    2. void f(Data&& data); // 2, data is r-ref
    3. Data data;
    4. Data& data1 = data; // OK
    5. Data& data1 = Data{}; // not compile: invalid binding
    6. Data&& data2 = Data{}; // OK
    7. Data&& data2 = data; // not compile: invalid binding
    8. Data&& data2 = std::move(data); // OK
    9. f(data); // 1, data is lvalue
    10. f(Data{}); // 2, data is rvalue
    11. f(data1); // 1, data1 is l-ref type and lvalue
    12. f(data2); // 1, data2 is r-ref type but lvalue
    • 左值引用 变量 data1 在初始化时,不能绑定右值 Data{}
    • 右值引用 变量 data2 在初始化时,不能绑定左值data
    • 但可以通过 std::move() 将左值 转为右值引用(参考 [sec|移动转发])
    • 右值引用 变量 data2 被初始化后,在作用域内是 左值(参考 [sec|误解:不移动右值引用参数]),所以匹配 f() 的 重载 2

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

    1. void g(const Data& data); // data is c-ref
    2. g(data); // ok, data is lvalue
    3. g(Data{}); // ok, data is rvalue

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

    • 通过 右值引用/常引用 初始化的右值,都可以将生命周期扩展 (lifetime extension) 到 绑定该右值的 引用的生命周期
    • 初始化时 绑定了右值后,右值引用 可以修改 引用的右值,而 常引用 不能修改
    1. const Data& data1 = Data{}; // OK: extend lifetime
    2. data1.modify(); // not compile: const
    3. Data&& data2 = Data{}; // OK: extend lifetime
    4. data2.modify(); // OK: non-const

    引用参数重载优先级

    如果函数重载同时接受 右值引用/常引用 参数,编译器 优先重载 右值引用参数 —— 是 [sec|移动语义] 移动语义 的实现基础:

    1. void f(const Data& data); // 1, data is c-ref
    2. void f(Data&& data); // 2, data is r-ref
    3. f(Data{}); // 2, prefer 2 over 1 for rvalue

    针对不同左右值 实参 (argument) 重载 引用类型 形参 (parameter) 的优先级如下:

    • 数值越小,优先级越高;如果不存在,则重载失败
    • 如果同时存在 传值(by value) 重载(接受值类型参数 T),会和上述 传引用(by reference) 重载产生歧义,编译失败
    • 常右值引用(const rvalue reference)const T&& 一般不直接使用(参考

    引用折叠

    引用折叠(reference collapsing)是 [sec|移动转发] std::move() 和 [sec|完美转发] std::forward() 的实现基础:

    1. using Lref = Data&;
    2. using Rref = Data&&;
    3. Data data;
    4. Lref& r1 = data; // r1 is Data&
    5. Lref&& r2 = data; // r2 is Data&
    6. Rref& r3 = data; // r3 is Data&
    7. Rref&& r4 = Data{}; // r4 is Data&&

    移动语义

    在 C++ 11 强化了左右值概念后,提出了 移动语义 (move semantic) 优化:由于右值对象一般是临时对象,在移动时,对象包含的资源 不需要先拷贝再删除,只需要直接 从旧对象移动到新对象

    同时,要求 被移动的对象 处于 合法但未指定状态(参考 [sec|误解:被移动的值不能再使用]):

    • (基本要求)能正确析构(不会重复释放已经被移动了的资源,例如 std::unique_ptr::~unique_ptr() 检查指针是否需要 delete
    • (一般要求)重新赋值后,和新的对象没有差别(C++ 标准库基于这个假设)
    • (更高要求)恢复为默认值(例如 std::unique_ptr 恢复为 nullptr

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

    避免先拷贝再释放资源

    一般通过 重载构造/赋值函数 实现移动语义。例如,std::vector 有:

    • 以常引用作为参数的 拷贝构造函数(copy constructor)
    • 以右值引用作为参数的 移动构造函数(move constructor)
    1. template<typename T>
    2. class vector {
    3. public:
    4. vector(const vector& rhs); // copy data
    5. vector(vector&& rhs) noexcept; // move data
    6. ~vector(); // dtor
    7. private:
    8. T* data_ = nullptr;
    9. size_t size_ = 0;
    10. };
    11. vector::vector(const vector& rhs) : data_(new T[rhs.size_]) {
    12. auto &lhs = *this;
    13. lhs.size_ = rhs.size_;
    14. std::copy_n(rhs.data_, rhs.size_, lhs.data_); // copy data
    15. }
    16. vector::vector(vector&& rhs) noexcept {
    17. auto &lhs = *this;
    18. lhs.size_ = rhs.size_;
    19. lhs.data_ = rhs.data_; // move data
    20. rhs.size_ = 0;
    21. rhs.data_ = nullptr; // set data of rhs to null
    22. }
    23. vector::~vector() {
    24. if (data_) // release only if owned
    25. delete[] data_;
    26. }

    上述代码中,构造函数 vector::vector() 根据实参判断(重载优先级参考 [sec|引用参数重载优先级]):

    • 实参为左值时,拷贝构造,使用 new[]/std::copy_n 拷贝原对象的所有元素(本方案有一次冗余的默认构造,仅用于演示)
    • 实参为右值时,移动构造,把指向原对象内存的指针 data_、内存大小 size_ 拷贝到新对象,并把原对象这两个成员置 0

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

    此处省略 拷贝赋值/移动赋值 函数,但建议加上。(参考 [sec|误解:手写错误的移动构造函数])

    此外,类的成员函数 还可以通过ef="https://en.cpprefhttp://erence.com/w/cpp/language/member_functions#const-.2C_volatile-.2C_and_ref-qualified_member_functions">引用限定符(reference qualifier),针对当前对象本身的左右值状态(以及 const-volatile)重载:

    1. class Foo {
    2. public:
    3. Data data() && { return std::move(data_); } // rvalue, move-out
    4. Data data() const& { return data_; } // otherwise, copy
    5. };
    6. auto ret1 = foo.data(); // foo is lvalue, copy
    7. auto ret2 = Foo{}.data(); // Foo{} is rvalue, move

    转移不可拷贝的资源

    在之前写的  资源管理小记 提到:如果资源是  不可拷贝  (non-copyable) 的,那么装载资源的对象也应该是不可拷贝的。

    如果资源对象不可拷贝,一般需要定义 移动构造/移动赋值 函数,并禁用 拷贝构造/拷贝赋值 函数。例如,智能指针 std::unique_ptr 只能移动 (move only)

    1. template<typename T>
    2. class unique_ptr {
    3. public:
    4. unique_ptr(const unique_ptr& rhs) = delete;
    5. unique_ptr(unique_ptr&& rhs) noexcept; // move only
    6. private:
    7. T* data_ = nullptr;
    8. };
    9. unique_ptr::unique_ptr(unique_ptr&& rhs) noexcept {
    10. auto &lhs = *this;
    11. lhs.data_ = rhs.data_;
    12. rhs.data_ = nullptr;
    13. }

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

    • 把指向原对象内存的指针 data_ 拷贝到新对象
    • 把原对象的指针 data_ 置为空

    反例:不遵守移动语义

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

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

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

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

    1. bad_vec<int> v_old { 0, 1, 2, 3 };
    2. auto v_new = std::move(v_old);
    3. v_old[0] = v_new[3]; // ok, but odd :-)
    4. assert(v_old[0] != v_new[0]);
    5. assert(v_old[0] == v_new[3]);

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

    拷贝省略

    尽管 C++ 引入了移动语义,移动的过程 仍有优化的空间 —— 与其调用一次 没有意义的移动构造函数,不如让编译器 直接跳过这个过程 —— 于是就有了 ref="https://en.cppreference.com/w/cpp/language/copy_elision">拷贝省略(copy elision)

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

    • 移动语义是 语言标准 提出的概念,通过编写遵守移动语义的 移动构造函数、右值限定成员函数,逻辑上 优化 对象内资源 的转移流程
    • 拷贝省略是(C++ 17 前)非标准的 编译器优化,跳过移动/拷贝构造函数,让编译器直接在 移动后的对象 内存上,构造 被移动的对象(例如 [sec|误解:移动非引用返回值] 的代码,直接在 函数返回值对象 的内存上,构造 函数局部对象 ret —— 在 不同作用域 里,共享 同一块内存

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

    1. Data f() {
    2. Data val;
    3. // ...
    4. throw val;
    5. // ...
    6. return val;
    7. // NRVO from lvalue to ret (not guaranteed)
    8. // if NRVO is disabled, move ctor is called
    9. }
    10. void g(Date arg);
    11. Data v = f(); // copy elision from prvalue (C++ 17)
    12. g(f()); // copy elision from prvalue (C++ 17)

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

    通用引用和完美转发

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

    为什么需要通用引用

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

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

    1. template<typename T, typename... Args>
    2. unique_ptr<T> make_unique(const Args&... args) {
    3. return unique_ptr<T> {
    4. new T { args... }
    5. };
    6. }
    7. template<typename T, typename... Args>
    8. unique_ptr<T> make_unique(Args&&... args) {
    9. return unique_ptr<T> {
    10. new T { std::move<Args>(args)... }
    11. };
    12. }
    • 对于传入的左值引用 const Args&… args,只要展开 args… 就可以转发这一组左值引用
    • 对于传入的右值引用 Args&&… args,需要通过 [sec|移动转发] std::move() 转发出去,即 std::move(args)…(为什么要转发:参考 [sec|误解:不移动右值引用参数])

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

    通用引用

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

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

    1. // rvalue ref: no type deduction
    2. void f1(Widget&& param1);
    3. Widget&& var1 = Widget();
    4. template<typename T> void f2(vector<T>&& param2);
    5. // universal ref: type deduction
    6. auto&& var2 = var1;
    7. template<typename T> void f3(T&& param);

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

    • 对于var2
      • 因为 var1 是左值,所以 var2 也是左值引用
      • 推导不会参考 var1 的变量类型
    • 对于T&&
      • 如果 param 传入左值,T&& 是左值引用 std::remove_reference_t&
      • 如果 param 传入右值,T&& 是右值引用 std::remove_reference_t&&

    基于通用引用,[sec|为什么需要通用引用] 的模板 std::make_unique<> 只需要一个重载:

    1. template<typename T, typename... Args>
    2. unique_ptr<T> make_unique(Args&&... args) {
    3. return unique_ptr<T> {
    4. new T { std::forward<Args>(args)... }
    5. };
    6. }

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

    完美转发

    什么是 完美转发 (perfect forwarding)

    • 如果参数是 左值引用,直接以 左值引用 的形式,转发给下一个函数
    • 如果参数是 右值引用,要先 “还原” 为 右值引用 的形式,再转发给下一个函数

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

    1. template <typename T>
    2. T&& forward(std::remove_reference_t<T>& val) noexcept { // #1
    3. // forward lvalue as either lvalue or rvalue
    4. return static_cast<T&&>(val);
    5. }
    6. template <typename T>
    7. T&& forward(std::remove_reference_t<T>&& val) noexcept { // #2
    8. // forward rvalue as rvalue (not lvalue)
    9. static_assert(!std::is_lvalue_reference_v<T>,
    10. "Cannot forward rvalue as lvalue.");
    11. return static_cast<T&&>(val);
    12. }

    实参/返回值重载l-ref 返回值r-ref 返回值l-ref 实参#1完美转发移动转发r-ref 实参#2语义错误完美转发

    • 尽管初始化后的变量都是 左值(参考 [sec|误解:不移动右值引用参数]),但原始的 变量类型 仍会保留
    • 因此,可以根据 实参类型 选择重载,和模板参数 `T` 的类型无关
    • 返回值类型static_cast(val) 经过模板参数 T&& [sec|引用折叠] 引用折叠 实现 完美转发/移动转发,和实参类型无关
    • “将 l-ref 实参 转发为 r-ref 返回值” 等价于 [sec|移动转发] std::move() 移动转发

    移动转发

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

    1. template <typename T>
    2. std::remove_reference_t<T>&& move(T&& val) noexcept {
    3. // forward either lvalue or rvalue as rvalue
    4. return static_cast<std::remove_reference_t<T>&&>(val);
    5. }

    实参/返回值r-ref 返回值l-ref 实参移动转发r-ref 实参移动转发(完美转发)

    • 接受 通用引用模板参数 T&&(无需两个模板,使用时不区分 T 的引用类型)
    • 返回值 static_cast&&(val) 将实参 转为将亡值(右值引用类型)
    • 所以 std::move() 等价于 std::forward<std::removereference_t&&()

    最后,std::move()/std::forward() 只是编译时的变量类型转换,不会产生目标代码。

    写在最后

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

  • 相关阅读:
    AI究竟能提升多少效率?哈佛已揭秘量化结果
    解决dataset.mnist无法加载进去的情况
    python之 @staticmethod 和 @classmethod
    Hugging Face发布重量级版本:Transformer 4.42
    01_CCC3.0数字钥匙_蓝牙OOB配对过程
    玩转Vue3全家桶03丨新特性:初探Vue3新特性
    紫光同创初使用
    Java高性能实体类转换工具MapStruct
    Docker 与 K8S学习笔记(二十五)—— Pod的各种调度策略(下)
    工程企业管理软件源码-综合型项目管理软件
  • 原文地址:https://blog.csdn.net/weixin_52183917/article/details/127928567