3、接口存根文件
这个选项允许你如下图一般保存你的代码:
并在原文件的旁边添加一个扩展名为pyi的文件:
接口文件并不是一个新事物,C/C++已经用了十几年了。因为Python是一种解释语言,它通常不需要接口文件。但是由于计算机科学中的每一个问题都可以通过增加一个新的中间层来解决,那么我们就可以添加一层来存储类型信息。
这样做的优点是:
√ 你不需要修改源代码,且在任何python版本下都可以工作,因为解释器不会处理这些文件。
√ 在存根文件中,你可以使用最新的语法(比如,类型标注)。因为在应用的执行过程中这些根本不会被查看。
√ 因为不需要修改你的源代码,这就意味着添加类型提示的过程中不会导致新的错误,你添加的内容也不会和其他linter工具产生冲突。
√ 这是一种经过测试的设计,typeshed项目使用接口存根文件对整个标准库添加了类型提示,并加入了一些流行的库,比如:requests, yaml, dateutil等等。
√ 可以用来对不是你的代码或者你不能轻易修改的代码添加类型提示。
现在要对这种方法列出罚单了:
· 你需要复制你的代码库,也就是说每个方程现在都有两种定义(注意你不需要重复代码的主体或默认参数,使用…-省略号-做占位符即可)。
· 现在你有一些额外的文件需要打包并要和源代码一起装载。
· 你不太可能注释掉函数中的内容了(在上面的例子中,这会影响到pyi文件中方法内部的两个方法和局部变量)。
· 没法确保实现的文件和存根文件相匹配(而且IDE总是会优先使用存根文件中的定义)。
· 然而,最严重的一张罚单是,你不能对使用存根文件添加的类型提示进行类型检查。通过存根文件来添加类型提示这种方式是被设计用来对使用库函数的代码进行类型检查的,而不是对你的代码库进行类型检查的。
最后两个缺点使得检测使用存根文件添加类型提示的代码是否同步变得非常困难。在当下的形式中,类型存根文件是一种给你的用户提供类型提示功能的方法,但并不能给你带来便利,并且很难维护。为了解决这些问题,我承担起了将存根文件和源文件在mypy中合并在一起的任务;理论上,这将同时解决这两个问题。你可以在python/mypy ~ issue 5208下追踪项目的进展。
4、文档字符串
将类型信息添加到文档字符串中也是可行的。尽管这不是为Python设计的类型提示功能框架的一部分,它依旧得到了大部分主流IDE的支持。这大概是实现这一目标的传统方式吧。
从好的一面出发:
√ 适用于任何Python版本,它的定义在PEP-257中。
√ 不和其他linter工具冲突,因为大多数工具不检查文档字符串,通常只检查代码部分。
然而,这种方法存在以下严重缺陷:
· 没有一种标准的方法来声明复杂数据类型提示(比如,int或bool)。Pycharm有它特有的方法,但是像Sphinx就使用了一种完全不同的方法。
· 需要修改文档,并且因为没有工具来检查它的有效性所以很难保证数据的及时性和准确性。
· 文档字符串和添加有类型提示的代码的兼容性不好。试想当类型标注和文档字符串同时生效时,哪个优先呢?
添加什么?
现在让我们深入细节。如果需要查看可添加的类型信息的具体列表,请参考官方文档。在这里,我仅做一个3分钟的简述帮助你理解它的思想。将数据类型分为两类:标准类型和鸭子类型(协议)。
1、标准类型
标准类型是指在python解释器中有名称的类型。比如所有的内置类型(int, bolean, float, type, object等等)。然后这些一般的数据类型主要以容器的形式体现:
对于复合类型,一遍一遍的重复定义显得很笨重。所以系统允许你通过下面的方式对复合类型进行命名:
我们甚至可以对内置的数据类型重新命名,这在避免例如给一个函数以错误的顺序传入两个类型一样的参数这类的错误时很有用:
对于命名元组,你可以直接附上自己的类型信息(注意,它和及great attrs库中的数据类非常相似。)
你还可以指定类型为多种指定类型中的一个:
我们还可以用TypeVar函数来定义自己的一般变量:
最后,在不需要检查的地方可以使用Any这种类型提示来禁止类型检查:
2、鸭子类型 – 协议
在这种情况下,与其说是使用类型去定义更像是用python语言来定义,并遵守这样的定律:如果一个生物像鸭子一样嘎嘎叫又表现的很像鸭子,那么无论是出于什么样的目的都可以认为这个生物就是鸭子。在本例中,你需要在对象上定义想要的操作和属性,而不是直接声明它们的类型。这里的依据请查看PEP-544~Protocols。
哈,抓到你了
一旦你开始在代码库中添加类型提示,有时可能会遇到一些奇怪的情况。那时你可能像下面这只海豹一样露出“这是什么鬼”的表情。
1、Python2/3间的差异
这里来快速地在一个类上实现__repr__方法:
这段代码是有bug的。在python3下运行是正确的,但在python2下不行,这是因为在python2环境中,解释器期望__repr__方法返回bytes类型的数据,然而Unicode_literals的引入使得返回值实际上为unicode类型的。本例中,将下一个版本的新特性引入意味着不可能编写一个同时满足python2和python3的类型需求的__repr__方法。你需要加入运行逻辑来使代码做正确的事情:
现在,要让IDE接收这种形式,你需要添加一些linter注释,这使得代码读起来很复杂。更重要的是,类型检查器的存在迫使你必须在运行中额外进行一次检查。
2、多个返回类型
假设你想编写一个函数,作用是将一个字符串或者整数乘以2。对此的一种尝试可能是:
你输入的类型是str或者int,你的返回值相应的也是str或int。然而,如果如上图中那样做,你实际上是在告诉类型提示输入的类型可以是这两种类型中的任意一种。因此,作为调用方,你需要声明调用的类型:
这种不便可能会让一些人通过定义返回值为any类型来避免麻烦。但是,这儿有一种更好的解决方案。类型提示系统允许你定义重载。重载的意思是对于一个给定类型的输入只会返回一个指定类型的输出。对于本例而言:
不过这也有不利的一面。此时你的静态linter工具正在抱怨你利用相同的名称重新定义函数。这是一个错误的告警,可以添加一个静态linter禁用注释标记(#pylint:disable=function-redefined)。
3、类型查找
试想你有一个类,它允许将其包含的数据表示成不同的类型,或者具有一个包含不同类型的字段。你希望用户有一个快捷简单的方式来引用它们,所以你添加了一个内置了类型名称的函数:
一旦运行你就会发现:
有人可能会问这到底是怎么回事。我给返回值的定义是float类型而不是类型。出现这种混淆错误的原因时类型提示通过定义的位置出发评估每一个遇到的范围来解析类型。一旦找到匹配的名称,它就停止了。本例中类型提示所查找的第一层次是在类A中,在这里它找到了一个float(float函数)并进行了替换。
避免这类问题的解决办法是明确地定义我们不是想要任何float,而是只要内置的float(buil):
值得注意的是,要做到这一点你还需要引入builtins同时为了避免在运行时出现这种问题,你可以使用标志来指导类型提示,这个标志只有在linter工具执行期间为真,其余时刻都为假。
4、 逆变参数
检查下面的例子。你定义了一个抽象的基本类,其包含了一些常规操作。然后你有一些具体的类,它们都只处理一种类型。控制类的创建来确保传递正确的类型,并且基本类是抽象的,这似乎是一个让人满意的设计:
然而当你运行类型linter工具进行检查时会发现:
这里的问题在于类中的参数是逆变的。这意味着在你的派生类中,必须处理父类中所有的类型。然而,你还可以在派生类中添加额外的类型。也就是说你只能扩展派生类中的函数参数,但不能以任何形式加以限制:
5、 兼容性
看看你能否从下面的代码片段中发现错误:
如果你还没有想好,请试想如果你运行下面的脚本会发生什么:
如果你尝试运行它,这会运行失败并给出以下告警:
这是因为B是A的子类。进而B可以被装入一个A类的容器中(又因为B扩展了A,所以B能做的比A更多)。然而B的类方法不能被装入A类的容器,因为它不能近用一个参数来调用magic方法。此外,类型linter工具也不能指出这一点:
一个快速而简单的解决方法是通过一些手段确保B.magic方法可以在只有一个参数的情况下工作,比如将第二个参数设置为可选项。现在用我们所学到的来看下面的代码:
你觉得会发生什么?注意,我们将类方法移动到构造函数中,并没有做其他的修改。所以我们的脚本也需要一点小小的修改:
下面是运行时的告警,和之前的基本一致,只是现在是关于__init__而不是magic的:
那么你觉得mypy会说什么?如果去运行你会发现mypy选择保持沉默。没错,它会将这些标记为正确的,尽管在运行时失败了。mypy的创作者说这是因为类型失配太普遍了,以致于不能禁止__init__和__new__的不匹配问题。
英文原文:
译者:舞象加冠