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

Spec list-pattern on enumerable collections #4575

Merged
merged 11 commits into from
Mar 25, 2021
Merged

Conversation

jcouv
Copy link
Member

@jcouv jcouv commented Mar 23, 2021

Tagging @dotnet/roslyn-compiler @alrz @MadsTorgersen for review.

Relates to proposal #3435

@jcouv jcouv self-assigned this Mar 23, 2021
@jcouv jcouv requested a review from a team as a code owner March 23, 2021 04:47
@jcouv jcouv marked this pull request as draft March 23, 2021 07:59
@jcouv
Copy link
Member Author

jcouv commented Mar 23, 2021

I need to tweak this a bit more, to be able to share the data structure between two branches where the staring patterns in one may overlap with the ending patterns in another:

Fixed to handle scenarios where an element needs to be saved both in the start buffer and the end buffer.

collection switch
{
   { 1, 2, 3, .., 5 } => ... // causes start buffer to hold 3 elements
   {1,  .., 3, 4, 5 } => ... // causes end buffer to hold 3 elements
}

@jcouv jcouv marked this pull request as ready for review March 23, 2021 08:22
@alrz
Copy link
Contributor

alrz commented Mar 23, 2021

We could possibly ditch the separate start buffer if we disallow element match after a slice pattern.

case { .., 1, 2 }:
case { 1, 2, .. }: // subsumption error
case { 1, 2, .. }:
case { .., 1, 2 }: // ok

Note that we need to start buffering from the beginning anyways to facilitate forthcoming trailing matches but we only need to maintain a single buffer for that.

This might be too restrictive but I'm not sure how common this is to worth the added complexity.

@alrz
Copy link
Contributor

alrz commented Mar 23, 2021

That said, I'd prefer we have a separate helper for skipping elements,

