网易首页 > 网易号 > 正文 申请入驻

Python 类不要再写 __init__ 方法了

0
分享至

  

  花下猫语:我们周刊第 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 有自己的文件对象抽象[1],但为了演示,我们暂时忽略它。

  假设我们有以下函数,位于一个fileio模块中:

  open(path: str) -> int

  read(fileno: int, length: int)

  close(fileno: int)

  我们假设fileio.open返回一个表示文件描述符的整数【注1】,fileio.read从打开的文件描述符中读取length个字节,而fileio.close则关闭该文件描述符,使其失效。

  根据我们写了无数个__init__方法所形成的思维习惯,我们可能会这样定义FileReader类:

  classFileReader: def__init__(self, path: str)->None: self._fd = fileio.open(path) defread(self, length: int)-> bytes: returnfileio.read(self._fd, length) defclose(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,现在我们不得不采用像这样的奇怪变通方法:

  defreader_from_fd(fd: int)-> FileReader: fr = object.__new__(FileReader) fr._fd = fd returnfr

  这样一来,我们之前通过规范对象创建过程所获得的所有优势都丢失了。reader_from_fd的类型签名接收的只是一个普通的int,它甚至无法向调用者建议该如何传入的正确的int类型。

  测试也变得麻烦多了,因为当我们想要在测试中获取FileReader的实例而不做实际的文件 I/O 时,都必须打桩替换自己的fileio.open副本,即使我们可以(例如)为测试目的在多个FileReader之间共享一个文件描述符。

  上述例子都假定fileio.open是同步操作。但有许多网络资源实际上只能通过异步(因此:可能缓慢,可能容易出错)API 获得,虽然这可能是一个假设性[2]问题。如果你曾经想要写出async def __init__(self): ...,那么你已经在实践中碰到了这种限制。

  要全面描述这种方法的所有问题,恐怕得写一本关于面向对象设计哲学的专著。所以我简单总结一下:所有这些问题的根源其实是相同的——我们把“创建数据结构”这个行为与“这个数据结构常见的副作用”紧密地绑定在了一起。既然说是“常见的”,那就意味着它们并非“总是”相关联的。而在那些并不相关的情况下,代码就会变得笨重且容易出问题

  总而言之,定义__init__是一种反模式,我们需要一个替代方案。

本文翻译并首发于【Python猫】:https://pythoncat.top/posts/2025-05-02-init
解决方案

  我认为采用以下三种设计,可解决a上述问题:

  使用dataclass定义属性,

  替换之前在__init__中执行的行为,改为用一个新的类方法来实现相同的功能,

  使用精确的类型来描述一个有效的实例。

  使用dataclass属性来创建__init__

  首先,让我们将FileReader重构为一个dataclass。它会为我们生成一个__init__方法,但这不是我们可以随意定义的,它会受到约束,即只能用于赋值属性。

  @dataclass classFileReader: _fd: int defread(self, length: int)-> bytes: returnfileio.read(self._fd, length) defclose(self)->None: fileio.close(self._fd)

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

  我们丢失了FileReader("path")的简洁便利。现在用户不得不导入底层的fileio.open,这让最常见的创建对象方式变得既啰嗦又不直观。如果我们想让用户知道如何在实际场景中创建FileReader,就不得不在文档中添加对其它模块的使用指导。

  对_fd作为文件描述符的有效性没有强制检查;它只是一个整数,用户很容易传入不正确的数字,但没有出现报错。

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

  使用classmethod工厂来创建对象

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

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

  fromtypingimportSelf @dataclass classFileReader: _fd: int @classmethod defopen(cls, path: str)-> Self: returncls(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`[3]来精确要求:

  fromtypingimportNewType FileDescriptor = NewType("FileDescriptor", int)

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

  fromtypingimportCallable _open: Callable[[str], FileDescriptor] = fileio.open # type:ignore[assignment] _read: Callable[[FileDescriptor, int], bytes] = fileio.read _close: Callable[[FileDescriptor],None] = fileio.close

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

  fromtypingimportSelf @dataclass classFileReader: _fd: FileDescriptor @classmethod defopen(cls, path: str)-> Self: returncls(_open(path)) defread(self, length: int)-> bytes: return_read(self._fd, length) defclose(self)->None: _close(self._fd)

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

  总结 - 新的最佳实践

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

  将它写成数据类(或者一个 attrs 类 [4] ,如果你喜欢的话)

  使用默认的__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__` [5] 来实现,而不必直接写__init__。

  参考资料

  自己的文件对象抽象:https://docs.python.org/3.13/library/io.html#io.FileIO

  假设性:https://stackoverflow.com/questions/87892/what-is-the-status-of-posix-asynchronous-i-o-aio

  NewType:https://docs.python.org/3.13/library/typing.html#newtype

  attrs 类:https://blog.glyph.im/2016/08/attrs.html

  [5]

  __post_init__:https://docs.python.org/3.13/library/dataclasses.html.__post_init__

  如果你正在寻找优质的Python文章和项目,我必须向你推荐Python潮流周刊!

  它精选全网的优秀文章、教程、开源项目、软件工具、播客、视频、热门话题等丰富内容,让你紧跟技术最前沿,获取最新的第一手学习资料!

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
10月1日菲律宾果然有动作!事后,菲方吹牛:

10月1日菲律宾果然有动作!事后,菲方吹牛:

叶葉夜
2026-10-01 19:50:24
沉寂两月一鸣惊人!郭德纲921字长文通篇不喊冤,字字是江湖智慧

沉寂两月一鸣惊人!郭德纲921字长文通篇不喊冤,字字是江湖智慧

情感大头说说
2026-10-01 07:39:20
乌克兰“移交2名朝鲜战俘”给韩国,韩国要求道歉,泽连斯基:朝鲜俘虏老想自杀,抓2个不容易

乌克兰“移交2名朝鲜战俘”给韩国,韩国要求道歉,泽连斯基:朝鲜俘虏老想自杀,抓2个不容易

蓝星杂谈
2026-09-29 17:12:11
几乎全是假货!利润高达2400%,为何消费者还前赴后继争相购买?

几乎全是假货!利润高达2400%,为何消费者还前赴后继争相购买?

星星跌入梦里中
2026-09-26 05:47:11
环球体育:C罗只和斯科拉里、马丁内斯两位非葡萄牙籍主帅合得来

环球体育:C罗只和斯科拉里、马丁内斯两位非葡萄牙籍主帅合得来

懂球帝
2026-10-01 13:51:13
水谷隼展望奥运混团:想要击败中国队,双打更容易拿分更容易爆冷

水谷隼展望奥运混团:想要击败中国队,双打更容易拿分更容易爆冷

排球黄金眼
2026-10-01 11:18:37
月薪8万?!被全网销号一年后,争议网红「户晨风」开始找工作了

月薪8万?!被全网销号一年后,争议网红「户晨风」开始找工作了

雷科技
2026-09-30 12:10:36
亚运奖牌榜:中国155金71银64铜碾压日韩 金牌数历史第5+境外历史第2

亚运奖牌榜:中国155金71银64铜碾压日韩 金牌数历史第5+境外历史第2

醉卧浮生
2026-10-01 21:47:00
伊朗内部发生激烈交火!安全部队出动坦克控局,准将级指挥官阵亡

伊朗内部发生激烈交火!安全部队出动坦克控局,准将级指挥官阵亡

闻识
2026-10-01 11:37:44
彭德怀只指挥了一年半,谁接过了抗美援朝后半程?

彭德怀只指挥了一年半,谁接过了抗美援朝后半程?

纪史行者
2026-08-02 19:08:57
金鹰奖这一夜,人情冷暖,江湖地位,在朱亚文身上体现得淋漓尽致

金鹰奖这一夜,人情冷暖,江湖地位,在朱亚文身上体现得淋漓尽致

陈意小可爱
2026-09-30 07:17:52
家中钻进剧毒银环蛇,没人说被咬,救助人员离开后发现不对劲打电话叫住正要睡的居民发现他已被蛇咬:如果没发现,他睡过去可能就死了

家中钻进剧毒银环蛇,没人说被咬,救助人员离开后发现不对劲打电话叫住正要睡的居民发现他已被蛇咬:如果没发现,他睡过去可能就死了

扬子晚报
2026-10-01 09:20:27
不用怀疑,小米今年55万辆的目标,不可能完成了!

不用怀疑,小米今年55万辆的目标,不可能完成了!

互联网.乱侃秀
2026-10-01 12:40:30
男不男女不女!穿裙子露大腿的周深,鸟巢献唱才2天恶心一幕发生

男不男女不女!穿裙子露大腿的周深,鸟巢献唱才2天恶心一幕发生

林轻吟
2026-10-01 07:15:56
昨天国庆招待会高层重要讲话,有四个字分量很不一般!

昨天国庆招待会高层重要讲话,有四个字分量很不一般!

识局Insight
2026-10-01 15:11:14
赛力斯半价“问界”来了,华为彻底懵圈!

赛力斯半价“问界”来了,华为彻底懵圈!

互联网品牌官
2026-09-29 12:34:29
曼晚:JJ-加布里埃尔仍希望离开曼联,目前在巴塞罗那训练

曼晚:JJ-加布里埃尔仍希望离开曼联,目前在巴塞罗那训练

懂球帝
2026-10-02 01:13:16
陪睡门主角魏莹奢靡生活曝光,豪车出行、到处旅游打卡,纸醉金迷

陪睡门主角魏莹奢靡生活曝光,豪车出行、到处旅游打卡,纸醉金迷

一曲一场談
2026-08-20 15:50:26
游本昌孙女讲述爷爷最后时光:90岁以后,他吃东西就很少了,越来越瘦,但头脑很清晰;他走的时候没有病痛,非常安详

游本昌孙女讲述爷爷最后时光:90岁以后,他吃东西就很少了,越来越瘦,但头脑很清晰;他走的时候没有病痛,非常安详

台州交通广播
2026-09-30 17:33:46
国民党主席改选落幕!表面赢家是郑丽文,真正的赢家另有其人

国民党主席改选落幕!表面赢家是郑丽文,真正的赢家另有其人

呼呼历史论
2026-10-01 07:56:38
2026-10-02 01:31:00
Python猫 incentive-icons
Python猫
人生苦短,我用Python。博客:https://pythoncat.top
758文章数 8122关注度
往期回顾 全部

科技要闻

5999元起!华为Mate 90系列发布

头条要闻

美国一女囚犯被执行死刑 注射两剂药物后其"鼾声大作"

头条要闻

美国一女囚犯被执行死刑 注射两剂药物后其"鼾声大作"

体育要闻

“今天的表现,我们可以昂首离开球场”

娱乐要闻

奚梦瑶晒四太豪礼!22只龙凤镯近300万

财经要闻

智谱发上亿Token 能挽回开发者信任吗?

汽车要闻

2027款极氪001将于明年一季度上市 现款猎装同步推新配色

态度原创

健康
时尚
亲子
本地
军事航空

刷酸祛痘,为什么有人翻车?

在米兰,用时装唤醒内心的自我

亲子要闻

铅笔有橡皮,但人生无草稿#健康跃动计划

本地新闻

中秋逛白塔寺,体验国医妙荟雅集

军事要闻

美防长宣布组建“自主作战司令部”

无障碍浏览 进入关怀版