如何“完美”重写一个字典?

271 投票
6 回答
181679 浏览
提问于 2025-04-16 02:11

我该如何创建一个“完美”的dict子类呢?我的最终目标是有一个简单的dict,它的键都是小写字母。

看起来应该有一些基本的方法可以重写,让这个功能实现,但根据我所有的研究和尝试,似乎并不是这样:

  • 如果我重写了 __getitem__/__setitem__ ,那么 get/set 就不能用了。我该怎么让它们正常工作呢?难道我需要单独实现它们吗?

  • 我是不是阻止了序列化(pickling)的工作?我需要实现 __setstate__ 等方法吗?

  • 我需要 reprupdate__init__ 吗?

  • 我是不是应该 使用 mutablemapping(似乎不应该使用 UserDictDictMixin)?如果是的话,怎么做呢?文档并没有给出明确的指导。

这是我第一次尝试的代码,get() 不工作,肯定还有很多其他小问题:

class arbitrary_dict(dict):
    """A dictionary that applies an arbitrary key-altering function
       before accessing the keys."""

    def __keytransform__(self, key):
        return key

    # Overridden methods. List from 
    # https://stackoverflow.com/questions/2390827/how-to-properly-subclass-dict

    def __init__(self, *args, **kwargs):
        self.update(*args, **kwargs)

    # Note: I'm using dict directly, since super(dict, self) doesn't work.
    # I'm not sure why, perhaps dict is not a new-style class.

    def __getitem__(self, key):
        return dict.__getitem__(self, self.__keytransform__(key))

    def __setitem__(self, key, value):
        return dict.__setitem__(self, self.__keytransform__(key), value)

    def __delitem__(self, key):
        return dict.__delitem__(self, self.__keytransform__(key))

    def __contains__(self, key):
        return dict.__contains__(self, self.__keytransform__(key))


class lcdict(arbitrary_dict):
    def __keytransform__(self, key):
        return str(key).lower()

6 个回答

7

在尝试了两个最热门的建议后,我选择了一条看起来不太靠谱的中间路线,适用于Python 2.7。也许Python 3更靠谱,但对我来说:

class MyDict(MutableMapping):
   # ... the few __methods__ that mutablemapping requires
   # and then this monstrosity
   @property
   def __class__(self):
       return dict

我其实很讨厌这样做,但似乎符合我的需求,这些需求包括:

  • 可以重写 **my_dict
    • 如果你从 dict 继承,这样会绕过你的代码。试试看。
    • 这让我在任何时候都无法接受 #2,因为在Python代码中这种情况非常常见。
  • 看起来像 isinstance(my_dict, dict)
    • 仅仅依靠 MutableMapping 是不够的,所以 #1 不够用。
    • 如果你不需要这个,我强烈推荐 #1,它简单且可预测。
  • 完全可控的行为
    • 所以我不能从 dict 继承。

如果你需要让自己和别人区分开来,个人上我用的是这样的东西(不过我建议用更好的名字):

def __am_i_me(self):
  return True

@classmethod
def __is_it_me(cls, other):
  try:
    return other.__am_i_me()
  except Exception:
    return False

只要你只需要在内部识别自己,这样做就能减少意外调用 __am_i_me 的可能,因为Python会对名字进行处理(外部调用这个类的任何东西都会把它改成 _MyDict__am_i_me)。在实践和文化上,这比 _method 更私密。

到目前为止我没有任何抱怨,除了那个看起来很可疑的 __class__ 重写。听到其他人遇到的问题我会很高兴,虽然我不完全理解后果。但到目前为止我没有遇到任何问题,这让我能够在很多地方迁移大量质量一般的代码,而不需要做任何更改。


作为证据: https://repl.it/repls/TraumaticToughCockatoo

基本上:复制 当前的 #2 选项,在每个方法中添加 print 'method_name' 语句,然后试试这个,看看输出:

d = LowerDict()  # prints "init", or whatever your print statement said
print '------'
splatted = dict(**d)  # note that there are no prints here

你会在其他场景中看到类似的行为。假设你的假 dict 是某种其他数据类型的包装器,那么就没有合理的方法将数据存储在后端字典中;**your_dict 将会是空的,无论其他方法做什么。

这在 MutableMapping 中工作正常,但一旦你从 dict 继承,就变得无法控制。


编辑:作为更新,这个已经运行了将近两年,没有出现过任何问题,处理了数十万(可能是几百万)行复杂的、遗留的Python代码。所以我对此很满意 :)

编辑2:显然我很久以前复制错了什么。 @classmethod __class__ 不适用于 isinstance 检查 - @property __class__ 可以: https://repl.it/repls/UnitedScientificSequence

137

我该如何尽可能完美地创建一个字典的子类?

最终目标是有一个简单的字典,所有的键都是小写字母。

  • 如果我重写了 __getitem____setitem__,那么获取和设置就不能正常工作。我该怎么做才能让它们正常工作?难道我需要单独实现它们吗?

  • 我是否阻止了序列化的工作?我需要实现 __setstate__ 等吗?

  • 我需要实现 reprupdate__init__ 吗?

  • 我应该直接使用 mutablemapping 吗(似乎不应该使用 UserDictDictMixin)?如果是这样,我该怎么做?文档并没有很清楚地说明。

我接受的答案是我最初的想法,但由于它有一些问题,而且没有人提到替代方案,实际上是子类化一个 dict,所以我在这里会这样做。

接受的答案有什么问题?

对我来说,这似乎是一个相当简单的请求:

我该如何尽可能完美地创建一个字典的子类?最终目标是有一个简单的字典,所有的键都是小写字母。

接受的答案实际上并没有子类化 dict,而且对此的测试失败了:

>>> isinstance(MyTransformedDict([('Test', 'test')]), dict)
False

理想情况下,任何类型检查的代码都应该测试我们期望的接口或一个抽象基类,但如果我们的数据对象被传递到测试 dict 的函数中,而我们无法“修复”这些函数,这段代码就会失败。

其他可能的争论点:

  • 接受的答案也缺少类方法:fromkeys
  • 接受的答案还有一个多余的 __dict__,因此在内存中占用了更多空间:

    >>> s.foo = 'bar'
    >>> s.__dict__
    {'foo': 'bar', 'store': {'test': 'test'}}
    

实际子类化 dict

我们可以通过继承重用字典的方法。我们需要做的就是创建一个接口层,确保如果键是字符串,就以小写形式传入字典。

如果我重写了 __getitem____setitem__,那么获取和设置就不能正常工作。我该怎么做才能让它们正常工作?难道我需要单独实现它们吗?

实际上,单独实现它们是这种方法的缺点,而使用 MutableMapping 是其优点(见接受的答案),但这并不是额外的工作。

首先,让我们考虑一下 Python 2 和 3 之间的区别,创建一个单例 (_RaiseKeyError) 来确保我们知道是否真的给 dict.pop 传递了参数,并创建一个函数来确保我们的字符串键是小写的:

from itertools import chain
try:              # Python 2
    str_base = basestring
    items = 'iteritems'
except NameError: # Python 3
    str_base = str, bytes, bytearray
    items = 'items'

_RaiseKeyError = object() # singleton for no-default behavior

def ensure_lower(maybe_str):
    """dict keys can be any hashable object - only call lower if str"""
    return maybe_str.lower() if isinstance(maybe_str, str_base) else maybe_str

现在我们来实现 - 我使用 super 和完整的参数,这样这段代码在 Python 2 和 3 中都能工作:

class LowerDict(dict):  # dicts take a mapping or iterable as their optional first argument
    __slots__ = () # no __dict__ - that would be redundant
    @staticmethod # because this doesn't make sense as a global function.
    def _process_args(mapping=(), **kwargs):
        if hasattr(mapping, items):
            mapping = getattr(mapping, items)()
        return ((ensure_lower(k), v) for k, v in chain(mapping, getattr(kwargs, items)()))
    def __init__(self, mapping=(), **kwargs):
        super(LowerDict, self).__init__(self._process_args(mapping, **kwargs))
    def __getitem__(self, k):
        return super(LowerDict, self).__getitem__(ensure_lower(k))
    def __setitem__(self, k, v):
        return super(LowerDict, self).__setitem__(ensure_lower(k), v)
    def __delitem__(self, k):
        return super(LowerDict, self).__delitem__(ensure_lower(k))
    def get(self, k, default=None):
        return super(LowerDict, self).get(ensure_lower(k), default)
    def setdefault(self, k, default=None):
        return super(LowerDict, self).setdefault(ensure_lower(k), default)
    def pop(self, k, v=_RaiseKeyError):
        if v is _RaiseKeyError:
            return super(LowerDict, self).pop(ensure_lower(k))
        return super(LowerDict, self).pop(ensure_lower(k), v)
    def update(self, mapping=(), **kwargs):
        super(LowerDict, self).update(self._process_args(mapping, **kwargs))
    def __contains__(self, k):
        return super(LowerDict, self).__contains__(ensure_lower(k))
    def copy(self): # don't delegate w/ super - dict.copy() -> dict :(
        return type(self)(self)
    @classmethod
    def fromkeys(cls, keys, v=None):
        return super(LowerDict, cls).fromkeys((ensure_lower(k) for k in keys), v)
    def __repr__(self):
        return '{0}({1})'.format(type(self).__name__, super(LowerDict, self).__repr__())

我们对任何引用键的方法或特殊方法使用几乎是模板化的方法,但通过继承,我们可以免费获得 lenclearitemskeyspopitemvalues 方法。虽然这需要一些细致的思考才能正确实现,但很容易看出这能正常工作。

(注意,haskey 在 Python 2 中已被弃用,在 Python 3 中被移除。)

这里有一些用法:

>>> ld = LowerDict(dict(foo='bar'))
>>> ld['FOO']
'bar'
>>> ld['foo']
'bar'
>>> ld.pop('FoO')
'bar'
>>> ld.setdefault('Foo')
>>> ld
{'foo': None}
>>> ld.get('Bar')
>>> ld.setdefault('Bar')
>>> ld
{'bar': None, 'foo': None}
>>> ld.popitem()
('bar', None)

我是否阻止了序列化的工作?我需要实现 __setstate__ 等吗?

序列化

这个字典子类的序列化工作得很好:

>>> import pickle
>>> pickle.dumps(ld)
b'\x80\x03c__main__\nLowerDict\nq\x00)\x81q\x01X\x03\x00\x00\x00fooq\x02Ns.'
>>> pickle.loads(pickle.dumps(ld))
{'foo': None}
>>> type(pickle.loads(pickle.dumps(ld)))
<class '__main__.LowerDict'>

__repr__

我需要实现 reprupdate__init__ 吗?

我们定义了 update__init__,但你默认有一个很好的 __repr__

>>> ld # without __repr__ defined for the class, we get this
{'foo': None}

不过,写一个 __repr__ 是个好主意,可以提高代码的可调试性。理想的测试是 eval(repr(obj)) == obj。如果对你的代码来说这很简单,我强烈推荐这样做:

>>> ld = LowerDict({})
>>> eval(repr(ld)) == ld
True
>>> ld = LowerDict(dict(a=1, b=2, c=3))
>>> eval(repr(ld)) == ld
True

你看,这正是我们需要的,以重建一个等效的对象 - 这可能会出现在我们的日志或回溯中:

>>> ld
LowerDict({'a': 1, 'c': 3, 'b': 2})

总结

我应该直接使用 mutablemapping 吗(似乎不应该使用 UserDictDictMixin)?如果是这样,我该怎么做?文档并没有很清楚地说明。

是的,这需要多写几行代码,但它们是为了全面考虑。我的第一反应是使用接受的答案,如果有问题,我再看看我的答案 - 因为它稍微复杂一些,而且没有 ABC 来帮助我正确设置接口。

过早优化就是为了追求性能而增加复杂性。MutableMapping 更简单 - 所以在其他条件相同的情况下,它有一个立即的优势。不过,为了列出所有的区别,让我们进行比较和对比。

我应该补充的是,曾经有推动将类似字典放入 collections 模块,但它被拒绝了。你可能应该这样做:

my_dict[transform(key)]

这应该更容易调试。

比较与对比

使用 MutableMapping 实现了 6 个接口函数(缺少 fromkeys),而使用 dict 子类实现了 11 个。我不需要实现 __iter____len__,但我需要实现 getsetdefaultpopupdatecopy__contains__fromkeys - 但这些相对简单,因为我可以通过继承来实现大部分。

MutableMapping 在 Python 中实现了一些东西,而 dict 在 C 中实现 - 所以我预计 dict 子类在某些情况下会更高效。

在这两种方法中,我们都可以免费获得 __eq__ - 两者都假设只有当另一个字典全部为小写时才算相等 - 但我认为 dict 子类的比较会更快。

总结:

  • 子类化 MutableMapping 更简单,出错的机会更少,但速度较慢,占用更多内存(见多余的字典),并且无法通过 isinstance(x, dict)
  • 子类化 dict 更快,占用更少内存,并且可以通过 isinstance(x, dict),但实现起来复杂度更高。

哪种更完美?这取决于你对完美的定义。

285

你可以很简单地写一个像 dict 的对象,使用的是 ABC(抽象基类),这个功能在 collections.abc 模块里。它甚至会告诉你如果你漏掉了某个方法,所以下面是一个最简单的版本,可以让 ABC 不再发出警告。

from collections.abc import MutableMapping


class TransformedDict(MutableMapping):
    """A dictionary that applies an arbitrary key-altering
       function before accessing the keys"""

    def __init__(self, *args, **kwargs):
        self.store = dict()
        self.update(dict(*args, **kwargs))  # use the free update to set keys

    def __getitem__(self, key):
        return self.store[self._keytransform(key)]

    def __setitem__(self, key, value):
        self.store[self._keytransform(key)] = value

    def __delitem__(self, key):
        del self.store[self._keytransform(key)]

    def __iter__(self):
        return iter(self.store)
    
    def __len__(self):
        return len(self.store)

    def _keytransform(self, key):
        return key

你可以从 ABC 中获得一些免费的方法:

class MyTransformedDict(TransformedDict):

    def _keytransform(self, key):
        return key.lower()


s = MyTransformedDict([('Test', 'test')])

assert s.get('TEST') is s['test']   # free get
assert 'TeSt' in s                  # free __contains__
                                    # free setdefault, __eq__, and so on

import pickle
# works too since we just use a normal dict
assert pickle.loads(pickle.dumps(s)) == s

我不建议直接继承 dict(或其他内置类型)。这样做通常没有意义,因为你真正想要的是 实现一个 dict 的接口。而这正是 ABC 的用途。

撰写回答