所謂“結構”就是使專案清楚地直接地乾淨地優雅地達到他的目的。我們要考慮如何最大限度的利用python的特性來寫乾淨、有效的程式碼。實際上,“structure”意味著寫乾淨的程式碼(邏輯關係,依賴關係明確),以及如何在檔案系統中組織檔案和資料夾。
哪個函式應該在哪個模組裡?專案的資料流是怎樣的?什麼特性和功能應該放在一起,什麼樣的應該隔離?廣義上說,通過回答這些問題你可以開始計劃成品是什麼樣子的。
在這一節,我們會仔細研究python的module(模組)和import(匯入)系統,因為它們是增強專案結構化的核心內容。然後我們討論如何構建可擴充套件可測試的可靠程式碼。
結構是關鍵
由於import和module是python掌控的,結構化一個Python專案相對比較容易。這裡所指的容易,意味著沒有很多約束而且python的模組匯入模型比較容掌握。因此,留給你的就剩下一些架構性的工作,編寫專案的不同模組並負責它們之間的互動。 Easy structuring of a project means it is also easy to do it poorly. Some signs of a poorly structured project include: 容易結構化的專案同樣意味著它的結構化容易做的不好。。結構性差的專案,其特徵包括:
- 多重且混亂的迴圈依賴關係:如果furn.py中你的類Table和Chair需要import worker.py中的Carpenter來回答例如table.isdomeby()這種問題,相返Carpenter類需要import Table和Chair類來回答carpenter.whatdo()這種問題,所以你有一個迴圈依賴項。在這種情況下你將不得不借助於一些不太可靠的技巧,比如在方法或函式內部使用import語句。
- 隱藏耦合:每一次改變Table的實現都會打破20個互不相關的測試用例,因為它打破了Carpenter的程式碼,所以需要小心操作來適應改變。這意味著在Carpenter的程式碼中你有太多關於Table的假設,反過來也是如此。
- 大量使用全域性狀態或上下文:彼此間不使用顯示傳遞(高度、寬度、型別、材料)引數,Table和Carpenter都依賴全域性變數,可以被修改而且被不同的引用修改。你需要仔細檢查所有訪問這些全域性變數的地方,來理解為什麼一個矩形變成一個正方形,並發現遠端模板程式碼也修改上下文,弄亂了桌子的尺寸引數。
- 麵條式程式碼:多層巢狀的if語句for迴圈,大量複製貼上程式碼,沒有適當分割的程式碼被稱為麵條式程式碼。python的具有意義的縮排(其最具爭議的特性之一)使它難以維持這種程式碼。所以好訊息是你也許看不到太多這種麵條式程式碼。
- python中更可能出現混沌程式碼:如果沒有適當的結構,它會包括幾百個相似的小邏輯塊,類或物件。如果你記不住在手頭的任務中你是否必須用FurnitureTable,AssetTable或Table甚至TableNew,你就可能陷入混沌的程式碼中。
模組
Python 的模組是最主要的抽象層之一,也算最自然的一個。抽象層允許我們把程式碼分成不同的部分,每部分包含著相關的資料和功能。
例如:一層控制使用者的行為的介面,而另一層處理低階別的資料操作。分離這兩層的最自然的方式是將所有介面功能重新組合到一個檔案裡,所有低階別操作在另一個檔案。在這種情況下,介面檔案需要import低階別操作的檔案。通過from … import 語句來完成。
只要你一用import語句,你就可以用這個module了。可以是內建的模組比如os和sys,可以是已經安裝到環境中的第三方模組,也可以是你專案的內部模組。
為了和編碼風格儲存一致,模組名要短,使用小寫字母,一定要避免使用特殊符號,如點(.),問號(?)。所以要避免的像my.spam.pu這樣的檔名!這種命名方式會干擾python查詢模組。
在my.spam.py 這個例子中,python想要在my資料夾中查詢 spam.py檔案,而這不是我們想要的。在python文件中還有一個關於應該如何使用點的例子。
你可以將模組命名為my_spam.py。儘管可以使用,但還是不應該經常在模組名中看到下劃線。
除了一些命名的限制,在將一個python檔案當做一個模組方面沒有什麼特別的要求。但是你需要理解import的機制來正確使用這一概念和避免一些問題。
具體而言,如果modu.py檔案和呼叫方在同一目錄中,import modu 語句將能尋找到適當的檔案。如果找不到他,python直譯器將在“path”中遞迴查詢,如果沒有找到將raise ImportError異常。
一旦發現了modu.py,python直譯器會在一個隔離的作用域內執行這個模組。任何modu.py中的頂級語句將被執行,包括其他import。函式和類的定義將被儲存在module的字典中。
然後,該模組的變數、函式和類將通過模組的名稱空間提供給呼叫方。在python中這是特別有用和強大的核心概念。
在很多語言中,包含檔案的指令的作用是:由前處理器找到檔案中的所有程式碼並複製到呼叫方的程式碼中。在Python是不同的:包含的程式碼被隔離在一個模組名稱空間中,這就意味著你一般不需要擔心包含的程式碼產生不良影響,例如覆蓋具有相同名稱的現有函式。
也可以通過用特殊語法的import語句來模擬更標準的行為:from modu import *
。普遍認為這是不好的做法。使用 `i使得程式碼難以閱讀並且使得依賴關係沒有進行足夠的劃分**.
用from modu import func
這種方式可以準確定位你想要imprint的函式並把它引入全域性名稱空間。比 import *
的危害小的多,因為它明確地顯示什麼被引入全域性名稱空間中,相比import modu
的唯一優勢是他可以少打點字。
非常糟糕
1 2 3 4 |
[...] from modu import * [...] x = sqrt(4) # sqrt是modu的? 內建的? 上面定義的? |
好一點
1 2 3 |
from modu import sqrt [...] x = sqrt(4) # sqrt如果不在中間定義那可能是modu的一部分 |
最好
1 2 3 |
import modu [...] x = modu.sqrt(4) # sqrt明顯是modu名稱空間的一部分 |
Code Style一節中提到可讀性是python的主要特點之一。可讀性意味著避免無用的文字和散亂的結構,因此要花費一些精力在達到一定程度的簡潔。但是不能太簡介,否則就晦澀難懂了。要能夠立刻告訴一個類或函式來自哪裡,比如modu.func
這種。這能大大提高程式碼的可讀性和可理解性,除了最簡單的單檔案專案。
包
python提供了一個非常簡單的包系統,可以簡單的將一個目錄擴充套件為一個包。 任何有 __init__.py
檔案的目錄都可以被認為是一個python包。包中不同的模組可以像普通的模組一樣被引入。__init__.py
檔案有一個特殊的作用,收集所有包範圍的定義。
import pack.modu
語句可以引入pack/
目錄裡的modu.py
檔案。此語句將在pack中查詢__init__.py
檔案,執行所有其頂層的語句。然後他將查詢名為pack/modu.py
的檔案並執行檔案中的所有頂級語句。這些操作中,所有modu.py
中的變數、函式和類的定義可以通過pack.modu
名稱空間獲得。
一個常見的問題是將太多程式碼寫在了__init__.py
檔案中。當專案的複雜性增長時,可能在深層的目錄結構中可能會有子包甚至子子包。在這種情況下,從子子包中import一個簡單的專案同樣需要執行所有在遍歷樹中遇到的__init__.py
檔案。
__init__.py
檔案是空的這很正常。如果包的模組和子模組不需要共享任何程式碼這甚是是一個好的做法。 最後,介紹一種方便的語法,可以用來引入深層巢狀的包:import very.deep.module as mod
。這樣可以用 mode來代替冗長的very.deep.module
。
物件導向程式設計
Python有時被描述為一種物件導向的程式語言。這可能會讓人誤解需要加以澄清。
在python中,一切都是物件。這是什麼意思,例如:函式是一級物件。函式、類、字串等在python中都是物件:像任何物件一樣,他們有型別,他們可以被作為函式引數傳遞,他們可能有方法和屬性。這樣理解的話,python是一種物件導向的語言。
但是不像java。python沒有將物件導向程式設計作為主要的程式設計正規化。對於python專案不是物件導向的(也就是沒有使用或很少使用類的定義、類的繼承或任何其他特定於物件導向程式設計的機制)是完全可行的。
此外,在模組部分,python處理模組和名稱空間的方式給開發者很自然的方式去確保抽象層的封裝和分離,這成為了使用物件導向的最常見的原因。因此,當沒有被要求時,python程式設計師有更多空間來不使用物件導向。
實際上確實存在一些場合,應當避免在不必要的時候使用”物件導向”。若要將“狀態”和“功能”結合起來,通過自定義類的方式自然是很受用。不過問題在於,正如我們在討論函數語言程式設計時指出的那樣,函式式的“狀態”和類的“狀態”根本就不是一回事。
通常在一些架構中,典型的例子是web應用程式,會生成 Python程式的多個例項,使得可以在同一時間對外部請求進行響應。在這種情況下,例項化的物件持有著某種狀態,也就是說持有一些環境的靜態資訊,這很容易出現併發問題或爭態條件。有時,在初始化的物件 (通常是用__init__()
方法) 與實際使用物件之間,環境可能發生了改變,而且保留的狀態可能已經過時。例如,請求可能載入一個item到記憶體中的,並將其標記為已讀。在同一時間如果另一個請求需要刪這個item,可能會發生這樣的事情:第一個程式載入了item後被刪除了,然後我們把已經刪除的物件標記為了已讀。
這個問題或者其他問題讓我們產生這樣一個想法,使用無狀態函式或許是一個更好的程式設計正規化。 另一種方式是建議使用隱式上下文和副作用盡可能少的函式和過程。函式的隱式上下文是由全域性變數和在函式內部訪問可以訪問的持久層中的項組成。副作用是函式會使其隱式上下文發生改變,如果一個函式儲存或刪除了全域性變數或持久層中的資料,我們把這種行為稱之為副作用。
把帶有上下文和副作用的函式從僅僅包含邏輯的函式(純函式)中小心的剝離出來,會帶來如下的益處:
- 純函式都具有確定性: 給出一個固定的輸入,輸出總是會相同。
- 純函式更容易更改或替換,如果它們需要重構或優化的話。
- 純函式的測試與單元測試更容易編寫: 很少需要複雜的上下文設定和之後的資料清洗。
- 純函式更容易操縱,修飾,分發。
總之,一些架構中純函式比類和物件能更有效地進行模組化構建。因為他們沒有任何上下文或副作用。很明顯,在許多情況下物件導向是有用的,甚至是必要的,例如當開發圖形化桌面應用程式或遊戲,有需要的操縱的東西 (視窗、 按鈕、 人物、 車輛) 需要在計算機的記憶體中具有相對較長的生命週期。
修飾器
python語言提供簡單但功能強大的語法:“修飾器”。裝飾器是一個函式或者類,它可以包裝(或修飾)一個函式或方法。裝飾器函式或方法將取代原來的“未裝飾”的函式或方法。因為在python中函式是一級物件,它可以被“手動操作”,但是用@decorator語法更清晰,因此要首選這種方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def foo(): # do something def decorator(func): # 操作 func return func foo = decorator(foo) # 手動修飾 @decorator def bar(): # Do something # bar() 已經被修飾 |
這種機制是對分離關注點和避免外部非相關的邏輯 ‘汙染’ 函式或方法的核心邏輯來說是有用的。有些功能如果用裝飾器來實現會更好,快取就是一個很好的例子:你想要將耗時的函式結果儲存在一個表中並直接使用他們而不是重複計算他們。這顯然不是函式邏輯的一部分。
動態型別
python是動態型別的,這意味著變數並沒有固定的型別。事實上,在python中,變數和很多其他語言非常不同,特別是靜態型別語言。變數不是電腦記憶體中的一段,他們是指向物件的’tags’或’names’。因此可能變數“a”被設為1,然後變成了“a string”,然後又變成了一個函式。
這樣不好
1 2 3 4 |
a = 1 a = 'a string' def a(): pass # Do something |
這樣好
1 2 3 4 |
count = 1 msg = 'a string' def func(): pass # Do something |
python的動態型別通常被認為是不可靠的,確實會帶來複雜的,難以除錯的程式碼。命名為“a”的可能是很多不同的東西,開發者或維護者需要在程式碼中跟蹤它確保它沒有被設為完全無關的物件。 一些方法有助於避免這種問題: 避免為不同的事物使用相同的變數名
使用剪短的函式或方法有助於降低使用同名代表兩個不同事物所帶來的風險。
甚至對於相關的事物,也最好使用不同的名稱,如果它們型別不同的話
這樣不好
1 2 3 |
items = 'a b c d' # 這是一個字串... items = items.split(' ') # ...變成了列表 items = set(items) # ...又變成了集合 |
重用名稱並不會提高效率:賦值的時候無論怎樣都會去建立新的物件。然而,隨著複雜性的上升,賦值語句被其他程式碼分開,包括“if”分支和迴圈,將越來越難以確定變數的型別是什麼。 一些編碼實踐,比如函數語言程式設計,建議永遠不會重新分配一個變數。在java中,可以使用final關鍵字,python沒有final關鍵字而且無論如何這都是違反python的哲學的。不過,避免多次為同一個變數賦值是一個好習慣,而且可以有助於掌握可變型別和不可變型別的概念
可變型別和不可變型別
python提供兩種內建或使用者定義的型別。 可變型別是內容允許修改的。典型的可變型別是list和dict:所有的list都有可變方法,比如list.append()
或llist.pop()
,並且可以就地修改。字典也是一樣的。 不可變型別沒有提供改變其內容的方法。例如:設定為6的整數變數x沒有“increment”方法。如果你想要計算x+1,你必須建立另一個整數並給他一個名稱。
1 2 3 4 5 6 |
my_list = [1, 2, 3] my_list[0] = 4 print my_list # [4, 2, 3] <- The same list as changed x = 6 x = x + 1 # The new x is another object |
這種差異的一個後果是可變型別不是”穩定的”,並因此不能用作字典的鍵。 可變性質的東西用可變型別,固定不變的用不可變型別這有助於闡明程式碼的目的。
例如,類似列表的不可變型別是元組,通過類似 (1,2)這種方式建立。此元組是一對,不能就地更改,並且可以用作鍵的字典。 python中一件令初學者吃驚的事情是,字串型別是不可變的。這意味著,當需要組合一個字串時,把每一部分都放到列表中(是可變的)會比較好,然後當需要整個字串的時候再 把他們連(‘join’)起來。然而,有一件事要注意,列表推導比在迴圈呼叫append () 來構造列表要更好和更快。
不好
1 2 3 4 5 |
# create a concatenated string from 0 to 19 (e.g. "012..1819") nums = "" for n in range(20): nums += str(n) # slow and inefficient print nums |
好
1 2 3 4 5 |
# create a concatenated string from 0 to 19 (e.g. "012..1819") nums = [] for n in range(20): nums.append(str(n)) print "".join(nums) # much more efficient |
最佳
1 2 3 |
# create a concatenated string from 0 to 19 (e.g. "012..1819") nums = [str(n) for n in range(20)] print "".join(nums) |
關於字串最後要提的是,使用 join () 不是總是最好。比如,當你要用預先確定數目的字串建立一個新的字串時,使用加法運算子確實是更快,但在上述情況下或新增到現有的字串的情況下用你應該首選 join ()。
1 2 3 4 5 6 |
foo = 'foo' bar = 'bar' foobar = foo + bar # This is good foo += 'ooo' # This is bad, instead you should do: foo = ''.join([foo, 'ooo’]) |
注意 除了str.join() 和 +,你也可以使用 %格式運算子來串聯預先確定數目的字串。然而PEP 3101,建議用 str.format() 方法 取代%運算子。
1 2 3 4 5 6 |
foo = 'foo' bar = 'bar' foobar = '%s%s' % (foo, bar) # It is OK foobar = '{0}{1}'.format(foo, bar) # It is better foobar = '{foo}{bar}'.format(foo=foo, bar=bar) # It is best |