如何閱讀一份原始碼?

Databend發表於2022-04-21

閱讀原始碼的能力算是程式設計師的一種底層基礎能力之一,這個能力之所以重要,原因在於:

  • 不可避免的需要閱讀或者接手他人的專案。比如調研一個開源專案,比如接手一個其他人的專案。
  • 閱讀優秀的專案原始碼是學習他人優秀經驗的重要途徑之一,這一點我自己深有體會。
    讀程式碼與寫程式碼是兩個不太一樣的技能,原因在於“寫程式碼是在表達自己,讀程式碼是在理解別人”。因為面對的專案多,專案的作者有各自的風格,理解起來需要花費不少的精力。

我從業這些年泛讀、精讀過的專案原始碼不算少了,陸陸續續的也寫了一些程式碼分析的文章,本文中就簡單總結一下我的方法。

先跑起來

開始閱讀一份專案原始碼的第一步,是先讓這個專案能夠通過你自己編譯通過並且順利跑起來。這一點尤其重要。

有的專案比較複雜,依賴的元件多,搭建起一個除錯環境並不容易,所以並不見得是所有專案都能順利的跑起來。如果能自己編譯跑起來,那麼後面講到的情景分析、加上除錯程式碼、除錯等等才有展開的基礎。

就我的經驗而言,一個專案程式碼,是否能順利的搭建除錯環境,效率大不一樣。

跑起來之後,又要儘量的精簡自己的環境,減少除錯過程中的干擾資訊。比如,Nginx 使用多程式的方式處理請求,為了除錯跟蹤 Nginx 的行為,我經常把 worker 數量設定為1個,這樣除錯的時候就知道待跟蹤的是哪個程式了。

再比如,很多專案預設是會帶上編譯優化選項或者去掉除錯資訊的,這樣在除錯的時候可能會有困擾,這時候我會修改 makefile 編譯成 -O0 -g,即編譯生成帶上除錯資訊且不進行優化的版本。

總而言之,跑起來之後的除錯效率能提升很多,而在跑起來的前提之下又要儘量精簡環境排除干擾的因素。

明確自己的目的

儘管閱讀專案原始碼很重要,但是並不見得所有專案都需要從頭到尾看的清清楚楚。在開始展開閱讀之前,需要明確自己的目的:是需要了解其中一個模組的實現,還是需要了解這個框架的大體結構,還是需要具體熟悉其中的一個演算法的實現,等等。

比如,很多人看 Nginx 的程式碼,而這個專案有很多模組,包括基礎的核心模組( epoll 、網路收發、記憶體池等)和擴充套件具體某個功能的模組,並不是所有這些模組都需要了解的非常清楚,我在閱讀 Nginx 程式碼的過程中,主要涉及了以下方面:

  • 瞭解 Nginx 核心的基礎流程以及資料結構。
  • 瞭解 Nginx 如何實現一個模組。有了這些對這個專案大體的瞭解,剩下的就是遇到具體的問題檢視具體的程式碼實現了。

總而言之,並不建議毫無目的的就開始展開一個專案的程式碼閱讀,無頭蒼蠅式的亂看只會消耗自己的時間和熱情。

區分主線和支線劇情

有了前面明確的閱讀目的,就能在閱讀過程中區分開主線和支線劇情了。比如:

想了解一個業務邏輯的實現流程,在某個函式中使用一個字典來儲存資料,在這裡,“字典這個資料結構是如何實現的”就屬於支線劇情,並不需要深究其實現。
在這一原則的指導下,對於支線劇情的程式碼,比如一個不需要了解其實現的類,讀者只需要瞭解其對外介面,瞭解這些介面的入口、出口引數以及作用,把這部分當成一個“黑盒”即可。

順便一提的是,早年間看到一種 C++ 的寫法,標頭檔案中只有一個類的對外介面宣告,將實現通過內部的 impl 類轉移到 C++ 檔案中,比如:

標頭檔案:

// test.h
class Test {
public:
  void fun();

private:
  class Impl;
  Impl *impl_;
};

C++檔案:

void Test::fun() {
  impl_->fun()
}

class Test::Impl {
public:
  void fun() {
    // 具體的實現
  }
}

這樣的寫法,讓標頭檔案清爽了很多:標頭檔案中沒有與實現相關的私有成員、私有函式,只有對外暴露的介面,使用者一目瞭然就能知道這個類對外提供的功能。

“主線”和“支線”劇情在整個程式碼閱讀的過程中經常切換,需要閱讀者有一定的經驗,清楚自己在這段程式碼的閱讀中哪部分屬於主線劇情。

縱向和橫向

