Live Cells C++
Reactive Programming for C++
|
A cell is an object with a value and a set of observers that react to changes in its value, you'll see exactly what that means in a moment.
There are a number of ways to create cells. The simplest cell is the constant cell, created with live_cells::value()
, which holds a constant value.
live_cells::value()
takes the constant value and wraps it in a constant cell. The values of constant cells never change.
The value of a cell is accessed using the value()
accessor method.
Mutable cells, created with live_cells::variable()
, which takes the initial value of the cell, hold a value that can be set with the value()
setter method, which takes the new value.
The value of a mutable cell can also be set with the assignment =
operator:
When the value of a cell changes, its observers are notified of the change. The simplest way to demonstrate this is to set up a watch function using live_cells::watch()
:
live_cells::watch()
takes a watch function and registers it to be called when the values of the cells referenced within it change. In the example above, a watch function that prints the values of cells a
and b
to standard output, is defined. This function is called automatically when the value of either a
or b
changes.
There are a couple of important points to keep in mind when using live_cells::watch()
:
live_cells::watch()
is called, to determine which cells are referenced by it.live_cells::watch()
automatically tracks which cells are referenced within it, and registers it to be called when their values change. This works even when the cells are referenced conditionally.value()
method. The difference between the two is that value()
only references the value, whereas the function call operator also tracks it as a referenced cell.value()
method.Every call to live_cells::watch()
adds a new watch function, for example:
The watch function defined above, watcher2
, observes the value of a
only. Changing the value of a
results in both watch functions being called. Changing the value of b
only results in the first watch function being called, since the second watch function does not reference b
and hence is not observing it.
stop()
on the "handle" returned by live_cells::watch()
live_cells::watch()
returns a shared pointer (std::shared_ptr
) holding a handle (live_cells::watcher
) to the watch function. This handle provides the stop()
method, which stops the watch function from being called for further changes in the cell values.
The watch function is also stopped automatically when the last shared_ptr
point to the handle, is destroyed.
live_cells::watch()
to a variable, even if you don't intend on using it directly. If you don't assign it to a variable or store it in a class member, the handle will be destroyed immediately and the watch will not be called when the cell values change.A computed cell is a cell with a value that is defined as a function of the values of one or more argument cells. Whenever the value of an argument cell changes, the value of the computed cell is recomputed.
Computed cells are defined using live_cells::computed()
which takes the value computation function of the cell:
In the above example, sum
is a computed cell with the value defined as the sum of cells a
and b
. The value of sum
is recomputed whenever the values of either a
or b
change. This is demonstrated below:
In this example:
sum
cell is defined.a
is set to 3
, which:sum
to be recomputedb
is set to 4
, which likewise also results in sum
being recomputed and the watch function being called.By default, computed cells notify their observers whenever their value is recomputed, which happens when the value of at least one of the referenced argument cells changes. This means that even if the new value of the computed cell is equal to its previous value, the observers of the cell are still notified that the cell's value has changed.
By providing live_cells::changes_only
to live_cells::computed
, the computed cell will not notify it's observers if it's new value is equal, by ==
, to its previous value. This is demonstrated with the following example:
This results in the following being printed to standard output:
Notice that only three lines are printed to standard output, even though the value of the computed cell argument a
was changed five times.
If live_cells::changes_only
is removed from the definition of b
, the following is printed to standard output:
Notice that a new line is printed to standard output, whenever the value of a
, which is an argument of b
, is changed. This is because b
notifies its observers whenever the value of its argument a
has changed, even if b
's new value is equal to its previous value.
The values of multiple cells can be set simultaneously in a batch update. The effect of this is that while the values of the cells are changed as soon as the value()
setter method, or assignment operator, is called, the observers of the cells are only notified after all the cell values have been set.
Batch updates are performed with live_cells::batch()
, which takes a function that is called to set the values of one or more cells:
In the example above, the values of a
and b
are set to 15
and 3
respectively, within live_cells::batch()
. The watch function, which observes both a
and b
, is only called once after the values of both a
and b
are set.
As a result the following is printed to the console:
a = 0, b = 1
is printed when the watch function is first defined.a = 15, b = 3
is printed when the function provided to live_cells::watch()
returns.Alternatively a batch update can be performed by creating a live_cells::batch_update
in a given scope. The batching comes into effect when the batch_update
is created and the cell values are updated when the batch_update
is destroyed (on leaving the scope).
The following is equivalent to the previous example using live_cells::batch()
:
In the examples till this point we've been using auto
to declare the variables holding our cells. This is because the actual type varies depending on the type of cell (constant cell, mutable cell, computed cell, etc.) and also on the parameters used to create the cell. For example, the type of a computed cell depends on the value computation function.
All cell types satisfy the live_cells::Cell
concept, which specifies the cell protocol. We've already used two methods specified by the Cell
concept, the value()
getter method and the function call operator overload.
live_cells::MutableCell
concept, which specifies the mutable cell protocol. MutableCell
is a superset of Cell
, which means that every type that satisfies MutableCell
also satisfies Cell
.To define a function that takes a cell as an argument, define a function template
with the template parameters constrained by the Cell
concept. For example here's a simple function add
, which takes two cells and returns a computed cell that computes the sum of the two cells:
This ensures that only an object that implements the cell protocol can be provided for a
and b
.
The definition of the add
function can be simplified using C++20's template shorthand syntax:
The Cell
concept and auto
limits you to cells for which the exact type is known at compile-time. This means you cannot use them to store cells of unrelated, and unknown, types in a container such as std::vector
.
For this use case, the live_cells::cell
wrapper is provided. cell
is a wrapper over a Cell
that performs type erasure, much like std::function
, in which a cell of any type can be stored.
A cell
wrapper is created by providing a Cell
to its constructor. The wrapper exposes the same methods specified by the Cell
concept however the value()
getter method is a template that has to be invoked with a type parameter, specifying the type of value to retrieve:
value
has to match the type of the value held in the cell exactly, otherwise an std::bad_cast
exception is thrown.When the value type of the value held in the cell is known ahead of time, the live_cells::typed_cell
wrapper can be used, which is the same as live_cells::cell
but takes the value type as a template parameter:
Notice there is no need to provide a value type template parameter to the value()
method, because the value type (int
in this case) is already given in the typed_cell
template parameter.
Cells mostly take care of their own memory management, but there are a few points to keep in mind when using cells:
A cell holds a reference to its state. This means, copying a cell does not copy the underlying state but merely creates a new reference to it:
That's why, as you've probably noticed, the lambda functions provided to live_cells::watch()
and live_cells::computed()
capture cells by value, not by reference.
The cell state holds the cell's value and its observers.
If a lambda function can escape its scope, capture cells by value not by reference.
Both live_cells::watch()
and live_cells::computed()
store a copy of the lambda which can potentially outlive the scope in which the lambda function is defined. If a cell is captured by reference in this case, it ends up as a dangling reference.
On the other hand, live_cells::batch()
neither copies nor stores the function provided to it but only calls it immediately. Thus it is safe to use capture by reference in this case. However, when in doubt capture by value.
The state of a cell is destroyed when the last cell referencing it is destroyed.
In this regard, cells function much like std::shared_ptr
.
std::shared_ptr
. Whilst not wrong, its unnecessary because cells already have a shared pointer to their underlying state. Therefore, it's best to simply copy the cell, whether statically or dynamically typed.Mutable cell values are assigned by value, not by reference. This probably goes without saying but its best to state it explicitly
Now that we've covered the basic you can proceed to the next section, which introduces utilities for creating cells directly from expressions.