Any way to skip a constructor for Accessors.jl-like syntax?

So this internal :new expression seems to be working again. I’m wondering if it’s possible to unsafely skip inner constructors’ validation to instantiate tweaks to existing instances with setting syntax like Accessors.jl.

julia> macro new(call::Expr)
         if call.head != :call
           return call
         else
           Expr(:new, call.args...)
         end
       end
@new (macro with 1 method)

julia> struct EvenInt
         x::Int
         function EvenInt(x::Int)
           iseven(x) || throw(ArgumentError("Must be even."))
           sleep(1) # pretend this is useful processing
           new(x)
         end
       end

julia> @time y = EvenInt(2)
  1.002108 seconds (4 allocations: 112 bytes)
EvenInt(2)

julia> @time @new(EvenInt(1)) # skips validation like mutation
  0.000001 seconds
EvenInt(1)

julia> using Accessors

julia> @time @reset y.x = 4 # goes through inner constructor
  1.034079 seconds (5.05 k allocations: 260.352 KiB, 2.56% compilation time: 100% of which was recompilation)
EvenInt(4)
1 Like

Yes, the way you do this is by overloading ConstructionBase.constructorof:

using Accessors, ConstructionBase

macro new(call::Expr)
    if call.head != :call
        esc(return call)
    else
        esc(Expr(:new, call.args...)) # Note this needs to be esc-d!
    end
end

struct EvenInt
    x::Int
    function EvenInt(x::Int)
        iseven(x) || throw(ArgumentError("Must be even."))
        sleep(1) # pretend this is useful processing
        new(x)
    end
end

ConstructionBase.constructorof(::Type{EvenInt}) = x -> @new EvenInt(convert(Int, x))

Then:

julia> @time y = EvenInt(2)
  1.002133 seconds (4 allocations: 112 bytes)
EvenInt(2)

julia> @time @reset y.x = 4 # compilation overhead
  0.028366 seconds (73.41 k allocations: 3.709 MiB, 99.89% compilation time)
EvenInt(4)

julia> @time @reset y.x = 4
  0.000005 seconds (1 allocation: 16 bytes)
EvenInt(4)

Now, whether or not this is a good idea is of course up for discussion. The authors of Accessors / ConstructionBase made a conscious choice to work in terms of properties and constructors rather than using @new because people put restrictions in constructors for a good reason.

That said, there are of course times where you want to be able to bypass this, so it’s good that there is a way (though I would like if there was a clean way to do this on a per-call basis, rather than globally overriding the behaviour for all instances of EvenInt).

2 Likes

Looks like this is actually not so hard to do by just defining a custom lens:

using Accessors: IndexLens, PropertyLens, ComposedOptic, setmacro, opticmacro, modifymacro

struct FieldLens{L}
    inner::L
end

(l::FieldLens)(o) = l.inner(o)
function Accessors.set(o, l::FieldLens{<:ComposedOptic}, val)
    o_inner = l.inner.inner(o)
    set(o_inner, FieldLens(l.inner.outer), val)
end
function Accessors.set(o, l::FieldLens{PropertyLens{prop}}, val) where {prop}
    setfield(o, val, Val(prop))
end

@generated function setfield(obj::T, val, ::Val{name}) where {T, name}
    fields = fieldnames(T)
    name ∈ fields || error("$(repr(name)) is not a field of $T, expected one of ", fields)
    Expr(:new, T, (name == field ? :val : :(getfield(obj, $(QuoteNode(field)))) for field ∈ fields)...)
end

macro setfield(ex)
    setmacro(FieldLens, ex, overwrite=false)
end
macro resetfield(ex)
    setmacro(FieldLens, ex, overwrite=true)
end

then define our type:

struct EvenInt
    x::Int
    function EvenInt(x::Int)
        iseven(x) || throw(ArgumentError("Must be even."))
        sleep(1) # pretend this is useful processing
        new(x)
    end
end

Regular @reset is still safe and slow:

julia> @time y = EvenInt(2)
  1.002596 seconds (4 allocations: 112 bytes)
EvenInt(2)

julia> @reset y.x = 5
ERROR: ArgumentError: Must be even.

julia> @time @reset y.x = 4
  1.002588 seconds (5 allocations: 128 bytes)
EvenInt(4)

But we now also have @resetfield which is unsafe and fast:

julia> @resetfield y.x = 5
EvenInt(5)

julia> @time @resetfield y.x = 4
  0.000005 seconds (1 allocation: 16 bytes)
EvenInt(4)
2 Likes

The call chain for setting a property, as in y = @set x.a = 123, goes like this by default:

  • first setproperties(x, (;a=123))
  • then constructorof(typeof(x))(123)
  • then the constructor.

Depending on the intended semantics of your type, you may want to overload either setproperties or constructorof – both are defined in ConstructionBase.jl.

1 Like