Vixen
dd8b0a81
csharp
public sealed class SignalDictionary<TKey, TValue> where TKey : notnull

A map that is written into rather than replaced, and notifies either way.

Read the guide page for this →

Remarks

CollectionSignal`1's sibling for the keyed shape, and it exists for one measured reason. Without it a live map is a Signal<ImmutableDictionary<K, V>> — correct, because replacing the map is the notification, and paid for with a rebalanced spine of tree nodes on every single write. RemoteInspectorClient's counters are the case that asked for this: a build reports its frame rate every frame, and saying that one moved allocated a handful of objects. An in-place write allocates nothing, and this type is that write with the notification still attached.

⚠ One reactive node for the whole map, not one per key — which is CollectionSignal`1's choice, taken here for the same reasons and one more. Every read subscribes to the map as a whole, so a binding that reads map["fps"] is woken when map["draws"] is written. That over-approximates the dependency and never under-approximates it: the cost of the coarse edge is a re-run, never a stale answer, and a binding that re-runs and computes the same string writes nothing further because equality stops the propagation one level up. Both dependency shapes — reading one key, and enumerating the lot — are therefore supported and neither is silently wrong.

The per-key alternative is worse than it sounds. A binding that reads a key which is not there yet must still be woken when it appears, so a per-key node would have to be created on read as well as on write, and kept after removal — which turns a map of twelve counters into an unbounded set of nodes keyed by whatever strings the callers happened to ask about. And the fine-grained path a UI actually wants from a map is not "this key moved" in the first place: @for cannot bind to a dictionary at all, because a dictionary has no order, so a pane of live numbers binds to a sorted projection of it and the reconciler's keys come from there.

⚠ Which is also why there is no change log here, and that is the one half of CollectionSignal`1 deliberately not carried across. A list's log earns its per-write cost because a keyed reconciler reads it and turns "inserted at 3" into one appended row instead of ten thousand rebuilt ones. Nothing reads a map's, for the reason in the paragraph above — the projection is what gets reconciled — so a log here would be a ring buffer written on every counter update and read by nobody, which is the cost this type was built to remove, reintroduced under a different name.

⚠ Equality still stops propagation, and it is checked per key. Writing a value the comparer already agrees with does nothing at all — no version bump, no notification, no effect run — which is Signal`1's central property and matters more here than anywhere: the counters map is written from a poll that runs every frame, and a build reporting an unchanged number has to cost the panel nothing. ⚠ For a mutable reference value the default comparer is the wrong answer for exactly the reason Signal`1 gives, and the answer is the same: hold an immutable value, or pass Never``1 and accept that every write propagates.

Reads and writes assert OwningThread, and a write only ever queues: it marks dependents dirty and evaluates nothing, so the effects it wakes run when Flush says so and not on the line that wrote the key. Both are ADR-007's contract and neither is different here.

⚠ The name is the wrong way round from CollectionSignal`1's, and that is the analyzer's call rather than a slip. DictionarySignal is what symmetry asks for and CA1710 refuses it: a type implementing IReadOnlyDictionary`2 has to end in Dictionary or Collection, and CollectionSignal`1 escapes the same rule only because IReadOnlyList`1 is not on its list. Suppressing it was the other option and is not worth it — the suffix is what tells a caller that foreach over this yields KeyValuePair`2 — so the words swap and the sibling is found by its namespace instead.

Fields and properties (4)

  • public int Count

    How many entries there are.

  • public Dictionary<TKey, TValue>.KeyCollection Keys

    The keys, in the map's own arbitrary order.

  • public Dictionary<TKey, TValue>.ValueCollection Values

    The values, in the same arbitrary order as Keys.

  • public TValue this[TKey key]

    The value stored under .

Methods (9)

  • public SignalDictionary(IEqualityComparer<TKey>? keyComparer = null, IEqualityComparer<TValue>? valueComparer = null)

    Creates an empty map.

  • public SignalDictionary(IEnumerable<KeyValuePair<TKey, TValue>> initial, IEqualityComparer<TKey>? keyComparer = null, IEqualityComparer<TValue>? valueComparer = null)

    Creates a map holding .

  • public bool ContainsKey(TKey key)

    Whether there is an entry under .

  • public bool TryGetValue(TKey key, out TValue value)

    Reads an entry if it is there.

  • public IReadOnlyDictionary<TKey, TValue> Peek()

    The entries, without recording a dependency.

  • public bool TryPeek(TKey key, out TValue value)

    Reads one entry without recording a dependency.

  • public bool Remove(TKey key)

    Removes an entry.

  • public void Clear()

    Removes everything.

  • public Dictionary<TKey, TValue>.Enumerator GetEnumerator()

    Enumerates the entries, recording a dependency on the map.

Used by (2)

  • SignalDictionaryTestsVixen.Ui.Reactive.Tests
  • RemoteInspectorClientVixen.Editor.Debugger