fsharp / fslang-suggestions

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

Enable deconstruction syntax in :? patterns #830

Closed smoothdeveloper closed 2 years ago

smoothdeveloper commented 4 years ago

I propose we enable usual deconstruction syntax in :? matches:

type DU = A of int | B of string
type Rec = {a: int; b: string }
exception Ex of int
let f =
  function
  | :? A(1) -> "a1"
  | :? A(b) -> sprintf "other a%i" b
  | :? {a=1;b=b} -> "rec1"
  | :? Ex(1) -> "ex1"
  | _ -> "?"

There is no exhaustivity check, but the existing requirement for a fallback case as a warning.

A warning (or an error) could be added if type tests among several cases of same DU or record types are separated with other non related cases.

function
| :? A(1) -> "a1"
| :? {a=1;b=b} -> "rec1"
| :? A(b) (*warning here*) -> sprintf "other a%i" b
| _ -> "?"

FSXXXX the type tested pattern match on type DU should be defined in direct sequence of existing cases.

Approximate ways of approaching this problem in F#:

the equivalent code right now:

 let inline fallback () = "?"
 function
  | :? DU as du ->
     match du with 
     | A(1) -> "a1"
     | A(b) -> sprintf "other a%i" b
     | _ -> fallback ()
  | :? Rec as record ->
    match record with
    | {a=1;b=b} -> "rec1"
    | _ -> fallback ()
  | :? Ex as ex -> 
    match ex with 
    | Ex(1) -> "ex1"
    | _ -> fallback ()
  | _ -> fallback ()

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): M

Affidavit

Please tick this by placing a cross in the box:

Please tick all that apply:

charlesroddie commented 4 years ago

Similar to https://github.com/fsharp/fslang-suggestions/issues/828: I think it's bad, outside of the compiler, to make type deconstruction more convenient.

If you are doing this it's clear that the type system has failed. Or, in the case of the compiler, is under construction.

@smoothdeveloper can you think of any example outside the compiler when it would be good to write code using this syntax? The one case you suggested (elmish) had a "use with caution" warning and I would strengthen that to "do not use".

smoothdeveloper commented 4 years ago

@charlesroddie elmish and also actor code are valid use cases, I wrote "use with caution" in sense of "don't expect exhaustivity check from compiler".

I don't think it should be forbidden as the syntax makes it clear (:? on each match) that exhaustivity checks are out, the suggestion is just asking to be able to destructure / match on F# types at the same time as the usual type test cases, enabling a design choice which is, right now restricted to exception types.

Without the feature, it is practically impossible to have a pattern match that would still fall through the remaining cases but to resort on active patterns that aren't going to be reused elsewhere in the code and may lead to more obfuscated code for the purpose at hand.

An alternative syntax would be to force using / reusing when keyword while retaining the requirement for :? to be followed by a type name:

let f =
  function
  | :? DU when A(1) -> "a1"
  | :? DU when A(b) -> sprintf "other a%i" b
  | :? Rec when {a=1;b=b} -> "rec1"
  | :? Ex when Ex(1) -> "ex1"
  | _ -> "?"

Without supporting this, a whole class of code that exist and is expressible in languages supporting more flexible pattern matching won't be translatable to F#, people will try to adopt F# coming from such languages and wonder why there is no escape hatch beside active pattern contortions (deviating those from their intent of handling non destructurable types).

abelbraaksma commented 4 years ago

What's wrong with matching on the type with as and then matching over the resulting type safe, now known DU in a nested match?

I'd argue in general, that when you need to match over types, and it isn't to match polymorphism, that there seems to be something wrong with the design of your code, as you essentially write code where you say 'this part is not type safe, let's try to find something we can use here'. Though admittedly, there are certainly cases where type tests are a necessary evil.

I do see (some) benefit in your proposal, as to less typing, but your post suggests that certain scenarios are now impossible or very hard to do. However, I fail to understand what is currently impossible (you say it's now restricted to exception types, I don't see that either). You write "it is practically impossible to have a pattern match that would still fall through", but if a type test fails, it won't fall through, it'll try the next type test,until it reaches the end.

I may be missing the obvious here, just trying to get a firm grasp of your proposal.

abelbraaksma commented 4 years ago

people will try to adopt F# coming from such languages and wonder why there is no escape hatch

The same could be said for a lot of features in F#. People coming from C# are used to implicit conversions. In F#, all of a sudden these don't exist anymore. It takes time to adopt to the new coding style. When I see students attempting F# they use casts everywhere, and complain about it being so cumbersome, until they start embracing DU's for a better fit, and finally enjoying the benefits of both type safety and better inference.

I'm not saying your suggestion is bad (I haven't understood it yet), but mimicking another language may not be the proper argument here ;)

smoothdeveloper commented 4 years ago

@abelbraaksma

but mimicking another language may not be the proper argument here ;)

The suggestion is not really about mimicking the other languages, but I wanted to put the notice that languages have more flexible/lenient pattern matching handling now, or will in nearish future; in few years, a majority of developers will have gained exposure to pattern matching and some constructs won't be translatable.

In my sample, say I'm only interested in A(1), and want the other cases to fallback to the remaining guards, I'm left with active pattern, or worse: factoring the wildcard case in a function, do nested match in a whole bunch of branches and call the fallback function in each fallback of each subbranch.

In those scenarios, nesting match is an option but it doesn't bring the clarity/simplicity, it works against the developer when what is wanted is to filter few cases per matchable types and have a common fallback case.

F# already has the constructs of the suggestion baked in, just restricted to exception types somehow, and lacking the cues.

Here is the same sample without the suggestion:

 let inline fallback () = "?"
 function
  | :? DU as du ->
     match du with 
     | A(1) -> "a1"
     | A(b) -> sprintf "other a%i" b
     | _ -> fallback ()
  | :? Rec as record ->
    match record with
    | {a=1;b=b} -> "rec1"
    | _ -> fallback ()
  | :? Ex as ex -> 
    match ex with 
    | Ex(1) -> "ex1"
    | _ -> fallback ()
  | _ -> fallback ()

Hope that explains the crux of it.

Regarding casts, no early returns, etc. there are idiomatic escape hatches.

smoothdeveloper commented 4 years ago

Added a potential warning/error that could happen when type tests among a same non polymorphic deconstructible type happen to be interleaved instead of done in a straight sequence.

Thanks for the feedback so far, I hope my explanations and samples clarify a bit.

7sharp9 commented 4 years ago

Making :? available as an operator rather than a hardcoded global would be nice too, so you could use (:?) as part of an expression etc.

cartermp commented 4 years ago

Making :? available as an operator rather than a hardcoded global would be nice too, so you could use (:?) as part of an expression etc.

Note that :?>/downcast are already available today, so it's not quite clear to me what :? as a regular operator would be (maybe returns an optional?)

As for the original suggestion, I think I do like it. Since the use of :? already requires a discard case for exhaustiveness checking, the scenario of only caring about specific things (e.g., only one case of a DU) makes sense.

NinoFloris commented 4 years ago

Isn't :?> a hardcoded global too, as are :> upcast downcast?

I'd say :? as function would return bool, leaving the refinement aspect out of it, though I wouldn't know how to call any of those with a type argument. I don't think this works in any way for operators (:?)<'T> x, accepting a System.Type argument is an option of course.

It seems better to have upcast and downcast be real functions that could be used like downcast<Foo> x, we'd still be missing a friendly named function for :? though.

/offtopic

heronbpv commented 4 years ago

Wouldn't a function for the :? operator be named typeOf? At least, that's how I read it.

abelbraaksma commented 4 years ago

@heronbpv it's more or less the equivalent of the is operator from C#. It tests whether a value is of a certain type. A function like isTypeOf might be more of the right name, I think.

glchapman commented 4 years ago

If the pattern(s) you want to match against allow the relevant types to be inferred, you can use a relatively generic active pattern. Here's the example function:

let (|Is|_|) (x: obj) : 'a option =
    match x with
    | :? 'a as a -> Some a
    | _ -> None

let f = function
    | Is (A(1)) -> "a1"
    | Is (A(b)) -> sprintf "othera%d" b
    | Is {a=1;b=b} -> "rec1"
    | Is (Ex(1)) -> "ex1"
    | _ -> "?"
abelbraaksma commented 4 years ago

@glchapman, that's a pretty nifty approach! (I deleted my prev comment, it had the wrong assumptions).

I just checked how it actually compiled. And the code is not too bad, it actually translates mostly to how matches translate. But since it is a partial active pattern, the compiler will not combine tests. I.e.:

If you had

let f = function
    | {a = 1; b = b } -> printfn "Record 1, b = %s" b
    | {a = 2; b = b } -> printfn "Record 2, b = %s" b
    | {a = 3; b = b } -> printfn "Record 3, b = %s" b

that'll end up as a single switch statement on the integer (three cases, but table lookup, so single assembler instruction), and a single grab of the variable. Whereas the following:

let f = function
    | Is {a = 1; b = b } -> printfn "Record 1, b = %s" b
    | Is {a = 2; b = b } -> printfn "Record 2, b = %s" b
    | Is {a = 3; b = b } -> printfn "Record 3, b = %s" b

will end up as three calls to Is, the return value tested, if Some, then its a value tested. So, worst case, 3x3 = 9 tests, whereas if the compiler could optimize it, it would be a combined type test (for all the same consecutively declared types) and one switch for the DU or Record, total 2 tests.

