Python3.7 dataclass 介紹
Python3.7 加入了一個新的 module:dataclasses。可以簡單的理解成“支持默認值、可以修改的tuple”( “mutable namedtuples with defaults”)。其實沒什麽特別的,就是你定義一個很普通的類,@dataclass
裝飾器可以幫你生成 __repr__
__init__
等等方法,就不用自己寫一遍了。但是此裝飾器返回的依然是一個 class,這意味著並沒有帶來任何不便,你依然可以使用繼承、metaclass、docstring、定義方法等。
先展示一個 PEP 中舉的例子,下面的這段代碼(Python3.7):
1 2 3 4 5 6 7 8 9 | @dataclass class InventoryItem: ‘‘‘Class for keeping track of an item in inventory.‘‘‘ name: str unit_price: float quantity_on_hand: int = 0 def total_cost(self) -> float: return self.unit_price * self.quantity_on_hand |
@dataclass
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
def __init__(self, name: str, unit_price: float, quantity_on_hand: int = 0) -> None:
self.name = name
self.unit_price = unit_price
self.quantity_on_hand = quantity_on_hand
def __repr__(self): |
引入dataclass的理念
Python 想簡單的定義一種容器,支持通過的對象屬性進行訪問。在這方面已經有很多嘗試了:
- 標準庫的
collections.namedtuple
- 標準庫的
typing.NamedTuple
- 著名的 attr 庫
- 各種 Snippet,問題和回答等
那麽為什麽還需要 dataclass 呢?主要的好處有:
- 沒有使用 BaseClass 或者 metaclass,不會影響代碼的繼承關系。被裝飾的類依然是一個普通的類
- 使用類的 Fields 類型註解,用原生的方法支持類型檢查,不侵入代碼,不像 attr 這種庫對代碼有侵入性(要用 attr 的函數將一些東西處理)
dataclass 並不是要取代這些庫,作為標準庫的 dataclass 只是提供了一種更加方便使用的途徑來定義 Data Class。以上這些庫有不同的 feature,依然有存在的意義。
基本用法
dataclasses 的 dataclass 裝飾器的原型如下:
1 | def dataclass(*, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False) |
很明顯,這些默認參數可以控制是否生成魔術方法。通過本文開頭的例子可以看出,不用加括號也可以調用。
通過 field 可以對參數做更多的定制化,比如默認值、是否參與repr、是否參與hash等。比如文檔中的這個例子,由於 mylist
的缺失,就調用了 default_factory
。更多 field 能做的事情參考文檔吧。
1 2 3 4 5 6 | @dataclass class C: mylist: List[int] = field(default_factory=list) c = C() c.mylist += [1, 2, 3] |
此外,dataclasses 模塊還提供了很多有用的函數,可以將 dataclass 轉換成 tuple、dict 等形式。話說我自己重復過很多這樣的方法了……
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @dataclass class Point: x: int y: int @dataclass class C: mylist: List[Point] p = Point(10, 20) assert asdict(p) == {‘x‘: 10, ‘y‘: 20} c = C([Point(0, 0), Point(10, 4)]) assert asdict(c) == {‘mylist‘: [{‘x‘: 0, ‘y‘: 0}, {‘x‘: 10, ‘y‘: 4}]} |
Hook init
自動生成的 __init__
可以被 hook。很簡單,自動生成的 __init__
方法會調用 __post_init__
1 2 3 4 5 6 7 8 | @dataclass class C: a: float b: float c: float = field(init=False) def __post_init__(self): self.c = self.a + self.b |
如果想傳給 __post_init__
方法但是不傳給 __init__
,可以使用一個特殊的類型 InitVar
1 2 3 4 5 6 7 8 9 10 11 | @dataclass class C: i: int j: int = None database: InitVar[DatabaseType] = None def __post_init__(self, database): if self.j is None and database is not None: self.j = database.lookup(‘j‘) c = C(10, database=my_database) |
不可修改的功能
Python 沒有 const 類似的東西,理論上任何東西都是可以修改的。如果非要說不能修改的實現呢,這裏有個比較著名的實現。只有不到10行代碼。
但是有了 dataclass ,可以直接使用 @dataclass(frozen=True)
了。然後裝飾器會對 Class 添加上 __setattr__
和 __delattr__
。Raise 一個 FrozenInstanceError。缺點是會有一些性能損失,因為 __init__
必須通過 object.__setattr__
。
繼承
對於有繼承關系的 dataclass,會按照 MRO 的反順序(從object開始),對於每一個基類,將在基類找到的 fields 添加到順序的一個 mapping 中。所有的基類都找完了,按照這個 mapping 生成所有的魔術方法。所以方法中這些參數的順序,是按照找到的順序排的,先找到的排在前面。因為是先找的基類,所以相同 name 的話,後面子類的 fields 定義會覆蓋基類的。比如文檔中的這個例子:
1 2 3 4 5 6 7 8 9 | @dataclass class Base: x: Any = 15.0 y: int = 0 @dataclass class C(Base): z: int = 10 x: int = 15 |
那麽最後生成的將會是:
1 | def __init__(self, x: int = 15, y: int = 0, z: int = 10): |
註意 x y 的順序是 Base 中的順序,但是 C 的 x 是 int 類型,覆蓋了 Base 中的 Any。
可變對象的陷阱
在前面的“基本用法”一節中,使用了 default_factory 。為什麽不直接使用 []
作為默認呢?
老鳥都會知道 Python 這麽一個坑:將可變對象比如 list 作為函數的默認參數,那麽這個參數會被緩存,導致意外的錯誤。詳細的可以參考這裏:Python Common Gotchas。
考慮到下面的代碼:
1 2 3 4 5 | @dataclass class D: x: List = [] def add(self, element): self.x += element |
將會生成:
1 2 3 4 5 6 7 8 | class D: x = [] def __init__(self, x=x): self.x = x def add(self, element): self.x += element assert D().x is D().x |
這樣無論實例化多少對象,x
變量將在多個實例之間共享。dataclass 很難有一個比較好的辦法預防這種情況。所以這個地方做的設計是:如果默認參數的類型是 list
dict
或 set
,就拋出一個 TypeError。雖然不算完美,但是可以預防很大一部分情況了。
如果默認參數需要是 list,那麽就用上面提到的 default_factory 。
Python3.7 dataclass 介紹