Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.d/1630.change.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion docs/hashing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
5 changes: 3 additions & 2 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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)),",
]
)

Expand Down
31 changes: 31 additions & 0 deletions tests/test_dunders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading