Type Erasure,直译就是“类型擦除”。什么时候需要擦除类型?当我们想令一些代码具备多态性质时,我们往往没办法保留对象本身的类型,而需要用一种通用的类型去使用它们,这个时候,就需要擦除对象原有的类型。
Type Erasure的几种形式
void*
在C语言中,很多通用算法函数都会使用void*
作为参数类型,比如qsort
,它的原型是:
1 | void qsort (void* base, size_t num, size_t size, int (*compare)(const void*,const void*)); |
为了使qsort
有处理多种类型的能力,它只能把参数类型设为void*
,这样我们可以用同一个qsort
函数,处理各种各样的类型。代价就是对象原有的类型被擦除了,我们只能看到void*
。
这种方法的缺点是,它不能保证类型安全。当我们擦除了一个对象的类型后,总会在某个时刻需要把它再找回来的。在qsort
中,我们总是需要能拿到对象的正确类型的,才能进行正确的排序。而这个工作是通过compare
完成的:
1 | int int_compare(const void* a, const void* b) { |
假设我们传递了错误的compare
,谁能知道这件事?编译器不知道,因为你把类型擦除掉了。你自己也不知道,因为代码就是你写的。测试程序可能知道,也可能不知道,因为这个时候程序的行为是未定义的。
继承
在面向对象语言中,继承是最常见的Type Erasure。
1 | interface Counter { |
在Test.down
中,我们只知道c
的类型是Counter
,但不知道它是哪个实现类型,这里它的类型就被擦除了。
继承当然是比void*
要好的,因为我们操作对象时调用的是对象具体的实现API,换句话说,我们只擦除了调用处对象的类型,实际上它并没有丢掉自己的类型,也保证了类型安全性。
继承的问题在于,它的侵入性,即它要求每个实现类型都继承自某个基类。在很多情况下,这是很难做到的,或者是很别扭的。
比如说RedApple
,一个红色的苹果,当我们想使用“红色”这个泛型概念时,它需要实现Red
这个接口;而当我们想使用“苹果”这个概念时,它又需要实现Apple
这个接口。某天当我们想使用“类球形”这个概念时,它又要实现RoundLike
接口吗?
当接口一个又一个的出现时,有人会说,干脆我们到处传递Object
吧,用的时候再down_cast成具体的类型。于是我们又回到了void*
的时代。
尤其是,有些类型我们是没有办法改的,比如三方库中定义的类型,比如内置类型。这些情况下,继承就无能为力了。
Duck Typing和Template
如果一个东西,走路像鸭子,叫声也像鸭子,那么它就是鸭子。换句话说,如果一个东西,满足我们对鸭子的所有要求,那么它就是鸭子。如果一个T
,满足我们对X
的所有要求,那么它就是X
。这就是duck typing,即鸭子类型。
Python中大量应用了duck typing:
1 | class RedApple: |
在map_by_color
中,我们对items
有两项要求:
- 可遍历。
- 其中每个元素都有
color
方法。
但不要求items
或其中每个item
继承自哪个特定的接口。
这也是Type Erasure,但明显比继承来得更自由。当然自由都是有代价的,duck typing的代价就是它的运行时性能损失。Python中每个对象都会保留自己的类型信息,在调用时进行动态绑定。Go的interface
有着类似的用法,也有着类似的优缺点。
C++的模板也是一种duck typing:
1 | template <typename C> |
这里面有个模板参数C
,我们对它的要求是:
- 可遍历,具体来说是支持
begin(container)
和end(container)
两种API。 - 遍历出来的每个元素有
T Color() const
方法,且T
与Color
类型有合适的operator==
函数存在。
所有满足这个条件的C
都可以作为CountByColor
的参数类型。
当然C++的模板与Python的duck typing还是有很大区别的,因为它并没有真的擦除掉元素类型:C
是CountByColor
原型的一部分。这样我们其实一直都保留着元素的具体类型信息,好处:
- 完整的类型安全性,没有任何环节丢掉了类型信息。
- 因此不需要动态绑定,所有环节都是静态的,没有运行时性能损失。
但也有坏处:
- 模板类型会作为模板函数或模板类的原型的一部分,即
vector<int>
和vector<double>
是两个类型,没办法用一个类型来表示,也就没办法实现出上面Python例子中的map_by_color
函数。 - 每次用不同的参数类型来实例化模板时,都会新生成一份代码,导致编译出来的二进制文件很大。
C++中结合继承与Template的Type Erasure
在C++中我们可以结合继承与Template,实现出一种Type Erasure,它既有duck typing的优点,又可以将不同类型用同一种类型表示。
假设我们现在要重新设计上面的Counter
接口,首先我们定义一个内部的基类,Counter
的每个方法都对应它的一个虚函数:
1 | class CounterBase { |
接下来我们使用模板实现一个通用的子类:
1 | template <typename T> |
最后我们还要定义一个Counter
类型,但它不需要有任何的虚函数,也不需要作为任何类型的基类:
1 | class Counter { |
然后我们就可以使用Counter
来表示所有满足条件的类型了:
1 | Counter c1(ClassA{}); |
对于没有Increase
、Decrease
、Count
接口的类型,比如内置类型int
,我们还可以特化模板来满足要求:
1 | template <> |
然后我们就可以写:
1 | Counter c = 5; |
是不是很赞?
C++中Type Erasure的例子
std::shared_ptr
我们知道std::shared_ptr
的Deleter不是std::shared_ptr
类型的一部分(参见为什么unique_ptr的Deleter是模板类型参数,而shared_ptr的Deleter不是),这给使用者带来了很多好处(相比std::unique_ptr
):
- 对于
std::shared_ptr<T>
,使用者不需要知道T
的完整类型(当然创建者需要)。 - 两个
std::shared_ptr<T>
对象类型相同,可以相互赋值,即使它们的Deleter类型不同。 - 销毁
T
使用的Deleter永远是来自创建std::shared_ptr
的编译单元,跨DLL和so使用时不会有销毁问题。
它的秘诀就是Type Erasure。参考clang的实现,std::shared_ptr
只有两个成员变量:
1 | template <typename _Tp> |
其中__shared_weak_count
的定义为:
1 | class __shared_weak_count; |
可以看到不包含Deleter的类型。实际上构造的类型是它的子类__shared_ptr_pointer
:
1 | template <class _Tp, class _Dp, class _Alloc> |
具体的实现略。可以看到这里就使用了我们上面提到的继承与Template结合的方法。
std::function
std::function
中使用了一个基类__base
:
1 | template<class _Rp, class ..._ArgTypes> |
同样地,具体的子类通过模板来保留类型信息,而通过基类来实现统一的存储与调用。
boost::any
boost::any
是非常典型的应用了Type Erasure方法的类型。它允许你用一种类型来保存任何类型的对象,且通过type_info
方法返回具体的对象类型。这样我们可以使用一个boost::any
的容器保存任意类型的对象。它的实现很短,只有313行,很值得看一下。
std::any
std::any
是C++17引入的新类型,与boost::any
的接口几乎完全相同,区别在于,它使用了SBO(Small Buffer Optimization)方法,可以把小对象直接构造在类型内部,性能更好。
基于Type Erasure实现Unified Call Syntax
假设我们想实现一个接口类型Fooable
,它有一个方法foo
,使得Fooable::foo
和foo(Fooable)
都可以用来表示T::foo
和foo(T)
两种调用方式,即:
1 | struct Member { |
第一步我们先定义基类:
1 | class Storage { |
第二步定义模板子类:
1 | template <typename T, bool HasMemberFoo = has_member_foo<T>::value> |
其中has_member_foo
是用来判断T::foo
是否存在的辅助类型:
1 | template <typename T, typename = void> |
当T::foo()
是个合法的表达式时,has_member_foo<T>::value
就是true
,否则就是false
。
然后我们为false
准备一个特化版本:
1 | template <typename T> |
最后实现Fooable
类型:
1 | class Fooable { |
什么时候使用Type Erasure
简单来说,如果你有下面两个需求,你可能是需要Type Erasure的:
- 你需要用同一种方式处理不同的类型。
- 你需要用同一种类型或容器保存不同类型的对象。
然而在很多情况下,你可能只需要用std::shared_ptr
或std::function
就能达到这个目的,这个时候就不需要自己实现Type Erasure了。