Pythonで関数の型定義を行う方法

## はじめに
Pythonで関数(`Callable`)の型定義(Type Hint)をどうやるのが良いか悩んだのでまとめておきたいと思います。

## 前提
* Python 3.8以降(確認は3.12で行っています)
* VSCode + Pylance(standard mode) + Error Lens
* mypy 1.8.0

## パターン
1. 型定義(Type Hint)なし
2. `Callable`のみ
3. `Callable`に引数と返り値の型を付与
4. 3.をエイリアスにしたもの
5. `Protocol`を使用

これらのパターンで、ソースコード、エディタ上でどうチェックされるか、mypyでどうチェックされるかの結果を見ていきたいと思います。


## 詳細

### 1. 型定義(Type Hint)なし

#### ソースコード
```python: func_no_type.py 
def some_func_1_OK(func, value1, value2):
    return func(value1, value2)

def some_func_1_NG(func, value1, value2):
    return func(value1, value2, 3)


if __name__ == "__main__":
    def add(a: int, b: int) -> int:
        return a + b

    print(f'add {some_func_1_OK(add, 1, 2)}')
    print(f'add {some_func_1_NG(add, 1, 2)}')
```

### エディタ上(Pylanceなどを使用)
`some_func_1_NG`は指定できる引数に対して1つ多い引数が指定されていますが、エディタ上では特にチェックされません。
当然、何を指定すればいいかも情報はありません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3120150/dfc1cf8c-d2c9-0733-b008-3b7c0dcb45e6.png)



#### mypyの結果
mypyのチェックも成功で通ってしまいます。
```bash
> rye run mypy src/python_typed_function/func_no_type.py 
Success: no issues found in 1 source file
```


### 2. `Callable`のみ
次に`Callable`だけ指定してみます。
#### ソースコード
```python: func_callable_only.py
from typing import Callable

def some_func_2_OK(func: Callable, value1, value2) -> int:
    return func(value1, value2)

def some_func_2_NG(func: Callable, value1, value2) -> int:
    return func(value1, value2, 3)


if __name__ == "__main__":
    def add(a: int, b: int) -> int:
        return a + b

    print(f'add {some_func_2_OK(add, 1, 2)}')
    print(f'add {some_func_2_NG(add, 1, 2)}')
```

#### エディタ上(Pylanceなどを使用)
同じくNGの方もエディタ上では特にチェックされません。
何らかの関数であることは教えてくれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3120150/ee278bb4-09f9-74db-1658-6b51a7144fd5.png)


#### mypyの結果
mypyのチェックも成功で通ってしまいます。
```bash
> rye run mypy src/func_callable_only.py 
Success: no issues found in 1 source file
```



### 3. `Callable`に引数と返り値の型を付与
Callableに引数と返り値の型まで指定してみたいと思います。
また、ここからキーワード引数も追加してみたいと思います。

#### ソースコード
```python:func_callable_and_type.py
from typing import Callable

def some_func_3_OK(func: Callable[[int, int], int], value1, value2) -> int:
    return func(value1, value2)

def some_func_3_OK_2(func: Callable[[int, int], int], value1, value2) -> int:
    return func(b=value1, a=value2)

def some_func_3_NG(func: Callable[[int, int], int], value1, value2) -> int:
    return func(value1, value2, 3)

def some_func_3_NG_2(func: Callable[[int, int], int], value1, value2) -> int:
    return func(b=value1, c=value2)


if __name__ == "__main__":
    def add(a: int, b: int) -> int:
        return a + b

    print(f'add {some_func_3_OK(add, 1, 2)}')
    print(f'add {some_func_3_NG(add, 1, 2)}')
    print(f'add {some_func_3_OK_2(add, 1, 2)}')
    print(f'add {some_func_3_NG_2(add, 1, 2)}')
```

#### エディタ上(Pylanceなどを使用)
ここまで来ると、エディタ上でどんな関数を指定すれば良いかまで教えてくれるようになりました。
キーワード引数は、型定義で指定できていない(できない)ので、位置引数がない旨のエラーとして教えてくれています。
また、不要な位置引数もここでチェックしてくれています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3120150/13e41c7a-b4d4-0c63-af97-b17f7995e904.png)


#### mypyの結果
キーワード引数が指定できないことや、位置引数の数などをここでもチェックしてくれています。
```bash
> rye run mypy src/python_typed_function/func_callable_and_type.py 
src/python_typed_function/func_callable_and_type.py:7: error: Unexpected keyword argument "b"  [call-arg]
src/python_typed_function/func_callable_and_type.py:7: error: Unexpected keyword argument "a"  [call-arg]
src/python_typed_function/func_callable_and_type.py:10: error: Too many arguments  [call-arg]
src/python_typed_function/func_callable_and_type.py:13: error: Unexpected keyword argument "b"  [call-arg]
src/python_typed_function/func_callable_and_type.py:13: error: Unexpected keyword argument "c"  [call-arg]
Found 5 errors in 1 file (checked 1 source file)
```


### 4. 3.をエイリアスにしたもの
続いてさっきと同じものをエイリアスにしてみたいと思います。
型に名前をつけるようなイメージです。

