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

Allow for shorter dot syntax to access enum values #357

Open
rami-a opened this issue May 16, 2019 · 217 comments
Open

Allow for shorter dot syntax to access enum values #357

rami-a opened this issue May 16, 2019 · 217 comments
Labels
enums feature Proposed language feature that solves one or more problems

Comments

@rami-a
Copy link

rami-a commented May 16, 2019

When using enums in Dart, it can become tedious to have to specify the full enum name every time. Since Dart has the ability to infer the type, it would be nice to allow the use of shorter dot syntax in a similar manner to Swift

The current way to use enums:

enum CompassPoint {
  north,
  south,
  east,
  west,
}

if (myValue == CompassPoint.north) {
  // do something
}

The proposed alternative:

enum CompassPoint {
  north,
  south,
  east,
  west,
}

if (myValue == .north) {
  // do something
}
@johnsonmh
Copy link

This would be especially nice in collections:

const supportedDirections = <CompassPoint>{.north, .east, .west};
bool isSupported = supportedDirections.containsAll({.north, .east});

It's worth noting too that we would only allow it in places where we can infer the enum type.

So

final north = .north; // Invalid.
final CompassPoint north = .north; // Valid.
final north = CompassPoint.north; // Valid.

@kasperpeulen
Copy link

kasperpeulen commented May 18, 2019

In Swift this feature works not only for enums but also for static properties of classes. See also:

munificent/ui-as-code#7

class Fruit {
    static var apple = Fruit(name: "apple");
    static var banana = Fruit(name: "banana");
    
    var name: String;
    
    init(name: String) {
        self.name = name;
    }
}

func printFruit(fruit: Fruit) {
    print(fruit.name);
}

// .banana is here inferred as Fruit.banana
printFruit(fruit: .banana);

@lrhn
Copy link
Member

lrhn commented May 20, 2019

How would the resolution work?

If I write .north, then the compiler has to look for all enums that are available (say, any where the name of the enum resolves to the enum class), and if it finds exactly one such which has a north element, use that.
If there is more than one enum class in scope with a north element, it's a compile-time error. If there is zero, it is a compile-time error.

If we have a context type, we can use that as a conflict resolution: <CompassPoint>[.north, .south] would prefer CompassPoint.north, CompassPoint,south over any other enum with a north or south element.
We won't always have a context type, the example if (myValue == .north) { does not.

Alternatively, we could only allow the short syntax when there is a useful context type.
For the equality, you will have to write CompassPoint.north (unless we introduce something like "context type hints" because we know that if one operand of an == operator is a CompassPoint enum type, and enums don't override Object.==, then the other is probably also a CompassPoint, but that's a different can of worms).
Then we could extend the behavior to any static constant value of the type it's embedded in.
That is, if you have Foo x = .bar; then we check whether Foo has a static constant variable named bar of type Foo, and if so, we use it. That way, a user-written enum class gets the same affordances as a language enum.

I guess we can do that for the non-context type version too, effectively treating any self-typed static constant variable as a potential target for .id.

