自定义类型的对象作为字典键

241

我该怎么样才能将自定义类型的对象用作Python字典中的键(其中我不希望“对象ID”充当键),例如:

class MyThing:
    def __init__(self,name,location,length):
            self.name = name
            self.location = location
            self.length = length

我想使用MyThing作为键,如果名称和位置相同,则它们被视为相同的键。从C#/Java中,我习惯于必须重载并提供equals和hashcode方法,并承诺不要更改任何hashcode依赖的内容。

在Python中,我该怎么做才能实现这一点?我应该吗?(在像这样的简单情况下,可能最好只将(name,location)元组作为键 - 但请考虑我希望键是一个对象)


使用哈希有什么问题吗? - Rafe Kettler
7
可能是因为他想要两个MyThing,如果它们有相同的namelocation,则通过索引字典返回相同的值,即使它们作为两个不同的"对象"分别创建。 - Santa
1
也许更好的做法是将(名称,位置)元组作为键 - 但请考虑我希望键是一个对象。你的意思是:一个非复合对象吗? - eyquem
6个回答

285

你需要添加 2 个方法,注意 __hash____eq__

class MyThing:
    def __init__(self,name,location,length):
        self.name = name
        self.location = location
        self.length = length

    def __hash__(self):
        return hash((self.name, self.location))

    def __eq__(self, other):
        return (self.name, self.location) == (other.name, other.location)

    def __ne__(self, other):
        # Not strictly necessary, but to avoid having both x==y and x!=y
        # True at the same time
        return not(self == other)

Python dict documentation 规定了 key 对象的要求,即它们必须是可哈希的。


20
hash(self.name) зЬЛиµЈжЭ•жѓФ self.name.__hash__() жЫіе•љпЉМе¶ВжЮЬдљ†ињЩж†ЈеБЪеєґдЄФдљ†еПѓдї•дљњзФ® hash((x, y)) жЭ•йБњеЕН XOR ињРзЃЧгАВ - Rosh Oxymoron
5
另外需要注意的是,我刚刚发现这样调用 x.__hash__() 也是错误的,因为它可能会产生不正确的结果:http://pastebin.com/C9fSH7eF - Rosh Oxymoron
1
@user877329:你是否尝试使用一些Blender数据结构作为键?显然,某些仓库中的特定对象要求您首先“冻结”它们以避免可变性(不允许改变基于值的对象在Python字典中用作键)。 - 6502
@6502 是的,我有。在构造函数中添加 self.uv.freeze()self.normal.freeze() 可以解决这个问题。 - user877329
1
@kawing-chiu 这个链接展示了 Python 2 中的“bug”。然而,Python 3 没有这个问题:默认的 __ne__() 已经得到了修复。修复细节请见此链接:http://pastebin.com/Uhz79Rhj。 - Bob Stein
显示剩余12条评论

38

在Python 2.6或以上版本中的一种替代方法是使用collections.namedtuple()--它可以帮助你省去编写特殊方法的麻烦:

from collections import namedtuple
MyThingBase = namedtuple("MyThingBase", ["name", "location"])
class MyThing(MyThingBase):
    def __new__(cls, name, location, length):
        obj = MyThingBase.__new__(cls, name, location)
        obj.length = length
        return obj

a = MyThing("a", "here", 10)
b = MyThing("a", "here", 20)
c = MyThing("c", "there", 10)
a == b
# True
hash(a) == hash(b)
# True
a == c
# False

23

如果您希望特殊处理哈希语义,请覆盖__hash__,并覆盖__cmp____eq__以使您的类可用作键。比较相等的对象需要具有相同的哈希值。

Python期望__hash__返回一个整数,不建议返回Banana() :)

用户定义的类默认情况下具有__hash__,该类调用id(self),如您所述。

文档中还提供了一些额外的提示:documentation.

继承父类的__hash __()方法但更改__cmp__()__eq__()的含义的类(例如通过从默认基于标识的相等性切换到基于值的概念)可以在类定义中明确标记自己为不可哈希,方法是设置__hash__ = None。这样做意味着,当程序试图检索它们的哈希值时,该类的实例不仅会引发适当的TypeError,而且在检查isinstance(obj,collections.Hashable)时也将正确识别为不可哈希(与显式引发TypeError的类定义自己的__hash __()不同)。


2
д»…жңүе“ҲеёҢжҳҜдёҚеӨҹзҡ„пјҢжӯӨеӨ–жӮЁиҝҳйңҖиҰҒиҰҶзӣ–__eq__жҲ–__cmp__гҖӮ - Oben Sonne
@Oben Sonne:如果它是一个用户定义的类,Python会为您提供__cmp__,但您可能仍然想要覆盖它们以适应新的语义。 - Skurmedel
1
@Skurmedel:是的,但是尽管您可以调用cmp并在未覆盖这些方法的用户类上使用=,但其中一个必须被实现以满足提问者的要求,即具有相似名称和位置的实例具有相同的字典键。 - Oben Sonne

12

我注意到在Python 3.8.8中(可能更早),您不再需要明确声明 __eq__() __hash__()就可以使用自己的类作为字典键。

class Apple:
    def __init__(self, weight):
        self.weight = weight
        
    def __repr__(self):
        return f'Apple({self.weight})'

apple_a = Apple(1)
apple_b = Apple(1)
apple_c = Apple(2)

apple_dictionary = {apple_a : 3, apple_b : 4, apple_c : 5}

print(apple_dictionary[apple_a])  # 3
print(apple_dictionary)  # {Apple(1): 3, Apple(1): 4, Apple(2): 5}

我假设 Python 在某个时刻开始自动管理它,但是我可能错了。


1
我也发现这是真的。 - user2268997

1

据我所知,对于今天这个问题,像我一样的其他人可能会在这里找答案,解决方法是在Python>3.7中使用数据类dataclasses。它具有哈希相等函数。


你的回答可以通过提供更多支持信息来改进。请编辑以添加进一步的细节,例如引用或文档,以便他人可以确认你的答案是正确的。您可以在帮助中心中找到有关如何编写良好答案的更多信息。 - Community

0

@dataclass(frozen=True) 示例(Python 3.7)

@dataclass 已经在 https://dev59.com/Q2445IYBdhLWcg3wXZEy#69313714 中提到过,但这里有一个示例。

这个很棒的新功能,除了其他好处之外,还会自动为您定义一个 __hash____eq__ 方法,使其像通常在字典和集合中一样正常工作:

dataclass_cheat.py

from dataclasses import dataclass, FrozenInstanceError

@dataclass(frozen=True)
class MyClass1:
    n: int
    s: str

@dataclass(frozen=True)
class MyClass2:
    n: int
    my_class_1: MyClass1

d = {}
d[MyClass1(n=1, s='a')] = 1
d[MyClass1(n=2, s='a')] = 2
d[MyClass1(n=2, s='b')] = 3
d[MyClass2(n=1, my_class_1=MyClass1(n=1, s='a'))] = 4
d[MyClass2(n=2, my_class_1=MyClass1(n=1, s='a'))] = 5
d[MyClass2(n=2, my_class_1=MyClass1(n=2, s='a'))] = 6

assert d[MyClass1(n=1, s='a')] == 1
assert d[MyClass1(n=2, s='a')] == 2
assert d[MyClass1(n=2, s='b')] == 3
assert d[MyClass2(n=1, my_class_1=MyClass1(n=1, s='a'))] == 4
assert d[MyClass2(n=2, my_class_1=MyClass1(n=1, s='a'))] == 5
assert d[MyClass2(n=2, my_class_1=MyClass1(n=2, s='a'))] == 6

# Due to `frozen=True`
o = MyClass1(n=1, s='a')
try:
    o.n = 2
except FrozenInstanceError as e:
    pass
else:
    raise 'error'

正如我们在这个例子中所看到的,哈希值是基于对象内容而不仅仅是实例地址计算的。这就是为什么像这样的东西:

d = {}
d[MyClass1(n=1, s='a')] = 1
assert d[MyClass1(n=1, s='a')] == 1

即使第二个 MyClass1(n=1, s='a') 是一个完全不同的实例,并且有不同的地址,它也能正常工作。

frozen=True 是强制性的,否则该类是不可散列的,否则用户可能会在将对象用作键之后意外修改对象,从而使容器不一致。更多文档:https://docs.python.org/zh-cn/3/library/dataclasses.html

已在 Python 3.10.7、Ubuntu 22.10 上测试。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接