注意
本文是特性规范。 此规范是功能的设计文档。 它包括建议的规范变更,以及功能设计和开发过程中所需的信息。 这些文章将持续发布,直至建议的规范变更最终确定并纳入当前的 ECMA 规范。
功能规范与已完成的实现之间可能存在一些差异。 这些差异记录在相关的语言设计会议 (LDM) 说明中。
可以在有关规范的文章中了解更多有关将功能规范子块纳入 C# 语言标准的过程。
支持者问题:https://github.com/dotnet/csharplang/issues/39
总结
此建议将仅初始化属性和索引器的概念添加到 C# 中。
在创建对象时可以设置这些属性和索引器,但只有在对象创建完成后,它们才会有效地达到get
状态。
这允许在 C# 中实现更灵活的不可变模型。
动机
在 C# 中构建不可变数据的底层机制自 1.0 版以来一直未变。 它们保持:
- 将字段声明为
readonly
。 - 仅包含
get
访问器的声明属性。
这些机制在允许构建不可变数据方面非常有效,但它们是通过增加类型样板代码的成本,并从对象和集合初始值设定项等功能中选择这些类型来实现的。 这意味着开发人员必须在易用性和不可变性之间进行选择。
简单的不可变对象(如 Point
)在支持构造时所需的样板代码是声明类型时所需代码的两倍。 类型越大,这种样板文件的成本就越高:
struct Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
this.X = x;
this.Y = y;
}
}
init
访问器允许调用方在构造过程中更改成员,从而使不可变对象更加灵活。 这意味着对象的不可变属性可以参与对象初始化器,从而消除了类型中对所有构造函数样板的需求。 Point
类型现在只是:
struct Point
{
public int X { get; init; }
public int Y { get; init; }
}
然后,用户可以使用对象初始化器创建对象
var p = new Point() { X = 42, Y = 13 };
详细设计
init 访问器
仅限 Init 的资源库(或索引器)是通过用 init
访问器替换 set
访问器来声明的:
class Student
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
在以下情况下,包含 init
访问器的实例属性被视为可设置,但在本地函数或 lambda 中除外:
- 在对象初始值设定项期间
- 在
with
表达式初始值设定项期间 - 在
this
或base
上包含或派生类型的实例构造函数内 - 在
init
或this
上任何属性的base
访问器内 - 具有命名参数的属性使用情况内
在本文档中,上述可设置 init
访问器的时间统称为对象的构建阶段。
这意味着 Student
类可以通过以下方式来使用:
var s = new Student()
{
FirstName = "Jared",
LastName = "Parosns",
};
s.LastName = "Parsons"; // Error: LastName is not settable
关于何时可以设置 init
访问器的规则可以扩展到不同的类型层次结构。 如果成员是可访问的,并且该对象已知处于构建阶段,则该成员可以设置。 具体来说,它允许:
class Base
{
public bool Value { get; init; }
}
class Derived : Base
{
public Derived()
{
// Not allowed with get only properties but allowed with init
Value = true;
}
}
class Consumption
{
void Example()
{
var d = new Derived() { Value = true };
}
}
在调用 init
访问器时,已知实例处于开放构造阶段。 因此,除了正常的 set
访问器可以执行的操作外,init
访问器还可以执行以下操作:
- 通过
init
或this
可调用其他base
访问器 - 通过
this
分配在同一类型上声明的readonly
字段
class Complex
{
readonly int Field1;
int Field2;
int Prop1 { get; init; }
int Prop2
{
get => 42;
init
{
Field1 = 13; // okay
Field2 = 13; // okay
Prop1 = 13; // okay
}
}
}
从 readonly
访问器分配 init
字段的能力仅限于与访问器在同一类型上声明的字段。 它不能用于在基类型中分配 readonly
字段。 这一规则确保了类型作者对其类型的可变性行为保持控制。 不希望使用 init
的开发人员不会受到选择使用其他类型的影响:
class Base
{
internal readonly int Field;
internal int Property
{
get => Field;
init => Field = value; // Okay
}
internal int OtherProperty { get; init; }
}
class Derived : Base
{
internal readonly int DerivedField;
internal int DerivedProperty
{
get => DerivedField;
init
{
DerivedField = 42; // Okay
Property = 0; // Okay
Field = 13; // Error Field is readonly
}
}
public Derived()
{
Property = 42; // Okay
Field = 13; // Error Field is readonly
}
}
在虚拟属性中使用 init
时,所有替代也必须标记为 init
。 同样,也不可能用 set
覆盖简单的 init
。
class Base
{
public virtual int Property { get; init; }
}
class C1 : Base
{
public override int Property { get; init; }
}
class C2 : Base
{
// Error: Property must have init to override Base.Property
public override int Property { get; set; }
}
interface
声明还可以通过以下模式参与 init
样式初始化:
interface IPerson
{
string Name { get; init; }
}
class Init
{
void M<T>() where T : IPerson, new()
{
var local = new T()
{
Name = "Jared"
};
local.Name = "Jraed"; // Error
}
}
该功能的限制:
init
访问器只能用于实例属性- 属性不能同时包含
init
和set
访问器 - 如果基类具有
init
,则属性的所有覆盖都必须具有init
。 此规则也适用于接口的实现。
只读结构
readonly struct
的属性和 readonly
属性上允许使用 init
访问器(自动实现的访问器和手动实现的访问者)。 在 init
和非readonly
readonly
类型中,readonly
访问器本身不允许被标记为 struct
。
readonly struct ReadonlyStruct1
{
public int Prop1 { get; init; } // Allowed
}
struct ReadonlyStruct2
{
public readonly int Prop2 { get; init; } // Allowed
public int Prop3 { get; readonly init; } // Error
}
元数据编码
属性 init
访问器将作为标准 set
访问器发出,其返回类型标记为 IsExternalInit
的 modreq。 这是一个新类型,其定义如下:
namespace System.Runtime.CompilerServices
{
public sealed class IsExternalInit
{
}
}
编译器将根据全名匹配类型。 不要求它必须出现在核心库中。 如果有多个同名类型,则编译器将按以下顺序连接断点:
- 正在编译的项目中定义的
- corelib 中定义的
如果两者都不存在,则会出现类型歧义错误。
问题
中断性变更
此功能编码方式的主要关键点之一将取决于以下问题:
使用
init
替换set
是二进制重大更改吗?
将 init
替换为 set
,从而使属性完全可写,这绝不是对非虚拟属性的源代码重大更改。 它只是扩展了可以写入属性的方案集。 唯一有问题的行为是,这是否仍然是一个二进制重大更改。
如果我们想要将 init
更改为 set
,使其成为源代码和二进制兼容的更改,那么我们在下面关于 modreq 与属性的决策上将被迫做出决定,因为这将排除 modreqs 作为解决方案。 另一方面,如果这被视为无趣,则这会使 modreq 与属性决策的影响较小。
解决方案 此方案被 LDM 认为没有说服力。
Modreqs 与属性
init
属性访问器的发出策略在元数据期间发出时必须在使用属性或 modreqs 之间进行选择。 这些需要考虑不同的权衡。
对属性集访问器使用 modreq 声明进行批注,意味着 CLI 兼容的编译器会忽略该访问器,除非它能理解 modreq。 这意味着只有能够识别 init
的编译器才会读取该成员。 编译器对 init
不知情时,将忽略 set
访问器,因此不会意外地将该属性视为读/写。
modreq 的缺点是 init
会成为 set
访问器的二进制签名的一部分。 添加或删除 init
会破坏应用程序的二进制兼容性。
使用属性来注解 set
访问器意味着,只有理解属性的编译器才知道要限制对它的访问。 一个不了解 init
的编译器会将其视为一个简单的读/写属性,并允许访问。
这似乎意味着这个决定是在牺牲二进制兼容性的额外安全性之间做出的选择。 深入分析后,额外的安全措施并不像看起来那样。 它无法在下列情况时提供保护:
- 对
public
成员的反思 - 使用
dynamic
- 无法识别 modreqs 的编译器
还应该考虑的是,当我们完成 .NET 5 的 IL 验证规则时,init
将成为其中的一条规则。 这意味着,只需验证生成可验证 IL 代码的编译器即可获得额外的执行措施。
.NET 的主要语言(C#、F# 和 VB)都将进行更新,以识别这些 init
访问器。 因此,这里唯一现实的情况是,当 C# 9 编译器发出 init
属性时,它们会被 C# 8、VB 15 等... C# 8 旧工具集看到。 这是考虑和权衡二进制兼容性的权衡。
注意本讨论主要只适用于成员,不适用于字段。 虽然 init
字段被 LDM 拒绝,但在讨论 modreq 与属性时,它们仍然值得考虑。 字段的 init
功能放宽了现有的 readonly
限制。 这意味着,如果我们以 readonly
+ 属性的形式发出字段,就不会出现旧版编译器误用字段的风险,因为它们已经能识别 readonly
。 因此,在这里使用 modreq 不会增加任何额外的保护。
解决方法 该功能将使用 modreq 对属性 init
setter 进行编码。 令人信服的因素包括(排名不分先后):
- 希望阻止旧版编译器违反
init
语义 - 希望在
init
声明或virtual
中添加或删除interface
都是源代码和二进制的重大更改。
考虑到没有明显的支持将 init
删除视为二进制兼容更改,因此直接选择使用 modreq。
init 与 initonly
在我们的 LDM 会议上,有三种语法形式得到了重点考虑:
// 1. Use init
int Option1 { get; init; }
// 2. Use init set
int Option2 { get; init set; }
// 3. Use initonly
int Option3 { get; initonly; }
解决方法 LDM 中没有压倒性支持的语法。
一个引起广泛关注的问题是,语法的选择将如何影响我们将来作为一般功能处理 init
成员的能力。
选择选项 1 意味着将来很难定义具有 init
样式 get
方法的属性。 最终决定,如果我们决定在未来继续使用一般 init
成员,我们可以允许 init
作为属性访问器列表中的修饰符,以及 init set
的简写。 以下两个声明本质上是相同的。
int Property1 { get; init; }
int Property1 { get; init set; }
已决定将 init
作为属性访问器列表中的独立访问器继续推进。
init 失败时发出警告
请考虑以下场景。 类型声明了一个仅包含 init
的成员,该成员未在构造函数中设置。 如果没有初始化值,构造对象的代码是否应该收到警告?
此时,很明显该字段永远不会被设置,因此与初始化 private
数据失败的警告有很多相似之处。
因此,警告似乎在这里有一些价值?
不过,这种警告也有很大的弊端:
- 这使得将
readonly
更改为init
的兼容性问题变得更加复杂。 - 它需要携带额外的元数据来表示调用方需要初始化的成员。
此外,如果我们认为在强制对象创建者对特定字段进行警告/出错的整体方案中有价值,那么这可能是一个有意义的通用功能。 没有理由将其仅限于 init
成员。
解决方案 使用 init
字段和属性时不会发出警告。
LDM 希望就必填字段和属性的想法进行更广泛的讨论。 这可能会导致我们重新考虑我们对 init
成员和验证的立场。
允许 init 作为字段修饰符
同样,init
可以作为属性访问器,也可以作为字段的指定,使其具有与 init
属性类似的行为。
这将允许在类型、派生类型或对象初始值设定项完成构造之前分配字段。
class Student
{
public init string FirstName;
public init string LastName;
}
var s = new Student()
{
FirstName = "Jarde",
LastName = "Parsons",
}
s.FirstName = "Jared"; // Error FirstName is readonly
在元数据中,这些字段的标记方式与 readonly
字段相同,但增加了一个属性或 modreq 来表明它们是 init
风格字段。
解决方法 LDM 同意这一提议是合理的,但总体而言,这种方案与属性脱节。 目前决定只处理 init
属性。
这具有适当的灵活性,因为 init
属性可以更改属性声明类型上的 readonly
字段。 如果有重要的客户反馈能够证明这种情形,此问题将被重新考虑。
允许 init 作为类型修饰符
同样,readonly
修饰符可以应用于 struct
,以自动将所有字段声明为 readonly
。而 init
修饰符只能在 struct
或 class
上声明,以自动将所有字段标记为 init
。
这意味着下面两个类型声明是等效的:
struct Point
{
public init int X;
public init int Y;
}
// vs.
init struct Point
{
public int X;
public int Y;
}
解决方法 此功能在这里过于刻意,因此与其所基于的 readonly struct
功能冲突。 readonly struct
功能很简单,它将 readonly
应用于所有成员:字段、方法等...而 init struct
功能只适用于属性。 这实际上最终会让用户感到困惑。
鉴于 init
只对类型的某些方面有效,我们拒绝了将其作为类型修饰符的想法。
注意事项
兼容性
init
功能被设计为仅与现有 get
属性兼容。 具体来说,这意味着对一个属性的完全累加的更改,这个属性目前只有 get
,但需要更灵活的对象创建语义。
例如,请考虑以下类型:
class Name
{
public string First { get; }
public string Last { get; }
public Name(string first, string last)
{
First = first;
Last = last;
}
}
向这些属性添加 init
是一个非重大更改:
class Name
{
public string First { get; init; }
public string Last { get; init; }
public Name(string first, string last)
{
First = first;
Last = last;
}
}
IL 验证
当 .NET Core 决定重新实现 IL 验证时,将需要调整规则以考虑 init
成员。 这将需要纳入用于非可变访问 readonly
数据的规则变更中。
IL 验证规则需要分为两部分:
- 允许
init
成员对readonly
字段进行设置。 - 确定何时可以合法调用
init
成员。
首先是对现有规则的简单调整。 可以教导 IL 验证程序识别 init
成员,之后只需考虑使 readonly
字段能够在此类成员中的 this
上设置。
第二条规则更为复杂。 在对象初始值设定项的简单情况下,规则简单明了。 当 new
表达式的结果仍在堆栈上时,调用 init
成员应该是合法的。 也就是说,在值被存储到局部、数组元素或字段中,或作为参数传递给其他方法之前,调用 init
成员仍然是合法的。 这确保了一旦将 new
表达式的结果发布到命名标识符(而不是 this
),调用 init
成员将不再合法。
然而,更复杂的情况是当我们混合使用 init
成员、对象初始值设定项和 await
时。 这可能会导致新创建的对象被临时提升到状态机中,从而被放入字段中。
var student = new Student()
{
Name = await SomeMethod()
};
在这里,new Student()
的结果将在 Name
集合出现之前作为字段被提升到状态机中。 编译器需要以一种方式标记这些被提升的字段,使 IL 验证器理解它们不是用户可访问的,因此不会违反 init
的预期语义。
init 成员
init
修饰符可扩展为适用于所有实例成员。 这将概括对象构造过程中 init
的概念,并允许类型声明辅助方法,这些方法可以参与构造过程,以初始化 init
字段和属性。
此类成员将具有 init
访问器在此设计中所具有的所有限制。 虽然对此需求存在疑问,但可以在语言的未来版本中以兼容的方式安全添加。
生成三个访问器
init
属性的一种潜在实现方式是使 init
与 set
完全分离。 这意味着属性可能具有三个不同的访问器:get
、set
、init
。
这具有潜在优势,可以在保持二进制兼容性的同时,允许使用 modreq 来确保正确性。 实现办法大致如下:
- 如果存在
set
,则始终发出init
访问器。 当开发人员没有定义时,它只是对set
的引用。 - 对象初始值设定项中的属性集将始终使用
init
(如果存在);但如果缺少,则回退到set
。
这意味着开发人员总是可以安全地从属性中删除 init
。
这种设计的缺点是,只有当有一个 set
时,init
总是发出时,这种设计才有用。 该语言不知道过去是否删除了 init
,它必须假定init
已经被删除,因此必须始终发出 。 这将导致元数据显著扩展,而在这里为了兼容性付出这样的代价是完全不值得的。