modularml / mojo

The Mojo Programming Language
https://docs.modular.com/mojo
Other
22.31k stars 2.55k forks source link

[Feature Request] Clarify Function Overloading Resolution #1321

Open ParadaCarleton opened 7 months ago

ParadaCarleton commented 7 months ago

Review Mojo's priorities

What is your request?

When resolving a function call, Mojo tries each candidate and uses the one that works (if only one works), or it picks the closest match (if it can determine a close match), or it reports that the call is ambiguous if it can’t figure out which one to pick. In the latter case, you can resolve the ambiguity by adding an explicit cast on the call site.

What does "Closest match" mean? To me, it sounds like you're proposing to let functions call methods that have types that "almost match" but don't. That would break type safety and definitely cause a lot of bugs.

Is this supposed to be a version of the "Most specific type" rule used by Julia and most implementations of multiple dispatch (the dynamic-typing equivalent of function overloading)?

What is your motivation for this change?

Clarity.

Any other details?

No response

soraros commented 7 months ago

I agree some classification is needed, a clear spec will be greatly helpful.

I don't think " the closest match" will break type safety though, as it likely refers to calling the overload that requires the least number of implicit type conversion. Similar behaviour can be seen in C++. However, I do worry the full C++ behaviour is a bit too complicated and ad hoc.

lattner commented 7 months ago

Mojo currently uses a very C++ style of overload resolution, where it counts the number of implicit conversions required for a candidate to work out. If two different candidates have the same number of conversions, then an ambiguity error is emitted. Some examples:

fn test(a: PythonObject, b: PythonObject): ...
fn test(a: Int, b: PythonObject): ...
fn test(a: PythonObject, b: Int): ...

test(someObj, someObj) # First one, obviously
test(someInt, someObj) # Second one, since it requires zero conversions
test(someInt, someInt) # error, don't know if it should be the 2nd or 3rd one.

agree we should document this!

ParadaCarleton commented 7 months ago

I don't think " the closest match" will break type safety though, as it likely refers to calling the overload that requires the least number of implicit type conversion.

Mojo currently uses a very C++ style of overload resolution, where it counts the number of implicit conversions required for a candidate to work out. If two different candidates have the same number of conversions, then an ambiguity error is emitted. Some examples:

I'm a bit confused/surprised here by the reference to implicit conversions--is Mojo supposed to be a weakly (or at least somewhat weakly) typed language? That would be a pretty huge change from Python, and the other major machine learning languages (R and Julia), which only have implicit conversions for numeric types (numbers/integers/floats).

ParadaCarleton commented 7 months ago

On the subject of using the sum of distances by argument as the metric for choosing the closest method, that seems like it would work and be familiar to C++ users, but it's also very counterintuitive because it's implicit, rather than explicit, and can be very nonlocal--for instance, say we have a hierarchy with types Float < Real < Number < Tensor < Any :

fn test(a: float, b: float, c: float, d):
# somewhere off in another part of the code
fn test(a: real, b: real, c: real, d: real):

Then, the second test function can easily hijack other code unintentionally. For instance, passing arguments a: float, b: float, c: float, d:float will call the latter function, even though the first 3 arguments match with the first method perfectly, which I find very counterintuitive. (I tend to assume the first few arguments are the most important, with later arguments progressively less important).

My own thought: I'm familiar with overloading in both R and Julia. It's been a while since I touched S4, so I can't 100% remember the details of how this is resolved, but Julia's behavior is very intuitive for me: overloading a function this way would create an error, because neither signature "dominates" the other (where signature A dominates B if all its arguments are more specific). With this rule, I'd get an error at compile time forcing me to disambiguate using:

fn test(a: float, b: float, c: float, d):
fn test(a: real, b: real, c: real, d: real):
# if the next method is omitted,
# compiler or linter should give an error
fn test(a: float, b: float, c: float, d:real)
soraros commented 7 months ago

@ParadaCarleton I don't think implicit conversion will make the language weakly typed in anyway, Mojo just calls the corresponding constructor for you. Even "excessively" typed language like Coq supports some form of implicit coercion. It's quite useful when you want to use literals while calling (say numeric) functions and initialising variables. I agree it can be confusing at times and should be used sparingly, though. See here for more discussion, and suggestion for future improvements.

... say we have a hierarchy with types Float < Real < Number < Tensor < Any

If I understand correctly, Mojo's ITC (implicit type conversion) doesn't work this way (at least for now, for the more tricky cases are blocked by traits). The ITC graph is just a plain directed graph (not even a DAG), and is not at all required to be totally ordered by topological ordering, so it may not collapse to the above chain. In the following example, you may also see that f: fn (B, B, B) -> ... doesn't hijack fn[T: AnyType](A, A, T) -> .... Actually, given the way Mojo implicit conversion works, I don't see would one want to write a fn[T: AnyType](A, A, T) -> ... in a more realistic setting.

