Do you put return type in function definitions?

Do you have any opinion about annotating function definitions with return types? Like it or not and why?

Here’s a dummy example:

add(x, y)::Int = x + y

It gives additional type checking:

julia> add(1,2)
3

julia> add(1.0,2)
3

julia> add(1.1, 2)
ERROR: InexactError: Int64(3.1)
3 Likes

In general, don’t. The fact that methods in julia can automatically infer return types is a feature, not a bug.

3 Likes

I agree that it’s a feature. The question is whether it’s “idiomatic” to annotate return type and make Julia do type checks at runtime.

1 Like

No it’s not. There are cases where return type annotation is useful. However, you must not take that as a license to use it for all or most of your functions.

It also has little to do with runtime type check…

3 Likes

FWIW, I sometimes find it helpful to annotate return types in function definitions if I’m using several function calls within the same function but this is mostly for clarity for own own sake when I come back to large projects after a few weeks or months.

I don’t think performance is affected by whether you annotate return type in a function or not, but correct me if I’m wrong.

1 Like

I try to write mostly generic code so I never do this.

The only thing I ever use is its twin brother some_function_call(x, y)::SomeType to give the compiler a nudge for inference, but that’s an entirely different thing.

5 Likes

In general I don’t do it, as I use Julia as a dynamic language.
The same reason, why I don’t do it neither (in general) for the function parameters.

Thats how I start. And during evolving of the code I think here or there I should specify the types.

2 Likes

Do I understand this right?
You know that some_function_call returns SomeType, but you help the compiler with specifiying it explicitely in the line of calling some_function_call?

Do you have an example on which I can see why this can be?

1 Like

Type inference can fail in some complex settings, but this usually happens in nontrivial code so I don’t have a self-contained MWE to demonstrate. The last time I needed this was a

mapreduce(some_complicated_function::AbstractVector, hcat, xs)::AbstractMatrix

because inference was shaky (it was a Union of a few things). This was in one of the release candidates for 1.3 so I don’t know if it persists, also it was under many layers in a 10k LOC codebase.

2 Likes

I expected this, but your example is a good one. I didn’t imagine, when I asked, that functions e.g. can be parameters as well and the return type can change with that and adding some complexity does the rest.

Sorry, this is nonsensical, it was more like

mapreduce(x -> some_complicated_function(x)::AbstractVector, hcat, xs)::AbstractMatrix
3 Likes

I think in general it is nice to see the return type in the place where the function is defined (i. e. at the top). Much better than having to hunt for it through the source code, emulating the work of the compiler really. For instance, what does this function return

inv(B::BunchKaufman{<:BlasComplex})

on line 350 of bunchkaufman.jl? Wouldn’t it be nice if this information was on the line where this function is defined?

6 Likes

For a lot of high-level idiomatic Julia code, the return type may be just be an implementation detail, and/or depend on the input types in a way that is impractical to document with ::. Eg

"""
    make_hermitian(A)

Return a hermitian matrix that is similar to `A` in size and type.

For unit testing.
"""
make_hermitian(A) = Hermitian(A' * A)

make_hermitian(rand(10, 10))

make_hermitian(@SMatrix rand(10, 10))

True.

Note that Julia does not dispatch over return type as may be desired when searching this topic.

3 Likes

For clarity reason, I sometimes annotated return type.

However, I stopped when I realized this :

Given:

foo()::AbstractVector{AbstractString} = [“hello”]
foo2() = [“hello”]

fii(v) = println(eltype(v))
fii2(v) = @inbounds v[1]*v[1]

then:

julia> fii(foo())
AbstractString

julia> fii(foo2())
String

and

julia> using BenchmarkTools

julia> @btime fii2(foo())
198.081 ns (3 allocations: 160 bytes)
“hellohello”

julia> @btime fii2(foo2())
82.447 ns (2 allocations: 96 bytes)
“hellohello”

It seems that return type annotation can affect type information propagation and induce runtime penalties. Am I wrong?

(I am using Julia v1.7.1)

You can change the code to

foo()::AbstractVector{String} = ["hello"]

to match the performance of the un-annotated version. Your original code converts an array of concrete type String to an array of abstract type AbstractString, which causes a performance penalty. So return type annotation is sometimes not just an “annotation” but can trigger implicit conversion; a type error is only produced if this conversion is impossible.

1 Like

@greatpet Yes, I understand. However I generally used return type annotations when I wanted to clarify my “interfaces”. In such context, I didn’t want to prematurely restrict the function to return a vector of String.

Maybe:

foo3()::Vector{T} where {T<:AbstractString} = ["hello"]

can express more precisely the interface.
(and maintains performance)

2 Likes

That’s a good idea.

foo4()::AbstractVector{T} where {T<:AbstractString} = [“hello”]

also seems to work without penalty (at least for this example)

julia> @btime fii2(foo3())
81.278 ns (2 allocations: 96 bytes)
“hellohello”

julia> @btime fii2(foo4())
81.377 ns (2 allocations: 96 bytes)
“hellohello”

julia> typeof(foo4())
Vector{String} (alias for Array{String, 1})