Python 使用 attrs 和 cattrs 實現面向對象編程

Python 是支持面向對象的,很多情況下使用面向對象編程會使得代碼更加容易擴展,並且可維護性更高,但是如果你寫的多了或者某一對象非常複雜了,其中的一些寫法會相當相當繁瑣,而且我們會經常碰到對象和 JSON 序列化及反序列化的問題,原生的 Python 轉起來還是很費勁的。

首先讓我們定義一個對象吧,比如顏色。我們常用 RGB 三個原色來表示顏色,R、G、B 分別代表紅、綠、藍三個顏色的數值,範圍是 0-255,也就是每個原色有 256 個取值。如 RGB(0, 0, 0) 就代表黑色,RGB(255, 255, 255) 就代表白色,RGB(255, 0, 0) 就代表紅色,如果不太明白可以具體看看 RGB 顏色的定義哈。
好,那麼我們現在如果想定義一個顏色對象,那麼正常的寫法就是這樣了,創建這個對象的時候需要三個參數,就是 R、G、B 三個數值,定義如下:

class Color(object):
    """
    Color Object of RGB
    """
    def __init__(self, r, g, b):
        self.r = r
        self.g = g
        self.b = b

其實對象一般就是這麼定義的,初始化方法裏面傳入各個參數,然後定義全局變量並賦值這些值。其實挺多常用語言比如 Java、PHP 裏面都是這麼定義的。但其實這種寫法是比較冗餘的,比如 r、g、b 這三個變量一寫就寫了三遍。

好,那麼我們初始化一下這個對象,然後打印輸出下,看看什麼結果:

color = Color(255, 255, 255)
print(color)

結果是什麼樣的呢?或許我們也就能看懂一個 Color 吧,別的都沒有什麼有效信息,像這樣子:

<__main__.Color object at 0x103436f60>

我們知道,在 Python 裏面想要定義某個對象本身的打印輸出結果的時候,需要實現它的__repr__方法,所以我們比如我們添加這麼一個方法:

def __repr__(self):
    return f'{self.__class__.__name__}(r={self.r}, g={self.g}, b={self.b})'

這裏使用了 Python 中的 fstring 來實現了 __repr__方法,在這裏我們構造了一個字符串並返回,字符串中包含了這個 Color 類中的 r、g、b 屬性,這個返回的結果就是 print 的打印結果,我們再重新執行一下,結果就變成這樣子了:

Color(r=255, g=255, b=255)

再繼續,如果我們要想實現這個對象裏面的__eq____lt__等各種方法來實現對象之間的比較呢?照樣需要繼續定義成類似這樣子的形式:

def __lt__(self, other):
    if not isinstance(other, self.__class__): return NotImplemented
    return (self.r, self.g, self.b) < (other.r, other.g, other.b)

這裏是 __lt__方法,有了這個方法就可以使用比較符來對兩個 Color 對象進行比較了,但這裏又把這幾個屬性寫了兩遍。

最後再考慮考慮,如果我要把 JSON 轉成 Color 對象,難道我要讀完 JSON 然後一個個屬性賦值嗎?如果我想把 Color 對象轉化爲 JSON,又得把這幾個屬性寫幾遍呢?

attrs 這個庫,其官方的介紹如下:

attrs 是這樣的一個 Python 工具包,它能將你從繁綜複雜的實現上解脫出來,享受編寫 Python 類的快樂。它的目標就是在不減慢你編程速度的前提下,幫助你來編寫簡潔而又正確的代碼。

其實意思就是用了它,定義和實現 Python 類變得更加簡潔和高效。


基本用法
首先明確一點,我們現在是裝了 attrscattrs 這兩個庫,但是實際導入的時候是使用 attrcattr 這兩個包,是不帶 s 的。


attr這個庫裏面有兩個比較常用的組件叫做attrsattr,前者是主要用來修飾一個自定義類的,後者是定義類裏面的一個字段的。有了它們,我們就可以將上文中的定義改寫成下面的樣子:

from attr import attrs, attrib


@attrs
class Color(object):
    r = attrib(type=int, default=0)
    g = attrib(type=int, default=0)
    b = attrib(type=int, default=0)


if __name__ == "__main__":
    color = Color(255, 255, 255)
    print(color)
    

首先我們導入了剛纔所說的兩個組件,然後attrs裏面修飾了 Color 這個自定義類,然後用attrib來定義一個個屬性,同時可以指定屬性的類型和默認值。最後打印輸出,結果如下:

# Color(r=255, g=255, b=255)

觀察一下有什麼變化,是不是變得更簡潔了?r、g、b 三個屬性都只寫了一次,同時還指定了各個字段的類型和默認值,另外也不需要再定義__init__方法和 __repr__方法了,一切都顯得那麼簡潔。


實際上,主要是 attrs 這個修飾符起了作用,然後根據定義的 attrib 屬性自動幫我們實現了__init____repr____eq____ne____lt____le____gt____ge____hash__這幾個方法。

如使用 attrs 修飾的類定義是這樣子:

from attr import attrs, attrib

@attrs
class SmartClass(object):
    a = attrib()
    b = attrib()

其實就相當於已經實現了這些方法:

class RoughClass(object):
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __repr__(self):
        return "RoughClass(a={}, b={})".format(self.a, self.b)

    def __eq__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) == (other.a, other.b)
        else:
            return NotImplemented

    def __ne__(self, other):
        result = self.__eq__(other)
        if result is NotImplemented:
            return NotImplemented
        else:
            return not result

    def __lt__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) < (other.a, other.b)
        else:
            return NotImplemented

    def __le__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) <= (other.a, other.b)
        else:
            return NotImplemented

    def __gt__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) > (other.a, other.b)
        else:
            return NotImplemented

    def __ge__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) >= (other.a, other.b)
        else:
            return NotImplemented

    def __hash__(self):
        return hash((self.__class__, self.a, self.b))

別名使用

感覺裏面的定義好亂啊,庫名叫做 attrs,包名叫做 attr,然後又導入了 attrsattrib,這太奇怪了。

首先庫的名字就叫做 attrs,這個就是裝 Python 包的時候這麼裝就行了。但是庫的名字和導入的包的名字確實是不一樣的,我們用的時候就導入 attr 這個包就行了,裏面包含了各種各樣的模塊和組件,這是完全固定的。

好,然後接下來看看 attr 包裏面包含了什麼,剛纔我們引入了attrsattrib

首先是 attrs,它主要是用來修飾 class 類的,而 attrib主要是用來做屬性定義的,這個就記住它們兩個的用法就好了。

翻了一下源代碼,發現其實它還有一些別名:

s = attributes = attrs
ib = attr = attrib

也就是說,attrs可以用 sattributes來代替,attrib 可以用 attrib來代替。

from attr import s, ib

@s
class Color(object):
    r = ib(type=int, default=0)
    g = ib(type=int, default=0)
    b = ib(type=int, default=0)

if __name__ == '__main__':
    color = Color(255, 255, 255)
    print(color)

所以總結一下:

  • 庫名:attrs
  • 導入包名:attr
  • 修飾類:sattributesattrs
  • 定義屬性:ibattrattrib

聲明和比較

在這裏我們再聲明一個簡單一點的數據結構,比如叫做 Point,包含 x、y 的座標,定義如下:

@attrs
class Point(object):
    x = attrib()
    y = attrib()

其中 attrib裏面什麼參數都沒有,如果我們要使用的話,參數可以順次指定,也可以根據名字指定,如:

    p1 = Point(1, 2)
    print(p1)       # Point(x=1, y=2)

    p2 = Point(x=3, y=4)
    print(p2)       # Point(x=3, y=4)

重載運算符 OK,接下來讓我們再驗證下類之間的比較方法,由於使用了 attrs,相當於我們定義的類已經有了` __eq__`、`__ne__`、`__lt__`、`__le__`、`__gt__`、`__ge__` 這幾個方法,所以我們可以直接使用比較符來對類和類之間進行比較,下面我們用實例來感受一下:
    print('Equal:', Point(1, 2) == Point(1, 2))
    # Equal: True

    print('Not Equal(ne):', Point(1, 2) != Point(3, 4))
    # Not Equal(ne): True

    print('Less Than(lt):', Point(1, 2) < Point(3, 4))
    # Less Than(lt): True

    print('Less or Equal(le):', Point(1, 2) <= Point(1, 4), Point(1, 2) <= Point(1, 2))
    # Less or Equal(le): True True

    print('Greater Than(gt):', Point(4, 2) > Point(3, 2), Point(4, 2) > Point(3, 1))
    # Greater Than(gt): True True

    print('Greater or Equal(ge):', Point(4, 2) >= Point(4, 1))
    # Greater or Equal(ge): True

