Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bring records proposal up to date #3527

Merged
merged 10 commits into from
Jun 4, 2020
155 changes: 91 additions & 64 deletions proposals/records.md
Original file line number Diff line number Diff line change
@@ -1,75 +1,60 @@
# Records Work-in-Progress

Unlike the other records proposals, this is not a proposal in itself, but a work-in-progress designed to record consensus design
decisions for the records feature. Specification detail will be added as necessary to resolve questions.
# Records

The syntax for a record is proposed to be added as follows:
This proposal tracks the specification for the C# 9 records feature, as agreed to by the C#
language design team.

```antlr
class_declaration
: attributes? class_modifiers? 'partial'? 'class' identifier type_parameter_list?
parameter_list? type_parameter_constraints_clauses? class_body
;
The syntax for a record is as follows:

struct_declaration
: attributes? struct_modifiers? 'partial'? 'struct' identifier type_parameter_list?
parameter_list? struct_interfaces? type_parameter_constraints_clauses? struct_body
```antlr
record_declaration
agocke marked this conversation as resolved.
Show resolved Hide resolved
: attributes? class_modifier* 'partial'? 'record' identifier type_parameter_list?
parameter_list? record_base? type_parameter_constraints_clause* record_body
;

class_body
: '{' class_member_declarations? '}'
| ';'
record_base
: ':' class_type argument_list?
agocke marked this conversation as resolved.
Show resolved Hide resolved
| ':' interface_type_list
| ':' class_type argument_list? interface_type_list
;

struct_body
: '{' struct_members_declarations? '}'
record_body
: '{' class_member_declaration* '}'
| ';'
;
```

The `attributes` non-terminal will also permit a new contextual attribute, `data`.

A class (struct) declared with a parameter list or `data` modifier is called a record class (record struct), either of which is a record type.

It is an error to declare a record type without both a parameter list and the `data` modifier.
Record types are reference types, similar to a class declaration. It is an error for a record to provide
a `record_base` `argument_list` if the `record_declaration` does not contain a `parameter_list`.
Comment on lines +27 to +28

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@agocke could you explain the rationale behind this restriction? (related discussion: #3795)

Thanks!


## Members of a record type

In addition to the members declared in the class or struct body, a record type has the following additional members:
In addition to the members declared in the record body, a record type has additional synthesized members.
Members are synthesized unless an accessible concrete (non-abstract) member with a "matching" signature is
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"matching" signature [](start = 83, length = 20)

The spec doesn't have a definition of signature for a property.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should an abstract property declared on the record type be allowed without error?

abstract data class C(object P)
{
    public abstract object P { get; }
}

either inherited or declared in the record body. Two members are considered matching if they have the same
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inherited [](start = 7, length = 9)

An abstract member in a grandparent class is still inherited into the child class even if overridden (implemented) in the parent class due to transitivity of inheritance. You may want to be more precise about what conditions cause the synthesiis or its suppression.

signature or would be considered "hiding" in an inheritance scenario.

### Primary Constructor

A record type has a public constructor whose signature corresponds to the value parameters of the
type declaration. This is called the primary constructor for the type, and causes the implicitly
declared default class constructor, if present, to be suppressed. It is an error to have a primary
constructor and a constructor with the same signature already present in the class.

At runtime the primary constructor

1. executes the instance field initializers appearing in the class-body; and then
invokes the base class constructor with no arguments.

1. initializes compiler-generated backing fields for the properties corresponding to the value parameters (if these properties are compiler-provided

### Properties

For each record parameter of a record type declaration there is a corresponding public property member whose name and type are taken from the value parameter declaration. If no concrete (i.e. non-abstract) property with a get accessor and with this name and type is explicitly declared or inherited, it is produced by the compiler as follows:

For a record struct or a record class:

* A public `get` and `init` auto-property is created (see separate `init` accessor specification). Its value is initialized during construction with the value of the corresponding primary constructor parameter. Each "matching" inherited abstract property's get accessor is overridden.
The synthesized members are as follows:

### Equality members

Record types produce synthesized implementations for the following methods:
Record types produce synthesized implementations for the following methods, where `T` is the
containing type:

* `object.GetHashCode()` override, unless it is sealed or user provided
* `object.Equals(object)` override, unless it is sealed or user provided
* `object.GetHashCode()` override
* `object.Equals(object)` override
* `T Equals(T)` method, where `T` is the current type
* `Type EqualityContract` get-only property

If either `object.GetHashCode()` or `object.Equals(object)` are sealed, an error is produced.

`EqualityContract` is a virtual instance property which returns `typeof(T)`. If the base type
defines an `EqualityContract` it is overridden in the derived record. If the base `EqualityContract`
is sealed or non-virtual, an error is produced.

`T Equals(T)` is specified to perform value equality such that `Equals` is
true if and only if all the instance fields declared in the receiver type
are equal to the fields of the other type.
`T Equals(T)` is specified to perform value equality such that `Equals` is true if and only if
all accessible instance fields in the receiver are equal to the fields of the parameter
and `this.EqualityContract` equals `other.EqualityContract`.
Copy link
Member

@cston cston Jun 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base class Equals(T) will ignore derived class fields. Is that expected?

For instance, the following will print False, True:

using System;

data class A(int X);

data class B(int X, int Y) : A(X);

class Program
{
    static void Main()
    {
        var b1 = new B(1, 2);
        var b2 = new B(1, 3);
        Console.WriteLine(b1.Equals(b2));
        Console.WriteLine(((A)b1).Equals(b2));
    }
}

Copy link

@theunrepentantgeek theunrepentantgeek Jun 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be different EqualityContract values for A & B - so even if you are calling A.Equals(), it can detect that they are not the same type, and therefore your example would write true both times.


`object.Equals` performs the equivalent of

Expand All @@ -79,18 +64,60 @@ override Equals(object o) => Equals(o as T);

### Copy and Clone members

A record type contains two synthesized copying members if methods with the same
signature are not already declared within the record type:
A record type contains two synthesized copying members:

* A protected constructor taking a single argument of the record type.
* A public parameterless instance method called `Clone` which returns the record type.
* A public parameterless virtual instance "clone" method with a compiler-reserved name

The protected constructor is referred to as the "copy constructor" and the synthesized
body copies the values of all instance fields declared in the input type to the corresponding
body copies the values of all accessible instance fields in the input type to the corresponding
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all accessible instance fields [](start = 26, length = 30)

Consider clarifying this, obviously all fields within a record are accessible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about inherited private fields?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about inherited private fields?

Shouldn't base take care of this?


In reply to: 434061428 [](ancestors = 434061428)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for the very first record in a hierarchy, inheriting from a user-written class.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for the very first record in a hierarchy, inheriting from a user-written class.

That is why I think it would be good to clearly describe two distinct scenarios and what is the behavior of the constructor in each of them.


In reply to: 434063198 [](ancestors = 434063198)

fields of `this`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this`. [](start = 11, length = 6)

Just to be clear: I assume it is intentional that fields of the base are not copied. This will be extremely confusing to users.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither intentional nor unintentional -- I see two viable specifications here. One way is to try to set up a pattern of delegation, where if the base type is a record, we call base.Equals, and the other where we always override and check all types regardless.

LDM hasn't decided on anything so I haven't spent time detailing either one.


The `Clone` method returns the result of a call to a constructor with the same signature as the
copy constructor.
The "clone" method returns the result of a call to a constructor with the same signature as the
copy constructor. The return type of the clone method is the containing type, unless a virtual
clone method is present in the base class. In that case, the return type is the current containing
type if the "covariant returns" feature is supported and the override return type otherwise. The
synthesized clone method is an override of the base type clone method if one exists. An error is
produced if the base type clone method is sealed.

## Positional record members

In addition to the above members, records with a parameter list ("positional records") synthesize
additional members with the same conditions as the members above.

### Primary Constructor

A record type has a public constructor whose signature corresponds to the value parameters of the
type declaration. This is called the primary constructor for the type, and causes the implicitly
declared default class constructor, if present, to be suppressed. It is an error to have a primary
constructor and a constructor with the same signature already present in the class.

At runtime the primary constructor

1. executes the instance initializers appearing in the class-body

1. invokes the base class constructor with the arguments provided in the `record_base` clause, if present


### Properties

For each record parameter of a record type declaration there is a corresponding public property
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public [](start = 80, length = 6)

synthesized?

member whose name and type are taken from the value parameter declaration.

For a record:

* A public `get` and `init` auto-property is created (see separate `init` accessor specification).
Each "matching" inherited abstract accessor is overridden. The auto-property is initialized to
Copy link
Member

@cston cston Jun 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we require the abstract property to match the expected signature? Could an accessor be missing, or non-public? Could the property be a type that is assignable from the record parameter type rather than an exact match?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overridden [](start = 49, length = 10)

But virtual (non-abstract) ones are not overridden?

the value of the corresponding primary constructor parameter.

### Deconstruct

A positional record synthesizes a public void-returning method called Deconstruct with an out
parameter declaration for each parameter of the primary constructor declaration. Each parameter
of the Deconstruct method has the same type as the corresponding parameter of the primary
constructor declaration. The body of the method assigns each parameter of the Deconstruct method
to the value from an instance member access to a member of the same name.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if there is a positional parameter with the same name as a property or field that is already present in the type? e.g.

record C(int X)
{
    public int X { get; init; }
}

It feels like we shouldn't generate a Deconstruct method in this case, for the same reason we don't automatically assign from the positional parameter to the explicitly declared property.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean that patterns like case Some(var value) would not be possible?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I completely follow, but users are free to write whatever Deconstruct method is appropriate for their type. The compiler should refrain from generating the Deconstruct method if a method with the same signature exists already on the record type. Not sure what should happen if the Deconstruct() method is on the base type. I don't think that is specified here, so let's make sure we come to a conclusion on that question.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, I read that as not generating a Deconstruct for a positional record with a single parameter. Nevermind.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should generate a Deconstruct regardless. If the user doesn't want one, they can declare a private method with the same signature.


## `with` expression

A `with` expression is a new expression using the following syntax.
Expand All @@ -100,7 +127,7 @@ with_expression
: switch_expression
| switch_expression 'with' '{' member_initializer_list? '}'
;

member_initializer_list
: member_initializer (',' member_initializer)*
;
Expand All @@ -114,13 +141,13 @@ A `with` expression allows for "non-destructive mutation", designed to
produce a copy of the receiver expression with modifications in assignments
in the `member_initializer_list`.

A valid `with` expression has a receiver with a non-void type. The receiver type must contain an accessible
parameterless instance method called `Clone` whose return type must be the receiver type, or a base type thereof.
A valid `with` expression has a receiver with a non-void type. The receiver type must contain an
accessible synthesized record "clone" method.

On the right hand side of the `with` expression is an `member_initializer_list` with a
sequence of assignments to *identifier*, which must an accessible instance field or property of the return
On the right hand side of the `with` expression is a `member_initializer_list` with a sequence
of assignments to *identifier*, which must be an accessible instance field or property of the return
type of the `Clone()` method.

Each `member_initializer` is processed the same way as an assignment to the field or property target on the return
value of the `Clone()` method. The `Clone()` method is executed only once and the assignments are processed in
lexical order.
Each `member_initializer` is processed the same way as an assignment to a field or property
access of the return value of the record clone method. The clone method is executed only once
and the assignments are processed in lexical order.