mypyのキーワード引数・デフォルト引数を持ったメソッドのプロトコルのメモ

デフォルト引数周りの型のことでちょっと把握できなかった部分があったのでメモ。

余分なデフォルト引数を持つ実装について

例えば、以下の様なプロトコルがあるとする。

import typing as t
import typing_extensions as tx

class Adder(tx.Protocol):
    def add(self, x: int, y: int) -> int:
        ...

このAdder.addメソッドはデフォルト引数を持たない。一方で、デフォルト引数を持つような実装はこのプロトコルの制約を満たすのかということが気になる。

例えば以下のようなもの

class A:
    def add(self, x: int, y: int, *, verbose: bool = False) -> int:
        return x + y


class B:
    def add(self, x: int, y: int, verbose: bool = False) -> int:
        return x + y


class C:
    def add(self, x: int, y: int) -> t.Any:
        return x + y

Aはkeyword only argumentsのデフォルト引数、Bはデフォルト引数、Cはそのままの実装。これらは全てadder.add(10,20)のような形で呼べるので、直感的には全て先程のAdderの制約を満たしていそう。 実際これらは全部大丈夫。

def use(adder: Adder, x: int, y: int) -> int:
    return adder.add(x, y)


def main() -> None:
    a = A()
    print(use(a, 10, 20))
    b = B()
    print(use(b, 10, 20))
    c = C()
    print(use(c, 10, 20))

こういうコードを書いてあげて mypy --strict <filename>.py とやった場合にエラーは出ない。

関数でも同様

これは関数でも同様。例えば、以下の様な型を書いてあげる。

import typing as t

F = t.Callable[[int, int], int]

そしてこれらの型について、以下の実装も満たす。

def add(x: int, y: int, *, verbose: bool = False) -> int:
    return x + y


def add2(x: int, y: int, verbose: bool = True) -> int:
    return x + y

逆はどうか

逆はどうか。

デフォルト値を持つキーワード引数を取った関数の型

デフォルト値を持つキーワード引数を取った関数の型は以下の様に書ける(experimental)。

import typing as t
import mypy_extensions as mx

# (x: int, y:int, *, verbose: bool=...) -> int みたいな型
F = t.Callable[[int, int, mx.DefaultNamedArg(bool, "verbose")], int]

こちらは以下の実装も満たす。

def add(x: int, y: int, *, verbose: bool = False) -> int:
    return x + y


def add2(x: int, y: int, verbose: bool = False) -> int:
    return x + y

デフォルト引数を持つ通常の引数(positional arguments)を持つ関数の型

一方で、デフォルト引数を持つ通常の引数(positional arguments)を持つ関数の型は以下の様になる。

# (x: int, y:int, verbose: bool=...) -> int みたいな型
F = t.Callable[[int, int, mx.DefaultArg(bool, "verbose")], int]

こちらは、add2だけが満たす。これは当然でadd(10,20,True)のような関数適用も許す型なので。

# error
def add(x: int, y: int, *, verbose: bool = False) -> int:
    return x + y


def add2(x: int, y: int, verbose: bool = False) -> int:
    return x + y

ちなみに以下の様なエラーが出る。

error: Argument 1 to "use" has incompatible type "Callable[[int, int, DefaultNamedArg(bool, 'verbose')], int]"; expected "Callable[[int, int, bool], int]"

(捕捉: callableなprotocolでの関数の型定義)

以下のissueが対応されたので、後のmypyではcallableなprotocolを関数の型として扱えるようになる。

import typing_extensions as tx


# (x: int, y:int, *, verbose: bool=...) -> int みたいな型
class F(tx.Protocol):
    def __call__(self, x: int, y: int, *, verbose: bool = False) -> int:
        ...

**kwargsのような引数を持つ型

以下のように任意個の引数を取れる型に関してはどうだろう。

import typing as t
import typing_extensions as tx


class P(tx.Protocol):
    def f(self, **kwargs: t.Any) -> None:
        ...

以下の実装は満たすか?

class A:
    def f(self, verbose: bool = False) -> None:
        pass

呼び出しだけを気にした場合にはいけそうな気がするがだめ。これはa.f(), a.f(verbose=False) 以外の呼び出し、例えば a.f(foo=x,bar=y,zzz=z) なども許可するプロトコルの制約なので。というわけで例えばちょっとキーワード引数の数が多めのメソッドを持つ実装のサブセットをプロトコルで取り出そうとした時に**kwargsで横着することはできない。

error: Argument 1 to "use" has incompatible type "A"; expected "P"
note: Following member(s) of "A" have conflicts:
note:     Expected:
note:         def f(self, **kwargs: Any) -> None
note:     Got:
note:         def f(self, verbose: bool = ...) -> None

複雑な例(ArgumentParserのサブセット)

もう少し複雑な例で試してみる。例えばargparseのArgumentParserのサブセット(add_argument()とparse_args()だけを持つ)とか。

myprotocol.pyi

import typing as t
import typing_extensions as tx
import argparse

T = t.TypeVar("T", contravariant=True)


class ArgumentParserSubset(tx.Protocol[T]):
    def parse_args(
        self,
        argv: t.Optional[t.Sequence[str]] = ...,
        namespace: t.Optional[argparse.Namespace] = ...
    ) -> t.Any:
        ...

    # これはダメ。。
    # def add_argument(self, *name_or_flags: str, **kwargs: t.Any) -> argparse.Action:
    #     ...

    def add_argument(
        self,
        *name_or_flags: str,
        action: t.Union[str, t.Type[argparse.Action]] = ...,
        nargs: t.Union[int, str] = ...,
        const: t.Any = ...,
        default: t.Any = ...,
        type: t.Union[t.Callable[[str], T], argparse.FileType] = ...,
        choices: t.Iterable[T] = ...,
        required: bool = ...,
        help: t.Optional[str] = ...,
        metavar: t.Union[str, t.Tuple[str, ...]] = ...,
        dest: t.Optional[str] = ...,
        version: str = ...
    ) -> t.Any:
        ...

(型の値はAnyでも良いけれど。プロトコル側でもデフォルト引数の部分は全部列挙してあげる必要がある)

もちろん、このプロトコルを満たす実装の方は雑に**kwargsを使ってOK。

import typing as t
import argparse

class FakeParser:
    def parse_args(
        self,
        argv: t.Optional[t.Sequence[str]] = None,
        namespace: t.Optional[argparse.Namespace] = None
    ) -> t.Any:
        pass

    def add_argument(
        self,
        *name_or_flags: str,
        **kwarg: t.Any,
    ) -> t.Any:
        pass

例えば以下の様にして試してみると、argparse.ArgumentParserとFakeParserの両者は共に定義したプロトコルのArgumentParserSubsetを満たすことが確認できる(mypy --strictで確認)。

import typing as t
import argparse
if t.TYPE_CHECKING:
    import myprotocol  # noqa

def use(parser: "myprotocol.ArgumentParserSubset") -> None:
    parser.add_argument("--verbose", action="store_true")
    print(parser.parse_args())


def main() -> None:
    use(argparse.ArgumentParser())
    use(FakeParser())

gist