PythonでPhantom Type(幽霊型)を使って静的にプログラムの欠陥を発見する
PythonでのPhantom Typeの実現
はじめに
本記事では、HaskellやScalaなどの静的型付け言語で用いられるテクニックであるPhantom TypeをPythonのType Hintシステムを利用して実現できることを示します。具体的には、mypyやpyrightなどのType Checkerを通して、Phantom Typeを活用して静的検査を行う方法について説明します。
環境
- Python 3.8
- PyrightのStrict Mode
Phantom Typeの簡単な解説
Phantom Typeとは、組み込み型(例: intやstr)とは異なり、コンパイルタイム(Pythonの場合はType CheckerによるType Check時)にのみ影響を与える型を、プレースホルダ的に使用するテクニックです。型による検査を利用することで、プログラム内のある種の欠陥を実行時ではなくプログラム中に発見することができます。
しかし、この説明だけではわかりにくいかもしれません。実際のユースケースを通じて、Phantom Typeの目的とその実現方法を見ていきましょう。
例と説明
やりたいこと
HTTPリクエストで何らかのデータをPOSTしたいと考えます。POSTなので、リクエスト先のURLとBodyが必要になります。
def post(url: str, body: Dict[str, Any]):
...
例えば/users
というURLには{name: str, age: int}
、/articles
というURLには{title: str, body: str}
という形のBodyを送りたいとします。この場合、URLとそのBodyの形の整合性を取る必要が生じます。
通常、間違ったURLとBodyの組み合わせを弾くためにif文などで実行時に検査を行いますが、Phantom Typeを使用すると型によって静的検査で宣言的に弾くことができます。
型によるPOSTのモデリング
まず、POSTにはURLとBodyという2つの値が出てくるので、これらをモデリングします。
Body = TypeVar("Body", bound=TypedDict)
class Url(str, Generic[Body]):
pass
URLの実態はstr
なので、継承させるだけで他には何も実装しません。ただし、一つ違うのがGeneric[_]
を継承させることで、型パラメータを取ることが可能な型として宣言します(この場合はBody
というパラメータ名で多相化しています)。この型パラメータはプログラムの実行には何も影響を与えず、単なるプレースホルダ=何かしらのマーキングとしてのみ振る舞います。今回はURLに対応するBodyの型をマーキングするために使用されます。また、Body
はdict
に厳格に型をつけるためにTypedDict
を上限境界として持っています。
次に、Urlを実際のURLごとにインスタンス化します。
UsersBody = TypedDict("UsersBody", name=str, age=int)
ArticlesBody = TypedDict("ArticlesBody", title=str, body=str)
UsersUrl: Url[UsersBody] = Url("/users")
ArticlesUrl: Url[ArticlesBody] = Url("/articles")
これはUsersUrl
を使用する際にはUsersBody
型のdictをBodyにしなければならないということを示しています。ArticlesUrl
についても同様です。
URLとBodyについてのマーキングが済んだので、これを使用して実際にPOSTを実装してみましょう。
B = TypeVar("B", bound=TypedDict)
def post(url: Url[B], body: B):
# implementations
...
このメソッドのシグネチャはUrl[B]
のときには同時にBodyとしてB
も渡さなければならないということを宣言しています。つまり、先程用意したURLとBodyの対応付けがここで生きてくるわけです。
実際にpost
をType Checkしてみましょう。
def main():
post(UsersUrl, {"name": "john", "age": 20}) # => ok
post(ArticlesUrl, {"title": "daily", "body": "hello"}) # => ok
post(UsersUrl, {"name": "john", "age": "hello"}) # => bad
post(UsersUrl, {"title": "daily", "body": "hello"}) # => bad
post(ArticlesUrl, {"title": "daily"}) # => bad
Type Checkerで検査すると、ちゃんと対応として宣言されたdict
のみを受け付けるようになっていることが分かります。
おわり
Pythonもちゃんとやればちゃんとなる。