fslang-suggestions icon indicating copy to clipboard operation
fslang-suggestions copied to clipboard

Support 'without' for Anonymous Records

Open cartermp opened this issue 6 years ago • 58 comments

I propose we support expressions of the following:

let a = {| X = 1; Y = 2; Z = 3|}
let a' = {| a without Y |} // {| X = 1; Z = 3 |}

That is, being able to construct a new anonymous record that is a subset of another one.

The existing way of approaching this problem in F# is to manually construct a':

let a = {| X = 1; Y = 2; Z = 3|}
let a' = {| X = a.X; Z = a.Z |} // {| X = 1; Z = 3 |}

Pros and Cons

The advantages of making this adjustment to F# are:

  • Less code involved to create a subset
  • This sort of "drop a row"-style programming would be familiar to those using Python and Pandas for data science-y tasks

The disadvantages of making this adjustment to F# are :

  • Anonymous Records can do even more than normal records, making the "smooth path to nominalization" less of a goal for these types.

Extra information

Here's what the RFC says about this:

Supporting "smooth nominalization" means we need to carefully consider whether features such as these allowed:

  • removing fields from anonymous records { x without A }
  • adding fields to anonymous records { x with A = 1 }
  • unioning anonymous records { include x; include y }

These should be included if and only if they are also implemented for nominal record types. Further, their use makes the cost of nominalization higher, because F# nominal record types do not support the above features - even { x with A=1 } is restricted to create objects of the same type as the original x, and thus multiple nominal types will be needed where this construct is used.

However, Anonymous Records already support {| x with SomethingElse = foo |} to construct a new AR that has more fields than the one it was constructed from. This means that the middle point is already sort of violated, since you cannot reproduce this with record types.

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

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

cartermp avatar Jul 01 '19 18:07 cartermp

Would you be able to chain multiple withouts, as in something akin to this?

{| a without Foo without Bar |}

jwosty avatar Jul 01 '19 18:07 jwosty

I would expect a semicolon-delimited list of labels, sort of an inverse to what you can do today:

let a = {| X = 1; Y = 2; Z = 3|}
let b = {| a with A = 3; B = 4; C = 12 |}

So it would probably look like:

let _ = {| a without Foo; Bar |}

cartermp avatar Jul 01 '19 18:07 cartermp

What are your thoughts on allowing combining with and without?

{| a without Foo; Bar
     with Baz = ... |}

jwosty avatar Jul 01 '19 18:07 jwosty

It might be worth noting that in Elm:

Pretty much no one ever used field addition or deletion. In the few cases where people did use it, it got pretty crazy pretty quickly.

7sharp9 avatar Jul 01 '19 20:07 7sharp9

RE: https://elm-lang.org/blog/compilers-as-assistants#simplified-records

The syntax in Elm was actually really nice and intuitive.

7sharp9 avatar Jul 01 '19 20:07 7sharp9

@jwosty I think combining would probably be out of scope - works for simple stuff, but there'd have to be some rule for determining what subsequent with or withouts applied to - just the base a, or the result of a without ... or a with ...?

cartermp avatar Jul 02 '19 05:07 cartermp

I think I'm basically OK with this - it is true that it is no worse than {| x with A = 1 |} and aligned with it.

Note this would make without a reserved keyword. I'm ok with that if under a /langversion switch but we should be aware of it, and it would be the first such new keyword we've introduced via /langversion

dsyme avatar Jul 02 '19 11:07 dsyme

Just to clarify, for backwards compat, would we still compile something like this?

let without x = ()

without 12

cartermp avatar Jul 02 '19 14:07 cartermp

to the extreme:

let y = {| x without without |}

without as a keyword would be highly contextual, so I guess it would not interfere with most parts?

yatli avatar Jul 02 '19 14:07 yatli

Correct, I can't think of a way for it to collide unless we explicitly took a breaking change here.

cartermp avatar Jul 02 '19 14:07 cartermp

Don't be shy :)

let without = {| with="or"; without="you" |}
let with = {| without without without |}

dsyme avatar Jul 03 '19 09:07 dsyme

I was going to propose with out in 2 words, but then I realized that out isn't actually a keyword in F#, so it's no better than without 😄

More seriously, we don't have contextual keywords yet, do we? This feels like too minor a feature to introduce such a major concept.

Tarmil avatar Jul 03 '19 12:07 Tarmil

@cartermp @dsyme Is there any reason we couldn't {| thing not with whatever |}? This would avoid the keyword issue and, quite possibly, make just as much sense.

EBrown8534 avatar Jul 03 '19 15:07 EBrown8534

perhaps because not is a function? If thing is a functor then it's ambiguous.

yatli avatar Jul 03 '19 15:07 yatli

I guess with- would work, as a new keyword (in the language with is never followed by - today)

{| a with- Foo; Bar
     with Baz = ... |}

dsyme avatar Jul 03 '19 16:07 dsyme

Would there be an issue with a contexual keyword like this? I don't think it's too crazy. Generally I don't think I'd like with-; we don't really have a precedent for tagging - or + onto letters or words (like Scala or OCaml's co/contravariance)

cartermp avatar Jul 03 '19 18:07 cartermp

I think with- looks confusing, you could end up with it on a pattern match to inverse the matches for consistency.

7sharp9 avatar Jul 03 '19 21:07 7sharp9

Would there be an issue with a contexual keyword like this? I don't think it's too crazy. Generally I don't think I'd like with-; we don't really have a precedent for tagging - or + onto letters or words (like Scala or OCaml's co/contravariance)

It feels hard TBH

dsyme avatar Jul 04 '19 13:07 dsyme

I'm little with- simmer a bit and it doesn't seem too bad.

cartermp avatar Jul 04 '19 22:07 cartermp

https://github.com/fsharp/fslang-design/pull/370

cartermp avatar Jul 05 '19 04:07 cartermp

perhaps this would fly, though it's a bit weird too:

{| a with not Foo; not Bar; Baz = ... |}

or

{| a with -Foo; -Bar; Baz = ... |}

or for the unix heads (joke)

{| a with rm Foo; rm Bar; Baz = ... |}

dsyme avatar Jul 08 '19 11:07 dsyme

Can someone describe why this is needed? What are the applications or existing use-cases / scenarios?

matthid avatar Jul 08 '19 11:07 matthid

Can someone describe why this is needed? What are the applications or existing use-cases / scenarios?

I think the OP explains why - basically, if you can create a new anonymous with extra fields, it seems reasonable to also support removing a field.

I don't think it's crucial at all, it's just a reasonable completion of the anon record feature within the criteria of the original RFC.

dsyme avatar Jul 08 '19 12:07 dsyme

Yes I understand what the feature should do. I'm asking because you could also "solve" this another way: Why not allow Anonymous Records at places where fewer fields are required?

let f {| X = x |} = x
f {| X = 1; Y = 2 |} // 1

This obviously needs to solve how we can represent this in IL and also some other corner cases, but it would remove the need to explicitly remove fields.

However, for this to be useful you need to know what the feature use-cases are (which hasn't been answered)

matthid avatar Jul 08 '19 12:07 matthid

Im all for more record row polymorphism type features, it is really curious that the feature was almost never used in Elm though.

7sharp9 avatar Jul 08 '19 13:07 7sharp9

@matthid This was filed as a suggestion after speaking with @rickasaurus and his team at an F# meetup. The "drop a column" use case is applicable to any scenario where you'd do the same with Pandas/Python. It is also a dual to the "add a column" functionality you can do now with no apparent downside aside from just being another thing you can learn.

Your comment - effectively structural subtyping - was also brought up as highly relevant to that space, but it's explicitly called out as a non-goal here: https://github.com/fsharp/fslang-design/blob/master/FSharp-4.6/FS-1030-anonymous-records.md#design-principle-no-structural-subtyping

So the issues listed in the RFC would have to be resolved for that to progress.

Though the two suggestions are sort of related, but the ability to "drop a column and move on" seems distinct enough from structural subtyping to warrant its own issue.

cartermp avatar Jul 08 '19 15:07 cartermp

Though the two suggestions are sort of related, but the ability to "drop a column and move on" seems distinct enough from structural subtyping to warrant its own issue.

Yes, drop-a-column may well get used in places where there is no strongly-typed consumer with the new set of columns made explicit.

@cartermp One criticism of these features might be that these operations have no corresponding things in the type algebra, e.g. given type R = {| X:int |} you can't write {| R with Y:int |} nor {| R with rm X |} (equivalent to {| X:int Y:int |} and {| |} respectively). But I don't think that really matters, just mentioning it for completeness

dsyme avatar Jul 08 '19 15:07 dsyme

Though the two suggestions are sort of related, but the ability to "drop a column and move on" seems distinct enough from structural subtyping to warrant its own issue.

Not necessarily. One could think of a implementation where structural subtyping is implemented by the compiler initializing a new instance. In this scenario there is no "need" for "remove" as they would be gone for reflection as well. Note that I'm not saying that I like that particular suggestion of mine. Just tried to understand the reasoning behind this original suggestion better. Thanks.

matthid avatar Jul 08 '19 15:07 matthid

@dsyme as I was writing the RFC I was actually wondering if it was worth exploring that kind of type algebra for normal records 🙂. Decided not to write it out.

@matthid

Not necessarily. One could think of a implementation where structural subtyping is implemented by the compiler initializing a new instance. In this scenario there is no "need" for "remove" as they would be gone for reflection as well.

Sure, the compiler could employ whatever tricks it needs, but that doesn't guarantee that the programmer also understands that the compiler is doing this for them. It also requires type annotations wherever you use it to make it clear that you're working with a subset. A "drop a column" approach still ties you to the nominal representation, but it's extremely clear that you've got a subset after constructing one.

cartermp avatar Jul 08 '19 15:07 cartermp

@matthid FWIW to a newbie like myself, the "drop a column" approach makes the most sense.

In my opinion, there is absolutely no situation where I should accept a {| X : int |} and be able to pass a {| X : int; Y : int |} strictly because the X is no longer the same. In the context of Y it has a new meaning. I should have to do {| thing with -Y |} or {| X = thing.X |}. It means I decided how to do the conversion.

I think the suggestion of structural subtyping falls too close to implicit conversions -- F# doesn't have implicit conversions, and in my eyes these two are one-and-the-same.

EBrown8534 avatar Jul 12 '19 20:07 EBrown8534