1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonのコンストラクタ設計実践

Last updated at Posted at 2025-08-26

“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 で検証する

呼び出し側ファーストで名前とシグネチャを設計すると、実装もレビューも分かりやすくなる。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?