Posted at

【Python】外部モジュールで始まるAnyの汚染


はじめに

皆さん、きちんと型付けしてますか?

Python 3.7 で PEP 563 -- Postponed Evaluation of Annotations が導入され、前方参照もできるようになってますます使いやすくなった Python typing ですが、まだまだ課題も残っています。

今日はそんな Python typing の課題のうち、外部モジュールによってAnyの侵略が始まってしまう話をします。


Any型のこと

動的型付け言語である Python には、Anyという型が存在しています。

Anyは「型情報がない」ことを表しており、Any型のインスタンスはあらゆる型にアップキャスト・ダウンキャストすることができます。

サラッと書きましたが、これはとんでもないことです。

Anyintになれます。すなわち、Any型のインスタンスをintしか受け取らない引数に渡すことができます。

それと同時に、Anystrにもなれるので、strしか受け取らない引数に渡しても問題ありません。

Callableにもなれるので、()オペレータを適用しても怒られません。foo()メソッドを持つ任意の型にもなれるので、突然any_instance.foo()としても誰も文句を言いません。

逆に、Anyを受け取る引数には何を渡しても構いません。intを渡そうがstrを渡そうが、自前の型Tを渡しても何の文句も言われませんし、渡した瞬間それらは全てAnyになります。

例を出しましょう。


WTFAny.py

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


本来42intであり、Anyのインスタンスではありません。しかし任意の型は暗黙的にAnyになれるので、any_instance: Any = 42はエラーなく静的型チェックを通過し、any_instanceAnyと推論されます。

また、also_any_instanceは値としてはint46なので、本来also_any_instance()などと呼ぶことはできません。しかし、any_instanceAnyと推論したことで連鎖的にalso_any_instanceAnyとなっており、この行は静的型チェックをエラーなく通過します。

その戻り値ofcourse_any_instanceも当然Anyと推論されるため、ofcourse_any_instance.foo('bar')['baz']というでたらめな行もエラーを吐いてくれません。


出現! 侵略者Any

このように、型情報の一切を蹂躙しながらソースコードを侵略するAnyですが、実は Python のソースコードを書いていると結構頻繁に現れます。

どういう時に現れるのかというと、外部モジュールをインポートするときです。


UntypedModule.py

class UntypedClass:

def get_some_integer(self):
return 42
...


WTFAny2.py

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()とした時点でbAnyになってしまい、b.foo()という行がエラーなく通ってしまいます。その戻り値であるfoo2も当然Anyなので、以降この関数スコープが終わるまで、プログラマは延々Anyの汚染に頭を抱えることになってしまいます。

なお、頭を抱えられるのはまだ良い方です。多くの場合、bの型がAnyになっていることにプログラマは気づけません。型検査機でも明らかに見つけられるはずの実行時エラーに遭遇して初めて、その変数がずっと前からAnyだったことに気付くのです。


なんとかする方法

完全な対処方法はありませんが、一番良いのは関数を細かく分けることです。

自分で型のある関数を細かく書くことで、Anyの汚染を最小限に留めることができます。


minimal_contaminated_with_Any.py

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()内で呼ぶときにはbintと推論され、Anyの汚染をmake_a_and_get_integer()関数内に抑えることができました。

numpydjangotorchといった著名なモジュールが気軽にAnyを返してくることがあるので(無名なモジュールは言うまでもない)、外部モジュールを使うときは常に気が抜けません。

私としては、Anyを見たら常にエラーを吐くくらい厳しくしてくれたっていいんですけどね。


おわりに

Any型の恐怖と発生例、その抑え方について軽くお話ししました。

Python typing は漸進的型付けなので、型の立場はまだまだ弱いように思います。

しかしながら、PEP 526 -- Syntax for Variable AnnotationsPEP 544 -- Protocols: Structural subtyping (static duck typing)PEP 560 -- Core support for typing module and generic types など、Python の型付けサポートは日々手厚くなっており、Python typing は今後どんどん便利になっていくと考えられます。

あらゆる Python module に適切な型が付いた幸せな世界になることを切望しています。

 

 

でもこれ、もう Python じゃなくてもよくない?