Type stable Base.return_types - is it possible?

Lets say I have this rust inspired Result type:

using Moshi.Data: @data
using Moshi.Match: @match


@data Result{OkT, ErrT} begin
    Ok(OkT)
    Err(ErrT)
end

#insert pretty printer here

Unlike Julia built in Union type, we can force the user to pattern match the output like in Rust.

If I want to return a result type, both Ok and Err need to have its types known in advance:

function return_result(x)
    T_Ok = Int
    T_Err = String
    if x == 2
        return Result.Ok{T_Ok, T_Err}(2)
    else
        return Result.Err{T_Ok, T_Err}("not two")
    end
end

Then in my library I want to make a safe wrapper that verifies the input before running the user defined function with the input data:

function verify_input_before_function(f::Function, x)
    if x != 2 
        return Result.Err("not two")
    else
        return Result.Ok(f(x))
    end
end

# example usage
j = verify_input_before_function(2) do x
    # if x is not two then julia will segfault 
    x+1
end

Unfortunately as this is not rust, the above function will not work as both Result.Err and Result.Ok need to have the two Ok and Err types defined first.
We know the Err type, but we don’t know the Ok type, which is the return type of f(x).

After some searching around I found out about Base.return_types:

function verify_input_before_function(f::Function, x)
    if x != 2 
        return Result.Err{Base.return_types(f, (typeof(x),))[1], String}("not two")
    else
        return Result.Ok{Base.return_types(f, (typeof(x),))[1], String}(f(x))
    end
end

which works:

julia> j = return_result(2) do x
           # if x is not two then julia will segfault 
           x+1
       end
Result.Ok{Int64, String}(3)

julia> j = return_result(1) do x
           # if x is not two then julia will segfault 
           x+1
       end
Result.Err{Int64, String}("not two")

but unfortunately it makes my function type unstable:

julia> @code_warntype verify_input_before_function(2) do x
           # if x is not two then julia will segfault 
           x+1
       end
MethodInstance for verify_input_before_function(::var"#56#57", ::Int64)
  from verify_input_before_function(f::Function, x) @ Main ~/scratchspace/io_uring_julia/tstable.jl:35
Arguments
  #self#::Core.Const(Main.verify_input_before_function)
  f::Core.Const(var"#56#57"())
  x::Int64
Body::Main.Result.var"typeof(Result)"{_A, String} where _A
1 ─ %1  = Main.:!=::Core.Const(!=)
│   %2  = (%1)(x, 2)::Bool
└──       goto #3 if not %2
2 ─ %4  = Main.Result::Core.Const(Main.Result)
│   %5  = Base.getproperty(%4, :Err)::Core.Const(Main.Result.Err)
│   %6  = Main.Base::Core.Const(Base)
│   %7  = Base.getproperty(%6, :return_types)::Core.Const(Base.return_types)
│   %8  = Main.typeof::Core.Const(typeof)
│   %9  = (%8)(x)::Core.Const(Int64)
│   %10 = Core.tuple(%9)::Core.Const((Int64,))
│   %11 = (%7)(f, %10)::Vector{Any}
│   %12 = Base.getindex(%11, 1)::Any
│   %13 = Main.String::Core.Const(String)
│   %14 = Core.apply_type(%5, %12, %13)::Type{Main.Result.Err{_A, String}} where _A
│   %15 = (%14)("not two")::Main.Result.var"typeof(Result)"{_A, String} where _A
└──       return %15
3 ─ %17 = Main.Result::Core.Const(Main.Result)
│   %18 = Base.getproperty(%17, :Ok)::Core.Const(Main.Result.Ok)
│   %19 = Main.Base::Core.Const(Base)
│   %20 = Base.getproperty(%19, :return_types)::Core.Const(Base.return_types)
│   %21 = Main.typeof::Core.Const(typeof)
│   %22 = (%21)(x)::Core.Const(Int64)
│   %23 = Core.tuple(%22)::Core.Const((Int64,))
│   %24 = (%20)(f, %23)::Vector{Any}
│   %25 = Base.getindex(%24, 1)::Any
│   %26 = Main.String::Core.Const(String)
│   %27 = Core.apply_type(%18, %25, %26)::Type{Main.Result.Ok{_A, String}} where _A
│   %28 = (f)(x)::Int64 # It knows the return type!!!!!!
│   %29 = (%27)(%28)::Main.Result.var"typeof(Result)"{_A, String} where _A
└──       return %29

but the compiler obviously knows the return type of our passed in function, so whats stopping it from just setting those _A as Int64 instead of having this Any and all those type instabilities?

1 Like

I believe this is a case of Be aware when Julia does not specialize

Try:

function verify_input_before_function(f::F, x) where F
    if x != 2 
        return Result.Err{Base.return_types(f, (typeof(x),))[1], String}("not two")
    else
        return Result.Ok{Base.return_types(f, (typeof(x),))[1], String}(f(x))
    end
end

Doesn’t help because Base.return_types(f, (typeof(x),))[1] is not type stable:

function verify_input_before_function(f::F, x) where F
    if x != 2 
        return Result.Err{Base.return_types(f, (typeof(x),))[1], String}("not two")
    else
        return Result.Ok{Base.return_types(f, (typeof(x),))[1], String}(f(x))
    end
end

julia> @code_warntype verify_input_before_function(2) do x
           # if x is not two then julia will segfault
           if x !=2 error("segfault!") end 
           x+1
       end  
MethodInstance for verify_input_before_function(::var"#32#33", ::Int64)
  from verify_input_before_function(f::F, x) where F @ Main ~/scratchspace/io_uring_julia/tstable.jl:35
Static Parameters
  F = var"#32#33"
Arguments
  #self#::Core.Const(Main.verify_input_before_function)
  f::Core.Const(var"#32#33"())
  x::Int64
Body::Main.Result.var"typeof(Result)"{_A, String} where _A
1 ─ %1  = Main.:!=::Core.Const(!=)
│   %2  = (%1)(x, 2)::Bool
└──       goto #3 if not %2
2 ─ %4  = Main.Result::Core.Const(Main.Result)
│   %5  = Base.getproperty(%4, :Err)::Core.Const(Main.Result.Err)
│   %6  = Main.Base::Core.Const(Base)
│   %7  = Base.getproperty(%6, :return_types)::Core.Const(Base.return_types)
│   %8  = Main.typeof::Core.Const(typeof)
│   %9  = (%8)(x)::Core.Const(Int64)
│   %10 = Core.tuple(%9)::Core.Const((Int64,))
│   %11 = (%7)(f, %10)::Vector{Any}
│   %12 = Base.getindex(%11, 1)::Any
│   %13 = Main.String::Core.Const(String)
│   %14 = Core.apply_type(%5, %12, %13)::Type{Main.Result.Err{_A, String}} where _A
│   %15 = (%14)("not two")::Main.Result.var"typeof(Result)"{_A, String} where _A
└──       return %15
3 ─ %17 = Main.Result::Core.Const(Main.Result)
│   %18 = Base.getproperty(%17, :Ok)::Core.Const(Main.Result.Ok)
│   %19 = Main.Base::Core.Const(Base)
│   %20 = Base.getproperty(%19, :return_types)::Core.Const(Base.return_types)
│   %21 = Main.typeof::Core.Const(typeof)
│   %22 = (%21)(x)::Core.Const(Int64)
│   %23 = Core.tuple(%22)::Core.Const((Int64,))
│   %24 = (%20)(f, %23)::Vector{Any}
│   %25 = Base.getindex(%24, 1)::Any
│   %26 = Main.String::Core.Const(String)
│   %27 = Core.apply_type(%18, %25, %26)::Type{Main.Result.Ok{_A, String}} where _A
│   %28 = (f)(x)::Int64
│   %29 = (%27)(%28)::Main.Result.var"typeof(Result)"{_A, String} where _A
└──       return %29

What is type stable is to use typeof(f(x)) instead of using Base.return_types, but it will run the function even when it is unsafe to do those:

julia> function verify_input_before_function2(f::F, x) where F
           if x != 2 
               return Result.Err{typeof(f(x)), String}("not two")
           else
               return Result.Ok{typeof(f(x)), String}(f(x))
           end
       end
verify_input_before_function2 (generic function with 1 method)

julia> @code_warntype verify_input_before_function2(3) do x
           # if x is not two then julia will segfault
           if x !=2 error("segfault!") end 
           x+1
       end  
MethodInstance for verify_input_before_function2(::var"#44#45", ::Int64)
  from verify_input_before_function2(f::F, x) where F @ Main ~/scratchspace/io_uring_julia/tstable.jl:44
Static Parameters
  F = var"#44#45"
Arguments
  #self#::Core.Const(Main.verify_input_before_function2)
  f::Core.Const(var"#44#45"())
  x::Int64
Body::Main.Result.var"typeof(Result)"{Int64, String}
1 ─ %1  = Main.:!=::Core.Const(!=)
│   %2  = (%1)(x, 2)::Bool
└──       goto #3 if not %2
2 ─ %4  = Main.Result::Core.Const(Main.Result)
│   %5  = Base.getproperty(%4, :Err)::Core.Const(Main.Result.Err)
│   %6  = Main.typeof::Core.Const(typeof)
│   %7  = (f)(x)::Int64
│   %8  = (%6)(%7)::Core.Const(Int64)
│   %9  = Main.String::Core.Const(String)
│   %10 = Core.apply_type(%5, %8, %9)::Core.Const(Main.Result.Err{Int64, String})
│   %11 = (%10)("not two")::Core.Const(Main.Result.var"typeof(Result)"{Int64, String}(Main.Result.var"##Storage#Err"{Int64, String}("not two")))
└──       return %11
3 ─ %13 = Main.Result::Core.Const(Main.Result)
│   %14 = Base.getproperty(%13, :Ok)::Core.Const(Main.Result.Ok)
│   %15 = Main.typeof::Core.Const(typeof)
│   %16 = (f)(x)::Int64
│   %17 = (%15)(%16)::Core.Const(Int64)
│   %18 = Main.String::Core.Const(String)
│   %19 = Core.apply_type(%14, %17, %18)::Core.Const(Main.Result.Ok{Int64, String})
│   %20 = (f)(x)::Int64
│   %21 = (%19)(%20)::Core.PartialStruct(Main.Result.var"typeof(Result)"{Int64, String}, Any[Main.Result.var"##Storage#Ok"{Int64, String}])
└──       return %21

julia> verify_input_before_function2(3) do x
           # if x is not two then julia will segfault
           if x !=2 error("segfault!") end 
           x+1
       end  
ERROR: segfault!
Stacktrace:
 [1] error(s::String)
   @ Base ./error.jl:44
 [2] #41
   @ ~/scratchspace/io_uring_julia/tstable.jl:53 [inlined]
 [3] verify_input_before_function2(f::var"#41#42", x::Int64)
   @ Main ~/scratchspace/io_uring_julia/tstable.jl:46
 [4] top-level scope
   @ ~/scratchspace/io_uring_julia/tstable.jl:51

# it should've returned Result.Err{Int64, String}("not two") instead of "crashing"

1 Like

Don’t use that, except interactively. It’s not public API, for one. Even if it were, this is really not how you’d want to use it.

BTW, your post is an example of a “XY problem”:

This code is type-stable and only uses public API:

function verify_input_before_function(f::Function, x)
	fT = Base.promote_op(f, typeof(x))
    if x != 2 
        return Result.Err{fT, String}("not two")
    else
        return Result.Ok{fT, String}(f(x))
    end
end

promote_op is not public either.

help?> Base.promote_op
  │ Warning
  │
  │  The following bindings may be internal; they may change or be removed in future versions:
  │
  │    •  Base.promote_op

It’s public in Julia LTS (1.10):

Julia Base and standard library functionality described in the the documentation that is not marked as unstable (e.g. experimental and internal) is covered by SemVer.

So the fact that promote_op is not marked public in 1.11 should be a bug (unless it’s a breaking change, but those Julia claims to avoid).

1 Like

No it doesn’t. What you’re doing would only work for concrete inferred types. If the compiler inferred an abstract type for a return value, like something(::Some{Any}), then it can’t possibly match the actual return type at runtime. Compiler-inferred types are reliable enough for performance, but they can change, so if you want to specify concrete types semantically, you need to do it manually. Obviously, that’s the typical practice in statically typed languages where even generic functions typically rely entirely on parameters in the header, so that’s probably what you should aim for.

C++ does have decltype to infer a type from an input expression, but decltype(auto) for return types seems avoided in practice and by other languages because it does make call chains harder for a human to follow. Another critical difference is that statically typed languages like C++ disallow multiple possible concrete return types (well, there’s covariant return types for pointers/references to subclasses), whereas dynamically typed languages like Julia allow it as an implicitly boxed feature, hence the possibility of inferring abstract types for return values. If you’re confident that in practice, f(x) call’s concrete return type is inferrable, you were basically looking for a riskier Julia approximation of decltype(f(x)). As you’ve found, Base.return_types doesn’t do it because it’s designed to infer multiple methods for various combinations of input types, not one particular call signature. typeof(f(x)) doesn’t cut it because it executes the input expression and gets the runtime type. It’s possible in principle, but I don’t know of a direct way to do it, it’s all repurposing internals with different purposes.

I’m pretty sure the default “internal; they may change or be removed in future versions” warning counts as being marked as unstable.

There was no such warning before v1.11, which is @aplavin’s point. And it seems valid. I looked into the PR that introduced public, which also modified the part in the docs from which @aplavin quoted, and it seems the idea was to mark all symbols mentioned in the docs as public before v1.11 was to be released. I didn’t know about this fact, or forgot about it. PR:

That said, it is my impression that the docs included and mentioned lots of functionality which was definitely not meant to be public at all, so clearly the “described in the the documentation” quote was also not completely true.

2 Likes

I misread aplavin’s comment for sure, thanks.

My impression was that phrase referred to the docstrings in the Base chapter, not function calls in code examples. promote_ops is only used in this element type computation example, which is the same in 1.10 and 1.11. return_types is also used there and the typed globals section. Still, not great that something that’s “fragile” and “should be avoided” is THE documented way to compute output types, whether for 1 operation or array broadcasting. Its docstring actually states verbatim “The guess is in part based on type inference, so can change any time,” which makes it surprising that it’s documented to be a good alternative to the “brittle type inference” of return_types.

Very similar examples to those of this thread are warned against in the extended docstring of `promote_op`, and the 'fix' is to use an additional internal function that's never mentioned in the documentation on top of recomputing the output type if it turns out to be wrong at runtime. That obviously conflicts with the request for a type parameter at compile time.
  function invalid_usecase1(f, xs)
      R = promote_op(f, eltype(xs))
      ys = similar(xs, R)
      for i in eachindex(xs, ys)
          ys[i] = f(xs[i])
      end
      return ys
  end

  function invalid_usecase2(f, xs)
      R = promote_op(f, eltype(xs))
      if isconcretetype(R)
          ys = similar(xs, R)
      else
          ys = similar(xs, Any)
      end
      for i in eachindex(xs, ys)
          ys[i] = f(xs[i])
      end
      return ys
  end
1 Like

Since all of these are “invalid usecases”, is there a proper way of extracting the return type of a future function call?

I feel like this is not usually done in Julia for some reason. But how should I preallocate storage for returned values then? I can’t know the return type before calling the function, so I guess it’s not possible to do this?

Why doesn’t Julia have a “proper” Function type that specifies both the return type and arguments’ types?

function verify_input(f::Function{R, T}, x::T) where {R, T}
   if x != 2
      Result.Err{R, String}("error")
   else
      y::R = f(x) # that y::R is known at compile-time since x::T
      Result.Ok{R, String}(y)
   end
end

Here, Function{R, T1, T2, ..., Tk} is the type of a function with the following signature: func(::T1, ::T2, ..., ::Tk)::R.

  1. The compiler knows that x::T, so the range of R is restricted. R is decided using the usual way of choosing the most specific method to call given the types of arguments. If multiple methods with different return types match, R<:Union{R1, R2, ...}. BTW, when calling verify_input, T is a concrete type, right? So there can be only one corresponding method f(::T)::R, so there should be one concrete R type.
  2. Thus, the possible return types are known and we’re happy.

That would be nice, but not in julia’s current incarnation:

f(x) = x < 0.5 ? 0 : pi

With the call

f(rand())

the input type is Float64, and all the compiler can say is that the output is Union{Int, Irrational{:π}}.

Or, take something like:

f(n::Int) = Array{Float64, n}(undef, ntuple(_ -> 3, Val(n)))
1 Like

That really depends on what exactly do you mean/want. For example, Julia already provides type assertion and function composition, which could be interpreted to be exactly what you want. For example:

julia> comp = Base.Fix2(typeassert, Float64) ∘ sin
Base.Fix2{typeof(typeassert), Type{Float64}}(typeassert, Float64) ∘ sin

julia> typeof(comp)
ComposedFunction{Base.Fix2{typeof(typeassert), Type{Float64}}, typeof(sin)}

julia> comp isa ComposedFunction{Base.Fix2{typeof(typeassert), Type{Float64}}}
true

So, instead of your imagined Function{R}, you can use ComposedFunction{Base.Fix2{typeof(typeassert), Type{R}}}. You’d perhaps want a type alias to make it shorter.

On the other hand, it might be that you instead want more something like this stalled PR:

IMO, Julia should embrace type inference and make this function (promote_op or return_type) fully public and recommended. There’s a lot of code, including many packages, that use it – see incomplete sample on JuliaHub.
People use it not because they actively want to use internals, but because this functionality is clearly useful and very hard/impossible to replace with something else.

Sure type inference is cool. I opened the relevant feature request myself:

However I guess most use of type inference that you mention is quite invalid. Type inference in user code is certainly not, due to the nature of type inference in Julia as a compiler optimization, a feature that can ever be safe or recommended.