必需的成员
注意
本文是特性规范。 该规范充当该功能的设计文档。 它包括建议的规范更改,以及功能设计和开发过程中所需的信息。 这些文章将发布,直到建议的规范更改最终确定并合并到当前的 ECMA 规范中。
功能规范与已完成的实现之间可能存在一些差异。 这些差异记录在相关的语言设计会议 (LDM) 说明中。
可以在 规范一文中详细了解将功能规范采用 C# 语言标准的过程。
总结
此提案添加了一个方法,指定在对象初始化期间必须设置属性或字段,迫使实例创建者在创建点的对象初始化器中为成员提供初始值。
动机
在现代,对象层次结构需要大量的样板代码才能在整个层次结构的各个级别上传输数据。 让我们看看 C# 8 中可能定义的涉及 Person
的简单层次结构:
class Person
{
public string FirstName { get; }
public string MiddleName { get; }
public string LastName { get; }
public Person(string firstName, string lastName, string? middleName = null)
{
FirstName = firstName;
LastName = lastName;
MiddleName = middleName ?? string.Empty;
}
}
class Student : Person
{
public int ID { get; }
public Student(int id, string firstName, string lastName, string? middleName = null)
: base(firstName, lastName, middleName)
{
ID = id;
}
}
其中存在很多重复的内容:
- 在层次结构的根部,每个属性的类型必须重复两次,且名称必须重复四次。
- 在派生级别,每个继承属性的类型必须重复一次,并且名称必须重复两次。
这是一个简单的继承层次结构,具有3个属性和1个继承级别,而这类结构的许多实际示例则可深入到更多级别,累积越来越多的属性进行传递。 Roslyn 就是这样一个代码库,例如,我们的 CST 和 AST 就是由各种树类型构成的。 这种嵌套非常繁琐,以至于我们使用代码生成器来生成这些类型的构造函数和定义,许多客户也采取类似的方法来解决这个问题。 C# 9 引入了记录,对于某些方案,这可以改善相关情况:
record Person(string FirstName, string LastName, string MiddleName = "");
record Student(int ID, string FirstName, string LastName, string MiddleName = "") : Person(FirstName, LastName, MiddleName);
record
消除了第一个重复源,但第二个重复源保持不变:遗憾的是,这是随着层次结构的增长而增长的重复源,也是在层次结构中进行更改后需要修复的重复中最棘手的部分,因为它需要在所有位置追逐层次结构,甚至可能跨越项目,并可能对用户造成影响。
作为避免这种重复的解决方法,我们长期以来看到消费者采用对象初始值设定项作为避免编写构造函数的一种方式。 但在 C# 9 之前,这有 2 个主要缺点:
- 对象的层次结构必须完全可变,并且每个属性都需要有
set
访问器。 - 无法确保图中对象的每个实例化都会设置每个成员。
C# 9 再次解决了此处的第一个问题,方法是引入 init
访问器:通过它,可以在对象创建/初始化上设置这些属性,但随后不能设置这些属性。 但是,我们仍然有第二个问题:自 C# 1.0 以来,C# 中的属性一直是可选的。 C# 8.0 中引入的可为空引用类型解决了此问题的一部分:如果构造函数未初始化不可为 null 的引用类型属性,则用户将对此发出警告。 这无法解决问题:此处的用户希望避免在构造函数中重复其类型的许多部分,而是希望通过向使用者传递 要求 来设置属性。 它也不会对来自 Student
的 ID
发出任何警告,因为那是一个值类型。 这些方案在数据库模型 ORM(如 EF Core)中极为常见,因为它们需要有一个公共无参数构造函数,但又要根据属性的 Null 性来驱动行的 Null 性。
此提案试图通过引入 C# 的一种新特性来解决这些问题:必需成员。 使用者(而不是类型作者)需要使用各种自定义来初始化所需的成员,以便灵活处理多个构造函数和其他方案。
详细设计
class
、struct
和 record
类型可以声明 required_member_list。 此列表列出了类型的所有被视为必需的属性和字段,它们必须在构建和初始化该类型的实例时进行初始化。 类型会自动从其基类型继承这些列表,从而提供一种无缝的体验,去除样板代码和重复代码。
required
修饰符
我们将 'required'
添加到 field_modifier 和 property_modifier的修饰符列表中。 类型的 required_member_list 由所有已应用 required
的成员组成。 因此,之前提到的 Person
类型现在如下所示:
public class Person
{
// The default constructor requires that FirstName and LastName be set at construction time
public required string FirstName { get; init; }
public string MiddleName { get; init; } = "";
public required string LastName { get; init; }
}
具有 required_member_list 的类型的所有构造函数都会自动播发合约,即该类型的使用者必须初始化列表中的所有属性。 如果构造函数播发的合约要求的成员至少与构造函数本身一样不可访问,那么这种做法就是错误的。 例如:
public class C
{
public required int Prop { get; protected init; }
// Advertises that Prop is required. This is fine, because the constructor is just as accessible as the property initer.
protected C() {}
// Error: ctor C(object) is more accessible than required property Prop.init.
public C(object otherArg) {}
}
required
仅在 class
、struct
和 record
类型中有效。 它在 interface
类型中无效。 required
不能与以下修饰符组合:
fixed
ref readonly
ref
const
static
不允许将 required
应用于索引器。
当 Obsolete
应用于类型的必需成员时,编译器将发出警告,并:
- 类型未标记为
Obsolete
,或 - 未带有
SetsRequiredMembersAttribute
的任何构造函数都不会标记为Obsolete
。
SetsRequiredMembersAttribute
具有必需成员的类型中的所有构造函数(或者其基类型指定必需成员)必须在调用该构造函数时由使用者设置这些成员。 为了使构造函数免于此要求,可以将 SetsRequiredMembersAttribute
特性应用于构造函数,从而消除这些要求。 构造函数体未经验证,无法确保它确实设置了该类型所需的成员。
SetsRequiredMembersAttribute
从构造函数中删除 所有 要求,并且不会以任何方式检查这些要求的有效性。 注意:如果有必要从具有无效必填成员列表的类型继承,这是一种应急措施:使用 SetsRequiredMembersAttribute
来标记该类型的构造函数,就不会报错。
如果构造函数 C
调用或链接到被赋予 SetsRequiredMembersAttribute
特性的 base
或 this
构造函数,那么 C
也必须被赋予 SetsRequiredMembersAttribute
特性。
对于记录类型,如果记录类型或其任何基本类型都有必需的成员,我们将在记录的合成复制构造函数上发出 SetsRequiredMembersAttribute
。
NB:此提案的早期版本围绕初始化使用了更大的元语言,允许添加和删除构造函数中的单个必需成员,并验证构造函数是否设置了所有必需成员。 对于初始版本而言,这被认为太复杂,并且已删除。 我们可以考虑在之后添加更复杂的合约和修改作为功能。
执法
对于 Ci
类型中带有所需成员 R
的每个构造函数 T
,调用 Ci
的使用者必须执行以下操作之一:
- 在 object_creation_expression 上的 object_initializer 中设置
R
的所有成员, - 或者通过 attribute_target 的 named_argument_list 部分来设置
R
的所有成员。
除非 Ci
的属性为 SetsRequiredMembers
。
如果当前上下文不允许使用 object_initializer 或不是 attribute_target,并且 Ci
是属性不是 SetsRequiredMembers
的对象,则调用 Ci
将是一个错误。
new()
约束
具有无参数构造函数的类型,如果播发合约,则不允许用限制为 new()
的类型参数来代替,因为泛型实例化无法确保满足要求。
struct
default
使用 default
或 default(StructType)
创建的 struct
类型实例不强制执行必需的成员。 即使 StructType
没有无参数构造函数且使用默认结构构造函数,也会对使用 new StructType()
创建的 struct
实例强制执行它们。
可及性
如果在包含类型可见的任何上下文中都无法设置成员,则标记为必需的成员是错误的。
- 如果成员是字段,则不能是
readonly
。 - 如果该成员是一个属性,它必须有一个 setter 或 initer,其访问权限至少与该成员的包含类型相同。
这意味着不允许以下情况:
interface I
{
int Prop1 { get; }
}
public class Base
{
public virtual int Prop2 { get; set; }
protected required int _field; // Error: _field is not at least as visible as Base. Open question below about the protected constructor scenario
public required readonly int _field2; // Error: required fields cannot be readonly
protected Base() { }
protected class Inner
{
protected required int PropInner { get; set; } // Error: PropInner cannot be set inside Base or Derived
}
}
public class Derived : Base, I
{
required int I.Prop1 { get; } // Error: explicit interface implementions cannot be required as they cannot be set in an object initializer
public required override int Prop2 { get; set; } // Error: this property is hidden by Derived.Prop2 and cannot be set in an object initializer
public new int Prop2 { get; }
public required int Prop3 { get; } // Error: Required member must have a setter or initer
public required int Prop4 { get; internal set; } // Error: Required member setter must be at least as visible as the constructor of Derived
}
隐藏 required
成员是一个错误,因为该成员不能再由使用者设置。
重载 required
成员时,必须在方法签名中包含 required
关键字。 这样做的目的是,如果我们将来想允许通过重写来取消对属性的请求,我们就有了设计空间来这样做。
如果成员 required
在基本类型中不是 required
,则允许重写来标记该成员。 如此标记的成员将被添加到派生类型的必需成员列表中。
允许类型重写所需的虚拟属性。 这意味着,如果基本虚拟属性具有存储,并且派生类型尝试访问该属性的基本实现,则它们可能会观察到未初始化的存储。 NB:这是一个通用的 C# 反模式,我们认为这个建议不应该试图解决它。
对可为 null 分析的影响
标记为 required
的成员不需要在构造函数末尾初始化为有效的可为 null 状态。 根据可为 null 分析,该类型和任何基础类型的所有 required
成员在该类型的任何构造函数的开头都被视为默认成员,除非链接到属性为 SetsRequiredMembersAttribute
的 this
或 base
构造函数。
可为 null 分析会对当前类型和基础类型中所有 required
成员发出警告,这些成员在归属于 SetsRequiredMembersAttribute
的构造函数结束时没有有效的可为 null 状态。
#nullable enable
public class Base
{
public required string Prop1 { get; set; }
public Base() {}
[SetsRequiredMembers]
public Base(int unused) { Prop1 = ""; }
}
public class Derived : Base
{
public required string Prop2 { get; set; }
[SetsRequiredMembers]
public Derived() : base()
{
} // Warning: Prop1 and Prop2 are possibly null.
[SetsRequiredMembers]
public Derived(int unused) : base()
{
Prop1.ToString(); // Warning: possibly null dereference
Prop2.ToString(); // Warning: possibly null dereference
}
[SetsRequiredMembers]
public Derived(int unused, int unused2) : this()
{
Prop1.ToString(); // Ok
Prop2.ToString(); // Ok
}
[SetsRequiredMembers]
public Derived(int unused1, int unused2, int unused3) : base(unused1)
{
Prop1.ToString(); // Ok
Prop2.ToString(); // Warning: possibly null dereference
}
}
元数据表示形式
C# 编译器知道以下 2 个属性,并且此功能正常运行所必需的:
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class RequiredMemberAttribute : Attribute
{
public RequiredMemberAttribute() {}
}
}
namespace System.Diagnostics.CodeAnalysis
{
[AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
public sealed class SetsRequiredMembersAttribute : Attribute
{
public SetsRequiredMembersAttribute() {}
}
}
手动将 RequiredMemberAttribute
应用于类型是错误的。
任何标记为 required
的成员都应用了 RequiredMemberAttribute
。 此外,定义此类成员的任何类型都标记为 RequiredMemberAttribute
,作为指示此类型中存在必需成员的标记。 请注意,如果类型 B
派生自 A
,并且 A
定义 required
成员,但 B
不会添加新成员或替代任何现有 required
成员,B
将不会用 RequiredMemberAttribute
进行标记。
为了完全确定 B
中是否有任何必需的成员,需要检查完整的继承层次结构。
类型中包含 required
成员且未应用 SetsRequiredMembersAttribute
的任何构造函数都标有两个属性:
- 具有功能名称
"RequiredMembers"
的System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute
。 - 将
System.ObsoleteAttribute
与字符串"Types with required members are not supported in this version of your compiler"
结合使用,并将该属性标记为错误,以防任何较旧的编译器使用这些构造函数。
我们在这里不使用 modreq
,是因为我们的目标是保持二进制兼容:如果从类型中删除最后一个 required
属性,编译器将不再合成这个 modreq
属性,这是一个破坏二进制的变化,所有使用者都需要重新编译。 了解 required
成员的编译器将忽略此过时属性。 请注意,成员也可以来自基类型:即使当前类型中没有新的 required
成员,如果任何基类型具有 required
成员,则生成此 Obsolete
属性。 如果构造函数已有 Obsolete
属性,则不会生成其他 Obsolete
属性。
我们同时使用 ObsoleteAttribute
和 CompilerFeatureRequiredAttribute
,因为后者是新版本,而较旧的编译器不理解它。 将来,我们也许能够删除 ObsoleteAttribute
和/或不使用它来保护新功能,但现在我们需要这两者进行全面保护。
要为给定类型 T
建立 required
成员 R
的完整列表,包括所有基础类型,则需要运行以下算法:
- 对于每一个
Tb
,从T
开始,通过基本类型链,直到object
为止。 - 如果
Tb
被标记为RequiredMemberAttribute
,则所有标记为RequiredMemberAttribute
的Tb
成员将被收集到Rb
中。- 对于
Rb
中的每个Ri
,如果Ri
被R
中的任何成员重写,则它会被跳过。 - 否则,如果任何
Ri
被R
的成员隐藏,那么所需成员的查找就会失败,并且不会采取进一步的步骤。 调用T
的任何构造函数时,如果属性不是SetsRequiredMembers
,则会出错。 - 否则,
Ri
会被添加到R
。
- 对于
未决问题
嵌套成员初始值设定项
嵌套成员初始值设定项的强制机制是什么? 他们会完全被禁止吗?
class Range
{
public required Location Start { get; init; }
public required Location End { get; init; }
}
class Location
{
public required int Column { get; init; }
public required int Line { get; init; }
}
_ = new Range { Start = { Column = 0, Line = 0 }, End = { Column = 1, Line = 0 } } // Would this be allowed if Location is a struct type?
_ = new Range { Start = new Location { Column = 0, Line = 0 }, End = new Location { Column = 1, Line = 0 } } // Or would this form be necessary instead?
讨论的问题
init
条款的执行程度
init
子句功能未在 C# 11 中实现。 它仍然是一个积极的建议。
我们是否要严格执行 init
子句中指定的不使用初始值设定项的成员必须初始化所有成员? 看来我们很可能会这样做,否则我们就很容易造成一个失败的陷阱。 但是,我们也面临着重新引入在 C# 9 中用 MemberNotNull
解决的同样问题的风险。 如果我们想严格执行这一点,则可能需要一种方法来让辅助方法表明它设置了一个成员。 我们对此进行了一些可能的语法讨论:
- 允许
init
方法。 仅允许从构造函数或其他init
方法调用这些方法,并且可以像在构造函数中一样访问this
(即,设置readonly
和init
字段/属性)。 这可以与此类方法的init
子句结合使用。 如果子句中的成员在方法/构造函数的主体中被明确指定,则init
子句将被视为已满足要求。 调用包含成员的init
子句的方法,即视为对该成员赋值。 如果我们决定这是我们想要追求的路线,现在或将来,我们似乎不应该使用init
作为构造函数上的 init 子句的关键字,因为这会令人困惑。 - 允许
!
运算符显式抑制警告/错误。 如果以复杂方式(如在共享方法中)初始化成员,用户可以向 init 子句添加!
,以指示编译器不应检查初始化。
结论:讨论后,我们喜欢 !
运算符的想法。 它允许用户有意识地处理更复杂的情况,同时也不会在初始化方法周围留下巨大的设计漏洞,并将每个方法都注释为设置成员 X 或 Y。之所以选择 !
,是因为我们已经使用它来抑制可为 null 的警告,而在另一处使用它来告诉编译器“我比你聪明”,则是语法形式的自然延伸。
所需的接口成员
此建议不允许接口根据需要标记成员。 这使我们不必现在就在泛型中围绕 new()
和接口约束找出复杂的方案,并与工厂和泛型构造直接相关。 为了确保我们在该区域拥有设计空间,我们禁止在接口中使用 required
,并且禁止将带有 required_member_lists 的类型替换为受限于 new()
的类型参数。 当我们想更广泛地了解工厂的通用建筑方案时,我们可以重新审视此问题。
语法问题
init
子句功能未在 C# 11 中实现。 它仍然是一个积极的建议。
- "
init
是正确的词吗?"init
作为构造函数上的后缀修饰符,可能会干扰我们在工厂中重用它,同时妨碍启用带有前缀修饰符的init
方法。 其他可能性:set
required
是用于指定初始化所有成员的正确修饰符吗? 其他建议:default
all
- 带有一个 ! 来表示复杂逻辑
- 我们应该在
base
/this
和init
之间需要分隔符吗?-
:
分隔符 - ',' 分隔符
-
required
正确的修饰符吗? 建议的其他替代方法:req
require
mustinit
must
explicit
结论:我们暂时删除了 init
构造函数子句,并继续使用 required
作为属性修饰符。
Init 子句限制
init
子句功能未在 C# 11 中实现。 它仍然是一个积极的建议。
我们是否应该允许在 init 子句中访问 this
? 如果我们想让 init
中的赋值成为构造函数中成员赋值的简写,那么似乎就应该这样做。
此外,它是否创建新的范围(如 base()
),或者它是否与方法正文共享同一范围? 如果 init 表达式通过 out
参数引入一个变量,这一点对于局部函数(init 子句可能希望访问局部函数)或名称覆盖等情况尤为重要。
结论:init
子句已被移除。
辅助功能要求和 init
init
子句功能未在 C# 11 中实现。 它仍然是一个积极的建议。
在包含 init
子句的各个版本提案中,我们讨论过可能出现以下情况:
public class Base
{
protected required int _field;
protected Base() {} // Contract required that _field is set
}
public class Derived : Base
{
public Derived() : init(_field = 1) // Contract is fulfilled and _field is removed from the required members list
{
}
}
但是,我们此时已从提案中删除了 init
条款,因此我们需要决定是否以有限的方式允许此方案。 我们采用的选项包括:
- 禁止该方案。 这是最保守的方法,无障碍 中的规则目前是基于这一假设编写的。 规则是,任何必需的成员必须至少与其包含类型一样可见。
- 要求所有构造函数都是以下之一:
- 不比可见性最低的所需成员更可见。
- 将
SetsRequiredMembersAttribute
应用于构造函数。 这将确保任何可以看到构造函数的人,要么可以设置它导出的所有内容,要么没有什么可以设置。 这对于仅通过静态Create
方法或类似生成器创建的类型非常有用,但该实用工具看起来整体有限。
- 如之前在 LDM 中讨论的那样,将一种用于删除合同特定部分的方法重新添加到该提案中。
结论:选项 1,所有必需的成员必须至少与其包含的类型一样可见。
重写规则
目前的规范规定,required
关键字需要复制过来,重写可以使对成员的需求更大,而不是更小。 我们要做什么吗?
允许删除要求需要比我们目前建议的更多的合同修改能力。
结论:允许在重写中添加 required
。 如果被重写的成员是 required
,那么正在重写的成员也必须是 required
。
替代元数据表示形式
我们还可以借鉴扩展方法,采用不同的元数据表示方法。 我们可以在类型上放置一个 RequiredMemberAttribute
,以指示该类型包含所需成员,然后将 RequiredMemberAttribute
放在所需的每个成员上。 这将简化查找序列(无需执行成员查找,只需查找具有该属性的成员)。
结论:替代方案已获批准。
元数据表示形式
需要对“元数据表示形式”进行批准。 我们还需要确定这些属性是否应包含在 BCL 中。
- 对于
RequiredMemberAttribute
,此属性更类似于我们用于 nullable/nint/tuple 成员名称的通用嵌入属性,并且不会在 C# 中由用户手动应用。 但是,其他语言可能想要手动应用此属性。 - 另一方面,
SetsRequiredMembersAttribute
会被使用者直接使用,因此可能位于 BCL 中。
如果我们采用上一部分中的另一种表示方法,可能会改变 RequiredMemberAttribute
的计算方法:它不再类似于 nint
/nullable/tuple 成员名的一般嵌入属性,而是更接近于 System.Runtime.CompilerServices.ExtensionAttribute
,后者自扩展方法发布以来就一直存在于框架中。
结论:我们将这两个属性放在 BCL 中。
警告与错误
是否应将未设置所需成员视为警告或错误? 当然,可以通过 Activator.CreateInstance(typeof(C))
或类似方法欺骗系统,这意味着可能无法完全保证所有属性始终被设置。 我们还允许在构造函数站点使用 !
来抑制诊断,而我们通常不允许出现错误。 但是,该功能与只读字段或 init 属性类似,如果用户试图在初始化后设置此类成员,我们就会直接报错,但可以通过反射来规避。
结论:错误。