How to determine the methods for a callable object without instantiating it?

Suppose I have the following:

struct A{T} x::T end
(a::A)(y) = a.x+y

Then I can say

julia> a = A(1); methods(a)
# 1 method for callable object:
 [1] (a::A)(y)
     @ Main REPL[2]:1

This list only depends on the type of a, not on the element a itself. Is it possible to get this list directly from the type A{T}, without instantiating any element?

2 Likes

Yes, it is possible. But from what I can see this is not supported yet.

However, you can use the following function to achieve the desired functionality:

function fancymethods(x)
    tsyg = Tuple{x,Vararg{Any}}
    temp = Base._methods_by_ftype(tsyg, -1, Base.get_world_counter())
    ms = Method[]
    for m in temp::Vector
        m = m::Core.MethodMatch
        push!(ms, m.method)
    end
    Base.MethodList(ms, x.name.mt)
end

It required a little digging, and I donā€™t have much time to explain everything right now (if a detailed explanation is needed, I can provide it later).

This is not defined under Base and I didnā€™t test it with types defined under various modules - so I cannot provide many guarantees at this point. Iā€™ll make it more robust later.

The usage differs from parametric struct vs non-parametric.

Examples:

struct A{T}
    x::T
end
(a::A)(y) = a.x + y
(b::A)(x::String) = string(b.x) * x
(c::A{Float64})(x::Float64) = c.x + x

fancymethods(A{Any})

The above fancymethods call will produce:

# 2 methods for callable object:
 [1] (b::A)(x::String)
     @ Main ~/code/teach.jl/cobj.jl:5
 [2] (a::A)(y)
     @ Main ~/code/teach.jl/cobj.jl:4

You can see that the A{Float64} was ignored.

However, calling fancymethods(A{Float64}) will produce, as expected:

# 3 methods for callable object:
 [1] (b::A)(x::String)
     @ Main ~/code/teach.jl/cobj.jl:5
 [2] (c::A{Float64})(x::Float64)
     @ Main ~/code/teach.jl/cobj.jl:6
 [3] (a::A)(y)
     @ Main ~/code/teach.jl/cobj.jl:4

The idea is that you donā€™t need to use an instance element; the type will suffice. But you need to be careful since A{TypeA} is not necessarily the same as A{TypeB} (e.g., the returned methods follow the dispatch rules).

For non-parametric types, things are straightforward:

struct B
    x::Int
end

(b::B)(x::String) = string(b.x) * x
(b::B)(y) = b.x + y

fancymethods(B)

Will produce:

# 2 methods for callable object:
 [1] (b::B)(x::String)
     @ Main ~/code/teach.jl/cobj.jl:20
 [2] (b::B)(y)
     @ Main ~/code/teach.jl/cobj.jl:22

The fancymethods function I wrote above is specially designated to get the callable object methods from using the type instead of the instance (if no method is defined for the object, youā€™ll get an empty MethodList).

Rely on the standard methods for everything else.

Have fun.

P. S. I think this would be helpful to be part of Base (the API should be something like methods(T, withobjectmethods=true) - this would return both the constructors for T and the callable object methods - or maybe the keyword can be used to indicate that only the callable object-related methods should be returned).

3 Likes

Thatā€™s expected though. A{Float64} is not a subtype of A{Any}, both are concrete types that subtype A.

Playing with this a little, fancymethods(A) doesnā€™t work (throws error for UnionAll), and declared-abstract types will include the concrete subtype methods (I subtyped B <: C in your example to check), which Iā€™d sometimes want to exclude. Still, itā€™s incredibly useful as is, I wish more reflection methods accepted a callableā€™s type.

Iā€™d rather a keyword that specifies where Iā€™m checking a callable or a callableā€™s type, so methods(T, ..., :callable) for T(...) methods and methods(T, ..., :typeofcallable) for (::T)(...) methods.

2 Likes

This extends the original functionality to include UnionAll scenario as well:

function fancymethods(x)
    tsyg = Tuple{x,Vararg{Any}}
    temp = Base._methods_by_ftype(tsyg, -1, Base.get_world_counter())
    ms = Method[]
    for m in temp::Vector
        m = m::Core.MethodMatch
        push!(ms, m.method)
    end
    mt = x isa UnionAll ? Base.unwrap_unionall(x).name.mt : x.name.mt
    Base.MethodList(ms, mt)
