dart-lang / language

Design of the Dart language
Other
2.62k stars 198 forks source link

Patterns and related features #546

Closed munificent closed 1 year ago

munificent commented 4 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.

werediver commented 4 years ago

A great digest 👍 In the meanwhile, there is a young library that generates sum-types (-alike classes) for you: https://github.com/werediver/sum_types.dart (is this shameless plug an off-topic?)

munificent commented 4 years ago

In the meanwhile, there is a young library that generates sum-types (-alike classes) for you: https://github.com/werediver/sum_types.dart

Thanks for mentioning this! This is a good reference for ensuring that whatever we add to the language covers the functionality that users want to see.

rrousselGit commented 4 years ago

If existing packages are interesting to see, here's a bunch of them:

wkornewald commented 4 years ago

Since you're collecting information, I'd like to mention my "proposal" where I suggested how you could combine pattern matching, data classes, union types, and typedef into a coherent whole while still making each of those concepts useful individually. The result is easier to use/understand and more flexible than algebraic data types. This is what we want.

So, pattern matching should IMHO focus on these orthogonal concepts of data classes (for destructuring) and union types (for exhaustiveness).

Also, before I found this issue I already commented here on why algebraic data types result in a worse developer experience vs. union types. TL/DR: with algebraic data types, pattern matching forces you to check for cases at runtime (and raise exceptions) although they should be forbidden at compile-time! I suppose that's also relevant to this issue.

werediver commented 4 years ago

@wkornewald The untagged unions concept sounds nice, but it doesn't sound like a replacement to the algebraic data types. They can totally coexist and serve different needs.

