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

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.

相关推荐
热点推荐
高下立判!《马卡报》对比梅西和C罗的国家队结局!

高下立判!《马卡报》对比梅西和C罗的国家队结局!

历史第一人梅西
2026-10-01 22:26:08
名古屋大翻车!中国客场151金碾压,日本差百金,印度争三成笑话

名古屋大翻车!中国客场151金碾压,日本差百金,印度争三成笑话

林子说事
2026-10-01 18:11:45
因为是张维伊才结婚!董璇聊再婚,认可老公适合结婚过日子

因为是张维伊才结婚!董璇聊再婚,认可老公适合结婚过日子

布丁冰淇淋
2026-10-01 19:21:48
30岁阿根廷高官炮轰中国,马岛问题上站队英国,访华前先捅娄子?

30岁阿根廷高官炮轰中国,马岛问题上站队英国,访华前先捅娄子?

阿纂看事
2026-09-10 14:57:12
我在ICU当了15年护士,发现一个规律:平时对父母不孝顺的子女,在签放弃治疗同意书时,反而哭得最狠最伤心

我在ICU当了15年护士,发现一个规律:平时对父母不孝顺的子女,在签放弃治疗同意书时,反而哭得最狠最伤心

世间烟火集
2026-08-22 16:54:16
凌晨发觉客厅声控灯突然亮了,我立马躲进床底,刚想拿出手机去求救

凌晨发觉客厅声控灯突然亮了,我立马躲进床底,刚想拿出手机去求救

悬案解密档案
2025-11-28 10:16:22
震惊!网传某半导体大厂号召全员降薪共渡难关,一同降薪20%,财报曝光:老总年薪大涨33%,年薪反倒从600万涨至800万

震惊!网传某半导体大厂号召全员降薪共渡难关,一同降薪20%,财报曝光:老总年薪大涨33%,年薪反倒从600万涨至800万

火山詩话
2026-09-27 13:25:11
不用怀疑,小米今年55万辆的目标,不可能完成了!

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

互联网.乱侃秀
2026-10-01 12:40:30
破案了!林诗栋夺冠领奖全程黑脸,终于知道他为什么笑不出来

破案了!林诗栋夺冠领奖全程黑脸,终于知道他为什么笑不出来

观察者海风
2026-09-30 11:42:54
教育部任命!211文科大学,迎新副校长

教育部任命!211文科大学,迎新副校长

双一流高校
2026-10-02 00:25:41
心理学中,有个永不抑郁的秘诀

心理学中,有个永不抑郁的秘诀

鼠鼠健康图鉴
2026-09-30 06:00:13
不再称高市早苗为“首相”!中方这个表述值得关注

不再称高市早苗为“首相”!中方这个表述值得关注

看看新闻Knews
2026-10-01 01:55:12
拳王泰森来瞻仰毛主席遗容,他出来说了一句这辈子都没敢说的话

拳王泰森来瞻仰毛主席遗容,他出来说了一句这辈子都没敢说的话

游文刀
2026-09-30 00:14:51
女性长期使用情趣用品,身体可能出现哪些不适?不少人缺乏认知

女性长期使用情趣用品,身体可能出现哪些不适?不少人缺乏认知

看世界的人
2026-08-07 09:22:50
日本最奇葩的将军?带14万日军却不打仗,投降时战俘营已自己盖好

日本最奇葩的将军?带14万日军却不打仗,投降时战俘营已自己盖好

千秋文化
2026-09-29 21:26:19
梁洛施和男友马浴柯出席路演,十指紧扣大方秀恩爱,尽显甜蜜

梁洛施和男友马浴柯出席路演,十指紧扣大方秀恩爱,尽显甜蜜

阿废冷眼观察所
2026-10-02 00:30:52
北大化学才女李天乐,利用职务之便,将清华毕业的丈夫,一点点“熬”死在病房!她领出的剧毒为何消失了?

北大化学才女李天乐,利用职务之便,将清华毕业的丈夫,一点点“熬”死在病房!她领出的剧毒为何消失了?

史源观点
2026-09-29 19:09:22
杨毅:杨鸣出来没想到这么受欢迎 他肯定还有执教意愿等最合适机会

杨毅:杨鸣出来没想到这么受欢迎 他肯定还有执教意愿等最合适机会

狼叔评论
2026-10-01 18:49:04
七天前C罗曾表示:如果教练不打算用我,我今天就可以离开

七天前C罗曾表示:如果教练不打算用我,我今天就可以离开

懂球帝
2026-10-01 08:54:09
连续嘲讽郭德纲两月,杨议万万没料到,对方人气丝毫未减

连续嘲讽郭德纲两月,杨议万万没料到,对方人气丝毫未减

草莓解说体育
2026-10-02 01:21:35
2026-10-02 02:16:49
Python猫 incentive-icons
Python猫
人生苦短,我用Python。博客:https://pythoncat.top
758文章数 8122关注度
往期回顾 全部

科技要闻

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

头条要闻

桂林市文促会深夜发布致歉信:向阿丘诚恳致歉

头条要闻

桂林市文促会深夜发布致歉信:向阿丘诚恳致歉

体育要闻

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

娱乐要闻

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

财经要闻

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

汽车要闻

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

态度原创

时尚
教育
手机
本地
军事航空

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

教育要闻

免中考”试点到底改了什么?

手机要闻

瑞银:iPhone18Pro系列等待期表现平平予苹果“中性”评级

本地新闻

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

军事要闻

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

无障碍浏览 进入关怀版