This post is a little long so I’ve split it into three parts. I’m not too familiar with any of this, so please correct me if something is wrong.
Method Types are Possible
With regard to the current conversation:
Can you write the type of a method independently of the function it’s a method of? I think the answer is no. A method takes its function identity as its first parameter so it must be in the type.
But that’s not necessarily a problem. These things are supposed to be covariant on the return side and contravariant on the argument side. That is, (A --> B) <: (C --> D)
iff A >: C
and B <: D
. Note that this is also the convention used in functional languages, including Scala and Haskell.
That means that the only sensible interpretation of method::({X} --> T)
is:
- A guarantee that the method applies to signature
{X}
; that is, if x isa X
, then x()
should be able to dispatch to this method (I personally think this is a little silly, but see below.)
- A guarantee that the return value of this method (if it returns at all) is an
T
The first guarantee is a little vague, but from the perspective of the method itself, it’s just a subtyping check to see if the argument type is matched.
That means that as @jameson noted, {Any, Int, Int} --> Int
is wrong for a method of an anonymous function (because this would imply the method were dispatchable for, e.g., "x"(1, 2)
, which is false). But this is not really a problem, because {Union{}, Int, Int} --> Int
describes these methods fine. The only trouble theoretically is that there are no objects of type Tuple{Union{}, Int, Int}
, so we’re relying on the fact that this is not considered just Union{}
, but is instead some hypothetical type. It means our definition above is not strictly correct, and we have to add a special case for tuples with Union{}
as a parameter. But overall there is no problem here.
Function Types are Technically Infeasible
A more significant problem is function types. These are inherently a lot harder than method types, and just taking the Union
of method types does not really work. (It’s not at all what you want; a Union
means this object is of one of these types, and it does not make sense for a typeof(f)
to be a Union
of multiple types.) If
f(x::Int) = 0
f(x::String) = 1
then we note that the function f
, in general, satisfies that it can dispatch to f(::Int)
, and the return type is Int
, so we might want f::({typeof(f), Int} --> Int)
. Furthermore it can dispatch to f(::String)
, and the return type is String
, so we might want f::({typeof(f), String} --> String)
. This means that the subtype relationships we want to have are:
typeof(f) <: ({typeof(f), Int} --> Int)
typeof(f) <: ({typeof(f), String} --> String)
in other words, we want
typeof(f) <: typeintersect({typeof(f), Int} --> Int, typeof(f), String} --> String)
and furthermore we want typeintersect
to return a type that actually is contained by both of the supertypes. As far as I can tell, this is already infeasible. This would require an Intersect{F, G}
alongside the currently existing Union{F, G}
. The type lattice is not really built to support Intersect{F, G}
as a type. This would also make a lot of problems like subtyping very hard or possibly undecidable.
Another possible approach is to use {F, G}
(i.e. Tuple{F, G}
) to simulate intersection. This also has a lot of problems, as you might imagine, and is not very helpful for the programmer.
Function Arrow Types are Unnecessary
Overall I’m very convinced that true arrow types are not possible, because the behavior of functions is too complex to describe in a type system. In practice, arrow types are useful for two reasons:
- to allow compiler/user to check the correctness of an argument, or to dispatch on arguments
- for performance reasons: so that a function pointer can be stored in a type stable way
For the second point we don’t need true arrow types at all. It’s totally fine to have a “arrow type” that is a callable type with a type assertion which wraps around a function pointer and ccall
s it; I believe that @yuyichao has worked on this.
For the first point I’m unconvinced of the usefulness of arrow types. I am pretty sure that dispatching on arrow types will never work well, from both a technical standpoint and a user usability standpoint; for the behavior of dispatch to depend on implementation details of inference seems crazy to me. Theoretically, this could be used to provide optimizations when inference can figure something out, but Core.Inference.return_type
can already be used to do that in cases where it matters.
Furthermore, I’m not really sure what the point of arrow types is in a language where any function call will compile, and there is no real difference (from the programmer’s perspective) between a MethodError
and an ArgumentError
. In languages with arrow types, MethodError
s are found at compile time, whereas ArgumentError
s are found at runtime, so it is meaningful to use types to describe that certain things will pass compile.
Function types in Scala and Haskell guarantee that if f::({typeof(f), Int} --> Int)
, then f(1)::Int
will not fail compilation. (There is no guarantee that f(1)::Int
will not have, for example, a runtime error.) If we use the same definition in Julia, then every object has this type: there is no object for which f(1)::Int
will fail compilation. So if we go by the same standard, then {typeof(f), Int} --> Int
is just Any
.
The argument is often made that MethodError
s are different than ArgumentError
s, even though they’re both runtime, because the former indicates a likely mistake by the programmer, whereas the latter may be more likely due to exceptional input as opposed to programmer error. But this is really a pointless distinction; if I define f(::Int) = error("don't use this on Ints")
, why should all of a sudden f
become isa {typeof(f), Int} --> Int
? To me this does not make much sense. Sure, f
accepts an Int
argument and always returns an Int
if it returns at all… but this is really the same as f
not being defined to accept an Int
argument at all.
The issue, really, is that the idea of a function “accepting” a certain signature is not well defined. Should f
be considered to “accept” signature S
if there’s no method error? What if there is sometimes ambiguities in certain exotic cases?
Overall I’m of the opinion that arrow types are an incomplete way of detecting whether the programmer is likely to have made an error. There is no replacement for good unit tests in catching typos. A linter can help too for missing coverage. But I don’t see type checking functions as being sensible here.