csharplang icon indicating copy to clipboard operation
csharplang copied to clipboard

[Proposal] Extend with expression to anonymous type

Open leandromoh opened this issue 4 years ago • 28 comments

Extend with expression to anonymous type

  • [x] Proposed
  • [x] Prototype: Done.
  • [x] Implementation: Done,
  • [ ] Specification: Not Started

Speclet: https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/record-structs.md

Summary

The with expression, introduced in C# 9, is designed to produce a copy of the receiver expression, in a "non-destructive mutation" manner.

This proposal extend with expression to anonymous type, since they are also immutable, the feature may fit well with them too.

Note: F# has a very similar feature called copy and update record expressions.

Motivation

Reduce boilerplate code to create new instances of anonymous type based on already existing instance.

Current approach:

var person = new { FirstName = "Scott", LastName = "Hunter", Age = 25 };

var otherPerson = new { person.FirstName, LastName = "Hanselman", person.Age };

Proposed:

var person = new { FirstName = "Scott", LastName = "Hunter", Age = 25 };

var otherPerson = person with { LastName = "Hanselman" };

Detailed design

The syntax is the same described in with expression section of the record proposal, that is

with_expression
    : switch_expression
    | switch_expression 'with' '{' member_initializer_list? '}'
    ;

member_initializer_list
    : member_initializer (',' member_initializer)*
    ;

member_initializer
    : identifier '=' expression
    ;

In the context of this proposal, the receiver expression must be an anonymous object. Also, different of the original with proposal, the anonymous type will not need contains an accessible "clone" method, since the copy can be done by the compiler just calling the constructor of the anonymous type, what maintain how anonymous types are emitted.

Currently, each anonymous type's property has a correspondent parameter in the type constructor, with same name and type. So compiler must pass the correspondent member expression for each argument. Case the member exists in the member_initializer_list, compiler must pass as equivalent argument the expression on the right side of the member_initializer.

The orders each member_initializer appears is irrelevant, since they will be processed in constructor call, therefore in the order of the parameters.

Drawbacks

None.

Alternatives

One workaround is use reflection and expression trees to build such anonymous type's constructor call. However theses features have performance cost and are, perhaps, not common for all programmers of the language. It also requires new intermediary anonymous instances to represent the properties and values that we will be changed. Here is a gist with my workaround with follow usage:

var otherPerson = person.With(new { LastName = "Hanselman" });

Unresolved questions

Design meetings

  • https://github.com/dotnet/csharplang/blob/main/meetings/2020/LDM-2020-06-22.md#with-expressions-on-non-records (approved this scenario)

leandromoh avatar Jun 03 '20 14:06 leandromoh

In case of someone is looking for such feature, here is a gist with my workaround. with follow usage:

var otherPerson = person.With(new { LastName = "Hanselman" });

leandromoh avatar Jun 09 '20 15:06 leandromoh

I'm willing to champion this extension, with the caveat that it won't come in 9 :)

333fred avatar Jun 09 '20 16:06 333fred

Makes sense, you could probably consider anonymous types to automatically be records given they already have value equality, no?

HaloFour avatar Jun 09 '20 16:06 HaloFour

Possibly. It would still depend on whether we add anything else to the definition of a record type. I'm not sure whether we'd add a copy constructor, for example, or if we'd just do the copy at call site.

333fred avatar Jun 09 '20 17:06 333fred

@leandromoh this will need an actual spec. Would you be willing to take a stab at writing one? It would need to be an extension to the existing records proposal.

333fred avatar Jun 09 '20 17:06 333fred

Would "withers" come for free if an anonymous type definition was modified so that:

  1. Every property was { get; init; } instead of { get; }, and
  2. The anonymous type exposed a Clone method which would create a shallow clone of the anonymous type?

HaloFour avatar Jun 09 '20 17:06 HaloFour

Probably, but then every piece of code generates an anonymous type would need to be recompiled to work with this feature.

333fred avatar Jun 09 '20 17:06 333fred

@333fred Since anonymous types can't be exposed publicly, why would that be a problem?

svick avatar Jun 09 '20 19:06 svick

I suppose it might be fine. Still, we need a spec to actually evaluate this :)

333fred avatar Jun 09 '20 20:06 333fred

@leandromoh this will need an actual spec. Would you be willing to take a stab at writing one? It would need to be an extension to the existing records proposal.

@333fred I would like to do it, of course. I just need to know exactly what and how to do it. if someone explain me, I will be glad to contribute more.

leandromoh avatar Jun 09 '20 21:06 leandromoh

Basically, look at the existing records proposal (https://github.com/dotnet/csharplang/blob/master/proposals/records.md#with-expression) and detail exactly how it would need to be modified in order to support withing an anonymous type. How would it actually function? Would we make changes to how anonymous types are emitted as @HaloFour suggested to generate a copy constructor & mark the properties as init, or would we do codegen at the with invocation site? I think the former approach has promise, but it needs to be investigated and formalized. The proposal template is here: https://github.com/dotnet/csharplang/blob/master/proposals/proposal-template.md. Note: don't make a PR yet, just filling it out in this issue is fine for now.

333fred avatar Jun 09 '20 21:06 333fred

Given that the compiler has complete control over the definition and use of anonymous types it might make more sense to have the compiler treat it special and just emit its own call to the constructor manually copying the source properties, as suggested in the original post. I don't see any other added benefit to adding/changing the members of the anonymous type, it's not like external code can take advantage of it outside of reflection.

HaloFour avatar Jun 09 '20 22:06 HaloFour

Will it allow adding properties, since anonymous types do not have declared type like records?

ChayimFriedman2 avatar Jun 12 '20 12:06 ChayimFriedman2

@ChayimFriedman2

That sounds interesting, although I'd a little concerned that it would make it very easily to make a mistake:

var person1 = new { Name = "Bill", Age = 45 };
var person2 = person1 with { AGe = 46 }; // oops!

HaloFour avatar Jun 12 '20 13:06 HaloFour

Right. That seems like VB's implicit variable declaration, which is known to be error-prone...

ChayimFriedman2 avatar Jun 13 '20 20:06 ChayimFriedman2

@333fred @HaloFour proposal added in the description of this issue. Feel free to edit it.

leandromoh avatar Jun 13 '20 20:06 leandromoh

@HaloFour perhaps 'anon with' {x=y} requires x to be an already existing variable (ie, you can't extend an anon type with 'with')

AartBluestoke avatar Jun 14 '20 02:06 AartBluestoke

@leandromoh it's a good start, but it needs to be more concrete. A spec isn't a guess about how to implement something: it lays out exactly how it will be implemented.

333fred avatar Jun 16 '20 16:06 333fred

@333fred okay, I will try detail it more, but probably it will need some adjusts of you after all.

leandromoh avatar Jun 23 '20 13:06 leandromoh

@ChayimFriedman2 @HaloFour javascript has a spread operator that makes exactly this. example:

var obj1 = { name: "bob",  type:"Apple" };
var obj2 = { name: "joe", price: 0.20 }; 
var merge = {...obj1, ...obj2, age: 34 }; 

console.log(merge); 
/* { 
   age: 34
   name: "joe"
   price: 0.2
   type: "Apple"
}; */

If objects have common properties the priority will be right to left. The object the most at the right will have priority over the one at its left and so on. If we could expand C#'s anonymous type in a similar way would be amazing.

leandromoh avatar Jun 23 '20 14:06 leandromoh

@333fred @HaloFour "Detailed design" section updated in the description. Hope it is closer of what is need.

leandromoh avatar Jun 24 '20 03:06 leandromoh

If this were pre-C#-7.0 I would think this was a great idea. Post-C#-7.0 I don't see any point of putting development work into anonymous types. Tuples are better in nearly every respect - they are structs, they can be return types and parameter types, and if you really really need to mutate them, you already can.

Given that, how often, and why, do you really need to "mutate" an anonymous type? The use cases for this seem vanishingly small.

MgSam avatar Sep 29 '20 03:09 MgSam

@MgSam this proposal enables anonymous types practically act like anonymous record types, as they have the rest of the properties of a record already: value equality, ToString, etc.

F# already have anonymous record types.

leandromoh avatar Oct 01 '20 23:10 leandromoh

It would be also cool if those with operators could extend the anonymous type. My usecase:

var files = Directory.EnumerateFiles(dir, "*.*", SearchOption.TopDirectoryOnly)
  .Select(x => new FileInfo(x))
  .Select(x => new
  {
      File = x,
      FileName = x.Name,
      Match = myRegex.Match(x.Name)
  })
  .Where(x => x.Match.Success)
  .Select(x => x with
  {
      Group = x.Match.Groups[1].Value,
      FileNumber = x.Match.Groups[2].Value
  }));

The Resulting anonymous type should then contain

File
FileName
Match
Group
FileNumber

JKamsker avatar Jan 13 '21 21:01 JKamsker

It would be also cool if those with operators could extend the anonymous type. My usecase:

var files = Directory.EnumerateFiles(dir, "*.*", SearchOption.TopDirectoryOnly)
  .Select(x => new FileInfo(x))
  .Select(x => new
  {
      File = x,
      FileName = x.Name,
      Match = myRegex.Match(x.Name)
  })
  .Where(x => x.Match.Success)
  .Select(x => x with
  {
      Group = x.Match.Groups[1].Value,
      FileNumber = x.Match.Groups[2].Value
  }));

