泛型<编程>:可识别联合(Discriminated Unions)(3)
Andrei Alexandrescu
在进入今天的主题之前,这里有些你可能感兴趣的新闻。
不久前,Jonathan, H,Lundquist和Mat Marcus各自把部分Loki库改写使之兼容于Visual C++ 6。他们的实现是某种理念上的实验,还没有达到完善的地步。大体上由于各种编译器相关的问题,在现在,Loki的作用更是人们设计时灵感的源泉,而不是作为一个“包装好的,有可伸缩性的”产品能够直接放到你的程序里。典型的Loki用户是一个机智勇敢的开发者,他在遇到错误信息时不会藏到桌子底下去——即使当那个错误信息长到足以让网络服务器“缓冲区溢出”。
但是,这种情况在你阅读本文时正在改变。
当Rani Sharoni写信寄给我一个完整的Loki对于Visual C++ 7.0的移植时,我没有一点惊讶。里面除了几处地方外,库使用的语法保持不变,这明显是因为Visual C++ 7.0(Visual C++.NET)不支持部分模板特化。
如果你用某个编译器,也许你也想要把Loki移植到你工作的平台上[1]。(译注:这篇文章发表于02年8月,现在的Loki已经兼容于VC6,VC7和BCB)
其他相关新闻——现在正在进行把Loki加入到Boost的工作,这个举动能够把两项分头进行的工作集中起来。Loki::SmartPtr提交工作已经在着手进行了;正如伴随着聪明指针总有许多讨论,它同时也带来空前的工作效率的提高。同时。Joe Swatosh已经提交了非常受欢迎的ScopeGuard[2],它实际上由Petru Marinean和大家一起完成,也将被包含在Boost中。
总之一句话,现在是C++飞速发展的时代。最终这意味着你能花更少的力气,把更多的精力放到高层次的设计中。
Variant:待完成的工作
在“返型编程”的前两个部分,我们定义了可识别联合并定义了Variant类的核心部分。这个核心类通过提供主要的基本功能能让用户在Variant中存储一个对象,在Variant中的对象类型查询,和通过类型安全的手段得到那个对象。此外,Variant是通用的和非常高效的。
但这些功能还不足以让人兴奋。当然,兼容性是好东西(但是如果你用陈旧的,不兼容的编译器,光Variant通用有什么用?)同样的,高效肯定没有坏处。但光这些还不够——当你写代码用到它时,Variant的通用性和高效率不能提供更多的功能。重点是,更多的功能会非常有帮助。今天我们集中于为Variant增加一些强大功能——一些从来没有出现在类似实现中的功能。
但在此之前,先讲几句题外话。
最终这还是发生了——而且还会再次发生
生活中不可避免的是无论你怎么努力,你也不能取悦每个人。这就是为什么,自从《Modern C++ Design》上了书店的书架。我一直在等着类似于“你的书糟透了”的评论,毫无疑问,或早或晚我能等到。达摩克利斯之剑挂在我头上已经一年多,最终还是化作亚马逊上的书评。掉下来了。
照那个评论的话来说,Modern C++ Design是“典型的不切实际,假充内行,过于理论的东西,是那些没有实际工作经验的和必须每月硬挤出些东西给C++ Report的人写的/看的”。这句话同时包含了我——作为作者,和你们自己,因为你们现在正在看。也许我们都应该马上停下来。
但也许没必要停下来。我本来想看到的是更有实际内容的“你的书糟透了”的评论;这个评论太容易驳倒了。第一件事,这本书的写作完全基于作者每天几个小时的C++实际工作——而且把实际工作中的许多概念放在书里。
第二件事——这也是为什么我想要把所有的东西组织起来——自从我每两个月写“泛型<编程>”的一部分之后,我真的没感到有什么压力。但是这里有件事情我需要征求你们的意见。
可能让那位书评作者烦恼的是,我决定写第二本书,暂时名为《The return of C++》(如果你还把Herb Sutter和我和写的新书《C++ Coding Standards》也算上,那位作者真的要郁闷了)。
《The return of C++》不是《Modern C++ Design》的续作,续作可能质量不高,尤其是当续作只是改头换面重复前作的思想,而缺乏一些新的灵感和想法。同时,我也不想这本新书是我自己文章的集合。我相信如果你付钱买这本书,你应该读到在网上找不到的东西。
这最终意味着自由发表新想法和利益相冲突。当想到一些很酷的主意时,困难的决定是,这应该是一篇新文章的部分,还是新书的部分?这阻止了我写一些让人兴奋的东西,比如契约设计,观察者(Observer)模式的泛型实现,复合对象,或完整的C++错误处理。
如果你对解决这个冲突有什么想法,不要犹豫,写信给我。
幸运的是,总有许多很酷的新主意适合用文章形式表现。比如,你很快能看到为什么现有的用C++的通用的排序和查找不是很完美,怎样能得到比大多数库所能提供的更好的效率表现。用那个程序查找时只用80%的时间(当不用I/O时),就是说如果你不用I/O你能够从那篇文章中获得0.8的利益。
就此打住,要不然就会让有些人说:“啊哈!这个人没什么花头了,所以在那凑字”事实是,优秀的长期的编程专栏作者,象Al Stevens, Michael Swaine,或我们亲爱的Bobby Schmidt,在他们专栏里也有题外话。你知道吗?我爱读这些。就象读俄国小说:不能让人耳目一新,但就是爱读。
回到Variant
我们用几个代码实例来快速回忆我们的Variant实现现在做到哪一步。
Variant对象只能存储属于一个类型集合的一个对象。通过模板构造函数,你能用那些类型中的一个类型的对象来初始化Variant。例如:
//数据库字段,能够包含一个字符串,一个整型,或一个双精度浮点数
typedef Variant<cons<string, int, double>::type>
DatabaseField;
//构造三个数据库字段对象——一个字符串一个整型和一个双精度浮点数
DatabaseField fld1(string("Hello, world");
DatabaseField fld2(25);
DatabaseField fld3(3.14);
有了一个Variant对象,你能够获取它的类型:
assert(fld2.TypeId() == typeid(int));
你能够存取在Variant内部的实际的类型化的值:
string* p fld1.GetPtr<string>();
//我们知道fld1包含一个字符串
assert(p != 0);
//现在它是"Hello, world!"
Variant有传统的构造函数并且当调用析构函数时执行清理工作
//默认构造函数,用缺省值初始化——
//用类型串的第一个类型来构造对象
DatabaseField fld5;
assert(fld5.GetType() == typeid(string));
assert(fld5.GetPtr<string>()->empty());
DatabaseField fld4 = fld1; //拷贝构造
assert(fld4.GetType() == typeid(string));
assert(*fld4.GetPtr<string>() == "Hello, world!");
好,现在我们来实现一些重要的功能函数。
首先,我们解决存取Variant内的类型化的值。你也看到了,GetPtr不错,但基于它的方法不能提供足够的功能。看看这个:
void ProcessField(const DatabaseField& fld)
{
if (const string* pS = fld.GetPtr<string>())
{
...
}
else if (const int* pI = fld.GetPtr<int>())
{
...
}
else if (const double* pD = fld.GetPtr<double>())
{
...
}
}
你想用Variant做任何事情,你必须显式测试它可能包含的每个类型并分别操作每个类型。这样很难增加程序规模——小小的if-else链会很快埋葬掉你做的实际工作。更糟的是,当你在你的Variant增加新的类型时(比如在DatabaseField定义中增加Memo类型)编译器不会拍拍你的肩膀,提醒你在每个else-if测试链最后增加一个新的对应的else-if,你得自己动手,编译器象一个暴徒——你最好让它站在你这一边。
访问(Visitation)
我们怎么实现这些函数而不用对Variants执行令人作呕的类型查询?
一个方案是在Variant的模拟vtable中增加更多的函数。,就象TypeId,Clone等等。这样目的能够达到,但会破坏Variant的与应用程序无关的特性。我们想要的是通用的,可扩展的Variant,不是每次使用时都要修改的东西。
你也不会想要继承Variant;它是一个值的类型,不是一个引用类型(reference type)。
在这里访问是更合适的技术。访问者(Visitor)[4]是一个设计模式,它允许你在一个类层次中增加一个新的操作,而不用修改这个类层次。
访问怎么工作?完整的解释超出这篇文章的范围,但如果这个概念你不熟悉,高度建议你在[4]里看一下访问者。访问者模式在许多有趣的地方都适用,,而且至少能让你看到一些类层次不具备被访问能力其实是严重的设计错误[5]。
在Variant的案例中,类层次是所存储的类型的集合。他们的确没有形成一个类层次,但我们现有的模拟的vtable允许我们根据类型定义多态操作,而和这些类型本身无关。(他们甚至可以是基本类型,就象上面的代码实例所展现的。)
顺便说一句,如果你对这个主题感兴趣,但不懂“模拟vtable”,别忘了这篇文章基于前两篇[6、7]。
为了让Variant能被访问,我们需要执行下列步骤:
* 为Variant类定义一个BaseVisitor类。在DatabaseField这个案例中,BaseVisitor类就象这样:
struct BaseVisitor
{
virtual void Visit(string&) = 0;
virtual void Visit(int&) = 0;
virtual void Visit(double&) = 0;
};
* 在模拟虚函数表的实现中定义Accept函数,就象前一篇文章中所展现的[7]
...在Variant中...
template <class T>
struct VTableImpl
{
...和前面一样...
static void Accept(Variant& var, BaseVisitor& v)
{
T& data = reinterpret_cast<const T*>
(&var.buffer_[0]);
v.Visit(data);
}
};
* 在模拟虚函数表结构中增加一个指向Accept函数的指针:
...在Variant中...
struct Vtable
{
...和前面一样...
void (*accept_)(Variant&, BaseVisitor&);
};
* 不要忘记在Variant的构造函数中把accept_指针绑定到VTableImpl<T>::Accept函数。
...在Variant中...
template <class T>
Variant(const T& val)
{
new(&buffer_[0] T(val)
static Vtable vtbl =
{
&VTableImpl<T>::TypeId,
&VTableImpl<T>::Destroy,
&VTableImpl<T>::Clone,
&VTableImpl<T>::Accept, //新增
};
*ptr_ = &vtbl;
}
* 提供一个接口函数
...在Variant中...
void Accept(BaseVisitor& v)
{
(vptr_->accept_)(*this, v);
}
的确,这比单单增加一个新函数花费更多工作,但幸运的是,我们不需要一遍又一遍做这个。一旦访问就绪了,我们能够通过简单地增加从BaseVisitor继承新类来操作DatabaseField对象:
struct ProcessDatabaseField public BaseVisitor
{
virtual void Visit(string& s)
{
...
}
virtual void Visit(int& I)
{
...
}
virtual void Visit(double& d)
{
...
}
};
DatabaseField fld = ...;
ProcessDatabaseField processor;
//调用processor中对应的Visit()
fld.Accept(processor);
这给我们带来两个好处。首先、你现在能够把ProcessDatabaseField的成员函数散布到各个编译单元(compilation unit)中(可能各个编译单元分别专门处理字符串操作或数字操作)。其次,如果你后来增加一个新类型到DatabaseField的定义中,比如说:
typedef Variant<cons<string, int, double, Memo>::type>
DatabaseField;
然后如果你同时在BaseVisitor的定义中增加void Visit(Memo&) = 0,然后编译器提醒每个BaseVisitor继承类没有正确处理Memo;
如果你们看懂了这篇文章(希望你们看懂了),你可能会想:“太好了,这个概念我懂了。访问是讨人厌的GetPtr的漂亮替代品。”当然,就象任何概念一样,它总有实际的细节。前面的表述为了一次讲清楚一件事,忽略了一些细节。如果你看得够仔细,你可能注意到这样一个令人不安的细节:BaseVisitor只能处理string,int,和double,它在访问DatabaseField对象时工作得很好(DatabaseField能够存放这些类型中得任意一个),但对其他的Variant实例没用,因为其他的Variant实例可能包含不同的类型集合。那你们到底应该怎样定义一个虚基类BaseVisitor,有一个纯虚函数Visit(X&)对应于给定一个类型串中的每个类型X?
这是完全能实现的,而且Modern C++ Design展现了怎样做到这一点。更好的是,Loki定义了一个访问框架。既然这个框架既是关于访问的又是通用的,嗨,为什么不用它呢。来看看怎么用[8]:
template <class List, class Align =
AlignedPOD<Tlist>::Result>
Class Variant
{
typedef Loki::Visitor<void, Tlist> StrictVisitpr;
};
这很简单。为了在DatabaseField中增加新的功能,现在你需要做的就是从DatabaseField::StrictVisitor中继承新的访问类。毫无疑问,DataBaseField::StrictVisitor定义了三个纯虚访问函数,分别接受一个string,一个int和一个double参数。或任何类型你认为应该包含在DatabaseField里。太好了!
访问的调整
现在有了基本概念,我们能够做一些改进。
比如说,如果把访问和常数正确性(const-correctness)混用。但BaseVisitor::Visit函数都带非常量参数。这意味着下面的代码不能工作:
struct Streamer : public DataBaseField::StrictVisitor
{
virtual void Visit(string& s)
{
cout << s;
}
virtual void Visit(int& I)
{
cout << I;
}
virtual void Visit(double& d)
{
cout << d;
}
};
const DatabaseField fld(4.5); //作为常量
Streamer str;
//错误!没有重载Streamer::Visit版本
//接受const double&
fld.Accept(str);
从问题回朔,我们找到麻烦的根源。
...在Variant中...
template <class T>
struct VTableImpl
{
...和前面一样...
static void Accept(Variant& var, BaseVisitor& v)
{
...
}
};
上面的Accept函数接受一个非常量Variant对象作为第一个参数。
古话说的好(我相信是古希腊人说的):“当一个VtableImpl<T>::Accept没用,也许两个会有用。”实际上,我们所要做的就是定义一个新的Variant成员函数(名字也叫Accept),但是这次我们传入一个const Variant&而不是一个简单的Variant&。
对应于const Variant对象的访问者基类可以直接通过Loki::Visitor获得,但传给它的是一个变了形的类型串。这个类型串变形接受原来的类型串返回一个新的类型串,这个新类型串的每个类型都加了const。就象这样:
template <class Tlist> struct MakeConst;
template <> struct MakeConst<null_typelist>
{
//停止类型不是const的
typedef null_typelist Result;
};
template <typename Head, class Tail>
struct MakeConst <typelist<Head, Tail> >
{
typedef typename MakeConst<Tail>::Result NewTail;
typedef typelist<const Head, NewTail> Result;
};
MakeConst的关键在于最后一行,当返回Result类型时在这里Head类型转化为const Head。有了MakeConst,我们定义一个新的访问者基类,就象这样:
Template <class Tlist, class Align =
AlignedPOD<Tlist>::Result>
Class Variant
{
typedef Loki::Visitor<void, Tlist> StrictVisitor;
typedef Loki::Visitor<void,
typename MakeConst<Tlist>::Result>
ConstStrictVisitor;
...
};
那么到现在为止,对所有我们定义的访问者名字前面的Strict前缀还能怎么进一步改善?对应于严格访问,有没有什么“非严格“的访问。
考虑下面的需求:如果一个DatabaseField是一个字符串,它需要放在方括号里面[就象这样]。不是字符串则不作处理。实现是:
struct Transformer : public DatabaseField::StrictVisitor
{
virtual void Visit(string& s)
{
s = '[' + s + ']';
}
virtual void Visit(int& I)
{
//不作处理
}
virtual void Visit(double& d)
{
//不作处理
}
};
在这个情况下,尽管Transformer根本不需要访问in和double,它仍旧需要定义空的Visit(int&)和Visit(double&)重载函数。访问者实现是严格的:所有函数必须被实现,编译器确保这一点。
对于象Transformer的情况,当我们只需要类型集合的子集而对其他类型不作处理,非严格访问是达到目的的一个方法。这样,Variant 多增加两个类型定义:对非常量和常量的Variant对象的非严格访问。
Template <class Tlist, class Align =
AlignedPOD<Tlist>::Result>
Class Variant
{
typedef Loki::Visitor<void, Tlist> StrictVisitor;
typedef Loki::Visitor<void,
typename MakeConst<Tlist>::Result>
ConstStrictVisitor;
Typedef Loki::NonStrictVisitor<void,
Tlist> NonStrictVisitor;
Typedef Loki:: NonStrictVisitor<void,
Typename MakeConst<Tlist>::Result
ConstNonstrStrictVisitor;
...
};
对Variant的访问机制的进一步改善可能包括自定返回类型。(现在所有访问函数返回void)
松散的结尾
在这里总结我们的Variant实现。
一旦有了访问,你能够增加任何你想在Variant中增加的功能,以两个虚函数调用作为代价(而不是一个)。这是为了让Variant通用而付的代价。幸运的是,这个解决方案能够纵向扩展。(开销不会随着传入Variant的类型串大小增加而增加)。
Variant实现中的可用的重要特性是把它的值取出转化为另一个类型,或者就地改变存储类型。比如,考虑下面的代码:
DatabaseField fld(45);
Assert(fld TypeId() == typeid(int));
Double d = fld.ConvertTo<double>();
Assert(d == 45.0);
如果现在存储在Variant对象的类型(在上面例子中是int)能够转换到需要的类型(在这个例子里是double),那么进行转换。否则,ConvertTo抛出一个意外。
上面的一个变化是就地改变对象的类型
fld ChangeType<double>();
assert(fld TypeId() == typeid(int));
assert(*fld.GetPtr() == 45.0);
这些函数通过访问来实现,正如你可以从供下载代码(alexandr.zip)中中看到。里面还增加其他的函数来和Variant协同工作。
总结
可识别联合是个有用的建模工具。用它能自然地表达一个对象的类型,这个类型属于在任何时候给定的类型集合。不象多态类型,可识别联合事先限定了可能类型的集合。这带来了重要的效率优势,而这,用C++实现是困难的,但不是不可能的。我们通过使用就地内存分配——加上对齐计算——和通过使用模拟vtables——这对于值类型模拟了多态——来获得效率。
同时,理解可识别联合和访问间的联系是非常重要的。在多数过程语言中,访问其实隐式用类似于“switch”语句实现,如果一个对象中的所有可能类型没有都被处理,这种语句会产生错误。在C++里,我们通过用抽象基类来实现这种类型安全的访问。
非常重要的是,可识别联合可能对你的设计带来更多的一致性,更好地强制实现你的设计原则,并容易地在具体实现中保持架构同步。
参考书目
[1] <www.geocities.com/rani_sharoni/LokiPort.html> (also linked from Modern C++ Design's homepage, <http://moderncppdesign.com>).
[2] Andrei Alexandrescu and Petru Marginean. "Generic<Programming>: Simplify Your Exception-Safe Code," C/C++ Users Journal C++ Experts Forum, December 2000, <www.cuj.com/experts/1812/alexandr.htm>.
[3] Andrei Alexandrescu. Modern C++ Design (Addison-Wesley, 2001).
[4] E. Gamma, et al. Design Patterns (Addison-Wesley, 1995), Chapter 5.
[5] 举例来说,标准的意外类不是可访问的,但它们肯定应该是
[6] Andrei Alexandrescu. "Generic<Programming>: Discriminated Unions (I)," C/C++ Users Journal C++ Experts Forum, April 2002, <www.cuj.com/experts/2004/alexandr.htm>.
[7] Andrei Alexandrescu. "Generic<Programming>: Discriminated Unions (II)," C/C++ Users Journal C++ Experts Forum, June 2002, <www.cuj.com/experts/2006/alexandr.htm>.
[8]下面的一些Visitor功能没有在原来版本的Loki中出现,但在以后会增加。
Andrei Alexandrescu 是位于西雅图的华盛顿大学的博士生,也是受到好评的《Modern C++ Design》一书的作者。可以通过www.moderncppdesign.com. 来联系他。Andrei同时也是C++研讨会 (<www.gotw.ca/cpp_seminar>).的一名有号召力的讲师。