julia
julia copied to clipboard
provide access to @kwdef default values
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
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.
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)
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.
Well, I see several solutions, for example:
- Just carefully define what the function does: basically, say that
T(; kwargs...)is equivalent toT(; 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, useinfer_effectsthere, and returnnothingif defaults potentially have sideeffects.
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.
Note this is part of what I proposed and have implemented in the StructUtils.jl package: https://hackmd.io/@quinnj/HkJ96bxCa (proposal), StructUtils.jl
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)
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 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.
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
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.
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.
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.
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.