💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
### 为什么开发者不愿意重构他们的程序? 假设你是一位软件开发者。如果你的项目刚刚开始(没有向下兼容的问题),如果你知道系统想要解决的问题,如果你的投资方愿意一直付钱直到你对结果满意,你真够幸运。虽然这样的情景适用面向对象技术,但对我们大多数人来说,这是梦中才会出现的情景。 更多时候,你需要对既有软件进行扩展,你对自己所做的事情没有完整的了解,你受到生产进度的压力。这种情况下你该怎么办? 你可以重写整个程序。你可以倚赖自己的设计经验来纠正程序中存在的错误,这是创造性的工作,也很有趣。但谁来付钱呢?你又如何保证新的系统能够完成旧系统所做的每一件事呢? 你可以拷贝、修改现有系统的一部分,以扩展它的功能。这看上去也许很好,甚至可能被看做一种复用(reuse)方式:你甚至不必理解自己复用的东西。但是,随着 时间流逝,错误会不断地被复制、被传播,程序变得臃肿,程序的当初设计开始腐败变质,修改的整体成本逐渐上升。 重构是上述两个极端的中庸之道。通过「重新组织软件结构」,重构使得设计思路更详尽明确。重构被用于开发框架、抽取可复用组件、使软件架构〔architecture)更清晰、使新功能的增加更容易。重构可以帮助你充分利用以前的投资,减少重复劳动、使程序更简化更有性能。 假设你是一位开发者,你也想获得这些好处。你同意Fred Brooks 所说的「应对并处理变化,是软件开发的根本复杂性之一」⑵。你也同意,就理论而言,重构能够提供上面所说的各种好处。 为什么还不肯重构你的程序呢?有几个可能的原因: 1. 你不知道如何重构。 1. 如果这些利益是长远(才展现)的,何必现在付出这些努力呢?长远看来,说不定当项目收获这些利益时,你已经不在职位上了。 1. 代码重构是一项额外工作,老板付钱给你,主要是让你编写新功能。 1. 重构可能破坏现有程序。 这些担忧都很正常,我经常听到电信公司和其他高科技公司的员工那么说。这其中有一些技术问题,以及一些管理问题。首先必须解决所有这些问题,然后开发者才会考虑在他们的软件中使用重构技术。现在让我们逐一解决这些问题。 如何重构,在哪里重构 如何才能学会重构呢?有什么工具?有什么技术?如何把这些工具和技术组合起来做出有用的事?应该何时使用它们?本书定义了好几十条重构作法,这些都是Martin 在自己的工作经验中发掘的有用手法。重构如何被用以支持程序重大修改?本书提供了很好的例子。 在伊利诺斯大学的软件重构项目中,我们选择了一条「极简抽象派艺术家」(minimalist )路线。我们定义了较少的一组重构⑴,⑶,展示它们的使用方法。我 们对重构的收集系建立于自己的编程经验上。我们评估好几个面向对象框架(多数以C++开发完成)的结构演化( structural evolution ),和数字经验丰富的Smalltalk 开发者交谈,并阅读他们的回顾记录。我们收集的重构手法大多很低层,例如建立或删除一个class、一个变量或一个函数,修改变量和函数的属性,如访问权限(public 或protected),修改函数参数等等,或者在classes 之间移动变量和函数。我们以另一组数量较少的高级重构手法来处理较为复杂的情况,例如建立abstract superclass、 通过subclassing 和「简化条件」等方式来简化一个class 、从现有的class 中分解一 部分,创建一个崭新而可复用的组件等等(经常会在继承(inheritance)、委 托(delegation)、聚合(aggregation)之间转换)。这些较复杂的重构手法是以低层重构手法定义出来的。之所以采用这种方法,乃是为了「自动化支持」和「安全」两方面考量,我将于稍后讨论。 面对一个既有程序,我们该使用哪些重构呢?当然,这取决于你的目标。一个常见的重构原因,同时也是本书关注焦点,是「调整程序结构以使(短期内)添加新功能更容易」。我将在下一节讨论这一点,除此之外,还有其他理由让你使用重构。 有经验的面向对象程序员和那些受过设计模式(design patterns)和优秀设计技巧训练的人都知道,目前已经出现数种令人满意的程序结构性质量和特征(structural qualities and characteristics ),可以支持扩展性和复用性[4],[5],[6]。诸如CRC[7]之类的面向对象设计技术也关注定义classes 和classes 之间的协议(protocols)。虽然它们关注的焦点是前期设计,但也可以用这些指导方针来评价一个现有程序。 自动化工具可用来识别程序中的结构缺陷,例如函数参数过多、函数过长等等。这些都应该考虑成为重构的对象。自动化工具还可以识别出结构上的相似,这样的相似很可能代表着冗余代码的存在。比如说,如果两个函数几乎相同(这经常是「拷贝/修改」第一个函数以获得第二个函数时造成的),自动化工具就会检测到这样相似性,并建议你使用一些重构手法,将相同代码搬到同一个地方去。如果程序中不同位置的两个变量有相同名称,有时你可以使用一个变量替代它们,并在两处继承之。这些都是非常简单的例子。有了自动化工具,其他很多更复杂的情况都可以被检测出来并被纠正。这些结构上的畸形或结构上的相似并非总是暗示你必须重构, 但很多时候它们的确就是这个意思。 对设计模式(design patterns)的很多研究,都集中于良好编程风格以及程序各部位之间有用的交互模式(patterns of interactions ),而这些都可以映像为结构特征和重构手法。例如Template Method 模式[8]的「适用性」(applicability)一节就参考 了我们的abstract superclass 重构手法[9]。 我列出了一些试探法则[1],可以帮助你识别C++程序中需要重构的地方。John Brant 和 Don Roberts[10],[11]开发出一个工具,使用更大范围的试探来自动分析Smalltalk 程序。这个工具会向幵发者建议「可用以改进程序」的重构方法,以及适合使用这些重构方法的地点。 运用这样一个工具来分析你的程序,有点像运用lint 来改善C/C++程序。这个工具尚未聪明到能够理解程序意图,它在程序结构分析基础上提出的建议,或许只有一部分是你真正想要做出的修改。作为程序员,决定权在你手上。由你决定把哪些建议用于自己的程序上。这些修改应该改进程序的结构,应该为日后的修改提供更好的支撑。 在程序员说服自己「我应该重构我的代码」之前,他们需要先了解如何重构、在哪里重构。经验是无可替代的。研究过程中,我们得益于经验丰富的面向对象开发者的经验,得到了一些有用的重构作法,以及「该在哪里使用这些重构」的认识。自动化工具可以分析程序结构,建议可能改进程序结构的重构作法。和其他大多数学科一样,工具和技术会带来帮助,但前提是你打算使用它们。重构过程中,程序员自己对重构的理解也会逐渐加深。 重构 C++ 程序 Bill Opdyke 1989.年,我和Ralph Johnson 刚开始研究重构的时候,C++ 正在飞快发展,并日渐在面向对象开发圈中流行起来。Smalltalk 用户是最先汄识重构重要性的一群人,而我们认为,如果能够证明重构对C++ 程序也同样可用,就会使更多面向对象开发者对重构产生兴趣。 C++ 的某些语言特性(特别是静态型别检查)简化了一部分程序分析和重构工作。但是另一方面,C++ 语言很复杂也很庞大,这很大程度是由于其历史而造成(C++ 是从C 语言演化而来的)。C++ 允许的某些编程风格,使程序的重构和发展变得困难。 对重构有支持能力的语言特性和编程风袼 重构吋,你必须找出待重构的这一部分程序被什么地方引用(指涉)。C++ 静态型别特性让你可以比较容易地缩小搜索范围。举个简单但常见的例子,假设你想要给C++ class 的一个成员函数改名,为正确完成这个动作,你必须修改函数声明以及对这个函数的所有引用点。如果程序很大,搜索、修改这些引用点会很困难。 和Smalltalk 相比,C++ 的classes 继承和保护访问级别(public、protected和private )特性,使你更容易判斯哪些地方引用了这个「待易名函数」,如果这个函数被其所属class 声明为private ,那么这个函数的「被引用点」就只可能出现在这个class 内部以及被这个class 声明为friend 的地方;如果这个函数被声明为protected ,那么引用点只可能出现在它所属的class 内、它的subclass (及更底层的subclass )内以及它的friends 中:如果这个函数被声明为public (限制最少的一种访问级别),引用点彼限制在上述protected 所列情况,以及对某些特定class 实体(对象)的操作之上——该特定class 可以是内含「待易名函数」者,或其subclasses,或更底层的subclasses。 在十分庞大的程序中,不同地点有可能声明一些同名函数。有时候,两个或多个同名函数以同一个函数取代可能更好,某些重构手法可用来做这种修改;有时候则应该给两个同名函数中的一个改名,让另一个保持原来名称。如果项目开发成员不只一人,不同的程序员可能给风牛马不相及及的函数取相同的名称。在C++ 中当你对两个同名函数中的一个改名之后,几乎总是很容易找到哪些引用点针对的是这个被易名函数,哪些引用点针对的是另一个函数。这种分析在Smalltalk 中要困难得多。 由于C++ 以subclassing 实现subtyping,所以通常可以通过「将变量或函数在继承体系中移上移下」来扩大(普通化)I或缩小(特殊化)其作用域(scope)。对程序做这一类分析并进行相应重构,都是很简单的。 如果在最初开发和整个开发过程中一直遵循一些良好的设计原则,那么重构过程会更轻松,软件的进化会更容易。「将所有成员变量和大多数成员函数定义为private 或protected 」是一个抽象技术,常常使class 的内部重构更简单,因为对程序其他地方造成的影响被减至最低。以继承机制表现「普通化和特殊化」体系(这在C++ 中很自然),也使日后「泛化或特化成员变量或成员函数」的重构动作更容易进行,你只需在继承体系内上下移动这些成员即可。 C++ 环境中的很多特性都支持重构。如果程序员在重构时引入错误,C++ 编译器通常都会指出这个错误。许多C++ 软件开发环境都提供了强大的交叉参考和代码浏览功能。 增加重构复杂度的语言特性和编程风格 众所周知,C++ 对C 的兼容性是一柄双刃剑。许多程序以C 写成,许多程序员受的训练是C 风格,所以(至少从表面看来)转移到C++ 比转移到其他面向对象语言容易些。 但是支持许多编程风格,其中某些违反了合理健全的设计原则。 程序如果使用诸如指针、转型操作(cast operation)和sizeof(object)之类的C++ 特性,将难以重构。指计和转型搡作会造成别名(alias),使你很难找到待重构对象的所有被引用点。上述这些特性暴露了对象的内部表现形式,违反了抽象原则。 举个例子,在可执行程序中,C++ 以V-table 机制表现成员变量。从superclass 继承而来的成员变量首先出现,而后才是自身(locally)定义的成员变量。「将某个变量移往superclass 」通常是很安全的重构手法,但如果变量是由superclass 继承而来,不是subclass 自身定义出来,它在可执行文件中的物理(实际)位置有可能因这样的重构而发生改变。当然啦,如果程序中对变量的所有引用(指涉)都是通过class interface 进行,变量的物理位置调整,并不会改变程序行为。 此外,如果程序通过指针算术运算来引用这个变量(例如程序员拥有一个对象指针,而且他知道他想赋值的变量保存于第5个byte ,于是他就使用指针算术,直接把一个值赋进对象的第5个byte 去),那么「将变量移到superclass 」的重构手法就有可能改变程序行为。同样地,如果程序员写下 if (sizeof(object) == 15) 这样的条件式,然后又对程序进行重构,删除class 之中未用到的变量,那么这个class 的实体大小就会发生改变,导致先前判断为真的条件式,如今有可能判断为伪。 可曾有人根据对象大小做条件判断?C++ 提供远为清楚的接口用以访问成员变量,还会有人以指针运算进行访问吗?这样写程序实在太荒唐了不是吗?我的观点是:C++ 提供了这些特性(以及其他倚赖对象物理布局的特性),而某些经验丰富的程序员的确使用了它们。毕竟,从C 到C++ 的移植不可能由面向对象程序员或设计师来进行(只能由C 程序员来做)。 由于C++ 是一个如此复杂的语言(和Smalltalk 以及Java 相比),意图建立某种程序结构,使之得以「协助自动检查某一重构是否安全,并于安全情况下自动执行该重构」,就困难得多。 C++ 在编译期对大多数references 进行决议(resolves),所以对一个C++ 程序进行重构,通常需要至少重新编译程序的某一部分,重新连接并生成可执行文件,然后才能测试修改效果。与之形成鲜明对比的是,Smalltalk 和 CLOS (Common Lisp Object System) 提供解释(interpretation )和增量编译(incremental compilation)环境。因此尽管在Smalltalk 和CLOS 中进行一系列渐进式重构是很自然的事,对C++ 程序来说,每次迭代(重新编译 + 测试)的成本却太高了,所以C++ 程序员往往不太乐意经常做这种小改动。 许多应用程序都用到了数据库。如果在C++ 程序中改变对象结构,可能会需要对database schma(数据库表格的结构、架构、定义)作相应修改。(我在重构工作中应用的许多思想都来自对面向对象数据库模型演化的研究。) C++ 的另一个局限性(这对软件研究者的吸引力可能大于软件开发者)就是:它没有支持meta-level 的程序分析和修改。C++ 缺乏任何类似CLOS metaobject 协议的东西。举个例子,CLOS 的metaobject 协议支持一个时而很有用的重构手法:将选定的对象变成另一个class 的实体,并让所有指向就对象的references 自动指向新对象。幸运的是只有在极少数情况下才会需要这种特性。 结语 很多时候,重构技术可以(并且已经)应用于C++ 程序了,C++ 程序员通常希望自己的程序能在未来数年中不断演化进步,而软件演化过程正是最能凸显重构的好处。C++ 语言提供的某些特性可以简化重构,但另一些特性会使重构变得困难。幸运的是,程序员已经公认:使用诸如「指针运算」之类的语言特性并不是好主意。大多数优秀的面向对象程序员都会避免使用它们。 非常感谢Ralph Johnson, Mick Murphy, James Roskind 以及其他一些人,向我介绍了C++ 之于重构的威力和复杂性。 重构以求短期利益 要说明「重构有哪些中长期好处」是比较容易的。但许多公司受到来自投资方日益沉重的压力,不得不追求短期成绩。重构可以在短期之内带来惊喜吗? 那些经验丰富的面向对象开发者,成功运用重构已经有超过十年的历史了。在强调代码简洁明了、复用性高的Smalltalk 文化中,许多程序员都变得成熟了。在这样的文化中,程序员会投入时间去进行重构,因为他应该这样做。Smalltalk 语言和实现品使得重构成为可能,这是过去绝大多数语言和开发环境都没有能够做到的。许多早期的Smalltalk 程序设计都是在Xerox、PARC 这样的研究机构或技术尖端的小型开发团队和顾问公司中进行的。这些团体的价值观和许多产业化软件团队的价值观是有所差异的。Martin 和我都知道:如果要让主流软件开发者接受重构思想, 重构带来的利益起码有一部分必须能够在短期内体现出来。 我们的研究团队[3], [9], [12], [13], [14], [15] 记录了数个例子,描述重构如何和程序功能的扩展交错进行,最终同时获得短期利益和长期利益。我们的一个例子是Choices 文件系统框架。最初这个框架实现了 BSD (Berkeley Software Distribution) Unix 文件系统格式。后来它又被扩展支持UNIX System V, MS-DOS、永续性(persistent )和分布式(distributed)文件系统。框架开发者采用的办法是:先把实现BSD Linux 的部分原样复制一份过来,然后修改它,使它支持System V。系统最终可以有效运作,但充斥大量重复的代码。加入新代码后,框架开发者重构了这些代码,建立abstract superclass 容纳两个Unix 文件系统的共通行为。相同的变量和函数被移到superclass 中。当两个对应函数几乎相同、但不完全相同时,他们就在subclass 中定义新函数来包容两者不同之处,然后在原先函数里头把这些代码换成对新函数的调用。这样一来,两个subclass 的代码就逐渐变得愈来愈相似了。一旦两个函数变得完全相同,就可以将它们搬移到共同的superclass 去。 这些重构手法为开发者提供了多方面好处,既有短期利益,也有长期利益。短期来看,如果在测试阶段发现共同的代码有错误,只需在一个地方修改就行了。代码总量变少了。「特定于某一文件系统的行为」与「两种文件系统的共同行为」清晰地分开了,这使得追踪、修补「特定于某种文件系统的行为」更加容易。中期来看,重构得到的抽象层对于定义后续文件系统常常很有帮助。当然,现有的两种文件系统的共通行为未必就完全适用于第三种文件格式,但现有的共享基础是一个很有价值的起点。后继的重构动作可以澄清究竟哪些东西真正是所有文件系统共有的。框架开发团队发现:随着时间流逝,「增加新文件系统的支持」愈来愈省劲。就算新的格式更复杂、开发团队经验更浅,情况也一样。 我还可以找出其他例子来证明重构能够带来短期和长期利益,但是Martin 早已做了 此事,我不想再延长他的列表。还是拿我们都非常熟悉的一件事来做个比喻吧:我 们的身体健康状况。 从很多角度来说,重构就好像运动、吃适当的食物。许多人都知道:我们应该多锻炼身体,应该注意均衡饮食。有些人的生活文化中非常鼓励这些习惯,有些人没有这些好习惯也可以混过一段时间,甚至看不出有什么影响。我们可以找各种借口, 但如果一直忽视这些好习惯,那么我们只是在欺骗自己。 有些人之运动和均衡饮食,动机着眼于短期利益(例如精力更充沛、身体更灵活、 自尊心增强……等等)。几乎所有人都知道这些短期利益非常真实。许多人(但不是所有人)都时断时续做过一些努力,另一些人则是不见棺材不掉泪,不到关键时 刻不会有足够动力去做点什么事。 没错,做事应该谨慎。在着手干一件事之前,应该先向专家咨询一下。在开始运动和均衡饮食之前,应该先问问自己的保健医生。在开始重构之前,应该先查找相关资源——你手上这本书和本章引用的其他数据都很好。对重构有丰富经验的人可以 向你提供更到位的帮助。 我见过的一些人正是「健康与重构」的典范。我羡慕他们旺盛的精力和超人的工作性能。反面典型则是明显的粗心大意爱忘事,他们的未来和他们开发的软件产品的未来,恐怕都不会很光明。 重构可以带来短期利益,让软件更易修改、更易维护。重构只是一种手段,不是目的。它是「程序员或程序开发团队如何开发并维护自己的软件」这一更宽广场景的一部分⑶。 降低重构带来的额外幵销(Reducing the Overhead of Refactoring) 『重构是一种需要额外开销的活动。我付钱是为了让程序员写出新的、能带来收益的软件功能』。对于这种声音,我的回复总结如下: - 目前已有一些工具和技术,可以使重构「快速」而「相对无痛苦」地完成。 - 一些面向对象程序员的经验显示,重构虽然需要额外开销,但可以从它「在程序开发的其他阶段协助降低所需心力及滞怠时间」而获得补偿。 - 尽管乍见之下重构可能有点笨拙、开销太大,但是当它成为软件开发规则的一部分,人们就不会再觉得它费事,反而开始觉得它是必不可少的。 - 伊利诺斯大学的软件重构团队开发的Smalltalk 自动化重构工具也许是目前最成熟的自动化重构工具〔参见第14章〉。你可以从他们的网站([http://st-www.cs.vivc.edu)自由下载这个工具。尽管其他语言的重构工具还没能这么方便,但是我们的论文和本书介绍的许多技术,都可以相对简单地套用,只要有一个文本编辑器或一个浏览器就足够了。软件开发环境和浏览器技术已经在最近数年获得了长足发展。我们希望将来能看到更多重构工具投入使用。](http://st-www.cs.vivc.edu%EF%BC%89%E8%87%AA%E7%94%B1%E4%B8%8B%E8%BD%BD%E8%BF%99%E4%B8%AA%E5%B7%A5%E5%85%B7%E3%80%82%E5%B0%BD%E7%AE%A1%E5%85%B6%E4%BB%96%E8%AF%AD%E8%A8%80%E7%9A%84%E9%87%8D%E6%9E%84%E5%B7%A5%E5%85%B7%E8%BF%98%E6%B2%A1%E8%83%BD%E8%BF%99%E4%B9%88%E6%96%B9%E4%BE%BF%EF%BC%8C%E4%BD%86%E6%98%AF%E6%88%91%E4%BB%AC%E7%9A%84%E8%AE%BA%E6%96%87%E5%92%8C%E6%9C%AC%E4%B9%A6%E4%BB%8B%E7%BB%8D%E7%9A%84%E8%AE%B8%E5%A4%9A%E6%8A%80%E6%9C%AF%EF%BC%8C%E9%83%BD%E5%8F%AF%E4%BB%A5%E7%9B%B8%E5%AF%B9%E7%AE%80%E5%8D%95%E5%9C%B0%E5%A5%97%E7%94%A8%EF%BC%8C%E5%8F%AA%E8%A6%81%E6%9C%89%E4%B8%80%E4%B8%AA%E6%96%87%E6%9C%AC%E7%BC%96%E8%BE%91%E5%99%A8%E6%88%96%E4%B8%80%E4%B8%AA%E6%B5%8F%E8%A7%88%E5%99%A8%E5%B0%B1%E8%B6%B3%E5%A4%9F%E4%BA%86%E3%80%82%E8%BD%AF%E4%BB%B6%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E5%92%8C%E6%B5%8F%E8%A7%88%E5%99%A8%E6%8A%80%E6%9C%AF%E5%B7%B2%E7%BB%8F%E5%9C%A8%E6%9C%80%E8%BF%91%E6%95%B0%E5%B9%B4%E8%8E%B7%E5%BE%97%E4%BA%86%E9%95%BF%E8%B6%B3%E5%8F%91%E5%B1%95%E3%80%82%E6%88%91%E4%BB%AC%E5%B8%8C%E6%9C%9B%E5%B0%86%E6%9D%A5%E8%83%BD%E7%9C%8B%E5%88%B0%E6%9B%B4%E5%A4%9A%E9%87%8D%E6%9E%84%E5%B7%A5%E5%85%B7%E6%8A%95%E5%85%A5%E4%BD%BF%E7%94%A8%E3%80%82/) Kent Beck 和 Ward Cunningham 都是经验丰富的Smalltalk 程序员,他们已经在OOPSLA 和其他论坛上提出报告:重构使他们能够更快开发证券交易之类的软件。 从C++ 和CLOS 开发者那里,我也听到了同样的消息。本书之中Martin 介绍了重构对于程序的好处。我们希望读过本书、使用书中介绍的重构原则的人们,能够给我们带来更多好消息。 从我的经验看来,只要重构成为日常事务的一部分,人们就不会觉得它需要多么高昂的代价。说来容易做来难。对于那些怀疑论者,我的建议就是:只管去做,然后自己决定。但是,请给它一点时间证明它自己。 安全地进行重构 安全性(safety)是令人关心的议题,特别对于那些开发、维护大型系统的组织更是如此。许多应用程序背负着财政、法律和道德伦理方面的压力,必须提供不间断的、可靠的、不出错的服务。有许多组织提供大量培训和努力,力图以严谨的开发过程来帮助他们保证产品的安全性。 但是,对很多程序员来说,安全性的问题往往没那么严重。我们总是向孩子们灌输 「安全第一」的思想,自己却扮演渴望自由的程序员、西部牛仔和血气方刚的驾驶员的角色,这实在是个莫大讽刺。给我们自由,给我们资源,看我们飞吧。不管怎 么说,难道我们真的希望公司放弃我们的创造性果实,就为了获得可重复性和一致性吗? 这一节我将讨论安全重构(safe refactoring)的方法。和Martin 在本书先前章节介 绍过的方法相比,我关注的方法其结构比较更组织化、更严格,可因此排除重构可能引入的很多错误。 安全性(safety)是一个很难定义的概念。直观的定义是:所谓「安全重构」(safe refactoring)就是不会对程序造成破坏的重构。由于重构的意图就是在不改变程序行为的前提下修改程序结构,所以重构后的程序行为应该与重构前完全相同。 如何进行安全重构呢?你有以下数种选择: - 相信你自己的编码功力。 - 相信你的编译器能捕捉你遗漏的错误。 - 相信你的测试套件(test suite )能捕捉你和编译器都遗漏的错误。 - 相信代码复审(code review)能捕捉你、编译器和测试套件(test suite )都遗漏的错误。 Martin 在他的重构原则中比较关注前三个选项。大中型公司则常常以代码复审作为前三个步骤的补充。 尽管编译器、测试套件、代码复审、严守纪律的编码风格都很有价值,但所有这些方法还是有下列局限性: - 程序员是可能犯错的,你也一样(我也一样)。 - 有一些微妙和不那么微妙的错误,编译器无法捕捉,特别是那些与继承相关的作用域错误(scoping errors)[1]。 - Perry and Kaiser[16] 和其他人已经指出,尽管「将继承作为一种实现技术」的作 法让测试工作简单了不少,但由于先前「向class 的某个实体发出请求」的很多操作如今「转而向subclass 发出请求」,我们仍然需要大量测试来覆盖这种情况。除非你的测试设计者是全知全能的上帝,或除非他对细节非常谨慎,否则就有可能出现测试套件禝盖不到的情况。「是否测试了所有可能的执行 路径」?这是一个无法以计算判定的问题。换句话说,你无法保证测试套件覆盖所有可能情况。 - 和程序员一样,代码复审人员也是可能犯错的。而且复审人员可能因为忙于自己的主要工作,无法彻底检杳别人的代码。 我在研究工作中使用的另一种方法是:定义并快速实现一个重构工具前原型,用以检查某项重构是否可以安全地施加于程序身上。如果可以,就重构之。这避免了大量可能因为人为错误而引入的臭虫。 在这里,我将概括介绍我的安全重构(safe refactoring)法。这可能是本章最具价值的一部分了。如果你想获得更详细的信息,请看我的论文[1]和本章末尾所列的参考文献,也可以参考本书第14章。如果你觉得这一部分有点过分偏重技术,不妨跳过本节余下的数小段。 我的重构工具的一部分是程序分析器(program analyzer),这是一个用来分析程序结构的程序(被分析的对象是将来打算施加某项重构的一个C++ 程序)。这个工具可以解答一系列问题,内容涉及作用域(scoping)、型别(typing)和程序语义 (程序的意图或用途)等方面。作用域的问题与继承有关,所以这一分析过程比起很多「非面向对象程序分析」要复杂;但的某些语言特性(例如静态型别,static typing)又使得这一分析过程比起「对Smalltalk 等动态型别(dynamic typing ) 程序的分析」要简单。 举个例子,假设我们的重构是要删除程序中的某个变量。我的工具可以判断程序其他部分(如有的话)是否引用了这个变量。如果有,径自删除这一变量将会造成dangling references,那么这项重构就是不安全的。于是工具用户就会收到一个错误标记(error flag)。用户可能因此决定放弃进行这次重构,也可能修改程序中对此变量的引用点,使它们不再引用它,然后才进行重构,删除该变量。这个工具还可以进行其他许多检查,其中大多数都和上述检查一样简单,有些稍微复杂。 在我的研究中,我把安全(safety)定义为:「程序属性(包括作用域和型别等等) 在重构之后仍然保持不变」。很多程序属性很像数据库中的完整性约束(integrity constraints )——修改database schemas (数据库表格的结构、架构、定义)时,完整性约束必须保持不变[17]。每个重构都伴随一组必要前提,如果这些前提得到满足,该重构就能保证程序属性获得维持。一旦确定某次重构的全部过程都安全,我的工具才会执行该次重构。 幸运的是,对于「重构是否安全」进行的检查(尤其是对于数量占绝对优势的低层重构〕往往是琐屑而平淡无奇的。为了保证较高层重构、较复杂重构的安全性, 我们以低层重构来定义它们。例如「建立一个abstract superclass 」的复杂重构手法就被定义为数个较小步骤,每个步骤都以较简单的重构完成,像是创建和搬移变量或函数等等。只要证明复杂重构的每一个步骤是安全的,我们就可以确定整个复杂 重构也是安全的。 在某些十分罕见的情况下,重构其实可以在「工具无法确认」时仍然安全施加于程序身上。在那种情况下,工具会选择较安全的方式:禁止重构。拿先前例子来说,你想删除程序中的某个变量,但程序其他地方对该变量有引用动作。然而或许这个引用动作所处段落永远不会被执行到,例如它也许出现于条件式(如if - then)中, 而它所处分支永远不为真。如果肯定这个分支永远不为真,你可以移除它,连同那个影响你重构的引用点一并移除。然后你就可以安全地进行重构,删除你想删除的变量或函数了。只不过,一般情况下你无法肯定分支永远为假(如果你继承了别人开发的代码,你有多大把握安全删掉其中某段代码?) 重构工具可以标记出这种「可能不安全」的引用关系,并向用户提出警告。用户可以先把这段代码放在一旁。一旦用户能够肯定引用点永远不会被执行到时,他就可以把这段多余代码移除,而后进行重构。这个工具让用户知道存在这么一个隐藏的引用关系,而不是盲目地进行修改。 这听起来好像有点复杂,作为博士论文的主题倒是不错(博士论文的主要读者——论文评议委员会——比较喜欢理论性题目),但是对于实际重构有用吗? 所有这些安全性检查都可以在重构工具中实现。如果程序员想要重构一个程序,只需以这个工具检查其代码。如果检查结果为「安全」,就执行重构。我的工具只是个研究雏型。Don Roberts, John Brant, Ralph Johnson 和我[10]后来实现了一个体质更健壮、功能更齐备的工具〔参见第14章),这是我们对于「Smalltalk 程序重构」 研究的一部分。 安全性可分很多不同级别施行于重构身上。其中某些级别容易实施,但不保证高级安全性。使用重构工具有很多好处。它可以在高级问题中做许多简单而乏味的检查和标记。如果不做这些检查,重构动作有可能导致程序完全崩溃。 编译、测试和代码复审可以指出很多错误,但也会遗漏一些错误,重构工具则可以帮助你抓住漏网之鱼。尽管如此,编译、测试和代码复审仍然是很有价值的,在实时(real-time)系统的开发和维护中更是如此。这些系统中的程序往往不是孤立运行的,它们是大型通信系统网络中的一部分。有些重构不但把代码清扫干净,而且会让程序跑得更快。然而提升某个程序的速度,可能会在另一个地方造成性能瓶颈。 这就好像你升级CPU 进而提升了部分系统性能,你需要以类似方法来调整、测试系统整体性能。另一方面,有些重构也可能略微降低系统整体性能。一般说来,重构对性能的影响是微不足道的。 「安全性作法」用来保证重构不会向程序引入新错误。这些作法并不能检查或修复 程序重构前就存在的错误。但重构可以使你更容易找到并修复这些错误。