ANN: Setfield.jl Yet another package for working with immutables

@ChrisRackauckas Btw I am curious, why are @generated functions should not statically be compilable?
I would have guessed that f(obj) always works, if f(::typeof(obj)) was seen at static compiletime and
never works if f(::typeof(obj)) was not seen at compiletime. Why does it matter if f is generated?

That’s why you’d want to have something like constructor_of where you can register foreign “fake” constructor/factory function.

Keyword-only constructor is just a suboptimal solution to hook arbitrary pre-construction check/processing, which is (sometimes) needed to be done in the inner constructor. If it can be done with positional-only constructor then that’s also a nice solution.

Last time I checked, it was broken also in nightly :cry:

Really? Did you build it yourself? The nightlies haven’t built in a very long time so that would be the problem if you downloaded the binary.

No, I didn’t build myself. It was the downloaded binary. But I thought it was showing something like “N days older than master” where N < 10.

Redhat using COPR?

https://status.julialang.org/

FWIW I build a master from two days ago and it does not work:

julia> struct S{X,Y}
           x::X
           y::Y
           S(;x::X=1,y::Y=2) where {X,Y} = new{X,Y}(x,y)
           end

julia> Test.@inferred S(x=:a, y="")
ERROR: return type S{Symbol,String} does not match inferred return type Any
Stacktrace:
 [1] error(::String) at ./error.jl:33
 [2] top-level scope
1 Like

I was using https://julialangnightlies-s3.julialang.org/bin/linux/x64/julia-latest-linux64.tar.gz linked from https://julialang.org/downloads/nightlies.html

I’m not sure which one in the https://status.julialang.org/ was that.

It seems that’s what Travis is using. For example, this shows “Commit a237986bf9 (2018-02-20 19:08 UTC)” and the @inferred with just keyword-only constructor is broken:

This didn’t handle it?

https://github.com/JuliaLang/julia/pull/24795

Apparently not. You think this is a bug?

Worth opening an issue to track it (if that hasn’t been done already).

1 Like

https://github.com/JuliaLang/julia/issues/25918

3 Likes

It was apparently quite easy to get “theoretical” type-stability from the keyword-only approach, with a similar @generated trick:

@generated function recon(thing; kwargs...)
    fields = [Expr(:kw, k, :(thing.$k)) for k in fieldnames(thing)]
    return quote
        constructor = $constructor_of(thing)
        constructor(; $(fields...), kwargs...)
    end
end

https://github.com/tkf/Reconstructables.jl/commit/68075dafe54c749ecea1179c8d839e76b7d76753

Of course, it’s only “theoretical” at this point since the constructor itself is not type-stable.

Possible benefits over lens/setproperty approach are that (1) you can do a “batch” update in which the constructor is called only once and (2) it generates only one (or two [*]) function for each struct instead of length(fieldnames(...)) functions.

[*] I may need to turn constructor_of into a generated function for type-stability.

1 Like

I would have thought that with the kw-constructor you shouldn’t need a generated function for “theoretical” type stability.

Well, I don’t know if it is actually needed since I’ve never tried it with Julia with type-stable keyword-only constructor. I just thought the getfield & fieldnames combo in the following code would be hard for Julia to make it type-stable. (or not?)

function recon(thing; kwargs...)
    constructor = constructor_of(thing)
    defaults = [(n => getfield(thing, n)) for n in fieldnames(typeof(thing))]
    return constructor(; defaults..., kwargs...)
end

The type of array defaults becomes Array{Pair{Symbol}} (the “value” part is undetermined) for sufficiently complex struct. If the type of thing has type parameters that depend on the fields, the return type cannot be determined, right? Can you avoid it?

Also, in Julia 0.6 and 0.7, it seems that the type of defaults itself cannot be inferred:

using Base.Test

struct S{X,Y,Z}
    x::X
    y::Y
    z::Z

    S(x::X, y::Y, z::Z) where {X, Y, Z} = new{X, Y, Z}(x, y, z)
end

get_fields(thing) =
    [(n => getfield(thing, n)) for n in fieldnames(typeof(thing))]

s = S(1, 2.0, "3")
@show @inferred get_fields(s)  # fails

The most specific inference I can get out of variants of get_fields was when I used tuple and generator. But it still was not perfect.

There is a way to avoid @generated functions, have full type stability and no need of a keyword constructor. It can be adapted to both the Lens design and your design. I sketch for simplicity the case of only updating a single field.

macro register(T)
    M = current_module()
    fields = eval(M, :(fieldnames($T)))
    quote
        function Reconstructables.recon(thing::$T, field, val)
            new_fields = map($fields) do fld
                fld == field ? val : getfield(thing, field)
            end
            constructor_of($T)(new_fields...)
        end
    end
end

The price is that you need to add @register MyStruct to each struct you want to use this feature with. This is even possible for structs living in other modules.

Isn’t it generating the similar amount code as the @generated function version? How is it better?

Also, I want to have custom inner constructors. In that case, you need to generate keyword-only or positional-only constructors anyway for it to have some systematic mutation mechanism. So, “you don’t need keyword-only constructors” is not a valid appealing point for use-cases I have in mind.

1 Like

Good question. I don’t know if this is better then using @generated, as I don’t know what the problem with @generated is. If its only the amount of generated code, then I don’t think its any better. I even think there is no way to get the same performance with less generated code. At the end of the day each variant with perfect performance has to generate fully specialized @code_llvm. So I guess the total amount of generated code will always be the same for fully typestable variants.

In Parameters.jl I require that there is a positional inner constructor for all fields defined (either automatically via @with_kw or by the user). That does all the enforcing of constraints and other things you may want to do. All other inner/outer constructors then go through that one. I think this scheme should be able to cover almost all cases.

1 Like

Yeah I think this is good design. I usually do the same thing:

  1. Have one and only one “low level” inner constructor. All other constuctors must go through this one in the end. Its only job is to enforce invariants, it does nothing else. It is positional and its signature is the fields of the struct.

  2. Have whatever high level outer constructors you like.

I think that’s a source of bugs. If I understand correctly, that means users have to manually call the inner constructor from the outer constructor with positional arguments. If there are many structs with more than 5 fields (say) it would be terrifying to do any kind of refactoring touching the fields.

(OK. We need to do that already when calling new. But please don’t increase the burden.)

Obviously, it does not imply that @with_kw is a bad design since it also generates a keyword-only constructor as well.

If you let users to write a inner-constructor, it has to be usable by humans. Positional-only constructor is not for humans. Humans are good at referring to things by names, not positions. I think that’s the primary reason why we use structs instead of tuples.

Then what is required for packages like Reconstructables.jl and Setfield.jl are to generate constructor usable by programs. That means a keyword-only or positional-only constructor. They both have pros and cons.