How to best carry around "state" in my program?

I have to parse a configuration file and run a simulation based on the settings in that file. In some language like Python, I’d probably make a class, say ConfigParser and it would both parse and store my configuration. Since I can’t rest on all of my old bad habits, I figured I’d ask for advice on new ways to do old things.

I guess I’m looking for a Julian way of doing things, not just something that doesn’t provoke the compiler to throw things.

I could use global variables

Global variables have obvious problems (not to mention it winds up being Dict{Any, Any}.

state::Dict = Dict()

function parsefile(fname)
    fh = open(fname)
    # ... read file, stuff info in state
end

function run_simulation()
    # ... run simulation based on state
end

parse_file("config.cfg")
run_simulation()

I could use inner functions (closures?)

Also has problems, and my co-workers aren’t fond of.

function simulate(fname)
    function parsefile(fh)
        # ... read file, stuff into `state`
    end

    state = Dict()
    fh = open(fname)
    parsefile(fh)

    run_simulation() # run simulation based on `state`
end

simulate("config.cfg")

Chain things together somehow?

function read_configuration(fname)
    dict = Dict()

    # ... read config, store in dict
    dict
end

function run_simulation(c)
    # based on configuration, run stuff
end

read_configuration("config.cfg") |> run_simulation

won’t be around until 1.11, but this looks apropos Scoped values by vchuravy · Pull Request #50958 · JuliaLang/julia · GitHub

All the Julia solvers I’m aware of (NLOpt, Optim, DiffEq, Trixi, …) use something like the third approach, where read_configuration returns a struct (if possible) rather than a dict, allowing more optimizations to be applied. This is related to the function barrier performance tip. If there are a number of fundamentally-different configurations that could be simulated (say, 3D Cartesian vs. 2D r-z axisymmetric, or with vs. without body forces), those differences can be encoded in the type domain, and you’d then define multiple simulate methods, e.g. simulate(initial_condition::Cartesian) and simulate(initial_condition::Axisymmetric).

2 Likes

Well, I always use a mutable struct for my configuration, which I store in a global const variable.

You can actually modify const mutable structs, so if I want to run the simulation based on a configuration file, but vary one parameter I can easily do that…

I pass this variable, named se (like settings) as first parameter to my functions. Sometimes I create
a deep copy of the global settings, modify the copy and then call one of my functions.

I store the configuration on disc using a .yaml file which is easy to read and write for humans.

I’m glad to hear that at least someone else uses global const values … I know that they can cause performance issues if they are Any typed. Sometimes I feel like I am overcorrecting in favor of having few arguments - since there was a codebase I had to work on for a long time that had fuction(way, way, way, too, many, darn, positional, arguments, good, greif) … and no, that is not an exaggeration, one function (in Matlab) had 11 positional arguments. :frowning:

1 Like

Imho, the third approach should always be supported. It just gives more flexibility, i.e., when running simulations across multiple different configurations etc.

In case, a good default is available it can be combined with global state:

  1. Having a non-exported global with getter (and possibly setter) functions and default methods. E.g., randuses this approach:

    import Random
    rand()  # use default
    rand(Random.default_rng())
    
  2. Again using some non-exported global state and providing a convenient way to execute code with different values:

    const global_cfg = Ref(:whatever)
    function with_config(body, cfg) 
       old = global_cfg[]
       global_cfg[] = cfg
       try
           body()
       finally
           global_cfg[] = old
       end
    end
    export with_config  # but not the actual global Ref    
    
    run_simulation() = run_simulation(global_cfg[])
    run_simulation(cfg) = ...
    
    with_config(read_config("config.cfg")) do
        run_simulation()
    end
    

    Note that this pattern is very similar to scoped values and similarly implemented by task_local_storage.

2 Likes

This is not true. They CANNOT be any typed if they are defined as const. They get
a concrete type on first assignment, and you cannot change the type afterwords.
Only disadvantage: You need to restart Julia if you extend your configuration struct
and add new parameters.

Too complicated for a simple scientist. And not needed unless your work in a team on a large piece of software.

I could be wrong about the intent of scoped values in Julia, but they seem like they were meant to be like parameters in Lisp/Scheme? They can in some cases make code cleaner, safer.

Yes, these are basically known as dynamic variables in Common and Emacs Lisp. Especially, in Emacs they are put to good use enabling a highly extensible/configurable system.
The main discussion nowadays seems to be how they should best interact with Threads or Tasks.