(Even more alternatively, we can omit the . and just write north. If that name is not in scope, and it's not defined on the interface of this. then we do "magical constant lookup" for enum or enum-like constant declarations in scope.
That's a little more dangerous because it might happen by accident.

@eernstg
Copy link
Member

eernstg commented May 20, 2019

One approach that could be used to avoid writing CompassPoint several times is a local import (#267).

@kasperpeulen
Copy link

How would the resolution work?

@lrhn You may want to study how it works in Swift. I think their implementation is fine.

@johnsonmh
Copy link

@lrhn

Alternatively, we could only allow the short syntax when there is a useful context type.

If we're taking votes, I vote this ☝️

Regarding the case with if (myValue == .north) {, if myValue is dynamic, then I agree, this should not compile. However; myValue would often already be typed, if it is typed, it should work fine. For example:

void _handleCompassPoint(CompassPoint myValue) {
  if (myValue == .north) {
    // do something
  }   
}

For the equality, you will have to write CompassPoint.north

I don't know enough about this, but I don't see why this would need to be the case if we're going with the "useful context type" only route?

Right now we can do:

final direction = CompassPoint.north;
print(direction == CompassPoint.south); // False.
print(direction == CompassPoint.north); // True.
print("foo" == CompassPoint.north); // False.

If we know that direction is CompassPoint, can we not translate direction == .south to direction == CompassPoint.south? Or is that not how this works?

Even more alternatively, we can omit the . and just write north

I don't personally prefer this approach because we risk collisions with existing in scope variable names. If someone has var foo = 5; and enum Bar { foo, }, and they already have a line foo == 5, we won't know if they mean Bar.foo == 5 or 5 == 5.

@lrhn
Copy link
Member

lrhn commented May 22, 2019

The problem with context types is that operator== has an argument type of Object. That gives no useful context type.

We'd have to special case equality with an enum type, so if one operand has an enum type and the other is a shorthand, the shorthand is for an enum value of the other operand's type. That's quite possible, it just doesn't follow from using context types. We have to do something extra for that.

@lrhn
Copy link
Member

lrhn commented Jun 24, 2019

We can generalize the concept of "enum value" to any value or factory.

If you use .foo with a context type of T, then check whether the class/mixin declaration of T declares a static foo getter with a type that is a subtype of T. If so, use that as the value.
If you do an invocation on .foo, that is .foo<...>(...), then check if the declaration of T declares a constructor or static function with a return type which is a subtype of T. If so, invoke that. For constructors, the context type may even apply type arguments.

It still only works when there is a context type. Otherwise, you have to write the name to give context.

@ReinBentdal
Copy link

ReinBentdal commented Jun 24, 2019

To omit the . would make sense for widgets with constructors.

From

Text(
  'some text',
  style: FontStyle(
    fontWeight: FontWeight.bold
  ),
),

To

Text(
  'some text',
  style: ( // [FontStyle] omitted
    fontWeight: .bold // [FontWeight] omitted
  ),
),

For enums and widgets without a constructor the . makes sense to keep, but for widgets where the . never existed, it makes sense to not add it.

FontWeight.bold -> .bold // class without a constructor
Overflow.visible -> .visible // enum
color: Color(0xFF000000) -> color: (0xFF000000) // class with constructor

From issue #417

_Some pints may have been presented already

Not include subclasses of type

Invalid
padding: .all(10)

This wont work because the type EdgeInsetsGeometry is expected, but the type EdgeInsets which is a subclass is given.

Valid
textAlign: .cener

This will work because TextAlign is expected and TextAlign is given.
The solution for the invalid version would be for flutter to adapt to this constraint.

The ?. issue

Alot of people have pointed out this issue on reddit. The problem is as follows:

bool boldText = true;

textAlign = boldText ? .bold : .normal;

The compiler could interpret this as boldText?.bold.
But as mentioned on reddit: https://www.reddit.com/r/FlutterDev/comments/c3prpu/an_option_to_not_write_expected_code_fontweight/ert1nj1?utm_source=share&utm_medium=web2x
This will probably not be a problem because the compiler cares about spaces.

Other usecases

void weight(FontWeight fontWeight) {
  // do something
}
weight(.bold);

@andrewackerman
Copy link

@ReinBentdal

Omitting the period for constructors would lead to a whole slew of ambiguous situations simply because parentheses by themselves are meant to signify a grouping of expressions. Ignoring that, though, I think removing the period will make the intent of the code far less clear. (I'm not even sure I'd agree that this concise syntax should be available for default constructors, only for named constructors and factories.)

And about the ?. issue, like I said in both the reddit post and issue #417, the larger issue is not whether the compiler can use whitespace to tell the difference between ?. and ? .. It's what the compiler should do when there isn't any whitespace at all between the two symbols. Take this for example:

int value = isTrue?1:2;

Notice how there is no space between the ? and the 1. It's ugly, but it's valid Dart code. That means the following also needs to be valid code under the new feature:

textAlign = useBold?.bold:.normal;

And now that there's no space between the ? and the ., how should the compiler interpret the ?.? Is it a null-aware accessor? Is it part of the ternary followed by a type-implicit static accessor? This is an ambiguous situation, so a clear behavior needs to be established.

@ReinBentdal
Copy link

A solution could be to introduce a identifyer.

*.bold // example symbol

But then again, that might just bloat the code/ language.

@lukepighetti
Copy link

lukepighetti commented Feb 27, 2020

I'd like to see something along these lines

final example = MyButton("Press Me!", onTap: () => print("foo"));

final example2 = MyButton("Press Me!",
    size: .small, theme: .subtle(), onTap: () => print("foo"));

class MyButton {
  MyButton(
    this.text, {
    @required this.onTap,
    this.icon,
    this.size = .medium,
    this.theme = .standard(),
  });

  final VoidCallback onTap;
  final String text;
  final MyButtonSize size;
  final MyButtonTheme theme;
  final IconData icon;
}

enum MyButtonSize { small, medium, large }

class MyButtonTheme {
  MyButtonTheme.primary()
      : borderColor = Colors.transparent,
        fillColor = Colors.purple,
        textColor = Colors.white,
        iconColor = Colors.white;

  MyButtonTheme.standard()
      : borderColor = Colors.transparent,
        fillColor = Colors.grey,
        textColor = Colors.white,
        iconColor = Colors.white;

  MyButtonTheme.subtle()
      : borderColor = Colors.purple,
        fillColor = Colors.transparent,
        textColor = Colors.purple,
        iconColor = Colors.purple;

  final Color borderColor;
  final Color fillColor;
  final Color textColor;
  final Color iconColor;
}

@MarcelGarus
Copy link
Contributor

MarcelGarus commented Jul 3, 2020

Exhaustive variants and default values are both concepts applicable in a lot of scenarios, and this feature would help in all of them to make the code more readable. I'd love to be able to use this in Flutter!

return Column(
  mainAxisSize: .max,
  mainAxisAlignment: .end,
  crossAxisAlignment: .start,
  children: <Widget>[
    Text('Hello', textAlign: .justify),
    Row(
      crossAxisAlignment: .baseline,
      textBaseline: .alphabetic,
      children: <Widget>[
        Container(color: Colors.red),
        Align(
          alignment: .bottomCenter,
          child: Container(color: Colors.green),
        ),
      ],
    ),
  ],
);

@lrhn lrhn added the small-feature A small feature which is relatively cheap to implement. label Jul 8, 2020
@lrhn lrhn removed the small-feature A small feature which is relatively cheap to implement. label Sep 8, 2020
@munificent
Copy link
Member

munificent commented Sep 10, 2020

Replying to @mraleph's comment #1077 (comment) on this issue since this is the canonical one for enum shorthands:

I think this is extremely simple feature to implement - yet it has a very delightful effect, code becomes less repetetive and easier to read (in certain cases).

I agree that it's delightful when it works. Unfortunately, I don't think it's entirely simple to implement. At least two challenges are I know are:

How does it interact with generics and type inference?

You need a top-down inference context to know what .foo means, but we often use bottom-up inference based on argument types. So in something like:

f<T>(T t) {}

f(.foo)

We don't know what .foo means. This probably tractable by saying, "Sure, if there's no concrete inference context type, you can't use the shorthand", but I worry there are other complications related to this that we haven't realized yet. My experience is that basically anything touching name resolution gets complex.

What does it mean for enum-like classes?

In large part because enums are underpowered in Dart, it's pretty common to turn an enum into an enum-like class so that you can add other members. If this shorthand only works with actual enums, that breaks any existing code that was using the shorthand syntax to access an enum member. I think that would be really painful.

We could try to extend the shorthand to work with enum-like members, but that could get weird. Do we allow it at access any static member defined on the context type? Only static getters whose return type is the surrounding class's type? What if the return type is a subtype?

Or we could make enum types more full-featured so that this transformation isn't needed as often. That's great, but it means the shorthand is tied to a larger feature.

How does it interact with subtyping?

If we extend the shorthand to work with enum-like classes, or make enums more powerful, there's a very good chance you'll have enum or enum-like types that have interesting super- and subtypes. How does the shorthand play with those?

Currently, if I have a function:

foo(int n) {}

I can change the parameter type to accept a wider type:

foo(num n) {}

That's usually not a breaking change, and is a pretty minor, safe thing to do. But if that original parameter was an enum type and people were calling foo with the shorthand syntax, then widening the parameter type might break the context needed to resolve those shorthands. Ouch.

All of this does not mean that I think a shorthand is intractable or a bad idea. Just that it's more complex than it seems and we'll have to put some real thought into doing it right.

@Abion47
Copy link

Abion47 commented Sep 10, 2020

@munificent

If changing the interface breaks the context to the point that name inference breaks, then that is probably a good thing in the same way that making a breaking change in a package should be statically caught by the compiler. It means that the developer needs to update their code to address the breaking change.

To your last example in particular

foo(int n) {}
// to
foo(num n) {}

if that original parameter was an enum type

Enums don't have a superclass type, so I don't really see how an inheritance issue could arise when dealing with enums. With enum-like classes, maybe, but if you have a function that takes an enum-like value of a specific type, changing the type to a wider superclass type seems like it would be an anti-pattern anyway, and regardless would also fall into what I said earlier about implementing breaking changes resulting in errors in the static analysis of your code being a good thing.

@mraleph
Copy link
Member

mraleph commented Sep 10, 2020

Unfortunately, I don't think it's entirely simple to implement. At least two challenges are I know are:

FWIW you list design challenges, not implementation challenges. The feature as I have described it (treat .m as E.m if .m occurs in place where E is statically expected) is in fact extremely simple to implement. You just treat all occurrences of .m as a dynamic, run the whole inference and then at the very end return to .m shorthands - for each of those look at the context type E and check if E.m is assignable to E (this condition might be tightened to require E.m to be specifically static final|const E m). If it is - great, if it is not issue an error. Done. As described it's a feature on the level of complexity of double literals change that we did few years back (double x = 1 is equivalent to double x = 1.0).

I concede that there might be some design challenges here, but I don't think resolving them should be a blocker for releasing "MVP" version of this feature.

Obviously things like grammar ambiguities would need to be ironed out first: but I am not very ambitions here either, I would be totally fine shipping something that only works in parameter positions, lists and on the right hand side of comparisons - which just side steps known ambiguities.

Just that it's more complex than it seems and we'll have to put some real thought into doing it right.

Sometimes putting too much thought into things does not pay off because you are entering the area of diminishing returns (e.g. your design challenges are the great example of things which I think is not worth even thinking about in the context of this language feature) or worse you are entering analysis paralysis which prevents you from moving ahead and actually making the language more delightful to use with simple changes to it.

That's usually not a breaking change, and is a pretty minor, safe thing to do.

You break anybody doing this:

var x = foo;
x = (int n) { /* ... */ }

Does it mean we should maybe unship static tear-offs? Probably not. Same applies to the shorthand syntax being discussed here.

@lukepighetti
Copy link

lukepighetti commented Sep 10, 2020

I'm not a computer scientist but aren't the majority of these issues solved by making it only work with constructors / static fields that share return a type that matches the host class & enum values? That's my only expectation for it anyway, and none of those come through generic types to begin with. If the type is explicit, it seems like the dart tooling would be able to to know what type you're referring to.

I don't think the value of this sugar can be understated. In the context of Flutter it would offer a ton of positive developer experience.

enum FooEnum {
  foo,
  bar,
  baz
}

f(FooEnum t) {}

f(.foo) // tooling sees f(FooEnum .foo)
f(.bar) // tooling sees f(FooEnum .bar)
f(.baz) // tooling sees f(FooEnum .baz)

In the context of Flutter the missing piece that I find first is how to handle foo(Color c) and trying to do foo(.red) for Colors.red. That seems like it would be a nice feature but I'm not sure how you'd handle that quickly and cleanly. I don't think it's necessary to be honest, though.

@munificent
Copy link
Member

munificent commented Sep 10, 2020

FWIW you list design challenges, not implementation challenges.

Yes, good point. I mispoke there. :)

As described it's a feature on the level of complexity of double literals change that we did few years back

That feature has caused some problems around inference, too, though, for many of the same reasons. Any time you use the surrounding context to know what an expression means while also using the expression to infer the surrounding context, you risk circularity and ambiguity problems. If we ever try to add overloading, this will be painful.

I concede that there might be some design challenges here, but I don't think resolving them should be a blocker for releasing "MVP" version of this feature.

We have been intensely burned on Dart repeatedly by shipping minimum viable features:

  • The cascade syntax is a readability nightmare when used in nested contexts. The language team at the time dismissed this as, "Well, users shouldn't nest it." But they do, all the time, and the code is hard to read because of it. No one correctly understands the precedence and god help you if you try to combine it with a conditional operator.

  • We shipped minimal null-aware operators that were described as a "slam dunk" because of how simple and easy it was. If I recall right, the initial release completely forgot to specify what short-circuiting ??= does. The ?. specified no short-circuiting at all which made it painful and confusing to use in method chains. We are laboriously fixing that now with NNBD and we had to bundle that change into NNBD because it's breaking and needs an explicit migration.

  • The generalized tear-off syntax was basically dead-on-arrival and ended up getting removed.

  • Likewise, the "minimal" type promotion rules initially added to the language didn't cover many common patterns and we are again fixing that with NNBD (even though most of it is not actually related to NNBD) because doing otherwise is a breaking change.

  • The crude syntax-driven exhaustiveness checking for switch statements was maybe sufficient when we were happy with any function possibly silently returning null if it ran past the end without a user realizing but had to be fixed for NNBD.

  • The somewhat-arbitrary set of expressions that are allowed in const is a constant friction point and every couple of releases we end up adding a few more cherry-picked operations to be used there because there is no coherent principle controlling what is and is not allowed in a const expression.

  • The completely arbitrary restriction preventing a method from having both optional positional and optional named parameters causes real pain to users trying to evolve APIs in non-breaking ways.

  • The deliberate simplifications to the original optional type system—mainly covariant everything, no generic methods, and implicit downcasts—were the wrong choice (though made for arguably good reasons at the time) and had to be fixed with an agonizing migration in Dart 2.

I get what you're saying. I'm not arguing that the language team needs to go meditate on a mountain for ten years before we add a single production to the grammar. But I'm pretty certain we have historically been calibrated to underthink language designs to our detriment.

I'm not proposing that we ship a complex feature, I'm suggesting that we think deeply so that we can ship a good simple feature. There are good complex features (null safety) and bad simple ones (non-shorting ?.). Thinking less may by necessity give you a simple feature, but there's no guarantee it will give you a good one.

It's entirely OK if we think through something and decide "We're OK with the feature simply not supporting this case." That's fine. What I want to avoid is shipping it and then realizing "Oh shit, we didn't think about that interaction at all." which has historically happened more than I would like.

You break anybody doing this:

var x = foo;
x = (int n) { /* ... */ }

Does it mean we should maybe unship static tear-offs?

That's why I said "usually". :) I don't think we should unship that, no. But it does factor into the trade-offs of static tear-offs and it is something API maintainers have to think about. The only reason we have been able to change the signature of constructors in the core libraries, which we have done, is because constructors currently can't be torn off.

@Abion47
Copy link

Abion47 commented Jun 13, 2024

I personally think that the consequences of parameter default scopes are worse than the added value.

I hate the fact that two double could have a different list of available .identifier.

This is opening the door for so much confusion IMO.

This is also an excellent point. Consider the following:

class Beauty {
  static const low = 0.0;
  static const medium = 0.5;
  static const high = 1.0;
}

class Math {
  static const pi = 3.14;
  static const tau = 6.28;
  static const e = 2.72;
}

double foo({ double beauty in Beauty, double math in Math });

The fact that foo(.medium, .tau) would compile while foo(.tau, .medium) wouldn't despite both parameters and both constants being double is going to cause some major mental dissonance. It will also just rub more than a few people the wrong way.

The only thing parameter default scopes would accomplish that static extension methods wouldn't is that it adds identifiers to a parameter without adding it to the type's own namespace. The benefits of that are few and highly subjective, so you would find it hard to convince anyone that that alone is worth the long list of downsides, complexities, and very probable sources of confusion.

@bernaferrari
Copy link

bernaferrari commented Jun 13, 2024

I like when I can copy and paste the code anywhere and it still works. This wouldn't allow it, so I don't like it.

@dickermoshe
Copy link

dickermoshe commented Jun 14, 2024

Overview

I have another place where this feature is extremely useful that may not be readily apparent:
Generated Code

Ramblings

The Drift ORM (which I co-maintain) uses generated code for it's Manager API.

This generated code includes pre-built filters for composing complex queries easily.
We didn't want users to have to use very ambiguous class names (like UsersTableFilter) to write these queries.
Being that the developer hasn't named the class himself, he has no idea what he should be using without digging through the generated code.

The code would have looked horrible:

users.filter(UsersTableFilter.name("Bob") & UsersTableFilter.age(10));

Instead we decided to use a callback which provides the class UsersTableFilter as an argument.
This is much more readable, but less user friendly.

users.filter((f) => f.name("Bob") & f.age(10));

In pretty much all packages that generate code (like freezed, auto_route, etc.) the generated classes have with long, badly names. This means that the developer limits what classes are used externally Also, being that the developer hasn't the generated code, he has no idea what the name is.

This feature would allow for packages that generate code to be used in a much more user friendly way

users.filter(.name("Bob") & .age(10));

@lrhn
Copy link
Member

lrhn commented Jun 14, 2024

I think it's unlikely that .name("Bob") & .age(10) will work.
Even if that expression has a context type, the .name("Bob") is in receiver position and has no context type, and .age(10) would get its context type from the parameter type of operator & of the receiver. Neither will use the context type of the entire expression.

Even if we introduce receiver context inference, we'd have to guess that a bobcat operator will have operands of the same type as the result.
We could do that, assume that an operator with only shorthand operands will have operands of the same type as the result.

@eernstg
Copy link
Member

eernstg commented Jun 14, 2024

@Abion47 wrote:

Consider the following:

class Beauty {
  static const low = 0.0;
  static const medium = 0.5;
  static const high = 1.0;
}

class Math {
  static const pi = 3.14;
  static const tau = 6.28;
  static const e = 2.72;
}

double foo({ double beauty in Beauty, double math in Math });

The fact that foo(.medium, .tau) would compile while foo(.tau, .medium) wouldn't despite both parameters and both constants being double is going to cause some major mental dissonance. It will also just rub more than a few people the wrong way.

That's an interesting conclusion, and quite surprising to me!

I'd arrive at the opposite conclusion, looking at exactly the same code: The author of foo has explicitly indicated that the specific values found as static members of Beauty are particularly relevant as actual arguments passed to the parameter beauty, and similarly for Math and math. The author of an API is likely to know which actual arguments are particularly significant, such that they deserve to be given a name and offered as possible actual arguments to specific parameters.

You can obviously ignore the default scope and pass any double value you want to both of those parameters, including beauty: Math.tau and math: Beauty.medium. However, that's likely to be a strong code smell because the expressions Math.tau and Beauty.medium clearly hint that there is an association between the namespace Math and the parameter math, and similarly between Beauty and beauty. In fact, foo(beauty: Math.tau, math: Beauty.medium) will immediately stand out and be double checked because it looks wrong. You can do it, but it looks like a bug.

The parameter default scope mechanism is designed to make the well-aligned invocations concise, and require the mis-aligned ones to be written out explicitly. If the mechanism is used as intended then the mis-aligned ones are indeed going to be likely bugs, so it's good that they stand out.

For that reason, I think it's a feature rather than a bug that foo(beauty: .tau, math: .medium) is a compile-time error.

If you insist then you can write foo(beauty: Math.tau, math: Beauty.medium) such that the mismatches are explicit. I don't want to support a version of that invocation that makes the mismatches implicit.

Finally, if the double variables in Math and in Beauty are just values of type double, with no implied relevance to any particular parameter, then you shouldn't use a parameter default scope in the first place. In that case you would probably write the expressions explicitly (that is, nothing is abbreviated):

 void main() {
  double d = Math.tau + Beauty.medium;
  foo(beauty: Math.tau, math: Beauty.medium); // This is perfectly appropriate!
}

In this case, Math.tau and Beauty.medium are just values of type double, and we do not intend to nudge developers in the direction of passing Math values to math or Beauty values to beauty, the naming similarity is completely accidental.

Finally, you could also use the namespace of the class double, if you insist that those values are always relevant to any situation where a double is needed (at least, in the scope of the static extension):

static extension Beauty on double { // We could omit the name `Beauty`, if preferred.
  static const low = 0.0;
  static const medium = 0.5;
  static const high = 1.0;
}

static extension Math on double { // Ditto, could omit `Math`.
  static const pi = 3.14;
  static const tau = 6.28;
  static const e = 2.72;
}

void main() {
  double d = .pi;
  d = .low;
}

You can do it, but I think it's very unlikely to be a good idea. If we put a large number of named values of a particular type into the namespace of that type itself then those named values will be available in every situation where the context type is that type.

I just don't think we're going to be happy about a 500 element long list of completions whenever we type . at a location where a double is expected. double d = .nan is OK, and .infinity and a couple of other values that are really universally known and plausibly relevant in any context, but other named values are generally relevant to a specific context (e.g., a bunch of parameters of methods in a package), and they shouldn't pollute completion everywhere else.

@eernstg
Copy link
Member

eernstg commented Jun 14, 2024

@dickermoshe wrote about UsersTableFilter. I'll expand a bit on @lrhn's response. Here is the example again:

users.filter(UsersTableFilter.name("Bob") & UsersTableFilter.age(10));

I couldn't immediately find UsersTableFilter at https://drift.simonbinder.eu/, so I'll guesstimate what is going on here. I don't think that should be too hard.

I assume that users.filter accepts an argument of type UsersTableFilter, that UsersTableFilter.name and UsersTableFilter.age are constructors (or static methods returning a UsersTableFilter), and UsersTableFilter declares an operator with the signature UsersTableFilter operator &(UsersTableFilter). We could then do at least some of the requested things:

class UsersTableFilter {
  UsersTableFilter.name(String _);
  UsersTableFilter.age(int _);
  UsersTableFilter operator &(UsersTableFilter _) => this;
}

class Users {
  void filter(UsersTableFilter _) {}
}

void main() {
  var users = Users();
  users.filter(.name("Bob")); // OK.
  users.filter(UsersTableFilter.name("Bob") & .age(10)); // OK.
  users.filter(.name("Bob") & .age(10)); // Compile-time error.
}

The reason why we can abbreviate UsersTableFilter.name("Bob") to .name("Bob") in the first invocation is that the context type is UsersTableFilter, and the constructor that we're invoking is declared in UsersTableFilter.

In the next invocation we can abbreviate UsersTableFilter.age(10) to .age(10) because of the same context type.

However, we cannot abbreviate both operands of &, because then .name("Bob") is a receiver, so it doesn't have a context type (well, the context type is _ which just means "I don't care"). We would basically have to search the entire universe to see if there is some type C out there which has a constructor C.name and an operator &. The "intended" interpretation is that C is UsersTableFilter, but we can't assume this in general, it could be a completely different class. Also, it could be a static member (method or variable). Etc.etc.

There is ongoing work to allow receivers to have a non-trivial context type, but it seems unlikely that it would cover this kind of situation. So we can do some of these things using parameter default scopes, but not all.

@nidegen
Copy link

nidegen commented Jun 14, 2024

A bit random my comment, but I just really wish I could type mainAxisAliggment: .c and it would autocomplete to mainAxisAliggment: .center :)

@eernstg
Copy link
Member

eernstg commented Jun 14, 2024

wish I could type mainAxisAliggment: .c and it would autocomplete to mainAxisAliggment: .center :)