#### ソースコード
```python: func_callable_and_type_with_alias.py
from typing import Callable

MyCallable = Callable[[int, int], int]

def some_func_4_OK(func: MyCallable, value1, value2) -> int:
    return func(value1, value2)

def some_func_4_OK_2(func: MyCallable, value1, value2) -> int:
    return func(b=value1, a=value2)

def some_func_4_NG(func: MyCallable, value1, value2) -> int:
    return func(value1, value2, 3)

def some_func_4_NG_2(func: MyCallable, value1, value2) -> int:
    return func(b=value1, c=value2)


if __name__ == "__main__":
    def add(a: int, b: int) -> int:
        return a + b

    print(f'add {some_func_4_OK(add, 1, 2)}')
    print(f'add {some_func_4_NG(add, 1, 2)}')
    print(f'add {some_func_4_OK_2(add, 1, 2)}')
    print(f'add {some_func_4_NG_2(add, 1, 2)}')

```

#### エディタ上(Pylanceなどを使用)
さきほどと同じく、キーワード引数は、型定義で指定できていない(できない)ので、位置引数がない旨のエラーとして教えてくれています。
また、不要な位置引数もここでチェックしてくれています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3120150/614580d1-53f8-c210-1763-833141fa7e97.png)


#### mypyの結果
キーワード引数が指定できないことや、位置引数の数などをここでもチェックしてくれています。

```bash
> rye run mypy src/python_typed_function/func_callable_and_type_with_alias.py 
src/python_typed_function/func_callable_and_type_with_alias.py:9: error: Unexpected keyword argument "b"  [call-arg]
src/python_typed_function/func_callable_and_type_with_alias.py:9: error: Unexpected keyword argument "a"  [call-arg]
src/python_typed_function/func_callable_and_type_with_alias.py:12: error: Too many arguments  [call-arg]
src/python_typed_function/func_callable_and_type_with_alias.py:15: error: Unexpected keyword argument "b"  [call-arg]
src/python_typed_function/func_callable_and_type_with_alias.py:15: error: Unexpected keyword argument "c"  [call-arg]
Found 5 errors in 1 file (checked 1 source file)
```


### 5. `Protocol`を使用
最後にプロトコルを使用したパターンです。

#### ソースコード
```python
from typing import Protocol

class MyCallableType(Protocol):
    def __call__(self, a: int, b: int) -> int: # type: ignore
        pass

def some_func_5_OK(func: MyCallableType, value1, value2) -> int:
    return func(value1, value2)

def some_func_5_OK_2(func: MyCallableType, value1, value2) -> int:
    return func(b=value1, a=value2)

def some_func_5_NG(func: MyCallableType, value1, value2) -> int:
    return func(value1, value2, 3)

def some_func_5_NG_2(func: MyCallableType, value1, value2) -> int:
    return func(b=value1, c=value2)


if __name__ == "__main__":
    def add(a: int, b: int) -> int:
        return a + b

    print(f'add {some_func_5_OK(add, 1, 2)}')
    print(f'add {some_func_5_NG(add, 1, 2)}')
    print(f'add {some_func_5_OK_2(add, 1, 2)}')
    print(f'add {some_func_5_NG_2(add, 1, 2)}')
```

#### エディタ上(Pylanceなどを使用)
Protocolを使用するとキーワード引数も定義できるようになりました。
そのため、指定できない名前のキーワード引数のチェックもしてくれています。当然、不要な位置引数もここでチェックしてくれています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3120150/09f1b697-0408-5a8d-275d-348784e142ac.png)

#### mypyの結果
エディタ上と同じ内容をmypyでもチェックしてくれました。

```bash
> rye run mypy src/python_typed_function/func_protocol.py 
src/python_typed_function/func_protocol.py:4: note: "__call__" of "MyCallableType" defined here
src/python_typed_function/func_protocol.py:14: error: Too many arguments for "__call__" of "MyCallableType"  [call-arg]
src/python_typed_function/func_protocol.py:17: error: Unexpected keyword argument "c" for "__call__" of "MyCallableType"  [call-arg]
Found 2 errors in 1 file (checked 1 source file)
```

## まとめ
最後に結果をまとめておきます。

* キーワード引数を含む関数の型定義を行いたい場合は、`Protocol` を使用する
* そうではない場合、`Callable`を使用しても良い。複雑な関数になるような場合は特に、エイリアスで型自体に命名するのが良い
* 型定義いらない場合は好き選択する

| パターン | 型定義 | 型自体への命名 | 位置引数の型定義の可否 | キーワード引数の型定義の可否 | 
|---|---|---|---|---|
| 1. 型定義(Type Hint)なし | ✕ | ✕ | ✕ | ✕ |
| 2. `Callable`のみ | △ | ✕ | ✕ | ✕ |
| 3. `Callable`に引数と返り値の型を付与 | ◯ | ✕ | ◯ | ✕ |
| 4. 3.をエイリアスにしたもの | ◯ | ◯ | ◯ | ✕ |
| 5. `Protocol`を使用 | ◯ | ◯ | ◯ | ◯ |

個人的にはできるだけ厳密になっている方が好きだったり、制約がしっかりできるの方が好みというのもありますが、こんな結果となりました。
今回は検証のパターンから外しましたが、ABC使って抽象クラスとしてインタフェースを表現するのも良いと思います。