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

Generate F# 9-style Is* property for single case discriminated union #1394

Open
6 tasks done
tw0po1nt opened this issue Nov 27, 2024 · 11 comments
Open
6 tasks done

Generate F# 9-style Is* property for single case discriminated union #1394

tw0po1nt opened this issue Nov 27, 2024 · 11 comments

Comments

@tw0po1nt
Copy link

I propose we generate an Is* boolean property for single case unions, as F# 9 does today for multi-case unions.

module Types

open System

type AssetType = Image // Intention is to add to this definition in the future as asset types are needed

type Asset =
  { Type : AssetType 
    SourceURL : Uri }

// I want to write a function like this, such that even if new cases are added in the future, this function gives me only the ones of type `Image`
let onlyImages assets =
  assets
  |> List.filter _.Type.IsImage // as is, this line errors with: 'The type 'AssetType' does not define the field, constructor, or member 'IsImage'. Maybe you want one of the following: Image'

The existing way of approaching this problem in F# is to add a throwaway associated value and pattern match, like so:

type AssetType = Image of int // could be any type, in theory

let onlyImages assets =
  assets
  |> List.filter (fun ({ Type = (Image _) }) -> true)

Pros and Cons

The advantages of making this adjustment to F# are that the property is generated for all cases of a DU, not just when there are multiple. While in theory the Is property is redundant for that case since it is guaranteed to be true, this enhancement improves the behavior for "planning for future cases" by allowing to use the same syntax, instead of having to do a hacky workaround just for the single case scenario

The disadvantages of making this adjustment to F# are that perhaps this is a small thing and maybe the juice isn't worth the squeeze just for this extra consistency?

Extra information

Estimated cost (XS, S, M, L, XL, XXL): S to M

Affidavit (please submit!)

Please tick these items 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
  • This is a language change and not purely a tooling change (e.g. compiler bug, editor support, warning/error messages, new warning, non-breaking optimisation) belonging to the compiler and tooling repository
  • 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
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate

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.

@brianrourkeboll
Copy link

Not sure how I feel about this.

One odd thing is that you can't polyfill it yourself if you want:

type U =
    | A
    member this.IsA = match this with A -> true
> type U =
-     | A
-     member this.IsA = match this with A -> true;;

      member this.IsA = match this with A -> true;;
  ----------------^^^

stdin(3,17): error FS0023: The member 'IsA' cannot be defined because the name 'IsA' clashes with the default augmentation of the union case 'A' in this type or module

I wonder if that is by design.

@brianrourkeboll
Copy link

It looks like the original implementation emitted this, and it was later changed not to: fsharp/fslang-design#517 (comment)

The RFC itself does not mention it: https://github.com/fsharp/fslang-design/blob/3a46bc9342f4c2c38d09617b4d95909697e7b9d6/RFCs/FS-1079-union-properties-visible.md

@tw0po1nt
Copy link
Author

Interesting. Doesn't seem like there was a ton of discussion involved - just "this feels weird" and then it gets removed. I think we should revisit that. Having it missing for single case unions feels like a "gotcha".

But that is also good news in that the exact place to implement this is straightforward.

@brianrourkeboll
Copy link

just "this feels weird" and then it gets removed.

To be fair, @T-Gro did mention the main reason why:

it increased the surface area of IL-exposed members since those were not generated before either (testers for single-case unions).

and, in dotnet/fsharp#16571,

[the RFC was originally about] exposing those members to F# as well, not changing codegen.

I.e., the F# compiler already did generate Is* properties for multi-case unions before F# 9; they just weren't visible from F#. Hence the RFC title: "Make .Is* discriminated union properties visible from F#."

But the compiler did not generate an Is* property for single-case unions before F# 9, so making it do so is somewhat a separate question from exposing the existing generated properties to F#.

@tw0po1nt
Copy link
Author

Yes, that makes sense.

Then this could probably be added as a con to implementing this.

increased the surface area of IL-exposed members since those were not generated before

I do think the consistency it would bring would be worth the effort.

@T-Gro
Copy link

T-Gro commented Nov 28, 2024

Not generating the warning in the described case is doable, that was definitely an oversight and mismatch in codegen vs. warning.
i.e. changing the warning to allow defining your own .Is* member for single-case DU. As a workaround, a any other name can be used of course.

I must admit I do not fully see the motivation for practical F# programs.
When extending a single-case DU to >1 cases, warnings/errors are produced for code working with it, such as function arguments or various forms of pattern matching. Therefore making it clear which pieces of business logic must be changed for the newly added cases.

 let workWithImage Image = 42
 let workWithImages imgList =
     match imgList with
     | Image :: _rest = 42
     | [] -> 2112

@charlesroddie
Copy link

Regularity is good so this should be added.
Use-cases niche, e.g.

// This code is OK and remains OK when more shape cases are added.
type Shape = | Rectangle of Dimensions
let countRectangles(shapes: Shape list) =
    shapes |> List.countWhere(s -> s.IsRectangle)

But you don't need big use cases to justify making the langauge more regular. Even if there were no use case it should be there!

@T-Gro
Copy link

T-Gro commented Nov 28, 2024

If you would have hand-written logic for .IsRectangle, you could amend it if/once future extension arrives.
Let's say the next ones to be added will be Circle and Square, following most OO teaching materials.

I am the kind of person who would rather get a warning and adjust logic to new requirements case by case. Rather then having no new warnings, but wrong business logic caused by pre-existing usage of .IsRectangle . (because once new requirements were added, counting rectangles might had to include squares as well).

But you are right that this equally applies to any number of cases, not only size=1. Usage for size=1 just makes it apparent that the intention is to prepare for unknown future/possible requirements, which I guess is the part which feels wrong about this.

@Martin521
Copy link

Usage for size=1 just makes it apparent that the intention is to prepare for unknown future/possible requirements, which I guess is the part which feels wrong about this.

Yes. YAGNI.

@charlesroddie
Copy link

charlesroddie commented Nov 28, 2024

But you are right that this equally applies to any number of cases, not only size=1. Usage for size=1 just makes it apparent that the intention is to prepare for unknown future/possible requirements, which I guess is the part which feels wrong about this.

Yes it does make that apparent, as it should as this is a definition of a DU aka discriminated union. So it feels wrong that the discrimination scaffolding isn't there, including IsX.

Yes. YAGNI.

And yes to YAGNI - sometimes discriminated unions shouldn't be used but instead the underlying data type or a simpler non-discriminated non-union wrapper used, for situations in which it's not expected that cases will be added. But this doesn't mean that it's never useful to start with the possibility of discrimination and so there shouldn't be a restriction on DUs that they should have at least 2 cases. E.g. I might know I am going to write more Shapes, and I might want my serialization/deserialization of Shapes to conform to a DU structure so that it doesn't change when adding more cases.

Given that we are not going to ban DUs with one case, ensuring that they are treated as DUs and not something special is important for consistency.

@tw0po1nt
Copy link
Author

Even if there were no use case it should be there!

Disagree here, but I think you put it well:

Given that we are not going to ban DUs with one case, ensuring that they are treated as DUs and not something special is important for consistency

Some additional context might be helpful - I am a relative newcomer to F#, I've only just recently started writing meaningful amounts of F# code. When I saw the Is* feature as added in F# 9, I immediately had an intuition as to how it should behave based on how the feature was advertised. Namely:

Previously, you had to write something like:

let canSendEmailTo person =
    match person.contact with
    | Email _ -> true
    | _ -> false

Now, you can instead write:

let canSendEmailTo person =
    person.contact.IsEmail

Nothing in this presentation makes me think "instead you can write this, but only in the case of a DU with at least 2 cases"

It's a small inconsistency, sure. It's also one that, if fixed, arguably might not necessarily benefit a vast number of use cases.

But it's an inconsistency nonetheless. And in my opinion, small inconsistencies are the ones that are the most off-putting for me as a user of a language.

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

5 participants