dart-lang / language

Design of the Dart language
Other
2.66k stars 204 forks source link

Argument namespaces #1955

Open munificent opened 2 years ago

munificent commented 2 years ago

This is a strawman proposal to address (some of) issue #357.

Here is an example of it in use. Say you have a couple of data types:

enum ButtonAlignment { top, center, bottom }

class Color {
  final int red, green, blue;
  const Color(this.red, this.green, this.blue);
}

You also have this separate namespace-like class for working with colors:

class Colors {
  static const red = Color(255, 0, 0);
  static const yellow = Color(255, 255, 0);
  static const green = Color(0, 255, 0);
  static const cyan = Color(0, 255, 255);
  static const blue = Color(0, 0, 255);
  static const magenta = Color(255, 0, 255);

  static Color darker(Color color) =>
      Color(color.red ~/ 2, color.green ~/ 2, color.blue ~/ 2);
}

Then you have a class that uses these:

class Button {
  Button(
    this.text, {
    this.alignment,
    this.color,
  });

  final String text;
  final ButtonAlignment alignment;
  final Color color;
}

To create an instance of this class today, you have to write:

Button('One',
  alignment: ButtonAlignment.top,
  color: Colors.darken(Colors.blue)
);

You would like to be able to write:

Button('One', alignment: top, color: darken(blue));

This strawman enables that. But to turn it on, you need to change the Button and Colors classes like so:

class Button {
  Button(
    this.text, {
    this.alignment from ButtonAlignment, // <--
    this.color from Colors, // <--
  });

  final String text;
  final ButtonAlignment alignment;
  final Color color;
}

class Colors {
  // ...

  static Color darken(Color color from Colors) =>  // <--
      Color(color.red ~/ 2, color.green ~/ 2, color.blue ~/ 2);
}

The from clauses after the marked parameters are how those parameters opt in to special lookup rules for bare identifiers in arguments. When evaluating an argument expression:

The from clause lets an API author deliberately opt in to a set of identifiers that become valid arguments for that parameter. An API author can sort of say "here is the enumerated set of short names this parameter accepts".

Pros

Compared to other proposals, this strawman more explicit and verbose. That explicitness provides a couple of benefits:

Identifiers can be looked up on another type

One of the common areas where users are frustrated by redundancy is color parameters in Flutter:

var myStyle = TextStyle(Color: Colors.red);

But, as you can see here, red isn't a property on the actual Color class, it's a constant on a separate Colors class. An explicit from clause lets an API deliberately redirect to a separate type like that, like the example here shows.

The namespace can be custom-tailored to the API

In fact, API authors can define their own custom namespace-like classes containing exactly the identifiers they want for a specific parameter. Any given parameter can have its own little purpose-built autocomplete namespace.

For example, you could do:

class Ascii {
  static const a = 97;
  // ...
  static const z = 122;
}

class Text {
  Text.fromCharCode(int charCode from Ascii) ...
}

Here, the parameter's actual type is int, which isn't specific to any particular domain. There's no way we're going to add the ASCII table to the int class itself in order to have nicer looking charCode arguments.

But since the author of a parameter chooses which type to look up argument shorthands on, this fromCharCode() constructor can point to a type specific to the API's domain.

APIs are less fragile

The API author knows that changing the from clause can be a breaking change to uses of the API. If we rely on the parameter's type to determine which identifiers are allowed, then changing a parameter type is always a breaking change, even in ways that aren't breaking today.

For example, say we decide that Button should also allow a string of CSS for its color. That means loosening the type of color to both Color or String:

class Button {
  Button(
    this.text, {
    this.alignment,
    this.color,
  });

  final String text;
  final ButtonAlignment alignment;
  final Object color;
}

If the parameter's type determined what identifiers could be used, every existing callsite could have just broken. But by specifying the type that identifiers are looked up on explicitly, the API author can loosen the type while still preserving the lookup on Colors:

class Button {
  Button(
    this.text, {
    this.alignment,
    this.color from Colors,
  });

  final String text;
  final ButtonAlignment alignment;
  final Object color;
}

The parameter's type has changed, but every existing callsite relying on lookup on Colors continues to work. By not making the identifier lookup implicit based on the parameter's type, we give API authors more freedom to change parameter types without breaking users. They can evolve the parameter's type and its lookup namespace independently.

Cons

There are some negatives, though:

leafpetersen commented 2 years ago
  • treat the argument expression as a static getter or method call on the referenced type.

If the referenced type is not in scope at the call site is it an error?

Separately, I wonder whether it would be worth considering generalizing this to arbitrary receivers of some sort? That is allow instance receivers as well?

mateusfccp commented 2 years ago

I don't know if I understand this proposal well...

You gave an example of Flutter color parameters:

One of the common areas where users are frustrated by redundancy is color parameters in Flutter:

 var myStyle = TextStyle(Color: Colors.red);

However, if we have to explicitly declare the class/enum with the from keyword, it's entirely up to the Flutter API to decide which class/enum may be used as namespace. This isn't a viable solution for the problem of Flutter color parameters (or similar), at least not for all cases.

Considering the example you gave, TextStyle, the following rises:

The only way I see to workaround this, considering the current stable Dart + this proposal, would be to extend the class and opt in to whatever we want in it, which IMO is cumbersome and annoying to maintain:

class MyTextStyle extends TextStyle {
  const MyTextStyle({
    bool inherit = true,
    Color? color from MyColors,
    Color? backgroundColor from MyColors,
    double? fontSize,
    FontWeight? fontWeight,
    FontStyle? fontStyle,
    double? letterSpacing,
    double? wordSpacing,
    TextBaseline? textBaseline,
    double? height,
    TextLeadingDistribution? leadingDistribution,
    Locale? locale,
    Paint? foreground,
    Paint? background,
    List<Shadow>? shadows,
    List<FontFeature>? fontFeatures,
    TextDecoration? decoration,
    Color? decorationColor from MyColors,
    TextDecorationStyle? decorationStyle,
    double? decorationThickness,
    String? debugLabel,
    String? fontFamily,
    List<String>? fontFamilyFallback,
    String? package,
    TextOverflow? overflow,
  }) : super(
          inherit: inherit,
          color: color,
          backgroundColor: backgroundColor,
          fontSize: fontSize,
          fontWeight: fontWeight,
          fontStyle: fontStyle,
          letterSpacing: letterSpacing,
          wordSpacing: wordSpacing,
          textBaseline: textBaseline,
          height: height,
          leadingDistribution: leadingDistribution,
          locale: locale,
          foreground: foreground,
          background: background,
          shadows: shadows,
          fontFeatures: fontFeatures,
          decoration: decoration,
          decorationColor: decorationColor,
          decorationStyle: decorationStyle,
          decorationThickness: decorationThickness,
          debugLabel: debugLabel,
          fontFamily: fontFamily,
          fontFamilyFallback: fontFamilyFallback,
          package: package,
          overflow: overflow,
        );
}
Levi-Lesches commented 2 years ago

@mateusfccp, I think you're last request may be a bit out of scope. The other proposals I've seen are to implicitly rename .foo, when the context type is T, to T.foo. All that to say it's reasonable to expect this shorthand to only work when there's one obvious type involved. If you want to use your own class or namespace, you might as well just use it in the call-site. I don't see a way to to signal to Dart that this is the class you want to use that's more concise or readable than just spelling it out at the call-site.

@munificent, I like this idea, I just have a few concerns:

mateusfccp commented 2 years ago

@Levi-Lesches I didn't make any request. Instead, I commented on what was proposed based on what it was supposed to solve. The proposal's author specifically stated that the proposal should solve one problem, and I questioned how would it solve this specific problem considering some limitations.

munificent commented 2 years ago

If the referenced type is not in scope at the call site is it an error?

I don't think so. What matters is that the type is in scope where the API is defined. That's where the linkage is established. Then, from there, the parameter implicitly makes the names on that type in scope in the argument location.

However, if we have to explicitly declare the class/enum with the from keyword, it's entirely up to the Flutter API to decide which class/enum may be used as namespace.

That's correct and is a limitation of the proposal.

This isn't a viable solution for the problem of Flutter color parameters (or similar), at least not for all cases.

Considering the example you gave, TextStyle, the following rises: ... Flutter has a Colors class. However, it belongs to the material library. It would be inconsistent for TextStyle to opt in to Material colors, as TextStyle itself is not a Material component;

That's a really good point. A consequence of this proposal is that it means that the API accepting the shorthand must directly couple itself to the namespace where those identifiers are looked up.

  • For example, I work in a project that, as this proposal itself exemplify, has a custom Colors namespace-like class with our design system colors. We use these colors for everything in our project, but we wouldn't be able to use them in non-material components like TextStyle, Container, ColoredBox, which is, at least in our case, the vast majority of cases.

In cases where you want to have your own set of short names for argument values, you can always simply define a bunch of top-level variables/constants in a library and import it wherever you want to use them.

  • will users still be able to pass other valid instances, even if it's not the type specified in the from? As already mentioned, this can be crucial when the namespace only represents a small subset of valid values.

Yes, this wouldn't be defining a restricted enumerated set of values, just a set of shorthands you can use to refer to some of them.

cedvdb commented 2 years ago

Is this a proposal that would fit a lot of use cases ? It seems like it's mainly aimed at flutter.

munificent commented 2 years ago

Is this a proposal that would fit a lot of use cases ? It seems like it's mainly aimed at flutter.

I think Flutter definitely exacerbates the problem since the Flutter API leans really heavily on named parameters and has a lot of parameters of enum or enum-like types.

a14n commented 2 years ago

How could we define such a namespace for a parameter of type List<Color>?

Alternativelly couldn't we have this notion of namespace in a similar way as import/export?

For instance:

namespace Colors for Color;

f() => Button('One', alignment: .top, color: .darken(.blue));

(.top is ok because implicitely A is a namespace for A)

or directly in a library defining:

export namescape Colors for Color;

NB : I like the dot prefix because it avoids naming conflicts and it is explicit about the usage of a namespace.

ltOgt commented 2 years ago

This might not be the right place for this, but for me function parameters came to mind, and I do like the idea of the prefixed dot:

feels similar to

However,

lrhn commented 2 years ago

It's not clear what the from scope hangs on in language semantic model.

Is it part of the function type? If so, does it affect subtyping? Probably not, so most likely it's not part of the function type.

More likely it's static metadata carried along with the function type by the static inference and type propagation system, but completely absent at run-time. A kind of extra inferred knowledge about the parameter, but not something inherent to the parameter of the function type itself. Any function type can carry the metadata, and the method signatures of interfaces, or the signatures of static functions, which is declared with the feature would have the metadata from the start. We'd have to figure out how it works with joining function types from separate paths.

Or it could be a feature which only works when calling an interface method (where we know the class declaring the interface) or static method (where we know the function declaration directly), not on a function value.

nate-thegrate commented 2 years ago

~I think it could be better to use "in" as the keyword, instead of "from"~ never mind, that's pretty confusing.

I'm a huge fan of this proposal, hopefully this or something similar is integrated into the language at some point.

medz commented 9 months ago

I have a different take, why don't we allow nested ;statements? For example

class Top {
   class Inner {
     static const int value = 1;
   }

   static const int value = 2;
}

void main(List<String> args) {
   print(Top.value); // 2
   print(Top.Inner.value); // 1
}

And we allow a private class to be declared within any structure, for example

void main(List<String> args) {
   class MainInner {
     static const int value = 1;
   }

   print(MainInner.value); // 1
}

I think nested classes will make the code cleaner, more readable, and easier to maintain. And we can use shorter names without worrying about naming conflicts.