Support C#-style Deconstruct method based pattern matching
The C#-way of doing quick pattern matching and value extraction is by declaring member functions of name Deconstruct, or static extension methods accordingly. A Deconstruct method has the signature of:
public void Deconstruct(out T1 name1, out T2 name2, ...)
... which actively extracts values from the class instance. Multiple overloads can be supplied to accommodate different ways of deconstruction.
In F#, we automatically receive pattern matching benefits for DUs and records, but currently the only way to peek into the content of a class instance in a pattern, is to create an active pattern for it. Since active patterns cannot be overloaded, one has to come up with different names for different ways of extraction, which adds extra complexity to the matter.
So I propose that we support this in F#.
A new kind of pattern is then added to classes, which allows a class to be matched against a tuple. When the compiler sees such a pattern, it looks up the class definition and extensions for Deconstruct methods, and align the tuple signature with the [<Out>] T byref parameters -- the [<Out>] and byref part should be removed. Then further matching of the elements in the tuple may proceed. Type inference rules unify the items.
Note, it's not ~~possible~~ practical to use records (anonymous or not) in this case, because there can be multiple Deconstruct overloads.
A quick glance of what it may look like:
type MyEventArgs() =
inherits EventArgs()
member val foo: int = 123 with get, set
member x.Deconstruct([<Out>] foo: _ byref) =
foo <- x.foo
// later:
myControl.MyEvent.Subscribe(fun (foo: int) ->
printfn "extracted foo = %d" foo
) |> ignore
Applications Brainstorming
- It would be then very cool to also add support in
FSharp.Data, so that the provided types can have better pattern matching. - A
ResizeArray<T>can be then matched as a list! - Allow custom deconstruction on DU/records?
- (Going too far maybe?) if some parameters are not marked as
[<Out>] byref, it can be used as input parameter, giving it full active pattern matching capabilities
Pros and Cons
The advantages of making this adjustment to F# are:
- More natural pattern matching on classes
- Better interop with C# while not disrupting the F# paradigms
The disadvantages of making this adjustment to F# are:
- Implicit dependency on the name
Deconstruct [<Out>] name: T byrefseems taboo in F#- (Taken from the C# docs) Multiple Deconstruct methods that have the same number of out parameters or the same number and type of out parameters in a different order can cause confusion
- ...but C# already did this
Extra information
Estimated cost (XS, S, M, L, XL, XXL): M
Related suggestions: (put links to related suggestions here)
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
This is not a breaking change to the F# language design
let f x = match x with 1, 2 -> "1, 2" | y, z -> "y, z"
f is inferred to take a 2-element tuple as the input. How would this play with type inference?
Ooh, good question!
I think in this case, we should keep the type inference as-is (tuple).
The deconstruction routines should only be active if x is inferred as a non-tuple.
Not that the type checker will blow up (a tuple can now unify with an arbitrary type :/ ), but it does make things a bit more complicated.
Edit: escalating the workload estimation to 'M'.
An alternative may be to have a special pattern matching construct, e.g.
match x with
| Deconstruct(a,b) -> ...
that is known to the compiler and looks for the Deconstruct pattern and emits good code for it. A shorter name than Deconstruct could also be used.
Combining this with a type test may also be important, a possible syntax is this:
match x with
| :? Node1 as (Deconstruct(a,b)) -> ... // a full nested pattern would be allowed
I do understand why some F# programmers have down-voted this. Fully implicit, type directed deconstruction is really weird for F#, especially given the existence of active patterns in the language and the general lack of type-directed magic rules in pattern matching (a part of the language that is, I think, particularly prone to problems in code comprehension if magic is being applied).
@dsyme the pattern suggestion could be implemented right now with SRTP, right? Would that be a useful proof of concept for someone to contribute and show usage examples?
@baronfel Please don't encourage the use of SRTP. @dsyme Spent ages trying to make the syntax as unmemorable as possible for a reason :-)
@7sharp9 it turns out that many deconstruct members are implemented as extension members + out parameters, which is problematic from an 'active pattern with SRTP' perspective for a few reasons:
- extension members aren't visible to SRTP constraints
- you'd have to initialize a dynamic series of out-parameters and pass them into the deconstruct call to collect them before returning them via the pattern
Well, using SRTP defeats the purpose of being C# compatible—which is my main motivation.
Using SRTP in this case is C# compatible because it is used when matching, i.e. consuming information from C# (or other F# code). You can still define new deconstruct methods as you wish.
@Happypig375
I think @baronfel has already covered the points, but let me try to rephrase:
First, the deconstruction methods are not necessarily attached to the types, so type constraints are not enough to resolve them.
Second, when there are multiple deconstructs, they cannot be used in one single active pattern because we cannot overload active patterns (unless allocating a list, and let the user pass in the number of parameters for the pattern)
Edit: Third, which is the most exotic — there can be multiple deconstructs with the same number of params so SRTP would be confused..
A shorter name than Deconstruct could also be used
Right, that's much better. To avoid clashing with an existing DU case, I'd prefer a new symbol for this new pattern matching construct, for example:
match x with
| :? Node1 as ?( a: T, b: U ) -> ...
// or, combining the two patterns:
| :? Node2(x, y, z) -> ...
... where the second form is similar to the C# switch (x) { case Node1(x, y): ... } syntax.
I prefer this form, because it then fix the type to deconstruct from, which feels safer to write.
Fully implicit, type directed deconstruction is really weird for F#,
If we have to write explicitly type names in deconstruction:
match x with
| MyEventArgs(a,b) -> ... // Can write only both F#'s record type and C# style Deconstruct method patterns.
In this case, the compiler will check x arg type is MyEventArgs, and find Deconstruct method (with out parameters) in it. I know it's syntax sugar, pros is naturally decomstruction syntax in F# and improves interops for C#'s.
@kekyo, that won't work, because a DU case with the same name can be in scope (which is not a type name from F# perspective), or an active pattern. Also, because Deconstruct can have overloads, the type of a and b is not set.
And since Deconstruct can be an extension method, what if there's a DU or record with that extension?
One of the greatest strengths of F# is its predictability. To fix all these ambiguities, you'll need unique syntax.
Generally I don't think I'm in favor here. The most common use case in C# is something to the effect of, "turn this class into a tuple of the data that matters" and using it like that. This would work in F#, and perhaps feel natural to an extent, but not having a good or simple way to represent it in match expressions would feel like a dealbreaker.
Understand problem, I vote @yatli 's solution, and:
- I feel better the extension Deconstruct methods searchable from opened namespace like C#.
- Thinking about Deconstruct method overloads:
pblic sealed class Node2
{
public void Deconstrcut(out int a, out int b) { ... }
public void Deconstrcut(out int a, out int b, out int c) { ... }
}
match x with
| :? Node2(x, y) -> ...
| :? Node2(x, y, z) -> ...
I feel these example matchers are possible, what's problem for ?
@kekyo, it doesn't solve what I mentioned before. The problem is that it still doesn't cover overloads. How can the compiler infer the correct types here?
public sealed class Node2
{
public void Deconstruct(out int a, out int b) { ... }
public void Deconstruct(out float a, out float b) { ... }
}
match x with
| :? Node2(x, y) -> ... // int or float?
| :? Node2(x, y) -> ... // int or float?
@abelbraaksma We could require to specify types the same way it's now required when overriding a member with several overloads with the same parameters count.
match x with
| :? Node2(x: int, y: int) -> ...
| :? Node2(x: float, y: float) -> ...
Although it's kind of ugly it seems it happens rare enough in practice, at least for the override overloads case. The typed patterns are there too already so no need to change many things in regards to the parser.
@auduchinok, yes, that could work. But indeed, it isn't too pretty.
While syntactically that covers it, I'm not convinced of using :?, which currently means 'runtime type check'. In the above examples it would be a compile time resolution to an (extension or instance) method, which is not orthogonal I think.
Also, it doesn't cooperate well with completeness checking.
If we go in this direction, I'd suggest the arguments should themselves be allowed to be inline pattern matchable (ie if they consist of DU's), to give it strong and idiomatic F# language support.
Apart from syntax, which could be resolved by taking another operator, allowing this on extension methods as well as instance methods can allow for a simpler alternative to active patterns, that instead can be defined on the type as methods, as opposed to let bindings, which may be desirable in certain scenarios. One would just augment a type in F# with a Deconstruct method.
@auduchinok, yes, that could work. But indeed, it isn't too pretty.
And at the same time is consistent with other design choices in the language. :)
While syntactically that covers it, I'm not convinced of using :?, which currently means 'runtime type check'. In the above examples it would be a compile time resolution to an (extension or instance) method, which is not orthogonal I think.
Could we then use the type names instead? It'd be similar to what matching union cases looks like. It'd be a breaking change, though. Not that I'm proposing this syntax, I just find it more or less suitable here.
match x with
| Node2(x: int, y: int) -> ...
| Node2(x: float, y: float) -> ...
I think in practice, most deconstruct methods are used together with a run-time type check. If you know the type ahead of time, you can already call a concrete method to extract what you want.
In contrast, when matching against class hierarchy it makes much more sense, and currently there isn't a F# equivalence.
Also, it doesn't cooperate well with completeness checking
When we tap into OO it's lost already. This is not intended to be used with ADT.
I just did some quick checking with C#, and, the example given doesn't work in C#.
public sealed class Node2
{
public void Deconstruct(out int a, out int b) { ... }
public void Deconstruct(out float a, out float b) { ... }
}
When attempting to use it, whether by using tuple assignment or by using pattern matching switch expression, even when specifying the types in the pattern matching.
(int a, int b) = x; // The call is ambiguous between Node2.Deconstruct(out int a, out int b) and Node2.Deconstruct(out float a, out float b)
x switch {
Node2(int a, int b) => ..., // Same error as above
}
Thus the above example already doesn't work for C#, at least when using C# 8.0 on .NET Core 3.0.
Example from MSDN:
using System;
public class Person
{
public string FirstName { get; set; }
public string MiddleName { get; set; }
public string LastName { get; set; }
public string City { get; set; }
public string State { get; set; }
public DateTime DateOfBirth { get; set; }
public Decimal AnnualIncome { get; set; }
public void Deconstruct(out string fname, out string mname, out string lname, out int age)
{
fname = FirstName;
mname = MiddleName;
lname = LastName;
age = DateTime.Now.Year - DateOfBirth.Year;
if (DateTime.Now.DayOfYear - (new DateTime(DateTime.Now.Year, DateOfBirth.Month, DateOfBirth.Day)).DayOfYear < 0)
age--;
}
public void Deconstruct(out string lname, out string fname, out string mname, out decimal income)
{
fname = FirstName;
mname = MiddleName;
lname = LastName;
income = AnnualIncome;
}
}
@TheJayMann I think your example doesn't work because there's implicit conversion between float and integer -- try string instead?
The first example I tried was between int and string, with the same results. I only changed it to int and float in my reply to match the discussion.
I'll try the example you gave above to see if I see the same results or not.
After having just used the Person example listed above, and trying both the switch expression syntax as well as tuple deconstruction syntax, it still has the ambiguity errors.
32: static void Main() {
33: var person = new Person() {
34: FirstName = "James",
35: MiddleName = "Willy",
36: LastName = "Smith",
37: City = "DownTown",
38: State = "ST",
39: DateOfBirth = DateTime.Today.AddYears(-35),
40: AnnualIncome = 95687.39m
41: };
42:
43: (string firstName1, string middleName1, string lastName1, int age1) = person;
44: (string lastName2, string middleName2, string firstName2, decimal income2) = person;
45:
46: var a = person switch
47: {
48: Person(string firstName, string middleName, string lastName, int age) => 19
49: };
50: }
1>Program.cs(43,83,43,89): error CS0121: The call is ambiguous between the following methods or properties: 'Person.Deconstruct(out string, out string, out string, out int)' and 'Person.Deconstruct(out string, out string, out string, out decimal)'
1>Program.cs(44,90,44,96): error CS0121: The call is ambiguous between the following methods or properties: 'Person.Deconstruct(out string, out string, out string, out int)' and 'Person.Deconstruct(out string, out string, out string, out decimal)'
1>Program.cs(48,23,48,86): error CS0121: The call is ambiguous between the following methods or properties: 'Person.Deconstruct(out string, out string, out string, out int)' and 'Person.Deconstruct(out string, out string, out string, out decimal)'
The conclusion that I am coming to, assuming this were to be approved and implemented, is either remove the need for providing the types to determine which same parameter count overload to use, or to suggest to C# language design that they allow to disambiguate Deconstruct calls based on types provided.
@TheJayMann, I think that's a bug, certainly considering that the documentation states otherwise. That doesn't remove for us the requirement to be able to deal with overloads, esp assuming c# will fix it.
I think it's already reported and related to, or the same as: https://github.com/dotnet/roslyn/issues/25240.
If you know the type ahead of time, you can already call a concrete method to extract what you want.
@yatli That's an interesting consideration. So far I saw this as a compile time feature. But using just a runtime type check is not gonna cover it, we need to know if the required methods are available. To do that at runtime is going to be expensive and unpredictable.
Any reasonable way forward should, imo, be compile time only. The compiler needs to find the appropriate methods, just as if you designed it yourself with SRTP. That way it's generic and the compiler will fail if it cannot find the appropriate Deconstruct method.
Since work is being done to allow SRTP with extension methods, once finished, this may be the proper groundwork to implement this with the necessary syntactic sugar (TBD), assuming the powers that be find the required work in balance with the benefits.
@abelbraaksma I understand your concern and I fully agree that the deconstruction should be compile-time only. Being best served with run-time type check does not mean it is inherently bound to that.
Plus, there's another use case outside match, in C# one would write:
SomeClass x = new SomeClass();
var (a,b) = x;
... and a :? there will be very strange.
When I look back in the thread I think I get a better understanding of @dsyme 's idea now:
match x with
| :? Node1 as (Deconstruct(a,b)) -> ... // a full nested pattern would be allowed
Here :? constraints the type (runtime check), and Deconstruct is compile time. It's two different things. If the type is known ahead, invoke Deconstruct directly.
Applying this to the non-match example:
let x = SomeClass()
let Deconstruct(a,b) = x
(looks too much like a function def?)
in C# one would write:
I wonder if in C# it works with a one-tuple. If it doesn't, we could make this feature closer to existing F# language by 'simply' testing for a Deconstruct method after we find that the input is not an F# or standard .NET Tuple<> type.
let Deconstruct(a,b) = x
This wouldn't work for backwards compat reasons. However, you can use pattern syntax already:
// gives warning for missing match, but is allowed
let (Some x) = x
// valid syntax, but compiler will complain about indeterminate type.
// I couldn't find a variant of this syntax that the compiler liked, but for F# it is legal syntax
let testThis (:? string as foo) = foo
// not allowed, when-clauses cannot appear on the lh side of let-bindings
let (Some x when x = 1) = x
// allowed, which could theoretically be used with dsyme's proposal as well
let (Some x as foo) = foo, x
// this would be a function that takes tuple
let Deconstruct(a,b) = x
// this would expect a DU that has a DU case named Deconstruct
let (Deconstruct(a,b)) = x
Here
:?constraints the type (runtime check), and Deconstruct is compile time
Yes, that is probably how this could work, but that's unfortunate, as it requires potentially relatively expensive type checks. I would prefer the language feature to be compile-time. This could also work with @dsyme's suggestion, which would mean the compiler constraints x to be of Node1 or derived types of it.
However, it has the downside that it cannot be made to work with extension members that have Deconstruct(...) (unless perhaps we'd allow | :? _ as Deconstruct(a, b) -> ..., for any type that supports the Deconstruct member. Come to think of it, it could even be made into a feature that supports any member that has out parameters that has whatever name we put in there, assuming resolution is static).