写点什么

【Python】关于 Type Hints 你应该知道这些

用户头像
zhujun
关注
发布于: 2021 年 02 月 25 日
【Python】关于 Type Hints 你应该知道这些

Typing 是 Python 近几个版本中迭代比较快的模块,也许你和我一样想要了解它的用法、适用场景与发展趋势。本文对 Typing 模块进行一次简要的介绍,抛砖引玉,希望能对你有所帮助。

在我们开始 Typing 模块之旅前,需要先了解一些关于 Type System 的知识。

类型系统简介

在编程语言中,类型系统(Type System)是一个“逻辑”上的概念,因为“物理”上计算机不能区分一块内存是整型、字符串、可执行指令还是指针,它看到的只是一些字节,比如这个简单的 C++程序:

// typing.cc// g++ -g typing.cc#include <iostream>
int main() { const char* data = "Hell"; unsigned int idata = 1819043144; std::cout << data << " " << idata << std::endl;}
复制代码

使用调试器运行程序

在调试器中,可以看到地址 0x0000000100001f54 (常量字符串 "Hell")与地址 0x00007ffeefbff744(无符号整型变量 1819043144)的前 4 个字节是完全一样的,计算机不知道它是什么类型的值。

但是在编程语言中,我们可以对整型进行数值操作,比如:idata /= 2,却不能对字符串 data 做同样的操作,否则编译器会报错:

 typing.cc:9:10: error: invalid operands to binary expression ('const char *' and 'int')    data /= 2;    ~~~~ ^  ~1 error generated.
复制代码

这正是类型系统的功劳,它赋予内存值相应的“类型”标签,不但标示了这部分内存的性质(是整型、浮点数、字符串还是 class 等),还标示了可对它进行的操作。也就是说,类型系统不但定义了类型,还定义了规则,更重要的它还检查这些规则是否被忠实的执行:某一操作符是否可以应用在特定变量上、类型值之间的转换是否安全等等。实际上,类型系统的主要目的就是发现程序中潜在的 bug,使我们不必在暗无天日的“地狱“(Hell)中进行编程。

在不同的问题领域中,类型系统有不同的含义,但是一般来说,它主要就是检查对特定类型的使用是否符合该类型的规范

执行类型检查的工具叫做类型检查器(type checker),类型检查器可以集成在编译器或解释器内,也可以是独立的第三方工具。类型检查可以在编译时(compile-time),也可以在运行时(runtime),相应的,编程语言可分为静态类型语言与动态类型语言两种,C、C++、Java 等是静态类型语言,Python、JS 等是动态类型语言。

静态类型检查并不运行程序,而是靠分析源代码来验证程序的类型安全性,检查失败则中止编译;动态类型检查则在程序运行时根据包含的类型信息进行检查,如果检查失败则产生一个 fatal 信息中止程序。

静态类型检查可以尽早的发现程序的 Bug,而避免在运行过程中止程序,像 Python 这样的动态类型语言正是缺少了静态类型检查的过程。在业界有很多项目是为动态类型语言增加静态类型,像 TypeScript 和 Closure Compiler 之于 JavaScript,Hack 之于 PHP,以及 PHP 7.0+ 等等,它们主要的动机都是提高代码的可读性和鲁棒性,使之可以应用于大型项目。

除了检查程序 Bug,类型系统有时也包含性能优化、multiple dispatch 或自动生成文档等功能,请参考维基百科 Type System 获取更多信息。

Typing 模块

可读性与鲁棒性

Python 是一门动态类型语言,不支持静态类型检查。在使用 Python 时,我们虽然也知道每个对象有类型,但不需要特别担心它的限制,因为 Python 的自由和宽容,使我们只要专注在待解决的问题上。

这正是 Python 高效开发的优势,不过也会产生较多随意的代码,尤其在大型的项目中,由多人参与完成,可读性往往是一个很重要的指标,它决定代码的可复用性,可维护性甚至代码的寿命。

而增加了注解的代码可读性更好,Union[str, int]表示只接受字符串或整型的类型:

# 3.8 and early
from typing import Union, Dict
class Config: def __init__(self, init_vals: Dict[str, Union[str, int]]): self.data: Dict[str, Union[str, int]] = init_vals
def register_item(self, key: str, value: Union[str, int]) -> None: self.data[key] = value
复制代码

在 3.9 以后,标准库中的内建集合(如 list、dist、tuple 等)已经支持 Generics;而在 3.10 (约 2021 年底),可用 X | Y 来代替 Union ,不需要再显式的引用 typing 模块:

# from 3.10+
class Config: def __init__(self, init_vals: dict[str, str|int]): self.data: dict[str, str|int] = init_vals
def register_item(self, key: str, value: str|int) -> None: self.data[key] = value
复制代码

使用 Type hints 的主要目的就是为了引入静态类型检查,在上线之前增加一道防线,及早的发现 Bug,提高代码的鲁棒性。另外我觉得适当限制的代码比随意书写的要更加健壮,因为 Type hints 可以强制程序员思考接口的规范:接口是作者与使用者之间的契约,也是作者的一种承诺,添加了类型信息的接口则是更明确的承诺,让作者和使用者尽早的思考输入与输出信息,减少误用。

下面我就分别介绍一下 Typing 模块和 Mypy

Typing 模块

Python 在 3.0 中 加入了 Function annotations(PEP 3107)语法,程序员可以为函数参数和返回值添加注解(annotation),注解可以是任何表达式(expression),在函数定义时这些表达式被求值(Python 3.10 之前的版本,Python 3.10 之后采用延迟求值,详见 PEP 563),过程类似于参数的默认值,求值的结果被保存在函数的 __annotations__ 属性中。在运行阶段 Python 解释器像处理代码注释一样忽略这些注解,不会处理它们。

自问世以后,Function annotations 最主要的用途就是作为类型提示(Type hints),给静态类型检查器或者 IDE 使用,而 PEP 3107 只定义了语法,没有定义语义,所以 Python 提出的 Type Hints(PEP 484 针对函数注解,3.5)和 Variable Annotations(PEP 526 针对变量注解,3.6),官宣了用于 Type hints 的标准与工具,并在后面几个版本持续的进行完善,形成了目前的 Typing 模块。

Typing 为静态类型检查提供了支持,但它不是强制的规范,它的进化也不是激进的,而是平缓的,始终把选择的权利留给程序员。程序员可以自由的选择动态类型还是静态类型,随时可以退回到动态类型的领域。就像 PEP 484(Type Hints)中强调的:

It should also be emphasized that Python will remain a dynamically typed language, and the authors have no desire to ever make type hints mandatory, even by convention.


作为一种多范式编程语言,Python 将永远保留动态类型语言的特性,而 Type hints 将来也不会作为默认策略强制推行。

下面列举一些 Typing 基本用法:

Build-in Types

支持内建的 int、float、str、bool、bytes、object 等类型,这些类型可以直接写到 annotation 上。

对容器的支持:

# for Python 3.9+ & mypy 0.800+l1: list[int] = [1, 2, 3]t1: tuple[int, int] = (1, 2)d1: dict[str, int] = {"a": 3, "b": 4}
print(l1[0])print(t1[1])print(d1["b"])
d1["a"] = "c" # error: Incompatible types in assignment (expression has type "str", target has type "int")print(d1)
# for Python 3.8 and earlierfrom typing import List, Tuple, Dict
x: List[int] = [1]x: Tuple[int, str, float] = (3, "yes", 7.5)x: Dict[str, float] = {'field': 2.0}
复制代码

Python 3.9 之后,内建的容器类型直接支持 Generics。

Any

typing.Any 可以与任何类型兼容,这包含两层含义:

  • 任何类型的值都可以赋值给 Any 类型的变量,就好像任何类型都是 Any 的子类。

  • Any 可以赋值给任何类型的变量,就好像 Any 是任何类型的子类。

注意,这是 Any 和 Object 不同的地方,Object 并不符合第二点。

在不能确定类型的地方我们可以使用 Any:

from typing import Any
x: Any = some_function()
复制代码

Any 让我们可以把静态类型和动态类型的代码混合在一起。

Union & Optional

Union[X, Y] 代表 X 类型或者 Y 类型,比如 Union[int, str] 代表可能是 int,也可能是 str

from typing import Union, Iterable
def f(s1: Union[Iterable[str], str]) -> None: s2 = [s1] if isinstance(s1, str) else s1 for s in s2: print(s)
f("hig") # OKf(["h", "i", "g"]) # OKf(123) # error: Argument 1 to "f" has incompatible type "int"; expected "Union[Iterable[str], str]"
复制代码

而 Optional[T] 则是 Union[T, None] 的别名。

Protocol and structural subtyping(static duck typing)

判断两个类型是否兼容,有两种方式:

  • B 是否是 A 的子类,如果 B 继承于 A,则 B 可以应用在 A 所适用的任何地方。

  • B 与 A 是否有同样的结构:比如包含一样的函数或者成员等。

第一种基于类继承关系,比较常见,被称为 Nominal subtyping;第二种称为 Structural subtyping,又叫 duck typing。Typing 在 3.5 只支持 Nominal subtyping 静态检查,从 3.8 以后开始通过 Protocol 支持 Structural subtyping 的静态检查(PEP 544)。

比如我们可以定义一个 Serializable 的 Protocol 来表明对象是否支持序列化:

import jsonfrom typing import Iterable, Protocol, Dict
class Serializable(Protocol): def serialize(self) -> str: ... # Empty function body
class Config: def __init__(self, data: Dict[str, str]): self.data: Dict[str, str] = data
def serialize(self) -> str: return "CNF " + json.dumps(self.data)
def save_all(s: Iterable[Serializable]) -> None: with open("/tmp/some_file.dat", "a+") as fp: for d in s: fp.write(d.serialize() + "\n")
save_all([ Config({"a": "1", "b": "2"}), # ....])
复制代码

另外 Python 提供很多预定义的抽象类来支持常见的 Structural,比如:

# collections.abc.Iterable[T], for-loopdef __iter__(self) -> Iterator[T]
# collections.abc.Iterator[T], Iteratordef __next__(self) -> Tdef __iter__(self) -> Iterator[T]
# collections.abc.Sized, len(x)def __len__(self) -> int
# ...
# visit https://mypy.readthedocs.io/en/stable/protocols.html# for more predefined protocols
复制代码

Type 与 Class

注意:上述的 Any、Union、List、Tuple 和 Dict 是 Type,不是运行时的 Class,这也是初学者最容易误解的地方。Type 只在静态类型检查时可用,在运行时没有意义,不可以继承与实例化,也就是说不存在一个叫 Any 的类,下面的代码将会报错:

from typing import Any, Union
c: Any = Any() # TypeError: Cannot instantiate typing.Anyd = Union[int, str](1) # TypeError: Cannot instantiate typing.Union
复制代码

Typing 中定义的类型非常多,这里就不一一列出来了,除了上面的类型,Typing 和 Mypy 的文档中还介绍了 Generics(泛型)、Final(常量与 Final Class)、Literal(字面值)等多种类型工具,每一种都对应一种应用的场景,请参见相关文档进一步了解。

Mypy

如果说 Typing 中定义了用于 Type hints 的标准,那么 Mypy 就是使用这些标准的 类型系统 与 静态检查器。

Mypy 实现了 Gradual Typing,所谓 Gradual Typing 是一种既可以用动态类型,也可以用静态类型的类型系统,由 Jeremy Siek 在 2006 年提出,Siek 认为在大型系统中,有些问题适合用动态类型解决,有些问题则适合用静态类型解决,具体使用哪一种方式由程序员来决定。在支持 Gradual typing 的编程语言中,程序员不需要切换语言就可以选择使用哪种类型系统,提高了开发效率。关于 Gradual Typing 的详细介绍请见附录链接。

安装与运行

使用 pip 安装 Mypy:

> pip install mypy
> mypy program.py
复制代码

除了命令行中,还可以在 IDE 中配置使用 Mypy:

在 Pycharm 中使用 Mypy,点击菜单的 "Run" —— "Edit Configurations"

添加一个新的运行配置 "run-mypy-checker",指定运行 Mypy 模块和其他相关参数:

测试代码:

def add_ints(i1: int, i2: int) -> int:    return i1 + i2
s = 1s += 1print(add_ints(s, s))s = "abc" # Error, Incompatible types in...print(add_ints("1", 2)) # Error, Argument 1 to "add_ints" ...
复制代码

需要指出的是,使用 Mypy 要对代码填加 type hints,没有 type hints 的代码 Mypy 一般不会报错。

运行 “run-mypy-checker”,得到如下错误提示:

注意,我们并没有为变量 s 注解类型,只是为它做了初始化赋值,Mypy 会根据初始化值进行类型推导(type inference),确定为 int 类型。

Library stub

在开发中不可避免的要使用各种库,为了兼容性考虑,这些库大部分是没有 type hints 的,为此 Python 提供了 Library stub 机制来弥补这个空白(PEP 561)。Library stub 为库的公共接口定义类型注解,使静态检查器可以覆盖到对库的使用。

另外 Python 提供了一个独立的项目 typeshed(地址见附录),包含了 Python 标准库及一些第三方库(如 requests、tornado、protobuf 等)的 Library stubs。另外一些第三方库自己提供了 stubs,比如 django-stubs(https://pypi.org/project/django-stubs/

下面是标准库中 json.dumps 的 stub 定义:

# from https://github.com/python/typeshed/blob/master/stdlib/json/__init__.pyi
def dumps( obj: Any, *, skipkeys: bool = ..., ensure_ascii: bool = ..., check_circular: bool = ..., allow_nan: bool = ..., cls: Optional[Type[JSONEncoder]] = ..., indent: Union[None, int, str] = ..., separators: Optional[Tuple[str, str]] = ..., default: Optional[Callable[[Any], Any]] = ..., sort_keys: bool = ..., **kwds: Any,) -> str: ...
复制代码

关于如何创建自己的 stub,请参考附录地址

Mypy 的实用建议

  • 小型项目不建议使用 Typing,因为增加 annotation 可能会拖累开发进度;

  • 对于已有代码库,不建议一次性的把所有代码都加上 annotation,建议采用逐步的、渐进的方式进行改造,先从代码中的一个模块开始,改好一个再改下一个,逐步的增加 annotations,改好一个再改下一个;

  • 一些公共的代码或工具性质的代码可以先改,因为它们的功能相对明确、复用度也比较高,改完也能更早发现 bug,产生更加鲁棒的代码,享受静态检查的好处;

  • 使用一致的 Mypy 版本和配置进行检查,保证对团队中所有成员产生一致的输出,可以通过配置文件、运行脚本把检查的过程结合到 CI 中进行,成为上线的一部分。这一点很重要,因为一致性保证团队使用统一的类型检查标准,毕竟 Typing 也是不断迭代的功能;

  • 对于不想检查的部分,可以加注释 # type: ignore 来忽略检查,# type: ignore 可以加在 import library 或者 file 头部(忽略对整个文件的检查)

  • 逐步增加检查的严格级别,最后可以使用-disallow_untyped_defs-strict

Mypy 是独立运行的工具,你可以用它做静态检查,遵从它的提示,也可以完全忽略它的输出,直接运行程序。Mypy 提供了丰富的配置项与命令行参数,可以根据项目的具体需求进行定制检查,从指定要检查的目录、管理 import 到关闭某些限制都可以进行定制。

Type hints 的现状与趋势

Typing 的迭代速度

近些年很多动态语言都加入了对类型的支持:TypeScript(Microsoft)和 Flow(FaceBook) 为 JS 引入了静态类型检查(附加的编译器,把带 type hints 的代码编译为 js 代码),PHP 7.0+ 和 Hack 都原生支持类型。Python 也不例外,不断的加强对 Typing 的支持,下面是我从 Python ChangeLog 中整理的,自 Python 3.0 以来涉及到 Typing 的所有 PEP:

  • 3.0

  • PEP 3107 -- Function Annotations

  • 3.5

  • PEP 484 -- Type Hints

  • PEP 483 -- The Theory of Type Hints

  • 3.6

  • PEP 526 -- Syntax for Variable Annotations

  • 3.7

  • PEP 563 -- Postponed Evaluation of Annotations

  • PEP 560 -- Core support for typing module and generic types

  • PEP 561 -- Distributing and Packaging Type Information

  • 3.8

  • PEP 544 -- Protocols: Structural subtyping (static duck typing)

  • PEP 586 -- Literal Types

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

  • PEP 591 -- Adding a final qualifier to typing

  • 3.9

  • PEP 585 -- Type Hinting Generics In Standard Collections

  • 3.10(2021.11 ?)

  • PEP 613 -- Explicit Type Aliases

  • PEP 604 -- Allow writing union types as X | Y

这些 PEP 主要包括 2 个方面:

  • 完善 Typing 中的标准与工具(大部分的 PEP)

  • 优化底层与库对 Typing 的支持(PEP 560/PEP 561/PEP 563)

除了这些已形成标准的功能,还有大量性能优化和功能增强,限于篇幅就没列出来。

可以看出来从 3.5 开始,Python 不断加强了对于 Typing 的迭代速度。

学习成本

Typing 内容繁杂,不亚于一门新的语言,但是好在 Python 社区已经提供了完善的文档和方便的工具,使程序员可以很容易的上手,如果你只是想引入静态检查的过程,学习曲线不会很陡峭,熟悉一下 Typing 支持的类型和一些静态检查器(如 Mypy) 的用法就可以实践了。如果你有静态语言(比如 Java、C++、C# 等)的开发经验那会更快。

当然如果你想要了解 Typing 背后的技术细节,不但想知其然,还想要知其所以然,那就要学习更多的内容并花费较多的时间了,可以参照下一节列出的 PEP index 了解更多信息。

社区争论

去年 11 月在 twitter 上有一场关于 Typing 可用性的争论,反对者(Thomas Wiecki @twiecki,也是 PyMC Labs 的 CEO)批评 typing 是 net negative:使代码可读性下降,并让人有高效率的错觉,而且在他的实践中 Typing 没有发现过任何一个 Bug。

这个批评可以说非常尖锐,并且附议者众多,引得 Python core developer Łukasz Langa(@llanga) 连发 10 推进行回应:

Langa 的主要观点包括:

  • 如果你使用 IDE(PyCharm 或者 VS Code)的 Code completion,那你已经在受益于 type annotation 了,如果你想要 IDE 对你的代码做同样的检查,那么就为它添加 annotation。

  • 少量的注解不会发现很多 bug,只有大量的代码被注解后才会产生明显效果。

  • Typing check 有助于检查边界、程序员遗漏或者疏忽的情况。

  • Microsoft 在 TypeScript 上的努力与成功,同样的,MS 在 VSCode 上为 Python 开发静态检查功能,而且雇佣了包括 Guido van Rossum 在内的 Python 基金会的开发人员。

  • 在很多项目中已经见到了静态类型带来的成果(首先是 FaceBook 的 Hack,然后是 Instagram 在项目中使用 Python annotation,EdgeDB 中 type 覆盖达到 70%),好处是实实在在的。

  • Typing 甚至可以生成更好的文档。

感兴趣的读者可以到 https://twitter.com/llanga/status/1331922351659872258 做进一步了解。

批评意见并非全无道理,只增加少量的 Type Hints,确实不如预期那样立竿见影,这部分需要慢慢的积累,不过近些年随着越来越多的库开始使用 Typing (比如 numpypandasscikit-learn 等) 以及 Library stubs 的提出,这种情况正在逐步的改善。另外对于很多人来说,增加 Type hints 不仅仅是个成本问题,可能还是个心理上转变,对于习惯了动态语言风格的 Python 程序员,一时想要适应类型系统的限制也许没那么容易,同样需要一个过程。

我认为静态类型检查的大方向是没有问题,动态语言开发效率是高,但是当项目逐渐变大时,其维护成本也越来越高,这是也很多大公司(如微软、FB 等)积极引入静态类型系统的原因。面对激烈的编程语言竞争环境,Python 官方不断加强对 Typing 的支持并不让人意外,不进则退。虽然静态类型编程比较 boring,但是可以让代码可验证,bug 更少,可读性与可维护性更好,那为什么不给它一个机会呢?

我相信在工具和底层的不断支持下,Typing 会在得到广泛的应用,毕竟我们的目标是写出健壮的代码,而静态类型可以帮我们达到这个目的。

References


Photo by Sigmund on Unsplash


有什么想法和意见请留言告诉我~


关注个人公众号 WhatHowWhy 获得及时内容


发布于: 2021 年 02 月 25 日阅读数: 23
用户头像

zhujun

关注

心怀理想,接受现实 2021.02.20 加入

中老年程序员,老师傅

评论

发布
暂无评论
【Python】关于 Type Hints 你应该知道这些