Python 类不要再写 __init__ 方法了

花下猫语:我们周刊第 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__ 特殊方法有着重要的用途。如果你有一个表示数据结构的类------例如带有 xy 属性的 2DCoordinate------你如果想通过 2DCoordinate(x=1, y=2) 这样的方式构造它,就需要添加一个带有 xy 参数的 __init__ 方法。

那时候可用的其它实现方法都存在相当严重的问题:

  1. 你可以将 2DCoordinate 从公共 API 中移除,转而暴露一个 make_2d_coordinate 函数并使其不可导入,但这样你该如何在文档体现返回值或参数类型呢?
  2. 你可以记录 xy 属性并让用户自己分别赋值,但这样 2DCoordinate() 就会返回一个无效的对象。
  3. 你可以使用类属性将坐标默认值设为 0,这虽然解决了选项 2 的问题,但这会要求所有 2DCoordinate 对象不仅是可变的,而且在每个调用点都必须被修改。
  4. 你可以通过添加一个新的抽象 类来解决选项 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 类:

python 复制代码
class FileReader:
    def __init__(self, path: str) -> None:
        self._fd = fileio.open(path)
    def read(self, length: int) -> bytes:
        return fileio.read(self._fd, length)
    def close(self) -> None:
        fileio.close(self._fd)

对于我们的初始用例,这没问题。客户端代码通过执行类似 FileReader("./config.json") 的操作,来创建一个 FileReader,它会将文件描述符 int 作为私有状态维护起来。这正是我们期望的;我们不希望用户代码看到或篡改 _fd,因为这可能会违反 FileReader 的不变性。构造有效 FileReader 所需的所有必要工作------即调用 open------都由 FileReader.__init__ 处理好了。

然而,随着需求增加,FileReader.__init__ 变得越来越尴尬。

最初我们只关心 fileio.open,但后来,我们可能需要适配一个库,它因为某种原因需要自己管理对 fileio.open 的调用,并想要返回一个 int 作为我们的 _fd,现在我们不得不采用像这样的奇怪变通方法:

python 复制代码
def reader_from_fd(fd: int) -> FileReader:
    fr = object.__new__(FileReader)
    fr._fd = fd
    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__ 方法,但这不是我们可以随意定义的,它会受到约束,即只能用于赋值属性。

python 复制代码
@dataclass
class FileReader:
    _fd: int
    def read(self, length: int) -> bytes:
        return fileio.read(self._fd, length)
    def close(self) -> None:
        fileio.close(self._fd)

但是... 糟糕。在修复自定义 __init__ 调用 fileio.open 的问题时,我们又引入了它所解决的几个问题:

  1. 我们丢失了 FileReader("path") 的简洁便利。现在用户不得不导入底层的 fileio.open,这让最常见的创建对象方式变得既啰嗦又不直观。如果我们想让用户知道如何在实际场景中创建 FileReader,就不得不在文档中添加对其它模块的使用指导。
  2. _fd 作为文件描述符的有效性没有强制检查;它只是一个整数,用户很容易传入不正确的数字,但没有出现报错。

单独来看,只使用 dataclass ,无法解决所有问题,所以我们要加入第二项技术。

使用 classmethod 工厂来创建对象

我们不希望产生额外的导入,或要求用户去查看其它模块------即除了 FileReader 本身之外的任何东西------来弄清楚该如何创建想要的 FileReader

幸运的是,我们有一个工具可以轻松解决这些问题:@classmethod。让我们定义一个 FileReader.open 类方法:

python 复制代码
from typing import Self
@dataclass
class FileReader:
    _fd: int
    @classmethod
    def open(cls, path: str) -> Self:
        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 来精确要求:

python 复制代码
from typing import NewType
FileDescriptor = NewType("FileDescriptor", int)

有几种方法可以处理底层库的问题,但为简洁起见,也为了展示这种方法不会带来任何运行时开销,我们干脆直接告诉 Mypy:这里使用的 fileio.openfileio.readfileio.write 已经接收 FileDescriptor 类型的整数,而不是普通整数。

python 复制代码
from typing import Callable
_open: Callable[[str], FileDescriptor] = fileio.open  # type:ignore[assignment]
_read: Callable[[FileDescriptor, int], bytes] = fileio.read
_close: Callable[[FileDescriptor], None] = fileio.close

当然,我们也必须稍微调整 FileReader,但改动很小。综合这些修改,代码变成了:

python 复制代码
from typing import Self
@dataclass
class FileReader:
    _fd: FileDescriptor
    @classmethod
    def open(cls, path: str) -> Self:
        return cls(_open(path))
    def read(self, length: int) -> bytes:
        return _read(self._fd, length)
    def close(self) -> None:
        _close(self._fd)

请注意,这里的关键不是使用NewType,而是让"属性齐全"的对象自然成为"有效实例"。NewType只是一个方便的工具,帮助我们在使用intstrbytes等基本类型时施加必要的约束。

总结 - 新的最佳实践

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

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

如果以这种方式来定义类,你将获得自定义 __init__ 方法的所有好处:

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

还有一些其它的好处:

  • 你的代码会更经得起未来的考验,能轻松应对用户创建对象的各种新需求。
  • 如果需要有多种实例化你的类的方式,那么可以给每种方式一个有意义的名称;不需要使用像 def __init__(self, maybe_a_filename: int | str | None = None): 这样的怪物。
  • 写测试时,你只需要提供所有需要的依赖项就能构造对象;不需要再用猴子补丁了,因为你可以直接调用类型构造器而不会产生任何 I/O 操作或副作用。

在没有数据类之前,Python 语言中有个怪现象:仅仅是给数据结构填充数据这么基础的事情,竟然要重写一个带着 4 个下划线的方法。__init__方法就像个异类。而其他的魔术方法,像__add____repr__,本质上是在处理类的一些高级特性。

如今,这个历史遗留的语言瑕疵已经得到解决。有了@dataclass@classmethodNewType ,你可以构建出易用、符合 Python 风格、灵活、易测试和健壮的类。

文中注释:

  1. 如果你还不熟悉,"文件描述符"其实是一个只在程序内部有意义的整数。当你让操作系统打开一个文件时,它会回应"我已经为你打开了文件 7",之后每当你引用"7"这个数字,它就代表那个文件,直到你执行close(7)关闭它。
  2. 当然,除非你有非常充分的理由。比如为了向后兼容,或者与其它库兼容,这些都可能是合理的理由。还有一些数据一致性校验,是无法通过类型系统表达的。最常见的例子是需要检查两个不同字段之间关系的类,比如"range"对象,其中start必须始终小于end。这类规则总有例外。不过,在__init__里执行任何 I/O 操作基本上都不是好主意,而那些在某些特殊情况下可能有用的其它操作,几乎都可以通过__post_init__来实现,而不必直接写__init__
相关推荐
程序员龙叔2 小时前
编写高质量 Skill 系列 -- 如何设计需求分析与用例生成的 SKILL
自动化测试·软件测试·python·软件测试工程师·接口测试·性能测试·skill·ai测试
星星在线2 小时前
MusicFree:一个「All in One」的个人音乐服务器,让听歌回归简单
前端·后端
IT_陈寒3 小时前
Redis的SETNX并发问题让我加了三天班
前端·人工智能·后端
demo007x3 小时前
Docling 文档转换以及技术架构分析
前端·后端·程序员
袋鱼不重4 小时前
我的神奇同事,AI 用多了居然写了个 Open In Codex
前端·后端·ai编程
大树884 小时前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
用户8356290780514 小时前
使用 Python 操作 Word 内容控件
后端·python
像我这样帅的人丶你还4 小时前
啥? 前端也要会干Java?🛵🛵🛵
后端
Hommy884 小时前
【剪映小助手】添加贴纸接口(Add Sticker)
后端·github·剪映小助手·视频剪辑自动化·剪映api
施小赞5 小时前
普通 RAG vs GraphRAG 核心对比
人工智能·ai