Dispatch on symbol name for `getproperty`

The documentation for getproperty has this example:

function Base.getproperty(obj::MyType, sym::Symbol)
    if sym === :special
        return obj.x + 1
    else # fallback to getfield
        return getfield(obj, sym)
    end
end

Is there a way to dispatch getproperty for a specific symbol? That is, something like

Base.getproperty(obj::MyType, sym::Val{:special}) = obj.x + 1

It seems strange having to go through a chain of if/else statements (if there are multiple properties) at run-time instead of at compile time.

I’m pretty sure it doesn’t, and that constant propagation handles this.

If you define

foo(m::MyType) = m.special

and call @code_llvm foo(m) you can see there are no branches.

2 Likes

You can do something like this:

getproperty(obj::MyType, s::Symbol)  = getproperty(obj, Val(s))
getproperty(obj::MyType, Val{:special}) = obj.x + 1

but you need to redefine getproperty for all symbols you want to be part of the interface.

The if ... else does create run time dispatch, allocations, etc. I have just been there last week.

An alternative is to put special as a type parameter of obj, to dispatch with:

function Base.getproperty(obj::MyType{Special}, sym::Symbol) where Special
    if sym === Special
        return obj.x + 1
    else # fallback to getfield
        return getfield(obj, sym)
    end
end

That eliminates the branch at compile time (but the signature of your object must be MyObj{:Special} or similar, with all the constructors adapted accordingly, which may not be more practical than redefine getproperty for all fields as the first alternative).

3 Likes

I like it!

You could still define a general fallback, even with Val, with something like this:

getproperty(obj::MyType, name::Val) = getfield(obj, typeof(name).parameters[1])

That’s with typeof(name).parameters[1] being the only way I’ve figured out to convert e.g. Val(:x) back to the symbol :x

This fails at obj.x because getproperty lacks a fallback branch, and there’s no method for Val{:x}. Adding goerz’s fallback works, though there is a way to do it without digging into internal fields:

getValue(::Val{T}) where T = T
getproperty(obj::MyType, name::Val) = getfield(obj, getValue(name))

# or if you don't need to decouple the two,
getproperty(obj::MyType, name::Val{T}) where T = getfield(obj, T)

I wouldn’t name it getproperty though, m.special is always going to lower to getproperty(m, ::Symbol), so dispatching on Val really should be done by a separate internal function.

More to the point, this part is inherently type-unstable, going from 1 Symbol type to many Val types based on the runtime Symbol instance. To get rid of that instability, you really need to bake information into the argument types, whether you dispatch on instances of Val{:special}, MyType{:special}, or a special singleton struct MySpecial end.

Getting rid of that instability isn’t often necessary. As DNF pointed out, the compiler does constant propagation, and it will do it with partial information for accessing struct fields or Tuple elements. Given foo(m::MyType) = m.special, the call foo(m) compiles m.special without branches even though m is not a constant.

3 Likes

That’s why I said to implement all the getindex(obj::MyType, ::Val{:x}) etc, required by the API. I was not aware of the getValue possibility (will try it).

Edit: one needs to call getfield then: getproperty(obj::MyObj, ::Val{:x}) = getfield(obj, :x).

When a function uses obj.special, hardcoded, the compiler is eliminating the instability and just using the final call here.

Well, it doesn’t always with the branched version. I have pulled my hair off in a real case where it failed to do that. And finding where was the problem was a pretty hard, btw, only with Chutchlu I was able to find the issue.

Maybe that is dependent on the number of properties.

Edit2: my case was somewhat more complicated, because the special name was a variable to be set on the construction of the object, thus I had to make it a type parameter anyway.

1 Like

Maybe it’s dependent on how big the branched method is? I’m not positive but I think branch elimination only works when the code is all in one place, so the branching method has to be inlined into the caller method that has the constant information for eliminating branches. I know that smaller methods like OP’s example are more easily inlined.

In that case, maybe your refactoring does help by splitting 1 big method with many branches into many smaller ::Val methods. Just propagating a constant s to an inlined getproperty(obj::MyType, s::Symbol) = getproperty(obj, Val(s)) would be enough to select the Val method at compile-time, and a small enough Val method itself could be inlined. Not sure how to test this hypothesis though. Testing the method foo(m::MyType) = m.special along with the other code written so far would work, but problem is how exactly to edit the branched getproperty in the OP to be un-inline-able. I don’t know how the compiler makes that decision: many branches, big branches, how big?

1 Like

Can you show an example? My, admittedly brief, test showed no such thing, and I got llvm code identical to a normal getfield, with no branches and zero allocations. As long as the Symbol argument is literal, and isn’t decided at runtime.

Edit: ok, so it’s a bit variable/unpredictable? But the example in the OP seems to constant fold fine.

My actual case contained quite complicated structures, containing another nested custom relatively complicated structures. I tested now here with this:

julia> struct S5
           a::Int
           b::Float32
           c::Float64
           d::Char
           e::Bool
       end

and it constant-folds well. Thus, I’m not sure if I can come up with a simple MWE. (it is also always possible that in the process of fixing my code I fixed this and something else at the same time… If at some point I end up disentangling that I will let you know).

@DNF : I think you are probably right (as usual), and what fixed my problem was the type-parameter for the variable struct name, and nothing else. Though I would like to see some guarantee of that behavior, because finding that issue inside a package is quite hard, because the allocating manifests in very unpredictable places.