164 lines
6.6 KiB
Markdown
164 lines
6.6 KiB
Markdown
|
|
||
|
# C# Language Design for June 1, 2020
|
||
|
|
||
|
## Agenda
|
||
|
|
||
|
Records:
|
||
|
|
||
|
1. Base call syntax
|
||
|
2. Synthesizing positional record members and assignments
|
||
|
3. Record equality through inheritance
|
||
|
|
||
|
## Discussion
|
||
|
|
||
|
### Record base call syntax
|
||
|
|
||
|
We'd like to reconsider adding a base call syntax to a record declaration, i.e.
|
||
|
|
||
|
```antlr
|
||
|
record_base
|
||
|
: ':' class_type argument_list?
|
||
|
| ':' interface_type_list
|
||
|
| ':' class_type argument_list? interface_type_list
|
||
|
;
|
||
|
```
|
||
|
|
||
|
The main question is how the scoping of the parameters from the record positional
|
||
|
constructor interact with the base call syntax and the record body.
|
||
|
|
||
|
We would definitely like the parameters to be in scope inside the base call. For the record body,
|
||
|
it's proposed that the parameters of the primary constructor are in scope for initializers, and
|
||
|
the primary constructor body (if we later accept a proposal for such syntax). The parameters
|
||
|
shadow any members of the same name. The parameters are not in scope outside of these locations.
|
||
|
|
||
|
To unify the scoping behavior between the base call and the body, we propose that members of the
|
||
|
body are also in scope in the base call syntax. Instance members would be an error in these locations
|
||
|
(similar to how instance members are in scope in initializers today, but an error to use), but
|
||
|
the parameters of the positional record constructor would be in scope and useable. Static members
|
||
|
would also be useable, similar to how base calls work in ordinary constructors today.
|
||
|
|
||
|
**Conclusion**
|
||
|
|
||
|
The above proposals are accepted.
|
||
|
|
||
|
### Synthesized positional record members
|
||
|
|
||
|
A follow-up question is how to do generation for auto-generated positional properties. We need
|
||
|
to decide both 1) when we want to synthesize positional members and 2) when we want to initialize
|
||
|
the corresponding members. The affect is most clearly visible in the example below, where the
|
||
|
initialization order will affect what values are visible at various times during construction,
|
||
|
namely whether the synthesized properties are initialized before or after the `base` call.
|
||
|
|
||
|
```C#
|
||
|
record Person(string FirstName, string LastName)
|
||
|
{
|
||
|
public string Fullname => $"{FirstName} {LastName}";
|
||
|
public override string ToString() => $"{FirstName} {LastName}";
|
||
|
}
|
||
|
|
||
|
record Student(string FirstName, string LastName, int Id)
|
||
|
: Person(FirstName, LastName)
|
||
|
{
|
||
|
public override string ToString() => $"{FirstName} {LastName} ({ID})";
|
||
|
}
|
||
|
```
|
||
|
|
||
|
First we discussed when to synthesize members, namely when an "existing" member will prevent
|
||
|
synthesis. A simple rule is that we synthesize members when there is no accessible, concrete
|
||
|
(non-abstract) matching member in the type already, either because it was inherited or because it
|
||
|
was declared. The rule for matching is that if the member would be considered identical in signature,
|
||
|
or if it would require the `new` keyword in an inheritance scenario, those members would "match." This
|
||
|
rule allows us to avoid generating duplicate members for record-record inheritance and also produces the
|
||
|
intuition that we should err on the side of not synthesizing members when they could be confused with
|
||
|
an existing member.
|
||
|
|
||
|
Second, we discussed when and in what order assignments were synthesized from positional record
|
||
|
parameters to "matching" members. A starting principle is that in record-record inheritance we don't
|
||
|
want to duplicate assignment -- the base record will already assign its members. In that case, we could
|
||
|
choose to assign only members synthesized or declared in the current record. That would mean
|
||
|
|
||
|
```C#
|
||
|
record R(int X)
|
||
|
{
|
||
|
public int X { get; init; }
|
||
|
}
|
||
|
```
|
||
|
|
||
|
would initialize the `X` property to the value of the constructor parameter even though the property
|
||
|
is not compiler synthesized. However, we would have to decide if it is synthesized before or after
|
||
|
the `base` call. In essence, the question is how we de-sugar the assignments. Is `record Point(int X, int Y);`
|
||
|
equivalent to
|
||
|
|
||
|
```C#
|
||
|
record Point(int X, int Y) : Base
|
||
|
{
|
||
|
public int X { get; init; } = X;
|
||
|
public int Y { get; init; } = Y;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
or
|
||
|
|
||
|
```C#
|
||
|
record Point(int X, int Y) : Base
|
||
|
{
|
||
|
public int X { get; init; }
|
||
|
public int Y { get; init; }
|
||
|
public Point
|
||
|
{
|
||
|
this.X = X;
|
||
|
this.Y = Y;
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
Note that today property and field initializers are always executed before the `base` call, while
|
||
|
statements in the constructor body are executed afterwards and we are disinclined to change that
|
||
|
for record initializers.
|
||
|
|
||
|
Looking at the examples as a whole, we think using the initializer behavior is good -- it's easy
|
||
|
to understand and more likely to be correct in the presence of a virtual call in the base class,
|
||
|
but it makes things significantly more complicated if we synthesize it even for user-written
|
||
|
properties. Is the initializer synthesized even if there's already an initializer on the property?
|
||
|
What if the user-written property isn't an auto-property?
|
||
|
|
||
|
**Conclusion**
|
||
|
|
||
|
We think it's much clearer if we simplify the rules to only initialize synthesized properties.
|
||
|
Effectively, if you replace the synthesized record property, you also have to write the initialization,
|
||
|
if you want it. In the case that the property is not already declared, e.g. `record Point(int X, int Y);`
|
||
|
the equivalent code is
|
||
|
|
||
|
```C#
|
||
|
record Point(int X, int Y)
|
||
|
{
|
||
|
public int X { get; init; } = X;
|
||
|
public int Y { get; init; } = Y;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
### Equality through inheritance
|
||
|
|
||
|
We have a number of small and large questions about how records work with inheritance.
|
||
|
|
||
|
Q: What should we do if one of the members which we intend to override, like object.Equals and
|
||
|
object.GetHashCode, are sealed?
|
||
|
|
||
|
A: Error. This is effectively outside of the scope of automatic generation.
|
||
|
|
||
|
Q: Should we generate a strongly-typed Equals (i.e., `bool Equals(T)`) for each record declaration? What
|
||
|
about implementing `IEquatable<T>`?
|
||
|
|
||
|
A: Yes. Implementing `IEquatable<T>` is very useful and would require a strongly-typed equals method. We
|
||
|
could explicitly implement the method, but we also think this is useful surface area. If we broaden
|
||
|
support to structs, this would prevent a boxing conversion, which has a significant performance impact.
|
||
|
Even for classes this could avoid extra type checks and dispatch.
|
||
|
|
||
|
Q: Should each record declaration re-implement equality from scratch? Or should we attempt to dispatch
|
||
|
to base implementations of equality?
|
||
|
|
||
|
A: For the first record in a type hierarchy, we should define equality based on all the accessible fields,
|
||
|
including inherited ones, in the record. For records inheriting from a class with an existing
|
||
|
`EqualityContract`, we should assume that it implements our contract appropriately, and delegate comparing
|
||
|
the EqualityContract itself and the base fields to the base class.
|