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

Enhancing Flexibility in Method Overrides #4138

Open
nathnaeld opened this issue Oct 24, 2024 · 1 comment
Open

Enhancing Flexibility in Method Overrides #4138

nathnaeld opened this issue Oct 24, 2024 · 1 comment
Labels
feature Proposed language feature that solves one or more problems

Comments

@nathnaeld
Copy link

Dart's strict method signature matching for overrides limits subclasses from using meaningful parameter names. This impacts methods clarity and usability, leading to unnecessary boilerplate.

// Bass class
class Pair<T, U> {
  final T first;
  final U second;

  Pair(this.first, this.second);

  Pair<T, U> copyWith({T? first, U? second}) => Pair(
    first ?? this.first,
    second ?? this.second,
  );
}

//Subclass
class Range<T extends Comparable> extends Pair<T?, T?> {
  Range({T? lower, T? upper}) : super(lower, upper);

  T? get lower => first;
  T? get upper => second;

  // Overriding copyWith without signature mismatch, but losing clarity.
  @override
  Range<T> copyWith({T? first, T? second}) => Range<T>(
    lower: first ?? this.lower,
    upper: second ?? this.upper,
  );

  // A separate copyWithRange method that is redundant and inefficient
  Range<T> copyWithRange({T? lower, T? upper}) => Range<T>(
    lower: lower ?? this.lower,
    upper: upper ?? this.upper,
  );

  // Ideal, but causes signature mismatch error
  @override
  Range<T> copyWith({T? lower, T? upper}) => Range<T>(
    lower: lower ?? this.lower,
    upper: upper ?? this.upper,
  );
}

In this example, the subclass Range ideally wants to use parameters like lower and upper to represent the bounds of the range, but is forced to stick with first and second to avoid signature mismatches. Introducing another method like copyWithRange becomes redundant and inefficient.

Solution Proposal:

Dart should allow parameter renaming in method overrides, maintaining type compatibility with the base class. This would enhance clarity without breaking contracts.

Possible Syntax:

Range<T> copyWith({
  T? lower as first, 
  T? upper as second
}) => Range<T>(
  lower: lower ?? this.lower,
  upper: upper ?? this.upper,
);

In this solution, the lower and upper parameters are mapped to the base class's first and second, allowing more meaningful parameter names in the subclass without breaking the base class contract.

@nathnaeld nathnaeld added the feature Proposed language feature that solves one or more problems label Oct 24, 2024
@lrhn
Copy link
Member

lrhn commented Oct 24, 2024

I think this is, essentially, asking for the same named parameter to be able to have more than one name.

Dart functions exist independtly of their declaration. It doesn't matter what we write at the declaration, it matters what the runtime type of

var c = (Range<int>(lower: 42, upper: 87) as Pair<int, int>).copyWith;

is. Since the static type is Pair<int, int> Function({int? first, int? second}), the function's runtime type will be a subtype of that. Which means it can be called with first and second as named arguments.
But then you can also do:

var range = Range<int>(lower: 42, upper: 87);
var pair = range as Pair<int, int>;
var c = pair.copyWith; // Static type `Pair<int, int> Function({int? first, int? last})`.
var d = range.copyWith; // static and runtime type ... ???
assert(identical(c, d));

Whatever runtime type Range<int>.copyWith has, it allows using both first and lower as argument names, for the same single parameter.
That's why I conclude that there is one parameter with two names.

That's ... probably possible. Won't say easy or likely, but I can see a consistent underlying model.
Let's introduce a horrible strawman syntax:

class Range ... {
   Range<T> copyWith({T? lower + first, T? upper + second}) => ...;
}
void main() {
  Range<int> Function({T? lower + first, R? upper + second}) copy = Range<int>(lower: 4, upper: 8).copyWith;
}

(Alternative operators could be / or |: T? lower/first or T? lower|first. The | will probably step on any attempt to add union types. I actuall like the /, especially without spacing. But again, strawman!)

Every named parameter, in functions and function types, can have names which is <identifier> ('+' <identifier>)*. Order doesn't matter (semantically, tools may suggest the first name if they need a name).
No two parameters can have the same name, even if they also have different names, and there is no renaming. You cannot remove a name, because that would be unsound.

Function subtyping allows a subtype to have more names for a named parameter than the supertype. That is, if the supertype has a named parameter with names n1,...,nk, the subtype must have a named parameter with names that include all of n1,...,nk, but it may add more names to that parameter.

A function invocation can pass an argument only once, by any of its names. It's an error to do, fx, copy(lower: 1, first: 2). You can mix methaphors and do copy(lower: 1, second: 2).

Inside the function you can either only access the parameter by its first name, or by all the names.
If it's all the names, do they represent the same variable or different variables?
To avoid that question entirely, I'd only introduce a local variable for the first name in the list.

(Which means it's not symmetric, so the T? lower as first syntax can work, but it needs a way to add more than one, like lower as first as alpha as initial or lower as first + alpha + initial ... the latter reads better, but then lower + first + alpha + initial is even better.)

Need to consider (non-redirecting generative) constructors.
You probably cannot combine this with initializing formals or super-parameters in constructors. Or if you can, the initializing formal or super parameter must be the first entry, and the rest become different names

class C extends B {
  int x;
  C({this.x + a = 0, super.y + b = 0}) : assert(x >= 0); 
}

This could be allowed, calling it as C(y: 42) would still initialize x, and would introduce only a final x variable in the initializer list.
So, in a declaration, the first identifier is the "real" identifier that has an effect in the function declaration itself, the rest are aliases for that parameter that can be used when calling it. In the function type there is no distinction, it's an implementation choice which name to use internally. (Although for this.x and super.y the name is forced by the target, you have to stop using those constructors to change the internal/first name).

I don't see this feature happening. It's nice in that it allows you to use different names for named parametes in a subclass, but it doesn't remove the old name, so it's likely to feel cluttered. It complicates function types in general.
The recommended workaround is to have a different method name, or to not extend the superclass if you don't actually want its API. "Favor composition over inheritance" as they say in Java.

Here I'd likely use an extension type instead of a subtype:

extension type Range<T extends Comparable>._(Pair<T?, T?> _) /* implements Pair<T?, T?> */ {
  Range({T? lower, T? upper}) : this._(Pair<T?, T?>(lower, upper));

  T? get lower => _.first;
  T? get upper => _.second;

  Range<T> copyWith({T? lower, T? upper}) => Range<T>(
    lower: lower ?? this.lower,
    upper: upper ?? this.upper,
  );
}

You can add the implements Pair<T?, T?> if you want assignability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

2 participants