Is there a reason ***not*** to explicitly define return types?

Correct, you want to check the manual on Types in particular, regarding concrete vs abstract types and type-invariance. For reference, I had tried to explain concrete/abstract type here.

Julia is not Rust and it’s type system works differently. In the end, it’s dynamically typed and every value has a type (always). In contrast, statically-typed languages assign types to variables, expressions, but not values (except if runtime-type information is requested). Confusingly, the Julia compiler also does that, but only to optimize performance, i.e., sematically it is still dynamically typed.

say x = 3 then saying x <= 5 is NOT incorrect it’s imprecise.

We have String <: AbstractString so it’s imprecision not incorrect.

Equality relations with numbers has nothing to do with types and subtypes.

The subtype operator <: isn’t the same as <=

If you want to understand why I said incorrect, please re-read the two explanations I provided.

I don’t want to derail this thread into a tangent where we argue over English semantics.

It may possibly be helpful if I point out that by annotating the return type as Vector{AbstractString}, while also having a line of code inside the function

return v # (v::Vector{String})

an implicit call to convert(Vector{AbstractString}, v) will be made.

This is incorrect because it is not the behavior intended by the programmer.

Another possible thought I had about this. It rarely makes sense to leave the types of arguments unspecified. (They default to ::Any.)

If you have an algorithm which is a function which takes a set of arguments, if that function is some numerical method, I would suggest it makes sense to at least constrain the arguments to ::Number.

function algorithm(a::Number, b::Number, c::Number, ...)::Number

This is sort of tangential to the main discussion here.

Then try to keep in mind the difference between a function and a method of a function (in Julia). A function is basically a collection of methods (a function is not usually an iterator, this is about the idea of a collection more generally). So I guess you want to “explicitly define the return type of a method”.

Well, you’re wrong. Look into unitful types, for example. For example: Unitful.jl.

Wrong. Both are partial orders.

FWIW I think in another topic of yours I pointed you to the doc string for <:, which explains this in more detail.

What is the point you are making here?

That has no relevance to what is being discussed here. 1 and "hello world" are both pieces of data in memory. What matters is how the compiler handles each of them. They are not the same.

The answer in fact, is here.

Where it says

This last point is very important: even though Float64 <: Real we DO NOT have Point{Float64} <: Point{Real} .

In other words, Vector{AbstractString} is wrong because it is not a supertype of Vector{String}. It isn’t even in the same branch of the type tree (type hierarchy).

If you don’t have much of a feel for the internal workings of compilers, I would assuming understanding why this is the case is not intuitive. If you have worked in something like C++ or Java before, you can probably see why this is the case.

Right, this is also a FAQ.

If you don’t have much of a feel for the internal workings of compilers, I would assuming understanding why this is the case is not intuitive. If you have worked in something like C++ or Java before, you can probably see why this is the case.

Right, this has been discussed repeatedly, e.g. Problem with Complex{Rationals} - #7 by stevengj and Why [1, 2, 3] is not a Vector{Number}? - #19 by stevengj

To answer your original question: explicitly declaring the return type of a function doesn’t generally help the compiler, doesn’t prevent type instabilities within the function (and may merely conceal them), isn’t visible in docstrings, is very difficult to do correctly for generic functions without greatly restricting their generality, and even for simple non-generic functions adds unnecessary complexity to the code. Therefore, it’s not used very often; the main practical utility is when when the type conversion implied by the declaration allows you to remove type conversions in several return statements for different branches of the code.

9 Likes

@nsajko See above, @stevengj has provided you with answers

There’s a much simpler reason. When you annotate a return type, all return values will be converted to this type. You can’t convert a String to an AbstractString. Values can’t have abstract types. Therefore convert(::Type{AbstractString}, ::String) has been defined to return a String. Just like convert(AbstractFloat, 1) is a Float64.

On the other hand, it it entirely possible to have a Vector{AbstractString}. It’s a Vector which can hold String, SubString, or any other string type (provided it’s a subtype of AbstractString). If you create a Vector{String} in the function, but it’s annotated to return Vector{AbstractString}, it will be converted to a Vector{AbstractString}. A vector type which can hold any string type.

