Generic:Traits on Steroids
以前我们介绍了traits的一些基本用法,大家感觉如何?这次我们接着介绍traits的一些更为有趣的用途,让大家兴奋一下(Steroid是类固醇,常见的兴奋剂)。
在上期的Generic<Programming>[1]中,我们讨论了traits模板和traits类。这篇文章进一步讨论traits对象和全层次(hierarchy-wide)traits。
Traits技术很有用,但是什么时候你需要这种非凡的灵活性呢?如果你用了traits,你怎么才能避免手工向现有类层次中的大量的类添加traits 的苦差事呢?这篇文章以上一次的SmartPtr为例,解答这些问题。特别是介绍了全层次(hierarchy-wide)traits,一项非常 cool的C++新技术,可以让你一下子为整个类层次而不仅是单个类定义traits。
回到SMARTPTR
上一次的专栏里介绍了一个smart pointer,它可以根据客户对模板实例化的方式不同而用于单线程或多线程的代码中。再来看一下SmartPtr的定义:
template <class T, class RCTraits = RefCountingTraits<T> >
class SmartPtr
{
...
};
用RefCountingTraits可以对SmartPtr进行定制,以适应不同类型T所使用的引用计数的语法和语义。如果你要在单线程代码中用 SmartPtr,RefCountingTraits就可以了。否则,你必须另外提供一个traits类(MtRefCountingTraits)作 为第二个模板参数。MtRefCountingTraits保证在多线程情况下,引用计数是安全的。
class MtRefCountingTraits
{
static void Refer(Widget* p)
{
// serialize access
Sentry s(lock_);
p->AddReference();
}
static void Unrefer(Widget* p)
{
// serialize access
Sentry s(lock_);
if (p->RemoveReference() == 0)
delete p;
}
private:
static Lock lock_;
};
对于单线程的widget,客户代码可以用SmartPtr<Widget>,对于多线程的widget,可以用SmartPtr< Widget, MtRefCountingTraits>。如果没有上一篇文章最后留下的那个问题,事情就这样简单。那个问题是:在多线程版的SmartPtr 里,哪一部分还是低效的?
正如很多读者指出的那样,问题在于MtRefCountingTraits用了类级别的加锁操作。Herb Sutter形象地说:Static lock, bad juju!【juju,符咒】当你进行串行化的操作时,比如MtRefCountingTraits::Refer(),类级别的锁会把 MtRefCountingTraits类的所有对象都锁住,因为lock_是所有MtRefCountingTraits实例共享的静态变量。
如果你有很多线程频繁的操作widget的smart pointer,这可能会成为程序低效的一个根源。原本可能完全无关的线程在复制SmartPtr<Widget, MtRefCountingTraits>对象时,也必须排队等待。
在对象级别上加锁是解决这个问题的一个方法。要使用对象级别上的锁,只需把MtRefCountingTraits的成员lock_改成非静态的普通成员 变量,就可以对每一个对象单独加锁。但是这个方法的缺点是每个对象都因为增加了一个lock_变量而变大了。让我们来实现对象级别加锁的smart pointer。
TRAITS对象
当我们把对象级别加锁的方法运用到SmartPtr上时,我们遇到了一个问题。让我们再来看一下SmartPtr的析构函数的定义:
template <class T, class RCTraits = RefCountingTraits<T> >
class SmartPtr
{
private:
T* pointee_;
public:
...
~SmartPtr()
{
RCTraits::Unrefer(pointee_);
}
};
正如你所看到的那样,根本没有RCTraits的对象。SmartPtr的析构函数用静态函数的语法调用RCTraits::Unrefer()。 SmartPtr把RCTraits作为只有两个静态函数的包装来使用。现在traits类需要保存一些状态,所以我们开始讨论如何保存traits对 象。显然,保存traits对象的地方就在SmartPtr对象里,因此我们可以这样修改代码:
template <class T, class RCTraits = RefCountingTraits<T> >
class SmartPtr
{
private:
T* pointee_;
RCTraits rcTraits_;
public:
...
~SmartPtr()
{
rcTraits_.Unrefer(pointee_);
}
};
现在,SmartPtr拥有了一个Lock对象,并使用这个对象完成对象级别的加锁操作,这正是我们想要的。属于不同线程的SmartPtr对象不再共享任何数据,因此不会有任何同步的问题。问题解决了。
然而,SmartPtr变得更大了。“这是显然的,”我听到你说,“我们首先要保证的就是多线程的SmartPtr拥有一个Lock对象。”但是,不仅仅是多线程SmartPtr变大了,单线程的SmartPtr也变大了,尽管没有任何附加的数据(回忆一下,RefCountingTraits没有任何数 据成员)。这是为什么呢?因为C++中空对象的大小也不是0。这条规则在C++语言的很多地方都是合理的。(比如,如果有大小为0的对象的话,你怎样才能 建立这样的对象的数组?)
不管这条规则是否明智,至少在现在这种情况下,它对我们是不利的。SmartPtr<Something, RefCountingTraits<Something> >要比一个单纯的指向T的指针大,这是不应该的。现在单线程的SmartPtr的大小至少是sizeof(T*)+1,但通常由于字对齐和字节填充的原因,最终SmartPtr对象大小可能会在2*sizeof(T*)左右。如果你有很多单线程的SmartPtr,尺寸增加的代价会变得很显著,更不 用说通过传值方式传递SmartPtr的附加消耗了。
幸运的是,C++标准中有另一条关于对象大小的规则,可以帮助我们解决这个问题。这就是空基类优化(empty base optimization)。如果类D的基类B是空的(没有非静态数据成员),那么D对象中的B子对象的有效大小可以是0。这并没有违反前面那条规则,因 为B子对象被包含于D对象中;当然,如果你抽出一个单独的B对象时,它还是有非0的大小。你是否可以使用空基类优化取决于你的编译器,因为这条规则的实现是可选,而不是必需的。Metrowerks的Code Warrior 5.x和Microsoft Visual C++ 6.0都实现了空基类优化。还有其他地方也需要使用这个优化。(注:这几个编译器里所包含的Standard C++ Library中,在containers的实现时利用了空基类优化。每一个标准的container都聚合了一个allocator对象,缺省的 allocator通常是一个空类【编者:请参考《C++空成员优化》一文】。)
把空基类优化应用到前面的SmartPtr代码中,我们可以让SmartPtr继承RCTraits,而不是用聚合。通过这种方式,如果RCTraits是空的,编译器会通过优化去掉多余的空间;如果RCTraits不是空的,那么结果和聚合的情况一样。
我们应该用那种继承呢?private,protected,还是public?不要忘了这只是一种实现上的优化,而不是概念的变化。不管怎么说,SmartPtr不是一个RCTraits。因此,最好的选择是私有继承。
template <class T, class RCTraits = RefCountingTraits<T> >
class SmartPtr : private RCTraits
{
private:
T* pointee_;
public:
...
~SmartPtr()
{
RCTraits::Unrefer(pointee_);
}
};
这只是利用继承来优化对象大小的一个技巧。有趣的是,我们又回到了用两个冒号的写法,因为现在RCTraits是SmartPtr的基类。
当traits需要保持状态时,就需要用traits对象了。Traits对象可以是其他对象的一部分,也可以作为参数传递。当traits对象可能为空时,也许以可以考虑用继承的技巧来优化对象的内存布局,当然你的编译器要支持空基类优化。
定义:traits对象是traits类的一个实例。
Definition: A traits object is an instance of a traits class.
插曲
Traits模板, traits类, traits对象……当我们的讨论从纯粹的静态代码生成方式转变到具有状态的实体时,我们的表达方式也从最静态的方式(模板)发展到具有更多动态特性的方式(完整的traits对象)。Traits模板完全是一种编译时的机制;它们在编译结束前就已经消失了。在另一个极端,traits对象是具有状态和行 为的动态实体。
更进一步的动态化是使用多态traits和traits对象的指针或者引用。但那已经超出traits的范畴了。确切地说,多态traits是一个策略(Strategy)设计模式。[2]
使用能满足要求的任何一种traits机制,并且尽可能选择静态的方案。相对于运行时的解决方案,我们一般更倾向于选择编译时的解决方案。编译时的解决方案意味着:编译器会对代码进行更好的检查,并且往往生成的代码有更好的效率。当然,另一方面,动态【注:dynamism,这里一语双关,也可解释为活力,有生气】为生活带来情趣。
全层次TRAITS
Traits往往不是只用于单个类型,而是用于整个类层次。例如,引用计数的方法通常对于整个类层次都是一样的。如果不用对于每个类都手工添加 traits,而能够定义一个traits可以用于整个类层次,那就好了。但是,traits技术的基础模板对于继承是一无所知的。这怎么办呢?
也许一个好设计的首要准则是要有灵活性,不要局限于一种策略。解决设计问题就像攻打坚固的城堡:如果一个策略不行,最好就换另外一个。一个坏的策略可能也能解决问题,但是比其他方法代价更高。
根据这个想法,我们重新理一下思路。我们需要找一种在类型层次中保持不变的东西,用它来建立一个类模板。你猜那是什么?嵌套类(在类中定义的类)!除非你重新定义,嵌套类在继承过程中是不变的。嵌套类可以像其他符号一样被继承。这看上去是个值得一试的方法。为了能自动加上嵌入的类型定义,我们先做一个简单 的模板:
template <class T>
struct HierarchyRoot
{
// HierarchyId is a nested class
struct HierarchyId {};
};
比如说我们有一个以Shape为根的类层次(图1)。为了表示Shape是根,你可以让它继承Hierarchy<Shape>,如下所示。其他类不变。
图1 Shape层次
class Shape : public HierarchyRoot<Shape>
{
...
};
class Rectangle : public Shape
{
...
};
如果你想防止从Shape到HierarchyRoot<Shape>的隐式类型转换(通常也是不希望的),你可以这样定义Shape:
class Shape : private HierarchyRoot<Shape>
{
...
public:
using HierarchyRoot<Shape>::HierarchyId;
};
我们得到一个关键的结果:Rectangle::HierarchyId和Shape::HierarchyId是同样的类型。不论你直接或者间接地从Shape派生新类,只要你不重新定义符号HierarchyId,这个符号代表的类型就在整个继承体系中保持不变。
要设计一个使用全层次traits的SmartPtr和设计使用普通traits的SmartPtr一样简单。你只要用T::HierarchyId代替T就行了,象这样:
template <class T, class RCTraits = RefCountingTraits<typename T::HierarchyId> >
class SmartPtr
{
...
};
现在,假设在你的应用程序中有两个类层次关系:一个以Shape为根,另一个以Widget为根。象Shape一样,Widget从HierarchyRoot<Widget>继承。现在你可以这样为两个类层次特化RefCountingTraits:
template <>
class RefCountingTraits<Shape::HierarchyId>
{
...
};
template <>
class RefCountingTraits<Widget::HierarchyId>
{
...
};
就是这样,上面的traits可以正确的应用于在两个类继承体系中的类,甚至对还没有定义的类也没有问题。下面两节中将指出,全层次traits是相当灵活的。
定制全层次TRAITS
简单的traits可以为每个类型提供特化;全层次traits为每个类层次提供特化。有时候你可能遇到介于两者之间的情况:你为整个继承体系提供了traits模板,同时也要对体系中单独的一个或两个类型进行特化。
你可以这样定义traits模板来达到目的:
template <class HierarchyId>
class HierarchyTraits
{
... most general traits here ...
};
template <class T>
class Traits
: public HierarchyTraits<T::HierarchyId>
{
// empty body - inherits all symbols from base class
};
这个traits模板怎样工作呢?客户代码可以这样使用:Traits<Shape>,Traits<Circle>等。若想特 化整个Shape层次的traits,我们可特化HierarchyTraits<Shape::HierarchyId>。缺省情况下,因为Traits<T>继承HierarchyTraits<T::HierarchyId>,所有Shape的派生类会使用 HierarchyTraits<T::HierarchyId>里定义的traits。(我敢打赌,如果你跟踪所有这些符号的来源,你会得到很多乐趣。其实这很简单,HierarchyTraits对应于整个层次,Traits对应于每个类型。)
如果你想特化一个特定的类的traits,比如说Ellipse,你可以直接特化Traits模版:
template <>
class Traits<Ellipse>
{
... specialized stuff for Ellipse ...
};
你可以选择是否继承HierarchyTraits<Ellipse>。如果你只想重写一两个符号,你可以选择继承;如果你想完全重写Ellipse的traits,你可以选择不继承。这完全取决于你。
对于刚才提到的继承的使用还有一点说明:从动态多形的观点来看,事实上Traits<T>继承HierarchyTraits<T:: HierarchyId>是不恰当的,因为HierarchyTraits不是一个具有多态性的基类。我们在这里用继承是因为另一个理由:把继承作 为符号传递的工具,目的是让Traits<T>具有HierarchyTraits<T::HierarchyId>里定义的所有符号。继承不仅可以用来实现动态的多态性,也可以用来在编译时操作类型。
用这一节里介绍的Traits-HierarchyTraits方法,可以对类层次traits根据每个类型进行特化。在上面讨论的例子中, Traits<Rectangle>和Traits<Circle>用的是共同的HierarchyTraits< Shape::HierarchyId>,而Traits<Ellipse>用的是特化了的Traits< Ellipse>。实现这一切不需要做太多的手脚。
子层次的TRAITS
假设你要对于Shape的类层次的一个子层次重新定义traits,比如图中以Bitmap为根的子树(图2)。因此你需要特化Bitmap和它的直接或间接子类的traits。
为了达到这个目的,你可以让Bitmap同时继承Shape和HierarchyRoot<Bitmap>。然后你必须通过using语句来消除符号HierarchyId的二义性,像这样:
图2 Shape层次的Bitmap子层次
class Bitmap : public Shape,
public HierarchyRoot<Bitmap>
{
...
public:
using HierarchyRoot<Bitmap>::HierarchyId;
};
通过using语句,Bitmap类会优先使用HierarchyRoot<Bitmap>::HierarchyId,而不是 Shape::HierarchyId。这样你可以用Bitmap::HierarchyId来特化traits,并且Bitmap的子类也将用这个特 化,除非你为某个子层次又定义了不同的traits。
注意事项
全层次traits的最大的缺点是需要修改类层次中的基类,而你有时候不能做到这点。还好,你会得到一个编译错误("Class Widget does not define a type HierarchyId"),而不是运行时的错误。
你可以对于无意义类型(如void)特化全层次traits模板,这样可以在一定程度上解决这个问题。对于你不能修改的类层次,你可以用HierarchyTraits<void>。虽然不是很灵活,但在开发受阻时,也不失为一个可行的方法。
还有其它不用介入类层次来实现全层次traits的方法,但那些方法往往更脆弱,并且暴露出各种错误。我始终欢迎读者提出各种建议。
结论
当trait必须保持某些状态的时候,就需要用到traits对象。如果trait类的状态是可选的(有些traits有状态,有些没有),那么最好是通过继承的技巧,利用空基类优化(如果可能的话)。
只需要一点点手脚,你就可以定义全层次traits。通过这种方式,你只需为一个类层次写一次traits。全层次traits可以提供很大的灵活性,你可以对类层次中一个特定的类定义特别的traits,也可以对一个子层次定义traits。
全层次traits使用继承的方法比较怪异。继承不仅仅是实现运行时多态性的工具,也是编译时操纵类型的工具。C++把继承的两种特性混合在一起,有时候会引起误解。
然而,最好的消息是,全层次traits只用了模板的基本功能。就是说,即使你现在使用的编译器不那么符合标准,你还是可以用这个技术。
感谢
非常感谢Herb Sutter, 他花时间审阅了这篇文章并提出深刻的见解。