跳至主要内容

模組與套件

模組與套件

在日常生活中,當你的個人收藏品越來越多的時候,你可能會想買一些箱子、櫃子來整理或分類。當箱子、櫃子越來越多的時候,有預算的話你說不定還會開始買貨櫃或倉庫(?)來放這些箱子、櫃子,可以更方便的找到想要找的東西。

寫程式也是,當函數越寫越多、功能越來越複雜,你可能會把想把一些相關或功能類似的函數放在一起,Python 的模組與套件的設計可以讓我們用簡單的方式管理這些程式碼。

什麼是模組?​

「模組(Module)」是一個 Python 檔案,裡面可能會包含一些函數、變數和類別。透過模組的設計,可以讓程式碼更有組織性,也更容易維護。另外一個常會跟模組一起看到的名詞叫做「套件(Package)」,如果說剛才講到的模組是一個檔案的話,那麼套件就是一個目錄、資料夾的概念。一個套件裡面可以放很多的模組,或是放更多的子套件裡面再放更多的模組,基本上就是個像檔案系統一樣的結構,一個目錄裡能放很多檔案以及更多的子目錄一樣,待會就會看到實際的例子。

模組這個名詞聽起來有點抽象,或是感覺像什麼複雜的資料結構,但在 Python 的模組就是一個檔案,在這個檔案裡包含了一些資料,可以給其他函數或模組使用。Python 的模組也不一定只能用 Python 寫,也有的模組是用 C 語言寫的,然後在執行時候再動態的載入。

但不管是用什麼程式語言寫,之所以會把程式碼分在不同的檔案或模組,都是希望讓程式碼更有組織性,或是把功能類似的函數放在同一個地方,維護起來也更容易。把程式碼分成不同的模組之後,不管是自己要用或是給其他人使用,只要把這些程式碼「匯入」就可以了。

使用模組​

在 Python 中,要使用模組的話,主要是透過 import 關鍵字,這個關鍵字可以讓我們把模組匯入到目前的程式碼中,這樣就可以使用這個模組裡的東西了。

匯入模組​

在 Python 匯入模組主要是使用 import 關鍵字,例如我們之前曾經介紹過的數學模組 math:

>>> import math

模組匯入成功的話不會有任何反應,但如果匯入失敗,例如模組名字打錯了,或是根本沒有安裝過這個模組,就會出現錯誤訊息:

>>> import helloworld
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'helloworld'

錯誤訊息滿明顯的,就是說現在的環境裡並沒有叫做 helloworld 的模組。

問題是,到底 Python 是去哪裡找這些所謂的模組呢?這就要講到 Python 的模組搜尋路徑了。我們進到 REPL 環境進行以下操作:

>>> import sys
>>> sys.path

sys 模組是 Python 內建的模組,所以不需要另外安裝就能使用,可以看到這裡同樣也是使用 import 的方式把它匯入。在 sys 模組裡有不少跟系統相關的東西,其中 path 變數存放了 Python 搜尋模組的路徑。這個路徑是一個串列,我稍微整理排版過之後如下:

[
'',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python312.zip',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python3.12',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python3.12/lib-dynload',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python3.12/site-packages'
]

這個路徑可能會因為作業系統或 Python 版本不同而有些差異,最前面的空字串 '' 表示 Python 會先在當前目錄找看看有沒有這個模組,如果找到就載入,如果沒找到就繼續照著順序依序往下找,最後一個找的地方是 Python 安裝目錄下的 site-packages。萬一這整串都沒找到,就會出現剛才的 ModuleNotFoundError 錯誤訊息。

模組搜尋的路徑是一個串列結構,它是可以被修改的,事實上我們前面介紹過的虛擬環境,不管是 venv 還是功能更完整的 Poetry,當啟動虛擬環境的時候,你可以去觀察 sys.path 印出來的結果,會發現虛擬環境的目錄也會被加進 sys.path 串列的最後面,變成模組搜尋的路徑之一,這也正是這些虛擬環境套件之所以能做到隔離環境的原因之一。

因為 Python 預設會先從當前的目錄開始找模組,所以如果我們有自己寫的模組,只要把這個模組放在當前目錄,就可以直接匯入使用了。我們就來動手做看看,我這裡有兩個檔案,分別叫做 app.py 以及 greeting.py,我把這兩個檔案擺放在同一個目錄,我先在 greeting.py 裡寫上以下內容:

檔案:greeting.py
def helloworld():
print("Hello World")

def hi():
print("Hi!")

def hey():
print("Hey!")

def bye():
print("Bye!")

這些函數非常簡單也沒什麼營養,基本上就是一堆打招呼的函數。這個 greeting.py 檔案在 Python 我們就可以說它是一個模組。接下來如果我想在 app.py 裡使用這個模組,我可以在 app.py 裡這樣寫:

檔案:app.py
import greeting

import 關鍵字表示要匯入一個模組,這裡的 greeting 就是我們自己剛剛寫的模組。Python 會先在當前目錄找看看有沒有這個模組(也就是 greeting.py 檔案),找到之後就會把這個模組的內容載入,接下來就可以使用這個模組裡的東西了。注意在 import 的時候,greeting 不需要使用引號包起來,而且也不需要寫副檔名 .py,像這樣:

import "greeting"   # 這不行
import greeting.py # 這也不可以

硬是加上引號或附檔名會造成語法錯誤,Python 很聰明的,它會自動找到並載入我們想要的檔案,找不到也會透過錯誤訊息讓我們知道。模組匯入後就可以使用這個模組裡的東西了,使用的方式就是連名帶姓的呼叫模組以及函數的名稱,中間用 . 連接:

檔案:app.py
import greeting

greeting.helloworld()
greeting.hi()

這樣就行了。這裡可以匯入的不是只有函數,就算只是個簡單的變數也行,只要是在模組裡的東西,都可以這樣使用。

再繼續往下看之前,我想來看看這個匯進來的 greeting 是個什麼東西,來把它印出來看看:

檔案:app.py
import greeting

print(greeting)
# 印出 <module 'greeting' from '/demo/greeting.py'>

print(type(greeting))
# 印出 <class 'module'>

可以看到我們透過 import 關鍵字匯進來的是個 module 物件,還能隱約看的出來這個模組是寫在哪個檔案裡,我們回到 REPL 來看看內建的 sys 模組:

>>> import sys
>>> sys
<module 'sys' (built-in)>

>>> type(sys)
<class 'module'>

看的出來 sys 同樣也是個 module 物件,不過它的來源是寫著 (built-in),這表示 sys 模組是 Python 的一部分,這些模組跟著 Python 一起出廠,不需要另外安裝就能使用。

回到剛才的 greeting 模組,在 Python 如果我們想要看看某個物件身上有哪些東西,可以透過那個物件身上的 __dict__ 屬性,或是直接使用內建函數 dir() 來查看。在 Python 很多東西都是物件,模組也是,所以:

檔案:app.py
import greeting

print(dir(greeting))

# 印出
# ['__builtins__', '__cached__', '__doc__', '__file__', '__loader__',
# '__name__', '__package__', '__spec__', 'bye', 'helloworld', 'hey', 'hi']

除了那些兩個底線開頭的特別屬性外,可以看到我們自己定義的 bye、helloworld、hey、hi 都在這裡面了,這也是為什麼當我們執行 greeting.hi() 的時候可以正常運作的原因。是說,如果知道 greeting 是個物件,那表示我們也可以在上面隨便加屬性,例如:

import greeting

greeting.yo = lambda: print("Yo!")
greeting.yo() # 印出 "Yo!"

這可以正常執行,但這沒什麼意義也不是什麼好習慣,我只是想透過這個例子讓大家知道模組本身其實就只是個物件而已,沒什麼特別的。

現在 greeting.py 跟 app.py 這兩個檔案都在同一個目錄裡,不過隨著模組越來越多,總不能把全部的雞蛋都放在同一個籃子裡,這時候可以用「套件(Package)」來幫模組做分類。套件這名字好像聽起來很厲害的樣子,但其實就是個目錄而已。我可以手動建立一個 utils 目錄,然後把剛才的 greeting.py 檔案移到這個目錄裡,現在的目錄結構看起來像這樣:

