- Proposed
- Prototype: Complete
- Implementation: In Progress
- Specification: Draft specification enclosed
Records are a new, simplified declaration form for C# class and struct types that combine the benefits of a number of simpler features. We describe the new features (caller-receiver parameters and with-expressions), give the syntax and semantics for record declarations, and then provide some examples.
A significant number of type declarations in C# are little more than aggregate collections of typed data. Unfortunately, declaring such types requires a great deal of boilerplate code. Records provide a mechanism for declaring a datatype by describing the members of the aggregate along with additional code or deviations from the usual boilerplate, if any.
See Examples, below.
Currently a method parameter's default-argument must be
- a constant-expression; or
- an expression of the form
new S()
whereS
is a value type; or - an expression of the form
default(S)
whereS
is a value type
We extend this to add the following
- an expression of the form
this.Identifier
This new form is called a caller-receiver default-argument, and is allowed only if all of the following are satisfied
- The method in which it appears is an instance method; and
- The expression
this.Identifier
binds to an instance member of the enclosing type, which must be either a field or a property; and - The member to which it binds (and the
get
accessor, if it is a property) is at least as accessible as the method; and - The type of
this.Identifier
is implicitly convertible by an identity or nullable conversion to the type of the parameter (this is an existing constraint on default-argument).
When an argument is omitted from an invocation of a function member for a corresponding optional parameter with a caller-receiver default-argument, the value of the receiver's member is implicitly passed.
Design Notes: the main reason for the caller-receiver parameter is to support the with-expression. The idea is that you can declare a method like this
class Point { public readonly int X; public readonly int Y; public Point With(int x = this.X, int y = this.Y) => new Point(x, y); // etc }and then use it like this
Point p = new Point(3, 4); p = p.With(x: 1);To create a new
Point
just like an existingPoint
but with the value ofX
changed.It is an open question whether or not the syntactic form of the with-expression is worth adding once we have support for caller-receiver parameters, so it is possible we would do this instead of rather than in addition to the with-expression.
- Open issue: What is the order in which a caller-receiver default-argument is evaluated with respect to other arguments? Should we say that it is unspecified?
A new expression form is proposed:
primary_expression
: with_expression
;
with_expression
: primary_expression 'with' '{' with_initializer_list '}'
;
with_initializer_list
: with_initializer
| with_initializer ',' with_initializer_list
;
with_initializer
: identifier '=' expression
;
The token with
is a new context-sensitive keyword.
Each identifier on the left of a with_initializer must bind to an accessible instance field or property of the type of the primary_expression of the with_expression. There may be no duplicated name among these identifiers of a given with_expression.
A with_expression of the form
e1
with
{
identifier = e2, ...}
is treated as an invocation of the form
e1
.With(
identifier2:
e2, ...)
Where, for each method named With
that is an accessible instance member of e1, we select identifier2 as the name of the first parameter in that method that has a caller-receiver parameter that is the same member as the instance field or property bound to identifier. If no such parameter can be identified that method is eliminated from consideration. The method to be invoked is selected from among the remaining candidates by overload resolution.
Design Notes: Given caller-receiver parameters, many of the benefits of the with-expression are available without this special syntax form. We are therefore considering whether or not it is needed. Its main benefit is allowing one to program in terms of the names of fields and properties, rather than in terms of the names of parameters. In this way we improve both readability and the quality of tooling (e.g. go-to-definition on the identifier of a with_expression would navigate to the property rather than to a method parameter).
- Open issue: This description should be modified to support extension methods.
See the Pattern Matching Specification for a specification of Deconstruct
and its relationship to pattern-matching.
Design Notes: By virtue of the compiler-generated
Deconstruct
as specified herein, and the specification for pattern-matching, a record declarationpublic class Point(int X, int Y);will support positional pattern-matching as follows
Point p = new Point(3, 4); if (p is Point(3, var y)) { // if X is 3 Console.WriteLine(y); // print Y }
The syntax for a class
or struct
declaration is extended to support value parameters; the parameters become properties of the type:
class_declaration
: attributes? class_modifiers? 'partial'? 'class' identifier type_parameter_list?
record_parameters? record_class_base? type_parameter_constraints_clauses? class_body
;
struct_declaration
: attributes? struct_modifiers? 'partial'? 'struct' identifier type_parameter_list?
record_parameters? struct_interfaces? type_parameter_constraints_clauses? struct_body
;
record_class_base
: class_type record_base_arguments?
| interface_type_list
| class_type record_base_arguments? ',' interface_type_list
;
record_base_arguments
: '(' argument_list? ')'
;
record_parameters
: '(' record_parameter_list? ')'
;
record_parameter_list
: record_parameter
| record_parameter ',' record_parameter_list
;
record_parameter
: attributes? type identifier record_property_name? default_argument?
;
record_property_name
: ':' identifier
;
class_body
: '{' class_member_declarations? '}'
| ';'
;
struct_body
: '{' struct_members_declarations? '}'
| ';'
;
Design Notes: Because record types are often useful without the need for any members explicitly declared in a class-body, we modify the syntax of the declaration to allow a body to be simply a semicolon.
A class (struct) declared with the record-parameters is called a record class (record struct), either of which is a record type.
- Open issue: We need to include primary_constructor_body in the grammar so that it can appear inside a record type declaration.
- Open issue: What are the name conflict rules for the parameter names? Presumably one is not allowed to conflict with a type parameter or another record-parameter.
- Open issue: We need to specify the scope of the record-parameters. Where can they be used? Presumably within instance field initializers and primary_constructor_body at least.
- Open issue: Can a record type declaration be partial? If so, must the parameters be repeated on each part?
In addition to the members declared in the class-body, a record type has the following additional members:
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 constructor to be suppressed.
At runtime the primary constructor
- initializes compiler-generated backing fields for the properties corresponding to the value parameters (if these properties are compiler-provided; see 1.1.2); then
- executes the instance field initializers appearing in the class-body; and then
- invokes a base class constructor:
- If there are arguments in the record_base_arguments, a base constructor selected by overload resolution with these arguments is invoked;
- Otherwise a base constructor is invoked with no arguments.
- executes the body of each primary_constructor_body, if any, in source order.
- Open issue: We need to specify that order, particularly across compilation units for partials.
- Open Issue: We need to specify that every explicitly declared constructor must chain to the primary constructor.
- Open issue: Should it be allowed to change the access modifier on the primary constructor?
- Open issue: In a record struct, it is an error for there to be no record parameters?
primary_constructor_body
: attributes? constructor_modifiers? identifier block
;
A primary_constructor_body may only be used within a record type declaration. The identifier of a primary_constructor_body shall name the record type in which it is declared.
The primary_constructor_body does not declare a member on its own, but is a way for the programmer to provide attributes for, and specify the access of, a record type's primary constructor. It also enables the programmer to provide additional code that will be executed when an instance of the record type is constructed.
- Open issue: We should note that a struct default constructor bypasses this.
- Open issue: We should specify the execution order of initialization.
- Open issue: Should we allow something like a primary_constructor_body (presumably without attributes and modifiers) in a non-record type declaration, and treat it like we would the code of an instance field initializer?
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. Its name is the identifier of the record_property_name, if present, or the identifier of the record_parameter otherwise. If no concrete (i.e. non-abstract) public 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
sealed
record class: - A
private
readonly
field is produced as a backing field for areadonly
property. Its value is initialized during construction with the value of the corresponding primary constructor parameter. - The property's
get
accessor is implemented to return the value of the backing field. - Each "matching" inherited virtual property's
get
accessor is overridden.
Design notes: In other words, if you extend a base class or implement an interface that declares a public abstract property with the same name and type as a record parameter, that property is overridden or implemented.
- Open issue: Should it be possible to change the access modifier on a property when it is explicitly declared?
- Open issue: Should it be possible to substitute a field for a property?
For a record struct or a sealed
record class, implementations of the methods object.GetHashCode()
and object.Equals(object)
are produced by the compiler unless provided by the user.
- Open issue: We should precisely specify their implementation.
- Open issue: We should also add the interface
IEquatable<T>
for the record type and specify that implementations are provided. - Open issue: We should also specify that we implement every
IEquatable<T>.Equals
. - Open issue: We should specify precisely how we solve the problem of Equals in the face of record inheritance: specifically how we generate equality methods such that they are symmetric, transitive, reflexive, etc.
- Open issue: It has been proposed that we implement
operator ==
andoperator !=
for record types. - Open issue: Should we auto-generate an implementation of
object.ToString
?
A record type has a compiler-generated public
method void Deconstruct
unless one with any signature is provided by the user. Each parameter is an out
parameter of the same name and type as the corresponding parameter of the record type. The compiler-provided implementation of this method shall assign each out
parameter with the value of the corresponding property.
See the pattern-matching specification for the semantics of Deconstruct
.
Unless there is a user-declared member named With
declared, a record type has a compiler-provided method named With
whose return type is the record type itself, and containing one value parameter corresponding to each record-parameter in the same order that these parameters appear in the record type declaration. Each parameter shall have a caller-receiver default-argument of the corresponding property.
In an abstract
record class, the compiler-provided With
method is abstract. In a record struct, or a sealed record class, the compiler-provided With
method is sealed
. Otherwise the compiler-provided With
method is `virtual and its implementation shall return a new instance produced by invoking the the primary constructor with the parameters as arguments to create a new instance from the parameters, and return that new instance.
- Open issue: We should also specify under what conditions we override or implement inherited virtual
With
methods orWith
methods from implemented interfaces. - Open issue: We should say what happens when we inherit a non-virtual
With
method.
Design notes: Because record types are by default immutable, the
With
method provides a way of creating a new instance that is the same as an existing instance but with selected properties given new values. For example, givenpublic class Point(int X, int Y);there is a compiler-provided member
public virtual Point With(int X = this.X, int Y = this.Y) => new Point(X, Y);Which enables an variable of the record type
var p = new Point(3, 4);to be replaced with an instance that has one or more properties different
p = p.With(X: 5);This can also be expressed using the with_expression:
p = p with { X = 5 };
Because the programmer can add members to a record type declaration, it is often possible to change the set of record elements without affecting existing clients. For example, given an initial version of a record type
// v1
public class Person(string Name, DateTime DateOfBirth);
A new element of the record type can be compatibly added in the next revision of the type without affecting binary or source compatibility:
// v2
public class Person(string Name, DateTime DateOfBirth, string HomeTown)
{
// Note: below operations added to retain binary compatibility with v1
public Person(string Name, DateTime DateOfBirth) : this(Name, DateOfBirth, string.Empty) {}
public static void operator is(Person self, out string Name, out DateTime DateOfBirth)
{ Name = self.Name; DateOfBirth = self.DateOfBirth; }
public Person With(string Name, DateTime DateOfBirth) => new Person(Name, DateOfBirth);
}
This record struct
public struct Pair(object First, object Second);
is translated to this code
public struct Pair : IEquatable<Pair>
{
public object First { get; }
public object Second { get; }
public Pair(object First, object Second)
{
this.First = First;
this.Second = Second;
}
public bool Equals(Pair other) // for IEquatable<Pair>
{
return Equals(First, other.First) && Equals(Second, other.Second);
}
public override bool Equals(object other)
{
return (other as Pair)?.Equals(this) == true;
}
public override int GetHashCode()
{
return (First?.GetHashCode()*17 + Second?.GetHashCode()).GetValueOrDefault();
}
public Pair With(object First = this.First, object Second = this.Second) => new Pair(First, Second);
public void Deconstruct(out object First, out object Second)
{
First = self.First;
Second = self.Second;
}
}
- Open issue: should the implementation of Equals(Pair other) be a public member of Pair?
- Open issue: This implementation of
Equals
is not symmetric in the face of inheritance. - Open issue: Should a record declare
operator ==
andoperator !=
?
Design notes: Because one record type can inherit from another, and this implementation of
Equals
would not be symmetric in that case, it is not correct. We propose to implement equality this way:public bool Equals(Pair other) // for IEquatable<Pair> { return other != null && EqualityContract == other.EqualityContract && Equals(First, other.First) && Equals(Second, other.Second); } protected virtual Type EqualityContract => typeof(Pair);Derived records would
override EqualityContract
. The less attractive alternative is to restrict inheritance.
This sealed record class
public sealed class Student(string Name, decimal Gpa);
is translated into this code
public sealed class Student : IEquatable<Student>
{
public string Name { get; }
public decimal Gpa { get; }
public Student(string Name, decimal Gpa)
{
this.Name = Name;
this.Gpa = Gpa;
}
public bool Equals(Student other) // for IEquatable<Student>
{
return other != null && Equals(Name, other.Name) && Equals(Gpa, other.Gpa);
}
public override bool Equals(object other)
{
return this.Equals(other as Student);
}
public override int GetHashCode()
{
return (Name?.GetHashCode()*17 + Gpa?.GetHashCode()).GetValueOrDefault();
}
public Student With(string Name = this.Name, decimal Gpa = this.Gpa) => new Student(Name, Gpa);
public void Deconstruct(out string Name, out decimal Gpa)
{
Name = self.Name;
Gpa = self.Gpa;
}
}
This abstract record class
public abstract class Person(string Name);
is translated into this code
public abstract class Person : IEquatable<Person>
{
public string Name { get; }
public Person(string Name)
{
this.Name = Name;
}
public bool Equals(Person other)
{
return other != null && Equals(Name, other.Name);
}
public override bool Equals(object other)
{
return Equals(other as Person);
}
public override int GetHashCode()
{
return (Name?.GetHashCode()).GetValueOrDefault();
}
public abstract Person With(string Name = this.Name);
public void Deconstruct(Person self, out string Name)
{
Name = self.Name;
}
}
Given the abstract record class Person
above, this sealed record class
public sealed class Student(string Name, decimal Gpa) : Person(Name);
is translated into this code
public sealed class Student : Person, IEquatable<Student>
{
public override string Name { get; }
public decimal Gpa { get; }
public Student(string Name, decimal Gpa) : base(Name)
{
this.Name = Name;
this.Gpa = Gpa;
}
public override bool Equals(Student other) // for IEquatable<Student>
{
return Equals(Name, other.Name) && Equals(Gpa, other.Gpa);
}
public bool Equals(Person other) // for IEquatable<Person>
{
return (other as Student)?.Equals(this) == true;
}
public override bool Equals(object other)
{
return (other as Student)?.Equals(this) == true;
}
public override int GetHashCode()
{
return (Name?.GetHashCode()*17 + Gpa.GetHashCode()).GetValueOrDefault();
}
public Student With(string Name = this.Name, decimal Gpa = this.Gpa) => new Student(Name, Gpa);
public override Person With(string Name = this.Name) => new Student(Name, Gpa);
public void Deconstruct(Student self, out string Name, out decimal Gpa)
{
Name = self.Name;
Gpa = self.Gpa;
}
}
As with any language feature, we must question whether the additional complexity to the language is repaid in the additional clarity offered to the body of C# programs that would benefit from the feature.
We considered adding primary constructors in C# 6. Although they occupy the same syntactic surface as this proposal, we found that they fell short of the advantages offered by records.
Open questions appear in the body of the proposal.
TBD