Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support method/function overloads #1122

Open
nex3 opened this issue May 18, 2016 · 116 comments
Open

Support method/function overloads #1122

nex3 opened this issue May 18, 2016 · 116 comments
Labels
request Requests to resolve a particular developer problem

Comments

@nex3
Copy link
Member

nex3 commented May 18, 2016

This has been discussed periodically both in the issue tracker and in person, but I don't think there's a tracking issue yet. (EDIT: Here is the original issue from 2011 - dart-lang/sdk#49).

Now that we're moving to a sound type system, we have the ability to overload methods—that is, to choose which of a set of methods is called based on which arguments are passed and what their types are. This is particularly useful when the type signature of a method varies based on which arguments are or are not supplied.

@jodinathan
Copy link

jodinathan commented Sep 29, 2017

any news on this?
maybe with dart 2.0?
=]

@lrhn
Copy link
Member

lrhn commented Jun 22, 2018

Not in Dart 2.
This is a significant change to the object model of Dart.
Currently, a Dart object has at most one accessible member with any given name. Because of that, you can do a tear-off of a method.
If you could overload methods, tear-offs would no longer work. You would have to say which function you tore off, or create some combined function which accepts a number of different and incompatible parameter signatures.
It would make dynamic invocations harder to handle. Should they determine that method to call dynamically? That might cause a significant code overhead on ahead-of-time compiled programs.

I don't see this happening by itself. If we make a large-scale change to the object model for other reasons, then it might be possible to accommodate overloading too, but quite possibly at the cost of not allowing dynamic invocations.

@jodinathan
Copy link

but with a sound dart we don't have dynamic invocations, do we?

@eernstg
Copy link
Member

eernstg commented Jun 22, 2018

We can certainly still have dynamic invocations: If you use the type dynamic explicitly and invoke an expression of that type then you will get a dynamic invocation, and it is an important part of the Dart semantics that we have enough information available at run time to actually make that happen safely (that is, we will have a dynamic error if the invocation passes the wrong number of arguments, or one or more of the arguments has a wrong type, etc).

Apart from that, even with the most complete static typing you can come up with, it would still be ambiguous which method you want to tear off if you do x.foo and foo has several implementations. So it's more about first class usage (passing functions around rather than just calling them) than it is about static typing.

@matanlurey
Copy link
Contributor

matanlurey commented Jun 26, 2018

@lrhn:

If you could overload methods, tear-offs would no longer work.

You already cannot tear off what users write instead of overloads, which is multiple methods:

class Foo {
  void bar() {}
  void barString(String s) {}
  void barNumber(num n) {}
}

... so given that overloads would be sugar for that, I don't see it any worse.

@eernstg:

and it is an important part of the Dart semantics that we have enough information available at run time to actually make that happen safely

Is it being dynamically invokable a requirement? I don't think it is.

I'd heavily like to see a push for overloads in the not-so-distance future. My 2-cents:

(@yjbanov and @srawlins get credit for parts of this discussion, we chatted in person)

Proposal

Don't allow dynamic invocation of overloaded methods

... or limit how they work:

class Foo {
  void bar() => print('bar()');
  void bar(String name) => print('bar($name)');
  void bar(int number) => print('bar($number)');
}

void main() {
  dynamic foo = new Foo();

  // OK
  foo.bar();

  // Runtime error: Ambiguous dispatch. 2 or more implementations of `bar` exist.
  foo.bar('Hello');
}

If you wanted to be real fancy (@munificent's idea, I think), you could have this generate a method that does dynamic dispatch under the scenes. I'm not convinced this holds its weight (and makes overloading, which should be a great static optimization a potential de-opt), but it's an idea.

I realize this adds a feature that is mostly unusable with dynamic dispatch, but Dart2 already has this issue with stuff like reified generics.

Consider this very common bug:

var x = ['Hello'];
dynamic y = x;

// Error: Iterable<dynamic> is not an Iterable<String>
Iterable<String> z = y.map((i) => i);

Limit tear-offs if the context is unknown

Rather, if the context is ambiguous, then make it a static error.

void main() {
  var foo = new Foo();

  // Static error: Ambiguous overload.
  var bar = foo.bar;

  // OK
  var bar = (String name) => foo.bar(name);

  // Also OK, maybe?
  void Function(String) bar = foo.bar;
}

... another option is have var bar = foo.bar basically generate a forwarding closure (a similar de-opt to the dynamic dispatch issue). Again, not my favorite, but I guess no more bad than code already being written.

Side notes

Let's consider how users are working around this today:

  1. Using Object or dynamic with is checks and optional arguments:
class Foo {
  void bar([dynamic nameOrNumber]) {
    if (nameOrNumber == null) {
      print('bar()');
      return;
    }
    if (nameOrNumber is String) {
      // ...
      return;
    }
    if (nameOrNumber is num) {
     // ...
     return;
    }
  }
}
  • This works with dynamic dispatch
  • This works with tear-offs
  • This isn't very efficient, and it's very hard/impossible to create complex overloads
  • You lose virtually all static typing
  1. Creating separate methods or constructors:
class Foo {
  void bar() {}
  void barString(String s) {}
  void barNumber(num n) {}
}
  • This doesn't work with dynamic dispatch
  • This doesn't work with tear-offs
  • This is the most efficient, but cumbersome and creates a heavy API surface
  • Best static typing

I think the idea for overloads is no worse than 2, and you can still write 1 if you want.

EDIT: As @srawlins pointed out to be, another huge advantage of overloads over the "dynamic"-ish method with is checks is the ability to have conditional type arguments - that is, type arguments that only exist in a particular context:

class Foo {
  void bar();
  T bar<T>(T i) => ...
  List<T> bar<T>(List<T> i) => ...
  Map<K, V> bar<K, V>(Map<K, V> m) => ...
}

It's not possible to express this pattern in dynamic dispatch (or with a single bar at all).

@matanlurey
Copy link
Contributor

By the way, this would have solved the Future.catchError issue:

class Future<T> {
  Future<T> catchError(Object error) {}
  Future<T> catchError(Object error, StackTrace trace) {}
}

... as a bonus :)

@eernstg
Copy link
Member

eernstg commented Jun 29, 2018

@matanlurey,

Is it being dynamically invokable a requirement? I don't think it is.

That was actually the point I was making: It is important that there is a well-defined semantics of method invocation, and if just one static overload is allowed to exist then every dynamic invocation will need to potentially handle static overloads, and that presumably amounts to multiple dispatch (like CLOS, Dylan, MultiJava, Cecil, Diesel, etc.etc.), and I'm not convinced that it is a good trade-off (in terms of the complexity of the language and its implementations) to add that to Dart.

In particular, the very notion of making the choice among several method implementations of a method based on the statically known type is a completely different mechanism than standard OO method dispatch, and there is no end to the number of students that I've seen over time who just couldn't keep those two apart. (And even for very smart people who would never have a problem with that, it's likely to take up some brain cells during ordinary daily work on Dart projects, and I'm again not convinced that it's impossible to find better things for those brain cells to work on ;-).

@matanlurey
Copy link
Contributor

@eernstg:

and if just one static overload is allowed to exist then every dynamic invocation will need to potentially handle static overloads

Why? If we just don't allow dynamic invocation to invoke static overloads, nothing is needed.

In particular, the very notion of making the choice among several method implementations of a method based on the statically known type is a completely different mechanism than standard OO method dispatch, and there is no end to the number of students that I've seen over time who just couldn't keep those two apart

I just want what is already implemented in Java/Kotlin, C#, or other modern languages. Do they do something we aren't able to do, or is this just about preserving dynamic invocation? As I mentioned, the alternative is users write something like this:

class Foo {
  void bar() {}
  void barString(String s) {}
  void barNumber(num n) {}
}

Not only do we punish users (they have to name and remember 3 names), dynamic invocation cannot help you here (mirrors could of, but that is no longer relevant).

@nex3
Copy link
Member Author

nex3 commented Jun 29, 2018

It's worth mentioning that if we decide to support overloads without dynamic invocations, this means that adding an overload to an existing method will be a breaking change--one that probably won't be obvious to API designers.

@matanlurey
Copy link
Contributor

Depending how we do it, we theoretically could support a dynamic fallback overload:

class Future<T> {
  // This one is used for any dynamic invocations only.
  Future<T> catchError(dynamic callback);
  Future<T> catchError(void Function(Object));
  Future<T> catchError(void Function(Object, StackTrace));
}

It's not clear to me this is particularly worth it, though. Other hotly requested features like extension methods would also suffer from being static only, and changing a method from invocation to extension would be a breaking change.

@nex3
Copy link
Member Author

nex3 commented Jun 29, 2018

I expect it won't be too surprising to users that changing an existing method is a breaking change. Adding a new method being a breaking change, on the other hand, is likely to be very surprising, especially since it's safe in other languages that support overloading.

@matanlurey
Copy link
Contributor

Right, because they never supported dynamic invocation (or only do, like TypeScript).

One project @srawlins was working on back in the day was a tool that could tell you if you accidentally (or on purpose) introduced breaking changes in a commit. I imagine a tool could help, or we could even add a lint "avoid_overloads" for packages that want to be dynamically-invoke-able.

@nex3
Copy link
Member Author

nex3 commented Jun 29, 2018

Users aren't going to know to run a tool to tell them that overloads are breaking changes any more than they're going to know that overloads are breaking changes. And even if they did, the fact that adding an overload requires incrementing a package's major version would make the feature much less useful for anyone with downstream users.

I don't think a lint would do anything, because upstream API authors don't control whether their downstream users dynamically invoke their APIs. In fact, since we don't have robust and universal support for --no-implicit-dynamic, the downstream users probably also don't know when they're dynamically inovking APIs.

@matanlurey
Copy link
Contributor

matanlurey commented Jun 29, 2018

OK, I think we can note that this feature would be breaking for dynamic invocation and leave it at that.

The language team hasn't given any indication this particular feature is on the short-list for any upcoming release, and I'm assuming when and if they start on it we can revisit the world of Dart (and what support we have for preventing dynamic invocation entirely).

I would like to hope this issue continues to be about implementing the feature, not whether or not it will be a breaking change (for all we know this will happen in Dart 38, and dynamic invocation has been disabled since Dart 9).

EDIT: For anyone reading this, I am not saying that will happen.

@munificent
Copy link
Member

I also think overloading would be a fantastically useful feature, but it's complexity is not to be under-estimated. If the language folks seem to cower in fear every time it comes up, that's not without reason. This tweet sums it up pretty well:

C# language design is

10% exploring cool ideas

75% overload resolution

15% being sad at past decisions we made

@yjbanov
Copy link

yjbanov commented Jul 4, 2018

This tweet sums it up pretty well

It's nice of the author to leave a hint though: "I would do it more like F#. It is there in a very basic, simple form" 😄

@matanlurey
Copy link
Contributor

@munificent Definitely understand it shouldn't be underestimated. Do we have a requirement that all new features support dynamic invocation? If so, didn't we already break that with type inference?

@eernstg
Copy link
Member

eernstg commented Jul 4, 2018

I'm with @munificent on the need to recognize the complexity ('C#: 75% is overload resolution' ;-), but I'm not worried about the complexity of specifying or even implementing such a feature, I'm worried about the complexity that every Dart developer is involuntarily subjected to when reading and writing code. In particular, I'm worried about the inhomogeneous semantics where some decisions are based on the properties of entities at run time, and other decisions are based on properties of entities produced during static analysis—one is fine, the other is fine, but both at the same time is costly in terms of lost opportunities for developers to think about more useful things.

One way we could make the two meet would be based on a dynamic mechanism that compilers are allowed to compile down to a static choice whenever that's guaranteed to be correct. For instance, using the example from @matanlurey as a starting point:

abstract class Future<T> {
  ...
  Future<T> catchError(Function)
  case (void Function(Object) onError)
  case (void Function(Object, StackTrace) onError)
  default (Function onError);
  ...
  // Could be a bit nicer with union types.
  Future<T> catchError2(void Function(Object) | void Function(Object, StackTrace))
  case (void Function(Object) onError)
  case (void Function(Object, StackTrace) onError);
}

class FutureImpl<T> implements Future<T> {
  Future<T> catchError
  case (void Function(Object) onError) {
    // Implementation for function accepting just one argument.
  }
  case (void Function(Object, StackTrace) onError) {
    // Implementation for function accepting two arguments.
  }
  default (Function onError) => throw "Something";
  ...
  // The variant with union types would just omit the default case.
}

There would be a single method catchError (such that the tear-off operation is well-defined and preserves the full semantics), and the semantics of the declared cases is simply like a chain of if-statements:

  Future<T> catchError(Function onError) {
    if (onError is void Function(Object)) {
      // Implementation for function accepting just one argument.
    } else if (onError void Function(Object, StackTrace) onError) {
      // Implementation for function accepting two arguments.
    } else {
      default (Function onError) => throw "Something";
    }
  }

However, the declared cases are also part of the interface in the sense that implementations of catchError must handle at least the exact same cases, such that it is possible to generate code at call sites where it is statically known that the argument list satisfies a specific case. In that situation we would have code that directly calls one of those cases (such that there is no run-time penalty corresponding to the execution of a chain of if-statements, we have already chosen the correct branch at compile-time).

For instance, we always know everything about the type of a function literal at the call site. Special types like int and String are constrained by the language such that we can't have one instance which is both at the same time, and with sealed classes we can have more cases with that property.

This means that we will have multiple dispatch in a way where the priority is explicitly chosen by the ordering of the cases (so we avoid the infinite source of complexity which is "ambiguous message send"), and the mechanism will double as a static overloading mechanism in the cases where we have enough information statically to make the choice.

I'm not saying that this would be ridiculously simple, but I am saying that I'd prefer working hard on an approach where we avoid the static/dynamic split. And by that split I don't mean code which is full of expressions of type dynamic, I mean code which may be strictly typed (--no-implicit-cast and whatnot), because that would still allow the same operations applied to the very same objects to behave differently, just because the type checker doesn't know the same amount of things at two different call sites.

... Java/Kotlin, C#, or other modern languages. Do they do something we
aren't able to do, or is this just about preserving dynamic invocation?

Neither (we can surely make a big mess of things as well ;-), but, to me, it is very much about avoiding a massive amount of subtleties for the developers, also for code which is statically typed to any level of strictness that we can express.

@matanlurey
Copy link
Contributor

matanlurey commented Jul 4, 2018

Most Dart developers don't want dynamic invocation (in Dart2, it is actively bad in many places with reified types), so it seems to me trying to preserve that feature for new language features isn't worth the time or effort.

@eernstg
Copy link
Member

eernstg commented Jul 4, 2018

@matanlurey, if that's concerned with this comment, it sounds like maybe you did not notice that I'm not talking about dynamic invocations, or certainly not only about them:

I don't mean code which is full of expressions of type dynamic, I mean .. strictly typed ..
[code that still causes a] massive amount of subtleties for the developers

@munificent
Copy link
Member

Do we have a requirement that all new features support dynamic invocation?

I think dynamic invocation is red herring. C#'s dynamic supports full C# overload resolution. The complexity isn't around dynamic invocation. It is more inherent to how overload resolution interacts with type inference, overriding, generics, generic methods, optional parameters, and implicit conversions.

(We don't have implicit conversions in Dart yet, but we will once you can pass 0 to a method that expects a double.)

I just slapped this together, but here's a sketch that might give you a flavor of how it can get weird:

class Base {
  bar(int i) {
    print("Base.bar");
  }
}

class Foo<T extends num> extends Base {
  bar(T arg) {
    print("Foo<$T>.bar");
  }
}

test<T extends num>() {
  Foo<T>(null);
}

main() {
  test<int>();
  test<double>();
}

@matanlurey
Copy link
Contributor

matanlurey commented Jul 6, 2018

I might be ignorant, but isn't there a similar set of complexity for extension methods? Meaning that if we need to eventually figure out how to dispatch extension methods, at least some of the same logic holds for dispatching overload methods?

(It looks like, casually, most OO languages that support extensions support overloading)

@munificent
Copy link
Member

Potentially, yes, but I think they tend to be simpler. With extension methods, you still only have a single "parameter" you need to dispatch on. You don't have to worry about challenges around tear-offs. Things might get strange if we support generic extension classes. I don't know. But I would be surprised if extension methods weren't easier than overloading.

@matanlurey
Copy link
Contributor

Thanks for this! A few more questions, but don't feel like they are important to answer immediately :)

Potentially, yes, but I think they tend to be simpler.

Are there some limitations we could add to overloads to make them easier to implement and grok? I might be incredibly naive ( /cc @srawlins ) but I imagine 95%+ of the benefit could be gained with a few simplifications:

  • Either no tear-off support, or force users to write void Function(String) bar = foo.bar
  • Don't support overloading on bottom or top types
  • Don't support overloading on generic types

For example, today I was writing a sample program for a r/dailyprogramming question.

I wanted to be able to write:

abstract class DiceRoll {
  int get amount;
  int get sides;
}

abstract class DiceRoller {
  /// Roll a dice defined by the expression "NdN".
  List<int> roll(String expression);

  /// Roll [amount] of dice with [sides].
  List<int> roll(int amount, int sides);

  /// ...
  List<int> roll(DiceRoll roll);
}

But I'd either have to write:

abstract class DiceRoller {
  /// Roll a dice defined by the expression "NdN".
  List<int> rollParse(String expression);

  /// Roll [amount] of dice with [sides].
  List<int> roll(int amount, int sides);

  /// ...
  List<int> rollFor(DiceRoll roll);
}

Or do something extra silly like:

abstract class DiceRoller {
  List<int> roll(dynamic expressionOrAmountOrRoll, [int sides]) {
    if (expressionOrAmountOrRoll is int) {
      if (sides == null) {
        throw 'Expected "sides"';
      }
      return _rollActual(expressionOrAmountOrRoll, sides);
    }
    if (sides != null) {
      throw 'Invalid combination';l
    }
    if (expressionOrAmountOrRoll is String) {
      return _rollAndParse(expressionOrAmountOrRoll);
    }
    if (expressionOrAmountOrRoll is DiceRoll) {
      return _rollActual(expressionOrAmountOrRoll.amount, expressionOrAmountOrRoll.sides);
    }
    throw 'Invalid type: $expressionOrAmountOrRoll';
  }
}

The former is hard for the users to use (and find APIs for) and the latter sucks to write, test, and basically forgoes any sort of static type checking.

You don't have to worry about challenges around tear-offs.

Does that mean tear-offs wouldn't be supported for extension methods, or that it's easier?

I imagine folks would find it weird if you could do:

void main() {
  // This will, or will not work, depending on if `map` is an extension method or not?
  wantsAClosure(['hello', 'world'].map);
}

void wantsAClosure(Iterable<String> Function(String) callback) {}

Things might get strange if we support generic extension classes

Do you mean (psuedo-syntax):

/// ['hello, 'world'].joinCustom()
extension String joinCustom(this Iterable<String> parts) {
  // ...
}

Or:

extension Map<K, V> groupBy<K, V>(this Iterable<V>, K Function(V) groupBy) {
  // ...
}

@munificent
Copy link
Member

Either no tear-off support, or force users to write void Function(String) bar = foo.bar

I believe the latter is what C# does. It helps, though it causes some weird confusing behavior. It's always strange for users when you can't take a subexpression and hoist it out to a local variable.

Don't support overloading on bottom or top types

I don't think that's the cause of much of the pain.

Don't support overloading on generic types

That might help, but it's probably too painful of a limitation in practice. One of the key uses of overloading is being able to extend the core libraries without breaking them, and many of the classes where that would be most helpful, like Iterable and Future, are generic.

abstract class DiceRoller {
  /// Roll a dice defined by the expression "NdN".
  List<int> roll(String expression);

  /// Roll [amount] of dice with [sides].
  List<int> roll(int amount, int sides);

  /// ...
  List<int> roll(DiceRoll roll);
}

Yeah, I've run into this exact scenario.

Just allowing overloading by arity (number of parameters) would help many of these simple cases and doesn't require a lot of static typing shenanigans. Dynamically-typed Erlang supports it. Though it would interact in complex ways with optional parameters in Dart.

You don't have to worry about challenges around tear-offs.

Does that mean tear-offs wouldn't be supported for extension methods, or that it's easier?

That it's easier. Once you've don't the extension method lookup statically, you know exactly what method is being torn off, so you can just do it.

With overloading, there are interesting questions around whether the lookup should be done statically, dynamically, or some combination of both.

Things might get strange if we support generic extension classes

Do you mean (psuedo-syntax):

/// ['hello, 'world'].joinCustom()
extension String joinCustom(this Iterable<String> parts) {
  // ...
}

I mean:

extension class Iterable<int> {
   int sum() => fold(0, (sum, element) => sum + element);
}

test<T>(Iterable<T> elements) {
  elements.sum(); // <--???
}

I'm sure @leafpetersen and @lrhn can figure out how to handle all of this, but I'm not sure if I could. :)

@matanlurey
Copy link
Contributor

Thanks! I am sure I will understand this issue eventually :)

One of the key uses of overloading is being able to extend the core libraries without breaking them, and many of the classes where that would be most helpful, like Iterable and Future, are generic.

Did you mean one of the key uses of extension methods, or overloading?

@lrhn
Copy link
Member

lrhn commented Jul 9, 2018

Overloading and extension methods are orthogonal. Both allow "adding a method" to a class without breaking an existing method with the same name. If you have both, there is a good chance that the extension method won't completely shadow the original method. Extension methods are not virtual, which is annoying. You can add them from the side, which is useful. We don't have a way to add a virtual method from the side, and I'm not sure it's possible.

The languages with overloading mentioned so far do not have optional parameters the same way Dart does. They do have optional positional parameters, so that might not be an issue.
We still have to handle cases like:

  int foo(int x, [int y, int z]) => ...
  int foo(int x, {int y, int z}) => ...
  ...
     theFoo.foo(42);

Likely it's just an unresolved overload error at compile-time. Again, a dynamic invocation might not apply here, but if it does, then it's not clear that there is a solution.
Maybe we can solve it by (theFoo.foo as int Function(int, int, int))(42). I'd like as to actually induce a preference on the expression.

As for

extension class Iterable<int> {
   int sum() => fold(0, (sum, element) => sum + element);
}
test<T>(Iterable<T> elements) {
  elements.sum(); // <--???
}

my way of figuring that one out would just be to say "extension method does not apply". The static type of elements is Iterable<T>, which is not the same as, or a subtype of, Iterable<int>, so the static extension method cannot be used. Since elements does not have a sum method, your program won't compile.
Now, if it had been:

test<T extends int>(Iterable<T> elements) {
  elements.sum(); // <--???
}

then the extension method would likely have applied.

More controversial is:

extension List<T> {
  R join<R>(R base, R Function(R, T) combine) => ...;
}

Should that function "override" the join function on List? Shadow join completely, or only act as an alternative overload? What if I named it fold instead? That kind of conflict is troublesome, but probably not a real issue (it'll just allow people to shoot themselves in the foot, and linting can tell you to stop being silly).

Anyway, this is about overloading, not extension methods.

@jodinathan
Copy link

jodinathan commented Jul 9, 2018 via email

@matanlurey
Copy link
Contributor

I also tend to agree that trying to combine overloads and optional parameters (either named or positional) is probably not worth its weight. A lot of the places that optional parameters are used today are to emulate overloads, and users would likely use overloads instead if available.

@nex3
Copy link
Member Author

nex3 commented Jul 9, 2018

Not allowing optional named parameters with overloads will make it difficult to backwards-compatibly extend APIs that were originally defined as overloads. This would incentivize API designers to add lots of overloads with additional positional parameters, which is generally less readable and more fragile than named parameters.

My preference for APIs like

  int foo(int x, [int y, int z]) => ...
  int foo(int x, {int y, int z}) => ...

would be to disallow the definition at compile time. This ensures that overload calls are never ambiguous, and that API designers are aware when they try to design an API that would be ambiguous and can avoid it (e.g. by making int y mandatory in the first definition above).

@Levi-Lesches
Copy link

@lukepighetti, types are already considered part of the signature. It's how Dart lets you know when you make an invalid override. But throwing overloading into the mix can make things complicated. Consider the following classes:

/// A pair of 2D coordinates that can be decimals or integers. 
class Coordinates {
  final num x, y;
  const Coordinates(this.x, this.y);
  
  // Adds two coordinates together.
  operator +(covariant Coordinates other) => 
    Coordinates(x + other.x, y + other.y);
}

/// A pair of 2D coordinates that can only be integers. 
class IntegerCoordinates extends Coordinates {
  @override
  final int x, y;
  const IntegerCoordinates(this.x, this.y) : super(x, y);
  
  /// Only adds IntegerCoordinates together. 
  ///
  /// This ensures the resulting coordinates are also integers. 
  @override
  operator +(IntegerCoordinates other) => 
    IntegerCoordinates(x + other.x, y + other.y);
}

This is a common pattern where you subclass a type to make it more restricted. In this case, IntegerCoordinates can only contain integers and can only be added to other integers to keep the condition met. In today's Dart, that gives you this:

void main() {
  final a = Coordinates(0.5, 0.5), b = Coordinates(1.5, 1.5);
  final c = IntegerCoordinates(1, 1), d = IntegerCoordinates(2, 2);
  
  // [Coordinates] can be added to any other subtype of [Coordinates].
  print(a + b);  // Coordinates(2.0, 2.0)
  print(a + c);  // Coordinates(1.5, 1.5)
  
  // [IntegerCoordinates] can only be added to themselves. 
  print(c + d);  // IntegerCoordinates(3, 3)
  print(c + a);  // Error: Coordinates cannot be assigned to IntegerCoordinates
}

With overloading, does this still hold? Or is IntegerCoordinates.+ no longer an override of Coordinates.+ but rather a whole new method, so that c + a is now valid?


Dart isn't Java, but since Java has overloading and overriding, I thought I'd bring a Java example to compare. Due to Java using a different type of number system than Dart, I repurposed the example into one that makes less logical sense but still shows the problem:

class Car {
  void crash(Car other) { System.err.println("Two cars crashed!"); }
} 

class Boat extends Car {
  // Turns out this is an *overload*, not an override, of Car.crash. 
  void crash(Boat other) { System.err.println("Two boats crashed!"); }
}

public class Temp {
  public static void main(String[] args) {
    final Car car1 = new Car(), car2 = new Car();
    final Boat boat1 = new Boat(), boat2 = new Boat();

    car1.crash(car2);  // Car.crash
    car1.crash(boat2);  // Car.crash

    boat1.crash(boat2);  // Boat.crash
    boat1.crash(car1);  // This shouldn't work, but it calls Car.crash 
  }
}

@lukepighetti
Copy link

I'm not sure what the current rule is, but it sounds like types/parameters are part of the signature, but you cannot have name collisions. What would happen if we removed the unique name requirement and did nothing else?

@Jetz72
Copy link

Jetz72 commented Jun 14, 2022

What would happen if we removed the unique name requirement and did nothing else?

For one, without specifying any rules to decide how to prioritize or disambiguate them, determining what gets called becomes unclear:

void foo(int x, num y) => print("foo type 1!");

void foo(num x, int y) => print("foo type 2!");

void main() {
  foo(1, 2.0); //All is well, calls type 1.
  foo(3, 4); //Which one do we call here?
}

Tear-offs are a bigger issue:

var x = foo; //Which one? They both could fit into an int-int function type but what exact type is x?
if(x is void Function(int, num)) print("It's type 1!");
else if(x is void Function(num, int)) print("It's type 2!");
else if(x is void Function(int, int))
  print("It's not a reference to the function itself, it's some placeholder that kicks the discrepancy down the road "
  "to the invocation site and has an inexact type until then!");
else print("It's still a placeholder, but either we aren't computing the bound of foo's overloads or someone added "
  "another overload for foo which subtly changed the behavior here simply through its existence!");

@munificent
Copy link
Member

munificent commented Jun 28, 2022

Overloads are a useful, powerful feature. But they are also an enormous can of worms that add a ton of complexity to the language in ways that can be confusing and painful for users. Some examples off the top of my head:

Tear-offs

Presumably this is fine:

class C {
  bool method(int i) => true;
}

main() {
  var tearOff = C().method;
  var x = tearOff(3);
}

Now say you add an overload:

class C {
  bool method(int i) => true;
  String method(String s) => s;
}

main() {
  var tearOff = C().method; // ?
  var x = tearOff(3); // ?
}

What is the type of that tearOff now? Is the call to it valid? If so, what is the type of x?

We could say that it's an error to tear off a method that has overloads. But one of the primary goals with overloading is that it lets you add new methods to a class without breaking existing code. If adding an overload causes untyped tear-offs to become an error, then adding any overload anywhere is a potentially breaking change.

We could say that it's always an error to tear-off a method in a context where there is no expected type. That would be a significant breaking change to the language. That also means that changing an API to loosen an expected type (for example changing a callback parameter from a specific function type to Function) is now a breaking change.

Overriding and parameter types

Consider:

class A {
  void foo(int i) {}

  void bar(String s) {}
}

class B {
  void foo(num i) {}

  void bar(bool b) {}
}

Is B.foo() an override? Presumably yes. Is B.bar()? Probably not?

So what if you later change B to:

class B {
  void foo(num i) {}

  void bar(Object o) {}
}

Now that presumably will become an override of A.bar(). So the parameter types of a method may affect whether or not it's overriding a base class method.

Optional parameters

Consider:

class C {
  void method(int x) {}
  void method([int x, int y]) {}
}

main() {
  C().method(1);
}

Is that a valid set of overloads? If so, which one is called when you pass a single argument?

Promotion

Consider:

class C {
  void method(num n) { print('num'); }
  void method(int i) { print('int'); }
}

test(num n) {
  if (n is int) C().method(n);
}

I'm guessing this prints int. Are there other interactions between overload selection and type promotion that are less intuitive? Do users need a way to opt out of having promotion affect overload resolution?

Inference

Consider:

class C {
  void method(List<int> ints) {}
  void method(List<Object> objects) {}
}

main() {
  C().method([]);
}

What does this do?

Generics

Consider:

class C<T> {
  method(num n) {}
  method(T t) {}
}

Is that a valid pair of overrides? What if T is instantiated with num? int? Object?

What about:

class C<T extends num> {
  method(String s) {}
  method(T t) {}
}

Are those now valid overloads?

What about:

class A<T extends num> {
  method(T t) {}  
}

class B<T> extends A<T> {
  method(num n) {}
}

Is B.method() considered an overload of A.method() or an override?

I'm sure it gets even weirder when generic functions come into play.

Covariant

Here's a fun one:

class A {
  void method(num n) {}
}

class B extends A {
  void method(covariant int i) {}
  void method(Object? o) {}
}

Is the second method() in B a distinct overload, or does it collide with the other B.method()?

The point is not that all of these issues are unsolvable, but it's that they must be solved in order to add overloading to the language and users will have to understand at least a fraction of them whenever they interact with the feature.

@jodinathan
Copy link

jodinathan commented Aug 29, 2022

@munificent two things:

  1. Dart already has overloading in the try catch clause. If overloading is not something necessary, don't you agree that all catch should be something like catch(Exception e, [StackTrace st])?

  2. Is there any plan to not have these many somethingOrNull like firstWhereOrNull without losing performance? I mean that with overloading the compiler do a check and call the correct method by the type signature so we don't have to do it at runtime.
    Also a simple rule that we have here is not to have methods with And or Or in the name. It usually means bad architecture and the whole context should be reanalyzed.

@lrhn
Copy link
Member

lrhn commented Aug 29, 2022

Catch clauses are not methods. Yes, it would be nice to have a way to pass either onError: (e) { ... } or onError: (e, s) { ... } as async error handlers in a typed way, and overloading on parameter types could do that. So could union types. Or just always requiring (e, s) ...., which I'd do in a heartbeat if I could migrate the world easily.

For something (throwing) vs somethingOrNull, I'd probably have made more of them OrNull-versions originally if I knew then where Dart would end up, with null safety and null aware operators.
We didn't, and we can't add new methods to Iterable today, with or without overloading (but maybe with interface default methods).

Even then, the difference between something and somethingOrNull is not in the parameters, it's in the return type.
You can overload on that too, theoretically. I thought one of C# and Java actually did at some point (but maybe it's just the JVM which uses the return type as part of the signature, not the compiler function resolution). Most do not, and Dart likely also woudn't.

@jodinathan
Copy link

which I'd do in a heartbeat if I could migrate the world easily

The point is not if we can change that or not, but the reasoning behind not having an optional parameter in the first place.

Assuming that not requiring the StackTrace in the try catch clause can be seen as an optimization, since we are telling the compiler we don't need that info, makes total sense to it be overloadable as it currently is.

The point is simplicity. You don't want to see the StackTrace argument everywhere as you don't want to have firstWhereOrNull kind of method.

Be it parameter or return type, the point is overloading in general. However, if we could have at least the return type overloadable I would be glad, honestly.

@Levi-Lesches
Copy link

1. Dart already has overloading in the try catch clause. If overloading is not something necessary, don't you agree that all catch should be something like catch(Exception e, [StackTrace st])?

@munificent brought up valid questions and complications that arise with methods, so comparing it to try/catch isn't a valid response because it doesn't answer those questions.

2. Is there any plan to not have these many somethingOrNull like firstWhereOrNull without losing performance?

I agree with this but the ideal answer, IMO, would be to make firstWhere nullable in the first place to cover all cases, not overloading.

The point is simplicity. You don't want to see the StackTrace argument everywhere as you don't want to have firstWhereOrNull kind of method.

Simplicity is good, but the sheer amount of syntactic and semantic questions that arise when using overloads negates this. If you have to ask yourself which method you're using, I don't see how that's simpler than firstWhere/firstWhereOrNull. Sure, the latter is more verbose, but you never have to ask yourself what arguments/return values are valid because it's in the name. Doesn't get much simpler than that. Overloads, however, mean you always have to check the source code to see which types you can use because there may be more than one implementation with the same name.

For example, imagine parsing a DateTime. Right now there are several options: .fromMicrosecondsSinceEpoch, .fromMillisecondsSinceEpoch, .utc, .parse, and .tryParse. Sure these names are quite verbose, but imagine trying to figure out what value to pass if there were just one DateTime.parse constructor.

@jodinathan
Copy link

jodinathan commented Aug 30, 2022

For example, imagine parsing a DateTime. Right now there are several options: .fromMicrosecondsSinceEpoch, .fromMillisecondsSinceEpoch, .utc, .parse, and .tryParse. Sure these names are quite verbose, but imagine trying to figure out what value to pass if there were just one DateTime.parse constructor.

The only one of that list that I see using overload is parse.

The reason why we have firstWhereOrNull instead of tryFirstWhere is because when you type firstWhere you see the firstWhereOrNull in the list. It would be harder to find the nullable version if it was tryFirstWhere because of autocomplete.

I understand the point regarding the aesthetic preference but think with me a bit and take the tryParse above as example.
Coding some project that uses other libs we know that there is a something method, now we need its nonnull version. What is easier?

a) a method that is overloaded to nullable and nonullable versions that you can read the docs and the implementation in the same place
b) some random name that is up to the developer like trySomething, somethingOrNull or nonNullSomething