#   ℕ
#   ↓
#   A
#  ↙ ↘
# B   C
#  ↘ ↗
#   D

@value
@register_passable("trivial")
struct A:
  fn __init__(n: IntLiteral) -> Self: return A {}

@value
struct B:
  fn __init__(inout self, a: A): ...

@value
struct C:
  fn __init__(inout self, b: A): ...
  fn __init__(inout self, d: D): ...  # doesn't need to form a DAG

@value
struct D:
  fn __init__(inout self, c: B): ...

fn main():
  let a: A = 0  # powered by implicit conversion™
  let b: B = a
  let c: C = a
  let d: D = b

  let d0: D = a  # error: cannot implicit convert 'A' value to 'D'

  fn f[T: AnyType](x: A, y: A, z: T):
    print("AAT")
  fn f(x: B, y: B, z: B):
    print("BBB")

  f(a, a, a)  # prints "AAT"
ParadaCarleton commented 7 months ago

@ParadaCarleton I don't think implicit conversion will make the language weakly typed in anyway, Mojo just calls the corresponding constructor for you. Even "excessively" typed language like Coq supports some form of implicit coercion. It's quite useful when you want to use literals while calling (say numeric) functions and initialising variables. I agree it can be confusing at times and should be used sparingly, though. See here for more discussion, and suggestion for future improvements.

I'm happy with literals being automatically converted/inferred. For instance, in x + 1, 1 should be inferred to have the same type as x. I'm more concerned about converting or casting almost anything else without asking the user--if a method says fun f(x: Type), f should only work with Type, not with anything that isn't a Type.

If I understand correctly, Mojo's ITC (implicit type conversion) doesn't work this way (at least for now, for the more tricky cases are blocked by traits). The ITC graph is just a plain directed graph (not even a DAG), and is not at all required to be totally ordered by topological ordering, so it may not collapse to the above chain.

I'm not sure what you mean here--doesn't the type hierarchy have to be a DAG for multiple inheritance to work and stay compatible with Python?

In the following example, you may also see that f: fn (B, B, B) -> ... doesn't hijack fn[T: AnyType](A, A, T) -> .... Actually, given the way Mojo implicit conversion works, I don't see would one want to write a fn[T: AnyType](A, A, T) -> ... in a more realistic setting.

Right! Sorry if I wasn't clear, but that's basically what I was trying to say: most users probably don't want to overload functions like that, but it could easily happen accidentally. So instead, this should throw an error to make users clarify what they want. The problem is f(B, B, B) isn't being called, even though I think most people would intuitively consider it to be "closer."

As an example, we could have something like plus(1, 2, "kahan") (last argument provides summation algorithm, which otherwise defaults to "pairwise") vs. plus(1., 2., 3.). If the user leaves off the type annotation for the 3rd argument, this should error.

soraros commented 7 months ago

I'm more concerned about converting or casting almost anything else without asking the user--if a method says fun f(x: Type), f should only work with Type, not with anything that isn't a Type.

Agreed. Please checkout this discussion, and help us push for more control over implicit conversions.

... doesn't the type hierarchy have to be a DAG for multiple inheritance to work and stay compatible with Python?

I think it is true, but that's in the realm of classes, which current Mojo doesn't even touch. Currently, the standard library types doesn't form inheritance chains like Float <: Real <: Number <: Tensor. And with traits coming in a few days, they probably never will. In my example above, if we add a constructor D.__init__(self, c: C) from C to D, the hierarchy is not a DAG anymore.

... even though I think most people would intuitively consider it to be "closer."

If we assume implicit conversion follow inheritance hierarchy, which they don't seem to. It's no easy task to determine the most specific type, Julia had problem with this before. Things may have improved since, IDK. I don't like the C++ style overload resolution, but at least it's sound™.

As an example, we could have something like plus(1, 2, "kahan") (last argument provides summation algorithm, which otherwise defaults to "pairwise") vs. plus(1., 2., 3.). If the user leaves off the type annotation for the 3rd argument, this should error.

This feels like bad API design. Mojo demands more type discipline than e.g. Julia, and I see no point (and not possible) in leaving out the type annotation for c. Moreover, fn[T: AnyType](a: A, b: A, c: T) means something very different from fn(a: A, b: A, c: object) or fn(a: A, b: A, c: A | String), and both feel like bad candidate for your use case regardless of overloading resolution. If the last argument is meant to specify the method, wouldn't it better be typed fn(a: A, b: A, method: String) or fn(*args: A, method: String)?

That said, the kind of hijacking can happen to very naive looking code (in Mojo 0.5.0). Just because we have a constructor from Int to String, JavaScript happened, it's horrifying. And I agree it's a bug and should be fixed (removing Int -> String ctor; stop using all ctors for conversion), which lead to #1310.

fn plus(a: Int, b: Int, c: String) -> Int:
  return a + b

fn plus(a: Float64, b: Float64, c: Float64) -> Float64:
  return a + b + c

plus(1, 1, 1)  # calls the first one