iT邦幫忙

2024 iThome 鐵人賽

DAY 2
1

本文同步刊載於 「為你自己學 Python - CPython 專案簡介

CPython 專案簡介

為你自己學 Python

這個章節會介紹 CPython 專案的結構,以及如譯編譯專案,我們會試著從原始碼來編譯出自己魔改過的 Python,來感受一下當 Python Core Dev 是什麼感覺(開玩笑的)!

專案結構

首先,要開始讀原始碼,得先把專案從 GitHub 上拉一份下來:

$ git clone git@github.com:python/cpython.git

扣掉一些比較不重要的檔案,剛從 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 的輔助工具。

以這整個系列單元來說,比較常看到的應該是 IncludeLibModulesObjectsPython 這幾個目錄,這些都是 CPython 直譯器的核心原始碼,如果想要了解 Python 的運作原理,可能就得多一些時間泡在這些目錄裡。

編譯專案

專案下載之後,先 cd 切換到目錄裡並執行 ./configure 指令:

$ ./configure

如果在 ./configure 後面加上 --prefix 參數,像這樣:

$ ./configure --prefix=/tmp/my-python

有特別加上 --prefix 的話,之後如果執行 make install 指令的時候,就會把 Python 以及相關的程式安裝到 /tmp/my-python 目錄裡。以目前來說我並沒打算執行 make install 進行安裝,所以可以先不加 --prefix 參數,等到後續有機會執行其它外部程式例如 pip 的時候再加即可。

剛才這個 ./configure 指令會在畫面上不斷的跳出一堆我看不懂的資訊,這是在檢查系統環境,看看有沒有缺什麼套件或函式庫,沒問題的話會產生一個 Makefile 檔案,這個檔案會告訴 make 指令待會應該要怎麼編譯整個專案。

接著執行 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 打招呼!

我們在上個章節練習過使用 printf() 來印點東西,這回我們動手改一點 CPython 原始碼。例如,我想要的效果是一進到 REPL 的時候就先印個 Hello,離開 REPL 的時候也有禮貌的說聲 Goodbye,禮多人不怪嘛!所以我們得先找到進到 REPL 的那段程式碼。這段程式碼在 Python/pythonrun.c 裡,翻一下 _PyRun_InteractiveLoopObject() 函數,應該會看到一個 do...while... 迴圈,這迴圈就是全名 Read-Eval-Print Loop 的 REPL 的那個 Loop:

// 檔名:Python/pythonrun.c

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;
}

這段程式應該不難懂,重點就在迴圈裡而已。所以如果我想在進到 REPL 迴圈之前先打聲招呼,應該只要在 do 前面來個 printf() 就好。為了感覺自己有在寫點程式,我刻意在這個檔案寫一個 say_something() 函數,其實它就只是把傳進去的字串印出來而已:

// 檔名:Python/pythonrun.c

void
say_something(const char *message)
{
    printf("==============\n");
    printf("%s\n", message);
    printf("==============\n");
}

因為待會要在 _PyRun_InteractiveLoopObject() 函數裡呼叫 say_something(),所以要把 say_something() 寫在 _PyRun_InteractiveLoopObject() 的前面。然後就可以準備來呼叫它了:

// 檔名:Python/pythonrun.c

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 字樣,這樣就算是成功了!

不過這還有個小問題,因為要離開 REPL 除了 Ctrl + D 之外還可以輸入 exit(),但現在輸入 exit() 並不會印出 Bye,但這還容易看出來的,你看原本的 do...while... 迴圈就只有判斷 ret != E_EOF 而已。這個問題待會我們再來處理,主要是這個 say_something() 函數之後可能在別的地方還會用到,所以我想把它抽出來,後續要用的時候就可以重複呼叫這個函數。

也趁這個機會學一下在 C 語言裡怎麼把函數整理成模組!

超陽春模組

在 C 語言裡要定義模組,大概就是先在某個 .h 檔案宣告函數的原型,然後在另一個 .c 檔裡實作這個函數的功能,這在 C 語言是很常見的做法。根據前面對 CPython 的介紹,這種要被引入的 .h 檔案通常會放在 Include 目錄裡,而用 C 語言寫的 .c 檔案通常會放在 ModulesPythonObjects 目錄裡。Modules 目錄通常用來放擴展模組(C Extension),而核心功能通常在 PythonObjects 目錄中。由於我們加的這個 say_something() 函數算是修改直譯器的行為,我把它擺在 Python 目錄更為合適。

我們就照著 CPython 的慣例,首先在 Include 目錄裡建立一個 greeting.h 檔案。名字你可以自己決定,檔案內容如下

// 檔案:Include/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 檔案,檔案內容如下:

// 檔案: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 找個地方加上去:

// 檔案:Makefile.pre.in

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

這樣一來,我們就可以我們想要的地方,例如 Python/pythonrun.c 裡呼叫 say_something() 函數了:

// 檔案:Python/pythonrun.c

#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"   // <-- 加上這個

// ... 略 ...

這樣以後想要用 say_something() 函數的時候,只要引入 greeting.h 就可以了。

離開也要說聲 Goodbye

剛才在 REPL 裡輸入 exit() 不會觸發 say_something() 的問題,這是因為 exit() 會直接結束程式,不會進到迴圈裡面。所以我們要在 Python/pythonrun.c 裡找到 handle_system_exit() 函數,這個函數是用來處理離開 REPL 的,我們可以在裡面加上一行 say_something("Bye");

// 檔案:Python/pythonrun.c

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,打完收工!

大家在學 Python 的時候,可能聽過「在 Python 裡什麼東西都是『物件』」的說法,所以下個章節我們就先從這所謂的「物件」開始看吧,看看它到底是什麼東西。

本文同步刊載於 「為你自己學 Python - CPython 專案簡介


上一篇
Day 1 - 來讀 CPython 原始碼!
下一篇
Day 3 - 全部都是物件!(上)
系列文
為你自己讀 CPython 原始碼31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言