专栏名称: 程序员的那些事
最有影响力的程序员自媒体,关注程序员相关话题:IT技术、IT职场、在线课程、学习资源等。
目录
相关文章推荐
OSC开源社区  ·  RAG市场的2024:随需而变,从狂热到理性 ·  17 小时前  
程序员的那些事  ·  印度把 DeepSeek ... ·  2 天前  
OSC开源社区  ·  宇树王兴兴早年创业分享引围观 ·  3 天前  
程序员的那些事  ·  成人玩偶 + ... ·  4 天前  
51好读  ›  专栏  ›  程序员的那些事

关于烂代码的那些事(下)

程序员的那些事  · 公众号  · 程序员  · 2017-08-24 19:12

正文

(点击 上方公众号 ,可快速关注)


作者:蛋疼的axb

如有好文章投稿,请点击 → 这里了解详情


本系列:


假设你已经读过烂代码系列的前两篇:了解了什么是烂代码,什么是好代码,但是还是不可避免的接触到了烂代码(就像之前说的,几乎没有程序员可以完全避免写出烂代码!)接下来的问题便是:如何应对这些身边的烂代码。


1.改善可维护性


改善代码质量是项大工程,要开始这项工程,从可维护性入手往往是一个好的开始,但也仅仅只是开始而已。


1.1.重构的悖论


很多人把重构当做一种一次性运动,代码实在是烂的没法改了,或者没什么新的需求了,就召集一帮人专门拿出来一段时间做重构。这在传统企业开发中多少能生效,但是对于互联网开发来说却很难适应,原因有两个:


  • 互联网开发讲究快速迭代,如果要做大型重构,往往需要暂停需求开发,这个基本上很难实现。

  • 对于没有什么新需求的项目,往往意味着项目本身已经过了发展期,即使做了重构也带来不了什么收益。


这就形成了一个悖论:一方面那些变更频繁的系统更需要重构;另一方面重构又会耽误开发进度,影响变更效率。


面对这种矛盾,一种方式是放弃重构,让代码质量自然下降,直到工程的生命周期结束,选择放弃或者重来。在某些场景下这种方式确实是有效的,但是我并不喜欢:比起让工程师不得不把每天的精力都浪费在毫无意义的事情上,为什么不做些更有意义的事呢?


1.2.重构step by step


1.2.1.开始之前


开始改善代码的第一步是把IDE的重构快捷键设到一个顺手的键位上,这一步非常重要:决定重构成败的往往不是你的新设计有多么牛逼,而是重构本身会占用多少时间。


比如对于IDEA来说,我会把重构菜单设为快捷键:



这样在我想去重构的时候就可以随手打开菜单,而不是用鼠标慢慢去点,快捷键每次只能为重构节省几秒钟时间,但是却能明显减少工程师重构时的心理负担,后面会提到,小规模的重构应该跟敲代码一样属于日常开发的一部分。


我把重构分为三类:模块内部的重构、模块级别的重构、工程级别的重构。分为这三类并不是因为我是什么分类强迫症,后面会看到对重构的分类对于重构的意义。


1.2.2.随时进行模块内部的重构


模块内部重构的目的是把模块内部的逻辑梳理清楚,并且把一个巨大无比的函数拆分成可维护的小块代码。大部分IDE都提供了对这类重构的支持,类似于:


  • 重命名变量

  • 重命名函数

  • 提取内部函数

  • 提取内部常量

  • 提取变量


这类重构的特点是修改基本集中在一个地方,对代码逻辑的修改很少并且基本可控,IDE的重构工具比较健壮,因而基本没有什么风险。


以下例子演示了如何通过IDE把一个冗长的函数做重构:



上图的例子中,我们基本依靠IDE就把一个冗长的函数分成了两个子函数,接下来就可以针对子函数中的一些烂代码做进一步的小规模重构,而两个函数内部的重构也可以用同样的方法。每一次小规模重构的时间都不应该超过60s,否则将会严重影响开发的效率,进而导致重构被无尽的开发需求淹没。


在这个阶段需要对现有的模块补充一些单元测试,以保证重构的正确。不过以我的经验来看,一些简单的重构,例如修改局部变量名称,或者提取变量之类的重构,即使没有测试也是基本可靠的,如果要在快速完成模块内部重构和100%的单元测试覆盖率中选一个,我可能会选择快速完成重构。


而这类重构的收益主要是提高函数级别的可读性,以及消除超大函数,为未来进一步做模块级别的拆分打好基础。


1.2.3.一次只做一个较模块级别的的重构


之后的重构开始牵扯到多个模块,例如:


  • 删除无用代码

  • 移动函数到其它类

  • 提取函数到新类

  • 修改函数逻辑


IDE往往对这类重构的支持有限,并且偶尔会出一些莫名其妙的问题,(例如修改类名时一不小心把配置文件里的常量字符串也给修改了)。


这类重构主要在于优化代码的设计,剥离不相关的耦合代码,在这类重构期间你需要创建大量新的类和新的单元测试,而此时的单元测试则是必须的了。


为什么要创建单元测试?


  • 一方面,这类重构因为涉及到具体代码逻辑的修改,靠集成测试很难覆盖所有情况,而单元测试可以验证修改的正确性。

  • 更重要的意义在于,写不出单元测试的代码往往意味着糟糕的设计:模块依赖太多或者一个函数的职责太重,想象一下,想要执行一个函数却要模拟十几个输入对象,每个对象还要模拟自己依赖的对象……如果一个模块无法被单独测试,那么从设计的角度来考虑,无疑是不合格的。


还需要啰嗦一下,这里说的单元测试只对一个模块进行测试,依赖多个模块共同完成的测试并不包含在内-例如在内存里模拟了一个数据库,并在上层代码中测试业务逻辑-这类测试并不能改善你的设计。


在这个期间还会写一些过渡用的临时逻辑,比如各种adapter、proxy或者wrapper,这些临时逻辑的生存期可能会有几个月到几年,这些看起来没什么必要的工作是为了控制重构范围,例如:


class Foo {

String foo() {

...

}

}


如果要把函数声明改成:


class Foo {

boolean foo() {

...

}

}


那么最好通过加一个过渡模块来实现:


class FooAdaptor {

private Foo foo;

boolean foo() {

return foo.foo().isEmpty();

}

}


这样做的好处是修改函数时不需要改动所有调用方,烂代码的特征之一就是模块间的耦合比较高,往往一个函数有几十处调用,牵一发而动全身。而一旦开始全面改造,往往就会把一次看起来很简单的重构演变成几周的大工程,这种大规模重构往往是不可靠的。


每次模块级别的重构都需要精心设计,提前划分好哪些是需要修改的,哪些是需要用兼容逻辑做过渡的。但实际动手修改的时间都不应该超过一天,如果超过一天就意味着这次重构改动太多,需要控制一下修改节奏了。


1.2.4.工程级别的重构不能和任何其他任务并行


不安全的重构相对而言影响范围比较大,比如:


  • 修改工程结构

  • 修改多个模块


我更建议这类操作不要用IDE,如果使用IDE,也只使用最简单的“移动”操作。这类重构单元测试已经完全没有作用,需要集成测试的覆盖。不过也不必紧张,如果只做“移动”的话,大部分情况下基本的冒烟测试就可以保证重构的正确性。


这类重构的目的是根据代码的层次或者类型进行拆分,切断循环依赖和结构上不合理的地方。如果不知道如何拆分,可以依照如下思路:


  1. 优先按部署场景进行拆分,比如一部分代码是公用的,一部分代码是自己用的,可以考虑拆成两个部分。换句话说,A服务的修改能不能影响B服务。

  2. 其次按照业务类型拆分,两个无关的功能可以拆分成两个部分。换句话说,A功能的修改能不能影响B功能。

  3. 除此之外,尽量控制自己的代码洁癖,不要把代码切成一大堆豆腐块,会给日后的维护工作带来很多不必要的成本。

  4. 案可以提前review几次,多参考一线工程师的意见,避免实际动手时才冒出新的问题。


