skip to content

A Dart Set of map Coordinate objects stops finding elements and shows duplicates after coordinates are edited in place; what is wrong, and how do you fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. stored under the old hash
  2. buckets are not recomputed
  3. fields that feed hashCode changed
  4. final fields, copyWith, remove then add
  5. avoid_equals_and_hash_code_on_mutable_classes

basics

~20 s

The Set stored each Coordinate under the hashCode it had when added; mutating a field that feeds hashCode changes the hash, so lookups probe the wrong bucket. Make the class immutable and replace elements with remove then add.

solid answer

~50 s

Dart's default `Set` is a hash table: `add` computes `hashCode` once and stores the element in that bucket, and `contains`, `remove` and later `add` calls look only in the bucket of the *current* hash. When code sets `pin.lat = ...` on an element already in the set, its hash changes but its position does not, so `contains(pin)` can return `false`, `remove(pin)` can fail, and adding an equal coordinate can insert a second copy. The fix is to make value types immutable: `final` fields, a `const` constructor, `@immutable`, and a `copyWith` that returns a new instance. To move a pin, `remove` the old value and `add` the new one. If an object must stay mutable, do not base `==` and `hashCode` on its mutable fields; key it by an immutable id instead. The opt-in `avoid_equals_and_hash_code_on_mutable_classes` lint flags the pattern.

code

dart · 23 lines
dart
class Coordinate {
  Coordinate(this.lat, this.lng);

  double lat; // mutable field that feeds hashCode: the bug
  double lng;

  @override
  bool operator ==(Object other) =>
      other is Coordinate && other.lat == lat && other.lng == lng;

  @override
  int get hashCode => Object.hash(lat, lng);
}

void main() {
  final pin = Coordinate(52.52, 13.40);
  final pins = {pin};

  pin.lat = 48.85; // hash changes, stored position does not
  print(pins.contains(pin)); // may print false: looked up under the new hash
  pins.add(Coordinate(48.85, 13.40));
  print(pins.length); // may print 2: an equal element stored twice
}

go deeper

for a junior

Recall that objects used as set elements or map keys should have final fields, because their hash code must not change.

for a middle

Explain that a hash set stores each element under the hash it had on insertion and looks up by the argument's current hash.

for a senior

Diagnose the intermittent lookup failure, fix it with immutable values and remove-then-add, and catch the persisted-hash mistake.

for a principal

Separate value types from mutable entities in the domain model and enforce it with @immutable and the mutable-class equality lint.

## The symptom A map screen keeps the visited or pinned locations in a `Set<Coordinate>`. `Coordinate` overrides `==` and `hashCode` correctly over `lat` and `lng`, but its fields are not `final`, and a drag handler updates `pin.lat` and `pin.lng` in place. Afterwards: - `pins.contains(pin)` can return `false` for an object that is in the set; - `pins.remove(pin)` can do nothing; - adding a new `Coordinate` equal to the edited one can succeed, producing an apparent duplicate; - the behaviour is intermittent, because it depends on where the new hash lands. ## Why it happens The default `Set` is a `LinkedHashSet` and the default `Map` a `LinkedHashMap`. Both are **hash tables**: 1. On `add`, the set calls `element.hashCode` once and stores the element in the bucket that hash selects. 2. On `contains`, `remove` or a later `add`, it computes the **argument's current** hash, goes to that bucket, and calls `==` only on the entries it finds there. 3. The table **never recomputes** the hash of an element already stored; it has no way to know the object changed. Mutating a field that feeds `hashCode` changes the hash the object would report, while the object sits under its old one. Lookups now look in the wrong place. Sometimes the new hash happens to lead to the same slot and the lookup still works, which is why the bug is intermittent rather than consistent. `Object`'s documentation states the requirement directly: **the hash code should only change if the object changes in a way that affects equality**, and hash-based collections assume that does not happen while the object is inside them. ## The fix: immutable value types | Before | After | |---|---| | `double lat;` | `final double lat;` | | `Coordinate(this.lat, this.lng);` | `const Coordinate(this.lat, this.lng);` plus `@immutable` | | `pin.lat = 48.85;` | `final moved = pin.copyWith(lat: 48.85);` | | mutate while in the set | `pins..remove(pin)..add(moved);` | With `final` fields, an object's hash can never change after construction, so the set's buckets stay valid. `@immutable`, from Flutter's `foundation` library or `package:meta`, makes the analyzer report `must_be_immutable` for any non-final field in the class or its subclasses. ## When the object must be mutable Some objects are entities rather than values: a marker whose position changes but whose identity stays. Then: 1. **Do not override `==` and `hashCode` over mutable state.** Keep identity equality, which is stable for the object's lifetime. 2. **Key by an immutable identifier.** Use `Map<String, Marker>` keyed by `marker.id`, or override `==` and `hashCode` on the `id` field only, if it is `final`. 3. **Take the element out before mutating it, and put it back afterwards**, if you truly must mutate a key in place. It is fragile and easy to forget. ## A related trap: persisting hash codes `Object.hash`, `Object.hashAll` and `Object.hashAllUnordered` are **not guaranteed stable across runs**, across isolates of the same program, across platforms or across SDK versions; they may mix in a per-run seed. So `hashCode` is fine for in-memory tables but wrong as: - a deduplication key saved to local storage or a database; - an identifier sent to a server; - a cache file name meant to survive a restart. For persistent keys, derive a canonical string, such as fixed-precision latitude and longitude, or use a real cryptographic digest. ## A checklist for value types kept in collections - Every field that `==` or `hashCode` reads is `final`, and any collection field is unmodifiable. - The class is annotated `@immutable`, so a subclass cannot quietly add a mutable field. - Updates go through `copyWith` and replace the element: remove the old value first, then add the new one. - Anything persisted or sent over the network uses a canonical, documented key rather than `hashCode`. - Entities with changing state keep identity equality, or equality on a `final` id only. ## Tooling - **`hash_and_equals`**, in the `core`, `recommended` and `flutter` rule sets, catches overriding one member without the other. - **`avoid_equals_and_hash_code_on_mutable_classes`** flags classes that override `==` and `hashCode` without being marked `@immutable`. It is in no default set, so enable it in `analysis_options.yaml`. - Code generators and `package:equatable` produce correct `==` and `hashCode`, but they do not make fields `final` for you; immutability is still your job.

  • Why is the failure intermittent instead of happening every time?
    After the mutation the lookup uses the new hash. If that hash happens to select the same slot where the element was stored, the table still finds it and `==` succeeds. With other values it probes elsewhere and misses. So the outcome depends on the numbers, which makes the bug hard to reproduce.
  • A teammate stores Object.hash(lat, lng) in local storage to skip already-synced points after a restart. What is the problem?
    `Object.hash` is only guaranteed consistent within one run of one isolate; its algorithm and seed may differ across runs, platforms and SDK versions. After a restart the same point can hash differently, so deduplication silently fails. Persist a canonical key instead, such as a fixed-precision string of the coordinates.

A filing cabinet sorted by surname: if someone changes surname after their folder is filed, looking under the new letter finds nothing, even though the folder is still in the cabinet under the old one.

saying these in an interview costs you the question

  • A Set rehashes its elements automatically when their fields change.
  • A hash set finds elements with == alone, so hashCode changes do not matter.
  • Object.hash values are stable across app restarts and can be persisted.
  • Marking the class @immutable makes existing mutable fields read-only at runtime.
  • Mutating a set element throws ConcurrentModificationError.