I think every proposal will do that.

@Abion47
Copy link

Abion47 commented Jun 14, 2024

@eernstg

I'd arrive at the opposite conclusion, looking at exactly the same code: The author of foo has explicitly indicated that the specific values found as static members of Beauty are particularly relevant as actual arguments passed to the parameter beauty, and similarly for Math and math. The author of an API is likely to know which actual arguments are particularly significant, such that they deserve to be given a name and offered as possible actual arguments to specific parameters.

The only time an author can possibly know what all arguments to a parameter will be significant is if the parameter itself were of a sealed enum type. Otherwise, the author cannot know if the user has either extended the type or has specified some constant values of their own that they wish to pass as an argument. This goes back to the problem that I laid out in the other issue that parameter default scopes aren't extensible - they require the author to either enforce a rigid contract or see the future.

Also:

You can obviously ignore the default scope and pass any double value you want to both of those parameters, including beauty: Math.tau and math: Beauty.medium. However, that's likely to be a strong code smell because the expressions Math.tau and Beauty.medium clearly hint that there is an association between the namespace Math and the parameter math, and similarly between Beauty and beauty. In fact, foo(beauty: Math.tau, math: Beauty.medium) will immediately stand out and be double checked because it looks wrong. You can do it, but it looks like a bug.

I agree that there is code smell in that example, but I disagree where it's ultimately coming from.

