文章说明:
1.重构:改善既有代码的设计(5)
每当我们学习一种可以大大提高生产力的新技术时,是很难发现它的局限性的。你往往是在一个特定的环境中学习它,这个特定的环境可能仅仅是一个项目。所以很难看出是什么原因导致技术事倍功半,甚至是产生危害。就想10年前的对象技术,如果有人问我什么时候不适合应用对象对数,这对我来说是很难回答的。这不是说对象技术就是完美无缺的(我对此一直持怀疑态度),而只是我仅仅知道它的好处在哪儿,确实不知道它的局限性。
我理解的重构就如同上面所说,我知道重构的益处,知道它可以让我们的工作变得不同。但是我没有足够的经验来发现它的局限性。
数据库(database)
重构容易出问题的领域之一就是数据库。大多数的应用程序都紧紧地与支撑它的dababase schema(数据库表结构)耦合。这就是数据库难以改变的原因之一。另一个原因就是数据的迁移。尽管你很仔细的将系统分为不同的层次,让database schema与对象模型之间的依赖降低到最小,但只要你改变database schema就不得不进行数据迁移,这是一项令人焦虑而又漫长的任务。
非面向对象数据库解决这个问题的方法就是在对象模型与数据库模型之间放置一个分隔层(即所谓的ORM层),这就可以隔离两个模型各自的变化。升级某一模型时无需同时升级另一模型,只需升级上述的分隔层即可。这样的分隔层会增加系统复杂度,但可以给你很大的灵活度。如果你同时拥有多个数据库,或如果数据库模型较为复杂使你难以控制,那么即使不进行重构,这分隔层也是很重要的。
你无需一开始就插入分隔层,可以在发现对象模型变得不稳定时再产生它。这样你就可以为你的改变找到最好的杠杆效应。
对开发者而言,对象数据库既有帮助也有妨碍。某些面向对象数据库提供不同版本的对象之间的自动迁移功能,这减少了数据迁移时的工作量,但还是会损失一定时间。如果各数据库之间的数据迁移并非自动进行,你就必须自行完成迁移工作,这个工作量可是很大的。这种情况下你必须更加留神classes内的数据结构变化。你仍然可以放心将classes的行为转移过去,但转移值域(field)时就必须格外小心。数据尚未被转移前你就得先运用访问函数(accessors)造成「数据已经转移」的假象。一旦你确定知道「数据应该在何处」时,就可以一次性地将数据迁移过去。这时惟一需要修改的只有访问函数(accessors),这也降低了错误风险。
修改接口(Changing Interfaces)
关于对象,另一件重要事情是:它们允许你分开修改软件模块的实现 (implementation〕和接口(interface)。你可以安全地修改某对象内部而不影响他人,但对于接口要特别谨慎——如果接口被修改了,任何事情都有可能发生。
一直对重构带来困扰的一件事就是:许多重构手法的确会修改接口。像Rename Method这么简单的重构手法所做的一切就是修改接口。这对极为珍贵的封装概念会带来什么影响呢?
如果某个函数的所有调用动作都在你的控制之下,那么即使修改函数名称也不会有任何问题。哪怕面对一个public函数,只要能取得并修改其所有调用者,你也可以安心地将这个函数易名。只有当需要修改的接口系被那些「找不到,即使找到也不能修改」的代码使用时,接口的修改才会成为问题。如果情况真是如此,我就会说:这个接口是个「已发布接口」(published interface)——比公开接口(public interface)更进一步。接口一旦发布,你就再也无法仅仅修改调用者而能够安全地修改接口了。 你需要一个略为复杂的程序。
这个想法改变了我们的问题。如今的问题是:该如何面对那些必须修改「已发布接口」的重构手法?
简言之,如果重构手法改变了已发布接口(published interface〕,你必须同时维护新旧两个接口,直到你的所有用户都有时间对这个变化做出反应。幸运的是这不太 困难。你通常都有办法把事情组织好,让旧接口继续工作。请尽量这么做:让旧接口调用新接口。当你要修改某个函数名称时,请留下旧函数,让它调用新函数。千万不要拷贝函数实现码,那会让你陷入「重复代码」(duplicated code)的泥淖中难以自拔。你还应该使用Java提供的(deprecation〕设施,将旧接口标记为 "deprecated"。这么一来你的调用者就会注意到它了。
这个过程的一个好例子就是Java容器类(群集类,collection classes)。Java2的新容器取代了原先一些容器。当Java2容器发布时,JavaSoft花了很大力气来为开发者提供一条顺利迁徙之路。
「保留旧接口」的办法通常可行,但很烦人。起码在一段时间里你必须建造(build)并维护一些额外的函数。它们会使接口变得复杂,使接口难以使用。还好我们有另 一个选择:不要发布(publish)接口。当然我不是说要完全禁止,因为很明显你必得发布一些接口。如果你正在建造供外部使用的APIs,像Sun所做的那样,肯定你必得发布接口。我之所以说尽量不要发布,是因为我常常看到一些开发团队公开了太多接口。我曾经看到一支三人团队这么工作:每个人都向另外两人公开发布接口。这使他们不得不经常来回维护接口,而其实他们原本可以直接进入程序库,径行修改自己管理的那一部分,那会轻松许多。过度强调「代码拥有权」的团队常常会犯这种错误。发布接口很有用,但也有代价。所以除非真有必要,别发布接口。这可能意味需要改变你的代码拥有权观念,让每个人都可以修改别人的代码,以运应接口的改动。以搭档(成对〕编程(Pair Programming)完成这一切通常是个好主意。
注:不要过早发布(publish)接口。请修改你的代码拥有权政策,使重构更顺畅。
Java之中还有一个特别关于「修改接口」的问题:在Throws子句中增加一个异常。这并不是对签名式(signature)的修改,所以你无法以delegation(委托手法)隐 藏它。但如果用户代码不做出相应修改,编译器不会让它通过。这个问题很难解决。你可以为这个函数选择一个新名字,让旧函数调用它,并将这个新增的checked exception(可控式异常〗转换成一个unchecked exception(不可控异常:)。你也可 以拋出一个unchecked异常,不过这样你就会失去检验能力。如果你那么做,你可以警告调用者:这个unchecked异常日后会变成一个checked异常。这样他们就有时间在自己的代码中加上对此异常的处理。出于这个原因,我总是喜欢为整个package定义一个superclass异常(就像java.sql的SQLException),并确保所有public函数只在自己的throws子句中声明这个异常。这样我就可以随心所欲地定义异常,不会影响调用者,因为调用者永远只知道那个更具一般性的superclass异常。
难以通过重构手法完成的设计改动
通过重构,可以排除所有设计错误吗?是否存在某些核心设计决策,无法以重构手法修改?在这个领域里,我们的统计数据尚不完整。当然某些情况下我们可以很有效地重构,这常常令我们倍感惊讶,但的确也有难以重构的地方。比如说在一个项目中,我们很难(但还是有可能)将「无安全需求(no security requirements)情况下构造起来的系统」重构为「安全性良好的〔 good security)系统」。
这种情况下我的办法就是「先想像重构的情况」。考虑候选设计方案时,我会问自己:将某个设计重构为另一个设计的难度有多大?如果看上去很简单,我就不必太担心选择是否得当,于是我就会选最简单的设计,哪怕它不能覆盖所有潜在需求也没关系。但如果预先看不到简单的重构办法,我就会在设计上投入更多力气。不过我发现,这种情况很少出现。
何吋不该重构?
有时候你根本不应该重构——例如当你应该重新编写所有代码的时候。有时候既有代码实在太混乱,重构它还不如重新写一个来得简单。作出这种决定很困难,我承认我也没有什么好准则可以判断何时应该放弃重构。
重写(而非重构)的一个清楚讯号就是:现有代码根本不能正常运作。你可能只是试着做点测试,然后就发现代码中满是错误,根本无法稳定运作。记住,重构之前,代码必须起码能够在大部分情况下正常运作。
一个折衷办法就是:将「大块头软件」重构为「封装良好的小型组件」。然后你就可以逐一对组件做出「重构或重建」的决定。这是一个颇具希望的办法,但我还没有足够数据,所以也无法写出优秀的指导原则。对于一个重要的古老系统,这肯定会是一个很好的方向。
另外,如果项目已近最后期限,你也应该避免重构。在此时机,从重构过程赢得的生产力只有在最后期限过后才能体现出来,而那个时候已经时不我予。Ward Cunningham对此有一个很好的看法。他把未完成的重构工作形容为「债务」。很多公司都需要借债来使自己更有效地运转。但是借债就得付利息,过于复杂的代码所造成的「维护和扩展的额外开销」就是利息。你可以承受一定程度的利息,但如果利息太高你就会被压垮。把债务管理好是很重要的,你应该随时通过重构来偿还一部分债务。
如果项目己经非常接近最后期限,你不应该再分心于重构,因为己经没有时间了。不过多个项目经验显示:重构的确能够提高生产力。如果最后你没有足够时间,通常就表示你其实早该进行重构。