案例
这次我们还是通过对Drogon的实现分析,一起来窥探与学习一下C++模板特性。
Drogon文档中介绍『基于template实现了简单的反射机制,使主程序框架、控制器(controller)和视图(view)完全解耦』。先看一下官方文档中的样例代码:
class User : public drogon::HttpController<User>
{
public:
METHOD_LIST_BEGIN
//use METHOD_ADD to add your custom processing function here;
METHOD_ADD(User::getInfo, "/{id}", Get); //path is /api/v1/User/{arg1}
METHOD_ADD(User::getDetailInfo, "/{id}/detailinfo", Get); //path is /api/v1/User/{arg1}/detailinfo
METHOD_ADD(User::newUser, "/{name}", Post); //path is /api/v1/User/{arg1}
METHOD_LIST_END
//your declaration of processing function maybe like this:
void getInfo(const HttpRequestPtr &req, std::function<void(const HttpResponsePtr &)> &&callback, int userId) const;
void getDetailInfo(const HttpRequestPtr &req, std::function<void(const HttpResponsePtr &)> &&callback, int userId) const;
void newUser(const HttpRequestPtr &req, std::function<void(const HttpResponsePtr &)> &&callback, std::string &&userName);
public:
上述代码需要解决的问题:注册的METHOD_ADD(User::getInfo, "/{id}", Get);
,对应的请求消息怎么路由到getInfo
方法?
- 无反射机制的做法是,通过保存处理的函数指针,接收到请求再回调函数,函数签名只能是固定格式
- 但URL Pattern中会存在多个
{}
替换参数,参数的类型可能是string, int, long等类型,是无固定参数
像Java等语言由于有底层Runtime框架(JVM),实现了运行期的反射机制。借助反射把请求动态路由到对应的处理函数,代码实现上不会太难。但C++是没有Runtime,只能是借助于模板在编译期做一些事情,来达到像Java一样的反射机制。
注册
METHOD_ADD
是一个宏,它定义在HttpController.h
文件中,代码如下:
#define METHOD_LIST_BEGIN \
static void initPathRouting() \
{
#define METHOD_ADD(method, pattern, ...) \
registerMethod(&method, pattern, {__VA_ARGS__}, true, #method)
#define ADD_METHOD_TO(method, path_pattern, ...) \
registerMethod(&method, path_pattern, {__VA_ARGS__}, false, #method)
#define ADD_METHOD_VIA_REGEX(method, regex, ...) \
registerMethodViaRegex(&method, regex, {__VA_ARGS__}, #method)
#define METHOD_LIST_END \
return; \
}
业务代码增加METHOD_ADD
,就是在静态方法initPathRouting
中增加registerMethod
方法调用,从而完成自动注册功能。而initPathRouting
是每个Controller
内部的static class methodRegistrator
类的构造方法中调用,能先于main方法执行完成注册管理。
整个注册与消息路由实现比较复杂,我们先不展开来讲,主要实现逻辑:
- 注册的函数指针,最后会转换为
HttpBinder
的智能指针存储 HttpBinder
是一个类模板,提供handleHttpRequest
入口方法来接收请求消息handleHttpRequest
的实现逻辑是借助于模板在编译期间的匹配检查,转换对应的参数值,传递到具体的处理函数
分发
handleHttpRequest
方法要能正确调用具体的目标处理函数,需要解决的问题:
- 知道目标函数签名:参数个数,参数的类型,是否值传递,还是引用类型(T,T&, const T&, T&&)
- 知道目标函数类型:是类的成员函数,是静态函数,还是lambda表达式,函数所在类是否继承DrObject,可做内存管理等等
萃取函数信息
基于模板的特化机制,定义了函数信息的萃取器FunctionTraits
,它用于获取上述需要知道的函数信息,其基类如下:
template <typename ReturnType, typename... Arguments>
struct FunctionTraits<ReturnType (*)(Arguments...)>
{
using result_type = ReturnType; // 函数返回值类型
template <std::size_t Index>
using argument = typename std::tuple_element<Index, std::tuple<Arguments...>>::type; // 函数的参数
static const std::size_t arity = sizeof...(Arguments); // 函数的参数个数
using class_type = void; // 函数的归属类的类型,子类会覆写
static const bool isHTTPFunction = false; // 是否是标签的http处理函数,子类会覆写
static const bool isClassFunction = false; // 是否是类的成员函数,子类会覆写
static const bool isDrObjectClass = false; // 归属类是否是继承DrObject,子类会覆写
static const std::string name() // 子类会覆写
{
return std::string("Normal or Static Function");
}
};
此类模板参数是函数指针的格式,在实例化展开时需要精确匹配具体的函数指针(如前面的getInfo,newUser)。则可以定义不同的特化子类模板(子类代码不就贴了,参见FunctionTraits
),在子类中来覆写class_type,isClassFunction,isClassFunction,isDrObjectClass的值,以达到正确地萃取函数的信息。
展开可变参数
解决获取目标函数的信息之后,我们再把视线拉回到HttpBinder
的handleHttpRequest
方法,其定义如下:
// 重载基类方法,主体逻辑由run函数模板承载
virtual void handleHttpRequest(
std::deque<std::string> &pathArguments,
const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) override
{
run(pathArguments, req, std::move(callback));
}
// 可变参数模板,参数个数小于实际个数时,用于展开参数包
template <typename... Values, std::size_t Boundary = argument_count>
typename std::enable_if<(sizeof...(Values) < Boundary), void>::type run(
std::deque<std::string> &pathArguments,
const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
Values &&... values)
{
// Call this function recursively until parameter's count equals to the
// count of target function parameters
static_assert(
BinderArgTypeTraits<nth_argument_type<sizeof...(Values)>>::isValid,
"your handler argument type must be value type or const left "
"reference type or right reference type");
using ValueType = typename std::remove_cv<typename std::remove_reference<
nth_argument_type<sizeof...(Values)>>::type>::type;
ValueType value = ValueType();
// 省略,其逻辑是pathArguments取出参数,并调用getHandlerArgumentValue转为目标参数类型的值
run(pathArguments,
req,
std::move(callback),
std::forward<Values>(values)...,
std::move(value));
}
// 可变参数模板,参数个数等于实际个数时,是终止函数
template <typename... Values, std::size_t Boundary = argument_count>
typename std::enable_if<(sizeof...(Values) == Boundary), void>::type run(
std::deque<std::string> &,
const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
Values &&... values)
{
callFunction(req, std::move(callback), std::move(values)...);
}
handleHttpRequest是覆写基础的方法,实现由run方法承载。两个方法run使用C++11的新特性可变模板参数,它可以递归函数方式展开参数包。通过递归函数展开参数包,提供一个参数包展开的函数和一个递归终止函数,递归终止函数正是用来终止递归的。第一个run方法中采用展开参数递归对每个参数的值进行转换,第二个run方法是一个终止函数。
萃取参数引用类型
在第一个run方法中,使用BinderArgTypeTraits用于对参数引用类型萃取。它也是一系列类模板,通过isValid值为判断是否是常左值引用与右值引用,目标函数的入参只支持这两种类型(const T &与 T&&)。
// 省略其它的模板参数,其它场景isValid = false
template <typename T>
struct BinderArgTypeTraits<const T &>
{
static const bool isValid = true;
};
template <typename T>
struct BinderArgTypeTraits<T &&>
{
static const bool isValid = true;
};
转换参数值
在第一个run方法中,通过如下方式获了实际的类型,先remove_reference剔除引用(&),再remove_cv剔除引限定符(const或volatile):
using ValueType = typename std::remove_cv<typename std::remove_reference<
nth_argument_type<sizeof...(Values)>>::type>::type;
拿到实际类型之后,使用ValueType()
创建一个此类型的对象,再通过重载不同getHandlerArgumentValue函数来达到对每个url参数(不同参数类型)的赋值转换:
// ValueType value = ValueType();
// getHandlerArgumentValue(value, std::move(v));
// 省略其它的方法
void getHandlerArgumentValue(std::string &value, std::string &&p)
{
value = std::move(p);
}
void getHandlerArgumentValue(int &value, std::string &&p)
{
value = std::stoi(p);
}
调用目标函数
在run方法中对url参数赋值转换后,就是调用目标函数,传递相应的参数。而目标函数调用通过函数模板callFunction特化实现不同的逻辑:
template <typename... Values,
bool isClassFunction = traits::isClassFunction,
bool isDrObjectClass = traits::isDrObjectClass,
bool isNormal = std::is_same<typename traits::first_param_type,
HttpRequestPtr>::value>
typename std::enable_if<isClassFunction && !isDrObjectClass && isNormal,
void>::type
callFunction(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
Values &&... values)
{
static auto &obj = getControllerObj<typename traits::class_type>();
(obj.*func_)(req, std::move(callback), std::move(values)...);
}
// 省略其它callFunction的特化实现
每个特化的函数模板都通过std::enable_if来判断特化的条件,主要有如下几种场景:
- 若目标函数是controller的成员函数,则会实例化一个全局的controller对象并调用它
- 若目标函数是DrClass的子类,则会从DrClassMap获取实例来调用它
- 若目标函数是普通的函数(lambda,functor),则直接调用它
- 若目标函数的第一个参数HttpRequestPtr不是传引用,则上述三种场景的调用,先要把HttpRequestPtr取值copy一份
知识点
C++并没有runtime提供自省(introspection)功能,也就无法在运行期通过反射检查与获取对象的类型或属性。通过前面的逐步分析,我们大概已清楚如何基于模板实现了简单的反射机制。整个案列代码采用模板元编程的设计思想,主要涉及到知识点如下。
模板特化
先搞清楚两个概念:
- 模板实例化(instantiation)指函数模板(类模板)生成具体的模板函数(模板类)的过程
- 模板特化(specialization)是指模板参数在某种特定类型下的具体实现称为模板的特化
以函数模板为例,函数模板并不是具体的函数,传参时编译器会检查实参的类型,然后产生该类型对应的函数,这一过程称为实例化,示例如下:
template<typename T>
T add(const T& left,const T& right) {
return left + right;
}
void test() {
int a = 1, b = 3;
double c = 1.1, d = 3.3;
add(a, b); // 隐式实例化,编译已生成对应具体函数
add<int>(c, d);// 显式实例化,可以类型隐式转换
add(a, c); // 不能通过编译,函数模板不会进行类型转换操作
}
模板特化又分两种:
- 全特化是指将所有的模板参数类型指定为具体类型
- 偏特化是指将部分模板参数类型指定为具体类型
模板特化与重载的区别:
- 全特化和偏特化都没有引入一个全新的模板或者模板实例,只是对原来的泛型(或者非特化)模板参数隐式声明的实例提供另一种定义
- 重载是指函数名相同,参数的数目不同或者显示地指定了模板参数。另外类模板是不能被重载。
//模板函数
template<class T1,class T2>
void test(T1 a,T2 b) {}
//模板函数的全特化
template<>
void test(char a, int b) {}
//模板函数的偏特化
template<class T1>
void test(T1 a, float b) {}
//模板函数的函数重载1
void test(char a, float b) {}
//模板函数的函数重载2
void test(char a, int b) {}
函数模板特化与重载的优先级:
- 传入类型匹配度越高调用优先级越高
- 若传入类型对普通函数重载和函数模板的全特化一样高,则优先调用普通函数重载
前面案例代码中的FunctionTraits,BinderArgTypeTraits是属于类的特化,通过类特化对类的static变赋不同值,达到获取应的信息目的。
SFINAE
SFINAE即替换失败不是错误(Substitution Failure Is Not An Error),其作用是当我们在进行模板特化的时候,会去选择那个正确的模板,避免失败。
当一个函数名称和某个函数模板名称匹配时,重载决议过程大致如下:
- 根据名称找出所有适用的函数和适用的函数模板,要根据实际情况对模板形参进行替换
- 替换过程中如果发生错误,这个模板会被丢弃,但不是一个错误而终止
- 在上面两步生成的可行函数集合中,编译器会寻找一个最佳匹配,产生对该函数的调用
- 如果没有找到最佳匹配,或者找到多个匹配程度相当的函数,则编译器报错
在HttpBinder.h
中有一个CanConvertFromStringStream
内部类,用于编译期参数类型是否支持stringstream。它是典型的SFINAE的应用,代码如下:
template <typename T>
struct CanConvertFromStringStream
{
private:
using yes = std::true_type;
using no = std::false_type;
template <typename U>
static auto test(U *p, std::stringstream &&ss) -> decltype((ss >> *p), yes()); // [1] 显式模板实参
template <typename>
static no test(...); // [2] 其它
public:
static constexpr bool value = std::is_same<decltype(test<T>(nullptr, std::stringstream())),
yes>::value; // 调用test方法,尝试查找对应的函数集合,匹配到最佳的函数模板
};
// 当入参支持 >> 输入时,则把p通过stringstream传递给它
template <typename T>
typename std::enable_if<CanConvertFromStringStream<T>::value, void>::type
getHandlerArgumentValue(T &value, std::string &&p)
{
if (!p.empty())
{
std::stringstream ss(std::move(p));
ss >> value;
}
}
在替换的结果可能是毫无意义的,编译器不会报错,会忽略这个函数模板。替换(substitute)和实例化(instantiation)不一样,最终不需要被实例化的模板也要进行替换,当然只会替换直接出现在函数声明中的相关内容。所以两个重载的test函数模板可以没有函数实现体,因为他们不需要被实例化。
有时候为模板定义一个合适的表达式是比较难,SFINAE需要结合decltype使用,上述的第一个test方法并没有直接返回yes,而是:
- decltype内的参数是一个逗号表达式
- 第一个参数表示满足对应的调用,即需要支持
U
类型需要支持>>
操作 - 第二个参数表示函数返回值的类型
enable_if
enable_if
的主要作用就是当某个条件成立时,可以提供某种类型。enable_if
典型的使用场景:
- 控制类型偏特化
- 控制函数返回类型
- 校验函数模板参数类型
// 类型偏特化,根据模板参数的某些特性进行不同类型的选择
template <typename T, typename Enable=void>
struct check;
template <typename T>
struct check<T, typename std::enable_if<T::value>::type> {
static constexpr bool value = T::value;
};
// 控制函数返回类型,对于模板函数,有时希望根据不同的模板参数返回不同类型的值,进而给函数模板也赋予类型模板特化的性质
template <std::size_t k, class T, class... Ts>
typename std::enable_if<k==0, typename element_type_holder<0, T, Ts...>::type&>::type
get(tuple<T, Ts...> &t) {
return t.tail;
}
template <std::size_t k, class T, class... Ts>
typename std::enable_if<k!=0, typename element_type_holder<k, T, Ts...>::type&>::type
get(tuple<T, Ts...> &t) {
tuple<Ts...> &base = t;
return get<k-1>(base);
}
// 校验函数模板参数类型,只希望特定的类型可以实例化
template <typename T>
typename std::enable_if<std::is_integral<T>::value, bool>::type
is_odd(T t) {
return bool(t%2);
}
template <typename T, typename = typename std::enable_if<std::is_integral<T>::value>::type>
bool is_even(T t) {
return !is_odd(t);
}
使用enable_if
就是实现SFINAE最直接的方式,SFINAE机制在对一个函数调用进行模板推导时,编译器会尝试推导所有的候选函数(重载函数,模板),以确保得到一个最完美的匹配。在此过程在过程中,如果enable_if
条件不满足,则会在候选函数集合中剔除此函数。
前面的每个callFunction都会带有一个enable_if
判断,它就是解决在函数编译期间根据特定的条件来选择启用或禁用模板实例化。
可变参数模板
可变参数模板是C++11新增的特性之一,它对参数高度泛化,能表示0到任意个数、任意类型的参数。可变参数模板分为两种:
- 可变参数模板函数
- 可变参数模板类
// 可变参数模板函数
template <class... T>
void func(T... args);
// 可变参数模板类
template< class... Types >
class tuple;
可变参数模板函数
参数args前面有省略号,把带省略号的参数称为“参数包”,它里面包含了0到N个模板参数。无法直接获取参数包args中的每个参数,只能通过展开参数包的方式来获取参数包中的每个参数。
参数包展开有两种方式:
- 递归函数方式展开参数包
- 逗号表达式展开参数包
样例:
//递归终止函数
template <class T>
void print(T t) { // 终止函数也可以是void print()
cout << t << endl;
}
//第一种递归函数方式展开参数包
template <class T, class ...Args>
void print(T head, Args... rest) {
cout << "parameter " << head << endl;
print(rest...);
}
//第二种逗号表达式展开参数包,是利用初始化列表的特性,不太好理解
template <class ...Args>
void expand(Args... args) {
int arr[] = {(print(args), 0)...};
}
可变参数模板类
可变参数模板类的参数包展开的方式和可变参数模板函数的展开方式不同,可变参数模板类的参数包展开需要通过模板特化和继承方式去展开,展开方式比可变参数模板函数要复杂:
- 模板偏特化和递归方式来展开参数包
- 继承方式展开参数包
本文就展开举例了,建议参考相关的书籍。
结语
模板是一种对类型进行参数化的工具,是C++中一种常见的代码复用方式,开始是为了实现泛型编程设计。但C++的模板能力已远超过了泛型编程的范围,为C++语言提供了元编程的能力,具备了图灵完备性,可以完成计算任务。模板使C++编程变得异常灵活,在编译期能实现很多高级动态语言才有的特性,但也极其复杂。本文通过对Drogon的代码分析,不是对模板知识的完全讲解,打开一扇门,窥看到了模板应用的魔力,若对元编程的深入掌握还需要大量的学习与应用经验。