教你寫出可讀性高的Python程式碼

  • 2019 年 12 月 19 日
  • 筆記

作者: Python 最佳實踐指南 2018

來源:https://learnku.com/docs/python-guide/2018/writing-style/3261

如果有人問起 Python 程式設計師他們最喜歡 Python 哪一點,他們一定會提到 Python 的高可讀性。確實,對於 Python 來說,其高可讀性一直是 Python 這門語言設計的核心。一個不爭的事實是,相對於寫程式碼而言,讀程式碼才是更加平常的事情。

Python 程式碼有高可讀性的一個原因就是其有著相對而言更加完善的編碼風格準則和 「Python 化」習慣語法。

當 Python 老手(Pythonista)認為一段程式碼不「Python 化」,他們通常的意思是這段程式碼沒有遵循一般準則,同時亦沒有以最佳的(最具可讀性的)方式表達出程式碼的意圖。

在一些極端的情況下,沒有公認最佳的方式來表達 Python 程式碼的意圖,不過這種極端情況非常罕見。

一般概念

明確程式碼意義

儘管 Python 可以寫出從各種意義上來說都像是黑魔法的程式碼,但最簡單直白的表達才是正道。

不優雅

def make_complex(*args):      x, y = args      return dict(**locals())  

優雅

def make_complex(x, y):      return {'x': x, 'y': y}  

在上述好的程式碼中,x 和 y 清晰明了的從參數中獲取值,並清晰明了的返回了一個字典。當開發者看到這個函數後就可以明了這個函數的用途,而不好的程式碼則不行。

一行一個聲明語句

雖然在 Python 中我們推崇使用形如列表生成式這種簡潔明了的複合語句,但是除此以外,我們應該盡量避免將兩句獨立分割的程式碼寫在同一行。

不好的風格

print 'one'; print 'two'    if x == 1: print 'one'    if <complex comparison> and <other complex comparison>:      # do something  

好的風格

print 'one'  print 'two'    if x == 1:      print 'one'    cond1 = <complex comparison>  cond2 = <other complex comparison>  if cond1 and cond2:      # do something

函數的參數

函數的參數可以使用四種不同的方式傳遞給函數。

  1. 必選參數 是沒有默認值的必填的參數。必選參數是最簡單的參數構成,用於參數較少的函數的構成,是該函數意義的一部分,使用他們的順序是按照定義自然排序的。舉個例子,對於 send(message, recipient)point(x, y) 這兩個函數,使用函數的人需要知道這個函數需要兩個參數,並且記住兩個參數的順序。

在調用函數的時候,我們也可以使用參數的名稱調用。使用參數的名稱的方式可以調換參數的順序,就像 send(recipient='World',message='Hello')point(y=2, x=1) 這樣。但這樣的做法會降低程式碼的可讀性,並且使程式碼冗長,因此更建議使用 send('Hello', 'World')point(1,2) 這樣的方式調用。

  1. 關鍵字參數 是非強制的,且有默認值。它們經常被用在傳遞給函數的可選參數中。當一個函數有超過兩個或三個位置參數時,函數簽名會變得難以記憶,使用帶有默認參數的關鍵字參數有時候會給你帶來便利。比如,一個更完整的 send 函數可以被定義為 send(message, to, cc=None, bcc=None)。這裡的 ccbcc 是可選的, 當沒有傳遞給它們其他值的時候,它們的值就是 None。

Python 中有多種方式調用帶關鍵字參數的函數。比如說,我們可以按照定義時的參數順序而無需明確的命名參數來調用函數,就像 send('Hello', 'World', 'Cthulhu', 'God') 是將密件發送給上帝。我們也可以使用命名參數而無需遵循參數順序來調用函數,就像 send('Hello again', 'World', bcc='God', cc='Cthulhu') 。沒有特殊情況的話,這兩種方式都需要儘力避免,最優的調用方式是與定義方式一致:send('Hello', 'World', cc='Cthulhu',bcc='God')

作為附註,請遵循 YAGNI 原則。通常,移除一個用作『以防萬一』但從未使用的可選參數(以及它在函數中的邏輯),比添加一個所需的新的可選參數和它的邏輯要來的困難。

  1. 任意參數列表 是第三種給函數傳參的方式。如果函數的參數數量是動態的,該函數可以被定義成 *args 的結構。在這個函數體中, args 是一個元組,它包含所有剩餘的位置參數。舉個例子, 我們可以用任何容器作為參數去調用 send(message, *args) ,比如 send('Hello', 'God', 'Mom','Cthulhu')。在此函數體中, args 相當於 ('God','Mom', 'Cthulhu')

然而,這種結構有一些缺點,使用時應該特別注意。如果一個函數接受的參數列表具有相同的性質,通常把它定義成一個參數,這個參數是一個列表或者其他任何序列會更清晰。在這裡,如果 send 參數有多個容器(recipients),將之定義成 send(message,recipients) 會更明確,調用它時就使用 send('Hello', ['God', 'Mom', 'Cthulhu'])。這樣的話, 函數的使用者可以事先將容器列表維護成列表(list)形式,這為傳遞各種不能被轉變成其他序列的序列(包括迭代器)帶來了可能。

  1. 任意關鍵字參數字典 是最後一種給函數傳參的方式。如果函數要求一系列待定的命名參數,我們可以使用 **kwargs 的結構。在函數體中, kwargs 是一個字典,它包含所有傳遞給函數但沒有被其他關鍵字參數捕捉的命名參數。

任意參數列表 中所需注意的一樣,相似的原因是:這些強大的技術在非特殊情況下,都要盡量避免使用,因為其缺乏簡單和明確的結構來足夠表達函數意圖。

編寫函數的時候採用何種參數形式,是用位置參數,還是可選關鍵字參數,是否使用形如任意參數 的高級技術,這些都由程式設計師自己決定。如果能明智地遵循上述建議,即可輕鬆寫出這樣的 Python 函數:

  • 易讀(名字和參數無需解釋)
  • 易改(添加新的關鍵字參數不會破壞程式碼的其他部分)

避免魔法方法

Python 對駭客來說是一個強有力的工具,它擁有非常豐富的鉤子(hook)和工具,允許你施展幾乎任何形式的技巧。比如說,它能夠做以下:

  • 改變對象創建和實例化的方式;
  • 改變 Python 解釋器導入模組的方式;
  • 甚至可能(如果需要的話也是被推薦的)在 Python 中嵌入 C 程式。

儘管如此,所有的這些選擇都有許多缺點。使用最直接的方式來達成目標通常是最好的方法。它們最主要的缺點是可讀性不高。許多程式碼分析工具,比如說 pylint 或者 pyflakes,將無法解析這種『魔法』程式碼。

我們認為 Python 開發者應該知道這些近乎無限的可能性,因為它為我們灌輸了沒有不可能完成的任務的信心。然而,知道何時 不能 使用它們也是非常重要的。

就像一位功夫大師,一個 Pythonista 知道如何用一個手指殺死對方,但從不會那麼去做。

我們都是負責任的用戶

如前所述,Python 允許很多技巧,其中一些具有潛在的危險。一個好的例子是:任何客戶端程式碼能夠重寫一個對象的屬性和方法(Python 中沒有 private 關鍵字)。這種哲學是在說:『我們都是負責任的用戶』,它和高度防禦性的語言(如 Java,擁有很多機制來預防錯誤操作)有著非常大的不同。

這並不意味著,比如說,Python 中沒有屬性是私有的,也不意味著沒有合適的封裝方法。與其依賴在開發者的程式碼之間樹立起的一道道隔牆,Python 社區更願意依靠一組約定,來表明這些元素不應該被直接訪問。

私有屬性的主要約定和實現細節是在所有的 內部 變數前加一個下劃線。如果客戶端程式碼打破了這條規則並訪問了帶有下劃線的變數,那麼因內部程式碼的改變而出現的任何不當的行為或問題,都是客戶端程式碼的責任。

鼓勵大方地使用此約定:任何不開放給客戶端程式碼使用的方法或屬性,應該有一個下劃線前綴。這將保證更好的職責劃分以及更容易對已有程式碼進行修改。將一個私有屬性公開化總是可能的,但是把一個公共屬性私有化可能是一個更難的選擇。

返回值

當一個函數變得複雜,在函數體中使用多返回值的語句並不少見。然而,為了保持函數的可讀性,建議在函數體中避免使用返回多個有意義的值。

在函數中返回結果主要有兩種情況:函數正常運行並返回它的結果,以及錯誤的情況,要麼因為一個錯誤的輸入參數,要麼因為其他導致函數無法完成計算或任務的原因。

如果你在面對第二種情況時不想拋出異常,返回一個值(比如說 None 或 False )來表明函數無法正確運行,可能是需要的。在這種情況下,越早返回所發現的不正確上下文越好。這將幫助扁平化函數的結構:我們假定在『因為錯誤而返回』的語句後的所有程式碼都能夠滿足函數主要結果運算。這種類型的多發揮結果,是有必要的。

然而,當一個函數在其正常運行過程中有多個主要出口點時,它會變得難以調試其返回結果,所以保持單個出口點可能會更好。這也將有助於提取某些程式碼路徑,而且多個出口點很有可能意味著這裡需要重構:

def complex_function(a, b, c):      if not a:          return None  # 拋出一個異常可能會更好      if not b:          return None  # 拋出一個異常可能會更好        # 一些複雜的程式碼試著用 a,b,c 來計算x      # 如果成功了,抵制住返回 x 的誘惑      if not x:          # 使用其他的方法來計算出 x      return x  # 返回值 x 只有一個出口點有利於維護程式碼  

習語(Idiom)

編程習語,說得簡單些,就是寫程式碼的 方式。編程習語的概念在 c2 和 Stack Overflow 上有詳盡的討論。

符合習語的 Python 程式碼通常被稱為 Pythonic

通常只有一種、而且最好只有一種明顯的方式去編寫程式碼。對 Python 初學者來說,無意識的情況下很少能寫出習語式 Python 程式碼,所以應該有意識地去獲取習語的書寫方式。

如下有一些常見的 Pythonic

解包(Unpacking)

如果你知道一個列表或者元組的長度,你可以將其解包並為它的元素取名。比如,enumerate() 會對 list 中的每個項提供包含兩個元素的元組:

for index, item in enumerate(some_list):      # do something with index and item  

你也能通過這種方式交換變數:

a, b = b, a  

嵌套解包也能工作:

a, (b, c) = 1, (2, 3)  

Python 3 提供了擴展解包的新方法在 PEP 3132 有介紹:

a, *rest = [1, 2, 3]  # a = 1, rest = [2, 3]  a, *middle, c = [1, 2, 3, 4]  # a = 1, middle = [2, 3], c = 4

創建一個被忽略的變數

如果你需要賦值(比如,在 解包(Unpacking) )但不需要這個變數,請使用 __:

filename = 'foobar.txt'  basename, __, ext = filename.rpartition('.')  

注意 許多 Python 風格指南建議使用單下劃線的 _ 而不是這裡推薦的雙下劃線 __ 來標記廢棄變數。問題是, _常用在作為 gettext() 函數的別名,也被用在互動式命令行中記錄最後一次操作的值。相反,使用雙下劃線 十分清晰和方便,而且能夠消除使用其他這些用例所帶來的意外干擾的風險。

創建一個含 N 個對象的列表

使用 Python 列表中的 * 操作符:

four_nones = [None] * 4

創建一個含 N 個列表的列表

因為列表是可變的,所以 * 操作符(如上)將會創建一個包含 N 個且指向 同一個 列表的列表,這可能不是你想用的。取而代之,請使用列表解析:

four_lists = [[] for __ in xrange(4)]  

注意:在 Python 3 中使用 range() 而不是 xrange()

根據列表來創建字元串

創建字元串的一個常見習語是在空的字元串上使用 str.join()

letters = ['s', 'p', 'a', 'm']  word = ''.join(letters)  

這會將 word 變數賦值為 spam。這個習語可以用在列表和元組中。

在集合體(collection)中查找一個項

有時我們需要在集合體中查找。讓我們看看這兩個選擇,列表和集合(set),用如下程式碼舉個例子:

s = set(['s', 'p', 'a', 'm'])  l = ['s', 'p', 'a', 'm']    def lookup_set(s):      return 's' in s    def lookup_list(l):      return 's' in l  

即使兩個函數看起來完全一樣,但因為 查找集合 是利用了 Python 中的『集合是可哈希』的特性,兩者的查詢性能是非常不同的。為了判斷一個項是否在列表中,Python 將會查看每個項直到它找到匹配的項。這是耗時的任務,尤其是對長列表而言。另一方面,在集合中, 項的哈希值將會告訴 Python 在集合的哪裡去查找匹配的項。結果是,即使集合很大,查詢的速度也很快。在字典中查詢也是同樣的原理。想了解更多內容,請見 StackOverflow 。想了解在每種數據結構上的多種常見操作的花費時間的詳細內容, 請見 此頁面。

因為這些性能上的差異,在下列場景中,使用集合或者字典而不是列表,通常會是個好主意:

  • 集合體中包含大量的項;
  • 你將在集合體中重複地查找項;
  • 你沒有重複的項。

對於小的集合體、或者你不會頻繁查找的集合體,建立哈希帶來的額外時間和記憶體的開銷經常會大過改進搜索速度所節省的時間。

約定

這裡有一些你應該遵循的約定,以讓你的程式碼更加易讀。

檢查變數是否等於常量

你不需要明確地比較一個值是 True,或者 None,或者 0 – 你可以僅僅把它放在 if 語句中。參閱 真值測試 來了解什麼被認為是 false:

糟糕:

if attr == True:      print 'True!'    if attr == None:      print 'attr is None!'  

優雅:

# 檢查值  if attr:      print 'attr is truthy!'    # 或者做相反的檢查  if not attr:      print 'attr is falsey!'    # 或者,None 等於 false,你可以直接相較它進行匹配  if attr is None:      print 'attr is None!'  

訪問字典元素

不要使用 dict.has_key() 方法。相反,使用 x in d 語法,或者將默認參數傳遞給 dict.get() 方法。

壞的示例

d = {'hello': 'world'}  if d.has_key('hello'):      print d['hello']    # prints 'world'  else:      print 'default_value'  

推薦的示例:

d = {'hello': 'world'}    print d.get('hello', 'default_value') # prints 'world'  print d.get('thingy', 'default_value') # prints 'default_value'    # 或者:  if 'hello' in d:      print d['hello']  

操作列表的簡便方法

列表推導式 提供了一個強大並且簡潔的方法來對列表價進行操作。除此之外,map()filter() 函數在列表的操作上也是非常簡潔的。

:

# Filter elements greater than 4  a = [3, 4, 5]  b = []  for i in a:      if i > 4:          b.append(i)  

:

a = [3, 4, 5]  b = [i for i in a if i > 4]  # Or:  b = filter(lambda x: x > 4, a)  

:

# Add three to all list members.  a = [3, 4, 5]  for i in range(len(a)):      a[i] += 3  

:

a = [3, 4, 5]  a = [i + 3 for i in a]  # Or:  a = map(lambda i: i + 3, a)  

使用 enumerate() 來跟蹤正在被處理的元素索引。

a = [3, 4, 5]  for i, item in enumerate(a):      print i, item  # prints  # 0 3  # 1 4  # 2 5  

比起手動計數,使用 enumerate() 函數有更好的可讀性,而且,他更加適合在迭代器中使用。

讀文件

使用 with open 語法來讀文件,它能夠為你自動關閉文件。

:

f = open('file.txt')  a = f.read()  print a  f.close()  

:

with open('file.txt') as f:      for line in f:          print line  

即使在 with 控制塊中出現了異常,它也能確保你關閉了文件,因此,使用 with 語法是更加優雅的。

行的延續

當一個程式碼邏輯行的長度超過可接受的限度時,你需要將之分為多個物理行。如果行的結尾是一個反斜杠,Python 解釋器會把這些連續行拼接在一起。這在某些情況下很有幫助, 但我們總是應該避免使用,因為它的脆弱性:如果在行的結尾,在反斜杠後加了空格,這會破壞程式碼,而且可能有意想不到的結果。

一個更好的解決方案是在元素周圍使用括弧。左邊以一個未閉合的括弧開頭,Python 解釋器會把行的結尾和下一行連接起來直到遇到閉合的括弧。同樣的行為適用中括弧和大括弧。

糟糕

my_very_big_string = """For a long time I used to go to bed early. Sometimes,      when I had put out my candle, my eyes would close so quickly that I had not even      time to say "I'm going to sleep.""""    from some.deep.module.inside.a.module import a_nice_function, another_nice_function,      yet_another_nice_function  

優雅

my_very_big_string = (      "For a long time I used to go to bed early. Sometimes, "      "when I had put out my candle, my eyes would close so quickly "      "that I had not even time to say "I'm going to sleep.""  )    from some.deep.module.inside.a.module import (      a_nice_function, another_nice_function, yet_another_nice_function)  

儘管如此,通常情況下,必須去分割一個長邏輯行意味著你同時想做太多的事,這可能影響可讀性。