dart-lang / language

Design of the Dart language
Other
2.61k stars 200 forks source link

Allow for shorter dot syntax to access enum values #357

Open rami-a opened 5 years ago

rami-a commented 5 years ago

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
}
munificent commented 2 years ago

I must admit that I don't really like the leading dot syntax. Since it would often appear following the : of an argument name, that's a lot of punctuation. Just using the bare enum value name is clean and minimal.

I think using a bare identifier is consistent with the rest of the language. In practice, there are a number of namespaces where an identifier can be resolved:

In theory, collisions can occur among and within any of those. In practice, collisions are actually pretty rare. In order to resolve them, we usually give users a way to explicitly qualify the name to disambiguate: this.foo, ExtensionName.foo, import_prefix.foo, ClassName.foo, etc. We haven't, as far as I know, added any new syntax to disambiguate. (For example, unlike C++, Dart doesn't have ::foo for accessing the top level lexical scope.)

Given that, it feels weird to me to add a new syntactic form just to make enum values unambiguous. There is already an unambiguous way to refer to them: EnumName.foo. If we think that syntax is too verbose—and I agree that it is especially in Flutter code where named parameters are the norm—then I think we should just allow enum values as bare identifiers and resolve them and deal with collisions the way we do with other bare identifiers.

In other words, I like #‍2. It also seems to be the approach that C# is taking.

Hixie commented 2 years ago

If the problem is that typing all these explicit namespaces is tedious, then it seems to me the solution is better autocomplete in IDEs.

Having the types be explicit makes reading foreign code significantly easier and all of the solutions proposed here dramatically worsen the reading experience, IMHO.

Hixie commented 2 years ago

(FWIW, bare identifiers is the norm in much older languages like Pascal and C. In Pascal the convention becomes to prefix every enum value with a two-letter mnemonic so that you can remember what enum the identifier refers to. In C... well, C is hardly the most readable language at the best of times.)

munificent commented 2 years ago

If the problem is that typing all these explicit namespaces is tedious, then it seems to me the solution is better autocomplete in IDEs.

I think it's not just writing but reading. In:

Row(
  crossAxisAlignment: CrossAxisAlignment.baseline,
),

I think there's a pretty strong argument that CrossAxisAlignment doesn't illuminate much for the reader given that it is literally the same as the previous identifier they just read, modulo the capitalization of a single letter.

Levi-Lesches commented 2 years ago

I don't see how .start is less semantically ambiguous than enum.start:

@eernstg, for the record, I equally disagree with .start. I was more suggesting what @lrhn said, that it should be an IDE autocomplete feature (perhaps triggered by typing .). That way there's no ambiguity.

@munificent, there are cases where an enum doesn't match the name of the parameter. Also, omitting the name of the enum would make it a breaking change to change a parameter type from an enum to a static constant in a class of the same name.

munificent commented 2 years ago

@munificent, there are cases where an enum doesn't match the name of the parameter.

Yeah, I agree there are places where the context type is much less obvious and then just using the enum value as a bare identifier becomes less clear. But those are also the places where users are not clamoring for and wouldn't necessarily have to use this feature. I believe the reason users want the feature so badly is specifically because Flutter has a lot of named parameters that take enums with almost identical names.

Also, omitting the name of the enum would make it a breaking change to change a parameter type from an enum to a static constant in a class of the same name.

I think if we were to add syntactic sugar to Dart for this, it would support both true enums and enum-like static constants in classes. I agree completely that it would be a misfeature if it prevented API maintainers from converting an enum to a class or vice versa.

Hixie commented 2 years ago

But those are also the places where users are not clamoring for and wouldn't necessarily have to use this feature.

Having it be inconsistent would be even worse than this feature being used everywhere, IMHO.

One problem with code like:

Fish(
  style: salmon,
),

...is that when we change the type that style expects from Color to Species then suddenly the semantics have radically changed but the developer gets no warning whatsoever since Color.salmon and Species.salmon are both values that exist.

Another problem, and this is the one I really care about, is that when I'm reading the code and I see this, I have no idea what type "salmon" is and thus no way to know where to go look it up. (This is especially true in environments like GitHub or YouTube where I cannot have an IDE help me.)

users want the feature so badly

This issue has only accrued 75 upvotes over more than 2 years. That's less than abstract static methods (the immediately previous issue, #356). Not sure I would describe it as "so badly". :-)

leafpetersen commented 2 years ago

(FWIW, bare identifiers is the norm in much older languages like Pascal and C. In Pascal the convention becomes to prefix every enum value with a two-letter mnemonic so that you can remember what enum the identifier refers to. In C... well, C is hardly the most readable language at the best of times.)

@Hixie I hear this concern, but I think it's not entirely a fair comparison. In C at least (I haven't programmed in Pascal in... many moons) these conventions are mostly to avoid name clashes, which this proposal still avoids. That is, you can use Color.salmon and Species.salmon without the prefix in the same scope without one shadowing the other so long as the context disambiguates.

Another problem, and this is the one I really care about, is that when I'm reading the code and I see this, I have no idea what type "salmon" is and thus no way to know where to go look it up. (This is especially true in environments like GitHub or YouTube where I cannot have an IDE help me.)

