Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Parameter default scopes #3834

Open
eernstg opened this issue May 24, 2024 · 143 comments
Open

Parameter default scopes #3834

eernstg opened this issue May 24, 2024 · 143 comments
Labels
enums feature Proposed language feature that solves one or more problems

Comments

@eernstg
Copy link
Member

eernstg commented May 24, 2024

In response to #357:

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

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

Introduction

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

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

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

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

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

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

enum E { e1, e2 }

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

void g(E e) {}

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

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

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

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

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

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

enum E { e1, e2 }

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

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

void f(Color c in Colors) {}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Proposal

Syntax

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

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

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

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

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

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

Static analysis

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This feature is implicitly induced in some cases:

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

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

Enumerated types

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

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

  bool operator ==(Object other in E);

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

Dynamic semantics

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

Versions

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

eernstg commented May 24, 2024

Checking this proposal against the cases in this comment.

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

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

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

Enums

Example 1: BoxFit

Use current:

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

Use with this proposal:

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

Definitions:

class Image extends StatefulWidget {
  final BoxFit? fit;

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

enum BoxFit {
  fill,
  contain,
  ...
}

Example 2: Alignment

Use current:

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

Use with this proposal:

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

Definitions:

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

class Flex extends MultiChildRenderObjectWidget {
  final MainAxisAlignment mainAxisAlignment;

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

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

Named constructors

Example 1: BackdropFilter

Use current:

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

Use with this proposal:

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

Definitions:

class BackdropFilter extends SingleChildRenderObjectWidget {
  final ui.ImageFilter filter;

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

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

Example 2: Padding

Use current:

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

Use with this proposal:

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

Definitions:

class Padding extends SingleChildRenderObjectWidget {
  final EdgeInsetsGeometry padding;

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

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

Static members

Use current:

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

Use with this proposal:

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

Definitions:

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

