NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

Pydanticで始めるイミュータブルクラス駆動開発

はじめに

こんにちは!NFLabs. 研究開発部の林です。普段はセキュリティ教育プラットフォームの開発をしています。
今回はセキュアコーディングの重要な要素である「バリデーション(入力検証)」に関連して、PythonのPydanticライブラリにフォーカスしてお話します。

Python界隈では、昨今、型ヒントやFastAPIの普及に伴い、型の重要性や有用性が徐々に認識されつつあるかと思います。 それに伴い、バリデーションライブラリのデファクトスタンダードの一つであるPydanticの注目度も上がってきたと感じています。

Pydanticは実行速度の速さを特長として挙げていますが、Pydanticがもたらす安全性・Immutable(不変)性は、開発速度向上にも一役買っています。
本稿ではPydanticがいかに開発速度・開発体験に寄与するか考察します。

ちなみに、タイトルの「イミュータブルクラス駆動開発」は自分の造語で「クラスをImmutableにすることを前提に設計・実装を考え始める」くらいの意味です。これの意義は本編でお話します。

  • 本稿のコードは以下のバージョンで動作検証しています。
    • Python 3.10.6
    • Pydantic 2.0
    • Ubuntu 22.04

本稿の対象者

  • Pythonを使って複数人で大規模なWebアプリを開発する人たち
  • コードベースが大きくなってきてコードを追うのに疲弊している人たち
  • 個人的にFastAPIやPydantic使ってみたけど、イマイチ良さが分かってない人たち

結論

Pydanticが求められる背景

プロダクトの成長に伴うコードベースの複雑化

  • 機能が増えていくと、様々な理由で属性やメソッドが増え、クラスが大きくなってきます。
  • それに伴いクラスのコンストラクタ(__init__関数)内に、属性に対する条件式が増えます。(例: age属性は自然数である必要がある)
    • コンストラクタ内の条件式のことを特に「不変式」と呼んで区別するそうです。
      • (注) 不変式の「不変」は「その属性が満たすべき条件」くらいの意味なので、後述のオブジェクトの「不変(変更不可)」とは意味が異なります。
  • 複雑化に伴う認知負荷の上昇と変更容易性の低下
    • コンストラクタが複雑になるにつれ、いつどこでどんな属性が宣言・変更されたか把握するのに認知負荷が高くなります。
    • 弊害として、コードを読み進めるのに時間がかかる、条件漏れや予期せぬバグが混入する etc...

複雑性への対抗としてのPydantic導入

可読性・保守性の向上

  • Pydanticにより、属性の宣言とそれぞれの属性に関する不変式を分離できます。
    • クラスがどんな属性を持ってるか一目瞭然で、その属性の不変式もすぐ見つけられるためコードの見通しが良くなります。
    • それぞれの不変式同士も分離しているので、他のコードへの影響が少なく機能追加・改修が出来るようになります。

Immutableの徹底による安全性の向上

  • 通常のクラスは、いつでも(インスタンス初期化後でも)、どこでも(クラスの外部からでも)属性の追加削除変更が出来てしまいます。
    • 不変式は大抵は初期化時にのみ考慮されるので、変更時は不変式を無視できるケースが多いです。
  • Pydanticのfrozen=TrueオプションによりImmutable(変更不可)であることが確約されるので、他のクラスでインスタンスに変更が加えられていないかコードベースを隅から隅まで探さなくてよくなります。

これらのPydanticの恩恵により、複数人での開発でも、複雑なコードベースでも認知負荷を下げて安全かつスムーズに開発できます。
詳細について次項から解説していきます。

