A Deep Dive into Python's Dataclasses (Part 2)

和其他 Python 設計的類別或函式一樣,基本上 Dataclass 就是給懶惰的程式設計師用的。

當你的類別主要是處理資料時,你可以用 Dataclass 來自動幫你產生程式碼,而不用自己撰寫。


首先複習上次的內容

以下內容引用自 Chris 兄熱心提供的中英對照 Jupyter Notebook:


__post_init__

顧名思義,__post_init__ 是在 __init__ 之後做的事。

就像 Prepay 表示事前預付,而 Postpay 表示事後支付。

先看老師示範的最簡範例:

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
        
    def __post_init__(self):
        print('__post_init__ called')
        print(repr(self))

c = CircleD()

輸出:

__post_init__ called
CircleD(x=0, y=0, radius=1)

我們可以把我們想在 __init__ 中做的事,寫在 __post_init__ 中, Dataclass 會幫忙處理掉瑣碎的事。

補充:__post_init__ 是實體方法(instance method),可以存取任何實體欄位(instance field,由 Dataclass __init__ 實體化的欄位)。

什麼情境適合使用 __post_init__

  1. 驗證屬性值

  2. 初始化與外部資源的連接

  3. 相依屬性的計算


Init-Only Variables

在上述 __post_init__ 中,我們還可以傳入參數,這些參數稱為 初始化專用變數(Init-Only Variables)。

初始化專用變數 不會存入實體記憶體中,所以必須使用參數方式數傳入 __post_init__

初始化專用變數 有特別的宣告方式(InitVar),而且需依序宣告。

from dataclasses import dataclass, InitVar

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    translate_x: InitVar[int] = 0  # 比較:translate_x: int = 0
    translate_y: InitVar[int] = 0  # 比較:translate_y: int = 0
        
    def __post_init__(self, translate_x, translate_y):
        print(f"Translating center by: \u0394x={translate_x}, \u0394y={translate_y}")
        self.x += translate_x
        self.y += translate_y

c = CircleD(0, 0, 1, -1, -2)
## 輸出:Translating center by: Δx=-1, Δy=-2
c
## 輸出:CircleD(x=-1, y=-2, radius=1)

如果要將 translate_xtranslate_y 設定為 keyword-only arguments,我們可以使用上一則影片中的 KW_ONLY

from dataclasses import dataclass, InitVar, KW_ONLY

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    _: KW_ONLY
    translate_x: InitVar[int] = 0
    translate_y: InitVar[int] = 0
        
    def __post_init__(self, translate_x, translate_y):
        print(f"Translating center by: \u0394x={translate_x}, \u0394y={translate_y}")
        self.x += translate_x
        self.y += translate_y
try:
    c = CircleD(0, 0, 1, -1, -2)
except TypeError as ex:
    print(f"TypeError: {ex}")

## 輸出:TypeError: CircleD.__init__() takes from 1 to 4 positional arguments but 6 were given
try:
    c = CircleD(0, 0, 1, translate_x=-2, translate_y=-1)
    ## 輸出:Translating center by: Δx=-2, Δy=-1
except TypeError as ex:
    print(f"TypeError: {ex}")

c
## 輸出:CircleD(x=-2, y=-1, radius=1)

證明一下 translate_x 和 translate_y 不會存入 instance field 記憶體中。

c.__dict__

## 輸出:{'x': -2, 'y': -1, 'radius': 1}

# 或用 fields(CircleD) 也可以檢查
# from dataclasses import fields
# fields(CircleD)

Field Level Customizations

參數 描述
default 屬性的默認值。如果使用者未提供該屬性的值,將使用默認值。
default_factory 可呼叫物件,用於在需要默認值時動態計算。
init 布林值,表示是否將此屬性包含在 __init__ 方法中。
repr 布林值,表示是否將此屬性包含在 __repr__ 方法中。
hash 布林值,表示是否將此屬性包含在自動生成的 __hash__ 方法中。
compare 布林值,表示是否將此屬性包含在自動生成的 __eq____ne__ 方法中。
metadata 字典,用於存儲自定義的屬性元數據。
kw_only 布林值,表示是否將此屬性僅接受關鍵字引數提供。

