Python學習之路28-符合Python風格的物件

VPointer發表於2018-06-17

《流暢的Python》筆記。

本篇是“物件導向慣用方法”的第二篇。前一篇講的是內建物件的結構和行為,本篇則是自定義物件。本篇繼續“Python學習之路20”,實現更多的特殊方法以讓自定義類的行為跟真正的Python物件一樣。

1. 前言

本篇要討論的內容如下,重點放在了物件的各種輸出形式上:

  • 實現用於生成物件其他表示形式的內建函式(如repr()bytes()等);
  • 使用一個類方法實現備選構造方法;
  • 擴充套件內建的format()函式和str.format()方法使用的格式微語言;
  • 實現只讀屬性;
  • 實現物件的可雜湊;
  • 利用__slots__節省記憶體;
  • 如何以及何時使用@classmethod@staticmethd裝飾器;
  • Python的私有屬性和受保護屬性的用法、約定和侷限。

本篇將通過實現一個簡單的二維歐幾里得向量型別,來涵蓋上述內容。

不過在開始之前,我們需要補充幾個概念:

  • repr():以便於開發者理解的方式返回物件的字串表示形式,它呼叫物件的__repr__特殊方法;
  • str():以便於使用者理解的方式返回物件的字串表示形式,它呼叫物件的__str__特殊方法;
  • bytes():獲取物件的位元組序列表示形式,它呼叫物件的__bytes__特殊方法;
  • format()str.format()格式化輸出物件的字串表示形式,呼叫物件的__format__特殊方法。

2. 自定義向量類Vector2d

我們希望這個類具備如下行為:

# 程式碼1
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y)  # Vector2d例項的分量可直接通過例項屬性訪問,無需呼叫讀值方法
3.0 4.0
>>> x, y = v1  # 例項可拆包成變數元組
>>> x, y
(3.0, 4.0)
>>> v1  # 我們希望__repr__返回的結果類似於構造例項的原始碼
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1))  # 只是為了說明repr()返回的結果能用來生成例項
>>> v1 == v1_clone  # Vector2d需支援 == 運算子
True
>>> print(v1)  # 我們希望__str__方法以如下形式返回例項的字串表示
(3.0, 4.0)
>>> octets = bytes(v1)  # 能夠生成位元組序列
>>> octets
b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
>>> abs(v1)  # 能夠求模
5.0
>>> bool(v1), bool(Vector2d(0, 0))  # 能進行布林運算
(True, False)
複製程式碼

Vector2d的初始版本如下:

# 程式碼2
from array import array
import math

class Vector2d:
    # 類屬性,在Vector2d例項和位元組序列之間轉換時使用
    typecode = "d"    # 轉換成C語言中的double型別

    def __init__(self, x, y):
        self.x = float(x)  # 構造是就轉換成浮點數,儘早在構造階段就捕獲錯誤
        self.y = float(y)

    def __iter__(self): # 將Vector2d例項變為可迭代物件
        return (i for i in (self.x, self.y))  # 這是生成器表示式!

    def __repr__(self):
        class_name = type(self).__name__ # 獲取類名,沒有采用硬編碼
        # 由於Vector2d例項是可迭代物件,所以*self會把x和y提供給format函式
        return "{}({!r}, {!r})".format(class_name, *self)

    def __str__(self):
        return str(tuple(self)) # 由可迭代物件構造元組

    def __bytes__(self):
        # ord()返回字元的Unicode碼位;array中的陣列的元素是double型別
        return (bytes([ord(self.typecode)]) + bytes(array(self.typecode, self)))

    def __eq__(self, other): # 這樣實現有缺陷,Vector(3, 4) == [3, 4]也會返回True
        return tuple(self) == tuple(other)  # 但這個缺陷會在後面章節修復

    def __abs__(self): # 計算平方和的非負數根
        return math.hypot(self.x, self.y)

    def __bool__(self): # 用到了上面的__abs__來計算模,如果模為0,則是False,否則為True
        return bool(abs(self))
複製程式碼

3. 備選構造方法

初版Vector2d可將它的例項轉換成位元組序列,但卻不能從位元組序列構造Vector2d例項,下面新增一個方法實現此功能:

# 程式碼3
class Vector2d:
    -- snip --
    @classmethod
    def frombytes(cls, octets): # 不用傳入self引數,但要通過cls傳入類本身
        typecode = chr(octets[0]) # 從第一個位元組中讀取typecode,chr()將Unicode碼位轉換成字元
        # 使用傳入的octets位元組序列構建一個memoryview,然後根據typecode轉換成所需要的資料型別
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(*memv)  # 拆包轉換後的memoryview,然後構造一個Vector2d例項,並返回
複製程式碼

