歡迎光臨
每天分享高質量文章

這可能是 Python 面向物件程式設計的最佳實踐

(點選上方快速關註並設定為星標,一起學Python)

來源:進擊的Coder  連結:

https://mp.weixin.qq.com/s/oHK-Y4lOeaQCFtDWgqXxFA

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(255255255)
print(color)

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

<__main__.color class="hljs-number" style="font-size: inherit;line-height: inherit;color: rgb(174, 135, 250);overflow-wrap: inherit !important;word-break: inherit !important;">0x103436f60>
</__main__.color>

我們知道,在 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) 

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

最後再考慮考慮,如果我要把 JSON 轉成 Color 物件,難道我要讀完 JSON 然後一個個屬性賦值嗎?如果我想把 Color 物件轉化為 JSON,又得把這幾個屬性寫幾遍呢?如果我突然又加了一個屬性比如透明度 a 引數,那麼整個類的方法和引數都要修改,這是極其難以擴充套件的。不知道你能不能忍,反正我不能忍!

如果你用過 Scrapy、Django 等框架,你會發現 Scrapy 裡面有一個 Item 的定義,只需要定義一些 Field 就可以了,Django 裡面的 Model 也類似這樣,只需要定義其中的幾個欄位屬性就可以完成整個類的定義了,非常方便。

說到這裡,我們能不能把 Scrapy 或 Django 裡面的定義樣式直接拿過來呢?能是能,但是沒必要,因為我們還有專門為 Python 面向物件而專門誕生的庫,沒錯,就是 attrs 和 cattrs 這兩個庫。

有了 attrs 庫,我們就可以非常方便地定義各個物件了,另外對於 JSON 的轉化,可以進一步藉助 cattrs 這個庫,非常有幫助。

說了這麼多,還是沒有介紹這兩個庫的具體用法,下麵我們來詳細介紹下。

安裝

安裝這兩個庫非常簡單,使用 pip 就好了,命令如下:

pip3 install attrs cattrs

安裝好了之後我們就可以匯入並使用這兩個庫了。

簡介與特性

首先我們來介紹下 attrs 這個庫,其官方的介紹如下:

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

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

基本用法

首先明確一點,我們現在是裝了 attrs 和 cattrs 這兩個庫,但是實際匯入的時候是使用 attr 和 cattr 這兩個包,是不帶 s 的。

在 attr 這個庫裡面有兩個比較常用的元件叫做 attrs 和 attr,前者是主要用來修飾一個自定義類的,後者是定義類裡面的一個欄位的。有了它們,我們就可以將上文中的定義改寫成下麵的樣子:

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(255255255)
    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)         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 的話,就可以不用再寫這些冗餘又複雜的程式碼了。

翻看原始碼可以發現,其內部新建了一個 ClassBuilder,透過一些屬性操作來動態添加了上面的這些方法,如果想深入研究,建議可以看下 attrs 庫的原始碼。

別名使用

這時候大家可能有個小小的疑問,感覺裡面的定義好亂啊,庫名叫做 attrs,包名叫做 attr,然後又匯入了 attrs 和 attrib,這太奇怪了。為了幫大家解除疑慮,我們來梳理一下它們的名字。

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

好,然後接下來看看 attr 包裡麵包含了什麼,剛才我們引入了 attrs 和 attrib。

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

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

s = attributes = attrs
ib = attr = attrib

也就是說,attrs 可以用 s 或 attributes 來代替,attrib 可以用 attr 或 ib 來代替。

既然是別名,那麼上面的類就可以改寫成下麵的樣子:

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(255255255)
    print(color)

是不是更加簡潔了,當然你也可以把 s 改寫為 attributes,ib 改寫為 attr,隨你怎麼用啦。

不過我覺得比較舒服的是 attrs 和 attrib 的搭配,感覺可讀性更好一些,當然這個看個人喜好。

所以總結一下:

  • 庫名:attrs
  • 匯入包名:attr
  • 修飾類:s 或 attributes 或 attrs
  • 定義屬性:ib 或 attr 或 attrib

OK,理清了這幾部分內容,我們繼續往下深入瞭解它的用法吧。

宣告和比較

在這裡我們再宣告一個簡單一點的資料結構,比如叫做 Point,包含 x、y 的坐標,定義如下:

from attr import attrs, attrib

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

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

p1 = Point(12)
print(p1)
p2 = Point(x=1, y=2)
print(p2)

其效果都是一樣的,列印輸出結果如下:

Point(x=1, y=2)
Point(x=1, y=2)

OK,接下來讓我們再驗證下類之間的比較方法,由於使用了 attrs,相當於我們定義的類已經有了 __eq____ne____lt____le____gt____ge__ 這幾個方法,所以我們可以直接使用比較符來對類和類之間進行比較,下麵我們用實體來感受一下:

print('Equal:', Point(12) == Point(12))
print('Not Equal(ne):', Point(12) != Point(34))
print('Less Than(lt):', Point(12) 3, 4))
print('Less or Equal(le):', Point(12) <= Point(14), Point(12) <= Point(12))
print('Greater Than(gt):', Point(42) > Point(32), Point(42) > Point(31))
print('Greater or Equal(ge):', Point(42) >= Point(41))

