1. 程式人生 > 實用技巧 >13-搭建積木:Python模組化

13-搭建積木:Python模組化

簡單模組化

說到最簡單的模組化方式,你可以把函式、類、常量拆分到不同的檔案,把它們放在同一個資料夾,然後使用 from your_file import function_name, class_name 的方式呼叫。之後,這些函式和類就可以在檔案內直接使用了。

# utils.py

def get_sum(a, b):
    return a + b
# class_utils.py

class Encoder(object):
    def encode(self, s):
        return s[::-1]

class Decoder(object):
    def decode(self, s):
        return ''.join(reversed(list(s)))
# main.py

from utils import get_sum
from class_utils import *

print(get_sum(1, 2))

encoder = Encoder()
decoder = Decoder()

print(encoder.encode('abcde'))
print(decoder.decode('edcba'))

########## 輸出 ##########

3
edcba
abcde

我們來看這種方式的程式碼:get_sum() 函式定義在 utils.py,Encoder 和 Decoder 類則在 class_utils.py,我們在 main 函式直接呼叫 from import ,就可以將我們需要的東西 import 過來。

非常簡單。

但是這就足夠了嗎?當然不,慢慢地,你會發現,所有檔案都堆在一個資料夾下也並不是辦法。

於是,我們試著建一些子資料夾:

# utils/utils.py

def get_sum(a, b):
    return a + b
# utils/class_utils.py

class Encoder(object):
    def encode(self, s):
        return s[::-1]

class Decoder(object):
    def decode(self, s):
        return ''.join(reversed(list(s)))
# src/sub_main.py

import sys
sys.path.append("..")

from utils.class_utils import *

encoder = Encoder()
decoder = Decoder()

print(encoder.encode('abcde'))
print(decoder.decode('edcba'))

########## 輸出 ##########

edcba
abcde

而這一次,我們的檔案結構是下面這樣的:

.
├── utils
│   ├── utils.py
│   └── class_utils.py
├── src
│   └── sub_main.py
└── main.py

很容易看出,main.py 呼叫子目錄的模組時,只需要使用 . 代替 / 來表示子目錄,utils.utils 表示 utils 子資料夾下的 utils.py 模組就行。

那如果我們想呼叫上層目錄呢?注意,sys.path.append("..") 表示將當前程式所在位置向上提了一級,之後就能呼叫 utils 的模組了。

同時要注意一點,import 同一個模組只會被執行一次,這樣就可以防止重複匯入模組出現問題。當然,良好的程式設計習慣應該杜絕程式碼多次匯入的情況。在Facebook 的程式設計規範中,除了一些極其特殊的情況,import 必須位於程式的最前端

最後我想再提一下版本區別。你可能在許多教程中看到過這樣的要求:我們還需要在模組所在的資料夾新建一個 __init__.py,內容可以為空,也可以用來表述包對外暴露的模組介面。不過,事實上,這是 Python 2 的規範。在 Python 3 規範中,__init__.py 並不是必須的,很多教程裡沒提過這一點,或者沒講明白,我希望你還是能注意到這個地方。

整體而言,這就是最簡單的模組呼叫方式了。在我初用 Python 時,這種方式已經足夠我完成大學期間的專案了,畢竟,很多學校專案的檔案數只有個位數,每個檔案程式碼也只有幾百行,這種組織方式能幫我順利完成任務。

但是在我來到 Facebook後,我發現,一個專案組的 workspace 可能有上千個檔案,有幾十萬到幾百萬行程式碼。這種呼叫方式已經完全不夠用了,學會新的組織方式迫在眉睫。

接下來,我們就係統學習下,模組化的科學組織方式。

專案模組化

我們先來回顧下相對路徑和絕對路徑的概念。

在 Linux 系統中,每個檔案都有一個絕對路徑,以 / 開頭,來表示從根目錄到葉子節點的路徑,例如 /home/ubuntu/Desktop/my_project/test.py,這種表示方法叫作絕對路徑。

另外,對於任意兩個檔案,我們都有一條通路可以從一個檔案走到另一個檔案,例如 /home/ubuntu/Downloads/example.json。再如,我們從 test.py 訪問到 example.json,需要寫成 '../../Downloads/example.json',其中 .. 表示上一層目錄。這種表示方法,叫作相對路徑。

通常,一個 Python 檔案在執行的時候,都會有一個執行時位置,最開始時即為這個檔案所在的資料夾。當然,這個執行路徑以後可以被改變。執行 sys.path.append("..") ,則可以改變當前 Python 直譯器的位置。不過,一般而言我並不推薦,固定一個確定路徑對大型工程來說是非常必要的。

理清楚這些概念後,我們就很容易搞懂,專案中如何設定模組的路徑。

首先,你會發現,相對位置是一種很不好的選擇。因為程式碼可能會遷移,相對位置會使得重構既不雅觀,也易出錯。因此,在大型工程中儘可能使用絕對位置是第一要義。對於一個獨立的專案,所有的模組的追尋方式,最好從專案的根目錄開始追溯,這叫做相對的絕對路徑。

事實上,在 Facebook 和 Google,整個公司都只有一個程式碼倉庫,全公司的程式碼都放在這個庫裡。我剛加入 Facebook 時對此感到很困惑,也很新奇,難免會有些擔心:

  • 這樣做似乎會增大專案管理的複雜度吧?
  • 是不是也會有不同組程式碼隱私洩露的風險呢?

後來,隨著工作的深入,我才發現了這種程式碼倉庫獨有的幾個優點。

第一個優點,簡化依賴管理。整個公司的程式碼模組,都可以被你寫的任何程式所呼叫,而你寫的庫和模組也會被其他人呼叫。呼叫的方式,都是從程式碼的根目錄開始索引,也就是前面提到過的相對的絕對路徑。這樣極大地提高了程式碼的分享共用能力,你不需要重複造輪子,只需要在寫之前,去搜一下有沒有已經實現好的包或者框架就可以了。

第二個優點,版本統一。不存在使用了一個新模組,卻導致一系列函式崩潰的情況;並且所有的升級都需要通過單元測試才可以繼續。

第三個優點,程式碼追溯。你可以很容易追溯,一個 API 是從哪裡被呼叫的,它的歷史版本是怎樣迭代開發,產生變化的。

如果你有興趣,可以參考這篇論文:https://cacm.acm.org/magazines/2016/7/204032-why-google-stores-billions-of-lines-of-code-in-a-single-repository/fulltext

在做專案的時候,雖然你不可能把全世界的程式碼都放到一個資料夾下,但是類似模組化的思想還是要有的——那就是以專案的根目錄作為最基本的目錄,所有的模組呼叫,都要通過根目錄一層層向下索引的方式來 import。

明白了這一點後,這次我們使用 PyCharm 來建立一個專案。這個專案結構如下所示:

.
├── proto
│   ├── mat.py
├── utils
│   └── mat_mul.py
└── src
    └── main.py
# proto/mat.py

class Matrix(object):
    def __init__(self, data):
        self.data = data
        self.n = len(data)
        self.m = len(data[0])
# utils/mat_mul.py

from proto.mat import Matrix

def mat_mul(matrix_1: Matrix, matrix_2: Matrix):
    assert matrix_1.m == matrix_2.n
    n, m, s = matrix_1.n, matrix_1.m, matrix_2.m
    result = [[0 for _ in range(n)] for _ in range(s)]
    for i in range(n):
        for j in range(s):
            for k in range(m):
                result[i][k] += matrix_1.data[i][j] * matrix_2.data[j][k]

    return Matrix(result)
# src/main.py

from proto.mat import Matrix
from utils.mat_mul import mat_mul


a = Matrix([[1, 2], [3, 4]])
b = Matrix([[5, 6], [7, 8]])

print(mat_mul(a, b).data)

########## 輸出 ##########

[[19, 22], [43, 50]]

這個例子和前面的例子長得很像,但請注意 utils/mat_mul.py,你會發現,它 import Matrix 的方式是from proto.mat。這種做法,直接從專案根目錄中匯入,並依次向下匯入模組 mat.py 中的 Matrix,而不是使用 .. 匯入上一級資料夾。

是不是很簡單呢?對於接下來的所有專案,你都能直接使用 Pycharm 來構建。把不同模組放在不同子資料夾裡,跨模組呼叫則是從頂層直接索引,一步到位,非常方便。

我猜,這時你的好奇心來了。你嘗試使用命令列進入 src 資料夾,直接輸入 Python main.py,報錯,找不到 proto。你不甘心,退回到上一級目錄,輸入Python src/main.py,繼續報錯,找不到 proto。

Pycharm 用了什麼黑魔法呢?

