跳至主要内容

匯入模組的時候...

為你自己學 Python

不管是內建的還是第三方套件,我猜大家寫 Python 程式的時候,應該都用過 import 關鍵字匯入過模組,那麼請各位猜猜看,以下這三種寫法,有什麼差別?

# 寫法 A
import sys
print(sys.version)

# 寫法 B
import sys as s
print(s.version)

# 寫法 C
from sys import version
print(version)

不同的匯入方式

以結果來說,都可以順利印出 version 而得到當前 Python 的版本,如果檢視編譯出來的 Bytecode 的話,會發現寫法 A 跟寫法 B 幾乎是一樣的,差別在於寫法 A 是把模組透過 STORE_NAME 指令存成 sys,而使用 as 關鍵字的寫法 B 則是另存成 s

1           2 LOAD_CONST               0 (0)
4 LOAD_CONST 1 (None)
6 IMPORT_NAME 0 (sys)
8 STORE_NAME 0 (sys)

4 46 LOAD_CONST 0 (0)
48 LOAD_CONST 1 (None)
50 IMPORT_NAME 0 (sys)
52 STORE_NAME 3 (s)

這兩種寫法都是透過 IMPORT_NAME 指令匯入模組,其它的差別不大。不過 from ... import ... 的寫法,執行起來就有一點點不同了:

7          90 LOAD_CONST               0 (0)
92 LOAD_CONST 2 (('version',))
94 IMPORT_NAME 0 (sys)
96 IMPORT_FROM 2 (version)
98 STORE_NAME 2 (version)
100 POP_TOP

除了 IMPORT_NAME 之外,還多了 IMPORT_FROM 指令,我們來看看這兩個指令到底做些什麼事。

import 指令

先從 IMPORT_NAME 開始看,在 Python/bytecodes.c 可以找到 IMPORT_NAME 的原始碼:

檔案:Python/bytecodes.c
inst(IMPORT_NAME, (level, fromlist -- res)) {
PyObject *name = GETITEM(frame->f_code->co_names, oparg);
res = import_name(tstate, frame, name, fromlist, level);
DECREF_INPUTS();
ERROR_IF(res == NULL, error);
}

開頭的 inst 巨集只是用來簡化 switch ... case ... 的寫法,真正做事的是 import_name() 函數:

檔案:Python/ceval.c
static PyObject *
import_name(PyThreadState *tstate, _PyInterpreterFrame *frame,
PyObject *name, PyObject *fromlist, PyObject *level)
{
// ... 略 ...
}

繼續往下追,看一下裡面在做什麼事:

檔案:Python/ceval.c
// ... 略 ...
import_func = PyObject_GetItem(frame->f_builtins, &_Py_ID(__import__));
if (import_func == NULL) {
if (_PyErr_ExceptionMatches(tstate, PyExc_KeyError)) {
_PyErr_SetString(tstate, PyExc_ImportError, "__import__ not found");
}
return NULL;
}
// ... 略 ...

這是從內建的函數表裡取得 __import__ 來用,如果找不到的話,會出現 "__import__ not found" 的錯誤訊息。咦?__import__ 這種內建的怎麼會找不到?正常來說應該都是會有啦,但我們也是可以手賤的把它刪掉:

# 一開始是存在的
>>> __import__
<built-in function __import__>

# 用 del 關鍵字把它刪掉
>>> import builtins
>>> del builtins.__import__

# 再試著 import 其它模組
>>> import sys
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ImportError: __import__ not found

果然就出現預期的錯誤訊息了,不過這個玩玩就好,沒事別這樣拿石頭砸自己或同事的腳。回來原本的 import_name() 函數,繼續往下看:

檔案:Python/ceval.c
// ... 略 ...
if (_PyImport_IsDefaultImportFunc(tstate->interp, import_func)) {
Py_DECREF(import_func);
int ilevel = _PyLong_AsInt(level);
if (ilevel == -1 && _PyErr_Occurred(tstate)) {
return NULL;
}
res = PyImport_ImportModuleLevelObject(
name,
frame->f_globals,
locals == NULL ? Py_None :locals,
fromlist,
ilevel);
return res;
}
// ... 略 ...

這個函數真正做事的應該就是這個 PyImport_ImportModuleLevelObject() 函數了,繼續往下追:

檔案:Python/import.c
PyObject *
PyImport_ImportModuleLevelObject(PyObject *name, PyObject *globals,
PyObject *locals, PyObject *fromlist,
int level)
{
// ... 略 ...
}

在這個函數裡面會看到這段:

檔案:Python/import.c
mod = import_get_module(tstate, abs_name);
if (mod == NULL && _PyErr_Occurred(tstate)) {
goto error;
}

再追進 import_get_module() 函數的話,就會看到 Python 試著從 sys.modules 字典裡取得模組,如果有就取出來使用,如果沒有,就繼續往下執行 import_find_and_load()

