解析C++中std::ref的使用
作者:leapmotion
1. 前言
关于c++中的std::ref,std::ref在c++11引入。
本文通过讲解std::ref的常用方式,及剖析下std::ref内部实现,进而再来讲解下std::reference_wrapper,然后我们再进一步分析为什么使用std::ref。
2. std::ref 用法
简单举例来说:
int n1 = 0; auto n2 = std::ref(n1); n2++; n1++; std::cout << n1 << std::endl; // 2 std::cout << n2 << std::endl; // 2
可以看到 是把n1的引用传递给了n2,分别进行加法,可以看到n2是n1的引用,最终得到的值都是2
那么大家可能会想,我都已经有了’int& a = b’的这种引用赋值的语法了,为什么c++11又出现了一个std::ref,我们继续来看例子:
#include <iostream> #include <thread> void thread_func(int& n2) { // error, >> int n2 n2++; } int main() { int n1 = 0; std::thread t1(thread_func, n1); t1.join(); std::cout << n1 << std::endl; }
我们如果写成这样是编译不过的,除非是去掉引用符号,那么我如果非要传引用怎么办呢?
// snap ... int main() { int n1 = 0; std::thread t1(thread_func, std::ref(n1)); t1.join(); std::cout << n1 << std::endl; // 1 }
这样可以看到引用传递成功,并且能够达到我们效果,我们再来看个例子:
#include <iostream> #include <functional> void func(int& n2) { n2++; } int main() { int n1 = 0; auto bind_fn = std::bind(&func, std::ref(n1)); bind_fn(); std::cout << n1 << std::endl; // 1 }
这里我们也发现std::bind这样也是需要通过std::ref来实现bind引用。
那么我们其实可以看的出来,std::bind或者std::thread里是做了什么导致我们原来的通过&传递引用的方式失效,或者说std::ref是做了什么才能使得我们使用std::bind和std::thread能够传递引用。
那么我们展开std::ref看看他的真面目,大致内容如下:
template <class _Ty> reference_wrapper<_Ty> ref(_Ty& _Val) noexcept { return reference_wrapper<_Ty>(_Val); }
这里我们看到std::ref最终只是被包装成reference_wrapper返回,所以关键点还是std::reference_wrapper
3. std::reference_wrapper
关于这个类,我们看下cppreference上的实现形式为:
namespace detail { template <class T> constexpr T& FUN(T& t) noexcept { return t; } template <class T> void FUN(T&&) = delete; } template <class T> class reference_wrapper { public: // types typedef T type; // construct/copy/destroy template <class U, class = decltype( detail::FUN<T>(std::declval<U>()), std::enable_if_t<!std::is_same_v<reference_wrapper, std::remove_cvref_t<U>>>() )> constexpr reference_wrapper(U&& u) noexcept(noexcept(detail::FUN<T>(std::forward<U>(u)))) : _ptr(std::addressof(detail::FUN<T>(std::forward<U>(u)))) {} reference_wrapper(const reference_wrapper&) noexcept = default; // 赋值 reference_wrapper& operator=(const reference_wrapper& x) noexcept = default; // 访问 constexpr operator T& () const noexcept { return *_ptr; } constexpr T& get() const noexcept { return *_ptr; } template< class... ArgTypes > constexpr std::invoke_result_t<T&, ArgTypes...> operator() ( ArgTypes&&... args ) const { return std::invoke(get(), std::forward<ArgTypes>(args)...); } private: T* _ptr; }; // deduction guides template<class T> reference_wrapper(T&) -> reference_wrapper<T>;
里边有一些语法比较晦涩,我们一点一点的来看
最开始是一个detail的namespace,里边有两个函数,第一个是接收左值引用的,第二个是接收右值引用的,接收右值引用的被delete,不能调用。这里detail是为后边做校验的,大家可能会像,不用右值引用不写就可以了,为啥写了这个函数还要标记为delete。这是因为如果没有第二个函数右值参数是可以传递给第一个函数的,如果写了就会优先匹配到到第二个函数,发现这个函数是delete,不能编译通过,明白了这个我们继续。
接着我们看到reference_wrapper,首先是一个模板,看到很长的一个构造函数,我们拆开来看,template <class U, class = xxx>这种写法,后边那个class=也是在编译期做校验使用,SFINEA的一种实现形式吧,如果class=后边那个编译不过,那么你就不可以使用这个构造函数。
class=后边这段很长的代码:
template <class U, class = decltype( detail::FUN<T>(std::declval<U>()), std::enable_if_t<!std::is_same_v<reference_wrapper, std::remove_cvref_t<U>>>() )>
首先是一个decltype关键字,得到的是一个类型。decltype内部是使用逗号表达式连接两部分,逗号左边部分调用detail的FUN来校验,std::declval是不用调用构造函数便可以使用类的成员函数,不过只能用于不求值语境。获取U的对象看下是否是右值,上边也说到如果右值则编译不过。
如果是左值的话看逗号右边的部分,std::enable_if_t<>, 这里<>中的第一个参数是条件,如果条件满足返回第二个参数,第二个参数是类型, 这里没有第二个参数,默认是void,即如果满足条件可以编译通过,否则编译不通过。条件是std::is_same_v取反,std::is_same_v<>是如果两个模板参数相同类型则是true,否则false。
所以reference_wrapper和std::remove_cvref_t<U>不相同则可以通过编译,std::remove_cvref_t这个模板又是去掉U这个类型的const,volatile和引用的属性,单纯两个类型比较。
上边总结就是在调用构造函数时,首先进行校验,传入参数时右值和reference_wrapper类型就不能编译通过。
然后是构造函数的正文:
constexpr reference_wrapper(U&& u) noexcept(noexcept(detail::FUN<T>(std::forward<U>(u)))) : _ptr(std::addressof(detail::FUN<T>(std::forward<U>(u)))) {}
这里先看下“noexcept(noexcept(detail::FUN(std::forward<U>(u))))”这段代码,不了解noexcept我这里大概讲解下。
- 语法上来说noexcept分为修饰符和操作符两种分类吧。
- 修饰符写法是noexcept(expression),expression是常量表达式,expression这个值返回true则编译器认为修饰的函数不抛出异常,这时如果该函数再抛出异常则调用std::abort终止程序,如果值返回false则认为该函数可能会抛出异常。而我们常看到函数声明后边只写一个noexcept,其实也是相当于noexcept(true)。
- 操作符大都用于模板中,写法就是我们这里缩写的那样noexcept(noexcept(T())),那么这里T()决定该函数是否抛出异常,如果T()会抛出异常那么第二个noexcept就会返回false,否则返回true。
那么这里构造函数就是说如果执行“detail::FUN(std::forward<U>(u))”不会抛出异常,那么就不会抛出异常,这样也是更好的告知编译器一个条件吧。
继续的就是_ptr存放的是传进来参数的地址,这里也是比较关键,相当于是reference_wrapper的实现就是通过保存传进来参数的地址来达到引用的包装(ref wrapper)效果。
构造函数终于讲完了,拷贝构造函数和赋值运算符应该不用讲了
再然后就是看下如何访问了
constexpr operator T& () const noexcept { return *_ptr; } constexpr T& get() const noexcept { return *_ptr; }
这两个也比较简单,提供了一个get函数和()的重载,实现就是获取_ptr存放地址所指向的值。
template< class... ArgTypes > constexpr std::invoke_result_t<T&, ArgTypes...> operator() ( ArgTypes&&... args ) const { return std::invoke(get(), std::forward<ArgTypes>(args)...); }
还有一个实现是给存放的参数是函数类型使用的,也就是重载"()()",可以调用这个函数并传参过去。
最后就是C++17引入的推导指引,顾名思义就是帮助模板类型推导使用的
推导指引 template<class T> reference_wrapper(T&) -> reference_wrapper<T>;
如果没有这句话,我们构造reference_wraper时,需要这么写reference_wraper<int>(n1),那么有了这句推导指引,我们可以写成这样reference_wraper(n1),方便很多,不用写模板参数类型。
那么接下来我们调用试试看(因为cppreference中实现有些语法用到了C++17或者更高,使用编译器要更高版本或者替换一些语法即可):
void func(int& n2) { n2++; } int main() { int n1 = 0; auto bind_fn = std::bind(&func, reference_wrapper(n1)); bind_fn(); std::cout << n1 << std::endl; // 1 }
完美!可以通过, 所以reference_wrapper本质是把对象的地址保存, 访问是取出地址的值。
这里我们借助的是cppreference中实现来讲解的,大家也可以参考自己本地编译器的实现。
4. 为什么使用
我们看下为什么std::bind或者std::thread为什么要使用reference_wrapper,我们以std::bind为例子吧,我们大致去跟踪下std::bind,跟踪的目的是看传递bound参数(即我们传给bind函数的参数)的生命周期,以vs2019的实现为例:
template <class _Fx, class... _Types> _NODISCARD _CONSTEXPR20 _Binder<_Unforced, _Fx, _Types...> bind(_Fx&& _Func, _Types&&... _Args) { return _Binder<_Unforced, _Fx, _Types...>(_STD forward<_Fx>(_Func), _STD forward<_Types>(_Args)...); }
看到是构造了一个_Binder的对象返回,bound参数作为构造函数的参数传入,
using _Second = tuple<decay_t<_Types>...>; //std::decay_t会移除掉引用属性 _Compressed_pair<_First, _Second> _Mypair; constexpr explicit _Binder(_Fx&& _Func, _Types&&... _Args) : _Mypair(_One_then_variadic_args_t{}, _STD forward<_Fx>(_Func), _STD forward<_Types>(_Args)...) {}
也可以看到构造函数中,参数传递给_Mypair成员。到这里结束。
我们再看下调用时:
#define _CALL_BINDER \ _Call_binder(_Invoker_ret<_Ret>{}, _Seq{}, _Mypair._Get_first(), _Mypair._Myval2, \ _STD forward_as_tuple(_STD forward<_Unbound>(_Unbargs)...)) template <class... _Unbound> _CONSTEXPR20 auto operator()(_Unbound&&... _Unbargs) noexcept(noexcept(_CALL_BINDER)) -> decltype(_CALL_BINDER) { return _CALL_BINDER; }
看到调用时会用到_CALL_BINDER宏,这里调用_Call_binder函数,并把_Mypair传入,再接下来就会调用到我们的函数并传入bound的参数了。
总结下就是std::bind首先将传入的参数存放起来,等到要调用bind的函数就将参数传入,而这里没有保存传入参数的引用,只能保存一份参数的拷贝,如果使用我们上边说的“int& a = b”语法,_Binder类中无法保存b的引用,自然调用时传入的就不是b的引用,所以借助reference_wrapper将传入参数的地址保存,使用是通过地址取出来值进而调用函数。
5. 总结
我来给总结下,首先我们讲解了std::ref的一些用法,然后我们讲解std::ref是通过std::reference_wrapper实现,然后我们借助了cppreference上的实现来给大家剖析了他本质就是存放了对象的地址(类似指针的用法),还讲解了noexcept等语法,最后我们讲解了下std::bind为什么要使用到reference_wrapper.
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。