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

Proposal: User-defined positional patterns #4131

Open
1 of 4 tasks
alrz opened this issue Nov 12, 2020 · 7 comments
Open
1 of 4 tasks

Proposal: User-defined positional patterns #4131

alrz opened this issue Nov 12, 2020 · 7 comments

Comments

@alrz
Copy link
Contributor

alrz commented Nov 12, 2020

User-defined positional patterns

  • Proposed
  • Prototype: Not Started
  • Implementation: Not Started
  • Specification: Not Started

Summary

Defining a named positional pattern which is not bound to a type check.

Exploring a different approach than the one originally presented in #1047 which suggests to relax Deconstruct to return bool.

This proposal does not conflict with bool-returning Deconstruct methods, i.e. both can exist at the same time, but this enables much more flexibility in this space as these patterns have a name of their own rather than just using the type's name.

Motivation

Currently, as soon as you want to run some custom logic as part of a match you need to fallback to using expressions and the when clause. This feature would enable custom logic to run as part of the match itself with the advantage of nesting in recursive patterns.

Detailed design

No new syntax is required. We just use the recursive pattern syntax and bind the name to a method if no type is found.

For instance:

public static bool BitPatternMatch(this int value. int mask) => (value & mask) == mask;

case BitPatternMatch(0xb_1001):

In a recursive pattern like that, when the type lookup for BitPatternMatch fails, we'll look for an instance method or an extension method on the target value, e.g. value.BitPatternMatch(0xb_1001) is evaluated.

To prevent accidental binding, we may require an attribute on those methods or use a name convention like XXXPattern similar to XXXAttribute.

Each argument can be a:

  • (a) constant pattern,
    • (1) If the corresponding parameter is not out/ref we'll pass the constant value as the argument.
    • (2) If the corresponding parameter is out we'll match it against the result.
    • (3) otherwise, an error is produced.
  • or (b) some other pattern.
    • (1) If the corresponding parameter is out we'll match it against the result.
    • (2) otherwise, an error is produced.

This is mostly the same as how we handle Deconstruct, except for the case (a-1).

Some other examples:

// Parsing patterns
static bool Integer(this string s, out int value) => int.TryParse(s, out value);

case Integer(var i):


// Recursive patterns (literally)
static bool Symmetrical<T>(this Span<T> s)
    => s switch { [] or [_] => true, [var left, ..Symmetrical(), var right] when left == right => true, _ => false };


// Parametric patterns
static bool RegexMatch(this string s, string pattern, out string[] captures) => { ... }

static bool Insensitive(this string s, string str) => s.Equals(str, StringComparison.InvariantCultureIgnoreCase);

case RegexMatch(@"(\w+)Controller", [var controllerName]):
case Insensitive("str"):

We require the return type to be either:

  • (a) bool - in which case no variable designator is permitted and it can fail, or
  • (b) void - in which case no variable designator is permitted and it always succeeds, or
  • (c) A nullable type T? where T is either a reference type or value type.

In case (c), the stripped return type would be the narrowed type of the pattern, and also the type of the pattern variable, if any.

For example, Integer pattern above could be defined differently to use pattern variables:

static int? Integer(this string s) => int.TryParse(s, out var value) ? value : null;

case Integer() i: // i is int
case Integer() and var i: // i is int

Whether or not we allow () to be omitted is an open question.

Companion features

out patterns

We may want to extract a specific pattern as a method:

static bool MethodCall(this Expression expr, out string name, out Expression[] arguments)
    => expr is MethodCallExpression { Method: { Name: out name }, Arguments: out arguments };

case MethodCall("IndexOf", [var argument])

We used out identifier syntax to express assignment to an existing variable. The variable type needs to match the target.

The variable is always definitely-assigned after the match, regardless if it's failed in which case we assign default to it.

We can possibly require these variables to be unassigned out parameters to prevent any other misusage.

as expressions with a pattern instead of a type

Since the return type of an extension pattern is the narrowed type, we can provide a shorthand for when we want to bubble up the narrowed type from an inner match:

static MethodCallExpression MethodCall(..)
    => expr is MethodCallExpression { .. } p ? p : null;
    
static MethodCallExpression MethodCall(..)
    => expr as MethodCallExpression { .. };

case MethodCall(..) m: // m is MethodCallExpression

This is compatible with the existing as expression, as it returns null for when the type check is failed.

As a consequence, we will have x as var v as some kind of declaration expression.

With pattern semantics, x as int would be now allowed but it'll be equivalent to x as int? as it requires to return a nullable T.

Further considerations

Beyond out parameters, we can also accept patterns in the position of Func<T, U?> parameters.

Just like top-level user-defined patterns, the return type must be bool or a nullable type which will be used as the narrowed type.

All this together would enable some interesting scenarios within a pattern match:

static TNarrowed[]? Many<T, TNarrowed>(this T[] array, Func<T, TNarrowed?> patternFunc)

// Match an array of points which start with origin and continues with `(1, 1)` points.
if (objectArray is [Point(0, 0) p, ..Many(Point(1, 1)) pointArray]) 

if (objectArray is [Point(0, 0) p, ..var rest] && rest.Many(p => p is Point(1, 1) q ? q : null) is Point[] pointArray) 

No pattern variable is permitted inside those that are passed to a delegate.

Design meetings

https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-02-16.md#user-defined-positional-patterns

@alrz
Copy link
Contributor Author

alrz commented Nov 12, 2020

I don't expect this feature to get much attention right now (triaged into backlog). But since variable reassignment was brought up in the context of other features, I thought this could add some background for future reference. cc @MadsTorgersen

@Rekkonnect
Copy link
Contributor

as expressions with a pattern instead of a type

A practical application I consider for this feature is passing the value to a field, property or as an argument for another function call, without explicitly caring whether the result is null, something like:

A a = ...;
var extended = a as B { Property: < 0 };
AnotherCall(extended, ...);

In that case, I reckon this proposal deserves to be split into a new issue on its own.

One other practical improvement I could see here is enabling to use existing variables as the variable where the assigned result will be, which is another proposal. For example,

A a = ...;
B b = null;
if (other)
{
    if (a is B { Property: < 0 } b)
    {
        // something
    }
}

Currently, the only way for this to happen is either creating another temporary variable, or using the proposed as with the specified pattern.

@HaloFour
Copy link
Contributor

HaloFour commented Jan 27, 2022

@alfasgd

Pattern matching with as or using existing variables for variable patterns sound like they both should be separate issues, they're not related to user-defined patterns.

@sab39
Copy link

sab39 commented Jan 28, 2022

In the much earlier proposal #277 it was suggested to use the Is prefix on methods to identify candidate patterns, eg

public static bool IsInteger(this string str) => int.TryParse(str, out _);

if (myStr is Integer()) ...

That was a good idea (IMO at least!) and it would be nice if it at least doesn't get forgotten entirely in a new proposal. The advantage would be that the new syntax would automatically recognize a lot of such methods in existing code, but wouldn't erroneously assume that every bool-returning method must be a pattern. It would also avoid ugliness like str is IsInteger() when those methods do exist.

For case (c) in this proposal, where the return type is T?, the prefix As could be recognized instead.

I'm not sure what the use case is for void-returning patterns that always succeed so I'm not sure whether there's a prefix that would make sense for recognizing them. The idea of every void-returning method and extension method showing up as potential patterns in Intellisense seems awkward at best.

Another question if the Is prefix idea is adopted: would it make sense to support boolean properties with the Is prefix as well? if (myGuiControl is Enabled) etc...

@alrz
Copy link
Contributor Author

alrz commented Feb 1, 2022

@alfasgd @HaloFour

Pattern matching with as or using existing variables for variable patterns sound like they both should be separate issues, they're not related to user-defined patterns.

This was initially meant to be a context around those features as I haven't considered any usage outside the scenarios mentioned. Should eventually moved/removed once the design is fleshed out.

@sab39

The advantage would be that the new syntax would automatically recognize a lot of such methods in existing code,

A variation of this was mentioned but if anything, the goal is to avoid too many existing methods to be applicable here, addressing your next point:

The idea of every void-returning method and extension method showing up as potential patterns in Intellisense seems awkward

Either using an attribute or a naming convention. Nevertheless, it might be a good idea to cover common cases such as Try* methods.

return s switch {
  [.. double.TryParse(var v), 'm'] => TimeSpan.FromMinutes(v),
  // ..
}

(That’s not supported in the current iteration of the proposal.)

@sab39
Copy link

sab39 commented Feb 1, 2022

I'd venture to guess that catching bool-returning IsFoo() and Foo?-returning AsFoo() would get a lot of useful common cases with very few false positives. I can't really think of any naming convention that would catch TryParse, especially as that's a method on a whole other unrelated type.

I also think there's a definite ergonomic benefit in being able to support x is Foo(), x switch { ... case Foo(): ... } and x.IsFoo() with a single implementation; the fact that many such implementations already exist is icing on the cake. It'd suck to have to choose between writing x is IsFoo() or having to create a boolean x.Foo() method with a name that doesn't make sense in any context other than pattern matching.

@jcouv
Copy link
Member

jcouv commented Feb 12, 2022

@333fred You may want to use the existing championed issue for "user-defined positional patterns", or close it. I assigned to you and you can figure how you want to do it.

@333fred 333fred added this to the Backlog milestone Feb 16, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants