ReacTcl

Reactive Programming Infrastructure for Tcl

CGM : Reactive Programming is a technique where the programmer specifies a set of invariant relationships between the inputs and outputs of a system.  The system then reacts to changes in the inputs by automatically updating the outputs to maintain these relationships.  Automatic tracking of the interdependencies of these computations allows them to be scheduled when required but not otherwise.  This can be viewed as a generalisation of the operation of spreadsheets to support more free-form and complex computations.

The code presented here is an attempt to provide a simple and lightweight framework to support reactive programming in Tcl. It defines a TclOO class "Reactive" whose objects represent reactive variables, with an alias react so we can create such an object with just: react myVar

Calling the object with no arguments will return its value, or an error if that has not been defined.

Such a variable can be given a fixed value: react myStr == {a b c}

or defined with an expression: react myNum = {3 * 2}

or defined by a chunk of code to evaluate: react myResult := {string repeat [myStr] [myNum]}

Such calculated values are memoized to avoid recalculating them unnecessarily. However the Reactive objects also keep track of the dependencies between them, discarding the memoized values when any of the inputs from which they were calculated is updated.

So if we modify myNum by doing: myNum = 10 the memoized value of myResult will be discarded, and the next time it is requested it will be recalculated.

These are trivial examples - this technique only becomes really useful when the computations being memoized are complex and slow enough that it's worth this extra complication to avoid re-doing them unnecessarily.

Linking to Input and Output Tcl Variables

A Reactive object can be defined to track the value of an ordinary Tcl variable: react myInput <= someVar

Among other uses, this Tcl variable can be linked to a Tk Entry field, e.g. ttk::entry .someInput -textvariable someVar

Alternatively a Reactive object can be used to drive the value of an ordinary Tcl variable: react myOutput = {some computation}; myOutput => otherVar . This requires the event loop to be active as updating the variable is scheduled with after idle to make sure any other updates which are in progress have completed first. Important: connecting a Reactive object to an output variable in this way has the side-effect of forcing the Reactive object to be recomputed whenever its inputs changes, i.e. eager rather than lazy evaluation is used. Such an output link can be broken by doing: myOutput !=> otherVar.

So you can have such a value displayed by linking the Tcl variable to a Tk Label field: ttk::label .someOutput -textvariable otherVar

The abc.tcl program below shows a simple example of linking GUI inputs and outputs.

Caution: By linking variables in a cycle, it is possible to create an endless loop. ReacTcl will detect and report cycles in Reactive computations, but it cannot detect loops via linked Tcl variables. Doing react x <= v; react y = {2 * [x]}; y => v will cause an infinite loop and hang your program.

Reactive Maps

Sometimes we need to handle not just individual computed values, but whole collections of computed values, each of which should only be recomputed when its specific inputs have changed. The ReactiveMap class is intended to help with this situation. It can be used to define a set of Reactive values, distinguished by one or more parameters, with a script to compute its values. A separate Reactive object will be created and cached for each instance of its parameter(s).

E.g. let's define myMap as: react_map myMap parm {string repeat $parm [myNum]}

if myNum is set to 3 and we do: myMap get foo

we get the result foofoofoo. Then if we do: myMap get bar

we get the result barbarbar. However this is not done by modifying the previous reactive computation, but by creating a new one. If we request either of these results again we will get back the memoized value without it needing to be recomputed. But if myNum changes they will both be invalidated and so will need to be recomputed if they are requested again.

Repository

I have checked the code in at https://chiselapp.com/user/cmacleod/repository/reactcl/home

Code: reactcl.tcl

# ReacTcl - Reactive Programming Infrastructure for Tcl

# See https://en.wikipedia.org/wiki/Reactive_programming - here we use
# invalidity notification propagation and a dynamic dependency graph.

# clean up in case we are reloading
catch {Reactive destroy; Reactive.Delegate destroy}
catch {ReactiveMap destroy; ReactiveMap.Delegate destroy}

