Sniffing the return type

I’d like to be able to programmatically determine the return type of a closure. The closure is explicitly declared such that it has no arguments and can only possibly return one type.

Minimal example:

julia> f() = "This"

What I’d like would a function/macro that would do something like this:

julia> returntype(f)
String

I’d like to use this information at compile time to declare the type of struct member. The computation is expensive so evaluating the closure at compile time is out of the question. But presumably, the compiler can determine the return type without performing the calculation.

Is there such a thing?

1 Like

I don’t know how to infer the type of f in your example, but what I do wonder when reading your question is: Wouldn’t a parametric struct be more suitable? For example

julia> struct A{T}
       val::T
       end

julia> f() = "This"
f (generic function with 1 method)

julia> function process()
           out = f()
           return A(out)
       end
process (generic function with 1 method)

julia> process()
A{String}("This")

Essentially, this determines the type of the struct during runtime instead of during compilation time. This is much more flexible. Note that it doesn’t have a negative effect on performance since the struct is still concrete.

This is an interesting idea but I’d really like to determine the output type before evaluating the function. I’d like to create a Ref{T} to hold the result where the type T is determined by the function before the function has been evaluated.

Since posting, I might be on my way to solving this problem myself:

julia> code_typed(f)
1-element Vector{Any}:
 CodeInfo(
1 ─     return "String"
) => String
julia> code_typed(f)[1].second
String

