C++11 模板 一:彻底理解 std::move 和 std::forward

2023-07-10 05:51:27

 本文尝试串联 C++11 中的几个基础概念来帮助理解 11 中新增的几个特性及原因。

左值和右值

最初的定义来源于 C 语言,即左值是等号左边可以被赋值的表达式,而右值是等号右边的表达式。

然而在 C++ 中要复杂得多,看了很多资料,没有一个很清晰简单的规定,cppreference 上只是罗列了大量的左值和右值情况。

一般来说,变量、函数这些可以取地址的都是左值,可以理解为一个内存单元;一个数字、字符串字面量是右值因为无法给一个 5 、str + "a"、"hello world" 取地址,可以理解为内存单元里的值。

右值引用

左值引用常规引用(&),事实上就是变量的别名。右值引用(&&)是 C++11 新概念。

int a = 0; int &b = a; // b 是一个左值引用 int &c = 4; // error, 不能绑定到右值,没有固定地址 const int& d = 4; // ok, 常量引用,因为不需要修改所以可以引用 int &&e = 10; // ok, e 是一个右值引用,10 是一个右值, e 是一个左值(是的) int &&f = a; // error, 不可以直接指向一个左值

std::move

怎么让如上代码最后一行编译成功,很简单,可以用 std::move

int &&g = std::move(a); // ok, 执行了类型转换,并且 a 仍然可以使用, // 因为 a 是一个 POD 类型

移动语义使编译器可以使用移动操作来代替昂贵的拷贝操作, 但注意 std::move 本身并没有执行什么“移动”操作,事实上只是执行了一次类型转换 static_cast<T&&>(lvalue) ,但可以带来代码上的便利。

左值引用和右值引用都可以避免拷贝,但是右值引用更加灵活:

void f1(const int & a) { a += 1; // not ok } void f2(int && a) { a += 1; // ok } f1(5); void f3(int & a) // 无法使用右值调用,例如 f3(5)

类似地我们可以定义一个移动构造函数:

A(A&& a) { this->data_ = a.data_; a.data_ = nullptr; }

而通常的赋值构造函数参数是一个 const 变量,无法直接交换内部数据,如果改成非 const 则不支持这种临时构造的写法:

A object = A(A());

还有一种情况是不可复制但可以移动的对象,比如 std::thread 和 std::unique_ptr ,那么只能用 std::thread A = std::move(B) 的形式来转移对象的所有权。

注意上文只是为了方便语法上的描述,对于 POD 类型,事实上 move 只能执行 copy 操作。此外现在的很多类型已经优化了构造函数可能也不能从 std::move 中获得更多收益,所以有没有性能提升还是看 benchmark。

item 29 (下文的 item 均指 Effective Modern C++)列举了几种C++11的移动语义并无优势情况:

没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快。 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为noexcept (item14)

另外不要滥用 std::move ,比如没必要在 函数返回值处使用。

通用引用

前面我们介绍了右值引用的基本情况,接下来我们来看下模板。

上文介绍了一个可以接受右值的函数定义

void f(int && a); or template <typename T> void f1(T&& a) { }

准确来说,参数定义是一个通用引用,它也可以接受一个左值。

一个通用引用的初始值决定了它是代表了右值引用还是左值引用。如果初始值是一个右值,那么通用引用就会是对应的右值引用,如果初始值是一个左值,那么通用引用就会是一个左值引用。

那么具体的类型是怎么被推导的?这里我们就可以引入下文引用折叠的概念。

引用折叠

C++ 代码里是不允许直接定义一个引用的引用,但是由于通用引用的存在,在类型推导时可能会出现一种场景:

template <typename T> void f(T&& a) { } int a = 0; f(a); // 1. T 是什么, T 是 int& f(std::move(a)); // 2. 正常的右值引用

如果将模板展开,那么就是:

void f(int& && a); // 注意空格的位置,帮助理解

如果我们间接创建一个引用的引用,则这些引用形成了“折叠”。在所有情况下(除了一个例外),引用会折叠成一个普通的左值引用类型。只在一种特殊情况下引用会折叠成右值引用:右值引用的右值引用。即,对于一个给定类型X:

· X& &、X& &&和X&& & 都折叠成类型 X&

· 类型X&& &&折叠成X&& (c++ primer)

因而实际上等价于

void f(int& a);

这就实现了左值还是左值,右值还是右值,故称为通用引用。然而还有一种情况,单纯使用通用引用仍然是不够的。

完美转发

“转发”仅表示将一个函数的形参传递——就是转发——给另一个函数。对于第二个函数(被传递的那个)目标是收到与第一个函数(执行传递的那个)完全相同的对象。

来看一种情况:

template<typename T> void f(T&& a) { g(a); // 1. 这里的 a 是什么类型? } // 版本 1 template<typename T> void g(T &) { cout << "T&" << endl; } // 版本 2 template<typename T> void g(T &&) { cout << "T&&" << endl; } int a; f(0); f(a);

打印的结果是什么?

T&

T&

思考下 问题 1,a 是一个左值还是右值?a 是一个变量,当然就是左值!所以展开方式跟引用折叠一节是一样的!

怎么解决这个问题呢?

假定我们有一些函数f,然后想编写一个转发给它的函数(事实上是一个函数模板)。我们需要的核心看起来像是这样:
template<typename T> void fwd(T&& param) //接受任何实参 { f(std::forward<T>(param)); //转发给f }
从本质上说,转发函数是通用的。例如fwd模板,接受任何类型的实参,并转发得到的任何东西。这种通用性的逻辑扩展是,转发函数不仅是模板,而且是可变模板,因此可以接受任何数量的实参。fwd的可变形式如下:
template<typename... Ts> void fwd(Ts&&... params) //接受任何实参 { f(std::forward<Ts>(params)...); //转发给f }

因此改变一个函数 f

template<typename T> void f(T&& a) { g(std::forward<T>(a)); } // 版本 1 template<typename T> void g(T &) { cout << "T&" << endl; } // 版本 2 template<typename T> void g(T &&) { cout << "T&&" << endl; } int a; f(0); f(a);

问题就解决了!

补充:条款30 描述了一些转发失败的场景 kelthuzadx/EffectiveModernCppChinese

参考文献

Effective Modern C++ item 23 ~30 C++ primer 第16章Value categories


以上就是关于《C++11 模板 一:彻底理解 std::move 和 std::forward》的全部内容,本文网址:https://www.7ca.cn/baike/51203.shtml,如对您有帮助可以分享给好友,谢谢。
标签:
声明