Python如何設計物件導向的類(上)

自動化程式碼美學發表於2021-07-02

Python是一門高階語言,支援物件導向設計,如何設計一個符合Python風格的物件導向的類,是一個比較複雜的問題,本文提供一個參考,表達一種思路,探究一層原理。

目標

期望實現的類具有以下基本行為:

  • __repr__ 為repr()提供支援,返回便於開發者理解的物件字串表示形式。
  • __str__ 為str()提供支援,返回便於使用者理解的物件字串表示形式。
  • __bytes__ 為bytes()提供支援,返回物件的二進位制表示形式。
  • __format__ 為format()和str.format()提供支援,使用特殊的格式程式碼顯示物件的字串表示形式。

Vector2d是一個向量類,期望它能支援以下操作:

>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y)  # 通過屬性直接訪問
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  # 支援==運算子
True
>>> print(v1)  # 支援str
(3.0, 4.0)
>>> octets = bytes(v1)  # 支援bytes
>>> octets
b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
>>> abs(v1)  # 實現__abs__
5.0
>>> bool(v1), bool(Vector2d(0, 0))  # 實現__bool__
(True, False)

基本實現

程式碼與解析如下:

from array import array
import math


class Vector2d:
    # Vector2d例項和二進位制之間轉換時使用
    typecode = 'd'  

    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__
        # {!r}是個萬能的格式符
        # *self是拆包,*表示所有元素
        return '{}({!r}, {!r})'.format(class_name, *self)

    def __str__(self):
        # Vector2d例項是可迭代物件,可以得到一個元組,並str
        return str(tuple(self))

    def __bytes__(self):
        # 轉換為二進位制
        return (bytes([ord(self.typecode)]) +  
                bytes(array(self.typecode, self)))  

    def __eq__(self, other):
        # 比較相等
        return tuple(self) == tuple(other)  

    def __abs__(self):
        # 向量的模是直角三角形的斜邊長
        return math.hypot(self.x, self.y) 

    def __bool__(self):
        # 0.0是False,非零值是True
        return bool(abs(self))  
    
    @classmethod
    def frombytes(cls, octets):  # classmethod不傳self傳cls
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(*memv)  # 拆包後得到構造方法所需的一對引數

程式碼最後用到了@classmethod裝飾器,它容易跟@staticmethod混淆。

@classmethod的用法是:定義操作類,而不是操作例項的方法。常用來定義備選構造方法。

@staticmethod其實就是個普通函式,只不過剛好放在了類的定義體裡。實際定義在類中或模組中都可以。

格式化顯示

程式碼與解析如下:

def angle(self):
    return math.atan2(self.y, self.x)


def __format__(self, fmt_spec=''):
    if fmt_spec.endswith('p'):  # 以'p'結尾,使用極座標
        fmt_spec = fmt_spec[:-1]
        coords = (abs(self), self.angle())  # 計算極座標(magnitude, angle)
        outer_fmt = '<{}, {}>'  # 尖括號
    else:
        coords = self  # 不以'p'結尾,構建直角座標(x, y)
        outer_fmt = '({}, {})'  # 圓括號
    components = (format(c, fmt_spec) for c in coords)  # 使用內建format函式格式化字串
    return outer_fmt.format(*components)  # 拆包後代入外層格式

它實現了以下效果:

直角座標:

>>> format(v1)
'(3.0, 4.0)'
>>> format(v1, '.2f')
'(3.00, 4.00)'
>>> format(v1, '.3e')
'(3.000e+00, 4.000e+00)'

極座標:

>>> format(Vector2d(1, 1), 'p')  # doctest:+ELLIPSIS
'<1.414213..., 0.785398...>'
>>> format(Vector2d(1, 1), '.3ep')
'<1.414e+00, 7.854e-01>'
>>> format(Vector2d(1, 1), '0.5fp')
'<1.41421, 0.78540>'

可雜湊的

實現__hash__特殊方法能讓Vector2d變成可雜湊的,不過在這之前需要先讓屬性不可變,程式碼如下:

def __init__(self, x, y):
    # 雙下劃線字首,變成私有的
    self.__x = float(x)
    self.__y = float(y)

@property  # 標記為特性
def x(self):
    return self.__x

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

這樣x和y就只讀不可寫了。

屬性名字的雙下劃線字首叫做名稱改寫(name mangling),相當於_Vector2d__x_Vector2d__y,能避免被子類覆蓋。

然後使用位運算子異或混合x和y的雜湊值:

def __hash__(self):
    return hash(self.x) ^ hash(self.y)

節省記憶體

Python預設會把例項屬性儲存在__dict__字典裡,字典的底層是雜湊表,資料量大了以後會消耗大量記憶體(以空間換時間)。通過__slots__類屬性,能把例項屬性儲存到元組裡,大大節省記憶體空間。

示例:

class Vector2d:
    __slots__ = ('__x', '__y')

    typecode = 'd'

有幾點需要注意:

  • 必須把所有屬性都定義到__slots__元組中。
  • 子類也必須定義__slots__
  • 例項如果要支援弱引用,需要把__weakref也加入__slots__

覆蓋類屬性

例項覆蓋

Python有個很獨特的特性:類屬性可用於為例項屬性提供預設值。例項程式碼中的typecode就能直接被self.typecode拿到。但是,如果為不存在的例項屬性賦值,會新建例項屬性,類屬性不會受到影響,self.typecode拿到的是例項屬性的typecode。

示例:

>>> v1 = Vector2d(1, 2)
>>> v1.typecode = 'f'
>>> v1.typecode
'f'
>>> Vector2d.typecode
'd'

子類覆蓋

類屬性是公開的,所以可以直接通過Vector2d.typecode = 'f'進行修改。但是更符合Python風格的做法是定義子類:

class ShortVector2d(Vector2d):
    typecode = 'f'

Django基於類的檢視大量使用了這個技術。

小結

本文先介紹瞭如何實現特殊方法來設計一個Python風格的類,然後分別實現了格式化顯示與可雜湊物件,使用__slots__能為類節省記憶體,最後討論了類屬性覆蓋技術,子類覆蓋是Django基於類的檢視大量用到的技術。

參考資料:

《流暢的Python》第9章 符合Python風格的物件

https://www.jianshu.com/p/7fc0a177fd1f

相關文章