  final IconData? icon;
}

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

@rrousselGit
Copy link

rrousselGit commented May 24, 2024

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

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

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

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

void fn<T>(T value);

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

@eernstg
Copy link
Member Author

eernstg commented May 24, 2024

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

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

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

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

We could have some amount of tool support.

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

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

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

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

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

Finally, for the generic case:

void fn<T>(T value);

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

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

@cedvdb
Copy link

cedvdb commented May 24, 2024

This could be implied and the default

enum E { e1, e2 }

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

Which would be the same as

enum E { e1, e2 }

void f({E e}) {

@eernstg
Copy link
Member Author

eernstg commented May 24, 2024

This could be implied

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

@cedvdb
Copy link

cedvdb commented May 24, 2024

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

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

@jakemac53
Copy link
Contributor

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

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

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

@jakemac53
Copy link
Contributor

jakemac53 commented May 24, 2024

so no "in" introduction for now.

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

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

As a total straw man:

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

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

@Reprevise
Copy link

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

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

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

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

@lukepighetti
Copy link

lukepighetti commented May 24, 2024

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

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

enum MyEnum { foo, bar}

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

void fn(MyEnum x) => null;

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

@eernstg
Copy link
Member Author

eernstg commented May 24, 2024

@cedvdb wrote:

color: Colors.green should be green.

True, thanks! Fixed.

keep the dot . in front of the shorthand

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

@eernstg
Copy link
Member Author

eernstg commented May 24, 2024

@jakemac53 wrote:

users might want to be able to opt out

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

@tatumizer
Copy link

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

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

@Reprevise
Copy link

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

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

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

foo(padding: all(16));

@eernstg
Copy link
Member Author

eernstg commented May 24, 2024

@jakemac53 wrote:

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

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

@eernstg
Copy link
Member Author

eernstg commented May 24, 2024

@Reprevise wrote:

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

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

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

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

@tatumizer
Copy link

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

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

@cedvdb
Copy link

cedvdb commented May 24, 2024

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

Type 'on' | 'off'

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

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

@bernaferrari
Copy link

bernaferrari commented May 24, 2024

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

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

@jakemac53
Copy link
Contributor

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

@cedvdb
Copy link

cedvdb commented May 24, 2024

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

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

@tatumizer
Copy link

tatumizer commented May 24, 2024

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

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

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

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

@StarProxima
Copy link

How is this proposal different from this one?

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

@bernaferrari
Copy link

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

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

For me this is unbeatable:
image

@tatumizer
Copy link

tatumizer commented May 24, 2024

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

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

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

@StarProxima
Copy link

StarProxima commented May 24, 2024

@tatumizer

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

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

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

I would vote for the existing design proposal to address this issue #357.

@Abion47
Copy link

Abion47 commented Jun 3, 2024

OK, I concede this point. But I still find a bit awkward that the user doesn't have full control over the shortcuts. Those that come from Color itself - they are accidental, "unwanted" shortcuts. There's no good concept behind the whole thing. It's close, but we haven't reached the destination yet IMO.

This is a "meta-feature". It is based on a convention: resolve the shortcuts for class X in the namespace X (it can be a class X itself, or a static extension on X). We can adopt a different convention. E.g. resolve the shortcuts for X in a namespace XShortcuts. This will give us full control over the shortcuts.

Unwanted elements in the IntelliSense in the editor is a fact of life, and this feature will change nothing about it. Remember that all the shortcut feature does is allow you to omit the T from T.member. In practice, whether you type T. or just ., the list of suggestions at that point will be the same either way. And beyond IntelliSense? What does it even matter?

And for the record, I categorically disagree - the static members from the Color class are extremely wanted and useful in their own right, and I would entirely expect to be able to reference them as well:

class MyPalette {
  final Color background = .fromArgb(0xFF, 0xFF, 0xFF, 0xFF);
  final Color foreground = .fromArgb(0xFF, 0x00, 0x00, 0x00);
  final Color accent = .fromArgb(0xFF, 0x10, 0x98, 0xF7);
  final Color shadow = .fromArgb(0xFF, 0xB8, 0x9E, 0x97);
  final Color caption = .fromArgb(0xFF, 0xDE, 0xCC, 0xCC);

  static List<Color> createGradient(Color start, Color end, int steps) {
    final colors = <Color>[];
    final diff = 1 / steps;
    for (var i = 0; i < steps; i++) {
      colors[i] = .lerp(start, end, steps * i);
    }
    return colors;
  }
}

(The argument of whether it's better to do final background = Color.fromArgb(...) or final Color background = .fromArgb(...) is neither here nor there. They should both be allowed, and whichever one ends up being used would merely be a styling detail.)

Not only that, but including them is entirely within the spirit of the feature. Strip away everything else, and these shortcuts are simply a way to more conveniently access static members from the contextual type. And, frankly, that's all it should be - not only is everything else window dressing, it frequently unnecessarily complicates the entire issue. If shortcuts are nothing more than that and anything more is implemented using static extensions, that solves 99% of all issues people have - just maybe not in the exact way they wanted them solved.

@tatumizer
Copy link

tatumizer commented Jun 3, 2024

With the same amount of typing, you can write

final background = Color.fromArgb(0xFF, 0xFF, 0xFF, 0xFF);

vs your version:

final Color background = .fromArgb(0xFF, 0xFF, 0xFF, 0xFF);

So I don't find the justification you've given convincing.
(I don't have a strong opinion on that, and even if I had one, that would be immaterial anyway :-)

@Abion47
Copy link

Abion47 commented Jun 3, 2024

With the same amount of typing, you can write

final background = Color.fromArgb(0xFF, 0xFF, 0xFF, 0xFF);

vs your version:

final Color background = .fromArgb(0xFF, 0xFF, 0xFF, 0xFF);

So I don't find the justification you've given convincing. (I don't have a strong opinion on that, and even if I had one, that would be immaterial anyway :-)

I literally addressed this exact thing in my last comment:

(The argument of whether it's better to do final background = Color.fromArgb(...) or final Color background = .fromArgb(...) is neither here nor there. They should both be allowed, and whichever one ends up being used would merely be a styling detail.)

I also addressed this in another comment a while back where I brought up that it's not necessarily a given that the inferred type is the desired type. Consider this:

class Foo {
  factory Foo.bar() = Bar;
}
class Bar extends Foo() {}

void main() {
  final Foo a = .bar();
  final b = Foo.bar();
}

The types of a and b are not the same in this scenario. a will be of type Foo because it is explicitly specified so, but b will be of type Bar because that's the type inferred from the return type of Foo.bar(). If it was intended that b be of type Foo, the type must be explicitly stated, at which point typing Foo twice would be redundant.

In fact, here's a more relevant example using the MaterialColor factory constructor extension I mentioned before:

extension on Color {
  factory Color.material(int primary, Map<int, Color> swatch) = MaterialColor;
}

class MyTheme {
  var primary = Color.material(...);

  void updateTheme(Color newPrimary) {
    primary = newPrimary; // Error: A value of type Color can't be assigned to a variable of type MaterialColor.
  }
}

Oops, instead of making primary of type Color like intended, type inference made it a MaterialColor, which means I can't assign it any kind of color that isn't a MaterialColor. This would've been avoided if I had instead used var Color primary = .material(...);.

This is why the syntax final Foo a = .bar() needs to be supported, because there are edge cases in which it is necessary. And besides, it is more work to make it not allowed, so if anything, we would need a reason to not allow it. And I see no convincing reason why it shouldn't be.

@lrhn
Copy link
Member

lrhn commented Jun 3, 2024

but b will be of type Bar because that's the type inferred from the return type of Foo.bar()

The return type of Foo.bar is Foo, all constructors have their containing type as return type, even if they're forwarding factory constructors.
If you're seeing anything else, that's a bug.
Extension constructors should have their on type as return type. (Whether they include your arguments is an interesting question, but again they should probably work the same way as static access through type aliases.)

@eernstg
Copy link
Member Author

eernstg commented Jun 3, 2024

@tatumizer wrote:

I think the same can be expressed in a conventional language of union types

That's actually quite different: If the parameter type is a union type, say fn ((Color | Enum<Color>) color), then you can pass any expression whose type is assignable to Color and any expression whose type is assignable to Enum<Color>. Moreover, the available abbreviated forms are static members and constructors in Color as well as in Enum<Color>.

With fn(Color color in Something) there is no support for passing an expression of type Something. In that declaration, Something provides a namespace where we can look up static members and constructors, and there is no connection between the type Something and the type of the parameter.

This means that the default scope and the parameter type are decoupled, which is useful in the case where the parameter type is used for different purposes in different APIs. For example, you might want to use some named values as arguments to a certain parameter of type int, but it would probably not be a good idea to pollute the completion of every parameter of type int with those magic numbers—they are relevant to a few parameters of type int, not to all of them.

Fortunately, the concept of static extensions (proposed earlier for a different purpose), appears to be capable of accommodating our requirements (by pure luck).

Well, I did mention them already in the very first version of the initial comment on this issue. Static extensions are highly relevant (because they support extensibility), but the language team hasn't committed to add them to the language so I can't promise a 100% that we will get them.

@eernstg
Copy link
Member Author

eernstg commented Jun 3, 2024

@Abion47 wrote, about the syntax in E1, ... Ek:

it's ambiguous

Ah, of course! Thanks for catching that!

I adjusted the proposal to omit the support for multiple default scopes. This discussion has shown that this kind of feature is actually better handled via static extensions (the static extensions would then be used to populate a single default scope, possibly from multiple sources).

proactively extending types in function declarations: ...

Container({ Color color = Colors.transparent in Colors, CupertinoColors })

Proactive extensions have three major flaws.

I'm not sure what you mean by 'proactive extensions'.

The clause in Colors, CupertinoColors (apart from the fact that I just eliminated the support for multiple default scopes from the proposal) specify that Colors and CupertinoColors are default scopes for this parameter. That is, we can specify the name of a static member or constructor in those namespaces, and have the default scope added implicitly by the compiler (for example .red will be implicitly transformed into Colors.red).

I don't see how that could be described as 'extending types'...

First, they require the library developer to do the vast majority of the heavy lifting to get this feature to work at all.

The library developer can just choose to have a declaration that does not specify a default scope, for example Color color = Colors.transparent. The default scope will then be Color.

The ability to specify a default scope which isn't the parameter type allows library authors to have more control when they want it. Nothing is forcing an in ... clause on anybody.

On the other hand, I think it's quite likely to be a viable approach to use a separate declaration as the default scope for Color valued parameters in Flutter. For example, it could be named Colors and be located in dart:ui. This allows us to have a small and manageable name space in Color itself, and ui.Colors would provide abbreviated names for colors (and it wouldn't contain all kinds of other stuff).

It would presumably be empty as declared, and it would be populated by clients. They could add in the specialized colors like primaryOf which was mentioned earlier, or they could add in some material colors, or whatever they want.

Second, they don't allow user extension whatsoever

Static extensions can add static members to Colors exactly as easily as they can add static members to Color.

downstream library/package support is difficult if not impossible to achieve

If Flutter makes the choice to use Color as the default scope for colors then downstream libraries would just get the same for free by having Color as the parameter type.

If Flutter uses ui.Colors as the default scope then, presumably, it would be considered good style to use Color color in ui.Colors as the declaration of parameters of that type, because ui.Colors would then be assumed to provide access to whatever set of important colors the clients want to have in that namespace—not just in APIs declared by the Flutter team, but also by downstream libraries.

class Container extends StatelessWidget {
  Container({ Color color in Colors });
}

Container is in flutter/widgets. Color comes from dart:ui. Colors comes from flutter/material. There is no way to specify this extension without creating a three-way dependency between those libraries,

Obviously you wouldn't do that.

I don't think anybody ever mentioned the idea that a declaration like Container (which is intended to be usable with material colors and other entities, as well as cupertino colors and other entities) should give special preference to material colors. The import dependency from flutter/widgets to 'material.dart' is of course also unthinkable.

However, Flutter/widgets does already (and should) depend on dart:ui, so there's no problem in the fact that the declaration of Container refers to the type Color. So one possible approach would be to use Color as the namespace from where we obtain an abbreviated notation for specific colors:

> class Container extends StatelessWidget {
>   Container({Color color}); // The default scope for colors is `Color`.
> }

If Flutter makes the choice to use a separate declaration as the default scope for a set of colors then it would be declared in dart:ui. I've used the notation ui.Colors to refer to this declaration. It has no relationship with the Colors in 'material.dart' (that one is associated with material colors, the one in dart:ui is an empty namespace that clients will populate with the colors that they want to have abbreviated names for, including methods like primaryOf, if that's what they want).

Anyway, I'll use a different name for the thing that I've called 'ui.Colors', just to make it even more explicit that it is not the same thing as Colors in 'material.dart'.

// --- dart:ui

class Color {...}

class DefaultNameSpaceForColors {} // Empty, populated by static extensions.

// --- google3/third_party/dart/flutter/lib/src/widgets/container.dart

class Container extends StatelessWidget {
  Container({Color color in DefaultNameSpaceForColors});
}

Furthermore, because of the user extensibility issue, there's no way to add the extension in some tertiary package that depends on all of them, and even if there was, it would require making that package for every combination of Colors, CupertinoColors, and any other collection type of Color that exists, and it would have to be manually updated every time Flutter was. It would be a maintenance nightmare.

First, there is no reason whatsoever to have the dependencies that you mention. Next, it is no problem at all to populate the chosen namespace, no matter which approach you're considering.

If you are using Color as the default scope of colors:

// Third party library.
import 'dart:ui';

static extension on Color {
  ... // Put static members and/or constructors into `Color`.
}

If instead Flutter uses a separate namespace to hold colors then we'd do this:

// Third party library.
import 'dart:ui';

static extension on DefaultNameSpaceForColors {
  ... // Put static members and/or constructors into `DefaultNameSpaceForColors`.
}

Where do you see 'every combination of Colors, CupertinoColors, and any other collection type of Color that exists'? I don't see anything like that. There's no need to update something that doesn't exist, either.

Now, onto the responses.

I am not saying that there should not be an expression X ? Y : Z where Y and Z are different types

But then we are in complete agreement on the possible existence and even legitimacy of such expressions. This implies that we can also be justified in discussing the software engineering properties of expressions with that typing structure.

You seem to claim that we can only discuss language design decisions based on excerpts from production code, it's never appropriate to discuss code with the same structure that happens to lay out this structure using 10 times fewer lines of code?

specifying double and letting inference specify num are semantically different outcomes

That's true. You could argue that there's some semantic noise in the int/double based example because it involves the implicit coercion that makes 1 evaluate to a value of type double when the context type is double.

That would be an argument in favor of using examples where we declare a bunch of extra classes, just so we can illustrate a point about a typing structure without relying on numbers. It is going to be considerable more verbose, though.

The difference is that in all the instances you listed, the purpose of on in the statement is self-evident,

You don't mean 'familiar'?

For example, try ... on T obviously means "when the thrown object, if any, has a run-time type which is a subtype of T, execute the following catch block? And mixin M on C ... obviously means "whenever M is applied to a superclass as in S with M (and a slightly more complex rule for S with ... M ...), it is a compile-time error unless S implements C, and it is a compile-time error unless S has a concrete implementation of each member which is invoked in the body of M in a superinvocation"?

I tend to believe that every language mechanism comes with a certain burden of getting to know what it is and how it works.

In any case, it's very easy to change the keyword (or keyword sequence) in the default scope clause. I'm all for anything that works (and that discussion will include considerations about readability as well as conciseness).

About context types:

For instance, this code is perfectly legal today:

if (foo is Bar) {
  foo = Foo.create();
}

We don't have enough context to be able to say how this works. (No pun intended, of course ;-)

If foo is a local variable (and that includes formal parameters) whose type is Foo (declared, implicitly or inferred, possibly promoted) and Bar is a subtype of Foo, and foo is promotable (for instance, we can't assign a new value to foo in a function literal), then foo will have the promoted type Bar after foo is Bar in the control flow graph.

In particular, foo has the type Bar when it is assigned a new value using foo = Foo.create(). If Foo.create is a constructor or static member of Foo whose return type is Foo then the assignment will demote the variable foo, taking it back to an earlier type (that could be the declared type or one of the results of earlier promotions). So if foo is declared as Foo foo (and then possibly an initializing expression) then it will have type Foo after the assignment.

So the context type for the expression Foo.create() is Bar. It is not a compile-time error, but it will demote foo.

Not only would using the declared type be conceptually simple, I'd bet that it's the behavior that most people would expect.

Well, we should note that the declared type could very well be rather general. For example

class FooBase {}

class Foo extends FooBase {
  Foo();
  Foo.create();
}

class Bar extends Foo {}

void f(FooBase? foo) {
  if (foo == null) foo = Bar();
  if (foo is Bar) {
    foo = Foo.create();
    // `foo` now has type `FooBase`.
  }
}

What I'm saying is simply that (1) it is a non-trivial exercise to find the exact static type of foo at every step of this computation, and (2) classes used as static member namespaces are unrelated, which means that it really matters whether the static type of foo is FooBase or Foo (or Bar, for that matter) if we try to use its type to look up a static member.

I'm sure we can find some rules, and they might even work OK. I just think it's going to be too hard to reason about, and too hard to maintain, if we allow these flimsy types obtained from promotion or from type inference to be used for static member lookups. So I'm recommending that we warn against them.

someone might want to do something like this:

class State {
  State();
  factory State.working() = WorkingState;
  factory State.invalid() = InvalidState;
}

class WorkingState extends State {}
class InvalidState extends State {}

void main() {
  var state = getCachedState();
  if (state is InvalidState) {
    state = State.working();
  }
  initialize(state);
}

// Glue code, allowing the code above to compile.
State getCachedState() => throw 0;
void initialize(State _) => throw 0;

I don't see a problem, this is working code (when getCachedState and initialize are defined). The type of state is demoted to State by the assignment state = State.working(), and at initialize(state) the type is also State (two control-flow branches are joined, but they both have state at the type State).

If the shorthand syntax doesn't support this kind of scenario, that will feel like a strange and seemingly arbitrary omission in practice.

So you'd want state = .working() to be accepted, meaning state = State.working()?

First, I'd prefer to warn against this, thus effectively recommending an explicit State.working(), simply because the context type is a promoted type. I think state = .working() is too hard to read and not sufficiently maintainable, because promoted types are non-trivial to reconstruct during reading. Granted, perhaps everybody will be looking at code in an IDE where such types can be shown by hovering on the word state, but there will be situations where code is considered outside an IDE (e.g., during reviews), and code mustn't become completely unreadable in that situation.

Next, if we do embrace the use of promoted types as default scopes then I'm not convinced that the declared type will always be the preferred default scope.

For example:

sealed class State {
  State();
  factory State.working(int value) = WorkingState;
  factory State.invalid() = InvalidState;
}

class WorkingState extends State {
  final int value;
  WorkingState(this.value);
}

class InvalidState extends State {}

void main() {
  var state = getCachedState();
  if (state is InvalidState) {
    state = State.working(10);
  } else if (state case WorkingState(:var value)) {
    if (value < 15) {
      state = State.working(value + 1); // Or `state = .new(value + 1)`.
    }
  }
  initialize(state);
}

// Glue code: Just enough to allow the code to compile.
State getCachedState() => throw 0;
void initialize(_) => throw 0;

With the approach that I've recommended (emitting a warning when a promoted or inferred type is used as a default scope), we would be able to use state = .new(value + 1), but the recommended form is still state = State.working(value + 1). Developers who want to rely on promoted and inferred types as default scopes can disable the warning.

@eernstg
Copy link
Member Author

eernstg commented Jun 3, 2024

@tatumizer wrote here:

This is a "meta-feature". It is based on a convention: resolve the shortcuts for class X in the namespace X (it can be a class X itself, or a static extension on X). We can adopt a different convention. E.g. resolve the shortcuts for X in a namespace XShortcuts. This will give us full control over the shortcuts.

Right, the rule that we're using the class/mixin/etc. C as the default scope when the context type is C is what we get when no default scope is declared. (I'll use C rather than X because X is often taken to be a type variable.)

With a formal parameter we can make the choice to use any other namespace, e.g., CShortcuts. Similarly for switches.

class C { const C(); }
class CShortcuts { static const theC = C(); }

void f(C c in CShortcuts) {}

void main() {
  f(.theC); // OK.
  switch (C()) in CShortcuts {
    case theC: ...;
    default: ...;
  }

  var anotherC = CShortcuts.theC; // OK.
  C anotherC = .theC; // Error, context `C` is not enough to bring up `CShortcuts`.
  C yetAnotherC = CShortcuts.theC; // Redundant, but we have no fix for this now.
}

I've thought about it, and it might be possible to provide a more general kind of support for using a separate namespace in other situations as well, but it is not obvious that it could be done in a way which is sufficiently expressive and at the same time comprehensible. So there's no support for such things in this proposal.

One issue that comes up is that if we associate CShortcuts with the type C (and specify, somehow, that CShortcuts contains shortcuts for C) then we might just as well use static extension CShortcuts on C {...}.

Next, if we don't declare CShortcuts to be a default scope which is used whenever the context type is C then we need some other way of enabling CShortcuts as a default scope for something. That is, we must have a way to specify that it should be used as the default scope in certain situations.

class CShortcuts provides_a_default_scope_for C {
  static const theC = C();
}

This exactly what a parameter declaration like C c in CShortcuts will do, and it is enabled whenever we're passing an actual argument to that parameter, and similarly for switch (myC) in CShortcuts {...}.

But that will work without any specification saying that CShortcuts is used as a default scope, it just requires CShortcuts to exist, as seen from a parameter declaration like C c in CShortcuts. A plain abstract final class CShortcuts {...} will do just fine for that.

We could invent an additional syntax for the variable declaration (e.g., C c in CShortcuts = .theC;) but it isn't shorter than just writing CShortcuts before the period in .theC, as in C c = CShortcuts.theC;.

@eernstg
Copy link
Member Author

eernstg commented Jun 3, 2024

(FYI: I'm on vacation for a bit more than a week now, so I'll only be able to respond here on Wed Jun 12 or so.)

@Abion47
Copy link

Abion47 commented Jun 3, 2024

but b will be of type Bar because that's the type inferred from the return type of Foo.bar()

The return type of Foo.bar is Foo, all constructors have their containing type as return type, even if they're forwarding factory constructors. If you're seeing anything else, that's a bug. Extension constructors should have their on type as return type. (Whether they include your arguments is an interesting question, but again they should probably work the same way as static access through type aliases.)

That is correct, I got my wires crossed with the example I had posted a few days ago. This is what I meant to do:

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

void main() {
  final Foo a = .bar;
  final b = Foo.bar;
}

The issue comes from static fields, not factory constructors. In this example, a would be of type Foo, and b would be of type Bar.

@tatumizer
Copy link

@lrhn: in principle, would it be appropriate to use class annotation, say: @shortcuts(Color) like this:

@shortcuts(Color)  // provides shortcuts for Color class
class ColorShortcuts {
   const red = Color(someValue);
   const blue = Color(otherValue);
   // etc.
}

I admit I don't understand the status of annotations in dart. The docs say the annotation is for the tools only, but the nature of some annotations like @noInline suggests one of these "tools" is a compiler itself. :-) Or maybe dart2js is not a compiler, but a mere tool? Some hair-splitting is going on here, don't you think? 😄

@lrhn
Copy link
Member

lrhn commented Jun 3, 2024

Annotations have no status in the Dart language itself. They're allowed to exist, but the language assigns no meaning to annotations other than them needing to be valid constants.

So an annotation cannot change anything, it's just a signal to tools, which can choose to do something on their own, mainly refuse to continue in some cases, but they're not allowed to change the runtime semantics of valid programs.

@Abion47
Copy link

Abion47 commented Jun 3, 2024

@eernstg

I adjusted the proposal to omit the support for multiple default scopes. This discussion has shown that this kind of feature is actually better handled via static extensions (the static extensions would then be used to populate a single default scope, possibly from multiple sources).

I understand why you did it, but that just makes this feature even more nonsensical if it doesn't support multiple scopes. It whittles the effective use case of this feature to when you want to extend the scope of a type in a parameter but only in a handful of places and only using a single target type. I'm still of the opinion that this proposal is largely a solution in search of a problem, but that use case is so hyperspecific that, even if I had been 100% agreeing with you on its importance, I would wonder if so much work was worth it for such a narrow payoff.

I'm not sure what you mean by 'proactive extensions'.

By proactive/retroactive, I am referring to extending a type for use with the shorthand syntax either "proactively" (i.e. at the source) or "retroactively" (i.e. after the fact). For instance, your proposal is a proactive extension:

void foo(Color color in Colors) { ... }

This approach proactively adds support for members of Colors to the parameter which would otherwise only support Color. The benefits of this is that it will just work without people needing to import anything additional. The downsides are, as I have stated, that this kind of approach is hyperspecific, limited, and inflexible, and in this specific case would need to be repeated everywhere in the SDK, package, or user project that Color is used.

Conversely, static extensions are a retroactive extension:

extension MaterialColorExt on Color {
  static final Color red = Colors.red;
  ...
}

This approach retroactively adds support for members of Colors to the Color type itself. The benefits of this is that it's highly flexible and extensible, plus it piggybacks on a feature that has a wide range of other practical uses. The downsides are that it is an additional import that users will usually need to remember to include.

I don't see how that could be described as 'extending types'...

I think this fact is one of the biggest personal issues I have with this proposal. It's generous to even call this feature syntactic sugar as it is effectively just an IntelliSense assistance tool. The latter half of Color color in Colors has no semantic value to the code itself, and when compiled it is likely to be completely ignored. Dedicating a keyword usage for a feature that is basically a glorified comment annotation just rubs me the wrong way.

The library developer can just choose to have a declaration that does not specify a default scope, for example Color color = Colors.transparent. The default scope will then be Color.

The ability to specify a default scope which isn't the parameter type allows library authors to have more control when they want it. Nothing is forcing an in ... clause on anybody.

That is not the point. The point is that when people do want to use in ..., it requires a lot of work. The intent behind this feature is all well and good, but what if a package author wants to make use of this feature to support their MyColors class in a package that has dozens or even hundreds of functions that take a Color parameter? They would have to manage each and every one to use in MyColors. That's a lot of copy and pasted boilerplate.

Second, they don't allow user extension whatsoever

Static extensions can add static members to Colors exactly as easily as they can add static members to Color.

I have no idea what this is in response to. The context of my quote was such that it was proactive extensions (and hence your proposal) that isn't user extensible. I don't know what that has to do with the ability to add static members to Colors.

If Flutter makes the choice to use Color as the default scope for colors then downstream libraries would just get the same for free by having Color as the parameter type.

Between this and the previous quote, I think some wires got crossed. When I was listing my issues, those were issues with your proposal of in ..., not my issues with static extensions. Otherwise, I am fundamentally misunderstanding what point you are trying to make here, because that is not what would happen with the in ... syntax.

If Flutter uses ui.Colors as the default scope then, presumably, it would be considered good style to use Color color in ui.Colors as the declaration of parameters of that type, because ui.Colors would then be assumed to provide access to whatever set of important colors the clients want to have in that namespace—not just in APIs declared by the Flutter team, but also by downstream libraries.

If I understand this correctly, your idea is that there is some dart:ui.Colors class that is added to every function that takes a Color via Color color in ui.Colors. That class is initially empty with the intention that users will be able to add to it themselves using static extensions which would make them available everywhere.

Here are some issues I can see with that, in no particular order:

  1. A class in the official SDK that intentionally does nothing and is designed to be statically extended strikes me as an antipattern. It's one of those ideas that if it were ever truly the best solution, something somewhere went terribly wrong.
  2. Extensions only exist on a type when the extension itself is imported. How would the Flutter SDK be able to know that a user-created extension exists?
  3. Having a Colors class in dart:ui when one already exists in flutter/material is a naming conflict nightmare waiting to happen. (And proposing a breaking change to rename the existing Colors to MaterialColors is a non-starter.)
  4. Instead of making it a matter of whether it's better to use proactive or retroactive extensions, this idea would use both simultaneously which would combine the worst of both worlds for little upside, including requiring the boilerplate of both approaches.
  5. Why would people extend ui.Colors when they can get the same thing more straightforwardly by just extending Color? What would people gain from extending ui.Colors?
  6. It doesn't address the original issue of wanting the members of Colors to be accessible via Color. This proposal would suggest that, if someone wanted that, they would need to do it themselves.

If you are using Color as the default scope of colors:

// Third party library.
import 'dart:ui';

static extension on Color {
  ... // Put static members and/or constructors into `Color`.
}

...
Where do you see 'every combination of Colors, CupertinoColors, and any other collection type of Color that exists'? I don't see anything like that. There's no need to update something that doesn't exist, either.

The point was that using your in ... proposal, this issue would exist. Ironically, you've gotten around the issue by doing precisely what I suggested - just use a static extension on Color that doesn't suffer from that problem in the first place.

Basically, here's what I'm getting at. The more we hammer out these issues with your proposal, the more you suggest shoring those issues with static extensions. At some point, you have to wonder what exactly is gained from the additional complexity of in ... as opposed to just using static extensions exclusively.

That's true. You could argue that there's some semantic noise in the int/double based example because it involves the implicit coercion that makes 1 evaluate to a value of type double when the context type is double.

That would be an argument in favor of using examples where we declare a bunch of extra classes, just so we can illustrate a point about a typing structure without relying on numbers. It is going to be considerable more verbose, though.

I mean, not really? This may be a special case due to the involvement of numeric literals, but using that to suggest we shouldn't be using numeric literals at all (or wrapper classes at the very least) is a bit of a leap.

I'm not saying that we should only use production-quality code when considering use cases for feature proposals (because we shouldn't). I'm saying that the circumstances behind this specific example code are so specific, contrived, and antithetical to best practices that I have difficulty drawing any meaningful conclusions from it.

You don't mean 'familiar'?

No, I meant self-evident. Someone who can generally understand programming languages but had no experience with Dart could read that code and make a pretty good guess what it does. Familiarity does play a part in that ability, but so does the ability to logic through a vaguely English structure. (And like I already said, the use of on in other languages in similar situations also helps.)

But again, my point wasn't that features shouldn't be written if they aren't readable without becoming familiar with it (though, in fairness, one would need a very good reason to implement a feature that wasn't). My point is that the in ... syntax isn't just hard to understand, it directly promotes misunderstanding. Just because it's okay for a feature to have a learning curve doesn't mean it's okay for the curve to bow downward.

If foo is a local variable (and that includes formal parameters) whose type is Foo (declared, implicitly or inferred, possibly promoted) and Bar is a subtype of Foo, and foo is promotable (for instance, we can't assign a new value to foo in a function literal), then foo will have the promoted type Bar after foo is Bar in the control flow graph.

In particular, foo has the type Bar when it is assigned a new value using foo = Foo.create(). If Foo.create is a constructor or static member of Foo whose return type is Foo then the assignment will demote the variable foo, taking it back to an earlier type (that could be the declared type or one of the results of earlier promotions). So if foo is declared as Foo foo (and then possibly an initializing expression) then it will have type Foo after the assignment.

So the context type for the expression Foo.create() is Bar. It is not a compile-time error, but it will demote foo.

Essentially, there is a discrepancy here between the context of the promotion and the context of the expression:

class Foo {
  factory Foo.create() = Bar;
}
class Bar extends Foo {}

void main() {
  var Foo foo;
  foo = ???; // A
  if (foo is Bar) {
    foo = ???; // B
  }
}

Looking at line A, the "context type" of the right hand side of the expression is the base type of any value that would be accepted in that position. In this case, that aligns with the computed context type of Foo.

Looking at line B, the "context type" hasn't changed, since any value of Foo is accepted in the expression. But because foo has been promoted to Bar, the computed context type of the right hand side of the expression is Bar.

This discrepancy makes no sense to me. If it is statically known that the root accepted type of the assignment is Foo, why can it not be said that the context type is Foo? lrhn alluded to this with the code shared earlier:

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

The problem was whether the 1 in n = 1 should be interpreted as an int literal or as a double literal. But as of today, it is interpreted as an int literal, which is demonstrated when you print(n.runtimeType) that shows that n is an int after the assignment. The fact that, when given the opportunity, Dart explicitly chooses a type incompatible with the promoted type would imply that Dart has enough contextual information to not just allow but encourage a demotion to the declared type in an assignment expression.

@Abion47
Copy link

Abion47 commented Jun 3, 2024

@tatumizer

in principle, would it be appropriate to use class annotation, say: @shortcuts(Color) like this:

@shortcuts(Color)  // provides shortcuts for Color class
class ColorShortcuts {
   const red = Color(someValue);
   const blue = Color(otherValue);
   // etc.
}

I admit I don't understand the status of annotations in dart. The docs say the annotation is for the tools only, but the nature of some annotations like @noInline suggests one of these "tools" is a compiler itself. :-) Or maybe dart2js is not a compiler, but a mere tool? Some hair-splitting is going on here, don't you think? 😄

I see no reason something like this wouldn't be possible. The only thing is that it would require the annotation to be paired with a code generation pass that detects and parses the annotation, then generates an extension to Color and creates static getters or methods that map to the equivalents in ColorShortcuts.

@rrousselGit
Copy link

rrousselGit commented Jun 3, 2024

I see no reason something like this wouldn't be possible. The only thing is that it would require the annotation to be paired with a code generation pass that detects and parses the annotation, then generates an extension to Color and creates static getters or methods that map to the equivalents in ColorShortcuts.

At that point, why not just write the extension directly, and name it ColorShortcuts?

static extension ColorShortcuts on Color {
   static const red = Color(someValue);
   static const blue = Color(otherValue);
   // etc.
}

@tatumizer
Copy link

tatumizer commented Jun 3, 2024

At that point, why not just write the extension directly

The problem I'm trying to solve is this: how to tell the compiler to ignore all static methods defined in class C, but take only those defined in the extension?
I am not sure this problem is worth solving, but let's treat it as an abstract exercise.
If we want to ignore all definitions in Color, then the instruction "take all shortcuts from the namespace ColorShortcuts" will automatically exclude all "native" static methods defined in the class Color.

The idea is to keep the list of shortcuts "clean" - let them contain only what I really use, without the accidental stuff that was never intended to serve as shortcuts.
This is a variant of show/hide problem.
(The word "shortcut" implies something that is used often).
This might be too complicated though.

@Abion47
Copy link

Abion47 commented Jun 3, 2024

At that point, why not just write the extension directly

The problem I'm trying to solve is this: how to tell the compiler to ignore all static methods defined in class C, but take only those defined in the extension? I am not sure this problem is worth solving, but let's treat it as an abstract exercise. If we want to ignore all definitions in Color, then the instruction "take all shortcuts from the namespace ColorShortcuts" will automatically exclude all "native" static methods defined in the class Color.

The idea is to keep the list of shortcuts "clean" - let them contain only what I really use, without the accidental stuff that was never intended to serve as shortcuts. This is a variant of show/hide problem. (The word "shortcut" implies something that is used often). This might be too complicated though.

The whole idea of static extensions is to add functionality to a type. What you're talking about is in essence hiding or removing functionality from a type. Making .bar invisible to the autocomplete is akin to making Foo.bar invisible to the autocomplete. You can't do that, and I can't think of a practical reason why you would want to in a language like Dart. If you don't want to access .bar, then you don't want to access Foo.bar, and if you don't want to access Foo.bar, then just make Foo.bar private in the first place.

These shortcuts aren't extending the language with any new functionality. All they do is add syntactic sugar for the sake of convenience. Tacking on features like customizing how it actually works is, in my opinion, making it far more complicated than it needs to be.

@tatumizer
Copy link

Making .bar invisible to the autocomplete is akin to making Foo.bar invisible to the autocomplete.

Swift, which serves as a source of inspiration for the feature, hides stuff. Based on their proposal (which I cited earlier), they provide shortcuts only for members that return a base type (they even say "exactly" a base type).
I couldn't get around to testing it (I don't know swift), but I could learn some minimum to test it, and then report my findings.
One difference I've already noticed is that all their constructors are declared as methods with the name init(...), and there can be several of them (overloading is supported). When you create an instance of class Foo, you normally can say Foo(parameters), like in dart, but when you use a shortcut to a constructor, you call it .init(parameters).
To be investigated further.

@bernaferrari
Copy link

bernaferrari commented Jun 3, 2024

To be fair, if all the Dart team did was . on Enums and left the rest for later, I would be super happy already. Enum itself doesn't seem like a big change, and has 80% of the benefit. The static thing feels way bigger.

@Abion47
Copy link

Abion47 commented Jun 4, 2024

Making .bar invisible to the autocomplete is akin to making Foo.bar invisible to the autocomplete.

Swift, which serves as a source of inspiration for the feature, hides stuff. Based on their proposal (which I cited earlier), they provide shortcuts only for members that return a base type (they even say "exactly" a base type).

The difference is that it "hides" members by only showing the members that match the context type. So, for instance, if you had a class that had a static member of a different type, that member wouldn't be accessible using the shorthand syntax:

class SomeClass {
    static var shared = SomeClass()
    static var label = "SomeClass Label"

    func f() -> SomeClass { return SomeClass() }
}

@main
struct App {
    static func main() {
        let x: SomeClass = .shared // Allowed
        let x: SomeClass = .shared.f() // Also allowed
        let x: SomeClass = .label // Not allowed, also non-sensical
    }
}

This "hiding" of static members is just a natural consequence of the shorthand syntax. If the variable type is SomeClass, then it doesn't make sense to try and assign .label (a string) to it anyway. Might as well hide it from the autocompletion in that case as a matter of convenience.

But what you're talking about is manually designating members of a type to not be included in the autocompletion, and that's another matter entirely. To my knowledge, Swift does not allow that.

@tatumizer
Copy link

tatumizer commented Jun 4, 2024

Here are the initial findings of my research into swift's shortcuts
(it was a Herculean effort!)

Swift allows you to use the shortcuts liberally.
If class C has a static member "member" then you can use the shortcut .member whenever the context type is C. The only limitation is that the chain that starts with .member should be assignable to C (naturally enough).
Constructors (which are uniformly called init()) are also considered static methods and thus are eligible for shortcuts. (In the swift's universe, they use the term "initializer" vs dart's "constructor", but it's more or less the same thing)

class Vehicle {
  static let car = Car()

  static let c=Vehicle.car // OK
  static let d:Car=.car // error: '=' must have consistent whitespace on   both sides
  static let e:Car = .car // OK
  static let f = .car // error: reference to member 'car' cannot be resolved without a contextual type
  static let g = car // OK! no dot, not prefix is required in static context

}

class Car: Vehicle {
}

(Please notice the quirk on the let d line)

Swift supports extensions. An extension for class Vehicle can be declared, rather unimaginatively, as extension Vehicle (no on clause, no separate namespace), and can include instance methods, static methods, and initializers (=constructors). The only restriction is that the initializer declared in the extension must be a convenience initializer, as opposed to a designated initializer that can only be declared in the class. If I'm not mistaken, the latter corresponds to the generative constructors in dart, and the former - to everything else.

Every static method defined in the extension can be called via the shortcut without limitations.

(recommended playground: https://www.programiz.com/swift/online-compiler/ )

@tatumizer
Copy link

tatumizer commented Jun 5, 2024

In an attempt to find out why other languages are in no rush to copy this "shortcuts" feature, engaged myself in research. Here's what people say.
Among the main advantages of the method, the apologists often cite "discoverability".
However, the critics respond with a simple argument: IDE can be instructed to interpret the leading dot by suggesting useful variants, and then substitute the full name.
In case of colors, when we type . in Color contests, we get all available variants - e.g. FancyColors.fancyRed - and upon selection, substitute the full name FancyColors.fancyRed. This method will work even in the presence of conflicting names (user selects the full name anyway). The amount of typing is the same in both cases.

There are two main advantages of the method:

  1. It's not a language feature, so it's much easier to implement (e.g. using annotations)
  2. full names are more readable without the help of IDE (e.g. while reading code on github or in a blog post).

The annotation controlling the IDE treatment of dot might be as expressive as we wish.

(BTW, using Colors as a motivating example might be wrong because flutter doesn't recommend it, and points instead towards color schemes).

@eernstg
Copy link
Member Author

eernstg commented Jun 14, 2024

I removed support for plain identifiers, only .id is supported in the newest version of this proposal.

The reason for this change is that it is too difficult to read code where any plain identifier could be looked up in the context type (that is, in the namespace provided by the class which is the context type, etc.). The form .id makes it explicit that we are using a default scope rather than the normal scope rules.

@eernstg
Copy link
Member Author

eernstg commented Jun 14, 2024

@Abion47 wrote, 2 weeks ago:

that just makes this feature even more nonsensical if it doesn't support multiple scopes .. so much work was worth it for such a narrow payoff

Did you read the proposal? What is it that you want and that you can't express using this proposal? I'll try to find the answer to this question as I'm reading your comments, but a bit of help would be awesome!

void foo(Color color in Colors) { ... }

This approach proactively adds support for members of Colors to the parameter which would otherwise only support Color

This doesn't make sense. The parameter whose name is color in the example has type Color, and any expression of type Color (or a subtype thereof) is a type correct actual argument for that parameter. Conversely, no expression whose static type isn't Color or a subtype thereof (or dynamic) is accepted as an actual argument.

There is no change to the set of acceptable arguments at all.

The clause in Colors has a different effect: It enables transformation of expressions of the form .id which are passed as actual arguments for that parameter to Colors.id. This means that Colors can declare members (in particular, static variables, but it could also be any other kind of static member or constructor), and the name of those members can be used in this abbreviated form. For example:

abstract class Colors {
  static const red = MaterialColor(...);
  ...
}

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

Some other proposals use additional criteria to filter the available members of the given default scope. For example, it has been a requirement that we only get to use members whose type is the given context type (here Color). That is possible (trivial, actually), but I'm not convinced that it is a good idea. For example, I think it is a non-problem that we allow Colors.red to be used even though it has type MaterialColor rather than Color. That's a subtype of the parameter type, so we can use it as the actual argument without problems.

If we want to enable chains (like .foo(42).bar().baz) then it probably doesn't make sense to require that the given foo method or constructor has a return type which is assignable to the context type. In the end we will just type check the invocation after the expansion (e.g., f(Colors.red) respectively SomeClass.foo(42).bar().baz).

Conversely, static extensions are a retroactive extension:

extension MaterialColorExt on Color {
  static final Color red = Colors.red;
  ...
}

This approach retroactively adds support for members of Colors to the Color type itself.

I'd very much like to have static extensions, and as you may know this proposal has used them from day one, based on the hope that we'll get them (somewhat soon, too).

So we don't have to choose one or the other, I'm arguing that they work well together.

I think your term 'retroactively' covers pretty much the same concept as when I say 'extensible'. The point is that a client (who doesn't have the ability to edit a class like Color or Colors) can add static members and factory constructors (in a way that is similar to extension declarations), and anyone who imports the static extension will get the exact same behavior as if those static members and constructors were added to the target (Color or Colors).

The downsides are that it is an additional import

That's true. Support for adding this kind of imports would be great, and I think we'll have better support for that over time, module priorities and resources.

The latter half of Color color in Colors has no semantic value to the code itself, and when compiled it is likely to be completely ignored.

You obviously can't ignore the clause in Colors during compilation. If it is present then .red can have the meaning Colors.red, for example. If it is deleted then .red may just be a compile-time error (if Color doesn't have a static member or constructor with that name) or it denotes a declaration in Color, that is, it denotes a completely different thing.

That is not the point.

I can have points, too. ;-)

if a package author wants to make use of this feature to support their MyColors class in a package that has dozens or even hundreds of functions that take a Color parameter?

You're right, that could take several hours. It's a one time investment, though.

Second, they don't allow user extension whatsoever

Static extensions can add static members to Colors exactly as easily as they can add static members to Color.

I have no idea what this is in response to.

You said that parameter default scopes do not allow for extensibility, I responded that static extensions are a perfectly effective tool to make parameter default scopes extensible.

You can use the type itself (that is, put stuff into Color, by editing the declaration or using a static extension, whatever you want and have permissions to do), or you can use a different namespace (I mentioned a declaration in 'dart:ui' specifically for this purpose, following the idea that Colors is currently used as such a separate namespace, just for material colors).

The point is that the different namespace can be less crowded, because it only plays the role as a provider of named distinguished values that we're expected to want to pass to specific parameters, and the class itself may have many static declarations that do not have this purpose.

Otherwise, I am fundamentally misunderstanding what point you are trying to make here, because that is not what would happen with the in ... syntax.

If you wish to use the class itself (such as Color) then you won't use any in ... clauses on any parameters, you will just rely on the context type everywhere.

No big deal. When you write an if statement, you can also omit the else part. What's the problem?

If I understand this correctly, your idea is that there is some dart:ui.Colors class that is added to every function that takes a Color via Color color in ui.Colors. That class is initially empty with the intention that users will be able to add to it themselves using static extensions which would make them available everywhere.

Exactly. This is one possible approach. It does involve some machinery (and some work, initially), but it allows clients to populate that empty namespace in any way they wish.

A class in the official SDK that intentionally does nothing and is designed to be statically extended strikes me as an antipattern.

It's a hook, that is, a mechanism that allows clients to add functionality to something, e.g., a big framework.

Another example of a hook is a virtual method (in Dart: every class/mixin/enum instance method is virtual, but extension methods are not). The Template Method design pattern is an example where it is very clearly used as a hook.

Is that an antipattern?

Extensions only exist on a type when the extension itself is imported

That is, when the extension is imported into the library that uses the additional members. 'dart:ui' certainly does not have to import a static extension in order to use those additional members in some client library. Just like ordinary extension methods.

Having a Colors class in dart:ui when one already exists in flutter/material is a naming conflict nightmare

OK, choose a different name. The point is how it is used.

Why would people extend ui.Colors when they can get the same thing more straightforwardly by just extending Color?

Both are possible. Using a separate namespace is more work and more control, that might be preferable.

It doesn't address the original issue of wanting the members of Colors to be accessible via Color.

If we are using a separate namespace then the compiler cannot possibly guess that any particular namespace is intended to be used in this way. We have to declare it. That's what the in Colors declaration on parameters are for.

just use a static extension on Color that doesn't suffer from that problem in the first place

That is indeed a possible outcome, and then you'd just rely on the context type to deliver the default scope. Depends on whether or not this makes Color too crowded.

the more you suggest shoring those issues with static extensions

As I mentioned, static extensions have been part of this proposal from day one. Extensibility is important!

Essentially, there is a discrepancy here between the context of the promotion and the context of the expression:

class Foo {
  factory Foo.create() = Bar;
}
class Bar extends Foo {}

void main() {
  var Foo foo;
  foo = ???; // A
  if (foo is Bar) {
    foo = ???; // B
  }
}

Looking at line A, the "context type" of the right hand side of the expression is the base type of any value that would be accepted in that position. In this case, that aligns with the computed context type of Foo.

Looking at line B, the "context type" hasn't changed, since any value of Foo is accepted in the expression. But because foo has been promoted to Bar, the computed context type of the right hand side of the expression is Bar.

This discrepancy makes no sense to me. If it is statically known that the root accepted type of the assignment is Foo, why can it not be said that the context type is Foo?

The context type of any expression in Dart is well defined. It's been around for several years. We don't get to invent a new meaning for that concept willy-nilly, because that would basically break all programs. Context types are also not trivial, and demotion of promoted variables is one of the tricky cases.

At line A, the context type of the right hand side of the assignment is Foo because foo has type Foo. The is expression promotes the type of foo to Bar in the continuation of that expression where it evaluated to true (that is, the body of the if statement).

So the context type of the right hand side of the assignment in line B is Bar. This has consequences for the type inference that may be performed on that right hand side, but the resulting expression may or may not have type Bar.

If the type of that right hand side is not a subtype of Bar then the variable foo is demoted by the assignment. It is a compile-time error if an attempt is made to demote the variable to a type that isn't assignable to the declared type Foo.

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

The int-to-double coercion example is different. n = 1 is the same thing, already at compile time, as n = 1.0. The compiler will treat the int literal such that the value which is assigned to n will be of type double. At run time we never have the corresponding int object, there is no transformation, because this already occurred at compile time.

The reason why this coercion takes place is a specific rule in the language specification: An integer literal that occurs such that the context type C satisfies (1) double is assignable to C and (2) int is not assignable to C will evaluate to an instance of type double whose value is the same integer number (it's a compile-time error if that isn't possible).

@eernstg
Copy link
Member Author

eernstg commented Jun 14, 2024

@rrousselGit wrote:

At that point, why not just write the extension directly, and name it ColorShortcuts?

You could do that, if you aren't worried about overcrowding Color.

@eernstg
Copy link
Member Author

eernstg commented Jun 14, 2024

@tatumizer wrote:

The problem I'm trying to solve is this: how to tell the compiler to ignore all static methods defined in class C, but take only those defined in the extension?

That's the main motivation for doing some extra work and putting those distinguished values (static variables, or static methods, or constructors) into a separate namespace.

@rrousselGit
Copy link

I think "overcrowding" is what some people actively want.

Take one of my packages: Riverpod
Normally, you're supposed to define top-level variables and spread them around your project to place them in a location revelant to their use.

But I've seen multiple people prefer having all of their "providers" hosted as static final in a single class:

abstract class AllProviders {
  static final a = Provider(...);
  static final b = Provider(...);
}

They do so because they explicitly wish to have a way to see the list of all objects of a given kind in the same place, for the sake of autocompletion.
It makes them more discoverable (at the cost of messier file organisation).

So I think many people would actively prefer seeing all colours under Color.something for the same reason.

@eernstg
Copy link
Member Author

eernstg commented Jun 14, 2024

I think "overcrowding" is what some people actively want.

I've seen multiple people prefer having all of their "providers" hosted as static final in a single class:

abstract class AllProviders {
  static final a = Provider(...);
  static final b = Provider(...);
}

We've had a couple of decades where the word 'aspect' was associated with a lot of discussions about software elements that are 'scattered' or 'tangled', corresponding to the situation where we'd want them to be located in one place (such that they are easier to find), or where we'd want to have two different elements in different places because they are dealing with different 'concerns'.

Static extensions actually touch on the same topic area: They make it possible for some static declarations or constructors to be declared in a different location than the one which is logically their "home". So we could use them to populate AllProviders from many different locations:

// --- Library 'widely_used_lib.dart';

abstract class AllProviders {} // Just an empty container. Pure extensibility.

// --- Library 'a.dart'.
import 'widely_used_lib.dart';

static extension on AllProviders
  static final a = Provider(...);

// --- Library 'b.dart'.
import 'widely_used_lib.dart';

static extension on AllProviders {
  static final b = Provider(...);
}

// --- Library 'lib.dart'. Populate `AllProviders`, in a reusable way.
export 'widely_used_library.dart';
export 'a.dart';
export 'b.dart';

// --- Library 'my_program.dart'.
import 'lib.dart';

var useIt = AllProviders.a; // OK.

This approach might be considered as scattered, or it might be considered as the opposite (cohesive?): For the client, it looks like AllProviders is a big bucket that holds all the providers together. Looking at AllProviders in isolation shows none of them (so 'widely_used_library.dart' doesn't have to depend on any of the actual providers), but we use static extensions to make it look like we actually have such a big bucket.

So if people really want the physical overcrowding then they'd put everything into AllProviders, but if they want to avoid such a dependency concentrator then they may wish to use the static extensions.

I'm suggesting that colors could be a good example: 'dart:ui' cannot possibly depend on 'material.dart' or 'cupertino.dart', but it can hold an empty bucket that everybody knows about, and clients can populate in any way they wish.

@Abion47
Copy link

Abion47 commented Jun 14, 2024

This is the last time I'm going to reply on this thread, as I feel like at this point I have said everything that needs to be said, and after this you either understand and will address my concerns or you don't and you won't. In either case, it's been made pretty clear that we aren't going to see eye to eye on this, so there isn't much more reason to arguing in circles ad infinitum.

Did you read the proposal? What is it that you want and that you can't express using this proposal? I'll try to find the answer to this question as I'm reading your comments, but a bit of help would be awesome!

What I would want is to be able to add multiple types to a parameter scope so I don't need to arbitrarily combine everything into a single utility type. Your suggestion of having a single type that a user can extend using a static extension is something that would be considered a dirty workaround, not an official solution, and in many scenarios, it begs the question of why the user wouldn't just use a static extension on the parameter type itself (or, better yet, just add the members to the type directly).

Also, what if a function doesn't define a type for a parameter scope at all?

// in package:my_company_common/authorization.dart
Future<SignInResult> signIntoUserPool(String pool, String username, String password) { ... }

Imagine this is a function in a company's internal generic common library. In an implementation that references it, they might have several pre-defined pools relevant to that application:

// in package:my_company_client_app/.../auth_service.dart

import 'package:my_company_common/authorization.dart';

abstract class ClientUserPools {
  static const workers = 'workers';
  static const teamLeads = 'team-leads';
  static const supervisors = 'supervisors';
}

void signInWorker(String username, String password) {
  final result = await signIntoUserPool(ClientUserPools.workers, username, password);
  ...
}

It would be nice if they could shorten ClientUserPools.workers to just .workers, but they can't because the parameter doesn't specify a type as a default scope for them to extend. Sure, they could add a dummy type in the common library themselves, but what if this function was in a code base that the implementation author has no control over? What if it's in a third-party package? There's only so much the user can do before they would be forced to either extend String itself or just accept the verbosity, all because the package author didn't have the foresight to provide a dummy type on that parameter.

Also, what about the username and password parameters? Should the package author include dummy type parameter scopes on those fields as well on the off-chance some downstream user would want to extend them? Well that's three dummy types just for this one function. How many other functions are there, and should all those functions' parameters also have dummy types of their own? How many parameters need dummy types need to be created before they can consider the level of support for this feature to be satisfactory to address any given user's needs?

This is why this feature being opt-in is such a big issue. Not only can users not take advantage of it unless a package author explicitly supports it, but it is entirely the package author's responsibility to make sure it is supported anywhere a user might want to take advantage of it. And as you can see, package authors adding this support quickly turns into a slippery slope of trying to anticipate any potential user's use cases.

This approach proactively adds support for members of Colors to the parameter which would otherwise only support Color

This doesn't make sense. The parameter whose name is color in the example has type Color, and any expression of type Color (or a subtype thereof) is a type correct actual argument for that parameter. Conversely, no expression whose static type isn't Color or a subtype thereof (or dynamic) is accepted as an actual argument.

I would've thought that in the context my comment was made, I was making it clear that "adds support" was referring to dot syntax support, not to what values are considered valid for the parameter itself.

Conversely, static extensions are a retroactive extension:

extension MaterialColorExt on Color {
  static final Color red = Colors.red;
  ...
}

This approach retroactively adds support for members of Colors to the Color type itself.

I'd very much like to have static extensions, and as you may know this proposal has used them from day one, based on the hope that we'll get them (somewhat soon, too).

So we don't have to choose one or the other, I'm arguing that they work well together.

Again, you missed the point of my comment. I'm not trying to argue that they are mutually exclusive features. I'm trying to explain the difference between "proactive extension" and "retroactive extension", and why the latter is preferable to the former in almost every regard.

I think your term 'retroactively' covers pretty much the same concept as when I say 'extensible'.

No, again, see above. When I use the terms "proactive" and "retroactive", they are both in the context of extending a type and they describe different methodologies for accomplishing it.

That is not the point.

I can have points, too. ;-)

You can have points all you want, but when you make points that argue against points that I never actually made, that's called strawmanning, and it's generally frowned upon.

if a package author wants to make use of this feature to support their MyColors class in a package that has dozens or even hundreds of functions that take a Color parameter?

You're right, that could take several hours. It's a one time investment, though.

I don't know about you, but I for one would hate with every fiber of my being a feature that required me to spend hours writing out boilerplate just to make use of it. Not to mention that if I ever wanted to deprecate MyColors in favor of MyMaterialColors or something, that would require once again going back through all that boilerplate to make the change (or risk breaking something by using a find-and-replace tool).

And that's just for one type - if I also wanted to make use of MyThemes or MyMathConstants, I would have to go through all of that all over again. What you don't seem to understand is that this isn't a one-time deal. A typical package could easily contain a dozen or more of these collection types, and something like Flutter could conceivably contain well over a hundred. A parameter default scope would have to be added for each and every one of them to each and every function in the entire code base that references a related parameter. I would very quickly just go back to using static extensions because this sheer amount of boilerplate just isn't worth whatever perceived namespace pollution concerns I may or may not have.

If I understand this correctly, your idea is that there is some dart:ui.Colors class that is added to every function that takes a Color via Color color in ui.Colors. That class is initially empty with the intention that users will be able to add to it themselves using static extensions which would make them available everywhere.

Exactly. This is one possible approach. It does involve some machinery (and some work, initially), but it allows clients to populate that empty namespace in any way they wish.

It also adds potentially dozens of classes that literally do nothing to the global namespace for no reason whatsoever from the perspective of the vast majority of users who will never make use of them. Why are you not concerned with that form of namespace pollution but believe the concept of using a static extension to make Colors.red be generally accessible via Color.red is such a big deal?

A class in the official SDK that intentionally does nothing and is designed to be statically extended strikes me as an antipattern.

It's a hook, that is, a mechanism that allows clients to add functionality to something, e.g., a big framework.

You call it a hook, I call it a crutch for a poorly implemented mechanism.

Another example of a hook is a virtual method (in Dart: every class/mixin/enum instance method is virtual, but extension methods are not).

Not entirely accurate since Dart implemented final classes, but I digress.

The Template Method design pattern is an example where it is very clearly used as a hook.

Is that an antipattern?

This is a very apples-to-oranges comparison. This is like saying that because recursion is better than integration in one scenario, it is better in all scenarios.

Templates and hooks have their uses, but there are also plenty of use cases where they are the wrong thing to use because they address the wrong problems of the system or because they make things more complicated than they need to be. You wouldn't use an instance of a class with a virtual callback to pass the result value of a simple synchronous operation - you just return the result to the function caller.

Likewise, your solution of a "hook" type adds multiple layers of complexity to a scenario where a much simpler solution exists, and the sole benefit of doing so is to avoid having to see a handful of identifiers on a type's namespace. The subjective benefits are far outweighed by the objective downsides, and that is the definition of an antipattern.

the more you suggest shoring those issues with static extensions

As I mentioned, static extensions have been part of this proposal from day one. Extensibility is important!

I don't know how to make this any clearer. The point isn't that this feature and static extensions are mutually exclusive proposals. The point is that this feature has little reason to exist when A) static extensions do 99% of the same job but better, and B) the 1% that is left is both highly limited in usable situations and highly subjective in its beneficial nature. Saying static extensions are part of this proposal does nothing to address that point.


And with that, I officially rest my case on this matter. As a form of a parting summary, here are the primary issues your proposal has in no particular order that you have yet to adequately address:

  • It utilizes a syntax that is confusing and unintuitive in virtually every aspect of its design.
  • It introduces mandatory non-automatable boilerplate that results in potentially fragile code.
  • It's not user-extensible without relying on both a separate language proposal and a convoluted mess of "hook" types.
  • It forces the user of a function to be entirely dependent on the author of the function to explicitly add support for it, without which the user can't take advantage of it at all.
  • Specific to the hook types, it ironically results in an even worse namespace pollution than what the feature proposal claims to avoid.
  • It is almost entirely superseded by the very proposal it depends on for extensibility with the remainder of use cases being too few, too narrow, and too subjective to justify the implementation cost and added complexity.

Address the issues, argue them as incorrect, disregard them as unimportant, do what you will with them. It no longer concerns me.

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

No branches or pull requests