Cached library implements F# computation expressions where values can be cached and on the next run cached values can be reused instead of recomputed.
The main use for this library is to create and update user interfaces. Having separate code for creation and update of user interface leads to inconsistencies. To remedy this we can avoid updates and always recreate user interface from scratch while preserving it's internal state. And this is what Cached library helps with. You can write a single computation expression which can be used to create and also to update a user interface. Caching controls with internal state ensures that internal state is preserved and also improves performance.
My goal was to create UI in F#. But unfortunately there are no production-ready reactive libraries for F# and not even for .NET. That's at least my opinion and I'll explain why.
Currently the closest candidates are
ImGui.NET
. Dear ImGui is an awesome immediate mode UI library.ImGui.NET
NuGet package works with Silk.NET rendering. Unfortunately it doesn't look and behave as other desktop applications.egui
written in Rust. It is also an immediate mode library which looks and behaves more like other desktop applications (at least on Mac). I tried wrappingegui
so I can use it from F#. Unfortunately wrapping Rust code for other languages needs unsafe code and thus it's harder than wrapping C or C++ code. The reason is that Rust compiler expects that certain aliasing rules are followed. These aliasing rules are more complex than for C and C++ and can be easily violated in unsafe code thus introducing undefined behaviors.Avalonia
. This library looks and behaves fine on various platforms. Unfortunately I don't like XAML nor its reactive part.
The other well known libraries are either full of bugs like MAUI. Or are extremely complex like React or Blazor or Jetpack Compose or even Solid.js. These widely used libraries are maybe based on simple ideas but their implementation is complex (all have more than 1 K lines and some even more than 10 K lines). This complexity hides special cases which will probably surprise you. And I don't want to be surprised by my applications in production environments.
I also tried other F# libraries which were adding reactivity to Avalonia. But most of them were not working properly even in simple scenarios. Among F# libraries I really liked Vide. Cached library is inspired by Vide. The main difference is that a type of a state used by a computation expression is not propagated outside. This makes resulting types much friendlier to the user and probably to the type checker. On the other hand types of Vide computation expressions contain a type of a whole state which can be massive.
Cached library tries to be simple. Currently it has less than 300 lines. so anyone can read its source code and understand everything. Cached library can be used to add reactivity to retained mode UI libraries like Avalonia.
Let's construct our first computation with cached values:
open Cached
let c = computation "counter" {
let! n = cachedHere { ref 0 }
n.Value <- n.Value + 1
printfn "Count is %d" n.Value
}
We gave our computation a name "counter"
and stored it in a variable c
.
To run the computation we need a storage:
let storage = createEmptyStorage ()
When we run our computation for the first time with
runWithStorage storage c
it prints Count is 1
. When we run it with the same storage for the second time
it prints Count is 2
. And you can bet that when we run our computation for the third time
and use the same storage it prints Count is 3
.
n
in the computation has type int ref
. When the computation is run
cachedHere
looks into the storage to see if there is a value cached by a previous run.
If there is no value from a previous run then cachedHere
evaluates the expression in the braces
to create a value, puts the value into the storage and also returns the value.
If there is already a value in the storage then cachedHere
returns this value.
In our example in the first run cachedHere
evaluates ref 0
, puts the result into the storage
and also returns the result. So in the first run n
is bound to ref 0
.
In the next runs cachedHere
finds the previously cached value in the storage and returns it.
This means that subsequent runs of our computation c
use mutable cell
which was created by the first run.
cachedHere
uses the line number of the last let!
as a key into the storage. So multiple calls of cachedHere
work fine as long as let!
s are on different lines
let c2 = computation "date and counter" {
let! date = cachedHere { DateTimeOffset.UtcNow }
let! n = cachedHere { ref 0 }
n.Value <- n.Value + 1
printfn "Count is %d, counting started at %A" n.Value date
}
If we evaluate a single let!
multiple times
let c3 = computation "multiple dates" {
for i = 1 to 5 do
let! date = cachedHere { DateTimeOffset.UtcNow }
printfn "%d-th iteration executed at %A (in the first run)" i date
}
we get an exception saying Value at ... cannot be used twice
.
The reason is that usually when caching inside a loop we want
different iterations of the loop to not interfere with each other.
In other words we usually don't want value cached in the first iteration
to be used by the second iteration.
So using the line number of the last let!
as the key into the storage is not enough
in the above example. Fortunately there's a function cachedHereUnder
which uses the line number of the last let!
and the value given by the user as the key into the storage:
let c4 = computation "multiple dates" {
for i = 1 to 5 do
let! date = cachedHereUnder i { DateTimeOffset.UtcNow }
printfn "%d-th iteration executed at %A (in the first run)" i date
}
This basic knowledge is enough to build simple user interfaces.
See Cached.SampleAvalonia/TodoList.fs
as an example.
As explained above cachedHere
and cachedHereUnder
use the line number of the last let!
as the key into the storage. The consequence is that both calls of cachedHere
in
the following example use the same key into the storage
let c5 b = computation "let! matters" {
let! x =
if b
then cachedHere { 1 }
else cachedHere { 2 }
return x
}
which may result in a surprising behavior. The result of c5
computation will
depend on the value of b
given to the first run.
Another alternative for Cached
library would be to use a line number of cachedHere
or cachedHereUnder
.
This alternative would make it harder to use cachedHere
and cachedHereUnder
outside of computations.
The following example would fail
let cachedInstance = cachedHere { obj() }
let c = computation "two different instances" {
let! x = cachedInstance
let! y = cachedInstance
return x, y
}
with an exception saying Value at ... cannot be used twice
.
Currently when using the line number of the last let!
it works fine.
Each call runWithStorage storage c
monitors which values in a storage storage
are used by a computation c
. After c
finishes values which were not used by c
are removed from storage
.
In the previous tutorial when defining computations we used expressions of the form
computation computation-name { ... }
and always gave our computations a name.
The name can be any value whose type satisfies equality
constraint.
Computation names determine scopes. Scopes exist to ensure that different computations don't interfere with each other. For example suppose that we define
let counter = computation "counter" {
let! n = cachedHere { ref 0 }
return n
}
let countPositive xs = computation "countPositive" {
let! n = counter
xs
|> List.iter (fun x -> if x > 0 then n.Value <- n.Value + 1)
return n.Value
}
let countNegative xs = computation "countNegative" {
let! n = counter
xs
|> List.iter (fun x -> if x < 0 then n.Value <- n.Value + 1)
return n.Value
}
let updateAndPrintStats xs = computation "updateAndPrintStats" {
let! positive = countPositive xs
let! negative = countNegative xs
printfn "We have seen %d positive numbers and %d negative numbers" positive negative
}
In the above example counter
computation is used by both countPositive
and countNegative
computations
and yet when we run updateAndPrintStats
we see that two counters don't interfere with each other:
let countingStorage = createEmptyStorage ()
runWithStorage countingStorage (updateAndPrintStats [-1; 1])
// Prints: We have seen 1 positive numbers and 1 negative numbers
runWithStorage countingStorage (updateAndPrintStats [-2; -3])
// Prints: We have seen 1 positive numbers and 3 negative numbers
runWithStorage countingStorage (updateAndPrintStats [-4; 2; 3])
// Prints: We have seen 3 positive numbers and 4 negative numbers
The reason is that cachedHere
is called from different scopes
and different scopes don't share any data in the storage.
- If
counter
is used fromcountPositive
thencachedHere
is called from the scope"updateAndPrintStats"
/"countPositive"
/"counter"
. - On the other hand if
counter
is used fromcountNegative
thencachedHere
is called from the scope"updateAndPrintStats"
/"countNegative"
/"counter"
.
A scope is something like a call stack for computations. Initially
runWithStorage storage root
opens the scope for root
computation.
If root
computation calls nested
computation then
it opens the scope for nested
computation. When nested
terminates
the scope for nested
is closed. Finally the scope for root
is closed.
cachedHere
and cachedHereUnder
operate only on the currently open scope.
This means they look into the storage only for values stored in the currently open scope
and when they put a new value into the storage they put it in the currently open scope.
This also means they don't see values stored in the parent scope.
Earlier we saw that we can't call cachedHere
multiple times from the same line.
Similarly to that we can't open one scope multiple times.
Or in other words we can't open one scope after we closed it.
If we run the following computation
let twoCountersWrong = computation "twoCountersWrong" {
let! m = counter
let! n = counter
printfn "Values are %d and %d" m.Value n.Value
}
we get an exception saying Scope cannot be used twice: "counter"
.
When evaluating let! m = counter
we open and close the scope "twoCountersWrong"
/ "counter"
.
The evaluation of let! n = counter
then fails because it tries to open the scope "twoCountersWrong"
/ "counter"
which was already closed.
If we really want to use counter
twice we have to do it from different scopes. For example
let twoCounters = computation "twoCounters" {
let! m = computation 1 { return! counter }
let! n = computation 2 { return! counter }
printfn "Values are %d and %d" m.Value n.Value
}