6.6 KiB
C# Language Design for June 1, 2020
Agenda
Records:
- Base call syntax
- Synthesizing positional record members and assignments
- 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.
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.
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
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
record Point(int X, int Y) : Base
{
public int X { get; init; } = X;
public int Y { get; init; } = Y;
}
or
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
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.