跳至主要内容

為你自己讀 CPython 原始碼

為你自己學 Python

這次的 iThome 鐵人賽,我給自己選了一個有點硬的主題,就是閱讀 CPython 的原始碼。

今年也剛好在 PyCon Taiwan 有一場工作坊,主題是介紹如何閱讀 CPython 的原始碼,所以原本只是想藉著鐵人賽的活動,把工作坊的內容文字化做為補充資料,沒想到越寫越多,對我這個不懂 C 語言的人來說更是吃力。雖然現在有很多 AI 工具的輔助,但幾乎每篇文章得要一邊挖原始碼,一邊驗證我解讀的程式碼是不是正確的,每篇文章至少都得花三個小時以上的時間。

但做這件事也是有好處的,除了學到了入門的 C 語言,現在對 Python 這個程式語言也有更進一步的認識,就算要跟別人戰程式的話(並沒有)也比較有底氣一點點。而且,我的工作有一半以上都是在講課,萬一被同學問到奇怪的 Python 問題,現在應該都透過翻閱 CPython 的原始碼的實作講的出真正的原因。

另外,我自己有個有點無聊的小小堅持,就是希望這個系列的每篇文章(包括現在大家在看的這篇也是),都能給它一個合適的 Banner 圖片,從一開始像是新兵戰士面對的像火龍一樣強大的對手,現在能跟它和平相處,甚至藉由它可以再回頭看看其它程式語言的實作,能更了解不同程式語言之間的差異,對我來說真的是件很有趣的事 :)

漏網之魚?

在時間有限而且能力也有限的情況下,目前只能暫時先寫這 30 篇的文章,但我對以下這些主題也很有興趣:

  • 虛擬機器(VM)的實作
  • 記憶體管理與資源回收
  • GIL,特別是在 3.13 之後 GIL 解禁之後有什麼不一樣

希望在不久的將來,在時間比較寬裕以及能力提昇之後,可以再回來挑戰這些主題 :)

主題

第 1 章:來讀 CPython 原始碼

CPython 是最常見的 Python 實作品,先把專案原始碼下載一份到本機,順便寫一個簡單的 C 語言 Hello World 程式,為後續的 CPython 原始碼學習做準備。

第 2 章:CPython 專案簡介

介紹如何編譯並修改 CPython 原始碼以及專案目錄結構,順便範例展示了如何修改 CPython 原始碼,例如在進入 REPL 時顯示「Hello CPython」,離開時顯示「Bye」讓 Python REPL 禮貌地打招呼和道別。

第 3 章:全部都是物件(上)

在 CPython 裡,所有物件都由 PyObject 結構來表示,內含兩個主要成員:ob_refcnt 管理物件的引用計數,當計數降為 0 時,物件會被回收;ob_type 則指向 PyTypeObject,決定物件的型別及行為...

第 4 章:物件生成全紀錄

介紹從 Python 執行簡單的類別定義與實體化的過程,首先,程式碼經過 Tokenization 和 AST 解析,接著編譯為 Bytecode,再由 Python 虛擬機器執行...

第 5 章:全部都是物件(下)

在 CPython 中,PyTypeObject 是負責定義所有 Python 型別的關鍵結構,裡面包含了物件的名稱、大小、屬性和行為,並使用串列做為範例,展示了這個結構是如何運作。

第 6 章:我的 Python 會後空翻!

介紹如何在 CPython 裡面自製一個叫做 PyKitty_Type 的型別,並讓這個型別可以做出打招呼和後空翻的動作。

第 7 章:匯入模組的時候...

Python 的模組匯入機制其實比我們平常看到的複雜許多。當我們使用 import 指令時,Python 會先從 sys.modules 查找已經匯入過的模組,如果沒找到,則透過內建的 importlib 模組來加載模組。import 和 from ... import ... 這兩種寫法在底層執行時略有不同,前者會直接匯入整個模組,而後者則會提取模組中的特定屬性。

第 8 章:整數的前世今生

當在 Python 裡執行 n = 9527 這樣的程式碼時,背後發生了不少事。首先,數字 9527 會被編譯成 Bytecode,並存放在常數表中,等到執行時再從這裡取出。Python 的設計使它能處理任意大小的整數,因為可以用多個「digit」來分段儲存大數字,打破了其他程式語言的數字上限問題。此外,為了提升效能,Python 預先建立了從 -5 到 256 的小整數,這些整數會重複使用,而不是每次都新建物件。這樣的設計讓 Python 既靈活又高效。

第 9 章:浮點數之小數點漂流記

浮點數,顧名思義,它的「小數點」位置是可以浮動的,這樣的設計讓電腦能有效處理非常大的或非常小的數字。不同於日常生活中的小數,電腦內的浮點數是以類似科學記號的方式儲存,使用「有效數字」和「指數」來表示。Python 的浮點數其實就是 C 語言的 double,這是依照 IEEE 754 標準來處理的,因此會有精度不足的問題,這就是為什麼計算浮點數時會出現些許誤差。

第 10 章:字串的秘密生活(上)

