Guru of the Week 条款15:类之间的关系(下篇)

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
   
   
    
    
   
   
  bool GenericTableAlgorithm( const string& table ) {
   
   
    // ... 原来的Process() 放在在这里...
   
   
  } 
   
   
  int main( int, char*[] ) {
   
   
    GenericTableAlgorithm
   
   
    
    ( "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)

(完)




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值