Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Partial Record destructuring #3964

Open
rrousselGit opened this issue Jul 5, 2024 · 12 comments
Open

Partial Record destructuring #3964

rrousselGit opened this issue Jul 5, 2024 · 12 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@rrousselGit
Copy link

Problem:

Currently, it is not possible to use destructuring to only extract one or two variables out of a Record.
Consider a large Record:

typedef Big = ({ int field1, int field2, ...., int field8 });

If we want to extract field1+field2 but nothing else, we currently have to write:

void main() {
  final Big big = (field1: 42, field2: 42, field8: 42);
  final (:field1, :field2, field3: _, field4: _, ..., field8: _) = big;
}

That's bloody inconvenient. It's both really tedious to type, difficult to read (due to a lot of noise), and difficult to maintain.
In particular, adding a new field in that Record requires going through every places where it was destructured.

Proposal:

When destructuring is used inside variable declarations, we could make specifying any field optional.
Using the previous Big record, we could therefore write:

void main() {
  final Big big = (field1: 42, field2: 42, field8: 42);
  final (:field1, :field2) = big;
}

This is how Record types work in Typescript.

@rrousselGit rrousselGit added the feature Proposed language feature that solves one or more problems label Jul 5, 2024
@lrhn
Copy link
Member

lrhn commented Jul 5, 2024

It's not completely unreasonable for a pattern with a record type as matched value type, to be able to ignore some of the fields.

The hurdle is that it's not allowed for a refutable match against a non-record type, because a record pattern must ensure that it is matched against a single known record shape.
That means case (: var x, ...) is only allowed against some matched value types, as the only refutable pattern that can be invalid because of the type being matched.

@water-mizuu
Copy link

water-mizuu commented Jul 7, 2024

You can also use the defined type alias to object destructure the record. However, I do support this feature.

void main() {
  final Big big = (field1: 42, field2: 42, field8: 42);
  final Big(:field1, :field2) = big;
}

@tatumizer
Copy link

tatumizer commented Jul 8, 2024

A straightforward solution would be to enable the syntax like

final (:field1, :field2, ...rest) = big; 
final [x, y, ...rest] = [1, 2, 3, 4];

If the "rest" is of no interest, then

final (:field1, :field2, ..._) = big;
final [x, y, ..._] = [1, 2, 3, 4];

@munificent
Copy link
Member

Yeah, I agree this would be useful. As @tatumizer suggests, I do think it should be something you explicitly opt into, probably using the same ... rest syntax we have for list patterns.

However, as @lrhn notes, it's challenging to incorporate this into refutable patterns. If we support:

var stuff = (a: 1, b: 2, c: 3);
var (:a, :c, ...) = stuff;

Then users would probably expect this to work:

var stuff = (a: 1, b: 2, c: 3);
if (stuff case (a: 1, :var c, ...)) {
  print('a is one and c is $c');
}

But that might in turn lead them to expect this to work:

Object stuff = (a: 1, b: 2, c: 3); // <- Note: Has type Object now.
if (stuff case (a: 1, :var c, ...)) {
  print('a is one and c is $c');
}

Even though this looks similar, it's a very different program. Because now, what is the type that stuff is being tested against? Up to this point, every pattern is basically "test if the value has some type X and then do other stuff". But there's no type for "any record type that has these fields".

You can't just say "a record with fields a and c" because there's no subtype relationship between that record type and a record type like ({a: int, b: int, c: int}). In the literature, this is called "width subtyping" and we deliberately avoided it in Dart because it makes optimizations and other stuff harder.

We could say that you can use incomplete record patterns (i.e. ones with a ...) only in contexts where there is a surrounding context type that is a record type that fills in the .... That would work. I just worry that it would be a confusing limitation when users tried to use ... in a place where there isn't that context type.

In short, it's an interesting idea and would be practically useful. I just worry about the risk of user confusion.

@nhannah
Copy link

nhannah commented Nov 19, 2024

You can also use the defined type alias to object destructure the record. However, I do support this feature.

void main() {
  final Big big = (field1: 42, field2: 42, field8: 42);
  final Big(:field1, :field2) = big;
}

I am interested if there would be a possibility to improve inference around this type alias object destructuring:

class Student {
  final String name;
  final int age;

  Student(this.name, this.age);
}

class StudentT<T> {
  final String name;
  final int age;
  final T something;

  StudentT(this.name, this.age, this.something);
}

StudentT<T> getStudent<T>(Student student, T Function(Student) selector) =>
    StudentT<T>(student.name, student.age, selector(student));

void main() {
  final StudentT(:age, :name, :something) = getStudent(Student('Ethiel', 12), (s) => s.age > 5);
  
  print('Student $name is $age $something years old');
}

In this example something is typed as dynamic as the generic type is lost.

void main() {
  final a = getStudent(Student('Ethiel', 12), (s) => s.age > 5);
  
  print('Student $name is $age $something years old');
}

a here is properly inferred as StudentT bool.

void main() {
  final StudentT<bool>(:age, :name, :something)  = getStudent(Student('Ethiel', 12), (s) => s.age > 5);
  
  print('Student $name is $age $something years old');
}

In this example something is properly typed as bool as it's explicitly set as the generic on StudentT.

void main() {
  final StudentT(:age, :name, :something)  = getStudent<bool>(Student('Ethiel', 12), (s) => s.age > 5);
  
  print('Student $name is $age $something years old');
}

This is where I get a slightly confused, getStudent can properly infer T as bool, but can't pass that inference to the destructuring. But if getStudent does not infer it's type and has it set explicitly this all works fine.

Swap to records and everything works as expected with no explicit types assigned but partial destructuring goes away:

typedef Student = ({
  String name,
  int age,
});

typedef StudentT<T> = ({
  String name,
  int age,
  T something,
});

StudentT<T> getStudent<T>(Student student, T Function(Student) selector) =>
    (name: student.name, age: student.age, something: selector(student));

void main() {
  final (:age, :name, :something) = getStudent((name: 'Ethiel', age: 12), (s) => s.age > 5);

  print('Student $name is $age $something years old');
}

@rrousselGit
Copy link
Author

rrousselGit commented Nov 19, 2024

@lrhn @munificent IMO it feels like we're trying to mix "pattern matching" and "destructuring" here.
To me, those are separate features, as destructuring could work without patterns.

Thinking about it: Although this issue mentioned Records, the solution probably doesn't have to be unique to them.

Instead of the existing () used for record patterns, we could use a different expression.
For example :{} (this is just an example. I'm sure a better syntax can be found):

final :{a, b} = (a: 42, b: 21);
final :{name, age}= Person('john', 18);
final :{length, first} = [1,2,3];
final :{entries} = {'key': value};

And this could apply to other variable definition, such as parameters:

Event(onChange: (:{value}) => print(value));
// Equivalent to:
Event(onChange: (obj) => print(obj.value));

void fn(Person :{name}) => print(name);
// Equivalent to:
void fn(Person person) => print(person.name);

In that scenario, we don't care about "refutable vs irrefutable pattern" as this doesn't involve patterns at all.
And we don't need ... either, as that syntax is by nature opt-in.

@lrhn
Copy link
Member

lrhn commented Nov 19, 2024

This does look like an Object pattern destructuring with a different syntax.

If we had a shorthand for not writing the type, strawman ., then

final :{length, first} = [1,2,3];

would just be

final .(:length, :first) = [1,2,3];

Using the existing syntax.

We're not mixing destructuring and matching. Patterns are designed as a syntax for both, and declaration patterns are only for destructuring.
It allows renaming, so you can do var .(first: begin, last: end) = ..., a feature you'd soon have to add to the :{} syntax.

The reason to ask for ... is that object destructuring is not a good match records, because it doesn't handle positioal properties well.
You have to do

  var .($1: first, $2: second) = quintuple;

To get the first two positional elements of a larger record, or live with the $i names.
It's nicer to do var (first, second, ...) = quintuple;, because you don't have to mention the $is.
The ... is there to say that this is not the entire type of the pattern, what it would otherwise be taken as.
This would only work on declaration patterns, or at least it won't introduce a context type (or at least not over that can be reified as anything but Record). It's only allowed if the matches value type is a record type that it can match.

@rrousselGit
Copy link
Author

We're not mixing destructuring and matching. Patterns are designed as a syntax for both, and declaration patterns are only for destructuring.

Yet the problems listed were about case (:x, ...) and Object obj; var (:x) = obj.
The former isn't a declaration pattern afaik. And the latter is only a concern if we use the "pattern" bit of the syntax to have the variable declaration perform a "match".

Both concerns, to me, feels like they are raised only because we're reusing pattern matching instead of having destructuring be its own thing.

@nhannah
Copy link

nhannah commented Nov 19, 2024

Bouncing back up to my example, #3964 (comment)

This works properly and infers the generic:

void main() {
  final a = getStudent(Student('Ethiel', 12), (s) => s.age > 5);
  final StudentT(:age, :something) = a;
}

But combining the two lines into one does not infer the generic.

Changing this to an iife then works on 1 line as well:

void main() {
  final StudentT(:age, :something) = () {
    return getStudent(Student('Ethiel', 12), (s) => s.age > 5);
  }();

  print('Student is $age $something years old');
}

That seems funky...

@munificent
Copy link
Member

@nhannah, I think your problem is different enough from one this one is originally about that it's better to fork it off to a separate issue: #4180.

Let's keep this one about partial record destructuring, which is different from how inference works with object patterns.

@water-mizuu
Copy link

Even though this looks similar, it's a very different program. Because now, what is the type that stuff is being tested against? Up to this point, every pattern is basically "test if the value has some type X and then do other stuff". But there's no type for "any record type that has these fields".

What if we only partial record deconstruction on explicitly Record typed objects? This would encourage them to create a type alias, or use the entire field.

@eernstg
Copy link
Member

eernstg commented Nov 27, 2024

@munificent wrote:

We could say that you can use incomplete record patterns (i.e. ones with a ...) only in contexts where there is a surrounding context type that is a record type that fills in the .... That would work.

I do think that's a nice response to the initial post in this issue. It would work, based on the static type of the initializing expression in irrefutable contexts (declarations), and based on the matched value type in refutable contexts (switches etc.). In the case where it doesn't work, the developer would get a compile-time error (in particular, it wouldn't silently have a different semantics). Not bad!

Whether it's a feature that pays for its implementation effort is another question, but I definitely think this is a viable proposal, and also reasonably readable.

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

No branches or pull requests

7 participants