The Resulting anonymous type should then contain

File
FileName
Match
Group
FileNumber

any progress on this?

my use case is flattening groupby data

var group = data.GroupBy(a=>a.KeyProp).Select(a=>new {KeyProp = a.Key, Items = new {...}});
var flat = group.SelectMany(a=>a.Items.Select(b=>b with {Key = a.KeyProp}));

darkflame0 avatar Aug 07 '22 06:08 darkflame0

but using with expression for extending objects can be misleading. meybe this is better?

@ChayimFriedman2 @HaloFour javascript has a spread operator that makes exactly this. example:

var obj1 = { name: "bob",  type:"Apple" };
var obj2 = { name: "joe", price: 0.20 }; 
var merge = {...obj1, ...obj2, age: 34 }; 

console.log(merge); 
/* { 
   age: 34
   name: "joe"
   price: 0.2
   type: "Apple"
}; */

If objects have common properties the priority will be right to left. The object the most at the right will have priority over the one at its left and so on. If we could expand C#'s anonymous type in a similar way would be amazing.

var flat = group.SelectMany(a=>a.Items.Select(b=>new {Key = a.KeyProp, ...b}));

darkflame0 avatar Aug 07 '22 06:08 darkflame0

Introducing spread operator would be much bigger feature. And there would be complicated cases where obj1 and obj2 have properties with the same name but different types, for example.

Here using with is a natural extension of an existing feature, with well defined behavior.

qrli avatar Aug 08 '22 08:08 qrli

@ChayimFriedman2

That sounds interesting, although I'd a little concerned that it would make it very easily to make a mistake:

var person1 = new { Name = "Bill", Age = 45 };
var person2 = person1 with { AGe = 46 }; // oops!

On the other hand, the analogous F# feature does allow exactly this on anonymous record types.

mrwensveen avatar May 23 '23 06:05 mrwensveen

@ChayimFriedman2

That sounds interesting, although I'd a little concerned that it would make it very easily to make a mistake:

var person1 = new { Name = "Bill", Age = 45 };
var person2 = person1 with { AGe = 46 }; // oops!

On the other hand, the analogous F# feature does allow exactly this on anonymous record types.

Such errors can be avoided by using intellisense, which should know existing anonymous properties.

JKamsker avatar May 23 '23 09:05 JKamsker