# In Tcl8.7 reactcl_target can become a classvariable in Reactive
set reactcl_target {}

# A Reactive object represents a variable which can either be set to a
# specific value or computed from other Reactive objects.  Such computed
# values are memoized and their dependencies on other Reactive values are
# automatically tracked so that these computations are re-run only when
# changes to their inputs have invalidated the currently memoized output
# value.

oo::class create Reactive {

variable value script ret_code ret_opts version targets_vers
variable traced_var driven_vars

constructor args {

    set value {}
    set script {}; # run this to (re)calculate value when required.
    set ret_code {}; # return code from running script,
                     # or empty when value is unknown,
                     # or code 4 used to detect circular dependencies.
    set ret_opts {}; # return-options from running script.
    set version 0; # incremented when previous value becomes invalid.
    set targets_vers [dict create]; # record the Reactive objects whose
                # values were computed from this one, and their versions.
    set traced_var {}; # when tracking a variable this holds its
                       # fully qualified name.
    set driven_vars [dict create]; # set of variables which we keep
                # updated with our value; having this non-empty
                # forces eager, not lazy, recomputation.

    # Support appending the definition to the creation of the object:
    if {[llength $args]} {tailcall [self] {*}$args}
}

destructor {
    my untrace
}

method == val {
    my unset
    set value $val
    set ret_code 0
    set ret_opts {}
}

method unset {} {
    my := {}
}

method := code {
    my untrace
    set script $code
    my invalidate
    return [self]
}

method = expr {
    my := [list expr $expr]
}

method get {} {
    global reactcl_target

    set prev_target $reactcl_target
    set reactcl_target [list [self] $version]

    try {
        # Do sanity check and (re)calculate if needed
        switch $ret_code {
            {} {if {$script eq {}} {error "[self] is unset."}
                set ret_code 4; # For cycle check

                # Evaluate the script, catching errors
                set ret_code [catch $script value ret_opts]
               }
            4  {error "[self]: Circular dependency"}
        }
    } finally {
        # Record the target value (if any) that we are contributing to
        set reactcl_target $prev_target
        foreach {target ver} $reactcl_target {
            dict set targets_vers $target $ver
        }
    }

    if {$ret_code == 1} {return -options $ret_opts $value }
    return $value
}

method valid {} {
    return [expr {$ret_code == 0}]
}

method invalidate {} {
    set ret_code {}
    set ret_opts {}
    set value {}
    incr version
    dict for {target ver} $targets_vers  {
        $target invalidate_target $ver
    }
    set targets_vers {}

    # If we are driving any variables, schedule their update
    if {[dict size $driven_vars]} {after idle [self] drive_vars}
}

method invalidate_target ver {
    if {$ver == $version && $ret_code ne {}} {
        my invalidate
    }
}

# Show internal state for debugging
method show {} {
    return "[self] value='$value' script='$script' ret_code='$ret_code' ret_opts='$ret_opts' version='$version' targets_vers='$targets_vers' traced_var='$traced_var' driven_vars='$driven_vars'"
}

# Hack so we can call with no arguments to get the value
method unknown {} {
    return [my get]
}

# Derive our value from a traced variable
method <= var_name {
    my unset
    uplevel [list trace add variable $var_name {write unset} [list [self] var_trace]]
    set traced_var [uplevel namespace which -variable $var_name]

    if {[info exists $traced_var]} {
        set value [set $traced_var]
        set ret_code 0
    }
}

method untrace {} {
    if {$traced_var ne {}} {
        trace remove variable $traced_var {write unset} [list [self] var_trace]
        set traced_var {}
    }
}

method var_trace {n1 n2 op} {
    switch $op {
        write {
            my invalidate
            set value [set $traced_var]
            set ret_code 0
        }
        unset {
            my unset
        }
    }
}

# Drive a Tcl variable from our value
method => var_name {
    if {[catch {my get} val]} {set val {}}
    upvar $var_name var
    set var $val
    #uplevel set $var_name [my get]
    set driven_var [uplevel namespace which -variable $var_name]
    dict set driven_vars $driven_var {}
}

# Stop driving a Tcl variable
method !=> var_name {
    set driven_var [uplevel namespace which -variable $var_name]
    dict unset driven_vars $driven_var
}

# Update all driven variables
method drive_vars {} {
    if {[catch {my get} val]} {set val {}}
    dict for {var -} $driven_vars {
        set $var $val
    }
}

export == = := <= => !=>

}

