



Python是一种动态类型语言,直到运行时才知道代码元素的类型。这对编写健壮代码来说是一个阻碍。因为类型嵌入在值中,开发人员很难知道他们正在处理什么类型的变量。有时变量的名字看起来像字符串(str),但是谁能说得准它会不会变成字节(bytes)呢?类型推断建立在动态类型语言类型可变这个特性上。不过,好在Python 3.5引入了一个全新的特性:类型注解。
类型注解使编写健壮代码的能力提升到一个全新的水平。Python的创造者Guido van Rossum一针见血地说道:
我得到了一个惨痛的教训,即对小型应用程序来说,动态类型是非常有用的。对于大型程序来说,你必须采用更严格的方法来约束开发,最好是语言本身就提供这种约束,而不是告诉你:“好吧,你可以为所欲为。” [1]
类型注解是更规范的方法,在处理大型代码库的时候需要格外注意。在本章中,你将学习如何使用类型注解、它们为何如此重要,以及如何利用类型检查器这个工具确保你的代码意图。
在第2章中,本书第一次在示例代码中使用了类型注解:
❶这里使用的类型注解是:datetime.datetime。
类型注解是一种补充语法,它可以告诉开发者这个变量的期望类型是什么。这些注解承担了类型提示的作用:它们为读者提供了提示,但Python语言在运行时并未使用它们。因此,其实你完全可以忽略这些提示。思考以下代码片段与代码注释。
应该尽量减少出现违反类型提示。代码的作者用一个非常清楚的案例进行了介绍。如果你不遵循类型注解,那么当你使用的类型与原始代码的类型不一致的时候就是在给自己挖坑(比如期望某个函数与该类型一起工作)。
在这种情况下,Python不会在运行时抛出任何错误。事实上,它在运行时根本不理会类型注解。使用类型注解也不会在Python执行的时候进行检查或产生任何开销。但这些类型注解仍然很重要:它可以通知代码的读者变量预期的类型是什么。这样代码的维护人员就可以知道在修改代码时可以使用合法的类型。同理在调用代码的场景下也可以获得收益,因为开发人员能够确切地知道传入什么类型的参数。总而言之,通过类型注解,可以减少代码协作中的摩擦。
我们可以把自己放在未来维护者的角度。遇到直观易懂的代码难道体验会不好吗?不必在层层调用的函数中挖掘信息来确定其用法。不需要对代码做出错误的类型假设,然后承担处理异常和错误行为带来的后果。
思考另一段代码,它根据员工的工作时间和餐厅的营业时间,进行员工的进餐安排。假设你想要使用下面这段代码:
让我们暂时忽略函数的实现,因为我想专注于第一印象。你认为可以传递什么样的参数给这个函数?停下来,闭上眼睛,问问自己哪些参数类型是合法的,然后再继续阅读。open_time是datetime、自纪元以来的秒数,还是精确到时的字符串?workers_needed是一个名字列表、一个Worker对象列表,还是其他什么东西?如果你猜错了,或者不确定,你就需要查看实现或调用代码试一试再做判断,这些事情是一定会花费大量时间并令人头疼的。
下面我们看看函数的实现,你可以验证一下刚刚的猜想。
你可能猜到了open_time是一个datetime,但是你想过workers_needed会是一个整数吗?当你看到类型注解的时候,就能更好、更直观地了解变量的类型信息。这降低了开发者的认知负担,同时减少了开发人员的时间浪费。
添加正确的类型注解是我们向成功迈出的一大步,但不要止步于此。如果你看到这样的代码,应该将变量重命名为number_of_workers_needed以反映这个参数的意图。在下一章中,我还将探讨什么是类型别名,它是另一种类型自我表达的方式。
行文至此,我演示的所有类型注解示例都将关注点放在参数上,但你也可以将类型注解添加到函数的返回值上。
回顾一下schedule_restaurant_open这个函数。在该代码片段中,我调用了find_workers_available_for_time这个函数。它会返回一个名为workers的变量。假设你想修改代码以选择未工作时间最长的工人,而不是随机查找一个工人,我们可以用什么来标识工人的类型呢?
如果只关注函数的签名,那么你会看到以下内容:
从函数的签名我们获取不到什么有用的信息。我们可以猜测函数对应的实现或者让自动化测试告诉我们它的功能,对吧?它返回的可能是一个工人名字的列表吗?与其对它进行测试,不如看看它的代码实现。
很可惜,代码实现中也没有任何信息可以直接告诉开发者这个函数的返回值是什么类型。这段代码共有三种不同的返回语句,开发者当然希望它们的类型是一致的。(每个if语句都会进行单元测试以确保它们的一致性,是吗?确定你会这样做吗?)然后你需要更深入地挖掘。需要查看worker_database对象,还需要查看is_available和get_emergency_workers这两个函数。也需要查看OWNER变量。这些元素的类型都需要保持一致,否则你就需要在旧代码中添加特殊情况处理的逻辑。
如果这些信息也不能准确地告诉你期望的返回值类型是什么怎么办?如果你必须深入了解更多函数调用怎么办?你必须确保你查看过的每一层调用都作为一层抽象保留在大脑中。每一条信息都会产生认知负载。你承受的认知负载越多,出错的可能性就越大。
所有这些都可以通过添加返回值类型注解来避免。通过在函数声明的末尾放置-><type>来注释返回类型。比如当遇到这样的函数签名:
很明确地就能知道确实应该将工作人员视为字符串列表。无须深入查看数据库、函数调用或其他模块的细节信息。
在Python 3.8及更早版本中,内置集合类型(例如list、dict和set)不允许使用括号语法,例如list[Cookbook]或dict[str,int]。开发者需要使用typing模块进行类型注解:
还可以给变量添加注解:
虽然我对所有函数使用类型注解,但一般不会给变量进行注解,除非我想在代码中表达一些特定含义(例如与预期不同的类型)。我不想过度使用类型注解——不啰唆是Python吸引许多开发人员的主要原因。冗余的类型注解会使你的代码变得混乱,特别是类型显而易见的时候。
这些类型注解没有提供额外价值。读者一看便知"useless"是一个str。所以一定要记住,类型注解的作用是类型提示,它是为未来的代码阅读添加标记以改善沟通的。不需要陈述显而易见的事情。
如果你不幸无法使用高版本的Python,那么可以采用另外的方式。即使对于Python 2.7,也有另一种方式使用类型注解。
要编写类型注解,需要在注释代码中这样做:
但是这容易被忽略,因为这些类型在视觉上与变量距离太远。如果你可以升级到Python 3.5,还是建议你升级并使用较新的类型注解方法。
在使用类型注解之前,跟所有的决策一样,需要权衡成本和收益。预先考虑类型有助于深思熟虑的设计过程,但类型注解是否还能提供其他的好处呢?我在下面将向你展示类型注解如何配合开发工具发挥更大的作用。
前面主要讨论了与其他开发人员的沟通问题,但是类型注解也能为个人开发者编写Python代码带来好处。由于Python的动态特性,很难知道变量有哪些可用操作。使用类型注解,可以使许多Python感知代码编辑器提供自动补全变量的操作。
在图3-1中,你将看到一个屏幕截图,它展示了一个流行的代码编辑器VS Code,它检测datetime并提供自动补全变量的功能。
图3-1:VS Code的自动补全功能
在整本书中,我一直在谈论类型如何传达意图,但忽略了一个关键细节:如果不愿意,任何程序员都可以不遵守这些类型注解。如果开发者的代码与类型注解相矛盾,就可能制造出一个错误,并且这种情况仍然需要依靠人来找出错误。我们需要更好的方式来应对这个问题。我们期望计算机来帮助我们找到这一类错误。
我在第2章讨论动态类型时展示了这个代码片段:
这里有一个问题:当你不相信开发人员会遵循规则时,类型注解怎么保证代码的健壮性呢?为了代码的健壮性,开发者希望他们的代码能够经得起时间的考验。为此,开发者就需要某种工具来检查所有类型注解并提示是否有类型问题。这种工具被称为类型检查器。
类型检查器将类型注解从沟通方法升级为安全网。它是一种代码静态分析的形式。静态分析工具是指在源代码上运行的工具,完全不会影响代码的运行时。你可以在第20章学到更多关于静态分析工具的知识,但现在我只会解释什么是类型检查器。
首先,需要安装一个类型检查器。这里使用mypy,它是一个非常流行的Python类型检查器。
现在创建一个名为 invalid_type.py 的文件,编写操作不当的代码:
然后在命令行上对该文件运行mypy,就会收到以下错误提示消息:
像这样,类型注解就成为预防错误发生的第一道防线。在任何时候犯了违背代码作者意图的错误时,类型检查器都会发现并提醒你。事实上,大多数开发环境都提供了这种代码分析,并在编写代码的时候给开发者错误提示。(除了用读心术搞懂开发者的想法之外,这可能是能最早找出程序错误的工具了,这种开发体验的提升非常棒。)
以下是mypy在我的代码中捕获错误的示例。希望你能在代码片段中试着查找错误并记录找到错误或放弃所需的时间,然后查看代码片段下方列出的输出,看看你是否找对了。
下面是mypy输出的错误提示:
它提示我在这个函数中返回的是bytes,而不是str。我在代码中使用的是编码函数而不是解码函数,最终导致的结果就是返回类型全都混淆了。我过去在将Python 2.7代码迁移到Python 3时犯了数不胜数的类似错误。所以感谢有类型检查器这个美好的东西。
以下是另一个例子:
mypy的错误提示如下:
这里在list类型上调用update方法而不是extend方法犯下了一个无心的错误。在不同的集合类型之间切换的时候,这一类错误很容易发生(在这个例子里,我们在处理set类型时,它提供了update方法,而切换到处理list类型的时候,它就不提供了)。
再举最后一个例子:
mypy的错误提示如下:
这个错误很微妙。函数期望的返回值是字符串,但是返回值可能是None。如果所有代码都按照条件检查餐厅名称来做出判断,就像上面代码调用的那样,测试将会通过,并且不会有任何问题。即使对于否定的情况也是如此,因为在if语句中检查None绝对没问题(它是false-y)。这是Python的动态类型又来坑我们的一个例子。
但是,几个月后,一些开发人员将开始尝试将此返回值当作字符串,并且一旦需要添加新城市,代码就开始尝试对None值进行操作,从而引发异常。这不是一段健壮的代码:这里潜伏了一个错误。但是有了类型检查器,就不用担心这个问题并能及早发现这些错误。
有了类型检查器,你还需要测试吗?当然需要。类型检查器只能捕获特定类别的错误:不兼容类型的错误。开发者仍然需要测试许多其他类别的错误。我们应该将类型检查器当作错误识别工具箱中的一种工具。
在所有这些示例中,类型检查器都发现了潜在的错误。这些错误是否会被测试、代码审查或被客户发现并不重要。重要的是程序员可以更早地发现它们,从而节省时间和金钱。类型检查器为我们提供了静态类型语言的好处,同时允许Python在运行时保持动态类型的特点。这确实是一个两全其美的技术。
在本章的开头,我们提到了Guido van Rossum说的话。在Dropbox工作期间,他发现在没有安全网的大型代码库中工作就像在泥潭中挣扎。他是将类型提示引入语言的大力支持者。如果你希望你的代码具备传达意图与捕获错误的能力,请马上开始采用类型注解和类型检查。
讨论
你的代码库是否有可能被类型检查器发现的错误?这些错误会让你付出多少代价?有多少次代码审查或集成测试发现了错误?这些投入生产环境的错误让你感觉如何?
现在,在你开始为所有东西添加类型之前,我要先聊聊成本了。添加类型是一件很简单的事情,但可能会过度。当开发者尝试测试和试验代码时,他们可能会陷入与类型检查器的斗争中,因为他们会在对给类型编写注解时感到麻烦。对于类型注解的初学者来说,使用它是有学习成本的。我前面也提到了我不会给所有的内容编写注解。我不会注解所有的变量,特别是类型很明显的场景。我也不会为类中的每个小型私有方法添加参数注解。
那么什么时候应该使用类型检查器呢?
•在你期望其他模块或用户调用的函数上(例如,公共API、库入口点等)。
•当你想突出表达复杂类型时(例如,映射到对象列表的字符串字典)或类型不直观时。
•mypy提示你需要添加类型的地方(通常在分配给一个空集合时——使用该工具比反对它更容易)。
类型检查器会为它可以推断的任何值推断类型,因此即使你没有显式填写所有的类型注解,你也可以获得收益。我将在第6章介绍配置类型检查器。
当引入类型提示时,Python社区引起了恐慌。开发人员担心Python会变成像Java或C++一样的静态类型语言。他们担心在所有地方添加类型会减慢他们的开发速度并破坏他们喜爱的动态类型语言的优势。
然而,类型提示只是提示。它们是完全可选的。我不推荐将它们用于小脚本,或者任何不会存活很长时间的代码。但如果你的代码需要长期维护,类型提示就是无价之宝。它们作为一种沟通方法,使你的编程环境更智能,并与类型检查器配合检测潜在错误。它们保护了代码原作者的意图。注解类型的时候,可以减轻读者理解代码的心智负担。减少了通过阅读函数实现来了解它的意图的必要性。阅读代码对人来说很一件很难的事情,应该尽量减少开发人员需要阅读的代码量。通过使用深思熟虑的类型,可以减少突发情况的发生并提高代码的易读性。
类型检查器对开发者来说也是一个信心建设者。请记住,为了使你的代码健壮,它必须易于更改、重写和删除。类型检查器可以让开发人员更轻松地做到这一点。如果某些内容依赖于已更改或删除的类型或字段,则类型检查器会将有问题的代码标记为不兼容。自动化工具使你和你未来合作者的工作变得更简单:这会使更少的错误进入生产环境,并且更快地交付功能。
在下一章中,你将在基本类型注解的基础上学习如何构建新类型。这些类型将帮助你限制代码行为和出现问题的方式。在实践中如何发挥类型注解的作用这个话题上我也只是触及了皮毛。
[1] Guido van Rossum.“A Language Creators'Conversation.”PuPPy(Puget Sound Programming Python)Annual Benefit 2019. https://oreil.ly/1xf01.