Generic<Programming>:类型和值之间的映射
Andrei Alexandrescu
在C++中,术语“转化”(conversion)描述的是从另外一个类型的值(value)获取一个类型(type)的值的过程。可是有时候你会需要一种不同类型的转化:可能是在你有一个类型时需要获取一个值,或是其它的类似情形。在C++中做这样的转化是不寻常的,因为类型域和值域之间隔有有一堵很严格的界线。可是,在一些特定的场合,你需要跨越这两个边界,本栏就是要讨论该怎么做到这个跨越。
映射整数为类型
一个对许多的generic programming编程风格非常有帮助的暴简单的模板:
template <int v>
struct Int2Type
{
enum { value = v };
};
对传递的每一个不同的常整型值,Int2Type“产生”一个不同的类型。这是因为不同的模板的实体(instantiation)是不同的类型,所以Int2Type<0>不同于Int2Type<1>等其它的类型的。此外,产生类型的值被“存放”在枚举(enum)的成员值里面。
不管在任何时候,只要你需要快速“类型化”(typify)一个整型常数时,你都可以使用Int2Type。比如这个例子,你要设计一个NiftyContainer类模板。
template <typename T>
class NiftyContainer
{
...
};
NiftyContainer存储了指向T的指针。在NiftyContainer的一些成员函数(member functions)中,你需要克隆类型 T的对象,如果T是一个非多态的类型,你可能会这样说:
T* pSomeObj = ...;
T* pNewObj = new T(*pSomeObj);
对于T是多态类型的情形,情况要更为复杂一些,那么我们假定你建立了这样的规则,所有的使用于NiftyContainer 的多态类型必须定义一个Clone虚拟函数(virtual function)。那么你就可以像这样来克隆对象:
T* pNewObj = pSomeObj->Clone();
因为你的容器(container)必须能够接受这两种类型,所以你必须实现两种克隆算法并在编译时刻选择适当的一个。那么不管通过NiftyContainer的布尔(非类型,non-type)模板参数传递的类型是不是多态的,你都要和它交互,而且还要依赖程序员给它传递的是正确的标识。
template <typename T, bool isPolymorphicWithClone>
class NiftyContainer
{
...
};
NiftyContainer<Widget, true> widgetBag;
NiftyContainer<double, false> numberBag;
如果你存储在NiftyContainer里的类型不是多态的,那么你就可以对NiftyContainer的许多成员函数进行优化处理,因为可以借助于常量的对象大小(constant object size)和值语义(value semantics)。在所有的这些成员函数中,你需要选择一个算法,或是另外一个依赖于模板参数isPolymorphic的算法。
乍一看,似乎只用一个if语句就可以了。
template <typename T, bool isPolymorphic>
class NiftyContainer
{
...
void DoSomething(T* pObj)
{
if (isPolymorphic)
{
... polymorphic algorithm ...
}
else
{
... non-polymorphic algorithm ...
}
}
};
问题是编译器是不会让你摆脱这些代码的。例如,如果多态算法使用了pObj->Clone,那么NiftyContainer::DoSomething就不会那些任何一个没有定义Clone成员函数的类型而编译。的确,看起来在编译时刻要执行哪一个if语句分支是很明显的,但是这不关编译器的事,编译器仍然坚持不懈地尽心尽职地编译这两个分支,即使优化器最终会消除这些废弃代码(dead code)。如果你试图调用NiftyContainer<int, false>的DoSomething函数的话,编译器就会停留在pObj->Clone的调用之处,这是怎么回事?
等等,问题还多着呢。如果T是一个多态类型,那么代码将又一次不能通过编译了。如果T将它的copy constructor设为private和protected,禁止外部对其访问——作为一个行为良好的多态类,应该如此。那么,如果非多态的代码分支要做new T(*pObj),则代码不能编译通过。
如果编译器不为编译废弃代码费神那多好啊,但无望的期望不是解决之道,那么怎样才是一个满意的解决方案呢?
已经证实,有许多的解决办法。Int2Type就提供了一个非常精巧的解决方案。对应于isPolymorphic的值为true和false,Int2Type可以将特定的布尔值isPolymorphic转化为两个不同的类型。那么你就可以通过简单的重载(overloading)来使用Int2Type<isPolymorphic>了,搞定!
“整型类型化”(integral typifying)风格的原型(incarnation)如下所示:
template <typename T, bool isPolymorphic>
class NiftyContainer
{
private:
void DoSomething(T* pObj, Int2Type<true>)
{
... polymorphic algorithm ...
}
void DoSomething(T* pObj, Int2Type<false>)
{
... non-polymorphic algorithm ...
}
public:
void DoSomething(T* pObj)
{
DoSomething(pObj, Int2Type<isPolymorphic>());
}
};
这个代码简单扼要,DoSomething调用重载了的私有成员函数,根据isPolymorphic的值,两个私有重载函数之一被调用,从而完成了分支。这里,类型Int2Type<isPolymorphic>的虚拟临时变量没有被用到,它只是为传递类型信息之用。
不要太快了,天行者!
看到上面的方法,你可能认为还有更为巧妙的解决之道,可以使用比如template specialization这样的技巧。为什么必须用虚拟的临时变量,一定还有更好的方式。但是,令人惊奇的是,在简单性、通用性和效率上,Int2Type是很难打败的。
一个可能的尝试是,根据任意的T及isPolymorphic的两个可能的值,对NiftyContainer::DoSomething作特殊处理。这不就是partial template specialization的拿手戏吗?
template <typename T>
void NiftyContainer<T, true>::DoSomething(T* pObj)
{
... polymorphic algorithm ...
}
template <typename T>
void NiftyContainer<T, false>::DoSomething(T* pObj)
{
... non-polymorphic algorithm ...
}
看上去很美,可是啊呀,不好,它是不合法的。没有这样的对一个类模板的成员函数进行partial specialization的方式,你可以对整个NiftyContainer作partial specialization:
template <typename T>
class NiftyContainer<T, false>
{
... non-polymorphic NiftyContainer ...
};
你也可以对整个DoSomething作specialization:
template <>
void NiftyContainer<int, false>::DoSomething(int* pObj)
{
... non-polymorphic algorithm ...
}
但奇怪的是,在[1]之间,你不能做任何事。
另一个办法可能是引入traits技术[2],并在NiftyContainer的外部来实现DoSomething(在traits类中),但把DoSomething分开来实现显得有些笨拙了。
第三个办法仍然试图用traits技术,但把实现都放在一起,这就要在NiftyContainer里面把traits定义为私有的内部类。总之,这是可以的,但在你设法实现的时候,你就会认识到基于Int2Type的风格有多好。而且这种风格最好的地方可能就在于:在实际应用中,你可以把这个小小的Int2Type模板放在库中,并把它的预期使用记录在案。
类型到类型的映射
考虑下面这个函数:
template <class T, class U>
T* Create(const U& arg)
{
return new T(arg);
}
Create通过传递一个参数给T的构造函数(constructor)而产生了一个新的对象。
现在假设在你的应用中用这么一个规则:类型Widget的对象是遗留下来的代码,在构造时必须要带两个参数,第二个参数是一个像-1这样的固定值。在所有派生自Widget的类中你不会碰到什么问题。
你要怎么对Create作特殊化处理,才能让它在处理Widget时,不同于所有的其它类型呢?你是不可以对函数作partial specialization的,也就是说,你不能像这样做:
// Illegal code - don't try this at home
template <class U>
Widget* Create<Widget, U>(const U& arg)
{
return new Widget(arg, -1);
}
由于缺乏函数的partial specialization,我们所拥有的唯一工具,还是重载。可以传递一个类型T的虚拟对象,并重载。
// An implementation of Create relying on overloading
template <class T, class U>
T* Create(const U& arg, T)
{
return new T(arg);
}
template <class U>
Widget* Create(const U& arg, Widget)
{
return new Widget(arg, -1);
}
// Use Create()
String* pStr = Create("Hello", String());
Widget* pW = Create(100, Widget());
Create的第二个参数只是为作选择适当的重载函数之用,这也是这种自创风格的一个问题所在:你把时间浪费在建构一个你不使用的强类型的复杂对象上,即使优化器可以帮助你,但如果Widget屏蔽掉default construtor的话,那优化器也爱莫能助了。
The proverbial extra level of indirection can help here, too. (不好意思,这句不知道怎么翻译)一个想法是:传递T*而不是T来作为虚拟的模板参数。在运行时刻,总是可以传递空指针的,这在构造时的代价是相当低廉的。
template <class T, class U>
T* Create(const U& arg, T*)
{
return new T(arg);
}
template <class U>
Widget* Create(const U& arg, Widget*)
{
return new Widget(arg, -1);
}
// Use Create()
String* pStr = Create("Hello", (String*)0);
Widget* pW = Create(100, (Widget*)0);
这种方式对于Create的使用者来说,是最具迷惑性的。为了保持这种解决风格,我们可以使用同Int2Type有一些类似的模板。
template <typename T>
struct Type2Type
{
typedef T OriginalType;
};
现在你可以这样写了:
template <class T, class U>
T* Create(const U& arg, Type2Type<T>)
{
return new T(arg);
}
template <class U>
Widget* Create(const U& arg, Type2Type<Widget>)
{
return new Widget(arg, -1);
}
// Use Create()
String* pStr = Create("Hello", Type2Type<String>());
Widget* pW = Create(100, Type2Type<Widget>());
比起其它的解决方案来说,这当然更加说明问题。当然,你又得在库里面包含Type2Type并将在它预期使用的地方记录在案。
检查可转化性和继承
在实现模板函数和模板类时,经常会碰到这样的问题:给出两个强类型B和D,你怎么检查D是不是派生自B的呢?
在对通用库作进一步的优化时,在编译时刻发现这样的关系是一个关键。在一个通用函数里,如果一个类实现了一个特定的接口,你就可以借助于一个优化的算法,而不必对其作一个dynamic_cast。
检查派生关系借助于一个更为通用的机制,那就是检查可转化性。同时,我们还要解决这样一个更为普遍的问题:要怎样才能检查一个强类型T是否支持自动转化为一个强类型U?
这个问题有一个解决办法,它可借助于sizeof。(你可能会想,sizeof不就是用作memset的参数吗,是吧?)sizeof有相当多的用途,因为你可以将sizeof使用到任何一个表达式(expression)中,在运行时刻,不管有多复杂,不用计较表达式的值是多少,sizeof总会返回表达式的大小(size)。这就意味着sizeof知道重载、模板实体化和转化的规则——每一个参与C++表达式的东东。实际上,sizeof是一个推导表达式类型的功能齐备的工具。最终,sizeof撇开表达式并返回表达式结果的大小[3]。
可转化性检查的想法借助于对重载函数使用sizeof。你可以巧妙地对一个函数提供两个重载:一个接受可以转化为U的类型,另一个则可以接受任何类型。这样你就可以调用以T为参数的重载函数了,这里T是你想要的可以转化为U 的类型。如果传入的参数为U的函数被调用,则T就转化为U;如果“退化”(fallback)函数被调用,则T不转化为U。
为了检查是哪一个函数被调用了,你可以让两个重载函数返回不同大小的类型,并用sizeof对其进行鉴别。只要是不同大小的类型,它们就难逃法眼。
首先创建两个不同大小的类型(显然,char和long double有不同的大小,但标准没有作此担保),一个简单的代码摘要如下所示:
typedef char Small;
struct Big { char dummy[2]; };
由定义可知,sizeof(Small)大小为1,Big的大小是未知的,但肯定是大于1的,对于我们来说,能保证这个已经足够了。
下一步,需要做两个重载,一个接受U并返回一个Small。
Small Test(U);
那你要怎样来写一个可以接受任何“东东”的函数呢?模板解决不了这个问题,因为模板会寻找最匹配的一个,由此而隐藏了转化。我们需要的是一个比自动转化要更差一些的匹配。快速浏览一下应用于给定了省略号的函数调用的转化规则。它就在列表的最后面,就是最差的那个,这恰恰就是我们所需要的。
Big Test(...);
(以一个C++对象来调用一个带省略符的函数会产生未知的结果,可谁在乎呢?又不会有人真的调用这样的函数,这种函数甚至都不会被实现。)
现在我要对Test的调用使用sizeof,给它传递一个T:
const bool convExists =
sizeof(Test(T())) == sizeof(Small);
就是它!Test调用产生了一个缺省构造对象T,然后sizeof提取了表达式的结果的大小。它可能是sizeof(Small),也可能是sizeof(Big),这要看编译器是否找到了转化的可能。
还有一个小问题,如果T把它的缺省构造函数作为私有成员会怎么样?在这种情况下,T的表达式将不能通过编译,我们所构建的所有的这一切都是白费。幸好,这又一个简单的解决方案——只要使用一个能返回T的像稻草人一样没用的函数就好了。这样,一切问题统统解决!
T MakeT();
const bool convExists =
sizeof(Test(MakeT())) == sizeof(Small);
(By the way, isn't it nifty just how much you can do with functions, like MakeT and Test, which not only don't do anything, but which don't even really exist at all?)
(顺便说一下,就像MakeT和Test一样,这类函数是好是坏取决于你用它来做什么,它不但什么都不做,而且甚至根本不存在的?)
现在我们可以让它工作了,把一切都封装到一个类模板中,隐藏所有关于类型推导的细节,只把结果暴露出来。
template <class T, class U>
class Conversion
{
typedef char Small;
struct Big { char dummy[2]; };
static Small Test(U);
static Big Test(...);
T MakeT();
public:
enum { exists =
sizeof(Test(MakeT())) == sizeof(Small) };
};
现在你可以这样来测试Conversion模板类了。
int main()
{
using namespace std;
cout
<< Conversion<double, int>::exists << ' '
<< Conversion<char, char*>::exists << ' '
<< Conversion<size_t, vector<int> >::exists << ' ';
}
这个小程序的打印结果为“1 0 0”。我们注意到,尽管std::vector实现了一个带参数为size_t的构造函数,转化测试返回的结果还是0,因为构造函数是显式的(explicit)。
我们可以在Conversion中实现这样的两个或是更多的常量(constants)。
exists2Way表示在T和U之间是否可以相互转化。例如,int和double就是可以相互转化的情况,但是各种自定义类型也可以实现这样的相互转化。
sameType表示T和U是否是同种类型。
template <class T, class U>
class Conversion
{
... as above ...
enum { exists2Way = exists &&
Conversion<U, T>::exists };
enum { sameType = false };
};
我们通过对Conversion作partial specialization来实现sameType。
template <class T>
class Conversion<T, T>
{
public:
enum { exists = 1, exists2Way = 1, sameType = 1 };
};
那么,怎么来做派生关系的检查呢?最漂亮的地方就在于此,只要你把转化处理好了,派生关系的检查就简单了。
#define SUPERSUBCLASS(B, D) (Conversion<const D*, const B*>::exists && !Conversion<const B*, const void*>::sameType)
是不是一目了然了?可能还有一点点的迷糊。SUPERSUBCLASS(B, D)判断D公有派生自B是否为真,或者B和D代表的是同种类型。通过判别一个const D*到const B*的可转化性,SUPERSUBCLASS就可以作出这样的判断。const D*隐式转化为const B*只有三种情况:
B和D是同种类型;
B是D的明确的公有基类;
B是空类型。
通过第二个测试可以排除最后一种情形。在实际应用中,第一种情形(B和D为同种类型)的测试是很有用的。因为出于实际考虑,你通常都会考虑一个类是它自己的超类。如果你需要一个更严格的测试,可以这样写:
#define SUPERSUBCLASS_STRICT(B, D) (SUPERSUBCLASS(B, D) && !Conversion<const B, const D>::sameType)
为什么这些代码中都加上了const修饰呢?原因是你总不想因为const的问题而让转化测试失败吧。所以,在每个地方都使用了const,如果模板代码使用了两次const(对一个已经是const的类型使用const),则第二个const将被忽略掉。简而言之,在SUPERSUBCLASS中使用const是基于安全考虑的。
Why SUPERSUBCLASS and not the cuter BASE_OF or INHERITS? For a very practical reason: with INHERITS(B, D), I kept forgetting which way the test works — does it test whether B inherits D or vice versa? SUPERSUBCLASS(B, D) makes it clearer (at least for me) which one is first and which one is second.
为什么用SUPERSUBCLASS而不是更为贴切的BASE_OF或是INHERITS呢?为了一个实际的原因:使用INHERITS(B, D),我会经常忘记检测的运作方式——它测试的是B派生自D呢,还是D派生自B?而对这个问题(谁是第一个谁是第二个),SUPERSUBCLASS(B, D)说明得更为清楚一些(至少对于我来说)。
小结
在这里介绍这三种风格,一个最重要的地方就是它们是可重用的。你可以把它们写在一个库里面,并可以让程序员们使用它们,而不是要他们掌握这其中复杂的内部实现工作。
nontrivial技术的可重用性是很重要的,要人们记住一个复杂的技术,即使这个技术可以用来帮助他们的实际工作更为简化一些,但如果这个技术稍显麻烦,他们也是不会用的。给人们一个简单的黑盒,它可以带来一些有用的惊奇,人们是会喜欢它并使用它的,因为这是一个“自由”的方式。
Int2Type,Type2Type,特别是Conversion都属于一个通用的工具库。通过使用重要的编译时刻的类型推导,它们扩展了程序员的编译时刻的能力。
致谢
如果Clint Eastwood问我:“你感觉幸运吗?”,我的回答肯定为“是”。这是我这个系列的第三篇文章,这得益于 Herb Sutter的直接的关注和重要的建议。感谢日本的Tomio Hoshida发现了一个bug并做了一些有深刻见解的建议。
注:这篇文章引用自Andrei Alexandrescu的即将出版的一本书,书名暂定为《Modern C++ Design》(Addison-Wesley, 2001)。(译注:这本书已经出版了,要是能早日拜读,那有多爽啊)
注释
[1]C++对函数的partial specialization支持是没有概念上的障碍的,这是一个很有价值的特性。
[2] Andrei Alexandrescu. "Traits: The else-if-then of types", C++ Report (April 2000).
[3]建议在C++中加入一个类型of操作符,也就是一个返回一个表达式的类型的操作符。有了这么一个操作符,将会使编写模板代码更加易于编写、易于理解。GNU C++已经把typeof作为一个扩展实现了。明显地,typeof和sizeof有同样的障碍,因为无论如何,sizeof都必须计算类型。[参阅Bill Gibbons在CUJ上的一篇文章,他介绍了一个漂亮的方法,这个方法用于在现在的Standard C++里创造一个(几乎是)天然的typeof操作符。]