1 什么是 Tag Dispatching
一般重载函数的设计是根据不同的参数决定具体做什么事情,编译器会根据参数匹配的原则确定正确的重载版本。但是对于函数模板,其参数类型是泛化的模板参数,此时又如何让编译器选择我们希望的那个函数模板的实例呢?提供特化版本是一个方法,但是如果需要特殊处理的类型很多,就需要搞一大堆特化版本,非常不方便。C++ 11 的语言库提供了 std::enable_if,配合编译器的 SFINAE 原则也可以实现在编译期间的特定选择。C++ 17 还提供了一个 std::void_t,以模板别名定义的语法形式提供了另一种利用 SFINAE 的方法。当然,同样是 C++ 17 提供的 if constexpr 语言特性配合各种 type traits,可以更优雅地实现编译期间的特定选择。但是这一篇我们要介绍的是另一种常用的技术:Tag Dispatching。
Tag Dispatching 是一种利用某种类型特征,在一系列重载函数之间进行编译期调度(分派、选择)的技术。Tag Dispatching 并不是 C++ 的某种特性,但是作为一种习惯用法在 C++ 中被广泛应用,尤其是在标准库中。这里说的 tag,其实就是定义一种没有操作、没有数据的类型,将这种类型作为重载函数的一个参数,通过不同的 tag 参数控制编译器的选择。定义一个 tag 非常简单,一般用 struct:
struct tag1 {};
struct tag2 {};
虽然结构体都是空的,但是在 C++ 编译器看来,tag1 和 tag2 是两个完全不同的类型。基于 Tag Dispatching 的实现就是定义不同的 tag,并将 tag 设计成函数的一个参数。一般会将 tag 设计成 函数的最后一个参数,因为编译器在代码生成的时候对这种完全是空的参数类型会有针对性的优化。具体来说,就是将重载函数设计成这个样子:
template <typename T>
int Function(T t, tag1) { ... }
template <typename T>
int Function(T t, tag2) { ... }
这就是所谓的 Tag Dispatching,其实就是利用 tag1 和 tag2 是不同类型的特性,控制编译器在编译期间选择希望的重载版本,实现在编译期间的重载分派,比如:
int a = Function(42, tag1()); //
可以确保编译器使用第一个模板函数。这只是一个简单的例子,要让编译器能够根据类型自动选择,还需要自定义 type traits,请继续看下去。
2 标准库中的例子
标准库中大量使用 Tag Dispatching,这一节就介绍一下标准库的 std::advance() 函数。void std::advance(Iter& it, Distance n) 函数的作用是将迭代器向前(或向后)移动 n 个位置。这里需要注意的是,根据迭代器类型的不同,std::advance() 函数内部是不同的实现。比如对于随机类型的迭代器,可以采用高效的 it + n 的形式移动位置,对于不支持随机访问的单向迭代器,只能通过执行 n 次 ++it 的方式移动迭代器,而对于双向类型的迭代器,n 可以是负数,表示向后移动迭代器。
std::advance() 函数首先针对不同类型的迭代器定义了相应的重载形式:
template <class RAIter, class Distance>
void advance(RAIter& it, Distance n, std::random_access_iterator_tag) {
it += n;
}
template <class BidirIter, class Distance>
void advance(BidirIter& it, Distance n, std::bidirectional_iterator_tag) {
if (n > 0) {
while (n--) ++it;
}
else {
while (n++) --it;
}
}
template <class InputIter, class Distance>
void advance(InputIter& it, Distance n, std::input_iterator_tag)
while (n--) {
++it;
}
}
这几个重载函数的第三个参数就是所谓的 tag,以 std::input_iterator_tag 为例,标准库中的定义大概是这个样子:
struct input_iterator_tag {};
标准库还定义了 iterator_traits<>
类模板用于提取迭代器的 tag,对于支持随机访问的迭代器,它的 iterator_category 被特化处理为:
template <class Iter>
struct iterator_traits<Iter> {
....
using iterator_category = random_access_iterator_tag;
};
可用 iterator_traits<Iter>::iterator_category
提取 Iter 类型迭代器的分类 tag。最终 advance() 的实现大致是这个样子:
template <class Iter, class Distance>
void advance(Iter& it, Distance n) {
advance(it, n, typename std::iterator_traits<Iter>::iterator_category{} );
}
编译器根据编译期间从 std::iterator_traits<Iter>::iterator_category
提取出来的 tag 选择参数一致的那个函数模板(函数模板实例化的结果)。
3 使用自己的 Tag Dispatching
3.1 使用 type traits 技术
在介绍 std::enable_if 和 if constexpr 两个主题的时候,我们提到了 ToString()
还可以使用 Tag Dispatching 实现,但是没有详细说明。其实 Tag Dispatching 并不是个复杂的技术,那个例子使用 type traits 技术实现分配选择,本篇就借这个主题把这个例子完整解释一下。
首先要定义 tag,这个例子需要两个 tag 用于区分两种情况:
struct NumTag {};
struct StrTag {};
理论上说,此时用 ToString(42, NumTag())
和 ToString(std::string("Emma"), StrTag())
就能区分两个重载函数了,但是我们设计的是针对泛型的函数模板,需要提供一种根据类型提取 tag 的手段。其实就是仿照标准库的样子做一个自己的 traits 类,利用 traits 类的特化版本实现编译期间的 tag 定义:
template <typename T>
struct traits {
typedef NumTag tag;
};
template <>
struct traits<std::string> {
typedef StrTag tag;
};
可以使用 traits<T>::tag
提取 T 对应的 tag,针对 std::string
提供了一个 traits<>
的特化版本,这个版本里的 tag 被定义为 StrTag
。
接下来就是实现针对两种 tag 的 ToString()
重载版本,为了区分,我们使用 ToString_impl()
作为函数名字:
template <typename T>
auto ToString_impl(T t, NumTag) {
return std::to_string(t);
}
template <typename T>
auto ToString_impl(T t, StrTag) {
return t;
}
对于数字类型的数据,用 std::to_string()
转换,对于字符串类型的数据,直接返回字符串即可。ToString_impl()
函数的第二个参数是哑形参,不需要指定参数名称,编译器会针对这种情况做适当的优化(优化掉这个参数),如果指定参数名字反而会影响编译器的优化判断。
最后就是提供统一的 ToString()
函数,通过 traits<T>
提取类型的对应的 tag,让编译器根据 tag 选择正确的重载函数:
template <typename T>
auto ToString(T t) {
return ToString_impl(t, typename traits<T>::tag());
}
int main() {
std::cout << ToString(42) << std::endl;
std::cout << ToString(std::string("Emma")) << std::endl;
}
3.2 使用 Type_2_Type 技术
Type_2_Type
是一种类型映射技术,常用来将一种普通类型映射为另一种可控类型。Tag Dispatching 也可以借助 Type_2_Type
实现类型分派,此时的 tag 也被称为 templated tags。
首先需要定义一个泛化的 TypeTag<T>
,用作控制分派的可控类型:
template<typename T>
struct TypeTag {};
然后修改 ToString_impl()
的参数类型,改用我们定义的可控类型做模板参数:
template <typename T>
auto ToString_impl(T t, TypeTag<int>) {
return std::to_string(t);
}
template <typename T>
auto ToString_impl(T t, TypeTag<std::string>) {
return t;
}
最后就是修改 ToString()
函数,根据函数参数 t 推导出的类型 T,利用 TypeTag<T>
映射为可控类型中的 TypeTag<int>
或 TypeTag<std::string>
,使得编译器可以根据 TypeTag<T>
选择正确的重载函数:
template <typename T>
auto ToString(T t) {
return ToString_impl(t, TypeTag<T>());
}
4 什么时候用 Tag Dispatching
编译期需要进行的重载函数分派可以考虑用 Tag Dispatching,运行期间的分派可以考虑 C++ 对象的抽象和分派方式。什么情况适合放在编译期分派呢?对操作或行为需要进行额外控制的场合可以考使用这种编译期进行的 Tag Dispatching,因为这对提高代码运行时的效率非常有用(不需要在运行时对条件进行判断) 。对数据的额外处理就不适合在编译期间决定,因为数据是运行期变化的。总之一句话,Tag Dispatching 用于定制行为,不是定制数据。
5 参考资料
[1] David Vandevoorde, Nicolai M. Josuttis. C++ Template. Addison-Wesley. 2002
[2] Scott Meyers. Effective C++. Oreilly. 2005
[3] https://www.fluentcpp.com/2018/04/27/tag-dispatching/
[4] https://arne-mertz.de/2016/10/tag-dispatch/
[5] Nicolai M. Josuttis. The C++ Standard Library (Second Edition). Addison-Wesley. 1999
关注作者的算法专栏
https://blog.csdn.net/orbit/category_10400723.html
关注作者的出版物《算法的乐趣(第二版)》
https://www.ituring.com.cn/book/3180