csharplang/meetings/2020/LDM-2020-06-01.md
2020-06-01 14:33:26 -07:00

6.6 KiB

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.

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.