If there is a scenario where the source of the value matters so much as to actually specify the source of the extended type(s) to pull from, then that would imply that there is significant correlation of said type with the value itself. Say in the above example, the beauty parameter works as a gradient scale, with low = 0.0 and high = 1.0. In that case, passing Math.tau to that parameter would suggest that something has a beauty rating six times higher than the highest defined rating. For that matter, what if someone passed 1000000.0 as a beauty value?

The issue with the PDS approach is that it promotes validation by convention. The contract between type and value is virtually non-existent, so there is no language function that actually checks if the passed value is an appropriate one. In this case, I would not consider PDS to be a feature, I would consider it an anti-pattern because it is promoting the use of an approach that isn't the best one for the task.

If the association between the namespace of the scoped type(s) and the passed value is so strong that it's worth specifying a type, then the parameter shouldn't be a double at all. It should be a value enum:

enum Beauty {
  low(0.0),
  medium(0.5),
  high(1.0);

  final double value;

  const Beauty(this.value);
}

enum Math {
  pi(3.14),
  tau(6.28),
  e(2.72);

  final double value;

  const Math(this.value);
}

double foo(Beauty beauty, Math math);

Now both foo(.medium, .tau) or foo(Beauty.medium, Math.tau) will work fine, and both foo(.tau, .medium) and foo(Math.tau, Beauty.medium) will cause an error. The contract between type and value is explicit. Not only that, but people can't just pass whatever value they want to fields that were intended to represent specific values.

The parameter default scope mechanism is designed to make the well-aligned invocations concise, and require the mis-aligned ones to be written out explicitly. If the mechanism is used as intended then the mis-aligned ones are indeed going to be likely bugs, so it's good that they stand out.

