C++ 的函数类型包括了以下几种:
- 函数指针;
- 成员函数指针;
- 上述两种函数类型的引用、
c-v-
和noexcept
修饰符的排列组合。
在 C++11 后,语言标准引入了更灵活的 lambda 函数;因此在函数类型中又新增了 lambda 类型和一堆修饰符的排列组合。
实际上,C++ 中 lambda 函数是一个匿名类的类对象;这个匿名类由编译器生成,并且重载了
operator()()
运算符。所以任何一个重载了operator()()
的类对象都可以被视作是广义上的 lambda。
如果 lambda 函数带有捕获(包括引用和值捕获),那么这个生成的匿名类就会持有对应值类型的数据成员;否则匿名类是一个大小为 1 的空类(标准要求不含数据成员的类大小必须为 1)。
并且,不带有捕获的 lambda 函数可以被隐式类型转换为一个函数指针;这一行为可以通过在 lambda 表达式的捕获列表(方括号)左侧添加一个一元运算符+
显式触发。
最重要的是,任意两个 lambda 表达式的实际类型永远不可能相同(见 wiki),即使它们的函数签名和捕获列表完全一致。
这些类型的排列组合背后是沉重的历史包袱,以至于函数类型被称作是 Abominable Function Types(糟糕的函数类型)。
这里的历史包袱说的就是 C 语言中的函数类型(呕。
在与一些需要传递自定义函数的场景下,我们往往会传递一个 lambda 或指向某个函数的指针;这种用法在与一些提供了 Modern Cpp 封装的接口交互时会显得很优雅和简洁。
这些接口往往都被编写为模板函数,使得它能够接收任意可调用类型;而在无法模板化的接口中,则使用 std::function
作为可调用类型的存储容器。
但对于一些没有提供 Modern Cpp 支持,或者干脆就是由 C 语言编写的接口(例如 Linux 的系统调用)来说,我们在传递这类可调用对象时能选择的类型就只剩下函数指针了。
函数指针很万能,但有一点致命缺陷:它只能指向一个已存在的全局函数,并且带有捕获列表的 lambda 函数无法经类型转换变成函数指针。所以如果我想传递一个指针,那么我必须在全局作用域创建一个新函数。
而且麻烦还不止于此:如果我需要的功能被封装在一个类方法中,那么我还必须想办法提供一个能在全局范围内访问到这个局部对象的接口,否则创建的全局函数什么都干不了。
因为函数指针中包含了函数的参数类型,所以这个局部对象是不能经由函数参数列表传入的;不然就会破坏参数列表。
到了这里已经可以预见一个软件工程灾难了:为了一个简单的函数指针,就需要创建一个全局函数,还得想办法将局部对象的访问权暴露给全局。
所以这里我们需要一个简洁的解决方案。
1.将局部对象的作用域提升到全局
lambda 函数与常规函数一样,能够在函数体内未经捕获地访问全局对象,前提是在定义 lambda 时就已经看到了这些对象。
这里的全局对象包括:
- 任何被
static
修饰的变量; - 在全局作用域内声明/定义的变量(包括
extern
引入)。
而一个不带捕获的 lambda 是可以被隐式转换为函数指针的。因此我们可以将捕获列表内的局部对象提升为 static
,然后使用 lambda 包装一下。
int main()
{static std::vector<int> arr;auto fptr = +[]( int a ) { arr.push_back( a ); };foo( fptr ); // 传递函数指针
}
这很蠢,而且局部对象被提升为 static
毫无意义,甚至会破坏原有的 RAII 语义;更不用说部分情况下全局化一个局部对象的行为可能比获得一个函数指针更困难。
2.利用全局函数
我们可以反过来做:把一个带捕获的 lambda 表达式存放到一个函数内部作用域的 static
变量中,然后透过一个能够访问这个 static
变量的函数访问。
注意:因为在标准定义中,lambda 函数的赋值运算符全部都被标记为弃置,所以我们只能通过声明语句,从一个已存在的 lambda 对象上再构造一个 lambda 对象。
这时我们需要引入一点模板元编程技巧。
template<typename Lambda>
typename std::enable_if<std::is_class<FnTp>::value && !std::is_empty<Lambda>::value>::typemake_fnptr( Lambda&& fn )
{static typename std::remove_reference<Lambda>::type fntor = std::forward<Lambda>( fn );
}
我们可以在内部返回一个不带捕获、且转为了函数指针的 lambda 函数;但我们很快就会发现:我们不知道返回的函数指针类型是什么,也就是说这个函数的返回值是未决的;这会直接触发编译失败。
我们无法推断返回类型的原因有很多:
- 我们不知道这个 Lambda 的返回值和参数列表是什么,所以写不出能够描述指向二次包装后 lambda 的指针类型;
- 函数参数列表需要在调用该函数时传入,但是可变模板参数列表会与函数参数中的模板参数产生语义冲突;
- 即使我们获取了被包装的类型参数列表,我们也无法在函数体内就地展开并填充到二次包装的 lambda 中(这里需要使用模板类的模式匹配)。
第一条原因还能借由引入复杂的类型萃取器解决,第二条也可以引入额外的类型包装解决,但第三条原因直接宣判了这个方案的死刑。
3.引入一个包装器类
因为类的静态函数天然就可以被转换为函数指针,同时这个静态函数的可见范围是全局,所以我们不妨引入一个模板包装类,将返回的函数指针指向这个类的静态函数。
为了支持带有参数列表的 lambda,这个静态函数还需要被模板化。
模板函数在被实例化后(填入函数参数列表)就可以获取地址了,这里没有问题。
此外 C++ 还有一个比较有意思的语法点:返回类型都是 void
的函数,如下形式的调用是没问题的。
#include <iostream>void foo() { std::cout << "HelloWorld"; }
void func()
{return foo();
}int main()
{func();
}
综上所述,略去一些设计过程不表,我们就可以得到这样一个实现。
template<typename Fn>
struct LambdaWrapper {
private:static_assert( std::is_class<Fn>::value, "Only available to lambda" );static_assert( !std::is_empty<Fn>::value, "Only available to lambda with capture" );static const Fn* fntor;template<typename... Args>static typename std::result_of<Fn( Args... )>::type invoking( Args... args )//static std::invoke_result_t<Fn, Args...> invoking( Args... args ){return ( *fntor )( std::forward<Args>( args )... );}public:template<typename... Args>decltype( &invoking<Args...> ) to_fnptr() noexcept{return &invoking<Args...>;}template<typename _FnTp, typename FnTp>friend typename std::enable_if<std::is_class<FnTp>::value && !std::is_empty<FnTp>::value,LambdaWrapper<FnTp>>::typemake_fnptr( _FnTp&& fn ) noexcept;
};
template<typename Fn>
const Fn* LambdaWrapper<Fn>::fntor = nullptr;template<typename _FnTp, typename FnTp = typename std::remove_reference<_FnTp>::type>
typename std::enable_if<std::is_class<FnTp>::value && !std::is_empty<FnTp>::value,LambdaWrapper<FnTp>>::typemake_fnptr( _FnTp&& fn ) noexcept
{static FnTp fntor = std::forward<FnTp>( fn );if ( LambdaWrapper<FnTp>::fntor == nullptr )LambdaWrapper<FnTp>::fntor = std::addressof( fntor );return LambdaWrapper<FnTp>();
}
需要注意的是:函数 invoking
需要获取函数的返回值,而标准库函数 std::result_of
于 C++17 被标记为弃置,并在 C++20 中移除。故对于 C++17 后应当使用 std::invoke_result_t
;在 C++17 前使用时请自行切换到被注释掉的行。
方案本身适用于 C++11 及之后的标准。
这个方案有个优点:由于标准中保证了每个 lambda 的类型唯一,所以对于所有相同的 lambda 函数对象,每次调用 make_fnptr
时所指向的内部变量都是相同的。
而且我们还利用了类的访问控制权限,保证了只有 lambda 的创建者才能访问到指向对应的 lambda 对象的函数指针。
但我不保证带引用捕获的 lambda 内部的引用生命周期问题。且这个方案会引入轻微的内存开销。
最后我们就可以这样使用。
#include <bits/stdc++.h>int main()
{int x = 10;// 右值类型的 lambda 没问题auto wrapper = make_fnptr( [&x]( int i ) { return x += i; } );auto lambda = [&x]( int i ) { return x += i; };// 左值类型也没有问题auto wrapper2 = make_fnptr( lambda );// 但是不支持不带捕获的 lambda 函数// 这类函数可以直接转换为函数指针,所以类型转换是没必要的// auto wrapper3 = make_fnptr( []() { std::cout << "Hello World!"; } );// 调用 to_fnptr 方法、并提供函数的参数列表就能获得函数指针auto ptr = wrapper.to_fnptr<int>();auto ptr2 = wrapper2.to_fnptr<int>();
}
函数的类型参数列表完全由 to_fnptr
的模板参数指定,你给什么它就返回什么。
但是与实际函数的参数列表不匹配的类型参数会导致编译错误。
int main()
{int a = 2, b = 3;double result = 4;auto wrapper = make_fnptr( [&result]( int& a, int& b ) {std::swap( a, b );return ( a + b ) * result;} ); // 参数列表是引用就传入引用auto ptr = wrapper.to_fnptr<int&, int&>();std::cout << "a = " << a << std::endl << "b = " << b << std::endl;std::cout << "Return = " << ptr( a, b ) << std::endl;std::cout << "a = " << a << std::endl << "b = " << b << std::endl;
}
测试程序见此。
为什么这个包装器只支持带捕获的 lambda 函数对象?
因为其他函数类型都能轻松转换为函数指针。
4.添加一个类型容器
更进一步的,我们可以引入一个用于存储类型的模板;这个模板能够将一组类型在不同模板列表间相互传递。
template<typename...>
struct TypeList {};// 可以这样用
using ArgList = TypeList<int, double, char*>;
虽然这里使用
std::tuple
也可以,但是我们只需要找个地方存放类型,所以最简单的就好。
然后我们直接要求使用者在创建一个 lambda 包装器时,必须显式给出对应 lambda 的函数参数列表;也就是说在 make_fnptr
的模板参数列表额外添加一个类型参数。
过程略,总之我们可以得到如下的实现。
template<typename...>
struct TypeList {};template<typename, typename>
class LambdaWrapper;
template<typename Fn, template<typename...> class List, typename... Args>
class LambdaWrapper<Fn, List<Args...>> {static_assert( std::is_class<Fn>::value, "Only available to lambda" );static_assert( !std::is_empty<Fn>::value, "Only available to lambda with capture" );static_assert( std::is_same<List<Args...>, TypeList<Args...>>::value, "Only accepts TypeList types" );static const Fn* fntor;static typename std::result_of<Fn( Args... )>::type invoking( Args... args )//static std::invoke_result_t<Fn, Args...> invoking( Args... args ){return ( *fntor )( std::forward<Args>( args )... );}template<typename ParamList, typename FnTp>friend typename std::enable_if<std::is_class<typename std::remove_reference<FnTp>::type>::value&& !std::is_empty<typename std::remove_reference<FnTp>::type>::value,decltype( &LambdaWrapper<typename std::remove_reference<FnTp>::type, ParamList>::invoking )>::typemake_fnptr( FnTp&& fn ) noexcept;
};
template<typename Fn, template<typename...> class List, typename... Args>
const Fn* LambdaWrapper<Fn, List<Args...>>::fntor = nullptr;template<typename ParamList = TypeList<>, typename FnTp>
typename std::enable_if<std::is_class<typename std::remove_reference<FnTp>::type>::value&& !std::is_empty<typename std::remove_reference<FnTp>::type>::value,decltype( &LambdaWrapper<typename std::remove_reference<FnTp>::type, ParamList>::invoking )>::typemake_fnptr( FnTp&& fn ) noexcept
{using LambdaType = typename std::remove_reference<FnTp>::type;using WrapperType = LambdaWrapper<LambdaType, ParamList>;static LambdaType fntor = std::forward<FnTp>( fn );if ( WrapperType::fntor == nullptr )WrapperType::fntor = std::addressof( fntor );return &WrapperType::invoking;
}
现在包装器类就成为了一个纯粹的内部实现,不会暴露任何方法。并且每次调用 make_fnptr
都只会立即产出一个函数指针,而不再会出现中间类对象。
int main()
{int x = 10;std::cout << "x = " << x << std::endl;auto wrapper = make_fnptr<TypeList<int>>( [&x]( int i ) { return x += i; } );auto lambda = [&x]( int i ) { return x += i; };auto wrapper2 = make_fnptr<TypeList<int>>( lambda );// auto wrapper3 = make_fnptr( []() { std::cout << "Hello World!"; } );std::cout << "x = " << x << std::endl;std::cout << "x = " << x << std::endl;std::cout << "delta = " << wrapper( 5 ) << std::endl;std::cout << "x = " << x << std::endl;std::cout << "delta = " << ( *wrapper2 )( 5 ) << std::endl;std::cout << "x = " << x << std::endl;
}
如果你觉得引入新的类型容器很突兀且不太雅观,那么也可以使用 std::tuple
,效果是一样的。
测试程序见此。
如果你愿意为 C++ 的所有函数类型写出一个类型萃取器的话,那么 make_fnptr
的函数参数列表需求其实也可以去除。
5.线程安全改造
在上面的代码中我们可以注意到:由于我们需要使用局部 static
变量的地址初始化另一个 static
变量,所以有且仅有这个初始化过程是线程不安全的。
对于函数内
static
变量而言,它的初始化构造在标准中被明确规定为线程安全,C++11 中有一个术语专门用于描述这种构造技巧:magic static
。
很显然,类的 static
成员只应该被初始化一次,所以我们可以考虑使用标准库提供的 std::call_once
函数初始化它。也就是像这样改造 make_fnptr
的实现:
using LambdaType = typename std::remove_reference<FnTp>::type;using WrapperType = LambdaWrapper<LambdaType, ParamList, InstanceTag>;static std::once_flag seal;static LambdaType fntor = std::forward<FnTp>( fn );std::call_once( seal, []() { WrapperType::fntor = std::addressof( fntor ); } );return &WrapperType::invoking;
lambda 函数本身就是一个仿函数对象;但与 lambda 不同,同种类型的仿函数对象所对应的函数并不相同。例如 std::function(int())
既可以指 main
,也可以指 std::rand
。
如果希望支持包装一些除了 lambda 之外的仿函数对象,如 std::function
等,那么我们还需要为这个封装器和包装函数提供一个模板参数标识,用于区分同种类型、但不同实例的仿函数对象。
最终的代码见下。
#include <mutex>
#include <type_traits>
#include <utility>template<typename...>
struct TypeList {};template<typename, typename, std::size_t = 0>
class LambdaWrapper;
template<typename Fn, template<typename...> class List, typename... Args, std::size_t Tag>
class LambdaWrapper<Fn, List<Args...>, Tag> {static_assert( std::is_class<Fn>::value, "Only available to lambda" );// 为了支持任意仿函数对象,这里不再约束类的大小static_assert( std::is_same<List<Args...>, TypeList<Args...>>::value,"Only accepts TypeList types" );static const Fn* fntor;public:static typename std::result_of<Fn( Args... )>::type invoking( Args... args )// static std::invoke_result_t<Fn, Args...> invoking( Args... args ){return ( *fntor )( std::forward<Args>( args )... );}template<typename ParamList, std::size_t InstanceTag, typename FnTp>friendtypename std::enable_if<std::is_class<typename std::remove_reference<FnTp>::type>::value,decltype( &LambdaWrapper<typename std::remove_reference<FnTp>::type,ParamList,InstanceTag>::invoking )>::typemake_fnptr( FnTp&& fn ) noexcept;
};
template<typename Fn, template<typename...> class List, typename... Args, std::size_t Tag>
const Fn* LambdaWrapper<Fn, List<Args...>, Tag>::fntor = nullptr;template<typename ParamList = TypeList<>, std::size_t InstanceTag = 0, typename FnTp>
typename std::enable_if<std::is_class<typename std::remove_reference<FnTp>::type>::value,decltype( &LambdaWrapper<typename std::remove_reference<FnTp>::type,ParamList,InstanceTag>::invoking )>::typemake_fnptr( FnTp&& fn ) noexcept
{using LambdaType = typename std::remove_reference<FnTp>::type;using WrapperType = LambdaWrapper<LambdaType, ParamList, InstanceTag>;static std::once_flag seal;static LambdaType fntor = std::forward<FnTp>( fn );std::call_once( seal, []() { WrapperType::fntor = std::addressof( fntor ); } );return &WrapperType::invoking;
}
对于 lambda 和仿函数对象,我们可以有如下使用方法。
#include <iostream>struct Functor {int operator()( int x, int y ) const noexcept { return x + y; }
};int main()
{int x = 10, y = 20;auto wrapper = make_fnptr<TypeList<int>>( [&x]( int i ) { return x += i; } );std::cout << "x = " << x << std::endl;std::cout << "delta = " << wrapper( 5 ) << std::endl;std::cout << "x = " << x << std::endl;Functor fntor1, fntor2;auto wrapper2 = make_fnptr<TypeList<int, int>, 0>( fntor1 );auto wrapper3 = make_fnptr<TypeList<int, int>, 1>( fntor2 );std::cout << "sum = " << wrapper2( x, y ) << std::endl;std::cout << "sum = " << wrapper3( 114, 514 ) << std::endl;
}
注意 make_fnptr
函数中构造 funtor
的方式,如果传递一个左值形式的不可拷贝仿函数对象,会导致编译错误;右值形式的不可移动对象同理。
代码不对仿函数对象的私有引用提供任何生命周期保证。
测试程序见此。
6.Reference
感谢 bilibili@Ayano_Aishi 在视频 BV1Hm421j7qc 下方的评论区中提供了本文代码的原始版本以及本文的灵感来源。