julia icon indicating copy to clipboard operation
julia copied to clipboard

provide access to @kwdef default values

Open aplavin opened this issue 1 year ago • 2 comments

As of now, there's no way to access default field values passed to @kwdef, aside from parsing the constructor signature (https://discourse.julialang.org/t/how-to-get-default-values-of-a-functions-kwargs/66158/2).

This PR adds Base.kwdef_defaults(::Type) = (default fieldvals) for all structs defined with @kwdef. This function can either be made public immediately, or remain internal for some time.

Fixes https://github.com/JuliaLang/julia/issues/50876. Useful in various cases, for example in pretty-printing: knowing the defaults makes it possible for user show() methods to omit fields with default values.

That's what @kwdef now expands to:

julia> @macroexpand @kwdef struct S
               a = 1
               b::Int = 2
               c
       end
quote
    #= REPL[22]:51 =#
    begin
        $(Expr(:meta, :doc))
        struct S
            #= REPL[25]:2 =#
            a
            #= REPL[25]:3 =#
            b::Int
            #= REPL[25]:4 =#
            c
        end
    end
    #= REPL[22]:52 =#
    function S(; a = 1, b = 2, c)
        #= REPL[25]:1 =#
        S(a, b, c)
    end
    #= REPL[22]:53 =#
    Base.kwdef_defaults(::Base.Type{S}) = begin
            #= REPL[22]:53 =#
            (; a = 1, b = 2)
        end
end

aplavin avatar Aug 27 '24 11:08 aplavin

IMO this is worth having and needs to be in Base (or wherever @kwdef is defined). This sort of introspection function is much more robust, extensible, and performant than Julia's generic reflection utilities.

Tagging triage to bikeshed the name of this new feature that will likely land as public API, and because as a matter of principle I think it's good to check with many others about adding new API.

LilithHafner avatar Aug 27 '24 13:08 LilithHafner

What will happen if the default is non-constant:

using Random
const RNG = Xoshiro(1234)

Base.@kwdef struct S
    a::Int = rand(RNG,1:3)
end

@assert S() == S(1)
@assert S() == S(2)

jw3126 avatar Aug 28 '24 06:08 jw3126

This will happen:

x@x:~$ julia +pr55599
  o  | Version 1.12.0-DEV.1100 (2024-08-27)
 o o | aplavin:patch-18/855e2096a0a (fork: 2 commits, 1 day)
julia> using Random

julia> const RNG = Xoshiro(1234)
Xoshiro(0x9951797c85a704f1, 0xb9d66be14dfba82b, 0xb170153285fd9556, 0xe90a07f7bdd1fd77, 0x9d4b5ee33e4bd661)

julia> Base.@kwdef struct S
           a::Int = rand(RNG,1:3)
       end

julia> @assert S() == S(1)

julia> @assert S() == S(2)

julia> Base.kwdef_defaults(S)
(a = 1,)

julia> Base.kwdef_defaults(S)
(a = 3,)

I don't see an easy fix with this API. Specifically, I'm concerned that printing based on if a field value equals the default value is broken in this case.

LilithHafner avatar Aug 28 '24 12:08 LilithHafner

Well, I see several solutions, for example:

  • Just carefully define what the function does: basically, say that T(; kwargs...) is equivalent to T(; kwdef_default(T)..., kwargs...). Stuff like shortened show has better be opt-in anyway (and outside of kwdef scope). For constant defaults – the vast majority of usage cases – users can enable/implement it then.
  • Documeent kwdef_defaults() as best-effort, use infer_effects there, and return nothing if defaults potentially have sideeffects.

aplavin avatar Aug 28 '24 13:08 aplavin

Well, I see several solutions, for example:

* Just carefully define what the function does: basically, say that `T(; kwargs...)` is equivalent to `T(; kwdef_default(T)..., kwargs...)`. Stuff like shortened show has better be opt-in anyway (and outside of kwdef scope). For constant defaults – the vast majority of usage cases – users can enable/implement it then.

* Documeent `kwdef_defaults()` as best-effort, use `infer_effects` there, and return `nothing` if defaults potentially have sideeffects.

I like your first proposal. I think we should not rely on fancy guessing and instead just document that keywork expressions are evaluated with each call to kwdef_defaults which can be funny for non pure expressions.

