仅限 Init 的资源库

注意

本文是特性规范。 此规范是功能的设计文档。 它包括建议的规范变更,以及功能设计和开发过程中所需的信息。 这些文章将持续发布,直至建议的规范变更最终确定并纳入当前的 ECMA 规范。

功能规范与已完成的实现之间可能存在一些差异。 这些差异记录在相关的语言设计会议 (LDM) 说明中。

可以在有关规范的文章中了解更多有关将功能规范子块纳入 C# 语言标准的过程。

支持者问题:https://github.com/dotnet/csharplang/issues/39

总结

此建议将仅初始化属性和索引器的概念添加到 C# 中。 在创建对象时可以设置这些属性和索引器,但只有在对象创建完成后,它们才会有效地达到get状态。 这允许在 C# 中实现更灵活的不可变模型。

动机

在 C# 中构建不可变数据的底层机制自 1.0 版以来一直未变。 它们保持:

  1. 将字段声明为 readonly
  2. 仅包含 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 表达式初始值设定项期间
  • thisbase 上包含或派生类型的实例构造函数内
  • initthis 上任何属性的 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 访问器还可以执行以下操作:

  1. 通过 initthis 可调用其他 base 访问器
  2. 通过 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 访问器只能用于实例属性
  • 属性不能同时包含 initset 访问器
  • 如果基类具有 init,则属性的所有覆盖都必须具有 init。 此规则也适用于接口的实现。

只读结构

readonly struct 的属性和 readonly 属性上允许使用 init 访问器(自动实现的访问器和手动实现的访问者)。 在 init 和非readonlyreadonly 类型中,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
    {
    }
}

编译器将根据全名匹配类型。 不要求它必须出现在核心库中。 如果有多个同名类型,则编译器将按以下顺序连接断点:

  1. 正在编译的项目中定义的
  2. corelib 中定义的

如果两者都不存在,则会出现类型歧义错误。

IsExternalInit将进一步介绍 的设计

问题

中断性变更

此功能编码方式的主要关键点之一将取决于以下问题:

使用 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 的编译器会将其视为一个简单的读/写属性,并允许访问。

这似乎意味着这个决定是在牺牲二进制兼容性的额外安全性之间做出的选择。 深入分析后,额外的安全措施并不像看起来那样。 它无法在下列情况时提供保护:

  1. public 成员的反思
  2. 使用 dynamic
  3. 无法识别 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 数据失败的警告有很多相似之处。 因此,警告似乎在这里有一些价值?

不过,这种警告也有很大的弊端:

  1. 这使得将 readonly 更改为 init 的兼容性问题变得更加复杂。
  2. 它需要携带额外的元数据来表示调用方需要初始化的成员。

此外,如果我们认为在强制对象创建者对特定字段进行警告/出错的整体方案中有价值,那么这可能是一个有意义的通用功能。 没有理由将其仅限于 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 修饰符只能在 structclass 上声明,以自动将所有字段标记为 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 验证规则需要分为两部分:

  1. 允许 init 成员对 readonly 字段进行设置。
  2. 确定何时可以合法调用 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 属性的一种潜在实现方式是使 initset 完全分离。 这意味着属性可能具有三个不同的访问器:getsetinit

这具有潜在优势,可以在保持二进制兼容性的同时,允许使用 modreq 来确保正确性。 实现办法大致如下:

  1. 如果存在 set,则始终发出 init 访问器。 当开发人员没有定义时,它只是对 set 的引用。
  2. 对象初始值设定项中的属性集将始终使用 init(如果存在);但如果缺少,则回退到 set

这意味着开发人员总是可以安全地从属性中删除 init

这种设计的缺点是,只有当有一个 set 时,init 总是发出时,这种设计才有用。 该语言不知道过去是否删除了 init,它必须假定init已经被删除,因此必须始终发出 。 这将导致元数据显著扩展,而在这里为了兼容性付出这样的代价是完全不值得的。