社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

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

数据分析与开发 • 3 周前 • 35 次点击  

近年来,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 类型可以表示 HeaderPayload 或 Trailer。虽然这些类没有明确的标识符来区分,但可以通过 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

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

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

Packet = Header | Payload | Trailer
PacketWithData = Payload | Trailer

联合类型对自动序列化也非常有用。比如使用 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))

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

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
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(driver_id, car_id)

这是一个非常简单的模式,可以帮助捕捉那些难以发现的错误,尤其是在处理许多不同类型的 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 代码中还有更多的 “完整模式”,但以上是我目前能想到的全部。欢迎讨论!

推荐阅读  点击标题可跳转

1、西部数据突然宣布:退出 SSD 市场!

2、55 岁程序员预感被裁,提前在服务器埋雷,被裁当天数千员工集体掉线…最绝的是他给检方送了一堆证据

3、代码界的“瘟疫”?卡帕西“Vibe Coding”兴起,YC披露:1/4新创公司,95%代码全由AI生成


Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/180328
 
35 次点击