使用自定义编码器将复杂 Python 对象序列化为 JSON
Python 的 json 模块默认仅支持字典、列表、元组、字符串、整数、浮点数、布尔值和 None。其他类型会引发 TypeError,除非通过 default 参数或 JSONEncoder 子类进行拦截。为实现往返保真,解码侧必须提供匹配的 object_hook 来从标记字典或字符串重建原始类型。本文详细解释了如何处理复杂数字、日期时间、路径和用户定义类,并比较了函数与子类两种方法的适用场景。
本文内容
简明答案
Python 的 json 模块默认只能转换 dict、list、tuple、str、int、float、bool 和 None。任何其他类型都会引发 TypeError,除非你拦截它。拦截发生在 json.dumps 的 default 参数或通过 JSONEncoder 子类的 default 方法中。这两种方法都接收不支持的对象,并必须返回一个与 JSON 兼容的值,或者抛出 TypeError 以表示失败。为了实现往返保真,你还必须在解码侧提供一个匹配的 object_hook,该 hook 能够从标记字典或字符串重建原始类型。
为什么 JSON 无法序列化任意 Python 对象
json 模块定义了一个固定的转换表。Python 中的 dict 映射为 JSON 对象,list 和 tuple 映射为数组,str 映射为字符串,int 和 float 映射为数字,True 和 False 映射为它们的 JSON 字面量,None 映射为 null。复杂数、datetime 实例、pathlib.Path 对象和用户定义的类实例在此表中没有条目。当编码器遇到表外的值时,它会调用 default 钩子。如果未提供钩子,default 钩子将引发 TypeError。文档明确指出:编码器仅支持列出的类型,要扩展它,你必须继承 JSONEncoder 并实现 default 方法。
摘自 Python 文档:'为了扩展它以识别其他对象,请继承并实现一个 default() 方法,该方法对于 o 返回一个可序列化的对象(如果可能),否则应调用超类实现(以引发 TypeError)。'
import json
# This raises TypeError because complex has no default mapping
try:
json.dumps(1 + 2j)
except TypeError as e:
print(e)实用建议
在序列化自定义类时,始终嵌入一个类型判别符键(如 __type__),以便解码器能够区分你的标记字典与恰好具有相同字段名的普通字典。
通过 json.dumps 中的 default 参数进行序列化
default 参数接受一个可调用对象,该对象接收不支持的对象并返回一个 JSON 可序列化的值。返回值可以是 dict、list、string、number 或 boolean。如果可调用对象无法处理该对象,它必须引发 TypeError,以便同一文档中的其他对象仍可以通过其自己的规则或由链式 super 调用来编码。这种方法非常适合当你需要在单个调用站点进行一次一次性转换,而不想定义可重用的类时。
源文档展示了复杂数的规范示例:一个函数检查 isinstance(obj, complex),返回包含 real 和 imag 字段的标记字典,并对其他所有内容引发 TypeError。
import json
def custom_json(obj):
if isinstance(obj, complex):
return {"__complex__": True, "real": obj.real, "imag": obj.imag}
raise TypeError(f"Cannot serialize object of {type(obj)}")
print(json.dumps(1 + 2j, default=custom_json))通过 JSONEncoder 子类扩展行为
当相同的序列化逻辑必须在多个调用站点重用,或者当你需要将编码器级参数(如 indent 或 ensure_ascii)与自定义类型处理一起配置时,继承 JSONEncoder 是更清洁的选择。你覆盖 default 方法,并为未处理的类型调用 super().default(o),以便标准 TypeError 被引发,并带有库自己的消息。子类通过 cls 参数传递给 json.dumps 或 json.dump。
这种模式将编码策略与调用站点分离。任何导入你的编码器类的消费者都能获得一致的行为,而无需重复 isinstance 链。
import json
class SimpleEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, complex):
return {"__complex__": True, "real": o.real, "imag": o.imag}
return super().default(o)
print(json.dumps({"z": 3 + 4j}, cls=SimpleEncoder))选择 default 函数还是 JSONEncoder 子类
当转换特定于一次调用、逻辑简短且你不需要共享编码器配置时,请使用普通的 default 函数。该函数作为内联传递并在调用后消失。当逻辑必须重用,当你希望将自定义类型处理与非默认编码器设置(如 sort_keys 或自定义 separators 元组)结合使用时,或者当转换链超过两三个 isinstance 检查且平面函数变得难以阅读时,请使用 JSONEncoder 子类。
一个实用阈值:如果你发现自己将同一个 default 函数传递给 dumps 的地方超过两个,就将其提升为类。如果该类只覆盖 default 且不添加构造函数参数,则函数形式仍然可以接受。
import json
from datetime import datetime, timezone
class ProjectEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime):
return o.isoformat()
if isinstance(o, complex):
return {"__complex__": True, "real": o.real, "imag": o.imag}
return super().default(o)
payload = {"ts": datetime(2024, 1, 15, tzinfo=timezone.utc), "z": 1+1j}
print(json.dumps(payload, cls=ProjectEncoder, sort_keys=True))编码日期、路径和用户定义的类
datetime 对象没有 JSON 映射。常规表示是由 isoformat 方法生成的 ISO 8601 字符串。解码侧使用 datetime.fromisoformat 重建值。pathlib.Path 对象通过 str(path) 序列化为字符串形式;解码器用 Path() 将字符串包装回去。对于用户定义的类,推荐的模式是一个包含类型判别符键的标记字典,例如将 __type__ 设置为类名,加上重建所需的字段。
编码和解码之间的一致性至关重要。如果编码器发出 {"__type__": "Point", "x": 1, "y": 2},解码器的 object_hook 必须检查 __type__ 是否等于 "Point" 并返回相应的实例。如果没有判别符键,具有相同形状的普通字典将与你的自定义对象无法区分。
import json
from datetime import datetime, timezone
from pathlib import Path
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def encode(obj):
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, Path):
return str(obj)
if isinstance(obj, Point):
return {"__type__": "Point", "x": obj.x, "y": obj.y}
raise TypeError(f"Unsupported type {type(obj)}")
def decode(dct):
if dct.get("__type__") == "Point":
return Point(dct["x"], dct["y"])
return dct
p = Point(3, 4)
s = json.dumps(p, default=encode)
print(s)
restored = json.loads(s, object_hook=decode)
print(type(restored).__name__, restored.x, restored.y)影响输出的编码器参数
ensure_ascii 控制非 ASCII 字符是转义为 \uXXXX 序列还是原样发出。当你的自定义 default 返回包含非 ASCII 文本的字符串时,此设置会更改字节表示,但不更改逻辑内容。indent 添加空格以提高可读性,并将默认分隔符从 (',', ':') 更改为 (', ', ': ')。sort_keys 在字符串转换之前按字母顺序对字典键进行排序,这对于测试中的确定性输出很有用。separators 允许你通过传递 (',',':') 来生成紧凑输出。allow_nan 控制 NaN、Infinity 和 -Infinity 是编码为 JavaScript 字面量还是引发 ValueError。check_circular 检测容器中的引用循环并在启用时引发 ValueError;如果禁用,循环会导致 RecursionError。skipkeys 静默删除不是 str、int、float、bool 或 None 的字典键,而不是引发 TypeError。
来源摘录:'如果 check_circular 为 true(默认值),则在编码期间将检查列表、字典和自定义编码对象是否存在循环引用,以防止无限递归。'
import json
from datetime import datetime
class Enc(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime):
return o.isoformat()
return super().default(o)
print(json.dumps({"b": 1, "a": datetime(2024,1,1)}, cls=Enc, sort_keys=True, indent=2))错误处理和便携性限制
当 default 或编码器的 default 方法引发 TypeError 时,整个 dumps 调用失败;没有部分输出。这意味着树中任何地方的一个不支持对象都会中止整个序列化。设计你的 default 函数以引发带有命名 offending 类型的清晰消息的 TypeError,以便调试快速。
JSON 不是一个框架协议。对同一文件对象重复调用 json.dump 会产生连接文档,这些文档不是有效的 JSON。非字符串键在往返后变为字符串,因此 loads(dumps(x)) 可能不等于 x,如果 x 具有整数或元组键。非常大的整数和 decimal.Decimal 值可能会超过 IEEE 754 双精度消费者的精度。文档警告恶意 JSON 可能导致解码器消耗大量 CPU 和内存,因此在解析不受信任的数据时应限制输入大小。
来源摘录:'与 pickle 和 marshal 不同,JSON 不是一个框架协议,因此尝试使用相同的 fp 重复调用 dump() 序列化多个对象将导致无效的 JSON 文件。'
import json, io
buf = io.StringIO()
json.dump({"a": 1}, buf)
json.dump({"b": 2}, buf) # second call makes the file invalid
print(buf.getvalue()) # {"a": 1}{"b": 2} - not valid JSON结合复杂数和自定义类的实际示例
以下完整示例并排演示了这两种方法。一个独立的 default 函数在一个调用站点处理复杂数。一个 JSONEncoder 子类在另一个调用站点处理复杂数和自定义 Event 类,并结合 sort_keys 以实现确定性输出。解码侧使用 object_hook 从其标记字典重建这两种类型。
此示例在 Python 3.6 及更高版本上运行。它不需要第三方包。唯一的先决条件是熟悉 isinstance、字典和 json 模块的基本 API。
import json
class Event:
def __init__(self, name, ts, payload):
self.name = name
self.ts = ts
self.payload = payload
# Approach 1: standalone default function
def complex_default(obj):
if isinstance(obj, complex):
return {"__complex__": True, "real": obj.real, "imag": obj.imag}
raise TypeError(f"Cannot serialize {type(obj)}")
s1 = json.dumps(1 + 2j, default=complex_default)
print(s1)
# Approach 2: reusable JSONEncoder subclass
class AppEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, complex):
return {"__complex__": True, "real": o.real, "imag": o.imag}
if isinstance(o, Event):
return {"__type__": "Event", "name": o.name,
"ts": o.ts, "payload": o.payload}
return super().default(o)
s2 = json.dumps({"z": 3+4j, "ev": Event("click", 100, {"x": 1})},
cls=AppEncoder, sort_keys=True)
print(s2)
# Round-trip decode
def app_hook(dct):
if dct.get("__complex__"):
return complex(dct["real"], dct["imag"])
if dct.get("__type__") == "Event":
return Event(dct["name"], dct["ts"], dct["payload"])
return dct
restored = json.loads(s2, object_hook=app_hook)
print(type(restored["z"]).__name__, restored["ev"].name)检查清单
- json.dumps 不带 default 或 cls 会对 complex、datetime、Path 或任意类实例引发 TypeError
- default 函数必须对未处理的类型引发 TypeError;返回 None 会静默编码为 null
- JSONEncoder 子类必须对未处理的类型调用 super().default(o) 以保持标准错误消息
- 往返保真需要解码侧有一个匹配的 object_hook,该 hook 检查标记键
- 对同一文件对象重复调用 json.dump 会产生无效的拼接输出
- ensure_ascii、indent、sort_keys 和 separators 影响表示但不影响 default 产生的逻辑内容
- allow_nan=False 将 NaN 和 Infinity 编码转换为 ValueError
- check_circular=True 检测自定义编码对象中的循环并引发 ValueError
适用范围
本文仅涵盖 CPython 3.6 及更高版本的 standard library json 模块。它不涉及第三方序列化程序,如 orjson、ujson 或 rapidjson,它们有自己的扩展机制。此处讨论的往返保证完全取决于你的编码标记和解码钩子之间的一致性;库本身不提供类型注册表或架构验证。非常大的整数和 decimal.Decimal 值可能会在解析为 IEEE 754 双精度的 JSON 消费者中丢失精度。不受信任 JSON 的输入大小限制是应用程序的责任,而不是模块的责任。