Python中类名的自动全名资格是如何工作的?[与对象序列化相关]

4 投票
2 回答
555 浏览
提问于 2025-04-16 17:40

(你可以直接跳到问题部分,跳过介绍。)

在从用户自定义类中保存(序列化)Python对象时,常常会遇到一个问题:

# This is program dumper.py
import pickle

class C(object):
    pass

with open('obj.pickle', 'wb') as f:
    pickle.dump(C(), f)

实际上,试图从另一个程序 loader.py 中恢复这个对象时,

# This is program loader.py
with open('obj.pickle', 'rb') as f:
    obj = pickle.load(f)

会出现

AttributeError: 'module' object has no attribute 'C'

实际上,这个类是通过名字("C")进行保存的,而 loader.py 程序对 C 一无所知。一个常见的解决办法是通过以下方式导入:

from dumper import C  # Objects of class C can be imported

with open('obj.pickle', 'rb') as f:
    obj = pickle.load(f)

不过,这个解决办法有一些缺点,包括需要导入所有被保存对象引用的类(可能有很多);此外,局部命名空间会被 dumper.py 程序中的名字污染。

现在,解决这个问题的方法是在保存之前对对象进行完全限定:

# New dumper.py program:
import pickle
import dumper  # This is this very program!

class C(object):
    pass

with open('obj.pickle', 'wb') as f:
    pickle.dump(dumper.C(), f)  # Fully qualified class

使用上面的原始 loader.py 程序进行恢复现在可以直接工作(不需要执行 from dumper import C)。

问题:现在,来自 dumper.py 的其他类似乎在保存时自动进行了完全限定,我想知道这是怎么回事,以及这是否是一个可靠且有文档支持的行为:

import pickle
import dumper  # This is this very program!

class D(object):  # New class!
    pass

class C(object):
    def __init__(self):
        self.d = D()  # *NOT* fully qualified

with open('obj.pickle', 'wb') as f:
    pickle.dump(dumper.C(), f)  # Fully qualified pickle class

现在,使用原始的 loader.py 程序进行恢复也可以正常工作(不需要执行 from dumper import C);print obj.d 输出的是一个完全限定的类,这让我感到惊讶:

<dumper.D object at 0x122e130>

这种行为非常方便,因为只需要对顶层的被保存对象使用模块名进行完全限定(dumper.C())。但这种行为是否可靠且有文档支持?为什么类是通过名字("D")进行保存的,但在恢复时却决定 self.d 属性是类 dumper.D(而不是某个本地的 D 类)?

附注:问题的进一步细化:我刚刚注意到一些有趣的细节,可能指向这个问题的答案:

在保存程序 dumper.py 中,print self.d 输出 <__main__.D object at 0x2af450>,这是在第一个 dumper.py 程序中(没有 import dumper)。另一方面,执行 import dumper 并在 dumper.py 中用 dumper.C() 创建对象时,print self.d 输出 <dumper.D object at 0x2af450>self.d 属性被Python自动进行了限定!所以,看起来 pickle 模块在上述良好的恢复行为中并没有起到作用。

因此,真正的问题是:为什么Python在第二种情况下将 D() 转换为完全限定的 dumper.D?这个问题是否有文档说明?

2 个回答

3

当你的类在主模块中定义时,pickle会在这个地方寻找它们,特别是在你想要把它们从文件中读取出来的时候。在你的第一个例子中,类是在主模块中定义的,所以当loader运行时,loader就是主模块,而pickle找不到这些类。如果你查看obj.pickle的内容,你会看到__main__被用作你C和D类的命名空间。

在你的第二个例子中,dumper.py自己导入了自己。这样一来,你实际上就有了两组不同的C和D类:一组在__main__命名空间中,另一组在dumper命名空间中。你序列化的是在dumper命名空间中的那一组(可以查看obj.pickle来确认)。

如果找不到命名空间,pickle会尝试动态导入它,所以当loader.py运行时,pickle本身会导入dumper.py以及dumper.C和dumper.D类。

由于你有两个独立的脚本,dumper.py和loader.py,因此将它们共享的类定义在一个公共的导入模块中是很有意义的:

common.py

class D(object):
    pass

class C(object):
    def __init__(self):
        self.d = D()

loader.py

import pickle

with open('obj.pickle','rb') as f:
    obj = pickle.load(f)

print obj

dumper.py

import pickle
from common import C

with open('obj.pickle','wb') as f:
    pickle.dump(C(),f)

需要注意的是,即使在这个例子中dumper.py导出了C(),pickle也知道它是一个common.C对象(查看obj.pickle)。当loader.py运行时,它会动态导入common.py,并成功加载这个对象。

2

事情是这样的:当你在 dumper.py 文件里导入 dumper(或者用 from dumper import C)的时候,整个程序会被重新解析一遍(你可以通过在模块里插入一个打印语句来看到这一点)。这种行为是正常的,因为 dumper 这个模块并没有被提前加载(不过 __main__ 是被认为已经加载了的)——它不在 sys.modules 里。

正如 Mark 的回答中所示,导入一个模块会让模块里定义的所有名称都被识别,所以当重新解析 dumper.py 文件时,self.d = D() 会被理解为是 dumper.D 类的实例(这和解析 common.py 是一样的,正如 Mark 的回答所说)。

因此,import dumper(或者 from dumper import C)的这个小技巧就解释清楚了,序列化不仅会完整地识别 C 类,还会识别 D 类。这使得通过外部程序反序列化变得更简单!

这也表明,在 dumper.py 里执行 import dumper 会让 Python 解释器解析程序两次,这样做既不高效也不优雅。因此,在一个程序里序列化类,然后在 另一个 程序里反序列化,最好还是按照 Mark 的回答中提到的方法来做:序列化的类应该放在一个单独的模块里。

撰写回答