“APIは呼び出し側で評価される”。
__init__
を最小に絞り、入力形ごとに @classmethod from_xxx
を用意すると、呼び出し元から対象クラスが扱いやすくなります。
コンストラクタの扱いについて体系立てて整理したく、実務で使えるパターンをまとめます。
1. コンストラクタのパターン
1.1 __init__
は「必須最小限」+「キーワード専用」に
-
__init__
では必須の値だけを受ける。オプションが増える場合は入口を分けるため代替コンストラクタを検討する。 -
*
を置いてキーワード専用にすると呼び出し元が読みやすくなる。
from typing import Self, TypedDict
class UserDict(TypedDict):
id: int
name: str
class User:
def __init__(self, *, id: int, name: str) -> None: # キーワード専用
if id <= 0:
raise ValueError("id must be positive")
if not name:
raise ValueError("name must not be empty")
self.id = id
self.name = name
1.2 代替コンストラクタで「入力値」をメソッド名として表す
- 入力型(dict / DB 行 / 環境変数など)ごとに
@classmethod
を用意する。 - メソッド名で何を渡せばよいかを明確にする。
- 代替コンストラクタは正規化だけ行い、最終検証は
__init__
に一元化する。
from typing import Self, TypedDict
import os
class UserDict(TypedDict):
id: int
name: str
class User:
def __init__(self, *, id: int, name: str) -> None:
if id <= 0:
raise ValueError("id must be positive")
if not name:
raise ValueError("name must not be empty")
self.id, self.name = id, name
@classmethod
def from_dict(cls, d: UserDict) -> Self:
return cls(id=int(d["id"]), name=str(d["name"]))
@classmethod
def from_row(cls, row: tuple[int, str]) -> Self:
uid, name = row
return cls(id=int(uid), name=str(name))
@classmethod
def from_env(cls) -> Self:
uid = int(os.environ["USER_ID"])
uname = os.environ["USER_NAME"].strip()
return cls(id=uid, name=uname)
1.3 オーバーロードを表現したい場合は @overload
を利用する
- 実装は1つで、型だけを分けたい場合は
@overload
を利用する。 - 入口の目的が異なるならメソッド名を分けるほうが読みやすい。
- 補足:
@overload
は 静的型チェッカ向け の宣言で、実行時の分岐は行わない。
from typing import overload, Self
from pathlib import Path
import json
class Config:
def __init__(self, *, host: str, port: int) -> None:
self.host = host
self.port = port
@overload
@classmethod
def from_source(cls, src: str) -> Self: ...
@overload
@classmethod
def from_source(cls, src: Path) -> Self: ...
@overload
@classmethod
def from_source(cls, src: dict[str, object]) -> Self: ...
@classmethod
def from_source(cls, src):
if isinstance(src, Path):
data = json.loads(src.read_text(encoding="utf-8"))
elif isinstance(src, str):
text = Path(src).read_text(encoding="utf-8") if Path(src).exists() else src
data = json.loads(text)
elif isinstance(src, dict):
data = src
else:
raise TypeError("Unsupported source")
return cls(host=str(data["host"]), port=int(data["port"]))
1.4 実行時検証を足す(最後の砦は __init__
/ __post_init__
)
- 原則:ドメインの不変条件は
__init__
(または dataclass の__post_init__
)で検証する。最後の砦 - 代替コンストラクタは正規化だけを行い、検証作業は必ず
__init__
に委譲する(検証の二重実装を避ける)。 - 外部入力が荒い場合は、境界で pydantic / attrs を用いて型変換・前処理を行い、最後に
__init__
で再検証する。
from typing import Self, TypedDict
import os
class UserDict(TypedDict):
id: int
name: str
class User:
def __init__(self, *, id: int, name: str) -> None:
# 不変条件の最終検証
if id <= 0:
raise ValueError("id must be positive")
if not name:
raise ValueError("name must not be empty")
self.id, self.name = id, name
@classmethod
def from_dict(cls, d: UserDict) -> Self:
# 正規化のみ → 最終検証は __init__
return cls(id=int(d["id"]), name=str(d["name"]))
@classmethod
def from_env(cls) -> Self:
uid = int(os.environ["USER_ID"])
uname = os.environ["USER_NAME"].strip()
return cls(id=uid, name=uname)
注意:同じ検証を from_xxx
と __init__
に重複させない(正規化は前者、不変条件は後者)。
2. 呼び出し側の Before / After(呼び出し側ファースト)
Before:なんでも __init__
で受ける
# どれが id/name で、どの入力形か? 呼び出し側から見えづらい
user = User(1, "Alice") # 位置引数でも通る“ゆるい” __init__(*args/**kwargs 等)を書いた場合に限り成立
user = User(d=payload) # d とは? dict? どんな形?
user = User(row=db_row) # row の順序や型は?
After:入口を分ける
user = User(id=1, name="Alice") # 最小・明確
user = User.from_dict(payload) # 入力値が名前で明確
user = User.from_row(db_row) # 同上
user = User.from_env() # 引数なしで明確
3. 選び方
3.1 比較表
ライブラリ / 機能 | 主な用途 | 検証機構 | 利点 | 注意点・制約 |
---|---|---|---|---|
dataclasses | 軽量な値オブジェクト | __post_init__ |
標準ライブラリで導入容易 自動生成が便利 |
検証は手動実装 柔軟性はやや低い |
attrs | 柔軟な構造体 |
validator , converter
|
検証・変換が簡潔 便利なユーティリティが豊富 |
外部依存あり 標準ライブラリではない |
pydantic v2 | 外部入力の型変換・厳格検証 |
BaseModel + 型変換 |
型安全 例外メッセージが整っている 境界設計に強い |
実行速度や依存に注意 内部構造が複雑 |
@overload |
型レベルの多重定義(静的解析用) | 静的型チェッカ(mypyなど) | 実装は1つで済む IDE補完や型安全性が向上 |
実行時には無効 分岐処理は別途必要 |
3.2 用語の超短い説明
- dataclasses(標準):
__init__
/__repr__
/ 比較 等を自動生成する。検証は基本自前か__post_init__
で実現。 - attrs:サードパーティ。
validator
/converter
での検証の実現が容易、asdict
/evolve
など便利な機構も豊富。 - pydantic v2:外部入力の型変換・厳格検証に強い。例外メッセージを整えることができる。
-
@overload
:型レベルだけ多重定義して、検証には静的解析を効かせる(実装は1つ/実行時検証はしない)。
3.3 採択にあたるフロー
- 受け取るのが外部からの生データ(JSON/フォーム/環境変数)の場合
→ pydantic v2 で境界を固める → 最後はドメイン__init__
で最終検証する。 - 軽量な DTO / Value Object を変更不可で持ちたい場合
→ dataclasses(frozen=True, slots=True, kw_only=True
)+必要に応じて__post_init__
で最小の検証を実現。 - 振る舞いが多いドメインオブジェクトが欲しい場合
→ 手書きクラス:__init__(*, 必須だけ)
を薄く用意し、入口はfrom_dict
/from_row
/from_env
など代替コンストラクタを用意する。 - 入口は同じだがパラメータの型だけ違う場合(文字列 / Path / dict など)
→@overload
で型だけ多重定義する(実装は1つ)。 - 迷ったら以下順で採用する。
→ まず dataclasses、境界に pydantic、ドメインは 最小__init__
+ 代替コンストラクタ。
どの選択でも、不変条件の最終検証は常に __init__
(または __post_init__
)に一元化する。
代替コンストラクタは正規化だけ行い、必ず検証は __init__
に委譲する。
4. まとめ
-
__init__
は最小限の利用のみ、もしくは、キーワード専用として利用する(def __init__(self, *, ...)
) - 入口は目的ごとに
@classmethod from_xxx
で分けた方が呼び出し元も含めて読みやすさ・明確性が上がる - 入口のみ分けたい場合は
@overload
で型だけ多重定義する(実装は1つ) - 不変条件は
__init__
/__post_init__
/ pydantic で検証する
呼び出し側ファーストで名前とシグネチャを設計すると、実装もレビューも分かりやすくなる。