Python __hash__: идентичность и эквивалентность - PullRequest
2 голосов
/ 11 октября 2019

У меня есть класс, экземпляры которого должны различаться по идентичности, отличной от значений данных, которые они несут. В моем коде я намерен использовать == для обозначения двух экземпляров, эквивалентных по своим данным, и is для обозначения двух переменных, относящихся к одному и тому же экземпляру, то есть, что они идентичны. Это все вполне нормально, насколько я понимаю.

Более того, я хочу использовать экземпляры (эквивалентные или нет) в set с, а в качестве ключей в dict с. Это требует, чтобы для класса была определена функция __hash__.

Но в этом отношении я не понимаю документированного требования __hash__:

Единственное обязательное свойствов том, что сравниваемые объекты имеют одинаковое значение хеш-функции.

Означает ли это, что два различных, но эквивалентных объекта не могут использоваться в качестве разных ключей в dict или появляются по отдельности в set? В приведенном ниже коде я нарушаю это требование, переопределяя __eq__ и __hash__, чтобы отразить мое предполагаемое использование. Это работает, как и ожидалось в Python 2.7 и 3.7.

Каковы отрицательные последствия нарушения требования __hash__, как я это сделал здесь?

Есть ли лучший способ выполнить мойцель?

class A( object ):
        def __init__( self, v1, v2 ):
                self.v = ( v1, v2 )

        def __eq__( self, b ):
                return self.v[0] == b.v[0] and self.v[1] == b.v[1]

        def __hash__( self ):
                return id( self )

        def __str__( self ):
                return str( self.v )

p = A( 1, 0 )
q = A( 1, 0 )

print( str( p ), str( q ) )
print( "identical?", p is q )
print( "equivalent?", p == q )
print( "hashes", hash(p), hash(q) )

s = set( [p, q] )
print( "set length", len( s ) )
print( "both in set?", p in s, q in s )

d = { p:3, q:4 }
print( "dict length", len( d ) )
print( "as distinct keys", d[p], d[q] )

Ответы [ 2 ]

1 голос
/ 11 октября 2019

Единственное обязательное свойство заключается в том, что сравниваемые объекты имеют одинаковое значение хеш-функции.

"Сравнение равно" в тексте спецификации означает результат их __eq__ методов -не требуется, чтобы они были одним и тем же объектом.

Предполагается, что __hash__ должны основываться на значениях, которые используются в __eq__, а не на "id" объекта - эта часть неверна в вашем коде. Чтобы это работало, вот как это должно быть:

Просто сделайте:

      ...
      def __eq__( self, b ):
           return self.v[0] == b.v[0] and self.v[1] == b.v[1]

      def __hash__( self ):
           return hash((self.v[0], self.v[1]))

Означает ли это, что два разных, но эквивалентных объекта не могут использоваться в качестве разных ключей? в диктовке или выступать индивидуально в наборе?

Да. Это то, что означает спецификация.

Обходной путь для этого состоит в том, чтобы оставить реализацию __eq__ по умолчанию для вашего класса, чтобы соответствовать правилам, и внедрить альтернативную форму сравнения, которую вы должны будете использовать в своем коде.

Самый простой способ - просто оставить реализацию по умолчанию __eq__ как есть, которая сравнивается по тождеству, и иметь произвольный метод, который вы используете для сравнения (идиома, кодирующая в языках, которые делаютне поддерживает переопределение оператора должны использовать в любом случае):

class A( object ):
    ...
    def equals( self, b ):
       return self.v[0] == b.v[0] and self.v[1] == b.v[1]

p = A( 1, 0 )
q = A( 1, 0 )

print( str( p ), str( q ) )
print( "identical?", p is q )
print( "equivalent?", p.equals(q) )

Есть способы немного улучшить это - но базовый уровень: __eq__ должны соответствовать правилам и проводить сравнение личности.

Одним из способов является создание внутреннего связанного объекта, который работает как «пространство имен», которое вы можете использовать для сравнения:

class CompareSpace:
    def __init__(self, parent):
        self.parent = parent

        def __eq__( self, other ):
            other = other.parent if isinstance(other, type(self)) else other 
            return self.parent.v[0] == other.v[0] and other.v[1] == b.parent.v[1]


    class A:
        def __init__( self, v1, v2 ):
            self.v = ( v1, v2 )
            self.comp = CompareSpace(self)

        def __str__( self ):
            return str( self.v )

p = A( 1, 0 )
q = A( 1, 0 )

print( str( p ), str( q ) )
print( "identical?", p is q )
print( "equivalent?", p.comp == q )
print( "hashes", hash(p), hash(q) )

демонстрация поломки

Теперь я будувставьте пример того, как это ломается - я создаю класс намеренно более сломанным, чтобы проблема возникла с первой попытки. Но если проблема возникает хотя бы один раз каждые 2 миллиона раз, ваш код все равно будет слишком взломан, чтобы использовать его в чем-либо реальном, даже если это личный код: у вас будет недетерминированный словарь:


class Broken:
    def __init__(self, name):
        self.name = name
    def __hash__(self):
        return id(self) % 256
    def __eq__(self, other):
        return True
    def __repr__(self):
        return self.name


In [23]: objs = [Broken(f"{i:02d}") for i in range(64)]                                        

In [24]: print(objs)                                                                           
[00, 01, 02, 03, 04, 05, 06, 07, 08, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63]

In [25]: test = {}                                                                             

In [26]: for obj in objs: 
    ...:     if obj not in test: 
    ...:         test[obj] = 0 
    ...:                                                                                       

In [27]: print(test)                                                                           
{00: 0, 01: 0, 02: 0, 11: 0}

# Or with simple, unconditional, insertion:
In [29]: test = {obj: i for i, obj in enumerate(objs)}                                         

In [30]: test                                                                                  
Out[30]: {00: 57, 01: 62, 02: 63, 11: 60}

(Я повторяю, в то время как ваши значения has не будут конфликтовать сами по себе, внутренний код dict должен уменьшить число в хеш-индексе до индекса в своей хеш-таблице - необязательно по модулю (%) - в противном случае для каждого пустого dict потребуется 2 ** 64 пустых записи, и только если все хеши были только 64-битными)

0 голосов
/ 20 октября 2019

Я сделал больше испытаний. В результате, несмотря на отсутствие документации и предупреждения о том, что что-то может пойти не так,

код, который я написал, никогда не завершается ошибкой .

У меня сейчасдобавили много миллиардов объектов к dict s и set s, на 64-битных и 32-битных платформах, с CPython 2.7 и 3.0 и с PyPy. Я попробовал это на более крупной машине, где я добавил более 2 миллиардов объектов одновременно к одному set. Это сработало отлично. Я никогда не был свидетелем столкновения с кодом, представленным в ОП.

Это не случайность или несчастный случай.

Кто-то приложил некоторые усилия, чтобы обеспечить такое поведение. Загадка в том, почему это не задокументировано?

Лучшее, что я мог разглядеть из других публикаций, заключается в том, что алгоритмы контейнеров каким-то образом теряют уникальность, гарантированную функцией id() в классе OPA, и когда это происходит, происходит коллизия, и вызывается __eq__.

Это может произойти на некоторой платформе и в некоторых реализациях Python. Но везде, где я пробовал, это никогда не происходит .

Это может иметь отношение к паре недокументированных свойств: для любого экземпляра класса obj,

hash( id( obj ) ) == hash( obj )
# and
hash( hash( obj ) ) == hash( obj )

(На самом деле hash( id( x ) ) не всегда hash( x ). Попробуйте x = -2. На самом деле, похоже, что для экземпляров объектов obj, hash( obj ) == id( obj ) >> 16. Но это кажется мне чем-то, что можетбыть зависимым от реализации.)

Чтобы увидеть, когда или как код может сломаться, я проверил код ниже. Идея состоит в том, что если какой-то экземпляр A каким-то образом столкнется с новым экземпляром, его не удастся поместить в набор, поскольку __eq__ не может разорвать связь. Этот код проверяет, происходит ли это когда-либо. Я никогда не видел это. Пожалуйста, попробуйте сами, и дайте мне знать, какую ОС, какую версию Python вы используете!

Будьте осторожны - вы можете израсходовать все свои системные ресурсы и вывести компьютер из строя. Поднимите консоль и запустите top, чтобы увидеть, что происходит. Используя определение OP class A:

from __future__ import print_function
from sys import stdout

class A( object ):
    def __init__( self, v1, v2 ):
            self.v = ( v1, v2 )

    def __eq__( self, b ):
            return self.v[0] == b.v[0] and self.v[1] == b.v[1]

    def __hash__( self ):
            return id( self )

    def __str__( self ):
            return str( self.v )

NINSTANCES = 3000000    # play with this number -- carefully!
STATUS_INTERVAL = 100000

def test():
        """ hammer the set algorithms """
        s = set()
        instances = []
        for i in range( 0, NINSTANCES ):
                p = A( 1, 0 )
                s.add( p )
                instances.append( p )
                if not i % STATUS_INTERVAL:
                        stdout.write( str( i // STATUS_INTERVAL ) + " " )
                        stdout.flush()
        stdout.write( "\n" )

        print( "length of set", len( s ) )
        print( "number of instances", len( instances ) )

        for i in instances:
                if not i in s:
                        print( "INSTANCE DROPPED OUT!" )
test()
...