前言

我已经使用 Python 超过 12 年,在国内可能是应用 Python 最广泛的前公司工作超过 6 年,接触无数 Python 项目,其中少的几千行,多的几百万行 Python 代码。除此之外我非常关注类型系统的发展,可以说我见证了类型系统从无到有,从有到优,发展到今天的整个过程,借着 Python 3.11 发布,就想到了这么个主题。

我从反对到支持

最有意思的是,刚有类型注解时我是坚决的反对者,而现在我是坚定的支持者。为什么呢?

有很多静态语言开发者吐槽 Python 经常引用的一句话是:

动态类型一时爽,代码重构火葬场

一直到现在对这句话我还是嗤之以鼻,我认为【代码重构火葬场】的根源还是开发者的能力和编程规范的问题,静态语言只是相对于动态语言,提供了门槛不让你犯错。而使用 Python 语言的开发者的上限和下限区别就太大了,这也是 Python 在国内发展缓慢的原因之一:优秀的 Python 工程师实在太少了。

从前公司离职前我印象里没有一个项目的代码是有类型注解的,尤其是那些上百万行的大型项目可以说完全没有类型注解,其中很多逻辑极为复杂,代码逻辑诡异,我甚至觉得以当时的 Python 类型系统并不能完美的支持前公司把代码都加上类型注解。在早些年,这些项目都是相对稳定迭代的,我认为如果团队的开发者对 Python 熟悉,有好的编程习惯和工作态度,在加上有一些工作流保证代码质量,没有类型注解不是什么问题。

以我阅读过很多优秀开源项目和认识一些非常优秀的 Python 工程师的经历来说,代码大面积重构一般难点在两个地方:

  1. 很多高效率、聪明的代码非常简短难懂,如果能力不够是很难理解和维护它的。
  2. 很多能力一般的开发者写的代码设计有问题、细节考虑不周,这样的代码在野蛮生长的过程中满足了产品开发进度要求,但是在不断地留坑。这些代码几经迭代,参数、逻辑非常混乱复杂,能力稍差的维护者不敢动它的逻辑。

所以长久的、从根本的解决代码的质量问题其实要编写可维护的代码,而不要滥用或者错误使用语言特性,尤其是不要炫技。

好,回到正题。我一开始反对 Python 引入类型系统,是因为我用的就是你 Python 这个动态语言的不受约束,写代码爽 (没写过 Python 你是真不知道有多爽),你别管我怎么传值,反正我能高效完成开发,也能利用例如标准库、元类、描述符、自省、IPython 等等语言特性和工具快速迭代,正常下班。结果你现在告诉我,你推荐我对参数、变量、返回值标注类型?你 Python 不想着提高你的运行效率还要求我使用静态语言的类型系统,那我为什么不直接用静态语言?隔壁 Golang 它不香吗?

我逐渐地「被动」接受和支持,是因为现在 Python 开发者能力的下限真的是一年不如一年,如果没有类型注解的约束,很多 Python 开发者写的代码真的一言难尽。我是从大概 17 年开始认为类型注解应该是一个好的商业应用的必选,在前公司我就深刻的感受到新来的很多工程师对 Python 的熟悉程度、写代码的能力等等越来越差,如果你关注微博和前公司的话,一定见过 #XX 崩了 #这个热搜。其实有大部分都是人为的问题,事实上,如果有一个好的类型注解支持其中大部分是可以避免的。

为什么需要类型系统

在这里先解释一下,文章会多次的使用【类型系统】、【类型注解】等词,你可能感觉很混乱。我觉得它们是不一样的,我认为【类型系统】是实现检查对特定类型的使用是否符合该类型的规范的系统,【类型注解】是 Python 语言特性,类型系统除此之外还有执行类型检查的工具(做类型检查器,Type Checker,本文提到的是 mypy,一会还会介绍还有其他的工具)。

Python 是一种动态类型语言,Python 解释器仅在代码运行时进行类型检查,并且变量的类型在其生命周期内是进行更改的:

In : a = 1

In : type(a)  # `type()`返回对象的类型
Out: int

In : a = 'string'  # 改变了变量的类型

In : type(a)
Out: str

In : def test(a):
...:     return a + 1
...:

In : test(1)  # OK
Out: 2

In : test('s')  # Error  因为字符串和数字不能相加
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In , in <cell line: 1>()
----> 1 test('s')

Input In , in test(a)
      1 def test(a):
----> 2     return a + 1

TypeError: can only concatenate str (not "int") to str

相比于静态语言(像 Java、C/C++ 和 Go 等)在编译期间就能发现并改进代码问题,动态语言直到运行时才会发现这类类型问题,所以就会出现低级错误把整站搞挂了这种直觉上让开发者不能理解和接受的事件。

现在 Python 社区的推荐的实践是「给 Python 代码标注类型,再配合静态检查工具,那么也会向静态语言那样在代码提交前就发现问题」,这样的方式已经在其他语言里面获得了成功,如 Javascript 到 TypeScript、PHP 到 Hack。

Python 这种动态语言在阅读代码时很考验编程经验,即便是再资深的 Python 工程师也需要通过代码了解变量的类型,而加了类型注解后,对于开发者理解和维护会容易很多。

另外一个需要类型注解的理由是它给 IDE(如 Pycharm、VS Code 等)提供了尽量多甚至是准确的信息,这对于形参的类型提示、检查、批量处理等操作来说如有神助。

接下来,按着时间线,来了解下 Python 类型系统的发展,看看类型注解都能帮助 Python 开发者做什么。

PEP 3107 – Function Annotations

从 Python 3.0 开始就加入了 PEP 3107 里面设计的新的语法「可以给函数参数和返回值注释」:

In : def test(a: 'this is str', b: 'this is int'):  # 形参冒号后面的就是注释
...:     ...
...:

In : test.__annotations__  # 注释信息存在__annotations__里面
Out: {'a': 'this is str', 'b': 'this is int'}

In : def test2(a: str, b: int):
...:     ...
...:
...:

In : test2.__annotations__
Out: {'a': str, 'b': int}

In : test2(1, '2') # OK 即便不符合注释内容也无所谓

In : def test3(a: str, b: int) -> int:  # 使用-> 后面对返回值注释
...:     return b * 2
...:
...:

In : test3('a', 3)
Out: 6

In : def test4(a: str, b: int) -> int:  # 返回值只是注释
...:     return a * 2
...:
...:

In : test4('a', 3)
Out: 'aa'

In : test4.__annotations__
Out: {'a': str, 'b': int, 'return': str}

这个语法里面的注释的值是表达式,所以可以是字符串、类名、类型名、变量等等,但这些注释并不附加没有任何语义,也不会做检查。

mypy

mypy 是作者 Jukka Lehtosalo 2012 年为了完成博士学位论文而做的,当时 Jukka 认为 Python 效率低下,且应该有健全的静态类型,所以它实现了这个 Python 的变种。注意此时 mypy 的定位并不是静态检查工具。

在 PyCon 2013 时,Jukka 做了<Mypy: Optional Static Typing for Python> 这个分享,之后和 Python 之父 Guido van Rossum (以下都简称 Guido) 对于它的课题以及 mypy 进行了交流,发现 Guido 也在思考类似的问题 (但是没有行动)。最终他接受了 Guido 的建议:

  1. 让 mypy 的语法和 CPython 兼容。
  2. 可以使用普通的 Python 解释器直接运行 mypy 程序。
  3. 加强 mypy 的类型检查器部分的实现。

对,到这里 mypy 就开始走向了静态检查工具的方向,如果早期你使用它就会发现它其实叫做mypy-lang,现在已经不在提lang,而是static analyzer或者lint tool

接着 Guido 邀请他访问 Dropbox (Guido13-19 年在 Dropbox)并最终给了 Jukka 工作机会。由于类型系统需要解决和讨论的问题还很多,入职后 Jukka 并没有专门从事 mypy 的工作,但是对 mypy 研究一直在进行。

Guido 的类型注解提案

在 Europython 2014 上 simplejson 作者 Bob Ippolito 做了<What can python learn from Haskell?> 的演讲,提到了一些类型方面的建议,之后 Guido、Jukka 和 Bob 进行了深入交流。其中「用 mypy 语法给函数注解」这个方案获得了 Guido 认可。

不久,Guido 在 Python 邮件组提交了提案:<Proposal: Use mypy syntax for function annotations>(延伸阅读链接 2)。

在这个最初的草案中,明确了把 mypy 作为一个类型检查的 linter,而不是作为编译器或解释器,并确定了类型注解的定位:

  1. 在运行时不能进行数据类型推断。
  2. 类型注解会被解释器当作注释丢弃掉。类型注解功能是为了提高开发者的体验而生的,所以不应该影响原有程序。

类型标注风格在一开始就已经确定了:

from typing import List, Dict

def word_count(input: List[str]) -> Dict[str, int]:
    result = {}  #type: Dict[str, int]
    for line in input:
        for word in line.split():
            result[word] = result.get(word, 0) + 1
    return result

形参input: <type>是语言支持时的语法 (Python 3), 而#type: type这种注释是当语言不支持语法的兼容用法 (Python 2)。

这个提案中还提到了很多内容,例如把 mypy 的 typing.py 文件拷贝到标准库、调整 PEP 3107 的注释方式等,就不挨个介绍了。在之后的 PEP 里面还有具体介绍最终的实现。

类型注解的 PEP 提案 (Python 3.5)

接着,Guido 和 Ivan Levkivskyi 的<PEP 483 – The Theory of Type Hints>(延伸阅读链接 3, 也就是类型注解理论) 和 Guido、Jukka 和Łukasz Langa 的<PEP 484 – Type Hints>(延伸阅读链接 4,类型注解最主要的 PEP) 提交了。这些 PEP 提案在 Python 3.5 实现了,所以从 Python 3.5 开始正式支持类型注解了。

先统一一下对的翻译。它直译「类型提示」,也有人翻译成「类型标注」,而我一般使用「类型注解」,主要是因为 Python 的中文官网是这么用的 (延伸阅读 5),所以我认为还是以官网用词为准。

虽然 PEP 的 id 更小,但是在我的理解 PEP 483 是 PEP 484 的理论补充,对于开发者来说,类型注解主要看 PEP 484。这个 PEP 的内容很多,我把它总结成如下几部分内容。

类型注解不会强制推行

PEP 明确提出,Python 将永远保留动态类型语言的特性,而类型注解将来也不会作为默认策略强制推行。走到现在,可以看到它的进化非常平缓,非侵入性的,如果你不关注可以完全忽略这部分内容。

确定了注解语法

如之前 Guido 在邮件组的草案一样,我就不重复了。另外一个重要语法是用中括号把类型括起来表现容器 / 泛型结构的元素类型:

In : from typing import *

In : List[int]
Out: typing.List[int]

In : Tuple[int]
Out: typing.Tuple[int]

In : Dict[str, int]  # 因为有键和值2个类型
Out: typing.Dict[str, int]

In : T = TypeVar('T')

In : Generic[T]
Out: typing.Generic[~T]

In : class Item:
...:     ...
...:

In : Sequence[Item]
Out: typing.Sequence[__main__.Item]

In : Set[Any]
Out: typing.Set[typing.Any]

泛型和 TypeVar

在之前的文章 Python 3.11 新加入的和类型系统相关的新特性: PEP 646 – Variadic Generics 已经介绍过了泛型和 TypeVar,这里不重复了。

用 Union 组合多个类型

当单个参数可以是多个类型时可以使用 Union 组合。不过在 Python 3.10 的 PEP 604 中提供了新的|语法,更 Pythonic,具体的可以看我之前的文章 Python 3.10 新加入的四个和类型系统相关的新特性: PEP 604: New Type Union Operator

Stub 文件

Stub (存根) 文件是包含类型注解的文件,这些注解仅供类型检查器使用,而不是在运行时使用。

Stub 文件通常后缀是pyi,你可以理解为在 Stub 文件中重新定义了一遍相关的函数 / 类 / 变量等内容的类型,而原来的.py源文件不受影响。

这个 Stub 机制之后还会专门说。

后记

Python 3.5 发布后,Guido、Jukka 等人组建了一个专门的专门研究 mypy,并且对它做了很多性能改进,在 Python 3.6 发布时 (也就是 16 年底),Dropbox 完成了超过 400 万行代码的类型注解,mypy 在各个团队里面迅速普及。当时他们还专门写了一篇文章介绍这个事情,这个是 Python 类型系统发展的里程碑事件了 (延伸阅读 16)。

Python3.6

Python 3 的大的 feature 除了类型系统就是 asyncio,Python 3.6 里面除了引入异步生成器、异步推导式以外还加入了非常好用的 f-string。这个版本是我心目中第一个可以用生产环境的 Python 3 版本。

不过这个版本里面对于类型注解相关的新特性只有<PEP 526 – Syntax for Variable Annotations>(延伸阅读链接 6)。在 Python 3.5 引入的类型注解主要是针对函数 / 方法的,而 PEP 526 是针对于变量的:

my_var: int  # 不带默认值
my_var: int = 10  # 带默认值
my_var = 5  # OK
other_var: int  = 'a'  # Rejected

some_list: List[int] = []
body: Optional[List[str]]

class BasicStarship:
    captain: str = 'Picard'               # 带默认值的实例变量
    damage: int                           # 不带不认知
    stats: ClassVar[Dict[str, int]] = {}  # 使用ClassVar就是类变量

Python 3.7

这个版本中主要有 2 个类型系统的修改。

PEP 560 – Core support for typing module and generic types

最初 PEP 484 中的设计是不会对核心 CPython 解释器进行任何和类型标注相关更改,完全由标准库 typing 和外部的 mypy 等静态检查工具来完成。但是可以想象这会存在潜在的限制,在很多特殊场景里面需要做很多 hack,存在一些不好解决的 bug,还有性能问题 (具体的可以看 PEP 内容,延伸阅读链接 7)。但是此时已经有大量的开发者在使用类型注解,所以官方决定解除这个限制,可以通过添加两个特殊方法__class_getitem____mro_entries__以便更好地支持泛型类型。

这 2 个新方法日常基本接触不到,就不介绍了。

PEP 563 – Postponed Evaluation of Annotations

当时的类型注解是在函数 / 变量定义时进行评估的,这就有了 2 个问题,第一个是类型注解是在模块导入时执行的,它的执行是需要有开销的,而第二个是向前引用 (Forward References) 的问题,我举个例子:

In : class Item:
...:     def __init__(self, id):
...:         self.id = id
...:
...:     @classmethod
...:     def get(cls: Item, id) -> Item:  # 期待返回Item类型的结果
...:         return cls(id)
...:
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In [1], line 1
----> 1 class Item:
      2     def __init__(self, id):
      3         self.id = id

Cell In [1], line 6, in Item()
      2 def __init__(self, id):
      3     self.id = id
      5 @classmethod
----> 6 def get(cls: Item, id) -> Item:
      7     return cls(id)

NameError: name 'Item' is not defined

这样用会报错,因为如果按照方法定义时计算的话,那个时候 Item 类还没有创建成功呢。

所以这个 PEP 是建议更改函数 / 变量注释的评估 (Evaluate) 时机,以便在函数 / 变量定义时不再对它们进行评估:会先在__annotations__以字符串形式保存在之后评估:

In : from __future__ import annotations

In : class Item:
...:     def __init__(self, id):
...:         self.id = id
...:
...:     @classmethod
...:     def get(cls: Item, id) -> Item:
...:         return cls(id)
...:

In : Item.get(1).id
Out: 1

In : Item.get.__annotations__
Out: {'cls': 'Item', 'return': 'Item'}  # 返回值被自动保存成了字符串类型

In : from typing import get_type_hints

In : get_type_hints(Item.get)
Out: {'cls': __main__.Item, 'return': __main__.Item}  # 别担心,typing提供方法获得正确的类型

在 Python 3.11 之前,解决这个问题的其中一个方案就是使用from __future__ import annotations(当然,还可以直接让返回值的标注为字符串)。

当时说这个 PEP 的评估设想会在 Python 3.10 作为默认的方案,但是在 Python 3.11 引入了新的 Self 类型更好的解决了这个问题,具体的可以看我之前写的: Python 3.11 新加入的和类型系统相关的新特性: PEP 673 – Self Type ,这个方案也就被抛弃了,Python 3.11 的会更新日志里明确说了「PEP 563 may not be the future」,这个计划已经被无限期的搁置。

Python 3.8

这个版本是类型系统的一次重大的更新,它主要包含如下几个新功能。

PEP 589 – TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys

新增的TypedDict是具有一组固定键的字典的类型提示,非常有价值。具体的在 Python 3.11 新加入的和类型系统相关的新特性: PEP 655 – Marking individual TypedDict items as required or potentially-missing

PEP 586 – Literal Types

之前定义参数或者返回使用的都是抽象的类型,而这个字面值类型可以直接定义一 (多) 个具体的可选值:

from typing import Literal, Union


def accepts_only_four(x: Literal[4]) -> None:
    pass

accepts_only_four(4)   # OK
accepts_only_four(19)  # Rejected
accepts_only_four(2 + 2)  # Rejected  这就是字面量哈,需要直接写值,不能通过计算


def open(path: Union[str, bytes, int],
         mode: Literal["r", "w", "a", "x", "r+", "w+", "a+", "x+"],
         ):
    ...


open('1.py', 'r')  # OK
open('1.py', 'xx')  # Rejected
open('1.py', 'b')  # Rejected


union_var: Literal[Literal[Literal[1, 2, 3], "foo"], 5, None]  # 其实这就是一个字面量值的组合罢了

union_var = 5  # OK
union_var = 'foo'  # OK
union_var = 1  # OK
union_var = [1, 2]  # Rejected

最后那个union_var其实就是个演示,实际工作中用处不大。

PEP 591 – Adding a final qualifier to typing

这个 PEP 定义了 Final 限定符,可以通过 final 装饰器或者 Final 作为类型注解。它用在:

  1. 被声明的方法不能被重载
  2. 被声明的类不能被继承 (子类化)
  3. 被声明的属性或者变量不能被重新设值。

这个 PEP 比较好理解,直接粘贴 PEP 里面的例子一看就懂了:

from typing import final, Final

@final
class Base:
    ...

class Derived(Base):  # Error: Cannot inherit from final class "Base"
    ...

RATE: Final = 3000

class Base:
    DEFAULT_ID: Final = 0

RATE = 300  # Error: can't assign to final attribute
Base.DEFAULT_ID = 1  # Error: can't override a final attribute

这个我没实际用过,总之如果你不希望某个方法 / 函数 / 类 / 属性 / 变量等内容的类型在运行过程中被修改就可以使用它。

PEP 544 – Protocols: Structural subtyping (static duck typing)

鸭子类型(duck typing)在程序设计中是动态类型的一种风格。 在这种风格中,「当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子」。

所以在 Python 中编写接收特定输入的函数时,我们只需要关心该函数输入的行为、属性,而不是该函数输入的显式类型:

class Duck:
    def quack(self) -> str:
        return "Quack."


def sonorize(duck: Duck) -> None:
    print(duck.quack())


sonorize(Duck())

在上述例子里,sonorize函数并不关心形参 duck 的类型,只要它有quack方法就可以让函数正常执行。

在过去,类型注解只支持上述直接方案 (可以传入当前类 Duck 或者其子类),这个 PEP 提供了Protocol来对鸭子类型进行支持。那么只需要判断是否有同样的结构就可以了,这种类型叫做Structural subtyping。看个例子:

from typing import Protocol


class Quacker(Protocol):
    def quack(self) -> str:
        ...


class OtherDuck:
    def quack(self) -> str:
        return "QUACK!"


def sonorize2(duck: Quacker) -> None:
    print(duck.quack())


sonorize2(Duck())
sonorize2(OtherDuck())

定义类时只需要继承 Protocol 就可以声明一个接口类型,当遇到接口类型的注解时,只要接收到的对象实现了接口类型的所有方法,即可通过类型注解的检查。所以DuckOtherDuck都可以作为参数传给sonorize2

如果你学过 Golang 的接口,会更好理解这个 PEP 的内容。mypy 官网列出了很多使用 protocol 的例子,链接是延伸阅读 9。

Python 3.9

这个版本中只有一个主要的类型系统的新特性,就是<PEP 585 – Type Hinting Generics In Standard Collections>。

原来注解使用的 Collection 类型 (列表、字典、集合、元组、collections 模块内的结构等等) 需要从 typing 模块显示的 import,举个例子:

from collections import Counter
from typing import List, Dict, Counter as CounterType


l : List[int] = [1, 2]
dct: Dict[str, int] = {'key': 10}
c: CounterType[str] = Counter('abbddx')

而现在这些类型已经原生支持泛型了,可以直接当做类型用了:

l2: list[int] = [1, 2]
dct2: dict[str, int] = {'key': 10}
c2: Counter[str] = Counter('abbddx')

这样非常方便。

Python 3.10

这个版本有四个新的特性,我之前专门写过: Python 3.10 新加入的四个和类型系统相关的新特性 ,具体的可以看原文。简单说一下:

  1. PEP 604: New Type Union Operator。可以使用 | 组合不同的类型。
  2. PEP 613: TypeAlias。「类型别名」这个类型可以帮助分辨是 TypeAlias,还是普通的赋值。
  3. PEP 647: User-Defined Type Guards。当某个参数类型本来可以符合多个类型,但是在特定的条件里可以让类型范围缩小。
  4. PEP 612 – Parameter Specification Variables。新增的 typing.ParamSpec 帮助我们方便【引用】位置和关键字参数,而这个 PEP 另外一个新增的 typing.Concatenate 是提供一种添加、删除或转换另一个可调用对象的参数的能力。

Python 3.11

刚刚发布的版本,这个版本有五个新的特性,我之前专门写过: Python 3.11 新加入的和类型系统相关的新特性 ,具体的可以看原文。这个就简单说一下:

  1. PEP 646 – Variadic Generics。可变数量的泛型类型,之前介绍的 TypeVar 是单个泛型,而这次引入了数量不确定的泛型类型 TypeVarTuple。
  2. PEP 673 – Self Type。解决前面提到的向前引用的问题,替代 PEP 563 成为解决这个问题的新方案。
  3. PEP 675 – Arbitrary Literal String Type。LiteralString 可以表示任意的字符串字面值,不像前面的 typing.Literal,只能规定几个对应的确定的值,灵活性太差。
  4. PEP 681 – Data Class Transforms。实现了一种把普通类的一些和标准库 dataclasses 相似的行为的类型检查自动转换的方案。
  5. PEP 655 – Marking individual TypedDict items as required or potentially-missing。可以明确 TypedDict 内各个键值的类型是可选还是必选。

Python 的发展史目前就到这里了,相信未来很有很多路要走,我们继续期待吧。接着说一点和类型系统相关的主题。

类型注解代码存放分发方案

类型注解通常是直接在源码上加,但是也有相当多的项目使用 Stub 文件把代码和注解分开,这里继续展开 Stub 文件的存放分发方案。

受「懒」、「不喜欢」、「兼容性考虑」或者「不认可类型」注解等等原因影响,我们日常使用的大部分库是没有注解的的,知名项目的情况越来越好,但是一些相对受众少不太知名的项目类型注解没有或者极少。

为此缓解这个问题,社区提供了 Library stub 机制,也就是 PEP 561 (延伸阅读链接 11)。Stub 文件为库的公共接口定义类型注解,使静态检查器可以覆盖到对应库的使用。这样可以在第三方引入库这个角度缓解开发者的负担,也间接提供了很多范例帮助开发者快速熟悉和理解类型注解,甚至可以作为借鉴。

这部分通过不同的知名项目来了解一下这类文件的存放方案,从而了解这个机制。

1. 将它们与代码放在同一目录中

这个方式是最简单的,开发者和静态检查工具可以容易的发现,当然在开源项目中见得不多,主要场景是私有的代码库。日志库 loguru 就是这样的:

 ll loguru/__init__*
-rw-r--r--  1 weiming.dong  staff   626B Oct 27 23:20 loguru/__init__.py
-rw-r--r--  1 weiming.dong  staff    14K Oct 27 23:20 loguru/__init__.pyi

loguru 库使用__init__.py暴露接口,所以它把想要注解的都放在了loguru/__init__.pyi里。

2. 上传到 PYPI

另外一个方式就是把 Stub 文件独立作为一个包上传到 PYPI,可以在需要时安装或者更新它。把类型注解和原始代码完全分离,这样的好处是不会影响原来的代码逻辑,这样既不影响开发的进度和效率,也能尽量的覆盖类型标注。不过这主要是一个管理的问题,类型注解理论上永远都会滞后于代码迭代。

我觉得这个方案比较好的场景是完成注解的开发者不是源项目的开发者,更适合社区行为。例如 Django 就没有官方的类型标注支持,如果你需要的话可以使用 django-stubs ,它的目录结构和 Django 的一样,但只有.pyi文件标注类型。

3. 官方的 typeshed

社区提供了一个独立的项目 typeshed (https://github.com/python/typeshed),包含了Python标准库(stdlib目录)及一些第三方库(stubs目录)的stub文件。

如 Flask-SQLAlchemy、redis、requests 等知名项目。看一个例子了解下这套流程吧:

import requests

r = requests.post('https://httpbin.org/post', data={'key': 'value'})
print(r.json())

在一个新的项目中,准备用 requests 这个库,运行 mypy 会报如下错误:

 python -m pip install requests
 mypy reuquests_type.py
reuquests_type.py:1: error: Library stubs not installed for "requests" (or incompatible with Python 3.11)
reuquests_type.py:1: note: Hint: "python3 -m pip install types-requests"
reuquests_type.py:1: note: (or run "mypy --install-types" to install all missing stub packages)
reuquests_type.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
Found 1 error in 1 file (checked 1 source file)

它会非常明确的告诉你有这样的 Stub 文件,只是还没有安装,安装了再运行 mypy 就好了:

 python3 -m pip install types-requests
 mypy reuquests_type.py
Success: no issues found in 1 source file

这个types-requests库其实就是上面这个 typepushed 里面的 stubs/requests ,这是项目自己实现的一个上传 PYPI 功能,具体的可以看 README 页面的说明。

typing_extensions

Python 标准库的 typing 更新受限于 Python 版本发布,如果你想提前使用一些在未来新的版本才会有的特性,可以安装最新版的typing_extensions

 python3 -m pip install -U typing_extensions

例如现在 Python 3.11 才发布,Python 3.12 需要等到明年 10 月份。但是通过typing_extensions,你可以现在就体验:

  1. <PEP 698 – Override Decorator for Static Typing> 里面的 override。
  2. <PEP 696 – Type defaults for TypeVarLikes> 里面的 TypeVarParamSpecTypeVarTuple 的默认值。
  3. <PEP 695 – Type Parameter Syntax> 里面的 infer_variance

当一个版本发布后,下一个版本的那些 PEP 就或慢慢实现和完善,目前typing_extensions对 PEP 688、PEP 692 的功能还没实现,需要再等等。当然,受 CPython 解释器限制,也不是下一个版本的每个 PEP 都可以提前体验,可以关注社区对应讨论。

其他静态类型检查工具

前面我只用了 mypy 这一种官方提供的静态类型检查工具,它是最主流的,但是依然有另外几个工具也值得提一下。

注:下面这几个我只是列出来,生产环境使用 mypy 永远是第一选择,其他的如果有必要可以作为额外的检查工具。其他的如 Pydantic 这种在运行时强制执行类型检查的我并不赞同所以本文就不涉及了。

pyright

pyright 是微软开源的静态类型检查工具,它是用 TypeScript 编写的,它的特点主要是和 VS Code (毕竟也是微软家的) 的集成 (通过部分功能开源的 Pylance)。

pytype

pytype 是谷歌开源的静态类型检查工具,它没有 mypy 对类型的那么严格的要求,更宽松一些,另外是可以通过代码对没有注解的逻辑进行类型推测。在 PyCon 2019 时,开发者做一个演讲介绍和 mypy 的区别,可以看延伸阅读链接 14,其中举了 2 个例子:

# Case 1
def f():
    return "PyCon"


def g():
    return f() + 2019  # str和int相加其实会报错,pytype会报错,但是mypy不会


# Case 2
from typing import List
def get_list() -> List[str]:
    lst = ["PyCon"]
    lst.append(2019)
    return [str(x) for x in lst]  # mypy会报错,但是pytype会报错

另外我的感受是它的开发迭代更慢,对于社区的响应和支持差 mypy 很多。

pyre

pre 是 Facebook 开源的静态类型检查工具,它的存在感比较低。它的价值按官网说主要是比 mypy 快,因为在大型项目中 mypy 会非常慢,这会让本地的检查非常耗时,不过我暂时没有大型项目的经验这部分不了解。

pyre 另外一个功能是通过pyre infer对代码做自动的类型推断,可以直接修改源代码,不过我试了一下效率很差。目前没有可以自动做类型注解的能在生产环境中使用工具,还是人工更靠谱。

怎么让自己成为 Type Hints 专家?

在我的理解里面:

  1. typing 模块和 mypy 的官方文档都非常完善,也有对应的例子,熟读和理解它。
  2. 熟悉和类型注解的那些 PEP 提案。
  3. 在自己的项目或者公司小型的 1-2 个项目中实战一下。

我认为这样就可以足够了。

另外我特别推荐 Adam Johnson 的博客,里面有很多非常细的类型注解知识点的理解和案例,非常值得去阅读。

后记

这些年随着越来越多的库开始使用类型注解,类型注解越来越受到开发者的关注,未来也势必会变得流行。在最后,从一个 Python 开发的角度尝试说服大家成为类型注解的支持者。

从我的角度,看看当时我不喜欢类型标注的理由吧:

  1. 开发成本。必须承认给项目引入静态检查增加了学习和完成工作的成本,但是如果从长远得看,它能带来的收益是远大于开发者的付出。Python 不加注解写起来真的很爽,但是出现 bug 的几率高的太多了,即便是现在的我也会时常编写一些会有低级的、类型有关的错误的代码跑到服务器上,等报错了才发现,哎呀,这里没考虑到,然后紧急改一下,而使用类型注解可以在运行前就发现绝大部分这类问题。
  2. 降低了可读性。本来简洁的 Python 代码加了注解就变得很混乱,让你有一种写的不是 Python 的感觉。是的,使用类型注解是一种心理的转变,这个是需要过程适应的,其实如果你写过短短几天,你就会习惯在读代码时忽略对应的类型注解。当然如果你正在关注它的类型,那么类型注解反而直接给你答案而不会自己读逻辑去总结,这个角度反而是提高了代码可读性。

我以前写的时候偶尔会遇到对于一些复杂逻辑注解非常难表达的,即便表达出来也会有注解的内容非常冗长、不易理解、类型不准确、不灵活等问题,这个时候特别容易怀疑这个类型注解到底行不行,很劝退。不过后来都解决了,其实是自己对类型注解了解的不够深刻,所以要善用TypeVar+boundTypeDictProtocolGenerics@overload等等特性。

代码目录

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

延伸阅读

  1. http://mypy-lang.blogspot.com/2012/12/why-mypy-can-be-more-efficient-than.html
  2. https://mail.python.org/pipermail/python-ideas/2014-August/028618.html
  3. https://peps.python.org/pep-0483/
  4. https://peps.python.org/pep-0484/
  5. https://docs.python.org/zh-cn/3/glossary.html
  6. https://peps.python.org/pep-0526/
  7. https://peps.python.org/pep-0560/
  8. https://peps.python.org/pep-0563/
  9. https://mypy.readthedocs.io/en/stable/protocols.html
  10. https://peps.python.org/pep-0585/
  11. https://peps.python.org/pep-0561/
  12. https://github.com/microsoft/pyright
  13. https://github.com/google/pytype
  14. https://www.youtube.com/watch?v=yFcCuinRVnU&t=2300s
  15. https://github.com/facebook/pyre-check
  16. https://dropbox.tech/application/our-journey-to-type-checking-4-million-lines-of-python
  17. https://adamj.eu/tech/