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__
特殊方法有着重要的用途。如果你有一个表示数据结构的类——例如带有 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
类:
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__
是一种反模式,我们需要一个替代方案。
本文翻译并首发于【Python猫】:https://pythoncat.top/posts/2025-05-02-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)
有几种方法可以处理底层库的问题,但为简洁起见,也为了展示这种方法不会带来任何运行时开销,我们干脆直接告诉 Mypy:这里使用的 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
等基本类型时施加必要的约束。
总结 - 新的最佳实践
从现在开始,当你定义新的 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__
。
Python 类不要再写 __init__ 方法了的更多相关文章
- 以前写SpringMVC的时候,如果需要访问一个页面,必须要写Controller类,然后再写一个方法跳转到页面,感觉好麻烦,其实重写WebMvcConfigurerAdapter中的addViewControllers方法即可达到效果了
以前写SpringMVC的时候,如果需要访问一个页面,必须要写Controller类,然后再写一个方法跳转到页面,感觉好麻烦,其实重写WebMvcConfigurerAdapter中的addViewC ...
- Python类中super()和__init__()的关系
Python类中super()和__init__()的关系 1.单继承时super()和__init__()实现的功能是类似的 class Base(object): def __init__(sel ...
- 【转】python类中super()和__init__()的区别
[转]python类中super()和__init__()的区别 单继承时super()和__init__()实现的功能是类似的 class Base(object): def __init__(se ...
- 孤荷凌寒自学python第二十四天python类中隐藏的私有方法探秘
孤荷凌寒自学python第二十四天python类中隐藏的私有方法探秘 (完整学习过程屏幕记录视频地址在文末,手写笔记在文末) 今天发现了python的类中隐藏着一些特殊的私有方法. 这些私有方法不管我 ...
- Python类属性访问的魔法方法
Python类属性访问的魔法方法: 1. __getattr__(self, name)- 定义当用户试图获取一个不存在的属性时的行为 2. __getattribute__(self, name)- ...
- python类的__new__和__init__
python的类,和其他语言有一点不太一样,就是,他把新建一个类和初始化一个类,分成了两个方法: __new__ __init__ 当然,想想就知道,肯定是__new__先发生,然后才是__init_ ...
- [ python ] 类中的一些特殊方法
item系列 __getitem__(self, item) 对象通过 object[key] 触发 __setitem__(self, key, value) 对象通过 object[key] = ...
- python类中__unicode__和__str__方法的妙用
在python类中有个__str__的特殊方法,该方法可以使print打印出来的东西更美观,在类里就可以定义,如下代码: class Test: def __init__(self, name, jo ...
- Python 类的常用内置方法
类的内置方法(魔法方法): 凡是在类内部定义,以__开头__结尾的方法,都是类的内置方法,类的内置方法,会在满足某种条件下自动触发. 1.1__new__ __new__:在___init__触发前, ...
- Python 简明教程 --- 20,Python 类中的属性与方法
微信公众号:码农充电站pro 个人主页:https://codeshellme.github.io 与客户保持良好的关系可以使生产率加倍. -- Larry Bernstain 目录 类中的变量称为属 ...
随机推荐
- flutter-应用版本更新dialog
https://www.cnblogs.com/upwgh/p/13367589.html
- python基础-元组-集合-字典
元组 概念 元组:由一系列变量组成的不可变序列容器 序列:支持索引和切片 不可变:1.没有增删改的方法 2.所有的操作都不会直接作用于原数据 定义 <span style="font- ...
- Typecho 如何开启 HTTPS
一般来说,我们直接开启 HTTPS 就行,开启后进去网站后台修改网站的 URL 即可. 但是我昨天发现,我的工具箱迁移服务器之后,前台看着是很正常的,但是后台的登陆页面引入的依然的 http 标头,所 ...
- Typecho自定义右键菜单美化和禁用F12
右键美化 使用右键美化,请禁用 HoerMouse 鼠标美化插件,否则貌似没效果 Joe主题在后台-外观设置-设置外观-全局设置-自定义<body></body>标签内填入如下 ...
- Processing中获取表格数据( .tsv\.csv )的经验分享
在日常收集数据的需求中,会有很多场合用到表格数据类型,如.tsv和.csv,一来高效查看和编辑,二来数据条理清晰,导入数据结构方便.在Prcocessing中帮我预留好了loadTable().loa ...
- Flink学习(十五) 滑动事件时间窗口加上水位线开始窗口时间如何确定?(底层源码)
先看上一节的代码程序 package com.wyh.windowsApi import org.apache.flink.streaming.api.TimeCharacteristic impor ...
- 在ubuntu系统下,安装opencv各个版本
要在Linux系统上安装OpenCV库,你可以通过包管理器(如apt)来安装.以下是详细的步骤,包括如何在/usr/local/lib或/usr/lib/x86_64-linux-gnu目录下安装Op ...
- linux shell用expect实现在scp时自动输入密码
文章目录 linux shell用expect自动输入密码 按行读取文件 expect 其他 linux shell用expect自动输入密码 最近有东西需要部署到很多服务器上去,一个服务器一个服务器 ...
- node几个类之间的关系
B/S 架构的应用程序,包含两个部分,客户端和服务端,在每一个http请求至响应的完成,产生了这多姿多彩的网页世界,所以,与B/S 架构的一切相关技术问题,都可以认为是一个http请求过程的详细解释: ...
- python包管理工具pip使用手册
pip是什么? pip 是 Python 标准库管理器,也就是一个工具让你安装不同的类库来使用. 当你要安装某些类库时,都会使用 pip,那 pip 除了安装类库之外,还能做什么呢? 首先,我们进入 ...