揭示常见的重构误区
作者 Danijel Arsenovski译者 张逸 发布于 2008年11月3日 下午10时49分
公正地说,.NET社区对于重构技术的研究起步太晚。直到今天,.Net开发的旗舰产品Visual Studio仍然无法在C#中突破重构的界限(http://www.martinfowler.com /articles/refactoringRubicon.html)。Visual Basic以及最新的C++情况略好,但却需要你下载和安装一个免费的重构插件Refactor!,它是Developer Express为VB或C++开发的。
之后的所有替代品都不再是免费的晚餐。虽然这些产品完全配得上你的投入,然而当我们开始关注那些诸如“代码质量”等虽非必要却极为深奥的要素,并达 成一致意见时,这些产品却难以成为开发者的主流工具。即使不使用工具,你仍然可以进行重构,但手工方式会由于太过复杂而会将开发者拒之门外。无怪 乎.Net社区对重构的引入会大大地滞后,因为我们对于重构的所有问题及其作用,依旧混乱不堪。
本文试图列出一些我经常遇到的使用重构的误区。这些误区与某些传统的对编程的偏执一样,总是会成为吸取技术精华的壁垒。紧接着,我还会列举某些先入 为主的误解,试图阐释其起源,并给出有力的证据驳斥这些论点。我希望本文能为每个人澄清对重构本质的怀疑,让他们学会成为一个重构者,或者在他的团队中建 立并推广这种实践。
“如果没有坏,就不要修复它”
这句话老少相传,可谓工程智慧的真实写照,然而,“如果没有坏,就不要修复它”只会滋生得过且过的情绪。重构有足够充分的理由来摒弃这种思想。
在你的编程生涯的早期,一定明白“千里之堤,溃于蚁穴”的道理,并为之而付出过深深地代价。即使一个细微的变化,都会导致软件在最糟糕的时刻以令人 莫名惊 诧的方式而停止运转。一朝被蛇咬,十年怕井绳,故而你害怕任何变化的发生,只要变化不是必须的。然而这只能够苟延残喘维持一时。一旦形势急转直下,出现的 错误不得不解决,新的功能需求也不能一拖再拖了。此时你面对的代码即使是相同的,但实际却已养成了大患。
那些接受了“如果没有坏,就不要修复它”信条的开发人员认定重构是可有可无的,甚至会干扰既定的开发目标。实际上,这种试图维持“现状”的顺从姿态,来源于某种心理,他们认为对代码的恐惧,以及无法掌控的事实都是合理的。
许多经验丰富的程序员之所以认可这种观点,是因为对一些不必要工作“偷工减料”,乃人之常情,是合情合理的。譬如说,如果应用程序已经具备优越的性 能,就无需为了性能对处理器周期耗费心机。类似的投机性设计,通常会用来搪塞那些具有前瞻性的编程观点,诸如“我们可能会在将来的某一天需要这一特性”。
从这个意义上讲,重构需要随时进行。在重构时,你需要消除冗余代码,避免投机性设计或预先优化。
而对于重构的老手来讲,这样的软件完全是“金玉其外,败絮其中”。如果设计有瑕疵,例如拙劣的代码和糟糕的结构,那么在软件外部是看不到这些问题 的。然而 即使应用程序在某个时候能够正常运行,我们仍然需要对此进行重构,对设计进行优化。从这个意义上讲,重构坚持在一些不太明显,但却具有决定性作用的特征上 作文章,例如设计、简单性,以及改善源代码的可读性,便于理解。
重构可以帮助你赢回对代码的支配权。这对于那些业已脱离控制的代码库而言并非易事,如果不诉诸于重构,那么唯一的解决之道就是彻底地重写。
重构不是新生事物
换而言之,这种误解可以这样说:“重构无非就是一种新瓶装旧酒的说辞罢了。”这意味着你对于如下种种早已熟谙于心:编写好的代码,面向对象设计,编码风格,最佳实践,如此等等,不一而足。重构无非是某些人的故作玄虚之语,编造出来用以兜售自己的新书,如此而已。
对,重构从一诞生之初,就从未放言像面向对象或面向方面编程那般会成为一种划时代的全新模式。它仅仅是从根本上改变你编码的方式:它定义了一些规 则,使得利用工具(例如点击按钮)完成代码的复杂转换成为可能。你不应将代码看作是不易修改的僵化的结构。相反,你应该看到自己有能力维持代码总是恰当好 处,有效地应对新的挑战,而无需害怕修改代码。
重构是高科技
编程并不易为。这是一项复杂活动,需要大量的知识积累。某些知识可能很难掌握。VB程序员如果要掌握Visual Basic .NET,必须具备熟练运用面向对象语言的能力。对于多数人而言,这是一大困惑。而其好处则在于学习一门新的技能绝对是物有所值的。
重构的伟大之处就在于它的简单。只需了解很小的一套简单规则,你就可以“盛装出发”了。再加上一个好的工具,迈开重构的第一步简直就是轻而易举。与 当前一 个高级程序员应该了解的其他技术相比,例如UML或设计模式,我得说重构有着最简单的学习曲线,就像VB和其他编程语言相比一样。
学习重构很快就会迎来你的收获季节。当然,与世间万事万物相同,知识的学习总是“一份耕耘,一份收获”的。
重构会导致性能低下
复杂点儿的说法是“因为重构通常会引入大量的细粒度元素(如方法和类),这种间接的设计会导致性能的损失。”
让我们把时钟往回调一小段,你会发现这样的观点似曾相识,当初在质疑面向对象编程时,就发出过类似奇怪的声音。
事实的真相是代码结构重构与否,在性能上的区别微乎其微,所以常常可以忽略不计,除非是某些特别的系统。
经验证明,性能总是受制于某一段确定的代码。在优化阶段修复它们,可以获得你需要的性能等级。能够轻易地识别关键代码是重中之重。减少代码的重复与数量,从而使得代码易于理解,一旦发生变化,也只会影响到单独的模块,这样的重构极大地改善了优化的过程。
当我们发现在一段日子里,CPU好似上足了马力一般,使用率不停上升,而代码的其他特性,例如可维护性、质量、可伸缩性以及可靠性又使得我们不得不将性能置诸脑后。而现在,我们再也不能以这样的理由为编写出性能糟糕的代码寻求托辞了,当然,我们也不能矫枉过正。
面临这样的境况,你可以看看本文提供的一些数据,也算是我的一点小小经验。我会使用两个代码示例。第一个例子代码结构简陋,且只有一个单独的 Main方 法。第二个例子的Main方法则被放到一个模块中,其中定义的一个类Circle包含了几个细粒度的方法。最初,我使用这些示例仅仅是为了演示非结构化代 码与结构化代码的编码风格,因而缺乏一些用于量化的代码。
我计算了执行一个简单几何公式(求圆周长)的时间,为了避免编写一些涉及计算敏感应用的代码,我增加了一些数据库查询代码。为了量化值的准确性,我 将执行放入到一个循环中,重复执行10000次。由于我并没有打算获得极端精确的值,因此我使用了 System.Diagnostics.Stopwatch类用以捕捉耗时值,毕竟在这个案例中,Stopwatch的值已经足够精确了。
代码示例1:非结构化代码
Option Explicit On Option Strict On Imports System.Diagnostics Imports System.Data.SqlClient Namespace RefactoringInVb.Chapter9 Structure Point Public X As Double Public Y As Double End Structure Module CircleCircumferenceLength Sub Main() Dim center As Point Dim pointOnCircumference As Point 'read center coordinates Console.WriteLine("Enter X coordinate" + _ "of circle center") center.X = CDbl(Console.In.ReadLine()) Console.WriteLine("Enter X coordinate" + _ "of circle center") center.Y = CDbl(Console.In.ReadLine()) 'read some point on circumference coordinates Console.WriteLine("Enter X coordinate" + _ "of some point on circumference") pointOnCircumference.X = CDbl(Console.In.ReadLine()) Console.WriteLine("Enter X coordinate" + _ "of some point on circumference") pointOnCircumference.Y = CDbl(Console.In.ReadLine()) 'calculate and display the length of circumference Console.WriteLine("The lenght of circle" + _ "circumference is:") 'calculate the length of circumference Dim radius As Double Dim lengthOfCircumference As Double Dim i As Integer 'use stopWatch to measure transcurred time Dim stopWatch As New Stopwatch() stopWatch.Start() 'repeat calculation for more precise measurement For i = 1 To 10000 'add some IO Dim connection As IDbConnection = New SqlConnection( _ "Data Source=TESLATEAM;" + _ "Initial Catalog=RENTAWHEELS;" + _ "User ID=RENTAWHEELS_LOGIN;" + _ "Password=RENTAWHEELS_PASSWORD_123") connection.Open() Dim command As IDbCommand = New SqlCommand( _ "Select GETDATE()") command.Connection = connection Dim reader As IDataReader = command.ExecuteReader() reader.Read() reader.Close() connection.Close() radius = ((pointOnCircumference.X - center.X) ^ 2 + _ (pointOnCircumference.Y - center.Y) ^ 2) ^ (1 / 2) lengthOfCircumference = 2 * 3.1415 * radius Next stopWatch.Stop() Console.WriteLine(stopWatch.Elapsed) Console.WriteLine(lengthOfCircumference) Console.Read() End Sub End Module End Namespace
代码示例2:结构化代码
Option Explicit On Option Strict On Imports System.Data.SqlClient Namespace RefactoringInVb.Chapter11 Public Structure Point Public X As Double Public Y As Double End Structure Module CircleCircumferenceLength Sub Main() Dim circle As Circle = New Circle circle.Center = InputPoint("circle center") circle.PointOnCircumference = InputPoint( _ "point on circumference") Console.WriteLine("The length of circle " + _ "circumference is:") Dim circumference As Double Dim i As Integer 'use stopWatch to measure transcurred time Dim stopWatch As New Stopwatch() stopWatch.Start() 'repeat calculation for more precise measurement For i = 1 To 10000 circumference = circle.CalculateCircumferenceLength() Next stopWatch.Stop() Console.WriteLine(stopWatch.Elapsed) Console.WriteLine(circumference) WaitForUserToClose() End Sub Public Function InputPoint(ByVal pointName As String) As Point Dim point As Point Console.WriteLine("Enter X coordinate " + _ "of " + pointName) point.X = CDbl(Console.In.ReadLine()) Console.WriteLine("Enter Y coordinate " + _ "of " + pointName) point.Y = CDbl(Console.In.ReadLine()) Return point End Function Private Sub WaitForUserToClose() Console.Read() End Sub End Module Public Class Circle Private centerValue As Point Private pointOnCircumferenceValue As Point Public Property Center() As Point Get Return centerValue End Get Set(ByVal value As Point) centerValue = value End Set End Property Public Property PointOnCircumference() As Point Get Return pointOnCircumferenceValue End Get Set(ByVal value As Point) pointOnCircumferenceValue = value End Set End Property Public Function CalculateCircumferenceLength() As Double QueryDatabase() Return 2 * 3.1415 * CalculateRadius() End Function Private Function CalculateRadius() As Double Return ((Me.PointOnCircumference.X - Me.Center.X) ^ 2 + _ (Me.PointOnCircumference.Y - Me.Center.Y) ^ 2) ^ (1 / 2) End Function Private Sub QueryDatabase() Dim connection As IDbConnection = New SqlConnection( _ "Data Source=TESLATEAM;" + _ "Initial Catalog=RENTAWHEELS;" + _ "User ID=RENTAWHEELS_LOGIN;" + _ "Password=RENTAWHEELS_PASSWORD_123") connection.Open() Dim command As IDbCommand = New SqlCommand( _ "Select GETDATE()") command.Connection = connection Dim reader As IDataReader = command.ExecuteReader() reader.Read() reader.Close() connection.Close() End Sub End Class End Namespace
经过几次执行,可以看到两个例子的耗时相当接近。在我的机器上,它们的值在2.2到2.4秒之间。值得称许的一点细微差别是:非结构化示例的最小耗时为1.9114800,而结构化示例的则为2.0398497。在我看来,二者并无太大差异。
重构破坏好的面向对象设计
具有优美结构以及经过重构的代码,在菜鸟的眼里,是笨拙而粗陋的。方法是如此的短小,在他们看来简直言之无物。类显得不够重量级,仅仅包含屈指可数的几个成员。这样的代码看起来简直就等于没有嘛。
像类和方法那样,若要管理大量的元素,则意味着需要处理的复杂度会加大。
这样的观点常会引人误解。事实上,复杂度总是相同的。但重构后的代码会显得条理更清晰,结构更合理。
重构无法提供短期利益
一个占据主流的论调是重构可以使得你的程序更快。迄今为止,就我所知,并没有相关的研究(这是我强烈呼吁的)可以证明我的看法,但我的经验告诉我存 在这样的情形。这是唯一合乎逻辑的。由于你在整体上具有少量的代码,极少的重复以及清晰的意图,因此重构带来的益处很快就能彰显无遗,除非你处理的是一些 可有可无,也无任何实际意义的小规模代码。
重构只适宜敏捷团队
在敏捷方法学中,重构是被频繁提及的其中一项关键技术,因而通常的解释是,重构只有在遵循敏捷原则的团队中才能如鱼得水。
重构是敏捷团队不可或缺的
即使你的团队采取的方法别出蹊径,但在大多数时候,总是由你来负责管理编码方式,这时就是运用重构的时机。 其他的团队成员或管理方式可能会忽略这样一个事实,就是你需要不停地在IDE中使用“Refactor”选项。不管你的团队采取何种方法,都没有任何东西 可以阻止你对代码进行重构。如果你遵循小步重构,并在编码过程中定期执行,就能达到最佳的重构效果。某些实践例如严格的代码所有权或瀑布过程,可能会与重 构背道而驰。如果你能够证明重构从编程的视角来看是有意义的,你就可以开始构建你的支撑库,首先从你的伙伴开始,然后推广到整个团队。
重构可以在开发过程中作为独立的阶段实施,并由独立的团队执行
经理们通常对此深以为然。将重构视为一个独立的阶段,然后将其放在诸如实现阶段和测试阶段期间,从管理学的角度来看,容易给人一种错觉,认为重构就是甘特图的一根线条,可以轻易地将其压缩时间甚至移走。
事实上,为了成功地执行重构,你需要完整地理解整个问题域,了解需求、设计甚至实现阶段的细节。倘若你从一开始就没有将实施重构的人看作团队的一份子,也没有花时间与客户交流,分析需求和思考设计,你将很难改善最初的团队构建的内容。
遵循某种模型,则代码可以通过它在编码之后得到精化,就像工业生产过程中提炼出的某种物质那样,总会带来一些好处。若是不能切实地理解代码的意义, 则你真正能够对重构保有信心的只能是一些细微的改进。若在此种情形下,妄图通过重构使代码焕然一新,结果很有可能是南辕北辙,适得其反。代码若与问题域紧 密相关,就有可能使得事情变得更加糟糕,最终还会为你的应用程序引入bugs。
没有单元测试重构照样能够工作
我想,一些简单的重构可以在没有单元测试的情形下进行。重构工具与编译器自身可以提供一定的安全保障,不至于引入一些简单的人为错误。你也可以采用 传统方 式对代码进行测试,例如使用调试器或者执行功能测试。但这些手动的测试方法却是乏味而不值得信赖的。重构时,代码比以前对修改更为敏感与脆弱。若要避免不 必要的问题,则应添加NUnit单元测试放到项目中。在你执行每一小步重构时,就能够及时发现错误。
不要依赖于注释的观点未必正确
我敢担保这会导致某些看起来有趣的混乱与疑惑。毫无疑问,你已经千百次地被告知,在编写代码时一定要添加注释。作为一种好的编程实践,这种思想会帮 助别人理解你的代码。这通常意味着编码的方式是优秀的、有序的、专业的。因此,如果现在有人居然胆敢告诉你注释未必是一桩好事儿,你一定会大吃一惊。
添加注释的动机通常与代码重构一致。你应该竭力提高代码的可读性。在早期,编程工具受制于标识符的长度,故而注释成为了传达编程涵义的唯一选择。立 足于重 构,则要求代码是自解释性的,即选择正确的方法、类、变量以及其他标识符。同时,你应该避免为了相同的目的使用注释,因为注释不会被执行,且很容易作废。 在每日的编程成为急就章时,总是会忘记更新注释、文档、图示或其他次等级的工件。
匈牙利命名法怎么了?
并不只是Charles Simonyi的母语触发了创造匈牙利命名法的灵感。那些看起来像是匈牙利语的命名,例如a_crszkvc30LastNameCol和 lpszFile,并不会让我感到惊讶。如今,编译器会检查类型安全性,跟踪类型信息。在现代的IDE中,所有必要的类型信息都能被找到,并作为鼠标指针 的提示出现。在诸如C#和VB.Net的语言中,匈牙利命名法已经过时了。
然而,旧有的习惯总是很难改变。匈牙利命名法通常被看作是一种有效的然而却难以掌握的编程实践。难怪并非所有人都乐于看见自己掌握的传统命名规则原来已经过时。
使用那些类似人类的自然语言为变量命名,可以使得代码简明易懂。放弃那些有趣的前缀,改而使用别人能够轻松理解的词语吧。
结论
可以毫不夸张地说,重构是编程的一次变革,它从根本上改变了某些旧有的习惯。它必然会面对许多阻力,让不知所以者感到无比的困惑。
我希望本文能够为你打开重构之门。在你第一次展卷阅读时,不要惊讶于那些困扰你的问题。如果你正在倡导重构,或者试图向别人讲解重构,那么你应该时刻准备提出质疑。面对这种情形,我希望我已经提供了足够的论据,以证明采纳重构的原因与必要性是行得通的。
本文源于《Professional Refactoring in Visual Basic》一书,该书作者为Danijel Arsenovski,已由Wrox出版。
关于作者
Danijel Arsenovski是Wrox出版的《Professional Refactoring in Visual Basic》一书的作者。目前,他就职于Excelsys S.A(该公司为地区内的大量客户设计网上银行解决方案),担任产品和解决方案架构师。他对于重构的最初体验来自于对大型银行系统的整改, 从此,他就迷上了重构。他首创了利用重构完成代码从VB 6到VB.NET的升级。Arsenovski还是多个主要出版商的丛书作者,拥有微软认证解决方案开发专家(MCSD,Microsoft Certified Solution Developer)证书,并在2005年被提名为Visual Basic MVP。你可以通过电子邮件danijel.arsenovski@empoweragile.com与他取得联系,他的博客是 http://blog.vbrefactoring.com。