《重构》笔记

读的是《重构:改善既有代码的设计》第二版英文,代码示例是 JavaScript。

总的来说,这本书的独到之处就是把很多日常直觉和经验明确总结为一套具体的方法论。作者在前几章用比较完整的案例介绍了重构的指导思想和理念,剩下部分占了全书四分之三的篇幅,给出了每种重构的具体指南,它们各自都有名字、动机、方法和示例,其中大部分是非常细致、微小的操作,例如提炼函数、行内变量、包装数据等等。

按照我对《重构》一书的理解,重构的核心工具/习惯包括

  • 版本控制系统
  • 测试
  • 每种重构的具体操作,有的可能已经受到 IDE 的支持,有的仍需手动完成

我认为,这本书并不是向完全没有重构经验的人教授如何重构,而是向已经有重构经验和意识的人传授更进一步的经验,如何提升重构的 awareness,如何归纳方法论,有章可循地、一步一个脚印地、更好地重构。因此,这本书不是一本“重要而紧急”的书,不应该指望一气呵成读一遍就完事,更适合时不时地翻阅和反思。

作为机器学习、深度学习算法工程师,经常面临变化的需求,没有 code review 之类流程,因而重构注定是日常工作的一部分。由于篇幅限制,本文侧重于重构的思想部分,谈谈自己的体会。但是读者应当知道,重构的具体方法指南是这本书的重要组成部分,阅读本笔记完全无法代替阅读原书。

定义:

  • 重构(名词):对软件内部结构的修改,不改变软件的可观察的行为,使之更容易理解,更方便编辑
  • 重构(动词):通过施加一系列重构,重新组织软件结构而不改变它的可观察的行为

重构,首先是理解原代码在做什么,然后将这种理解从自己的头脑中转移到代码的逻辑结构中,使后来人更容易理解它。重构过程不会破坏代码,或者使代码长期处于不可用状态。

如果需要增加特性,发现代码难以维护,那就先重构,再增加特性,而不是顺序相反。当然这可能是一个反复迭代的过程:重构一点,增加一个特性,再重构,再增加。但每一步都明确当前的目的。

我的工作中经常遇到难以维护的代码。一方面,机器学习的编程过程主要是建模过程,一边实验一边改进是很正常的,因此代码里往往会留下许多半成品和没有打扫干净的废料。另一方面,有些算法团队脱离工程和产品,也没有 code review 流程,代码质量可想而知。即使如此,接手代码时也要注意重构和增加特性的优先级,并且注意小步迭代改进,不要贪多求全。

重构代码的第一步是,对原代码建立一套靠谱的测试,以免重构时引入意外行为。重构过程中无需额外增加测试。应该用测试框架,使测试很容易批量执行,而且测试结果一目了然,无需人工比对(最好是采取所谓 self-testing 代码)。每做一个小修改就进行测试,并在版本控制系统中确认代码改动(commit)。

如果接手的历史代码缺少测试,那么大刀阔斧的重构很危险,增加完整测试也很困难。但是依然可以采取谨小慎微的重构,并且在适当位置增加适当的测试。

我认为测试这方面的经验并不完全适用于深度学习,因为这里代码跑通的门槛比传统开发要高一些,调试占比更大,而像传统开发那样的测试似乎并不太普及,甚至可能写不出单元测试。无论如何,我们更倾向于采取数据驱动、实验驱动的开发,而不是测试驱动的开发。不过,测试依然有其重要价值,例如可以借鉴课程作业采用的方式,采用一个人工的小数据集,控制代码中的伪随机数(指定随机数种子),以便创造一个可重复性的测试设置,观察结果是否一致。Jupyter Notebook 也很有帮助。

如果代码的 owner 是个人,重构代码时往往要为了修改接口而束手束脚,甚至可能两两之间都造一套接口。建议代码的 owner 是团队,团队中每个人都有权限修改代码,使接口更干净利落。或者效仿开源项目的合作方式,重构分支合并的同时修改相应的接口,持续集成,以降低合并分支时的复杂度。

很久以前,人们相信软件架构设计应该在编写代码之前就规划完毕。但是现实世界并非如此,需求是会变的,软件设计当然也允许随之改变。如果采用重构的思维,初次设计时无需将未来所有的扩展性都考虑在内,而只需实现当前的需求,以后有需要时再重构。因此可以采用极简设计或者增量式设计,或者所谓 yagni,即 you aren’t going to need it。

我同意这在深度学习中也是比较常见的,例如过度抽象或者过度泛化一个类型或者函数定义,一下子就配置过多的参数,而文档又写不完整,后续修改会非常麻烦。实际上不妨先把不重要的参数写死(可以留下注释,提醒自己),先跑通一个最简单的模型,后续再增加和扩展。不过遗憾的是,有时无法说服产品经理接受这种不完美和未来重构的可能性。

关于性能,一般有三种方法致力于提升代码执行速度。第一种是预算式,将资源(时间和 footprint)预先指定分配给不同模块,这种方案只适用于特定的对时间要求苛刻的系统。第二种是时刻关注式,每个程序员独立采取提升性能的措施,但是这种方案通常缺乏大局观,特别是难以发现真正的瓶颈所在,因而效果欠佳。第三种是先专心写好代码,然后专门优化性能,首先用 profiler 监控运行,找到性能瓶颈,然后采取措施,可能只需要修改很少的地方就能带来很大的提升。当然,最后一种是推荐的方法。

我非常认同这条经验:跑通优先,然后再优化性能。不过在机器学习的世界里,往往是数据 I/O 对时间空间的影响最大,因此养成好的数据集处理习惯还是有必要的。除非数据集非常小,内存、显存非常够用,否则不要一次性全部读入甚至复制多份数据喂入模型。应该习惯于写一个生成器来专门准备数据,例如 tensorflow 就有 Dataset 相关的 api,keras 也有 fit_generator。这部分代码很方便复用,不会耽误多少编程时间。

什么时候该重构?没有统一的标准,作者的建议是,如果代码有臭味,那就该重构了。臭味的表现可能是代码重复,函数太长,传参太多,全局变量、mutable 变量,或者是改代码的时候发现必须一次改掉好多地方,等等等等,这里不再一一列举。总之,如果代码让你忍不住皱眉头,那就算闻到了臭味。

至于注释,它常被用做除臭剂,但这并不是它的本职工作。注释更好的用途是用来说明自己不确定的事情以及记录踩过的坑,而解释代码逻辑应该是代码自己的本职工作。关于注释具体该如何使用,以及其他维护代码的经验,在《编写可读代码的艺术》一书中也有清晰的介绍。

我对《编写可读代码的艺术》的笔记见 https://sighsmile.github.io/2018-10-20-readable-code/



Previous     Next
sighsmile /
Published under (CC) BY-NC-SA