Except the mechanism doesn't facilitate that at all. Well-named types, variables, and functions do. On its own, the PDS mechanism doesn't provide any more protection against misused invocations than a style guide.

Finally, if the double variables in Math and in Beauty are just values of type double, with no implied relevance to any particular parameter, then you shouldn't use a parameter default scope in the first place. In that case you would probably write the expressions explicitly (that is, nothing is abbreviated):

The problem is that the number of scenarios in which it makes sense to use PDS is getting narrower and narrower. As it stands, the only time it would be potentially a good application would be if:

  • The parameters of a function need to be able to take any arbitrary value of a root type.
  • There is a single collection of pre-defined values that can be used for convenience.
  • That collection is specific to the function and its values shouldn't be added to the root type itself.
  • That collection is not expected to be extended by the user.

This is a hyper-specific set of circumstances, and most examples of these circumstances I have seen have other problems with them that would prompt the question of whether or not it should just be refactored. And beyond that, point 1 and 4 are directly at odds with each other. If a function can take any arbitrary parameter beyond what is pre-defined, then it would not at all be unusual for the user to want to be able to define their own pre-defined types, at which point they will get upset that they can't use the dot syntax to reference them. At that point, either they will have to either put up with the limitations of PDS or they will end up extending the root type with their values anyway.

I just don't think we're going to be happy about a 500 element long list of completions whenever we type . at a location where a double is expected. double d = .nan is OK, and .infinity and a couple of other values that are really universally known and plausibly relevant in any context, but other named values are generally relevant to a specific context (e.g., a bunch of parameters of methods in a package), and they shouldn't pollute completion everywhere else.

This is the same concern about putting stuff in the global namespace in languages like JS, and the solution there should be the solution here - don't do that. There will almost always be code smell involved with extending a built-in type, particularly a primitive type like double. If someone has a bunch of app-constants, stapling them directly to the double type rather than putting them in a utility class would be bad practice. But at the same time, if someone had a calculation library, they might want to put those constants on double to make expressions involving global constants concise. (e.g. final gravForce = objectMass * .gravitationalConstant;)

Also, keep in mind that values added to double wouldn't be available everywhere - only in files that import the extension. In other words, if the flutter/material package exposed an extension that extended Color with all the values in Colors, those values wouldn't be visible in a file where someone only imported flutter/cupertino.

@eernstg
Copy link
Member

eernstg commented Jun 14, 2024

  • The parameters of a function need to be able to take any arbitrary value of a root type.

This is known as the type of the parameter. Any value of a type which is assignable to the type of the parameter can be passed. That is hardly a rare exception. Actually, every single formal parameter in Dart has that property.

Not much narrowing so far.

  • There is a single collection of pre-defined values that can be used for convenience.

In some situations there may be such a single collection. That's fine.

In other situations there's a need for extensibility. I've several times mentioned the example where some namespace (for example, Color or some separate namespace that we have called ui.Colors many times) is populated by static extensions.

That's definitely not limited to a single collection, that's a collection which is created by clients according to their needs and preferences.

  • That collection is specific to the function and its values shouldn't be added to the root type itself.

Your choice, you can add them to a separate namespace or to the root type. Please don't pretend that anything will stop you if you make the choice to add them to the root type.

  • That collection is not expected to be extended by the user.

You really, seriously do not read what I'm writing, right? I haven't tried to count how many times I've explained that extensibility is an integrated and important part of this proposal, and it is achieved by means of static extensions that are used to populate the chosen default scopes, be it the parameter type itself or some other namespace.

This is a hyper-specific set of circumstances

None of your points have been valid so far, you're just inventing limitations that do not exist.

I just don't think we're going to be happy about a 500 element long list of completions

don't do that

It is you, not I, who has maintained that it is unacceptable to have any other mechanism than the context type to determine the set of named, distinguished values of the form .id we can use in any given situation.

If we must put every such value into the same bucket, and that's a widely used type (like int, double, or String, and lots of other types are also widely used) then it will inevitably create a large namespace. I expect that large namespaces will be inconvenient to navigate.

The ability to use an in E clause on a parameter is exactly a mechanism that makes it possible to create any number of smaller buckets, containing distinguished values for a particular purpose. It's more work to use separate namespaces (for the API provider, not for the client), but that's an engineering trade-off, just like so many others.

@Abion47, I'm sorry I had to turn up the volume a bit at this time. I will not continue to explain the same things again and again, only to read arguments, again and again, that are obviously based on assumptions that you should know are not true.

@Abion47
Copy link

Abion47 commented Jun 14, 2024

This is known as the type of the parameter. Any value of a type which is assignable to the type of the parameter can be passed. That is hardly a rare exception. Actually, every single formal parameter in Dart has that property.

Not much narrowing so far.

You misunderstand. "Any arbitrary value" means any value assignable to a type that a user might provide, as opposed to there being only a small subset of available values that the author restricts the input to.

For instance, this:

void foo(int bar) // accepts any integer

And not this:

enum Numbers { one, two, three }

void foo(A bar) // The only valid arguments are Numbers.one, Numbers.two, or Numbers.three

Your choice, you can add them to a separate namespace or to the root type. Please don't pretend that anything will stop you if you make the choice to add them to the root type.

This subjective choice is now going back to another major problem listed in the other issue - it's an opt-in feature. If a package author didn't agree with you that PDS is beneficial, they won't design their package using it. Now everyone using their package is locked into their opinion with no way to work around it.

You really, seriously do not read what I'm writing, right? I haven't tried to count how many times I've explained that extensibility is an integrated and important part of this proposal, and it is achieved by means of static extensions that are used to populate the chosen default scopes, be it the parameter type itself or some other namespace.

And as I have repeatedly brought up, your idea of extensibility for your feature is to piggy back on another feature that already does 99% of what your proposal will accomplish with the remaining 1% being extremely limited and subjective in its benefits.

And on top of this, you have yet to address any of the other REAL concerns I brought up in the other issue thread:

  • The feature is opt-in.
  • The feature uses confusing unintuitive syntax.
  • The feature requires a lot of fragile boilerplate.

Lest you say these are all just my opinions, note that I am not the only one to bring them up. So until you can adequately address these four things, your proposal is not going to get any real traction.

If we must put every such value into the same bucket, and that's a widely used type (like int, double, or String, and lots of other types are also widely used) then it will inevitably create a large namespace. I expect that large namespaces will be inconvenient to navigate.

Not any more inconvenient than having to navigate those same namespaces without the dot syntax. If I wanted to use a member of Colors that had been extended onto Color, I would see the same autocomplete options whether I used Colors., Color., or just ..

I think you are greatly overestimating the real-world impact of static extensions on namespace pollution. Namespace pollution is only a problem when values are added to the namespace that don't belong in that namespace, and when that happens, 999999 times out of a million, it's an implementation problem, not a language problem. The overwhelming majority of the time, when someone finds themselves in this situation, it's their own fault.

And again, if your worry is that people will start to add hundreds of extended members onto common Dart types, that's already a discouraged practice in any language that supports a global namespace or these kinds of extensions.

It is you, not I, who has maintained that it is unacceptable to have any other mechanism than the context type to determine the set of named, distinguished values of the form .id we can use in any given situation.

I never said that was unacceptable. What I said was it should be the target for this issue to be implemented as it keeps the baseline functionality simple and easy to understand.

I'm sorry I had to turn up the volume a bit at this time. I will not continue to explain the same things again and again, only to read arguments, again and again, that are obviously based on assumptions that you should know are not true.

The reason I repeat these arguments (even on this thread where it is likely off-topic, but it was brought up and now here we are) is that you aren't adequately addressing them. You have yet to defend why your proposed feature needs to exist beyond an extremely subjective viewpoint regarding a handful of extremely specific circumstances. You have yet to explain why your feature's few benefits outweigh the staggeringly long list of downsides and complexities that exist in its current design.

So I'm sorry if you're getting irritated by these arguments, but every time I bring them up, your response boils down to either "if you don't like the feature, don't use it" or "avoiding polluting the namespace is worth introducing all these other problems". Because I'm sorry, but that's not good enough.

@gryznar

This comment was marked as resolved.

@lutes1
Copy link

lutes1 commented Sep 18, 2024

I suggest you try SwiftUI for a month and then switch back to Flutter.

I bet that after your semicolon rant, you'll see this feature as really the game changer it is in a modern declarative UI framework

I am against that. This feature has a lot of edge cases, introduces confusion in many situations and will unnecessary complicate compiler and analyzer implementation

@rrousselGit
Copy link

rrousselGit commented Sep 18, 2024

@eernstg Going back to the in feature.

Could the concern be instead solved through extension types? Not static extensions, but the feature that was added not too long ago.

Given:

fn({num beauty in Beauty});

We could alternatively express it as:

extension type const Beauty(num value) {
  static const Beauty high = Beauty(1);
}

fn({Beauty beauty });

I think this would be more sensical, as extension types have an actual impact on type safety.
This way, the safety isn't limited to the .identfier syntax, but also benefit Type.identifier too.

One thing I dislike with the in proposal is that if we don't want to allow fn(beauty: .pi), I don't think we should allow fn(beauty: Math.pi).
With extension types, fn(beauty: Math.pi) would become an error. But you would still have the option to map/cast it: fn(beauty: Beauty(Math.pi). This feels more explicit, and it's a win-win overall IMO.

It would be a bit breaking to make such a change for Colors. But a tool could easily automate the fix.

@gryznar
Copy link

gryznar commented Sep 19, 2024

enum CompassPoint {
  north,
  south,
  east,
  west,
}

if (myValue == .north) {
  // do something
}

Main concerns comes to me from original case. Consider:

enum CompassPoint {
  north,
  south,
  east,
  west,
}

enum Direction {
  north,
  south,
  east,
  west,
}

if (myValue == .north) {
  // do something
}

The same applies to import. If e.g. external library would define enum with north member, allowing syntax like:
if (myValue == .north) { } could lead to strange bugs. On the other hand, I am fine if type is specified at callee site (functons) and in case above - required to annotate type of myValue

@lrhn
Copy link
Member

lrhn commented Sep 19, 2024

The way .north is currently proposed to work, it's only looked up in types related to type expected for the expression.
That is, a void goForth(Direction dir) { ... } would make goForth(.north) mean goForth(Direction.north), because CompassPoint is not related to the context type of Direction.

If it works with == too, then the static type of the other operand is used as the guide. Inside goForth, an if (dir == .north) would also mean if (dir == Direction.north), because it's a Direction it's being compared to.
(This is a little more heuristic because the context type of the operand to operator == is usually just Object?. But I think it works.)

If there is every an ambiguity, it's safer to not allow the program to compile than it is to guess, but even if we are very permissive in where we'd look for candidates, I can't see you ever breaking a .name with an enum type as context type by adding something else to the program.

And we probably won't be that permissive. Checking for static members and constructors on the context type/static type of other == operand only, and only accepting those having/returning something of that type, should be enough. (With static extensions, #723, you'd be able to add to that scope, but not clobber something already there.)

@gryznar
Copy link

gryznar commented Sep 19, 2024

The way .north is currently proposed to work, it's only looked up in types related to type expected for the expression. That is, a void goForth(Direction dir) { ... } would make goForth(.north) mean goForth(Direction.north), because CompassPoint is not related to the context type of Direction.

If it works with == too, then the static type of the other operand is used as the guide. Inside goForth, an if (dir == .north) would also mean if (dir == Direction.north), because it's a Direction it's being compared to. (This is a little more heuristic because the context type of the operand to operator == is usually just Object?. But I think it works.)

If there is every an ambiguity, it's safer to not allow the program to compile than it is to guess, but even if we are very permissive in where we'd look for candidates, I can't see you ever breaking a .name with an enum type as context type by adding something else to the program.

And we probably won't be that permissive. Checking for static members and constructors on the context type/static type of other == operand only, and only accepting those having/returning something of that type, should be enough. (With static extensions, #723, you'd be able to add to that scope, but not clobber something already there.)

As far as I understand, your point seems to be OK for me. How about sth like this:

late dynamic myValue; // not initialized, initialization goes later based of some runtime condition.
// Variable can be assigned to CompassPoint  / Direction or sth else
if (myValue == .north) {
  // do something
}

If compiler would reject such dynamic cases, I am OK with proposition

@rrousselGit
Copy link

This feature wouldn't work with dynamic. At least, that's not what has been discussed so far.

@gryznar
Copy link

gryznar commented Sep 19, 2024

This feature wouldn't work with dynamic. At least, that's not what has been discussed so far.

Ok then. Sorry for fuss. I am +1

@eernstg
Copy link
Member

eernstg commented Sep 23, 2024

@rrousselGit wrote:

Given:

fn({num beauty in Beauty});

We could alternatively express it as:

extension type const Beauty(num value) {
  static const Beauty high = Beauty(1);
}

fn({Beauty beauty });

I think this would be more sensical, as extension types have an actual impact on type safety.

That's a very interesting idea! It does differ from the default scope idea, of course, and some things do get harder. For example: In order to invoke fn with any argument that isn't a distinguished value we would now need to make it a Beauty: For instance, we'd use fn(beauty: Beauty(0.99)) rather than fn(beauty: 0.99).

We could make the invocation of the constructor implicit if we get support for implicit constructors. However, this introduces an extra (and an implicit) step to the situation, which is probably not very good for the overall readability of the code.

The point about type safety is that only expressions of type Beauty can be passed as actual arguments to fn. However, any object of type num can also have type Beauty, using Beauty(saidNum), which means that it doesn't provide any type safety that differs from just accepting a num.

It does make a difference when it comes to distinguished values: We can do fn(beauty: .high) and we can't do fn(beauty: .anythingElse), because anythingElse isn't a static member of Beauty.

In other words, this idea seems to be highly optimized for passing distinguished values, and not so user-friendly for passing any other value.

If we actually, seriously, want to enforce that only some distinguished values can be passed then we do have another option:

enum Beauty {
  low(0),
  medium(0.5),
  high(1);

  final num value;
  const Beauty(this.value);
}

void fn({Beauty beauty = Beauty.medium}) {
  var beautyValue = beauty.value;
  // ...
}

void main() {
  fn(beauty: .high);
}

This is not only optimized for distinguished values, it enforces that we can't have anything other than one of the distinguished values.

... if we don't want to allow fn(beauty: .pi), I don't think we should allow fn(beauty: Math.pi).

That's again a good point!

However, I have a slightly different starting point: I'm thinking of this mechanism as a way to provide convenient and concise access to specific (that's what I call "distinguished") values of the required type. I haven't considered it to be an important goal to avoid any values of the correct type to be passed. So if you wish to call fn(beauty: Math.pi) then go ahead and do that (it may not "work", in some sense, but you could just as well call fn(beauty: 3.1415926), and I don't see any support for making that an error).

However, you do get a hint: fn(beauty: .pi) is an error, so .pi isn't a distinguished value for this parameter.

@rrousselGit
Copy link

rrousselGit commented Sep 23, 2024

I don't think there's anything wrong with having to write fn(beauty: Beauty(0.99)) or fn(beauty: Beauty(Math.pi)).
If we want to actively discourage fn(beauty: .pi), IMO those snippets listed previously make a lot of sense. But anyway, that's a bit tangential to the topic.

I personally really dislike the inconsistency between fn(beauty: Math.pi) and fn(beauty: .pi). I don't think it's sane for one to be a compilation error, and the other to be completely legal with no diagnostic at all.
You mentioned that with in Type, we get an hint at an error. But a hint would be something folks can ignore, not something mandatory.


