[Python]変数や引数として扱うCallableオブジェクトに対する型アノテーションの書き方

Pythonで関数やメソッドを変数や引数（Callableオブジェクト）として扱う必要が出て来る時があります。

そういった時の型アノテーション周りについて備忘録も兼ねて記事にしておきます。

# 使う環境

- Python 3.8.5
- VS Code
- VS Code拡張機能のPylance（型チェックや補完用）
  - ※詳細は以前書いた[[Python]PylanceのVS Code拡張機能をさっそく使ってみた。](https://qiita.com/simonritchie/items/33ca57cdb5cb2a12ae16)などの記事をご確認ください。

# まずは型アノテーションの無い状態

とある以下のような関数があり、引数に関数のオブジェクト（`func`）と任意の値（`x`）を受け付けるとします。

```py
def any_function(func, x):
    return func(x)
```

当たり前ですがこれだとVS Code上でマウスオーバーしてみても型などの情報がUnknownとなってしまいます。Pylance（Pyright）などでも型のチェックなどは行われません。docstringなどで詳細を詳しく書いておくという対策もありますが、このままだと曖昧でミスをしがちです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/5c03469a-d375-8861-c0de-67b99567eb62.png)

# Callableの型アノテーションを行う

続いてfunc引数にCallableの型アノテーションをします。Callableはビルトインのtypingパッケージ内に存在します。ついでにx引数などに対しても型アノテーションをしておきます。

```py
from typing import Callable


def any_function(func: Callable, x: int) -> int:
    return func(x)
```

こうするとVS Code上でマウスオーバーなどをするとfunc引数は呼び出せるなんらかの関数やメソッドだということを認識してくれます。

また、func引数に関数など以外を指定すればPylanceのチェックで引っかかってくれます。

```py
any_function(func=10, x=20)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/d7bd335f-90de-7c72-b49a-fb848d32f5a8.png)

ただしマウスオーバーした際の引数や返却値の情報はUnknownのままです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/a416b9d1-1f6b-c864-44d7-ccdb307656b0.png)

# 引数や返却情報の型アノテーションを行う

func関数を第一引数にint、第二引数にfloat、返却値にfloatを返すといった型アノテーションをしていきます（サンプルの引数にも、`y`というflaotの引数を追加していきます）。

書き方としては`Callable[[第一引数の型, 第二引数の型], 返却値の型]`といったように書きます。引数が増減した場合にはコンマ区切りで増やしたり減らしたりします。

```py
def any_function(
        func: Callable[[int, float], float],
        x: int,
        y: float) -> float:
    return func(x, y)
```

これによって、マウスオーバー時に関数の引数や返却値の型情報が表示されたり、func引数を呼び出した際に引数が足りなかったり型が一致していなければエラーで検知できるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/20cdca73-ac6d-6ce8-352f-555429afb7f9.png)

```py:func呼び出し箇所で引数が足りていないサンプル
def any_function(
        func: Callable[[int, float], float],
        x: int,
        y: float) -> float:
    return func(x)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/02cdb804-d45c-8e9c-d6d2-56c7bc6e58b9.png)

```py:func呼び出しで型が一致していないサンプル
def any_function(
        func: Callable[[int, float], float],
        x: int,
        y: float) -> float:
    return func(y, x)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/003d241b-8ac6-fc01-8a9d-65f1c6d7d02c.png)

引数に渡す時などにも指定した関数の構造が一致しているかがチェックされます。

```py:引数に渡しているCallableの返却値が一致しておらずチェックに引っかかるサンプル
def any_function(
        func: Callable[[int, float], float],
        x: int,
        y: float) -> float:
    return func(x, y)


def other_function(x: int, y: float) -> str:
    ...


any_function(func=other_function, x=10, y=12.5)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/214f399c-fa0f-ebf4-fa2a-23c44c97f0f4.png)

ここまでの対応でも型アノテーションの無いケースと比べると大分堅牢な感じになってきました。

ただしこの場合キーワード引数の指定が元の引数名が使えません。引数名は保持されず、p0, p1, ...という名前が割り振られます。例えば以下のようにキーワード引数付きで書くとPylanceのチェックで引っかかってしまいます。

```py
def any_function(
        func: Callable[[int, float], float],
        x: int,
        y: float) -> float:
    return func(x=x, y=y)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/0f905895-3fb1-629a-aa0b-712ce2f36cc2.png)

多くのケースではこれでも特に問題にならない場合も多いですが、例えば引数が多かったり同じ型の引数がたくさんあるとキーワード引数を使いたくなってきます。

# 引数名の情報を保持する形で型アノテーションを行う

実はtypingパッケージのProtocolを使うと、引数名も保持した状態で型アノテーションをすることができます（最近知りました）。

使い方としては、Protocolを継承した形でクラスを定義し、`__call__`メソッドに必要な引数や返却値・型の情報などを定義します（今回はサンプルとして`FuncType`という名前にしました）。あとはそのクラス（`FuncType`）をfunc引数の型アノテーションに指定すればOKです。

```py
from typing import Callable, Protocol


class FuncType(Protocol):

    def __call__(self, x: int, y: float) -> float:
        ...


def any_function(
        func: FuncType,
        x: int,
        y: float) -> float:
    return func(x=x, y=y)

```

これでキーワード引数を使ってもPylance上でエラーが出なくなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/4cc19098-8480-2825-65d9-dd647748e7c5.png)

ProtocolはPython3.8以降で利用ができます。Python3.7以前のバージョンで使いたい場合や配布ライブラリなどで過去のPythonバージョンもサポートしたい場合にはバックポート的にpipなどで[typing-extensionsライブラリ](https://pypi.org/project/typing-extensions/)をインストールする必要があります。mypyなどをインストールした場合には一緒にインストールされます。

importの記述もtypingモジュールではなくtyping_extensionsというように記述が変わります。挙動や使い方はtypingのProtocolもtyping_extensionsのProtocolも変わりません。

```py
from typing_extensions import Protocol
```

# 参考サイト・参考文献まとめ

- [python typing signature (typing.Callable) for function with kwargs](https://stackoverflow.com/a/57840786/9610928)