驯服烂代码之实践、总结与讨论

jopen 10年前

  何为“驯服烂代码”?

  国内程序员们在每天的编程工作中,都有可能面对烂代码。在国外,烂代码被称为 Legacy Code(遗留代码),其在国外通行的含义出自 2004 年出版的 Michael Feathers 所著的 Working Effectively with Legacy Code 一书(中文版书名译为《修改代码的艺术》)的前言对它的定义:遗留代码就是没有测试的代码。而在国内,程序员们对烂代码的定义会比上面的定义的范围更广一 些,即除了没有测试之外,烂代码还经常是难以理解和难以扩展的代码。

  如果我们把烂代码定义为“没有测试、难以理解和难以扩展的代码”,那么想要驯服烂代码,首先就要为烂代码编写测试,然后在测试的保护之下将其重 构到容易理解和容易扩展的状态。这里的“重构”,指的是在不改变软件外在行为的前提下,改进软件内部的实现代码,使其容易理解和扩展,以便于当需求变更时 对软件进行修改。而首先为烂代码编写测试,正是保证重构 “不改变软件外在行为”的前提的手段。

  程序员要在一坨烂代码上新增功能或修改 bug 时,首先要驯服这坨烂代码。理解上述驯服烂代码的过程并不难,但真的要驯服时,你会发现困难重重且没有头绪。要想让驯服烂代码的工作做得有章法,需要长期 和刻意地操练,总结其中的收获,并与其他程序员不断交流,以不断改进驯服烂代码的工作。

  驯服 Trivia 烂代码的编程操练

  2014 年 2 月 23 日,bjdp.org 北京设计模式学习组的第 13 次编程道场中,24 位匠友(软件匠艺之友)用 Java 语言,对一个编程操练题目 Trivia[1]进行了驯服烂代码的结对编程操练。Trivia 是一个答题闯关游戏,比赛时参赛者在游戏盘上按次序掷色子,根据色子掷出的数字来在游戏盘上前进相应的步数,并回答智力问题,如果回答正确会获得金币,如 果回答错误则要被关禁闭,不能参与下一次回答问题。

  作为 bjdp.org 的发起者和组织者,伍斌在这次活动前,已经对 Trivia 先后进行了两次驯服操练[2]。在本次活动中,伍斌首先分享了他在两次驯服 Trivia 烂代码过程中的一些体会,并现场编程演示了一些重构步骤。接着十几位匠友结对进行了编程操练。

  伍斌随后在 bjdp.org 的微信公众号 bjdp_org 中,撰写微信文章“8 个驯服烂代码的原则:bjdp.org 第 13 次编程道场回顾”,对这次活动进行了回顾,并转发到敏捷教练姚若舟所在的微信群。姚若舟随即发邮件对上述回顾给出了精彩的点评,伍斌随后给出了回复。下面 是带有姚若舟和武可的点评和伍斌回复的一些驯服烂代码编程道场的回顾要点。

  参加编程道场的其他 23 匠友分享了一些有关“驯服烂代码”的体会:

  - 在重构前,一定要先写测试代码,把要重构的代码先保护好,之后才能重构。

  - 在重构代码时应该要考虑性能。

【伍斌的观点】在重构代码时,若能先把代码的可读性和可扩展性重构好,那么就能让提高性能的工作更加轻松。

【姚若舟的点评】我很同意你的观点。改善性能不是代码重构的目标,通常情况下,重构之后结构良好的代码性能都是不错的。刚开始重构时,如果的确怕影 响现有代码的性能,可以考虑建立一些性能测试来守护一下。我最近就遇到过这样的情况,由于是第一次重构 PLSQL 的代码,我也不确定是否会影响性能。所以,我和同伴就写了一个性能测试,每 45 分钟运行一次,来确保性能没有比老代码来的差。实际的测试结果也证明了我们做的所有重构都没有影响性能,甚至重构后的代码可能还比原来的要快一点。

  【伍斌的回复】性能测试守护是个好主意!

  - 除了消除明显的重复代码,也要消除那些不大明显的重复代码

  - 在消除魔法数的过程中,同时也想把魔法数转移到另一个新类中,感觉有些顾此失彼。建议一次只作一件事,即可以先在那个“身兼数职”的原有类中消除魔法数,再把魔法数转移到一个新类中。

  - 在测试代码中,创建待测的类的实例这条语句,应该放到@Before里面,使得每个测试运行前,都能得到一个崭新的实例。而不要作为测试类的一个成员变量,以避免不同测试之间共享一个实例而造成相互干扰。

【姚若舟的点评】这一点和我的实践结果不一致。 如果“创建待测的类的实例这条语句”指的是 ClassUnderTest obj = new ClassUnderTest (),那么我可以很负责的说 obj 作为一个测试类成员变量初始化和在@Before 中初始化是没有任何区别的。原因是 JUnit(及很多其他的单元测试框架)在运行每一个测试方法时,都会创建一个新的测试类实例,因此不会共享那个被测试的 obj。当然也有个别框架不是这样处理的。

【伍斌的回复】赞叹您为验证而所做的实践。不过即使是这样,我个人还是愿意把创建待测实例放到 @Before 里面,因为 @Before 就是为解决不同测试相互独立而设计的接口,而我更愿意面向接口编程。

【武可的点评】@Before 只是保证方法在 test case 执行之前执行吧。我认为和测试是否相互独立,以及面向接口没有什么关系。

【伍斌的回复】我认为“@Before 是保证方法在 test case 执行之前执行”这句话本身就描述了@Before 的功能。如果把 JUnit 视作一个软件系统的服务端,那么程序员作为客户端使用 JUnit 的@Before 功能时,@Before 就可视作 JUnit 的一个接口。

  匠友们在活动中还产生了以下疑问:

  - 驯服烂代码不知从哪里开始

【伍斌的观点】先从区分哪些是不能修改的接口开始。

【姚若舟的点评】应该从找到代码臭味开始吧。:)

【伍斌的回复】我原先也是先找代码腐臭。但我发现不仅服务端的代码有腐臭,往往客户端代码也有腐臭。我认为先消除服务端的腐臭优先级更高,所以就先区分不能修改的服务端接口来定位服务端。

  - 结对编程的目的是什么?两人如何配合?如果两人想法不同该怎么办?

【伍斌的观点】结对编程的目的就是“知识的相互传递”,对于个人能增长技能,对于公司能减少因专职负责某个模块的程序员生病、休假而造成的“单点故 障”,让团队更健壮。结对编程中,两人的想法肯定会有所不同,这一点即使在日常不编程的工作中,也会时时碰到。我个人认为,解决方法也和日常碰到的情况一 样,即需要掌握良好的沟通方法,比如要摆正沟通的位置:沟通不是为了说服对方,而是为了了解对方。您了解对方越多,您就越能和对方配合好。

【姚若舟的点评】我同意你的观点。再补充几点结对遇到想法不同(争议)时可能做的事情:

  • 如果争议可以搁置,那就先把它记下来,之后有空时再讨论。我看到过很多结对时的争议其实都是可以被搁置的。
  • 如果争议无法被搁置,可以考虑通过一些手段(比如测试)来验证一下,避免大家空对空争论。
  • 如果争议无法被搁置,且不能简单或者马上得到验证(如一些代码设计争议),那么我觉得结对双方都要有让一步的觉悟。 毕竟很多时候不同选择之间的差异不大,走哪条路都是可以。不要纠结谁对谁错,而是要想办法尽快验证那些争议(假设)。

我可以分享一个真实的例子。我有次和 Lance Kind(康美国,长居中国的一位敏捷教练)结对,我们对上面提到的那个测试类成员变量初始化的问题有不同的理解。这个争议对于当时要解决的问题没有什么 影响。于是,我们决定搁置争议,事后再解决。后来,我们通过邮件的方式做了讨论和澄清。

【伍斌的回复】我很赞同“搁置、验证、让步”的解决策略。我曾和 Lance 作为同事一起工作过,他是个高手。

【姚若舟的点评】Trivia 这个 Kata 我大概两年前也练习过,代码在 https://github.com/JosephYao/refactored-trivia/tree/master /UglyTriviaGame (有不少问题,呵呵)。这个 Kata 是 Legacy Coderetreat 的练习,相信你也了解过 http://legacycoderetreat.typepad.com/。 我一直没有尝试做过 Legacy Coderetreat,因为我并不建议重构遗留代码和给遗留代码添加单元测试(尤其反对所谓的重构项目)。在没有任何业务价值驱动的前提下做这件事,我 觉得是没有意义的。虽然说 Trivia 作为练习无可厚非,但是会给参与者一种“此事可行”的假象。

我建议对遗留代码的重构和添加单元测试应该伴随着新功能的开发(或者其他有业务价值的事情),原因如下:

  • 一边增加新功能(产生业务价值),一边给修改涉及到的遗留代码重构和添加单元测试 (偿还技术债务),这样做更加经济合理。
  • 这样做需要考虑如何最少的修改遗留代码,如何让新代码和修改涉及的代码与遗留代码隔离(在单元测试中),如何用最少的时间和成本做好这件事。这些都是对程序员很好的锻炼
  • 如果说遗留代码是一个“坑”的话,那么以上面这个方式工作下去(填坑),这个 “坑”可能永远都不需要填满,原因是:
  • 有一部分遗留代码已经很稳定了(虽然可能没有自动化测试覆盖),这样的代码是不需要为他做任何事情的(如重构和添加单元测试)。
  • 另外有一部分遗留代码对应的业务功能其实并没有被使用或者使用的很少,这样的代码也是不需要为他做任何事情的。

