Blog

Python dataclass 转 dict:asdict()、嵌套与 JSON

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。dateDecimal 仍然需要 default=,而且 Decimal 应该转成字符串,不要转成浮点数。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。