diff --git a/changelog.d/1630.change.md b/changelog.d/1630.change.md new file mode 100644 index 000000000..4641f8b62 --- /dev/null +++ b/changelog.d/1630.change.md @@ -0,0 +1,6 @@ +Generated `__hash__` methods now mix in `id(type(self))` instead of a +class identity captured when the method was generated. + +Undecorated subclasses of frozen classes therefore no longer share hash +values for equal field contents. +[#528](https://github.com/python-attrs/attrs/issues/528) diff --git a/docs/hashing.md b/docs/hashing.md index 231d818af..d9f48600e 100644 --- a/docs/hashing.md +++ b/docs/hashing.md @@ -21,7 +21,7 @@ It can be obtained by calling {func}`hash` on an object and is implemented by wr *attrs* will happily write a `__hash__` method for you [^fn1], however it will *not* do so by default. Because according to the [definition](https://docs.python.org/3/glossary.html#term-hashable) from the official Python docs, the returned hash has to fulfill certain constraints: -[^fn1]: The hash is computed by hashing a tuple that consists of a unique id for the class plus all attribute values. +[^fn1]: The hash is computed by hashing a tuple that consists of `id(type(self))` plus all attribute values. 1. Two objects that are equal, **must** have the same hash. This means that if `x == y`, it *must* follow that `hash(x) == hash(y)`. diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..2f1107893 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1650,7 +1650,6 @@ def _make_hash_script( tab = " " - type_hash = hash(_generate_unique_filename(cls, "hash")) # If eq is custom generated, we need to include the functions in globs globs = {} @@ -1678,7 +1677,9 @@ def append_hash_computation_lines(prefix, indent): method_lines.extend( [ indent + prefix + hash_func, - indent + f" {type_hash},", + # Use the instance class, not the class that generated + # ``__hash__``, so undecorated subclasses do not collide. + indent + " id(type(self)),", ] ) diff --git a/tests/test_dunders.py b/tests/test_dunders.py index f299f777a..d57ad30be 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -621,6 +621,37 @@ def test_hash_works(self, cls): # __hash__ for the cached-hash versions assert hash(a) != hash(b) + @pytest.mark.parametrize("slots", [True, False]) + @pytest.mark.parametrize("cache_hash", [True, False]) + def test_inherited_hash_includes_instance_type(self, slots, cache_hash): + """ + Generated `__hash__` mixes in `type(self)`, not only the class that + defined the method. + + Undecorated subclasses therefore get distinct hashes even though they + inherit `__hash__` from the base class. See GH #528. + """ + Base = make_class( + "Base", + {"x": attr.ib()}, + frozen=True, + slots=slots, + cache_hash=cache_hash, + ) + + class SubA(Base): + pass + + class SubB(Base): + pass + + assert SubA(1) != SubB(1) + assert SubA(1) != Base(1) + assert hash(SubA(1)) != hash(SubB(1)) + assert hash(SubA(1)) != hash(Base(1)) + assert hash(SubA(1)) == hash(SubA(1)) + assert hash(SubA(1)) != hash(SubA(2)) + def test_hash_default(self): """ Classes are not hashable by default.