Hacker News new | ask | show | jobs
by andygocke 338 days ago
Unfortunately, there are alternatives to this behavior, but they all have other downsides. The biggest constraint was the schedule didn't support a new version of the .NET IL format (and reving the IL format is an expensive change for compat purposes, as well). There were two strong lowering contenders, with their own problems.

The first is to use a `With` method and rely on "optional" parameters in some sense. When you write `with { x = 3 }` you're basically writing a `.With(x: 3)` call, and `With` presumably calls the constructor with the appropriate values. The problem here is that optional parameters are also kind of fake. The .NET IL format doesn't have a notion of optional parameters -- the C# compiler just fills in the parameters when lowering the call. So that means that adding a new field to a record would require adding a new parameter. But adding a new parameter means that you've broken binary backwards compatibility. One of the goals of records was to make these kinds of "simple" data updates possible, instead of the current situation with classes where they can be very challenging.

The second option is a `With` method for every field. A single `with { }` call turns into N `WithX(3).WithY(5)` for each field being set. The problem with that is that it is a lot of dead assignments that need to be unwound by the JIT. We didn't see that happening reliably, which was pretty concerning because it would also result in a lot of allocation garbage.

So basically, this was a narrow decision that fit into the space we had. If I had the chance, I would completely rework dotnet/C# initialization for a reboot of the language.

One thing I proposed, but was not accepted, was to make records much more simple across the board. By forbidding a lot of the complex constructs, the footguns are also avoided. But that was seen as too limiting. Reading between the lines, I bet Jon wouldn’t have liked this either, as some of the fancy things he’s doing may not have been possible.

1 comments

> The biggest constraint was the schedule didn't support a new version of the .NET IL format (and reving the IL format is an expensive change for compat purposes, as well).

My biggest sadness reading this is that what MS have done is to outsource the issue to all C# devs. We will all hit this problem at some point (I have a couple of times) and I suspect we will all lose hours of time trying to work out WTF is going on. It may not quite be the Billion Dollar Mistake, but it's an ongoing cost to us all.

A possible approach I mentioned elsewhere in the thread is this (for the generation of the `with`):

    var n2 = n1.<Clone>$();
    n2.Value = 3;                  // 'with' field setters
    n2.<OnPostCloneInitialise>();  // run the initialisers
Then the <OnPostCloneInitialise>:

    public virtual void <OnPostCloneInitialise>()
    {
        base.<OnPostCloneInitialise>();

        Even = (Value & 1) == 0;    
    }
If the compiler could generate the <OnPostCloneInitialise> based on the initialisation code in the record/class, could that work?

That would just force the new object to initialise after the cloning without any additional IL or modifications.

> MS have done is to outsource the issue to all C# devs

Let's be clear: breaking dozens of tools because of a change to the IL format also outsources an issue to all C# devs. The .NET IL format has been basically unchanged since .NET 2.0 and huge numbers of people take very hard dependencies on the exact things they do and do not expect. I don't expect we would have been able to make significant changes due to the breaking change impact.

> A possible approach I mentioned

This would likely be even harder to understand. For better or worse, the .NET design is that external initializers happen _after_ the constructor runs. That's been true all the way back to when the initializer syntax was first introduced in C# 3. Making regular initializers and `with` initializers have inverted order strikes me as being way worse.

If I could go back in time, I think the main change to C# I would make would be to enforce that the constructor always runs after all external initialization.

Slightly confused. My suggestion was to run the initialisers after the new object has been constructed (cloned+modified). The semantics are the same as you describe even if the underlying implementation is different.

What am I missing?

There are two types of initializers: internal and external. Internal are inside the type, like field and property initializers. External are outside, like object initializers, collection initializers, and ‘with’ clauses.

Internal initializers are run as part of the constructor, before any user code. External initializers are run after the constructor, on the constructed object.

For instance:

  class C
  {
    public int P = 5;
  }

  var c = new C { P = 3 };
`c.P` has the value 3.

In your example:

    var n2 = n1.<Clone>$();
    n2.Value = 3;                    // 'with' field setters
    n2.<OnPostCloneInitialise>();  // run the initialisers
The “PostCloneInitializers” you’re running are the field initializers, so the order is backwards. You’re overwriting the value of the external initializers with the internal initializers.