近年来,Rust因安全性受到科技公司青睐。其他主流语言能否借鉴Rust的思想?
在Rust中,错误使用接口会导致编译错误。在Python中,虽然错误代码仍能运行,但使用类型检查器(如pyright)或带类型分析的IDE(如PyCharm)可以获得快速反馈,发现潜在问题。
本文中,Python 中引入了 Rust 的一些理念:尽量使用类型提示,遵循“非法状态不可表示”原则。无论是长期维护的程序还是一次性脚本,我都这样做,因为后者往往会变成前者,而这种方法让程序更易理解和修改。
本文将展示一些应用此方法的Python示例,虽然不算高深,但记录下来或许有用。
类型提示
首先要尽可能使用类型提示,尤其是在函数说明和类属性中。当我看到这样的函数说明。
从函数说明本身来看,我完全不知道其中发生了什么:是列表、字典还是数据库连接?是布尔值还是函数?函数的返回值是什么?如果失败会发生什么?是抛出异常还是返回某个值?要找到这些问题的答案,我要么必须读取函数的主体(通常还要递归读取它调用的其他函数的主体,这非常烦人),要么只能读取它的文档(如果有的话)。虽然文档中可能包含有关函数的有用信息,但不一定要使用文档来回答前面的问题。许多问题都可以通过内置机制(即类型提示)来回答。
写函数说明要花更多时间吗?是的。
但这有问题吗?没有,除非我打字速度慢到每分钟只能敲几个字,但这很少见。明确写出类型能让我更清楚地思考函数到底提供了什么接口,以及如何让接口更严格,避免调用者用错。有了清晰的函数说明,我一眼就能知道怎么用这个函数、需要传什么参数、返回值是什么。而且,和文档注释不同,文档注释容易过时,但类型检查器会在类型变化时提醒我更新调用代码。如果我想了解某个东西的类型,直接看就行,非常直观。
当然,我也不是死板的人。如果一个参数的类型提示要嵌套五层,我通常会放弃,改用简单但不那么精确的类型。根据我的经验,这种情况很少见。如果真的遇到,那可能是代码设计有问题——如果一个参数既可以是数字、字符串、字符元组,又可以是字典映射字符串到整数,那可能意味着你需要重构和简化代码了。
使用数据类而非元组或字典
使用类型提示只是一方面,它只是描述了函数的接口,第二步是尽可能准确地 “锁定 ”这些接口。一个典型的例子是从函数返回多个值(或单个复杂值),懒惰而快速的方法是返回一个元组:
我们知道要返回三个值,但它们是什么?第一个字符串是人名吗?第二个是姓氏吗?数字是年龄、位置还是社保号?这种编码方式很不透明,除非看函数内部,否则根本不知道它代表什么。
如果想改进,可以返回一个字典:
现在,我们至少能知道返回的属性是什么,但还是得看函数内部才能确定。某种程度上,类型变得更糟了,因为我们甚至不知道属性的数量和类型。而且,当函数变化时,比如字典的键被重命名或删除,类型检查器很难发现,调用者只能通过运行-崩溃-修改的繁琐循环来调整代码。
正确的解决方案是返回一个强类型的对象,并带有命名的参数。在Python中,这意味着要创建一个类。我猜很多人用元组或字典是因为定义一个类(还得给它起名字)比直接返回数据麻烦得多。但从Python 3.7开始(或者用polyfill包支持更早的版本),有了更简单的解决方案:dataclasses
。
虽然还是得给类起名字,但除此之外,这种方式非常简洁,而且所有属性都有类型注解。
通过这个数据类,函数的返回值变得非常明确。当我调用这个函数并处理返回值时,IDE的自动补全功能会显示属性的名称和类型。这听起来可能很小,但对我来说,这是提高效率的一大优势。此外,当代码重构或属性变化时,IDE和类型检查器会提醒我,并显示需要修改的地方,而不需要运行程序。对于一些简单的重构(比如属性重命名),IDE甚至可以自动完成这些更改。更重要的是,通过明确命名的类型,我可以建立一个共享的词汇表(比如Person
、City
),并与其他函数和类共用。
代数数据类型
Rust 有一个大多数主流语言缺乏的强大功能:代数数据类型(ADT)。它能明确描述数据的形状。比如处理数据包时,可以枚举所有可能的类型并为每种类型分配不同字段:
通过模式匹配,可以处理每种情况,编译器会检查是否遗漏了任何可能:
ADT 能确保无效状态不可表示,避免运行时错误。它在静态类型语言中特别有用,尤其是当需要统一处理一组类型时。如果没有 ADT,通常需要用接口或继承来实现。如果类型集是封闭的,ADT 和模式匹配是更好的选择。
在 Python 这样的动态类型语言中,虽然不需要为类型集设置共享名称,但类似 ADT 的结构仍然有用。比如可以用联合类型:
Packet
类型可以表示 Header
、Payload
或 Trailer
。虽然这些类没有明确的标识符来区分,但可以通过 isinstance
或模式匹配来处理:
在 Rust 中,遗漏情况会导致编译错误,而在 Python 中需要用 assert False
来处理意外数据。
联合类型的好处是它在类之外定义,减少了代码耦合。同一个类可以用于多个联合类型:
联合类型对自动序列化也非常有用。比如使用 pyserde
库,可以轻松序列化和反序列化联合类型:
联合类型还可以用于版本化配置,保持向后兼容性:
通过反序列化,可以读取所有旧版本的配置格式。
使用 NewType
在 Rust 中,定义不添加任何新行为的数据类型很常见,但这些数据类型用于指定其他常见数据类型(如整数)的域和预期用途。这种模式被称为 NewType
,例如 Python 中也有这种模式:
发现错误?
函数 get_ride_info
的参数位置颠倒了。由于汽车 ID 和驾驶员 ID 都是简单整数,因此类型是正确的,尽管函数调用在语义上是错误的。
我们可以通过使用 NewType
为不同类型的 ID 定义不同的类型来解决这个问题:
这是一个非常简单的模式,可以帮助捕捉那些难以发现的错误,尤其是在处理许多不同类型的 ID 和某些指标混合在一起时。
使用构造函数
Rust 并没有构造函数。相反,人们倾向于使用普通函数来创建(最好是正确初始化的)结构体实例。在 Python 中,没有构造函数重载的概念,所以如果你需要以多种方式构造一个对象,通常会产生一个带有许多参数的方法,这些参数以不同的方式用于初始化,并不能一起使用。
相反,我喜欢创建具有明确名称的 “构造函数”,这样就可以清楚地知道对象是如何构造的,以及是通过哪些数据构造的:
这样做可以使对象的构造更加清晰,不允许用户传递无效数据,并能更清楚地表达构造对象的意图。
写在最后
总之,我确信我的 Python 代码中还有更多的 “完整模式”,但以上是我目前能想到的全部。欢迎讨论!