第9章 9.4 dataclass 与 attrs:让数据类自己搞定繁琐代码

🎯 开场 3 分钟:为什么要学这个?

上一章我们学会了用 with 语句管理资源,现在你写代码时已经能优雅地处理「打开→关闭」「加锁→释放」这类配对操作了。

但你有没有遇到过这种烦心事:

痛点1:你要写一个「学生信息」类,光是为了存储姓名、年龄、成绩,你得手动写 __init____repr____eq__ 一堆模板代码,核心业务还没开始写呢,重复劳动先做半小时。

痛点2:你写了 5 个类,发现每个类都有类似的「打印调试信息」需求,改一行逻辑要改 5 个地方,心累。

痛点3:你想给类加个默认值、想校验输入、想让某个字段不可变——手写的话代码又臭又长。

学完这一章,上面三个痛点将统统消失。你会学到两把利器:dataclass(Python 内置,轻量)和 attrs(第三方库,功能更全)。它们的核心思想是一样的:用装饰器自动生成那些重复的模板代码,让你专注写真正的业务逻辑。


🧱 基础 25 分钟:核心概念

什么是 dataclass?—— 包装盒的自动化工厂

想象你开了一家快递站,每天要打包 1000 个包裹。每个包裹都要经历:装货 → 贴标签 → 封箱 → 记录物流。如果这些步骤全靠人工,一模一样的流程要重复 1000 遍。

dataclass 就是这个自动化工厂——你只要告诉它「我要一个装水果的盒子」,它自动帮你把装货、贴标签、封箱的流程跑完,你只需要往里面放水果就行。

生活类比:dataclass = 给你一个自带说明书、自带比较功能、自带打印格式的数据盒子

为什么要用 dataclass?

你可能会问:我用普通类也能写啊,干嘛非要用 dataclass?

看这个对比:

# 普通类写法 - 要手写一堆
class Student:
def __init__(self, name: str, age: int, score: float):
    self.name = name
    self.age = age
    self.score = score

def __repr__(self):
    return f"Student(name={self.name}, age={self.age}, score={self.score})"

def __eq__(self, other):
    if not isinstance(other, Student):
        return False
    return self.name == other.name and self.age == other.age and self.score == other.score

# dataclass 写法 - 5 行搞定
from dataclasses import dataclass

@dataclass
class Student:
name: str
age: int
score: float

两段代码功能几乎一样,但第二段你只需要专注「name、age、score 是啥」,__init____repr____eq__ 全都自动生成了。

第一个 dataclass:正确打开方式

来,上手跑一个:

from dataclasses import dataclass

@dataclass
class Book:
title: str
author: str
price: float
in_stock: bool = True  # 有默认值,默认在库

# 创建实例
book1 = Book("活着", "余华", 39.9)
book2 = Book("活着", "余华", 39.9)

print(book1)
print(book1 == book2)

输出

Book(title='活着', author='余华', price=39.9, in_stock=True)
True

解释
- @dataclass 装饰器自动生成了 __init__(构造)、__repr__(打印)、__eq__(比较)
- in_stock: bool = True 这个参数有默认值,创建时可以不传
- 两个字段完全相同的 Book 实例,== 比较返回 True

这就是 dataclass 的核心能力——写更少的代码,做更多的事

配图1 - 配图1

dataclass 进阶:field 和默认值工厂

有时候你不想所有实例共享同一个可变对象(比如列表),这时候要用 field(default_factory=...)

from dataclasses import dataclass, field

@dataclass
class ShoppingCart:
owner: str
items: list = field(default_factory=list)  # 每个实例独立的空列表
total: float = 0.0

# 测试
cart1 = ShoppingCart("小明")
cart2 = ShoppingCart("小红")

cart1.items.append("苹果")
print(f"cart1: {cart1}")
print(f"cart2: {cart2}")

输出

cart1: ShoppingCart(owner='小明', items=['苹果'], total=0.0)
cart2: ShoppingCart(owner='小红', items=[], total=0.0)

解释
- 如果用 items: list = [](直接赋空列表),所有实例会共享同一个列表(坑!)
- 用 field(default_factory=list) 确保每个实例有自己独立的空列表

dataclass 继承:子类还能自动继承

from dataclasses import dataclass, field

@dataclass
class Animal:
name: str
age: int

@dataclass
class Dog(Animal):
breed: str = "中华田园犬"
tricks: list = field(default_factory=list)

# 测试
dog = Dog(name="旺财", age=3, breed="金毛", tricks=["坐下", "握手"])
print(dog)

输出

Dog(name='旺财', age=3, breed='金毛', tricks=['坐下', '握手'])