If you instead say the return type should be Vector{<:AbstractString}, it will not touch a Vector{String}. The returned vector can’t be used to store e.g. view("abc", 1:2), since it is a SubString. Any such SubString will be converted to String before being put into a slot in the vector.

And, of course, the elements of a Vector{AbstractString} are not of type AbstractString, but of various concrete string types.

julia> v = ["abc", view("abcd",1:3)]
2-element Vector{AbstractString}:
 "abc"
 "abc"

julia> typeof.(v)
2-element Vector{DataType}:
 String
 SubString{String}
2 Likes

Perhaps worth mentioning here that if you only want to assert a return type and not attempt a conversion, you can place a type assert at the point of return:

function square(x::T) where {T}
    return (x^2)::T
end

This will simply error if x^2 isn’t already of type T. This can help catch typos/confusion like the invariance/covariance issue:

julia> function myFunction()
           v::Vector{String} = ["hello", "world"]
           return v::Vector{AbstractString}
       end
myFunction (generic function with 1 method)

julia> myFunction()
ERROR: TypeError: in typeassert, expected Vector{AbstractString}, got a value of type Vector{String}

I find this pattern more useful than the one discussed in the rest of this thread, even though it’s less ergonomic if you have multiple return points within the method body.

8 Likes

I don’t think this is quite correct. If it were the case, you could not have

x::Any

Here, x is effectively a reference to some data along with some metadata which provides runtime information about what it is. (The exact compiler output might differ a bit from this, but those pieces of information must be there.)

So why not

x::AbstractString

It could be a String or some other kind of string thing which is a subtype of AbstractString.

This seems like a more accurate statement. This seems like an arbitrary rule of the type system/core language.

There isn’t an equivalent for Real, for example. If you were to return a Real from a function, I am fairly confident that doesn’t convert to Float64.

I’ve checked and the behavior for this is the same:

julia> function floatFunction()::Real
       x::Float64=1.0
       return x
       end
floatFunction (generic function with 1 method)

julia> typeof(floatFunction())
Float64

julia> function vectorFunction()::DenseVector
       v::Vector{Float64} = [1.0]
       return v
       end
vectorFunction (generic function with 1 method)

julia> typeof(vectorFunction())
Vector{Float64} (alias for Array{Float64, 1})

julia> function vectorFunction2()::DenseVector
       v::Vector{Real} = [1.0]
       return v
       end
vectorFunction2 (generic function with 1 method)

julia> typeof(vectorFunction2())
Vector{Real} (alias for Array{Real, 1})
julia> isconcretetype(DenseVector)
false

It seems like only the first “level” of a type. The outer type? Whatever this is called - only that bit is optimized to a concrete type. I guess that makes sense, the compiler is just doing regular type inference and it finds a way to put a tighter constraint on your return type.


I should point out, it seems type annotations on return types of functions appear to behave differently to type annotations on variables.

… actually, this seems to be an important point. I guess when you realize this, the behavior observed above makes a lot more sense.

This, I think, is the point being made also by @danielwe above?

Others have mentioned what a method return type annotation means, my point was just to offer an alternative, depending on what your objective is.

That said, since you seem very eager to type assert, it’s probably worthwhile going through the semantics of the different variants (to the best of my understanding).

  1. Just assertion: when you attach ::Foo to a variable in a position where it’s not being assigned to, like in any of these cases:

    x::Foo
    y = x::Foo
    return x::Foo
    

    This is more or less a shorthand for (@assert x isa Foo; x). Either typeof(x) <: Foo, or you get an error. If no error, the value of the expression if x.

  2. Declaration: convert & assert. When you attach ::Foo to a variable that’s being assigned to, like so:

    y::Foo = x
    

    This basically translates to

    y = convert(Foo, x)::Foo
    

    where the meaning of ::Foo on the right-hand side is as described above (1.). In other words, this syntax attempts to convert x to a value of the requested type, and then assert that convert actually returned a value of that type. Moreover, the same conversion/assertion is applied at every assignment to the same y (where “same” is defined by scoping rules). In other words,

    let
        y = x
        y::Foo = z
        y = w
    end
    

    translates to

    let
        y = convert(Foo, x)::Foo
        y = convert(Foo, z)::Foo
        y = convert(Foo, w)::Foo
    end
    

    Note that this syntax is rarely used in Julia. It’s not idiomatic to use this for local variables; the implicit conversion can be a footgun, and it’s usually preferable to put the assertion on the right-hand side like y = f(x)::Foo if you want to assert that f indeed returned a Foo.

    The one somewhat common usage of y::Foo = x is when y is a global variable, since then the compiler can infer the type of y when you use it in functions.

  3. Method return type annotation: convert & assert, similar to assignment (2.). In other words, defining

    function f(args...)::Foo
        # stuff
    end
    

    is morally equivalent to

    function f(args...)
        ret = @inline f_inner(args...)
        return convert(Foo, ret)::Foo
    end
    function f_inner(args...)
        # stuff
    end
    

    although I’m pretty sure it doesn’t actually define a separate inner function under the hood.

(For completeness, the syntax x::Foo is of course also used as type selector for dispatch in method definitions, function f(x::Foo), but that’s unrelated to assertion/conversion.)

Hope some of that’s helpful!

4 Likes

I think they behave the same. In a function annotated with return type T, every return x is replaced by return convert(T, x)::T. When assigning to a variable y annotated to have type T, the assignment y = x is replaced by y = convert(T, x)::T.

And no value can have an abstract type, so when a variable is annotated with an abstract type, or a function return is annotated with an abstract type, there is never a value of this type. You can’t create an x such that typeof(x) === AbstractString, or typeof(x) === Real. You can have a vector v with eltype(v) === Real, but for no index i will you have typeof(v[i]) === Real.

I think there is a confusion here about the semantics of the relationship between variables, instances, types, and type annotations.

All variables refer to a specific instance at any given time. This means they always have a concrete type. If you query, typeof(x) it can never return AbstractString because there is no such thing as an AbstractString instance. You can, however, have a Vector{AbstractString} which is a concrete vector that can contain any string type.

Then, there is the question of type annotations. When you mark a variable x::Any, you’re not saying typeof(x) === Any, you’re saying typeof(x) <: Any. Also, some annotations cause conversion (variable and function return annotations) and others are just typeasserts (marking an express).

This has a few implications:

  1. x::Vector{AbstractString} CAN’T refer to Vector{String} because they are both concrete types and concrete types can’t be subtyped.
  2. x::Vector{<:AbstractString} CAN refer to Vector{String} since it is an abstract UnionAll of all Vectors with an eltype(v) <: AbstractString.
  3. If it is a converting annotation, then the instance will be converted to a subtype if possible. In the case of y = ["hi"]; x::Vector{AbstractString} = y, Julia will run a convert on the Vector{String} that y refers to. In this case, there is a method of convert that works – it creates a new vector with a wider eltype.
  4. If typeof(y) is already a subtype of the annotation, no conversion is necessary. So x::AbstractString="hi" will yield an x of type String.
  5. There’s the weird case of an abstract assert and a convertible non-subtype. x::AbstractFloat=1 yields an x of type Float64. This is “arbitrary”, but only happens if a method is defined with a default concrete type for conversion.
4 Likes

I see a possible disconnect happening, so to clear this up:

  • The UndefVarError at the call doesn’t mean it failed to compile, it successfully compiled to throwing that runtime error.
  • The current issue there is the method needs to infer static (method) parameters from the inputs, and you have none. This isn’t different from other languages’ generic methods and their parameters, and they tend to need that inference right at the method signature, way before even looking at the method body. Julia at least compiles the whole method at the call, then it always errors at runtime because T doesn’t exist and you can’t convert the return value to a Vector{T} that doesn’t exist.
  • It works if it’s not a method parameter, but a parameter of the return type annotation itself. Well, work is pushing it, it can’t do anything to your Vector{String}. You’ll have to use parentheses to let it parse this way:
function myFunction3()::(Vector{T} where T<:AbstractString)

or the equivalent shorthand:

function myFunction3()::Vector{<:AbstractString}

Type annotations do like 3 or 4 different behaviors depending on what it’s attached to. It’s not as straightforward as AOT-compiled languages where a variable owns its data, so the type annotation more or less declares the variable’s structure and the compiler stops everything if anything tries to contradict it. In an interactive language where we can attempt to assign any variable to any type’s instance at runtime, type annotations must do a few more things, and it’s not even the primary way to design type stability (variable having a consistent type through a method, making it optimizable) for such a generic language.