程式码是别人写的,只有原作者才真的了解程式码的用途及涵义。许多程式人心裡都有一种不自觉的恐惧感,深怕被迫去碰触其他人所写的程式码。但是,与其抗拒接收别人的程式码,不如彻底了解相关的语言和惯例,当成是培养自我实力的基石。对大多数的程式人来说,撰写程式码或许是令人开心的一件事情,但我相信,有更多人视阅读他人所写成的程式码為畏途。许多人寧可自己重新写过一遍程式码,也不愿意接收别人的程式码,进而修正错误、维护它们、甚至加强功能。这其中的关键究竟在何处呢?若是一语道破,其实也很简单,程式码是别人写的,只有原作者才真的了解程式码的用途及涵义。许多程式人心裡都有一种不自觉的恐惧感,深怕被迫去碰触其他人所写的程式码。这是来自于人类内心深处对于陌生事物的原始恐惧。
读懂别人写的程式码,让你收穫满满
不过,基于许多现实的原因,程式人时常受迫要去接收别人的程式码。例如,同事离职了,必须接手他遗留下来的工作;也有可能你是刚进部门的菜鸟,而同事经验值够了、升级了,风水轮流转,一代菜鸟换菜鸟。甚至,你的公司所承接的专案,必须接手或是整合客户前一个厂商所遗留下来的系统,你们手上只有那套系统的原始码(运气好时,还有数量不等的文件)。
诸如此类的故事,其实时常在程式人身边或身上持续上演著。许多程式人都将接手他人的程式码,当做一件悲惨的事情。每个人都不想接手别人所撰写的程式码,因為不想花时间去探索,寧可将生產力花在產生新的程式码,而不是耗费在了解这些程式码上。
很遗憾的是,上述的情况对程式人来说很难避免。我们总是必须碰触到其他人所写成的程式码,甚至必须了解它、加以修改。对于这项需求,在现今开放原始码的风气如此盛行的今日,正如之前的「程式设计2.0」文中所提到的,你可以透过开放原始码学习到新的技术、学习到高手的架构设计,大幅提高学习的效率及效果。你甚至可以直接自开放原始码专案中抽取、提炼出自己所需的程式码,站在巨人的肩膀上,直接由彼端获得所需的生產力。从这个观点来看,读懂别人所写的程式码,就不再只是从负面观点的「被迫接收」,而是极具正面价值的「汲取养份」。
先了解系统架构与行為模式,再细读
倘若撰写程式码是程式人的重要技艺之一,那麼读懂别人的程式码、接著加以修改,也势必是另一个重要的技艺。
如果你不能熟悉这项工作,不仅在遭逢你所不愿面对的局面时,无法解决眼前接手他人程式码的难题,更重要的是,当你看著眼前现成的程式码,却不知如何从中擷取自己所需,导致最后只能入宝山空手回,望之兴叹。
接触他人的程式码,大致上可以分為三种程度:一、了解,二、修改、扩充,三、抽取、提炼。
了解别人的程式码是最基础的工作,倘若不能了解自己要处理的程式码,就甭论修改或扩充,更不可能去芜存菁,从中萃取出自己所需,回收再利用别人所撰写的程式码。
虽说是「阅读」,但程式码并不像文章或小说一样,透过这种做法,便能够获得一定程度的了解。阅读文章或小说时,几乎都是循序地阅读,你只消翻开第一页,一行行阅读下去即可。但是,有许多程式人在试著阅读其他人的程式码时,却往往有不知如何读起的困难。
或许找到系统的第一页(也就是程式码执行的啟始点)并不难,但是复杂度高的系统,有时十分庞大,有时千头万绪。
从程式码的啟始点开始读起,一来要循序读完所有的程式码旷日费时,二来透过这种方式来了解系统,很难在脑中构建出系统的面貌,进而了解到系统真正的行為。所以,阅读程式码的重点,不在于读完每一行程式码,而是在于有效率地透过探索及阅读,从而了解系统的架构及行為模式。以便在你需要了解任何片段的细节实作时,能够很快在脑上对映到具体的程式码位置,直到那一刻,才是细读的时机。
熟悉沟通语言与惯例用语
不论如何,有些基本的準备,是阅读他人程式码时必须要有的。
首先,你最好得了解程式码写成的程式语言。想要读懂法文写成的小说,总不能连法文都不懂吧。有些情况则很特殊。我们虽然不懂该程式码撰写所用的语言,但是因為现代语言的高阶化,而且流行的程式语言多半都是血统相近,所以即使不那麼熟悉,有时也可勉力為之。
除了认识所用语言之外,再来就是要先确认程式码所用的命名惯例(naming convention)。了解命名惯例很重要,不同的程式人或开发团队,差异可能很大。
这命名惯例涵盖的范围通常包括了变数的名称、函式的名称、类别(如果是物件导向的话)的名称、原始码档案、甚至是专案建构目录的名称。倘若使用了像设计模式之类的方法,这些名称更有一些具体的表述方式。
命名惯例有点像是程式人在程式语言之上,另行建构的一组沟通行话。程式人会透过共通约束、遵守的命名惯例,来表达一些较高阶的概念。例如,有名的匈牙利式命名法,便将变数名称以属性、型别、说明合併在一起描述。对程式人来说,这种方式能够提供更丰富的资讯,以了解该变数的作用及性质。
对程式码阅读来说,熟悉这个做法之所以重要,是因為当你了解整个系统所采用的惯例时,你便能试著以他们所共同操用的语汇来进行理解。倘若,不能了解其所用的惯例,那麼这些额外提供的资讯,就无法為你所用。像以设计模式写成的程式码,同样处处充满著模式的名称,诸如:Factory、Facade、Proxy等等。以这些名称指涉的类别,也直接透过名称,表达了它们自身的作用。对于懂得这命名惯例的读者来说,不需要深入探索,也能很快捕捉到这些类别的意义。
当你拿到一套必须阅读的程式码时,最好先取得命名惯例的说明文件。然而,并不是每套程式码都附有此类的说明文件。另一个方式,就是自己到程式码中,大略瀏览一遍,有经验的程式人可以轻易发掘出该系统所用的命名惯例。
常见的命名方式不脱那几类,这时候经验就很重要,倘若你知道的惯例越多,就越能轻易识别他人所用的惯例。如果运气很糟,程式码所用的惯例是前所未见的,那麼你也得花点时间归纳,凭自己的力量找出这程式码命名上的规则。
掌握程式码撰写者的心态与习惯
大多数的程式码,基本上都依循一致的命名惯例。不过运气更差的时候,一套系统中可能会充斥著多套命名惯例。这有可能是因為开发团队由多组人马所构成,每组人马都有不同的文化,而在专案开发管理又没有管控得宜所造成。最糟的情况,程式码完全没有明显的惯例可言,这时候阅读的难度就更高了。
想要阅读程式码,得先试著体会程式码作者的「心」。想要这麼做,就得多了解对方所使用的语言,以及惯常运用的语汇。在下一回中,我们将继续探讨阅读程式码的相关议题。
【摸清架构,便可轻鬆掌握全貌 】
在本文中,我们的重点放在:要了解一个系统,最好是采取由上至下的方式。先试著捕捉系统架构性的观念,不要过早钻进细节,因為那通常对于你了解全貌,没有多大的帮助。阅读程式码不需要从第一行读起,我们的目的并不是在于读遍每一段程式码。
基于许多原因,程式人需要阅读其他人所写成的程式码。而对程式设计2.0时代的程式人来说,最正面的价值在于,能读懂别人程式的人,才有能力从中萃取自己所需的程式,藉以提高生產力。
阅读程式码的目的,在于了解全貌而非细节
想要读懂别人程式码的根本基础,便是了解对方所用的程式语言及命名惯例。有了这个基础之后,才算是具备了基本的阅读能力。正如我之前提到的──想要读懂法文写成的小说,总不能连法文都不懂吧。阅读程式码和阅读文学作品,都需要了解撰写所用的语言及作者习用的语汇。
但我们在阅读文学作品通常是采循序的方式,也就是从第一页开始,一行一行地读下去,依循作者為你铺陈的步调,逐渐进到他為你準备好的世界裡。
阅读程式码却大大不同。我们很少从第一行开始读起,因為除非它是很简单的单执行绪程式,否则很少这麼做。因為要是这麼做,就很难了解整个系统的全貌。
是的,我们这边提到了一个重点,阅读程式码的目的在于了解系统的全貌,而不是在于只是為了地毯式的读遍每一段程式码。
就拿面向对象程式语言所写成的系统来说,整个系统被拆解、分析成為一个个独立的类别。阅读个别类别的程式码,或许可以明白每项类别物件个别的行為。但对于各类别物件之间如何交互影响、如何协同工作,又很容易陷入盲人摸象的困境。这是因為各类别的程式码,只描述个别物件的行為,而片段的阅读就只能造就片面的认识。
由上而下釐清架构后,便可轻易理解组成关系
如果你想要跳脱困境,不想浪费大量时间阅读程式码,却始终只能捕捉到对系统片段认识,就必须转换到另一种观点来看待系统。从个别的类别行為著手,是由下至上(Bottom-Up)的方法;在阅读程式码时,却应该先采由上至下(Top-Down)的方式。对程式码的阅读来说,由上至下意谓著,你得先了解整个系统架构。
系统的架构是整个系统的骨干、支柱。它表现出系统最突出的特徵。知道系统架构究竟属于那一种类型,通常大大有益于了解系统的个别组成之间的静态及动态关系。
有些系统因為所用的技术或框架的关系,决定了最上层的架构。例如,采用Java Servlet/JSP技术的应用系统,最外层的架构便是以J2EE(或起码J2EE中的Web Container)為根本。
使用Java Servlet/JSP技术时,决定了某些组成之间的关系。例如,Web Container依据web.xml的内容载入所有的Servlets、Listeners、以及Filters。每当Context发生事件(例如初始化)时,它便会通知Listener类别。每当它收到来自客户端的请求时,便会依循设定的所有Filter Chain,让每个Filter都有机会检查并处理此一请求,最后再将请求导至用来处理该请求的Servlet。
当我们明白某个系统采用这样的架构时,便可以很容易地知道各个组成之间的关系。即使我们还不知道究竟有多少Servlets,但我们会知道,每当收到一个请求时,总是会有个相对应的Servlet来处理它。当想要关注某个请求如何处理时,我应该去找出这个请求对应的Servlet。
了解架构,必须要加上层次感
同样的,以Java写成的Web应用程式中,也许会应用诸如Struts之类的MVC框架,以及像Hibernate这样的资料存取框架。它们都可以视為最主要的架构下的较次级架构。而各个应用系统,甚至有可能在Struts及Hibernate之下,建立自有的更次级的架构。
也就是说,当我们谈到「架构」这样的观念时,必须要有层次感。而不论是那一层级的架构,都会定义出各自的角色,以及角色间的关系。对阅读者来说,相较于直接切入最细微的单一角色行為,不如了解某个特定的架构中,究竟存在多少角色,以及这些角色之间的互动模式,比较能够帮助我们了解整个系统的运作方式。
这是一个很重要的关键,当你试著进到最细节处之前,应该先试著找出参与的角色,及他们之间的关系。例如,对事件驱动式的架构而言,有3个很重要的角色。一个是事件处理的分派器(Event Dispatcher)、一个是事件產生者(Event Generator)、另一个则是事件处理器(Event Handler)。
事件產生器產生事件,并送至事件分派器,而事件分派器负责找出各事件相对应的事件处理器,并且转交该事件,并命令事件处理器加以处理。像Windows的GUI应用程式,便是采用事件驱动式的架构。
当你知道此类的应用程式皆為事件驱动式的架构时,你便可以进一步得知,在这样的架构下会有3种主要的角色。虽然也许还不清楚整个系统中,究竟会需要处理多少事件的类型,但对你而言,已经建立了对系统全貌最概观的认识。
虽然你还不清楚所有的细节,但诸如确切会有那些事件类型之类的资讯,在此刻还不重要──不要忘了,我们采取的是由上而下的方式,要先摸清楚主建筑结构,至于壁纸的花色怎麼处理,那是到了尾声时才会做的事。
探索架构的第一件事:找出系统如何初始化
有经验的程式人,对于时常被运用的架构都很熟悉。常常只需要瞧上几眼,就能明白一个系统所用的架构,自然就能够直接联想到其中会存在的角色,以及角色间的关系。
然而,并不是每个系统所用的架构,都是大眾所熟悉,或是一眼能够望穿的。这时候,你需要探索。目标同样要放在界定其中的角色、以及角色间的静态、动态关系。
不论某个系统所采用的架构是否為大部分人所熟知的,在试著探索一个系统的长相时,我们应该找出来几个答案,了解在它所用的架构下,下列这件事是如何被完成的:一、系统如何初始化,二、与这个系统相接的其他系统(或使用者)有那些,而相接的介面又是什麼;三、系统如何反应各种事件,四、系统如何处理各种异常及错误。
系统如何初始化是很重要的一件事,因為初始化是為了接下来的所有事物而做的準备。从初始化的方式、内容,能知道系统做了什麼準备,对于系统会有什麼行為展现,也就能得窥一二了。
之所以要了解与系统相接的其他系统(或使用者),為的是要界定出系统的边界。其他的系统可能会提供输入给我们所探索的系统,也可能接收来自这系统的输出,了解这边界所在,才能确定系统的外观。
而系统所反应的事件类型、以及如何反应,基本上就代表著系统本身的主要行為模式。最后,我们必须了解系统处理异常及错误的方式,这同样也是系统的重要行為,但容易被忽略。
之前,我们提到必须先具备一个系统的语言基础,才能够进一步加以阅读,而在本文中,我们的重点放在:要了解一个系统,最好是采取由上至下的方式。先试著捕捉系统架构性的观念,不要过早钻进细节,因為那通常对于你了解全貌,没有多大的帮助。
【优质工具在手,读懂程式非难事 】
系统的复杂度往往超过人脑的负荷。阅读程式码的时候,你会需要更多工具提供协助。使用好的整合式开发环境(IDE)或文字编辑器,就能提供最基本的帮助。
阅读程式码的动作,可以是很原始的,利用最简单的文字编辑器,逐一开啟原始码,然后凭藉著一己的组织能力,在不同的程式码间跳跃,拼凑出脑中想要构建的图像。
不过,系统的复杂度往往超过人脑的负荷。阅读程式码的时候,你会需要更多工具提供协助。使用好的整合式开发环境(IDE)或文字编辑器,就能提供最基本的帮助。
善用文字编辑器或IDE,加速解读程式码
许多文字编辑器提供了常见程式语言的语法及关键字标示功能。这对于阅读来说,绝对能够起很大的作用。有些文字编辑器(例如我常用的EditPlus及偶而使用的Notepad++),甚至能够自动列出某个原始档中所有定义的函式清单,更允许你直接从清单中选择函式,直接跳跃到该函式的定义位置。这对于阅读程式码的人来说,就提供了极佳的便利性。
因為在阅读程式码时,最常做的事,就是随著程式中的某个控制流,将阅读的重心,从某个函式移至它所呼叫的另一个函式。所以对程式人来说,阅读程式码时最常做的事之一就是:找出某个函式位在那一个原始档裡,接著找到该函式所在的位置。
好的IDE能够提供的协助就更多了。有些能够自动呈现一些额外的资讯,最有用的莫过于函式的原型宣告了。例如,有些IDE支援当游标停留在某函式名称上一段时间后,它会以Tooltip的方式显示该函式的原型宣告。
对阅读程式码的人来说,在看到程式码中呼叫到某个函式时,可以直接利用这样的支援,立即取得和这个函式有关的原型资讯,马上就能知道呼叫该函式所传入的各个引数的意义,而不必等到将该函式的定义位置找出后,才能明白这件事。
grep是一个基本而极為有用的工具
除了选用好的文字编辑器或IDE之外,还有一个基本、但却极為有用的工具,它就是grep。熟悉Unix作业系统的程式人,对grep这个公用程式多半都不陌生。Grep最大的用途,在于它允许我们搜寻某个目录(包括递迴进入所有子目录)中所有指定档案,是否有符合指定条件(常数字串或正规表示式)档案。
倘若有的话,则能帮你指出所在的位置。这在阅读程式码时的作用极大。当我们随著阅读的脚步,遇上了任何一个不认识、但自认為重要的类别、函式、资料结构定义或变数,我们就得找出它究竟位在这茫茫程式码海中的何处,才能将这个图块从未知变為已知。
grep之所以好用,就是在于当我们发现某个未知的事物时,可以轻易地利用它找出这个未知的事物究竟位在何方。此外,虽说grep是Unix的标準公用程式之一,但是像Windows这样子的平臺,也有各种类型的grep程式。对于在Windows环境工作的程式人来说,可以自行选用觉得称手的工具。
gtags可建立索引,让搜寻更有效率
grep虽然好用,但是仍然有一些不足之处。第一个缺点在于它并不会為所搜寻的原始码档案索引。每当你搜寻时,它都会逐一地找出所有的档案,并且读取其中的所有内容,过滤出满足指定条件的档案。当专案的原始码数量太大时,就会產生搜寻效率不高的问题。
第二个缺点是它只是一个单纯的文字档搜寻工具,本身并不会剖析原始码所对应的语言语法。当我们只想针对「函式」名称进行搜寻时,它有可能将註解中含有该名称的原始码,也一併找了出来。
针对grep的缺点,打算阅读他人程式码的程式人,可以考虑使用像是gtags这样子的工具。gtags是GNU GLOBAL source code tag system,它不只搜寻文字层次,而且因為具备了各种语言的语法剖析器,所以在搜寻时,可以只针对和语言有关的元素,例如类别名称、函式名称等。
而且,它能针对原始码的内容进行索引,这意谓一旦建好索引之后,每次搜寻的动作,都毋需重新读取所有原始码的内容并逐一搜寻。只需要以现成的索引结构為基础,即可有效率的寻找关键段落。
gtags提供了基于命令列的程式,让你指定原始码所在的目录执行建立索引的动作。它同时也提供程式让你得如同操作grep一般,针对索引结构进行搜寻及检索。它提供了许多有用的检索方式,例如找出专案中定义某个资料结构的档案及定义所在的行号,或者是找出专案中所有引用某资料结构的档案,以及引用处的行号。
这麼一来,你就可以轻易地针对阅读程式码时的需求予以检索。相较于grep所能提供的支援,gtags这样的工具,简直是强大许多。
再搭配htags製作HTML文件,更是如虎添翼
还有一个绝对需要一提的工具。这个叫做htags的工具,能够帮你将已製作完成的索引结构,製作成為一组相互参考的HTML文件。基本上,利用这样的HTML文件阅读程式码,比起单纯地直接阅读原始码,来得更有结构。原因是阅读程式码时,这样的HTML文件,已经為你建立起在各个原始码档案片段间跳跃的链结。例如,图一是针对一个有名的开放原始码专案ffmpeg,由gtags所產生出来的HTML文件首页的一部分。
【望文生义,进而推敲组件的作用 】
先建立系统的架构性认识,然后透过名称及命名惯例,就可以推测出各组件的作用。例如:当Winamp尝试著初始化一个Plug-In时,它会呼叫这个结构中的init函式,以便让每个Plug-In程式有机会初始化自己。当Winamp打算结束自己或结束某个Plug-In的执行时,便会呼叫quit函式。
在阅读程式码的细节之前,我们应先试著捕捉系统的运作情境。在采取由上至下的方式时,系统性的架构是最顶端的层次,而系统的运作情境,则是在它之下的另一个层次。
好的说明文件难求,拼凑故事的能力很重要
有些系统提供良善的说明文件,也许还利用UML充分描述系统的运作情境。那麼对于阅读者来说,从系统的分析及设计文件著手,便是快速了解系统运作情境的一个途径。
但是,并不是每个软体专案都伴随著良好的系统文件,而许多极具价值的开放原始码专案,也时常不具备此类的文件。对此,阅读者必须尝试自行捕捉,并适度地记录捕捉到的运作情境。
我喜欢将系统的运作情境,比拟成系统会上演的故事情节。在阅读细节性质的程式码前,先知道系统究竟会发生那些故事,是必备的基本功课。你可以利用熟悉或者自己发明的表示工具,描述你所找到的情境。甚至可以只利用简单的列表,直接将它们列出。只要能够达到记录的目的,对程式码阅读来说,都能够提供帮助。或者,你也可以利用UML中的类别图、合作图、循序图之类的表示方法,做出更详细的描述。
当你能够列出系统可能会有的情境,表示你对系统所具备的功能,以及在各种情况下的反应,都具备概括性的认识。以此為基础,便可在任何需要的时候,钻进细节处深入了解。
探索架构的第一步──找到程式的入口
在之前,我们在一个开发专案中,曾经需要将系统所得到的MP3音讯档,放至iPod这个极受欢迎的播放设备中。
虽然iPod本身也可以做為可移动式的储存设备,但并不是单纯地将MP3档案放到iPod中,就可以让iPod的播放器认得这个档案,甚至能够加以播放。
这是因為iPod利用一个特殊的档案结构(iTunes DB),记录播放器中可供播放的乐曲、播放清单以及乐曲资讯(例如专辑名称、乐曲长度、演唱者等)。為了了解并且试著重复使用既有的程式码,我们找到了一个Winamp的iPod外掛程式(Plug-In)。
Winamp是个人电脑上极受欢迎的播放软体,而我们找到的外掛程式,能让Winamp直接显示连接至电脑的iPod中的歌曲资讯,并且允许Winamp直接播放。
我们追踪与阅读这个外掛程式的思路及步骤如下,首先,我们要先了解外掛程式的系统架构。很明显的,大概瀏览过原始码后,我们注意到它依循著WinAmp為Plug-In程式所制定的规范,也就是说,它是实作成Windows上的DLL,并且透过一个叫做winampGetMediaLibraryPlugin的DLL函式,提供一个名為winampMediaLibraryPlugin的结构。
当我们不清楚系统的架构究竟為何时,我们会试著探索,而第一步,便是找到程式的入口。如何找到呢?这会依程式的性质不同而有所差别。
对一个本身就是可独立执行的程式来说,我们会找啟动程式的主要函式,例如对C/C++来说就是main(),而对Java来说,便是static void main()。在找到入口后,再逐一追踪,摸索出系统的架构。
但有时,我们所欲阅读的程式码是类别库或函式库,它只是用来提供多个类别或函式供用户端程式(Client Program)使用,本身并不具单一入口,此类的程式码具有多重的入口──每个允许用户端程式呼叫的函式或类别,都是它可能的入口。
例如,对WinAmp的iPod Plug-In来说,它是一个DLL形式的函式库,所以当我们想了解它的架构时,必须要先找出它对外提供的函式,而对Windows DLL来说,对外提供的函式,皆会以dllexport这个关键字来修饰。所以,不论是利用grep或gtags之类的工具,我们可以很快从原始码中,找到它只有一个DLL函式(这对我们而言,真是一个好消息),而这个函式便是上述的winampGetMediaLibraryPlugin。
系统多会采用相同的架构处理Plug-In程式
如果经验不够的话,也许无法直接猜出这个函式的作用。
不过,如果你是个有经验的程式人,多半能从函式所回传的结构,猜出这个函式实际的用途。而事实上,当你已经知道它是一个Plug-In程式时,就应该要明白,它可能采用的,就是许多系统都采用的相同架构处理Plug-In程式。
当一个系统采用所谓Plug-In形式的架构时,它通常不会知道它的Plug-In究竟会怎麼实作、实作什麼功能。它只会规范Plug-In程式需要满足某个特定介面。当系统初始化时,所有的Plug-In都可以依循相同的方式,向系统註册,合法宣示自己的存在。
虽然系统并不确切知道Plug-In会有什麼行為展现,但是因為它制定了一个标準的介面,所以系统仍然可以预期每个Plug-In能够处理的动作类型。这些动作具体上怎麼执行,对系统来说并不重要。这也正是物件导向程式设计中的「多型」观念。
随著实务经验,归纳常见的架构模式
我想表达的重点,是当你「涉世越深」之后,所接触的架构越多,就越能触类旁通。只需要瞧上几眼,就能明白系统所用的架构,自然就能够直接联想到其中可能存在的角色,以及角色间的关系。
像上述的Plug-In程式手法,时常可以在许多允许「外掛」程式码的系统中看到。所以,有经验的阅读者,多半能够立即反应,知道像Winamp这样的系统,应该是让每个Plug-In程式,都写成DLL函式库。
而每个Plug-In的DLL函式库中,都必须提供winampGetMediaLibraryPlugin()这个函式(如果你熟悉Windows的程式设计,你会知道这是利用LoadLibrary()和GetProcAddress()来达成的一种多型手法)。如果你熟悉设计模式,你更会知道这是Simple Factory Method这个设计模式的运用。
winampGetMediaLibraryPlugin()所回传的winampMediaLibraryPlugin结构,正好就描述了每个Winamp Plug-In的实作内容。
善用名称可加速了解
利用gtags这个工具,我们立即发现,这个Plug-In它所定义的init、quit、PluginMessageProc这三个名称,都是函式名称。这暗示在多型的作用下,它们都是在某些时间点,会由Winamp核心本体呼叫的函式。
名称及命名惯例是很重要的。看到「init」,我们会知道它的作用多半是进行初始化的动作,而「quit」大概就是结束时处理函式,而PluginMessageProc多半就是各种讯息的处理常式(Proc通常是procedure的简写,所以PluginMessageProc意指Plugin Message Procedure)了。
「望文生义」很重要,我们看到函式的名称,就可以猜想到它所代表的作用,例如:当Winamp尝试著初始化一个Plug-In时,它会呼叫这个结构中的init函式,以便让每个Plug-In程式有机会初始化自己;当Winamp打算结束自己或结束某个Plug-In的执行时,便会呼叫quit函式。当Winamp要和Plug-In程式沟通时,它会发送各种不同的讯息至Plug-In,而Plug-In程式必须对此做出回应。
我们甚至不需要检视这几个函式的内容,就可以做出推测,而这样的假设,事实上也是正确的。
【找到程式入口,再由上而下抽丝剥茧 】
根据需要决定展开的层数,或展开特定节点,并记录树状结构,然后适度忽略不需要了解的细节─这是一个很重要的态度。因為你不会一次就需要所有的细节,阅读都是有目的的,每次的阅读也许都在探索程式中不同的区域。
探索系统架构的第一步,就是找到程式的入口点。找到入口点后,多半采取由上而下(Top-Down)的方式,由最外层的结构,一层一层逐渐探索越来越多的细节。
我们的开发团队曾针对Winamp的iPod plug-in进行阅读及探索,不仅找到入口点,也找出、并理解它最根本的基础架构。从这个入口点,可以往下再展开一层,分别找到三个重要的组成及其意义:
● init():初始化动作
● quit():终止化动作
● PluginMessageProc():以讯息的方式处理程式所必须处理的各种事件
展开的同时,随手记录树状结构
当我们从一个入口点找到三个分支后,可以顺著每个分支再展开一层,所以分别继续阅读init、quit、以及PluginMessageProc的内容,并试著再展开一层。阅读的同时,你可以在文件中试著记录展开的树状结构。
● init():初始化动作
● itunesdb_init_cc():建立存取iTunes database的同步物件
● 初始化资料结构
● 初始化GUI元素
● 载入设定
● 建立log档
● autoDetectIpod():侦测iPod插入的执行绪
● quit():终止化动作
● itunesdb_del_cc():终止存取iTunes database的同步物件
● 关闭log档
● 终止化GUI元素
● PluginMessageProc():以讯息的方式处理程式所必须面临的各种事件
● 执行所连接之iPod的MessageProc()
这部分必须要留意几个重点。首先,应该一边阅读,一边记录文件。因為人的记忆力通常有限,对于陌生的事物更是容易遗忘,因此边阅读边记录,是很好的辅助。
再者,因為我们采取由上而下的方式,从一个点再分支出去成為多个点,因此,通常也会以树状的方式记录。
itunesdb_init_cc();
currentiPod=NULL;
iPods = new C_ItemList;
…略
conf_file=(char*)SendMessage(plugin.hwndWinampParent,WM_WA_IPC,0,IPC_GETINIFILE);
m_treeview = GetDlgItem(plugin.hwnd LibraryParent,0x3fd);
//this number is actually magic 🙂
…略
g_detectAll = GetPrivateProfileInt(“ml_ipod”, “detectAll”,0,conf_file)!=0;
…略
g_log=GetPrivateProfileInt(“ml_ipod”,”log”,0,conf_file)!=0;
…略
g_logfile=fopen(g_logfilepath,”a”);
…略
autoDetectIpod();
return 0;
}因為我们只试著多探索一层,而目的是希望发掘出下一层的子动作。所以在init()中看到像「itunesdb_init_cc();」这样的函式呼叫动作时,我们知道它是在init()之下的一个独立子动作,所以可以直接将它列入。但是当看到如下的程式行:
currentiPod=NULL;
iPods = new C_ItemList;
我们并不会将它视為init()下的一个独立的子动作。因為好几行程式,才构成一个具有独立抽象意义的子动作。例如以上这两行构成了一个独立的抽象意义,也就是初始化所需的资料结构。
理论上,原来的程式撰写者,有可能撰写一个叫做init_data_structure()的函式,包含这两行程式码。这样做可读性更高,然而基于种种理由,原作者并没有这麼做。身為阅读者,必须自行解读,将这几行合併成单一个子动作,并赋予它一个独立的意义──初始化资料结构。
无法望文生义的函式,先试著预看一层
对于某些不明作用的函式叫用,不是望其文便能生其义的。当我们看到「itunesdb_init_cc()」这个名称时,我们或许能从「itunesdb_init」的字眼意识到这个函式和iPod所采用的iTunes database的初始化有关,但「cc」却实在令人费解。為了理解这一层某个子动作的真实意义,有时免不了要往前多看一层。
原来它是用来初始化同步化机制用的物件。作用在于这程式一定是用了某个内部的资料结构来储存iTunes database,而这资料结构有可能被多执行绪存取,所以必须以同步物件(此处是Windows的Critical Section)加以保护。
所以说,当我们试著以树状的方式,逐一展开每个动作的子动作时,有时必须多看一层,才能真正了解子动作的意义。因為有了这样的动作,我们可以在展开树状结构中,為itunesdb_init_cc()附上补充说明:建立存取itunes database的同步物件。这麼一来,当我们在检视自己所写下的树状结构时,就能轻易一目了然的理解每个子动作的真正作用。
根据需要了解的粒度,决定展开的层数
我们究竟需要展开多少层呢?这个问题和阅读程式码时所需的「粒度(Granularity)」有关。如果我们只是需要概括性的了解,那麼也许展开两层或三层,就能够对程式有基础的认识。倘若需要更深入的了解,就会需要展开更多的层次才行。
有时候,你并不是一视同仁地针对每个动作,都展开到相同深度的层次。也许,你会基于特殊的需求,专门针对特定的动作展开至深层。例如,我们阅读Winamp iPod plug-in的程式目录,其实是想从中了解究竟应该如何存取iPod上的iTunes DB,使我们能够将MP3歌曲或播放清单加至此DB中,并于iPod中播放。
当我们层层探索与分解之后,找到了parseIpodDb(),从函式名称判断它是我们想要的。因為它代表的正是parse iPod DB,正是我们此次阅读的重点,也就达成阅读这程式码的目的。
我们强调一种不同的做法:在阅读程式码时,多半采取由上而下的方式;而本文建议了一种记录阅读的方式,就是试著记录探索追踪时层层展开的树状结构。你可以视自己需要,了解的深入程度,再决定要展开的层数。你更可以依据特殊的需要,只展开某个特定的节点,以探索特定的细目。
适度地忽略不需要了解的细节,是一个很重要的态度,因為你不会一次就需要所有的细节,阅读都是有目的的。每次的阅读也许都在探索程式中不同的区域;而每次探索时,你都可以增补树状结构中的某个子结构。渐渐地,你就会对这个程式更加的了解。
【阅读的乐趣:透过程式码认识作者 】
即便每个人的写作模式多半受到他人的影响,程式人通常还是会融合多种风格,而成為自己独有的特色,如果你知道作者程式设计的偏好,阅读他的程式码就更得心应手。
阅读程式码时,多半会采取由上而下、抽丝剥茧的方式。透过记录层层展开的树状结构,程式人可以逐步地建立起对系统的架构观,而且可以依照需要的粒度(Granularity),决定展开的层次及精緻程度。
建立架构观点的认识是最重要的事情。虽然这一系列的文章前提為「阅读他人的程式码」,但我们真正想做的工作,并不在于彻底地详读每一行程式码的细节,而是想要透过重点式的程式码「摘读」,达到对系统所需程度的了解。每个人在阅读程式码的动机不尽相同,需要了解的程度也就有深浅的分别。只有极為少数的情况下,你才会需要细读每一行程式码。
阅读程式码是新时代程式人必备的重要技能
这一系列的文章至此已近尾声,回顾曾探讨的主题,我们首先研究了阅读程式码的动机。尤其在开放原始码的风气如此之盛的情况下,妥善利用开放原始码所提供的资源,不仅能够更快学习到新的技术,同时在原始码版权合适时,还可以直接利用现成的程式码,大幅地提高开发阶段的生產力。所以,阅读程式码儼然成為了新时代程式人必备的重要技能之一。
接著,我们提到了阅读程式码前的必要準备,包括了对程式语言、命名惯例的了解等等。
追踪原始码时,固然可以用本来的方式,利用编辑器开啟所需的档案,然后利用编辑器提供的机制阅读,但是倘若能够善用工具,阅读程式码的效率及品质都能大大提升。在本系列文章中,我们介绍了一些工具,或许你还可以在坊间找到其他更有用的工具。
我在这一系列的文章中,实际带著大家阅读、追踪了一个名為ml_pod的开放原始码专案。它是一个Winamp的iPod plug-in程式。在追踪的过程中,我们试著印证这一系列文中所提到的观念及方法。我们采用逐渐开展的树状结构来记录追踪的过程,并藉以建立起对系统的概观认识。
就原始码的阅读来说,之前的讨论涉及了工具面及技巧面。但还有一些主题不在这两个范畴之内,例如,善用名称赋予你的提示。名称做為隐喻(Metaphor)的作用很大,好的名称能够摘要性地点出实体的作用,例如我们看到autoDetectIpod(),自然而然能够想像它的作用在于自动(Auto)侦测(Detect)iPod的存在。
我们在展开树状结构时,有时候需要预看一层,有时却不需要这麼做,便可得到印证。程式人都会有惯用的名称以及组合名称的方法,倘若能够从名称上理解,便毋需钻进细节,可以省去相当多的时间。例如,当我们看到parseIpodDb()时,便可以轻易了解它是剖析(Parse)iPod的资料库(DB),因此便不需要立即钻进parseIpodDb()中查看底细。
儘管如此,能否理解程式人命名的用意,和自身的经验以及是否了解原作者的文化背景,是息息相关的。
命名本身就是一种文化產物。不同的程式人文化,就会衍生出不同的命名文化。当你自己的经验丰富,看过及接触过的程式码也多时,对于名称的感受及联想的能力自然会有不同。
这种感受和联想的能力,究竟应该如何精进,很难具体描述。就我个人的经验,多观察不同命名体系的差异,并且尝试归纳彼此之间的异同,有助于更快地提升对名称的感受及联想力。
转换立场,理解作者的思考方式
除了工具及技巧之外,「想要阅读程式码,得先试著阅读写这个程式码的程式人的心。」这句话说来十分抽象,或许也令人难以理解。
当你在阅读一段程式码时,或许可以试著转换自己的立场,从旁观者的角度转换成為写作者的心态,揣摩原作者的心理及处境。当你试著设身处地站在他的立场,透过他的思考方式来阅读、追踪他所写下的程式码,将会感觉更加流畅。
许多软体专案,都不是由单一程式人所独力完成。因此,在这样的专案中,便有可能呈现多种不同的风格。
许多专案会由架构师决定主体的架构及运作,有既定实施的命名惯例,及程式设计需要遵守方针。在多人开发的模式下,越是好的软体专案,越看不出某程式码片段究竟是由谁所写下的。
不过,有些开放原始码的专案,往往又整合了其他开放原始码的专案。有的时候,也很难求风格的统一,便会出现混杂的情况。好比之前提到的ml_pod专案,因為程式码中混合了不同的来源,而呈现风格不一致的情况。
我在阅读非自己所写的程式码时,会观察原作者写作的习惯,藉以对应到脑中所记忆的多种写作模型。在阅读的过程中,读完几行程式码,我会试著猜想原作者在写下这段程式码时的心境。他写下这段程式码的用意是什麼?為什麼他会采取这样的写法?顺著原作者的思考理路阅读,自己的思考才能更贴近对方写作当时的想法。
当你短暂化身為原作者时,才能更轻易的理解他所写下的程式码。
如果你能知道原作者的背景,程式设计时的偏好,阅读他的程式码,就更能得心应手了。
从程式码著手认识作者独有的风格,进而见贤思齐
我在阅读别人写下的程式码时,我会试著猜想,原作者究竟是属于那一种「流派」呢?每个人都有自己独特的写作模式,即便每个人的写作模式多半受到他人的影响──不论是书籍的作者、学习过程中的指导者,或一同参与专案的同儕,但每个程式人通常会融合多种风格,而成為自己独有的风格。
物件导向的基本教义派,总是会以他心中觉得最优雅的物件导向方式来撰写程式。而阅读惯用、善用设计模式的程式人所写下的程式码时,不难推想出他会在各种常见的应用情境下,套用哪些模式。
有些时候,在阅读之初,你并不知道原作者的习性跟喜好,甚至你也不知道他的功力。但是,在阅读之后,你会慢慢地从一个程式人所写下的程式码,开始认识他。
你或许会在阅读他人的程式码时,发现令人拍案叫绝的技巧或设计。你也有可能在阅读的同时,发现原作者所留下的缺失或写作时的缺点,而暗自警惕于心。这也算是阅读他人程式码时的一项乐趣。
当你从视阅读他人的程式码為畏途,转变成為可以从中获取乐趣的时候,我想,你又进到了另一个境界。
王建興
清華大學資訊工程系的博士研究生,研究興趣包括電腦網路、點對點網路、分散式網路管理、以及行動式代理人,專長則是Internet應用系統的開發。曾參與過的開發專案性質十分廣泛而且不同,從ERP、PC Game到P2P網路電話都在他的涉獵範圍之內。
原创文章,转载请注明: 转载自Humyna's Blog
本文链接地址: 閱讀他人的程式碼