前言
用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
, 加上了subscript
的generic
就变成了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])
那么说T
对F
是协变的(Functor); 反之, 若:
当存在态射(a -> b), 总有态射(F[b] -> F[a])
那么说T
对F
是逆变的(反-Functor); 如果T
对F
既不是协变的也不是逆变的, 那它就是不变的.
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, 所以略.