jw3126 avatar Aug 28 '24 13:08 jw3126

Note this is part of what I proposed and have implemented in the StructUtils.jl package: https://hackmd.io/@quinnj/HkJ96bxCa (proposal), StructUtils.jl

quinnj avatar Aug 28 '24 15:08 quinnj

For some reflection tasks I would actually like to access also the function in cases like @jw3126 pointed out, couldn't we add another function for this? something like

using Random
const RNG = Xoshiro(1234)

Base.@kwdef struct S
    a::Int = rand(RNG,1:3)
    b::Int = 1
end

Base.kwdef_defaults(S, expr=true) # which would return something like (a = :(rand(RNG, 1:3)), b = :1)

Tortar avatar Aug 28 '24 19:08 Tortar

I think having this feature misleads you to think the defaults are fixed constants, when they are not. It just seems incompatible to me with the semantics we have of evaluating the default expressions on each construction.

JeffBezanson avatar Aug 29 '24 14:08 JeffBezanson

I think having this feature misleads you to think the defaults are fixed constants, when they are not. It just seems incompatible to me with the semantics we have of evaluating the default expressions on each construction.

I also really like the proposal of expanding a constructor to a call of such a defaults function. Thus, I'd say, in order to prevent people from believing defaults are fixed constants we should both document the behavior and maybe slightly change the name to something like kwdef_instantiate or kwdef_init or kwdef_setup or kwdef_prepare etc.

Edit: or maybe even something more descriptive but longer: kwdef_instantiate_defaults or kwdef_init_defaults since it most probably will only be used in a few cases anyway.

rapus95 avatar Aug 29 '24 20:08 rapus95

I also implemented something a while ago mostly only for printing and ssrialization. A special singleton is defined to distinguish the ones with default value and the ones with no default values.

https://configurations.rogerluo.dev/stable/ref/#Configurations.field_defaults-Union{Tuple{Type{T}},%20Tuple{T}}%20where%20T

Roger-luo avatar Aug 29 '24 22:08 Roger-luo

if we were to ditch kwdef from the name and go with init_defaults(::Type) this could become the backbone for a more generic and extensible defaults framework. Though, for non-kwdef types we'd need a different approach since there position matters. For now we could stick to Returns(nothing) as the catch-all-case if someone calls it for non-kwdef types.

@quinnj's StructUtils.jl already has solutions for extending that concept. And each approach that introduces new trait functions is a good approach in my opinion 😄

Edit: To clarify, this shouldn't become the new constructor in disguise but instead allow for splitting the constructor in two parts. One that solely depends on the type signature and one that constructs the final struct from the result of the previous part and more arguments. As such, we should advise against stateful defaults, shouldn't we?

Two possible ideas: The default constructor will create an empty new() instance, call that defaults function, apply the values to the instance and then apply the arguments of the default constructor on top of that.

Alternatively if we want a more selective approach, we could allow for a signature like init_defaults(::Type, field::Symbol) so only needed ones are instantiated. Used in a constructor which first applies the arguments given to an empty new() instance and then calls that function for all remaining uninitialized fields.

rapus95 avatar Aug 30 '24 02:08 rapus95

I think the main complexity is that the default for a field can depend on previous fields, so generating the defaults can't be entirely separated from a constructor call.

JeffBezanson avatar Sep 12 '24 22:09 JeffBezanson

Fow now, I created https://github.com/JuliaAPlavin/KwdefHelpers.jl that extract kwdef default arguments even in complex scenarios:

julia> @kwdef struct MyS{T}
           somefield = 123
           another::Union{Int,Nothing} = nothing
           somemore
           lastone::Vector{T} = [1+2im, somemore, somefield]
       end

julia> kwdef_defaults(MyS)
(somefield = 123, another = nothing)

julia> kwdef_defaults(MyS; somemore=567)
(somefield = 123, another = nothing, somemore = 567, lastone = [1 + 2im, 567, 123])

It's being registered in General and everyone is welcome to use KwdefHelpers.jl if they need this functionality.

Of course, would be nice to have something in Julia itself, but I understand the complicating concerns.

aplavin avatar Sep 24 '24 16:09 aplavin

Let's see how that package goes and revisit this in a few months to see if something consistent, useful, and belonging in Base emerges.

LilithHafner avatar Sep 26 '24 13:09 LilithHafner