Skip to content
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

Closed
4 of 5 tasks
SchlenkR opened this issue Sep 19, 2021 · 27 comments
Closed
4 of 5 tasks

Implicit construction of a default union case #1078

SchlenkR opened this issue Sep 19, 2021 · 27 comments

Comments

@SchlenkR
Copy link

SchlenkR commented Sep 19, 2021

I propose we

  • add a way for defining a default union case in the definition of a DU,
  • add a feature that constructs a DU value without explicitly specifying the discriminator.

Example:

Given this type and code:

type A<'a> =
    | C1 of 'a
    | C2

let show (a: A<'a>) =
    match a with
    | C1 x -> $"a = {a}"
    | C2 -> "Empty"

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:

show (C1 23)

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]" and null 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:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • 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:

  • This is not a breaking change to the F# language design
  • 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 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.

@Frassle
Copy link

Frassle commented Sep 20, 2021

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.

@SchlenkR
Copy link
Author

SchlenkR commented Sep 20, 2021

This feels very close to [...] implicit upcasts (which F# supports in very limited cases)

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 Option type, the semantics is: "It can have a value, or not". So 43.0 is already a value - but that's not enough: It has to be made clear that it is actually a value by using "Some".

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 (string | number) can be strings or numbers, and the compiler accepts values of string and number without further annotation / construction. In C#, optionality is represented by nullability. It's not about if that's good or not, but the fact that C# accepts just a value or null for a nullable type is convenient and intuitive, and it is worthful thinking about whether F# should behave in similar ways.

I can't see this fitting into the F# style

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 let f1 (x: 'T) : Nullable<'T> = x - why not having "Selection of Implicit Construction"?

@cartermp
Copy link
Member

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 #nowarn directive at the moment. These are driven largely be the use of System.Nullable in some numeric libraries and the need to "cleanly" interop with them.

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.

@dsyme
Copy link
Collaborator

dsyme commented Sep 20, 2021

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.

@SchlenkR
Copy link
Author

SchlenkR commented Sep 21, 2021

Even if similar goals can be achieved, I actually see the use of op_implicit differently than the definition of a default case, which is then clearly named and recognizable at definition side, and it's also a way more limited feature than op_implicit. Apart from a few .Net types, I dislike op_implicit and I can't remember ever having it used except for types used for arithmetik. Also, I am not a strong advocate of this proposal from a developer's personal point of view (I share the feeling of @cartermp that it's not that annoying). However, I keep noticing that people who want to learn F # do not understand very well why "Some" has to be used, even though a value is already given. Of course ADTs have to be understood in general and then such questions can be answered well. There is also value in encountering such questions as they open the door to further knowledge. If it is a general goal to make F# attractive to developers from other languages (as I feel it's the case e.g. for Python devs), it could mean that it is valuable to enable simple transitions with few stumbling blocks. Specifically, I see the possibility here of making such a simplification that causes little pain. I can perfectly understand if one is in favour of not doing this and I also have mixed feelings in general, but very concrete for Option (somehow "value/null") or Result (somehow "value/Exception"), I'm in favour of this proposal.

@vzarytovskii
Copy link

vzarytovskii commented Sep 21, 2021

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.

@vshapenko
Copy link

vshapenko commented Sep 21, 2021

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

@Szer
Copy link

Szer commented Sep 21, 2021

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

@SchlenkR
Copy link
Author

SchlenkR commented Sep 21, 2021

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.

I think it is a bad idea because people usually try to make safer code with single case union

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.

@voronoipotato
Copy link

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.

@SchlenkR
Copy link
Author

Couldn't you just use an active pattern if you don't want to specify it?

@voronoipotato Could it be possible to clarify this with an example from a call site perspective?

@Happypig375
Copy link
Contributor

Just use anonymous unions when they are available.

@voronoipotato
Copy link

voronoipotato commented Sep 22, 2021

show (Some 3) in your example you're not going to be able to avoid Some/None because you have to pass something into your function or else it's not a function. So even with the implicit conversion you'd still need to use Some or None, otherwise we end up in implicit Nones which are the same danger as implicit nulls. I don't think it's very useful, and I think there's dangers of "oops I forgot about this implicit conversion". Implicits are nearly always more trouble than they're worth. In any case where an implicit would be super useful, you can usually write a function that fills out the multiple steps with let create thing = This (That (Other thing)) . I won't say there aren't exceptions to this general rule, but they are exceptions to the rule, and not a norm.

@SchlenkR
Copy link
Author

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: Generic type arguments may not be used as naked in erased unions.. Using ('a | Nothing) or (obj | Nothing) wouldn't work, since obj also contains Nothing, so they are not disjoint.

@Happypig375 Or did you have something else in mind?

@Happypig375
Copy link
Contributor

Then why not do a :? Nothing test on obj? You can certainly pass everything into your function including Nothing.

@Happypig375
Copy link
Contributor

FS-1093 gives you implicit upcasts to obj.

@SchlenkR
Copy link
Author

SchlenkR commented Sep 22, 2021

Then why not do a :? Nothing test on 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.

@voronoipotato
Copy link

Wouldn't the implicit effectively also lose your safety given that you can bypass any casting to a DU type?

@charlesroddie
Copy link

charlesroddie commented Sep 22, 2021

add a feature that constructs a DU value without explicitly specifying the discriminator.

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?

@SchlenkR
Copy link
Author

Wouldn't the implicit effectively also lose your safety given that you can bypass any casting to a DU type?

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. 'a or obj), then: Yes on call site. But the effective value itself would be a case of a DU, and in case of e.g. Option, the user has to deal with that value explicitly, and could never run in some unintended cases that could have happened without DUs. Does this answer the question, @voronoipotato ? Just in case I didn't understand the stamenent correctly, here are some more examples that might help clarifying:

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 Some and "not a value" by None, the information from just reading the code at call-site is reduced (in general, this can also be the case for type inference). Looking at the definition site, nothing changes as we do it now.

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 obj, since there are cases that don't compile when the passed value isn't an instance of the default-cases payload type.

@Happypig375
Copy link
Contributor

Example 1: ('a|Nothing) can be simplified to obj and you can use XML documentation to instruct the use of Nothing. Since you can't see "Nothing" in the type definition of the function anyways (you have to go into the type definition of the DU by F12 or similar), you can just as well use XML documentation.

Example 2: Easy use of ((int * string)|Nothing).

@SchlenkR
Copy link
Author

Example 1: ('a|Nothing) can be simplified to obj and you can use XML documentation to instruct the use of Nothing

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.

@SchlenkR
Copy link
Author

SchlenkR commented Oct 2, 2021

...just in case anybody stumbles across this closed issue, there's a talk from Rich Hickey: "Maybe Not":

Yesterday: f : 'a -> 'b
Now f : 'a option -> 'b

Rich says (about 8:00 to 9:30):

This is an easing of requirements, and [...] it should be a compatible change (I think).

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

@voronoipotato
Copy link

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.

@SchlenkR
Copy link
Author

SchlenkR commented Oct 2, 2021

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.

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.

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.

there are also differences both philosophically and practically

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

@voronoipotato
Copy link

voronoipotato commented Oct 2, 2021

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.

@seanamos
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests