Function type signature

I defined two methods of a function, which I thought will have different type signatures:

foo((a, b)) = print(a, "--", b)
foo(a) = print(a)

but I’m getting:

julia> methods(foo)
# 1 method for generic function "foo":
[1] foo(a) in Main at REPL[13]:1

instead of expected two methods:

foo(::Any)
foo(Tuple{::Any, ::Any})

Why is that? Also, why the listing is different with a single method (foo(a)) vs. multiple methods (foo(::Any))?

When I add:

foo(t::Tuple{Any, Any}) = print(t[1], "----", t[2])

I get:

julia> methods(foo)
# 2 methods for generic function "foo":
[1] foo(t::Tuple{Any,Any}) in Main at REPL[20]:1
[2] foo(::Any) in Main at REPL[17]:1

but when I add:

foo((a, b)::Tuple{Any, Any}) = print(a, "---", b)

it redefines the last method and shows:

julia> methods(foo)
# 2 methods for generic function "foo":
[1] foo(::Tuple{Any,Any}) in Main at REPL[23]:1
[2] foo(::Any) in Main at REPL[17]:1

so the type signature foo(t::Tuple{Any,Any}) was redefined by foo(::Tuple{Any,Any}). Why a slightly different notation?

4 Likes

It does look like this might restrict the input types of foo, but if you try it out you’ll find that this method still matches any arguments. It will simply try to unpack whatever value you pass in, and it will throw an error if that cannot be done. For example:

julia> foo((a, b)) = @show a b
foo (generic function with 1 method)

julia> foo((1, 2))  # a tuple
a = 1
b = 2
2

julia> foo([1, 2])  # an array
a = 1
b = 2
2

julia> foo(1 => 2)  # a pair
a = 1
b = 2
2

julia> foo(1)  # a value which cannot be unpacked
ERROR: BoundsError: attempt to access Int64
  at index [2]

The (a, b) unpacking syntax is not a type restriction on the input, and therefore it does not create a separate method from any other foo(a) or foo(a::Any).

5 Likes

Thank you. I was just going to post my surprise at:

julia> foo(1)
ERROR: BoundsError: attempt to access Int64
  at index [2]

but now I see that foo((a, b)) redefined foo(a). Is there an explanation of rationale behind this design choice somewhere?

https://docs.julialang.org/en/v1/manual/functions/#Argument-destructuring-1

1 Like

It explains the syntax, but not foo((a, b)) redefines foo(a) design choice.

I take this statement:

as implicitly meaning that foo((a,b)) = print(a, "--", b) is equivalent to:

function foo(argument)
    (a, b) = argument
    print(a, "--", b)
end

And that explains why it redefines foo(a).

2 Likes

I think the best way to understand the design choice is to try to write the type restriction that you want f((a,b)) to convert to. It would need to include 2 element arrays tuples and iterables, despite that 2 of the 3 of those don’t have their length as part of their type.

1 Like

What signature would you want this method to have? You haven’t constrained it’s input type at all, you just told julia to unpack the argument into two variables a and b.

I expected foo(Tuple{::Any, ::Any}) as I mentioned in the top post

I guess what’s unclear to me is that you seemed to also be expressing surprise at this:

What you found is that julia only cares about what’s on the right hand side of :: for the purposes of dispatch. The left hand side is just about the naming of variables.

3 Likes

Note that the documentation says “If a function argument name is written as a tuple (e.g. (x, y) ) instead of just a symbol” (emphasis added), instead of “if it is a tuple”. So, as @Oscar_Smith has explained, with that syntax you can actually use multiple types of arguments, i.e. it should be considered as Any.

1 Like

Thank you. This should find its way into the manual. It is the simplest answer to my question.

Can you comment on:

[1] foo(t::Tuple{Any,Any}) in Main at REPL[20]:100:

vs.

[1] foo(::Tuple{Any,Any}) in Main at REPL[23]:100:

I this case, Julia cared a little bit about what is on the left side of ::.

Methods have the same signature. Julia just captures the names of arguments for pretty printing in things like methods, but whether or not you name an argument, it matches in exactly the same way.

Is it consistent, though?

foo(a) = print("-", a)
foo(t::Tuple{Any, Any}) = print(t[1], "----", t[2])
......
julia> methods(foo)
# 2 methods for generic function "foo":
[1] foo(t::Tuple{Any,Any}) in Main at REPL[4]:1
[2] foo(a) in Main at REPL[1]:1

Why not foo(a::Any) in the second line?

Ah, maybe I’m misunderstanding you a little. We just use foo(a) to implicitly mean foo(x::Any). The ::Tuple{Any, Any} is important because it tells you there are two elements. It could be NTuple{2, Any} I suppose.

By the way, with MLStyle.jl it’s possible to make custom destructuring syntax that’s a bit more powerful than Julia’s and automatically constraints types.

using MLStyle, ExprTools

macro destruct(fdef)
    d = splitdef(fdef)
    dargs = Tuple(deepcopy(d[:args]))
    d[:args] = map(1:length(d[:args])) do i
        arg = d[:args][i]
        @match d[:args][i] begin
            :($_ :: $T)              => :($(gensym("arg$i")) :: $T)
            :(   :: $T)              => :($(gensym("arg$i")) :: $T)
            Expr(:tuple, names...)   => :($(gensym("arg$i")) :: Tuple)
            Expr(:vect,  names...)   => :($(gensym("arg$i")) :: Vector)
            Expr(:hcat,  names...)   => :($(gensym("arg$i")) :: Array)
            Expr(:vcat,  names...)   => :($(gensym("arg$i")) :: Array)
            Expr(:call, T, names...) => :($(gensym("arg$i")) :: $T)
            _                        => :($(gensym("arg$i")) :: Any)
        end
    end 
    d[:body] = :($(@__MODULE__).@match ($(d[:args]...),) begin ($(dargs...),) => $(d[:body]) end)
    out = esc(combinedef(d))
end

struct Foo
    x::Int
    y::String
end

@as_record Foo
@destruct function f([a, b], Dict("one" => Dict("two" => c)), Foo(d, e)) where {T}
    (a, b, c, d, e)
end

f([1, 2], Dict("one" => Dict("two" => "boo")), Foo(1, "hi"))

#+RESULTS:
: (1, 2, "boo", 1, "hi")
methods(f)

#+RESULTS:
: # 1 method for generic function "f":
: [1] f(arg1::Array{T,1} where T, arg2::Dict, arg3::Foo) where T in Main
1 Like

This is a problem for a beginner like me, who knows very little about implicit behaviors. Here is the inconsistency of pretty printing method type signature, which added to my confusion:

julia> foo((a, b)) = print(a, "--", b)
foo (generic function with 1 method)

julia> methods(foo)
# 1 method for generic function "foo":
[1] foo(::Any) in Main at REPL[1]:1

julia> foo(a) = print("-", a)
foo (generic function with 1 method)

julia> methods(foo)
# 1 method for generic function "foo":
[1] foo(a) in Main at REPL[3]:1

The same type signature is printed as foo(::Any) (nice) and foo(a) (less nice).

2 Likes

I think that the algorithm is something like the following:

  1. if the argument has a variable name, print that (for destructuring, that does not apply),
  2. if no variable name was printed, or if the type is something other than Any, print the type

I find this nice because it leads to compact output.

1 Like

I can only hope that with time I will find this rule worth more than initial confusion. Just offering beginner’s perspective here.

Perhaps it would make some sense to have Julia for dummies REPL setting, which would minimize implicit rules and irregularities at the cost of more verbose output. Experts could use Julia for experts settings to not get bothered by obvious (to them) info.

I think it is just better to learn Julia as is. It happens quickly for most people.

Whenever people ask for “Julia with training wheels”, it is implicitly assumed that it would be written and maintained by some “experts”. But they may not be interested in working on this in their free time, and handle complaints about the training wheels being the wrong color. YMMV.