如果要评选软件设计工程界「最让人厌恶行为」排行榜,我相信「写文档」绝对可以名列三甲。程序员们痛恨文档,也留下过名垂千古的名言,比如 Read the Fxxking Code。文档,费时费力,又没有人看,代码多好啊。程序员对文字工作的厌恶,可见一斑。
不过,今天我来唱个反调。
今年过年前几天,我们的项目出了一个问题。我们自己开发一个模块导致目前的产品出现随机地退出延迟。这个后果导致本月的内部发布无法进行。事后查明,这个问题的根源是主框架中的一个系统库发现我们的新版模块在退出过程中占有了几个系统资源。系统库认为程序没有完全清空所有模块,所以导致了额外的等待。
这个问题一开始让我非常迷惑。因为代码里确实按照系统库的要求定义了一个 stop() 函数,系统退出时调用此函数表示模块停止,此时停用所有资源请求。一切行为似乎都很合理,直到一位资深同事的一句话为我指了一条明路:「你们为什么没有在 stop() 中释放资源,而是销毁模块时才做?很多模块都在 stop() 过程中释放了资源,但只有你们不是。」我和另一位同事兵分两路,终于搞清楚了问题。
这个错误之所以产生,是因为我们模块定义的 stop() 和框架中要求的 stop() 在含义上存在微妙的差别。这个 stop() 的作用是让模块停止活动,但保存了对外部相关资源的引用,直到模块被销毁;而调用方期待的 stop() 函数行为,是让模块调用停止自身,同时释放所有外部资源。当系统退出时,调用方总是先执行所有模块的 stop() 后,检查是否所有资源已经释放,最后再依次销毁模块。恰恰就是这个检查,发现我们占用了一些资源,导致主程序拒绝退出。其实这些资源并没有泄漏,只是我们把它们的释放时机推迟到了销毁自身时才进行。
那么,这个理解偏差是怎么产生的?
在最初编写这个模块时,我们被客户要求必须支持一种场景,即程序员在 stop() 后可以不释放资源,重新通过调用对应的 start() 再次启动模块。但事实上,上层的调用方并没有试图支持这种用法。最初设计者要求定义 stop(),最主要的目的是为了按模块间的依赖关系释放资源,保证任何模块在自己尚未退出之前相关依赖肯定不会被销毁。换言之,框架中的这个 stop() 的本意,其实本身也包含了释放外部资源的意思。但它又不是完整的销毁操作,因为模块自身在这个过程中绝对不能被销毁。为了保证新的模块行为正确,它必须完整保证语义与这个描述相符。
知道了这个区别,改动自然就很容易。连上讨论加通读代码,到最后解决方案被通过,从头到尾三四天的工作量而已。关键是:这个问题是怎么被引入的?
问题很清楚:我们的模块没有符合设计者的本意,因此在集成时无法正常工作。但事实是,程序设计者的「本意」,从来没有任何人对我们提起!别说最初开发模块时所有资深专家们都没意见,即使是出错了之后,那位提出问题的开发者一开始也并不确定代码逻辑是错误的,只知道和惯例有些不符。事实上,我们还是在读过相关代码之后才最终确认原因。
明白了吧,我们碰上了暗礁。
为什么叫暗礁?在工程里,程序的逻辑永远被分为两个部分:看得见的,和看不见的。什么是看得见的?函数的名字,程序的逻辑。什么是看不见的?设计者期望功能中的功能细节和行为约定。函数的名字可以一定程度上反映一部分期望的行为,但其实没法涵盖所有的细节。随着程序在多个负责人之间传递和扩展,那些看不见的部分会随着大家对系统的理解角度不同产生差异。就以这个故事为例:产品最初的设计者们选择了 stop() 这个名字,也许仅仅是认为对他们而言这个含义很清楚,没有必要长篇大论。但如果要完全反映其细节,我们恐怕得用上这样的函数名:
stop_you_must_release_all_external_references_but_keep_your_own_objects_not_freed_and_make_sure_user_can_call_start_again()
恐怕没人能忍受如此冗长的函数名,即便您是 Objective-C 程序员(这个梗我不解释,非程序员朋友可以简要地理解为鄙人对苹果产品没有爱)。这还没有算上更麻烦的情况:将来这个函数行为还有改变的可能。把这么多要求塞在函数名里,你是不是要通知所有使用你的 stop() 函数的程序员们修改代码?
我知道有人肯定会说,只要你通读了所有代码,自然就明白为什么。我不是最终通过读代码才确定了问题所在么?这句话看似有理,其实扯淡。我们且不说有限的工作时间内能不能读完,即使阁下天赋异禀一目十行,请问,就算阁下读完了所有其他代码发现他们都释放了外部资源,是否意味着我们有足够的证据证明这个动作是必须,而不是仅仅出于巧合或程序员的洁癖?事实上,除非某个模块没有尊重这个约定出了 bug,然后被老板火急火燎地敲你的桌子命令你立即找到原因;否则,绝大多数读代码的人(比如前几个月的我)看到这里,多半只会点点头继续读下去,没人会在意。
所以,读代码其实不能让人提供足够的信息。通常情况下,代码只能告诉我们「是什么」,而无法告诉我们「为什么」。「是什么」,是可见的;而「为什么」,则通常不可见。想了解「为什么」,只有两种方法:一种是玩程序,把程序执行起来,修改自己感兴趣的部分,观察程序行为如何改变;第二种就是把这些规范注解留成文档,使得后来接手的工程师们可以根据文档确定最初设计者期待的行为是什么。
当然了,让程序员们口口相传也是一个办法,这个效果怎么样?大家都看到了。
— 完 —
本文作者:陈甫鸼
【知乎日报】
你都看到这啦,快来点我嘛 Σ(▼□▼メ)