Avoiding long unnamed parameters in constructors


#1

Sometimes I have structs with quite a few properties. This makes my constructor look really ugly and makes it difficult to modify later.

What do you I could do to improve this?

For example, see how the return is so ugly with so many arguments

mutable struct Scene
    field::String
    shader::GLShader
    renderer::GLShader
    pointcloud::GLBuffer
    cmdBuffer::GLBuffer
    meshBuffer::GLBuffer
    mesh::Array{Float64}
    normals::Array{Float64}
    files::Array{String}
    errorComputer::GLShader
    pointcloudVAO
end

function Scene(fieldPath::String)::Scene
    field = open(fieldPath) do file
        read(file, String)
    end
    computer = genFieldComputeShader(field)
    renderer = GLShader("MeshRenderer", "./vertex.glsl", "./fragment.glsl", ["\$df" => field])
    pointcloud, cmdBuffer, pointcloudVAO = computePointCloud(computer)
    errorComputer = GLShader("MeshDistanceErrorComputer", "./computeMeshError.glsl", ["\$df" => field])
    mesh, normals, meshBuffer = computeMeshFromPointCloud(cmdBuffer, pointcloud)
    return Scene(field, computer, renderer, pointcloud, cmdBuffer, meshBuffer, mesh, normals, [fieldPath], errorComputer, pointcloudVAO)
end

#2

Split it up on multiple lines?

[...]
    return Scene(field, computer, renderer, pointcloud,
                 cmdBuffer, meshBuffer, mesh, normals,
                 [fieldPath], errorComputer, pointcloudVAO)
end

#3

Consider a constructor method with keywords. Base.@kwdef and similar functionality in some packages (eg Parameters.jl) should make it easy.

Keywords should help with some of that. You could also group fields that belong together into composite types.


#4

Could you explain this a little bit more? I found https://github.com/JuliaLang/julia/blob/master/base/util.jl and I saw how to specify default values, but in my example I don’t have default values.

I don’t know if you are referring to @kwdef again or I am missing something else

Thanks!


#5

Read the example more carefully; you don’t need to specify default values (missing values will just error).

julia> Base.@kwdef struct Foo
           a
           b
       end
Foo

julia> Foo(a = 1, b = 2)
Foo(1, 2)

julia> Foo()
ERROR: UndefKeywordError: keyword argument a not assigned
Stacktrace:
 [1] Foo() at ./util.jl:673
 [2] top-level scope at none:0

#6

This improves things a little bit, but still, if I add a new property to the struct I will need to find in that list where exactly should it go.


#7

I see, I think this is what I was looking for, I’ll try it! Thx!

Another question, I’m trying to understand Julia more and I usually try to see similarities with other languages. With @kwdef I clearly see a similarity with Javascript:

function factory(params){
     ...
}
factory({
  param1: 123,
  param2: 34,
  ...
});

Is there also a way to solve this with a C++ like approach like:

class MyClass{
     MyClass(){
         this.prop1 = ...;
         this.prop2 = ...;
         ...
      }
}

?


#8

Not sure what the question is. But named keyword arguments exist in most modern languages. The macro is just a way to define them quickly, you can also make an outer constructor

Foo(; a, b) = Foo(a, b)

yourself.


#9

See for example https://stackoverflow.com/questions/52992375/julia-mutable-struct-with-attribute-which-is-a-function-and-code-warntype However, I wouldn’t recommend it.


#10

I also struggled with this.
Not so much that the constructor looked ugly, but rather that maintaining the code, especially calling the constructor, was easy to get wrong when rearranging, adding and deleting fields (which I do often).

So I wrote a macro to make this a bit easier.

macro CompositeFieldConstructor(T)
    dataType = eval(current_module(), T)
    esc(Expr(:call, T, fieldnames(dataType)...))
end

(Note: macro works on v0.6, probably need modifying to work with v1.0)

Essentially, the constructor needs to define a local variable with the same name for each field in the struct.
Order of variable definitions doesn’t matter.

So your constructor becomes:

function Scene(fieldPath::String)::Scene
    field = open(fieldPath) do file
        read(file, String)
    end
    shader = genFieldComputeShader(field)
    renderer = GLShader("MeshRenderer", "./vertex.glsl", "./fragment.glsl", ["\$df" => field])
    pointcloud, cmdBuffer, pointcloudVAO = computePointCloud(computer)
    errorComputer = GLShader("MeshDistanceErrorComputer", "./computeMeshError.glsl", ["\$df" => field])
    mesh, normals, meshBuffer = computeMeshFromPointCloud(cmdBuffer, pointcloud)
 
    return @CompositeFieldConstructor(Scene)
end

Note I had to change local variable computer to shader to match the field name.


#11

Wow!

Your macro is amazing!

I changed it to make it work with v1.0:

macro Compose(T)
    esc(Expr(:call, T, fieldnames(eval(T))...))
end

#12

To make it work from other modules (in v1):

macro Compose(T)
    esc(Expr(:call, T, fieldnames(Base.@eval(__module__, $(T)))...))
end

#13

I always do this for a constructor. I found it more convenient about the ordering. Seems this is no longer in the new documentation?

mutable struct Foo
    a::int 
    b::Float64
    ... 

    function Foo(a::Int, ...)
        this = new()
        this.b = 3.0 * a    # order does not matter
        this.a = a
        ......
        this
    end
end

#14

Inner constructors should be used sparingly cf the docs

It is considered good form to provide as few inner constructor methods as possible: only those taking all arguments explicitly and enforcing essential error checking and transformation. Additional convenience constructor methods, supplying default values or auxiliary transformations, should be provided as outer constructors that call the inner constructors to do the heavy lifting.