檔案:Python/import.c
static PyObject *
import_find_and_load(PyThreadState *tstate, PyObject *abs_name)
{
// ... 略 ...

mod = PyObject_CallMethodObjArgs(IMPORTLIB(interp), &_Py_ID(_find_and_load),
abs_name, IMPORT_FUNC(interp), NULL);

// ... 略 ...
}

這裡會呼叫 importlib 模組的 _find_and_load() 函數來尋找並載入模組,這個 importlib 不是 C 語言寫的,而是用 Python 寫的。不過這個 _find_and_load() 函數並不是公開的 API,所以如果在 REPL 裡要呼叫的話,需要改成 importlib._bootstrap._find_and_load() 這樣的寫法,但更建議直接使用 importlib 模組的 import_module() 函數來匯入模組。

既然追到了 importlib 模組,那就來看看 _find_and_load() 函數到底在做什麼事:

檔案:Lib/importlib/_bootstrap.py
def _find_and_load(name, import_):
module = sys.modules.get(name, _NEEDS_LOADING)
if (module is _NEEDS_LOADING or
getattr(getattr(module, "__spec__", None), "_initializing", False)):
with _ModuleLockManager(name):
module = sys.modules.get(name, _NEEDS_LOADING)
if module is _NEEDS_LOADING:
return _find_and_load_unlocked(name, import_)

# ... 略 ...

return module

總算看到用 Python 寫的程式碼,親切多了!

在這個函數裡,會先從 sys.modules 字典裡取得模組,如果有的話就直接回傳,如果沒有的話,就會呼叫 _find_and_load_unlocked() 函數來載入模組。繼續追 _find_and_load_unlocked() 函數就會看到把匯入的模組存到 sys.modules 字典裡,這樣下次再匯入的時候就可以直接取用了。

from .. import .. 指令

再來看 IMPORT_FROM 指令,從 Python/bytecodes.c 應該可以找到這個指令的定義:

檔案:Python/bytecodes.c
inst(IMPORT_FROM, (from -- from, res)) {
PyObject *name = GETITEM(frame->f_code->co_names, oparg);
res = import_from(tstate, from, name);
ERROR_IF(res == NULL, error);
}

這裡呼叫了 import_from() 函數,繼續看下去:

檔案:Python/ceval.c
static PyObject *
import_from(PyThreadState *tstate, PyObject *v, PyObject *name)
{
PyObject *x;
PyObject *fullmodname, *pkgname, *pkgpath, *pkgname_or_unknown, *errmsg;

if (_PyObject_LookupAttr(v, name, &x) != 0) {
return x;
}
// ... 略 ...
}

這會試著從 v 這個模組裡找出 name 這個屬性,如果找到的話就回傳,找不到的話就會繼續往下執行。這裡的 v 其實就是從 IMPORT_FROM 指令裡的 from 參數傳進來的,也就是說這個 from 參數是用來存放已經匯入的模組。再繼續往下看:

檔案:Python/ceval.c
fullmodname = PyUnicode_FromFormat("%U.%U", pkgname, name);
if (fullmodname == NULL) {
Py_DECREF(pkgname);
return NULL;
}
x = PyImport_GetModule(fullmodname);

這個 fullmodname 是嘗試組合出完整模組名稱並從,然後再試著透過 PyImport_GetModule() 函數從 sys.modules 字典裡看能不能找到。雖然程式教課書都會教我們要用有意義的名字來命名,而在這個函數裡用了變數 x 表示模組,不知道是不是懶得想名字 :)

簡單的整理一下,import 指令底層呼叫 import_name() 函數再透過 importlib 模組進行查找和匯入,而 from.. import.. 指令則是透過 import_from() 函數來查找模組,這個函數會試著從物件的屬性查找,然後才嘗試從 sys.modules 獲取。

瘋狂的副作用!

在追 _find_and_load_unlocked() 函數的時候,有一行 # Crazy side-effects! 的註解引起我的興趣:

檔案:Lib/importlib/_bootstrap.py
def _find_and_load_unlocked(name, import_):
# ... 略 ...
if parent:
if parent not in sys.modules:
_call_with_frames_removed(import_, parent)
# Crazy side-effects!
if name in sys.modules:
return sys.modules[name]
parent_module = sys.modules[parent]

到底是什麼副作用會讓設計者在 CPython 的原始碼裡加上這段註解?瘋狂是有多瘋狂?

事實上這段程式碼是在處理循環引用的問題,為了造成特定的情境,這裡我刻意用了比較極端的範例。假設我有個專案結構如下:

├── hello
│ ├── __init__.py
│ ├── child.py
│ └── parent.py
└── main.py

檔案內容如下:

檔案:hello/__init__.py
from .parent import give_me_child
檔案:hello/child.py
class ChildClass:
pass
檔案:hello/parent.py
from .child import ChildClass

