Python 类不要再写 __init__ 方法了

打印 上一主题 下一主题

主题 1877|帖子 1877|积分 5635

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

x
花下猫语:我们周刊第 98 期分享过一篇文章,它指出了 __init__ 方法存在的题目和新的最佳实践,第 99 期也分享了一篇文章佐证了第一篇文章的观点。我认为它们提出的是一个值得注意和思考的题目,因此将第一篇文章翻译成了中文。
原作:Glyph
译者:豌豆花下猫@Python猫
原题:Stop Writing __init__ Methods
原文:https://blog.glyph.im/2025/04/stop-writing-init-methods.html
历史配景

在 Python 3.7 版本(2018 年 6 月发布)引入数据类 (dataclasses) 之前,__init__ 特别方法有着告急的用途。如果你有一个表示数据结构的类——例如带有 x 和 y 属性的 2DCoordinate——你如果想通过 2DCoordinate(x=1, y=2) 这样的方式构造它,就需要添加一个带有 x 和 y 参数的 __init__ 方法。
那时候可用的别的实现方法都存在相当严重的题目:

  • 你可以将 2DCoordinate 从公共 API 中移除,转而暴露一个 make_2d_coordinate 函数并使其不可导入,但这样你该如何在文档体现返回值或参数类型呢?
  • 你可以记录 x 和 y 属性并让用户自己分别赋值,但这样 2DCoordinate() 就会返回一个无效的对象。
  • 你可以使用类属性将坐标默认值设为 0,这虽然解决了选项 2 的题目,但这会要求所有 2DCoordinate 对象不仅是可变的,而且在每个调用点都必须被修改。
  • 你可以通过添加一个新的抽象类来解决选项 1 的题目,这个抽象类可以在公共 API 中暴露,但这会使每个新的公共类的复杂性激增,无论它有多简朴。更糟糕的是,typing.Protocol 直到 Python 3.8 才出现,以是在 3.7 之前的版本中,这会迫使你使用详细的继续并声明多个类,纵然对于最根本的数据结构也是如此。
别的,一个只负责分配几个属性的 __init__ 方法并没有什么显着的题目,以是在这种情况下它是一个不错的选择。考虑到我刚才描述的所有替换方案的题目,它在大多数情况下成为了显着的默认选择,这是有道理的。
然而,因为接受了"定义一个自定义的 __init__"作为用户创建对象的默认方式,我们养成了一个习惯:在每个类的开头都放上一堆可以随意编写的代码,这些代码在每次实例化时都会被执行。
哪里有随意编写的代码,哪里就会有不可控的题目。
题目所在

让我们设想一个复杂点的数据结构,创建一个与外部 I/O 交互的结构:FileReader。
固然 Python 有自己的文件对象抽象,但为了演示,我们暂时忽略它。
假设我们有以下函数,位于一个 fileio 模块中:

  • open(path: str) -> int
  • read(fileno: int, length: int)
  • close(fileno: int)
我们假设 fileio.open 返回一个表示文件描述符的整数【注1】,fileio.read 从打开的文件描述符中读取 length 个字节,而 fileio.close 则关闭该文件描述符,使其失效。
根据我们写了无数个 __init__ 方法所形成的思维习惯,我们可能会这样定义 FileReader 类:
  1. class FileReader:
  2.     def __init__(self, path: str) -> None:
  3.         self._fd = fileio.open(path)
  4.     def read(self, length: int) -> bytes:
  5.         return fileio.read(self._fd, length)
  6.     def close(self) -> None:
  7.         fileio.close(self._fd)
复制代码
对于我们的初始用例,这没题目。客户端代码通过执行雷同 FileReader("./config.json") 的操作,来创建一个 FileReader,它会将文件描述符 int 作为私有状态维护起来。这正是我们期望的;我们不希望用户代码看到或篡改 _fd,因为这可能会违背 FileReader 的不变性。构造有效 FileReader 所需的所有必要工作——即调用 open——都由 FileReader.__init__ 处理好了。
然而,随着需求增加,FileReader.__init__ 变得越来越尴尬。
最初我们只关心 fileio.open,但后来,我们可能需要适配一个库,它因为某种原因需要自己管理对 fileio.open 的调用,并想要返回一个 int 作为我们的 _fd,现在我们不得不采用像这样的奇怪变通方法:
  1. def reader_from_fd(fd: int) -> FileReader:
  2.     fr = object.__new__(FileReader)
  3.     fr._fd = fd
  4.     return fr
复制代码
这样一来,我们之前通过规范对象创建过程所得到的所有优势都丢失了。reader_from_fd的类型签名接收的只是一个平凡的int,它乃至无法向调用者建议该如何传入的精确的int 类型。
测试也变得贫苦多了,因为当我们想要在测试中获取 FileReader 的实例而不做现实的文件 I/O 时,都必须打桩替换自己的 fileio.open 副本,纵然我们可以(例如)为测试目的在多个 FileReader 之间共享一个文件描述符。
上述例子都假定 fileio.open 是同步操作。但有许多网络资源现实上只能通过异步(因此:可能缓慢,可能容易堕落)API 得到,虽然这可能是一个假设性题目。如果你曾经想要写出 async def __init__(self): ...,那么你已经在实践中遇到了这种限制。
要全面描述这种方法的所有题目,恐怕得写一本关于面向对象计划哲学的专著。以是我简朴总结一下:所有这些题目的根源其实是雷同的——我们把“创建数据结构”这个行为与“这个数据结构常见的副作用”紧密地绑定在了一起。既然说是“常见的”,那就意味着它们并非“总是”相干联的。而在那些并不相干的情况下,代码就会变得笨重且容易出题目
总而言之,定义 __init__ 是一种反模式,我们需要一个替换方案。
本文翻译并首发于【Python猫】:https://pythoncat.top/posts/2025-05-02-init
解决方案

我认为采用以下三种计划,可解决上述题目:

  • 使用 dataclass 定义属性,
  • 替换之前在 __init__ 中执行的行为,改为用一个新的类方法来实现雷同的功能,
  • 使用精确的类型来描述一个有效的实例。
使用 dataclass 属性来创建 __init__

首先,让我们将 FileReader 重构为一个 dataclass。它会为我们生成一个 __init__ 方法,但这不是我们可以随意定义的,它会受到束缚,即只能用于赋值属性。
  1. @dataclass
  2. class FileReader:
  3.     _fd: int
  4.     def read(self, length: int) -> bytes:
  5.         return fileio.read(self._fd, length)
  6.     def close(self) -> None:
  7.         fileio.close(self._fd)
复制代码
但是... 糟糕。在修复自定义 __init__ 调用 fileio.open 的题目时,我们又引入了它所解决的几个题目:

  • 我们丢失了 FileReader("path") 的简便便利。现在用户不得不导入底层的 fileio.open,这让最常见的创建对象方式变得既啰嗦又不直观。如果我们想让用户知道如何在现实场景中创建 FileReader,就不得不在文档中添加对别的模块的使用指导。
  • 对 _fd 作为文件描述符的有效性没有逼迫检查;它只是一个整数,用户很容易传入不精确的数字,但没有出现报错。
单独来看,只使用 dataclass ,无法解决所有题目,以是我们要加入第二项技术。
使用 classmethod 工厂来创建对象

我们不希望产生额外的导入,或要求用户去查看别的模块——即除了 FileReader 自己之外的任何东西——来弄清楚该如何创建想要的 FileReader。
幸运的是,我们有一个工具可以轻松解决这些题目:@classmethod。让我们定义一个 FileReader.open 类方法:
  1. from typing import Self
  2. @dataclass
  3. class FileReader:
  4.     _fd: int
  5.     @classmethod
  6.     def open(cls, path: str) -> Self:
  7.         return cls(fileio.open(path))
复制代码
现在,你的调用者可以将 FileReader("path") 替换为 FileReader.open("path"),得到与__init__ 雷同的利益。
另外,如果我们需要使用await fileio.open(...),就需要一个签名为@classmethod async def open的方法,这可以不受限于__init__作为特别方法的束缚。@classmethod 完全可以是async的,它还可对返回值作修改,比如返回一组相干值的tuple,而不仅仅是返回构造好的对象。
使用 NewType 解决对象有效性题目

接下来,让我们解决稍微棘手的对象有效性题目。
我们的类型签名将这个东西称为 int,底层的 fileio.open 返回的就是平凡整数,这点我们无法改变。但是为了有效校验,我们可以使用 NewType 来精确要求:
  1. from typing import NewType
  2. FileDescriptor = NewType("FileDescriptor", int)
复制代码
有几种方法可以处理底层库的题目,但为简便起见,也为了展示这种方法不会带来任何运行时开销,我们干脆直接告诉 Mypy:这里使用的 fileio.open、fileio.read 和 fileio.write 已经接收 FileDescriptor 类型的整数,而不是平凡整数。
  1. from typing import Callable
  2. _open: Callable[[str], FileDescriptor] = fileio.open  # type:ignore[assignment]
  3. _read: Callable[[FileDescriptor, int], bytes] = fileio.read
  4. _close: Callable[[FileDescriptor], None] = fileio.close
复制代码
固然,我们也必须稍微调整 FileReader,但改动很小。综合这些修改,代码酿成了:
  1. from typing import Self
  2. @dataclass
  3. class FileReader:
  4.     _fd: FileDescriptor
  5.     @classmethod
  6.     def open(cls, path: str) -> Self:
  7.         return cls(_open(path))
  8.     def read(self, length: int) -> bytes:
  9.         return _read(self._fd, length)
  10.     def close(self) -> None:
  11.         _close(self._fd)
复制代码
请注意,这里的关键不是使用NewType,而是让“属性齐全”的对象自然成为“有效实例”。NewType只是一个方便的工具,资助我们在使用int、str或bytes等根本类型时施加必要的束缚。
总结 - 新的最佳实践

从现在开始,当你定义新的 Python 类时:

  • 将它写成数据类(或者一个 attrs 类,如果你喜欢的话)
  • 使用默认的 __init__ 方法。【注2】
  • 添加 @classmethod ,为调用者提供方便且公开的对象构造方法。
  • 要求所有依赖项都通过属性来满足,这样总是先创建出一个有效的对象。
  • 使用typing.NewType来对根本数据类型(比如int和str)添加限制条件,尤其是当这些类型需要具备一些特别属性时,比如必须来自某个特定库、必须是随机生成的等等。
如果以这种方式来定义类,你将得到自定义 __init__ 方法的所有利益:

  • 所有调用你数据结构的人都能拿到有效对象,因为只要属性设置精确,对象自然就是有效的。
  • 你的库用户能够使用便捷的对象创建方法,这些方法会处理好各种复杂工作,让使用变得简朴。而且用户只要看一眼类的方法列表,就能发现这些创建方式。
另有一些别的的利益:

  • 你的代码会更经得起未来的考验,能轻松应对用户创建对象的各种新需求。
  • 如果需要有多种实例化你的类的方式,那么可以给每种方式一个有意义的名称;不需要使用像 def __init__(self, maybe_a_filename: int | str | None = None): 这样的怪物。
  • 写测试时,你只需要提供所有需要的依赖项就能构造对象;不需要再用猴子补丁了,因为你可以直接调用类型构造器而不会产生任何 I/O 操作或副作用。
在没有数据类之前,Python 语言中有个怪征象:仅仅是给数据结构填凑数据这么基础的事情,竟然要重写一个带着 4 个下划线的方法。__init__方法就像个异类。而其他的魔术方法,像__add__或__repr__,本质上是在处理类的一些高级特性。
如今,这个历史遗留的语言瑕疵已经得到解决。有了@dataclass、@classmethod 和 NewType ,你可以构建出易用、符合 Python 风格、灵活、易测试和健壮的类。
文中解释:

  • 如果你还不熟悉,“文件描述符”其实是一个只在程序内部有意义的整数。当你让操作系统打开一个文件时,它会回应“我已经为你打开了文件 7”,之后每当你引用“7”这个数字,它就代表那个文件,直到你执行close(7)关闭它。
  • 固然,除非你有非常充分的理由。比如为了向后兼容,或者与别的库兼容,这些都可能是合理的理由。另有一些数据一致性校验,是无法通过类型系统表达的。最常见的例子是需要检查两个不同字段之间关系的类,比如“range”对象,其中start必须始终小于end。这类规则总有例外。不过,在__init__里执行任何 I/O 操作根本上都不是好主意,而那些在某些特别情况下可能有用的别的操作,险些都可以通过__post_init__来实现,而不必直接写__init__。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

东湖之滨

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表