language icon indicating copy to clipboard operation
language copied to clipboard

Parameter default scopes

Open eernstg opened this issue 1 year ago • 152 comments
trafficstars

In response to https://github.com/dart-lang/language/issues/357:

Here is an idea that the language team members have discussed previously, but so far it does not seem to have an issue where it is spelled out in any detail.

It supports concise references to enum values (e.g., f(mainAxisAlignment: .center) and case .center: rather than f(mainAxisAlignment: MainaxisAlignment.center) and case MainAxisAlignment.center:), and it supports similarly concise invocations of static members and constructors of declarations that may not be enums. The leading period serves as a visible indication that this feature is being used (that is, we aren't using normal scope rules to find center when we encounter .center).

Introduction

We allow a formal parameter to specify a default scope, indicating where to look up identifiers when the identifier is prefixed by a period, as in .id.

We also allow a switch statement and a switch expression to have a similar specification of default scopes.

Finally, we use the context type to find a default scope, if no other rule applies.

The main motivation for a mechanism like this is that it allows distinguished values to be denoted concisely at locations where they are considered particularly relevant.

The mechanism is extensible, assuming that we introduce support for static extensions. Finally, it allows the context type and the default scope to be decoupled; this means that we can specify a set of declarations that are particularly relevant for the given parameter or switch, we aren't forced to use everything which is specified for that type.

The syntax in E is used to specify the default scope E. For example, we can specify that a value of an enum type E can be obtained by looking up a static declaration in E:

enum E { e1, e2 }

void f({E e in E}) {}

void g(E e) {}

void main() {
  // Using the default scope clause `in E` that `f` declares for its parameter.
  f(e: E.e1); // Invocation as we do it today.
  f(e: .e1); // `.e1` is transformed into `E.e1`: `.` means that `e1` must be found in `E`.

  // Using the context type.
  E someE = .e2;
  g(.e1);

  // A couple of non-examples.
  (f as dynamic)(e: .e1); // A compile-time error, `dynamic` does not provide an `e1`.
  Enum myEnum = .e2; // A compile-time error, same kind of reason.
}

It has been argued that we should use the syntax T param default in S rather than T param in S because the meaning of in S is that S is a scope which will be searched whenever the actual argument passed to param triggers the mechanism (as described below). This proposal is written using in S because of the emphasis on conciseness in many recent language developments.

If a leading dot is included at the call site then the default scope is the only scope where the given identifier can be resolved. This is used in the invocation f(e: .e1).

The use of a default scope is especially likely to be useful in the case where the declared type is an enumerated type. For that reason, when the type of a formal parameter or switch scrutinee is an enumerated type E, and when that formal parameter or switch does not have default scope, a default scope clause of the form in E will implicitly be induced. For example:

enum E { e1, e2 }

void main() {
  var x = switch (E.e1) {
    .e1 => 10,
    .e2 => 20,
  };
}

We can support looking up colors in Colors rather than Color because the in E clause allows us to specify the scope to search explicitly:

void f(Color c in Colors) {}

void main() {
  f(.yellow); // OK, means `f(Colors.yellow)`.
}

Assuming that a mechanism like static extensions is added to the language then we can add extra colors to this scope without having the opportunity to edit Colors itself:

static extension MyColors on Colors {
  static const myColor = Colors.blue;
}

void main() {
  f(.myColor); // OK, means `f(Colors.myColor)`, aka `f(MyColors.myColor)`.
}

We can also choose to use a completely different set of values as the contents of the default scope. For example:

class AcmeColors {
  static const yellow = ...;
  ... // Lots of colors, yielding a suitable palette for the Acme App.
  static const defaultColor = ...;
}

class MyAcmeWidget ... {
  MyAcmeWidget({Color color = defaultColor in AcmeColors ...}) ...
}

...
build(Context context) {
  var myWidget = MyWidget(color: .yellow); // Yields that very special Acme Yellow.
}
...

This means that we can use a standard set of colors (that we can find in Colors), but we can also choose to use a specialized set of colors (like AcmeColors), thus giving developers easy access to a set of relevant values.

If for some reason we must deviate from the recommended set of colors then we can always just specify the desired color in full: MyAcmeWidget(color: Colors.yellow ...). The point is that we don't have to pollute the locally available set of names with a huge set of colors that covers the needs of the entire world, we can choose to use a more fine tuned set of values which is deemed appropriate for this particular purpose.

This is particularly important in the case where the declared type is widely used. For instance, int.

extension MagicNumbers on Never { // An extension on `Never`: Just a namespace.
  static const theBestNumber = 42;
  static const aBigNumber = 1000000;
  static const aNegativeNumber = -273;
}

void f(int number in MagicNumbers) {...}

void main() {
  f(.theBestNumber); // Means `f(42)`.
  f(14); // OK.
  
  int i = 0;
  f(i); // Also OK.
}

This feature allows us to specify a set of int values which are considered particularly relevant to invocations of f, and give them names such that the code that calls f will be easier to understand.

We can't edit the int class, which implies that we can't use a mechanism that directly and unconditionally uses the context type to provide access to such a parameter specific set of names.

We could use static extensions, but that doesn't scale up: We just need to call some other function g that also receives an argument of type int and wants to introduce symbolic names for some special values. Already at that point we can't see whether any of the values was intended to be an argument which is passed to f or to g.

// Values that are intended to be used as actual arguments to `f`.
static extension on int {
  static const theBestNumber = 42;
  static const aBigNumber = 1000000;
  static const aNegativeNumber = -273;
}

// Values that are intended to be used as actual arguments to `g`.
static extension on int {
  static const theVeryBestNumber = 43;
}

// A mechanism that relies on the context type would work like a
// default scope which is always of the form `T parm in T`.
void f(int number in int) {...}
void g(int number in int) {...}

void main() {
  f(theBestNumber); // OK.
  g(theBestNumber); // Oops, should be `theVeryBestNumber`.
}

Proposal

Syntax

<normalFormalParameter> ::= // Modified rule.
    <metadata> <normalFormalParameterNoMetadata> <defaultScope>?

<defaultNamedParameter> ::= // Modified rule.
    <metadata> 'required'? <normalFormalParameterNoMetadata>
    ('=' <expression>)? <defaultScope>?

<defaultScope> ::= 'in' <namedType>
<namedType> ::= <typeIdentifier> ('.' <typeIdentifier>)?

<primary> ::= // Add one alternative at the end.
    :    ...
    |    '.' <identifierOrNew>

<switchExpression> ::=
    'switch' '(' <expression> ')' <defaultScope>?
    '{' <switchExpressionCase> (',' <switchExpressionCase>)* ','? '}'

<switchStatement> ::=
    'switch' '(' <expression> ')' <defaultScope>?
    '{' <switchStatementCase>* <switchStatementDefault>? '}'

Static analysis

This feature is a source code transformation that transforms a sequence of a period followed by an identifier, .id, into a term of the form E.id, where E resolves to a declaration.

The feature has two parts: An extra clause known as a default scope clause which can be specified for a formal parameter declaration or a switch statement or a switch expression, and a usage of the information in this clause at a call site (for the formal parameter) respectively at a case (of the switch).

The syntactic form of a default scope clause is in E.

A compile-time error occurs if a default scope contains an E which does not denote a class, a mixin class, a mixin, an extension type, or an extension. These are the kinds of declarations that are capable of declaring static members and/or constructors.

The static namespace of a default scope clause in E is a mapping that maps the name n to the declaration denoted by E.n for each name n such that E declares a static member named n.

The constructor namespace of a default scope clause in E is a mapping that maps n to the constructor declaration denoted by E.n for each name n such that there exists such a constructor; moreover, it maps new to a constructor declaration denoted by E, if it exists (note that E.new(); also declares a constructor whose name is E).

Consider an actual argument .id of the form '.' <identifier> which is passed to a formal parameter whose statically known declaration has the default scope clause in E.

Assume that the static or constructor namespace of in E maps id to a declaration named id. In this case id is replaced by E.id.

Otherwise, a compile-time error occurs (unknown identifier).

In short, an expression of the form .id implies that id is looked up in a default scope.

Consider an actual argument of the form .id(args) where id is an identifier and args is an actual argument list.

If neither the static nor the constructor namespace contains a binding of id then a compile-time error occurs (unknown identifier).

Otherwise, .id(args) is transformed into E.id(args).

Consider an actual argument of the form .id<tyArgs>(args) where id is an identifier, tyArgs is an actual type argument list, and args is an actual argument list.

If neither the static nor the constructor namespace contains a binding of id then a compile-time error occurs (unknown identifier). If the constructor namespace contains a binding of id, and the static namespace does not, then a compile-time error occurs (misplaced actual type arguments for a constructor invocation).

Otherwise, .id<tyArgs>(args) is transformed into E.id<tyArgs>(args).

Note that it is impossible to use the abbreviated form in the case where actual type arguments must be passed to a constructor. We can add syntax to support this case later, if desired.

class A<X> {
  A.named(X x);
}

void f<Y>(A<Y> a) {}

void main() {
  // Assume that we want the type argument of `f` to be `num`, and the type argument
  // to the constructor to be `int`.
  f<num>(A<int>.named(42)); // Using the current language, specifying everything.
  f<num>(<int>.named(42)); // Syntax error.
  f<num>(.named<int>(42)); // Wrong placement of actual type arguments.
  f<num>(.named(42)); // Allowed, but the constructor now gets the type argument `num`.
}

We generalize this feature to allow chains of member invocations and cascades:

Let e be an expression of one of the forms specified above, or a form covered by this rule. An expression of the form e s where s is derived from <selector> will then be transformed into e1 s if e will be transformed into e1 according to the rules above.

The phrase "a form covered by this rule" allows for recursion, i.e., we can have any number of selectors.

Let e be an expression of one of the forms specified above. An expression of the form e .. s or e ?.. s which is derived from <cascade> will then be transformed into e1 .. s respectively e1 ?.. s if e will be transformed into e1 according to the rules above.

The resulting expression is subject to normal static analysis. For example, E.id<tyArgs>(args) could have actual type arguments that do not satisfy the bounds, or we could try to pass a wrong number of args, etc.

This feature is implicitly induced in some cases:

  • Assume that P is a parameter declaration whose declared type is an enumerated type E. If P does not have a default scope clause then in E is induced implicitly.
  • Assume that S is a switch expression or statement that does not have a default scope clauses, and whose scrutinee has a static type E which is an enumerated type. In this case a default scope clause of the form in E is implicitly induced.
  • Finally, assume that an expression .id derived from '.' <identifier> is encountered at a location where the context type is of the form C, C?, C<...>, or C<...>?, where C is an identifier or a qualified identifier that denotes a class, mixin, mixin class, or an extension type. Assume that C declares a static member named id or a constructor named C.id. In that situation .id is replaced by C.id. As in the previously declared cases, this rule is also extended to the case where .id is followed by a chain of member invocations and/or a cascade.

It is recommended that the last clause gives rise to a warning in the situation where said context type is the result of promotion, or it's the result of type inference.

Enumerated types

An enumerated type is specified in terms of an equivalent class declaration.

With this proposal, each enumerated type E will have an abstract declaration of operator == of the following form:

  bool operator ==(Object other in E);

Assume that E is an enumerated type that declares the value v and e is an expression whose static type is E. An expression of the form e == .someName (or e != .someName) will then resolve as e == E.someName (respectively e != E.someName).

Dynamic semantics

This feature is specified in terms of a source code transformation (described in the previous section). When that transformation has been completed, the resulting program does not use this feature. Hence, the feature has no separate dynamic semantics.

Versions

  • Version seven, Friday June 14: Remove support for bare identifiers, only .id is supported now. This was done because it is likely to be hard to spot that any given plain identifier is looked up in a default scope, rather than using the normal scope rules.
  • Version six, Monday June 3: Remove support for multiple default scopes. The syntax was ambiguous (thanks to @Abion47 for pointing out this ambiguity), and the expressive power is already covered rather well by using static extensions to populate a single default scope.
  • Version five, Friday May 31: Add a recommendation to have a warning when a context type which is used as a default scope is obtained by promotion or type inference.
  • Version four, Wednesday May 29: Add a catch-all rule that transforms .id to T.id when no other rule is applicable. Change the support for selector chains and cascades to a part of the proposal.
  • Version three, Tuesday May 28: Mention support for selector chains (.id.foo().bar[14].baz) and cascades as a possible extension.
  • Version two, Monday May 27: Include dot-identifier. General rewrite and clarification.
  • First version posted on Friday May 24.

eernstg avatar May 24 '24 09:05 eernstg

Checking this proposal against the cases in this comment.

The main issue to discuss here is probably that we will fix at the declaration of each formal parameter that supports this kind of abbreviation from which scope it can be made available.

For example, there is a case below where a member has type EdgeInsetsGeometry, but the actual argument has type EdgeInsets. I've addressed that by including support for both of those scopes, but it gets harder if we wish to enable many scopes.

A counter point would be that we can add static extensions to the language, and this would allow us to add extra members to existing scopes.

Enums

Example 1: BoxFit

Use current:

Image(
  image: collectible.icon,
  fit: BoxFit.contain,
)

Use with this proposal:

Image(
  image: collectible.icon,
  fit: .contain,
)

Definitions:

class Image extends StatefulWidget {
  final BoxFit? fit;

  const Image({
    super.key,
    required this.image,
    ...
    this.fit,
  });
}

enum BoxFit {
  fill,
  contain,
  ...
}

Example 2: Alignment

Use current:

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  mainAxisSize: MainAxisSize.min,
  children: [ ... ],
)

Use with this proposal:

Row(
  mainAxisAlignment: .center,
  mainAxisSize: .min,
  children: [ ... ],
)

Definitions:

class Row extends Flex {
  const Row({
    ...
    super.mainAxisAlignment,
    ...
  }) : super(
    ...
  );
}

class Flex extends MultiChildRenderObjectWidget {
  final MainAxisAlignment mainAxisAlignment;

  const Flex({
    ...
    this.mainAxisAlignment = MainAxisAlignment.start,
    ...
  }) : ...
}

enum MainAxisAlignment {
  start,
  end,
  center,
  ...
}

Named constructors

Example 1: BackdropFilter

Use current:

BackdropFilter(
  filter: ImageFilter.blur(sigmaX: x, sigmaY: y),
  child: myWidget,
)

Use with this proposal:

BackdropFilter(
  filter: .blur(sigmaX: x, sigmaY: y),
  child: myWidget,
)

Definitions:

class BackdropFilter extends SingleChildRenderObjectWidget {
  final ui.ImageFilter filter;

  const BackdropFilter({
    required this.filter in ui.ImageFilter,
    ...
  });
}

abstract class ImageFilter {
  ImageFilter._(); // ignore: unused_element
  factory ImageFilter.blur({
    double sigmaX = 0.0,
    double sigmaY = 0.0,
    TileMode tileMode = TileMode.clamp,
  }) { ... }
}