4. classmethod與staticmethod

程式碼3中用到了@classmethod裝飾器,與它相伴的還有@staticmethod裝飾器。

從上述程式碼可以看出,classmethod定義的是傳入而不是傳入例項的方法,即傳入的第一個引數必須是,而不是例項classmethod改變了呼叫方法的方式,但是,在實際呼叫這個方法時,我們不需要手動傳入cls這個引數,Python會自動傳入。(按照傳統,第一個引數一般命名為cls,當然你也可以另起名)

staticmethod也會改變方法的呼叫方式,但第一個引數不是特殊值,既不是cls,也不是self,就是使用者傳入的普通引數。以下是它們的用法對比:

# 程式碼4
>>> class Demo:
...     @classmethod
...     def klassmeth(*args):
...         return args  # 返回傳入的全部引數
...     @staticmethod
...     def statmeth(*args):
...         return args  # 返回傳入的全部引數
...
>>> Demo.klassmeth()
(<class 'Demo'>,) # 不管如何呼叫Demo.klassmeth,它的第一個引數始終是Demo類自己
>>> Demo.klassmeth("spam")
(<class 'Demo'>, 'spam')
>>> Demo.statmeth()
()   # Demo.statmeth的行為與普通函式類似
>>> Demo.statmeth("spam")
('spam',)
複製程式碼

classmethod很有用,但staticmethod一般都能找到很方便的替代方案,所以staticmethod並不是必須的。

5. 格式化顯示

內建的format()函式和str.format()方法把各個型別的格式化方式委託給相應的.__format__(format_spec)方法。format_spec是格式說明符,它是:

  • format(my_obj, format_spec)的第二個引數;

  • 也是str.format()方法的格式字串,{}裡替換欄位中冒號後面的部分,例如:

    # 程式碼5
    >>> brl = 1 / 2.43
    >>> "1 BRL = {rate:0.2f} USD".format(rate=brl)  # 此時 format_spec為'0.2f'
    複製程式碼

    其中,冒號後面的0.2f是格式說明符,冒號前面的rate是欄位名稱,與格式說明符無關。格式說明符使用的表示法叫格式規範微語言(Format Specification Mini-Language)。格式規範微語言為一些內建型別提供了專門的表示程式碼,比如b表示二進位制的int型別;同時它還是可擴充套件的,各個類可以自行決定如何解釋format_spec引數,比如時間的轉換格式%H:%M:%S,就可用於datetime型別,但用於int型別則可能報錯。

如果類沒有定義__format__方法,則會返回__str__的結果,比如我們定義的Vector2d型別就沒有定義__format__方法,但依然可以呼叫format()函式:

# 程式碼6
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
複製程式碼

但現在的Vector2d在格式化顯示上還有缺陷,不能向format()傳入格式說明符:

>>> format(v1, ".3f")
Traceback (most recent call last):
   -- snip --
TypeError: non-empty format string passed to object.__format__
複製程式碼

現在我們來為它定義__format__方法。新增自定義的格式程式碼,如果格式說明符以'p'結尾,則以極座標的形式輸出向量,即<r, θ>'p'之前的部分做正常處理;如果沒有'p',則按笛卡爾座標形式輸出。為此,我們還需要一個計算弧度的方法angle

# 程式碼7
class Vector2d:
    -- snip --
    
    def angle(self):
        return math.atan2(self.y, self.x)  # 弧度

    def __format__(self, format_spec=""):
        if format_spec.endswith("p"):
            format_spec = format_spec[:-1]
            coords = (abs(self), self.angle())
            outer_fmt = "<{}, {}>"
        else:
            coords = self
            outer_fmt = "({}, {})"
        components = (format(c, format_spec) for c in coords)
        return outer_fmt.format(*components)
複製程式碼

以下是實際示例:

# 程式碼8
>>> format(Vector2d(1, 1), "0.5fp")
'<1.41421, 0.78540>'
>>> format(Vector2d(1, 1), "0.5f")
'(1.00000, 1.00000)'
複製程式碼

6. 可雜湊的Vector2d

關於可雜湊的概念可以參考之前的文章《Python學習之路22》

目前的Vector2d是不可雜湊的,為此我們需要實現__hash__特殊方法,而在此之前,我們還要讓向量不可變,即self.xself.y的值不能被修改。之所以要讓向量不可變,是因為我們在計算向量的雜湊值時需要用到self.xself.y的雜湊值,如果這兩個值可變,那向量的雜湊值就能隨時變化,這將不是一個可雜湊的物件。

補充

  • 在文章《Python學習之路22》中說道,使用者自定義的物件預設是可雜湊的,它的雜湊值等於id()的返回值。但是此處的Vector2d卻是不可雜湊的,這是為什麼?其實,如果我們要讓自定義類變為可雜湊的,正確的做法是同時實現__hash____eq__這兩個特殊方法。當這兩個方法都沒有重寫時,自定義類的雜湊值就是id()的返回值,此時自定義類可雜湊;當我們只重寫了__hash__方法時,自定義類也是可雜湊的,雜湊值就是__hash__的返回值;但是,如果只重寫了__eq__方法,而沒有重寫__hash__方法,此時自定義類便不可雜湊。
  • 這裡再次給出可雜湊物件必須滿足的三個條件:
    • 支援hash()函式,並且通過__hash__方法所得到的雜湊值是不變的;
    • 支援通過__eq__方法來檢測相等性;
    • a == b為真,則hash(a) == hash(b)也必須為真。

根據官方文件,最好使用異或運算^混合各分量的雜湊值,下面是Vector2d的改進:

# 程式碼9
class Vector2d:
    -- snip --
    
    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property  # 把方法變為屬性呼叫,相當於getter方法
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    def __hash__(self):
        return hash(self.x) ^ hash(self.y)
    
    -- snip --
複製程式碼

文章至此說的都是一些特殊方法,如果想到得到功能完善的物件,這些方法可能是必備的,但如果你的應用用不到這些東西,則完全沒有必要去實現這些方法,客戶並不關心你的物件是否符合Python風格。

Vector2d暫時告一段落,現在來說一說其它比較雜的內容。

7. Python的私有屬性和"受保護的"屬性

Python不像C++、Java那樣可以用private關鍵字來建立私有屬性,但在Python中,可以以雙下劃線開頭來命名屬性以實現"私有"屬性,但是這種屬性會發生名稱改寫(name mangling):Python會在這樣的屬性前面加上一個下劃線和類名,然後再存入例項的__dict__屬性中,以最新的Vector2d為例:

# 程式碼10
>>> v1 = Vector2d(1, 2)
>>> v1.__dict__
{'_Vector2d__x': 1.0, '_Vector2d__y': 2.0}
複製程式碼

當屬性以雙下劃線開頭時,其實是告訴別的程式設計師,不要直接訪問這個屬性,它是私有的。名稱改寫的目的是避免意外訪問,而不能防止故意訪問。只要你知道規則,這些屬性一樣可以訪問。

還有以單下劃線開頭的屬性,這種屬性在Python的官方文件的某個角落裡被稱為了"受保護的"屬性,但Python不會對這種屬性做特殊處理,這只是一種約定俗成的規矩,告訴別的程式設計師不要試圖從外部訪問這些屬性。這種命名方式很常見,但其實很少有人把這種屬性叫做"受保護的"屬性。

還是那句話,Python中所有的屬性都是公有的,Python沒有不能訪問的屬性!這些規則並不能阻止你有意訪問這些屬性,一切都看你遵不遵守上面這些"不成文"的規則了。

8. 覆蓋類屬性

這裡首先需要區分兩個概念,類屬性例項屬性

  • 類屬性屬於整個類,該類的所有例項都能訪問這個屬性,可以動態繫結類屬性,動態繫結的類屬性所有例項也都可以訪問,即類屬性的作用域是整個類。可以按Vector2d中定義typecode的方式來定義類屬性,即直接在class中定義屬性,而不是在__init__中;
  • 例項屬性只屬於某個例項物件,例項也能動態繫結屬性。例項屬性只能這個例項自己訪問,即例項屬性的作用域是類物件作用域。例項屬性需要和self繫結,self指向的是例項,而不是類。

Python有個很獨特的特性:類屬性可用於為例項屬性提供預設值

Vector2d中有個typecode類屬性,注意到,我們在__bytes__方法中通過self.typecode兩次用到了它,這裡明明是通過self呼叫例項屬性,可Vector2d的例項並沒有這個屬性。self.typecode其實獲取的是Vector2d.typecode類屬性的值,而至於怎麼從例項屬性跳到類屬性的,以後有機會單獨用一篇文章來講。

補充:證明例項沒有typecode屬性

# 程式碼11
>>> v = Vector2d(1, 2)
>>> v.__dict__
{'_Vector2d__x': 1.0, '_Vector2d__y': 2.0} # 例項中並沒有typecode屬性
複製程式碼

如果為不存在的例項屬性賦值,則會新建該例項屬性。假如我們為typecode例項屬性賦值,同名類屬性不會受到影響,但會被例項屬性給覆蓋掉(類似於之前在函式閉包中講的區域性變數和全域性變數的區別)。藉助這一特性,可以為各個例項的typecode屬性定製不同的值,比如在生成位元組序列時,將例項轉換成4位元組的單精度浮點數:

# 程式碼12
>>> v1 = Vector2d(1.1, 2.2) 
>>> dumpd = bytes(v1) # 按雙精度轉換
>>> dumpd
b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
>>> len(dumpd)
17
>>> v1.typecode = "f"
>>> dumpf = bytes(v1) # 按單精度轉換
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'  # 明白為什麼要在位元組序列前加上typecode的值了嗎?為了支援不同格式。
>>> len(dumpf)
9
>>> Vector2d.typecode
'd'
複製程式碼

如果想要修改類屬性的值,必須直接在類上修改,不能通過例項修改。如果想修改所有例項的typecode屬性的預設值,可以這麼做:

# 程式碼13
Vector2d.typecode = "f"
複製程式碼

然而有種方式更符合Python風格,而且效果持久,也更有針對性。通過繼承的方式修改類屬性,生成專門的子類。Django基於類的檢視就大量使用了這個技術:

# 程式碼14
>>> class ShortVector2d(Vector2d):
...     typecode = "f"   # 只修改這一處
...    
>>> sv = ShortVector2d(1/11, 1/27)
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035) # 沒有硬編碼class_name的原因
>>> len(bytes(sv))
9
複製程式碼

9. __slots__類屬性

預設情況下,Python在各個例項的__dict__屬性中以對映型別儲存例項屬性。正如《Python學習之路22》中所述,為了使用底層的雜湊表提升訪問速度,字典會消耗大量記憶體。如果要處理數百萬個屬性不多的例項,其實可以通過__slots__類屬性來節省大量記憶體。做法是讓直譯器用類似元組的結構儲存例項屬性,而不是字典。

具體用法是,在類中建立這個__slots__類屬性,並把它的值設為一個可迭代物件,其中的元素是其餘例項屬性的字串表示。比如我們將之前定義的Vector2d改為__slots__版本:

# 程式碼15
class Vector2d:
    __slots__ = ("__x", "__y")
    
    typecode = "d"  # 其餘保持不變
    -- snip -- 
複製程式碼

試驗表明,建立一千萬個之前版本的Vector2d例項,記憶體用量高達1.5GB,而__slots__版本的Vector2d的記憶體用量不到700MB,並且速度也比之前的版本快。

__slots__也有一些需要注意的點:

  • 使用__slots__之後,例項不能再有__slots__中所列名稱之外的屬性,即,不能動態新增屬性;如果要使其能動態新增屬性,必須在其中加入'__dict__',但這麼做又違背了初衷;
  • 每個子類都要定義__slots__屬性,直譯器會忽略掉父類的__slots__屬性;
  • 自定義類中預設有__weakref__屬性,但如果定義了__slots__屬性,而且還要自定義類支援弱引用,則需要把'__weakref__'加入到__slots__中。

總之,不要濫用__slots__屬性,也不要用它來限制使用者動態新增屬性(除非有意為之)。__slots__在處理列表資料時最有用,例如模式固定的資料庫記錄,以及特大型資料集。然而,當遇到這類資料時,更推薦使用Numpy和Pandas等第三方庫。

10. 總結

本篇首先按照一定的要求,定義了一個Vector2d類,重點是如果實現這個類的不同輸出形式;隨後,能從位元組序列"反編譯"成我們需要的類,我們實現了一個備選構造方法,順帶介紹了@classmethod@staticmethod裝飾器;接著,我們通過重寫__format_方法,實現了自定義格式化輸出資料;然後,通過使用@property裝飾器,定義"私有"屬性以及重寫__hash__方法等操作實現了這個類的可雜湊化。至此,關於Vector2d的內容基本結束。最後,我們介紹了兩種常見型別的屬性(“私有”,“保護”),覆蓋類屬性以及如何通過__slots__節省記憶體等問題。

本文實現了這麼多特殊方法只是為展示如何編寫標準Python物件的API,如果你的應用用不到這些內容,大可不必為了滿足Python風格而給自己增加負擔。畢竟,簡潔勝於複雜


迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~

Python學習之路28-符合Python風格的物件

相關文章