我如何避免在修改遗留代码时破坏功能
请允许我自省一下。我已经在软件工程领域工作了31年。在这31年里,我修改了很多遗留的软件。
随着时间的推移,我在处理遗留代码时已经形成了某些习惯。因为在大多数项目中,我得到的报酬是提供易于维护的工作软件,所以我不能奢侈地花时间去完全理解我将要修改的遗留代码。所以,我倾向于略读。略过代码可以帮助我快速识别 repo 中的相关部分。这是一场与时间的赛跑,我没有时间去纠缠那些不太相关的细节。我一直在寻找代码中最相关的部分。一旦我找到它,我就会放慢脚步,开始分析它。
我在很大程度上依赖于我的电动工具--集成开发环境(IDE)。哪种电动工具并不重要;如今,它们几乎都能做同样的事情。对我来说,最重要的是有能力快速找到函数被调用的地方和变量被使用的地方。
迟早,在我浏览完代码并分析完我打算修改的代码段后,我确定了一个我想插入一些代码的地方。现在我明白了执行功能所涉及的类、组件和对象的含义,我先写一个测试。
之后,我写代码以使测试通过。我输入我打算使用的对象的名称,然后按点键(.),IDE就会做出反应,给我一个为该对象定义的全部方法列表。所有这些方法都可以从我光标所在的位置调用。
然后我选择对我来说有意义的方法。我填入空白处(也就是说,我为预期的参数提供数值),保存修改,然后运行测试。如果测试通过,我就完成了这个微观的改变。
我通常每小时要重复这个活动很多次。在整个工作日里,重复几十次,甚至几百次是很正常的。
我相信我修改软件的方式并不是我独有的工作习惯。我认为它描述了许多(我甚至可以说是大多数)软件工程师所坚持的典型流程。
一些看法
在这种修改遗留软件的方式中,第一件明显的事情是没有任何关于文档的工作。经验表明,软件开发人员极少花时间去接触文档。花费在准备文档和生成HTML风格的在线文档的时间往往被浪费。
相反,大多数开发者只依赖电动工具。而正确的做法是--IDE从不说谎,因为它们总是提供他们正在修改的系统的实时情况,而文档通常是陈旧的。
另一件事是,开发者并不按照源代码的写法来阅读它。当从头开始写代码时(第一遍),许多开发人员倾向于写长的函数。源代码往往会堆积起来。捆绑代码使它在第一遍时更容易阅读和推理,并进行调试。但是,在第一遍之后,人们很少,如果有的话,会按照写的方式来阅读代码。如果我们发现自己从头到尾阅读了整个函数,那很可能是因为我们已经用尽了所有其他的选择,别无选择,只能放慢脚步,用步行的方式阅读代码。然而,根据我的经验,这种缓慢而有序地阅读代码的情况很少发生。
乱七八糟的代码造成的问题
如果你在第一遍写代码时就保持原样(即长的函数,大量捆绑在一起的代码,以便于初步理解和调试),就会使IDE无能为力。如果你把一个对象所能提供的所有功能都塞进一个巨大的函数中,以后当你试图利用这个对象时,IDE就没有任何帮助了。集成开发环境将显示一个方法的存在(该方法可能包含一个大的参数列表,提供强制执行该方法内的分支逻辑的值)。因此,除非你打开它的源代码并仔细阅读其处理逻辑,否则你不会知道如何真正使用该对象。即使这样,你的头也可能会痛。
匆忙拼凑起来的 "捆绑式 "代码的另一个问题是,它的处理逻辑是不可测试的。虽然你仍然可以为该代码写一个端到端的测试(输入值和预期的输出值),但你没有办法知道这个拼凑起来的代码是否在做任何其他有潜在风险的处理。而且,你也没有办法测试边缘情况、不寻常的情况、难以复制的情况等等。这使你的代码变得不可测试,这对你来说是一件非常糟糕的事情。
长的函数或方法总是思维混乱的标志。当一个代码块包含许多语句时,这通常意味着它正在做太多的处理。把大量的处理塞进一个地方,通常意味着开发者没有仔细考虑过问题。
你不需要进一步了解公司通常是如何组织的。与其让数百名员工在一个部门工作,公司往往分成许多小部门。这样一来,职责所在就更清楚了。
软件代码也不例外。一个应用程序的存在是为了将许多复杂的处理过程自动化。处理过程被分解成多个小步骤,所以每个步骤必须被映射到一个单独的、隔离的代码块上。你通过提取方法来创建这种单独的、隔离的、自主的代码块。你把一个长而庞大的代码块,通过提取责任将其分解成独立的代码块。
开发人员写软件代码,但开发人员更多的是在消费(即阅读),而不是编写。
在消费软件代码时,如果代码具有表现力,就会有帮助。表达性可以归结为适当的结构和适当的命名。考虑一下下面的说法。
if((x && !y) && !b) || (b && y) && !(z >= 65))
如果不运行代码,不使用调试器踏过它,就根本不可能理解这句话的含义和意图。这种活动被称为GAK(Geek at Keyboard)。它是百分之百的无用功,而且相当浪费。
在这里,提取方法和正确的命名方法就能发挥作用了。把包含在if
语句中的复杂语句,提取到它自己的方法中,并给这个方法起一个有意义的名字。比如说。
public bool IsEligible(bool b, bool x, bool y, int z) {
return ((x && !y) && !b) || (b && y) && !(z >= 65);
}
现在用一个更易读的语句来替换丑陋的if
语句。
if(IsEligible(b, x, y, z))
当然,你也应该用更有意义的名字来替换笨重的单字符变量名,以提高可读性。
重用遗留的代码
经验表明,任何没有被提取和正确命名并移到最合理的类中的功能都不会被重复使用。提取法促进了频繁的重复使用,这对提高代码质量有很大帮助。
测试遗留代码
为现有的代码编写测试是很难的,而且感觉比做测试驱动开发(TDD)的回报要少。即使在你确定应该有几个测试来确保生产代码按预期运行后,当你意识到生产代码必须被改变以实现测试时,你往往决定跳过编写测试。在这种情况下,实现你的目标,交付可测试的代码,缓慢但肯定地,不断减少。
为遗留代码编写测试是乏味的,因为它经常需要大量的时间和代码来设置前提条件。这与你在做TDD时写测试的方式正好相反,在TDD中,写前提条件的时间是最少的。
使遗留代码可测试的最好方法是实践提取方法的方法。找到一个嵌套在循环和条件中的代码块,并将其提取出来,就能写出小而精确的测试。对提取的函数进行这样的测试,不仅可以提高代码的可测试性,还可以提高可理解性。如果遗留的代码由于提取方法和编写清晰的测试而变得更容易理解,那么引入缺陷的机会就会大大减少。
总结
大部分关于提取方法的讨论在TDD中是没有必要的。先写一个测试,然后让测试通过,然后扫描该代码,以获得更多关于代码应该如何结构化和改进的见解,进行改进,最后对代码库的一部分进行修改,这就保证了不需要担心提取方法的问题。由于遗留代码通常意味着不是用TDD方法制作的代码,你不得不采用不同的方法。根据我的经验,当涉及到修改遗留代码时,提取方法能带来最大的收益,同时避免了破坏功能的风险。
转载自:https://juejin.cn/post/6981338597018304525