Example 2: Padding

Use current:

Padding(
  padding: EdgeInsets.all(32.0),
  child: myWidget,
),

Use with this proposal:

Padding(
  padding: .all(32.0),
  child: myWidget,
),

Definitions:

class Padding extends SingleChildRenderObjectWidget {
  final EdgeInsetsGeometry padding;

  const Padding({
    super.key,
    required this.padding in EdgeInsets,
    super.child,
  });
}

class EdgeInsets extends EdgeInsetsGeometry {
  ...
  const EdgeInsets.all(double value)
   : left = value,
      top = value,
      right = value,
      bottom = value;
}

Static members

Use current:

Icon(
  Icons.audiotrack,
  color: Colors.green,
  size: 30.0,
),

Use with this proposal:

Icon(
  .audiotrack,
  color: green,
  size: 30.0,
),

Definitions:

class Icon extends StatelessWidget {
  /// Creates an icon.
  const Icon(
    this.icon in Icons, {
    ...
    super.color in Colors, // Or whatever the default scope of colors is called.
  }) : ... ;

  final IconData? icon;
}

abstract final class Icons {
  ...
  static const IconData audiotrack = IconData(0xe0b6, fontFamily: 'MaterialIcons');
  ...
}

eernstg avatar May 24 '24 11:05 eernstg

To me the fact that functions have to explicitly opt-in to this is a deal breaker.

It is going to be extremely frustrating to have to add this in Type in all parameters of the public API of a package. And users are bound to be frustrated when they want to use the shorthand, but a parameter did not specify in Type.

It also hard-codes those short-hands in the package ; when users may want to define their own shorthands. A typical example: Colors/Icons. Folks will want to define shortcuts for their primary colors or app icons. But Flutter would have a hard-coded in Colors, so this wouldn't work.

Last but not least, there's also the case of generics:

void fn<T>(T value);

It is unclear to me how we could handle fn<Color>(Colors.red) here.

rrousselGit avatar May 24 '24 12:05 rrousselGit

To me the fact that functions have to explicitly opt-in to this is a deal breaker.

Good points! Let me try to soften them a bit.

It is going to be extremely frustrating to have to add this in Type in all parameters of the public API of a package.

True, that could give rise to a substantial amount of editing.

We could have some amount of tool support.

For example, I'd expect enumerated types to give rise to the vast majority of usages of this mechanism. This is a good match because there's no doubt that we will have to provide one of the values of that particular enumerated type, so we're always going to get a shorthand for precisely the values that are relevant. So we should probably have a quick fix for any parameter whose type is an enumerated type E, adding in E.

Next, the mechanism could be introduced gradually for any other usages. For example, adding support for blur and other ImageFilter constructors could be done for parameters of that type, and call sites in new code could then be less verbose than existing call sites.

It also hard-codes those short-hands in the package

I expect this mechanism to play well together with a static extension mechanism. So if you want to have your own extended set of colors you would add them to Colors, rather than creating a new entity (that the parameter does not know anything about). Search for MyColors in the initial posting in order to see an example.

This makes a specification like Color c in Colors extensible in a scoped manner. That is, you can have your own extra colors in a static extension of Colors, and other folks could have their own extra colors similarly, and they would exist at the same time without creating any conflicts, even if both of you want to use Colors.crimson with a different meaning, because each of you would import one of those static extensions, not both.

Finally, for the generic case:

void fn<T>(T value);

For the invocation fn<Color>(Colors.red) there wouldn't be any support for an abbreviation, you will just have to write it in full. We might be able to come up with something really fancy, but for now I think it's OK.

I think the danger associated with a very broad mechanism that would enable red to be transformed into Colors.red in a very large number of locations (like, "in every location where the context type is Color") is more serious than the convenience of being able to cover cases like fn<Color>(red) can justify. This is particularly true because the type argument which is passed to fn is probably going to be inferred, not explicit.

eernstg avatar May 24 '24 14:05 eernstg

This could be implied and the default

enum E { e1, e2 }

void f({E e in E}) {     // unnecessary in E

Which would be the same as

enum E { e1, e2 }

void f({E e}) {

cedvdb avatar May 24 '24 15:05 cedvdb

This could be implied

True! I don't know if that would be too aggressive. Maybe ... perhaps ... it would be OK to say that this mechanism is always enabled implicitly for parameters whose type is an enum. On the other hand, that would immediately call for a way to opt out. We could use something like in Never to indicate that the abbreviation should not be used at all. In any case, that's fine tuning and we can easily make adjustments like that if it turns out to be desirable.

eernstg avatar May 24 '24 15:05 eernstg

@eernstg I believe your example is not what you meant to write in static members color: Colors.green should be green.

imo, keep the dot . in front of the shorthand, it's more readable

cedvdb avatar May 24 '24 15:05 cedvdb

On the other hand, that would immediately call for a way to opt out.

Out of curiosity, why? At least for the author of an API, they should not care how the parameters are passed syntactically, only that the values that are coming in are of the expected type?

If anything, users might want to be able to opt out, but I don't know how that would work.

jakemac53 avatar May 24 '24 15:05 jakemac53

so no "in" introduction for now.

I agree that in seems unnecessary, especially if we get static extensions. I think it is better if the person invoking the function, not the API designer, controls which things can be passed using this shorthand.

That makes me think, what if we just had a more general feature to add static members into the top level scope?

As a total straw man:

import 'package:flutter/material.dart' with Colors; // All the static members on Colors are now in the top level scope 

That I think is possibly a simpler feature, and puts all the control in the users hands? And at least you don't have to repeat the class name multiple times in a library. Maybe you could even export the static scope like this as a top level scope, so you could have a utility import which does this by default.

jakemac53 avatar May 24 '24 15:05 jakemac53

I like the idea of being able to import things into the top level scope. In Java (and surely in other languages too), you'd use a asterisk (*) to denote that but I understand Dart doesn't have the import syntax to achieve something like that. Though, I don't think that'd work with calling static methods, like BorderRadius.circular() or EdgeInsets.all().

imo, keep the dot . in front of the shorthand, it's more readable

100% agree. For EdgeInsets, .all() is a lot more readable than all(), and its what's done in other languages with enums.

This being an opt-in feature with the in syntax doesn't sit right with me. I can sort of understand it when dealing with constructors but at the very least enum's shouldn't have to be opt-in. As Jacob said, package authors shouldn't care about how parameters are passed syntactically.

Reprevise avatar May 24 '24 15:05 Reprevise

Strongly recommend the leading dot syntax for this. It's a really nice way to indicate to the programmer that it's shorthand enum syntax instead of some other thing in scope.

As far as I'm concerned, this only needs to work when the type is explicit and an enum. Bonus points for working with named constructors / factories / static members that return the same type

enum MyEnum { foo, bar}

final MyEnum x = .foo; // success
final y = .foo; // syntax error

void fn(MyEnum x) => null;

main(){
  fn(.foo); // success
}

lukepighetti avatar May 24 '24 16:05 lukepighetti

@cedvdb wrote:

color: Colors.green should be green.

True, thanks! Fixed.

keep the dot . in front of the shorthand

I would be worried about that. New syntactic forms of expression is always an extremely delicate matter, because it makes every expression more likely to be syntactically ambiguous.

eernstg avatar May 24 '24 16:05 eernstg

@jakemac53 wrote:

users might want to be able to opt out

That should not be necessary: Anything that currently has a meaning will continue to have that meaning (because we're using the standard scope rules). So you'd just write what you would write today, and it would never trigger this mechanism.

eernstg avatar May 24 '24 16:05 eernstg

If you are OK with the new syntax, then instead of in clause or with clause (which every other user will forget to add), we can target the root cause by allowing the syntax like

class Colors simulates Enum<Color> {
  static Color red = ...
  //etc
}

ghost avatar May 24 '24 16:05 ghost

What would be the difference between defining a global method with the same signature as one defined in the class if we don't keep the leading .?

EdgeInsets all(double value) {
  // ...
}

void foo({required EdgeInsets padding in EdgeInsets}) {
  // ...
}

foo(padding: all(16));

Reprevise avatar May 24 '24 16:05 Reprevise

@jakemac53 wrote:

what if we just had a more general feature to add static members into the top level scope?

This could add a lot of names to the top-level scope. It might be difficult to manage them and avoid name clashes. We could consider local imports, https://github.com/dart-lang/language/issues/267. That is definitely one way to provide direct access to a set of names in some other scope (it's got one vote at this time ...).

eernstg avatar May 24 '24 16:05 eernstg

@Reprevise wrote:

What would be the difference between defining a global method with the same signature as one defined in the class if we don't keep the leading .?

The main difference is that the top-level function would pollute the name space much more pervasively: Every occurrence of all would then resolve to a declaration (the top-level function that you mention, or some nested declaration that shadows it).

With the mechanism proposed here we would only be able to call all(...) when the particular formal parameter admits the transformation (for example, from all(...) to EdgeInsets.all(...)).

So, for example, this mechanism would allow many different constructors whose name is of the form *.all to coexist. With a top-level function you'd have to choose one of them.

eernstg avatar May 24 '24 16:05 eernstg

I should be able to do the following : String status in 'on' | 'off' .

What is the type of the expression on | off ? Isn't it a kind of Enum? What if you want to declare a type with two values on and off ? What syntax will you use?

ghost avatar May 24 '24 16:05 ghost

@tatumizer (removed my previous comment before you quoted me but)

Type 'on' | 'off'

Same as type Colors.green | Colors.red | ... which can be generated from static members of Colors by a macro or the language ( with an "in" keyword for example). How the subset is generated is a detail.

Enum shorthand syntax is a different feature imo, but the two seem to be conflated in the proposal.

cedvdb avatar May 24 '24 16:05 cedvdb

I personally don't like this proposal. It would be 5x easier to just convert enums to strings like TS and support "contain", with no scoping problem, and union being easier as a bonus. I think what most people want is "left" | "center" | "right" (right now as an enum, but if it were an union type you wouldn't need to remember the class name, so win-win scenario). Swift is nice beause you type "." and it suggests the available types. You don't need to remember anything, just the ".". Similarly, TypeScript is nice because you type " and it suggests the available types.

In your proposal you loose this super important aspect, there is no way to type "something" and ask for the analyzer to suggest the options.

bernaferrari avatar May 24 '24 16:05 bernaferrari

I agree that one advantage of having the . prefix is it gives a good thing for autocomplete to work off of. I had the exact same thought.

jakemac53 avatar May 24 '24 17:05 jakemac53

In your proposal you loose this super important aspect, there is no way to type "something" and ask for the analyzer to suggest the options.

Note that autocomplete works with for example "ctrl + space" too without having to type anything but it may propose more options than necessary without "."

cedvdb avatar May 24 '24 17:05 cedvdb

@cedvdb : And now you have 2 different concepts formalizing the "fixed set of values": one is the (existing) enum, and another is ... What do you call the type of 'on' | 'off'? If it's not an enum, then what is it? A second concept, parallel to enum? But if it is an enum, then you have to somehow shoehorn it into an existing concept of enum.

In principle, with static interfaces, you can do something like this:

@SimulateEnum()
class Colors {
   static Color red = ...
   static Color blue = ...
   ...
}

and make SimulateEnum macro add implements static Enum<Color> and define all the remaining Enum methods. But we don't have a parametrized class Enum<T> today.

ghost avatar May 24 '24 18:05 ghost

How is this proposal different from this one?

The existing design proposal seems more thoughtful. Also, I agree that having starting points can simplify typing with autocomplete, also it would help avoid name collisions.

StarProxima avatar May 24 '24 19:05 StarProxima

Note that autocomplete works with for example "ctrl + space" too without having to type anything but it may propose more options than necessary without "."

Nothing beats " or .. Still half the muscle than ctrl+space (which changes depending on OS, machine and keyboard). " and . are always consistent.

For me this is unbeatable: image

bernaferrari avatar May 24 '24 19:05 bernaferrari

@StarProxima: In the existing proposal, there's a restriction

We restrict to static members returning the same type as the type declaration they’re on

This doesn't allow Color values defined in the class Colors to be used with the context type Color.

ghost avatar May 24 '24 20:05 ghost

@tatumizer

Is it really necessary to support the Colors class? In theory, we can get colors from many places, from our own class with static fields, from ThemeExtension, directly using the constructor...

It would be weird to support only the Colors class for the possibility of shorthand.

Supporting enum, static fields and constructors (static methods?) already covers most use cases and is fairly obvious without requiring changes to existing code to support shorthand for use.

I would vote for the existing design proposal to address this issue https://github.com/dart-lang/language/issues/357.

StarProxima avatar May 24 '24 20:05 StarProxima

@eernstg

If we want to allow Colors class, perhaps we could add some annotation to support the use of Colors wherever Color is required (perhaps on the declaration of the Color class itself, rather than on each use)? I think that would be easier than introducing new syntax into the language.

StarProxima avatar May 24 '24 20:05 StarProxima

@tatumizer

If it's not an enum, then what is it? A second concept, parallel to enum?

What is the theoritical type of Color color in Colors in the proposal anyway ? It's obviously not Color but a subset. In that way, String text in 'on' | 'off' is not different, as a subset of String (implementation details aside).

cedvdb avatar May 24 '24 22:05 cedvdb

@jakemac53 wrote:

the . prefix is it gives a good thing for autocomplete to work off of

That's a very strong argument in favor of having the leading .. I'm looking into the grammar in order to try to learn some more about the impact of allowing '.' <identifier> as a <primary>.

@StarProxima wrote about the proposal named 'Dart enum value shorthand' from @lrhn:

How is this proposal different from this one?

You could say that all these proposals (we've discussed a bunch of them, some more elaborate than others, over a period of several years) are concerned with the selective access to namespaces that are not otherwise available.

(I'll use 'namespace' to refer to the static declarations of a class, mixin, extension type, extension, etc, because it's just a mapping from names to values that we are using.)

In particular, it allows id or .id (or some other variation of that theme) to have the meaning E.id because we have a reason to look for the name id in the namespace associated with E.

We could do that directly by opening the scope (that's a local import, #267). We could do it by putting a . in front of the identifier (that's https://github.com/dart-lang/language/blob/main/working/3616%20-%20enum%20value%20shorthand/proposal-lrhn.md). We could do it by declaring that specific locations will provide the namespace (such as this proposal).

(By the way, I don't think there would be anything wrong with a proposal which is similar to this one, but a '.' in front of an identifier would be used to trigger the mechanism. That should work just fine together with the in E mechanism. It could probably be optional, and it could be used to insist that we should use the extra namespace even in the case where the given name is already declared somewhere in an enclosing scope. I'm just worried about the syntactic cost ...)

So that was the trigger mechanism: How do we gain access to that extra namespace?

The next question is which namespace we're talking about. Several proposals rely on the context type, including 'Dart enum value shorthand'. In other words, for an actual argument to a function/method/constructor invocation, we look up the parameter type and use that in order to select the namespace. Concretely, if the parameter type is Color then we find the class denoted by the identifier Color (that is, the one which is in scope at the location where the formal parameter is declared), and then we try to find something named id in that namespace.

In contrast, this proposal relies on a user-specified namespace. For example, for a parameter of type Color we can make the choice to specify a namespace like the class Colors, which will then be used to look up id.

One reason why I've chosen to use an explicitly specified namespace is that this allows us to have specific choices for a specific context. For example, if we want to provide symbolic names for a set of magic numbers then we can do this:

extension MagicNumbers on Never { // An extension on `Never`: This is nothing but a namespace
  static const theBestNumber = 42;
  static const aBigNumber = 1000000;
  static const aNegativeNumber = -273;
}

void f(int number in MagicNumbers) {...}

void main() {
  f(theBestNumber); // Means `f(42)`.
  f(14); // Also OK.
  
  int i = 0;
  f(i); // Also OK.
}

If we were to insist that the chosen namespace for every int must be the int class then we wouldn't be able to have specific "application domains" for the same type, we'd have to stick to very specific sets of values like a specific enum, which would then be used in exactly the same way whenever it's used at all.

It is quite important for this proposal that we can have something like static extensions to provide extensibility.

static extension on MagicNumbers {
  static const aRandomNumber = 87;
}

void main() {
  f(aRandomNumber + theBestNumber);
}

Surely, static extensions would also be useful in collaboration with 'Dart enum value shorthand'.

Another thing to note is that we can offer values of more than one type in the given namespace.

extension MyNumbers on Never {
  static const int numberOne = 1;
  static const double numberHalf = 0.5;
}

void f<X extends num>(X x in MyNumbers) {}

void main() {
  f(numberOne); // Means `f<int>(1)`.
  f(numberHalf); // Means `f<double>(0.5)`.
}

This illustrates that it is (1) possible to provide values of different types in the same namespace (the call site chooses a name like numberOne and hence the caller is in control with respect to the chosen value and type); and (2) the chosen type could influence type inference.

Another way to use the ability to have multiple types (rather than relying strictly on the context type) is that this allows us to use subtypes of the context type. For example, both numberOne and numberHalf can be passed as arguments to a function whose parameter is num n in MyNumbers.

Finally, the explicitly declared namespace allows for combinations of namespaces num n in MyNumbers, MagicNumbers. Presumably, it would be OK for those namespaces to have conflicts, which would be resolved in a standardized manner (for example, if name has two different declarations then it's the last namespace that wins). This is again not so easy to do if we rely on the context type as the only and final selector of the namespace.

eernstg avatar May 24 '24 22:05 eernstg

@cedvdb wrote:

What is the theoritical type of Color color in Colors in the proposal anyway?

As the title may hint, this proposal is about providing some default values from a specified namespace. The type of color is Color, and any instance of type Color can be passed. But we expect that certain values of that type are particularly useful, and hence we'd like to provide support for denoting them in a concise manner. The role played by said namespace is to contain declarations of those "extra useful" values.

As always, we may also find a static method m in said namespace, and we may have an invocation like m([1, 2]) which turns out to mean MyNameSpace.m([1, 2]). This means that we have a set of "extra useful" functions, but we can also choose to pass any "normal" expression whose type fits the given parameter.

Also, constructs like switch (e) in E { ... } have yet another variant of similar properties.

eernstg avatar May 24 '24 23:05 eernstg