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

    技术2022-05-11  179

    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)

    (完)


    最新回复(0)