社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

Python 类不要再写 __init__ 方法了

Python见习室 • 4 周前 • 80 次点击  

👉 欢迎加入小哈的星球,你将获得: 专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17..., 点击查看项目介绍
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2期已完结,演示链接:http://116.62.199.48/;

截止目前,累计输出 77w+ 字,讲解图 3088+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,Spring Cloud Alibaba 等等,戳我加入学习,解锁全部项目,已有2600+小伙伴加入

图片

原作: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__ 方法。

那时候可用的其它实现方法都存在相当严重的问题:

  1. 你可以将  2DCoordinate 从公共 API 中移除,转而暴露一个 make_2d_coordinate 函数并使其不可导入,但这样你该如何在文档体现返回值或参数类型呢?
  2. 你可以记录 x 和 y 属性并让用户自己分别赋值,但这样 2DCoordinate() 就会返回一个无效的对象。
  3. 你可以使用类属性将坐标默认值设为 0,这虽然解决了选项 2 的问题,但这会要求所有 2DCoordinate 对象不仅是可变的,而且在每个调用点都必须被修改。
  4. 你可以通过添加一个新的抽象类来解决选项 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 类:

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 获得,虽然这可能是一个假设性[2]问题。如果你曾经想要写出 async def __init__(self): ...,那么你已经在实践中碰到了这种限制。

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

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

解决方案

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

  • 使用 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 的问题时,我们又引入了它所解决的几个问题:

  1. 我们丢失了 FileReader("path") 的简洁便利。现在用户不得不导入底层的 fileio.open,这让最常见的创建对象方式变得既啰嗦又不直观。如果我们想让用户知道如何在实际场景中创建 FileReader,就不得不在文档中添加对其它模块的使用指导。
  2. 对 _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`[3] 来精确要求:




    
from typing import NewType
FileDescriptor = NewType("FileDescriptor", int)

有几种方法可以处理底层库的问题,但为简洁起见,也为了展示这种方法不会带来任何运行时开销,我们干脆直接告诉 Mypy:这里使用的 fileio.openfileio.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只是一个方便的工具,帮助我们在使用intstr bytes等基本类型时施加必要的约束。

总结 - 新的最佳实践

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

  • 将它写成数据类(或者一个 attrs 类[4],如果你喜欢的话)
  • 使用默认的 __init__ 方法。【注2】
  • 添加 @classmethod ,为调用者提供方便且公开的对象构造方法。
  • 要求所有依赖项都通过属性来满足,这样总是先创建出一个有效的对象。
  • 使用typing.NewType来对基本数据类型(比如intstr)添加限制条件,尤其是当这些类型需要具备一些特殊属性时,比如必须来自某个特定库、必须是随机生成的等等。

如果以这种方式来定义类,你将获得自定义 __init__ 方法的所有好处:

  • 所有调用你数据结构的人都能拿到有效对象,因为只要属性设置正确,对象自然就是有效的。
  • 你的库用户能够使用便捷的对象创建方法,这些方法会处理好各种复杂工作,让使用变得简单。而且用户只要看一眼类的方法列表,就能发现这些创建方式。

还有一些其它的好处:

  • 你的代码会更经得起未来的考验,能轻松应对用户创建对象的各种新需求。
  • 如果需要有多种实例化你的类的方式,那么可以给每种方式一个有意义的名称;不需要使用像 def __init__(self, maybe_a_filename: int | str | None = None): 这样的怪物。
  • 写测试时,你只需要提供所有需要的依赖项就能构造对象;不需要再用猴子补丁了,因为你可以直接调用类型构造器而不会产生任何 I/O 操作或副作用。

在没有数据类之前,Python 语言中有个怪现象:仅仅是给数据结构填充数据这么基础的事情,竟然要重写一个带着 4 个下划线的方法。__init__方法就像个异类。而其他的魔术方法,像__add____repr__,本质上是在处理类的一些高级特性。

如今,这个历史遗留的语言瑕疵已经得到解决。有了@dataclass@classmethod 和 NewType ,你可以构建出易用、符合 Python 风格、灵活、易测试和健壮的类。

文中注释:

  1. 如果你还不熟悉,“文件描述符”其实是一个只在程序内部有意义的整数。当你让操作系统打开一个文件时,它会回应“我已经为你打开了文件 7”,之后每当你引用“7”这个数字,它就代表那个文件,直到你执行close(7)关闭它。
  2. 当然,除非你有非常充分的理由。比如为了向后兼容,或者与其它库兼容,这些都可能是合理的理由。还有一些数据一致性校验,是无法通过类型系统表达的。最常见的例子是需要检查两个不同字段之间关系的类,比如“range”对象,其中start必须始终小于end。这类规则总有例外。不过,在 __init__里执行任何 I/O 操作基本上都不是好主意,而那些在某些特殊情况下可能有用的其它操作,几乎都可以通过`__post_init__`[5]来实现,而不必直接写__init__

👉 欢迎加入小哈的星球,你将获得: 专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新项目:《从零手撸:仿小红书(微服务架构)》  正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17..., 点击查看项目介绍
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2期已完结,演示链接:http://116.62.199.48/;

截止目前,累计输出 77w+ 字,讲解图 3088+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,Spring Cloud Alibaba 等等,戳我加入学习,解锁全部项目,已有2600+小伙伴加入

图片
图片




    
图片

1. 我的私密学习小圈子,从0到1手撸企业实战项目!

2. 知乎热议:为什么多数程序员都不做个人独立开发?

3. 你知道什么是 SaaS 吗?

4. 3200+ Cursor 用户被恶意“劫持”!贪图“便宜API”却惨遭收割, AI 开发者们要小心了

图片

最近面试BAT,整理一份面试资料Java面试BATJ通关手册,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。

获取方式:点“在看”,关注公众号并回复 Java 领取,更多内容陆续奉上。

PS:因公众号平台更改了推送规则,如果不想错过内容,记得读完点一下 在看,加个星标,这样每次新文章推送才会第一时间出现在你的订阅列表里。

“在看”支持小哈呀,谢谢

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/182354
 
80 次点击