
Pydantic V2: Essentials: コンポジションと継承 (セクション11/13)
モデルコンポジション:モデルのフィールドとして別の Pydantic モデルを利用することで、ネストされたデータのシリアライズや逆シリアライズを自動化できる。
モデル継承:共通の設定(例:alias_generator や extra の設定)や共通フィールドをカスタムベースモデルに定義し、各モデルに再利用することでコードの重複を避ける。
これらを使い分けることで、複雑なデータ構造を整理し、保守性と再利用性の高い効率的なデータモデリングが実現できる。
Pydantic では、通常、`BaseModel` を継承するクラスとしてモデルを定義します。これらのモデルは多くのデータ構造に対してうまく機能しますが、スキーマがより複雑になるにつれて、コードの整理のためにモデルを コンポジション(構成)または 継承 を用いて組み合わせる必要が出てきます。本記事では、Pydantic V2 における2つの基本概念について検討します。
モデルコンポジション:モデルのフィールド自体が Pydantic モデルである場合のこと。
モデル継承:カスタムのベースクラス(または共通の親クラス)を作成し、他のモデルが設定やフィールドを拡張または再利用できるようにする方法。
1. コンポジション:モデルのネスト
コンポジションとは、モデルのフィールドとして、単なる Python の基本型や注釈付き型ではなく、別の Pydantic モデルを用いることを指します。これは、たとえば「円」モデルが「点」モデルを中心として持つ場合や、ある「連絡先」モデルが「電話番号」サブモデルを持つ場合に非常に有用です。
1.1 基本的な例
たとえば、まず `Point2D` モデルを定義します:
from pydantic import BaseModel, Field
class Point2D(BaseModel):
x: float = 0
y: float = 0
次に、`Circle2D` モデルを作成し、その `center` フィールドの型として `Point2D` を指定し、半径 `radius` を定義します:
class Circle2D(BaseModel):
center: Point2D
radius: float = Field(default=1, gt=0)
インスタンス生成の例:
circle = Circle2D(center=Point2D(x=1, y=1), radius=2)
print(circle)
# 出力例: Circle2D(center=Point2D(x=1.0, y=1.0), radius=2.0)
シリアライズの場合:
`circle.model_dump()` は以下のような辞書を生成します:
{
"center": {"x": 1.0, "y": 1.0},
"radius": 2.0
}
`circle.model_dump_json()` は次のような JSON 文字列となります:
{"center":{"x":1.0,"y":1.0},"radius":2.0}
辞書や JSON からの 逆シリアライズ も同様に動作します。ネストされた辞書(または JSON オブジェクト)に `"center"` キーが存在すれば、それが `Point2D` として検証され、`Circle2D` インスタンスの `center` フィールドとなります。
1.2 実際のユースケース:必要なフィールドのみの選択
コンポジションは、入力データに大量の余分な情報が含まれている場合にも有効です。Pydantic では `extra="ignore"`(または、特定の設定によって未定義フィールドの入力を禁止する)を利用することで、必要なフィールドのみを抽出することができます。たとえば、大きな JSON ペイロードが次のように与えられたとします:
{
"firstName": "David",
"lastName": "Hilbert",
"contactInfo": {
"email": "d.hilbert@example.com",
"homePhone": { /* … 大量のネストしたオブジェクト … */ }
},
"personalInfo": {
"nationality": "German",
"born": {
"date": "1862-01-23",
"place": {
"city": "Konigsberg",
"country": "Prussia"
}
},
"died": { /* … この部分は無視 */ }
},
"awards": ["Lobachevsky Prize", "ForMemRS"],
"notableStudents": ["von Neumann", "Weyl", "Courant", "Zermelo"]
}
ここで、たとえば `firstName`、`lastName`、`email`、`born.date` と `born.place`、および `notableStudents` のみが必要であるとします。複数のサブモデルを定義し、それぞれが不要なフィールドを無視するように設定することで、最終的な `Person` モデルで必要なフィールドのみを抽出できます。各 Pydantic モデルは独自の `model_config` を持ち、未定義フィールドを無視することにより、必要なフィールドだけを確実に取得できます。
2. 継承:共通設定および共有フィールド
Pydantic における 継承 は、通常の Python の継承と同様です。親クラスも `BaseModel` から派生しているため、親クラスで定義した設定やフィールドは子クラスに引き継がれます。これにより、以下のようなメリットがあります。
共通設定の共有
例えば、`extra` の設定や `alias_generator`(エイリアス生成関数)の設定をすべてのモデルに対して一括で行えます。これにより、同じ設定を各モデルで繰り返し記述する必要がなくなります。共通フィールドの提供
すべてのモデルに共通のフィールド(例えば、リクエストごとの一意な ID やタイムスタンプなど)を親クラスで定義し、各モデルがそれを引き継ぐようにできます。
2.1 カスタムベースモデルの作成
一般的なパターンとして、まず「カスタムベースモデル」を定義します。これはフィールドは持たず、`model_config` のみを設定するものです。たとえば:
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class CamelBaseModel(BaseModel):
model_config = ConfigDict(
extra="ignore",
alias_generator=to_camel,
populate_by_name=True
)
これにより、`CamelBaseModel` を継承するすべてのモデルは以下の設定を自動的に引き継ぎます。
未定義フィールドを無視する。
出力時にフィールド名が camelCase に変換される。
エイリアスまたは名前のどちらからもフィールドに値を設定できる。
これは、各モデルごとに同じ `model_config` を記述するよりも遥かに効率的です。
2.2 フィールドの共有による継承
すべてのモデルに共通のフィールドを持たせたい場合も、継承が有効です。たとえば、すべての API レスポンスに対して、リクエスト ID や実行時間などの共通フィールドが必要な場合、以下のような親クラスを定義します。
from uuid import uuid4
from pydantic import Field
class ResponseBaseModel(CamelBaseModel):
request_id: UUID = Field(default_factory=uuid4)
elapsed_time: float = 0.0
その後、API の各レスポンスモデルは `ResponseBaseModel` を継承するだけで、これらの共通フィールドを自動的に持つことができます。
注意点:Pydantic では複数継承は基本的に避けるべきです。単一継承で共通の設定やフィールドを共有するのが一般的であり、複数継承は Python の MRO(メソッド解決順序)との相互作用で予期せぬ挙動を引き起こす可能性があるため、慎重に扱う必要があります。
3. コンポジションと継承の使い分け
コンポジションと継承はどちらもコードの再利用の手法ですが、目的が異なります。
コンポジション
→ 階層構造のデータ、つまりあるモデルがサブモデル(他の Pydantic モデル)をフィールドとして持つ場合に使用します。たとえば、ユーザーの住所や連絡先情報など、ネストされた構造を表現する場合に適しています。継承
→ 全体に共通する設定(例:`extra="ignore"` や `alias_generator`)や、すべてのモデルに含めたい共通フィールド(例:ID やタイムスタンプ)を定義するために使用します。
要点:
階層的なデータ構造には コンポジション を使い、
全体に共通する設定やフィールドの共有には 継承 を使います。
4. 具体例:Automobile モデルのリファクタリング
たとえば、`Automobile` モデルが日付、複数の文字列フィールド、そして検証済みの「国名」フィールドを含んでいるとします。ここで以下の要件があると仮定します。
すべてのモデルに対して「camelCase のエイリアス生成」と「未定義フィールドの禁止」の共通設定を適用したい。
「登録国(registration country)」のロジックは、サブモデルに切り出したほうが良い(重複を避けるため)。
4.1 カスタムベースモデルの定義
まず、共通設定をまとめた `CamelBaseModel` を定義します。
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class CamelBaseModel(BaseModel):
model_config = ConfigDict(
extra="ignore",
str_strip_whitespace=True,
validate_default=True,
validate_assignment=True,
alias_generator=to_camel,
)
4.2 登録国サブモデルの作成
次に、登録国に関するロジック(例えば、標準化された国名と3文字コードの算出)をサブモデルとして切り出します。
from functools import cached_property
from pydantic import computed_field, Field
# 例:country_code_lookup は「United States of America」で "USA" を返す辞書など
class RegistrationCountry(CamelBaseModel):
name: str | None = Field(default=None)
@computed_field
@cached_property
def code3(self) -> str | None:
if self.name:
return country_code_lookup[self.name] # 例:"USA"
return None
4.3 Automobile モデルのリファクタリング
最後に、`Automobile` モデルは `CamelBaseModel` を継承し、従来の「registration_country」フィールドの代わりに、サブモデル `RegistrationCountry` をフィールドとして持ちます。
class Automobile(CamelBaseModel):
id_: UUID4 = Field(alias="id", default_factory=uuid4)
manufacturer: str
# ... 他のフィールド …
# 登録国フィールドを文字列ではなくサブモデルとして定義
registration_country: RegistrationCountry | None = Field(default=None)
# その他のフィールドは従来通り
このように分離することで、共通のロジック(エイリアス生成や検証)は一箇所にまとめられ、各モデルはより整理された状態となります。
5. まとめてみると
以下は、これまでの内容をまとめた実例です。
from uuid import UUID, uuid4
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, PlainSerializer, UUID4, ValidationInfo
from pydantic.alias_generators import to_camel
from functools import cached_property
# 国名とコードの対応辞書(例)
countries = {
"australia": ("Australia", "AUS"),
"canada": ("Canada", "CAN"),
"us": ("United States of America", "USA"),
# 他の国も同様…
}
country_code_lookup = {name: code for name, code in countries.values()}
# CamelBaseModel:共通設定を持つカスタムベースモデル
class CamelBaseModel(BaseModel):
model_config = ConfigDict(
extra="ignore",
str_strip_whitespace=True,
validate_default=True,
validate_assignment=True,
alias_generator=to_camel,
)
# 登録国サブモデル
class RegistrationCountry(CamelBaseModel):
name: str | None = Field(default=None)
@computed_field
@cached_property
def code3(self) -> str | None:
if self.name:
return country_code_lookup[self.name]
return None
# Automobile モデル(共通設定とサブモデルを利用)
class Automobile(CamelBaseModel):
id_: UUID4 = Field(alias="id", default_factory=uuid4)
manufacturer: str
# ... 他のフィールド …
# 登録国フィールドは RegistrationCountry サブモデル
registration_country: RegistrationCountry | None = Field(default=None)
# 例として、製造日や登録日などの他のフィールドも含む
実際のデータを用いた逆シリアライズの例:
data = {
"id": "c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7",
"manufacturer": "BMW",
"seriesName": "M4 Competition xDrive",
"type": "Convertible",
"isElectric": False,
"completionDate": "2023-01-01",
"msrpUSD": 93300,
"doors": 2,
"registrationCountry": {"name": "us"},
"registrationDate": "2023-06-01",
"licensePlate": "AAA-BBB"
}
auto = Automobile.model_validate(data)
print(auto)
# Automobile(id_=UUID(...), manufacturer='BMW', ...)
print(auto.registration_country)
# RegistrationCountry(name='United States of America', code3='USA')
シリアライズ時は、`registrationCountry` がネストされた辞書として出力され、内部に `"name"` と `"code3"` が含まれます。これがコンポジション(サブモデルによるネスト)と継承(共通設定の共有)の本質です。
結論
Pydantic における複雑なデータ要件では、ネスト構造や共通設定の重複が生じがちです。
コンポジション:あるモデルが別の Pydantic モデルをフィールドとして持つことで、ネストされた構造のシリアライズ・逆シリアライズを自動で行います。
継承:共通設定やフィールドを親クラスに定義することで、各モデルで同じロジックを繰り返す必要がなくなります。
つまり、階層的なデータにはコンポジションを、全体に共通する設定やフィールドの共有には継承を使うことで、コードの再利用性、保守性が大幅に向上します。これにより、同じロジックや設定の繰り返しを避け、Python のベストプラクティスに則った整理されたデータモデリングが可能となります。