Mutable / Immutableとは

  • Pydanticの解説をする前に、まず大事な概念であるMutable/Immutableについて解説します。
    • 簡単に言うと、とあるデータ型やクラスを作成・初期化した後に、そのオブジェクトの状態が変更できる(可変/Mutable)か否か(不変/Immutable)ということです。
    • 以降はid()関数を使ってオブジェクトのID(オブジェクトが格納されているメモリのアドレス)を確認します。
      • 生成したオブジェクトに加算や追加などを行った後に、IDを見ることで以下のように判断できます。
        • IDが変わらない -> 同じオブジェクト(Mutableなデータ型・クラス)
        • IDが変わっている -> 新しいオブジェクトが生成されている(Immutableなデータ型・クラス)
    • コードはコピペすればそのまま動くはずなので、ご自身の環境でもお試しいただければと思います。

Mutable

  • 例えばlistdictはMutableです。
l = [1,2,3]
print(l)     # -> [1, 2, 3]
print(id(l)) # -> 140589188020992

l.append(4)
print(l)     # -> [1, 2, 3, 4]
print(id(l)) # -> 140589188020992 変更前のオブジェクトと同じID. 
  • lの初期化後に4を追加していますが、IDに変更はないことが分かります。 つまり変更後も同じオブジェクトということです。

  • Pythonのクラスも(基本は)Mutableです。

    • なのでインスタンス生成後に属性の追加削除変更が簡単にできます。
class Person:
      def __init__(self, name):
            self.name = name

p = Person("エヌエフ")
print(id(p))             # -> 140192177880080

p.name = "ラボラトリーズ"
print(id(p))             # -> 140192177880080 クラスオブジェクトのIDは変更後も一緒

Immutable

  • 例えばstringinttupleはImmutableです。
    • 下記のaは加算後に再代入しているだけなので感覚的には一見同じオブジェクトに見えますが、IDが異なります。
    • つまり、異なる場所に格納されている別のオブジェクトであり、元のオブジェクトが変更されている訳ではないということです。
a = 1
print(a)       # -> 1
print(id(a))   # -> 140037972132080
    
a = a + 1      # `a+=1`でもOK
print(a)       # -> 2
print(id(a))   # -> 140037972132112
  • 「これの何が問題なの?」と思うかもしれませんが、Mutableは扱いが難しいです。
    • Mutableなオブジェクトは、元のオブジェクトが後から簡単に変更できてしまうので、自分の思わぬところで、他の開発者(もしくは未来の自分)による、予期せぬ変更が加えられる可能性があります。
    • 一方で、Immutableなオブジェクトは、一度生成するとそのオブジェクトを変更することは不可能なので、将来に渡って予期せぬ変更が生じないことが保証されます。
    • 具体的な問題は後述します。

Pydanticとは

  • Pydantic公式ドキュメントによると、PydanticはPythonライブラリの中で最も広く使われているバリデーションライブラリだそうです。
    • 事実、FastAPIを始めとする8000以上のPythonパッケージがPydanticを使用しており、AppleやAmazonなどアメリカの大手企業でも多く導入されています。
  • 特徴としては、シンプルで学習コストが低く、動作が高速で、カスタマイズ次第で様々なバリデーションとシリアライズができます。
  • 利用方法は以下の通り。
    インストールは他のライブラリ同様、以下の1コマンドのみ。
$ pip install -U pydantic

BaseModelをクラスに継承するだけで簡単に使えます。

from pydantic import BaseModel

class Person(BaseModel):
    name: str

person = Person(name=1) # -> Input should be a valid string [type=string_type, input_value=1, input_type=int]
  • 2023/6/30にver.2.0が正式にリリースされたので、今後Pydanticの導入を考えてる方はver2.0を使った方が良いでしょう。
    一部の機能が非推奨や後方互換なしのアップデートになっているようです。

Pydanticのメリット

可読性・保守性の向上

  • コードが読みやすいこと、保守性に優れていること(既存のコードに影響を与えずに機能追加ができること等)は、開発速度にとって重要な要素です。
  • Pythonは動的型付けならではの柔軟性により自由な記述ができるため、少人数・小規模のツール作成などでは開発速度は早いです。
    • その自由度の高さから、Pythonを大規模開発で安全に利用するためには、緻密なコーディング規約を作る必要があります。
    • そして内製のコーディング規約違反は大抵一般的なLinterやFormatterでは検知されないため、品質維持はレビュアーの腕にかかってきます。
  • 以降のコード例は読みやすさのためにシンプルなコードにしていますが、現実では不変式のネストがもっと深く、様々な条件が複雑に絡み合うこともあります。

通常 (Pydantic無し)

  • コンストラクタ内のガード節に不変式を記述し、入力値をバリデーションします。
  • 不変式の条件に反する値がある場合はValueErrorとしてraiseして処理を中断します。
    • 一般的にはif分岐ごとにraiseしますが、自分はerrorsリストにエラー内容を格納して一括でraiseしています。理由は以下の通りです。
      • 一度に全ての属性に対してバリデーションチェックを実行するため。
      • PydanticのValidationErrorに挙動を寄せて比較しやすくするため。
    • 不変式の中で副作用のある操作を行う必要がある場合はこの限りではありません。
class Person:
    def __init__(self, name: str, age: int):
         errors: list[dict[str,str]] = []

         # ガード節
         if len(name) < 3:
            errors.append({
                "field": "name",
                "message": "名前は3文字以上である必要があります。"
            })

         if age < 20:
            errors.append({
                "field": "age",
                "message": "年齢は20歳以上である必要があります。"
            })

         if errors:
            raise ValueError(errors)

         self.name = name
         self.age = age

p1 = Person(name="エヌエフ", age=20)  # -> OK
p2 = Person(name="ラボ", age=-1)     # -> ValueError

Pydantic

  • Pydanticでは、属性の宣言と、それぞれの属性の不変式を別々に記述します。
  • また、それぞれ属性のValueErrorはValidationErrorに集約されるため、errorsリストに格納しなくても全てのバリデーションチェックが実行されます。
from pydantic import BaseModel, ValidationError, field_validator

class Person(BaseModel, frozen=True):
    name: str
    age: int

    @field_validator('name')
    def check_name(cls, name):
        if len(name) < 3:
            raise ValueError("名前は3文字以上である必要があります。")
        return name

    @field_validator('age')
    def check_age(cls, age):
        if age < 20:
            raise ValueError("年齢は20歳以上である必要があります。")
        return age

p1 = Person(name="エヌエフ", age=20)  # -> OK
p2 = Person(name="ラボ", age=-1)     # -> ValidationError

dataclass (参考)

  • 属性の宣言と不変式を別々に書ける点はPydanticと同様ですが、不変式は__post_init__関数内にまとめて記述します。
  • dataclassは標準ライブラリとしてインストールなしで使えますが、dataclassはそもそもバリデーションライブラリではないので、バリデーションが目的なら高機能なPydanticを使う方が良いでしょう。
from dataclasses import dataclass

@dataclass(frozen=True)
class Person:
    name: str
    age: int

    def __post_init__(self):
         errors: list[dict[str,str]] = []

         if len(name) < 3:
            errors.append({
                "field": "name",
                "message": "名前は3文字以上である必要があります。"
            })

         if age < 20:
            errors.append({
                "field": "age",
                "message": "年齢は20歳以上である必要があります。"
            })

         if errors:
            raise ValueError(errors)

p1 = Person(name="エヌエフ", age=20)  # -> OK
p2 = Person(name="ラボ", age=-1)     # -> ValueError

Immutableの徹底による安全性の向上

  • クラスから生成(初期化)したインスタンスの状態がどこの誰にも変更されてないという安心感(認知負荷の低減)もまた開発速度に大きく貢献します。
  • しかし、Pydanticを使わない場合、インスタンスの状態変更を制御することは難しいです。

通常 (Pydantic無し)

属性の変更
  • 一般的には_nameなどアンダースコアを付けてプライベート属性として、setterデコレーターを付与したsetter関数で変更します。
  • ただし、プライベート属性であっても外部から直接属性にアクセスできるのでsetter関数以外からでも変更が出来ます。
class Person:
    def __init__(self, name: str):
         self._name = name

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, new_name: str):
        self._name = new_name

person = Person(name="エヌエフ")
print(person.name)               # -> エヌエフ
    
# setter関数で変更
person.name = "ラボラトリーズ"
print(person.name)               # -> ラボラトリーズ
    
# 属性に直接アクセスして変更
person._name = "エヌエフ" 
print(person.name)               # -> エヌエフ
  • typingモジュールのFinalを付けることで変更禁止であることを他の開発者に対し明示できますが、実行時の変更を防ぐことは出来ません。
  • 一方で、mypyなどの型チェッカーではFinal型への再代入違反を検出可能です。したがって、FinalのみでImmutableを担保するには以下のような仕組みを作ることが重要です。
    • 基本的に全ての属性にFinalを付与する。Finalを付与しない場合は相応の理由を説明する。
    • CI(継続的インテグレーション)に型チェッカーを組み込み、エラーが出たらマージしない。
from typing import Final

a: Final[str] = "エヌエフ"
print(a)  # "エヌエフ"
a = "ラボラトリーズ"
print(a)  # "ラボラトリーズ" エラーなしで実行出来る
$ mypy .
error: Cannot assign to final name "a"  # 型チェッカーでは検出できる。
属性の追加・削除
  • クラスの中からも外からも簡単にインスタンスの属性の追加削除ができます。
  • 属性に依存する機能を実装するとバグが生じる可能性があります。
    • 防御的プログラミングやダックタイピングのようにhasattr関数で属性を持っているか毎回チェックする必要が出てきます。
class Person:
    def __init__(self, name: str):
         self.name = name

    def _add_job(self, job: str):
         setattr(self, "job", job)

    def get_job(self) -> str:
         return self.job
     
def retire(person: Person):
     delattr(person, "job")

person = Person(name="エヌエフ")
job = person.get_job() # -> Attribute Error

person._add_job("security engineer")
job = person.get_job()
print(job)             # -> "security engineer"

retire(person)
job = person.get_job() # -> Attribute Error

Pydantic

属性の変更
  • frozen=Trueであれば、Immutableなデータ型への変更は出来ません。
from pydantic import BaseModel, ValidationError, field_validator

class Person(BaseModel, frozen=True):
    name: str
    
person = Person(name="エヌエフ")
person.name = "ラボラトリーズ" # -> Instance is frozen [type=frozen_instance, input_value='ラボラトリーズ', input_type=str]
  • 「属性の変更なんてよくある操作なのに、できないのは不便では?」と思うかもしれませんが、「変更したければ新しくオブジェクトを生成してください」というのがイミュータブルな開発思想です。
    • Pydanticはこれを簡単に実現できるmodel_copyメソッドも提供しているので、そこまでImmutable化に抵抗感を持たなくても大丈夫です。
from pydantic import BaseModel, ValidationError, field_validator

class Person(BaseModel, frozen=True):
    name: str
    
person = Person(name="エヌエフ")
print(person.name)         # -> 'エヌエフ'
print(id(person))          # -> 140582076825456

renamed_person = person.model_copy(deep=True, update={"name": "ラボラトリーズ"})
print(renamed_person.name) # -> 'ラボラトリーズ'
print(id(renamed_person))  # -> 140582076825408 元のオブジェクトからIDが変わってる
  • 一部のObserver(PubSub)パターンやインメモリキャッシュなどでは定石通りMutableなlistやdictを使う方が簡単で伝わりやすい可能性もあります。
    • ただし、この場合もobserversにattachメソッド以外で追加しない、などのルール作りが必要です。
    • または、デザインパターンに拘らずにデータベースや外部のメッセージキューサービスを使う等の代替方法を検討しましょう。
属性の追加
  • 同様にfrozen=Trueであれば、後から属性を追加することは出来ません。
from pydantic import BaseModel, ValidationError, field_validator

class Person(BaseModel, frozen=True):
    name: str
   
