5. 模組引入系統¶
一個 module 中的 Python 程式碼透過 importing 的過程來存取另一個模組中的程式碼。import 陳述式是叫用 (invoke) 引入機制最常見的方法,但這不是唯一的方法。函式如 importlib.import_module() 以及內建函式 __import__() 也可以用來叫用引入機制。
import 陳述式結合了兩個操作:首先搜尋指定的模組,然後將搜尋結果繫結到本地作用域中的一個名稱。import 陳述式的搜尋操作被定義為一個對 __import__() 函式的呼叫,並帶有相應的引數。__import__() 的回傳值用於執行 import 陳述式的名稱繫結操作。有關名稱繫結操作的詳細資訊,請參見 import 陳述式。
直接呼叫 __import__() 只會執行模組搜尋操作,以及在找到時執行模組的建立操作。雖然某些副作用可能會發生,例如引入父套件 (parent package),以及更新各種快取(包括 sys.modules),但只有 import 陳述式會執行名稱繫結操作。
當執行 import 陳述式時,會呼叫內建的 __import__() 函式。其他叫用引入系統的機制(如 importlib.import_module())可以選擇略過 __import__(),並使用它們自己的解決方案來實作引入語意。
當模組首次被引入時,Python 會搜尋該模組,若找到則會建立一個模組物件 [1],並對其進行初始化。如果找不到指定的模組,則會引發 ModuleNotFoundError。當引入機制被叫用時,Python 會實作各種策略來搜尋指定的模組。這些策略可以透過使用以下章節描述的各種 hook(掛鉤)來修改和擴展。
在 3.3 版的變更: 引入系統已被更新,以完全實作 PEP 302 的第二階段。不再有隱式引入機制——完整的引入系統已透過 sys.meta_path 公開。此外,原生命名空間套件支援(請參閱 PEP 420)也已被實作。
5.1. importlib¶
importlib 模組提供了豐富的 API 來與引入系統互動。例如,importlib.import_module() 提供了一個比內建的 __import__() 更推薦且更簡單的 API 來叫用引入機制。更多詳細資訊請參閱 importlib 函式庫文件。
5.2. 套件¶
Python 只有一種類型的模組物件,且所有模組,無論其是使用 Python、C 還是其他語言實作,都是這種類型。為了幫助組織模組並提供命名階層,Python 導入了套件的概念。
你可以將套件視為檔案系統中的目錄,模組則是目錄中的檔案,但不要過於字面地理解這個比喻,因為套件和模組不一定來自檔案系統。為了方便解釋,我們將使用這個目錄和檔案的比喻。就像檔案系統目錄一樣,套件是分層組織的,套件本身可以包含子套件以及一般模組。
請記住,所有的套件都是模組,但並非所有模組都是套件。換句話說,套件只是一種特殊的模組。具體來說,任何包含 __path__ 屬性的模組都被視為套件。
所有模組都有一個名稱。子套件的名稱與其父套件名稱之間用一個點來分隔,類似於 Python 的標準屬性存取語法。因此,你可能會有一個名為 email 的套件,該套件又有一個名為 email.mime 的子套件,並且該子套件中有一個名為 email.mime.text 的模組。
5.2.1. 一般套件¶
Python 定義了兩種類型的套件,一般套件和命名空間套件。一般套件是 Python 3.2 及更早版本中存在的傳統套件。一般套件通常實作成一個包含 __init__.py 檔案的目錄。當引入一般套件時,該 __init__.py 檔案會被隱式執行,其定義的物件會繫結到該套件的命名空間中的名稱。__init__.py 檔案可以包含與任何其他模組相同的 Python 程式碼,並且 Python 會在引入時為該模組增加一些額外的屬性。
例如,以下檔案系統布置定義了一個頂層的 parent 套件,該套件包含三個子套件:
parent/
__init__.py
one/
__init__.py
two/
__init__.py
three/
__init__.py
引入 parent.one 將隱式執行 parent/__init__.py 和 parent/one/__init__.py。隨後引入 parent.two 或 parent.three 將分別執行 parent/two/__init__.py 和 parent/three/__init__.py。
5.2.2. 命名空間套件¶
命名空間套件是由不同的部分 組成的,每個部分都為父套件提供一個子套件。這些部分可以位於檔案系統上的不同位置。部分可能也存在於壓縮檔案中、網路上,或 Python 在引入時搜尋的任何其他地方。命名空間套件不一定直接對應於檔案系統中的物件;它們可能是沒有具體表示的虛擬模組。
命名空間套件的 __path__ 屬性不使用普通的串列。它們使用自訂的可疊代型別,當父套件的路徑(或頂層套件的 sys.path)發生變化時,會在下一次引入嘗試時自動執行新一輪的套件部分搜尋。
在命名空間套件中,不存在 parent/__init__.py 檔案。實際上,在引入搜尋過程中可能會找到多個 parent 目錄,每個目錄由不同的部分提供。因此,parent/one 可能與 parent/two 不會實際位於一起。在這種情況下,每當引入頂層 parent 套件或其子套件之一時,Python 會為頂層 parent 套件建立一個命名空間套件。
有關命名空間套件的規格,請參見 PEP 420。
5.3. 搜尋¶
在開始搜尋之前,Python 需要被引入模組(或套件,但在本討論中,兩者的區別無關緊要)的完整限定名稱 (qualified name)。此名稱可能來自 import 陳述式的各種引數,或來自 importlib.import_module() 或 __import__() 函式的參數。
此名稱將在引入搜尋的各個階段中使用,並且它可能是指向子模組的點分隔路徑,例如 foo.bar.baz。在這種情況下,Python 會首先嘗試引入 foo,然後是 foo.bar,最後是 foo.bar.baz。如果任何中間引入失敗,則會引發 ModuleNotFoundError。
5.3.1. 模組快取¶
在引入搜尋過程中首先檢查的地方是 sys.modules。此對映用作所有先前引入過的模組的快取,包括中間路徑。因此,如果 foo.bar.baz 之前已被引入,sys.modules 將包含 foo、foo.bar 和 foo.bar.baz 的條目。每個鍵的值都是相應的模組物件。
在引入過程中,會在 sys.modules 中查找模組名稱,如果存在,則相關的值為滿足此引入的模組,此引入過程即完成。然而,如果值是 None,則會引發 ModuleNotFoundError。如果模組名稱不存在,Python 會繼續搜尋該模組。
sys.modules 是可寫入的。刪除一個鍵可能不會銷毀相關聯的模組(因為其他模組可能持有對它的參照),但會使指定的模組的快取條目失效,導致 Python 在下一次引入該模組時重新搜尋。也可以將鍵賦值為 None,這會強制下一次引入該模組時引發 ModuleNotFoundError。
但請注意,如果你保留了對模組物件的參照,並在 sys.modules 中使其快取條目失效,然後重新引入指定的模組,這兩個模組物件將不會相同。相比之下,importlib.reload() 會重用相同的模組物件,並透過重新執行模組的程式碼來簡單地重新初始化模組內容。
5.3.2. 尋檢器 (Finder) 與載入器 (Loader)¶
如果在 sys.modules 中找不到指定的模組,則會叫用 Python 的引入協定來尋找並載入該模組。這個協定由兩個概念性物件組成,尋檢器 和載入器。尋檢器的任務是使用其已知的策略來確定是否能找到命名模組。實作這兩個介面的物件稱為引入器 (importer) ——當它們發現可以載入所請求的模組時,會回傳它們自己。
Python 包含多個預設的尋檢器和引入器。第一個尋檢器知道如何定位內建模組,第二個尋檢器知道如何定位凍結模組。第三個預設尋檢器會在 import path 中搜尋模組。import path 是一個位置的列表,這些位置可能是檔案系統路徑或壓縮檔案,也可以擴充以搜尋任何可定位的資源,例如由 URL 識別的資源。
引入機制是可擴充的,因此可以增加新的尋檢器來擴充模組搜尋的範圍和作用域。
尋檢器實際上不會載入模組。如果它們能找到指定的模組,它們會回傳一個模組規格,這是一個模組的引入相關資訊的封裝,引入機制會在載入模組時使用這些資訊。
以下各節將更詳細地描述尋檢器和載入器的協定,包括如何建立和註冊新的尋檢器和載入器來擴充引入機制。
在 3.4 版的變更: Python 在之前的版本中,尋檢器會直接回傳載入器,而現在它們回傳的是包含載入器的模組規格。載入器仍在引入過程中使用,但其責任減少了。
5.3.3. 引入掛鉤 (Import hooks)¶
引入機制的設計是可擴充的;其主要機制是引入掛鉤。引入掛鉤有兩種類型:元掛鉤 (meta hooks) 和引入路徑掛鉤。
元掛鉤會在引入處理的開始階段被呼叫,除了查找 sys.modules 快取外,其他引入處理還未發生時就會呼叫。這允許元掛鉤覆蓋 sys.path 的處理、凍結模組,甚至是內建模組。元掛鉤透過將新的尋檢器物件新增到 sys.meta_path 中來註冊,具體描述請參閱以下段落。
引入路徑掛鉤被視為 sys.path(或 package.__path__)處理過程的一部分來呼叫,當遇到與其相關聯的路徑項目時就會被觸發。引入路徑掛鉤透過將新的可呼叫物件增加到 sys.path_hooks 中來註冊,具體描述請參閱以下段落。
5.3.4. 元路徑¶
當在 sys.modules 中找不到命名模組時,Python 接下來會搜尋 sys.meta_path,其中包含一個元路徑尋檢器物件串列。這些尋檢器會依次被查詢,看它們是否知道如何處理命名模組。元路徑尋檢器必須實作一個名為 find_spec() 的方法,該方法接收三個引數:名稱、引入路徑和(可選的)目標模組。元路徑尋檢器可以使用任何策略來確定它是否能處理命名模組。
如果元路徑尋檢器知道如何處理命名模組,它會回傳一個規格物件。如果它無法處理命名模組,則回傳 None。如果 sys.meta_path 的處理到達串列的末尾仍未回傳規格,則會引發 ModuleNotFoundError。任何其他引發的例外將直接向上傳播,並中止引入過程。
元路徑尋檢器的 find_spec() 方法會以兩個或三個引數來呼叫。第一個是被引入模組的完全限定名稱,例如 foo.bar.baz。第二個引數是用於模組搜尋的路徑條目。對於頂層模組,第二個引數是 None,但對於子模組或子套件,第二個引數是父套件的 __path__ 屬性的值。如果無法存取相應的 __path__ 屬性,將引發 ModuleNotFoundError。第三個引數是一個現有的模組物件,該物件將成為後續載入的目標。引入系統只會在重新載入時傳入目標模組。
對於一個引入請求,元路徑可能會被遍歷多次。例如,假設參與的模組都沒有被快取,則引入 foo.bar.baz 將首先執行頂層引入,對每個元路徑尋檢器(mpf)呼叫 mpf.find_spec("foo", None, None)。當 foo 被引入後,將再次藉由遍歷元路徑引入 foo.bar,並呼叫 mpf.find_spec("foo.bar", foo.__path__, None)。當 foo.bar 被引入後,最後一次遍歷會呼叫 mpf.find_spec("foo.bar.baz", foo.bar.__path__, None)。
一些元路徑尋檢器僅支援頂層引入。當第二個引數傳入 None 以外的值時,這些引入器將始終回傳 None。
Python 的預設 sys.meta_path 有三個元路徑尋檢器,一個知道如何引入內建模組,一個知道如何引入凍結模組,還有一個知道如何從 import path 引入模組(即 path based finder)。
在 3.4 版的變更: 元路徑尋檢器的 find_spec() 方法取代了 find_module(),後者現在已被棄用。雖然它將繼續正常工作,但引入機制僅在尋檢器未實作 find_spec() 時才會嘗試使用它。
在 3.10 版的變更: 引入系統現在使用 find_module() 時將引發 ImportWarning。
在 3.12 版的變更: find_module() 已被移除。請改用 find_spec()。
5.4. 載入¶
如果找到模組規格,引入機制會在載入模組時使用該規格(以及它包含的載入器)。以下是引入過程中載入部分的大致情況:
module = None
if spec.loader is not None and hasattr(spec.loader, 'create_module'):
# 這裡假設載入器上也會定義 'exec_module'
module = spec.loader.create_module(spec)
if module is None:
module = ModuleType(spec.name)
# 與引入相關的模組屬性會在此處設定:
_init_module_attrs(spec, module)
if spec.loader is None:
# 不支援
raise ImportError
if spec.origin is None and spec.submodule_search_locations is not None:
# 命名空間套件
sys.modules[spec.name] = module
elif not hasattr(