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

馭風者發表於2017-05-17

第 6 章 內部機制

建議 61:使用更加安全的 property

property 實際上是一種實現了 __get__() 、 __set__() 方法的類,使用者也可以根據自己的需要定義個性化的 property,其實質是一種特殊的資料描述符(資料描述符:如果一個物件同時定義了 __get__() 和 __set__() 方法,則稱為資料描述符,如果僅定義了__get__() 方法,則稱為非資料描述符)。它和普通描述符的區別在於:普通描述符提供的是一種較為低階的控制屬性訪問的機制,而 property 是它的高階應用,它以標準庫的形式提供描述符的實現,其簽名形式為:

property(fget=None, fset=None, fdel=None, doc=None) -> property attribute

property 有兩種常用的形式:

1、第一種形式

class Some_Class(object):
    def __init__(self):
        self._somevalue = 0
    def get_value(self):
        print('calling get method to return value')
        return self._somevalue
    def set_value(self, value):
        print('calling set method to set value')
        self._somevalue = value
    def def_attr(self):
        print('calling delete method to delete value')
        def self._somevalue
    x = property(get_value, set_value, del_attr, "I'm the 'x' property.")
obj = Some_Class()
obj.x = 10
print(obj.x + 2)
del obj.x
obj.x

2、第二種形式

class Some_Class(self):
    _x = None
    def __init__(self):
        self._x = None
    @property
    def x(self):
        print('calling get method to return value')
        return self._x
    @x.setter
    def x(self, value):
        print('calling set method to set value')
        self._x = value
    @x.deleter
    def x(self):
        print('calling delete method to delete value')
        del self._x

以上我們可以總結出 property 的優勢:

1、程式碼更簡潔,可讀性更強

2、更好的管理屬性的訪問。property 將對屬性的訪問直接轉換為對對應的 get、set 等相關函式的呼叫,屬效能夠更好地被控制和管理,常見的應用場景如設定校驗(如檢查電子郵件地址是否合法)、檢查賦值的範圍(某個變數的賦值範圍必須在 0 到 10 之間)以及對某個屬性進行二次計算之後再返回給使用者(將 RGB 形式表示的顏色轉換為#******)或者計算某個依賴於其他屬性的屬性。

class Date(object):
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    def get_date(self):
        return self.year + '-' + self.month + '-' + self.day
    def set_date(self, date_as_string):
        year, month, day = date_as_string.split('-')
        if not (2000 <= year <= 2017 and 0 <= month <= 12 and 0 <= day <= 31):
            print('year should be in [2000:2017]')
            print('month should be in [0:12]')
            print('day should be in [0, 31]')
            raise AssertionError
        self.year = year
        self.month = month
        self.day = day
    date = property(get_date, set_date)

建立一個 property 實際上就是將其屬性的訪問與特定的函式關聯起來,相對於標準屬性的訪問,property 的作用相當於一個分發器,對某個屬性的訪問並不直接操作具體的物件,而對標準屬性的訪問沒有中間這一層,直接訪問儲存屬性的物件:

3、程式碼可維護性更好。property 對屬性進行再包裝,以類似於介面的形式呈現給使用者,以統一的語法來訪問屬性,當具體實現需要改變的時候,訪問的方式仍然可以保持一致。

4、控制屬性訪問許可權,提高資料安全性。如果使用者想設定某個屬性為只讀,來看看 property 是如何實現的。

class PropertyTest(object):
    def __init__(self):
        self.__var1 = 20
    @property
    def x(self):
        return self.__var1
pt = PropertyTest()
print(pt.x)
pt.x = 12

注意這樣使用 property 並不能真正意義達到屬性只讀的目的,正如以雙下劃線命令的變數並不是真正的私有變數一樣,我們還是可以通過pt._PropertyTest__var1 = 30來修改屬性。稍後我們會討論如何實現真正意義上的只讀和私有變數。

既然 property 本質是特殊類,那麼就可以被繼承,我們就可以自定義 property:

def update_meta(self, other):
    self.__name__ = other.__name__
    self.__doc__ = other.__doc__
    self.__dict__.update(other.__dict__)
    return self
class UserProperty(property):
    def __new__(cls, fget=None, fset=None, fdel=None, doc=None):
        if fget is not None:
            def __get__(obj, objtype=None, name=fget.__name__):
                fegt = getattr(obj, name)
                return fget()
            fget = update_meta(__get__, fget)
        if fset is not None:
            def __set__(obj, value, name=fset.__name__):
                fset = getattr(obj, name)
                return fset(value)
            fset = update_meta(__set__, fset)
        if fdel is not None:
            def __delete__(obj, name=fdel.__name__):
                fdel = getattr(obj, name)
                return fdel()
            fdel = update_meta(__delete__, fdel)
        return property(fget, fset, fdel, doc)
class C(object):
    def get(self):
        return self._x
    def set(self, x):
        self._x = x
    def delete(self):
        del self._x
    x = UserProperty(get, set, delete)
c = C()
c.x = 1
print(c.x)
def c.x

UserProperty 繼承自 property,其建構函式 __new__(cls, fget=None, fset=None, fdel=None, doc=None) 中重新定義了 fget() 、 fset() 以及 fdel() 方法以滿足使用者特定的需要,最後返回的物件實際還是 property 的例項,因此使用者能夠像使用 property 一樣使用 UserProperty。

使用 property 並不能真正完全達到屬性只讀的目的,使用者仍然可以繞過阻礙來修改變數。我們來看看一個可行的實現:

def ro_property(obj, name, value):
    setattr(obj.__class__, name, property(lambda obj: obj.__dict__["__" + name]))
    setattr(obj, "__" + name, value)

class ROClass(object):
    def __init__(self, name, available):
        ro_property(self, "name", name)
        self.available = available

a = ROClass("read only", True)
print(a.name)
a._Article__name = "modify"
print(a.__dict__)
print(ROClass.__dict__)
print(a.name)

建議 62:掌握 metaclass

關於元類這知識點,推薦stackoverflow上Jerub回答

這裡有中文翻譯

建議 63:熟悉 Python 物件協議

因為 Python 是一門動態語言,Duck Typing 的概念遍佈其中,所以其中的 Concept 並不以型別的約束為載體,而另外使用稱為協議的概念。

In [1]: class Object(object):
   ...:     def __str__(self):
   ...:         print('calling __str__')
   ...:         return super(Object, self).__str__()
   ...:     

In [2]: o = Object()

In [3]: print('%s' % o)
calling __str__
<__main__.Object object at 0x7f133ff20160>

比如在字串格式化中,如果有佔位符 %s,那麼按照字串轉換的協議,Python 會自動地呼叫相應物件的 __str__() 方法。

總結一下 Python 中的協議:

1、型別轉換協議:__str__() 、__repr__()、__init__()、__long__()、__float__()、__nonzero__() 等。

2、比較大小的協議:__cmp__(),當兩者相等時,返回 0,當 self < other 時返回負值,反之返回正值。同時 Python 又有 __eq__()、__ne__()、__lt__()、__gt__() 等方法來實現相等、不等、小於和大於的判定。這也就是 Python 對 ==、!=、< 和 > 等操作符的進行過載的支撐機制。

3、數值相關的協議:

其中有個 Python 中特有的概念:反運算。以something + other為例,呼叫的是something的__add__(),若沒有定義__add__(),這時候 Python 有一個反運算的協議,檢視other有沒有__radd__(),如果有,則以something為引數呼叫。

4、容器型別協議:容器的協議是非常淺顯的,既然為容器,那麼必然要有協議查詢內含多少物件,在 Python 中,就是要支援內建函式 len(),通過 __len__() 來完成,一目瞭然。而 __getitem__()、__setitem__()、__delitem__() 則對應讀、寫和刪除,也很好理解。__iter__() 實現了迭代器協議,而 __reversed__() 則提供對內建函式 reversed() 的支援。容器型別中最有特色的是對成員關係的判斷符 in 和 not in 的支援,這個方法叫 __contains__(),只要支援這個函式就能夠使用 in 和 not in 運算子了。

5、可呼叫物件協議:所謂可呼叫物件,即類似函式物件,能夠讓類例項表現得像函式一樣,這樣就可以讓每一個函式呼叫都有所不同。

In [1]: class Functor(object):
   ...:     def __init__(self, context):
   ...:         self._context = context
   ...:     def __call__(self):
   ...:         print('do something with %s' % self._context)
   ...:         

In [2]: lai_functor = Functor('lai')

In [3]: yong_functor = Functor('yong')

In [4]: lai_functor()
do something with lai

In [5]: yong_functor()
do something with yong

6、還有一個可雜湊物件,它是通過 __hash__() 方法來支援 hash() 這個內建函式的,這在建立自己的型別時非常有用,因為只有支援可雜湊協議的型別才能作為 dict 的鍵型別(不過只要繼承自 object 的新式類就預設支援了)。

7、上下文管理器協議:也就是對with語句的支援,該協議通過__enter__()和__exit__()兩個方法來實現對資源的清理,確保資源無論在什麼情況下都會正常清理:

class Closer:
    def __init__(self):
        self.obj = obj
    def __enter__(self):
        return self.obj
    def __exit__(self, exception_type, exception_val, trace):
        try:
            self.obj.close()
        except AttributeError:
            print('Not closeable.')
            return True

這裡 Closer 類似的類已經在標準庫中存在,就是 contextlib 裡的 closing。

以上就是常用的物件協議,靈活地用這些協議,我們可以寫出更為 Pythonic 的程式碼,它更像是宣告,沒有語言上的約束,需要大家共同遵守。

建議 64:利用操作符過載實現中綴語法

熟悉 Shell 指令碼程式設計應該熟悉|管道符號,用以連線兩個程式的輸入輸出。如按字母表反序遍歷當前目錄的檔案與子目錄:

$ ls | sort -r
Videos/
Templates/
Public/
Pictures/
Music/
examples.desktop
Dropbox/
Downloads/
Documents/
Desktop/

管道的處理非常清晰,因為它是中綴語法。而我們常用的 Python 是字首語法,比如類似的 Python 程式碼應該是 sort(ls(), reverse=True)。

Julien Palard 開發了一個 pipe 庫,利用|來簡化程式碼,也就是過載了 __ror__() 方法:

class Pipe:
    def __init__(self, function):
        self.function = function
    def __ror__(self, other):
        return self.function(other)
    def __call__(self, *args, **kwargs):
        return Pipe(lambda x: self.function(x, *args, **kwargs))

這個 Pipe 類可以當成函式的 decorator 來使用。比如在列表中篩選資料:

@Pipe
def where(iterable, predicate):
    return (x for x in iterable if (predicate(x)))

pipe 庫內建了一堆這樣的處理函式,比如 sum、select、where 等函式盡在其中,請看以下程式碼:

fib() | take_while(lambda x: x < 1000000) \
      | where(lambda x: x % 2) \
      | select(lambda x: x * x) \
      | sum()

這樣寫的程式碼,意義是不是一目瞭然呢?就是找出小於 1000000 的斐波那契數,並計算其中的偶數的平方之和。

我們可以使用pip3 install pipe安裝,安裝完後測試:

In [1]: from pipe import *

In [2]: [1, 2, 3, 4, 5] | where(lambda x: x % 2) | tail(2) | select(lambda x: x * x) | add
Out[2]: 34

此外,pipe 是惰性求值的,所以我們完全可以弄一個無窮生成器而不用擔心記憶體被用完:

In [3]: def fib():
   ...:     a, b = 0, 1
   ...:     while True:
   ...:         yield a
   ...:         a, b = b, a + b
   ...:         

In [4]: euler2 = fib() | where(lambda x: x % 2 ==0) | take_while(lambda x: x < 400000) | add

In [5]: euler2
Out[5]: 257114

讀取檔案,統計檔案中每個單詞出現的次數,然後按照次數從高到低對單詞排序:

from __future__ import print_function
from re import split
from pipe import *
with open("test_descriptor.py") as f:
    print(f.read()
          | Pipe(lambda x: split("/W+", x))
          | Pipe(lambda x:(i for i in x if i.strip()))
          | groupby(lambda x:x)
          | select(lambda x:(x[0], (x[1] | count)))
          | sort(key=lambda x: x[1], reverse=True)
          )

建議 65:熟悉 Python 的迭代器協議

首先介紹一下 iter() 函式,iter() 可以輸入兩個實參,為了簡化,第二個可選引數可以忽略。iter() 函式返回一個迭代器物件,接受的引數是一個實現了 __iter__() 方法的容器或迭代器(精確來說,還支援僅有 __getitem__() 方法的容器)。對於容器而言,__iter__() 方法返回一個迭代器物件,而對迭代器而言,它的 __iter__() 方法返回其自身。

所謂協議,是一種鬆散的約定,並沒有相應的介面定義,所以把協議簡單歸納如下:

  1. 實現 __iter__() 方法,返回一個迭代器

  2. 實現 next() 方法,返回當前的元素,並指向下一個元素的位置,如果當前位置已無元素,則丟擲 StopIteration 異常

沒錯,其實 for 語句就是對獲取容器的迭代器、呼叫迭代器的 next() 方法以及對 StopIteration 進行處理等流程進行封裝的語法糖(類似的語法糖還有 in/not in 語句)。

迭代器最大的好處是定義了統一的訪問容器(或集合)的統一介面,所以程式設計師可以隨時定義自己的迭代器,只要實現了迭代器協議即可。除此之外,迭代器還有惰性求值的特性,它僅可以在迭代至當前元素時才計算(或讀取)該元素的值,在此之前可以不存在,在此之後也可以銷燬,也就是說不需要在遍歷之前實現準備好整個迭代過程中的所有元素,所以非常適合遍歷無窮個元素的集合或或巨大的事物(斐波那契數列、檔案):

class Fib(object):
    def __init__(self):
        self._a, self._b = 0, 1
    def __iter__(self):
        return self
    def next(self):
        self._a, self._b = self._b, self._a + self._b
        return self._a
for i, f in enumerate(Fib()):
    print(f)
    if i > 10:
        break

下面來看看與迭代有關的標準庫 itertools。

itertools 的目標是提供一系列計算快速、記憶體高效的函式,這些函式可以單獨使用,也可以進行組合,這個模組受到了 Haskell 等函數語言程式設計語言的啟發,所以大量使用 itertools 模組中的函式的程式碼,看起來有點像函數語言程式設計語言。比如 sum(imap(operator.mul, vector1, vector2)) 能夠用來執行兩個向量的對應元素乘積之和。

itertools 提供了以下幾個有用的函式:chain() 用以同時連續地迭代多個序列;compress()、dropwhile() 和 takewhile() 能用遴選序列元素;tee() 就像同名的 UNIX 應用程式,對序列作 n 次迭代;而 groupby 的效果類似 SQL 中相同拼寫的關鍵字所帶的效果。

[k for k, g in groupby("AAAABBBCCDAABB")] --> A B C D A B
[list(g) for k, g in groupby("AAAABBBCCD")] --> AAAA BBB CC D

除了這些針對有限元素的迭代幫助函式之外,還有 count()、cycle()、repeat() 等函式產生無窮序列,這 3 個函式就分別可以產生算術遞增數列、無限重複實參的序列和重複產生同一個值的序列。

組合函式意義product()計算 m 個序列的 n 次笛卡爾積permutations()產生全排列combinations()產生無重複元素的組合combinations_with_replacement()產生有重複元素的組合

In [1]: from itertools import *

In [2]: list(product('ABCD', repeat=2))
Out[2]: 
[('A', 'A'),
 ('A', 'B'),
 ('A', 'C'),
 ('A', 'D'),
 ('B', 'A'),
 ('B', 'B'),
 ('B', 'C'),
 ('B', 'D'),
 ('C', 'A'),
 ('C', 'B'),
 ('C', 'C'),
 ('C', 'D'),
 ('D', 'A'),
 ('D', 'B'),
 ('D', 'C'),
 ('D', 'D')]
# 其中 product() 可以接受多個序列
In [5]: for i in product('ABC', '123', repeat=2):
   ...:     print(''.join(i))
   ...:     
A1A1
A1A2
A1A3
A1B1
A1B2
A1B3
A1C1
A1C2
...

建議 66:熟悉 Python 的生成器

生成器,顧名思義,就是按一定的演算法生成一個序列。

迭代器雖然在某些場景表現得像生成器,但它絕非生成器;反而是生成器實現了迭代器協議的,可以在一定程度上看作迭代器。

如果一個函式,使用了 yield 關鍵字,那麼它就是一個生成器函式。當呼叫生成器函式時,它返回一個迭代器,不過這個迭代器是以生成器物件的形式出現的:

In [1]: def fib(n):
   ...:     a, b = 0, 1
   ...:     while a < n:
   ...:         yield a
   ...:         a, b = b, a + b
   ...: for i, f in enumerate(fib(10)):
   ...:     print(f)
   ...:     
0
1
1
2
3
5
8

In [2]: f = fib(10)

In [3]: type(f)
Out[3]: generator

In [4]: dir(f)
Out[4]: 
['__class__',
 '__del__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__iter__',
 '__le__',
 '__lt__',
 '__name__',
 '__ne__',
 '__new__',
 '__next__',
 '__qualname__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 'close',
 'gi_code',
 'gi_frame',
 'gi_running',
 'gi_yieldfrom',
 'send',
 'throw']

可以看到它返回的是一個 generator 型別的物件,這個物件帶有__iter__()和__next__()方法,可見確實是一個迭代器。

分析:

  1. 每一個生成器函式呼叫之後,它的函式並不執行,而是到第一次呼叫 next() 的時候才開始執行;

  2. yield 表示式的預設返回值為 None,當第一次呼叫 next() 方法時,生成器函式開始執行,執行到 yield 表示式為止;

  3. 再次呼叫next()方法,函式將在上次停止的地方繼續執行。

send() 是全功能版本的 next(),或者說 next() 是 send()的快捷方式,相當於 send(None)。還記得 yield 表示式有一個返回值嗎?send() 方法的作用就是控制這個返回值,使得 yield 表示式的返回值是它的實參。

除了能 yield 表示式的“返回值”之外,也可以讓它丟擲異常,這就是 throw() 方法的能力。

對於常規業務邏輯的程式碼來說,對特定的異常有很好的處理(比如將異常資訊寫入日誌後優雅的返回),從而實現從外部影響生成器內部的控制流。

當呼叫 close() 方法時,yield 表示式就丟擲 GeneratorExit 異常,生成器物件會自行處理這個異常。當呼叫 close() 方法,再次呼叫 next()、send() 會使生成器物件丟擲 StopIteration 異常。換言之,這個生成器物件已經不再可用。當生成器物件被 GC 回收時,會自動呼叫 close()。

生成器還有兩個很棒的用處:

  • 實現 with 語句的上下文管理協議,利用的是呼叫生成器函式時函式體並不執行,當第一次呼叫 next() 方法時才開始執行,並執行到 yield 表示式後中止,直到下一次呼叫 next() 方法這個特性;

  • 實現協程,利用的是 send()、throw()、close() 等特性。

第二個用處在下一個小節講解,先看第一個:

In [1]: with open('/tmp/test.txt', 'w') as f:
   ...:     f.write('Hello, context manager.')
   ...:     

In [2]: from contextlib import contextmanager

In [3]: @contextmanager
   ...: def tag(name):
   ...:     print('<%s>' % name)
   ...:     yield
   ...:     print('<%s>' % name)
   ...:     

In [4]: with tag('h1'):
   ...:     print('foo')
   ...:     
<h1>
foo
<h1>

這是 Python 文件中的例子。通過 contextmanager 對 next()、throw()、close() 的封裝,yield 大大簡化了上下文管理器的程式設計複雜度,對提高程式碼可維護性有著極大的意義。除此之外,yield 和 contextmanager 也可以用以“池”模式中對資源的管理和回收,具體的實現留給大家去思考。

建議 67:基於生成器的協程及 greenlet

先介紹一下協程的概念:

協程,又稱微執行緒和纖程等,據說源於 Simula 和 Modula-2 語言,現代程式語言基本上都支援這個特性,比如 Lua 和 ruby 都有類似的概念。

協程往往實現在語言的執行時庫或虛擬機器中,作業系統對其存在一無所知,所以又被稱為使用者空間執行緒或綠色執行緒。又因為大部分協程的實現是協作式而非搶佔式的,需要使用者自己去排程,所以通常無法利用多核,但用來執行協作式多工非常合適。用協程來做的東西,用執行緒或程式通常也是一樣可以做的,但往往多了許多加鎖和通訊的操作。

基於生產著消費者模型,比較搶佔式多執行緒程式設計實現和協程程式設計實現。執行緒實現至少有兩點硬傷:

  • 對佇列的操作需要有顯式/隱式(使用執行緒安全的佇列)的加鎖操作。

  • 消費者執行緒還要通過 sleep 把 CPU 資源適時地“謙讓”給生產者執行緒使用,其中的適時是多久,基本上只能靜態地使用經驗,效果往往不盡如人意。

下面來看看協程的解決方案,程式碼來自廖雪峰 Python3 教程

def consumer():
    r = ''
    while True:
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        r = '200 OK'

def produce(c):
    c.send(None)
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()

c = consumer()
produce(c)

執行結果:

[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK

注意到consumer函式是一個generator,把一個consumer傳入produce後:

  1. 首先呼叫c.send(None)啟動生成器;

  2. 然後,一旦生產了東西,通過c.send(n)切換到consumer執行;

  3. consumer通過yield拿到訊息,處理,又通過yield把結果傳回;

  4. produce拿到consumer處理的結果,繼續生產下一條訊息;

  5. produce決定不生產了,通過c.close()關閉consumer,整個過程結束。

整個流程無鎖,由一個執行緒執行,produce和consumer協作完成任務,所以稱為“協程”,而非執行緒的搶佔式多工。

最後套用Donald Knuth的一句話總結協程的特點:

“子程式就是協程的一種特例。”

greenlet 是一個 C 語言編寫的程式庫,它與 yield 關鍵字沒有密切的關係。greenlet 這個庫裡最為關鍵的一個型別就是 PyGreenlet 物件,它是一個 C 結構體,每一個 PyGreenlet 都可以看到一個呼叫棧,從它的入口函式開始,所有的程式碼都在這個呼叫棧上執行。它能夠隨時記錄程式碼執行現場,並隨時中止,以及恢復。它跟 yield 所能夠做到的相似,但更好的是它提供從一個 PyGreenlet 切換到另一個 PyGreenlet 的機制。

from greenlet import greenlet
def test1():
    print(12)
    gr2.switch()
    print(34)
def test2():
    print(56)
    gr1.switch()
    print(78)
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()

協程雖然不能充分利用多核,但它跟非同步 I/O 結合起來以後編寫 I/O 密集型應用非常容易,能夠在同步的程式碼表面下實現非同步的執行,其中的代表當屬將 greenlet 與 libevent/libev 結合起來的 gevent 程式庫,它是 Python 網路程式設計庫。最後,以 gevent 併發查詢 DNS 的例子為例,使用它進行併發查詢 n 個域名,能夠獲得幾乎 n 倍的效能提升:

In [1]: import gevent

In [2]: from gevent import socket

In [3]: urls = ['www.baidu.com', 'www.python.org', 'www.qq.com']

In [4]: jobs = [gevent.spawn(socket.gethostbyname, url) for url in urls]

In [5]: gevent.joinall(jobs, timeout=2)
Out[5]: 
[<Greenlet at 0x7f37e439c508>,
 <Greenlet at 0x7f37e439c5a0>,
 <Greenlet at 0x7f37e439c340>]

In [6]: [job.value for job in jobs]
Out[6]: ['115.239.211.112', '151.101.24.223', '182.254.34.74']

建議 68:理解 GIL 的侷限性

多執行緒 Python 程式執行的速度比只有一個執行緒的時候還要慢,除了程式本身的並行性之外,很大程度上與 GIL 有關。由於 GIL 的存在,多執行緒程式設計在 Python 中並不理想。GIL 被稱為全域性直譯器鎖(Global Interpreter Lock),是 Python 虛擬機器上用作互斥執行緒的一種機制,它的作用是保證任何情況下虛擬機器中只會有一個執行緒被執行,而其他執行緒都處於等待 GIL 鎖被釋放的狀態。不管是在單核系統還是多核系統中,始終只有一個獲得了 GIL 鎖的執行緒在執行,每次遇到 I/O 操作便會進行 GIL 鎖的釋放。

但如果是純計算的程式,沒有I/O操作,直譯器則會根據sys.setcheckinterval的設定來自動進行執行緒間的切換,預設是每隔100個內部時鐘就會釋放GIL鎖從而輪換到其他執行緒:

在單核 CPU 中,GIL 對多執行緒的執行並沒有太大影響,因為單核上的多執行緒本質上就是順序執行的。但對於多核 CPU,多執行緒並不能真正發揮優勢帶來效率上明顯的提升,甚至在頻繁 I/O 操作的情況下由於存在需要多次釋放和申請 GIL 的情形,效率反而會下降。

那麼 Python 直譯器為什麼要引入 GIL 呢?

我們知道 Python 中物件的管理與引用計數器密切相關,當計數器變為 0 的時候,該物件便會被垃圾回收器回收。當撤銷一個物件的引用時,Python 直譯器對物件以及其計數器的管理分為以下兩步:

  1. 使引用計數值減1

  2. 判斷該計數值是否為 0,如果為0,則銷燬該物件

鑑於此,Python 引入了 GIL,以保證對虛擬機器內部共享資源訪問的互斥性。

GIL 的引入確實使得多執行緒不能再多核系統中發揮優勢,但它也帶來了一些好處:大大簡化了 Python 執行緒中共享資源的管理,在單核 CPU 上,由於其本質是順序執行的,一般情況下多執行緒能夠獲得較好的效能。此外,對於擴充套件的 C 程式的外部呼叫,即使其不是執行緒安全的,但由於 GIL 的存在,執行緒會阻塞直到外部呼叫函式返回,執行緒安全不再是一個問題。

在 Python3.2 中重新實現了 GIL,其實現機制主要集中在兩個方面:一方面是使用固定的時間而不是固定數量的操作指令來進行執行緒的強制切換;另一個方面是線上程釋放 GIL 後,開始等待,直到某個其他執行緒獲取 GIL 後,再開始嘗試去獲取 GIL,這樣雖然可以避免此前獲得 GIL 的執行緒,不會立即再次獲取 GIL,但仍然無法保證優先順序高的執行緒優先獲取 GIL。這種方式只能解決部分問題,並未改變 GIL 的本質。

Python 提供了其他方式可以繞過 GIL 的侷限,比如使用多程式 multiprocess 模組或者採用 C 語言擴充套件的方式,以及通過 ctypes 和 C 動態庫來充分利用物理核心的計算能力。

建議 69:物件的管理與垃圾回收

class Leak(object):
    def __init__(self):
        print('object with id %d was born' % id(self))
while(True):
    A = Leak()
    B = Leak()
    A.b = B
    B.a = A
    A = None
    B = None

執行上述程式,我們會發現 Python 佔用的記憶體消耗一直在持續增長,直到最後記憶體耗光。

先簡單談談 Python 中的記憶體管理的方式:

Python 使用引用計數器(Reference counting)的方法來管理記憶體中的物件,即針對每一個物件維護一個引用計數值來表示該物件當前有多少個引用。

當其他物件引用該物件時,其引用計數會增加 1,而刪除一個隊當前物件的引用,其引用計數會減 1。只有當引用計數的值為 0 時的時候該物件才會被垃圾收集器回收,因為它表示這個物件不再被其他物件引用,是個不可達物件。引用計數演算法最明顯的缺點是無法解決迴圈引用的問題,即兩個物件相互引用。如同上述程式碼中A、B物件之間相互迴圈引用造成了記憶體洩露,因為兩個物件的引用計數都不為 0,該物件也不會被垃圾回收器回收,而無限迴圈導致一直在申請記憶體而沒有釋放。

迴圈引用常常會在列表、元組、字典、例項以及函式使用時出現。對於由迴圈引用而導致的記憶體洩漏的情況,可以使用 Python 自帶的一個 gc 模組,它可以用來跟蹤物件的“入引用(incoming reference)“和”出引用(outgoing reference)”,並找出複雜資料結構之間的迴圈引用,同時回收記憶體垃圾。有兩種方式可以觸發垃圾回收:一種是通過顯式地呼叫 gc.collect() 進行垃圾回收;還有一種是在建立新的物件為其分配記憶體的時候,檢查 threshold 閾值,當物件的數量超過 threshold 的時候便自動進行垃圾回收。預設情況下閾值設為(700,10,10),並且 gc 的自動回收功能是開啟的,這些可以通過 gc.isenabled() 檢視:

In [1]: import gc

In [2]: print(gc.isenabled())
True

In [3]: gc.isenabled()
Out[3]: True

In [4]: gc.get_threshold()
Out[4]: (700, 10, 10)

所以修改之前的程式碼:

def main():
    collected = gc.collect()
    print("Garbage collector before running: collected {} objects.".format(collected))
    print("Creating reference cycles...")
    A = Leak()
    B = Leak()
    A.b = B
    B.a = A
    A = None
    B = None
    collected = gc.collect()
    print(gc.garbage)
    print("Garbage collector after running: collected {} objects".format(collected))

if __name__ == "__main__":
    ret = main()
    sys.exit(ret)

gc.garbage 返回的是由於迴圈引用而產生的不可達的垃圾物件的列表,輸出為空表示記憶體中此時不存在垃圾物件。gc.collect() 顯示所有收集和銷燬的物件的數目,此處為 4(2 個物件 A、B,以及其例項屬性 dict)。

我們再來考慮一個問題:如果在類 Leak 中新增析構方法 __del__(),會發現 gc.garbage 的輸出不再為空,而是物件 A、B 的記憶體地址,也就是說這兩個物件在記憶體中仍然以“垃圾”的形式存在。

這是什麼原因呢?實際上當存在迴圈引用並且當這個環中存在多個析構方法時,垃圾回收器不能確定物件析構的順序,所以為了安全起見仍然保持這些物件不被銷燬。而當環被打破時,gc 在回收物件的時候便會再次自動呼叫 __del__() 方法。

gc 模組同時支援 DEBUG 模式,當設定 DEBUG 模式之後,對於迴圈引用造成的記憶體洩漏,gc 並不釋放記憶體,而是輸出更為詳細的診斷資訊為發現記憶體洩漏提供便利,從而方便程式設計師進行修復。更多 gc 模組可以參考文件

第 7 章 使用工具輔助專案開發

Python 專案的開發過程,其實就是一個或多個包的開發過程,而這個開發過程又由包的安裝、管理、測試和釋出等多個節點構成,所以這是一個複雜的過程,使用工具進行輔助開發有利於減少流程損耗,提升生產力。本章將介紹幾個常用的、先進的工具,比如 setuptools、pip、paster、nose 和 Flask-PyPI-Proxy 等。

建議 70:從 PyPI 安裝包

PyPI 全稱 Python Package Index,直譯過來就是“Python 包索引”,它是 Python 程式語言的軟體倉庫,類似 Perl 的 CPAN 或 Ruby 的 Gems。

$ tar zxvf requests-1.2.3.tar.gz
$ cd requests-1.2.3
$ python setup.py install
$ sudo aptitude install python-setuptools   # 自動安裝包

建議 71:使用 pip 和 yolk 安裝、管理包

pip 常用命令:

$ pip install package_name
$ pip uninstall package_name
$ pip show package_name
$ pip freeze

建議 72:做 paster 建立包

distutils 標準庫,至少提供了以下幾方面的內容:

  • 支援包的構建、安裝、釋出(打包)

  • 支援 PyPI 的登記、上傳

  • 定義了擴充套件命令的協議,包括 distutils.cmd.Command 基類、distutils.commands 和 distutils.key_words 等入口點,為 setuptools 和 pip 等提供了基礎設施。

要使用 distutils,按習慣需要編寫一個 setup.py 檔案,作為後續操作的入口點。在arithmetic.py同層目錄下建立一個setup.py檔案,內容如下:

from distutils.core import setup
setup(name="arithmetic",
     version='1.0',
     py_modules=["your_script_name"],
     )

setup.py 檔案的意義是執行時呼叫 distutils.core.setup() 函式,而實參是通過命名引數指定的。name 引數指定的是包名;version 指定的是版本;而 py_modules 引數是一個序列型別,裡面包含需要安裝的 Python 檔案。

編寫好 setup.py 檔案以後,就可以使用 python setup.py install 進行安裝了。

distutils 還帶有其他命令,可以通過 python setup.py --help-commands 進行查詢。

實際上若要把包提交到 PyPI,還要遵循 PEP241,給出足夠多的後設資料才行,比如對包的簡短描述、詳細描述、作者、作者郵箱、主頁和授權方式等:

setup(
    name='requests',
    version=requests.__version__,
    description='Python HTTP for Humans.',
    long_description=open('README.rst').read() + '\n\n' +
    				open('HISTORY.rst').read(),
    author='Kenneth Reitz',
    author_email='me@kennethreitz.com',
    url='http://python-requests.org',
    packages=packages,
    package_data={'': ['LICENSE', 'NOTICE'], 'requests': ['*.pem']},
    package_dir={'requests': 'requests'},
    include_package_data=True,
    install_requires=requires,
    license=open('LICENSE').read(),
    zip_safe=False,
    classifiers=(
    	'Development Status :: 5 - Production/Stable',
        'Intended Audience :: Developers',
        'Natural Language :: English',
        'License :: OSI Approved :: Apache Software License',
        'Programming Language :: Python',
        'Programming Language :: Python :: 2.6',
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.3',
        ),
)

包含太多內容了,如果每一個專案都手寫很困難,最好找一個工具可以自動建立專案的 setup.py 檔案以及相關的配置、目錄等。Python 中做這種事的工具有好幾個,做得最好的是 pastescript。pastescript 是一個有著良好外掛機制的命令列工具,安裝以後就可以使用 paster 命令,建立適用於 setuptools 的包檔案結構。

安裝好 pastescript 以後可以看到它註冊了一個命令列入口 paster:

$ paster create --list-template     # 查詢目錄安裝的模板
$ paster create -o arithmethc-2 -t basic_package atithmetic     # 為了 atithmetic 生成專案包

簡單地填寫幾個問題以後,paster 就在 arithmetic-2 目錄生成了名為 arithmetic 的包專案。

用上 --config 引數,它是一個類似 ini 檔案格式的配置檔案,可以在裡面填好各個模板變數的值(查詢模板有哪些變數用 --list-variables引數),然後就可以使用了。

[pastescript]
description = corp-prj
license_name = 
keywords = Python
long_description = corp-prj
author = xxx corp
author_email = xxx@example.com
url = http://example.com
version = 0.0.1

以上配置檔案使用paster create -t basic_package --config="corp-prj-setup.cfg" arithmetic

建議 73:理解單元測試概念

單元測試用來驗證程式單元的正確性,一般由開發人員完成,是測試過程的第一個環節,以確保縮寫的程式碼符合軟體需求和遵循開發目標。好的單元測試有以下好處:

  • 減少了潛在 bug,提高了程式碼的質量。

  • 大大縮減軟體修復的成本

  • 為整合測試提供基本保障

有效的單元測試應該從以下幾個方面考慮:

  • 測試先行,遵循單元測試步驟:

    • 建立測試計劃(Test Plan)

    • 編寫測試用例,準備測試資料

    • 編寫測試指令碼

    • 編寫被測程式碼,在程式碼完成之後執行測試指令碼

    • 修正程式碼缺陷,重新測試直到程式碼可接受為止

  • 遵循單元測試基本原則:

    • 一致性:避免currenttime = time.localtime()這種不確定執行結果的語句

    • 原子性:執行結果只有 True 或 False 兩種

    • 單一職責:測試應該基於情景(scenario)和行為,而不是方法。如果一個方法對應著多種行為,應該有多個測試用例;而一個行為即使對應多個方法也只能有一個測試用例

    • 隔離性:不能依賴於具體的環境設定,如資料庫的訪問、環境變數的設定、系統的時間等;也不能依賴於其他的測試用例以及測試執行的順序,並且無條件邏輯依賴。單元測試的所有輸入應該是確定的,方法的行為和結構應是可以預測的。

  • 使用單元測試框架,在單元測試方面常見的測試框架有 PyUnit 等,它是 JUnit 的 Python 版本,在 Python2.1 之前需要單獨安裝,在 Python2.1 之後它成為了一個標準庫,名為 unittest。它支援單元測試自動化,可以共享地進行測試環境的設定和清理,支援測試用例的聚集以及獨立的測試報告框架。unittest 相關的概念主要有以下 4 個:

    • 測試韌體(test fixtures):測試相關的準備工作和清理工作,基於類 TestCase 建立測試韌體的時候通常需要重新實現 setUp() 和 tearDown() 方法。當定義了這些方法的時候,測試執行器會在執行測試之前和之後分別呼叫這兩個方法

    • 測試用例(test case):最小的測試單元,通常基於 TestCase 構建

    • 測試用例集(test suite):測試用例的集合,使用 TestSuite 類來實現,除了可以包含 TestCase 外,也可以包含 TestSuite

    • 測試執行器(test runner):控制和驅動整個單元測試過程,一般使用 TestRunner 類作為測試用例的基本執行環境,常用的執行器為 TextTestRunner,它是 TestRunner 的子類,以文字方式執行測試並報告結果。

# 測試以下類
class MyCal(object):
    def add(self, a, b):
        return a + b
    def sub(self, a, b):
        return a - b
# 測試
class MyCalTest(unittest.TestCase):
    def setUp(self):
        print('running set up')
    def tearDown(self):
        print('running teardown')
        self.mycal = None
    def testAdd(self):
        self.assertEqual(self.mycal.add(-1, 7), 6)
    def testSub(self):
        self.assertEqual(self.mycal.sub(10, 2), 8)
suite = unittest.TestSuite()
suite.addTest(MyCalTest("testAdd"))
suite.addTest(MyCalTest("testSub"))
runner = unittest.TextTestRunner()
runner.run(suite)

執行 python3 -m unittest -v MyCalTest 得到測試結果。

相關文章