Python
typing

【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 じゃなくてもよくない?