はじめに
皆さん、きちんと型付けしてますか?
Python 3.7 で PEP 563 -- Postponed Evaluation of Annotations が導入され、前方参照もできるようになってますます使いやすくなった Python typing ですが、まだまだ課題も残っています。
今日はそんな Python typing の課題のうち、外部モジュールによってAny
の侵略が始まってしまう話をします。
Any
型のこと
動的型付け言語である Python には、Any
という型が存在しています。
Any
は「型情報がない」ことを表しており、Any
型のインスタンスはあらゆる型にアップキャスト・ダウンキャストすることができます。
サラッと書きましたが、これはとんでもないことです。
Any
はint
になれます。すなわち、Any
型のインスタンスをint
しか受け取らない引数に渡すことができます。
それと同時に、Any
はstr
にもなれるので、str
しか受け取らない引数に渡しても問題ありません。
Callable
にもなれるので、()
オペレータを適用しても怒られません。foo()
メソッドを持つ任意の型にもなれるので、突然any_instance.foo()
としても誰も文句を言いません。
逆に、Any
を受け取る引数には何を渡しても構いません。int
を渡そうがstr
を渡そうが、自前の型T
を渡しても何の文句も言われませんし、渡した瞬間それらは全てAny
になります。
例を出しましょう。
from typing import Any
any_instance: Any = 42 # No error
also_any_instance = any_instance + 4 # No error
ofcourse_any_instance = also_any_instance() # No error (this line will raise error at runtime)
lol = ofcourse_any_instance.foo('bar')['baz'] # No error
本来42
はint
であり、Any
のインスタンスではありません。しかし任意の型は暗黙的にAny
になれるので、any_instance: Any = 42
はエラーなく静的型チェックを通過し、any_instance
はAny
と推論されます。
また、also_any_instance
は値としてはint
の46
なので、本来also_any_instance()
などと呼ぶことはできません。しかし、any_instance
をAny
と推論したことで連鎖的にalso_any_instance
もAny
となっており、この行は静的型チェックをエラーなく通過します。
その戻り値ofcourse_any_instance
も当然Any
と推論されるため、ofcourse_any_instance.foo('bar')['baz']
というでたらめな行もエラーを吐いてくれません。
出現! 侵略者Any
このように、型情報の一切を蹂躙しながらソースコードを侵略するAny
ですが、実は Python のソースコードを書いていると結構頻繁に現れます。
どういう時に現れるのかというと、外部モジュールをインポートするときです。
class UntypedClass:
def get_some_integer(self):
return 42
...
from UntypedModule import UntypedClass
a = UntypedClass() # type is UntypedClass
# foo1 = a.foo() # Error! "UntypedClass" has no attribute "foo"
b = a.get_some_integer() # Here b is Any, not int, because there is no type hint in get_some_integer()
foo2 = b.foo() # No error, return type is Any
静的型検査機はUntypedClass
がどのような型であるかまではチェックできるので、UntypedClass
であるa
に対するa.foo()
という呼び出しは正しくエラーになります。しかしながら、そのインスタンスメソッドであるget_some_integer()
が何を返すかまでは知ったこっちゃないので、b = a.get_some_integer()
とした時点でb
はAny
になってしまい、b.foo()
という行がエラーなく通ってしまいます。その戻り値であるfoo2
も当然Any
なので、以降この関数スコープが終わるまで、プログラマは延々Any
の汚染に頭を抱えることになってしまいます。
なお、頭を抱えられるのはまだ良い方です。多くの場合、b
の型がAny
になっていることにプログラマは気づけません。型検査機でも明らかに見つけられるはずの実行時エラーに遭遇して初めて、その変数がずっと前からAny
だったことに気付くのです。
なんとかする方法
完全な対処方法はありませんが、一番良いのは関数を細かく分けることです。
自分で型のある関数を細かく書くことで、Any
の汚染を最小限に留めることができます。
from UntypedModule import UntypedClass
def make_a_and_get_integer() -> int:
a = UntypedClass()
return a.get_some_integer() # Here the return type will be int without any errors because Any can be int
def say_foo() -> None:
b = make_a_and_get_integer() # b is int
b.foo() # Error! "int" has no attribute "foo"
if __name__ == '__main__':
say_foo()
UntypedClass.get_some_integer()
は型ヒントが付いていないため、a.get_some_integer()
は一度Any
と推論されてしまいます。
しかし、Any
は当然int
にもなれるので、関数make_a_and_get_integer()
は戻り値の型であるint
にエラーなくキャストされ、int
として返ります。これにより、say_foo()
内で呼ぶときにはb
はint
と推論され、Any
の汚染をmake_a_and_get_integer()
関数内に抑えることができました。
numpy
やdjango
、torch
といった著名なモジュールが気軽にAny
を返してくることがあるので(無名なモジュールは言うまでもない)、外部モジュールを使うときは常に気が抜けません。
私としては、Any
を見たら常にエラーを吐くくらい厳しくしてくれたっていいんですけどね。
おわりに
Any
型の恐怖と発生例、その抑え方について軽くお話ししました。
Python typing は漸進的型付けなので、型の立場はまだまだ弱いように思います。
しかしながら、PEP 526 -- Syntax for Variable Annotations や PEP 544 -- Protocols: Structural subtyping (static duck typing)、PEP 560 -- Core support for typing module and generic types など、Python の型付けサポートは日々手厚くなっており、Python typing は今後どんどん便利になっていくと考えられます。
あらゆる Python module に適切な型が付いた幸せな世界になることを切望しています。
でもこれ、もう Python じゃなくてもよくない?