Python & AI Tutorials Logo
Python 编程

33. 用于简单结构化数据的数据类

在第 30 章中,我们学习了如何创建类来定义我们自己的类型。我们编写了 __init__ 方法来初始化实例,__repr__ 方法来显示它们,以及 __eq__ 方法来比较它们。虽然这种方法完全可行,但它需要编写大量重复代码,尤其是当一个类主要用于存储数据时。

Python 的数据类(data class)提供了一种更干净、更简洁的方式来创建主要作为数据容器的类。通过使用 @dataclass 装饰器(decorator),Python 会根据你定义的类属性自动生成常用方法,例如 __init____repr____eq__。这减少了样板代码,让你的意图更清晰。

33.1) 什么是数据类以及何时使用它们

数据类(data class)是一种主要用于存储数据值的类。你无需手动编写初始化和比较方法,只需定义你的类应该具备的属性,Python 就会自动生成必要的方法。

为什么数据类很重要

考虑一个用于表示书籍的普通类:

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 装饰器只是自动化创建常用方法,让你不必再编写重复代码。

普通类

手动 init

手动 repr

手动 eq

自定义方法

数据类

@dataclass 装饰器

自动生成的 init

自动生成的 repr

自动生成的 eq

自定义方法

33.2) 使用 @dataclass 创建数据类

要创建数据类,你需要从 dataclasses 模块导入 dataclass 装饰器并将其应用到类定义上。在类内部,你要定义带有类型注解(type annotation)类属性,以说明该类应该持有的数据。

基本的数据类语法

python
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 装饰器做了什么:

  1. @dataclass:应用该装饰器会让 Python 自动为你编写 __init____repr____eq__ 方法

  2. 自动 __init__:Python 会创建一个初始化方法,按定义的顺序接受这三个参数,并将它们赋给实例属性

  3. 自动 __repr__:Python 会创建一个字符串表示形式,显示类名以及所有属性值

  4. 自动 __eq__:Python 会创建一个相等性比较方法,比较所有属性

  5. 将类型注解转换为实例属性:在普通类中,在类体里写 name: str 会创建一个类属性。但 @dataclass 装饰器会改变这种行为——它会使用这些类型注解来定义实例属性。每个实例都会拥有自己的 namestudent_idgpa 属性。

与普通类相比,关键差异在于:

python
# 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

理解数据类中的类型注解

在数据类中,类型注解定义属性并记录它们期望的类型:

python
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 更难追踪。类型检查工具可以在你运行代码之前捕获这些不匹配,帮助你尽早发现问题。

访问与修改属性

数据类实例的行为与普通类实例完全一样。你使用点号语法来访问和修改属性:

python
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 会根据你属性定义的顺序生成它:

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__ 等价于手写:

python
def __init__(self, width: float, height: float):
    self.width = width
    self.height = height

这种自动生成能帮你省去重复的初始化代码,特别是对于拥有许多属性的类。

生成的 __repr__ 方法

__repr__ 方法提供实例的字符串表示,会展示所有属性值。这对调试和日志记录非常有价值:

python
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__ 方法支持实例之间的相等性比较。如果两个数据类实例所有对应属性都相等,那么它们就被认为相等:

python
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),而不是标识相等。尽管 color1color2 在内存中是不同对象(如 is 所示),但由于它们的属性匹配,因此被认为相等。

生成的 __eq__ 方法会按属性定义顺序比较属性:

python
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

将生成的方法与手动实现进行比较

为了体会数据类提供了什么,让我们把数据类版本与手动实现版本对比一下:

python
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 装饰器只会生成初始化、表示和相等性方法——你可以自由添加任何其他功能:

python
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() 函数配置更高级的行为,例如将属性从比较中排除,或控制它们在字符串表示中如何出现。

提供默认值

你可以直接在类定义中为属性赋默认值。带默认值的属性必须放在不带默认值的属性之后:

python
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 章学到的带默认值的函数参数要求相同。

可变默认值以及为什么不允许

数据类会保护你避免一种常见的可变默认值错误。如果你尝试直接使用列表或字典等可变对象作为默认值,你会得到一个错误:

python
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,为每个实例创建一个新的默认值:

python
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 independent

default_factory 参数接收一个函数(例如 listdictset),该函数会在你创建实例且未提供该属性时被调用,从而生成新的默认值。例如,default_factory=list 表示 Python 会调用 list(),为每个实例创建一个新的空列表。

将字段排除在比较之外

有时你希望某些属性不参与相等性比较。可以使用 field(compare=False)

python
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) 将字段从字符串表示中排除:

python
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

python
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__ 方法,使实例可哈希:

python
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 York

33.5) 使用 __post_init__ 进行自定义初始化

有时你需要在生成的 __init__ 方法运行后执行额外设置。__post_init__ 方法会在初始化后自动被调用,使你能够验证数据、计算派生属性或执行其他设置任务。

__post_init__ 的基本用法

__post_init__ 方法会在生成的 __init__ 设置完所有属性后被调用:

python
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__ 的一个常见用途是验证提供的数据是否满足某些要求:

python
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)

python
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 章中学习的普通类相辅相成。


© 2025. Primesoft Co., Ltd.
support@primesoft.ai