dart-lang / language

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

Patterns and related features #546

Closed munificent closed 1 year ago

munificent commented 5 years ago

We're starting to investigate a couple of related language changes around tuples, pattern matching, and destructuring. These features touch on other things like "data classes", so I wanted to kick things off by trying to sketch out my understanding of the space, how I think the various features relate to each other, and the relevant issues people have filed.

Draft feature specification: https://github.com/dart-lang/language/blob/master/accepted/future-releases/0546-patterns/feature-specification.md

Implementation issue: https://github.com/dart-lang/sdk/issues/50585

Concepts

As I understand it, the core concepts are:

Patterns

Languages with pattern matching revolve around patterns. "Expression" and "statement" are both syntactic categories in the grammar. Patterns form a third category. Most languages that have pattern matching have a variety of different kinds of patterns they support. The basic idea with a pattern is that it:

Languages with patterns use them in a variety of places. They can be how you declare variables and catch exceptions. Some languages use them for defining parameter lists or "overloads" of a function. Every language with patterns has some kind of explicit pattern matching expression or statement...

Pattern matching

Once you have patterns, it makes sense to introduce a control flow structure that uses them. A pattern matching statement takes a value that it matches against. Then it has a series of pairs of patterns and bodies. At runtime, the implementation tests each pattern against the value in turn. The first pattern that matches has its body executed. Any variables the pattern binds are only available in the corresponding body.

If you really want a functional style, an even more powerful form is a pattern matching expression that lets you do pattern matching in the middle of a larger expression and have it evaluate to a value. In order to do that in a sound way, though, you need the next concept...

Exhaustiveness

So you're executing a pattern matching construct, walking through all of the patterns to find a match. What happens if none of the cases match? If the pattern matching construct is an expression, this is a real problem because what value do you evaluate to in that case? Even when you are using pattern matching as a statement, users still likely want to know if it's possible for a value to not match any of the cases.

To help with that, many languages with pattern matching also do exhaustiveness checking. The static checker will examine all of the patterns and determine if it's possible for a value to sneak through and match none of them. If so, the compiler reports a warning or error to let the user know. Dart does a limited form of this now with switch statements on enum types. If you miss one of the enum cases, Dart gives you a warning to let you know. Exhaustiveness checking in pattern matching takes that concept and applies it to much larger, more complex patterns.

Destructuring

A list literal expression takes a number of smaller values, the list element expressions, and composes a new larger value out of them, the list. Patterns go in the other direction. For example, a list pattern contains a series of nested patterns for elements. When the list pattern is matched against a list, it destructures the list by walking the elements and matching them against the corresponding element patterns.

This gives you a really nice terse way to pull pieces of aggregate objects like lists, maps, and even instances of classes. Any time you have a pattern that contains nested subpatterns, you're usually doing some kind of destructuring.

Tuples

Lists are a great way to collect a series of values of the same type, but they work poorly when you want to, say return a number and a string from a function. Since lists only have a single element type, the type of each separate element is lost.

Tuples fix that. A tuple is sort of like a fixed-size list where each element has its own distinct type. The type system can see "into" the tuple. A tuple of an int followed by a String has a different static type than a tuple of two booleans. Syntactically, tuples are usually a comma-separated list of expressions surrounded by parentheses, like (1, "string").

Tuples are useful in a statically-typed language for things like multiple return values because they maintain precise types. Once you have a tuple, you eventually need to pull the elements back out of it. You can provide number-like getters, but that gets tedious. Languages with tuples almost always have some kind of tuple pattern so that you can destructure them.

Algebraic data types, sum types, unions with fields

Pattern matching comes to us from the ML language family. Another key feature of those languages that ties into pattern matching are algebraic data types, also known as sum types, discriminated unions, or tagged unions. To make matters more confusing, these are quite different from both union types and (untagged) unions in C and C++. Algebraic data types are sometimes abbreviated ADT, which is again distinct from abstract data types. Thanks, computer scientists.

Sum types are like superpowered unions. You have a type that contains a fixed, closed set of cases. Unlike Dart enums, each case may also have its own fields containing extra data. For example, you might have a Shape type, with cases for Rect and Circle. Rect would have four coordinates for its corners. Circle would have a center and radius.

