鐵人賽 - 為你自己讀 CPython 原始碼
這次的 iThome 鐵人賽,我給自己選了一個有點硬的主題,就是閱讀 CPython 的原始碼。
今年也剛好在 PyCon Taiwan 有一場工作坊,主題是介紹如何閱讀 CPython 的原始碼,所以原本只是想藉著鐵人賽的活動,把工作坊的內容文字化做為補充資料,沒想到越寫越多,對我這個不懂 C 語言的人來說更是吃力。雖然現在有很多 AI 工具的輔助,但幾乎每篇文章得要一邊挖原始碼,一邊驗證我解讀的程式碼是不是正確的,每篇文章至少都得花三個小時以上的時間。
但做這件事也是有好處的,除了學到了一些入門的 C 語言,就是現在對這個程式語言有更進一步的認識了,就算要跟別人戰程式的話(並沒有)也比較有底氣一點點。而且,我的工作有一半以上都是在講課,萬一被同學問到奇怪的 Python 問題,現在應該都透過翻閱 CPython 的原始碼的實作講的出真正的原因。
另外,我自己有個有點無聊的小小堅持,就是希望這個系列的每篇文章(包括現在大家在看的這篇也是),都能給它一個合適的 Banner 圖片,從一開始像是新兵戰士面對的像火龍一樣強大的對手,現在能跟它和平相處,甚至藉由它可以再回頭看看其它程式語言的實作,能更了解不同程式語言之間的差異,對我來說真的是件很有趣的事 :)
漏網之魚?
在時間有限而且能力也有限的情況下,目前只能暫時先寫這 30 篇的文章,但我對以下這些主題也很有興趣:
- 虛擬機器(VM)的實作
- 記憶體管理與資源回收
- GIL,特別是在 3.13 之後 GIL 解禁之後有什麼不一樣
希望在不久的將來,在時間比較寬裕以及能力提昇之後,可以再回來挑戰這些主題 :)
主題
CPython 是最常見的 Python 實作品,先把專案原始碼下載一份到本機,順便寫一個簡單的 C 語言 Hello World 程式,為後續的 CPython 原始碼學習做準備。
介紹如何編譯並修改 CPython 原始碼以及專案目錄結構,順便範例展示了如何修改 CPython 原始碼,例如在進入 REPL 時顯示「Hello CPython」,離開時顯示「Bye」讓 Python REPL 禮貌地打招呼和道別。
在 CPython 裡,所有物件都由 PyObject 結構來表示,內含兩個主要成員:ob_refcnt 管理物件的引用計數,當計數降為 0 時,物件會被回收;ob_type 則指向 PyTypeObject,決定物件的型別及行為...
介紹從 Python 執行簡單的類別定義與實體化的過程,首先,程式碼經過 Tokenization 和 AST 解析,接著編譯為 Bytecode,再由 Python 虛擬機器執行...
在 CPython 中,PyTypeObject 是負責定義所有 Python 型別的關鍵結構,裡面包含了物件的名稱、大小、屬性和行為,並使用串列做為範例,展示了這個結構是如何運作。
介紹如何在 CPython 裡面自製一個叫做 PyKitty_Type 的型別,並讓這個型別可以做出打招呼和後空翻的動作。
Python 的模組匯入機制其實比我們平常看到的複雜許多。當我們使用 import 指令時,Python 會先從 sys.modules 查找已經匯入過的模組,如果沒找到,則透過內建的 importlib 模組來加載模組。import 和 from ... import ... 這兩種寫法在底層執行時略有不同,前者會直接匯入整個模組,而後者則會提取模組中的特定屬性。
當在 Python 裡執行 n = 9527 這樣的程式碼時,背後發生了不少事。首先,數字 9527 會被編譯成 Bytecode,並存放在常數表中,等到執行時再從這裡取出。Python 的設計使它能處理任意大小的整數,因為可以用多個「digit」來分段儲存大數字,打破了其他程式語言的數字上限問題。此外,為了提升效能,Python 預先建立了從 -5 到 256 的小整數,這些整數會重複使用,而不是每次都新建物件。這樣的設計讓 Python 既靈活又高效。
浮點數,顧名思義,它的「小數點」位置是可以浮動的,這樣的設計讓電腦能有效處理非常大的或非常小的數字。不同於日常生活中的小數,電腦內的浮點數是以類似科學記號的方式儲存,使用「有效數字」和「指數」來表示。Python 的浮點數其實就是 C 語言的 double,這是依照 IEEE 754 標準來處理的,因此會有精度不足的問題,這就是為什麼計算浮點數時會出現些許誤差。
當你在 Python 裡寫下 message = "Hello, World!",背後其實發生了很多事。這段程式碼會將字串「Hello, World!」編譯成 Bytecode 並建立一個字串物件。如果字串是空的,CPython 會從直譯器內部拿出同一個空字串;若字串包含 ASCII 字元,則會使用更節省記憶體的 ASCII 結構。不過,一旦字串含有像 Emoji 這樣的 Unicode 字元,Python 會自動轉換成較大的 Unicode 結構來處理。最重要的是,Python 的字串是不可變的,這表示每次修改字串,實際上都是產生一個新的字串物件。
在 Python 中,字串的操作其實不像表面看起來那麼簡單。比如字串串接時,雖然像 a = a + "😊" 這樣的操作看似直接,但實際上會產生一個新的字串物件,並透過內部的函數來進行字元的複製與串接。過程中,如果兩個字串的編碼相同,會直接進行記憶體層級的快速拷貝。如果編碼不同,則會轉換編碼並逐字複製,速度會慢一些。
另外,Python 也會利用字串內部化技術來節省記憶體,將常用的字串存入字串池,讓相同內容的字串共用同一塊記憶體,進一步提升效能。
當執行 python hello.py 時,Python 直譯器先讀取程式碼,然後經過一系列的處理步驟,最終執行並顯示結果。在這個過程中,程式碼會先被讀取並轉換成一種可以被理解的結構,接著編譯成可執行的形式,最後由 Python 的執行器執行並輸出「Hello Kitty」...
當執行 Python 程式時,程式碼會被編譯成 Bytecode,並且生成 .pyc 檔案。這個檔案主要是為了加快執行速度,特別是被重複匯入的模組。但執行主程式時,Python 並不會自動生成 .pyc,因為多數情況下主程式只會執行一次,生成 .pyc 的必要性不大。 .pyc 檔案裡面除了存有魔術數字,還包含編譯後的 Bytecode,這些 Bytecode 是由一連串的 opcode 組成,Python 的虛擬機器會根據這些 opcode 來執行程式。
使用 Python 的串列(List)的時候會發現它可以存放不同型態的資料,還可以動態增加或減少元素,而且還不用事先定義大小。這麼方便的設計背後,CPython 是如何實現的?串列的內部結構其實是透過一個指向元素的指針來運作,而不是直接存放資料,因此它能裝下各種型態的物件。而當需要更多記憶體時,CPython 會採取「過度分配」的策略,也就是一次多要一些空間,以減少頻繁重新分配的次數,提升效能。
字典是 Python 中常用的資料結構,Key & Vaule 的組合存取資料,在 CPython 裡,字典的內部結構由 PyDictObject 定義,Key 跟 Value 分別存放在不同的物件中,這樣設計是為了效能與記憶體使用的平衡。
新增元素時,Python 會先透過計算鍵的雜湊值來決定其在字典中的位置,如果發生雜湊碰撞,系統會利用特殊的計算公式來找到下個可用的空位,並將鍵值對存放於對應的記憶體空間內。查找時則會根據計算出的索引快速定位到對應的鍵值。雖然這個流程聽起來有些複雜,但實際運行速度非常快,讓大部分情況下字典的查找效率接近 O(1)。
在 Python 中,字典的記憶體空間會隨著加入的元素數量自動調整,這是為了在效能和記憶體使用之間取得平衡。當字典裡的空間不足時,系統會根據「負載因子」來決定何時需要擴展容量,通常當使用率超過 2/3 時會自動擴充空間。每次擴展的量約為原本的 2 倍,以減少碰撞機率並確保效能不受影響。然而,即使刪除元素後,已擴展的空間不會自動回收,除非特意清空字典或重新建立...
在 Python 裡 Tuple 是一種不可變的資料結構,意思是它一旦建立後,裡面的元素就不能更改、刪除或新增。建立 Tuple 時,Python 會透過特殊的函數來處理,無論是空的 Tuple 還是有資料的 Tuple,背後都有不同的流程處理。Python 還會使用「空閒列表」來有效管理記憶體回收,像是當元素數量小於 20 時,Python 不會馬上釋放記憶體,而是暫時保留以供未來使用。此外,Tuple 的不可變性意味著你無法直接修改它的內容,這個特性跟字串相似。Python 甚至對某些特定數量元素的 Tuple 做了些有趣的效能最佳化來提升運行效率。
在 Python 裡,函數也是物件,當我們定義一個函數時,Python 會建立一個 PyFunctionObject,裡面包含了函數的名字、參數、文件字串等資訊。函數背後最關鍵的部分是一個叫做 Code Object 的東西,這個物件儲存了函數的程式碼。在編譯階段,Python 將我們寫的程式轉換 Bytecode,然後將 Bytecode 包裝成 Code Object,最後由虛擬機器執行。
函數物件是透過 MAKE_FUNCTION 指令建立的,裡面包含了 Code Object、函數名稱、預設值等屬性。oparg 參數則用來決定函數物件具備哪些屬性...
當呼叫一個 Python 函數,背後會建立一個叫 Frame 的物件。這個 Frame 會儲存像是區域變數、全域變數等資料,並形成一個堆疊結構,每次呼叫函數就會加上一個新的 Frame。當函數執行完畢後,這個 Frame 就會被清理掉。在 CPython 裡,Frame 包含許多重要的資訊,比如指向函數的程式碼、變數、甚至前一個 Frame,確保執行流程順暢,然後在適當時機被銷毀,讓記憶體資源得以回收。
當 Python 執行程式碼時,它會根據 LEGB(Local、Enclosing、Global、Built-in)的規則來查找變數,大家可能不知道的是,其實 Python 的編譯器會在程式執行前,就已經決定該如何查找變數了...
Python 中的閉包(Closure)是怎麼運作的?閉包允許內層函數存取外層函數的變數,而這些變數會被包裝成所謂的 Cell Object。當我們定義一個包含閉包的函數時,Python 會自動建立這些 Cell,並將外層的變數放入其中,讓內層函數可以在之後使用。
物件導向設計中,類別是用來建立實體(物件)的,但在 Python 世界裡,類別本身也是物件。那麼類別本身又是誰創造的?其實所有的類別都是透過它們的 metaclass 來建立。Python 裡的 class 語法背後其實是使用內建函數 build_class() 來完成這個過程,而 type 類別本身的 metaclass 也還是 type,這樣就形成了類別的遞迴關係。
在大多數程式語言裡,繼承可能沒那麼複雜,基本上就是把共用的功能寫在上層類別,然後讓下層類別繼承它就好了。但 Python 的繼承多了一個「多重繼承」,也就是一個類別可以同時繼承多個上層類別,讓整個結構複雜不少。
當 Python 要找到一個物件的方法時,會依據「MRO」(方法解析順序)來決定要先去哪一個上層類別找,這個順序是用一套規則計算出來的。當然,平常只用單一繼承時,你不會覺得有什麼不同,但多重繼承的情況下,MRO 就會幫忙決定方法的查找順序。
Python 採用了 C3 線性演算法來解決方法繼承順序的問題。這個演算法能找出一個符合規則的單調次序,確保在多重繼承下方法的呼叫順序是合理的。
透過 MRO 可以看到 Python 是依據 C3 線性演算法決定方法查找的順序,先看上層,再看平行層,最後會到最上層類別 object。不過當遇到無法確定順序的繼承關係時,Python 會拋出錯誤。
MRO 是透過 C3 線性演算法來解決多重繼承的順序問題,而 super() 則是根據這個順序從 MRO 中的下一個類別開始查找方法。特別的是,Python 的 super() 並不是單純呼叫上層類別的方法,而是透過一個代理物件來動態解析方法,確保不會跳過任何一個繼承的類別,從而避免了多重繼承時的「鑽石問題」。
產生器在 Python 裡的運作很好玩。簡單來說,透過 yield 關鍵字,可以讓函數暫停並回傳值,這對處理大量資料或無限集合特別實用。產生器的內部狀態會記錄在 PyGenObject 裡,包含名稱、例外狀態、以及執行狀態。雖然產生器能省下記憶體,但它的實作有點複雜,特別是在管理狀態和例外處理的部分。
迭代器在 Python 裡非常常見,它讓我們能很簡單的遍歷不同類型的「容器」,像是字串、串列、Tuple 或是字典等結構。Python 中的迭代器只要有遵循「迭代器協議」就能被稱為迭代器。Python 的 iter() 函數背後有幾個有趣的實作細節,例如它支援一種「哨兵」機制,可以用來指定當某個條件達成時停止迭代。不同的資料型態,如串列、字典和範圍,都有各自的迭代器實作方式。
描述器(Descriptor)其實是 Python 中很常見的東西,只是你可能不知道自己已經在用了。它可以控制屬性的存取,讓我們在讀取或設定屬性時,背後做一些額外的事。描述器有兩種:資料描述器和非資料描述器,區別在於是否實作了特定的方法...
Python 處理例外的方式很直觀,使用 try...except 就能應付各種可能的錯誤。當然,這背後在 CPython 裡可是有一整套機制運作。當發生例外時,Python 會把錯誤資訊堆疊起來,方便之後的處理。除此之外,Bytecode 也會包含一些用來追蹤例外的表格(ExceptionTable),確保我們能跳到正確的處理位置。最後,finally 區塊更是保證不論發生什麼事都會被執行,讓程式有條理地結束...
工商服務
實體課程:ASTRO Camp 全端工程師實戰培訓營
線上課程:五倍學院線上課程