屬性定義

現在看來,對於這個類的定義莫過於每個屬性的定義了,也就是 attrib 的定義。對於attrib的定義,我們可以傳入各種參數,不同的參數對於這個類的定義有非常大的影響。

每個屬性的具體參數和用法

首先讓我們概覽一下總共可能有多少可以控制一個屬性的參數,我們用 attrs裏面的fields方法可以查看一下:

from attr import attrs, attrib, fields

@attrs
class Point(object):
    x = attrib()
    y = attrib()

print(fields(Point))

這就可以輸出 Point 的所有屬性和對應的參數,結果如下:

(Attribute(name='x', default=NOTHING, validator=None, repr=True, 
			cmp=True, hash=None, init=True,  metadata=mappingproxy({}), 
		 	type=None,  converter=None, kw_only=False), 
 
 Attribute(name='y', default=NOTHING, validator=None, repr=True, 
 			cmp=True, hash=None, init=True, metadata=mappingproxy({}), 
 			 type=None, converter=None, kw_only=False)
 )

輸出出來了,可以看到結果是一個元組,元組每一個元素都其實是一個 Attribute對象,包含了各個參數,下面詳細解釋下幾個參數的含義:

  • name:屬性的名字,是一個字符串類型。
  • default:屬性的默認值,如果沒有傳入初始化數據,那麼就會使用默認值。如果沒有默認值定義,那麼就是 NOTHING,即沒有默認值。
  • validator:驗證器,檢查傳入的參數是否合法。
  • init:是否參與初始化,如果爲 False,那麼這個參數不能當做類的初始化參數,默認是 True。
  • `metadata:元數據,只讀性的附加數據。
  • type:類型,比如 int、str 等各種類型,默認爲 None。
  • converter:轉換器,進行一些值的處理和轉換器,增加容錯性。
  • kw_only:是否爲強制關鍵字參數,默認爲 False。

屬性名
對於屬性名,非常清楚了,我們定義什麼屬性,屬性名就是什麼,例如上面的例子,定義了:

x = attrib()

那麼其屬性名就是 x。

默認值

對於默認值,如果在初始化的時候沒有指定,那麼就會默認使用默認值進行初始化,我們看下面的一個實例:

from attr import attrs, attrib, fields

@attrs
class Point(object):
    x = attrib()
    y = attrib(default=100)

if __name__ == '__main__':
    print(Point(x=1, y=3))
    print(Point(x=1))

在這裏我們將 y 屬性的默認值設置爲了 100,在初始化的時候,第一次都傳入了 x、y 兩個參數,第二次只傳入了 x 這個參數,看下運行結果:

Point(x=1, y=3)
Point(x=1, y=100)

可以看到結果,當設置了默認參數的屬性沒有被傳入值時,他就會使用設置的默認值進行初始化。

那假如沒有設置默認值但是也沒有初始化呢?比如執行下:

Point()

那麼就會報錯了

初始化

如果一個類的某些屬性不想參與初始化,比如想直接設置一個初始值,一直固定不變,我們可以將屬性的 init 參數設置爲 False,看一個實例:

from attr import attrs, attrib

@attrs
class Point(object):
    x = attrib(init=False, default=10)
    y = attrib()

if __name__ == '__main__':
    print(Point(3))

比如 x 我們只想在初始化的時候設置固定值,不想初始化的時候被改變和設定,我們將其設置了 init 參數爲 False,同時設置了一個默認值,如果不設置默認值,默認爲 NOTHING。然後初始化的時候我們只傳入了一個值,其實也就是爲 y 這個屬性賦值。

Point(x=10, y=3)

沒什麼問題,y 被賦值爲了我們設置的值 3。

那假如我們非要設置 x 呢?會發生什麼,比如改寫成這樣子:

Point(1, 2)

報錯了,

強制關鍵字

強制關鍵字是 Python 裏面的一個特性,在傳入的時候必須使用關鍵字的名字來傳入,如果不太理解可以再瞭解下 Python 的基礎。

設置了強制關鍵字參數的屬性必須要放在後面,其後面不能再有非強制關鍵字參數的屬性,否則會報這樣的錯誤:

ValueError: Non keyword-only attributes are not allowed after a keyword-only attribute (unless they are init=False)

將最後一個屬性設置kw_only 參數True

from attr import attrs, attrib, fields

@attrs
class Point(object):
    x = attrib(default=0)
    y = attrib(kw_only=True)

if __name__ == '__main__':
    print(Point(1, y=3))

如果設置了 kw_only 參數爲 True,那麼在初始化的時候必須傳入關鍵字的名字,這裏就必須指定 y 這個名字,運行結果如下:

Point(x=1, y=3)

如果沒有指定 y 這個名字,像這樣調用:

Point(1, 3)

那麼就會報錯

注意,如果我們將一個屬性設置了initFalse,那麼kw_only這個參數會被忽略。

驗證器

有時候在設置一個屬性的時候必須要滿足某個條件,比如性別必須要是男或者女,否則就不合法。對於這種情況,我們就需要有條件來控制某些屬性不能爲非法值。

from attr import attrs, attrib

def is_valid_gender(instance, attribute, value):
    if value not in ['male', 'female']:
        raise ValueError(f'gender {value} is not valid')

@attrs
class Person(object):
    name = attrib()
    gender = attrib(validator=is_valid_gender)

if __name__ == '__main__':
    print(Person(name='Mike', gender='male'))
    print(Person(name='Mike', gender='mlae'))

在這裏我們定義了一個驗證器 Validator 方法,叫做 is_valid_gender。
然後定義了一個類 Person 還有它的兩個屬性 name 和 gender,其中 gender 定義的時候傳入了一個參數validator,其值就是我們定義的 Validator 方法。

這個Validator定義的時候有幾個固定的參數:

  • instance:類對象
  • attribute:屬性名
  • value:屬性值

這是三個參數是固定的,在類初始化的時候,其內部會將這三個參數傳遞給這個 Validator,因此 Validator 裏面就可以接受到這三個值,然後進行判斷即可。在 Validator 裏面,我們判斷如果不是男性或女性,那麼就直接拋出錯誤。

OK,結果顯而易見了,第二個報錯了,因爲其值不是正常的性別,所以程序直接報錯終止。

注意在 Validator 裏面返回 True 或 False 是沒用的,錯誤的值還會被照常複製。所以,一定要在 Validator 裏面 raise 某個錯誤

另外 attrs 庫裏面還給我們內置了好多 Validator,比如判斷類型,這裏我們再增加一個屬性 age,必須爲 int 類型:

age = attrib(validator=validators.instance_of(int))

這時候初始化的時候就必須傳入 int 類型,如果爲其他類型,則直接拋錯:

TypeError: ("'age' must be <class 'int'> (got 'x' that is a <class 'str'>).

其它的一些驗證器:

attr.validators.in_(options)

>>> import enum
>>> class State(enum.Enum):
...     ON = "on"
...     OFF = "off"
>>> @attr.s
... class C(object):
...     state = attr.ib(validator=attr.validators.in_(State))
...     val = attr.ib(validator=attr.validators.in_([1, 2, 3]))
>>> C(State.ON, 1)
C(state=<State.ON: 'on'>, val=1)
>>> C("on", 1)
Traceback (most recent call last):
   ...
ValueError: 'state' must be in <enum 'State'> (got 'on')
>>> C(State.ON, 4)
Traceback (most recent call last):
   ...
ValueError: 'val' must be in [1, 2, 3] (got 4)
attr.validators.instance_of()

>>> @attr.s
... class C(object):
...     x = attr.ib(validator=attr.validators.instance_of(int))
>>> C(42)
C(x=42)
>>> C("42")
Traceback (most recent call last):
   ...
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None, kw_only=False), <type 'int'>, '42')
>>> C(None)
Traceback (most recent call last):
   ...
TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True, type=None, kw_only=False), <type 'int'>, None)

更多驗證器,參考官方文檔

轉換器

其實很多時候我們會不小心傳入一些形式不太標準的結果,比如本來是 int 類型的 100,我們傳入了字符串類型的 100,那這時候直接拋錯應該不好吧,所以我們可以設置一些轉換器來增強容錯機制,比如將字符串自動轉爲數字等等,看一個實例:

from attr import attrs, attrib

def to_int(value):
    try:
        return int(value)
    except:
        return None

@attrs
class Point(object):
    x = attrib(converter=to_int)
    y = attrib()

if __name__ == '__main__':
    print(Point('100', 3))

看這裏,我們定義了一個方法,可以將值轉化爲數字類型,如果不能轉,那麼就返回 None,這樣保證了任何可以被轉數字的值都被轉爲數字,否則就留空,容錯性非常高。
運行結果如下

Point(x=100, y=3)

類型

爲什麼把這個放到最後來講呢,因爲 Python 中的類型是非常複雜的,有原生類型,有 typing 類型,有自定義類的類型。

首先我們來看看原生類型是怎樣的,這個很容易理解了,就是普通的 int、float、str 等類型,其定義如下:

from attr import attrs, attrib

@attrs
class Point(object):
    x = attrib(type=int)
    y = attrib()

if __name__ == '__main__':
    print(Point(100, 3))
    print(Point('100', 3))

這裏我們將 x 屬性定義爲 int 類型了,初始化的時候傳入了數值型 100 和字符串型 100,結果如下:

Point(x=100, y=3)
Point(x='100', y=3)

但我們發現,雖然定義了,但是不會被自動轉類型的。

另外我們還可以自定義 typing 裏面的類型,比如 List,另外 attrs 裏面也提供了類型的定義:

from attr import attrs, attrib, Factory
import typing

@attrs
class Point(object):
    x = attrib(type=int)
    y = attrib(type=typing.List[int])
    z = attrib(type=Factory(list))

這裏我們引入了 typing 這個包,定義了 y 爲 int 數字組成的列表,z 使用了 attrs 裏面定義的 Factory 定義了同樣爲列表類型。

序列轉換

在很多情況下,我們經常會遇到 JSON 等字符串序列和對象互相轉換的需求,尤其是在寫 REST API、數據庫交互的時候。

attrs 庫的存在讓我們可以非常方便地定義 Python 類,但是它對於序列字符串的轉換功能還是比較薄弱的,cattrs 這個庫就是用來彌補這個缺陷的,下面我們再來看看 cattrs 庫

cattrs 導入的時候名字也不太一樣,叫做 cattr,它裏面提供了兩個主要的方法,叫做 structureunstructure,兩個方法是相反的,對於類的序列化和反序列化支持非常好。

基本轉換

首先我們來看看基本的轉換方法的用法,看一個基本的轉換實例:

from attr import attrib, attrs
from cattr import unstructure, structure


@attrs
class Point(object):
    x = attrib(type=int, default=0)
    y = attrib(type=int, default=0)


if __name__ == "__main__":
    point = Point(1, 2)
    json = unstructure(point)
    print('json:', json)
    obj = structure(json, Point)
    print('obj:', obj)

在這裏我們定義了一個 Point 對象,然後調用 unstructure 方法即可直接轉換爲 JSON 字符串。如果我們再想把它轉回來,那就需要調用 structure 方法,這樣就成功轉回了一個 Point 對象。

看下運行結果:

json: {'x': 1, 'y': 2}
obj: Point(x=1, y=2)

多類型轉換

另外 structure 也支持一些其他的類型轉換,看下實例:

>>> cattr.structure(1, str)
'1'
>>> cattr.structure("1", float)
1.0
>>> cattr.structure([1.0, 2, "3"], Tuple[int, int, int])
(1, 2, 3)
>>> cattr.structure((1, 2, 3), MutableSequence[int])
[1, 2, 3]
>>> cattr.structure((1, None, 3), List[Optional[str]])
['1', None, '3']
>>> cattr.structure([1, 2, 3, 4], Set)
{1, 2, 3, 4}
>>> cattr.structure([[1, 2], [3, 4]], Set[FrozenSet[str]])
{frozenset({'4', '3'}), frozenset({'1', '2'})}
>>> cattr.structure(OrderedDict([(1, 2), (3, 4)]), Dict)
{1: 2, 3: 4}
>>> cattr.structure([1, 2, 3], Tuple[int, str, float])
(1, '2', 3.0)

不過總的來說,大部分情況下,JSON 和對象的互轉是用的最多的。

屬性處理

上面的例子都是理想情況下使用的,但在實際情況下,很容易遇到 JSON 和對象不對應的情況,比如 JSON 多個字段,或者對象多個字段。

from attr import attrs, attrib
from cattr import structure

@attrs
class Point(object):
    x = attrib(type=int, default=0)
    y = attrib(type=int, default=0)

json = {'x': 1, 'y': 2, 'z': 3}
print(structure(json, Point))

在這裏,JSON 多了一個字段 z,而 Point 類只有 x、y 兩個字段,那麼直接執行 structure 會出現什麼情況呢?

TypeError: __init__() got an unexpected keyword argument 'z'
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章