PythonでPhantom Type(幽霊型)を使って静的にプログラムの欠陥を発見する

はじめに

HaskellやScalaなどの静的型付け言語で用いられるテクニックとしてphantom typeというものがある。
Pythonにtype hintシステムは備わっており、mypyやpyrightなどのtype checkerを通して同様にphantom typeが実現できることを示す。

環境

  • python3.8
  • pyrightのstrict mode

phantom typeの簡単な解説

phantom typeとはintやstrのような組み込み型とは違い、コンパイルタイム(Pythonならtoolによるtype check時)にのみ影響を与えるような型をプレースホルダ的に使用することで実現する。
型による検査を使用することでプログラム内のある種の欠陥を実行時にではなくプログラミング中に発見するテクニックである。

この説明だけではわかりにくいと思うので実際のユースケースを通じて何を目的に、それをどう実現するかを見ていく。

例と説明

やりたいこと

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の型をマーキングするために使用される。
またBodydictに厳格に型をつけるために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):
  # implemantations
  ...

このメソッドのシグネチャはUrl[B]のときには同時にbodyとしてBも渡さなければないということを宣言している。
つまり先程用意したurlとbodyの対応付けがここで生きてくる。

実際にpostをtype checkしてみる。

def main():
  post(UsersUrl, {"name": "john", "age": 20}) # => ok
  post(ArticlesUrl, {"title": "dialy", "body": "hello"}) # => ok
  post(UsersUrl, {"name": "john", "age": "hello"}) # => bad
  post(UsersUrl, {"title": "dialy", "body": "hello"}) # => bad
  post(ArticlesUrl, {"title": "dialy"}) # => bad

type checkerで検査するとちゃんと対応として宣言されたdictのみを受け付けるようになっている。

最後に全コード例。

from typing import TypeVar, Generic, TypedDict

Body = TypeVar("Body", bound=TypedDict)

class Url(str, Generic[Body]):
  pass


UsersBody = TypedDict("UsersBody", name=str, age=int)
ArticlesBody = TypedDict("ArticlesBody", title=str, body=str)

UsersUrl: Url[UsersBody] = Url("/users")
ArticlesUrl: Url[ArticlesBody] = Url("/articles")

B = TypeVar("B", bound=TypedDict)

def post(url: Url[B], body: B):
  # implemantations
  ...


def main():
  post(UsersUrl, {"name": "john", "age": 20}) # => ok
  post(ArticlesUrl, {"title": "dialy", "body": "hello"}) # => ok
  post(UsersUrl, {"name": "john", "age": "hello"}) # => bad
  post(UsersUrl, {"title": "dialy", "body": "hello"}) # => bad
  post(ArticlesUrl, {"title": "dialy"}) # => bad

おわり

Pythonもちゃんとやればちゃんとなる。

PythonのEllipsis(…)とtype hints PySpark on AWS Glue PySpark DataFrameメモ
View Comments
There are currently no comments.