In an object-oriented language like Dart, you'd model this using subclasses. You'd have an abstract Shape class and Rect and Circle subclasses. Subclasses and ADTs are sort of duals. Indeed, Scala's case classes are like ADTs but are subclasses under the hood.

These come into discussions of pattern matching because the typical way to define behavior on specific cases in a sum type is by using a pattern match on the different cases. Likewise, the way to extract each case's fields is by using a case pattern to destructure them. Something like:

someShape match {
  case Rect(left, top, right, bottom) => ...
  case Circle(x, y, radius) => ...
}

It would be very strange to add algebraic data types to a language without a nice way to switch on and decompose them like this.

"Data classes" and "value types"

One of the most frequent feature requests for Dart is some kind of data classes or value types. The former is inspired by Kotlin's corresponding feature. The latter means different things to different people, but common attributes seem to be:

Data classes are involved with patterns and pattern matching because users also often request that a data class also implicitly provides destructuring support. (Kotlin data classes give you this.) That means some kind of pattern to let you pull apart the fields in an object.

Also, part of the motivation for both algebraic data types and data classes is a nicer notation for defining a new composite type without all of the boilerplate overhead of a full class declaration. In other words, a good ADT feature might subsume data classes or vice versa.

Type patterns

The last concept which has mostly come up internally but exposes a capability the language doesn't otherwise offer is some kind of way to expose the runtime type argument of an object. If you have patterns and pattern matching, a natural solution is a type pattern that matches an object of some generic type and binds variables to the object's type arguments.

Structure

We are very early in our investigation. This is a family of features that are very close to my heart. I wrote a doc in 2011 requesting that we add pattern matching to Dart. At the same time, as you can see, this is a big sprawling feature and we currently have our hands full with extension methods and non-nullable types (both very large features in their own right!).

That being said, here is how I am interested in approaching the space and what I hope we can do:

  1. Define a set of pattern syntax and semantics. I think patterns are the most important core to all of these features. You obviously can't do pattern matching, destructuring, and exhaustiveness checking at all without them. You technically can do tuples, sum types, and data classes, but I think they are significantly hampered without them.

    Also, while it may not be obvious, I believe there are some real tricky design challenges in this area. Patterns are a dense grammatical region where we want to be able to express a wide variety of behaviors in a small amount of syntax. We need to be able to destructure lists, maps, sets, and tuples. Do runtime type tests. Pull fields out of, at least, sum types or data classes. And, of course, test for literals and constant values for simple switch-like cases.

    We are constrained by the expectations users bring from other languages, the desire for patterns to mirror their corresponding expression forms, and (hardest) Dart's existing notation for variable declarations, since those are already a limited form of "pattern" in that they introduce bindings.

    I think getting this right is key.

  2. Define a set of language constructs where patterns can be used. Some new kind of pattern matching statement is the obvious one. But also retrofitting them into variable declarations so that you can destructure in a declaration. Can we re-engineer catch clauses to be pattern based so that you can have more sophisticated catches? Should parameter lists use patterns? Do we want an expression-based pattern match? Some kind of if-let-like statement? This is the fun stuff. Once users have internalized how patterns work, the more places they can use that knowledge the better.

  3. Add tuples. This can happen in parallel with the previous one. Much like adding sets, this is a whole feature in its own right with new syntax, object representation, runtime behavior, and type checking.

    @lrhn has already put some work into defining this.

  4. User-defined destructuring. My favorite language features are ones that add new expressive syntax whose semantics can be programmed by an API designer. The for-in loop syntax is baked into the language, but you get to decide what it means in your implementation of Iterable.

    I want the same thing for destructuring. I think a syntax something like Name(subpattern1, subpattern2) (an identifier followed by a parenthesized list of subpatterns) should desugar to calling some user-defined destructuring operation on the matched value. That operation can define whether the value matches or not and, if it does, what subvalues the subpatterns are matched against. There are obviously many details to work out here, but also prior are in Scala's unapply() method and Kotlin's componentN() methods.

  5. Data classes. The least-defined (in my mind) goal is around improving the user experience for easily defining value-like types. This could mean some new explicit "data class" syntax, or a family of smaller features like allowing implicit constructors to take parameters.

    If we allow these two be subclasses of a superclass, then that gets pretty close to sum types once you include the previously-mentioned ability to pattern match on types and destructure them. Likewise, once you have general user-defined destructuring, then any lightweight data class-like syntax can just be syntactic sugar for a class declaration that includes a declaration of its own destructuring support.

As I said, this is all still very early and tentative, but I wanted to collect what I think are the related issues into one place and then begin sketching out a map through this territory.

mraleph commented 2 years ago

@cedvdb the boilerplate is the artefact of current syntactic decisions. enum classes reduce amount of boilerplate you need to write and allow capturing the state invariants easily.

enum DataLoaderState {
  loading, 
  loaded(Data data),
}

If enums can be non-constant, I don't think they can be considered enums anymore and are rather normal classes.

One can be viewed as a natural extension of another though. Enum cases without any arguments (or with constant arguments) can be represented by constant objects.

Unfortunately I think enhanced enums are taking the language on a different path which means it would be harder to consolidate this in the future.

rrousselGit commented 2 years ago

Why do you thing that they should be non-constant ?

They'd have to if we want to store the result of a network request in an enum if we want to use enums as an enabler for pattern-matching.

One can be viewed as a natural extension of another though. Enum cases without any arguments (or with constant arguments) can be represented by constant objects.

On paper, yes. But I could see that cause some confusion, with people asking "Why use a class when I can use an enum" or those sorts of questions.

Especially with Enhanced Enums in mind, it'd feel like we'd have two keywords to do the same thing (enum vs class), but with enums having a better syntax.

After-all, lots of people are asking for data-classes. Yet the answer to this request seems to be "We won't be doing that as part of the language, but rather through code-generation". With the current talks being about having the constructors generated – which is a syntax degradation.

Yet at the same time we're talking about allowing enums to do:

enum AsyncValue<T> {
  loading,
  Error(Object? error, [StackTrace? stackTrace]),
  Data(T value),
}

which is essentially data classes + sealed classes.

That feels paradoxical to me. If that syntax is reasonable, then I don't understand why true data classes would be undesirable:

data class Model<T>(int count, {T? value});
Levi-Lesches commented 2 years ago

To be fair, the answer to popular requests like built-in data classes has rarely been "it's an undesirable feature" as much as it's been "we're spending team efforts on bigger features right now". While not everyone will agree with their priorities, if you monitor this repo you would indeed see constant discussion on topics across the board, like metaprogramming, static variance, enhanced enums, nullability, and more. Although I'll insert that I think it would be cool if the team could go through a bunch of the "smaller" features, like data classes (#314), automatic constructors (#698), number delimiters (#1), etc.

Nikitae57 commented 2 years ago

Please, we need this feature

munificent commented 2 years ago

I'll insert that I think it would be cool if the team could go through a bunch of the "smaller" features, like data classes (#314), automatic constructors (#698), number delimiters (#1),

The "size" of a feature can be non-obvious sometimes. Syntactic features like data classes and enhanced default constructors are relatively small in terms of implementation effort (which is great), but they can be surprisingly large in terms of design effort. Figuring out what the best syntax should be is really hard and once we pick we're stuck with it so we want to make sure we get a design that's great.

munificent commented 2 years ago

For those following along, I wrote a long doc explaining the approach the current proposal takes for distinguishing refutable and irrefutable patterns.

OlegAlexander commented 2 years ago

@munificent Wow, what a great document. Thank you for putting so much thought into this feature! I agree with your rationale that Swift-like pattern matching is appropriate for Dart. If switch remains a statement, are you considering adding a match expression?

munificent commented 2 years ago

If switch remains a statement, are you considering adding a match expression?

The proposal builds on the existing switch to allow patterns in switch statements but it also separately adds support for switch expressions too.

munificent commented 2 years ago

More documentation! If you've found the proposal a little too large to wrap your head around, I've added a little summary of all of the kinds of patterns and where they are allowed to appear.

cedvdb commented 2 years ago

Thank you for the summary!

Has there been discussion about whether the case keyword should stay ? It seems superfluous

munificent commented 2 years ago

Has there been discussion about whether the case keyword should stay ? It seems superfluous

Yes, some. I lean towards keeping most of the current syntax including case because it's familiar. It's hard to eliminate that without potentially running into ambiguity because each case contains an undelimited list of statements. That means whatever syntax we use to separate cases needs to not overlap either statement syntax or (thanks to expression statements) expression syntax. That's a pretty crowded grammar.

It's not impossible to eliminate case or find something shorter, but it's not trivial either.

Jonas-Sander commented 2 years ago

While I got this pretty quickly

var list = [1, 2, 3];
var [a, b, c] = list;
print(a + b + c); // 6.

on the other hand this

map = {'first': 1, 'second': 2};
var {'first': a, 'second': b} = map;
print(a + b); // 3.

threw me off at first.
Especially var {'first': a, 'second': b} was confusing for me to understand because:

switch

double calculateArea(Shape shape) =>
  switch (shape) {
    case Square(length: l) => l * l;
    case Circle(radius: r) => math.pi * r * r;
  };

I think I wrote this before but I don't like that this Square(length: l) looks like a constructor.

It also works in the exact opposite way of normal constructors where l is a value you provide but in this case l is a variable that is assigned to by the statement to use after the =>. I think this would make the language harder to understand.

I want the switch statement to switch on class types in a concise and exhaustive way. I don't need a shorter way to access the attributes (at least not in the proposed syntax). It's fine for me to write more chars but avoid this confusing syntax.

double calculateArea(Shape shape) =>
  switch (shape) {
    // 1. just use the var in the switch
    case Square => shape.length * shape.length;
    // 2. use a closure to name the object to make it more readable (could be optional)
    case Circle (circle) => math.pi * circle.radius * circle.radius;
    // 2.1 closure can be used to save at least some code
    case Square (s) => s.length * s.length;
    // 3. Use other kind of brackets to signify that it's not a constructor
    case Circle[radius: r] => math.pi * r * r;
    // 4. Make the assignment clearer?
    case Circle[r = radius] => math.pi * r * r;
    // 4.1 Or maybe like this?
    case Foo(b = bar; q = quz) => bar - quz;
  };

Also in the proposal was

