程序员的核心能力 - 构建快速反馈


程序员的大部分时间都处在“运行代码,出错,修改代码”这样一个循环中,它被称为“反馈循环(Feedback Loop)”。反馈循环转得越快,单位时间里你可以迭代的次数就越多,你的编程效率也就提高了,这是一个很简单的道理。所以在做任何项目的时候,在你埋头到具体问题的调试之前,你首先要优化反馈循环的时间。

那反馈循环的时间都花在哪儿了呢?

也许你是一个移动应用工程师,为了验证一个 API,你每次都要在应用里提交一个复杂的表单,你的时间浪费在了重复填写表单上。也许你是一个数据工程师,你正在调试的一个数据查询时需要读取海量的数据,每次运行都至少要几分钟的时间。也许你在做的项目代码庞大依赖众多,每次的编译都要花上十来分钟。

一个慢的反馈循环不但使你的时间浪费在等待中,让你心情不佳,也会打断你的思路,所以你实际上的效率损失很可能远大于表面上浪费的时间。写了这么多年程序,对于加快调试时的反馈时间,有些心得可以与大家分享。

REPL

许多解释型语言,像是 Python, Ruby, Node 都有 REPL 环境(Read-Eval-Print-Loop),你可以在里面输入一些语句,立即就可以看到结果,这就是一个快速的反馈循环。目前主流的代码编缉器都有插件与这些 REPL 整合,在写代码的时候可以一边在主文件里写,一边让代码在 REPL 中执行,从而即时地检验代码的正确性。

程序员在写代码的时候,头脑里会不断产生各种疑问,“这个正则表达式能匹配这个字符串吗?”,“这个函数调用的参数写对了没有?”,这些疑问不管对错,在它们被证实之前都会对头脑产生额外负担。在你写下一句代码的时候,头脑还是会忍不住思考上一句的正确性。REPL 能快速回答你头脑里的这些疑问,从而提升了你的思考效率。

我当初学 C/C++/Java 这些编译型语言的时候,它们是没有 REPL 工具的,我也一度认为 REPL 是解释型语言的专利。但现在我发现 Swift, Kotlin 这些现代的编译型语言都原生提供了 REPL。而像 Go, Rust 等也都有第三方实现的 REPL,除此之外还有它们官网上的 Playground。所以现今无论你用哪种语言,几乎都能找到可用的 REPL 工具,赶紧用起来吧!

Mock

有时你调试的功能依赖一个很慢的外部输入,这可能是一个远程的第三方API(尤其是国外的API),也可能是需要在界面上做人工输入,或是查询一个庞大的数据库表。这导致你调试的大部分时间都是在等待中渡过。解决这个问题的一个方法就是用 Mock 输入来代替真实输入。对一个远程的 REST API,你可以通过直接返回一个本地的 JSON 对象来代替。对人工界面输入,你可以在测试时让系统预填好所有的表单。对于庞大的数据库表的操作,你可以在本地创建一个相同结构的表,只取原本数据表中的少量数据复制到本地。这些设置在初期可能会花掉你一部分时间,但很快就能从调试过程中省回来。

许多编程语言还提供了好用的 Mock 类库,以 Python 为例

from unittest.mock import patch

@patch('module_name.expensive_api', mock_api)
def awesome_func():
    expensive_api()
    ...

在上面这段代码中,expensive_apiawesome_func 中会自动被替换为 mock_api,在 awesome_func 之外则保侍不变。patch 方法不但让替换逻辑清晰,而且让替换只作用于要调试函数。

Cache

与 Mock 输入类似,Cache 是解决外部依赖执行速度慢的另一种方法。因为构建 Mock 也会引入一些意想不到的问题,比如 Mock 的结果与真实情况可能有差异,所以把费时的外部依赖函数的结果 Cache 起来也是个很不错的方法,但前提是这些依赖函数是“纯函数”。换句话说,就是这些函数的执行结果只取决于函数的参数,与任何全局状态无关,并且在函数执行后,除了返回数据外,没有任何副作用。顺便提一句,尽量把大部分函数写成纯函数也是一个重要的编程能力。

这里的 Cache 不能用 in-memory cache,不然每次改代码重新运行的时候,Cache 就失效了。在调试时,File Cache 是很常用方法,也就是把函数执行的结果序列化后存在文件里,下次可以直接从文件里读。

写函数 Cache 的时候时,不要把 Cache 的逻辑直接写在函数里。以Python为例,我不推荐以下的写法

def slow_api(a, b):
    cache_file = '{}_{}.json'.format(a, b)
    if not os.path.exists(cache_file):
        # 在这里执行函数原有逻辑
        # 将最后结果存入result变量
        writer = open(cache_file, 'w')
        json.dump(result, writer)
        return result
    else:
        return json.load(open(cache_file))