基于上面的考虑,我会选择一个既要重构遗留代码(同时添加单元测试)又要增加新功能的 Kata 来做练习。我记得这样的 Kata 在 Coding Dojo Handbook 那本书里面看到过。

【伍斌的回复】赞同。我个人对“烂代码”的定义是:能够运行但对于代码维护者来说反馈迟缓的代码。这里的 “反馈迟缓”包括: 难理解、难扩展、难测试。 我的这个"烂代码"的定义,与 Michael Feathers 在 Working Effectively with Legacy Code 书中对 Legacy code 的定义(没有测试的代码就是 Legacy Code)本质上是一样的,即“没有测试”就是反馈迟缓。如果这个能运行的烂代码不需要维护,那么就没有必要驯服。但如果需要维护,比如增加新功能或修改 bug,那么就需要先驯服那些与维护相关的烂代码,再做维护。我也买了 Emily 的 Coding Dojo Handbook,Trivia 就是读了这本书后才尝试的。下次一定尝试一下这本书另外几个与 Legacy code 相关的 katas: Gilded Rose 和 Four Katas on a Racing-Car Theme。驯服 Trivia 这个操练本身我认为意义还是很大的,至少能收获一些驯服烂代码的心得。但我发现 Trivia 这个 kata 还算相对简单的烂代码,里面只有一个类。我想找一些有多个类、且这些类相互紧紧耦合、能运行的烂代码,这样能练习驯服烂代码的解偶手法,不知您是否有这样 的 kata?

  总结

  伍斌根据两次驯服 Trivia 烂代码的体会,整理出下面 8 个驯服烂代码的原则:

  1. 正在被客户端使用的服务端的公共接口不能改
  2. 如果没有测试保护,则不能改相关代码
  3. 让不能改的公共接口尽量地窄
  4. 尽量早地消除重复代码
  5. 尽量用整洁的代码替代注释
  6. 对于无法修改且“词不达意”的公共接口,要添加 what 注释来描述接口做了什么事情
  7. 要编写粒度大些的验收级别的测试,比如验收特征测试(Acceptance Characterization Test),来覆盖尽可能大的范围,且与实现细节解偶,有利于方便地进行代码接口实现层面的重构,减少测试编写和维护的数量
  8. 尽量多用 SonarQube 做代码内在质量的静态扫描

  姚若舟的点评与伍斌的回复的要点:

  1. 对遗留代码的重构和添加单元测试应该伴随着新功能的开发(或者其他有业务价值的事情)。如果这个能运行的烂代码不需要维护,那么就没有必要驯服。但如果需要维护,比如增加新功能或修改 bug,那么就需要先驯服那些与维护相关的烂代码,再做维护。
  2. 代码分为服务端和客户端,都会有代码腐臭。服务端的代码腐臭的驯服优先级要高于客户端。驯服烂代码,要先从寻找服务端的代码腐臭开始。
  3. 在重构代码时,若能先把代码的可读性和可扩展性重构好,那么就能让提高性能的工作更加轻松。
  4. 改善性能不是代码重构的目标,通常情况下,重构之后结构良好的代码性能都是不错的。
  5. 刚开始重构时,如果的确怕影响现有代码的性能,可以考虑建立一些性能测试来守护一下。
  6. 如果在测试类中定义如下成员变量 obj: ClassUnderTest obj = new ClassUnderTest (),那么 obj 作为一个测试类成员变量初始化和在@Before 中初始化是没有任何区别的。原因是 JUnit(及很多其他的单元测试框架)在运行每一个测试方法时,都会创建一个新的测试类实例,因此不会共享那个被测试的 obj。当然也有个别框架不是这样处理的。
  7. 在 JUnit 中,@Before 是保证该方法在每个 test case 执行之前都能执行。如果把 JUnit 视作一个软件系统的服务端,程序员作为使用 JUnit 的@Before 功能的客户端时,@Before 就可视作 JUnit 的一个接口,程序员要尽量面向接口编程。
  8. 结对编程的目的是团队内部知识的相互传递,以提升个人技能,并让团队避免关键路径上的单点故障。
  9. 当结对编程的两人意见不一致时,从心法上要做到“沟通不是为了说服对方,而是为了了解对方。”在手法上可以采用“搁置、验证、让步”的策略。

  [1] 编程操练题目 Trivia 的各种编程语言的源代码参见:https://github.com/wubin28/trivia

  [2] 第一次驯服 Trivia 的源代码:https://github.com/wubin28/TriviaJava;第二次驯服 Trivia 的版源代码:https://github.com/wubin28/TriviaJava-2nd

  感谢侯伯薇对本文的审校。

来自: InfoQ