重构遗留代码(3):复杂的条件语句

旧代码,丑陋的代码,复杂的代码,意大利面条似的代码,鬼话废话……就是四个字:遗留代码。这是一个系列文章,将有助于你处理并解决它。

我喜欢以看待散文的方式看待代码。非常难以理解的、长的、杂乱的、一堆外来词拼接出来的语句。虽然有时确实需要这样的语句,但是多数时候,仅仅需要使用由简单词汇构成的短句。源代码也正是这样。复杂的条件语句很难理解。而冗长的方法就像无穷无尽的长句一样。

从散文到代码

这里有一个散文的例子来热身一下。首先,这是一个包含所有信息的长句,丑陋的那个。

如果服务器的房间温度低于5度,并且湿度上升到超过百分之五十但是保持在百分之八十以下,同时气压是稳定的,那么在网络和服务器管理领域有着三年以上工作经验的传感器技术人员John就会被通知,他必须在半夜醒来,穿好衣服,出门,开车或者在他没有车的情况下叫一辆出租车,到公司,进入房间,开启空调,一直等到温度上升至10度、湿度回落至百分之二十。

如果你不用重读就能明白、理解并且记住这个段落,我给你发个奖章(当然是虚拟的)。在一个复杂的句子里面包含冗长、纠缠不清的段落是非常难以理解的,不幸的是,由于我不知道足够的外来英语词汇,所以没能让这个句子更难懂。

简化

让我们来寻找简化这个句子的方法。整个句子的第一部分直到“then”都是一个条件。是的,原句确实很复杂,但我们可以把它归结为:如果环境条件预示着一种风险,那么应该做一些事情来应对在复杂句中,应对的方式是需要通知满足许多条件的某个人:即通知三级以上的技术支持人员。最后,复杂句中描述了一个从清醒到处理完事故的完整流程:期望环境参数恢复到正常水平。我们把分析的结果放到一起就是。

现在,修改后的长度仅仅是原文的20%。虽然我们不清楚细节,但是多数情况下,这些细节是不被关心的。源代码也正是如此。你有多少次关心过logInfo("Some message")的具体细节;方法的实现细节?可能只有在实现的过程中会关心一次。之后就只是将消息写入“info”分类中。或者当用户购买你的产品之一的时候,你在乎怎么为他开发票吗?不会的。你只关心当有人买下的它的时候,从库存里发出,并为客户开具发票。这样的例子是无穷无尽的。这也是我们正确编写软件的基础。

复杂的条件语句

在本节中,我们试着把散文哲学应用到我们的小游戏中。一步一步来。从复杂的条件语句开始。为了热身,我们先看一下简单的代码。

GameRunner.php文件中的第20行是这样子的:

如果把这句换成散文,是什么样子呢?如果一个介于最小answerID和最大answerID之间的随机数与wrongAnswer的ID相同,那么……

这并不是十分复杂,但我们仍可以让其变得更简单。换成“如果选择了错误的答案,那么……”怎么样?更好了,不是吗?

提取方法重构

我们需要找到一种方法,步骤或者技术来把条件陈述转移到别的地方。目的地可以是一个方法。或者在我们的例子中,因为代码并不在类中,那么就放到一个函数中。把转移到函数或者方法中的行为叫做“提取方法”重构。下面是Martin Fowler在他精彩的《Refactoring: Improving the Design of Existing Code(重构:改善既有代码的设计)》一书中定义了实施这种重构的几个步骤。如果你尚未读过这本书,那你应该把它加入到你的待读列表中。这是作为现代程序员的必读书之一。

根据我们的教程,我稍微简化了一下原始的步骤,从而更适合我们的教程来使用。

  1. 创建一个方法,方法的名字应该描述了方法的功能而不是如何实现。
  2. 从待提取的地方拷贝代码到方法中。请注意,这是拷贝,还不要删除原来的代码。
  3. 查看提取出来的代码中所有的局部变量。这些变量都必须作为方法的参数。
  4. 查看提取出来的代码中是否有临时变量。如果有的话,在方法中声明并且删除额外的参数。
  5. 把变量作为参数传递给目标方法。
  6. 用目标方法的调用替代提取出来的代码。
  7. 执行测试。

这样就基本完成了这个过程。但实际上,可能除了重命名之外,提取方法重构可以说是使用最频繁的重构方式了。所以,你必须理解它的机制。

幸运的是,现代的IDEs,如PHPStorm 提供了非常好的重构工具,正如我们已经在PHPStorm教程中见过的:PHPStorm: When the IDE Really Matters。所以我们可以利用手中的工具来实现重构,而不是完全的手工实现这个过程。这不容易出错,并且非常非常快。

