今天認識了一個名詞:鑑別欄位(discriminator field / discriminator column)
在處理資料時,我常會遇到以下形式:
- 多筆資料雖然擁有各自不同的欄位,但會被放置在一起。
- 每筆資料中都有一個特定欄位,藉由該欄位的值,我們能夠辨識出對應的物件類型。
這種資料結構正是鑑別欄位的應用。
舉例來說,我可以將多個形狀放在一個陣列之中:
[
{
"type": "rectangle",
"height": 5,
"width": 10
},
{
"type": "circle",
"radius": 5
}
]
此時 type
就是這裡的鑑別欄位。
使用 Pydantic 處理:僅使用聯集
在將一系列的 JSON 資料讀取、反序列化(deserialization)為 Python 類別的過程中,我很喜歡使用 Pydantic 來進行資料驗證。
而過去我通常會直接使用聯集型別(Union)來處理這種各自帶有不同欄位的情形,如:
from typing import Literal
from pydantic import BaseModel
class Rectangle(BaseModel):
type: Literal["rectangle"]
width: int
height: int
class Circle(BaseModel):
type: Literal["circle"]
radius: int
Shape = Union[Rectangle, Circle]
然後就可以用這個 Shape
型別去處理上面那個清單。但這個手法在進行報錯的時候會很煩——舉例來說,我放了一個長方形進去、卻忘記寫上寬度:
[
{
"type": "rectangle",
"height": 5
}
]
那麼 Pydantic 會把所有可能的錯誤列出來:
pydantic_core._pydantic_core.ValidationError: 3 validation errors for list[union[Rectangle,Circle]]
0.Rectangle.width
Field required [type=missing, input_value={'type': 'rectangle', 'height': 5}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.10/v/missing
0.Circle.type
Input should be 'circle' [type=literal_error, input_value='rectangle', input_type=str]
For further information visit https://errors.pydantic.dev/2.10/v/literal_error
0.Circle.radius
Field required [type=missing, input_value={'type': 'rectangle', 'height': 5}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.10/v/missing
而過去我會額外建一個函數、以工廠方法模式去進行操作——即先確認 type
欄位,然後再將資料送給對應的類別進行解析,以減少錯誤回報時的雜訊。
使用 Pydantic 處理:使用鑑別欄位
撰寫這篇文的起因就是最近我終於已知用火:Pydantic 自身就有內建鑑別欄位的處理機制,利用其提供的 Discriminator 註釋可以讓 Pydantic 依照該鑑別欄位進行反序列化,並且在有出錯的時候報錯也比較不會那麼囉唆。
程式碼方面,我們要補加上對應的註釋:
from typing import Annotated, Union
from pydantic import Discriminator
# 兩個 class 都沒動,故省略之
type Shape = Annotated[Union[Rectangle, Circle], Discriminator("type")]
有了 Discriminator
提供注釋之後,Pydantic 就會理解先前舉例的那個缺少寬度的 rectangle
需要對應 Rectangle
類別,故報錯的時候就會僅列出跟 Rectangle
的驗證錯誤了:
pydantic_core._pydantic_core.ValidationError: 1 validation error for list[tagged-union[Rectangle,Circle]]
0.rectangle.width
Field required [type=missing, input_value={'type': 'rectangle', 'height': 5}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.10/v/missing
Callable Discriminator ——更彈性的鑑別欄位
上面的範例中我們都有確保了每個欄位一定都帶有 type
欄位、但總有些時候識別用的手段不是那麼明確。
舉例來說,也許那堆資料中會包含一些 該死的歷史因素 不使用 type
欄位的資料:
[
{
"shape": "square",
"side_length": 4
}
]
那麼我們可以利用 Callable Discriminator 的形式來動態取得鑑別欄位:
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Discriminator, Tag
# Rectangle 及 Circle 沒動,故省略之
class Square(BaseModel):
shape: Literal["square"]
side_length: int
def get_discriminator_value(v: Any) -> str:
if isinstance(v, dict):
return v.get("type") or v.get("shape")
return getattr(v, "type", getattr(v, "shape", None))
Shape = Annotated[
Union[
Annotated[Rectangle, Tag("rectangle")],
Annotated[Circle, Tag("circle")],
Annotated[Square, Tag("square")],
],
Discriminator(get_discriminator_value),
]
在上面的範例中,我們新增了一個自定義的函數、來讓 shape
欄位成為 type
欄位的後備欄位,然後 Pydantic 會依照回傳的值去呼叫 Tag 標記的類別來進行解析,進而達成讓三個各自擁有不同結構的類別可以有效地一同處理。
利用這個特性,我們不僅於可以單純使用鑑別欄位、甚至可以實作各類啟發式的規則來彈性地進行資料解析呢!