Python 的 Mypy——高级用法

Mypy 可以检查超出简单 Python 类型的内容。

我的上一篇文章中,我介绍了 Mypy,这是一个在 Python 程序中强制执行类型检查的包。Python 本身是,并且永远会保持,一种动态类型语言。然而,Python 3 支持“注解”,这是一个允许您将对象附加到变量、函数参数和函数返回值的功能。这些注解会被 Python 本身忽略,但它们可以被外部工具使用。

Mypy 就是这样一个工具,并且它正变得越来越受欢迎。其理念是您在运行代码之前先在您的代码上运行 Mypy。Mypy 会查看您的代码,并确保您的注解与实际用法相符。从这个意义上说,它比 Python 本身严格得多,但这正是重点。

我的上一篇文章中,我介绍了一些 Mypy 的基本用法。在这里,我想在这些基础知识之上进行扩展,并展示 Mypy 如何真正深入挖掘类型定义,让您能够以一种让您对代码的稳定性更有信心的方式来描述您的代码。

类型推断

考虑以下代码


x: int = 5
x = 'abc'
print(x)

第一个定义了变量 x,并为其提供了 int 类型注解。它还将其赋值为整数 5。在下一行,它将字符串 abc 赋值给 x。在第三行,它打印 x 的值。

Python 语言本身对上面的代码没有问题。但是,如果您针对它运行 mypy,您会收到一条错误消息


mytest.py:5: error: Incompatible types in assignment
   (expression has type "str", variable has type "int")

正如消息所说,代码声明变量的类型为 int,但随后又为其分配了一个字符串。Mypy 可以弄清楚这一点,因为,尽管许多人认为 Python 是一种弱类型语言,但事实并非如此。也就是说,每个对象都有一个明确定义的类型。Mypy 注意到这一点,然后警告说代码正在分配与声明不符的值。

在上面的代码中,您可以看到我在定义时声明 x 的类型为 int,但随后将其赋值为一个字符串,然后我收到了一个错误。如果我根本不添加注解会怎么样?也就是说,如果我通过 Mypy 运行以下代码会怎么样


x = 5
x = 'abc'
print(x)

您可能会认为 Mypy 会忽略它,因为我没有添加任何注解。但实际上,Mypy 会从分配给变量的第一个值中推断出变量应包含的值的类型。因为我在第一行将整数赋值给 x,所以 Mypy 假设 x 应该始终包含一个整数。

这意味着,尽管您可以注解变量,但通常不必这样做,除非您要声明一种类型,然后可能想要使用另一种类型,并且您希望 Mypy 接受这两种类型。

定义字典

Python 的 dict(“字典”)类型可能是整个语言中最重要的类型。乍一看,名称-值对似乎不是很令人兴奋或重要。但是,当您想到程序使用名称-值对的频率时——用于变量、命名空间、用户名-ID 关联——就会清楚地看到这有多么必要。

字典也用作小型数据库或结构,用于跟踪数据。对于许多 Python 新手来说,每当他们需要一种新的数据类型时,定义一个新的类似乎很自然。但对于许多 Python 用户来说,使用字典更自然。或者,如果您需要它们的集合,可以使用字典列表。

例如,假设我想跟踪商店中各种商品的价格。我可以将商店的价格表定义为一个字典,其中键是商品名称,值是商品价格。例如


menu = {'coffee': 5, 'sandwich': 7, 'soup': 8}

如果我不小心尝试向菜单中添加新商品,但混淆了名称和值会发生什么?例如


menu[5] = 'muffin'

Python 不在意;就它而言,您可以将任何可哈希类型作为键,并将绝对任何类型作为值。但当然,您会在意,并且收紧代码以确保您不会犯这个错误可能会很好。

关于 Mypy 的一件很棒的事情是:它会自动为您执行此操作,而无需您说任何其他内容。如果我采用上面的两行代码,将它们放入一个 Python 文件中,然后使用 Mypy 检查程序,我会得到以下结果


mytest.py:4: error: Invalid index type "int" for
 ↪"Dict[str, int]"; expected type "str"
mytest.py:4: error: Incompatible types in assignment
 ↪(expression has type "str", target has type "int")

换句话说,Mypy 注意到字典(隐式地)设置为将字符串作为键,将整数作为值,仅仅是因为初始定义是这样设置的。然后它注意到它试图分配一个具有不同类型的新键值对,并指出了问题。

但是,假设您想显式声明。您可以通过使用 typing 模块来做到这一点,该模块定义了许多内置类型的注解友好版本,以及许多为此目的设计的新类型。因此,我可以这样说


from typing import Dict

menu: Dict[str, int] = {'coffee': 5, 'sandwich': 7, 'soup': 8}
menu[5] = 'muffin'

换句话说,当我定义我的 menu 变量时,我也给它一个类型注解。这个类型注解明确了 Mypy 从字典的定义中暗示的内容——即键应该是字符串,值应该是整数。所以,我从 Mypy 收到了以下错误消息


mytest.py:6: error: Invalid index type "int" for
 ↪"Dict[str, int]"; expected type "str"
mytest.py:6: error: Incompatible types in assignment
 ↪(expression has type "str", target has type "int")

如果我想将汤的价格提高 0.5 怎么办?那么代码看起来像这样


menu: Dict[str, int] = {'coffee': 5, 'sandwich': 7,
 ↪'soup': 8.5}

我最终得到了一个额外的警告


mytest.py:5: error: Dict entry 2 has incompatible type "str":
 ↪"float"; expected "str": "int"

正如我在上一篇文章中解释的那样,您可以使用 Union 来定义几个不同的选项


from typing import Dict, Union

menu: Dict[str, Union[int, float]] = {'coffee': 5,
 ↪'sandwich': 7, 'soup': 8.5}
menu[5] = 'muffin'

有了这个,Mypy 知道键必须是字符串,但值可以是整数或浮点数。因此,这消除了关于汤的价格为 8.5 的抱怨,但保留了关于反向分配松饼的警告。

可选值

在我的上一篇文章中,我展示了当您定义一个函数时,您不仅可以注解参数,还可以注解返回类型。例如,假设我想实现一个函数 doubleget,它接受两个参数:一个字典和一个键。它返回与键关联的值,但翻倍。例如


from typing import Dict

def doubleget(d: Dict[str, int], k) -> int:
   return d[k] * 2

menu: Dict[str, int] = {'coffee': 5, 'sandwich': 7,
 ↪'soup': 8}
print(doubleget(menu, 'sandwich'))

这很好,但是如果用户传递了一个字典中不存在的键会发生什么?这将最终引发 KeyError 异常。我想做 dict.get 方法所做的事情——即如果键未知则返回 None。所以,我的实现看起来像这样


from typing import Dict

def doubleget(d: Dict[str, int], k) -> int:
   if k in d:
       return d[k] * 2
   else:
       return None

menu: Dict[str, int] = {'coffee': 5, 'sandwich': 7, 'soup': 8}
print(doubleget(menu, 'sandwich'))
print(doubleget(menu, 'elephant'))

从 Python 的角度来看,这完全没问题;它会从第一次调用中获得 14,从第二次调用中获得 None。但从 Mypy 的角度来看,存在一个问题:这表明该函数将始终返回一个整数,而现在它返回的是 None


mytest.py:10: error: Incompatible return value type
 ↪(got "None", expected "int")

我应该注意到,当您调用该函数时,Mypy 不会标记此问题。相反,它注意到您允许函数在函数定义本身中返回 None 值。

一种解决方案是使用 Union 类型,正如我之前展示的那样,允许返回整数或 None。但这并不能完全表达这里的目标。我想做的是说它可能会返回一个整数,但也可能不返回——或多或少意味着返回的整数是可选的。

当然,Mypy 通过其 Optional 类型提供了这一点


from typing import Dict, Optional

def doubleget(d: Dict[str, int], k) -> Optional[int]:
   if k in d:
       return d[k] * 2
   else:
       return None

通过使用 Optional[int] 注解函数的返回类型,这意味着如果返回了某些内容,它将是一个整数。但是,返回 None 也是可以的。

Optional 不仅在您从函数返回值时有用,而且在您定义变量或对象属性时也很有用。例如,在一个类的 __init__ 方法中定义所有对象的属性是很常见的,即使是那些在 __init__ 本身中未定义的属性也是如此。由于您还不知道要设置什么值,因此您可以使用 None 值。但当然,这也意味着该属性可能等于 None,或者它可能等于(例如)一个整数。通过在设置属性时使用 Optional,您可以表明它可以是整数或 None 值。

例如,考虑以下代码


class Foo():
   def __init__(self, x):
       self.x = x
       self.y = None


f = Foo(10)
f.y = 'abcd'
print(vars(f))

从 Python 的角度来看,没有任何问题。但您可能想说 xy 都必须是整数,除非 y 被初始化并设置为 None。您可以按如下方式操作


from typing import Optional

class Foo():
     def __init__(self, x: int):
        self.x: int = x
        self.y: Optional[int] = None

请注意,这里有三个类型注解:在参数 x 上(int),在属性 self.x 上(也是 int),以及在属性 self.y 上(它是 Optional[int])。如果您违反这些规则,Python 不会抱怨,但如果您仍然有之前运行的代码


f = Foo(10)
f.y = 'abcd'
print(vars(f))

Mypy 会抱怨


mytest.py:13: error: Incompatible types in assignment
 ↪(expression has type "str", variable has type
 ↪"Optional[int]")

当然,您现在可以将 None 或一个整数赋值给 f.y。但是,如果您尝试设置任何其他类型,您将收到来自 Mypy 的警告。

结论

Mypy 是大规模 Python 应用程序向前迈进的一大步。它承诺保持您多年来所知的 Python 的方式,但增加了可靠性。如果您的团队正在从事一个大型 Python 项目,那么将 Mypy 纳入您的集成测试中可能很有意义。它在语言之外运行的事实意味着您可以随着时间的推移缓慢地添加 Mypy,使您的代码越来越健壮。

资源

您可以在这里阅读更多关于 Mypy 的信息。该站点有文档、教程,甚至还有针对使用 Python 2 并希望通过注释(而不是注解)引入 mypy 的人员的信息。

Reuven Lerner 向世界各地的公司教授 Python、数据科学和 Git。您可以订阅他的免费每周“更好的开发者”电子邮件列表,并从他的书籍和课程中学习,网址为 http://lerner.co.il。Reuven 与他的妻子和孩子住在以色列的莫迪因。

加载 Disqus 评论