[本文大纲] 引言 模板实例化 隐式实例化 显式实例化 模板具体化 显式具体化 部分具体化 函数重载和具体化 类型推断 隐式类型转换 支持的类型转换 引用和const 通用引用、引用折叠和完美转发 通用引用和右值引用 类型萃取 typename 关联类型 类型萃取 迭代器萃取 特性萃取 - SFINAE特性 auto和decltype auto decltype 后置返回值类型 逗号运算符与类型限制 lambda函数 可变参数模板 参数包展开 函数模板:使用函数展开 函数模板:使用初始化表展开 函数模板:使用递归展开 类模板:使用继承递归展开 模板函数重载决议 重载匹配规则 指针 列表和数组 类类型 引用和const 通用引用的重载 辅助函数重载匹配 其他问题 模板与继承 模板与友元函数 支持隐式转换 函数回调 |
引言
C++作为常用于高性能开发环境的编程语言,不仅提供了对底层的精细控制方法,而且为工程开发提供了足够的特性支持,C++的模板特性就是其中重要的体现之一,它的意义体现在以下多个方面:
① 作为泛型编程的基础。C++标准库中提供的泛型容器以及其它第三方泛型库普遍依赖C++模板。泛型意味着我们在编写代码时可以忽略对象本身具体类型,而认为它可以是任意类型,在使用的时候才去决定它的类型。
如果我们基于泛型的角度去认识模板,就是通过模板可以实现一次编程适配所有类型。如果单是实现这一点那么模板并没有那么复杂,引入复杂度的可能来自以下这些内容,一是我们总是需要对特定类型做特殊处理,二是编译器本身在翻译模板的时候可能会产生的问题,比如模板参数会被推断成什么类型、多个特化的情况下如何匹配模板类型等。
② 实现代码的复用和生成。模板本质上就是编译器在编译期间为我们生成代码,我们可以简单地把它理解成一个更高级版的宏定义。
如果基于代码生成的角度去认识模板,我们可以基于模板去实现一些高级语言的特性,比如反射;或者像高级语言一样做一些易用的封装接口,比如实现一个通用消息转发类。更进一步,我们可以利用代码生成去完成一些编译期的计算。
* 阅读本文需要对左右值的概念有一定的了解,需要对模板基础概念有一定的了解
模板实例化
首先需要明确的一点是,模板是一个纯编译期的特性,它不具备运行时动态的特性,并且模板的生成仅在使用模板的时候,编译器才会根据模板定义生成对应类型的实例;这一特性导致了如下情况的发生:
① 只有实际使用到的模板参数才会被实例化。换言之,如果从未调用过某个模板参数,那么就不会生成对应的实例。这个规则同样适用于模板类的函数,也就是说模板类的函数可以是部分实例化的。
② 模板不支持运行时动态推断。一个最简单的例子是,如果我们定义一个非类型模板参数作为数组大小的模板数组,那么它只能是一个静态数组:
template <typename T, int SIZE>
class Array { T arr[SIZE];
};Array<int, 5> arr0; // ok
Array<int, n> arr1; // error
③ 模板的声明和定义必须同时写在头文件中,不然可能会产生链接错误。这是因为当我们写下模板函数定义的实现的时候,我们只是向编译器描述了模板函数应该是什么样的,但是编译器并没有真正实例化,只有当我们实际调用某个模板函数的时候,才会生成对应的实例化结果。假如我们把模板的函数定义放在cpp文件中,实例化的时候就找不到函数模板定义。
隐式实例化
当我们在讨论模板的声明和定义能否分离编译的时候,我们实际上基于这一事实,就是模板是在实际调用的时候才去实例化的,这被称为隐式实例化:
min(1.0f, 4.0f); // 生成min<float>(float, float)的实例
显式实例化
当然,我们也可以直接地指出实例化的类型,称为显式实例化。这通常应用于当我们需要频繁使用某个实例化的类型时,我们在头文件中完成显式实例化,比如:
using ObjType = Array<Object, ObjectAllocator>;
这样我们就确保了我们可以在不同文件中都去访问ObjType类,并且它是一定存在的。
显式实例化只需要像上述这样的声明,不需要额外的定义,定义是由编译器根据模板内容生成的。
假设我们仅仅需要引用有限数量的模板实例,我们也可以利用显式实例化来完成分离编译。但这通常只在少数场景上是有效的。
模板特化/具体化
模板特化/具体化(specialization),是指我们可能需要为特殊类型的实例化做一些模板的改动。其中,包含了显式具体化(全特化)和部分具体化(偏特化)两种改动方式。
显式具体化
对于一些特殊类型,模板的实现是不合适的,所以需要我们对这种情况做特殊处理。这个时候就可以使用显式具体化(explicit specialization),也被翻译为全特化。
比如,对const char*的字符串类型,我们需要做特殊处理:
template<>
bool Compare<const char*>(const char* a, const char* b)
{//...
}
或者对类模板做处理:
template<typename T1, typename T2>
class A
{T1 a;T2 b;
};template<>
class A<float, int>
{float a;int b;
};
显式具体化的特点是声明template时,尖括号的内容是空的。
部分具体化
我们还可以对部分实例做特殊的模板改动,这被称为部分具体化(partial specialization),也被翻译为偏特化。
template<typename T1>
class A<T1, int>
{T1 a;int b;
};
当我们在实现部分具体化时,我们在尖括号内仅省略我们具体化的参数,而没有具体化的参数仍然保留在尖括号内。
部分具体化只能作用于类模板,不能作用于函数模板
函数重载和具体化
具体化实际上只是告诉编译器,在生成特定类型的模板函数实例化时,需要做什么特殊操作。它有别于函数重载,因为它根本就没有生成可供选择的同名函数重载。
类型推断
当我们使用隐式实例化的时候,编译器会去推断我们匹配的类型。当我们提供了准确的类型时,类型推断一般不会出什么问题,但如果我们提供了会产生歧义的类型呢?
隐式类型转换
如果我们调用普通的函数,并提供了不匹配的类型,那么函数可能会通过隐式转换来完成类型转换,比如:
double min(double a, double b)
{return a < b ? a : b;
}float x = min(1.0, 2);
此时2作为int类型,可以隐式转换为double类型,调用成立。
但是对于模板而言,对于不同的类型,它更倾向于生成新的类型实例,而不是基于已有的类型实例进行隐式转换,比如同样对于上述例子,如果我们把min函数写成模板函数:
template<typename T>
T min(T a, T b)
{return a < b ? a : b;
}int x = min(1.0, 2.0); // min<float>
int y = min(1, 2); // min<in>
int z = min(1.0, 2); // error
当我们先调用min(1.0, 2.0)时,会生成min<double>模板实例,随后调用min(1, 2)时,会新生成min<int>的模板实例,而不是将int隐式转换为double,并调用min<double>的实例。
但如果我们一定要让min(1,2)使用min<double>的实例,我们可以使用显式实例化:
int y = min<double>(1, 2);
如果我们提供了不匹配的输入,比如min(1.0, 2),由于模板调用默认不进行隐式转换,编译器无法确认生成min<double>还是min<int>的实例,就会发生编译报错。
支持的类型转换
特别的,模板仅支持以下有限的类型转换:
const/reference
我们同样使用以上模板函数为例,假如我们混合输入const和非const的类型:
const int a = 1;
int b = 2;
int x = min(a, b); // min<int>();
以上例子是可以通过编译的,事实上,模板处理值传递时总是会忽略顶层const,比如两个参数都是const int类型,模板总是会翻译成int。
函数/数组
当我们传入函数或者数组的时候,它会退化成函数指针/数组指针,相当于发生了类型转换:
template<typename T>
void func(T param);void test(int param);int arr[2] = { 1, 2 };
func(arr); // int*func(test); // void(*)(int)
总体而言,模板推断会忽略顶层const/reference、函数和数组。但如果我们强制通过引用来访问,则不会发生类型转换:
int arr[2] = { 1, 2 };
auto& arr1 = arr; // int(&)[2]const int ci = 1;
auto& cr = ci; // const int&
以上设计是可以理解的,比如对于数组来说,如果对不同大小的数组都生成对应大小的实例化函数,那么将会出现代码膨胀。另一方面,大小不一致也会不被认为是一个类型。
引用和const
我们在考虑模板的类型推断的时候,还需要考虑到变量的修饰属性,也就是const和左右值属性。假设我们在声明模板的时候,添加了const或是&/&&的修饰符,那么将会发生什么呢?我们分以下几种情况讨论。
值类型
template<typename T>
void func1(T);
正如我们在前面所提及,对于非引用的模板参数,模板推断会忽略const和引用属性。
int i = 1;
const int ci = 1;
int& ri = i;
func1(i); // int
func1(ci); // int
func1(ri); // int
左值引用
template<typename T>
void func2(T&);
假如我们在函数参数中添加了&的左值限定,这个时候用右值作为输入就会出错。
int i = 1;
const int ci = 1;
func2(i); // int
func2(ci); // const int
func2(1); // error
在上述例子中,对于包含了const的输入,模板会翻译出const。这和模板参数不带任何左右值限定的情况完全不一样。
常量左值引用
template<typename T>
void func3(const T&);
假如我们在函数参数中添加了&的左值限定和const,这个时候理论上我们可以输入任何参数,并且由于参数本身包含了const,我们推断出的结果就不会包含const,这是基于const本身可以提升临时变量的生命周期。
int i = 1;
const int ci = 1;
func3(i); // int
func3(ci); // int
func3(1); // int
右值引用
template<typename T>
void func4(T&&);
假设我们在函数参数中添加了&&的右值,我们可以输入右值,但同时也能输入左值,只是编译器会将左值推断成左值引用类型。
int i = 1;
const int ci = 1;
func4(i); // int&
func4(ci); // const int&
func4(1); // int
这是一个非常特殊的引用,因为它和我们认识的非模板函数的右值引用完全不一样,它保留了变量的const/引用属性,比如对于数组而言,就不会退化成指针:
template<typename T>
void TestType(T&& param)
{cout << typeid(T).name() << endl;
}Test("abc"); // const char[3]
常量右值引用
template<typename T>
void func5(const T&&);
只能接受右值作为输入。
在以上情况中,通常我们使用非引用的版本。对于一些常见的作为工具函数使用的模板函数,我们其实并不关心其const/引用属性,此时它如果生成了const/引用的多个实例化,对于开发者来说是一种不必要的代码膨胀。
当我们使用了包含左值/右值的版本时,通常我们是为了处理一些和引用/const相关的逻辑,比如我们可能想要保留原有参数的const/引用属性,或者移除/添加const/引用属性,又或者是完成左右值转换等。
比如在标准库中就提供了一些辅助方法:
remove_reference // 移除引用属性
add_lvalue_reference // 添加左值引用
add_rvalue_reference // 添加右值引用
注意以上方法作用于类型而不是对象,比如:
remove_reference<int&>::type; // -> int
C++标准库中同样也封装了对应的方法,比如std::declval可以返回添加右值引用的结果,它的内部就引用了add_rvalue_reference:
template < class T >
typename std::add_rvalue_reference <T>::type declval ( ) noexcept;
通用引用、引用折叠和完美转发
在我们上面提到的所有引用输入中,有一个应用比较广泛的例子,那就是右值引用:
template<typename T>
void func(T&&);
我们在前面提到了,右值引用能够接受所有类型的参数,并且当我们输入左值的时候,会被推断成左值引用,相当于我们得到了这样的结果:
void func(int& &&);
实际上,在常规的编程规则中,& &&这样的语法是不合法的,它仅仅在右值引用输入的模板中是被允许的语法,对应的规则被称为引用折叠:
T&& & -> T&
T& && -> T&
T& & -> T&
T&& && -> T&&
对于双重引用的情况,编译器会将其折叠成一个引用,对应的翻译规则如上所示。
也就是说,当我们传入左值的时候,会被翻译成左值(&& &->&),当我们传入右值的时候,会被翻译成右值(&& && -> &&)。因此我们将其称之为“右值引用”的模板已经不那么合适了,因为它并不如字面意思那样只能接受右值的输入,实际上它和我们常说的非模板情况下的右值引用也不是一个东西。通常情况下我们将其称之为“通用引用”(universal references)。
另一方面,通用引用能够很好地保留参数的左右值/const属性,因此我们常常将其用作转发。因为它很好的保留参数的属性,我们也将之称为完美转发。
完美转发的意思是函数需要将其输入维持原状传递给另一个函数。
① 当我们把承担转发作用的函数参数改成通用引用模板(&&)后,此时我们就能够正确识别了输入的参数是左值还是右值;
② 即使我们识别了输入的参数的左右值属性,由于在函数内部,实参本身作为左值存在,如果我们在执行转发的时候直接调用实参本身,就会丢失左右值属性;
③ 为了解决上述问题,C++标准库引入了forward函数来辅助完美转发的实现,相当于我们应该这样编写转发的代码:
template<typename T>
void func(T&& param)
{otherfunc(std::forward<T>(param));
}
也就是我们需要配套使用forward和通用引用来实现完美转发。
标准库中,forward的实现如下:
// 左值引用
template<typename T>
T&& forward(typename remove_reference<T>::type& t) noexcept {return static_cast<T&&>(t);
}// 右值引用(非通用引用)
template<typename T>
T&& forward(typename remove_reference<T>::type&& t) noexcept {return static_cast<T&&>(t);
}
它实现了左值和右值两个版本,其中接受左值的版本返回左值,接受右值的版本返回右值,通过返回值的引用折叠实现。
接下来我们来看一个完美转发的应用例子,那就是std::vector中的emplace_back函数。
struct Point
{float x = 0.0f;float y = 0.0f;Point() { }Point(int _x, int _y) : x(_x), y(_y) { }
};vector<Point> v;
v.push_back(Point(1.0, 2.0));
v.emplace_back(1.0, 2.0);
常规情况下,我们会调用std::vector的push_back函数来完成新数据的添加,以上例子中,我们调用Point的构造函数、拷贝构造函数以及析构函数。即使拷贝构造函数本身可以通过移动语义优化,但我们仍然避免不了临时对象的创建。
而使用emplace_back后,我们相当于把参数转发到vector内部进行直接构造,就不需要借助于临时变量的载体,我们去参考C++ vector的源码,就会发现emplace_back内部就是遵循完美转发的语法实现的。
template <class... _Valty>
decltype(auto) emplace_back(_Valty&&... _Val) {// ..._Ty& _Result = *_Emplace_reallocate(_Mylast, _STD forward<_Valty>(_Val)...);// ...
}
由此可见,完美转发有以下两个重要的作用:
① 保留参数的引用/const属性
② 避免不必要的拷贝和临时对象
通用引用和右值引用
我们把形如template<typename> func(T&&)的引用称为通用引用,是因为它几乎可以接受所有类型的参数。但这不意味着出现了&&的形参就一定是通用引用,它也可能就是普通的只接受右值的右值引用。
比如对于一个普通函数来说,它是一个右值引用:
void func(int&& param); // 右值引用
我们再来看C++ vector库,提供了这样一个方法:
void push_back(_Ty&& _Val) {emplace_back(_STD move(_Val));
}
这里实际上也是push_back一个右值输入的函数重载。虽然这里也出现了模板,但和通用引用不一样的是,这里的模板是类模板的类型,并非函数模板的类型。我们还会发现它搭配了move来使用,而不是完美转发配套的forward,这也正是因为输入参数是右值引用而非通用引用。
我们再回顾一下forward的实现,提供了左值和右值的两个重载版本,其中右值引用输入的定义如下:
template<typename T>
T&& forward(typename remove_reference<T>::type&& t) noexcept {return static_cast<T&&>(t);
}
这里通过remove_reference巧妙地移除了类型可能存在的引用,也就规避了引用折叠,因此这个输入只能是一个右值。
类型萃取
对于模板而言,我们通常会需要获得模板的类型信息,这种在编译期间获取类型信息的技术被称为类型萃取(type traits)。
typename
在讨论类型萃取之前,我们先来简单介绍模板定义中一个非常常见的关键词,typename。实际上我们在前面已经多次见到这个关键词了,我们至少见到了它的以下两种用法:
① 作为模板类型
template<typename T> void func() { }
template<class T> void func() { }
在这个例子中,typename和class的使用并无差别。
② 描述对象是一个类型
我们在前面介绍了可以修改类型引用/const属性的一系列方法,比如remove_reference<T>::type,它会返回一个类型。
但是这个表达是存在歧义的,我们也可以认为这是在访问类的静态成员变量。为了让编译器识别到这是一个类型而不是一个变量,我们需要用typename来修饰。这也就是为什么我们在forward函数实现时,使用typename remove_reference<T>::type来表达类型。
我们通常也会使用using或typedef简化如上书写:
// (1)
template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;// (2)
typedef typename Iterator::value_type value_type;
// 新版本使用using
两者差别不是很大,using相比起来更符合C++编程风格,stl中using的使用更为广泛。
当我们将别名应用到模板参数中时,我们像这样使用:
template<typename = XXX>
关联类型
对于模板而言,我们已知template<typename T>,那么直接使用T就能获取对应的类型,那么类型萃取听起来并不是用于获取本身的类型的,它的应用场景更多的出现在获取与当前类型”相关联“的类型。
在前文中我们介绍了可以修改左右值属性的方法,并且我们提到了这些方法是作用于类型的。比如,我们多次提到的remove_reference就是类型萃取的一个例子,就像我们在forward函数的定义中一样,我们通过调用remove_reference<T>::type获得类型T移除引用后的类型。
通过上述例子,我们了解到了类型萃取的一个应用,那么就是获取当前类型的关联类型,也就是我们已知类型T,通过remove_reference<T>::type得到了T移除引用后的类型。
类型萃取
在这里我们是作为类型萃取的使用者,通过库开发者提供的::type方法获取类型。同样的我们也可以按照相同的形式,在接口开发的时候提供给使用者必要的类型信息,比如提供这个类型的一个关联类型信息。
我们假设我们有不同的类型的工厂,每个类型的工厂都会生产一种特定的产品,那么我们就认为特定类型的工厂和对应类型的产品这两个类是相关的。
其中一个工厂是苹果工厂,它可以生产苹果,我们先定义这两个类:
class Apple { // ... };class AppleFactory { // ... };
然后我们用萃取类来定义这两个类的关联,也就是输入工厂的类型,就能得到工厂生产对象的类型,萃取类被设计为一个模板类:
template<typename T>
class FactoryTraits
{
public:typedef typename T::ValueType ValueType;
};
接下来我们通过类的特化来定义具体的关联:
template<>
class FactoryTraits<AppleFactory>
{
public:typedef Apple ValueType;
};
此时我们就完成了萃取的所有定义。我们注意到该例子中AppleFactory和Apple本身并不是模板类,只有萃取类本身是模板类。如果我们希望使用我们上述定义的萃取,我们需要在模板中使用它们:
template<typename T>
class FactoryContainter
{
public:typedef typename FactoryTraits<T>::ValueType ObjType;ObjType Produce(){// ...}
};
如上所示,借助萃取类,当我们将FactoryContainer实例化为特定的Factory时,我们也能在FactoryContainer中访问到它的关联类。
FactoryContainter<AppleFactory>::ObjType a; // Type = Apple
当我们输入不同类型的Factory,都能通过::ObjType的统一写法获取对应的对象类型。
萃取类本身的实现并不难,只是提供一些预设的类型,它更像是一种编程约定与规范,也就是描述了可以提供哪些具体的类型,并且这些类型是需要在显式实例化中提供的。或者说,正是因为有了萃取类的存在,我们才有办法获取关联类的信息。
迭代器萃取
我们再来看一个C++标准库的例子,容器元素和迭代器,这两者也是具有一定关联的类。
当我们定义迭代器的时候,可以通过迭代器萃取类来获得容器元素的类型:
template <class Iterator>
struct iterator_traits {typedef typename Iterator::value_type value_type;
};
我们通过如下调用访问类型:
typename iterator_traits<T>::value_type;
这里的value_type是指值类型,通常我们需要提供所有限定符的类型:
template <class Iterator>
struct iterator_traits {typedef typename Iterator::iterator_category iterator_category;typedef typename Iterator::value_type value_type;typedef typename Iterator::pointer pointer;typedef typename Iterator::reference reference;typedef typename Iterator::difference_type difference_type;
};
这意味着假如我们要实现迭代器(Iterator),我们应该按照上述形式提供萃取类的实例化,即必须提供以下五种类型:iterator_catergory、value_type、pointer、reference、difference_type,只有当我们提供了所有类型,才能通过迭代器访问类型。
其中iterator_category标识当前容器支持哪种级别的迭代器访问(输入输出迭代器、前向迭代器、双向迭代器、随机访问迭代器)。
特别的,标准库提供了指针类型的特化版本,用来处理指针类型无法正常访问到value_type的情况。
// partial specialization
template<class T>
struct iterator_traits<T*> {typedef T value_type;
};
template<class T>
struct iterator_traits<const T*> {typedef T value_type;
};
特性萃取 - SFINAE特性
类型萃取可以让我们获得这个类型“是什么”,那么进一步的,当我们有办法知道类型是什么的时候,我们就可以基于类型萃取获取这个类型“是怎样的”信息。
获取类型特性
template<typename T>
struct is_void {static const bool value = false;
};template<>
struct is_void<void> {static const bool value = true;
};is_void<int> i1; // value = false
is_void<void> i2; // value = true
以上是一个非常简单的例子,我们先定义一个模板函数,is_void,默认值为false, 然后我们提供一个显式具体化版本:is_void<void>,默认值为true。
那么,通过调用is_void,我们就能获取这个类型是不是void,也就是说当我们匹配void失败后,就不去生成特化的版本。
基于类似这样的写法,我们就可以实现类型特性的判定,比如在C++标准库中,就提供了大量相关的预设方法:
is_integral
is_enum
is_floating_point
is_arithmetic
is_class
is_rvalue_reference
is_lvalue_reference
is_scalar
is_compound
对于上述方法,通常来说,如果我们访问::value,就能得到它返回的值。
我们再来看一个利用模板函数参数匹配来实现的is_class方法,它比is_void的实现要复杂一些,可以用来判断输入的对象是不是一个类,原理是只有是类类型时,才能够调用对应函数,根据函数的不同返回值来确认最终调用了哪个重载:
template<typename T>
class is_class
{typedef char yes[1];typedef char no[2];template<typename C>static yes& test(int C::*); // 只接受类的输入template<typename C>static no& test(...); // 通配符
public:static bool const value = sizeof(test<T>(nullptr)) == sizeof(yes);
};
当我们具备了识别类型特性的能力后,我们就可以用它来做一些静态类型的检测,比如之前介绍的forward的右值引用版本,实际上就添加了静态检测:
template<typename T>
T&& forward(typename remove_reference<T>::type&& t) noexcept {static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");return static_cast<T&&>(t);
}
广义的模板具体化
如果仅仅适用于静态类型检查,那么特性萃取看起来作用面并没有那么广。但我们可以把静态类型检查的特性推广到模板的具体化上。
我们通常把实现这一点的这种特性称为SFINAE特性,也就是Substitution failure is not an error。它字面上的含义是:类型匹配失败时,放弃该特化,而不是由于失败而产生编译错误。
template<class T>void f(typename T::Y*) { }
比如上述例子中,如果类型T并不包含类型Y,那么就认为替换失败,将不会选择使用这个重载。
我们在前面提到我们可以通过明确指定Type是什么来实现模板的显式具体化,我们也可以通过对一类特定类型比如指针实现部分具体化。但我们在前面并没有解释,如果我们想要对另外一些“满足某些条件”的类型执行特定的具体化,应该如何实现,比如,也许我们希望对enum类型做一些特殊处理,我们期望的代码如下:
tempalte<typename T>
class C<T, (if T is enum)>
{
};
为了达成这个目的,我们可以利用SFINAE特性,也就是我们在上述代码中(if T is enum)的占位符中,提供一个仅在T为enum的时候有意义的语句。如果T不是enum,那么就让它替换失败。
我们已经有了is_enum<T>::value可以返回布尔值判断类型是否是enum。但是只通过一个布尔值我们无法得到无意义的语句,因此标准库中还提供了enable_if来辅助实现这一点:
template<bool predicate, typename result = void>
class enable_if;tempalte<typename result>
class enable_if<true, result>
{
public:typedef result type;
};template<typename result>
class enable_if<false, result>
{
};
在这个模板类中,如果表达式为true,我们就能够通过enable_if<expression>::type获得类型,否则,enable_if<expression>:::type将不会被定义。
我们在模板中包含enable_if,并且传入is_enum<T>::value作为第一个参数,如果is_enum<T>::value为false,那么enable_if<expression>:::type就不存在,编译器会忽略该替换。
我们最终的实现是:
template<typename T, typename Enable = void>
class A
{
};template<typename T>
class A<T, typename enable_if<is_enum<T>::value>::type>
{
};enum EName
{
};
int main()
{A<float> a1;// primary templateA<EName> a2; // partial template
}
但是如上的书写有些过于麻烦了,我们可以用预设的enable_if_t来简化书写:
template<typename T>
class A<T, enable_if_t<is_enum<T>::value>>
{
};
在上述例子中,我们演示了enable_if的一种用法,那就是作为作为模板的偏特化,并且是一个额外类型。该额外的类型可以不参与到真正的类型运算中,仅仅便于编译器推断条件是否成立,由于提供了默认值(void),使用者也不需要指定这个参数,就像它不存在一样。
总体而言,enable_if可以出现在很多地方,包括类的模板参数、偏特化参数、函数模板参数、函数参数等等,如下所示:
模板类的偏特化参数
偏特化的使用我们在前面介绍过,我们还可以提供多种偏特化版本:
template<typename T>
class A<T, enable_if_t<is_enum<T>::value>>
{
public:A() { cout << "A enum" << endl; }
};template<typename T>
class A<T, enable_if_t<is_integral<T>::value>>
{
public:A() { cout << "A int" << endl; }
};
模板类的默认模板参数
我们可以将enable_if作为模板类的模板参数:
template<typename T, typename = enable_if_t<is_enum<T>::value>>
class A
{
public:A() { cout << "A enum" << endl; }
};
如果定义了两个模板类,而默认模板参数不一样,编译器会认为这是重复的类模板定义,也就是说,如下代码将无法通过编译:
// error !
template<typename T, typename = enable_if_t<is_enum<T>::value>>
class A
{
public:A() { cout << "A enum" << endl; }
};template<typename T, typename = enable_if_t<is_integral<T>::value>>
class A
{
public:A() { cout << "A int" << endl; }
};
模板函数的默认模板参数
我们可以将enable_if作为模板函数的模板参数。
template<typename T, typename = enable_if_t<is_integral<T>::value>>
void func() { cout << "int" << endl;};
如果定义了两个函数模板,而默认模板参数不一样,编译器会认为这是重复的函数定义,而不会将其视为函数重载,也就是说,如下代码将无法通过编译:
// error !
template<typename T, typename = enable_if_t<is_integral<T>::value>>
void func() { cout << "int" << endl;};template<typename T, typename = enable_if_t<is_enum<T>::value>>
void func() { cout << "enum" << endl;};
模板函数的非类型模板参数
我们还可以将enable_if的值作为非类型模版参数作用于类模板,也就是将enable_if的第二个模板参数设置为bool,这样就能返回true/false的结果:
template<typename T, enable_if_t<is_integral<T>::value, bool> = true>
void func() { cout << "int" << endl;}template<typename T, enable_if_t<is_enum<T>::value, bool> = true>
void func() { cout << "enum" << endl;}
通过这样的方式,就能够实现不同类型特例化的“模板函数重载”,而不会发生编译报错。
模板函数的默认函数参数
前面的例子中,enable_if都是应用在模板参数列表之中。实际上enable_if也可以应用于函数参数之中,作为一个无需指定的默认参数,如下所示:
template<typename T>
void func(typename enable_if<is_enum<T>::value>::type* = 0)
{cout << "enum" << endl;
}template<typename T>
void func(typename enable_if<is_integral<T>::value>::type* = 0)
{cout << "int" << endl;
}
模板函数的返回值
enable_if可以用作函数参数,同样也可以作用于函数返回值,其中std::enable_if<std::is_enum<T>::value>::type的结果是void。
// return void
template<typename T>
typename enable_if<is_enum<T>::value>::type func()
{cout << "enum" << endl;
}template<typename T>
typename enable_if<is_integral<T>::value>::type func()
{cout << "int" << endl;
}// return T
template<typename T>
typename enable_if<is_enum<T>::value>::type func1()
{cout << "enum" << endl;
}
使用生成的类型
我们在前面介绍的例子都是利用了enable_if的特性来控制模板的生成,但是我们并没有实际使用它本身的类型(第二个参数),而是默认将其视为void。如果我们将其设置为T,则能够将其视为类型T使用。
定义输入为T:
template<typename T>
void func(typename enable_if<is_integral<T>::value, T>::type t)
{cout << "int" << endl;
}
定义返回值为T:
template<typename T>
typename enable_if<is_integral<T>::value, T>::type func()
{cout << "int" << endl;return 1;
}
控制输入类型
在上一节中,我们介绍了enable_if的可以应用的场景(模板参数,模板非类型参数,函数参数,函数返回值等),并且介绍了enable_if的一个使用实例,就是实现广义的模板显示具体化。它成立的逻辑是,如果类型满足enable_if的条件,那么生成对应的实例化,否则,使用通用模板的实例化。此时,如果我们并没有提供可用的“通用版本”,那么当类型不满足enable_if的条件时,将无法找到合适的重载,此时就会发生编译报错。
利用这个特性,我们可以实现对类模板/函数模板的类型校验和限制。也就是说,只有满足了条件的才会生成对应类模板或函数模板,否则就会发生编译报错,或者编译器会选择其它重载。
比如,在前面的函数模板的例子中,如果我们只提供了int的特化版本:
template<typename T, enable_if_t<is_integral<T>::value, bool> = true>
void func() { cout << "int" << endl;}
此时我们调用func<float>()就会发生编译报错。
控制返回类型
利用enable_if,我们可以实现不同条件下返回值类型的控制。比如我们希望在i为0和不为0时获得不同类型的返回结果:
template<int i>
typename enable_if<i == 0, int>::type func()
{return 0;
}template<int i>
typename enable_if<i != 0, float>::type func()
{return 0;
}auto t1 = func<0>();
auto t2 = func<2>();
cout << typeid(t1).name() << " " << typeid(t2).name() << endl; // int, float
auto和decltype
我们都知道C++中有两个和类型自动推导相关的关键字,auto和decltype。这两者一方面让我们写代码变得更方便,比如我们常常会用auto来代替那些名字很长的类型定义的书写;另一方面它们在适配模板自动类型推导也非常有用。
在此之前,我们先简单的介绍一下auto和decltype的语法。总体而言,两者都能推导出表达式/变量的类型,只不过auto更倾向于移除表达式/变量的reference/const属性,decltype更倾向于保留表达式/变量的reference/const属性。
auto
编译器推断auto的类型和原始类型有时不一定相同,如前所述,它可能会有如下差异:
1.忽略引用
对于引用类型,翻译为引用对象本身的类型。
int i = 0;
int ri = &;
auto a = r; // int
2.忽略顶层const,保留底层const
const int ci = 1;
const int& cr = ci;
auto b = ci; // int
auto c = cr; // int
auto d = &ci; // const int*
3.auto配合&和const
我们可以手动添加推导对象的&/const属性:
const auto e = ci; // const int
auto& f = ci; // const int&
auto的类型推导基本和模板推断类型的逻辑一致,我们在前面介绍的模板类型匹配规则,也同样适用于auto。
decltype
decltype基本保留变量和表达式原本的类型:
1.变量
处理变量时,会保留变量的顶层const和引用:
const int ci = 0;
const int ri& = ci;
decltype(ci) a = 0; // const int
decltype(ri) b = a; // const int&
对变量添加括号,会被认为是表达式。变量是出现在赋值语句的表达式,它将返回引用类型:
const int ci = 0;
decltype((ci)) c = ci;
/todo
2.表达式
编译器在会分析表达式返回类型,但不会实际计算表达式本身,表达式按照实际情况返回结果:
int a = 0, b = 1;
int* p = &a;
decltype(a + b) x; // int
decltype(*p) y = b; // int&
auto/decltype都是编译时确定类型的,因此它非常适合应用在同样在编译期确定代码的模板,辅助我们获取编译期已知但编写代码时还未知的类型。
后置返回值类型
auto/decltype的一个比较常见的用法是用于辅助推导返回值类型。
假如我们想要用decltype推导加法的返回类型,我们可以这么写:
decltype(x + y) z = x + y;
把这个方法扩展成模板函数,我们无法像如下这样常规的形式编写,因为解析到decltype(t1 + t2)时,t1和t2变量还没有定义。
// error
template<typename T1, typename T2>
decltype(t1 + t2) add(const T1& t1, const T2& t2) {return t1 + t2;
}
为了解决这个问题,C++支持了类似lambda函数的后置返回值表达方式:
template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2) ->decltype(t1 + t2) {return t1 + t2;
}
C++14中, 提供了更为简洁的decltype(auto)语法,来代替复杂的后置的写法:
template<typename T1, typename T2>
decltype(auto) add(const T1& t1, const T2& t2) {return t1 + t2;
}
此外,我们还可以直接使用auto作为返回值,它和decltype(auto)的区别和auto与decltype一样,可能不会保留变量的const/reference属性:
template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2) {return t1 + t2;
}
在有些情况下,保留const/reference属性非常重要,比如下述使用下标访问对象时,我们期望返回的是一个左值,这样才满足我们可以直接对下标对象赋值的需求:
template<typename T>
decltype(auto) At(T& container, int index)
{return container[index];
}vector<int> vec{1,2,3,4};
At(vec, 0) = 0; // int&
cout << vec[0] << endl; // output : 0
逗号运算符与类型限制
我们知道逗号运算符中,会去计算第一个操作数和第二个操作数的结果,并且丢弃第一个操作数的结果,并保留/返回第二个操作数的结果。
在后置返回值类型中,如果我们将逗号运算符应用到decltype,相当于向decltype输入一个表达式,表达式会返回第二个操作数的结果,因此我们把实际返回的类型放在第二个操作数的位置,在第一个操作数可以放置用于类型限制的操作数。
比如下述代码,限制类型T为Class,类型F为C的成员函数的指针,并且返回void:
// from cppreference.com
template<class C, class F> auto test(C c, F f) -> decltype((void)(c.*f)(), void()) { }
lambda函数
在C++14中,支持了在lambda形参中使用auto关键字,它实际上相当于在lambda函数中实现了函数模板:
auto f = [](auto param) { ... };
不过template<typename T>是隐藏的,这也就意味着我们无法通过T来直接访问参数类型,这时我们必须依赖于auto/decltype的类型推导:
auto f = [](auto param)
{auto param1 = param;
};auto f1 = [](auto&& param)
{remove_reference<decltype(param)>::type param1;
};
如果我们要在lambda函数中实现参数的完美转发,可以按如下方式来实现:
auto f = [](auto&& param)
{func(std::forward<decltype(param)>(param));
};
可变参数模板
到目前为止,我们已经介绍了模板的不少高级特性,我们已经可以完成对任意类型的匹配,并且能够对类型实现控制。但是,对于泛型而言,这一切还没那么完善,因为我们的参数数量还是固定的。这意味着如果我们想要实现一个通用转发调用,我们需要按如下方式书写:
template<typename T1>
void func_OneParam(T1 t1) { };template<typename T1, typename T2>
void func_TwoParam(T1 t1, T2 t2) { };template<typename T1, typename T2, typename T3>
void func_ThreeParam(T1 t1, T2 t2, T3 t3) { };// ...
这会使得编码过程变得比较繁琐,而且扩展性较差,因为不知道需要预留几个参数的版本。我们期望仅编写一次就能处理所有情况,换句话说,我们需要一个能够输入任意数量和类型参数的方法。
实际上在C语言中,我们已经见过类似的方法,那就是函数的通配符,用...来表示,最经典的例子就是可以打印任意类型的printf函数:
int printf(const char* format, ...)
我们在前面介绍的is_class实现中,为了避免输入类型不是“类”类型时找不到合适重载函数,就是使用的通配符...的函数作为fallback。
同样的,C++模板也提供了类似于通配符的可变参数,它可以支持任意类型和数量的输入参数,可以应用到模板类和函数模板中,它的语法如下:
template<typename... Args> class C; // 可变类模板参数
template<typename... Bases> class D : public Bases... { } // 可变参数作为基类 template<typename... Args> void func(Args... args) {} // 可变函数参数
其中,typename... Args说明Args是一个模板参数包(parameter pack),可以接受0个或多个模板参数。
Args... args说明args是一个函数参数包,可以接受0个或多个函数参数 对于函数参数包,使用sizeof...args可以获取当前参数包里有多少个参数。
为了读取参数包里的参数,我们需要对参数包进行“解包”,这个过程被称为参数包展开(parameter pack expansion)。
参数包展开
如果我们将省略号放在包含了参数包的pattern的右侧,那么我们可以称其为参数包展开:
Pattern...
比如args...就是一个最简单的参数包展开,它可以被展开为arg0, arg1, arg2, arg3...
pattern只需要包含至少一个参数包,它还可以有很多形式,如下:
&args... // 展开为&arg0, &arg1, &arg2
std::forward<Args>(args)... // 展开为std::forward<Args>(args0), std::forward<Args>(args1)...
++args; // 展开为++arg0, ++arg1, ++arg2...
(args, 0)... // 展开为(arg0,0), (arg1, 0)...
展开之后,会得到参数包里每个参数的多个pattern。
参数包展开只能发生在有限的位置,比较常见的是:
① 函数参数列表
func(args...)
auto func = [args...]() { }
② 初始项/初始列表
Class c(args...)
{ args... }
③ 模板参数列表
vector<T, Args...>
如果我们想要从参数包中解析出每个独立的参数,我们需要一个具体的实现函数,这个函数也是一个常规的模板函数,我们对每个参数的具体处理都写在这个函数里:
template<typename T>
void funcImpl(T value)
{// ...
}
我们现在期望的是展开为如下形式:funcImpl(args0),funcImpl(args1),funcImpl(args2)... 这样我们就能保证对每个参数都执行了一遍对应的逻辑,因此,我们的参数包展开如下:
funcImpl(args)...
这里我们的pattern是funcImpl(args)结果是void,这并不合规,为了让这个参数包展开合规,我们可以给funcImpl随便添加一个返回值:
template<typename T>
int funcImpl(T value)
{// ...return 0;
}
或者我们也可以不给funcImpl加返回值,而是使用括号表达式避免结果是void,因为括号表达式返回的结果是第二个值:
(funcImpl(args), 0)...
函数模板:使用函数展开
我们已经得到了一个参数包展开的样例,但我们刚刚提到参数包展开只能出现在特定的位置,因此虽然以上参数包就已经能够实现所有的逻辑,但在语法上不合规,我们还需要套一个壳子来使它语法正确,虽然这个”壳子“可能没有实际上的作用。
假如我们使用函数来展开:
template<typename... Args>
void helper(Args&&...) {}template<typename... Args>
void func(Args&&... args)
{helper(funcImpl(args)...); // 或者helper((funcImpl(args),0)...);
}
它得到的展开结果可能是:
helper(funcImpl(arg2), funcImpl(arg1),funcImpl(arg0));
这是因为函数参数的求值顺序是不固定的,因此在我们对顺序有要求的情况下,函数不是一个好的选择。
函数模板:使用初始化表展开
如前所述,参数包展开还可以出现在花括号里,因此我们可以构造一个同样没有实际作用的初始化对象作为壳子来实现展开:
template<typename... Args>
void func(Args&&... args)
{int arr[] { (funcImpl(args),1)...};
}
由于初始化表的求值顺序是确定的,因此我们可以得到如下展开结果:
int arr[] { (funcImpl(arg0),1), (funcImpl(arg1),1), (funcImpl(arg2),1)};
函数模板:使用递归展开
还有一个比较常见的方法是使用递归函数展开,但由于它用到了递归,性能肯定没有直接展开那么好,但是代码阅读起来更加直观易懂。
常见的例子就是打印的递归展开:
递归展开中通常包含两个函数,递归函数和终结函数,这两者缺一不可。但参数包只剩最后一个参数的时候,就会调用到终结函数。
在C++17中,我们可以使用折叠表达式来简化递归展开的写法,避免一定要使用两个函数实现递归展开。
我们在前面介绍了模板函数参数包展开的做法,除此之外,我们还可能会使用到模板类,比如C++中可以支持任意数量类型的tuple类:
tuple<int, char, string> t(1,'a',"test");
为了对模板类进行展开,我们使用了继承+递归的方式:
类模板:使用继承递归展开
使用继承实现类模板的展开时,同样需要包含递归终止类和递归展开实现类,同时需要类模板的前向声明:
// 前向声明
template <class... _Types>
class tuple;// 递归终止类
template <>
class tuple<> { // empty tuple
public:// ...
};// 继承递归实现类
template <class _This, class... _Rest>
class tuple<_This, _Rest...> : private tuple<_Rest...> { // recursive tuple definition
public:// ...constexpr _Mybase& _Get_rest() noexcept { // get reference to rest of elementsreturn *this;}_Tuple_val<_This> _Myfirst; // the stored element
};
通过_Myfirst访问当前元素,通过_Get_rest()访问剩下的元素(*this指向父类)
模板函数重载决议
在前文中,我们了解到,我们可以定义多个同名函数,包括常规函数,模板函数以及模板函数的显式具体化版本。如果我们同时定义了多个版本的同名模板函数,并且输入参数也一致,那么编译器又会如何选择对应的匹配函数呢?
总体而言,编译器会去优先选择更加“特化”的版本,它遵循如下优先级:普通函数 -> 显式具体化函数(全特化) ->部分具体化函数(偏特化)-> 通用模板函数。只有这样我们额外定义的具体化函数才可能会生效。
但如果参数不那么一致,又同时有普通函数和模板函数,我们又该如何决策呢?
重载匹配规则
我们在实现模板函数的重载时,遵循如下的步骤:
1.编译器先替换显式具体化(特化)的模板参数
2.编译器确定模板参数的类型
3.编译器将函数参数/返回值、模板参数值/表达式替换成该类型
4.如果替换失败,就从重载集中丢弃它
5.如果替换成功,对比替换之后的函数和其它函数更匹配的重载
6.如果替换成功的函数参数完全一样,并且更加特化的版本,遵循普通函数-显式具体化函数-通用模板函数的规则选择更合适的重载
7.如果替换成功的函数同名但参数不一致,按照更匹配的参数选择重载
8.如果无法决议出更匹配的参数,说明发生了重载歧义,编译报错
● 简单来说,编译器会先完成模板参数替换,然后再去做重载决议,决议时如果参数一致,选更特化的版本,如果参数不一致,选更匹配的参数
● 重载集中包含了普通函数和替换后的模板函数,它们均可作为重载函数的候选
替换的位置可能发生在:
1.函数定义中的类型/表达式(包括返回值/输入参数)
2.模板参数定义中的类型/表达式
3.模板显式具体化的参数中的参数/表达式
正是因为替换的位置非常多,我们在应用SFINAE特性的时候才可以将类型判断逻辑(如enable_if)放在多个地方,因为这些地方都会参与编译期的替换计算。但需要注意的是,函数体内部并不会参与替换的过程。
以下是一些具体的模板函数重载示例:
指针
模板函数重载的一个比较常见的做法是在提供常规模板的同时,提供指针和常量指针的特化版本,这是因为通常而言,指针类型会需要特殊处理。
当我们提供了指针类型的特化时,指针类型就会优先匹配对应的模板函数:
template<typename T>
void Test(T t)
{cout << "Test(T t)" << endl;
}template<typename T>
void Test(T* t)
{cout << "Test(T* t)" << endl;
}template<typename T>
void Test(const T* t)
{cout << "Test(const T* t)" << endl;
}const int i = 2;
const int* cp = &i;
Test(cp); // Test<int>(const int*)
列表和数组
常规模板无法推断出花括号初始化列表的类型,因此如果我们想要接收对应类型,需要手动提供initializer_list的重载,如下所示:
template<typename T>
void func(T param);template<typename T>
void func(std::initializer_list<T> initList);func({ 1, 2, 3}); // match void func(std::initializer_list<int>)
同理,我们可以为数组类型提供重载:
template<typename T>
void func(Array<T>& arr) { }
类类型
如果我们想要确保输入是一个“类”类型,我们可以提供如下版本的重载:
template<typename T>
void func(T param);template<typename T>
void func(T::* param);
引用和const
我们在前面介绍了带有引用和const的同名函数的匹配规则。如果我们已经定义了引用版本的重载函数,再去定义常规的模板函数,那么重载决议就会发生冲突,因为编译器无法决定哪个匹配更优:
template<typename T>
void Test(T t)
{cout << "Test(T t)" << endl;
}template<typename T>
void Test(const T& t)
{cout << "Test(const T& t)" << endl;
}template<typename T>
void Test(T&& t)
{cout << "Test(T&& t)" << endl;
}int i = 1;
const int ci = 2;
Test(i); // error! match Test(T t) Test(T&& t)
Test(2); // error! match Test(T t) Test(T&& t)
Test(ci); // error! match Test(T t) Test(const T& t)
因此,我们在提供了引用版本的输入后,一般不再提供常规版本。
对于剩下的模板重载函数,根据我们前文介绍的规则,相同的输入也有可能同时正确匹配到多个函数上,但是对于编译器而言,可以决议出更优的结果,因此不存在歧义。
对于左值非const变量,优先匹配左值引用,再匹配通用引用,最后才去匹配常量左值引用:
Test(i); // Test(T&) -> Test(T&&) -> Test(const T&)
// i并非常量,所以不会优先匹配const版本
// i是左值,所以左值引用比通用引用更匹配
对于右值,优先匹配通用引用, 再匹配常量左值引用,它无法匹配左值引用:
Test(1); // Test(T&&) > Test(const T&)
对于左值const变量,优先匹配常量左值引用,再匹配左值引用,最后才去匹配通用引用:
Test(ci); // Test(const T&) -> Test(T&) -> Test(T&&)
通用引用的重载
我们已经介绍了引用/const的函数重载决议规则,虽然我们是基于模板函数来介绍的,但是这套规则同样适用于普通函数,包括模板函数和普通函数共存的时候。正如我们在介绍函数的重载决议规则中所提,我们会完成模板函数的参数替换,再用替换后的函数”公平的“和普通函数做对比。
我们在前面提到,我们会使用通用引用来实现完美转发,如下:
class Test {
public:template<typename T>Test (T&& t) { ... }
};
对于以上类,根据规则,编译器会默认生成拷贝构造函数,它和完美转发的构造函数正好构成了重载关系。
此时,如果我们试图去调用拷贝构造函数,根据我们介绍的函数重载决议规则,对于非常量左值而言,T&&是比const T&更优的匹配,因此它实际上并不会调用拷贝构造函数:
Test t1;
Test t2(t1); // call Test(T&&)
辅助函数重载匹配
由此可见通用引用的“匹配”能力很强,这会使得函数重载决议的结果往往和我们的预期不一致。
有时我们会选择绕开函数重载,比如提供一个名字不一样的函数,但有时我们绕不开函数重载,比如上例中的构造函数。此时我们就需要通过SFINAE特性来辅助函数重载,也就是说,我们限定只有特定类型能匹配到通用引用。换句话说,就是我们期望匹配到拷贝构造函数的时候,不应该匹配到通用引用。
那么,什么时候应该匹配到拷贝构造函数呢?也就是输入类型和类类型完全一致的时候,因此我们可以这样实现:
class Test
{
public:template<typename T, typename = enable_if_t<!std::is_same<Test, typename std::decay<T>::type>::value>>Test(T&& t);
};
// decay : remove const/reference
我们再来看C++标准库中的一处使用:
vector(const size_type _Count, const _Ty& _Val, const _Alloc& _Al = _Alloc())
{
}template <class _Iter, enable_if_t<_Is_iterator_v<_Iter>, int> = 0>
vector(_Iter _First, _Iter _Last, const _Alloc& _Al = _Alloc())
{
}
这里给出了vector的两种构造方式,一种是指定元素数量和默认值,另一种使用迭代器的首尾进行构造,这里如果我们没有对第二种构造函数做迭代器类型的限制,当我们期望调用第一个构造函数,且在元素类型和size_type一致的时候,就很有可能错误地匹配到第二种构造函数。
其他问题
模板与继承
如果我们想要在派生类中访问基类的内容,如果这是一个模板类,那么和普通类不一样,基类的名称对派生类不是直接可见的,所以我们无法通过常规的访问方式。
① 使用this访问基类
我们在前面介绍tuple时介绍了tuple访问其余元素的一个方法_Get_rest(),我们可能会注意到它访问了*this。仔细一想,tuple是一个递归定义类,每个tuple只包含了当前的第一个元素,为什么*this就能表示剩余元素的语义呢?
template <class _This, class... _Rest>
class tuple<_This, _Rest...> : private tuple<_Rest...> { // recursive tuple definition
public:constexpr _Mybase& _Get_rest() noexcept { // get reference to rest of elementsreturn *this;}
};
实际上在当前语境下,这里的this是指基类对象。如果tuple包含当前第一个元素,那么基类就包含剩下的元素。
同理,如果我们想要访问模板基类的函数,也可以通过this->来访问:
template<typename T>
class Base
{
public:void BaseCall() { }
};template<typename T>
class Derived : public Base<T>
{
public:void Call(){this->BaseCall(); // okBaseCall(); // error}
};
我们不能直接调用基类函数,是因为模板T没有确定时,父类还没有实例化,编译器无法确认基类是否包含对应函数,在搜索函数定义的时候也不会把基类加入到查找空间。使用this->相当于告诉编译器该函数的定义来自基类。
2. 使用using访问基类
我们还可以使用using语句,这是因为using本身就可以用来在派生类中访问隐藏的基类名称:
template<typename T>
class Derived : public Base<T>
{
public:using Base<T>::BaseCall;void Call(){BaseCall(); // ok}
};
模板与友元函数
对于模板类而言,相比起常规类,它的友元函数的情况会更加复杂,比如我们可能要去考虑这个友元函数是共享的还是每个实例独立的,友元类型和模板类型是否有关联。
根据以上这些情况,我们可以把友元函数大致划分成三种情况:
① 非模板友元
如果友元函数不是模板函数,那么仅存在一个友元函数,且它是所有实例的友元:
void friendfunc()
{cout << "call friend func" << endl;
}template<class T>
class C
{
public:friend void friendfunc();
};
② 约束的模板友元
如果友元函数是模板函数,并且它的类型和模板类的类型有关联,那么每个类的具体化有一个对应的友元函数:
template<typename T>
void friendfunc()
{cout << "call friend func " << endl;
}template<typename T>
class C
{
public:friend void friendfunc<T>();
};
③ 非约束的模板友元
如果友元函数是模板函数,并且它的类型和模板类的类型没有关联,那么每个友元函数的具体化可以对应每个具体化的类:
template<typename T>
void friendfunc()
{cout << "call friend func " << endl;
}template<typename T>
class C
{
public:template<typename U>friend void friendfunc<U>();
};
我们已经介绍了模板的一些常用高级特性,接下来我们关注一些更加偏向应用的内容。
支持隐式转换
我们知道模板类型倾向于生成一个“新的类型”,而不是进行隐式转换,实际上模板推断也几乎不支持隐式转换,在这个情况下,假如我们想要实现隐式转换,可以借助成员函数模板来实现,比如智能指针模拟指针的隐式转换:
template<typename T>
class SmartPtr {
public:template<typename U>SmartPtr(const SmartPtr<U>& other): ptr(other.get()){};
};
我们将这种写法称为通用构造函数,通过这种写法,我们可以实现比如派生类向基类的转化:
SmartPtr<Base> p = SmartPtr<Derived>(new Derived);
函数回调
如果我们想要实现一个消息处理机制类,能够执行函数回调,我们也可以利用模板来实现。
为了实现这一点,我们需要使用嵌套的模板类。首先,我们需要实现一个模板函数类,能够生成任何类型的函数,其次我们实现一个面向用户的函数类,提供更像函数的封装。
对于定义的函数类而言:
首先我们需要缓存一个函数指针,我们可以将函数设计为模板。
此外,我们还需要调用这个函数,因此还需要知道参数类型和返回类型。 考虑到函数调用包含一个返回值和任意数量的参数输入,我们设计两个模板参数,一个普通模板参数,一个可变模板参数来替代单一模板参数;考虑到参数的转发,我们需要使用通用引用。
为了提供像普通函数一样的调用方式,我们需要实现operator()的重载:
template <typename Ret, typename... Params>
class FuncImpl
{
public:using FuncType = Ret(*)(Params...);FuncImpl(FuncType func) {funcImpl = func;}Ret operator()(Params&&... params) {return funcImpl(forward<Params>(params)...);}
private:FuncType funcImpl;
};
以上就实现了基本的函数封装,只是对于调用者而言不够友好。我们再设计一个面向用户的函数类,并将FuncImpl作为它的一个成员,实现具体的函数存储和调用。
我们可以对类型格式进行限制:class Func<Ret(Params...)>,这样我们就可以通过类似Func<void(int)>的形式来调用。
template <typename Ret>
class Func;template <typename Ret, typename... Params>
class Func<Ret(Params...)>
{using CallableType = FuncImpl<Ret, Params...>;CallableType* Callable = nullptr;
public:Func(typename CallableType::FuncType func): Callable(new CallableType(func)) { }Ret operator()(Params... params){return (*Callable)(forward<Params>(params)...);}
};
以上只是一个初步可执行的简化类,更详细的内容我们可以参考std::function的实现,与这里不一样的是,stl中使用了继承而不是组合的方式来包含可调用对象。