106 строки
5.2 KiB
Markdown
106 строки
5.2 KiB
Markdown
|
|
||
|
# C# Language Design for Jan. 29, 2020
|
||
|
|
||
|
## Agenda
|
||
|
|
||
|
Record -- With'ers
|
||
|
|
||
|
## Discussion
|
||
|
|
||
|
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: https://github.com/dotnet/csharplang/issues/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.
|
||
|
|
||
|
1. 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.
|
||
|
|
||
|
2. 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,
|
||
|
|
||
|
```C#
|
||
|
public data class Point(int X, int Y);
|
||
|
var p2 = p1 with { Y = 2 };
|
||
|
```
|
||
|
|
||
|
Would generate
|
||
|
|
||
|
```C#
|
||
|
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":
|
||
|
|
||
|
```C#
|
||
|
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.
|