Initialization of container of constants? NamedTuple, etc

Would there be some disadvantage in using a module as a container of constants?

I sometimes have 5–10 related global constants, which I sometimes want to treat as a whole. I thought that the simplest solution was

params = (a = 3.14, b = "hello", c = 6.28)
# . . . use params.a, etc. . . .

So far so good, but in my actual program, I need some of the parameters to depend on some others:

params = (
  a = 3.14
 ,b = "hello"
 ,c = func(a) 
)

which the compiler complains with “a not defined”.

You can fix this situation by

params = let
  a = 3.14
  (a = a, b = "hello", c = func(a))
end

This isn’t too bad, but if you have more parameters and more dependent ones, redundancy increases.

The good thing about the named tuple is that it’s simple and reads as a table of definitions with little redundancy. But, if you use the above let construct, that virtue is diminished.

Is there an elegant solution?


So, currently I’ve started to abuse a nested module as a container:

module params
  a = 3.14
  b = "hello"
  c = Main.func(a)
end
# . . . use params.a, etc. . . .

Would there be disadvantages in this solution?

I’ve used modules to share constants between different programs or different modules of a single program, but I’ve never thought of using it as if it were a NamedTuple.

I cannot really answer that, but I have an alternative suggestion that feels less like abuse :sweat_smile:

make_params(;
    a = 1,
    b = "hello",
    c = collect(a)
) = (; a, b, c)

Granted, it’s slightly more verbose than the module version, but I think it’s less confusing (at least for me). And if re-generating the parameters every time would be expensive, it’s straightforward to define a constant holding the results

const params = make_params()

Yet another way would be to make a struct, which is probably the most idiomatic for a “container of some values” (but I also like NamedTuple for its simplicity):

Base.@kwdef struct Params
    a::Float64 = 3.14
    b::String = "hello"
    c::Float64 = -a
end

Again, slightly more verbose due to type annotations (which you could also omit), but the intent is most clear here I think. The usage would be essentially the same as with the function, but with @kwdef you also have a nice syntax that already defines the constructor for you.

1 Like

Thanks for your interesting ideas! But, to me, your first solution feels more, rather than less, like abuse :wink: because you are using the keyword arguments of a function as a list of constants we want to encapsulate!

Yes, I agree that your struct solution is more proper than my abuse of module.

1 Like

One issue with your approach as written is that your variables are all globals

julia> module params
       a = 3.14
       b = "hello"
       c = Main.func(a)
       end
Main.params

julia> @code_typed params.a
CodeInfo(
1 ─ %1 = Base.getglobal(x, f)::Any
└──      return %1
) => Any

julia> @code_typed params.b
CodeInfo(
1 ─ %1 = Base.getglobal(x, f)::Any
└──      return %1
) => Any

julia> @code_typed params.c
CodeInfo(
1 ─ %1 = Base.getglobal(x, f)::Any
└──      return %1
) => Any

@Sevi’s method is probably the best to use. The keyword arguments wouldn’t cause any problem. If you know the constants won’t change and so don’t need the ability to specify them, then you can do

struct Params
a::Float64 
b::String 
c::Float64 
end
function Params()
a = 3.14 
b = "hello"
c = func(a)
return Params(a, b, c)
end
2 Likes

That’s interesting. Thanks. I didn’t know that the compiler cannot get the type of the variables in the module:

julia> module params2
       const a = 3.14
       const b = "hello"
       const c = cos(a)
       end
Main.params2

julia> @code_typed params2.a
CodeInfo(
1 ─ %1 = Base.getglobal(x, f)::Any
└──      return %1
) => Any

What about a NamedTuple?

julia> params3 = (a = 3.14, b = "hello")
(a = 3.14, b = "hello")

julia> @code_typed params3.a
CodeInfo(
1 ─ %1 = Base.getfield(x, f)::Union{Float64, String}
└──      return %1
) => Union{Float64, String}

So, this one also potentially harms performance?


This makes me wonder: Does this mean that constants in other modules shouldn’t be used if you care about performance?

I’ve been long using modules to share read-only values between modules and programs.


You seem to say “global” as if it implies that its type cannot be determined at compile time. Is that always the case for globals?

For your params2, if your code is in a function it works fine:

julia> module params2
       const a = 3.14
       const b = "hello"
       const c = cos(a)
       end
WARNING: replacing module params2.
Main.params2

julia> @code_typed params2.a # >:(
CodeInfo(
1 ─ %1 = Base.getglobal(x, f)::Any
└──      return %1
) => Any

julia> function testf()
       return params2.a
       end
testf (generic function with 1 method)

julia> @code_typed testf() # :)
CodeInfo(
1 ─     return Main.params2.a
) => Float64

Not sure about the performance with named tuples, I never use them. If it’s const it figures it out though

julia> const p3 = (a = 3.14, b = "hello")
(a = 3.14, b = "hello")

julia> function g()
       return p3.a
       end
g (generic function with 1 method)

julia> @code_typed g()
CodeInfo(
1 ─     return 3.14
) => Float64

Does this mean that constants in other modules shouldn’t be used if you care about performance?

Just make sure you’re using them in a function. If they’re correctly given a const tag its fine.

You seem to say “global” as if it implies that its type cannot be determined at compile time. Is that always the case for globals?

Yes because the compiler doesn’t know if the global will change type. You can use typed globals if you do need to also change the variable, and the performance will improve a bit since it can infer the type.

julia> global x::Float64 = 3.14
3.14

julia> function h()
       return x
       end
h (generic function with 1 method)

julia> @code_typed h()
CodeInfo(
1 ─ %1 = Main.x::Float64
└──      return %1
) => Float64

julia> badx = 3.14
3.14

julia> function hh()
       return badx
       end
hh (generic function with 1 method)

julia> @code_typed hh()
CodeInfo(
1 ─ %1 = Main.badx::Any
└──      return %1
) => Any
1 Like

No you can’t really infer this from your tests. I think @code_typed applied to expressions in global scope is just not a good indicator for type stability.
Consider:

julia> module params2
       const a = 3.14
       const b = "hello"
       const c = cos(a)
       end

julia> @code_typed params2.a
CodeInfo(
1 ─ %1 = Base.getglobal(x, f)::Any
└──      return %1
) => Any

julia> @code_typed (()->params2.a)() # same as above just wrapped in a function
CodeInfo(
1 ─     return Main.params2.a
) => Float64

This has nothing do with globals:

julia> let a = (5,"a")
       @code_typed a[1]
       end
CodeInfo(
1 ─ %1 = Base.getfield(t, i, $(Expr(:boundscheck)))::Union{Int64, String}
└──      return %1
) => Union{Int64, String}

julia> let a = (5,"a",1.0,2im,π)
       @code_typed a[1]
       end
CodeInfo(
1 ─ %1 = Base.getfield(t, i, $(Expr(:boundscheck)))::Any
└──      return %1
) => Any

julia> let a = (5,"a",1.0,2im,π)
       @code_typed (()->a[1])()
       end
CodeInfo(
1 ─ %1 = Core.getfield(#self#, :a)::Tuple{Int64, String, Float64, Complex{Int64}, Irrational{:π}}
│   %2 = Base.getfield(%1, 1, true)::Int64
└──      return %2
) => Int64

I think, the thing here is that the type domain just does not carry the necessary information, i.e. indexing a Tuple{Int,String} with some Int could yield any of the contained types. However when you actually execute some piece of code then Julia has a lot more information: I this case the index Int is actually a constant and thus can be evaluated statically and that’s how Julia figures out that the return type actually will always be Int.
In summary, I think @code_typed is just to primitive on its own. Wrap things in a function to get more accurate results!

1 Like

You can somewhat reduce the redundancy, especially with longer names, by writing a only once: (; a, b = "hello", c = func(a)).

But generally, if c is some simple and cheap function of a, do you really need to store c at all?

1 Like
params = let
  a = 3.14
  (; a, b = "hello", c = func(a))
end

Add a const if this is at module/top-level scope.

The package AddToField.jl, which I maintain, makes this pretty easy

julia> using AddToField

julia> @addnt begin
           @add a = 1
           @add b = a + 2
       end
(a = 1, b = 3)
2 Likes

Fair point :slight_smile: As others pointed out already, the let block can also be made a bit less redundant and turned into something that visually looks a lot like the “function with keyword arguments” approach (the result would be the same).

params = let # or `const params` as discussed above
    a = 1
    b = "hello"
    c = collect(a)
    (; a, b, c)
end
2 Likes

Thank you all for your ideas.

I’ve learned (if I’m not mistaken) that a module is fine as long as the variables are declared const and these constants are used in functions.

Also, I like the “magic” (; a, b, c):

1 Like

So, I guess my final question is, what’s so special about functions? It seems to me that you always want to put all your code in functions.

# (1) Potentially slow?
using MyModule
for i in 1:N
   # do some calculation using MyModule.x
end

may be slow because the type of MyModule.x may not be known.

# (2) Potentially faster?
using MyModule
function func()
  for i in 1:N
     # do some calculation using MyModule.x
  end
end
func()

Is that correct? If so, can’t the compiler mechanically translate code (1) into code (2) as an optimization?

I feel I must be missing something.

1 Like

It may be slower of faster compared to code in a function because code in a function is compiled before being executed.

Perhaps. But that wouldn’t really be desirable, I think. There needs to be a simple way to run Julia code without compiling it.

1 Like

So, if I understand you correctly, the time it takes to compile the code is the only potential disadvantage.

If compilation is fast enough, we can always put everything in a function without any negative impacts. . . .

(Or do you mean that compilation sometimes makes your code slower without taking the compilation time into account?)

But this is probably academic. When your code grows, you almost always split it into function calls, and your non-function (global) statements are always little.

1 Like

I think the more severe issue is that global scope has different (more dynamic) semantics. E.g. when it comes to the definition of new methods:

  • The for loop in global scope redefines the function globally:
julia> foo(x) = println("Foo: ",x)
foo (generic function with 1 method)

julia> for i in 1:5
       foo(x) = println("Foo: ",i," - ", x)
       foo(i)
       end
Foo: 1 - 1
Foo: 2 - 2
Foo: 3 - 3
Foo: 4 - 4
Foo: 5 - 5

julia> foo(6)
Foo: 5 - 6
  • The for loop in a function does not:
julia> foo(x) = println("Foo: ",x)
foo (generic function with 1 method)

julia> function bar() 
       for i in 1:5 
           foo(x) = println("Foo: ",i," - ", x)
           foo(i)
       end
       end
bar (generic function with 1 method)

julia> bar()
Foo: 1 - 1
Foo: 2 - 2
Foo: 3 - 3
Foo: 4 - 4
Foo: 5 - 5

julia> foo(6)
Foo: 6
  • using @eval to redefine the function globally from inside the function, does mean that function body itself does not use the updated function:
julia> foo(x) = println("Foo: ",x)
foo (generic function with 1 method)

julia> function bar() 
       for i in 1:5 
           @eval foo(x) = println("Foo: ", $i," - ", x)
           foo(i)
       end
       end
bar (generic function with 1 method)

julia> bar()
Foo: 1
Foo: 2
Foo: 3
Foo: 4
Foo: 5

julia> foo(6)
Foo: 5 - 6
  • So you’d need to use Base.invokelatest in the function to restore the previous behavior faithfully. But performing the transformation from the top-level code to a function and littering every call with Base.invokelatest does not allow for any compiler optimization and is essentially equivalent to how top-level code is run anyways.

So I conclude, that this transformation is not mechanical for it to be actually useful.

1 Like

Parameters.jl

1 Like

That’s what I meant, yeah. But, as @abraemer shows, the different semantics might be of significance, too.

1 Like