I'm not saying it is a bad idea, it isn't, I like it and never thought of using active patterns in that way. But still there's room for adding this to the :? syntax, I think.

glchapman commented 4 years ago

Yeah, it would be nice if the compiler was a little smarter with active patterns. Here's another possibility which seems to compile better, but requires some boilerplate UnionXX types:

[<Struct>]
type Union3<'a,'b,'c> = UA of a:'a | UB of b:'b | UC of c:'c | UU of u:obj with
    static member Create(x: obj) =
        match x with
        | :? 'a as a -> UA a
        | :? 'b as b -> UB b
        | :? 'c as c -> UC c
        | _ -> UU x

let f2 x =
    match Union3.Create(x) with
    | UA(A(1)) -> "a1"
    | UA(A(b)) -> sprintf "other a%d" b
    | UB({a=1; b=b}) -> "rec1"
    | UB({a=2; b=b}) -> "rec2"
    | UC(Ex(1)) -> "ex1"
    | _ -> "?"

FWIW, the proposed change would not be a high priority for me, but if something like it were to be made, I think it would be better to extend the & pattern so that patterns on the right could take advantage of an extended environment established by a successful match on the left. E.g.:

let f x =
    match box x with
    | :? DU & A(1) -> "a1"
   // ...

The A(1) on the right would be allowed because the type test on the left appropriately narrowed the target type.

codingedgar commented 4 years ago

Currently, I'm also having this issue! I post it on stack overlow as "inline Type Testing Pattern with Record Pattern":

I'll be more involved here than I was there, as you're looking for a more valid scenario:

In my case, I have an SDK that is wrapping an API, to avoid a humongous tree of DUs to represent each possible request, what I did was creating an interface ISDKRequest, so the SDK takes any valid ISDKRequest and process it:

type IGERequestV2 =
  { From: Agent
    ExternalID: System.Guid
    Props: ISDKRequest
  }

The SDK also returns a response, which again is a generic:

type IGEResponseV2 =
  { Request: IGERequestV2
    Response: HttpResponse }

And my sagas can decide what to do when toy see an SDKResponse:

let choose =
function
| { Response = { StatusCode = statusCode; Body = body };
    Request = { Props = (:? UserNode.Media.GET.Props as props)) } }
        when statusCode = 200 ->

     match props with
     ....

This way I make choices based on the request, as the response is just a generic HTTP Response.

Of course, today I nest another match props with but I find it weird I cannot pattern directly the props as it has been resolved already.

codingedgar commented 4 years ago

An alternative syntax would be to force using / reusing when keyword while retaining the requirement for :? to be followed by a type name:

let f =
  function
  | :? DU when A(1) -> "a1"
  | :? DU when A(b) -> sprintf "other a%i" b
  | :? Rec when {a=1;b=b} -> "rec1"
  | :? Ex when Ex(1) -> "ex1"
  | _ -> "?"

This syntax would be amazing, I asked the other day why the when clause doesn't narrow types, like TypeScrip Control Flow Based Type Analysis

I tried to do this a lot in the cases of nested options until I discovered that you can just match tuples.

However, one of the answers I got stated:

As far as I am aware, F# does not provide control-flow based type analysis. I think this would run counter to the design of F# as an expression-orientated language.

seeing the nested pattern syntax, and the tuple workaround for, and the "expression oriented" nature of the language, I say that I'd prefer the option of:

let f =
  function
  | :? Rec ({a=1;b=b}) -> "rec1"
  | _ -> "?"

This way the Type Test Pattern is indistinct of the normal Identifier Pattern which is what I'm looking for, treating subtypes as Identifiers (like it would do in a DU), and be able to match on the right expression ({ a = 1}).

codingedgar commented 4 years ago

seeing the nested pattern syntax, and the tuple workaround for, and the "expression oriented" nature of the language, I say that I'd prefer the option of:

let f =
  function
  | :? Rec ({a=1;b=b}) -> "rec1"
  | _ -> "?"

A related comment https://github.com/fsharp/fslang-suggestions/issues/751#issuecomment-507306437

751, in general, is very related

Swoorup commented 3 years ago

Now that we have Anonymous typed-tagged union RFC, this suggestion would be a complement to that feature.

xperiandri commented 2 years ago

That would reduce a lot of boilerplate and make use of true DU wider. As now you usually have to create DU with cases of a single record 😥

dsyme commented 2 years ago

We added patterns on the right of as in F# 6, so now you can do

type DU = A of int | B of string
type Rec = {a: int; b: string }
exception Ex of int
let f (x: obj) =
  match x with
  | :? DU as A(1) -> "a1"
  | :? DU as A(b) -> sprintf "other a%i" b
  | :? Rec as {a=1;b=b} -> "rec1"
  | :? exn as Ex(1) -> "ex1"
  | _ -> "?"

So this is good enough