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