自动默认结构
注意
本文是特性规范。 该规范充当该功能的设计文档。 它包括建议的规范更改,以及功能设计和开发过程中所需的信息。 这些文章将发布,直到建议的规范更改最终确定并合并到当前的 ECMA 规范中。
功能规范与已完成的实现之间可能存在一些差异。 这些差异记录在相关的 语言设计会议(LDM)说明中。
可以在 规范一文中详细了解将功能规范采用 C# 语言标准的过程。
https://github.com/dotnet/csharplang/issues/5737
总结
此功能使结构构造函数能够识别在返回或使用之前用户未显式分配的字段,并将其隐式初始化为 default
,而不是产生明确的分配错误。
动机
为了解决在 dotnet/csharplang#5552 和 dotnet/csharplang#5635 中发现的可用性问题,并处理 #5563(所有字段必须明确分配,但在构造函数内无法访问 field
),此建议被提出作为一种可能的缓解措施。
自从 C# 1.0 起,结构体构造函数必须明确分配 this
,仿佛它是 out
参数。
public struct S
{
public int x, y;
public S() // error: Fields 'S.x' and 'S.y' must be fully assigned before control is returned to the caller
{
}
}
在半自动属性上手动定义 setter 时会产生问题,因为编译器无法将对属性的赋值视为等同于对支持字段的赋值。
public struct S
{
public int X { get => field; set => field = value; }
public S() // error: struct fields aren't fully assigned. But caller can only assign 'this.field' by assigning 'this'.
{
}
}
我们假设为 setter 引入更精细的限制(例如,setter 不采用 ref this
,而是将 out field
作为参数的方案)对于某些用例来说过于小众且不完整。
我们正在努力解决的一个基本问题是,当结构属性手动实现了 setter 时,用户通常必须进行某种形式的“重复”,重复分配或重复其逻辑:
struct S
{
private int _x;
public int X
{
get => _x;
set => _x = value >= 0 ? value : throw new ArgumentOutOfRangeException();
}
// Solution 1: assign some value in the constructor before "really" assigning through the property setter.
public S(int x)
{
_x = default;
X = x;
}
// Solution 2: assign the field once in the constructor, repeating the implementation of the setter.
public S(int x)
{
_x = x >= 0 ? x : throw new ArgumentOutOfRangeException();
}
}
先前的讨论
一个小小组已研究此问题,并考虑了一些可能的解决方案:
- 当半自动属性已手动实现 setter 时,要求用户分配
this = default
。 我们一致认为这是错误的解决方案,因为它会破坏字段初始值设定项中设置的值。 - 隐式初始化自动/半自动属性的所有支持字段。
- 这解决了“半自动属性设置器”问题,并且将显式声明的字段明确置于不同的规则下:“不要隐式初始化我的字段,而是要隐式初始化我的自动属性。”
- 提供一种分配半自动属性的支持字段的方法,并要求用户进行分配。
- 与(2)相比,这可能很麻烦。 自动属性应该是“自动”的,可能包括字段的“自动”初始化。 它可能会导致混淆,即什么时候通过对属性的赋值来分配基础字段,以及什么时候调用属性 setter。
我们还收到了来自用户的反馈,例如,他们希望在结构中包含一些字段初始值设定项,而不必显式分配所有内容。 我们可以同时解决这个问题以及“手动实现 setter 的半自动属性”问题。
struct MagnitudeVector3d
{
double X, Y, Z;
double Magnitude = 1;
public MagnitudeVector3d() // error: must assign 'X', 'Y', 'Z' before returning
{
}
}
调整明确赋值
我们不是执行明确赋值分析来为 this
上的未赋值字段提供错误,而是确定哪些字段需要隐式初始化。 此类初始化被插入到构造函数的开头。
struct S
{
int x, y;
// Example 1
public S()
{
// ok. Compiler inserts an assignment of `this = default`.
}
// Example 2
public S()
{
// ok. Compiler inserts an assignment of `y = default`.
x = 1;
}
// Example 3
public S()
{
// valid since C# 1.0. Compiler inserts no implicit assignments.
x = 1;
y = 2;
}
// Example 4
public S(bool b)
{
// ok. Compiler inserts assignment of `this = default`.
if (b)
x = 1;
else
y = 2;
}
// Example 5
void M() { }
public S(bool b)
{
// ok. Compiler inserts assignment of `y = default`.
x = 1;
if (b)
M();
y = 2;
}
}
在示例(4)和(5)中,生成的 codegen 有时具有字段的“双赋值”。 这通常很好,但是对于那些关心此类双重赋值的用户,我们可以将过去的明确赋值错误诊断发出为 disabled-by-default 警告诊断。
struct S
{
int x;
public S() // warning: 'S.x' is implicitly initialized to 'default'.
{
}
}
将此诊断的严重性设置为“错误”的用户将主动选择沿用 C# 11 之前的行为。 这些用户基本上被手动实现的 setter “排除”在半自动属性之外。
struct S
{
public int X
{
get => field;
set => field = field < value ? value : field;
}
public S() // error: backing field of 'S.X' is implicitly initialized to 'default'.
{
X = 1;
}
}
乍一看,这似乎是功能中的一个“漏洞”,但实际上这是正确的做法。 通过启用诊断,用户告诉我们,他们不希望编译器在构造函数中隐式初始化其字段。 这里无法避免隐式初始化,因此他们的解决方案是使用与手动实现的 setter 不同的初始化字段的方式,例如手动声明字段并分配它,或者通过包含字段初始值设定项。
目前,JIT 不会通过 refs 消除死存储,这意味着这些隐式初始化确实具有实际成本。 但这可能是可以修复的。 https://github.com/dotnet/runtime/issues/13727
值得注意的是,初始化单个字段而不是整个实例实际上只是一个优化。 编译器应该可以自由地实现它想要的任何启发式方法,只要它满足一个不变条件,即在所有返回点或在任何非字段成员访问 this
之前都没有明确分配的字段被隐式初始化。
例如,如果结构有 100 个字段,并且只显式初始化其中一个字段,则对整个事情执行 initobj
可能更有意义,而不是隐式地为 99 个其他字段发出 initobj
。 但是,对其他 99 个字段隐式发出 initobj
的实现仍然有效。
语言规范的更改
我们将调整标准中的以下部分:
https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12814-this-access
如果构造函数声明没有构造函数初始值设定项,则
this
变量的行为与结构类型的out
参数完全相同。 具体而言,这意味着变量应在实例构造函数的每个执行路径中明确分配。
我们对这段语言进行调整,使其更易于阅读。
如果构造函数声明没有构造函数初始值设定项,则 this
变量的行为类似于结构类型的 out
参数,但当未满足明确的赋值要求(§9.4.1)时,该变量不是错误。 而是引入以下行为:
- 当
this
变量本身不符合要求时,在构造函数中的任何其他代码运行之前,在初始化阶段,在所有违反要求的点,this
内的所有未分配的实例变量都隐式初始化为默认值 (§9.3)。 - 当
this
内的实例变量 v 不符合要求,或者 v 内任何级别的嵌套的任何实例变量不符合要求时,v 将隐式初始化为构造函数中任何其他代码运行前的默认值 初始化 阶段。