程式碼閱讀過程中,分為兩個不同的方向:

  • 縱向:順著程式碼的順序閱讀,在需要具體瞭解一個流程、演算法的時候,經常需要縱向閱讀。
  • 橫向:區分不同的模組進行閱讀,在需要首先弄清楚整體框架時,經常需要橫向閱讀。
    兩個方向的閱讀,應該交替進行,這需要程式碼閱讀者有一定的經驗,能夠把握當前程式碼閱讀的方向。我的建議是:過程中還是以整體為首,在不理解整體的前提之前,不要太過深入某個細節。把某個函式、資料結構當成一個黑盒,知道它們的輸入、輸出就好,只要不影響整體的理解就暫且放下接著往前看。

情景分析

假如有了前面的基礎,已經能夠讓專案順利在自己的除錯環境跑起來了,也明確了自己想了解的功能,那麼就可以對專案程式碼進行情景分析了。

所謂的“情景分析”,就是自己構造一些情景,然後通過加斷點、除錯語句等分析在這些場景下的行為。

以我自己為例,在寫 《 Lua 設計與實現》 時,講解到Lua虛擬機器指令的解釋和執行過程中,需要針對每個指令做分析,此時用的就是情景分析的方法。我會模擬出來使用該指令的 Lua 指令碼程式碼,然後在程式裡斷點除錯這些場景下的行為。

我慣用的做法,是在某個重要的入口函式上面加上斷點,然後構造觸發場景的除錯程式碼,當程式碼在斷點處停下,通過檢視堆疊、變數值等等來觀察程式碼的行為。

例如,Lua 直譯器程式碼中中,生成 Opcode 最終都會呼叫函式 luaK_code,那麼我就在這個函式上面加上斷點,然後構造我想要除錯的場景,只要在斷點處中斷,我通過函式堆疊就能看到完整的呼叫流程:

(lldb) bt
* thread #1: tid = 0xb1dd2, 0x00000001000071b0 lua`luaK_code, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
* frame #0: 0x00000001000071b0 lua`luaK_code
frame #1: 0x000000010000753e lua`discharge2reg + 238
frame #2: 0x000000010000588f lua`exp2reg + 31
frame #3: 0x000000010000f15b lua`statement + 3131
frame #4: 0x000000010000e0b6 lua`luaY_parser + 182
frame #5: 0x0000000100009de9 lua`f_parser + 89
frame #6: 0x0000000100008ba5 lua`luaD_rawrunprotected + 85
frame #7: 0x0000000100009bf4 lua`luaD_pcall + 68
frame #8: 0x0000000100009d65 lua`luaD_protectedparser + 69
frame #9: 0x00000001000047e1 lua`lua_load + 65
frame #10: 0x0000000100018071 lua`luaL_loadfile + 433
frame #11: 0x0000000100000eb9 lua`pmain + 1545
frame #12: 0x00000001000090cd lua`luaD_precall + 589
frame #13: 0x00000001000098c1 lua`luaD_call + 81
frame #14: 0x0000000100008ba5 lua`luaD_rawrunprotected + 85
frame #15: 0x0000000100009bf4 lua`luaD_pcall + 68
frame #16: 0x00000001000046fb lua`lua_cpcall + 43
frame #17: 0x00000001000007af lua`main + 63
frame #18: 0x00007fff6468708d libdyld.dylib`start + 1

情景分析的好處在於:不會在一個專案中大海撈針似的查詢,而是能夠把問題縮小到一個範圍內展開來理解。

“情景分析”這一概念不是我想出來的名詞,比如有這麼幾本分析程式碼的書籍,如: 《Linux核心原始碼情景分析》,《Windows核心情景分析》。

利用好測試用例

好的專案都會自帶不少用例,這型別的例子有:etcd、google 出品的幾個開源專案。

如果測試用例寫的很仔細,那麼很值得好好去研究一下。原因在於:測試用例往往是針對某個單一的場景,獨自構造出一些資料來對程式的流程進行驗證。所以,其實跟前面的“情景分析”一樣,都是讓你從大的專案轉而關注具體某個場景的手段之一。

釐清核心資料結構之間的關係

雖然說“程式設計=演算法+資料結構”,然後我實際中的體會,資料結構更加重要。

因為結構定義了一個程式的架構,結構定下來了才有具體的實現。好比蓋房子,資料結構就是房子的框架結構,如果一間房子很大,而你並不清楚這個房子的結構,會在這裡面迷路。而對於演算法,如果屬於暫時不需要深究的細節部分,可以參考前面“區分主線和支線劇情”部分,先了解其入口、出口引數以及作用即可。

Linus 說:“爛程式設計師關心的是程式碼。好程式設計師關心的是資料結構和它們之間的關係。”

因此,在閱讀一份程式碼時,釐清核心的資料結構之間的關係尤其重要。這個時候,需要使用一些工具來畫一下這些結構之間的關係,我的原始碼分析類部落格中有很多這樣的例子,比如 《Leveldb程式碼閱讀筆記》、《Etcd儲存的實現》 等等。
需要說明的是,情景分析、釐清核心資料結構這兩步並沒有嚴格的順序關係,不見得是先做某事再做某事,而是互動進行的。

比如,你如果現在剛接手某個專案,需要簡單的瞭解一下專案,可以先閱讀程式碼瞭解都有哪些核心資料結構。理解了之後,如果不清楚某些情景下的流程,可以使用情景分析法。總而言之,交替進行直到解答你的疑問為止。

多問自己幾個問題

學習的過程中離不開互動。

如果閱讀程式碼只是輸入 (Input),那麼還需要有輸出 (Output)。只有簡單的輸入好比喂東西給你吃,而只有更好的消化才能變為自己的營養,而輸出就是更好消化知識的重要手段。

其實這個思想很常見,比如學生上課 (Input) 了需要做練習作業 (Output),比如學了演算法 (Input) 需要自己編碼練習 (Output),等等。簡而言之,輸出是學習過程中的一種及時反饋,質量越高學習效率越高。

輸出的手段有很多,在閱讀程式碼時,比較建議的是自己能夠多問自己一些問題,比如:

  • 為什麼選擇這個資料結構來描述這個問題?類似的場景下,其他專案是怎麼設計的?都有哪些資料結構做這樣的事 ?
  • 如果由我來設計這樣的專案,我會怎麼做?
    等等等等。越是主動積極的思考,就越有更好的輸出,輸出質量與學習質量成正比關係。

寫自己的程式碼閱讀筆記

我從開始寫部落格,就是寫不少各種專案的程式碼解讀類文章,網名 “codedump” 也源於想把 code 內部的實現原理 dump 出來”之意。

前面提到學習質量與輸出質量成正比關係,這是我自己的深刻體會。也因為如此,所以才要堅持閱讀原始碼之後寫自己的分析類筆記。

寫這類筆記,有以下幾個需要注意的地方。

雖然是筆記,但是要想象著在向一個不太熟悉這個專案的人講解原理,或者想象一下是幾個月甚至幾年後的自己回頭來看這個文章。在這種情況下,會盡量的把語言組織好,循循善誘的解釋。

儘量避免大段的貼程式碼。我認為在這類文章中,大段貼上程式碼有點自欺欺人:就是看上去自己懂了,其實並不見得。如果真要解釋某段程式碼,可以使用虛擬碼或者縮減程式碼的方式。記住:不要自欺欺人,要真的懂了。如果真的想在程式碼上加上自己的註釋,我有一個建議是 fork 出來一份該專案某個版本的程式碼,提交到自己的github上,上面隨時可以加上自己的註釋並且儲存提交。比如我自己註釋的 etcd 3.1.10 程式碼: etcd-3.1.10-codedump,類似的我閱讀的其他專案都會在 github 上 fork 出一個帶上 codedump 字尾的專案。
多畫圖,一圖勝千言,使用圖形展示程式碼流程、資料結構之間的關係。我最近才發現畫圖能力也是很重要的能力,自己在從頭學習如何使用影像來表達自己的想法。

寫作是很重要的基礎能力,我一個朋友最近教育我,大體的意思是說:如果你在某方面的能力很強,如果再加上寫作好、英語好,那麼將極大放大你在這方面的能力。而類似寫作、英語這樣的底層基礎能力,不是一撮而就的,需要長時間保持練習才可以。而寫部落格,對於技術人員而言,就是一種很好的鍛鍊寫作的手段。

PS:如果很多事情,你當時做的時候能想到今後面對這個輸出的人是你自己,比如自己寫的程式碼後面要自己維護、自己寫的文章後面給自己看,等等的,世界會美好很多。比如寫技術部落格這些事情,因為我在寫的時候考慮到以後看這份文件的人可能就是我本人,所以在寫的時候會盡量的清晰、易懂,力圖我自己一段時間後再看到自己的這份文件時,能夠馬上回憶起當時的細節,也正是因為這樣,我很少在部落格裡貼大段的程式碼,儘可能的補充圖例。

總結

以上是我簡單總結的一些閱讀原始碼時候的手段和注意方法,大體而言有那麼幾點吧:

  • 只有更好的輸出才能更好的消化知識,所謂的搭建除錯環境、情景分析、多問自己問題、寫程式碼閱讀筆記等都是圍繞輸出來展開的。總而言之,不能像一條死魚一樣指望著光靠看程式碼就能完全理解它的原理,需要想辦法跟它互動起來。
  • 寫作是人的基礎硬實力之一,不僅鍛鍊自己表達能力,還能幫助整理自己的思路。對程式設計師而言鍛鍊寫作能力的手段之一就是寫部落格,越早開始鍛鍊越好。

最後,如同任何可以習得的技能一般,閱讀程式碼這種能力也需要長時間、大量的反覆練習,下一次就從自己感興趣的專案開始鍛鍊自己的這種技能吧。

作者:codedump
來源:https://www.codedump.info/post/20200605-how-to-read-code-v2020/

更多精彩好文請掃碼關注我們微信公眾號,新鮮資訊不迷路~

相關文章