Simple Topologically Ordered Reactive Model
Storm.NET is a simple, topologically ordered, reactive model.
Take a look on this code:
var a = 1;
var b = 2;
var c = a + b;
a = 2;c is evaluated once, its value is 3.
After affecting 2 to a, c didn't reacted and its value is still 3.
Let's make it reactive:
var a = Storm.Input.Create(1);
var b = Storm.Input.Create(2);
var c = Storm.Func.Create(a, b, (aValue, bValue) => aValue + bValue);
a.SetValue(2);This time, c reacted and its value is now 4.
That's a reactive model.
When a reactive model is built, the notion of dependency graph appear quickly. Each item of the model is a node on the graph, each item dependency is an arrow in the graph.
Here is the dependency graph of the previous exemple:
More complex graphs can appear quickly, consider this code:
var input = Storm.Input.Create<int>();
// Let's divide input value by 2
var a = Storm.Func.Create(input, aValue => aValue / 2); // a store the quotient
var b = Storm.Func.Create(input, bValue => bValue % 2); // b store the remainder
// Let's build back the input value
var output = Storm.Func.Create(a, b, (aValue, bValue) => aValue * 2 + bValue); // output store the same value as inputThe dependency graph of this code is called a diamond graph:
In this exemple:
outputmust be evaluated afteraandbaandbmust be evaluated afterinput
One can evaluate the graph in this order: input, a, b and output.
That is called a topological sort of the graph.
Storm.NET ensure a topologically ordered evaluation.
Storm.NET nodes are created via the Storm factory. This allow fluent parameterization of the created objects:
var a = Storm.Input.WithoutCompare.Create<int>(); // 'a' will not compare old and new values. It raise 'changed' at each update.
var f = Storm.Func.FromStates.Create(a, b, (aState, bState) => ...); // the evaluation of f will be done with extended informationsWithCompare/WithoutCompare
When a node is created using the WithCompare flavour: on evaluation, it's new value is compared to the old value using the provided IEqualityComparer (or the DefaultEqualityComparer) to define the state Changed/Unchanged.
When a node is created using the WithoutCompare flavour: on evaluation, the state is always Changed. That's can be usefull to create repeatable actions.
The default flavour is WithCompare.
Immutable nodes are roots of the dependency graph. That's it, they don't have dependencies. They don't trigger any graph update since their content is immutable.
Storm.Immutable factory provide three methods:
CreateError<T>(StormError error)create an immutable node that contains the given error.CreateError<T>(string message)create an immutable node that contains an error with the given message.CreateValue<T>(T value)create an immutable node that contains the given value.
Input nodes are roots of the dependency graph. That's it, they don't have dependencies. They are the entry point of a graph update via the SetValue and SetError method.
Storm.Input factory came with two flavours WithCompare/WithoutCompare.
The default content is Storm.Error.EmptyContent.
Func nodes are readonly, their values depends only on their dependencies. They are the core of the graph.
Storm.Func factory came with four flavours mixed from WithCompare/WithoutCompare and FromValues/FromStates.
When a StormFunc node is updated, at least one of it dependency should have the Changed state to trigger the evaluation. That's it, if the dependencies are either not updated or updated without changes, the Func is not evaluated and the StormFunc is defined as Unchanged. Even if it was created with the WithoutCompare flavor !
When a StormFunc node evaluation fails (throw an exception), it's content is a Storm.Error. The inner exception contain the original exception.
When a StormFunc node is created using the FromValues flavour, it's value is evaluated from the values of the dependencies. If one or more dependencies are in an Error state, the Storm function content is a Storm.Error with an inner AggregateException contening dependencies errors.
Example:
var a = Storm.Input.Create<int>();
var b = Storm.Input.Create<int>();
// c content is a + b when a and b are not in error.
var c = Storm.Func.Create(a, b, (aValue, bValue) => aValue + bValue);When a StormFunc node is created using the FromStates flavour, it's value is evaluated from the states informations of the dependencies.
For each dependency, the state information contains:
VisitStateeither:NotVisited: The dependency was not impacted by the current update.VisitedWithChange: The dependency was impacted by the current update and its content changed.VisitedWithoutChange: The dependency was impacted by the current update but its content was not changed.
Contentthe content of the dependency.
Example:
var a = Storm.Input.Create<int>();
var b = Storm.Input.Create<int>();
// c will contain a value only if it just changed to a valid value
// otherwise, it will contain the value of b or default if b is in error.
var c = Storm.Func.FromStates.Create(a, b, (aState, bState) =>
{
if (aState.VisitState == EStormFuncInputState.VisitedWithChange && !aState.Content.TryGetError(out _))
return aState.Content.GetValueOrThrow();
return bState.Content.GetValueOr(default);
});Creating a complex model with hundreds of inter-dependent Input and Func nodes force the developer to declare the nodes in a topological order. That it, you can't create a Func before its dependency. This is a tough work, and it's hard to maintain.
Socket nodes can be created before their dependencies.
This allow the user to create the nodes, then to interconnect them with the Connect method:
public class GlobalModel
{
public GlobalModel()
{
// instantiate nodes.
FooSubModel = new FooSubModel();
BarSubModel = new BarSubModel();
// create dependency
FooSubModel.Configure(this);
BarSubModel.Configure(this);
}
public FooSubModel FooSubModel { get; }
public BarSubModel BarSubModel { get; }
}
public class FooSubModel
{
private readonly IStormSocket<string> _fooString = Storm.Socket.Create<string>();
public void Configure(GlobalModel globalModel)
{
_fooString.Connect(Storm.Func.Create(globalModel.BarSubModel.BarInt, barInt => $"{barInt:00}"));
}
public IStormInput<int> FooInt { get; } = Storm.Input.Create(0);
public IStorm<string> FooString => _fooString;
}
public class BarSubModel
{
private readonly IStormSocket<string> _barString = Storm.Socket.Create<string>();
public void Configure(GlobalModel globalModel)
{
_barString.Connect(Storm.Func.Create(globalModel.FooSubModel.FooInt, fooInt => $"{fooInt:00}"));
}
public IStormInput<int> BarInt { get; } = Storm.Input.Create(0);
public IStorm<string> BarString => _barString;
}The dependency graph show how FooSubModel and BarSubModel are interdependent. They could not have been created without the use of sockets:
- Q: I don't know how, I end up with a
IStorm<IStorm<T>>.- A: Can't you use a
Func, put all knownIStorm<T>possible values as input and made a switch on it?- Q: Erh.. Nope.
- A: In this case, just made a
Switchon it.
Switch transform an IStorm<IStorm<T>> into a IStorm<T>.
Remark
If you know all possible target, a Func may be preferable:
Example:
var selector = Storm.Input.Create(0);
var evenTarget = Storm.Input.Create("even");
var oddTarget = Storm.Input.Create("odd");
var output = Storm.Switch.Create(selector, i => i % 2 == 0 ? evenTarget : oddTarget);Can be transformed to:
var selector = Storm.Input.Create(0);
var evenTarget = Storm.Input.Create("even");
var oddTarget = Storm.Input.Create("odd");
var output = Storm.Func.Create(selector, evenTarget, oddTarget, (i, evenValue, oddValue) => i % 2 == 0 ? evenValue : oddValue);But in some cases you can't:
var selectedIndex = Storm.Input.Create(0);
var models = new List<FooSubModel>();
var selectedFooString = Storm.Switch.Create(selectedIndex, i => models[i].FooString);