實際上,Python 直譯器在遇到 import 的時候,它會在一個特定的列表中尋找模組。這個特定的列表,可以用下面的方式拿到:

import sys  

print(sys.path)

########## 輸出 ##########

['', '/usr/lib/python36.zip', '/usr/lib/python3.6', '/usr/lib/python3.6/lib-dynload', '/usr/local/lib/python3.6/dist-packages', '/usr/lib/python3/dist-packages']

請注意,它的第一項為空。其實,Pycharm 做的一件事,就是將第一項設定為專案根目錄的絕對地址。這樣,每次你無論怎麼執行 main.py,import 函式在執行的時候,都會去專案根目錄中找相應的包。

你說,你想修改下,使得普通的 Python 執行環境也能做到?這裡有兩種方法可以做到:

import sys

sys.path[0] = '/home/ubuntu/workspace/your_projects'

第一種方法,“大力出奇跡”,我們可以強行修改這個位置,這樣,你的 import 接下來肯定就暢通無阻了。但這顯然不是最佳解決方案,把絕對路徑寫到程式碼裡,是我非常不推薦的方式(你可以寫到配置檔案中,但找配置檔案也需要路徑尋找,於是就會進入無解的死迴圈)。

第二種方法,是修改 PYTHONHOME。這裡我稍微提一下 Python 的 Virtual Environment(虛擬執行環境)。Python 可以通過 Virtualenv 工具,非常方便地建立一個全新的 Python 執行環境。

事實上,我們提倡,對於每一個專案來說,最好要有一個獨立的執行環境來保持包和模組的純淨性。更深的內容超出了今天的範圍,你可以自己查資料瞭解。

回到第二種修改方法上。在一個 Virtual Environment 裡,你能找到一個檔案叫 activate,在這個檔案的末尾,填上下面的內容:

export PYTHONPATH="/home/ubuntu/workspace/your_projects"

這樣,每次你通過 activate 啟用這個執行時環境的時候,它就會自動將專案的根目錄新增到搜尋路徑中去。

神奇的 if __name__ == '__main__'

最後一部分,我們再來講講 if __name__ == '__main__' ,這個我們經常看到的寫法。

Python 是指令碼語言,和 C++、Java 最大的不同在於,不需要顯式提供 main() 函式入口。如果你有 C++、Java 等語言經驗,應該對 main() {} 這樣的結構很熟悉吧?

不過,既然 Python 可以直接寫程式碼,if __name__ == '__main__' 這樣的寫法,除了能讓 Python 程式碼更好看(更像 C++ )外,還有什麼好處嗎?

專案結構如下:

.
├── utils.py
├── utils_with_main.py
├── main.py
└── main_2.py
# utils.py

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

print('testing')
print('{} + {} = {}'.format(1, 2, get_sum(1, 2)))
# utils_with_main.py

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

if __name__ == '__main__':
    print('testing')
    print('{} + {} = {}'.format(1, 2, get_sum(1, 2)))
# main.py

from utils import get_sum

print('get_sum: ', get_sum(1, 2))

########## 輸出 ##########

testing
1 + 2 = 3
get_sum: 3
# main_2.py

from utils_with_main import get_sum

print('get_sum: ', get_sum(1, 2))

########## 輸出 ##########

get_sum_2: 3

看到這個專案結構,你就很清晰了吧。

import 在匯入檔案的時候,會自動把所有暴露在外面的程式碼全都執行一遍。因此,如果你要把一個東西封裝成模組,又想讓它可以執行的話,你必須將要執行的程式碼放在 if __name__ == '__main__'下面。

為什麼呢?其實,__name__ 作為 Python 的魔術內建引數,本質上是模組物件的一個屬性。我們使用 import 語句時,__name__ 就會被賦值為該模組的名字,自然就不等於 __main__了。更深的原理我就不做過多介紹了,你只需要明白這個知識點即可。

總結

今天這節課,我為你講述瞭如何使用 Python 來構建模組化和大型工程。這裡需要強調幾點:

  1. 通過絕對路徑和相對路徑,我們可以 import 模組;
  2. 在大型工程中模組化非常重要,模組的索引要通過絕對路徑來做,而絕對路徑從程式的根目錄開始;
  3. 記著巧用if __name__ == '__main__'來避開 import 時執行。

思考題

最後,我想為你留一道思考題。from module_name import *import module_name有什麼區別呢?