改善 Python 程式的 91 個建議(一)

馭風者發表於2017-04-06

第 1 章 引論

建議 1:理解 Pythonic 概念

Pythonic

Tim Peters 的 《The Zen of Python》相信學過 Python 的都耳熟能詳,在互動式環境中輸入import this可以檢視,其實有意思的是這段 Python 之禪的原始碼:

d = {}
for c in (65, 97):
    for i in range(26):
        d[chr(i+c)] = chr((i+13) % 26 + c)

print "".join([d.get(c, c) for c in s])

哈哈哈,相信這是大佬在跟我們舉反例吧。

書中還舉了一個快排的例子:

def quicksort(array):
    less = []
    greater = []
    if len(array) <= 1:
        return array
    pivot =array.pop()
    for x in array:
        if x <= pivot:
            less.append(x)
        else:
            greater.append(x)
    return quicksort(less) + [pivot] + quicksort(greater)

程式碼風格

通過對語法、庫和應用程式的理解來編寫程式碼,充分體現 Python 自身的特色:

# 變數交換
a, b = b, a
# 上下文管理
with open(path, 'r') as f:
    do_sth_with(f)
# 不應當過分地追求奇技淫巧
a = [1, 2, 3, 4]
a[::-1] # 不推薦。好吧,自從學了切片我一直用的這個
list(reversed(a))   # 推薦

然後表揚了 Flask 框架,提到了 generator 之類的特性尤為 Pythonic,有個包和模組的約束:

  • 包和模組的命名採用小寫、單數形式,而且短小
  • 包通常僅作為名稱空間,如只含空的__init__.py檔案

建議 2:編寫 Pythonic 程式碼

命名的規範:

def find_num(searchList, num):
    for listValue in searchList:
        if num == listValue:
            return True
        else:
            pass

嘗試去通讀官方手冊,掌握不斷髮展的新特性,這將使你編寫程式碼的執行效率更高,推薦深入學習 Flask、gevent 和 requests。

建議 3:理解 Python 與 C 語言的不同之處

提到了三點:

  • Python 使用程式碼縮排的方式來分割程式碼塊,不要混用 Tab 鍵和空格
  • Python 中單、雙引號的使用
  • 三元操作符:x if bool else y

建議 4:在程式碼中適當新增註釋

這一點已經受教了,現在編寫程式碼都會合理地加入塊註釋、行註釋和文件註釋,可以使用__doc__輸出。

建議 5:通過適當新增空行使程式碼佈局更為優雅、合理

建議 6:編寫函式的 4 個原則

  1. 函式設計要儘量短小,巢狀層次不宜過深
  2. 函式申明應該做到合理、簡單、易於使用
  3. 函式引數設計應該考慮向下相容
  4. 一個函式只做一件事,儘量保證函式語句粒度的一致性

Python 中函式設計的好習慣還包括:不要在函式中定義可變物件作為預設值,使用異常替換返回錯誤,保證通過單元測試等。

# 關於函式設計的向下相容
def readfile(filename):         # 第一版本
    pass
def readfile(filename, log):    # 第二版本
    pass
def readfile(filename, logger=logger.info):     # 合理的設計
    pass

最後還有個函式可讀性良好的例子:

def GetContent(ServerAdr, PagePath):
    http = httplib.HTTP(ServerAdr)
    http.putrequest('GET', PagePath)
    http.putheader('Accept', 'text/html')
    http.putheader('Accept', 'text/plain')
    http.endheaders()
    httpcode, httpmsg, headers = http.getreply()
    if httpcode != 200:
        raise "Could not get document: Check URL and Path."
    doc = http.getfile()
    data = doc.read()       # 此處是不是應該使用 with ?
    doc.close
    return data

def ExtractData(inputstring, start_line, end_line):
    lstr = inputstring.splitlines()             # split
    j = 0
    for i in lstr:
        j += 1
        if i.strip() == start_line: slice_start = j
        elif i.strip() == end_line: slice_end = j
    return lstr[slice_start:slice_end]

def SendEmail(sender, receiver, smtpserver, username, password, content):
    subject = "Contented get from the web"
    msg = MIMEText(content, 'plain', 'utf-8')
    msg['Subject'] = Header(subject, 'utf-8')
    smtp = smtplib.SMTP()
    smtp.connect(smtpserver)
    smtp.login(username, password)
    smtp.sendmail(sender, receiver, msg.as_string())
    smtp.quit()

建議 7:將常量集中到一個檔案

在 Python 中應當如何使用常量:

  • 通過命名風格提醒使用者該變數代表常量,如常量名全部大寫
  • 通過自定義類實現常量功能:將存放常量的檔案命名為constant.py,並在其中定義一系列常量
class _const:
    class ConstError(TypeError): pass
    class ConstCaseError(ConstError): pass

    def __setattr__(self, name, value):
        if self.__dict__.has_key(name):
            raise self.ConstError, "Can't change const.%s" % name
        if not name.isupper():
            raise self.ConstCaseError, \
                    'const name "%s" is not all uppercase' % name
        self.__dict__(name) = value

import sys
sys.modules[__name__] = _const()
import const
const.MY_CONSTANT = 1
const.MY_SECOND_CONSTANT = 2
const.MY_THIRD_CONSTANT = 'a'
const.MY_FORTH_CONSTANT = 'b'

其他模組中引用這些常量時,按照如下方式進行即可:

from constant import const
print(const.MY_CONSTANT)

第 2 章 程式設計慣用法

建議 8:利用 assert 語句來發現問題

>>> y = 2
>>> assert x == y, "not equals"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: not equals
>>> x = 1
>>> y = 2
# 以上程式碼相當於
>>> if __debug__ and not x == y:
...     raise AssertionError("not equals")
... 
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
AssertionError: not equals

執行是加入-O引數可以禁用斷言。

建議 9:資料交換的時候不推薦使用中間變數

>>> Timer('temp = x; x = y; y = temp;', 'x = 2; y = 3').timeit()
0.059251302998745814
>>> Timer('x, y = y, x', 'x = 2; y = 3').timeit()
0.05007316499904846

對於表示式x, y = y, x,在記憶體中執行的順序如下:

  1. 先計算右邊的表示式y, x,因此先在記憶體中建立元組(y, x),其識別符號和值分別為y, x及其對應的值,其中y和x是在初始化已經存在於記憶體中的物件
  2. 計算表示式左邊的值並進行賦值,元組被依次分配給左邊的識別符號,通過解壓縮,元組第一識別符號y分配給左邊第一個元素x,元組第二識別符號x分配給左邊第一個元素y,從而達到交換的目的

下面是通過位元組碼的分析:

>>> import dis
>>> def swap1():
...     x = 2
...     y = 3
...     x, y = y, x
... 
>>> def swap2():
...     x = 2
...     y = 3
...     temp = x
...     x = y
...     y = temp
... 
>>> dis.dis(swap1)
  2           0 LOAD_CONST               1 (2)
              3 STORE_FAST               0 (x)

  3           6 LOAD_CONST               2 (3)
              9 STORE_FAST               1 (y)

  4          12 LOAD_FAST                1 (y)
             15 LOAD_FAST                0 (x)
             18 ROT_TWO                             # 交換兩個棧的最頂層元素
             19 STORE_FAST               0 (x)
             22 STORE_FAST               1 (y)
             25 LOAD_CONST               0 (None)
             28 RETURN_VALUE
>>> dis.dis(swap2)                                                                                                                                    
  2           0 LOAD_CONST               1 (2)
              3 STORE_FAST               0 (x)

  3           6 LOAD_CONST               2 (3)
              9 STORE_FAST               1 (y)

  4          12 LOAD_FAST                0 (x)
             15 STORE_FAST               2 (temp)

  5          18 LOAD_FAST                1 (y)
             21 STORE_FAST               0 (x)

  6          24 LOAD_FAST                2 (temp)
             27 STORE_FAST               1 (y)
             30 LOAD_CONST               0 (None)
             33 RETURN_VALUE

建議 10:充分利用 Lazy evaluation 的特性

def fib():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

哈哈哈,我猜到肯定是生成器實現菲波拉契序列的例子,不過對比我寫的版本,唉。。。

建議 11:理解列舉替代實現的缺陷

利用 Python 的動態特徵,可以實現列舉:

# 方式一
class Seasons:
    Spring, Summer, Autumn, Winter = range(4)
# 方式二
def enum(*posarg, **keysarg):
    return type("Enum", (object,), dict(zip(posarg, range(len(posarg))), **keysarg))
Seasons = enum("Spring", "Summer", "Autumn", Winter=1)
Seasons.Spring
# 方式三
>>> from collections import namedtuple
>>> Seasons = namedtuple('Seasons', 'Spring Summer Autumn Winter')._make(range(4))
>>> Seasons.Spring
0
# 但通過以上方式實現列舉都有不合理的地方
>>> Seasons._replace(Spring=2)                                             │
Seasons(Spring=2, Summer=1, Autumn=2, Winter=3)  
# Python3.4 中加入了列舉,僅在父類沒有任何列舉成員的時候才允許繼承

建議 12:不推薦使用 type 來進行型別檢查

作為動態語言,Python 直譯器會在執行時自動進行型別檢查並根據需要進行隱式型別轉換,當變數型別不同而兩者之間又不能進行隱式型別轉換時便丟擲TypeError異常。

>>> def add(a, b):
...     return a + b
... 
>>> add(1, 2j)
(1+2j)
>>> add('a', 'b')
'ab'
>>> add(1, 2)
3
>>> add(1.0, 2.3)
3.3
>>> add([1, 2], [3, 4])
[1, 2, 3, 4]
>>> add(1, 'a')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in add
TypeError: unsupported operand type(s) for +: 'int' and 'str'

所以實際應用中,我們常常需要進行型別檢查,但是不推薦使用type(),因為基於內建型別擴充套件的使用者自定義型別,type()並不能準確返回結果:

class UserInt(int):
    def __init__(self, val=0):
        self._val = int(val)
    def __add__(self, val):
        if isinstance(val, UserInt):
            return UserInt(self._val + val._val)
        return self._val + val
    def __iadd__(self, val):
        raise NotImplementedError("not support operation")
    def __str__(self):
        return str(self._val)
    def __repr__(self):
        return "Integer %s" % self._val
>>> n = UserInt()
>>> n
Integer 0
>>> print(n)
0
>>> m = UserInt(2)
>>> print(m)
2
>>> type(n) is int
False                   # 顯然不合理
>>> isinstance(n, int)
True

我們可以使用isinstance來檢查:isinstance(object, classinfo)

建議 13:儘量轉換為浮點型別後再做除法

# 計算平均成績績點
>>> gpa = ((4*96+3*85+5*98+2*70)*4) / ((4+3+5+2)*100)
>>> gpa
3.625714285714286   # 終於知道自己的績點是咋算的了

建議 14:警惕 eval() 的安全漏洞

eval(expression[, globals[, locals]])將字串 str 當成有效的表示式來求值並返回計算結果,globas為字典形式,locals為任何對映物件,它們分別表示全域性和區域性名稱空間,兩者都省略表示式將在呼叫的環境中執行,為什麼需要警惕eval()呢:

# 合理正確地使用
>>> eval("1+1==2")
True
>>> eval('"a"+"b"')
'ab'
# 壞心眼的geek
>>> eval('__import__("os").system("dir")')
Desktop  Documents  Downloads  examples.desktop  Music  Pictures  Public  __pycache__  Templates  Videos
0
>>> eval('__import__("os").system("del * /Q")')     # 嘿嘿嘿

如果確實需要使用eval,建議使用安全性更好的ast.literal_eval。

建議 15:使用 enumerate() 獲取序列迭代的索引和值

>>> li = ['a', 'b', 'c', 'd', 'e']
>>> for i, e in enumerate(li):
...     print('index: ', i, 'element: ', e)
... 
index:  0 element:  a
index:  1 element:  b
index:  2 element:  c
index:  3 element:  d
index:  4 element:  e
# enumerate(squence, start=0) 內部實現
def enumerate(squence, start=0):
    n = start
    for elem in sequence:
        yield n, elem   # 666
        n += 1
# 明白了原理我們自己也來實現一個反序的
def reversed_enumerate(squence):
    n = -1
    for elem in reversed(sequence):
        yield len(sequence) + n, elem
        n -= 1

建議 16:分清 == 與 is 的適用場景

操作符意義isobject identity==equal

is的作用是用來檢查物件的標示符是否一致,也就是比較兩個物件在記憶體中是否擁有同一塊記憶體空間,相當於id(x) == id(y),它並不適用於判斷兩個字串是否相等。==才是用來判斷兩個物件的值是否相等,實際是呼叫了內部的__eq__,所以a==b相當於a.__eq__(b),也就是說==是可以被過載的,而is不能被過載。

>>> s1 = 'hello world'
>>> s2 = 'hello world'
>>> s1 == s2
True
>>> s1 is s2
False
>>> s1.__eq__(s2)
True
>>> a = 'Hi'
>>> b = 'Hi'
>>> a == b
True
>>> a is b
True

咦~怎麼上例中的a, b又是“同一物件”了?這跟 Python 的 string interning 機制有關,為了提高系統效能,對於較小的字串會保留其值的一個副本,當建立新的字串時直接指向該副本,所以a和b的 id 值是一樣的,同樣對於小整數[-5, 257)也是如此:

>>> id(a)
140709793837832
>>> id(b)
140709793837832
>>> x = -5
>>> y = -5
>>> x is y
True
>>> id(x) == id(y)
True

建議 17:考慮相容性,儘可能使用 Unicode

我之前也總結過編碼的問題。由於最早的編碼是 ASCII 碼,只能表示 128 個字元,顯然這對其它語言編碼並不適用,Unicode就是為了不同的文字分配一套統一的編碼。

建議 18:構建合理的包層次來管理 module

本質上每一個 Python 檔案都是一個模組,使用模組可以增強程式碼的可維護性和可重用性,在較大的專案中,我們需要合理地組織專案層次來管理模組,這就是包(Package)的作用。

一句話說包:一個包含__init__.py 檔案的目錄。包中的模組可以通過.進行訪問,即包名.模組名。那麼這個__init__.py檔案有什麼用呢?最明顯的作用就是它區分了包和普通目錄,在該檔案中申明模組級別的 import 語句從而變成了包級別可見,另外在該檔案中定義__all__變數,可以控制需要匯入的子包或模組。

這裡給出一個較為合理的包組織方式,是FlaskWeb 開發:基於Python的Web應用開發實戰一書中推薦而來的:

|-flasky
    |-app/                      # Flask 程式
        |-templates/            # 存放模板
        |-static/               # 靜態檔案資源
        |-main/
            |-__init__.py
            |-errors.py         # 藍本中的錯誤處理程式
            |-forms.py          # 表單物件
            |-views.py          # 藍本中定義的程式路由
        |-__init__.py
        |-email.py              # 電子郵件支援
        |-models.py             # 資料庫模型
    |-migrations/               # 資料庫遷移指令碼
    |-tests/                    # 單元測試
        |-__init__.py
        |-test*.py
    |-venv/                     # 虛擬環境
    |-requirements/
        |-dev.txt               # 開發過程中的依賴包
        |-prod.txt              # 生產過程中的依賴包
    |-config.py                 # 儲存程式配置
    |-manage.py                 # 啟動程式以及其他的程式任務

第 3 章:基礎語法

建議 19:有節制地使用 from...import 語句

Python 提供三種方式來引入外部模組:import語句、from...import語句以及__import__函式,其中__import__函式顯式地將模組的名稱作為字串傳遞並賦值給名稱空間的變數。

使用import需要注意以下幾點:

  • 優先使用import a的形式
  • 有節制地使用from a import A
  • 儘量避免使用from a import *

為什麼呢?我們來看看 Python 的 import 機制,Python 在初始化執行環境的時候會預先載入一批內建模組到記憶體中,同時將相關資訊存放在sys.modules中,我們可以通過sys.modules.items()檢視預載入的模組資訊,當載入一個模組時,直譯器實際上完成了如下動作:

  1. 在sys.modules中搜尋該模組是否存在,如果存在就匯入到當前區域性名稱空間,如果不存在就為其建立一個字典物件,插入到sys.modules中
  2. 載入前確認是否需要對模組對應的檔案進行編譯,如果需要則先進行編譯
  3. 執行動態載入,在當前名稱空間中執行編譯後的位元組碼,並將其中所有的物件放入模組對應的字典中
>>> dir()
['__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__']
>>> import test
testing module import
>>> dir()
['__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'test']
>>> import sys
>>> ‘test’ in sys.modules.keys()
True
>>> id(test)
140367239464744
>>> id(sys.modules['test'])
140367239464744
>>> dir(test)
['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'a', 'b']
>>> sys.modules['test'].__dict__.keys()
dict_keys(['__file__', '__builtins__', '__doc__', '__loader__', '__package__', '__spec__', '__name__', 'b', 'a', '__cached__'])

從上可以看出,對於使用者自定義的模組,import 機制會建立一個新的 module 將其加入當前的區域性名稱空間中,同時在 sys.modules 也加入該模組的資訊,但本質上是在引用同一個物件,通過test.py所在的目錄會多一個位元組碼檔案。

建議 20:優先使用 absolute import 來匯入模組

建議 21: i+=1 不等於 ++i

首先++i或--i在 Python 語法上是合法,但並不是我們通常理解的自增或自減操作:

>>> ++1     # +(+1)
1
>>> --1     # -(-1)
1
>>> +++2
2
>>> ---2
-2

原來+或-只表示正負數符號。

建議 22:使用 with 自動關閉資源

對於開啟的資源我們記得關閉它,如檔案、資料庫連線等,Python 提供了一種簡單優雅的解決方案:with。

先來看with實現的原理吧。

with的實現得益於一個稱為上下文管理器(context manager)的東西,它定義程式執行時需要建立的上下文,處理程式的進入和退出,實現了上下文管理協議,即物件中定義了__enter__()和__exit__(),任何實現了上下文協議的物件都可以稱為一個上下文管理器:

  • __enter__():返回執行時上下文相關的物件
  • __exit__(exception_type, exception_value, traceback):退出執行時的上下文,處理異常、清理現場等

包含with語句的程式碼塊執行過程如下:

with 表示式 [as 目標]:
    程式碼塊
# 例
>>> with open('test.txt', 'w') as f:
...     f.write('test')
... 
4
>>> f.__enter__
<built-in method __enter__ of _io.TextIOWrapper object at 0x7f1b967aaa68>
>>> f.__exit__
<built-in method __exit__ of _io.TextIOWrapper object at 0x7f1b967aaa68>
  1. 計算表示式的值,返回一個上下文管理器物件
  2. 載入上下文管理器物件的__exit__()以備後用
  3. 呼叫上下文管理器物件的__enter__()
  4. 將__enter__()的返回值賦給目標物件
  5. 執行程式碼塊,正常結束呼叫__exit__(),其返回值直接忽略,如果發生異常,會呼叫__exit__()並將異常型別、值及 traceback 作為引數傳遞給__exit__(),__exit__()返回值為 false 異常將會重新丟擲,返回值為 true 異常將被掛起,程式繼續執行

於此,我們可以自定義一個上下文管理器:

>>> class MyContextManager(object):
...     def __enter__(self):
...         print('entering...')
...     def __exit__(self, exception_type, exception_value, traceback):
...         print('leaving...')
...         if exception_type is None:
...             print('no exceptions!')
...             return False
...         elif exception_type is ValueError:
...             print('value error!')
...             return True
...         else:
...             print('other error')
...             return True
... 
>>> with MyContextManager():
...     print('Testing...')
... 
entering...
Testing...
leaving...
no exceptions!
>>> with MyContextManager():
...     print('Testing...')
...     raise(ValueError)
... 
entering...
Testing...
leaving...
value error!

Python 還提供contextlib模組,通過 Generator 實現,其中的 contextmanager 作為裝飾器來提供一種針對函式級別上的上下文管理器,可以直接作用於函式/物件而不必關心__enter__()和__exit__()的實現。

推薦文章

建議 23:使用 else 子句簡化迴圈(異常處理)

Python 的 else 子句提供了隱含的對迴圈是否由 break 語句引發迴圈結束的判斷,有點繞哈,來看例子:

>>> def print_prime(n):
...     for i in range(2, n):
...         for j in range(2, i):
...             if i % j == 0:
...                 break
...         else:
...             print('{} is a prime number'.format(i))
... 
>>> print_prime(7)
2 is a prime number
3 is a prime number
5 is a prime number

可以看出,else 子句在迴圈正常結束和迴圈條件不成立時被執行,由 break 語句中斷時不執行,同樣,我們可以利用這顆語法糖作用在 while 和 try...except 中。

改善 Python 程式的 91 個建議(二)

相關文章