You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
std::unique_ptr<int> foo(){ auto ret = std::make_unique<int>(1); returnstd::move(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. —— Scott Meyers, Effective Modern C++
深入浅出 C++ 11 右值引用 | BOT Man JL
https://ift.tt/lbwkinL
目录
1 写在前面
尽管 C++ 17 标准已经发布了,很多人还不熟悉 C++ 11 的 右值引用/移动语义/拷贝省略/通用引用/完美转发 等概念,甚至对一些细节 有所误解(包括我 🙃)。
本文将以最短的篇幅,一步步解释 关于右值引用的 为什么/是什么/怎么做。先分享几个我曾经犯过的错误。😂
1.1 误解:返回前,移动局部变量
上述代码的问题在于:使用
std::move()
移动局部变量source
,会导致后续代码不能使用该变量;如果使用,会出现 未定义行为 (undefined behavior)(参考:std::basic_string(basic_string&&)
)。如果把
std::string
换成std::unique_ptr
,则可能导致 空指针崩溃:对于不需要 转移所有权 的情况,应该改用 裸指针引用:
如何检查 移动后使用 (use after move):
1.2 误解:被移动的值不能再使用
很多人认为:被移动的值会进入一个 非法状态 (invalid state),对应的 内存不能再访问。
其实,C++ 标准要求对象 遵守 § 3 移动语义 —— 被移动的对象进入一个 合法但未指定状态 (valid but unspecified state),调用该对象的方法(包括析构函数)不会出现异常,甚至在重新赋值后可以继续使用:
另外,基本类型(例如
int/double
)的移动语义 和拷贝相同:1.3 误解:移动非引用返回值
上述代码的问题在于:没必要使用
std::move()
移动非引用返回值。C++ 会把 即将离开作用域的 非引用类型的 返回值当成 右值(参考 § 2.1),对返回的对象进行 § 3 移动构造(语言标准);如果编译器允许 § 4 拷贝省略,还可以省略这一步的构造,直接把
ret
存放到返回值的内存里(编译器优化)。另外,误用
std::move()
会 阻止 编译器的拷贝省略 优化。不过聪明的 Clang 会提示-Wpessimizing-move
/-Wredundant-move
警告。1.4 误解:不移动右值引用参数
上述代码的问题在于:没有对返回值使用
std::move()
(编译器提示std::unique_ptr(const std::unique_ptr&) = delete
错误)。因为不论 左值引用 还是 右值引用 的变量(或参数)在初始化后,都是左值(参考 § 2.1):
所以,返回右值引用变量时,需要使用
std::move()
/std::forward()
显式的 § 5.4 移动转发 或 § 5.3 完美转发,将变量 “还原” 为右值(右值引用类型)。1.5 误解:手写错误的移动构造函数
实际上,多数情况下:
=default
让编译器生成 移动构造/移动赋值 函数,否则 容易写错noexcept
不抛出异常(编译器生成的版本会自动添加),否则 不能高效 使用标准库和语言工具例如,标准库容器
std::vector
在扩容时,会通过std::vector::reserve()
重新分配空间,并转移已有元素。如果扩容失败,std::vector
满足 强异常保证 (strong exception guarantee),可以回滚到失败前的状态。为此,
std::vector
使用std::move_if_noexcept()
进行元素的转移操作:noexcept
移动构造函数(高效;不抛出异常)noexcept
移动构造函数(高效;如果异常,无法回滚)如果 没有定义移动构造函数 或 自定义的移动构造函数没有
noexcept
,会导致std::vector
扩容时执行无用的拷贝,不易发现。2 基础知识
之所以会出现上边的误解,往往是因为 C++ 语言的复杂性 和 使用者对基础知识的掌握程度 不匹配。
2.1 值类别 vs 变量类型
划重点 —— 值 (value) 和 变量 (variable) 是两个独立的概念:
i + j + k
)值类别 (value category) 可以分为两种:
引用类型 (reference type) 属于一种 变量类型 (variable type),将在 § 2.2 详细讨论。
在变量 初始化 (initialization) 时,需要将 初始值 (initial value) 绑定到变量上;但 引用类型变量 的初始化 和其他的值类型(非引用类型)变量不同:
2.2 左值引用 vs 右值引用 vs 常引用
引用类型 可以分为两种:
&
符号引用 左值(但不能引用右值)&&
符号引用 右值(也可以移动左值)data1
在初始化时,不能绑定右值Data{}
data2
在初始化时,不能绑定左值data
std::move()
将左值 转为右值引用(参考 § 5.4)data2
被初始化后,在作用域内是 左值(参考 § 1.4),所以匹配f()
的 重载 2另外,C++ 还支持了 常引用 (c-ref, const reference),同时接受 左值/右值 进行初始化:
常引用和右值引用 都能接受右值的绑定,有什么区别呢?
2.3 引用参数重载优先级
如果函数重载同时接受 右值引用/常引用 参数,编译器 优先重载 右值引用参数 —— 是 § 3 移动语义 的实现基础:
针对不同左右值 实参 (argument) 重载 引用类型 形参 (parameter) 的优先级如下:
T
),会和上述 传引用 (by reference) 重载产生歧义,编译失败const T&&
一般不直接使用(参考)const T
,否则无法使用 § 3 移动语义)2.4 引用折叠
引用折叠 (reference collapsing) 是 § 5.4
std::move()
和 § 5.3std::forward()
的实现基础:3 移动语义
在 C++ 11 强化了左右值概念后,提出了 移动语义 (move semantic) 优化:由于右值对象一般是临时对象,在移动时,对象包含的资源 不需要先拷贝再删除,只需要直接 从旧对象移动到新对象。
同时,要求 被移动的对象 处于 合法但未指定状态(参考 § 1.2):
std::unique_ptr::~unique_ptr()
检查指针是否需要delete
)std::unique_ptr
恢复为nullptr
)由于基本类型不包含资源,其移动和拷贝相同:被移动后,保持为原有值。
3.1 避免先拷贝再释放资源
一般通过 重载构造/赋值函数 实现移动语义。例如,
std::vector
有:上述代码中,构造函数
vector::vector()
根据实参判断(重载优先级参考 § 2.3):new[]
/std::copy_n
拷贝原对象的所有元素(本方案有一次冗余的默认构造,仅用于演示)data_
、内存大小size_
拷贝到新对象,并把原对象这两个成员置0
析构函数
vector::~vector()
检查 data_ 是否有效,决定是否需要释放资源。此外,类的成员函数 还可以通过 引用限定符 (reference qualifier),针对当前对象本身的左右值状态(以及 const-volatile)重载:
3.2 转移不可拷贝的资源
如果资源对象不可拷贝,一般需要定义 移动构造/移动赋值 函数,并禁用 拷贝构造/拷贝赋值 函数。例如,智能指针
std::unique_ptr
只能移动 (move only):上述代码中,
unique_ptr
的移动构造过程和vector
类似:data_
拷贝到新对象data_
置为空3.3 反例:不遵守移动语义
移动语义只是语言上的一个 概念,具体是否移动对象的资源、如何移动对象的资源,都需要通过编写代码 实现。而移动语义常常被 误认为,编译器 自动生成 移动对象本身的代码(§ 4 拷贝省略)。
为了证明这一点,我们可以实现不遵守移动语义的
bad_vec::bad_vec(bad_vec&& rhs)
,执行拷贝语义:那么,一个
bad_vec
对象在被move
移动后仍然可用:虽然代码可以那么写,但是在语义上有问题:进行了拷贝操作,违背了移动语义的初衷。
4 拷贝省略
尽管 C++ 引入了移动语义,移动的过程 仍有优化的空间 —— 与其调用一次 没有意义的移动构造函数,不如让编译器 直接跳过这个过程 —— 于是就有了 拷贝省略 (copy elision)。
然而,很多人会把移动语义和拷贝省略 混淆:
上述代码展示了什么是拷贝省略(在线运行):
Foo
对象大小为 2 个int
(8 byte)不要相信一个熬夜的人说的每一句话 #1CreateFoo()
给变量foo
分配 8 byte 栈空间 5 Easy Ways to Save an Article Feedly Blog #3CreateFoo()
返回时把 8 byte(qword
)数据拷贝到rax
寄存器 魏阳 中国人的月亮崇拜史 #4main()
把调用返回的rax
拷贝到变量a
的 8 byte(qword
)的栈空间里 企業復盤SLOW之O 青蛙加復盤等於閉環Closed Loop #6Bar
对象大小为 8 个int
(32 byte)如何收集竞争情报财报解读 #2main()
把给变量b
分配的 32 byte 栈空间的地址,写入rdi
寄存器 C11多线程编程(2) - Chen Yuan's Blog #7CreateBar()
的变量bar
和调用者main()
里的b
使用相同的地址(放在rdi
),不需要分配栈空间,也不构造新的对象 卢小波 难吃的月饼勾画出了中国的人际关系 #5另外,§ 1.3 提到:如果使用
std::move()
移动返回值,会导致拷贝省略不可用 —— 分配两次栈空间,再多执行一次构造函数,将会带来 不必要的开销。C++ 17 要求编译器对 纯右值 (prvalue, pure rvalue) 进行拷贝省略优化。(参考)
初始化 局部变量、函数参数时,传入的纯右值可以确保被优化 —— Return Value Optimization (RVO);而返回的 将亡值 (xvalue, eXpiring value) 不保证被优化 —— Named Return Value Optimization (NRVO)。
5 通用引用和完美转发
5.1 为什么需要通用引用
C++ 11 引入了变长模板的概念,允许向模板参数里传入不同类型的不定长引用参数。由于每个类型可能是左值引用或右值引用,针对所有可能的左右值引用组合,特化所有模板 是 不现实的。
假设没有 通用引用的概念,模板
std::make_unique<>
至少需要两个重载:const Args&... args
,只要展开args...
就可以转发这一组左值引用Args&&... args
,需要通过 § 5.4std::move()
转发出去,即std::move<Args>(args)...
(为什么要转发:参考 § 1.4)上述代码的问题在于:如果传入的
args
既有 左值引用 又有 右值引用,那么这两个模板都 无法匹配。5.2 通用引用
Scott Meyers 指出:有时候符号
&&
并不一定代表右值引用,它也可能是左值引用 —— 如果一个引用符号需要通过 左右值类型推导(模板参数类型 或auto
推导),那么这个符号可能是左值引用或右值引用 —— 这叫做 通用引用 (universal reference)。上述代码中,前三个
&&
符号不涉及引用符号的左右值类型推导,都是右值引用;而后两个&&
符号会 根据初始值推导左右值类型:var2
var1
是左值,所以var2
也是左值引用var1
的变量类型T&&
param
传入左值,T&&
是左值引用std::remove_reference_t<T>&
param
传入右值,T&&
是右值引用std::remove_reference_t<T>&&
基于通用引用,§ 5.1 的模板
std::make_unique<>
只需要一个重载:其中,
std::forward()
实现了 针对不同左右值参数的转发 —— 完美转发。5.3 完美转发
什么是 完美转发 (perfect forwarding):
因此,
std::forward()
定义两个 不涉及 左右值类型 推导 的模板(不能使用 通用引用参数):T
的类型无关static_cast<T&&>(val)
经过模板参数T&&
§ 2.4 引用折叠 实现 完美转发/移动转发,和实参类型无关std::move()
移动转发5.4 移动转发
类似的,
std::move()
只转发为右值引用类型:T&&
(无需两个模板,使用时不区分T
的引用类型)static_cast<std::remove_reference_t<T>&&>(val)
将实参 转为将亡值(右值引用类型)std::move<T>()
等价于std::forward<std::remove_reference_t<T>&&>()
最后,
std::move()
/std::forward()
只是编译时的变量类型转换,不会产生目标代码。写在最后
虽然这些东西你不知道,也不会伤害你;但如果你知道了,就可以合理利用,从而提升开发效率,避免不必要的问题。
感谢 @flythief/@WalkerJG 的修改建议,感谢 @泛化之美 对 § 1.5 的补充~ 😊
如果有什么问题,欢迎交流。😄
Delivered under MIT License © 2018, BOT Man
via bot-man-jl.github.io https://ift.tt/AyXdL0G
September 28, 2023 at 10:08AM
The text was updated successfully, but these errors were encountered: