数据类是 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
技术支持
这段看似朴素的代码,比普通类要好上几个数量级。小小的 @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 )
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__(即相等性运算符):
@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
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
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 )
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 实例组成的列表。
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 )
duration_minutes: int = 0
hiit_monday = WorkoutSession( )
hiit_monday
WorkoutSession( exercises= [ ] , duration_minutes= 0 ) 技术支持
default_factory 参数接受一个函数,为数据类字段返回初始值。这意味着它可以接受任意函数:
无论函数返回的结果是否可变,这一点都成立。
进一步想想,大多数人开始训练时都会先做热身动作,而且通常对任何类型的训练都差不多。所以,用“没有任何练习”来初始化会话,可能并不符合某些人的需求。
因此,我们来创建一个函数,返回三个热身 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
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( )
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
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. increase_duration( 10 )
hiit_wednesday = WorkoutSession( )
hiit_monday > hiit_wednesday
True 技术支持
这次,比较可用了,但我们究竟在比较什么?
在数据类中,比较是按照字段定义的顺序进行的。当前情况下,类是基于训练时长进行比较的,因为第一个字段 exercises 包含的是自定义对象,无法直接比较。
我们可以通过增加周三会话的时长来验证:
hiit_monday = WorkoutSession( )
hiit_wednesday = WorkoutSession( )
hiit_wednesday. increase_duration( 10 )
hiit_monday > hiit_wednesday
False 技术支持
正如预期,返回了 False。
但如果 Workout 的第一个字段是其他类型,比如字符串,会怎样?我们来试试:
@dataclass ( order= True )
class WorkoutSession :
date: str = None
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 分钟、对每个动作进行动态累加。
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 是一个列表,列表本身是可变的,因此下面的操作是可能的:
# 修改列表中的某个元素
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
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 等内容。
数据类是 Python 中一种“用过就回不去”的特性。先看一个普通类:
在我看来,这样的类定义非常低效——在
__init__方法里,您至少要把每个参数重复三次。这听起来也许不算什么,但想想看,您这一生要写多少带有更多参数的类。对比来看,看看上面代码的数据类替代方案:
这段看似朴素的代码,比普通类要好上几个数量级。小小的
@dataclass装饰器在幕后实现了__init__、__repr__、__eq__等方法,手写这些至少需要 20 行代码。此外,许多其他特性,比如比较运算符、对象排序和不可变性,也都只需一行配置就能为我们的类“自动生成”。
因此,本教程的目的是向您展示:如果您热爱面向对象编程,数据类为何是 Python 最好的发明之一。
让我们开始吧!
Python 数据类基础
先来介绍一些让数据类如此好用的基本概念。
数据类会自动生成一些方法
尽管功能丰富,数据类本质上仍是普通类,但用更少的代码实现了同样的功能。下面再次给出
Exercise类:此时,
Exercise已经内置了__repr__和__eq__方法。我们来验证一下:对象的
repr表示应返回可用来重建该对象的代码,我们可以看到ex1完全符合这一点。相比之下,按旧方式定义的
Exercise会是这样:看起来很糟糕,也没什么用!
现在,我们来验证一下
__eq__(即相等性运算符):将类与自身、以及与参数完全相同的另一个类实例进行比较,都应返回 True:
确实如此!在普通类中,实现这套逻辑会很繁琐。
数据类需要类型注解
如您所见,定义字段时,数据类需要类型注解。事实上,数据类允许使用
typing模块中的任意类型。例如,下面是一个可接受Any类型的字段:不过,Python 有个特性是:尽管数据类需要类型注解,但运行时并不会强制类型检查。
例如,用完全错误的数据类型来创建
Exercise的实例,也不会报错:如果您想强制检查数据类型,必须使用类型检查器,例如 Mypy。
数据类允许为字段设置默认值
到目前为止,我们还未为类添加默认值。现在来补上:
请记住,非默认字段不能放在默认字段之后。例如,下面的代码会报错:
在实践中,您很少会使用
name: type = value的语法来定义默认值。更常见的是使用
field函数,它能对字段定义进行更精细的控制:field函数还提供更多参数,例如:reprinitcomparedefault_factory等等。我们会在接下来的小节中讨论这些。
可以用函数创建数据类
关于数据类基础的最后一点是:使用
make_dataclass函数可以让定义更短:但这会牺牲可读性,所以我不推荐使用这种方式。
Python 数据类进阶
本节将讨论数据类的高级特性,它们能带来更多好处。其中一个就是默认工厂(default factory)。
默认工厂
为解释默认工厂,我们来创建另一个名为
WorkoutSession的类,它接受两个字段:通过使用
List类型,我们指定WorkoutSession接受一个由Exercise实例组成的列表。目前,每个训练会话实例在初始化时都需要传入练习列表。但这并不符合人们实际的锻炼方式——通常先开始一个会话(可能在 App 里),然后边练边添加动作。
因此,我们必须能够创建没有练习、也没有时长的会话。我们尝试为
exercises字段添加一个空列表作为默认值:然而,我们收到了错误——数据类不允许可变的默认值。
幸好,可以用默认工厂来解决:
default_factory参数接受一个函数,为数据类字段返回初始值。这意味着它可以接受任意函数:tupledictset无论函数返回的结果是否可变,这一点都成立。
进一步想想,大多数人开始训练时都会先做热身动作,而且通常对任何类型的训练都差不多。所以,用“没有任何练习”来初始化会话,可能并不符合某些人的需求。
因此,我们来创建一个函数,返回三个热身
Exercise:现在,每次创建会话时,都会自带一些热身动作记录。新版
WorkoutSession也将默认时长设为 5 分钟以反映这一点。为数据类添加方法
由于数据类仍是普通类,给它们添加方法的方式并没有变化。我们给
WorkoutSession数据类加上两个方法:使用这些方法,我们现在可以为会话记录新的动作:
不过有个问题:
当我们打印会话时,其默认表示过于冗长且不易阅读,因为它包含了用于重建对象的代码。我们来改进它。
__repr__和__str__在数据类中的用法数据类会自动实现
__repr__,但不会实现__str__。这使得在调用 print 时,会回退到__repr__。因此,我们自定义
__str__来覆盖这一行为:__repr__仍然相同,但当我们对它调用print:类的字符串表示更友好易读了。现在也来改进
WorkoutSession:提示:使用代码片段底部的“Explain code”按钮可获得逐行讲解。
现在,我们得到了可读、紧凑的输出。
数据类中的比较
对于很多类来说,按某种逻辑比较其对象是有意义的。对于训练来说,可以按训练时长、动作强度或重量比较。
首先,看看在当前状态下比较两个会话会发生什么:
我们收到了
TypeError,因为数据类默认不实现比较运算符。但这很容易通过将order参数设为True来解决:这次,比较可用了,但我们究竟在比较什么?
在数据类中,比较是按照字段定义的顺序进行的。当前情况下,类是基于训练时长进行比较的,因为第一个字段
exercises包含的是自定义对象,无法直接比较。我们可以通过增加周三会话的时长来验证:
正如预期,返回了
False。但如果
Workout的第一个字段是其他类型,比如字符串,会怎样?我们来试试:尽管周一会话更长,但比较结果显示它比周三“小”。原因在于,在字符串比较中,“25”位于“27”之前。
那么,如何在保持字段顺序的同时,仍然按训练时长来排序呢?使用
field很容易做到:将任意字段的
compare设为False,即可将其排除在排序之外,上面的结果就证明了这一点。后初始化字段处理
目前,我们将默认会话时长设为 5 分钟,以反映热身动作。但这只在用户以热身开始会话时才合理。如果他们以其他动作开始会话:
对于只有一个动作的会话,总时长为 5 分钟就不合逻辑了。每个会话应当根据各动作的组数动态估算其时长。这意味着
duration_minutes应依赖于exercises字段。我们来实现它:
这一次,我们将
duration_minutes的init设为False,以推迟该字段的初始化。随后,在特殊方法
__post_init__中,我们根据每个Exercise的总组数来更新它。现在,当我们初始化
WorkoutSession时,duration_minutes会按每组 3 分钟、对每个动作进行动态累加。一般来说,如果您想定义一个依赖于数据类其他字段的字段,就可以使用
__post_init__逻辑。数据类的不可变性
我们的
WorkoutSession数据类几乎准备就绪了;接下来需要对其进行保护。当前它很容易被随意修改:我们希望保护类中的所有字段,使其只能以我们允许的方式被修改。为此,
@dataclass装饰器提供了一个方便的frozen参数:现在,如果我们想修改任何字段,就会报错:
将
frozen设为True会为每个字段自动添加__deleteattr__和__setattr__,从而在初始化后防止删除或更新。并且,也无法新增字段:如果是传统类,实现这些功能得写上几十行代码。
不过请注意,我们仍无法让类“真正不可变”。例如,将
WorkoutSession改写为frozen=True:如预期,我们无法直接修改练习列表:
然而,
exercises是一个列表,列表本身是可变的,因此下面的操作是可能的:# 修改列表中的某个元素
因此,为防止意外更改,建议在字段值中使用不可变对象,例如元组。
数据类中的继承
最后一点,我们来讨论父类与子类中字段顺序的问题。
由于数据类仍是普通类,继承照常工作:
但是,由于父类(
ImmutableWorkoutSession)的最后一个字段有默认值,子类中的所有新增字段都必须有默认值。例如,下面这样是不允许的:
数据类的不足与进阶资源
自 Python 3.7 起(一开始就已经很不错了),数据类一直在不断改进,覆盖了许多需要编写类的用例。但在以下场景中,它们可能不太合适:
__init__方法__new__方法以及更多情况,详见 这个很棒的 Reddit 讨论帖。如果您想了解引入数据类的更详细原因、以及它们为何不能直接替代常规类定义,请阅读 PEP 557。
如果您对面向对象编程感兴趣,这里有一门课程可继续深入:
从根本上说,数据类是一种更精巧的数据容器,让数据的存取更高效。不过,Python 还有许多其他数据结构在这方面同样表现不俗。例如,您可以在 Data Types for Data Science 课程的最后一章学习 counters、
defaultdict和namedtuple等内容。