33. 用于简单结构化数据的数据类
在第 30 章中,我们学习了如何创建类来定义我们自己的类型。我们编写了 __init__ 方法来初始化实例,__repr__ 方法来显示它们,以及 __eq__ 方法来比较它们。虽然这种方法完全可行,但它需要编写大量重复代码,尤其是当一个类主要用于存储数据时。
Python 的数据类(data class)提供了一种更干净、更简洁的方式来创建主要作为数据容器的类。通过使用 @dataclass 装饰器(decorator),Python 会根据你定义的类属性自动生成常用方法,例如 __init__、__repr__ 和 __eq__。这减少了样板代码,让你的意图更清晰。
33.1) 什么是数据类以及何时使用它们
数据类(data class)是一种主要用于存储数据值的类。你无需手动编写初始化和比较方法,只需定义你的类应该具备的属性,Python 就会自动生成必要的方法。
为什么数据类很重要
考虑一个用于表示书籍的普通类:
class Book:
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, year={self.year})"
def __eq__(self, other):
if not isinstance(other, Book):
return False
return (self.title == other.title and
self.author == other.author and
self.year == other.year)
book1 = Book("1984", "George Orwell", 1949)
print(book1) # Output: Book(title='1984', author='George Orwell', year=1949)
book2 = Book("1984", "George Orwell", 1949)
print(book1 == book2) # Output: True这能工作,但请注意,我们仅仅为了存储三条信息就写了这么多代码。__init__、__repr__ 和 __eq__ 方法遵循可预测的模式——它们只是处理我们定义的属性。
数据类消除了这种重复。它们在以下场景尤其有用:
- 你的类主要用于存储数据,而不是实现复杂行为
- 你需要标准方法,例如初始化、字符串表示、以及相等性比较
- 你希望代码更清晰、更易维护,并且样板代码更少
- 你在创建配置对象、数据传输对象或简单记录
数据类不会取代普通类——它们是对普通类的补充。当你需要自定义初始化逻辑、复杂方法或继承层次结构时,使用普通类。当你主要需要一个用于相关数据的结构化容器时,使用数据类。
数据类与普通类的关系
数据类仍然是普通的 Python 类。它们支持我们在第 30-32 章学到的所有特性:方法、属性、继承以及特殊方法。@dataclass 装饰器只是自动化创建常用方法,让你不必再编写重复代码。
33.2) 使用 @dataclass 创建数据类
要创建数据类,你需要从 dataclasses 模块导入 dataclass 装饰器并将其应用到类定义上。在类内部,你要定义带有类型注解(type annotation)的类属性,以说明该类应该持有的数据。
基本的数据类语法
from dataclasses import dataclass
@dataclass
class Student:
name: str
student_id: int
gpa: float
# Create instances
alice = Student("Alice Johnson", 12345, 3.8)
bob = Student("Bob Smith", 12346, 3.5)
print(alice) # Output: Student(name='Alice Johnson', student_id=12345, gpa=3.8)
print(bob) # Output: Student(name='Bob Smith', student_id=12346, gpa=3.5)让我们拆解一下 @dataclass 装饰器做了什么:
-
@dataclass:应用该装饰器会让 Python 自动为你编写__init__、__repr__和__eq__方法 -
自动
__init__:Python 会创建一个初始化方法,按定义的顺序接受这三个参数,并将它们赋给实例属性 -
自动
__repr__:Python 会创建一个字符串表示形式,显示类名以及所有属性值 -
自动
__eq__:Python 会创建一个相等性比较方法,比较所有属性 -
将类型注解转换为实例属性:在普通类中,在类体里写
name: str会创建一个类属性。但@dataclass装饰器会改变这种行为——它会使用这些类型注解来定义实例属性。每个实例都会拥有自己的name、student_id和gpa属性。
与普通类相比,关键差异在于:
# Regular class - these are class attributes (shared by all instances)
class RegularStudent:
name: str
student_id: int
# Data class - these become instance attributes (each instance has its own)
@dataclass
class DataStudent:
name: str
student_id: int理解数据类中的类型注解
在数据类中,类型注解定义属性并记录它们期望的类型:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
# 使用文档中记录的正确类型
laptop = Product("Laptop", 999.99, True)
print(laptop) # Output: Product(name='Laptop', price=999.99, in_stock=True)
# Python 不会强制类型检查 —— 这段代码可以正常运行
macbook = Product("Macbook", "expensive", True)
print(macbook) # Output: Product(name='Macbook', price='expensive', in_stock=True)
# 但使用错误类型会在之后造成问题:
discounted = laptop.price * 0.9 # Works: 899.991
discounted = macbook.price * 0.9 # TypeError: can't multiply sequence by non-int of type 'float'
tax = laptop.price + 50 # Works: 1049.99
tax = macbook.price + 50 # TypeError: can only concatenate str (not "int") to str当你创建数据类实例时,Python 不会阻止你传入错误类型。类型注解主要是文档——它们告诉其他程序员(以及像 mypy 这样的类型检查工具)你期望的类型,但 Python 不会在运行时强制执行。这与 Python 的动态类型哲学一致。
不过,遵循类型注解能让你的代码更可预测,也更容易调试。当你使用了错误类型,错误会在之后你尝试使用这些数据时才出现,使得 bug 更难追踪。类型检查工具可以在你运行代码之前捕获这些不匹配,帮助你尽早发现问题。
访问与修改属性
数据类实例的行为与普通类实例完全一样。你使用点号语法来访问和修改属性:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
position: str
salary: float
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
# Access attributes
print(emp.name) # Output: Sarah Chen
print(emp.position) # Output: Software Engineer
# Modify attributes
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
print(emp) # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)数据类默认是可变的(mutable)——你可以在创建后更改其属性。这与元组或命名元组不同,后者是不可变的。如果你需要不可变性,你可以用 frozen=True 来配置数据类(我们会在 33.4 节中探讨)。
33.3) 生成的方法:__init__、__repr__ 和 __eq__
@dataclass 装饰器会自动生成三个关键方法。理解这些方法的作用,能帮助你更有效地使用数据类,并知道何时需要自定义它们。
生成的 __init__ 方法
__init__ 方法使用提供的值来初始化新实例。Python 会根据你属性定义的顺序生成它:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
# The generated __init__ accepts width and height in that order
rect = Rectangle(10.5, 5.0)
print(rect.width) # Output: 10.5
print(rect.height) # Output: 5.0
# You can also use keyword arguments
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width) # Output: 12.0
print(rect2.height) # Output: 8.0生成的 __init__ 等价于手写:
def __init__(self, width: float, height: float):
self.width = width
self.height = height这种自动生成能帮你省去重复的初始化代码,特别是对于拥有许多属性的类。
生成的 __repr__ 方法
__repr__ 方法提供实例的字符串表示,会展示所有属性值。这对调试和日志记录非常有价值:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str
point = Point(3.5, 7.2, "A")
print(point) # Output: Point(x=3.5, y=7.2, label='A')
print(repr(point)) # Output: Point(x=3.5, y=7.2, label='A')生成的 __repr__ 遵循一种约定:显示类名以及所有属性,并以一种可以用于重新创建对象的格式呈现。这比没有 __repr__ 时得到的默认表示形式要有用得多:<__main__.Point object at 0x...>。
生成的 __eq__ 方法
__eq__ 方法支持实例之间的相等性比较。如果两个数据类实例所有对应属性都相等,那么它们就被认为相等:
from dataclasses import dataclass
@dataclass
class Color:
red: int
green: int
blue: int
color1 = Color(255, 0, 0)
color2 = Color(255, 0, 0)
color3 = Color(0, 255, 0)
print(color1 == color2) # Output: True (same RGB values)
print(color1 == color3) # Output: False (different RGB values)
print(color1 is color2) # Output: False (different objects in memory)这种自动相等性比较基于值相等(value equality),而不是标识相等。尽管 color1 和 color2 在内存中是不同对象(如 is 所示),但由于它们的属性匹配,因此被认为相等。
生成的 __eq__ 方法会按属性定义顺序比较属性:
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
year: int
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("1984", "George Orwell", 1949)
book3 = Book("Animal Farm", "George Orwell", 1945)
print(book1 == book2) # Output: True (all attributes match)
print(book1 == book3) # Output: False (title and year differ)
# Comparison with non-Book objects returns False
print(book1 == "1984") # Output: False
print(book1 == None) # Output: False将生成的方法与手动实现进行比较
为了体会数据类提供了什么,让我们把数据类版本与手动实现版本对比一下:
from dataclasses import dataclass
# Data class version (concise)
@dataclass
class PersonData:
first_name: str
last_name: str
age: int
# Equivalent manual version (verbose)
class PersonManual:
def __init__(self, first_name: str, last_name: str, age: int):
self.first_name = first_name
self.last_name = last_name
self.age = age
def __repr__(self):
return f"PersonManual(first_name={self.first_name!r}, last_name={self.last_name!r}, age={self.age})"
def __eq__(self, other):
if not isinstance(other, PersonManual):
return False
return (self.first_name == other.first_name and
self.last_name == other.last_name and
self.age == other.age)
# Both work identically
p1 = PersonData("Alice", "Johnson", 30)
p2 = PersonManual("Alice", "Johnson", 30)
print(p1) # Output: PersonData(first_name='Alice', last_name='Johnson', age=30)
print(p2) # Output: PersonManual(first_name='Alice', last_name='Johnson', age=30)数据类版本用显著更少的代码实现了相同功能。这种样板代码的减少让你的代码更容易阅读、维护和修改。
为数据类添加自定义方法
数据类可以像普通类一样拥有自定义方法。@dataclass 装饰器只会生成初始化、表示和相等性方法——你可以自由添加任何其他功能:
from dataclasses import dataclass
@dataclass
class Temperature:
celsius: float
def to_fahrenheit(self):
"""Convert temperature to Fahrenheit."""
return (self.celsius * 9/5) + 32
def to_kelvin(self):
"""Convert temperature to Kelvin."""
return self.celsius + 273.15
def is_freezing(self):
"""Check if temperature is at or below freezing point."""
return self.celsius <= 0
temp = Temperature(25.0)
print(temp) # Output: Temperature(celsius=25.0)
print(f"{temp.celsius}°C = {temp.to_fahrenheit()}°F") # Output: 25.0°C = 77.0°F
print(f"Kelvin: {temp.to_kelvin()}") # Output: Kelvin: 298.15
print(f"Freezing: {temp.is_freezing()}") # Output: Freezing: False
cold_temp = Temperature(-5.0)
print(f"Freezing: {cold_temp.is_freezing()}") # Output: Freezing: True数据类负责处理重复部分(初始化、表示以及比较),同时允许你为特定需求添加自定义方法,如上面温度转换方法所示。
33.4) 默认值与字段选项
数据类支持为属性提供默认值,这使你可以在不指定每个参数的情况下创建实例。你也可以使用 field() 函数配置更高级的行为,例如将属性从比较中排除,或控制它们在字符串表示中如何出现。
提供默认值
你可以直接在类定义中为属性赋默认值。带默认值的属性必须放在不带默认值的属性之后:
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # 默认值
role: str = "user" # 默认值
# Create instances with and without defaults
user1 = User("alice", "alice@example.com")
print(user1) # Output: User(username='alice', email='alice@example.com', is_active=True, role='user')
user2 = User("bob", "bob@example.com", False, "admin")
print(user2) # Output: User(username='bob', email='bob@example.com', is_active=False, role='admin')
# Use keyword arguments to override specific defaults
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3) # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')这种顺序规则(无默认值的属性在前,带默认值的属性在后)避免了生成的 __init__ 方法出现歧义。这与我们在第 20 章学到的带默认值的函数参数要求相同。
可变默认值以及为什么不允许
数据类会保护你避免一种常见的可变默认值错误。如果你尝试直接使用列表或字典等可变对象作为默认值,你会得到一个错误:
from dataclasses import dataclass
# This will raise an error
@dataclass
class ShoppingCart:
customer: str
items: list = [] # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory这个错误能防止我们在第 20 章看到的同类问题:所有实例会共享同一个可变对象。
使用 field() 搭配 default_factory 处理可变默认值
解决方案是使用 field() 函数并搭配 default_factory,为每个实例创建一个新的默认值:
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
items: list = field(default_factory=list) # 正确:每个实例都有自己的列表
# Now each instance gets its own list
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items) # Output: ['Book']
cart2 = ShoppingCart("Bob")
print(cart2.items) # Output: [] - Bob has an empty list
cart2.items.append("Laptop")
print(cart1.items) # Output: ['Book'] - Alice's cart unchanged
print(cart2.items) # Output: ['Laptop'] - Bob's cart independentdefault_factory 参数接收一个函数(例如 list、dict 或 set),该函数会在你创建实例且未提供该属性时被调用,从而生成新的默认值。例如,default_factory=list 表示 Python 会调用 list(),为每个实例创建一个新的空列表。
将字段排除在比较之外
有时你希望某些属性不参与相等性比较。可以使用 field(compare=False):
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LogEntry:
message: str
level: str
timestamp: datetime = field(compare=False) # 不参与比较的时间戳
# Create two log entries with the same message but different times
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
# They're equal because timestamp is excluded from comparison
print(entry1 == entry2) # Output: True
# But they have different timestamps
print(entry1.timestamp) # Output: 2024-01-15 10:30:00
print(entry2.timestamp) # Output: 2024-01-15 10:35:00当你有元数据字段(例如时间戳、ID 或内部计数器)且它们不应影响两个实例是否被认为相等时,这会很有用。
将字段排除在表示之外
你也可以使用 field(repr=False) 将字段从字符串表示中排除:
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
email: str
password: str = field(repr=False) # 在 repr 中不显示密码
account = Account("alice", "alice@example.com", "secret123")
print(account) # Output: Account(username='alice', email='alice@example.com')
# Password is not shown, but it's still stored
print(account.password) # Output: secret123这对密码、API key 等敏感数据,或会让表示内容变得杂乱的大型数据结构尤其有用。
使用 frozen=True 让数据类不可变
默认情况下,数据类实例是可变的——你可以在创建后更改它们的属性。如果你想要不可变实例(像元组一样),可以使用 frozen=True:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
point = Point(3.0, 4.0)
print(point) # Output: Point(x=3.0, y=4.0)
# Attempting to modify raises an error
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Output: Error: cannot assign to field 'x'冻结的数据类在你想确保数据完整性,或想将实例用作字典键(因为字典键必须不可变)时很有用。当数据类被冻结时,Python 还会生成 __hash__ 方法,使实例可哈希:
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
# Frozen instances can be dictionary keys
locations = {
Coordinate(40.7128, -74.0060): "New York",
Coordinate(51.5074, -0.1278): "London",
Coordinate(35.6762, 139.6503): "Tokyo"
}
nyc = Coordinate(40.7128, -74.0060)
print(locations[nyc]) # Output: New York33.5) 使用 __post_init__ 进行自定义初始化
有时你需要在生成的 __init__ 方法运行后执行额外设置。__post_init__ 方法会在初始化后自动被调用,使你能够验证数据、计算派生属性或执行其他设置任务。
__post_init__ 的基本用法
__post_init__ 方法会在生成的 __init__ 设置完所有属性后被调用:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
area: float = 0.0 # 将在 __post_init__ 中计算
def __post_init__(self):
"""Calculate area after initialization."""
self.area = self.width * self.height
rect = Rectangle(5.0, 3.0)
print(rect) # Output: Rectangle(width=5.0, height=3.0, area=15.0)
print(f"Area: {rect.area}") # Output: Area: 15.0__post_init__ 方法可以访问在初始化期间设置的所有实例属性。这对于计算依赖多个属性的派生值很有用。
在 post_init 中验证数据
__post_init__ 的一个常见用途是验证提供的数据是否满足某些要求:
from dataclasses import dataclass
@dataclass
class BankAccount:
account_number: str
balance: float
def __post_init__(self):
"""Validate account data."""
if self.balance < 0:
raise ValueError("Balance cannot be negative")
# Valid account
account1 = BankAccount("ACC001", 1000.0)
print(account1) # Output: BankAccount(account_number='ACC001', balance=1000.0)
# Invalid account - negative balance
try:
account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Balance cannot be negative这种验证确保实例始终处于有效状态。如果数据不满足要求,实例就不会被创建,从而防止无效对象在你的程序中存在。
将 post_init 与 field(init=False) 一起使用
有时你希望某个属性在 __post_init__ 中计算,但它不应作为 __init__ 的参数。可以使用 field(init=False):
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False) # 不作为 __init__ 的参数
circumference: float = field(init=False)
def __post_init__(self):
"""Compute area and circumference from radius."""
self.area = math.pi * self.radius ** 2
self.circumference = 2 * math.pi * self.radius
# Only radius is required during initialization
circle = Circle(5.0)
print(circle) # Output: Circle(radius=5.0, area=78.53981633974483, circumference=31.41592653589793)
print(f"Area: {circle.area:.2f}") # Output: Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Output: Circumference: 31.42当你有一些属性总是由其他属性计算得到,并且在初始化时绝不应该被直接设置时,这种模式非常有用。
数据类代表了一项现代 Python 特性:在保持类的全部能力的同时减少样板代码。当你处理结构化数据时,它们对于创建干净、可读的代码尤其有价值。随着你继续学习 Python,你会发现数据类会成为许多以数据为中心的编程任务的自然选择,并与第 30-32 章中学习的普通类相辅相成。