Base.getproperty via Val types

I’m familiar with Base.getproperty, and often structure it as a big list of if statements, with a fallback to getfield. Sometimes, though, having to define all the behaviour in one place can be a bit annoying.

I thought of doing this instead:

Base.getproperty(x::MyType, s::Symbol) = Base.getproperty(x, Val(s))
Base.getproperty(x::MyType, ::Val{s}) where s = getfield(x, s)
Base.getproperty(x::MyType, ::Val{:foo}) = 123
Base.getproperty(x::MyType, ::Val{:bar}) = 342

It seems to work, and seems to be type-stable. Before I get carried away, are there any obvious drawbacks to this approach?

1 Like

The obvious drawback is that this will be super, super slow if a user does something like

[getproperty(x, s) for s in [:foo, :bar]]

That is, if the symbol value isn’t a compile time constant, you’re in for a bad time. But when you write things like MyType().foo or whatever, the symbol :foo is a compile time constant, so the literal syntax should almost always be fine.

2 Likes

I do things like this all the time for high level performance non-critical code. My perspective is that type stability SHOULD be broken when convenient. Otherwise we might as well write C/C++ etc

4 Likes

Isn’t that also true when the “usual” way of defining geproperty is used?
I mean, It will also be type unstable…

The only difference that I see is that in the OP’s method dynamic dispatch will be used to figure out which method to call, while with the “usual” way that is done thourgh if-else branches. (Both cases with type unstable results)

Is dynamic dispatch much more expensive than if-else branches? I don’t know…

Is dynamic dispatch much more expensive than if-else branches? I don’t know…

big time. With the vals you’d be looking at microseconds for even the most trivial overloads.

struct MyType1 end
Base.getproperty(x::MyType1, s::Symbol) = Base.getproperty(x, Val(s))
Base.getproperty(x::MyType1, ::Val{s}) where s = getfield(x, s)
Base.getproperty(x::MyType1, ::Val{:foo}) = 123
Base.getproperty(x::MyType1, ::Val{:bar}) = 342

let s = Ref{Symbol}(:foo)
    @btime getproperty(MyType1(), $s[])
end

#+RESULTS:
:   2.836 μs (0 allocations: 0 bytes)
: 123
struct MyType2 end
Base.getproperty(x::MyType2, s::Symbol) = if s == :foo
    123
else
    342
end

let s = Ref{Symbol}(:foo)
    @btime getproperty(MyType2(), $s[])
end

#+RESULTS:
:   1.329 ns (0 allocations: 0 bytes)
: 123

2000x the performance cost.

But a lot of the time, a couple microseconds (in the edge case where constant prop fails) is peanuts to pay for convenience.

8 Likes

That all makes sense, thanks for the help!

Wow I knew that dynamic dispatch is to be avoided in hot loops, but I had no idea it is this expensive. I was expecting something on the order of 10ns-100ns. Any idea why it is so expensive?

It really depends. Val is usually a worst case for dynamic dispatch. Sometimes it can be basically the same as ifelse in certain situations (but not with Val).

2 Likes