前言

本文是 Python 与 Type Annotations 的续作. 主要是回顾在那篇文章之前的 Python 类型标注(即 Python 3.6 以及之前版本), 并不过多涉及新的类型标注使用. 主要作用是追本溯源, 固实基础. 接下来我应该还会出一篇介绍这两年来 Python 在类型标注方面的新进展.

本来呢是想两篇写成一篇的, 但是发现光是回顾历史就占据了过多篇幅, 索性分为两篇, 一篇往前, 一篇往后, 也是一件美事.

所谓类型标注之本, 在下面的 PEP 里:

我抽出时间来通读了上面的 PEP, 下面便是我在其中看到的一些或有趣或意料之外的事实 (以及吐槽与思考).

类型标注啥时候出现的?

对函数接口的标注早在 Python 3.0 (2006.10) 就出现了, 但是在 3.5 (2014.09) 才规定了标注作为类型提示时的语义. 关于变量的标注语法更晚, 在 3.6 (2016.08) 才出现.

类型标注里只能放类型吗?

根据 PEP 3107 的说法:

Function annotations are nothing more than a way of associating arbitrary Python expressions with various parts of a function at compile-time.

def compile(source: "something compilable",
        filename: "where the compilable thing comes from",
        mode: "is this a single statement or a suite?"):
    ...

也就是说, 其实类型标注里原本就能随便放东西. 不过随便乱放会被类型检查器警告就是了. 并且在当时标准并不为类型标注关联任何语义 (哪怕是 builtin 类型), 它们的意义完全靠第三方定义.

不过情况在 3.5 (2014.09) 改变了, PEP 484 - Type hints 正式给类型标注安上了 “类型提示” 的名头并赋予了其一些基本语义. 其中规定了作为类型提示的标注里只能放 “简单的表达式”. PEP 并没有规定这里的 “简单” 到底是指多简单, 但截至目前(2022.05), mypy 不允许的是:

  • 函数调用. 这同时包括了对 TypeVar('T') 的调用
  • 字面量. 字面量必须使用 Literal[] 来标注
  • 非类型别名的变量.

类型标注的求值时间?

由上一个问题可知类型标注就是普通的表达式, 那么它们何时求值呢?

根据 PEP 3107, 函数的类型标注会在函数定义时被求值(包括参数的与返回值的). 但是根据 PEP …

运行时获得的类型标注是其求值后的结果

根据 PEP 3107:

For example, the following annotation:

def foo(a: 'x', b: 5 + 6, c: list) -> max(2, 9):
    ...

would result in an __annotations__ mapping of

{'a': 'x',
'b': 11,
'c': list,
'return': 9}

也就是说运行期其实没办法获得类型标注本来的 “字面上” 的结果.

标注类成员列表

TODO

Type vs Class (and Type Gaurd)

在 PEP 483 中提到:

In Python, classes are object factories defined by the class statement, and returned by the type(obj) built-in function. Class is a dynamic, runtime concept.

Type concept is described above, types appear in variable and function type annotations, can be constructed from building blocks described below, and are used by static type checkers.

也就是说现在 class 与 type 已经被明确分开用作两种意思了. 在本文中用类型指代 type, 指代 class.

每一个类都可以作为一个类型. 但类并不是类型. 某种意义上可以说类是类型的语义.

初用类型标注的人很多都会尝试写出下面这样的代码:

if isinstance(x, List[int]):
    ...

不过如果你不小心打错了, 或者好奇试了一下的话, 就会发现 isinstance([], List) 是可以的. 这是因为至少从 3.6 开始, ListTuple 就分别继承了 listtuple… 但是 Union 并没有继承任何基础类型, 这就很怪了.

令事情更加混淆的是, 你可以从 Tuple[T] 或者 Tuple[int, str] 继承, 都相当于从 tuple 继承… 并且假如你从 Generic 继承, __getitem__ 的结果也能拿来构造新实例. 考虑下面的代码:

# 效果相当于 class MyTuple(tuple):
# 也就是说, 这同时规定了 MyTuple 是 tuple 的子类 (subclass)
# 并且 MyTuple[T] 是 Tuple[T, T] 的子类型 (subtype)
class MyTuple(Tuple[T, T]):
  class_var = 1

# 你可以这样写, 效果相当于 c: MyTuple[str] = MyTuple()
c = MyTuple[str]()

# 这种写法甚至可以拿来获取类成员 (并且该类成员会被推断成正确的类型)
# 这个跟 PEP 484 矛盾了, PEP 484 里是不准这种用法的. 
# Ref: https://peps.python.org/pep-0484/#instantiating-generic-classes-and-type-erasure
# 有可能是后来的 PEP 加入或者是 mypy 的扩展
MyTuple[str].class_var

# 但是这个绝对会报错
isinstance(c, MyTuple[str])

这就是混淆了类与类型. isinstance 是判断某个对象的类是否为给定的类的, 自然不能把类型作为第二个参数. 何况类型只是 “编译期” 的信息, 在运行时跟文档字符串没有什么区别.

正因为类型标注是编译期信息, 它只跟标识符绑定, 不跟对象绑定. 而类作为运行期信息自然是跟对象绑定. 也就是说, 其实并不能获得某个对象的类型标注, 而只能获得它的类. 也就是说:

# Assume x is a variable
print(x.__annotaitons__) # 错误做法, 类型标注并不跟对象绑定, 尝试对对象取类型标注是无用功, 应该取对象的类

print(type(x)) # 正确做法, 取对象的类
print(dir(x))

# 如果真的想获得 `x` 的类型标注, 可能可以从 AST 入手.

不过对于函数而言事情又有所不同. 在函数定义执行的时候会将类型标注与创建出的函数对象绑定, 作为函数对象的 __annotations__ 存在, 就好像文档字符串一样.

def neg(x: int) -> int:
    return -x

neg.__annotations__ 
# {'x': <class 'int'>, 'return': <class 'int'>}

其实几乎所有编程语言里都存在一个极易混淆的概念, “变量”. 有时候当我们说起变量 “x” 时, 我们是指这个标识符, 这时候我们可以谈论 “变量的作用域”, “变量的可见性”; 而更多的时候, 我们是指当时(运行时)与 “x” 绑定的对象, 这时候我们谈论 “变量的可变性”, “变量的生存期”, “变量的值”.

就我目前个人认为, 标识符与其绑定的对象是必须分开的, 而求值(eval)将 AST 转化成对象, 而我们对对象的解释称之为对象的值, 而这种意义下的 “值” 也就是 r5rs 里所说的 “对象的外部表示” (external representation of an object).

list 为什么是不变 (invariant) 类型?

因为 Python 中的 list 实际上是一个引用, 而这个引用指向的对象还是可变的. 考虑下面的极端情况:

def append_pi(lst: List[float]) -> None:
    lst += [3.14]

my_list = [1, 3, 5]  # type: List[int]

append_pi(my_list)   # Naively, this should be safe...

my_list[-1] << 5     # ... but this fails

如果 list 是协变的, 那么上面的代码就能通过类型检查了, 而这显然不太合适. 所以干脆 list 就是不变的了.

为什么泛型是方括号?

因为就实现而言, 实际上像 List, Union 这种泛型, 不过就是一个定义了特殊的 __getitem__ 函数的单例罢了. 这就是为什么要使用方括号, 因为方括号在 Python 中是一个 “运算符”, 使用方括号可以避免在 Python 中加入新的语法.

于是你就能看到:

Union.__getitem__(int) #==> <class 'int'>
Union[(int)]           #==> <class 'int'>
Union[int]             #==> <class 'int'>

Union[()]              # TypeError: Cannot take a Union of no types.

为了防止有读者向我一样不知道往方括号里塞元组是啥回事, 现在来一个快速复习.

__getitem__ 方法理论上可以接受任意对象, 如果中括号内有多个对象, 那么会被打包成一个元组; 也就是说 [1, 2, 3][(1, 2, 3)] 是完全相同的.

唯一不等价的情况是 [()][], 前者是空元组, 后者是语法错误

class ShowGetItem:
    def __getitem__(self, x):
            return x

o = ShowGetItem()
o[1]      #==> 1
o[1, 2]   #==> (1, 2)
o[(1, 2)] #==> (1, 2)
o[1:2]    #==> slice(1, 2, None)
o["2"]    #==> '2'
o[()]     #==> ()
o[]       # SyntaxError: invalid syntax

顺带一提, 空元组 () 的类型是 Tuple[()].

Nonetype(None)

None == type(None)                  #==> False
Union[None] == Union[type(None)]    #==> True
Tuple[None] == Tuple[type(None)]    #==> True
List[None] == List[type(None)]      #==> True

def f(x: None):
    return x

def g(x: type(None)):
    return x

f.__annotations__                       #==> {'x': None}
g.__annotations__                       #==> {'x': <class 'NoneType'>}
f.__annotations__ == g.__annotations__  #==> False

这个神秘的行为来自于 PEP 483: Pragmatics:

Some things are irrelevant to the theory but make practical use more convenient. (This is not a full list; I probably missed a few and some are still controversial or not fully specified.)

其中提到: 当该处期待一个类型时, Nonetype(None) 有相同的作用.

数字塔简写

根据 PEP 3141, Python 里的数字类型有下面的子类型关系:

Number :> Complex :> Real :> Rational :> Integral

但是为了写起来爽, PEP 484 规定在类型检查上下文里, int 就代表 numbers.Integral, float 就代表 numbers.Real, complex 就代表 numbers.Complex. 不过很遗憾 fractions.Fraction 因为用的人少无缘此殊荣, 它并 能代表 numbers.Retional.

TypeVar 会自动放宽类型约束

请看下面的函数:

T = TypeVar('T')

def first(x: T, y: T) -> T:
    return x

在一开始, 我以为只有 xy 的类型严格相同(即 type(x) == type(y))才能通过类型检查. 不过事实上类型变量会自动放宽要求, T 会变成两个类型的最小公共子类型. 比如说:

a = first(1, '2') # 能通过类型检查, `a` 的类型是 `int | str`

类型变量的作用域

在 Python 层面, 类型变量的作用域跟普通的变量是一样的. 但是在用作类型检查时, 其在该上下文上有特殊的对类型检查器的作用域规则. 详见 PEP 484. 不过如果你写过一定数量的泛型的话, 这些规则都是自然而然的. 但是回顾这些规则的精确定义有助于你明白类型变量这玩意有多扯淡…

一个比较特殊的情况是, 内部类里不能用外部类的类型变量:

T = TypeVar('T')
S = TypeVar('S')

class Outer(Generic[T]):
    # 内部类泛型参数不能跟外部重名
    class Bad(Iterable[T]):       # Error
        ...

    # 内部类内部不能用外部类的泛型参数
    class AlsoBad:
        x: List[T] = None         # Also an error

    # 但是可以另起炉灶
    class Inner(Iterable[S]):     # OK
        ...
    
    # 然后使用时就这样用来达到泛型类的内部同样类型的泛型类的效果
    attr: Inner[T] = None         # Also OK

TypeVar的属性最终是泛型的属性

如题, 不管是协变/逆变, 还是限定, 这些属性的作用都是在用类型变量定义泛型的时候附加给 泛型 的, 而不是在类型变量被应用到泛型时起作用的. 具体可见下面的协变与逆变.

为什么 Python 要把泛型的属性放到类型变量来呢? 我猜是因为 Python 不想再引入一种用于引入类型参数的新语法. 因为之前规定的标注与泛型的实现, 所以想办法在标注里再设置参数位置的属性几乎就变得不可能, 从而不得不 “出此下策”, 搞出来一个单独的 “类型变量” 来. 实际上它完全应该被看做一种 “形参” 而不是 “变量”.

协变与逆变

考虑下面的代码:

class Human:
    ...
class Man(Human):
    ...

T_co = TypeVar('T_co', Human, Man, covariant=True)
T_contra = TypeVar('T_contra', Human, Man, contravariant=True)

class Base(Generic[T_contra]):
    ...

class Derived(Base[T_co]):
    ...

如果用记号 a -> b 表示 ab 的子类型, 那么有:

Base[Man]    <-  Base[Human]
    ^                   ^
    |                   |
Derived[Man] ->  Derived[Human]

两个向上的箭头是因为当泛型参数 T 相同时, Derived[T]Base[T] 的子类, 所以有一个子类型关系. 上面的向左的箭头是因为当时泛型 Base[T] 的第一个参数是一个带着逆变的类型变量, 所以 Base 对第一个参数逆变; 下面的向右的箭头是因为当时泛型 Derived[T] 的第一个参数是一个带着协变的类型变量, 所以 Derived 对第一个参数协变. 也就是说类型变量的协变/逆变属性最终是成为其定义的泛型的属性(比如 T_co 的协变最终是 Derived 的性质), 而与定义该泛型过程中被塞入的其他泛型的协变/逆变属性无关(T_co 带给 Drived 协变不会被 Base 定义时的 Base[T_contra] 的逆变影响, 即使 T_co 被应用于泛型 Base).

限定类型

# 限定 T 的类型必须为 complex 的子类型
T = TypeVar('T', bound=complex)

# 类似 Java 的 public<T extends complex> T add(T x, T y)
def add(x: T, y: T) -> T:
    return x + y
# 限定 T 的类型必须是 complex
T = TypeVar('T', complex)

# 类似 Java 的 public complex add(complex x, complex y)
# 此时如果调用 add(1, 2), 1 和 2 会被推断为 complex 而不是 int, 因为它们是 complex 的子类型
def add(x: T, y: T) -> T:
    return x + y

PEP 484 中给的例子:

from typing import TypeVar

AnyStr = TypeVar('AnyStr', str, bytes)
def concat(x: AnyStr, y: AnyStr) -> AnyStr:
    return x + y


class MyStr(str): ...
x = concat(MyStr('apple'), MyStr('pie'))
# x 的类型被推断为 `str` 而不是 `MyStr`
# 因为 AnyStr 带给 concat 的限定为 Text 或者 bytes
# 所以 MyStr('apple') 只能作为 str 来 "实例化" 泛型

Type 允许子类

class A: ...
class B(A): ...

def for_A(a_class: Type[A]): ...
for_A(B) # Accept

也就是说, for_A 的定义等价于:

T = TypeVar('T', bound=A)
def for_A(a_class: Type[T]): ...

只在类型检查期执行的代码

PEP 484 里描述了这样的用法, 给予了用户判断执行当前代码的是类型检查器还是解释器的手段:

import typing

if typing.TYPE_CHECKING:
    import expensive_mod

def a_func(arg: 'expensive_mod.SomeClass') -> None:
    a_var = arg  # type: expensive_mod.SomeClass
    ...

如果某些代码只是为了类型检查而存在, 可以像上面那样在 typing.TYPE_CHECKINGTrue 时再执行代码. 这个变量只会在执行类型检查期间为 True, 而在代码执行期间是保持 False 的. 同时, 为了保持 “标注是合法的表达式” 这个约束, 这种情况下在涉及块内引入的对象时只能写字符串形式的类型标注.

当 import 某个 module 代价过大, 或者是会产生循环引用时, 这个特性就非常有用了.

忽略类型检查

  • 对于变量, 使用 var # type: ignore
  • 对于普通函数或类, 使用 @no_type_check 装饰器
  • 对于装饰器函数/类本身, 使用 @no_type_check_decorator
  • 对于整个文件, 将 # type: ignore 置于整个文件头. 在此行前面仅允许有注释跟空行

Stub files

TODO

PEP 484