搭建積木:Python 模塊化-day11

搭建積木:Python 模塊化

寫在前面

你好,我是禪墨!

這是基礎版塊的最後一節。本來計劃14講,在壓縮之後成了11講。到目前爲止,你已經掌握了 Python 這一門當代武功的基本招式和套路,走出了新手村,看到了更遠的世界,有了和這個世界過過招的衝動。

於是,你可能開始嘗試寫一些不那麼簡單的系統性工程,或者代碼量較大的應用程序。這時候,簡單的一個 py 文件已經過於臃腫,無法承擔一個重量級軟件開發的重任。今天的主要目的,就是化繁爲簡,將功能模塊化、文件化,從而可以像搭積木一樣,將不同的功能,組件在大型工程中搭建起來。

簡單模塊化

說到最簡單的模塊化方式,你可以把函數、類、常量拆分到不同的文件,把它們放在同一個文件夾,然後使用 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 時,這種方式已經足夠我完成項目了,畢竟,很多學校項目的文件數只有個位數,每個文件代碼也只有幾百行,這種組織方式能幫我順利完成任務。但是在大廠,一個項目組的 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,整個公司都只有一個代碼倉庫,全公司的代碼都放在這個庫裏。我剛對此感到很困惑,也很新奇:

  • 這樣做似乎會增大項目管理的複雜度吧?
  • 是不是也會有不同組代碼隱私泄露的風險呢?

後來查證,我才發現了這種代碼倉庫獨有的幾個優點。

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

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

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

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

在做項目的時候,雖然你不可能把全世界的代碼都放到一個文件夾下,但是類似模塊化的思想還是要有的——那就是以項目的根目錄作爲最基本的目錄,所有的模塊調用,都要通過根目錄一層層向下索引的方式來 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 時執行。

寫在後面

OK! 我真的是堅持下來了!
今天這篇就是本階段的最後一篇了!
我一直在奔跑,希望你也是!
微信公衆號:興趣路人甲

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章