解释:子类继承父类,子类的字段和父类的字段会合并生成一个新的 __init__

对比 namedtuple:谁是更好的选择?

Python 还有一个叫 namedtuple 的东西,长得和 dataclass 有点像:

from collections import namedtuple

# namedtuple 写法
Point = namedtuple('Point', ['x', 'y'])
p1 = Point(1, 2)
print(p1.x, p1.y, p1)

# dataclass 写法
from dataclasses import dataclass

@dataclass
class Point:
x: int
y: int

p2 = Point(1, 2)
print(p2.x, p2.y, p2)

两者都能用 .x 访问属性,都能打印。但区别在哪?

特性 namedtuple dataclass
可变字段 ❌ 不支持 ✅ 支持
默认值 ✅ 支持 ✅ 支持
方法自定义 ❌ 只能继承 ✅ 可以自己写方法
类型提示 ❌ 没有 ✅ 有
继承 ❌ 不支持 ✅ 支持

一句话总结namedtuple 是轻量级不可变数据容器,dataclass 是功能更全的数据类。

什么是 attrs?—— dataclass 的功能加强版

如果说 dataclass 是基础款洗衣机,那 attrs 就是顶配版——烘干、杀菌、静音全给你加上。

attrs 是个第三方库,需要先安装:

pip install attrs

核心用法:

import attr

@attr.s
class Student:
name = attr.ib()
age = attr.ib()
score = attr.ib(default=0.0)

stu = Student("张三", 20)
print(stu)

输出

Student(name='张三', age=20, score=0.0)

attrsdataclass 多了什么?
- 更灵活的验证器(@validators
- 更细致的转换器(@converters
- 完整的 slots 支持(性能更好,内存占用更小)

配图2 - 配图2


🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):学生成绩管理器

跟着抄就能跑,学会 dataclass 的基本操作:

from dataclasses import dataclass
from typing import Optional

@dataclass
class StudentRecord:
name: str
chinese: float
math: float
english: float
student_id: str = "未分配"

@property
def average(self) -> float:
    return (self.chinese + self.math + self.english) / 3

@property
def total(self) -> float:
    return self.chinese + self.math + self.english

def grade(self) -> str:
    avg = self.average
    if avg >= 90:
        return "A"
    elif avg >= 80:
        return "B"
    elif avg >= 70:
        return "C"
    elif avg >= 60:
        return "D"
    else:
        return "F"

# 创建两条记录
stu1 = StudentRecord("小明", 85.0, 92.0, 88.0, "S001")
stu2 = StudentRecord("小红", 76.0, 81.0, 69.0, "S002")

print(f"{stu1.name} - 总分: {stu1.total}, 平均: {stu1.average:.1f}, 等级: {stu1.grade()}")
print(f"{stu2.name} - 总分: {stu2.total}, 平均: {stu2.average:.1f}, 等级: {stu2.grade()}")

# 比一比谁成绩更好
print(f"两人成绩相同吗?{stu1 == stu2}")

预期输出

小明 - 总分: 265.0, 平均: 88.3, 等级: B
小红 - 总分: 226.0, 平均: 75.3, 等级: C
两人成绩相同吗?False

解释:这个例子展示了 dataclass 可以加方法(averagetotalgrade),还能自动比较相等性。


项目 2(15 分钟):从 CSV 文件读取商品数据并统计

现在你有一个 products.csv 文件,内容如下:

name,price,category,stock
iPhone 15,6999,手机,100
MacBook Pro,14999,电脑,50
AirPods,1299,配件,200
iPad Air,4799,平板,80
Apple Watch,2999,配件,150

我们要用 dataclass 建模商品,然后用它统计各种信息:

from dataclasses import dataclass, field
from typing import List
import csv

@dataclass
class Product:
name: str
price: float
category: str
stock: int

@property
def total_value(self) -> float:
    return self.price * self.stock

def load_products(filepath: str) -> List[Product]:
products = []
with open(filepath, 'r', encoding='utf-8') as f:
    reader = csv.DictReader(f)
    for row in reader:
        products.append(Product(
            name=row['name'],
            price=float(row['price']),
            category=row['category'],
            stock=int(row['stock'])
        ))
return products

# 加载数据
products = load_products('products.csv')

# 统计各类商品库存总值
from collections import defaultdict
category_value = defaultdict(float)
for p in products:
category_value[p.category] += p.total_value

print("=== 各品类库存总值 ===")
for cat, value in category_value.items():
print(f"{cat}: ¥{value:,.2f}")

# 找出库存最少的商品
min_stock_product = min(products, key=lambda p: p.stock)
print(f"\n库存最少: {min_stock_product.name}, 仅剩 {min_stock_product.stock} 件")

# 计算所有商品的总库存价值
total_inventory_value = sum(p.total_value for p in products)
print(f"总库存价值: ¥{total_inventory_value:,.2f}")

预期输出(假设 products.csv 在当前目录):

=== 各品类库存总值 ===
手机: ¥699,900.00
电脑: ¥749,950.00
配件: ¥454,350.00
平板: ¥383,920.00

库存最少: MacBook Pro, 仅剩 50 件
总库存价值: ¥2,288,120.00

解释:用 dataclass 定义数据结构后,读 CSV、处理数据、统计分析都能优雅地链在一起。


项目 3(15 分钟):做一个命令行待办清单小工具

组合项目 1 和项目 2 的能力,做一个真实的命令行工具:

from dataclasses import dataclass, field
from typing import List
import json
import os

TASK_FILE = "tasks.json"

@dataclass
class Task:
title: str
priority: int  # 1-5,数字越大越重要
done: bool = False
created_at: str = ""

def mark_done(self):
    self.done = True

def __str__(self):
    status = "✅" if self.done else "⬜"
    return f"{status} [{self.priority}] {self.title}"

@dataclass  
class TodoList:
name: str
tasks: List[Task] = field(default_factory=list)

def add_task(self, title: str, priority: int = 3):
    task = Task(title=title, priority=priority)
    self.tasks.append(task)
    print(f"已添加: {task}")

def complete_task(self, index: int) -> bool:
    if 0 <= index < len(self.tasks):
        self.tasks[index].mark_done()
        return True
    return False

def show_pending(self):
    pending = [t for t in self.tasks if not t.done]
    if not pending:
        print("🎉 所有任务都完成了!")
        return
    # 按优先级排序
    pending.sort(key=lambda t: t.priority, reverse=True)
    print(f"=== {self.name} 待办事项 ({len(pending)} 项) ===")
    for i, task in enumerate(pending):
        print(f"  {i+1}. {task}")

def save(self):
    data = {
        "name": self.name,
        "tasks": [
            {"title": t.title, "priority": t.priority, "done": t.done}
            for t in self.tasks
        ]
    }
    with open(TASK_FILE, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False)
    print(f"已保存到 {TASK_FILE}")

@classmethod
def load(cls) -> 'TodoList':
    if not os.path.exists(TASK_FILE):
        return TodoList(name="我的待办")
    with open(TASK_FILE, 'r', encoding='utf-8') as f:
        data = json.load(f)
    tasks = [Task(**t) for t in data["tasks"]]
    return cls(name=data["name"], tasks=tasks)

# 演示用法
if __name__ == "__main__":
todo = TodoList.load()

# 添加几个任务
todo.add_task("写完 Python 作业", priority=5)
todo.add_task("整理房间", priority=2)
todo.add_task("给妈妈打电话", priority=4)

# 展示待办
todo.show_pending()

# 完成一个任务
todo.complete_task(0)
todo.show_pending()

# 保存
todo.save()

预期输出

已添加: ⬜ [5] 写完 Python 作业
已添加: ⬜ [2] 整理房间
已添加: ⬜ [4] 给妈妈打电话
=== 我的待办事项 (3 项) ===
1. ⬜ [5] 写完 Python 作业
2. ⬜ [4] 给妈妈打电话
3. ⬜ [2] 整理房间
=== 我的待办事项 (2 项) ===
1. ⬜ [4] 给妈妈打电话
2. ⬜ [2] 整理房间
已保存到 tasks.json

解释:这个工具用 dataclass 优雅地组织了任务数据,还支持持久化存储(JSON 文件),可以当作你的日常小助手。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:可变默认值要小心

# ❌ 错误写法 - 所有实例共享同一个列表
@dataclass
class Wrong:
items: list = []

# ✅ 正确写法 - 每个实例独立
@dataclass
class Right:
items: list = field(default_factory=list)

原因:dataclass 的默认值在类定义时创建,如果用可变对象,所有实例会共享它。

坑 2:字段顺序影响构造函数

# ❌ 错误 - 默认值字段不能在无默认值字段前面
@dataclass
class Wrong:
name: str = "匿名"  # 有默认值
age: int            # 没有默认值,错误!

# ✅ 正确 - 无默认值字段在前,有默认值的在后
@dataclass
class Right:
age: int
name: str = "匿名"

坑 3:dataclass 生成的方法可能被你的方法覆盖

@dataclass
class MyClass:
x: int

# ❌ 你自己定义了 __repr__,会覆盖 dataclass 生成的
def __repr__(self):
    return f"<MyClass {self.x}>"

# ✅ 如果你想扩展,可以调用 dataclass 生成的
def detailed_repr(self):
    return f"详细: {self.__repr__()}"

坑 4:继承时字段顺序有讲究

@dataclass
class Base:
a: int

@dataclass
class Child(Base):
b: int
a: int = 10  # ❌ 子类不能重新定义父类已有字段

坑 5:dataclass 不做类型校验

@dataclass
class Person:
name: str
age: int

# ❌ 类型提示只是提示,Python 不会阻止你传入错误类型
p = Person(name=123, age="不是数字")  # 能运行,但逻辑上不对

如果你想加类型校验,可以用 attrs 的验证器:

import attr

@attr.s
class Person:
name = attr.ib(type=str)
age = attr.ib(type=int, validator=attr.validators.instance_of(int))

性能小优化:使用 slots=True

dataclass 默认用字典存储属性,如果你有大量实例,可以用 slots=True 节省内存:

@dataclass(slots=True)
class Optimized:
x: int
y: int

注意slots=True 的类不能使用可变默认值(field(default_factory=list) 仍可用)。

调试技巧:用 field(repr=False) 隐藏敏感字段

@dataclass
class User:
username: str
password: str = field(repr=False)  # 打印时不显示密码
email: str

user = User("张三", "123456", "zhangsan@example.com")
print(user)

输出User(username='张三', email='zhangsan@example.com')(密码被隐藏了)


✏️ 练习题 + 作业题

练习题(10 分钟)

练习 1(2 分钟):抄改基础题
- 输入:基于项目 1 的 StudentRecord,把小明换成你自己,填入三科成绩
- 预期输出:打印你的总分和平均分
- 提示:只改构造函数的参数值

练习 2(2 分钟):加个判断
- 输入:在练习 1 的基础上,加一个 if 判断,如果平均分 >= 90 打印「学霸!」
- 预期输出:成绩好的打印「学霸!」,否则打印「继续加油」
- 提示:用 if stu1.average >= 90: 判断

练习 3(2 分钟):用新数据处理
- 输入:创建一个 products.csv,包含你喜欢的 3 本书(name, price, category, stock)
- 预期输出:用项目 2 的代码统计你的「书库」库存总值
- 提示:CSV 的列名要和代码里的一致(name, price, category, stock)

练习 4(2 分钟):串起两个项目
- 输入:用项目 2 的 Product dataclass 设计一个「书架」的数据结构
- 预期输出:打印每本书的库存价值
- 提示:把 Producttotal_value 思路用过来

练习 5(2 分钟):报错分析
- 输入:运行下面这段「有问题」的代码,观察报错

from dataclasses import dataclass

@dataclass
class Broken:
name: str = "默认名"
value: int  # ❌ 没有默认值但在有默认值的后面

obj = Broken(100)  # 这行会报错
  • 预期输出:你能说出为什么报错,以及怎么修复
  • 提示:Python 报错信息里有答案

作业题(30 分钟 - 2 小时)

作业:做一个「课程表 + 成绩记录」小工具

  • 需求描述:做一个命令行工具,能管理你的课程表和每门课的成绩
  • 功能点
    1. 用 dataclass 定义 Course(课程)和 Grade(成绩)两个数据类
    2. 课程包含:课程名、老师、上课时间(周几 + 第几节)、教室
    3. 成绩包含:课程名、平时分、期末分、最终成绩(加权计算)
    4. 支持添加课程、添加成绩、查看所有课程、查看 GPA
  • 加分项
    1. 数据持久化到 JSON 文件(下次打开还在)
    2. 用 field(repr=False) 隐藏某个敏感字段
  • 验收标准:能跑起来 + 输入输出符合预期 + 代码有注释
  • 提交方式:评论区贴代码或 GitHub 链接

📚 总结 + 资源

本章核心 3 点

  1. @dataclass 是自动生成模板代码的装饰器:用了它,__init____repr____eq__ 全部自动搞定
  2. 可变默认值要用 field(default_factory=...):避免所有实例共享同一个列表/字典
  3. attrs 是 dataclass 的功能加强版:如果 dataclass 不够用,试试 attrs 的验证器和 slots 优化

延伸资源

互动钩子

你有没有被「要写一堆 __init____repr__」折磨过的经历?或者你现在在用什么方法解决这个问题?评论区聊聊,老粉优先回复!下一章我们要学「项目结构与代码组织」,学了之后你就能把这些散落的 dataclass 代码组织成一个真正的 Python 项目了。


往期章节回顾
- 第 9 章 9.3 上下文管理器 with 进阶 - 学会了 with 语句优雅管理资源
- 第 10 章 10.1 项目结构与代码组织 - 预告:如何把 dataclass 们放进一个正经项目里

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。