display(Object obj) {
  switch (obj) {
    case Rect(var width, var height):
      print('Rect $width x $height');

I think the var differentiates it a bit more from a constructor but I still don't like it that much 😁.

Implicit break

Hell yeah, lets get rid of these break statements!

Switch expression

I love it!
I also like making it high precedence.

switch-case / if-case statement

Often you want to conditionally match and destructure some data, but you only want to test a value against a single > pattern. You can use a switch statement for that, but it's pretty verbose:

switch (json) {
  case [int x, int y]:
    return Point(x, y);
}

Would case [int x, int y]: only match if json is a list with two int values? What about three ints? Or does it work in a whole other way and I don't get this whole proposal at all yet?:D

Just for my undestanding: The : could also be replaced with => in this example right?

// returns Point or null
return switch (json) {
  case [int x, int y] => Point(x,y)
}

We can make simple uses like this a little cleaner by introducing an if-like form similar to if-case in Swift:

if (case [int x, int y] = json) return Point(x, y);

Thats really confusing for me!
The way I reason about if, case and = from normal Dart code just make no sense here anymore.
If there is any if then I expect an == but this is assignment and comparison at once. Also adding case just adds to the confusion for me. I don't know Swift, I just know Dart and this confuses me.

typeOrBinder rule - final

The typeOrBinder rule is similar to the existing type grammar rule, but also allows final followed by an identifier to declare a type variable.

switch (object) {
  case List<final E>: ...
  case Map<String, final V>: ...
  case Set<List<(final A, b: final B)>>: ...
}

Can someone explain what final is used for here in simple terms? I didn't understand that unfortunately.

destructuring named fields

When destructuring named fields, it's common to want to bind the resulting value to a variable with the same name. As a convenience, the binder can be omitted on a named field. In that case, the field implicitly contains a variable binder subpattern with the same name. These are equivalent:

  var (first: first, second: second) = (first: 1, second: 2);
  var (first:, second:) = (first: 1, second: 2);

Why not just:

var (first, second) = (first: 1, second: 2);
var (bar, foo) = (foo: 1, bar: 2);

I guess there is probably some ambiguity?


I like that this proposal exists and that we think about new ways to make the language better and exiting.
On the other hand I don't know it just seems like such a mountain of complexity and just hard to get into (at least for me).

munificent commented 2 years ago

I think I wrote this before but I don't like that this Square(length: l) looks like a constructor.

It also works in the exact opposite way of normal constructors where l is a value you provide but in this case l is a variable that is assigned to by the statement to use after the =>. I think this would make the language harder to understand.

This is basically how patterns work in every language that has them. The pattern syntax mirrors the corresponding expression syntax that would create an object that the pattern destructures. It's weird at first, but once you get used to the idea, it lets you easily infer what a pattern means based on what you know about the corresponding expression.

Would case [int x, int y]: only match if json is a list with two int values? What about three ints?

Just two int values. The proposal for list patterns requires it to have exactly the matched length. (We'll probably add a ... syntax to mean "match a list of any length that starts with these elements".)

Can someone explain what final is used for here in simple terms? I didn't understand that unfortunately.

It lets you extract the runtime type argument out of an instance of a generic type:

// The static type is List<Object>, the actual runtime type is List<int>:
List<Object> list = <int>[];

switch (list) {
  case List<final T>(): print('Runtime type is $T'); // Prints "int".
}

There's currently no way in the language to extract a generic object's reified type arguments.

Why not just:

var (first, second) = (first: 1, second: 2);
var (bar, foo) = (foo: 1, bar: 2);

I guess there is probably some ambiguity?

Yes, since a record can contain both named and positional fields.

lrhn commented 2 years ago

This is basically how patterns work in every language that has them. The pattern syntax mirrors the corresponding expression syntax that would create an object that the pattern destructures. It's weird at first, but once you get used to the idea, it lets you easily infer what a pattern means based on what you know about the corresponding expression.

Letting construction and destructuring mirror each other's syntax is also the reasoning behind the structure of C types. I'm not sure that's necessarily a convincing argument for the result being readable :wink:.

The pattern syntax here does work fairly well for predictably structured values with good literal syntax, like lists and types themselves. It can work for maps, if you provide the keys in the pattern, but it can easily get hairy because {/} means so many things already. Will probably never be able to match sets, because they have no predictable structure and no way to enforce an ordering in a predictable way (or at least, anything which works for sets could also work for arbitrary iterables).

Dart objects are not Haskell/SML abstract data-types, they have properties separate from the constructor arguments/field values, and that abstraction has to be respected. You can't always mirror construction there.

munificent commented 2 years ago

Letting construction and destructuring mirror each other's syntax is also the reasoning behind the structure of C types. I'm not sure that's necessarily a convincing argument for the result being readable πŸ˜‰.

Oh you don't have to tell me that "declaration reflects use" was a misfeature. :)

The pattern syntax here does work fairly well for predictably structured values with good literal syntax, like lists and types themselves. It can work for maps, if you provide the keys in the pattern, but it can easily get hairy because {/} means so many things already.

Yeah, one of the challenges I ran into was deciding how named record field destructuring works. Most ML-derived languages treat tuples and records as totally distinct concepts with different syntax for each. They use { ... } for records and then use that for record patterns too. But that would be weird in Dart because we use { ... } for maps. Also, my proposal for records unifies positional and named fields into a single construct. That's why I went with (...) for destructuring even named fields.

Will probably never be able to match sets, because they have no predictable structure and no way to enforce an ordering in a predictable way (or at least, anything which works for sets could also work for arbitrary iterables).

Yeah, I thought about sets and didn't come up with anything. In practice, I think it's fine. I don't think they're used that often and destructuring them feels like an odd operation since they're unordered.

Dart objects are not Haskell/SML abstract data-types, they have properties separate from the constructor arguments/field values, and that abstraction has to be respected. You can't always mirror construction there.

Agreed that there's no perfect correspondence and no perfect syntax. We just have to try to come up with the best we can building on the grammar we already have.

cedvdb commented 2 years ago

I can get behind that it will become second nature but I wouldn't mind a keyword either.

case Square with (length: l) => l * l;

Maybe it's cleaner without keyword once you get used to it though

Levi-Lesches commented 2 years ago

That's why I went with (...) for destructuring even named fields.

I also like this because the proposal says to think of records like a reified version of a function's parameter list, so making the syntax match as closely as possible helps with that.

munificent commented 2 years ago

For those following along on this thread, I've added a proposal for exhaustiveness checking.

cedvdb commented 2 years ago

I do not understand why there is sealed. Prior to introducing sealed you say the following, which seems to be enough.

Dart and most other languages have an implicit rule that the compile errors in a file should be determined solely by the files that file depends on, directly or indirectly.

So I'd assumed you would have reached consensus on exhaustiveness, just by scanning that file.

Is it to avoid switch on an Object type or something like that ? That part was a bit unclear

jodinathan commented 2 years ago

I do not understand why there is sealed. Prior to introducing sealed you say the following, which seems to be enough.

Dart and most other languages have an implicit rule that the compile errors in a file should be determined solely by the files that file depends on, directly or indirectly.

So I'd assumed you would have reached consensus on exhaustiveness, just by scanning that file.

Is it to avoid switch on an Object type or something like that ? That part was a bit unclear

I guess because devs can import and implement any classes that aren't private?

// original lib
abstract class Foo {}

class Bar extends Foo {}

void daz(Foo foo) {
  switch (foo) {
    case Bar: print('Bar'); break;
  }
}

what would happen if we imported that lib and then

class Fus extends Foo {}

daz(Fus()); // how would the switch work?
munificent commented 2 years ago

I guess because devs can import and implement any classes that aren't private?

It's this. The supertype needs to be explicitly marked sealed so that it can be an error for downstream code to implement or extend the class.

SandroMaglione commented 2 years ago

How is this issue related to Static Metaprogramming #1482 ? Is the dart team planning to work on both of this features? Are they related somehow? Is it possible that Static Metaprogramming could overlap in some of its capabilities to patterns?

Also, is there a timeline estimation for patterns to be available in dart (similar to this estimation for Static Metaprogramming)?

munificent commented 2 years ago

How is this issue related to Static Metaprogramming #1482 ?

It's not. :)

Is the dart team planning to work on both of this features?

Yes! We have a big enough language team to do some work in parallel.

Are they related somehow? Is it possible that Static Metaprogramming could overlap in some of its capabilities to patterns?

In general, yes, we always hope that every new language feature can compose with other features in useful ways. But these two features specifically don't have a lot of affinity for each other as far as I know. Users may certainly surprise us with what they come up with, though.

ykmnkmi commented 2 years ago

Can patterns be used in function definitions?


void printPoint(Point(x: x, y: y)) {
  print('x: $x, y: $y');
}
munificent commented 2 years ago

The current proposal does not allow patterns in parameter lists, unfortunately. It's something I would really really like to do but... Dart's parameter list syntax is already super weird and complex. You have required, this., super., [...] for optional parameters, {...} for named parameters, default values, and probably other stuff I'm forgetting. Figuring out a pattern syntax that also harmonizes with all of that is very hard.

I expect that our initial release of pattern matching won't support patterns in parameter lists but I do hope we can get there eventually once we have time to work out all the details.

Levi-Lesches commented 2 years ago

Follow-up question: Could you be able to unpack a record to use as arguments to a function? I didn't see anything in the records proposal describing it, but it does say it "is identical to the grammar for a function call argument list."

void printPoint(int x, int y) {
  print('x: $x, y: $y');
}

void main() {
  final (int, int) origin = (0, 0);
  printPoint(origin);  // some special syntax to unpack the record?
}
munificent commented 2 years ago

Follow-up question: Could you be able to unpack a record to use as arguments to a function? I didn't see anything in the records proposal describing it, but it does say it "is identical to the grammar for a function call argument list."

The current proposal does not have any support for spreading records into argument lists. That has always been a goal of mine and still is, but I suspect we won't get it into the first release of support for records and patterns. The proposal already has a lot of surface area, and that's something we can reasonably add later, I think.

SandroMaglione commented 1 year ago

Is the dart team planning to work on Patterns before, after, or at the same time of #1482? When would it be reasonable to expect a first alpha implementation of this?

mateusfccp commented 1 year ago

Is the dart team planning to work on Patterns before, after, or at the same time of #1482? When would it be reasonable to expect a first alpha implementation of this?

They are working on both, as of now, although more recently I've seen much more movement in patters than static meta-programming.

I can't say about release, tho. Maybe they are going to release both on Dart 3?

bouraine commented 1 year ago

records have recently moved to /accepted/future-releases

munificent commented 1 year ago

Is the dart team planning to work on Patterns before, after, or at the same time of #1482?

Records and patterns will be coming before static metaprogramming, unless something comes up that causes us to change the plan.

When would it be reasonable to expect a first alpha implementation of this?

Sorry, but we don't generally offer future release dates since dates tend to change often.

xmine64 commented 1 year ago

Records and patterns will be coming before static metaprogramming

Even knowing that they'll come is so good. Without them codes are really dirty and and running build_runner takes so much time.

atrauzzi commented 1 year ago

I just wanted to add my 2c, but you guys really need to look at how C# has implemented pattern matching and destructuring. It's turned out to be so elegant at least in utility.

if (something is { YouCan: true, Do: { Some: "crazy", Stuff: true}})
{
   // ...
}

Even if it looks verbose at first blush, the amount of verbosity it cuts down when performing checks of complicated object hierarchies is just priceless.

munificent commented 1 year ago

you guys really need to look at how C# has implemented pattern matching and destructuring.

We did. :)

It's turned out to be so elegant at least in utility.

if (something is { YouCan: true, Do: { Some: "crazy", Stuff: true}})
{
   // ...
}

Yes, the proposal supports arbitrary destructuring like that. There are some syntactic differences between Dart has map literals which naturally want to claim the curly braces, but the level of expressiveness is roughly the same.

pedromassango commented 1 year ago

Good to see this in the "Being implemented" funnel πŸŽ‰

@munificent now that this is being implemented how the new "Data class" feature will look like? Or perhaps where it is being dicused/designed?

munificent commented 1 year ago

No major updates on data classes. One relevant bit is that part of the design of views includes a primary constructor-like syntax and that led to discussing whether we can generalize that for classes (#2364). I believe we can and my hope is that we'll be able to design and ship that at some point after we ship records and patterns. My feeling is that primary constructors is a big piece of what users want when they ask for data classes.

SandroMaglione commented 1 year ago

@munificent since the whole "Pattern and related feature" is being implemented, does this include all the feature mentioned in this PR? I suppose that Record will be the first feature, but also the others are work in progress? Specifically, what about ADTs?

munificent commented 1 year ago

I can't promise it covers every single comment in this issue (there's a lot), but, yes, we're doing records and pattern matching and implementing both now. That includes support for algebraic datatype-style code. We want this to feel like a robust, complete set of features.

ghost commented 1 year ago

I've played around with Dart 3 Alpha and it's such a major upgrade in DX. Great job everyone involved! πŸ₯‡

@munificent With sealed classes in place, is it there a plan in the future to implement narrowing with the ternary operator when working with sealed classes? Something like this:

sealed class User {}
class A extends User {
  final String firstName;

  A(this.firstName);
}

class B extends User {
  final String lastName;

  B(this.lastName);
}

String formatName(User x) {
  return x is A ? x.firstName : x.lastName // Dart 3 can't deduce that x must be of Class B at this point.
}

That would be really valuable for handling conditional widget rendering inline in Flutter without the need to create a function with a switch statement.

lucavenir commented 1 year ago

@blueturningred I may be wrong, but can't we just implement the switch operator instead? Dart will never know how many subclasses will inherit sealed class User: using switch should be safer and fully exhaustive.

Also, when reading return x is A ? x.firstName : x.lastName I (personally) immediately get confused.

What is x's type in the "else" side of the ternary operator? Why should we able to acces lastName in an else statement, which is (to me) the equivalent of a default case in the aforementioned switch operator? If anything, a default clause should let us access User fields, but not just any other subclass field.

Maybe I'm missing something here. If Dart 3 is able to infer which classes are "left" in the default clause, then it might make sense to let x expose any field that is common to every other subclasses (lastName in this example). But I'd still write default instead of a implied else statement.

ghost commented 1 year ago

@lucavenir

I may be wrong, but can't we just implement the switch operator instead? Dart will never know how many subclasses will inherit sealed class User: using switch should be safer and fully exhaustive.

Isn't the whole purpose of sealed classes that the compiler would be able to find all the subclasses that extends the sealed class by preventing extension outside the package?. Also, you can't practically use switch inline.

Also, when reading return x is A ? x.firstName : x.lastName I (personally) immediately get confused.

What is x's type in the "else" side of the ternary operator? Why should we able to acces lastName in an else statement, which is (to me) the equivalent of a default case in the aforementioned switch operator? If anything, a default clause should let us access User fields, but not just any other subclass field.

I think it's pretty obvious what x's type would be on the else side. What seems to be the mystery here? You have two subclasses, you've already checked for one of them, what would be the subclass in the else side? Here's a working example in Typescript: Link

lucavenir commented 1 year ago

You have two subclasses

Sorry if I'm bothering you, but what happens if you add a third one? Could you show that with Typescript, too?

ghost commented 1 year ago

Sorry if I'm bothering you, but what happens if you add a third one? Could you show that with Typescript, too?

Not at all. If you add a third one which doesn't have lastName, then the compiler would complain and you'd be required to go back to formatName and account for the third one. The error in this particular case would be:

Property 'lastName' does not exist on type '{ _type: "UserB"; lastName: string; } | { _type: "UserC"; anothProp: string; }'.
  Property 'lastName' does not exist on type '{ _type: "UserC"; anothProp: string; }'

You can check the code here.

However, if you add a third one that DOES have lastName of the same type (string) then the compiler wouldn't complain and you'd not be required to make any changes.

lucavenir commented 1 year ago

Thank you @blueturningred, I understand this now. It's even better to what @freezed does today.

munificent commented 1 year ago

@munificent With sealed classes in place, is it there a plan in the future to implement narrowing with the ternary operator when working with sealed classes?

Good question! The main issue to discuss this is #2179. We aren't currently planning to have type promotion take sealed types into account and at this point it's very unlikely it will make it into 3.0. I think that's probably the right call. Flow analysis and type promotion versus sealed types and pattern matching are sort of two opposing styles for solving the same problem (moving from a more general type to a more specific one).

Most languages don't let you mix those styles at all. Rust, Swift, Haskell, and other functional languages do pattern matching but don't do any flow analysis. Kotlin and TypeScript do flow analysis but don't really have pattern matching. Dart is a little odd in doing both but it can get kind of weird when the two styles mix. I think it's probably easier for users to understand if we try to keep those styles mostly confined.

I could be wrong. But for now, if you want to work exhaustively with sealed types, that's what switches are for. If you want to work with type tests and control flow, that's what type promotion is for.

As to your example, I would write:

sealed class User {}

class A extends User {
  final String firstName;

  A(this.firstName);
}

class B extends User {
  final String lastName;

  B(this.lastName);
}

String formatName(User x) =>
  switch (x) { A a => a.firstName, B b => b.lastName };
munificent commented 1 year ago

Closing this because records and patterns are now enabled by default on the main branch of the Dart SDK! πŸŽ‰

albertodev01 commented 1 year ago

@munificent you should move this into the "Done" column of the "Language Funnel" project

munificent commented 1 year ago

I'm not sure exactly how the timing for stuff in the language funnel is managed. That might wait until it ships on stable. I'll let @mit-mit or @itsjustkevin handle that part. :)

talski commented 1 year ago

will I be able to use the record identifier instead of indexes?

const (String name, int age) person = ('John doe', 32);

// this
print('${person.name}, ${person.age}');

// or this
print('${person.$name}, ${person.$age}');

// is more readable than this
print('${person.$1}, ${person.$2}');
SandroMaglione commented 1 year ago

@talski Yes, you can do as follows:

const (String name, int age) person1 = ('John doe', 32);
print('${person1.$1}, ${person1.$2}');

const ({String name, int age}) person2 = (name: 'John doe', age: 32);
print('${person2.name}, ${person2.age}');

You can read more on Record type annotations:

(int, String name, bool) triple;

Each field is a type annotation and an optional name which isn't meaningful but is useful for documentation purposes.

Named fields go inside a brace-delimited section of type and name pairs:

({int n, String s}) pair;