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

Enable deconstruction syntax in :? patterns #830

Closed
3 of 5 tasks
smoothdeveloper opened this issue Jan 24, 2020 · 20 comments
Closed
3 of 5 tasks

Enable deconstruction syntax in :? patterns #830

smoothdeveloper opened this issue Jan 24, 2020 · 20 comments

Comments

@smoothdeveloper
Copy link
Contributor

smoothdeveloper commented Jan 24, 2020

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:

  • pattern matching becomes useful in type test matches
  • a syntax making the language on par with type testing pattern matching scenarios in dynamic languages and C#
  • not pushing people towards misuse of exception types to achieve similar aim
  • possibly useful in some elmish update function scenarios when handling heterogeneous commands instead of nesting DUs (use with caution)

The disadvantages of making this adjustment to F# are:

  • people who disaprove type test patterns won't like it
  • exceptions aren't an exceptional case anymore in pattern match syntax

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:

  • This is not a breaking change to the F# language design
  • I or my company would be willing to help implement and/or test this
@charlesroddie
Copy link

Similar to #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
Copy link
Contributor Author

@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
Copy link

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
Copy link

abelbraaksma commented Jan 26, 2020

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
Copy link
Contributor Author

smoothdeveloper commented Jan 26, 2020

@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
Copy link
Contributor Author

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
Copy link
Member

7sharp9 commented Feb 11, 2020

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
Copy link
Member

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
Copy link

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
Copy link

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

@abelbraaksma
Copy link

@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
Copy link

glchapman commented Feb 22, 2020

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
Copy link

abelbraaksma commented Feb 23, 2020

@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
Copy link

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
Copy link

codingedgar commented Aug 26, 2020

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
Copy link

codingedgar commented Aug 26, 2020

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
Copy link

codingedgar commented Aug 26, 2020

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
#751 (comment)

#751, in general, is very related

@Swoorup
Copy link

Swoorup commented Jan 28, 2021

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

@xperiandri
Copy link

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
Copy link
Collaborator

dsyme commented Jun 16, 2022

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

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