Some standard libraries let a hash container carry its own equality: C++'s std::unordered_map takes Hash and KeyEqual template arguments, and .NET's Dictionary<TKey,TValue> accepts an IEqualityComparer<T> at construction. Others fix the relation on the key type — java.util.HashMap has no such hook, and Go's map always uses the language's == operator. Suppose you need one case-insensitive, Unicode-normalised index over strings while ordinary string equality stays exact everywhere else in the program. How do you build that on each side of the divide, and what is the design argument for the keying relation belonging to the container rather than to the type?
answer
- Type answers "same value?"; index answers "same key here?"
- C++ Hash + KeyEqual template args; .NET IEqualityComparer at construction
- Rust parameterises the hasher only — Eq still comes from K
- Java/Go: no hash-map hook — canonicalise or newtype the key
- Equal keys must hash equal; copies silently drop the comparer
basics
~20 sA lookup relation belongs to the index, not the type. C++ and .NET let a container carry its own hash-and-equality pair; Java's HashMap and Go's map cannot, so you canonicalise or wrap the key instead. Never loosen the type's own equality.
solid answer
~50 sThese are two different questions. "Are these the same value?" belongs to the type. "Should these collide in *this* index?" belongs to the index — and one type can need several indexes at once. - **C++**: `std::unordered_map<K, V, Hash, KeyEqual>` — both relations are template parameters, may be stateful, and are supplied per container. C++20 transparent comparators (`is_transparent`) also enable heterogeneous lookup. - **.NET**: pass an `IEqualityComparer<T>` (Equals and GetHashCode bundled) at construction — e.g. `StringComparer.OrdinalIgnoreCase`. - **Rust**: only the *hasher* is parameterised (`HashMap<K, V, S: BuildHasher>`); equality always comes from `K: Eq`, so a case-insensitive index needs a newtype wrapper. - **Java, Go, Python, Swift**: no hash-container hook. Java's `TreeMap` takes a `Comparator` and keys by `compare() == 0` — deliberately inconsistent with `equals`. Otherwise normalise to NFC, case-fold, and store the canonical key, or wrap it in a dedicated key type. Whichever slot you use: equal keys must hash equal, and the relation must stay stable while the key is stored.
code
text · 7 linesTHREE PLACES THE RELATION CAN LIVE
(1) on the TYPE key.equals(other) one relation, global, unlosable
(2) on the CONTAINER map(hash=H, eq=E) many relations, config travels badly
(3) on the KEY map[Folded(key)] many relations, visible in the type
no-slot languages (Java, Go, Python, Swift) have (1) and (3) onlygo deeper
Know that a hash map decides matches using an equality plus a hash, and that the two must agree — equal keys must hash equal. Know that some libraries let you pass a comparer to the container and some do not.
Be able to name the mechanism on each side: C++ Hash/KeyEqual template arguments, .NET IEqualityComparer at construction, versus Java HashMap and Go maps with no hook. Give the portable fallback: canonicalise the key or wrap it in a key type, never loosen the type's own equality.
Frame it as a design axis — the type answers "same value?", the index answers "same key here?" — and reason about the trade: expressiveness against configuration that travels badly. Mention the concrete traps: dropped comparers on copy or serialization, locale-dependent case folding, Unicode normalisation belonging to the index.
Decide policy for a codebase: where the canonical form is produced, whether looseness lives in a comparer or in a distinct key type visible in signatures, and how that survives persistence and service boundaries. Argue when the one-relation-per-type discipline of Java and Go is actually worth its wrapper cost.
## Two questions that look like one Every hash container answers a question when you look something up: *is this probe key the same as that stored key?* Programmers usually assume the answer comes from the key type — from its `equals`, `__eq__`, `operator==`, or the language's built-in `==`. But the type answers a general question ("are these two things the same value?"), while a container answers a local one ("should these two things collide in this particular index?"). A single string type may simultaneously need an exact index, a case-insensitive index for usernames, and a Unicode-normalised index for filenames. Those are three relations over one type. Only one of them can be the type's own. That is why some standard libraries put a slot for the relation on the **container**, and it is the cleanest way to characterise how libraries differ here. ## Where the slot lives, language by language **C++ gives the container the slot outright.** `std::unordered_map<Key, T, Hash, KeyEqual, Allocator>` takes hashing and equality as template parameters; `std::map` takes a `Compare` and defines equivalence as `!(a < b) && !(b < a)`. Comparators may carry state and are passed at construction, so a locale-aware or normalising comparator is an ordinary object. Since C++20 (C++14 for the ordered containers), a comparator marked `is_transparent` also enables *heterogeneous lookup*: probing with a `string_view` without materialising a `string`. **.NET bundles the pair into one interface.** `Dictionary<TKey,TValue>` and `HashSet<T>` accept an `IEqualityComparer<T>`, which supplies both `Equals` and `GetHashCode`, at construction. `StringComparer.OrdinalIgnoreCase` is the everyday instance. Bundling is not cosmetic: it makes it structurally hard to supply an equality without the matching hash. **Rust gives you half the slot.** `HashMap<K, V, S = RandomState>` parameterises the *hasher* only; equality always comes from `K: Eq`. Swapping `S` changes distribution and hash-flooding resistance, never which keys are considered equal. A case-insensitive Rust index therefore needs a newtype wrapper implementing `PartialEq`/`Eq`/`Hash` over the folded form. **Java, Go, Python and Swift give the hash container no slot at all.** `HashMap` uses the key's own `hashCode`/`equals`; Go compares map keys with `==` on comparable types with no hook whatsoever; Python uses `__hash__`/`__eq__`; Swift requires `Hashable` on the type. Java's partial compensations are revealing: `TreeMap`/`TreeSet` *do* take a `Comparator` and key by `compare() == 0`, which the `SortedMap` contract openly documents as being inconsistent with `equals`; and `IdentityHashMap` had to ship as a **separate class** precisely because the relation could not be parameterised. ## Building the index on the no-slot side Two honest techniques, both of which move the relation off the general type: 1. **Canonicalise the key at the boundary.** Normalise to NFC, apply case folding once, store and probe with that canonical string. Cheap and portable; the cost is that the map no longer holds the original spelling, so keep it in the value if you need it back. 2. **Wrap the key in a dedicated type** — a `CaseInsensitiveKey` whose equality and hash are defined over the folded form. This puts the relation in the type system where a reader can see it, at the price of a wrapper allocation and conversion at every call site. What you must not do is redefine the string type's general equality, or push a looser `equals` onto your own domain type because one index wanted it. That makes every other comparison in the program lie. ## The invariants no slot exempts you from Whichever mechanism you pick, the relation must be an **equivalence relation** — reflexive, symmetric, transitive — and the hash must be **compatible**: if two keys are equal, they must hash equal. The classic self-inflicted bug is folding case in the equality function but hashing the raw string; equal keys then land in different buckets and lookups miss silently. Unicode makes this easy to get wrong: U+212A KELVIN SIGN folds to `k`, and full folding maps `ß` to `ss`, so an ad-hoc lowercase in one function and a different fold in the other quietly break compatibility. The relation must also stay **stable** while a key is stored — mutating a key already in a map is a logic error in every language named above. ## The trade the no-slot languages are making Container-owned relations are more expressive, but the behaviour of a lookup now depends on configuration supplied far from the call site — and that configuration is easy to lose. Copying a .NET dictionary with `new Dictionary<string,V>(existing)` uses the default comparer unless you pass one explicitly; `ToDictionary()` without a comparer argument does the same; and JSON round-tripping drops it entirely. A case-insensitive index silently becomes case-sensitive with no type error. Culture-sensitive comparers add a second hazard: under a Turkish locale, `I` folds to `ı`, so membership can differ by host. Prefer ordinal comparers for identifiers. Java and Go are buying the opposite property: one relation per type, globally checkable, impossible to lose in a copy. The cost is a wrapper type every time you need a second relation. Neither answer is wrong — but you should be able to say which regime you are in, and where your index's relation is written down.
- One index needs case-insensitive matching. Why not just relax the key type's own equality to ignore case?Because the type's equality is used by everything — other maps, sets, assertions, deduplication, distinctness checks — and loosening it makes all of them silently agree that two genuinely different values are one. Equality is a claim about the values; an index's relation is a claim about that index only. Keep the type's relation exact and push the looseness into the container's comparator, a canonicalised key, or a dedicated wrapper type where a reader can see it.
- Rust's HashMap has a third type parameter. Can you use it to get a case-insensitive index?No. That parameter is a `BuildHasher`; it controls how keys are hashed, which affects distribution and resistance to hash-flooding attacks, not which keys are considered equal. Equality always comes from the `Eq` implementation on the key type. A case-insensitive Rust index needs a newtype wrapping the string with `PartialEq`/`Eq`/`Hash` defined over the folded form — Rust deliberately kept the equality slot on the type.
- What goes wrong operationally with .NET dictionaries that carry a non-default comparer?The comparer is construction-site configuration that is easy to drop. Copying with the `Dictionary(IDictionary)` constructor, or building one via `ToDictionary()` without the comparer overload, falls back to the default comparer, so a case-insensitive map quietly becomes case-sensitive with no compile error. Serialization round-trips lose it too. Also prefer ordinal over culture-sensitive comparers: under a Turkish locale `I` folds to `ı`, so lookups would differ by host.
- What must any keying relation satisfy, regardless of which language slot you put it in?It must be an equivalence relation — reflexive, symmetric and transitive — and the hash must be compatible with it, meaning equal keys always hash equal. It must also stay stable for as long as the key is stored; mutating a key that is already in a map is a logic error everywhere. Deriving both the hash and the comparison from one canonical form is the reliable way to guarantee compatibility.
A library's catalogue can be indexed by exact title, by title ignoring a leading "The", and by author surname. Those are three indexes over one collection of books — none of them is a claim that two different books are the same book.
saying these in an interview costs you the question
- Believing java.util.HashMap accepts a Comparator or comparer — it has no hook; only the sorted containers (TreeMap/TreeSet) take one.
- Overriding a domain type's equals/hashCode to be case-insensitive because one index wanted it, corrupting every other comparison in the program.
- Supplying a custom equality without a matching hash — folding case in the comparison but hashing the raw string, so lookups miss silently.
- Thinking Rust's BuildHasher type parameter changes which keys are equal; it changes only hashing, and Eq stays on the key type.
- Assuming a .NET dictionary's comparer survives a copy, a ToDictionary() call, or serialization — it silently reverts to the default.
- Reaching for a culture-sensitive case-insensitive comparer for identifiers, making membership depend on the host's locale.