I really hear this, but I have to admit that this is a place where I've somewhat come to accept that Dart (and many modern languages) diverge from my own preferences and practices. I find this to be pretty much universally true in Dart code: prefixing imports is done rarely, so anything not defined in the current file is pretty much a mystery if you don't have access to some form of "go to definition". So while I can hear an argument for not doubling down on this... I also sort of feel that this ship has sailed in modern languages. People prefer the brevity of un-prefixed imports over the (IDE free) readability of prefixed imports, and I can see an argument for extending that to things like enums and classes (which are, after all, just another form of namespacing for the purposes we're describing here).

Hixie commented 2 years ago

so long as the context disambiguates.

I feel like that may be even worse, though? I mean, now you have to explain why sometimes things work and sometimes they don't. Why can't I copy the salmon in this argument into a var foo = salmon and pass foo in instead? Why does that not work? Dart is fantastic precisely because in so many ways it's really hard to get wrong. You can pick up Dart in a few minutes (literally, we've seen it in usability studies) just from following examples, and there's no magic, no confusing punctuation or mysterious visibility rules or anything.

Imports aren't generally a problem because you only have to check a few places (e.g. all flutter/ and dart: packages are in one set of API docs at api.flutter.dev, and you can see which packages you have imported and it's usually pretty obvious when you use something from a package just from context, especially in tutorials and videos and such where it's a bigger-than-usual problem).

cycloss commented 2 years ago

when we change the type that style expects from Color to Species then suddenly the semantics have radically changed but the developer gets no warning whatsoever since Color.salmon and Species.salmon are both values that exist.

This is an interesting example, but i'd argue that it fails because of the following example which uses the dot notation for enums:

void foo(Species sp) {
  switch (sp) {
    case .salmon:
       // do stuff
    case .bass:
      // do other stuff 
  }
}

If the type Species is now changed to Color, compilation will fail and errors will be thrown because, although Color has salmon as a possible value, it doesn't have bass as a value. This will immediately alert the programmer to the discrepancy caused by the change.

I'm not interested in this feature at all unless an enum like Foo.bar looks like .bar, or bar when used as an argument. If the Dart language developers are unwilling to implement it like this then I'd rather it was forgotten. It seems to work very well in Swift though, so I'm not quite sure why it's being treated with such caution here.

eernstg commented 2 years ago

Here's a way we could handle enum values denoted by plain identifiers (known as option #2) by adding them to the Dart lookup mechanism.

Dart currently performs lookups for a given identifier id (that is, an expression of the form <identifier>, which does not include the member name y in a member access like e.y, where e is some other expression) in the following way:

  1. Search id in the lexical scopes --- this includes local scopes, the body scope of the enclosing class/mixin/extension, if any (which holds instance as well as static members), the top-level scope of the library (which contains imported names as well). If not found:
  2. If there is access to this, prepend this. and re-analyze the expression as a member access (this also covers implicit extension member invocations outside the extension itself).

So in order to make enum value names directly available in a way that isn't a breaking change, we could do the following:

  1. Search id in the lexical scopes. If not found:
  2. If there is access to this, see if the basename of id is in the interface of this or is a member of any extension applicable to the type of this; if yes then prepend this. and proceed; otherwise:
  3. Lookup id as an enum value name (see below, Senum).

Basically, we will try an enum value name if everything else fails. This is needed in order to be non-breaking. We handle the enum value names as a scope, even though that scope isn't inserted anywhere into the list of enclosing scopes:

During name resolution, we may then resolve any given identifier as a reference to a non-empty set of enum values.

During type checking, consider an identifier id that has been resolved to such a set {E1, .. Ek}. If the context type T is not an enum type in E1, .. Ek, or Ej? for some j, a compile-time error occurs (e.g., if there is no context type, or it is dynamic, etc). Otherwise T is Ej or Ej? for some j, and id is desugared to Ej.id.

This means that enum value names are available anywhere whatsoever, but they never dominate an existing lookup mechanism, and they never kick in unless there is a context type which is specifically requesting one of the available enum types. Enum is not enough, Object is not enough, and other types like String must be an error in any case, and so they are.

lrhn commented 2 years ago

The enum only version fails to account for enum-like user-written classes, which is why I prefer the context-based lookup instead. So, if id is not in the lexical scope and this.id is not in a current this interface, then check if id is a static final or const getter (aka. with no setter) on the context type, which must be accessible, and with a return type assignable to that context type. That allows both enum declarations and user-written enum-classes.

We may want to consider some more places where we want to use shorthands, and where we don't currently have a context type to hang it onto:

Places where it would "just work" include:

// enums or enum-like classes:
foo({Color color = red}) ... // parameter default values
foo(color: red); // arguments
Color c = red; // plain assignment/initialization.
switch (c) {
  case red:
  case blue:
  case green:
}
if (c == blue) { // if we make it work.
// non-enums with static final/const getters.
double n = nan; // but:  var d = double.nan  is probably fine.
BigInt b = zero;
Duration d = zero;

There is still no solution for red being Colors.red instead of Color.red. We don't have any obvious link from the identifier source position to the declaration, so we'd have to check all "available" classes for whether they contain a static final/const getter named red with a type assignable to Color. It's also not a classical enum pattern, so we don't necessarily have to support it.

eernstg commented 2 years ago

@lrhn, I think it would be very easy to include any particular set of available static declarations as the result of the lookup in the Senum that I proposed here.

I actually thought switch cases would be covered already, but it would otherwise be easy to add them, too, and whatever we want for ==.

I think the conclusion remains: We can come up with detailed and working rules whereby (1) access to enum values and certain static members can be achieved based on the name and the context type only, and (2) it is a non-breaking change.

The remaining issue is: How much does it hurt readability and maintainability? See @Hixie's comment here, and note also that it might make completion drop-down lists explode.

There is still no solution for red being Colors.red instead of Color.red.

I think it is a solution to make it an error for the language to make that choice: If we have such choices then the developer will just have to take the hit, and write all those extra characters that makes the choice explicit.

jodinathan commented 2 years ago

Why can't I copy the salmon in this argument into a var foo = salmon and pass foo in instead? Why does that not work?

this already happens with inference:

void daz(String? a) => print(a);

T? parse<T extends Object>() => null;

void main() {
  final foo = parse();

  daz(parse()); // ok: the return from parse is String? by inference
  daz(foo); // Error: can't know what foo is
}
jodinathan commented 2 years ago

The no prefix version is the more modern and easiest to use. Even thought it looks like it will need a learning curve at first glance, I don't think it will be the case because we are already used to it with the inference system. Well, at least our projects use inferences a lot.
However, it seems to be the trickiest to implement.

What I really dislike is having to type or read the full enum Type name where 99% of the cases it has no meaningful value.

(and I seriously don't understand why WebStorm doesn't give me the correct options in the autocomplete)

Abion47 commented 2 years ago

I think it is a solution to make it an error for the language to make that choice: If we have such choices then the developer will just have to take the hit, and write all those extra characters that makes the choice explicit.

@eernstg The only problem with that stance is that, as has been said, these examples are coming from Flutter, not from some rando's user-written library. We can talk about whether it's questionable design to have Color and Colors be effectively unrelated classes in this context, but the fact of the matter is that that is the reality of the Flutter codebase and would be massively breaking to change that now. It's going to be extremely jarring if a shorthand syntax gets released and it doesn't support a significant portion of Flutter widget properties. (Though whether that's the Dart team's problem or the Flutter team's problem is a topic for debate itself, I suppose.)

eernstg commented 2 years ago

@Abion47, I think you're saying that we need more expressive power, in that red with a context type of Color should denote Colors.red, but the current proposals will not look it up in any other class than Color?

I don't think it's obvious at all that we should have a rule like "If the context type is C then we can look up a member of C", because such members do not have any good reason to be forced to have type C. Nor is it obvious that every static member of C which has type C is intended to be used for lookups of this kind.

However, that particular property happens to hold for the values of an enum declaration after desugaring to a class with some const members. So I understand why it might seem semi-natural.

The more general, conceptual property that we want would be a criterion like the following: "With a given type C and name n, how do we find a distinguished instance of C out there with the name n?".

This is a kind of a resource registration mechanism, somehow specifying that "exactly these declarations named n1 .. nk are the distinguished instances of C, so whenever anyone wants a C named nj, that's it. And it should be a language mechanism, not a magic comment or annotation!

Also, we probably want to be able to deliver instances of proper subtypes of C as the distinguished instances of C, but that could be achieved by having an explicitly declared type of C on a (const/final) variable which is considered distinguished.

Would it actually break Flutter to start following the enum style, and create aliases of the declarations in Colors inside the class Color?

Levi-Lesches commented 2 years ago

@jodinathan, those examples are not equivalent. parse() returns an Object?, so foo has the static type Object?. When calling parse directly within daz, Dart realizes that the generics have to match, so it assigns String to T. That type of inference only makes sense when you put parse directly inside of daz because it's essentially the "anonymous declaration" of daz's parameter, a. But otherwise, you can't expect the type of a variable (foo) to change based on what lines come after it.

Saying var foo = salmon would make more sense because, supposedly, salmon should have the type of Species or Color, and thus there's no ambiguity.

munificent commented 2 years ago

This issue has only accrued 75 upvotes over more than 2 years. That's less than abstract static methods (the immediately previous issue, #356). Not sure I would describe it as "so badly". :-)

This is a really good point. I do hear it anecdotally from users pretty frequently, but it may not be as important as I imagine it to be.

I mean, now you have to explain why sometimes things work and sometimes they don't. Why can't I copy the salmon in this argument into a var foo = salmon and pass foo in instead?

As @jodinathan notes, there are already places in the language where that property no longer holds. Downwards type inference on generics is the big one. Another one is int-to-double:

// OK:
var doubles = <double>[1, 2, 3];

// Compile error:
var two = 2;
var doubles = <double>[1, two, 3];

It's fair to say that we should minimize the number of places where context types change the behavior of a program so that it is possible to hoist subexpressions out without changing the meaning of the program as often as possible, but we'll never get back to always being able to do that, for better or worse.

One option we could consider to limit the blast radius of allowing bare identifiers to resolve to enum values (and static constants) is to only allow it in certain context types and not all of them. We could say, maybe, that you can only use the feature in context types on named parameters and switch cases, but disallow it for positional parameters or variable declarations where the name might be less obvious.

I'm not thrilled by this idea because it's yet another special rule, but it would prohibit confusing code like:

addToWater(salmon); // Mixing paint, or transporting fish?
Abion47 commented 2 years ago

I don't think it's obvious at all that we should have a rule like "If the context type is C then we can look up a member of C", because such members do not have any good reason to be forced to have type C. Nor is it obvious that every static member of C which has type C is intended to be used for lookups of this kind.

@eernstg I didn't intend my previous implicit suggestion to be a serious one, but should a mechanism like that be considered for such a use case? I agree that it's unacceptable both to burden the lookup with finding matching members of C based on some arbitrary set of rules and to pollute the namespace by just including everything. If there was a way to explicitly declare that a member was meant to be included in such lookups, that would resolve the issue.

Would it actually break Flutter to start following the enum style, and create aliases of the declarations in Colors inside the class Color?

It wouldn't break Flutter itself at all. I just point out that consolidating all those references e.g. of Colors to inside Color would break all existing user code that references Colors (which I would wager is just about every Flutter app in existence) and making aliases to Colors members within Color is a fair amount of tedious and redundant code (though again, the question of whether that is worth it is a question for the Flutter design team).

Levi-Lesches commented 2 years ago

and making aliases to Colors members within Color is a fair amount of tedious and redundant code (though again, the question of whether that is worth it is a question for the Flutter design team).

With the new non-function typedef:

typedef Colors = Color;
eernstg commented 2 years ago

OK, you can laugh now. ;-) But we could stress the connection to enum types:

class C {
  enum static const C name = ...;
  ...
}

void main() {
  C c = name; // OK, because that declaration is 'distinguished' for this type of lookup!
}
Abion47 commented 2 years ago

and making aliases to Colors members within Color is a fair amount of tedious and redundant code (though again, the question of whether that is worth it is a question for the Flutter design team).

With the new non-function typedef:

typedef Colors = Color;

@Levi-Lesches I had forgotten that was a new feature, so that's cool. Unfortunately, it also wouldn't work, as this just now occurs to me.

The Color class is part of dart:ui, while Colors is part of package:flutter/material.dart. Notably, most of the constants of the Colors class aren't actually of type Color but of derived types such as MaterialColor or ColorSwatch which aren't available in dart:ui. In fact, as far as I can tell, this precludes the options of migrating or aliasing anything from Colors to Color other than transparent and the various shades of black and white. (Presumably, this is why the Flutter team created Colors in the first place.)

csells commented 2 years ago

I assume you covered this in your rules, @eernstg, but I don't see any sample called out in this thread for what to do in the case of a name conflict, e.g.

Widget build(BuildContext context) {
  final baseline = ...;
  return Row(crossAxisAlignment: baseline, ...); // which baseline?
}

I like the idea of less code for sure and agree with @munificent about the simplicity of dropping the leading dot, but w/o the dot, as a reader I don't know if I'm referencing a local, a property, a field, a global or an enum value. At least with the leading dot, the reader can easily see what's going on:

Widget build(BuildContext context) {
  final baseline = ...;
  return Row(crossAxisAlignment: .baseline, ...); // clearly CrossAxisAlignment.baseline
}
cycloss commented 2 years ago

This issue has only accrued 75 upvotes over more than 2 years. That's less than abstract static methods (the immediately previous issue, #356). Not sure I would describe it as "so badly". :-)

This is a really good point. I do hear it anecdotally from users pretty frequently, but it may not be as important as I imagine it to be.

I think it doesn't draw much attention because most people just don't know it even exists. I've only ever seen it in Swift. It's not present in Java or Kotlin. Kotlin does have a feature request for it though. I believe it's one of those things that people don't know they want, but once they use it, they want it. Had I not used Swift before coming to Dart it would not have ever occurred to me as something that might be useful.

The remaining issue is: How much does it hurt readability and maintainability? See @Hixie's comment here, and note also that it might make completion drop-down lists explode.

Flutter widgets already make it quite obvious what the type is because of named parameters. Constructor parameters like mainAxisAlignment are self explanatory with respect to their enum types. In fact, it looks annoyingly verbose having mainAxisAlignment: MainAxisAlignment.center. If it is a concern, then a project team can agree to use named parameters for enums.

eernstg commented 2 years ago

@csells wrote

in the case of a name conflict

In the rules I suggested, lookups will preserve their current resolution in every case where it succeeds. This happens because the lookup in lexical scopes occurs first, as usual, and then the interface of this plus the available extension methods are checked (and if we're calling an extension method with the same name and getting it wrong, that's still a compile-time error because that invocation is wrong, it doesn't magically switch over to refer to the enum-or-other-distinguished-object).

Widget build(BuildContext context) {
  final baseline = ...;
  return Row(crossAxisAlignment: baseline, ...); // which baseline?
}

So in this case baseline will resolve to the local variable. If that's inconvenient then the local variable (perhaps several) in scope with the name baseline will have to be renamed. But that should be possible, because we're writing this piece of code as we encounter that problem.

A bigger problem arises if there is a conflict between a top-level name and a distinguished declaration (that used to be "an enum value", but is now more flexible): It would be really inconvenient to have an enum value named deprecated, because that word would generally resolve to the declaration named deprecated in the core libraries.

However, the assumption is that this will not occur often, because most top-level names are Capitalized, and variable names and enum values are (at this point ;-) lower case first.

jodinathan commented 2 years ago

One option we could consider to limit the blast radius of allowing bare identifiers to resolve to enum values (and static constants) is to only allow it in certain context types and not all of them. We could say, maybe, that you can only use the feature in context types on named parameters and switch cases, but disallow it for positional parameters or variable declarations where the name might be less obvious.

I'm not thrilled by this idea because it's yet another special rule, but it would prohibit confusing code like:

addToWater(salmon); // Mixing paint, or transporting fish?

Can't that be a lint?
do_not_esugar_positional_parameter

Hixie commented 2 years ago

It seems to work very well in Swift though

Swift is not a language that promotes readability the way that Dart does. It has other priorities, which is fine, and makes it a fine language for its own purposes. I think it's dangerous for us to forget our own strengths and try to adopt the strengths of other languages even when they contradict our own. All we will do is make our language into a murky mess.

There are plenty of languages that are designed for brevity and terseness. Rust comes to mind. Such languages certainly avoid the verbosity of Dart, but they have a major downfall: it's significantly harder for someone to pick up the language.

I bet that if you gave someone a snippet of code in a language without telling them what the language was or giving them any documentation about it, and then asked them to make changes, they would find that easy when the language was Dart, and hard when the language was Rust. (I know they'd find it easy for Dart, we've done that experiment already. I'm guessing for Rust.)

I've only ever seen it in Swift. It's not present in Java or Kotlin. [...] I believe it's one of those things that people don't know they want, but once they use it, they want it.

FWIW, as mentioned earlier, it does exist in C and Pascal. When I came to Dart at first I thought this was a major failing of Dart, and proposed these exact changes several times. However, I now think this is a mistake, for the reasons I've described in other comments above.

Hixie commented 2 years ago

this already happens with inference:

It's worth noting that I personally would get rid of var and inference too. Whenever I write Dart I do so with the lints that require explicit types everywhere enabled.

jodinathan commented 2 years ago

It's worth noting that I personally would get rid of var and inference too. Whenever I write Dart I do so with the lints that require explicit types everywhere enabled.

I do understand your point. TS is an example how generics and inference can be hell on earth.
However, I find that Dart is in a good middle term place where inference can be easily understood by reading the code but still gives you some flexibility to organize your logic.
We felt this when we started using the pedantic lints with the "avoid_local_types" lint. We didn't like it at start as we were used to explicitly add Types everywhere. But we gave it a chance and after a few days we did find it better for writing and reading.

This feature also fits nicely because you still have information at hand as enums are usually used as named parameters.

And finally to ensure readability to each team taste, we could have lints:

The most comfortable scenario is the .value version as it is clear the intention when you read it. My only concern is if it can limit the language evolution.

rydmike commented 2 years ago

I prefer the clarity of being explicit and verbose about types too. I use the lint that enforces types everywhere as well. I'm with @Hixie there.

Sure I can reason about my own code fine when I write it, but what about later, or when others read it?

When reading unfamiliar code, especially if not in an IDE, it helps a lot with types being explicitly stated everywhere. I really appreciate that the lint that enforces this style is used in the Flutter SDK.

If this type of "short" enums make it to Dart, I hope we can have lints that can be used set style preference. As a fun bonus we will get another style preference to argue about 🙂

cycloss commented 2 years ago

Swift is not a language that promotes readability the way that Dart does. It has other priorities, which is fine, and makes it a fine language for its own purposes. I think it's dangerous for us to forget our own strengths and try to adopt the strengths of other languages even when they contradict our own. All we will do is make our language into a murky mess.

I understand your concern. We shouldn't be trying to shoehorn a feature in simply because it's in another language. Just because semicolons aren't required in Swift and Kotlin doesn't mean we should also go down that route. That being said, I contest the assertion that it has a large impact on language readability. Having used it in Swift, it is highly legible; you know it's an enum because only enums have that short hand dot syntax. It could be argued that the cascade operator (..) makes Dart code less readable for a newer Dart developer as it isn't a feature that most languages have (I believe it came from smalltalk?). Nonetheless, it is a nice bit of sugar that I don't think anyone complains about even though it's something new to learn.

There have been some excellent suggestions here with regard to linting options. I am all for having the option to avoid short hand dot syntax if a development team believes that it impacts the readability of the project.

eernstg commented 2 years ago

By the way, if there are difficulties with Color being in one library/package and Colors in another, and we want to support having distinguished objects of type Color in Colors then it falls out easily with the idea I mentioned here:

// Somewhere far away, in a different galaxy.

class Colors {
  enum static const Color red = ...;
  enum static const Color blue = ...;
  ...
}

Those declarations would be distinguished (because (1) they have the enum modifier), or (2) they are values of an enum declaration --- in this case it is (1)), and hence it would be possible to look up Colors.red by writing red in a location where the context type is Color.

Abion47 commented 2 years ago

@eernstg The problem is as I said, the type of many of the static members in Colors isn't actually Color. It's classes that derive from Color. For example:

class Colors {
  static const Color black = ...;
  static const MaterialColor red = ...;
  static const MaterialAccentColor redAccent = ...;
}

There would need to be some way to mark those members as being compatible with Color instead of their actual declared types, like perhaps:

class Colors {
  static const enum Color black = ...;
  static const enum<Color> MaterialColor red = ...;
  static const enum<Color> MaterialAccentColor redAccent = ...;
}
rrousselGit commented 2 years ago

I am in favor of this, but not if it's specific to enums, and ideally not specific to static const either, but also static functions/constructor

I don't want code to become inconsistent, where some parts do ".value" and others to "Type.value"

So we could do:

Radius radius = .circular(20)
TimWhiting commented 2 years ago

This issue has only accrued 75 upvotes over more than 2 years. That's less than abstract static methods (the immediately previous issue, #356). Not sure I would describe it as "so badly". :-)

Funny how things changed in less than 24 hours. I'm guessing that it was a combination of awareness of the issue and twitter 😆. I like the # 2 solution here. I assume it can be made to work with static functions and constructors as Remi requested using the same resolution rules @eernstg proposed (locals > class > context type based on enums / static const / static function / static constructor), with the ability to write out the context type when you have a conflict in a closer scope. I'm sure the Dart team will implement lints for sure for this, so I'm not as worried about preferences and more about syntactic ambiguities restricting future language improvements, and I think the # 2 solution works best for that.

eernstg commented 2 years ago

@Abion47, yes, enum<Color> makes sense!

In any case, I'm simply looking for some syntactic support for developers to indicate that a specific declaration should be 'distinguished' in the sense that the plain name can be looked up when the context type matches the type that we've registered for that declaration.

I think everybody assumes that the values of an enum declaration will be distinguished in this sense, if anything is, and that's the reason why I thought it would make sense to specify this property using the reserved word enum.

It's like a secret channel that will teleport any distinguished object into every spot that needs it, but only if it isn't shadowed by mere mortals. ;-)

rrousselGit commented 2 years ago

I think I'm misunderstanding something. Why would we need an enum keyword or looking at locals/class variables?

Is it so that for say Color, we can define custom .myColor? If so, I don't like that.

I would expect:

Type foo = .something;

to only allow accessing static members/constructors of Type, but access all of them without any special keyword. I wouldn't expect this to access anything else

Then if we want to allow people to define custom values (like Colors for Color), I'd expect this to be the role of static extensions #723

So that we'd end-up with:

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

print(Color.red);
Color color = .red;

This makes the resolution a lot more deterministic.

eernstg commented 2 years ago

The idea that I'm exploring is that we provide access to a set of named objects ('distinguished' objects) using (1) a global scope (that is, these names are available everywhere, except that they are at the very end of lookup so they can be shadowed by anything), but (2) they are filtered by the context type (that is, we can only see the ones that have been registered for the exact same type as the context type).

I've mentioned enum as a potentially useful marker for these declarations, because I expect every enum value to be a distinguished object. Obviously, distinguished might work better conceptually, but we need to take parsing into account, and enum would be easy to handle.

My thinking is that it is only applicable to constant or final variables, and they should certainly be top-level or static (that is, with unlimited life time). It would be easy, technically, to apply it to any getter, but I'm not sure it will be very useful, or very easy to explain conceptually.

So, basically, these are "global names that you can only see when the context wants them".

However, there is no particular connection to the enclosing construct, so we could allow them to occur as top-level variables, or as static members of anything (a class, a mixin, an extension, etc., whatever comes up in the future). The top-level variant may seem redundant (those names are in the top-level scope anyway), but it might make sense when the library is imported with a prefix.

We could, arbitrarily, require that they must occur in the declaration of the class or mixin that is their registered type (so a distinguished object registered for the type Color must be a static member of the class Color), but the fact that Flutter uses Colors as a kind of namespace for commonly used ('distinguished') objects of type Color indicates to me that it could be useful to allow them to be located more freely. Hence the proposal that they can be declared anywhere, and they must specify the registration type if it isn't the same as the declared type.

This also means that you can have different collections of distinguished objects, and you would be able to make the distinction by writing out the full name (in libraries where both are imported), e.g., Colors.red vs. SoftColors.red vs. VibrantColors.red. If a specific library only imports VibrantColors then you can simply write red all over, but if you have several then you have a meaningful grouping and a way to specify which one to use. In any case, you must disambiguate if the choice isn't unique.

Conversely, I'm not convinced that it would be helpful to make every constant/final static variable a distinguished object. After all, we are putting a massive number of names into the global scope; they won't shadow anything else, but they could still pollute completion drop-down menus and similar devices with too much noise. That also pushed me in the direction of requiring an explicit modifier like enum in order to 'distinguish' a given variable declaration.

rrousselGit commented 2 years ago

We could, arbitrarily, require that they must occur in the declaration of the class or mixin that is their registered type (so a distinguished object registered for the type Color must be a static member of the class Color), but the fact that Flutter uses Colors as a kind of namespace for commonly used ('distinguished') objects of type Color indicates to me that it could be useful to allow them to be located more freely.

I agree, but I believe that this would be better solved through #723 than trying to make a custom solution for this specific case

With #723, Colors could be refactored to:

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

This achieves the same effect (allowing Color color = .red), without this enum keyword.

Conversely, I'm not convinced that it would be helpful to make every constant/final static variable a distinguished object. After all, we are putting a massive number of names into the global scope; they won't shadow anything else, but they could still pollute completion drop-down menus and similar devices with too much noise. That also pushed me in the direction of requiring an explicit modifier like enum in order to 'distinguish' a given variable declaration.

If we're relying on #723 to add new values to Color, then this wouldn't be an issue because only static extension on Color would add new declarations to Color. All other Color instance in the global scope wouldn't be visible.

So it achieve the same effect as the enum keyword you're describing, but by declaring a static extension instead.
In which case, enabling any static member of Color to be accessed isn't problematic, since we'd have only a limited pool of them: The built-in members and the extensions.

I see a few benefits:

My thinking is that it is only applicable to constant or final variables

I'm not sure if that's implying that we shouldn't have functions/constructors. But if so, I disagree (otherwise ignore me 😜 )

I see no difference between:

Padding padding = .zero;

and:

Padding padding = .all(10);

IMO either we can do both or neither of them.
I believe consistency is key. In that sense, I would prefer not having this feature at all over supporting only static final.

Abion47 commented 2 years ago

With #723, Colors could be refactored to:

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

This achieves the same effect (allowing Color color = .red), without this enum keyword.

@rrousselGit How would you handle a situation where the desired included members are named/factory constructors, e.g. EdgeInsetGeometry vs EdgeInsets? You can't declare new constructors via extension methods.

Padding(
  padding: .all(8), // padding is of type EdgeInsetsGeometry, but all is a named constructor from EdgeInsets
  child: ...
)

There's also a name collision here, as both EdgeInsets and EdgeInsetsDirectional extend EdgeInsetGeometry and they both declare only and all constructors as well as a zero constant. We would almost have to have something like an enum<EdgeInsetGeometry> marker and just say that only EdgeInsets is available for shorthand syntax while EdgeInsetsDirectional must be explicitly named.

rrousselGit commented 2 years ago

@rrousselGit How would you handle a situation where the desired included members are named/factory constructors, e.g. EdgeInsetGeometry vs EdgeInsets? You can't declare new constructors via extension methods.

723 is about a different kind of extension that allows defining constructors and static methods.

So:

static extension EdgeInsetGeometryExtension on EdgeInsetGeometry {
  factory EdgeInsetGeometry.all(num value) = EdgeInsets.all;
}

which allows:

Padding(padding:  EdgeInsetGeometry.all(20))

and therefore also:

Padding(padding: .all(20))

There's no conflict involved here either, since we defined only one EdgeInsetGeometry.all extension.

Levi-Lesches commented 2 years ago

I still side with @Hixie that readability could very well suffer from this, but if it will be done anyway, I like @rrousselGit's method -- using .foo means using the exact type that's expected and wouldn't consider subclasses (like EdgeInsetsGeometry > EdgeInsets) or collections classes (like Color > MaterialColor > Colors), and using static extensions to add convenience where needed. Otherwise, there's ambiguity with what .foo refers to, and syntax like implicit var and enum var adds confusion and possible breaking changes

eernstg commented 2 years ago

@rrousselGit wrote:

this would be better solved through #723

(that is, the ability to have static and factory declarations in an extension which are added to the on type, if that's a class or a generic instantiation of a class)

I can see that something like #723 would allow developers to add static members to an existing class, which would make the rule that distinguished objects of type C must be members of C more flexible.

However, it is not obvious to me how we'd handle static members on void Function(int) and other non-class types, or what we'd do in the case where the on type of one extension E1 is List<int>, and for E2<X> it is List<X>, and they both define a static member named foo, and we use foo or .foo with context type List<int>, etc.

So, granted, the support for static member injection from extension declarations (that's how I see #723 at this point) can do a lot of things, but I don't know the proposal well enough to know that it's going to be obviously better than just allowing the distinguished objects to be declared anywhere we want. It does allow us to avoid the explicit marker, but it is also clearly less flexible, and in particular it doesn't allow us to avoid making a static declaration distinguished.

For access to constructors, I still prefer to use a different mechanism (mainly because I don't think things like foo(.filled(1,0)) is particularly readable): We allow new to be used in place of the class denotation when the context type determines the class, and also at the declaration:

void f(List<int> xs) {}

void main() {
  f(new.filled(1,0)); // I'd still prefer `List.filled` when we can't see the context type.
  List<int> xs = new.filled(1, 0); // But I think this can work.
}

So here's the resulting code with the ideas that I've explored so far:

(1) Distinguished objects of type T can be declared by marking a top-level or static variable declaration with an explicit modifier enum<T>; T can be omitted if it is the declared type of the variable; multiple types can be given if the object should target several context types. (2) A distinguished object can be looked up by its name, with scoping as described here.

(3) In an instance creation that creates an instance of a class C (possibly with some type arguments), if the context type is C (possibly with some type arguments, not necessarily the same), the denotation of C can be replaced by new. For instance myPrefix.C<int> c = myPrefix.C.named(); can be abbreviated to myPrefix.C<int> c = new.named();.

(By the way, we could easily allow functions or getters whose return type is a function type as distinguished entities as well: They would then have to have the distinguished type as their return type, and the distinguished lookup would only succeed when they are invoked.)

And the usual example:

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

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

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

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

enum MyButtonSize { small, medium, large }

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

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

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

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

// -----------------------------------------------------

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

For access to constructors, I still prefer to use a different mechanism (mainly because I don't think things like foo(.filled(1,0)) is particularly readable): We allow new to be used in place of the class denotation when the context type determines the class, and also at the declaration:

void f(List<int> xs) {}

void main() {
  f(new.filled(1,0)); // I'd still prefer `List.filled` when we can't see the context type.
  Li

I have some doubts about the syntax new.constructor()

It feels like a step back to the time where new was required. Modern Dart no-longer write new MyClass.named(), but simply MyClass.named(). Requiring new.named() would make the language quite inconsistent IMO.
And in this case, I would likely prefer writing MyClass.named() over new.named().

This feels especially problematic IMO if we consider that static functions wouldn't require the new keyword.
Considering #723 only allows defining factory constructors, which are fairly close to static functions, chances are people would define static functions instead of constructors to bypass the new.


I agree that foo(.filled(1,0)) isn't particularly readable, but ultimately readability is a problem with this feature as a whole and it's not specific to constructors. I would argue that foo(.empty) is not any better, if not worse.
Ultimately many constants have similar name. Like Offset.zero vs EdgeInsets.zero, ... But named constructors tend to be more descriptive, and also benefit from extra contextual clues: parameters.

And ultimately, the real readability issue in this code is the function name foo. With a proper function name and potentially named parameters, .filled(...) isn't so bad anymore.
For example: writeAsBytes(.filled(100, 0)). It's immediately more obvious that the parameter is a List<int> since the function name tells us that this code is about bytes.

It does allow us to avoid the explicit marker, but it is also clearly less flexible, and in particular it doesn't allow us to avoid making a static declaration distinguished.

Why would preventing the usage of this shorthand for some specific declarations be desirable?

For me, this feature is purely stylistic. I would place it in the same bucket as trailing commas or tab vs spaces or the .. operator.
As a package author, I can't think of a use-case where I would actively want to make it impossible for users of my code to use this feature in a specific case.
At best I may want to encourage/discourage its usage in some cases with a recommended style-guide or lints. But I'd leave the final decision to the users, not the package, since it doesn't hinder the package evolution in any way (unless I am missing something?)

In fact, from my perspective I see the ability for packages to enable/disable this feature as actively undesirable.

Finally, if we really want to allow enabling/disabling this feature on certain static members, what about a package:meta annotation?
So that we'd have:

class MyClass {
  @notAnEnum
  static const something = ... 
}

which would warn users if they tried to do MyClass value = .something.

I still don't see why rejecting a static member is desirable. But at least with an annotation, it would reduce the burden on package authors.

lrhn commented 2 years ago

I think extension static members would go a long way towards allowing constants to be added from other libraries, and then access them consistently with other constants. With that, typedef Colors = Color; extension on Color { ...members of Colors ...} should be sufficient to migrate uses of Colors.foo and also make the same values available on Color.

I do not want to use the word enum for anything related to Colors because it's not really an enum, it's not enumerable. It's open ended, you do not get exhaustive match checks for switches over colors. So, while it might be a set of static constants, like an enum, it doesn't share the other properties of being an enum (a finite enumerable set of values).

eernstg commented 2 years ago

@rrussellGit wrote:

Requiring new.named() would make the language quite inconsistent IMO.

Note that we're not requiring new.named(), we are allowing the instance creation MyClass.named() to be abbreviated as new.named(), in a situation where MyClass is unambiguously determined by the context type. If you wish, you could write new new.named(), but everybody is used to omitting the keyword new before the instance creation, so I did that, too.

The point is that we can see that new.named() is an instance creation, but with .named() it is rather easy to overlook the period (assuming option #1, it wouldn't even be there with option #2), and we can't see whether it's an instance creation, or an enum value plus an invocation based on a method name call in an extension on Type, etc.

Why would preventing the usage of this shorthand for some specific declarations be desirable?

Because every name which is made available (under option #2) in a way that makes them similar to top-level declarations is both a resource and a nuisance: We get easy access, but we also pollute the global namespace. I definitely think it's useful to be able to control the level of pollution of this kind.

eernstg commented 2 years ago

@lrhn wrote:

I do not want to use the word enum for anything related to Colors

We could certainly use a better word. It should have a meaning which is close to "this object is available for lookup globally, filtered by context type", and that's a rather tall order for a single word. I did propose that we use enum because it will be a prominent property of enum values that they are available for lookup globally, filtered by context type, if we choose option #2.

Do you have a better word? Does it parse?

rrousselGit commented 2 years ago

@eernstg said:

Because every name which is made available (under option #2) in a way that makes them similar to top-level declarations is both a resource and a nuisance: We get easy access, but we also pollute the global namespace. I definitely think it's useful to be able to control the level of pollution of this kind.

So with option 1 we don't have the issue?

I'm personally strongly against option 2 to begin with. I see the leading . as a critical information for readers to communicate that this an access to a static member, and it significantly reduces the probability of unwanted shadowing.

The point is that we can see that new.named() is an instance creation, but with .named() it is rather easy to overlook the period

What do you mean by easily overlooking the period?

and we can't see whether it's an instance creation, or an enum value plus an invocation based on a method name call in an extension on Type, etc.

I am not quite sure. How is it any different from static functions or any default constructor?

Like:

MyClass()

could technically be:

class _Callable { void call(); }

final MyClass = _Callable();

Do you have a better word? Does it parse?

If we really need a keyword, what about var?

color: var.red

It's already used to indicate type inference.

lrhn commented 2 years ago

The point is that we can see that new.named() is an instance creation, but with .named() it is rather easy to overlook the period (assuming option #1, it wouldn't even be there with option #2), and we can't see whether it's an instance creation, or an enum value plus an invocation based on a method name call in an extension on Type, etc.

I disagree that you can easily overlook a leading .. I think it stands out quite strongly, actually, because it happens almost nowhere else. (The only other one is a line-break before a method call, like foo.longThing()\n .named();. I think the uses will be distinct enough that it's not a problem). With option #2, it's definitely easy to mistake it for a normal function call. The context should make it clear what's going on, that's what this feature is about. If the context doesn't make it clear, you should probably write Foo.named() out for readability. (And there should not be a lint forcing you to remove the class name just because you can!)

If we define the feature carefully enough, there will be very hard to get ambiguity. It's true that an enum member can implement call(), but .named() would not get the implicit .named enum lookup behavior if we only apply that to static getter invocations with a return type assignable to the context type, or to static method/constructor invocations with a return type assignable to the context type. A getter+invocation is neither of those.

Also, it would not apply to extension members on Type unless the context type is Type and we have extension static members and the method returns a Type object. In which case, it's correct usage.

Generally, if you're required to write anything before the ., we might as well keep the enum name. Adding more noise without any signal isn't a goal for me.