Skip to content

Remove __eq__ and __hash__ in 2.0; compare names via explicit methods #223

Description

@derek73

Summary

2.0 will remove HumanName.__eq__ and HumanName.__hash__. Instances revert to Python's default identity semantics (still safely usable in sets/dicts — see below). Value comparison moves to two explicit methods, shipped in 1.3.0 so the APIs overlap for a full minor release: matches() and comparison_key() (added in PR #247).

Feedback welcome — this is a breaking change and the rationale is laid out in full below. The deprecation warnings shipped in 1.3.0 (#224, implemented in PR #247; released).

Why the current design can't be fixed in place

__eq__ currently compares str(self).lower() against str(other).lower(), and __hash__ (added in 1.2.0, #138) hashes that string. This makes three promises:

  1. Case-insensitive equality
  2. Cross-type equality with plain strings (hn == "John Smith" → True)
  3. Hashability (usable in sets/dicts)

These are mathematically incompatible. If hn == "John Smith" and hn == "john smith" are both True, the hash invariant (a == bhash(a) == hash(b)) requires hash(hn) to equal two different values. Consequence today: {hn} | {"John Smith"} is a two-element set whose elements compare equal — broken dedup, the very use case #138 added hashing for. No implementation can fix this; one of the three promises has to go.

Additional defects in the current design

  • Mutation strands container entries. HumanName is mutable; assigning any attribute changes the hash, so an instance already in a set/dict becomes unfindable. (Python nulls __hash__ when a class defines __eq__ for exactly this reason — Make HumanName objects hashable #138 re-enabled what the language had disabled.)
  • Equality depends on display config. str(self) goes through string_format, so changing CONSTANTS.string_format retroactively changes whether two existing objects are equal. Same class of shared-mutable-state problem as Stop parsing from mutating the Constants it reads #222.
  • maiden is invisible to equality — the default string_format has no {maiden} placeholder.
  • hn == 42 can be True (if the name is "42"); __eq__ stringifies any operand instead of returning NotImplemented.

Why removal rather than component-based __eq__/__hash__

A component-based == was considered and is workable, but it still privileges one equality semantic for a domain that has none (is "J. Smith" equal to "John Smith"? "Smith, John"? every application answers differently), and still carries the mutate-while-hashed caveat. Removing both dunders is the only option with no footguns left: default identity hash is always stable and always consistent with identity equality, so sets/dicts of HumanName keep working — they just mean "these objects" rather than "these name values". Value semantics become explicit:

  • hn.matches(other) — parses a string operand, compares parsed components
  • hn.comparison_key() — hashable tuple of components, for dedup/dicts/groupby

Migration (from 1.3.0's deprecated ==)

Before After
hn == x hn.matches(x)
"john smith" == hn (reflected) hn.matches("john smith") — must flip operands
hn == maybe_none maybe_none is not None and hn.matches(maybe_none)matches() raises TypeError on non-str/HumanName
hn in names, .index(), assertIn any(hn.matches(n) for n in names) or comparison_key() sets
set/dict dedup via hash(hn) {n.comparison_key(): n for n in names}.values()

Users with a lossy custom string_format (e.g. "{first} {last}") currently get projection equality; matches() compares all components and is stricter. Compare the specific attributes instead.

Checklist

  • Remove __eq__ and __hash__ from HumanName
  • Replace equality/hash tests with identity-semantics tests
  • Update the docs/usage.rst deprecation note (the equality example already uses matches() as of 1.3.0)
  • Migration section in docs + release log entry

Metadata

Metadata

Assignees

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions