Struct with partial explicit constructs that init subset of fields

Hi, I have a struct with several fields, wherein various scenarios only some of them are filled.
The total amount of combinations of which fields are filled is too big to have different types for each.

For sake of simplicity, let’s say I have three numeric fields, and I want to be able to construct the struct while providing any two of them:

struct abc
    a::Int
    b::Int
    c::Int
    abc(a,b)=new(a,b,0)
    abc(b,c)=new(0,b,c)
end

abc1 = abc(a=1, b=2)
abc2 = abc(b=3, c=4)

However, this fails, as both constructors are the same:

MethodError: no method matching abc(; a=1, b=2)
Closest candidates are:
  abc(::Any, ::Any) at In[3]:6 got unsupported keyword arguments "a", "b"

Note that not every combination of fields is acceptable, so I can’t just have a single constructor with default values for all the fields.

So is using wrapper structs for each field the correct way? Seems IMHO like a long overkill…

struct Xwrap
    value::Int
end

struct Ywrap
    value::Int
end

struct Zwrap
    value::Int
end

struct xyz
    x::Int
    y::Int
    z::Int
    xyz(x::Xwrap,y::Ywrap)=new(x.value,y.value,0)
    xyz(y::Ywrap,z::Zwrap)=new(0,y.value,z.value)
end

xyz1 = xyz(Xwrap(1), Ywrap(2))
xyz2 = xyz(Ywrap(3), Zwrap(4))

Any suggestions on how to make it clear and concise are welcome, thanks!

The first idea that crossed my mind, not sure if it is any good:

struct abc
    a::Int
    b::Int
    c::Int
    abc(a, b, ::Val{:ab}) = new(a, b, 0)
    abc(b, c, ::Val{:bc}) = new(0, b, c)
end

@show abc1 = abc(1, 2, Val(:ab))
@show abc2 = abc(3, 4, Val(:bc))

yielding

abc1 = abc(1, 2, Val(:ab)) = abc(1, 2, 0)
abc2 = abc(3, 4, Val(:bc)) = abc(0, 3, 4)
1 Like

What if the constructors were the same as you have them, but with keyword arguments instead of default positional arguments? (Add semicolons after the opening parentheses to the inner constructors.) If I remember correctly you can also make them mandatory by not providing any value, in fact you might have to do this to make the dispatch unambiguous. I don’t have a terminal to hand so I can’t check sorry.

Edit: I don’t this will work as described because apparently you can’t dispatch on keyword arguments, even if they are mandatory. I think I have a sneaky way around it though, I’ll post it if I get it working…

2 Likes

If you have sane defaults for the uninitialized fields, then @Base.kwdef seems appropriate:

  @kwdef typedef

This is a helper macro that automatically defines a keyword-based constructor for the type declared in the expression typedef, which must be a struct or mutable struct expression. The default argument is supplied by declaring fields of the form field::T = default or field = default. If no default is provided then the keyword argument becomes a required keyword argument in the resulting type constructor.

Inner constructors can still be defined, but at least one should accept arguments in the same form as the default inner constructor (i.e. one positional argument per field) in order to function correctly with the keyword outer constructor.

Examples

  julia> Base.@kwdef struct Foo
             a::Int = 1         # specified default
             b::String          # required keyword
         end
  Foo
  
  julia> Foo(b="hi")
  Foo(1, "hi")
  
  julia> Foo()
  ERROR: UndefKeywordError: keyword argument b not assigned
  Stacktrace:
  [...]
2 Likes

This looks reasonable, but OP wants

Not sure if this is possible with @Base.kwdef?

1 Like

Right, that’s not possible with @kwdef.

Then, manual dispatch with keywords is probably the best one can get, albeit a bit tedious if there are lots of combinations:

struct abc
    a::Int
    b::Int
    c::Int
end

abc(; a=nothing, b=nothing, c=nothing) = __abc(a, b, c)

__abc(a::Number, b::Number, c::Nothing) = abc(Int(a), Int(b), 0)
__abc(a::Nothing, b::Number, c::Number) = abc(0, Int(b), Int(c))
2 Likes

In the actual case, the fields are of different types, and there are many more of them.
So I prefer a syntax where there are more explicit key-value pairs of field name and value.

Re a::Nothing, that’s very interesting, thanks!
What does it mean? That a parameter can be skipped and it will be ignored?

Why doesn’t it work when I use a function?

f1(a::Nothing,b::Number) = println("f(b)")
f1(a::Number,b::Nothing) = println("f(a)")
f1(a::Number,b::Number) = println("f(a,b)")

f1(a=1)

I’m getting this error:

MethodError: no method matching f1(; a=1)
Closest candidates are:
  f1(::Nothing, ::Int64) at In[62]:1 got unsupported keyword argument "a"
  f1(::Nothing, ::Number) at In[65]:1 got unsupported keyword argument "a"
  f1(::Nothing, ::Any) at In[61]:1 got unsupported keyword argument "a"

And this code:

f1(; a::Nothing,b::Number) = println("f(b)")
f1(; a::Number,b::Nothing) = println("f(a)")
f1(; a::Number,b::Number) = println("f(a,b)")

f1(a=1)

Gives this error:

UndefKeywordError: keyword argument b not assigned

What am I missing about keyword arguments here?

I think you are confusing positional and keyword arguments in your first example. You are defining your function with positional arguments then trying to call it with keyword arguments (by specifying a=1 in the function call, it makes it a keyword argument).

In the second example, you’ve specified the type of a,b and c but not the default value. That means that they are all mandatory when calling that particular function signature.

You might find it helpful to have a good read of the functions documentation.

1 Like

IMO a variation of your initial proposal is what may suite the best. Perhaps if these acceptable combinations of parameters comprise different setups, you can use something like:

struct Setup1
   a::Int
   b::Int
end
struct Setup2
   a::Int
   c::Int
end
@kwdef struct xyz
    a::Int = 0
    b::Int = 0
    c::Int = 0
end
xyz(s::Setup1) = xyz(;a=s.a, b=s.b)
xyz(s::Setup2) = xyz(;a=s.a, c=s.c)

mysetup = xyz(Setup1(a=1,b=2))
etc.

Nothing is just a type with a single value: nothing (all lowercase). It does not have any special behaviour that allows for skipping or ignoring parameters, but you can use it for this purpose (as you could use any other type, especially the empty ones). You can use them in both positional and keyword arguments but each use is considerably different.

In positional arguments:

julia> f1(a::Nothing,b::Number) = println("f(b)")
f1 (generic function with 1 method)

julia> f1(a::Number,b::Nothing) = println("f(a)")
f1 (generic function with 2 methods)

julia> f1(a::Number,b::Number) = println("f(a,b)")
f1 (generic function with 3 methods)

julia> f1(nothing, 10)
f(b)

julia> f1(10, nothing)
f(a)

julia> f1(10, 10)
f(a,b)

Basically, you can use it to mark which parameters you are not passing. My only annoyance with this solutions is that nothing is too long of a word. You can use a gloabl const SKIP = nothing or something like that to reduce the typing. Or even create a new empty type like struct S; end but then you need to define and call the code the following way:

julia> f1(a::S,b::Number) = println("f(b)")
f1 (generic function with 1 method)

julia> f1(a::Number,b::S) = println("f(a)")
f1 (generic function with 2 methods)

julia> f1(a::Number,b::Number) = println("f(a,b)")
f1 (generic function with 3 methods)

julia> f1(S(), 10)
f(b)

julia> f1(10, S())
f(a)

julia> f1(10, 10)
f(a,b)

The use of nothing in keyword parameters is considerably different because you do the dispatch manually inside the function.

julia> function f1(; a = nothing, b = nothing)
           if a !== nothing && b === nothing
               println("f(a)")
           elseif a === nothing && b !== nothing
               println("f(b)")
           elseif a !== nothing && b !== nothing
               println("f(a, b)")
           end
       end
f1 (generic function with 1 method)


julia> f1(; a = 1)
f(a)

julia> f1(b = 2)
f(b)

julia> f1(; b = 1, a = 2)
f(a, b)

You can also combine the two approaches (positional and keyword), you cannot combine them at the same call, but you can call either way and gain the benefit you do not need the manual dispatch (the positional solution will do the dispatch for you). Note that if you pass a combination of parameters that do not exist to the keyword alternative it will accept it and pass it to the positional that will then error with MethodError (I think this is good behaviour).

julia> f1(a::Nothing,b::Number) = println("f(b)")
f1 (generic function with 1 method)

julia> f1(a::Number,b::Nothing) = println("f(a)")
f1 (generic function with 2 methods)

julia> f1(a::Number,b::Number) = println("f(a,b)")
f1 (generic function with 3 methods)

julia> f1(; a = nothing, b = nothing) = f1(a, b)
f1 (generic function with 4 methods)

julia> f1(nothing, 1)
f(b)

julia> f1(1, nothing)
f(a)

julia> f1(1, 1)
f(a,b)

julia> f1(; a = 1)
f(a)

julia> f1(b = 1)
f(b)

julia> f1(; b = 2, a = 1)
f(a,b)

julia> f1()
ERROR: MethodError: no method matching f1(::Nothing, ::Nothing)
Closest candidates are:
  f1(::Nothing, ::Number) at REPL[1]:1
  f1(::Number, ::Nothing) at REPL[2]:1
Stacktrace:
 [1] f1(; a::Nothing, b::Nothing) at ./REPL[4]:1
 [2] f1() at ./REPL[4]:1
 [3] top-level scope at REPL[13]:1
2 Likes

Thanks for Nothing and nothing, it was much more than nothing! :laughing:

What if I don’t have the parameter names, but just their type, do I have to wrap them for multiple dispatch to distinguish between them?

Say I have this use case with two fields of the same value type but different meaning:

const VelocityT = Float32
const AccelerationT = Float32

struct MovementInfo
    veloc::VelocityT
    accel::AccelerationT
end

struct WrapVelocityT value::VelocityT end
struct WrapAccelerationT value::AccelerationT end

MovementInfo(v::VelocityT) = MovementInfo(v,0.0)
MovementInfo(a::AccelerationT) = MovementInfo(0.0,a)
MovementInfo(v::WrapVelocityT) = MovementInfo(v.value,0.0)
MovementInfo(a::WrapAccelerationT) = MovementInfo(0.0,a.value)

Then m1 and m2 are the same (not what we wanted), and only m3 and m4 are different and correct field is assigned.

m1 = MovementInfo(VelocityT(5.0))  # MovementInfo(0.0f0, 5.0f0) -- incorrect
m2 = MovementInfo(AccelerationT(5.0))       # MovementInfo(0.0f0, 5.0f0)

m3 = MovementInfo(WrapVelocityT(5.0))       # MovementInfo(5.0f0, 0.0f0)
m4 = MovementInfo(WrapAccelerationT(5.0))   # MovementInfo(0.0f0, 5.0f0)

Is this proper use of types and wrappers?

Which way do you think is better (keyword argument or wrapper arguments) and why?

Not entirely sure if I understand. How do you would not have parameter names? Both positional and keyword parameters generally have names.

m1 and m2 are the same because VelocityT and AccelerationT are not new types, just aliases for Float32, so the second definition has the same signature and replaces the first. The whole code could be rewritten replacing both of them by Float32 and it would be semantically exactly the same.

WrapVelocityT and WrapAccelerationT are pretty standard wrappers. I see nothing wrong about them.

It will always depends on your case. I think wrappers add some extra complexity and inconveniences, so if you go with them their use must be justified. If two or more wrapped values can be passed they may end up harder to combine with each other too: either they need to be given in the right order, or you need to manually allow them to be given in any order. I would go with my keyword + positional solution (the last example of my last post) because it allows the flexibility of calling with any combination of parameters in any order, and if you did not create a positional method that takes such combination then the user will receive a nice MethodError that will more or less point them to the right direction. You can also detect the error yourself and throw a better error message without needing to change anything externally (just the inner code of the keyword method). That said, I think that Gadfly.jl uses wrappers extensively to implement their grammar of graphics.

1 Like

For more explicit reference, here is how it does it with a macro that defines a type [1] and here is how this macro is used to create a type [2].

Taking that example, I was able to build my @boxedtype macro:

import Base: copy, convert

macro boxedtype(name::Symbol, type::Symbol)
    esc(quote
        Core.@__doc__ struct $(name)
            value::$(type)
        end
        Base.copy(a::$(name)) = $(name)(a)
        Base.convert(::Type{$(type)}, x::$(name)) = x.value
        $(name)
    end)
end

"Foo1 boxes Int"
@boxedtype Foo1 Int

"Foo2 boxes Float"
@boxedtype Foo2 Float32

process(x::Foo1) = "foo1"
process(x::Foo2) = "foo2"
process(x::Int) = "int"
process(x::Float32) = "float"

y1 = Foo1(15)
y2 = Foo2(3.14)

@assert process(y1) == "foo1"
@assert process(y2) == "foo2"
@assert process(y1.value) == "int"
@assert process(y2.value) == "float"

function explicit_conv_type(x::Foo1)
    z::Int = x
    return z * 2
end

@assert explicit_conv_type(y1) == Int(30)

[1] Gadfly.jl/varset.jl at master · GiovineItalia/Gadfly.jl · GitHub
[2] Gadfly.jl/data.jl at master · GiovineItalia/Gadfly.jl · GitHub