1. 程式人生 > >Python 中的黑暗角落(三):模組與包

Python 中的黑暗角落(三):模組與包

如果你用過 Python,那麼你一定用過 import 關鍵字載入過各式各樣的模組。但你是否熟悉 Python 中的模組與包的概念呢?或者,以下幾個問題,你是否有明確的答案?

  • 什麼是模組?什麼又是包?
  • from matplotlib.ticker import Formatter, FixedLocator 中的 matplotlib 和 ticker 分別是什麼?中間的句點是什麼意思?
  • from matplotlib.pyplot import * 中,import * 的背後會發生什麼?

魯迅先生說:「於無聲處聽驚雷」,講的是平淡時卻有令人驚奇、意外的事情。import 相關的模組、包的概念也是如此。如果你對上面幾個問題存有疑問,那麼這篇就是為你而作的。

模組

為什麼要有模組

眾所周知,Python 有一個互動式的直譯器。在直譯器中,你可以使用 Python 的所有功能。但是,直譯器是一次性的。也就是說,如果你關掉直譯器,那麼先前定義、執行的一切東西,都會丟失不見。另一方面,在直譯器中輸入程式碼是一件很麻煩的事情;這是因為在直譯器中複用程式碼比較困難。

為此,人們會把相對穩定、篇幅較長的程式碼儲存在一個純文字檔案中。一般來說,我們把這樣副檔名為 .py 的檔案稱為 Python 指令碼。為了提高程式碼複用率,我們可以把一組相關的 Python 相關的定義、宣告儲存在同一個 .py 檔案中。此時,這個 Python 指令碼就是一個 Python 模組(Module)。我們可以在直譯器中,或者在其他 Python 指令碼中,通過 import

 載入定義好的 Python 模組。

模組的識別

和 Python 中的其它物件一樣,Python 也為模組定義了一些形如 __foo__ 的變數。對於模組來說,最重要的就是它的名字 __name__ 了。每當 Python 執行指令碼,它就會為該指令碼賦予一個名字。對於「主程式」來說,這一指令碼的 __name__ 被定義為 "__main__";對於被 import 進主程式的模組來說,這一指令碼的 __name__ 被定義為指令碼的檔名(base filename)。因此,我們可以用 if __name__ == "__main__": 在模組程式碼中定義一些測試程式碼。

fibonacci.py
1
2 3 4 5 6 7 8 9 10 11 12
def fib_yield(n):
    a, b = 0, 1
    while b < n:
        yield b
        a, b = b, a+b

def fib(n):
    for num in fib_yield(n):
        print(num)

if __name__ == "__main__":
    fib(10)

我們將其儲存為 fibonacci.py,而後在 Python 直譯器中 import 它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
In [1]: import fibonacci

In [2]: dir(fibonacci)
Out[2]:
['__builtins__',
 '__doc__',
 '__file__',
 '__name__',
 '__package__',
 'fib',
 'fib_yield']

In [3]: print(fibonacci.__name__)
fibonacci

In [4]: fibonacci.fib(5)
1
1
2
3

In [5]: for num in fibonacci.fib_yield(5):
   ...:     print(num)
   ...:
1
1
2
3

可以觀察到,fibonacci.py 在作為模組引入時,fibonacci.__name__ 被設定為檔名 "fibonacci"。但若在命令列直接執行 python fibonacci.py,則 if 語句塊會被執行,此時 __name__ 是 "__main__"

模組的內部變數和初始化

Python 為每個模組維護了單獨的符號表,因此可以實現類似 C++ 中名字空間(namespace)的功能。Python 模組中的函式,可以使用模組的內部變數,完成相關的初始化操作;同時,import 模組的時候,也不用擔心這些模組內部變數與使用者自定義的變數同名衝突。

module_var.py
1
2
3
4
5
6
7
foo = 0

def show():
    print(foo)

if __name__ == "__main__":
    show()

此處我們在模組 module_var 內部定義了內部變數 foo,並且在函式 show 中引用了它。

1
2
3
4
5
6
7
8
9
10
11
12
In [7]: import module_var
   ...:
   ...: foo = 3
   ...:
   ...: print(foo)
   ...: print(module_var.foo)
   ...:
   ...: module_var.show()
   ...:
3
0
0

