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
1 Like

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)))
2 Likes

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. IMO 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.

1 Like

The root of the problem is we don’t actually want type inference to be stable. In an ideal world, the compiler narrows down types to the narrowest set of possibilities, and we could use that as a stable feature. But due to practical limitations on implementation, Julia doesn’t do that, and the goal is to further improve type inference in that direction e.g. inferring reassignable variables captured by closures. If we don’t often care what our variable’s types are, then that’s just good optimization. That’s the case in Julia, a dynamically typed language; while we can associate variables with types in some ways and the compiler infers types for expressions, our code ultimately considers the concrete runtime type of the value, e.g. a::Integer = 1; typeof(a) == Int. The compiler’s actions are only an implementation detail; a Julia implementation can have no compiler at all, and it could still implement code_warntype/return_types/promote_op independently (they can diverge from the compiler already, Issue #32834).

Bringing an unstable inferred type from compile time into the program at runtime makes the program unstable. For example, a conditional check of an inferred type can fail upon any improvement (or regression, though that should be patched) of the compiler. For another example, instantiating a parametric container like Vector{T} with an inferred type can narrow, which will break code designed to insert other elements of the previously wider type. That might a reason for array element type inference being noticeably wider than the compiler’s inference, but I don’t know if that’s semantically stable either, and it’s already not type-stable:

julia> badone(x::Integer) = (1, 1.0)[1+x%2] # infers as Union{Int, Float64}
badone (generic function with 1 method)

julia> Base.return_types(broadcast, typeof.((badone, 1:1)))
1-element Vector{Any}:
 Union{Vector{Float64}, Vector{Int64}, Vector{Real}}

julia> [eltype(badone.(x)) for x in [1:0, 1:1, 2:2, 1:2]]
4-element Vector{DataType}:
 Real
 Float64
 Int64
 Real

I think some sort of return type estimate API would be more useful than having to dig into an opaque set of overlapping internals, but I don’t think any version of it can amount to semantic stability. It really seems like a type is only stable if it was explicitly specified or if it was computed from another type that is, e.g. eltype(x) only uses typeof(x), ndims dispatches on the type parameters of x instead of using the instance directly.

Note that a return_type equivalent can easily be built out of public Julia functions. It has basically the same behavior as promote_op, just more overhead:

julia> return_type(f, T) = eltype(map(f, T[]))
return_type (generic function with 1 method)

julia> return_type(first, Vector{Int})
Int64

julia> return_type(x -> x > 2 ? x : 3., Int)
Real

julia> return_type(x -> x > 2 ? x : 3, Int)
Int64

So, inference results are effectively public, and “always” has been.

A package to make that simpler is EnforcedTypeSignatureCallables.jl. Discourse thread (may wish to scroll down to the v3 announcement):

1 Like

It does not, it’s wider for your example:

julia> return_type(x -> x > 2 ? x : 3., Int)
Real

julia> Base.return_types(x -> x > 2 ? x : 3., (Int,))
1-element Vector{Any}:
 Union{Float64, Int64}

julia> Base.promote_op(x -> x > 2 ? x : 3., Int)
Union{Float64, Int64}

promote_op’s docstring also warns that it is an undefined upper bound, in other words it can be wider in this way depending on unusual factors like language version, loaded packages, command line options, and who knows what else the docstring didn’t mention.

An undefined type could be fine for crunching numbers interactively, but it’s probably not acceptable for translating anything from Rust, a statically typed language where function pointer types are explicitly associated with input and output types. The map(f, T[]) bit is a source of type instability like return_types, so it can defeat the original purpose. A @generated function might be able to evaluate those calls at compile time and skip over the instability.

In rust I can do this:

fn verify_input_before_function_rust<T>(f:fn(i32) -> T, x:i32) -> Result<T, String>{
    if x != 2 {
        return Err("not two".to_string());
    } else {
        return Ok(f(x));
    }
}


fn main() {
    println!("Hello, world!");


    fn must_pass_two(x:i32) -> i32{
        if x !=2 {
            panic!("not two")
        }
        x+1
    }


    match verify_input_before_function_rust(must_pass_two ,2){
        Ok(o) => println!("Function returns: {}", o),
        Err(o) => println!("Function returns: {}", o)
    }

}

I want my verify_input_before_function to be generic across function f output type. If the compiler infers a type that is too wide or even Any my verify_input_before_function doesn’t care, same as how normal untyped Julia code doesn’t really care about its return type. As long as whatever its return type is, it is wrapped inside my Result type.

But my other goal is for this function to work inside --trim, where you won’t have the compiler at runtime to fix up your type shenanigans, so if your passed in function f have any type instability shenanigans I assume that --trim will also fail.

After some searching I found this: Ridiculous idea: types from the future and why using Base.promote_op is a bad idea: Use of promote_op problematic ¡ Issue #396 ¡ JuliaControl/ControlSystems.jl ¡ GitHub and Crash with MonteCarloMeasurements + ControlSystems ¡ Issue #38436 ¡ JuliaLang/julia ¡ GitHub