为什么解析后的字典是相等的,而pickle后的字典不是?



我正在做一个聚合配置文件解析工具,希望它能支持.json,.yaml.toml文件。所以,我做了下面的测试:

example.json配置文件如下:

{
"DEFAULT":
{
"ServerAliveInterval": 45,
"Compression": true,
"CompressionLevel": 9,
"ForwardX11": true
},
"bitbucket.org":
{
"User": "hg"
},
"topsecret.server.com":
{
"Port": 50022,
"ForwardX11": false
},
"special":
{
"path":"C:\Users",
"escaped1":"nt",
"escaped2":"\n\t"
}  
}

example.yaml配置文件如下:

DEFAULT:
ServerAliveInterval: 45
Compression: yes
CompressionLevel: 9
ForwardX11: yes
bitbucket.org:
User: hg
topsecret.server.com:
Port: 50022
ForwardX11: no
special:
path: C:Users
escaped1: "nt"
escaped2: nt

example.toml配置文件如下:

[DEFAULT]
ServerAliveInterval = 45
Compression = true
CompressionLevel = 9
ForwardX11 = true
['bitbucket.org']
User = 'hg'
['topsecret.server.com']
Port = 50022
ForwardX11 = false
[special]
path = 'C:Users'
escaped1 = "nt"
escaped2 = 'nt'
那么,输出的测试代码如下:
import pickle,json,yaml
# TOML, see https://github.com/hukkin/tomli
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
path = "example.json"
with open(path) as file:
config1 = json.load(file)
assert isinstance(config1,dict)
pickled1 = pickle.dumps(config1)
path = "example.yaml"
with open(path, 'r', encoding='utf-8') as file:
config2 = yaml.safe_load(file)
assert isinstance(config2,dict)
pickled2 = pickle.dumps(config2)
path = "example.toml"
with open(path, 'rb') as file:
config3 = tomllib.load(file)
assert isinstance(config3,dict)
pickled3 = pickle.dumps(config3)
print(config1==config2) # True
print(config2==config3) # True
print(pickled1==pickled2) # False
print(pickled2==pickled3) # True

那么,我的问题是,既然解析的对象都是字典,并且这些字典彼此相等,为什么它们的pickled代码不相同,也就是说,为什么从json解析的字典的pickled代码与其他两个不同?

提前感谢。

差异是由于:

  1. json模块对具有相同值的对象属性执行记忆(它没有对它们进行实习,但是扫描器对象包含一个memodict,它使用它在单个解析运行中删除相同的属性字符串),,而yaml(它只是在每次看到相同的数据时生成一个新的str),和

  2. pickle忠实地复制确切的数据的结构,将对相同对象的后续引用替换为对第一次看到的反向引用(除其他原因外,这使得可以转储递归数据结构,例如lst = [],lst.append(lst),而无需无限递归,并在解pickle时忠实地再现它们)

问题#1在相等性测试中不可见(str与相同的数据比较相等,而不仅仅是内存中完全相同的对象)。但是当pickle第一次看到"ForwardX11"时,它插入对象的pickle形式,并发出一个pickle操作码,为该对象分配一个数字。如果再次看到那个精确的对象(相同的内存地址,而不仅仅是相同的值),它不会重新序列化它,而是发出一个更简单的操作码,它只是说"去找到与上次的数字相关的对象,并把它放在这里"。如果它是一个不同的对象,即使它有相同的值,它是新的,并被单独序列化(并分配另一个数字,以防再次看到新对象)。

简化您的代码来演示这个问题,您可以检查生成的pickle输出,看看这是如何发生的:

s = r'''{
"DEFAULT":
{
"ForwardX11": true
},
"FOO":
{
"ForwardX11": false
}
}'''
s2 = r'''DEFAULT:
ForwardX11: yes
FOO:
ForwardX11: no
'''
import io, json, yaml, pickle, pickletools
d1 = json.load(io.StringIO(s))
d2 = yaml.safe_load(io.StringIO(s2))
pickletools.dis(pickle.dumps(d1))
pickletools.dis(pickle.dumps(d2))