(these may be not the precise signatures we need, it's just a guess based on the prototype)

@{
  // bufferSize: computed from the max number of trailing patterns in all arms
  var helper = new ListPatternHelper(collection, bufferSize); 

  // leading patterns
  helper.TryGetNext(0, out var element0) && ...

  // slice pattern; either:
  helper.SkipToEnd() // always succeeds
  helper.TrySkipToEnd(minLength)
  helper.TrySkipToEnd(maxLength)
  helper.TrySkipToEnd(minLength, maxLength)

  // trailing patterns
  helper.TryGetLast(2, out var hatElement2) && ..

  // length pattern; either:
  helper.TryGetCount(out var count)
  helper.TryGetCount(minLength, out var count)
  helper.TryGetCount(maxLength, out var count)
  helper.TryGetCount(minLength, maxLength, out var count)
}

For TryGetCount, we could possibly optimize a length pattern without binding e.g. [<N] to not iterate past the required length.

@jcouv
Copy link
Member Author

jcouv commented Mar 23, 2021

For TryGetCount, we could possibly optimize a length pattern without binding e.g. [<N] to not iterate past the required length.

I think that's going to be difficult. Patterns currently rely on having a ready value as input.

// slice pattern; ...
helper.SkipToEnd() // always succeeds

I've made a small change along those lines. A slice pattern with some following ending patterns can call and check Count() once. That way we don't need to do redundant count checks on each ending patterns.

class ListPatternHelper
{
// Notes:
// We could inline this logic to avoid creating a new type and to handle the pattern-based enumeration scenarios.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a pretty serious issue. So far, I tried to not depend on the buffer type for iteration exactly because of it.
This would be useful to reduce code size, but we should design around pattern-based case if we want to support it.


A *slice_pattern* is compatible with any type that is *countable* as well as *sliceable* - it has an accessible indexer that takes a `Range` argument or otherwise an accessible `Slice` method that takes two `int` arguments. If both are present, the former is preferred.
A *slice_pattern* is compatible with any type that is *countable* as well as *sliceable* — it has an accessible indexer that takes a `Range` argument or otherwise an accessible `Slice` method that takes two `int` arguments. If both are present, the former is preferred.
A *slice_pattern* without a sub_pattern is also compatible with any type that is *enumerable*.

This comment was marked as resolved.

This comment was marked as resolved.

If the collection does not produce enough elements to get a value corresponding to a starting pattern, the match fails. So the *constant_pattern* `3` in `{ 1, 2, 3, .. }` doesn't match when the collection has fewer than 3 elements.
Patterns at the end of the *list_pattern* (that are following the `..` *slice_pattern* if one is present) are matched against the elements produced at the end of the enumeration.
If the collection does not produce enough elements to get values corresponding to the ending patterns, the *splice_pattern* does not match. So the *splice_pattern* in `{ 1, .., 3 }` doesn't match when the collection has fewer than 2 elements.
A *list_pattern* without a *splice_pattern* only matches if the number of elements produced by complete enumeration and the number of patterns are equals. So `{ _, _, _ }` only matches when the collection produces exactly 3 elements.

This comment was marked as resolved.

> **Open question**: Confirm that async enumerables are out-of-scope.
> **Open question**: Confirm that slice patterns with a sub_pattern (such as `..var x`) are out-of-scope.

Although a helper type is not necessary, it helps simplify and illustrate the logic.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gather the following is not the rigorous codegen spec and we may make changes as we go through the impl?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right.

This rough codegen was mostly to convince myself that it would fit in the DAG binding design (evaluation steps, test steps, ...). Overall I expect it will fit okay.

The parts I'm not sure yet:

  • how do we account for evaluation steps returning a value or not? (in contrast, property accesses always succeed)
  • who is responsible for the end-of-enumeration count check (when exiting the list-pattern, like { 1, 2 })?
  • can we offer enough guarantees in the DAG binding that we don't need to cache starting values at all? (those values would get cached into temps if they are enumeratoed in the right order)
  • is there enough helper logic that we could extract to a BCL type?
  • how do we keep track of enumerators to dispose?

Copy link
Contributor

@alrz alrz Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how do we account for evaluation steps returning a value or not?

We either use an evaluation followed by a standard test, or a dedicated test node. As long as these won't affect subsumption checking, we can do away with simple eval nodes. In the prototype, I tried the first approach initially, but with trailing patterns, had to introduce a test node to disallow MoveNext after a slice which can happen in multi-arm matches. (This is the key to not requiring a buffer for starting elements, see the example above)

who is responsible for the end-of-enumeration count check

If the enumerator has a length (e.g. via TryGetNonEnumeratedCount), we check that first for an early failure, otherwise we only count if there's a length pattern. This happens right after we enumerate the sequence to the end. While we're doing it, we don't want to exceed the maxLength (if specified) and after that, we want to ensure we've reached the minLength (either inferred or specified). This covers the "early failure" that was discussed in the last LDM.

Note that a simple helper.Count() is P always enumerate the sequence to the end which is not what we want, therefore we need to compute the actual value set that we're testing for. Since we operate on a range of values, something like [1 or 3] causes incorrect results and should be gated.

can we offer enough guarantees in the DAG binding that we don't need to cache starting values at all? (those values would get cached into temps if they are enumerated in the right order)

That is correct. Starting patterns won't need any kind of buffer since we have a temp per each.

is there enough helper logic that we could extract to a BCL type?

I think if we use a standard buffer type we can indeed propose to it BCL. Currently I used a generic fixed-size stack with no additional logic.

how do we keep track of enumerators to dispose?

Since we can't "join" leaf nodes in a DAG, we just wrap the "rest" of the lowering in a try/finally, starting at each GetEnumerator. As a consequence enumerators might stack up. For instance, in { enum1: {0}, enum2: {0} }, both enumerators won't get disposed until after the whole pattern is executed.

proposals/list-patterns.md Outdated Show resolved Hide resolved
proposals/list-patterns.md Outdated Show resolved Hide resolved
If the collection does not produce enough elements to get values corresponding to the ending patterns, the *splice_pattern* does not match. So the *splice_pattern* in `{ 1, .., 3 }` doesn't match when the collection has fewer than 2 elements.
A *list_pattern* without a *splice_pattern* only matches if the number of elements produced by complete enumeration and the number of patterns are equals. So `{ _, _, _ }` only matches when the collection produces exactly 3 elements.

Note that those implicit checks for number of elements in the collection are unaffected by the collection type being *countable*. So `{ _, _, _ }` will not make use of `Length` or `Count` even if one is available.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if the type is countable we can just use it e.g. if we have Length but not an indexer, we match Length while enumerating for elements.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was that if we're enumerating to the end anyways, we might as well use the enumerated count that we've accumulated for the check at the end of the list-pattern.
But it's true that we could omit tailing discard patterns. So { 1, _, _ } could be "check first element, check Count == 3".

On the other hand, imagine that the Count property needs to enumerate again from the start.

I'll add an open issue.

Copy link
Contributor

@alrz alrz Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"check first element, check Count == 3".

We always check Count first if it's available without enumerating, subsequently MoveNext() && Current is 1 && MoveNext() && MoveNext() && !MoveNext() will be emitted to match elements which also tests for the length in itself.

That pattern is equivalent to [3] { 1, .. } but the codegen would be different as we generate a loop instead to test the length in which case we won't enumerate past the 4th element.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going to emit 4 MoveNext evaluations and enumerate completely, then there is no need to also call Count/Length. We don't need to check the count twice.

Copy link
Contributor

@alrz alrz Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We dont need to do it but if we can get count without enumerating we'll check it first to fail as early as possible if it doesn't match. This is from last LDM.

For example, the runtime just approved a new API for TryGetNonEnumeratedCount, and in order to make the pattern fast we could attempt to use it, then fall back to a state-machine-based approach if the collection must be iterated. This would give us the best of both worlds: If the enumerable is actually backed by a concrete list type, we don't need to do any enumeration of the enumerable to check the length pattern. If it's not, we can fall back to the state machine, which can do a more efficient enumeration while checking subpatterns than we could expose as an API from the BCL.

For the state machine fallback, we want to be as efficient as possible. This means not enumerating twice, and bailing out as soon as possible. So, the pattern enumerable [< 6] { 1, 2, 3, .., 10 } can immediately return false if it gets to more than 6 elements, or if any of the first 3 elements don't match the supplied patterns.

https:/dotnet/csharplang/blob/master/meetings/2021/LDM-2021-02-03.md#list-patterns-on-ienumerable

Copy link
Member Author

@jcouv jcouv Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I'm saying is that when there is a count check for the closing brace } of the list-pattern on an enumerable, that should use the enumerated count, since we'll have enumerated anyways. Similarly, the check that occurs to confirm that we had at least 3 elements before applying the 3 would use the enumeration.

I think that in your framing, all I'm saying is that before we check whether pattern 3 matches, we must have had 3 successful MoveNext() calls and that at the closing brace } we check that !MoveNext(). Those checks just rely on the enumerator, there was no need to call Count on a countable type.

If the enumerable is actually backed by a concrete list type, we don't need to do any enumeration of the enumerable to check the length pattern.

Yes, for the length-pattern (such as [<6]) it is fine to introduce a third concept of count, a "try-count", or extend the concept of the Count API, but that's orthogonal to what I'm saying about the list-pattern.
Note that the { 1, 2, 3, .., 10 } does not have an implicit end-of-list-pattern count, since it has a ... But it does have implicit checks that we could get at least 4 elements before we apply the pattern 10.

I hope that makes sense.
By the way, if you're already down the implementation path and feel there is a more natural way to spec this, feel free to update this doc. I'll merge as soon as I get a sign-off so you can stack a PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, one more point of clarification.
This (Note that those implicit checks for number of elements in the collection are unaffected by the collection type being *countable*. So { _, _, _ }will not make use ofLengthorCount even if one is available.) is merely calling out a consequence of what is stated in the preceding paragraph:

If the collection does not produce enough elements to get a value corresponding to a starting pattern, the match fails. [...]

If the collection does not produce enough elements to get values corresponding to the ending patterns, the slice_pattern does not match. [...]

A list_pattern without a slice_pattern only matches if the number of elements produced by complete enumeration and the number of patterns are equals. [...]

{ 1 } => /* here */ ...,
_ => /* here */ ...,
};
/* here too, with a spilled try/finally around the switch expression */
Copy link
Contributor

@alrz alrz Mar 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't quite clear to me. DAG lowering happens in its own block, so we only need one try/finally per enumerator, (provided we have a single node per each after simplification), from there we emit jumps to the target code section.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I'm not sure how to best represent this. What I'm trying to illustrate is that we dispose the enumerators as early as possible and in every case (even if an exception is thrown somewhere in a pattern evaluation).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I'm trying to illustrate is that we dispose the enumerators as early as possible and in every case

As I explained above, the earliest we could do that is at the end of the DAG lowering. At least that's what I could think of.

@alrz
Copy link
Contributor

alrz commented Mar 24, 2021

To facilitate early failures, we calculate max/min length based on the pattern itself and the length pattern so that we can check as we iterate. Therefore, we require this set to be contiguous (e.g. [1 or 3] is an error on enumerables). I think we should mention that in the spec.

@jcouv
Copy link
Member Author

jcouv commented Mar 24, 2021

we require this set to be contiguous (e.g. [1 or 3] is an error on enumerables

Two concerns:

  1. The evaluation model for patterns is "get a value" followed by "check whether the value matches the pattern". But what you're proposing is that we have special understanding of some patterns inside of length-patterns so that we can check before we have the (final) value.
  2. From a language perspective, why place such a restriction? The [1 or 3] pattern seems fine to me.

Maybe we could treat early termination as a compiler optimization (when possible) rather than something the language defines.

I'll add that as an open question.

@jcouv
Copy link
Member Author

jcouv commented Mar 25, 2021

@dotnet/roslyn-compiler for review.

{
count = 0;
enumerator = enumerable.GetEnumerator();
startBuffer = startPatternsCount == 0 ? null : new ElementType[startPatternsCount];
Copy link
Member

@333fred 333fred Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to consider renting arrays for this. #Resolved

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And having an IDisposable implementation for returning them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Since we'll need to track disposal of enumerator we have some options here.
Stackalloc might be an option too if we inline this logic (then the state is just locals to the expression).


In reply to: 601740384 [](ancestors = 601740384)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we'll want to use stackalloc (unconditionally, anyway). This might be a place where we'd like a runtime helper like dotnet/runtime#25423.

@{
var helper = new ListPatternHelper(collection, 0, 0);

helper.Count() == 3
Copy link
Member

@333fred 333fred Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not feel good. We probably want to take a parameter of some kind an avoid enumerating the whole enumerable if we pass an upper limit, 3 in this case. #Resolved

Copy link
Member Author

@jcouv jcouv Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current design of patterns in general, we get a value then we check the value. I've added an open question on length-pattern cutting enumerations short (checking non-final value in some way), following Ali's suggestion. #Resolved


helper.TryGetStartElement(index: 0, out var element0) && element0 is 0 &&
helper.TryGetStartElement(1, out var element1) && element1 is 1 &&
helper.Count() == 2
Copy link
Member

@333fred 333fred Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about enumerable length. #Pending

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. We only need to check we're at the end of the enumeration.

@{
var helper = new ListPatternHelper(collection, 0, 2);

helper.Count() >= 2 && // `..` with 2 ending patterns
Copy link
Member

@333fred 333fred Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one isn't so bad, since we need enumerate the whole thing anyway. #Resolved

Copy link
Member

@333fred 333fred left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM (commit 11). We'll keep whacking on that enumeration question in the implementation.

@jcouv jcouv merged commit 47143d1 into dotnet:main Mar 25, 2021
@jcouv jcouv deleted the enumerable-pattern branch March 25, 2021 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants