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

Open
smoothdeveloper opened this issue Jan 24, 2020 · 14 comments
Open

Enable deconstruction syntax in :? patterns #830

smoothdeveloper opened this issue Jan 24, 2020 · 14 comments

Comments

@smoothdeveloper
Copy link
Contributor

@smoothdeveloper 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

This comment has been minimized.

Copy link

@charlesroddie charlesroddie commented Jan 26, 2020

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".

@Happypig375 Happypig375 mentioned this issue Jan 26, 2020
5 of 5 tasks complete
@smoothdeveloper

This comment has been minimized.

Copy link
Contributor Author

@smoothdeveloper smoothdeveloper commented Jan 26, 2020

@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

This comment has been minimized.

Copy link

@abelbraaksma abelbraaksma commented Jan 26, 2020

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

This comment has been minimized.

Copy link

@abelbraaksma 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

This comment has been minimized.

Copy link
Contributor Author

@smoothdeveloper 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

This comment has been minimized.

Copy link
Contributor Author

@smoothdeveloper smoothdeveloper commented Jan 26, 2020

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

This comment has been minimized.

Copy link
Member

@7sharp9 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

This comment has been minimized.

Copy link
Member

@cartermp cartermp 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.

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

This comment has been minimized.

Copy link

@NinoFloris NinoFloris commented Feb 12, 2020

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

This comment has been minimized.

Copy link

@heronbpv heronbpv commented Feb 12, 2020

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

@abelbraaksma

This comment has been minimized.

Copy link

@abelbraaksma abelbraaksma commented Feb 13, 2020

@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

This comment has been minimized.

Copy link

@glchapman 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

This comment has been minimized.

Copy link

@abelbraaksma 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

This comment has been minimized.

Copy link

@glchapman glchapman commented Feb 23, 2020

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
8 participants
You can’t perform that action at this time.