像写 Rust 一样写 Python!(1)

上一篇 / 下一篇  2023-05-23 16:32:42

  几年前,我开始使用Rust编程,它逐渐改变了我使用其他编程语言(尤其是Python)设计程序的方式。在我开始使用Rust之前,我通常以一种非常动态和类型松散的方式编写Python代码,没有类型提示,到处传递和返回字典,偶尔回退到“字符串类型”接口。然而,在经历了Rust类型系统的严格性,并注意到它“通过构造”防止的所有问题之后,每当我回到Python并且没有得到相同的保证时,我突然变得非常焦虑。
  需要明确的是,这里的“保证”并不是指内存安全(Python本身是合理的内存安全),而是“稳健性”——设计很难或完全不可能被滥用的API的概念,从而防止未定义的行为和各种错误。在Rust中,错误使用的接口通常会导致编译错误。在Python中,您仍然可以执行此类不正确的程序,但如果您使用类型检查器(如pyright)或带有类型分析器的IDE(如PyCharm),您仍然可以获得类似级别的有关可能问题的快速反馈。
  最终,我开始在我的Python程序中采用Rust的一些概念。它基本上可以归结为两件事——尽可能多地使用类型提示,并坚持让非法状态无法表示的原则。我尝试对将维护一段时间的程序和 oneshot实用程序脚本都这样做。主要是因为根据我的经验,后者经常变成前者:)根据我的经验,这种方法导致程序更容易理解和更改。
  在本文中,我将展示几个应用于Python程序的此类模式示例。这不是火箭科学,但我仍然觉得记录它们可能会有用。
  注意:这篇文章包含了很多关于编写Python代码的观点。我不想在每句话中都加上“恕我直言”,所以将这篇文章中的所有内容仅作为我对此事的看法,而不是试图宣传一些普遍的真理:)另外,我并不是说所提出的想法是所有这些都是在Rust中发明的,当然,它们也被用于其他语言。
  一、ype hint
  首要的是尽可能使用类型提示,特别是在函数签名和类属性中。当我读到一个像这样的函数签名时:
  def find_item(records, check):
  我不知道签名本身发生了什么。是records列表、字典还是数据库连接?是check布尔值还是函数?这个函数返回什么?如果失败会发生什么,它会引发异常还是返回None?为了找到这些问题的答案,我要么必须去阅读函数体(并且经常递归地阅读它调用的其他函数的函数体——这很烦人),要么阅读它的文档(如果有的话)。虽然文档可能包含有关函数功能的有用信息,但没有必要将它也用于记录前面问题的答案。很多问题都可以通过内置机制——类型提示——来回答。
  def find_item(
    records: List[Item],
    check: Callable[[Item], bool]
  ) -> Optional[Item]:
  我写签名花了更多时间吗?是的。那是问题吗?不,除非我的编码受到每分钟写入的字符数的瓶颈,而这并没有真正发生。明确地写出类型迫使我思考函数提供的实际接口是什么,以及如何使其尽可能严格,以使其调用者难以以错误的方式使用它。通过上面的签名,我可以很好地了解如何使用该函数、将什么作为参数传递给它以及我期望从中返回什么。此外,与代码更改时很容易过时的文档注释不同,当我更改类型并且不更新函数的调用者时,类型检查器会对我大喊大叫。如果我对什么是Item感兴趣,我可以直接使用Go to definition并立即查看该类型的外观。
  在这方面,我不是一个绝对主义者,如果需要五个嵌套类型提示来描述单个参数,我通常会放弃并给它一个更简单但不精确的类型。根据我的经验,这种情况不会经常发生。如果它确实发生了,它实际上可能表明代码有问题——如果你的函数参数可以是一个数字、一个字符串元组或一个将字符串映射到整数的字典,这可能表明你可能想要重构和简化它。
  二、数据类(dataclass)而不是元组(tuple)或字典(dictionary)
  使用类型提示是一回事,但这仅仅描述了函数的接口是什么。第二步实际上是使这些接口尽可能精确和“锁定”。一个典型的例子是从一个函数返回多个值(或一个复杂的值)。懒惰而快速的方法是返回一个元组:
  def find_person(...) -> Tuple[str, str, int]:
  太好了,我们知道我们要返回三个值。这些是什么?第一个字符串是人的名字吗?第二串姓氏?电话号码是多少?是年龄吗?在某些列表中的位置?社会安全号码?这种输入是不透明的,除非你查看函数体,否则你不知道这里发生了什么。
  下一步“改进”这可能是返回一个字典:
  def find_person(...) -> Dict[str, Any]:
      ...
      return {
          "name": ...,
          "city": ...,
          "age": ...
      }
  现在我们实际上知道各个返回的属性是什么,但我们必须再次检查函数体才能找出答案。从某种意义上说,类型变得更糟,因为现在我们甚至不知道各个属性的数量和类型。此外,当这个函数发生变化并且返回的字典中的键被重命名或删除时,没有简单的方法可以用类型检查器找出来,因此它的调用者通常必须用非常手动和烦人的运行-崩溃-修改代码来改变循环。
  正确的解决方案是返回一个强类型对象,其命名参数具有附加类型。在Python中,这意味着我们必须创建一个类。我怀疑在这些情况下经常使用元组和字典,因为它比定义类(并为其命名)、创建带参数的构造函数、将参数存储到字段等容易得多。自Python 3.7 (并且更快地使用package 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)2。它是一个非常强大的工具,可以明确描述我的代码正在处理的数据的形状。例如,当我在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,这通常是使用OOP接口和/或继承来完成的。当使用的类型集是开放式的时,接口和虚方法有它们的位置,但是当类型集是封闭的,并且你想确保你处理所有可能的变体时,ADT和模式匹配更合适。
  在动态类型语言(如Python)中,实际上不需要为一组类型共享名称,主要是因为您甚至不必一开始就为程序中使用的类型命名。但是,通过创建联合类型,使用类似于ADT的东西仍然有用:
  @dataclass
  class Header:
    protocol: Protocol
    size: int
  @dataclass
  class Payload:
    data: str
  @dataclass
  class Trailer:
    data: str
    checksum: int
  Packet = typing.Union[Header, Payload, Trailer]
  # or `Packet = Header | Payload | Trailer` since Python 3.10
  Packet这里定义了一个新类型,它可以是报头、有效载荷或尾部数据包。当我想确保只有这三个类有效时,我现在可以在程序的其余部分中使用此类型(名称)。请注意,类没有附加明确的“标签”,因此当我们要区分它们时,我们必须使用eginstanceof或模式匹配:
  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
  可悲的是,在这里我们必须(或者更确切地说,应该)包括烦人的assert False分支,以便函数在接收到意外数据时崩溃。在Rust中,这将是一个编译时错误。
  注意:Reddit上的几个人已经提醒我,assert False实际上在优化构建( ) 中完全优化掉了python -O ...。因此,直接引发异常会更安全。还有typing.assert_never来自Python 3.11 的,它明确地告诉类型检查器落到这个分支应该是一个“编译时”错误。
  联合类型的一个很好的属性是它是在作为联合一部分的类之外定义的。因此该类不知道它被包含在联合中,这减少了代码中的耦合。您甚至可以使用相同的类型创建多个不同的联合:
  Packet = Header | Payload | Trailer
  PacketWithData = Payload | Trailer
  联合类型对于自动(反)序列化也非常有用。最近我发现了一个很棒的序列化库,叫做pyserde,它基于古老的Rustserde序列化框架。在许多其他很酷的功能中,它能够利用类型注释来序列化和反序列化联合类型,而无需任何额外代码:
  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.我一直在寻找类似的功能,因为它对(反)序列化联合类型非常有用。dataclasses_json但是,在我尝试过的大多数其他序列化库(例如或)中实现它非常烦人dacite。
  例如,在使用机器学习模型时,我使用联合将各种类型的神经网络(例如分类或分段CNN模型)存储在单个配置文件格式中。我还发现对不同格式的数据(在我的例子中是配置文件)进行版本化很有用,如下所示:
  Config = ConfigV1 | ConfigV2 | ConfigV3
  通过反序列化Config,我能够读取所有以前版本的配置格式,从而保持向后兼容性。
  四、使用newtype
  在Rust中,定义不添加任何新行为的数据类型是很常见的,但只是用于指定其他一些非常通用的数据类型(例如整数)的域和预期用途。这种模式被称为“newtype”3,它也可以用在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-versa
  info = db.get_ride_info(<error>driver_id</error>, <error>car_id</error>)
  这是一个非常简单的模式,可以帮助捕获难以发现的错误。它特别有用,例如,如果你正在处理许多不同类型的ID (CarId vs DriverId)或某些不应混合在一起的指标(Speed vs Lengthvs等)。Temperature
  五、使用构造函数
  我非常喜欢Rust的一件事是它本身没有构造函数。相反,人们倾向于使用普通函数来创建(理想情况下正确初始化)结构实例。在Python中,没有构造函数重载,因此如果您需要以多种方式构造一个对象,有人会导致一个__init__方法有很多参数,这些参数以不同的方式用于初始化,并且不能真正一起使用。
  相反,我喜欢创建具有明确名称的“构造”函数,这使得如何构造对象以及从哪些数据构造对象变得显而易见:
  class Rectangle:
      @staticmethod
      def from_x1x2y1y2(x1: float, ...) -> "Rectangle":
      
      @staticmethod
      def from_tl_and_size(top: float, left: float, width: float, height: float) -> "Rectangle":
  这使得构造对象变得更加清晰,并且不允许类的用户在构造对象时传递无效数据(例如通过组合y1和width)。

TAG: 软件开发 Python

 

评分:0

我来说两句

Open Toolbar