而这类重构绝对不能跟正常的需求开发并行执行:代码冲突几乎无法避免,并且会让所有人崩溃。我的做法一般是在这类重构前先演练一次:把模块按大致的想法拖来拖去,通过编译器找到依赖问题,在日常上线中把容易处理的依赖问题解决掉;然后集中团队里的精英,通知所有人暂停开发,花最多2、3天时间把所有问题集中突击掉,新的需求都在新代码的基础上进行开发。


如果历史包袱实在太重,可以把这类重构也拆成几次做:先大体拆分成几块,再分别拆分。无论如何,这类重构务必控制好变更范围,一次严重的合并冲突有可能让团队中的所有人几个周缓不过劲来。


1.3.重构的周期


典型的重构周期类似下面的过程:


  1. 在正常需求开发的同时进行模块内部的重构,同时理解工程原有代码。

  2. 在需求间隙进行模块级别的重构,把大模块拆分为多个小模块,增加脚手架类,补充单元测试,等等。

  3. (如果有必要,比如工程过于巨大导致经常出现相互影响问题)进行一次工程级别的拆分,期间需要暂停所有开发工作,并且这次重构除了移动模块和移动模块带来的修改之外不做任何其他变更。

  4. 重复1、2步骤


1.3.1.一些重构的tips


  1. 只重构经常修改的部分,如果代码一两年都没有修改过,那么说明改动的收益很小,重构能改善的只是可维护性,重构不维护的代码不会带来收益。

  2. 抑制住自己想要多改一点的冲动,一次失败的重构对代码质量改进的影响可能是毁灭性的。

  3. 重构需要不断的练习,相比于写代码来说,重构或许更难一些。

  4. 重构可能需要很长时间,有可能甚至会达到几年的程度(我之前用断断续续两年多的时间重构了一个项目),主要取决于团队对于风险的容忍程度。

  5. 删除无用代码是提高代码可维护性最有效的方式,切记,切记。

  6. 单元测试是重构的基础,如果对单元测试的概念还不是很清晰,可以参考《使用Spock框架进行单元测试》。


2.改善性能与健壮性


2.1.改善性能的80%


性能这个话题越来越多的被人提起,随便收到一份简历不写上点什么熟悉高并发、做过性能优化之类的似乎都不好意思跟人打招呼。


说个真事,几年前在我做某公司的ERP项目,里面有个功能是生成一个报表。而使用我们系统的公司里有一个人,他每天要在下班前点一下报表,导出到excel,再发一封邮件出去。


问题是,那个报表每次都要2,3分钟才能生成。


我当时正年轻气盛,看到有个两分钟才能生成的报表一下就来了兴趣,翻出了那段不知道谁写的代码,发现里面用了3层循环,每次都会去数据库查一次数据,再把一堆数据拼起来,一股脑塞进一个tableview里。


面对这种代码,我还能做什么呢?


  • 我立刻把那个三层循环干掉了,通过一个存储过程直接输出数据。

  • sql数据计算的逻辑也被我精简了,一些没必要做的外联操作被我干掉了。

  • 我还发现很多ctrl+v生成的无用的控件(那时还是用的delphi),那些控件密密麻麻的贴在显示界面上,只是被前面的大table挡住了,我当然也把这些玩意都删掉了;

  • 打开界面的时候还做了一些杂七杂八的工作(比如去数据库里更新点击数之类的),我把这些放到了异步任务里。

  • 后面我又觉得没必要每次打开界面都要加载所有数据(那个tableview有几千行,几百列!),于是我hack了默认的tableview,每次打开的时候先计算当前实际显示了多少内容,把参数发给存储过程,初始化只加载这些数据,剩下的再通过线程异步加载。


做了这些之后,界面只需要不到1s就能展示出来了,不过我要说的不是这个。


后来我去客户公司给那个操作员演示新的模块的时候,点一下,刷,数据出来了。那个人很惊恐的看着我,然后问我,是不是数据不准了。


