[译] 与 Python 无缝集成——基本特殊方法 2

注:原书作者 Steven F. Lott,原书名为 Mastering Object-oriented Python

__hash__() 方法

内置hash()函数会调用给定对象的__hash__()方法。这里hash就是将(可能是复杂的)值缩减为小整数值的计算。理想情况下,一个hash值反映了源值的所有信息。还有一些hash计算经常用于加密,生成非常大的值。

Python包含两个hash库。在hashlib模块中有高品质加密hash函数。zlib模块有两个高速hash函数:adler32()crc32()。对于相对简单的值,我们不使用这些。而对于大型、复杂的值,使用这些算法会有很大帮助。

hash()函数(和相关的__hash__()方法)用于创建集合中使用的小整数key,如下集合:setfrozensetdict。这些集合使用不可变对象的hash值来快速定位对象。

在这里不变性是很重要的,我们会多次提到它。不可变对象不会改变它们的状态。例如,数字3并没有改变状态,它总是3。更复杂的对象也是一样的,可以有一个不变的状态。Python字符串是不可变的,这样它们可以用来映射作集合的key。

默认的__hash__()继承自对象本身,返回一个基于对象的内部ID值。这个值可以通过id()函数看到,如下:

>>> x = object()
>>> hash(x)
269741571
>>> id(x)
4315865136
>>> id(x) / 16
269741571.0

由此,我们可以看到在作者的系统中,hash值就是对象的id / 16。这一细节针对不同平台可能会有所不同。例如,CPython使用可移植的C库,Jython依赖于Java JVM。

至关重要的是,内部ID和默认__hash__()方法间有一种强联系。这意味着每个对象默认是可以hash且完全不同的,即使它们似乎相同。

如果我们想将有相同值的不同对象合并到单个可hash对象中,我们需要修改这个。在下一节中,我们将看一个示例,该示例一个卡片的两个实例被视为是同一个对象。

1. 判断什么需要hash

不是每一个对象都需要提供一个hash值。具体地说,如果我们创建一个有状态、可变对象的类,该类万万不能返回hash值。__hash__应该定义为None

另一方面,不可变对象返回一个hash值,这样对象就可用作字典中的key或集合中的一员。在这种情况下,hash值需要用并行的方式检测相等性。对象有不同的hash值但被看作相等的对象是糟糕的。相反的,对象具有相同hash值,实际上不相等是可以接受的。

我们在比较运算符中看到的__eq__()方法与hash关系密切。

有三种级别的等式比较:

  • 相同的hash值:这意味着两个对象可能是相等的。该hash值为我们提供了一个快速检查对象相等的可能性。如果hash值是不同的,两个对象不可能是相等的,他们也不可能是相同的对象。

  • 等号比较:这意味着hash值也一定相等。这是==操作符的定义。对象可能是相同的对象。

  • 相同的IDD:这意味着他们是同一个对象。进行了等号比较且有相同的hash值。这是is操作符的定义。

Hash的基本规律(FLH)是:对象等号比较必须具有相同的hash值。

在相等性检测中我们能想到的第一步是hash比较。

然而,反过来是不正确的。对象可以有相同的hash值但比较是不相等的。在创建集合或字典时导致一些预计的处理开销是正当的。我们不能确切的从更大的数据结构创建不同的64位hash值。将有不相等的对象被简化为一致相等的hash值。

在使用集合和字典时比较hash值是一个预期的开销,它们是同时发生的。这些集合有内部的算法在hash冲突时会使用替换位置进行处理。

有三个用例通过__eq__()__hash__()方法定义相等性检测和hash值:

  • 不可变对象:对于有些无状态对象,例如tuplesnamedtuplesfrozensets这些不能被更新的类型。我们有两个选择:

    • 不定义__hash__()__eq__()。这意味着什么都不做,使用继承的定义。在这种情况下__hash__()返回一个简单的函数对象的ID值,然后__eq__()比较ID值。默认的相等性检测有时是违反直觉的。我们的应用程序可能需要两个Card(1, Clubs)实例检测相等性和计算相同的hash,默认情况下是不会发生这种情况的。

    • 定义__hash__()__eq__()。请注意,我们将为不可变对象定义以上两个。

  • 可变对象:这些是有状态的对象,可以进行内部修改。我们有一个选择:

    • 定义__eq__(),但__hash__()设置为None。这些不能被用作dict中的key或set中的项目。

请注意,有一个额外可能的组合:定义__hash__()但对__eq__()使用一个默认的定义。这其实是浪费时间,作为默认的__eq__()方法其实和is操作符是一样的。默认的__hash__()方法会为相同的行为编写更少的代码。

我们可以详细的看看这三种情况。

2. 为不可变对象继承定义

让我们看看默认定义操作。下面是一个简单的类层次结构,使用默认的__hash__()__eq__()定义:

class Card:

    insure= False

    def __init__(self, rank, suit, hard, soft):
        self.rank = rank
        self.suit = suit
        self.hard = hard
        self.soft = soft

    def __repr__(self):
        return "{__class__.__name__}(suit={suit!r}, rank={rank!r})"
          .format(__class__=self.__class__, **self.__dict__)

    def __str__(self):
       return "{rank}{suit}".format(**self.__dict__)

class NumberCard(Card):

    def __init__(self, rank, suit):
        super().__init__(str(rank), suit, rank, rank)

class AceCard(Card):

    def __init__(self, rank, suit):
        super().__init__("A", suit, 1, 11)

class FaceCard(Card):

    def __init__(self, rank, suit):
        super().__init__({11: 'J', 12: 'Q', 13: 'K'}[rank], suit, 10, 10)

这是一个不可变对象的类层次结构。我们还没有实现特殊方法防止属性更新。在下一章我们将看看属性访问。

当我们使用这个类层次结构时,看看会发生什么:

>>> c1 = AceCard(1, '♣')
>>> c2 = AceCard(1, '♣')

我们定义的两个相同的Card实例。我们可以检查id()的值,如下代码片段所示:

>>> print(id(c1), id(c2))
4302577232 4302576976

他们有不同的id()号,不同的对象。这符合我们的预期。

我们可以使用is操作符来检查它们是否一样,如下代码片段所示:

>>> c1 is c2
False

is测试”是基于id()的数字,它告诉我们,它们确实是独立的对象。

我们可以看到,它们的hash值是不同的:

>>> print(hash(c1), hash(c2))
268911077 268911061

这些hash值直接来自id()值。这是我们期望继承的方法。在这个实现中,我们可以从id()函数中计算出hash值,如下代码片段所示:

>>> id(c1) / 16
268911077.0
>>> id(c2) / 16
268911061.0

hash值是不同的,它们之间的比较必须不相等。这符合hash的定义和相等性定义。然而,这违背了我们对这个类的期望。下面是一个相等性检查:

>>> print(c1 == c2)
False

我们使用相同的参数创建了它们。它们比较后不相等。在某些应用程序中,这样不好。例如,当处理牌的时候累加计数,我们不想给一张牌做6个计数因为使用的是6副牌牌盒。

我们可以看到,他们是不可变对象,我们可以把它们放在一个集合里:

>>> print(set([c1, c2]))
{AceCard(suit='♣', rank=1), AceCard(suit='♣', rank=1)}

这是标准库参考文档中记录的行为。默认情况下,我们会得到一个基于对象ID的__hash__()方法,这样每个实例都唯一出现。然而,这并不总是我们想要的。

3. 覆写不可变对象的定义

下面是一个简单的类层次结构,它为我们提供了__hash__()__eq__()的定义:

class Card2:

    insure = False

    def __init__(self, rank, suit, hard, soft):
        self.rank = rank
        self.suit = suit
        self.hard = hard
        self.soft = soft

    def __repr__(self):
        return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".
          format(__class__=self.__class__, **self.__dict__)

    def __str__(self):
        return "{rank}{suit}".format(**self.__dict__)

    def __eq__(self, other):
        return self.suit == other.suit and self.rank == other.rank

    def __hash__(self):
        return hash(self.suit) ^ hash(self.rank)

class AceCard2(Card2):

    insure = True

    def __init__(self, rank, suit):
        super().__init__("A", suit, 1, 11)

原则上这个对象是不可变的。还没有正式的机制来让它不可变。关于这个机制我们将在第3章《属性访问、属性和描述符》中看看如何防止属性值变化。

同时,注意前面的代码省略了的两个子类,从前面的示例来看并没有显著的改变。

__eq__()方法函数比较这两个基本值:suitrank。它不比较派生自rankhard值和soft值。

21点的规则使这个定义有点可疑。花色在21点中实际上并不重要。我们只是比较牌值吗?我们是否应该定义一个额外的方法,而不是仅仅比较牌值?或者,我们应该依靠应用程序比较牌值的正确性?对于这些问题没有最好的回答,只是做好一个权衡。

__hash__()方法函数计算的位模式使用两个值作为基础进行hash,然后对hash值进行异或计算。使用^操作符是一种应急的hash方法,很有用。对于更大、更复杂的对象,使用更复杂的hash会更合适。在构造某个东东之前使用ziplib会有bug哦。

让我们来看看这些类对象的行为。我们期望它们比较是相等的且能够在集合和字典中正常使用。这里有两个对象:

>>> c1 = AceCard2(1, '♣')
>>> c2 = AceCard2(1, '♣')

我们定义的两个实例似乎是相同的牌。我们可以检查ID值,以确保他们是不同的对象:

>>> print(id(c1), id(c2))
4302577040 4302577296
>>> print(c1 is c2)
False

这些有不同的id()数字。当我们通过is操作符检测,我们看到它们是截然不同的。

让我们来比较一下hash值:

>>> print(hash(c1), hash(c2))
1259258073890 1259258073890

hash值是相同的。这意味着他们可能是相等的。

等号操作符告诉我们,他们是相等的

>>> print(c1 == c2)
True

它们是不可变的,我们可以把它们放到一个集合中,如下所示:

>>> print(set([c1, c2]))
{AceCard2(suit='♣', rank='A')}

对于复杂的不可变对象是符合我们预期的。我们必须覆盖这两个特殊方法获得一致的、有意义的结果。

4. 覆写可变对象的定义

这个例子将继续使用Cards类。可变的牌是很奇怪的想法,甚至是错误的。然而,我们想小小调整一下前面的例子。

以下是一个类层次结构,为我们提供了适合可变对象的__hash__()__eq__()的定义:

class Card3:

    insure = False

    def __init__(self, rank, suit, hard, soft):
        self.rank = rank
        self.suit = suit
        self.hard = hard
        self.soft = soft

    def __repr__(self):
        return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".
          format(__class__=self.__class__, **self.__dict__)

    def __str__(self):
        return "{rank}{suit}".format(**self.__dict__)

    def __eq__(self, other):
        return self.suit == other.suit and self.rank == other.rank
        # and self.hard == other.hard and self.soft == other.soft

       __hash__ = None

class AceCard3(Card3):

    insure= True

    def __init__(self, rank, suit):
        super().__init__("A", suit, 1, 11)

让我们来看看这些类对象的行为。我们期望它们比较是相等的,但是在集合和字典中完全不起作用。我们创建如下两个对象:

>>> c1 = AceCard3(1, '♣')
>>> c2 = AceCard3(1, '♣')

我们定义的两个实例似乎是相同的牌。我们可以检查ID值,以确保他们是不同的对象:

>>> print(id(c1), id(c2))
4302577040 4302577296

如果我们尝试获取hash值,毫无意外,我们将会看到如下情形:

>>> print(hash(c1), hash(c2))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'

__hash__被设置为None,这些Card3对象不能被hash,不能为hash()函数提供值。和我们预期的是一样的。

我们可以执行相等性比较,如下代码片段所示:

>>> print(c1 == c2)
True

相等性测试工作正常,才能很好的让我们比较牌。它们只是不能被插入到集合或用作字典的key。

我们试试会发生什么:

>>> print(set([c1, c2]))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'

当试图把这些放到集合中,我们会得到这样一个异常。

显然,这不是一个正确的定义,在现实生活中和牌一样是不可变对象。这种风格的定义更适合有状态的对象,如Hand,它的内容总是在变化的。我们将通过第二个示例为您提供一个有状态的对象在接下来的章节。

5. 从可变手牌变为冻结手牌

如果我们想对具体的Hand实例进行统计分析,我们可能需要创建一个字典来映射Hand实例到计数中。我们不能用一个可变Hand类作为一个映射的key。然而,我们可以并行的设计setfrozenset并且创建两个类:HandFrozenHand。这允许我们能通过FrozenHand类“冻结”Hand类;冻结版本是不可变的,可以作为一个字典的key。

下面是一个简单的Hand定义:

class Hand:

    def __init__(self, dealer_card, *cards):
        self.dealer_card = dealer_card
        self.cards = list(cards)

    def __str__(self):
        return ", ".join(map(str, self.cards))

    def __repr__(self):
        return "{__class__.__name__}({dealer_card!r}, {_cards_str})"
          .format(__class__=self.__class__, _cards_str=", "
          .join(map(repr, self.cards)), **self.__dict__)

    def __eq__(self, other):
        return self.cards == other.cards and self.dealer_card == other.dealer_card

    __hash__ = None

这是一个可变对象(__hash__None),它有一个恰当的相等性检测来比较两副手牌。

下面是关于Hand的一个“冻结”版本:

import sys

class FrozenHand(Hand):

    def __init__(self, *args, **kw):
        if len(args) == 1 and isinstance(args[0], Hand):
            # Clone a hand
            other = args[0]
            self.dealer_card = other.dealer_card
            self.cards = other.cards
        else:
            # Build a fresh hand
            super().__init__(*args, **kw)

    def __hash__(self):
        h = 0
        for c in self.cards:
            h = (h + hash(c)) % sys.hash_info.modulus
        return h

冻结版本有一个构造函数,将从另一个Hand类构建一个Hand类。它定义了一个__hash__()方法,计算牌的hash值的总和,这个值受sys.hash_info.modules限制。大多数情况,这种基于模块的计算,在计算复合对象hash时效果相当好。

我们现在可以使用这些类进行操作,如下代码片段所示:

stats = defaultdict(int)
d = Deck()
h = Hand(d.pop(), d.pop(), d.pop())
h_f = FrozenHand(h)
stats[h_f] += 1

我们需要初始化统计字典——statsdefaultdict字典,可以收集整型计数。为此我们可以使用一个collections.Counter对象。

通过冻结Hand类,我们可以把它作为一个字典的key,收集每副手牌计数的问题就可以解决了。

    原文作者:wanyoung
    原文地址: https://segmentfault.com/a/1190000003969462
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