仅需要选中所需的代码部分,然后右键点击。

IDE会自动分析得出,需要三个参数来运行我们的代码,然后生成下面的解决方案。

尽管这段代码在语法上是正确的,但会中断测试。在各种呈现给我们的红色,蓝色和黑色的输出中,我们可以找到错误的原因:


简单来说,编译器认为我们想对一个函数声明两次。但怎么会出现这个问题?这个函数仅仅在GameRunner.php中声明了一次!

看一下测试代码。generateOutput()方法对我们的GameRunner.php做了require()。而这方法至少被调用了两次。这就是错误的原因。

现在遇到了一个困境。由于随机数生成器的播种问题,我们需要控制值来调用我们的代码。

但是PHP中没有办法对一个函数声明两次,所以我们需要其他的解决方案。我们开始感受到金牌大师测试的负担。运行整个系统20000次,每次改变代码的一个小部分,这不是长久之计。这种测试除了占用大量时间外,还迫使我们改变代码以适应测试。这是坏测试的信号。需要修改代码,同时保证测试通过,但是修改应该有理由去修改,而且这个理由只能来自于源代码。

说的足够多了,现在需要的是一个解决方案,即使是一个临时的方案对目前也是可以的。我们的下一课将会涉及向单元测试的迁移。

一个解决问题的方法是把GameRunner.php中所有其他部分的代码都放在一个函数中,比如run()

如此就可以进行测试了,但是请明确,修改之后从控制台直接运行代码就无法运行游戏了。我们在代码的行为上做了轻微的改变。以此为代价换来了可测试性,最初我们是不愿这么做的。现在,如果想从控制台运行这段代码,就需要另外一个PHP文件,这个文件要引用或者包含需运行的代码,然后显式地调用run方法。这不是一个大的改变,但是需要记住,特别是有第三方参与者需要使用现有的代码。

另一方面,我们可以仅在测试中引入文件。

然后在generateOutput()方法内调用run()

目录结构、文件和命名

也许这是一个好机会来考虑目录和文件的结构。GameRunner.php中不再有复杂的条件语句,但是在继续回到Game.php文件前,不能留下个乱摊子。GameRunner.php不再能直接运行,我们需要用外部的方法调用来实现可测试,但这破坏了外部接口。而这么做的原因是,我们有可能在测试错误的东西。

我们的测试调用了GameRunner.php文件中的run()方法,这个方法引入了Game.php,并运行这个游戏,生成一个新的金牌大师文件。要是我们引入另外一个文件呢?我们让GameRunner.php调用run()方法来实现游戏的运行,且不做其他操作。那么要是代码中既没有逻辑会出错也不需要测试,这时将当前代码引动到另一个文件中会怎样?

现在就是一个完全不同的故事了。测试仅仅通过runner来访问代码,基本上,测试就是runner了。当然,新的GameRunner.php也仅仅调用一个方法来运行游戏了。它是一个真正的runner,除了调用run()方法外,不做其他任何事情。没有逻辑也就意味着不需要测试了。

讲到这儿,我们需要问自己一些其他的问题。我们真的需要RunnerFunctions.php吗?我们不能仅仅把这个方法转移到Game.php中?我们或许可以这样做,但是以我们目前的理解,函数应该放在哪里呢?所以目前理解的还不够,在未来的课程中,将会学习到如何放置方法。

我们也在尝试按照代码行为给文件命名。只是一堆对于runner来说为了满足其需要,我们认为这个时候应该放在一起的功能集合。将来的某个时候这会变成一个类吗?可能会。也可能不会。目前来讲,暂时是不需要了。

清理RunnerFunctions

再看一下RunnerFunctions.php文件,有些由我们引入产生杂乱的问题。

我们在run()方法中定义了:

这些定义只有一个原因存在,并且只在一个地方使用。那为什么不在方法中定义他们并且完全抛弃参数呢?

好的,现在测试可以通过了,而且代码更漂亮了,但是还是不足够好。

反向条件语句

对于人类的思维来讲,更容易理解正向推理。所以如果你能避免使用反向条件语句,你就应该这么做。在我们目前的例子中,方法是用来验证答案是否错误。当需要的时候检查有效性并否定它的方法更容易理解。

我们使用了重命名方法重构。同样,如果纯手工来是实现很麻烦,但是在任何IDE中就如同按下CTRL+r或者在菜单中选择合适的选项那么容易。为了使测试通过,我们同样需要把条件语句更新为取反的使用方式。

这让我们能够进一步的了解条件语句。在if语句中使用!实际上有帮助。它突出强调了一些条件是否定的。那我们是否按程序反转条件,从而避免使用取反呢?当然可以。

现在已经没有使用!的逻辑了,也没有在命名和返回错误情况时的使用反义词汇了。所有以上的步骤让我们的条件变得非常,非常容易理解。

Game.php中的条件语句

我们已经把RunnerFunctions.php简化到极致。现在来对Game.php操作。查找条件语句有很多方法,如果你愿意的话,可以简单通过看代买来扫描一下。这样比较慢,但有个好处,能够迫使你按顺序理解代码。

第二种查找条件语句的方法也很显然,搜索“if”或者“if(”即可。如果使用IDE内建的工具格式化过代码,那就可以确认所有的条件语句都具有相同的格式。我的格式是,“if”和括号之间有一个空格。再者,如果使用内建的工具搜索,那么所有找到的结果都会高亮显示,在我这里是黄色。

现在我们看到了所有查找的内容都高亮显示,像圣诞树一般。我们一个一个处理它们。我们知道怎么去做,做到可以用的技术,现在是时间处理它们了。

这看起来似乎相当合理。我们可以把它提取为一个方法,但是能不能找到一个好的名字让这个判定变得一目了然?

我打赌90%的程序员可以理解上面这个if语句。我们试着把注意力集中到当前方法所做的事上。并且我们的大脑也连接进该问题的领域。我们不愿意为了理解一个仅仅检查数是否是奇数的方法,而“开启另一个线程”去计算一个表达式的值。这是很多让我们分心的小原因中的一个,这些最终会毁掉整个逻辑推论过程。所以,我说让我们把它提取出来。

这样就好多了,因为它是关于问题的领域,并且不需要额外的大脑能量。

这看起来是一个好的备选方案。作为一个数学表达式来说,它不是很难理解,但是同样,它需要额外的加工。我问我自己,如果当前玩家位置到达了边界,这意味着什么?难道我们不能用更简洁的方式来表达它?我们大概可以。

这样就好多了,但是if中实际上发生了什么?玩家在告示板开始的地方时被重新定位的。游戏中开始了新的一圈。要是在以后,我们有了新的方式来开始新的一圈怎么办?当我们在私有方法中修改了底层逻辑时,if语句是否需要修改?当然不是!所以,让我们把这个方法以if语句的含义、语句中发生的行为来,而不是根据我们检查的内容。

当试着命名方法和变量的时候,要经常想想代码的行为而不是它代表的状态或者条件。一旦正确掌握了这个,那么代码需要重命名的地方就会显著减少。但是即使是一个经验丰富的程序员,在找到合适的名字之前,也要重命名三到五次。所以不要害怕频繁点击CTRL+r来重命名。所以在你没有检查你新增的方法的命名并使你的代码如散文一般之前,不要将你代码提交到工程的CVS上。重命名是如此容易的,所以你可以尝试多个名字,并且只用点击一下按钮,就可以还原。

90行处的if语句和我们之前遇到的一样。我们只要重用之前提取的方法即可。看,冗余剔除了!别忘了随时进行测试,即使你是使用IDE神奇的工具来进行重构。这是我们下一个要注意的地方,神奇的工具,有时也会出错。查看下65行。

我们声明了一个变量,并且只在作为新提取的方法的参数使用了一次。这种情况,强烈建议把变量放入方法内部。

同时不要忘记在if语句中调用方法时就不需要任何参数了。

askQuestion()方法中的if语句看起来是没有任何问题的,和currentCategory()中的一样。

这里看起来有一点复杂,但是还在可接受范围内,有着足够的表现力。

我们可以对这个进行改进。这是玩家走出边界的情况,与之前是明显的对比。但是根据我们之前所学习的,我们不希望看到状态。

这样就好多了,并且我们在172、189和203行处重用它。两处、三处、四处冗余代码都被排除了!

测试都通过了,所有的if语句都排除了复杂性。

最后的思考

从重构条件语句中还能学习到其他的课程。首先,对于理解代码意图有很大的帮助。其次,如果能够根据意图准确的命名方法,就能够避免将来对名字不必要的更改。在逻辑上发现重复比发现完全重复的代码要更难。或许你认为我们应该持续的排除冗余,但是我更愿意有能以我的性命相托的单元测试来发现冗余。金牌大师程序是不错的,但顶多是一个完全网,而非降落伞。

感谢阅读并且请继续关注我们下一个教程,我们将介绍第一个单元测试。

收藏 评论

关于作者:xiaoP_dZ

简介还没来得及写 :) 个人主页 · 我的文章 · 10

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部