fslang-suggestions icon indicating copy to clipboard operation
fslang-suggestions copied to clipboard

Object/property/field patterns

Open Happypig375 opened this issue 4 years ago • 41 comments

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:

  1. Property patterns depend on #506

  2. Property patterns can not resolve to methods. Use an active pattern, it's what they're there for.

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

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

  2. Object patterns can't use indexers. This is because the corresponding object creation syntax doesn't support nesting

  3. 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) -> ...
    
  4. 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

  1. Slice patterns function [| firstElem; _; thridElem; ..; secondToLastElem; _|] -> f firstElem thridElem secondToLastElem |> Some | _ -> None The two dots indicate skipping zero or more elements. We can use as to get the sliced area: let unsnoc = function [.. as s; lastElem] -> s, lastElem Only one slice is allowed per collection for now.
  2. Seq patterns function seq { _; secondElem; .. } -> Some secondElem | _ -> None We should be able to match on arbitrary sequences. If they are of type IReadOnlyList<T>, IList<T> or IList, 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 be seq [], 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

  1. More uses for collection patterns
  2. Less code to write
  3. 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.

Happypig375 avatar Jan 21 '21 10:01 Happypig375

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

  1. these features are obscure in syntax and semantics, and some will come to be loathed by C# teams

  2. 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?

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

dsyme avatar Jan 21 '21 13:01 dsyme

To be clear, that seq pattern was not C#'s design. The linked proposal only works with any type that:

  1. Has an accessible property getter that returns an int and has the name Length or Count
  2. Has an accessible indexer with a single int parameter
  3. Has an accessible Slice method that takes two int 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

  1. Has an accessible property getter that returns an int and has the name Length or Count
  2. Has an accessible indexer with a single int parameter
  3. Has an accessible Slice method that takes two int parameters (for slice subpatterns)

But that would be up to the implementation.

Happypig375 avatar Jan 21 '21 16:01 Happypig375

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.

kevmal avatar Jan 21 '21 17:01 kevmal

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.

Grauenwolf avatar Jan 21 '21 23:01 Grauenwolf

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.

Grauenwolf avatar Jan 21 '21 23:01 Grauenwolf

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.

laenas avatar Jan 22 '21 08:01 laenas

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.

dsyme avatar Jan 22 '21 12:01 dsyme

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

dsyme avatar Jan 22 '21 12:01 dsyme

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

cartermp avatar Jan 24 '21 17:01 cartermp

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

En3Tho avatar Mar 20 '21 11:03 En3Tho

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

kevmal avatar Mar 20 '21 12:03 kevmal

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

En3Tho avatar Mar 20 '21 21:03 En3Tho

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 avatar Jun 03 '21 15:06 En3Tho

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

  1. 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) -> ....
    
  2. 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.

  3. Do these include extension properties? I would assume so.

  4. Are these only instance properties? I would assume so

  5. The C# Deconstruct pattern should probably be dealt with in the same RFC, or at least the interaction with that considered.

dsyme avatar Jun 03 '21 17:06 dsyme

@Happypig375 If it's ok I'll change the title of this just to deal with property/field matching.

dsyme avatar Jun 03 '21 17:06 dsyme

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

Happypig375 avatar Jun 03 '21 17:06 Happypig375

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?

dsyme avatar Jun 03 '21 17:06 dsyme

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

Happypig375 avatar Jun 03 '21 17:06 Happypig375

Because we can also align this with https://github.com/fsharp/fslang-suggestions/issues/969#issuecomment-772153700 if we don't have the _.

Happypig375 avatar Jun 03 '21 17:06 Happypig375

Could the syntax just be inline with pattern matching on fields? For example,

match x with 
| {Length = 0} -> ....
| {IsEmpty = true} -> ...
| {Length = length; IsEmpty = false} -> ....

kevmal avatar Jun 03 '21 18:06 kevmal

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

dsyme avatar Jun 03 '21 18:06 dsyme

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.

dsyme avatar Jun 03 '21 18:06 dsyme

Would symmetry with https://github.com/fsharp/fslang-suggestions/issues/969#issuecomment-772153700 be doable if not #506?

Happypig375 avatar Jun 03 '21 18:06 Happypig375

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 avatar Jun 03 '21 18:06 kevmal

@kevmal { ... } does type inference which this will not do.

Happypig375 avatar Jun 03 '21 18:06 Happypig375

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.

dsyme avatar Jun 03 '21 18:06 dsyme

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:

  1. One object creation syntax MyObject(X=1, Y=2)
  2. One record creation syntax { X = 1; Y = 2 }
  3. One record pattern syntax { X = pat; Y = pat }
  4. 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?

dsyme avatar Jun 03 '21 18:06 dsyme

If we can apply other patterns while type testing too that would be great.

Happypig375 avatar Jun 03 '21 18:06 Happypig375

On top of the f2, we can add the corresponding creation expression with an inferred type as well!

Happypig375 avatar Jun 03 '21 19:06 Happypig375

On top of f3 we can also add #830 into the mix.

Happypig375 avatar Jun 03 '21 19:06 Happypig375