dart-lang / language

Design of the Dart language
Other
2.65k stars 203 forks source link

Parameter default scopes #3834

Open eernstg opened 4 months ago

eernstg commented 4 months ago

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:

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

Abion47 commented 4 months ago

Transforming .identifier(14).bar[0] into T.identifier(14).bar[0] because the context type is T seems more like inferring final int foo = 42.isEven; because 42 has static type int. ;-) We're by design ignoring the type of the expression as a whole and just latching onto the leading token (.identifier respectively 42).

This is a bad comparison. In the case of variable declaration:

final (T) foo = 42.isEven;

The inferred type T is determined by examining the type of the entire righthand expression. Once you type "42", foo is inferred to be an int, but as soon as you add the ".isEven", the inferred type is updated to be a bool.

final foo = 42;        // int
...
final foo = 42.isEven; // bool

But in the case of a parameter, the inference is going the complete opposite direction. It is the outer context that determines the type of the inner expression:

void foo(num n) { ... }

foo(42);        // Fine
foo(42.isEven); // Syntax error

Having said that, what is wrong with this syntax precisely?

final foo = bar(.baz().qux[0]);

As long as the final type of T.baz().qux[0] was of type T, I see no reason why omitting the T shouldn't be allowed as, syntactically speaking, no rule is broken:

In the end, the inference of T in the expression results in the expression returning a legal value, so I see no reason why it should be specifically disallowed. (Of course, I could totally foresee that Google would add an entry to the linting rules discouraging the practice.)

rrousselGit commented 4 months ago

I don't know. I don't think transforming .identifier(14).bar[0] into T.identifier(14).bar[0] just because the context type is T appears to be a beautiful or promising language mechanism.

Perhaps it's can work, and developers would just need to navigate the typing properties of those chains with care. But it does sound like a recipe for surprises and disappointments to me.

To me, relying on context type is the most predictable approach suggested. There are no edge-cases where doing fn(color: .identifier) means anything but fn(color: Color.identifier).

Every other suggestions implies that the outcome varies based on external factors.

For example, using this proposal, we could have:

fn({Color? color in Colors}) {}
fn2({Color? color in MyTheme}) {}

Notice the Color vs MyTheme here.

Suddently, some parameters expecting a Color will suggest Colors static members. And some parameters will be based of MyTheme... Even though both parameters have the same type.

That's to me the real recipe for surprises and disappointments.

The point I was making was that it seems silly to support chains of method and getter invocations in general if the resulting expression is unlikely to be type correct in the given context.

I think we disagree on the value of doing this. In fact, I don't think that hiding suggestions would be a good experience.

IMO a core value of my proposal is that it's predictable. There's never a difference between fn(color: .red) and fn(color: Color.red).

