GotW #15 Class Relationships Part II
著者:Herb Sutter
翻译:kingofark
[声明]:本文内容取自www.gotw.ca网站上的Guru of the Week栏目,其著作权归原著者本人所有。译者kingofark在未经原著者本人同意的情况下翻译本文。本翻译内容仅供自学和参考用,请所有阅读过本文的人不要擅自转载、传播本翻译内容;下载本翻译内容的人请在阅读浏览后,立即删除其备份。译者kingofark对违反上述两条原则的人不负任何责任。特此声明。
Revision 1.0
Guru of the Week 条款15:类之间的关系(下篇)
难度:6 / 10
(设计模式是编写可复用代码的一个重要工具。你能辨认出本条款中的代码所用到的模式吗?你能改进它吗?)
[问题]
一个操纵数据库的程序经常需要在一个给定的表(table)中对一条或多条纪录(record)施以一定的操作。这一般涉及到两个连续的过程:首先以只读方式游访(pass through)整个表以搜集信息,确定哪些纪录需要被操纵;然后再对表进行第二次游访,实施真正的操作。
为了避免每次重复的编写那些惯常使用的操作代码,一个程序员试图通过下面的抽象类来提供一个通用的可复用框架(framework)。他希望抽象类能通过如下方式来封装那些重复的代码:首先,生成一个清单(list),用来记录表中需要被处理的那些记录行(record row);其次,对清单中的每个表项进行相应的处理。各种特定的处理代码细节由各个派生类自己实现。
//--------------------------------------------------- // gta.h 文件 //--------------------------------------------------- class GenericTableAlgorithm { public: GenericTableAlgorithm( const string& table ); virtual ~GenericTableAlgorithm(); // Process() 如果执行成功就返回true. // 它做了所有的工作,包括: a) physically reads // a)从物理设备上读取表中的记录,然后对每一条记录调用Filter() // 来检查其是否就是需要被处理的记录; // b)当创建好需要被处理的记录的清单后,对每一条需要被处理的记录 // 调用ProcessRow()。 bool Process(); private: // 如果当前记录就是需要被处理的记录,Filter() 就返回true。 // 缺省的行为是将表中的所有记录都包括进去。 virtual bool Filter( const Record& ) { return true; } // 对每一条需要被处理的记录,ProcessRow()被调用一次。 // 这正是实际使用的特定的类中进行其特定的操作的地方。 // (注意:可以看出,每一条记录前前后后被读取了两次。 // 这里我们假设出现这种情况是必要的,而不是一个效率上的问题。) virtual bool ProcessRow( const PrimaryKey& ) =0; class GenericTableAlgorithmImpl* pimpl_; // MYOB };这个类的使用者从其派生出一个类,可能会像下面这样编写使用代码:
class MyAlgorithm : public GenericTableAlgorithm { // ... 在这里覆写Filter()和ProcessRow(),进行一些 // 特定的具体操作... }; int main( int, char*[] ) { MyAlgorithm a( "Customer" ); a.Process(); }现在有3个问题:
1. 这是一个不错的设计,它实现了一种常用的设计模式(design patterns)。请问,这里使用的是什么模式?为什么这种模式可以用在这里?
2. 在不改变其本设计的情况下,评估这种设计被实际执行的方式。你能采用一些与其不一样的方式吗?pimpl_成员是为什么而设计的?
3. 实际上,这个设计可以进行较大的改进。GenericTableAlgorithm所担负的责任是什么?如果其担负的责任多于一个,那么这些责任所包含的操作应该如何被更好的封装起来?说明你采用的方法是如何影响类的可复用性的,特别是类的可扩展性这方面。
[解答]
1. 这是一个不错的设计,它实现了一种常用的设计模式(design patterns)。请问,这里使用的是什么模式?为什么这种模式可以用在这里?
这种模式叫做Template Method(可别跟C++中的template模板搞混淆了)。[注1] 这种设计模式非常有用,因为我们由此可以从算法中提取出那些每次都要进行的步骤,将其抽象出来,只把一些因地制宜的细节留给派生类来实现。
(注意:pimpl_惯用法与Bridge方法非常相似[注1],但在这里,它只是作为一种对抗编译依赖性的防火墙而存在;它将各个特定类的具体实现细节隐藏起来,其在运作的时候与真正的具有可扩展性的bridge还不太一样。)
2. 在不改变其本设计的情况下,评估这种设计被实际执行的方式。你能采用一些与其不一样的方式吗?pimpl_成员是为什么而设计的?
这个设计里面使用bool变量作为返回值,同时也丧失了使用其它方法——例如状态码(status code)或者异常处理——来进行错误报告(error reporting)的能力。也许根据依照某些特定的需求来考虑的时候,这样做是不错的,但一般我们还是应该认识并注意到这一点。
那个(不太容易发音的)pimpl_成员很好的将实现细节隐藏在了一个神秘的指针后面。pimpl_所指向的结构包含了私有成员函数和成员变量。这样一来,对他们进行任何改变,都不用重新编译用户代码(client code)。这正是Lakos等人[注2]所描述的一种很重要的技术。之所以说很重要,是因为这种技术在不给代码带来过多的复杂性和干扰的情况下,从一定程度上弥补了C++缺少模块系统(module system)的不足。
3. 实际上,这个设计可以进行较大的改进。GenericTableAlgorithm所担负的责任是什么?如果其担负的责任多于一个,那么这些责任所包含的操作应该如何被更好的封装起来?
GenericTableAlgorithm还可以进行较大的改进,因为他现在还是身兼二职。这就跟普通人在身兼二职时需要承受额外的负担一样,压力会很大。所以我们可以想见,缓解和改变GenericTableAlgorithm这种身兼二职、一心两用的状况,一定会对类自身大有好处。
在原始代码中,GenericTableAlgorithm担负着两个完全不同且毫不相关的责任。这两个责任完全可以被有效的分离开来,这是因为它们面向着不同的作用对象。简单的说,这两种责任是:
(1) 用户代码(client code)使用特定的通用算法(generic algorithm);
(2) 针对特定的实际情况,GenericTableAlgorithm会使用具有特定实现细节的类来使其操作特殊化(specialize)。
好,该说的说完了,现在我们来看看改进之后的代码:
//--------------------------------------------------- // gta.h文件 //--------------------------------------------------- // 责任#1: 提供一个公共接口,使其能够将常用的功能作为 // template method进行封装。这与继承关系无关,并可以 // 在一个实现特定功能的类中被很好的孤立起来。这是一个面向 // GenericTableAlgorithm的外部用户(external users) // 的接口。 class GTAClient; class GenericTableAlgorithm { public: // 构造函数现在获取了一个有具体实现的对象。 GenericTableAlgorithm( const string& table, GTAClient& worker ); // 由于我们把继承关系隔离了起来,因此析构函数不必是virtual的。 // 事实上,我们也许压根儿就不需要它。 ~GenericTableAlgorithm(); bool Process(); // 这一行不变 private: class GenericTableAlgorithmImpl* pimpl_; // MYOB }; //--------------------------------------------------- // gtaclient.h文件 //--------------------------------------------------- // 责任 #2: 为可扩展性提供了一个抽象接口。在这里, // GenericTableAlgorithm的实现细节与外部用户代码无关, // 并且可以被隔离到一个作用更明确的抽象协议类中去。 // 这里的接口是面向那些利用GenericTableAlgorithm 来编写 //可被实际使用的类的代码编写者。 class GTAClient { public: virtual ~GTAClient() =0; virtual bool Filter( const Record& ) { return true; } virtual bool ProcessRow( const PrimaryKey& ) =0; };可以看到,上面的两个类需要放在不同的头文件里面。那么在经过了这些改变之后,用户代码(client code)又可能会是什么样子的呢?答案是,用户代码(client code)基本没有变化,与原来的几乎一样:
class MyWorker : public GTAClient { // ... 在这里覆写Filter()和ProcessRow(),进行一些 // 特定的具体操作... }; int main( int, char*[] ) { GenericTableAlgorithm a( "Customer", MyWorker() ); a.Process(); }尽管代码样子没怎么变,但是必须考虑改进之后产生的如下三个效果:
1. 如果GenericTableAlgorithm的公共接口改变了会怎么样?结果是:在原始的版本中,所有具体的用户端的类都需要被重新编译,这是因为它们都派生自GenericTableAlgorithm;而在改进的版本中,对GenericTableAlgorithm公共接口的任何改变都被很好的孤立起来了,并不会影响用户端所使用的具体的类。
2. 如果GenericTableAlgorithm的可扩展协议被改变了会怎么样(比如Filter()或Processrow()里增加了新的缺省参数)?结果是:在原始的版本中,即使GenericTableAlgorithm公共接口没有任何改变,所有使用GenericTableAlgorithm的外部代码都必须被重新编译。这是因为,一个派生接口(derivation interface)在类定义中是可见的。而在改进的版本中,对GenericTableAlgorithm扩展协议接口的任何改变都被很好的孤立起来了,并不影响外部的用户代码。
3. 在改进的版本中,任何具体被使用的类可以在任何以Filter()或Processrow()为接口的算法中被使用,而不仅仅限于GenericTableAlgorithm中。
其实,我们在改进的代码中使用了与Strategy Pattern[注1]极为相似的模式(pattern)。
要记住计算机科学领域中的一句格言:Most any problem can be solved by adding a level of indirection(大部分问题可以通过增加间接层次即间接性来解决)。当然,同时考虑“奥卡的剃刀(Occam's Razor)” 原则也是很明智的。“奥卡的剃刀(Occam's Razor)”原则说道:Don't multiply entities more than necessary(不要做超出需求的额外举动)。把握好这两者之间的平衡关系,可以使你在花费很少甚至免费的情况下,增强代码的可复用性和可维护性——这无论如何都是一笔划算的买卖。
你也许注意到了,GenericTableAlgorithm其实完全可以是一个函数(实际上,有些人会把Process()改称为operator()(),这是由于此时的类很明显的只是一个functor(函算符)而已)。这里的类之所以可以替换成函数,是因为这里并没有说明在调用Process()的前后需要保存状态。例如我们可以把代码替换成这样:
bool GenericTableAlgorithm( const string& table, GTAClient& method ) { // ... 原来的Process() 放在在这里... } int main( int, char*[] ) { GenericTableAlgorithm( "Customer", MyWorker() ); }这里的代码实际上就是一个通用函数(generic function),可以根据实际需要将其特殊化(specialized)。如果你发现“method”对象并不需要保存状态信息(),你就可以使“method”对象成为一个non-class template parameter(非class的模板参数):
template<typename GTACworker> bool GenericTableAlgorithm( const string& table ) { // ... 原来的Process() 放在在这里... } int main( int, char*[] ) { GenericTableAlgorithm<MyWorker>( "Customer" ); }这一个函数版本只比上面那个少了一个逗号。当然,在本条款所讨论的问题里面,少这一个逗号并不会给你带来多大的好处,因此第一个函数或许更好些。毕竟,能够抵挡住诱惑,不去编写这样一些以炫耀为目的的蹊跷的代码,总是一件好事。
无论如何,选择使用函数实现还是使用类实现完全取决于你要达到的目的。在本条款的这个问题中,使用函数实现比较好。
[注1]:E. Gamma et al., Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1995)。(中文版:《设计模式:可复用面向对象软件的基础》)
[注2]:J. Lakos. Large-Scale C++ Software Design (Addison-Wesley, 1996)。
(完)