上网试试!

对于json解析的输入,该代码的输出是(#注释内联指出重要的事情),至少在Python 3.7上(默认的pickle协议和精确的pickle格式可以在不同的版本中更改):

0: x80 PROTO      3
2: }    EMPTY_DICT
3: q    BINPUT     0
5: (    MARK
6: X        BINUNICODE 'DEFAULT'
18: q        BINPUT     1
20: }        EMPTY_DICT
21: q        BINPUT     2
23: X        BINUNICODE 'ForwardX11'      # Serializes 'ForwardX11'
38: q        BINPUT     3                 # Assigns the serialized form the ID of 3
40: x88     NEWTRUE
41: s        SETITEM
42: X        BINUNICODE 'FOO'
50: q        BINPUT     4
52: }        EMPTY_DICT
53: q        BINPUT     5
55: h        BINGET     3                 # Looks up whatever object was assigned the ID of 3
57: x89     NEWFALSE
58: s        SETITEM
59: u        SETITEMS   (MARK at 5)
60: .    STOP
highest protocol among opcodes = 2

yaml加载数据的输出为:

0: x80 PROTO      3
2: }    EMPTY_DICT
3: q    BINPUT     0
5: (    MARK
6: X        BINUNICODE 'DEFAULT'
18: q        BINPUT     1
20: }        EMPTY_DICT
21: q        BINPUT     2
23: X        BINUNICODE 'ForwardX11'   # Serializes as before
38: q        BINPUT     3              # and assigns code 3 as before
40: x88     NEWTRUE
41: s        SETITEM
42: X        BINUNICODE 'FOO'
50: q        BINPUT     4
52: }        EMPTY_DICT
53: q        BINPUT     5
55: X        BINUNICODE 'ForwardX11'   # Doesn't see this 'ForwardX11' as being the exact same object, so reserializes
70: q        BINPUT     6              # and marks again, in case this copy is seen again
72: x89     NEWFALSE
73: s        SETITEM
74: u        SETITEMS   (MARK at 5)
75: .    STOP
highest protocol among opcodes = 2

print将每个字符串的id替换为类似的信息,例如,将pickletools行替换为:

for k in d1['DEFAULT']:
print(id(k))
for k in d1['FOO']:
print(id(k))
for k in d2['DEFAULT']:
print(id(k))
for k in d2['FOO']:
print(id(k))

将在d1中显示一致的'ForwardX11's,但在d2中显示不同的CC_28 s;生成了一个示例运行(添加了内联注释):

140067902240944   # First from d1
140067902240944   # Second from d1 is *same* object
140067900619760   # First from d2
140067900617712   # Second from d2 is unrelated object (same value, but stored separately)

虽然我没有检查toml是否以相同的方式表现,鉴于它与yaml腌制相同,它显然没有试图重复字符串;json是唯一奇怪的。它如此在意你,这主意倒不坏;JSONdict的键在逻辑上等同于对象的属性,对于巨大的输入(例如,一个数组中有10M个对象具有相同的几个键),它可能通过删除在最终解析输出上节省大量的内存(例如,在CPython 3.11 x86-64构建中,用单个副本替换10M个"ForwardX11"副本将减少590 MB的字符串数据,仅为59字节)。


作为旁注:这"dicts是相等的,咸菜不是;也可能出现问题:

  1. 当两个dicts使用相同的键和值构造时,但是键的插入顺序不同(现代Python使用插入顺序的dicts;它们之间的比较忽略了顺序,但pickle将以它们自然迭代的顺序序列化它们)。
  2. 当存在比较相等但类型不同的对象时(例如setvs.frozenset,intvs.float);pickle会将它们分开处理,但相等性测试不会看到差异。

这两个都不是这里的问题(jsonyaml似乎都以输入中看到的相同顺序构造,并且它们将ints解析为ints),但是您的相等性测试完全有可能返回True,而pickle形式是不相等的,即使所有涉及的对象都是唯一的。

最新更新