Skip to content

Change events for AtomHashMap and the STM system (via Ref)

Pre-release
Pre-release
Compare
Choose a tag to compare
@louthy louthy released this 09 Mar 15:13
· 766 commits to main since this release

As requested by @CK-LinoPro in this Issue. AtomHashMap and Ref now have Change events.

Change<A>

Change<A> is a new union-type that represents change to a value, and is used by AtomHashMap and TrackingHashMap.

You can pattern-match on the Change value to find out what happened:

public string WhatHappened(Change<A> change) =>
    change switch
    {
        EntryRemoved<A> (var oldValue)      => $"Value removed: {oldValue}",
        EntryAdded<A> (var value)           => $"Value added: {value}",
        EntryMapped<A, A>(var from, var to) => $"Value mapped from: {from}, to: {to}",
        _                                   => "No change"
    };

EntryMapped<A, B> is derived from EntryMappedFrom<A> and EntryMappedTo<B>, so for any A -> B mapping change, you can just match on the destination value:

public string WhatHappened(Change<A> change) =>
    change switch
    {
        EntryRemoved<A> (var oldValue)      => $"Value removed: {oldValue}",
        EntryAdded<A> (var value)           => $"Value added: {value}",
        EntryMappedTo<A>(var to)            => $"Value mapped from: something, to: {to}",
        _                                   => "No change"
    };

That avoids jumping through type-level hoops to see any changes!

There are also various 'helper' properties and methods for working with the derived types:

Member Description
HasNoChange true if the derived-type is a NoChange<A>
HasChanged true if the derived-type is one-of EntryRemoved<A> or EntryAdded<A> or EntryMapped<_, A>
HasAdded true if the derived-type is a EntryAdded<A>
HasRemoved true if the derived-type is a EntryRemoved<A>
HasMapped true if the derived-type is a EntryMapped<A, A>
HasMappedFrom<FROM>() true if the derived-type is a EntryMappedFrom<FROM>
ToOption() Gives the latest value from the Change, as long as the Change is one-of EntryAdded or EntryMapped or EntryMappedTo

There are also constructor functions to build your own Change values.

AtomHashMap<K, V> and AtomHashMap<EqK, K, V>

The two variants of AtomHashMap both now have Change events that can be subscribed to. They emit a HashMapPatch<K, V> value, which contains three fields:

Field Description
From HashMap<K, V> that is the state before the change
To HashMap<K, V> that is the state after the change
Changes HashMap<K, Change<V>> that describes the changes to each key

There are three related Rx.NET extensions in the LanguageExt.Rx package:

AtomHashMap Extension Description
OnChange() Observable stream of HashMapPatch<K, V>
OnMapChange() Observable stream of HashMap<K, V>, which represents the latest snapshot of the AtomHashMap
OnEntryChange() Observable stream of (K, Change<V>), which represents the change to any key within the AtomHashMap

Example

var xs = AtomHashMap<string, int>();

xs.OnEntryChange().Subscribe(pair => Console.WriteLine(pair));
            
xs.Add("Hello", 456);
xs.SetItem("Hello", 123);
xs.Remove("Hello");
xs.Remove("Hello");

Running the code above yields:

(Hello, +456)
(Hello, 456 -> 123)
(Hello, -123)

Swap method (potential) breaking-change

The implementation of Swap has changed. It now expects a Func<TrackingHashMap<K, V>, TrackingHashMap<K, V>> delegate instead of a Func<HashMap<K, V>, HashMap<K, V>> delegate. This is so the Swap method can keep track of arbitrary changes during the invocation of the delegate, and then emit them as events after successfully committing the result.

Ref<A>

Refs are used with the atomic(() => ...), snapshot(() => ...), and serial(() => ...) STM transactions. Their changes are now tracked during a transaction, and are then (if the transaction is successful) emitted on the Ref<A>.Change event. These simply publish the latest value.

As before there's Rx extensions for this:

Ref Extension Description
OnChange() provides an observable stream of values

Example

var rx = Ref("Hello");
var ry = Ref("World");

Observable.Merge(rx.OnChange(), 
                 ry.OnChange())
          .Subscribe(v => Console.WriteLine(v));

atomic(() =>
{
    swap(rx, x => $"1. {x}");
    swap(ry, y => $"2. {y}");
});

This outputs:

1. Hello
2. World

TrackingHashMap

This is a new immutable data-structure which is mostly a clone of HashMap; but one that allows for changes to be tracked. This is completely standalone, and not related to the AtomHashMap in any way other than it's used by the AtomHashMap.Swap method. And so, this has use-cases of its own.

Changes are tracked as a HashMap<K, Change<V>>. That means there's at most one change-value stored per-key. So, there's no risk of an ever expanding log of changes, because there is no log! The changes that are tracked are from the 'last snapshot point'. Which is from the empty-map or from the last state where trackingHashMap.Snapshot() is called.

Example 1

var thm = TrackingHashMap<int, string>();

Console.WriteLine(thm.Changes);

thm = thm.Add(100, "Hello");
thm = thm.SetItem(100, "World");

Console.WriteLine(thm.Changes);

This will output:

[]
[(100: +World)]

Note the +, this indicates an Change.EntryAdded. And so there has been a single 'add' of the key-value pair (100, World). The "Hello" value is ignored, because from the point of the snapshot: a value has been added. That's all we care about.

If I take a snapshot halfway through, you can see how this changes the output:

var thm = TrackingHashMap<int, string>();

thm = thm.Add(100, "World");
thm = thm.Snapshot();
thm = thm.SetItem(100, "Hello");

Console.WriteLine(thm.Changes);

This outputs:

[(100: World -> Hello)]

So the snapshot is from when there was a (100, World) pair in the map.

Hopefully that gives an indication of how this works!