初窺 Python 的 import 機制

削微寒發表於2021-02-07

本文適合有 Python 基礎的小夥伴進階學習

作者:pwwang

一、前言

本文基於開源專案:

https://github.com/pwwang/python-import-system

補充擴充套件講解,希望能夠讓讀者一文搞懂 Python 的 import 機制。

1.1 什麼是 import 機制?

通常來講,在一段 Python 程式碼中去執行引用另一個模組中的程式碼,就需要使用 Python 的 import 機制。import 語句是觸發 import 機制最常用的手段,但並不是唯一手段。

importlib.import_module__import__ 函式也可以用來引入其他模組的程式碼。

1.2 import 是如何執行的?

import 語句會執行兩步操作:

  1. 搜尋需要引入的模組
  2. 將模組的名字做為變數繫結到區域性變數中

搜尋步驟實際上是通過 __import__ 函式完成的,而其返回值則會作為變數被繫結到區域性變數中。下面我們會詳細聊到 __import__ 函式是如果運作的。

二、import 機制概覽

下圖是 import 機制的概覽圖。不難看出,當 import 機制被觸發時,Python 首先會去 sys.modules 中查詢該模組是否已經被引入過,如果該模組已經被引入了,就直接呼叫它,否則再進行下一步。這裡 sys.modules 可以看做是一個快取容器。值得注意的是,如果 sys.modules 中對應的值是 None 那麼就會丟擲一個 ModuleNotFoundError 異常。下面是一個簡單的實驗:

In [1]: import sys

In [2]: sys.modules['os'] = None

In [3]: import os
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
<ipython-input-3-543d7f3a58ae> in <module>
----> 1 import os

ModuleNotFoundError: import of os halted; None in sys.modules

如果在 sys.modules 找到了對應的 module,並且這個 import 是由 import 語句觸發的,那麼下一步將對把對應的變數繫結到區域性變數中。

如果沒有發現任何快取,那麼系統將進行一個全新的 import 過程。在這個過程中 Python 將遍歷 sys.meta_path 來尋找是否有符合條件的元路徑查詢器(meta path finder)。sys.meta_path 是一個存放元路徑查詢器的列表。它有三個預設的查詢器:

  • 內建模組查詢器
  • 凍結模組(frozen module)查詢器
  • 基於路徑的模組查詢器。
In [1]: import sys

In [2]: sys.meta_path
Out[2]: 
[_frozen_importlib.BuiltinImporter,
 _frozen_importlib.FrozenImporter,
 _frozen_importlib_external.PathFinder]

查詢器的 find_spec 方法決定了該查詢器是否能處理要引入的模組並返回一個 ModeuleSpec 物件,這個物件包含了用來載入這個模組的相關資訊。如果沒有合適的 ModuleSpec 物件返回,那麼系統將檢視 sys.meta_path 的下一個元路徑查詢器。如果遍歷 sys.meta_path 都沒有找到合適的元路徑查詢器,將丟擲 ModuleNotFoundError。引入一個不存在的模組就會發生這種情況,因為 sys.meta_path 中所有的查詢器都無法處理這種情況:

In [1]: import nosuchmodule
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
<ipython-input-1-40c387f4d718> in <module>
----> 1 import nosuchmodule

ModuleNotFoundError: No module named 'nosuchmodule'

但是,如果這個手動新增一個可以處理這個模組的查詢器,那麼它也是可以被引入的:

In [1]: import sys
   ...: 
   ...: from importlib.abc import MetaPathFinder
   ...: from importlib.machinery import ModuleSpec
   ...: 
   ...: class NoSuchModuleFinder(MetaPathFinder):
   ...:     def find_spec(self, fullname, path, target=None):
   ...:         return ModuleSpec('nosuchmodule', None)
   ...: 
   ...: # don't do this in your script
   ...: sys.meta_path = [NoSuchModuleFinder()]
   ...: 
   ...: import nosuchmodule
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
<ipython-input-6-b7cbf7e60adc> in <module>
     11 sys.meta_path = [NoSuchModuleFinder()]
     12 
---> 13 import nosuchmodule

ImportError: missing loader

可以看到,當我們告訴系統如何去 find_spec 的時候,是不會丟擲 ModuleNotFound 異常的。但是要成功載入一個模組,還需要載入器 loader

載入器是 ModuleSpec 物件的一個屬性,它決定了如何載入和執行一個模組。如果說 ModuleSpec 物件是“師父領進門”的話,那麼載入器就是“修行在個人”了。在載入器中,你完全可以決定如何來載入以及執行一個模組。這裡的決定,不僅僅是載入和執行模組本身,你甚至可以修改一個模組:

In [1]: import sys
   ...: from types import ModuleType
   ...: from importlib.machinery import ModuleSpec
   ...: from importlib.abc import MetaPathFinder, Loader
   ...: 
   ...: class Module(ModuleType):
   ...:     def __init__(self, name):
   ...:         self.x = 1
   ...:         self.name = name
   ...: 
   ...: class ExampleLoader(Loader):
   ...:     def create_module(self, spec):
   ...:         return Module(spec.name)
   ...: 
   ...:     def exec_module(self, module):
   ...:         module.y = 2
   ...: 
   ...: class ExampleFinder(MetaPathFinder):
   ...:     def find_spec(self, fullname, path, target=None):
   ...:         return ModuleSpec('module', ExampleLoader())
   ...: 
   ...: sys.meta_path = [ExampleFinder()]

In [2]: import module

In [3]: module
Out[3]: <module 'module' (<__main__.ExampleLoader object at 0x7f7f0d07f890>)>

In [4]: module.x
Out[4]: 1

In [5]: module.y
Out[5]: 2

從上面的例子可以看到,一個載入器通常有兩個重要的方法 create_moduleexec_module 需要實現。如果實現了 exec_module 方法,那麼 create_module 則是必須的。如果這個 import 機制是由 import 語句發起的,那麼 create_module 方法返回的模組物件對應的變數將會被繫結到當前的區域性變數中。如果一個模組因此成功被載入了,那麼它將被快取到 sys.modules。如果這個模組再次被載入,那麼 sys.modules 的快取將會被直接引用。

三、import 勾子(import hooks)

為了簡化,我們在上述的流程圖中,並沒有提到 import 機制的勾子。實際上你可以新增一個勾子來改變 sys.meta_path 或者 sys.path,從而來改變 import 機制的行為。上面的例子中,我們直接修改了 sys.meta_path。實際上,你也可以通過勾子來實現:

In [1]: import sys
   ...: from types import ModuleType
   ...: from importlib.machinery import ModuleSpec
   ...: from importlib.abc import MetaPathFinder, Loader
   ...: 
   ...: class Module(ModuleType):
   ...:     def __init__(self, name):
   ...:         self.x = 1
   ...:         self.name = name
   ...: 
   ...: class ExampleLoader(Loader):
   ...:     def create_module(self, spec):
   ...:         return Module(spec.name)
   ...: 
   ...:     def exec_module(self, module):
   ...:         module.y = 2
   ...: 
   ...: class ExampleFinder(MetaPathFinder):
   ...:     def find_spec(self, fullname, path, target=None):
   ...:         return ModuleSpec('module', ExampleLoader())
   ...: 
   ...: def example_hook(path):
   ...:     # some conditions here
   ...:     return ExampleFinder()
   ...: 
   ...: sys.path_hooks = [example_hook]
   ...: # force to use the hook
   ...: sys.path_importer_cache.clear()
   ...: 
   ...: import module
   ...: module
Out[1]: <module 'module' (<__main__.ExampleLoader object at 0x7fdb08f74b90>)>

四、元路徑查詢器(meta path finder)

元路徑查詢器的工作就是看是否能找到模組。這些查詢器存放在 sys.meta_path 中以供 Python 遍歷(當然它們也可以通過 import 勾子返回,參見上面的例子)。每個查詢器必須實現 find_spec 方法。如果一個查詢器知道怎麼處理將引入的模組,find_spec 將返回一個 ModuleSpec 物件(參見下節)否則返回 None

和之前提到的一樣 sys.meta_path 包含三種查詢器:

  • 內建模組查詢器
  • 凍結模組查詢器
  • 基於路徑的查詢器

這裡我們想重點聊一聊基於路徑的查詢器(path based finder)。它用於搜尋一系列 import 路徑,每個路徑都用來查詢是否有對應的模組可以載入。預設的路徑查詢器實現了所有在檔案系統的特殊檔案中查詢模組的功能,這些特殊檔案包括 Python 原始檔(.py 檔案),Python 編譯後程式碼檔案(.pyc 檔案),共享庫檔案(.so 檔案)。如果 Python 標準庫中包含 zipimport,那麼相關的檔案也可用來查詢可引入的模組。

路徑查詢器不僅限於檔案系統中的檔案,它還可以上 URL 資料庫的查詢,或者其他任何可以用字串表示的地址。

你可以用上節提供的勾子來實現對同型別地址的模組查詢。例如,如果你想通過 URL 來 import 模組,那麼你可以寫一個 import 勾子來解析這個 URL 並且返回一個路徑查詢器。

注意,路徑查詢器不同於元路徑查詢器。後者在 sys.meta_path 中用於被 Python 遍歷,而前者特指基於路徑的查詢器。

五、ModuleSpec 物件

每個元路徑查詢器必須實現 find_spec 方法,如果該查詢器知道如果處理要引入的模組,那麼這個方法將返回一個 ModuleSpec 物件。這個物件有兩個屬性值得一提,一個是模組的名字,而另一個則是查詢器。如果一個 ModuleSpec 物件的查詢器是 None,那麼類似 ImportError: missing loader 的異常將會被丟擲。查詢器將用來建立和執行一個模組(見下節)。

你可以通過 <module>.__spec__ 來查詢模組的 ModuleSpec 物件:

In [1]: import sys

In [2]: sys.__spec__
Out[2]: ModuleSpec(name='sys', loader=<class '_frozen_importlib.BuiltinImporter'>)

六、載入器(loader)

載入器通過 create_module 來建立模組以及 exec_module 來執行模組。通常如果一個模組是一個 Python 模組(非內建模組或者動態擴充套件),那麼該模組的程式碼需要在模組的 __dict__ 空間上執行。如果模組的程式碼無法執行,那麼就會丟擲 ImportError 異常,或者其他在執行過程中的異常也會被丟擲。

絕大多數情況下,查詢器和載入器是同一個東西。這種情況下,查詢器的 find_spec 方法返回的 ModuleSpec 物件的 loader 屬性將指向它自己。

我們可以用 create_module 來動態建立一個模組,如果它返回 None Python 會自動建立一個模組。

七、總結

Python 的 import 機制靈活而強大。以上的介紹大部分是基於官方文件,以及較新的 Python 3.6+ 版本。由於篇幅,還有很多細節並沒有包含其中,例如子模組的載入、模組程式碼的快取機制等等。文章中也難免出現紕漏如果有任何問題,歡迎到 https://github.com/pwwang/python-import-system 開 issue 提問及討論。

相關文章