Closed smoothdeveloper closed 2 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".
@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).
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.
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 ;)
@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.
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.
Making :?
available as an operator rather than a hardcoded global would be nice too, so you could use (:?)
as part of an expression etc.
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.
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
Wouldn't a function for the :? operator be named typeOf? At least, that's how I read it.
@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.
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"
| _ -> "?"
@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.
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.
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.
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})
.
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
Now that we have Anonymous typed-tagged union RFC, this suggestion would be a complement to that feature.
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 😥
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
I propose we enable usual deconstruction syntax in
:?
matches: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.
Approximate ways of approaching this problem in F#:
exception
types, as done in the compiler itself https://github.com/dotnet/fsharp/blob/70883fb00a867f6e81aa9c7cecdd9212c4e78d93/src/fsharp/ErrorLogger.fs#L400-L407WrappedError
to a single case DU in the compiler code and reimplement the match with the same cases evaluated in same orderthe equivalent code right now:
Pros and Cons
The advantages of making this adjustment to F# are:
exception
types to achieve similar aimupdate
function scenarios when handling heterogeneous commands instead of nesting DUs (use with caution)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: