Record -- With'ers
In this meeting we'd like to talk about Mads's write-up of the "With-ers" feature, as it relates
to records. Multiple variations have been proposed, but the suggestion generally takes the form
of a with
expression that can return a copy of a data type, with selective elements changed.
Write-up: #3137
The first thing we learned is that the fundamental problem we're trying to solve is "non-destructive mutation."
There are two approaches we've thought of: direct copy and then direct modification, and creation of a new type based on the values of the old type.
-
Direct copy. We might call this "copy-and-update" because we copy the new data type exactly, then update the new type with required changes. The basic implementation would be to use MemberwiseClone, and then overwrite select properties.
-
Create a new type. we call this "constructing through virtual factories." If the type supports a constructor, this approach would call the constructor using the new values, or the existing ones if nothing new is given. The construction would be virtual so that derived types would not lose state when called through the base type.
There are advantages and disadvantages to each proposal.
(1) is simple but seemingly dangerous. There are often internal constraints to a type which must be preserved for correctness. Usually this is enforced through the type constructor and visibility of modification. That would not necessarily be available here.
(2) does construction similar to conventional construction today, so it doesn't introduce as many safety concerns. On the other hand, the contract looks a lot more complicated. To make the feature seem simple on the surface, it looks like we imply a lot of implicit dependency. For example,
public data class Point(int X, int Y);
var p2 = p1 with { Y = 2 };
Would generate
public class Point(int X, int Y)
{
public virtual Point With(int X, int Y) => new Point(X, Y);
}
var p2 = p1.With(p1.X, 2);
The first requirement is that an auto-generated With
method must have a primary constructor, in
order to know which constructor to call. Alternatively, we could have a With
method generated
for every constructor, although that would require a syntax to signal that With
methods should
be generated in the absence of a primary constructor.
The compiler also needs to know that the X
and Y
parameters of the With
method correspond
to particular properties, so it can fill in the defaults in the with
expression. Otherwise we
would need some way of signifying which of the parameters are meant to be "defaults":
public class Point(int X, int Y)
{
public virtual Point With(bool[] provided, int X, int Y)
{
return new Point(provided[0] ? X : this.X, provided[1] ? Y : this.Y);
}
}
var p2 = p1.With(new bool { false, true}, default, 2);
We also need to figure out which With
method to call at a particular call site. One way is to
construct an equivalent call and perform overload resolution. Another way would be to pick a
particular With
method as primary, and always use that one in overload resolution.
This also has some of the same compatibility challenges that we've seen in other areas.
Particularly, if you add members to the record, there will be a new With
method with a new
signature. This would break existing binaries referencing the old With
method. In addition, if
you add a new With
method, the old one would still be chosen by overload resolution, if
overload resolution is performed, as long as unspecified properties in the with
expression are
default values.
On the other hand, this is also a general problem with backwards compatibility overloads. We'll need to investigate whether we want to add a general purpose mechanism for handling backwards compatibility and if we want to introduce a special case for With-ers specifically.
What all of the above interdependency implies is that we need a significant amount of syntax or "hints" about what to do during autogeneration. We previously expressed interest in providing orthogonality for as many of the "record" features as possible. A conclusion is that auto-generated With-ers require or suggest many of syntactic and semantic components of records themselves. When we try to separate the feature entirely, we require user opt-in to specify the "backing" state of the With-er. This seems to imply that auto-generation should not be a general, orthogonal feature, but a specific property of records.
However, we don't have to give up orthogonality entirely. The requirements for auto-generated With-ers doesn't imply anything about manually written With-ers. Auto-generation seems possible in records because the syntax ties the state to the public interface. Manual specification looks just like the components of records that can be written explicitly in regular classes, like constructors themselves. If we do want to pursue this avenue, we should try to limit the complexity of the pattern as much as possible. It's not too bad if it's fully generated by the compiler, but it can't be very complicated if we want users to write it themselves.