執行結果如下:

Same: False
Equal: True
Not Equal(ne): True
Less Than(lt): True
Less or Equal(le): True True
Greater Than(gt): True True
Greater or Equal(ge): True

可能有的朋友不知道 ne、lt、le 什麼的是什麼意思,不過看到這裡你應該明白啦,ne 就是 Not Equal 的意思,就是不相等,le 就是 Less or Equal 的意思,就是小於或等於。

其內部怎麼實現的呢,就是把類的各個屬性轉成元組來比較了,比如 Point(1, 2) < Point(3, 4) 實際上就是比較了 (1, 2)(3, 4) 兩個元組,那麼元組之間的比較邏輯又是怎樣的呢,這裡就不展開了,如果不明白的話可以參考官方檔案:https://docs.python.org/3/library/stdtypes.html#comparisons。

屬性定義

現在看來,對於這個類的定義莫過於每個屬性的定義了,也就是 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()

那麼就會報錯了,錯誤如下:

TypeError: __init__() missing 1 required positional argument: 'x'

所以說,如果一個屬性,我們一旦沒有設定預設值同時沒有傳入的話,就會引起錯誤。所以,一般來說,為了穩妥起見,設定一個預設值比較好,即使是 None 也可以的。

初始化

如果一個類的某些屬性不想參與初始化,比如想直接設定一個初始值,一直固定不變,我們可以將屬性的 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(12)

報錯了,錯誤如下:

TypeError: __init__() takes 2 positional arguments but 3 were given

引數過多,也就是說,已經將 init 設定為 False 的屬性就不再被算作可以被初始化的屬性了。

強制關鍵字

強制關鍵字是 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(13)

那麼就會報錯:

TypeError: __init__() takes from 1 to 2 positional arguments but 3 were given

所以,這個引數就是設定初始化傳參必須要用名字來傳,否則會出現錯誤。

註意,如果我們將一個屬性設定了 init 為 False,那麼 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 裡面,我們判斷如果不是男性或女性,那麼就直接丟擲錯誤。

下麵做了兩個實驗,一個就是正常傳入 male,另一個寫錯了,寫的是 mlae,觀察下執行結果:

Person(name='Mike', gender='male')
TypeError: __init__() missing 1 required positional argument: 'gender'

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

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

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

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

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

TypeError: ("'age' must be  (got 'x' that is a ).

另外還有其他的一些 Validator,比如與或運算、可執行判斷、可迭代判斷等等,可以參考官方檔案:https://www.attrs.org/en/stable/api.html#validators。

另外 validator 引數還支援多個 Validator,比如我們要設定既要是數字,又要小於 100,那麼可以把幾個 Validator 放到一個串列裡面並傳入:

from attr import attrs, attrib, validators

def is_less_than_100(instance, attribute, value):
    if value > 100:
        raise ValueError(f'age {value} must less than 100')

@attrs
class Person(object):
    name = attrib()
    gender = attrib(validator=is_valid_gender)
    age = attrib(validator=[validators.instance_of(int), is_less_than_100])

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

這樣就會將所有的 Validator 都執行一遍,必須每個 Validator 都滿足才可以。這裡 age 傳入了 500,那麼不符合第二個 Validator,直接拋錯:

ValueError: age 500 must less than 100

轉換器

其實很多時候我們會不小心傳入一些形式不太標準的結果,比如本來是 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(1003))
    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 定義了同樣為串列型別。

另外我們也可以進行型別的巢狀,比如像這樣子:

from attr import attrs, attrib, Factory
import typing

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

@attrs
class Line(object):
    name = attrib()
    points = attrib(type=typing.List[Point])

if __name__ == '__main__':
    points = [Point(i, i) for i in range(5)]
    print(points)
    line = Line(name='line1', points=points)
    print(line)

在這裡我們定義了 Point 類代表離散點,隨後定義了線,其擁有 points 屬性是 Point 組成的串列。在初始化的時候我們宣告了五個點,然後用這五個點組成的串列宣告了一條線,邏輯沒什麼問題。

執行結果:

[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)]
Line(name='line1', points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])

可以看到這裡我們得到了一個巢狀型別的 Line 物件,其值是 Point 型別組成的串列。

以上便是一些屬性的定義,把握好這些屬性的定義,我們就可以非常方便地定義一個類了。

序列轉換

在很多情況下,我們經常會遇到 JSON 等字串序列和物件互相轉換的需求,尤其是在寫 REST API、資料庫互動的時候。

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

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

基本轉換

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

from attr import attrs, attrib
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(x=1, y=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.02"3"], Tuple[int, int, int])
(123)
>>> cattr.structure((123), MutableSequence[int])
[123]
>>> cattr.structure((1None3), List[Optional[str]])
['1'None'3']
>>> cattr.structure([1234], Set)
{1234}
>>> cattr.structure([[12], [34]], Set[FrozenSet[str]])
{frozenset({'4''3'}), frozenset({'1''2'})}
>>> cattr.structure(OrderedDict([(12), (34)]), Dict)
{1234}
>>> cattr.structure([123], Tuple[int, str, float])
(1'2'3.0)