值得一提的是,模組的初始化操作(這裡指 foo = 0 這條語句),僅只在 Python 直譯器第一次處理該模組的時候執行。也就是說,如果同一個模組被多次 import,它只會執行一次初始化。

from ... import ...

模組提供了類似名字空間的限制,不過 Python 也允許從模組中匯入指定的符號(變數、函式、類等)到當前模組。匯入後,這些符號就可以直接使用,而不需要字首模組名。

1
2
3
4
5
6
7
8
9
In [8]: from fibonacci import fib_yield, fib

In [9]: fib(10)
1
1
2
3
5
8

值得一提的是,被匯入的符號,如果引用了模組內部的變數,那麼在匯入之後也依然會使用模組內的變數,而不是當前環境中的同名變數。

1
2
3
4
5
6
In [11]: from module_var import show

In [12]: foo = 3

In [13]: show()
0

也有更粗暴的方式,匯入模組內的所有公開符號(沒有字首 _ 的那些)。不過,一般來說,除了實驗、排查,不建議這樣做。因為,通常你不知道模組定義了哪些符號、是否與當前環境有重名的符號。一旦有重名,那麼,這樣粗暴地匯入模組內所有符號,就會覆蓋掉當前環境的版本。從而造成難以排查的錯誤。

模組搜尋路徑

之前我們都在討論模組的好處,但是忽略了一個問題:Python 怎樣知道從何處找到模組檔案?

如果你熟悉命令列,那麼這個問題對你來說就不難理解。在命令列中執行的任何命令,實際上背後都對應了一個可執行檔案。命令列直譯器(比如 cmd, bash)會從一個全域性的環境變數 PATH 中讀取一個有序的列表。這個列表包含了一系列的路徑,而命令列直譯器,會依次在這些路徑裡,搜尋需要的可執行檔案。

Python 搜尋模組檔案,也遵循了類似的思路。比如,使用者在 Python 中嘗試匯入 import foobar,那麼

  • 首先,Python 會在內建模組中搜尋 foobar
  • 若未找到,則 Python 會在當前工作路徑(當前指令碼所在路徑,或者執行 Python 直譯器的路徑)中搜尋 foobar
  • 若仍未找到,則 Python 會在環境變數 PYTHONPATH 中指示的路徑中搜尋 foobar
  • 若依舊未能找到,則 Python 會在安裝時指定的路徑中搜尋 foobar
  • 若仍舊失敗,則 Python 會報錯,提示找不到 foobar 這個模組。
1
2
3
4
5
6
7
In [14]: import foobar
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
<ipython-input-14-909badd622c0> in <module>()
----> 1 import foobar

ImportError: No module named foobar

pyc 檔案

和 LaTeX 中遇到的問題一樣:裝載大量文字檔案是很慢的。因此 Python 也採用了類似 LaTeX 的解決方案:將模組編譯成容易裝載的檔案,並儲存起來(相當於 LaTeX 中的 dump 格式檔案 .fmt)。這些編譯好並儲存起來的檔案,有後綴名 .pyc

當 Python 編譯好模組之後,下次載入時,Python 就會讀取相應的 .pyc 檔案,而不是 .py 檔案。而裝載 .pyc 檔案會比裝載 .py 檔案更快。

值得一提的是,對於 .pyc,很多人一直有誤解。事實上,從執行的角度,裝載 .pyc 並不比裝載 .py 檔案更快。此處的加速,僅只在裝載模組的過程中起作用。因此 .pyc 中的 C 更多地可以理解為 cache。

包(package)是 Python 中對模組的更高一級的抽象。簡單來說,Python 允許使用者把目錄當成模組看待。這樣一來,目錄中的不同模組檔案,就變成了「包」裡面的子模組。此外,包目錄下還可以有子目錄,這些子目錄也可以是 Python 包。這種分層,對模組識別、管理,都是非常有好處的。特別地,對於一些大型 Python 工具包,內裡可能有成百上千個不同功能的模組。若是逐個模組釋出,那簡直成了災難。

科學計算領域,SciPy, NumPy, Matplotlib 等第三方工具,都是用包的形式釋出的。

目錄結構

Python 要求每一個「包」目錄下,都必須有一個名為 __init__.py 的檔案。從這個檔案的名字上看,首先它有 __ 作為前後綴,我們就知道,這個檔案肯定是 Python 內部用來做某種識別用的;其次,它有 init,我們知道它一定和初始化有關;最後,它有 .py 作為字尾名,因此它也是一個 Python 模組,可以完成一些特定的工作。

現在假設你想編寫一個 Python 工具包,用來處理圖片,它可能由多個 Python 模組組成。於是你會考慮把它做成一個 Python 包,內部按照功能分成若干子包,再繼續往下分成不同模組去實現。比如會有這樣的目錄結構。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
picture/                        Top-level package
      __init__.py               Initialize the picture package
      formats/                  Subpackage for file format conversions
              __init__.py
              jpgread.py
              jpgwrite.py
              pngread.py
              pngwrite.py
              bmpread.py
              bmpwrite.py
              ...
      filters/                  Subpackage for filters
              __init__.py
              boxblur.py
              gaussblur.py
              sharpen.py
              ...

此處 picture 目錄下有 __init__.py,因此 Python 會將其作為一個 Python 包;類似地,子目錄 formats 和 filters 就成了 picture 下的子包。這裡,子包的劃分以功能為準。formats 下的模組,設計用於處理不同格式的圖片檔案的讀寫;而 filters 下的模組,則被設計用於實現各種濾鏡效果。

使用 Python 包

Python 包的使用和模組的使用類似,是很自然的方式。以我們的 picture 包為例,若你想使用其中具體的模組,可以這樣做。

1
import picutre.filters.gaussblur

如此,你就匯入了 picture 包中 filters 子包中的 gaussblur 模組,你就能使用高斯模糊模組提供的功能了。具體使用方式,和使用模組也保持一致。

1
picture.filters.gaussblur.gaussblur_filter(input, output)

這看起來很繁瑣,因此你可能會喜歡用 from ... import ... 語句,脫去過多的名字限制。

1
from picture.filters import gaussblur

這樣一來,你就可以直接按如下方式使用高斯模糊這一濾鏡了。

1
gaussblur.gaussblur_filter(input, output)

__init__.py

之前簡單地介紹了 __init__.py 這個特殊的檔案,但未展開。這裡我們展開詳說。

首先的問題是,為什麼要設計 __init__.py,而不是自動地把任何一個目錄都當成是 Python 包?這主要是為了防止重名造成的問題。比如,很可能使用者在目錄下新建了一個子目錄,名為 collections;但 Python 有內建的同名模組。若不加任何限制地,將子目錄當做是 Python 包,那麼,import collections 就會引入這個 Python 包。而這樣的行為,可能不是使用者預期的。從這個意義上說,設計 __init__.py 是一種保護措施。

接下來的問題是,__init__.py 具體還有什麼用?

首先來說,__init__.py 可以執行一些初始化的操作。這是因為,__init__.py 作為模組檔案,會在相應的 Python 包被引入時首先引入。這就是說,import picture 相當於是 import picture.__init__。因此,__init__.py 中可以保留一些初始化的程式碼——比如引入依賴的其他 Python 模組。

其次,細心的你可能發現,上一小節中,我們沒有介紹對 Python 包的 from picture import * 的用法。這是因為,從一個包中匯入所有內容,這一行為是不明確的;必須要由包的作者指定。我們可以在 __init__.py 中定義名為 __all__ 的 Python 列表。這樣一來,就能使用 from picture import * 了。

具體來說,我們可以在 picture/__init__.py 中做如下定義。

__init__.py
1
2
3
import collections          # import the built-in package

__all__ = ["formats", "filters"]

此時,若我們在使用者模組中 from picture import *,則首先會引入 Python 內建的 collections 模組,而後引入 picture.formats 和 picture.filters 這兩個 Python 子包了。

在包內使用相對層級引用其他模組

細心的你應該已經發現,在引入 Python 包中的模組時,我們用句點 . 代替了斜線(或者反斜線)來標記路徑的層級(實際上是包和模組的層級)。在 Python 包的內部,我們也可以使用類似相對路徑的方式,使用相對層級來簡化包內模組的互相引用。

比如,在 gaussblur.py 中,你可以通過以下四種方式,引入 boxblur.py,而它們的效果是一樣的。

gaussblur.py
1
2
3
4
import boxblur
from . import boxblur
from ..filters import boxblur
from .. import filters.boxblur as boxblur