本文详细介绍在 Python 3.10 新加入的四个和类型系统相关的新特性。

PEP 604: New Type Union Operator

在之前的版本想要声明类型包含多种时,这么写:

from typing import Union


def square(number: Union[int, float]) -> Union[int, float]:
    return number ** 2

这么写其实比较麻烦,每次都要from typing import Union再使用Union[],现在直接可以使用|来表示:

def square(number: int | float) -> int | float:
    return number ** 2

可以说很方便了。事实上这个新的用法也可以用在isinstanceissubclass里面。之前确认是不是某些类型中的一种这么写:

In : isinstance('s', (int, str))
Out: True

In : isinstance(1, (int, str))
Out: True

只要是 int 或者 str 都返回 True,现在这么写就可以:

In : isinstance('s', int | str)
Out: True

In : isinstance(1, int | str)
Out: True

PEP 613: TypeAlias

有时候我们想要自定义一个类型,那么就创建一个别名 (Alias),之前的版本通常这么写:

Board = List[Tuple[str, str]]

但是对于类型检查器 (Type Checker) 来说,它无法分辨这是一个类型别名,还是普通的赋值。现在引入 TypeAlias 就很容易分辨了:

from typing import TypeAlias

Board:TypeAlias = List[Tuple[str, str]]  # 这是一个类型别名
Board = 2  # 这是一个模块常量

PEP 647: User-Defined Type Guards

在当代静态检查工具 (如 typescript、mypy 等) 里面会有一个叫做【Type narrowing】功能,如其名字,中文叫做【类型收窄】。就是当某个参数类型本来可以符合多个类型,但是在特定的条件里可以让类型范围缩小,直接限定到更小范围的某个 (些) 类型上。

在 mypy 官方里面有多个例子 (延伸阅读链接 4),这里拿一个来举例:

In : def show_type(obj: int | str):  # 参数obj是int或者str
...:     if isinstance(obj, int):  # 实现Type narrowing,mypy确认obj是int
...:         return 'int'
...:     return 'str'  # 由于前面限定了int,所以这里mypy会确认obj是str
...:

In : show_type(1)
Out: 'int'

In : show_type('1')
Out: 'str'

更准确的了解对象的类型对于 mypy 是非常友好的,检查的结论也会更准确。

类型收窄在一些场景下会有问题,再举个例子:

In : def is_str(obj: object) -> bool:
...:     return isinstance(obj, str)
...:
...:
...: def to_list(obj: object) -> list[str]:
...:     if is_str(obj):
...:         return list(obj)
...:     return []
...:

In : def is_str(obj: object) -> bool:
...:     return isinstance(obj, str)
...:
...:
...: def to_list(obj: object) -> list[str]:
...:     if is_str(obj):
...:         return list(obj)
...:     return []
...:

In : to_list('aaa')
Out: ['a', 'a', 'a']

In : to_list(111)
Out: []

这段代码比 PEP 里面提到的更简单,它们都是正确的代码,类型注释也有问题。但是运行 mypy:

 mypy wrong_to_list.py
wrong_to_list.py:7: error: No overload variant of "list" matches argument type "object"
wrong_to_list.py:7: note: Possible overload variants:
wrong_to_list.py:7: note:     def [_T] list(self) -> List[_T]
wrong_to_list.py:7: note:     def [_T] list(self, Iterable[_T]) -> List[_T]
Found 1 error in 1 file (checked 1 source file)

分析一下问题。在 2 个函数中 obj 由于不确定对象类型,所以用了 object,事实上to_list只会对 obj 为 str 类型做处理。本来if is_str(obj)会让类型收窄,但是由于被拆分成函数,isinstance 并没有在这里成功收窄

怎么解决呢?在新版本提供了用户自定的Type Guards:

from typing import TypeGuard

def is_str(obj: object) -> TypeGuard[str]:
    return isinstance(obj, str)

本来返回值的类型是 bool,现在我们指定成了TypeGuard[str],让 mypy 能理解它的类型。其实换个角度,你可以理解为TypeGuard[str]是一个带着类型声明的 bool 的别名,请仔细理解这句话。

现在就好啦:

 mypy right_to_list.py
Success: no issues found in 1 source file

PEP 612 – Parameter Specification Variables

Python 的类型系统对于 Callable 的类型 (例如函数) 的支持很有限,它只能注明这个 Callable 的类型但是对于函数调用时的参数是无法传播的。这个问题主要存在于装饰器用法上,看一下例子:

from collections.abc import Callable
from typing import Any, TypeVar


R = TypeVar('R')


def log(func: Callable[..., R]) -> Callable[..., R]:
    def inner(*args: Any, **kwargs: Any) -> R:
        print('In')
        return func(*args, **kwargs)
    return inner


@log
def join(items: list[str]):
    return ','.join(items)


print(join(['1', '2']))  # 正确用法
print(join([1, 2]))  # 错误用法,mypy应该提示类型错误

join 接收的参数值应该是字符串列表。但是 mypy 没有正确验证最后这个print(join([1, 2]))。因为在 log 装饰器中 inner 函数中 args 和 kwargs 的类型都是 Any,这造成调用时选用的参数的类型没有验证,说白了怎么写都可以。在新的版本中可以使用ParamSpec来解决:

from typing import TypeVar, ParamSpec


R = TypeVar('R')
P = ParamSpec('P')


def log(func: Callable[P, R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        print('In')
        return func(*args, **kwargs)
    return inner

通过使用typing.ParamSpec,inner 的参数类型直接通过 P.args 和 P.kwargs 传递进来,这样就到了验证的目的。现在再检查就正确了:

 mypy right_join.py
right_join.py:22: error: List item 0 has incompatible type "int"; expected "str"
right_join.py:22: error: List item 1 has incompatible type "int"; expected "str"
Found 2 errors in 1 file (checked 1 source file)

typing.ParamSpec帮助我们方便【引用】位置和关键字参数,而这个 PEP 另外一个新增的typing.Concatenate是提供一种添加、删除或转换另一个可调用对象的参数的能力。

我能想到比较常见的添加参数是指【注入】类型的装饰器。比如:

import logging
from collections.abc import Callable
from typing import TypeVar, ParamSpec, Concatenate

logging.basicConfig(level=logging.NOTSET)
R = TypeVar('R')
P = ParamSpec('P')


def with_logger(func: Callable[Concatenate[logging.Logger, P], R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        logger = logging.getLogger(func.__name__)
        return func(logger, *args, **kwargs)
    return inner


@with_logger
def join(logger: logging.Logger, items: list[str]):
    logger.info('Info')
    return ','.join(items)


print(join(['1', '2']))
print(join([1, 2]))

join 函数虽然有 2 个参数,但是由于第一个参数logger是在with_logger装饰器中【注入】的,所以在使用时只需要传递 items 参数的值即可。

除了添加,删除和转换参数也可用Concatenate。再看一个删除参数的例子:

from collections.abc import Callable
from typing import TypeVar, ParamSpec, Concatenate

R = TypeVar('R')
P = ParamSpec('P')


def remove_first(func: Callable[P, R]) -> Callable[Concatenate[int, P], R]:
    def inner(a: int, *args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)
    return inner


@remove_first
def add(a: int, b: int):
    return a + b


print(add(1, 2, 3))

使用 remove_first 装饰器后,传入的第一个参数会被忽略,所以add(1, 2, 3)其实是在计算add(2, 3)。注意理解这个Concatenate在的位置,如果是新增,那么Concatenate加在装饰器参数的 Callable 的类型声明里,如果是删除,加在返回的 Callable 的类型声明里。

注意:Concatenate目前只在作为Callable的第一个参数时有效。Concatenate的最后一个参数必须是一个ParamSpec

代码目录

本文代码可以在 mp 项目找到

延伸阅读

  1. https://mypy.readthedocs.io/en/latest/type_narrowing.html
  2. https://peps.python.org/pep-0604/
  3. https://peps.python.org/pep-0612/
  4. https://peps.python.org/pep-0647/
  5. https://peps.python.org/pep-0613/