├── utils
│   └── greeting.py
└── app.py

這樣在匯入模組的時候,需要把套件的名字,也就是目錄名稱也加進去:

檔案:app.py
import utils.greeting

utils.greeting.hey()

在這裡我們會說 utils 是個套件,而 greeting 是個模組。題外話,所以在 Python 所謂的套件就是資料夾而已嗎?也不全然是,但現在可以先這樣理解就好,晚點會看到我為什麼這樣說。匯入模組之後,如果要呼叫模組裡的 hey() 函數,一樣是連名帶姓的把套件跟模組名稱帶上。覺得囉嗦嗎?我們可以幫匯入的模組取個小名:

檔案:app.py
import utils.greeting as g
g.hey()

使用 as 關鍵字可幫原本的 utils.greeting 取個小名,但這樣一來就不能再透過 utils.greeting 來取用 hey() 函數了。

另外要提醒一件有點重要但又不是那麼重要的事,就是大家別要被「匯入」這個動作給誤導了,在 Python 匯入模組的時候,一開始的確是去讀取模組相對應的檔案沒錯,但讀完之後 Python 會直接執行它。所以如果我在 greeting.py 檔裡面試著加上一行 print("Hello Kitty") 的話,就算只有匯入模組然後什麼事都不做,也會看到印出 "Hello Kitty" 的結果,正是那個模組被執行的效果。

也就是說,在 Python 所謂的匯入模組,其實就是執行那個模組裡的所有的程式碼!

只匯入部份功能​

跟 import 還有另一個搭配的關鍵字叫做 from,這個關鍵字可以讓我們只匯入模組裡的某些功能,而不是整個模組。以剛才的例子可以這樣改寫:

from utils.greeting import hey
hey()

語法其實還算滿清楚的,就是從 utils.greeting 這個模組裡匯入 hey() 函數,所以在目前的 Scope 裡就會有 hey() 函數可以用。要注意的是這裡我們執行 hey() 函數的時候不能也不需要連模組名稱一起使用,否則會出現錯誤訊息,畢竟我們這裡就只是從某個模組匯入了 hey() 這一個函數而已。

如果要一次要從同一個模組匯入多個東西,可以使用逗號分開:

from utils.greeting import hey, hi, helloworld

其實還有個更快(或說更偷懶?)的方式:

from utils.greeting import *

後面的 * 表示要匯入 utils.greeting 模組裡所有的東西,雖然這樣寫起來比較短,但除非你知道你在做什麼,否則不建議這樣做,因為這樣會不知道匯入哪些東西,而且當我們執行 hey() 函數的時候,會比較不容易到底這個函數是從哪來的。

你可能會認為,使用 from ... import ... 的寫法應該只會匯入指定的程式碼,不會讀取整個模組。事實上 Python 還是得把整個模組讀進來並且執行整個檔案,只是最後 Python 會從模組裡挑選指定的名稱(變數、函數、類別等)到當前的 Scope 裡而已。

重複匯入模組?​

現在大家知道 import 可以匯入模組,但如果同一個模組匯入很多次會發生什麼事?我們來做個簡單的實驗。首先,我先在 greeting.py 的最後一行加上一行:

檔案:utils/greeting.py
def helloworld():
print("Hello World")

# ... 略 ...

print("importing greeting.py")

這樣當我們匯入 greeting.py 的時候就會印出 importing greeting.py 字樣。接著我直接到 app.py 裡,試著匯入兩次 greeting 模組:

import utils.greeting  # 第一次匯入
import utils.greeting # 再次匯入

執行之後會發現只有印出一次 importing greeting.py。這是因為 Python 會記住已經匯入過的模組也就是同一個模組只會被匯入一次,後續再次匯入相同模組的時候就當做沒這回事了。甚至改成這樣也一樣:

import utils.greeting
from utils import greeting
from utils.greeting import hi, hey, bye

即使用不同的方式匯入同一個模組,Python 還是會視為同一個模組,所以還是只會匯入一次。有興趣想知道更多細節的話,可以透過 sys 模組來看看有哪些模組被匯入:

import sys
import utils.greeting
from utils import greeting
from utils.greeting import hi, hey, bye

print(sys.modules)

執行之後會得到一個字典,我省略一些比較不重要的,排版整理一下:

{
'sys': <module 'sys' (built-in)>,
'builtins': <module 'builtins' (built-in)>,
# ... 略 ...
'utils': <module 'utils' (namespace) from ['/demo/utils']>,
'utils.greeting': <module 'utils.greeting' from '/demo/utils/greeting.py'>
}

可以在這個字典看到 utils.greeting 這個 Key,正是指向我們匯入的那個檔案。

匯入模組的位置​

大部份時候 import 語法可能都是寫在檔案的最上面,但如果要寫在函數裡面也是可以的:

def hello():
from utils.greeting import hi, hey
print(locals())

hello()

在函數裡的 import 語法是在被呼叫的時候才會執行,而且在函數裡匯入的東西,只有在函數裡有效,也就是會變成在這個函數裡的區域變數,有興趣的話可以用 locals() 函數觀察看看。因為是區域變數,所以在函數執行結束後就都會跟著消失。

匯入同名函數​

是說,如果我在原本的程式碼裡就已經有一個叫做 hey() 的函數,然後我也剛好從別的模組使用 from ... import ... 匯入同名的 hey() 函數,會發生什麼事?

from utils.greeting import hey

def hey():
print("Hey! in app.py")

hey() # 這會發生什麼事?

因為 def hey() 出現的比 import 還晚,所以會把匯入的 hey() 函數給蓋掉了,結果會印出 Hey! in app.py。但如果調整一下順序:

def hey():
print("Hey! in app.py")

from utils.greeting import hey

hey() # 這又會發生什麼事?

這樣反過來就是 def hey() 定義的函數被蓋掉了。所以,如果遇到這種同名的情況該怎麼處理?有幾種方式:

1. 重新命名​

這就跟我們小時候讀的黑羊白羊要過橋的故事一樣,如果有人願意先退讓是最好,衝突的名字如果有一邊願意改的話當然是最簡單的解法了,不過通常發生這種情況的時候,被匯入的模組大概沒辦法改,只能選擇改自己寫的函數。

2. 使用別名​

我們剛才介紹到的 as 關鍵字可以幫匯入的模組取個別名,這個特性也可以用在匯入的函數上:

from utils.greeting import hey as heyhey

def hey():
print("Hey! in app.py")

hey()
heyhey()

這樣兩個函數就可以和平共處了。

3. 使用完整路徑​

如果不想改名也不想使用別名,可以選用完整路徑來呼叫函數:

import utils.greeting

def hey():
print("Hey! in app.py")

hey()
utils.greeting.hey()

能有點隱私嗎?​

在 Python 的模組沒有所謂的隱私,所有的東西都是公開的,只要在模組裡的東西一定都能匯的進來。不過有個小技巧,就是在變數名稱前面加上一個底線 _,這樣在使用 import * 的時候,就會自動被跳過:

檔案:utils/greeting.py
def helloworld():
print("Hello World")

# ... 略 ...

def _welcome():
print("Welcome!")

如果使用 import * 的話:

from utils.greeting import *

print(globals())

用 globals() 就會發現底線開頭的 _welcome() 函數沒有被匯入進來。不過這只是個不成文的慣例,硬是要匯入的話 Python 並不會說什麼:

from utils.greeting import _welcome
_welcome() # 還是可以正常運作

同名模組​

大家看看這段程式碼,你覺得我在做什麼?

import math

你現在應該會說這是在匯入 Python 的內建的數學模組 math,但如果我在同一個目錄底下,寫一個 math.py 然後再執行一次一樣的程式碼,這行 import 會發生什麼事?執行之後發現不會發生任何錯誤,但你匯入的數學模組就不是內建的 math 了,而是自己寫的 math.py。為什麼?還記得剛剛前面我們講到的 sys.path 嗎?Python 在搜尋模組的時候,會先從當前目錄開始找,所以當我們在同一個目錄底下放一個名為 math.py 的檔案,Python 會先找到這個檔案,而不是內建的 math 模組。

被匯入 vs 直接執行​

現在我們知道一個 Python 的模組可以是一個 .py 檔案,既然是一個 .py 檔,我們也能請 Python 直接來執行這個檔案,例如剛才的 greeting.py:

$ python utils/greeting.py

這完全沒問題。如果我想在模組裡寫一些簡單的程式碼來驗證函數的功能是不是正確,例如我在 utils 裡另外新增一個 math.py 檔案,內容如下:

檔案 utils/math.py
def add(a, b):
return a + b

def sub(a, b):
return a - b

print(add(1, 2) == 3) # True
print(sub(3, -2) == 5) # True

我在最後面加了兩行簡單的測試程式碼,直接請 Python 執行這個檔案,不出意外的話應該都可以印出 True 的結果,代表這兩個函數的運作結果正如我們所預期的。雖然這樣寫沒什麼問題,但當這個 math.py 被其他程式匯入的時候,前面有講到匯入模組其實就是執行這個檔案,所以這兩行測試程式碼也會被執行然後印出來,這可能不是我們想要的結果。在 Python 預設的全域變數裡,有個叫做 __name__ 的特殊變數,這個變數在不同的情況下會有不同的值,我先在剛才的 math.py 最後面加上一行:

檔案 utils/math.py
def add(a, b):
return a + b

# ... 略 ...

print(__name__)

如果請 Python 直接執行它會印出 "__main__" 字串,這是 Python 預設的主程式名稱,代表這個檔案是直接被執行的。如果我們把這個檔案當模組匯入的話,__name__ 會被設定成模組的名稱,也就是 "utils.math"。利用這個設計,我們可以把剛才最後兩行的測試程式碼前面加上一個簡單 if 判斷,整個檔案改成這樣:

def add(a, b):
return a + b

def sub(a, b):
return a - b

if __name__ == "__main__":
print(add(1, 2) == 3) # True
print(sub(3, -2) == 5) # True

這種 __name__ == "__main__" 的手法在 Python 很常見,主要就是用來區分這個檔案是當做模組匯入還是被直接執行。像上面這樣的寫法,如果 math.py 被當模組匯入的時候,最後兩行測試程式碼就不會執行了。

套件與模組​

前面我們講到套件在 Python 是一個目錄,而裡面的每個 Python 檔案就是一個一個的模組。嗯...這講法不完全正確,沒關係,我們先繼續看下去。我現在的目錄結構如下:

├── utils
│   ├── greeting.py
│   └── math.py
└── app.py

然後我直接進到 REPL 裡來做幾個實驗:

>>> import utils

同樣是 import,但這次我只匯入 utils,也就是只有匯入套件,不是匯入套件裡的模組。那麼這個匯入的 utils 是什麼呢?我們來看看:

>>> utils
<module 'utils' (namespace) from ['/demo/utils']>

從顯示結果看起來,套件本身也是模組,它是一個可以用來整理、組織其他套件或模組的模組。有點繞口,但是它跟一般的模組不太一樣,它就只是個模組的容器,用內建函數 dir() 巡一下就會發現這傢伙肚子裡沒什麼料:

>>> dir(utils)
['__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__']

我們拿它來跟之前寫的 math.py 比一下:

>>> import utils.math
>>> dir(utils.math)
['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'add', 'sub']

可以看的出來比較大的差異在於套件有 __path__ 屬性而一般的模組沒有。關於套件,在官方文件裡有這麼一句話:

any module that contains a __path__ attribute is considered a package.

也就是說,模組只要有 __path__ 屬性就可以被當做套件。

如果說套件也是模組,那麼套件的程式碼應該寫在哪裡?這裡還少了一個關鍵的檔案,它的名字比較特殊,叫做 __init__.py。有些開發工具會在新增套件的時候自動幫我們建立這個檔案,不過就算沒有也沒關係,我們自己手動建立就行了,沒什麼大不了的。我就把這個檔案放在 utils 目錄裡,現在的目錄結構看起來應該是這樣:

├── utils
│   ├── __init__.py <-- 在這裡
│   ├── greeting.py
│   └── math.py
└── app.py

在比較早期的 Python 版本(3.2 之前),所謂的套件不光就只是目錄而已,Python 有規定要在這個目錄裡放一個 __init__.py 檔案,這個目錄才會被 Python 當做套件,裡面的模組也才能被 import 給匯入使用。不過在 Python 3.3 之後取消了這個限制,即使沒有這個檔案,只要有相對應的目錄就能被當做套件看待,所以大家應該可以看到在前面範例就算沒有這個檔案,一樣可以匯入模組。

__init__.py 檔案大部份時候可能都是空的,不要看這個檔名很奇怪,它也是個 Python 的程式檔,所以可以在裡面寫一些 Python 程式碼。當這個套件或這個套件裡的任何一個模組被匯入的時候,這個檔案都會被載入並執行,例如我在 __init__.py 裡試著用 print() 函數印點東西出來時看看:

檔案:utils/__init__.py
print("utils imported")

這樣不管是匯入 utils 套件本身,或是匯入在 utils 套件裡的 utils.math 或 utils.greeting 模組,這個檔案都會被執行,都會印出 "utils imported"。例如我試著匯入 utils.greeting 模組:

>>> import utils.greeting
utils imported
>>> import utils.math

可以看到第一次匯入 utils.greeting 模組的時候,utils 套件的 __init__.py 會被執行一次,不過接著匯入同一個套件底下另一個模組 utils.math 的時候,會發現 __init__.py 這個檔案不會再被執行一次。這是因為 Python 會把所有匯入的模組(包括套件)都記錄在 sys.modules 這個字典裡,當 Python 要匯入模組之前,會先檢查看看這個字典裡是不是已經有匯入過了,如果有就不會再次匯這個模組了:

>>> import sys
>>> sys.modules
{
'sys': <module 'sys' (built-in)>,
...略...
'utils': <module 'utils' from '/demo/utils/__init__.py'>,
'utils.greeting': <module 'utils.greeting' from '/demo/utils/greeting.py'>
}

這個 __init__.py 在子目錄也有效果,例如我讓現有的 utils 套件裡面再加個 loggers 套件,也就是再新增一個目錄順便也在裡面加一個 __init__.py 檔案跟隨便一個模組 simple_logger.py ,現在目錄結構看起來應該是這樣:

├── utils
│   ├── loggers
│   │   ├── __init__.py <-- 在這裡
│   │   └── simple_logger.py
│   ├── __init__.py
│   ├── greeting.py
│   └── math.py
└── app.py

在 loggers 目錄裡的 __init__.py 檔案我也同樣簡單的印點東西出來看看:

檔案 utils/loggers/__init__.py
print("loggers imported")

這樣當我匯入 utils.loggers.simple_logger 模組的時候,就會看到 utils 套件的 __init__.py 跟 utils.loggers 套件的 __init__.py 都會被載入並執行:

>>> import utils.loggers.simple_logger
utils imported
loggers imported

當然 __init__.py 不是只有用來印東西而已,當匯入套件或模組的時候,這個檔案會被執行,而且它也是個模組,所以任何在這個檔案裡定義的變數、函數、類別都可以像其他模組一樣被拿來使用,例如我在 utils/__init__.py 裡定義一個變數:

檔案 utils/__init__.py
heroes = ["悟空", "魯夫", "光之美少女"]

utils 是個套件但也是個模組,所以這樣寫的話,我們就可以在 utils 這個模組裡取用 heroes 變數,用起來就跟一般的模組一樣,例如:

>>> from utils import heroes
>>> heroes
['悟空', '魯夫', '光之美少女']

或是在其他檔案裡也可以使用這個變數,例如我在 greeting.py 檔案裡這樣寫:

檔案 utils/greeting.py
def helloworld():
print("Hello World")

# ... 略 ...

def print_heroes():
from utils import heroes
print(heroes)

用起來的手感就跟一般的模組或函數一樣:

>>> from utils.greeting import print_heroes
>>> print_heroes()
['悟空', '魯夫', '光之美少女']

所以,套件裡面有沒有 __init__.py 檔案都一樣嗎?看起來好像差不多但其實不一樣,不過這可能有點難理解,各位要把它們當做是一樣的東西也沒關係,可以繼續往下個章節前進。不過如果對這部份的細節有興趣,就繼續往下看這個不算太重要的冷知識吧!

《冷知識》不同的套件​

現行 Python 版本的套件其實有兩種,一種是按照 Python 3.2 之前的規定,目錄裡面有 __init__.py 檔案,這種套件叫做「常規套件(Regular Package)」,另一種是在 Python 3.3 之後不需要 __init__.py 只要有對應的目錄也能當做套件的叫做「命名空間套件(Namespace Package)」。

為了給大家看看這兩種套件有什麼差別,我在剛才的專案裡新增一個空的目錄叫做 tools,裡面空空的什麼都不放,接著進到 REPL 環境,來跟原本的 utils 比較一下有什麼不同:

>>> import tools, utils
>>> tools
<module 'tools' (namespace) from ['/demo/tools']>
>>> utils
<module 'utils' from '/demo/utils/__init__.py'>

跟剛才的 utils 比起來,光是只有空目錄的 tools 就是一個命名空間套件,後面的 (namespace) 字樣就是這個意思,而 utils 因為有 __init__.py 所以在 Python 是一個常規套件,可以看到這個套件的內容正是指向那個檔名有點怪的 __init__.py 檔案。

前面曾經提到,Python 在匯入套件的時候,會依照 sys.path 的順序逐個比對有沒有符合的目錄名稱,如果找到符合而且在這個目錄裡有 __init__.py 檔案的話,Python 就會用常規套件的方式處理它,如果沒有,就會用命名空間套件的方式處理。

但說到底,這兩種套件除了有沒有 __init__.py 的差別之外,比較大的差別就是命名空間套件的套件名稱是可以「共享」的,但常規套件不行。什麼意思?為了簡化說明,我開一個全新的專案做個示範,目錄跟檔案結構如下:

├── hello
│   └── utils
│   └── mm.py
└── world
└── utils
└── nn.py

在 hello 跟 world 目錄裡都開了一個叫做 utils 的目錄,裡面分別放了一個 mm.py 跟 nn.py 檔案,因為檔案內容不是重點就先忽略它。接著進到 REPL 來做一些事:

>>> import sys
>>> sys.path.extend(['./hello', './world'])
>>> import utils
>>> utils
<module 'utils' (namespace) from ['/demo/hello/utils', '/demo/world/utils']>
>>> import utils.mm
>>> import utils.nn

前面提到 Python 在匯入模組的時候,會去依照 sys.path 的順序找符合的標的物,所以我刻意把 hello 跟 world 目錄加到 sys.path 裡,這樣到時候匯入的時候也會找這兩個地方。接著我匯入 utils 套件,這時候 Python 會發現有兩個目錄都符合這個名稱,所以這個套件的名字就會是一個「共享」的命名空間,而且可以看到 utils 這個套件的路徑是兩個目錄的組合。接著我分別匯入 utils.mm 跟 utils.nn 這兩個模組,都可以匯入成功。也就是說,雖然這兩個模組的檔案是放在不同的目錄,但因為它們的命名空間是一樣的,所以 Python 可以把它們當成是同一個套件。

接下來我故意在 world/utils 目錄裡加上 __init__.py,現在的結構如下,讓它變成常規套件:

├── hello
│   └── utils
│   └── mm.py
└── world
└── utils
├── __init__.py <-- 加在這裡
└── nn.py

接著再做一樣的操作:

>>> import sys
>>> sys.path.extend(['./hello', './world'])
>>> import utils
>>> utils
<module 'utils' from '/demo/world/utils/__init__.py'>
>>> import utils.nn
>>> import utils.mm
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'utils.mm'

可以看的出來那個 utils 因為有遇到 __init__.py,所以 Python 會把它當做一般的常規套件處理。當我匯入 utils.nn 的時候沒問題,但因為現在 utils 是個常規套件,它的內容是指向 world 目錄裡的 utils 而不是 hello 的 utils,所以匯入 utils.mm 就會出現找不到模組的錯誤訊息。

這應該就是常規套件跟命名空間套件比較大的差異了。命名空間套件比較常見的應用場景,通常是遇到套件的內容太多但對一般使用者來說又不一定全部都需要用的上,或是可能有許多不同套件名稱但又想要讓它們在同一個命名空間裡讓它們看起來有點關連。

既然 Python 3.3 之後可以不用寫 __init__.py 也可以有套件效果,我們還需要手動新增 __init__.py 檔案嗎?或是,這個檔案還需要留著嗎?雖然我沒有做市場調查,但我就在公堂之上大膽假設一下,應該 99% 以上的開發者在使用套件的時候都是常規套件,所以加上個 __init__.py 也是很正常的。除此之外,有些測試用的框架或套件只會去掃描有包含 __init__.py 的目錄,否則萬一專案裡有像前端的 node_modules 這種龐然大物是要掃描到什麼時候。所以,除非你很確定你是要建立一個命名空間套件,而且你也很清楚自己在做什麼,否則即使 __init__.py 這個檔案是空的,也建議在建立套件的時候新增或保留它。想要知道更多關於命名空間套件的細節,可以參考 PEP 420 的說明。

《冷知識》壓縮檔也可以​

模組或套件通常是以檔案或目錄的方式存在,但其實 Python 也能直接匯入壓縮檔裡的模組或套件。只要把壓縮檔加到 sys.path 串列裡讓 Python 的匯入模組的時候找的到,就可以匯入壓縮檔裡面的模組或套件了。

例如我把剛剛的 hello 跟 world 兩個目錄先壓縮在一個名為 my_package.zip 的壓縮檔,然後把這個檔案加到套件搜尋路徑的 sys.path 串列裡:

>>> import sys
>>> sys.path.append('./my_package.zip')
>>> import hello
>>> import world
>>> hello
<module 'hello' (namespace) from ['./my_package.zip/hello']>
>>> world
<module 'world' (namespace) from ['./my_package.zip/world']>

可以看的出來,即使壓縮成 zip 檔,Python 也可以直接匯入壓縮檔裡的模組或套件,這樣就可以把模組或套件打包成一個壓縮檔,不用上架到 PyPI 網站也能整包丟給其他有需要的人。事實上 Python 自己也有做類似的設定,如果再看一下 sys.path 串列裡的內容

>>> import sys
>>> sys.path
[
'',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python312.zip',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python3.12',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python3.12/lib-dynload',
'/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python3.12/site-packages'
]

就會發現 Python 預設的搜尋路徑上就有一個 .zip 檔在裡面,而且順序還很前面。

另外,雖然我們可以動態的對 sys.path 進行修改,但也可以利用環境變數的設定來加入套件搜尋路徑,例如在 Linux 或 macOS 上可以透過 PYTHONPATH 環境變數來設定套件搜尋路徑:

$ PYTHONPATH=my_package.zip python
>>> import sys
>>> sys.path
['', '/demo/my_package.zip', '...略...', '/Users/kaochenlong/.pyenv/versions/3.12.7/lib/python3.12/site-packages']
>>> import hello
>>> import world

以上面這個範例來說,我直接透過 PYTHONPATH 環境變數把壓縮檔 my_package.zip 加進來,這樣就不用在程式裡手動修改 sys.path 了。如果是在 Windows 的 Powershell 環境的話,可以透過 $env:PYTHONPATH 來設定環境變數:

PS C:\demo> $env:PYTHONPATH="my_package.zip"
PS C:\demo> python
>>> import sys
>>> sys.path
['', 'C:\\demo\\my_package.zip', '...略...']
>>> import hello
>>> import world

也可以正常運作,這應該比改 sys.path 簡單多了。

工商服務

想學 Python 嗎?我教你啊 :)

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