近几年,Rust 以其安全可靠性有名,渐渐被数十家科技巨擘所亲吻——那么,其它非主流语言是否能参照 Rust 的程式结构设计思想呢?责任编辑翻译者以 Python 为例,做了一番试著。
书名镜像:https://kobzol.github.io/rust/python/2023/05/20/writing-python-like-its-rust.html
需经允许,明令禁止转发!
翻译者 | Jakub Ber á nek
翻译者 | ChatGPT 白眉林 | 郑丽媛
公司出品 | CSDN(ID:CSDNnews)
从几年前已经开始,我试著用 Rust 进行程式结构设计,它渐渐改变了我在其它程式结构设计语言中结构设计流程的形式,尤其是 Python。
在已经开始用 Rust 之前,我一般来说是以一种非常静态、不太细致的形式来撰写 Python 标识符,没有类别提示信息,到处传达和回到词典,偶而还班莱班县到 ” 数组类别 ” USB。然而,在新体验了 Rust 类别系统的严格性,并注意到它 ” 通过 construction” 避免的所有难题后,每每我回到 Python 时,就会突然变得相当恐惧,即使我没有获得同样的确保。
明确一点,我在这里所言的 ” 确保 ” 并并非指内存安全可靠(Python 在旧有情况下已相对安全可靠),而是指 ” 卢戈韦 ” ——结构设计极难或根本不可能将被误用的 API,从而避免enum行为和各种严重错误的基本概念。
在 Rust 中,严重错误采用的USB一般来说会导致校对严重错误。而在 Python 中,这种的严重错误流程还是能执行的,但如果你采用类别检查器(如 pyright)或暗含类别解析器的 IDE(如 PyCharm),你就能获得类似水平的快速意见反馈,以了解可能将存在的问题。
最终,我已经开始在我的 Python 流程中采用一些来自 Rust 的基本概念,基本上能归因于三点:尽量将采用类别提示信息,和坚持经典之作的 ” 使违法状态不可表示 ” 原则。我打声对那些将被维护一段时间的程序和纸制新颖JAVA都这种做——即使根据我的经验,后者往往会变成前者,而这种形式会让流程更更易理解和修正。
在责任编辑中,我将展现几个将该形式应用于 Python 流程的实例。虽然这并并非什么深奥的科学,但我觉得把它们纪录下来可能将会管用。
注意:责任编辑中包含了很多关于撰写 Python 标识符的观点,我不想在每句话中都加上 ” 在我看来 “,所以请把责任编辑中的一切都仅仅看作是我对此难题的观点,而并非试图宣传某些普遍真理。同样,我也不主张责任编辑所提出的想法都是在 Rust 中发明的,它们在其它语言中也有采用。
类别提示信息首先,最重要的是要尽量将地采用类别提示信息,特别是在函数签名和类属性中。当我看到一个像这种的函数签名时:
def find_item ( records, check ) :
从函数签名本身来看,我完全无法理解其中发生了什么:它是一个列表,词典还是数据库连接?是一个布尔值还是函数?这个函数的回到值是什么?如果它失败了会发生什么?会引发异常还是回到某个值?要找到这些难题的答案,我要么去阅读函数的主体(一般来说还要递归地阅读它调用的其它函数的主体,这非常烦人),要么只能阅读它的文档(如果有的话)。虽然文档中可能将包含了关于该函数的管用信息,但不应该必须采用文档来回答前面的难题。很多难题能通过内置机制,即类别提示信息来回答。
def find_item ( records: List [ Item ] , check: Callable [ [ Item ] , bool ] ) -> Optional [ Item ] :
写函数签名是否花费更多时间?是的。但这是个难题吗?并非,除非我的编码速度受到每分钟写入字符数量的限制,而这并不常见。明确地写出类别,迫使我思考函数实际提供的USB是什么,和如何使其尽量将严格,让调用者难以严重错误地采用它。通过上面的函数签名,我能很好地了解如何采用函数,传达什么参数,以及能期望从函数中回到什么。此外,与文档注释不同的是,当标识符发生变化时,文档注释很容易过时,而当我更改类别但未更新函数的调用者时,类别检查器会提醒我。如果我对什么感兴趣,我也能直接采用,并立即看到该类别看起来是怎样的。
当然,我并并非绝对主义者,如果描述单个参数需要嵌套五层类别提示信息,我一般来说会放弃,并采用一个更简单但不太精确的类别。根据我的经验,这种情况不常发生,如果它真的发生了,它实际上可能将预示了标识符的难题——如果你的函数参数既能是数字,又能是数组元组或将数组映射为整数的词典,这可能将意味着你需要重构和简化它。
采用数据类(Dataclasses)代替元组或词典
采用类别提示信息只是一方面,它仅描述了函数的USB是什么,第二步是尽量将准确地 ” 锁定 ” 这些USB。一个典型的例子是,从函数回到多个值(或单个复杂值),有一种懒惰且快速的形式是回到一个元组:
def find_person ( … ) -> Tuple [ str, str, int ] :
很好,我们知道我们要回到三个值,它们是什么?第一个数组是这个人的名字吗?第二个数组是姓氏吗?数字是什么?是年龄吗?还是某个列表中的位置?亦或是社会保障号码?这种类别的编码并不透明,除非你查看函数体,否则你根本不知道这代表着什么。
接下来如果要 ” 改进 ” 这一点,能回到一个词典:
def find_person ( … ) -> Dict [ str, Any ] : … return { “name”: …, “city”: …, “age”: … }
现在,我们实际上能知道各个回到属性是什么了,但我们又必须检查函数体才能发现。从某种意义上说,这个类别变得更糟了,即使现在我们甚至不知道各个属性的数量和类别。此外,当这个函数发生变化,回到的词典中的键被重命名或删除时,用类别检查器是不容易发现的,因此调用者一般来说必须经历非常繁琐的手动运行 – 崩溃 – 修正标识符循环来进行更改。
正确的解决方案是,回到一个具有附加类别的命名参数的强类别对象。在 Python 中,这意味着我们需要创建一个类。我怀疑在这些情况下经常采用元组和词典,是即使相较于定义一个类(并为其命名),创建带参数的构造函数、将参数存储到字段中等要简单得多。自从 Python 3.7(和采用 polyfill 包的更早版本)版本之后,有了一个更快捷的解决方案:.dataclasses。
@dataclasses.dataclassclass City: name: str zip_code: int
@dataclasses.dataclassclass Person: name: str city: City age: int
def find_person ( … ) -> Person:
你仍然需要为创建的类想一个名字,但除此之外,它已尽量将简洁,而且你能获得所有属性的类别注释。
通过这个数据类,我明确了函数回到的内容。当我调用这个函数并处理回到值时,IDE 的自动完成功能会显示属性的名称和类别。听起来这可能将很微不足道,但对我来说,这是一个很大的生产力优势。此外,当标识符被重构、属性发生变化时,我的 IDE 和类别检查器会提醒我,并显示所有需要更改的位置,无需我执行流程。对于一些简单的重构(如属性重命名),IDE 甚至能为我进行这些更改,此外,通过明确命名的类别,我能建立一个词汇表(例如 Person、City),然后与其它函数和类共享。
代数数据类别
对我而言,在采用大多数非主流语言时,最缺乏一项 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,一般来说会采用面向对象的USB或继承来实现这一点。当采用的类别集是开放式的时候,USB和虚拟形式能解决,但当类别集是封闭的时候,并且你想确保处理所有可能将的变体时,ADT 和模式匹配更加合适。
在像 Python 这种的静态类别语言中,实际上没有必要为一组类别起一个共享的名字,主要是即使在流程中采用的类别最初并不需要命名。不过使用类似 ADT 的工具仍然很有意义,例如能创建一个联合类别:
@dataclassclass Header: protocol: Protocol size: int
@dataclassclass Payload: data: str
@dataclassclass Trailer: data: str checksum: 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 False
def 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 | TrailerPacketWithData = Payload | Trailer
联合类型对于自动(反)序列化也非常管用。最近我发现了一个很棒的序列化库叫做 pyserde,它是基于备受推崇的 Rust serde 序列化框架开发的。除了许多其它不错的功能之外,它能利用类别注释来序列化和反序列化联合类别,而无需撰写额外的标识符:
import serde
…Packet = Header | Payload | Trailer
@dataclassclass 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
通过反序列化,我能读取所有以前版本的配置格式,从而保持向后兼容。
采用 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 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-versainfo = db.get_ride_info ( <error>driver_id</error>, <error>car_id</error> )
这是一个非常简单的模式,能帮助捕捉那些难以发现的严重错误,尤其适合处理许多不同类别的 ID 和某些混在一起的度量指标。
采用构造函数
我很喜欢 Rust 的一点是,它没有真正意义上的构造函数。相反,人们倾向于采用普通函数来创建(最好是正确初始化的)结构体实例。在 Python 中,没有构造函数重载的基本概念,因此如果你需要以多种形式构造一个对象,一般来说会导致一个形式有很多参数,这些参数以不同的形式用于初始化,而且不能真正地一起采用。
相反,我喜欢用一个明确的名字来创建 ” 构造 ” 函数,以便清楚地了解如何构造对象和从哪些数据中构造:
class Rectangle: @staticmethod def from_x1x2y1y2 ( x1: float, … ) -> “Rectangle”: @staticmethod def from_tl_and_size ( top: float, left: float, width: float, height: float ) -> “Rectangle”:
这种做能使对象的构造更清晰,并且不允许用户传达无效数据,也更加清晰地表达了构造对象的意图。
用类别对不变量进行编码
用类别系统本身来编码在运行时只能追踪的不变量,是一个非常通用且强大的基本概念。在 Python(和其它非主流语言)中,我经常看到由一大堆可变状态组成的复杂类,导致这种混乱的原因之一是:标识符试图在运行时跟踪对象的不变量。它必须考虑许多在理论上可能将发生的情况,即使这些情况并没有被类别系统排除(例如 ” 如果客户端被要求断开连接,但有人试著向其发送消息,而 Socket 仍处于连接状态 ” 等)
客户端
下面是一个典型的例子:
class Client: “”” Rules: – Do not call `send_message` before calling `connect` and then `authenticate`. – Do not call `connect` or `authenticate` multiple times. – Do not call `close` without calling `connect`. – Do not call any method after calling `close`. “”” def __init__ ( self, address: str ) :
def connect ( self ) : def authenticate ( self, password: str ) : def send_message ( self, msg: str ) : def close ( self ) :
……很简单,对吧?你只需要仔细阅读文档,并确保永远不会违反提到的规则(以免引发enum行为或崩溃)。另一种形式是在类中填入各种断言,在运行时检查所有提到的规则,这将导致混乱的标识符、遗漏的边缘情况和出错时较慢的意见反馈(校对时与运行时之间的区别)。难题的核心在于客户端能存在于各种(互斥的)状态中,但它们并没有被分别建模成单独的类别,而是全部合并到一个类别中。
让我们看看,是否能通过将不同状态拆分为单独的类别来改进这一点。
(1)首先,一个没有连接到任何东西的客户端是否有意义?似乎没有。在调用之前,这种一个没有连接的客户端无法执行任何操作。那为什么要允许这种状态存在呢?我们能创建一个构造函数,它将返回一个连接的客户端:Clientconnectconnect。
def connect ( address: str ) -> Optional [ ConnectedClient ] : pass
class ConnectedClient: def authenticate ( … ) : def send_message ( … ) : def close ( … ) :
如果函数成功,它将回到一个遵守 ” 已连接 ” 不变式的客户端,你也不能再调用它来搞乱事情。如果连接失败,该函数可引发异常或回到一些显式严重错误。
(2)类似的形式也可用于状态。我们能引入另一个类别,它拥有客户端既连接又认证的不变性:authenticated。
class ConnectedClient: def authenticate ( … ) -> Optional [ “AuthenticatedClient” ] :
class AuthenticatedClient: def send_message ( … ) : def close ( … ) :
只有当我们真正有了一个实例,我们才能已经开始发送消息。
(3)最后一个难题是形式。在 Rust 中(得益于破坏性移动语义),我们能够表达这种一个事实:当形式被调用时,你不能再采用客户端。但这在 Python 中是不可能将的,所以我们必须采用一些变通办法。有一个解决方案是班莱班县到运行时跟踪,在客户端引入一个布尔属性,并断言它还没有被关闭。另一种形式是完全删除该形式,只将客户端作为一个上下文管理器:
with connect ( … ) as client: client.send_message ( “foo” ) # Here the client is closed
由于没有可用的形式,你无法意外地关闭客户端两次。
强类别的边界框
目标检测是一项我有时会参与的计算机视觉任务,其中流程必须在图像中检测一组边界框。边界框基本上是暗含一些附加数据的矩形,在实现目标检测时,它们随处可见。不过边界框有一个令人讨厌的难题是:有时它们是规范化的(矩形的坐标和大小在区间内),但有时它们是非规范化的(坐标和大小受其所附图像的尺寸限制)。当你通过许多数据预处理或后处理的函数发送边界框时,很容易混淆这一点,例如多次规范化边界框,这就会导致非常麻烦的调试严重错误。
这种情况发生过好几次,所以我决定:将这两种类别的边界框拆分为两个单独的类别,以此来有效解决难题:NormalizedBoundingBox 和 DenormalizedBoundingBox。
@dataclassclass NormalizedBBox: left: float top: float width: float height: float
@dataclassclass DenormalizedBBox: left: float top: float width: float height: float
这种分离之后,规范化和非规范化的边界框就不容易混淆了。不过我们还能再做一些改进,把标识符变得更符合 ” 人体工学 “。
(1)通过组合或继承来减少重复:
@dataclassclass BBoxBase: left: float top: float width: float height: float
# Compositionclass NormalizedBBox: bbox: BBoxBase
class DenormalizedBBox: bbox: BBoxBase
Bbox = Union [ NormalizedBBox, DenormalizedBBox ]
# Inheritanceclass NormalizedBBox ( BBoxBase ) :class DenormalizedBBox ( BBoxBase ) :
(2)添加一个运行时检查,以确保边界框确实是规范化的:
class NormalizedBBox ( BboxBase ) : def __post_init__ ( self ) : assert 0.0 <= self.left <= 1.0 …
(3)添加一个在两种表示之间进行转换的形式。在某些情况下,我们可能将想要知道明确的表示形式,但有时候我们也希望能采用通用USB(” 任何类别的边界框 “)进行操作。在这种情况下,我们应该能够将 ” 任何边界框 ” 转换为其中一种表示形式:
class BBoxBase: def as_normalized ( self, size: Size ) -> “NormalizeBBox”: def as_denormalized ( self, size: Size ) -> “DenormalizedBBox”:
class NormalizedBBox ( BBoxBase ) : def as_normalized ( self, size: Size ) -> “NormalizedBBox”: return self def as_denormalized ( self, size: Size ) -> “DenormalizedBBox”: return self.denormalize ( size )
class DenormalizedBBox ( BBoxBase ) : def as_normalized ( self, size: Size ) -> “NormalizedBBox”: return self.normalize ( size ) def as_denormalized ( self, size: Size ) -> “DenormalizedBBox”: return self
通过这个USB,我能兼顾正确性和人性化的统一界面。
注意:如果你想给父类 / 基类添加一些共享形式,回到对应类的实例,你能在 Python 3.11 中使用 typing.Self。
class BBoxBase: def move ( self, x: float, y: float ) -> typing.Self: …
class NormalizedBBox ( BBoxBase ) : …
bbox = NormalizedBBox ( … ) # The type of `bbox2` is `NormalizedBBox`, not just `BBoxBase`bbox2 = bbox.move ( 1, 2 )
更安全可靠的互斥锁
在 Rust 中,互斥锁一般来说通过一个非常好的USB提供,这有两个好处:
(1)当你锁定互斥锁时,会获得一个 ” 守卫 ” 对象,该对象在销毁时可自动解锁互斥锁,主要利用了可靠的 RAII 机制:
{ let guard = mutex.lock ( ) ; // locked here …} // automatically unlocked here
这意味着,不会出现忘记解锁互斥锁的情况。在 C++ 中也有类似机制如 std::mutex,但它提供了一种没有 ” 守卫 ” 对象的显式 / USB,这意味着其仍可能将被严重错误采用。
数据:
let lock = Mutex::new ( 41 ) ; // Create a mutex that stores the data insidelet guard = lock.lock ( ) .unwrap ( ) ; // Acquire guard*guard += 1; // Modify the data using the guard
这与非主流语言(包括 Python)中常见的互斥锁 API 完全不同——在非主流语言中,互斥锁和受其保护的数据是分开的,因此在访问数据之前很容易忘记锁定互斥锁:
mutex = Lock ( )
def thread_fn ( data ) : # Acquire mutex. There is no link to the protected variable. mutex.acquire ( ) data.append ( 1 ) mutex.release ( )
data = [ ] t = Thread ( target=thread_fn, args= ( data, ) ) t.start ( )
# Here we can access the data without locking the mutex.data.append ( 2 ) # Oops
虽然在 Python 中,我们无法获得与 Rust 完全相同的功能,但它也并非一无是处。Python 锁实现了上下文管理器USB,这意味着你能在标识符块中采用它们,确保它们在作用域结束时自动解锁,甚至我们还能更进一步:采用 with 语句。
import contextlibfrom threading import Lockfrom typing import ContextManager, Generic, TypeVar
T = TypeVar ( “T” )
# Make the Mutex generic over the value it stores.# In this way we can get proper typing from the `lock` method.class Mutex ( Generic [ T ] ) : # Store the protected value inside the mutex def __init__ ( self, value: T ) : # Name it with two underscores to make it a bit harder to accidentally # access the value from the outside. self.__value = value self.__lock = Lock ( )
# Provide a context manager `lock` method, which locks the mutex, # provides the protected value, and then unlocks the mutex when the # context manager ends. @contextlib.contextmanager def lock ( self ) -> ContextManager [ T ] : self.__lock.acquire ( ) try: yield self.__value finally: self.__lock.release ( )
# Create a mutex wrapping the datamutex = Mutex ( [ ] )
# Lock the mutex for the scope of the `with` blockwith mutex.lock ( ) as value: # value is typed as `list` here value.append ( 1 )
采用这种结构设计,只有在锁定互斥锁之后,你才能访问受保护的数据。显然,这仍是 Python,如果你是故意的,不变量仍能被破坏——但这个形式已使得在 Python 中采用互斥锁USB更加安全可靠。
总之,我确信在我的 Python 标识符中还有更多的 ” 卢戈韦模式 “,但以上是我目前能想到的全部。如果你也有一些类似想法的例子或意见,欢迎留言告诉我。