ConstructionBase.jl
ConstructionBase
allows flexible construction and destructuring of objects. There are two levels of under which this can be done:
The raw level
This is where Base.fieldnames
, Base.getfield
, Base.setfield!
live. This level is what an object is ultimately composed of including all private details. At the raw level ConstructionBase
adds constructorof
and getfields
.
The semantic level
This is where Base.propertynames
, Base.getproperty
and Base.setproperty!
live. This level is typically the public interface of a type, it may hide private details and do magic tricks. At the semantic level ConstructionBase
adds setproperties
and getproperties
.
Interface
ConstructionBase
ConstructionBase.constructorof
ConstructionBase.getfields
ConstructionBase.getproperties
ConstructionBase.setproperties
ConstructionBase
— ModuleConstructionBase
ConstructionBase is a very lightweight package, that provides primitive functions for construction of objects:
setproperties(obj::MyType, patch::NamedTuple)
constructorof(MyType)
These functions can be overloaded and doing so provides interoperability with the following packages:
ConstructionBase.constructorof
— Functionconstructorof(T::Type) -> constructor
Return an object constructor
that can be used to construct objects of type T
from their field values. Typically constructor
will be the type T
with all parameters removed:
julia> using ConstructionBase
julia> struct T{A,B}
a::A
b::B
end
julia> constructorof(T{Int,Int})
T
It is however not guaranteed, that constructor
is a type at all:
julia> struct S
a
b
checksum
S(a,b) = new(a,b,a+b)
end
julia> ConstructionBase.constructorof(::Type{<:S}) =
(a, b, checksum=a+b) -> (@assert a+b == checksum; S(a,b))
julia> constructorof(S)(1,2)
S(1, 2, 3)
julia> constructorof(S)(1,2,4)
ERROR: AssertionError: a + b == checksum
Instead constructor
can be any object that satisfies the following properties:
- It must be possible to reconstruct an object from the elements of
getfields
:
ctor = constructorof(typeof(obj))
@assert obj == ctor(getfields(obj)...)
@assert typeof(obj) == typeof(ctor(getfields(obj)...))
- The other direction should hold for as many values of
args
as possible:
ctor = constructorof(T)
getfields(ctor(args...)) == args
For instance given a suitable parametric type it should be possible to change the type of its fields:
julia> struct T{A,B}
a::A
b::B
end
julia> t = T(1,2)
T{Int64, Int64}(1, 2)
julia> constructorof(typeof(t))(1.0, 2)
T{Float64, Int64}(1.0, 2)
julia> constructorof(typeof(t))(10, 2)
T{Int64, Int64}(10, 2)
constructorof
belongs to the raw level. constructorof
is generated for all anonymous Function
s lacking constructors, identified as having gensym
#
in their names. A custom struct <: Function
with a gensym
name may need to define constructorof
manually.
See also Tips section in the manual
ConstructionBase.getfields
— Functiongetfields(obj) -> NamedTuple
getfields(obj::Tuple) -> Tuple
Return a NamedTuple
containing the fields of obj
. On Tuples
getfields
is the identity function instead, since Tuple
fields have no symbolic names.
Examples
julia> using ConstructionBase
julia> struct S{A,B}
a::A
b::B
end
julia> getfields(S(1,2))
(a = 1, b = 2)
julia> getfields((a=10,b=20))
(a = 10, b = 20)
julia> getfields((4,5,6))
(4, 5, 6)
Specification
getfields
belongs to the the raw level. Semantically getfields
boils down to getfield
and fieldnames
:
function getfields(obj::T) where {T}
fnames = fieldnames(T)
NamedTuple{fnames}(getfield.(Ref(obj), fnames))
end
However the actual implementation can be more optimized. For builtin types, there can also be deviations from this semantics:
getfields(::Tuple)::Tuple
sinceTuples
don't have symbolic fieldnames- There are some types in
Base
that haveundef
fields. Since accessing these results in an error,getfields
instead just omits these.
Implementation
The semantics of getfields
should not be changed for user defined types. It should return the raw fields as a NamedTuple
in the struct order. In other words it should be equivalent to
function getfields(obj::T) where {T}
fnames = fieldnames(T)
NamedTuple{fnames}(getfield.(Ref(obj), fnames))
end
even if that includes private fields of obj
. If a change of semantics is desired, consider overloading getproperties
instead. See also getproperties
, constructorof
ConstructionBase.getproperties
— Functiongetproperties(obj)::NamedTuple
getproperties(obj::Tuple)::Tuple
Return the properties of obj
as a NamedTuple
. Since Tuple
don't have symbolic properties, getproperties
is the identity function on tuples.
Examples
julia> using ConstructionBase
julia> struct S
a
b
c
end
julia> s = S(1, 2, 3)
S(1, 2, 3)
julia> getproperties(s)
(a = 1, b = 2, c = 3)
julia> getproperties((10,20))
(10, 20)
Specification
getproperties
belongs to the semantic level. getproperties
guarantees a couple of invariants. When overloading it, the user is responsible for ensuring them:
getproperties
should be consistent withBase.propertynames
,Base.getproperty
,Base.setproperty!
. Semantically it should be equivalent to:julia function getproperties(obj) fnames = propertynames(obj) NamedTuple{fnames}(getproperty.(Ref(obj), fnames)) end
getproperties
is defined in relation tosetproperties
so that:
The only exception from this semantics is that undefined properties may be avoided in the return value ofobj == setproperties(obj, getproperties(obj))
getproperties
.
Implementation
getproperties
is defined by default for all objects. It should be very rare that a custom type MyType
, has to implement getproperties(obj::MyType)
. Reasons to do so are undefined fields or performance considerations.
ConstructionBase.setproperties
— Functionsetproperties(obj, patch::NamedTuple)
Return a copy of obj
with properties updated according to patch
.
Examples
julia> using ConstructionBase
julia> struct S
a
b
c
end
julia> s = S(1,2,3)
S(1, 2, 3)
julia> setproperties(s, (a=10,c=4))
S(10, 2, 4)
julia> setproperties((a=1,c=2,b=3), (a=10,c=4))
(a = 10, c = 4, b = 3)
There is also a convenience method, which builds the patch
argument from keywords:
setproperties(obj; kw...)
Examples
julia> using ConstructionBase
julia> struct S
a
b
c
end
julia> o = S(10, 2, 4)
S(10, 2, 4)
julia> setproperties(o, a="A", c="cc")
S("A", 2, "cc")
Specification
setproperties
belongs to the semantic level. If satisfies the following invariants:
- Purity:
setproperties
is supposed to have no side effects. In particularsetproperties(obj, patch::NamedTuple)
may not mutateobj
. - Relation to
propertynames
andfieldnames
:setproperties
relates topropertynames
andgetproperty
, not tofieldnames
andgetfield
. This means that any subsetp₁, p₂, ..., pₙ
ofpropertynames(obj)
is a valid set of properties, with respect to which the lens laws below must hold. setproperties
is defined in relation togetproperties
so that:obj == setproperties(obj, getproperties(obj))
setproperties
should satisfy the lens laws:
For any valid set of properties p₁, p₂, ..., pₙ
, following equalities must hold:
- You get what you set.
let obj′ = setproperties(obj, ($p₁=v₁, $p₂=v₂, ..., $pₙ=vₙ))
@assert obj′.$p₁ == v₁
@assert obj′.$p₂ == v₂
...
@assert obj′.$pₙ == vₙ
end
- Setting what was already there changes nothing:
@assert setproperties(obj, ($p₁=obj.$p₁, $p₂=obj.$p₂, ..., $pₙ=obj.$pₙ)) == obj
- The last set wins:
let obj′ = setproperties(obj, ($p₁=v₁, $p₂=v₂, ..., $pₙ=vₙ)),
obj′′ = setproperties(obj′, ($p₁=w₁, $p₂=w₂, ..., $pₙ=wₙ))
@assert obj′′.$p₁ == w₁
@assert obj′′.$p₂ == w₂
...
@assert obj′′.$pₙ == wₙ
end
Implementation
For a custom type MyType
, a method setproperties(obj::MyType, patch::NamedTuple)
may be defined. When doing so it is important to ensure compliance with the specification.
Prefer to overload
constructorof
whenever makes sense (e.g., nogetproperty
method is defined). Defaultsetproperties
is defined in terms ofconstructorof
andgetproperties
.If
getproperty
is customized, it may be a good idea to definesetproperties
.
The signature setproperties(obj::MyType; kw...)
should never be overloaded. Instead setproperties(obj::MyType, patch::NamedTuple)
should be overloaded.
Tips for designing types
When designing types from scratch, it is often possible to structure the types in such a way that overloading constructorof
or setproperties
is unnecessary in the first place. It let types in your package work nicely with the ecosystem built on top of ConstructionBase
even without explicitly depending on it. For simple struct
s whose type parameters can be determined from field values, ConstructionBase
works without any customization, provided that the "type-less" constructor exists. However, it is often useful or required to have type parameters that cannot be determined from field values. One way to solve this problem is to define singleton types that would determine the type parameters:
abstract type OutputBy end
struct Mutating <: OutputBy end
struct Returning <: OutputBy end
struct Add{O <: OutputBy, T}
outputby::O
value::T
end
(f::Add{Mutating})(y, x) = y .= x .+ f.value
(f::Add{Returning})(x) = x .+ f.value
add1! = Add(Mutating(), 1)
using ConstructionBase
add2 = constructorof(typeof(add1!))(Returning(), 2)
add2(1)
# output
3
setproperties
works as well:
add3 = setproperties(add2; value=3)
add3(1)
# output
4
Note that no overloading of ConstructionBase
functions was required. Importantly, this also provides an interface to change type parameters out-of-the-box:
add3! = setproperties(add3; outputby=Mutating())
add3!([0], 1)
# output
1-element Vector{Int64}:
4
Furthermore, it would work with packages depending on ConstructionBase
such as Setfield.jl.
using Setfield: @set
add3′ = @set add3!.outputby = Returning()
add3′ === add3
# output
true
If it is desirable to keep fields as an implementation detail, combining trait functions and Setfield.FunctionLens
may be useful:
OutputBy(x) = typeof(x)
OutputBy(::Type{<:Add{O}}) where O = O()
using Setfield: Setfield, @lens
Setfield.set(add::Add, ::typeof(@lens OutputBy(_)), o::OutputBy) =
@set add.outputby = o
obj = (add=add3!,)
obj′ = @set OutputBy(obj.add) = Returning()
obj′ === (add=add3,)
# output
true
Setfield.set(::Type{Add{O0, T}}, ::typeof(@lens OutputBy(_)), ::O1) where {O0, T, O1 <: OutputBy} =
Add{O1, T}
T1 = typeof(add3!)
T2 = @set OutputBy(T1) = Returning()
T2 <: Add{Returning}
# output
true