There’s Core.Compiler.return_type (but see discussions in add docstring for return_type by jw3126 · Pull Request #36834 · JuliaLang/julia · GitHub and RFC: Public API guarantee for `Core.Compiler.return_type` by tkf · Pull Request #44340 · JuliaLang/julia · GitHub). That said, probably an anti-pattern to structure your code around.

3 Likes

There is also Base.return_types, but I don’t think it is part of the public API, so maybe dangerous to use as well.

julia> f() = "foo"
f (generic function with 1 method)

julia> Base.return_types(f, ())
1-element Vector{Any}:
 String

Could you perhaps explain in a bit more details the wider context? There might be other solutions…

2 Likes

Just to clarify some terminology here, a closure needs to capture variables, e.g. function adder(x) y->x+y end returns a closure. f() = "foo" would just be referred to as a method. Second, “declaring the type of a struct member” could be misconstrued as what you do in a struct definition, and that doesn’t overlap with the compile-time of a method.

It’s still not entirely clear to me what you are trying to do, but based on this

I’m guessing you intend to do something like this:

julia> f() = "This"  # real case too expensive to be constant
f (generic function with 1 method)

julia> function g(fx::Function)
         fxtype = Base.return_types(fx)[1] # not public API
         storage = Ref{fxtype}() # unassigned Ref
         # yada yada yada
         storage[] = fx()
         return storage
       end
g (generic function with 1 method)

julia> g(f)
Base.RefValue{String}("This")

AFAIK there really isn’t a public API method for getting compiler-inferred return types, just code inspection methods that do it. It’s complicated because the compiler will infer abstract types for type-unstable calls e.g. fxtype is abstract and not the exact type of the call fx(). The more typical way is to just make the Ref right after you compute your result storage = Ref(fx()). In a local scope like a method body, you can even declare the storage variable in advance without assigning anything to it, which is as useful as an unassigned Ref anyway:

julia> function h(fx::Function)
         local storage # unassigned variable
         # yada yada yada
         storage = Ref(fx())
       end
h (generic function with 1 method)

julia> h(f)
Base.RefValue{String}("This")
1 Like

Regardless of whether an internal function for this exists or not, this is fundamentally not a stable operation. All compiler stuff and inference is an optimization in the context of julia, since it’s a dynamic language. In theory, anything can happen at any point, and no return type is ever guaranteed, as type inference is allowed to fail.

8 Likes

Maybe I should have said up front exactly what I’m trying to do.

I’m trying to create a lazy computation data type like this:

struct Deferred{T}
    func::Function
    item::Base.RefValue{T}

    Deferred(f::Function, ::Type{T}=Any) where {T<:Any} = new{T}(f, Base.RefValue{T}())
end

function (pc::Deferred{T})()::T where {T<:Any}
    if !isassigned(pc.item)
        pc.item[] = pc.func()
    end
    return pc.item[]
end

which can be used like this:

julia> df = Deferred(String) do 
            sleep(1.0) # placeholder for some expensive computation
            "Fish or fowl"
        end
julia> df()

Except I thought it would be nice to not have to specify the type in the constructor but rather have it determined by the function’s (or closure’s) return type.

This data type would be useful for me because it would allow me to defer calculation of an expensive member of a computation result until (if ever) it is required. (Deferred{T} is intended to be used as a field within an encompassing struct.)

It sounds like there is a non-public API to access the return type either
Base.return_types(fx)[1]
or
Core.Compiler.return_type(fx)
but using these hidden methods is discouraged.

A more complete example of how it could be used:

struct Demo{T, U}
    item1::Deferred{T}
    item2::Deferred{U}
    function Demo(d1::Deferred{V}, d2::Deferred{W}) where { V <: Any, W <: Any } 
        new{V,W}(d1,d2)
    end
end

fn(n) = n ∈ (0, 1) ? 1 : fn(n-2) + fn(n-1) # slow!

n, str = 42, "It's a bird!"
dd = Demo(Deferred(String) do 
        Base.sleep(2)
        str
    end, 
    Deferred(Int) do 
        fn(n)
    end
)

@test !isassigned(dd.item1)
@test dd.item1() == "It's a bird!"
@test isassigned(dd.item1)
@test !isassigned(dd.item2)
@test dd.item2() == 433494437
@test isassigned(dd.item2)

I’d like to eliminate the String and Int in the Deferred constructor since they are redundant.

This is similar to how Base.broadcast works - it has to allocate arrays with known eltype before the functions are run. In DiskArrays.jl we actually just defer running them at all unless you have to, somewhat how you are here - but the unmaterialised array still has the right eltype.

Broadcast uses Base._return_type which seems slightly safer than the Core method but is marked as internal with the leading underscore.

But probably broadcast will always need this method or something like it, so it’s not going away. But the interface can theoretically change at any time (at which point you would just need to edit your code, if it ever happened).

1 Like

I think the most important tidbit of this discussion is:

If your code semantically depends on the inferred type, then it is brittle code that has no guarantee to keep working the same way you want when you change the Julia version in the slightest.

If you are just trying to have an optimized code, well, you are storing a Function inside a struct and calling it. You gonna already take a hit there.

If you are trying to have convenient code, without having to define the type yourself, well, if your function is really heavy (and you are ok with calling it from a struct field) why do not use an Any field and function barriers? So you do not need to rely on inference working. You defer knowing the type for when Julia needs to compile the method that will work over the called value, you are already deferring the value, why not defer the knowledge of its type too?

4 Likes

I don’t get why he shouldn’t just try _return_type like Base broadcasting does, if he is prepared to risk some API instability. It will handle the Any fallback as well.

He can, I am not saying he can’t, but if this a possibility then his code does not semantically depends on the inferred type and, as he mentioned, the evaluated functions are very heavy, so I do not see what is the advantage of using _return_type. Will it give him a little more performance at the risk of API instability? He is allowed to make the trade-off, I was just pointing out it seems a bad trade-off in my opinion.

1 Like

You’re right that function in the struct field makes type stability a moot point (hint: @NicholasWMRitchie you can put it in a type parameter)

But having the right return type is useful for more than just performance, e.g. it lets us use GPUs where Any is not an allowed return type.

True. But then, again, it seems to me that what is being sacrificed is
just a little convenience (of not declaring the type). If the code
depends on not defaulting to Any to work, then _return_type should be
avoided, as it was mentioned it can default to Any. This is what I
called “semantically depending”.

Thanks to all. Your responses have been very instructive.

I’m hearing that I could use an undocumented function like Base._return_type(...) which could change at any time. However, there are very real risks involved and the benefit won’t be significant.

It seems to me that hard coding my belief about the return type of the function could only be less robust than asking the compiler - particularly as the compiler evolves. However, knowing this type is important for optimizing downstream code which depends upon the return type (otherwise I could just assume a return type of Any and be done with it.)

I’ll have to play around with the alternatives and decide which compromises I’m more willing to live with.

@Raf : what do you mean by put it in a type parameter? Do you mean to declare explicitly what I expect the return type to be as part of the template type?

The risks of using Base._return_type are very low. I’m pretty sure I had a registered package that used it somewhere at some point. Broadcasting is one of the most used functions in the language and depends on it heavily.

By put it in a type parameter I mean a free type paramter in the struct that has no field.

struct Deferred{T,F}
    item::Base.RefValue{T}

    Deferred(f::Function, ::Type{T}=Any) where {T<:Any} = new{T,f}(Base.RefValue{T}())
end

But only if type stability of the function is useful. You could instead just use a function barrier later on and leave things as-is.

(Although I’m not even sure how bad it is in a field to be honest, maybe not so bad)

3 Likes

@Raf Thanks for the suggestion. This would never have occurred to me as an option.

The documented version of return_type is Base.promote_op. It just calls return_type and has the same behavior.

2 Likes

That’s not necessarily true. As mentioned above, the compiler is very much allowed to fail inferring the return type and just say “best I can do is Any”, even though a human may say “it can only be XYZ, obviously”. The question is more about how certain you are that a given function will always return an object of a given type.

So, my question would be: in what context does this reliance on the inferred return type come to be (other than inference unit tests)?

That’s a little misleading. promote_op has a docstring but it’s not the documentation. In fact, the docstring warns that it’s fragile and should be avoided.

1 Like