fslang-suggestions
fslang-suggestions copied to clipboard
Object/property/field patterns
Trimmed down suggestion written by @dsyme: I propose we allow two additions - property/field patterns, and "object patterns".
Proposed syntax for property/field patterns:
match x with
| _.Property1 pattern -> ....
The pattern may be parenthesized, e.g.
match x with
| _.Elements([a;b;c]) -> ....
Property patterns can use nesting, so this is allowed:
match x with
| _.Property1.Property2 pat -> ...
Property patterns can resolve to fields - as supported in #506
Boolean property patterns may elide a true
pattern. (Will consider whether this also applies to other pattern elements)
match x with
| _.IsCold -> ...
Notes:
-
Property patterns depend on #506
-
Property patterns can not resolve to methods. Use an active pattern, it's what they're there for.
-
Property patterns can't resolve to indexers. Just use an active pattern, it's what they're there for. So not this:
match x with | _.[3] pat -> ...
Proposed syntax for object patterns:
match x with
| (Property1=pattern, Property2=pattern, Field3=pattern) -> ....
The type name can be given explicitly (if it doesn't already exist as a pattern discriminator):
match x with
| SomeObjectType(Property1=pattern, Property2=pattern, Field3=pattern) -> ....
Existing type-test patterns would be extended to allow object patterns:
let f3 (inp: obj) =
match inp with
| :? SubType1(X=3) -> 1
| :? SubType2(X=3, Y=4)-> 1
| _ -> 2
Existing type-test patterns would also be extended to non-object patterns such as unions and records:
type U = U of int * int
type R = { A: int; B: int }
let f3 (inp: obj) =
match inp with
| :? SubType1(X=3) -> 1
| :? { A = 1; B = 2 }-> 2
| :? U(a,b) -> 3
| _ -> 4
Notes
-
Object patterns can't use nesting of property names, so not
match x with | (Property1.Property2=pat, Property3=pat) -> ...
This is because the corresponding object creation syntax doesn't support nesting
-
Object patterns can't use indexers. This is because the corresponding object creation syntax doesn't support nesting
-
Object patterns can be used on records, despite the lack of a corresponding syntax for record construction
type R = { X: int; Y: int } match x with | (X=pat, Y=pat) -> ...
-
Where the above don't fit, use an active pattern. It's what they're there for.
Discussion and further suggestions below
Original suggestion:
Generalized collection patterns
Currently, list and array patterns can only match based on length, or in list's case, unconsing the first element and the rest of the list. We don't have patterns to match based on starting and ending elements, or patterns to match arbitrary types with indexes.
I propose we allow
- Slice patterns
function [| firstElem; _; thridElem; ..; secondToLastElem; _|] -> f firstElem thridElem secondToLastElem |> Some | _ -> None
The two dots indicate skipping zero or more elements. We can useas
to get the sliced area:let unsnoc = function [.. as s; lastElem] -> s, lastElem
Only one slice is allowed per collection for now. - Seq patterns
function seq { _; secondElem; .. } -> Some secondElem | _ -> None
We should be able to match on arbitrary sequences. If they are of typeIReadOnlyList<T>
,IList<T>
orIList
, we can even access by index. If there are multiple matches, we should cache the elements to a ResizeArray and access by index. The empty case should beseq []
, to unify with how we construct seqs. Sometimes we just want to match by index. Moreover, these collections may be hidden inside properties and fields.
The existing way of approaching this problem in F# is performing length checks and accessing indexes, or in the special cases, using library head
and last
functions.
Pros and Cons
The advantages of making this adjustment to F# are
- More uses for collection patterns
- Less code to write
- Being able to apply patterns to even more types
The disadvantage of making this adjustment to F# is the overlap with active patterns. However, active patterns completely disable completeness checks so an unnecessary catch-all pattern must be used every time.
Extra information
Estimated cost (XS, S, M, L, XL, XXL): M to L
Related suggestions: Champion "list pattern" for C# List patterns proposal for C# C# 8 recursive pattern matching F# pattern matching: parity with C# 9
Affidavit (please submit!)
Please tick this by placing a cross in the box:
- [x] This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
- [x] I have searched both open and closed suggestions on this site and believe this is not a duplicate
- [x] This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.
Please tick all that apply:
- [x] This is not a breaking change to the F# language design
- [x] I or my company would be willing to help implement and/or test this
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.
The existing way of approaching this problem in F# is performing length checks and accessing indexes, or in the special cases, using library head and last functions.
The existing way to do nearly all of these is one much more general feature - active patterns. Please rewrite the suggestion taking this into account.
Now, it is likely we will make some extensions to pattern matching - I favour nested pattern matching on properties in particular. However in F# 1.0 we made a decision not to randomly extend the pattern matching algebra with more and more "cute" features, and instead embrace a single unified feature for extending pattern matching.
Continually extending pattern matching with new "features" is frankly a trap in programming language design that stems right back to the 1970s and 80s when pattern matching was first introduced. You can see remanants of this thinking in OCaml, Haskell and so on. I believe the C# team are falling directly in this trap - and frankly they will regret it over time. Why? Because
-
these features are obscure in syntax and semantics, and some will come to be loathed by C# teams
-
the performance profile of these features is hard to understand, e.g. adding something as subtle as sequence patterns is frankly nuts - what happens on infinite sequences? what happens on re-matching of sequences?
-
these features often don't work well under changes to your program - e.g. change a property to a method (because an extra parameter is needed) and suddenly you have to change every bit of code that used pattern matching against that property - which can literally mean removing pattern matching from thousands of lines of code.
As explained in our 2006 paper, active patterns offer a single, unified point of extension for pattern matching capabilities, an observation based on 30+ years of programming language history. There are some small cases where they don't work particularly well, but in balance they should always be considered first.
To be clear, that seq pattern was not C#'s design. The linked proposal only works with any type that:
- Has an accessible property getter that returns an
int
and has the nameLength
orCount
- Has an accessible indexer with a single
int
parameter- Has an accessible
Slice
method that takes twoint
parameters (for slice subpatterns)This rule includes
T[]
,string
,Span<T>
,ImmutableArray<T>
and more.
That said, during the LDM considering list patterns, they considered extending this to all foreach
-able types, noting that if users need this pattern, they will code it by hand anyway, and the compiler can make it a bugfree process.
Since F# does not have a generalized collection syntax, I decided to make seq
be the pattern designator.
the performance profile of these features is hard to understand, e.g. adding something as subtle as sequence patterns is frankly nuts - what happens on infinite sequences? what happens on re-matching of sequences?
Infinite sequences already do not work well with aggregating functions in the Seq
module. Try Seq.initInfinite id |> Seq.max
for example. For re-matching, obviously, some caching will be needed, like with a Seq.cache
.
change a property to a method (because an extra parameter is needed)
We should have properties with parameters? Just kidding. Property to method conversions should be rare because properties should not depend on an external state. Properties are expected to be side-effect free while methods can have side-effects. Any conversion like this should expect large breaks.
these features are obscure in syntax and semantics, and some will come to be loathed by C# teams
This is really the point to emphasize. While syntax and semantics can be learned, we all like extensions of existing syntax. New active patterns should have less implementation complexity than adding new syntax. I imagine it would look like
let (|Start3|_|) x =
if Array.length x < 3 then None
else (x.[0], x.[1], x.[2]) |> Some
let (|Index|_|) index x =
if Array.length x < index then None
else Array.item index x |> Some
match [|1;2;3;4;5|] with
| Start3 (_, second, _) & Index 3 4 -> Some second
| _ -> None
|> printfn "%A" // Some 2
Ideally, this should be inline
and be generic over
- Has an accessible property getter that returns an
int
and has the nameLength
orCount
- Has an accessible indexer with a single
int
parameter- Has an accessible
Slice
method that takes twoint
parameters (for slice subpatterns)
But that would be up to the implementation.
Field matching exists but only within F#:
type Bleh =
val Field1 : int
val Field2 : string
new(a,b) = {Field1 = a; Field2 = b}
type Bleh2 =
inherit Bleh
val Field3 : string
new(a,b,c) = {inherit Bleh(a,b); Field3 = c}
let b1 = Bleh(1,"1")
let b2 = Bleh2(2,"2","c")
match b1 with
| {Field1 = f1; Field2 = f2} when string f1 = f2 -> "="
| {Field1 = 1} -> "1"
| _ -> ""
match b2 with
| {Field3 = "c"} -> "c"
// | {Field1 = 1} -> "1" // <--- would need to match on Bleh type for this
| _ -> ""
If Bleh
was in a non F# asm this would not work, it would be nice if that wasn't the case. In particular I had a use-case where I was hoping (assumed) this would work on type provider provided types.
change a property to a method (because an extra parameter is needed) and suddenly you have to change every bit of code that used pattern matching against that property - which can literally mean removing pattern matching from thousands of lines of code.
So what? If you change a property to a function with a new parameter that's used in thousands of different places, that's a huge breaking change regardless if you used pattern matching or not.
It's like saying we shouldn't paint the walls blue because if the building burns down we will have to repaint.
these features are obscure in syntax and semantics, and some will come to be loathed by C# teams
That's not really an argument. You're basically saying "F# shouldn't do X because C# did X and while they like it now they might not like it in the future".
An argument should say what X is and why you think it will not be liked in the future.
these features are obscure in syntax and semantics, and some will come to be loathed by C# teams
That's not really an argument. You're basically saying "F# shouldn't do X because C# did X and while they like it now they might not like it in the future".
An argument should say what X is and why you think it will not be liked in the future.
The argument is, clear as day, about endless extensions to pattern matching. It becomes an eternal race to keep coming up with new cool syntax, but owing to the nature of these languages, you also can't deprecate old syntax, and so now every developer has to learn every syntax, even the ones they don't want to use, just to be able to process code.
Consider polymorphic pattern matching in C# on an input arg of type object
. Off the top of my head, the ways to presently handle 'figure out which of these known possible types it is' include:
- Explicit Cast with exception handling
- GetType() / typeof comparisons
- 'as' keyword
- 'is' keyword
- case Type alias
- Type alias => And this is to say nothing of extensions to those features to do things like property matching on top of them.
Language cruft is real.
So what? If you change a property to a function with a new parameter that's used in thousands of different places, that's a huge breaking change regardless if you used pattern matching or not.
No, it's different. In C#, if you change a property to a method (say one taking no arguments - .Length
to .GetLength()
), and that property was used as part of large nested pattern matches, then you literally have to remove all the pattern matching and rewrite the whole thing in statements and expressions. There is simply no escape route AFAIK (because C# lacks the general feature akin to active patterns).
In contrast, changing a property to a method taking no arguments is a routine change at all callsites for expressions/statements.
Property to method conversions should be rare because properties
They are common enough, e.g. when moving a property .Foo
to a method .GetFoo()
because Foo
is expensive to compute.
But of course this isn't a huge problem for F# as an active pattern can be written for the property/method and localised replacements made to pattern matching syntax
To proceed with any of this in any shape I'd like to see some code samples where the proposed patterns actually do what the stated list of pros and cons state. As it stands, this just reads like "I'd like some more patterns".
@kevmal It exists but only within some specific FSharp. For example:
type Donk() =
member val One = 1
member val Two = 2
type Bleh =
val One : int
val Two : int
new(a,b) = {One = a; Two = b}
let d = Donk()
let b = Bleh(1,2)
match d with // does not work
| { One = 1 } -> printf "yay!"
| _ -> printf "boo"
match b with // works
| { One = 1 } -> printf "yay!"
| _ -> printf "boo"
And I don't get this difference at all.
Also, I believe active patterns is a great mechanism and it can tremendously help with tricky matching of nested structures, lots of if-elif-else blocks and so on, but using it just to get a value of property is an overkill. I believe that language should just have a generic one-for-all mechanism for working with properties. If you can match properties of one type then you should be able to work with properties with any other type in similar fashion. And whether someone decided to put heavy logic there or whatever should not be a language's concern.
And I don't get this difference at all.
type Donk() =
member val One = 1
member val Two = 2
One
and Two
are properties not fields.
type Donk() =
[<DefaultValue>]
val mutable One : int
member val Two = 2
let d = Donk()
match d with
| { One = 1 } -> printf "yay!"
| _ -> printf "boo"
Would work. One
is now a field Two
is still a property with a backing filed Two@
.
@kemval Thanks, but I actually meant it in a sense of looking from a language perspective. Not what it gets compiled to. Record "fields" are properties in fact and pattern matching works with them.
I've stumbled on this yet again when working with Source Generators. I'd really like to write generator backend on F# and leave C# with calling a few dedicated functions from F#.
Sadly, I feel like C# is better than F# in this kind of task simply because of Property matching. ActivePatterns are great, but they simply don't help in this case because you have no way to apply an ActivePattern to a property of C# object in a nested match expression. You have to write this matching manually.
It's kinda frustrating that instead of writing a simple and concise match expression I have to write additional active patterns, get object through type test matching then match properties of object one by one, but only 1st level because you simply can't go further. So to match other properties you have to get them first etc.
And to be honest, ActivePatterns aren't really better than simple bool returning functions in this case.
@En3Tho Thanks, I can see where you're coming. Writing separate active patterns for every .NET property is indeed a PITA
So let's discuss property/field matching specifically, putting aside the other things in this suggestion. I can fundamentally see the value in these, despite some of my comments above.
There are several possible syntaxes for property matching:
match x with
| _.Length as 0 -> ....
match x with
| _.Length(0) -> ....
match x with
| (Length=0) -> ....
match x with
| _.(Length=0) -> ....
match x with
| _(Length=0) -> ....
and for boolean properties either no special syntax:
match x with
| _.IsEmpty as true -> ....
match x with
| _.IsEmpty(true) -> ....
match x with
| (IsEmpty=true) -> ....
match x with
| _.(IsEmpty=true) -> ....
match x with
| _(IsEmpty=true) -> ....
Questions:
-
Which syntax? If we assume https://github.com/fsharp/fslang-suggestions/issues/506 is in then I quite like this, it is a little more verbose than the C# syntax, however that may be no bad thing - I think the C# syntax is perhaps too succinct on this:
match x with | _.IsEmpty(true) -> ....
-
We could consider whether leaving off the pattern implies
true
so:match x with | _.IsEmpty -> ....
Though the "leaving off the pattern for booleans" rule doesn't quite fit well with me. If we have this kind of rule it should really apply to active patterns - something like that should be orthogonal. However I would find that a bit weird. So I'd be inclined to leave off any special treatment for boolean properties in a first regard.
-
Do these include extension properties? I would assume so.
-
Are these only instance properties? I would assume so
-
The C# Deconstruct pattern should probably be dealt with in the same RFC, or at least the interaction with that considered.
@Happypig375 If it's ok I'll change the title of this just to deal with property/field matching.
@dsyme It's ok. In https://github.com/fsharp/fslang-suggestions/issues/1018 I hacked together a syntax if that is implemented along with #506.
let (|Member|_|) f = function null -> None | x -> Some <| f x
match typeof<int> with
| Member _.BaseType (Member _.BaseType null) -> printfn "A"
| Member _.BaseType (Member _.BaseType typeof<object>) -> printfn "B"
| Member _.BaseType typeof<object> -> printfn "C"
| Member _.BaseType null -> printfn "D"
| _ -> printfn "E"
But I guess compared to C# having an entire Member
in front is still off-putting to C#ers coming to F#. This is also potentially much less efficient. This should be added to the RFC.
Yes, interesting. With syntaxes proposed above this would be
match typeof<int> with
| _.BaseType (_.BaseType null) -> printfn "A"
| _.BaseType (_.BaseType ty) when ty = typeof<obj> -> printfn "B"
| _.BaseType ty when ty = typeof<obj> -> printfn "C"
| _.BaseType null -> printfn "D"
| _ -> printfn "E"
etc. Looks ok?
Is the _
just for aligning with #506? It seems to be wasting space.
Also to solve the property-changed-to-method problem, we can also allow whatever #506 enables, namely
_.Foo.Bar
_.Foo.[5]
_.Foo()
_.Foo(5).X
Because we can also align this with https://github.com/fsharp/fslang-suggestions/issues/969#issuecomment-772153700 if we don't have the _
.
Could the syntax just be inline with pattern matching on fields? For example,
match x with
| {Length = 0} -> ....
| {IsEmpty = true} -> ...
| {Length = length; IsEmpty = false} -> ....
Is the _ just for aligning with #506? It seems to be wasting space.
Are you suggesting
match typeof<int> with
| .BaseType (.BaseType null) -> printfn "A"
| .BaseType (.BaseType ty) when ty = typeof<obj> -> printfn "B"
| .BaseType ty when ty = typeof<obj> -> printfn "C"
| .BaseType null -> printfn "D"
| _ -> printfn "E"
or
match typeof<int> with
| BaseType (BaseType null) -> printfn "A"
| BaseType (BaseType ty) when ty = typeof<obj> -> printfn "B"
| BaseType ty when ty = typeof<obj> -> printfn "C"
| BaseType null -> printfn "D"
| _ -> printfn "E"
The second syntax is not possible, We need something to know that we need to do property resolution at all (i.e. "this is a property pattern"), and to disambiguate with active patterns and other pattern discriminators called BaseType
.
The first syntax may be possible but it would seem strange not to have symmetry with #506. (The _.
is present in #506 mainly because naked .Prop
is just hard to dismbiguate in nested positions, e.g. consider List.map .Prop inputs
- that's really subtle, compared to List.map _.Prop inputs
.)
Could the syntax just be inline with pattern matching on fields? For example,
I actually really dislike the use of { ... }
in patterns, I think it's always really hard to read, kind of unpleasant on the eye, and I sort of regret having it in F# at all. And in this case there's no symmetry with expression forms.
So I don't really like the idea of extending that.
Would symmetry with https://github.com/fsharp/fslang-suggestions/issues/969#issuecomment-772153700 be doable if not #506?
Could the syntax just be inline with pattern matching on fields? For example,
I actually really dislike the use of
{ ... }
in patterns, I think it's always really hard to read, kind of unpleasant on the eye, and I sort of regret having it in F# at all. And in this case there's no symmetry with expression forms.So I don't really like the idea of extending that.
Would the goal be to prefer the new syntax in the case of records/fields as well? When it comes to records (or fields) I would assume you have a choice in syntax at that point? Or would a defined "property" on a record need to &
match along with the { ... }
syntax?
@kevmal { ... }
does type inference which this will not do.
Would the goal be to prefer the new syntax in the case of records/fields as well?
Yes, because symmetry with #506 would mean _.Ident
will work for field, properties. So the pattern form should be likewise. So this should work:
type R = { X: int; Y: int }
let f (r: R) =
match r with
| _.X 3 -> 1
| _.X 3 & _.Y 4-> 1
| _ -> 2
It is however unfortunate that this gives two ways to do record matching, both of them verbose and the &
obscure.
I added two more syntax suggestsions to the summary above. First _.(bindings)
:
match x with
| _.(Length=0) -> ....
let f (r: R) =
match r with
| _.(X=3) -> 1
| _.(X=3, Y=4)-> 1
| _ -> 2
then same without the .
match x with
| _(Length=0) -> ....
let f (r: R) =
match r with
| _(X=3) -> 1
| _(X=3, Y=4)-> 1
| _ -> 2
There is also the question of whether property matching is available immediately on a type test, e.g.
match x with
| _(Length=0) -> ....
let f (inp: obj) =
match inp with
| :? SubType1(X=3) -> 1
| :? SubType2(X=3, Y=4)-> 1
| _ -> 2
These options again lean more towards symmetry with object creation syntax.
To summarize today we have:
- One object creation syntax
MyObject(X=1, Y=2)
- One record creation syntax
{ X = 1; Y = 2 }
- One record pattern syntax
{ X = pat; Y = pat }
- One proposed first-class property extraction syntax
_.P
My initial proposal said "symmetry with (4)" but the above tend more towards "symmetry with (1)". We could allow both, so this:
let f1 (x: int list) =
match x with
| _.Length 0 -> ....
let f2 (r: R) =
match r with
| _(X=3) -> 1
| _(X=3, Y=4)-> 1
| _ -> 2
let f3 (inp: obj) =
match inp with
| :? SubType1(X=3) -> 1
| :? SubType2(X=3, Y=4)-> 1
| _ -> 2
However it's not clear the _.P pat
form adds much, e.g. over
match x with
| _(Length=0) -> ....
Probably better just to have one "object patterns" feature?
If we can apply other patterns while type testing too that would be great.
On top of the f2
, we can add the corresponding creation expression with an inferred type as well!
On top of f3
we can also add #830 into the mix.