編寫高效且優雅的 Python 程式碼(1)

發表於2016-11-15

Python 作為一門入門極易並容易上癮的語音,相信已經成為了很多人 “寫著玩” 的標配指令碼語言。但很多教材並沒有教授 Python 的進階和優化。本文作為進階系列的文章,從基礎的語法到函式、迭代器、類,還有之後系列的執行緒 / 程式、第三方庫、網路程式設計等內容,共同學習如何寫出更加 Pythonic 的程式碼部分提煉自書籍:《Effective Python》&《Python3 Cookbook》,但也做出了修改,並加上了我自己的理解和運用中的最佳實踐

Pythonic

列表切割

list[start:end:step]

  • 如果從列表開頭開始切割,那麼忽略 start 位的 0,例如list[:4]
  • 如果一直切到列表尾部,則忽略 end 位的 0,例如list[3:]
  • 切割列表時,即便 start 或者 end 索引跨界也不會有問題
  • 列表切片不會改變原列表。索引都留空時,會生成一份原列表的拷貝

列表推導式

  • 使用列表推導式來取代mapfilter

  • 不要使用含有兩個以上表示式的列表推導式

  • 資料多時,列表推導式可能會消耗大量記憶體,此時建議使用生成器表示式

迭代

  • 需要獲取 index 時使用enumerate
  • enumerate可以接受第二個引數,作為迭代時加在index上的數值

  • zip同時遍歷兩個迭代器

  • zip遍歷時返回一個元組

  • 關於forwhile迴圈後的else
    • 迴圈正常結束之後會呼叫else內的程式碼
    • 迴圈裡通過break跳出迴圈,則不會執行else
    • 要遍歷的序列為空時,立即執行else

反向迭代

對於普通的序列(列表),我們可以通過內建的reversed()函式進行反向迭代:

除此以外,還可以通過實現類裡的__reversed__方法,將類進行反向迭代:

try/except/else/finally

  • 如果try內沒有發生異常,則呼叫else內的程式碼
  • else會在finally之前執行
  • 最終一定會執行finally,可以在其中進行清理工作

函式

使用裝飾器

裝飾器用於在不改變原函式程式碼的情況下修改已存在的函式。常見場景是增加一句除錯,或者為已有的函式增加log監控

舉個例子:

除此以外,還可以編寫接收引數的裝飾器,其實就是在原本的裝飾器上的外層又巢狀了一個函式:

但是像上面那樣使用裝飾器的話有一個問題:

也就是說原函式已經被裝飾器裡的new_fun函式替代掉了。呼叫經過裝飾的函式,相當於呼叫一個新函式。檢視原函式的引數、註釋、甚至函式名的時候,只能看到裝飾器的相關資訊。為了解決這個問題,我們可以使用 Python 自帶的functools.wraps方法。

stackoverflow: What does functools.wraps do?

functools.wraps是個很 hack 的方法,它本事作為一個裝飾器,做用在裝飾器內部將要返回的函式上。也就是說,它是裝飾器的裝飾器,並且以原函式為引數,作用是保留原函式的各種資訊,使得我們之後檢視被裝飾了的原函式的資訊時,可以保持跟原函式一模一樣。

此外,有時候我們的裝飾器裡可能會幹不止一個事情,此時應該把事件作為額外的函式分離出去。但是又因為它可能僅僅和該裝飾器有關,所以此時可以構造一個裝飾器類。原理很簡單,主要就是編寫類裡的__call__方法,使類能夠像函式一樣的呼叫。

使用生成器

考慮使用生成器來改寫直接返回列表的函式

用這種方法有幾個小問題:

  • 每次獲取到符合條件的結果,都要呼叫append方法。但實際上我們的關注點根本不在這個方法,它只是我們達成目的的手段,實際上只需要index就好了
  • 返回的result可以繼續優化
  • 資料都存在result裡面,如果資料量很大的話,會比較佔用記憶體

因此,使用生成器generator會更好。生成器是使用yield表示式的函式,呼叫生成器時,它不會真的執行,而是返回一個迭代器,每次在迭代器上呼叫內建的next函式時,迭代器會把生成器推進到下一個yield表示式:

獲取到一個生成器以後,可以正常的遍歷它:

如果你還是需要一個列表,那麼可以將函式的呼叫結果作為引數,再呼叫list方法

可迭代物件

需要注意的是,普通的迭代器只能迭代一輪,一輪之後重複呼叫是無效的。解決這種問題的方法是,你可以定義一個可迭代的容器類

這樣的話,將類的例項迭代重複多少次都沒問題:

但要注意的是,僅僅是實現__iter__方法的迭代器,只能通過for迴圈來迭代;想要通過next方法迭代的話則需要使用iter方法:

使用位置引數

有時候,方法接收的引數數目可能不一定,比如定義一個求和的方法,至少要接收兩個引數:

對於這種接收引數數目不一定,而且不在乎引數傳入順序的函式,則應該利用位置引數*args

但要注意的是,不定長度的引數args在傳遞給函式時,需要先轉換成元組tuple。這意味著,如果你將一個生成器作為引數帶入到函式中,生成器將會先遍歷一遍,轉換為元組。這可能會消耗大量記憶體:

使用關鍵字引數

  • 關鍵字引數可提高程式碼可讀性
  • 可以通過關鍵字引數給函式提供預設值
  • 便於擴充函式引數

定義只能使用關鍵字引數的函式

  • 普通的方式,在呼叫時不會強制要求使用關鍵字引數

  • 使用 Python3 中強制關鍵字引數的方式

  • 使用 Python2 中強制關鍵字引數的方式

關於引數的預設值

算是老生常談了:函式的預設值只會在程式載入模組並讀取到該函式的定義時設定一次

也就是說,如果給某引數賦予動態的值( 比如[]或者{}),則如果之後在呼叫函式的時候給引數賦予了其他引數,則以後再呼叫這個函式的時候,之前定義的預設值將會改變,成為上一次呼叫時賦予的值:

因此,更推薦使用None作為預設引數,在函式內進行判斷之後賦值:

__slots__

預設情況下,Python 用一個字典來儲存一個物件的例項屬性。這使得我們可以在執行的時候動態的給類的例項新增新的屬性:

然而這個字典浪費了多餘的空間 — 很多時候我們不會建立那麼多的屬性。因此通過__slots__可以告訴 Python 不要使用字典而是固定集合來分配空間。

__call__

通過定義類中的__call__方法,可以使該類的例項能夠像普通函式一樣呼叫。

通過這種方式實現的好處是,可以通過類的屬性來儲存狀態,而不必建立一個閉包或者全域性變數。

@classmethod & @staticmethod

資料:

@classmethod@staticmethod很像,但他們的使用場景並不一樣。

  • 類內部普通的方法,都是以self作為第一個引數,代表著通過例項呼叫時,將例項的作用域傳入方法內;
  • @classmethodcls作為第一個引數,代表將類本身的作用域傳入。無論通過類來呼叫,還是通過類的例項呼叫,預設傳入的第一個引數都將是類本身
  • @staticmethod不需要傳入預設引數,類似於一個普通的函式

來通過例項瞭解它們的使用場景:

假設我們需要建立一個名為Date的類,用於儲存 年/月/日 三個資料

上述程式碼建立了Date類,該類會在初始化時設定day/month/year屬性,並且通過property設定了一個getter,可以在例項化之後,通過time獲取儲存的時間:

但如果我們想改變屬性傳入的方式呢?畢竟,在初始化時就要傳入年/月/日三個屬性還是很煩人的。能否找到一個方法,在不改變現有介面和方法的情況下,可以通過傳入2016-11-09這樣的字串來建立一個Date例項?

你可能會想到這樣的方法:

但不夠好:

  • 在類外額外多寫了一個方法,每次還得格式化以後獲取引數
  • 這個方法也只跟Date類有關
  • 沒有解決傳入引數過多的問題

此時就可以利用@classmethod,在類的內部新建一個格式化字串,並返回類的例項的方法:

這樣,我們就可以通過Date類來呼叫from_string方法建立例項,並且不侵略、修改舊的例項化方式:

好處:

  • @classmethod內,可以通過cls引數,獲取到跟外部呼叫類時一樣的便利
  • 可以在其中進一步封裝該方法,提高複用性
  • 更加符合物件導向的程式設計方式

@staticmethod,因為其本身類似於普通的函式,所以可以把和這個類相關的 helper 方法作為@staticmethod,放在類裡,然後直接通過類來呼叫這個方法。

將與日期相關的輔助類函式作為@staticmethod方法放在Date類內後,可以通過類來呼叫這些方法:

建立上下文管理器

上下文管理器,通俗的介紹就是:在程式碼塊執行前,先進行準備工作;在程式碼塊執行完成後,做收尾的處理工作。with語句常伴隨上下文管理器一起出現,經典場景有:

通過with語句,程式碼完成了檔案開啟操作,並在呼叫結束,或者讀取發生異常時自動關閉檔案,即完成了檔案讀寫之後的處理工作。如果不通過上下文管理器的話,則會是這樣的程式碼:

比較繁瑣吧?所以說使用上下文管理器的好處就是,通過呼叫我們預先設定好的回撥,自動幫我們處理程式碼塊開始執行和執行完畢時的工作。而通過自定義類的__enter____exit__方法,我們可以自定義一個上下文管理器。

然後可以以這樣的方式進行呼叫:

在呼叫的時候:

  1. with語句先暫存了ReadFile類的__exit__方法
  2. 然後呼叫ReadFile類的__enter__方法
  3. __enter__方法開啟檔案,並將結果返回給with語句
  4. 上一步的結果被傳遞給file_read引數
  5. with語句內對file_read引數進行操作,讀取每一行
  6. 讀取完成之後,with語句呼叫之前暫存的__exit__方法
  7. __exit__方法關閉了檔案

要注意的是,在__exit__方法內,我們關閉了檔案,但最後返回True,所以錯誤不會被with語句丟擲。否則with語句會丟擲一個對應的錯誤。

相關文章