再后来,我又加了一个功能,那个模块每次打开之后都会显示一个进度条,上面的标题是“正在校验数据……”,进度条走完大概要1分钟左右,我跟那人说校验数据计算量很大,会比较慢。当然,实际上那60秒里程序毛事都没做,只是在一点点的更新那个进度条(我还做了个彩蛋,在读进度的时候按上上下下左右左右BABA的话就可以加速10倍读条…)。客户很开心,说感觉数据准确多了,当然,他没发现彩蛋。


我写了这么多,是想让你明白一个事实:大部分程序对性能并不敏感。而少数对性能敏感的程序里,一大半可以靠调节参数解决性能问题;最后那一小撮需要修改代码优化性能的程序里,性价比高的工作又是少数。


什么是性价比?回到刚才的例子里,我做了那么多事,每件事的收益是多少?


  • 把三层循环sql改成了存储过程,大概让我花了一天时间,让加载时间从3分钟变成了2秒,模块加载变成了”唰“的一下。

  • 后面的一坨事情大概花了我一周多时间,尤其是hack那个tableview,让我连周末都搭进去了。而所有的优化加起来,大概优化了1秒左右,这个数据是通过日志查到的:即使是我自己,打开模块也没感觉出有什么明显区别。


我现在遇到的很多面试者说程序优化时总是喜欢说一些玄乎的东西:调用栈、尾递归、内联函数、GC调优……但是当我问他们:把一个普通函数改成内联函数是把原来运行速度是多少的程序优化成多少了,却很少有人答出来;或者是扭扭捏捏的说,应该很多,因为这个函数会被调用很多遍。我再问会被调用多少遍,每遍是多长时间,就答不上来了。


所以关于性能优化,我有两个观点:


  1. 优化主要部分,把一次网络IO改为内存计算带来的收益远大于捯饬编译器优化之类的东西。这部分内容可以参考Numbers you should know;或者自己写一个for循环,做一个无限i++的程序,看看一秒钟i能累加多少次,感受一下cpu和内存的性能。

  2. 性能优化之后要有量化数据,明确的说出优化后哪个指标提升了多少。如果有人因为”提升性能“之类的理由写了一堆让人无法理解的代码,请务必让他给出性能数据:这很有可能是一坨没有什么收益的烂代码。


至于具体的优化措施,无外乎几类:


  1. 让计算靠近存储

  2. 优化算法的时间复杂度

  3. 减少无用的操作

  4. 并行计算


关于性能优化的话题还可以讲很多内容,不过对于这篇文章来说有点跑题,这里就不再详细展开了。


2.2.决定健壮性的20%


前一阵听一个技术分享,说是他们在编程的时候要考虑太阳黑子对cpu计算的影响,或者是农民伯伯的猪把基站拱塌了之类的特殊场景。如果要优化程序的健壮性,那么有时候就不得不去考虑这些极端情况对程序的影响。


大部分的人应该不用考虑太阳黑子之类的高深的问题,但是我们需要考虑一些常见的特殊场景,大部分程序员的代码对于一些特殊场景都会有或多或少考虑不周全的地方,例如:


  • 用户输入

  • 并发

  • 网络IO


常规的方法确实能够发现代码中的一些bug,但是到了复杂的生产环境中时,总会出现一些完全没有想到的问题。虽然我也想了很久,遗憾的是,对于健壮性来说,我并没有找到什么立竿见影的解决方案,因此,我只能谨慎的提出一点点建议:


  • 更多的测试测试的目的是保证代码质量,但测试并不等于质量,你做覆盖80%场景的测试,在20%测试不到的地方还是有可能出问题。关于测试又是一个巨大的话题,这里就先不展开了。

  • 谨慎发明轮子。例如UI库、并发库、IO client等等,在能满足要求的情况下尽量采用成熟的解决方案,所谓的“成熟”也就意味着经历了更多实际使用环境下的测试,大部分情况下这种测试的效果是更好的。


3.改善生存环境


看了上面的那么多东西之后,你可以想一下这么个场景:


在你做了很多事情之后,代码质量似乎有了质的飞跃。正当你以为终于可以摆脱天天踩屎的日子了的时候,某次不小心瞥见某个类又长到几千行了。








请到「今天看啥」查看全文