person = Person(name="エヌエフ")
setattr(person, "job", "security engineer") # -> Instance is frozen [type=frozen_instance, input_value='security engineer', input_type=str]
属性の削除
  • 残念ながらこれだけはdelattr関数からインスタンスを守ることは出来ませんでした。
    • なので、属性の削除に関してはルール化が必要かもしれません。(そもそも属性を削除するオペレーション自体あまりないかもしれませんが。)
    • もしくは__delattr__をオーバーライドして禁止することも出来ます。
from pydantic import BaseModel

class CustomBaseModel(BaseModel):
    def __delattr__(self, __name: str) -> None:
        raise AttributeError("cannot delete attributes.")

class Person(CustomBaseModel, frozen=True):
     name: str

person = Person(name="エヌエフ")
delattr(person, "name")  # -> AttributeError: cannot delete attributes.
注意点
  • frozen=Trueはインスタンス自体の変更保護であって、 クラス内のlistやdictなどのMutableなCollection型の属性は変更できます。
    • クラス設計者としてCollectionの変更を防ぎたいという明確な意図がある場合は、frozensetなどのImmutableなCollection型を使いましょう。
from pydantic import BaseModel

class Person(BaseModel, frozen=True):
    name: str
    hobbies: list[str]

person = Person(name="エヌエフ", hobbies=["guitar"])
print(person.hobbies) # -> ['guitar']
person.hobbies.append("piano")
print(person.hobbies) # -> ['guitar', 'piano']
from pydantic import BaseModel

class Person(BaseModel, frozen=True):
    name: str
    hobbies: frozenset[str]

person = Person(name="エヌエフ", hobbies=["guitar"])
print(person.hobbies) # -> ['guitar']
# frozensetにはそもそもappendメソッドやpopメソッドなどがなく、変更の方法がない。
person.hobbies.append("piano") # -> AttributeError: 'frozenset' object has no attribute 'append'
  • ベースクラスであるobjectのメソッドを使うと前述のfrozen=Trueや、BaseModelの__delattr__のオーバーライドも無視して属性の追加削除ができます。
    • この手法は場合によっては保守性が著しく低下するので用法には十分注意してください。
from pydantic import BaseModel

class CustomBaseModel(BaseModel):
    def __delattr__(self, __name: str) -> None:
        raise AttributeError("cannot delete attributes.")

class Person(CustomBaseModel, frozen=True):
     name: str

person = Person(name="エヌエフ")
print(*person)  # -> ('name', 'エヌエフ')
object.__setattr__(person, "job", "security engineer")
object.__delattr__(person, "name")
print(*person)  # -> ('job', 'security engineer')

まとめ

少ないルールでベターなコード

  • 大量のコーディング規約と睨めっこしてコードを書かなくて良くなります。
    • 「Pydanticを使う」「frozen=Trueにする」というルールのみでも、良い意味で誰が書いても同じようなコードになります。
      • むしろルール違反する方が煩雑になります。
        • 「なんで敢えて安全性の低いMutableにする必要があるの?」というレビュアーの質問に明確に答える必要が出てきます。
  • Immutableにするにはどうしたらいいか、という視点からクラス設計を考えるようになります。
    • 個人的にImmutable駆動で設計すると、自然と単一責任原則や開放閉鎖原則などのSOLID原則に準拠するようになる気がします。

認知負荷の低減

  • 機能追加や改修の際に「どこかで誰かにオブジェクト(インスタンス)が変更されていないか」という不安を抱えなくてよくなります。
    • 自信を持って機能追加・改修を行えるようになります。

本稿では触れてませんがその他にも …

  • Pydanticの(基礎部分は)学習コストが低いので、新規着任者やプログラミング初心者でもすぐにキャッチアップできます。
  • 型を徹底するようになるのでエディターの補完機能などが更に便利になります。

これらの恩恵により複数人での開発速度・開発体験はグッと良くなると思います。
本稿を読んで気になった方は、既存クラスのリファクタリングや、新しく追加するクラスからでも、ぜひPydanticの導入を検討してみてください!