身为一个软件工程师,我们不可避免的会遇到这样一些问题:不得不修改别人的代码,或者在别人的代码中添加新的功能。我们并不熟悉这些代码,它也可能在整个系统中与我们编写的部分无关。虽然这样的工作很困难,容易让人感到无奈,但是要达到足够的灵活性来也别的开发者一起编写代码,收获也蛮大的。这些收获包括提高影响力,修复烂软件,还能学到系统中以前并不了解的部分(还可以从其它程序员那里学到技术和技巧)。
在其它开发者的代码中工作时,既会感到郁闷,又会从中有益,考虑到这些因素,我们必须警惕一些极其容易出错的地方:
我们的自我意识:我们可能会认为自己最有能耐,但通常都不是。我们对要改变的代码知之甚少,不了解原作者的意图,也不了解多少年有哪些因素导致这些代码形成,以及作者在编写这些代码的时候使用了什么样的工具和框架。谦卑价值万金,我们应该时刻保持这种心态。
原作者的自我意识:我们要接触的代码来自另一个开发者,他/她有自己的网络、约束、最后期限等,当然也有他/她自己的生活(这会占用一点工作时间)。他/她也是一个人,当我们质疑他/她做出的决定,或者质问为什么代码这么糟糕的时候,他/她会自然地产生防御性心理。我们应该努力让原作者与我们合作,而不是成为我们工作的阻碍。
恐惧未知事物:我们很多时候会接触到只了解一点点甚至完全不了解的代码。这似乎是件可怕的事情:我们得对自己做出的改动负责,但我们就像是在一个没有光亮的黑屋子里走来走去。我们不需要害怕,而是应该建立起一个框架,可以在里面安心地进行大大小小的修改,同时确保我们不会破坏现有的功能。
所有开发人员,包括我们自己,都是人。因此在别人编写的代码上工作,会受到人性的影响。在本文中,我们会讲述五种方法,利用人性的优点,从现有代码和原作者身上取得尽可能多的收获,并改善代码既有的状态。虽然这个清单并不全面,但应用这些方法将确保我们在完成对别人代码的修改工作后,会有信心保持现有功能的工作状态,同时又能保证新功能融合在现有代码中。
1. 确保有测试
对于别的开发人员写出来的功能,它确实如预期一样工作吗?我们所做的修改是否会妨碍它按照预期工作?对此,唯一能让人产生信心完成前述问题的方式就是,用测试来支持代码。我们在阅读别人的代码时,会发现两种可能的状态:(1) 没有达到足够水平的测试,或者 (2) 有达到足够水平的测试。对于前者,我们会陷入创建测试的困境;而对于后者,我们可以使用现有的测试来确保我们所做的修改不会破解原来的代码,同时也能从测试中大量地了解到代码的意图。
创建新测试
这听起来可能很惨:我们在更改另一个开发人员的代码时,要对我们的行为负责,但我们无法保证更改是否会造成破坏。吐槽是没有用的。不管我们发现代码是什么状态,只要动了代码,就得对其负责。因此,我们应该在修改代码的时候控制自己的行为。如果不想造成破坏,那就自己写测试。
这很枯燥,但我们可以通过编写测试来了解代码,这也是它的主要优点。假如现在的代码工作良好,我们需要编写测试,使其在获得预期输入的情况下产生预期的输出。在写测试的过程中,我们会逐渐了解代码的意图和功能。比如,存在如下代码
publicclassPerson{privateintage;privatedoublesalary;publicPerson(intage,doublesalary){this.age = age;this.salary = salary; }publicvoidsetAge(intage){this.age = age; }publicintgetAge(){returnage; }publicvoidsetSalary(doublesalary){this.salary = salary; }publicdoublegetSalary(){returnsalary; }}publicclassSuccessfulFilterimplementsPredicate{@Overridepublicbooleantest(Person person){returnperson.getAge() <30&& ((((person.getSalary() - (250*12)) -1500) *0.94) >60000); }}
我们对其功能和代码中使用的魔法数字[译者注:指直接的数字常量]并不了解,但我们可以创建一组测试,根据已知的输入产生已知的输出。比如,通过简单的数学运算分析成功人士的薪资。我们发现如果 30 岁以下的人每年挣大约 $68,330,就会被认为是成功的(按代码中的标准)。虽然我们不知道那些魔法数字是什么意思,但我们知道它们会减少原始薪资。这样,$68,330 这个阈值是扣除前的基本薪资。使用这些信息,我们可以创建一些简单的测试,如下:
publicclassSuccessfulFilterTest{privatestaticfinaldoubleTHRESHOLD_NET_SALARY =68330.0;@Testpublicvoidunder30AndNettingThresholdEnsureSuccessful(){ Person person =newPerson(29, THRESHOLD_NET_SALARY); Assert.assertTrue(newSuccessfulFilter().test(person)); }@Testpublicvoidexactly30AndNettingThresholdEnsureUnsuccessful(){ Person person =newPerson(30, THRESHOLD_NET_SALARY); Assert.assertFalse(newSuccessfulFilter().test(person)); }@Testpublicvoidunder30AndNettingLessThanThresholdEnsureSuccessful(){ Person person =newPerson(29, THRESHOLD_NET_SALARY -1); Assert.assertFalse(newSuccessfulFilter().test(person)); }}
通过这三个测试,我们已经对当前代码的工作方式了有大致了解:如果一个人不到 30 岁,每年能挣 $68,300,他就被认为是成功的。我们可以创建更多测试来确保功能在边缘情况(比如没有年龄或薪资)下的正确性。而且建成一套自动化测试之后,它可以用以确保我们对现有代码的修改不会破坏现有的功能。
使用现存测试
在现有代码中存在足够测试的情况下,我们也可以从测试中了解不少东西。就像我们创建测试一样,我们可以通过阅读测试从功能级别来了解代码是如何工作的。另外,我们也可以了解到原作者所理解的代码功能。就算测试不是原作者,而是其他人(在我们之前)写的,它仍然可以向我们提供其他人对代码意图的理解。
即使现在的测试很有帮助,我们仍然要保持谨慎。我们很难判断测试是否和代码的变化保持一致。如果一致,我们就拥有理解代码的坚实基础;如果不一致,我们就必须小心不要被误导。比如,如果原薪资阈值是每年 $75,000,后来改为我们知道的 $68,330,那么这个过时的测试可能会把我们引入歧途:
@Testpublicvoidunder30AndNettingThresholdEnsureSuccessful(){ Person person =newPerson(29,75000.0); Assert.assertTrue(newSuccessfulFilter().test(person));}
这个测试仍然会通过,但不是预期的效果。它能通过不是因为正确的阈值,而是因为它超过了阈值。如果这个测试集中包括一个测试用例,其薪资只比阈值少 $1 时返回 false,那么第二个测试会失败,这表示阈值是错误的。如果套件没有这样的测试,那么旧的数据很容易对我们了解代码的实际意图产生误导。当存在疑问的时候,请相信代码:正如我们前端所展示的,解决阈值的问题表明测试并未针对实际的阈值。
此外,参考代码库日志(比如 Git 日志)来了解代码和测试用例:如果最后更新代码的时间比最后更新测试的时间要新得多(并且代码中存在重大的代码,比如修改阈值),那么测试可能已经过时,需要谨慎对待。注意,不要完全忽略它们,因为它们还可能为我们提供一些原作者(或最近编写测试的开发者)的资料,不它们可能包含过时或错误的数据。
2. 和编写代码的人谈谈
在任何涉及多个人的工作中,沟通都至关重要。无论是在公司中、越野旅行中或是在项目中,缺少沟通都极易产生严重后果。尽管我们在创建新代码的时候进行沟通,但当我们接触既存代码时,风险还是会增加。因为我们对既存代码的了解有限,我们所了解的东西有可能受到了误导,也有可能过于片面,因此,为了真正理解现有的代码,我们需要与编写它的人交谈。
在问问题的时候,我们要确保问题是有针对性的,能达到我们理解代码的目的。比如:
这段代码对应于系统蓝图的哪个部分?
你有没有相关的设计方案或图表?
有我需要注意的坑吗?
某个组件或类是做什么用的?
有没有你本想写进代码,当时却没有写的东西?为什么?
保持谦卑,从原作者那里寻找答案。几乎每个开发者都出现过这样的场景,他/她在那里看着别人的代码,问自己“他/她为什么要那样做?他们为什么不这么做?”然后花几个小时来得出本来只要原作者回答就能得到的结论。多数开发者都有能干的程序员,所以最好是假设我们看似糟糕的决定背后有个合理的理由(也可能没有,但在看别人代码的时候最好假设他有不错的理由;如果确实没有,我们可以通过重构来修改)。
软件开发中,沟通也存在一定的副作用。康威定律,这个最初于 1967 年由 Melvin Conway 提出的定律:
任何在设计系统的组织...都不可避免的会产生设计,其结构是组织沟通结构的副本。
也就是说,一个大团队紧密沟通,就有可能产生整体的、紧密耦合的代码,而一组相对较小的团队可能会产生更多独立、松耦合的代码(更多相关信息,请阅读康威定律解密)。对于我们来说,我们的通信结构不仅影响我们某段代码,还会影响整个代码库。因此,与原作者保持紧密的沟通是一个好办法,但我们应该避免过于依赖原作者。过分依赖会让原作者感厌烦,也可能在代码中产生不可预料的耦合。
虽然这可能有助于深入研究我们的代码,但这是我们假设可以接触原作者的情况下。在很多时候,原作者可能已离开公司,或者不在身边的(例如休假)。我们在这种情况下要做什么呢? 询问可能对此代码有想法的人。这并不一定是一个真正从事编码工作的人,但也可能是周围的某人或熟悉编写代码之人的人。只要从原作者身上得到哪怕一个想法,也有可能揭示一些代码中的未知片段。
3. 干掉所有警告
在心理学上有一个著名的概念叫“破窗理论”,这个理论由 Andrew Hunt 和 Dave Thomas 在程序员修炼之道(4-6页)揭示。这一理论,最初发展自 James Q. Wilson 和 George L. Kelling:
想像一栋有几扇破窗户的建筑。如果窗户没有修好,那么破坏者会趋于打破更多窗户。最终甚至有人会强行进入这栋建筑,如果这栋建筑没有住人,它可能会被占用甚至会有人在里面生火。也可以想像一下堆积着一些枯枝落叶的人行道。很快,就会产生更多的垃圾。最终,人们逐渐会在那里扔掉外卖的垃圾袋甚至报废的汽车。
这一理论认为,人性会放弃照管某个似乎已经无人照管的事务。比如,人们更容易去破坏显得凌乱的建筑。就软件而言,如果开发人员发现代码已经是一团糟,那么继续搞乱就很正常。从本质上来说,我们对自己说(尽管字不太多),“如果前任都不在乎,我为什么要在乎?”或者“我搞乱的东西会被隐藏在这个烂摊子下面”。
不过,这不应该成为我们的借口。我们应该停止推卸负责。一旦我们接触到他人留下的代码,就要对它负责,如果它出现问题,我们就得接受责问。为了确保我们能战胜这一人性发展的必须趋势,我们需要小步前进,逐步改善代码的凌乱状况(更换坏掉的窗户)。
有一个简单的方法是去掉整个包或模块中的所有警告,删除掉未使用或注释掉的代码。如果我们以后需要这些代码,可以从代码库之前的提交中找到它。如果存在不能解决的警告(如原始类型警告),对方法或者其调用添加 @SuppressWarnings 注解。这确保我们对代码进行了深思熟虑:它们不是因为疏忽造成的警告,而是已经注意到的警告(比如原始类型)。一旦我们删除或明确禁止所有警告,我们必须确保代码保持无警告状态。这有两个主要的含义:
它迫使我们对我们所创建的任何代码保持慎重。
它减少了代码腐烂的改动,这样警告会导致以后的错误。
这对他人或我们自己都有心理暗示作用,即我们是真的关心我们正在处理的代码。这不再是一个集合空间,其中我们盲目做出修改,提交,过后不再查看。相反,我们要对此代码的责任慎重一些。这也有助于未来的发展,向未来的开发者展示:这不是一个破窗的仓库:它是一个维护良好的代码库。
4. 重构
在过去几十年中,重构已经发展成为一个非常强大的述语,近年来它成为了变更工作代码的同义词。尽管重构确实涉及到对工作代码的修改,但这并不是它的完整意义。Martin Fowlerd 在它的开创性著作《重构》中将重构定义为:
对软件内部结构进行更改而不改变其表现的行为,使其更易于理解、更易于修改。
这个定义的关键在于它涉及的变化并不会改变系统的行为表现。也就是说,我们在重构代码的时候,必须保证代码对外部可见的行为不会发生变化。在我们的示例中就是指我们自己修改或创建的测试集。为了保证我们没有改变系统的外部行为,每次改变我们都应该重新编译并完整地进行测试。
此外,并非我们所做的每一次修改都可以被认为是重构。比如,重命名一个方法使其更好的反映其用途是一种重构,它加入了新功能就不是。为了看到重构的好处,我们会重构 SuccessfulFilter。我们首先要使用抽取方法这一重构手段来更好的封装计算个人净薪资的逻辑:
publicclassSuccessfulFilterimplementsPredicate{@Overridepublicbooleantest(Person person){returnperson.getAge() <30&& getNetSalary(person) >60000; }privatedoublegetNetSalary(Person person){return(((person.getSalary() - (250*12)) -1500) *0.94); }}
做出这个修改之后,重新编译并运行测试集,保持通过。现在的代码已经很容易看到成功的依据是年龄和净薪资,但是 getNetSalary 方法似乎并不属于 SuccessfulFilter,它应该是 Person 类(这样说是因为这个方法的唯一参数是 Person 对象,也只调用了 Person 的方法,所以它更接近 Person)。为了更好的放置这个方法,我们使用移动方法将它移动到 Person 类。
publicclassPerson{privateintage;privatedoublesalary;publicPerson(intage,doublesalary){this.age = age;this.salary = salary; }publicvoidsetAge(intage){this.age = age; }publicintgetAge(){returnage; }publicvoidsetSalary(doublesalary){this.salary = salary; }publicdoublegetSalary(){returnsalary; }publicdoublegetNetSalary(){return((getSalary() - (250*12)) -1500) *0.94; }}publicclassSuccessfulFilterimplementsPredicate{@Overridepublicbooleantest(Person person){returnperson.getAge() <30&& person.getNetSalary() >60000; }}为了进一步清理这段代码,我们对魔法数字分别执行将魔法数字替换为符号常量。为了找到每一个值的含义,我们可能要与原作者或者有足够相关领域知识的人交谈,以获得正确的结果。我们还会多次执行抽取方法重构以确保现在的方法尽可能简单。
publicclassPerson{privatestaticfinalintMONTHLY_BONUS =250;privatestaticfinalintYEARLY_BONUS = MONTHLY_BONUS *12;privatestaticfinalintYEARLY_BENEFITS_DEDUCTIONS =1500;privatestaticfinaldoubleYEARLY_401K_CONTRIBUTION_PERCENT =0.06;privatestaticfinaldoubleYEARLY_401K_CONTRIBUTION_MUTLIPLIER =1- YEARLY_401K_CONTRIBUTION_PERCENT;privateintage;privatedoublesalary;publicPerson(intage,doublesalary){this.age = age;this.salary = salary; }publicvoidsetAge(intage){this.age = age; }publicintgetAge(){returnage; }publicvoidsetSalary(doublesalary){this.salary = salary; }publicdoublegetSalary(){returnsalary; }publicdoublegetNetSalary(){returngetPostDeductionSalary(); }privatedoublegetPostDeductionSalary(){returngetPostBenefitsSalary() * YEARLY_401K_CONTRIBUTION_MUTLIPLIER; }privatedoublegetPostBenefitsSalary(){returngetSalary() - YEARLY_BONUS - YEARLY_BENEFITS_DEDUCTIONS; }}publicclassSuccessfulFilterimplementsPredicate{privatestaticfinalintTHRESHOLD_AGE =30;privatestaticfinaldoubleTHRESHOLD_SALARY =60000.0;@Overridepublicbooleantest(Person person){returnperson.getAge() < THRESHOLD_AGE && person.getNetSalary() > THRESHOLD_SALARY; }}
重新编译,然后测试,发现系统仍然如预期运行:我们没有改变外部行为,但我们已经改善了代码的内部结构和可靠性。想了解更多更复杂的重构方法和重构过程,请阅读 Martin Fowler 的重构,以及非常棒的重构大师网站。
5. 让代码比你发现它的时候更好
最后的方法在概念上很简单,做起来却很难:让代码比你发现的时候更好。我们在梳理代码,特别是别人的代码时,我们倾向于添加功能,测试新功能,然后继续,而不会关注我们为其贡献代码的软件存在糟糕的代码,或者我们新添加到某个类的方法可能会造成混淆。因此,本文总的来说可以归纳为如下原则:
当我们对代码进行更改时,确保它会比我发现它的时候更好。
如前所述,现在我们在对所修改代码负责,如果它有问题,我们会负责修复问题。为了抵御生产软件带来的负面影响,我们必须强制自己动过的代码会比原来更好。我们偿还技术债务而不是回避问题,确保下一个接触到这段代码的人不需要付出代价,并对其产生兴趣。没人知道以后如何,也许我们以后会感谢自己的及时修补。