Mutating a flag in a nonmutating struct

Dear Julia Community.
I have a struct that contains both Bool flags and arrays.
I want to be able to change the value of some of the Bool flags, but I would like to avoid having a mutable struct, which I believe can create problems like type instability (where the type of the elements could change at runtime, since I need to define it with abstract types for generality)

This is an example:

mutable struct StochasticSettings{T}
    remove_scha_forces :: Bool
    clean_start_gradient_centroids :: Bool
    clean_start_gradient_fcs :: Bool
    initialized :: Bool
    original_force :: Array{T}
    original_fc_gradient :: Array{T}
end

Now, This is currently a mutable struct, since I would like to change the value of initialized, however, I would like to have it immutable, as the Array{T} is generic and could be reallocated at runtime, so keeping it mutable I believe this could create unwanted type instability. I also want to avoid being more specific here (e.g. using a concrete type for the arrays), as based on some condition in the initialization, the type and ranks of the Array could change during the initialization.

Indeed, a working solution is to use a 1-length Bool array for initialized, but it is not really elegant. Does it exists an automatic wrapper on something like this (i.e., like a pointer to a single element)?

Option A: Use const on array fields inside a mutable struct

mutable struct StochasticSettings{T}
    remove_scha_forces :: Bool
    clean_start_gradient_centroids :: Bool
    clean_start_gradient_fcs :: Bool
    initialized :: Bool
    const original_force :: Array{T}
    const original_fc_gradient :: Array{T}
end

Option B: Embed a mutable struct inside an immutable wrapper, and flatten fields with ForwardMethods.jl

using ForwardMethods

mutable struct StochasticSettings
    remove_scha_forces :: Bool
    clean_start_gradient_centroids :: Bool
    clean_start_gradient_fcs :: Bool
    initialized :: Bool
end

struct StochasticParam{T}
    settings :: StochasticSettings
    original_force :: Array{T}
    original_fc_gradient :: Array{T}
end

@define_interface StochasticParam interface = properties delegated_fields = (settings,)

x = StochasticParam(...)
x.remove_scha_forces = true  # OK

Option C: Use Base.RefValue

struct StochasticSettings{T}
    remove_scha_forces :: Base.RefValue{Bool}
    clean_start_gradient_centroids :: Base.RefValue{Bool}
    clean_start_gradient_fcs :: Base.RefValue{Bool}
    initialized :: Base.RefValue{Bool}
    original_force :: Array{T}
    original_fc_gradient :: Array{T}
end

x = StochasticParam(...)
x.remove_scha_forces[] = true

A related discussion on using Base.RefValue inside immutable structs:
https://discourse.julialang.org/t/ref-t-vs-base-refvalue-t/127886

Option D: Using Accessors.jl

using Accessors

struct StochasticSettings{T}
    remove_scha_forces :: Bool
    clean_start_gradient_centroids :: Bool
    clean_start_gradient_fcs :: Bool
    initialized :: Bool
    original_force :: Array{T}
    original_fc_gradient :: Array{T}
end

s  = StochasticSettings(...)
s2 = @set s.initialized = true
s2.original_force === s.original_force  # true

The arrays are not deep-copied.

I would choose Option B. But it’s difficult for me to evaluate the performance of different options.

4 Likes

My solution is kind of hard but if I had your problem, that’s what I would have done.
First of all I would have made a completely immutable struct with parametric types for arrays:

struct StochasticSettings{T} # T <: Array
    remove_scha_forces :: Bool
    clean_start_gradient_centroids :: Bool
    clean_start_gradient_fcs :: Bool
    initialized :: Bool
    original_force :: T
    original_fc_gradient :: T
end

Then I would define accessors functions

settings = setinitialized(old_settings, true) # the arrays will just be copied

This method should be done only if performance are critical. If not the solutions provided by the others are 100% fine

use Accessors.jl

@reset mystruct.flag = true or newstruct = @set mystruct.flag = true

This is akin to an XY problem: you are trying to solve the wrong problem here, because the premise of the question has a fundamental misunderstanding.

Mutability vs immutability has nothing to do with type instabilities. If you have abstractly typed fields, it is type-unstable whether or not the struct is mutable.

The way to get both concrete types and generality is to define parametric structs, as you have attempted here with {T}. This can be done for both immutable and mutable types.

No. A parametric type like StochasticSettings{T} is not a single type, it is a family of types. e.g. when you initialize it with Vector{Float64}, you get a StochasticSettings{Float64} instance where the fields can only be Array{Float64}.

However, Array{Float64} is still abstractly typed, because it doesn’t include the dimensionality. To make it concrete, while remaining mutable, I would change it to Vector{T} in your definition (assuming you want only 1d arrays). You could make it even more generic with e.g.

mutable struct StochasticSettings{T <: AbstractArray}
    remove_scha_forces :: Bool
    clean_start_gradient_centroids :: Bool
    clean_start_gradient_fcs :: Bool
    initialized :: Bool
    original_force :: T
    original_fc_gradient :: T
end

which allows any type of array of any type of element and any dimensionality (replace with AbstractVector if you only want 1d arrays) … but the type is fixed for any single StochasticSettings{T} instance.

It’s really critical to understand this in order to get good performance in Julia.

5 Likes

Superficially, an immutable object’s fields’ runtime types don’t change because its fields don’t directly change, but if you constrain the field with an abstract declared type like Array{T} (an iterated union via unspecified N), then that’s what the compiler’s type inference has to work with, not the runtime value’s type. To use a simpler example, Some{Number}(1) and Some{Number}(1.0) both provide the compiler with the same immutable type Some{Number} regardless of the field’s type at runtime, typeof(something(s)). The mutability or immutability of StochasticSettings doesn’t actually matter here, your specified generality is the true cause.

It might not even help if you parameterize StochasticSettings to have concrete declared types because variables or expressions bound to instances of varying concrete StochasticSettings types for generality are also type unstable. The upside is a function call taking such instances as inputs can be internally type-stable even if the call site is type-unstable; this is called a function barrier in the Manual’s Performance Tips. You won’t even have to change StochasticSettings to leverage function barriers if you are mostly passing the original_force and original_fc_gradient arrays into performance critical function calls. A little bit of well-isolated type instability can be well worth the generality; to put some numbers on it, if 99% of your runtime is spent on internally type-stable function calls, then optimizing away the type instabilities and runtime dispatches in the other 1% of caller functions would only speed up the program by <(100/99-1)=1.01%.