(not clear which topic is more relevant, so put in both #546 and #83)

wkornewald commented 4 years ago

I'd say it's more relevant to #83, so let's discuss over there.

modulovalue commented 4 years ago

I haven't seen lenses mentioned anywhere before when data classes are being discussed. I strongly believe that they would greatly benefit from 'boilerplate free' lenses. This package attempts to solve that by generating data classes and a lens for each property functional_data. Another implementation for more advanced lenses can be found in the package dartz and an example can be found here.

wkornewald commented 4 years ago

@modulovalue That's a great suggestion. Lenses would be very helpful for safe and ergonomic state management (e.g. for my flutter_simple_state package or, if you prefer, something similar to immutable.js + Omniscient in TypeScript). Unfortunately, it's seemingly impossible to define a lens that can wrap arbitrary (including 3rd-party) data structures / objects. Well, at least without some compile-time code generation...

Maybe lenses should be a separate issue, though?

g5becks commented 4 years ago

@modulovalue Whats the benefit of lenses over record copying semantics of F# over lenses. https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/copy-and-update-record-expressions ?

spkersten commented 4 years ago

I’m not familiar with it F#, but what is shown on the linked page looks very similar to copyWith method. Lenses are much more powerful. They allow you to change values deep within a nested data structure. See the example in the functional_data package.

On Sat, 21 Sep 2019 at 06:08, Gary Becks notifications@github.com wrote:

@modulovalue https://github.com/modulovalue Whats the benefit of lenses over record copying semantics of F# over lenses.

https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/copy-and-update-record-expressions ?

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/dart-lang/language/issues/546?email_source=notifications&email_token=ABCADABNFAIEQD2WZVXNORTQKWM5TA5CNFSM4IPYOCZ2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD7IJ4IA#issuecomment-533765664, or mute the thread https://github.com/notifications/unsubscribe-auth/ABCADABIBLYRWKW5JK7AR4TQKWM5TANCNFSM4IPYOCZQ .

modulovalue commented 4 years ago

I'm also not too familiar with F# but as spkersten said, they are much more flexible by being a more general abstraction.

Take a look at the following code snippet:

class Foo {
  final Bar bar;
}

class Bar {
  final Baz baz;
}

class Baz {
  final String content;
}

A Lens of type Lens<Foo, String> removes the fact that a Bar exists. It lets me access the content of Foo by just having an instance of Foo. The consumer doesn't even care that Baz exists, or where content is. Any changes to the API would not break updateFoo.

Foo updateFoo(Lens<Foo, String> lens, Foo foo) {
  return lens.update(foo, "New Content");
}

Lenses also pave the way for many other abstractions like the FocusedLens in spkerstens package. Lenses can also be chained which I believe is not possible with copy and update expressions in F#.

But as I mentioned, creating such lenses is too verbose right now. I'd ideally want something like Foo$.bar.baz.content.

-- @wkornewald said

Unfortunately, it's seemingly impossible to define a lens that can wrap arbitrary (including 3rd-party) > data structures / objects. Well, at least without some compile-time code generation... Maybe lenses should be a separate issue, though?

I don't see how the dart team could add support for lenses right now as doing that in the OOP context of Dart only makes sense for data classes. There's not a lot I can say other than asking politely to consider the strengths of this abstraction when implementing data classes.

g5becks commented 4 years ago

@spkersten Very cool package, I didn't even know that existed!

@modulovalue

I understand what lenses are, but I don't understand what the use case is at a language level vs baked in copyWith ? Even the most functional of languages don't have lens support at the language level. Haskell uses the lens library, Scala uses monocle, F# uses aether, Ocaml uses lens.

Edit... Considering the OO nature of Dart, I do see why this would be more important for dart than it would be in a more functional language, I don't tend to use any inheritance when writing functional code and my records are always designed to be small enough to not need lenses and also not to negatively affect performance. That said, I agree with your point about the team not being able to implement such a feature, would be nice though.

jifalops commented 4 years ago

Can someone comment on the layers that have an effect on the Dart language as described in the grammar to the Dart language I experience as a developer? For instance the analyzer seems to add restrictions, such as not allowing late. I haven't searched the space satisfactorily but my recollection is that the grammar allows things the VS code plugin doesn't.

Another question: Would patterns allow default type parameters like A<B=X, C=Y>? Is this a thing? I want it to be a thing. Then I can have A => A<X,Y>() and A<Z> => A<Z,Y> I'm not sure about a named syntax, maybe A<C:=Z> => A<X,Z>? That looks ugly and maybe positional only would be best. This just came up because I want to have Graph<V, E=V> so that unweighted graphs can be described as Graph<V>, and that means I don't have to create classes solely for hiding the second type parameter.

I looked into other grammars from ANTLR's catalog, and ones with patterns (scala, C#) didn't seem to do anything out of the ordinary in their grammar. Is there a certain way of connecting rules?

Edit Sorry if this is rather open ended and asking for a volume of info, I hope I didn't derail the thread.

burakemir commented 4 years ago

I very much like the proposal to add a form of (ML style) algebraic data types - or even features that would let one encode algebraic data types in a convenient way.

I'd like to point out some "hooks" in the language spec that would make it easy to add ML style algebraic data types.

The following example uses a syntax that is borrowed from Scala 3 (see http://dotty.epfl.ch/docs/reference/enums/adts.html ...) but swift (https://docs.swift.org/swift-book/LanguageGuide/Enumerations.html) and Kotlin (https://kotlinlang.org/docs/reference/enum-classes.html) take a similar route.

enum Color {
  case Red
  case Green
  case Blue
  case Mix(int mix)
}

// alternatively:
enum Color(int rgb) {
  case Red extends Color(0xFF0000)
  case Green extends Color(0x00FF00)
  case Blue extends Color(0x0000FF)
  case Mix(int mix) extends Color(mix)
}

An enum either defines each case using the "case" keyword, or none (which would mean, it's a traditional enum as before). We'd extend the enum translation to create one subclass per case, e.g. (only doing the second example)

class Color {
  final int rgb;
  Color(this.rgb);
}

class Red extends Color {
   Red() : super(0xFF0000);
   // appropriate equals and hashcode implementations
}

class Mix extends Color {
  final int mix;
  Mix(this.mix) : super(mix);
  // ...
}

Instead of the verbose inline "extends", one could use the syntax for super constructors and say case Red : super(0xFF0000) right away. However it would be harder to specialize generic arguments, which is useful:

enum Expr<T> {
  case Number(int n) extends Expr<Int>;
  case String(string s) extends Expr<String>;
}
enum Option<X> {
  case Some(X x)
  case None
}

enum Tree {
  case Leaf;
  case Node(Tree left, Tree right);
}

@unapply Option[Tree] isSpecial(Node tree) {
  return  tree.left == tree.right
    ? Some(tree.left)
    : None
}

exp match {
  case Leaf => ...
  case .isSpecial(tree) => ...  // we already know that "tree is Node"
  case Node(left, right) => ...
}

I will leave it at that for now - hope this is helpful. Let me close with the statement that enum corresponds closely to the sum type from type theory (or disjoint union from set theory), so in my mind it's not a coincidence that all these languages have extended "enum" to cover case distinction in data modeling.

munificent commented 4 years ago

Thanks for the comment. I'm not sure if we literally want to build on Dart's existing enum syntax (though I'm not fundamentally opposed either). But, otherwise, we seem to be thinking in the same direction.

g5becks commented 4 years ago

Thought I’d share something I came across today while browsing through facebooks’ skiplang docs. This might be an interesting approach for adt’s. Of course , using the keyword “children” wouldn’t be an option because of flutter and what not, but maybe something else would work. If nothing else, I think it’s worth a look.

munificent commented 4 years ago

Very interesting, thanks @g5becks!

rrousselGit commented 4 years ago

For some reason, extension methods helped quite a bit in that area.

For example, we can write:

extension UnionValue<A> on Union2<A, A> {
  A get value => _value as A;
}

class Union2<A, B> {
  Object _value;
}

Which has interesting properties:

Union2<String, int> example;
// String and int have no shared interface, so value is of type Object
Object value = example.value;
int impossible = example.value; // COMPILE ERROR, not type safe

Union2<int, double> example2;
// int and double have a common interface: num. Therefore value is of type num
num value2 = example2.value; // valid and type safe
int stillImpossible = example2.value; // COMPILE ERROR, value is a num not a int

Union2<String, String> example3;
// The type is always a String, so value is a String too
String value3 = example3.value; // still type safe

See union for a package using that pattern.

The only thing we're lacking for now is:

The first one is not too important, as we can easily make a method that transform Union2<String, int> in Union2<int, String>.

The most important one being the second one (Union2 to Union3+)

Maybe a simplification of the feature could be an "any"-like, so that we can do:

class Union2<A, B> implements Union3<A, B, any> {}

which would allow:

Union2<String, int> union2;
Union3<String, int, double> union3 = union2;
g5becks commented 4 years ago

@rrousselGit not sure how that fills the need for adt’s. E.G. how can you create a Result type without all the boilerplate it would take wrt the class hierarchy? I think the extension methods help fill the need of pattern matching for the interim, but not with the creation of what you are matching against. Or at least not for the use cases I think they would be used for mostly.

From the looks of it (I could be wrong) a Union<A,A> that has multiple constructors is an And type and not an Or type? Looks more like a Tuple type than a Choice type.

rrousselGit commented 4 years ago

@g5becks No, this is not a Tuple but an actual union. See union

TL;DR Union2<String, int> is manipulated with custom methods. The typical switch case on the different types a union can take is performed this way:

Union2<String, int> myUnion;

myUnion.switchCase(
  (String value) => print("got a string $value"), 
  (int value) => print("got an integer $value"), 
);

It also has an example of a result-like type https://github.com/rrousselGit/union#making-a-reusable-union

g5becks commented 4 years ago

@rrousselGit i should have read closer! That’s actually pretty cool. I think I’ll definitely be using this.

rrousselGit commented 4 years ago

Had another go at this recently. Interestingly, we can write extensions for functions too.

Which means we can use functions instead of classes to implement our temporary unions.

Which also means we can write:

Union2<String, int> union2;

Union3<String, int, double> union3 = union2; // upcast, no problem
Union4<String, int, double, Object> union4 = union2; // still works

while still being unable to write:

Union3<String, int, double> union3;

// downcast, does not work because the value can be a double too.
Union2<String, int> union2 = union3;

It's sad that Dart doesn't support well typedefs though. It'd be almost perfect: https://github.com/rrousselGit/union#custom-unions

jogboms commented 4 years ago

True, nice work! @rrousselGit

Also patiently waiting on the typedefs of typedefs feature.

xsahil03x commented 4 years ago

I also faced these issues and handled using the factory constructors mentioned by @rrousselGit in a StackOverflow post and was pretty satisfied with the results. To avoid the boilerplate, I built a code generator with my colleague which generates these classes by annotating Enums.

@superEnum
enum _MoviesResponse {
  @Data(fields: [DataField('movies', Movies)])
  Success,
  @object
  Unauthorized,
  @object
  NoNetwork,
  @Data(fields: [DataField('exception', Exception)])
  UnexpectedException
}

where:-

@Data() marks an enum value to be treated as a Data class.

@object marks an enum value to be treated as an object.

Then it can be used easily with the generated when function

 moviesResponse.when(
    success: (data) => print('Total Movies: ${data.movies.totalPages}'),
    unauthorized: (_) => print('Invalid ApiKey'),
    noNetwork: (_) => print(
      'No Internet, Please check your internet connection',
    ),
    unexpectedException: (error) => print(error.exception),
  );
werediver commented 4 years ago

@xsahil03x You may like another solution mentioned earlier in this thread: sum_types (also on pub.dev). It has proven to be very useful in practice and (by now) it has noticeably less limitations then the solution outlined in your message. Hope you'll find it suitable for you needs!

xsahil03x commented 4 years ago

@werediver I tried sum_types but I didn't like the boilerplate we have to write for generating the classes and the boilerplate looks redundant to me. Whereas in super_enum we are using enums to replicate the same and it feels easy and not redundant at all as we are using the enum itself to generate exhaustive when statement.

As I am coming from the native development, annotating enums feel much similar to me than creating bunch of classes as boilerplate.

werediver commented 4 years ago

@xsahil03x There are reasons behind the design of sum_types (the required boilerplate is a single class with case-constructors, not sure why you mentioned a bunch of classes). The boilerplate buys it compatibility with other code generators (e.g. functional_data) and extensibility (user-defined members). But surely you have your reasons behind the design of super_enum.

A less related feature of sum_types is that it facilitates arbitrary serialization-deserialization of the generated sum-types (as in Nat.fromJson() / Nat.toJson()). That can mostly be implemented with your approach as well (which is similar the approach of sum_types v0.1.x).

Jonas-Sander commented 3 years ago

Hey @munificent just saw your draft "Patterns Feature Specification" ❤️

Is there already a place where this is discussed? Is it alright to give some feedback to this spec in this issue here? (Do you even want Feedback (yet)?)

Jonas-Sander commented 3 years ago

Just gonna put something I had written somewhere here anyway 😉

I didn't yet quite grasp the advantages of this:

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

From my point of view something like the following is simpler to understand:

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

Related: If you would want to have the name reflect the object type something like this may also work:

double calculateArea(Shape shape) =>
  switch (shape) {
    shape is Square (square) => square.length * square.length;
    shape is Circle (circle) => math.pi * circle.radius * circle.radius;
  };
duncanmak commented 3 years ago

@Jonas-Sander I think the mixing of pattern matching and destructuring together is a relic of the design from ML, which is very successful and the same design has been copied into other languages like Scala and Rust, etc.

But your proposal is an intriguing design, if we change the design by a bit, here's an idea that's partly inspired by COND in Scheme:

double calculateArea(Shape shape) =>
  switch (shape) {
    shape is Square: (square) => square.length * square.length;
    shape is Circle: (circle) => math.pi * circle.radius * circle.radius;
  };

If we allow a lambda to come after the pattern, it could be cool to allow any arbitrary lambda in that spot, instead of just a literal block. That could work pretty nicely as well.

Jonas-Sander commented 3 years ago

Something more general:

I personally haven't yet felt the need for record types.
This may very well stem from the fact that I didn't use any language where records are used very often (altough there are tuples in typescript - I've never had to use them).

What I instead felt a real need for were:

Data classes / tuples / records

Data classes are mostly "solved" with this (and the record) proposal, as the named record is more or less the same.
For me it's just that with these proposal is so much stuff that I never felt like I needed. I've didn't really felt the need for tuples and I'm also kinda scared of this:

switch (obj) {
  case [int a, int b]:
  case {"a": int a, "b": int b}:
    print(a + b); // OK.

  case [int a]:
  case (String a): // Error.
    break;
}

For example instead of:

var (lat, long) = geoCode('Aarhus');
print('Location lat:$lat, long:$long');

I'd probably write:

// Similar to Kotlin just as an example
data class Location(final double lat, final double long);

final loc = geoCode('Aarhus');
print('Location lat:${loc.lat}, long:${loc.long}');

or with named parameters:

data class Location({final double lat, final double long});

Yes the record is shorter. For this example I like the record more.
For a real codebase on the otherhand I would use the latter probably 95% of the time because I like that such a value object / data class is one place where I could put documentation etc.
The real issue with Dart I have up until now is that I wish there would be a class with "value class"-ish == and hashCode implementation and maybe a copyWith autogenerated method. Not that I definitely need tuples.

This not to say tuples/records are a bad idea. Like already mentioned I'm personally not very familiar with them and because of this I also won't see all the use cases right for them.
Some use cases I did see and where I think I might personally use records or tuples:

Algebraic datatypes / Union types

What I would really really like to see in Dart on the other hand is something I use in typescript aaalll the time - union types. For example: double calculateArea(Square | Circle shape) {...}. (Which are union types not algebraic datatypes, right?)

From the proposal:

Algebraic datatypes You often have a family of related types and an operation that needs specific behavior for each type. In an object-oriented language, the natural way to model that is by implementing each operation as an instance method on its respective type:

abstract class Shape {
  double calculateArea();
}

class Square implements Shape {
  final double length;
  Square(this.length);

  double calculateArea() => length * length;
}

class Circle implements Shape {
  final double radius;
  Circle(this.radius);

  double calculateArea() => math.pi * radius * radius;
}

Here, the calculateArea() operation is supported by all shapes by implementing the method in each class. This works well for operations that feel closely tied to the class, but it splits the behavior for the entire operation across many classes and requires you to be able to add new instance methods to those classes. ....

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

I don't want to subclass. What I really like about double calculateArea(Square | Circle shape) {...} is that I can see what the method takes on the spot. Together with a switch expression this would be great:

double calculateArea(Square | Circle shape) =>
  switch (shape) {
    shape is Square (square) => square.length * square.length;
    shape is Circle (circle) => math.pi * circle.radius * circle.radius;
  };

Also what about the cases where the classes are unrelated?
For example from what I can remember there are several Dart methods which take someMethod(dynamic exceptionOrError).
From what I can remember both are distinct classes. Could be someMethod(Exception | Error exceptionOrError).
Can this be experessed with Algebraic datatypes? (honest question I don't know)

In the end: I don't know if this stuff even made sense, I probably confused many ideas and concepts ;) Just wanted to express my feelings regarding the proposal (which seems to be very early stage of course).

Jonas-Sander commented 3 years ago

@duncanmak

For me it's just that when I see this I think of constructors:

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

I think the mixing of pattern matching and destructuring together is a relic of the design from ML, which is very successful and the same design has been copied into other languages like Scala and Rust, etc.

Something I'm also not familiar with;)
What are the advantages of ahving the pattern matching and destructuring together? There will be some but with the short example that is given I would probably use the easier syntax instead of introducing a new one into the language. Do you have some examples?

Jonas-Sander commented 3 years ago

(regarding my wall of text)

As I think the stuff regarding data classes / records is a bit hazy:
I think in the end I would rather not introduce the records like proposed (and all the complexity) into the language but would rather have something like a data class which is a less complex concept (for me), may also integrate more nicely with automatic (de)serialization and is a single point for documentation etc.

This may be because I don't understand records enough so I would love to see some use cases. I guess records address other use cases than data classes, right? Maybe these are just not problems / use cases I have.

burakemir commented 3 years ago

The advantages of destructuring over "typecase" only becomes apparent when you nest patterns. For example:

Expr simplify(Expr expr) => switch (expr) { case Plus(x, Zero()) => x case Plus(Zero(), x) => x case Mul(Zero(), x) => Zero() case Mul(x, Zero()) => Zero() case Mul(One(), x) => x case Mul(x, One()) => x default => expr }

Certainly in the academic context of ML (Hope, Miranda etc), manipulation of symbolic logic expressions was very common, but despite the arithmetic expressions coming up as examples all the time, dealing with such tagged unions/records appears a lot in compilers, interpreters (DSLs), or implementing the GoF Interpreter pattern for things like user actions etc. where nested patterns are useful.

igotfr commented 2 years ago

related to: https://github.com/dart-lang/language/issues/2006 https://github.com/dart-lang/language/issues/2007

bouraine commented 2 years ago

I would like to provide with a real world example where discriminated unions based on enums would be very useful to reduce the boilerplate and improve readability:

abstract class SearchState {}

class SearchInitialState extends SearchState {}

class SearchLoadingState extends SearchState {}

class SearchErrorState extends SearchState {
  final String errorMessage;

  SearchErrorState(this.errorMessage);
}

class SearchResultState extends SearchState {
  final List<String> result;

  SearchResultState(this.result);
}

These types are used with bloc-library to describe the state of the app at every step of the search:

class SearchBloc extends Cubit<SearchState> {
  SearchCubit() : super(SearchInitialState());

  void search(String term) async {
    emit(SearchLoadingState()); // UI updates after every emit to display the new state
    try {
      final result = await repository.search(term);
      emit(SearchResultState(result));
    } catch (ex) {
      emit(SearchErrorState(ex.toString()));
    }
  }
}

Flutter UI:

 return BlocBuilder<SearchBloc, SearchState>(builder: (context, state) {
    if (state is SearchLoadingState) {
        return LoadingWidget();
    }
    if (state is SearchErrorState) {
        return ErrorWidget(message: state.errorMessage);
    }
    if (state is SearchResultState) {
      return ResultWidget(result: state.result)  
    }
    return SizedBox();
   ...
}

This could be replaced by something like the code below:

enum SearchState  {
  case Initial()
  case Loading()
  case Error(String message)
  case Result(List<String> result)
}

The code is concise and very clear. 7 LOC instead of 17 with spaces in the first example.

And the pattern matching could be something like the code below in the the Flutter UI:

return BlocBuilder<SearchBloc, SearchState>(builder: (context, state) {
   return switch state {
      case Initial() => SizedBox()
      case Loading() => LoadingWidget()
      case Error(message) => ErrorWidget(massage: message)
      case Result(result) => ResultWidget(result: result)
   }
}

The compiler could be of great help here by forcing to implement the exhaustive list of possible cases.

munificent commented 2 years ago

This could be replaced by something like the code below:

enum SearchState  {
  case Initial()
  case Loading()
  case Error(String message)
  case Result(List<String> result)
}

The code is concise and very clear. 7 LOC instead of 17 with spaces in the first example.

The pattern matching proposal doesn't currently include any nicer syntax for declaring a family of classes like you have here. I've put some thought into it, but not a lot. I agree it's very nice to have.

And the pattern matching could be something like the code below in the the Flutter UI:

return BlocBuilder<SearchBloc, SearchState>(builder: (context, state) {
   return switch state {
      case Initial() => SizedBox()
      case Loading() => LoadingWidget()
      case Error(message) => ErrorWidget(massage: message)
      case Result(result) => ResultWidget(result: result)
   }
}

Yes, that's very close to the current proposal. The exact syntax in the proposal would be:

return BlocBuilder<SearchBloc, SearchState>(builder: (context, state) {
   return switch state {
      case Initial() => SizedBox();
      case Loading() => LoadingWidget();
      case Error(message: var message) => ErrorWidget(message: message);
      case Result(result: var result) => ResultWidget(result: result);
   }
}
Levi-Lesches commented 2 years ago

This could be replaced by something like the code below:

enum SearchState  {
  case Initial()
  case Loading()
  case Error(String message)
  case Result(List<String> result)
}

The pattern matching proposal doesn't currently include any nicer syntax for declaring a family of classes like you have here. I've put some thought into it, but not a lot. I agree it's very nice to have.

Hopefully, this can be made simpler with #314, data classes:

abstract class SearchState { }
class Initial extends SearchState { }
class Loading extends SearchState { }
data class Error(String message) extends SearchState;
data class Result(List<String> result) extends SearchState;

Or even, until then, a simple macro from #1482, metaprogramming:

abstract class SearchState { }
class Initial extends SearchState { }
class Loading extends SearchState { }
@data class Error extends SearchState { final String message; }
@data class Result extends SearchState { final List<String> result; }
cedvdb commented 2 years ago

Yes, that's very close to the current proposal. The exact syntax in the proposal would be:

return BlocBuilder<SearchBloc, SearchState>(builder: (context, state) {
   return switch state {
      case Initial() => SizedBox();
      case Loading() => LoadingWidget();
      case Error(message: var message) => ErrorWidget(message: message);
      case Result(result: var result) => ResultWidget(result: result);
   }
}

How does it aggregate the different subclasses of SearchState if those are in different files, or even different packages ? Does the user have to explicitely aggregate them like in a group of some sort ? Or does the analyzer gets every subclass of SearchState it can reach ?

Are this proposal and records likely to be a thing in 2022 or will static meta programing delay it too much for that to happen ? If that's the case that really a bummer, I'd take this and records over static meta programing any day.

munificent commented 2 years ago

How does it aggregate the different subclasses of SearchState if those are in different files, or even different packages ? Does the user have to explicitely aggregate them like in a group of some sort ? Or does the analyzer gets every subclass of SearchState it can reach ?

For non-exhaustive matches, there's no need to explicitly know or collect all of the subtypes. You can just check against whatever types you want. In order to have sound exhaustiveness checking, you need to know that the set of subtypes is a closed set. The proposal doesn't have any support for that yet. It's something I'm hoping to work on soon. So I guess a fully correct example using the proposal today would be more like:

return BlocBuilder<SearchBloc, SearchState>(builder: (context, state) {
   return switch state {
      case Initial() => SizedBox();
      case Loading() => LoadingWidget();
      case Error(message: var message) => ErrorWidget(message: message);
      case Result(result: var result) => ResultWidget(result: result);
      default: throw 'Unexpected type.';
   }
}

Are this proposal and records likely to be a thing in 2022 or will static meta programing delay it too much for that to happen ?

As usual, I can't comment on specific timelines. We try to focus on shipping features at a high quality level and the trade-off is that we can't always predict exactly when something will ship.

rrousselGit commented 2 years ago

The proposal doesn't have any support for that yet.

Exhaustive switch case is probably the most important part of this issue I'd say

Otherwise, especially with Metaprogramming, I'd likely still end up using Freezed unions instead of the switch-is syntax, just for the fact that it's when is exhaustive

munificent commented 2 years ago

Exhaustive switch case is probably the most important part of this issue I'd say

Well... I think having patterns and being able to match on them at all is probably the most important part. :) But, yes, exhaustiveness checking is a big part of ADT-style software engineering and is particularly important for switch expressions.

rrousselGit commented 2 years ago

I think having patterns and being able to match on them at all is probably the most important part.

We definitely are in an agreement :smile:

Rather, I think the switch-case proposal is optional all things considered. Sure, having destructuring + expression switch-case + case is Type are all neat features. But in the grand scheme of things, they are only syntax sugar and don't add anything that we can't already do with:

if (state is Initial) {

} else if (state is Loading) {
} ...

My point being, say we were to split the various features that this issue involves across different Dart versions, I'd prefer having the ability to define sealed-class equivalent first.

My reasoning is, even without destructuring and all the other fancy features proposed here, if we had something similar to sealed-classes, we could make our if (state if A) else if (sate is B) an exhaustive check already. Dart already does that with null-safety after all.

Such that we'd have:

// ignore the syntax, not what's discussed here
sealed class Interface {
  class A {}
  class B {}
}

int function() {
  Interface value = <whatever>;

  if (value is A) {
    return 0;
  } else if (value is B) {
    return 1;
  }
  // Never reached, and the compiler knows it.
  // As such, Dart isn't complaining that the function is lacking a return
}

This way we'd have exhaustive pattern matching already.
The other parts of the proposal could wait IMO as long as we can do that.

The other features are certainly nice, don't mind me. My point is only about the order in which we should have those features since I'm aware that all of those proposals involve a large amount of work.

munificent commented 2 years ago

Sealed class hierarchies is also quite a bit of work. Anything that touches modifiers and restrictions on how classes can be used opens up a can of worms around whether API consumers or maintainers should have more control.

I could be wrong, but I think exhaustiveness checking is a relatively minor feature. It's important if you want to rewrite a lot of your code into an ADT style. But for most typical Dart code which will likely remain fairly object oriented, I think it's in the same general area of importance as unreachable code warnings. Useful to prevent bugs, but not life-changing.

twop commented 2 years ago

I could be wrong, but I think exhaustiveness checking is a relatively minor feature. It's important if you want to rewrite a lot of your code into an ADT style. But for most typical Dart code which will likely remain fairly object oriented, I think it's in the same general area of importance as unreachable code warnings. Useful to prevent bugs, but not life-changing.

Personal opinion: I think exhaustiveness for ADTs is almost inseparable part of the feature. I think the biggest appeal to use ADTs is to be able to model your logic more accurately AND outsource verifying the correctness of it to the compiler.

That unlocks this workflow: I add/remove variants and rely on the compiler to guide me where I need to change the code.

from my perspective, removing the compiler support for exhaustiveness makes that mental "outsourcing" either impossible or untrustworthy (which is probably even worse?)

Side note: I wonder what memory models ADTs in Dart can support? E.g. can ADT's values be allocated on the stack or heap only? F# has support for stack allocated ADTs but using them for high perf compute present a big challenge for CPU cache lines (they are too big), Rust on the other hand allocates them on the stack with the size of the "biggest" variant which is way more efficient.

rrousselGit commented 2 years ago

Useful to prevent bugs, but not life-changing.

I'd argue that sealed-classes preventing bugs is the very reason why they are life changing.

Isn't the whole point of using pattern matching to prevent bugs? If it doesn't prevent bugs, why bother?

That's why Freezed offers an exhaustive check.
That's also why some folks made PRs to Freezed to disable the generation of the non-exhaustive pattern matches https://github.com/rrousselGit/freezed/issues/562

That issue was essentially them saying "My team prefers having a worse syntax for the sake of exhaustive checks so that we can prevent bugs", which I totally get behind.

mraleph commented 2 years ago

but I think exhaustiveness checking is a relatively minor feature.

I have said this internally, but I want to also say this externally as well.

I think we should not be considering features like pattern matching, data classes, sealed class hierarchies etc in separation. Rather they should all be viewed as a part of a single package. I like to call this package features enabling functional domain modeling, because it reflects that use case that it is going to address.

You get most of the benefits from these features when you combine all of them in a single cocktail where you get static checking, IDE completion (e.g. you write switch state and strictly speaking IDE should over you to create an exhaustive switch), ergonomics, etc.

In this picture of the world exhaustiveness is not a minor subfeature, but rather a crucial piece of an overall offering without which the overall offering looses a lot of the benefits.

I could also draw a parallel between exhaustiveness checking and NNBD, because they bring similar guarantees around safety and making code simpler to reason about.

bouraine commented 2 years ago

I am of the same view as this comment of @eernstg : enum mechanism with associated values would be a quite natural way in Dart. I'd personally prefer enums over sealed classes.

I also agree with @mraleph, this should be seen as cohent feature to enable functional domain modeling, i highly recommend this excellent talk from Scott Wlaschin (Domain Modeling Made Functional) to understand what we can do with a powerful type system.

cedvdb commented 2 years ago

I've been wondering why this kind of pattern is more prevalent on the client side. On one end it's more correct and more provable from a compilation perspective, in a sense, on par with null safety. On the other, making all these state classes requires extra care, more boiler plate and a bit of a new way to think about OOP.

Moreover this is especially prevalent in the UI layer than any other layers of a client application. Maybe it has something to do with the fact that that layer is the last layer, it depends on the state of everything above it, making the number of possible state bigger.

It's basically dynamic polymorphism. This class has the data property when load method resolved successfully. The provability part is what I find really interesting and have been wondering if there was a way that could be extended to other layers without making the code too obtuse or just generally explored a bit more. Maybe some hints to the compiler could help the provability part without requiring too much boiler plate.

class DataLoader {
   @rule accessible-when(isLoadingSuccessful == true)
   late Data data;
   bool isLoadingSuccessful = false;
}

// requiring
if (dataLoader.isLoadingSuccessful)
  useData(dataLoader.data);
}

I don't think looks quite as simple as the current way though

abstract class DataLoaderState {}
class DataLoaderStateLoading {}
class DataLoaderStateLoaded {
   Data data;
}
rrousselGit commented 2 years ago

I am of the same view as this comment of eernstg : enum mechanism with associated values would be a quite natural way in Dart. I'd personally prefer enums over sealed classes.

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