天天看點

如何閱讀代碼

身為一個程式員,工作中最重要的事情當然是寫代碼,其次就是讀代碼了。我們都是先閱讀了别人的代碼,才模仿着寫下了自己的第一行代碼。接手維護别人的項目,要讀代碼,遇到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。

使用者文檔也可以大緻浏覽一下,不用細看,掃一眼目錄,如果發現有些功能是自己不知道的,可以重點看看這些功能的介紹。

把握整體架構

讀代碼的時候并不需要去一行一行的閱讀完所有的代碼。掌握了整體結構之後,很容易判斷出自己希望了解的細節位于哪個部分的代碼裡,接下來直接找對應的代碼子產品就可以了。掌握了整體架構,了解了每個子產品的職責和輸入輸出,也能讓後面了解代碼變得更簡單。

對于整體架構,需要掌握哪些資訊呢?不妨嘗試要求自己回答下面幾個問題:

  1. 系統包含哪些元件
  2. 對于每個元件
    1. 職責是什麼
    2. 運作在哪裡,如何部署(是手工啟動還是系統自動建立)
    3. 什麼樣的方式運作 ,單機、叢集、主備
    4. 元件狀态管理,元件本身是否有資料,資料存放在哪裡
    5. 對外提供了哪些接口,這些接口誰會調用
    6. 對外接口的暴露方式,通信協定。對于叢集/主備方式部署的元件,接入流量的轉發模式,請求是通過什麼方式分發到元件執行個體上的。
  3. 一個完整的操作,在系統裡是怎麼流轉的。

概念模型、資料和流程

概念模型是軟體對現實世界問題的抽象,一個軟體項目中通常包含一組相關的概念模型。在設計系統時,我們有意識或無意識的将問題進行抽象,得到一組資料結構和資料結構上能進行的各種操作,然後用代碼來實作。

從任何一個項目中,我們都可以找出其中的概念模型,不管項目是大是小。

概念模型并不是孤立存在的,項目中包含多個概念模型,它們之間也存在各種關系。一個系統中包含商品和訂單兩個概念模型,一個訂單内又可以包含多個商品。

理清楚項目中的概念模型,模型間的關系,模型支援的操作,遇到相關的代碼就能輕松了解代碼的意圖。

主次有别

在掌握架構和概念模型之後,對項目已經掌握了一大半。接下來可以開始讀代碼,但不是所有的代碼都需要閱讀。什麼樣的代碼需要閱讀?對于我個人而言,隻有滿足下面的條件時我才會去看其中的代碼

  • 工作中遇到了問題,這個問題沒有現成的解決方案,或者雖然能找到解決方案,但是希望知道更多的細節。
  • 知道項目中實作了某個功能,我自己有一些實作的思路,想知道它怎麼實作的,和自己的方法進行對比。
  • 項目中有個神奇的功能,網絡上也找不到實作方面的資料,很好奇它是怎麼實作的,隻能去看代碼。

做事要分輕重緩急,讀代碼也一樣,厘清主次,找到需要讀的代碼,其他的代碼等真正需要的時候再看也不遲。

自頂向下

找到要閱讀的代碼之後,同樣不要忙着讀,依然要用自頂向下的政策,先理清元件内子產品和子產品關系,建構一個具象化的邏輯視圖。之後再按需閱讀代碼,這樣的好處是要讀的代碼變少了,而且讀的時候了解起來也更容易。那麼什麼是子產品呢?這篇文章裡,我們可以把子產品了解成各個程式設計語言中組織代碼的機關,比如Java/C++中Class。

很少有文檔能夠詳細到描述一個元件内的實作,此時我們就陷入了一個困境:要想理清元件内的子產品,我們不得不先讀一次代碼。

好在這個問題并不難解。分析架構的時候,我們已經分析了請求在元件間的處理過程,深入到子產品内的代碼後,我們還要在更低的層級上再分析一遍請求的處理流程。通過跟蹤一次請求在代碼中的流轉,我們可以大緻找出處理過程中涉及到的所有子產品。有了這些資訊,我們可以畫一個簡單的子產品互動圖,描述整個流程的處理過程。接着再從這個結構開始,逐個子產品的分析子產品的功能、接口和子產品之間的互動。

對于面向對象程式設計語言的項目,通常包含抽象接口和具體實作,而且一個接口還有多種實作,有時候還會出現複雜的繼承關系。了解這些代碼時,一方面要弄清楚接口語義,另一方面通過具體的實作加深對接口的了解,在抽象和具體之間穿插前行。在一張圖上畫出所有的類、類的集繼承和依賴關系,設計意圖就容易了解了。針對小部分特别複雜的邏輯,畫出流程圖,更清晰直覺。

如果代碼的核心邏輯是處理資料,尤其是較為複雜的資料,此時要重點關注資料結構,資料在記憶體中的表示。搞清楚資料結構,再去分析操作資料結構的代碼,順序不能錯,沒搞清楚資料結構,不可能了解操作資料的代碼。

區分獨立的庫

很多代碼中都依賴一些獨立的庫,比如架構、中間件。遇到這些代碼時,如果之前沒有接觸過對應的庫,先停下來,找到對應庫的文檔,看看它的用法,千萬不要直接跑到庫的實作代碼裡去。了解這些庫的用法,搞清楚正在閱讀的代碼通過庫實作什麼功能,做到這裡就夠了,代碼原作者大機率也隻是調用這些庫,并不清楚庫的内部實作。如果對庫的内部實作感興趣,想進一步了解,不妨換個時間再看。

工欲善其事,必先利其器。

一套好的工具能極大的提升讀代碼的效率。考慮到不同的語言工具并不相同,每個人也有自己的選擇偏好,這裡就不推薦了,隻要確定你在讀代碼前已經配置好了自己的工具,在代碼中能夠輕松跳轉,能夠快速找到類型定義,能夠查到哪些地方調用了目前函數。

從讀代碼到寫代碼

代碼讀的多了,自然就能了解什麼樣的代碼是好代碼,讀起來賞心悅目,什麼樣的是垃圾,讀起來讓人抓狂。輪到自己寫代碼的時候,不妨結合讀代碼的經驗,讓寫出來的代碼可讀性更好。想想自己的代碼組織結構是不是清晰,概念是不是準确,變量命名是不是合理,有沒有在合适的地方加上注釋。最後,有沒有提供對應的文檔,文檔有沒有及時更新。