匯入模組的時候...
不管是內建的還是第三方套件,我猜大家寫 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
的原始碼:
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()
函數:
static PyObject *
import_name(PyThreadState *tstate, _PyInterpreterFrame *frame,
PyObject *name, PyObject *fromlist, PyObject *level)
{
// ... 略 ...
}
繼續往下追,看一下裡面在做什麼事:
// ... 略 ...
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()
函數,繼續往下看:
// ... 略 ...
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()
函數了,繼續往下追:
PyObject *
PyImport_ImportModuleLevelObject(PyObject *name, PyObject *globals,
PyObject *locals, PyObject *fromlist,
int level)
{
// ... 略 ...
}
在這個函數裡面會看到這段:
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()
:
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()
函數到底在做什麼事:
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
應該可以找到這個指令的定義:
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()
函數,繼續看下去:
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
參數是用來存放已經匯入的模組。再繼續往下看:
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!
的註解引起我的興趣:
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
檔案內容如下:
from .parent import give_me_child
class ChildClass:
pass
from .child import ChildClass
def give_me_child():
return ChildClass()
import hello.child
當執行 main.py
的時候,會發生什麼事呢?我們來看看:
- Python 準備要匯入
hello.child
,而在匯入child
模組之前,Python 會先試著匯入hello
套件。 - 正當要匯入
hello
套件的時候,Python 執行了__init__.py
檔案。 - 在
__init__.py
試圖從parent
模組匯入give_me_child()
,因此需要執行parent.py
檔案。 - 而在
parent.py
的第一行是from .child import ChildClass
,此時,Python 發現它需要匯入child
模組,這剛好就是我們一開始想要匯入的模組! - 好,匯入了
child
模組,然後把它加到sys.modules
字典裡。 - 程式回到
parent.py
,完成give_me_child
的定義。 - 最後再回到一開始的
main.py
,但此時hello.child
已經在sys.modules
中了,所以直接回傳已經匯入的模組。
這大概就是為什麼會有那個 "Crazy side-effects!" 的原因了。在試圖匯入 child
模組的過程中,在上層模組或套件的匯入過程,child
模組就已經被匯入了。
幕後功臣 meta_path
在追 import_find_and_load()
函數的時候,裡面有發現個滿特別的東西:
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'>]
它也是個串列,裡面依序放了 BuiltinImporter
、FrozenImporter
以及 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()
函數的時候,裡面有一小段是這樣寫的:
def _find_and_load_unlocked(name, import_):
# ... 略 ...
spec = _find_spec(name, path)
# ... 略 ...
再追一下 _find_spec()
函數的實作:
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
裡拿就好。
不得不說,模組匯入的設計雖然複雜,但滿有趣的...好吧,至少我覺得有趣啦。