LoginSignup
13
10

More than 5 years have passed since last update.

スタブファイルで、Python2にも型アノテーションを!

Last updated at Posted at 2016-11-27

def main(args: Any) -> None:

Python の魅力は何と言っても型を明示せずにかけることですよね。そのおかげで数分で書いた小さなスクリプトでも大抵のことができます。しかし動的すぎるがゆえに、規模が大きくなるにつれ、これが裏目に出ることもあります。人間の脳が一度に保持できる情報量はたかが知れていますから、やっぱりどこかで型の不一致からくるバグが起こりえます。これに嫌気が差したプロフェッショナルなプログラマーたちは考えました。じゃあ、 Python も type-aware にすればいんじゃね、と。

その甲斐あって Python3.5からは関数の引数や戻り値に型を明示できるようになりました。あくまでも選択肢の一つとして、やりたければやっていいという感じに使用者の裁量に任されていますが。

def fizzbuzz(num: int) -> None:
    [print("fizzbuzz") if not x % 15
    else print("fizz") if not x % 5
    else print("buzz") if not x % 3
    else print(x) for x in range(num)]

今年(2016年)の末頃までには Python3.6 が出ます。そこではこれを更に発展させて、変数にも型を明示できるようになりますよね。

from typing import List, Dict
primes: List[int] = []

captain: str  # Note: no initial value!

class Starship:
   stats: Dict[str, int] = {}

What’s New In Python 3.6より

しかし当然ながら、コードの本体中に型を書いてしまうと、このコードは2系では動きません。3系でも2系でも動かせるよう、素晴らしい代替案が用意されています。コメントに書く docstring とは違います。型の情報だけを外部に切り出すのです。

スタブが生まれた

すなわち、型の情報だけを書いたファイルを作り、それを元にして先のコードの型チェックをするということです。PEP 484で提唱されたこれをスタブファイルといいますが、その中身はチェックしたい Python コードの骨組みです。これを、型チェックの機能を持つツールに渡すと、不具合の源を指摘してくれるというわけです。mypy (mypy での作り方: Creating Stubs For Python Modules) や PyCharm で使えます。ファイルの書き方自体はどちらでも同じです。

対象は

「自作モジュールの構成は把握している、そうじゃなくてライブラリを使う時にコード補完を賢くさせたいんだ」という方は typeshed: Collection of library stubs for Python, with static types へどうぞ。有志によって、標準モジュールや外部のライブラリの為のスタブファイルが作られています。
ライブラリではないスクリプトに適用する意義は、 Python のバージョンに依らず実行できるようにすることです。また、 IDE の補完の精度が上がるため、開発がしやすくなるのがメリットだと思います。

どんな感じなの

それでは、実際に作ってみましょう。スタブファイルの拡張子は .pyi です。それを検査したいコードと同じディレクトリーに置きます。型情報をスタブに移してしまえば、コード本体についていた方(あれば)は消しても大丈夫です。ちなみに PyCharm では新規作成の一覧に .pyi はありません。ありませんが、手動で作成すると自動で認識して、以降こちらを参照するようになります。型推測の優先順位は、 docstring < コード中の直書き < スタブ のようです。


Before

こんなコードがあったとします。説明用にできるだけ要素を詰め込んで作ったので不自然なコードかもしれませんが、一応動くものです。

まずは型情報が一切ないどこにでもある普通のコード。

import json
import logging
import requests
import sys


class Resources:
    POST_URL = "https://httpbin.org/post"

    def __init__(self, auth, logger):
        self.auth = auth
        self.logger = logger
        self.session = self.get_session()
        self.res = self.get_resources()

    def get_session(self):
        return requests.session()

    def get_resources(self):
        return json.loads(self.session.post(
            self.POST_URL, params=self.auth).text)

    def get_infos(self, queue):
        if isinstance(queue, str):
            return str(self.res.get(queue, ""))
        else:
            return {key: self.res.get(key, "") for key in queue}

class FooLogger(logging.Logger):
    def __init__(self):
        super(FooLogger, self).__init__("foobar", logging.INFO)
        self.logger = logging.getLogger()

        log_stdout = logging.StreamHandler(sys.stdout)
        self.addHandler(log_stdout)


r = Resources({"name": "watashi", u"文字列": u"もじもじ"}, FooLogger())
print(r.get_infos(["args", "origin"]))
print(r.get_infos("origin"))

これに型情報をつけるとこのようになります。この時点で3系専用になりました。

from typing import List, TypeVar, Union, Dict, Text
import json
import logging
import requests
import sys

DatabaseType = Dict[Text, Union[int, Text, Dict[Text, Text], None]]
LoggerType = TypeVar("LoggerType", bound=logging.Logger)


class Resources:
    POST_URL = "https://httpbin.org/post"

    def __init__(self, auth: Dict[Text, Text], logger: LoggerType) -> None:
        self.auth = auth
        self.logger = logger
        self.session = self.get_session()
        self.res = self.get_resources()

    def get_session(self) -> requests.Session:
        return requests.session()

    def get_resources(self) -> Dict:
        return json.loads(self.session.post(
            self.POST_URL, params=self.auth).text)

    def get_infos(self, queue: Union[List[Text], Text]) ->\
            Union[DatabaseType, Text]:
        if isinstance(queue, Text):
            return str(self.res.get(queue, ""))
        else:
            return {key: self.res.get(key, "") for key in queue}

class FooLogger(logging.Logger):
    def __init__(self) -> None:
        super().__init__("foobar", logging.INFO)
        self.logger = logging.getLogger()

        log_stdout = logging.StreamHandler(sys.stdout)
        self.addHandler(log_stdout)


r = Resources({"name": "watashi", "文字列": "もじもじ"}, FooLogger())
print(r.get_infos(["args", "origin"]))
print(r.get_infos("origin"))

After

これに対するスタブファイルの中身はこうなります。# type:と書いてあるのは変数の型を明示する為の記法です。

from typing import List, TypeVar, Union, Dict, Text, overload
import logging
import requests


# エイリアス
DatabaseType = Dict[Text , Union[int, Text , Dict[Text , Text], None]]

# ジェネリクス
LoggerType = TypeVar("LoggerType", bound=logging.Logger)


class Resources:
    POST_URL = ...  # type: Text

    def __init__(self, auth: Dict[Text , Text], logger: LoggerType) -> None:
        self.auth = ...  # type: Dict[Text , Text]
        self.logger = ...  # type: LoggerType
        self.session = ... # type: requests.Session
        self.res = ...  # type: Dict

    def get_session(self) -> requests.Session: ...

    def get_resources(self) -> Dict: ...

    @overload
    def get_infos(self, queue: Text) -> Text: ...
    @overload
    def get_infos(self, queue: List[Text]) -> DatabaseType: ...


class FooLogger(logging.Logger):
    def __init__(self) -> None:
        super().__init__(self, ...)
        self.logger = ...  # type: LoggerType

説明

まずスタブはコードの概観を表すものなので、実装は書かなくて良いんです。処理の本体は「...」で省略します。また、初期値が設定されている引数や定数もいくつかありますが、全て「...」です。ゆえにこれは Python の形をした別物であって、実行は出来ません。

@overload

スタブ特有の要素は実は一つだけなんです。ほかは typing モジュール自体の使い方と同じです。その一つがこれです。

上のコードで get_infos() は、リストが与えられると辞書を返し、文字列を与えられると文字列を返します。本体のように、
def get_infos(self, queue: Union[List[str], str]) -> Union[DatabaseType, str]:
と書いてしまうと、 リスト→リスト なのか、 リスト→文字列 なのか区別できません。こういうときにオーバーロードの出番です。引数と戻り値の型の組み合わせを明確にできます。

文字列

2系の対応も考えるなら、文字列の型が str では不自然です。 Text は「文字列」として、3系では str で、2系では unicode として振る舞ってくれます。 bytes も含めたい場合には AnyStr が用意されています。

数字

intfloat 両方を表すジェネリクスは用意されていないようです。なので
T = TypeVar('T', int, float)
とするのが無難でしょうか。

List, Tuple, Dict など

通常のlisttupledictに対応するものです。 typing モジュールからインポートしていますが、せずに小文字の方を使っても問題ありません。というのも実装を見ると

class List(list, MutableSequence[T], extra=list):

    def __new__(cls, *args, **kwds):
        if _geqv(cls, List):
            raise TypeError("Type List cannot be instantiated; "
                            "use list() instead")
        return list.__new__(cls, *args, **kwds)

となっているため、ただのlist()と大差ないようです。ちなみに Dict[str, str] は 「キーの型が str 、値の型が str 」ということです。Dict[str]は無効です。

UnionOptional

Union は文字通り、何かと何かが合わさっているものを表現するときに使います。
Union[int, str] では int と str のどちらも受け取れる(or 返す)ことを表します。
Optional は、 Union の要素の一つが None であるものを表します。つまり、
Optional[str] == Union[str, None]
です。昨今話題の null 安全を実現する(かもしれない)ものです。

エイリアス

get_infos() は辞書を返します。もちろん単に Dict と書くのでも十分ですが詳細に定義するとどうなるでしょう。
Dict[str, Dict[str, Union[int, str, List[str]]]]
こんなに長いものを何回も書くのは面倒です。コピペはバグのもとです。変数に丸め込んでしまいましょう。これをエイリアスと言います。

ジェネリクス

FooLogger はログ出力用のクラスですが、メインの処理にとってはその具体的な名前はどうでもよく、ただ logging を継承しているかどうかが重要です。そういう時にこれを使います。 Python 流のジェネリクスの書き方はこうです: T = TypeVar("T", bound=logging.Logger)
この場合 T は logging のサブクラスです。一般にはboundを指定せずに T = TypeVar("T") と書くことの方が多いと思います。

ちなみに LoggerType = TypeVar("T") は駄目です。変数名と文字列の中の型名は一致していないといけません。

13
10
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
10