C++面试基础知识:移动语义 Perfect Forwarding
之前在C++中的左值与右值问题的由来以及应用和意义中讨论了左值和右值这两个概念的由来
这篇blog讨论左值和右值的实际应用
移动语义std::move
C++11新增了的特性,移动语义可以在处理大型数据结构或资源密集型对象时避免深度拷贝,从而显著提升性能。
如果学过Rust,会发现,其实左值右值,移动语义的概念,和Rust中的所有权,引用,可变引用,借用这一套机制是差不多的原理,都是将“数据”和“变量”分割开了【或者说将“值”和“变量”分割开】,通过移动“值”来实现新变量的零开销构建。
引入:拷贝 vs 移动
C++可以从一个已有对象构造一个新对象称为拷贝构造,当该类型中含有指针指向堆上分配的资源时,拷贝构造函数需要进行深拷贝,即在堆上分配新的空间,将指针指向的内容也进行拷贝。
对于函数调用等情况,会产生临时变量,或者生命周期到头的变量,例如:
#include<iostream>
class A
{
public:
A(int capacity = 0):_capacity(capacity){
_data = (int*)malloc(sizeof(int) * capacity);
}
int capacity() const{
return _capacity;
}
~A(){
if(_data != nullptr){
free(_data);
}
}
private:
int _capacity = 0;
int* _data = nullptr;
};
class B{
public:
B(int capacity) : _capacity(capacity){
_data = (float*)malloc(capacity*sizeof(float));
}
~B(){
if(_data != nullptr){
free(_data);
}
}
private:
int _capacity = 0;
float* _data = nullptr;
};
B test(A a){
std::cout << "Test" << std::endl;
B ans(a.capacity());
return ans;
}
int main(){
A a(10);
B temp = test(a);
return 0;
}
当值传递的时候,会调用拷贝构造函数构造一个新的对象传给函数,但是如果这个对象,在传给这个函数后,就不需要了,那我们是否可以直接将这个对象就传给函数,而不调用拷贝构造,这样是否会快一些?还有在函数中生成的B对象,在返回的时候是否可以直接用这个B对象,而不是再拷贝构造一个。
答案就是今天的主角,移动语义,顾名思义,就是将
前置知识:左值右值
C++中左值右值问题
-gvalue:泛左值
- lvalue:左值,可以取地址的表达式
- rvalue:右值
- prvalue:纯右值,字面值如1,2,4,但是字符串字面值是左值,因为字符串字面值
“abc”
的本质是const char (*)[4]
。 - xvalue:将亡值
主要是了解将亡值(xvalue)
将亡值:
- prvalue:纯右值,字面值如1,2,4,但是字符串字面值是左值,因为字符串字面值
- 返回右值引用的调用表达式
- 转换为右值引用的转换函数调用表达式如
std::move
移动语义
移动语义的主要应用场景就是处理将亡值。当编译器遇到一个将亡值时,它可以选择调用移动构造函数或者移动赋值运算符来高效地转移资源,而不是调用拷贝构造函数进行复制。因为将亡值即将被销毁,所以将其资源转移给其他对象是一种安全且高效的操作。移动语义可以在处理大型数据结构或资源密集型对象时避免深度拷贝,从而显著提升性能。
代码:
#include <iostream>
#include <vector>
class MoveAble
{
public:
std::vector<int> _data;
MoveAble(std::vector<int> && input) noexcept : _data(std::move(input)) {
std::cout << "move input to data" <<std::endl;
}
MoveAble(MoveAble && other) noexcept {
// 右值引用经过函数传递后,会丢失右值特性,所以需要使用std::move
_data = std::move(other._data);
std::cout << "move construct" <<std::endl;
}
};
int main()
{
std::vector<int> input = {1,2,3,4,5};
MoveAble test(std::move(input));
MoveAble test2(std::move(test));
}
移动语义与拷贝优化(Copy Elision)
拷贝优化主要包括返回值优化(RVO)和具名返回值优化(NRVO)等情况。其原理是编译器在某些特定场景下(如函数返回对象或用临时对象初始化同类型对象),通过直接在目标位置构造对象,避免调用拷贝构造函数来创建副本,以提高性能。
- 返回值优化RVO(Return Value Optimization):当函数返回一个局部对象时,编译器可以直接在函数调用者的栈帧或目标对象的存储位置构造返回值,而不是先在函数内部构造一个局部对象,然后通过拷贝构造函数或移动构造函数将其复制到目标位置。
- 命名返回值优化NRVO(Named Return Value Optimization):具名返回值优化(Named Return Value Optimization,NRVO):这是 RVO 的一种扩展情况,当函数返回的对象有名字(即不是一个匿名临时对象)时,编译器也可能进行优化。
在某些环境下,编译器不能执行此优化。一个常见情形是当函数依据执行路径返回不同的命名对象,或者命名对象在asm内联块中被使用
在 C++17 之前,RVO 是一个可选优化,但在 C++17 标准之后,RVO 被强制启用,编译器必须在符合条件的情况下执行拷贝省略。std::move
的出现增加了对于对象资源管理的精细控制,但其滥用可能会破坏编译器的优化。
使用std::move在返回值时会阻止编译器进行RVO或NRVO。这是因为std::move强制将对象视为右值,即使它是一个局部变量。编译器必须假设这个对象的资源可能已经被外部引用,因此不能在原地构造返回值。虽然std::move在某些情况下可以提高性能,如在函数接受右值引用参数时,但在返回局部变量时使用它将阻止RVO或NRVO,导致不必要的移动或拷贝操作,从而降低程序性能。
最佳实践:
当返回局部对象时,应该避免使用std::move,以便编译器可以尽可能地应用RVO或NRVO。只需简单地返回对象即可
当需要进行传参的时候
#include <iostream>
#include <vector>
#include "./testclass.hpp"
Vector GetVector(int size)
{
Vector vec(size);
return vec;
}
Vector GetVector2(int size)
{
Vector vec(size);
return std::move(vec);
}
int main()
{
// 测试移动构造函数
Vector input(10);
TestClass test(std::move(input));
TestClass test2(std::move(test));
std::cout << "----------------" << std::endl;
// 测试返回值优化
Vector vec = GetVector(10);
std::cout << "----------------" << std::endl;
Vector vec2 = GetVector2(10);
// 可以看到,使用了move之后,返回值优化失效,相较于GetVector函数,GetVector2函数多了一次move构造函数的调用
}
noexpect关键字
noexcept关键字告诉编译器该函数不会抛出异常,方便编译器进行更多优化。
C++的异常处理是在运行时检测的,编译器需要添加额外代码,影响优化,C++11以后使用noexcept来表示该函数不会抛出异常
The noexcept operator performs a compile-time check that returns true if an expression is declared to not throw any exceptions.
It can be used within a function template’s noexcept specifier to declare that the function will throw exceptions for some types but not others.
此外,C++ 标准库容器(如 std::vector)在扩容或重新分配内存时,会优先选择使用 noexcept 的移动构造函数。对于不带 noexcept 的移动构造函数,std::vector 会选择拷贝而不是移动,以确保异常安全性。
在以下情况推荐使用noexcept:
- 移动构造函数(move constructor)
- 移动分配函数(move assignment)
- 析构函数(destructor)。这里提一句,在新版本的编译器中,析构函数是默认加上关键字noexcept的。下面代码可以检测编译器是否给析构函数加上关键字noexcept
- 叶子函数(Leaf Function)。叶子函数是指在函数内部不分配栈空间,也不调用其它函数,也不存储非易失性寄存器,也不处理异常。
最佳实践:
在移动构造和移动赋值中使用 noexcept:在定义移动构造函数和移动赋值运算符时,若确保无异常抛出,应加上 noexcept,以便标准库容器能够更高效地处理对象。
STL中的很多内容都优先调用noexcept函数,例如:标准库中的一些函数,如智能指针的析构函数,需要保证不会抛出异。
所以如果可以,尽量在上述情况中填上noexcept,但是没把握的情况下,不要轻易使用noexcept
Perfect Forwarding(完美转发/精确传递)
TestClass(Vector && input) noexcept : _data(input) {
std::cout << "move input to data" <<std::endl;
}
这么写一个成员对象移动构造函数对吗?
答案是不对,右值引用经过参数传递后会丢失右值性,所以我们需要再次调用move来变为右值
因为虽然input是一个"右值引用"类型,但是他是“左值”,只不过这个左值必须由右值来初始化
TestClass(Vector && input) noexcept : _data(std::move(input)) {
std::cout << "move input to data" <<std::endl;
}
完美转发就是将输入进来的右值转发给自己内部调用该右值的地方
TestClass(Vector && input) noexcept : _data(std::forward<Vector>(input)) {
std::cout << "move input to data" <<std::endl;
}
与std::move()相区别的是,move()会无条件的将一个参数转换成右值,而forward()则会保留参数的左右值类型。
所以,上面的这种情景并不是forward的常用情景,forward主要是配合模板使用。模板参数T&&
可以出发引用折叠,配合std::forward
可以实现完美转发效果,即不管进来的是什么类型的引用都可以以原来的类型转发过去。
简而言之,完美转发就像是个if-else一样,根据输入的引用类型自动选择调用对应的函数
#include <iostream>
template <typename T>
void Print(T& t)
{
std::cout << "left reference" << std::endl;
}
template <typename T>
void Print(T&& t)
{
std::cout << "right reference" << std::endl;
}
template <typename T>
void PerfectFroward(T&& t)
{
Print(t); // 永远调用左值引用版
Print(std::move(t)); // 永远调用右值引用版
Print(std::forward<T>(t)); // 根据输入的类型,调用左值引用版或右值引用版
}
int main()
{
int b=20,c=30;
PerfectFroward(b); // 调用左值引用版
std::cout << "----------------" << std::endl;
PerfectFroward(std::move(c)); // 调用右值引用版
return 0;
}
完美转发的实现原理为:universal reference(通用引用/万能引用)+Reference Collapsing Rules(引用折叠/引用合成)
universal reference是Scott Meyers在C++ and Beyond 2012演讲中自创的一个词,用来特指一种引用的类型。
构成通用引用有两个条件:
- 必须满足T&&这种形式
- 类型T必须是通过推断得到的【可以尝试将
forward<T>
中的T自己改成确定的类型,就会发现完美转发失效了】我们所使用的这种引用,其实是通用引用,而不是所谓的单纯的右值引用。
引用折叠的规则:
- T& & => T&
- T&& & => T&
- T& && => T&
- T&& && => T&&
标准库glibc中forward的实现
/**
* @brief Forward an lvalue.
* @return The parameter cast to the specified type.
*
* This function is used to implement "perfect forwarding".
*/
template<typename _Tp>
_GLIBCXX_NODISCARD
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }
当我们输入的是左值的时候,T自动推导为T&,则
constexpr T& &&
forward(typename std::remove_reference<T&>::type& __t) noexcept
{ return static_cast<T& &&>(__t); }
// 推导结果:
constexpr T&
forward(T & __t) noexcept
{ return static_cast<T&>(__t); }
带入到原始代码里
template <typename T>
void PerfectFroward(T&& t)
//折叠结果:void PerfectFroward(T& t)
{
Print(std::forward<T>(t));
//折叠结果:Print(T& t)
}
右值时同理,T自动推导为T&&,各处折叠如上折叠规则。
完美转发的本质目的是将左值引用右值引用统一签名,节约代码量
有点像继承一样,左值引用和右值引用都来自原对象,通过完美转发可以实现类似多态的效果
代码
#include <iostream>
/// @brief 对象五原则:拷贝(移动)构造函数、拷贝(移动)赋值函数、析构函数
/// 如果要自己实现其中一个,就要自己实现其他, 除非有特殊需求
class Vector
{
public:
/// @brief 默认构造函数
Vector():_size(0), _data(nullptr){}
/// @brief 构造函数
Vector(int size):_size(size){
std::cout << "malloc" << std::endl;
_data = new int[size];
}
/// @brief 拷贝构造函数
Vector(const Vector& other):_size(other._size){
std::cout << "malloc" << std::endl;
_data = new int[_size];
for(int i = 0; i < _size; i++){
_data[i] = other._data[i];
}
}
/// @brief 移动构造函数
Vector(Vector&& other):_size(other._size), _data(other._data){
std::cout << "move construct" << std::endl;
other._size = 0;
other._data = nullptr;
}
/// @brief 析构函数
~Vector(){
if(_data != nullptr){
delete[] _data;
}
}
/// @brief 拷贝赋值函数
Vector& operator=(const Vector& other){
if(this == &other){
return *this;
}
if(_data != nullptr){
delete[] _data;
}
_size = other._size;
std::cout << "malloc" << std::endl;
_data = new int[_size];
for(int i = 0; i < _size; i++){
_data[i] = other._data[i];
}
return *this;
}
/// @brief 移动赋值函数
Vector& operator=(Vector&& other){
if(this == &other){
return *this;
}
if(_data != nullptr){
delete[] _data;
}
_size = other._size;
_data = other._data;
other._size = 0;
other._data = nullptr;
return *this;
}
private:
int* _data;
int _size;
};
class TestClass
{
public:
Vector _data;
TestClass(Vector && input) noexcept : _data(std::forward<Vector>(input)) {
std::cout << "move input to data" <<std::endl;
}
TestClass(TestClass && other) noexcept { //移动构造函数
// 右值引用经过函数传递后,变成左值,丢失右值特性,所以需要使用std::move
_data = std::move(other._data);
std::cout << "move construct" <<std::endl;
}
TestClass(const TestClass& other){ //拷贝构造函数
_data = other._data;
std::cout << "copy construct" <<std::endl;
}
};