特征
使用特征#
特征是一种机制,用于提供模板所需的必要信息,比如模板中变量的类型,初始值等等。简单的理解,特征是有关模板参数的额外属性,具体指的可以是与该模板相关的其他模板类中的字段或是常量等。在编译阶段就可以从这个额外属性中获取到一些模板实例中会用到的信息。
template<typename T>
struct AccumTrait {
using Type = T;
inline static const Type zero = T{};
};
template<>
struct AccumTrait<int> {
using Type = long long;
static const Type zero = 0;
};
template<>
struct AccumTrait<unsigned> {
using Type = unsigned long long;
static const Type zero = 0;
};
template<>
struct AccumTrait<char> {
using Type = int;
static const Type zero = 0;
};
template<typename T>
auto accum(T* begin, T* end) {
using Type = typename AccumTrait<T>::Type;
Type total = AccumTrait<T>::zero;
while(begin != end) {
total += *begin;
++begin;
}
return total;
}
int main() {
int num[] = { 1,2,3,4,5 };
char str[] = { 'a','b','c','d','e' };
cout << accum(num, num + 5) << endl;
cout << accum(str, str + 5) << endl;
return 0;
}
求和需要一个保存和的变量和起始值,于是我们使用AccumTrait来根据不同的模板参数设置变量类型和起始值。这里使用到的AccumTrait就可以称作特征类,包含了求和模板accum所需的类型特征和初始化值特征。
值特征存在一个问题,C++的模板中只可以使用整型或枚举类型来初始化静态数据成员变量,就算使用constexpr也只允许浮点数或是字面量。如果是自定义的类型,在模板中编译阶段是无法使用的。
退而求其次的办法是在这里只声明,而在类外部再提供实现,或是在C++17以后使用内联变量,而在早期更普遍的做法是使用静态函数来实现这一操作。
通过特征类,可以更灵活地为不同的模板参数设置对应的信息,以char类型求和举例,如果没有这个特征类,而是使用模板参数char作为返回类型的话,求和结果就和我们预想的不同(char只能表示-128到127),而相比再为char特化一个模板,使用特征往往是更便捷和灵活的。
另外,上述这种在模板中直接使用的特征被称作固定特征,相对的,可以把特征也放到模板参数中,并给一个默认值,这又进一步扩展了模板的灵活性,允许在必要的时候使用自定义的特征。
template<typename T, typename Trait = AccumTrait<T>>
auto accum(T* begin, T* end) {
typename Trait::Type total = Trait::zero;
while(begin != end) {
total += *begin;
++begin;
}
return total;
}
这种放在模板参数中作为额外参数的特征,又被称作参数化特征。
policy#
policy是特征的一个实例,或者说特征只用于实现policy。特征更注重类型,而policy更注重行为。
特征类是一种用来代替模板参数的类,作为一个类,它可以是有用的类型,也可以是常量;作为一个模板,它提供了一条实现“额外层次间接性”的途径。
- 特征表示模板参数的一些额外自然属性。
- policy表示泛型函数和泛型类的一些可配置行为。
特征往往具有以下的特点:
- 特征可以是固定特征。
- 特征参数通常会有一个非常自然的默认值。
- 特征参数通常与一个或多个主参数密切相关。
- 特征大多是对类和常量进行组合,而不是成员函数。
- 特征通常是使用特征模板实现。
policy往往具有以下特点:
- 如果policy类不作为模板参数传递,那么这个类就毫无作用。
- policy参数并不需要默认值,并且通常是显式指定的。
- policy的参数通常与一个模板中的其他参数是正交关系。
- policy一般都包含成员函数。
- policy不局限于类模板,也可以通过普通类实现。
总的来说,两者之间并没有那么明显的界限。我们总是倾向于通过特征和policy的组合使用,使实际的函数变成类似接口的形式,具体的逻辑处理交给特征或policy。在模板参数空间中,特征参数往往放在policy参数后面,因为特征参数总是更容易被改变的部分。
template<typename T1, typename T2>
class SumPolicy {
public:
static void accumulate(T1& total, const T2& value) {
total += value;
}
};
template<typename T,
template<typename, typename> class Policy = SumPolicy,
typename Trait = AccumTrait<T>>
auto accum(T * begin, T * end) {
typename Trait::Type total = Trait::zero;
while(begin != end) {
Policy<typename Trait::Type, T>::accumulate(total, *begin);
++begin;
}
return total;
}
例如上述的累加函数,再添加一个SumPolicy的policy类,然后具体的累加过程放到SumPolicy类中的静态成员函数accumulate中执行。
类型函数#
传统的C/C++函数可以称为值函数:通过输入一个或多个值,返回一个值。而通过模板,可以定义一种类型函数:将某些类型作为实参,来生成一个类型或常量。
template<typename T>
struct ElementT;
template<typename T>
struct ElementT<std::vector<T>> {
using Type = T;
};
template<typename T>
struct ElementT<std::list<T>> {
using Type = T;
};
template<typename T, std::size_t N>
struct ElementT<T[N]> {
using Type = T;
};
template<typename T>
struct ElementT<T[]> {
using Type = T;
};
template<typename T>
void printType(const T& value) {
cout << typeid(typename ElementT<T>::Type).name() << endl;
}
通过ElementT类,可以获取到元素的类型。如果使用的是标准库中的容器,因为每个容器内部都定义了元素的类型,也可以通过下面的方式获取:
template<typename C>
struct ElementT {
using Type = typename C::value_type;
};
既然可以获取到类型,那么也可以根据获取到的类型来做转换,比如去除cv限定符,添加或删除引用等等。
template<typename T>
struct RemoveRef {
using Type = T;
};
template<typename T>
struct RemoveRef<T&> {
using Type = T;
};
template<typename T>
struct RemoveRef<T&&> {
using Type = T;
};
谓词特征#
这里将会是是模板元编程的起点和基石。正如前面所说,类型函数可以根据输入的类型返回类型或常量,那么我们就可以通过判断类型是否与预期相符,来返回一个布尔常量true或false。这一特性使得模板在编译期具有了分支选择的能力。
例如简单编写一个判断类型是否相等的方法:
template<typename T1, typename T2>
struct IsSame {
static constexpr bool value = false;
};
template<typename T>
struct IsSame<T, T> {
static constexpr bool value = true;
};
可以使用这个类来判断输入是否为int类型
if(IsSame<T, int>::value) {
// ...
}
这个功能与标准库中的std::is_same
类似,但标准库中的实现会更抽象和通用,类似于如下形式:
template<bool val>
struct BoolConstant {
using Type = BoolConstant<val>;
static constexpr bool value = val;
};
using TrueType = BoolConstant<true>;
using FalseType = BoolConstant<false>;
template<typename T1, typename T2>
struct IsSame : public FalseType {
};
template<typename T>
struct IsSame<T, T> : public TrueType {
};
考虑到通用性,应该只有一个true类型和false类型,而不要让泛型库都自己去定义属于自己的常量,好在标准库中提供了std::true_type
和std::false_type
。
结果类型特征#
在编写模板时,可能会遇到下面这种情况:
template<typename T1, typename T2>
Array<???> operator+(const Array<T1>&, const Array<T2>&);
我们希望可以根据模板参数的不同来自动填充函数的返回类型,除了使用标准库中提供的common_type
外,还可以自己编写一个模板,这个模板就叫做结果类型特征。
template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(declval<T1>() + declval<T2>());
};
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;
template<typename T1, typename T2>
vector<PlusResult<T1, T2>> add(const vector<T1>& c1, const vector<T2>& c2);
SFINAE(substitution failure is not an error)#
SFINAE,翻译为替换失败不是错误,这是实现模板编程的核心机制之一,意思是在模板实例化过程中发生了诸如类型不匹配等错误时,并不会报错,而是忽略这个模板候选,转而去寻找其他可能的模板,只要存在模板可以成功实例化即可(这也是模板编程可以轻而易举的编译报错成百上千行的主要原因,往往是因为编译器尝试了所有可能后仍未找到可行的模板)。
template<typename T>
struct IsDefaultConstructibleT {
private:
template<typename U, typename = decltype(U())>
static char test(void*);
template<typename>
static long test(...);
public:
static constexpr bool value = std::is_same<decltype(test<T>(nullptr)), char>::value;
};
使用SFINAE编写一个检测类是否有默认构造函数的特征类,如果存在默认构造函数,就会走上面的test函数,返回类型将会是char,否则会走下面一个兜底的test函数,返回类型是long,通过判断类型是否为char给特征类中的布尔值赋值true或false,比较这个布尔值就可以判断对应传入的类是否有默认构造。
需要说明一点,第一个test函数不可以直接使用类的模板参数,像这样
template<typename, typename = decltype(T())> static char test(void*);
,因为类在构造时,会将所有的成员函数都实现,而直接使用类的模板参数,会导致因为没有默认构造而直接编译报错。如果再使用另外的模板参数U就不会有这个问题,因为与类的模板参数T无关,所以会推迟到被使用时再进行实例化。
这不是实现这个功能的唯一方法,还可以使用SFINAE结合特征或者偏特化来实现。
书中给出了一个非常牛逼的,使用泛型lambda的实现,这里摘录下来:
template<typename F, typename... Args,
typename = decltype(declval<F>()(declval<Args&&>()...))>
std::true_type isValidImpl(void*);
template<typename F, typename... Args>
std::false_type isValidImpl(...);
inline constexpr auto isValid = [](auto f) {
return [](auto&&... args) {
return decltype(isValidImpl<decltype(f), decltype(args)&&...>(nullptr)){};
};
};
template<typename T>
struct TypeT {
using Type = T;
};
template<typename T>
constexpr auto type = TypeT<T>{};
template<typename T>
T valueT(TypeT<T>);
constexpr auto isDefaultConstructable =
isValid([](auto x) -> decltype((void)decltype(valueT(x))()) { });
从isValid
讲起,它是一个泛型lambda函数对象。这里有两层lambda表达式,就记作外层lambda和内层lambda吧。外层lambda就是isValid
本身,接受一个参数f作为入参,然后返回内层lambda函数对象。内层lambda接受可变参数作为入参,然后返回辅助函数isValidImp
的执行结果,即true_type或者false_type,这取决于isValidImp
的模板参数推导结果。
isValidImp
还是好理解的,和前面谈论的SFINAE一样,关键在于用于判断的第三个位置的模板参数typename = decltype(declval<F>()(declval<Args&&>()...))
,它会调用第一个模板参数和第二个位置的模板参数包,然后调用F的()重载运算符,并将参数包作为F的入参,这意味着F是一个重载了()运算符的对象,比如lambda表达式,同时参数包中的参数都应该是可以被默认构造的(因为是使用declval<Args&&>()...
来进行推导的)。
如果可以通过推导,就返回true_type,否则返回false_type。
实际上,isValid
实现的是一个闭包函数,其中传入的状态就是参数f,然后内层lambda对这个f进行推导,判断f是否可以成功调用。
再看isDefaultConstructable
,它传入了[](auto x) -> decltype((void)decltype(valueT(x))()) { }
作为isValid
的参数,然后接收isValid
的结果。isValid
的返回是内层lambda函数对象,所以isDefaultConstructable
可以看作是内层lambda某种程度上的一个特化,其中auto x
就代替了可变参数的位置,传入isValid
的lambda表达式就是参数f,后面的尾置返回类型-> decltype((void)decltype(valueT(x))())
指示isValidImp
中的typename = decltype(declval<F>()(declval<Args&&>()...))
如何推导,模板函数valueT
会推导x的类型,并返回一个x的默认构造值,那么尾置返回类型结合isValidImp
的typename = decltype(declval<F>()(declval<Args&&>()...))
模板参数意思就是f调用x的默认构造作为入参,返回x默认构造值并强转成void,也就是返回值是void,否则的话推导失败,根据SFINAE回退到isValidImp
返回false_type。
整体的替换效果就像这样:
constexpr auto isDefaultConstructable = [](auto&&... args) {
return
decltype(isValidImp<
decltype([](auto x) -> decltype((void)decltype(valueT(x))())),
decltype(args)&&...>(nullptr)) {};
}
那么,isDefaultConstructable
就可以使用如下:
isDefaultConstructable(type<int>); // true
isDefaultConstructable(type<int&>); // false
编写SFINAE友好的特征#
先假设一个简单的场景,比如计算两数之和,并且使用模板来判断返回的类型。下面是一个不使用SFINAE,或者说对SFINAE不友好的写法:
template<typename T1, typename T2>
struct PlusT {
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
using Plus = typename PlusT<T1, T2>::Type;
template<typename T1, typename T2>
Plus<T1, T2> operator+(T1, T2);
template<typename T>
class Matrix {
T m_num{};
};
void add(Matrix<int> a, Matrix<double> b) {
auto sum = a + b;
}
我们在全局中重载了一个加法运算符,但并没有为Matrix这个类重载+运算符,显然add这个函数是不会通过编译的。但很可惜,在编写时并没有错误提示,如果模板分散在多个文件中,这个错误可能只有等到编译时才会被暴露出来,那个时候甚至已经有了其他文件在依赖这个文件了。
所以,我们可以使用SFINAE来推导模板类型,并且使用SFINAE推导模板类型基本上都可以被现在的智能提示检测到,这样我们在写出add
函数时,就会被提示报错了。
template<typename T1, typename T2, typename = std::void_t<>>
struct HasPlusT : public std::false_type { };
template<typename T1, typename T2>
struct HasPlusT<T1, T2, std::void_t<decltype(std::declval<T1>() + std::declval<T2>())>> : public std::true_type { };
template<typename T1, typename T2, bool = HasPlusT<T1, T2>::value>
struct PlusT {
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
struct PlusT<T1, T2, false> { };
template<typename T1, typename T2>
using Plus = typename PlusT<T1, T2>::Type;
template<typename T1, typename T2>
Plus<T1, T2> operator+(T1, T2);
template<typename T>
class Matrix {
T m_num{};
};
void add(Matrix<int> a, Matrix<double> b) {
auto sum = a + b;
}
从PlusT讲起,我们希望如果存在重载运算符的话,就返回推导出的相加类型,而如果没有的话就报错。于是我们给PlusT添加了一个值特征,这个值特征如果计算出是false的话,因为还有一个PlusT的偏特化,所以会优先选择偏特化,也就是不存在相加的类型。换句话说,只有HasPlusT
这个特征结果是true_type时才会返回相加类型。
那再来看一下这个特征是如何实现的:首先其继承了std的两个布尔类型,HasPlusT
也有偏特化,所以编译时会优先选择偏特化的版本,如果推导不出来,再根据SFINAE退回到低优先级的普通模板类,也就是会先进入第二个HasPlusT
结构,然后推导std::void_t
包裹的模板类型,如果成功推导出来,HasPlusT
就会继承true_type,推导不出来,就退回到第一个继承false_type。PlusT
根据HasPlusT
的继承是true_type还是false_type,就会表现出是否有Type。
而在auto sum = a + b;
部分,就会推导加号运算符是否有合适的重载,也就是按照上述的逻辑推导,发现Matrix并没有重载加号运算符,于是干脆利落地报错,这时我们就知道该给Matrix添加一个重载了,或者放弃当前的用法。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 零经验选手,Compose 一天开发一款小游戏!
· 通过 API 将Deepseek响应流式内容输出到前端
· AI Agent开发,如何调用三方的API Function,是通过提示词来发起调用的吗