这有两大缺点。第一,这段 Cache 的逻辑你需要在每个要用的地方都写一遍。第二,这段 Cache 的逻辑入侵了函数内部逻辑,不但清理起来麻烦,也容易不小心破坏原有逻辑。

好的 Cache 实现要用到高阶函数。普通函数用来变换数据,高阶函数则用来变换其他函数。这个对初学者来说,可能有点难。但在主流语言中,大部分的通用类库大牛们都已经为大家实现了,所以只要会用就行了。比如 beaker 就是 Python 中处理 Cache 的第三方库,也支持用文件做为 Cache 的存储媒介。以下是使用 beaker 的代码示例

from beaker.cache import CacheManager 
from beaker.util import parse_cache_config_options

cache_opts = {
    'cache.type': 'file',
    'cache.data_dir': '/tmp/cache/data',
    'cache.lock_dir': '/tmp/cache/lock'
}
cache = CacheManager(**parse_cache_config_options(cache_opts))

@cache.cache()
def slow_api(a, b):
    ...

可以看到,在 CacheManager 对象初始化之后,对任何要加 Cache 的函数,只需要在它前面加上 @cache.cache() 就可以了,用起来很方便,也完全没有入侵性。

日志记录

在代码执行的一些关键点打印日志也是让反馈循环转得更快的好方法。对于执行时间长的函数,在中间节点打印日志可以在整个函数执行完之前,提前提供反馈。不过日志更重要的作用是在单次反馈中给到你更多有用的信息,从而减少解决问题所需的反馈循环的次数。

注意这里推荐使用的是 Log 而不是 Print。虽然从功能上讲两者是类似的,但相比 Print,日志记录有太多的优点。首先,日志可以提供不同的级别。通过给开发环境与生产环境设置不同的日志级别,你完全可以把调试时的日志代码提交,不用担心它会拖慢生产环境。而 Print 语句在提交前是必须清理干净的。日志记录还可以自动加上有用的上下文信息,比如当前时间,当前执行的模块与函数等。上下文信息既可以是日志模块内置的,也可以是你自定义的,比如在 Web 系统中你可以加上当前用户的 ID 做为上下文的一部分。这些上下文信息可以很好地帮助你排查错误。

单元测试

只要你调试的程序可以方便地写单元测试,那它就是构建快速反馈的最优方案,这也通常是我的第一选择。上面提到的很多方法与单元测试也是相辅相成的。比如 REPL 让你在写代码的过程中得到快速反馈,单元测试则可以在函数写完后提供整体检验。为了减少单元测试的外部依赖,或是为了让它运行得更快,Mock 方法在单元测试中经常用到。日志记录也可以帮助你在每次单元测试失败时看到更详尽的信息,让你更快速地定位错误原因。

开发与调试工具

最后一点也很重要,你要确保自己正在使用最好的开发与调试工具。好的构建工具可以加快你项目的编译速度,从而节省了反馈时间。有些工具则更进一步,例如顶顶大名的 React Hot Loader,它让对 React 代码的修改在页面上实时反应,而不需要重新加载页面,这样的工具大幅减少了反馈循环的时间。工欲善其事,必先利其器。千万不要小看工具的力量,工具的差异可能会造成效率上巨大的差距。以后会单独写文章聊聊如何打磨你的工具箱。

小结

“快速反馈”是一个泛用性很广的概念,提升编程效率只是它的一个应用而已。

几乎所有的电子游戏中都有一套高效的反馈机制,刷完任务立刻掉宝升级,甚至打怪的过程中也是各种数字乱飞,这一切都是对玩家的行为给予即时反馈。在体育训练时教练的重要性在于,他可以对你动作的正确性做出快速反馈。为什么很多公司喜欢把 KPI 实时地投到电视机上,并放在办公室最醒目的位置,就是把你每时每刻的工作“即时反馈”到公司的 KPI 上。即便这种即时性是一种幻觉,仍能激励员工。

所以当你想要改善某件事的时候,先想一想如何建立一个快速反馈机制。比如你想提高代码单元测试的覆盖率,如果你只是把这个做为季度目标,那十有八九是完成不了的。但如果你每周都在例会上展示当前的单元测试覆盖率,那你达成目标的成功率就会大大提升。如果你建立一个git hook,在每次代码提交时都会显示当前的单元测试覆盖率,还显示与上次提交相比这个覆盖率变化了多少,那你百分之百会达成这个目标。这就是快速反馈的力量。

在本文的最后,推荐一个视频,Bred Victor - Inventing on Principle,它把快速反馈的概念展示得淋漓尽致,到现在我仍能记得5年前看这个视频时的震撼。在我看过的所有的技术演讲里,它绝对排在前三。希望它也能对你的思想带来冲击与启发!