fsharp / fslang-design

RFCs and docs related to the F# language design process, see https://github.com/fsharp/fslang-suggestions to submit ideas
515 stars 144 forks source link

[RFC FS-1030] Discussion: Anonymous record types #170

Closed dsyme closed 5 years ago

dsyme commented 7 years ago

Discussion for https://github.com/fsharp/fslang-design/blob/master/FSharp-4.6/FS-1030-anonymous-records.md

gusty commented 7 years ago

@isaacabraham Yes, that's exactly what I said

It's true however that given that this notation was not available for tuples, for existing code with boolean comparisons that doesn't have yet the additional parenthesis, it would be a breaking change. But it would be aligned now with the method calling syntax.

isaacabraham commented 7 years ago

Apologies. Must've missed that.

vasily-kirichenko commented 7 years ago

What about using {| ; ; |} for Kind B and ( ; ; ) for Kind A?

let record = {| Kind = "B"; Syntax = "Awesome" |}
let tuple = (Kind = "A"; Syntax = "Awesome awesomeness")

Pros:

Cons:

However, frankly, I don't see why we are adding Kind A at all.

isaacabraham commented 7 years ago

Kind A has some uses to me - ability to share data across assembly boundaries without formally specifying a type, plus the ability to give tuple elements names does have some utility - although we've survived without it so far! I would prefer to keep the syntax as commas though - mixing and matching ( ) { } and , and ; sounds like a recipe for confusion when switching between types (I already do it when going from tuples to records and back again).

However, overall I agree - Kind B is growing on me as the "more useful" of the two. Usually the need for such elements is relatively short-lived and / or used for reflection-based work e.g. outputs in ASP .NET for JSON serialization etc., but not necessarily needed where you want a specific named contract between two systems.

dsyme commented 7 years ago

I find the arguments against including Kind A types (except in some separate interop mechanism) quite persuasive. From a purely ML-family language design POV they are the "right" thing - Standard ML has such types, and OCaml object types are structural, and TBH it wouldn't even occur to a typical ML-family language designer to make these implicitly nominal and assembly-bound. This more than anything else is why we might include them - from a certain perspective, they are "right". But in the context of applied F# programming the questions of reflection and runtime type information are really important.

Note that Kind B types may be consumed in other assemblies, e.g. if one assembly provides a list of Kind B values, then from another assembly you can iterate those values and access their contents. This means that, unlike C# anonymous types, the implied nominal types are public in the resulting assemblies.

You may not, however, under the current proposal, create new values of that type, nor write the type itself. They are, in the current proposal, undenotable in other assemblies - the dreaded "type that may not be named".

One could also add an ability to refer to an anonymous record type from another assembly. The exact syntax would probably be a bit odd, something like (| assembly A; X = 1; Y = 2 |}. The anonymous type must then of course actually exist in that assembly.

[ p.s. I fundamentally don't like "types that may not be named" - to my knowledge there is really only one such type (actually a type constraint) in the F# system at the moment, and I'll buy a beer for the first person who names it :) ]

drvink commented 7 years ago

@dsyme would those be the "generated" constraints that appear by compiler diagnostics (the ^?1234 sort)?

dsyme commented 7 years ago

@dsyme would those be the "generated" constraints that appear by compiler diagnostics (the ^?1234 sort)?

That's not what I had in mind - those are more like bugs - but yes I can see why you mentioned those :)

dsyme commented 7 years ago

I wrote

From a purely ML-family language design POV they are the "right" thing - Standard ML has such types, and OCaml object types are structurally-named, and TBH it wouldn't even occur to a typical ML-family language designer to make these implicitly nominal and assembly-bound.

I should add that, methodologically, I can't recall using the structurally-named capabilities of Standard ML record types and OCaml object types when I was programming in those languages. Some APIs I programmed against used such types, though in practice those record types could have been nominal, defined in the same component where the API occurred.

isaacabraham commented 7 years ago

Whilst we're discussing structural types - I assume that although that's been mentioned earlier in this thread (or one of the other links, somewhere), that's not really going to be a goal of this RFC i.e. the ability the write some code that can operate over two distinct nominal types that have the exact the same shape (or partial shape)?

dsyme commented 7 years ago

@isaacabraham That's correct, see

https://github.com/fsharp/fslang-design/blob/master/RFCs/FS-1030-anonymous-records.md#design-principle-no-row-polymorphism

isaacabraham commented 7 years ago

That makes sense - there was another issue floating about regarding making static member constraints a bit easier to do - I'll dig that one out as that's basically what I'm getting at.

dsyme commented 7 years ago

@isaacabraham @eiriktsarpalis @smoothdeveloper @gusty @sgoguen

See PR https://github.com/fsharp/fslang-design/pull/182 which removes Kind A anonymous records from the RFC, and removes the use of new for Kind B anonymous records. I've updated the implementation to match

dsyme commented 7 years ago

It's interesting to note that this feature gives a way of encoding significantly more structural type data in attributes anonymously:

[<Row(typeof<{| Field1: int; Field2 : string |}>)>}
type SomeClass() = 
   member x.P = 1

That may have various uses

dsyme commented 7 years ago

Likewise in generic arguments:

reader.ReadFileAsShape< {| Field1: int;  Field2: string |} >(...)

So yet another way to specify schema in F#.

eiriktsarpalis commented 7 years ago

@dsyme That's pretty cool actually. I imagine though this would only be practical for trivially sized schemata.

eiriktsarpalis commented 7 years ago

In asp.net:

member __.Endpoint([<FromBody>]request : {| x : string ; y : int |}) = ...
dsyme commented 7 years ago

Could you use this with reflection to construct such types dynamically (instead of formally declaring a type) e.g. instead of a TP?

Yes. It's identical to this:

type SomeType =  { Name : string; Age : int }
let json = """{ Name = "Isaac"; Age = 21 }"""
let typedData = parseJson<SomeType>(untypedData)

just oddly much less clumsy, at least for short schema

dsyme commented 7 years ago

Re this:

... to my knowledge there is really only one such "type that can't be written" in the F# system at the moment. Actually a type constraint. I'll buy a beer for the first person who names it :)...

Hint it's a constraint, only arises in let inline f x = ..., with very specific language construct in f

dsyme commented 7 years ago

Evgeniy Andreev wins the F# puzzle I set you all :) https://twitter.com/gsomix/status/845352453860216833

eiriktsarpalis commented 7 years ago

@dsyme would it make sense for this to also support some form of pattern matching? E.g.

let {| x = x |} = {| x = 2 ; y = "" |}
dsyme commented 7 years ago

@dsyme would it make sense for this to also support some form of pattern matching?

Certainly technically, yes. Though personally I'm no fan of record pattern matching for code readability or maintainability

I'm also not a fan of the "matches can have fewer fields" effect that happens at record matching (as in the example above). Technically, I don't think this would "drop out" with a basic implementation, since the two implied anon record types are different, and we would need to add row variables to correlate the two record types. I also find that loss of information odd from the point of view of the F# language design goals, where pattern matching is otherwise quite a "rigid" construct, forcing you to make changes as information is added/removed from structural types.

So I'm minded to just not allow anon record pattern matching and ask people to nominalize if they want record pattern matching. But alternative view points welcome.

ChrisBallard commented 7 years ago

Stepping into this discussion quite late, but just wanted to highlight a use case which is certainly inferred in the prior discussion, if not specifically mentioned, and that is the use of named tuples as a means for simply improving code clarity.

Whilst I'm a big fan of tuples, I often find a proliferation of tuples like string * string * int and need to walk the chain a bit to work out if this is first name; surname; age or surname; first name; age - being able to hover over a function call, for example, and see {| FirstName: string; Surname: string; Age: int |} would be a big win for improving code maintainability.

bikallem commented 7 years ago

@dsyme Is anonymous record supported in discriminated union? ,e.g.

type t = Foo of {| Field1: int; Field2 : string |}

dsyme commented 7 years ago

@dsyme Is anonymous record supported in discriminated union? ,e.g. type t = Foo of {| Field1: int; Field2 : string |}

Yes. Though maybe it doesn't give quite what you expect, e.g.

    match t with 
    | Foo data -> data.Field1

rather than

    match t with 
    | Foo {| Field1 = a; Field2 = b |} -> ...

See comment above on pattern matching too

eiriktsarpalis commented 7 years ago

@dsyme Probably a bit late to this discussion, but do you think it might make sense to add some kind of support for set-theoretic operations on anonymous records? As an example, consider the records:

type Foo = { A : int ; B : string }
type Bar = {| B : string ; C : bool |}

Then we can obtain Foo ∪ Bar as {| A : int ; B : string ; C : bool |} and Foo ∩ Bar as {| B : string |}.

A conservative approach at incorporating such functionality is adding support for extending records with additional fields:

let foo = { A = 42 ; B = "foo" }
{| foo with AppendedField = 123 |}

which would give back an anonymous record of type {| A : int ; B : string : AppendedField : int |}. This would be an immensely useful addition for cheaply augmenting large records with additional data.

A more exotic addition would be to allow expressions of the form

let foo = { A = 42 ; B = "foo" }
let bar = {| B = "bar" ; C = true |}

{| foo | bar |} : {| A : int ; B : string ; C : bool |}
{| foo & bar |} : {| B : string |}

NB the union and intersection operators are necessarily noncommutative and ordering could either alter the type signature or result on a type error, depending on how labels with conflicting types are handled.

EDIT: apologies, just noticed this has already been addressed in the design doc.

isaacabraham commented 7 years ago

I really like the sound of this idea from @eiriktsarpalis, although I'm not entirely sure what the full implications are, or where exactly this would be useful in real-world situations - but it looks like a simple yet powerful way to cheaply compose record types together.

dsyme commented 7 years ago

@eiriktsarpalis Yes, that is mentioned in the RFC, under https://github.com/fsharp/fslang-design/blob/master/RFCs/FS-1030-anonymous-records.md#design-principle-a-smooth-path-to-nominalization

However I am not totally convinced by my own argument there, and agree with what you write here:

This would be an immensely useful addition for cheaply augmenting large records with additional data.

Arguably the code can still be nominalized perfectly easily - you have to make multiple types and expand out some with constructs.

But doing what you say is just so useful in many data scripting scenarios where the operations augment the information available.

ReedCopsey commented 7 years ago

So yet another way to specify schema in F#.

@dsyme I have yet another valuable (at least for me) use case for this - related to the schema comment, but this allows more in this case. It's a combination of exposing schema and "one off data" concisely.

I've been modernizing one of my UI libraries, and this provides a very nice mechanism for "UI related schema", which would simplify the usage quite a bit.

A simple example is here: https://github.com/ReedCopsey/Gjallarhorn/blob/e4fe2532da0c08d52d7ea2e35ff198188cea0ca0/samples/FrameworkSimpleForm/Core/Program.fs#L37-L46

I'm creating that type solely for the purpose of making the d binding right below. That gets used to supply design time data for the UI (XAML platforms) as well as allowing type safety in the bindings below, but a "real type" isn't needed other than being reflected on by the xaml designer and usable below. As far as I can tell from the spec and current implementation, this should serve perfectly, which would completely eliminate the need for the "view model" type specifications.

dsyme commented 7 years ago

@ReedCopsey Is there a need to use custom attributes on the record elements on that case? The current proposal for anon records doesn't allow custom attributes.

ReedCopsey commented 7 years ago

@dsyme there shouldn't be. My understanding from current spec is that the type is already mutable (to match c# anonymous classes), which eliminates that need. I only need the climutable to keep tye xaml designer from complaining - a 2 way binding to a read only prop gives warnings. No other attributes are needed.

0x53A commented 7 years ago

[...] that the type is already mutable (to match c# anonymous classes) [...]

Afaik C# anon classes are immutable:

image

C# Tuples (System.ValueTuple) are mutable for some reason.

ReedCopsey commented 7 years ago

Oh well. Even without that, its useful in quite a few cases, just not all. Still provides benefits in those scenarios.

dsyme commented 7 years ago

@ReedCopsey In the RFC the proposal is that they are immutable but marked CLIMutable. See https://github.com/fsharp/fslang-design/blob/master/RFCs/FS-1030-anonymous-records.md#design-principle-not-anonymous-object-expressions. One hard thing in finalizing this is working out if we should lift any of those restrictions

ReedCopsey commented 7 years ago

@dsyme Yes, that was the source of my confusion. Marking CLIMutable normally adds setters on the props, which I was assuming they'd have (before) because of that note in the spec. (ie: from https://msdn.microsoft.com/en-us/visualfsharpdocs/conceptual/core.climutableattribute-class-%5Bfsharp%5D - makes records have "a default constructor with property getters and setters.") I'm assuming you intended for this to be "half CLIMutable" in that it'd have a default ctor, but no setters?

In my scenario, the "full CLIMutable" capability would be very helpful, as it "shuts up" the XAML designers when you use them for bindings. Being readonly like C# would still be useful in many situations - but it probably makes it usable in 30% of the cases instead of 100% -and a custom type would just have to be made in the others. Not horrible (that's how I wrote it to use today), but just a lot less convenient, as it's a "one off" type being created.

0x53A commented 7 years ago

My comments on the RFC are mostly based on the meta-programming scenario, where a large part is C# interoperability.

I'm also a big fan of the "better tuples" scenario, (int*int*string*string*int) gets old really fast.

1)

Anonymous record types types have full C#-compatible anonymous object metadata. [...] These types are CLIMutable and thus C#-compatible. [...]

C# anonymous records are immutable, both from the language pov and from the metadata. They have one constructor, which takes all values and expose getter-only properties.

CLIMutable may break some (badly written) methods that expect exactly C# anons, e.g. which expect exactly one constructor, but should otherwise not be an issue for type-2.

But it smells a little bit from a language pov imo - the objects would be immutable in F#, but mutable in C#, so if I pass an object back from a method, the calling C# code could mutate the object (and wouldn't even realise they violate an implicit contract, because from their POV it is mutable).

I would prefer it if they were strictly immutable by default, but you could place attributes on the types. Example:

{| [<type:CLIMutable>] X: int |}.

This would be a different type than {| X : int |}, but maybe we could allow implicit conversions.

2)

Fields are placed in a canonical order by the compiler, so type {| A : int; B : int |} is type-equivalent to {| B: int; A : int |}.

In some type-2 scenarios, the order is relevant. For example I like to use anon-types in C# in combination with Dapper, and there the order of the constructor parameters must match the order of the selects in the sql. Here is an example in C#: https://gist.github.com/0x53A/aa83039842364a1621701632920c08cd

3) (back at the end)

this is IL generated for C# code containing this expression:

For clarity, I think it may be more useful to decompile it back to C#. Most people in this thread can probably read IL, but here is the raw version, and a cleaned up version: https://gist.github.com/0x53A/4c29daa3ac8a2cbbad58fc6c29e5f3ac

Question: should it be possible to access anon-records by their compiled names in F#?

I can access C# anon-records by name if I add an internals-visble-to:

image

forki commented 6 years ago

In Fable's JavaScript interop we often create Plain old JavaScript objects objects like this:

    createObj
        [ "style" ==> keyValueList CaseRules.LowerFirst props
          "ref" ==> ref.SetRef
          "onSaveEvent" ==> onSaveEvent ]
    |> unbox

would the new syntax allow us to write it as:

    {| style = keyValueList CaseRules.LowerFirst props
       ref = ref.SetRef
       onSaveEvent = onSaveEvent |}

?

dsyme commented 6 years ago

@forki I don't see why not, if the Fable compiler decides to universally translate the FCS representation of "new anonymous record" to that form

forki commented 6 years ago

@dsyme my question was more along the lines: if it does, then please please please give it to us ;-)

If not then we should try to make it work. Because improved JS Interop in fable would be pretty awesome.

matthid commented 6 years ago

Is that a good idea?

forki commented 6 years ago

is what good idea?

matthid commented 6 years ago

I mean anonymous types are stillfully statically typed and reflectable. in fable they wouldn't be if you decide to use this language feature for that...

forki commented 6 years ago

oh. we just want nicer syntax. the stuff is usually only used very locally to pass it to a js API

matthid commented 6 years ago

Yes I mean i honestly don't know if it is a good idea to have different semantics between .net and fable.

I feel like it is here, but I just was trying to be a sceptical voice here

dsyme commented 6 years ago

I mean anonymous types are stillfully statically typed

They would still be fully statically typed, since that is up the the F# compiler front end which Fable uses

Yes I mean i honestly don't know if it is a good idea to have different semantics between .net and fable.

My impression is that there are semantic differences already, especially around reflection

forki commented 6 years ago

what semantics!? in fable everything becomes Pojo anyway. we unbox like crazy in the libs anyway

isaacabraham commented 6 years ago

In Fable at runtime all bets are off anyway - lots of stuff might behave differently. Even option types behave differently with null etc. to aid interop.

matthid commented 6 years ago

My impression is that there are semantic differences already, especially around reflection

Yes, I guess all I was trying to say is that such a decision should be made explicitly rather than by accident.

matthid commented 6 years ago

In Fable at runtime all bets are off anyway

Well that is quite a hard statement. Maybe we shouldn't call it F# if that is the case (which is the hard response to that I guess)

forki commented 6 years ago

@matthid it works really well ;-)

Am 06.10.2017 17:33 schrieb "Matthias Dittrich" notifications@github.com:

In Fable at runtime all bets are off anyway

Well that is quite a hard statement. Maybe we shouldn't call it F# if that is the case (which is the hard response to that I guess)

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/fsharp/fslang-design/issues/170#issuecomment-334789958, or mute the thread https://github.com/notifications/unsubscribe-auth/AADgNKHyq9_rC-lmxZdoEVk5G5QKqoDGks5spkhTgaJpZM4MXR94 .

isaacabraham commented 6 years ago

@matthid by the same token, units of measure are a trick since they are erased away at runtime, too (as are probably 95% of type providers out there).

They work great for me. In fact, one of the best things about UoM is that they can be added in retrospectively with very little concern of breaking changes at runtime e.g. performance degredation.