@TheKashe
Copy link

TheKashe commented Nov 2, 2022

Tear-Offs

How about

typedef bool Foo(int i);

class C {
  bool method(int i) => true;
  String method(String s) => s;
}

main() {
  Foo tearOff = C().method; //will resolve to: bool method(int i)
  var x = tearOff(3); // true
}

//or perhaps
main() {
  var tearOff = C().method<Foo>; //will resolve to: bool method(int i)
  var x = tearOff(3); // true
}

//or without the typedef
main() {
  var tearOff = C().method<int>; //will resolve to bool method(int i)
  var x = tearOff(3); // true
}

@lrhn
Copy link
Member

lrhn commented Nov 2, 2022

Using the context type to disambiguate the tear-off operation has been suggested.

It differs from the other places where we currently use the context type to do something. In those, the context type can cause something to happen (call tear-off, generic instantiation, downcast from dynamic, etc.), otherwise there is syntax to make the same thing happen explicitly instead, if you have no context type, or don't want to rely on it.
For overloaded method tear-off defined like this, there is no similar thing. There is no explicit tear-off syntax.
(You suggest C().method<Foo> here, but that's already valid syntax for explicitly instantiating a generic method tear-off.)

If we make as introduce a context type (which we should!), then C().method as Foo would be an explicit way to choose the context type, and therefore the method to tear off.

(There are other issues with method overloading in Dart than tear-off resolution. For example, Dart's optional parameters is already a kind of overloading, which allows a single method to satisfy two different member signatures. When you combine those with actual overloading, and subclassing, it's non-trivial what it even means to override one or more superclass members.)

@mattrberry
Copy link

On the topic of using a context type to disambiguate, there's also Crystal which requires you to specify the types of the parameters in order to tear off a method

str = "hello"
proc = ->str.count(Char)
proc.call('e') # => 1
proc.call('l') # => 2

https://crystal-lang.org/reference/1.6/syntax_and_semantics/literals/proc.html#from-methods

@dcharkes
Copy link
Contributor

Dart's optional parameters is already a kind of overloading

But in practise we advice making more members instead of using this kind of overloading, especially if we can't statically enforce the right arguments

void foo({required A a, B? b, C? c}) {
  if(b != null && c != null) throw ArgumentError('Provide at most one of b and c.');
  // ...
}

We usually rewrite that to two methods (which have overlapping behavior if neither b nor c are provided).

Now, if we could write some kind of more fancy constraints of permutations of optional parameters that are correct that would work with static checking ... 🎉

@Jetz72
Copy link

Jetz72 commented Apr 10, 2023

An approach occurred to me that might at least handle the overload/override ambiguities, tear offs, and mitigate the possibility of unintentional breaking changes - named overloads:

class A {
   //We name each overload at the declaration site.
   String foo.withInt(int x) => "int (A)";
   String foo.withString(String x) => "String (A)";
   
   String foo() => "Nothing"; //Not allowed, name collision on "foo" and can't be an overload of it without a name.
}

class A1 extends A {
   //To override one, you must use the same name.
   @override
   String foo.withString(String x) => "String (A1)";
   //To make a new overload in a subclass, just use a new name.
   String foo.withNum(num x) => "num (A1)";
}

void main() {
   var a = A1();
   //The name is not required at the call site.
   print(a.foo(1)); //int
   print(a.foo("hello")); //String (A1)
   print(a.foo(1.0)); //num (A1)
   //A specific overload can be referenced by name, which is used to perform tear-offs safely, or force a particular overload.
   var bar = a.foo.withInt;
   print(bar(1)); //int
   print(a.foo.withNum(2)); //num (A1)
}

Requiring each overload to have a unique label helps with declaration ambiguities while letting the variants share a singular name at the call site. It would also prevent people from accidentally overloading something they wanted to override, or vice versa. I do realize the silliness in solving a chunk of the problems with overloading by giving the overloads distinct names - i.e. missing the whole point of overloading. But in practice it seems like only a slight sacrifice in convenience assuming you write a method once then use it many times going forward.

This doesn't address all the problems, of course. Dispatch in particular would still have some pretty glaring questions, and I'm not really convinced that this feature would be worth the trouble in the first place. But I wanted to jot down this line of thought and see if it goes anywhere when fleshed out.

Going down the above (not comprehensive) list of things that would need to be solved, it at least provides solutions for these dilemmas:

Tear-offs

Since tear offs have to be manually disambiguated, adding a new overload with its own label won't affect existing tear offs. It would be a breaking change to make a non-overloaded method into an overloaded one, but the change in declaration syntax should make that obvious enough.

You could maybe add a way to blindly tear off an overloaded function and then resolve it at the call site - either storing it dynamically and letting the user assert that it'll work at runtime, or keeping it in a special function type that remembers the original overload family as part of its static type - but that seems tricky either way.

Overriding and parameter types

class A {
  void foo.a(int i) {}
  void bar.a(String s) {}
}

class B {
  void foo.a(num i) {} //Valid override
  void bar.a(bool b) {} //Invalid override, since String isn't a subtype of bool.
  void bar.b(bool b) {} //Valid new overload, not an override.
  void bar.a(Object b) {} //Valid override
}

Covariant

class A {
  void method.a(num n) {}
}

class B extends A {
  void method.a(covariant int i) {} //Valid override
  void method.b(Object? o) {} //Valid new overload
}

That doesn't answer the question of which method a double would dispatch to, especially if the static type is A but the runtime type is B. Similar complications remain with generic parameters and dynamic invocation, and I've barely given any thought to how return types work with all this, so I'm inclined to just leave it at that for now.

@lrhn
Copy link
Member

lrhn commented Apr 11, 2023

The extra name sounds like multiple different-named methods which allow being called through a (shared) prefix of the name, using some sort of static resolution.
It has to be static resolution because the resolution affects type analysis when the return types can differ, and runtime then has to match the statically predicted behavior to ensure soundness.

There is no real overloading, there are just longer names and static dispatch to one of those longer names when you only specify a prefix of the longer name.

You can add more members with the same prefix in a subclass. Static resolution won't see them when calling on the superclass. And you can always call a specific method directly.

Overloading an existing member gets weird. If you have a foo method already, adding a second foo.second method gets hard to specify.
We could say that foo is short for foo.<empty>, and it always exists in parallel with the other members with the same prefix, but then you either lose the ability to call that member explicitly, or to call foo.second as foo, because o.foo() can only mean one of those. We could introduce a special token, o.foo.#empty() to signify the empty name, like we have .new on constructors. Maybe just o.foo._(). (Or would that be a library-private sub-name? It probably should be, so you can have foo.public()=>...; foo._private()=>... and only expose one publicly.)

In any case, it's like o.foo is a callable object (like an object with a call method), but it has multiple ways to be called, one for each foo suffix member declared.

The thing you get for free is that invoking that object as a function will statically decide which of the members is called.

Tear-offs can be decided the same way as invocations, using a context type's parameter types to choose instead of actual argument types. If there is no useful context type, it's an error.

This has all the usual problems with dynamic. You just have to use the full name there.

It also suffers from the same kind of name conflicts as Dart does today. If you add int foo.x(int i) => i; in a subclass, and the superclass later adds String foo.x(String x) => x;, you have a naming conflict.
Full type-based overloading would consider those different members because they have different types.

@Jetz72
Copy link

Jetz72 commented Apr 11, 2023

Overloading an existing member gets weird. If you have a foo method already, adding a second foo.second method gets hard to specify.

The way I was imagining it, it'd be an error to have an overload syntax foo.second alongside a plain foo; converting a normal function into an overloaded one would be a breaking change from adding the label, but adding further overloads beyond that would be fine (barring that usual name collision with pre-existing subclasses thing).

It has to be static resolution because the resolution affects type analysis when the return types can differ, and runtime then has to match the statically predicted behavior to ensure soundness.

Ah, I figured once return types were considered the whole thing would snag pretty fast. I was sorta leaning towards dynamic invocation where you resolve all the runtime types before picking a method. I had a hard time imagining how you'd be able to statically handle the covariant situation, generics, and dynamic parameters. But I guess that would just propagate the problem outwards. Even if you computed a least upper bound for all the possible return types (and good luck when subclasses can add new ones), it'd still make it a strongly breaking change to introduce new overloads, which was something I was hoping to avoid. Losing static analysis for the whole feature would pretty strongly tip the "is it worth the trouble" scales towards the "no" side.

@jodinathan
Copy link

While I was creating Typings I had to deal with transpilling overloaded methods from TS to Dart. As JS interop uses extension methods a lot, I thought of a solution for tearoff and also bad overloading usage:

Allow overloading methods only in extension methods.

So a class:

class Foo {
  void bar();
}

Could have bar overloaded:

extension OverFoo on Foo {
  void bar(String daz); // ok
}

Then you can easily catch the bar version you need:

final foo = Foo();
final v1 = foo.bar; // void bar() {}
final v2 = (foo as OverFoo).bar; // void bar(String);

With this approach you can statically know the correct callback while also putting a minor restriction to its usage without really restricting it.

It would also solve one the JS interop questions

@lrhn
Copy link
Member

lrhn commented Apr 12, 2023

Allowing overloading only using extension methods was actually something I intentionally tried to prevent with the extension methods design. It was a goal (not completely reached, but still an aspiration) to not make extension methods better than instance methods.
That would cause users to design their APIs around extension methods instead of instance methods, with the short-comings that also has (no virtual dispatch being one).
Extension methods are available as an alternative to instance methods for some uses-cases, but not as a patch for reasonable functionality that instance methods could have too.

If we want to allow overloading, we want to allow overloading on both instance methods and extension methods, so the choice between instance and extension method is made on the basis of which best fits the use case, not which of them happens to allow overloading.

Anyway, if we were to allow overloading on extension members, then we'd still infer the type of the receiver, then check which extensions apply to that, and whether they have one (or now more) members of that name.

Then, most likely, instead of just picking the best on type from the applicable extensions, we'd also try to choose the best method based on the arguments (or even return type).
That's where it gets tricky, because we can't properly do type analysis on the argument expressions without knowing the context type provided by parameter types of the function being called, but we don't yet know which function we are calling.
So the best we can do to disambiguate is to eliminate all extension methods which definitely cannot be called with the provided number of positional arguments and names of named arguments. That's not a particularly good overloading resolution approach, it can distinguish between String pathFrom(String) and String pathFrom(Uri), which is like table-stakes for having overloading.

But nothing about that is special to extension methods, it applies equally well (and badly) to instance methods,
and that's one of the reasons it's so darn hard to add overloading to Dart. The type inference algorithm assumes that the method being called is known before type analyzing the argument expressions, and therefore the types of arguments cannot be used to choose which function to call.

Maybe that choice in type inference was a bad idea, but it's not going to be easy to change.

In short: Nothing here is unique to extension methods. Anything that can be used to resolve overloading involving extension methods can also be used to resolve overloading involving instance methods as well. And it has all the same problems which make that particularly hard for Dart.

(Also, (foo as OverFoo).bar is already possible as OverFoo(foo).bar.)

@jodinathan
Copy link

I thought the motives for not having overloading would be

  • By design: If spammed can make ugly/hard to read code (C# comes to mind)
  • Technical: Troublesome to tearOff

That is why I thought overloading through extensions would be a help there.

The type inference algorithm assumes that the method being called is known before type analyzing the argument expressions, and therefore the types of arguments cannot be used to choose which function to call.

This would not be an easy change, however, doesn't the new inference system already crawl the method beforehand?

Consider:

enum SomeEnum<T> {
  a<int>();

  const SomeEnum();
}

K foo<K>(SomeEnum<K> bar);

Then when you use foo you get the correct infered type:

final k = foo(SomeEnum.a); // k is an int

@lrhn
Copy link
Member

lrhn commented Apr 12, 2023

Type inference of final k = foo(SomeEnum.a); works by:

  • Infer the type of foo(SomeEnum.a) with no context type (because final k doesn't have any type expectations).
  • Type analyze the receiver foo. It resolves to something with type K Function<K>(SomeEnum<K>).
  • Try to infer K from the context type. Since there is no context type, that fails.
  • Type analyze SomeEnum.a with context type SomeEnum<K>.
  • This resolves SomeEnum.a to its declaration which has static type SomeEnum<int>.
  • If there had been further arguments, we'd have looked at those too (actually, now, incrementally in some order that improves our chance of solving for multiple variables).
  • Then try to solve for K again. Succes K = int found.
  • Then go back to to the invocation foo(SomeEnum.a) and check that SomeEnum<int> is assignable to the parameter type SomeEnum<K> with K = int, which it is. So make the type parameter in the call be int, effectively inferring that the expression is really foo<int>(SomeEnum.a);.
  • Then the static type of foo<int>(SomeEnum.a) is found to be int.
  • Type inference infers the declared type of k to be int.

So, type analysis finds the static type of expressions. Sometimes those types include yet-unbound type variables.
At suitable points in the analysis, type inference tries to infer type arguments for those type variables. Then it continues.

That's all well and fine, but it relied on having the full function type of foo in step 2.

Now imagine there being multiple possible functions that foo can refer to:

int foo(SomeEnum<int> a) => 9;
T foo<T>(SomeEnum<T> a) => a.value;
String foo(Object? a) => a.toStrung();

For each one of these, type analysis and inference can proceed, and they're all valid. But the return type differs.

Then make it even harder, by not having an argument type which doesn't require any inference.

Final<int> foo(Future<int> f) => f;
Final<double> foo(Future<double> f) => f;
/// and
final k = foo(Future.value(0));

Now it gets ambiguous.
The type inference and static type of Future.value(0) depends on the context type. The static type of 0 depends on the context type.

With only Final<int> foo(Future<int> f) => f; in scope, foo(Future.value(0)) would give Future.value(0) a context type of Future<int>, which will give 0 a context type of int, and type inference will turn it into:

final Future<int> k = foo(Future<int>.value(0));

With only Final<double> foo(Future<double> f) => f; in scope, type inference would make this effectively:

final Future<double> k = foo(Future<double>.value(0.0));

(Notice the meaning of the decimal literal depends on context type too!)

With both in scope, which one should be chosen? Either works, neither is more or less specific than the other,
and looking at the argument expressions's static type is impossible, because the argument type depends on the function type that it's an argument of.

@jodinathan
Copy link

jodinathan commented Apr 12, 2023

Final<int> foo(Future<int> f) => f;
Final<double> foo(Future<double> f) => f;

We could be restrict those with an analyzer error This overload leads to ambiguous methods signatures

@lrhn
Copy link
Member

lrhn commented Apr 12, 2023

We could be restrict those with an analyzer error

We could, but what would the criteria be for deciding whether two declarations introduce ambiguity or not?

I'm pretty sure I can always create an argument expression which depends on the context type, and do so in a way that makes it necessary to know the parameter type before doing type analysis on the argument expression.

The way inference works, just a

T hack<T>(List<T> list) => list.first;
// ...
  anyFunction(hack([null]));

will use the context type C to infer hack<C>(<C>[null]) which is a compile-time error if C is non-nullable,
but valid if C is nullable.
If the compiler doesn't know the context type, it can't decide whether that can be compiled.
And it can't decide what the static type of hack([null]) is before it knows the context type, so it can't use that type to decide which function to call.

So, the only safe criterion for not causing a potential conflict is that the functions have completely incompatible argument shapes. And then we're back to not having type-based overloading, only shape-based.
Which is a valid choice, but probably just not what people really want.

@jodinathan
Copy link

And then we're back to not having type-based overloading, only shape-based.
Which is a valid choice, but probably just not what people really want.

Would shape-based be number of arguments?
If yes then it is really not what the users want.

Quick thinking on how to try to fix the ambiguity problem...

I would take two approaches, first would be at definition by flatting all types and check if they collide:

void foo<T>(T bar);
void daz<K>(K yum); 

// flatted: 
void foo(dynamic bar);
void daz(dynamic yum);  // this throws an ambiguity error

Then at usage level we must make sure that there is only one definition from types analysis.
Definition:

Final<int> foo(Future<int> f) => f;
Final<double> foo(Future<double> f) => f;

Usage:

final k = foo(Future.value(0)); //  as the analyzer found two possible methods, 
// it throws an ambiguity error

// the user could possibly fix this by making sure the analyzer know all types: 
final k = foo(Future.value(0) as Future<int>);

@lrhn
Copy link
Member

lrhn commented Apr 12, 2023

You get the same issue with just

void foo(int x) {}
void foo(double? x) {}

Those are completely disjoint parameter types, no generics, no shenanigans.

Again, then I just do:

T hack<T>(List<T> l) => l.first;

foo(hack([null]));

and you still can't tell me whether that program is correct or not, or which foo to choose.

To choose the function based on the argument type, you need to know the static type of the argument expression.
To find that you need the context type (unless we stop providing context types for arguments, which will be a major blow to type inference) in order to analyze the expression in the context.
To get a context type you need to have already chosen a foo function.

So you cannot choose the the function to call based on the argument type.

We could choose to not provide the argument expression with a context type when there are multiple possible functions to call. That's effectively what you suggest when you say that you have to make sure the analyzer knows all types - disable type inference. It's not even as bad as that, just not giving a context type still allows all the other type inferences to apply.

We could select the possible functions ones which have a viable parameter list shape, and find the least upper bound of the parameter types of all those functions, and use that as context type. (Remembering that least-upper-bound is a fickle and confusing operation that can easily end up giving you effectively no context type anyway, especially for overloading where the argument types are more likely to be unrelated than not. So, probably not better than just having no context type.)

But if we do that, adding an overloading would worsen type inference for everybody. You'd be better off just giving the function another name. (And then we're back to square one, which is probably why we've been here for a while.)

@esoros
Copy link

esoros commented Oct 16, 2024

I'm pretty sure that languages that support var x = foo and anonymous methods with overloads are going to push the compiler to algorithms that are in NP-Complete and then would just be too slow. Kotlin, Swift, Modern C#, Modern Java aren't known for quick builds. In the context of business with large orgcharts, managers will start assigning costs to builds I'd argue it's better if those are faster than slower. As stated above, in the context of the call site, how would the compiler be able to quickly determine what methods to call, normally there's an entire express tree, not just one method? If people are going to basically not have any kind of type inference, the developers type in all of the types, including the type of the output parameter then it's probably not a big deal. Developers like to type in extremely literal types, especially with generics, so the staff may not want to type in really large type definitions repeatedly and then folks start getting into type inference algorithms. There's physical limits to such things and tradeoffs. I guess it just depends. If someone knows of a system that has quick inference and build times and also allows methods with the same name and same number of parameters, please let me know.

https://web.cs.ucla.edu/~palsberg/paper/dedicated-to-kozen12.pdf

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests