Python 类不要再写 __init__ 方法了
<p>花下猫语:我们周刊第 98 期分享过一篇文章,它指出了 <code>__init__</code> 方法存在的问题和新的最佳实践,第 99 期也分享了一篇文章佐证了第一篇文章的观点。我认为它们提出的是一个值得注意和思考的问题,因此将第一篇文章翻译成了中文。</p><blockquote>
<p>原作:Glyph</p>
<p>译者:豌豆花下猫@Python猫</p>
<p>原题:Stop Writing <code>__init__</code> Methods</p>
<p>原文:https://blog.glyph.im/2025/04/stop-writing-init-methods.html</p>
</blockquote>
<h2 id="历史背景">历史背景</h2>
<p>在 Python 3.7 版本(2018 年 6 月发布)引入数据类 (dataclasses) 之前,<code>__init__</code> 特殊方法有着重要的用途。如果你有一个表示数据结构的类——例如带有 <code>x</code> 和 <code>y</code> 属性的 <code>2DCoordinate</code>——你如果想通过 <code>2DCoordinate(x=1, y=2)</code> 这样的方式构造它,就需要添加一个带有 <code>x</code> 和 <code>y</code> 参数的 <code>__init__</code> 方法。</p>
<p>那时候可用的其它实现方法都存在相当严重的问题:</p>
<ol>
<li>你可以将 <code>2DCoordinate</code> 从公共 API 中移除,转而暴露一个 <code>make_2d_coordinate</code> 函数并使其不可导入,但这样你该如何在文档体现返回值或参数类型呢?</li>
<li>你可以记录 <code>x</code> 和 <code>y</code> 属性并让用户自己分别赋值,但这样 <code>2DCoordinate()</code> 就会返回一个无效的对象。</li>
<li>你可以使用类属性将坐标默认值设为 0,这虽然解决了选项 2 的问题,但这会要求所有 <code>2DCoordinate</code> 对象不仅是可变的,而且在每个调用点都必须被修改。</li>
<li>你可以通过添加一个新的<em>抽象</em>类来解决选项 1 的问题,这个抽象类可以在公共 API 中暴露,但这会使每个新的公共类的复杂性激增,无论它有多简单。更糟糕的是,<code>typing.Protocol</code> 直到 Python 3.8 才出现,所以在 3.7 之前的版本中,这会迫使你使用具体的继承并声明多个类,即使对于最基本的数据结构也是如此。</li>
</ol>
<p>此外,一个只负责分配几个属性的 <code>__init__</code> 方法并没有什么明显的问题,所以在这种情况下它是一个不错的选择。考虑到我刚才描述的所有替代方案的问题,它在大多数情况下成为了明显的<em>默认</em>选择,这是有道理的。</p>
<p>然而,因为接受了"定义一个自定义的 <code>__init__</code>"作为用户创建对象的<em>默认</em>方式,我们养成了一个习惯:在每个类的开头都放上一堆<em>可以随意编写的代码</em>,这些代码在每次实例化时都会被执行。</p>
<p>哪里有随意编写的代码,哪里就会有不可控的问题。</p>
<h2 id="问题所在">问题所在</h2>
<p>让我们设想一个复杂点的数据结构,创建一个与外部 I/O 交互的结构:<code>FileReader</code>。</p>
<p>当然 Python 有自己的文件对象抽象,但为了演示,我们暂时忽略它。</p>
<p>假设我们有以下函数,位于一个 <code>fileio</code> 模块中:</p>
<ul>
<li><code>open(path: str) -> int</code></li>
<li><code>read(fileno: int, length: int)</code></li>
<li><code>close(fileno: int)</code></li>
</ul>
<p>我们假设 <code>fileio.open</code> 返回一个表示文件描述符的整数【注1】,<code>fileio.read</code> 从打开的文件描述符中读取 <code>length</code> 个字节,而 <code>fileio.close</code> 则关闭该文件描述符,使其失效。</p>
<p>根据我们写了无数个 <code>__init__</code> 方法所形成的思维习惯,我们可能会这样定义 <code>FileReader</code> 类:</p>
<pre><code class="language-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)
</code></pre>
<p>对于我们的初始用例,这没问题。客户端代码通过执行类似 <code>FileReader("./config.json")</code> 的操作,来创建一个 <code>FileReader</code>,它会将文件描述符 <code>int</code> 作为私有状态维护起来。这正是我们期望的;我们不希望用户代码看到或篡改 <code>_fd</code>,因为这可能会违反 <code>FileReader</code> 的不变性。构造有效 <code>FileReader</code> 所需的所有必要工作——即调用 <code>open</code>——都由 <code>FileReader.__init__</code> 处理好了。</p>
<p>然而,随着需求增加,<code>FileReader.__init__</code> 变得越来越尴尬。</p>
<p>最初我们只关心 <code>fileio.open</code>,但后来,我们可能需要适配一个库,它因为某种原因需要自己管理对 <code>fileio.open</code> 的调用,并想要返回一个 <code>int</code> 作为我们的 <code>_fd</code>,现在我们不得不采用像这样的奇怪变通方法:</p>
<pre><code class="language-python">def reader_from_fd(fd: int) -> FileReader:
fr = object.__new__(FileReader)
fr._fd = fd
return fr
</code></pre>
<p>这样一来,我们之前通过规范对象创建过程所获得的所有优势都丢失了。<code>reader_from_fd</code>的类型签名接收的只是一个普通的<code>int</code>,它甚至无法向调用者建议该如何传入的正确的<code>int</code> 类型。</p>
<p>测试也变得麻烦多了,因为当我们想要在测试中获取 <code>FileReader</code> 的实例而不做实际的文件 I/O 时,都必须打桩替换自己的 <code>fileio.open</code> 副本,即使我们可以(例如)为测试目的在多个 <code>FileReader</code> 之间共享一个文件描述符。</p>
<p>上述例子都假定 <code>fileio.open</code> 是同步操作。但有许多网络资源实际上只能通过异步(因此:可能缓慢,可能容易出错)API 获得,虽然这可能是一个假设性问题。如果你曾经想要写出 <code>async def __init__(self): ...</code>,那么你已经在实践中碰到了这种限制。</p>
<p>要全面描述这种方法的所有问题,恐怕得写一本关于面向对象设计哲学的专著。所以我简单总结一下:所有这些问题的根源其实是相同的——我们把“创建数据结构”这个行为与“这个数据结构常见的副作用”紧密地绑定在了一起。既然说是“常见的”,那就意味着它们并非“总是”相关联的。而在那些并不相关的情况下,代码就会变得笨重且容易出问题</p>
<p>总而言之,定义 <code>__init__</code> 是一种反模式,我们需要一个替代方案。</p>
<blockquote>
<p>本文翻译并首发于【Python猫】:https://pythoncat.top/posts/2025-05-02-init</p>
</blockquote>
<h2 id="解决方案">解决方案</h2>
<p>我认为采用以下三种设计,可解决上述问题:</p>
<ul>
<li>使用 <code>dataclass</code> 定义属性,</li>
<li>替换之前在 <code>__init__</code> 中执行的行为,改为用一个新的类方法来实现相同的功能,</li>
<li>使用精确的类型来描述一个有效的实例。</li>
</ul>
<h3 id="使用-dataclass-属性来创建-__init__">使用 <code>dataclass</code> 属性来创建 <code>__init__</code></h3>
<p>首先,让我们将 <code>FileReader</code> 重构为一个 <code>dataclass</code>。它会为我们生成一个 <code>__init__</code> 方法,但这不是我们可以随意定义的,它会受到约束,即只能用于赋值属性。</p>
<pre><code class="language-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)
</code></pre>
<p>但是... 糟糕。在修复自定义 <code>__init__</code> 调用 <code>fileio.open</code> 的问题时,我们又引入了它所解决的几个问题:</p>
<ol>
<li>我们丢失了 <code>FileReader("path")</code> 的简洁便利。现在用户不得不导入底层的 <code>fileio.open</code>,这让最常见的创建对象方式变得既啰嗦又不直观。如果我们想让用户知道如何在实际场景中创建 <code>FileReader</code>,就不得不在文档中添加对其它模块的使用指导。</li>
<li>对 <code>_fd</code> 作为文件描述符的有效性没有强制检查;它只是一个整数,用户很容易传入不正确的数字,但没有出现报错。</li>
</ol>
<p>单独来看,只使用 <code>dataclass</code> ,无法解决所有问题,所以我们要加入第二项技术。</p>
<h3 id="使用-classmethod-工厂来创建对象">使用 <code>classmethod</code> 工厂来创建对象</h3>
<p>我们不希望产生额外的导入,或要求用户去查看其它模块——即除了 <code>FileReader</code> 本身之外的任何东西——来弄清楚该如何创建想要的 <code>FileReader</code>。</p>
<p>幸运的是,我们有一个工具可以轻松解决这些问题:<code>@classmethod</code>。让我们定义一个 <code>FileReader.open</code> 类方法:</p>
<pre><code class="language-python">from typing import Self
@dataclass
class FileReader:
_fd: int
@classmethod
def open(cls, path: str) -> Self:
return cls(fileio.open(path))
</code></pre>
<p>现在,你的调用者可以将 <code>FileReader("path")</code> 替换为 <code>FileReader.open("path")</code>,获得与<code>__init__</code> 相同的好处。</p>
<p>另外,如果我们需要使用<code>await fileio.open(...)</code>,就需要一个签名为<code>@classmethod async def open</code>的方法,这可以不受限于<code>__init__</code>作为特殊方法的约束。<code>@classmethod</code> 完全可以是<code>async</code>的,它还可对返回值作修改,比如返回一组相关值的<code>tuple</code>,而不仅仅是返回构造好的对象。</p>
<h3 id="使用-newtype-解决对象有效性问题">使用 <code>NewType</code> 解决对象有效性问题</h3>
<p>接下来,让我们解决稍微棘手的对象有效性问题。</p>
<p>我们的类型签名将这个东西称为 <code>int</code>,底层的 fileio.open 返回的就是普通整数,这点我们无法改变。但是为了有效校验,我们可以使用 <code>NewType</code> 来精确要求:</p>
<pre><code class="language-python">from typing import NewType
FileDescriptor = NewType("FileDescriptor", int)
</code></pre>
<p>有几种方法可以处理底层库的问题,但为简洁起见,也为了展示这种方法不会带来任何运行时开销,我们干脆直接告诉 Mypy:这里使用的 <code>fileio.open</code>、<code>fileio.read</code> 和 <code>fileio.write</code> 已经接收 <code>FileDescriptor</code> 类型的整数,而不是普通整数。</p>
<pre><code class="language-python">from typing import Callable
_open: Callable[, FileDescriptor] = fileio.open# type:ignore
_read: Callable[, bytes] = fileio.read
_close: Callable[, None] = fileio.close
</code></pre>
<p>当然,我们也必须稍微调整 <code>FileReader</code>,但改动很小。综合这些修改,代码变成了:</p>
<pre><code class="language-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)
</code></pre>
<p>请注意,这里的关键不是使用<code>NewType</code>,而是让“属性齐全”的对象自然成为“有效实例”。<code>NewType</code>只是一个方便的工具,帮助我们在使用<code>int</code>、<code>str</code>或<code>bytes</code>等基本类型时施加必要的约束。</p>
<h2 id="总结---新的最佳实践">总结 - 新的最佳实践</h2>
<p>从现在开始,当你定义新的 Python 类时:</p>
<ul>
<li>将它写成数据类(或者一个 attrs 类,如果你喜欢的话)</li>
<li>使用默认的 <code>__init__</code> 方法。【注2】</li>
<li>添加 <code>@classmethod</code> ,为调用者提供方便且公开的对象构造方法。</li>
<li>要求所有依赖项都通过属性来满足,这样总是先创建出一个有效的对象。</li>
<li>使用<code>typing.NewType</code>来对基本数据类型(比如<code>int</code>和<code>str</code>)添加限制条件,尤其是当这些类型需要具备一些特殊属性时,比如必须来自某个特定库、必须是随机生成的等等。</li>
</ul>
<p>如果以这种方式来定义类,你将获得自定义 <code>__init__</code> 方法的所有好处:</p>
<ul>
<li>所有调用你数据结构的人都能拿到有效对象,因为只要属性设置正确,对象自然就是有效的。</li>
<li>你的库用户能够使用便捷的对象创建方法,这些方法会处理好各种复杂工作,让使用变得简单。而且用户只要看一眼类的方法列表,就能发现这些创建方式。</li>
</ul>
<p>还有一些其它的好处:</p>
<ul>
<li>你的代码会更经得起未来的考验,能轻松应对用户创建对象的各种新需求。</li>
<li>如果需要有多种实例化你的类的方式,那么可以给每种方式一个有意义的名称;不需要使用像 <code>def __init__(self, maybe_a_filename: int | str | None = None):</code> 这样的怪物。</li>
<li>写测试时,你只需要提供所有需要的依赖项就能构造对象;不需要再用猴子补丁了,因为你可以直接调用类型构造器而不会产生任何 I/O 操作或副作用。</li>
</ul>
<p>在没有数据类之前,Python 语言中有个怪现象:仅仅是给数据结构填充数据这么基础的事情,竟然要重写一个带着 4 个下划线的方法。<code>__init__</code>方法就像个异类。而其他的魔术方法,像<code>__add__</code>或<code>__repr__</code>,本质上是在处理类的一些高级特性。</p>
<p>如今,这个历史遗留的语言瑕疵已经得到解决。有了<code>@dataclass</code>、<code>@classmethod</code> 和 <code>NewType</code> ,你可以构建出易用、符合 Python 风格、灵活、易测试和健壮的类。</p>
<p>文中注释:</p>
<ol>
<li>如果你还不熟悉,“文件描述符”其实是一个只在程序内部有意义的整数。当你让操作系统打开一个文件时,它会回应“我已经为你打开了文件 7”,之后每当你引用“7”这个数字,它就代表那个文件,直到你执行<code>close(7)</code>关闭它。</li>
<li>当然,除非你有非常充分的理由。比如为了向后兼容,或者与其它库兼容,这些都可能是合理的理由。还有一些数据一致性校验,是无法通过类型系统表达的。最常见的例子是需要检查两个不同字段之间关系的类,比如“range”对象,其中<code>start</code>必须始终小于<code>end</code>。这类规则总有例外。不过,在<code>__init__</code>里执行任何 I/O 操作基本上都不是好主意,而那些在某些特殊情况下可能有用的其它操作,几乎都可以通过<code>__post_init__</code>来实现,而不必直接写<code>__init__</code>。</li>
</ol><br><br>
来源:https://www.cnblogs.com/pythonista/p/18857758
頁:
[1]