Overall, I just feel like that in Type doesn't do enough to be a language feature. Its primary purpose is to give hints and impact autocompletion ; which is to say, not much.

It almost feels like in should be an annotation instead:

fn({@In(Beauty) num beauty});

We could very well have that @In implement the full scope of what's discussed here:

  • Specifying @In(Type) could reduce the scope of what's suggested during autocompletion.
    Such that typing fn(beauty: . would only suggest Beauty values.
  • fn(beauty: .99) is still legal with no problem
  • Any .identifier that aren't from the desired Type would produce a diagnostic.
  • Folks are free to ignore those diagnostics and assign PI to fn(beauty: if they wish, be it with fn(beauty: .pi) or fn(beauty: Math.pi)

And if a package author is looking for something stricter, they can instead use any of the mentioned alternatives (enums, classes, extension types, whatever...)

@eernstg
Copy link
Member

eernstg commented Sep 24, 2024

Overall, I just feel like that in Type doesn't do enough to be a language feature. Its primary purpose is to give hints and impact autocompletion ; which is to say, not much.

Well, the purpose of the in clause on parameters is to enable abbreviated references to static members and/or constructors declared in the specified namespace (which could be a class, a mixin, an extension, ...). It was never a goal to change the set of values that we can pass to that parameter; if you want to do that then go ahead and change the declared type of the parameter, whose purpose is exactly that.

I don't really understand the part about hints and autocompletion. You can write .high rather than SomeNamespace.high in specific locations, and that's it.

Surely, an IDE with support for completion could transform .h to .high, just like it can complete SomeNamespace.h to SomeNamespace.high today. You might take it as a hint that it wouldn't complete .p to .pi (and if you actually write .pi then you'll get a compile-time error because there is no static declaration named pi in the given namespace). But this is just normal completion and normal compile-time errors applied to the new mechanism, it doesn't mean that the mechanism is "about" hints or autocompletion. It's about the ability to abbreviate certain expressions.

in should be an annotation

Dart doesn't otherwise provide language mechanisms shaped as metadata. Of course, any mechanism could be given any syntactic form that provides the required information, so it doesn't matter, technically. But we're definitely talking about a language mechanism because it changes the binding of some name applications to the corresponding declarations.

Specifying @In(Type) could reduce the scope of what's suggested during autocompletion.

Sounds like you're thinking about a different mechanism: We would receive help from the IDE to transform fn(beauty: .high) to fn(beauty: SomeNamespace.high). This means that we don't have an abbreviation at all, we just receive some help from the IDE to write the full expression (same as today).

I'm proposing a mechanism that allows the program to contain the short form, not an IDE feature that allows us to obtain the long form that we have today with less manual work.

fn(beauty: .99) is still legal with no problem

Good, I'd want that from any proposal in this topic area. We can pass any expression whose static type is assignable to a type T as an actual argument to a parameter whose declared type is T. The proposed feature just turns .high into an expression which is equivalent to SomeNamespace.high (or Beauty.high, or whatever it is), when it occurs in this specific location.

Any .identifier that aren't from the desired Type would produce a diagnostic.

Any .identifier whatsoever would be a compile-time error unless we're considering a language mechanism. If we're talking about an IDE feature then we may be able to easily transform it into SomeNamespace.identifier, and that mechanism could of course refuse to do it unless SomeNamespace contains a static member named identifier or a constructor named SomeNamespace.identifier. The diagnostic could be shown when the transformation would otherwise have happened. OK, no problems.

It would definitely be possible to do those things. But it wouldn't abbreviate anything, it would just allow us to write the same code with fewer keystrokes.

I'm proposing a language mechanism that allows programs to contain abbreviated expressions whose meaning is defined in terms of the relevant namespace. The most common case will surely be that there is no in clause, and the namespace is the context type (in other words, a parameter declaration T p means T p in T by default), but for those cases where we want to have more control we can specify T p in S and then we'll process .identifier as a member of the namespace S rather than T.

My assumption is that it's a reasonable style choice to have programs where the abbreviated expressions exist. That is, I'm assuming that MyWidget(... mainAxisAlignment: .center ...) is at least as readable as MyWidget(... mainAxisAlignment: MainAxisAlignment.center ...)). If that is true then the language mechanism has a justification that doesn't apply for the IDE feature (where it would always be MyWidget(... mainAxisAlignment: MainAxisAlignment.center ...) because the short form is just a compile-time error).

Otherwise, if you insist that actual code must always use the long form MyWidget(... mainAxisAlignment: MainAxisAlignment.center ...) and the short form will just exist in the IDE for about half a second before it's expanded, then the IDE feature may be preferable.

YMMV.

@rrousselGit
Copy link

rrousselGit commented Sep 24, 2024

I think there's a big misunderstanding here.
For example, when I suggested fn({@In(Beauty) num beauty}), it wouldn't be about having the IDE autocompleting fn(beauty: .high) into fn(beauty: Beauty.high). It was merely about having the linter produce a diagnostic when using fn(beauty: .pi)

I think the core of the misunderstanding stems from what in Type does.
I believe you're basing your opinion by assuming that in Type is what tells the compiler the list of available options for the .identifier syntax on a given parameter.

On the flip side, I go by the assumption that there are other mechanisms possible that do not involve in Type for feeding a list of possible distinguished values.
A few options were discussed previously:

  • relying on context type + static extensions. Cf:

    static extension Math on num {
      static const double pi = 3.14
    }
    static extension Beauty on num {
      static const int high = 1;
    }
  • relying on a new keyword when defining static members. Cf:

    abstract class Math {
      static enum double pi = 3.14;
    }
    abstract class Beauty {
      static enum int high = 1;
    }

In both cases, the discussed in Beauty on a parameter would no-longer be necessary for feeding the compiler the list of possible values.
Without that in Type, we could still write fn(beauty: .high).

In that scenario, the in Type only serves as a mean to reduce the possible values. Therefore, it's legitimate to express it as an annotation:

void fn({num beauty});
fn(beauty: .pi); // sugar for either fn(beauty: num.pi) or fn(beauty: Math.pi) based on the implementation used

void fn({@In(Beauty) num beauty});
fn(beauty: .pi); // The code still runs, but analyzer now produces a warning, because num.pi/Math.pi aren't from Beauty

Does that make my view clearer?

I think that distinction matters, because one of the reason for pushback against "context + static extensions" is the namespace pollution and lack of restrictions on things like beauty: .pi ; with in Type used as a stricter alternative.

But those issues are mainly autocompletion/hint issues ; which could be covered by the linter rather than the language.

My apologies, I should've mentioned this from the get-go :)

@eernstg
Copy link
Member

eernstg commented Sep 24, 2024

I think there's a big misunderstanding here.

It's always good to fix those, thanks for the clarifications!

it wouldn't be about having the IDE autocompleting fn(beauty: .high) into fn(beauty: Beauty.high). It was merely about having the linter produce a diagnostic

