应用 Refactoring 需要考虑的问题
浙江大学灵峰科技开发公司技术总监
2001 年 12 月
本文紧接第二部分,继续讲述应用 refactoring 应该考虑的问题。
任何一种技术都可能有它自己的麻烦。但是往往在我们使用一种新技术的时候,可能还不能深入到发现它带来的问题,正如Martin Fowler所说:
在学习一种能够极大提高生产力的新技术时,你很难看到它不能应用的场合。
他把Refactoring的情景和面向对象出现使得情景相比较:
情况恰如10年前的对象。不是我不考虑对象有限制。只是因为我不知道那些限制是什么,虽然我知道他带来的好处。
但是Martin Fowler和其他人确实观察到了Refactoring可能引发的某些问题,我们可以来看一下:
数据库
很多应用程序的代码可能与数据库结构绑定得非常严密。如果要修改这些代码,需要改变还有数据库结构和原先已经存在的数据。
O/R mapping可以用来解决这个问题。使用专业的O/R mapping工具能够实现关系数据库的迁移。但是,就算这样,迁移也需要付出额外的代价。
如果你使用的并非关系数据库,而是直接采用OO数据库,这一点的影响可能会变得更小。
所以,我建议每一个使用数据库的应用程序都应该采用O/R mapping或者OO数据库。目前出现的各种企业级应用解决方案如J2EE本身就提供这样的构架。
如果你的代码没有这样一个隔离层,那么你必须手工或编写专用的代码来实现这些迁移功能。
接口改变和Published Interface
有很多Refactoring操作(如rename method name)确实改变了接口。面向对象承诺在接口不变的情况下给你以实现变化的自由。但如果接口发生改变,那么你就不得不非常小心了。
为了保证系统的可观察行为不变,你必须保证这些接口的改变不会影响到你无法取得 的代码。如果你拥有了所有使用该接口的类的源代码,你只要把这些地方同时也改变即可。
但是,如果你没有办法得到所有这些使用的代码,那么你就不得不采取额外的途径。事实上,如果你的代码是一个代码库(如Sun JDK的集合框架)或者是一个Framework,那么这一点几乎是不可避免的。
要使得这些依赖于你老接口的代码能够继续工作,你必须保留老接口。现在你有两套接口,一套是老的,一套是经过Refactoring的新接口。你必须把对老接口的调用分派到新接口。千万不要拷贝整个函数体,因为这会产生大量的重复代码。
这种方法虽然能够解决问题,但是却非常麻烦。由于Refactoring通常会涉及到状态、行为在不同类之间的转移,如果一个方法从一个类移动到另一个类,那么使用这种分派的方法可能需要一些不必要的中间状态或者参数。这会使你的代码显得难以理解和维护,在一定程度上削减了Refactoring所应起到的作用。
因此,这种方法只应该用于过渡时期。给用户一定的时间,允许用户代码能够逐渐转移到新接口,在超过一定的期限后,删除老方法,不再支持老接口。这也是Java Deprecated API的意义所在。
像这样保护接口虽然可能,却非常困难。你至少需要在一段时间内维护两套接口,以保证原来使用你老接口的客户代码还能继续使用你的新代码,Martin Fowler把这些接口称之为Published Interface。虽然你不可能避免公布你的一部分接口,不然谁也不能使用你的代码,但是过早公布不必要的接口会造成不必要的麻烦,就像Martin Fowler给我们的提示:
Don't publish interface prematurely.
用Refactoring思想武装自己的设计
如果你不理解OO的思想,那么你就不可能真正地用好OO语言。同样,如果你没有把Refactoring的思想贯穿于你的开发过程,你也不可能用好Refactoring。
Refactoring包含两个方面的想法:它告诉你可以从简单的设计做起,因为即使代码已经实现,你还是可以用它来改进你的设计。然而,另一方面,它绝不是告诉你可以信手涂鸦。我给你的忠告是:
Started simple but not stupid。
如果你一开始就设计了愚蠢的接口,甚至是错误的接口。在程序演变的过程中,这一部分可能变成系统的核心。对之进行Refactoring可能需要花费大量的精力,而改变接口和类的操作可能会是这些Refactoring主要内容。对核心类接口的变化可能会迅速波及到系统的各个层面,如果你的总体结构是好的,那么这种涟漪可能会在某一个层次消失。(譬如环状和层次性的体系结构。)如果你没有这样的抽象机制和保护体系,那么对核心类的修改将会直接导致整个系统的变更,这是不能接受的。
所以,在设计一个类的时候,你需要问自己几个问题,如果事情发生了这种变化,我会如何修改来适应?如果发生了那种变化,我会怎样来适应?如果你能够想到可能的Refactoring方法,那么证明你的设计是可行的。这并不意味着你要去实现这样的设计,而是保证自己的设计不会把自己逼入到死角。如果你发现自己的代码几乎没有办法Refactoring来适应新的需求,那么你要仔细考虑考虑别的思路。
每次公司的程序员问我一个设计是否合理,我总是反问几个问题:你如何适应这种变化,适应那种可能的变化。我同时指出现在没有必要去实现这些变化。我很少直接回答他好坏或者给他一个答案,但在思考了我反问他们的问题以后,程序员总能对自己的设计做出好的评判,从而找到很好的解决方案。所以,使用Refactoring的思想考虑你的设计。
编程语言
虽然Refactoring是一种独立于编程语言的方法,但你所使用的编程语言往往会或多或少地影响到Refactoring的效率,从而影响你采用Refactoring的积极性.
Refactoring最初的研究是从Smalltalk开始的.随着Refactoring在Smalltalk上的极端成功,更多的面向对象社团开始把Refactoring扩展到其他语言环境.但是不同语言的不同特点有时会对应用Refactoring提供便利,有时却会制造障碍.
支持Refactoring的语言特点和编程风格
.静态类型检查和存取保护
静态类型检查可以缩小对你想要refactoring的程序部分的可能引用范围.举个例子,如果你想要改变一个类的成员函数名,那么你必须改变函数的声明和所有对该函数的引用.如果程序很大,那么查找这样和改变这样的引用就比较困难.
和Smalltalk这样的动态类型语言不同,对静态类型进行检查的语言(C++,Java,Delphi等等)通常具有类继承和相关的存取保护(private,protected,public),这些特点使得寻找对某一个函数的引用变得相对简单.如果重命名的函数原先声明为private,那么对该函数的引用只能是在他所在的类或者该类的友类(C++)等等.如果声明为protected,那么只有本类,子类和友员类(同包类)才能引用到该成员函数.如果声明为public,那么还只需要在本类、子类、友类和明确引入该类的其他类即可(include,import)。
我想提起大家注意的另外一个问题。在软件的最初开发和整个开发流程中尽可能早地应用好的设计原则是一个软件项目成功的重要因素。不管是从封装的角度还是从Refactoring的角度来看,定义成员变量和成员函数应当从最高的保护级别开始。除了非常明显的例子之外,你最好首先把成员变量和函数定义为private。随着软件开发的进一步深入,当其他类对该类提出"额外"的请求,你慢慢地放宽保护。原则是:如果能够放在private,就不要放在protected,能够放在protected,就不要放在public。
使Refactoring复杂化的语言特点和编程风格
预处理指令
某些语言环境通常提供预处理指令,如C++。因为预处理不是C++语言的一部分,这通常使得Refactoring工具实现变得困难。有研究指出,程序往往需要在预处理之后才能进行更好的结构分析,而在这一点上预处理指令信息已经不存在。而refactoring一旦没有和源代码的直接联系,程序员将不太可能对理解Refactoring的结果。
依赖对象尺寸和实现格式的代码
C++继承自C,这使得C++很快流行起来,程序员的学习难度也大大减小。但这是一把双面刃。C++因此而支持很多编程风格,而其中的某些违反了优雅设计的基本原则。
使用C++的指针、cast操作和sizeof(Object)这些依赖对象尺寸和实现格式的代码很难refactor。指针和cast介入别名的概念,这使得你要查找所有对此Object有引用的代码变得非常困难。这些特征的一个共同特点就是它们暴露了对象的内部表达格式,从而违反了抽象的基本原则。
举个例子,C++使用V-table机制来表达可执行程序中的成员变量。继承得来的成员变量在前,本类定义的在后。一个我们经常使用,并且认为安全的refactoring是push up fields,也就是把子类中的一个成员变量移到父类。因为现在变量从父类继承而非本类定义,经过refactoring后的可执行程序之中变量的实际位置已经发生了变化。
如果程序中所有的变量引用都是通过类接口来存取的,那么这样的变化不会有问题。但是,如果变量是通过指针运算(譬如,一个程序员有一个指向对象的指针,知道变量在类的第9个字节,然后使用指针运算给第9个字节赋值),上面的refacoting过程就会改变程序的行为。类似情况,如果程序员使用if (sizeof(object)==15)这样的条件判断,refactoring的结果很可能会对该对象的大小产生影响,从而变得不再安全。
语言复杂度
语言越复杂,对语言语义的形式化就更加困难。相对Smalltalk和稍微复杂的Java而言,C++可称得上是一种非常复杂的语言,这使得对C++程序refactoring工具的研究大大滞后于smalltalk和Java。
解析引用的方式
由于C++绝大部分是在编译是解析引用,所以在refactoring一个程序之后通常至少需要编译程序的一部分,把可执行程序连接起来才能看到测试refactoring的影响。相反,smalltalk和CLOS提供解释执行和增量编译的技术。Java虽然没有解释执行,但它明确把一个公共类放在一个单元内的要求,使得执行一系列refactoring的成本减小。由于refactoring的基本方法就是每一步小小变化,每一步测试,对于C++而言,每一个迭代的成本相对较高,从而程序员变得不太愿意做这些小变化。
反射、Meta级程序分析和变更
这一点可能更让研究者关心而不是实践者的问题。C++并没有提供对meta级程序分析和变更的很好支持,你无法找到象CLOS这样的metaobject协议。这些协议有时对refactoring非常有用,譬如我们可以把一个类的选定实例改变为另一个类的实例,这时候可以利用这些反射协议实现把所有对旧对象的引用自动变更为指向新的实例。
Java虽然还没有像CLOS这样强大的meta级功能,但是JDK的发展已经显示了Java在这方面非常强劲的实力。象上面的例子,我们也可以在Java上做到。
一个小结
基于上面的比较,我们认为Java是应用Refactoring的最佳语言。最近的观察也证实了这一点[Lance Tokuda]。
从实践者的角度来看,目前最流行的refactoring文献基本上都采用Java语言作为范例,其中包括Martin的《Refactoring》。目前市场上有数种支持Java和Smalltalk的Refactoring工具,而C++的工具却几乎没有。这里面,语言本身的复杂性有很大的影响。
当然,这并不意味着C++程序员就不应该使用refactoring技术,只不过需要更多的努力。Refactoring技术已经证明自己是OO系统演化的最佳方法之一,不要放弃。