__init__
方法存在的问题和新的最佳实践,第 99 期也分享了一篇文章佐证了第一篇文章的观点。我认为它们提出的是一个值得注意和思考的问题,因此将第一篇文章翻译成了中文。原作:Glyph
译者:豌豆花下猫@Python猫
原题:Stop Writing
__init__
Methods原文:https://blog.glyph.im/2025/04/stop-writing-init-methods.html
__init__
特殊方法有着重要的用途。如果你有一个表示数据结构的类——例如带有 x
和 y
属性的 2DCoordinate
——你如果想通过 2DCoordinate(x=1, y=2)
这样的方式构造它,就需要添加一个带有 x
和 y
参数的 __init__
方法。2DCoordinate
从公共 API 中移除,转而暴露一个 make_2d_coordinate
函数并使其不可导入,但这样你该如何在文档体现返回值或参数类型呢?x
和 y
属性并让用户自己分别赋值,但这样 2DCoordinate()
就会返回一个无效的对象。2DCoordinate
对象不仅是可变的,而且在每个调用点都必须被修改。typing.Protocol
直到 Python 3.8 才出现,所以在 3.7 之前的版本中,这会迫使你使用具体的继承并声明多个类,即使对于最基本的数据结构也是如此。__init__
方法并没有什么明显的问题,所以在这种情况下它是一个不错的选择。考虑到我刚才描述的所有替代方案的问题,它在大多数情况下成为了明显的默认选择,这是有道理的。__init__
“作为用户创建对象的默认方式,我们养成了一个习惯:在每个类的开头都放上一堆可以随意编写的代码,这些代码在每次实例化时都会被执行。FileReader
。fileio
模块中:open(path: str) -> int
read(fileno: int, length: int)
close(fileno: int)
fileio.open
返回一个表示文件描述符的整数【注1】,fileio.read
从打开的文件描述符中读取 length
个字节,而 fileio.close
则关闭该文件描述符,使其失效。__init__
方法所形成的思维习惯,我们可能会这样定义 FileReader
类: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
,现在我们不得不采用像这样的奇怪变通方法: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__
是一种反模式,我们需要一个替代方案。dataclass
定义属性,__init__
中执行的行为,改为用一个新的类方法来实现相同的功能,dataclass
属性来创建 __init__
FileReader
重构为一个 dataclass
。它会为我们生成一个 __init__
方法,但这不是我们可以随意定义的,它会受到约束,即只能用于赋值属性。@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
的问题时,我们又引入了它所解决的几个问题:FileReader("path")
的简洁便利。现在用户不得不导入底层的 fileio.open
,这让最常见的创建对象方式变得既啰嗦又不直观。如果我们想让用户知道如何在实际场景中创建 FileReader
,就不得不在文档中添加对其它模块的使用指导。_fd
作为文件描述符的有效性没有强制检查;它只是一个整数,用户很容易传入不正确的数字,但没有出现报错。dataclass
,无法解决所有问题,所以我们要加入第二项技术。classmethod
工厂来创建对象FileReader
本身之外的任何东西——来弄清楚该如何创建想要的 FileReader
。@classmethod
。让我们定义一个 FileReader.open
类方法: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
来精确要求:from typing import NewType
FileDescriptor = NewType("FileDescriptor", int)
fileio.open
、fileio.read
和 fileio.write
已经接收 FileDescriptor
类型的整数,而不是普通整数。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
,但改动很小。综合这些修改,代码变成了: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
只是一个方便的工具,帮助我们在使用int
、str
或bytes
等基本类型时施加必要的约束。__init__
方法。【注2】@classmethod
,为调用者提供方便且公开的对象构造方法。typing.NewType
来对基本数据类型(比如int
和str
)添加限制条件,尤其是当这些类型需要具备一些特殊属性时,比如必须来自某个特定库、必须是随机生成的等等。__init__
方法的所有好处:def __init__(self, maybe_a_filename: int | str | None = None):
这样的怪物。__init__
方法就像个异类。而其他的魔术方法,像__add__
或__repr__
,本质上是在处理类的一些高级特性。@dataclass
、@classmethod
和 NewType
,你可以构建出易用、符合 Python 风格、灵活、易测试和健壮的类。close(7)
关闭它。start
必须始终小于end
。这类规则总有例外。不过,在__init__
里执行任何 I/O 操作基本上都不是好主意,而那些在某些特殊情况下可能有用的其它操作,几乎都可以通过__post_init__
来实现,而不必直接写__init__
。