interp alias {} react {} Reactive create


# A ReactiveMap object represents a set of Reactive variables generated
# by applying a common function to a varying parameter (or list of
# parameters).  Each Reactive variable is created on first use of its
# specific parameter(s) and then cached.

oo::class create ReactiveMap {
    variable params script cache

constructor {parms code} {
    #puts "MAP::CONSTRUCT [self] code='$code'"
    set params $parms
    set script $code
    set cache [dict create]
}

method get args {
    #puts "MAP::GET [self] args='$args' cache='$cache'"
    if {[llength $args] != [llength $params]} {
        error "[self]: parameters expected: $params, parameters supplied: $args."
    }

    # Check if we have a cached object for this argument list
    if {[dict exists $cache {*}$args]} {
        set react [dict get $cache {*}$args]
    } else {
        # Otherwise create object and add to cache
        set react [Reactive new := [list apply [list $params $script] {*}$args]]
        dict set cache {*}$args $react
    }

    return [$react]
}

# Show internal state for debugging
method show {} {
    return "params='$params' script='$script' cache='$cache'"
}

}

interp alias {} react_map {} ReactiveMap create

Example: abc.tcl

This a simple Tk example program which creates three input fields a,b,c and an output field which continuously displays (a+b)/c, or an error message if the inputs are missing or invalid.

ReacTcl abc image

# ReacTcl demo - GUI to calculate (a+b)/c

package require Tk

source reactcl.tcl

ttk::label .la -text a
ttk::entry .a -textvariable a -justify center
ttk::label .lb -text b
ttk::entry .b -textvariable b -justify center
ttk::label .lc -text c
ttk::entry .c -textvariable c -justify center
ttk::label .lr -text {(a+b)/c}
ttk::label .result -textvariable result

grid .la .a
grid .lb .b
grid .lc .c
grid .lr .result

react a <= a
react b <= b
react c <= c

react calc = {([a] + [b]) / [c]}
react result := {catch calc val; set val}
result => result

Larger Example

For a less trivial example of how ReacTcl can be used, see ReacTcl example: Grv


APN Nice. Why not just define methods called == etc. or forward instead of unknown? Also, could this wrap trace so any variable could be included in the framework?

CGM Thanks. Well I wanted to have the bare object name return its value and you can only do that via unknown since you can't define a method with no name (I think), but the other methods could be defined more directly, might change that. Yes, using trace to hook this up to ordinary variables is on my ToDo list above, not done yet.

Update - I tried defining e.g. == directly as a method name but it doesn't work, so I think I need to stick with the unknown mechanism.

Update again - Actually that does work, but because the method name doesn't start with a lower-case letter it's not exported by default, it needs an explicit export ==.

CGM I gave a little presentation on this work at EuroTcl 2023 - https://openacs.org/conf2023/info/schedule .

2023-09-01 - As requested, I have now added methods to link to any Tcl variable as an input or output, and given an example of using this to connect Tk input and output fields.

2023-09-16 - I have now posted a more substantial example program ReacTcl example: Grv. When working on this I found that I really needed to be able to specify whole groups of reactive computations, not just individual ones. This lead me to introduce the ReactiveMap class. I believe it should be possible to optimise its representation - it seems redundant to instantiate a separate Reactive object for each element in the map - but I have not yet found a workable way to do this.