fsharp / fslang-suggestions

The place to make suggestions, discuss and vote on F# language and core library features
344 stars 21 forks source link

Extend parameterized partial and single-case active patterns to _complete, multi-case_ active patterns #1213

Open abelbraaksma opened 1 year ago

abelbraaksma commented 1 year ago

This question came up a few times recently on Slack, which suggests to me there's a real-world use case for this scenario. This active SO question is another cry for this feature.

I propose we extend support of parameterized active patterns to include total active patterns, where the pattern has multiple choices.

There's currently no existing way of doing this in F#, such patterns are explicitly forbidden.

Example

let (|Ge|Lt|) x y =
   if y >= x then Ge
   else Lt

let f() = 
    match 42 with 
    | Ge 12 -> "greater or equal"
    | Lt 12 -> "smaller"

Syntax

As the above example shows, one might think that writing Lt 11 would make the pattern match non-total. This is correct, and honestly I don't have a good way around it. Since a total active pattern is considered a single block, perhaps the rule could be that only the first item should have the argument. After all, that's how such patterns are compiled currently:

Improved example

let (|Ge|Lt|) x y =
   if y >= x then Ge
   else Lt

let f() = 
    match 42 with 
    | Ge 12 -> "greater or equal"
    | Lt -> "smaller"

Pros and Cons

The advantages of making this adjustment to F# are

The disadvantages of making this adjustment to F# are

Extra information

Estimated cost (XS, S, M, L, XL, XXL): S

Related suggestions: I couldn't find any, though I'd be surprised if this wasn't suggested before.

Affidavit (please submit!)

Please tick this by placing a cross in the box:

Please tick all that apply:

For Readers

If you would like to see this issue implemented, please click the :+1: emoji on this issue. These counts are used to generally order the suggestions by engagement.

kerams commented 1 year ago

Wouldn't it be impossible to perform an exhaustiveness check? I don't like the improved sample, because it's confusing that it takes no parameter and basically acts as a catch all branch, which defeats the point of the match in some ways, that is at first glance.

You can do this, even if it is more verbose and has overhead (unless we inline it all):

let comp x y funGe funLt =
   if y >= x then funGe ()
   else funLt ()

let f() =
   comp 42 12 (fun () -> "greater or equal") (fun () -> "smaller")
abelbraaksma commented 1 year ago

Well, the example is just that, an example. Certainly there are other ways of writing that particular code. What I’m looking for is arguments to a total (exhaustive) pattern.

Maybe we should drop exhaustiveness, and allow for multi case partial patterns (which, in single case partial pattern scenarios, already allow for arguments, but that leads to repetitive code and suboptimal generated code).

Note that in the text above I do touch on a way to make the exhaustiveness check work with this sort of pattern.

kerams commented 1 year ago

Yeah, I suppose it could be exhaustive if it's just the first case that takes the pattern. However, then you can a DU and move the parameter up:

type GeLt =
    | Ge
    | Lt

let comp x y =
   if y >= x then Ge
   else Lt

let f() = 
    match comp 42 12 with 
    | Ge -> "greater or equal"
    | Lt -> "smaller"

That's still not an active pattern but has the same ergonomics.

allow for multi case partial patterns

That crossed my mind too, you have to keep executing the active pattern body for each consecutive branch with a different parameter though:

let (|G|L|_|) x y =
   if y > x then
       Some G
   elif y < x then
       Some L
   else
       None

let f() = 
    match 42 with 
    | G 500 -> "greater than 500"
    | G 200 -> "greater than 200"
    | G 50 -> "greater than 50"
    | L 12-> "less than 12"
    | _ -> "something else"

This can be done today with 2 active patterns. What I am trying to say is that not everything has to be solved by an active pattern if a similarly straightforward alternative exists.

Maaaaybe this could work?

let (|Ge|Lt|) x y =
   if y >= x then Ge
   else Lt

let f() = 
    match 42 with
    | Ge 12 -> "greater or equal 12"
    | Lt 0 -> "less than 0"
    | Ge 5 -> "greater or equal 5"
    | Lt 5 -> "less than 5"

As long as the the very last sequence of branches goes through every active pattern case with the same parameter, the compiler could conclude the match is exhaustive.

dsyme commented 1 year ago

We always intended to do this, but the reliance on a choice type encoding held us back, plus the general complexity compared to number of use cases. The metadata allows for it in principle.

dsyme commented 1 year ago

I guess that qualifies as an approve in principle

abelbraaksma commented 1 year ago

Tx @dsyme! I’ll work on an RFC. I think the main thing to decide upon will be whether a single argument should be provided (that is, only the first of the cases), or we go the more advanced route and compiler will consider same arguments in the ‘totality check’ (that is: each case with same arg translates to single execution of active pattern).

If then different args are used, unless the totality check concludes that at least once all cases with same arg were used, the normal warning for incomplete pattern matches is raised.

This (more complex) approach has my preference as it gives more freedom to consumers, but may lead to significantly more work to implement.

PS: I’m not sure if the expression forming the argument in the match cases could potentially be written with a side effect. If so, the totality check should only apply to constants (as args) and/or idents.

Edit: yes, this is currently possible, ie, the argument could be a function item, which in turn could execute a side effect. However, it may not matter if the active pattern code is executed once. So, in short, totality check is still possible.

abelbraaksma commented 1 year ago

Just happened upon my own bug report again, lol. Apparently I try to use this more often than I care to admit. Hmm, time for an RFC, I guess.

Anyway, I found a clumsy workaround. Apparently, you can use active patterns with a variable in the match clause. It does require both of the x's to be the same, i.e. match x with and MatchLower x ... must both be there.

This seems like a bug (or a feature?) and something to keep in mind that this might be used in the wild:

[<AutoOpen>]
module StringPatterns =
    /// Matches a string by its lowercase invariant version against the given constant string
    let (|MatchLower|_|) value matchValue =
        if String.trimToLower value = matchValue then
            Some matchValue
        else
            None

    /// Matches a string by its uppercase invariant version against the given constant string
    let (|MatchUpper|_|) value matchValue =
        if String.trimToUpper value = matchValue then
            Some matchValue
        else
            None

    let compareString x =
        match x with
        | MatchLower x "foo" -> true
        | _ -> false

Funny that this is always true??

let compareString x y =
    match x with
    | MatchLower x y -> true
    | _ -> false