PEP 526 -- 变量注解的语法

Meta

状态

本 PEP 已经被 Python 监管裁决者 (Guido) 临时性的通过了,更多通过的信息参阅 https://mail.python.org/pipermail/python-dev/2016-September/146282.html

读者注意

这个 PEP 是在单独的仓库 https://github.com/phouse512/peps/tree/pep-0526 中起草的。

这个想法的初步的讨论在 https://github.com/python/typing/issues/258

在公开的论坛上提出异议之前,请至少先阅读一个这个 PEP 文末的关于被拒绝想法的部分。

摘要

PEP 484 引入了类型提示,也称为类型注解。虽然它重点聚焦于函数的注解,但它也引入了通过类型注释来注解变量的概念:

1
2
3
4
5
6
7
8
9
# 'primes' 是一个列表,其中的元素都是整数
primes = [] # type: List[int]

# 'captain' 是一个字符串 (注意:初始化变量是错误的)
captain = ... # type: str

class Starship:
# 'stats' 是一个类变量
stats = {} # type: Dict[str, int]

这个 PEP 的目标是添加一个对 Python 变量 (也包括类变量和实例变量) 注解的语法来替代通过注释表达式注释。

1
2
3
4
5
6
primes: List[int] = []

captain: str # 注意:没有初始化值

class Starship:
stats: ClassVar[Dict[str, int]] = {}

PEP 484 明确指出类型注释旨在帮助在复杂的情况下进行类型推断,并且此 PEP 也不会改变此意图。然而,由于类型注释在实际中也被采用在类变量和实例变量上,因此这个 PEP 也会讨论这些变量的类型注解。

理由说明

尽管类型注释当前工作的很好,但实际上通过注释来传递类型信息有一些缺点:

  • 文本编辑器通常将类型注解和注释以不同的方式进行高亮。

  • 没有办法对未定义的变量进行类型的注解。一种方法是需要将其初始化为 None (例如: a = None # type: int) 。

  • 在条件分支中对变量的注解阅读很困难:

    1
    2
    3
    4
    if some_value:
    my_var = function() # type: Logger
    else:
    my_var = another_function() # 为什么这儿没有类型
  • 由于类型注释不是语言真实的一部分,如果 Python 脚本想要解析他们,就需要自定义的解析器来代替单一使用 AST

  • 类型注释在 typeshed (译者注:Python标准库和Python内置程序的外部类型注释) 中被大量使用,使用变量注解语法来代替类型注释进行迁移将会提高存根的可读性。

  • 在普通注释和类型注释一起使用的情况下,很难区分它们:

    1
    path = None  # type: Optional[str]  # 模块的路径
  • 除了尝试查找模块的源代码并在运行时解析它之外,不可能在运行时检索注释,至少可以说,这是不优雅的。

不是的理由

虽然提案在标准库中携带了 typing.get_type_hints 函数用来在运行时获取注解的函数,但变量注解并不是为运行时类型检查而设计的。第三方包应该为单独开发来实现该功能。

还应该强调的是,Python仍然是一个动态类型语言,即使按照惯例,作者也没有将类型提示作为强制的打算。类型注解不应该与静态类型语言的变量声明引起混淆。注解语法的目的是用简单的方式为三方攻击指定结构化元数据。

本 PEP 并不要求类型检查器修改他们的检查规则。它只是提供了一种更易读的语法来替换类型注释。

规范

类型注解可以被添加到赋值表达式中或者单独对变量作注解来被第三方类型检查器使用:

1
2
3
my_var: int
my_var = 5 # 类型检查通过
other_var: int = 'a' # 会被类型检查器标记为错误,但是运行时不会报错

这个语法在 PEP 484 之外没有引入新的语法,所以以下三个表达式是等价的:

1
2
3
var = value  # type: annotation
var: annotation; var = value
var: annotation = value

下面我们具体来说说类型注解的语法在不同的上下文以及运行时的影响。

我们依然建议类型检查器能够解释注解,但是是否遵循这些注解不是强制的 (这与 PEP 484 中的建议是一致的) 。

全局变量和局部变量的注解

全局变量和局部变量的类型注解可以通过以下方式进行:

1
2
some_number: int           # 变量没有初始值
some_list: List[int] = [] # 变量有初始值

允许没有初始值对变量做类型注解能够在条件分支中更容易使用

1
2
3
4
5
sane_world: bool
if 2 + 2 == 4:
sane_world = True
else:
sane_world = False

请注意,尽管允许元组打包的语法,但不允许在元组解包时对变量的类型做注解:

1
2
3
4
5
6
7
8
9
10
# 带有变量注解的元组打包语法
t: Tuple[int, ...] = (1, 2, 3)
# 或
t: Tuple[int, ...] = 1, 2, 3 # 这个语法在 Python3.8+ 的版本才支持

# 带有变量注解的元组解包语法
header: str
kind: int
body: Optional[List[str]]
header, kind, body = message

省略初始值会导致变量未初始化:

1
2
a: int
print(a) # 会报出 NameError 错误

然而,对局部变量做注解将会导致解释器始终将其作为局部变量:

1
2
3
4
def f():
a: int
print(a) # 会报出 UnboundLocalError 错误
# 如果将 a:int 注释掉,那么会报出 NameError 错误

同如下的代码一样:

1
2
3
4
def f():
if False:
a = 0
print(a) # 会报出 UnboundLocalError 错误

虽然重复的类型注解会被忽略。但是静态类型检查器可能会对同一个变量的不痛类型注解报出警告:

1
2
a: int
a: str # 静态检查器可能会报出警告

类变量和实例变量的注解

类型注解同样可以被用在类层级或类方法中的类变量或实例变量中。特别是,没有值的标记 a: int 允许注解在 __init____new__ 中初始化的实例变量。推荐的语法如下:

1
2
3
4
class BasicStarship:
captain: str = 'Picard' # 带默认值的实例变量
damage: int # 没有默认值的实例变量
stats: ClassVar[Dict[str, int]] = {} # 类变量

这儿的 ClassVar 是一个在 typing 模块中定义的特殊的类,用来向静态类型检查器标识这个变量不应该在实例中被设置。

请注意,无论嵌套级别如何, ClassVar 都不能包含任何类型变量:如果 T 是一个类型变量,那么 ClassVar[T]ClassVar[List[T]] 都是不合法的。

这需要用更多的例子来说明,在下面这个类中:

1
2
3
4
5
6
7
8
9
10
11
class Starship:
captain = 'Picard'
stats = {}

def __init__(self, damage, captain=None):
self.damage = damage
if captain:
self.captain = captain # 否则保持默认

def hit(self):
Starship.stats['hits'] = Starship.stats.get('hits', 0) + 1

stats 旨在作为一个类变量 (跟踪许多不同比赛的统计数据) ,而 captain 是一个实例变量,在类中设置了默认值。这种差异可能不会被类型检查器发现,因为它们都在类中进行了初始化,但 captain 仅仅被用作实例变量的默认值,而 stats 是一个真正的类变量,它在所有的实例中被共享。

由于两个类型都恰好在类中被初始化,使用被 ClassVar[...] 包裹的类型注解来标记它们对于区分类变量和实例变量是很有用。在这种情况下,类型检查器可以对在实例中相同名称的变量意外赋值做出标记。

例如,对前面所讨论的类做注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Starship:
captain: str = 'Picard'
damage: int
stats: ClassVar[Dict[str, int]] = {}

def __init__(self, damage: int, captain: str = None):
self.damage = damage
if captain:
self.captain = captain # 否则用默认值

def hit(self):
Starship.stats['hits'] = Starship.stats.get('hits', 0) + 1

enterprise_d = Starship(3000)
enterprise_d.stats = {} # 类型检查器会在这儿标记一个错误
Starship.stats = {} # 这样是合法的

为了方便 (和约定) ,实例变量可以在 __init__ 或其他方法中做注解,而不是在类中做注解。

1
2
3
4
5
6
from typing import Generic, TypeVar
T = TypeVar('T')

class Box(Generic[T]):
def __init__(self, content):
self.content: T = content

注解表达式

注解的目标可以是任何有效的单一赋值目标,至少语法上是如此的。 (取决与类型检查器如何处理):

1
2
3
4
5
6
7
8
9
10
class Cls:
pass

c = Cls()
c.x: int = 0 # 将 c.x 注解为 int
c.y: int # 将 c.y 注解为 int

d = {}
d["a"]: int = 0 # 将 d["a"] 注解为 int
d["b"]: int # 将 d["b"] 注解为 int

注意,即使是带括号的名称也被视为表达式,而不是简单的名称:

1
2
(x): int      # 将 x 注解为 int,但是 (x) 会被编译器视为表达式
(y): int = 0 # 同上

不允许注解的地方

尝试在同一函数作用域中对 globalnonlocal 变量做注解都是不允许的:

1
2
3
4
5
6
def f():
global x: int # SyntaxError

def g():
x: int # 同样也会报 SyntaxError
global x

原因是 globalnonlocal 不是他们自己的变量,因此,类型注解属于拥有变量的作用域。

只有单个赋值目标和单个右侧的值被允许。此外,注解不能在 forwith 中声明的变量,他们可以提前进行注解,类似于元组解包:

1
2
3
4
5
6
7
a: int
for a in my_iter:
...

f: MyFile
with myfunc() as f:
...

在存根文件 (stub file) 中的变量注解

由于变量注解比类型注释可读性更好,所以在存根文件中的方式对所有 Python 版本都是首选的 (包括 Python2.7 ) 。注意,存根文件不会被 Python 解释器执行,因此使用变量注解不会导致错误。类型检查器应该支持所有 Python 的版本在存根文件中的变量注解。例如:

1
2
3
4
5
# file lib.pyi
ADDRESS: unicode = ...

class Error:
cause: Union[str, unicode]

首选的代码注解风格

对模块层级的变量、类和实例变量、局部变量的注解应该在冒号后面有一个空格。在冒号前面没有空格。如果存在右侧的赋值,那么等号的两边都应该有一个空格。例如:

  • 正确的风格

    1
    2
    3
    4
    5
    code: int

    class Point:
    coords: Tuple[int, int]
    label: str = '<unknown>`
  • 错误的风格

    1
    2
    3
    4
    5
    code:int  # 冒号后面没有空格
    code : int # 冒号前面有空格

    class Test:
    result int=0 # 等号两边没有空格

标准库和文档的修改

  • typing 模块中添加了一个新的的协作类型 ClassVar[T_co] 。它只接收一个应该是有效类型的参数。并用来注解不应该被设置为类实例的类变量。这是通过静态检查器来限制的,而不是运行时来限制。查阅 classvar (译者注:本 PEP 的其他位置) 部分了解关于 ClassVar 的示例与说明,并可以查看 rejected 部分了解到 ClassVar 背后的更多原因。
  • typing 模块中的 get_type_hints 函数将被扩展,以便能够像函数一样在运行时获取到模块或类的类型注解。注解将会通过变量或参数与类型提示所组成的有序映射的形式返回,并评估向前引用。在类中将会返回一个在方法解析顺序 (MRO) 中构建注解的映射 (也可能是 collections.ChainMap ) 。
  • 使用注解的推荐指南将被添加进文档中,包括本 PEP 以及 PEP 484 中明确描述的教学概述。此外,用来将类型注释翻译为类型注解的脚本将会在发布后与标准库分开。

类型注解在运行时的效果

在对局部变量进行注解时,解释器会认为变量是一个局部变量,即使还没有进行赋值。局部变量的注解不会进行求值。

1
2
def f():
x: NonexistentName # No error.

但是,如果是模块或类层级的变量就会进行类型求值。

1
2
3
x: NonexistentName  # Error!
class X:
var: NonexistentName # Error!

此外,在模块或类的层级下,如果被当做注解的对象是一个简单的名称,那么会将名称与注解以有序映射的形式存储到模块或类(非私有)中的 __annotations__ 属性中。如下示例:

1
2
3
4
5
6
7
8
9
from typing import Dict
class Player:
...
players: Dict[str, Player]
__points: int

print(__annotations__)
# prints: {'players': typing.Dict[str, __main__.Player],
# '_Player__points': <class 'int'>}

__annotations__ 是可写的,所以以下代码是允许的

1
__annotations__['s'] = str

但尝试将 __annotations__ 更新为有序映射之外的其他类型,就会得到一个 TypeError

1
2
3
class C:
__annotations__ = 42
x: int = 5 # raises TypeError

(请注意,对 ``annotations` 的赋值是罪魁祸首,Python 解释器会毫无疑问地接受它,但后续的类型注释期望它是 MutableMapping 并且会失败。)

在运行时获取注解的推荐方式是使用 typing.get_type_hints 方法;和所有双下划线的属性一样,任何未注明的 __annotations__ 使用都可能在没有警告的情况下被破坏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from typing import Dict, ClassVar, get_type_hints
class Starship:
hitpoints: int = 50
stats: ClassVar[Dict[str, int]] = {}
shield: int = 100
captain: str
def __init__(self, captain: str) -> None:
...

assert get_type_hints(Starship) == {'hitpoints': int,
'stats': ClassVar[Dict[str, int]],
'shield': int,
'captain': str}

assert get_type_hints(Starship.__init__) == {'captain': str,
'return': None}

需要注意的是,如果无法在静态环境中找到注解,那么 __annotations__ 字典将不会被创建。此外,将注解可用于局部环境的价值并不能抵消在每次函数调用时都必须创建和填充注解字典的成本。因此,函数级别的注解不会被求值或存储。

注解的其他用途

使用这个 PEP 的 Python 将不会反对:

1
2
alice: 'well done' = 'A+'
bob: 'what a shame' = 'F-'

所以它将不会关心类型注解之外会引起报错的求值,类型检查器遇到时会将其标记,除非使用 # type: ignore 或者 @no_type_check 来禁用。

然而,由于 Python 不关心类型是什么,所以上述代码片段如果存在于全局模块或类层级中。那么 __annotations__ 中将会存在 {'alice': 'well done', 'bob': 'what a shame'} 的键值对。

将这些注解存储起来也能用于其他的目的,但通过这个 PEP ,我们明确的建议将类型注解作为首选用途。

拒绝/推迟的提案

  • 我们到底该不该引入变量注释?在 PEP 484 的批准下,变量注释已经以类型注释的形式存在近两年了。他们被第三方类型检查器 (mypy,pytype,pycharm 等) 和使用类型检查的项目大量的使用。然而,使用注释语法有很多如理由说明中的缺点。本 PEP 并不涉及是否需要类型注解,而是关于此类类型注解的语法应该是怎样的

  • 引入一个新关键字:选择一个好的关键字非常难,例如,不能使用 var 因为这是一个太常见的变量名;如果我们想把它用作类变量或全局变量,就不能使用 local 。其次无论我们选择什么,我们都需要 __future__ 来导入

  • 使用 def 作为关键字:这个提案将会是:

    1
    2
    def primes: List[int] = []
    def captain: str

    问题在于对于一些长久的 Python 程序员 (或工具) 来说, def 的含义是定义一个函数,并且这也不能让变量的定义变得更明确 (尽管这是很主观的)

  • 使用基于函数的语法:建议使用 var = cast(annotation[, value]) 来对变量的类型做注解。尽管这个语法缓解了一些类型注释中的问题,如,AST 中没有注释,但它没有解决其他例如可读性和运行时可能导致开销的问题。

  • 允许元组解包使用注解:这将会导致歧义,不清楚这个声明的含义:

    1
    x, y: T

    到底是 xy 的类型都是 T ,还是我们预期 T 是 元组的两个元素的类型饭后分配给 xy ,也可能 x 的类型是 Any ,而 y 的类型是 T (如何在函数的签名中出现了,则后者意味着什么) 。我们禁止这个做法而不是让读者来猜测,至少现在是这样。

  • 用括号的形式注解 (var: type) :它作为前面提到关于歧义的补救措施在 python-ideas 上被提出,它被拒绝的原因是这种语法会很麻烦,好处很小,并且可读性会变得更低。

  • 允许在链式赋值中进行注解:它和元组解包一样会导致歧义和可读性的问题。例如:

    1
    2
    x: int = y = 1
    z = w: int = 1

    yz 的类型到底是什么这是不明确的,而且第二行语法解析也会很麻烦。

  • 允许在 withfor 声明中进行注解:这被拒绝了,因为在 for 中会很难发现实际的可迭代对象,而在 with 中,会与 Cpython 中的 LL(1) 解析器混淆。

  • 在函数定义时计算本地注解:这一点被 Guido 拒绝了,因为注解的位置强烈表明它与周围代码处于相同的范围内。

  • 在函数作用域内存储变量的注解:在本地提供注释的价值不足以大幅抵消每次函数调用时创建和填充字典的成本。

  • 不带赋值的对变量进行初始化:这是在 python-ideas 中提出的,使用 x: int 来将 x 初始化为 None 或者额外添加一个例如 JavaScript 中的 undefined 的特殊常量。然后,在语言中额外添加一个单例值需要在代码的其他任何地方进行值的检测。所以,Guido 对这个提案直接说 No 了。

  • 在 typing 模块中添加一个 InstanceVar :这是一个多余的,因为实例变量比类变量更常见,更常见的用法理应成为默认的用法。

  • 允许仅在方法中对实例变量做注解:它的问题在于,许多 __init__ 方法在初始化实例变量之外还会做很多其他的事情,这会让读者很难找到所有的实例变量的注解。 有时,__init__ 会被加入到更多辅助的方法中,这会让找到它们更难了。在类中将实例变量的注解放在一起,可以帮助第一次阅读代码的人更快速的找到他们。

  • 对类变量使用 x: class t = v 的语法:这将会依赖更复杂的解析器,并且也会让简单的语法高亮器对 class 这个关键字困惑。无论如何,我们都需要使用 ClassVar 来将类变量存储到 __annotations__ ,所以我们选择了更简单的语法。

  • 完全不用 ClassVar :这是因为 mypy 似乎在不区分类变量和实例变量下没有办法很好的工作。但类型检查器可以通过额外的信息做一些有用的事情,例如通过实例对类变量错误的赋值的提醒 (或者创建一个实例变量附带掉类变量) 。它还可以标记具有可变默认值的实例变量,这是一个众所周知的危险行为。

  • 使用 ClassAttr 来代替 ClassVar :为什么 ClassVar 更好的主要原因是:许多东西都是类属性,例如方法、描述器等。但从概念上来说,只有特定的属性才是类变量 (也可能是常量) 。

  • 不要对注解求结果,把它们当做字符串:这将与函数的注解始终会被求值的语法相冲突。虽然将来可能会重新考虑这个提议,但 PEP 484 已经将其作为一个单独的 PEP。

  • 在类的文档中对变量类型进行注解: 许多项目已经使用了各种文档格式约定,但往往缺乏一致性,而且一般还不符合 PEP 484 注释语法。此外,这还需要一个特别复杂的解析器。这反过来又违背了 PEP 的目的–与第三方类型检查工具合作。

  • __annotations__ 实现为一个描述器:提出该建议是为了禁止将 __annotations__ 设置为空字典或者为 None。Guido 拒绝了这个提议,认为没有必要;相反如果当 __annotations__ 不是一个映射的时候,对其更新值将会抛出一个 TypeError 异常。

  • 以同样的方式对 global 或 nonlocal 做裸注解:被拒绝的提案更倾向于在函数体中出现不带赋值的注解时不进行任何评价。相比之下,PEP 暗示如果目标注解比单个名称更复杂,则应该在函数体内对其左边部分进行求值,以确保注解已被定义。例如,在本例中:

    1
    2
    def foo(self):
    slef.name: str

    slef 应该被求值,这样如果它未被定义 (本例中很可能会出现这个情况) ,错误就会在运行时发现。这与在此进行初始化值的情况更相似,因此有望能减少意外的发生。 (同样需要注意的是如果检测的目标是 self.name (拼写正确的情况) ,优化编译器只要能证明 self 肯定会被定义,就没有义务评估 self)

向后兼容性

此 PEP 完全向后兼容

实现

在 Python 3.6 中的实现可以在 https://github.com/ilevkivskyi/cpython/tree/pep-526 (译者注:该文件已无法被找到) 这个 GitHub 仓库中找到。

版权

本文档已置于公共领域。