2.1 类型中有什么

首先我们思考一个问题:如果没有数字、字符串、文本或者布尔值这几个词,怎么解释什么是类型?

相信对所有人而言这都不是一个简单的问题。那么要想在Python这种不需要显式声明变量类型的语言中解释“类型”有什么好处就更加困难了。

我认为可以给类型下一个非常简单的定义:一种用于沟通的方法。类型是可以传递信息的,它们提供了一种用户和计算机都可以理解的信息表达。我将这种信息表达拆分为两个不同的层面:

机器表达

类型将行为和约束信息传达给Python语言本身。

语义表达

类型将行为和约束信息传达给其他的开发人员。

下面让我们深入了解这两种表达的内涵。

2.1.1 机器表达

从本质上讲,跟计算机相关的一切都是二进制代码。处理器不会直接使用Python语言进行工作,它只知道电路上是否存在电流。计算机内存也是一样的。

假设内存中的信息如下所示:

它们看起来就像是一堆废话。我们先聚焦中间的部分:

我们发现没有办法明确地说出这些数字表示什么含义。根据不同的计算机架构,它们可能表示数字5259604或5521744,也可能表示字符串“PAT”。如果没有任何上下文,我们就无法确定它到底在表达什么。同理,这也是Python需要类型的原因。类型携带了帮助Python理解这些1和0所需的信息。让我们通过实例看看效果:

我是在小端机上运行的CPython 3.9.0,所以你不用因为代码运行结果不一样而担心,有些细小的差异可能会改变运行的结果(不保证此代码可以在其他Python实现中运行,例如Jython或PyPy)。

这些十六进制字符串表示了一个Python对象所在内存的信息。你将在链表中找到指向上一个和下一个对象的指针(用于垃圾收集)、引用计数、类型以及对象本身的实际数据。也可以通过检查每个返回值末尾的字节查看它是数字还是字符串(查找字节0x544150或0x504154)。最重要的是在这个内存编码中包含了类型信息。当Python执行到某变量时,它能够明确地知道所有内容的类型(就好比使用type()函数那样)。

很容易认为这是类型存在的唯一原因——计算机需要知道如何解释各种各样的内存块。了解Python如何使用类型对于编写健壮的代码很重要,更重要的是下面要介绍的语义表达。

2.1.2 语义表达

类型的第一种定义对编程的初级阶段来说非常友好,第二种定义则适用于所有的开发人员。类型除了具有机器表达之外,还具有语义表达。语义表达是一种沟通方式,开发者所选择的类型将跨越时间和空间将信息传递给未来的开发人员。

类型告诉开发者在与该实体进行交互时可以预测它表现出什么样的行为。这里的“行为”就是开发者和该类型相关联的操作(考虑所有的前置条件和后置条件)。它们是用户在使用该类型时与之交互的边界、约束以及自由。被正确使用的类型理解起来很容易,它们表现得非常自然。相反,使用不当的类型本身就是一种障碍。

就拿最基本的类型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作为一门语言所具备的基本元素以及这些元素对代码库健壮性产生的影响。

讨论

回顾一下你的代码库中使用过的类型。选择一些实际案例,问问自己它们的语义表达是什么,列出它们的约束、用例和行为。你可以在其他地方使用这些类型吗?是否有滥用类型的地方?