-
Notifications
You must be signed in to change notification settings - Fork 21
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
Support C#-style Deconstruct method based pattern matching #751
Comments
let f x = match x with 1, 2 -> "1, 2" | y, z -> "y, z"
|
Ooh, good question! 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 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? |
@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:
|
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. |
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: |
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# I prefer this form, because it then fix the type to deconstruct from, which feels safer to write. |
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 And since 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 |
Understand problem, I vote @yatli 's solution, and:
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 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 |
And at the same time is consistent with other design choices in the language. :)
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. In contrast, when matching against class hierarchy it makes much more sense, and currently there isn't a F# equivalence. |
When we tap into OO it's lost already. |
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:
|
@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: }
|
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: dotnet/roslyn#25240. |
@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 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 SomeClass x = new SomeClass();
var (a,b) = x; ... and a 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 let x = SomeClass()
let Deconstruct(a,b) = x (looks too much like a function def?) |
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
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
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 However, it has the downside that it cannot be made to work with extension members that have |
As it exists now in C#, Deconstruct must have two or more parameters to be used when deconstructing, but it can have a single parameter or be parameterless for pattern matching. When pattern matching on parameterless Deconstruct method, it does not invoke the parameterless Deconstruct method, but does require it to be present. |
I think it's even possible use the tuple pattern syntax for class objects. // Suppose ButtonEventArgs is derived from type TEventArgs<string>(obj, string)
let ev = ButtonEventArgs(this, "button pressed")
let (obj, data) = ev // what could go wrong?
match ev with // the type of ev is : ButtonEventArgs
| (obj, data) -> ... // calls ButtonEventArgs.Deconstruct, without runtime type check
| :? TEventArgs<string> as (obj, str) -> ... // runtime type check, then calls TEventArgs<string>.Deconstruct. same as c# case match Edit: looks like I'm going back to my original proposal, after all :p |
These code will work:
Usage:
but there is tow problem: |
@greatim Thanks for the example :) @Happypig375 brought this idea up and I opposed with a few reasons. But now I think of it, some of the reasons don't stand (e.g. multiple overloaded Deconstruct -- because the AP can be inlined), and instead of a keyword-based approach, adding this to the core library would make it less invasive and more discoverable. Sorry Happy, I should've thought more about it..
I don't get it, outref should be stack-based reference?
maybe #612 can help. |
|
let f (x: int outref) =
x <- 123
[<EntryPoint>]
let Main _ =
let mutable x = 0
f &x
printfn "%d" x
0
IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldc.i4.0
IL_0003: stloc.1
IL_0004: ldloca.s 01
IL_0006: call Query_iqojmb.f
...
f:
IL_0000: ldarg.0
IL_0001: ldc.i4.s 7B
IL_0003: stobj System.Int32
IL_0008: ret Works as I expect. |
I just found that the code above will produce the |
Understood. |
I finally labelled this as approved-in-principle. I agree there should be some way of consuming |
I don't think anyone has mentioned this in this thread yet... For what it's worth, you can already do something like this just by calling type PretendThisIsACSharpRecord =
{ A : int
B : string
C : decimal }
member this.Deconstruct (a : int outref, b : string outref, c : decimal outref) =
a <- this.A
b <- this.B
c <- this.C
let r = { A = 1; B = "2"; C = 3.0m }
// Bind to a tuple.
let a, b, c = r.Deconstruct ()
// Match as a tuple.
match r.Deconstruct () with
| a, b, c -> () It's not the same as the compiler calling it implicitly, of course, but still. |
@brianrourkeboll that's true in the presence of anambiguous overloads, but we will likely need compiler support for handling overloaded Deconstructs: type PretendThisIsACSharpRecord =
{ A : int
B : string
C : decimal }
member this.Deconstruct (a : int outref, b : string outref, c : decimal outref) =
a <- this.A
b <- this.B
c <- this.C
member this.Deconstruct(a: int outref, b: string outref) =
a <- this.A
b <- this.B
let r = { A = 1; B = "2"; C = 3.0m }
// Bind to a tuple.
// works ok, inference can find a two-element tuple and go from there. also works if there are multiple two-element tuple
// overloads as long as you annotate enough parameters to find one
let a, b = r.Deconstruct ()
// Match as a tuple.
match r.Deconstruct () with // FS0041 triggered here because we can't guide inference?
| a, (b: string) -> () |
The C#-way of doing quick pattern matching and value extraction is by declaring member functions of name
Deconstruct
, or static extension methods accordingly. ADeconstruct
method has the signature of:... 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>]
andbyref
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
possiblepractical to use records (anonymous or not) in this case, because there can be multipleDeconstruct
overloads.A quick glance of what it may look like:
Applications Brainstorming
FSharp.Data
, so that the provided types can have better pattern matching.ResizeArray<T>
can be then matched as a list![<Out>] byref
, it can be used as input parameter, giving it full active pattern matching capabilitiesPros and Cons
The advantages of making this adjustment to F# are:
The disadvantages of making this adjustment to F# are:
Deconstruct
[<Out>] name: T byref
seems taboo in F#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:
Please tick all that apply:
The text was updated successfully, but these errors were encountered: