跳至主要内容

Python 数据类:全面教程

面向初学者的 Python 数据类教程,以及如何在实践中使用它们
· 9分钟 阅读

使用 AI 探索

数据类是 Python 中一种“用过就回不去”的特性。先看一个普通类:

class Exercise:
   def __init__(self, name, reps, sets, weight):
       self.name = name
       self.reps = reps
       self.sets = sets
       self.weight = weight

在我看来,这样的类定义非常低效——在 __init__ 方法里,您至少要把每个参数重复三次。这听起来也许不算什么,但想想看,您这一生要写多少带有更多参数的类。

对比来看,看看上面代码的数据类替代方案:

from dataclasses import dataclass


@dataclass
class Exercise:
   name: str
   reps: int
   sets: int
   weight: float  # Weight in lbs

这段看似朴素的代码,比普通类要好上几个数量级。小小的 @dataclass 装饰器在幕后实现了 __init__、__repr__、__eq__ 等方法,手写这些至少需要 20 行代码。

此外,许多其他特性,比如比较运算符、对象排序和不可变性,也都只需一行配置就能为我们的类“自动生成”。

因此,本教程的目的是向您展示:如果您热爱面向对象编程,数据类为何是 Python 最好的发明之一。

让我们开始吧!

Python 数据类基础

先来介绍一些让数据类如此好用的基本概念。

数据类会自动生成一些方法

尽管功能丰富,数据类本质上仍是普通类,但用更少的代码实现了同样的功能。下面再次给出 Exercise 类:

from dataclasses import dataclass


@dataclass
class Exercise:
   name: str
   reps: int
   sets: int
   weight: float


ex1 = Exercise("Bench press", 10, 3, 52.5)

# Verifying Exercise is a regular class
ex1.name
'Bench press'

此时,Exercise 已经内置了 __repr__ 和 __eq__ 方法。我们来验证一下:

repr(ex1)
"Exercise(name='Bench press', reps=10, sets=3, weight=52.5)"

对象的 repr 表示应返回可用来重建该对象的代码,我们可以看到 ex1 完全符合这一点。

相比之下,按旧方式定义的 Exercise 会是这样:

class Exercise:
   def __init__(self, name, reps, sets, weight):
       self.name = name
       self.reps = reps
       self.sets = sets
       self.weight = weight


ex3 = Exercise("Bench press", 10, 3, 52.5)

ex3
<__main__.Exercise at 0x7f6834100130>

看起来很糟糕,也没什么用!

现在,我们来验证一下 __eq__(即相等性运算符):

# Redefine the class
@dataclass
class Exercise:
   name: str
   reps: int
   sets: int
   weight: float


ex1 = Exercise("Bench press", 10, 3, 52.5)
ex2 = Exercise("Bench press", 10, 3, 52.5)

将类与自身、以及与参数完全相同的另一个类实例进行比较,都应返回 True:

ex1 == ex2
True
ex1 == ex1
True

确实如此!在普通类中,实现这套逻辑会很繁琐。

数据类需要类型注解

如您所见,定义字段时,数据类需要类型注解。事实上,数据类允许使用 typing 模块中的任意类型。例如,下面是一个可接受 Any 类型的字段:

from typing import Any


@dataclass
class Dummy:
   attr: Any

不过,Python 有个特性是:尽管数据类需要类型注解,但运行时并不会强制类型检查。

例如,用完全错误的数据类型来创建 Exercise 的实例,也不会报错:

silly_exercise = Exercise("Bench press", "ten", "three sets", 52.5)

silly_exercise.sets

“Three sets”

如果您想强制检查数据类型,必须使用类型检查器,例如 Mypy。

数据类允许为字段设置默认值

到目前为止,我们还未为类添加默认值。现在来补上:

@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float = 0


# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-ups', reps=10, sets=3, weight=0)

请记住,非默认字段不能放在默认字段之后。例如,下面的代码会报错:

@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float  # NOT ALLOWED


ex5 = Exercise()
ex5
TypeError: non-default argument 'weight' follows default argument

在实践中,您很少会使用 name: type = value 的语法来定义默认值。

更常见的是使用 field 函数,它能对字段定义进行更精细的控制:

from dataclasses import field


@dataclass
class Exercise:
   name: str = field(default="Push-up")
   reps: int = field(default=10)
   sets: int = field(default=3)
   weight: float = field(default=0)


# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-up', reps=10, sets=3, weight=0)

field 函数还提供更多参数,例如:

  • repr
  • init
  • compare
  • default_factory

等等。我们会在接下来的小节中讨论这些。

可以用函数创建数据类

关于数据类基础的最后一点是:使用 make_dataclass 函数可以让定义更短:

from dataclasses import make_dataclass

Exercise = make_dataclass(
   "Exercise",
   [
       ("name", str),
       ("reps", int),
       ("sets", int),
       ("weight", float),
   ],
)

ex3 = Exercise("Deadlifts", 8, 3, 69.0)
ex3
Exercise(name='Deadlifts', reps=8, sets=3, weight=69.0)

但这会牺牲可读性,所以我不推荐使用这种方式。

Python 数据类进阶

本节将讨论数据类的高级特性,它们能带来更多好处。其中一个就是默认工厂(default factory)。

默认工厂

为解释默认工厂,我们来创建另一个名为 WorkoutSession 的类,它接受两个字段:

from dataclasses import dataclass
from typing import List


@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float = 0


@dataclass
class WorkoutSession:
   exercises: List[Exercise]
   duration_minutes: int

通过使用 List 类型,我们指定 WorkoutSession 接受一个由 Exercise 实例组成的列表。

# Define the Exercise instances for HIIT training
ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex2 = Exercise(name="Mountain Climbers", reps=20, sets=3)
ex3 = Exercise(name="Jump Squats", reps=12, sets=3)
exercises_monday = [ex1, ex2, ex3]

hiit_monday = WorkoutSession(exercises=exercises_monday, duration_minutes=30)

目前,每个训练会话实例在初始化时都需要传入练习列表。但这并不符合人们实际的锻炼方式——通常先开始一个会话(可能在 App 里),然后边练边添加动作。

因此,我们必须能够创建没有练习、也没有时长的会话。我们尝试为 exercises 字段添加一个空列表作为默认值:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = []
   duration_minutes: int = None


hiit_monday = WorkoutSession("25-02-2024")
ValueError: mutable default <class 'list'> for field exercises is not allowed: use default_factory

然而,我们收到了错误——数据类不允许可变的默认值。

幸好,可以用默认工厂来解决:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=list)  # PAY ATTENTION
   duration_minutes: int = 0


hiit_monday = WorkoutSession()
hiit_monday
WorkoutSession(exercises=[], duration_minutes=0)

default_factory 参数接受一个函数,为数据类字段返回初始值。这意味着它可以接受任意函数:

  • tuple
  • dict
  • set
  • 任意用户自定义函数

无论函数返回的结果是否可变,这一点都成立。

进一步想想,大多数人开始训练时都会先做热身动作,而且通常对任何类型的训练都差不多。所以,用“没有任何练习”来初始化会话,可能并不符合某些人的需求。

因此,我们来创建一个函数,返回三个热身 Exercise:

def create_warmup():
   return [
       Exercise("Jumping jacks", 30, 1),
       Exercise("Squat lunges", 10, 2),
       Exercise("High jumps", 20, 1),
   ]

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5  # Increase the default duration as well


hiit_monday = WorkoutSession()
hiit_monday

WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)

现在,每次创建会话时,都会自带一些热身动作记录。新版 WorkoutSession 也将默认时长设为 5 分钟以反映这一点。

为数据类添加方法

由于数据类仍是普通类,给它们添加方法的方式并没有变化。我们给 WorkoutSession 数据类加上两个方法:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   def add_exercise(self, exercise: Exercise):
       self.exercises.append(exercise)

   def increase_duration(self, minutes: int):
       self.duration_minutes += minutes

使用这些方法,我们现在可以为会话记录新的动作:

hiit_monday = WorkoutSession()

# Log a new exercise
new_exercise = Exercise("Deadlifts", 6, 4, 60)

hiit_monday.add_exercise(new_exercise)
hiit_monday.increase_duration(15)

不过有个问题:

hiit_monday

WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0), Exercise(name='Deadlifts', reps=6, sets=4, weight=60)], duration_minutes=20)

当我们打印会话时,其默认表示过于冗长且不易阅读,因为它包含了用于重建对象的代码。我们来改进它。

__repr__ 和 __str__ 在数据类中的用法

数据类会自动实现 __repr__,但不会实现 __str__。这使得在调用 print 时,会回退到 __repr__。

因此,我们自定义 __str__ 来覆盖这一行为:

@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float = 0

   def __str__(self):
       base = f"{self.name}: {self.reps}/{self.sets}"
       if self.weight == 0:
           return base
       return base + f", {self.weight} lbs"


ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex1
Exercise(name='Burpees', reps=15, sets=3, weight=0)

__repr__ 仍然相同,但当我们对它调用 print:

print(ex1)
Burpees: 15/3

类的字符串表示更友好易读了。现在也来改进 WorkoutSession:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5  # Increase the default duration as well

   def add_exercise(self, exercise: Exercise):
       self.exercises.append(exercise)

   def increase_duration(self, minutes: int):
       self.duration_minutes += minutes

   def __str__(self):
       base = ""

       for ex in self.exercises:
           base += str(ex) + "\n"
       base += f"\nSession duration: {self.duration_minutes} minutes."

       return base


hiit_monday = WorkoutSession()
print(hiit_monday)

Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1

Session duration: 5 minutes.

提示:使用代码片段底部的“Explain code”按钮可获得逐行讲解。

现在,我们得到了可读、紧凑的输出。

数据类中的比较

对于很多类来说,按某种逻辑比较其对象是有意义的。对于训练来说,可以按训练时长、动作强度或重量比较。

首先,看看在当前状态下比较两个会话会发生什么:

hiit_wednesday = WorkoutSession()

hiit_wednesday.add_exercise(Exercise("Pull-ups", 7, 3))
print(hiit_wednesday)

Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1
Pull-ups: 7/3

Session duration: 5 minutes.

hiit_monday > hiit_wednesday
TypeError: '>' not supported between instances of 'WorkoutSession' and 'WorkoutSession'

我们收到了 TypeError,因为数据类默认不实现比较运算符。但这很容易通过将 order 参数设为 True 来解决:

@dataclass(order=True)
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   ...

hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)
hiit_monday.increase_duration(10)

hiit_wednesday = WorkoutSession()

hiit_monday > hiit_wednesday

True

这次,比较可用了,但我们究竟在比较什么?

在数据类中,比较是按照字段定义的顺序进行的。当前情况下,类是基于训练时长进行比较的,因为第一个字段 exercises 包含的是自定义对象,无法直接比较。

我们可以通过增加周三会话的时长来验证:

hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)

hiit_wednesday = WorkoutSession()
hiit_wednesday.increase_duration(10)

hiit_monday > hiit_wednesday
False

正如预期,返回了 False。

但如果 Workout 的第一个字段是其他类型,比如字符串,会怎样?我们来试试:

@dataclass(order=True)
class WorkoutSession:
   date: str = None  # DD-MM-YYYY
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   ...

hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)

hiit_wednesday = WorkoutSession("27-02-2024")

hiit_monday > hiit_wednesday
False

尽管周一会话更长,但比较结果显示它比周三“小”。原因在于,在字符串比较中,“25”位于“27”之前。

那么,如何在保持字段顺序的同时,仍然按训练时长来排序呢?使用 field 很容易做到:

@dataclass(order=True)
class WorkoutSession:
   date: str = field(default=None, compare=False)
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   ...

hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)

hiit_wednesday = WorkoutSession("27-02-2024")

hiit_monday > hiit_wednesday
True

将任意字段的 compare 设为 False,即可将其排除在排序之外,上面的结果就证明了这一点。

后初始化字段处理

目前,我们将默认会话时长设为 5 分钟,以反映热身动作。但这只在用户以热身开始会话时才合理。如果他们以其他动作开始会话:

new_session = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])

new_session.duration_minutes
5

对于只有一个动作的会话,总时长为 5 分钟就不合逻辑了。每个会话应当根据各动作的组数动态估算其时长。这意味着 duration_minutes 应依赖于 exercises 字段。

我们来实现它:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = field(default=0, init=False)

   def __post_init__(self):
       set_duration = 3
       for ex in self.exercises:
           self.duration_minutes += ex.sets * set_duration

   ...

这一次,我们将 duration_minutes 的 init 设为 False,以推迟该字段的初始化。

随后,在特殊方法 __post_init__ 中,我们根据每个 Exercise 的总组数来更新它。

现在,当我们初始化 WorkoutSession 时,duration_minutes 会按每组 3 分钟、对每个动作进行动态累加。

# Adding an exercise with three sets
hiit_friday = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])

hiit_friday.duration_minutes
9

一般来说,如果您想定义一个依赖于数据类其他字段的字段,就可以使用 __post_init__ 逻辑。

数据类的不可变性

我们的 WorkoutSession 数据类几乎准备就绪了;接下来需要对其进行保护。当前它很容易被随意修改:

hiit_friday.duration_minutes = 1000

hiit_friday.duration_minutes
1000

del hiit_friday.exercises

我们希望保护类中的所有字段,使其只能以我们允许的方式被修改。为此,@dataclass 装饰器提供了一个方便的 frozen 参数:

@dataclass(frozen=True)
class FrozenExercise:
   name: str
   reps: int
   sets: int
   weight: int | float = 0


ex1 = FrozenExercise("Muscle-ups", 5, 3)

现在,如果我们想修改任何字段,就会报错:

ex1.sets = 5
FrozenInstanceError: cannot assign to field 'sets'

将 frozen 设为 True 会为每个字段自动添加 __deleteattr__ 和 __setattr__,从而在初始化后防止删除或更新。并且,也无法新增字段:

ex1.new_field = 10
FrozenInstanceError: cannot assign to field 'new_field'

如果是传统类,实现这些功能得写上几十行代码。

不过请注意,我们仍无法让类“真正不可变”。例如,将 WorkoutSession 改写为 frozen=True:

@dataclass(frozen=True)
class ImmutableWorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5


session1 = ImmutableWorkoutSession()

如预期,我们无法直接修改练习列表:

session1.exercises = [Exercise()]

然而,exercises 是一个列表,列表本身是可变的,因此下面的操作是可能的:

# 修改列表中的某个元素

# Changing one of the elements in a list
session1.exercises[1] = FrozenExercise("Totally new exercise", 5, 5)

print(session1)

ImmutableWorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), FrozenExercise(name='Totally new exercise', reps=5, sets=5, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)

因此,为防止意外更改,建议在字段值中使用不可变对象,例如元组。

数据类中的继承

最后一点,我们来讨论父类与子类中字段顺序的问题。

由于数据类仍是普通类,继承照常工作:

@dataclass(frozen=True)
class ImmutableWorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5


@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
   pass

但是,由于父类(ImmutableWorkoutSession)的最后一个字段有默认值,子类中的所有新增字段都必须有默认值。

例如,下面这样是不允许的:

@dataclass(frozen=True)
class ImmutableWorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5


@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
   intensity_level: str  # Not allowed, must have a default

TypeError: non-default argument 'intensity_level' follows default argument

数据类的不足与进阶资源

自 Python 3.7 起(一开始就已经很不错了),数据类一直在不断改进,覆盖了许多需要编写类的用例。但在以下场景中,它们可能不太合适:

  • 自定义 __init__ 方法
  • 自定义 __new__ 方法
  • 多种继承模式

以及更多情况,详见 这个很棒的 Reddit 讨论帖。如果您想了解引入数据类的更详细原因、以及它们为何不能直接替代常规类定义,请阅读 PEP 557。

如果您对面向对象编程感兴趣,这里有一门课程可继续深入:

从根本上说,数据类是一种更精巧的数据容器,让数据的存取更高效。不过,Python 还有许多其他数据结构在这方面同样表现不俗。例如,您可以在 Data Types for Data Science 课程的最后一章学习 counters、defaultdict 和 namedtuple 等内容。

Bexruz (Bex) Tuychiev

作者

Bexruz (Bex) Tuychiev

我是一名数据科学内容创作者,拥有超过 2 年的经验,并在 Medium 上拥有较大的读者群。我喜欢用略带讽刺的笔调撰写有关 AI 和机器学习的深度文章——毕竟总得想点办法让这些内容不那么枯燥。我已发表 130 多篇文章,并制作了一门 DataCamp 课程,另有一门正在筹备中。我的内容已被超过 500 万人次阅读,其中有 2 万人在 Medium 和 LinkedIn 上成为关注者。 

主题