end

The Base.get_methodtable fallback seems sound and the few tests I am running are looking good. Iā€™ll keep this approach until disproved by some weird outcome.

Now the fancymethods(A{<:Any}) call works OK.

3 Likes

Thatā€™s great, thanks a lot! I also think that this functionality would be valuable in Base. If one adds it, then maybe one should allow empty lists to be returned, as is the case with methods:

julia> struct A end; methods(A())
# 0 methods for callable object

Updated the solution to reflect this.

This can be easily adapted to work via methods the way @Benny suggested.

@gdalle, if you find this valuable and already have the Julia repo setup, you might find this as a low-hanging fruit - also, I think this is worthy of Base addition. Pointing you to the method that needs extending here.

@algunion I do like low-hanging fruit but these days Iā€™m on a fruit-heavy diet already :slight_smile: Could this be your opportunity to overcome the impostor syndrome and venture bravely into PR-land?

5 Likes

Iā€™ll do it this week sometime: it looks like more work to set up the whole repo deal than doing the PR :slight_smile: But I understand that is a one-time thing.

4 Likes

Done.

6 Likes

The PR using that keyword methods(T, :callable) to find methods (::T)(args...) is a bit cryptic because every input is a callable, including functions, type constructors, and callable instances we can use methods for already. Earlier I tried to distinguish the current usage and the proposed type-level usage with :callable and :typeofcallable, but in retrospect :callable by itself gives little information. Perhaps just directly with :instance vs :type? :instance (or omitting the argument entirely) would do what methods does already, while :type does the new stuff, in other words methods(f, [types], [module]) == methods(f, [types], [module], :instance) == methods(typeof(f), [types], [module], :type). Still not a final idea because that may be misconstrued as referring to the arguments, but :callableinstance and :typeofcallable seems a bit verbose.

It just occurred to me, which has a method that takes a type signature as a tuple type of the types of the callable and the arguments. That seems clearer and easier than a trailing keyword.

1 Like

It didnā€™t seem feasible to keep the surface as simple as possible and cover all the cases without adding much code. And the simplest option seemed only to introduce a singular keyword - :callable (with the meaning of callable object of type T).

So, as a general rule, the following assumptions need to hold:

struct A{T}
    x::T
end

(a::A{Float64})(x::Float64) = a.x + x
(a::A{String})(x::String) = a.x * x

# make `a` instance
a = A(...)

methods(A) != methods(A, :callable)
methods(a) == methods(typeof(a), :callable)
methods(a) == methods(a, :callable)

methods(push!) == methods(push!, :callable)

So, using :callable on an instance will just be ignored.

I tend to agree with the semantics part of things: especially after reading your message, maybe :instance/:type would be a better fit. And I would also avoid the verbose variants at this point.

At the time of writing the code + docs, my brain pretty much used the :callable idea in the context of callable object or functor (and I would avoid using functor).

Doesnā€™t methods(T, :instance) seems to imply returning the methods for the instance of T? Which is not the default behavior.

It feels like those discussions about discussions, in a way - we are in the meta realm now.

Wish it had occurred to me sooner but I edited that comment to point out which has a (undocumented) method that takes a tuple type of the types of the callable and the arguments together e.g. which(Tuple{typeof(+), Int, Int}). Seems clearer because you know itā€™s a callable by it preceding arguments, and you know itā€™s the type of the callable because it comes with types of the arguments, donā€™t need to look out for a trailing keyword. An instance of that tuple type would just be call inputs e.g. (+, 1, 2). This signature-type pattern shows up in other methods that just donā€™t make it to the documentation, so it seems like a precedence to adhere to. Just need to figure out how to write a signature without specified argumentsā€¦

A. S. For potential readers not aware of the full context: please be advised that the functionality discussed on this topic is not part of the Julia language at this point. You can use the function selected as solution if you need to achieve the behavior stated in the OP.

If we put aside for a moment the actual keyword name, what you say above is already compatible with both default methods implementation and the :callable flavor.

struct A{T}
    x::T
end

(a::A{Float64})(x::Float64) = a.x + x
(a::A{String})(x::String) = a.x * x
(a::A{String})(x::Symbol) = a.x * string(x)

# make A{String} instance
a = A("ha")

methods(a, Tuple{Symbol}) == methods(A{String}, Tuple{Symbol}, :callable)

Basically, the :callable just enables the caller to use the type instead of the instance in the methods calls and get out the same answer as you would get when using the instance.

The methods behavior is untouched in all other aspects.

Also, when printing the methods list, you always get the appropriate message: methods for callable object vs methods for type constructor (and the methods for generic function, etc. are left alone anyway).

One solution to drop the keyword usage altogether might be the creation (inside Base) of yet another convenience/wrapper type:

Instance{T}
    t::T
end

So methods(T) would return the type constructor methods (the current behavior), and methods(Instance{T}) would return the methods defined for instances of T (e.g., methods for the callable objects).

While technically possible, the Tuple{} usage (specifically the way it is used in which) seems like departing from the regular use of methods (where you merely return methods for the first argument and optionally restrict/filter by a second Tuple{} argument).

True, but weā€™re already departing from the regular use by invoking the callableā€™s type and possibly a new argument. Also checking the source code, methods with unspecified argument types does methods(f, Tuple{Vararg{Any}}, mod), and ultimately does use a helper _methods_by_ftype that takes in a signature tuple type like Tuple{typeof(f), Vararg{Any}}.

If we let the user-level methods take in this type, we could also make a helper method to make it. Iā€™m imagining that people donā€™t want to manually type out an entire signature type, which they often donā€™t even do for the arguments in methods, they may at most specify number of arguments. So this may help:

typesig(T,N) = Tuple{T, Vararg{Any, N}} # becomes Tuple{T, Any, Any, ...}
typesig(T) = Tuple{T, Vararg{Any}}
# potentially, methods(typesig(T))

Aesthetically, it doesnā€™t really save typing compared to a trailing key word, and itā€™s about the same clarity as Instance{T} with the key word in the lead. Downside of the latter is that Instance{T} itself is a callable struct, so itā€™s ambiguous whether methods treats it as callable instance (like it can now) or type (in which case Instance{T} itself becomes hard to inspect methods for). Admittedly, the tuple signature does the same thing because tuple types are callable (for example, you can do methods(Tuple) or methods(Tuple{typeof(+), Int, Int}) now), but thereā€™s more precedence for it. Either way could be breaking though, which needs to be put in a separate function.

A separate function certainly wonā€™t have to worry about collisions with the current usage. It also occurs to me that this could just be released as a v0 package, which people can experiment with, without the promise of backward compatibility of merging into v1 Julia. People can also contribute typeof(callable) versions of other standard library reflection methods, and it may eventually be absorbed into base Julia.

1 Like

Another idea for non-breaking trailing key words: :f vs :ftype, reflecting some of the naming conventions in reflection.jl. The documentation already shows methods(f, [types], [module]) to use f to indicate a callable instance, so a new methodā€™s documentation could say methods(ftype, [types], [module], :ftype), concise and seems clear enough. Iā€™m still partial to a clean signature type-based reflections package, but keeping the callable and the arguments separate in 1 existing function seems more realistic.

Also, we may have incorrectly assumed a new argument must be put at the end. I think it may be possible for new methods to dispatch on the 2nd argument as ::Symbol, so it could be methods(ftype, :ftype, [types], [module]).

Could also make it a keyword-only argument methods(Foo; mode=:ftype) just in case adding another optional positional argument is too radical. Itā€™s not like this argument needs to participate in multiple dispatch.

1 Like

@Benny , thank you for the insights.

Iā€™ll not be able to put much work into this in the following days - but before updating that PR, I would really like to somehow validate this (and your idea of a standalone package where we can iterate fast seems attractive indeed).

I wrote this a while back - and this was the exact rationale: to have a fast-moving environment where we can iterate/validate ideas. Also, donā€™t wait for the slow-paced review process (and this is not a critique - I just admit the existing/expected constraints) before making valuable stuff available to the community.

I might revisit that idea.

1 Like

After discussions here, I opted for instancemethods flavor and arrived at the following PR: