A reactive hash table that mirrors the structure of an object into a dotted path key/value store you can observe and update.
- Targets: .NET Standard 2.0, .NET 8, .NET 9
- Dependencies: System.Reactive
HashTableRx lets you:
- Reflect an object (even from an unknown assembly) into a hierarchical hash table.
- Get and set values using dotted path keys like "Process.Temperature.PV.Value".
- Observe individual variables or all changes as IObservable streams.
- Toggle case sensitivity via UseUpperCase.
- NuGet:
dotnet add package HashTableRx
- Or reference the HashTableRx project in your solution.
- Dotted variable paths: Nested members are addressed with dot separators, e.g. "A.B.C".
- Primitive-like members: primitives and string (including arrays of these) are treated as values.
- Complex members: non-primitive-like types become nested HashTableRx nodes.
- Case handling: When
UseUpperCase
is true, all keys are normalized to uppercase for reads/writes/observations. - Value API: Reading works with
Value<T>(path)
. Writing withValue(path, value)
requires the variable to already exist; otherwiseInvalidVariableException
is thrown (to prevent silent writes). - Indexer API: The string indexer always creates intermediate nodes as needed.
using CP.Collections;
using System.Reactive.Linq;
// Create a reactive hash table (case sensitive keys)
var h = new HashTableRx(useUpperCase: false);
// Create a few variables using the string indexer
h["System.Online"] = true; // bool
h["Process.Temperature.CV"] = 20f; // float
// Read variables
bool online = h.Value<bool>("System.Online") ?? false;
float temp = h.Value<float>("Process.Temperature.CV") ?? 0f;
// Observe changes to an individual variable
var sub1 = h.Observe<float>("Process.Temperature.CV")
.Subscribe(v => Console.WriteLine($"Temp changed to {v}"));
// Update a value (variable must already exist for Value(..) write)
h.Value("Process.Temperature.CV", 25f);
// Or use the indexer to create and/or set directly (creates if missing)
h["Process.Temperature.SP"] = 30f;
// Observe all changes
var sub2 = h.ObserveAll
.Subscribe(kv => Console.WriteLine($"{kv.key} => {kv.value}"));
// Cleanup
sub1.Dispose();
sub2.Dispose();
var h = new HashTableRx(useUpperCase: true);
// All keys are normalized to uppercase internally
h["Rig.Temp.PV"] = 10f;
Console.WriteLine(h.Value<float>("rig.temp.pv")); // 10
Console.WriteLine(h.Value<float>("RIG.TEMP.PV")); // 10
// Observations also normalize
using var sub = h.Observe<float>("rig.temp.pv").Subscribe(v => Console.WriteLine(v));
h.Value("RIG.TEMP.PV", 11f); // Emits 11
You can reflect any object instance (e.g., created via reflection from an external assembly) into the hash table.
using System.Reflection;
using CP.Collections;
// Load an external assembly and create an instance
var asm = Assembly.LoadFrom(@"path\to\UnknownLibrary.dll");
var obj = asm.CreateInstance("Namespace.TypeName");
var h = new HashTableRx(useUpperCase: false);
// Populate the hash table from the object's public fields/properties
h.SetStructure(obj!);
// Now you can read values from reflected primitive-like fields/properties
var pv = h.Value<float>("Some.Structured.Path.PV");
// Update values in the hash table
h.Value("Some.Structured.Path.SP", 42.0f);
// Push current hash table values back to the original object
var updated = h.GetStructure();
// 'updated' is the same instance with primitive-like values written back
Notes:
- Primitive-like = primitive or string (including arrays of those). Complex members become nested nodes.
- When trimming/AOT, reflection is annotated and may require preserving members. See code attributes for details.
-
String indexer
h["A.B.C"]
:- Reading returns the current value (or null if missing).
- Writing creates intermediate nodes as required and sets the value.
-
Value API:
T? Value<T>(string path)
: typed read, returns default when missing.bool Value<T>(string path, T value)
: typed write. ThrowsInvalidVariableException
if the variable does not exist.
Example:
var h = new HashTableRx(false);
// Create then write
h["A.B.C"] = 1;
h.Value("A.B.C", 2); // ok
// Attempting to write unknown path throws
Assert.Throws<InvalidVariableException>(() => h.Value("X.Y.Z", 5));
Observe<T>(path)
: emits typed values on change (distinct until changed).ObserveAll
: emits(key, object?)
for any change (also distinct until changed at the tuple level).
var h = new HashTableRx(false);
// Create initial variable then observe
h["A.B.C"] = 10;
using var sub = h.Observe<int>("A.B.C").Subscribe(v => Console.WriteLine($"A.B.C = {v}"));
h.Value("A.B.C", 10); // may not emit due to DistinctUntilChanged
h.Value("A.B.C", 11); // emits 11
using var subAll = h.ObserveAll.Subscribe(kv => Console.WriteLine($"{kv.key} => {kv.value}"));
h["X.Y"] = 3.14f; // emits ("X.Y", 3.14f)
var h = new HashTableRx(false);
// Writes automatically create intermediate nodes
h["Plant.Unit1.Pump.Speed"] = 1200;
// Reads follow the same structure
int? speed = h.Value<int>("Plant.Unit1.Pump.Speed");
// Switching a scalar to a branch via write
h["Plant.Unit1"] = 123; // was scalar
h["Plant.Unit1.Pump.Speed"] = 900; // Node becomes a nested table to accommodate deeper path
The bool indexer h[true]
is a convenience to reflect the entire object graph to and from the hash table.
var h = new HashTableRx(false);
// Push an object's values into the table
h[true] = myObject; // equivalent to h.SetStructure(myObject)
// Later, materialize current values back into the object
var updated = h[true]; // equivalent to h.GetStructure()
HashTableRx derives from a reactive HashTable that is observable as a sequence of (key, value) updates.
Add(object key, object? value)
: Adds a key/value (also notifies observers).Remove(object key)
: Removes a key (scheduled).Clear()
: Clears all entries (scheduled).Get(object key)
: ReturnsIObservable<(string key, object value)>
that reads the indexer on a scheduler.
var ht = new HashTable();
ht.Add("K", 123);
var (k, v) = ht.Get("K").Wait(); // ("K", 123)
ht.Remove("K");
ht.Clear();
InvalidVariableException
: thrown byValue(path, value)
when the variable does not exist.InvalidCastException
: thrown byValue(path, value)
when the existing variable type is incompatible with value.
try
{
h.Value("A.B.C", "wrong-type");
}
catch (InvalidCastException ex)
{
Console.WriteLine(ex.Message);
}
- Reflection for
SetStructure
/GetStructure
is optimized using compiled expression trees and caching per Type. - Primitive-like members read/write via fast delegates, avoiding slow
PropertyInfo
/FieldInfo.GetValue
/SetValue
loops. - For heavy usage, prefer creating variables once (via indexer or
SetStructure
) and then useValue(path, value)
for updates.
- If you need case-insensitive behavior, construct
HashTableRx
withuseUpperCase: true
. - Prefer
Value<T>(path)
for typed reads. It returnsdefault(T)
when missing instead of throwing. - To write to a brand new path, use the indexer (
h["A.B.C"] = value
) orSetStructure
first.Value(path, value)
enforces that the variable already exists.
MIT