Pydanticで特定のキーの値によってそれ以外のキーに異なるデフォルト値を設定する
先に結論
Literal
を使って、型定義を使って分岐させる
モチベーション
Pythonで設定ファイルから取り出してdictをクラスインスタンスに落とし込むときに、あるキーに応じて、それ以外のキーのデフォルト値を変更したくなった。
↓こういうイメージ
zoo = Zoo(
**{
"animals": [
{"type": "dog"}, # 鳴き声をbowに
{"type": "cat"}, # 鳴き声をmeowに
{"type": "duck"}, # 鳴き声をquackに
{"type": "cat", "sound": "ニャー"}, # 上書きも反映されてニャーに
]
}
)
for animal in zoo.animals:
print(animal.sound) # bow,meow,quack,ニャーの順に表示される
dictからクラスインスタンスを作成するにはPydanticを活用するとして、dict中の特定のキー(上記の例ではtype
)を参照して他のキー(e.g. sound
)のデフォルト値を自動で設定し、それを簡潔に記述するにはどうすればよいのかという問題が生じた。
素朴な方法
コンストラクタ内での条件分岐を検討した。
しかし、この方法では
- 分岐が増えたときに可読性が下がる
- デフォルト値以外で上書きをしたいときに処理が面倒
という問題が生じる。特に2が厄介で、例えば上記コードの
{"type": "cat", "sound": "ニャー"}, # 上書きも反映されてニャーに
という部分では、条件分岐内で定義するであろうデフォルト値と与えられた値がバッティングしてしまう。
これを防ぐために、コンストラクタで明示的に値を与えられた場合はデフォルト値をそれで上書きして、そのうえでインスタンスに書き込む処理が必要となる。このような書き方はコンストラクタが長くなってしまい、可読性が大幅に下がってしまい、作成するのも大変である。
型定義による分岐
PythonにはLiteral
型がある。これはTypescriptのリテラル型とほぼ同じで、特定の値のみの代入を許す型である。
例えば
cat: Literal["cat"] = "cat"
dog: Literal["dog"] = "dog"
であれば、catとdogは異なる型となる。リテラル型を用いることで、名前は同じだが、許容される値が異なるプロパティを別々のクラスを作成でき、dictのキーの値によって、異なる型のインスタンスを作成し分岐できるようになる。
最初の例であれば、次のように実装できる。
from typing import List, Literal
from pydantic import BaseModel
class Animal(BaseModel):
type: object
sound: str
class Cat(Animal):
type: Literal["cat"]
sound: str = "meow"
class Dog(Animal):
type: Literal["dog"]
sound: str = "bow"
class Duck(Animal):
type: Literal["duck"]
sound: str = "quack"
class Zoo(BaseModel):
animals: List[Cat | Dog | Duck]
zoo = Zoo(
**{
"animals": [
{"type": "dog"},
{"type": "cat"},
{"type": "duck"},
{"type": "cat", "sound": "ニャー"}, #soundも問題なく上書きされる
]
}
)
for animal in zoo.animals:
print(animal.sound) # bow,meow,quack,ニャーの順に表示される
animalsはCat,Dog,Duckのいずれかであるが、typeの値によって型がいずれかの一意に定まるため、デフォルト値もそれに応じて書き換えられるようになる。また、分岐による実装で問題となったデフォルト値を上書きするケースでも問題なく動く。
まとめ
PydanticのコンストラクタとLiteral型を活用することで、辞書とクラスの相互変換において、あるキー(プロパティ)の値をもとに、他のキー(プロパティ)のデフォルト値を動的に差し替えることができた。
Discussion