-
Notifications
You must be signed in to change notification settings - Fork 22
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
Implicit construction of a default union case #1078
Comments
This feels very close to the use of implicit conversion operators (which F# doesn't use) / implicit upcasts (which F# supports in very limited cases) so I can't see this fitting into the F# style. |
There is #849 and related issues #3, #91, #536, #792, as well as this RFC - the RFC and some of the issues are "approved in principle". This proposal could not only be seen as "remove explicitness", but also as "removing redundancies". An example: Looking at the As F# has not only an explicit, but also a practical nature, it is not unimportant how other popular languages handle similar / alike problems. In TypeScript, values of type
In the mentioned RFC, there is a detailed discussion on how / where implicitness shall work, and I think that should always be decided case by case. Selection of implicit conversions would allow |
A related discussion for this kind of type-directed conversion is here: fsharp/fslang-design#525 (comment) System.Nullable conversions work but you also need to specify a But this is largely an interop-driven feature. There are several aspects of using F# with other libraries that are kind of annoying without them. I could see a similar case being made here but I haven't really "felt" that annoyance with options/DUs. |
Note https://github.com/fsharp/fslang-design/blob/main/preview/FS-1093-additional-conversions.md is actually in preview and part of F# 6.0 So yes, as Phillip says you can do this in F# 6.0 with op_Implicit, at least to some extent. However it's not recommended to do it and per the RFC the compiler actually gives warnings to that effect when using op_Implicit at any point except a method call parameter. |
Even if similar goals can be achieved, I actually see the use of |
If we chose to support it (although, I personally think this will add confusion), I think there should be an explicit way of specifying default constructor for the given DU type, rather than leaving it to the compiler to determine. type Result<'R, 'E> =
| [<DefaultConstructor>] Ok of 'R
| Error of 'E Also, should this default constructor be applied only for parameters, when the function/method is called? Or return values too? In this case it will lead to all kinds of logical errors, e.g. where constructor was just "forgotten", and compiler will decide to use a default one, or the expression was not ignored, and implicitly passed to a default constructor and returned. |
I think, most dangerous part is for DUs like |A of int | B of int. Keeping in mind that order does not matter, we may receive very strange and fragile code in some cases. Otherwise, this issue can be worth for single case DUs |
I think it is a bad idea because people usually try to make safer code with single case union like type UserId = UserId of int
type OrderId = OrderId of int
let getUserOrder (UserId userId) (OrderId orderId) = ...
getUserOrder (UserId 42) (OrderId 15) With implicit conversion safety will be lowered with more WTF`s raised getUserOrder 42 15 |
It should indeed be a design choice of the developer whether to support this on a DU or not by opting-in a default case.
In the case of single case DUs propably yes, but I think the proposal is not necessarily a bad idea in general. Having a way of opting-in this feature explicitly by e.g. an attribute for a specific case adresses this concern well. |
Couldn't you just use an active pattern if you don't want to specify it? I understand the motivation for this, but also I think it's going to lead to less readable and maintainable code. It obfuscates at the calling point what is actually being passed in, when we already have a lot of inference available. I think it could get very confusing very quickly. |
@voronoipotato Could it be possible to clarify this with an example from a call site perspective? |
Just use anonymous unions when they are available. |
|
I already consideres this, but that might not work - at least not in this example, which would enable the goal of this proposal: type Nothing = Nothing
// API
let doSomething (x: 'a | Nothing) = ()
// that should compile:
doSomething 34.2
doSomething "Hello"
doSomething Nothing The RFC for anonymous unions, says: @Happypig375 Or did you have something else in mind? |
Then why not do a |
FS-1093 gives you implicit upcasts to obj. |
That would hide the fact that „Nothing“ has a significant semantic in the contract because it would not appear in the contract anymore (I hope I understood the idea correctly). Also, this would feel like duck typing, and that was not the intention of the original proposal. The safety of DUs on definition site shall not get lost by using obj and type tests. |
Wouldn't the implicit effectively also lose your safety given that you can bypass any casting to a DU type? |
This could be allowed by allowing specification of additional constructors: [<RequireQualifiedAccess>]
type A<'a> =
| C1 of 'a
| C2
new(x:'a) = C1(x)
let u = A.C1(23)
let v = A(23) Is it possible to do this in theory or does this break a .net rule about what a constructor is? |
I'm really not sure if I understand that correctly (I try to answer and then clarify with examples): In cases that the default case would accept any unconstrained value (e.g. Example 1 // definition site:
type Option<'a> =
| [<DefaultCase>] Some of 'a
| None
fun f (x: Option<_>) =
// match cases for x like usual ...
()
// call site (should compile):
f 33.4 // same as: f (Some 33.4)
f "Hello" // same as: f (Some "Hello World")
f None // same as: f (None) Any value can be passed into f at call site. And it's true that we lose something compared to how it is done today: As many commentators already pointed out, by removing the need for discriminating "a value" by Example 2 // definition site:
type DU<'a> =
| [<DefaultCase>] A of (int * string)
| B
fun f (x: DU<_>) =
// match cases for x like usual ...
()
// call site (should compile):
f (33.4, "Hello") // same as: f (A (33.4, "Hello"))
f B // same as: f (B)
// call site (ERROR):
f 33.4
f "Hello" Here, it's clear that there is a difference also at call site compared to just resorting to |
Example 1: Example 2: Easy use of |
Yes, that's true - having the info in the XML doc wouldn't be any different than having the DU in the signature without a need to discriminate it's cases due to implicit construction. With a need for naming the cases explicitly, the developer is "forced" to having understood that - the information of what can potentially be done never gets lost. I personally value that Even though I was never in a real strong favour of the proposal, I thank the contributors for their commenting for making that clear. I would then close this issue, since there are several ways / workarounds for adressing this in the mentioned issues / RFCs and comments. |
...just in case anybody stumbles across this closed issue, there's a talk from Rich Hickey: "Maybe Not": Yesterday: Rich says (about 8:00 to 9:30):
https://www.youtube.com/watch?v=YR5WdGrpoug (I hope I'm not violating any rules of this repo by posting this talk - if so, please inform me). |
I don't think its strictly off topic but I'm just a bystander, being said my view is that what is good for a lisp may or may not be good for an ML language, and vice versa. While there are similarities, there are also differences both philosophically and practically. What is helpful in one language or paradigm can be destructive in another, so in my view it's important to be careful when cross pollinating. |
Thank you for commenting, @voronoipotato. I just stumbled upon the talk by chance and thought it might add another interesting point of view on the subject - so just linking it, not as a solid basis for arguing. Interesting IMO because I think that it's possible to have one of the following two things, but not both together: a) Discriminating unions: the compiler forces the user of an API to deal with its peculiarities (which I see as an advantage see), but changes are breaking. b) Easing requirements or strengthing promises while retaining backward compat, but then, the change in characteristics of an API might get lost and is not obvious for it's users without looking up signatures / docu.
Yes, I agree. Even for a well known language, I find it hard to predict whether a change or addition might add benefit or confusion, or if it might just be useless at all, so it's propably impossible to just transfer between different languages. And I think that subtle features or tweaks can make a huge difference.
Do you have any quick thoughts or resources on some key points you might have in mind (statically <-> dynamically typed / (de)separation of data and code / others)? |
The syntax and flexibility of it comes to mind. MLs tend to have flexibility within a bounded context, whereas lisps tend to remove constraints. Macros and homoiconicity mean that you can make lisp feel like other languages all the way down, with ML I see the parallels between BNF and the product and sum types but it usually isn't interwoven with code in the same way, there's a bit more separation between the layers of abstraction and that's even more pronounced due to the affordances and ux of computation expressions. Languages often steer a speaker in speaking in certain terms by making certain things harder to say and others easier to say. The easier something is to say the more likely it will be said. In lisps in my opinion, it's easier to say more things but that isn't a strictly good or bad thing. It depends on your context and goals of using the language. If you have things you almost never want to say, it can be nice to have a language which reflects that by taking more words to say it. It's a bit like how you may not want a button on your plane that makes the wings fall off, because if it's there someone might at some point push it. It's good for languages to have some opinions and it's good that not all languages have the same opinions. A good paper on this is "notation as a tool of thought". It's a very opinionated paper about APL but it covers very similar ideas to what we're discussing regarding the user experience and design of programming languages. I won't say I necessarily agree with all the opinions presented in the paper but the observations are nonetheless interesting and pretty relevant to your thoughts. |
I actually bump into a case relatively often where having a default case for a DU would decrease verbosity. Specifically for me, it is when I am constructing lists containing DU cases, but I expect one case to be used most of the time. Take for instance this example: type MyUnion =
| Case1 of string
| Case2 of string
let unionList : MyUnion list =
[ MyUnion.Case1 "x"
MyUnion.Case1 "x"
MyUnion.Case2 "y"
MyUnion.Case1 "x"
MyUnion.Case1 "x" ] If it was possible to have a default case, this can be reduced to: // I would ideally like to be able to specify the default here, as part of the function signature.
// What is useful as a default can be context specific.
let unionProcessor (unionList: MyUnion(Case1) list) =
...
[ "x"; "x"; MyUnion.Case2 "y"; "x"; "x" ] |> unionProcessor
|
I propose we
Example:
Given this type and code:
This code doesn't compile currently
show 23
, but it would according to the proposition if
C1
was recognized as the default case by the compiler, so the code could be desugarized to:A way of specifying the default case could be:
The existing way of approaching this problem in F# is constructinga value explicitly by using the case label.
Pros and Cons
The advantages of making this adjustment to F# are:
A way of approaching the same thing in other languages e.g. in the domain of Options, like using just
[value]
representing "Some [value]" andnull
as representative for "None" is common and would simplify the transition to F#.The disadvantages of making this adjustment to F# are the usual ones when implicit behaviour is introduced.
Extra information
Estimated cost (XS, S, M, L, XL, XXL):
Related suggestions:
#684 #320 #849 #3 #91 #536 #792
fsharp/fslang-design#525 (comment)
https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1092-anonymous-type-tagged-unions.md
https://github.com/fsharp/fslang-design/blob/main/preview/FS-1093-additional-conversions.md
Affidavit (please submit!)
Please tick this by placing a cross in the box:
Please tick all that apply:
For Readers
If you would like to see this issue implemented, please click the 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.
The text was updated successfully, but these errors were encountered: