最近读到流畅的python第二版时,发现其中新增了不少内容。包括本文中要讨论的数据类构建器。
书中大部分篇幅在介绍collections.namedtuple, typing.NamedTuple(py3.6引入)以及dataclasses.dataclass(py3.7引入) 三种快速构建python数据类的方式和它们的差异。
但是我觉得最有价值的部分在于数据类导致的代码异味和延伸阅读,其中内容对我们何时使用数据类提供了建议和指导。
最后本章可总结为三个问题:
- 什么是数据类,和普通类有什么区别
- 什么情况下需要数据类
- 如何选择合适的数据类构建器
数据类 vs 普通类
这里使用书中的实例来说明
普通类:
class Coordinate:
def __init__(self, lat, lon):
self.lat = lat
self.lon = lon
在声明普通类中, 需要声明__init__ 方法来定义类初始化所需参数. 这种写法有点**“重复”**, 在上面示例中, lat, lon都要写3次. 后续需要新增参数的话也是如此.
除此之外, 普通类也无法自动提供有意义的__repr__ 以及 __eq__等方法:
moscow = Coordinate(55.76, 37.62)
moscow
<__main__.Coordinate at 0x1a738740620> # 信息没什么用
moscow == Coordinate(55.76, 37.62) # Object __eq__ 默认比较实例id
False
数据类正是为了解决上面问题所诞生的, 它们会自动构建__init__, __repr__, __eq__方法, 以及其他有用的功能(例如 _asdict(), _fields等)
python 提供了3种不同的数据类构建器:
-
collections.namedtuple
最简单的数据类构建方式,从 Python 2.6 开始提供。import collections Coordinate = collections.namedtuple('Coodinate', ["lat", "lon"]) -
typing.NamedTuple
构建数据类的另一种方式,需要为数据类中的字段添加类型提示(Type Hints)——从 Python 3.5 开始。在 Python 3.6 中,增加了 class 语法。import typing Coordinate = typing.NamedTuple("Coordinate", lat=float, lon=float)or
import typing class Coordinate(typing.NamedTuple): lat: float lon: float -
@dataclasses.dataclass
一个类装饰器,从 Python 3.7 开始提供。与前两种方式相比,可定制的内容更多,增加了大量选项,可实现更复杂的功能。from dataclasses import dataclass @dataclass class Coordinate: lat: float lon: float
什么情况下需要数据类
代码异味问题
书中提到了一个数据类导致代码异味的问题. 这里直接引用书中原文:
无论是自己编写所有代码实现数据类,还是利用本章介绍的类构建器来实现数据类,都要注意一点:这可能表示您的设计存在问题。
在《重构:改善既有代码的设计》第 2 版 一书中,Martin Fowler 和 Kent Beck 提出了 “代码异味” 的目录——列出了代码中可能需要进行重构的模式(Pattern)。其中,题为 “Data Class” 一条的内容如下:
“数据类” 是指那些只包含字段、读写字段的方法,除此之外没有其他功能与逻辑。这样的类被称为“愚蠢的数据持有者”,只用于存储数据,并被其他的类以过于详细的方式进行操作。
由于数据类只是字段集合, 几乎没有额外功能. 这就意味着实例的处理方法可能分散在不同的文件或者模块中, 一旦数据结构发生变化,对维护者来说将是一场噩梦. 所以对于有特殊方法的数据类需要重构, 将操作实例的方法放回类型当中去.
适用场景
为了避免代码异味问题, 数据类仅仅适合几乎没有行为的场景. 书中推荐了两种使用场景:
- 用作开发初期脚手架
- 用作中间表述
用作开发初期脚手架: 为了快速启动新模块或者项目, 先实现一个简单的数据类. 数据类拥有自己的方法, 而不是在别的模块或者类当中操作它. 在功能完善后在移除
用作中间表述: 可用于构建要导出为 JSON 或其他交换格式的记录,或者用来保存刚刚导入的、跨越系统边界的数据。Python 的数据类构建器都提供了一种方法或函数,用于将一个数据类实例构建为一个普通的 dict。以将 dict(用 ** 扩展)作为关键字参数,来调用数据类构建器的构造函数。这样的 dict 非常接近 JSON 记录
但是我觉得需要根据不同构造类来区分. 下面是书中关于不同构造类的对比:
| 功能特性 | nametuple | NamedTuple | dataclass |
|---|---|---|---|
| 可变实例 | NO | NO | YES |
| class语法 | NO | YES | YES |
| 构造字典 | x._asdict() | x._asdict() | dataclasses.asdict(x) |
| 获取字段名 | x._fields | x._fields | [f.name for f in dataclasses.fields(x)] |
| 获取默认值 | x._field_defaults | x._field_defaults | [f.default for f in dataclasses.fields(x)] |
| 获取字段类型 | N/A | x._annotations_ | x._annotations_ |
| 更改后,创建新实例 | x._replace(…) | x._replace(…) | dataclasses.replace(x, …) |
| 运行时,创建新类 | nametuple(…) | NamedTuple(…) | dataclasses.make_dataclass(…) |
其中collections.nametuple 和typing.NamedTuple 二者相似, 构造的都是tuple类型, 意味着元素不可变. 这样一来要实现对数据的操作方法是个非常大的限制. 更符合代码异味中愚蠢的数据持有者定义, 因此这两种构造方式基本上只适用于中间表述.
对于dataclass可以当做实现了默认__init__, __eq__ 等方法的普通类来看待, 不仅仅局限于开发初期的脚手架使用. 其完整签名如下:
@dataclass(*, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
init: 作用为生成__init__, 如果用户已实现,则忽略.repr: 生成__repr__, 如用于已实现,则忽略eq: 生成__eq__order: 生成__lt__, __le__, __gt__, __ge__unsafe_hash: 生成__hash__frozen: 让实例不可变
书中延伸阅读中总结不适合数据类的情况:
需要兼容 tuple 或 dict 的 API 时。
需要超出 PEP 484 和 526 提供的类型验证时,或者需要值验证或转换时。
——Eric V. Smith, PEP 557 “Rationale”
感觉还可以加上一条:
- 当你需要大量重载其自动生成的内部方法时
选择合适的数据类构建器
虽然介绍了3中构建器,但是从实际项目开发角度来说,只要python版本高于3.6 建议使用typing.NamedTuple 而不是collections.namedtuple. 最主要的原因就是前者可添加参数类型注释.
import typing
Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float)
同时typing.NamedTuple还支持class语法, 意味着用户可以继承typing.NameTuple,并重载类方法. 例如__repr__, __eq__等. 相比之下collections.nametuple 则不支持此类操作. 虽然二者都返回tuple 类型实例,但typing.NamedTuple 在开发和扩展上都提供了更高的灵活性和可读性。
可以认为typing.NamedTuple就是collections.namedtuple的上位替代者。
这样一来数据类构建器的选择基本就是typing.NamedTuple 和dataclass 二选一了。从上面章节的数据类对比表格中可以窥见一二
个人觉得从三方面考虑:
- python版本要求
- 是否要求实例可变
- 是否要求紧凑的数据类型
可以总结为一段伪代码:
import typing
import collections
from dataclasses import dataclass
match py_version:
case v if v >= 2.6 and v <= 3.6:
return collections.namedtuple
case 3.6:
return typing.NamedTuple
case >= 3.7:
# 要求不可变实例以及紧凑的数据结构时,选择`typing.NamedTuple`
if immutable_instance and compact_data_structrue:
return typing.NamedTuple
return dataclass
case _:
return None
1797

被折叠的 条评论
为什么被折叠?



