Python學習之路28-符合Python風格的對象

《流暢的Python》筆記。
本篇是「面向對象慣用方法」的第二篇。前一篇講的是內置對象的結構和行爲,本篇則是自定義對象。本篇繼續「Python學習之路20」,實現更多的特殊方法以讓自定義類的行爲跟真正的Python對象同樣。

1. 前言

本篇要討論的內容以下,重點放在了對象的各類輸出形式上:python

  • 實現用於生成對象其餘表示形式的內置函數(如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的初始版本以下:bash

# 代碼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 ~

相關文章
相關標籤/搜索