這裡面用到了 Tuple、MutableSequence、Optional、Set 等類,都屬於 typing 這個模組,後面我會寫內容詳細介紹這個庫的用法。

不過總的來說,大部分情況下,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'

不出所料,報錯了。意思是多了一個引數,這個引數並沒有被定義。

這時候一般的解決方法的直接忽略這個引數,可以重寫一下 structure 方法,定義如下:

def drop_nonattrs(d, type):
    if not isinstance(d, dict): return d
    attrs_attrs = getattr(type, '__attrs_attrs__'None)
    if attrs_attrs is None:
        raise ValueError(f'type {type} is not an attrs class')
    attrs: Set[str] = {attr.name for attr in attrs_attrs}
    return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
    return cattr.structure(drop_nonattrs(d, type), type)

這裡定義了一個 drop_nonattrs 方法,用於從 JSON 裡面刪除物件裡面不存在的屬性,然後呼叫新的 structure 方法即可,寫法如下:

from typing import Set
from attr import attrs, attrib
import cattr

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

def drop_nonattrs(d, type):
    if not isinstance(d, dict): return d
    attrs_attrs = getattr(type, '__attrs_attrs__'None)
    if attrs_attrs is None:
        raise ValueError(f'type {type} is not an attrs class')
    attrs: Set[str] = {attr.name for attr in attrs_attrs}
    return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
    return cattr.structure(drop_nonattrs(d, type), type)

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

這樣我們就可以避免 JSON 欄位冗餘導致的轉換問題了。

另外還有一個常見的問題,那就是資料物件轉換,比如對於時間來說,在物件裡面宣告我們一般會宣告為 datetime 型別,但在序列化的時候卻需要序列化為字串。

所以,對於一些特殊型別的屬性,我們往往需要進行特殊處理,這時候就需要我們針對某種特定的型別定義特定的 hook 處理方法,這裡就需要用到 register_unstructure_hook 和 register_structure_hook 方法了。

下麵這個例子是時間 datetime 轉換的時候進行的處理:

import datetime
from attr import attrs, attrib
import cattr

TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'

@attrs
class Event(object):
    happened_at = attrib(type=datetime.datetime)

cattr.register_unstructure_hook(datetime.datetime, lambda dt: dt.strftime(TIME_FORMAT))
cattr.register_structure_hook(datetime.datetime,
                              lambda string, _: datetime.datetime.strptime(string, TIME_FORMAT))

event = Event(happened_at=datetime.datetime(201961))
print('event:', event)
json = cattr.unstructure(event)
print('json:', json)
event = cattr.structure(json, Event)
print('Event:', event)

在這裡我們對 datetime 這個型別註冊了兩個 hook,當序列化的時候,就呼叫 strftime 方法轉回字串,當反序列化的時候,就呼叫 strptime 將其轉回 datetime 型別。

看下執行結果:

event: Event(happened_at=datetime.datetime(20196100))
json: {'happened_at''2019-06-01T00:00:00.000000Z'}
Event: Event(happened_at=datetime.datetime(20196100))

這樣對於一些特殊型別的屬性處理也得心應手了。

巢狀處理

最後我們再來看看巢狀型別的處理,比如類裡面有個屬性是另一個類的型別,如果遇到這種巢狀類的話,怎樣類轉轉換呢?我們用一個實體感受下:

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

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

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

@attrs
class Line(object):
    color = attrib(type=Color)
    points = attrib(type=List[Point])

if __name__ == '__main__':
    line = Line(color=Color(), points=[Point(i, i) for i in range(5)])
    print('Object:', line)
    json = unstructure(line)
    print('JSON:', json)
    line = structure(json, Line)
    print('Object:', line)

這裡我們定義了兩個 Class,一個是 Point,一個是 Color,然後定義了 Line 物件,其屬性型別一個是 Color 型別,一個是 Point 型別組成的串列,下麵我們進行序列化和反序列化操作,轉成 JSON 然後再由 JSON 轉回來,執行結果如下:

Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])
JSON: {'color': {'r'0'g'0'b'0}, 'points': [{'x'0'y'0}, {'x'1'y'1}, {'x'2'y'2}, {'x'3'y'3}, {'x'4'y'4}]}
Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])

可以看到,我們非常方便地將物件轉化為了 JSON 物件,然後也非常方便地轉回了物件。

這樣我們就成功實現了巢狀物件的序列化和反序列化,所有問題成功解決!

結語

本節介紹了利用 attrs 和 cattrs 兩個庫實現 Python 面向物件程式設計的實踐,有了它們兩個的加持,Python 面向物件程式設計不再是難事。

本節程式碼地址:

https://github.com/Germey/PythonOOP。

贊(0)

分享創造快樂