OK! But this implies that you do assume a language mechanism (such that .high isn't simply a syntax error, it actually means something), and on top of this we would have some IDE features. I'll concentrate on the language mechanism.

I think the core of the misunderstanding stems from what in Type does.

I was assuming my proposal, I don't think anyone else has proposed anything that introduces a clause which is added to a formal parameter declaration. With this proposal, an actual argument passed to a formal parameter declared as T p in S can be any expression whose static type is assignable to T, and it can be .id (possibly followed by more, e.g., .id(42)..bar()), if the namespace denoted by S has a static declaration named id or a constructor named S.id. (Slight variants of this rule applies to other kinds of parameters, e.g., required named ones.)

If you declare a parameter with no in clause then the default is the declared type: T p means T p in T.

This means that in S adds support for looking up .id in S when it is passed as an actual argument to p.

I go by the assumption that there are other mechanisms possible

Certainly.

relying on context type

Indeed, this is the default (and probably vastly most common) case for parameter default scopes, too.

static extensions

Indeed again, they are crucial to my proposal because they allow for a useful kind of separation of concerns: The provider of a service can say "I'll search for the id of .id in a specific namespace N", and the client can populate N with members using static extensions (N may contain declarations already, but static extensions can add more members).

relying on a new keyword when defining static members

That looks like a quite different mechanism, and I haven't thought about the implications. One question does arise, though: How would we know when we encounter .id out there in client code which set of static enum declarations to include in the search?

the discussed in Beauty on a parameter would no-longer be necessary

This is definitely a property that we can achieve. With my proposal, if you don't have an in clause on a parameter then you'll get the context type. So in that sense it's not necessary to provide an in clause.

I just happen to think that it's too inflexible to use the context type (or any other fixed choice of namespace) unconditionally: This implies that every parameter with the same declared type must use the same set of distinguished values (the id for which .id is defined). This is particularly inflexible for frequently used types like double or String, because it means that you have to put high (beauty) and pi (the number) in the same bucket. I introduced the in clause exactly because I wanted to allow the author/maintainer of the source code that contains the parameter declaration to use a customized set of distinguished values, rather than putting everything into the same fixed, global set.

I think you're saying pretty much the same thing here:

one of the reason for pushback against "context + static extensions" is the namespace pollution and lack of restrictions on things like beauty: .pi ; with in Type used as a stricter alternative.

It is possible to use "in Type + static extensions" to get a lot of namespace control. One thing which is still missing, though, is the ability to manipulate the namespaces directly. I'd like to have a static export mechanism for that.

// Provider's library.

void foo(String s in FooNamespace) {}

class FooNamespace {
  static final String id = 'Hello, world!'; // Universally useful!
}

// Client's library, or imported into client's library.

class SomeOtherNamespace {
  static String get id2 => 'Some other useful thing';
  // More useful static members.
}

static extension FooProvider on FooNamespace {
  static export SomeOtherNamespace; // Add all the members to `FooNamespace`.
}

@rrousselGit
Copy link

It sounds like I misunderstood quite a lot of things!
I for sure thought you were against context types/static extensions for this feature ; and were investigating solutions that did not involve those.

If you're talking about a cherry on top, that eases my mind quite a bit. Thank for clarifying :)
So I won't answer every gritty details, as it's suddenly a lot less important.


To avoid misunderstanding more, how does your in Type proposal solves concerns about value conflicts?

Consider:

class A {
  static int distinguished = 1;
}
class B {
  static int distinguished = 2;
}

void fn({int? value in A}) => print(value);
void fn2({int? value in B}) => print(value);

Then, we used as:

fn(value: .distinguished);
fn2(value: .distinguished);

My gut feeling is that with your proposal, this would print:

1
2

That's something I'm really worried about personally.

Instead, what I'd want is a compilation error, warning be about a conflict between two static extensions.
This would be very similar to when two normal extensions add members of the same name:

extension A on Object {
  int get distinguished => 1;
}
extension B on Object {
  int get distinguished => 2;
}

Object value = ...;
print(value.distinguished); // Conflict error

@eernstg
Copy link
Member

eernstg commented Sep 25, 2024

My gut feeling is that with your proposal, this would print: ...

Exactly.

... what I'd want is a compilation error

That's a good point!

I do think it's a conflict which is more or less a built-in property of this feature (in any variant that I can think of): If the point is that we want to write .id rather than SomeNamespace.id then it must basically be necessary to write SomeNamespace.id in the first place because id can be looked up in more than one location (at least in principle). So we do need to consider disambiguation, that is, "don't just look for a declaration named id in some very general, language-defined set of locations", but do "look for it in a more narrow set of locations", e.g., in the declaration of the context type, or a separately specified namespace, etc.

We can specify where to look locally (fn(value: SomeNamespace.distinguished)), but that's of course the initial problem, so we probably have to consider an approach where the place to search is specified in the signature of the enclosing invocation (here: of the function fn).

With this, it seems unavoidable that two different syntactic contexts can provide distinct specifications about the place to look, and hence also the meaning of a term like .distinguished.

It might not be so bad in practice, though: Most names in public APIs carry a useful amount of information about the meaning of the named entity, even in the case where it is somewhat ambiguous. So we are more likely to have something like this:

void main() {
  chooseTShirt(size: .medium);
  fryTheSteak(degree: .medium);

  // If we wish to resolve the ambiguity for improved
  // readability, nothing prevents us from doing this:
  chooseTShirt(size: TShirtSize.medium);
}

(Being a vegetarian, I can't promise that the frying part is done correctly. ;-)

@rrousselGit
Copy link

rrousselGit commented Sep 25, 2024

I do think it's a conflict which is more or less a built-in property of this feature

I disagree that the issue is unavoidable. That's the main reason why I keep pushing back :)

In my world where we use static extensions + context type, using the same example we'd have:

static extension A on int {
  static int distinguished = 1;
}
static extension B on int {
  static int distinguished = 2;
}

void fn({int? value}) => print(value);
void fn2({int? value}) => print(value);

Then, used as:

fn(value: .distinguished);
fn2(value: .distinguished);

The key is that the language interprets would interpret it as:

fn(value: int.distinguished);
fn2(value: int.distinguished);

Note how we're not interpreting it as A.distinguished vs B.distinguished.
That's key for the conflict resolution, because there are now two possible int.distinguished values due to those static extensions. So we'd get a compilation error similar to when using extension methods.

@eernstg
Copy link
Member

eernstg commented Sep 25, 2024

In my world where we use static extensions + context type, ...

This example is already directly supported by the default parameter scopes proposal, assuming that we get support for extension types as well:

static extension A on int {
  static int distinguished = 1;
}

static extension B on int {
  static int distinguished = 2;
}

void fn({int? value}) => print(value);
void fn2({int? value}) => print(value);

void main() {
  fn(value: .distinguished); // Error, ambiguous.
  fn2(value: .distinguished); // Error, ambiguous.
}

So that's a software design choice, not a language mechanism distinction.

[Edit: In the first version of this comment I had an extra paragraph about handling int? such that the chosen default namespace is int. But the proposal already handles that, so there was no need for the extra paragraph about how to fix it.]

The important point I'd like to make is that I do not think it's a good choice to put all the distinguished values of type int into the namespace int (and similarly for any other widely used type), because that's just another type of namespace pollution. With a specific parameter, certain actual arguments of type int will be distinguished values (that is: particularly relevant), with corresponding meaningful names, and we shouldn't be forced to look at the distinguished int values for every other purpose in the universe when we're completing an actual argument for that particular parameter.

@tatumizer
Copy link

tatumizer commented Sep 26, 2024

For the feature to be useful, it is not necessary to cover all theoretically possible options (which are hard to formalize).
Instead, the compiler can only support a short syntax for two cases: enums and enum-like values.
Now the question is how to define "enum-like values". Should they be specifically marked with some keyword or not?
E.g. static const enum topRight = Alignment(1.0, -1.0);
Or we can rely on a general rule like: if there's a static constant of final field in class C (or in a static extension on C) whose type is C, it can be used in a short form whenever the context type is C.
For all other cases, IDE can use the heuristics (discussed earlier) while forming a list of suggestions, but substitute the fully qualified name for the selection.

@rrousselGit
Copy link

rrousselGit commented Sep 27, 2024

IMO constructors and static functions are really important.
In Flutter, most values come from either a constructor or a .of(context) function.

Padding(
  padding: .all(42),
)

Border(
  border: .all(.elliptical(10, 20)),
)

...

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
Status: Being discussed
Development

No branches or pull requests