身为一个程序员,工作中最重要的事情当然是写代码,其次就是读代码了。我们都是先阅读了别人的代码,才模仿着写下了自己的第一行代码。接手维护别人的项目,要读代码,遇到bug排查问题,要读代码,学习别人精妙的设计,同样需要读代码。从代码量上来说,绝大多数人所阅读的代码量远超自己写的代码量。所以程序员必须学会正确的阅读代码姿势,高效正确的阅读代码。
为什么读代码很难
读代码并不比写代码简单,阅读代码的困难源自以下几个方面。
首先,实现一个功能,存在多种具体的实现方式。即使是同一个思路的算法,最终产生的代码也有多种表现形式,不同代码的风格、变量的命名、if嵌套或者for/while的选择都会影响最终呈现的代码。阅读代码时,人脑充当了编译器的角色,不过通常意义上的编译,而是反向从代码的表现去理解代码的意图。眼睛看着代码,根据它在做什么反向推导它要做什么。更复杂的是,不仅要看静态的代码,还要在头脑中构造一个运行时的状态转换。
举个简单的例子,看到下面这段代码,你能分析出它在做什么吗
void do_somthing(){
x := A[(hi + lo) / 2]
i := lo - 1
j := hi + 1
loop forever
do
i := i + 1
while A[i] < x
do
j := j - 1
while A[j] > x
if i ≥ j then
return j
swap A[i] with A[j]
}
上面的代码其实是快速排序的一次partition,很多人都熟悉,可能还比较容易猜得到它的目标。确定了一个目标,实现代码有好有坏,但是给出一个能正常工作的代码并不算很困难。例如实现一个排序功能,对大多数人来说可能都不是问题,但是从代码去反推行为反而更困难,写代码是顺着自己的思路,读代码是顺着作者的思路。
其次,一段代码的输入并不只是其参数,输出也不只是返回值。代码执行过程还会依赖各种外部状态:全局变量、进程外数据甚至网络上的数据。阅读代码时不仅要关注眼前的一段代码,还要考虑各种外部数据,考虑这些数据的结构以及能够对数据施加的各种操作,还有每种操作所导致的数据变化。代码运行过程中也会修改外部状态,阅读代码的过程中不仅要关注代码中自身数据的状态变化,还要考虑对外部数据的修改。
最后,实际的项目代码通常不是上面快速排序这么简单单纯,而是包含了复杂的概念和数据结构,众多的模块,各种各样的接口,冗长复杂的数据转换逻辑。每一段代码并非独立存在,而是作为一个更庞大整体的一部分。最要命的是代码里通常充斥着各种神奇补丁,以解决某个特定场景特定时间特定输入数据时才会遇到的问题,除非你能从其他渠道(例如注释或cvs log),否则想破脑袋也没法理解为什么这些代码的意图(别忘了读代码的目的就是分析意图)。
当然,有些代码由于作者能力问题,写出来的代码完全不具备可读性,这种情况不在讨论之列。
如何读代码
目的不同,阅读代码的方法也不同,为解决Bug而读代码和为掌握系统而读代码,所应使用的方式截然不同。如果为解决Bug而读代码,而且已经能快速定位到出现Bug的位置,那么直接分析相关的一小部分代码即可,运气好的话也能从代码中找到蛛丝马迹,顺利解决问题。如果接手维护现有的系统——无论是公司自己开发的还是直接使用开源软件部署——这时候就要完整的阅读所有的代码,以便掌握代码的方方面面,以后修改起来才能得心应手,出现问题也能快速定位和修复。有时候为了提升自己的能力,主动阅读一些优质开源软件的源码,学习其中的设计和实现,也要阅读完整的代码,或者某些模块的完整代码。后面这两种情况需要面对的代码量都很大,代码的实现通常也比较复杂,这时候就需要正确的方法。
不要急着读代码
读代码的第一要义,就是不要急着进入源码开始阅读。读代码不是为了把代码背下来,而是要掌握系统的结构,设计思路和关键模块的实现方法。整个项目通常规模较大,直接进入到代码里开始阅读,缺乏重点,没法区分该认真读的代码和该粗略读的代码,胡子眉毛一把抓,最终只能事倍功半。大脑里没有系统的整体结构,只看看一行行的代码,很难构建出系统的整体逻辑,只见树木不见森林。
想一下自己开发过的项目,相信很少有人能够记住某个文件某一行的代码。我们不会记住诸如某一行代码具体是什么这样的细节,记住的是代码实现的思路以及为什么要这么实现,哪里会调用这些代码,有什么样的输入,要返回什么样的结果。当然,还会记住项目的整体结构,运行环境,项目中的概念,各个模块的职责,每个功能的设计以及为什么要选择这样设计。
阅读代码不要尝试去记忆细节,从整体上把握。对项目中的设计,最好还要知道为什么选择这种设计方案而不是其他方案。能做到这些,对代码的掌握已经达到了代码Owner的水平。
从问题域出发
首先,你要明白要阅读的代码是做什么的。这个问题乍看上去很奇怪,都打算读代码了,还能不知道这些代码是干什么用?其实不然。很多项目包含的功能远多于我们所知道的,几乎所有的开源代码都包含着我们所不知道的功能,越是大型的流行的开源项目越是如此,因为开源项目的用户很多,部分用户针对自己的需求贡献了一些代码,这些需求如果不是实际场景中遇到,根本没法凭空想象出来。即使是一个公司的内部代码也可能包含很多奇怪的功能,尤其是年代久远的代码,充斥着各种已经废弃的逻辑。
为什么要先知道代码的功能呢?读代码的目的就是搞清楚代码做了什么,如果直接看代码,遇到自己没有考虑到功能,必然是一头雾水。如果已经知道了软件的功能,看到这些代码时就比较容易联想到它的意图了。
其实这里说的并不仅仅是代码功能,还包括代码的运行环境、开发语言、依赖的外部组件。一个运行在自建IDC内物理机上的系统,和针对云环境设计的系统,在一些技术方案的选择上有很大差异。一个运行在单机上的系统和一个集群模式运行的系统,技术方案的选择也会不同。语言、环境和外部依赖作为系统的约束条件,影响设计方案的选择和代码实现,了解这些信息,更容易推测代码的实现思路。
先看文档
文档是了解项目信息的最佳途径。一个好的项目至少包含用户文档和开发文档,用户文档站在用户视角描述项目安装部署和各个功能的使用,开发文档从代码实现的视角描述项目的架构、组件和关键设计。对于读代码,最关键的当然是设计文档,看完这个文档基本上就能对项目代码有个大致的了解。读设计文档时,重点关注这些内容:
- 架构。系统包含哪些组件,各个组件的职责,组件之间如何通信。
- 部署结构。系统运行环境,如何部署,需要什么样的配置。
- 概念模型。不同的系统都有自己的概念模型,比如调度系统里的Scheduler/Worker/Resource,权限系统的User/Role/Group/Action,这些模型都会反映在代码里,理解这些模型才能理解代码。
- 关键设计,每个系统里都会包含一些很关键的设计,有些设计是项目区别其他同类产品的核心,其他设计都是围绕着这个设计展开的。比如etcd的raft之于zookeeper的zab, rocksdb之于leveldb的compaction,docker的image和unionfs。
用户文档也可以大致浏览一下,不用细看,扫一眼目录,如果发现有些功能是自己不知道的,可以重点看看这些功能的介绍。
把握整体架构
读代码的时候并不需要去一行一行的阅读完所有的代码。掌握了整体结构之后,很容易判断出自己希望了解的细节位于哪个部分的代码里,接下来直接找对应的代码模块就可以了。掌握了整体架构,理解了每个模块的职责和输入输出,也能让后面理解代码变得更简单。
对于整体架构,需要掌握哪些信息呢?不妨尝试要求自己回答下面几个问题:
- 系统包含哪些组件
-
对于每个组件
- 职责是什么
- 运行在哪里,如何部署(是手工启动还是系统自动创建)
- 什么样的方式运行 ,单机、集群、主备
- 组件状态管理,组件本身是否有数据,数据存放在哪里
- 对外提供了哪些接口,这些接口谁会调用
- 对外接口的暴露方式,通信协议。对于集群/主备方式部署的组件,接入流量的转发模式,请求是通过什么方式分发到组件实例上的。
- 一个完整的操作,在系统里是怎么流转的。
概念模型、数据和流程
概念模型是软件对现实世界问题的抽象,一个软件项目中通常包含一组相关的概念模型。在设计系统时,我们有意识或无意识的将问题进行抽象,得到一组数据结构和数据结构上能进行的各种操作,然后用代码来实现。
从任何一个项目中,我们都可以找出其中的概念模型,不管项目是大是小。
概念模型并不是孤立存在的,项目中包含多个概念模型,它们之间也存在各种关系。一个系统中包含商品和订单两个概念模型,一个订单内又可以包含多个商品。
理清楚项目中的概念模型,模型间的关系,模型支持的操作,遇到相关的代码就能轻松理解代码的意图。
主次有别
在掌握架构和概念模型之后,对项目已经掌握了一大半。接下来可以开始读代码,但不是所有的代码都需要阅读。什么样的代码需要阅读?对于我个人而言,只有满足下面的条件时我才会去看其中的代码
- 工作中遇到了问题,这个问题没有现成的解决方案,或者虽然能找到解决方案,但是希望知道更多的细节。
- 知道项目中实现了某个功能,我自己有一些实现的思路,想知道它怎么实现的,和自己的方法进行对比。
- 项目中有个神奇的功能,网络上也找不到实现方面的资料,很好奇它是怎么实现的,只能去看代码。
做事要分轻重缓急,读代码也一样,分清主次,找到需要读的代码,其他的代码等真正需要的时候再看也不迟。
自顶向下
找到要阅读的代码之后,同样不要忙着读,依然要用自顶向下的策略,先理清组件内模块和模块关系,构建一个具象化的逻辑视图。之后再按需阅读代码,这样的好处是要读的代码变少了,而且读的时候理解起来也更容易。那么什么是模块呢?这篇文章里,我们可以把模块理解成各个编程语言中组织代码的单位,比如Java/C++中Class。
很少有文档能够详细到描述一个组件内的实现,此时我们就陷入了一个困境:要想理清组件内的模块,我们不得不先读一次代码。
好在这个问题并不难解。分析架构的时候,我们已经分析了请求在组件间的处理过程,深入到模块内的代码后,我们还要在更低的层级上再分析一遍请求的处理流程。通过跟踪一次请求在代码中的流转,我们可以大致找出处理过程中涉及到的所有模块。有了这些信息,我们可以画一个简单的模块交互图,描述整个流程的处理过程。接着再从这个结构开始,逐个模块的分析模块的功能、接口和模块之间的交互。
对于面向对象编程语言的项目,通常包含抽象接口和具体实现,而且一个接口还有多种实现,有时候还会出现复杂的继承关系。理解这些代码时,一方面要弄清楚接口语义,另一方面通过具体的实现加深对接口的理解,在抽象和具体之间穿插前行。在一张图上画出所有的类、类的集继承和依赖关系,设计意图就容易理解了。针对小部分特别复杂的逻辑,画出流程图,更清晰直观。
如果代码的核心逻辑是处理数据,尤其是较为复杂的数据,此时要重点关注数据结构,数据在内存中的表示。搞清楚数据结构,再去分析操作数据结构的代码,顺序不能错,没搞清楚数据结构,不可能理解操作数据的代码。
区分独立的库
很多代码中都依赖一些独立的库,比如框架、中间件。遇到这些代码时,如果之前没有接触过对应的库,先停下来,找到对应库的文档,看看它的用法,千万不要直接跑到库的实现代码里去。了解这些库的用法,搞清楚正在阅读的代码通过库实现什么功能,做到这里就够了,代码原作者大概率也只是调用这些库,并不清楚库的内部实现。如果对库的内部实现感兴趣,想进一步了解,不妨换个时间再看。
工欲善其事,必先利其器。
一套好的工具能极大的提升读代码的效率。考虑到不同的语言工具并不相同,每个人也有自己的选择偏好,这里就不推荐了,只要确保你在读代码前已经配置好了自己的工具,在代码中能够轻松跳转,能够快速找到类型定义,能够查到哪些地方调用了当前函数。
从读代码到写代码
代码读的多了,自然就能理解什么样的代码是好代码,读起来赏心悦目,什么样的是垃圾,读起来让人抓狂。轮到自己写代码的时候,不妨结合读代码的经验,让写出来的代码可读性更好。想想自己的代码组织结构是不是清晰,概念是不是准确,变量命名是不是合理,有没有在合适的地方加上注释。最后,有没有提供对应的文档,文档有没有及时更新。