前言

用Python写了个Scheme解释器, 代码. 记录一下Python的类型标注要怎么用, 有什么坑.

话说回来这好像是我第一篇技术性文章…?

不务正业实锤

本文假定您已有支持编译期泛型的静态类型语言的编程背景, 比如C++, Haskell之类的.

基础

Q & A

类型标注是什么

是Python在3.5版本以后推出的一种船新语法, 可以让你在代码(而不是注释)里比较自然地标记上你期望这个变量/函数参数/函数返回值是什么类型.

它会让我的程序变慢吗

不会. 因为Python在运行时压根不会管这些标注. 换句话讲, 这个语法就是一个官方钦定的标识变量/函数类型的注释

不会有人写Python了还在意性能吧?

类型标注有什么用啊

有许多linter已经支持了基于类型标注的静态代码查错, 最出名的一个是mypy. 也有许多IDE(比如PyCharm, VSCode)都可以获取类型标注里的信息来给你提示.

所以它能让你:

  • 尊享静态类型语言的IDE补全体验
  • 免去一部分啰里啰唆的注释: 好的类型声明即是好的文档

如果普通的Python写起来像是在把史莱姆捏成合适的形状的话, 类型标注就是能帮你把史莱姆撑起来的骨头

基本语法

大体上就是<id>: <type> (= <var>)这个样子

# 可以在普通的变量上加
a: int
b: str = '我是str'

# 可以在函数参数上加, 返回类型用箭头表示
# *arg 跟 **kw 不能加
def f(a: int, b: str='a str', *arg, **kw) -> None:
    return 

# lambda 的参数跟返回的东西都不可以加
x = lambda x: x

为什么lambda表达式不能加类型标注的原因可以看这个: Stackoverflow: Is it possible to type hint a lambda function?

简单来讲就是如果要加上类型标注的话lambda的语法可能要修改成这个样子以区分类型标注的冒号跟参数列表/lambda体之间的冒号:

lambda (x: str) -> int: ...

然而这是个破坏性的改动, 而且看起来一点都不’pythonic’, 所以就没动.

那么什么东西可以写在类型标注那里(<type>)呢?

  • type对象
  • typing里出来的各种东西, 下面简称标注对象
  • None

typing里面的东西主要有:

  • TypeVar: 用TypeVar定义出来的泛型类型参数
  • SpecialForm: Any, NoReturn, Union
  • GenericAlias: 常用数据结构的泛型化别名, 比如: List, Tuple, Dict
  • ProtocolMeta: Support*, 主要看你类有没有对应的__xxx__

基本上后者就是前者的弱化版, 只保留了前者标记类型和作为构造函数的功能, 而不能当作isinstance的参数使用.

typing模块的文档在这里.

常见用法

以下代码均假设你在开头已经from typing import *

Python 内置的各种的type

基本上就是int, float, str, byte之类的老面孔, 没什么好说的.

内置容器类型的GenericAlias

包括: List[T], Tuple[T1, T2, ...], Dict[KT, VT], Iterable[ReturnT]

方括号里的东西叫subscript, 加上了subscriptgeneric就变成了Subscripted generics

其实基本上就是C++里那些什么vector<T>, map<KT, VT>里的T

int_list: List[int] = [2, 1, 3, 4] # 现在int_list的类型就是只能放int的list

tw_list: List[List[int]] = [int_list] # 套娃类型, subscript里可以放任何能在<type>里放的东西

# 下面三个的类型是等价的, 都是一个可以放任何东西的list
any_list: List[Any] = ['asd', [12, 34, {'2': '3'}]]
any_list_2: list = any_list
any_list_3: List = any_list 

和类型与积类型

构建和类型可以用Union[T1, T2, ...], 表示这个变量可以是T1或者T2

构建积类型可以用Tuple[T1, T2, ...], 表示这个变量是一个元组, 里面的元素类型分别为T1, T2

Python 函数的多返回值其实就是返回一个元组, 所以它的返回类型标注要使用Tuple

特别地, Optional[T]等价于Union[T, None]

int_or_str: Union[int, str] = '123'
int_or_str = 123 # mypy不会报错

int_and_str: Tuple[int, str] = (123, '123')

def reciprocal(n: float) -> Optional[float]:
    if n == 0:
        return None
    else:
        return 1 / n

def make_a_pair(n: num) -> Tuple[int, str]:
    return n, str(n) # 等价于 return (n, str(n))

函数类型标记

mypy会检查你函数是不是在所有if分支里都会返回你标注的返回类型. 如果你觉得它很烦, 你可以直接在函数的末尾抛个异常, 这样它就不会管你了.

换句话说, 类型标注不能标注这个函数会抛出什么异常. (精Java狂怒)

def I_am_tired() -> List[int]:
    raise RuntimeError("I don't want to return anything.")

没有返回值的函数可以标注返回值为None. 永远不会返回的函数可以标注返回值为NoReturn. 不标注返回类型的函数mypy默认它返回Any.

def assuming_len(l: list, length: int) -> None:
    if len(l) != length:
        raise RuntimeError('invaild length')

def kill_myself() -> NoReturn:
    exit()

# 等价于: def return_something_as_I_like() -> Any:
def return_something_as_I_like():
    n = random.randint(1, 3)
    if n == 1:
        return 1
    elif n == 2:
        return '2'
    else:
        return (1, '2')

类型推断与转换

使用cast(目标类型, 要转换的变量)可以把变量从一个类型转换到另一个类型. cast不进行运行时类型检查.

换句话讲, cast相当于掐着linter的脖子指鹿为马

当然也会有linter错把马认为是鹿的时候 (mypy是马鹿!)

a: int = 1
b: str = cast(str, a)

# 目标类型参数可以塞<type>能塞的东西而不仅仅是type对象
al: List[int] = [1, 2, 3]
bl: List[str] = cast(List[str], al)

对于某个特定的变量, mypy的推导能力还是挺强的:

def to_int(a: Union[int, str]) -> int:
    if isinstance(a, int):
        return a # 这里a的类型就是int了
    else:
        return int(a) # 这里a的类型就是str了

但是对于表达式就不能通过isinstance判断类型了:

def first_to_int(a: Union[List[int], List[str]]) -> int:
    if isinstance(a[0], int):
        b = a[0] # 这里b还是Union[int, str]
        return b
    else:
        return int(a[0]) 

另外对于列表之类的容器类型, 如果在声明后面紧接着跟一个赋值用的for循环的话, mypy就能推断出那个容器的类型. (其实一般写上也没什么问题)

Python 3.8之后, typing多了一个蛮好用的Special Form: Literal, 它可以限定一个变量取特定的某些值, 比如:

TypeTag = Literal['A', 'B', 'C']

a: TypeTag = 'D' # 报错

并且根据你在Literal里设定的字面量类型, a可以塞到更广泛的, 比如接受一个str作为参数的函数中去. 如果字面量类型里有多个类型的值, a会被扩展为Union[...]

对于在代码中写死的变量而言, 它的类型会被推导为字面量类型:

l = [1, 2, 3] # l 的类型被推导为List[Literal[1, 2, 3]]

但是如果你手动指定, 它就会是你手动指定的类型. 在本篇文章中为行文方便~~(我懒)~~, 在不引起迷惑的情况下上面l这种变量的类型一般写为List[int]

泛型

基本

首先, 跟C++完全不一样的第一点是, Python的泛型参数不是跟函数/类型硬性绑定的, 也不是某种语法之类的, 而是一种对象, 在被绑定了之后就能作为标注对象放在标注那里.

比如在C++里我们会这么写:

template<typename T>
T abs(T x) {
    return x < 0 ? -x : x
}

这个类型变量T显然是只能在abs里用的.

但是在Python里我们要这样写:

# 构造类型变量
T = TypeVar('T')

a: T # 现在T没被绑定, 所以这行在mypy下会报错: Type variable "t.T" is unbound

# 将T用在某个函数的参数上以将T绑定到这个函数里面
def abs(n: T) -> T:
    v: T = n # 在函数体里就可以用T来标注变量类型了
    return -n if n < 0 else n

b: T # 脱离了函数体之后T就被解绑了, 现在T又是没有被绑定的了, 这行也会报错

TypeVar的第一个参数必须要跟要绑定类型变量的名字相同, 要不然mypy会跟你抱怨.

第二点不同的是, C++模板的类型检查只有在模板实例化的时候才会检查; 而Python的泛型更像Java和Haskell, 会要求类型至少满足在泛型函数中使用到的操作. 所以其实上面的Python泛型函数其实是会在mypy中报错的:

error: Unsupported operand type for unary - ("T")
error: Unsupported operand types for > ("int" and "T")

所以其实我们要这么写:

T = TypeVar('T', int, float)

def abs(n: T) -> T:
    return -n if n < 0 else n

TypeVar后面的参数限定了它只能取的类型, 这里就只能塞type对象, 而不能塞标注对象.

所以如果你像做下面这种操作的话:

Explain = TypeVar('Explain')
DataWithExplain = TypeVar('DataWithExplain', Tuple[V, int], Tuple[V, str])

def extract_data(v: DataWithExplain) -> V:
    return v[0]

当然是不可以的. 得这样写:

Explain = TypeVar('Explain', int, str)
Data = TypeVar('Data')

def extract_data(v: Tuple[Data, Explain]) -> Data:
    return v[0]

另外, 虽然TypeVar可以限制能取的类型, 但是不能限制不能取的类型; Python的typing项目里有一个issue是关于这个的有一个关于支持这项功能的issue.

泛型与重载 (坑)

在C++里, 我们经常会玩带模板参数的模板偏特化.

template<typename T>
list<T> as_list(T a) {
    return list(1, a)
}

template<typename T>
list<T> as_list(list<T> a) {
    return a
}

在Python里你可能会想这样写:

T = TypeVar('T')

@overload
def as_list(l: List[T]) -> List[T]: ...
@overload
def as_list(l: T) -> List[T]: ...

def as_list(l):
    if isinstance(l, list):
        return l
    return [l]

关于这种先写声明再写实现的Python重载写法, 可以参考这个PEP 0484

简单来说就是对Python来讲, 后来的重名函数会直接覆盖前面函数的定义, 所以要把实现写在最后面, 然后手动isinstacne分配

这个代码本身是没有问题的…但是就是某些情况下达不到目的罢了

对具体类型已知的变量, 上面这个函数都能工作得很好, 比如:

l = as_list([2, 3, 4]) # l: List[int]
v = as_list(1)         # v: List[int]

a: Union[int, List[int]] = 1 # 此时a的类型会被规约为int, 所以虽然它被标注为Union, 但是其实它的类型是一个具体的int
b = as_list(a)               # b: List[int], 此时mypy还能选择合适的重载

但是不是所有Union类型的变量都能被规约到具体的类型, 比如在函数参数里的:

def f(c: Union[int, List[int]]) -> List[int]:
    t = as_list(c) # 这里的类型就会变成 Union[List[int], List[List[int]]]而不是List[int]
    return t

这时候mypy就不能选择到正确的类型. (写本文时用到的mypy版本是0.782)

也许你会想为什么我们不能直接这样写:

def as_list(l: Union[T, List[T]]) -> List[T]:
    return l if isinstance(l, list) else [l]

但是这样子写出来mypy并不会优先匹配最好的, 甚至在确定的类型中也不能得到期望的结果

l = as_list([2, 3, 4]) # 这里就会出问题: l: Union[List[List[int], int]
v = as_list(1)         # v: List[int]

a: Union[int, List[int]] = 1 # 此时a的类型会被规约为int, 所以虽然它被标注为Union, 但是其实它的类型是一个具体的int
b = as_list(a)               # b: List[int], 此时mypy还能选择合适的重载

def f(c: Union[int, List[int]]) -> List[int]:
    t = as_list(c) # 这里的类型还是 Union[List[int], List[List[int]]]而不是List[int]
    return t

或者你会说为什么不重载f写成这个样子:

@overload
def f(c: int) -> List[int]: ...
@overload
def f(c: List[int]) -> List[int]: ...

但是这意味着每一个用到as_list的函数都需要这样写一次重载, 这样我还不如手动内联呢:(

什么时候如果mypy能够优先选择匹配度高的函数重载或者可以限定类型变量T不取list类型这个问题就能被解决了.

泛型类型

通过继承Generic[T, ...]来将T, ...绑定到class里面.


X, Y = TypeVar('X'), TypeVar('Y')

class Pair(Generic[X, Y]):
    # 构造函数的返回值是None; 所有对象方法的self都可以不用标注
    def __init__(self, car: X, cdr: Y) -> None:
        self.car, self.cdr = car, cdr

需要注意的是, 类似于Java, 运行时的泛型参数类型会被擦除掉; 意思就是说:

a: Pair[int, int] = Pair(2, 3)

isinstance(a, Pair[int, int]) # 这里会报错

或者你也可以这么认为, 泛型类型对象加上subscript之后就是标注对象(或者GenericAlias)说了.

用带subscript的泛型类型来构造对象的话, 构造函数就是对应subscript的类型; 否则就是按类型变量决定之后构造出对象之后再确定对象的subscript.

NNPair = Pair[int, int] # 可以起别名; 真的就只是缩写, 没有特别的

a: NNPair = NNPair(2, [2, 3]) # 这个会报错: [2, 3]的类型不符合Pair[int, int]

b: NNPair = Pair(2, [2, 3]) # 这个会报错: Pair[int, List[int]]的类型不符合NNPair

协变, 逆变和… 不变(?)

协变的英文短一点, 是Covariance, co-variance.

逆变的英文长一点, 是Contravariance, contra-variance.

不变的英文基本上不会跟上面两个混起来, 是irvariance, ir-variance.

在Python里, 协变/逆变的属性是可调节的, 并且是绑定在类型变量上的. (好奇怪啊…)

范畴论理解

Python里的泛型类型F[T]可以看作一个Functor/反-Functor(是叫这名字吗…?)/啥也不是. 如果对任意两类型a, b:

当存在态射(a -> b), 总有态射(F[a] -> F[b])

那么说TF是协变的(Functor); 反之, 若:

当存在态射(a -> b), 总有态射(F[b] -> F[a])

那么说TF是逆变的(反-Functor); 如果TF既不是协变的也不是逆变的, 那它就是不变的.

Python理解

如果如果a是b的子类型, 那么F[a]是F[b]的子类型为真, 那T就是协变的;

如果如果a是b的子类型, 那么F[b]是F[a]的子类型为真, 那T就是逆变的;

如果T不协变也不逆变, 那T就是不变的;

例子

范畴论例子:

一个函数(a -> b), 固定a以b为参数, 那么b就是协变的; 固定b以a为参数, 那么a就是逆变的.

让我们考虑列表. 假如B是A的子类型(你可以假定class B(A)), C也是A的子类型.

class A: pass
class B(A): pass
class C(A): pass
只读列表是协变的
# 只读列表是协变的
CoT = TypeVar('CoT', covariant=True)
class RList(Generic[CoT]):
    def __init__(self, l: List[CoT]) -> None:
        self.l = l
    
    def get(self, idx: int) -> CoT:
        return self.l[idx]

# 考虑
a: RList[A]
b: RList[B]

# 因为所有的B都是A, 所以如果我们从RList里读出来一个B, 也没有什么关系, 总之把它当成A就可以了
# 所以所有的RList[B]应该都是RList[A]的子类型, 也就是说T对RList是协变的
只写列表是逆变的
ContraT = TypeVar('ContraT', contravariant=True)
class WList(Generic[ContraT]):
    def __init__(self, l: List[ContraT]) -> None:
        self.l = l
    
    def set(self, idx: int, val: ContraT) -> None:
        self.l[idx] = val

# 考虑:
a: WList[A]
b: WList[B]

# 假如我有一个WList[A], 我总能把它当作一个WList[B]; 因为我总可以把类型为B的对象往WList[A]里写进去;
# 所以所有的WList[A]都应该是WList[B]的子类型, 也就是说T对WList是逆变的
可读可写的列表是不变的
IrT = TypeVar('IrT')
class RWList(Generic[IrT]):
    def __init__(self, l: List[IrT]) -> None:
        self.l = l
    
    def set(self, idx: int, val: IrT) -> None:
        self.l[idx] = val

    def get(self, idx: int) -> IrT:
        return self.l[idx]

# 考虑
a: RWList[A]
b: RWList[B]

# 我能把RWList[A]当作RWList[B]吗? 不能. 我不能保证RWList[A]读出来的对象一定是B, 它可能是C
# 我能把RWList[B]当作RWList[A]吗? 不能. 我不能保证拿A往里面写时A的实际类型就一定是B, 它可能是C
# 所以T对于RWList是不变的

总结

不懂也没关系, 说明您的应用场景还不需要用到协变/逆变的概念. 多用用自然就会懂了.

杂项

循环引用?

如果我要在类里标注类自己这种类型怎么办?

答: 直接用字符串就可以了

class A:
    def get_self(self) -> 'A':
        return self

同样的方法还可以用来定义递归类型

ValueType = Union[int, str, 'Procedure']
Procedure = Callable[[ValueType], ValueType]

模块与包?

当你有这样一个目录结构:

project
└── code
    ├── main.py
    └── my_util.py

并且在main.py里想import util.py的时候, 你会发现一个很尴尬的事情.

如果你这样来:

# main.py
import my_util

那么mypy会找不到my_util.py, 因为同目录的文件并不在mypy找文件的路径里; 然后自动补全就gg了.

如果你这样来:

import .my_util

在你运行python -u main.py的时候又会报错:

ImportError: attempted relative import with no known parent package

这个错是因为当python直接执行某个文件的时候, 这个文件就会变成__main__模块的一部分. 那么如果你在这个文件里使用相对路径来引用的话, python就不知道你这个相对路径是相对于你目录还是__main__模块了, 所以就直接ban掉这种import了.

那么怎么办呢?

首先你需要先把代码目录弄到mypy的补全路径里去. 在VSCode的Pylance拓展中你需要加入这样一条设置:

"python.analysis.extraPaths": [
    "code" // 直接写你代码目录的名字, 不用加路径
]

其次你要解决这个相对路径的问题. 那你直接就用第一种写法就可以了. 只要你的文件不跟python标准库的重名, python在找完所有路径之后没发现这个名字就会去找同一目录下的文件, 问题就解决了.

杂项

Type类型

如果你需要某种类型的类型对象怎么办? 你可以使用Type GenericAlias:

T = TypeVar('T')
def safe_cast(obj: Any, cls: Type[T]) -> T:
    if isinstance(obj, cls):
        return cast(obj, cls)
    raise RuntimeError(f'Type not match: given: {type(obj).__name__}, expect: {cls.__name__}')

IDE配置

VSCode

您可以使用PyRight扩展搭配基础的Python扩展使用. 也可以直接使用微软"次世代"的Python插件Pylance.

如果您使用Pylance的话, 可能还需要去设置里开启:

"python.analysis.typeCheckingMode": "basic"

基本上都是开箱即用的.

PyCharm

由于本人没有PyCharm, 所以略.