csharplang/meetings/2020/LDM-2020-04-27.md
2020-04-28 10:26:27 -07:00

7.7 KiB
Raw Permalink Blame History

C# Language Design Meeting for April 27, 2020

Agenda

  1. Records: positional

Discussion

The starting point for positional records is how it fits in with potential "primary constructor" syntax. The original proposal for primary constructors allowed the parameters for primary constructors to be visible inside the class:

class MyClass(int x, int y)
{
    public int P => x + y;
}

When referenced, x and y would be like lambda captures. They would be in scope, and if they are captured outside of a constructor, a private backing field would be generated.

One consequence of this design is that the primary constructor must always run. Since the parameters are in scope throughout the entire class, the primary constructor must run to provide the parameters. The proposed way of resolving this is to require all user constructors to call the primary constructor, instead of allowing calls to base. The primary constructor itself would be the only constructor allowed (and required) to call base.

This does present a conundrum for positional records. If positional records support the with expression, as we intended for all records, they must generate two constructors: a primary constructor and a copy constructor. We previously specified that the copy constructor works off of fields, and is generated as follows

class C
{
    protected C(C other)
    {
        field1 = other.field1;
        ...
        fieldN = other.fieldN;
    }
}

This generated code violates the previous rule: it doesn't call the primary constructors. One way to resolve this would be to change the codegen to delegate to the primary constructor:

record class Point(int X, int Y)
{
    protected Point(Point other) : Point(other.X, other.Y) { }
}

This is almost identical, except that the primary constructor may have side-effects, or the property accessors may have side-effects, if user-defined. We had strong opinions against using the accessors before because of this -- we couldn't know if the properties were even auto-properties and whether we were duplicating or even overwriting previous work.

However, we note that violating the rule for our generated code shouldn't be a problem in practice. Since the new object is a field-wise duplicate of the previous object, if we assume that the previous object is valid, the new object must be as well. All fields which were initialized by the primary constructor must already be initialized. Thus, for our code generation it's both correct and safer to keep our old strategy. For user-written constructors we can require that they call the primary constructor, but because the user owns the type, they should be able to provide safe codegen. In contrast, because the compiler doesn't know the full semantics of the user type, we have to be more cautious in our code generation.

This doesn't really contradict with our goal of making a record representable as a regular class. A mostly-identical version can be constructed via chaining as described above. The only difference is in property side effects, which the compiler itself cant promise is identical, but if it were written in source then the user could author their constructor to behave similarly.

Property side-effects have an established history of being flexible in the language and the tooling. Property pattern matching doesn't define the order in which property side effects are evaluated, doesn't promise that they even will be evaluated if theyre not necessary to determine whether the pattern matches, and doesn't promise that the ordering will be stable. Similarly, the debugger auto-evaluates properties in the watch window, regardless of side effects, and the default behavior is to step over them when single stepping. The .NET design guidelines also specifically recommend to not have observable side effects in property evaluation.

We now have a general proposal for both how positional records work, and how primary constructors work.

Primary constructors work like capturing. The parameters from the primary constructor are visible in the body of the class. In field initializers and possibly a primary constructor body, they are non-capturing, namely that use of the parameter does not capture to any fields. Everywhere else in the class, the parameters are captured and create a private backing field.

Positional records work like primary constructors, except that they also generate public init-only properties for each positional record parameter, if a member with the same signature does not already exist. This means that in field initializers and the primary constructor body, the parameter name is in scope and shadows the properties, while in other methods the parameter name is not in scope. In addition, the generated constructor will initialize all properties with the same names as the positional record parameters to the parameter values, unless the corresponding members are not writeable.

Conclusion

The above proposals are accepted. Both positional records and primary constructors are accepted with the above restrictions. In source, all non-primary constructors in a type with a primary constructor must call the primary constructor. The generated copy constructor will not be required to follow this rule, instead doing a field-wise copy. The exact details of the scoping rules, including whether primary constructors have parameters that are in scope everywhere, or simply generate a field that is in scope and shadows the parameter, is an open issue.

Primary constructor bodies and validators

We do have a problem with some syntactic overlap. We previously proposed that our original syntax for primary constructor bodies could be the syntax for a validator. However, there are reasons why you may want to write both. For instance, constructors are a good way to provide default values for init-only properties that may be overwritten later. Validators are still useful for ensuring that the state of the object is legal after the init-only. In that case we need two syntaxes that can be composed. The proposal is

class TypeName(int X, int Y)
{
    public TypeName
    {
        // constructor
    }

    init
    {
        // validator
    }
}

To mirror the keyword used for init-only properties, we could use the init keyword instead. This would also hint that validators aren't only for validating the state, they can also set init-only properties themselves. To that end, we have a tentative name: final initializers.

Conclusion

Accepted. type-name '{' ... '}' will refer to a primary-constructor-body and init '{' ... '}' is the new "validator"/"final initializer" syntax. No decisions on semantics.

Primary constructor base calls

Given that we have accepted the following syntax for primary constructors and primary constructor bodies,

class TypeName(int X, int Y)
{
    public TypeName
    {

    }
}

how should we express the mandatory base call? We have two clear options:

class TypeName(int X, int Y) : BaseType(X, Y);

class TypeName(int X, int Y) : BaseType
{
    public TypeName : base(X, Y) { }
}

We mostly like both. The first syntax feels very simple and it effectively moves the "entire" constructor signature up to the type declaration, instead of just the parameter list. However, we don't think that class members would be in scope in the argument list for this base call and there are some rare cases where arguments to base calls may involve calls to static private helper methods in the class. Because of that we think the second syntax is more versatile and reflects the full spectrum of options available in classes today.

Conclusion

Both syntaxes are accepted. If prioritization is needed, the base specification on the primary constructor body is preferred.