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
147 changes: 83 additions & 64 deletions proposals/records.md
Original file line number Diff line number Diff line change
@@ -1,75 +1,50 @@
# 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.

## 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:

### 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.
In addition to the members declared in the record body, a record type has the following additional members:
agocke marked this conversation as resolved.
Show resolved Hide resolved

### 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, unless it is sealed (error) or user provided
agocke marked this conversation as resolved.
Show resolved Hide resolved
* `object.Equals(object)` override, unless it is sealed (error) or user provided
* `T Equals(T)` method, where `T` is the current type
* `Type EqualityContract` get-only property

`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.
`EqualityContract` is a virtual instance property which returns `typeof(T)`. If it
is present in the base type, the synthesized property overrides the base. If the base
agocke marked this conversation as resolved.
Show resolved Hide resolved
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,
agocke marked this conversation as resolved.
Show resolved Hide resolved
agocke marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -83,14 +58,58 @@ A record type contains two synthesized copying members if methods with the same
signature are not already declared within the record type:
agocke marked this conversation as resolved.
Show resolved Hide resolved

* 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 parameter-less 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 instance fields declared in the input type to the corresponding
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
the following members, if a concrete (i.e. non-abstract) member with the same signature (or name
Copy link
Member

Choose a reason for hiding this comment

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

same signature [](start = 73, length = 14)

How do we handle inherited properties that are inaccessible, or get or set only.

And can the types, of inherited or explicit properties and record parameters, differ by dynamic/object, tuple element names, n[u]int/System.[U]IntPtr, or nullability?

Copy link
Member Author

Choose a reason for hiding this comment

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

Lemme know if this needs more specification but hopefully the "matching" rule resolves everything.

if the member is a field or property) is not already present:
agocke marked this conversation as resolved.
Show resolved Hide resolved

### 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; and then
invokes the base class constructor with the arguments provided in the `record_base` clause, if present
Copy link
Member

Choose a reason for hiding this comment

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

arguments provided [](start = 47, length = 18)

What if no arguments were provided?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good question. I'm not sure if anything else needs to be spec'd but my read is: reference types must call a base constructor. If there are no arguments, no base constructor is called. Thus, an error should be produced.

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 not sure if anything else needs to be spec'd but my read is: reference types must call a base constructor. If there are no arguments, no base constructor is called. Thus, an error should be produced.

I don't think there is anything special in this scenario by comparison to regular classes. A default constructor initializer is going to be synthesized. That can cause an error if, for example, base class doesn't have an accessible parameter-less constructor.


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


1. assigns fields or properties declared in the record body with the value of a primary constructor
agocke marked this conversation as resolved.
Show resolved Hide resolved
parameter of the same name, if one is present and the member is assignable

### 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.

### 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 +119,7 @@ with_expression
: switch_expression
| switch_expression 'with' '{' member_initializer_list? '}'
;

member_initializer_list
: member_initializer (',' member_initializer)*
;
Expand All @@ -114,13 +133,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 an `member_initializer_list` with a sequence
of assignments to *identifier*, which must an accessible instance field or property of the return
agocke marked this conversation as resolved.
Show resolved Hide resolved
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 the field or property
target on the return value of the record clone method. The clone method is executed only once
agocke marked this conversation as resolved.
Show resolved Hide resolved
and the assignments are processed in lexical order.