使用 Rust 后,我使用 Python 的方式发生了变化
Using type hints where possible, and sticking to the classic “make illegal state unrepresentable” principle.
尽可能使用类型提示,并坚持经典的“使非法状态不可表示”原则。
近年来,Rust 因其安全性而闻名,并逐渐被各大科技公司所拥抱——那么,其他主流语言是否可以参考 Rust 的编程思想呢?我以Python为例,做了一些尝试。
几年前我开始使用 Rust 进行编程,它逐渐改变了我用其他编程语言(尤其是 Python)设计程序的方式。在开始 Rust 之前,我通常以一种非常动态、不太严格的方式编写 Python 代码,没有类型提示,到处传递和返回字典,偶尔会回到“字符串类型”接口。然而,在体验了 Rust 类型系统的严格性,并注意到它“通过构造”防止的所有问题之后,每当我返回 Python 时,我都会突然变得相当焦虑,因为我没有得到同样的保证。
需要明确的是,我在这里所说的“保证”不是内存安全(Python 本身就已经相对安全),而是“理智”——设计难以或不可能被滥用的 API,以便防止未定义行为的概念以及各种错误。
在 Rust 中,错误使用接口通常会导致编译错误。在Python中,这样的错误程序仍然可以执行,但是如果您使用类型检查器(例如pyright)或带有类型分析器的IDE(例如PyCharm),您可以获得类似级别的快速反馈以了解潜在问题。
最终,我开始在我的 Python 程序中采用 Rust 的一些概念,这些概念基本上可以归结为两件事:尽可能使用类型提示,并坚持经典的“使非法状态不可表示”原则。我尝试对将要维护一段时间的程序以及一次性实用程序脚本执行此操作 - 因为根据我的经验,后者往往会成为前者,并且这种方法使程序更易于理解和修改。
在本文展示l了一些将此方法应用于 Python 程序的示例。虽然这不完全是先进的科学,但记录它们可能会有用。
Type Hint 类型提示
First and foremost, use type hints wherever possible, especially in function signatures and class attributes. When I see a function signature like this:
首先也是最重要的,尽可能使用类型提示,尤其是在函数签名和类属性中。当我看到这样的函数签名时:
def find_item(records, check):
Looking at the function signature itself, I have absolutely no idea what’s going on in it: is it a list, dictionary or database connection? Is it a boolean or a function? What is the return value of this function? What happens if it fails? Will it throw an exception or return some value? To find answers to these questions, I either have to read the function’s body (and usually recursively read the bodies of other functions it calls, which is very annoying), or I can only read its documentation (if there is one). While the documentation may contain useful information about the function, it should not be necessary to use the documentation to answer the preceding question. Many questions can be answered by a built-in mechanism, namely type hints.
看看函数签名本身,我完全不知道其中发生了什么:它是列表、字典还是数据库连接?它是布尔值还是函数?这个函数的返回值是多少?如果失败会怎样?它会抛出异常或返回一些值吗?为了找到这些问题的答案,我要么必须阅读函数的主体(并且通常递归地阅读它调用的其他函数的主体,这非常烦人),要么我只能阅读它的文档(如果有的话)。虽然文档可能包含有关该函数的有用信息,但不必使用文档来回答前面的问题。许多问题可以通过内置机制(即类型提示)来回答。
def find_item(records: List[Item],check: Callable[[Item], bool]
) -> Optional[Item]:
Does writing the function signature take more time? Yes.
编写函数签名是否需要更多时间?是的。
But is this a problem? No, unless my encoding speed is limited by the number of characters written per minute, which is not common. Writing out the type explicitly forces me to think about what interface the function actually provides, and how to make it as strict as possible so that it’s hard for callers to use it incorrectly. With the function signature above, I can get a good idea of how to use the function, what parameters to pass, and what can be expected to return from the function. Also, unlike doc comments, which are easily outdated when the code changes, the type checker alerts me when I change types but don’t update the function’s callers. If I’m interested in something, I can also just use it and immediately see what that type looks like.
但这有问题吗?不,除非我的编码速度受到每分钟写入的字符数的限制,这并不常见。显式写出类型迫使我思考该函数实际提供的接口是什么,以及如何使其尽可能严格,以便调用者很难错误地使用它。通过上面的函数签名,我可以很好地了解如何使用该函数、要传递哪些参数以及函数预计会返回什么。此外,与代码更改时很容易过时的文档注释不同,类型检查器会在我更改类型但不更新函数的调用者时提醒我。如果我对某些东西感兴趣,我也可以直接使用它并立即看到该类型是什么样子。
Of course, I’m not an absolutist, and if describing a single parameter requires nesting five levels of type hints, I’ll usually give up and use a simpler but less precise type. In my experience, this doesn’t happen very often, and if it does, it might actually signal a problem with your code — if your function arguments can be both numbers and tuples of strings or characters A dictionary that maps strings to integers, which probably means you need to refactor and simplify it.
当然,我不是绝对主义者,如果描述单个参数需要嵌套五层类型提示,我通常会放弃并使用更简单但不太精确的类型。根据我的经验,这种情况并不经常发生,如果发生的话,它实际上可能表明你的代码有问题 - 如果你的函数参数可以是数字和字符串或字符的元组将字符串映射到整数的字典,它可能意味着您需要重构和简化它。
使用Dataclasses 数据类而不是Tuples 元组或Dictionaries字典
使用类型提示只是一件事,它只描述了函数的接口是什么,第二步是尽可能准确地“锁定”这些接口。一个典型的例子是从函数返回多个值(或单个复杂值),一种懒惰而快速的方法是返回一个元组:
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: strzip_code: int@dataclasses.dataclass
class Person:name: strcity: Cityage: int
def find_person(...) -> Person:
您仍然需要为创建的类考虑一个名称,但除此之外它尽可能干净,并且您可以获得所有属性的类型注释。
通过这个数据类,我明确了函数返回的是什么。当我调用此函数并处理返回值时,IDE 的自动完成功能会显示属性的名称和类型。这听起来可能微不足道,但对我来说,这是一个巨大的生产力优势。此外,当代码重构和属性更改时,我的 IDE 和类型检查器会提醒我,并向我显示需要在何处进行所有更改,而无需我执行程序。对于一些简单的重构(例如属性重命名),IDE 甚至可以为我进行这些更改,此外,通过显式命名的类型,我可以构建一个词汇表(例如 Person、City),然后与其他函数和类共享。
Algebraic Data Types 代数数据类型
对我来说,Rust 有一个大多数主流语言最缺乏的功能:代数数据类型(ADT)。它是一个非常强大的工具,可以显式描述代码处理的数据的形状。例如,当我在 Rust 中处理数据包时,我可以显式枚举收到的所有可能类型的数据包,并为每个数据包分配不同的数据(字段):
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: Protocolsize: int@dataclass
class Payload:data: str@dataclass
class Trailer:data: strchecksum: int
Packet = typing.Union[Header, Payload, Trailer]
# or `Packet = Header | Payload | Trailer` since Python 3.10
这里,Packet 定义了一个新类型,可以表示报头、有效负载或尾部数据包。但是,这些类别之间没有明确的标识符来区分它们,因此当您想在程序中区分它们时,可以使用一些方法,例如使用“instanceof”运算符或模式匹配。
def handle_is_instance(packet: Packet):if isinstance(packet, Header):print("header {packet.protocol} {packet.size}")elif isinstance(packet, Payload):print("payload {packet.data}")elif isinstance(packet, Trailer):print("trailer {packet.checksum} {packet.data}")else:assert Falsedef handle_pattern_matching(packet: Packet):match packet:case Header(protocol, size): print(f"header {protocol} {size}")case Payload(data): print("payload {data}")case Trailer(data, checksum): print(f"trailer {checksum} {data}")case _: assert False
这里,我们必须在代码中包含一些分支逻辑,以便函数在收到意外数据时崩溃。在 Rust 中,这将是一个编译时错误,而不是 .assert False
。
联合类型的好处之一是它是在联合类之外定义的。因此,该类不知道它包含在联合中,这减少了代码耦合。此外,您甚至可以使用同一类创建多个不同的联合类型:
Packet = Header | Payload | Trailer
PacketWithData = Payload | Trailer
联合类型对于自动(反)序列化也非常有用。最近我发现了一个很棒的序列化库,名为 pyserde,它基于备受推崇的 Rust serde 序列化框架。在许多其他不错的功能中,它利用类型注释来序列化和反序列化联合类型,而无需编写额外的代码:
import serde...
Packet = Header | Payload | Trailer
@dataclass
class Data:packet: Packet
serialized = serde.to_dict(Data(packet=Trailer(data="foo", checksum=42)))
# {'packet': {'Trailer': {'data': 'foo', 'checksum': 42}}}
deserialized = serde.from_dict(Data, serialized)
# Data(packet=Trailer(data='foo', checksum=42))
您甚至可以选择如何序列化联合标签,就像使用 serde 一样。我长期以来一直在寻找类似的功能,因为它对于序列化和反序列化联合类型非常有用。然而,在我尝试过的大多数其他序列化库中,实现这一点相当乏味。
例如,在使用机器学习模型时,我可以使用联合类型将各种类型的神经网络(例如分类或分割 CNN 模型)存储在单个配置文件中。我还发现对不同版本的数据进行版本控制很有用,如下所示:
Config = ConfigV1 | ConfigV2 | ConfigV3
通过反序列化,我可以读取所有以前版本的配置格式,从而保持向后兼容性。
Use NewType 使用新类型
在 Rust 中,定义不添加任何新行为的数据类型是很常见的,但用于指定某些其他常见数据类型(例如整数)的域和预期用途。这种模式称为“NewType”,在 Python 中也可用,例如:
class Database:def get_car_id(self, brand: str) -> int:def get_driver_id(self, name: str) -> int:def get_ride_info(self, car_id: int, driver_id: int) -> RideInfo:db = Database()car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
info = db.get_ride_info(driver_id, car_id)
发现错误?
get_ride_info 函数的参数位置颠倒了。由于汽车 ID 和驾驶员 ID 是简单整数,因此类型是正确的,尽管函数调用在语义上是错误的。
我们可以通过使用“NewType”为不同类型的 ID 定义单独的类型来解决这个问题:
from typing import NewTypefrom typing import NewType# Define a new type called "CarId", which is internally an `int`
CarId = NewType("CarId", int)
# Ditto for "DriverId"
DriverId = NewType("DriverId", int)
class Database:def get_car_id(self, brand: str) -> CarId:def get_driver_id(self, name: str) -> DriverId:def get_ride_info(self, car_id: CarId, driver_id: DriverId) -> RideInfo:
db = Database()
car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
# Type error here -> DriverId used instead of CarId and vice-versa
info = db.get_ride_info(<error>driver_id</error>, <error>car_id</error>)
这是一个非常简单的模式,可以帮助捕获那些难以发现的错误,特别是在处理许多不同类型的 ID 和混合在一起的某些指标时。
Use Constructor 使用构造函数
我真正喜欢 Rust 的原因之一是它实际上没有构造函数。相反,人们倾向于使用普通函数来创建(最好是正确初始化的)结构体实例。在Python中,没有构造函数重载的概念,因此如果需要以多种方式构造一个对象,通常会导致一个方法有很多参数,这些参数以不同的方式用于初始化,并且不能真正一起使用。
Instead, I like to create “constructor” functions with an explicit name so that it’s clear how the object is constructed and from what data:
相反,我喜欢创建具有显式名称的“构造函数”函数,以便清楚地了解对象是如何构造的以及由哪些数据构造:
class Rectangle: @staticmethoddef from_x1x2y1y2(x1: float, ...) -> "Rectangle":@staticmethoddef from_tl_and_size(top: float, left: float, width: float, height: float) -> "Rectangle":
这样做使得对象的构造更加清晰,不允许用户传递无效数据,并且更清楚地表达构造对象的意图。
Conclusion 结论
无论如何,我确信Python 代码中还有更多“完整模式”,但目前我能想到的就是以上这些。如果您也有一些类似想法的例子或意见,请留下回复并告诉我。