Implementing singletons for configuration variables

Hello,
I am used to having an object containing configuration variables for my project.

For instance, in Java, I use static singletons to ensure those configuration variables are instantiated only once for the whole life-cycle of the program and that they are available anywhere in the code.

In Python, this is the same as having a module – i.e. a file – containing variables, since the module loading system ensures that each module is 1) a global object instance 2) not instantiable 3) imported only once 4) accessible from anywhere

In Julia, I see two options.

  1. use the PatModule package to mimic the Python behavior
  2. use a constant instance of a struct that makes itself not instantiable anymore:
    module Settings
        mutable struct SettingsStruct
            a::Int64
            b::Int64
            function SettingsStruct(a, b)
                eval(:(SettingsStruct(a, b) = error(
                    "Cannot instantiate SettingsStruct twice")))
                return new(a, b)
            end
        end
    
        const s = Settings(2, 3)
    end
    
    Such a method allows having a constant global variable named s which contains my settings. Anywhere in the code, I am sure that s was not replaced by anyone, neither in interactive sessions.

I would just like to receive opinions about these two alternatives and other
possible solutions.

There is also the option of creating the configuration object in your main function and passing it around through your functions.

And another, making a function that returns the configuration object.

I don’t really see the point of disabling multiple instantiation. What problem is prevented by that?

1 Like

It’s one of the design patterns in the Gang of Four.

It’s useful if you have some resource that must not be instantiated twice by the user, for instance, consider the following function:

module Settings
    struct SettingsStruct
        a::Int64
        b::Int64
        function SettingsStruct(a)
            eval(:(SettingsStruct(a) = error(
                "Cannot instantiate SettingsStruct twice")))
            return new(a, a^2)
        end
    end

    const settingsStruct = SettingsStruct(4)
    const settingsDict = Dict(a => 4, b => 5)
end # module

module Library
    using Settings

    function test1(a::Int64, conf::SettingsStruct)
        print("I'm doing stuffs with $a and $(conf.b)")
    end

    function test2(a::Int64, conf::Dict)
        print("I'm doing stuffs with $a and $(conf["b"])")
    end
end # module

Then the code of the user

module UserCode
    using Library
    using Settings

    d = Dict("g" => 5)
    test1(3, Settings.settingsStruct)
    test2(3, d)

end # module

As you see, test2 will fail, because the user has instantiated a wrong d.

If SettingsStruct could be instantiated twice, the user could pass new
wrong values.

1 Like

Your solution looks reasonable to me, except that redefining the constructor is very hackish (and do you need the struct the be mutable?).

You can instead check if the variable s is defined:

module Settings
    struct SettingsStruct
        a::Int64
        b::Int64
        function SettingsStruct(a, b)
            isdefined(Settings, :s) && error("Cannot instantiate SettingsStruct twice")
            return new(a, b)
        end
    end

    const s = SettingsStruct(2, 3)
end

See also Implementing Singleton design pattern.

However in this case I would just use a const with no additional check: you already get a warning if you change it:

WARNING: redefinition of constant s. This may fail, cause incorrect answers, or produce other errors.

But yeah, I do wish it would give an error instead. The current behavior seems designed for playing around and prototyping rather than building robust applications.

This was recently discussed on Behavior of reassignment to a `const` · Issue #38584 · JuliaLang/julia · GitHub.

2 Likes

Note that this kind of definition doesn’t really work that well in julia, as functions are not bound to instances of structs that they take as arguments. Simply instantiating SettingsStruct doesn’t make anything happen other than running the constructor function. If that is side effect free, it’s unnecessary to restrict creation of a new one (in fact, it may be preferable to do so, since being able to change settings on-the-fly and passing them around explicitly makes unit testing much easier and parallelizable).

Usually this is done with a const SETTINGS = Ref(SettingsStruct(...)) if you want to be able to change the settings, or just const SETTINGS = SettingsStruct() if you don’t, though this approach will require careful handling in multithreaded code that also modifies SETTINGS, which is why it’s better for that usecase to explicitly pass the “secret state” around. If you only read from SETTINGS, having a const global is not wrong though.

If Library doesn’t even provide the second method, the user will get a MethodError instead, signaling that they’ve done something wrong.

What do you mean by that? Are you thinking of keeping extra secret state per function call? If so, it’s much more user friendly to not do that and ask the user to pass in what you want to modify in the function.

4 Likes

Note that you cannot really ensure this with Julia. No matter how you plan to safeguard this, someone can always just eval into a module, etc.

I would just do

module Settings
export SETTINGS # note: only export
mutable struct _SettingsStruct
    a::Int64
    b::Int64
end
const SETTINGS = _SettingStruct(2, 3)
end

ie not export the constructor. Sure, the user can always access SettingsStruct, but that requires importing or an explicit module path, which should be warning enough, and also easy to grep the code for.

5 Likes

Yep, probably, having the const keyword, that is the way to go…

Actually, probably the best way is by using functions: Revise and const values changed during a session? - #3 by sapo