asdict() 把数据类(dataclass)转成字典,一路递归到底,边走边拷贝。多数时候你要的正是这样,偶尔却完全不是。
你手上有一个 dataclass,却需要一个字典:要序列化,要写日志,或者要交给一个只认字典的库。转换的办法有三种,行为各不相同,而且差别很关键。
大多数时候,答案是 asdict()
from dataclasses import dataclass, asdict
@dataclass
class Point:
x: int
y: int
p = Point(3, 4)
print(asdict(p))
输出:
{'x': 3, 'y': 4}
简单情况下,要用的 API 就这么多。往下深一层,事情才有意思。
它会递归,这正是重点
asdict 不会停在顶层。它会深入嵌套的数据类,以及装着数据类的列表、元组和字典。
from dataclasses import dataclass, asdict, field
@dataclass
class Address:
city: str
country: str
@dataclass
class Person:
name: str
address: Address
tags: list = field(default_factory=list)
p = Person('ada', Address('london', 'uk'), ['engineer', 'mathematician'])
print(asdict(p))
输出:
{'name': 'ada', 'address': {'city': 'london', 'country': 'uk'}, 'tags': ['engineer', 'mathematician']}
嵌套的 Address 变成了嵌套的字典。输出里已经没有任何数据类,序列化工具要的正是这个。
asdict() 会拷贝,vars() 不会
这个差别最容易引出 bug,而且很难察觉,因为两者得到的字典看上去都没问题。
from dataclasses import dataclass, asdict, field
@dataclass
class Team:
name: str
members: list = field(default_factory=list)
t = Team('core', ['ada', 'grace'])
copied = asdict(t)
shallow = vars(t)
copied['members'].append('MUTATED VIA asdict')
shallow['members'].append('MUTATED VIA vars')
print('original :', t.members)
print('asdict :', copied['members'])
print('vars :', shallow['members'])
输出:
original : ['ada', 'grace', 'MUTATED VIA vars']
asdict : ['ada', 'grace', 'MUTATED VIA asdict']
vars : ['ada', 'grace', 'MUTATED VIA vars']
看第一行。往 asdict 的结果里追加元素,原对象没受影响。往 vars 的结果里追加,原对象却变了,因为 vars(t) 返回的就是实例真正的 __dict__:不是它的副本,而是它本身。
asdict 在返回前会把每个值都深拷贝一遍。vars 给你的是一个活的引用。如果你正要把这个字典交给会修改它的代码,这个差别就是你的 bug 所在。
想自己掌控,就用 fields()
asdict 要么全转,要么不转。如果你想跳过某个字段、改个名字,或者不往下递归,就自己遍历字段。
from dataclasses import dataclass, fields, field
@dataclass
class User:
name: str
email: str
password: str = field(repr=False)
u = User('ada', 'ada@example.com', 'hunter2')
public = {f.name: getattr(u, f.name) for f in fields(u) if f.name != 'password'}
print(public)
print([f.name for f in fields(u)])
输出:
{'name': 'ada', 'email': 'ada@example.com'}
['name', 'email', 'password']
fields() 返回的是字段定义,所以字段定义里带的任何信息都能拿来过滤,包括你自己加的元数据。
把字段标记为永不导出
用 field(metadata=...) 更干净:在定义字段的地方说一次就够了,不用在别处另外维护一份字段名列表。
from dataclasses import dataclass, fields, field
@dataclass
class Account:
user: str
token: str = field(metadata={'private': True})
def public_dict(obj):
return {f.name: getattr(obj, f.name)
for f in fields(obj) if not f.metadata.get('private')}
a = Account('ada', 'secret-token-value')
print(public_dict(a))
输出:
{'user': 'ada'}
dict_factory 决定每一层怎么构建
asdict 可以接收一个工厂函数,参数是 (key, value) 对组成的列表。顶层对象和每个嵌套的数据类都会调用它,所以一个函数就能管住整棵树。
from dataclasses import dataclass, asdict
@dataclass
class Inner:
first_name: str
@dataclass
class Outer:
inner: Inner
is_active: bool
def camel(pairs):
def to_camel(s):
head, *rest = s.split('_')
return head + ''.join(w.capitalize() for w in rest)
return {to_camel(k): v for k, v in pairs}
print(asdict(Outer(Inner('ada'), True), dict_factory=camel))
输出:
{'inner': {'firstName': 'ada'}, 'isActive': True}
两层的键都由同一个函数改了名。
JSON,以及卡住它的类型
json.dumps(asdict(obj)) 一直好用,直到某个字段里装了 JSON 不知道怎么处理的东西。
import json
from dataclasses import dataclass, asdict
from datetime import date
from decimal import Decimal
@dataclass
class Invoice:
id: int
issued: date
total: Decimal
inv = Invoice(17, date(2026, 9, 9), Decimal('249.50'))
try:
print(json.dumps(asdict(inv)))
except TypeError as err:
print('TypeError:', err)
def encode(value):
if isinstance(value, Decimal):
return str(value)
if isinstance(value, date):
return value.isoformat()
raise TypeError(f'cannot serialize {type(value).__name__}')
print(json.dumps(asdict(inv), default=encode))
输出:
TypeError: Object of type date is not JSON serializable
{"id": 17, "issued": "2026-09-09", "total": "249.50"}
asdict 已经尽了本分:它生成了字典。它不转换叶子节点上的值,本来也不该转换。类型转换应该放在 default= 里;如果你想在构建字典时就完成转换,就放在 dict_factory 里。
注意 Decimal 变成了字符串,而不是浮点数。float(Decimal('249.50')) 会带来舍入误差,而用 Decimal 恰恰就是为了避免这种误差。
三个值得点名的错误
在热循环里调用 asdict。 它每次都会把整棵树深拷贝一遍。如果你只需要字段名,fields() 要便宜得多。
对非数据类调用它。 它会直接报错,不会去猜。
from dataclasses import asdict, is_dataclass
class Plain:
def __init__(self):
self.x = 1
try:
asdict(Plain())
except TypeError as err:
print('TypeError:', err)
print('is_dataclass:', is_dataclass(Plain()))
输出:
TypeError: asdict() should be called on dataclass instances
is_dataclass: False
对用了 slots 的类用 vars()。 设置了 slots=True 的数据类根本没有 __dict__,所以这条捷径不只是有风险,而是直接失败。
from dataclasses import dataclass, asdict
@dataclass(slots=True)
class Fast:
x: int
y: int
f = Fast(1, 2)
print('asdict works:', asdict(f))
try:
print(vars(f))
except TypeError as err:
print('TypeError:', err)
输出:
asdict works: {'x': 1, 'y': 2}
TypeError: vars() argument must have __dict__ attribute
asdict 读的是字段定义,不是 __dict__,所以照样能用。这又是一个把它当默认选择的理由。
记住这几点
asdict(obj)会递归处理嵌套的数据类、列表、元组和字典,并把每个值都深拷贝一遍。vars(obj)是浅层的,返回活的__dict__。改动拿到的结果就是改动对象本身;遇到用了 slots 的数据类,它还会直接失败。- 需要跳过、改名或提前停止时,用
fields(obj)。field(metadata=...)把这个决定记在字段本身旁边。 dict_factory作用于树的每一层,所以改键名只需要一个函数。asdict生成的是字典,不是 JSON。date和Decimal仍然需要default=,而且Decimal应该转成字符串,不要转成浮点数。