



想要编写可维护的Python,就必须了解类型的性质并谨慎地使用它们。首先我会介绍类型到底是什么以及它为什么很重要。然后我们将继续讨论Python的类型体系规则是如何影响代码健壮性的。
首先我们思考一个问题:如果没有数字、字符串、文本或者布尔值这几个词,怎么解释什么是类型?
相信对所有人而言这都不是一个简单的问题。那么要想在Python这种不需要显式声明变量类型的语言中解释“类型”有什么好处就更加困难了。
我认为可以给类型下一个非常简单的定义:一种用于沟通的方法。类型是可以传递信息的,它们提供了一种用户和计算机都可以理解的信息表达。我将这种信息表达拆分为两个不同的层面:
机器表达
类型将行为和约束信息传达给Python语言本身。
语义表达
类型将行为和约束信息传达给其他的开发人员。
下面让我们深入了解这两种表达的内涵。
从本质上讲,跟计算机相关的一切都是二进制代码。处理器不会直接使用Python语言进行工作,它只知道电路上是否存在电流。计算机内存也是一样的。
假设内存中的信息如下所示:
它们看起来就像是一堆废话。我们先聚焦中间的部分:
我们发现没有办法明确地说出这些数字表示什么含义。根据不同的计算机架构,它们可能表示数字5259604或5521744,也可能表示字符串“PAT”。如果没有任何上下文,我们就无法确定它到底在表达什么。同理,这也是Python需要类型的原因。类型携带了帮助Python理解这些1和0所需的信息。让我们通过实例看看效果:
我是在小端机上运行的CPython 3.9.0,所以你不用因为代码运行结果不一样而担心,有些细小的差异可能会改变运行的结果(不保证此代码可以在其他Python实现中运行,例如Jython或PyPy)。
这些十六进制字符串表示了一个Python对象所在内存的信息。你将在链表中找到指向上一个和下一个对象的指针(用于垃圾收集)、引用计数、类型以及对象本身的实际数据。也可以通过检查每个返回值末尾的字节查看它是数字还是字符串(查找字节0x544150或0x504154)。最重要的是在这个内存编码中包含了类型信息。当Python执行到某变量时,它能够明确地知道所有内容的类型(就好比使用type()函数那样)。
很容易认为这是类型存在的唯一原因——计算机需要知道如何解释各种各样的内存块。了解Python如何使用类型对于编写健壮的代码很重要,更重要的是下面要介绍的语义表达。
类型的第一种定义对编程的初级阶段来说非常友好,第二种定义则适用于所有的开发人员。类型除了具有机器表达之外,还具有语义表达。语义表达是一种沟通方式,开发者所选择的类型将跨越时间和空间将信息传递给未来的开发人员。
类型告诉开发者在与该实体进行交互时可以预测它表现出什么样的行为。这里的“行为”就是开发者和该类型相关联的操作(考虑所有的前置条件和后置条件)。它们是用户在使用该类型时与之交互的边界、约束以及自由。被正确使用的类型理解起来很容易,它们表现得非常自然。相反,使用不当的类型本身就是一种障碍。
就拿最基本的类型int来说。思考一下int类型在Python中的行为。下面是我给出的一个简单(可能不全)的行为列表:
•可以从整数、浮点数或字符串构造。
•数学运算,例如加、减、乘、除、求幂和求反。
•关系比较,例如<、>、==和!=。
•按位运算(操作数字的单个位),例如&、|、^、~和移位。
•可以使用str或repr函数转换为字符串。
•能够通过ceil、floor和round方法进行四舍五入(尽管这样会返回整数本身,但也是支持的方法)。
int有许多行为。在交互式Python控制台中键入help(int)可以查看其完整列表。
然后我们思考一下datetime这个类型:
datetime与int本质上没有太大不同。通常,它表示为距离某个时间纪元(例如1970年1月1日)的秒数或毫秒数。但是关于datetime的行为呢(我用楷体标注了它与整数行为的差异)?
•可以从字符串或一组代表日/月/年/等的整数构造。
•数学运算,例如时间间隔的加和减。
•关系比较。
•没有可用的按位运算。
•可以使用str或repr函数转换为字符串。
•无法通过ceil、floor或round方法进行四舍五入。
datetime支持加减,但不支持和其他datetime相互加减。我们只能够增加时间间隔(例如添加一天或减去一年)。乘除对于datetime也确实没有什么意义。同样,标准库中也不支持日期的四舍五入操作。但是datetime提供了与整数有相似语义的比较和字符串格式化操作。因此,尽管datetime本质上也是一个整数,但其操作是一个具有约束的子集。
语义指的是操作背后的意图。虽然str(int)和str(datetime.datetime.now())将返回不同格式的字符串,但其意图是相同的:我要从一个值创建出一个字符串。
时间类型的变量也有其特定的行为,为了进一步将它们与整数区分开来。这里列举一些它们的行为:
•根据时区更改值。
•能够控制字符串的格式。
•查找是星期几。
同样,如果想查看完整的行为列表,请进入REPL并输入import datetime; help(datetime.datetime)。
datetime比int更具体。它传递出了更具体的用例场景信息,而非单纯的数字。使用更具体的类型,就是在告诉未来的代码贡献者哪些操作是被允许的,同时注意那些在宽泛类型中缺少的约束。
让我们深入探讨这与代码健壮性的关系。假设你接手了一个用于自动化厨房开门和关门的代码。你需要新增一个功能来支持修改关门的时间(例如,延长假期厨房的营业时间)。
通过阅读代码我们知道需要在point in time上进行操作,但是怎么下手呢?要处理的变量是什么类型呢?它是str、int、datetime还是某个自定义类型?可以在point_in_time上执行哪些操作呢?因为这段代码不是你写的,也没有与它相关的历史上下文信息。哪怕你只是想调用它,你也会面临这些问题。因为你不知道什么是可以传递给这个函数的合法参数。
如果在调用代码的时候做出了不恰当的假设,并且将其投入生产环境,那么代码就会变得不那么健壮。也许这段代码不在经常执行的代码路径上;也许其他错误隐藏了这段代码的运行;也可能这段代码并没有经过大量的测试,以后它就会变成运行时错误。无论哪种情况,代码中潜伏着一个错误,并且降低了可维护性。
负责的开发人员会尽最大努力让错误不影响生产。他们会查看测试、查看文档(当然,有一点含糊——文档可能会很快过时)或调用代码来定位问题。他们会查看closing_time()和log_time_closed()以了解这两个函数期望的参数类型或提供的返回值类型,并进行相应的设计。这是一种正确的编程实践,但我认为它仍然不是最佳实践。虽然错误不会影响生产,但他们仍然花费了大量的时间查看代码,这阻碍了价值的快速交付。在看完这个小例子后,你可能会说如果它只是偶尔发生就不是什么大问题,这是可以原谅的。但我们要小心“滴水穿石”带来的后果:任何小问题本身并没有太大的危害,但是成千上万的小问题遍布在代码库中就会让代码交付变得步履蹒跚。
产生这个问题的根本原因是参数的语义表示不明确。编写代码时,应该尽可能通过类型表达意图。可以在需要的时候使用代码注释,但我建议使用类型注解(在Python 3.5+中支持)来解释部分代码。
我所需要做的就是在参数后面放一个:<type>。本书中的大多数代码示例都将使用类型注解来明确代码所期望的类型。
现在,当开发人员遇到这段代码的时候,就能明确地知道这个函数对point in time这个参数的期望是什么。他们不必再通过查看其他方法、测试或文档来了解如何操作变量。他们对要做的事情有非常清晰的线索,而且可以立即开始工作,执行需要做的修改。这样的做法实际上是在用无须直接交谈的方式向未来的开发人员传达语义表达。
此外,随着开发人员越来越多地使用某种类型,会对它愈加熟悉。当再次遇到该类型时,开发者无须查找文档或help()就能知道怎么使用该类型。这相当于在代码库中创建一套公认的类型词汇表,可以减轻维护代码的负担,因为开发人员在修改既有代码时,只想专注于必须进行的更改。
类型的语义表达非常重要,第一部分的剩余内容会介绍如何通过类型帮助开发人员提升开发体验。不过,在此之前,我们先快速学习一下Python作为一门语言所具备的基本元素以及这些元素对代码库健壮性产生的影响。
讨论
回顾一下你的代码库中使用过的类型。选择一些实际案例,问问自己它们的语义表达是什么,列出它们的约束、用例和行为。你可以在其他地方使用这些类型吗?是否有滥用类型的地方?
正如本章前面所述,类型系统旨在为用户提供一种对语言中的行为和约束进行建模的方法。无论是在代码编写的过程中还是在代码执行的阶段,编程语言对其类型系统的工作方式都设定了期望。
类型系统可以从弱和强的角度进行分类。强类型语言倾向于使用类型来限制它们的操作。换句话说,如果你破坏了类型的语义表达,程序会通过编译器错误或运行时错误来告知开发者(有时非常醒目)。Haskell、TypeScript和Rust等语言都被认为是强类型的。支持者推崇强类型语言的主要原因是在构建或运行代码时错误会更明显。
相比之下,弱类型语言不会使用类型来限制开发者对编程元素的操作。为了理解操作,类型通常会被强制转换为不同的类型。JavaScript、Perl和旧版本的C等语言都是弱类型的。它们的支持者提倡开发人员可以快速迭代代码而无须在开发过程中陷入语法的纠缠。
Python属于强类型语言。类型之间发生的隐式转换很少。它会非常明显地指出你的非法操作:
将其与弱类型语言(例如JavaScript)进行对比:
在健壮性方面,像Python这样的强类型语言肯定更具优势。虽然错误会在运行时而不是开发时出现,但它们会显式地出现在TypeError异常中。这会大大减少调试问题的时间,使开发人员能够更快地进行增量价值交付。
弱类型语言本质上就是不健壮的吗?
弱类型语言的代码毫无疑问也可以是健壮的,我不是在贬低这些弱类型语言。世界上运行的大量生产级JavaScript就可以说明这一点。但是,弱类型语言需要格外小心才能健壮。使用它们的过程中很容易弄错变量类型并做出错误的假设。开发人员会非常依赖代码检查工具、测试以及其他工具来提高代码的可维护性。
我要讨论的关于类型的另一个区别是:静态类型与动态类型。这是计算机处理类型机器表达的根本区别。
静态类型语言在构建或者打包代码时将其类型信息嵌入变量中。开发人员可以显式地向变量添加类型信息,或者某些工具(如编译器)可以为开发人员推断类型。变量在运行时不会改变其类型(因此被称为“静态”)。静态类型语言的支持者吹捧它们具备编写安全代码的能力,可以从强大的安全网中获益。
另一方面,动态类型语言在运行时将类型信息嵌入变量的值或者变量本身中。因为没有与该变量相关的类型约束信息,变量在运行时可以轻易地更改类型。动态类型语言的支持者声称它们具备开发所需的灵活性和速度,并且使用动态类型语言编写代码不会总是被编译器的报错打断。
Python是一种动态类型语言。正如在有关机器表达的讨论中所说,变量值中嵌入了类型信息。Python对在运行时更改变量的类型没有任何限制:
不幸的是,很多时候在运行时更改类型是构建健壮代码的障碍。因为这使开发者无法在变量的生命周期内做出确定性的假设。当假设具备不确定性时,很容易基于它们编写出其他不稳定的假设,进而在代码中留下逻辑炸弹。
动态类型语言从根本上就是不健壮的吗?
就像弱类型语言一样,用动态类型语言编写健壮的代码毫无疑问也是可能的。只是需要我们付出更大的努力。开发者必须做出更深思熟虑的决定,才能使代码库更易于维护。另外,静态类型也不能完全保证程序的健壮性,也可能限制了开发者对类型的操作,但其实并没有因此获得多大的收益。
更糟糕的是,我之前展示的类型注解在运行时对变量行为没有影响:
没有错误,没有警告,没有任何东西。但是希望并没有消失,你有很多策略可以使代码更健壮(否则,这将是一本很短的书)。作为健壮代码的开发者,我们将讨论最后一件事,然后开始深入改进我们的代码库。
每当提到鸭子类型时,一定会有人这样回复:
如果它走路像鸭子,叫起来像鸭子,那它一定是鸭子。
我对这句话的观点是,它对于解释什么是鸭子类型实际上没有任何帮助。它读起来朗朗上口、简洁明了,但是关键在于,只有那些已经了解了鸭子类型的人才能理解这句话。我年轻的时候,只是礼貌地点点头,生怕在这简单的一句话中漏掉了什么深刻的东西。直到后来我才真正领会鸭子类型的力量。
鸭子类型是指在编程语言中只要遵守某些接口就可以使用某个对象或者实体的能力。它在Python语言中是一个很棒的东西,大多数人可能在不知情的情况下使用它。让我们通过一个简单的例子来说明:
在print_items的3个调用中,我们循环遍历集合并打印集合中的每个元素。想想看它是如何工作的。print_items完全不知道它将收到什么类型的参数,只需要在运行时接收一个参数并对其进行操作。它不需要根据参数类型来决定做不同的事情,而是检查传入的参数是否可以迭代(通过调用__iter__方法检测)。如果参数具备__iter__属性,则在循环中调用和返回。
我们可以通过一个简单的代码示例来验证这一点:
这就是鸭子类型赋予我们的能力,只要某个类型支持被调用时函数使用的变量和方法,就可以在该函数中自由使用该类型。
另一个例子如下:
这里不管传入的参数是整数还是字符串都是合法的,因为这两种类型都支持加法操作,所以两者都可以正常工作。所有支持加法运算符的对象都可以被当作合法参数传入,甚至是列表类型:
那么如何利用这个特性为代码的健壮性服务呢?事实证明,鸭子类型是一把双刃剑。它可以提高健壮性的原因在于提高了代码的可组合性(我们将在第17章中了解有关可组合性的更多信息)。建立一个能够处理多种类型的可靠抽象库可以减少处理复杂特殊情况的需要。但是,如果过度使用了鸭子类型,就会开始打破开发人员的支撑性假设。代码更新就不再是仅仅进行更改那么简单,必须查看代码所有的调用点并确保传给函数的参数类型也适用于更新后的代码。
考虑到所有这些,最好将本节前面对鸭子类型的惯用解释改写为:
如果它走路像鸭子,叫起来像鸭子,而你正在寻找像鸭子一样走路和叫的东西,那么你可以把它当作一只鸭子对待。
这不是也非常朗朗上口吗?
讨论
你在你的代码库中使用过鸭子类型吗?是否有一些地方可以传入与代码想要的类型不一致的类型,但代码仍然可以正常工作?你认为这些会增加还是减少你的代码逻辑健壮性?
类型是整洁、可维护代码的支柱,同时它也是与其他开发者进行沟通的工具。如果你关注类型,就可以实现充分的沟通,从而为未来的维护人员减少负担。在第一部分的其余内容中,我将向你展示如何使用类型来增强代码库的健壮性。
请记住,Python是动态且强类型的。强类型对我们开发者来说是一个福音:当我们使用不兼容的类型时,Python会告知我们相关的错误。但是为了编写更好的代码,我们必须克服它的动态类型特性。这些语言上的决策塑造了Python代码的编写方式,在编写代码的时候应该牢记它们。
在第3章中,我们将讨论类型注解,它告诉我们如何明确我们所使用的类型。类型注解起着至关重要的作用:它是我们与未来的开发人员之间关于行为的主要交流方式,并且有助于克服动态类型语言的局限性,允许开发者在整个代码库中强制执行意图。