當你在 Python 裡寫下 message = "Hello, World!",背後其實發生了很多事。這段程式碼會將字串「Hello, World!」編譯成 Bytecode 並建立一個字串物件。如果字串是空的,CPython 會從直譯器內部拿出同一個空字串;若字串包含 ASCII 字元,則會使用更節省記憶體的 ASCII 結構。不過,一旦字串含有像 Emoji 這樣的 Unicode 字元,Python 會自動轉換成較大的 Unicode 結構來處理。最重要的是,Python 的字串是不可變的,這表示每次修改字串,實際上都是產生一個新的字串物件。

第 11 章:字串的秘密生活(下)

在 Python 中,字串的操作其實不像表面看起來那麼簡單。比如字串串接時,雖然像 a = a + "😊" 這樣的操作看似直接,但實際上會產生一個新的字串物件,並透過內部的函數來進行字元的複製與串接。過程中,如果兩個字串的編碼相同,會直接進行記憶體層級的快速拷貝。如果編碼不同,則會轉換編碼並逐字複製,速度會慢一些。

另外,Python 也會利用字串內部化技術來節省記憶體,將常用的字串存入字串池,讓相同內容的字串共用同一塊記憶體,進一步提升效能。

第 12 章:從準備到起飛!

當執行 python hello.py 時,Python 直譯器先讀取程式碼,然後經過一系列的處理步驟,最終執行並顯示結果。在這個過程中,程式碼會先被讀取並轉換成一種可以被理解的結構,接著編譯成可執行的形式,最後由 Python 的執行器執行並輸出「Hello Kitty」...

第 13 章:參觀 Bytecode 工廠

當執行 Python 程式時,程式碼會被編譯成 Bytecode,並且生成 .pyc 檔案。這個檔案主要是為了加快執行速度,特別是被重複匯入的模組。但執行主程式時,Python 並不會自動生成 .pyc,因為多數情況下主程式只會執行一次,生成 .pyc 的必要性不大。 .pyc 檔案裡面除了存有魔術數字,還包含編譯後的 Bytecode,這些 Bytecode 是由一連串的 opcode 組成,Python 的虛擬機器會根據這些 opcode 來執行程式。

第 14 章:串列的排隊人生

使用 Python 的串列(List)的時候會發現它可以存放不同型態的資料,還可以動態增加或減少元素,而且還不用事先定義大小。這麼方便的設計背後,CPython 是如何實現的?串列的內部結構其實是透過一個指向元素的指針來運作,而不是直接存放資料,因此它能裝下各種型態的物件。而當需要更多記憶體時,CPython 會採取「過度分配」的策略,也就是一次多要一些空間,以減少頻繁重新分配的次數,提升效能。

第 15 章:字典的整理收納課(上)

字典是 Python 中常用的資料結構,Key & Vaule 的組合存取資料,在 CPython 裡,字典的內部結構由 PyDictObject 定義,Key 跟 Value 分別存放在不同的物件中,這樣設計是為了效能與記憶體使用的平衡。

新增元素時,Python 會先透過計算鍵的雜湊值來決定其在字典中的位置,如果發生雜湊碰撞,系統會利用特殊的計算公式來找到下個可用的空位,並將鍵值對存放於對應的記憶體空間內。查找時則會根據計算出的索引快速定位到對應的鍵值。雖然這個流程聽起來有些複雜,但實際運行速度非常快,讓大部分情況下字典的查找效率接近 O(1)。

第 16 章:字典的整理收納課(下)

在 Python 中,字典的記憶體空間會隨著加入的元素數量自動調整,這是為了在效能和記憶體使用之間取得平衡。當字典裡的空間不足時,系統會根據「負載因子」來決定何時需要擴展容量,通常當使用率超過 2/3 時會自動擴充空間。每次擴展的量約為原本的 2 倍,以減少碰撞機率並確保效能不受影響。然而,即使刪除元素後,已擴展的空間不會自動回收,除非特意清空字典或重新建立...

第 17 章:不動如山的 Tuple

在 Python 裡 Tuple 是一種不可變的資料結構,意思是它一旦建立後,裡面的元素就不能更改、刪除或新增。建立 Tuple 時,Python 會透過特殊的函數來處理,無論是空的 Tuple 還是有資料的 Tuple,背後都有不同的流程處理。Python 還會使用「空閒列表」來有效管理記憶體回收,像是當元素數量小於 20 時,Python 不會馬上釋放記憶體,而是暫時保留以供未來使用。此外,Tuple 的不可變性意味著你無法直接修改它的內容,這個特性跟字串相似。Python 甚至對某些特定數量元素的 Tuple 做了些有趣的效能最佳化來提升運行效率。

第 18 章:虛擬機器五部曲(一)

在 Python 裡,函數也是物件,當我們定義一個函數時,Python 會建立一個 PyFunctionObject,裡面包含了函數的名字、參數、文件字串等資訊。函數背後最關鍵的部分是一個叫做 Code Object 的東西,這個物件儲存了函數的程式碼。在編譯階段,Python 將我們寫的程式轉換 Bytecode,然後將 Bytecode 包裝成 Code Object,最後由虛擬機器執行。

第 19 章:虛擬機器五部曲(二)

函數物件是透過 MAKE_FUNCTION 指令建立的,裡面包含了 Code Object、函數名稱、預設值等屬性。oparg 參數則用來決定函數物件具備哪些屬性...

第 20 章:虛擬機器五部曲(三)

當呼叫一個 Python 函數,背後會建立一個叫 Frame 的物件。這個 Frame 會儲存像是區域變數、全域變數等資料,並形成一個堆疊結構,每次呼叫函數就會加上一個新的 Frame。當函數執行完畢後,這個 Frame 就會被清理掉。在 CPython 裡,Frame 包含許多重要的資訊,比如指向函數的程式碼、變數、甚至前一個 Frame,確保執行流程順暢,然後在適當時機被銷毀,讓記憶體資源得以回收。

第 21 章:虛擬機器五部曲(四)

當 Python 執行程式碼時,它會根據 LEGB(Local、Enclosing、Global、Built-in)的規則來查找變數,大家可能不知道的是,其實 Python 的編譯器會在程式執行前,就已經決定該如何查找變數了...

第 22 章:虛擬機器五部曲(五)

Python 中的閉包(Closure)是怎麼運作的?閉包允許內層函數存取外層函數的變數,而這些變數會被包裝成所謂的 Cell Object。當我們定義一個包含閉包的函數時,Python 會自動建立這些 Cell,並將外層的變數放入其中,讓內層函數可以在之後使用。

第 23 章:類別與它們的產地

物件導向設計中,類別是用來建立實體(物件)的,但在 Python 世界裡,類別本身也是物件。那麼類別本身又是誰創造的?其實所有的類別都是透過它們的 metaclass 來建立。Python 裡的 class 語法背後其實是使用內建函數 build_class() 來完成這個過程,而 type 類別本身的 metaclass 也還是 type,這樣就形成了類別的遞迴關係。

第 24 章:繼承與家族紛爭(上)

在大多數程式語言裡,繼承可能沒那麼複雜,基本上就是把共用的功能寫在上層類別,然後讓下層類別繼承它就好了。但 Python 的繼承多了一個「多重繼承」,也就是一個類別可以同時繼承多個上層類別,讓整個結構複雜不少。

當 Python 要找到一個物件的方法時,會依據「MRO」(方法解析順序)來決定要先去哪一個上層類別找,這個順序是用一套規則計算出來的。當然,平常只用單一繼承時,你不會覺得有什麼不同,但多重繼承的情況下,MRO 就會幫忙決定方法的查找順序。

第 25 章:繼承與家族紛爭(中)

Python 採用了 C3 線性演算法來解決方法繼承順序的問題。這個演算法能找出一個符合規則的單調次序,確保在多重繼承下方法的呼叫順序是合理的。

透過 MRO 可以看到 Python 是依據 C3 線性演算法決定方法查找的順序,先看上層,再看平行層,最後會到最上層類別 object。不過當遇到無法確定順序的繼承關係時,Python 會拋出錯誤。

第 26 章:繼承與家族紛爭(下)

MRO 是透過 C3 線性演算法來解決多重繼承的順序問題,而 super() 則是根據這個順序從 MRO 中的下一個類別開始查找方法。特別的是,Python 的 super() 並不是單純呼叫上層類別的方法,而是透過一個代理物件來動態解析方法,確保不會跳過任何一個繼承的類別,從而避免了多重繼承時的「鑽石問題」。

第 27 章:產生一個產生器

產生器在 Python 裡的運作很好玩。簡單來說,透過 yield 關鍵字,可以讓函數暫停並回傳值,這對處理大量資料或無限集合特別實用。產生器的內部狀態會記錄在 PyGenObject 裡,包含名稱、例外狀態、以及執行狀態。雖然產生器能省下記憶體,但它的實作有點複雜,特別是在管理狀態和例外處理的部分。

第 28 章:轉呀轉呀七彩迭代器

迭代器在 Python 裡非常常見,它讓我們能很簡單的遍歷不同類型的「容器」,像是字串、串列、Tuple 或是字典等結構。Python 中的迭代器只要有遵循「迭代器協議」就能被稱為迭代器。Python 的 iter() 函數背後有幾個有趣的實作細節,例如它支援一種「哨兵」機制,可以用來指定當某個條件達成時停止迭代。不同的資料型態,如串列、字典和範圍,都有各自的迭代器實作方式。

第 29 章:無所不在的描述器

描述器(Descriptor)其實是 Python 中很常見的東西,只是你可能不知道自己已經在用了。它可以控制屬性的存取,讓我們在讀取或設定屬性時,背後做一些額外的事。描述器有兩種:資料描述器和非資料描述器,區別在於是否實作了特定的方法...

第 30 章:例外處理的幕後真相

Python 處理例外的方式很直觀,使用 try...except 就能應付各種可能的錯誤。當然,這背後在 CPython 裡可是有一整套機制運作。當發生例外時,Python 會把錯誤資訊堆疊起來,方便之後的處理。除此之外,Bytecode 也會包含一些用來追蹤例外的表格(ExceptionTable),確保我們能跳到正確的處理位置。最後,finally 區塊更是保證不論發生什麼事都會被執行,讓程式有條理地結束...

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!