CPython 專案簡介
這個章節我們來看看整個 CPython 專案長什麼樣子,以及試著編譯專案,我們會試著動手修改一點點 CPython 的原始碼,來感受一下當 Python Core Dev 是什麼感覺(開玩笑的)!
專案結構
首先,先把專案從 GitHub 上拉一份下來:
$ git clone [email protected]:python/cpython.git -b v3.12.6 --depth=1
在本書中我使用的版本是 3.12.6
版本,所以如果版本不同,可能原始碼的內容會有些許不同。扣掉一些比較不重要的檔案,剛從 GitHub 拉下來的 CPython 專案的目錄大概是這樣:
CPython
├── Doc
├── Grammar
├── Include
├── Lib
├── Mac
├── Misc
├── Modules
├── Objects
├── PC
├── PCbuild
├── Parser
├── Programs
├── Python
└── Tools
先簡單介紹一下每個目錄裡放的檔案:
Doc
:這很好猜,就如同它的名字一樣,就是放文件的地方,這些文件是用 reStructuredText(.rst
) 格式編寫的,如果晚上睡不著可以拿出來啃,助眠效果很好!Grammar
:定義 Python 語法規則解析用的文件。Include
:專案裡 C 語言用到的 Header 檔案,後續如果想要幫 CPython 寫 extension 的話,應該會用到這裡的檔案。Lib
:標準函式庫,在這個目錄裡的程式是使用 Python 寫的,如果略懂 Python 的話,這個目錄裡的內容讀起來會比較親切。Modules
:同Lib
目錄,不過這裡的內容是用 C 語言寫的。Mac
:這是給 Mac 作業系統用的東西。Misc
:雜七雜八的東西,依我自己個人的習慣,我開這種目錄就是用來放那些不知道怎麼分類的檔案。Objects
:所有 Python 內建物件的原始碼在這裡,例如str
或是list
都在這裡,在本書中會經常看到這個目錄裡面的檔案。PCbuild
:這是給 Windows 作業系統用的東西,特別是 Visual Studio,裡面有可以直接點兩下就能開啟的專案檔。PC
:同上,但是是給比較早期的 Windows 版本用的。大多數已經過時,但有些文件仍然是為了相容性而保留下來。Parser
:把.py
檔轉換成 Python 看的懂的 Token 的程式碼在這裡,難度有一點高。Programs
:存放與 CPython 執行檔相關的原始碼。Python
:CPython 直譯器(Interpreter)的原始碼在這裡,難度比較高,但對直譯器有興趣的可以看看。Tools
:一些開發和維護 Python 的輔助工具。
以本書來說,比較常看到的應該是 Include
、Lib
、Modules
、Objects
和 Python
這幾個目錄,這些都是 CPython 直譯器的核心原始碼,如果想要了解 Python 的運作原理,可能就得多一些時間泡在這些目錄裡。
編譯專案
專案下載後,先 cd
切換到剛才下載的專案並執行 ./configure
指令:
$ cd cpython
$ ./configure
./configure
指令會在畫面上不斷的跳出一堆大家可能看不懂的資訊,這個過程大概是在檢查系統環境,看看有沒有缺什麼套件或函式庫,沒問題的話會產生一個 Makefile
檔案,這個 Makefile
會告訴待會要執行的 make
指令待會應該要怎麼編譯整個專案。
如果在 ./configure
後面加上 --prefix
參數,像這樣:
$ ./configure --prefix=/tmp/my-python
有特別加上 --prefix
的話,之後如果執行 make install
指令進行安裝的時候,就會把編譯好的 Python 以及相關的程式安裝到 /tmp/my-python
目錄裡。不過我並沒打算執行 make install
進行安裝,所以可以先不加 --prefix
參數,等到後續有機會執行其它外部程式(例如 pip
)的時候再加即可。
接著執行 make
指令,這個指令會根據剛才產生的 Makefile
檔案來編譯整個專案:
$ make
編譯的過程可能會花一點點時間,如果編譯過程沒出錯的話,應該會在根目錄產生一個 python.exe
執行檔,即使在 macOS 上也是叫這個名字。我知道在 macOS 看到 .exe
可能有點不太習慣,不過這是刻意的設計,因為在 CPython 專案裡剛好就有個 Python/
目錄,所以刻意選擇 python.exe
而不是 python
就是為了避免跟這個目錄發生衝突。
如果想要「安裝」剛才我們自己編譯出來的 Python 版本,可以執行:
$ make install
剛才在 ./configure
指令後面如果有加上 --prefix
參數,這個指令會把 Python 安裝到指定的目錄裡。不過就算不安裝也沒關係,執行剛才編譯出來的 python.exe
也可以直接執行。接下來,執行剛才編譯出來的 python.exe
,就會看到我們熟悉的 REPL 環境了:
$ ./python.exe
Python 3.12.6+ (heads/3.12:b2a7d718e3b, Sep 15 2024, 23:31:57) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.version
'3.12.6+ (heads/3.12:b2a7d718e3b, Sep 15 2024, 23:31:57) [Clang 15.0.0 (clang-1500.3.9.4)]'
在版號後面的 +
表示這個 Python 版本並不是一個正式發行的版本,可能是「開發版本」或我們自己拿原始碼來編譯的「自訂版本」。
跟 CPython 打招呼!
接下來這回我們來動手改一點 CPython 原始碼,加入一些我們想要效果,例如,我想要一進到 REPL 的時候就先印個 Hello,離開 REPL 的時候也有禮貌的說聲 Bye,禮多人不怪嘛!為了做到這個效果,我得先找到進入 REPL 的那段程式碼。這段程式碼在 Python/pythonrun.c
裡,翻一下 _PyRun_InteractiveLoopObject()
函數,應該會看到一個 do...while...
迴圈,這個迴圈,就是全名 Read-Eval-Print Loop 的 REPL 的那個 Loop:
int
_PyRun_InteractiveLoopObject(FILE *fp, PyObject *filename, PyCompilerFlags *flags)
{
PyCompilerFlags local_flags = _PyCompilerFlags_INIT;
// ... 略 ...
do {
ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
// ... 略 ...
} while (ret != E_EOF);
return err;
}
這段程式不算難懂,重點就在這個 do...while...
迴圈裡。如果我想在進到 REPL 迴圈之前先打聲招呼,應該只要在 do
前面來個 printf()
就行了。為了感覺自己有點像在寫程式的樣子,我刻意在這個檔案寫一個 say_something()
函數,其實這個函數就只是把傳進去的字串印出來而已:
void
say_something(const char *message)
{
printf("==============\n");
printf("%s\n", message);
printf("==============\n");
}
因為待會要在 _PyRun_InteractiveLoopObject()
函數裡呼叫 say_something()
,所以要把 say_something()
寫在 _PyRun_InteractiveLoopObject()
的前面。然後就可以準備來呼叫它了:
int
_PyRun_InteractiveLoopObject(FILE *fp, PyObject *filename, PyCompilerFlags *flags)
{
PyCompilerFlags local_flags = _PyCompilerFlags_INIT;
// ... 略 ...
say_something("Hello CPython"); // 加這行
do {
ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
// ... 略 ...
} while (ret != E_EOF);
say_something("Bye"); // 加這行
return err;
}
這樣就能在進到 REPL 的時候印出 Hello CPython
,離開 REPL 的時候印出 Bye
了。不像 Python 或 JavaScript 之類的程式語言改完立刻執行就能看到效果,用 C 語言寫的程式需要先編譯才能執行。再次執行 make
指令重新編譯 CPython,不過這次不會整個專案重新編譯,所以速度上應該會比上次快一些。
編譯完之後再重新執行一次:
$ ./python.exe
Python 3.12.6+ (heads/3.12-dirty:b2a7d718e3b, Sep 16 2024, 14:46:05) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
=============
Hello CPython
=============
>>> 1 + 2
3
>>> print("你好")
你好
>>> ^D
=============
Bye
=============
一進到 REPL 就會看到 Hello CPython
,在按下 Ctrl+D 離開 REPL 的時候也會印出 Bye
字樣,這樣就算是成功了!
不過這還有個小問題,在 Python 3.12 版本,要離開 REPL 除了 Ctrl + D 之外還可以輸入 exit()
,但現在輸入 exit()
並不會印出 Bye
,各位看一下原本的 do...while...
迴圈就只有判斷 ret != E_EOF
而已。這個問題待會我們再來處理,我先來調整一下 say_something()
函數,因為它之後可能 還會在別的地方被用到,所以我想把它抽出來,後續要用的時候就可以重複呼叫這個函數,也趁這個機會學一下在 C 語言裡怎麼把函數整理成模組!
超陽春模組
在 C 語言裡要定義模組,大概是先在某個 .h
檔案宣告函數的原型,然後在另一個 .c
檔裡實作這個函數的功能,這在 C 語言是很常見的做法。根據前面對 CPython 專案結構的介紹,這種要被引入的 .h
檔案通常會放在 Include
目錄裡,而用 C 語言寫的 .c
檔案通常會放在 Modules
、Python
或 Objects
目錄裡。Modules
目錄通常用來放擴展模組(C Extension),而核心功能通常在 Python
或 Objects
目錄中。由於我們加的這個 say_something()
函數算是修改直譯器的行為,我想把它擺在 Python
目錄更合適一點。
所以接下來我就依照 CPython 的慣例,在 Include
目錄裡建立 greeting.h
檔案。檔名是不是要叫做 greeting.h
你可以自己決定,檔案內容如下
#ifndef _PY_GREETING_H
#define _PY_GREETING_H
extern void say_something(const char *message);
#endif
前面兩行的 #ifndef
和 #define
是一種名為 Header Guard 的寫法,是一種為了避免這個檔案被重複引入從而避免編譯錯誤的小技巧,這裡的 ifndef
是「if not defined」的意思,而 _PY_GREETING_H
就只是我自己隨便編的名字,只要不跟其它的重複就好。接著我在 Python
目錄裡建立一個 greeting.c
檔案,檔案內容如下:
#include <stdio.h>
#include "greeting.h"
void say_something(const char *message)
{
printf("=============\n");
printf("%s\n", message); // 使用傳入的 message
printf("=============\n");
}
內容跟剛才寫沒什麼差別,只是多引入了 greeting.h
檔案,這樣編譯器才知道 say_something()
函數的原型。
接下來要告訴 CPython 要把這兩個檔案編譯進去,這樣才能在 CPython 的原始碼裡使用 say_something()
函數。在 CPython 專案裡,要編譯的檔案都會在 Makefile.pre.in
裡列出來,這個檔 案是用來產生 Makefile
的模板,所以我要在這裡加上 greeting.c
這個檔案,搜尋一下 PYTHON_OBJS
,把我們自己寫的 greeting
找個地方加上去:
PYTHON_OBJS= \
Python/_warnings.o \
... 略 ...
Python/suggestions.o \
Python/perf_trampoline.o \
Python/greeting.o \
Python/$(DYNLOADFILE) \
$(LIBOBJS) \
$(MACHDEP_OBJS) \
$(DTRACE_OBJS) \
@PLATFORM_OBJS@
存檔之後需要重新執行 ./configure
指令,請它再次幫我們產生 Makefile
。完成後再次執行 make
指令重新編譯 CPython,這樣就會把 greeting.c
編譯成 greeting.o
。這樣一來就可以我們想要使用這個函數的地方把 "greeting.h"
引進來,例如:
#include "pycore_pylifecycle.h" // _Py_UnhandledKeyboardInterrupt
#include "pycore_pystate.h" // _PyInterpreterState_GET()
#include "pycore_sysmodule.h" // _PySys_Audit()
#include "pycore_traceback.h" // _PyTraceBack_Print_Indented()
#include "greeting.h"
// ... 略 ...
只要引入 greeting.h
,就可以直接呼叫我們自己定義的 say_something()
函數了。
離開也要說聲 Goodbye
回來剛剛的 REPL,前面說到在 REPL 裡輸入 exit()
可以離開但卻不會在畫面上印出 "Bye"
字樣,這是因為 exit()
函數並不會觸發 ret != E_EOF
條件而結束迴圈。在 Python/pythonrun.c
裡搜尋一下 handle_system_exit()
函數,這個函數就是執行 exit()
的時候所對應用來離開 REPL 的函數,我們可以在裡面加上一行 say_something("Bye");
:
static void
handle_system_exit(void)
{
int exitcode;
if (_Py_HandleSystemExit(&exitcode)) {
say_something("Bye"); // <-- 加上這行
Py_Exit(exitcode);
}
}
重新再 make
一次,這次應該就行了:
$ ./python.exe
Python 3.12.6+ (heads/3.12-dirty:b2a7d718e3b, Sep 16 2024, 15:41:49) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
=============
Hello CPython
=============
>>> print("Hey")
Hey
>>> exit()
=============
Bye
=============
Good,打完收工!不知道像這樣自己動手改 CPython 原始碼對各位來說是什麼樣感受呢。
是說,大家在學 Python 的時候,也許聽過「在 Python 裡什麼東西都是『物件』」的說法,所以下個章節我們就從這所謂的「物件」開始看,看看它到底長什麼樣子!