重要提示: 必須使用 field() 函數來創建 Field 類別的實體,而不要直接使用 Field 來實體化。

以下是 Field 實體化範例:

from dataclasses import field, Field

field()

輸出

Field(
name=None,
type=None,
default=<dataclasses._MISSING_TYPE object at 0x7aedf3cb3d90>,
default_factory=<dataclasses._MISSING_TYPE object at 0x7aedf3cb3d90>,
init=True,
repr=True,
hash=None,
compare=True,
metadata=mappingproxy({}),
kw_only=<dataclasses._MISSING_TYPE object at 0x7aedf3cb3d90>,
_field_type=None
)
from dataclasses import field, Field

f = field()
type(f)

輸出

dataclasses.Field

field 函式的 repr 參數

使用 repr 參數,來指定要顯示(represent)的屬性內容。

原本的正常顯示是這樣(所有的屬性都顯示):

from dataclasses import dataclass, field, Field

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1   

c  = CircleD()
c

## 輸出
CircleD(x=0, y=0, radius=1)

現在我們只想秀半徑(radius),而把座標 (x, y) 隱藏起來。

方法一:程式手工業

實作 __repr__

from dataclasses import dataclass, field, Field

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
        
    def __repr__(self):
        return f"{self.__class__.__qualname__}(radius={self.radius})"

c = CircleD()
c

## 輸出
CircleD(radius=1)

方法二:設定 field 的 repr 參數

設定 repr 參數,指定要顯示(represent)的屬性內容。

from dataclasses import dataclass, field, Field

@dataclass
class CircleD:
    x: int = field(repr=False)
    y: int = field(repr=False)
    radius: int = 1

c = CircleD(0, 0, 1)  ## <=====
c

## 輸出
CircleD(radius=1)

field 函式的 default 參數

前一段說明 repr 時,最後的例子裡,是用 c = CircleD(0, 0, 1) 來初始化,而不像前兩個例子使用 c = CircleD()

這是因為老師接下來就要介紹 default 參數。

from dataclasses import dataclass, field, Field

@dataclass
class CircleD:
    x: int = field(default=0, repr=False)
    y: int = field(default=0, repr=False)
    radius: int = 1

c = CircleD()  ## <=====
c

## 輸出
CircleD(radius=1)

Non-Initialized Fields

前面說明了如何創建(偽)欄位,而且這些欄位不是真正的欄位(dataclass 中的屬性),還能當作傳給 __init____post_init__ 的參數。

這節講的剛好倒過來:在類別中定義一個欄位,而且不一定會當作傳給 __init__ 的參數。

這個作法應該非常適合使用在計算欄位。

from dataclasses import dataclass
from math import pi

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
        
    def __post_init__(self):
        self._area = pi * self.radius ** 2
        
    @property
    def area(self):
        return self._area

c = CircleD()

c.area
## 輸出
3.141592653589793

這種方法有幾個問題:

  1. area 是一個常規屬性,而且不會顯示在資料類別的欄位中,這不是我們想要的。

  2. 類別狀態中,有一個額外的「backing 變數」_area。(「backing 變數」:封裝起來的屬性)

  3. 如果 dataclass 被凍結,嘗試在 __post_init__ 方法中存儲 self._area 將會失敗!

c.__dict__
## 輸出
{'x': 0, 'y': 0, 'radius': 1, '_area': 3.141592653589793}

from dataclasses import fields
fields(c)
## 輸出(只舉第一個 name 作範例)
Field(
name='x',
type=<class 'int'>,
default=0,
default_factory=<dataclasses._MISSING_TYPE object at 0x7f3f3f1ebd90>,
init=True,
repr=True,
hash=None,
compare=True,
metadata=mappingproxy({}),
kw_only=False,
_field_type=_FIELD),

如果我們想保持簡捷一致,我們真正需要的是一個在類別中定義的欄位,但不是預期的 __init__ (和 __post_init__ )的參數。

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    area: float = field(init=False, repr=False)  ## =====>
        
    def __post_init__(self):
        self.area = pi * self.radius ** 2

c = CircleD()
c.__dict__
## 輸出
{'x': 0, 'y': 0, 'radius': 1, 'area': 3.141592653589793}

c.area
## 輸出
3.141592653589793

fields(c)
## 輸出(省略)

如果我們想要,我們也可以選擇凍結類別。

但如果凍結類別,實體變數的任何賦值(即使在 __post__init__ 內部)都不會生效。

所以會出現錯誤,如下:

@dataclass(frozen=True)
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    area: float = field(init=False, repr=False)
        
    def __post_init__(self):
        self.area = pi * self.radius ** 2
from dataclasses import FrozenInstanceError

try:
    c = CircleD()
except FrozenInstanceError as ex:
    print(f"FrozenInstanceError: {ex}")

## 輸出
FrozenInstanceError: cannot assign to field 'area'  ## =====>

我們可以使用父類別(super())上的 __setattr__ ,來繞過資料類別的凍結保護,但這將導致雜湊時可能出現問題。

我們在上一段影片中,看到了可變性和雜湊性可能會出現問題。

@dataclass(frozen=True)
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    area: float = field(init=False, repr=False)
        
    def __post_init__(self):
        super().__setattr__("area", pi * self.radius ** 2)  ## =====>

## 以下的輸出測試同前,略過

我們甚至可以利用這一點來實現屬性的惰性評估(lazy evaluation):

@dataclass(frozen=True)
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    _area: float = field(init=False)
        
    @property
    def area(self):
        if not getattr(self, '_area', None):
            print("Cache miss")
            super().__setattr__('_area', pi * self.radius ** 2)
        else:
            print("Cache hit")
        return self._area

c = CircleD()
c.__dict__
## 輸出
{'x': 0, 'y': 0, 'radius': 1}

c.area
## 輸出
Cache miss  ## =====>
3.141592653589793

c.area
## 輸出
Cache hit  ## =====>
3.141592653589793

不建議這種解決方式(super().__setattr__())。

我們有時會理由,設計不包含在初始化引數中的欄位,但是當 dataclass 被凍結時,它就會衍生問題。

field 函式的 compare 參數

透過指定 field 函式的 compare 參數,我們可以改變 __eq__ 的行為。(因為 dataclass 好心的幫我們實作了簡單的 __eq__

其實還有 __lt__ , __le__

相等和(默認)排序順序,是基於 dataclass 的所有欄位 (按順序) 。

老師舉的例子是:將原本座標、半徑都相同的圓,改為半徑相同即判定兩圓相等。

from dataclasses import dataclass, field, Field

@dataclass(order=True)
class CircleD:
    x: int = field(default=0, compare=False)
    y: int = field(default=0, compare=False)
    radius: int = 1

c1 = CircleD(1, 0, 2)
c2 = CircleD(1, 1, 1)
c1 <= c2

## 輸出
False

c3 = CircleD(1, 0, 1)
c4 = CircleD(1, 1, 1)
c3 = c4

## 輸出
True

提醒:使用 dataclass 是因為方便,它幫我們實作了許多功能。所以如果你要做的事,違背了一般常理,你要實作(修改 dataclass 幫你做)的部分會愈來愈多,那不如直接使用一般的類別就好。

Hashing Partially Mutable Classes

用於為實體創建雜湊的實體狀態,應該是不可變的(但這並不表示整個類別都是不可變的)。

在大多數情況下,我們應該遵循這些規則:

  • 用於為實體生成雜湊的實體資料,應該是不可變的

  • 用於生成雜湊的相同資料,是實作相等性的一部分

  • 兩個相等的實體,應該有相同的雜湊

首先我們來看看之前的例子,符合上面三條件的二和三。

class Person:
    def __init__(self, name, age, ssn):
        self.name: str = name  # this could change over time
        self.age: int = age  # this changes over time
        self.ssn: str = ssn  # this never changes over time
        
    def __eq__(self, other):
        if self.__class__ == other.__class__:
            return self.ssn == other.ssn
        return NotImplemented
    
    def __hash__(self):
        return hash(self.ssn)

接著修改程式,來符合第一要件。

class Person:
    def __init__(self, name, age, ssn):
        self.name: str = name  # this could change over time
        self.age: int = age  # this changes over time
        self._ssn: str = ssn  # this never changes over time
        
    def __eq__(self, other):
        if self.__class__ == other.__class__:
            return self.ssn == other.ssn
        return NotImplemented
    
    def __hash__(self):
        return hash(self.ssn)
    
    @property
    def ssn(self):
        return self._ssn
    
    def __repr__(self):
        return f"Person(name={self.name}, age={self.age}, ssn={self.ssn}, id={hex(id(self))})"

驗證是否符合(一)

p1 = Person('A', 30, '12345')
p2 = Person('B', 40, '23456')
p3 = Person('C', 50, '12345')

p1 == p2, p1 == p3
## 輸出
(False, True)

hash(p1), hash(p2), hash(p3)
## 輸出
(-5618707572131111372, 441038772423436139, -5618707572131111372)

驗證是否符合(二)

以 set & dict 驗證

{p1, p2, p3}
## 輸出
{Person(name=A, age=30, ssn=12345, id=0x7f7d7fafb4c0),
 Person(name=B, age=40, ssn=23456, id=0x7f7d7faf96f0)}

d = {p1: "Person 1", p2: "Person 2"}
d
## 輸出
{Person(name=A, age=30, ssn=12345, id=0x7f7d7fafb4c0): 'Person 1',
 Person(name=B, age=40, ssn=23456, id=0x7f7d7faf96f0): 'Person 2'}

只要不改到 ssn(處理 hash),其他的 name & age 怎麼改都沒關係。

p1.name = 'X'
p1.age=100
d
## 輸出
{Person(name=X, age=100, ssn=12345, id=0x7f7d7fafb4c0): 'Person 1',
 Person(name=B, age=40, ssn=23456, id=0x7f7d7faf96f0): 'Person 2'}

d[p1]
## 輸出
Person 1

當我們將 dataclass 設為 frozen=True 時,會自動幫我們處理雜湊的相關事宜(別忘了 dataclass 號稱程式產生器。frozen 就成為不可變,也就是可雜湊)。

@dataclass(frozen=True)
class Person:
    name: str
    age: int
    ssn: str

p1 = Person('A', 30, '12345')
p2 = Person('B', 40, '23456')

{p1, p2} ## 實例可雜湊
## 輸出
{Person(name='A', age=30, ssn='12345'), Person(name='B', age=40, ssn='23456')}

p1 = Person('A', 30, '12345')
p2 = Person('A', 30, '12345')
p3 = Person('B', 40, '12345')
p1==p2, p2==p3, p1==p3
## 輸出
(True, False, False)

到目前為止,兩個實例相等,是指所有欄位相等。

不過我們只想讓 ssn 不可變,讓 name & age 是可變的。

@dataclass(frozen=True)
class Person:
    name: str = field(compare=False)
    age: int = field(compare=False)
    ssn: str

p1 = Person('A', 30, '12345')
p2 = Person('B', 40, '12345')

p1 is p2
## 輸出:False
p1 == p2
## 輸出:True
hash(p1) == hash(p2)
## 輸出:True

如果我們使用 order=True 裝飾器使 dataclass 可排序,當然會影響默認排序。

所以即使從技術上可以改變 nameage 屬性,我們也不能這樣做:

from dataclasses import FrozenInstanceError

try:
    p1.name = 'X'
except FrozenInstanceError as ex:
    print(f"FrozenInstanceError: {ex}")

## 輸出
FrozenInstanceError: cannot assign to field 'name'

field 函式的 unsafe_hash 參數

上面的問題要如何解決呢?

我們前面才剛看過老師示範用 super().__setattr__() 來解決,但這不是好方法。

假設我們可以讓 dataclass 設回可改變,而且所有開發人員都知道不可修改 不可變 的屬性:

from dataclasses import dataclass, field, Field

@dataclass(unsafe_hash=True) ## =====>
class Person:
    name: str = field(compare=False) 
    age: int = field(compare=False)
    ssn: str

p1 = Person('A', 30, '12345')
p2 = Person('B', 40, '12345')

p1 is p2, p1 == p2, hash(p1) == hash(p2)
## 輸出
(False, True, True)

而且我們可以修改 name & age。

p1 = Person('A', 30, '12345')
p2 = Person('B', 40, '23345')

d = {
    p1: 'Person A',
    p2: 'Person B'
}

d
## 輸出
{Person(name='A', age=30, ssn='12345'): 'Person A',
 Person(name='B', age=40, ssn='23345'): 'Person B'}

p1.name = 'AAA'
p1.age = 300

d
## 輸出
{Person(name='AAA', age=300, ssn='12345'): 'Person A',
 Person(name='B', age=40, ssn='23345'): 'Person B'}

但前面已強調「所有開發人員都知道不可修改 不可變 的屬性」,因為其實可以修改 ssn。

p2.ssn = '12345'

d
## 輸出
{Person(name='AAA', age=300, ssn='12345'): 'Person A',
 Person(name='B', age=40, ssn='12345'): 'Person A'}

d[p1]
## 輸出
Person A

d[p2]
## 輸出
Person A

果然出槌了!


field 函式的 kw_only 參數

前面已提及使用 KW_ONLY 關鍵字來區分 Keyword-Only Arguments。範例:

from dataclasses import dataclass, field, Field, InitVar, KW_ONLY

@dataclass
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    _: KW_ONLY
    translate_x: InitVar[int] = 0  
    translate_y: InitVar[int] = 0
        
    def __post_init__(self, translate_x, translate_y):
        print(f"Translating center by: \u0394x={translate_x}, \u0394y={translate_y}")
        self.x += translate_x
        self.y += translate_y

CircleD(0, 0, 1, translate_y=-1, translate_x=0)
## 輸出:
Translating center by: Δx=0, Δy=-1
CircleD(x=0, y=-1, radius=1)

我們知道 KW_ONLY 對傳入參數的限制:

try:
    CircleD(0, 0, 1, 0, -1)
except TypeError as ex:
    print(f"TypeError: {ex}")

## 輸出:
TypeError: CircleD.__init__() takes from 1 to 4 positional arguments but 6 were given

我們也知道 Python 的 keyword-only arguments,要放在 positional arguments 之後。

不過 dataclass 在 compile 時,會自己修正不符規定的順序。例如:

from dataclasses import dataclass, field, Field, InitVar, KW_ONLY

@dataclass
class CircleD:
    x: int = 0
    translate_x: InitVar[int] = field(default=0, kw_only=True) ## <=====
    y: int = 0
    translate_y: InitVar[int] = field(default=0, kw_only=True) ## <=====
    radius: int = 1
        
    def __post_init__(self, translate_x, translate_y):
        print(f"Translating center by: \u0394x={translate_x}, \u0394y={translate_y}")
        self.x += translate_x
        self.y += translate_y

CircleD.__init__
## 輸出
<function __main__.CircleD.__init__(
self, 
x: int = 0, 
y: int = 0, 
radius: int = 1, 
*, 
translate_x: dataclasses.InitVar[int] = 0, 
translate_y: dataclasses.InitVar[int] = 0)
 -> None>

我們還是可以依序創建 CircleD:

CircleD(0, 0, 1, translate_y=-1, translate_x=0)
## 輸出:
Translating center by: Δx=0, Δy=-1
CircleD(x=0, y=-1, radius=1)

以程式呼叫的方式建立 dataclass

因不確定觀看影片對象的程度,老師先複習了 named tuples。

方法一

from typing import NamedTuple

class Person(NamedTuple):
    name: str
    age: int
    ssn: str

方法二

from collections import namedtuple

Person = namedtuple("Person", "name age ssn")

原本的 dataclass 是這樣:

from dataclasses import dataclass, field, Field, InitVar

@dataclass(order=True)
class CircleD:
    x: int = 0
    y: int = 0
    radius: int = 1
    translate_x: InitVar[int] = field(default=0, kw_only=True)
    translate_y: InitVar[int] = field(default=0, kw_only=True)
        
    def __post_init__(self, translate_x, translate_y):
        print(f"Translating center by: \u0394x={translate_x}, \u0394y={translate_y}")
        self.x += translate_x
        self.y += translate_y

加上 make_dataclass 之後:

from dataclasses import dataclass, field, Field, InitVar, make_dataclass

def post_init(self, translate_x, translate_y):
    print(f"Translating center by: \u0394x={translate_x}, \u0394y={translate_y}")
    self.x += translate_x
    self.y += translate_y

CircleD2 = make_dataclass(
    'CircleD2',
    [
        ('x', int, 0),
        ('y', int, 0),
        ('radius', int, 0),
        ('translate_x', InitVar[int], field(default=0, kw_only=True)),
        ('translate_y', InitVar[int], field(default=0, kw_only=True))
    ],
    order=True,
    namespace = {
        "__post_init__": post_init
    }
)

c = CircleD2(1, 2, 3, translate_x=1, translate_y=2)

## 輸出
Translating center by: Δx=1, Δy=2

c
## 輸出
CircleD2(x=2, y=4, radius=3)

field 函式的 metadata 參數

Python 目前沒有內建,但第三方函式庫已經有了。

在 dataclass 的 field 自訂 metadata 的方式,就是建立 mapping 對應的表和列。

最簡單直覺的作法,就是利用 mappingproxy。

建立 metadata 前

from dataclasses import dataclass, field, Field

@dataclass(unsafe_hash=True)
class Person:
    name: str = field(compare=False)
    age: int = field(compare=False)
    ssn: str

建立 metadata 後

from dataclasses import dataclass, field, Field, fields ## <=====

@dataclass(unsafe_hash=True)
class Person:
    name: str = field(compare=False, metadata={'table': 'person', 'column': 'name'})
    age: int = field(compare=False, metadata={'table': 'person', 'column': 'current_age'})
    ssn: str = field(metadata={'table': 'person', 'column': 'ssn'})

以 help 觀察,並沒有 metadata 的相關資訊(呼應前述 Python 未內建的敘述)。

請點擊展開觀看 help 文字說明:
Help on class Person in module __main__:

class Person(builtins.object)
 |  Person(name: str, age: int, ssn: str) -> None
 |  
 |  Person(name: str, age: int, ssn: str)
 |  
 |  Methods defined here:
 |  
 |  __eq__(self, other)
 |      Return self==value.
 |  
 |  __hash__(self)
 |      Return hash(self).
 |  
 |  __init__(self, name: str, age: int, ssn: str) -> None
 |      Initialize self.  See help(type(self)) for accurate signature.
 |  
 |  __repr__(self)
 |      Return repr(self).
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes defined here:
 |  
 |  __annotations__ = {'age': <class 'int'>, 'name': <class 'str'>, 'ssn':...
 |  
 |  __dataclass_fields__ = {'age': Field(name='age',type=<class 'int'>,def...
 |  
 |  __dataclass_params__ = _DataclassParams(init=True,repr=True,eq=True,or...
 |  
 |  __match_args__ = ('name', 'age', 'ssn')
fields(Person)

## 輸出(僅取 name, age, ssn 中的 name 當範例)
(Field(
name='name',
type=<class 'str'>,
default=<dataclasses._MISSING_TYPE object at 0x7f7da0ec3d90>,
default_factory=<dataclasses._MISSING_TYPE object at 0x7f7da0ec3d90>,
init=True,
repr=True,
hash=None,
compare=False,
metadata=mappingproxy({'table': 'person', 'column': 'name'}), ## <=====
kw_only=False,
_field_type=_FIELD)

(fields(Person)[0]).metadata
## 輸出
mappingproxy({'table': 'person', 'column': 'name'})

field 函式的 default_factory 參數

首先以初學者常犯的錯誤做引言,這部分大家應該很熟,請自行點擊展開錯誤和正確程式碼的比對。

在函數中初始化的常見錯誤。請點擊展開

錯誤示範

def squares(i, l = []):
    l.append((i, i ** 2))
    return l

numbers = squares(1)
numbers
## 輸出
[(1, 1)]

others = squares(2)
others
## 輸出
[(1, 1), (2, 4)]

others = squares(3)
others
## 輸出
[(1, 1), (2, 4), (3, 9)]

說明:這是因為函數 squares 在編譯時(compile,不是呼叫 call,只發生一次),預設值 l 就已經創建(l = []),並儲存在函數物件的狀態中。

所以每次呼叫(call)函數時,都是存取 相同的 list,因此內容一直 append 進 list。

正確做法

def squares(i, l=None):
    if l is None:
        l = []
    l.append((i, i ** 2))
    return l

## 以下程式碼相同,輸出正確,省略

前面示範了函數的做法,接著以 class 示範同樣的問題。這部分大家還是很熟,請自行點擊展開錯誤和正確程式碼的比對。

在 class 中初始化的常見錯誤。請點擊展開

錯誤示範一

class Test:
    def __init__(self, tests=[]):
        self.tests = tests
        
    def add(self, i):
        self.tests.append((i, i ** 2))

t1 = Test()
t1.add(1)
t1.tests
## 輸出:
[(1, 1)]

t2 = Test()
t2.add(2)
t2.tests
## 輸出:
[(1, 1), (2, 4)]

錯誤示範二

class Test:
    tests = []
    
    def add(self, i):
        self.tests.append((i, i ** 2))

## 以下程式碼相同,輸出一樣錯誤,省略

正確做法

__init__ 中判斷 list 是否存在,若不存在就創建

class Test:
    def __init__(self, tests=None):
        if tests is None:
            self.tests = []
        else:
            self.tests = tests
        
    def add(self, i):
        self.tests.append((i, i ** 2))

## 以下程式碼相同,輸出正確,省略

前面複習了 函數類別 的做法,接下來看 dataclass 的做法,就很熟悉了。

錯誤示範

from dataclasses import dataclass

try:
    @dataclass
    class Test:
        tests: list = []

        def add(self, i):
            self.tests.append((i, i ** 2))
except ValueError as ex:
    print(f"ValueError: {ex}")

## 輸出
ValueError: mutable default <class 'list'> for field tests is not allowed: use default_factory

老師提醒我們,Python 會以是否可雜湊,來幫忙判斷,所以上述程式在 complie 階段就報錯。但我們必須心理有底,這種方法並不保證正確。

正確做法一

class Test:
    def __init__(self, tests_factory):
        self.tests = tests_factory()
        
    def add(self, i):
        self.tests.append((i, i ** 2))

def factory_func():
    return []

t1 = Test(factory_func)
t1.add(1)
t1.tests
## 輸出
[(1, 1)]

## 輸出
t2 = Test(factory_func)
t2.add(2)
t2.tests
[(2, 4)]

接著簡化上面的程式:直接以 list 取代 factory_func。(程式省略)

dataclass 的正確做法

from dataclasses import dataclass, field, Field

@dataclass
class Test:
    # tests: list = field(default_factory=factory_func)
    tests: list = field(default_factory=list)

    def add(self, i):
        self.tests.append((i, i ** 2))

t1 = Test()
t1.add(1)
t1.tests
## 輸出
[(1, 1)]

## 輸出
t2 = Test()
t2.add(2)
t2.tests
[(2, 4)]
t1 is t2
## 輸出
False

結論


補充資訊

為什麼要用 dataclass

這段影片很短,不到 9分鐘,卻以實際範例,精簡的說明了 dataclass 幫你做了哪些事(果然是懶人福音)。

順便送你個 bonus - attrs。(僅提及,另有專門影片介紹)

看看 dataclass 幫你自動產生的 code 有哪些?

程式來源:上述影片 James Murphy 的 github

import inspect
from dataclasses import dataclass, field
from pprint import pprint

@dataclass(frozen=True, order=True)
class Comment:
    id: int
    text: str

def main():
    comment = Comment(1, "I just subscribed!")
    print(comment)

    pprint(inspect.getmembers(Comment, inspect.isfunction))  ## <=====


if __name__ == '__main__':
    main()

輸出:

Comment(id=1, text='I just subscribed!')
[('__delattr__', <function Comment.__delattr__ at 0x7a6f30be6d40>),
 ('__eq__', <function Comment.__eq__ at 0x7a6f30be70a0>),
 ('__ge__', <function Comment.__ge__ at 0x7a6f30be6e60>),
 ('__gt__', <function Comment.__gt__ at 0x7a6f30be6ef0>),
 ('__hash__', <function Comment.__hash__ at 0x7a6f30be6c20>),
 ('__init__', <function Comment.__init__ at 0x7a6f30be7250>),
 ('__le__', <function Comment.__le__ at 0x7a6f30be6f80>),
 ('__lt__', <function Comment.__lt__ at 0x7a6f30be7010>),
 ('__repr__', <function Comment.__repr__ at 0x7a6f30be72e0>),
 ('__setattr__', <function Comment.__setattr__ at 0x7a6f30be6dd0>)]

本堂課的影片