This includes autocompletion. When typing fn(color: . vs fn(color: Color., the autocompletion is identical.

By typing .identifier, we don't diminish API discoverability. If we hid some suggestions, users may miss that one key function they need. You'd have to type Color. to get the list of all possible static utilities – just in case the one you need isn't compatible with the .identifier syntax. And then remove that leading Color once you picked one to use the .identifier sugar if possible. This feels unnatural.

Listing everything (even values that are not immediately compatible) is IMO consistent with how autocompletion works as a whole. When you type:

void fn() {
  int a;
  String b = // cursor here

The autocompletion will suggest a even though it is incorrect. But that's preferable. Some users may want to type b = a.toString() for example.

Suppose the user types color: Color. and presses CTRL-SPACE. What suggestion is expected here?

As of today, Color does not contain a bunch of constant variables holding specific colors. It's probably a good idea to keep it that way. For example, I don't think it's going to be pleasant if that particular situation would cause all named colors in the entire imported world to pop up.

So the completion would yield just those static methods and constructors of Color that you'd get today.

This is a pretty good reason why we don't want to limit ourselves to an unconditional 100% reliance on the context type. We want to make it possible for other namespaces than Color to provide named colors.

The context type proposal is built upon the idea that we'd have static extensions. So I disagree with this conclusion.
Of course without static extensions, the proposal is less appealing. But to me, that's like shipping macros without augmentation libraries. I view them as linked.

The issue of Cupertino vs Material colors could be one argument for why we'd want the context type proposal to support cases where T.identfiier isn't of type T.

APIs that are prone to name conflicts due to having various subclasses or values with similar names may want to introduce namespaces. One example would be:

abstract class Color {
  static const material = MaterialColors();
  static const cupertino = CupertinoColors();
}

class CupertinoColors { // A namespace for Cupertino-specifics
  const CupertinoColors();

  static const red = Color(...);
}

class MaterialColors {
  const MaterialColors();

  static const red = Color(...);
}

Then used as:

Color color = .cupertino.red;
Color color = .material.red;

One could argue that we could instead write:

Color color = Cupertino.red;

But .cupertino.red is more discoverable. One major problem with Cupertino.red is, folks are often not aware the Cupertino namespace even exists. Whereas with .cupertino.red, when typing ., we'd get .cupertino automatically suggested. And more importantly, that .cupertino will not be lost in the sea of hundreds of suggestions for top-level identifiers. Whereas for that Cupertino, unless you know it exists, you'd have to scroll quite a bit in the list of suggestions to find it.

tatumizer commented 4 months ago

@rrousselGit

abstract class Color {
 static const material = MaterialColors();
 static const cupertino = CupertinoColors();
}

It's not that simple. To access cupertino's stuff, you need to import 'flutter/cupertino.dart'. The sets of constants in question should be pluggable: cupertino's colors become available if and only if you import them.

Let's forget about extensions and any concrete syntax for a moment.

Wouldn't it be good to be able to mark an entity (class, extension, or something yet unheard-of) with the annotation saying "take every definition from here that satisfies criteria X, and make it available to the feature Y"? I don't know what the syntax for such annotation might look like. We can also argue about what criteria X is more reasonable in our case. Let's assume - just for the sake of example - that criteria X, in the case of colors, means: "all constants defined in this class that are assignable to the variable of type Color". And Y means: enable substitution of corresponding shortcuts into any variable of class Color.

Then we mark the class Colors with this annotation. And we mark the class CupertinoColors with this annotation. And do the same for every entity defining such shortcuts. Now we have a pluggable system. In a concrete configuration (depending on what's imported), we may have no shortcuts at all, or only Material shortcuts, or only Cupertino's, or all together.

Then the argument will boil down to the discussion of X and Y. How about that?

Abion47 commented 4 months ago

@tatumizer

It's not that simple. To access cupertino's stuff, you need to import 'flutter/cupertino.dart'. The sets of constants in question should be pluggable: cupertino's colors become available if and only if you import them.

It is that simple with static extensions. The Material library can just define its own static extension:

extension MaterialColorExt on Color {
  static const material = MaterialColors();
}

class MaterialColors {
  const MaterialColors();

  static const red = Color(...);
}

And then the Cupertino library can define a static extension of its own:

extension CupertinoColorExt on Color {
  static const cupertino = CupertinoColors();
}

class CupertinoColors { // A namespace for Cupertino-specifics
  const CupertinoColors();

  static const red = Color(...);
}

Then it will work exactly like how extension methods currently work. The static members added by the extension will become available when the library is imported.

Let's forget about extensions and any concrete syntax for a moment.

Why? The type inference proposal satisfies the underlying issue of verbose identifiers and static extensions solves the associated issue of getting Flutter to work nice with the feature. If we can isolate the Flutter compatibility aspect, this feature becomes way simpler to implement.

Wouldn't it be good to be able to mark an entity (class, extension, or something yet unheard-of) with the annotation saying "take every definition from here that satisfies criteria X, and make it available to the feature Y"? I don't know what the syntax for such annotation might look like.

There are a couple problems with the annotation approach.

First, the static extension approach will make use of a feature that has use cases beyond the scope of this problem. Making a bespoke annotation just for this feature adds a feature that has precisely only one use and is just bloat on the syntax otherwise. I for one definitely choose the multipurpose approach rather than the bespoke solution approach.

Second, the Dart team already has plans for annotations for use in metaprogramming. At worst, having a special case annotation would conflict with that goal, and at best, your annotation would just be a metaprogramming tag that still needs an implementation strategy under the hood. And hey, once metaprogramming hits the stable branch, you could always create a custom annotation that does what you want and implements it with dynamically generated static extensions.

tatumizer commented 4 months ago

I find the "extension" approach untenable. Consider

extension on int {
   static const randomNumber = 123456;
}

Without any special annotation, this definition will be automatically applicable to any int parameter everywhere. I think it's too radical.

BTW, by "annotation" I don't mean @Annotation. It can be some marker interface like implements Shortcuts<Color> or something.

And I don't see (yet) much of a reason to refactor Colors or CupertinoColors by taking out the constants into an extension, or for copy-pasting them. Why does it have to be an extension to begin with? What's wrong with annotating the class itself?

Abion47 commented 4 months ago

I find the "extension" approach untenable. Consider

extension on int {
   static const randomNumber = 123456;
}

Without any special annotation, this definition will be automatically applicable to any int parameter everywhere. I think it's too radical.

It would seem that your issue here is with static extensions themselves, and not specifically with their use in taking advantage of the shorthand syntax. Because this would make int.randomNumber valid everywhere that imports the file/library this extension is declared whether or not we were talking about the ability to shorten it to .randomNumber.

BTW, by "annotation" I don't mean @Annotation. It can be some marker interface like implements Shortcuts or something.

Then what you are referring to is actually an entirely new keyword than simply an "annotation". If the Dart team is going to add a new keyword to the language, it's only once the keyword has proven its necessity and that there isn't any other way to meaningfully accomplish the same thing. Ignoring the myriad of other reasons why defining a new keyword is an inherently non-trivial proposal, refer back to what I said about a bespoke solution vs a general purpose solution.

And I don't see (yet) much of a reason to refactor Colors or CupertinoColors by taking out the constants into an extension, or for copy-pasting them. Why does it have to be an extension to begin with? What's wrong with annotating the class itself?

Because locking the shorthand syntax behind a special keyword/annotation makes it opt-in, and that essentially makes it useless for all but the simplest of applications. A type in a library would only support the shorthand if the author of the library went through the work to explicitly support it, and the vast majority of package authors simply won't either because they don't know the feature exists or because they are no longer actively maintaining what is otherwise still a perfectly usable package. And this difference between code that supports it vs code that doesn't would only cause widespread confusion as to when the shorthand syntax can be used and when it can't. A feature that is so seemingly ambiguous on when it can be used is a feature that hasn't been properly designed.

There are too many issues and complexities that arise from making the feature opt-in or adopting a solution to manually allow/restrict what types are exposed. By contrast, the solution of making the shorthand only work for the exact type determined by the context is simple and straightforward, and adding support for auxilliary classes with static extensions is also simple and straightforward. The issue of it polluting a namespace isn't really relevant to the topic of shorthand syntax but rather of static extensions in general. If static extensions are going to be implemented anyway (which I believe they are), then there's not really any good reason not to leverage them for this.

lrhn commented 4 months ago

@rrousselGit I do like the simplicity of making .something completely equivalent to <aliasForContextType>.something (we already have rules for when you can call statics through a type alias, and we'd use the same rules here.)

Then something can be any sequence of selectors, it's just a normal type error if that doesn't yield something assignable to the context.

There are things it won't do. That's OK, it's a feature you have to design for, existing code may or may not fit. Flutter Colors do not fit, and probably they shouldn't, because if you want to use CupertinoColors instead, Colors is the wrong default. And you probably can't use both.

We can then ensure that static members can be declared where someone wants them:

rrousselGit commented 4 months ago

maybe allow importing static members into another namespace (if Flutter wants to do class Color { static export Colors; ... }, that could be an option).

Color is defined in dart:ui, which Flutter users generally don't import.
Instead they import package:flutter/material.dart, which re-exports Color among other things.

So I think it'd be simpler to stick to using static extension, and have the library that export Color also export the Colors extension:

// flutter/material.dart
export 'dart:ui' show Color;

static extension Colors on Color {
  static const red = ...;
}

Then when Flutter folks import Color, they'd get the extension alongside it.

There's the edge-case where they use import 'package:flutter/material.dart' show Color, but I don't think that's a huge issue. We could tell users to do import 'package:flutter/material.dart' show Color, Colors.

eernstg commented 4 months ago

@tatumizer wrote, about the parameter declaration Color color in Colors:

"color parameter should be one of the colors defined in Colors subclasses and extensions on Colors, and only in these places".

As @cedvdb already mentioned, it isn't that restrictive.

It is similar to the notation for default values: The parameter declaration Color color = mySpecialColor arguably looks like a declaration that requires the actual argument color to have the value mySpecialColor in every invocation.

It actually specifies that if no actual argument is provided for this parameter then we must use mySpecialColor.

Similarly, the parameter declaration Color color in Colors specifies that if no scope is specified for the actual argument passed to this parameter then we must use Colors as the scope where it's looked up. Hence the name default scopes.

We determine that no scope has been specified by detecting that (1) the argument has the form '.' <identifier> (possibly followed by a chain of member invocations and/or cascades), or (2) the argument has the form <identifier> (possibly followed by the same things), and normal lookup does not yield a result.

As @cedvdb also mentioned, we could use a more verbose syntax like Color colors default in Colors.

However, I'd assume that it is a matter of familiarity, and we'd generally prefer the more concise form when the meaning of this construct has been established.

In particular, a parameter declared as Color color in Colors can certainly accept an actual argument of the form Color.fromARGB(...).

Edit: How about

abstract final class Colors providing_shortcuts_for Color { // better wording is needed
  // A home for a bunch of colors, added by other libraries via
  // `static extension on Colors {...}`.
}

We could do something like that. It basically associates the default scope of the type Color as such with a specific declaration Colors. But if that's an appropriate design choice then we might as well do essentially the same thing by using static extensions to add all those constant colors to Color directly.

I added a catch-all clause to the proposal that transforms .id to T.id when .id is encountered in a location where the context type is T (there are some constraints on that context type as well). I did this based on the strong sentiment here that it is necessary to enable some kind of default scope processing even in the case where there is no in clause.

This means that adding the constant colors to Color would enable the following:

import 'dart:ui';

static extension on Color {
  static const myColor = ...;
}

void f(Color color)  {}

void main() {
  f(.myColor); // OK.
}

Of course, it would also be possible to use a ui.Colors namespace as described previously, in case we wish to have more control and more customizability, and we're prepared to pay the price in terms of adding in Colors to a bunch of parameter declarations.

@Abion47 wrote, about final foo = 42.isEven; being inferred as final int foo = 42.isEven;:

The inferred type T is determined by examining the type of the entire righthand expression.

Yes, indeed, that's my point. It is incorrect and useless to latch onto the first token when the type is inferred. So we don't do that, we actually infer the type of the variable based on the entire initializing expression.

Similarly, why would we decide that .foo().bar means T.foo().bar just because the context type is T? It looks like a long shot to me that the type of the whole expression T.foo().bar should be assignable to T, and if it isn't then we aren't helping anyone by supporting the implicit transformation from .foo().bar to T.foo().bar, it's basically just a slightly more concise and convenient way to obtain a compile-time error.

Anyway, let's try to spin it in a positive way:

We support the transformation of .foo().bar to T.foo().bar where T is the context type (roughly, the precise rules are given in the original posting).

This is considered appropriate based on the assumption that there is a reasonably useful set of reachable expressions whose type is assignable to T. The developer is expected to know that the steps taken (in the example it's just .bar) must yield a value which is assignable to T.

For example, if T.foo() is a constructor invocation and bar returns this, then it would be true (and might seem obvious to a developer) that the entire expression T.foo().bar has type T.

In other words, a developer who writes a term like .foo().bar needs to think "wherever I go at each member invocation in the chain, I need to come home to T in the end. Any result which is not a T is just a compile-time error."

Having said that, what is wrong with this syntax precisely?

final foo = bar(.baz().qux[0]);

The context specifies a type T, T has a static function or factory constructor baz, The return value has a property qux of type List<T>, Which, when indexed, returns a value compatible with type T (assuming an entry exists at said index).

As you said, it can work. It might be a prolific compile-time error factory, but it might also be usable when we all know how to avoid getting in trouble when using it.

@rrousselGit wrote:

Suddently, some parameters expecting a Color will suggest Colors static members. And some parameters will be based of MyTheme... Even though both parameters have the same type.

Good points!

We do have several possible approaches.

You could use the catch-all mechanism (that I added earlier today, based on the feedback) which turns .id into Color.id when the context type is Color (when no other rule is applicable). This means that you don't have to touch any parameter declarations, the very fact that the parameter has type Color will trigger .red --> Color.red.

This will not be a realistic way ahead unless we get static extensions: We would use static extensions on Color to add all those constant color values to Color. We could have typedef Colors = Color; somewhere in order to ensure that existing references to Colors.someColor will continue to work.

This approach puts everything into the same bucket, and it can be inconvenient that you may now have thousands of colors polluting the namespace whenever you try to get completion for another use of Color (like calling a constructor).

So I suggested that we should factor out this particular task to a separate namespace.

// In 'dart:ui'.

// Put your default set of colors into this namespace.
abstract final class Colors {}

This approach relies on putting in Colors on each parameter of type Color which is intended to provide access to this standardized set of named colors. It may or may not be worth the trouble to change all those parameters.

However, one reason why it may be worthwhile is that it allows us to avoid a lot of noise when accessing static members and constructors of Color.

Another approach could be to have specific parameters (of constructors of specific widgets, say) that are associated with a different (presumably much smaller) set of default named colors. The purpose would be that this makes it much easier for developers to consistently use a member of that smaller set of colors, thus making it easier to maintain a visually consistent UI.

So the purpose of having different default sets of named values (of any kind) is not to create chaos, it can instead be to provide a targeted set of defaults in situations where a certain discipline is desired.

That's to me the real recipe for surprises and disappointments.

Or a nudge in the direction of consistency in the choice of colors for specific kinds of widgets.

About support for .foo().bar --> T.foo().bar:

I think we disagree on the value of doing this.

I included support for this kind of transformation into the proposal. I think it can be useful if it is used very carefully, and why wouldn't we do that? ;-)

The context type proposal is built upon the idea that we'd have static extensions.

They are very helpful in several ways, presumably for every proposal in this topic area.

Next, about the ability to use something that we could call "nested default scopes":

Then used as:

Color color = .cupertino.red;
Color color = .material.red;

One could argue that we could instead write:

Color color = Cupertino.red;

But .cupertino.red is more discoverable.

That's a great point!

By the way, I didn't include variable declarations as a location where default scopes can be declared, and that's mainly because they aren't that useful:

// Using a default namespace.
Color color  = .red; // Possible if the desired color can be obtained from `Color`.

// Current Dart.
var color = Color.red; // Not much longer, arguably simpler.

However, we might be able to make Cupertino more discoverable by adjusting the completion mechanism.

Perhaps a lone . could be completed in multiple ways: Turn . into .red and the like; or turn . into Cupertino. or the like. In the latter case we'd search for static extensions that are contributing to the default scope of that location.

However, this would mainly be useful in those (hopefully rare) cases where there is a name conflict, such that we can't use the given default scopes directly.

@tatumizer wrote:

Wouldn't it be good to be able to mark an entity (class, extension, or something yet unheard-of) with the annotation saying "take every definition from here that satisfies criteria X, and make it available to the feature Y"?

Yes, I was thinking about exactly the same thing: We might want to provide access to sets of names without listing each of them. But that could very well be the same thing as an export mechanism. For example:

abstract final class BlueColors {
  static const blue = ...;
  // ... lots of other shades of blue ...
}

abstract final class RedColors {...}

abstract final class Colors {
  static export BlueColors;
  static export RedColors;
  ...
}

static extension MaterialColors on ui.Colors { // Or perhaps `on ui.Color`.
  static export Colors hide crimson; // Who needs crimson, anyway? ;-)
}

This would allow us to provide the same set of values in several different scopes without writing a lot of "forwarding" declarations (like static const red = Colors.red; static const blue = Colors.blue; and so on).

@tatumizer wrote:

I find the "extension" approach untenable. Consider

extension on int {
   static const randomNumber = 123456;
}

Without any special annotation, this definition will be automatically applicable to any int parameter everywhere. I think it's too radical.

That's one of the main reasons why I want to enable provision of values from separate scopes. The randomNumber has a certain domain of relevance, and we don't want to pollute the namespace of every int with this name. But if you have a package where this number is a very relevant value as the actual argument to certain parameters of type int (or a supertype) then you can make the choice to put it into some other namespace than int itself, and then use that other namespace as a default scope for those parameters.

@rrousselGit wrote:

So I think it'd be simpler to stick to using static extension, and have the library that export Color also export the Colors extension:

// flutter/material.dart
export 'dart:ui' show Color;

static extension Colors on Color {
  static const red = ...;
}

Then when Flutter folks import Color, they'd get the extension alongside it.

Sure, that's definitely one way to do it.

rrousselGit commented 4 months ago

This approach relies on putting in Colors on each parameter of type Color which is intended to provide access to this standardized set of named colors. It may or may not be worth the trouble to change all those parameters.

However, one reason why it may be worthwhile is that it allows us to avoid a lot of noise when accessing static members and constructors of Color.

What noise?

I don't consider third-party values/factories to be noise. I do want to see Color.magenta when accessing static members/constructors of Color. They aren't defined by the Color class itself. But that doesn't mean they are any less useful IMO.

By the way, I didn't include variable declarations as a location where default scopes can be declared, and that's mainly because they aren't that useful:

// Using a default namespace.
Color color  = .red; // Possible if the desired color can be obtained from `Color`.

// Current Dart.
var color = Color.red; // Not much longer, arguably simpler.

IMO variables should work merely as a side-effect of supporting expressions/statements like switch:

switch (color) {
  case .red: ...
}

Also, I think there is some value in supporting variable. There are various scenarios in which good practices or opinionated practices suggest explicitly typing variable types. For example:

So folks may want to type final Color color = .red over final color = Color.red because of those practices.

eernstg commented 4 months ago

What noise?

That's just concerned with the size of each namespace, and the situations where it pops up.

For example, if you want to write Color.fromARGB(...), but completion after Color. yields 1200 different constant instances of Color (Color.red, Color.blue, ...) then it might not be easy to find the exact constructor you want (or indeed to find anything).

The approach that uses void f(Color color in Colors) {...} allows us to avoid encountering all those constant colors when we're using completion to find some other thing in Color, simply because those constant colors are not members (static or anything) of Color.

This is of course a scalability thing: It's much more likely that we would want to put an extra effort into namespace management when the given namespace is widely used and has 1200 members than if it has just 12 members.

I do want to see Color.magenta when accessing static members/constructors of Color.

Fair enough. But that preference might not be universal, especially if we're talking about a really massive number of members, of different kinds.

Another thought, in passing: Perhaps completion could receive a hint like "I'm looking for a constructor" or "I'm looking for a constant", and it could then filter according to this criterion?

IMO variables should work merely as a side-effect of supporting expressions/statements like switch:

switch (color) {
  case .red: ...
}

True, that's less verbose than this:

switch (color) in Colors {
  case .red: ...
}

However, I don't think switching on colors is hugely common. There are too many of them ... except of course if we're very strictly working with a tight palette, in which case we still don't have exhaustiveness.

In general, I think we will use separate namespaces like Colors in cases involving large namespaces and widely used entities, or in cases involving widely used types (types like int, String, Map<String, Object?>). Those are cases where we may not want to push the named constants into the target class itself, because of the noise and the lack of specificity.

Anyway, that is a real trade-off. If we want to separate Color from the default scope from where we obtain named constant colors (in some specific set of situations), we may have to mention that separate scope now and then. YMMV, but I don't think the answer will invariably be "just put all the named constants into the same bucket, and let that bucket be the target class itself".

rrousselGit commented 4 months ago

For example, if you want to write Color.fromARGB(...), but completion after Color. yields 1200 different constant instances of Color (Color.red, Color.blue, ...) then it might not be easy to find the exact constructor you want (or indeed to find anything).

We could sort suggestions based on relevance. We could show built-in APIs first. Followed by extensions.

There's also the option to sort suggestions by kind. Such as grouping all constructor suggestions together, rather than sorting them alphabetically only.

And there's the option to use import '...' hide MyStaticExtension. Then, custom static Color members shouldn't be suggested anymore.

However, I don't think switching on colors is hugely common. There are too many of them ... except of course if we're very strictly working with a tight palette, in which case we still don't have exhaustiveness.

That's just an example. It isn't common. But switching on enums is extremely common ;)


switch (enum) {
  case .foo:
  case .bar:
}
tatumizer commented 4 months ago

@eernstg : Could you please describe the migration path for Flutter preserving backward-compatibility? Consider just the example of Colors and CupertinoColors.

A a basis for comparison, here's my proposal: in Colors, add a phrase implements Defaults<Color>. Do the same in CupertinoColors. That's it. (BTW, it's not a new language syntax. We introduce a marker interface Defaults<T> and use it like any other interface).

@rrousselGit , @lrhn and whoever else has any competing ideas - it would be good if you do the same. Then we can have a more concrete discussion of costs/benefits.

eernstg commented 4 months ago

@rrousselGit wrote:

But switching on enums is extremely common ;)

Sure, but they have the enumerated type as their default scope automatically. We will have abbreviated forms denoting exactly the set of values that have the given enumerated type:

enum E {foo, bar, baz}

void f(E anEnum) {
  switch (anEnum) {
    case .foo: ...
    case .bar: ...
    case .qux: // Error, not a member of `E`.
  } // Error, not exhaustive.
}

So we'll get all the usual properties of an enum switch, plus the abbreviation, without any in clauses. There is no reason to do anything else in the particular case where the context type is enumerated.

(Also, if you actually have a good reason to want something else then you can do switch (anEnum) in MyEScope {...}. If MyEScope doesn't contain all members of the type of anEnum then you may need to include a default case in order to achieve exhaustiveness, but that might be the very point: This particular switch is intended to handle the MyEScope subset of these enumerated values, and we should deal with all other values in a default clause.)

eernstg commented 4 months ago

@tatumizer wrote:

Could you please describe the migration path for Flutter preserving backward-compatibility?

Flutter as a whole vs. a language feature is rather broad. I'll just focus on colors. Note that this is a kind of worst case; for example, I believe that enumerated types will be handled quite easily in all proposals.

We have some options:

  1. Do nothing about colors. That is, all colors in Colors will continue to be denoted by terms like Colors.red, and similarly for other namespace declarations playing a similar role like CupertinoColors. Pro: This will not break anything. Con: It will also not allow us to abbreviate anything.
  2. Pile up all colors in Color, using static extension on Color {...} to push every color in Colors, in CupertinoColors, and in any and all similar declarations into Color. We would then rely on the catch-all clause to make lookups in Color available for '.' <identifier> in every location that has context type Color. Existing references like Colors.red would continue to work when Colors is changed from being a class to being a static extension, but they may now be eligible for abbreviation to a term like .red. Note that Color would only contain the material colors if 'material.dart' is imported, and similarly for other contributors. Pro: Avoids touching anything else. Con: May turn Color into a huge namespace which is difficult to navigate.
  3. Pile up all colors in a separate namespace, (abstract class Colors {}, declared in dart:ui, exported by material.dart and by cupertino.dart, and by other similar libraries if any). Add in Colors to all parameters of type Color where the desirable behavior is to use this global set of colors as the default. Again, only imported sets of names are added to ui.Colors. Pro: Makes the namespace in Color less crowded, enables more detailed control in many ways. Con: Requires the addition of in Colors to parameters that are intended to use this abbreviation.

There could be many other approaches, but these three have specifically played a role in the discussion so far.

tatumizer commented 4 months ago

Thanks. There are other classes in flutter which are not enums, but simpler than colors. Example: Alignment. All static members there are of type Aligment : in total, there are 9 constants like

static const Alignment bottomCenter = Alignment(0.0, 1.0);

plus one static function

static Alignment? lerp(Alignment? a, Alignment? b, double t);

plus the constructor:

const Alignment(this.x, this.y);

Question: what criteria do you use while selecting static members of the class eligible for shortcuts? Do they include

Question: does the class Alignment require another extension where all the selected stuff would be placed, or it is good as it is, and works without going into every place where alignment is a parameter and adding "in Alignment"?

eernstg commented 4 months ago

going into every place where alignment is a parameter and adding "in Alignment"?

It seems likely that there is no need to give Alignment any kind of special treatment: The catch-all rule which says that .bottomCenter is transformed into Alignment.bottomCenter when the context type is Alignment would kick in when needed, and it would also transform .lerp(a, b, t)! into Alignment.lerp(a, b, t)!.

Note that the context type for .lerp(a, b, t) is Alignment? when the context type for lerp(a, b, t)! is Alignment, and that is also handled because the ? is stripped off of the context type before it is used to search for the namespace that turns out to be Alignment. So that's an example where a non-trivial "tail" on an expression causes us to "leave" the context type (Alignment.lerp(a, b, t) has type Alignment?, so we have left the safe home which is Alignment), but then we're returning "home" (because the type of Alignment.lerp(a, b, t)! is Alignment). The consequence is that we can actually have things like Alignment alignment = .lerp(a, b, t)!; and it all works out. (It would be nice if we could get some practical experience with .expressions.that().have.a[long]._tail, to confirm that we can make them work, and they aren't just glorious compile-time error factories ;-).

tatumizer commented 4 months ago

👍 So far, we are on the same page! But some questions remain:

It would be nice if we could get some practical experience with .expressions.that().have.a[long]._tail

It seems swift has such an experience, but I am not sure what they allow as a "first selector". If someone knows the answer, please comment. (Maybe I'll investigate. Their documentation is sketchy).

Abion47 commented 4 months ago

@eernstg

By the way, I didn't include variable declarations as a location where default scopes can be declared, and that's mainly because they aren't that useful:

// Using a default namespace.
Color color  = .red; // Possible if the desired color can be obtained from `Color`.

// Current Dart.
var color = Color.red; // Not much longer, arguably simpler.

To clarify, local variable declarations wouldn't usually be that useful, though I still don't see any reason not to support it for people that want to do it as its inclusion would be trivial.

Color color = .red;    // Fine
var color = Color.red; // Also fine

However, there is still this scenario:

class Foo {
  static final foo = Foo();
  static final bar = Bar();
}

class Bar extends Foo {}
var a = Foo.foo;
var b = Foo.bar;

a = Foo.bar; // Fine
b = Foo.foo; // Error: A value of type `Foo` can't be assigned to a variable of type `Bar`

Type inference is making b be of type Bar, not of type Foo as was intended, which makes the later assignment fail. Incidentally, this would also affect the Color example since Colors.red is not of type Color but of type MaterialColor, which would mean that a variable implicitly instantiated with it would be incompatible not only with instances of Color but even with other members of Colors:

var color = Colors.red;
color = Color.fromARGB(...); // Error: A value of type `Color` can't be assigned to a variable of type `MaterialColor`
color = Colors.redAccent; // Error: A value of type `MaterialAccentColor` can't be assigned to a variable of type `MaterialColor`

To make this code work, you would need to explicitly declare the variables as type Foo, which leads to code repetition. That is where the shorthand syntax would help:

Foo a = .foo;
Foo b = .bar;

a = .bar; // Fine
b = .foo; // Also fine

Additionally, class variables would definitely benefit from this feature:

class Foo {
  Color color = .red; // Default initialization

  Foo() : color = .red; // Initializer list

  Foo.bar() {
    color = .red; // Constructor body
  }

  void reset() {
    color = .red; // Method body
  }
}

final foo = Foo()
  ..color = .red; // Cascading access

foo.color = .red; // Normal access

Keeping the feature uncomplicated means it can be supported in more places with minimal changes to the grammar.

  1. Do nothing about colors. That is, all colors in Colors will continue to be denoted by terms like Colors.red, and similarly for other namespace declarations playing a similar role like CupertinoColors. Pro: This will not break anything. Con: It will also not allow us to abbreviate anything.

Honestly, in terms of what the Flutter team should do, this should be the approach. Just make simple dot syntax work as proposed - it will work where it does and not work where it doesn't. Then when static extensions and/or metaprograming are added, people can do the work of extending Color in a third party package however they think it should be done.

IMO, such a package should expose three different imports:

Then anyone who wants or needs it can import whatever namespace they want or need.

Is it perfect? Of course not. But it's also highly unlikely that we will find an approach that will please all or even most people. So the Dart team should just focus on putting in the infrastructure to support such an extension, after which the Flutter community can collectively decide what the best approach to adding Flutter support should look like - voting with their downloads instead of just their opinions. And if a clear winner emerges, maybe we can resume this discussion then.

tatumizer commented 4 months ago

For those interested, here's the link to the proposal on chains. Note this paragraph:

The base type of the implicit member expression would be constrained to match the contextual/resultant type of the whole chain

This rule is enforced for all implicit expressions, regardless of chains. In the case of dart, this would eliminate Enum.values from the list of candidates. Constructors defined in the class matching the context type are included, but constructors of subclasses are not (which is a natural restriction that prevents an explosion of variants).

Predictably, dart's Colors class would not be picked up as a source of shortcuts. Here's an argument that shows that the best way to deal with Colors (and Icons) is to leave them alone. The logic is this: if we define an artificial extension on Color where we put all the constants found in (material) Colors. and define another extension on Color with the constants from CupertinoColors, then someone who likes SwatchColors will also create an extension, and eventually every provider of FancyColors will end up putting all the stuff into some extension on Color. This creates a precedent. Now for every hierarchy of classes (unrelated to UI at all) people will start creating the extensions to make their stuff "more visible". (Controversial issue - some will certainly disagree)

eernstg commented 4 months ago

@tatumizer wrote:

So far, we are on the same page! But some questions remain:

  • do you include constructors?
  • If yes, how do you invoke the default constructor? Like .(5,10) or .new(5, 10) or what?
  • Do you include enum's static values into the list of available shortcuts, despite a different return type?

Default first: Good point! I adjusted the syntax to allow .new(5, 10).

I think .(5, 10) is too hard to read (are we looking at a record? ... a regular parenthesis? ...), so I didn't do anything to enable that.

Constructor invocations have been in the proposal basically all the time. One issue hasn't been solved, though: It is not possible to use the abbreviated form and at the same time pass type arguments to a constructor invocation.

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

void main() {
  A<Object?> a;
  a = A<String>('Hello!'); // Let's try to abbreviate this.
  a = .new('Hello!'); // Not quite: Inferred as `A<Object?>.new('Hello!')`.
  a = .new<String>('Hello!'); // Compile-time error, misplaced type arguments.
}

Considering the possibility that we might add generic constructors (constructors that accept their own type arguments, separate from the type arguments of the class of the newly created object), we shouldn't allow .new<String>(...) to be an abbreviation of A<String>.new(...).

About the return type (and the type of constant variables, etc): The proposal has no constraints on those types. One example I mentioned recently is Alignment alignment = .lerp(a, b, t)!;. If we were to insist that only members whose return type (for a method, and for a getter, including the ones that are implicitly induced by a variable declaration) then that would just be an error (.lerp does not have return type Alignment).

So we can access any static member and any constructor in the declaration that does the job of being a default scope. It is up to the developer to .write.aTail().suchThat the entire expression has the required type in the given context.

One fun case is demotion:

void main() {
  Object? o = 15;
  if (o is int) {
    o = .parse('51').isEven; // Means `int.parse('51').isEven`.
    o.isEven; // Compile-time error.
  }
}

At .parse, o has been promoted to int. The type int is then used to desugar .parse... to int.parse.... The desugared expression int.parse('51').isEven has type bool, so we demote o back to Object?. There is no type error, we just can't do o.isEven in the next line.

@Abion47 wrote:

To clarify, local variable declarations wouldn't usually be that useful, though I still don't see any reason not to support it for people that want to do it as its inclusion would be trivial.

If all the named constant colors are placed in Color (physically, or using static extension on Color {...}, or in some other way) then we can simply rely on the context type and this would work:

Color color = .red;    // Fine
var color = Color.red; // Also fine

However, there's no support for Color color in Colors = .red; // Syntax error.

Type inference is making b be of type Bar, not of type Foo as was intended, which makes the later assignment fail. Incidentally, this would also affect the Color example since Colors.red is not of type Color but of type MaterialColor,

Good point! But that's a property of local variables with inferred declared types, not a specific property of any of these .id desugaring mechanisms.

By the way' it's not obvious that the type of b was intended to be Foo. ;-)

In any case, it is possible for a class designer to provide different types at run time and still give them the same "official" type at creation. That might be relevant. For example:

class Official {
  Official.firstKind() = _Implementation1;
  Official.secondKind() = _Implementation2;
}

class _Implementation1 implements Official {}
class _Implementation2 implements Official {}

Clients will then be able to use Official.firstKind() to obtain an instance of _Implementation1, etc, and the static type will be Official already at the constructor invocation.

Alternatively:

To make this code work, you would need to explicitly declare the variables as type Foo, which leads to code repetition. That is where the shorthand syntax would help:

Foo a = .foo;
Foo b = .bar;

a = .bar; // Fine
b = .foo; // Also fine

That would work, too. We're relying on having the context type Foo, and finding foo and bar as static members of Foo.

If red is a static member of Color then your next example will also work:

class Foo {
  Color color = .red; // Default initialization

  Foo() : color = .red; // Initializer list

  Foo.bar() {
    color = .red; // Constructor body
  }

  void reset() {
    color = .red; // Method body
  }
}

final foo = Foo()
  ..color = .red; // Cascading access

foo.color = .red; // Normal access

It does appear to be really convenient to have access to a static namespace using a mere . and a context type.

The proposal in this issue does support a general mechanism based on the context type. However, it's still worth thinking about this design choice.

The reason why I have been quite sceptical about having a very broad applicability of this feature was that we can have seemingly random compile-time errors or (worse) subtly changing semantics of constructs with no visible changes to the code that now has a different meaning.

Let's just consider a simple example where two expressions look similar, but they can have arbitrarily different effects:

class A {
  static A foo() {
    print("Ho-hum! Cleaning the kitchen.");
    return B();
  }
}

class B implements A {
  static A foo() {
    print("Hehe, not doing a thing! Now you're in trouble!");
    return B();
  }
}

void main() {
  A a = B();
  a = .foo(); // 'Ho-hum! Cleaning the kitchen.'
  if (a is B) {
    a = .foo(); // 'Hehe, not doing a thing! Now you're in trouble!'
  }
}

My initial proposal used only types that are declared explicitly as part of an API (that is, the declared type of a formal parameter, or an explicitly specified list of default scopes), and types that are visible locally (switch (e) in T {...}). The current proposal allows for a lot more. It looks very convenient, but it may or may not be too much of a footgun.

@tatumizer wrote:

For those interested, here's the link to the proposal on chains. Note this paragraph:

If those two conditions are met, then a constraint is introduced requiring the result of the whole chain to equal the type of the base of the implicit member expression.

Interesting, I hadn't seen that! It looks like we already have all the features in that proposal. Some details will differ because the two languages are so different, but I can't spot anything which is surprisingly different. ;-)

This proposal has essentially the same constraint, too: The expression as a whole must generally have a type which is assignable to the context type, which is again 'the type of the base of the implicit member expression'. This is a consequence of normal type checking on the desugared result.

However, the demotion example above illustrates that there are some situations where we don't have to preserve the context type (search for o.isEven above).

Enum.values was another example of a (short) chain. We could use it as follows:

enum E { one, two }

void main() {
  E e = .one;
  e = .values[1]; // OK.
}

The Swift chain proposal would allow for this kind of construct, too. But if you try to do e = .values; in the Dart example it would of course just be a compile-time error.

if we define an artificial extension on Color where we put all the constants found in (material) Colors. and define another extension on Color with the constants from CupertinoColors, then someone who likes SwatchColors will also create an extension, and eventually every provider of FancyColors will end up putting all the stuff into some extension on Color. This creates a precedent. Now for every hierarchy of classes (unrelated to UI at all) people will start creating the extensions to make their stuff "more visible". (Controversial issue - some will certainly disagree)

I'm not sure how much of a problem that is. As long as the technique used to populate any default scope is to put all the entities (here: constant colors) in a static extension, the available ones are determined by the imports: If 'cupertino.dart' is imported then the declarations in a static extension in 'cupertino.dart' will be added to their target scope (say, Color or Colors in dart:ui). If you don't want to see them then you can do hide CupertinoColors in your import, or maybe you can stop importing 'cupertino.dart' entirely, if you aren't using it after all.

rrousselGit commented 4 months ago

Agreed with .new(...). That's similar to constructor tear-offs: fn(Class.new). I was thinking about the same syntax for the context type variant.

void main() {
  A a = B();
  a = .foo(); // 'Ho-hum! Cleaning the kitchen.'
  if (a is B) {
    a = .foo(); // 'Hehe, not doing a thing! Now you're in trouble!'
  }
}

Interesting.
What about excluding upcasts for variables? So that if (a is B) a = .foo; would still imply A.foo regardless of the upcast.

I don't think upcasts make sense for this feature.

FMorschel commented 4 months ago

If https://github.com/dart-lang/language/issues/3837 ever comes to be a thing, that could be a better solution if someone wanted that kind of testing, I think.

tatumizer commented 4 months ago

I'm of two minds about introducing artificial extensions just for the sake of enabling "implicit member expressions". One concern is of a technical nature: how to create such extensions? Copy all the stuff from, say, Colors class into static extension on Color manually? Or via a macro? So we will have two almost identical sets of definitions: one in Colors class, another - in the extension?

Another issue: consider this code from flutter documentation:

ThemeData(
  primarySwatch: Colors.amber, // Colors.amber has type MaterialColor
)

Here, primarySwatch parameter is declared with the type MaterialColor, which is a subclass of Color. Does it mean we also have to create an extension on MaterialColor if we want Colors.amber to be a candidate for a shortcut here? And MaterialColor is not the only subclass of Color in flutter. There's also a (mysterious) SwatchColor, which might also want a separate extension for itself (some constants in Colors indeed have type SwatchColor). I'm afraid the solution to an (arguably) minor problem becomes too complicated. We don't know even how often people use Colors constants directly in real-life applications. Maybe they select color parameter from the Theme? Then we are trying to solve a non-existing problem.

I think if we target only Enum classes and Enum-like classes like Alignment, we will cover at least 90% of cases (the number is made-up; we need some stats). Maybe this is enough to justify the feature?

Abion47 commented 4 months ago

@eernstg

However, there's no support for Color color in Colors = .red; // Syntax error.

Type inference is making b be of type Bar, not of type Foo as was intended, which makes the later assignment fail. Incidentally, this would also affect the Color example since Colors.red is not of type Color but of type MaterialColor,

Good point! But that's a property of local variables with inferred declared types, not a specific property of any of these .id desugaring mechanisms.

By the way' it's not obvious that the type of b was intended to be Foo. ;-)

Then let me ask you this. How would this example work? (derived from one of your earlier examples):

enum E { one, two, three, four }

abstract final class EvenE {
  static const two = E.two;
  static const four = E.four;
}

void foo(E e on EvenE) { ... }

E e = .one;
foo(e); // Fine or syntax error?

If it's fine, then that makes your proposal a not-statically-safe feature. Such an addition would make sense in the TypeScript world, but in a statically typed language like Dart, it's out of place and leads to confusing situations like this one where it's ambiguous whether the code would result in an error.

If it throws an error, then because typing the variable as E on EvenE isn't supported, it has become impossible to declare a variable that would satisfy the function's parameter signature which represents a major flaw in your proposal.

(And the fact that b was assigned a value of Bar but was later attempted to be assigned a value of Foo implies that the intended value of b was supposed to be Foo, but that's splitting hairs.)

In any case, it is possible for a class designer to provide different types at run time and still give them the same "official" type at creation. That might be relevant. For example:

class Official {
  Official.firstKind() = _Implementation1;
  Official.secondKind() = _Implementation2;
}

class _Implementation1 implements Official {}
class _Implementation2 implements Official {}

Clients will then be able to use Official.firstKind() to obtain an instance of _Implementation1, etc, and the static type will be Official already at the constructor invocation.

Except this is not equivalent to the code I presented. In my example, I was using static final values, whereas in your example, you are using factory constructors (presumably, since non-factory constructors don't support that assignment syntax). This is not the same thing, and to get the same thing without utilizing explicit typing requires jumping through some hoops:

class Official {
  static final firstKind = Official._firstKind();
  static final secondKind = Official._secondKind();

  factory Official._firstKind() => _Implementation1();
  factory Official._secondKind() => _Implementation2();
}

class _Implementation1 implements Official {}
class _Implementation2 implements Official {}

And at this point, the code is definitely no longer KISS-compliant.

The reason why I have been quite sceptical about having a very broad applicability of this feature was that we can have seemingly random compile-time errors or (worse) subtly changing semantics of constructs with no visible changes to the code that now has a different meaning.

Let's just consider a simple example where two expressions look similar, but they can have arbitrarily different effects:

void main() {
  A a = B();
  a = .foo(); // 'Ho-hum! Cleaning the kitchen.'
  if (a is B) {
    a = .foo(); // 'Hehe, not doing a thing! Now you're in trouble!'
  }
}

This is a valid concern, though I would think that it's not a terribly likely scenario to arise in practice, nor would I consider it to be good practice as reassigning a in the middle of the upcasted block risks breaking the upcasting. I also agree with @rrousselGit that a simple solution to this problem is that the type shorthand syntax just shouldn't support upcasting.

If nothing else, I'd argue that the ambiguous cases regarding shorthand are far lesser in both quantity and severity than the ambiguous cases regarding your proposal, and that for shorthand syntax, the benefits far outweigh the concerns.

@tatumizer

I'm of two minds about introducing artificial extensions just for the sake of enabling "implicit member expressions". One concern is of a technical nature: how to create such extensions? Copy all the stuff from, say, Colors class into static extension on Color manually? Or via a macro? So we will have two almost identical sets of definitions: one in Colors class, another - in the extension?

While it's true that a package author having to manually copy every member of Colors into the extension as well as making sure it keeps in parity with every Flutter release would be tedious, remember that with metaprogramming it becomes trivial. All you would need to do is define a custom annotation that copies all static members from a source type to the target. And keep in mind that that isn't a theoretical future feature, it's something the Dart team has said they are actively working on.

Another issue: consider this code from flutter documentation:

ThemeData(
  primarySwatch: Colors.amber, // Colors.amber has type MaterialColor
)

Here, primarySwatch parameter is declared with the type MaterialColor, which is a subclass of Color. Does it mean we also have to create an extension on MaterialColor if we want Colors.amber to be a candidate for a shortcut here?

In this specific scenario, I would argue that this is an issue with the Flutter SDK arbitrarily typing this property as a MaterialColor in the first place. Looking into how this property is used, it seems like it really should be typed as a SwatchColor rather than a MaterialColor. Maybe it's a conceptual limitation instead of a technical one as they don't intend a user to pass something like Colors.redAccent to a property called primarySwatch, but it still strikes me as arbitrary.

But I digress. Because the type of primarySwatch is MaterialColor, there would need to be an extension on MaterialColor to make shorthand of Colors work on this property, yes. Though that's not really a surprise since it would have to be that way regardless of what feature enabled such an extension.

I think if we target only Enum classes and Enum-like classes like Alignment, we will cover at least 90% of cases (the number is made-up; we need some stats). Maybe this is enough to justify the feature?

In terms of quantity, perhaps, but in terms of how often they are actually used, I expect that number is way lower. Not including the color types, off the top of my head, EdgeInsetsGeometry, IconData, BoxBorder, and virtually every type that is primarily retrieved through an of static method (NavigatorState, MediaQueryData, ThemeData, etc) are all very common types that also have auxiliary classes which wouldn't play nice with the shorthand syntax out of the box.

tatumizer commented 4 months ago

@Abion47

Because the type of primarySwatch is MaterialColor, there would need to be an extension on MaterialColor to make shorthand of Colors work on this property, yes.

So now every color currently defined in Colors should be copied into (at least) two extensions: one on Color, another - on MaterialColor? For some reason, flutter did now want to put the colors from Colors class into MaterialColor, so this move will be equivalent to re-designing flutter, and it's not clear whether flutter people want every Material color be available as Colors.red OR as MaterialColor.red OR as Color.red. And this, as you observed, is just the proverbial tip of the iceberg. BTW, CupertinoColors is similar to Colors: most of the constants there are of type CupertinoDynamicColor, but some are simply Colors. Now every cupertino color will be also available from 3 places. Flutter is so huge that the scope of the problem is unknown.

Can it be that swift's "implicit shortcuts" feature was designed in sync with their libraries (which are not multi-platform AFAIK)? This is not so in dart/flutter world.

bernaferrari commented 4 months ago

Swift has their predefined colors inside of Color. The difference with Flutter is that they only have 16 colors + 3 semantic (accent, primary), they update the palette sometimes, and each of these colors is actually 4 colors: light mode / dark mode / standard / high contrast. It is multiplatform and more flexible (in Flutter would require the context I guess, to know the brightness and if accessibity high contrast is on).

My favorite one is CSS variables + Tailwind, you can either do bg-red-500 or bg-primary. You don't need Theme.of(context).colorScheme that arbitrary changes every few months and now has 60 colors.

I don't know an easy way to make .red in Flutter, other frameworks are just made differently.

But Icon and Padding would still see improvements. For them it would be easier.

eernstg commented 4 months ago

@rrousselGit wrote:

I don't think upcasts make sense for this feature.

That's the kind of thing I'm worried about: We're creating a strong connection between the static type of a local variable (or anything that could provide a context type) and the selection of a static member or constructor.

This is a kind of uncertainty multiplier: The type of a local variable can be seen as a more or less flimsy representation of the knowledge we have about the value of that variable at any given location in the code. The choice of a constructor or a static member is more of a timeless commitment. "When I say .parse(...) at this location I want to run int.parse, not T.parse for some unknown T that might be int". So perhaps some context types are changing so frequently (from one line of code to the next) that they shouldn't be used as default scopes at all?

For example, we could say that the context type must be obtained from an explicitly declared type. So Color color = .red; would work because Color is written right there, and bar(.red) would work when bar has a formal parameter whose declared type is Color, but a = .foo() (as in the example above) would be a compile-time error because a promoted local variable doesn't have a type which is stable enough to justify using it as a default scope.

Another case where the context type can be tricky to keep track of is when a generic function is invoked and an actual type argument is inferred, and a parameter type uses the corresponding type variable.

X f<X>(List<X> xs, X x) => x;

void main() {
  var x = 2 > 1 ? 1.5 : 2;
  f([x], .parse('whatever')); // `int.parse` or `num.parse`?
}

We could certainly lint the locations where a default scope is selected based on types that are obtained from promotion or inference.

@tatumizer wrote:

I'm of two minds about introducing artificial extensions

That is a new feature in Dart, but it is not a revolution.

Arguably, static extensions are not more disruptive than instance extensions (that is, the kind that we have today: extension SomeName on Foo {...}).

Those two kinds of extensions allow us to give a meaning to an access to a member m (an instance member respectively a static member or a constructor) in an existing declaration D (that does not already declare a member named m), without modifying the source code of D.

In both cases, the mechanism is scoped: We can perform this member access if the extension has been imported, otherwise not.

I'd even say that the static extensions are less disruptive than the (instance) extensions that we have today, because static members and constructors are resolved statically both when they are declared normally and when they are declared in a static extension. With instance members we have this deep discrepancy that class instance members have object-oriented dispatch, and extension instance members are resolved statically.

Copy all the stuff

I believe the underlying topic here is namespace management. Assume that we want namespace B to contain a large number of bindings that already exist in namespace A. We could do that by writing a large number of declarations, each of which will introduce one binding in B by forwarding to A.

I mentioned yesterday that we could use an export mechanism for this purpose:

abstract final class A {
  ... // Lots of declarations
}

// Add every static member of `A` to `B`.
static extension PushAStuffToB on B {
  static export A;
}

The motivation for having some features to deal with namespace management could be strengthened by the need to do things like this. However, it hasn't been a topic that's gotten a lot of attention so far.

Does it mean we also have to create an extension on MaterialColor if we want Colors.amber to be a candidate for a shortcut here?

I guess the point is that primarySwatch has declared type MaterialColor, so the question is whether we'd want to make all the material colors available based on MaterialColor as the context type.

I'd expect all material colors to be provided by a given scope already (Colors in 'material.dart'). It would then be possible to use MaterialColor? primarySwatch = .amber in Colors to enable abbreviations of material colors passed as actual arguments.

For the default value itself, it would make sense to say that it gets the same treatment as the actual arguments, which allows us to use .amber.

tatumizer commented 4 months ago

It would then be possible to use MaterialColor? primarySwatch = .amber in Colors

It all depends on your definition of "in" `:-) Not every color listed in Colors is a MaterialColor. And not every color listed in CupertinoColors is an actual CupertinoDynamicColor (assuming the same problem exists in cupertino libraries).

Maybe we have to talk more about this idea

// Add every static member of `A` to `B`.
static extension PushAStuffToB on B {
  static export A;
}

But I'm not sure about "every static member". It might be more complicated than that. Maybe "every static member that satisfies a certain criteria". And why limit this feature to extensions? Suppose MaterialColors defined the subset of colors that are really material colors. Then we could say in "MaterialColors": static export (to?) Colors - directly in the class definition, no extensions required. How about that? (Again, maybe the criteria should be more specific, not sure)

(Some restrictions apply here, lest people start cross-pollinating indiscriminately)

eernstg commented 4 months ago

@Abion47 wrote:

How would this example work?

enum E { one, two, three, four }

abstract final class EvenE {
  static const two = E.two;
  static const four = E.four;
}

void foo(E e in EvenE) { ... }

void main() {
  E e = .one;
  foo(e); // Fine or syntax error?
}

(It would be E e in EvenE, yes on EvenE was probably my typo initially. Next, I put the code at the end into a main to ensure that it doesn't have spurious compile-time errors.)

E e = .one; would be fine. foo(e) would be fine as well. We aren't using the default scope mechanism at all (there are no abbreviated terms in foo(e)). We're just checking that e has static type E, which is also the parameter type of foo.

a not-statically-safe feature

The default scope mechanism is only about defaults, that is, which values are particularly easy to denote, presumably because it is particularly relevant to use exactly those ones. So you can use the abbreviated forms with two and four, but you can always choose to pass any object of the specified parameter type.

If you want to prevent certain values from being passed as actual arguments to foo then you should use a type. We've discussed a few times how you could create a typing structure on enumerated types, but there is no really smooth solution (that I know of).

// Why can't we just do this?
enum E { one, two, three, four }
enum EvenE extends E { two, four }
enum OddE extends E { one, three }

One reason why we can't just do this is that the indices of values of E would be 0, 1, 2, and 3, but the indices of the values of EvenE should be 0 and 1, but then we can't have identical(E.two, EvenE.two). It isn't that easy to create enumerated types that are related by subtype relations that correspond to subset relations between their values.

But that's a completely different topic, so let's take that another time.

And at this point, the code is definitely no longer KISS-compliant.

Agreed. I wasn't trying to mimic your example that precisely, I was just illustrating the fact that it is possible to give access to several different types of objects in a hierarchy, yet only reveal the root of the type tree to clients.

reassigning a in the middle of the upcasted block risks breaking the upcasting.

I don't think it matters here, but it is actually a downcast (we have established that a is actually a B, previously we only knew that it was an A, and B is a subtype of A).

But the point is that promotion can give a local variable or parameter different types at different locations in the same class body, and it seems error-prone to me if we use the promoted type to select a static method or a constructor.

Anyway, we can always lint it ("using_promoted_type_as_default_scope").

I'd argue that the ambiguous cases regarding shorthand are far lesser in both quantity and severity than the ambiguous cases regarding your proposal

That's very interesting, and rather surprising! :smile:

What is 'shorthand'?

My starting point was very substantially less ambiguous and more restricted, and I've been adding support for more and more ways to obtain a default scope, because of the strong sentiment a la "it should work here, too".

Do you have an example where the proposal in this issue gives rise to ambiguities that wouldn't arise with the alternatives you have in mind? For example, an approach that simply says "in context T, transform .id to `T.id" is very nearly the same thing as the rule I've called "the catch-all rule", and the proposal in this issue consists of that plus some more restricted rules.

@tatumizer wrote:

Not every color listed in Colors is a MaterialColor

They don't have to have that type.

For example, Alignment contains Alignment.lerp that returns Alignment?, so we have already embraced the possibility that some members in a default scope have a type which is different from the "standard target type" for that default scope (here: Alignment).

So when you're using a default scope you may need to adapt the result you found (and use .lerp(a, b, t)! rather than just .lerp(a, b, t)). It's even more radical: .lerp as such is a tear-off of a static method whose type is a function type, which is very different from the target type Alignment.

So having a member in Colors whose static type isn't MaterialColor will influence the ways that you can use said member (or perhaps can't use it), but it's not a new thing—this discussion has included elements of that nature all the time.

Maybe "every static member that satisfies a certain criteria". And why limit this feature to extensions?

Certainly, the export mechanism in https://github.com/dart-lang/language/issues/2506 needs to be discussed and developed if we conclude that we really need such a thing.

The proposal would certainly be applicable to classes, static extensions, extension types, anything that has members. The outline of a proposal in #2506 includes a couple of features: You can specify members in terms of a list of names to include, or a list of names to exclude (show, hide), or in terms of interfaces (show I would work just like show m1, m2, ... mk if I is an interface type with members named m1, ... mk).

eernstg commented 4 months ago

(I put a thumbs-up on my own proposal, just to make sure it isn't 100% thumbs-down :grinning:)

tatumizer commented 4 months ago

@eernstg : thanks, I will read #2506. Just one remark for now: whenever we have "export", we must also have "import" - if only for symmetry. But in this case, it's not "only" for symmetry. It's much safer to import than to export. Someone you are exporting to must first agree to accept your export as its import. :-). That's exactly what happens with "export-import" of libraries. Nobody can export anything directly to your application. You must import something for their exported stuff to be available.

Class Colors can say: import stuff from MaterialColors. It's safe (at least not less safe than any kind of import). And maybe we do not need "export" at all here: to make every kind of color available as dart:ui Color, we can create N extensions on Color, each importing from a different place (one from MaterialColor, another from CupertionColors, etc).

It all looks rather straightforward as long as we are talking only about the constants (and possibly not even all constants, but let's simplify for now). But when we start importing all static stuff, the logic breaks down. There are several color subclasses that implement lerp as a static method (e.g. ColorSwatch implements Color and has its own lerp). Their definitions will clash.

Even the constants can clash. There's white in CupertinoColors, and white in (Material) Colors - and though the values are identical, this is not necessarily so in a general case.

EDIT: here's an example of irrelevant import-export. static const List<MaterialAccentColor> accents is defined in Colors class. It doesn't make much sense to export it to dart:ui Color. While defining an extension on Color, we have to be able to say "import only the constants/methods with the return types assignable to Color"

tatumizer commented 4 months ago

(Cont-d) TL;DR: to save time, skip to "strawman" example.

I browsed through #2506. The situation there is a bit different. Originally, this feature was discussed as a variant of show/hide clause, but the idea didn't make sense for extension types: it wasn't clear whether the result of any "shown" method would be automatically wrapped or left intact as a representation type. It's impossible for a compiler to decide whether the result of the "imported" operation should, or should not, be wrapped. (The fact that the result is int doesn't say much about it). And this "export" thing is the same "show" under a different name and with a different syntax.

The case of static members is (intuitively) simpler. If we had a constant MaterialColor.deepPurple, which is indeed a Color, then there would be no reason to disqualify it from being available through Color.deepPurple.

But imagine that MaterialColor had another constant called values as a list of all material colors defined in the class. It would be a mistake to implant it to the Color class (no matter how you implement the implantation - via import or via export). So the working hypothesis is that while importing stuff, we should select only the constants/methods with the correct type (that is, assignable to the base class).

Everything looks logical until we reach Colors. Colors is not a subclass of Color, so there's no indication that any MaterialColor color constant must be available in Colors.

The way out is (probably) to do the same as we do for library imports/exports. Here's a strawman:

class MaterialColor extends Color {
   const blue = MaterialColor(...);
   //...
  export static { blue,green, /* etc */ } as MaterialColorSet;
}
class Colors {
   //...
   from MaterialColor import MaterialColorSet;
   from ColorSwatch import SwatchColorSet;
}
static extension GoogleColorSet on Color {
    from MaterialColor import MaterialColorSet;
    from ColorSwatch import SwatchColorSet;
}

(As a bonus, we get a hint of a nicer syntax for library imports (#649): from flutter import 'material') :-)

EDIT: please think of the idea of "static mixins". E.g.

static mixin MaterialColorSet {
   const blue = MaterialColor(...);
   //...
}
class MaterialColor with static MaterialColorSet {
   //...
}
class Colors with static MaterialColorSet, SwatchColorSet {
   //...
}
Abion47 commented 4 months ago

@tatumizer

Because the type of primarySwatch is MaterialColor, there would need to be an extension on MaterialColor to make shorthand of Colors work on this property, yes.

So now every color currently defined in Colors should be copied into (at least) two extensions: one on Color, another - on MaterialColor? For some reason, flutter did now want to put the colors from Colors class into MaterialColor, so this move will be equivalent to re-designing flutter, and it's not clear whether flutter people want every Material color be available as Colors.red OR as MaterialColor.red OR as Color.red. And this, as you observed, is just the proverbial tip of the iceberg. BTW, CupertinoColors is similar to Colors: most of the constants there are of type CupertinoDynamicColor, but some are simply Colors. Now every cupertino color will be also available from 3 places. Flutter is so huge that the scope of the problem is unknown.

Can it be that swift's "implicit shortcuts" feature was designed in sync with their libraries (which are not multi-platform AFAIK)? This is not so in dart/flutter world.

That's exactly what happened. Swift's APIs were designed with implicit shortcuts in mind, and Flutter wasn't. "Redesigning Flutter" is a bit hyperbolic, though, as we are just talking about extending existing types with related static members. It's not like we are talking about actually moving the definitions from Colors to Color/MaterialColor and what not.

And yes, this is why I say that this effort is best left to the community, because:

  1. This is a monumental undertaking which will take the Flutter team's attention away from other more important things.
  2. There is far from a consensus on how the Flutter library should be extended (if at all) to support shortcuts.
  3. Implementing the extensions in an external package means people can import only the parts they want.

And for conflicts like MaterialColor vs CupertinoColor, as I said before, that can be alleviated by having the extensions loaded from separate imports. And frankly, this is only a problem if people are importing both flutter/material and flutter/cupertino in the same file which they really shouldn't be doing anyway.

Class Colors can say: import stuff from MaterialColors. It's safe (at least not less safe than any kind of import).

Except you've just created a dependency of Colors to MaterialColors, which goes back to the whole issue of how to import Colors into Color given that we cannot say class Color { import * from Colors; } without making dart:ui depend on flutter/material, and that will never happen. A proactive approach will not work here - the root types cannot be forced to manually specify what other types contain compatible values. Retroactive extensions are the only way this can work.

@eernstg

Another case where the context type can be tricky to keep track of is when a generic function is invoked and an actual type argument is inferred, and a parameter type uses the corresponding type variable.

X f<X>(List<X> xs, X x) => x;

void main() {
  var x = 2 > 1 ? 1.5 : 2;
  f([x], .parse('whatever')); // `int.parse` or `num.parse`?
}

I don't think it would be ambiguous at all - x is a num here, and the first argument establishes a context with [x], so the .parse here should resolve to num.parse. In fact, given how unambiguous it is that the type of x is num, I don't even see a world where it would attempt to resolve to int.parse, and if the inference decides to be problematic, it's more likely to attempt to do Object.parse (or, somehow, dynamic.parse).

That said, if there is any ambiguity in this case, it stems far more from the ambiguity of type inference where generics are involved than of the shorthand syntax itself.

E e = .one; would be fine. foo(e) would be fine as well. We aren't using the default scope mechanism at all (there are no abbreviated terms in foo(e)). We're just checking that e has static type E, which is also the parameter type of foo.

Then see my thoughts about it being fine. E e in EvenE implies that the function only expects parameters that are defined in EvenE, and to have that not be the case is counterintuitive. This kind of syntax gives the strong impression that it is related to the type system, so the fact that it isn't will be a major source of confusion.

For instance, I can fully see someone looking at this code and not knowing what's wrong with it:

enum Number { zero, one, two, three }

class NonZeroNumbers {
  static final one = Number.one;
  static final two = Number.two;
  static final three = Number.three;
}

num divide(Number divisor, Number quotient in NonZeroNumbers) {
  return divisor.index / quotient.index;
}

void main() {
  divide(Number.one, Number.two);
}

It's confusing because the in NonZeroNumbers reads like its creating a restriction on the type system itself (i.e. what values are allowed to be passed at all), not on what values are allowed to be used for the shorthand syntax. Someone who just happened upon this code would not see that whoever wrote this code simply had a fundamental misunderstanding on what in NonZeroNumbers actually does, and the fact that the code works as expected would reinforce that misunderstanding. They might even leave with the entirely reasonable but erroneous assumption that trying to do divide(Number.one, Number.zero) would result in a static error.

But the point is that promotion can give a local variable or parameter different types at different locations in the same class body, and it seems error-prone to me if we use the promoted type to select a static method or a constructor.

One thing to consider is that the shorthand syntax is for accessing static members, not instance members. As such, having the shorthand respect the downcast makes no sense:

class A {
  static A foo() { ... }
}
class B extends A {}

void main() {
  A a = .foo();
  if (a is B) {
    a = .foo();
  }
}

Respecting the downcast of A from B makes no sense because, as we are dealing with static members of types, there is no guarantee that just because A.foo exists, B.foo will also exist (which, in this case, it doesn't). Furthermore, the valid contextual type of whatever is assigned to a is A, and the fact that the type of the current value of a is B doesn't change that. For instance:

void main() {
  num a = 1;
  print(a.bitLength); // Error: The getter `bitLength` isn't defined for the type `num`.
  if (a is int) {
    print(a.bitLength); // Fine
    a = 1.5;            // Fine
    print(a.bitLength); // Error: The getter `bitLength` isn't defined for the type `num`.
  }
}

The type downcast only affects the contextual type in the context that a is known to be an int after the downcast, so any statements that would've otherwise failed if a was a num are now successful. However, the statement a = ?? is a completely different matter - just because a is known to be an int at this time doesn't affect what the contextual type of ?? is. As such, not only is it entirely valid to assign a non-int num to a in this scenario, doing so undoes the downcast and a is once again treated like a num.

What is 'shorthand'?

Shorthand syntax, dot syntax, implicit shortcuts, whatever we want to call A a = .foo;.

tatumizer commented 4 months ago

Except you've just created a dependency of Colors to MaterialColors

They reside in the same library material.dart, so it's not really a dependency. Colors class depends on MaterialColor today, so nothing changes.

Please see my last post with the strawman of a new syntax that addresses the issues you mentioned.

lrhn commented 4 months ago

As such, having the shorthand respect the downcast makes no sense:

That's not new to this feature. Whether the right-hand side of an assignment to a promoted variable should use the promoted type as context type or not, is a question as old as promotion. (So, not that old, but still not something with a clear-cut answer.)

  void foo(num n) {
    if (n is double) n = 1;
    // ...
  } 

Here, if we use the promoted type, double, as context type, the value becomes the double 1.0, if not, it becomes the int 1. What one is right? It's impossible to know, at least on a one-pass type inference algorithm, because we can't read the intent for future values from the prior code.

Dart currently uses the promoted type as context type, but allows a result as long as it's of the declared type.

Whatever we do for static access shorthands should be compatible with what we do in general, it's not something that needs to be designed from scratch for this feature. Whether it makes sense or not, it should just follow current behavior.

(Also, consider a design where MaterialColor is an extension type on Color, implementing Color, and you do.

  someColorVar as MaterialColor;
  // ...
  someColorVar = .green400;

where you promote a variable to a special extension subtype of colors, and then static access shorthand uses the promoted color for the constants. Looks somewhat meaningful that way.

rrousselGit commented 4 months ago

(Also, consider a design where MaterialColor is an extension type on Color, implementing Color, and you do.

  someColorVar as MaterialColor;
  // ...
  someColorVar = .green400;

That's a compelling example.
I guess the case of upcasts is one where we'd just hit the bullet, and maybe make a lint rule for it then.

So to answer to @eernstg:

That's the kind of thing I'm worried about: We're creating a strong connection between the static type of a local variable (or anything that could provide a context type) and the selection of a static member or constructor.

In that case following what @lrhn said, we wouldn't need to exclude upcasts.

I agree that it's a weird case. But tbh the example given sounds extremely niche:

I wouldn't over-worry about that. We have similar issues with other language features too. I'm still bugged by how extension tear-offs are never == :shrug:

I'd rather keep the feature simple, instead of defining a complex syntax to handle edge-cases that are going to happen once every eclipse.
The in Type thing feels like it adds way more complexity to the language IMO

eernstg commented 4 months ago

@tatumizer wrote:

whenever we have "export", we must also have "import"

If we're thinking about "import" and "export" as operations on namespaces then the semantics would be something like this:

Note that an imported declaration can be used in the body of the importing entity E, but it doesn't get exported to clients. If we want to do that then we need to export it.

As I see it, this means that we'd get the following example behaviors (focusing on importing static members):

class A {
  static a() {} 
  static a2() {}
}

class B {
  static import A hide a2;
  /* We can call `a()` here. Same thing as `A.a()`. */
}

void main() {
  var b = B();
  b.a(); // Error, imported names are not re-exported automatically.
}

This basically means that we can avoid mentioning the class name A when we use static members of A in the body of B. I don't think this is what we need.

Exports are used to make bindings available to clients:

class A {
  static a() {} 
  static a2() {}
}

class B {
  static export A hide a2;
  /* Library-like semantics would say: No changes to the local namespace. */
}

void main() {
  var b = B();
  b.a(); // OK.
}

I must admit that I combined the semantics of import and export because the export mechanism I'm proposing (or at least vaguely hinting at ;-) in #2506 will add the binding to the namespace of the entity E that has the export clause such that it is available for both internal references (from the body of E) and for clients.

In any case, it seems more consequential that the name is made available for clients. The fact that the name is available to the body of E may be convenient, but if you are allowed to write static export Something; in the body of E then you can also just write Something.foo in every location in the body of E where you would otherwise have used the export by writing foo.

All in all, I think export is a somewhat imprecise word to describe this mechanism, but I think it's slightly better than import. We can of course use a different word if someone comes up with a better idea.

The nature of this mechanism is, in any case, that it allows us to get the same effect as a (potentially large) number of forwarding declarations, e.g., static const foo = Something.foo; static const bar = Something.bar; in the case of variables.

It's much safer to import than to export.

I don't know if I understand you correctly, but I do see a connection to extensibility.

The purpose of the static extension mechanism is extensibility in the area of declarations that are either static members or constructors.

Perhaps you're referring to static extensions when you say 'export'? That would make sense in terms of a natural language interpretation: "If I export the declaration named foo to TargetClass then it can be used as TargetClass.foo".

But that's exactly what a static extension will do, and the export mechanism of #2506 doesn't do anything that fits this description.

class TargetClass {}

static extension on TargetClass {
  static void foo() {}
}

void main() {
  TargetClass.foo(); // OK.
}

You can use this export mechanism, but in that case you must have editing rights to the target:

class TargetClass {
  static export SourceClass.foo;
}

class SourceClass {
  static void foo() {}
}

void main() {
  TargetClass.foo(); // OK.
}

The crucial property is extensibility: When you use a static extension you can populate the static namespace of TargetClass (which could be Color or Colors) without having editing rights on the target. This is crucial because developers in general do not have editing rights to Color or Colors, and it is arguably useful to empower such developers to populate those classes (that are just considered as static namespaces here) with the colors that they want to have at their fingertips. They can write their own static extensions, or they can import something like

static extension MaterialColors on ... { // `on Color` or `on Colors`, whatever is adopted.
  ... // Lots of `static const colorName = MaterialColor(....);`
}

This means that clients have a lot of control: They can import 'material.dart' and have Color or Colors populated with a bunch of material colors, or they can get a bunch of cupertino colors by importing 'cupertino.dart', and the colors that they haven't imported won't disrupt the workflow in their code because they are not there. So if you're using cupertino colors only then there won't be any material colors during completion.

In contrast, if we use an export then there is no extensibility. Worse, dart:ui would now depend on both 'material.dart' and 'cupertino.dart', which is presumably a completely unthinkable situation:

class Color { // Or `Colors`.
  static export MaterialColors hide accents;
  static export CupertinoColors; // Probably hide something.
}

This is how I understand you when you say that "it's safer to import than to export". So the "import" is really spelled export, but the important fact is that the target entity (ui.Color or ui.Colors) is fetching the color declarations from elsewhere. I don't think that's going to work.

we can create N extensions on Color, each importing from a different place (one from MaterialColor, another from CupertionColors, etc).

I wasn't sure how that would work, or how it would conform to the following:

Someone you are exporting to must first agree to accept your export as its import

To me it really sounds like that 'accept' is a dependency, and we just can't allow dart:ui to depend on 'material.dart'.

we have to be able to say "import only the constants/methods with the return types assignable to Color"

We can put a lot of expressive power into the filtering part of an export mechanism. I would prefer to have a declaration like

static extension MaterialColorsStaticExtension on Color-or-Colors {
  ... // Declare all the material colors as constant variables
}

abstract final MaterialColors { // 
  static export MaterialColorsStaticExtension;
  ... // declare `accents` and other things that we don't want to inject into `Color`/`Colors`.
}

MaterialColors should contain the material colors and nothing else. This means that we don't need smart filtering mechanisms when we use this static extension to populate ui.Color or ui.Colors.

The class MaterialColors would then remain backward compatible. You can access the material colors as Color.myMaterialColor and also as MaterialColors.myMaterialColor (such that existing references like that won't break).

But imagine that MaterialColor had another constant called values as a list of all material colors defined in the class. It would be a mistake to implant it to the Color class

We would not declare values in MaterialColorsStaticExtension, it would be declared in MaterialColors.

Colors is not a subclass of Color, so there's no indication that any MaterialColor color constant must be available in Colors.

I don't think it would help if we turn Colors into a subtype of Color, we do not search all subtypes of anything in order to find a static member.

So if we're using an entity ui.Colors (perhaps an abstract final class) to hold all the colors that we wish to have at our fingertips then it would have to be declared explicitly (so we would have parameters like Color color in Colors).

If that's considered intolerable then we would just put all those colors into Color and rely on the context type (and no parameter declarations or anything like that would need to say where we will find the default scope: It's simply the context type).

Here's a strawman:

class MaterialColor extends Color {
   const blue = MaterialColor(...);
   //...
  export static { blue,green, /* etc */ } as MaterialColorSet;
}
class Colors {
   //...
   from MaterialColor import MaterialColorSet;
   from ColorSwatch import SwatchColorSet;
}
static extension GoogleColorSet on Color {
    from MaterialColor import MaterialColorSet;
    from ColorSwatch import SwatchColorSet;
}

The idea about a static mixin is interesting. It might be overkill, though, because static members only need simple namespace manipulations (union-where-the-left-operand-wins-name-clashes and such), and there is no need for management of override relationships and other extras that are specific to instance members.

It looks like the benefit you derive from being able to define MaterialColorSet is the same as the benefit that we'd get from putting all those material color declarations into a separate namespace (e.g., an abstract final class):

abstract final class MaterialColorSet {
  const blue = MaterialColor(...);
  ...
}

// `SwatchColorSet` is similar.

class MaterialColor extends Color {...} // Same as today.

class Colors {
   //... /* eernstg: I'm not sure what this is. I'll just leave it unchanged */
   static export MaterialColorSet;
   static export SwatchColorSet;
}

static extension GoogleColorSet on Color {
    static export MaterialColorSet;
    static export SwatchColorSet;
}
eernstg commented 4 months ago

@Abion47 wrote:

this effort is best left to the community

Note that if the community wants to switch to an approach where concrete colors are static members of Color (and abbreviations are thus enabled in every location where the context type is Color) then it is possible to take a lot of steps in that direction without any involvement from the Flutter team.

I'll assume that we have static extensions and #2506 exports.

// We currently have this namespace providing material colors in 'material.dart':

abstract final class Colors {
  static const Color transparent = Color(0x00000000);
  ...
  static const MaterialColor red = MaterialColor(...);
  ...
}

// A developer puts the following into a library 'my_colors.dart'.

import 'material.dart' as material;

static extension MyColors on Color {
  static export material.Colors;
}

Now, said developer can simply import my_colors.dart and 'material.dart' into any library where they want to have abbreviated names for material colors in every location that has context type Color. No need to ask the Flutter team to do anything.

If we don't have #2506 exports then we can still get the same effect by writing a number of forwarding declarations in MyColors along the lines of static const transparent = Colors.transparent; static const red = Colors.red;

About the flimsiness of relying on promoted and inferred types:

I don't think it would be ambiguous at all

Right, it is not technically ambigious, but I'm claiming that it is hard for a human reader to track down the semantics of the term .parse(...): In the example, we need to find the references to x in the previous lines of code to see what it's type is (this might involve promotions, and the type of x could be inferred from the type of an initializing expression, so we might need to look up each part of that initializing expression before we can know the declared type).

Next, we need to know the declaration of f in order to understand that it is a generic function with a type parameter X, and the type of the first parameter is List<X>. With that in mind we might conclude that x has type num, so the parameter type is inferred as List<num>, so the value of X is num, so the context type of the second parameter is num.

So it's num.parse.

If we haven't messed up one of those steps of reasoning. And if somebody edits 1.5 and changes it to 2 then we're calling int.parse rather than num.parse.

The crucial point is that there is no overriding among static members. So if we think we're calling int.parse and we are actually calling num.parse then it is a completely unrelated function, it is not just two different ways to express that we want to call the same function with different static types (which is a correct description of the interaction between promotion and instance member invocations).

I'd expect to have a lint to warn against using promoted and inferred types to select static members (or constructors). It's too flimsy, you can't maintain code that does this.

E e in EvenE implies that the function only expects parameters that are defined in EvenE

That's not true. Just like E e = E.one doesn't imply that the only possible actual argument is E.one. It's a default (a default scope respectively a default value), and the set of allowed arguments is specified by the type of the parameter.

You might say that the choice of keyword, in, is misleading. You might also say that = is misleading for default values, there's nothing inherently defaulty about =. I can understand that. But I'd expect in EvenE to be preferred over something like default in EvenE as soon as we get used to this notation.

I certainly agree that self-explanatory constructs are better than constructs that must be explained when first encountered, all other things being equal. But I don't believe that self-explanatory constructs exist in the real world, we always have to learn what any given language construct does before we can use it productively.

For an in clause on a parameter, we just need to learn that it introduces a way to find named entities (values, functions, constructors) that are deemed relevant to call sites by the developer who put that clause on the parameter. We're not preventing anything which could previously be done with that parameter, we are simply allowing some abbreviated terms to be used, namely the ones that we can find in that (or those) default scope(s).

Respecting the downcast of A from B makes no sense because, as we are dealing with static members of types, there is no guarantee that just because A.foo exists, B.foo will also exist

Sure, that's my point: It is dangerous to use the context type to do static lookups when that context type is an inferred or promoted type. For example, the change from num to int has no semantic significance for any instance member which is included in the interface of num (if we call, say, floor() or toString() then it will run the exact same code when the receiver has type num and when it has type int). But the fact that num.someStaticMember exists does not imply that there is a member int.someStaticMember, and it's even worse if it does exist, because it's just a completely different declaration, with no relation to num.someStaticMember.

However, not respecting the downcast (from num to int or from A to B) is a special exception, so we'd need to have special rules about how to process the context type before we get to the type that we will use as the default scope for static member lookups.

I would prefer to make it an error in the first place, because it's too flimsy. Then we'd simply write this, and know what we get:

class A {
  static A foo() { ... }
}
class B extends A {}

void main() {
  A a = .foo(); // OK, the type `A` is evident here.
  if (a is B) {
    a = A.foo(); // A promoted type does not enable `.id` lookups.
  }
}

Shorthand syntax, dot syntax, implicit shortcuts, whatever we want to call A a = .foo;.

OK, so 'shorthand' is a reference to a feature that is included in this proposal as well as (I think) all other proposals, e.g., this one.

rrousselGit commented 4 months ago

Right, it is not technically ambigious, but I'm claiming that it is hard for a human reader to track down the semantics of the term .parse(...): In the example, we need to find the references to x in the previous lines of code to see what it's type is (this might involve promotions, and the type of x could be inferred from the type of an initializing expression, so we might need to look up each part of that initializing expression before we can know the declared type).

Note that folks asking for this feature agree with this. The reason they don't mind is: We're not trying to use this new syntax everywhere. We only want the option when it's a reasonable improvement.

That'll usually translate to only using in on named parameters or inside case expressions or pattern matching, or when the function name is clear enough to understand what's happening.

fn(.parse(json)) is unlikely to pass code-reviews:

On the flip side, setBackgroundColor(.fromRgb(128, 10, 42)) is fine. Yet semantically, both expressions are very similar.

I'd blame the example, not the feature :)

eernstg commented 4 months ago

@Abion47 wrote, in response to a comment from me:

That's the kind of thing I'm worried about: We're creating a strong connection between the static type of a local variable (or anything that could provide a context type) and the selection of a static member or constructor.

In that case following what @lrhn said, we wouldn't need to exclude upcasts.

Consider the example again:

  someColorVar as MaterialColor;
  // ...
  someColorVar = .green400;

I think that's equally problematic. Perhaps green400 is one color in Color and a different color in MaterialColor. In that case you'll have to track down the control flow and the conclusions that you can reach about the type of someColorVar at this particular location in the code before you will know that .green400 means "look it up in MaterialColor, not in Color.

The point is again that (1) you will silently get a different color than the one you expect (so your app will look funny, and there's no hint that anything is wrong), or (2) you will get a compile-time error because you want to use MaterialColor.green400, but you're actually trying to use Color.green400 (or SomethingElse.green400), and there is no such declaration.

In short, we're transferring the relatedness between instance member lookups using subtype related receiver types to a postulated, but non-existing relatedness between static members of said types, and that's worrying.

the example given sounds extremely niche:

  • Using .identifier with variables is already going to be a bit niche.
  • For this to happen, we'd have to first assign a value to the variable, upcast it, only to reassign it again. Variables usually are final. Upcasting a variable for the sake of reassigning it is even more niche.

I just changed the proposal to have a warning in the case where .identifier is transformed into T.identifier where T is a type that is obtained by promotion or type inference.

So developers can do it all they want, but we will tell them that they're on thin ice. It could be a lint, such that it is optional, or it could be a --strict-something option to the analyzer, or it could just be a regular warning which is on by default. That's a separate dog fight. ;-)

I don't think the situation is particularly unrealistic: Promotion probably occurs in every single function/method/constructor body that contains is or as. It's all over the place, even if we don't see it.

The reassignment is not unrealistic, but also not necessary. I mentioned another example where the promoted type is propagated (based on type inference) to an expression using .identifier. This also happens all over the place.

Finally, if we get a warning then it will hopefully be very, very rare. ;-)

I'd rather keep the feature simple, instead of defining a complex syntax to handle edge-cases that are going to happen once every eclipse.

Agreed. That's exactly the reason why I prefer to introduce a warning (an error would be better, but I'll take what I can get) such that they won't unknowingly introduce this kind of unmaintainable code. This is better than introducing complex extra rules about how to compute the default scope type based on any given context type when it has been obtained using promotion or type inference.

rrousselGit commented 4 months ago

In general, I'd say .identifier should only be used when an enclosing identifier has the type in its name.

Container(backgroundColor: .red) is fine. If we write Colors.red, the word Color appears twice. It's redundant.
But return .new(...) isn't. We have no idea what's created from this expression alone.

So IMO if we make a lint rule, it'd be about whether expanding the expression adds a word duplicate or not.

rrousselGit commented 4 months ago

More on the lint rule idea.

Given:

class CamelCase {
  static const identifier = ...
}

We'd have:

With such a rule, casts and upcasts would naturally trigger a warning.

eernstg commented 4 months ago

More on the lint rule idea.

Interesting!

It might be difficult to determine whether or not the given parameter type is actually implied by any given identifier.

I was thinking that a type which is declared explicitly as the type of a parameter (say, of a top-level or static function, or a constructor) is "official" and "well-known", and this means that we can rely on it to look up static properties.

When the type is taken from the type of a local variable (promoted or not), it's a lot less "official", but it might still be "well-known". Nevertheless, a type which is obtained by promotion is certainly not easy to find by looking at the code, it's basically only realistic to find such types in general by using an IDE and hovering on the variable at that location. So that's not boding so well for "well-known", either.

In any case, we can introduce the mechanism with a specific semantics (even in questionable situations), and then we can add warnings/lints as needed as an ongoing effort, in order to make it work safely and conveniently in practice.

tatumizer commented 4 months ago

@rrousselGit

Container(backgroundColor: .red) is fine. If we write Colors.red, the word Color appears twice. It's redundant.

This example may not be representative. Color. is redundant just because "red" means "red" everywhere. If material's "red" was different from cupertino's "red", it wouldn't be redundant. Consider:

Container(backgroundColor: .tertiarySystemBackground) // no indication of Cupertino
Container(backgroundColor: .deepOrangeAccent) // no indication of Material

The unabbreviated equivalent is:

Container(backgroundColor: CupertinoColors.tertiarySystemBackground)
Container(backgroundColor: Colors.deepOrangeAccent) 

Colors and CupertinoColors come from two different worlds: Colors lives in Google's Material world, but CupertinoColors - in Apple's cupertino. Ideally, Colors should have been called MaterialColors, and then the invocation Container(backgroundColor: MaterialColors.deepOrangeAccent) would not look that redundant. For someone, the distinction might be quite important.

I don't know what happens in real life. Maybe some people are not using hardcoded colors at all, but select them from the theme like: color: Theme.of(context).colorScheme.onPrimary The shortcuts won't help at all here.

Maybe the shortcuts should not be a language feature. IDE could provide some suggestions when you type a leading . followed by CTRL-SPACE, and then substitute the full name? The suggestions could be based on some heuristics; they can't be 100% perfect, but no one expect that from the "suggestions".

rrousselGit commented 4 months ago

This example may not be representative.

I don't quite agree.

I agree overall that it's not clear what the final syntax for Color specifically will be, due to the different subclasses. But technically speaking, Color.red does not exist yet, and I'm explicitly against having color: .red unwrap to color: MaterialColor.red.

As such, there's no case of MaterialColor.red vs CupertinoColor.red when considering Color color = .red. Either Color.red does not exist as a static member, or Flutter decided on what Color.red should be (such as 255, 0, 0) or users made their own extension. Regardless of the outcome, .red has a fixed answer.

That's one of the major reasons why I'm against Color param in MaterialColors for instance. That's what introduce the complexity.

rrousselGit commented 4 months ago

But overall, if we want an actual good Color example: User-defined themes

Container(backgroundColor: .myPrimaryBackground)

Using .red itself is probably not that common in apps. And personally, I'd have .red point to whatever the CSS equivalent is (which is independent from whatever design system we are using). Then keep MaterialColor.red / Cupertino.red

Folks will define their own constants.

tatumizer commented 4 months ago

@rrousselGit elaborating on user-defined shortcuts: The user (not flutter) will create a static extension on Color and define their favorite colors in any way they want. These colors can be defined as constants, static final values, static getters - whatever is allowed by static extension syntax. Then, ONLY these constants (*) will be available as shortcuts with the context type Color (unless someone creates another static extension on Color, but is easy to hide). Do the same with other problematic cases (Icons, etc). Problem solved? (To support this idea, dart has to implement static extensions as a general feature, and nothing else)

(*) plus a couple of constructors defined in Color class itself

rrousselGit commented 4 months ago

Then, ONLY these constants (*) will be available with the context type Color (unless someone creates another static extension on Color, but is easy to hide). Do the same with other problematic cases (Icons, etc). Problem solved?

I personally don't want to restrict it to constants. IMO any static member on Color should be accessible, as I explained previously.

For instance, some folks may want to create a Color.primaryOf(context) extension method, to hook things to Theme. Or create custom factories to decode a Color from somewhere else (such as an asset or a proprietary object).

But otherwise yes, that's the flow I'd like.

tatumizer commented 4 months ago

(I mentioned static getters) Interestingly, this is the simplest possible idea, which for some reason remained unnoticed. :-) 👍