Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

type of function in Julia

Consider the following bit of code:

julia> function foo(x::Float64)::Float64
           return 2x
       end
foo (generic function with 1 method)

julia> typeof(foo)
typeof(foo)

There must be a reason why typeof(foo) doesn't return something more meaningful, for example (Float64 -> Float64). What is it?

I came across this looking at Zygote code.

like image 500
miha priimek Avatar asked Aug 27 '20 14:08

miha priimek


People also ask

What is a generic function Julia?

Method Tables Every function in Julia is a generic function. A generic function is conceptually a single function, but consists of many definitions, or methods. The methods of a generic function are stored in a method table. Method tables (type MethodTable ) are associated with TypeName s.

What is multiple dispatch in Julia?

Using all of a function's arguments to choose which method should be invoked, rather than just the first, is known as multiple dispatch.


3 Answers

When you write typeof(foo) you are asking about the type of function foo. This function can have multiple methods (type stable or not --- but this is another issue), and these methods have different signatures (types of arguments) and for some of them the compiler might be able to infer the return type while for others it might not (and you should not rely on it AFAICT --- just assume that most of the time the compiler does the right job).

To give an example consider this code (1 function, 2 methods):

julia> f1(::Int) = 1
f1 (generic function with 1 method)

julia> f1(::Bool) = 2
f1 (generic function with 2 methods)

julia> typeof(f1)
typeof(f1)

julia> methods(f1)
# 2 methods for generic function "f1":
[1] f1(::Bool) in Main at REPL[21]:1
[2] f1(::Int64) in Main at REPL[20]:1

Now referring to return value specifications like you have written:

f(x)::Float64 = x

they are not mere assertions. Julia actually does two things:

  • it converts the return value to the requested type
  • does assertion that the conversion succeeded

This is relevant as e.g. in my function f above you can write:

julia> f(1)
1.0

julia> f(true)
1.0

and you can see that the conversion (not only assertion) takes place.

This style is quite relevant in type unstable code (e.g. when using DataFrame that is a type unstable data structure) as such assertions can help "break the type instability chain" in your code (if you have some part of it that is not type stable).


EDIT

Could this not be resolved by using Union{Int, Bool} -> Int? for f1, for example? And similarly for type instability? I guess that would require the code to be compiled for all input types and therefore lose the advantages of JIT?

But what if for Int an Int were returned and for Bool a Bool were returned? Consider e.g. an identity function.

Also take the following example:

julia> f(x) = ("a",)[x]
f (generic function with 1 method)

julia> @code_warntype f(2)
Variables
  #self#::Core.Compiler.Const(f, false)
  x::Int64

Body::String
1 ─ %1 = Core.tuple("a")::Core.Compiler.Const(("a",), false)
│   %2 = Base.getindex(%1, x)::String
└──      return %2

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

@code_warntype correctly identifies that if this function returns something it is guaranteed to be String, however, return_types as Przemysław suggested tells you that it is Any. So you can see that this is a hard thing and you should not rely on it blindly - just assume that it is up to the compiler to decide what it can infer. In particular - for performance reasons - the compiler might give up doing the inference even if in theory it would be possible.

In your question you are probably referring to what e.g. Haskell provides, but in Haskell there is a restriction that the return value of the function may not depend on runtime values of the arguments passed, but rather only on the types of arguments. There is no such restriction in Julia.

like image 128
Bogumił Kamiński Avatar answered Oct 17 '22 15:10

Bogumił Kamiński


Let me show you something for comparison:

julia> struct var"typeof(foo)" end

julia> const foo = var"typeof(foo)"()
var"typeof(foo)"()

julia> (::var"typeof(foo)")(x::Float64) = 2x

julia> (::var"typeof(foo)")(x::String) = x * x

julia> foo(0.2)
0.4

julia> foo("sdf")
"sdfsdf"

julia> typeof(foo)
var"typeof(foo)"

This is approximately what happens internally. When you write function foo, the compiler generates something like an anoynymous singleton struct with an internal name, typeof(foo), and instance foo. Then all methods, or "dispatch combinations", are registered on its type.

You see it does not make sense to give foo a type like Float -> Float or String -> String -- the function is just the the singleton value, and its type the type of the struct. The struct does not know anything about its methods (internally, in the real compiler, it does, of course, but not in a way accessible to the type system).

Theoretically, you could design a system in which all methods of a function are collected in some kind of union type, but that gets unwieldily huge, and complicated due to the fact that function types are covariant in their codomain and contravariant in their domain. So it's not done. That being said, people are discussing syntax for referring to the type of a method instance itself, which would be kind of what you're thinking of.

like image 4
phipsgabler Avatar answered Oct 17 '22 15:10

phipsgabler


The ::Float64 at the end of function definition is just a type assertion (and conversion if possible) - it does not directly help the compiler in any way.

To understand it let us consider the following function:

f(a, b) = a//b

For a pair of Int values this will return a Rational{Int}. Let check what Julia compiler can do:

julia> code_warntype(f, [Int,Int])
Variables
  #self#::Core.Compiler.Const(f, false)
  a::Int64
  b::Int64

Body::Rational{Int64}
1 ─ %1 = (a // b)::Rational{Int64}
└──      return %1

Or for other pair of inputs:

julia> code_warntype(f, [Int16,Int16])
Variables
  #self#::Core.Compiler.Const(f, false)
  a::Int16
  b::Int16

Body::Rational{Int16}
1 ─ %1 = (a // b)::Rational{Int16}
└──      return %1

You can see that the compiler can calculate the type of output on the base of types of inputs. Hence the only rule in Julia code is "always write type stable code". Consider the function:

g(a,b) = b != 0 ? a/b : a

And let us test the type:

julia> code_warntype(g, [Int,Int])
Variables
  #self#::Core.Compiler.Const(g, false)
  a::Int64
  b::Int64

Body::Union{Float64, Int64}
1 ─ %1 = (b != 0)::Bool
└──      goto #3 if not %1
2 ─ %3 = (a / b)::Float64
└──      return %3
3 ─      return a

In the Julia REPL Union{Float64, Int64} is shown in red which means the code is not type stable (return either int or float).

Last but not least it is also possible to ask for function output type:

julia> Base.return_types(f,(Int8,Int16))
1-element Array{Any,1}:
 Rational{Int16}

julia> Base.return_types(g,(Int,Float64))
1-element Array{Any,1}:
 Union{Float64, Int64}

In conclusion:

  • there is no need to declare the output type of a Julia function - it is a job for compiler
  • defining your own output type works like a type assertion (or can be used for result type conversion - see Bogumil's answer)
  • in order to determine type of output of a function in Julia types of all inputs are required (not just the method name)
like image 2
Przemyslaw Szufel Avatar answered Oct 17 '22 15:10

Przemyslaw Szufel