



许多程序员在了解了基本的类型注解之后,就觉得它很简单,一天之内足够搞定了。但实际上还远远不够。还有很多有用的高级类型注解知识值得学习。这些高级类型注解支持开发者约束类型,进一步限制它们可以表示的内容。开发者的目标是让非法的代码元素无处遁形。开发者不应该在系统中创建相互矛盾或无效的类型。如果从一开始就没有产生错误的可能,那么你的代码中就不会出现错误。开发者可以使用类型注解来实现这一目标,从而节省时间和金钱。在本章中,我会教大家6种技巧:
Optional
用来替换代码库中的None。
Union
用来表示联合类型。
Literal
用来将变量的值限制为几个特定的值。
Annotated
用来给类型提供附加说明。
NewType
用于将类型限制为特定上下文。
Final
用于防止变量被重新绑定到新的值上。
让我们从处理带有Optional类型的None引用开始。
空指针通常被称为“10亿美元的错误”。C.A.R.Hoare曾说过:
我称之为我犯下的10亿美元错误。它是我在1965年发明的空指针。当时,我正在为面向对象语言中的引用设计第一个综合类型系统。我的目标是确保所有对引用的使用都绝对安全,并能由编译器自动执行检查。但是我无法抗拒实现空指针的诱惑,仅仅因为它很容易实现。这导致了无数错误、漏洞和系统崩溃,在过去的四十年里,这些问题可能造成了10亿美元的痛苦和损失。 [1]
虽然空指针始于Algol,但它渗透到了无数其他语言中。C和C++经常被嘲笑为空指针取消引用(这会产生分段错误或其他程序停止崩溃)。Java则因要求用户在整个代码中捕获NullPointerException而臭名昭著。可以毫不夸张地说,这类错误的价格高达数十亿美元。可以算一算由于意外的空指针或引用导致的开发人员时间浪费、客户流失和系统故障。
那么,为什么空指针对Python很重要呢?Hoare的名言是关于20世纪60年代面向对象的编译型语言的。Python现在肯定更好了,对吧?很遗憾地告诉你,这个10亿美元的错误也发生在Python中。在Python的上下文里,它有另一个名字名字:None。我后面会为你展示一种避免代价高昂的None错误的方法,但首先,让我们谈谈为什么None如此糟糕。
Hoare承认空指针是出于方便而创造的,这一点非常具有启发性。它向你演示了在开发过程中走捷径如何给开发生命周期的后期带来各种痛苦。所以请仔细考虑你当下的短期决策会对明天的维护产生什么样的不利影响。
让我们一起思考下面这段关于自动热狗摊的代码的运行逻辑。首先我希望我的系统取一个小圆片面包,在小圆片面包上放入一份烤豆,然后通过自动分配器喷洒番茄酱和芥末,如图4-1所示。有哪些地方可能出错?
图4-1:自动热狗摊的工作流程
很简单对吧?可令人难受的是我们没办法保证这段代码的逻辑万无一失。当一切顺利时,通过程序的主逻辑或控制流来思考很容易,但是在谈论健壮的代码时,开发者就需要考虑错误情况了。如果这是一个没有人工干预的自动化展台,你能想到哪些可能的错误呢?
这是我能想到的一个不全面的错误列表:
•原料不足(面包、热狗或番茄酱/芥末)。
•制作中途被取消订单。
•调味品机器被卡住。
•电源中断。
•客户不想要番茄酱或芥末,并试图在制作过程中移动面包。
•竞争对手供应商更换番茄酱;随之而来的混乱。
现在,假设你的系统是最先进的,可以检测所有这些信息,但它通过在失败时返回None来实现。这对这段代码意味着什么?你会看到如下错误:
把这些错误信息直接发送给客户将是灾难性的:你以干净的用户体验为荣,不希望丑陋的错误信息玷污你的界面。为了解决这个问题,你会开始进行防御性编码,或者预设每个可能的错误情况并尝试对其用解释的方式进行编码。防御性编程是一件好事,但它会导致开发者写出这样的代码:
这些代码看起来非常啰唆和无聊。因为Python中的任何值都可以为None,所以似乎每一个地方都需要进行防御性编程,并在每次取消引用之前进行判空检查。这太令人难受了,大多数开发人员会跟踪调用堆栈信息并确保不会将None值返回给调用者。这使得代码在调用外部系统时必须用None检查来进行包装。这很容易出错,因为你不能假设每个接触过你的代码的开发人员本能地知道在哪里检查None。此外,开发人员在编写代码时所做的原始假设(例如,此函数永远不会返回None)在充满不确定性的将来可能会被破坏,那时代码就会出现错误。这就是我们面临的问题:仅仅依靠人工干预来捕捉错误是不可靠的。
Exceptions是解决“10亿美元”问题的一个勇敢的尝试。当系统出了问题的时候,就抛出一个异常!当抛出异常时,该函数停止执行并将异常传递到调用链上,直到a)在适当的except代码块中捕获它,或者b)异常没有被捕获,最终终止程序。但实际上Exceptions没办法帮助程序员解决程序的健壮性问题。开发者仍然依靠手动干预来捕获错误(比如,编写适当的except代码块)。如果不通过手动干预,程序就会崩溃,用户将获得糟糕的体验。
这应该不足为奇。取消对None值的引用会引发异常,同理,为了能够通过静态分析检测异常,通常需要语言支持受检异常:将类型签名作为异常的一部分告诉静态分析工具你的代码期望得到什么样的异常。在撰写本书时,Python还不支持任何受检异常,因为受检异常的冗长和病毒性质,我怀疑它可能不会被支持。
这并不是说不要使用异常。我建议开发者将它们用于不希望发生但希望防范的场景下,例如网络出现故障。不要对正常行为使用异常,例如在搜索列表时找不到元素。请记住,返回值可以通过输入来强制执行,但异常不能。
None如此棘手(且如此昂贵)的原因是它被视为特殊情况。它存在于普通类型层次结构之外。每个变量都可以分配给None。为了解决这个问题,我们需要找到一种能够在类型层次结构中表示None的方法。这时候我们就需要Optional类型了。
可选类型为开发者提供了两种选择:要么有值,要么没有值。换句话说,将变量设置为一个值是可选的。
此代码表明变量maybe_a_string可以选择性地包含一个字符串。对该代码的类型检查也没有问题,无论maybe_a_string是"abcdef"还是None。
乍一看,这能给开发者带来什么可能并不明显。仍然需要使用None来表示空值。不过我要告诉你的好消息是,Optional类型有三个好处。
首先,可以更清楚地传达意图。如果开发人员在类型签名中看到Optional类型,他们就会认为这是一个很大的危险信号,于是就会警惕None的可能性。
如果发现函数返回Optional值,请打起精神来并检查None值。
其次,可以进一步区分值的缺失和空值。比如列表。如果调用函数并收到函数返回了一个空列表,这背后发生了什么呢?仅仅是没有提供任何结果吗?还是发生了错误,需要采取明确的行动?如果你收到一个空列表,不阅读源代码是不会明确背后发生的事情的。但是,如果你使用的是Optional,你就能明确得到以下三种可能的结果之一:
一个包含元素的列表
要操作的有效数据。
一个没有元素的列表
没有发生错误,但没有可用数据(是没有数据而不是错误)。
None
发生了你需要处理的错误。
最后,类型检查器可以检测Optional的类型并确保不会让None值漏掉。
比如:
在这段代码中添加一些错误情况:
使用类型检查器运行时,会收到以下错误:
完美!默认情况下,类型检查器不允许函数返回None值。通过将返回类型从Bun更改为Optional[Bun],代码将成功进行类型检查。这将给开发人员提示,他们不应在返回类型中没有编码信息的情况下返回None。可以通过捕获一个常见错误使此代码更加健壮。那么关于调用代码呢?
事实证明,调用代码的时候也能从中受益。比如:
如果dispense_bun返回一个Optional,此代码将不会进行类型检查。它将抛出以下错误:
根据不同的类型检查器,你可能需要专门启用一个选项来捕获这些类型的错误。注意查看类型检查器的文档以了解可用的选项。如果存在明确想要捕获的错误,则应该测试类型检查器是否确实能够捕获该错误。我强烈建议专门测试Optional的行为。对于我正在使用的mypy版本(0.800),我必须使用--strict-optional作为命令行标志来捕获此错误。
如果你想关闭类型检查器,你就需要对None进行明确的检查并处理None值,或者断言该值不能为None。以下代码类型做出了示范:
None值真的是一个10亿美元的错误。如果它们被忽略了,程序可能会崩溃,用户会感到糟糕,并且会损失金钱。使用Optional类型告诉其他开发者要注意None,并从工具的自动检查中受益。
讨论
你会经常处理代码库中的None吗?你对正确处理每个可能的None值有多大信心?尝试查看错误和失败的测试,看看你被错误的None值处理坑过多少次。说说你会如何使用Optional类型改善你的代码。
Union类型是用来指示多个不同类型可以用在同一个变量上的类型。Union[int,str]表示int或str均可用于某个变量。例如,思考以下代码:
我现在希望我的热狗摊进军利润丰厚的椒盐卷饼业务。与其尝试处理不属于热狗和椒盐卷饼之间奇怪的类继承(我们将在第二部分中详细介绍继承),不如返回两者的Union类型。
Optional只是Union的一个特殊版本。Optional[int]与Union[int,None]完全相同。
使用Union与使用Optional有异曲同工之妙。首先,你可以在沟通上获得相同的优势。遇到Union的开发人员马上就能知道他们需要处理多种类型。此外,类型检查器对Union和Optional的支持一样丰富。
Union类型在各种应用中都很有用:
•根据用户输入处理不同类型的返回值(如上所述)。
•处理错误并返回Optional类型,但包含更多信息,例如字符串或错误代码。
•处理不同的用户输入(例如用户是否能够提供列表或字符串)。
•返回不同的类型,例如为了向后兼容(根据请求的操作返回对象的旧版本或对象的新版本)。
•多个类型的值都合法的情况。
假设你有代码调用了dispense_snack函数,但只期望返回一个HotDog(或None):
一旦dispense_snack返回Pretzel类型的值,这段代码的类型检查就会失败。
在这种情况下类型检查器能报错是非常棒的开发体验。如果你依赖的任何函数产生更改以返回新类型,则其返回签名必须更新为联合新类型,这会强制你更新代码以处理新的类型。这意味着当你的依赖项出现与假设自相矛盾的变化时,你的代码将被警告。通过今天做出的决定,可以在未来发现错误。这是健壮代码的标志:这让开发人员越来越难犯错,降低了错误率,从而减少了用户会遇到的错误数量。
使用Union类型还有一个底层原因,但为了解释它,我需要教你一点类型理论,这是一个围绕类型系统的数学分支。
笛卡儿积类型与和类型
Union类型的好处在于它们有助于限制对象可表示状态的范围。可表示状态的范围是一个对象可以采用的所有可能组合的集合。
以这个dataclass为例:
Snack中包含一个名称、可以放在上面的调味品列表、指示错误的错误代码,以及一个跟踪该项目是否被正确处理的布尔值。这个字典可以有多少种不同的值组合?无穷无尽,对吧?名称(name)可以是任何字符串,从有效值(“hotdog”或“pretzel”)到无效值(“samosa”“kimchi”或“poutine”)或者毫无意义的(“12345”“”或“(╯°□°)╯(┻━┻”)。调味品也一样。就目前而言,没有办法穷尽它们的所有情况。
为了简单起见,我将人为地限制这种类型:
•名称可以是以下三个值之一:hotdog、pretzel或veggie burger。
•调味品可以是空的、芥末、番茄酱或两者兼有。
•有6个错误代码(0~5),0表示成功。
•disposed_of只能是True或False。
现在在这个字段组合中可以表示多少个不同的值?答案是144,这是一个非常大的数字。我通过以下方式实现了这一点:
名称的3种可能类型×调味品的4种可能类型×6个错误代码×2个布尔值(如果条目已被处理)=3×4×6×2=144
如果你接受所有的这些值都可以是None,那么总数将膨胀到420。虽然在编码时应该始终考虑None(参见本章前面关于Optional的内容),但对于这个思考练习,我会暂时忽略None值。
这种操作被称为笛卡儿积类型,它是指可表示状态的数量由属性可能值的乘积决定。但实际上并非所有状态都是有效的。如果将错误代码设置为非零,则变量disposed_of应仅能设置为True。开发人员会做出这样的假设,并相信非法的状态永远不会出现。然而,无心的错误可能会使整个系统崩溃。比如以下代码:
在这样的场景中,开发人员检查disposed_of而不是先检查非零错误代码。这是一个潜在的逻辑炸弹。只要disposed_of为True并且错误代码非零,这段代码就完全可以正常工作。如果一个有效的snack曾经错误地将disposed_of设置为True,该代码将开始产生无效的结果。这可能很难被发现,因为创建snack的开发人员没有理由检查这段代码。就目前而言,除了手动检查每个用例之外,开发者无法捕获此类错误,这对大型代码库来说是难以处理的。允许非法状态打开了脆弱代码的大门。
为了解决这个问题,需要使这个非法状态不可被表示。为此,我将重新编写示例并使用Union类型:
在这种情况下,snack可以是Snack(它只包含一个name和condiments)或一个Error(它只包含一个数字和一个布尔值)。使用Union类型,现在有多少个可表示的状态呢?
对于Snack,有3个名称和4个可能的列表值,总共有12个可表示的状态。对于ErrorCode,我可以删除0错误代码(因为它只是为了表示成功状态),它提供了5个错误代码值和2个布尔值,总共10个可表示状态。由于Union是一个非此即彼的结构,所以在一种情况下有12个可表示状态,在另一种情况下有10个状态,总共22个状态。这是一个和类型的示例,因为这里将可表示的数量状态相加而不是相乘。
总共有22个可表示的状态。将其与所有字段都集中在一个实体中时的144个状态对比。已经将可表示的状态范围减少了近85%。我们使彼此不兼容的字段不再能够组合在一起。使犯错变得更加困难,并且使我们要测试的组合少得多。使用和类型,例如Union类型,都会显著地减少可表示状态的数量。
上一节在计算可表示状态的数量时,我做了一些假设。我限制了可能的值的数量,但这有点作弊。正如我之前所说,几乎有无数个可能的值。不过好在有一种方法可以限制Python中的值:Literal。Literal类型允许开发者将变量限制为一组特定的值。
下面我会修改之前的Snack类以使用Literal值:
然后,如果我尝试使用错误的值来实例化这些数据类:
我会收到以下类型检查器报出的错误:
Python 3.8中引入了Literal,它是一种限制变量可能值的宝贵方法。它们比Python的枚举更轻量(我将在第8章中介绍枚举)。
如果我想更深入地指定更复杂的约束怎么办?编写数百个Literal量会非常无聊,而且一些约束不能用Literal类型建模。Literal无法将字符串限制为特定大小或匹配特定的正则表达式。这就是Annotated的用武之地。用Annotated可以在类型注解上指定任意元数据。
很不幸,上面的代码是不能运行的,因为ValueRange和MatchesRegex不是内置类型,它们是任意的表达式。你需要编写自己的元数据作为Annotated变量的一部分。此外,没有工具可以为你进行类型检查。在这样的工具出现之前,最好的方法就是编写虚拟注解或使用字符串来描述你的约束。从这个角度来看,将Annotated当作一种沟通方式是最恰当的。
除了等待工具支持Annotated,还有另一种用于表示复杂约束的方法可供选择:NewType。NewType允许开发者创建一个新的类型。
假设我现在要分离热狗站代码用于处理两种不同的情况:无法食用的热狗(没有餐盘,没有餐巾纸)和可食用的热狗(有餐盘,有餐巾纸)。在我的代码中,存在一些针对不同情况下的热狗进行操作的函数。例如,不能向顾客分发无法食用的热狗。
然而,我们没办法阻止开发者将无法食用的热狗作为参数传递进来。一旦开发人员这样做了,顾客会惊讶地发现他们的订单没有餐盘或餐巾纸从机器里出来。
因此作为开发人员,与其等待错误发生之后再捕获它们,不如寻找一种方法使得类型检查器能自动帮我们发现这些错误。这时,我们就可以使用NewType:
NewType可以通过现有类型创建一个全新的类型,该类型具有与现有类型相同的所有字段和方法。在本例中,我创建了一个与HotDog不同的类型ReadyToServeHotDog,它们之间不能相互置换。这个类型的精妙之处在于它限制了类型的隐式转换。开发者不能在应该使用ReadyToServeHotDog的地方使用HotDog(尽管可以用ReadyToServe HotDog代替HotDog)。在前面的例子中,我限制了dispense_to_customer只接受ReadyToServeHotDog值作为参数。这可以防止开发人员做出错误的假设。如果开发人员将HotDog类型的值传给此方法,类型检查器就会向他们报错:
强调这种类型转换的单向性是很重要的。作为开发人员,你应该控制旧类型什么时候变成新类型。
例如,我创建一个函数,该函数可以接受一个不可食用的HotDog参数:
注意我这里显式返回了一个ReadyToServeHotDog而不是一个普通的HotDog。这是一个“被推荐”的方法,prepare_for_serving函数是开发人员创建ReadyToServeHotDog的唯一合法方式。任何想要使用ReadyToServeHotDog对象的用户都需要先使用prepare_for_serving函数来创建它。
关键在于要告知其他开发者创建新类型的唯一方法是调用“被推荐”的方法。除了这个方法之外,我们不希望开发者在其他任何地方具备创建新类型的能力,因为这违背了创建新类型的初衷。
可惜Python中除了注释没有其他更好的办法可以告诉用户这一点。
不过,NewType仍然适用于实际案例。例如,下面这些都是我在实践中遇到的NewType可以解决的问题:
•将str与SanitizedString区分开,以捕获诸如SQL注入漏洞之类的错误。通过将SanitizedString设为NewType,确保只对经过适当清理的字符串进行操作,从而消除了SQL注入的机会。
•分别跟踪User对象和LoggedInUser。通过使用NewType将User与LoggedInUser区分开,我们可以编写只适用于登录用户的函数。
•跟踪表示有效用户ID的整数。通过将用户ID设置为一个NewType,可以确保某些函数仅对有效的ID进行操作,而不需要冗余的if检查语句。
在第10章中,你会看到如何用类和不变量来实现类似的约束,并更有效地避免非法状态。但NewType同样是一个值得关注的有效方式,并且比类轻量得多。
NewType与类型别名不同。类型别名只是为类型提供了另一个名称,并且可以与旧类型完全互换。
例如:
如果函数需要IDOrName类型的参数,那么它就可以接受IDOrName或Union[str,int],并且对它进行类型检查,其中NewType只在传入IDOrName时有效。
我发现当我开始做复杂类型嵌套时,类型别名非常有用,比如Union[dict[int,User],list[dict[str,User]]]。给它起一个表意的名字会带来很大的便利,比如IDOrNameLookup,这样可以将类型变得简单。
最后(一语双关),开发者也可能希望限制某个值的变更。这就是Final关键字的用武之地。Final在Python 3.8中被引入,它向类型检查器指示一个变量不能绑定到另一个值上。例如,我正准备经营我的热狗摊,但我不希望热狗摊的名称被意外更改。
如果开发人员之后不小心更改了名称,他们会看到这样的错误:
一般来说,当变量的作用域跨越大量代码(比如模块)时,最好使用Final。对于开发人员来说,在如此大的范围内跟踪变量的所有使用情况是很困难的。在这种情况下,让类型检查器自动识别不变性问题是一种美好的开发体验。
当通过函数改变对象时,Final不会出错。它只会防止变量被重新绑定(设置为新值)。
在本章中,你已经学习了许多不同的约束类型的方法。它们都有特定的用途,从用Optional处理None到用Literal限制特定的值,再到用Final防止变量被重新绑定。通过使用这些技术,开发者可以将假设和限制直接编码到代码中,从而使以后的代码阅读者不用猜测逻辑。类型检查器会根据这些高级类型注解来为代码提供更严格的保证,这将带给代码维护者信心。有了这种信心,他们就会犯更少的错误,代码也会因此变得更加健壮。
在下一章中,我们将继续从注解单个值的类型入手,并学习如何正确地注解集合类型。集合类型在Python代码中无处不在,因此开发者必须注意表达意图。你同时需要精通集合的所有表达方式,包括某些需要自定义类型的情况。
[1] C.A.R.Hoare.“Null References:The Billion Dollar Mistake.” Historically Bad Ideas .Presented at Qcon London 2009, n.d.