def give_me_child():
return ChildClass()
檔案:main.py
import hello.child

當執行 main.py 的時候,會發生什麼事呢?我們來看看:

  1. Python 準備要匯入 hello.child,而在匯入 child 模組之前,Python 會先試著匯入 hello 套件。
  2. 正當要匯入 hello 套件的時候,Python 執行了 __init__.py 檔案。
  3. __init__.py 試圖從 parent 模組匯入 give_me_child(),因此需要執行 parent.py 檔案。
  4. 而在 parent.py 的第一行是 from .child import ChildClass,此時,Python 發現它需要匯入 child 模組,這剛好就是我們一開始想要匯入的模組!
  5. 好,匯入了 child 模組,然後把它加到 sys.modules 字典裡。
  6. 程式回到 parent.py,完成 give_me_child 的定義。
  7. 最後再回到一開始的 main.py,但此時 hello.child 已經在 sys.modules 中了,所以直接回傳已經匯入的模組。

這大概就是為什麼會有那個 "Crazy side-effects!" 的原因了。在試圖匯入 child 模組的過程中,在上層模組或套件的匯入過程,child 模組就已經被匯入了。

幕後功臣 meta_path

在追 import_find_and_load() 函數的時候,裡面有發現個滿特別的東西:

檔案:Python/import.c
static PyObject *
import_find_and_load(PyThreadState *tstate, PyObject *abs_name)
{
// ... 略 ...
PyObject *sys_path = PySys_GetObject("path");
PyObject *sys_meta_path = PySys_GetObject("meta_path");
PyObject *sys_path_hooks = PySys_GetObject("path_hooks");
if (_PySys_Audit(tstate, "import", "OOOOO",
abs_name, Py_None, sys_path ? sys_path : Py_None,
sys_meta_path ? sys_meta_path : Py_None,
sys_path_hooks ? sys_path_hooks : Py_None) < 0) {
return NULL;
}
// ... 略 ...
}

sys.path 比較比較簡單,就是個串列,裡面放著 Python 在搜尋模組時候的順序。但 sys.meta_path 是什麼?我們進 REPL 把它印出來看看:

>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>,
<class '_frozen_importlib.FrozenImporter'>,
<class '_frozen_importlib_external.PathFinder'>]

它也是個串列,裡面依序放了 BuiltinImporterFrozenImporter 以及 PathFinder 這三個類別,前面兩個類別可以在 Lib/importlib/_bootstrap.py 找到實作,而最後一個 PathFinder 則是放在 Lib/importlib/_bootstrap_external.py

這些 Importer 或 Finder,就是 Python 負責匯入模組的傢伙,簡單的說,BuiltinImporter 負責匯入內建模組,FrozenImporter 負責處理「凍結模組」,所謂凍結模組是指被編譯成 Python 直譯器的一部份,所以不需要從硬碟裡載入,啟動速度更快,效能更好,安全性也更高。而 PathFinder 可能最複雜的,它負責用透過文件系統的路徑的來進行模組的查找,實際上會遍歷 sys.path 中的每個路徑,嘗試找到匹配的模組。 。

這幾個類別都有實作類別方法 find_spec()。在追前面 _find_and_load_unlocked() 函數的時候,裡面有一小段是這樣寫的:

檔案:Lib/importlib/_bootstrap.py
def _find_and_load_unlocked(name, import_):
# ... 略 ...

spec = _find_spec(name, path)
# ... 略 ...

再追一下 _find_spec() 函數的實作:

檔案:Lib/importlib/_bootstrap.py
def _find_spec(name, path, target=None):
meta_path = sys.meta_path

# ... 略 ...

for finder in meta_path:
with _ImportLockContext():
try:
find_spec = finder.find_spec
except AttributeError:
continue
else:
spec = find_spec(name, path, target)

# ... 略 ...

也就是說,在使用這些 Finder 類別的時候,會依照 sys.meta_path 的順序,先使用 BuiltinImporter,如果找不到會再使用 FrozenImporter,最後才是 PathFinder

如果你覺得這三個內建的 Finder 不夠,你也可以自己客製化自己專屬的 Finder,詳細規格可參閱官網文件說明。

小結

追了原始碼才發現原來 Python 的模組匯入機制比我想像中的複雜,在匯入模組的過程中,是由 C 語言跟 Python 共同合作來完成的,結合了 C 語言的效率和 Python 的靈活性。匯入模組的過程包括模組查找、載入和初始化以及處理循環匯入的問題,也因為使用 sys.modules 字典做快取,也因此在 Python 如果匯入重複的模組的時候,並不會額外佔用額外的資源,直接從 sys.modules 裡拿就好。

不得不說,模組匯入的設計雖然複雜,但滿有趣的...好吧,至少我覺得有趣啦。

工商服務

想學 Python 嗎?我教你啊 :)

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