受 Rust 启发,是时候改变 Python 编程方式了

开发 前端
Rust 并没有构造函数。相反,人们倾向于使用普通函数来创建(最好是正确初始化的)结构体实例。在 Python 中,没有构造函数重载的概念,所以如果你需要以多种方式构造一个对象,通常会产生一个带有许多参数的方法,这些参数以不同的方式用于初始化,并不能一起使用。

近年来,Rust因安全性受到科技公司青睐。其他主流语言能否借鉴Rust的思想?

在Rust中,错误使用接口会导致编译错误。在Python中,虽然错误代码仍能运行,但使用类型检查器(如pyright)或带类型分析的IDE(如PyCharm)可以获得快速反馈,发现潜在问题。

本文中,Python 中引入了 Rust 的一些理念:尽量使用类型提示,遵循“非法状态不可表示”原则。无论是长期维护的程序还是一次性脚本,我都这样做,因为后者往往会变成前者,而这种方法让程序更易理解和修改。

本文将展示一些应用此方法的Python示例,虽然不算高深,但记录下来或许有用。

类型提示

首先要尽可能使用类型提示,尤其是在函数说明和类属性中。当我看到这样的函数说明。

def find_item(records, check):
  • 1.

从函数说明本身来看,我完全不知道其中发生了什么:是列表、字典还是数据库连接?是布尔值还是函数?函数的返回值是什么?如果失败会发生什么?是抛出异常还是返回某个值?要找到这些问题的答案,我要么必须读取函数的主体(通常还要递归读取它调用的其他函数的主体,这非常烦人),要么只能读取它的文档(如果有的话)。虽然文档中可能包含有关函数的有用信息,但不一定要使用文档来回答前面的问题。许多问题都可以通过内置机制(即类型提示)来回答。

def find_item(
    records: List[Item],
    check: Callable[[Item], bool]
) -> Optional[Item]:
  • 1.
  • 2.
  • 3.
  • 4.

写函数说明要花更多时间吗?是的。

但这有问题吗?没有,除非我打字速度慢到每分钟只能敲几个字,但这很少见。明确写出类型能让我更清楚地思考函数到底提供了什么接口,以及如何让接口更严格,避免调用者用错。有了清晰的函数说明,我一眼就能知道怎么用这个函数、需要传什么参数、返回值是什么。而且,和文档注释不同,文档注释容易过时,但类型检查器会在类型变化时提醒我更新调用代码。如果我想了解某个东西的类型,直接看就行,非常直观。

当然,我也不是死板的人。如果一个参数的类型提示要嵌套五层,我通常会放弃,改用简单但不那么精确的类型。根据我的经验,这种情况很少见。如果真的遇到,那可能是代码设计有问题——如果一个参数既可以是数字、字符串、字符元组,又可以是字典映射字符串到整数,那可能意味着你需要重构和简化代码了。

使用数据类而非元组或字典

使用类型提示只是一方面,它只是描述了函数的接口,第二步是尽可能准确地 “锁定 ”这些接口。一个典型的例子是从函数返回多个值(或单个复杂值),懒惰而快速的方法是返回一个元组:

def find_person() -> Tuple[str, str, int]:
  • 1.

我们知道要返回三个值,但它们是什么?第一个字符串是人名吗?第二个是姓氏吗?数字是年龄、位置还是社保号?这种编码方式很不透明,除非看函数内部,否则根本不知道它代表什么。

如果想改进,可以返回一个字典:

def find_person(...) -> Dict[str, Any]:
    ...
    return {
        "name": ...,
        "city": ...,
        "age": ...
    }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

现在,我们至少能知道返回的属性是什么,但还是得看函数内部才能确定。某种程度上,类型变得更糟了,因为我们甚至不知道属性的数量和类型。而且,当函数变化时,比如字典的键被重命名或删除,类型检查器很难发现,调用者只能通过运行-崩溃-修改的繁琐循环来调整代码。

正确的解决方案是返回一个强类型的对象,并带有命名的参数。在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:
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

虽然还是得给类起名字,但除此之外,这种方式非常简洁,而且所有属性都有类型注解。

通过这个数据类,函数的返回值变得非常明确。当我调用这个函数并处理返回值时,IDE的自动补全功能会显示属性的名称和类型。这听起来可能很小,但对我来说,这是提高效率的一大优势。此外,当代码重构或属性变化时,IDE和类型检查器会提醒我,并显示需要修改的地方,而不需要运行程序。对于一些简单的重构(比如属性重命名),IDE甚至可以自动完成这些更改。更重要的是,通过明确命名的类型,我可以建立一个共享的词汇表(比如PersonCity),并与其他函数和类共用。

代数数据类型

Rust 有一个大多数主流语言缺乏的强大功能:代数数据类型(ADT)。它能明确描述数据的形状。比如处理数据包时,可以枚举所有可能的类型并为每种类型分配不同字段:

enum Packet {
    Header { protocol: Protocol, size: usize },
    Payload { data: Vec<u8> },
    Trailer { data: Vec<u8>, checksum: usize }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

通过模式匹配,可以处理每种情况,编译器会检查是否遗漏了任何可能:

fn handle_packet(packet: Packet) {
    match packet {
        Packet::Header { protocol, size } => ...,
        Packet::Payload { data } | Packet::Trailer { data, ... } => println!("{data:?}")
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

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+
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

Packet 类型可以表示 HeaderPayloadTrailer。虽然这些类没有明确的标识符来区分,但可以通过 isinstance 或模式匹配来处理:

def handle_packet(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
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

在 Rust 中,遗漏情况会导致编译错误,而在 Python 中需要用 assert False 来处理意外数据。

联合类型的好处是它在类之外定义,减少了代码耦合。同一个类可以用于多个联合类型:

Packet = Header | Payload | Trailer
PacketWithData = Payload | Trailer
  • 1.
  • 2.

联合类型对自动序列化也非常有用。比如使用 pyserde 库,可以轻松序列化和反序列化联合类型:

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))
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

联合类型还可以用于版本化配置,保持向后兼容性:

Config = ConfigV1 | ConfigV2 | ConfigV3
  • 1.

通过反序列化,可以读取所有旧版本的配置格式。

使用 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)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

发现错误?

函数 get_ride_info 的参数位置颠倒了。由于汽车 ID 和驾驶员 ID 都是简单整数,因此类型是正确的,尽管函数调用在语义上是错误的。

我们可以通过使用 NewType 为不同类型的 ID 定义不同的类型来解决这个问题:

from typing import NewType
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-versa
info = db.get_ride_info(<error>driver_id</error>, <error>car_id</error>)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

这是一个非常简单的模式,可以帮助捕捉那些难以发现的错误,尤其是在处理许多不同类型的 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":
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

这样做可以使对象的构造更加清晰,不允许用户传递无效数据,并能更清楚地表达构造对象的意图。

写在最后

总之,我确信我的 Python 代码中还有更多的 “完整模式”,但以上是我目前能想到的全部。欢迎讨论!

责任编辑:武晓燕 来源: 数据STUDIO
相关推荐

2018-10-18 09:58:41

物联网IOT数字化

2020-08-11 08:55:42

VSCode开发代码

2017-04-18 18:59:04

2016-12-29 11:18:05

2022-03-02 09:49:14

Rust编程语言

2017-09-15 18:16:56

人工智能Python

2019-08-27 08:45:10

Python编程语言代码

2023-10-19 15:25:40

2017-02-17 07:46:29

2018-08-21 05:12:10

2024-01-02 07:34:38

CentOSLinuxRedhat

2021-10-28 19:10:51

RustPythonjs

2024-04-07 00:00:01

TypeScript语言REST

2013-06-05 13:49:41

EclipseIntelliJ

2015-06-15 11:05:13

DCIM数据中心

2021-10-09 14:35:20

物联网IOT人工智能

2022-07-06 23:28:53

元宇宙Web3.0

2019-11-27 14:27:33

编程语言PythonJava

2021-09-24 09:15:19

Windowsfx 1LinuxWindows 11

2012-08-29 14:00:23

点赞
收藏

51CTO技术栈公众号