代码重构之道
如果我纯粹为今天工作,明天我将完全无法工作。 -- 某子
程序员要面向未来编程。代码重构永远是程序员们无法回避的话题,当你的软件在编写的那一刻起,重构就不可避免。做一个系统,我们为什么要费劲地不断抽象,竭尽全力让自己的代码能够被重用,说白了就是让我们今日所付出的时间,让未来的我们能够更轻松地工作而已。
有位读者让我就 Martin Fowler 的『重构 - 改善既有代码的设计』一书,谈谈重构。我诚惶诚恐。这本书我很久很久以前买过,至今还躺在北京家中的书柜里;当时对系统领悟还不够深,所以很多思想都并未参透,所以谈不上真正「读」进去了。如今手头并无此书,也无法就着书来谈重构。Martin Fowler 的书和博客都值得一看,这本书应该也不差(不知道翻译如何,实在没有印象了),大家可以买来翻翻。
这里我谈谈自己在工作中,对重构的看法。
重构之道
自动化测试
重构代码最最最重要的一个先验条件是:自动化测试。子在川上曰:
一切没有自动化测试的代码重构都是耍流氓。
写代码的目的是什么?是为了产出的系统能够满足功能需求。重构代码的目的是什么?是为了满足功能需求的代码能够像奥林匹克精神描述的那样:质量更高,性能更好,速度更快,让未来的工作更轻松,以及让代码看上去更美。如果你重构了代码,却破坏了基本的功能,纵使代码再漂亮,性能再高,又有何用?
那么如何保证重构不破坏既有的功能?答曰:测试。无论你是单元测试,功能测试,集成测试,还是哔哩哔哩测试,总之你需要尽一切可能去测试。重构有一个个「点」(细胞)的重构,所以你需要单元测试;也有一个个「切面」(器官)的重构,所以你需要功能测试;当「切面」的改动甚大(器官移植),还需要集成测试...相关的测试是否存在决定了你能否重构;而测试所花费的时间直接决定了你是否会进行重构,以及以一个什么样的频率进行重构。如果重构了十行代码,却需要花费一个小时进行运行一次单元测试,那么你要么不会去重构代码,要么你重构了不会去测试。
好的重构发生在构建系统的每时每刻,而非问题发生或者老板要求。如果重构之后测试立刻会告知你结果,你会更有信心进行更多的重构,使其成为你工作生活的一部分。
你也许会质疑:什么样的单元测试可能会需要一个小时来完成?答曰:手工测试。这是为什么先验条件不是「测试」,而是「自动化测试」。没有自动化测试(以下简称测试),谈重构纯属扯淡。如果要重构的环节测试覆盖率不好,先想法提高覆盖率;如果根本没有测试例,请先做好这个基本功再谈重构。
时时刻刻重构
前面已经提到了重构代码最佳的时间点:撰写每行代码的时候,而非火烧屁股的时候。那什么样的情况你需要进行重构呢?有道是 bad code smells —— 下面这些让你感到不太舒服的场景其实是提醒你,代码该重构了:
一. 当你写一段代码时,不得不从别处拷贝粘贴代码
显然,这有悖于 DRY(Don't repeat yourself)。一段代码(文档,测试,注释)如果要被复制,那么它的逻辑就该被抽取出来,单独成文。这几乎是重构最基础的实践。然而,这个问题,从小公司到大公司,几乎是每个系统最严重的问题之一。在我以前工作的公司,我维护过一个超过 5000 行的 C 函数,里面的 if-else 层层嵌套下的 copy&paste 让人叹为观止,添加一点逻辑需要检查七八个地方是否需要同样的逻辑,完全可以入选教材作为经典的反面案例。
如今,感谢包括 GoF,Martin Fowler,Kent Beck,松本行弘等大师的不懈努力,以及程序君猫在角落里不断地摇旗呐喊,代码的 DRY 越来越得到重视。这很好。那么测试代码呢?文档呢?注释呢?是不是也该重视一下了?我曾接手过一个 API 系统的测试,所有的测试都在做一件事,就是给 mockup 的 API server 发一个(或者多个)request,然后验证 response 是否正确。几十个测试例,数百行代码其实可以用十多行公共代码以及下面的语法抽象:
不要小看测试的 DRY,文档的 DRY,它们也同样重要(如果不是更重要的话)。文档如若被多次 copy&paste,可能导致某处的修改没有反映到所有的拷贝,误人子弟;测试代码不够 DRY,当其到达一个足够大的规模后,维护和更新起来会非常头疼。
二. 当你修改已有代码添加新功能时,发现已有代码总感觉哪里不对
比如说,逻辑写得太绕,太复杂,太难以理解,循环太多,分支太多,状态太多等等。这样的代码几乎跪在那里请求你的重构,不重构说不过去。
三. 当你调用已有的代码时(函数,类),不得不阅读被调用的代码才能确定怎么调用时
这个代码要么接口定义的不好,比如说,一个函数有十多个参数;要么是文档写的不好,比如说,关键性的函数没有对接口提供足够的说明。如果说上面所述的是纯粹的代码重构,那么这里就是用户体验的重构。程序员的代码是什么?是一个程序员为另一个程序员精心打造的产品!函数(或者类)的 signature,以及对 signature 的说明是这个产品的 UI。你如果打开微信,一个按钮是干什么的不知所云,总和你期望的效果不同,你是不是想像个混蛋一样跳起来骂娘?同样的道理,程序员也是人,尽管在工作中被磨砺得「温良恭俭让」,看到不知所谓的接口也会变身满嘴 WTF 的混蛋。
四. 当你写一段代码时,连带着要改很多代码
当这个场景发生的时候,代码的味道相当糟糕,意味着不仅代码本身有问题,相关代码的设计甚至架构也有很大的问题。如果没有一定功底的程序员,重构这样的代码会比较费劲。
严格自律与他律
稍微大一点的软件项目是多人一起合作完成的。和别人合作,我们要坚信两点:人天性都是懒惰的,有捷径的话,绝不规规矩矩走大道;同时人都会受到 role model 或者社区的感染,如果已有的代码库形成了一个良好的氛围,新加入的人有一种融入已有体系的紧迫感。开源项目其实可以给我们很多启发,看看那些著名的开源项目,很多参与其中的人在他们各自的公司里都未必有这么好的习惯,但在开源项目中,项目本身的检测和社区带来的压力会让它们自律。
对于人的这两种天性,我们可以如下引导之。
在一个项目启动之初,一定要设置足够的代码签入(checkin)门槛。lint / build / test 一个都不能少。这是我在公司给 team 定下的 linting 规矩(是的,我们用 nodejs):
除了 airbnb 的 javascript style 的基本要求外,我还有这些要求:
- 代码中不能有任何形式的 console.xxx 出现。为此,我不惜定义
print
函数为 cli 使用。这是逼着程序员好好考虑如何 log,用什么样的 log level 合适。 - 一个函数最多有 50 行代码。那么超过 50 行代码怎么办?要么拆分之,要么精简之。
- 一个函数的嵌套不能超过 5 层。多个 for 循环,深层的 if-else,这些都是罪恶之源。如果超过这个限制,只能拆分,或者使用函数式编程:map/filter/reduce。
- 一个函数最多有 3 层 callback。这是逼着程序员不要误用 callback,尽量多用 Promise。
- 一个函数最多 5 个参数。参数太多的函数,基本是试图揉太多事情在一起。
- 一个函数的复杂性不超过 10。你的所有分支,循环,回调等等统统加在一起,在一个函数里不超过 10 个(注意不是嵌套)。
这些要求严格到令人发指。尤其是 complexity,我经常会一不小心就超出了这个限制。然而它逼迫我对要写的代码做更多的思考,把更多的代码逻辑转化成数据。代码到数据的转化是抽象思维的很重要一步(比如上面的那个 test fixture),它将代码和代码进一步解耦,用数据(一些 rule)串联起来。
除了 lint 外,强制的 test case 也很重要。我在代码的 pre-commit hook 里,加入了这些 task。一份代码想要 checkin,先过了 lint / build / test 关再说。虽然,git pre-commit hook 可以被 skip,但是若有人胆敢这样做,还有严格的 code review 和 CI 等着他。发现不符合规范直接杀威棒伺候。
以上种种,都是解决人性中的惰性。先来一大棒子。
接下来要有 role model,或者社区氛围来引导行为。一般我启动的项目,我会撰写初始的项目,力保每行代码清晰可读,每个函数深思熟虑,每个接口都有友好的文档,每个关键的函数有详尽的测试,然后在 code review 严格把关。这样,在更多的人加入项目后,大家前有模板可循,经验可依,后有鞭策之威,自然写出比较漂亮的代码。荀子说:
干将莫邪(哎,这哥们说了那么多宝剑我就记得这俩)等,古之良剑 —— 然而不加砥厉则不能利,不得人力则不能断...夫人虽有性质美而心辩知,必将求贤师而事之,择良友而友之。
这就是 role model 和大环境的作用。有了这层铺垫,每次 review 的 well done,每个 pull request 的 LGTM(look good to merge),都是一根根胡萝卜,让工程师尝到写出优秀代码的甜头。
整个过程和代码重构看上去没什么关系,但处处要求程序员重构代码以达到比较高的标准。相信我,这么做即便大家开始不适,等渐渐建立信心之后,会时时刻刻重构,自我追求更高质量的代码。
重构之术
至于重构之术,我想,Martin Fowler 的书里应该都讲到了,不讲也罢。讲了的话无非是拾各种编程模式,范式之牙慧。而且各种编程范式,比如面向对象编程中的类的重构和函数式编程中的函数的重构也不尽相同;各种语言,比如 elixir(pattern matching, macro),javascript(closure,FP),和 C++(OOP) 三种语言的重构手段就千差万别。
- 转-Golang语言Interface漫谈
- WordPress导航菜单图标字体插件font awesome 4 menus纯代码版
- Oracle 12c远程克隆PDB的问题及修复(r12笔记第78天)
- Oracle表中含有255列以上时需要注意的(r12笔记第77天)
- Golang语言--资源自动回收技术
- Oracle 12.2中的一个参数说明(r12笔记第76天)
- Golang语言社区--【游戏服务器知识】多线程并发
- 用100行Nodejs代码写微博爬虫
- MySQL无法创建表的问题分析(r12笔记第73天)
- Golang语言社区--【H5游戏开发基础知识】JavaScript 用法
- Oracle中的PGA监控报警分析二(r12笔记第87天)
- Oracle 12c PDB的数据备份恢复(r12笔记第84天)
- MySQL和Oracle中唯一性索引的差别(r12笔记第83天)
- 如何用JavaScript进行数组去重
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- Python 微信机器人-20行代码实现斗图功能,简单易懂,全是干货!斗图啦API调用方法
- 浅谈布隆过滤器
- Python 技术篇-获取图片GPS信息,锁定追踪图片拍摄地点、拍摄时间
- 测试工具 - Postman接口测试入门使用手册,Postman如何进行数据关联、自动更新cookies、简单编程
- 白盒测试工具 - sonar的安装、配置与使用入门手册,用sonar检查代码质量实战演示
- Chmod -R 777 / 误操作恢复教程
- 最全总结 | 聊聊 Python 数据处理全家桶(Redis篇)
- 虚拟机安装mikrotik-ROS
- 搬砖武士|手把手教你在容器服务 TKE 上使用 LB直通 Pod
- linux安装snmp服务-ubuntu
- 企业微信机器人
- Kubernetes 新玩法:在 YAML 中编程
- 全网最实用 Python 面试题大全(花费了整整 3 天时间整理出来的)
- paramiko模块
- Loki 和 Fluentd 的那点事儿