はじめに
こんにちは。Insight Edgeでデータサイエンティストをしている善之です。
「Pythonで堅牢なコードを書きたいけど、どう設計すればいいんだろう…」
「バリデーション漏れや予期せぬバグに悩まされている…」
「Javaの設計原則は聞いたことあるけど、Pythonでも同じことができるの?」
こんな疑問や課題を持ったことはありませんか?
先日、名著「良いコード/悪いコードで学ぶ設計入門」を読んで、その設計原則に感銘を受けました。
しかし、この書籍はサンプルコードがJavaで書かれており、Pythonでどう実装すればいいか悩みました。
そこで実際のプロジェクトで、これらの原則をPydanticを使ってPythonで実装する工夫を行ったところ、非常に効果的だったため、そのノウハウをご紹介します。
本記事は、Pythonでより堅牢なコード設計を学びたいエンジニアや、Javaの設計原則をPythonに応用したい方に向けた内容となっています。
なぜこの設計原則が重要か:実践で得たメリット
実際にこの設計原則をプロジェクトで適用した結果、以下のような具体的なメリットが得られました。
コードの安全性・堅牢性の向上
- バリデーション漏れの防止: 不正な値の混入による想定外の動作を防げた
- 予期せぬバグの防止: 一度正しく設定した値が想定外に書き換わることがなく、安全にコードを書けた
開発生産性の向上
- 可読性の向上: どこに何の処理があるか分かりやすく、ロジックの重複も防ぎやすい
- チーム開発の効率化: クラス設計を事前にドキュメント化して共有することで、実装前に設計レビューができ、手戻りが少ない
以降では、これらのメリットを実現するための具体的な実装方法を解説します。
目次
- 実装した主要な設計原則
- 1. カプセル化:Pydanticを活用した堅牢なデータモデル
- 2. 不変(イミュータブル)の活用:frozenで安全性を高める
- 3. Nullを返さない、渡さない、代入しない
- 4. 条件分岐におけるInterfaceの活用:ポリモーフィズムと委譲
- 5. 実装して得られた実践的なメリット
- まとめ
実装した主要な設計原則
書籍で紹介されていた以下の設計原則を、Pythonで実装しました。
- カプセル化(バリデーション、ロジックのカプセル化)
- 不変(イミュータブル)の活用
- Nullを返さない、渡さない、代入しない
- 条件分岐におけるInterfaceの活用(ポリモーフィズムと委譲)
それぞれについて、Pythonでどのように実装したかを具体例とともに解説していきます。 具体例では、書籍と同様にRPGの実装を想定したサンプルコードを用います。
1. カプセル化:Pydanticを活用した堅牢なデータモデル
書籍での原則
書籍では、以下のようなカプセル化の重要性が説かれていました。
- コンストラクタで確実に正常値を設定する(バリデーションを必ず入れる)
- インスタンス変数の操作はクラス内のメソッドで行う(ロジックのカプセル化)
- データとそれを操作するロジックを同じクラスに配置
BaseModelとRootModelの使い分け
Pythonでこの原則を適用するには、Pydanticというライブラリが便利です。
Pydanticでは、BaseModelとRootModelを使い分けることで、カプセル化を実現できます。
- BaseModel: 複数の属性を持つクラス
- 例:
Member(パーティメンバー)、Equipment(装備)
- 例:
- RootModel: 単一の値を持つクラス(値クラス)
- 例:
MemberId、MemberName、HitPoint - メリット:
model_dump()で"member_id": "M001"のように自然な形式で出力される - RootModelを使わない場合:
"member_id": {"value": "M001"}という冗長な形式になってしまう
- 例:
バリデーションの実装
Pydanticでは、field_validatorとmodel_validatorを使ってバリデーションを実装します。
field_validator:単一フィールドのバリデーション
単一フィールドの値をチェックする際に使います。
class MemberName(RootModel[str]): """メンバー名""" root: str model_config = ConfigDict(frozen=True) @field_validator("root") @classmethod def validate_name_length(cls, v: str) -> str: """名前の長さをチェック""" if not v.strip(): raise ValueError("メンバー名は必須です") if len(v) > 20: raise ValueError("メンバー名は20文字以内にしてください") return v class Job(RootModel[str]): """職業""" root: str model_config = ConfigDict(frozen=True) @field_validator("root") @classmethod def validate_job(cls, v: str) -> str: """職業の種類をチェック""" valid_jobs = {"Warrior", "Mage", "Rogue"} if v not in valid_jobs: raise ValueError(f"職業は{valid_jobs}のいずれかを指定してください") return v
model_validator:クラスメンバ間の整合性チェック
複数のフィールド間の関係性をチェックする際に使います。
例えば、「現在HPは最大HPを超えてはいけない」といった制約を実装できます。
class HitPoint(RootModel[int]): """HP""" root: int model_config = ConfigDict(frozen=True) class Member(BaseModel): """パーティメンバー""" name: MemberName job: Job current_hp: HitPoint max_hp: HitPoint model_config = ConfigDict(frozen=True) @model_validator(mode="after") def validate_hp_constraint(self) -> Self: """現在HPが最大HPを超えないことをチェック""" if self.current_hp.root > self.max_hp.root: raise ValueError("現在HPは最大HPを超えることはできません") return self
field_validatorとmodel_validatorを使うメリット: initで実装すれば良いのでは?と思われるかもしれませんが、 field_validatorとmodel_validatorを使うことで以下のメリットがあります。
- バリデーションロジックの分離と再利用性が高まる
- Pydanticの
ValidationErrorで統一的なエラー処理ができる - JSON schema生成や自動ドキュメント化といったエコシステムとの統合
__init__に全て書くと可読性が低下するが、デコレータで宣言的に記述できる
ロジックのカプセル化
データを持つクラスに、そのデータを操作するメソッドも配置することで、ロジックのカプセル化を実現します。
class Job(RootModel[str]): """職業(Warrior, Mage, Rogueのいずれか)""" root: str model_config = ConfigDict(frozen=True) @field_validator("root") @classmethod def validate_value(cls, v: str) -> str: valid_jobs = {"Warrior", "Mage", "Rogue"} if v not in valid_jobs: raise ValueError(f"職業は{valid_jobs}のいずれかを指定してください") return v def can_use_magic(self) -> bool: """魔法を使える職業かどうかを判定する""" return self.root == "Mage" def can_equip_heavy_armor(self) -> bool: """重装備を装着できる職業かどうかを判定する""" return self.root == "Warrior" class HitPoint(RootModel[int]): """HP""" root: int model_config = ConfigDict(frozen=True) def is_critical(self, max_hp: "HitPoint") -> bool: """HPが危機的状態(最大HPの30%以下)かどうかを判定する""" return self.root <= max_hp.root * 0.3
このように、Jobクラスに職業に関する判定メソッドを持たせることで、ロジックが散らばらず、可読性が高まります。
値クラスの徹底
全ての値をクラス化することで、型安全性とバリデーションの一元化を実現します。
- 例:
strではなくMemberId、MemberName、ItemNameなど - 例:
intではなくHitPoint、MagicPoint、AttackPowerなど - 例:
boolではなくIsAlive、CanFly、IsEquippedなど
プリミティブ型をそのまま使うと、どの値に対してもバリデーションが必要になり、漏れが発生しやすくなります。
値クラスを使うことで、その値を使う全ての箇所でバリデーションが保証されます。
2. 不変(イミュータブル)の活用:frozenで安全性を高める
書籍での原則
書籍では、イミュータブルな設計の重要性が説かれていました。
- 再代入はしない
- オブジェクトの状態を変更せず、新しいオブジェクトを返す
- 予期せぬ副作用を防ぐ
frozenによる基本的なイミュータブル化
Pydanticでは、model_config = ConfigDict(frozen=True)を設定することで、インスタンス生成後の属性変更を禁止できます。
class MemberName(RootModel[str]): """メンバー名""" root: str model_config = ConfigDict(frozen=True) # これでイミュータブルになる
Pythonにはfinal修飾子がないことへの対処
Javaのfinalに相当する機能がPythonには存在しません。
typing.Finalは型ヒントのみで、実行時には強制されないため、frozen=Trueを使ってイミュータブル性を確保します。
コレクション型のイミュータブル化
listではなくtupleを使用
listはミュータブルなので、tupleを使うことでイミュータブル性を確保します。
from types import MappingProxyType class MemberName(RootModel[str]): """メンバー名""" root: str model_config = ConfigDict(frozen=True) class Party(RootModel[tuple[MemberName, ...]]): """パーティメンバーの集合""" root: tuple[MemberName, ...] = () model_config = ConfigDict(frozen=True) def add_member(self, new_member: MemberName) -> "Party": """新しいメンバーを追加した新インスタンスを返す(Immutable)""" if len(self.root) >= 4: raise ValueError("パーティは最大4人までです") return self.model_copy(update={"root": self.root + (new_member,)}) def __contains__(self, member: MemberName) -> bool: """指定されたメンバーがパーティに含まれているかチェック""" return member in self.root
このように、add_memberメソッドでは元のオブジェクトを変更せず、新しいPartyインスタンスを返します。
dictではなくMappingProxyTypeを使用
dictはミュータブルなので、読み取り専用のMappingProxyTypeを使用することで、外部からの変更を防ぐことができます。
model_copyによるイミュータブルなオブジェクトの更新
frozen=Trueのオブジェクトは直接変更できないため、値を変更するメソッドをクラス内に実装します。
メソッド内でmodel_copy(update={...})を使い、新しいインスタンスを返すことで、元のオブジェクトは変更されず、安全に状態を更新できます。
class Member(BaseModel): """パーティメンバー""" name: MemberName job: Job level: Level current_hp: HitPoint max_hp: HitPoint model_config = ConfigDict(frozen=True) def recover_hp(self, recovery_amount: int) -> "Member": """HPを回復した新しいインスタンスを返す(Immutable)""" new_hp = min(self.current_hp.root + recovery_amount, self.max_hp.root) return self.model_copy(update={"current_hp": HitPoint(new_hp)}) # 使用例 injured_warrior = Member( name=MemberName("アーサー"), job=Job("Warrior"), level=Level(25), current_hp=HitPoint(30), max_hp=HitPoint(100) ) # HPを回復(クラスのメソッドを通じて操作) healed_warrior = injured_warrior.recover_hp(70) print(f"元のメンバー: HP {injured_warrior.current_hp.root}") # 30 print(f"回復後: HP {healed_warrior.current_hp.root}") # 100
このように、クラス内のメソッドを通じて操作を行うことで、ロジックのカプセル化とイミュータブル性の両方を実現できます。
3. Nullを返さない、渡さない、代入しない
書籍での原則
書籍では、nullを避ける重要性が説かれていました。
nullは予期せぬエラーの原因となるnullチェックの漏れを防ぐ- 代わりに「空の状態を表すオブジェクト」を使う
EMPTYパターン(Null Objectパターン)
Javaではstatic finalで空のインスタンスを定義しますが、
今回Pythonで実装するにあたり@classmethodでempty()ファクトリメソッドを提供することにしました。
class ItemName(RootModel[str]): """アイテム名""" root: str model_config = ConfigDict(frozen=True) @classmethod def empty(cls) -> "ItemName": """空のItemNameを返す""" return cls("") def __bool__(self) -> bool: """ItemNameが空かどうかを判定する""" return bool(self.root.strip()) class MagicPoint(RootModel[int]): """MP""" root: int model_config = ConfigDict(frozen=True) @classmethod def empty(cls) -> "MagicPoint": """空のMagicPointを返す(MP=0)""" return cls(0) def __bool__(self) -> bool: """MPが存在するかどうかを判定する""" return self.root > 0
型ごとの空の表現:
- 数値の場合:
Noneの代わりに値が0のインスタンスを返す - 文字列の場合:
Noneの代わりに空文字""のインスタンスを返す - コレクションの場合:
Noneの代わりに空のtuple()のインスタンスを返す
boolメソッドによる存在判定
null判定の代わりに、__bool__(self)メソッドで中身の値が存在するかを判定します。
これにより、Pythonらしいif xxx:という記述で中身があるかの判定が可能になります。
# 使用例 item_name = ItemName.empty() if item_name: # アイテムが存在する場合の処理 print(f"アイテム: {item_name.root}") else: # アイテムが存在しない場合の処理 print("アイテムなし")
4. 条件分岐におけるInterfaceの活用:ポリモーフィズムと委譲
書籍での原則
書籍では、条件分岐をInterfaceで置き換える設計が推奨されていました。
- 条件分岐(if/switch文)を使わず、Interfaceとポリモーフィズムで表現
- 「委譲」を使ってロジックを分離
- 新しい条件追加時にコード修正が最小限になる(Open-Closed Principle)
ABCを使った抽象基底クラス
Pythonにはinterfaceキーワードがないため、abc.ABCを使用して抽象基底クラスを定義します。
from abc import ABC, abstractmethod class EquipmentCondition(ABC): """装備条件の抽象クラス""" @abstractmethod def can_equip(self, member: "Member") -> bool: """メンバーが装備可能かを判定する""" pass @abstractmethod def get_description(self) -> str: """条件の説明を取得する""" pass
具体的な条件クラスの実装
抽象クラスを継承して、具体的な条件を実装します。
class Level(RootModel[int]): """レベル""" root: int model_config = ConfigDict(frozen=True) class JobCondition(BaseModel, EquipmentCondition): """職業に基づく装備条件""" required_job: Job model_config = ConfigDict(frozen=True) def can_equip(self, member: "Member") -> bool: """指定された職業のメンバーのみ装備可能""" return member.job.root == self.required_job.root def get_description(self) -> str: return f"{self.required_job.root}専用" class LevelCondition(BaseModel, EquipmentCondition): """レベルに基づく装備条件""" required_level: Level model_config = ConfigDict(frozen=True) def can_equip(self, member: "Member") -> bool: """指定されたレベル以上のメンバーのみ装備可能""" return member.level.root >= self.required_level.root def get_description(self) -> str: return f"レベル{self.required_level.root}以上"
このように、JobConditionとLevelConditionという異なる条件をそれぞれ別のクラスで表現します。
条件の使用例:委譲パターン
装備アイテムクラスが装備条件を保持し、実行時に適切な条件クラスのメソッドが呼ばれます(ポリモーフィズム)。
条件分岐を書かずに、委譲で処理を実現できます。
class Equipment(BaseModel): """装備アイテム""" name: ItemName conditions: tuple[EquipmentCondition, ...] model_config = ConfigDict(frozen=True) def can_be_equipped_by(self, member: "Member") -> bool: """指定されたメンバーが装備可能かを判定""" # 全ての条件を満たす必要がある return all(condition.can_equip(member) for condition in self.conditions) def get_requirement_text(self) -> str: """装備条件の説明文を取得""" if not self.conditions: return "誰でも装備可能" return "、".join(cond.get_description() for cond in self.conditions) # 使用例 excalibur = Equipment( name=ItemName("エクスカリバー"), conditions=( JobCondition(required_job=Job("Warrior")), LevelCondition(required_level=Level(20)) ) ) warrior = Member( name=MemberName("アーサー"), job=Job("Warrior"), level=Level(25), current_hp=HitPoint(100), max_hp=HitPoint(100) ) # 条件分岐を書かずに、委譲で処理 if excalibur.can_be_equipped_by(warrior): print(f"{warrior.name.root}は{excalibur.name.root}を装備できます")
この実装の優れている点は、新しい条件(例えばStrengthCondition)を追加する際に、既存のコードを変更する必要がないことです。
新しい条件クラスを作るだけで、Equipmentクラスはそのまま動作します。
5. 実装して得られた実践的なメリット
実際にこの設計原則をプロジェクトで適用した結果、以下のようなメリットが得られました。
コードの安全性・堅牢性の向上
バリデーションによる予期せぬ挙動の防止
バリデーションをしっかり行うことで、不正な値の混入による想定外の動作を防げました。
特に、複数人で開発する際に、他のメンバーが誤った値を渡してもバリデーションで防げたため、開発初期にエラーが発生し、デバッグが容易でした。
実際のプロジェクトではあるアルゴリズムを実装したのですが、アルゴリズムの予期せぬ挙動を防ぐことができました。
イミュータブルによる安全性の向上
イミュータブルなので、バリデーションで正しく入った値を後から勝手に書き換えられない安心感がありました。
複数箇所でオブジェクトを参照しても、意図しない変更による副作用がなく、安全にコードを書けました。
値クラスによるバリデーション漏れの防止
全ての値をクラス化(値クラス)したことで、バリデーションに漏れがありませんでした。
プリミティブ型をそのまま使うと、バリデーション忘れが発生しやすいですが、値クラスを使うことで型エラーで早期に問題を発見できました。
一方で、これによりクラス数が膨大になってしまうと思われるかもしれません。
実際に、途中参加したメンバーからは「クラス数が多くて最初は面食らった」という声もありました。
しかし、同じメンバーも「1つ1つのクラスがシンプルな作りになっているため慣れるのは早かった」と述べており、学習コストはそれほど高くありませんでした。
また、1つ1つのクラスにバリデーションが集約されることで可読性が向上し、コードを読む際の認知負荷が軽減されるメリットは、クラス数が増えるデメリットを上回ると考えています。
開発生産性の向上
カプセル化による可読性の向上
クラス内にそのクラスの値に関連する操作の関数が入るので、可読性が高くなりました。
どこに何の処理があるか分かりやすく、ロジックの重複も防ぎやすくなりました。
インターフェース設計による要件の明確化とチーム開発の効率化
まずはインターフェースのみ設計したのち、MkDocsで自動ドキュメント化してメンバーに共有することで、データ型やバリデーション条件など、必要な要件が明確に伝わりました。
実装前に設計レビューができたため、手戻りが少なく、チーム全体の開発効率が向上しました。
まとめ
今回は、Javaベースの設計原則をPythonでいかに実現するかについて、実装例とともにご紹介しました。
Pydanticを活用することで、frozen=True、RootModel、field_validator、model_validatorといった強力な機能を使い、Javaベースの設計原則をPythonでも十分に実現できました。
実際のプロジェクトで適用した結果、バリデーション漏れの防止、予期せぬ副作用の回避、可読性の向上など、多くのメリットが得られました。
Pythonで堅牢なコードを書きたい方や、設計原則に興味がある方の参考になれば幸いです。
関連記事
設計原則については弊社のテックブログでも過去に取り上げていますので、以下の記事も是非ご参照ください。
- 生成AIアプリのクリーンアーキテクチャを考える - カプセル化や抽象化をアプリケーション全体のアーキテクチャに適用する方法を解説しています
- FastAPI,Pydantic,AWS DynamoDBを組み合わせた型安全なAPI構築方法について - Pydanticを実際のAPI開発で活用する実践例を紹介しています
- AdhocなPythonコードをProduction-readyにするために心掛けていること - 型情報の付与やバリデーションを含む、実運用を見据えたPythonコード品質向上の手法を解説しています
参考文献:
- 「良いコード/悪いコードで学ぶ設計入門」(仙塲大也 著、技術評論社)
- Pydantic公式ドキュメント: https://docs.pydantic.dev/
- Python公式ドキュメント(abc module): https://docs.python.org/3/library/abc.html